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

Randomization and trust, or the plumbing that makes a result believable

A well-powered test is worthless if the randomization is broken. Session 3 is about the machinery that protects internal validity: pick the right unit to randomize, respect SUTVA, stratify so the arms start balanced, prove the pipeline with an A/A test, and catch a sample ratio mismatch before you read a single metric. Every check here is a few lines of numpy and scipy on Lumen data - and each one has stopped a real experiment from lying to a real team.

🟡 Core Builders · DA / DE / DS Python · numpy / scipy 45 min
0-3 · Setup 3-16 · The four safeguards 16-40 · Build the guards in Python 40-45 · Wrap
Part 0

Why a "significant" result can still be garbage

In b1 randomization rescued a clean answer; in b2 you sized the test. But between the design and the dashboard sits a pile of plumbing that quietly decides whether your randomization actually held. Did you split by user or by session? Did one arm accidentally get 52% of the traffic? Does the pipeline even produce honest p-values when there is truly nothing to find? Session 3 makes each of these a runnable check on Lumen's assignment log, so that when a test does come back significant, you have earned the right to believe it.

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 guards you can drop into any experiment pipeline: (1) a stratified assignment routine in numpy that balances arms by device, (2) an A/A test harness that proves your pipeline emits uniform p-values and ~5% false positives - exactly alpha, working as designed, and (3) an SRM check with scipy.stats.chisquare that halts analysis when allocation drifts from intended. Run these before you read a metric, every single time.
Part 1 · what you randomize

Randomize the unit you analyze 6 min live

The single most common validity bug is randomizing by the wrong unit. If you analyze results per user but assign per session, the same person can land in both arms across visits - contaminating the comparison and breaking the independence your test assumes. The rule is blunt: randomize by the unit you analyze by. For a checkout-conversion test where a shopper visits several times, that unit is the user, not the session.

Split by session (leaks) User: Mia visit 1 visit 2 Variant arm Control arm same user in BOTH arms - comparison contaminated Split by user (clean) User: Mia visit 1 visit 2 Variant arm 🔒 sticky user pinned to one arm across all visits Hash the user id to a bucket so assignment is deterministic and sticky - the same id always lands in the same arm.
🔍 Click to zoom - session-level splitting leaks a user into both arms; user-level splitting pins them
LiveSUTVA - the assumption that quietly breaks3 min

SUTVA (stable unit treatment value assumption, Rubin 1980) has two halves, and both matter for online tests:

  • No interference - one unit's treatment does not affect another unit's outcome. Referral features, shared inventory, and social feeds break this: a treated user can change an untreated user's behavior.
  • No hidden versions - "the treatment" is one well-defined thing. If the new layout renders differently on old browsers, you are secretly running several treatments and averaging them.
Real world

Lumen tests a "refer a friend" banner. Treated users invite control users, whose conversion then rises - so the control arm is no longer a clean counterfactual, and the measured effect is diluted. When interference is structural, you switch to cluster randomization (assign whole regions or social clusters) rather than individual users. We build the geo version of this in b8.

Self-studyInternal vs external validity - two different jobs2 min read

Keep two ideas separate. Randomization (how you assign the units you have) buys internal validity - the effect you measure is caused by the treatment, not by confounding. Random sampling (how you drew the units from the population) buys external validity - the effect generalizes beyond your sample. An A/B test on logged-in Lumen shoppers can be internally airtight and still not generalize to first-time anonymous visitors. Both matter; they are not the same guarantee.

Part 2 · the three guards

Stratify, prove, and monitor 4 min live

Plain randomization balances arms in expectation, but any single draw can still be lopsided on an important covariate. Three practices tighten the guarantee: stratified (block) randomization balances within each stratum so arms match on device from the start; the A/A test proves your whole pipeline is honest before you trust an A/B; and the SRM check watches the allocation itself for drift while the test runs.

LiveWhy SRM alarms at p < 0.001, not 0.053 min

A sample ratio mismatch is when the observed split deviates from what you intended (a 50/50 design that lands 52/48 on large N). It is a symptom, not a metric - a signal that assignment, logging, or filtering is broken, so the arms are no longer comparable and no downstream result can be trusted.

  • Test it with a chi-square goodness-of-fit against the intended ratio.
  • The alarm threshold is ~p < 0.001, deliberately far stricter than the 0.05 you would use for a metric - SRM is a rare-but-catastrophic bug, and you do not want a false alarm every twentieth healthy test.
  • When SRM fires: stop, do not read the results, and debug the pipeline. Common causes are a redirect that drops one arm, a bot filter applied unevenly, or a broken hash bucket.
Self-studyWhat a clean A/A test looks like2 min read

