Skip to content

fix(analyzer): keep Stanza multi-word tokens as surface tokens to preserve text and entity offsets - #2253

Open
ManoharPaturi wants to merge 5 commits into
data-privacy-stack:mainfrom
ManoharPaturi:fix/stanza-mwt-offsets
Open

ManoharPaturi wants to merge 5 commits into
data-privacy-stack:mainfrom
ManoharPaturi:fix/stanza-mwt-offsets

Conversation

@ManoharPaturi

Copy link
Copy Markdown

Fixes #2249.

StanzaTokenizer.__get_tokens_with_heads flattened stanza multi-word-token expansions (im -> in + dem), so token texts no longer matched the source text. _convert_doc then rebuilt the doc text from those tokens, which corrupted the document text (see the issue repro), made Stanza NER spans miss every token boundary so entities were silently dropped, and could raise ValueError from the lemma enhancer on indented text.

Multi-word tokens now stay a single surface token (a small wrapper carrying surface text/lemma plus the first expanded word's annotations), with a word-index to token-index map that remaps dependency heads across the collapsed token. Single-word tokens keep the previous code path byte-for-byte.

Verified with the real German model: doc.text is preserved, PER and IP_ADDRESS entities are detected where pristine drops them. 11 new tests (pure-unit head remap + German model tests over all 8 contractions) fail on main and pass here; existing stanza suites pass unchanged (12 + 13 + 16), ruff clean vs main baseline.

…serve text and entity offsets

Stanza's mwt processor expands German contractions (im/am/zum/zur/beim/
vom/ins/ans) into multiple words. StanzaTokenizer.__get_tokens_with_heads
flattened the expanded words, which broke alignment with the original
text: _convert_doc then replaced the doc text with space-separated
expanded tokens, silently dropped every NER entity (offsets no longer
mapped to tokens), and, on indented text, let tokens_indices end before
pattern matches near the end of the text, raising 'Did not find word ...'
in LemmaContextAwareEnhancer (HTTP 500).

Keep a multi-word token as a single surface token (MultiWordTokenSurface:
text/lemma are the surface form, other annotations come from the first
expanded word) and remap 1-based word heads to the collapsed token list
so dependency heads around the collapsed token stay correct.

Fixes data-privacy-stack#2249
Copilot AI lite review requested due to automatic review settings September 13, 2026 16:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@ArjunPakhan

ArjunPakhan commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Hi @ManoharPaturi 🚀

I pulled branch pr-2253 locally and verified the analyzer changes in stanza_nlp_engine.py.

Linting and formatting pass cleanly via ruff, and unit tests pass. Wrapping multi-word tokens as surface tokens while remapping dependency heads across collapsed tokens cleanly resolves the German MWT offset corruption (im, am, zum, zur, beim, vom, ins, ans) without introducing regressions to single-word token execution paths.

Great fix! +1 for maintainers to approve workflow execution and merge.

@ManoharPaturi

Copy link
Copy Markdown
Author

thanks a lot for pulling the branch and testing it yourself, glad it holds up across the contractions. appreciate the detailed writeup.

@omri374
omri374 requested a lite review from Copilot September 15, 2026 12:51
Comment thread FIX-SUMMARY.md Outdated
@@ -0,0 +1,137 @@
# Fix: StanzaNlpEngine German multi-word tokens replace the doc text and drop entities (#2249)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please remove this file

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Dependency-head remapping still uses an incorrect relative index.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment on lines +513 to +517
head = (
offset
+ word_index_to_token_index.get(token.head - 1, token_index)
- len(tokens)
)
@ManoharPaturi

Copy link
Copy Markdown
Author

@omri374 removed, thanks.

on the copilot point about the head remap using an incorrect relative index: i checked it against the real German model rather than the synthetic fixture, and the remap comes out correct on every token. for 'Wir treffen uns im Büro mit Thomas Bergmann.' stanza gives words (in, dem) as ids 4,5 both governed by id 6 (Büro), which collapses to token 3 governed by token 4:

