CI / ledger-and-tools (push) Successful in 19s
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
151 lines
7.3 KiB
Python
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
|