candle-fire / scripts /eval_landscape.py
KevinIsInCoding
feat(landscape): bring experimental ALS therapy landscape to main (#19)
2e5367f unverified
Raw
History Blame Contribute Delete
4.01 kB
#!/usr/bin/env python3
"""
Evaluate the mechanism classifier against the hand-labeled gold set.
Reports multi-label precision / recall / F1 (micro), exact-set-match rate, and abstention rate,
plus a per-therapy breakdown so misclassifications are visible. Physician's rule — misleading is
worse than missing — so we optimize for PRECISION: a wrong label counts against us; an abstention
(no label) does not count as a precision error, only against recall.
Usage:
uv run python scripts/eval_landscape.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from rich.console import Console
from config import LANDSCAPE_PATH, THERAPY_GOLD_PATH
console = Console()
def _predicted_index(landscape: dict) -> dict[str, set]:
"""name/alias (lower) -> set of predicted mechanism classes. Abstained therapies map to set()."""
idx: dict[str, set] = {}
seen = {}
def add(th: dict, mech_classes: set):
for nm in [th["name"], *th.get("aliases", [])]:
key = nm.lower()
# a therapy appears in multiple class buckets; union its mechanism set once
seen.setdefault(key, set()).update(mech_classes)
idx[key] = seen[key]
for c in landscape.get("classifications", []):
for th in c["therapies"]:
add(th, {m["class"] for m in th.get("mechanisms", [])})
for th in landscape.get("unclassified", []):
add(th, set())
return idx
def main() -> None:
if not LANDSCAPE_PATH.exists():
console.print("[red]No landscape.json — run scripts/build_landscape.py first[/red]"); sys.exit(1)
landscape = json.loads(LANDSCAPE_PATH.read_text())
gold = json.loads(THERAPY_GOLD_PATH.read_text())["gold"]
idx = _predicted_index(landscape)
tp = fp = fn = 0
exact = matched = abstained = missing = 0
rows = []
for g in gold:
names = [g["therapy"], *g.get("aliases", [])]
pred = None
for nm in names:
if nm.lower() in idx:
pred = idx[nm.lower()]; break
gset = set(g["mechanisms"])
if pred is None:
missing += 1
rows.append(("?", g["therapy"], "NOT IN LANDSCAPE", gset, set()))
continue
matched += 1
if not pred:
abstained += 1
inter = pred & gset
tp += len(inter); fp += len(pred - gset); fn += len(gset - pred)
if pred == gset:
exact += 1
mark = "✓" if pred == gset else ("~" if inter else "✗")
rows.append((mark, g["therapy"], "", gset, pred))
prec = tp / (tp + fp) if (tp + fp) else 0.0
rec = tp / (tp + fn) if (tp + fn) else 0.0
f1 = 2 * prec * rec / (prec + rec) if (prec + rec) else 0.0
console.print("\n[bold]Per-therapy (✓ exact, ~ partial, ✗ wrong):[/bold]")
for mark, name, note, gset, pred in sorted(rows, key=lambda r: r[0]):
color = {"✓": "green", "~": "yellow", "✗": "red", "?": "dim"}.get(mark, "white")
console.print(f" [{color}]{mark}[/{color}] {name:22s} gold={sorted(gset)} pred={sorted(pred)} {note}")
console.print(f"\n[bold]Matched {matched}/{len(gold)} gold therapies[/bold] "
f"({missing} not in landscape, {abstained} abstained)")
console.print(f" Micro precision: [bold]{prec:.2f}[/bold] recall: {rec:.2f} F1: {f1:.2f}")
console.print(f" Exact-set match: {exact}/{matched}")
# hard assertions for the flagged cases
def pred_of(name):
return idx.get(name.lower())
console.print("\n[bold]Flagged-case checks:[/bold]")
cnm = pred_of("CNM-Au8") or set()
ok_cnm = "Neuroinflammation" not in cnm and ({"Mitochondrial dysfunction", "Oxidative stress"} & cnm)
console.print(f" CNM-Au8 not Neuroinflammation & has bioenergetic/oxidative: "
f"[{'green' if ok_cnm else 'red'}]{bool(ok_cnm)}[/] (pred={sorted(cnm)})")
if __name__ == "__main__":
main()