homemaker-layout/experiments/ab_report.py

169 lines
7.1 KiB
Python
Raw Permalink Normal View History

A/Bs now report what their sample could resolve Three times in this log a verdict rested on a sample that could not have produced it: 38.19 (programme-house claimed at N=20, resolves at N=60), 38.21 (harbor at n=3 resolves nothing finer than ~15 fails, yet every recorded margin is smaller), 39.5/38.20 (a 10-fail cpsat margin inside a +-23-fail noise band). Each was found years later. experiments/ab_report.py makes it visible when the verdict is made: minimum detectable difference = t_crit(0.975, N-1) * sd / sqrt(N) A margin below the MDD is not a weak result but an absent one -- the experiment could not have distinguished it from zero however it came out. The report flags that, refuses to endorse a winner, and states the N needed. Validated against both datasets measured this session, reproducing the hand-computed figures exactly: programme-house N=60 +0.567 p=0.017 MDD 0.462 verdict supported programme-house N=20 +0.700 p=0.085 MDD 0.805 UNDERPOWERED, N~=26 harbor N=24 +1.208 p=0.502 MDD 3.669 UNDERPOWERED, N~=202 Harbor needing ~200 seeds means it cannot answer the collapse_insearch question at any N this project would realistically run. Fixed a defect in my own first version: with all-ties (sd=0) the MDD collapses to zero and the naive abs(mean) < mdd reported "margin exceeds the MDD -- verdict supported" for a margin of 0.000, with t=nan. A reporter that endorses a zero margin is worse than none. Degenerate cases are now explicit and distinguish all-ties from a constant non-zero difference. Separate correction found while validating: 38.19's published p-values for N=20 and N=40 were 0.069 and 0.045, from a normal approximation. The exact paired t-test gives 0.085 and 0.052 -- so N=40 did NOT reach significance either; it took N=60. The approximation was anti-conservative, making results look more significant than they are, the same direction of error this thread is about. Corrected at all four citing sites, and the "needs N >= 40" guidance raised to N >= 60. Closes homemaker-py-tco. Lint at parity (46); tests 384 passed, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-29 19:51:47 +00:00
"""Paired A/B reporting that states what the sample could actually resolve.
Motivation (`homemaker-py-tco`). Two findings in DESIGN.md §38.19 and §38.21 say
this project's A/B protocols are routinely underpowered for the margins they
report:
* programme-house needed N=60 to resolve an effect its own protocol claimed at
N=20 -- at N=20 the current answer is p ~= 0.069, a null;
* harbor's paired sd is ~6.2 fails, so n=3 resolves nothing finer than ~15
fails. Every recorded harbor margin is below that, and 25% of 3-seed subsets
show a clean 3/3 sweep by chance.
Both were only discovered years later. The cheap prevention is to print, beside
every verdict, the smallest difference the sample could have detected -- so an
underpowered result is visible AT THE POINT IT IS MADE.
minimum detectable difference (MDD) = t_crit(0.975, N-1) * sd / sqrt(N)
A margin below the MDD is not a weak result, it is an ABSENT one: the experiment
could not have distinguished it from zero however it came out. `paired_report`
flags exactly that, and reports the N that would be needed instead.
Usage as a library::
from ab_report import paired_report, format_report
print(format_report(paired_report(off_list, on_list, "OFF", "ON")))
Usage as a CLI over a results TSV with `tag seed arm value [...]` rows::
python experiments/ab_report.py results/0wr_qpk_harbor_n24.tsv off on
"""
from __future__ import annotations
import csv
import math
import statistics
import sys
try: # scipy is a hard dep of the
from scipy import stats as _sps # project, but keep the
except ImportError: # report usable without it
_sps = None
def _t_crit(df: int, alpha: float = 0.05) -> float:
if _sps is not None:
return float(_sps.t.ppf(1 - alpha / 2, df))
return 1.96 if df > 30 else 2.2 # crude, only for a scipy-less fallback
def paired_report(a: list[float], b: list[float],
label_a: str = "A", label_b: str = "B",
alpha: float = 0.05) -> dict:
"""Paired comparison of two arms measured on the same seeds.
``a`` and ``b`` are aligned per seed. Positive ``mean_diff`` means ``b``
scored LOWER than ``a`` -- for fail counts, that is ``b`` winning.
"""
if len(a) != len(b):
raise ValueError(f"unpaired input: {len(a)} vs {len(b)}")
n = len(a)
if n < 2:
raise ValueError("need at least 2 paired observations")
diffs = [x - y for x, y in zip(a, b)]
mean = statistics.mean(diffs)
sd = statistics.stdev(diffs)
se = sd / math.sqrt(n)
tc = _t_crit(n - 1, alpha)
mdd = tc * se # smallest |mean| this N could resolve
# Degenerate case: every seed gave the SAME difference, so sd == 0 and the
# MDD collapses to 0. Without this, an all-ties comparison (mean 0, sd 0)
# reports "margin exceeds the MDD" and endorses a zero margin as a verdict,
# which is worse than saying nothing. t/p are nan here too.
degenerate = sd == 0.0
out = {
"n": n, "label_a": label_a, "label_b": label_b,
"mean_a": statistics.mean(a), "mean_b": statistics.mean(b),
"wins_b": sum(1 for d in diffs if d > 0),
"losses_b": sum(1 for d in diffs if d < 0),
"ties": sum(1 for d in diffs if d == 0),
"mean_diff": mean, "sd": sd, "se": se,
"ci": (mean - tc * se, mean + tc * se),
"mdd": mdd,
"degenerate": degenerate,
"identical": degenerate and mean == 0.0,
"underpowered": (not degenerate) and abs(mean) < mdd,
"t": None, "p": None, "wilcoxon_p": None, "n_needed": None,
}
if _sps is not None and not degenerate:
t, p = _sps.ttest_rel(a, b)
out["t"], out["p"] = float(t), float(p)
if any(d != 0 for d in diffs):
try:
out["wilcoxon_p"] = float(_sps.wilcoxon(a, b).pvalue)
except ValueError:
pass
if mean and not degenerate: # N that would resolve THIS margin
k = n
while k < 100000 and _t_crit(k - 1, alpha) * sd / math.sqrt(k) >= abs(mean):
k += 1
out["n_needed"] = k
return out
def format_report(r: dict, indent: str = " ") -> str:
L = []
A, B = r["label_a"], r["label_b"]
L.append(f"{indent}N={r['n']} {A} mean {r['mean_a']:.2f} {B} mean {r['mean_b']:.2f} "
f"{r['wins_b']}W/{r['losses_b']}L/{r['ties']}T (for {B})")
L.append(f"{indent}mean diff {r['mean_diff']:+.3f} sd {r['sd']:.3f} "
f"95% CI [{r['ci'][0]:+.3f}, {r['ci'][1]:+.3f}]")
if r["p"] is not None:
w = "" if r["wilcoxon_p"] is None else f" wilcoxon p={r['wilcoxon_p']:.4f}"
L.append(f"{indent}t={r['t']:.3f} p={r['p']:.4f}{w}")
if r["identical"]:
L.append(f"{indent}** NO DIFFERENCE: the two arms scored identically on every seed.")
L.append(f"{indent} Nothing to test -- do not report a winner.")
return "\n".join(L)
if r["degenerate"]:
L.append(f"{indent}** Every seed gave the same difference ({r['mean_diff']:+.3f}), "
f"so the variance is zero.")
L.append(f"{indent} Consistent, but a t-test is undefined here; treat N as the "
f"evidence and check the arms are genuinely independent.")
return "\n".join(L)
L.append(f"{indent}minimum detectable difference at N={r['n']}: {r['mdd']:.3f}")
if r["underpowered"]:
need = r["n_needed"]
L.append(f"{indent}** UNDERPOWERED: the observed margin ({abs(r['mean_diff']):.3f}) is "
f"BELOW what N={r['n']} can resolve.")
L.append(f"{indent} This experiment could not distinguish it from zero however it "
f"came out. Do not declare a winner.")
if need:
L.append(f"{indent} N ~= {need} would be needed for a margin this size.")
else:
L.append(f"{indent} margin exceeds the MDD -- the sample can support a verdict.")
return "\n".join(L)
def main() -> int:
if len(sys.argv) < 4:
print(__doc__.strip().splitlines()[-1], file=sys.stderr)
print("usage: ab_report.py <results.tsv> <arm_a> <arm_b> [value_col=3]",
file=sys.stderr)
return 2
path, arm_a, arm_b = sys.argv[1], sys.argv[2], sys.argv[3]
col = int(sys.argv[4]) if len(sys.argv) > 4 else 3
by: dict = {}
with open(path) as fh:
for row in csv.reader(fh, delimiter="\t"):
if len(row) <= col or not row[1].lstrip("-").isdigit():
continue # header / short row
by.setdefault(int(row[1]), {})[row[2]] = float(row[col])
seeds = sorted(s for s, v in by.items() if arm_a in v and arm_b in v)
if len(seeds) < 2:
print(f"only {len(seeds)} paired seed(s) for {arm_a!r}/{arm_b!r}", file=sys.stderr)
return 1
a = [by[s][arm_a] for s in seeds]
b = [by[s][arm_b] for s in seeds]
print(f"{path} {arm_a} vs {arm_b}")
print(format_report(paired_report(a, b, arm_a, arm_b)))
return 0
if __name__ == "__main__":
raise SystemExit(main())