Dimitris Codex commited on
Commit ·
1b58e74
1
Parent(s): 75d3367
feat(eval): field-level extraction eval harness + tests
Browse filesCo-authored-by: Codex <chatgpt-codex-connector[bot]@users.noreply.github.com>
- eval/run_eval.py +78 -0
- src/eval_scoring.py +115 -0
- tests/test_data_pipeline.py +43 -0
- tests/test_eval_scoring.py +51 -0
eval/run_eval.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Run extraction evaluation: gold labels vs predictions.
|
| 3 |
+
|
| 4 |
+
Two modes:
|
| 5 |
+
1. Score precomputed predictions:
|
| 6 |
+
python eval/run_eval.py --labels eval/data/synth_eval/labels.jsonl \
|
| 7 |
+
--predictions runs/pred.jsonl
|
| 8 |
+
2. Run the configured extractor over the images and score live (needs the model):
|
| 9 |
+
EXTRACTOR_BACKEND=local LOCAL_MODEL_PATH=... LOCAL_MMPROJ_PATH=... \
|
| 10 |
+
python eval/run_eval.py --labels eval/data/synth_eval/labels.jsonl --run
|
| 11 |
+
|
| 12 |
+
Use mode 2 twice (base vs fine-tuned GGUF) to produce the OpenBMB before/after numbers.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
import json
|
| 19 |
+
import sys
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 23 |
+
sys.path.insert(0, str(ROOT))
|
| 24 |
+
|
| 25 |
+
from src.eval_scoring import format_metrics, score # noqa: E402
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _load_jsonl(path: Path) -> list[dict]:
|
| 29 |
+
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _predict_live(labels: list[dict], labels_path: Path) -> list[dict]:
|
| 33 |
+
from src.extraction import build_extractor
|
| 34 |
+
|
| 35 |
+
extractor = build_extractor()
|
| 36 |
+
base = labels_path.parent
|
| 37 |
+
preds = []
|
| 38 |
+
for i, row in enumerate(labels):
|
| 39 |
+
image_path = str((base / row["image"]).resolve())
|
| 40 |
+
try:
|
| 41 |
+
result = extractor.extract(image_path, max_pages=3)
|
| 42 |
+
preds.append({"tests": result.tests})
|
| 43 |
+
except Exception as error: # keep going; a failed page is a miss
|
| 44 |
+
print(f" [{i}] extraction failed: {error}", file=sys.stderr)
|
| 45 |
+
preds.append({"tests": []})
|
| 46 |
+
return preds
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def main() -> int:
|
| 50 |
+
ap = argparse.ArgumentParser()
|
| 51 |
+
ap.add_argument("--labels", type=Path, required=True)
|
| 52 |
+
ap.add_argument("--predictions", type=Path, help="precomputed predictions JSONL")
|
| 53 |
+
ap.add_argument("--run", action="store_true", help="run the configured extractor live")
|
| 54 |
+
args = ap.parse_args()
|
| 55 |
+
|
| 56 |
+
gold = _load_jsonl(args.labels)
|
| 57 |
+
if args.predictions:
|
| 58 |
+
pred = _load_jsonl(args.predictions)
|
| 59 |
+
elif args.run:
|
| 60 |
+
pred = _predict_live(gold, args.labels)
|
| 61 |
+
else:
|
| 62 |
+
ap.error("provide --predictions or --run")
|
| 63 |
+
|
| 64 |
+
if len(pred) != len(gold):
|
| 65 |
+
ap.error(f"predictions ({len(pred)}) and labels ({len(gold)}) length mismatch")
|
| 66 |
+
|
| 67 |
+
m = score(gold, pred)
|
| 68 |
+
print(f"\n Extraction eval — {args.labels.name} ({len(gold)} reports)\n")
|
| 69 |
+
print(format_metrics(m))
|
| 70 |
+
worst = sorted(m.by_marker_fn.items(), key=lambda kv: -kv[1])[:5]
|
| 71 |
+
if worst:
|
| 72 |
+
print("\n most-missed markers:", ", ".join(f"{k}×{n}" for k, n in worst))
|
| 73 |
+
print()
|
| 74 |
+
return 0
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
if __name__ == "__main__":
|
| 78 |
+
raise SystemExit(main())
|
src/eval_scoring.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Field-level scoring for extraction quality.
|
| 2 |
+
|
| 3 |
+
Compares predicted lab values against gold labels and reports the metrics that matter for the
|
| 4 |
+
OpenBMB before/after story:
|
| 5 |
+
- **marker P / R / F1** — did we find the right markers (matched by canonical name/alias)?
|
| 6 |
+
- **value / unit / status accuracy** — for matched markers, are the fields right?
|
| 7 |
+
|
| 8 |
+
Pure functions, no model or I/O, so they are unit-tested directly.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
|
| 15 |
+
from src.markers import resolve
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _canon(name: str) -> str:
|
| 19 |
+
m = resolve(name)
|
| 20 |
+
return m.name.casefold() if m else (name or "").strip().casefold()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _num(s) -> float | None:
|
| 24 |
+
try:
|
| 25 |
+
return float(str(s).replace(",", "").strip())
|
| 26 |
+
except (TypeError, ValueError):
|
| 27 |
+
return None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _value_match(a, b, rel_tol: float = 0.001) -> bool:
|
| 31 |
+
na, nb = _num(a), _num(b)
|
| 32 |
+
if na is not None and nb is not None:
|
| 33 |
+
return abs(na - nb) <= rel_tol * max(1.0, abs(nb))
|
| 34 |
+
return str(a).strip().casefold() == str(b).strip().casefold()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _unit_match(a, b) -> bool:
|
| 38 |
+
norm = lambda s: (str(s or "").strip().casefold().replace(" ", ""))
|
| 39 |
+
return norm(a) == norm(b)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass
|
| 43 |
+
class Metrics:
|
| 44 |
+
tp: int = 0
|
| 45 |
+
fp: int = 0
|
| 46 |
+
fn: int = 0
|
| 47 |
+
value_ok: int = 0
|
| 48 |
+
unit_ok: int = 0
|
| 49 |
+
status_ok: int = 0
|
| 50 |
+
matched: int = 0
|
| 51 |
+
by_marker_fn: dict[str, int] = field(default_factory=dict)
|
| 52 |
+
|
| 53 |
+
@property
|
| 54 |
+
def precision(self) -> float:
|
| 55 |
+
return self.tp / (self.tp + self.fp) if (self.tp + self.fp) else 0.0
|
| 56 |
+
|
| 57 |
+
@property
|
| 58 |
+
def recall(self) -> float:
|
| 59 |
+
return self.tp / (self.tp + self.fn) if (self.tp + self.fn) else 0.0
|
| 60 |
+
|
| 61 |
+
@property
|
| 62 |
+
def f1(self) -> float:
|
| 63 |
+
p, r = self.precision, self.recall
|
| 64 |
+
return 2 * p * r / (p + r) if (p + r) else 0.0
|
| 65 |
+
|
| 66 |
+
@property
|
| 67 |
+
def value_acc(self) -> float:
|
| 68 |
+
return self.value_ok / self.matched if self.matched else 0.0
|
| 69 |
+
|
| 70 |
+
@property
|
| 71 |
+
def unit_acc(self) -> float:
|
| 72 |
+
return self.unit_ok / self.matched if self.matched else 0.0
|
| 73 |
+
|
| 74 |
+
@property
|
| 75 |
+
def status_acc(self) -> float:
|
| 76 |
+
return self.status_ok / self.matched if self.matched else 0.0
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def score_report(gold_tests: list[dict], pred_tests: list[dict], m: Metrics) -> None:
|
| 80 |
+
"""Accumulate one report's gold-vs-pred comparison into `m`."""
|
| 81 |
+
gold_by = {_canon(t.get("marker", "")): t for t in gold_tests}
|
| 82 |
+
pred_by = {_canon(t.get("marker", "")): t for t in pred_tests}
|
| 83 |
+
|
| 84 |
+
for key, g in gold_by.items():
|
| 85 |
+
p = pred_by.get(key)
|
| 86 |
+
if p is None:
|
| 87 |
+
m.fn += 1
|
| 88 |
+
m.by_marker_fn[key] = m.by_marker_fn.get(key, 0) + 1
|
| 89 |
+
continue
|
| 90 |
+
m.tp += 1
|
| 91 |
+
m.matched += 1
|
| 92 |
+
m.value_ok += _value_match(p.get("value"), g.get("value"))
|
| 93 |
+
m.unit_ok += _unit_match(p.get("unit"), g.get("unit"))
|
| 94 |
+
m.status_ok += str(p.get("status", "")).strip().casefold() == str(g.get("status", "")).strip().casefold()
|
| 95 |
+
|
| 96 |
+
for key in pred_by:
|
| 97 |
+
if key not in gold_by:
|
| 98 |
+
m.fp += 1
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def score(gold_rows: list[dict], pred_rows: list[dict]) -> Metrics:
|
| 102 |
+
"""Score aligned lists of {tests:[...]} rows (same order/length)."""
|
| 103 |
+
m = Metrics()
|
| 104 |
+
for g, p in zip(gold_rows, pred_rows):
|
| 105 |
+
score_report(g.get("tests", []), p.get("tests", []), m)
|
| 106 |
+
return m
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def format_metrics(m: Metrics) -> str:
|
| 110 |
+
return (
|
| 111 |
+
f" markers P={m.precision:.3f} R={m.recall:.3f} F1={m.f1:.3f} "
|
| 112 |
+
f"(tp={m.tp} fp={m.fp} fn={m.fn})\n"
|
| 113 |
+
f" fields value={m.value_acc:.3f} unit={m.unit_acc:.3f} status={m.status_acc:.3f} "
|
| 114 |
+
f"(matched={m.matched})"
|
| 115 |
+
)
|
tests/test_data_pipeline.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""End-to-end check of the data pipeline: generate → SFT convert → self-score."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 8 |
+
|
| 9 |
+
from src.eval_scoring import score # noqa: E402
|
| 10 |
+
from train.synth_reports import generate # noqa: E402
|
| 11 |
+
from train.to_sft_dataset import convert # noqa: E402
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_generate_produces_valid_labels(tmp_path):
|
| 15 |
+
labels = generate(5, tmp_path, seed=1)
|
| 16 |
+
rows = [json.loads(l) for l in labels.read_text().splitlines() if l.strip()]
|
| 17 |
+
assert len(rows) == 5
|
| 18 |
+
for r in rows:
|
| 19 |
+
assert (tmp_path / r["image"]).exists()
|
| 20 |
+
assert r["tests"], "every report should have at least one marker"
|
| 21 |
+
for t in r["tests"]:
|
| 22 |
+
assert t["status"] in {"low", "normal", "high"}
|
| 23 |
+
assert set(t) >= {"marker", "value", "unit", "reference_range", "status"}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_gold_scores_perfectly_against_itself(tmp_path):
|
| 27 |
+
labels = generate(8, tmp_path, seed=2)
|
| 28 |
+
rows = [json.loads(l) for l in labels.read_text().splitlines() if l.strip()]
|
| 29 |
+
m = score(rows, rows)
|
| 30 |
+
assert m.recall == 1.0 and m.precision == 1.0
|
| 31 |
+
assert m.value_acc == 1.0 and m.status_acc == 1.0
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_sft_conversion_targets_are_valid_json(tmp_path):
|
| 35 |
+
labels = generate(4, tmp_path, seed=3)
|
| 36 |
+
out = tmp_path / "sft.jsonl"
|
| 37 |
+
n = convert(labels, out)
|
| 38 |
+
assert n == 4
|
| 39 |
+
for line in out.read_text().splitlines():
|
| 40 |
+
rec = json.loads(line)
|
| 41 |
+
assert [m["role"] for m in rec["messages"]] == ["user", "assistant"]
|
| 42 |
+
json.loads(rec["messages"][1]["content"]) # assistant target parses as JSON
|
| 43 |
+
assert rec["images"] and Path(rec["images"][0]).exists()
|
tests/test_eval_scoring.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 5 |
+
|
| 6 |
+
from src.eval_scoring import score # noqa: E402
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _row(tests):
|
| 10 |
+
return {"tests": tests}
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _t(marker, value, unit="mg/dL", status="normal"):
|
| 14 |
+
return {"marker": marker, "value": value, "unit": unit, "status": status}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_perfect_match():
|
| 18 |
+
gold = [_row([_t("Glucose", "95"), _t("ALT", "30", "U/L")])]
|
| 19 |
+
m = score(gold, gold)
|
| 20 |
+
assert m.precision == 1.0 and m.recall == 1.0 and m.f1 == 1.0
|
| 21 |
+
assert m.value_acc == 1.0 and m.unit_acc == 1.0 and m.status_acc == 1.0
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_alias_is_matched_to_canonical():
|
| 25 |
+
gold = [_row([_t("Creatinine", "1.0")])]
|
| 26 |
+
pred = [_row([_t("Cr", "1.0")])] # alias
|
| 27 |
+
m = score(gold, pred)
|
| 28 |
+
assert m.tp == 1 and m.fp == 0 and m.fn == 0
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_missing_and_extra_markers():
|
| 32 |
+
gold = [_row([_t("Glucose", "95"), _t("ALT", "30")])]
|
| 33 |
+
pred = [_row([_t("Glucose", "95"), _t("HDL", "55")])] # missed ALT, hallucinated HDL
|
| 34 |
+
m = score(gold, pred)
|
| 35 |
+
assert m.tp == 1 and m.fn == 1 and m.fp == 1
|
| 36 |
+
assert "alt" in m.by_marker_fn
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_value_numeric_tolerance_and_status():
|
| 40 |
+
gold = [_row([_t("Glucose", "95", status="normal")])]
|
| 41 |
+
pred = [_row([_t("Glucose", "95.0", status="high")])] # value ok (numeric), status wrong
|
| 42 |
+
m = score(gold, pred)
|
| 43 |
+
assert m.value_ok == 1
|
| 44 |
+
assert m.status_ok == 0
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_unit_mismatch():
|
| 48 |
+
gold = [_row([_t("ALT", "30", "U/L")])]
|
| 49 |
+
pred = [_row([_t("ALT", "30", "IU/L")])]
|
| 50 |
+
m = score(gold, pred)
|
| 51 |
+
assert m.unit_ok == 0 and m.value_ok == 1
|