A/B tests and significance
Tell a real lift from noise.
Create a free account Try part of it here
Why it matters
You may have run A/B tests in a tool that announces a winner. An A/B test shows two versions of something, A and B, to different people and compares the results. Under the hood, the tool runs a hypothesis test. The same method tells you whether a new prompt, model or search setup really beats the old one on your eval (your fixed set of test questions). This lesson shows what a p-value means and what it doesn't. It also shows how much data a test needs. That keeps you from shipping noise, and from calling a winner on day two.
What you'll be able to do
- State a null hypothesis and read a p-value correctly
- Run a two-proportion z-test and a confidence interval for the difference
- Plan a test's sample size from the baseline rate and the lift you care about
- Avoid the classic mistakes: peeking, many comparisons, and confusing significant with important
What it covers
- The question a test answers
- The two-proportion z-test
- Power and sample size: plan before you start
- Worked example: a subject-line test
- Three ways to fool yourself
The question a test answers
A shop tries two versions of its checkout page. Version A converted 200 of 5,000 visitors, which is 4.0%. Version B converted 245 of 5,000, which is 4.9%. (To convert means to buy.) B looks better. But you saw in the last lesson that rates wobble from sample to sample. So is B really better, or did it get lucky?
Think of a coin. You flip it 10 times and get 6 heads. Nobody would call the coin unfair. A fair coin gives 6 or more heads about 38% of the time. Now say you get 9 heads in 10 flips. A fair coin does that only about once in 100 tries. That's rare enough to make you suspicious.
A hypothesis test runs the same reasoning with numbers:
- Start by assuming nothing special is going on. This is the null hypothesis, written (say "H-nought"). For an A/B test it says A and B have the same true rate. Any gap you see is chance.
- Ask: if that were true, how surprising would a gap at least this big be?
- The answer is a probability called the p-value.
For the coin, the null hypothesis is "the coin is fair". The p-value for 9 or more heads is about 0.01.
How do you read the answer?
- A small p-value means a gap this big would be rare if there were no real difference. The usual cutoff is 0.05, or 5%. This cutoff is the significance level, written (alpha). Below it, you call the result statistically significant. You reject the null hypothesis.
- A large p-value means the data fit with "no difference". That doesn't prove there's no difference. The test may be too small to tell.
In plain words: the p-value answers one question. "If A and B were truly the same, how often would luck alone make a gap this big?" Rarely means a small p-value.
Watch out: the p-value is not the chance that B is better. It isn't the chance that the null hypothesis is true, either. It's the chance of data this extreme, assuming there's no difference.
Show the answer
The data don't show a difference; there may be none, or the test was too small to detect it
A large p-value means 'consistent with no difference', not 'proven equal'. A gap at least this big would turn up 30% of the time by luck alone, so the test can't tell the two apart.
The two-proportion z-test
The usual test for comparing two rates is the two-proportion z-test. ("Proportion" is another word for a rate or a share.) It needs a few names first.
For version A, is the number of visitors. Of those, converted. So A's measured rate is . That's "p-hat A", like the p-hat in the last lesson. Version B gets the same three names, with a B at the bottom.
The test then runs in three steps.
Step 1: pool the two groups. The null hypothesis says A and B share one true rate. Your best guess at that shared rate lumps both groups together. This is the pooled rate:
Step 2: find the standard error of the gap. You met the standard error (SE) in the last lesson. It's the typical size of the wobble.
Step 3: divide the gap by it. The result is called .
In plain words: add up all the conversions and divide by all the visitors. That's the pooled rate. The SE says how big a gap luck alone would usually make, if both groups had that pooled rate. Then says how many of those SEs the real gap is. It's the same idea as the z-score in the normal distribution lesson.
Worked example with small numbers. A gets 10 sales from 100 visitors, or 10%. B gets 20 sales from 100 visitors, or 20%.
- Pooled rate: (10 + 20) ÷ (100 + 100) = 30 ÷ 200 = 0.15.
- SE: 0.15 × 0.85 = 0.1275. Next, 1/100 + 1/100 = 0.02. Multiply them to get 0.00255. The square root is about 0.0505.
- z: the gap is 0.20 − 0.10 = 0.10. Divide by 0.0505 and you get about 1.98.
import math xa, na, xb, nb = 10, 100, 20, 100 pooled = (xa + xb) / (na + nb) se = math.sqrt(pooled * (1 - pooled) * (1 / na + 1 / nb)) z = (xb / nb - xa / na) / se print(round(pooled, 2), round(se, 4), round(z, 2))
It prints 0.15 0.0505 1.98. The gap is about two standard errors wide.
From z to a p-value
If the null hypothesis is true, follows the standard bell curve: mean 0, standard deviation
- (That's an approximation, and a good one when the groups are large.) The two-sided p-value
is the chance the bell lands at least as far from zero as your . "Two-sided" means a gap in either direction counts, B ahead or A ahead.
Books write that distance as , the absolute value of z. It's z with its sign dropped, so .
From the normal distribution lesson, 95% of the bell lies within 1.96 of the middle. So a above 1.96 is the same as a p-value below 0.05. In the small example, 1.98 is barely past that line. Its p-value is about 0.048. Even a 10-point gap is borderline with only 100 visitors each.
Now the real test, with 5,000 visitors per version. SciPy's norm.sf gives the upper tail of the bell. Doubling it covers both sides.
import numpy as np from scipy.stats import norm xa, na, xb, nb = 200, 5000, 245, 5000 pa, pb = xa / na, xb / nb pooled = (xa + xb) / (na + nb) se = np.sqrt(pooled * (1 - pooled) * (1 / na + 1 / nb)) z = (pb - pa) / se p_value = 2 * norm.sf(abs(z)) print(f"A {pa:.2%} B {pb:.2%} z = {z:.2f} p = {p_value:.4f}") se_diff = np.sqrt(pa * (1 - pa) / na + pb * (1 - pb) / nb) diff = pb - pa print(f"difference {diff:+.2%}, 95% CI {diff - 1.96 * se_diff:+.2%} to {diff + 1.96 * se_diff:+.2%}")
The first line gives z = 2.18 and p ≈ 0.03. If there were no real difference, a gap this big would turn up about 3 times in 100. That's below 0.05, so the result is significant.
The second line is a 95% confidence interval for the lift. It's built as in the last lesson: the estimate plus or minus 1.96 standard errors. This time the SE uses each version's own rate, not the pooled one. The whole interval, +0.09% to +1.71%, sits above zero. That's more evidence B is really better.
Report the interval along with the p-value. A rise from 4.0% to 4.9% is 0.9 percentage points. "B lifts conversion by 0.9 points, plausibly between 0.1 and 1.7" says much more than "significant".
Go deeper Why pool for the test but not for the interval?
The test asks "what would happen if the null hypothesis were true?" The null says both versions share one rate. So the test's SE uses that one shared rate, the pooled rate.
The interval asks a different question: "how big is the real gap?" It doesn't assume the rates are equal. So its SE treats A and B as two separate rates, each with its own wobble. You add the two squared wobbles and take the square root:
Read it as: "the SE of A's rate squared, plus the SE of B's rate squared, then the square root." Each part is the rate formula from the last lesson.
With the small example, A's part is 0.10 × 0.90 ÷ 100 = 0.0009. B's part is 0.20 × 0.80 ÷ 100 = 0.0016. They add to 0.0025, and the square root is 0.05. That's the se_diff line in the code above, with different numbers.
A test can also be one-sided: it only counts a gap in one direction. If the gap goes the way you predicted, that halves the p-value. You must choose it before you see the data, so two-sided is the safe default.
gap, se = 0.02, 0.01 print(round(gap / se, 1))
Show the answer
2.0, and yes: |z| is above 1.96
z is the gap divided by its standard error: 0.02 / 0.01 = 2.0. Any |z| above 1.96 gives a two-sided p-value below 0.05, so this gap is significant, though barely.
Run the test once, then again a few more times. With a real 20% lift and 5,000 visitors each, some runs come out significant and some don't. 'Run it 500 times' shows the test's power: the share of runs that catch the lift. Then set the lift to 0 and see how often noise looks like a win.
Power and sample size: plan before you start
Picture someone talking to you across a noisy room. A shout is easy to hear. A whisper needs you to listen much longer. Tests work the same way. A big lift shows up with little data. A small lift needs a lot.
Power is the chance a test detects a real effect of a given size. Most teams plan for 80% power. That means if the lift is real and as big as you planned for, the test catches it 4 times in 5. Small lifts on small baselines need a lot of traffic. (The baseline is the rate you have now.)
For a two-sided test at with 80% power, the visitors needed per variant are roughly:
Here is the number of visitors each version needs. The rate you have now is . The rate you hope to reach is . The 1.96 comes from the 5% significance level. The 0.84 comes from 80% power.
In plain words: add 1.96 and 0.84 to get 2.8, and square it to get 7.84. Multiply by how noisy the two rates are: the "rate times one minus the rate" parts. Then divide by the lift, squared. Because the lift is squared, halving the lift you want to catch roughly quadruples the visitors you need.
Worked example with small numbers. You want to detect a rise from 10% to 15%.
- 1.96 + 0.84 = 2.8, and 2.8 squared is 7.84.
- The noise: 0.10 × 0.90 = 0.09, and 0.15 × 0.85 = 0.1275. Together that's 0.2175.
- The lift is 0.05. Squared, that's 0.0025.
- 7.84 × 0.2175 = 1.7052. Divide by 0.0025 to get 682.08.
- Round up: 683 visitors per version.
import math pa, pb = 0.10, 0.15 n = (1.96 + 0.84) ** 2 * (pa * (1 - pa) + pb * (1 - pb)) / (pb - pa) ** 2 print(math.ceil(n))
It prints 683.
Now the shop's numbers. A lift from 4.0% to 4.4% is 0.4 points. That's a 10% relative lift, because 0.4 is a tenth of 4. It needs about 39,400 visitors per variant. A lift from 4% to 5% needs about 6,700.
So plan in this order. Decide the smallest lift worth detecting. Compute . Run the test until each version reaches it.
Go deeper Where 1.96 and 0.84 come from
Both numbers are points on the standard bell curve.
- 1.96 marks the middle 95% of the bell. A gap must land past it to count as significant at 5% (two-sided). In SciPy,
norm.ppf(0.975)gives about 1.96. - 0.84 is where 80% of the bell sits below. In SciPy,
norm.ppf(0.80)gives about 0.8416.
Adding them picks an where the real lift sits 2.8 standard errors from zero. Then the measured gap lands past the 1.96 line about 80% of the time. For 90% power, swap 0.84 for 1.28. The formula is an approximation, and it works well for the large samples A/B tests use.
Show the answer
About 25,000: roughly four times as many
The lift is squared at the bottom of the formula. Half the lift means a quarter of the squared lift, so roughly four times the visitors. The exact answer is about 25,500.
Worked example: a subject-line test
You email 400 people with subject line A and 400 with subject line B. A gets 50 opens (12.5%). B gets 75 opens (18.75%). Is B's lead real? Run the three steps of the z-test, then build the 95% interval for the gap.
import math xa, na = 50, 400 # subject line A: opens, emails sent xb, nb = 75, 400 # subject line B pa, pb = xa / na, xb / nb pooled = (xa + xb) / (na + nb) se = math.sqrt(pooled * (1 - pooled) * (1 / na + 1 / nb)) z = (pb - pa) / se print(f"A {pa:.2%} B {pb:.2%} z = {z:.2f}") print("significant at 0.05" if abs(z) > 1.96 else "no clear difference") se_diff = math.sqrt(pa * (1 - pa) / na + pb * (1 - pb) / nb) diff = pb - pa print(f"difference {diff:+.2%}, 95% CI {diff - 1.96 * se_diff:+.2%} to {diff + 1.96 * se_diff:+.2%}")
z comes out at 2.43. That's past 1.96, so the result is significant at 0.05. (Its p-value is about 0.015.) B's lead is 6.25 points. The interval runs from about +1.2 to +11.3 points. It sits above zero, which is good evidence that B is better. But the interval is wide. With only 400 emails each, you know B wins, not by how much. A larger follow-up test would narrow it.
Three ways to fool yourself
1. Peeking. Imagine a race where you may stop the clock whenever your favorite runner is ahead. They'll "win" far more often than they should. Tests work the same way. Checking the p-value every day and stopping the first time it dips below 0.05 pushes false positives far above 5%. A false positive is declaring a winner when there's no real difference.
The simulation below runs 2,000 A/A tests. In an A/A test both groups see the same version, so any "winner" is a false positive. Each test runs for 20 days, with 500 visitors a day per group. It peeks at the p-value every day.
import numpy as np from scipy.stats import norm rng = np.random.default_rng(0) p, visitors_per_day, days, tests = 0.04, 500, 20, 2000 false_wins_peeking = false_wins_once = 0 for _ in range(tests): a = rng.random((days, visitors_per_day)) < p b = rng.random((days, visitors_per_day)) < p ca, cb = a.sum(axis=1).cumsum(), b.sum(axis=1).cumsum() n = visitors_per_day * np.arange(1, days + 1) pooled = (ca + cb) / (2 * n) z = (cb - ca) / n / np.sqrt(pooled * (1 - pooled) * 2 / n) pvals = 2 * norm.sf(np.abs(z)) false_wins_peeking += bool((pvals < 0.05).any()) false_wins_once += bool(pvals[-1] < 0.05) print(f"declared a winner when checking only at the end: {false_wins_once / tests:.1%}") print(f"declared a winner when stopping at the first p < 0.05: {false_wins_peeking / tests:.1%}")
Checking only at the end gives about 4%, close to the 5% you signed up for. Stopping at the first p below 0.05 gives more than 20%. Each daily look is one more chance for noise to cross the line. Repeated looks inflate the false-positive rate well above the 5% you intended. The fix is to set the sample size in advance and look once, at the end. Or use a method built for watching results as they arrive, called sequential testing.
2. Many comparisons. Test 20 headlines against the original at . Even if none is better, you expect about one to look "significant" by luck alone: 20 × 0.05 = 1. One simple fix is the Bonferroni correction: divide by the number of comparisons. For 20 comparisons, that's 0.05 ÷ 20 = 0.0025. Another fix is to confirm any winner in a fresh test.
3. Significant isn't the same as important. A precise enough kitchen scale can show that one bag of flour is 1 gram heavier than another. The difference is real, and it doesn't matter. In the same way, with tens of millions of visitors a 0.01-point lift can be "significant" and worth nothing. Look at the size of the effect and its interval, not only at p.
For marketers: the same rules apply when you compare prompts or models on an eval. Fix the eval set in advance. Use enough examples. Compare both on the same examples. Report the difference with an interval.
Show the answer
Treat it as a lead: confirm it in a fresh test, or hold it to the Bonferroni cutoff of 0.005
With 10 comparisons, a p below 0.05 can easily come from luck. Bonferroni divides 0.05 by 10, giving 0.005, and 0.03 doesn't pass. A fresh test is the cleanest way to confirm the winner.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
What does p = 0.02 mean in an A/B test?