An A/A test runs two identical arms - no real difference - through the exact pipeline you will use for the A/B. If the plumbing is honest, the p-values across many repeats are uniformly distributed on 0 to 1, and about 5% fall below 0.05 purely by chance. That 5% is not a bug - it is alpha doing exactly its job. If instead 15% come back "significant", or the p-values pile up near zero, your pipeline has a leak (often a randomization or logging fault) and any A/B built on it is suspect.

Build-along 1 of 3

Assign 20,000 users, then stratify by device ★ 8 min · everyone

Start with plain random assignment of 20,000 Lumen users in numpy, then upgrade to stratified randomization so the arms balance on device (mobile / desktop / tablet). Verify the balance both ways so you can feel what stratification buys.

Build the user frame with a device mix, then do a plain coin-flip assignment:

demo1_assign.py
import numpy as np, pandas as pd
rng = np.random.default_rng(7)

N = 20_000
device = rng.choice(["mobile", "desktop", "tablet"],
                    size=N, p=[0.62, 0.30, 0.08])
users = pd.DataFrame({"user_id": np.arange(N), "device": device})

# plain randomization: independent coin flip per user
users["arm_plain"] = np.where(rng.random(N) < 0.5, "variant", "control")

Now stratify: split within each device group so the arms match on device by construction:

demo1_stratify.py
def stratified_assign(frame, strat_col, p=0.5, seed=7):
    rng = np.random.default_rng(seed)
    arm = np.empty(len(frame), dtype=object)
    for _, idx in frame.groupby(strat_col).groups.items():
        idx = np.array(idx)
        rng.shuffle(idx)                      # shuffle within stratum
        cut = int(round(len(idx) * p))
        arm[idx[:cut]] = "variant"
        arm[idx[cut:]] = "control"
    return arm

users["arm_strat"] = stratified_assign(users, "device")

Verify balance. Compare the device mix across arms under each scheme - stratification pins it near-perfectly:

demo1_check_balance.py
for col in ["arm_plain", "arm_strat"]:
    tab = (users.groupby(col)["device"]
                .value_counts(normalize=True)
                .unstack().round(3))
    print(col, "\n", tab, "\n")
# arm_plain: device shares wobble a little between arms
# arm_strat: device shares are essentially identical across arms
Stratify on what predicts the outcome Stratification only helps when the covariate is related to conversion. Device is a fair bet for Lumen (mobile converts differently). Stratifying on a variable unrelated to the outcome costs complexity and buys nothing - pick your strata deliberately.
Build-along 2 of 3

Run an A/A test and read the p-value histogram ★ 8 min · everyone

Prove the pipeline is honest. Simulate two arms with the same true rate (3.2%), run the two-proportion test many times, and confirm the p-values are ~uniform and ~5% land below 0.05. That 5% is alpha, working exactly as designed.

Write one A/A trial: draw both arms from the same rate, run a two-proportion z-test, return the p-value:

demo2_aa_trial.py
import numpy as np
from statsmodels.stats.proportion import proportions_ztest

rng = np.random.default_rng(0)
BASE = 0.032          # identical rate in BOTH arms - no real effect
N_ARM = 20_000

def aa_trial():
    a = rng.random(N_ARM) < BASE     # arm A conversions
    b = rng.random(N_ARM) < BASE     # arm B conversions, same rate
    count = np.array([a.sum(), b.sum()])
    nobs  = np.array([N_ARM, N_ARM])
    _, p = proportions_ztest(count, nobs)
    return p

Repeat it thousands of times and measure the false-positive rate - it should sit right around alpha:

demo2_aa_run.py
pvals = np.array([aa_trial() for _ in range(5_000)])
false_pos = (pvals < 0.05).mean()
print(f"share of p < 0.05 = {false_pos:.3f}")   # ~0.05, as designed

Plot the histogram - a flat bar chart is the signature of a healthy pipeline:

demo2_aa_plot.py
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(7, 4))
ax.hist(pvals, bins=20, range=(0, 1), color="#4F46E5", edgecolor="white")
ax.axhline(len(pvals) / 20, color="#84CC16", linestyle="--",
           label="uniform expectation")
ax.set_xlabel("p-value under A/A (no true effect)")
ax.set_ylabel("count")
ax.set_title("A/A p-values are ~uniform - the pipeline is honest")
ax.legend(); fig.tight_layout()
# a flat histogram = trustworthy plumbing; a spike near 0 = a leak
Real world

Teams run a scheduled A/A on live traffic every quarter. If it ever comes back with 12% of metrics "significant", someone changed the assignment service, a caching layer, or a logging join - and every A/B since is suspect. The A/A is the smoke detector you hope never goes off.

Build-along 3 of 3

Catch a sample ratio mismatch with chi-square ★ 6 min · everyone

