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

Power and sample size, or how big does this test actually need to be?

Session 1 gave you the estimand. Now the first real design decision: how many users before you can trust the answer? We make Type I and Type II error concrete, define power as the probability of catching a real effect, treat the minimum detectable effect as an input you choose rather than a result you discover, and then compute the number in statsmodels. By the end you will have rebuilt the planner from b1 by hand and landed on ~32,000 users per arm for Lumen's flagship test - with every assumption stated out loud.

🟢 Core Builders · DA / DE / DS Python · statsmodels / scipy 45 min
0-3 · Setup 3-16 · Errors and power 16-40 · Compute N in Python 40-45 · Wrap
Part 0

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.

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 small planner of your own: functions that take a baseline rate, a minimum detectable effect, alpha, and power, and return (1) the sample size per arm using 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.
Part 1 · the two mistakes

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.

The truth (unknown to you) → No real effect Real effect exists You call it "significant" You call it "not significant" Type I error false positive rate = alpha (0.05) ship a dud Correct - detected true positive rate = power = 1 - beta the win you wanted Correct - no call true negative rate = 1 - alpha Type II error false negative rate = beta (0.20) miss a real win Power is the green cell: given a real effect, how often you catch it. More sample lifts power without touching alpha.
🔍 Click to zoom - truth vs decision: alpha guards false positives, power fights false negatives
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.
"Not significant" does not mean "no effect" A non-significant result is the absence of evidence, not evidence of absence. It usually means the effect was smaller than your test could resolve, or the test was underpowered. Report the confidence interval and the power you had - never the phrase "the variant made no difference".
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.

Part 2 · the design input

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 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 tool this session is about

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.

Build-along 1 of 3

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:

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

rule_of_16.py
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
Real world

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.

Build-along 2 of 3

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:

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

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

power 0.80 0.0 sample size / arm → power = 0.80 ~32,000 Steep then flat: the 0.80 convention sits near the knee - most of the detection for a sane amount of traffic.
🔍 Click to zoom - power rises with sample size, then flattens; 0.80 sits near the knee
Build-along 3 of 3

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:

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

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

Round days up, and pad for weekly seasonality Always round runtime up, and prefer to run in whole weeks when your traffic swings by day of week (Lumen's does - weekends convert differently). A 16-day plan is usually run as a full 2-3 weeks so every weekday appears an equal number of times.
Before Session 3

This week ◐ 40 min total

Check yourself

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.

Source material

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.

Type I / II error, alpha, beta, power = 1 - betaPart 1 + the 2x2 truth-vs-decision figure
MDE as a design input, rule of 16, two-proportion NPart 2 + build-along 1 (~32,000/arm, stated assumptions)
Power curves and N-to-runtimebuild-alongs 2-3 (statsmodels + matplotlib planner)
365 Data Science - A/B Testing in Python (Kuznetsova)most on-point; graded work stays on platform
LinkedIn - Data Science of Experimental Design (Wahi)G*Power UI for sample size - concept-level here
Udacity A/B Testing (Google, ud257)the canonical online-experiment course - videos on platform

Builder Session 2 cheat sheet · pin this

Two errorsType I = false positive (rate alpha, 0.05). Type II = false negative (rate beta). You trade both against sample size.
PowerPower = 1 - beta, conventionally 0.80. The chance you catch a real effect. More sample raises it without touching alpha.
MDE is an inputThe smallest lift worth detecting - you choose it with the business. N scales with 1/delta^2, so small MDEs cost a lot.
Rule of 16n per arm ≈ 16·p(1-p)/delta^2 at alpha 0.05, power 0.80, two-sided. A fast sanity check on statsmodels.
Lumen's number3.2% → 3.6% (0.4pp, +12.5%): ~32,000/arm, ~64,000 total, ~16-17 days at 4,000/day. Always quote the assumptions.
Not sig ≠ no effectA non-significant result is absence of evidence, not evidence of absence. Report the CI and the power, never "no difference".