The readout everyone gets wrong
Sessions b1-b3 got you a trustworthy test: a clean estimand, enough power, and a randomization you can defend. Now the data has landed and someone wants a number by end of day. The failure mode is not the arithmetic - statsmodels does that in one line. The failure mode is the story around it: quoting a lift with no interval, confusing "statistically significant" with "worth shipping", and running an eye over five metrics until one crosses 0.05. Today we do the whole readout the way a staff data scientist would - the test, the interval, the effect size, and the multiple-comparisons correction that keeps you honest.
The two-proportion z-test, piece by piece 6 min live
Conversion is a binary outcome per user - bought or did not - so each arm is a proportion. To ask whether variant 3.6% beats control 3.2%, we compare two proportions. The z-test pools the two arms into one rate under the null (they are equal), builds a standard error from that pooled rate, and measures how many standard errors apart the observed difference sits. Big z, small p. But the p-value alone never ships a change - you also need the interval on the difference, because that is what tells you the plausible size of the effect.
LiveWhy the interval matters more than the p-value3 min▶
A p-value answers one narrow question: "if the true effect were zero, how surprising is this data?" It says nothing about how big the effect is. Two tests can both hit p = 0.005 while one has a CI of [0.1pp, 0.7pp] and the other [3pp, 9pp] - wildly different business cases. The confidence interval on the difference carries the magnitude, so read it first.
- z-test SE (pooled) - assumes the null is true (both arms share one rate p̄). Used to compute z and p.
- CI SE (unpooled) - uses each arm's own rate. Used to build the interval on the difference.
- Decision rule - the 95% CI excluding 0 is equivalent to a two-sided test at α = 0.05, but the CI also hands you the plausible range.
A deck says "variant won, p = 0.03, ship it." You ask for the interval; it is [0.02pp, 0.9pp]. The lower bound is a rounding error - the test is technically significant but the effect might be trivially small. "Significant" bought you "probably not zero", not "worth the engineering cost". Always ask for the interval.
LiveEffect size: absolute lift vs relative lift3 min▶
The same win reads two ways, and stakeholders hear them very differently. State both, and label which is which.
| Measure | Formula | Lumen value |
|---|---|---|
| Absolute lift | p₁ - p₀ | 3.6% - 3.2% = 0.4pp |
| Relative lift | (p₁ - p₀) / p₀ | 0.004 / 0.032 = +12.5% |
Test many metrics, invent a winner 4 min live
Every test at α = 0.05 has a 5% chance of a false positive when nothing is going on. Test one metric, that risk is 5%. Test 20 metrics, you expect about one to cross 0.05 by pure luck - and the more you slice (per device, per geo, per product line), the surer you are to "find" something. This is the multiple-comparisons problem, and it is why dashboards full of green stars are so often noise. Two corrections tame it: Bonferroni tests each metric at α/m, which controls the family-wise error rate but is conservative; Benjamini-Hochberg controls the false discovery rate and is more powerful when you have many metrics.
Self-studyFWER vs FDR - which correction, when3 min read▶
The two corrections control different things, so pick by how costly a false positive is.
- Family-wise error rate (FWER) - the chance of any false positive across the whole family. Bonferroni holds it at α by testing each hypothesis at α/m. Simple, conservative, loses power fast as m grows. Use it when even one false ship is expensive (a guardrail claim, a launch gate).
- False discovery rate (FDR) - the expected proportion of your declared winners that are false. Benjamini-Hochberg sorts the p-values and compares each to a rising threshold (i/m)α, tolerating a few false positives to keep more true ones. Use it for exploratory metric sweeps where missing a real effect costs more than chasing a spurious one.
- Either way - the primary decision metric (Lumen conversion) is pre-registered and read on its own; corrections apply to the secondary metric sweep, not the one headline test.
Run the two-proportion z-test on Lumen ★ 9 min · everyone
The test came back: control 3.2% (1024 of 32000), variant 3.6% (1152 of 32000). We run the z-test and, in the same breath, the confidence interval on the difference - because a p-value without an interval is half a readout.
Feed the counts to proportions_ztest. Order variant first so a positive z means "variant higher":
import numpy as np
from statsmodels.stats.proportion import proportions_ztest, confint_proportions_2indep
# Lumen hero-first product-page test, primary metric = checkout conversion
n_ctrl, n_var = 32_000, 32_000
conv_ctrl, conv_var = 1024, 1152 # 3.2% vs 3.6%
count = np.array([conv_var, conv_ctrl]) # successes, variant first
nobs = np.array([n_var, n_ctrl]) # trials
z, p = proportions_ztest(count, nobs, alternative='two-sided')
print(f"z = {z:.3f} p = {p:.4f}") # z ≈ 2.79 p ≈ 0.005
Now the interval on the difference in rates (variant minus control). This uses the unpooled SE, so it is the number you quote for effect magnitude:
low, high = confint_proportions_2indep(
conv_var, n_var, conv_ctrl, n_ctrl,
compare='diff', alpha=0.05)
print(f"diff 95% CI = [{low:.4f}, {high:.4f}]")
# ≈ [0.0012, 0.0068] -> [0.12pp, 0.68pp], excludes 0
Read it honestly. p ≈ 0.005 clears 0.05, and the CI excludes zero - so the result is statistically significant. But the lower bound is only 0.12pp. Before you write "ship", ask the second question: is 0.4pp practically meaningful for Lumen at $18M/yr revenue, and did any guardrail (latency, refund, add-to-cart) move against us? Significance is a gate, not a decision.
Effect size and the lift interval ★ 8 min · everyone
A single "the variant won" is not a report. We compute both flavours of lift and put an interval on the relative lift, then say out loud where statistical significance and practical significance diverge.
Absolute lift is percentage points; relative lift divides by the control rate:
p_ctrl, p_var = conv_ctrl / n_ctrl, conv_var / n_var # 0.032, 0.036
abs_lift = p_var - p_ctrl # 0.004 -> 0.4pp
rel_lift = abs_lift / p_ctrl # 0.125 -> +12.5%
print(f"absolute lift = {abs_lift*100:.2f}pp")
print(f"relative lift = {rel_lift*100:.1f}%")
Turn the CI on the difference into a CI on the relative lift by dividing the bounds by the control rate - this is what a stakeholder actually wants to hear:
rel_low, rel_high = low / p_ctrl, high / p_ctrl
print(f"relative lift 95% CI = [{rel_low*100:.1f}%, {rel_high*100:.1f}%]")
# ≈ [3.7%, 21.3%] -> point estimate +12.5%, but the true lift
# could plausibly be as small as ~4% or as large as ~21%
State it in one sentence, both scales, with the interval: "Conversion rose 0.4pp (from 3.2% to 3.6%), a +12.5% relative lift, 95% CI [3.7%, 21.3%]; significant at α=0.05, guardrails held." Statistical vs practical: the effect is real, but the wide interval means finance should model the lower bound, not the point estimate, before committing revenue to it.
A team ships on "+12.5% conversion" and forecasts a 12.5% revenue jump. Two things go wrong: 12.5% is relative (0.4 points, not 12.5 points), and it was the top of a wide interval. The realised lift lands near the lower bound and someone asks why the model missed by 3x. Quoting the interval up front is how you avoid that meeting.
Five metrics, and a winner that evaporates ★ 8 min · everyone
The product team did not just watch conversion. They eyeballed AOV, add-to-cart, refund rate, and page latency too - and refund rate "improved" at p = 0.048. We correct for the five simultaneous tests with multipletests and watch that naive win disappear.
Line up the five raw p-values, one per secondary metric:
from statsmodels.stats.multitest import multipletests
metrics = ["conversion", "add_to_cart", "AOV", "refund_rate", "latency"]
pvals = [0.005, 0.009, 0.028, 0.048, 0.55]
m = len(pvals)
naive = [p < 0.05 for p in pvals]
print("naive winners (α=0.05):", [x for x, w in zip(metrics, naive) if w])
# ['conversion', 'add_to_cart', 'AOV', 'refund_rate'] - 4 "wins"
Apply Bonferroni (FWER) and Benjamini-Hochberg (FDR) with one call each:
bonf = multipletests(pvals, alpha=0.05, method='bonferroni')[0]
bh = multipletests(pvals, alpha=0.05, method='fdr_bh')[0]
print("Bonferroni survivors:", [x for x, s in zip(metrics, bonf) if s])
# ['conversion', 'add_to_cart'] (threshold 0.05/5 = 0.01)
print("Benjamini-Hochberg :", [x for x, s in zip(metrics, bh) if s])
# ['conversion', 'add_to_cart', 'AOV'] (more powerful - keeps AOV)
Watch refund_rate (p = 0.048) vanish under both corrections - it was a naive winner manufactured by testing five things at once. Note the difference in power too: Bonferroni keeps 2 metrics, Benjamini-Hochberg keeps 3, because BH tolerates a controlled false-discovery rate instead of forbidding any single false positive. The headline conversion result survives every correction - which is exactly why we pre-registered it as the primary metric and read it on its own.
This week ◐ 40 min total
- Bootstrap the CI to cross-check. Resample each arm with replacement 10,000 times, recompute the difference in rates each time, and take the 2.5th / 97.5th percentiles. Confirm the bootstrap interval lands close to the analytic
confint_proportions_2indepresult - two independent methods agreeing is your sanity check. - Re-run the sweep with more metrics. Add device and geo splits until you have ~15 secondary metrics, feed random-ish p-values, and watch how many "winners" Bonferroni vs BH keep. Feel the power gap widen as m grows.
- Flip absolute and relative. Recompute the lift assuming the baseline were 8% instead of 3.2%. Same 0.4pp absolute lift, but the relative lift shrinks to +5% - see why baseline choice changes the headline.
- Sequential teaser. You analyzed this test once, at the pre-committed N of ~32,000/arm. What if a PM had peeked on day 3 and called it early? That is the peeking problem - fixed-horizon p-values are only valid at the planned N, and repeated looks can push Type I error past 30%. We fix it properly in b6 (sequential testing and peeking).
Three questions before you go 🎯 ◐ 90 seconds
1 · A test is significant (p = 0.03) with a 95% CI on the difference of [0.02pp, 0.9pp]. What is the honest read?
The CI excludes zero (so it is significant) but its lower bound is near zero, so the effect could be trivially small. Significance is a gate; the interval and the business case decide the ship.
2 · Lumen went from 3.2% to 3.6%. Which statement is correct?
Absolute lift is 3.6 - 3.2 = 0.4pp; relative lift is 0.4/3.2 = +12.5%. Same win, two scales. Confusing the relative figure for percentage points is a classic forecasting error.
3 · You test 5 metrics at α=0.05 and one crosses at p=0.048. Why correct, and how do Bonferroni and BH differ?
Each test carries a 5% false-positive risk, so five tests inflate the chance one crosses by luck. Bonferroni caps the family-wise error at α but loses power; Benjamini-Hochberg controls the false discovery rate and keeps more true effects.
What this session covers
Analyzing a two-arm test is the working core of every A/B course. We teach ~80% of the practical readout here - the test, the interval, effect size, and multiple comparisons; the fuller inference theory and platform-specific stats engines live in the sources below.
Builder Session 4 cheat sheet · pin this
proportions_ztest.confint_proportions_2indep(..., compare='diff'). CI excludes 0 ≡ significant at α=0.05.multipletests.