Skip to content

feat: close most of the measured NER recall gaps - #18

Merged
cristiangirlea merged 13 commits into
mainfrom
feat/ner-recall
Oct 1, 2026
Merged

cristiangirlea merged 13 commits into
mainfrom
feat/ner-recall

Conversation

@cristiangirlea

@cristiangirlea cristiangirlea commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Summary

PR #17 measured the redact task's NER pass. It found every entity in news-style sentences, about four in five in terse records, and about one in eight in informal lower-case messages. It also found a surname left visible after an initial. This PR closes most of those gaps, measured the same way.

How the changes were chosen. Tuning on the published sample would overfit its figures. So the rules were chosen on a separate, invented development set, tests/fixtures/ner-dev.json, with 31 sentences. Some of them hold no entity, and some are full of acronyms, so that false positives show. The published sample, tests/fixtures/ner-sample.json, stayed unseen until the final measurement. NER_SAMPLE points the measurement at either set.

What changed in the worker (worker/ai_bridge/tasks.py):

  • A second, title-cased reading. A sentence written all in lower case or all in capitals is read again from a title-cased copy that keeps every offset. So is a surname in capitals before a comma and a given name, as in WIERZBICKI, Tomasz, the records convention.
    • Sentence-case and mixed-case sentences are left alone. Title-casing "the board approved" made "Board" an organisation at 0.99.
    • Capitals elsewhere are left alone too. Title-cased, NATO lost its confidence and CEO became an organisation.
  • Pieces of one name are joined before the threshold. Pieces that touch, or meet at a hyphen, full stop or apostrophe, become one entity, scored by their most confident piece. So the initial of A. Okonkwo carries the surname the model was less sure of. My first version averaged the pieces instead. On the development set that dropped Jean-Luc Moreau below the threshold entirely, so it was changed.
  • The merge keeps the more confident of two readings. Two model candidates with the same extent now keep the higher score. The as-written reading of WIERZBICKI was a place at 0.71; the title-cased one, a person at 0.998. A rule still outranks the model.

What changed in the measurement (scripts/ner_measure.py):

  • A masked column. It reports the share of entities hidden under any label, which is what a redaction does.
  • One job per threshold again. PR feat: measure the NER pass's precision and recall on an invented sample #17 filtered a single low-threshold job locally. That stopped being equivalent once two readings meet in the worker's merge: a lower threshold lets weaker spans in, where they can displace stronger ones.

Results

On the published sample, measured by the model workflow on GitHub's runner. The "before" column is PR #17's CI run on the same runner type. Threshold 0.85, exact boundaries:

Recall before Recall now Masked now, any label
news 1.00 1.00 1.00
informal 0.13 0.47 0.63
records 0.78 0.92 0.97
PER 0.57 0.79 0.83

At 0.5, informal masking reaches 0.87 and records 1.00. On the development set at 0.85, recall rose from 0.18 to 0.64 in lower case, from 0.00 to 0.67 in all capitals and from 0.27 to 0.64 in records. Its sentences with no entity stayed clean.

What still leaks:

  • a lower-case name in a sentence that also holds a capital, such as Regards, olumide;
  • a handle, such as @nadia;
  • some lower-case first names side by side, such as ines and pedro.

A title-cased reading can also take a filler word into a name (lol tomasz) or call a person an organisation, but the name stays hidden. The deeper fix is a case-insensitive NER model, which would need its own pinning and measurement. The plan records that.

The ONNX redact description and the protocol now describe the second reading, and quote the masked figures. docs/testing.md publishes the table and says how the rules were chosen.

Review

A self-review at high effort produced 9 findings. Eight are fixed, tests first:

  • The second reading covered the whole text. Its spans in sentences it hadn't changed could replace the original's labels, and a long text with one lower-case line paid a full second pass. It now reads only the runs of rewritten sentences, with spans shifted back to the text's offsets. Both sets measure exactly as before, and it costs a window or two.
  • The docs, the protocol and the tool description quoted PR feat: measure the NER pass's precision and recall on an invented sample #17's figures. They are refreshed from this PR's CI table. The protocol now says a joined name carries its most confident piece's score, and gives the new merge order.
  • The measurement treated HTTP 429 as fatal. That happened to me while tuning. It now waits up to 400 s, since the worker holds finished jobs for 300 s.
  • No test confined the second reading to its sentences. One does now.
  • Both sets were written by the same author. The docs say so, and say the figures describe these sentences, not informal text in general.
  • The casing rules work around a cased-only model. The docs and the plan now name a case-insensitive model as the deeper fix.

Skipped: the surname-first rule landed in 180ac82, whose message mentions only the merge change. The history is already pushed, so I recorded this in the next commit's message instead of rewriting it.

Verification

