Probability & statistics, section 6 of 6, 4h

Hypothesis tests and intervals, practically

By the end: You can run and interpret the common tests, say what a p-value does and does not mean, and prefer intervals where they help.

  • What a p-value is
  • Common misreadings
  • t-test and chi-square in SciPy
  • Confidence intervals
  • Power, briefly

New to probability & statistics? Probability is the arithmetic of things that are uncertain: how likely an outcome is, and how that changes once you learn something new. A distribution is the full picture of which outcomes occur and how often. Adult heights cluster near the middle with few people at either extreme, and that shape is the distribution. Statistics runs the same machinery backwards: you hold a sample of outcomes and work out what produced it. Why it matters

You have measured a difference between two things — two models, two prompts, two versions of a page. This section is about the question that follows: could a gap that size have turned up by chance alone? It covers what a p-value does and does not say, the several ways it is routinely misread, how to run the standard tests in SciPy, and why a confidence interval is usually the more honest thing to put in the report.

The definition, carefully#

A p-value is the probability of seeing data at least as extreme as yours if there were really no effect. A small p-value means your data would be surprising in a world with no effect. That is all it means.

  • It is not the probability that there is no effect.
  • It is not the probability your result is a fluke.
  • It says nothing about how big or important the effect is. A tiny effect in a huge sample can have a tiny p-value.

The definition has a consequence that makes the whole thing click, and it is easier to see by simulation than to argue. If there is genuinely no effect, the p-value is a uniformly distributed random number between 0 and 1. Not usually large — uniform. So 5 per cent of the time it lands below 0.05, half the time below 0.5, and there is nothing special about any particular value it takes. Run two identical arms against each other twenty thousand times and look.

import math
import numpy as np
from scipy import stats

rng = np.random.default_rng(0)

def two_proportion_test(x1, n1, x2, n2):
    """Successes and trials for each arm. Returns the two-sided p-value."""
    pooled = (x1 + x2) / (n1 + n2)
    se = math.sqrt(pooled * (1 - pooled) * (1 / n1 + 1 / n2))
    z = (x2 / n2 - x1 / n1) / se
    return 2 * (1 - stats.norm.cdf(abs(z)))

null_p = np.array([
    two_proportion_test(rng.binomial(2_000, 0.03), 2_000,
                        rng.binomial(2_000, 0.03), 2_000)
    for _ in range(20_000)
])
print((null_p < 0.05).mean(), (null_p < 0.10).mean(), (null_p < 0.50).mean())
# 0.049 0.09855 0.49675
Ran as shown. Two arms with identical 3 per cent conversion, so every difference is noise, and the p-values come out uniform: 4.9 per cent below 0.05, 9.9 per cent below 0.10, 49.7 per cent below 0.50. The 5 per cent threshold is not a discovery rate, it is the false alarm rate you chose to accept.

A worked A/B test#

Take a real-shaped question. The control page converts at 3.0 per cent and the variant at 3.4 per cent, a relative lift of 13 per cent, on 10,000 visitors each. Someone wants to ship it on Friday. The two-proportion test gives p equal to 0.108, so by the usual convention it does not clear the bar — and the more useful output is the interval on the difference, which runs from minus 0.09 to plus 0.89 percentage points. The data is consistent with the variant being slightly worse and with it being a 30 per cent relative improvement. Run it to 40,000 per arm and the same rates give p equal to 0.0013 and an interval of 0.16 to 0.64 points.

def ab(x1, n1, x2, n2):
    p1, p2 = x1 / n1, x2 / n2
    se = math.sqrt(p1 * (1 - p1) / n1 + p2 * (1 - p2) / n2)
    lift = p2 - p1
    return p1, p2, two_proportion_test(x1, n1, x2, n2), lift - 1.96 * se, lift + 1.96 * se

for n in (10_000, 40_000):
    p1, p2, pv, lo, hi = ab(int(0.030 * n), n, int(0.034 * n), n)
    print(n, p1, p2, round(pv, 4), round(lo * 100, 3), round(hi * 100, 3))

