The LLM StackFrom Silicon to Agents
Part XI — Evaluation
37 min read·Updated ·▶ Run the code (Colab)

11.6 Statistical Rigor in Evaluation: Confidence Intervals & Significance

A leaderboard says model A scores 84.2 % and model B scores 83.6 % on a 500-question benchmark. The press release announces “a new state of the art.” A careful engineer asks a different question: is 0.6 percentage points even distinguishable from noise? With 500 questions, the standard error on each number is around 1.6 points, so the gap is well inside the uncertainty. The “improvement” might be a coin flip.

This chapter is about treating evaluation as an experiment, not a scoreboard. Every benchmark number is a random variable: it depends on which items you sampled, which random seeds you used, which prompts and decoding parameters you chose, and — when a model grades a model — which judge you trusted. A score reported without an interval is a measurement reported without error bars, and in any other branch of empirical science that would be unpublishable.

We build the toolkit a scientist would demand: bootstrap confidence intervals on accuracy and Elo, paired significance tests (paired \(t\), McNemar, Wilcoxon, permutation) for the head-to-head A-vs-B comparison that practitioners actually care about, a variance decomposition that tells you where your noise lives (items? seeds? judges?), and a power analysis to size a test set before you spend money running it. We close with item-response theory (IRT) and adaptive evaluation — how to extract more signal per item — and the single most important leaderboard-reading skill: knowing that overlapping confidence intervals mean “no demonstrated gap.” Every method appears twice: once from scratch, so you understand the mechanism, and once in the library that actually ships it — scipy.stats, statsmodels, and the per-sample logs emitted by lm-evaluation-harness.

This chapter is the statistical spine of Part XI. It assumes you have met the benchmark landscape in The Evaluation Problem & Benchmark Landscape, the judge paradigm in LLM-as-a-Judge & Automated Evaluation, and harness mechanics in Building Eval Harnesses. The probability foundations live in Probability, Statistics & Information Theory. Online A/B testing — the production cousin of everything here — is in Online Evaluation: A/B Testing, Canaries & Guardrail Metrics.


Why Every Eval Number Needs an Error Bar

Accuracy is a sample statistic

When you evaluate a model on a benchmark, you score it on \(n\) items drawn from some universe of possible items. The accuracy you report, \(\hat p\), is an estimate of the true accuracy \(p\) — the rate the model would achieve on the entire (infinite, hypothetical) population the benchmark is meant to represent. Because \(\hat p\) is computed from a finite sample, it has sampling variance.

If each item is scored pass/fail (a Bernoulli trial) and items are independent, then the number correct is \(\text{Binomial}(n, p)\), and the standard error of the proportion is

\[ \text{SE}(\hat p) = \sqrt{\frac{p(1-p)}{n}} \approx \sqrt{\frac{\hat p(1-\hat p)}{n}}. \]

The familiar normal-approximation (“Wald”) 95 % confidence interval is \(\hat p \pm 1.96\,\text{SE}\). Plug in numbers: at \(\hat p = 0.84\), \(n = 500\),

\[ \text{SE} = \sqrt{\frac{0.84 \times 0.16}{500}} = \sqrt{0.0002688} \approx 0.0164, \]

so the 95 % CI is roughly \(0.84 \pm 0.032\), i.e. \([0.808, 0.872]\). That is a band more than six points wide. A rival at 0.836 sits squarely inside it. There is no measurable difference. The width shrinks only as \(1/\sqrt n\): to halve the interval you must quadruple the test set.

The width-of-CI table you should memorize

The half-width \(1.96\sqrt{p(1-p)/n}\) near \(p=0.5\) (the worst case, where variance is maximal) gives a handy rule of thumb: the 95 % margin of error is about \(1/\sqrt n\). Some magnitudes worth carrying in your head:

\(n\) (items) Margin of error at \(p\approx0.5\) Margin at \(p=0.9\)
100 \(\pm 9.8\) pts \(\pm 5.9\) pts
500 \(\pm 4.4\) pts \(\pm 2.6\) pts
1,000 \(\pm 3.1\) pts \(\pm 1.9\) pts
5,000 \(\pm 1.4\) pts \(\pm 0.8\) pts
10,000 \(\pm 1.0\) pts \(\pm 0.6\) pts

So MMLU (around 14,000 items) can resolve differences of roughly a point; a bespoke 200-item eval cannot reliably resolve anything smaller than 7 points. This single table kills most “our model beats theirs by 0.4 points” claims.

The Wald interval lies at the extremes

The normal-approximation interval is badly behaved when \(\hat p\) is near 0 or 1, or when \(n\) is small: it can produce limits below 0 or above 1, and its true coverage can dip well under 95 %. If your model scores 49/50 (\(\hat p = 0.98\)), do not report \(0.98 \pm 1.96\sqrt{0.98\times0.02/50} = 0.98 \pm 0.039\). Use a Wilson score interval or Clopper–Pearson exact interval instead (both shown below), or bootstrap. For pass/fail metrics the Wilson interval is the pragmatic default.

Two kinds of variance: items vs. generation

There are actually two independent sources of randomness, and conflating them is a classic mistake:

  1. Item sampling variance. You happened to draw these questions. A different draw of 500 questions gives a different score. This is the binomial variance above, and it is what a confidence interval over items captures.
  2. Generation/seed variance. For a fixed question, a model with temperature \(>0\) produces different outputs on different runs (different seeds), and may pass on one run and fail on the next. Greedy decoding (\(T=0\)) removes this in principle, but in practice non-determinism from GPU kernel reductions, batching, and floating-point non-associativity means even “greedy” runs can differ — see Numerical Computing, Floating Point & Precision and the sampling discussion in Sampling Strategies & Decoding Algorithms.

If you report a single greedy run, you have no estimate at all of generation variance — you are pretending it is zero. The honest protocol is to run each item \(k\) times (e.g. \(k=3\) or \(k=5\)) at your production temperature and report mean accuracy with an interval that accounts for both axes. We formalize this with a variance decomposition later in the chapter.


Confidence Intervals That Don’t Lie: Bootstrap & Beyond

The bootstrap in one paragraph

The bootstrap (Efron, 1979) is the Swiss-army knife of eval statistics because it makes almost no distributional assumptions and works for any statistic — accuracy, F1, Elo, mean judge score, BLEU, a 90th-percentile latency. The idea: your sample of \(n\) scored items is your best estimate of the population. To learn how much your statistic would wobble across hypothetical re-samples of the population, you re-sample with replacement from your own data, recompute the statistic, and repeat \(B\) times (say \(B=10{,}000\)). The spread of those \(B\) recomputed values approximates the sampling distribution of your statistic. The 2.5th and 97.5th percentiles give a 95 % percentile bootstrap interval.

\[ \hat\theta^{*(b)} = s\!\left(\{x_{i}^{*(b)}\}_{i=1}^{n}\right), \quad x^{*(b)} \sim \text{resample}(x_1,\dots,x_n), \qquad \text{CI}_{95\%} = \left[\hat\theta^{*}_{(2.5\%)},\ \hat\theta^{*}_{(97.5\%)}\right]. \]
1. original sample (n = 12) solid tile = pass · hollow x = fail 1 2 3 4 5 6 7 8 9 10 11 12 acc = 8/12 = 0.667 draw 12 indices, with replacement 2. resample (b = 1) duplicates marked *, some items never drawn 2 4 4 9 1 7 9 3 5 9 6 2 resample stat = 9/12 = 0.75 recompute stat -> drop a dot 3. bootstrap distribution each dot = one resample stat replicate b = 1 ... B = 10,000 percentile bootstrap 95% CI lo 2.5% point est. hi 97.5% no distributional assumptions -- works for accuracy, Elo, F1, p90 latency
The bootstrap: resample with replacement, recompute, repeat thousands of times. Each replicate redraws the original items with replacement (so some items are duplicated and others dropped), recomputes the statistic, and drops one more dot into the histogram; the 2.5th and 97.5th percentiles of the accumulated cloud are the 95% confidence interval, with no assumption about the underlying distribution.

Bootstrap CI, Wilson, and Clopper–Pearson — from scratch

Here is a complete, dependency-light implementation you can drop into an eval harness. It computes a percentile bootstrap CI, a BCa (bias-corrected and accelerated) bootstrap CI — which corrects for skew and is the one to prefer — and the two analytic intervals for binary outcomes.

import numpy as np
from scipy import stats  # only used for the inverse-normal and beta quantiles


def bootstrap_ci(scores, statistic=np.mean, n_boot=10_000, alpha=0.05, seed=0):
    """Percentile bootstrap CI for an arbitrary statistic of per-item scores.

    `scores`    : 1-D array of per-item results (0/1 for pass-fail, or floats
                  for judge scores, F1, etc.).
    `statistic` : any function array -> scalar (mean, median, np.percentile...).
    Returns (point_estimate, lo, hi).
    """
    rng = np.random.default_rng(seed)
    scores = np.asarray(scores, dtype=float)
    n = len(scores)
    point = statistic(scores)
    # Vectorized resampling: draw an (n_boot x n) matrix of indices at once.
    idx = rng.integers(0, n, size=(n_boot, n))
    boot = np.array([statistic(scores[row]) for row in idx])
    lo, hi = np.percentile(boot, [100 * alpha / 2, 100 * (1 - alpha / 2)])
    return point, lo, hi


def bca_bootstrap_ci(scores, statistic=np.mean, n_boot=10_000, alpha=0.05, seed=0):
    """Bias-Corrected and accelerated (BCa) bootstrap CI (Efron, 1987).

    Corrects the percentile interval for (1) median bias and (2) skew of the
    sampling distribution via a jackknife acceleration estimate. This is the
    interval to trust for skewed statistics like Elo or 90th-percentile latency.
    """
    rng = np.random.default_rng(seed)
    scores = np.asarray(scores, dtype=float)
    n = len(scores)
    theta_hat = statistic(scores)

    # 1. Bootstrap replicates.
    idx = rng.integers(0, n, size=(n_boot, n))
    boot = np.array([statistic(scores[row]) for row in idx])

    # 2. Bias-correction z0: how often replicates fall below the point estimate.
    prop = np.mean(boot < theta_hat)
    prop = min(max(prop, 1e-6), 1 - 1e-6)  # guard against 0/1 -> +-inf
    z0 = stats.norm.ppf(prop)

    # 3. Acceleration a from the jackknife (leave-one-out) distribution.
    jack = np.array([statistic(np.delete(scores, i)) for i in range(n)])
    jack_mean = jack.mean()
    num = np.sum((jack_mean - jack) ** 3)
    den = 6.0 * (np.sum((jack_mean - jack) ** 2) ** 1.5) + 1e-12
    a = num / den

    # 4. Adjusted percentiles.
    z_lo, z_hi = stats.norm.ppf(alpha / 2), stats.norm.ppf(1 - alpha / 2)
    def adjust(z):
        return stats.norm.cdf(z0 + (z0 + z) / (1 - a * (z0 + z)))
    p_lo, p_hi = adjust(z_lo), adjust(z_hi)
    lo, hi = np.percentile(boot, [100 * p_lo, 100 * p_hi])
    return theta_hat, lo, hi


