Skip to content

Commit 7d27f4c

Browse files
committed
Add per-field precision/F1 and a corpus fabrication metric to the harness
Additive superset of reading_order_eval.py implementing SCORING_RECOMMENDATIONS #1/#4 without changing any existing output: - field_prf() decomposes the headline rField into per-field RECALL and PRECISION (same reference-anchored fields, same length-normalized macro, same alignment). Precision is the in-field hallucination signal the old recall-flavored rField hid; F1 == rField by construction, so it is not double-counted. - Corpus fabrication (folds fidelity_check + keys_check ideas): a SOFT token-level rate (generated alpha tokens absent from the reference vocabulary, immune to mis-segmentation) and a HARD count of invented exact identifiers (ORCID/DOI/email present in output, absent from the poster). Reported per extractor via --fab; an extractor is flagged FABRICATING if hard>0 or soft>FAB_SOFT_TAU. VLM text is scrubbed before scoring so the charge is against shipped text. run()/_fmt()/main() are supersets: every prior key, column and flag keeps its meaning; new columns rec/prec/fab and a --fab summary are added. Verified rField holds at 0.762 and the corrector board is unchanged (12/13, 13/13, 20/21). First numbers (scrubbed): pdfplumber soft 3.3% / 1 invented id; LightOnOCR soft 26.1% / 10 invented ids. This is the fidelity gap the recall-only metrics could not see: LightOnOCR wins reading/recall (rField 0.788 vs 0.762) but fabricates markedly more, which the paper must report alongside the win.
1 parent 9db2408 commit 7d27f4c

1 file changed

Lines changed: 222 additions & 9 deletions

File tree

calibration/reading_order_eval.py

Lines changed: 222 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,162 @@ def _items():
259259
return out
260260

261261