# 10000 0.03 0.034 0.108  -0.088  0.888
# 40000 0.03 0.034 0.0013  0.156  0.644
Ran as shown. Identical measured rates, four times the data, and the conclusion changes completely. Nothing about the variant differed between those two rows; only how much evidence was collected about it.

Two common tests#

Proportions are the common case in this field, but two others turn up constantly. Use a t-test when you are comparing averages of a continuous quantity, and pass equal_var=False, because the equal-variance version assumes something you rarely have any reason to believe. Use a chi-square test on a contingency table when you are asking whether two categorical variables are related.

group_a = rng.lognormal(mean=3.0, sigma=0.6, size=400)     # session length, minutes
group_b = rng.lognormal(mean=3.1, sigma=0.6, size=400)
t, p = stats.ttest_ind(group_a, group_b, equal_var=False)
print(round(group_a.mean(), 1), round(group_b.mean(), 1), round(float(t), 2), round(float(p), 4))
# 23.9 26.8 -2.39 0.0169

table = [[120, 880],    # plan A: churned, stayed
         [ 90, 910]]    # plan B
chi2, p2, dof, expected = stats.chi2_contingency(table)
print(round(float(chi2), 2), round(float(p2), 4), dof)
print(np.round(expected, 1).tolist())
# 4.47 0.0344 1
# [[105.0, 895.0], [105.0, 895.0]]
Ran as shown. The expected table is the one worth printing: it is what you would have seen if churn were identical across plans, 105 in each cell against the observed 120 and 90. The chi-square statistic is a summary of how far the observed table is from that, and reading the expected table tells you which cell drove it.

Prefer an interval#

A confidence interval gives a range of effect sizes consistent with the data. 'Plan B churn is 3 points lower, 95% interval 0.2 to 5.8' tells you the direction, the size and the uncertainty at once, which a p-value alone does not.

It also makes the two honest failure modes distinguishable, and a p-value does not. An interval from minus 0.1 to plus 0.9 points says you have not learned much and should keep the experiment running. An interval from minus 0.05 to plus 0.06 points says you have learned something definite: whatever the effect is, it is too small to care about. Both of those come back as not significant, and they call for opposite decisions.

What the 95 per cent is attached to

The interval either contains the true effect or it does not; there is no 95 per cent about this particular one. The 95 per cent describes the procedure: intervals built this way cover the truth 95 times in 100 over the long run. Say 'a 95 per cent confidence interval' and describe the range, and do not say 'there is a 95 per cent chance the effect is in here', which is the Bayesian statement and needs a prior to be true.

Power#

Power is the chance your study detects an effect of a given size if it exists. Small studies have low power, so they miss real effects, and the effects they do report tend to be exaggerated. Estimate the sample you need before collecting data.

Power is simulable, which means you never have to trust a formula you half remember. Decide the effect you would want to catch, generate data with that effect in it, run your actual test, and count how often it fires. Here the effect is the 3.0 against 3.4 per cent from above, and the question is how many visitors per arm it takes to see it reliably.

for n in (5_000, 20_000, 80_000):
    wins = sum(
        two_proportion_test(rng.binomial(n, 0.030), n, rng.binomial(n, 0.034), n) < 0.05
        for _ in range(4_000)
    )
    print(n, wins / 4_000)
# 5000 0.2125
# 20000 0.61925
# 80000 0.99425
Ran as shown. At 5,000 per arm the test finds a real effect one time in five, so four experiments in five end in 'no significant difference' about a change that genuinely works. That is what an underpowered test buys: not caution, but a coin flip dressed as a decision.

The closed form agrees and is quicker for planning. For two proportions, the sample per arm is 2.8 squared — that constant being 1.96 for the false positive rate plus 0.84 for 80 per cent power — times the sum of the two variances, divided by the squared difference. For these numbers it says 30,353 per arm, and simulating at exactly that n gives 80.2 per cent power.

