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

Sequential testing, and why peeking quietly breaks your A/B test

A fixed-horizon p-value is only valid at one moment: the sample size you pre-committed to. Watch the dashboard every morning, stop the first day it dips under 0.05, and you have not run a 5% test - you have run a 20-30% one. This session shows the leak live, explains exactly why it happens, then builds three real fixes in numpy: always-valid inference, group-sequential boundaries, and a Thompson-sampling bandit. This is a platform-wide gap the course owns (Johari et al. 2017).

🟠 Advanced Builders · DA / DE / DS Python · numpy / scipy 45 min
0-3 · Setup 3-12 · See the leak 12-24 · Why + the fixes 24-42 · Build all three 42-45 · Wrap
Part 0

The most common way a trustworthy test lies

In b2 you computed that Lumen's flagship test - control 3.2% vs variant 3.6% - needs ~32,000 users per arm to detect that 0.4pp lift at alpha 0.05, power 0.80. That number is a promise: the p-value you read is only a valid 5% test at that N. But real dashboards update live, and the temptation is unbearable - you look every morning, and the day it crosses 0.05 you call it. That habit, "peeking," silently converts your 5% false-positive rate into 20-30%+. Today you see it happen on a simulator, understand precisely why, and build the three families of fix that let you monitor continuously and still be 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 Three notebooks on Lumen: (1) a numpy reproduction of the peeking inflation - many A/A tests, peeked daily, landing ~20-30% false positives vs ~5% at a single final look; (2) an always-valid confidence sequence / mSPRT-style boundary that holds the error even under continuous monitoring; (3) a Thompson-sampling bandit on a 2-arm Lumen test that minimizes regret - and a clear-eyed read on the tradeoff it makes against clean fixed inference.
See it before we explain it

Peek daily at an A/A test and watch alpha inflate try it

This simulator runs many A/A tests - two arms with no real difference at all, seeded from Lumen's 3.2% baseline and its ~32,000/arm horizon. The honest false-positive rate is 5% (the dashed line): that is what you get if you look exactly once, at the pre-committed N. The other number is what happens when you peek along the way and stop the first time p dips under 0.05. There is no real effect anywhere in this data - every "significant" result the peeking column reports is manufactured by looking.

Read the gap, not the numbers Rerun it a few times. The single-look column hovers near 5% - exactly the alpha you designed for. The peeking column sits far above it, often 20-30%. Same data, same null, same alpha. The only thing that changed is how many times you looked. The rest of this session explains why, and what to do instead.
Part 1 · the mechanism

Why fixed-horizon p-values only work once 5 min live

A classical p-value is calibrated for a single decision at a pre-committed sample size. Under the null, the running test statistic wanders like a random walk. Give it many chances to cross the 0.05 threshold - one per peek - and the probability that it crosses at least once grows far past 5%. This is a multiple-comparisons problem in disguise, except the comparisons are strung out over time instead of across metrics. You are not testing once; you are testing every day and reporting the best day.

p-value 1.0 0.0 day 1 final N (~32k/arm) alpha = 0.05 false alarm the ONE valid look Under the null, each path is a random walk. Any single day it is below 0.05 with prob ~5% - but "below on ANY day" compounds fast. Only the indigo endpoint at the pre-committed N carries the 5% guarantee. Reading a path's minimum is reading its luckiest moment.
🔍 Click to zoom - many null p-value paths; the red ones dip under 0.05 at least once, but only the pre-committed endpoint is valid
LivePeeking is multiple comparisons over time3 min