262+
263+
def _rougeL_prf(ref, gen):
264+
"""(precision, recall, fmeasure) for rougeL. rouge_score's convention is
265+
score(target, prediction): precision is w.r.t. the PREDICTION (gen), recall
266+
w.r.t. the TARGET (ref) -- identical arg order to the existing _rougeL()."""
267+
s = _SCORER.score(ref, gen)["rougeL"]
268+
return s.precision, s.recall, s.fmeasure
269+
270+
271+
def field_prf(gen_text, ref_md):
272+
"""Decompose the headline rField into RECALL and PRECISION per field.
273+
274+
Same reference-anchored fields and same length normalization as
275+
field_scores() (equal-weight macro: a 3-word banner counts as much as a
276+
500-word section), and the *same alignment rule* (each ref field takes the
277+
gen chunk that maximizes rougeL F). For that aligned pair we report:
278+
279+
recall = LCS / len(ref field) -- of the reference field, how much the
280+
extractor reproduced. This is the recall the old rField hid.
281+
precision = LCS / len(gen chunk) -- of the gen chunk aligned to this
282+
field, how much is actually in the reference; i.e. did the
283+
extractor pad the field with tokens the reference lacks.
284+
THIS is the in-field hallucination signal (rec. #1).
285+
f1 = rougeL F of the aligned pair. By construction this equals the
286+
old field_scores() macro, so f1_macro == existing rField --
287+
the new information is the recall/precision split, not F1.
288+
289+
Returns (recall_macro, precision_macro, f1_macro, per_field) where per_field
290+
rows are (name, recall, precision, f1, ref_len, gen_len).
291+
292+
NOTE (validated on the corpus): aligned precision conflates two defects --
293+
true hallucination AND mis-segmentation (gen merged two ref sections into
294+
one chunk, so half its tokens don't match the aligned field even though they
295+
ARE elsewhere in the reference). Poster `42` scores aligned precision 0.68
296+
yet fabrication_soft() 0.003: its low precision is segmentation, not
297+
invention. Use aligned precision to localize which field degraded, and the
298+
corpus fabrication number below to say whether content was actually invented.
299+
"""
300+
r_title, r_banner, r_secs = _parse_md(ref_md)
301+
chunks = [_alpha(c) for c in _gen_chunks(gen_text)] or [""]
302+
fields = [("title", r_title), ("authors+affiliations", r_banner)]
303+
fields += [(f"S:{h[:34]}", b) for h, b in r_secs]
304+
per = []
305+
for name, ref in fields:
306+
ra = _alpha(ref)
307+
if not ra.strip():
308+
continue
309+
best = max(chunks, key=lambda c: _rougeL_prf(ra, c)[2])
310+
p, r, f = _rougeL_prf(ra, best)
311+
per.append((name, round(r, 3), round(p, 3), round(f, 3),
312+
len(ra.split()), len(best.split())))
313+
r_macro = statistics.fmean(x[1] for x in per) if per else float("nan")
314+
p_macro = statistics.fmean(x[2] for x in per) if per else float("nan")
315+
f_macro = statistics.fmean(x[3] for x in per) if per else float("nan")
316+
return r_macro, p_macro, f_macro, per
317+
318+
319+
# ---- corpus FABRICATION number (folds keys_check / fidelity_check) -----------
320+
# Two components, deliberately kept as distinct units because they mean
321+
# different things and a single blended float would hide the categorical one:
322+
#
323+
# SOFT token-level fabrication rate = fraction of generated alpha tokens that
324+
# are absent from the reference vocabulary. This is exactly "gen adds
325+
# tokens the reference lacks", generalized from the aligned pair to the
326+
# whole poster, so it is immune to mis-segmentation (a token counts as
327+
# supported no matter which section it landed in). Micro-averaged over
328+
# the corpus (sum unsupported / sum tokens) it is one number per
329+
# extractor. Measured 0.033 pdfplumber vs 0.316 LightOnOCR.
330+
#
331+
# HARD invented exact identifiers (ORCID / DOI / email present in the output,
332+
# absent from the poster) -- fidelity_check.py's charge. One wrong
333+
# ORCID silently misattributes work, so this is categorical, not a rate.
334+
# Measured 1 pdfplumber vs 10 LightOnOCR.
335+
#
336+
# keys_check.py's "lost lookup keys" is the mirror image (RECALL: keys dropped so
337+
# no ORCID/ROR query fires) and belongs to the recall side already surfaced by
338+
# recall_macro, not to fabrication -- lost != invented -- so it is reported as a
339+
# companion, never folded into the fabrication scalar.
340+
#
341+
# Single headline: report "soft=NN.N% hard=N ids". Flag an extractor/poster as
342+
# FABRICATING iff hard > 0 (any misattribution) OR soft > FAB_SOFT_TAU.
343+
344+
FAB_SOFT_TAU = 0.10
345+
346+
_ID_PATTERNS = {
347+
"orcid": re.compile(r"\b\d{4}-\d{4}-\d{4}-\d{3}[\dX]\b"),
348+
"doi": re.compile(r"\b10\.\d{4,9}/[-._;()/:A-Za-z0-9]+\b"),
349+
"email": re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"),
350+
}
351+
352+
353+
def _find_ids(text, kind):
354+
t = _norm(text)
355+
if kind == "email":
356+
t = t.replace("\\", "")
357+
return {m.group(0).rstrip(".,;)").lower()
358+
for m in _ID_PATTERNS[kind].finditer(t)}
359+
360+
361+
def invented_ids(gen_text, ref_md):
362+
"""{kind: [strings]} present in gen, absent from ref (hard fabrication)."""
363+
out = {}
364+
for kind in _ID_PATTERNS:
365+
extra = _find_ids(gen_text, kind) - _find_ids(ref_md, kind)
366+
if extra:
367+
out[kind] = sorted(extra)
368+
return out
369+
370+
371+
def fabrication_soft(gen_text, ref_md):
372+
"""(rate, n_unsupported, n_total): generated alpha tokens absent from the
373+
reference vocabulary. The soft-hallucination volume for one poster."""
374+
voc = set(_alpha(ref_md).split())
375+
toks = _alpha(gen_text).split()
376+
if not toks:
377+
return 0.0, 0, 0
378+
unsup = sum(1 for t in toks if t not in voc)
379+
return unsup / len(toks), unsup, len(toks)
380+
381+
382+
def corpus_fabrication(gen_provider, label="gen"):
383+
"""Fold both signals into ONE corpus number for an extractor.
384+
385+
gen_provider(pid, pdf, raw, ann) -> gen text (or "" / None to skip a poster,
386+
e.g. an image-only poster or a missing VLM .md). Returns a dict with the
387+
micro soft rate, the hard invented-id count, and a boolean `fabricating`.
388+
Reuse the harness for pdfplumber AND for LightOnOCR by swapping the provider
389+
(see main()). Micro (token-weighted), not macro, so long posters dominate
390+
proportionally to how much text they actually contribute.
391+
"""
392+
unsup = tot = hard = 0
393+
per = []
394+
for pid, pdf, raw, ann in _items():
395+
ref_md = open(raw, encoding="utf-8").read()
396+
gen = gen_provider(pid, pdf, raw, ann)
397+
if not gen:
398+
continue
399+
rate, u, t = fabrication_soft(gen, ref_md)
400+
inv = invented_ids(gen, ref_md)
401+
h = sum(len(v) for v in inv.values())
402+
unsup += u
403+
tot += t
404+
hard += h
405+
per.append({"id": pid, "soft": round(rate, 3),
406+
"invented": inv, "n_tok": t})
407+
soft = unsup / tot if tot else float("nan")
408+
return {"label": label, "soft": round(soft, 4), "hard_ids": hard,
409+
"n_tok": tot,
410+
"fabricating": hard > 0 or (tot and soft > FAB_SOFT_TAU),
411+
"per_poster": per}
412+
413+
414+
# ============================================================================
415+
# Superset driver: only ADDS keys/columns; every pre-existing key, column, and
416+
# CLI flag keeps its old meaning.
417+
# ============================================================================
262418
def run(params=None):
263419
_apply(params)
264420
rows = []
@@ -274,9 +430,19 @@ def run(params=None):
274430
row["w"] = round(len(_words(gen) & _words(ref_md)) /
275431
max(len(_words(ref_md)), 1), 3)
276432
row["r_global"] = round(_rougeL(_alpha(ref_md), _alpha(gen)), 3)
277-
macro, fields = field_scores(gen, ref_md)
433+
macro, fields = field_scores(gen, ref_md) # unchanged rField
278434
row["r_field"] = round(macro, 3)
279435
row["fields"] = fields
436+
# NEW: recall/precision split + F1 (== r_field by construction)
437+
rM, pM, fM, prf = field_prf(gen, ref_md)
438+
row["recall_field"] = round(rM, 3)
439+
row["prec_field"] = round(pM, 3)
440+
row["f1_field"] = round(fM, 3)
441+
row["fields_prf"] = prf
442+
# NEW: per-poster fabrication signals
443+
srate, su, st = fabrication_soft(gen, ref_md)
444+
row["fab_soft"] = round(srate, 3)
445+
row["inv_ids"] = invented_ids(gen, ref_md)
280446
row["affil"] = affil_metric(gen, ref_md, annotation)
281447
except Exception as ex: # noqa: BLE001
282448
row["error"] = f"{type(ex).__name__}: {ex}"[:160]
@@ -286,6 +452,7 @@ def run(params=None):
286452

