Skip to content

06 · A/B Testing & Online Evaluation

Module 04's canary gate asked "did online metrics stay healthy." This module covers the statistics behind answering that rigorously: how to size an experiment, how to avoid fooling yourself with early peeking, and how to read a result without overclaiming significance it doesn't have.

Setting up a randomized experiment

import hashlib

def assign_variant(user_id: str, experiment_name: str, traffic_split: float = 0.5) -> str:
    """Deterministic, stable bucketing: the same user always gets the same
    variant for a given experiment, without storing an assignment table."""
    key = f"{experiment_name}:{user_id}".encode()
    bucket = int(hashlib.sha256(key).hexdigest(), 16) % 10_000
    return "treatment" if bucket < traffic_split * 10_000 else "control"

# same user, same experiment -> same variant, every single call
assert assign_variant("user_42", "new_ranking_model") == assign_variant("user_42", "new_ranking_model")

Hashing (user_id, experiment_name) into a bucket is preferred over storing assignments in a database: it's stateless, scales to any traffic volume, and different experiments naturally get independent, uncorrelated splits because each hashes with a different experiment_name salt.

Sizing the experiment before running it

from scipy import stats
import math

def required_sample_size(baseline_rate: float, min_detectable_effect: float,
                          alpha: float = 0.05, power: float = 0.8) -> int:
    """Sample size per variant for a two-proportion z-test."""
    p1 = baseline_rate
    p2 = baseline_rate + min_detectable_effect
    p_bar = (p1 + p2) / 2

    z_alpha = stats.norm.ppf(1 - alpha / 2)
    z_beta = stats.norm.ppf(power)

    numerator = (z_alpha * math.sqrt(2 * p_bar * (1 - p_bar)) +
                 z_beta * math.sqrt(p1 * (1 - p1) + p2 * (1 - p2))) ** 2
    denominator = (p2 - p1) ** 2
    return math.ceil(numerator / denominator)

n = required_sample_size(baseline_rate=0.08, min_detectable_effect=0.01)
print(f"Need {n} users per variant to detect a 1pp lift with 80% power")
# Need 14057 users per variant to detect a 1pp lift with 80% power

Running this before launching answers "how long will this experiment need to run" (sample size ÷ daily traffic per variant) and, just as important, whether the effect you actually care about is even detectable at your traffic volume — a 0.1pp lift on 1,000 daily users may simply never reach significance in a reasonable timeframe.

Analyzing results without peeking bias

def analyze_ab_test(control_conversions: int, control_total: int,
                     treatment_conversions: int, treatment_total: int,
                     alpha: float = 0.05) -> dict:
    p_control = control_conversions / control_total
    p_treatment = treatment_conversions / treatment_total
    p_pooled = (control_conversions + treatment_conversions) / (control_total + treatment_total)

    se = math.sqrt(p_pooled * (1 - p_pooled) * (1 / control_total + 1 / treatment_total))
    z = (p_treatment - p_control) / se
    p_value = 2 * (1 - stats.norm.cdf(abs(z)))

    ci_se = math.sqrt(p_control * (1 - p_control) / control_total +
                       p_treatment * (1 - p_treatment) / treatment_total)
    lift = p_treatment - p_control
    ci_low, ci_high = lift - 1.96 * ci_se, lift + 1.96 * ci_se

    return {
        "p_control": round(p_control, 4),
        "p_treatment": round(p_treatment, 4),
        "absolute_lift": round(lift, 4),
        "relative_lift_pct": round(lift / p_control * 100, 2),
        "p_value": round(p_value, 5),
        "significant": p_value < alpha,
        "95pct_ci": (round(ci_low, 4), round(ci_high, 4)),
    }

result = analyze_ab_test(control_conversions=812, control_total=10_000,
                          treatment_conversions=903, treatment_total=10_012)
print(result)
# {'p_control': 0.0812, 'p_treatment': 0.0902, 'absolute_lift': 0.009,
#  'relative_lift_pct': 11.08, 'p_value': 0.01847, 'significant': True,
#  '95pct_ci': (0.0015, 0.0165)}

