Files
GotthardGandClaude Fable 5.1 82f0d9099b
CI / ledger-and-tools (push) Successful in 19s
gate: bootstrap by mount, light states and excluded frames of the gold set
Frames of one mount are the same loop seen again, so the paired bootstrap now draws
mounts (data/gold/groups.json, built by tools/gold_groups.py from the mount ids the
studio writes in the gold manifest). The comparison also reports the unseen core without
the saturated frames, and frames the studio dropped from the gold set are pruned.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 15:46:31 +02:00

124 lines
5.9 KiB
Python

"""How sure is a difference between two models measured on the same gold frames?
The gold set is small (149 unseen frames on 2026-10-02: 40 crystals, 10 ice objects), so a
difference of one or two points in F1 between two models can be the luck of which frames are
gold. A paired bootstrap answers that: draw the gold frames again with replacement, the SAME
draw for both models, and look at the spread of the difference. ``win`` is the share of draws
in which the candidate beats the baseline.
Frames of one mount are not independent (the same loop at another angle, 24 of the 203 gold
frames on 2026-10-02), so the draw is by mount when ``data/gold/groups.json`` names them: a
mount is drawn with all its frames. Frame-wise draws would call the interval narrower than it is.
Pure Python on purpose (the gate's tests run without numpy); only ``per_frame`` needs the
evaluation code, and imports it when called.
"""
from __future__ import annotations
import json
import random
from pathlib import Path
from typing import Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
Counts = Tuple[float, float, float, float] # tp, fp, fn, sum of matched mask IoU
FrameCounts = Dict[int, Counts] # class id -> counts, for one frame
def load_groups(gold_root) -> Dict[str, Dict[str, str]]:
"""``data/gold/groups.json`` (tools/gold_groups.py): ``mount`` = frame -> mount id (a frame not
listed is its own mount), ``light`` = frame -> state for the frames whose light is not usable,
``excluded`` = frame -> why it is left out of every measurement. Empty when the file is missing."""
p = Path(gold_root) / "groups.json"
g = json.loads(p.read_text(encoding="utf-8")) if p.exists() else {}
return {"mount": dict(g.get("mount") or {}), "light": dict(g.get("light") or {}), "excluded": dict(g.get("excluded") or {})}
def per_frame(pred_dir, gold_dir, iou_thr: float = 0.5, stems: Optional[Iterable[str]] = None) -> Dict[str, FrameCounts]:
"""Per gold frame and class: tp, fp, fn and the summed mask IoU of the matches.
``stems`` restricts the frames; a gold frame without a prediction file is left out
(the caller pairs on the frames both models have)."""
from src.eval.metrics import evaluate_image
from src.eval.yolo_labels import read_label_file
pred_dir, gold_dir = Path(pred_dir), Path(gold_dir)
wanted = set(stems) if stems is not None else None
out: Dict[str, FrameCounts] = {}
for g in sorted(gold_dir.glob("*.txt")):
if wanted is not None and g.stem not in wanted:
continue
p = pred_dir / g.name
if not p.exists():
continue
frame: FrameCounts = {}
for c, cc in evaluate_image(read_label_file(g), read_label_file(p), iou_thr).items():
frame[int(c)] = (float(cc.tp), float(cc.fp), float(cc.fn), float(sum(cc.mask_ious)))
out[g.stem] = frame
return out
def _total(frames: Sequence[FrameCounts], class_id: Optional[int]) -> Counts:
tp = fp = fn = iou = 0.0
for f in frames:
for c, (a, b, d, e) in f.items():
if class_id is None or c == class_id:
tp += a; fp += b; fn += d; iou += e
return tp, fp, fn, iou
def f1_of(c: Counts) -> float:
tp, fp, fn, _ = c
return 2 * tp / (2 * tp + fp + fn) if tp else 0.0
def mask_score_of(c: Counts) -> float:
"""Mean mask IoU over every gold object, a missed object counting as zero."""
tp, _, fn, iou = c
return iou / (tp + fn) if (tp + fn) else 0.0
def _summary(point: float, deltas: List[float]) -> Dict[str, float]:
d = sorted(deltas)
n = len(d)
lo, hi = d[int(0.025 * (n - 1))], d[int(0.975 * (n - 1))]
return {"delta": round(point, 4), "lo": round(lo, 4), "hi": round(hi, 4),
"win": round(sum(1 for x in d if x > 0) / n, 3)}
def paired_bootstrap(cand: Mapping[str, FrameCounts], base: Mapping[str, FrameCounts], *, draws: int = 2000,
seed: int = 0, classes: Optional[Mapping[str, int]] = None,
groups: Optional[Mapping[str, str]] = None) -> Dict:
"""Candidate minus baseline on the frames both have: point difference, 95 % interval and win share
for F1 and the mask score, overall and for each class in ``classes`` (name -> class id).
``groups`` (frame -> mount id) makes the draw by mount: the frames of a mount come and go together."""
keys = sorted(set(cand) & set(base))
n = len(keys)
if n == 0:
return {"frames": 0, "draws": 0}
a = [cand[k] for k in keys]; b = [base[k] for k in keys]
members: Dict[str, List[int]] = {}
for i, k in enumerate(keys):
members.setdefault((groups or {}).get(k) or f"frame:{k}", []).append(i)
units = [members[g] for g in sorted(members)]
targets: List[Tuple[str, Optional[int]]] = [("overall", None)] + [(name, cid) for name, cid in (classes or {}).items()]
point = {}
for name, cid in targets:
ca, cb = _total(a, cid), _total(b, cid)
point[name] = (f1_of(ca) - f1_of(cb), mask_score_of(ca) - mask_score_of(cb))
rng = random.Random(seed)
d_f1 = {name: [] for name, _ in targets}; d_ms = {name: [] for name, _ in targets}
for _ in range(draws):
idx = [i for _ in range(len(units)) for i in units[rng.randrange(len(units))]]
sa = [a[i] for i in idx]; sb = [b[i] for i in idx]
for name, cid in targets:
ca, cb = _total(sa, cid), _total(sb, cid)
d_f1[name].append(f1_of(ca) - f1_of(cb)); d_ms[name].append(mask_score_of(ca) - mask_score_of(cb))
out = {"frames": n, "mounts": len(units), "draws": draws,
"f1": _summary(point["overall"][0], d_f1["overall"]),
"mask_score": _summary(point["overall"][1], d_ms["overall"])}
if classes:
out["per_class"] = {name: {"f1": _summary(point[name][0], d_f1[name]), "mask_score": _summary(point[name][1], d_ms[name])}
for name in classes}
return out