287453
def _fmt(rows):
288454
print(f" {'poster':40s} {'w':>5} {'rGlob':>6} {'rField':>7} "
455+
f"{'rec':>5} {'prec':>5} {'fab':>5} "
289456
f"{'scheme':>8} {'refOK':>6} {'genOK':>6} {'status':>10}")
290457
for r in rows:
291458
if "error" in r:
@@ -295,17 +462,25 @@ def _fmt(rows):
295462
w = f"{r['w']:.2f}" if "w" in r else " -"
296463
rg = f"{r['r_global']:.3f}" if "r_global" in r else " - "
297464
rf = f"{r['r_field']:.3f}" if "r_field" in r else " - "
465+
rc_f = f"{r['recall_field']:.2f}" if "recall_field" in r else " -"
466+
pc_f = f"{r['prec_field']:.2f}" if "prec_field" in r else " -"
467+
fb = f"{r['fab_soft']:.2f}" if "fab_soft" in r else " -"
298468
rc = a.get("ref_correct")
299469
gc = a.get("gen_correct")
300-
print(f" {r['id']:40s} {w:>5} {rg:>6} {rf:>7} {a.get('scheme','?'):>8} "
470+
print(f" {r['id']:40s} {w:>5} {rg:>6} {rf:>7} {rc_f:>5} {pc_f:>5} "
471+
f"{fb:>5} {a.get('scheme','?'):>8} "
301472
f"{(f'{rc:.2f}' if rc is not None else '-'):>6} "
302-
f"{(f'{gc:.2f}' if gc is not None else '-'):>6} {a.get('status','?'):>10}")
473+
f"{(f'{gc:.2f}' if gc is not None else '-'):>6} "
474+
f"{a.get('status','?'):>10}")
303475
core = [r for r in rows if "oos" not in r["id"] and "w" in r]
304-
print(f" {'-'*94}")
476+
print(f" {'-'*110}")
305477
if core:
306478
print(f" corpus avg w={statistics.fmean(r['w'] for r in core):.3f} "
307479
f"rGlobal={statistics.fmean(r['r_global'] for r in core):.3f} "
308-
f"rField={statistics.fmean(r['r_field'] for r in core):.3f}")
480+
f"rField={statistics.fmean(r['r_field'] for r in core):.3f} "
481+
f"recall={statistics.fmean(r['recall_field'] for r in core):.3f} "
482+
f"prec={statistics.fmean(r['prec_field'] for r in core):.3f} "
483+
f"F1={statistics.fmean(r['f1_field'] for r in core):.3f}")
309484
passes = sum(1 for r in rows if r.get("affil", {}).get("status")
310485
in ("PASS", "ref-ok(img)", "single(n/a)", "n/a(<2 authors)"))
311486
numbered = [r for r in rows if r.get("affil", {}).get("scheme") == "numbered"]
@@ -317,12 +492,44 @@ def _fmt(rows):
317492
f"{passes}/{len(rows)} of all 21 acceptable")
318493

