manus-skills/.kortix/opencode/skills/data/statistical-power/scripts/simulate_power.py
zhukang 1c7abd81d1 All 310 Manus skill-hub community skills as a Kortix marketplace registry
- 310 registry:skill items, 1528 files, 7 categories (hub tags)
- registry.json generated by @kortix/registry buildRegistry
- nested SKILL.md in sn-da-* renamed SUBSKILL.md (no phantom skills)
- provenance + licenses: SOURCES.md
2026-09-15 13:37:20 +08:00

218 lines
7.8 KiB
Python

"""Monte Carlo power for designs with no closed-form formula.
Closed-form power covers a handful of standard tests. For anything else --
logistic/Poisson regression, mixed-effects models, cluster-randomized trials,
survival analysis, mediation, interactions -- you estimate power by simulation:
1. simulate a dataset under the assumed truth
2. analyze it with the EXACT test/model you will use on real data
3. repeat many times; power = fraction of replicates that reach significance
This module provides the harness (`simulate_power`, `find_sample_size`) plus four
worked, runnable examples. Copy an example and swap in your own data-generating
process and analysis -- that is the intended workflow.
Requires: numpy, scipy, statsmodels. The survival example also needs lifelines.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
import numpy as np
@dataclass
class PowerEstimate:
power: float
n_sims: int
n: int
ci_low: float
ci_high: float
def __str__(self):
return (f"n={self.n}: power={self.power:.3f} "
f"(95% MC CI {self.ci_low:.3f}-{self.ci_high:.3f}, {self.n_sims} sims)")
def _wilson_ci(k, n, z=1.96):
"""Wilson score interval for a proportion -- the right CI for a simulated rate."""
if n == 0:
return (0.0, 1.0)
p = k / n
denom = 1 + z**2 / n
center = (p + z**2 / (2 * n)) / denom
half = z * math.sqrt(p * (1 - p) / n + z**2 / (4 * n**2)) / denom
return (max(0.0, center - half), min(1.0, center + half))
def simulate_power(gen_and_test, n, n_sims=2000, alpha=0.05, seed=0):
"""Estimate power at sample size `n`.
gen_and_test(n, rng) -> bool : build a dataset of size n under the assumed
effect, run the planned analysis, and return True iff it is significant.
It receives a numpy Generator `rng` so results are reproducible and each
replicate is independent. (Take alpha into account inside the function,
or compare a returned p-value yourself; see examples.)
Returns a PowerEstimate including a Wilson confidence interval, so you can
tell whether 0.81 vs 0.79 is real or just simulation noise.
"""
rng = np.random.default_rng(seed)
hits = 0
for _ in range(n_sims):
if gen_and_test(n, rng):
hits += 1
lo, hi = _wilson_ci(hits, n_sims)
return PowerEstimate(power=hits / n_sims, n_sims=n_sims, n=n,
ci_low=lo, ci_high=hi)
def find_sample_size(gen_and_test, target_power=0.80, n_sims=2000, alpha=0.05,
lo=10, hi=2000, seed=0, verbose=True):
"""Smallest n reaching target_power, via bisection over n.
Assumes power is (roughly) monotincreasing in n -- true for essentially all
real designs. Uses a fixed seed per n so the search is stable; widen n_sims
near the boundary if the curve is noisy. Returns (n, PowerEstimate).
"""
# expand hi until it clears target (guards against too-small upper bound)
while True:
est_hi = simulate_power(gen_and_test, hi, n_sims, alpha, seed)
if est_hi.power >= target_power or hi >= 1_000_000:
break
lo, hi = hi, hi * 2
best = est_hi
while lo < hi:
mid = (lo + hi) // 2
est = simulate_power(gen_and_test, mid, n_sims, alpha, seed)
if verbose:
print(est)
if est.power >= target_power:
hi, best = mid, est
else:
lo = mid + 1
return hi, best
# ========================================================================== #
# Worked examples -- run this file directly to see them.
# Each is a `gen_and_test(n, rng)` you can adapt.
# ========================================================================== #
def example_two_group_difference(effect=0.5, sd=1.0, alpha=0.05):
"""Two-group difference in means (sanity check vs. the closed-form t-test)."""
from scipy import stats
def gen_and_test(n, rng): # n per group
a = rng.normal(0.0, sd, n)
b = rng.normal(effect, sd, n)
_, p = stats.ttest_ind(a, b)
return p < alpha
return gen_and_test
def example_logistic_regression(beta=0.8, base_rate=0.2, x_sd=1.0, alpha=0.05):
"""Power for a single coefficient in logistic regression.
beta is the log-odds change per 1-SD increase in a continuous predictor x.
No closed form -- this is the canonical reason to simulate.
"""
import statsmodels.api as sm
intercept = math.log(base_rate / (1 - base_rate))
def gen_and_test(n, rng):
x = rng.normal(0, x_sd, n)
logit = intercept + beta * x
p = 1 / (1 + np.exp(-logit))
y = rng.binomial(1, p)
X = sm.add_constant(x)
try:
res = sm.Logit(y, X).fit(disp=0)
return res.pvalues[1] < alpha
except Exception:
return False # non-convergence / perfect separation -> not significant
return gen_and_test
def example_cluster_randomized(effect=0.3, icc=0.05, cluster_size=20,
resid_sd=1.0, alpha=0.05):
"""Cluster-randomized trial: clusters (not individuals) are randomized.
Ignoring the clustering (analyzing individuals as independent) would badly
overstate power -- that is pseudoreplication. Here `n` is the number of
clusters PER ARM; the analysis is a mixed model with a random cluster intercept.
"""
import statsmodels.formula.api as smf
import pandas as pd
# split total variance into between-cluster (tau^2) and residual by the ICC
tau = math.sqrt(icc * resid_sd**2 / (1 - icc)) if icc > 0 else 0.0
def gen_and_test(n, rng): # n clusters per arm
rows = []
cid = 0
for arm in (0, 1):
for _ in range(n):
u = rng.normal(0, tau) # cluster random effect
for _ in range(cluster_size):
y = effect * arm + u + rng.normal(0, resid_sd)
rows.append((y, arm, cid))
cid += 1
df = pd.DataFrame(rows, columns=["y", "arm", "cluster"])
try:
res = smf.mixedlm("y ~ arm", df, groups=df["cluster"]).fit()
return res.pvalues["arm"] < alpha
except Exception:
return False
return gen_and_test
def example_linear_mixed_repeated(effect=0.4, n_timepoints=3, subj_sd=0.7,
resid_sd=1.0, alpha=0.05):
"""Repeated-measures: a linear time trend within subjects, random intercepts.
`n` is the number of subjects; each is measured at n_timepoints occasions.
`effect` is the slope per time unit. Tests whether the time slope != 0.
"""
import statsmodels.formula.api as smf
import pandas as pd
def gen_and_test(n, rng): # n subjects
rows = []
for s in range(n):
b0 = rng.normal(0, subj_sd)
for t in range(n_timepoints):
y = b0 + effect * t + rng.normal(0, resid_sd)
rows.append((y, t, s))
df = pd.DataFrame(rows, columns=["y", "time", "subj"])
try:
res = smf.mixedlm("y ~ time", df, groups=df["subj"]).fit()
return res.pvalues["time"] < alpha
except Exception:
return False
return gen_and_test
if __name__ == "__main__":
print("== two-group difference (compare to closed-form t-test n/group=64) ==")
g = example_two_group_difference(effect=0.5)
print(simulate_power(g, n=64, n_sims=2000))
print("\n== logistic regression, beta=0.8 ==")
g = example_logistic_regression(beta=0.8, base_rate=0.2)
print(simulate_power(g, n=150, n_sims=1000))
print("\n== search: subjects needed for repeated-measures slope=0.4 ==")
g = example_linear_mixed_repeated(effect=0.4, n_timepoints=3)
n, est = find_sample_size(g, target_power=0.80, n_sims=500, lo=10, hi=60,
verbose=False)
print(f"-> need ~{n} subjects ({est})")