Skip to content

Hypothesis tests: paired and two-sample permutation, McNemar, Holm

ModuleM07.5 · build · Python · Pass 5 · 3 to 4 h
You buildpython/tinyllm/prob/tests.py: mean_difference, paired_permutation_test, permutation_test, mcnemar, holm, holm_adjust
Contractcourse/contracts/py/tinyllm/prob/tests.pyi · the generator behind rng: spec/pcg32.md
Testscourse/tests/M07.5/test_tests.py (what they check: section 4), golden values from scipy 1.17.1 in course/fixtures/M07.5/scipy_golden.json
Needsno code from earlier modules · reading: M07.4 sampling distributions and intervals, S-M07c
Used bylater: L6.7 compares models in the zoo · L8.5 and L8.6 assert “no regression” after quantization and speculative decoding · C1 and C2 A/B ablations · load.02 re-implements the two-sample test in Go
MilestoneMS-P5 (the Pass 5 gate)
Optional depthWasserman, All of Statistics, ch. 10 (hypothesis testing and p-values); Good, Permutation, Parametric, and Bootstrap Tests of Hypotheses (2005), ch. 3; Phipson and Smyth, “Permutation p-values should never be zero” (2010); Holm, “A simple sequentially rejective multiple test procedure” (1979)
  • A p-value is the probability, if nothing really changed, of a difference at least as large as the one you saw; a valid test rejects a true null at rate α\alpha (test_paired_type_one_rate, test_two_sample_type_one_rate).
  • A permutation test builds that “nothing changed” world by relabelling: flip which model each paired score belongs to, or reshuffle two pools; no normality assumption (test_hand_example_paired_enumeration, test_paired_matches_exact_scipy).
  • Count the observed labelling as one more permutation: p=(1+count)/(1+B)p = (1 + \text{count})/(1 + B) is never 0 and keeps the type-I promise exactly (test_add_one_and_degenerate_data).
  • McNemar compares two classifiers on the same examples using only the pairs they disagree on: an exact binomial test (test_hand_example_mcnemar, test_mcnemar_matches_scipy).
  • Ten comparisons at 0.05 give a false alarm 40% of the time; Holm controls the chance of any false alarm and rejects at least as much as Bonferroni (test_hand_example_holm, test_holm_steps_down_and_stops).
Terminal window
ol start M07.5 # stubs tests.py into your repo
ol tests M07.5 # read the test catalog first: rung R0, you write no tests here
ol check M07.5 # exit code is the verdict
ol diff M07.5 # after passing: your code against the reference

From Pass 5 on, every change you make to a model is a claim: “pre-LN trains better”, “this LoRA rank matches full fine-tuning”, “int8 quantization costs nothing” (L8.5). The evidence is a score on a finite eval set, and two runs of the same model already differ by a few tenths of a point. M07.4 put an interval on each number; this module answers the comparison question directly. Is the gap between model A and model B on the same 200 prompts larger than the gap chance alone produces? The model zoo (L6.7) prints a p-value next to every comparison, the quantization and speculative-decoding modules fail their “no regression” checks on one, and the zoo compares many models at once, which needs a correction or it will “discover” a winner by luck. All of these call the six functions you write here.

SymbolMeaningType / shape
H0H_0the null hypothesis: “no difference” in a precise form (exchangeable labels)
TTthe test statistic, larger meaning more extremefloat
tobst_{\text{obs}}TT on the data as observedfloat
ppthe p-value, P(T≥tobs∣H0)P(T \ge t_{\text{obs}} \mid H_0)float in [0,1][0, 1]
α\alphathe significance level: reject H0H_0 when p≤αp \le \alphafloat, often 0.05
ai,bia_i, b_ithe scores of models A and B on prompt ii (paired)float64[n]
di=ai−bid_i = a_i - b_ipaired differencesfloat64[n]
si∈{−1,+1}s_i \in \{-1, +1\}a random sign per pair
BBthe number of random permutations, n_permint
b01,b10b_{01}, b_{10}counts of examples where only A is right, and only B is rightint
mm, p(k)p_{(k)}the number of tests in a family, and the kk-th smallest p-value (k=0,…,m−1k = 0, \ldots, m-1)

Suppose B scores 0.8 points above A on 50 prompts. Two stories explain it: B is better, or the prompts happened to favour B. A test makes the second story precise as a null hypothesis H0H_0, picks a statistic TT that is large when the data disagree with H0H_0, and asks how often H0H_0 would produce a TT at least as large as the observed one. That probability is the p-value. A small p-value says the data would be surprising if H0H_0 were true; it is not the probability that H0H_0 is true, and a large one does not prove H0H_0 (the test may just lack data).

