A test can be wrong in two ways: it can see an effect that is not there, or miss one that is. Small studies miss often and exaggerate when they hit; many tests find something by chance.
Every test ends in a decision, significant or not, and the truth is either “no effect” or “an effect”. That makes four outcomes, two of them wrong:
no effect in truth
an effect in truth
significant
false alarm (type I error)
a hit
not significant
correct
a miss (type II error)
The chance of a false alarm is the significance level, usually 5%: we choose it. The chance of a hit, when there is an effect, is the power. We do not choose it directly. It depends on how large the effect is, how noisy the data are, and how many units we measure.
Code
SHIFT, LINE =2.2, 1.96# the effect, in standard errors; the line for 5%, two-sidedx = np.linspace(-4, 6.5, 500)def plot_errors(ax: Axes) ->None:"""The two curves, the line, and the areas of false alarms, misses and power.""" null, effect = stats.norm.pdf(x), stats.norm.pdf(x, SHIFT)for tail in (x >= LINE, x <=-LINE): ax.fill_between(x[tail], null[tail], color=style.CORAL, alpha=0.5, lw=0) missed = x < LINE ax.fill_between(x[missed], effect[missed], color=style.GREY, alpha=0.35, lw=0) ax.fill_between(x[~missed], effect[~missed], color=style.BLUE, alpha=0.2, lw=0) ax.plot(x, null, color=style.GREY, ls="--") ax.plot(x, effect, color=style.BLUE) ax.axvline(LINE, color=style.INK, lw=1.4) power = stats.norm.sf(LINE - SHIFT) + stats.norm.cdf(-LINE - SHIFT) ax.text(LINE +0.1, 0.42, "significant →", fontsize=9.5) ax.text(SHIFT +0.6, 0.2, f"power\n{power:.0%}", fontsize=10, color=style.BLUE) ax.text( LINE -0.5, 0.05, f"misses\n{1- power:.0%}", fontsize=10, ha="right", color=style.INK ) ax.text(3.1, 0.015, "false alarms", fontsize=9, color=style.CORAL) ax.set(xlabel="test statistic (standard errors from zero)", ylabel="how common", yticks=[])fig, ax = plt.subplots(figsize=(8, 3.3))plot_errors(ax)plt.show()
Figure 3.10.1: Where the test statistic lands, with no effect (grey, dashed) and with a real effect (blue). Beyond the line counts as significant. Red: false alarms, 5% of the grey curve. Grey area: misses. The rest of the blue curve is the power.
The two errors pull against each other. Move the line to the right (a stricter level, like 1%) and false alarms fall, but misses rise. The only way to cut both is to separate the curves: measure more units, or measure them with less noise.
3.10.2 Power and sample size
More units make the standard error smaller, so the same effect sits more standard errors from zero, and the blue curve moves right. Try it:
Before a study, turn it around: how many units do we need to have a good chance of finding the effect we care about? The usual target is 80% power.
Code
sizes = np.arange(5, 401)solver = TTestIndPower()def plot_power(ax: Axes) ->None:"""Power against group size for three effect sizes, with the size needed for 80%.""" label_offsets = {0.2: (-92, 8), 0.5: (8, -34), 0.8: (8, -16)} # keep the labels apartfor d, color in ((0.2, style.CORAL), (0.5, style.GOLD), (0.8, style.BLUE)): power = solver.power(effect_size=d, nobs1=sizes, alpha=0.05) ax.plot(sizes, power, color=color, label=f"d = {d}") needed =int(np.ceil(solver.solve_power(effect_size=d, power=0.8, alpha=0.05))) ax.plot(needed, 0.8, "o", ms=6, color=color) ax.annotate(f"{needed} per group", (needed, 0.8), xytext=label_offsets[d], textcoords="offset points", fontsize=9, color=color, ) ax.axhline(0.8, color=style.INK, ls="--", lw=1) ax.yaxis.set_major_formatter(style.PERCENT) ax.legend(fontsize=8, loc="lower right") ax.set(xlabel="units per group", ylabel="power", ylim=(0, 1.02))fig, ax = plt.subplots(figsize=(8, 3.4))plot_power(ax)plt.show()
Figure 3.10.2: Power of a two-group t-test at the 5% level, by group size, for small, medium and large effects. The dashed line is the usual target of 80%.
A large effect needs 26 per group, a medium one 64, a small one almost 400. For a two-group comparison with n per group and effect size d (Chapter 3.4), the test statistic sits on average standard errors from zero, so with the normal approximation
where Φ is the cumulative normal curve, for a two-sided 5% test and for 80% power. For d = 0.5: n ≈ 2 × 2.8² / 0.25 ≈ 63 per group. Because d is squared in the denominator, halving the effect means four times the data. Many published studies have far less power than 80%, which matters for the next point.
3.10.3 Small studies exaggerate
A study with low power finds the effect only when chance happens to make it look large. So the significant results of small studies are, on average, too big. This is the winner’s curse.
Code
TRUE_EFFECT, GROUP =0.3, 20a = rng.normal(0, 1, (10_000, GROUP))b = rng.normal(TRUE_EFFECT, 1, (10_000, GROUP))estimates = b.mean(axis=1) - a.mean(axis=1)significant = stats.ttest_ind(b, a, axis=1).pvalue <0.05def plot_curse(ax: Axes) ->None:"""All estimates, with the significant ones in red and their average.""" edges = np.linspace(-1, 1.6, 53) ax.hist(estimates[~significant], bins=edges, color=style.BLUE, alpha=0.5) ax.hist(estimates[significant], bins=edges, color=style.CORAL, alpha=0.7) ax.axvline(TRUE_EFFECT, color=style.INK, ls="--", lw=1.4) average = estimates[significant].mean() ax.axvline(average, color=style.CORAL, lw=1.6) style.note( ax, f"{significant.mean():.0%} significant;\ntheir average: {average:.2f}", x=0.7 ) ax.set(xlabel="estimated effect (standard deviations)", ylabel="studies")fig, ax = plt.subplots(figsize=(8, 3.2))plot_curse(ax)plt.show()
Figure 3.10.3: 10,000 simulated studies of a true effect of 0.3 standard deviations (dashed line), with 20 per group. Only the red ones are significant, and their average estimate is more than twice the truth.
If journals publish mainly significant results, the published effects are inflated, and later, larger studies find smaller effects. Part of what is called the replication crisis is just this. The cure is more power, and reporting every result, not only the significant ones.
3.10.4 Many tests
At the 5% level, one test in twenty gives a false alarm when nothing is going on. Run twenty tests and the chance of at least one false alarm is 1 − 0.95²⁰ = 64%. Test enough things, and something will always be significant.
Code
TESTS, REAL =100, 10real = np.arange(TESTS) < REALshifts = np.where(real, 3.0, 0.0) # real effects sit 3 standard errors from zeroz = rng.normal(shifts)p =2* stats.norm.sf(abs(z))def plot_raw(ax: Axes) ->None:"""Each test's p-value on a log scale, with the 5% line.""" order = rng.permutation(TESTS) colors = np.where(real[order], style.BLUE, style.GREY) ax.scatter(np.arange(TESTS), p[order], s=16, c=colors) ax.axhline(0.05, color=style.INK, ls="--", lw=1) false_alarms =int((p[~real] <0.05).sum()) style.note(ax, f"false alarms at 5%: {false_alarms}", x=0.03, y=0.08, va="bottom") ax.set_yscale("log") ax.set(title="Uncorrected", xlabel="test", ylabel="p-value (log scale)")def plot_corrected(ax: Axes, alpha: float=0.05) ->None:"""Sorted p-values against the Bonferroni line and the Benjamini-Hochberg line.""" order = np.argsort(p) rank = np.arange(1, TESTS +1) colors = np.where(real[order], style.BLUE, style.GREY) ax.scatter(rank, p[order], s=16, c=colors) ax.axhline(alpha / TESTS, color=style.CORAL, lw=1.4, label="Bonferroni") ax.plot(rank, alpha / (TESTS - rank +1), color=style.GOLD, lw=1.4, ls="--", label="Holm") ax.plot(rank, alpha * rank / TESTS, color=style.GREEN, lw=1.4, label="Benjamini-Hochberg") ax.set_yscale("log") ax.set_xlim(0, 40) ax.legend(fontsize=8, loc="lower right") ax.set(title="Corrected", xlabel="rank of the p-value", ylabel="p-value (log scale)")fig, axes = style.subplots(2, height=3.3, width=9.5)plot_raw(axes[0])plot_corrected(axes[1])fig.tight_layout()plt.show()
Figure 3.10.4: 100 tests, of which 10 have a real effect (blue) and 90 have none (grey). Left: p-values in order of the tests; at 5%, several of the 90 come out significant (below the dashed line). Right: the same p-values sorted, with the Bonferroni line, Holm’s steps and the Benjamini-Hochberg line.
There are two things to control. The family-wise error rate (FWER) is the chance of even one false alarm among all the tests. The false discovery rate (FDR) is the expected share of false alarms among the tests called significant. With m tests and V false alarms out of R discoveries:
Three ways to correct:
Bonferroni: divide the level by the number of tests. With 100 tests, only p below 0.0005 counts. It keeps the FWER at 5% whatever the tests, but it is strict and misses real effects.
Holm: a step-down version of Bonferroni that is never worse. Sort the p-values; compare the smallest with α/m, the next with α/(m − 1), and so on, stopping at the first that fails. It also keeps the FWER at 5% and finds a little more.
Benjamini-Hochberg: sort the p-values and compare the k-th smallest with 5% × k / 100. Everything up to the last one below its line counts. It keeps the share of false alarms among the discoveries near 5%, the false discovery rate, and finds more of the real effects.
Use Holm (or Bonferroni) when a single false alarm is costly, Benjamini-Hochberg when screening many candidates (genes, products, features) for the ones worth a closer look. For all pairs of group means there is Tukey’s method (Chapter 3.5); for any comparison dreamed up after seeing the data, Scheffé’s even wider intervals. When tests are strongly correlated, permutation methods that shuffle all tests at once (keeping their correlation) are less wasteful than Bonferroni.
3.10.5 Hidden tests
The worst multiple testing is the kind nobody counts. Try several outcomes, drop a few outliers, split the data a different way, stop collecting when p dips below 0.05: each choice is another chance, and the reported p-value pretends there was only one. This is called p-hacking, and it is often done in good faith.
One hidden choice is easy to simulate: stopping as soon as the result is significant.
Code
LOOKS = np.arange(10, 201, 10)def p_path(seed: int) -> np.ndarray:"""p-values of a t-test recomputed after every 10 participants per group, with no true effect.""" study = np.random.default_rng(seed) a, b = study.normal(size=(2, LOOKS[-1]))return np.array([stats.ttest_ind(a[:n], b[:n]).pvalue for n in LOOKS])paths = np.array([p_path(seed) for seed inrange(2_000)])def plot_paths(ax: Axes) ->None:"""A few p-value paths, with where a peeking researcher would have stopped."""for seed, color inzip((3, 7, 11, 21, 40), style.PALETTE): ax.plot(LOOKS, paths[seed], color=color, lw=1.2) hit = np.argmax(paths[seed] <0.05) if (paths[seed] <0.05).any() elseNoneif hit isnotNone: ax.plot(LOOKS[hit], paths[seed][hit], "o", ms=8, color=style.CORAL) ax.axhline(0.05, color=style.INK, ls="--", lw=1) ax.set_yscale("log") ax.set( title="Five studies, peeking", xlabel="participants per group so far", ylabel="p-value (log scale)", )def plot_peeking(ax: Axes) ->None:"""False-alarm rate for different peeking schedules.""" schedules = [ ("one look at 200", [19]), ("every 50", [4, 9, 14, 19]), ("every 20", list(range(1, 20, 2))), ("every 10", list(range(20))), ] rates = [(paths[:, looks] <0.05).any(axis=1).mean() for _, looks in schedules] ax.barh( [name for name, _ in schedules], rates, color=[style.BLUE] + [style.CORAL] *3, alpha=0.7, )for i, r inenumerate(rates): ax.text(r +0.005, i, f"{r:.0%}", va="center", fontsize=9) ax.axvline(0.05, color=style.INK, ls="--", lw=1) ax.xaxis.set_major_formatter(style.PERCENT) ax.invert_yaxis() ax.set( title="False alarms", xlabel="studies declaring an effect", xlim=(0, max(rates) *1.25) )fig, axes = style.subplots(2, height=3.3, width=10)plot_paths(axes[0])plot_peeking(axes[1])fig.tight_layout()plt.show()
Figure 3.10.5: Optional stopping, simulated with no true effect. Left: the p-value of five studies, recomputed after every 10 new participants per group up to 200; a study that stops as soon as p < 0.05 (red dots) declares an effect that is not there. Right: the false-alarm rate for one fixed look, and for looking after every 10, 20 or 50 participants.
The defence is to decide the analysis before seeing the data, write it down (pre-registration), and report all of it. If interim looks are needed, as in clinical trials, plan them in advance with stricter thresholds for each look (group sequential designs). Exploring is fine, but call it exploring, and test what you found on new data.
3.10.6 In one picture
Two errors: false alarms (set by the significance level) and misses (1 − power).
Power grows with the effect size and with √n. Aim for 80%, and work out n before the study.
Significant results from small studies overestimate the effect.
Sample size for two groups: per group.
With many tests, correct: Holm or Bonferroni for the family-wise error rate, Benjamini-Hochberg for the false discovery rate.
Count the hidden tests too.
Next: meta-analysis, combining the results of many studies.