def wilson_interval(k, n, alpha=0.05):
    """Wilson score interval for a binomial proportion. Well-behaved near 0/1."""
    if n == 0:
        return (0.0, 0.0, 1.0)
    z = stats.norm.ppf(1 - alpha / 2)
    p = k / n
    denom = 1 + z**2 / n
    center = (p + z**2 / (2 * n)) / denom
    half = (z / denom) * np.sqrt(p * (1 - p) / n + z**2 / (4 * n**2))
    return (p, center - half, center + half)


def clopper_pearson(k, n, alpha=0.05):
    """Exact (Clopper-Pearson) binomial interval via the Beta distribution.

    Guaranteed >= 95% coverage (conservative). Good for tiny n or extreme p.
    """
    lo = 0.0 if k == 0 else stats.beta.ppf(alpha / 2, k, n - k + 1)
    hi = 1.0 if k == n else stats.beta.ppf(1 - alpha / 2, k + 1, n - k)
    return (k / n, lo, hi)


if __name__ == "__main__":
    # 420 of 500 correct -> 84% accuracy.
    correct = np.concatenate([np.ones(420), np.zeros(80)])
    print("bootstrap   :", bootstrap_ci(correct))
    print("BCa         :", bca_bootstrap_ci(correct))
    print("Wilson      :", wilson_interval(420, 500))
    print("ClopperPears:", clopper_pearson(420, 500))
    # Extreme case: 49/50 correct -> Wald would be nonsense.
    print("Wilson 49/50:", wilson_interval(49, 50))
    print("Wald   49/50:", (0.98, 0.98 - 1.96 * (0.98 * 0.02 / 50) ** 0.5,
                                  0.98 + 1.96 * (0.98 * 0.02 / 50) ** 0.5))

Running this, the four methods agree closely for 420/500 (all give roughly \([0.807, 0.870]\)), which is reassuring — when \(n\) is large and \(p\) is mid-range, everything converges. The 49/50 case is where they diverge: Wilson gives roughly \([0.894, 0.997]\) while the naive Wald interval gives \([0.941, 1.019]\) — an upper limit above 1.0, which is nonsense. That is the bug Wilson exists to fix.

Cluster-bootstrap when items aren’t independent

Many benchmarks violate item independence: a coding benchmark may have 5 variations of the same underlying problem, an agentic eval may have multiple turns from one task, or a long-document QA set may ask 10 questions about the same passage. Resampling individual items then underestimates variance because correlated items don’t carry independent information. The fix is the cluster (block) bootstrap: resample whole groups (problems, documents, tasks) with replacement, keeping each group intact. The same code works — just resample group indices instead of item indices, then flatten. Ignoring clustering is one of the most common ways eval CIs come out too narrow.

Bootstrapping an Elo / Bradley–Terry rating

Pairwise-comparison leaderboards (LMArena-style human votes, or pairwise LLM-judge tournaments) summarize models with an Elo or, equivalently, a Bradley–Terry rating. The Bradley–Terry model says the probability that model \(i\) beats model \(j\) is a logistic function of their rating difference:

\[ P(i \succ j) = \frac{1}{1 + 10^{-(R_i - R_j)/400}} = \sigma\!\left(\frac{\ln 10}{400}(R_i - R_j)\right). \]

Ratings \(R_i\) are fit by maximum likelihood over the observed battle outcomes. The crucial point for this chapter: a single Elo number is also a point estimate with sampling error, and that error is often large — tens of points — when a model has played few battles. You estimate it by bootstrapping over battles: resample the table of (model A, model B, winner) rows with replacement, refit the ratings, and repeat. The spread of refit ratings is the CI.

import numpy as np


def fit_bradley_terry(battles, models, scale=400.0, base=10.0,
                      n_iter=200, lr=0.05, anchor=1000.0):
    """Fit BT/Elo ratings by gradient ascent on the log-likelihood.

    battles : list of (winner_idx, loser_idx). Ties can be split into two
              half-weight battles or dropped; we drop them here for clarity.
    Returns a rating per model, mean-anchored to `anchor`.
    """
    m = len(models)
    R = np.zeros(m)
    c = np.log(base) / scale  # convert rating-diff to logit units
    w = np.array([b[0] for b in battles])
    l = np.array([b[1] for b in battles])
    for _ in range(n_iter):
        # P(winner beats loser) under current ratings.
        p = 1.0 / (1.0 + np.exp(-c * (R[w] - R[l])))
        grad = np.zeros(m)
        # d/dR of sum log p: winner gets +(1-p), loser gets -(1-p).
        np.add.at(grad, w, c * (1.0 - p))
        np.add.at(grad, l, -c * (1.0 - p))
        R += lr * grad
        R -= R.mean()            # remove the unidentifiable global shift
    return R - R.mean() + anchor


def bootstrap_elo(battles, models, n_boot=1000, seed=0):
    """Bootstrap CI on Elo by resampling battles with replacement."""
    rng = np.random.default_rng(seed)
    battles = np.asarray(battles)
    n = len(battles)
    boots = np.zeros((n_boot, len(models)))
    for b in range(n_boot):
        sample = battles[rng.integers(0, n, size=n)]
        boots[b] = fit_bradley_terry([tuple(x) for x in sample], models)
    point = fit_bradley_terry([tuple(x) for x in battles], models)
    lo = np.percentile(boots, 2.5, axis=0)
    hi = np.percentile(boots, 97.5, axis=0)
    return {models[i]: (round(point[i]), round(lo[i]), round(hi[i]))
            for i in range(len(models))}


if __name__ == "__main__":
    models = ["A", "B", "C"]
    # Synthetic battles: A strong, B middling, C weak.
    rng = np.random.default_rng(1)
    true_R = {"A": 1150, "B": 1000, "C": 880}
    battles = []
    pairs = [(0, 1), (0, 2), (1, 2)]
    for _ in range(300):
        i, j = pairs[rng.integers(0, 3)]
        pi = 1 / (1 + 10 ** (-(true_R[models[i]] - true_R[models[j]]) / 400))
        if rng.random() < pi:
            battles.append((i, j))
        else:
            battles.append((j, i))
    for name, (pt, lo, hi) in bootstrap_elo(battles, models).items():
        print(f"{name}: {pt}  95% CI [{lo}, {hi}]  width={hi - lo}")

With only 300 battles split across three pairings, the per-model Elo CIs span roughly \(\pm 40\)\(60\) points. That is why real leaderboards report rating intervals and explicitly group models into tiers: if A’s interval and B’s interval overlap, the leaderboard rank between them is not statistically meaningful, no matter what the ordering of the point estimates says.


The Question You Actually Care About: Is A Better Than B?

A confidence interval on a single model is necessary but rarely the real question. The real question is comparative: does my new checkpoint beat the baseline? And here a subtle but decisive trick is available: pairing. You should evaluate both models on the same items, then test the per-item difference. This cancels the enormous item-to-item difficulty variance and gives a vastly more powerful test than comparing two independent CIs.

Why pairing crushes the variance

Suppose item difficulty varies wildly — some questions are trivial (both models pass), some are impossible (both fail), and only a minority are discriminating (one passes, one fails). For independent (unpaired) comparison, the variance of \(\hat p_A - \hat p_B\) is \(\text{Var}(\hat p_A) + \text{Var}(\hat p_B)\). For the paired difference \(d_i = \text{score}_A(i) - \text{score}_B(i)\), the variance is

\[ \text{Var}(\bar d) = \frac{\text{Var}(d_i)}{n} = \frac{\sigma_A^2 + \sigma_B^2 - 2\,\text{Cov}(A,B)}{n}. \]

Because two competent models tend to pass and fail the same easy/hard items, \(\text{Cov}(A,B)\) is large and positive, so the paired variance is much smaller. In practice pairing can be equivalent to a 4–10× larger unpaired test set. Always pair when you can.

Three paired tests and when to use each

Test Data type Null hypothesis Use when
McNemar’s test binary pass/fail discordant pairs equally likely either way accuracy / pass-rate on the same items
Paired \(t\)-test continuous (judge scores, F1, log-prob) mean difference \(= 0\) per-item numeric scores, roughly symmetric diffs
Wilcoxon signed-rank continuous, non-normal median difference \(= 0\) skewed/heavy-tailed per-item diffs
Paired permutation anything the A/B labels are exchangeable within each item corpus-level metrics (BLEU, macro-F1, pass@k) where the metric is not a per-item mean

The last row matters more than it looks. McNemar and the paired \(t\)-test assume your score is an average of per-item numbers, so a per-item difference exists. Many metrics are not: corpus BLEU, macro-averaged F1, pass@k pooled across problems, and “win rate against a reference judged by GPT-class model” are all computed from the whole set at once. For those, the paired permutation (randomization) test is the general-purpose answer, and it is the test Dror et al. recommend as the NLP default: under the null that the two systems are interchangeable, you may swap A’s and B’s outputs on any item without changing the distribution of the metric. So flip a fair coin per item, recompute the metric difference on the swapped data \(B\) times, and the p-value is the fraction of permuted differences at least as extreme as the observed one (with the standard \(+1\) in numerator and denominator, which keeps the p-value from ever being exactly 0).

