Data Engineering No. 233Editor's pick

Statistical Testing skill for Claude Code

The right test, an effect size, honest corrections. Claude picks the hypothesis test from a decision table keyed on outcome type, number of groups and pairing, checks the assumptions that actually matter such as independence of units, and reports effect sizes with confidence intervals ahead of p-values. A bundled script applies Bonferroni, Holm, Benjamini-Hochberg and BY corrections side by side, and results are written as plain-language statements with limits.

  • namestatistical-testing
  • size174 lines · ≈2,200 tokens
  • files2
  • licenseMIT
  • 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: Chooses and runs the right hypothesis test with scipy and statsmodels - a decision table by outcome type, groups, and pairing (Welch t, paired t, Mann-Whitney U, Wilcoxon, chi-square, Fisher exact, McNemar, ANOVA/Kruskal-Wallis, correlation), assumption checks that matter, effect sizes with confidence intervals, power and sample size, multiple-comparison correction with a bundled Holm/BH script, bootstrap and permutation alternatives, and plain-language reporting. Use when asked whether a difference is significant, which statistical test to use, comparing groups or proportions, interpreting p-values, or correcting for many comparisons.

Try asking

  • Is the difference in resolution time between these two support teams significant?
  • Which statistical test should I use for these survey responses by region?
  • I ran 24 subgroup tests - which results survive a multiple comparison correction?

Use it when

  • Deciding which hypothesis test fits the data
  • Comparing means, medians, or proportions between groups
  • Interpreting or reporting p-values and confidence intervals
  • Many tests were run and need correcting

Skip it when

How to check the result

The test matches outcome type, groups and pairing, every p-value sits next to an effect size and CI, and adjusted p-values are shown for the whole family of tests.

The common mistake it prevents

Treating thousands of page views from a few hundred users as independent observations, which shrinks standard errors and manufactures significance.

~/.claude/skills/statistical-testing/SKILL.md

