[add] calibration of the per-line categorisation logic - #32
Merged
Conversation
The pre-filter repairs the common OCR confusion "2" -> "z" before a lowercase letter, but the pattern also fired inside numbers, so measurements and inventory codes were silently rewritten: "0.25 cm" became "0.z5 cm" and "1932b" became "193zb". A line whose digits are corrupted this way scores as damaged text and is demoted, even though the OCR read it correctly. Anchors the repair with a negative lookbehind for digits, "." and "," so it only applies where a "2" really stands in for a letter (word-initial or after a letter), leaving numeric contexts untouched. Refs ufal#3
compute_valid_ratio() judged every whitespace token as a candidate "word",
so archival reference notation dragged the ratio down even when the OCR was
perfect. Dates, volume/page cites, initials, sigla, measurement units and
dotted abbreviations ("24.2.2020", "/1933/32", "I.L.", "s.o.", "Mzm.",
"t. III") are not judgeable as prose words: counting them as invalid made
correctly-read catalogue lines look like damaged text.
Introduces _is_neutral_token(): such tokens are excluded from BOTH the
numerator and the denominator instead of counting as failures. A single
dot-terminated letter stays evaluable unless it precedes a number or a Roman
numeral, so a reference ("t. III", "š. 12,5") is neutral while a stray
fragment before an ordinary word ("e. Hodomi") is not. When no evaluable
token remains the ratio is 1.0 — a line made entirely of reference notation
carries no evidence of bad reading.
Also adds _RE_SIGLUM for a whole-line dotted domain abbreviation, used by the
short-line gate that follows.
Refs ufal#3
On a 1-2 word line the language identifier has almost nothing to work with,
yet its low confidence was enough to hard-route the line to Trash. Archival
records are full of such lines — sigla, museum shorthand, units, figure and
table references — and they were being discarded even when read perfectly.
Adds a structural gate ahead of the score bands for word_count <= 2 that
decides on evidence of damage rather than on language confidence:
* a whole-line dotted siglum with at least two letters ("Mzm.", "M.z.m.")
is Clear outright;
* weird-word ratio >= 0.40, or high garbage density that is not explained by
trailing form-fill dots, means damage -> capped at Noisy;
* the gibberish and fused-word detectors count as damage only when the line
is NOT structurally clean. On their own they false-positive on ordinary
Czech: syllabic-r clusters ("vrstva") and vowel runs ("obou") were being
Trashed. This is the deliberate trade-off discussed in ufal#3 — see the PR
description for its measured cost;
* a line with no evaluable token left is Trash unless the forgiven-headline
floor applies, which is a floor and never demotes;
* otherwise structural validity decides.
The reference floor is also honoured in the rescue path, and figure/table
citations ("Obr.3", "Tab.208") plus colon-terminated single letters join the
neutral tokens.
Refs ufal#3
The score-based routes react to line averages, so a single badly-read token
("Ch. i6dn.283/54", "Dtntäuujij1", "voaovčín řcd") did not move them enough
to matter — the line came out Clear, which claims no correction is needed.
Adds count_damaged_tokens(), which looks for character-level OCR damage
inside a token rather than at line level: a stray symbol from the disallowed
set, an apostrophe between two letters, a digit wedged among lowercase
letters, or a vowel-less lowercase run (r/l excluded, Czech has syllabic
r and l).
It is applied as a cap, never as a push to Trash: a line with a damaged
token cannot be Clear and settles at Noisy, which is exactly what "needs
correction" means. Wired into both the 3+ word path (as rule_damaged_token)
and the short-line gate, where the Trash branch is left untouched — a
correctly read siglum, unit or reference still reaches Clear.
An alternating-bigram run also counts as damage in the gate, and a solitary
letter is routed to Trash while still honouring the forgiven floor.
Refs ufal#3
Catalogue, inventory and measurement lines are dense in digits, dots and
short abbreviations, so the garbage-density and fragmentation routes read
them as noise and sent them to Trash — discarding data that the OCR had
in fact captured correctly.
Adds is_clean_reference(): the line carries a reference marker (inv., kont.,
nál., obr., tab., neg., č. j., inv. č.) or a measurement with a multi-letter
unit, AND has zero damaged tokens. Both halves are required, so the lift only
applies where there is positive evidence of catalogue notation and no
evidence of bad reading.
Used as rule_reference_floor in check_rescues() and in the garbage-density
route. It is a floor: it can only raise Trash to Noisy and never demotes a
Clear line. Single-letter units (m, g, l) are deliberately excluded — they
collide with ordinary tokens ("3L", "11 g") and produced false lifts.
Refs ufal#3
Four additions from the calibration round, each measured on annotated lines
and then volume-checked against the full corpus before being kept:
* count_damaged_tokens() also treats "(c)", "(R)", "(TM)" and the Devanagari
danda as damage, and flags a case mix inside a token of 4+ characters
("dalSÍ", "zjiStěna"). Dot-terminated tokens are exempt so title and code
abbreviations survive ("PhDr.", "ZvK", "StAŮ").
* rule_fragment_tokens: an average token length below 2 characters means the
line is shredded, which rule_ledger_fragmentation does not reach (it needs
4+ words). Clean references are skipped outright, and a dot-terminated
abbreviation is measured with its dot, so "N. č. 16" averages 2.0 rather
than 1.3.
* rule_bigram_run: an alternating bigram repeated three times or more
("IDIDIDIDIDIDUOID") is scanner noise and goes to Trash. Previously it was
only consulted as damage in the short-line gate, which capped it at Noisy.
* a solitary letter is Trash unconditionally; the forgiven-headline escape is
removed, because a single letter is not a headline.
Characters deliberately NOT treated as damage after checking how they occur
in the corpus: "ä"/"Ä" (German toponymy), "ô ľ ŕ ĺ Ľ Ŕ" (Slovak), "¬" and
U+00AD (end-of-line hyphenation), "±" (measurement tolerance) and "•" (a form
glyph standing in for ".:" or a colon). Each of those would have demoted tens
of thousands of correctly read lines.
Refs ufal#3
CodeQL flags the class [A-Za-zÁ-Žá-ž] in _RE_DOTTED_ABBREV and _RE_ABBREV_NUM:
the ranges Á-Ž and á-ž overlap, and between them they also cover U+00D7 (×)
and U+00F7 (÷), which are not letters.
Replaced with the contiguous, non-overlapping [A-Za-zÀ-ÖØ-öø-ſ], which skips
exactly those two symbols. Narrowing the class to the Czech alphabet instead
was tried and rejected: it changes the verdict on 1 674 tokens that are
legitimate German and Slovak spellings in this corpus ("Nähe", "wäre", "ŕ",
"ľ", "ô"). The replacement above was checked over 8 683 757 tokens from 4 000
documents and matched the previous behaviour on every one of them.
Also applies ruff-format to the lines this branch touched so pre-commit
passes; that part is whitespace only and leaves the AST identical.
Refs ufal#3
david-spacil
marked this pull request as ready for review
July 26, 2026 15:49
Collaborator
|
will look into it tonight/tomorrow and maybe fix some of the errors from tests |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #3
Motivation
Calibration of the per-line categorisation, measured against manual annotation of Czech archaeological reports.
Two failure modes dominated the disagreements, and both punish text the OCR had actually read correctly:
Short lines were routed on language confidence. On a 1–2 word line the language identifier has almost nothing to work with, yet its low confidence decided the outcome. Archival records consist largely of such lines — sigla, museum shorthand, units, figure and table references — so correctly read catalogue data was being marked as damaged:
Mzm.and inventory codes likeA059/2020-107-008go toTrash, andObr.3,t. III,š. 12,5toNoisy, which asserts they need correcting when nothing is wrong with them.Reference notation was scored as if it were prose.
compute_valid_ratio()judged every token as a candidate word, so dates, cites, initials and abbreviations (24.2.2020,/1933/32,I.L.,s.o.) counted as invalid words and dragged clean catalogue lines down. On the random sample this is by far the dominant effect: 74 of the 95 disagreements are lines the shipped engine callsNoisyand this PR callsClear, and the annotator agreed withClearon 61 of them.Conversely, a single badly-read token in an otherwise clean sentence did not move the line averages enough to matter, so the line came out
Clear— asserting that no correction is needed. Both of these, from the random sample, areClearontestandNoisyhere, and the annotator called bothNoisy:The change makes short lines decide on evidence of damage rather than on language confidence, excludes unjudgeable reference tokens from the word ratio, and adds a character-level damage detector that caps a damaged line at
Noisywithout ever pushing it toTrash.Description of change
One file (
text_util.py, +309/−5), six commits, each independently reviewable:[fix]2→zOCR repair no longer fires inside numbers (0.25 cmbecame0.z5 cm,1932bbecame193zb)[edit][add]word_count <= 2, deciding on damage evidence instead of language confidence[add]count_damaged_tokens()— stray symbol, apostrophe between letters, digit inside a lowercase word, vowel-less run — applied as a cap atNoisy[add]is_clean_reference()reference floor: a catalogue/measurement line with zero damaged tokens can be liftedTrash→Noisy, never demoted[add]rule_fragment_tokens,rule_bigram_run, solitary letter →TrashDesign constraints kept throughout: damage only ever caps at
Noisy(it never routes toTrash), the reference floor only ever lifts, and every new rule is behind its existingDISABLED_RULESname so the ablation tooling keeps working.Characters deliberately rejected as damage after checking how they actually occur in the corpus:
ä/Ä(German toponymy, e.g."Sandäcker"— 27 561Clearlines carry it),•(bullet point, tens of thousands more),ô ľ ŕ ĺ Ľ Ŕ(Slovak),¬and U+00AD (end-of-line hyphenation), and±(measurement tolerance, 892 lines).Testing
Everything below was measured with
tools/recategorize_from_csv.py— not once at the end, but as the working instrument throughout this calibration. It re-scores storedDOC_LINE_CATEGrows through the realcompute_quality_score,categorize_lineandapply_document_postprocessing, so every figure here is the pipeline's own behaviour rather than a parallel re-implementation. The only things wrapped around it werepd.set_option("future.infer_string", False)for pandas 3 and splitting the input directory into symlinked shards so several processes could run at once; neither touches scoring. Refs #30.Three natural samples, drawn three different ways, annotated by two people. In every one of them the baseline is the current
testbranch recomputed today — never the categories stored in the dataset, which are a July snapshot and no longer match this branch:testUniform random sample — the primary estimate, and the only one that is representative of the corpus as a whole. 300 lines drawn uniformly from all 17 327 811 scored lines of the corpus (documents drawn with probability proportional to their line count, then a line uniformly within), annotated blind: the annotator saw only the text, never either engine's verdict. Sanity check on the draw: 83.3 % of the sample is
Clearunder this PR against 83.4 % in the corpus at large.Accuracy 61.3 % → 79.0 %, a paired difference of +17.7 pp, 95 % CI [11.6, 23.7], exact McNemar p = 5.2 × 10⁻⁹. The two engines disagree on 95 of the 300 lines; of those the annotator sided with this PR on 69 and with
teston 16 (10 went to neither). The label distribution is the clearest summary — annotatorClear246 /Noisy39 /Trash15, this PR 250 / 30 / 20, currenttest173 / 88 / 39: the shipped engine flags 127 of the 300 lines — more than two in five — as needing attention, where the annotator flags 54, about one in six.Independent validation. 13 documents of a different collection (CTX), annotated by a second annotator who never saw this work. Both engines were recomputed today from the same stored signals — the baseline column is the current
testbranch, not the categories that were on file when the annotation was made (those have since drifted, matching this branch on only 642 of 835 lines, which is why they are not used here).Scored on the lines the categoriser actually decides: 120 lines improved against 18 worsened, 6.7 : 1.
Counting all 814 annotated lines except the obsolete
Roughcategory gives 71.6 % → 84.2 %, but that basis is flattering to both engines: 160 of those lines areEmpty/Non-textfast-track rows that the re-scorer passes through untouched, so both columns are identical there by construction. The 654-line figure above is the honest one.Complete-document sample. 16 MTX documents annotated end to end, every line, no selection within them — 257 lines, of which 170 are
Clear/Noisy/Trash. On those: 32 lines improved against 1 worsened. Caveat: the 16 documents are a consecutive block from 1945, so this sample says nothing about later decades; the random sample above spans the whole 1945–2024 range.Full-corpus run. 47 677 documents / 40.5 M lines re-scored with
tools/recategorize_from_csv.py, 0 errors. Resulting distribution: Clear 14 460 142 / Noisy 1 662 072 / Trash 1 205 597.Primary annotation set. 1 993 lines / 1 196 documents of the MTX collection, annotated by hand. This set is deliberately enriched with the lines this change moves — most of it was sampled from lines whose category changed, so an absolute accuracy figure on it would be meaningless (the baseline is being judged precisely where it was known to differ). What it does measure is whether the moves are the right ones:
1 258 lines improved : 220 worsened, 5.7 : 1, on the hardest cases in the corpus.
What this trades away
The accuracy gain is not free, and the random sample shows the price. Treating "needs attention" as
NoisyorTrash— 54 of the 300 lines by the annotator's judgement:testRecall on problem lines drops from 72 % to 46 %. This PR misses 29 lines that deserve attention where
testmisses 15 — while raising precision from 31 % to 50 % and cutting false alarms from 88 to 25. Per class,ClearF1 goes 0.75 → 0.89,Noisy0.25 → 0.29, andTrashslips 0.37 → 0.34.Whether that is the right trade depends on what the categories are for, so it is your call rather than mine:
atrium-nlp-enrich), it is a clear win.testmarks only 64 % of genuinely clean lines asClear; this PR marks 90 %, at almost the same purity (precision 0.91 → 0.88). At corpus scale that is millions of usable lines currently withheld to buy three points of purity.test's 72 % at the cost of 88 false alarms per 300 lines is a defensible different choice — in which case this PR is the worse one and the damage detector should be tightened rather than the gate loosened.I looked at all 29 missed lines rather than assuming what they are, and they fall into four groups:
malta: - -,Současný s:,mírně -;indet,Kom.,Nov.,inv-číslo, which the neutral-token rule promotes on purpose;NINNNIC,KTU?A,AODP. FROJ EKTANI:,Jw.le.. The vowel-less and digit-in-word patterns incount_damaged_tokens()match lower-case runs only, so none of these reach them;uvláčeno I I; the solitary-letter route only fires on a one-word line.So this is not purely a lexical problem, and I do not want to overclaim in either direction. The one extension that was measured — letting the digit-in-word pattern see upper case, together with treating
=as damage — was rejected on volume. Measured together with a second candidate (treating=as damage), the two would newly hit roughly 190 000 lines across the corpus — 139 541 of the hits from the upper-case part alone — of which 85 901 are currentlyClear, and those are overwhelmingly legitimate inventory and locality codes (MTX20091565,MD01/001/2022-E-170-230, probe designations likeS6Y). Tens of thousands of correct lines is too much to pay for this.The rest I have deliberately not guessed at. Note that an upper-case vowel-less rule would not catch
NINNNICeither — it contains a vowel — so the fix for that group is not simply a case-insensitive regex. Candidates worth sizing properly are a repeated-letter run,?inside a token, and a label whose value is only dashes; none of them is measured yet, so none is in this PR.8 tests fail, and they fail by design — please arbitrate
pytest -m "not slow"ontestis fully green (495 passed). With this PR: 8 failed, 487 passed. Nothing else changes. I have deliberately not touched the tests.Four behavioural tests assert that short garbage reaches
Trash. What they actually get now:test_text_utils.py::TestShortGarbageRoute::test_short_gibberish_token_routed_to_trasholietest_smoke.py::TestFullPipelineSmoke::test_real_short_garbage_is_trash_per_lineolietest_rotation_regression.py::test_inverted_trash_stays_trash_at_default[oueussd…]oueussdtest_smoke.py::TestFullPipelineSmoke::test_garbage_and_mirror_is_trash_or_nontextpbqdnuwmoxszeyv!!These are the cost side of the same mechanism that produces the gains. The gains, from the random sample — the annotator called every one of these
Clear, and there are 63 of them in the 300 lines:Unbroken Czech prose and plain inventory codes, marked as needing correction by the shipped engine. The same loosening that lets those through also lets
olieandoueussdthrough: they are short, they carry no non-alphabetic damage, and the alpha heuristic incompute_valid_ratio()rates them structurally valid, exactly as it rates a genuine short Czech word. Telling them apart needs a lexical signal rather than another character rule — of the 68 over-lenient lines in the independent annotation, 63 (93 %) cannot be reached by any character-level rule at all, which is the #23 direction.Four parity tests in
tests/test_recategorize_parity.pycheck that the offline re-scorer still reproduces thecategstored indata_samples/DOC_LINE_CATEG/— three of them with a ≤ 2 % drift gate, the fourth with ≤ 5 % per document. They guard the faithfulness of the measurement tool, not the correctness of the categories.That corpus is 3 synthetic documents, 15 lines in total, so the smallest possible non-zero change is 1/15 = 6.7 %, three times the gate; and the document that flips has 4 lines, so that one line is 25 % against a 5 % gate. In practice these tests therefore assert no behaviour change at all, and any calibration fails them regardless of its merits.
This PR flips exactly one of the 15 lines:
That is flawless Czech: a correctly read report title with a citation number. The current code scores it
compute_valid_ratio() = 0.667, becauseč.,1/2024and—all count as invalid words; that is what leaves it at the quality score of 0.7999 on file and below theClearband. The neutral-token commit scores the same line 1.000 and it comes outClear— which is, I think, what any reader would call it.So the parity failures are not a regression: they are this change correcting a mislabel in your own fixture. Fixing them on your side is one value in one file.
Happy to do either, just say which: update the four behavioural tests to the new expectations (with the reasoning in their docstrings) and regenerate the 15 stored categories in the synthetic fixtures — or narrow the change until the current expectations hold, at the cost of the numbers above. That decision is the main thing I am asking for; the rest of the CI is green.
Provenance
To be explicit about how this was produced: the code, the comments and the measurements in this PR were written by Claude (Anthropic's Claude Opus), working from my data over several sessions. My own contribution is the ground truth and the judgement calls — I annotated the lines by hand, decided which candidate rules were acceptable and which were not, rejected several that looked good on paper, and validated every step against the corpus. Nothing here was accepted because the model proposed it: each candidate rule was first measured against the annotated lines and then scanned across the corpus to see how many lines it would actually move, and several were dropped at that second step precisely because the volume showed they would do more harm than good.
I am flagging this so you can weight the review accordingly.