McNemar’s test is the right tool for the most common case — both models scored pass/fail on the same items. It looks only at the discordant pairs: items where exactly one model was right. Let \(b\) = count where A right, B wrong, and \(c\) = count where A wrong, B right. Concordant pairs (both right, both wrong) carry no information about which is better and are discarded. Under the null “the two models are equally good,” each discordant item is a coin flip, so \(b \sim \text{Binomial}(b+c, 0.5)\). The test statistic is

\[ \chi^2 = \frac{(|b - c| - 1)^2}{b + c} \sim \chi^2_1 \quad\text{(with continuity correction)}, \]

and for small \(b+c\) you should use the exact binomial test instead of the chi-square approximation.

Only the discordant cells carry comparative signal worked example from the text: 500-item paired eval, A = 82.6%, B = 79.0% B correct B wrong A correct A wrong 372 both right CONCORDANT 38 both wrong CONCORDANT b = 51 A right, B wrong DISCORDANT c = 33 A wrong, B right DISCORDANT concordant -- no comparative signal, discarded discordant -- the ONLY items that vote McNemar intuition H 0 fair coin 84 discordant items, each a coin flip. Is 51 vs 33 more lopsided than chance allows? pairing cancels the difficulty variance living in the diagonal -- worth a 4-10x larger unpaired test set
In a paired comparison, only the off-diagonal cells vote. The 372 items both models got right and the 38 both got wrong (the diagonal) tell you nothing about which model is better -- they're discarded. McNemar's test looks only at the 51-vs-33 split of the 84 discordant items, asking whether that split is more lopsided than a fair coin would produce.

Paired tests — from scratch

import numpy as np
from scipy import stats


def mcnemar_test(a_correct, b_correct, exact=True):
    """Paired test for two models scored pass/fail on the SAME items.

    a_correct, b_correct : boolean arrays of per-item correctness.
    Returns (b, c, p_value) where
        b = # items A right & B wrong,
        c = # items A wrong & B right.
    """
    a = np.asarray(a_correct, dtype=bool)
    b_ = np.asarray(b_correct, dtype=bool)
    b = int(np.sum(a & ~b_))   # A beats B on this item
    c = int(np.sum(~a & b_))   # B beats A on this item
    n_disc = b + c
    if n_disc == 0:
        return b, c, 1.0       # models never disagree -> no evidence
    if exact and n_disc < 25:
        # Exact two-sided binomial test on the discordant pairs.
        p = stats.binomtest(b, n_disc, 0.5).pvalue
    else:
        chi2 = (abs(b - c) - 1) ** 2 / n_disc   # continuity-corrected
        p = stats.chi2.sf(chi2, df=1)
    return b, c, p


def paired_t_test(scores_a, scores_b):
    """Paired t-test on per-item numeric scores (e.g. judge ratings 1-10)."""
    d = np.asarray(scores_a, dtype=float) - np.asarray(scores_b, dtype=float)
    n = len(d)
    mean_d = d.mean()
    se = d.std(ddof=1) / np.sqrt(n)
    t = mean_d / se if se > 0 else 0.0
    p = 2 * stats.t.sf(abs(t), df=n - 1)
    # Effect size: Cohen's d_z for paired designs.
    dz = mean_d / d.std(ddof=1) if d.std(ddof=1) > 0 else 0.0
    return {"mean_diff": mean_d, "t": t, "df": n - 1, "p": p, "cohen_dz": dz}


def wilcoxon_signed_rank(scores_a, scores_b):
    """Non-parametric paired test; robust to non-normal per-item diffs."""
    a, b = np.asarray(scores_a, float), np.asarray(scores_b, float)
    res = stats.wilcoxon(a, b, zero_method="wilcox", correction=False,
                         alternative="two-sided")
    return {"statistic": res.statistic, "p": res.pvalue}


def paired_permutation_test(scores_a, scores_b, metric=np.mean,
                            n_perm=10_000, seed=0):
    """Paired randomization test for an ARBITRARY corpus-level metric.

    `metric` maps a full array of per-item outputs -> scalar (mean, but also
    corpus BLEU, macro-F1, a pass@k pooler...). Under the null, swapping A's
    and B's output on an item is a no-op, so we swap each item with prob 0.5.
    """
    rng = np.random.default_rng(seed)
    a = np.asarray(scores_a, dtype=float)
    b = np.asarray(scores_b, dtype=float)
    observed = abs(metric(a) - metric(b))
    n, count = len(a), 0
    for _ in range(n_perm):
        swap = rng.random(n) < 0.5           # independent coin flip per item
        a_p = np.where(swap, b, a)
        b_p = np.where(swap, a, b)
        count += abs(metric(a_p) - metric(b_p)) >= observed
    return (count + 1) / (n_perm + 1)        # +1/+1: never report p = 0


def paired_bootstrap_diff(a_correct, b_correct, n_boot=10_000, seed=0):
    """Bootstrap CI on the accuracy DIFFERENCE, resampling item indices once
    and applying the SAME resample to both models (this preserves pairing)."""
    rng = np.random.default_rng(seed)
    a = np.asarray(a_correct, float)
    b = np.asarray(b_correct, float)
    n = len(a)
    idx = rng.integers(0, n, size=(n_boot, n))
    diffs = a[idx].mean(axis=1) - b[idx].mean(axis=1)
    point = a.mean() - b.mean()
    lo, hi = np.percentile(diffs, [2.5, 97.5])
    return point, lo, hi


if __name__ == "__main__":
    rng = np.random.default_rng(7)
    n = 500
    # Shared latent difficulty -> strong positive correlation between models.
    difficulty = rng.normal(0, 1, n)
    skill_a, skill_b = 0.45, 0.30          # A is genuinely a bit better
    a_correct = (rng.normal(skill_a, 0.6, n) > difficulty)
    b_correct = (rng.normal(skill_b, 0.6, n) > difficulty)

    print("acc A:", a_correct.mean(), " acc B:", b_correct.mean())
    b, c, p = mcnemar_test(a_correct, b_correct)
    print(f"McNemar: b(A>B)={b}, c(B>A)={c}, p={p:.4f}")
    pt, lo, hi = paired_bootstrap_diff(a_correct, b_correct)
    print(f"paired bootstrap diff: {pt:+.3f}  95% CI [{lo:+.3f}, {hi:+.3f}]")
    p_perm = paired_permutation_test(a_correct.astype(float),
                                     b_correct.astype(float), n_perm=2000)
    print(f"paired permutation p = {p_perm:.4f}")

Worked example: pairing rescues a borderline result

A team compares fine-tune A against baseline B on a 500-item eval. Headline accuracies: A = 82.6 %, B = 79.0 % — a 3.6-point gap.

Naive unpaired check. Each model’s Wald SE is about \(\sqrt{0.8\times0.2/500}\approx 0.0179\). The SE of the difference (treating them as independent) is \(\sqrt{0.0179^2 + 0.0179^2}\approx 0.0253\), so the unpaired 95 % CI on the gap is \(0.036 \pm 0.050 = [-0.014,\ 0.086]\). It crosses zero — by this analysis you cannot claim A is better.

Paired analysis on the same 500 items. Cross-tabulating: 372 items both got right, 38 both got wrong, \(b = 51\) items where only A was right, \(c = 33\) where only B was right. There are only \(b+c = 84\) discordant items. McNemar’s chi-square is

\[ \chi^2 = \frac{(|51-33|-1)^2}{84} = \frac{17^2}{84} = \frac{289}{84} \approx 3.44, \]

giving \(p \approx 0.064\). The paired bootstrap CI on the accuracy difference is roughly \([+0.004,\ +0.069]\) — it barely excludes zero. Pairing shrank the interval from \(\pm 0.050\) to about \(\pm 0.033\) because the two models agreed on 410 of 500 items, and those agreements carried no comparative signal but did inflate the unpaired variance. The honest verdict: suggestive (\(p\approx 0.06\)) but not conclusive at \(\alpha=0.05\) — you would want more items or more discriminating items before shipping a “beats baseline” claim. This is exactly the kind of nuance a single accuracy number hides.

Multiple comparisons: the leaderboard trap

If you test 20 candidate checkpoints against a baseline at \(\alpha = 0.05\), you expect one false positive on average even if none is truly better — because \(0.05 \times 20 = 1\). Leaderboards with hundreds of models are multiple-comparison machines: some model will look “significantly best” by chance. Two standard corrections:

  • Bonferroni: test each comparison at \(\alpha/m\) for \(m\) comparisons. Simple, conservative, controls the family-wise error rate (probability of any false positive).
  • Benjamini–Hochberg (FDR): sort the \(m\) p-values, find the largest \(k\) with \(p_{(k)} \le \frac{k}{m}\alpha\), reject all below it. Controls the false discovery rate (expected fraction of false positives among rejections) — more powerful and usually the right choice when you are screening many candidates.
import numpy as np

def benjamini_hochberg(pvals, alpha=0.05):
    """Return a boolean mask of which hypotheses are rejected under BH-FDR."""
    p = np.asarray(pvals)
    m = len(p)
    order = np.argsort(p)
    thresh = (np.arange(1, m + 1) / m) * alpha
    passed = p[order] <= thresh
    if not passed.any():
        return np.zeros(m, dtype=bool)
    k_max = np.max(np.where(passed))      # largest index that passes
    cutoff = p[order][k_max]
    return p <= cutoff

The library layer: SciPy, statsmodels, and lm-evaluation-harness logs

You wrote all of the above from scratch so you know exactly what it does; in a real harness you should call the maintained implementations, which handle edge cases (ties, zero differences, degenerate resamples) you do not want to rediscover. The mapping is one-to-one:

import numpy as np
from scipy import stats
from statsmodels.stats.contingency_tables import mcnemar
from statsmodels.stats.multitest import multipletests
from statsmodels.stats.power import NormalIndPower
from statsmodels.stats.proportion import proportion_effectsize

rng = np.random.default_rng(7)
difficulty = rng.normal(0, 1, 500)                          # shared per item
a = (rng.normal(0.45, 0.6, 500) > difficulty).astype(float) # correctness, A
b = (rng.normal(0.30, 0.6, 500) > difficulty).astype(float) # correctness, B

# 1. BCa bootstrap CI on the PAIRED accuracy difference.
#    paired=True makes SciPy apply the same resampled indices to both arrays.
res = stats.bootstrap(
    (a, b),
    lambda x, y, axis=-1: x.mean(axis=axis) - y.mean(axis=axis),
    paired=True, vectorized=True, method="BCa",
    n_resamples=10_000, confidence_level=0.95, random_state=0)
