- 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
218 lines
7.8 KiB
Python
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})")
|