Files
AareLC_train/tools/gate_stats.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

151 lines
7.3 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 kept_outlines_known(gold_root) -> bool:
"""True when ``kept_machine.json`` exists: the evaluation then ignores a model's own outlines
object by object, and the frame-level exclusion of ``without_own_proposals`` is not needed."""
return (Path(gold_root) / "kept_machine.json").exists()
def load_prelabel(gold_root) -> Dict[str, str]:
"""``data/gold/prelabel.json`` (tools/gold_prelabel.py): frame -> version of the model that
proposed its labels before a reviewer saw them. Empty when the file is missing."""
p = Path(gold_root) / "prelabel.json"
if not p.exists():
return {}
return dict(json.loads(p.read_text(encoding="utf-8")).get("frames") or {})
def without_own_proposals(frames: Dict[str, FrameCounts], version: Optional[str],
prelabel: Mapping[str, str]) -> Dict[str, FrameCounts]:
"""The frames a model did not pre-label. Where a reviewer kept an outline the model proposed,
the gold holds the model's own mask, which overlaps itself perfectly: on the 50 frames v4 had
pre-labelled it scored a matched IoU of 0.959 against 0.905 elsewhere, and every other model
0.93. Such a frame says nothing about that model and is left out of its measure."""
if not version or not prelabel:
return frames
return {s: f for s, f in frames.items() if prelabel.get(s) != version}
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_kept_outlines, read_label_file
pred_dir, gold_dir = Path(pred_dir), Path(gold_dir)
wanted = set(stems) if stems is not None else None
kept = read_kept_outlines(gold_dir.parent) # a model's own outlines are not judged
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, ignore=kept.get(g.stem, ())).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