print("diff CI:", res.confidence_interval, "SE:", res.standard_error)

# 2. Exact / continuity-corrected McNemar from a 2x2 contingency table.
both  = int(np.sum((a == 1) & (b == 1)));  nb = int(np.sum((a == 1) & (b == 0)))
nc    = int(np.sum((a == 0) & (b == 1)));  neither = int(np.sum((a == 0) & (b == 0)))
print(mcnemar([[both, nb], [nc, neither]], exact=False, correction=True))

# 3. Paired permutation test for any statistic (permutation_type="samples").
print(stats.permutation_test(
    (a, b), lambda x, y: np.mean(x) - np.mean(y),
    permutation_type="samples", n_resamples=10_000,
    vectorized=False, random_state=0).pvalue)

# 4. Benjamini-Hochberg across many checkpoint-vs-baseline p-values.
reject, p_adj, _, _ = multipletests([0.001, 0.02, 0.04, 0.3],
                                    alpha=0.05, method="fdr_bh")

# 5. Power: items per arm to detect 80% vs 81% (unpaired), 80% power.
#    Prints ~24,600 -- agreeing with the closed form worked by hand below.
print(NormalIndPower().solve_power(proportion_effectsize(0.81, 0.80),
                                   power=0.80, alpha=0.05, ratio=1.0))

The missing link in practice is getting per-item results out of your eval run at all — an aggregate accuracy cannot be paired with anything. lm-evaluation-harness exposes this with --log_samples --output_path <dir>, which writes one JSONL record per document (containing the doc_id, the model’s filtered response, and the per-document metric values) next to the aggregate results.json. Run both models with identical --tasks, --num_fewshot, and seed, then join on doc_id:

import json, glob
import numpy as np


def load_per_item(samples_glob, metric="acc"):
    """Read lm-evaluation-harness --log_samples JSONL into {doc_id: score}.

    Field names shift a little across harness versions, so we key on `doc_id`
    and read whichever metric key the task emitted (`acc`, `exact_match`,
    `acc_norm`, ...). Always compare runs produced by the SAME harness commit.
    """
    out = {}
    for path in glob.glob(samples_glob):
        with open(path) as f:
            for line in f:
                rec = json.loads(line)
                if metric in rec:
                    out[rec["doc_id"]] = float(rec[metric])
    return out


def paired_vectors(dir_a, dir_b, task, metric="acc"):
    """Align two runs on their shared doc_ids -> (a_scores, b_scores)."""
    A = load_per_item(f"{dir_a}/**/samples_{task}_*.jsonl", metric)
    B = load_per_item(f"{dir_b}/**/samples_{task}_*.jsonl", metric)
    shared = sorted(set(A) & set(B))          # defensive: runs can differ
    return (np.array([A[i] for i in shared]),
            np.array([B[i] for i in shared]))

Feed those two vectors to mcnemar_test, paired_bootstrap_diff, or the SciPy equivalents above and you have a real significance verdict rather than two point estimates. Two cautions about the numbers the harness itself prints. First, lm-eval’s reported acc_stderr is the item-level standard error only (computed in closed form for means, and by an internal bootstrap — sized by bootstrap_iters — for metrics without one); it knows nothing about prompt-template or seed variance, so it is a floor on your true uncertainty, not the whole of it. Second, it is a marginal SE for one model: never compare two models by checking whether \(\hat p_A \pm 1.96\,\text{SE}_A\) overlaps \(\hat p_B \pm 1.96\,\text{SE}_B\) when you could pair. Harness mechanics — task YAML, --log_samples layout, bootstrap_iters, and run-record hygiene — are covered in Building Eval Harnesses.


Variance Decomposition: Where Does Your Noise Live?

A benchmark score is buffeted by several noise sources at once. Knowing which one dominates tells you what to fix: more items, more seeds, prompt averaging, or a better judge. The framework is the variance components model from classical experimental design (and, in psychometrics, generalizability theory).

A linear variance model

Model the score of model \(m\) on item \(i\), under prompt template \(t\), seed \(s\), judged by judge \(j\) as

\[ y_{mitsj} = \mu + \alpha_m + \beta_i + \gamma_t + \delta_s + \zeta_j + \varepsilon_{mitsj}, \]

where each term is a zero-mean random effect with its own variance: \(\sigma^2_{\text{item}}\) (item difficulty), \(\sigma^2_{\text{prompt}}\) (prompt-template sensitivity), \(\sigma^2_{\text{seed}}\) (generation noise), \(\sigma^2_{\text{judge}}\) (judge disagreement), and residual \(\sigma^2_{\varepsilon}\). The variance of your reported mean over \(n_i\) items, \(n_t\) prompts, \(n_s\) seeds, and \(n_j\) judges is approximately

\[ \text{Var}(\bar y) \approx \frac{\sigma^2_{\text{item}}}{n_i} + \frac{\sigma^2_{\text{prompt}}}{n_t} + \frac{\sigma^2_{\text{seed}}}{n_i n_s} + \frac{\sigma^2_{\text{judge}}}{n_j} + \frac{\sigma^2_{\varepsilon}}{n_i n_s n_j}. \]

The practical lesson is in the denominators. If \(\sigma^2_{\text{prompt}}\) is large and you used one prompt template, that term is divided by \(n_t = 1\) — no amount of extra items reduces it. This is why “we changed the system prompt and the score moved 4 points” is so common: prompt variance is frequently the dominant term, and it is invisible to an item-bootstrap CI. The robustness chapter Red-Teaming, Safety & Robustness Evaluation treats prompt sensitivity as a first-class failure mode.

Variance budget of the reported mean: five sources, five denominators Var(y-bar) ~ item/n_i + prompt/n_t + seed/(n_i x n_s) + judge/n_j + residual/(n_i x n_s x n_j) bar height = relative contribution (schematic, not fitted values); n_i, n_t, n_s, n_j are illustrative sample counts n_t: 1 -> 3 (n_i = 500, n_s = 3, n_j = 3 held fixed) LEFT: single prompt (n_t = 1) n_i=500, n_s=3, n_j=3 RIGHT: n_t = 3 prompts n_i=500, n_s=3, n_j=3 PROMPT / n_t n_t = 1 invisible to an item-only CI n_t = 1 -> /1, never shrinks PROMPT / n_t prompt block / n_t n_t = 3 -> shrinks to about a third item / n_i n_i = 500 (large) -> shrinks only THIS block seed / (n_i x n_s) judge / n_j residual / (n_i x n_s x n_j) You cannot fix prompt variance by buying more items -- vary the prompt.
Each variance source shrinks with its own sample count, not with the others. With a single prompt template (left), the prompt term is divided by n_t = 1 and stays large no matter how many items you add -- it is invisible to an item-bootstrap confidence interval. Move to three prompt templates (right) and that same term shrinks to about a third, while the item, seed, judge, and residual blocks -- which depend on n_i, n_s, and n_j -- are untouched.

Estimating the components

A clean way to estimate components is a crossed design: run the eval over a grid of \(n_i\) items \(\times\) \(n_t\) prompts \(\times\) \(n_s\) seeds and fit the variances by ANOVA or REML. Here is a compact estimator for the two axes practitioners most often neglect — prompt and seed — using nested means.

import numpy as np


def variance_components(scores):
    """Estimate item / prompt / seed variance components from a crossed grid.

    scores : array of shape (n_items, n_prompts, n_seeds), each entry the
             per-(item,prompt,seed) score for ONE model (0/1 or float).
    Returns a dict of variance components and the implied SE of the grand mean.
    """
    s = np.asarray(scores, dtype=float)
    n_i, n_t, n_k = s.shape
    grand = s.mean()

    # Marginal means along each axis.
    item_means   = s.mean(axis=(1, 2))   # average over prompts & seeds
    prompt_means = s.mean(axis=(0, 2))   # average over items & seeds
    # Variance of marginal means, de-biased by the within noise they still carry
    # (Method-of-moments; fine for a diagnostic, use REML for a paper.)
    var_item   = max(item_means.var(ddof=1)   - 0.0, 0.0)
    var_prompt = max(prompt_means.var(ddof=1) - 0.0, 0.0)
    # Seed/residual: variance within an (item,prompt) cell, averaged.
    within = s.var(axis=2, ddof=1).mean() if n_k > 1 else 0.0

    se_mean = np.sqrt(var_item / n_i + var_prompt / n_t + within / (n_i * n_k))
    return {
        "grand_mean": grand,
        "var_item": var_item,
        "var_prompt": var_prompt,
        "var_seed_resid": within,
        "se_of_reported_mean": se_mean,
    }


if __name__ == "__main__":
    rng = np.random.default_rng(0)
    n_i, n_t, n_k = 200, 4, 3
    item_eff   = rng.normal(0, 1.2, n_i)      # big item-difficulty spread
    prompt_eff = rng.normal(0, 0.5, n_t)      # nontrivial prompt sensitivity
    logits = (0.4 + item_eff[:, None, None] + prompt_eff[None, :, None]
              + rng.normal(0, 0.3, (n_i, n_t, n_k)))
    scores = (logits > 0).astype(float)       # pass/fail
    for k, v in variance_components(scores).items():
        print(f"{k:>22}: {v:.4f}")

The output makes the dependence concrete: with strong item effects and four prompts, the prompt term may rival the item term in the SE budget even though each individual prompt contributes only modestly. If you had reported a single prompt, your CI would have been a fiction.

Report on at least 3 prompts and 3 seeds

A cheap, defensible protocol: evaluate every model on \(\ge 3\) paraphrased prompt templates and \(\ge 3\) seeds, report the mean and a CI computed by bootstrapping over the (item, prompt, seed) tuples (use a cluster bootstrap that resamples items as blocks). This folds prompt and seed variance into the interval automatically and immunizes you against the “we picked the lucky prompt” critique. The cost is a 9× compute increase — often worth it for a headline claim, skippable for a quick dev-loop check.


Power Analysis: Sizing the Test Set Before You Run It

Confidence intervals are retrospective — they describe the experiment you already ran. Power analysis is prospective: given the smallest effect you would care about, how many items do you need so that, if the effect is real, your test will actually detect it? Running an underpowered eval is the worst of both worlds: you spend the compute and still cannot conclude anything.

