lyte-codes commited on
Commit
98f945c
·
verified ·
1 Parent(s): 810e5aa

Add eval.py

Browse files
Files changed (1) hide show
  1. eval.py +415 -0
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())