learn-experimentation-with-phoebe / Builder session 4 of 10
Learn Experimentation with Phoebe · Builder session 4 of 10

Run and analyze an A/B test, and report it honestly

This is the payoff session. Lumen ran the hero-first product-page test - control 3.2% vs variant 3.6% on ~32,000 users per arm - and now the numbers are in. You will analyze it end to end in statsmodels: the two-proportion z-test, a confidence interval on the difference, effect size as absolute and relative lift, and the trap that sinks most real readouts - testing many metrics at once and celebrating the one that crossed 0.05 by luck. By the end you can defend a ship decision, not just announce one.

🟡 Core Builders · DA / DE / DS Python · statsmodels 45 min
0-3 · Setup 3-14 · The test + effect size 14-40 · Analyze it in Python 40-45 · Wrap
Part 0

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.

Live - built in session Self-study - read after class ★ Build-along - everyone codes it The data: Lumen Skincare
★ What you walk out with today A notebook that (1) runs the two-proportion z-test on Lumen's real conversion counts and returns z, p, and a CI on the difference, (2) reports absolute and relative lift with an interval and asks "significant AND practically meaningful?", and (3) tests five metrics at once, then applies Bonferroni and Benjamini-Hochberg and watches a naive "winner" evaporate. This is the readout you paste into the ship-decision doc.
Part 1 · the test

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.

Control 1024 / 32000 = 3.2% Variant 1152 / 32000 = 3.6% Pooled rate p̄ = 3.4% SE = √( p̄(1-p̄)(1/n₁+1/n₂) ) = 0.00143 z = (0.036 - 0.032) / SE = 0.004 / 0.00143 ≈ 2.79 p (two-sided) ≈ 0.005 below 0.05 - reject the null And the part the p-value hides - the CI on the difference: 0 (no effect) diff = 0.4pp 95% CI ≈ [0.1pp, 0.7pp] - excludes 0, so significant The z-test pools rates under the null; the CI on the difference uses the unpooled SE - two SEs, do not mix them up.
🔍 Click to zoom - two rates in, a pooled SE, a z, a p-value, and the interval that actually sizes 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.
Real world

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.

MeasureFormulaLumen value
Absolute liftp₁ - p₀3.6% - 3.2% = 0.4pp
Relative lift(p₁ - p₀) / p₀0.004 / 0.032 = +12.5%
"+12.5%" and "0.4pp" are the same number Relative lift sounds bigger and belongs in the headline; absolute lift (percentage points) is what actually moves revenue and belongs in the model. A finance partner who hears "+12.5%" and budgets against a 12.5-point rate jump will be badly wrong. Quote both, always with the interval and the guardrail check from canon (latency, refund rate, add-to-cart).
Part 2 · the trap

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.

20 metrics tested at α = 0.05, and the truth is: none of them changed this one "won" at p < 0.05 - a false positive, expected ~1 in 20 After a correction (Bonferroni tests each at α/20 = 0.0025): the star is gone - corrected away Decide your metric list before you look. Bonferroni controls FWER (conservative); Benjamini-Hochberg controls FDR (more power with many metrics).
🔍 Click to zoom - 20 metrics, ~1 false positive expected; a correction darkens it back to not significant
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.
Build-along 1 of 3

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":

demo1_ztest.py
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:

demo1_diff_ci.py
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.

Build-along 2 of 3

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:

demo2_effect_size.py
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:

demo2_lift_ci.py
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.

Real world

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.

Build-along 3 of 3

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:

demo3_metrics.py
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:

demo3_correct.py
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.

Pre-register the primary metric, correct the rest One decision metric, declared before launch, read at face value. Every other metric you glance at inflates your false-positive rate, so it goes through Bonferroni or BH. "We looked at 20 things and one was significant" is not a finding - it is the definition of the multiple-comparisons trap.
Before Session 5

This week ◐ 40 min total

Check yourself

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.

Source material

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.

Two-proportion z-test, pooled vs unpooled SE, CI on the differencestatsmodels - Part 1, build-along 1
Effect size: absolute lift, relative lift, statistical vs practical significancePart 1, build-along 2 + guardrails from canon
Multiple comparisons: Bonferroni (FWER) vs Benjamini-Hochberg (FDR)statsmodels.stats.multitest - Part 2, build-along 3
A/B Testing in Python (365 Data Science, Kuznetsova)most on-point platform course - full worked examples
The Power of Statistics (Google GADA C3)hypothesis-testing + CI foundations in depth

Builder Session 4 cheat sheet · pin this

Two-proportion z-testPool the arms under the null: p̄, SE = √(p̄(1-p̄)(1/n₁+1/n₂)), z = diff/SE. proportions_ztest.
CI on the differenceUses the unpooled SE. confint_proportions_2indep(..., compare='diff'). CI excludes 0 ≡ significant at α=0.05.
Absolute vs relative liftabs = p₁-p₀ (percentage points); rel = (p₁-p₀)/p₀. Lumen: 0.4pp = +12.5%. Quote both.
Significant ≠ meaningfulp-value = "probably not zero". Read the interval for magnitude, then check practical value + guardrails.
Multiple comparisonsTest m metrics, expect ~m×0.05 false positives. Pre-register the primary; correct the rest.
Bonferroni vs BHBonferroni: each at α/m, controls FWER, conservative. Benjamini-Hochberg: controls FDR, more powerful. multipletests.