30-second mental model
- A p-value measures surprise under the null. The interval measures precision. Neither measures value.
- Decide on the effect size and its interval, not on clearing 0.05.
- Fix the metric, the sample size, and the end date before data lands. Everything chosen afterwards is storytelling.
- Randomization is the only assumption you control. Every other causal method trades it for a belief.
Reading a result
Say what a p-value does and does not mean
| Reading | Verdict |
|---|---|
| "If nothing changed, results this extreme happen this often" | Correct |
| "Probability the null is true" | Wrong |
| "Probability this result is a fluke" | Wrong |
| "The effect is big / important" | Wrong - p = 0.01 on a 0.05% lift is worthless |
| p from a test stopped when it dipped below 0.05 | Meaningless - see peeking below |
Read a confidence interval
A 95% CI comes from a procedure that captures the truth in 95% of repeated samples. Your one interval either contains it or not.
| Interval on conversion lift | What to do |
|---|---|
[-0.1%, +4.3%] |
Inconclusive - underpowered, get more data |
[-0.2%, +0.3%] |
Real answer: any effect is too small to care about |
[+1.8%, +2.4%] |
Ship, and quote the range not the point |
Not "95% probability the truth is in here", not "95% of users", not "95% of future results".
Convert significance level to confidence level
confidence = 1 - alpha. alpha = 0.05 pairs with a 95% interval, and for a standard two-sided test the interval excludes the null exactly when p < 0.05.
The equivalence breaks near the boundary for score/Wilson intervals, bootstrap intervals, and one-sided tests.
Choose one-tailed or two-tailed
| Threshold at 5% | Cost | |
|---|---|---|
| Two-tailed | 97.5th percentile | Default. Harder to clear |
| One-tailed | 95th percentile | Blind to harm - an 8% drop reads as "not significant" |
Use one-tailed only when a move in the opposite direction leads to the same decision as no move. That is rare. Never pick the tail after seeing the data.
Name the error you are trading
| Meaning | Controlled by | |
|---|---|---|
| Type I | Claimed an effect that is not there | alpha (significance level) |
| Type II | Missed an effect that is there | power (1 - beta) |
With a fixed sample, tightening one loosens the other. Only more data shrinks both. Set alpha by which mistake costs more: shipping a dud burns time and pollutes baselines; killing a real winner costs revenue silently forever.
Quote a prediction interval for a single case
CI covers a parameter (the mean) and shrinks with n. A prediction interval covers the next single observation and never shrinks below the population spread.
mean delivery 3 days, 95% CI [2.9, 3.1] // how well we know the average
95% PI [1, 7] // what one parcel actually does
Quoting the CI to a customer is how you manufacture broken promises.
Sizing and powering a test
Compute sample size per arm
Inputs: baseline rate, minimum detectable effect (MDE), alpha, power.
// two-sided, alpha = 0.05, power = 0.80 -> n ~= 16 * variance / effect^2
const variance = 0.05 * 0.95; // 5% baseline conversion
const n = 16 * variance / (0.005 ** 2); // 30,400 per arm for +0.5pp absolute
- Revenue-style metrics have far larger variance than rates - budget much more traffic.
- Each extra variant raises the count each arm needs.
- Sample size scales with
1 / effect^2: halving the MDE quadruples the traffic.
Pick the minimum detectable effect
The MDE comes from the decision, never from the traffic you have. Ask what lift would justify the build and the risk. If that number needs more users than a month supplies, say the test cannot answer the question.
Read 80% power correctly
Power is the chance of detecting an effect of the size you designed for. 80% power = a 1-in-5 miss rate at that size, and much worse for smaller true effects.
A flat result at 80% power is weak evidence of nothing. Report the interval, not a verdict.
Buy power with: more users, longer run, variance reduction (CUPED, stratification), or a bigger MDE.
Choose primary and guardrail metrics
| Want | Rule |
|---|---|
| Primary metric | Exactly one, fixed before launch, per-user, as close to value as traffic allows |
| Recommender test | Purchases or engaged sessions, not homepage CTR |
| Button/color test | Completed purchases per visitor, clicks are diagnostic only |
| Guardrails (veto power) | Latency, error/crash rate, refunds, unsubscribes, total revenue |
| Vanity metrics to refuse | Raw totals: pageviews, sessions, cumulative signups - they rise with traffic and season |
Decide how long to run
- Minimum one full week, usually two: weekday and weekend users differ, and two weeks covers a purchase or renewal cycle.
- Hitting the sample size in two days does not end the test.
- Longer is not free: cookie churn dilutes assignment and the test queue backs up.
- Write the end date down before launch and let the analysis wait.
Assigning traffic
Bucket users deterministically
const bucket = hashInt(`${userId}:${experimentId}`) % 1000;
const arm = bucket < 500 ? 'control' : 'treatment'; // stable across sessions
Buckets are numbered slots: give 1% to a risky arm, widen to 50% later, reserve a range as a holdout, all without remapping anyone.
Two traps: no per-experiment salt means the same users are always in treatment;
id % 2skips the hash and maps signup order onto arms.
Pick the randomization unit
| Unit | Use when | Cost |
|---|---|---|
| User id | Logged in | Cleanest |
| Cookie / device | Anonymous traffic | One person can see both arms |
| Session | Almost never for UX changes | Inconsistent experience |
| Cluster (city, market, community) | Users influence each other | Power collapses - see interference |
Analyze only users who were actually exposed to the surface under test; counting the unexposed shrinks the detectable effect.
Verify assignment was actually random
- Sample ratio mismatch (SRM): chi-square the observed split against the intended one. 50.4/49.6 over 2M users is not luck - it is broken assignment or logging. Hard stop.
- Pre-period balance: compare country, platform, tenure, and last month's primary metric across arms.
- Do not over-read: check 20 covariates and one will hit p < 0.05 by chance. Covariate gaps need a much higher bar than SRM.
Usual causes: bots in one arm, a redirect dropping slow clients, exposure logged only inside the treatment code path.
Diagnosing a result
The test came back flat
Read the interval first. [-3%, +4%] could never have seen a 2% lift - the honest word is inconclusive, not "no effect". Then check in order:
| Cause | Check |
|---|---|
| Dilution | What share of assigned users were actually exposed? |
| Broken plumbing | Did the treatment render? Did both arms get the same experience? |
| Wrong metric | Did the change move an earlier step you did not measure? |
| Real null | Interval tight around zero + clean instrumentation - believe it and move on |
The in-flow lift is 40% but overall moved 2%
Dilution explains nearly every gap this size. 40% is on the 5% of traffic that reached the new flow.
- Check exposure share, whether the jump sits in one narrow segment, and whether 40% is relative on a tiny base (0.5% -> 0.7% is +40% relative, +0.2pp absolute).
- Report both: exposed-user effect says whether the feature works; all-assigned effect says what shipping is worth.
Quoting 40% to leadership when the company will only ever feel 2% is the trap.
A rate improved while volume fell
CTR rose because impressions (the denominator) fell. Look at the numerator alone: if clicks fell too, the product delivered less value.
- Stricter relevance filter = deliberate tradeoff, possibly fine. Rendering bug = not fine. Same rate move, opposite meaning.
- Decide on absolute clicks, sessions per user, or downstream conversion - far less gameable than a ratio.
- Ratio metrics need bootstrap over users or the delta method; treating impressions as independent units understates variance.
The winner faded after launch
| Cause | Signature | Fix |
|---|---|---|
| Novelty | Effect shrank day over day inside the test window | Read late-window effect, or new-user-only cohort |
| Winner's curse | Effect measured at the moment it crossed the threshold | Expect regression; rerun or hold back |
| Population shift | Test ran on an active slice, launch covers everyone | Segment the launch read |
| Seasonality | Baseline moved underneath | Compare against a concurrent control |
Keep 5% of users on control after launch and read the gap over the following month.
Ten variants and one wins at p < 0.05
const anyFalsePositive = 1 - Math.pow(0.95, 10); // 0.401 -> 40% chance of a fake winner
| Correction | When |
|---|---|
| Bonferroni (alpha / k, so 0.005) | Few comparisons, false positives are expensive |
| Benjamini-Hochberg (FDR) | Screening many ideas at once - gentler, keeps power |
| Rerun the single winner vs control | Usually the cheapest and most convincing. A real effect repeats |
Someone peeked daily and stopped at 0.05
The 5% guarantee covers one planned analysis. Peeking daily for two weeks pushes the real false positive rate toward 20% or higher, and the effect at the stopping point is biased upward because you only stop when noise favors you.
If you must monitor: sequential / group sequential boundaries that spend the error budget across looks, or Bayesian monitoring. Fixed 0.05 plus daily peeking is the one option that quietly lies.
One metric rose and another dropped
Only ship if the exchange rate was agreed before the test ran.
- Guardrails (crash, latency, errors, unsubscribes) get a veto, not a trade.
- Genuine tradeoffs (revenue vs sessions) convert into one currency.
- Check the shape: a 1% average drop is often a 6% drop concentrated in new users.
- Check they are not one effect counted twice.
- Time asymmetry usually settles it: engagement gains fade, retention damage compounds. When close, ramp partially with a long-run holdback.
When you cannot randomize
Decide not to run a test
Skip when the test cannot answer the question or costs more than the decision: low traffic (six months to power = stalling), legal/security/rebrand changes, strong network effects, one-shot launches, market-wide changes like pricing.
| Instead | Key assumption to defend |
|---|---|
| Difference-in-differences | Parallel pre-trends - plot them |
| Regression discontinuity | Credible only near the cutoff |
| Synthetic control / interrupted time series | Blend of untreated units tracks the treated one pre-launch |
| Switchback (flip whole market on a schedule) | Washout gap absorbs carryover |
| Staged rollout on guardrails + session replay | No causal claim, just harm detection |
Test the assumption, not just the estimate: rerun the model on a fake launch date and confirm it finds nothing. Report the number with its assumption attached.
Limit interference between arms
Randomize above the boundary where users affect each other: cluster by market/city/graph community, switchback by time slice, or split the resource (inventory, driver supply, ad budget) per arm.
- Power now scales with the number of clusters, not users. Thirty cities detects only large effects.
- Cluster sizes are unequal: analyze cluster means or use ratio estimators with cluster-robust variance.
- Watch invisible shared state: caches, pacing budgets, and any model retrained on pooled logs carry treatment into control.
Fix standard errors when users are correlated
variance inflation ~= 1 + (m - 1) * ICC
ICC = 0.05, m = 100 -> ~6x the reported variance
Use cluster means, cluster-robust standard errors, and a bootstrap that resamples clusters instead of users. Effective sample size is the number of independent units, not the number of events.
Separately, the estimand shifts: control users are partly treated by neighbors, so in a marketplace the measured lift can beat any achievable global gain. Model exposure (share of a user's neighbors in treatment), fit dose-response, and confirm with a market-level holdout.
Measure a metric that takes two months to mature
Split into a fast read and a slow read.
- Fast: a surrogate metric validated on past experiments where you hold both the early signal and the mature outcome. A surrogate that worked for pricing will mislead on onboarding.
- Slow: a long-term holdback - ship to most traffic, keep a small population untreated, read the mature metric when it lands. Start the clock early; extra traffic cannot buy calendar time.
- Holdbacks decay: two months of logged-out sessions, reinstalls, and device switches, and the attrition is not random. One permanent shared holdout beats a per-feature one.
Causal reasoning
Choose what to adjust for
Draw a DAG (nodes = variables, arrows = cause -> effect, no loops) before touching the data.
| Variable type | Definition | Action |
|---|---|---|
| Confounder | Causes both treatment and outcome | Adjust |
| Mediator | Sits on the causal path | Do not adjust - it erases the effect |
| Collider | Caused by both sides | Do not condition - it invents correlation |
"Control for more variables" is not safer. And a DAG is an assumption: data cannot tell you which way an arrow points.
Spot Simpson's paradox
A trend holds in every subgroup and reverses when pooled, because the mix is unequal.
desktop: B 10% vs A 9% B wins
mobile: B 4% vs A 3% B wins
pooled: B loses (B is 80% mobile; mobile converts worse for everyone)
Split on a variable only when it causes the outcome and exists before assignment. Splitting on something the treatment itself caused hides the effect. Randomization usually balances the mix; quarter-over-quarter comparisons never do.
Separate correlation from causation
Correlation is symmetric and cheap; causation is directional and needs an intervention or a mechanism. When two variables move together, name the three rivals before believing the story: confounder, reverse causation, self-selection.
"App users spend more" is almost always heavy spenders installing first. Randomize who gets nudged to install.
Distributions, sampling, correlation
Pick the distribution
| Situation | Distribution | Params |
|---|---|---|
| Successes out of a known n (conversions / visitors) | Binomial | mean np, var np(1-p) |
| Events per window with no fixed n (tickets per hour) | Poisson | mean = var = lambda |
| Counts where variance far exceeds the mean | Negative binomial | over-dispersion |
| Sums of many small influences, sample means | Normal | mean, sd |
- Binomial requires independent trials with the same p. Real users break both (repeat visits, heavy buyers), so true spread exceeds
np(1-p)and your error bars are too narrow. - Poisson assumes mean = variance. Incidents and hourly clustering break it - fitting Poisson to over-dispersed counts gives intervals that are far too tight.
- Normal sanity check with 68-95-99.7: mean 5 min, sd 4 puts two sd below zero, so the data is not normal. Skewed metrics like revenue break the rule badly.
Check the CLT actually applies
Needs: independent, identically distributed, finite variance.
n = 30is enough only for mildly skewed data. Revenue per user can stay skewed at n in the thousands - bootstrap the mean and look at its shape.- Heavy tails (near-Cauchy: file sizes, insurance losses) never settle.
- Independence breaks first in practice: repeated events from one user mean effective n = number of users.
- When a handful of users can move the mean, cap or trim rather than trusting the theorem.
Build a confidence interval for a proportion
const r = k / n; // 6/10 = 0.6
const half = 1.96 * Math.sqrt(r * (1 - r) / n); // 0.304 (Wald)
| Situation | Use |
|---|---|
| Large n, rate away from 0 and 1 | Wald (above) |
| Small n, or rate near 0 or 1 | Wilson or Clopper-Pearson |
| Weird estimator, ratio metric | Bootstrap: resample, read 2.5th and 97.5th percentiles |
At zero successes Wald collapses to zero width and claims certainty from nothing.
Get a two-tailed p-value from counts
// 1 head in 10 flips. H0: fair coin, p = 0.5, flips independent.
// p-value = P(X <= 1) + P(X >= 9) under Binomial(10, 0.5)
const p = 2 * (1 + 10) / 1024; // 0.0215 -> reject fairness
The p-value is the tail, not the point probability of exactly what you saw. And 1 in 20 fair coins lands here - flip more.
Measure correlation
| Data | Measure | Note |
|---|---|---|
| Two numeric, linear | Pearson r | -1 to 1, unit-free |
| Curved or outlier-heavy | Spearman rank | Ranks first, one extreme point cannot dominate |
| Two categorical | Cramer's V | Pearson does not apply |
Plot the scatter first. Pearson 0.8 next to Spearman 0.3 means a handful of rows do all the work. Pearson near zero with an obvious U shape means you measured the wrong thing.
Pick a sampling design
| Design | Use when | Cost |
|---|---|---|
| Simple random | You have a full population list | Cleanest math |
| Systematic (every kth) | Cheap draw from an ordered list | Fails on repeating cycles |
| Stratified | A small segment would be missed by chance | Needs the group label up front; reweight to population shares |
| Cluster (stores, shifts, cities) | Field work, interference | Wider error bars |
| Convenience | Never | Bakes in whatever made those units easy to reach |
Stratifying buys variance reduction only when the strata differ on the metric. With 3% enterprise customers, a random sample of 500 pulls ~8 of them and swings wildly.
Logs from a single region are a convenience sample in a warehouse costume.
Keep conditional probabilities pointing the right way
P(A and B) = P(A|B) * P(B). Sum a joint table across a row to get a marginal; divide a joint by a marginal to get a conditional.
1,000 users, 400 mobile, 100 of those churned
joint P(mobile and churn) = 10%
marginal P(mobile) = 40%
conditional P(churn|mobile)= 25%
"Most churners are on mobile" is
P(mobile|churn). "Mobile users churn more" isP(churn|mobile). Only the second justifies rebuilding the app.
Common pitfalls
- Choosing the metric, the threshold, or the tail after seeing results.
- Stopping the moment p dips under 0.05.
- Filtering on post-assignment behavior ("engaged users only") - it rebuilds the bias randomization removed.
- Reporting a rate without its denominator, or a relative lift without the absolute.
- Calling an underpowered flat result "no effect".
- Adjusting for a mediator or conditioning on a collider.
- Trusting
np(1-p)or Poisson variance on clustered real-user data. - Averaging percentiles, or averaging a stratified sample without weights.
- Comparing a freshly retrained model against a stale control - that measures staleness, not the algorithm.
- Quoting a confidence interval as a promise about a single case.