Skip to content

fix(ml): detect PII past the model window and stop inflating confidence - #58

Open
mazzasaverio wants to merge 2 commits into
ma2za:mainfrom
mazzasaverio:fix/ml-window-and-confidence
Open

fix(ml): detect PII past the model window and stop inflating confidence#58
mazzasaverio wants to merge 2 commits into
ma2za:mainfrom
mazzasaverio:fix/ml-window-and-confidence

Conversation

@mazzasaverio

Copy link
Copy Markdown
Contributor

Stacked on #57, which it contains. Review or merge that one first; the diff against it is src/pseudonymize/backends/ml/onnx.py and its tests only.

Two defects in LocalONNXPIIBackend. Both make the backend report something other than what it actually found, and the first one silently leaves personal data in the output.

Personal data past the model window was silently dropped

The shipped tokenizer.json configures truncation at 512 tokens:

>>> tokenizer.truncation
{'max_length': 512, 'stride': 0, 'strategy': 'longest_first', 'direction': 'right'}

so tokenizer.encode quietly discarded every token past it. Measured on the cached DistilBERT with "Contact Maria Rossi in Milan." at the end of a growing block:

Preceding text Detections on main
none Maria Rossi PERSON, Milan LOCATION
~100 words Maria Rossi, Milan
~300 words Rossi only
~600 words none

No exception, no warning, no statistic. The caller receives a document that looks sanitized. Roughly two kilobytes is one page of a PDF, or one long email.

Truncation is now turned off and the block is split into overlapping token windows sized from the tokenizer's own limit, falling back to the config's max_position_embeddings and then to 512. Detections are shifted back to absolute offsets, and a span found in two overlapping windows collapses to its higher-confidence copy. The same entity is now found at 36 KB:

Block size After
484 B Maria Rossi 0.998, Milan 0.959
3.6 KB Maria Rossi 0.997, Milan 0.971
11 KB Maria Rossi 0.994, Milan 0.952
36 KB Maria Rossi 0.995, Milan 0.913

The reported confidence was manufactured

dynamic_boost_threshold = policy.minimum_confidence * 0.01
...
calibrated_conf = policy.minimum_confidence + (raw_conf - dynamic_boost_threshold) / (
    1.0 - dynamic_boost_threshold
) * (1.0 - policy.minimum_confidence)

A runner-up label beat O at one hundredth of the policy floor, and every surviving span was then rescaled from that point onto the floor. Two consequences:

  • minimum_confidence could not filter anything. Clearing the boost guaranteed clearing the floor, by construction.
  • It was inverted. A stricter floor produced a lower boost threshold, so asking for more confidence returned more detections.

The same weak prediction, on main:

min_conf 0.5  -> ('Apollo', PERSON, 0.5055)
min_conf 0.99 -> ('Apollo', PERSON, 0.9901)

The model's actual probability for that span is 0.016.

Confidence is now the model's own probability, so raising minimum_confidence can only ever remove detections. Recall is tuned by a separate explicit entity_threshold (default 0.5): the absolute probability a runner-up label must reach to beat O. window_overlap_tokens is exposed alongside it, and both are validated.

For your call

Honest probabilities cost recall at the default 0.8 floor, which is calibrated for deterministic detectors emitting 0.9–1.0 rather than for a probabilistic model. One existing test needed its policy lowered to 0.5 for the second of two John Smith mentions (true probability 0.717) to be detected.

The ai4privacy F1 will fall. That is the previously reported number being corrected rather than a regression: 0.10.0's "calibration" gain came from this rescaling. Whether the ML backend deserves a different default floor from the rules backend is a real question, and worth deciding on its own rather than inside this fix.

Verification

Six new tests: windowing at three block sizes, window coverage and overlap, confidence monotonicity under two floors, threshold-controlled recall, and argument validation.

256 passed, 1 skipped
coverage 95.46%   (main measures 95.31% locally, below its own 95.36% gate)
ruff, mypy        clean

🤖 Generated with Claude Code

https://claude.ai/code/session_012JiX3zWeEC28kmy5KmAXvf

mazzasaverio and others added 2 commits August 30, 2026 07:28
Three release-gate defects, all reproducible on main.

The ONNX backend interpolated the originating exception message into
BackendExecutionError. The release gate requires that exceptions do not
expose matched values, and PR ma2za#40 established the same invariant for
adapters; tokenizer and runtime messages can quote the input they failed
on. The message is now fixed text and the cause is dropped, matching how
every other adapter and backend in the tree raises.

Detection also scanned every character with unicodedata.category on every
block, building two N-sized lists even when nothing was stripped. No ASCII
character has category Cf, so pure ASCII text now takes a single C-level
scan and keeps the source offsets untouched, and non-ASCII text only pays
for the rebuild when a format character is actually present. This is 13%
of end-to-end processing time on a 64 KiB block.

Finally, `uv run ruff format --check .` fails on main: the 0.16.0 OCR
commit landed unformatted, so CI has been red across 0.16.0 and 0.17.0
even though both shipped. The two files are reformatted here so the gate
passes again. uv.lock still recorded 0.16.0 and is refreshed to 0.17.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JiX3zWeEC28kmy5KmAXvf
Two defects in LocalONNXPIIBackend, both of which make the backend report
something other than what it found.

Personal data past the model's position budget was silently ignored. The
shipped tokenizer.json configures truncation at 512 tokens, so
`tokenizer.encode` quietly dropped every token after it: on the cached
DistilBERT, "Contact Maria Rossi in Milan." was found on its own and after
100 words of context, and returned nothing once roughly 2 KB of text
preceded it. No error, no warning, and the caller gets a document that
looks sanitized. That is one page of a PDF or one long email.

Truncation is now turned off and the block is split into overlapping token
windows sized from the tokenizer's own limit (falling back to the config's
max_position_embeddings, then 512). Detections are shifted back to absolute
offsets and identical spans found in two overlapping windows collapse to the
higher-confidence copy. The same text is now detected at 36 KB.

The reported confidence was also manufactured. The runner-up label beat "O"
whenever it reached `minimum_confidence * 0.01`, and every surviving span was
then rescaled from that point onto the policy floor. The consequences: the
policy knob could not filter anything, since clearing the boost guaranteed
clearing the floor; and it was inverted, because a stricter floor produced a
lower boost threshold and therefore more detections. A prediction the model
gave 1.6% probability was reported at 0.99 confidence under a 0.99 floor.

Confidence is now the model's own probability, so raising minimum_confidence
can only ever remove detections. Recall is tuned by a separate, explicit
`entity_threshold` (default 0.5): the absolute probability a runner-up label
must reach to beat "O". `window_overlap_tokens` is exposed alongside it.

Note for review: honest probabilities cost recall at the default 0.8 floor,
which is calibrated for deterministic detectors emitting 0.9-1.0 rather than
for a probabilistic model. The ai4privacy benchmark number will fall. That is
the previously reported number being corrected rather than a regression, and
whether the ML default floor should differ from the rules floor is a separate
decision worth taking on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JiX3zWeEC28kmy5KmAXvf
@mazzasaverio
mazzasaverio force-pushed the fix/ml-window-and-confidence branch from 6e9c788 to 4328880 Compare August 30, 2026 05:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant