Files
magnus919_agent-skills/data-scientist/scripts/effect-size-calculator.py
T
Magnus Hedemark 487f8923dc feat: add data-scientist skill
PhD-level data science expertise with decision framework, five reference
documents (statistical methodology, experimental design, causal inference,
regression modeling, Bayesian workflow), five automation scripts (power
analysis, assumption diagnostics, model comparison, effect size calculator,
experimental design generator), and two report templates.

Python default with --engine r flag for R output. Dual language support.
2026-05-22 16:35:31 -04:00

408 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Effect Size Calculator
Computes effect sizes with confidence intervals for common designs.
Usage:
python effect-size-calculator.py --design cohens-d --mean1 10 --mean2 8 --sd1 2.5 --sd2 2.8 --n1 30 --n2 30
python effect-size-calculator.py --design cohens-d --t-stat 3.41 --df 58
python effect-size-calculator.py --design eta-squared --ss-between 120 --ss-total 350
python effect-size-calculator.py --design cramers-v --chi-sq 12.4 --n 200 --min-dim 2
python effect-size-calculator.py --design odds-ratio --a 45 --b 55 --c 30 --d 70
python effect-size-calculator.py --design correlation --r 0.45 --n 100
python effect-size-calculator.py --design cohens-f2 --r-squared 0.34
python effect-size-calculator.py --design r-squared --r 0.58
python effect-size-calculator.py ... --json
python effect-size-calculator.py ... --engine r
"""
import argparse
import json
import math
import sys
try:
import numpy as np
from scipy import stats as sp_stats
HAS_NUMERIC = True
except ImportError:
HAS_NUMERIC = False
DESIGNS = {
"cohens-d": "Cohen's d (independent groups)",
"cohens-d-paired": "Cohen's d_z (paired groups)",
"hedges-g": "Hedges' g (corrected Cohen's d for small samples)",
"eta-squared": "Eta-squared (ANOVA)",
"partial-eta-squared": "Partial eta-squared (factorial ANOVA)",
"cohens-f": "Cohen's f (ANOVA effect size)",
"cohens-f2": "Cohen's f² (regression effect size)",
"r-squared": "R-squared from correlation",
"cramers-v": "Cramér's V (chi-square associations)",
"odds-ratio": "Odds ratio (2×2 tables)",
"risk-ratio": "Risk ratio / Relative risk (2×2 tables)",
"risk-difference": "Risk difference (2×2 tables)",
"pearson-r": "Pearson correlation",
"cohens-h": "Cohen's h (arcsine transformation for proportions)",
"glass-delta": "Glass's Δ (experimental SD as reference)",
}
def _check_deps():
if not HAS_NUMERIC:
print("Error: scipy required. pip install scipy numpy", file=sys.stderr)
sys.exit(1)
def cohens_d_from_means(m1, m2, sd1, sd2, n1=None, n2=None):
"""Cohen's d with pooled SD."""
pooled_sd = math.sqrt(((n1 - 1) * sd1**2 + (n2 - 1) * sd2**2) / (n1 + n2 - 2)) if n1 and n2 else math.sqrt((sd1**2 + sd2**2) / 2)
d = (m1 - m2) / pooled_sd
return d, pooled_sd
def cohens_d_from_t(t, df, n1, n2):
"""Cohen's d from t-statistic."""
d = t * math.sqrt((n1 + n2) / (n1 * n2))
return d
def hedges_g(d, n1, n2):
"""Convert Cohen's d to Hedges' g (small sample correction)."""
df = n1 + n2 - 2
correction = 1 - (3 / (4 * df - 1))
return d * correction
def ci_cohens_d(d, n1, n2, alpha=0.05):
"""Confidence interval for Cohen's d using non-central t."""
_check_deps()
t_val = d * math.sqrt((n1 * n2) / (n1 + n2))
df = n1 + n2 - 2
try:
# Non-central t confidence interval
ncp_lower = sp_stats.nct.ppf(alpha / 2, df, t_val)
ncp_upper = sp_stats.nct.ppf(1 - alpha / 2, df, t_val)
d_lower = ncp_lower * math.sqrt((n1 + n2) / (n1 * n2))
d_upper = ncp_upper * math.sqrt((n1 + n2) / (n1 * n2))
return round(d_lower, 4), round(d_upper, 4)
except Exception:
# Fallback: delta method approximation
se_d = math.sqrt((n1 + n2) / (n1 * n2) + d**2 / (2 * (n1 + n2)))
z = sp_stats.norm.ppf(1 - alpha / 2)
return round(d - z * se_d, 4), round(d + z * se_d, 4)
def eta_squared(ss_between, ss_total):
"""η² = SS_between / SS_total"""
return ss_between / ss_total
def cohens_f_from_eta2(eta2):
"""f = sqrt(η² / (1 - η²))"""
return math.sqrt(eta2 / (1 - eta2))
def cohens_f2_from_r2(r2):
"""f² = R² / (1 - R²)"""
return r2 / (1 - r2)
def cramers_v(chi2, n, min_dim):
"""V = sqrt(χ² / (n * min(r-1, c-1)))"""
return math.sqrt(chi2 / (n * min_dim))
def odds_ratio(a, b, c, d):
"""OR = (a/c) / (b/d) = ad / bc"""
return (a * d) / (b * c)
def risk_ratio(a, b, c, d):
"""RR = (a/(a+b)) / (c/(c+d))"""
p1 = a / (a + b) if (a + b) > 0 else 0
p2 = c / (c + d) if (c + d) > 0 else 0
return p1 / p2 if p2 > 0 else float('inf')
def risk_difference(a, b, c, d):
"""RD = a/(a+b) - c/(c+d)"""
p1 = a / (a + b) if (a + b) > 0 else 0
p2 = c / (c + d) if (c + d) > 0 else 0
return p1 - p2
def ci_odds_ratio(a, b, c, d, alpha=0.05):
"""Woolf's CI for odds ratio."""
_check_deps()
or_val = odds_ratio(a, b, c, d)
se_log_or = math.sqrt(1/a + 1/b + 1/c + 1/d)
z = sp_stats.norm.ppf(1 - alpha / 2)
lo = math.exp(math.log(or_val) - z * se_log_or)
hi = math.exp(math.log(or_val) + z * se_log_or)
return round(lo, 4), round(hi, 4)
def ci_pearson_r(r, n, alpha=0.05):
"""Fisher z-transformation CI for Pearson r."""
_check_deps()
z_r = 0.5 * math.log((1 + r) / (1 - r))
se_z = 1 / math.sqrt(n - 3)
z = sp_stats.norm.ppf(1 - alpha / 2)
lo = math.tanh(z_r - z * se_z)
hi = math.tanh(z_r + z * se_z)
return round(lo, 4), round(hi, 4)
def cohens_h(p1, p2):
"""h = 2 * arcsin(sqrt(p1)) - 2 * arcsin(sqrt(p2))"""
return 2 * math.asin(math.sqrt(p1)) - 2 * math.asin(math.sqrt(p2))
def r_squared_from_r(r):
"""R² = r²"""
return r ** 2
def interpret_cohens_d(d):
if abs(d) < 0.2:
return "negligible"
elif abs(d) < 0.5:
return "small"
elif abs(d) < 0.8:
return "medium"
else:
return "large"
def run(args):
result = {}
if args.design == "cohens-d":
if args.mean1 is not None and args.mean2 is not None and args.sd1 is not None and args.sd2 is not None and args.n1 and args.n2:
d, pooled = cohens_d_from_means(args.mean1, args.mean2, args.sd1, args.sd2, args.n1, args.n2)
ci = ci_cohens_d(d, args.n1, args.n2)
result = {
"effect_size": "Cohen's d",
"d": round(d, 4),
"pooled_sd": round(pooled, 4),
"interpretation": interpret_cohens_d(d),
"ci_95": ci,
"parameters": {"mean1": args.mean1, "mean2": args.mean2, "n1": args.n1, "n2": args.n2}
}
elif args.t_stat is not None and args.df is not None:
# Need n1 and n2 to compute d from t
result = {"error": "t-statistic requires also --n1 and --n2 for Cohen's d"}
else:
result = {"error": "Provide --mean1 --mean2 --sd1 --sd2 --n1 --n2"}
elif args.design == "hedges-g":
if args.mean1 is not None and args.mean2 is not None and args.sd1 is not None and args.sd2 is not None and args.n1 and args.n2:
d, pooled = cohens_d_from_means(args.mean1, args.mean2, args.sd1, args.sd2, args.n1, args.n2)
g = hedges_g(d, args.n1, args.n2)
result = {
"effect_size": "Hedges' g",
"g": round(g, 4),
"cohens_d": round(d, 4),
"interpretation": interpret_cohens_d(d),
"parameters": {"mean1": args.mean1, "mean2": args.mean2, "n1": args.n1, "n2": args.n2}
}
else:
result = {"error": "Provide --mean1 --mean2 --sd1 --sd2 --n1 --n2"}
elif args.design == "eta-squared":
if args.ss_between is not None and args.ss_total is not None:
eta2 = eta_squared(args.ss_between, args.ss_total)
f = cohens_f_from_eta2(eta2)
result = {
"effect_size": "Eta-squared",
"eta_squared": round(eta2, 4),
"cohens_f": round(f, 4),
"interpretation": "small" if eta2 < 0.01 else ("medium" if eta2 < 0.06 else ("large" if eta2 < 0.14 else "very large")),
}
else:
result = {"error": "Provide --ss-between and --ss-total"}
elif args.design == "cohens-f2":
if args.r_squared is not None:
f2 = cohens_f2_from_r2(args.r_squared)
result = {
"effect_size": "Cohen's f²",
"f_squared": round(f2, 4),
"r_squared": args.r_squared,
"interpretation": "small" if f2 < 0.02 else ("medium" if f2 < 0.15 else "large"),
}
else:
result = {"error": "Provide --r-squared"}
elif args.design == "cramers-v":
if args.chi_sq is not None and args.n is not None and args.min_dim is not None:
v = cramers_v(args.chi_sq, args.n, args.min_dim)
result = {
"effect_size": "Cramér's V",
"V": round(v, 4),
"parameters": {"chi_sq": args.chi_sq, "n": args.n, "min_dim": args.min_dim},
"interpretation": "small" if v < 0.1 else ("medium" if v < 0.3 else "large"),
}
else:
result = {"error": "Provide --chi-sq --n --min-dim"}
elif args.design == "odds-ratio":
if args.a is not None and args.b is not None and args.c is not None and args.d is not None:
or_val = odds_ratio(args.a, args.b, args.c, args.d)
ci = ci_odds_ratio(args.a, args.b, args.c, args.d)
rr = risk_ratio(args.a, args.b, args.c, args.d)
rd = risk_difference(args.a, args.b, args.c, args.d)
result = {
"effect_size": "Odds ratio",
"OR": round(or_val, 4),
"ci_95": ci,
"RR": round(rr, 4),
"risk_difference": round(rd, 4),
"log_OR": round(math.log(or_val), 4),
"interpretation": "OR = 1 (no effect)" if 0.95 <= or_val <= 1.05 else
f"OR > 1 (increased odds)" if or_val > 1 else "OR < 1 (decreased odds)",
}
else:
result = {"error": "Provide --a --b --c --d (2×2 table counts)"}
elif args.design == "risk-ratio":
if args.a is not None and args.b is not None and args.c is not None and args.d is not None:
rr = risk_ratio(args.a, args.b, args.c, args.d)
result = {
"effect_size": "Risk ratio",
"RR": round(rr, 4),
"interpretation": "RR = 1 (no effect)" if 0.95 <= rr <= 1.05 else ("RR > 1 (increased risk)" if rr > 1 else "RR < 1 (decreased risk)"),
}
else:
result = {"error": "Provide --a --b --c --d"}
elif args.design == "risk-difference":
if args.a is not None and args.b is not None and args.c is not None and args.d is not None:
rd = risk_difference(args.a, args.b, args.c, args.d)
result = {
"effect_size": "Risk difference",
"RD": round(rd, 4),
"interpretation": f"Absolute difference of {abs(rd):.1%} in risk",
}
else:
result = {"error": "Provide --a --b --c --d"}
elif args.design in ("pearson-r", "correlation"):
if args.r is not None and args.n is not None:
ci = ci_pearson_r(args.r, args.n)
result = {
"effect_size": "Pearson r",
"r": args.r,
"r_squared": round(args.r ** 2, 4),
"ci_95": ci,
"n": args.n,
"interpretation": "small" if abs(args.r) < 0.1 else ("medium" if abs(args.r) < 0.3 else "large"),
}
else:
result = {"error": "Provide --r and --n"}
elif args.design == "r-squared":
if args.r is not None:
r2 = r_squared_from_r(args.r)
result = {
"effect_size": "R-squared",
"r_squared": round(r2, 4),
"r": args.r,
}
else:
result = {"error": "Provide --r"}
elif args.design == "cohens-h":
if args.p1 is not None and args.p2 is not None:
h = cohens_h(args.p1, args.p2)
result = {
"effect_size": "Cohen's h",
"h": round(h, 4),
"parameters": {"p1": args.p1, "p2": args.p2},
"interpretation": "small" if abs(h) < 0.2 else ("medium" if abs(h) < 0.5 else "large"),
}
else:
result = {"error": "Provide --p1 and --p2"}
else:
result = {"error": f"Unknown design '{args.design}'"}
if "error" in result:
print(f"Error: {result['error']}", file=sys.stderr)
sys.exit(1)
if args.json:
print(json.dumps(result, indent=2, default=str))
else:
print(f"## {result.get('effect_size', 'Effect Size')}")
print(f"Value: {result.get(list(result.keys())[1], '?')}")
if "ci_95" in result:
print(f"95% CI: ({result['ci_95'][0]:.4f}, {result['ci_95'][1]:.4f})")
if "interpretation" in result:
print(f"Interpretation: {result['interpretation']}")
if "r_squared" in result:
print(f"R²: {result['r_squared']}")
print()
# Report template
print("Reporting template:")
if result.get("effect_size") == "Cohen's d":
print(f" d = {result['d']:.2f}, 95% CI [{result['ci_95'][0]:.2f}, {result['ci_95'][1]:.2f}]")
elif result.get("effect_size") == "Odds ratio":
print(f" OR = {result['OR']:.2f}, 95% CI [{result['ci_95'][0]:.2f}, {result['ci_95'][1]:.2f}]")
elif result.get("effect_size") == "Cramér's V":
print(f" V = {result['V']:.2f}")
if args.engine == "r":
print("\n--- R equivalent ---")
print("library(effectsize)")
if result.get("effect_size") == "Cohen's d":
print(f"cohens_d({args.mean1}, {args.mean2}, pooled_sd = TRUE)" if args.mean1 else "# Provide data vectors")
elif result.get("effect_size") == "Pearson r":
print(f"library(psych); r.con(r = {args.r}, n = {args.n}, p = 0.95)")
elif result.get("effect_size") == "Cramér's V":
print(f"cramers_v(chi2 = {args.chi_sq}, n = {args.n}, nrow = {args.min_dim + 1})")
def main():
parser = argparse.ArgumentParser(description="Effect Size Calculator")
parser.add_argument("--design", choices=list(DESIGNS.keys()), required=True,
help="Effect size type")
# Means/SDs
parser.add_argument("--mean1", type=float)
parser.add_argument("--mean2", type=float)
parser.add_argument("--sd1", type=float)
parser.add_argument("--sd2", type=float)
parser.add_argument("--n1", type=int)
parser.add_argument("--n2", type=int)
parser.add_argument("--t-stat", type=float)
parser.add_argument("--df", type=int)
# ANOVA
parser.add_argument("--ss-between", type=float)
parser.add_argument("--ss-total", type=float)
# Regression
parser.add_argument("--r-squared", type=float)
parser.add_argument("--r", type=float)
# Categorical
parser.add_argument("--chi-sq", type=float)
parser.add_argument("--n", type=int)
parser.add_argument("--min-dim", type=int, help="Cramér's V: min(rows-1, cols-1). For a 2×2 table pass 1; for 3×4 table pass 2 (min(2,3)=2)")
parser.add_argument("--a", type=float)
parser.add_argument("--b", type=float)
parser.add_argument("--c", type=float)
parser.add_argument("--d", type=float)
# Proportions
parser.add_argument("--p1", type=float)
parser.add_argument("--p2", type=float)
# General
parser.add_argument("--json", action="store_true", help="Output as JSON")
parser.add_argument("--engine", choices=["python", "r"], default="python",
help="Output language")
args = parser.parse_args()
run(args)
if __name__ == "__main__":
main()