token stanza gov expected rel our output
Wir (w1) w2 +1 +1
treffen (w2) root 0 0
uns (w3) w2 -1 -1
im (w4+w5) w6 +1 +1
Büro (w6) w2 -3 -3
mit (w7) w8 +1 +1
Thomas (w8) w2 -5 -5
Bergmann (w9) w8 -1 -1
. (w10) w2 -7 -7

the offset + word_index_to_token_index term is the governor's absolute doc index and len(tokens) is the current token's absolute index at that point, so the difference is the relative head. happy to add this sentence as a regression test if useful.

Copilot AI review requested due to automatic review settings September 15, 2026 15:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@ArjunPakhan

Copy link
Copy Markdown
Contributor

Hey @ManoharPaturi! 🚀

Glad the German model trace held up and disproved the Copilot bot finding. Since we worked through verifying the MWT offset logic and head remapping edge cases together, would you mind adding a co-author trailer to your final commit before merge?

You can just add this to the end of the commit message:

Co-authored-by: Arjun Sanjay Pakhan arjunpakhan@gmail.com

Appreciate it, and great job getting this fix put together!

@omri374 omri374 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

self.sentences = sentences


def test_get_tokens_with_heads_collapses_mwt_and_remaps_heads():

@omri374 omri374 Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add @pytest.mark.skip_engine("stanza_de"). We don't want to download a german stanza model on every unit test run

assert heads == [1, 0, 1, -2, 1, 0]


@pytest.mark.skip_engine("stanza_en")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be @pytest.mark.skip_engine("stanza_de")?

assert doc.text[token.idx : token.idx + len(token.text)] == token.text


@pytest.mark.skip_engine("stanza_en")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@pytest.mark.skip_engine("stanza_en")
@pytest.mark.skip_engine("stanza_de")

assert doc.ents[0].end_char == expected_start + len("Thomas Bergmann")


@pytest.mark.skip_engine("stanza_en")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@pytest.mark.skip_engine("stanza_en")
@pytest.mark.skip_engine("stanza_de")

@omri374 omri374 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! The only thing missing is proper handling of downloading Stanza models during unit tests. You'd might need to update the conftest here:
https://github.com/ManoharPaturi/presidio/blob/b71a4b4aeaa889a0f51eb2c7d0612fc1702b1619/presidio-analyzer/tests/conftest.py#L31

To add stanza_de as a preloaded NLP Engine

Copilot AI review requested due to automatic review settings September 16, 2026 10:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com>
Co-authored-by: Arjun Sanjay Pakhan <arjunpakhan@gmail.com>
@ManoharPaturi

Copy link
Copy Markdown
Author

done, trailer added to the final commit. thanks for the thorough verification pass, that kind of pull-the-branch testing is exactly what the fix needed.

Copilot AI review requested due to automatic review settings September 16, 2026 15:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…erman tests

Add stanza_de to nlp_engines fixture and provide stanza_de_nlp_engine.
Update German Stanza integration tests to gate on stanza_de engine and
use preloaded pipelines from nlp_engines when available.

Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 17, 2026 04:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@ManoharPaturi

Copy link
Copy Markdown
Author

@omri374 Updated! Added stanza_de to the nlp_engines fixture in conftest.py (along with a stanza_de_nlp_engine session fixture), updated stanza_pipeline_de and stanza_pipeline to use the preloaded engine when available, and updated the German tests to gate on @pytest.mark.skip_engine("stanza_de"). Pushed in b6834f6.

@ManoharPaturi

Copy link
Copy Markdown
Author

@omri374 following up on this one. the notes file is removed and i added the co-author trailer arjunpakhan asked for. his verification pass plus your main merge are on the thread. anything else needed before this can be merged?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

StanzaNlpEngine: German multi-word tokens (im/am/zum) replace the doc text and drop every NER entity; indented text ends in 500 "Did not find word"

4 participants