Add eval.py
Browse files
eval.py
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Score clock-time predictions against hand labels. Error is in MINUTES.
|
| 3 |
+
|
| 4 |
+
./eval.py --labels clockface-real/labels.jsonl --pred preds.jsonl
|
| 5 |
+
|
| 6 |
+
Labels (JSONL, one object per line, written by tools/label_server.py):
|
| 7 |
+
{"id": "cf_0001", "time": "3:47", "unsure": false, "unreadable": false}
|
| 8 |
+
|
| 9 |
+
Predictions (JSONL):
|
| 10 |
+
{"id": "cf_0001", "time": "3:45"}
|
| 11 |
+
{"id": "cf_0001", "minutes": 225.0, "agreement_minutes": 1.2}
|
| 12 |
+
|
| 13 |
+
Rules this script enforces, because they are the ones that quietly go wrong:
|
| 14 |
+
* Every label must have a prediction. A missing prediction is a failure,
|
| 15 |
+
never a silently dropped row. Use --allow-missing to score anyway; the
|
| 16 |
+
headline then counts each missing item at the worst possible error (360).
|
| 17 |
+
* Items labelled `unreadable` are excluded and the count is printed.
|
| 18 |
+
* Items labelled `unsure` are included by default and also reported alone,
|
| 19 |
+
so you can see whether the label noise is carrying the result.
|
| 20 |
+
* Synthetic or fixture items (source != "real") are excluded from the
|
| 21 |
+
headline unless --allow-synthetic. Numbers in the model card come from
|
| 22 |
+
real photos.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import argparse
|
| 28 |
+
import json
|
| 29 |
+
import math
|
| 30 |
+
import sys
|
| 31 |
+
|
| 32 |
+
import clocktime as ct
|
| 33 |
+
import version as ver
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ---------------------------------------------------------------- loading
|
| 37 |
+
|
| 38 |
+
def read_jsonl(path):
|
| 39 |
+
rows = []
|
| 40 |
+
with open(path) as fh:
|
| 41 |
+
for lineno, line in enumerate(fh, 1):
|
| 42 |
+
line = line.strip()
|
| 43 |
+
if not line or line.startswith("#"):
|
| 44 |
+
continue
|
| 45 |
+
try:
|
| 46 |
+
rows.append(json.loads(line))
|
| 47 |
+
except json.JSONDecodeError as exc:
|
| 48 |
+
raise SystemExit(f"{path}:{lineno}: bad JSON: {exc}")
|
| 49 |
+
return rows
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def record_minutes(rec, path, what):
|
| 53 |
+
"""Pull a face position out of a label or prediction record."""
|
| 54 |
+
if "minutes" in rec and rec["minutes"] is not None:
|
| 55 |
+
return ct.to_minutes(0, float(rec["minutes"]))
|
| 56 |
+
if "time" in rec and rec["time"]:
|
| 57 |
+
return ct.parse(str(rec["time"]))
|
| 58 |
+
if "hour" in rec and "minute" in rec:
|
| 59 |
+
return ct.to_minutes(float(rec["hour"]), float(rec["minute"]))
|
| 60 |
+
raise SystemExit(f"{path}: {what} {rec.get('id')!r} has no time/minutes/hour+minute")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def load_labels(path, allow_synthetic=False, allow_sources=None):
|
| 64 |
+
labels, unreadable, nonreal, seen = {}, [], [], set()
|
| 65 |
+
for rec in read_jsonl(path):
|
| 66 |
+
rid = rec.get("id")
|
| 67 |
+
if not rid:
|
| 68 |
+
raise SystemExit(f"{path}: label with no id: {rec}")
|
| 69 |
+
if rid in seen:
|
| 70 |
+
raise SystemExit(f"{path}: duplicate label id {rid!r}")
|
| 71 |
+
seen.add(rid)
|
| 72 |
+
if rec.get("unreadable"):
|
| 73 |
+
if rec.get("time"):
|
| 74 |
+
print(f"warning: {rid} is marked unreadable but carries the time "
|
| 75 |
+
f"{rec['time']!r}; excluding it. Re-label it.", file=sys.stderr)
|
| 76 |
+
unreadable.append(rid)
|
| 77 |
+
continue
|
| 78 |
+
src = rec.get("source", "real")
|
| 79 |
+
ok = (src == "real") or allow_synthetic or any(
|
| 80 |
+
src.startswith(p) for p in (allow_sources or []))
|
| 81 |
+
if not ok:
|
| 82 |
+
nonreal.append(rid)
|
| 83 |
+
continue
|
| 84 |
+
labels[rid] = {
|
| 85 |
+
"minutes": record_minutes(rec, path, "label"),
|
| 86 |
+
"unsure": bool(rec.get("unsure")),
|
| 87 |
+
"source": rec.get("source", "real"),
|
| 88 |
+
}
|
| 89 |
+
return labels, unreadable, nonreal
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def load_preds(path):
|
| 93 |
+
preds, seen = {}, set()
|
| 94 |
+
for rec in read_jsonl(path):
|
| 95 |
+
rid = rec.get("id")
|
| 96 |
+
if not rid:
|
| 97 |
+
raise SystemExit(f"{path}: prediction with no id: {rec}")
|
| 98 |
+
if rid in seen:
|
| 99 |
+
raise SystemExit(f"{path}: duplicate prediction id {rid!r}")
|
| 100 |
+
seen.add(rid)
|
| 101 |
+
preds[rid] = {
|
| 102 |
+
"minutes": record_minutes(rec, path, "prediction"),
|
| 103 |
+
"agreement_minutes": rec.get("agreement_minutes"),
|
| 104 |
+
}
|
| 105 |
+
return preds
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# ---------------------------------------------------------------- scoring
|
| 109 |
+
|
| 110 |
+
def percentile(sorted_vals, q):
|
| 111 |
+
if not sorted_vals:
|
| 112 |
+
return float("nan")
|
| 113 |
+
if len(sorted_vals) == 1:
|
| 114 |
+
return sorted_vals[0]
|
| 115 |
+
pos = q / 100.0 * (len(sorted_vals) - 1)
|
| 116 |
+
lo = math.floor(pos)
|
| 117 |
+
hi = math.ceil(pos)
|
| 118 |
+
return sorted_vals[lo] + (sorted_vals[hi] - sorted_vals[lo]) * (pos - lo)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def summarise(errors):
|
| 122 |
+
"""Everything is in minutes. No degrees, no normalised anything."""
|
| 123 |
+
n = len(errors)
|
| 124 |
+
if n == 0:
|
| 125 |
+
return {"n": 0}
|
| 126 |
+
s = sorted(errors)
|
| 127 |
+
within = lambda t: sum(1 for e in errors if e <= t) / n
|
| 128 |
+
return {
|
| 129 |
+
"n": n,
|
| 130 |
+
"mae_minutes": sum(errors) / n,
|
| 131 |
+
"median_minutes": percentile(s, 50),
|
| 132 |
+
"p90_minutes": percentile(s, 90),
|
| 133 |
+
"max_minutes": s[-1],
|
| 134 |
+
"within_1min": within(1.0),
|
| 135 |
+
"within_3min": within(3.0),
|
| 136 |
+
"within_5min": within(5.0),
|
| 137 |
+
"within_10min": within(10.0),
|
| 138 |
+
"gross_fail_rate": sum(1 for e in errors if e > 30.0) / n,
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def evaluate(labels, preds, missing_error=ct.MAX_ERR, allow_missing=False):
|
| 143 |
+
items, missing = [], []
|
| 144 |
+
for rid, lab in labels.items():
|
| 145 |
+
p = preds.get(rid)
|
| 146 |
+
if p is None:
|
| 147 |
+
missing.append(rid)
|
| 148 |
+
if allow_missing:
|
| 149 |
+
items.append({
|
| 150 |
+
"id": rid, "label": lab["minutes"], "pred": None,
|
| 151 |
+
"error": missing_error, "unsure": lab["unsure"],
|
| 152 |
+
"agreement": None, "missing": True,
|
| 153 |
+
})
|
| 154 |
+
continue
|
| 155 |
+
err = ct.error_minutes(p["minutes"], lab["minutes"])
|
| 156 |
+
items.append({
|
| 157 |
+
"id": rid, "label": lab["minutes"], "pred": p["minutes"],
|
| 158 |
+
"error": err, "unsure": lab["unsure"],
|
| 159 |
+
"agreement": p["agreement_minutes"], "missing": False,
|
| 160 |
+
"error_if_hands_swapped": ct.error_minutes(ct.swapped(p["minutes"]), lab["minutes"]),
|
| 161 |
+
})
|
| 162 |
+
extra = sorted(set(preds) - set(labels))
|
| 163 |
+
return items, missing, extra
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def risk_coverage(items):
|
| 167 |
+
"""MAE when you keep only the most confident fraction of predictions.
|
| 168 |
+
|
| 169 |
+
Confidence is the model's hour/minute-hand disagreement in minutes: small
|
| 170 |
+
disagreement means the two hands tell the same story. A useful signal makes
|
| 171 |
+
this table fall as coverage drops.
|
| 172 |
+
"""
|
| 173 |
+
scored = [i for i in items if i.get("agreement") is not None and not i["missing"]]
|
| 174 |
+
if len(scored) < 4:
|
| 175 |
+
return None
|
| 176 |
+
scored.sort(key=lambda i: i["agreement"])
|
| 177 |
+
out = []
|
| 178 |
+
for cov in (1.0, 0.9, 0.75, 0.5, 0.25):
|
| 179 |
+
k = max(1, int(round(cov * len(scored))))
|
| 180 |
+
errs = [i["error"] for i in scored[:k]]
|
| 181 |
+
out.append({
|
| 182 |
+
"coverage": k / len(scored),
|
| 183 |
+
"n": k,
|
| 184 |
+
"mae_minutes": sum(errs) / k,
|
| 185 |
+
"within_5min": sum(1 for e in errs if e <= 5.0) / k,
|
| 186 |
+
})
|
| 187 |
+
return out
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ---------------------------------------------------------------- report
|
| 191 |
+
|
| 192 |
+
def pct(x):
|
| 193 |
+
return f"{100.0 * x:5.1f}%"
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def print_block(title, s):
|
| 197 |
+
print(f"\n{title}")
|
| 198 |
+
if s["n"] == 0:
|
| 199 |
+
print(" (no items)")
|
| 200 |
+
return
|
| 201 |
+
print(f" n {s['n']}")
|
| 202 |
+
print(f" MAE {s['mae_minutes']:7.2f} min")
|
| 203 |
+
print(f" median {s['median_minutes']:7.2f} min")
|
| 204 |
+
print(f" p90 {s['p90_minutes']:7.2f} min")
|
| 205 |
+
print(f" worst {s['max_minutes']:7.2f} min")
|
| 206 |
+
print(f" within 1 min {pct(s['within_1min'])}")
|
| 207 |
+
print(f" within 3 min {pct(s['within_3min'])}")
|
| 208 |
+
print(f" within 5 min {pct(s['within_5min'])}")
|
| 209 |
+
print(f" within 10 min {pct(s['within_10min'])}")
|
| 210 |
+
print(f" worse than 30 {pct(s['gross_fail_rate'])}")
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def main(argv=None):
|
| 214 |
+
ap = argparse.ArgumentParser(description=__doc__,
|
| 215 |
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 216 |
+
ap.add_argument("--labels", default="clockface-real/labels.jsonl")
|
| 217 |
+
ap.add_argument("--pred", help="predictions JSONL")
|
| 218 |
+
ap.add_argument("--allow-missing", action="store_true",
|
| 219 |
+
help="score anyway; missing predictions count as 360 min errors")
|
| 220 |
+
ap.add_argument("--allow-synthetic", action="store_true",
|
| 221 |
+
help="include items whose label source is not 'real'")
|
| 222 |
+
ap.add_argument("--allow-source", action="append", metavar="PREFIX",
|
| 223 |
+
help="also score labels whose source starts with PREFIX, e.g. "
|
| 224 |
+
"--allow-source hf: to score third-party real photos. The "
|
| 225 |
+
"report says which sources were included.")
|
| 226 |
+
ap.add_argument("--exclude-unsure", action="store_true")
|
| 227 |
+
ap.add_argument("--per-item", action="store_true", help="print every item, worst first")
|
| 228 |
+
ap.add_argument("--json", dest="json_out", help="write the full report here")
|
| 229 |
+
ap.add_argument("--synth", type=int,
|
| 230 |
+
help="how many synthetic images the model was trained on; "
|
| 231 |
+
"recorded in the provenance stamp")
|
| 232 |
+
ap.add_argument("--version", dest="version_str",
|
| 233 |
+
help="release version YYWWNN; defaults to the next one for this week")
|
| 234 |
+
ap.add_argument("--self-test", action="store_true", help="check the metric itself")
|
| 235 |
+
args = ap.parse_args(argv)
|
| 236 |
+
|
| 237 |
+
if args.self_test:
|
| 238 |
+
return self_test()
|
| 239 |
+
if not args.pred:
|
| 240 |
+
ap.error("--pred is required (or use --self-test)")
|
| 241 |
+
|
| 242 |
+
labels, unreadable, nonreal = load_labels(args.labels, args.allow_synthetic, args.allow_source)
|
| 243 |
+
preds = load_preds(args.pred)
|
| 244 |
+
if not labels:
|
| 245 |
+
why = f"{len(unreadable)} unreadable, {len(nonreal)} not marked source='real'"
|
| 246 |
+
raise SystemExit(
|
| 247 |
+
f"{args.labels}: no usable labels ({why}).\n"
|
| 248 |
+
f"Numbers in the model card come from real photos. Pass --allow-synthetic "
|
| 249 |
+
f"only when you are deliberately scoring something else.")
|
| 250 |
+
|
| 251 |
+
items, missing, extra = evaluate(labels, preds, allow_missing=args.allow_missing)
|
| 252 |
+
if missing and not args.allow_missing:
|
| 253 |
+
head = ", ".join(missing[:10]) + (" ..." if len(missing) > 10 else "")
|
| 254 |
+
raise SystemExit(
|
| 255 |
+
f"{len(missing)} of {len(labels)} labelled items have no prediction: {head}\n"
|
| 256 |
+
f"Fix the predictor, or pass --allow-missing to score them as failures.")
|
| 257 |
+
|
| 258 |
+
scored = items if not args.exclude_unsure else [i for i in items if not i["unsure"]]
|
| 259 |
+
errors = [i["error"] for i in scored]
|
| 260 |
+
|
| 261 |
+
provenance = ver.stamp(args.version_str, args.synth, len(scored))
|
| 262 |
+
print(provenance)
|
| 263 |
+
if "-dirty" in provenance:
|
| 264 |
+
print(" working tree is dirty: this number cannot be reproduced from a commit")
|
| 265 |
+
srcs = sorted({v["source"] for v in labels.values()})
|
| 266 |
+
print(f"labels {args.labels}")
|
| 267 |
+
if srcs != ["real"]:
|
| 268 |
+
print(f"SOURCES {', '.join(srcs)}")
|
| 269 |
+
print(" not the real test set: these are third-party labels, "
|
| 270 |
+
"reported separately and never as the headline number")
|
| 271 |
+
print(f"predictions {args.pred}")
|
| 272 |
+
print(f"scored {len(scored)} items"
|
| 273 |
+
f" (excluded: {len(unreadable)} unreadable, {len(nonreal)} non-real"
|
| 274 |
+
f"{', ' + str(len(items) - len(scored)) + ' unsure' if args.exclude_unsure else ''})")
|
| 275 |
+
if missing:
|
| 276 |
+
print(f"MISSING {len(missing)} predictions counted at {ct.MAX_ERR:.0f} min each")
|
| 277 |
+
if extra:
|
| 278 |
+
print(f"note {len(extra)} predictions have no label; ignored")
|
| 279 |
+
|
| 280 |
+
print_block("ALL SCORED ITEMS (this is the number that goes in the model card)",
|
| 281 |
+
summarise(errors))
|
| 282 |
+
|
| 283 |
+
confident = [i["error"] for i in scored if not i["unsure"]]
|
| 284 |
+
unsure = [i["error"] for i in scored if i["unsure"]]
|
| 285 |
+
if unsure and not args.exclude_unsure:
|
| 286 |
+
print_block(f"labels marked confident ({len(confident)})", summarise(confident))
|
| 287 |
+
print_block(f"labels marked unsure ({len(unsure)})", summarise(unsure))
|
| 288 |
+
|
| 289 |
+
rc = risk_coverage(items)
|
| 290 |
+
if rc:
|
| 291 |
+
print("\nHAND-AGREEMENT CONFIDENCE (keep only the most confident predictions)")
|
| 292 |
+
print(" coverage n MAE min within 5 min")
|
| 293 |
+
for r in rc:
|
| 294 |
+
print(f" {pct(r['coverage'])} {r['n']:5d} {r['mae_minutes']:8.2f} {pct(r['within_5min'])}")
|
| 295 |
+
|
| 296 |
+
bad = [i for i in scored if i["error"] > 30.0 and not i["missing"]]
|
| 297 |
+
swap_fixes = [i for i in bad if i.get("error_if_hands_swapped", 999) < i["error"] - 15]
|
| 298 |
+
if bad:
|
| 299 |
+
print(f"\nDIAGNOSTIC {len(bad)} items worse than 30 min; "
|
| 300 |
+
f"{len(swap_fixes)} of those would improve by swapping the hands")
|
| 301 |
+
|
| 302 |
+
if args.per_item:
|
| 303 |
+
print("\nPER ITEM (worst first)")
|
| 304 |
+
for i in sorted(scored, key=lambda i: -i["error"]):
|
| 305 |
+
p = "MISSING" if i["missing"] else ct.fmt(i["pred"])
|
| 306 |
+
flag = " unsure" if i["unsure"] else ""
|
| 307 |
+
print(f" {i['id']:<12} label {ct.fmt(i['label']):>6} pred {p:>7}"
|
| 308 |
+
f" err {i['error']:7.2f} min{flag}")
|
| 309 |
+
|
| 310 |
+
if args.json_out:
|
| 311 |
+
report = {
|
| 312 |
+
"provenance": provenance,
|
| 313 |
+
"version": args.version_str or ver.next_version(),
|
| 314 |
+
"code": ver.code_hash(),
|
| 315 |
+
"synth_images": args.synth,
|
| 316 |
+
"labels_path": args.labels, "pred_path": args.pred,
|
| 317 |
+
"n_labels": len(labels), "n_scored": len(scored),
|
| 318 |
+
"n_unreadable_excluded": len(unreadable), "n_nonreal_excluded": len(nonreal),
|
| 319 |
+
"n_missing_predictions": len(missing),
|
| 320 |
+
"headline": summarise(errors),
|
| 321 |
+
"confident_only": summarise(confident),
|
| 322 |
+
"unsure_only": summarise(unsure),
|
| 323 |
+
"risk_coverage": rc,
|
| 324 |
+
"items": scored,
|
| 325 |
+
}
|
| 326 |
+
with open(args.json_out, "w") as fh:
|
| 327 |
+
json.dump(report, fh, indent=2)
|
| 328 |
+
print(f"\nwrote {args.json_out}")
|
| 329 |
+
return 0
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
# ---------------------------------------------------------------- self-test
|
| 333 |
+
|
| 334 |
+
def self_test():
|
| 335 |
+
"""Assertions on the metric. Run this whenever clocktime.py changes."""
|
| 336 |
+
checks = []
|
| 337 |
+
|
| 338 |
+
def check(desc, got, want, tol=1e-6):
|
| 339 |
+
ok = abs(got - want) <= tol
|
| 340 |
+
checks.append(ok)
|
| 341 |
+
print(f" {'ok ' if ok else 'FAIL'} {desc:<52} got {got:8.3f} want {want:8.3f}")
|
| 342 |
+
|
| 343 |
+
e = lambda a, b: ct.error_minutes(ct.parse(a), ct.parse(b))
|
| 344 |
+
print("metric self-test (all values in minutes)")
|
| 345 |
+
check("identical times", e("3:47", "3:47"), 0)
|
| 346 |
+
check("one minute apart", e("3:47", "3:48"), 1)
|
| 347 |
+
check("across the 12 seam 11:58 vs 12:02", e("11:58", "12:02"), 4)
|
| 348 |
+
check("across the 12 seam 12:02 vs 11:58", e("12:02", "11:58"), 4)
|
| 349 |
+
check("opposite sides of the face", e("12:00", "6:00"), 360)
|
| 350 |
+
check("never exceeds 360", e("12:00", "6:01"), 359)
|
| 351 |
+
check("24h clock reads the same face", e("15:47", "3:47"), 0)
|
| 352 |
+
check("midnight is noon on a face", e("00:00", "12:00"), 0)
|
| 353 |
+
check("wrong hour, right minute", e("4:15", "3:15"), 60)
|
| 354 |
+
check("bare digits parse", e("347", "3:47"), 0)
|
| 355 |
+
check("bare digits parse 4-digit", e("1215", "12:15"), 0)
|
| 356 |
+
check("hand swap 3:00 -> 12:15", ct.error_minutes(ct.swapped(ct.parse("3:00")), ct.parse("12:15")), 0)
|
| 357 |
+
# Swapping is not an involution: at 12:15 the hour hand is 15 face-minutes
|
| 358 |
+
# past 12, which read as a minute hand is 15/12 = 1.25 minutes.
|
| 359 |
+
check("hand swap 12:15 -> 3:01.25", ct.swapped(ct.parse("12:15")), ct.to_minutes(3, 1.25))
|
| 360 |
+
# 12:15 -> 3:01.25 -> 12:15.104, so swapping twice does not quite return.
|
| 361 |
+
check("swap of a swap is not the original",
|
| 362 |
+
ct.error_minutes(ct.swapped(ct.swapped(ct.parse("12:15"))), ct.parse("12:15")), 15 / 144)
|
| 363 |
+
check("hand swap is a no-op at 12:00", ct.error_minutes(ct.swapped(ct.parse("12:00")), ct.parse("12:00")), 0)
|
| 364 |
+
|
| 365 |
+
print("\nsummary statistics on a known set")
|
| 366 |
+
errs = [0.0, 1.0, 2.0, 3.0, 100.0]
|
| 367 |
+
s = summarise(errs)
|
| 368 |
+
check("MAE of [0,1,2,3,100]", s["mae_minutes"], 21.2)
|
| 369 |
+
check("median of [0,1,2,3,100]", s["median_minutes"], 2.0)
|
| 370 |
+
check("within 3 min of [0,1,2,3,100]", s["within_3min"], 0.8)
|
| 371 |
+
check("gross fail rate", s["gross_fail_rate"], 0.2)
|
| 372 |
+
check("p90", s["p90_minutes"], 61.2)
|
| 373 |
+
|
| 374 |
+
print("\nround trip through parse/format")
|
| 375 |
+
for t in ["12:00", "1:05", "6:30", "11:59", "3:47"]:
|
| 376 |
+
got = ct.fmt(ct.parse(t))
|
| 377 |
+
ok = got == t
|
| 378 |
+
checks.append(ok)
|
| 379 |
+
print(f" {'ok ' if ok else 'FAIL'} {t} -> {got}")
|
| 380 |
+
|
| 381 |
+
bad = 0
|
| 382 |
+
for junk in ["", "abc", "3:60", "25:00", "3:", ":47"]:
|
| 383 |
+
try:
|
| 384 |
+
ct.parse(junk)
|
| 385 |
+
print(f" FAIL accepted junk {junk!r}")
|
| 386 |
+
checks.append(False)
|
| 387 |
+
except ValueError:
|
| 388 |
+
bad += 1
|
| 389 |
+
checks.append(True)
|
| 390 |
+
print(f" ok rejected {bad} malformed inputs")
|
| 391 |
+
|
| 392 |
+
print("\nversion scheme")
|
| 393 |
+
import datetime as _dt
|
| 394 |
+
for d, want in [(_dt.date(2026, 9, 6), "2636"), (_dt.date(2024, 12, 30), "2501"),
|
| 395 |
+
(_dt.date(2027, 1, 1), "2653"), (_dt.date(2021, 1, 1), "2053")]:
|
| 396 |
+
got = ver.week_stamp(d)
|
| 397 |
+
ok = got == want
|
| 398 |
+
checks.append(ok)
|
| 399 |
+
print(f" {'ok ' if ok else 'FAIL'} {d} -> {got} (want {want}, ISO year not calendar year)")
|
| 400 |
+
p = ver.parse("263601")
|
| 401 |
+
ok = (p["iso_year"], p["iso_week"], p["release"], p["week_starts"]) == (2026, 36, 1, "2026-08-31")
|
| 402 |
+
checks.append(ok)
|
| 403 |
+
print(f" {'ok ' if ok else 'FAIL'} 263601 parses to week 36 of 2026, starting 2026-08-31")
|
| 404 |
+
try:
|
| 405 |
+
ver.parse("26xx01"); checks.append(False); print(" FAIL accepted junk version")
|
| 406 |
+
except ValueError:
|
| 407 |
+
checks.append(True); print(" ok rejected a malformed version")
|
| 408 |
+
|
| 409 |
+
n_fail = sum(1 for c in checks if not c)
|
| 410 |
+
print(f"\n{len(checks) - n_fail}/{len(checks)} checks passed")
|
| 411 |
+
return 1 if n_fail else 0
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
if __name__ == "__main__":
|
| 415 |
+
sys.exit(main())
|