--- name: statistical-testing description: Chooses and runs the right hypothesis test with scipy and statsmodels - a decision table by outcome type, groups, and pairing (Welch t,… license: MIT ---

A test result is complete when the hypothesis and test were chosen before looking at the outcome, the test matches the data’s type, pairing, and independence, the report leads with the effect size and its confidence interval, multiple comparisons are corrected, and the conclusion is stated in plain language without “proves”. A p-value alone answers almost nothing a stakeholder asked.

Workflow

- [ ] 1. State the question as H0/H1, the primary outcome, alpha, and one- vs two-sided
- [ ] 2. Describe the data: type, groups, pairing, sample sizes, independence
- [ ] 3. Pick the test from the decision table
- [ ] 4. Check the assumptions that actually matter
- [ ] 5. Run the test; compute effect size and CI
- [ ] 6. Correct for multiple comparisons if more than one test
- [ ] 7. Report with the template; state limitations

1. Before computing anything

  • Two-sided by default. One-sided only if the opposite direction is genuinely irrelevant and was declared beforehand.
  • Alpha 0.05 unless the domain sets another; say so.
  • Count the tests you will run (outcomes x subgroups x comparisons) - that is the family for correction.
  • If the data are a full population (all orders last month), a significance test answers a hypothetical question; descriptive comparison may be enough. Say so.

3. Decision table

OutcomeGroupsDesignDefault testAlternative
Continuous2independentWelch t-test (ttest_ind(equal_var=False))Mann-Whitney U for ordinal or heavy outliers; bootstrap CI of the mean difference
Continuous2paired (before/after, same units)paired t-test (ttest_rel)Wilcoxon signed-rank
Continuous3+independentWelch ANOVA, or one-way ANOVA if variances similarKruskal-Wallis; then post-hoc (Games-Howell / Tukey HSD / Dunn)
Continuous3+repeated measuresrepeated-measures ANOVA or mixed modelFriedman
Binary / proportion2independenttwo-proportion z-test or chi-square 2x2Fisher exact when any expected count < 5
Binary2pairedMcNemarexact McNemar for small discordant counts
Categoricalr x cindependentchi-square test of independence (chi2_contingency)Fisher-Freeman-Halton / Monte Carlo for sparse tables
Counts per unit time2independentPoisson / negative binomial rate comparison (GLM)-
Two continuous variables-associationPearson r (linear)Spearman rho (monotonic, ordinal, outliers)
Outcome with covariatesanyadjust for confoundersregression (OLS / logistic / GLM) with HC3 standard errors-
Time to event2+censoredlog-rank test, Cox model-

Prefer Welch over Student’s t always; it loses almost nothing when variances are equal. Mann-Whitney does not test medians in general - it tests whether one group tends to have larger values (stochastic dominance). Report it that way.

4. Assumptions that matter

AssumptionMatters forCheckIf violated
Independence of observationsevery testdesign: repeated users, clustered by store/classroom/sessionaggregate to the unit, paired test, mixed model, or cluster-adjusted standard errors
Approximate normality of the meant-tests with small nn per group; histograms/QQ plotsn above ~30-50 per group usually fine via CLT unless very skewed; else bootstrap or rank test
Expected cell counts >= 5chi-squarechi2_contingency(...).expected_freqFisher exact or combine categories
Equal variancesStudent t, classic ANOVAratio of SDsuse Welch versions
Linearity, no extreme influential pointsPearson, OLSscatter plotSpearman, transform, Huber regression

Do not run normality tests (Shapiro-Wilk) as a gate: with large n they reject trivial deviations, with small n they miss real ones. Look at plots and think about the data.

Independence is the assumption most often broken: 10,000 page views from 300 users are not 10,000 independent observations.

5. Run it

import numpy as np
from scipy import stats

a = np.asarray(group_a, float); b = np.asarray(group_b, float)

res = stats.ttest_ind(a, b, equal_var=False)
ci = res.confidence_interval(confidence_level=0.95)        # CI of mean difference (scipy >= 1.10)
diff = a.mean() - b.mean()

# Effect size: Hedges' g (bias-corrected Cohen's d)
na, nb = len(a), len(b)
sp = np.sqrt(((na - 1) * a.var(ddof=1) + (nb - 1) * b.var(ddof=1)) / (na + nb - 2))
g = diff / sp * (1 - 3 / (4 * (na + nb) - 9))

# Rank-based alternative with effect size (rank-biserial correlation)
u = stats.mannwhitneyu(a, b, alternative="two-sided")
rbc = 2 * u.statistic / (na * nb) - 1    # scipy reports U for `a`; positive means a tends to be larger

# Proportions
table = np.array([[conv_a, n_a - conv_a], [conv_b, n_b - conv_b]])
chi2, p, dof, expected = stats.chi2_contingency(table, correction=False)
odds, p_fisher = stats.fisher_exact(table)

# Bootstrap CI for a difference in medians (no distribution assumption)
boot = stats.bootstrap((a, b), lambda x, y: np.median(x) - np.median(y),
                       n_resamples=10_000, vectorized=False, random_state=0)
TestEffect size to report
t-testsmean difference with CI (in original units) + Hedges’ g
Mann-Whitney / Wilcoxonrank-biserial correlation, or Hodges-Lehmann shift estimate
Proportionsabsolute difference in percentage points with CI, plus relative lift or odds ratio
Chi-square r x cCramer’s V
ANOVAeta-squared / omega-squared, plus pairwise differences with CIs
Correlationr or rho with CI

Rough Cohen benchmarks (d 0.2 small, 0.5 medium, 0.8 large) are a last resort; prefer domain meaning (“3.1 minutes faster per ticket”).

Power and sample size before collecting data:

from statsmodels.stats.power import TTestIndPower
n = TTestIndPower().solve_power(effect_size=0.3, alpha=0.05, power=0.8, alternative="two-sided")

A non-significant result from an underpowered study is “inconclusive”, not “no effect”. For equivalence claims (“no meaningful difference”), run TOST with a pre-set margin.

6. Multiple comparisons

python3 scripts/adjust_pvalues.py 0.004 0.012 0.03 0.049 0.21          # Bonferroni, Holm, BH, BY side by side
python3 scripts/adjust_pvalues.py --file pvals.csv --method holm --alpha 0.05
SituationMethod
Few confirmatory tests, any false positive is costlyHolm (controls FWER; dominates Bonferroni)
Many exploratory tests (segments, metrics, genes), tolerate some false discoveriesBenjamini-Hochberg (FDR)
Many tests with arbitrary dependenceBenjamini-Yekutieli
Pairwise comparisons after ANOVATukey HSD / Games-Howell, which handle the family internally

statsmodels equivalent: multipletests(pvals, method="holm" | "fdr_bh"); scipy >= 1.11 has stats.false_discovery_control for BH/BY.

Gotchas

  • Peeking: testing repeatedly as data arrive and stopping at p < 0.05 inflates false positives far above 5%. Fix the sample size or use sequential methods (see experiment-analysis).
  • Subgroup fishing after a null overall result is exploratory; label it and correct.
  • Statistical significance with huge n can be practically meaningless; lead with the effect size.
  • “p = 0.06, trending toward significance” is not a finding. Report the CI.
  • Ratios of means across units (revenue per session when randomized by user) need the delta method or bootstrap by unit, not a t-test on sessions.
  • Excluding outliers after seeing results is a forking path; set exclusion rules first.

Output: result statement

**Question:** Does the new onboarding reduce time-to-first-project? (two-sided, alpha 0.05, pre-registered)
**Data:** 412 new users (control 205, new 207), one observation per user; times right-skewed.
**Test:** Mann-Whitney U (primary, declared), Welch t on log time (sensitivity).
**Result:** median 34 min vs 27 min; Hodges-Lehmann shift -6.2 min (95% CI -10.1 to -2.4), p = 0.002;
rank-biserial r = 0.18. Sensitivity analysis agrees.
**Multiple comparisons:** 3 secondary outcomes, Holm-adjusted; none significant.
**Interpretation:** New onboarding likely shortens time-to-first-project by about 2-10 minutes.
**Limits:** single 3-week cohort; desktop only.

Verification

  • Test choice matches the decision table row for outcome type, groups, and pairing; independence unit stated.
  • Effect size and CI reported in original units next to every p-value.
  • Family size counted and the correction applied; adjusted values shown.
  • A sensitivity check (alternative test, bootstrap, or excluding outliers by the pre-set rule) agrees in direction; if not, report the disagreement.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/adjust_pvalues.py 112 lines · 3.6 KB
#!/usr/bin/env python3
"""Adjust a family of p-values for multiple comparisons (stdlib only).

Usage:
  python3 adjust_pvalues.py 0.01 0.04 0.03 0.20            # p-values as arguments
  python3 adjust_pvalues.py --file pvals.csv               # CSV with columns: label,p  (header optional)
  options: --alpha 0.05  --method all|bonferroni|holm|bh|by

Methods:
  bonferroni  family-wise error rate (FWER), most conservative
  holm        FWER, uniformly more powerful than Bonferroni (default choice for FWER)
  bh          Benjamini-Hochberg false discovery rate (FDR), assumes independence or positive dependence
  by          Benjamini-Yekutieli FDR, valid under arbitrary dependence (more conservative)

Prints adjusted p-values and reject decisions. Exit 0 on success, 1 on bad input.
"""
import argparse
import csv
import sys


def bonferroni(p):
    m = len(p)
    return [min(1.0, x * m) for x in p]


def holm(p):
    m = len(p)
    order = sorted(range(m), key=lambda i: p[i])
    adj = [0.0] * m
    running = 0.0
    for rank, i in enumerate(order):
        val = min(1.0, (m - rank) * p[i])
        running = max(running, val)          # enforce monotonicity
        adj[i] = running
    return adj


def bh(p, dependent=False):
    m = len(p)
    c = sum(1.0 / k for k in range(1, m + 1)) if dependent else 1.0
    order = sorted(range(m), key=lambda i: p[i], reverse=True)
    adj = [0.0] * m
    running = 1.0
    for idx, i in enumerate(order):
        rank = m - idx                       # 1-based rank in ascending order
        val = min(1.0, p[i] * m * c / rank)
        running = min(running, val)
        adj[i] = running
    return adj


METHODS = {
    "bonferroni": bonferroni,
    "holm": holm,
    "bh": bh,
    "by": lambda p: bh(p, dependent=True),
}


def read_file(path):
    labels, vals = [], []
    with open(path, newline="") as fh:
        for row in csv.reader(fh):
            if not row:
                continue
            try:
                v = float(row[-1])
            except ValueError:
                continue                     # header or junk line
            labels.append(row[0] if len(row) > 1 else f"p{len(vals) + 1}")
            vals.append(v)
    return labels, vals


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("pvalues", nargs="*", type=float)
    ap.add_argument("--file")
    ap.add_argument("--alpha", type=float, default=0.05)
    ap.add_argument("--method", default="all", choices=["all", *METHODS])
    a = ap.parse_args()

    if a.file:
        labels, p = read_file(a.file)
    else:
        p = a.pvalues
        labels = [f"p{i + 1}" for i in range(len(p))]
    if not p:
        print("error: no p-values given", file=sys.stderr)
        return 1
    if any(not 0.0 <= x <= 1.0 for x in p):
        print("error: p-values must be within [0, 1]", file=sys.stderr)
        return 1

    methods = list(METHODS) if a.method == "all" else [a.method]
    adjusted = {m: METHODS[m](p) for m in methods}

    head = f"{'label':14} {'raw p':>9} " + " ".join(f"{m:>12}" for m in methods)
    print(head)
    for i, lab in enumerate(labels):
        cells = " ".join(
            f"{adjusted[m][i]:>9.4f} {'*' if adjusted[m][i] <= a.alpha else ' ':>2}" for m in methods)
        print(f"{lab[:14]:14} {p[i]:>9.4f} {cells}")
    print(f"\n* = reject H0 at alpha {a.alpha} after adjustment (m = {len(p)})")
    for m in methods:
        print(f"{m:>10}: {sum(x <= a.alpha for x in adjusted[m])} of {len(p)} rejected")
    return 0


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.

esc