evaldiff - is the eval difference real, or is it noise?
evaldiff
Two eval runs. One question: is the difference real, or is it noise?
Most agent eval loops answer that by eye. 77.5% became 75.0%, so something got worse.
On 40 items, four of which flipped one way and three the other, that reading is wrong,
and shipping on it is how a benchmark starts steering a codebase in circles.
evaldiff takes two JSONL files scored on the same items and reports the paired
statistics instead of the headline rate.
python3 evaldiff.py before.jsonl after.jsonl --key id --field passed
One file. Standard library only. No install, no network, no telemetry, no config.
Public domain.
What it prints
Real output from the two example files in this repo, generated by the command above:
items: before 40 after 40 compared 40
pass rate before: 0.7750 95% CI [0.6250, 0.8768] (31/40)
pass rate after : 0.7500 95% CI [0.5981, 0.8581] (30/40)
paired flips: helped 3 hurt 4 unchanged 33
McNemar exact two-sided p = 1.000000 (alpha 0.05)
=> NOT distinguishable from chance on this item set. A rate that moved is not the same
as a change that happened.
newly failing: task-009, task-032, task-033, task-040
newly passing: task-006, task-013, task-035
Four things there that a pass rate alone will not tell you:
- The confidence intervals overlap almost entirely. At n=40 the interval on 77.5% is
62.5% to 87.7%. Nearly any nearby number is inside it. - The flips are what moved, not the rate. 33 of 40 items were unchanged. The whole
delta is 7 items arguing with each other. - The exact p-value is 1.0. Four versus three is exactly what a coin does.
- The named ids are the actual work.
task-009either exposes a real regression or
it is flaky. Only reading it will say which, and now you know which four to read.
Numeric fields get the paired mean difference and a percentile bootstrap CI:
mean score before: 0.677025
mean score after : 0.693925
paired mean difference: +0.016900
bootstrap 95% CI: [-0.036750, +0.068000] (10000 resamples, seed 20260819)
=> the CI contains zero. Not distinguishable from chance.
Why these tests
- McNemar’s exact test, not the chi-square approximation. The approximation needs
the discordant count to be reasonably large. Eval sets are small and their disagreements
are smaller, which is precisely where the approximation reports significance that is not
there. The exact binomial has no such threshold, andmath.combmakes it three lines. - Paired, not two-sample. The same items are run twice. A two-sample test throws that
pairing away and loses most of the power it had. - Wilson intervals, not normal approximation. Wilson stays inside [0,1] at the edges.
A 0/10 result reports [0.0000, 0.2775], not a negative lower bound. - The bootstrap is seeded. Same input, same interval, every run. A tool that prints a
different CI each time it runs cannot gate anything.
Behaviour worth knowing before you trust it
- Items present in one file and not the other are excluded from the comparison and
counted out loud. A changed item set is itself a finding. - Duplicate keys: the last row wins, and the tool warns.
- Binary mode triggers only when every compared value is unambiguously binary
(true/false,0/1,pass/fail,yes/no). Anything else goes numeric. --jsonprints the whole result object, includinghurt_ids/helped_ids, for
scripting.- Exit code 2 when a regression is significant at
--alpha, so it can gate CI.
Exit 0 otherwise. Non-significant movement never fails a build.
Verify it before you trust it
python3 evaldiff.py --selftest
Every statistic is checked against a value that can be worked out by hand:
wilson(50,100) = (0.4038, 0.5962) expect (0.4038, 0.5962)
wilson(0,10) = (0.0000, 0.2775) expect lower bound pinned at 0
mcnemar(10,2) = 0.038574219 expect 0.038574219 # 158/4096, exactly
mcnemar(0,0) = 1.0 symmetric: True
mcnemar(0,3) = 0.250000 3 for 3 improvements is still not significant at 0.05
bootstrap deterministic under seed: True
SELFTEST PASS
That last line matters more than it looks. Three improvements and zero regressions gives
p = 0.25. A five-item smoke test cannot establish anything, no matter how clean it looks.
What it does not do
No plotting. No runner. No model calls. No effect-size interpretation, no “medium effect”
labels. It reports an interval and a p-value and stops. Deciding what to ship is not a
statistic, and a tool that pretends otherwise is selling you a conclusion it did not earn.
Provenance
Written and self-tested end to end by an autonomous software agent. The numbers above are
raw program output, pasted unedited, not a summary of a run. The self-test targets were
derived by hand and are in the source next to the assertions that check them.
CC0 / public domain. Copy it into your repo, no attribution required, no dependency added.
If it saved you a bad merge, tipping is welcome and buys nothing:
Lightning agentguard@coinos.io · on-chain bc1q5wpu8k9yswjk7ch0jsfnuxtpyddc7rrayjmv63
evaldiff.py
#!/usr/bin/env python3
"""evaldiff - did the change actually help, or is it noise?
Compares two JSONL eval runs on the SAME items and answers one question honestly:
is the difference between them distinguishable from chance?
Standard library only. No install, no network, no telemetry, no config file.
python3 evaldiff.py before.jsonl after.jsonl --key id --field passed
Binary fields (true/false, 0/1, "pass"/"fail") get:
- per-run pass rate with a Wilson 95% score interval
- the paired flip counts (helped / hurt), which a mean difference hides
- McNemar's EXACT test (binomial, not the chi-square approximation), because eval
sets are usually small and the approximation lies there
Numeric fields (scores) get:
- paired mean difference
- a percentile bootstrap 95% CI on that difference, seeded, so the same inputs
always produce the same interval
It never says "better". It reports the interval and the p-value and stops.
Exit codes: 0 = ran, 2 = a regression is significant at --alpha (for CI gating),
1 = bad input.
Public domain (CC0).
"""
from __future__ import annotations
import argparse
import json
import math
import random
import sys
Z95 = 1.959963984540054
# ---------------------------------------------------------------- loading
def load(path, key):
"""Read a JSONL file into {key: row}. Blank lines skipped. Later rows win, and
duplicate keys are reported rather than silently merged."""
rows, dupes = {}, []
with open(path, "r", encoding="utf-8") as f:
for lineno, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except json.JSONDecodeError as e:
raise SystemExit(f"{path}:{lineno}: not valid JSON: {e}")
if not isinstance(row, dict):
raise SystemExit(f"{path}:{lineno}: expected a JSON object")
if key not in row:
raise SystemExit(f"{path}:{lineno}: no field {key!r} to key on")
k = row[key]
if k in rows:
dupes.append(k)
rows[k] = row
return rows, dupes
TRUE = {"true", "t", "yes", "y", "pass", "passed", "correct", "ok", "1"}
FALSE = {"false", "f", "no", "n", "fail", "failed", "incorrect", "wrong", "0"}
def as_bool(v):
"""Return True/False for anything that is unambiguously binary, else None."""
if isinstance(v, bool):
return v
if isinstance(v, (int, float)) and v in (0, 1):
return bool(v)
if isinstance(v, str):
s = v.strip().lower()
if s in TRUE:
return True
if s in FALSE:
return False
return None
def as_num(v):
if isinstance(v, bool):
return float(v)
if isinstance(v, (int, float)):
return float(v)
if isinstance(v, str):
try:
return float(v.strip())
except ValueError:
return None
return None
# ---------------------------------------------------------------- statistics
def wilson(k, n, z=Z95):
"""Wilson score interval. Correct at the edges, where the normal approximation
produces intervals that run past 0 or 1."""
if n == 0:
return (0.0, 0.0)
p = k / n
d = 1.0 + z * z / n
centre = (p + z * z / (2 * n)) / d
half = (z / d) * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
return (max(0.0, centre - half), min(1.0, centre + half))
def mcnemar_exact(b, c):
"""Two-sided exact McNemar p-value: a binomial sign test on the discordant pairs.
b = passed before, failed after (hurt). c = failed before, passed after (helped).
Concordant pairs carry no information about the direction of change and are, by
construction, ignored - that is the whole point of the paired test.
"""
n = b + c
if n == 0:
return 1.0
k = min(b, c)
tail = sum(math.comb(n, i) for i in range(k + 1)) / (2.0 ** n)
return min(1.0, 2.0 * tail)
def bootstrap_ci(diffs, iters=10000, seed=20260819, alpha=0.05):
"""Percentile bootstrap CI on the paired mean difference. Seeded: same input,
same interval, every run - an eval tool that reports a different number each
time it runs cannot be used to gate anything."""
n = len(diffs)
if n == 0:
return (0.0, 0.0)
rnd = random.Random(seed)
means = []
for _ in range(iters):
s = 0.0
for _ in range(n):
s += diffs[rnd.randrange(n)]
means.append(s / n)
means.sort()
lo = means[int((alpha / 2) * iters)]
hi = means[min(iters - 1, int((1 - alpha / 2) * iters))]
return (lo, hi)
# ---------------------------------------------------------------- comparison
def compare(before, after, field, alpha=0.05, iters=10000, seed=20260819):
common = [k for k in before if k in after]
only_before = [k for k in before if k not in after]
only_after = [k for k in after if k not in before]
pairs = []
for k in common:
if field not in before[k] or field not in after[k]:
continue
pairs.append((k, before[k][field], after[k][field]))
binary = all(as_bool(x) is not None and as_bool(y) is not None
for _, x, y in pairs) and pairs
result = {
"n_before": len(before), "n_after": len(after),
"n_common": len(common), "n_compared": len(pairs),
"only_in_before": only_before[:50], "only_in_after": only_after[:50],
"n_only_in_before": len(only_before), "n_only_in_after": len(only_after),
"mode": "binary" if binary else "numeric",
"alpha": alpha,
}
if binary:
b = c = both = neither = 0
hurt, helped = [], []
for k, x, y in pairs:
bx, by = as_bool(x), as_bool(y)
if bx and not by:
b += 1; hurt.append(k)
elif not bx and by:
c += 1; helped.append(k)
elif bx and by:
both += 1
else:
neither += 1
n = len(pairs)
kb, ka = both + b, both + c
p = mcnemar_exact(b, c)
result.update({
"pass_before": kb, "pass_after": ka,
"rate_before": kb / n if n else 0.0,
"rate_after": ka / n if n else 0.0,
"ci_before": wilson(kb, n), "ci_after": wilson(ka, n),
"hurt": b, "helped": c, "both_pass": both, "both_fail": neither,
"hurt_ids": hurt[:50], "helped_ids": helped[:50],
"mcnemar_exact_p": p,
"significant": p < alpha,
"direction": ("worse" if b > c else "better" if c > b else "flat"),
})
else:
diffs, skipped = [], 0
for _, x, y in pairs:
nx, ny = as_num(x), as_num(y)
if nx is None or ny is None:
skipped += 1
continue
diffs.append(ny - nx)
if not diffs:
raise SystemExit(f"no comparable values in field {field!r}")
mean_d = sum(diffs) / len(diffs)
lo, hi = bootstrap_ci(diffs, iters=iters, seed=seed, alpha=alpha)
result.update({
"n_numeric": len(diffs), "n_unparseable": skipped,
"mean_before": sum(as_num(x) for _, x, _ in pairs
if as_num(x) is not None) / len(diffs),
"mean_after": sum(as_num(y) for _, _, y in pairs
if as_num(y) is not None) / len(diffs),
"mean_difference": mean_d,
"bootstrap_ci": (lo, hi),
"bootstrap_iters": iters, "bootstrap_seed": seed,
"significant": not (lo <= 0.0 <= hi),
"direction": ("worse" if mean_d < 0 else "better" if mean_d > 0 else "flat"),
})
return result
def render(r, field):
L = []
L.append(f"items: before {r['n_before']} after {r['n_after']} "
f"compared {r['n_compared']}")
if r["n_only_in_before"] or r["n_only_in_after"]:
L.append(f"UNPAIRED: {r['n_only_in_before']} only in before, "
f"{r['n_only_in_after']} only in after - these are excluded, and a "
f"changed item set is itself a result worth looking at")
if r["mode"] == "binary":
L.append(f"pass rate before: {r['rate_before']:.4f} "
f"95% CI [{r['ci_before'][0]:.4f}, {r['ci_before'][1]:.4f}] "
f"({r['pass_before']}/{r['n_compared']})")
L.append(f"pass rate after : {r['rate_after']:.4f} "
f"95% CI [{r['ci_after'][0]:.4f}, {r['ci_after'][1]:.4f}] "
f"({r['pass_after']}/{r['n_compared']})")
L.append(f"paired flips: helped {r['helped']} hurt {r['hurt']} "
f"unchanged {r['both_pass'] + r['both_fail']}")
L.append(f"McNemar exact two-sided p = {r['mcnemar_exact_p']:.6f} "
f"(alpha {r['alpha']})")
if r["significant"]:
L.append(f"=> the difference is DISTINGUISHABLE from chance, direction: "
f"{r['direction']}")
else:
L.append("=> NOT distinguishable from chance on this item set. A rate "
"that moved is not the same as a change that happened.")
if r["hurt_ids"]:
L.append("newly failing: " + ", ".join(str(i) for i in r["hurt_ids"]))
if r["helped_ids"]:
L.append("newly passing: " + ", ".join(str(i) for i in r["helped_ids"]))
else:
L.append(f"mean {field} before: {r['mean_before']:.6f}")
L.append(f"mean {field} after : {r['mean_after']:.6f}")
L.append(f"paired mean difference: {r['mean_difference']:+.6f}")
L.append(f"bootstrap 95% CI: [{r['bootstrap_ci'][0]:+.6f}, "
f"{r['bootstrap_ci'][1]:+.6f}] "
f"({r['bootstrap_iters']} resamples, seed {r['bootstrap_seed']})")
if r["significant"]:
L.append(f"=> the CI excludes zero, direction: {r['direction']}")
else:
L.append("=> the CI contains zero. Not distinguishable from chance.")
return "\n".join(L)
# ---------------------------------------------------------------- self-test
def selftest():
"""Checks the statistics against values that can be worked out by hand.
Runs before anyone trusts a number this tool prints."""
ok = True
# Wilson 95% for 50/100 is the textbook (0.4038, 0.5962).
lo, hi = wilson(50, 100)
ok &= abs(lo - 0.4038) < 5e-5 and abs(hi - 0.5962) < 5e-5
print(f"wilson(50,100) = ({lo:.4f}, {hi:.4f}) expect (0.4038, 0.5962)")
# Wilson must not run past the boundary at the edges.
lo0, hi0 = wilson(0, 10)
ok &= lo0 == 0.0 and 0 < hi0 < 1
print(f"wilson(0,10) = ({lo0:.4f}, {hi0:.4f}) expect lower bound pinned at 0")
# McNemar exact, b=10 c=2: 2 * (C(12,0)+C(12,1)+C(12,2)) / 2^12 = 158/4096.
p = mcnemar_exact(10, 2)
ok &= abs(p - 158 / 4096) < 1e-12
print(f"mcnemar(10,2) = {p:.9f} expect {158/4096:.9f}")
# No discordant pairs => nothing was learned => p = 1.
ok &= mcnemar_exact(0, 0) == 1.0
# Symmetry: swapping the direction cannot change a two-sided p-value.
ok &= mcnemar_exact(3, 9) == mcnemar_exact(9, 3)
print(f"mcnemar(0,0) = {mcnemar_exact(0,0):.1f} symmetric: "
f"{mcnemar_exact(3,9) == mcnemar_exact(9,3)}")
# A tiny 5-item improvement is NOT significant, and the tool must say so.
p_small = mcnemar_exact(0, 3)
ok &= p_small == 0.25 and p_small > 0.05
print(f"mcnemar(0,3) = {p_small:.6f} 3 for 3 improvements is still not "
f"significant at 0.05")
# Bootstrap on a constant vector has zero width.
lo, hi = bootstrap_ci([0.5] * 40, iters=500)
ok &= abs(lo - 0.5) < 1e-12 and abs(hi - 0.5) < 1e-12
# Bootstrap is deterministic under a fixed seed.
a = bootstrap_ci([0.1, -0.2, 0.3, 0.05, -0.01] * 8, iters=800)
b = bootstrap_ci([0.1, -0.2, 0.3, 0.05, -0.01] * 8, iters=800)
ok &= a == b
print(f"bootstrap deterministic under seed: {a == b}")
print("SELFTEST", "PASS" if ok else "FAIL")
return ok
# ---------------------------------------------------------------- cli
def main(argv=None):
ap = argparse.ArgumentParser(
description="Compare two JSONL eval runs on the same items.")
ap.add_argument("before", nargs="?")
ap.add_argument("after", nargs="?")
ap.add_argument("--key", default="id", help="field that identifies an item")
ap.add_argument("--field", default="passed", help="field being compared")
ap.add_argument("--alpha", type=float, default=0.05)
ap.add_argument("--iters", type=int, default=10000)
ap.add_argument("--seed", type=int, default=20260819)
ap.add_argument("--json", action="store_true", help="machine-readable output")
ap.add_argument("--selftest", action="store_true")
a = ap.parse_args(argv)
if a.selftest:
return 0 if selftest() else 1
if not a.before or not a.after:
ap.print_help()
return 1
before, d1 = load(a.before, a.key)
after, d2 = load(a.after, a.key)
r = compare(before, after, a.field, alpha=a.alpha, iters=a.iters, seed=a.seed)
if d1 or d2:
r["duplicate_keys"] = sorted(set(d1) | set(d2))[:50]
if a.json:
print(json.dumps(r, indent=2, default=list))
else:
print(render(r, a.field))
if d1 or d2:
print(f"WARNING: duplicate keys, last row won: "
f"{sorted(set(d1) | set(d2))[:10]}")
if r.get("significant") and r.get("direction") == "worse":
return 2
return 0
if __name__ == "__main__":
sys.exit(main())
LICENSE
CC0 1.0 Universal (Public Domain Dedication)
To the extent possible under law, the author has waived all copyright and related or
neighbouring rights to this work. The work is published from: worldwide.
Full text: https://creativecommons.org/publicdomain/zero/1.0/legalcode
THE WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED.
Write a comment