The last guard. Feed the canonical Lumen SRM example - 20,000 assignments landing 10,420 / 9,580 - to a chi-square goodness-of-fit test, watch the p-value collapse toward zero, and compare it to a clean 50/50. Then write the guard that halts analysis when SRM fires.

Run the chi-square test against the intended 50/50 split for both a broken and a clean allocation:

demo3_srm_check.py
import numpy as np
from scipy.stats import chisquare

def srm_pvalue(observed, expected_ratio=(0.5, 0.5)):
    observed = np.array(observed, dtype=float)
    total = observed.sum()
    expected = total * np.array(expected_ratio)
    # goodness-of-fit: observed counts vs intended split
    _, p = chisquare(f_obs=observed, f_exp=expected)
    return p

srm   = srm_pvalue([10_420, 9_580])   # the canon broken split
clean = srm_pvalue([10_005, 9_995])   # a healthy 50/50
print(f"SRM example p = {srm:.2e}")   # ~1e-8, catastrophic
print(f"clean split p = {clean:.3f}") # large, no problem

Wrap it in a guard that stops the analysis before anyone reads a metric - alarm at the strict p < 0.001:

demo3_guard.py
SRM_ALARM = 1e-3   # strict, NOT 0.05 - SRM is rare but catastrophic

def guard_or_stop(observed):
    p = srm_pvalue(observed)
    if p < SRM_ALARM:
        raise RuntimeError(
            f"SRM detected (p={p:.2e}). Split is {observed}, "
            "expected ~50/50. STOP - debug the pipeline, "
            "do not read the results.")
    print(f"allocation OK (p={p:.3f}) - safe to analyze")

guard_or_stop([10_005, 9_995])   # passes
guard_or_stop([10_420, 9_580])   # raises -> analysis halts

Wire guard_or_stop as the first line of your analysis script. A test with SRM is not a weaker result - it is a non-result. Reading its conversion lift is like trusting a scale you know is miscalibrated.

Intended split (50/50) control10,000 variant10,000 chi-square p large - allocation healthy, proceed Observed split (SRM) control10,420 variant9,580 🚩 STOP chi-square p ≈ 1e-8 < 0.001 - SRM, do not read results A 4.2-point gap on 20,000 units is astronomically unlikely under a true 50/50 - the split itself is the bug report.
🔍 Click to zoom - intended 50/50 vs an observed SRM split, with the chi-square verdict
Before Session 4

This week ◐ 40 min total

Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · You analyze conversion per user but assign treatment per session. The core problem is...

Randomize by the unit you analyze by. Session-level assignment lets one user land in both arms across visits, breaking independence and mixing the arms. For a user-level metric, assign per user with a sticky hash.

2 · A clean A/A test on your pipeline should produce...

With no true effect, an honest pipeline emits uniform p-values, so ~5% fall under 0.05 - that is alpha working as designed. A spike near zero or a much higher rate means the plumbing has a leak.

3 · Your 50/50 test logged 10,420 / 9,580 and the chi-square p is ~1e-8. You should...

That is a sample ratio mismatch. A p below the ~0.001 alarm threshold means allocation deviated from intended, so the arms are not comparable and no downstream metric can be trusted. Stop and fix the assignment or logging.

Source material

What this session covers

Validity threats and trust checks are scattered across the A/B and design courses; we build the working guards here and point to the sources for the fuller taxonomy.

Unit of randomization + SUTVA (interference, hidden versions)Part 1 + the user-vs-session figure
Stratified / block randomizationbuild-along 1 (numpy, balance by device)
A/A test + SRM chi-square (alarm ~p < 0.001)build-alongs 2-3, canon 10,420 / 9,580 example
Udemy - Ultimate AB Testing (Dan Lee)full SRM / SUTVA / novelty validity-threat taxonomy
LinkedIn - Data Science of Experimental Design (Wahi)design + SRM at concept level - videos on platform
Kohavi et al. - Trustworthy Online Controlled Experimentsthe definitive treatment of trust and pitfalls

Builder Session 3 cheat sheet · pin this

Unit of randomizationRandomize by the unit you analyze by. User-level metric → assign per user with a sticky hash, never per session.
SUTVANo interference (one unit's treatment can't affect another's outcome) + no hidden treatment versions. Referrals and shared feeds break it.
Stratified randomizationSplit within strata (e.g. device) so arms balance from the start. Only stratify on covariates tied to the outcome.
A/A testTwo identical arms through the real pipeline → uniform p-values, ~5% below 0.05. That 5% is alpha, not a bug.
SRM checkchi-square goodness-of-fit vs intended split. Alarm at ~p < 0.001 (not 0.05). Canon: 10,420 / 9,580 → p ≈ 1e-8.
On SRM: STOPSRM makes the arms non-comparable. It is a non-result - do not read metrics, debug assignment / logging / filters first.