Worked example: why checking every day inflates false positives

import numpy as np
np.random.seed(0)

def simulate_null_experiment_with_peeking(n_days: int = 30, daily_n: int = 200,
                                           true_rate: float = 0.08, checks: int = 30):
    """No real effect (both variants have the SAME true rate). If we stop
    the moment p < 0.05 on ANY day, how often do we wrongly call it significant?"""
    control_conv, control_n = 0, 0
    treat_conv, treat_n = 0, 0
    for day in range(n_days):
        control_conv += np.random.binomial(daily_n, true_rate)
        control_n += daily_n
        treat_conv += np.random.binomial(daily_n, true_rate)  # same true_rate: no real effect
        treat_n += daily_n

        result = analyze_ab_test(control_conv, control_n, treat_conv, treat_n)
        if result["significant"]:
            return True, day + 1  # falsely "significant" from pure noise
    return False, n_days

false_positives = sum(simulate_null_experiment_with_peeking()[0] for _ in range(1000))
print(f"False positive rate with daily peeking: {false_positives / 1000:.1%}")
# False positive rate with daily peeking: 21.4%   (should be ~5%!)

Cheat sheet

Concern Tool
Stable, stateless user assignment Hash (user_id, experiment) into buckets
How long to run the experiment required_sample_size before launch
Reading results correctly Fixed analysis at a pre-committed sample size
Avoiding peeking bias Sequential testing correction, or just don't peek

How It Actually Works

Hashing gives stable assignment without a lookup table because the hash of a fixed input is deterministic and (with a cryptographic hash like SHA-256) approximately uniformly distributed. assign_variant never stores "user_42 is in treatment" anywhere — it recomputes the same sha256("new_ranking_model:user_42") every time, which always maps to the same bucket in [0, 10000), so consistency across sessions/services comes for free. Salting with experiment_name matters: without it, a user who lands in treatment for one experiment would land in treatment for every experiment sharing the same hash of their ID, correlating exposure across unrelated experiments and making their effects harder to isolate statistically. With the salt, sha256("exp_a:user_42") and sha256("exp_b:user_42") are effectively independent random values, so a user's bucket in one experiment carries no information about their bucket in another.

The false-positive inflation from peeking is a direct consequence of what a p-value threshold actually promises. A single test at alpha=0.05 guarantees a 5% false-positive rate for that one comparison. Checking the result every day and stopping the first time p < 0.05 is actually running many correlated tests (one per day) and taking the best (smallest p) one — and the probability that at least one of many independent-ish trials crosses a fixed threshold by chance grows well beyond 5% the more times you check, which is exactly the ~21% observed in the worked example against a true 5% target. The math doesn't distinguish "I ran one test that happened to be significant" from "I ran thirty tests and reported the one that was" — both produce a p-value under 0.05, but only the first means what a p-value is supposed to mean. This is why the fix isn't a smarter test but a different discipline: either commit to a fixed sample size computed by required_sample_size and analyze exactly once when it's reached, or use a sequential testing method (e.g. alpha-spending, mSPRT) explicitly designed to control the false-positive rate across repeated looks.

The confidence interval and the p-value are answering different questions, and reporting only one loses information the other-supplies. p_value < alpha says "this lift is unlikely to be pure noise," but a statistically significant 0.01% lift might not be worth shipping if it adds engineering complexity. The 95% CI on absolute_lift(0.0015, 0.0165) in the example — says "the true lift is plausibly anywhere in this range," which is what a launch decision actually needs: even the low end of that interval (0.15pp) might or might not clear the bar for "worth the engineering cost," a business judgment call the p-value alone cannot make.

Exercise

Using required_sample_size, compute how many days a 5,000-daily-user experiment (split evenly) would take to detect a 0.5 percentage point lift over an 8% baseline conversion rate. Then modify simulate_null_experiment_with_peeking to instead check significance only once, after the full pre-computed sample size is reached, and rerun the 1000-trial simulation. Confirm the false-positive rate drops back to roughly 5%, and explain in your own words why fixing the number of looks (not the math of the test itself) is what fixes the inflation.