Causation

skills/reading-experiments

reading-experiments

Runs the trust gate on a finished experiment before the result is believed: sample ratio mismatch, exposure counts, always-valid or sequential bounds, guardrails read before the primary metric, the pre-registered segment rather than the most flattering one, CUPED variance reduction, and empirical Bayes shrinkage against the prior store. Use when analysing or reviewing A/B test results, when a test looks like a winner, when someone reports a lift, or when deciding whether to ship on an experiment readout.

shell
$ git clone https://github.com/0trm/gallop
$ cp -r gallop/skills/reading-experiments .claude/skills/
Read the source on GitHub

Whether the result is a result. Five of the six steps in an experiment can hand back a wrong number without the readout looking any different, so the reading order is fixed and the checks come before the number. Read against the pre-registered plan; if no plan exists, say so first, because everything below weakens without one.

The mechanical checks are scripted. Given assignment/exposure counts and unit-level data, run them in one pass:

python skills/reading-experiments/scripts/run_checks.py \
  --counts counts.csv --data units.csv --y metric --x pre_metric --arm arm \
  [--store priors.jsonl --metric activation_rate --unit pp]

Or individually via python -m gallop.trust|variance|sequential|shrink.

1 · The trust gate, before any effect

Two checks, and a failure at either stops the reading. Details and the cause taxonomy: reference/trust-gate.md.

  • Sample ratio mismatch. Chi-square on assignment counts against the intended split (gallop.trust.srm, alpha 0.001). A 0.4% imbalance at a million units looks like nothing on a dashboard and is a broken test. On SRM: stop, do not analyse, work the cause taxonomy. There is no correction; a biased assignment is not a smaller sample, it is a different population per arm.
  • Exposure. Did the variant reach anyone, at the same rate in both arms? (gallop.trust.exposure_check). Differential exposure is a trigger bug and invalidates the comparison. Uniform under-exposure dilutes the ITT estimate toward zero by a known factor; report both the diluted and scaled numbers, labelled.

2 · Guardrails before the primary

Read the guardrail metrics first, in the plan's order. A win on the primary with a guardrail breach is a rollback, not a trade-off discussion, because the trade was already made when the guardrail was registered. A rollback triggered by a guardrail is the system working and still gets a readout.

3 · The effect, under the licence you actually have

0% 5% 10% 15% 20% 25% 30% 1 5 10 15 20 30 looks at the data during the run false positives under a true null the 5% you signed up for 1 look: 5% 5 looks: 14% 15 looks: 22% 30 looks: 28%
Read a fixed-horizon test every day and the false-positive rate is not 5%. Simulated under a true null at alpha 0.05, 100,000 runs: about 14% at five looks, 28% at thirty.
  • If the design was fixed-horizon and nobody looked early: the plain interval stands.
  • If anyone peeked without a sequential design: the nominal p-value is broken (daily peeking roughly triples the false positive rate) and the reported effect is selected on noise. Recompute against always-valid bounds (python -m gallop.sequential bound) and label the result as salvaged, not as designed.
  • If the design was sequential: read against the pre-declared boundary (bound for always-valid, obf for the fixed schedule). A day-three crossing of an always-valid bound is a licensed stop.

Apply CUPED if a pre-period covariate exists (gallop.variance.cuped): the se falls by sqrt(1 - rho²), often the difference between a conclusive and an inconclusive read. Validity rule and failure modes: reference/variance-reduction.md.

0.00 0.25 0.50 0.75 1.00 0.0 0.2 0.4 0.6 0.8 1.0 correlation between the pre-period covariate and the outcome standard error after CUPED, as a share of before rho 0.5: se x0.87 rho 0.7: se x0.71 rho 0.9: se x0.44
CUPED's whole effect in one curve: the standard error falls by sqrt(1 minus rho squared), so a pre-period covariate at rho 0.7 buys the same precision as doubling the traffic.

A null is an interval, not a zero. "No difference" when the MDE was 1.4pp means "we could not have seen less than 1.4pp". Report the interval and the MDE every time; an underpowered null read as a kill is as wrong as a noise spike read as a win.

4 · Segments: the one you registered

Read the pre-registered segment. Do not read the other eleven the platform offers; with eleven cuts at alpha 0.05, finding a "significant" segment is the expected outcome under a true null. Anything interesting in an unregistered cut is a hypothesis for the next test, filed as one.

5 · Shrink toward what this metric actually does