The four interlocking quantities

Power analysis ties together four numbers; fix any three and the fourth is determined:

  • \(\alpha\) — false-positive rate (typically 0.05). \(z_{1-\alpha/2} = 1.96\) for a two-sided test.
  • Power \(1-\beta\) — probability of detecting a true effect (typically 0.80; sometimes 0.90). \(z_{1-\beta} = 0.84\) for 80 %.
  • Effect size \(\Delta\) — the minimum difference worth detecting (the minimum detectable effect, MDE).
  • \(n\) — the number of items.

For comparing two proportions on paired data, the McNemar-based sample size depends on the discordant-pair rate. A convenient and widely-used approximation for the number of items needed, given expected discordant proportion \(p_d = p_b + p_c\) and effect \(p_b - p_c\), is

\[ n \approx \frac{\left(z_{1-\alpha/2}\sqrt{p_d} + z_{1-\beta}\sqrt{p_d - (p_b - p_c)^2}\right)^2}{(p_b - p_c)^2}. \]

For the simpler unpaired two-proportion case (or a back-of-envelope), the classic formula with pooled proportion \(\bar p\) is

\[ n_{\text{per arm}} \approx \frac{\left(z_{1-\alpha/2} + z_{1-\beta}\right)^2 \cdot 2\,\bar p (1-\bar p)}{\Delta^2}. \]

Power analysis — from scratch, including simulation

Closed forms rely on normal approximations; for paired binary outcomes with small discordant counts, a simulation-based power analysis is more trustworthy and barely more code. You assume a data-generating process, simulate the experiment thousands of times, and count how often the test rejects.

import numpy as np
from scipy import stats


def n_for_unpaired_proportions(p1, p2, alpha=0.05, power=0.80):
    """Sample size PER ARM to detect a difference between two proportions."""
    z_a = stats.norm.ppf(1 - alpha / 2)
    z_b = stats.norm.ppf(power)
    pbar = (p1 + p2) / 2
    delta = abs(p1 - p2)
    return int(np.ceil((z_a + z_b) ** 2 * 2 * pbar * (1 - pbar) / delta ** 2))


def n_for_mcnemar(p_b, p_c, alpha=0.05, power=0.80):
    """Number of items for a paired McNemar test.

    p_b : P(A right, B wrong);  p_c : P(A wrong, B right).
    Larger discordance (p_b+p_c) -> fewer items needed.
    """
    z_a = stats.norm.ppf(1 - alpha / 2)
    z_b = stats.norm.ppf(power)
    pd = p_b + p_c
    diff = p_b - p_c
    num = (z_a * np.sqrt(pd) + z_b * np.sqrt(pd - diff ** 2)) ** 2
    return int(np.ceil(num / diff ** 2))


def simulate_power(p_both, p_b, p_c, n, n_sims=4000, alpha=0.05, seed=0):
    """Monte-Carlo power for the paired McNemar test at sample size n.

    Each item falls into one of four cells with the given probabilities:
      both-right, A-right-only (p_b), B-right-only (p_c), both-wrong.
    """
    rng = np.random.default_rng(seed)
    p_neither = max(0.0, 1 - p_both - p_b - p_c)  # clamp: fp rounding can
                                                   # make this a tiny negative
                                                   # number when the inputs
                                                   # sum to exactly 1.0
    probs = [p_both, p_b, p_c, p_neither]
    rejects = 0
    for _ in range(n_sims):
        counts = rng.multinomial(n, probs)
        b, c = counts[1], counts[2]
        nd = b + c
        if nd == 0:
            pval = 1.0
        elif nd < 25:
            pval = stats.binomtest(b, nd, 0.5).pvalue
        else:
            chi2 = (abs(b - c) - 1) ** 2 / nd
            pval = stats.chi2.sf(chi2, df=1)
        rejects += (pval < alpha)
    return rejects / n_sims


if __name__ == "__main__":
    # We want to detect a true 3-point accuracy edge for A.
    # Suppose models agree 80% of the time; of the 20% discordant items,
    # A wins on 11.5% and B on 8.5% -> a 3-point gap.
    p_b, p_c = 0.115, 0.085
    n_closed = n_for_mcnemar(p_b, p_c)
    print(f"closed-form n for 80% power: {n_closed}")
    for n in [n_closed, 2 * n_closed]:
        pw = simulate_power(p_both=0.80, p_b=p_b, p_c=p_c, n=n)
        print(f"  n={n:5d} -> simulated power {pw:.2f}")
    # Compare to the UNPAIRED requirement for the same 3-point gap at p~0.8.
    print("unpaired n PER ARM:", n_for_unpaired_proportions(0.83, 0.80))

How big a test set to catch a 1-point gap?

You want to reliably detect a 1-point accuracy difference (say 80.0 % vs 81.0 %) at \(\alpha=0.05\), power 0.80. Using the unpaired formula with \(\bar p = 0.805\) and \(\Delta=0.01\):

\[ n \approx \frac{(1.96 + 0.84)^2 \cdot 2 \times 0.805 \times 0.195}{0.01^2} = \frac{7.84 \times 0.3140}{0.0001} \approx 24{,}600 \text{ per arm}. \]

Roughly 25,000 items per model — which is why no 500-item benchmark can adjudicate a one-point claim, and why frontier leaderboards still cannot cleanly separate the top few models. Now switch to a paired design: if the models agree on 85 % of items, the effective discordant sample is far richer, and n_for_mcnemar returns on the order of a few thousand items for the same 1-point effect — an order-of-magnitude saving from pairing alone. The takeaway: choose your MDE honestly, then either accept that small gaps need huge test sets, or pair aggressively, or stop reporting differences you cannot resolve.


Squeezing More Signal Per Item: IRT & Adaptive Evaluation

Power analysis says “buy more items.” But items are not equally informative — a question every model gets right (or every model gets wrong) tells you nothing about relative ability. Item Response Theory (IRT), the measurement framework behind standardized tests like the SAT and GRE, formalizes this and lets you (a) build a better ability estimate from the same items and (b) select the most informative items to ask, cutting eval cost dramatically.

The 2-parameter logistic model

In the 2PL IRT model, the probability that a test-taker (here, a model) with latent ability \(\theta\) answers item \(i\) correctly is

\[ P(\text{correct} \mid \theta, a_i, b_i) = \sigma\big(a_i(\theta - b_i)\big) = \frac{1}{1 + e^{-a_i(\theta - b_i)}}, \]

where \(b_i\) is the item’s difficulty (the ability at which a model has a 50 % chance) and \(a_i\) is its discrimination (how sharply the pass probability rises with ability). High-\(a_i\) items are the ones that cleanly separate strong from weak models; low-\(a_i\) items (everyone guesses, or a typo makes the answer ambiguous) are noise. Note the structural kinship with Bradley–Terry — both are logistic latent-trait models — and with the logistic regression in Machine Learning Fundamentals.

The Fisher information an item contributes about ability \(\theta\) is

\[ I_i(\theta) = a_i^2\, P_i(\theta)\big(1 - P_i(\theta)\big), \]

maximized when \(P_i(\theta) = 0.5\), i.e. when the item’s difficulty matches the model’s ability. This is the engine of adaptive testing: ask each model items near its own ability frontier, where each answer is maximally informative, and you reach a target precision with a fraction of the items.

Item information peaks where difficulty matches the model's ability top: item characteristic curves P(theta) -- bottom: Fisher information I(theta) = a^2 P(1-P) -- same ability axis P(correct | theta) 1 0 .5 P(correct) high discrimination (steep a) low discrimination (shallow a) difficulty: 50% pass point (theta = b, one per item) I(theta) = a^2 P(1-P) 0 information tall & narrow: cleanly separates strong from weak models short & broad: everyone guesses -- mostly noise low high ability (theta) theta-hat model's current ability estimate Adaptive testing serves the item whose peak sits at theta-hat -- fixed-test precision in a fraction of the items.
Fisher information peaks exactly where an item's difficulty matches the model's ability. A high-discrimination item (solid) produces a tall, narrow information peak that cleanly separates strong from weak models right where it matters; a low-discrimination item (dashed) produces a short, broad peak that is mostly noise everywhere. Adaptive testing exploits this by serving each model items whose peak sits near its current ability estimate theta-hat, reaching a fixed test's precision in a fraction of the items.

Fitting a tiny IRT model and computing item information

import numpy as np
from scipy.optimize import minimize


def fit_2pl(R, n_iter=300):
    """Fit a 2PL IRT model by alternating MAP estimation.

    R : (n_models x n_items) binary response matrix (1 = correct).
    Returns theta (ability per model), a (discrimination), b (difficulty).
    Priors: theta,b ~ N(0,1); log a ~ N(0,1) keeps discriminations positive.
    """
    n_m, n_i = R.shape
    theta = np.zeros(n_m)
    a = np.ones(n_i)
    b = np.zeros(n_i)

    def sig(x):
        return 1.0 / (1.0 + np.exp(-x))

    for _ in range(n_iter):
        # --- E-ish step: update abilities given items ---
        for m in range(n_m):
            def negll_theta(t):
                p = sig(a * (t[0] - b))
                ll = np.sum(R[m] * np.log(p + 1e-9) +
                            (1 - R[m]) * np.log(1 - p + 1e-9))
                return -(ll - 0.5 * t[0] ** 2)        # + N(0,1) prior
            theta[m] = minimize(negll_theta, [theta[m]], method="BFGS").x[0]
        theta -= theta.mean()                         # identifiability anchor

        # --- M-ish step: update item params given abilities ---
        for i in range(n_i):
            def negll_item(par):
                a_i, b_i = np.exp(par[0]), par[1]     # exp keeps a_i > 0
                p = sig(a_i * (theta - b_i))
                ll = np.sum(R[:, i] * np.log(p + 1e-9) +
                            (1 - R[:, i]) * np.log(1 - p + 1e-9))
                return -(ll - 0.5 * par[0] ** 2 - 0.5 * b_i ** 2)
            res = minimize(negll_item, [np.log(a[i]), b[i]], method="BFGS")
            a[i], b[i] = np.exp(res.x[0]), res.x[1]
    return theta, a, b