Reject H0H_0 whenever p≤αp \le \alpha. If H0H_0 is true, a valid p-value satisfies P(p≤α)≤αP(p \le \alpha) \le \alpha: a rejection is a false alarm (a type-I error) at most a fraction α\alpha of the time. Not rejecting a false H0H_0 is a type-II error; the probability of rejecting when the effect is real is the power. A test that never rejects has a perfect type-I rate and no power, so the tests check both. “Different” is usually two-sided: B worse is as interesting as B better, so TT is a magnitude, ∣⋅∣|\cdot|, and swapping A and B must not change pp.

When both models answer the same prompts, compare them prompt by prompt: di=ai−bid_i = a_i - b_i. Under H0H_0 (“which model produced which score of a pair is arbitrary”), each did_i was as likely to come out as −di-d_i. So the null world is: flip each sign with probability 12\frac{1}{2}. With T(s)=∣∑isidi∣T(s) = |\sum_i s_i d_i| and tobs=T(1,…,1)t_{\text{obs}} = T(1, \ldots, 1), the exact p-value is the fraction of all 2n2^n sign vectors with T(s)≥tobsT(s) \ge t_{\text{obs}}. For n=50n = 50 that is 101510^{15} vectors, so draw BB of them at random. The natural estimate count/B\text{count}/B can be 0, which no true p-value is. Counting the observed labelling as one more draw,

p=1+#{b:T(s(b))≥tobs}1+B,p = \frac{1 + \#\{b : T(s^{(b)}) \ge t_{\text{obs}}\}}{1 + B},

fixes that, and it is exactly valid: under H0H_0 the observed TT is one of B+1B + 1 exchangeable values, its rank is uniform, so P(p≤α)=⌊α(B+1)⌋/(B+1)≤αP(p \le \alpha) = \lfloor \alpha (B + 1) \rfloor / (B + 1) \le \alpha. With B=99B = 99, P(p≤0.1)P(p \le 0.1) is exactly 0.1: the rate the type-I tests measure. Pairing matters: prompt difficulty varies far more than the model gap, and pairing cancels it.

When the two samples are not paired (latencies of two server builds, scores on different eval sets), H0H_0 says the group labels are arbitrary: all N=na+nbN = n_a + n_b values come from one distribution. The null world deals the pooled values into two groups of the original sizes at random, and T=∣stat(a′,b′)∣T = |\text{stat}(a', b')|, by default the difference of means. A uniformly random order of NN items comes from Fisher-Yates: for i=N−1i = N - 1 down to 1, pick jj uniformly in {0,…,i}\{0, \ldots, i\} and swap positions ii and jj. Each of the N!N! orders comes out with probability 1/N!1/N!. The tempting shortcut, jj uniform over all NN positions every time, produces NNN^N equally likely swap sequences, which is not a multiple of N!N!, so some orders are more likely than others.

Two classifiers on the same nn examples give a 2 by 2 table: both right, both wrong, only A right (b01b_{01}), only B right (b10b_{10}). The examples where they agree say nothing about which is better. Under H0H_0 each disagreement is a fair coin, so b01∼Binomial(b01+b10,12)b_{01} \sim \text{Binomial}(b_{01} + b_{10}, \frac{1}{2}), and the exact two-sided p-value doubles the smaller tail:

p=min⁡(1, 2∑i=0k(ni)2−n),n=b01+b10, k=min⁡(b01,b10).p = \min\Bigl(1,\ 2 \sum_{i=0}^{k} \binom{n}{i} 2^{-n}\Bigr), \qquad n = b_{01} + b_{10},\ k = \min(b_{01}, b_{10}).

The doubling can exceed 1 when b01=b10b_{01} = b_{10}, hence the cap. For large nn the classic approximation is χ2=(∣b01−b10∣−1)2/n\chi^2 = (|b_{01} - b_{10}| - 1)^2 / n on one degree of freedom (the −1-1 is Edwards’ continuity correction), with p=erfc⁡(χ2/2)p = \operatorname{erfc}(\sqrt{\chi^2/2}). With no disagreements at all there is no evidence: p=1p = 1.

Test m=10m = 10 true null hypotheses at α=0.05\alpha = 0.05 and the chance of at least one false alarm is 1−0.9510=0.401 - 0.95^{10} = 0.40. The family-wise error rate (FWER) is that chance. Bonferroni tests each at α/m\alpha/m, which bounds the FWER by α\alpha (a union bound) but is needlessly strict. Holm is uniformly better: sort the p-values, compare the smallest with α/m\alpha/m, the next with α/(m−1)\alpha/(m-1), and so on, rejecting while each passes and stopping at the first that does not. Once a hypothesis is rejected, only m−1m - 1 can still be true nulls, which is why the bar relaxes. The adjusted p-values p~(k)=max⁡j≤kmin⁡(1,(m−j)p(j))\tilde p_{(k)} = \max_{j \le k} \min(1, (m - j) p_{(j)}) make Holm a table column: reject exactly where p~≤α\tilde p \le \alpha. The running maximum keeps the order: a smaller raw p-value never gets a larger adjusted one.

