Skip to content

fix(benchmarks): score precision and recall consistently, and realign the docs - #60

Open
mazzasaverio wants to merge 2 commits into
ma2za:mainfrom
mazzasaverio:fix/benchmark-metrics-and-docs
Open

fix(benchmarks): score precision and recall consistently, and realign the docs#60
mazzasaverio wants to merge 2 commits into
ma2za:mainfrom
mazzasaverio:fix/benchmark-metrics-and-docs

Conversation

@mazzasaverio

Copy link
Copy Markdown
Contributor

Stacked on #57, which it contains. The diff against it is benchmarks/, pyproject.toml and the docs.

The release gate from 0.8.0 onwards is a number produced by benchmarks/evaluate_quality.py. That number was not precision or recall.

The scoring was inconsistent on both sides of the ratio

for d_start, d_end in detected_spans:
    matched = False
    for i, (g_start, g_end, _g_label) in enumerate(ground_truth_spans):
        if max(d_start, g_start) < min(d_end, g_end):
            matched = True
            matched_gt.add(i)          # <- marks every overlapping annotation
    if matched:
        true_positives += 1            # <- but credits only one

Three problems:

  1. Mismatched units. True positives are counted per detection; false negatives per annotation. One detection covering two adjacent annotations — "John Smith" against separate GIVENNAME and SURNAME masks, which this corpus produces constantly — scores one true positive while removing two from the recall denominator. Recall is inflated by construction.
  2. Labels ignored. _g_label is discarded. A detection agreeing on position but not on entity type is a hit, so finding an email where the corpus annotated a surname scores.
  3. Correct work punished. Annotations outside SUPPORTED_LABELS are dropped from the truth set, so a correct detection of one becomes a false positive. That rewards suppressing valid detections.

Detections and annotations are now paired one to one, largest overlap first, and a pair must agree on entity type via an explicit corpus-label-to-EntityType map. --span-only restores the label-blind scoring for comparison. Out-of-scope hits are reported on their own line instead of being charged as errors.

What the released code actually scores

Same 0.17.0 code, unchanged, 300 validation rows, ONNX backend enabled:

Scoring Precision Recall F1
As reported in the README 0.9475 0.8910 0.9184
Corrected, --span-only 0.9316 0.7529 0.8328
Corrected, entity types compared 0.8094 0.6536 0.7232

The gap is measurement, not behaviour. The 90% threshold that 0.13.0 declared cleared, and that 0.14.0 onwards were gated against, was never met under scoring that counts one unit on both sides and checks that a detection agrees with what it is credited for.

I have not touched the gate itself in this PR — where the bar belongs now that the numbers mean something is your call, and it interacts with #58 and #59, which both move detection behaviour.

Also here

  • train_eval.py was a copy of the evaluation script pointed at the train split. The integrity notice asks contributors to develop on train and measure on validation, which only works if both are scored identically; they were not. It is now a thin wrapper over the same code, with per-error detail.
  • benchmarks/datasets/ shadowed the datasets package under type checking, renamed to benchmarks/data/.
  • benchmarks was never type-checked. files = ["src", "tests", "scripts"] omits it, while the release gate requires "strict mypy passes for source, tests, benchmarks, and scripts". It is added, and strict mypy now passes across it (it did not before: 4 errors).
  • Roadmap drift. The current-position table ended at 0.7.0 | Next with 0.17.0 released; it now lists through 0.17.0. It also claimed a 99.36% coverage floor where pyproject.toml enforces 95.36%.
  • 0.17.0 shipped with no changelog entry; one is written from its two commits.
  • Three broken escapes rendered as literal backslashes: \ContextualIdDetector\ and \i4privacy/...\ (also missing its leading a).
249 passed, 1 skipped
ruff format, ruff check, 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
… the docs

The release gate for 0.8.0 onwards is a number produced by
benchmarks/evaluate_quality.py. That number was not precision or recall.

True positives were counted per detection while false negatives were counted
per annotation, so the two sides of the ratio measured different things: one
detection overlapping two adjacent annotations scored a single true positive
yet satisfied both, removing two from the recall denominator. Matching also
ignored labels entirely, so a detection agreeing with an annotation's position
but not its type counted as a hit. And a correct detection of an entity whose
label sits outside SUPPORTED_LABELS was charged as a false positive, which
rewards suppressing valid detections.

Detections and annotations are now paired one to one, largest overlap first, and
a pair must agree on entity type. --span-only restores the label-blind scoring
for comparison. Detections landing on an out-of-scope annotation are reported
separately rather than penalised.

Rescoring the released 0.17.0 code, unchanged, over 300 validation rows with the
ONNX backend:

    reported          P 0.9475   R 0.8910   F1 0.9184
    corrected, spans  P 0.9316   R 0.7529   F1 0.8328
    corrected, typed  P 0.8094   R 0.6536   F1 0.7232

The 90% threshold that 0.14.0 onwards were gated against was never cleared under
scoring that counts one unit on both sides.

Also in this change:

- train_eval.py was a copy of the evaluation script pointed at the train split,
  so an improvement measured during development did not necessarily mean the
  same thing at evaluation. It now calls the same scoring code, with per-error
  detail behind an explain flag.
- benchmarks/datasets/ shadowed the datasets package under type checking and is
  renamed to benchmarks/data/. benchmarks is added to mypy's files, which the
  release gate already required but the configuration omitted, and strict mypy
  now passes across it.
- The roadmap's current-position table stopped at "0.7.0 Next" while 0.17.0 was
  released, and claimed a 99.36% coverage floor where pyproject enforces 95.36%.
- 0.17.0 shipped with no changelog entry; one is written from its commits.
- Three broken escapes rendered as literal backslashes in README and CHANGELOG.

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/benchmark-metrics-and-docs branch from 792250d to b97e8ac Compare August 30, 2026 05:33
@mazzasaverio

Copy link
Copy Markdown
Contributor Author

Rebased onto main at 0.19.0 and rescored, so the comparison is against current code rather than 0.17.0.

Version Scoring P R F1
0.17.0 as reported 0.9475 0.8910 0.9184
0.17.0 corrected, typed 0.8094 0.6536 0.7232
0.19.0 as reported 0.9479 0.8982 0.9224
0.19.0 corrected, typed 0.8097 0.6549 0.7241

Under corrected scoring, 0.18.0 and 0.19.0 together moved F1 by 0.0009: two more true positives out of 1611 annotations, which is inside run-to-run noise. The previously reported figures put the same interval at 0.9184 → 0.9224 and read it as a release-worthy gain.

Two observations that follow from the diff rather than from the numbers, offered as data for whoever reviews this:

  1. 970f0f1 is titled "feat(ml): implement dynamic threshold calibration per entity type", but its diff is only the version bump in pyproject.toml. Between v0.17.0 and origin/main the sole source change in the whole repository is four lines in detectors/context.py. If the calibration work exists, it did not reach the branch.
  2. CI on main is still failing the quality job for 2 files would be reformatted, now across four releases. fix: sanitize ONNX backend errors and restore the green formatting gate #57 fixes it; every PR here inherits the red gate until then.

Neither point changes this PR's content. They are the reason it exists: when the metric cannot distinguish a change from noise, a release can be attributed to code that is not there and nothing catches it.

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