Dimitris Codex commited on
Commit
1b58e74
·
1 Parent(s): 75d3367

feat(eval): field-level extraction eval harness + tests

Browse files

Co-authored-by: Codex <chatgpt-codex-connector[bot]@users.noreply.github.com>

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