Paired. Model B minus model A on three prompts: d=(2,1,3)d = (2, 1, 3), tobs=6t_{\text{obs}} = 6. All eight sign patterns:

ss++++{+}{+}++−+{+}{-}+−++{-}{+}+−−+{-}{-}−++-{+}{+}−+−-{+}{-}−−+-{-}{+}−−−-{-}{-}
∑sidi\sum s_i d_i604−2-22−4-40−6-6
∣⋅∣≥6\lvert \cdot \rvert \ge 6yesyes

The exact p-value is 2/8=1/42/8 = 1/4: with three prompts, even a unanimous win is not convincing. Fed exactly these eight patterns as its B=8B = 8 permutations, the add-one estimate is (1+2)/(1+8)=1/3(1 + 2)/(1 + 8) = 1/3.

McNemar. On 40 prompts, A alone is right on b01=1b_{01} = 1, B alone on b10=7b_{10} = 7. Then n=8n = 8, k=1k = 1, the tail is (80)+(81)=9\binom{8}{0} + \binom{8}{1} = 9 out of 28=2562^8 = 256, and p=2⋅9/256=9/128=0.0703p = 2 \cdot 9/256 = 9/128 = 0.0703. Not significant at 0.05, despite 7 to 1. The approximation gives χ2=(6−1)2/8=3.125\chi^2 = (6 - 1)^2/8 = 3.125 and p=erfc⁡(1.25)=0.0771p = \operatorname{erfc}(1.25) = 0.0771.

Holm. Four comparisons with p-values (0.01,0.04,0.02,0.005)(0.01, 0.04, 0.02, 0.005) at α=0.05\alpha = 0.05:

sorted kkp(k)p_{(k)}bar α/(m−k)\alpha/(m-k)reject?(m−k) p(k)(m-k)\,p_{(k)}adjusted
00.0050.0125yes0.020.02
10.010.0167yes0.030.03
20.020.025yes0.040.04
30.040.05yes0.040.04

Holm rejects all four; Bonferroni (every p against 0.0125) rejects only 0.005 and 0.01. In input order the adjusted values are (0.03,0.04,0.04,0.02)(0.03, 0.04, 0.04, 0.02). These are the first three tests.

def mean_difference(a: NDArray, b: NDArray) -> float: ...
def paired_permutation_test(a: ArrayLike, b: ArrayLike, n_perm: int, rng: UniformSource) -> float: ...
def permutation_test(a: ArrayLike, b: ArrayLike, stat: Callable[[NDArray, NDArray], float],
n_perm: int, rng: UniformSource) -> float: ...
def mcnemar(b01: int, b10: int, exact: bool = True) -> float: ...
def holm(pvalues: ArrayLike, alpha: float = 0.05) -> NDArray: ... # bool, True = rejected
def holm_adjust(pvalues: ArrayLike) -> NDArray: ...

The draw order is part of the contract, because load.02 re-implements the two-sample test in Go and must reproduce your p-values from the same seed: the paired test draws one uniform per pair per permutation (u<0.5u < 0.5 flips); the two-sample test restarts from the identity order each permutation and runs Fisher-Yates from the end with j=min⁡(⌊u(i+1)⌋,i)j = \min(\lfloor u(i+1) \rfloor, i). A permuted statistic counts as “at least as extreme” when it is at least tobs(1−10−9)t_{\text{obs}}(1 - 10^{-9}), so rounding never splits a tie.

