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.
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.
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.
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.
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.
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.
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.
| Boundary | Shape | Behaviour |
|---|---|---|
| Pocock | constant across looks | spends alpha evenly - easier early stops, slightly less power at the end |
| O'Brien-Fleming | very high early, drops to near-nominal at the final look | hard to stop early, preserves near-full power at the planned N |
| Lan-DeMets | an alpha-spending function | generalizes 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.
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).
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:
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:
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 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:
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:
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.
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.
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.
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:
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.
This week ◐ 40 min total
- Plot false-positive rate vs number of peeks. Take demo 1 and vary the peeking cadence - look once, twice, weekly, daily, hourly. Plot the false-positive rate against the number of looks and watch it climb from 5% toward 30%+.
- Try a group-sequential design. Pick 5 pre-planned looks and apply O'Brien-Fleming-style boundaries (spend little alpha early, most at the end). Confirm the family-wise error stays at 5% while still allowing an early stop on a strong effect.
- Measure the always-valid tax. Rerun demo 2 with a genuine 3.2% vs 3.6% effect and record how many users the always-valid test needs to detect it, versus the ~32,000/arm fixed design. Quantify the conservatism.
- Optional: add a third arm to the Thompson bandit and plot cumulative regret over time for bandit vs 50/50 vs a group-sequential test - three curves, one picture of the tradeoff.
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.
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.