Index Ai Academy
Phase 2 · Math, when you need it · Probability & statistics

A/B tests and significance

Tell a real lift from noise.

About 30 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. The question a test answers
  2. The two-proportion z-test
  3. Power and sample size: plan before you start
  4. Worked example: a subject-line test
  5. 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:

  1. Start by assuming nothing special is going on. This is the null hypothesis, written H0H_0 (say "H-nought"). For an A/B test it says A and B have the same true rate. Any gap you see is chance.
  2. Ask: if that were true, how surprising would a gap at least this big be?
  3. 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 (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.

Quick check
A test gives p = 0.30. What's the right conclusion?
  1. The data don't show a difference; there may be none, or the test was too small to detect it
  2. There's a 30% chance B is better
  3. B is 30% better
  4. A and B are proven to perform the same
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, nAn_A is the number of visitors. Of those, xAx_A converted. So A's measured rate is p^A=xA/nA\hat{p}_A = x_A / n_A. 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:

p^=xA+xBnA+nB\hat{p} = \frac{x_A + x_B}{n_A + n_B}

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 zz.

SE=p^(1−p^)(1nA+1nB)z=p^B−p^ASE\text{SE} = \sqrt{\hat{p}(1 - \hat{p})\left(\frac{1}{n_A} + \frac{1}{n_B}\right)} \qquad z = \frac{\hat{p}_B - \hat{p}_A}{\text{SE}}

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 zz 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 runs right here in your browser, with nothing to install.

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, zz follows the standard bell curve: mean 0, standard deviation

  1. (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 zz. "Two-sided" means a gap in either direction counts, B ahead or A ahead.

Books write that distance as ∣z∣|z|, the absolute value of z. It's z with its sign dropped, so ∣−2∣=2|-2| = 2.

From the normal distribution lesson, 95% of the bell lies within 1.96 of the middle. So a ∣z∣|z| 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:

SEdiff=p^A(1−p^A)nA+p^B(1−p^B)nB\text{SE}_{\text{diff}} = \sqrt{\frac{\hat{p}_A(1 - \hat{p}_A)}{n_A} + \frac{\hat{p}_B(1 - \hat{p}_B)}{n_B}}

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.

Quick check
Two conversion rates differ by 0.02, and the standard error of the gap is 0.01. What does this print, and is the gap significant at 0.05?
gap, se = 0.02, 0.01
print(round(gap / se, 1))
  1. 0.02, and no: the gap is too small
  2. 0.5, and no: the gap is smaller than the SE
  3. 2.0, and no: z must be above 5
  4. 2.0, and yes: |z| is above 1.96
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.

Try it now

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 α=0.05\alpha = 0.05 with 80% power, the visitors needed per variant are roughly:

n≈(1.96+0.84)2 [pA(1−pA)+pB(1−pB)](pB−pA)2n \approx \frac{(1.96 + 0.84)^2\,\big[p_A(1 - p_A) + p_B(1 - p_B)\big]}{(p_B - p_A)^2}

Here nn is the number of visitors each version needs. The rate you have now is pAp_A. The rate you hope to reach is pBp_B. 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 nn. 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 nn 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.

Quick check
To detect a lift from 4% to 5%, you need about 6,700 visitors per version. Your boss now wants to detect a lift from 4% to 4.5%. Roughly how many visitors per version will that take?
  1. About 6,700: the same
  2. About 3,400: half as many
  3. About 25,000: roughly four times as many
  4. About 13,000: twice as many
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

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%}")
In the lesson you can change this and run it.

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 α=0.05\alpha = 0.05. 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 α\alpha 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.

Quick check
You compare 10 new prompts with the old one, each at α = 0.05. One prompt scores better with p = 0.03. What should you do?
  1. Throw it away: p-values don't work with more than one comparison
  2. Ship it: p is below 0.05
  3. Treat it as a lead: confirm it in a fresh test, or hold it to the Bonferroni cutoff of 0.005
  4. Rerun the analysis on the same data until p gets lower
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

Create a free account

Try a quiz question

What does p = 0.02 mean in an A/B test?

  1. If there were no real difference, a gap this large or larger would happen about 2% of the time
  2. There's a 98% chance that B is better than A, so you can roll it out with confidence
  3. B is 2% better than A, and this result is how far the two versions were apart in the test
  4. There's a 2% chance the null hypothesis is true, which means the difference is very likely real

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson