Files
AareLC_train/tools/compare_unseen.py
T
GotthardGandClaude Fable 5.1 831367d35d noise-aware gate, frozen core of unseen frames, and a cycle script for the Spark
A paired bootstrap over the 149 unseen gold frames (2026-10-02) showed that a
difference between two models carries about +-0.023 of noise in F1, +-0.018 in
the mask score and +-0.10 in crystal F1, while the gate's tolerance was 0.005.

- tools/gate_stats.py: per-frame counts and a paired bootstrap (pure Python).
- train_run.py: when the baseline's gold predictions exist, the gate requires
  the candidate to beat it on F1 or the mask score in 90 % of the draws and
  not to lose the other in more than 90 %; the point rule is the fallback and
  is always reported (pass_point_rule).
- tools/compare_unseen.py replaces the Spark's ~/fair_gate_all.py: all models
  on the frames none trained on, on the frozen core (data/gold/unseen_core.json,
  149 frames) and on all unseen frames once the gold set grows, with the
  interval and win share against the active model.
- measure_weights.py keeps a version's gate verdict when it re-measures it.
- tools/spark_cycle.sh: gold growth, export, re-measure, train, gate, compare,
  push, with what cycles 3 and 4 taught built in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 13:25:45 +02:00

149 lines
7.0 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.
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)
stems = sorted(p.stem for p in gold.glob("*.txt"))
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] = {}
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)
frames[label] = gate_stats.per_frame(d, gold, stems=unseen)
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}
print(f"clean gold frames: {len(unseen)}" + (f" (core {len(core_now)})" if len(unseen) != len(core_now) else ""))
table("", reps)
short = [k for k in order if reps[k]["dataset"]["gold_images"] < 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: 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)
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%}")
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}
(root / "runs" / "clean_gold_comparison.json").write_text(json.dumps(out, indent=2), encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())