CI / ledger-and-tools (push) Successful in 19s
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>
385 lines
19 KiB
Python
385 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""Train one ledger run end to end: train → registry → gold measure → gate → promote.
|
|
|
|
python tools/ledger.py new --dataset dataset/2026-10-02_alc --tag alc --base yolo26n-seg.pt
|
|
python tools/train_run.py 2026-10-02_alc --device 0 2>&1 | tee runs/2026-10-02_alc/train.log
|
|
|
|
Every retraining starts from the base weights on the whole dataset (the cumulative
|
|
labelled pool), so runs stay comparable. The gate compares the gold measure with the
|
|
deployed model's own gold report (``models/registry/<active>/gold_report.json``):
|
|
a pass needs no drop beyond 0.005 in overall F1 and in the mask score (mean mask IoU
|
|
over all gold objects, unmatched = 0), an improvement in at least one of them, and no
|
|
drop beyond 0.02 in F1 for loop_all or pin. On a pass the
|
|
run is promoted on its own: ``models/active/production.json`` is rewritten. The
|
|
first run, with no baseline, only records its report as the future baseline.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import shutil
|
|
import socket
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from tools import ledger # noqa: E402
|
|
|
|
# Recipe since 2026-09-29: no horizontal flip (the camera never shows the pin on the left) and
|
|
# full-resolution masks (mask_ratio 1), the combination that kept run A's detection with the
|
|
# tightest masks; before that fliplr=0.5 and mask_ratio=4, as in the legacy train_yolo_seg.py.
|
|
AUGMENT = dict(scale=0.7, translate=0.15, mosaic=0.5, close_mosaic=10, fliplr=0.0, flipud=0.3,
|
|
shear=5.0, perspective=0.0, mask_ratio=1)
|
|
GATE_TOL = 0.005
|
|
CLASS_TOL = 0.02
|
|
# Since 2026-10-02 the gate knows how noisy the gold set is: when the baseline's predictions are
|
|
# at hand, a paired bootstrap over the gold frames decides. The candidate must beat the baseline
|
|
# on F1 or on the mask score in WIN_CLEAR of the draws, and must not lose the other one in more
|
|
# than 1 - WIN_FLOOR of them. The 0.005 point rule stays as the fallback and is always reported.
|
|
WIN_CLEAR = 0.90
|
|
WIN_FLOOR = 0.10
|
|
GUARDED_CLASSES = ("loop_all", "pin")
|
|
|
|
|
|
def dataset_dir(run: Dict[str, Any]) -> Path:
|
|
name = str(run["dataset"]).split("/", 1)[-1]
|
|
d = ROOT / "data" / "datasets" / name
|
|
if not (d / "dataset.yaml").exists():
|
|
raise SystemExit(f"dataset {run['dataset']} not found at {d} (git checkout the tag, git lfs pull)")
|
|
return d
|
|
|
|
|
|
def resolved_yaml(run_dir: Path, ds: Path) -> Path:
|
|
text = (ds / "dataset.yaml").read_text(encoding="utf-8").replace("path: .", f"path: {ds.resolve()}", 1)
|
|
out = run_dir / "dataset.resolved.yaml"
|
|
out.write_text(text, encoding="utf-8")
|
|
return out
|
|
|
|
|
|
def base_weights_path(spec: str) -> str:
|
|
p = Path(spec)
|
|
if p.exists():
|
|
return str(p)
|
|
cand = ROOT / "models" / "base" / spec
|
|
return str(cand) if cand.exists() else spec # else an Ultralytics model name, downloaded
|
|
|
|
|
|
def results_summary(train_dir: Path) -> Dict[str, Any]:
|
|
csv_path = train_dir / "results.csv"
|
|
if not csv_path.exists():
|
|
return {}
|
|
rows = list(csv.DictReader(csv_path.open(encoding="utf-8")))
|
|
if not rows:
|
|
return {}
|
|
last = {k.strip(): v for k, v in rows[-1].items()}
|
|
out: Dict[str, Any] = {"epochs_run": len(rows)}
|
|
for k, v in last.items():
|
|
if "metrics/" in k:
|
|
try:
|
|
out[k.replace("metrics/", "")] = float(v)
|
|
except ValueError:
|
|
pass
|
|
return out
|
|
|
|
|
|
def measure(weights: Path, run_dir: Path, names: list, conf: float = 0.25) -> Dict[str, Any]:
|
|
from src.eval.evaluate import evaluate_dirs
|
|
from src.eval.yolo_labels import PolygonInstance, write_label_file
|
|
from ultralytics import YOLO
|
|
|
|
gold = ROOT / "data" / "gold"
|
|
img_dir, gold_labels = gold / "images", gold / "labels"
|
|
if not img_dir.exists() or not gold_labels.exists():
|
|
raise SystemExit("no gold set under data/gold (import it with tools/import_studio_export.py --gold)")
|
|
pred_dir = run_dir / "gold_predictions"
|
|
pred_dir.mkdir(parents=True, exist_ok=True)
|
|
model = YOLO(str(weights))
|
|
for img_path in sorted(p for p in img_dir.iterdir() if p.suffix.lower() in (".jpg", ".jpeg", ".png")):
|
|
res = model.predict(str(img_path), conf=conf, verbose=False)[0]
|
|
insts = []
|
|
if res.masks is not None:
|
|
h, w = res.orig_shape
|
|
for poly, cls in zip(res.masks.xy, res.boxes.cls.tolist()):
|
|
pts = [(float(x) / w, float(y) / h) for x, y in poly]
|
|
if len(pts) >= 3:
|
|
insts.append(PolygonInstance(int(cls), pts))
|
|
write_label_file(pred_dir / f"{img_path.stem}.txt", insts)
|
|
return evaluate_dirs(pred_dir, gold_labels, names=names)
|
|
|
|
|
|
def mask_score(overall: Dict[str, Any]) -> float:
|
|
"""Mean mask IoU over every gold object, an unmatched object counting as 0.
|
|
|
|
``mean_mask_iou`` alone is averaged over matched objects only, so a model that
|
|
finds only the easy objects scores higher than one that also finds the hard ones
|
|
with looser masks; run 1 on 2026-09-28 lost the gate exactly that way. Weighting
|
|
by recall removes that bias.
|
|
"""
|
|
tp, fn = float(overall.get("tp", 0)), float(overall.get("fn", 0))
|
|
return float(overall.get("mean_mask_iou", 0.0)) * (tp / (tp + fn) if tp + fn else 0.0)
|
|
|
|
|
|
def gate(report: Dict[str, Any], baseline: Optional[Path], paired: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
|
if not baseline or not Path(baseline).exists():
|
|
return None
|
|
base = json.loads(Path(baseline).read_text(encoding="utf-8"))
|
|
f1_delta = float(report["overall"]["f1"]) - float(base["overall"]["f1"])
|
|
iou_delta = mask_score(report["overall"]) - mask_score(base["overall"])
|
|
per_class = report.get("per_class") or report.get("classes") or {}
|
|
base_classes = base.get("per_class") or base.get("classes") or {}
|
|
class_deltas = {}
|
|
for c in GUARDED_CLASSES:
|
|
a, b = per_class.get(c) or {}, base_classes.get(c) or {}
|
|
if "f1" in a and "f1" in b:
|
|
class_deltas[c] = float(a["f1"]) - float(b["f1"])
|
|
guarded = all(d >= -CLASS_TOL for d in class_deltas.values())
|
|
ok_point = f1_delta >= -GATE_TOL and iou_delta >= -GATE_TOL and (f1_delta > 0 or iou_delta > 0) and guarded
|
|
out = {"baseline": str(baseline), "f1_delta": round(f1_delta, 4), "mask_iou_delta": round(iou_delta, 4),
|
|
"mask_score": round(mask_score(report["overall"]), 4), "mask_score_baseline": round(mask_score(base["overall"]), 4),
|
|
"matched_only_iou_delta": round(float(report["overall"]["mean_mask_iou"]) - float(base["overall"]["mean_mask_iou"]), 4),
|
|
"class_f1_delta": {k: round(v, 4) for k, v in class_deltas.items()}, "pass_point_rule": bool(ok_point)}
|
|
if paired and paired.get("frames"):
|
|
f, m = paired["f1"], paired["mask_score"]
|
|
improved = f["win"] >= WIN_CLEAR or m["win"] >= WIN_CLEAR
|
|
not_worse = f["win"] >= WIN_FLOOR and m["win"] >= WIN_FLOOR
|
|
out.update({"rule": "paired bootstrap", "bootstrap": paired, "pass": bool(improved and not_worse and guarded)})
|
|
else:
|
|
out.update({"rule": "point", "pass": bool(ok_point)})
|
|
return out
|
|
|
|
|
|
def environment() -> Dict[str, Any]:
|
|
"""What a measurement depends on besides the weights: the library versions decide how masks are
|
|
decoded and filtered, so two machines can score the same weights differently."""
|
|
env: Dict[str, Any] = {"host": socket.gethostname()}
|
|
for mod in ("ultralytics", "torch"):
|
|
try:
|
|
env[mod] = __import__(mod).__version__
|
|
except Exception:
|
|
env[mod] = None
|
|
return env
|
|
|
|
|
|
def same_environment(report_env: Optional[Dict[str, Any]], here: Dict[str, Any]) -> bool:
|
|
"""Was a stored report measured with the libraries of this machine? Reports from before
|
|
2026-10-02 carry no environment: they were all measured on the Spark."""
|
|
if not report_env:
|
|
return str(here.get("host", "")).startswith("mx-spark")
|
|
return all(report_env.get(k) == here.get(k) for k in ("ultralytics", "torch"))
|
|
|
|
|
|
def baseline_here(baseline: Optional[Path], names: list, conf: float) -> tuple:
|
|
"""The baseline report to gate against, measured in THIS environment.
|
|
|
|
On 2026-10-02 a cluster run was gated against a baseline measured on the Spark: the same
|
|
weights scored F1 0.840 and mask score 0.706 on the cluster, 0.861 and 0.757 on the Spark,
|
|
and the gate failed a model that passes it in a single environment. So when the stored
|
|
baseline report comes from other library versions, the baseline weights are measured again
|
|
here (predictions kept under runs/_measure_<version>, where the paired bootstrap finds
|
|
them). Returns (report path or None, note or None); a baseline that cannot be measured here
|
|
gives (None, why) and the caller refuses to promote.
|
|
"""
|
|
if not baseline or not Path(baseline).exists():
|
|
return baseline, None
|
|
base = json.loads(Path(baseline).read_text(encoding="utf-8"))
|
|
here = environment()
|
|
if same_environment(base.get("environment"), here):
|
|
return Path(baseline), None
|
|
version = Path(baseline).parent.name
|
|
weights = Path(baseline).parent / "weights.pt"
|
|
if not weights.exists() or weights.stat().st_size < 1_000_000: # absent, or a git-lfs pointer
|
|
return None, (f"the baseline {version} was measured with {base.get('environment') or 'the Spark environment'}, this machine has "
|
|
f"{here}; its weights are not here to measure again (git lfs pull -I 'models/registry/{version}/weights.pt')")
|
|
work = ROOT / "runs" / f"_measure_{version}"
|
|
work.mkdir(parents=True, exist_ok=True)
|
|
rep = measure(weights, work, names, conf=conf)
|
|
rep["environment"] = here
|
|
local = work / "gold_report.local.json"
|
|
local.write_text(json.dumps(rep, indent=2) + "\n", encoding="utf-8")
|
|
print(f"gate: baseline {version} measured again in this environment (ultralytics {here.get('ultralytics')}, torch {here.get('torch')}): "
|
|
f"F1 {rep['overall']['f1']:.4f} (stored report: {base['overall']['f1']:.4f})")
|
|
return local, f"baseline measured again here; stored report came from {base.get('environment') or 'the Spark'}"
|
|
|
|
|
|
def baseline_predictions(baseline: Optional[Path]) -> Optional[Path]:
|
|
"""The baseline model's predictions on the gold frames, when they were kept: the re-measure
|
|
directory of its version, or the gold predictions of the run that produced it."""
|
|
if not baseline:
|
|
return None
|
|
if Path(baseline).name == "gold_report.local.json": # measured again here: predictions sit beside it
|
|
d = Path(baseline).parent / "gold_predictions"
|
|
return d if d.is_dir() else None
|
|
version = Path(baseline).parent.name
|
|
d = ROOT / "runs" / f"_measure_{version}" / "gold_predictions"
|
|
if d.is_dir():
|
|
return d
|
|
for rj in sorted((ROOT / "runs").glob("*/run.json")):
|
|
try:
|
|
if json.loads(rj.read_text(encoding="utf-8")).get("version") == version and (rj.parent / "gold_predictions").is_dir():
|
|
return rj.parent / "gold_predictions"
|
|
except (OSError, ValueError):
|
|
continue
|
|
return None
|
|
|
|
|
|
def paired_with_baseline(run_dir: Path, baseline: Optional[Path]) -> Optional[Dict[str, Any]]:
|
|
"""Paired bootstrap of this run against the baseline on the gold frames both were measured on."""
|
|
base_pred = baseline_predictions(baseline)
|
|
if base_pred is None:
|
|
return None
|
|
from tools import gate_stats
|
|
gold = ROOT / "data" / "gold" / "labels"
|
|
cand = gate_stats.per_frame(run_dir / "gold_predictions", gold)
|
|
base = gate_stats.per_frame(base_pred, gold)
|
|
missing = len(cand) - len(set(cand) & set(base))
|
|
if missing:
|
|
print(f"gate: the baseline has no prediction for {missing} gold frames (gold grew since it was measured); pairing on the rest")
|
|
return gate_stats.paired_bootstrap(cand, base, groups=gate_stats.load_groups(gold.parent)["mount"])
|
|
|
|
|
|
def active_version() -> Optional[str]:
|
|
p = ROOT / "models" / "active" / "production.json"
|
|
if not p.exists():
|
|
return None
|
|
return json.loads(p.read_text(encoding="utf-8")).get("version")
|
|
|
|
|
|
def promote(version: str, run_id: str, g: Dict[str, Any]) -> Path:
|
|
p = ROOT / "models" / "active" / "production.json"
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_text(json.dumps({
|
|
"version": version,
|
|
"export": "weights.pt", # engine built on the target GPU at deploy time
|
|
"promoted_on": datetime.now().strftime("%Y-%m-%d"),
|
|
"promoted_by": "gate",
|
|
"run": run_id,
|
|
"gate": g,
|
|
}, indent=2) + "\n", encoding="utf-8")
|
|
return p
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("run_id")
|
|
ap.add_argument("--epochs", type=int, default=400)
|
|
ap.add_argument("--patience", type=int, default=50)
|
|
ap.add_argument("--imgsz", type=int, default=640)
|
|
ap.add_argument("--batch", type=int, default=16)
|
|
ap.add_argument("--device", default="0")
|
|
ap.add_argument("--workers", type=int, default=8)
|
|
ap.add_argument("--conf", type=float, default=0.25, help="confidence used for the gold measure")
|
|
ap.add_argument("--baseline", default=None, help="gold report to gate against (default: the active model's)")
|
|
ap.add_argument("--no-promote", action="store_true", help="gate, but leave production.json alone")
|
|
ap.add_argument("--skip-train", action="store_true", help="reuse runs/<id>/ultralytics/train/weights/best.pt")
|
|
ap.add_argument("--force", action="store_true", help="train again a run that already has a gate verdict")
|
|
ap.add_argument("--set", nargs="*", default=[], metavar="KEY=VALUE",
|
|
help="override any Ultralytics train argument, e.g. fliplr=0 mask_ratio=1 overlap_mask=true; recorded in the ledger")
|
|
args = ap.parse_args(argv)
|
|
|
|
run = ledger.load(args.run_id)
|
|
run_dir = ledger.run_path(args.run_id).parent
|
|
# A gate verdict is an outcome, not a crash: the exit code 2 of a failed gate must not make a
|
|
# wrapper's retry train the whole run a second time (cycle 4 lost two hours that way).
|
|
if run.get("status") in ("gate_failed", "gate_passed", "promoted", "deployed") and not (args.skip_train or args.force):
|
|
print(f"{args.run_id} is already {run['status']}: not training it again (--force to retrain, --skip-train to re-measure)")
|
|
return 0
|
|
ds = dataset_dir(run)
|
|
data_yaml = resolved_yaml(run_dir, ds)
|
|
names = [v for _, v in sorted(json.loads((ds / "source.json").read_text())["classes"].items(), key=lambda kv: int(kv[0]))] \
|
|
if (ds / "source.json").exists() else ["loop_all", "pin", "crystal", "loop_face", "ice", "needle"]
|
|
# A run may carry its own training arguments (ledger new --kv train_args=...): the cluster
|
|
# job passes no --set, so they come from the ledger; a --set on the command line still wins.
|
|
overrides: Dict[str, Any] = dict(run.get("train_args") or {})
|
|
for kv in args.set or []:
|
|
if "=" not in kv:
|
|
raise SystemExit(f"--set expects KEY=VALUE, got {kv!r}")
|
|
k, v = kv.split("=", 1)
|
|
try:
|
|
overrides[k] = json.loads(v.lower() if v.lower() in ("true", "false") else v)
|
|
except json.JSONDecodeError:
|
|
overrides[k] = v
|
|
hyper = {"epochs": args.epochs, "patience": args.patience, "imgsz": args.imgsz, "batch": args.batch,
|
|
"device": args.device, "overlap_mask": False, **AUGMENT, **overrides}
|
|
ledger.set_status(args.run_id, "training", note="train_run.py", kv={
|
|
"hyperparams": hyper, "host": socket.gethostname(), "started": datetime.now().isoformat(timespec="seconds")})
|
|
|
|
best = run_dir / "ultralytics" / "train" / "weights" / "best.pt"
|
|
if not args.skip_train:
|
|
from ultralytics import YOLO
|
|
model = YOLO(base_weights_path(run["base_weights"]))
|
|
train_kwargs = dict(epochs=args.epochs, patience=args.patience, imgsz=args.imgsz, batch=args.batch,
|
|
device=args.device, workers=args.workers, overlap_mask=False, amp=False, **AUGMENT)
|
|
train_kwargs.update(overrides) # --set wins over the recipe
|
|
model.train(data=str(data_yaml), project=str(run_dir / "ultralytics"), name="train", exist_ok=True, **train_kwargs)
|
|
if not best.exists():
|
|
ledger.set_status(args.run_id, "aborted", note=f"no best.pt at {best}")
|
|
return 1
|
|
|
|
family = Path(run["base_weights"]).stem.split("-")[0]
|
|
tag = args.run_id.split("_", 1)[-1]
|
|
version = f"{family}-seg-{tag}-{datetime.now().strftime('%Y-%m-%d')}"
|
|
reg = ROOT / "models" / "registry" / version
|
|
reg.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(best, reg / "weights.pt")
|
|
|
|
report = measure(reg / "weights.pt", run_dir, names, conf=args.conf)
|
|
report["environment"] = environment()
|
|
baseline = Path(args.baseline) if args.baseline else None
|
|
if baseline is None:
|
|
av = active_version()
|
|
if av and (ROOT / "models" / "registry" / av / "gold_report.json").exists():
|
|
baseline = ROOT / "models" / "registry" / av / "gold_report.json"
|
|
stored_baseline = baseline
|
|
baseline, env_note = baseline_here(baseline, names, args.conf)
|
|
paired = None
|
|
try:
|
|
paired = paired_with_baseline(run_dir, baseline)
|
|
except Exception as e: # the point rule still stands
|
|
print(f"gate: paired bootstrap unavailable ({e}); falling back to the point rule")
|
|
g = gate(report, baseline, paired)
|
|
if g is not None:
|
|
g["baseline"] = str(stored_baseline)
|
|
if env_note:
|
|
g["environment_note"] = env_note
|
|
elif stored_baseline is not None and env_note:
|
|
# there is a baseline, but not one this machine can be compared with: no verdict, no promotion
|
|
g = {"baseline": str(stored_baseline), "pass": False, "rule": "none", "environment_note": env_note}
|
|
print(f"gate: no verdict: {env_note}")
|
|
report["gate"] = g
|
|
(reg / "gold_report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
|
metrics = {"overall": report["overall"], "gate": g, "training": results_summary(run_dir / "ultralytics" / "train"),
|
|
"version": version, "measured_at": datetime.now().isoformat(timespec="seconds")}
|
|
(run_dir / "metrics.json").write_text(json.dumps(metrics, indent=2) + "\n", encoding="utf-8")
|
|
(reg / "metadata.json").write_text(json.dumps({
|
|
"model_id": version, "family": family, "task": "segmentation",
|
|
"training_date": datetime.now().strftime("%Y-%m-%d"), "run": args.run_id,
|
|
"dataset": run["dataset"], "base_weights": run["base_weights"], "hyperparams": hyper,
|
|
"export_formats": ["pt"], "metrics": metrics["overall"], "gate": g,
|
|
}, indent=2) + "\n", encoding="utf-8")
|
|
ledger.record_metrics(args.run_id, run_dir / "metrics.json")
|
|
ledger.set_status(args.run_id, "measured", kv={"version": version})
|
|
|
|
if g is None:
|
|
print(f"measured; no baseline, {version} is the baseline for the next run")
|
|
return 0
|
|
if g["pass"]:
|
|
ledger.set_status(args.run_id, "gate_passed", note=json.dumps(g))
|
|
if not args.no_promote:
|
|
promote(version, args.run_id, g)
|
|
ledger.set_status(args.run_id, "promoted", note="by the gate")
|
|
print(f"PROMOTED {version}: {g}")
|
|
return 0
|
|
ledger.set_status(args.run_id, "gate_failed", note=json.dumps(g))
|
|
print(f"gate failed: {g}")
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|