def item_information(theta, a, b):
    """Fisher information each item gives about a model at ability theta."""
    p = 1.0 / (1.0 + np.exp(-a * (theta - b)))
    return a ** 2 * p * (1 - p)


if __name__ == "__main__":
    rng = np.random.default_rng(0)
    n_m, n_i = 30, 120
    true_theta = rng.normal(0, 1, n_m)
    true_a = np.abs(rng.normal(1.0, 0.4, n_i)) + 0.2
    true_b = rng.normal(0, 1, n_i)
    P = 1 / (1 + np.exp(-true_a * (true_theta[:, None] - true_b[None, :])))
    R = (rng.random((n_m, n_i)) < P).astype(float)

    theta, a, b = fit_2pl(R)
    # Recovery check: correlation between true and estimated ability.
    print("ability recovery r =", np.corrcoef(true_theta, theta)[0, 1].round(3))
    # The 10 most discriminating items -- these are worth their weight.
    top = np.argsort(a)[::-1][:10]
    print("most informative items (idx, a, b):",
          [(int(i), round(a[i], 2), round(b[i], 2)) for i in top])

Adaptive evaluation: same precision, a fraction of the items

Once items are calibrated (their \(a_i, b_i\) are known from a reference panel of models), evaluating a new model adaptively is cheap: ask an item near the current ability estimate, update the estimate, repeat until the standard error on \(\hat\theta\) drops below a threshold. Because each item is chosen to maximize information at the current \(\hat\theta\), this computerized adaptive testing (CAT) loop typically reaches the precision of a fixed 100-item test in 20–40 items. This is the statistical core behind “efficient benchmarking” work (e.g. tinyBenchmarks, Anchor Points, and IRT-based leaderboard analyses): you do not need all 14,000 MMLU items if you have calibrated the item bank — a carefully chosen few hundred recover the full-benchmark ranking within its own confidence band.

import numpy as np
from scipy.optimize import minimize_scalar


def adaptive_eval(answer_fn, a, b, max_items=40, target_se=0.30):
    """Computerized adaptive test against a pre-calibrated item bank.

    answer_fn(i) -> 0/1 : runs the model on item i, returns correctness.
    a, b                : calibrated discrimination/difficulty per item.
    Stops when SE(theta_hat) < target_se or max_items reached.
    """
    asked, resp = [], []
    theta = 0.0
    remaining = list(range(len(a)))
    for _ in range(max_items):
        # Pick the unasked item with max Fisher information at current theta.
        info = [(item_information(theta, a[i], b[i]), i) for i in remaining]
        _, best = max(info)
        y = answer_fn(best)
        asked.append(best); resp.append(y); remaining.remove(best)

        # Re-estimate ability by MAP over items asked so far.
        aa, bb, yy = a[asked], b[asked], np.array(resp, float)
        def negll(t):
            p = 1 / (1 + np.exp(-aa * (t - bb)))
            return -(np.sum(yy * np.log(p + 1e-9) +
                            (1 - yy) * np.log(1 - p + 1e-9)) - 0.5 * t ** 2)
        theta = minimize_scalar(negll, bounds=(-4, 4), method="bounded").x

        # SE from the inverse total information at the current estimate.
        tot_info = sum(item_information(theta, a[i], b[i]) for i in asked) + 1.0
        se = 1 / np.sqrt(tot_info)
        if se < target_se:
            break
    return theta, se, len(asked)


def item_information(theta, a, b):
    p = 1.0 / (1.0 + np.exp(-a * (theta - b)))
    return a ** 2 * p * (1 - p)

Adaptive evaluation needs a calibrated, uncontaminated bank

IRT-based shortcuts are only valid if the item parameters were estimated on a representative panel of models and the items are not in the model’s training data. A contaminated item (memorized from training) looks artificially easy and corrupts the calibration — see the contamination discussion in The Evaluation Problem & Benchmark Landscape. Re-calibrate when the model population shifts (e.g., a new generation of reasoning models), because an item that was discriminating for last year’s models may be trivially solved by this year’s.


Statistics at 100M Scale: Chance Floors and Validation Loss

Everything above assumed a model good enough that accuracy is the interesting signal. When you evaluate the ~100M-parameter model built in Evaluation & Serving: Honest Benchmarks, int4 Quantization, and Running on a Laptop, two extra statistical facts dominate, and getting them wrong is the single most common way small-model reports become nonsense.

Measure against the chance floor, not against zero

A four-way multiple-choice benchmark has a chance floor of \(p_0 = 0.25\). A 100M model that scores 27 % on such a task has demonstrated nothing: the null hypothesis is not “accuracy is 0,” it is “accuracy is 0.25.” Near the floor the standard error is \(\sqrt{0.25 \times 0.75/n} \approx 0.43/\sqrt n\), so on \(n = 1{,}000\) items the 95 % margin is about 2.7 points — you must clear roughly 27.7 % before you may claim the model is above chance at all, and the right test is a one-sided exact binomial against \(p_0\):

from scipy import stats

k, n, p0 = 281, 1000, 0.25        # 28.1% on a 4-way multiple-choice task
print(stats.binomtest(k, n, p0, alternative="greater").pvalue)   # ~0.014

# Report the chance-corrected score too: what fraction of the headroom
# above the floor did the model actually capture?
print((k / n - p0) / (1 - p0))    # ~0.041 -> 4.1% of available headroom

Report the chance-corrected accuracy \((\hat p - p_0)/(1 - p_0)\) alongside the raw number whenever a model sits near the floor; it makes “28 % vs 26 %” legible as “captured 4 % of the headroom vs 1.3 %,” which is the honest framing. Note also that the log-likelihood scoring used by lm-evaluation-harness has no random-guessing behaviour — a small model always picks the highest-scoring option — so a below-chance score is a real (and informative) signal of a systematic bias toward, say, the longest option, not just noise.

At small scale, validation loss is the sensitive instrument

Downstream accuracy is a coarse, saturating, near-chance measurement for a 100M model; validation loss in bits per byte (Pretraining Objectives) is not. It is continuous rather than binary, averaged over hundreds of thousands of tokens rather than a thousand items, and paired across checkpoints on the same held-out documents. That is why a change worth 0.01 bits/byte — clearly visible in a loss comparison — is completely invisible in a 1,000-item accuracy check. It is also why scaling-law fits and architecture ablations are done on loss (Mini Scaling Laws: Fit Your Own Law Before Spending the Budget) rather than on benchmarks.

Loss still needs an interval, and the correct one is a cluster bootstrap over held-out documents, because tokens within a document are strongly correlated. One subtlety makes the bootstrap the right tool rather than a closed form: bits-per-byte is a ratio of sums (total bits over total bytes), not a mean of per-document ratios, so the delta method is awkward while resampling is trivial.

import numpy as np


def paired_bpb_bootstrap(bits_a, bits_b, n_bytes, n_boot=10_000, seed=0):
    """Paired cluster bootstrap on the bits-per-byte DIFFERENCE (A - B).

    bits_a, bits_b : per-document total negative log-likelihood in BITS
                     (sum over tokens of -log2 p) for two checkpoints,
                     scored on the SAME held-out documents.
    n_bytes        : per-document UTF-8 byte count -- the tokenizer-independent
                     denominator (see Pretraining Objectives on bits-per-byte).

    Documents are the resampling unit (tokens within a doc are correlated),
    and the SAME resample is applied to both checkpoints to preserve pairing.
    """
    bits_a, bits_b = np.asarray(bits_a, float), np.asarray(bits_b, float)
    nb = np.asarray(n_bytes, float)
    n = len(nb)
    rng = np.random.default_rng(seed)
    point = bits_a.sum() / nb.sum() - bits_b.sum() / nb.sum()
    idx = rng.integers(0, n, size=(n_boot, n))
    # Ratio-of-sums recomputed inside every resample -- this is the whole point.
    boot = (bits_a[idx].sum(1) / nb[idx].sum(1)
            - bits_b[idx].sum(1) / nb[idx].sum(1))
    lo, hi = np.percentile(boot, [2.5, 97.5])
    return point, lo, hi


if __name__ == "__main__":
    rng = np.random.default_rng(0)
    n_docs = 400
    nb = rng.integers(500, 4000, n_docs).astype(float)   # doc byte lengths
    hard = rng.normal(0, 0.15, n_docs)                   # shared doc difficulty
    bits_b = nb * (1.30 + hard)                          # baseline checkpoint
    bits_a = nb * (1.29 + hard + rng.normal(0, 0.01, n_docs))  # 0.01 bpb lower
    pt, lo, hi = paired_bpb_bootstrap(bits_a, bits_b, nb)
    print(f"delta bpb {pt:+.4f}  95% CI [{lo:+.4f}, {hi:+.4f}]")

The shared per-document difficulty term is what pairing cancels: the marginal CI on either checkpoint’s bits-per-byte is far wider than the CI on their difference, so a 0.01 bpb gain is resolvable with a few hundred documents even though neither absolute number is known that precisely. The same paired-bootstrap machinery carries straight through the capstone’s post-training stage — comparing an SFT checkpoint against DPO against RLVR in Post-Training: SFT, DPO, and Narrow RLVR (GRPO) That Works at 100M is a paired comparison on a fixed held-out set, not a leaderboard race.


Reading Leaderboards Like a Statistician

Bring it together with the skill this chapter exists to instill. When you look at a leaderboard:

  1. Find the error bars. If a leaderboard reports no intervals, mentally attach \(\pm 1/\sqrt n\) to every number (\(n\) = items). LMArena-style boards do publish rating CIs and group models into tiers — use them.
  2. Overlapping CIs ⇒ no demonstrated gap. If model A’s 95 % interval overlaps model B’s, you cannot claim A ranks above B from this data, regardless of point-estimate order. (Strictly, non-overlapping intervals imply a significant difference, but overlapping intervals do not imply non-significance — for a rigorous yes/no you still want the paired test on the difference. Overlap is a quick screen, the paired test is the verdict.)
  3. Ask how many evaluations. Two models near the top of an Elo board may have played thousands of battles each (tight CIs) while a new entrant has played 200 (a \(\pm 50\)-point interval). Rank is meaningless until the new entrant has enough battles.
  4. Suspect the prompt. A 3-point swing between two papers’ numbers for the same model is almost always prompt/harness variance, not a real capability change. Demand the prompt template and the harness version.
  5. Check for multiple comparisons. “Best on 9 of 12 benchmarks” with 12 noisy benchmarks is roughly what chance produces. Look for a pre-registered headline metric, not a victory lap across cherry-picked subsets.
