"""Before/after extraction eval on Modal — the OpenBMB proof. Runs the labeled reports through the BASE and the FINE-TUNED model on a GPU and reports the field-level accuracy jump. The model runs through the same ZeroGPU/Transformers backend the Space uses (here `@spaces.GPU` is a no-op because the `spaces` package isn't installed, so generation runs directly on the Modal GPU). modal run train/modal_eval.py::compare modal run train/modal_eval.py::compare --finetuned-id build-small-hackathon/blood-test-minicpmv-4_6-medreason Writes eval/before_after.json locally with the base vs fine-tuned metrics. """ from __future__ import annotations import modal app = modal.App("blood-test-eval") image = ( modal.Image.debian_slim(python_version="3.11") .apt_install("git") .pip_install( "torch", "torchvision", "transformers[torch]>=5.7.0", "accelerate", "pillow", "pymupdf", "av", "requests", "json-repair", ) # NOTE: deliberately no `spaces` package -> @spaces.GPU is a no-op -> runs on the Modal GPU. .add_local_dir("src", "/root/app/src") .add_local_dir("kb", "/root/app/kb") .add_local_dir("eval", "/root/app/eval") ) hf_cache = modal.Volume.from_name("blood-test-hf-cache", create_if_missing=True) @app.function( image=image, gpu="A100", timeout=60 * 60, volumes={"/root/.cache/huggingface": hf_cache}, secrets=[modal.Secret.from_name("huggingface-secret")], ) def eval_model(model_id: str, labels_rel: str = "eval/data/real/labels.jsonl") -> dict: """Run the configured model over the labeled reports and return field-level metrics.""" import json import os import sys from pathlib import Path sys.path.insert(0, "/root/app") os.environ["ZEROGPU_QUANTIZE"] = "0" # bf16 — a clean, representative eval from src.eval_scoring import format_metrics, score # Import the ZeroGPU backend directly (not via the factory) so we don't pull in the llama.cpp # backend, which isn't installed in this eval image. from src.extraction.zerogpu_transformers import ZeroGPUTransformersExtractor labels_path = Path("/root/app") / labels_rel gold = [json.loads(ln) for ln in labels_path.read_text(encoding="utf-8").splitlines() if ln.strip()] extractor = ZeroGPUTransformersExtractor(model_id=model_id) base_dir = labels_path.parent preds: list[dict] = [] for i, row in enumerate(gold): image_path = str((base_dir / row["image"]).resolve()) try: result = extractor.extract(image_path, max_pages=3) preds.append({"tests": result.tests}) print(f"[{i}] {row['image']}: {len(result.tests)} markers") except Exception as error: # a failed report is a miss, keep going print(f"[{i}] {row['image']}: FAILED — {error}") preds.append({"tests": []}) m = score(gold, preds) print(f"\n=== {model_id} ===\n{format_metrics(m)}\n") return { "model": model_id, "n": len(gold), "precision": m.precision, "recall": m.recall, "f1": m.f1, "value_acc": m.value_acc, "unit_acc": m.unit_acc, "status_acc": m.status_acc, "tp": m.tp, "fp": m.fp, "fn": m.fn, "matched": m.matched, } @app.local_entrypoint() def compare( finetuned_id: str = "build-small-hackathon/blood-test-minicpmv-4_6-medreason", base_id: str = "openbmb/MiniCPM-V-4.6", labels_rel: str = "eval/data/real/labels.jsonl", ) -> None: """Eval base vs fine-tuned and write the before/after numbers for the chart.""" import json from pathlib import Path base = eval_model.remote(base_id, labels_rel) fine = eval_model.remote(finetuned_id, labels_rel) metrics = ("f1", "recall", "precision", "value_acc", "unit_acc", "status_acc") print(f"\n Extraction before/after — {base['n']} labeled reports\n") print(f" {'metric':<12}{'base':>9}{'fine-tuned':>14}{'delta':>10}") for key in metrics: b, f = base[key], fine[key] print(f" {key:<12}{b:>9.3f}{f:>14.3f}{('+' if f >= b else '') + f'{f - b:.3f}':>10}") out = Path("eval/before_after.json") out.write_text(json.dumps({"base": base, "finetuned": fine}, indent=2), encoding="utf-8") print(f"\n wrote {out}\n") @app.function( image=image, gpu="A100", timeout=60 * 60, volumes={"/root/.cache/huggingface": hf_cache}, secrets=[modal.Secret.from_name("huggingface-secret")], ) def draft_labels( pdf_dir_rel: str = "eval/data/real", model_id: str = "openbmb/MiniCPM-V-4.6", exclude: tuple[str, ...] = ("06_drlogy_cbc.pdf", "02_cbc_umc_johndoe.pdf"), ) -> list[dict]: """Run the BASE model over the real PDFs to produce DRAFT labels you then correct by hand. Excludes the held-out eval reports so train/eval stay separate (no leakage). """ import os import sys from pathlib import Path sys.path.insert(0, "/root/app") os.environ["ZEROGPU_QUANTIZE"] = "0" from src.extraction.zerogpu_transformers import ZeroGPUTransformersExtractor pdf_dir = Path("/root/app") / pdf_dir_rel extractor = ZeroGPUTransformersExtractor(model_id=model_id) drafts: list[dict] = [] for pdf in sorted(pdf_dir.glob("*.pdf")): if pdf.name in exclude: continue try: tests = extractor.extract(str(pdf), max_pages=3).tests print(f"{pdf.name}: {len(tests)} draft markers") except Exception as error: print(f"{pdf.name}: FAILED — {error}") tests = [] drafts.append({"image": pdf.name, "tests": tests, "notes": []}) return drafts @app.local_entrypoint() def label(pdf_dir: str = "eval/data/real", out: str = "eval/data/real/labels_train_draft.jsonl") -> None: """Generate draft labels (base model) for you to correct, then mix into training.""" import json from pathlib import Path drafts = draft_labels.remote(pdf_dir_rel=pdf_dir) out_path = Path(out) with out_path.open("w", encoding="utf-8") as fh: for row in drafts: fh.write(json.dumps(row, ensure_ascii=False) + "\n") print(f"\n Wrote {len(drafts)} DRAFT labels -> {out_path}") print(" Correct each line (fix marker/value/unit/status, delete junk rows), save it as") print(" eval/data/real/labels_train.jsonl, then retrain with the real mix-in:") print(" modal run train/modal_finetune.py::main --real-labels eval/data/real/labels_train.jsonl\n") @app.function( image=image, gpu="A100", timeout=60 * 60, volumes={"/root/.cache/huggingface": hf_cache}, secrets=[modal.Secret.from_name("huggingface-secret")], ) def build_traces( model_id: str = "build-small-hackathon/blood-test-minicpmv-4_6-medreason", pdf_dir_rel: str = "eval/data/real", repo_id: str = "build-small-hackathon/blood-test-explainer-traces", ) -> str: """Run the deployed agent (extract + KB interpretation) over the real reports and publish the full traces as a public dataset on the Hub (the Sharing-is-Caring badge).""" import json import os import sys from pathlib import Path sys.path.insert(0, "/root/app") os.environ["ZEROGPU_QUANTIZE"] = "0" from src.extraction.zerogpu_transformers import ZeroGPUTransformersExtractor from src.interpretation import build_interpretation extractor = ZeroGPUTransformersExtractor(model_id=model_id) pdf_dir = Path("/root/app") / pdf_dir_rel traces: list[dict] = [] for pdf in sorted(pdf_dir.glob("*.pdf")): try: tests = extractor.extract(str(pdf), max_pages=3).tests except Exception as error: print(f"{pdf.name}: extraction failed - {error}") tests = [] interp = build_interpretation(tests) traces.append({ "report": pdf.name, "model": model_id, "steps": ["read the document", "extract markers and values", "interpret with the knowledge base"], "extraction": {"n_markers": len(tests), "tests": tests}, "interpretation": { "flagged": [ {"marker": c.marker, "value": c.value, "unit": c.unit, "status": c.status, "reference_range": c.reference_range, "note": c.note, "questions": list(c.questions)} for c in interp.flagged ], "patterns": [{"name": p.name, "note": p.note} for p in interp.patterns], "normal_count": interp.normal_count, "disclaimer": interp.disclaimer, }, }) print(f"{pdf.name}: {len(tests)} markers, {len(interp.flagged)} flagged, {len(interp.patterns)} patterns") traces_path = Path("/root/traces.jsonl") traces_path.write_text("\n".join(json.dumps(t, ensure_ascii=False) for t in traces), encoding="utf-8") readme = ( "---\nlicense: mit\ntask_categories:\n- image-text-to-text\ntags:\n- agent-traces\n" "- medical\n- blood-test\n---\n\n" "# Blood Test Explainer - agent traces\n\n" "Agent traces from the Blood Test Explainer app (Build Small hackathon). Each row is one lab " "report run through the full agent: a small vision model reads the document and extracts the " "markers, then a curated medical knowledge base turns the values into a grounded, per-marker " "explanation plus cross-marker patterns.\n\n" f"Model: `{model_id}`, a fine-tuned MiniCPM-V 4.6 running fully offline.\n\n" "Each record has `report`, `model`, `steps`, `extraction` (the structured markers), and " "`interpretation` (flagged markers with grounded notes and doctor-questions, cross-marker " "patterns, and the educational disclaimer). Educational only, not a diagnosis.\n" ) readme_path = Path("/root/README.md") readme_path.write_text(readme, encoding="utf-8") from huggingface_hub import HfApi token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") if not token: raise RuntimeError("HF_TOKEN not set - add it to the 'huggingface-secret' Modal secret.") api = HfApi(token=token) api.create_repo(repo_id, repo_type="dataset", exist_ok=True) api.upload_file(path_or_fileobj=str(traces_path), path_in_repo="traces.jsonl", repo_id=repo_id, repo_type="dataset") api.upload_file(path_or_fileobj=str(readme_path), path_in_repo="README.md", repo_id=repo_id, repo_type="dataset") print(f"Published {len(traces)} traces to https://huggingface.co/datasets/{repo_id}") return repo_id @app.local_entrypoint() def traces( model_id: str = "build-small-hackathon/blood-test-minicpmv-4_6-medreason", repo_id: str = "build-small-hackathon/blood-test-explainer-traces", ) -> None: # spawn() so the job runs to completion on Modal even if the local connection drops call = build_traces.spawn(model_id=model_id, repo_id=repo_id) print(f"\nLaunched traces build on Modal (call {call.object_id}) — runs in the background.") print(f"When it finishes, the dataset will be live at https://huggingface.co/datasets/{repo_id}")