TestKINDChecksWhy it matters downstream
test_hand_example_paired_enumerationunit, smokesection 3: (1+2)/(1+8)=1/3(1 + 2)/(1 + 8) = 1/3 over the eight patternsyou and the tests agree on the statistic and the add-one rule
test_hand_example_mcnemarunit, smoke9/1289/128 exact, erfc⁡(1.25)\operatorname{erfc}(1.25) approximateclassifier comparisons in L6.7
test_hand_example_holmunit, smokeall four rejected, adjusted (0.03,0.04,0.04,0.02)(0.03, 0.04, 0.04, 0.02)the zoo’s comparison table
test_paired_matches_exact_scipygoldenMonte Carlo within 4.5 standard errors of scipy’s exact enumerationthe estimate converges to the right number
test_two_sample_matches_exact_scipygoldenthe same for 6 + 6, 7 + 5, 8 + 8 splitslatency and eval comparisons
test_replay_pins_the_draw_ordergoldenexact p-values for fixed seedsload.02 in Go reproduces them
test_mcnemar_matches_scipygoldenbinomtest and the chi-square, up to 360 pairsexact arithmetic, no overflow
test_holm_matches_referencegoldenadjusted p and rejections on five familiesties, 0 and 1, a single test
test_paired_type_one_ratestatisticalP(p≤0.1)=0.1P(p \le 0.1) = 0.1 under H0H_0 over 300 datasetsfalse alarms at the promised rate
test_two_sample_type_one_ratestatisticalthe same with groups of 6 and 9the shuffle really mixes the groups
test_tests_have_powerstatisticalreal shifts are detected at 0.05a test that never rejects is useless
test_draw_countsunitBnB n and B(N−1)B(N - 1) uniformsone stream in every language
test_fisher_yates_orderunitu→1u \to 1 is the identity, u=0u = 0 deals (5,1∣2,3)(5, 1 \mid 2, 3)the shuffle of section 2.4
test_two_sidedpropertyswapping A and B, or negating the statistic, leaves pp unchanged“different”, not “better”
test_add_one_and_degenerate_databoundaryp>0p > 0 always; no differences give p=1p = 1no “p = 0” in a report
test_rounding_never_splits_a_tieboundaryd=(−0.7,0.2,0.7,0.1)d = (-0.7, 0.2, 0.7, 0.1) counts 12 of 16 tiesdecimal scores tie in exact arithmetic
test_holm_steps_down_and_stopspropertystops at the first failure; equality rejects; superset of Bonferronisection 2.6
test_holm_adjust_is_monotone_and_cappedpropertyadjusted p sorted like raw p, at most 1a table column you can trust
test_mcnemar_edgesboundaryno disagreements or a tie give 1; symmetricsmall eval sets
test_rejects_bad_argumentsboundarybad lengths, NaN, n_perm < 1, bad counts, p outside [0,1][0, 1] raisebugs surface at the call
PitfallSymptomCaught by
1. a one-sided comparison by accident (no ∣⋅∣\lvert \cdot \rvert)pp near 1 when B is worse, too small when bettertest_two_sided (mutant s01)
2. count/B\text{count}/B without the add-one“p = 0.000” in a report; type-I promise brokentest_add_one_and_degenerate_data (mutant s02)
3. the naive shuffle (jj over the whole array)some orders more likely than others; another stream than Gotest_fisher_yates_order, test_replay_pins_the_draw_order (mutant s03)
4. shuffling on from the previous permutationstill a valid test, but not the contract’s streamtest_fisher_yates_order (mutant s04)
5. comparing permuted statistics with a bare ≥\geexact ties land an ulp below and are missed: pp too smalltest_rounding_never_splits_a_tie (mutant s05)
6. McNemar without doubling, without the cap, or without the continuity correctionpp halved, above 1, or too smalltest_hand_example_mcnemar, test_mcnemar_edges (mutants s06, s07, s12)
7. Bonferroni where Holm was meantreal improvements missedtest_hand_example_holm (mutant s08)
8. Holm that does not stop at the first failurea later hypothesis rejected past a failed bar: FWER brokentest_holm_steps_down_and_stops (mutant s09)
9. adjusted p-values without the running maximuma smaller raw p gets a larger adjusted onetest_holm_adjust_is_monotone_and_capped (mutant s10)
10. a relabelling that never mixes the groupsevery permutation equals the data: p=1p = 1, no powertest_two_sample_type_one_rate, test_tests_have_power (mutant s11)
DirectionModuleHow it uses this
BackM07.4the sampling distribution and the standard error; an interval and a test are two readings of it (reading)
BackS-M07cthe CLT behind the chi-square approximation (reading)
ForwardS-M07dq1 to q4 are these tests by hand
ForwardL6.7the zoo prints a paired p-value for each comparison against the baseline, Holm-adjusted over the table
ForwardL8.5“quantization costs nothing”: a paired test on per-prompt losses must not reject
ForwardL8.6speculative decoding must not change quality: McNemar on exact-match outcomes
Forwardload.02the two-sample test in Go, for p50 and p99 latency comparisons, bit-identical to yours
ForwardC1ablation verdicts over seeds
Your pieceProduction equivalentWhat it addsWhere to look
permutation_testscipy.stats.permutation_testvectorized batches, exact enumeration when it is small enough, one- and two-sided alternativesscipy/stats/_resampling.py
mcnemarstatsmodels.stats.contingency_tables.mcnemarthe same two variants, plus Cochran’s Q for more than two classifiersstatsmodels/stats/contingency_tables.py
holmstatsmodels.stats.multitest.multipletestsHochberg, Benjamini-Hochberg (false discovery rate), and othersstatsmodels/stats/multitest.py
paired tests on eval scoreslm-evaluation-harness, HELMbootstrap standard errors per task; paired comparisons across modelslm_eval/api/metrics.py (stderr_for_metric)
sign flipssequential testing (always-valid p-values)stop an A/B test early without inflating false alarmsJohari et al., “Peeking at A/B Tests” (2017)