319494

495+
def _fab_summary(vlm_out=None):
496+
"""Single corpus fabrication number, pdfplumber vs (optional) LightOnOCR.
497+
vlm_out: dir of scrubbed <id>.md (e.g. calibration/vlm/out). For a true
498+
apples-to-apples charge, feed SCRUBBED VLM text (vlm_scrub.scrub) here."""
499+
def ctl(pid, pdf, raw, ann):
500+
return E.extract_text_with_pdfplumber(pdf) if pdf else ""
501+
# VLM text is scrubbed before scoring fabrication, so the charge is against
502+
# the text we actually ship, not raw markup (raw overstates soft ~+5pts).
503+
try:
504+
sys.path.insert(0, os.path.join(
505+
os.path.dirname(os.path.abspath(__file__)), "vlm"))
506+
from vlm_scrub import scrub as _scrub
507+
except Exception:
508+
def _scrub(t):
509+
return t
510+
print("\nFABRICATION (corpus, folds fidelity_check invented-ids + soft rate;"
511+
f" flag if hard>0 or soft>{FAB_SOFT_TAU}):")
512+
for prov, name in ([(ctl, "pdfplumber")] +
513+
([(lambda pid, pdf, raw, ann,
514+
_d=vlm_out: (_scrub(open(os.path.join(_d, f"{pid}.md"),
515+
encoding="utf-8").read())
516+
if os.path.exists(os.path.join(_d, f"{pid}.md"))
517+
else ""), "LightOnOCR")]
518+
if vlm_out and os.path.isdir(vlm_out) else [])):
519+
f = corpus_fabrication(prov, name)
520+
print(f" {name:12s} soft={f['soft']*100:5.1f}% hard={f['hard_ids']:2d} ids "
521+
f"({f['n_tok']} tok) -> {'FABRICATING' if f['fabricating'] else 'clean'}")
522+
523+
320524
def main():
321525
ap = argparse.ArgumentParser()
322526
ap.add_argument("--set", nargs="*", default=[], metavar="K=V")
323527
ap.add_argument("--sweep", nargs=2, metavar=("CONST", "V1,V2,.."))
324528
ap.add_argument("--save", metavar="PATH")
325529
ap.add_argument("--details", action="store_true", help="print per-field rougeL")
530+
ap.add_argument("--fab", nargs="?", const="vlm/out", default=None,
531+
metavar="VLM_OUT_DIR",
532+
help="print corpus fabrication (optionally vs a VLM out dir)")
326533
args = ap.parse_args()
327534
base = _snapshot()
328535

@@ -336,6 +543,8 @@ def main():
336543
numbered = [r for r in rows if r.get("affil", {}).get("scheme") == "numbered"]
337544
npass = sum(1 for r in numbered if r.get("affil", {}).get("status") == "PASS")
338545
print(f" {const}={v:>7} rField={statistics.fmean(r['r_field'] for r in core):.3f}"
546+
f" prec={statistics.fmean(r['prec_field'] for r in core):.3f}"
547+
f" fab={statistics.fmean(r['fab_soft'] for r in core):.3f}"
339548
f" rGlobal={statistics.fmean(r['r_global'] for r in core):.3f}"
340549
f" affilPASS={npass}/{len(numbered)}")
341550
return
@@ -344,14 +553,18 @@ def main():
344553
print(f"constants: {{**{base}, **{params or {}}}}\n")
345554
rows = run(params)
346555
_fmt(rows)
556+
if args.fab is not None:
557+
vlm = args.fab if os.path.isabs(args.fab) else os.path.join(REPO, "calibration", args.fab)
558+
_fab_summary(vlm)
347559
if args.details:
348-
print("\nper-field rougeL:")
560+
print("\nper-field recall / precision / f1:")
349561
for r in rows:
350-
if "fields" not in r:
562+
if "fields_prf" not in r:
351563
continue
352564
print(f" {r['id']}")
353-
for name, sc, ln in r["fields"]:
354-
print(f" {sc:.3f} ({ln:4d}w) {name}")
565+
for name, rc, pc, f1, rl, gl in r["fields_prf"]:
566+
print(f" r={rc:.3f} p={pc:.3f} f1={f1:.3f} "
567+
f"(ref {rl:4d}w / gen {gl:4d}w) {name}")
355568
if args.save:
356569
os.makedirs(os.path.dirname(args.save) or ".", exist_ok=True)
357570
with open(args.save, "w", encoding="utf-8") as fh:

0 commit comments

Comments
 (0)