Files
AareLC_train/tools/compare_unseen.py
T
jungfrauandClaude Fable 5.1 63ee07fe61
CI / ledger-and-tools (push) Successful in 19s
a gold outline the reviewer kept from the model's proposal is not judged
454 of the 1 029 gold outlines are a proposal kept unchanged (loop 159 of 317, pin 140 of
224, face 140 of 295, crystal 14 of 149): the pre-labelling model's own masks. The
evaluation now ignores them object by object for every model, like a crowd region: they
leave the gold, and an unmatched prediction on one of them is not a false alarm.
tools/gold_kept.py finds them against the studio's batch proposals (mask IoU >= 0.98) and
writes data/gold/kept_machine.json; evaluate_image, evaluate_dirs and gate_stats.per_frame
read it. With it, the frame-level exclusion of 845bd87 is no longer applied (it stays as
the fallback without the file), so the gold set can grow with frames the active model
pre-labelled. Every registry model is measured again on this basis and the three v5 runs
are re-gated, without promotion.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uu3Bxsu5JqFhaWa9Ftu6z
2026-10-25 09:48:20 +01:00

190 lines
10 KiB
Python

#!/usr/bin/env python3
"""Every measured model on the gold frames none of them trained on, with the noise made visible.
python tools/compare_unseen.py # table, runs/clean_gold_comparison.json
Two sets of frames:
* the **core**: the 149 frames that were unseen by every model on 2026-10-02
(``data/gold/unseen_core.json``). It never changes, so the progression of the models over time
stays comparable;
* **all unseen** frames: the core plus every gold frame added since that no dataset has used.
Where the reviewer kept a proposed outline, the gold holds the pre-labelling model's own mask,
which overlaps itself perfectly. With ``data/gold/kept_machine.json`` (tools/gold_kept.py) such
outlines are ignored object by object for every model; without it, a frame a model pre-labelled
(``data/gold/prelabel.json``) leaves that model's measure altogether.
It grows with the gold set and is the one to judge a candidate on.
A model is listed when its predictions on the current gold set exist under
``runs/_measure_<version>/gold_predictions`` (``train_run.py`` keeps them for a new run; after
the gold set grew, re-measure the older versions or they are compared on fewer frames).
For every model the script also reports, against the active model, the paired-bootstrap
interval of the difference in F1 and mask score and the share of draws it wins (tools/gate_stats.py).
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Dict, List, Optional
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import gate_stats # noqa: E402
NAMES = ["loop_all", "pin", "crystal", "loop_face", "ice", "needle"]
BEAMLINE_PREFIX = "yolo26n-seg-overlap-false" # the model trained before the loop
def class_report(c) -> Dict[str, float]:
tp, fp, fn, iou = c
precision = tp / (tp + fp) if (tp + fp) else 0.0
recall = tp / (tp + fn) if (tp + fn) else 0.0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
return {"tp": int(tp), "fp": int(fp), "fn": int(fn), "precision": precision, "recall": recall, "f1": f1,
"mean_mask_iou": iou / tp if tp else 0.0, "n_matched": int(tp)}
def report(frames: Dict[str, gate_stats.FrameCounts]) -> Dict:
rows = list(frames.values())
per_class = {}
for cid, name in enumerate(NAMES):
c = gate_stats._total(rows, cid)
if c[0] or c[1] or c[2]:
per_class[name] = class_report(c)
return {"per_class": per_class, "overall": class_report(gate_stats._total(rows, None)),
"dataset": {"gold_images": len(rows)}}
def mask_score(o: Dict) -> float:
return o["mean_mask_iou"] * (o["tp"] / (o["tp"] + o["fn"]) if (o["tp"] + o["fn"]) else 0.0)
def table(title: str, reps: Dict[str, Dict]) -> None:
print(title)
print(f"{'run':46} {'frames':>6} {'F1':>6} {'recall':>7} {'IoU matched':>12} {'mask score':>11} loop/pin/crystal/face/ice F1")
for k, r in reps.items():
o, pc = r["overall"], r["per_class"]
print(f"{k:46} {r['dataset']['gold_images']:6d} {o['f1']:6.3f} {o['recall']:7.2f} {o['mean_mask_iou']:12.3f} {mask_score(o):11.3f} "
+ "/".join(f"{pc.get(c, {}).get('f1', 0):.2f}" for c in NAMES[:5]))
def main(argv: Optional[List[str]] = None) -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--root", default=str(HERE.parent), help="AareLC_train checkout (default: this one)")
ap.add_argument("--draws", type=int, default=2000)
args = ap.parse_args(argv)
root = Path(args.root).expanduser()
sys.path.insert(0, str(root))
gold = root / "data" / "gold" / "labels"
core_file = root / "data" / "gold" / "unseen_core.json"
if not core_file.exists():
print(f"{core_file} is missing: it lists the frozen core and the gold frames an older model trained on")
return 2
cj = json.loads(core_file.read_text(encoding="utf-8"))
core, seen = set(cj["core"]), set(cj.get("seen", []))
trained_on = set()
for m in (root / "data" / "datasets").glob("*/manifest.csv"):
with open(m, encoding="utf-8") as fh:
next(fh, None)
for line in fh:
key = line.split(",")[0].strip()
if key.startswith("tpi_"):
trained_on.add(key)
groups = gate_stats.load_groups(gold.parent)
stems = sorted(p.stem for p in gold.glob("*.txt") if p.stem not in groups["excluded"])
key_of = lambda s: "tpi_" + re.search(r"tpi_(\d+)", s).group(1)
unseen = [s for s in stems if s not in seen and key_of(s) not in trained_on]
core_now = [s for s in unseen if s in core]
ver2run = {}
for rj in (root / "runs").glob("*/run.json"):
r = json.loads(rj.read_text(encoding="utf-8"))
if r.get("version"):
ver2run[r["version"]] = r["id"]
active = None
ap_file = root / "models" / "active" / "production.json"
if ap_file.exists():
active = json.loads(ap_file.read_text(encoding="utf-8")).get("version")
frames: Dict[str, Dict[str, gate_stats.FrameCounts]] = {}
label_of: Dict[str, str] = {}
# with kept_machine.json the evaluation ignores a model's own outlines object by object;
# without it, a frame the model pre-labelled leaves its measure altogether (the older rule)
object_level = gate_stats.kept_outlines_known(gold.parent)
prelabel = {} if object_level else gate_stats.load_prelabel(gold.parent)
own: Dict[str, int] = {}
for d in sorted((root / "runs").glob("_measure_*/gold_predictions")):
ver = d.parent.name[len("_measure_"):]
label = ("beamline " + ver) if ver.startswith(BEAMLINE_PREFIX) else ver2run.get(ver, ver)
measured = gate_stats.per_frame(d, gold, stems=unseen)
# a frame the model pre-labelled holds its own outlines wherever the reviewer kept them:
# it is "seen" by that model and leaves its measure (gate_stats.without_own_proposals)
frames[label] = gate_stats.without_own_proposals(measured, ver, prelabel)
own[label] = len(measured) - len(frames[label])
label_of[ver] = label
order = sorted(frames, key=lambda k: (not k.startswith("beamline"), k))
reps = {k: report(frames[k]) for k in order}
core_reps = {k: report({s: f for s, f in frames[k].items() if s in core}) for k in order}
# frames whose light makes them unusable (saturated) are part of the measure; this is the same
# table without them, to tell a model that got worse from a set that holds more bad light
bad_light = {s for s, state in groups["light"].items() if state == "saturated"}
usable_reps = {k: report({s: f for s, f in frames[k].items() if s in core and s not in bad_light}) for k in order}
print(f"clean gold frames: {len(unseen)}" + (f" (core {len(core_now)})" if len(unseen) != len(core_now) else ""))
if object_level:
kept = sum(len(v) for v in __import__("src.eval.yolo_labels", fromlist=["x"]).read_kept_outlines(gold.parent).values())
print(f"{kept} gold outlines are a model's proposal kept unchanged by the reviewer: not judged for any model (data/gold/kept_machine.json)")
elif any(own.values()):
print("frames a model pre-labelled itself, left out of its own measure: "
+ ", ".join(f"{k} {n}" for k, n in own.items() if n))
elif not prelabel:
print("!! data/gold/prelabel.json is missing (tools/gold_prelabel.py): a model is measured on frames it pre-labelled")
table("", reps)
short = [k for k in order if reps[k]["dataset"]["gold_images"] + own.get(k, 0) < len(unseen)]
if short:
print(f"!! measured on fewer frames than the current unseen set (re-measure them): {', '.join(short)}")
if len(unseen) != len(core_now):
table(f"\ncore, the {len(core_now)} frames frozen on {cj.get('frozen', '?')}:", core_reps)
boot = {}
base_label = label_of.get(active) if active else None
if base_label and base_label in frames:
print(f"\nagainst the active model ({base_label}), paired bootstrap over the frames both have, drawn by mount: difference [95 % interval], share of draws won")
classes = {"crystal": NAMES.index("crystal")}
for k in order:
if k == base_label:
continue
b = gate_stats.paired_bootstrap(frames[k], frames[base_label], draws=args.draws, classes=classes, groups=groups["mount"])
if not b.get("frames"):
continue
boot[k] = b
f, m, c = b["f1"], b["mask_score"], b["per_class"]["crystal"]["f1"]
print(f"{k:46} F1 {f['delta']:+.3f} [{f['lo']:+.3f}, {f['hi']:+.3f}] {f['win']:4.0%} mask {m['delta']:+.3f} [{m['lo']:+.3f}, {m['hi']:+.3f}] {m['win']:4.0%} crystal {c['delta']:+.3f} [{c['lo']:+.3f}, {c['hi']:+.3f}] {c['win']:4.0%}")
# per frame and model: overall and crystal counts, so any two models can be compared head to head
# with their noise interval (the monitor page runs the paired bootstrap in the browser)
crystal = NAMES.index("crystal")
def row(f):
o = gate_stats._total([f], None); c = gate_stats._total([f], crystal)
return [int(o[0]), int(o[1]), int(o[2]), round(o[3], 3), int(c[0]), int(c[1]), int(c[2]), round(c[3], 3)]
per_frame = {"stems": unseen, "mount": [groups["mount"].get(st) or st for st in unseen],
"light": [groups["light"].get(st, "ok") for st in unseen], "fields": ["tp", "fp", "fn", "iou_sum", "crystal_tp", "crystal_fp", "crystal_fn", "crystal_iou_sum"],
"models": {k: [row(frames[k][st]) if st in frames[k] else None for st in unseen] for k in order}}
(root / "runs" / "_unseen_per_frame.json").write_text(json.dumps(per_frame, separators=(",", ":")), encoding="utf-8")
out = {"clean_frames": len(unseen), "reports": reps, "core_frames": len(core_now), "core_frozen": cj.get("frozen"),
"core_reports": core_reps, "active": active, "bootstrap_vs_active": boot,
"core_reports_usable_light": usable_reps, "saturated_frames": sorted(s for s in bad_light if s in core),
"mounts": len({groups["mount"].get(s) or s for s in unseen}), "excluded": groups["excluded"]}
(root / "runs" / "clean_gold_comparison.json").write_text(json.dumps(out, indent=2), encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())