Experiment Analysis skill for Claude Code
A/B decisions you can defend. Claude writes the hypothesis, primary metric, guardrails and ship rule before any data arrives, sizes the test from baseline and MDE with a bundled calculator, and checks sample ratio mismatch before reading outcomes. Results come as effects with confidence intervals, with CUPED, delta-method ratios and multiple-comparison corrections where needed, and it calls out peeking and segment fishing.
- Name
experiment-analysis- Size
- 174 lines · ≈1,950 tokens
- Files
- 2
- License
- MIT
- Updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Plans and analyzes A/B tests — pre-registered hypothesis and primary metric, sample size and duration from baseline and minimum detectable effect, sample ratio mismatch checks, two-proportion and Welch tests with confidence intervals, CUPED variance reduction, ratio metrics with the delta method, multiple-comparison corrections, guardrail metrics, and the peeking problem — with a bundled stdlib calculator. Use when sizing an experiment, reading A/B test results, deciding whether to ship a variant, checking for SRM, or reviewing an experiment readout. Try asking
- How many users do we need to detect a 5% lift on a 4% conversion rate?
- Here are the A/B results for the new checkout. Should we ship it?
- Our test has 50,410 users in control and 49,590 in treatment. Is that a problem?
Use it when
- You are planning an A/B test and need sample size and duration
- Results are in and someone has to make a ship decision
- Group sizes look uneven and you suspect a sample ratio mismatch
- An experiment readout needs a statistical review
Skip it when
- You need event tracking for the experiment set up first — use analytics-tracking-plan instead
- You are optimizing page copy and layout before testing — use conversion-optimization instead
How to check the result
The readout matches the pre-registration, the SRM check passes, and the effect, confidence interval and p-value reproduce from the raw counts with the bundled ab_test.py calculator.
The common mistake it prevents
Checking the dashboard every day and stopping the test the first time p dips below 0.05, which makes false wins far more likely than the stated 5%.
An experiment is analyzed correctly when the decision rule was written before the data came in, the sample was sized for an effect worth detecting, assignment is verified free of sample ratio mismatch, the primary metric is reported as an effect with a confidence interval (not just a p-value), guardrails are checked, and the recommendation follows the pre-registered rule.
Ground rules
- Pre-register: hypothesis, one primary metric, guardrail metrics, MDE, sample size, duration, randomization unit, and the ship rule. Changing them after seeing data invalidates the test.
- Analyze once at the planned sample size, unless the platform uses a sequential method designed for continuous monitoring. Repeatedly checking a fixed-horizon test and stopping at the first p < 0.05 inflates false positives several-fold.
- Check SRM before looking at any outcome metric. If SRM is present, the results are not interpretable.
- Run whole weeks (at least one, usually two) to cover weekly cycles.
- Report effect size with a confidence interval and the absolute numbers behind it.
- Statistical significance is not the decision; practical significance against the MDE and guardrails is.
Workflow
- [ ] 1. Pre-registration doc (template below)
- [ ] 2. Sample size and duration (script)
- [ ] 3. Launch checks: A/A or assignment logging sanity, exposure definition
- [ ] 4. At the planned end: SRM check
- [ ] 5. Primary metric: effect, CI, p-value (variance reduction if planned)
- [ ] 6. Guardrails and secondary metrics (with correction)
- [ ] 7. Segments only as exploration; write the readout and decision
1. Pre-registration
## Experiment: one-page checkout (EXP-142)
- Hypothesis: Removing the shipping step increases checkout conversion.
- Randomization unit: user_id (logged-in), 50/50. Analysis unit: user.
- Exposure: user reaches /checkout during the test window.
- Primary metric: checkout conversion = users with >= 1 order / exposed users.
- Guardrails: revenue per exposed user (no drop > 1%), p95 checkout latency, refund rate.
- Baseline: 4.0% conversion; MDE: +5% relative (4.0% -> 4.2%).
- Alpha 0.05 two-sided, power 0.8 -> 154,304 users per group; ~16 days at 20k/day -> run 21 days.
- Ship rule: ship if primary CI excludes 0 and all guardrails pass; otherwise do not ship.
- Variance reduction: CUPED with pre-period orders (28 days before exposure).
2. Sample size and duration
python3 scripts/ab_test.py size --baseline 0.04 --mde-rel 0.05 --daily 20000
python3 scripts/ab_test.py size --sd 42.0 --mde-abs 1.5 --daily 20000 # continuous metric
python3 scripts/ab_test.py size --baseline 0.04 --mde-rel 0.05 --variants 3 --alpha 0.025
If the required duration is unrealistic: test a bolder change, pick a more sensitive metric (closer to the change), use CUPED, or accept a larger MDE. Never shorten the test and hope.
4. SRM check
python3 scripts/ab_test.py srm --counts 50410 49590 # expected 50/50
python3 scripts/ab_test.py srm --counts 33100 66900 --ratios 1 2
p < 0.001 means assignment or logging is broken (bot filtering applied unevenly, redirects dropping users, variant crashing before logging, caching). Stop and investigate; do not “adjust” the numbers.
5. Primary metric
python3 scripts/ab_test.py prop --a 1200 30000 --b 1310 30100
python3 scripts/ab_test.py mean --a 52.1 40.3 30000 --b 53.0 41.0 30100 # mean sd n
With statsmodels available:
from statsmodels.stats.proportion import proportions_ztest, confint_proportions_2indep
stat, p = proportions_ztest([1310, 1200], [30100, 30000])
low, high = confint_proportions_2indep(1310, 30100, 1200, 30000, compare="diff")
Metric type decides the test:
| Metric | Method |
|---|---|
| Conversion (per randomization unit) | Two-proportion z-test; CI on difference and relative lift |
| Mean per unit (revenue/user, sessions/user) | Welch t-test; with heavy tails, winsorize at p99.9 (pre-registered) or bootstrap |
| Ratio with different denominator unit (CTR = clicks/pageviews when randomizing users) | Delta method or bootstrap clustered by user; a naive per-pageview test understates variance |
| Time-to-event, retention curves | Survival analysis or fixed-window retention as a proportion |
| Medians / percentiles (latency) | Bootstrap by randomization unit, or quantile tests |
CUPED (variance reduction)
Use a pre-experiment covariate X (same metric in the pre-period) for each unit:
import numpy as np
import pandas as pd
# df: one row per user with columns group ('A'/'B'), y (metric in test), x (same metric pre-period, 0 if new)
theta = np.cov(df["x"], df["y"], ddof=1)[0, 1] / np.var(df["x"], ddof=1) # pooled across groups
df["y_cuped"] = df["y"] - theta * (df["x"] - df["x"].mean())
summary = df.groupby("group")["y_cuped"].agg(["mean", "std", "count"])
# then run the Welch test on y_cuped (e.g. scripts/ab_test.py mean with these summaries)
Variance falls by a factor of (1 − ρ²), where ρ is the correlation of X and Y. The covariate must be measured before exposure, never during the test.
6. Multiple metrics and variants
| Situation | Correction |
|---|---|
| One primary metric | None |
| Several treatment arms vs control | Bonferroni (alpha / k) or Holm on the primary |
| Many secondary/exploratory metrics | Benjamini-Hochberg (FDR), labeled exploratory |
| Guardrails | Test for harm (one-sided non-inferiority margin), pre-registered |
7. Segments and readout
Segment cuts (device, country, new vs. returning) are hypothesis generators. With 20 segments, one will be “significant” by chance. Do not ship based on a segment result unless it was pre-registered; propose a follow-up test instead.
Decision table:
| Primary | Guardrails | Decision |
|---|---|---|
| CI excludes 0, positive, effect >= MDE-ish | Pass | Ship |
| CI excludes 0, positive, tiny effect | Pass | Ship only if cost is low; note small effect |
| CI includes 0 | Pass | No detectable effect; do not claim “no impact”, report CI width |
| Any | Fail | Do not ship; investigate guardrail |
| SRM detected | n/a | Invalid; fix and rerun |
Gotchas
- Peeking: a dashboard checked daily with “stop when significant” behaves like many tests. Use the fixed horizon or a proper sequential method (group sequential with alpha spending, or always-valid intervals provided by the platform).
- Novelty and learning effects: early lift often fades; inspect the effect by week.
- Randomizing by session but analyzing by user (or vice versa) breaks independence.
- Counting only converters or only engaged users after assignment introduces selection bias; analyze all exposed units (intent to treat).
- Network effects and shared inventory (marketplaces, social features) violate independence; consider cluster or switchback designs.
- Relative lift CIs are asymmetric; report the absolute difference too.
- A non-significant result with a wide CI is “inconclusive”, not “no effect”.
Output
## EXP-142 readout: one-page checkout
**Decision:** Ship (pre-registered rule met)
**Sample:** 21 days, A 154,880 / B 155,210 users; SRM p = 0.55
**Primary — checkout conversion:** A 4.00% -> B 4.31%, +0.31 pp (95% CI +0.17 to +0.45),
relative +7.8% (CI +4.2% to +11.4%), p < 0.001 (CUPED, variance -22%)
**Guardrails:** revenue/user +1.9% (CI -0.4% to +4.2%, non-inferior); p95 latency unchanged; refunds unchanged
**Secondary (BH-corrected):** add-to-cart rate unchanged
**Exploratory:** effect larger on mobile (+10%) than desktop (+5%); not used for decision
**Caveats:** week-3 effect smaller than week 1 (possible novelty); monitor post-launch Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/ab_test.py
#!/usr/bin/env python3
"""A/B test calculator: sample size, SRM check, proportion and mean tests (stdlib only).
Subcommands:
size --baseline 0.04 --mde-rel 0.05 [--alpha 0.05 --power 0.8 --daily 20000 --variants 2]
size --sd 42.0 --mde-abs 1.5 [...] (continuous metric)
srm --counts 50410 49590 [--ratios 1 1]
prop --a 1200 30000 --b 1310 30100 [--alpha 0.05] (conversions visitors)
mean --a 52.1 40.3 30000 --b 53.0 41.0 30100 (mean sd n per group, Welch)
All tests are two-sided. Exit codes: 0 ok; 1 when `srm` detects a sample ratio
mismatch (p < 0.001); 2 on bad input. Results are for fixed-horizon tests analyzed
once at the planned sample size; repeated peeking invalidates the p-values.
"""
import argparse
import math
import sys
from statistics import NormalDist
N = NormalDist()
# ---------- special functions ----------
def gammaincc(a, x):
"""Regularized upper incomplete gamma Q(a, x)."""
if x < 0 or a <= 0:
raise ValueError("invalid gammaincc arguments")
if x == 0:
return 1.0
gln = math.lgamma(a)
if x < a + 1: # series for P, then Q = 1 - P
ap, s, d = a, 1.0 / a, 1.0 / a
for _ in range(10_000):
ap += 1
d *= x / ap
s += d
if abs(d) < abs(s) * 1e-15:
break
return max(0.0, 1.0 - s * math.exp(-x + a * math.log(x) - gln))
b = x + 1 - a # continued fraction for Q
c, d = 1 / 1e-300, 1 / b
h = d
for i in range(1, 10_000):
an = -i * (i - a)
b += 2
d = an * d + b
d = 1e-300 if abs(d) < 1e-300 else d
c = b + an / c
c = 1e-300 if abs(c) < 1e-300 else c
d = 1 / d
delta = d * c
h *= delta
if abs(delta - 1) < 1e-15:
break
return math.exp(-x + a * math.log(x) - gln) * h
def chi2_sf(x, df):
return gammaincc(df / 2.0, x / 2.0)
def betacf(a, b, x):
qab, qap, qam = a + b, a + 1, a - 1
c, d = 1.0, 1 - qab * x / qap
d = 1e-300 if abs(d) < 1e-300 else d
d = 1 / d
h = d
for m in range(1, 10_000):
m2 = 2 * m
aa = m * (b - m) * x / ((qam + m2) * (a + m2))
d = 1 + aa * d
d = 1e-300 if abs(d) < 1e-300 else d
c = 1 + aa / c
c = 1e-300 if abs(c) < 1e-300 else c
d = 1 / d
h *= d * c
aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2))
d = 1 + aa * d
d = 1e-300 if abs(d) < 1e-300 else d
c = 1 + aa / c
c = 1e-300 if abs(c) < 1e-300 else c
d = 1 / d
delta = d * c
h *= delta
if abs(delta - 1) < 1e-15:
break
return h
def betainc(a, b, x):
"""Regularized incomplete beta I_x(a, b)."""
if x <= 0:
return 0.0
if x >= 1:
return 1.0
bt = math.exp(math.lgamma(a + b) - math.lgamma(a) - math.lgamma(b) + a * math.log(x) + b * math.log(1 - x))
if x < (a + 1) / (a + b + 2):
return bt * betacf(a, b, x) / a
return 1 - bt * betacf(b, a, 1 - x) / b
def t_sf_two_sided(t, df):
return betainc(df / 2.0, 0.5, df / (df + t * t))
def t_ppf(q, df):
"""Quantile of Student t via bisection (q in (0.5, 1))."""
lo, hi = 0.0, 1000.0
target = 2 * (1 - q) # two-sided tail
for _ in range(200):
mid = (lo + hi) / 2
if t_sf_two_sided(mid, df) > target:
lo = mid
else:
hi = mid
return (lo + hi) / 2
# ---------- commands ----------
def cmd_size(a):
za = N.inv_cdf(1 - a.alpha / 2)
zb = N.inv_cdf(a.power)
if a.baseline is not None:
p1 = a.baseline
if a.mde_rel is not None:
p2 = p1 * (1 + a.mde_rel)
elif a.mde_abs is not None:
p2 = p1 + a.mde_abs
else:
raise SystemExit("error: give --mde-rel or --mde-abs")
if not (0 < p1 < 1 and 0 < p2 < 1):
raise SystemExit("error: rates must be within (0, 1)")
pbar = (p1 + p2) / 2
n = (za * math.sqrt(2 * pbar * (1 - pbar)) + zb * math.sqrt(p1 * (1 - p1) + p2 * (1 - p2))) ** 2 / (p2 - p1) ** 2
desc = f"proportion {p1:.4%} -> {p2:.4%} (abs diff {p2 - p1:+.4%}, rel {p2 / p1 - 1:+.2%})"
else:
if a.sd is None or a.mde_abs is None:
raise SystemExit("error: continuous metric needs --sd and --mde-abs")
n = 2 * (za + zb) ** 2 * a.sd ** 2 / a.mde_abs ** 2
desc = f"mean difference {a.mde_abs} with sd {a.sd}"
n = math.ceil(n)
total = n * a.variants
print(f"Detect {desc}")
print(f" alpha {a.alpha} (two-sided), power {a.power}, {a.variants} groups")
print(f" required per group: {n:,}")
print(f" required total: {total:,}")
if a.daily:
days = math.ceil(total / a.daily)
weeks = math.ceil(days / 7)
print(f" at {a.daily:,} eligible units/day: {days} days -> run {weeks * 7} days (whole weeks)")
print(" Note: with more than one treatment vs control, consider alpha / (variants - 1).")
return 0
def cmd_srm(a):
counts = a.counts
ratios = a.ratios or [1] * len(counts)
if len(ratios) != len(counts) or len(counts) < 2 or any(c < 0 for c in counts):
raise SystemExit("error: need >= 2 non-negative counts and matching ratios")
total = sum(counts)
rsum = sum(ratios)
expected = [total * r / rsum for r in ratios]
chi2 = sum((o - e) ** 2 / e for o, e in zip(counts, expected))
p = chi2_sf(chi2, len(counts) - 1)
print("Sample ratio mismatch check (chi-square goodness of fit)")
for i, (o, e) in enumerate(zip(counts, expected)):
print(f" group {i}: observed {o:,} expected {e:,.0f} share {o / total:.4%}")
print(f" chi2 = {chi2:.3f}, df = {len(counts) - 1}, p = {p:.3g}")
if p < 0.001:
print(" SRM DETECTED (p < 0.001): do not trust the results; investigate assignment/logging.")
return 1
print(" No SRM at p < 0.001.")
return 0
def cmd_prop(a):
xa, na = a.a
xb, nb = a.b
if not (0 <= xa <= na and 0 <= xb <= nb and na > 0 and nb > 0):
raise SystemExit("error: need 0 <= conversions <= visitors")
pa, pb = xa / na, xb / nb
pooled = (xa + xb) / (na + nb)
se_pooled = math.sqrt(pooled * (1 - pooled) * (1 / na + 1 / nb))
z = (pb - pa) / se_pooled if se_pooled else 0.0
p = 2 * (1 - N.cdf(abs(z)))
se = math.sqrt(pa * (1 - pa) / na + pb * (1 - pb) / nb)
zc = N.inv_cdf(1 - a.alpha / 2)
diff = pb - pa
lo, hi = diff - zc * se, diff + zc * se
print("Two-proportion z-test (B vs A)")
print(f" A: {xa:,}/{na:,} = {pa:.4%}")
print(f" B: {xb:,}/{nb:,} = {pb:.4%}")
print(f" absolute diff: {diff:+.4%} ({1 - a.alpha:.0%} CI {lo:+.4%} to {hi:+.4%})")
if pa > 0:
# delta-method CI on relative lift (log ratio)
if pb > 0:
se_log = math.sqrt((1 - pa) / xa + (1 - pb) / xb) if xa and xb else float("nan")
rr = pb / pa
rlo, rhi = rr * math.exp(-zc * se_log), rr * math.exp(zc * se_log)
print(f" relative lift: {rr - 1:+.2%} (CI {rlo - 1:+.2%} to {rhi - 1:+.2%})")
print(f" z = {z:.3f}, p = {p:.4g} -> {'significant' if p < a.alpha else 'not significant'} at alpha {a.alpha}")
return 0
def cmd_mean(a):
ma, sa, na = a.a
mb, sb, nb = a.b
na, nb = int(na), int(nb)
if na < 2 or nb < 2 or sa < 0 or sb < 0:
raise SystemExit("error: need n >= 2 and sd >= 0")
va, vb = sa ** 2 / na, sb ** 2 / nb
se = math.sqrt(va + vb)
diff = mb - ma
t = diff / se if se else 0.0
df = (va + vb) ** 2 / (va ** 2 / (na - 1) + vb ** 2 / (nb - 1)) if (va + vb) else na + nb - 2
p = t_sf_two_sided(abs(t), df)
tc = t_ppf(1 - a.alpha / 2, df)
print("Welch t-test (B vs A)")
print(f" A: mean {ma:g}, sd {sa:g}, n {na:,}")
print(f" B: mean {mb:g}, sd {sb:g}, n {nb:,}")
print(f" diff: {diff:+.4g} ({1 - a.alpha:.0%} CI {diff - tc * se:+.4g} to {diff + tc * se:+.4g})")
if ma:
print(f" relative: {diff / ma:+.2%}")
print(f" t = {t:.3f}, df = {df:.1f}, p = {p:.4g} -> {'significant' if p < a.alpha else 'not significant'} at alpha {a.alpha}")
return 0
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("size")
s.add_argument("--baseline", type=float)
s.add_argument("--mde-rel", type=float)
s.add_argument("--mde-abs", type=float)
s.add_argument("--sd", type=float)
s.add_argument("--alpha", type=float, default=0.05)
s.add_argument("--power", type=float, default=0.8)
s.add_argument("--daily", type=int, help="eligible units per day across all groups")
s.add_argument("--variants", type=int, default=2, help="number of groups including control")
r = sub.add_parser("srm")
r.add_argument("--counts", type=int, nargs="+", required=True)
r.add_argument("--ratios", type=float, nargs="+")
p = sub.add_parser("prop")
p.add_argument("--a", type=int, nargs=2, required=True, metavar=("CONV", "N"))
p.add_argument("--b", type=int, nargs=2, required=True, metavar=("CONV", "N"))
p.add_argument("--alpha", type=float, default=0.05)
m = sub.add_parser("mean")
m.add_argument("--a", type=float, nargs=3, required=True, metavar=("MEAN", "SD", "N"))
m.add_argument("--b", type=float, nargs=3, required=True, metavar=("MEAN", "SD", "N"))
m.add_argument("--alpha", type=float, default=0.05)
a = ap.parse_args()
try:
return {"size": cmd_size, "srm": cmd_srm, "prop": cmd_prop, "mean": cmd_mean}[a.cmd](a)
except SystemExit as exc:
if isinstance(exc.code, str):
print(exc.code, file=sys.stderr)
return 2
raise
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.