Skip to main content

solow_stats/
power.rs

1//! Statistical power for one-sample t-tests and two-sample normal tests.
2//!
3//! Mirrors the reference `TTestPower` and `NormalIndPower`. Both expose
4//! [`power`](TTestPower::power) and a [`solve_power`](TTestPower::solve_power)
5//! that inverts the power equation for the sample size at a target power.
6
7use crate::noncentral::{nct_cdf, nct_sf};
8use crate::weightstats::Alternative;
9use solow_distributions::{norm_cdf, norm_isf, norm_ppf, norm_sf, t_isf, t_ppf};
10
11/// Convert the alternative to the per-tail significance used by power formulas.
12fn alpha_tail(alpha: f64, alternative: Alternative) -> f64 {
13    match alternative {
14        Alternative::TwoSided => alpha / 2.0,
15        Alternative::Larger | Alternative::Smaller => alpha,
16    }
17}
18
19/// Power of a one-sample (or paired) t-test.
20///
21/// `effect_size` is the standardized mean (Cohen's d), `nobs` the sample size,
22/// and `df` defaults to `nobs − 1`. The power integrates the noncentral
23/// t-distribution with noncentrality `d·√nobs`. Mirrors the reference
24/// `ttest_power`.
25#[derive(Debug, Clone, Copy, Default)]
26pub struct TTestPower;
27
28impl TTestPower {
29    /// Power at the given configuration. `df = None` uses `nobs − 1`.
30    pub fn power(
31        &self,
32        effect_size: f64,
33        nobs: f64,
34        alpha: f64,
35        df: Option<f64>,
36        alternative: Alternative,
37    ) -> f64 {
38        let d = effect_size;
39        let df = df.unwrap_or(nobs - 1.0);
40        let alpha_ = alpha_tail(alpha, alternative);
41        let nc = d * nobs.sqrt();
42        let mut pow_ = 0.0;
43        if matches!(alternative, Alternative::TwoSided | Alternative::Larger) {
44            let crit_upp = t_isf(alpha_, df);
45            pow_ += nct_sf(crit_upp, df, nc);
46        }
47        if matches!(alternative, Alternative::TwoSided | Alternative::Smaller) {
48            let crit_low = t_ppf(alpha_, df);
49            pow_ += nct_cdf(crit_low, df, nc);
50        }
51        pow_
52    }
53
54    /// Solve for the sample size `nobs` that achieves `power` at the given
55    /// `effect_size` and `alpha`. Inverts the monotone power-in-`nobs` curve by
56    /// bracketed bisection (with `df = nobs − 1`).
57    pub fn solve_power(
58        &self,
59        effect_size: f64,
60        alpha: f64,
61        power: f64,
62        alternative: Alternative,
63    ) -> f64 {
64        let f = |n: f64| self.power(effect_size, n, alpha, None, alternative) - power;
65        solve_nobs(f, 2.000_001, 1.0e7)
66    }
67}
68
69/// Power of a two-sample z-test for independent samples (normal approximation).
70///
71/// `effect_size` is the standardized mean difference, `nobs1` the size of the
72/// first sample, and `ratio` the size of sample two relative to sample one
73/// (`nobs2 = ratio·nobs1`; `ratio = 0` gives the one-sample test). Mirrors the
74/// reference `NormalIndPower` with `ddof = 0`.
75#[derive(Debug, Clone, Copy, Default)]
76pub struct NormalIndPower;
77
78impl NormalIndPower {
79    /// Power at the given configuration.
80    pub fn power(
81        &self,
82        effect_size: f64,
83        nobs1: f64,
84        alpha: f64,
85        ratio: f64,
86        alternative: Alternative,
87    ) -> f64 {
88        let ddof = 0.0;
89        let nobs = if ratio > 0.0 {
90            let nobs2 = nobs1 * ratio;
91            1.0 / (1.0 / (nobs1 - ddof) + 1.0 / (nobs2 - ddof))
92        } else {
93            nobs1 - ddof
94        };
95        normal_power(effect_size, nobs, alpha, alternative)
96    }
97
98    /// Solve for `nobs1` achieving `power` at the given configuration.
99    pub fn solve_power(
100        &self,
101        effect_size: f64,
102        alpha: f64,
103        power: f64,
104        ratio: f64,
105        alternative: Alternative,
106    ) -> f64 {
107        let f = |n: f64| self.power(effect_size, n, alpha, ratio, alternative) - power;
108        solve_nobs(f, 1.000_001, 1.0e7)
109    }
110}
111
112/// Power of a normally distributed test statistic. Mirrors `normal_power`.
113fn normal_power(effect_size: f64, nobs: f64, alpha: f64, alternative: Alternative) -> f64 {
114    let d = effect_size;
115    let alpha_ = alpha_tail(alpha, alternative);
116    let mut pow_ = 0.0;
117    if matches!(alternative, Alternative::TwoSided | Alternative::Larger) {
118        let crit = norm_isf(alpha_);
119        pow_ += norm_sf(crit - d * nobs.sqrt());
120    }
121    if matches!(alternative, Alternative::TwoSided | Alternative::Smaller) {
122        let crit = norm_ppf(alpha_);
123        pow_ += norm_cdf(crit - d * nobs.sqrt());
124    }
125    pow_
126}
127
128/// Find the sample size where `f` (power minus target) crosses zero.
129///
130/// Power increases monotonically in `nobs`, so a simple bracket-and-bisect is
131/// robust and converges to machine precision.
132fn solve_nobs<F: Fn(f64) -> f64>(f: F, lo0: f64, hi0: f64) -> f64 {
133    let mut lo = lo0;
134    let mut hi = hi0;
135    let flo = f(lo);
136    let fhi = f(hi);
137    // Expect a sign change across the bracket.
138    if flo * fhi > 0.0 {
139        // No crossing in range; return the endpoint nearest zero.
140        return if flo.abs() < fhi.abs() { lo } else { hi };
141    }
142    for _ in 0..200 {
143        let mid = 0.5 * (lo + hi);
144        let fm = f(mid);
145        if fm == 0.0 {
146            return mid;
147        }
148        if (flo < 0.0) == (fm < 0.0) {
149            lo = mid;
150        } else {
151            hi = mid;
152        }
153        if hi - lo <= 1e-12 * (1.0 + hi) {
154            break;
155        }
156    }
157    0.5 * (lo + hi)
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn ttest_power_in_unit_interval() {
166        let tt = TTestPower;
167        let p = tt.power(0.5, 30.0, 0.05, None, Alternative::TwoSided);
168        assert!(p > 0.0 && p < 1.0);
169    }
170
171    #[test]
172    fn solve_power_roundtrips() {
173        let tt = TTestPower;
174        let n = tt.solve_power(0.5, 0.05, 0.8, Alternative::TwoSided);
175        let p = tt.power(0.5, n, 0.05, None, Alternative::TwoSided);
176        assert!((p - 0.8).abs() < 1e-6, "{p}");
177    }
178
179    #[test]
180    fn normal_power_solve_roundtrips() {
181        let nip = NormalIndPower;
182        let n = nip.solve_power(0.5, 0.05, 0.8, 1.0, Alternative::TwoSided);
183        let p = nip.power(0.5, n, 0.05, 1.0, Alternative::TwoSided);
184        assert!((p - 0.8).abs() < 1e-9, "{p}");
185    }
186}