Point estimate +/- 95% CI, by model relative positions -- illustrative, no benchmark numbers point estimate 95% CI lower <= relative accuracy => higher Model A Model B CIs overlap -> NO demonstrated gap; rank A vs B is noise Model C separated -> real difference gap Model D (new) few battles logged few evaluations -> rank not yet meaningful width ~ 1 / sqrt(n) -- to halve a CI, you must 4x the number of items
Overlapping intervals mean "no demonstrated gap"; separated intervals mean a real difference. Model A's point estimate sits to the right of Model B's, but their 95% CIs overlap heavily, so the ranking between them is not statistically supported by this data. Model C's interval is fully separated from A and B, so that gap is real; Model D, a new entrant with few evaluations, has such a wide interval that its rank is not yet meaningful at all.

Interview Corner

Q: Your team’s new model scores 71.4 % on a 1,000-item internal benchmark; the previous model scored 70.1 %. Leadership wants to ship the new one as “better.” Walk me through how you’d decide whether that 1.3-point gain is real, and what you’d report.

A: First, recognize both numbers are estimates with sampling error. The single-model 95 % margin at \(n=1{,}000\), \(p\approx0.7\) is about \(1.96\sqrt{0.7\times0.3/1000}\approx 2.8\) points — so the two independent intervals overlap heavily and a naive comparison is inconclusive. But I wouldn’t compare independent intervals; I’d pair. Both models were run on the same 1,000 items, so I’d build the per-item correctness vectors and run McNemar’s test on the discordant pairs, plus a paired bootstrap CI on the accuracy difference (resample item indices once, apply to both models). If the paired CI excludes zero and McNemar’s \(p < 0.05\), the gain is statistically credible.

I’d also check three things that frequently overturn such a result: (1) prompt and seed variance — I’d re-run both models on \(\ge 3\) prompt templates and \(\ge 3\) seeds, because a 1.3-point gap can vanish under a different system prompt; (2) clustering — if the benchmark has grouped items (e.g. multiple questions per document), I’d use a cluster bootstrap so the CI isn’t falsely narrow; (3) contamination/regressions — even if the average improved, I’d inspect the items the new model newly fails (the McNemar \(c\) cell), since a net gain can hide a meaningful regression on an important slice. What I’d report: the paired difference with its 95 % CI, the McNemar p-value, the per-prompt spread, and an explicit minimum-detectable-effect statement (“at this \(n\) we can resolve gaps down to ~X points”). If the CI is, say, \([+0.1, +2.5]\) points, I’d say “likely a small real improvement, but within the range where prompt choice matters — ship behind an online A/B test (Online Evaluation: A/B Testing, Canaries & Guardrail Metrics) rather than declaring a decisive win.”

Frequentist CIs vs. Bayesian credible intervals

Everything above is frequentist: a 95 % CI means “95 % of intervals built this way would contain the true value.” A Bayesian credible interval instead says “given a prior and the data, there’s a 95 % posterior probability the value lies in this range” — often what people think a CI means. For a binomial accuracy with a uniform \(\text{Beta}(1,1)\) prior, the posterior is \(\text{Beta}(k+1, n-k+1)\), and its 2.5/97.5 percentiles give a credible interval that closely matches the Wilson interval for moderate \(n\). Bayesian framing shines for leaderboards: you can report \(P(\text{model A} > \text{model B})\) directly from the posterior over abilities — a more decision-relevant statement than a p-value. The mechanics live in Probability, Statistics & Information Theory.


Key Takeaways