-0.4pp -0.2pp +0.0pp +0.2pp +0.4pp +0.6pp +0.8pp +1.0pp effect on the metric, percentage points what this metric usually does: +0.10pp, tau 0.065 raw readout +0.306pp shrunk toward the prior +0.113pp weight on the data 0.08; the readout overstated by +0.193pp
The quickstart's own numbers. The prior is tight and the readout is noisy, so the weight on the data is 0.08 and the planning number is a third of the reported one.

Raw winners are inflated: conditioning on crossing a threshold selects the draws noise helped. Shrink the estimate toward the prior store's distribution for this metric (gallop.shrink.from_store); the shrunk number is the planning number and the one that gets written back. Mechanics and when shrinkage is not available: reference/shrinkage.md.

6 · Time: novelty and decay

If the daily lift trends down across the run (regress daily lift on day; a negative slope with a positive intercept), the effect is novelty and the horizon-end average overstates the long run. A decay slower than the horizon is invisible to any test you can afford; the only instrument for that is a long-term holdback, so recommend one when the decision is expensive and the mechanism is plausibly novelty-driven.

The verdict

One of four, stated plainly with the decision rule beside it:

  • Trustworthy and clears the rule → ship; hand to writing-readouts.
  • Trustworthy and misses the rule → kill or iterate; hand to writing-readouts (nulls are filed with the same care as wins).
  • Trust gate failed → no result exists. Diagnose, fix, rerun. Also filed: a broken test is a lesson about the pipeline.
  • Salvage (peeked without licence, diluted exposure): a labelled, weaker number with its caveat attached in the same sentence, and a note in the readout that the next design fixes the licence.

Every verdict flows to writing-readouts; the prior store only stays honest if losses and broken tests are written back as faithfully as wins.

reference shrinkage.md

Empirical Bayes shrinkage

The winner's-curse correction, and the moment the prior store pays for itself.

Why raw winners are inflated

A result is reported because it crossed a threshold. Conditioning on crossing selects the realisations noise pushed up, so the expected reported effect exceeds the true one, and the excess grows as power falls. An underpowered test that "wins big" is the most inflated object in the building: with 20% power, a just-significant result overstates the true effect by roughly a factor of two or more. The sign is usually right; the magnitude is not, which is worse for planning than a clean false positive.

The model

Normal-normal. The prior is what this metric has actually done, estimated from the store by method of moments:

mu   = mean of past effects
tau² = max( var(past effects) − mean(past se²), 0 )

Posterior mean = w·effect + (1−w)·mu, with w = tau²/(tau² + se²).

python -m gallop.shrink eb --effect 0.0031 --se 0.0012 \
    --store priors.jsonl --metric activation_rate --unit pp

Reading the output:

  • w near 1: the test was precise relative to how much true effects vary; the data mostly stands.
  • w near 0: the test was noisy relative to the metric's history; the estimate collapses toward the prior mean. This is not the method being timid, it is the observation carrying little information.
  • tau² = 0: past effects are indistinguishable from noise around their mean; the model is saying this metric has never produced a distinguishable effect, and the new result shrinks all the way to mu. That verdict is itself worth surfacing in the readout.

The shrunk number is the planning number: what to forecast from, what to write into the prior store, what to tell the roadmap. The raw number and interval are still reported beside it, labelled.

Preconditions, checked by the tooling

  • At least 3 past effects for the metric (gallop.shrink refuses fewer; below ~8 the prior itself is noisy, so say so).
  • One unit. Effects in pp do not pool with effects in relative; from_store refuses mixed units rather than averaging them.
  • Comparable readouts. The store should hold effects from the same metric under comparable designs; a store polluted with one 10x outlier from a pricing change will inflate tau² and under-shrink. supersedes exists for corrections; use it.

When there is no store

First tests at a company have no prior. Options, in honesty order: borrow the published base rate (most experiments do nothing; the honest prior mean is roughly zero, so at minimum discount the raw estimate mentally and say the readout is unshrunk); or start the store with this readout and accept that shrinkage begins from test four. What is not available is treating the raw number as a forecast: that is the exact error the correction exists for.

reference trust-gate.md

The trust gate

Two mechanical checks that run before any effect is read. Both exist because their failure modes produce readouts indistinguishable from healthy ones.

Sample ratio mismatch

The check: chi-square goodness of fit of assignment counts against the intended split.