p1, p2 = 0.030, 0.034
n_needed = (1.96 + 0.84) ** 2 * (p1 * (1 - p1) + p2 * (1 - p2)) / (p2 - p1) ** 2
print(round(n_needed))       # 30353

wins = sum(
    two_proportion_test(rng.binomial(30_353, p1), 30_353, rng.binomial(30_353, p2), 30_353) < 0.05
    for _ in range(4_000)
)
print(wins / 4_000)          # 0.80225
Ran as shown. The formula and the simulation agree to within a fifth of a point. The term that dominates is the squared difference on the bottom: halving the effect you want to detect multiplies the sample you need by four, which is why detecting small wins is so expensive and why teams quietly stop trying.

Twenty metrics, one winner#

The most common way a team fools itself is not a bad test, it is many tests. Every dashboard has twenty metrics, and each one gets a 5 per cent chance of a false alarm, so the chance that at least one fires when nothing at all has changed is one minus 0.95 to the twentieth, which is 64 per cent. On average one metric will be significant every time you look.

print(round(1 - 0.95 ** 20, 4))          # 0.6415

flags = rng.random((50_000, 20)) < 0.05   # 20 metrics, nothing happening
print(flags.any(axis=1).mean(), flags.sum(axis=1).mean())
# 0.64172 0.99986
Ran as shown. Simulated and algebraic answers agree: 64 per cent of experiments produce at least one significant metric, and the average count of false winners is exactly one. The fixes are to name the one primary metric before the experiment starts, and to treat anything else that lights up as a hypothesis for the next experiment rather than a finding from this one.

Where people go wrong

Treating p below 0.05 as 'real' and above as 'no effect'. Both conclusions depend on sample size, and the effect size is what matters.

If this comes up in an interview

What does a p-value of 0.03 mean?

If there were no real effect, data at least this extreme would occur about 3% of the time. It does not mean there is a 97% chance the effect is real, and it says nothing about the effect's size.

Then they push further

You test 20 metrics and one has p = 0.03. Should you trust it?

Not much. With 20 tests at the 5% level you expect about one false positive by chance. Correct for multiple comparisons or treat it as a hypothesis to confirm with new data.

What gets marked up. Precise wording matters here. Candidates who say 'the probability the null is true' are marked down.

How many users do you need for an A/B test?

It depends on four things and nothing else: the baseline rate, the smallest lift worth shipping, the false positive rate you accept and the power you want. For a 3.0 per cent baseline and a 0.4 point lift at 5 per cent and 80 per cent, it is about 30,000 per arm. The term that dominates is the squared difference on the denominator, so halving the lift you want to detect multiplies the sample by four.

Then they push further

You only have 5,000 per arm. What do you tell the team?

That the test has about 20 per cent power for that effect, so four times in five it will come back not significant even if the change genuinely works, and the times it does fire the measured effect will be exaggerated. Either pick a bigger effect to chase, move to a more sensitive metric, or accept that this experiment cannot answer the question.

How would you check any of this without trusting a formula?

Simulate. Generate data with the effect you care about, run the actual test you plan to run, and count how often it fires. That also catches the cases the formula does not cover, such as a skewed revenue metric or a test applied to correlated observations.

Your dashboard has twenty metrics and one moved with p = 0.03. What do you do?

Treat it as nothing. With twenty independent metrics at a 5 per cent threshold, the chance at least one fires when nothing has changed is one minus 0.95 to the twentieth, about 64 per cent, and the average number of false winners is exactly one. Name the primary metric before the experiment and treat everything else as a hypothesis for the next one.

Build this: Simulate 1,000 A/B tests with no true difference and count how many give p below 0.05. Then simulate a small true difference at three sample sizes and measure the power of each.

This is one section of Probability & statistics, 27 hours across 6 sections. Read it in full to get the whole topic in order.