If you test once, P(false positive) = alpha = 0.05. If you test on k independent-ish occasions and reject the first time any crosses, the family-wise error rate balloons toward 1 as k grows - the same logic as testing many metrics in b4, just spread across calendar days. Continuous monitoring is the k = infinity limit.

  • Fixed-horizon validity is conditional. The p-value's 5% calibration assumes you commit to exactly one look, at exactly the planned N. Break that promise and the number no longer means what it says.
  • Stopping early on a win inflates the effect too. You are most likely to cross the line on a lucky upswing, so the effect you report at the stop is biased high - a double penalty (winner's curse).
  • It is not fixed by "just wait for significance". The problem is the rule (stop when p<0.05), not any single look.
Real world

A Lumen analyst ships the hero-first layout on day 4 because "it hit p=0.03 this morning." By day 11 the effect has drifted back to zero. Nothing went wrong with the layout - the day-4 dip was one of the red paths above. This is the single most common way a well-run experimentation program still ships noise.

Self-studyJust how bad does it get?2 min read

Johari, Pekelis, Walsh & Koomen ("Peeking at A/B Tests," KDD 2017, the Optimizely team) showed that continuous monitoring of a fixed-horizon test pushes the true Type I error to roughly 0.30 or higher - a nominal 5% test that lies one time in three. Their paper is why Optimizely and other platforms switched their default engines to always-valid inference. That paper, and Deng/Xu/Kohavi 2013 for b7, are the two primary sources no other platform course teaches well - the reason this course exists.

Part 2 · three families of fix

How to monitor continuously and stay honest 5 min live

There are three respectable answers. The first two keep clean inference (a valid p-value or interval at any stopping time); the third abandons fixed inference on purpose to chase a different goal - minimizing lost conversions. Know which problem you are solving before you pick.

critical z boundary high low look 1 look 5 (final N) Pocock - constant, spends alpha evenly O'Brien-Fleming - very strict early near nominal at the end Both spend a total alpha of 0.05 across the 5 pre-planned looks. Pocock makes early stopping easier; OBF protects the final-look power. Lan-DeMets alpha-spending generalizes both to any schedule of looks - you do not have to fix the look times in advance.
🔍 Click to zoom - Pocock (constant) vs O'Brien-Fleming (stringent early, near-nominal at the end) boundaries over interim looks
LiveFix 1 - always-valid / anytime-valid inference2 min

Design the statistic so it is valid at every sample size simultaneously, not just one. Then you can look as often as you like and stop whenever you want, and the guarantee holds.

  • mSPRT (mixture sequential probability ratio test) - the engine in Johari et al. 2017, deployed at Optimizely. It yields an always-valid p-value.
  • Confidence sequences - an interval that is simultaneously valid at all times; it starts wide and tightens as data arrives. Stop when it excludes zero, at any moment, with the coverage still honest.

The cost is a little conservatism: to be valid at all times, the boundary is wider than a single fixed-N test, so you pay modestly more data for the freedom to peek.

LiveFix 2 - group-sequential boundaries (from clinical trials)2 min

Pre-plan a handful of interim looks and spend your total alpha across them so the family-wise rate stays at 0.05.

BoundaryShapeBehaviour
Pocockconstant across looksspends alpha evenly - easier early stops, slightly less power at the end
O'Brien-Flemingvery high early, drops to near-nominal at the final lookhard to stop early, preserves near-full power at the planned N
Lan-DeMetsan alpha-spending functiongeneralizes both to any (even unplanned) look schedule
Self-studyFix 3 - Bayesian bandits (a different goal entirely)2 min read

Thompson sampling and other bandits shift traffic toward the arm that currently looks best, so fewer users see the loser. That minimizes regret - the conversions you forfeit while learning. But it is not solving the inference problem: a bandit does not hand you a clean fixed p-value or a crisp effect size, because the sampling probabilities and the data are now entangled.

Optimize vs decide Use a bandit when the goal is to earn during the test (short-lived promos, homepage modules where every impression costs money). Use always-valid or group-sequential inference when the goal is a trustworthy verdict you will ship org-wide. Do not use a bandit and then quote a p-value from it.
Build-along 1 of 3

Reproduce the peeking inflation in numpy ★ 7 min · everyone

Rebuild the widget's result yourself. Simulate many A/A tests on Lumen's 3.2% baseline - no real difference - accumulate users day by day, and count how often a daily peek finds "significance" versus a single look at the final N. You should land ~5% for the single look and ~20-30% for peeking.

Set up the null world: both arms convert at exactly 3.2%, ~2,000 users per arm per day, run to the ~32,000/arm horizon (~16 days).

demo1_peeking.py
import numpy as np
from scipy import stats
rng = np.random.default_rng(42)

P0 = 0.032           # Lumen baseline; SAME in both arms (a true A/A)
PER_DAY = 2_000      # users per arm per day (50/50 of ~4,000/day)
DAYS = 16            # ~32,000/arm final horizon
RUNS = 4_000

def one_trial():
    a_succ = a_n = b_succ = b_n = 0
    hit_peek = False
    for _ in range(DAYS):
        a_succ += rng.binomial(PER_DAY, P0); a_n += PER_DAY
        b_succ += rng.binomial(PER_DAY, P0); b_n += PER_DAY
        if two_prop_p(a_succ, a_n, b_succ, b_n) < 0.05:
            hit_peek = True                    # stop-early rule fired at least once
    p_final = two_prop_p(a_succ, a_n, b_succ, b_n)   # the ONE valid look
    return hit_peek, (p_final < 0.05)

A pooled two-proportion z-test as the daily statistic:

two_prop.py
def two_prop_p(x1, n1, x2, n2):
    p1, p2 = x1 / n1, x2 / n2
    p = (x1 + x2) / (n1 + n2)                  # pooled rate under H0
    se = np.sqrt(p * (1 - p) * (1 / n1 + 1 / n2))
    if se == 0: return 1.0
    z = (p1 - p2) / se
    return 2 * (1 - stats.norm.cdf(abs(z)))    # two-sided

Run the whole thing and compare the two false-positive rates:

run_peeking.py
res = np.array([one_trial() for _ in range(RUNS)])
peek_fpr  = res[:, 0].mean()      # "significant on ANY day"
final_fpr = res[:, 1].mean()      # significant at the pre-committed N only
print(f"peeking daily  -> false positives {peek_fpr:.1%}")   # ~20-30%
print(f"single final look -> false positives {final_fpr:.1%}") # ~5%

There is no real effect in any run - P0 is identical in both arms. Every "win" the peeking column reports is a false discovery you manufactured by looking. This is the widget, in ~30 lines you wrote.

Build-along 2 of 3

Build an always-valid boundary that survives peeking ★ 8 min · everyone

Now implement a simple mSPRT-style always-valid statistic and rerun the exact same daily-peeking loop. This time the false-positive rate stays near 5% even though you look every single day - the boundary was built to be valid at all times.

An mSPRT for two proportions mixes the likelihood ratio over a prior on the effect. The always-valid p-value is 1 / max running likelihood ratio. Here is a compact normal-approximation version on the difference in rates:

demo2_msprt.py
def msprt_pvalue(diff, n_eff, tau2, sigma2):
    # diff = observed rate difference; n_eff = harmonic effective N;
    # tau2 = mixing-prior variance on the true effect; sigma2 = per-obs variance
    v = sigma2 / n_eff                         # variance of the estimate
    # mixture likelihood ratio of H1 (effect ~ N(0, tau2)) vs H0 (effect = 0)
    lr = np.sqrt(v / (v + tau2)) * np.exp(
        (diff ** 2) / 2 * (tau2 / (v * (v + tau2))))
    return min(1.0, 1.0 / lr)                  # always-valid p-value

Drop it into the same peeking loop - stop the first day the always-valid p dips under 0.05 - and count false positives over many A/A runs:

run_msprt.py
def one_av_trial(tau2=0.001):
    a_succ = a_n = b_succ = b_n = 0
    fired = False
    for _ in range(DAYS):
        a_succ += rng.binomial(PER_DAY, P0); a_n += PER_DAY
        b_succ += rng.binomial(PER_DAY, P0); b_n += PER_DAY
        diff = a_succ / a_n - b_succ / b_n
        n_eff = 1 / (1 / a_n + 1 / b_n)
        sigma2 = P0 * (1 - P0)
        if msprt_pvalue(diff, n_eff, tau2, sigma2) < 0.05:
            fired = True
    return fired

av_fpr = np.mean([one_av_trial() for _ in range(RUNS)])
print(f"always-valid, peeked daily -> false positives {av_fpr:.1%}")  # ~5%

Same continuous monitoring, same null - but the error is controlled. The price is conservatism: on a real effect the always-valid test needs somewhat more data than a perfectly-timed fixed-N test would. That is the honest cost of the freedom to peek.

Real world

This is essentially the engine Optimizely shipped after Johari et al. 2017. Confidence sequences (Howard et al.) give the interval-flavoured version - a band around the effect that is valid at every timestep; stop when it clears zero. Either lets Lumen's team watch the live dashboard guilt-free.

Build-along 3 of 3

A Thompson-sampling bandit - and the tradeoff it makes ★ 8 min · everyone

Code a 2-arm Thompson-sampling bandit on a Lumen test where the variant genuinely converts better (3.2% vs 3.6%). Watch it steer traffic toward the winner, minimizing regret - then see why the same behaviour makes a clean fixed p-value impossible.

Beta-Bernoulli Thompson sampling: keep a Beta posterior per arm, sample from each, serve whichever sample is higher, update with the realized outcome.

demo3_thompson.py
TRUE = {"control": 0.032, "variant": 0.036}    # variant really is better
N = 32_000
alpha = {"control": 1.0, "variant": 1.0}       # Beta priors
beta  = {"control": 1.0, "variant": 1.0}
pulls = {"control": 0, "variant": 0}

for _ in range(N):
    draw = {a: rng.beta(alpha[a], beta[a]) for a in TRUE}
    arm = max(draw, key=draw.get)              # serve the best sample
    reward = rng.random() < TRUE[arm]          # did they convert?
    pulls[arm] += 1
    alpha[arm] += reward
    beta[arm]  += 1 - reward

Measure regret - conversions lost versus always serving the true best arm - and see how lopsided the traffic became:

regret.py
best = max(TRUE.values())
got  = sum(TRUE[a] * pulls[a] for a in TRUE)   # expected conversions earned
regret = best * N - got
print(pulls)                                   # most traffic went to 'variant'
print(f"expected regret over {N} users: {regret:.1f} conversions")
# compare: a 50/50 fixed A/B "wastes" ~half the traffic on the weaker arm

Now try to read a verdict. The arms have wildly unequal, outcome-dependent sample sizes, so a plain two-proportion z-test is not valid here - assignment is correlated with the data. The bandit optimized earnings, not inference.

Two goals, one choice Regret minimized (bandit) and clean p-value (fixed / always-valid) are different objectives. If Lumen must decide "ship hero-first everywhere, forever," run a fixed or always-valid test and get a trustworthy answer. If it is a two-week promo module, let a bandit harvest conversions and accept there is no crisp significance statement at the end.
Before Session 7

This week ◐ 40 min total

Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Peeking at a fixed-horizon test and stopping the first time p<0.05 inflates the Type I error because...

A fixed-horizon p-value is calibrated for one look at the pre-committed N. Many looks is multiple comparisons over time; under the null the running statistic crosses 0.05 on some day with probability that compounds toward 30%+ (Johari et al. 2017).

2 · How do Pocock and O'Brien-Fleming group-sequential boundaries differ?

Both spend a total alpha of 0.05 across pre-planned looks. Pocock's constant boundary makes early stopping easier; O'Brien-Fleming spends almost nothing early to protect final-look power. Lan-DeMets alpha-spending generalizes both.

3 · What does a Thompson-sampling bandit give you that a fixed A/B test does not - and at what cost?

Bandits optimize - they minimize the conversions lost while learning by shifting traffic. But assignment becomes outcome-dependent, so you cannot read a valid fixed p-value from the result. Optimize vs decide: pick the goal first.

Source material

What this session covers

Sequential testing and the peeking problem are a genuine gap on every major platform (DeepLearning.AI, Coursera, 365, LinkedIn, Udemy). This session owns it, taught from the primary source - Johari, Pekelis, Walsh & Koomen, "Peeking at A/B Tests," KDD 2017. The bandit depth lives with Lazy Programmer's Bayesian A/B Udemy course.

Fixed-horizon inflation / the peeking problemsimulator + numpy reproduction - Parts 0-1, build-along 1
Always-valid / anytime-valid inference (mSPRT, confidence sequences)Johari et al. 2017 / Optimizely - Fix 1, build-along 2
Group-sequential boundaries (Pocock, O'Brien-Fleming, Lan-DeMets)from clinical trials - Fix 2, SVG + homework
Bandits / Thompson sampling and the regret-vs-inference tradeoff2-arm Lumen bandit - Fix 3, build-along 3
Bayesian A/B in depth (priors, decision theory)Lazy Programmer (Udemy) owns the depth - concept-level here

Builder Session 6 cheat sheet · pin this

The peeking problemFixed-horizon p-values are valid ONLY at the pre-committed N. Peek + stop early and Type I error inflates to ~30%+ (Johari 2017).
Why it happensEach look is another chance to cross 0.05 - multiple comparisons over time. The k=∞ limit is continuous monitoring.
Always-valid inferencemSPRT + confidence sequences are valid at every N. Peek freely; cost is mild conservatism (a wider boundary).
Group-sequentialPocock = constant boundary, spends alpha evenly. O'Brien-Fleming = strict early, near-nominal at the end. Lan-DeMets = alpha-spending generalizes both.
BanditsThompson sampling minimizes regret by shifting traffic - it does NOT give a clean fixed p-value. Optimize vs decide.
The rule to keepPre-commit your N and look once, OR use an always-valid / group-sequential method. Never read a path's luckiest day.