The question you must answer before you ship a single variant
Lumen wants to test a new "hero-first" product-page layout, hoping to lift checkout conversion from a baseline of 3.2%. Before the experiment runs, someone will ask: "How long do we leave it on?" The honest answer is a calculation, not a vibe. Too few users and a real win hides inside the noise - you kill a good idea. Too many and you burn traffic and calendar you could have spent on the next test. Session 2 turns that trade into arithmetic: pick your tolerance for two kinds of mistake, pick the smallest effect worth detecting, and the sample size falls out. We derive it, then compute it in code, then convert it into a runtime in days.
statsmodels.stats.power, (2) a power curve you can read the tradeoff off, and (3) the runtime in days at Lumen's traffic. You will reproduce the ~32,000-per-arm number the b1 planner showed you - and understand every assumption behind it.
Type I, Type II, and what power really means 6 min live
Every test can be wrong in exactly two ways. A Type I error (false positive) is calling a win when nothing happened - its rate is alpha, conventionally 0.05. A Type II error (false negative) is missing a real effect - its rate is beta. Power = 1 - beta is the probability you detect an effect that is genuinely there, conventionally set to 0.80. These four cells are the whole design problem: you buy lower error rates with more sample.
LiveWhy 0.05 and 0.80 - and why they are just conventions3 min▶
Neither number is a law of nature. alpha = 0.05 means you accept a 1-in-20 false-positive rate on a true null; power = 0.80 means you accept missing a real effect 1 time in 5. They are defaults that traded off cost and caution well enough to stick. What matters is that you set them before the test and let them drive the sample size.
- Lower alpha (say 0.01) - fewer false wins, but you need more sample to keep the same power.
- Higher power (say 0.90) - you catch more real effects, but again the sample grows.
- The lever you cannot cheat - you cannot lower both error rates and shrink the test. Sample size is what you pay.
LiveOne-sided vs two-sided, and the effect size in between3 min▶
A two-sided test asks "did the variant change conversion, up or down?" and splits alpha across both tails (z at 1 - alpha/2). A one-sided test asks only "did it go up?" - cheaper in sample, but you are blind to a drop, which for a product change is exactly the thing a guardrail is meant to catch. We default to two-sided.
Between the rates and the sample sits the effect size - a standardized distance between the two arms. For proportions, statsmodels uses proportion_effectsize, which applies an arcsine (variance-stabilizing) transform so the two-proportion problem maps onto its normal-power machinery.
MDE is a choice, not an output 4 min live
The minimum detectable effect is the smallest lift you care about catching. It is an input you decide with the business - "a 0.4pp absolute lift, from 3.2% to 3.6%, is worth shipping" - and it drives everything. Smaller MDE means you are hunting a fainter signal, which costs sample fast: sample size scales with 1 / delta^2, so halving the MDE roughly quadruples the test. Get the product to commit to an MDE before you compute anything.
Self-studyThe rule of 16 and the two-proportion formula3 min read▶
For a quick back-of-envelope check at the conventional alpha = 0.05, power = 0.80, two-sided settings, the rule of 16 is your friend:
n per arm ≈ 16 · sigma^2 / delta^2
The 16 is (z_{1-alpha/2} + z_{1-beta})^2 = (1.96 + 0.84)^2 ≈ 7.84 · 2 for two arms, rounded up for safety. For a proportion, sigma^2 = p(1 - p). The exact two-proportion formula the planner uses is:
n ≈ 2 · p̄(1 - p̄) · (z_{1-alpha/2} + z_{1-beta})^2 / (p1 - p2)^2
where p̄ is the pooled rate. Plug Lumen's numbers - p = 0.032, delta = 0.004 - and the rule of 16 gives 16 · 0.032 · 0.968 / 0.004^2 ≈ 31,000 per arm. The exact formula lands a touch higher, at ~32,000. Close enough that if statsmodels ever disagrees with the rule of 16 by a lot, you typed something wrong.
The sample-size planner - this is its home try it
You met this planner in b1 as a preview. Today it is the whole point. Drag the dials: raise the baseline, shrink the MDE, tighten alpha, push power up - and watch sample size and runtime respond. It is seeded with Lumen's flagship test (3.2% baseline, +12.5% relative MDE, alpha 5%, power 80%, 4,000 users/day). The three build-alongs below rebuild this tool's math by hand so nothing here is a black box.
Compute N per arm in statsmodels ★ 8 min · everyone
Rebuild the planner's core number. We use statsmodels.stats.power with NormalIndPower and turn Lumen's two rates into an effect size with proportion_effectsize. The target: ~32,000 per arm for the 3.2% → 3.6% test - and we state every assumption alongside it.
Turn the two conversion rates into a standardized effect size, then solve for the sample size per arm:
import numpy as np
from statsmodels.stats.power import NormalIndPower
from statsmodels.stats.proportion import proportion_effectsize
p1, p2 = 0.032, 0.036 # Lumen: control 3.2% vs variant 3.6%
alpha, power = 0.05, 0.80 # conventions, chosen up front
# arcsine-transformed standardized effect for a two-proportion test
effect = proportion_effectsize(p2, p1)
analysis = NormalIndPower()
n_per_arm = analysis.solve_power(
effect_size=effect,
alpha=alpha,
power=power,
ratio=1.0, # 50/50 split, equal arms
alternative="two-sided", # we care about up OR down
)
print(f"n per arm = {np.ceil(n_per_arm):,.0f}") # ~32,000
print(f"total N = {np.ceil(n_per_arm)*2:,.0f}") # ~64,000
Run it. You land on ~32,000 per arm, ~64,000 total. Say the assumptions out loud every time you quote it: baseline 3.2%, absolute MDE 0.4pp (relative +12.5%), alpha 0.05, power 0.80, two-sided, equal 50/50 allocation. A sample size with no assumptions attached is a number nobody can trust.
Sanity-check against the rule of 16 - if these disagree by more than a rounding, you have a bug:
p, delta = 0.032, 0.004 # baseline and absolute MDE
approx = 16 * p * (1 - p) / delta**2 # sigma^2 = p(1-p)
print(f"rule-of-16 ≈ {approx:,.0f} per arm") # ~31,000, agrees
A stakeholder says "let's just run it for a week and see." At 4,000 users/day that is ~28,000 total - under half of what this test needs. You would be running an experiment that, even if the layout truly lifts conversion by 0.4pp, has well under 50% odds of showing significance. That is how good ideas get "disproven" by an underpowered test.
Draw the power curve and see the tradeoff ★ 8 min · everyone
A single number hides the shape of the decision. Sweep the sample size, compute power at each point, and plot it. You will see power climb toward 1.0 with diminishing returns - and read straight off the curve how much traffic buys you how much detection.
Hold the effect fixed at Lumen's MDE and sweep N per arm, computing power at each size:
import numpy as np
import matplotlib.pyplot as plt
from statsmodels.stats.power import NormalIndPower
from statsmodels.stats.proportion import proportion_effectsize
effect = proportion_effectsize(0.036, 0.032)
analysis = NormalIndPower()
ns = np.arange(2_000, 60_001, 2_000) # sweep sample size per arm
powers = [analysis.solve_power(effect_size=effect, nobs1=n,
alpha=0.05, alternative="two-sided")
for n in ns]
Plot the curve and mark the 0.80 power line and the ~32,000 crossing point:
fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(ns, powers, color="#4F46E5", linewidth=2.5)
ax.axhline(0.80, color="#84CC16", linestyle="--", label="power = 0.80")
ax.axvline(32_000, color="#DC2626", linestyle=":", label="~32,000 / arm")
ax.set_xlabel("sample size per arm")
ax.set_ylabel("power (1 - beta)")
ax.set_title("Power vs sample size - Lumen 3.2% to 3.6% test")
ax.legend()
fig.tight_layout()
fig.savefig("power_curve.png", dpi=120)
# the curve rises steeply, then flattens: past ~32k you pay a lot for little
Read the shape. The curve is steep in the middle and flat near the top - the last few points of power cost enormous sample. This is why 0.80 is the convention: it sits near the knee, where you get most of the detection for a reasonable price. Chasing 0.99 power is almost never worth the traffic.
Turn N into a runtime in days ★ 6 min · everyone
A sample size means nothing to a stakeholder until it becomes a calendar. Wrap the whole calculation in one function that takes the business inputs and returns both the per-arm N and the number of days at Lumen's traffic. At 4,000 eligible users/day split 50/50, the ~64,000-user test runs ~16-17 days.
Write the planner function - baseline, MDE, alpha, power, daily traffic in; sample size and days out:
import numpy as np
from statsmodels.stats.power import NormalIndPower
from statsmodels.stats.proportion import proportion_effectsize
def plan_test(baseline, mde_rel, alpha=0.05, power=0.80,
daily_traffic=4000, two_sided=True):
"""Return (n_per_arm, days) for a two-proportion test.
baseline: control rate, e.g. 0.032
mde_rel: relative lift to detect, e.g. 0.125 for +12.5%
daily_traffic: eligible users/day, split 50/50 across 2 arms
"""
p1 = baseline
p2 = baseline * (1 + mde_rel) # 0.032 -> 0.036
effect = proportion_effectsize(p2, p1)
alt = "two-sided" if two_sided else "larger"
n = NormalIndPower().solve_power(
effect_size=effect, alpha=alpha, power=power,
ratio=1.0, alternative=alt)
n_per_arm = int(np.ceil(n))
total = n_per_arm * 2
days = int(np.ceil(total / daily_traffic)) # 50/50 uses all traffic
return n_per_arm, days
Call it with Lumen's flagship test and print a one-line brief anyone can act on:
n_arm, days = plan_test(baseline=0.032, mde_rel=0.125,
daily_traffic=4000)
print(f"{n_arm:,} per arm -> {n_arm*2:,} total -> {days} days")
# 32,000 per arm -> 64,000 total -> 16 days (~2.5 weeks)
Now the planner is yours end to end. Change any input and the calendar updates: this is exactly what the b1 widget does under the hood, minus the sliders. Hand a PM the number and the days, and "how long do we run it?" stops being a debate.
This week ◐ 40 min total
- Feel the blow-up. Recompute N for a smaller MDE - drop the target lift from +12.5% to +6%. Watch the per-arm sample roughly quadruple. Plot N vs MDE and see the
1/delta^2curve with your own eyes. - Power a mean metric too. Lumen also tracks average order value ($92 canonical). Use
statsmodels.stats.power.TTestIndPowerwith a standardized effect (Cohen's d = delta/sigma) to size a t-test, and compare the feel to the proportion case. - Tighten the rates. Rerun the planner at alpha 0.01 and at power 0.90. Note how much extra sample each buys you, and decide which you would actually pay for on Lumen's flagship test.
- Optional: add an
unequal allocationoption (ratio = 2.0, more traffic to control) and see how a lopsided split changes the total N for the same power.
Three questions before you go 🎯 ◐ 90 seconds
1 · Statistical power is the probability of...
Power = 1 - beta is the true-positive rate: given a real effect of at least your MDE, how often the test flags it. More sample raises power without touching alpha.
2 · Your test comes back p = 0.21 and "not significant". The safest reading is...
"Not significant" ≠ "no effect". A non-significant result is the absence of evidence, not evidence of absence - often it just means the test was underpowered for the true effect. Report the confidence interval and the power you had, never "no difference".
3 · You halve the minimum detectable effect you want to catch, holding alpha and power fixed. Sample size per arm roughly...
Sample size scales with 1/delta^2, so halving the MDE multiplies N by about four. MDE is a design input you choose up front, and small MDEs are expensive.
What this session covers
Power and sample size are taught across the A/B and statistics courses; we build the working core in statsmodels and leave the deeper theory and the tool UIs to the sources below.