import streamlit as st import numpy as np import pandas as pd import plotly.graph_objects as go from scipy.stats import norm def _solve_with_mode( solver, analysis_mode, effect_size=None, nobs=None, alpha=None, power=None, nobs_name="nobs", **extra_kw, ): """Call solver.solve_power with the parameter to solve for set to None.""" effective = {} for k, v in [("effect_size", effect_size), ("alpha", alpha), ("power", power)]: if v is not None: effective[k] = v effective[nobs_name] = nobs effective.update(extra_kw) if analysis_mode == "Post Hoc": effective["power"] = None elif analysis_mode == "Sensitivity": effective["effect_size"] = None elif analysis_mode == "Criterion": effective["alpha"] = None else: # A Priori (or Compromise, handled separately) effective[nobs_name] = None return solver.solve_power(**effective) def _solve_n_binary(func_n, target_n, lo=2, hi=1000000): """Binary search an integer N to make func_n(N) >= target_n (max 100 iterations).""" for _ in range(100): mid = (lo + hi) // 2 if func_n(mid): hi = mid else: lo = mid + 1 if lo >= hi: break return hi def _solve_compromise( solver, effect_size, nobs, cost_ratio, alternative="two-sided", nobs_name="nobs", **extra_kw, ): """Brent's method to find α such that β/α = cost_ratio for given N and effect size. Returns dict {'alpha': adjusted_alpha, 'power': achieved_power}. """ from scipy.optimize import brentq def f(alpha_candidate): kw = { nobs_name: nobs, "effect_size": effect_size, "alpha": alpha_candidate, "power": None, } kw.update(extra_kw) try: achieved = solver.solve_power(**kw) except Exception: return 1e6 beta = max(1e-10, 1 - achieved) return beta / alpha_candidate - cost_ratio lo, hi = 1e-8, 0.5 f_lo, f_hi = f(lo), f(hi) if f_lo * f_hi > 0: return None alpha_solved = brentq(f, lo, hi) kw = { nobs_name: nobs, "effect_size": effect_size, "alpha": alpha_solved, "power": None, } kw.update(extra_kw) power_solved = solver.solve_power(**kw) return {"alpha": alpha_solved, "power": power_solved} def render_power_calculator(params, analysis_mode="A Priori"): """Render power analysis results (a_priori / post_hoc / sensitivity / criterion / compromise).""" atype = params["type"] alpha = params["alpha"] power = params["power"] tails = params["tails"] alternative = "two-sided" if tails == "Two-tailed" else "larger" # Determine z critical values if alternative == "two-sided": z_alpha = norm.ppf(1 - alpha / 2) else: z_alpha = norm.ppf(1 - alpha) z_beta = norm.ppf(power) _z_sub = r"z_{1-\alpha/2}" if alternative == "two-sided" else r"z_{1-\alpha}" # Extract global adjustment parameters (with defaults for backward compat) dropout_rate = params.get("dropout_rate", 0.0) num_tests = params.get("num_tests", 1) mc_method = params.get("mc_method", "None") cost_per = params.get("cost_per", 0.0) recruitment_rate = params.get("recruitment_rate", 0.0) # Apply multiple testing correction alpha_raw = alpha if num_tests > 1 and mc_method != "None": if mc_method == "Bonferroni" or mc_method == "Holm-Bonferroni": alpha = alpha / num_tests elif mc_method == "Benjamini-Hochberg (FDR)": alpha = alpha * (num_tests + 1) / (2 * num_tests) if alternative == "two-sided": z_alpha = norm.ppf(1 - alpha / 2) else: z_alpha = norm.ppf(1 - alpha) # --- Computation --- result = None n_total = None n_total_raw = None n_per_group = None explanation = "" formula_latex = "" computed_value = None computed_label = "" _skip_computation = False # Compromise mode: generic solver using z-approximation for all test types if analysis_mode == "Compromise": from scipy.optimize import brentq n_total = params.get("n_total", 0) if n_total > 0: es_val = params.get("effect_size") n_eff = n_total if atype == "one_prop": from statsmodels.stats.proportion import proportion_effectsize es_val = abs( proportion_effectsize(params["prop_alt"], params["prop_null"]) ) elif atype == "two_prop": from statsmodels.stats.proportion import proportion_effectsize es_val = abs(proportion_effectsize(params["p2"], params["p1"])) ratio = params.get("ratio", 1.0) n_eff = int(n_total / (1 + ratio)) if ratio > 0 else int(n_total / 2) elif atype == "mannwhitney": es_val = np.sqrt(3) * (params["effect_size"] - 0.5) ratio = params.get("ratio", 1.0) n_eff = int(n_total / (1 + ratio)) if ratio > 0 else int(n_total / 2) elif atype == "regression": es_val = params.get("effect_size", 0.15) elif atype == "logistic": ev_cr = params.get("event_rate", 0.3) or_cr = params.get("or", 2.0) p1_cr = (or_cr * ev_cr) / (1 - ev_cr + or_cr * ev_cr) d_cr = abs(p1_cr - ev_cr) p_bar_cr = (ev_cr + p1_cr) / 2 se_cr = np.sqrt(2 * p_bar_cr * (1 - p_bar_cr)) es_val = d_cr / se_cr if se_cr > 0 else 0 if es_val is not None and es_val > 0: def f(alpha_candidate): z_a = ( norm.ppf(1 - alpha_candidate / 2) if alternative == "two-sided" else norm.ppf(1 - alpha_candidate) ) z_b = es_val * np.sqrt(n_eff) - z_a achieved = norm.cdf(z_b) beta = max(1e-10, 1 - achieved) return beta / alpha_candidate - params.get("cost_ratio", 1.0) lo, hi = 1e-8, 0.5 try: if f(lo) * f(hi) < 0: alpha_solved = brentq(f, lo, hi) z_a_solved = ( norm.ppf(1 - alpha_solved / 2) if alternative == "two-sided" else norm.ppf(1 - alpha_solved) ) z_b_solved = es_val * np.sqrt(n_eff) - z_a_solved power_solved = norm.cdf(z_b_solved) computed_value = {"alpha": alpha_solved, "power": power_solved} n_per_group = n_total _skip_computation = True except Exception: pass if computed_value is None: explanation = "Compromise analysis could not converge with current parameters. Try adjusting N or cost ratio." else: explanation = ( f"Compromise power analysis with N = {n_total}, " f"effect size = {es_val:.4f}, cost ratio β/α = {params.get('cost_ratio', 1.0):.2f}. " f"Adjusted α = {computed_value['alpha']:.4f}, achieved power = {computed_value['power']:.1%}." ) formula_latex = r"\text{Find } \alpha, \beta \text{ s.t. } \beta/\alpha = q \text{ via } \Phi^{-1}" if _skip_computation: pass elif atype == "one_mean": d = params["effect_size"] from statsmodels.stats.power import TTestPower solver = TTestPower() if d > 0: if analysis_mode == "Compromise": n_total = params.get("n_total", 0) n_per_group = n_total comp = _solve_compromise( solver, d, n_total, params.get("cost_ratio", 1.0), alternative=alternative, ) computed_value = comp else: raw = _solve_with_mode( solver, analysis_mode, effect_size=d, nobs=params.get("n_total"), alpha=alpha, power=power, alternative=alternative, ) if analysis_mode == "A Priori": n_total = int(np.ceil(raw)) n_per_group = n_total else: n_total = params.get("n_total", 0) n_per_group = n_total computed_value = raw if analysis_mode == "A Priori": explanation = ( f"Required total sample size for a one-sample {tails.lower()} t-test " f"to detect Cohen's d = {d:.3f} with α = {alpha} and power = {power}." ) formula_latex = ( rf"n = \left( \frac{{{_z_sub} + z_{{1-\beta}}}}{{d}} \right)^2" ) elif analysis_mode == "Post Hoc": explanation = ( f"Achieved power for a one-sample {tails.lower()} t-test " f"with N = {n_total}, Cohen's d = {d:.3f}, α = {alpha}." ) formula_latex = rf"\text{{Power}} = \int_{{t_\text{{crit}}}}^{{\infty}} f(t; n-1, \delta) dt" elif analysis_mode == "Sensitivity": explanation = ( f"Minimum detectable effect size for a one-sample {tails.lower()} t-test " f"with N = {n_total}, α = {alpha}, power = {power}." ) formula_latex = ( rf"d \approx \frac{{{_z_sub} + z_{{1-\beta}}}}{{\sqrt{{n}}}}" ) elif analysis_mode == "Criterion": explanation = ( f"Required significance level for a one-sample {tails.lower()} t-test " f"with N = {n_total}, Cohen's d = {d:.3f}, power = {power}." ) formula_latex = rf"\alpha = \int_{{t_\text{{crit}}}}^{{\infty}} f(t; n-1, \delta = 0) dt \quad \text{{where }} t_\text{{crit}} = t_{{1-\alpha, n-1}}" elif analysis_mode == "Compromise" and isinstance(computed_value, dict): explanation = ( f"Compromise power analysis for a one-sample {tails.lower()} t-test " f"with N = {n_total}, Cohen's d = {d:.3f}, " f"cost ratio β/α = {params.get('cost_ratio', 1.0):.2f}. " f"Adjusted α = {computed_value['alpha']:.4f}, achieved power = {computed_value['power']:.1%}." ) formula_latex = rf""" \beta = q \cdot \alpha \quad \text{{Find }} \alpha, \beta \text{ via } \Phi^{-1} \alpha = 2 \left(1 - \Phi\left(c)\right) \beta = \Phi \left(c - \delta \right) -\Phi\left(-c - \delta \right) \delta = d \sqrt{{n}} \quad c = z_{{1-\alpha/2}} \text{{ or }} z_{{1-\alpha}} """ elif atype == "two_means": d = params["effect_size"] ratio = params["ratio"] from statsmodels.stats.power import TTestIndPower solver = TTestIndPower() if d > 0: raw = _solve_with_mode( solver, analysis_mode, effect_size=d, nobs=params.get("n_total"), alpha=alpha, power=power, nobs_name="nobs1", ratio=ratio, alternative=alternative, ) if analysis_mode == "A Priori": n1 = int(np.ceil(raw)) n2 = int(np.ceil(n1 * ratio)) n_total = n1 + n2 n_per_group = (n1, n2) else: n_total = params.get("n_total", 0) n1 = int(np.ceil(n_total / (1 + ratio))) if ratio > 0 else 0 n2 = n_total - n1 n_per_group = (n1, n2) computed_value = raw if analysis_mode == "A Priori": explanation = ( f"Required sample size per group for an independent {tails.lower()} t-test " f"to detect Cohen's d = {d:.3f} with α = {alpha} and power = {power}, " f"allocation ratio n₂/n₁ = {ratio:.2f}." ) elif analysis_mode == "Post Hoc": explanation = ( f"Achieved power for an independent {tails.lower()} t-test " f"with N = {n_total}, Cohen's d = {d:.3f}, α = {alpha}, ratio = {ratio:.2f}." ) elif analysis_mode == "Sensitivity": explanation = ( f"Minimum detectable effect size for an independent {tails.lower()} t-test " f"with N = {n_total}, α = {alpha}, power = {power}, ratio = {ratio:.2f}." ) elif analysis_mode == "Criterion": explanation = ( f"Required significance level for an independent {tails.lower()} t-test " f"with N = {n_total}, Cohen's d = {d:.3f}, power = {power}, ratio = {ratio:.2f}." ) formula_latex = rf"n_1 = 2 \left( \frac{{{_z_sub} + z_{{1-\beta}}}}{{d}} \right)^2 \quad n_2 = r \cdot n_1" elif atype == "paired": d = params["effect_size"] from statsmodels.stats.power import TTestPower solver = TTestPower() if d > 0: raw = _solve_with_mode( solver, analysis_mode, effect_size=d, nobs=params.get("n_total"), alpha=alpha, power=power, alternative=alternative, ) if analysis_mode == "A Priori": n_total = int(np.ceil(raw)) n_per_group = n_total else: n_total = params.get("n_total", 0) n_per_group = n_total computed_value = raw if analysis_mode == "A Priori": explanation = ( f"Required number of pairs for a paired {tails.lower()} t-test " f"to detect Cohen's d_z = {d:.3f} with α = {alpha} and power = {power}." ) elif analysis_mode == "Post Hoc": explanation = ( f"Achieved power for a paired {tails.lower()} t-test " f"with {n_total} pairs, Cohen's d_z = {d:.3f}, α = {alpha}." ) elif analysis_mode == "Sensitivity": explanation = ( f"Minimum detectable effect size for a paired {tails.lower()} t-test " f"with {n_total} pairs, α = {alpha}, power = {power}." ) elif analysis_mode == "Criterion": explanation = ( f"Required significance level for a paired {tails.lower()} t-test " f"with {n_total} pairs, Cohen's d_z = {d:.3f}, power = {power}." ) formula_latex = ( rf"n = \left( \frac{{{_z_sub} + z_{{1-\beta}}}}{{d_z}} \right)^2" ) elif atype == "one_prop": p0 = params["prop_null"] p1 = params["prop_alt"] from statsmodels.stats.proportion import proportion_effectsize from statsmodels.stats.power import NormalIndPower d_eff = proportion_effectsize(p1, p0) solver = NormalIndPower() if abs(d_eff) > 0: raw = _solve_with_mode( solver, analysis_mode, effect_size=abs(d_eff), nobs=params.get("n_total"), alpha=alpha, power=power, nobs_name="nobs1", alternative=alternative, ) if analysis_mode == "A Priori": n_total = int(np.ceil(raw)) n_per_group = n_total else: n_total = params.get("n_total", 0) n_per_group = n_total computed_value = raw if analysis_mode == "A Priori": explanation = ( f"Required sample size for a one-sample proportion test " f"to detect a difference from {p0} to {p1} " f"with α = {alpha} and power = {power}." ) elif analysis_mode == "Post Hoc": explanation = ( f"Achieved power for a one-sample proportion test " f"with N = {n_total}, difference {p0} → {p1}, α = {alpha}." ) elif analysis_mode == "Sensitivity": explanation = ( f"Minimum detectable proportion difference " f"for a one-sample proportion test with N = {n_total}, α = {alpha}, power = {power}." ) elif analysis_mode == "Criterion": explanation = ( f"Required significance level for a one-sample proportion test " f"with N = {n_total}, difference {p0} → {p1}, power = {power}." ) formula_latex = rf"n = \left( \frac{{{_z_sub} \sqrt{{p_0(1-p_0)}} + z_{{1-\beta}} \sqrt{{p_1(1-p_1)}}}}{{{{p_1 - p_0}}}} \right)^2" elif atype == "two_prop": p1 = params["p1"] p2 = params["p2"] ratio = params["ratio"] from statsmodels.stats.proportion import proportion_effectsize from statsmodels.stats.power import NormalIndPower d_eff = proportion_effectsize(p2, p1) solver = NormalIndPower() if abs(d_eff) > 0: raw = _solve_with_mode( solver, analysis_mode, effect_size=abs(d_eff), nobs=params.get("n_total"), alpha=alpha, power=power, nobs_name="nobs1", ratio=ratio, alternative=alternative, ) if analysis_mode == "A Priori": n1 = int(np.ceil(raw)) n2 = int(np.ceil(n1 * ratio)) n_total = n1 + n2 n_per_group = (n1, n2) else: n_total = params.get("n_total", 0) n1 = int(np.ceil(n_total / (1 + ratio))) if ratio > 0 else 0 n2 = n_total - n1 n_per_group = (n1, n2) computed_value = raw if analysis_mode == "A Priori": explanation = ( f"Required sample size per group for a two-proportion z-test " f"to detect a difference between {p1} and {p2} " f"with α = {alpha}, power = {power}, ratio = {ratio:.2f}." ) elif analysis_mode == "Post Hoc": explanation = ( f"Achieved power for a two-proportion z-test " f"with N = {n_total}, proportions {p1} vs {p2}, α = {alpha}, ratio = {ratio:.2f}." ) elif analysis_mode == "Sensitivity": explanation = ( f"Minimum detectable proportion difference " f"for a two-proportion z-test with N = {n_total}, α = {alpha}, power = {power}, ratio = {ratio:.2f}." ) elif analysis_mode == "Criterion": explanation = ( f"Required significance level for a two-proportion z-test " f"with N = {n_total}, proportions {p1} vs {p2}, power = {power}, ratio = {ratio:.2f}." ) formula_latex = rf"n_1 = \left( \frac{{{_z_sub} \sqrt{{2\bar{{p}}(1-\bar{{p}})}} + z_{{1-\beta}} \sqrt{{p_1(1-p_1) + p_2(1-p_2)}}}}{{{{p_1 - p_2}}}} \right)^2" elif atype == "anova": f_eff = params["effect_size"] k = params["k"] from statsmodels.stats.power import FTestAnovaPower solver = FTestAnovaPower() if f_eff > 0: raw = _solve_with_mode( solver, analysis_mode, effect_size=f_eff, nobs=params.get("n_total"), alpha=alpha, power=power, k_groups=k, ) if analysis_mode == "A Priori": n_per_g = int(np.ceil(raw)) n_total = n_per_g * k n_per_group = n_per_g else: n_total = params.get("n_total", 0) n_per_g = max(1, n_total // k) n_per_group = n_per_g computed_value = raw if analysis_mode == "A Priori": explanation = ( f"Required sample size per group for a one-way ANOVA with {k} groups " f"to detect Cohen's f = {f_eff:.3f} with α = {alpha} and power = {power}." ) elif analysis_mode == "Post Hoc": explanation = ( f"Achieved power for a one-way ANOVA with {k} groups " f"with N = {n_total}, Cohen's f = {f_eff:.3f}, α = {alpha}." ) elif analysis_mode == "Sensitivity": explanation = ( f"Minimum detectable effect size for a one-way ANOVA with {k} groups " f"with N = {n_total}, α = {alpha}, power = {power}." ) elif analysis_mode == "Criterion": explanation = ( f"Required significance level for a one-way ANOVA with {k} groups " f"with N = {n_total}, Cohen's f = {f_eff:.3f}, power = {power}." ) formula_latex = r"n = \frac{\text{from non-central }F\text{ distribution}}{k} \quad f = \frac{\sigma_{\text{between}}}{\sigma_{\text{within}}}" elif atype == "correlation": r_val = params["effect_size"] import math as cmath fisher_z = cmath.atanh(r_val) if analysis_mode == "A Priori": n_total = int(np.ceil(3 + ((z_alpha + z_beta) / fisher_z) ** 2)) elif analysis_mode == "Post Hoc": n_total = params.get("n_total", 0) z_beta_c = (cmath.sqrt(max(0, n_total - 3)) * fisher_z) - z_alpha computed_value = norm.cdf(z_beta_c) elif analysis_mode == "Sensitivity": n_total = params.get("n_total", 0) z_beta_c = norm.ppf(power) fisher_z_c = (z_alpha + z_beta_c) / cmath.sqrt(max(1, n_total - 3)) computed_value = cmath.tanh(fisher_z_c) elif analysis_mode == "Criterion": n_total = params.get("n_total", 0) z_beta_c = norm.ppf(power) z_alpha_c = cmath.sqrt(max(0, n_total - 3)) * fisher_z - z_beta_c computed_value = 2 * (1 - norm.cdf(z_alpha_c)) n_per_group = n_total if analysis_mode == "A Priori": explanation = ( f"Required sample size to detect a Pearson correlation of r = {r_val:.3f} " f"with α = {alpha} and power = {power} ({tails.lower()}), " f"based on Fisher's z-transformation." ) elif analysis_mode == "Post Hoc": explanation = ( f"Achieved power to detect Pearson correlation r = {r_val:.3f} " f"with N = {n_total}, α = {alpha}." ) elif analysis_mode == "Sensitivity": explanation = ( f"Minimum detectable Pearson correlation " f"with N = {n_total}, α = {alpha}, power = {power}." ) elif analysis_mode == "Criterion": explanation = ( f"Required significance level to detect Pearson correlation r = {r_val:.3f} " f"with N = {n_total}, power = {power}." ) formula_latex = rf"n = 3 + \left( \frac{{{_z_sub} + z_{{1-\beta}}}}{{\text{{arctanh}}(r)}} \right)^2" elif atype == "regression": f2 = params["effect_size"] k = params["k"] if f2 > 0: from scipy.stats import ncf as noncentral_f, f as f_dist if analysis_mode == "A Priori": for n_candidate in range(k + 2, 10000): dfd = n_candidate - k - 1 ncp = f2 * n_candidate f_crit = f_dist.ppf(1 - alpha, k, dfd) pwr_cur = 1 - noncentral_f.cdf(f_crit, k, dfd, ncp) if pwr_cur >= power: n_total = n_candidate break elif analysis_mode == "Post Hoc": n_total = params.get("n_total", 0) dfd = n_total - k - 1 ncp = f2 * n_total f_crit = f_dist.ppf(1 - alpha, k, dfd) computed_value = 1 - noncentral_f.cdf(f_crit, k, dfd, ncp) elif analysis_mode == "Sensitivity": n_total = params.get("n_total", 0) from scipy.optimize import brentq def power_for_f2(f2_try): dfd = n_total - k - 1 ncp = f2_try * n_total f_crit = f_dist.ppf(1 - alpha, k, dfd) return 1 - noncentral_f.cdf(f_crit, k, dfd, ncp) - power computed_value = brentq(power_for_f2, 1e-6, 5.0) elif analysis_mode == "Criterion": n_total = params.get("n_total", 0) from scipy.optimize import brentq def power_for_alpha(a_try): dfd = n_total - k - 1 ncp = f2 * n_total f_crit = f_dist.ppf(1 - a_try, k, dfd) return 1 - noncentral_f.cdf(f_crit, k, dfd, ncp) - power computed_value = brentq(power_for_alpha, 1e-8, 0.5) n_per_group = n_total if analysis_mode == "A Priori": explanation = ( f"Required total sample size for multiple linear regression " f"with {k} predictor(s) to detect Cohen's f² = {f2:.3f} " f"(R² = {f2 / (1 + f2):.3f}) with α = {alpha} and power = {power}." ) elif analysis_mode == "Post Hoc": explanation = ( f"Achieved power for multiple linear regression " f"with {k} predictor(s), N = {n_total}, Cohen's f² = {f2:.3f}, α = {alpha}." ) elif analysis_mode == "Sensitivity": explanation = ( f"Minimum detectable Cohen's f² for multiple linear regression " f"with {k} predictor(s), N = {n_total}, α = {alpha}, power = {power}." ) elif analysis_mode == "Criterion": explanation = ( f"Required significance level for multiple linear regression " f"with {k} predictor(s), N = {n_total}, Cohen's f² = {f2:.3f}, power = {power}." ) formula_latex = r"n = \text{from non-central }F\text{ distribution} \quad f^2 = \frac{R^2}{1-R^2}" elif atype == "logistic": k = params["k"] ev_rate = params["event_rate"] or_val = params["or"] p1_log = (or_val * ev_rate) / (1 - ev_rate + or_val * ev_rate) d_log = abs(p1_log - ev_rate) p_bar = (ev_rate + p1_log) / 2 from statsmodels.stats.power import NormalIndPower solver = NormalIndPower() se = np.sqrt(2 * p_bar * (1 - p_bar)) d_eff_log = d_log / se if se > 0 else 0 if d_eff_log > 0: raw = _solve_with_mode( solver, analysis_mode, effect_size=d_eff_log, nobs=params.get("n_total"), alpha=alpha, power=power, nobs_name="nobs1", alternative=alternative, ) if analysis_mode == "A Priori": n_base = int(np.ceil(raw)) n_total = max(n_base, 10 * k) else: n_total = params.get("n_total", 0) n_per_group = n_total computed_value = raw if analysis_mode == "A Priori": explanation = ( f"Required total sample size for logistic regression " f"with {k} predictor(s) to detect OR = {or_val:.2f} " f"with baseline event rate = {ev_rate:.2f}, α = {alpha}, power = {power}. " f"Lower bound of 10 × {k} = {10 * k} events per predictor applied." ) elif analysis_mode == "Post Hoc": explanation = ( f"Achieved power for logistic regression " f"with {k} predictor(s), N = {n_total}, OR = {or_val:.2f}, " f"event rate = {ev_rate:.2f}, α = {alpha}." ) elif analysis_mode == "Sensitivity": explanation = ( f"Minimum detectable odds ratio for logistic regression " f"with {k} predictor(s), N = {n_total}, event rate = {ev_rate:.2f}, " f"α = {alpha}, power = {power}." ) elif analysis_mode == "Criterion": explanation = ( f"Required significance level for logistic regression " f"with {k} predictor(s), N = {n_total}, OR = {or_val:.2f}, " f"event rate = {ev_rate:.2f}, power = {power}." ) formula_latex = rf"n = \frac{{({_z_sub} + z_{{1-\beta}})^2 \bar{{p}}(1-\bar{{p}})}}{{{{(p_1 - p_0)^2}}}} \quad \text{{min }} 10k" elif atype == "chisq": w = params["effect_size"] df = params["df"] from statsmodels.stats.power import GofChisquarePower solver = GofChisquarePower() if w > 0: raw = _solve_with_mode( solver, analysis_mode, effect_size=w, nobs=params.get("n_total"), alpha=alpha, power=power, nobs_name="nobs", n_bins=df + 1, ) if analysis_mode == "A Priori": n_total = int(np.ceil(raw)) else: n_total = params.get("n_total", 0) n_per_group = n_total computed_value = raw if analysis_mode == "A Priori": explanation = ( f"Required total sample size for a chi-square test " f"with {df} degree(s) of freedom to detect Cohen's w = {w:.3f} " f"with α = {alpha} and power = {power}." ) elif analysis_mode == "Post Hoc": explanation = ( f"Achieved power for a chi-square test " f"with N = {n_total}, {df} DF, Cohen's w = {w:.3f}, α = {alpha}." ) elif analysis_mode == "Sensitivity": explanation = ( f"Minimum detectable Cohen's w for a chi-square test " f"with N = {n_total}, {df} DF, α = {alpha}, power = {power}." ) elif analysis_mode == "Criterion": explanation = ( f"Required significance level for a chi-square test " f"with N = {n_total}, {df} DF, Cohen's w = {w:.3f}, power = {power}." ) formula_latex = r"n = \text{from non-central }\chi^2\text{ distribution} \quad w = \sqrt{\sum \frac{(p_{0i} - p_{1i})^2}{p_{0i}}}" elif atype == "mannwhitney": p_val = params["effect_size"] ratio = params["ratio"] are = params["are"] from statsmodels.stats.power import NormalIndPower solver = NormalIndPower() d_mw = np.sqrt(3) * (p_val - 0.5) if d_mw > 0: raw = _solve_with_mode( solver, analysis_mode, effect_size=d_mw, nobs=params.get("n_total"), alpha=alpha, power=power, nobs_name="nobs1", ratio=ratio, alternative=alternative, ) if analysis_mode == "A Priori": n1 = int(np.ceil(raw / are)) n2 = int(np.ceil(n1 * ratio)) n_total = n1 + n2 n_per_group = (n1, n2) else: n_total = params.get("n_total", 0) n1 = int(np.ceil(n_total / (1 + ratio))) if ratio > 0 else 0 n2 = n_total - n1 n_per_group = (n1, n2) computed_value = raw if analysis_mode == "A Priori": explanation = ( f"Required sample size for Mann-Whitney / Wilcoxon test " f"to detect P(X>Y) = {p_val:.3f} (d ≈ {d_mw:.3f}) with ARE = {are:.3f}, " f"α = {alpha}, power = {power}, ratio = {ratio:.2f}." ) elif analysis_mode == "Post Hoc": explanation = ( f"Achieved power for Mann-Whitney / Wilcoxon test " f"with N = {n_total}, P(X>Y) = {p_val:.3f}, α = {alpha}, ratio = {ratio:.2f}." ) elif analysis_mode == "Sensitivity": explanation = ( f"Minimum detectable P(X>Y) for Mann-Whitney / Wilcoxon test " f"with N = {n_total}, α = {alpha}, power = {power}, ratio = {ratio:.2f}." ) elif analysis_mode == "Criterion": explanation = ( f"Required significance level for Mann-Whitney / Wilcoxon test " f"with N = {n_total}, P(X>Y) = {p_val:.3f}, power = {power}, ratio = {ratio:.2f}." ) formula_latex = r"n_{\text{nonparam}} = \frac{n_{\text{param}}}{\text{ARE}} \quad \text{ARE} \approx 0.955" elif atype == "logrank": hr = params["hr"] ratio = params["ratio"] med_ctrl = params["median_survival"] study_dur = params["study_duration"] log_hr = np.log(hr) num_events = ( ((z_alpha + z_beta) ** 2) * ((ratio + 1) ** 2) / (ratio * (log_hr**2)) ) lambda_ctrl = np.log(2) / med_ctrl p_event_ctrl = 1 - np.exp(-lambda_ctrl * study_dur) p_event_trt = 1 - np.exp(-lambda_ctrl / hr * study_dur) p_event = (p_event_ctrl + ratio * p_event_trt) / (1 + ratio) if p_event > 0: n_total = int(np.ceil(num_events / p_event)) n1 = int(np.ceil(n_total / (1 + ratio))) n2 = n_total - n1 n_per_group = (n1, n2) explanation = ( f"Required sample size for log-rank test to detect HR = {hr:.2f} " f"with median survival {med_ctrl:.0f} months, study duration {study_dur:.0f} months, " f"α = {alpha}, power = {power}, ratio = {ratio:.2f}. " f"Events needed: {int(np.ceil(num_events))}." ) formula_latex = rf"E = \frac{{({_z_sub}+z_{{1-\beta}})^2 (r+1)^2}}{{r (\log HR)^2}} \quad N = \frac{{E}}{{P(\text{{event}})}}" elif atype == "cox": hr = params["hr"] k = params["k"] sd_x = params["sd_x"] r2_x = params["r2_x"] ev_rate = params["event_rate"] log_hr = np.log(hr) var_denom = (sd_x**2) * (log_hr**2) * (1 - r2_x) if var_denom > 0: num_events = ((z_alpha + z_beta) ** 2) / var_denom num_events = max(num_events, 10 * k) n_total = int(np.ceil(num_events / ev_rate)) n_per_group = n_total explanation = ( f"Required total sample size for Cox regression with {k} predictor(s) " f"to detect HR = {hr:.2f} (SD = {sd_x:.1f}, R² = {r2_x:.2f}) " f"with α = {alpha}, power = {power}, event rate = {ev_rate:.2f}. " f"Events needed: {int(np.ceil(num_events))}." ) formula_latex = rf"E = \frac{{({_z_sub}+z_{{1-\beta}})^2}}{{\sigma_x^2 \beta^2 (1-R^2)}} \quad N = \frac{{E}}{{P(\text{{event}})}}" elif atype == "equiv": margin = params["margin"] exp_diff = params["expected_diff"] sd = params["sd"] ratio = params["ratio"] equiv_type = params.get("equiv_param_type", "Mean") if equiv_type == "Proportion": p1_eq = params.get("p1_eq", 0.2) p2_eq = params.get("p2_eq", 0.2) d_prop = abs(p1_eq - p2_eq) d_e = margin - d_prop if d_e > 0: p_bar = (p1_eq + p2_eq) / 2 se = np.sqrt(2 * p_bar * (1 - p_bar)) es_e = d_e / se if se > 0 else 0 from statsmodels.stats.power import NormalIndPower n1 = NormalIndPower().solve_power( effect_size=es_e, alpha=alpha, power=power, ratio=ratio, alternative="larger", ) n1 = int(np.ceil(n1)) n2 = int(np.ceil(n1 * ratio)) n_total = n1 + n2 n_per_group = (n1, n2) explanation = ( f"Required sample size for non-inferiority test of proportions " f"with margin = {margin:.3f}, p₁ = {p1_eq:.3f}, p₂ = {p2_eq:.3f}, " f"expected difference = {d_prop:.3f}, α = {alpha}, power = {power}, ratio = {ratio:.2f}." ) formula_latex = r"n_1 = \frac{(z_{1-\alpha}+z_{1-\beta})^2 \, 2\bar{p}(1-\bar{p})}{(\delta - |p_1-p_2|)^2}" else: d_e = margin - abs(exp_diff) if d_e > 0: from statsmodels.stats.power import NormalIndPower es_e = d_e / sd n1 = NormalIndPower().solve_power( effect_size=es_e, alpha=alpha, power=power, ratio=ratio, alternative="larger", ) n1 = int(np.ceil(n1)) n2 = int(np.ceil(n1 * ratio)) n_total = n1 + n2 n_per_group = (n1, n2) explanation = ( f"Required sample size for non-inferiority test of means " f"with margin = {margin:.2f}, expected difference = {exp_diff:.2f}, " f"SD = {sd:.1f}, α = {alpha}, power = {power}, ratio = {ratio:.2f}." ) formula_latex = r"n_1 = \frac{(z_{1-\alpha}+z_{1-\beta})^2 \sigma^2 (1+1/r)}{(\delta - |d|)^2}" elif atype == "rm_anova": f_eff = params["effect_size"] k = params["k"] m = params["m"] rho = params["rho"] epsilon = params["epsilon"] from statsmodels.stats.power import FTestAnovaPower solver = FTestAnovaPower() if f_eff > 0: n_per_g = solver.solve_power( effect_size=f_eff, alpha=alpha, power=power, k_groups=k, ) design_effect = (1 + (m - 1) * rho) / m df_adj = (m - 1) * epsilon n_per_g_adj = int( np.ceil(n_per_g * design_effect * k / (k * df_adj / (k - 1))) ) n_per_g_adj = max(n_per_g_adj, int(np.ceil(n_per_g))) n_total = n_per_g_adj * k n_per_group = n_per_g_adj explanation = ( f"Required sample size per group for repeated measures ANOVA " f"with {k} group(s), {m} measurement(s), ρ = {rho:.2f}, ε = {epsilon:.2f}, " f"Cohen's f = {f_eff:.3f}, α = {alpha}, power = {power}." ) formula_latex = r"\text{Adjustment: } n_{\text{adj}} = n \times \frac{1+(m-1)\rho}{m} \quad \text{GG: } \varepsilon" elif atype == "twoway_anova": f_a = params["f_a"] f_b = params["f_b"] f_ab = params["f_ab"] rows = params["rows"] cols = params["cols"] focus = params["focus"] from statsmodels.stats.power import FTestAnovaPower solver = FTestAnovaPower() if focus == "Main Effect A": f_use = f_a k_use = rows elif focus == "Main Effect B": f_use = f_b k_use = cols else: f_use = f_ab k_use = rows * cols if f_use > 0: n_per_cell = solver.solve_power( effect_size=f_use, alpha=alpha, power=power, k_groups=k_use, ) n_per_cell = int(np.ceil(n_per_cell)) n_total = n_per_cell * rows * cols n_per_group = n_per_cell explanation = ( f"Required sample size per cell for two-way ANOVA " f"({rows} × {cols} design), focus on {focus}, " f"Cohen's f = {f_use:.3f}, α = {alpha}, power = {power}. " f"Total N = {n_total} across {rows * cols} cells." ) formula_latex = r"n_{\text{per cell}} = \text{from non-central }F \quad N = n \times r \times c" elif atype == "roc_auc": auc = params["auc"] null_auc = params["null_auc"] ratio = params["ratio"] v_auc = auc * (1 - auc) + (ratio - 1) * (auc / (2 - auc) - auc**2) / (1 + ratio) delta_auc = auc - null_auc if delta_auc > 0: n_cases = ((z_alpha + z_beta) ** 2 * v_auc) / (delta_auc**2) n_cases = int(np.ceil(n_cases)) n_controls = int(np.ceil(n_cases * ratio)) n_total = n_cases + n_controls n_per_group = (n_cases, n_controls) explanation = ( f"Required sample size for ROC/AUC analysis to detect AUC = {auc:.3f} " f"(null = {null_auc:.1f}) with case:control ratio {ratio:.2f}, " f"α = {alpha}, power = {power}." ) formula_latex = rf"n_{{\text{{cases}}}} = \frac{{({_z_sub}+z_{{1-\beta}})^2 V(AUC)}}{{{{(AUC - 0.5)^2}}}}" elif atype == "kappa": kappa = params["kappa"] null_kappa = params["null_kappa"] raters = params["raters"] cats = params["categories"] delta_k = kappa - null_kappa if delta_k > 0: n_total = int( np.ceil( ((z_alpha + z_beta) ** 2 * null_kappa * (1 - null_kappa)) / (delta_k**2) ) ) n_total = max(n_total, raters * cats * 5) n_per_group = n_total explanation = ( f"Required sample size for Cohen's Kappa with {raters} rater(s), {cats} category(ies), " f"expected κ = {kappa:.3f}, null κ = {null_kappa:.2f}, " f"α = {alpha}, power = {power}." ) formula_latex = rf"n = \frac{{({_z_sub}+z_{{1-\beta}})^2 \kappa_0(1-\kappa_0)}}{{(\kappa - \kappa_0)^2}}" elif atype == "cluster_rct": d = params["effect_size"] icc = params["icc"] cluster_m = params["cluster_size"] ratio = params["ratio"] deff = 1 + (cluster_m - 1) * icc from statsmodels.stats.power import TTestIndPower solver = TTestIndPower() if d > 0: n1_ind = solver.solve_power( effect_size=d, alpha=alpha, power=power, ratio=ratio, alternative=alternative, ) n1_ind = int(np.ceil(n1_ind)) n1_clust = int(np.ceil(n1_ind * deff)) n1_clust = int(np.ceil(n1_clust / cluster_m)) * cluster_m n2_clust = int(np.ceil(n1_clust * ratio)) n_total = n1_clust + n2_clust num_clusters_1 = int(np.ceil(n1_clust / cluster_m)) num_clusters_2 = int(np.ceil(n2_clust / cluster_m)) n_per_group = (n1_clust, n2_clust) explanation = ( f"Required sample size for cluster RCT with ICC = {icc:.3f}, " f"cluster size m = {cluster_m}, DEFF = {deff:.2f}, " f"Cohen's d = {d:.3f}, α = {alpha}, power = {power}, ratio = {ratio:.2f}. " f"Clusters needed: {num_clusters_1} in group 1, {num_clusters_2} in group 2." ) formula_latex = ( r"N_{\text{clust}} = N_{\text{ind}} \times [1+(m-1)\rho] \quad \text{DEFF}" ) elif atype == "precision": half_width = params["half_width"] conf_level = params["conf_level"] conf_alpha = 1 - conf_level / 100 param_type = params["param_type"] if param_type == "Mean": sd = params["sd"] if sd > 0 and half_width > 0: from scipy.stats import t as t_dist n_total = int( np.ceil((norm.ppf(1 - conf_alpha / 2) * sd / half_width) ** 2) ) n_total = max(n_total, 3) for _ in range(20): t_val = t_dist.ppf(1 - conf_alpha / 2, df=n_total - 1) n_next = int(np.ceil((t_val * sd / half_width) ** 2)) n_next = max(n_next, 3) if n_next == n_total: break n_total = n_next else: n_total = 3 else: prop = params["prop"] if prop > 0 and half_width > 0: n_total = int( np.ceil( (norm.ppf(1 - conf_alpha / 2) ** 2 * prop * (1 - prop)) / (half_width**2) ) ) else: n_total = 3 n_total = max(n_total, 3) n_per_group = n_total explanation = ( f"Required sample size for precision-based estimation of a {param_type.lower()} " f"with {conf_level:.0f}% CI half-width = {half_width:.2f}. " f"σ = {sd:.2f}" if param_type == "Mean" else f"p = {prop:.3f}." ) formula_latex = ( r"n = \left(\frac{t_{1-\alpha/2, n-1} \sigma}{w}\right)^2" if param_type == "Mean" else r"n = \frac{z_{1-\alpha/2}^2 p(1-p)}{w^2}" ) elif atype == "pilot": method = params["method"] if method == "Rule of thumb": n_total = params["n_per_group"] n_per_group = n_total elif method == "Precision-based": half_width = params["half_width"] conf_level = params["conf_level"] conf_alpha = 1 - conf_level / 100 param_type = params["param_type"] if param_type == "Mean": sd = params["sd"] if sd > 0 and half_width > 0: from scipy.stats import t as t_dist n_total = int( np.ceil((norm.ppf(1 - conf_alpha / 2) * sd / half_width) ** 2) ) n_total = max(n_total, 3) for _ in range(20): t_val = t_dist.ppf(1 - conf_alpha / 2, df=n_total - 1) n_next = int(np.ceil((t_val * sd / half_width) ** 2)) n_next = max(n_next, 3) if n_next == n_total: break n_total = n_next else: z_hw = norm.ppf(1 - conf_alpha / 2) prop = params["prop"] if prop > 0 and half_width > 0: n_total = int( np.ceil((z_hw**2 * prop * (1 - prop)) / (half_width**2)) ) n_total = max(n_total, 5) n_per_group = n_total else: main_n = params["main_n"] fraction = params["fraction"] n_total = max(int(np.ceil(main_n * fraction)), 5) n_per_group = n_total explanation = ( f"Pilot/feasibility study sample size using '{method}' method. " f"N = {n_total} per group. Recommended for estimating parameters " f"for a future definitive trial." ) formula_latex = r"n_{\text{pilot}} = \text{rule of thumb: } 12\text{/group}" elif atype == "wilcoxon_sr": p_val = params["effect_size"] are = params["are"] d_wsr = np.sqrt(3) * (p_val - 0.5) * 2 if d_wsr > 0: from statsmodels.stats.power import NormalIndPower n_total = int( np.ceil( NormalIndPower().solve_power( effect_size=d_wsr, alpha=alpha, power=power, alternative=alternative, ) / are ) ) n_per_group = n_total explanation = ( f"Required sample size for a paired Wilcoxon signed-rank test ({tails.lower()}) " f"to detect Pr(positive diff) = {p_val:.3f} with α = {alpha}, power = {power}, ARE = {are:.3f}." ) formula_latex = rf"n = \frac{{1}}{{\text{{ARE}}}} \left( \frac{{{_z_sub} + z_{{1-\beta}}}}{{\sqrt{{3}} \cdot (P - 0.5)}} \right)^2" elif atype == "kruskal": f_eff = params["effect_size"] k = params["k"] are_kw = params.get("are", 0.955) if f_eff > 0: from statsmodels.stats.power import FTestAnovaPower n_per_g = FTestAnovaPower().solve_power( effect_size=f_eff, alpha=alpha, power=power, k_groups=k ) n_per_g = int(np.ceil(n_per_g / are_kw)) n_total = n_per_g * k n_per_group = n_per_g explanation = ( f"Required sample size per group for Kruskal-Wallis test with {k} groups " f"to detect Cohen's f = {f_eff:.3f} with α = {alpha}, power = {power}. " f"Inflated by {1/are_kw:.2f}× vs ANOVA (ARE = {are_kw:.3f}) to account for efficiency loss." ) formula_latex = r"n_{\text{KW}} = \frac{n_{\text{ANOVA}}}{\text{ARE}} \quad f = \sqrt{\frac{\sum(\bar{R}_i - \bar{R})^2}{(N(N+1)/12)}}" elif atype == "friedman": k = params["k"] m = params["m"] w = params["w"] are = params["are"] if w > 0: from statsmodels.stats.power import FTestAnovaPower f_fr = np.sqrt(w / (1 - w)) n_per_g = FTestAnovaPower().solve_power( effect_size=f_fr, alpha=alpha, power=power, k_groups=k ) n_per_g = int(np.ceil(n_per_g * m / are)) n_total = n_per_g * k n_per_group = n_per_g explanation = ( f"Required sample size per group for Friedman test with {k} groups, {m} measurements, " f"Kendall's W = {w:.3f}, α = {alpha}, power = {power}." ) formula_latex = ( r"W = \frac{12 \sum R_i^2}{m^2 k(k^2-1)} \quad f = \sqrt{\frac{W}{1-W}}" ) elif atype == "mcnemar": p_b = params["p_b"] p_c = params["p_c"] d_mc = abs(p_b - p_c) p_discordant = p_b + p_c if d_mc > 0 and p_discordant > 0: n_total = int(np.ceil((z_alpha + z_beta) ** 2 * p_discordant / (d_mc**2))) n_per_group = n_total explanation = ( f"Required number of pairs for McNemar's test ({tails.lower()}) " f"to detect discordant proportions p_b = {p_b:.3f}, p_c = {p_c:.3f} " f"with α = {alpha}, power = {power}." ) formula_latex = rf"n = \frac{{({_z_sub} + z_{{1-\beta}})^2 (p_b + p_c)}}{{{{(p_b - p_c)^2}}}}" elif atype == "fisher": p1 = params["p1"] p2 = params["p2"] ratio = params["ratio"] are_f = params.get("are", 0.833) p_bar = (p1 + ratio * p2) / (1 + ratio) d_f = abs(p1 - p2) if d_f > 0 and p_bar > 0 and p_bar < 1: from statsmodels.stats.proportion import proportion_effectsize from statsmodels.stats.power import NormalIndPower d_eff = proportion_effectsize(p2, p1) n1 = NormalIndPower().solve_power( effect_size=abs(d_eff), alpha=alpha, power=power, ratio=ratio, alternative=alternative, ) n1 = int(np.ceil(n1 / are_f)) n2 = int(np.ceil(n1 * ratio)) n_total = n1 + n2 n_per_group = (n1, n2) explanation = ( f"Required sample size per group for Fisher's exact test ({tails.lower()}) " f"to detect difference between {p1:.3f} and {p2:.3f} " f"with α = {alpha}, power = {power}, ratio = {ratio:.2f}. " f"Inflated by {1/are_f:.2f}× vs z-test (ARE = {are_f:.3f}) for exact-method efficiency." ) formula_latex = r"n_{\text{Fisher}} \approx \frac{n_{\text{two-prop}}}{\text{ARE}} \quad \text{(exact conditional inference)}" elif atype == "manova": k = params["k"] dv = params["dv"] f2 = params["f2"] rho = params["rho"] manova_test = params.get("manova_test", "Pillai's Trace") if f2 > 0: u = dv v_num = k - 1 v_den = 1e9 n_total = None for n_try in range(k * dv + 2, 5000): v_den = n_try - k - dv if v_den <= 0: continue s_val = min(u, v_num) df1 = u * v_num if manova_test == "Pillai's Trace": df2 = s_val * (v_den - dv + 1) + 4 elif manova_test == "Wilks' Lambda": t_val = max( np.sqrt((u**2 * v_num**2 - 4) / max(u**2 + v_num**2 - 5, 1)), 1 ) df2 = (v_den - (u - v_num + 1) / 2) * t_val - (u * v_num - 2) / 2 elif manova_test == "Hotelling-Lawley Trace": df2 = s_val * (v_den - dv - 1) + 4 else: df2 = s_val * (v_den - dv + 1) + 4 ncp = f2 * n_try * (1 - rho) from scipy.stats import ncf as noncentral_f, f as f_dist f_crit = f_dist.ppf(1 - alpha, df1, df2) p_cur = 1 - noncentral_f.cdf(f_crit, df1, df2, ncp) if p_cur >= power: n_total = n_try break if n_total is not None: n_per_group = int(np.ceil(n_total / k)) n_total = n_per_group * k explanation = ( f"Required total sample size for MANOVA with {k} groups, {dv} dependent variables, " f"effect size f²(V) = {f2:.4f}, α = {alpha}, power = {power}, " f"DV correlation ρ = {rho:.2f}. Test: {manova_test}." ) formula_latex = r"n \text{ from non-central } F \text{ with } df_1 = u \cdot v_{\text{num}}, \quad f^2(V) = \frac{V}{s - V}" elif atype == "binomial": p0 = params["p0"] p1 = params["p1"] if p0 != p1: from scipy.stats import binom n_total = None for n_try in range(3, 10000): if alternative == "two-sided": alpha_lo = binom.ppf(alpha / 2, n_try, p0) alpha_hi = binom.ppf(1 - alpha / 2, n_try, p0) p_pow = binom.cdf(alpha_hi, n_try, p1) - binom.cdf( alpha_lo - 1, n_try, p1 ) elif p1 > p0: crit = binom.ppf(1 - alpha, n_try, p0) p_pow = 1 - binom.cdf(crit - 1, n_try, p1) else: crit = binom.ppf(alpha, n_try, p0) p_pow = binom.cdf(crit, n_try, p1) if p_pow >= power: n_total = n_try break n_per_group = n_total explanation = ( f"Required sample size for binomial exact test ({tails.lower()}) " f"to detect π₁ = {p1:.3f} vs π₀ = {p0:.3f} " f"with α = {alpha}, power = {power}. Uses exact binomial CDF." ) _z_sub_phi = r"1-\alpha/2" if alternative == "two-sided" else r"1-\alpha" formula_latex = rf"n = \min\{{n: 1 - \Phi(\Phi^{{-1}}({_z_sub_phi}) - \sqrt{{n}} d) \geq \text{{power}}\}}" elif atype == "simulation": sim_test = params["sim_test"] n_sim = params["n_sim"] n_per = params["n_per"] n_total = n_per * 2 n_per_group = (n_per, n_per) alpha_sim = alpha st.info( f"Running {n_sim} Monte Carlo simulations with N = {n_per} per group..." ) progress_bar = st.progress(0) rejects = 0 np.random.seed(42) if sim_test == "Two-proportion z-test": p1_s = params["p1_s"] p2_s = params["p2_s"] for i in range(n_sim): x1 = np.random.binomial(1, p1_s, n_per) x2 = np.random.binomial(1, p2_s, n_per) from scipy.stats import chi2_contingency _, p_val, _, _ = chi2_contingency( pd.crosstab( pd.Series(np.concatenate([x1, x2])), pd.Series(["G1"] * n_per + ["G2"] * n_per), ) ) if p_val < alpha_sim: rejects += 1 if (i + 1) % max(1, n_sim // 20) == 0: progress_bar.progress((i + 1) / n_sim) else: mu1 = params["mu1"] mu2 = params["mu2"] sd = params["sd"] dist = params.get("dist", "Normal") for i in range(n_sim): if dist == "Normal": g1 = np.random.normal(mu1, sd, n_per) g2 = np.random.normal(mu2, sd, n_per) elif dist == "Skewed (Exponential)": g1 = np.random.exponential(sd, n_per) + mu1 g2 = np.random.exponential(sd, n_per) + mu2 else: g1 = np.random.uniform(mu1 - sd * 1.732, mu1 + sd * 1.732, n_per) g2 = np.random.uniform(mu2 - sd * 1.732, mu2 + sd * 1.732, n_per) from scipy.stats import ttest_ind, mannwhitneyu if sim_test == "Independent t-test (pooled)": _, p_val = ttest_ind(g1, g2, equal_var=True) elif sim_test == "Welch's t-test": _, p_val = ttest_ind(g1, g2, equal_var=False) else: _, p_val = mannwhitneyu(g1, g2, alternative="two-sided") if p_val < alpha_sim: rejects += 1 if (i + 1) % max(1, n_sim // 20) == 0: progress_bar.progress((i + 1) / n_sim) progress_bar.empty() empirical_power = rejects / n_sim st.success(f"Monte Carlo simulation complete — {rejects}/{n_sim} rejections.") params["_empirical_power"] = empirical_power explanation = ( f"Monte Carlo simulation ({n_sim} iterations) using {sim_test} " f"with N = {n_per} per group. " f"Estimated power: {empirical_power:.1%} " f"(95% CI: {max(0, empirical_power - 1.96*np.sqrt(empirical_power*(1-empirical_power)/n_sim)):.1%} – " f"{min(1, empirical_power + 1.96*np.sqrt(empirical_power*(1-empirical_power)/n_sim)):.1%})." ) formula_latex = r"\text{Power} = \frac{1}{N_{\text{sim}}} \sum_{i=1}^{N_{\text{sim}}} I(p_i < \alpha)" # --- Compromise fallback for test types not explicitly handled --- if ( analysis_mode == "Compromise" and computed_value is None and n_total is not None and n_total > 0 ): from scipy.optimize import brentq es_val = params.get("effect_size", None) if es_val is not None and es_val > 0: from statsmodels.stats.power import TTestPower, NormalIndPower if atype in ("one_mean", "paired", "correlation"): solver = TTestPower() elif atype in ("one_prop", "mannwhitney", "wilcoxon_sr"): solver = NormalIndPower() else: solver = None if solver is not None: cost_ratio = params.get("cost_ratio", 1.0) try: comp = _solve_compromise( solver, es_val, n_total, cost_ratio, alternative=alternative ) if comp is not None: computed_value = comp explanation = ( f"Compromise power analysis for {atype} with N = {n_total}, " f"effect size = {es_val:.4f}, cost ratio β/α = {cost_ratio:.2f}. " f"Adjusted α = {computed_value['alpha']:.4f}, achieved power = {computed_value['power']:.1%}." ) except Exception: pass # --- Apply Attrition Adjustment --- n_total_raw = n_total if analysis_mode == "A Priori" and dropout_rate > 0 and n_total is not None: n_total = int(np.ceil(n_total / (1 - dropout_rate))) if isinstance(n_per_group, tuple): n1_adj = int(np.ceil(n_per_group[0] / (1 - dropout_rate))) n2_adj = int(np.ceil(n_per_group[1] / (1 - dropout_rate))) n_per_group = (n1_adj, n2_adj) elif n_per_group is not None: n_per_group = int(np.ceil(n_per_group / (1 - dropout_rate))) # --- Display Results --- if n_total is None and computed_value is None: st.error( "Effect size is too small — consider a larger effect or different design." ) return mode_titles = { "A Priori": "Sample Size Results", "Post Hoc": "Post-Hoc Power Analysis Results", "Sensitivity": "Sensitivity Analysis Results", "Criterion": "Criterion Analysis Results", "Compromise": "Compromise Power Analysis Results", } st.subheader(mode_titles.get(analysis_mode, "Power Analysis Results")) if formula_latex: st.latex(formula_latex) if analysis_mode == "A Priori": col1, col2, col3 = st.columns(3) with col1: st.metric("Total Sample Size (N)", n_total) with col2: if isinstance(n_per_group, tuple): st.metric("Group 1 (n₁)", n_per_group[0]) st.metric("Group 2 (n₂)", n_per_group[1]) else: st.metric("Per Group (n)", n_per_group) with col3: if atype == "simulation": emp_pwr = params.get("_empirical_power", 0) st.metric("Empirical Power", f"{emp_pwr:.1%}") else: st.metric("Power", f"{power:.0%}") st.metric("Alpha (α)", f"{alpha:.3f}") adjustments = [] if dropout_rate > 0 and n_total_raw is not None and n_total_raw != n_total: adjustments.append( f"Raw N = {n_total_raw} (before {dropout_rate:.0%} dropout adjustment)" ) if num_tests > 1: adjustments.append( f"{mc_method} α = {alpha:.4f} (original: {alpha_raw:.4f}) for {num_tests} comparisons" ) if adjustments: st.caption(" | ".join(adjustments)) elif analysis_mode == "Post Hoc": col1, col2, col3 = st.columns(3) with col1: st.metric("Total Sample Size (N)", n_total) with col2: if isinstance(n_per_group, tuple): st.metric("Group 1 (n₁)", n_per_group[0]) st.metric("Group 2 (n₂)", n_per_group[1]) else: st.metric("Per Group (n)", n_per_group) with col3: pwr_display = f"{computed_value:.1%}" if computed_value is not None else "—" st.metric("Achieved Power", pwr_display) st.metric("Alpha (α)", f"{alpha:.3f}") elif analysis_mode == "Sensitivity": col1, col2, col3 = st.columns(3) with col1: st.metric("Total Sample Size (N)", n_total) with col2: st.metric("Power", f"{power:.0%}") st.metric("Alpha (α)", f"{alpha:.3f}") with col3: es_display = f"{computed_value:.4f}" if computed_value is not None else "—" st.metric("Min Detectable Effect", es_display) elif analysis_mode == "Criterion": col1, col2, col3 = st.columns(3) with col1: st.metric("Total Sample Size (N)", n_total) with col2: if isinstance(n_per_group, tuple): st.metric("Group 1 (n₁)", n_per_group[0]) st.metric("Group 2 (n₂)", n_per_group[1]) else: st.metric("Per Group (n)", n_per_group) with col3: st.metric("Power", f"{power:.0%}") a_display = f"{computed_value:.4f}" if computed_value is not None else "—" st.metric("Required α", a_display) elif analysis_mode == "Compromise": col1, col2, col3 = st.columns(3) with col1: st.metric("Total Sample Size (N)", n_total) with col2: st.metric( "Adjusted α", ( f"{computed_value['alpha']:.4f}" if isinstance(computed_value, dict) else "—" ), ) with col3: st.metric( "Achieved Power", ( f"{computed_value['power']:.1%}" if isinstance(computed_value, dict) else "—" ), ) st.info(explanation) if isinstance(n_per_group, tuple) and n_total is not None and n_total > 0: labels = [ f"Group 1 (n₁ = {n_per_group[0]})", f"Group 2 (n₂ = {n_per_group[1]})", ] values = [n_per_group[0], n_per_group[1]] fig_pie = go.Figure( data=[ go.Pie(labels=labels, values=values, hole=0.4, textinfo="label+percent") ] ) fig_pie.update_layout( template="plotly_dark", height=280, title="Group-Size Distribution", margin=dict(t=40, b=10, l=10, r=10), ) st.plotly_chart(fig_pie, use_container_width=True) if atype == "simulation": st.info( "Power curve and sensitivity plots use parametric formulas and are not available for Monte Carlo simulation. Change N above and re-run to test different sample sizes." ) return # --- Power Curve --- st.subheader("Power Analysis Curve") max_n = max(n_total * 3, 30) n_range = np.arange(5, max_n + 1, 2) from statsmodels.stats.power import ( TTestIndPower, TTestPower, NormalIndPower, FTestAnovaPower, GofChisquarePower, ) alpha_curve_values = sorted(set([0.01, 0.05, 0.10, alpha])) alpha_curve_colors = {0.01: "#FF6B6B", 0.05: "#00BFFF", 0.10: "#51CF66"} if alpha not in alpha_curve_values: alpha_curve_colors[alpha] = "#FFD43B" fig = go.Figure() for a in alpha_curve_values: saved_alpha = alpha saved_z_alpha = z_alpha alpha = a if alternative == "two-sided": z_alpha = norm.ppf(1 - alpha / 2) else: z_alpha = norm.ppf(1 - alpha) power_vals = [] for n in n_range: try: if atype == "one_mean": solver = TTestPower() pv = solver.solve_power( effect_size=params["effect_size"], nobs=n, alpha=alpha, alternative=alternative, ) elif atype == "two_means": solver = TTestIndPower() pv = solver.solve_power( effect_size=params["effect_size"], nobs1=n, alpha=alpha, ratio=params["ratio"], alternative=alternative, ) elif atype == "paired": solver = TTestPower() pv = solver.solve_power( effect_size=params["effect_size"], nobs=n, alpha=alpha, alternative=alternative, ) elif atype == "one_prop": from statsmodels.stats.proportion import proportion_effectsize solver = NormalIndPower() d_eff = proportion_effectsize( params["prop_alt"], params["prop_null"] ) pv = solver.solve_power( effect_size=abs(d_eff), nobs1=n, alpha=alpha, alternative=alternative, ) elif atype == "two_prop": from statsmodels.stats.proportion import proportion_effectsize solver = NormalIndPower() d_eff = proportion_effectsize(params["p2"], params["p1"]) pv = solver.solve_power( effect_size=abs(d_eff), nobs1=n, alpha=alpha, ratio=params["ratio"], alternative=alternative, ) elif atype == "anova": solver = FTestAnovaPower() pv = solver.solve_power( effect_size=params["effect_size"], nobs=n, alpha=alpha, k_groups=params["k"], ) elif atype == "correlation": import math fisher_z = math.atanh(params["effect_size"]) z_alpha_c = ( norm.ppf(1 - alpha / 2) if alternative == "two-sided" else norm.ppf(1 - alpha) ) z_beta_c = (np.sqrt(n - 3) * fisher_z) - z_alpha_c pv = norm.cdf(z_beta_c) elif atype == "regression": from scipy.stats import ncf as noncentral_f, f as f_dist k_r = params["k"] dfd = n - k_r - 1 if dfd > 0: ncp = params["effect_size"] * n f_crit = f_dist.ppf(1 - alpha, k_r, dfd) pv = 1 - noncentral_f.cdf(f_crit, k_r, dfd, ncp) else: pv = 0 elif atype == "logistic": k = params["k"] ev_rate = params["event_rate"] or_val = params["or"] p1_log = (or_val * ev_rate) / (1 - ev_rate + or_val * ev_rate) d_log = abs(p1_log - ev_rate) p_bar = (ev_rate + p1_log) / 2 se = np.sqrt(2 * p_bar * (1 - p_bar)) d_eff_log = d_log / se if se > 0 else 0 solver = NormalIndPower() pv = solver.solve_power( effect_size=d_eff_log, nobs1=n, alpha=alpha, alternative=alternative, ) elif atype == "chisq": solver = GofChisquarePower() pv = solver.solve_power( effect_size=params["effect_size"], nobs=n, alpha=alpha, n_bins=params["df"] + 1, ) elif atype == "mannwhitney": p_val = params["effect_size"] ratio = params["ratio"] are = params["are"] d_mw = np.sqrt(3) * (p_val - 0.5) if d_mw > 0: solver = NormalIndPower() pv = solver.solve_power( effect_size=d_mw, nobs1=n * are, alpha=alpha, ratio=ratio, alternative=alternative, ) else: pv = 0 elif atype == "logrank": hr = params["hr"] ratio = params["ratio"] med_ctrl = params["median_survival"] study_dur = params["study_duration"] log_hr = np.log(hr) lambda_ctrl = np.log(2) / med_ctrl p_event_ctrl = 1 - np.exp(-lambda_ctrl * study_dur) p_event_trt = 1 - np.exp(-lambda_ctrl / hr * study_dur) p_event = (p_event_ctrl + ratio * p_event_trt) / (1 + ratio) num_events = n * p_event if log_hr != 0 and num_events > 0: z_val = ( abs(log_hr) * np.sqrt(num_events * ratio / ((ratio + 1) ** 2)) - z_alpha ) pv = norm.cdf(z_val) else: pv = 0 elif atype == "cox": hr = params["hr"] sd_x = params["sd_x"] r2_x = params["r2_x"] ev_rate = params["event_rate"] log_hr = np.log(hr) num_events = n * ev_rate if log_hr != 0 and num_events > 0: z_val = ( abs(log_hr) * sd_x * np.sqrt(num_events * (1 - r2_x)) - z_alpha ) pv = norm.cdf(z_val) else: pv = 0 elif atype == "equiv": margin = params["margin"] exp_diff = params["expected_diff"] sd = params["sd"] ratio = params["ratio"] equiv_type = params.get("equiv_param_type", "Mean") if equiv_type == "Proportion": p1_eq = params.get("p1_eq", 0.2) p2_eq = params.get("p2_eq", 0.2) d_prop = abs(p1_eq - p2_eq) d_e = margin - d_prop if d_e > 0: p_bar = (p1_eq + p2_eq) / 2 se = np.sqrt(2 * p_bar * (1 - p_bar)) es_e = d_e / se if se > 0 else 0 solver = NormalIndPower() pv = solver.solve_power( effect_size=es_e, nobs1=n, alpha=alpha, ratio=ratio, alternative="larger", ) else: pv = 0 else: d_e = margin - abs(exp_diff) if d_e > 0: es_e = d_e / sd solver = NormalIndPower() pv = solver.solve_power( effect_size=es_e, nobs1=n, alpha=alpha, ratio=ratio, alternative="larger", ) else: pv = 0 elif atype == "rm_anova": f_eff = params["effect_size"] k = params["k"] m = params["m"] rho = params["rho"] epsilon = params["epsilon"] if f_eff > 0: solver = FTestAnovaPower() design_effect = (1 + (m - 1) * rho) / m df_adj = (m - 1) * epsilon n_indep = max(n * df_adj / (design_effect * (k - 1)), k + 1) pv = solver.solve_power( effect_size=f_eff, nobs=n_indep, alpha=alpha, k_groups=k, ) else: pv = 0 elif atype == "twoway_anova": f_a = params["f_a"] f_b = params["f_b"] f_ab = params["f_ab"] rows = params["rows"] cols = params["cols"] focus = params["focus"] if focus == "Main Effect A": f_use = f_a k_use = rows elif focus == "Main Effect B": f_use = f_b k_use = cols else: f_use = f_ab k_use = rows * cols if f_use > 0: solver = FTestAnovaPower() pv = solver.solve_power( effect_size=f_use, nobs=n, alpha=alpha, k_groups=k_use, ) else: pv = 0 elif atype == "roc_auc": auc = params["auc"] null_auc = params["null_auc"] ratio = params["ratio"] v_auc = auc * (1 - auc) + (ratio - 1) * ( auc / (2 - auc) - auc**2 ) / (1 + ratio) delta_auc = auc - null_auc if delta_auc > 0: n_cases_eff = n / (1 + ratio) z_val = delta_auc * np.sqrt(n_cases_eff / v_auc) - z_alpha pv = norm.cdf(z_val) else: pv = 0 elif atype == "kappa": kappa = params["kappa"] null_kappa = params["null_kappa"] raters = params["raters"] cats = params["categories"] delta_k = kappa - null_kappa if delta_k > 0: z_val = ( delta_k * np.sqrt(n / (null_kappa * (1 - null_kappa))) - z_alpha ) pv = norm.cdf(z_val) else: pv = 0 elif atype == "cluster_rct": d = params["effect_size"] icc = params["icc"] cluster_m = params["cluster_size"] ratio = params["ratio"] deff = 1 + (cluster_m - 1) * icc if d > 0: n1_equiv = (n / (1 + ratio)) / deff solver = TTestIndPower() if n1_equiv > 1: pv = solver.solve_power( effect_size=d, nobs1=n1_equiv, alpha=alpha, ratio=ratio, alternative=alternative, ) else: pv = 0 else: pv = 0 elif atype == "precision": half_width = params["half_width"] conf_level = params["conf_level"] conf_alpha = 1 - conf_level / 100 param_type = params["param_type"] if param_type == "Mean": sd = params["sd"] if sd > 0 and half_width > 0: from scipy.stats import t as t_dist n_req = max( (norm.ppf(1 - conf_alpha / 2) * sd / half_width) ** 2, 3 ) for _ in range(20): t_val = t_dist.ppf( 1 - conf_alpha / 2, df=int(n_req) - 1 ) n_next = (t_val * sd / half_width) ** 2 if abs(n_next - n_req) < 0.5: break n_req = max(n_next, 3) else: n_req = 0 else: prop = params["prop"] z_hw = norm.ppf(1 - conf_alpha / 2) if prop > 0 and half_width > 0: n_req = (z_hw**2 * prop * (1 - prop)) / (half_width**2) else: n_req = 0 if n_req > 0: pv = min(n / n_req, 1.0) else: pv = 0 elif atype == "pilot": method = params["method"] if method == "Rule of thumb": n_req = params["n_per_group"] pv = min(n / n_req, 1.0) if n_req > 0 else 0 elif method == "Precision-based": conf_level = params["conf_level"] conf_alpha = 1 - conf_level / 100 param_type = params["param_type"] if param_type == "Mean": sd = params["sd"] half_width = params["half_width"] if sd > 0 and half_width > 0: from scipy.stats import t as t_dist n_req = max( (norm.ppf(1 - conf_alpha / 2) * sd / half_width) ** 2, 3, ) for _ in range(20): t_val = t_dist.ppf( 1 - conf_alpha / 2, df=int(n_req) - 1 ) n_next = (t_val * sd / half_width) ** 2 if abs(n_next - n_req) < 0.5: break n_req = max(n_next, 3) else: n_req = 0 else: z_hw = norm.ppf(1 - conf_alpha / 2) prop = params["prop"] half_width = params["half_width"] n_req = ( (z_hw**2 * prop * (1 - prop)) / (half_width**2) if prop > 0 and half_width > 0 else 0 ) pv = min(n / n_req, 1.0) if n_req > 0 else 0 else: main_n = params["main_n"] fraction = params["fraction"] n_req = max(int(np.ceil(main_n * fraction)), 5) pv = min(n / n_req, 1.0) elif atype == "wilcoxon_sr": p_val = params["effect_size"] are = params["are"] d_wsr = np.sqrt(3) * (p_val - 0.5) * 2 if d_wsr > 0: solver = NormalIndPower() pv = solver.solve_power( effect_size=d_wsr, nobs1=n * are, alpha=alpha, alternative=alternative, ) else: pv = 0 elif atype == "kruskal": f_eff = params["effect_size"] k = params["k"] are_kw = params.get("are", 0.955) if f_eff > 0: solver = FTestAnovaPower() pv = solver.solve_power( effect_size=f_eff, nobs=n * are_kw, alpha=alpha, k_groups=k ) else: pv = 0 elif atype == "friedman": w = params["w"] k = params["k"] m = params["m"] are = params["are"] f_fr = np.sqrt(w / (1 - w)) if w < 1 else 1 if f_fr > 0: solver = FTestAnovaPower() n_indep = n * are / m pv = solver.solve_power( effect_size=f_fr, nobs=n_indep, alpha=alpha, k_groups=k ) else: pv = 0 elif atype == "mcnemar": p_b = params["p_b"] p_c = params["p_c"] d_mc = abs(p_b - p_c) p_discordant = p_b + p_c if d_mc > 0 and p_discordant > 0: z_val = d_mc * np.sqrt(n / p_discordant) - z_alpha pv = norm.cdf(z_val) else: pv = 0 elif atype == "fisher": p1 = params["p1"] p2 = params["p2"] ratio = params["ratio"] are_f = params.get("are", 0.833) from statsmodels.stats.proportion import proportion_effectsize d_eff = proportion_effectsize(p2, p1) if abs(d_eff) > 0: solver = NormalIndPower() pv = solver.solve_power( effect_size=abs(d_eff), nobs1=n / (1 + ratio) * are_f, alpha=alpha, ratio=ratio, alternative=alternative, ) else: pv = 0 elif atype == "manova": k = params["k"] dv = params["dv"] f2 = params["f2"] rho = params["rho"] manova_test = params.get("manova_test", "Pillai's Trace") if f2 > 0: u = dv v_num = k - 1 v_den = n - k - dv if v_den > 0: s_val = min(u, v_num) df1 = u * v_num if manova_test == "Pillai's Trace": df2 = s_val * (v_den - dv + 1) + 4 elif manova_test == "Wilks' Lambda": t_val = max( np.sqrt( (u**2 * v_num**2 - 4) / max(u**2 + v_num**2 - 5, 1) ), 1, ) df2 = (v_den - (u - v_num + 1) / 2) * t_val - ( u * v_num - 2 ) / 2 elif manova_test == "Hotelling-Lawley Trace": df2 = s_val * (v_den - dv - 1) + 4 else: df2 = s_val * (v_den - dv + 1) + 4 ncp = f2 * n * (1 - rho) from scipy.stats import ncf as noncentral_f, f as f_dist f_crit = f_dist.ppf(1 - alpha, df1, df2) pv = 1 - noncentral_f.cdf(f_crit, df1, df2, ncp) else: pv = 0 else: pv = 0 elif atype == "binomial": p0 = params["p0"] p1 = params["p1"] if p0 != p1: from scipy.stats import binom if alternative == "two-sided": alpha_lo = binom.ppf(alpha / 2, n, p0) alpha_hi = binom.ppf(1 - alpha / 2, n, p0) pv = binom.cdf(alpha_hi, n, p1) - binom.cdf( alpha_lo - 1, n, p1 ) elif p1 > p0: crit = binom.ppf(1 - alpha, n, p0) pv = 1 - binom.cdf(crit - 1, n, p1) else: crit = binom.ppf(alpha, n, p0) pv = binom.cdf(crit, n, p1) else: pv = 0 else: pv = 0 except Exception: pv = 0 power_vals.append(pv) alpha = saved_alpha z_alpha = saved_z_alpha is_selected = abs(a - saved_alpha) < 1e-10 line_w = 4 if is_selected else 2 line_d = "solid" if is_selected else "dash" fig.add_trace( go.Scatter( x=n_range, y=power_vals, mode="lines", name=f"α = {a:.3f}", line=dict( color=alpha_curve_colors.get(a, "#FFD43B"), width=line_w, dash=line_d, ), ) ) target_power_val = params["power"] fig.add_hline( y=target_power_val, line_dash="dash", line_color="orange", annotation_text=f"Target Power ({target_power_val:.0%})", ) fig.add_vline( x=n_total, line_dash="dot", line_color="green", annotation_text=f"N = {n_total}", ) fig.update_layout( template="plotly_dark", height=450, xaxis_title="Sample Size (N)", yaxis_title="Power (1 − β)", ) st.plotly_chart(fig, use_container_width=True) # --- Distribution Plot --- st.subheader(":orange[Distribution Plot (Null & Alternative)]") st.caption( "Shows the sampling distributions under H₀ (null) and H₁ (alternative) with critical regions." ) x_min = -4.5 x_max = 4.5 if n_total is not None and n_total > 0: sqrt_n = np.sqrt(n_total) if atype in ("one_mean", "paired", "wilcoxon_sr"): d = ( params.get("effect_size", 0.5) if atype != "wilcoxon_sr" else np.sqrt(3) * (params["effect_size"] - 0.5) * 2 ) ncp = d * sqrt_n elif atype == "two_means": d = params.get("effect_size", 0.5) ncp = d * sqrt_n / np.sqrt(1 + 1 / params.get("ratio", 1)) elif atype in ("one_prop", "binomial"): p0 = params.get("prop_null", params.get("p0", 0.5)) p1 = params.get("prop_alt", params.get("p1", 0.7)) d_eff = abs(p1 - p0) / np.sqrt(p0 * (1 - p0)) if p0 > 0 and p0 < 1 else 0 ncp = d_eff * sqrt_n elif atype in ("two_prop", "fisher"): p1 = params["p1"] ratio = params.get("ratio", 1) d_eff = abs(p1 - params.get("p2", 0.5)) / np.sqrt(p1 * (1 - p1)) ncp = d_eff * sqrt_n / np.sqrt(1 + ratio) elif atype in ("mcnemar",): p_b = params.get("p_b", 0.2) p_c = params.get("p_c", 0.4) d_mc = abs(p_b - p_c) p_disc = p_b + p_c ncp = d_mc * sqrt_n / np.sqrt(p_disc) if p_disc > 0 else 0 elif atype in ("correlation",): r = params.get("effect_size", 0.3) ncp = np.arctanh(r) * sqrt_n elif atype in ("mannwhitney",): p = params.get("effect_size", 0.65) d_mw = np.sqrt(3) * (p - 0.5) ncp = d_mw * sqrt_n * np.sqrt(params.get("are", 1)) elif atype in ("logistic",): or_val = params.get("or", 2) ev_rate = params.get("event_rate", 0.3) p1_log = (or_val * ev_rate) / (1 - ev_rate + or_val * ev_rate) d_log = abs(p1_log - ev_rate) p_bar = (ev_rate + p1_log) / 2 se = np.sqrt(2 * p_bar * (1 - p_bar)) d_eff = d_log / se if se > 0 else 0 ncp = d_eff * sqrt_n elif atype in ("logrank",): hr = params.get("hr", 2) ncp = abs(np.log(hr)) * sqrt_n / 2 elif atype in ("cox",): hr = params.get("hr", 2) sd_x = params.get("sd_x", 1) r2_x = params.get("r2_x", 0) ev_rate = params.get("event_rate", 0.5) ncp = abs(np.log(hr)) * sd_x * np.sqrt(ev_rate * (1 - r2_x)) * sqrt_n else: ncp = 2.5 if ncp > 0: x_range = np.linspace(max(-4.5, -ncp - 4), max(4.5, ncp + 4), 400) null_pdf = norm.pdf(x_range, 0, 1) alt_pdf = norm.pdf(x_range, ncp, 1) z_crit = z_alpha fig_dist = go.Figure() fig_dist.add_trace( go.Scatter( x=x_range, y=null_pdf, mode="lines", name="H₀ (null)", line=dict(color="#00BFFF", width=2), ) ) fig_dist.add_trace( go.Scatter( x=x_range, y=alt_pdf, mode="lines", name="H₁ (alternative)", line=dict(color="#FF6B6B", width=2), ) ) if alternative == "two-sided": x_shade_left = x_range[x_range <= -z_crit] x_shade_right = x_range[x_range >= z_crit] if len(x_shade_left) > 0: fig_dist.add_trace( go.Scatter( x=x_shade_left, y=null_pdf[: len(x_shade_left)], mode="lines", fill="tozeroy", name=f"α/2 ({alpha/2:.4f})", line=dict(color="rgba(255,0,0,0.3)"), fillcolor="rgba(255,0,0,0.2)", ) ) if len(x_shade_right) > 0: fig_dist.add_trace( go.Scatter( x=x_shade_right, y=null_pdf[len(null_pdf) - len(x_shade_right) :], mode="lines", fill="tozeroy", name=f"α/2 ({alpha/2:.4f})", line=dict(color="rgba(255,0,0,0.3)"), fillcolor="rgba(255,0,0,0.2)", ) ) x_beta = x_range[x_range <= z_crit] if len(x_beta) > 0: beta_pdf = alt_pdf[: len(x_beta)] fig_dist.add_trace( go.Scatter( x=x_beta, y=beta_pdf, mode="lines", fill="tozeroy", name=f"β ({1-power:.2f})", line=dict(color="rgba(255,165,0,0.3)"), fillcolor="rgba(255,165,0,0.2)", ) ) else: ncp_pos = ncp > 0 if ncp_pos: x_shade = x_range[x_range >= z_crit] x_beta = x_range[x_range <= z_crit] else: x_shade = x_range[x_range <= -z_crit] x_beta = x_range[x_range >= -z_crit] if len(x_shade) > 0: null_shade = ( null_pdf[-len(x_shade) :] if ncp_pos else null_pdf[: len(x_shade)] ) fig_dist.add_trace( go.Scatter( x=x_shade, y=null_shade, mode="lines", fill="tozeroy", name=f"α ({alpha:.4f})", line=dict(color="rgba(255,0,0,0.3)"), fillcolor="rgba(255,0,0,0.2)", ) ) if len(x_beta) > 0: alt_shade = ( alt_pdf[-len(x_beta) :] if not ncp_pos else alt_pdf[: len(x_beta)] ) fig_dist.add_trace( go.Scatter( x=x_beta, y=alt_shade, mode="lines", fill="tozeroy", name=f"β ({1-power:.2f})", line=dict(color="rgba(255,165,0,0.3)"), fillcolor="rgba(255,165,0,0.2)", ) ) fig_dist.add_vline( x=0, line_dash="dot", line_color="#00BFFF", annotation_text="H₀ center", ) fig_dist.add_vline( x=ncp, line_dash="dot", line_color="#FF6B6B", annotation_text="H₁ center", ) fig_dist.update_layout( template="plotly_dark", height=350, xaxis_title="Test Statistic (z)", yaxis_title="Density", legend=dict(orientation="h", y=1.1), ) st.plotly_chart(fig_dist, use_container_width=True) else: st.info( "Distribution plot not available for this test type with current parameters." ) # --- Sensitivity Table --- st.subheader("Sensitivity Analysis") st.caption("How required sample size changes with different effect sizes.") effect_multipliers = [0.5, 0.67, 0.8, 1.0, 1.25, 1.5, 2.0] sens_data = [] for mult in effect_multipliers: try: if atype in ("one_mean", "paired", "correlation"): adj_es = params["effect_size"] * mult if atype == "correlation": import math adj_es = max(min(adj_es, 0.99), 0.01) fisher_z_s = math.atanh(adj_es) n_s = int(np.ceil(3 + ((z_alpha + z_beta) / fisher_z_s) ** 2)) elif atype in ("one_mean", "paired"): solver = TTestPower() n_s = int( np.ceil( solver.solve_power( effect_size=adj_es, alpha=alpha, power=power, alternative=alternative, ) ) ) elif atype == "two_means": adj_es = params["effect_size"] * mult solver = TTestIndPower() n1_s = solver.solve_power( effect_size=adj_es, alpha=alpha, power=power, ratio=params["ratio"], alternative=alternative, ) n1_s = int(np.ceil(n1_s)) n_s = n1_s + int(np.ceil(n1_s * params["ratio"])) elif atype == "one_prop": adj_es = ( params["prop_null"] + (params["prop_alt"] - params["prop_null"]) * mult ) adj_es = max(min(adj_es, 0.99), 0.01) from statsmodels.stats.proportion import proportion_effectsize d_eff_s = proportion_effectsize(adj_es, params["prop_null"]) solver = NormalIndPower() n_s = int( np.ceil( solver.solve_power( effect_size=abs(d_eff_s), alpha=alpha, power=power, alternative=alternative, ) ) ) elif atype == "two_prop": adj_p2 = params["p1"] + (params["p2"] - params["p1"]) * mult adj_p2 = max(min(adj_p2, 0.99), 0.01) adj_es = abs(adj_p2 - params["p1"]) from statsmodels.stats.proportion import proportion_effectsize d_eff_s = proportion_effectsize(adj_p2, params["p1"]) solver = NormalIndPower() n1_s = solver.solve_power( effect_size=abs(d_eff_s), alpha=alpha, power=power, ratio=params["ratio"], alternative=alternative, ) n1_s = int(np.ceil(n1_s)) n_s = n1_s + int(np.ceil(n1_s * params["ratio"])) elif atype == "logistic": adj_es = max(params["or"] ** mult, 1.01) ev_rate = params["event_rate"] k_log = params["k"] p1_log = (adj_es * ev_rate) / (1 - ev_rate + adj_es * ev_rate) d_log = abs(p1_log - ev_rate) p_bar = (ev_rate + p1_log) / 2 se = np.sqrt(2 * p_bar * (1 - p_bar)) d_eff_log = d_log / se if se > 0 else 0 solver = NormalIndPower() n_base = int( np.ceil( solver.solve_power( effect_size=d_eff_log, alpha=alpha, power=power, alternative=alternative, ) ) ) n_s = max(n_base, 10 * k_log) elif atype == "anova": adj_es = params["effect_size"] * mult solver = FTestAnovaPower() n_s = ( int( np.ceil( solver.solve_power( effect_size=adj_es, alpha=alpha, power=power, k_groups=params["k"], ) ) ) * params["k"] ) elif atype == "regression": adj_es = params["effect_size"] * mult k_r = params["k"] from scipy.stats import ncf as noncentral_f, f as f_dist n_s = None for nc in range(k_r + 2, 10000): dfd = nc - k_r - 1 ncp = adj_es * nc f_crit = f_dist.ppf(1 - alpha, k_r, dfd) p_cur = 1 - noncentral_f.cdf(f_crit, k_r, dfd, ncp) if p_cur >= power: n_s = nc break elif atype == "chisq": adj_es = params["effect_size"] * mult solver = GofChisquarePower() n_s = int( np.ceil( solver.solve_power( effect_size=adj_es, alpha=alpha, power=power, n_bins=params["df"] + 1, ) ) ) elif atype == "mannwhitney": adj_p = params["effect_size"] adj_p = max(min(adj_p * mult, 0.99), 0.01) adj_es = np.sqrt(3) * (adj_p - 0.5) are = params["are"] ratio = params["ratio"] solver = NormalIndPower() n1_s = solver.solve_power( effect_size=adj_es, alpha=alpha, power=power, ratio=ratio, alternative=alternative, ) n1_s = int(np.ceil(n1_s / are)) n_s = n1_s + int(np.ceil(n1_s * ratio)) elif atype == "logrank": adj_es = max(params["hr"] ** mult, 1.001) ratio = params["ratio"] med_ctrl = params["median_survival"] study_dur = params["study_duration"] log_hr = np.log(adj_es) num_events = ( ((z_alpha + z_beta) ** 2) * ((ratio + 1) ** 2) / (ratio * (log_hr**2)) ) lambda_ctrl = np.log(2) / med_ctrl p_event_ctrl = 1 - np.exp(-lambda_ctrl * study_dur) p_event_trt = 1 - np.exp(-lambda_ctrl / adj_es * study_dur) p_event = (p_event_ctrl + ratio * p_event_trt) / (1 + ratio) if p_event > 0: n_s = int(np.ceil(num_events / p_event)) else: n_s = None elif atype == "cox": adj_es = max(params["hr"] ** mult, 1.001) k = params["k"] sd_x = params["sd_x"] r2_x = params["r2_x"] ev_rate = params["event_rate"] log_hr = np.log(adj_es) var_denom = (sd_x**2) * (log_hr**2) * (1 - r2_x) if var_denom > 0: num_events = ((z_alpha + z_beta) ** 2) / var_denom num_events = max(num_events, 10 * k) n_s = int(np.ceil(num_events / ev_rate)) else: n_s = None elif atype == "equiv": adj_margin = params["margin"] * mult exp_diff = params["expected_diff"] sd = params["sd"] ratio = params["ratio"] equiv_type = params.get("equiv_param_type", "Mean") if equiv_type == "Proportion": p1_eq = params.get("p1_eq", 0.2) p2_eq = params.get("p2_eq", 0.2) d_prop = abs(p1_eq - p2_eq) d_e = adj_margin - d_prop if d_e > 0: p_bar = (p1_eq + p2_eq) / 2 se = np.sqrt(2 * p_bar * (1 - p_bar)) es_e = d_e / se if se > 0 else 0 adj_es = es_e solver = NormalIndPower() n1_s = solver.solve_power( effect_size=es_e, alpha=alpha, power=power, ratio=ratio, alternative="larger", ) n1_s = int(np.ceil(n1_s)) n_s = n1_s + int(np.ceil(n1_s * ratio)) else: n_s = None else: d_e = adj_margin - abs(exp_diff) adj_es = d_e / sd if d_e > 0 else 0 if d_e > 0: es_e = d_e / sd solver = NormalIndPower() n1_s = solver.solve_power( effect_size=es_e, alpha=alpha, power=power, ratio=ratio, alternative="larger", ) n1_s = int(np.ceil(n1_s)) n_s = n1_s + int(np.ceil(n1_s * ratio)) else: n_s = None elif atype == "rm_anova": adj_es = params["effect_size"] * mult k = params["k"] m = params["m"] rho = params["rho"] epsilon = params["epsilon"] solver = FTestAnovaPower() n_per_g = solver.solve_power( effect_size=adj_es, alpha=alpha, power=power, k_groups=k, ) design_effect = (1 + (m - 1) * rho) / m df_adj = (m - 1) * epsilon n_per_g_adj = int( np.ceil(n_per_g * design_effect * k / (k * df_adj / (k - 1))) ) n_per_g_adj = max(n_per_g_adj, int(np.ceil(n_per_g))) n_s = n_per_g_adj * k elif atype == "twoway_anova": adj_f_a = params["f_a"] * mult adj_f_b = params["f_b"] * mult adj_f_ab = params["f_ab"] * mult rows = params["rows"] cols = params["cols"] focus = params["focus"] if focus == "Main Effect A": adj_es = adj_f_a k_use = rows elif focus == "Main Effect B": adj_es = adj_f_b k_use = cols else: adj_es = adj_f_ab k_use = rows * cols solver = FTestAnovaPower() n_per_cell = solver.solve_power( effect_size=adj_es, alpha=alpha, power=power, k_groups=k_use, ) n_per_cell = int(np.ceil(n_per_cell)) n_s = n_per_cell * rows * cols elif atype == "roc_auc": auc = params["auc"] adj_auc = 0.5 + (auc - 0.5) * mult adj_auc = max(min(adj_auc, 0.99), 0.51) null_auc = params["null_auc"] ratio = params["ratio"] v_auc = adj_auc * (1 - adj_auc) + (ratio - 1) * ( adj_auc / (2 - adj_auc) - adj_auc**2 ) / (1 + ratio) delta_auc = adj_auc - null_auc adj_es = delta_auc if delta_auc > 0: n_cases = ((z_alpha + z_beta) ** 2 * v_auc) / (delta_auc**2) n_cases = int(np.ceil(n_cases)) n_controls = int(np.ceil(n_cases * ratio)) n_s = n_cases + n_controls else: n_s = None elif atype == "kappa": adj_kappa = params["kappa"] adj_kappa = 0.5 + (adj_kappa - 0.5) * mult adj_kappa = max(min(adj_kappa, 0.99), 0.01) null_kappa = params["null_kappa"] adj_kappa = max(adj_kappa, null_kappa + 0.01) delta_k = adj_kappa - null_kappa raters = params["raters"] cats = params["categories"] adj_es = delta_k if delta_k > 0: n_s = int( np.ceil( ((z_alpha + z_beta) ** 2 * null_kappa * (1 - null_kappa)) / (delta_k**2) ) ) n_s = max(n_s, raters * cats * 5) else: n_s = None elif atype == "cluster_rct": adj_es = params["effect_size"] * mult icc = params["icc"] cluster_m = params["cluster_size"] ratio = params["ratio"] deff = 1 + (cluster_m - 1) * icc solver = TTestIndPower() n1_ind = solver.solve_power( effect_size=adj_es, alpha=alpha, power=power, ratio=ratio, alternative=alternative, ) n1_ind = int(np.ceil(n1_ind)) n1_clust = int(np.ceil(n1_ind * deff)) n1_clust = int(np.ceil(n1_clust / cluster_m)) * cluster_m n2_clust = int(np.ceil(n1_clust * ratio)) n_s = n1_clust + n2_clust elif atype == "precision": adj_hw = params["half_width"] * mult conf_level = params["conf_level"] conf_alpha = 1 - conf_level / 100 param_type = params["param_type"] adj_es = adj_hw if param_type == "Mean": sd = params["sd"] if sd > 0 and adj_hw > 0: from scipy.stats import t as t_dist n_s = int( np.ceil((norm.ppf(1 - conf_alpha / 2) * sd / adj_hw) ** 2) ) n_s = max(n_s, 3) for _ in range(20): t_val = t_dist.ppf(1 - conf_alpha / 2, df=n_s - 1) n_next = int(np.ceil((t_val * sd / adj_hw) ** 2)) n_next = max(n_next, 3) if n_next == n_s: break n_s = n_next else: n_s = None else: prop = params["prop"] z_hw = norm.ppf(1 - conf_alpha / 2) if prop > 0 and adj_hw > 0: n_s = int(np.ceil((z_hw**2 * prop * (1 - prop)) / (adj_hw**2))) else: n_s = None if n_s is not None: n_s = max(n_s, 3) elif atype == "pilot": method = params["method"] adj_es = mult if method == "Rule of thumb": n_s = int(np.ceil(params["n_per_group"] / max(mult, 0.1))) elif method == "Precision-based": conf_level = params["conf_level"] conf_alpha = 1 - conf_level / 100 param_type = params["param_type"] adj_hw = params["half_width"] * mult if param_type == "Mean": sd = params["sd"] if sd > 0 and adj_hw > 0: from scipy.stats import t as t_dist n_s = int( np.ceil( (norm.ppf(1 - conf_alpha / 2) * sd / adj_hw) ** 2 ) ) n_s = max(n_s, 3) for _ in range(20): t_val = t_dist.ppf(1 - conf_alpha / 2, df=n_s - 1) n_next = int(np.ceil((t_val * sd / adj_hw) ** 2)) n_next = max(n_next, 3) if n_next == n_s: break n_s = n_next else: n_s = None else: z_hw = norm.ppf(1 - conf_alpha / 2) prop = params["prop"] if prop > 0 and adj_hw > 0: n_s = int( np.ceil((z_hw**2 * prop * (1 - prop)) / (adj_hw**2)) ) else: n_s = None if n_s is not None: n_s = max(n_s, 5) else: main_n = params["main_n"] fraction = params["fraction"] n_s = max(int(np.ceil(main_n * fraction / max(mult, 0.1))), 5) elif atype == "wilcoxon_sr": p_val = params["effect_size"] are = params["are"] d_wsr = np.sqrt(3) * (p_val - 0.5) * 2 adj_es = d_wsr * max(mult, 0.1) if d_wsr > 0: solver = NormalIndPower() n_s = int( np.ceil( solver.solve_power( effect_size=adj_es / are, alpha=alpha, power=power, alternative=alternative, ) / are ) ) else: n_s = None elif atype == "kruskal": adj_es = params["effect_size"] * mult k = params["k"] are_kw = params.get("are", 0.955) if adj_es > 0: solver = FTestAnovaPower() n_per_g = solver.solve_power( effect_size=adj_es, alpha=alpha, power=power, k_groups=k ) n_per_g = int(np.ceil(n_per_g / are_kw)) n_s = n_per_g * k else: n_s = None elif atype == "friedman": w = params["w"] k = params["k"] m = params["m"] are = params["are"] adj_w = max(min(w * mult, 0.99), 0.01) adj_es = np.sqrt(adj_w / (1 - adj_w)) if adj_es > 0: solver = FTestAnovaPower() n_per_g = solver.solve_power( effect_size=adj_es, alpha=alpha, power=power, k_groups=k ) n_per_g = int(np.ceil(n_per_g * m / are)) n_s = n_per_g * k else: n_s = None elif atype == "mcnemar": p_b = params["p_b"] p_c = params["p_c"] p_b_adj = max(min(p_b * mult, 0.99), 0.01) p_c_adj = p_c * mult p_c_adj = max(min(p_c_adj, 0.99), 0.01) d_mc = abs(p_b_adj - p_c_adj) p_discordant = p_b_adj + p_c_adj adj_es = d_mc if d_mc > 0 and p_discordant > 0: n_s = int( np.ceil((z_alpha + z_beta) ** 2 * p_discordant / (d_mc**2)) ) n_s = max(n_s, 5) else: n_s = None elif atype == "fisher": p1 = params["p1"] p2 = params["p2"] ratio = params["ratio"] are_f = params.get("are", 0.833) adj_p2 = p1 + (p2 - p1) * mult adj_p2 = max(min(adj_p2, 0.99), 0.01) adj_es = abs(adj_p2 - p1) from statsmodels.stats.proportion import proportion_effectsize d_eff = proportion_effectsize(adj_p2, p1) if abs(d_eff) > 0: solver = NormalIndPower() n1 = solver.solve_power( effect_size=abs(d_eff), alpha=alpha, power=power, ratio=ratio, alternative=alternative, ) n1 = int(np.ceil(n1 / are_f)) n_s = n1 + int(np.ceil(n1 * ratio)) else: n_s = None elif atype == "manova": k = params["k"] dv = params["dv"] f2 = params["f2"] rho = params["rho"] adj_f2 = f2 * mult adj_es = adj_f2 manova_test = params.get("manova_test", "Pillai's Trace") if adj_f2 > 0: u = dv v_num = k - 1 n_s = None for n_try in range(k * dv + 2, 5000): v_den = n_try - k - dv if v_den <= 0: continue s_val = min(u, v_num) df1 = u * v_num if manova_test == "Pillai's Trace": df2 = s_val * (v_den - dv + 1) + 4 elif manova_test == "Wilks' Lambda": t_val = max( np.sqrt( (u**2 * v_num**2 - 4) / max(u**2 + v_num**2 - 5, 1) ), 1, ) df2 = (v_den - (u - v_num + 1) / 2) * t_val - ( u * v_num - 2 ) / 2 elif manova_test == "Hotelling-Lawley Trace": df2 = s_val * (v_den - dv - 1) + 4 else: df2 = s_val * (v_den - dv + 1) + 4 ncp = adj_f2 * n_try * (1 - rho) from scipy.stats import ncf as noncentral_f, f as f_dist f_crit = f_dist.ppf(1 - alpha, df1, df2) p_cur = 1 - noncentral_f.cdf(f_crit, df1, df2, ncp) if p_cur >= power: n_s = n_try break else: n_s = None elif atype == "binomial": p0 = params["p0"] p1 = params["p1"] p1_adj = p0 + (p1 - p0) * mult p1_adj = max(min(p1_adj, 0.99), 0.01) adj_es = abs(p1_adj - p0) if p0 != p1_adj: from scipy.stats import binom n_s = None for n_try in range(3, 10000): if alternative == "two-sided": alpha_lo = binom.ppf(alpha / 2, n_try, p0) alpha_hi = binom.ppf(1 - alpha / 2, n_try, p0) p_pow = binom.cdf(alpha_hi, n_try, p1_adj) - binom.cdf( alpha_lo - 1, n_try, p1_adj ) elif p1_adj > p0: crit = binom.ppf(1 - alpha, n_try, p0) p_pow = 1 - binom.cdf(crit - 1, n_try, p1_adj) else: crit = binom.ppf(alpha, n_try, p0) p_pow = binom.cdf(crit, n_try, p1_adj) if p_pow >= power: n_s = n_try break else: n_s = None else: n_s = None except Exception: n_s = None if n_s is not None and n_s > 0 and n_s < 100000: sens_data.append( { "Effect Size Multiplier": f"{mult:.1f}×", "Adjusted Effect": ( f"{adj_es:.3f}" if mult != 1.0 else f"{adj_es:.3f} (baseline)" ), "Required N": n_s, } ) if sens_data: st.dataframe(pd.DataFrame(sens_data), use_container_width=True, hide_index=True) # --- Interpretation Guide --- with st.expander("📖 How to Interpret These Results"): if n_per_group is not None: if isinstance(n_per_group, tuple): n1, n2 = n_per_group st.markdown(f""" - You need **at least {n1} participants in Group 1** and **{n2} in Group 2**. - **Total**: {n_total} participants. - This assumes α = {alpha}, power = {power:.0%}, and your estimated effect size. - Use the **Power Curve** above to see how N affects your study's power. - The **Sensitivity Table** shows how N changes with different effect sizes. """) else: st.markdown(f""" - You need **at least {n_per_group} participants per group** (total N = {n_total}). - This assumes α = {alpha}, power = {power:.0%}, and your estimated effect size. - Use the **Power Curve** above to see how N affects your study's power. - The **Sensitivity Table** shows how N changes with different effect sizes. """) st.warning( "**Disclaimer**: Sample size estimation is based on statistical assumptions. " "Always consult a biostatistician and consider practical constraints (budget, " "dropout rates, feasibility) when finalizing your study size." ) # --- Budget & Feasibility --- if cost_per > 0 and n_total is not None: st.subheader("💰 Budget & Feasibility") total_cost = n_total * cost_per col_b1, col_b2, col_b3 = st.columns(3) with col_b1: st.metric("Total Study Cost", f"${total_cost:,.0f}") with col_b2: st.metric("Cost per Participant", f"${cost_per:,.0f}") with col_b3: if recruitment_rate > 0: months = int(np.ceil(n_total / recruitment_rate)) st.metric("Est. Recruitment Duration", f"{months} months") else: st.metric("Recruitment Rate", "Not specified") if dropout_rate > 0 and n_total_raw is not None: st.caption( f"Base N = {n_total_raw}, adjusted for {dropout_rate:.0%} dropout → {n_total}. " f"Extra cost due to dropout: ${(n_total - n_total_raw) * cost_per:,.0f}." ) # --- What-If Scenario Explorer --- st.subheader("🔍 What-If Scenario Explorer") st.caption( "Explore how sample size changes across different combinations of power and effect size." ) whatif_powers = [0.70, 0.80, 0.90] whatif_mults = [0.5, 0.75, 1.0, 1.25, 1.5] # Compute a heatmap: rows = power, cols = effect multiplier heatmap_data = [] for w_power in whatif_powers: w_z_beta = norm.ppf(w_power) row = [] for w_mult in whatif_mults: try: if atype == "one_mean": from statsmodels.stats.power import TTestPower d = params["effect_size"] * w_mult w_n = int( np.ceil( TTestPower().solve_power( effect_size=d, alpha=alpha, power=w_power, alternative=alternative, ) ) ) elif atype == "two_means": from statsmodels.stats.power import TTestIndPower d = params["effect_size"] * w_mult n1 = TTestIndPower().solve_power( effect_size=d, alpha=alpha, power=w_power, ratio=params["ratio"], alternative=alternative, ) n1 = int(np.ceil(n1)) w_n = n1 + int(np.ceil(n1 * params["ratio"])) elif atype == "paired": from statsmodels.stats.power import TTestPower d = params["effect_size"] * w_mult w_n = int( np.ceil( TTestPower().solve_power( effect_size=d, alpha=alpha, power=w_power, alternative=alternative, ) ) ) elif atype == "one_prop": from statsmodels.stats.proportion import proportion_effectsize from statsmodels.stats.power import NormalIndPower adj_p1 = ( params["prop_null"] + (params["prop_alt"] - params["prop_null"]) * w_mult ) adj_p1 = max(min(adj_p1, 0.99), 0.01) d_eff = proportion_effectsize(adj_p1, params["prop_null"]) w_n = int( np.ceil( NormalIndPower().solve_power( effect_size=abs(d_eff), alpha=alpha, power=w_power, alternative=alternative, ) ) ) elif atype == "two_prop": from statsmodels.stats.proportion import proportion_effectsize from statsmodels.stats.power import NormalIndPower adj_p2 = params["p1"] + (params["p2"] - params["p1"]) * w_mult adj_p2 = max(min(adj_p2, 0.99), 0.01) d_eff = proportion_effectsize(adj_p2, params["p1"]) n1 = NormalIndPower().solve_power( effect_size=abs(d_eff), alpha=alpha, power=w_power, ratio=params["ratio"], alternative=alternative, ) n1 = int(np.ceil(n1)) w_n = n1 + int(np.ceil(n1 * params["ratio"])) elif atype == "anova": from statsmodels.stats.power import FTestAnovaPower f_eff = params["effect_size"] * w_mult n_per_g = FTestAnovaPower().solve_power( effect_size=f_eff, alpha=alpha, power=w_power, k_groups=params["k"], ) n_per_g = int(np.ceil(n_per_g)) w_n = n_per_g * params["k"] elif atype == "correlation": import math r_val = max(min(params["effect_size"] * w_mult, 0.99), 0.01) fisher_z = math.atanh(r_val) w_n = int(np.ceil(3 + ((z_alpha + w_z_beta) / fisher_z) ** 2)) elif atype == "regression": from scipy.stats import ncf as noncentral_f, f as f_dist adj_f2 = params["effect_size"] * w_mult k_r = params["k"] w_n = None for nc in range(k_r + 2, 10000): dfd = nc - k_r - 1 ncp = adj_f2 * nc f_crit = f_dist.ppf(1 - alpha, k_r, dfd) p_cur = 1 - noncentral_f.cdf(f_crit, k_r, dfd, ncp) if p_cur >= w_power: w_n = nc break elif atype == "logistic": from statsmodels.stats.power import NormalIndPower adj_or = max(params["or"] ** w_mult, 1.01) ev_rate = params["event_rate"] k_log = params["k"] p1_log = (adj_or * ev_rate) / (1 - ev_rate + adj_or * ev_rate) d_log = abs(p1_log - ev_rate) p_bar = (ev_rate + p1_log) / 2 se = np.sqrt(2 * p_bar * (1 - p_bar)) d_eff = d_log / se if se > 0 else 0 w_n = int( np.ceil( max( NormalIndPower().solve_power( effect_size=d_eff, alpha=alpha, power=w_power, alternative=alternative, ), 10 * k_log, ) ) ) elif atype == "chisq": from statsmodels.stats.power import GofChisquarePower adj_w = params["effect_size"] * w_mult w_n = int( np.ceil( GofChisquarePower().solve_power( effect_size=adj_w, alpha=alpha, power=w_power, n_bins=params["df"] + 1, ) ) ) elif atype == "mannwhitney": from statsmodels.stats.power import NormalIndPower adj_p = max(min(params["effect_size"] * w_mult, 0.99), 0.01) d_mw = np.sqrt(3) * (adj_p - 0.5) are = params["are"] ratio = params["ratio"] n1 = NormalIndPower().solve_power( effect_size=d_mw, alpha=alpha, power=w_power, ratio=ratio, alternative=alternative, ) n1 = int(np.ceil(n1 / are)) w_n = n1 + int(np.ceil(n1 * ratio)) elif atype == "logrank": adj_hr = max(params["hr"] ** w_mult, 1.001) ratio = params["ratio"] med_ctrl = params["median_survival"] study_dur = params["study_duration"] log_hr = np.log(adj_hr) num_events = ( ((z_alpha + w_z_beta) ** 2) * ((ratio + 1) ** 2) / (ratio * (log_hr**2)) ) lambda_ctrl = np.log(2) / med_ctrl p_event_ctrl = 1 - np.exp(-lambda_ctrl * study_dur) p_event_trt = 1 - np.exp(-lambda_ctrl / adj_hr * study_dur) p_event = (p_event_ctrl + ratio * p_event_trt) / (1 + ratio) w_n = int(np.ceil(num_events / p_event)) if p_event > 0 else None elif atype == "cox": adj_hr = max(params["hr"] ** w_mult, 1.001) k = params["k"] sd_x = params["sd_x"] r2_x = params["r2_x"] ev_rate = params["event_rate"] log_hr = np.log(adj_hr) var_denom = (sd_x**2) * (log_hr**2) * (1 - r2_x) if var_denom > 0: num_events = ((z_alpha + w_z_beta) ** 2) / var_denom num_events = max(num_events, 10 * k) w_n = int(np.ceil(num_events / ev_rate)) else: w_n = None elif atype == "equiv": adj_margin = params["margin"] * w_mult exp_diff = params["expected_diff"] sd = params["sd"] ratio = params["ratio"] equiv_type = params.get("equiv_param_type", "Mean") if equiv_type == "Proportion": p1_eq = params.get("p1_eq", 0.2) p2_eq = params.get("p2_eq", 0.2) d_prop = abs(p1_eq - p2_eq) d_e = adj_margin - d_prop if d_e > 0: p_bar = (p1_eq + p2_eq) / 2 se = np.sqrt(2 * p_bar * (1 - p_bar)) es_e = d_e / se if se > 0 else 0 from statsmodels.stats.power import NormalIndPower n1 = NormalIndPower().solve_power( effect_size=es_e, alpha=alpha, power=w_power, ratio=ratio, alternative="larger", ) n1 = int(np.ceil(n1)) w_n = n1 + int(np.ceil(n1 * ratio)) else: w_n = None else: d_e = adj_margin - abs(exp_diff) if d_e > 0: es_e = d_e / sd from statsmodels.stats.power import NormalIndPower n1 = NormalIndPower().solve_power( effect_size=es_e, alpha=alpha, power=w_power, ratio=ratio, alternative="larger", ) n1 = int(np.ceil(n1)) w_n = n1 + int(np.ceil(n1 * ratio)) else: w_n = None elif atype == "rm_anova": from statsmodels.stats.power import FTestAnovaPower adj_es = params["effect_size"] * w_mult k = params["k"] m = params["m"] rho = params["rho"] epsilon = params["epsilon"] n_per_g = FTestAnovaPower().solve_power( effect_size=adj_es, alpha=alpha, power=w_power, k_groups=k ) design_effect = (1 + (m - 1) * rho) / m df_adj = (m - 1) * epsilon n_per_g_adj = max( int( np.ceil( n_per_g * design_effect * k / (k * df_adj / (k - 1)) ) ), int(np.ceil(n_per_g)), ) w_n = n_per_g_adj * k elif atype == "twoway_anova": from statsmodels.stats.power import FTestAnovaPower rows = params["rows"] cols = params["cols"] focus = params["focus"] if focus == "Main Effect A": adj_es = params["f_a"] * w_mult k_use = rows elif focus == "Main Effect B": adj_es = params["f_b"] * w_mult k_use = cols else: adj_es = params["f_ab"] * w_mult k_use = rows * cols n_per_cell = int( np.ceil( FTestAnovaPower().solve_power( effect_size=adj_es, alpha=alpha, power=w_power, k_groups=k_use, ) ) ) w_n = n_per_cell * rows * cols elif atype == "roc_auc": auc = params["auc"] adj_auc = 0.5 + (auc - 0.5) * w_mult adj_auc = max(min(adj_auc, 0.99), 0.51) null_auc = params["null_auc"] ratio = params["ratio"] v_auc = adj_auc * (1 - adj_auc) + (ratio - 1) * ( adj_auc / (2 - adj_auc) - adj_auc**2 ) / (1 + ratio) delta_auc = adj_auc - null_auc if delta_auc > 0: n_cases = int( np.ceil( ((z_alpha + w_z_beta) ** 2 * v_auc) / (delta_auc**2) ) ) n_controls = int(np.ceil(n_cases * ratio)) w_n = n_cases + n_controls else: w_n = None elif atype == "kappa": adj_kappa = params["kappa"] adj_kappa = 0.5 + (adj_kappa - 0.5) * w_mult adj_kappa = max(min(adj_kappa, 0.99), 0.01) null_kappa = params["null_kappa"] adj_kappa = max(adj_kappa, null_kappa + 0.01) delta_k = adj_kappa - null_kappa if delta_k > 0: w_n = int( np.ceil( ( (z_alpha + w_z_beta) ** 2 * null_kappa * (1 - null_kappa) ) / (delta_k**2) ) ) w_n = max(w_n, params["raters"] * params["categories"] * 5) else: w_n = None elif atype == "cluster_rct": from statsmodels.stats.power import TTestIndPower adj_d = params["effect_size"] * w_mult icc = params["icc"] cluster_m = params["cluster_size"] ratio = params["ratio"] deff = 1 + (cluster_m - 1) * icc n1_ind = TTestIndPower().solve_power( effect_size=adj_d, alpha=alpha, power=w_power, ratio=ratio, alternative=alternative, ) n1_ind = int(np.ceil(n1_ind)) n1_clust = int(np.ceil(n1_ind * deff)) n1_clust = int(np.ceil(n1_clust / cluster_m)) * cluster_m n2_clust = int(np.ceil(n1_clust * ratio)) w_n = n1_clust + n2_clust elif atype == "precision": adj_hw = params["half_width"] * w_mult conf_level = params["conf_level"] conf_alpha = 1 - conf_level / 100 param_type = params["param_type"] if param_type == "Mean": sd = params["sd"] if sd > 0 and adj_hw > 0: from scipy.stats import t as t_dist w_n = int( np.ceil( (norm.ppf(1 - conf_alpha / 2) * sd / adj_hw) ** 2 ) ) w_n = max(w_n, 3) for _ in range(20): t_val = t_dist.ppf(1 - conf_alpha / 2, df=w_n - 1) n_next = int(np.ceil((t_val * sd / adj_hw) ** 2)) n_next = max(n_next, 3) if n_next == w_n: break w_n = n_next else: w_n = None else: prop = params["prop"] z_hw = norm.ppf(1 - conf_alpha / 2) w_n = ( int(np.ceil((z_hw**2 * prop * (1 - prop)) / (adj_hw**2))) if prop > 0 and adj_hw > 0 else None ) if w_n is not None: w_n = max(w_n, 3) elif atype == "pilot": method = params["method"] if method == "Rule of thumb": w_n = int(np.ceil(params["n_per_group"] / max(w_mult, 0.1))) elif method == "Precision-based": conf_level = params["conf_level"] conf_alpha = 1 - conf_level / 100 param_type = params["param_type"] adj_hw = params["half_width"] * w_mult if param_type == "Mean": sd = params["sd"] if sd > 0 and adj_hw > 0: from scipy.stats import t as t_dist w_n = int( np.ceil( (norm.ppf(1 - conf_alpha / 2) * sd / adj_hw) ** 2 ) ) w_n = max(w_n, 3) for _ in range(20): t_val = t_dist.ppf(1 - conf_alpha / 2, df=w_n - 1) n_next = int(np.ceil((t_val * sd / adj_hw) ** 2)) n_next = max(n_next, 3) if n_next == w_n: break w_n = n_next else: w_n = None else: z_hw = norm.ppf(1 - conf_alpha / 2) prop = params["prop"] w_n = ( int( np.ceil((z_hw**2 * prop * (1 - prop)) / (adj_hw**2)) ) if prop > 0 and adj_hw > 0 else None ) if w_n is not None: w_n = max(w_n, 5) else: main_n = params["main_n"] fraction = params["fraction"] w_n = max(int(np.ceil(main_n * fraction / max(w_mult, 0.1))), 5) elif atype == "wilcoxon_sr": p_val = params["effect_size"] are = params["are"] d_wsr = np.sqrt(3) * (p_val - 0.5) * 2 if d_wsr > 0: from statsmodels.stats.power import NormalIndPower w_n = int( np.ceil( NormalIndPower().solve_power( effect_size=d_wsr, alpha=alpha, power=w_power, alternative=alternative, ) / are ) ) else: w_n = None elif atype == "kruskal": f_eff = params["effect_size"] * w_mult k = params["k"] are_kw = params.get("are", 0.955) if f_eff > 0: from statsmodels.stats.power import FTestAnovaPower n_per_g = FTestAnovaPower().solve_power( effect_size=f_eff, alpha=alpha, power=w_power, k_groups=k ) n_per_g = int(np.ceil(n_per_g / are_kw)) w_n = n_per_g * k else: w_n = None elif atype == "friedman": w = params["w"] k = params["k"] m = params["m"] are = params["are"] adj_w = max(min(w * w_mult, 0.99), 0.01) f_fr = np.sqrt(adj_w / (1 - adj_w)) if f_fr > 0: from statsmodels.stats.power import FTestAnovaPower n_per_g = FTestAnovaPower().solve_power( effect_size=f_fr, alpha=alpha, power=w_power, k_groups=k ) n_per_g = int(np.ceil(n_per_g * m / are)) w_n = n_per_g * k else: w_n = None elif atype == "mcnemar": p_b = params["p_b"] p_c = params["p_c"] p_b_adj = max(min(p_b * w_mult, 0.99), 0.01) p_c_adj = max(min(p_c * w_mult, 0.99), 0.01) d_mc = abs(p_b_adj - p_c_adj) p_discordant = p_b_adj + p_c_adj if d_mc > 0 and p_discordant > 0: w_n = max( int( np.ceil( ((z_alpha + w_z_beta) ** 2 * p_discordant) / (d_mc**2) ) ), 5, ) else: w_n = None elif atype == "fisher": p1 = params["p1"] p2 = params["p2"] ratio = params["ratio"] are_f = params.get("are", 0.833) adj_p2 = p1 + (p2 - p1) * w_mult adj_p2 = max(min(adj_p2, 0.99), 0.01) from statsmodels.stats.proportion import proportion_effectsize from statsmodels.stats.power import NormalIndPower d_eff = proportion_effectsize(adj_p2, p1) if abs(d_eff) > 0: n1 = NormalIndPower().solve_power( effect_size=abs(d_eff), alpha=alpha, power=w_power, ratio=ratio, alternative=alternative, ) n1 = int(np.ceil(n1 / are_f)) w_n = n1 + int(np.ceil(n1 * ratio)) else: w_n = None elif atype == "manova": k = params["k"] dv = params["dv"] f2 = params["f2"] rho = params["rho"] adj_f2 = f2 * w_mult manova_test = params.get("manova_test", "Pillai's Trace") if adj_f2 > 0: u = dv v_num = k - 1 w_n = None for n_try in range(k * dv + 2, 5000): v_den = n_try - k - dv if v_den <= 0: continue s_val = min(u, v_num) df1 = u * v_num if manova_test == "Pillai's Trace": df2 = s_val * (v_den - dv + 1) + 4 elif manova_test == "Wilks' Lambda": t_val = max( np.sqrt( (u**2 * v_num**2 - 4) / max(u**2 + v_num**2 - 5, 1) ), 1, ) df2 = (v_den - (u - v_num + 1) / 2) * t_val - ( u * v_num - 2 ) / 2 elif manova_test == "Hotelling-Lawley Trace": df2 = s_val * (v_den - dv - 1) + 4 else: df2 = s_val * (v_den - dv + 1) + 4 ncp = adj_f2 * n_try * (1 - rho) from scipy.stats import ncf as noncentral_f, f as f_dist f_crit = f_dist.ppf(1 - alpha, df1, df2) p_cur = 1 - noncentral_f.cdf(f_crit, df1, df2, ncp) if p_cur >= w_power: w_n = n_try break else: w_n = None elif atype == "binomial": p0 = params["p0"] p1 = params["p1"] p1_adj = p0 + (p1 - p0) * w_mult p1_adj = max(min(p1_adj, 0.99), 0.01) if p0 != p1_adj: from scipy.stats import binom w_n = None for n_try in range(3, 10000): if alternative == "two-sided": alpha_lo = binom.ppf(alpha / 2, n_try, p0) alpha_hi = binom.ppf(1 - alpha / 2, n_try, p0) p_pow = binom.cdf(alpha_hi, n_try, p1_adj) - binom.cdf( alpha_lo - 1, n_try, p1_adj ) elif p1_adj > p0: crit = binom.ppf(1 - alpha, n_try, p0) p_pow = 1 - binom.cdf(crit - 1, n_try, p1_adj) else: crit = binom.ppf(alpha, n_try, p0) p_pow = binom.cdf(crit, n_try, p1_adj) if p_pow >= w_power: w_n = n_try break else: w_n = None else: w_n = None except Exception: w_n = None row.append(w_n if w_n and w_n < 1000000 else None) heatmap_data.append(row) if any(any(r is not None for r in row) for row in heatmap_data): fig_heat = go.Figure( data=go.Heatmap( z=heatmap_data, x=[f"{m:.2f}×" for m in whatif_mults], y=[f"Power = {p:.0%}" for p in whatif_powers], text=[[str(v) if v else "—" for v in row] for row in heatmap_data], texttemplate="%{text}", colorscale="Blues", hovertemplate="Power: %{y}
Effect Size: %{x}
N: %{text}", ) ) fig_heat.update_layout( template="plotly_dark", height=250, xaxis_title="Effect Size Multiplier", yaxis_title="", ) st.plotly_chart(fig_heat, use_container_width=True) st.caption( "Cells show required N at each power × effect size combination. Adjust your design assumptions accordingly." ) # --- Inverse Power Analysis (Minimum Detectable Effect) --- st.subheader("🎯 Sensitivity Analysis: Minimum Detectable Effect") st.caption( "Given a fixed sample size, what is the smallest effect size your study can detect?" ) with st.expander( "Enter a candidate sample size to compute the minimum detectable effect" ): c1, c2 = st.columns([1, 3]) with c1: candidate_n = st.number_input( "Candidate N (total)", min_value=5, max_value=100000, value=n_total if n_total else 100, step=10, ) with c2: st.caption(" ") if st.button("Compute Minimum Detectable Effect", type="secondary"): try: from scipy.stats import t as t_dist_inv mde = None mde_label = "" mde_note = "" if atype == "one_mean": from statsmodels.stats.power import TTestPower mde = TTestPower().solve_power( effect_size=None, nobs=candidate_n, alpha=alpha, power=power, alternative=alternative, ) mde_label = "Cohen's d" elif atype == "two_means": from statsmodels.stats.power import TTestIndPower ratio_val = params.get("ratio", 1) n1 = candidate_n / (1 + ratio_val) mde = TTestIndPower().solve_power( effect_size=None, nobs1=n1, alpha=alpha, power=power, ratio=ratio_val, alternative=alternative, ) mde_label = "Cohen's d" elif atype == "paired": from statsmodels.stats.power import TTestPower mde = TTestPower().solve_power( effect_size=None, nobs=candidate_n, alpha=alpha, power=power, alternative=alternative, ) mde_label = "Cohen's d_z" elif atype == "one_prop": from statsmodels.stats.power import NormalIndPower d_eff = NormalIndPower().solve_power( effect_size=None, nobs1=candidate_n, alpha=alpha, power=power, alternative=alternative, ) p0 = params.get("prop_null", 0.5) mde = p0 + d_eff * np.sqrt(p0 * (1 - p0)) mde = max(0.01, min(0.99, mde)) mde_label = "Detectable proportion p₁" mde_note = f"(null p₀ = {p0})" elif atype == "two_prop": from statsmodels.stats.proportion import proportion_effectsize from statsmodels.stats.power import NormalIndPower ratio_val = params.get("ratio", 1) n1 = candidate_n / (1 + ratio_val) d_eff = NormalIndPower().solve_power( effect_size=None, nobs1=n1, alpha=alpha, power=power, ratio=ratio_val, alternative=alternative, ) p1_base = params.get("p1", 0.3) mde = p1_base + d_eff * np.sqrt(2 * p1_base * (1 - p1_base)) mde = max(0.01, min(0.99, mde)) mde_label = "Detectable proportion p₂" mde_note = f"(group 1 p₁ = {p1_base})" elif atype == "anova": from statsmodels.stats.power import FTestAnovaPower k_val = params.get("k", 3) mde = FTestAnovaPower().solve_power( effect_size=None, nobs=candidate_n, alpha=alpha, power=power, k_groups=k_val, ) mde_label = "Cohen's f" elif atype == "correlation": import math fisher_z = ( norm.ppf( 1 - alpha / 2 if alternative == "two-sided" else 1 - alpha ) + z_beta ) / np.sqrt(candidate_n - 3) mde = min(math.tanh(fisher_z), 0.99) mde_label = "Pearson r" elif atype == "chisq": from statsmodels.stats.power import GofChisquarePower df_val = params.get("df", 2) mde = GofChisquarePower().solve_power( effect_size=None, nobs=candidate_n, alpha=alpha, power=power, n_bins=df_val + 1, ) mde_label = "Cohen's w" elif atype == "mannwhitney": from statsmodels.stats.power import NormalIndPower are_val = params.get("are", 0.955) ratio_val = params.get("ratio", 1) n1_eff = candidate_n / (1 + ratio_val) * are_val d_mw = NormalIndPower().solve_power( effect_size=None, nobs1=n1_eff, alpha=alpha, power=power, ratio=ratio_val, alternative=alternative, ) mde = 0.5 + d_mw / np.sqrt(3) mde = max(0.51, min(0.99, mde)) mde_label = "P(X>Y)" elif atype == "wilcoxon_sr": from statsmodels.stats.power import NormalIndPower are_val = params.get("are", 0.955) d_z = NormalIndPower().solve_power( effect_size=None, nobs1=candidate_n * are_val, alpha=alpha, power=power, alternative=alternative, ) mde = 0.5 + d_z / (2 * np.sqrt(3)) mde = max(0.51, min(0.99, mde)) mde_label = "Pr(positive diff)" elif atype == "kruskal": from statsmodels.stats.power import FTestAnovaPower are_val = params.get("are", 0.955) k_val = params.get("k", 3) mde = FTestAnovaPower().solve_power( effect_size=None, nobs=candidate_n * are_val, alpha=alpha, power=power, k_groups=k_val, ) mde_label = "Cohen's f" elif atype == "mcnemar": p_b = params.get("p_b", 0.2) p_c = params.get("p_c", 0.4) p_disc = p_b + p_c if p_disc > 0: delta = ( norm.ppf( 1 - alpha / 2 if alternative == "two-sided" else 1 - alpha ) + z_beta ) * np.sqrt(p_disc / candidate_n) mde = max(delta, 0.01) mde_label = "|p_b − p_c|" elif atype == "fisher": from statsmodels.stats.proportion import proportion_effectsize from statsmodels.stats.power import NormalIndPower are_val = params.get("are", 0.833) ratio_val = params.get("ratio", 1) p1_base = params.get("p1", 0.3) n1 = candidate_n / (1 + ratio_val) * are_val d_eff = NormalIndPower().solve_power( effect_size=None, nobs1=n1, alpha=alpha, power=power, ratio=ratio_val, alternative=alternative, ) mde = p1_base + d_eff * np.sqrt(2 * p1_base * (1 - p1_base)) mde = max(0.01, min(0.99, mde)) mde_label = "Detectable proportion p₂" elif atype == "logrank": ratio_val = params.get("ratio", 1) hr_guess = params.get("hr", 2) num_events_est = candidate_n * 0.5 log_hr_min = ( norm.ppf( 1 - alpha / 2 if alternative == "two-sided" else 1 - alpha ) + z_beta ) * np.sqrt((ratio_val + 1) ** 2 / (ratio_val * num_events_est)) if log_hr_min > 0: mde = np.exp(log_hr_min) mde = max(1.001, min(10.0, mde)) mde_label = "Hazard Ratio (HR)" mde_note = "(approximate, depends on event probability)" elif atype == "cox": sd_x = params.get("sd_x", 1) r2_x = params.get("r2_x", 0) ev_rate = params.get("event_rate", 0.5) num_events_est = candidate_n * ev_rate var_denom_inv = ( norm.ppf( 1 - alpha / 2 if alternative == "two-sided" else 1 - alpha ) + z_beta ) ** 2 / num_events_est if var_denom_inv > 0 and sd_x > 0: log_hr_min = np.sqrt(var_denom_inv / (sd_x**2 * (1 - r2_x))) mde = np.exp(log_hr_min) mde = max(1.001, min(10.0, mde)) mde_label = "Hazard Ratio (HR)" mde_note = "(approximate)" elif atype == "logistic": import math ev_rate = params.get("event_rate", 0.3) or_val = params.get("or", 2) p1_log = (or_val * ev_rate) / (1 - ev_rate + or_val * ev_rate) d_log = abs(p1_log - ev_rate) p_bar = (ev_rate + p1_log) / 2 se = np.sqrt(2 * p_bar * (1 - p_bar)) if se > 0: d_eff = d_log / se from statsmodels.stats.power import NormalIndPower mde_d = NormalIndPower().solve_power( effect_size=None, nobs1=candidate_n, alpha=alpha, power=power, alternative=alternative, ) p1_mde = ev_rate + mde_d * se p1_mde = max(0.01, min(0.99, p1_mde)) mde = (p1_mde * (1 - ev_rate)) / (ev_rate * (1 - p1_mde)) mde = max(1.001, min(100.0, mde)) mde_label = "Odds Ratio (OR)" mde_note = "(approximate)" elif atype == "regression": k_r = params.get("k", 3) from scipy.stats import ncf as noncentral_f, f as f_dist dfd = candidate_n - k_r - 1 if dfd > 0: mde = None for f2_try in np.linspace(0.001, 2.0, 2000): ncp = f2_try * candidate_n f_crit = f_dist.ppf(1 - alpha, k_r, dfd) p_cur = 1 - noncentral_f.cdf(f_crit, k_r, dfd, ncp) if p_cur >= power: mde = f2_try break mde_label = "Cohen's f²" else: mde = None elif atype == "binomial": p0 = params.get("p0", 0.5) from scipy.stats import binom mde = None for p1_try in np.linspace(p0 + 0.001, 0.99, 990): if alternative == "two-sided": alpha_lo = binom.ppf(alpha / 2, candidate_n, p0) alpha_hi = binom.ppf(1 - alpha / 2, candidate_n, p0) p_pow = binom.cdf( alpha_hi, candidate_n, p1_try ) - binom.cdf(alpha_lo - 1, candidate_n, p1_try) elif p1_try > p0: crit = binom.ppf(1 - alpha, candidate_n, p0) p_pow = 1 - binom.cdf(crit - 1, candidate_n, p1_try) else: crit = binom.ppf(alpha, candidate_n, p0) p_pow = binom.cdf(crit, candidate_n, p1_try) if p_pow >= power: mde = p1_try break mde_label = "Detectable proportion p₁" elif atype == "equiv": margin = params.get("margin", 1.0) sd = params.get("sd", 1.0) ratio_v = params.get("ratio", 1) equiv_type = params.get("equiv_param_type", "Mean") n1_eff = candidate_n / (1 + ratio_v) if n1_eff > 1: from statsmodels.stats.power import NormalIndPower es_mde = NormalIndPower().solve_power( effect_size=None, nobs1=n1_eff, alpha=alpha, power=power, ratio=ratio_v, alternative="larger", ) if equiv_type == "Proportion": p_bar = ( params.get("p1_eq", 0.2) + params.get("p2_eq", 0.2) ) / 2 se = np.sqrt(2 * p_bar * (1 - p_bar)) mde = es_mde * se mde_label = "Detectable margin remaining (δ - |p₁−p₂|)" else: mde = es_mde * sd mde_label = "Detectable margin remaining (δ - |d|)" elif atype == "rm_anova": from statsmodels.stats.power import FTestAnovaPower f_eff = params.get("effect_size", 0.25) k_v = params.get("k", 2) m_v = params.get("m", 3) rho_v = params.get("rho", 0.5) eps_v = params.get("epsilon", 0.75) design_effect = (1 + (m_v - 1) * rho_v) / m_v df_adj = (m_v - 1) * eps_v n_eff = ( candidate_n * design_effect * (k_v - 1) / (k_v * df_adj / (k_v - 1)) ) if n_eff > k_v: mde = FTestAnovaPower().solve_power( effect_size=None, nobs=n_eff, alpha=alpha, power=power, k_groups=k_v, ) mde_label = "Cohen's f" elif atype == "twoway_anova": from statsmodels.stats.power import FTestAnovaPower rows_v = params.get("rows", 2) cols_v = params.get("cols", 2) focus_v = params.get("focus", "Main Effect A") k_use = ( rows_v if focus_v == "Main Effect A" else (cols_v if focus_v == "Main Effect B" else rows_v * cols_v) ) n_per_cell_eff = candidate_n / (rows_v * cols_v) if n_per_cell_eff > k_use: mde = FTestAnovaPower().solve_power( effect_size=None, nobs=n_per_cell_eff, alpha=alpha, power=power, k_groups=k_use, ) mde_label = "Cohen's f" if mde is not None and mde > 0: st.success( f"**Minimum detectable {mde_label}** with N = {candidate_n}: **{mde:.4f}** {mde_note}" ) else: st.warning( "Could not compute minimum detectable effect for this analysis type." ) except Exception as e: st.error(f"Could not compute minimum detectable effect: {e}") # --- Sample Size Justification --- st.subheader("📝 Sample Size Justification") st.caption( "Copy the full protocol below for your grant application, IRB submission, or research protocol." ) from datetime import datetime now_str = datetime.now().strftime("%Y-%m-%d %H:%M") atype_label = { "one_mean": "One-sample Mean t/z-test", "two_means": "Two Independent Means t-test", "paired": "Paired Means t-test", "one_prop": "One-sample Proportion", "two_prop": "Two Proportions", "anova": "One-way ANOVA", "correlation": "Pearson Correlation", "regression": "Multiple Linear Regression", "logistic": "Logistic Regression", "chisq": "Chi-Square Test", "mannwhitney": "Mann-Whitney / Wilcoxon (Non-parametric)", "logrank": "Log-Rank Test (Survival)", "cox": "Cox Regression", "equiv": "Equivalence / Non-Inferiority", "rm_anova": "Repeated Measures ANOVA", "twoway_anova": "Two-way / Factorial ANOVA", "roc_auc": "ROC / AUC Analysis", "kappa": "Cohen's Kappa / ICC Agreement", "cluster_rct": "Cluster-RCT / Multilevel", "precision": "Precision-based (CI Width)", "pilot": "Pilot / Feasibility Study", "wilcoxon_sr": "Wilcoxon Signed-Rank (paired)", "kruskal": "Kruskal-Wallis Test", "friedman": "Friedman Test", "mcnemar": "McNemar's Test", "fisher": "Fisher's Exact Test", "manova": "MANOVA (Multivariate ANOVA)", "binomial": "Binomial Exact Test", "simulation": "Simulation-based Power (Monte Carlo)", } es_val = params.get( "effect_size", params.get("or", params.get("w", params.get("f2", params.get("f", "N/A")))), ) es_str = ( f"{es_val:.4f}" if isinstance(es_val, (int, float, np.integer, np.floating)) else str(es_val) ) if n_per_group is not None: n_desc = ( f"{n_per_group} per group" if not isinstance(n_per_group, tuple) else f"{n_per_group[0]} (Group 1) and {n_per_group[1]} (Group 2)" ) protocol = f"""SAMPLE SIZE ESTIMATION PROTOCOL Generated: {now_str} Application: Statistical Test Finder (opencode.ai) Analysis: {atype_label.get(atype, atype)} Direction: {tails.lower()} Significance Level (α): {alpha * num_tests:.4f}""" if num_tests > 1: protocol += ( f" ({mc_method}-adjusted from {alpha_raw:.4f}, {num_tests} comparisons)" ) protocol += f""" Statistical Power (1−β): {power:.0%} Effect Size: {es_str} """ if isinstance(n_per_group, tuple): protocol += f"""Allocation Ratio (n₂/n₁): {n_per_group[1] / n_per_group[0]:.2f} """ protocol += f""" Required Sample Size: {n_desc} Total N: {n_total}""" if dropout_rate > 0 and n_total_raw is not None and n_total_raw != n_total: protocol += f""" Dropout Rate: {dropout_rate:.0%} Raw N (pre-dropout): {n_total_raw} Adjusted N (post-dropout): {n_total} """ else: protocol += "\n" if cost_per > 0: protocol += f"\nEstimated Study Cost: ${n_total * cost_per:,.0f}" if recruitment_rate > 0: protocol += f"\nEst. Recruitment Duration: {int(np.ceil(n_total / recruitment_rate))} months" protocol += "\n" else: protocol = f"""SAMPLE SIZE ESTIMATION PROTOCOL Generated: {now_str} Application: Statistical Test Finder (opencode.ai) Analysis: {atype_label.get(atype, atype)} Direction: {tails.lower()} Significance Level (α): {alpha * num_tests:.4f} Statistical Power (1−β): {power:.0%} Effect Size: {es_str} Required Total N: {n_total} """ fields_str = [] for k, v in params.items(): if k in ( "type", "alpha", "power", "tails", "dropout_rate", "num_tests", "cost_per", "recruitment_rate", ): continue fields_str.append(f" {k}: {v}") if fields_str: protocol += "\nFull Parameters:\n" + "\n".join(fields_str) + "\n" protocol += "\n--- Generated by Statistical Test Finder ---" if n_per_group is not None: if isinstance(n_per_group, tuple): n1, n2 = n_per_group justification = ( f"A sample size of {n1} in Group 1 and {n2} in Group 2 " f"(total N = {n_total}) was determined to provide {power:.0%} power " f"at a significance level of α = {alpha * num_tests:.3f} " ) if num_tests > 1: justification += f"({mc_method}-adjusted for {num_tests} comparisons, per-test α = {alpha:.4f}) " justification += ( f"to detect the anticipated effect size. " f"Sample size estimation was performed using a {tails.lower()} {explanation.split('to detect')[0].strip().lower()}. " ) if dropout_rate > 0: justification += ( f"To account for an anticipated dropout rate of {dropout_rate:.0%}, " f"the required sample was inflated from {n_total_raw} to {n_total} participants. " ) justification += "All calculations were performed using the Statistical Test Finder application." else: justification = ( f"A sample size of {n_per_group} per group (total N = {n_total}) " f"was determined to provide {power:.0%} power at a significance level of α = {alpha * num_tests:.3f} " ) if num_tests > 1: justification += f"({mc_method}-adjusted for {num_tests} comparisons, per-test α = {alpha:.4f}) " justification += ( f"to detect the anticipated effect size. " f"Sample size estimation was performed using a {tails.lower()} {explanation.split('to detect')[0].strip().lower()}. " ) if dropout_rate > 0: justification += ( f"To account for an anticipated dropout rate of {dropout_rate:.0%}, " f"the required sample was inflated from {n_total_raw} to {n_total} participants. " ) justification += "All calculations were performed using the Statistical Test Finder application." else: justification = f"A sample size of {n_total} participants was determined to provide {power:.0%} power at α = {alpha * num_tests:.3f}. All calculations were performed using the Statistical Test Finder application." st.text(justification) st.divider() with st.expander("📄 Full Protocol Text"): st.text_area( ":orange[Full Protocol (select all, Ctrl+C / Cmd+C to copy)]", protocol, height=350, ) # --- Key References --- with st.expander("📚 Key References"): st.markdown(""" **General Sample Size & Power:** - Cohen, J. (1988). *Statistical Power Analysis for the Behavioral Sciences* (2nd ed.). Lawrence Erlbaum Associates. - Faul, F., Erdfelder, E., Lang, A.-G., & Buchner, A. (2007). G*Power 3: A flexible statistical power analysis program. *Behavior Research Methods*, 39(2), 175–191. **Means & t-tests:** - Julious, S. A. (2004). Sample sizes for clinical trials with normal data. *Statistics in Medicine*, 23(12), 1921–1986. **Proportions:** - Fleiss, J. L., Levin, B., & Paik, M. C. (2003). *Statistical Methods for Rates and Proportions* (3rd ed.). Wiley. **ANOVA & F-tests:** - Cohen, J. (1988). *Statistical Power Analysis for the Behavioral Sciences* (2nd ed.). Lawrence Erlbaum Associates. **Regression:** - Green, S. B. (1991). How many subjects does it take to do a regression analysis? *Multivariate Behavioral Research*, 26(3), 499–510. - Hsieh, F. Y., Bloch, D. A., & Larsen, M. D. (1998). A simple method of sample size calculation for linear and logistic regression. *Statistics in Medicine*, 17(14), 1623–1634. **Correlation:** - Fisher, R. A. (1915). Frequency distribution of the values of the correlation coefficient in samples from an indefinitely large population. *Biometrika*, 10(4), 507–521. **Survival Analysis:** - Schoenfeld, D. (1983). Sample-size formula for the proportional-hazards regression model. *Biometrics*, 39(2), 499–503. - Freedman, L. S. (1982). Tables of the number of patients required in clinical trials using the logrank test. *Statistics in Medicine*, 1(2), 121–129. **Non-parametric Tests:** - Lehmann, E. L. (2006). *Nonparametrics: Statistical Methods Based on Ranks*. Springer. **Equivalence / Non-Inferiority:** - Blackwelder, W. C. (1982). "Proving the null hypothesis" in clinical trials. *Controlled Clinical Trials*, 3(4), 345–353. **Cluster-RCT:** - Donner, A., & Klar, N. (2000). *Design and Analysis of Cluster Randomization Trials in Health Research*. Arnold. **Repeated Measures:** - Greenhouse, S. W., & Geisser, S. (1959). On methods in the analysis of profile data. *Psychometrika*, 24(2), 95–112. **ROC / AUC:** - Obuchowski, N. A. (1994). Sample size calculations in studies of test accuracy. *Statistical Methods in Medical Research*, 7(4), 371–392. **Kappa / Agreement:** - Cantor, A. B. (1996). Sample-size calculations for Cohen's kappa. *Psychological Methods*, 1(2), 150–153. **Pilot Studies:** - Julious, S. A. (2005). Sample size of 12 per group rule of thumb for a pilot study. *Pharmaceutical Statistics*, 4(4), 287–291. - Whitehead, A. L., et al. (2016). Estimating the sample size for a pilot randomised trial to minimise the overall trial sample size. *Journal of Clinical Epidemiology*, 71, 23–29. **Multiple Testing:** - Bonferroni, C. E. (1936). Teoria statistica delle classi e calcolo delle probabilità. *Pubblicazioni del R Istituto Superiore di Scienze Economiche e Commerciali di Firenze*, 8, 3–62. """)