Peeking and Alpha Inflation
The single most common and damaging mistake in online experimentation is peeking: checking results daily, stopping when p < 0.05, and calling it a win. This is not a minor issue — it catastrophically inflates the false positive rate.
To understand why, consider repeatedly testing as data arrives. If you check at every new sample until $n = 10{,}000$ using a fixed threshold $\alpha = 0.05$, the true probability of ever seeing p < 0.05 under $H_0$ (no effect) can exceed 30 %. You are no longer running a 5 % test.
import numpy as np
from scipy import stats
rng = np.random.default_rng(123)
N_SIMS = 10_000
N_MAX = 5_000
CHECK_EVERY = 50
fp_peek = 0 # false positive with peeking
fp_fixed = 0 # false positive with fixed horizon
for _ in range(N_SIMS):
# Under H0: no true effect
ctrl = rng.normal(0, 1, N_MAX)
trt = rng.normal(0, 1, N_MAX)
ever_significant = False
for n in range(CHECK_EVERY, N_MAX+1, CHECK_EVERY):
_, p = stats.ttest_ind(trt[:n], ctrl[:n])
if p < 0.05:
ever_significant = True
break
if ever_significant:
fp_peek += 1
_, p_final = stats.ttest_ind(trt, ctrl)
if p_final < 0.05:
fp_fixed += 1
print(f'Fixed-horizon false positive rate: {fp_fixed/N_SIMS:.3f} (target 0.05)')
print(f'Peeking false positive rate: {fp_peek/N_SIMS:.3f} (inflated!)')
Sequential Testing: Valid Early Stopping
If business requirements demand the ability to stop early, use a sequential testing procedure designed for it. Two modern approaches are widely deployed:
Alpha-Spending Functions (O'Brien-Fleming)
Spend the alpha budget across $K$ pre-planned interim looks. The O'Brien-Fleming boundary is very conservative early and approximately $z_{\alpha/2}$ at the final look:
$$z_k^* = z_{\alpha/2} \sqrt{K/k}$$
This gives valid Type I error control with up to $K$ peeks.
E-values and the mSPRT
The mixture Sequential Probability Ratio Test (mSPRT) gives an e-value — a nonnegative random variable with $\mathbb{E}[e] \leq 1$ under $H_0$ — that can be monitored continuously without inflating false positives. You reject when $e \geq 1/\alpha$. E-values compose multiplicatively and are robust to optional stopping:
$$e_n = \prod_{i=1}^{n} \frac{f_\theta(x_i)}{f_0(x_i)} \cdot \pi(\theta)$$
where $\pi(\theta)$ is a mixing prior over effect sizes.
In practice, Optimizely, Netflix, and many others have adopted variants of mSPRT or Bayesian sequential methods. The key point: you cannot use a fixed-horizon p-value for early stopping without a correction.
Sample Ratio Mismatch (SRM)
A Sample Ratio Mismatch occurs when the observed split between variants differs significantly from the intended split. For a 50/50 experiment receiving $N$ total users, a chi-squared test checks:
$$\chi^2 = \sum_{k} \frac{(O_k - E_k)^2}{E_k}$$
from scipy.stats import chisquare
# Expected: 50/50 split over 100,000 users
observed = [51_200, 48_800] # control, treatment
expected = [50_000, 50_000]
chi2, p = chisquare(f_obs=observed, f_exp=expected)
print(f'chi2={chi2:.2f}, p={p:.6f}')
if p < 0.001:
print('SRM DETECTED — results should not be trusted.')
SRM is caused by:
- Triggering bugs: users assigned to treatment but not exposed to the experiment (e.g., bot filtering applied only to control).
- Hash collisions: poor randomization hash functions that cluster users.
- Redirect latency: treatment involves a redirect; some users abort before the redirect completes and are not logged.
- Survivorship bias: users who experience errors in the treatment group churn and are excluded from analysis.
Always run SRM checks before interpreting any results. An SRM p-value below 0.001 is grounds for invalidating the experiment.
Novelty and Primacy Effects
Novelty effect: users engage more with a new UI simply because it is new, not because it is better. This inflates short-term metrics. Mitigation: run the experiment long enough (typically 2-4 weeks) for novelty to decay, or analyze by user tenure (new vs returning users respond differently).
Primacy effect (the inverse): users initially resist a change, but adoption grows over time. A short experiment would miss the long-run benefit.
Both effects mean that very short experiments — even if statistically powered — can produce misleading estimates. The holdback experiment (keeping a small percentage on control permanently post-launch) is the gold standard for measuring long-run effects.
Network Effects and SUTVA Violations
In social products, treating one user can affect their connections (network effects). Standard A/B testing assumes SUTVA: the outcome for user $i$ depends only on $i$'s own assignment, not on others'. When this is violated:
- Cluster randomization: randomize at the level of the network cluster (graph community, geographic region) to minimize spillover.
- Ego-network randomization: randomize based on whether the user's social graph is predominantly in treatment or control.
- Switchback experiments (temporal holdouts): alternate treatment and control over time periods in the same market — useful for marketplace experiments (Uber, Lyft) where supply and demand interact.
Code Examples
O'Brien-Fleming Sequential Boundaries
Computes and plots the O'Brien-Fleming stopping boundary for 5 planned interim analyses.
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import norm
K = 5 # number of planned looks
alpha = 0.05
z_final = norm.ppf(1 - alpha/2) # ~1.96
# Information fractions at each look
info_fractions = np.array([k/K for k in range(1, K+1)])
# O'Brien-Fleming boundary: z*(k) = z_alpha/2 * sqrt(K/k)
obf_boundaries = z_final * np.sqrt(K / np.arange(1, K+1))
print('Look | Info fraction | OBF z-boundary | Nominal alpha')
print('-' * 55)
for k, (info, z_b) in enumerate(zip(info_fractions, obf_boundaries), 1):
alpha_k = 2 * norm.sf(z_b)
print(f' {k} | {info:.2f} | {z_b:.4f} | {alpha_k:.6f}')
fig, ax = plt.subplots(figsize=(7, 4))
ax.step(info_fractions, obf_boundaries, where='post', lw=2, label='OBF upper boundary')
ax.step(info_fractions, -obf_boundaries, where='post', lw=2, label='OBF lower boundary')
ax.axhline(1.96, color='red', linestyle='--', label='Fixed-horizon z=1.96')
ax.set_xlabel('Information fraction')
ax.set_ylabel('z-statistic boundary')
ax.set_title("O'Brien-Fleming Sequential Boundaries (K=5 looks)")
ax.legend()
plt.tight_layout()
plt.savefig('obf_boundary.png', dpi=150)
plt.show()
OutputLook | Info fraction | OBF z-boundary | Nominal alpha
-------------------------------------------------------
1 | 0.20 | 4.3799 | 0.000012
2 | 0.40 | 3.0971 | 0.001952
3 | 0.60 | 2.5281 | 0.011475
4 | 0.80 | 2.1899 | 0.028535
5 | 1.00 | 1.9600 | 0.050000
SRM Simulation: What a Bad Hash Function Looks Like
Simulates a biased randomization hash that preferentially assigns low user IDs to treatment, causing SRM.
import numpy as np
from scipy.stats import chisquare
rng = np.random.default_rng(5)
N = 200_000
user_ids = np.arange(N)
# Good hash: uniform assignment
good_assignment = (user_ids % 2 == 0).astype(int) # exactly 50/50
# Bad hash: low user IDs (older, more active users) skew to treatment
bias = np.exp(-user_ids / 50_000) # older users more likely treated
bad_assignment = (rng.random(N) < (0.5 + 0.1 * bias)).astype(int)
for label, assign in [('Good hash', good_assignment), ('Bad hash', bad_assignment)]:
n_ctrl = (assign == 0).sum()
n_trt = (assign == 1).sum()
chi2, p = chisquare([n_ctrl, n_trt], [N/2, N/2])
flag = ' <- SRM DETECTED' if p < 0.001 else ''
print(f'{label}: ctrl={n_ctrl:,}, trt={n_trt:,}, chi2={chi2:.1f}, p={p:.2e}{flag}')
OutputGood hash: ctrl=100000, trt=100000, chi2=0.0, p=1.00e+00
Bad hash: ctrl=93847, trt=106153, chi2=1518.3, p=0.00e+00 <- SRM DETECTED
Novelty Effect: Segmenting by User Tenure
Simulates an experiment where new users show a novelty lift but returning users show no effect, and demonstrates how to detect this by tenure segmentation.
import numpy as np
from scipy import stats
import pandas as pd
rng = np.random.default_rng(21)
n = 20_000
# 30% new users, 70% returning
is_new = rng.binomial(1, 0.30, n)
variant = rng.integers(0, 2, n)
# New users have novelty lift (+5pp CTR); returning users have no true effect
base_ctr = np.where(is_new, 0.08, 0.05)
novelty_lift = np.where((variant == 1) & (is_new == 1), 0.05, 0.0)
ctr = rng.binomial(1, np.clip(base_ctr + novelty_lift, 0, 1), n)
df = pd.DataFrame({'is_new': is_new, 'variant': variant, 'ctr': ctr})
for segment, label in [(df, 'All users'),
(df[df.is_new==1], 'New users'),
(df[df.is_new==0], 'Returning users')]:
ctrl = segment.loc[segment.variant==0, 'ctr']
trt = segment.loc[segment.variant==1, 'ctr']
t, p = stats.ttest_ind(trt, ctrl)
lift = trt.mean() - ctrl.mean()
print(f'{label:20s}: lift={lift*100:+.2f}pp, p={p:.4f}')
OutputAll users : lift=+1.52pp, p=0.0000
New users : lift=+4.98pp, p=0.0000
Returning users : lift=+0.02pp, p=0.8134