The tests were written first and observed failing at every step: the development-set setting, the second reading, the joining, the joining rule's change from mean to best piece, the surname rule, the merge tie-break, the per-threshold jobs, the masked column, the confinement and the capacity wait.

  • python-tests: 109 passed, up from 91.
  • mcp-tests: 119 passed with the new description.
  • php-tests and php-integration are unaffected; this changes only the ONNX path.
  • The model workflow passed on the measured head, run 36862817554, and its table is the one published. It passed again on the final head 6900249, run 36863750957, after the docs and the tool description changed.

… require a setting to measure it

tests/fixtures/ner-dev.json holds 31 invented sentences for tuning the NER pass: lower case, sentence
case, all capitals, records, acronyms, news and mixed case, some with no entity so that false positives
show. It shares no sentence with ner-sample.json, whose figures stay the published ones, and the
measurement must take NER_SAMPLE to run on it.
…d one entity for touching name pieces

A sentence with no capital letter, or written all in capitals, must be read again in title case from a
copy that keeps every offset, with sentence-case and cased text left alone, since title-casing "the
board approved" made "Board" an organisation. Pieces of one name that touch, or meet at a hyphen, full
stop or apostrophe, must be joined before the threshold, so the initial of "A. Okonkwo" carries the
surname the model was less sure of. The aggregation takes the text to see what lies between pieces, and
a stub model drives the two passes without numpy.
…nsure piece cannot hide a sure one

Measured on the development set, averaging the joined pieces dropped "Jean-Luc Moreau" and "M. A.
Delacroix" below the threshold, so nothing of either was masked where a confident first piece had been
before. A joined name must pass when any of its pieces does, with that piece's score.
… of one name before the threshold

The NER model was trained on cased news and found almost no lower-case name. A sentence written all in
lower case or all in capitals is now read a second time from a title-cased copy that keeps every offset;
sentence-case and mixed-case sentences are left alone, because title-casing "the board approved" made
"Board" an organisation. The text's own reading comes first, so it wins where both find a span.

Pieces of one name that touch or meet at a hyphen, full stop or apostrophe are joined before the
threshold and scored by their most confident piece, so the initial of "A. Okonkwo" carries the surname
the model was less sure of, and an unsure piece never hides a confident one. The numpy step is now
_probabilities, which the tests replace.

On the development set at 0.85, exact recall rose from 0.18 to 0.64 in lower case, 0.00 to 0.33 in all
capitals and 0.27 to 0.45 in records, with no new false positive in its sentences without entities.
For redaction the question is whether a name is hidden, and a name masked as an organisation is still
hidden. The scorer must count, one to one, the gold spans overlapped by a model span of any label, and
the per-label rows must keep counting only their own label.
…asure each threshold with its own job

The text read as written called "WIERZBICKI" a place at 0.71 and its title-cased copy called it a person
at 0.998, and the merge kept whichever came first. Of two model candidates with the same extent the more
confident must win, while a rule still outranks the model. A low threshold lets weaker spans into that
merge, where they can displace stronger ones, so the measurement must ask for each threshold in its own
job instead of filtering one low-threshold job.
…sure each threshold with its own job

The merge now orders candidates of the same extent and source by score, so the title-cased reading of
"WIERZBICKI, Tomasz" as a person at 0.998 replaces the as-written reading as a place at 0.71; a rule
still outranks the model. The measurement asks for each threshold in its own job again, since a lower
one lets weaker spans into the merge, where they can displace stronger ones. On the development set at
0.85, exact recall rose to 0.67 in all capitals and 0.64 in records, with no new false positive.
…ences, and the measurement to wait out 429

A cased sentence must be read once and the second reading must cover only the sentences it rewrote, so
its spans cannot relabel a sentence that needed no help and a long text with one lower-case line costs
one more window, not a second pass over everything. The measurement must wait out a full worker, which
two thresholds and a development-set run inside five minutes reach, and stop cleanly if it stays full.
…ment wait out a full worker

The second reading now covers each run of rewritten sentences alone, its spans shifted back to the
text's offsets, so it cannot relabel a sentence that needed no help and a long text with one lower-case
line costs a window or two rather than a second pass over everything. Both sets measure exactly as
before. This commit's parent, 180ac82, also added the surname-first rule, which its message omits. The
measurement waits out HTTP 429 for up to 400 s, since the worker holds finished jobs 300 s by default.
…t where clients and agents read

The testing notes publish the model workflow's table for this pull request, run 36862817554 at
2d14a59: exact recall at 0.85 rose from 0.13 to 0.47 in informal messages and from 0.78 to 0.92 in
records, news stayed at 1.00, and 0.63 and 0.97 of entities are masked. They say how the rules were
chosen on a separate development set, what still leaks, and that a case-insensitive model is the
deeper fix. The protocol describes the second reading, the joined score and the merge order, and the
ONNX redact description now says lower-case and all-capitals sentences are read again.
@cristiangirlea
cristiangirlea merged commit 85b70e3 into main Oct 1, 2026
13 checks passed
@cristiangirlea
cristiangirlea deleted the feat/ner-recall branch October 1, 2026 12:52
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