Key Takeaways

  • Every eval number is a random variable. Report a confidence interval, never a bare point estimate. The 95 % margin near \(p=0.5\) is roughly \(1/\sqrt n\) — so a 500-item benchmark cannot resolve gaps smaller than about 4 points, and a 1-point claim needs tens of thousands of items.
  • Use the right interval. Wilson or Clopper–Pearson for binomial accuracy (the Wald interval breaks near 0/1); bootstrap (prefer BCa) for any non-trivial statistic — Elo, F1, judge means, percentiles. Use a cluster bootstrap when items are grouped, or your CIs will be too narrow. In production call the maintained implementations — scipy.stats.bootstrap(..., paired=True, method="BCa"), scipy.stats.permutation_test, statsmodelsmcnemar and multipletests — fed by the per-item vectors that lm-evaluation-harness writes under --log_samples.
  • Pair whenever possible. Evaluate A and B on the same items and test the per-item difference (McNemar for pass/fail, paired \(t\) or Wilcoxon for scores). Pairing cancels item-difficulty variance and can be worth a 4–10× larger unpaired test set.
  • Know where your noise lives. Decompose variance across items, prompts, seeds, and judges. Prompt-template variance is often dominant and is invisible to an item-only CI — run \(\ge 3\) prompts and \(\ge 3\) seeds before any headline claim.
  • Size the test set before running it. Power analysis turns “what’s the smallest gap I care about?” into “how many items do I need?” Simulate the paired test when the closed-form normal approximation is shaky.
  • Correct for multiple comparisons. Testing many checkpoints/benchmarks manufactures false positives; use Benjamini–Hochberg (FDR) or Bonferroni, and prefer a pre-registered headline metric.
  • At small scale, compare against the chance floor and prefer loss. For a ~100M model, a multiple-choice score must be tested against \(p_0 = 1/\#\text{choices}\), not against zero, and reported chance-corrected; the sensitive instrument is validation bits per byte, with a paired cluster bootstrap over held-out documents (bits-per-byte is a ratio of sums, so resample rather than delta-method it).
  • IRT buys efficiency. Items differ in discrimination and information; a calibrated item bank plus adaptive selection reaches fixed-test precision in a fraction of the items — provided the bank is uncontaminated and re-calibrated as models evolve.
  • Overlapping CIs mean “no demonstrated gap.” On a leaderboard, rank order without separation is noise. Demand error bars, battle counts, and the prompt/harness version before believing any “new state of the art.”

State of the Art & Resources (2026)

Statistical rigor in LLM evaluation has moved from academic concern to mainstream practice: leaderboards now publish rating confidence intervals, ICML 2025 accepted a spotlight position paper on CLT failures at small sample sizes, and IRT-based methods that cut evaluation cost 10–100× have been published and packaged. The field consensus is that bare point estimates are no longer acceptable for any published claim.

Foundational work

Recent advances (2023–2026)

Open-source & tools

  • felipemaiapolo/tinyBenchmarks — Python package for IRT/p-IRT/gp-IRT estimation on MMLU, GSM8K, and Open LLM Leaderboard subsets.
  • Kaleidophon/deep-significance — library implementing Almost Stochastic Order, bootstrap, and permutation tests for comparing deep-learning models, with power-analysis utilities.
  • statsmodelsstats.contingency_tables.mcnemar, stats.multitest.multipletests (Bonferroni/BH), and stats.power cover the significance and sample-sizing layer; scipy.stats.bootstrap (BCa, paired=True) and scipy.stats.permutation_test cover the resampling layer.
  • rtmdrr/testSignificanceNLP — companion code for Dror et al. 2018; runs Shapiro–Wilk, t-test, Wilcoxon, and McNemar from the command line on any score files.

Go deeper

Further reading

  • Bradley, R. A. & Terry, M. E., Rank Analysis of Incomplete Block Designs (1952) — the pairwise-comparison model underlying Elo and modern LLM arenas.
  • Efron, B. & Tibshirani, R. J., An Introduction to the Bootstrap (1993) — the definitive treatment of bootstrap CIs, including BCa.
  • Brown, Cai & DasGupta, Interval Estimation for a Binomial Proportion (Statistical Science, 2001) — why Wald is bad and Wilson/Agresti–Coull are good.
  • Dietterich, T. G., Approximate Statistical Tests for Comparing Supervised Classification Learning Algorithms (1998) — paired tests, McNemar, and the perils of naive comparison in ML.
  • Benjamini, Y. & Hochberg, Y., Controlling the False Discovery Rate (1995) — the standard multiple-comparison correction for screening many candidates.
  • Lord, F. M., Applications of Item Response Theory to Practical Testing Problems (1980) — the foundational IRT and adaptive-testing reference.
  • Polo et al., tinyBenchmarks: Evaluating LLMs with Fewer Examples, and Vivek et al., Anchor Points — IRT-style efficient LLM evaluation.
  • Miller, E., Adding Error Bars to Evals — a practitioner-facing call to report confidence intervals on LLM benchmarks.
  • Chiang et al., Chatbot Arena — the human-preference leaderboard whose Bradley–Terry ratings and confidence intervals popularized statistical rigor in LLM ranking.

Exercises

1. (CI width) You run a bespoke eval on \(n = 250\) items and a model scores near \(p \approx 0.5\). Using the chapter’s normal-approximation rule, (a) what is the 95% margin of error, and how wide is the band? (b) A competitor reports a score 2 points higher on the same-sized eval – is that gap resolvable? © How many items would you need so the margin shrinks to \(\pm 2\) points?

Solution

Near \(p = 0.5\) the variance is maximal, so the half-width is $$ m = 1.96\sqrt{\frac{0.5 \times 0.5}{n}} = \frac{0.98}{\sqrt{n}}. $$

(a) At \(n = 250\): \(\sqrt{250} = 15.81\), so \(m = 0.98 / 15.81 = 0.062\), i.e. \(\pm 6.2\) points. The 95% band is about \(12.4\) points wide. (This matches the chapter’s rule of thumb \(m \approx 1/\sqrt{n} = 1/15.81 = 0.063\).)

(b) No. A 2-point gap sits deep inside a \(\pm 6.2\)-point margin; either model’s point estimate lands well within the other’s interval, so the difference is indistinguishable from sampling noise on a 250-item eval.

© Set \(0.98/\sqrt{n} = 0.02\), so \(\sqrt{n} = 49\) and \(n = 2401\) items. Because the margin scales as \(1/\sqrt{n}\), going from a \(\pm 6.2\)-point margin to a \(\pm 2\)-point margin (roughly a \(3\times\) tightening) costs roughly a \(9\times\) larger test set.

2. (Why Wald fails) A model scores 49/50, so \(\hat p = 0.98\). (a) Compute the naive Wald 95% interval and identify what is wrong with it. (b) Explain in one or two sentences why the Wald interval misbehaves here and why the Wilson interval (which the chapter reports as roughly \([0.894, 0.997]\)) does not.

Solution

(a) The Wald interval is \(\hat p \pm 1.96\sqrt{\hat p(1-\hat p)/n}\): $$ \text{SE} = \sqrt{\frac{0.98 \times 0.02}{50}} = \sqrt{\frac{0.0196}{50}} = \sqrt{0.000392} = 0.0198, $$ so the interval is \(0.98 \pm 1.96 \times 0.0198 = 0.98 \pm 0.0388 = [0.941,\ 1.019]\). The upper limit \(1.019\) is above 1.0, which is impossible for a probability – the interval is nonsense.

(b) The Wald interval assumes the sampling distribution of \(\hat p\) is symmetric and normal, and it plugs the point estimate into the SE. Near the boundary \(p \to 1\) the true sampling distribution is strongly skewed (it cannot exceed 1), and the plug-in SE \(\sqrt{\hat p (1 - \hat p)/n}\) shrinks toward 0 as \(\hat p \to 1\), so a symmetric normal band is the wrong shape and understates uncertainty on the low side while spilling past 1 on the high side. The Wilson interval is derived by inverting the score test – it centers the interval away from \(\hat p\) toward \(0.5\) and its algebra keeps both limits inside \([0, 1]\) by construction, so it stays well-behaved at the extremes.

3. (McNemar by hand) Two models are scored pass/fail on the same 500 items. Cross-tabulating: 400 both right, 38 both wrong, \(b = 40\) items where only A was right, and \(c = 22\) where only B was right. (a) Which items does McNemar’s test use, and why are the rest discarded? (b) Compute the continuity-corrected McNemar chi-square statistic and its two-sided p-value. © State the verdict at \(\alpha = 0.05\).

Solution

(a) McNemar uses only the \(b + c = 62\) discordant items – those where exactly one model was right. The \(400 + 38 = 438\) concordant items (both right or both wrong) carry no information about which model is better: under the null “equally good,” they are equally likely regardless of which model is superior, so they only add noise. Discarding them is precisely why pairing cancels item-difficulty variance.

(b) With \(b = 40\), \(c = 22\), \(n_{\text{disc}} = 62 \ge 25\), the chi-square approximation is appropriate: $$ \chi^2 = \frac{(|b - c| - 1)^2}{b + c} = \frac{(|40 - 22| - 1)^2}{62} = \frac{17^2}{62} = \frac{289}{62} = 4.66. $$ For \(\chi^2_1\), the p-value equals \(2\,\Phi(-\sqrt{4.66}) = 2\,\Phi(-2.159) = 2 \times 0.0154 = 0.031\).

© Since \(p = 0.031 < 0.05\), the difference is statistically significant: model A is credibly better on this eval. (Had \(b + c\) been below 25, you would use the exact two-sided binomial test on \(b \sim \text{Binomial}(b + c, 0.5)\) rather than the chi-square approximation.)

4. (IRT item information) A calibrated item bank is being used to evaluate a model whose current ability estimate is \(\hat\theta = 0\). Consider three items with \((a, b)\) parameters: item X \((a = 2,\ b = 0)\), item Y \((a = 1,\ b = 0)\), item Z \((a = 2,\ b = 2)\). (a) Compute the Fisher information each contributes at \(\hat\theta = 0\). (b) Which item should the adaptive-testing loop ask next, and what two properties made it win? Confirm your ranking with a one-line call to the chapter’s item_information.

Solution

The 2PL pass probability is \(P = \sigma(a(\theta - b))\) and the information is \(I = a^2 P(1 - P)\).

Item X: \(P = \sigma(2(0 - 0)) = \sigma(0) = 0.5\), so \(I_X = 2^2 \times 0.5 \times 0.5 = 4 \times 0.25 = 1.00\).

Item Y: \(P = \sigma(1 \times 0) = 0.5\), so \(I_Y = 1^2 \times 0.25 = 0.25\).

Item Z: \(P = \sigma(2(0 - 2)) = \sigma(-4) = 0.018\), so \(I_Z = 2^2 \times 0.018 \times 0.982 = 4 \times 0.0177 = 0.071\).

(b) Ask item X. It wins on both levers of Fisher information: (1) its difficulty matches the model’s ability (\(b = \hat\theta\), so \(P = 0.5\), which maximizes \(P(1-P)\)), and (2) it has high discrimination (\(a = 2\)). Item Y sits at the same difficulty but has half the discrimination (\(a^2\) enters quadratically, so it carries a quarter of the information). Item Z is highly discriminating but far too easy for this model (\(b = 2 \gg \hat\theta\)); the model almost always passes it, so it reveals almost nothing about \(\hat\theta\).

import numpy as np
def item_information(theta, a, b):
    p = 1.0 / (1.0 + np.exp(-a * (theta - b)))
    return a ** 2 * p * (1 - p)

for name, (a, b) in {"X": (2, 0), "Y": (1, 0), "Z": (2, 2)}.items():
    print(name, round(item_information(0.0, a, b), 3))
# X 1.0   Y 0.25   Z 0.071

5. (Power / sizing a test set) You want to detect a 2-point accuracy gap (say 76% vs 74%) at \(\alpha = 0.05\), power 0.80, using an unpaired two-proportion design. (a) Compute the required \(n\) per arm from the chapter’s closed form. (b) Given the answer, what would you do differently in practice, and roughly how much would it help?

Solution

(a) The unpaired formula is $$ n_{\text{per arm}} \approx \frac{(z_{1-\alpha/2} + z_{1-\beta})^2 \cdot 2\,\bar p (1 - \bar p)}{\Delta^2}, $$ with \(z_{1-\alpha/2} = 1.96\), \(z_{1-\beta} = 0.84\), \(\bar p = 0.75\), and \(\Delta = 0.02\). Then \((1.96 + 0.84)^2 = 2.8^2 = 7.84\) and \(2 \times 0.75 \times 0.25 = 0.375\), so $$ n \approx \frac{7.84 \times 0.375}{0.02^2} = \frac{2.94}{0.0004} = 7350 \text{ items per arm}. $$ So about 7,350 items on each model – roughly 15,000 evaluations total – just to resolve a 2-point gap.

(b) Pair the design: run both models on the same items and test the per-item difference with McNemar. Because competent models agree on most easy/hard items, the discordant sample is far richer per item and the paired variance is much smaller – the chapter notes pairing is worth a \(4\)\(10\times\) larger unpaired test set, dropping the requirement from thousands to a few hundred or low thousands of items for the same effect. Alternatively, if a 2-point gap genuinely is your minimum-detectable-effect and pairing is unavailable, accept that no 500-item benchmark can adjudicate it and report the MDE honestly rather than a bare point difference.

6. (Implement a cluster bootstrap) The chapter warns that resampling individual items underestimates variance when items are grouped (e.g. multiple questions per document), and prescribes a cluster (block) bootstrap that resamples whole groups. Implement cluster_bootstrap_ci(scores, groups, statistic, ...) in the style of the chapter’s bootstrap_ci, and write a short demo showing that when items within a group are correlated, the cluster CI is wider than the naive item-level CI.

Solution

Resample group labels with replacement (not individual items), then concatenate the item indices belonging to the drawn groups and recompute the statistic on that flattened sample. Pre-bucketing each group’s member indices keeps the inner loop fast.

import numpy as np


def cluster_bootstrap_ci(scores, groups, statistic=np.mean,
                         n_boot=10_000, alpha=0.05, seed=0):
    """Cluster (block) bootstrap CI: resample whole GROUPS with replacement.

    scores : 1-D array of per-item results.
    groups : same-length array of group ids (items sharing an id are a block).
    Returns (point_estimate, lo, hi).
    """
    rng = np.random.default_rng(seed)
    scores = np.asarray(scores, dtype=float)
    groups = np.asarray(groups)
    unique = np.unique(groups)
    members = [np.where(groups == g)[0] for g in unique]  # indices per group
    n_groups = len(unique)
    point = statistic(scores)
    boot = np.empty(n_boot)
    for b in range(n_boot):
        pick = rng.integers(0, n_groups, size=n_groups)     # resample groups
        idx = np.concatenate([members[g] for g in pick])    # keep blocks intact
        boot[b] = statistic(scores[idx])
    lo, hi = np.percentile(boot, [100 * alpha / 2, 100 * (1 - alpha / 2)])
    return point, lo, hi


def item_bootstrap_ci(scores, statistic=np.mean, n_boot=10_000,
                      alpha=0.05, seed=0):
    """Naive item-level bootstrap for comparison."""
    rng = np.random.default_rng(seed)
    scores = np.asarray(scores, dtype=float)
    n = len(scores)
    idx = rng.integers(0, n, size=(n_boot, n))
    boot = scores[idx].mean(axis=1)
    lo, hi = np.percentile(boot, [100 * alpha / 2, 100 * (1 - alpha / 2)])
    return statistic(scores), lo, hi


if __name__ == "__main__":
    # 50 documents x 10 questions each. A per-document latent effect makes
    # questions within a document highly correlated (they pass/fail together).
    rng = np.random.default_rng(0)
    n_docs, per_doc = 50, 10
    doc_effect = rng.normal(0, 1.5, n_docs)          # strong shared component
    scores, groups = [], []
    for d in range(n_docs):
        p_doc = 1 / (1 + np.exp(-(0.3 + doc_effect[d])))
        scores.extend((rng.random(per_doc) < p_doc).astype(float))
        groups.extend([d] * per_doc)
    scores = np.array(scores)

    print("item   CI:", item_bootstrap_ci(scores))
    print("cluster CI:", cluster_bootstrap_ci(scores, groups))

The item-level CI treats all 500 questions as independent and comes out narrow. The cluster CI resamples the 50 documents, correctly recognizing that the effective sample size is closer to 50 blocks than 500 questions, and is markedly wider – roughly twice the half-width here (and wider still as the within-group correlation grows). Reporting the item-level interval would falsely claim precision the data does not support; the cluster bootstrap is the honest interval whenever items share a group structure.