#!/usr/bin/env python3 """Compare two eval runs: solve rates, Wilson intervals, and whether the difference survives. Usage: compare.py runs/A runs/B [...] Prints a pairwise table. The p-value is a two-sided Fisher exact test on the 2x2 table of solved/not — the right test for "did this actually move" when n is a hundred episodes and the rate is under 30%. Overlapping Wilson intervals are reported too, since that is the check the task brief asks for. """ import itertools import json import math import os import sys def wilson(k, n, z=1.96): if n == 0: return 0.0, 0.0, 0.0 p = k / n d = 1 + z * z / n c = (p + z * z / (2 * n)) / d h = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d return p, max(0.0, c - h), min(1.0, c + h) def load(path): """-> (solved_count, n, {task_name: solved_bool})""" f = os.path.join(path, "traces.jsonl") k = n = 0 per = {} if not os.path.exists(f): return 0, 0, per for line in open(f): rec = json.loads(line) for t in rec.get("traces", []): rw = {k: v for k, v in (t.get("rewards") or {}).items() if isinstance(v, dict) and v.get("score") is not None} if not rw: continue tot = sum(v["score"] * v.get("weight", 1.0) for v in rw.values()) wsum = sum(v.get("weight", 1.0) for v in rw.values()) or 1.0 solved = tot / wsum >= 0.999 n += 1 k += 1 if solved else 0 name = ((t.get("task") or {}).get("data") or {}).get("name") if name is not None: per[name] = solved or per.get(name, False) return k, n, per def mcnemar(pa, pb): """Paired test on the tasks both runs attempted. Returns (b, c, p, n_paired).""" shared = set(pa) & set(pb) b = sum(1 for t in shared if pa[t] and not pb[t]) # only A solved c = sum(1 for t in shared if pb[t] and not pa[t]) # only B solved m = b + c if m == 0: return b, c, 1.0, len(shared) from math import comb # exact two-sided binomial on the discordant pairs tail = sum(comb(m, i) for i in range(0, min(b, c) + 1)) / (2 ** m) return b, c, min(1.0, 2 * tail), len(shared) def fisher(a, b, c, d): """Two-sided Fisher exact p for [[a,b],[c,d]].""" from math import comb def hyp(x, r1, r2, c1): return comb(r1, x) * comb(r2, c1 - x) / comb(r1 + r2, c1) r1, r2, c1 = a + b, c + d, a + c obs = hyp(a, r1, r2, c1) lo = max(0, c1 - r2) hi = min(r1, c1) return min(1.0, sum(hyp(x, r1, r2, c1) for x in range(lo, hi + 1) if hyp(x, r1, r2, c1) <= obs * (1 + 1e-9))) def main(): runs = sys.argv[1:] if len(runs) < 1: sys.exit(__doc__) stats = {} for r in runs: k, n, per = load(r) p, lo, hi = wilson(k, n) stats[r] = (k, n, p, lo, hi, per) print(f"{os.path.basename(r.rstrip('/')):<28} {k:>4}/{n:<4} {p:.3f} ci95=[{lo:.3f},{hi:.3f}]") print() for a, b in itertools.combinations(runs, 2): ka, na, pa, loa, hia, pera = stats[a] kb, nb, pb, lob, hib, perb = stats[b] if not na or not nb: continue p = fisher(ka, na - ka, kb, nb - kb) onlya, onlyb, pm, npair = mcnemar(pera, perb) overlap = not (hia < lob or hib < loa) print( f"{os.path.basename(a.rstrip('/'))} vs {os.path.basename(b.rstrip('/'))}: " f"{pa:.3f} vs {pb:.3f} diff={pb - pa:+.3f} fisher_p={p:.3f}\n" f" paired on {npair} tasks: only-A {onlya}, only-B {onlyb}, mcnemar_p={pm:.3f} " f"| {'unpaired intervals OVERLAP' if overlap else 'unpaired intervals separate'}" ) if __name__ == "__main__": main()