python -m gallop.trust srm --counts counts.csv        # columns: arm, assigned

Alpha is 0.001, not 0.05, deliberately. The check runs on every experiment ever analysed; at 0.05 it would fire on one healthy test in twenty, be ignored within a quarter, and protect nothing. At 0.001 a firing means the test is broken, with high confidence.

Why there is no correction. An SRM means units left (or never entered) one arm non-randomly. The remaining populations differ in whatever made those units leave, which is usually correlated with the outcome. Reweighting cannot recover a comparison that randomisation no longer underwrites.

The cause taxonomy (Fabijan et al.), worked in order, stopping at the first that fits:

  1. Assignment – bucketing bug, hash collision, a variant filter upstream.
  2. Execution – one variant errors or times out; its events never arrive.
  3. Logging – telemetry differs by variant; a slower page fires fewer beacons.
  4. Experiment definition – the ramp changed mid-flight and the expected ratio was not updated. (The one benign cause: fix the expectation, not the test.)
  5. Triggering – the trigger condition is itself affected by treatment.
  6. Interference – units move between variants or share state.
  7. Filtering – a bot or outlier filter removes units at different rates by variant.

Check daily during the run, not once at the end: an SRM caught on day two costs two days; caught at readout it costs the whole test.

Exposure versus eligibility

The check: exposure counts per arm against assignment counts.

python -m gallop.trust exposure --counts counts.csv   # columns: arm, assigned, exposed

Two distinct failures:

Differential exposure (the exposed counts fail an SRM at the assignment split): the arms trigger unequally, which is a bug in the trigger or the surface, and the comparison is invalid outright. Treat exactly like an SRM.

Uniform dilution (both arms under-exposed at the same rate r): the intention-to-treat estimate is attenuated by roughly r. Two honest readings:

  • ITT as measured, labelled "diluted by exposure at r".
  • The exposed-only comparison, if exposure is logged symmetrically in both arms (control fires the event on seeing the control surface). If only the treatment arm logs exposure, the exposed subsets are not comparable and scaling ITT by 1/r is the safer statement.

The deeper fix belongs to design: log exposure, not eligibility, and alert on event volume so a dark platform or country is visible within a day. A test that "ran fine" with a 60% exposure rate was a test on a 40% smaller effect than anyone thought they were testing.

reference variance-reduction.md

Variance reduction at readout

CUPED

Deng, Xu, Kohavi & Walker (2013). Adjust the metric by a pre-experiment covariate that treatment cannot have touched:

Y_adj = Y - theta (X - mean(X)),   theta = Cov(Y,X) / Var(X)

Var(Y_adj) = Var(Y)(1 - rho²): the entire benefit is the squared pre/post correlation, so measure rho before promising anyone a number. At rho 0.7 the required sample halves; at rho 0.2 the gain is cosmetic.

python -m gallop.variance cuped --data units.csv --y metric --x pre_metric --arm arm

The validity rule, in one sentence: the covariate must be fully determined before the first unit was exposed, and defined identically for every unit, including units with no history (impute a constant, usually the mean; never drop them).

Failure modes, in order of danger:

  1. Contaminated covariate. X computed over a window that overlaps the experiment, or otherwise touched by treatment. The only failure that biases the estimate rather than weakening it: CUPED then subtracts part of the true effect. If the covariate window's end is not provably before first exposure, do not use it.
  2. Per-arm theta. Theta and mean(X) must be estimated pooled across arms. Estimated per arm, each theta fits its own arm's noise and most of the variance reduction is thrown away (gallop.variance pools; a hand-rolled version often does not).
  3. Missing pre-period. New users have no history. Coverage c of the covariate cuts the reduction by roughly c²; with 40% coverage, expect ~16% of the nominal gain. Report the effective rho, not the rho among covered users.

The natural covariate is the same metric over an equal-length pre-period. Anything pre-treatment and correlated works; the same metric is usually the most correlated thing available.

The alternatives

Post-stratification on a discrete covariate (platform, country, tenure): weight each stratum by its pooled share. Wins over CUPED only when the covariate is genuinely categorical or must be fixed before launch.

Triggered analysis: restrict to units that could have been affected at all. Not a variance trick on the same population; it changes the estimand to the effect on the triggered subset. Legitimate and often what the decision wants, but say which population the number is about.

What is not on the list: collecting more traffic. At readout the sample is what it is; the levers above are the only ones left, which is why the covariate is planned at design time.