Skip to main content

solow_stats/
meta_analysis.rs

1//! Meta-analysis — fixed-effect (inverse-variance weighting) and
2//! random-effects (DerSimonian-Laird) pooled estimates with a
3//! heterogeneity summary.
4
5use solow_core::{Error, Result};
6
7/// One study contribution to a meta-analysis.
8#[derive(Clone, Copy, Debug, PartialEq)]
9pub struct Study {
10    /// Point estimate `θᵢ`.
11    pub estimate: f64,
12    /// Standard error `SEᵢ` (must be > 0).
13    pub se: f64,
14}
15
16/// Pooled meta-analysis result.
17#[derive(Clone, Debug, PartialEq)]
18pub struct MetaResult {
19    /// Pooled estimate.
20    pub estimate: f64,
21    /// Pooled standard error.
22    pub se: f64,
23    /// 95% confidence interval `(lower, upper)`.
24    pub ci_95: (f64, f64),
25    /// Cochran's Q statistic.
26    pub q: f64,
27    /// Q's degrees of freedom.
28    pub df: usize,
29    /// Q's p-value against χ²(df).
30    pub q_pvalue: f64,
31    /// Higgins' I² — fraction of total variance due to heterogeneity.
32    pub i_squared: f64,
33    /// Between-study variance τ² (0 under a fixed-effect model).
34    pub tau_squared: f64,
35    /// Per-study inverse-variance weights.
36    pub weights: Vec<f64>,
37    /// Model kind.
38    pub model: MetaModel,
39}
40
41/// Pooling model.
42#[derive(Clone, Copy, Debug, PartialEq)]
43pub enum MetaModel {
44    /// Inverse-variance-weighted fixed effect.
45    FixedEffect,
46    /// DerSimonian-Laird random effects.
47    RandomEffects,
48}
49
50/// Fixed-effect meta-analysis.
51pub fn meta_fixed_effect(studies: &[Study]) -> Result<MetaResult> {
52    validate(studies)?;
53    let (q, weights, pooled, pooled_se) = fixed_effect_stats(studies);
54    let ci = ci_95(pooled, pooled_se);
55    let df = studies.len().saturating_sub(1);
56    let q_pvalue = chi2_survival(q, df as f64);
57    let i2 = i_squared(q, df as f64);
58    Ok(MetaResult {
59        estimate: pooled,
60        se: pooled_se,
61        ci_95: ci,
62        q,
63        df,
64        q_pvalue,
65        i_squared: i2,
66        tau_squared: 0.0,
67        weights,
68        model: MetaModel::FixedEffect,
69    })
70}
71
72/// DerSimonian-Laird random-effects meta-analysis.
73pub fn meta_random_effects(studies: &[Study]) -> Result<MetaResult> {
74    validate(studies)?;
75    let (q, weights_fe, pooled_fe, _) = fixed_effect_stats(studies);
76    let df = studies.len().saturating_sub(1) as f64;
77    // τ² = max(0, (Q − df) / (Σw − Σw² / Σw))
78    let sum_w: f64 = weights_fe.iter().sum();
79    let sum_w2: f64 = weights_fe.iter().map(|w| w * w).sum();
80    let c = sum_w - sum_w2 / sum_w.max(1e-30);
81    let tau2 = if c > 0.0 {
82        ((q - df) / c).max(0.0)
83    } else {
84        0.0
85    };
86    let mut weights_re = Vec::with_capacity(studies.len());
87    for s in studies {
88        weights_re.push(1.0 / (s.se * s.se + tau2));
89    }
90    let sum_w_re: f64 = weights_re.iter().sum();
91    let mut pooled = 0.0_f64;
92    for (i, s) in studies.iter().enumerate() {
93        pooled += weights_re[i] * s.estimate;
94    }
95    pooled /= sum_w_re.max(1e-30);
96    let pooled_se = (1.0 / sum_w_re.max(1e-30)).sqrt();
97    let ci = ci_95(pooled, pooled_se);
98    let q_pvalue = chi2_survival(q, df);
99    let i2 = i_squared(q, df);
100    Ok(MetaResult {
101        estimate: pooled,
102        se: pooled_se,
103        ci_95: ci,
104        q,
105        df: df as usize,
106        q_pvalue,
107        i_squared: i2,
108        tau_squared: tau2,
109        weights: weights_re,
110        model: MetaModel::RandomEffects,
111    })
112}
113
114fn validate(studies: &[Study]) -> Result<()> {
115    if studies.len() < 2 {
116        return Err(Error::Value("meta_analysis: need ≥ 2 studies".into()));
117    }
118    for s in studies {
119        if !(s.se > 0.0 && s.se.is_finite()) {
120            return Err(Error::Value("meta_analysis: SEs must be finite and > 0".into()));
121        }
122    }
123    Ok(())
124}
125
126fn fixed_effect_stats(studies: &[Study]) -> (f64, Vec<f64>, f64, f64) {
127    let mut weights = Vec::with_capacity(studies.len());
128    let mut sum_w = 0.0_f64;
129    let mut sum_wy = 0.0_f64;
130    for s in studies {
131        let w = 1.0 / (s.se * s.se);
132        weights.push(w);
133        sum_w += w;
134        sum_wy += w * s.estimate;
135    }
136    let pooled = sum_wy / sum_w;
137    let pooled_se = (1.0 / sum_w).sqrt();
138    // Cochran's Q.
139    let mut q = 0.0_f64;
140    for (i, s) in studies.iter().enumerate() {
141        q += weights[i] * (s.estimate - pooled).powi(2);
142    }
143    (q, weights, pooled, pooled_se)
144}
145
146fn ci_95(estimate: f64, se: f64) -> (f64, f64) {
147    (estimate - 1.959963984540054 * se, estimate + 1.959963984540054 * se)
148}
149
150fn i_squared(q: f64, df: f64) -> f64 {
151    if q <= df || df <= 0.0 {
152        return 0.0;
153    }
154    ((q - df) / q).clamp(0.0, 1.0)
155}
156
157fn chi2_survival(x: f64, df: f64) -> f64 {
158    if x <= 0.0 || df <= 0.0 {
159        return 1.0;
160    }
161    1.0 - lower_regularised_gamma(df / 2.0, x / 2.0)
162}
163
164fn lower_regularised_gamma(s: f64, x: f64) -> f64 {
165    if x < 0.0 || s <= 0.0 {
166        return 0.0;
167    }
168    if x < s + 1.0 {
169        gamma_series(s, x)
170    } else {
171        1.0 - gamma_continued_fraction(s, x)
172    }
173}
174
175fn gamma_series(s: f64, x: f64) -> f64 {
176    let mut sum = 1.0 / s;
177    let mut term = sum;
178    for n in 1..200 {
179        term *= x / (s + n as f64);
180        sum += term;
181        if term.abs() < sum.abs() * 3e-15 {
182            break;
183        }
184    }
185    sum * (-x + s * x.ln() - ln_gamma(s)).exp()
186}
187
188fn gamma_continued_fraction(s: f64, x: f64) -> f64 {
189    let mut b = x + 1.0 - s;
190    let mut c = 1.0 / 1e-300;
191    let mut d = 1.0 / b;
192    let mut h = d;
193    for i in 1..200 {
194        let an = -(i as f64) * (i as f64 - s);
195        b += 2.0;
196        d = an * d + b;
197        if d.abs() < 1e-300 {
198            d = 1e-300;
199        }
200        c = b + an / c;
201        if c.abs() < 1e-300 {
202            c = 1e-300;
203        }
204        d = 1.0 / d;
205        let delta = d * c;
206        h *= delta;
207        if (delta - 1.0).abs() < 3e-15 {
208            break;
209        }
210    }
211    (-x + s * x.ln() - ln_gamma(s)).exp() * h
212}
213
214fn ln_gamma(x: f64) -> f64 {
215    let g = 7.0;
216    let cof = [
217        0.999_999_999_999_809_93,
218        676.520_368_121_885_1,
219        -1_259.139_216_722_402_8,
220        771.323_428_777_653_13,
221        -176.615_029_162_140_59,
222        12.507_343_278_686_905,
223        -0.138_571_095_265_720_12,
224        9.984_369_578_019_571_5e-6,
225        1.505_632_735_149_311_6e-7,
226    ];
227    if x < 0.5 {
228        std::f64::consts::PI.ln() - (std::f64::consts::PI * x).sin().ln() - ln_gamma(1.0 - x)
229    } else {
230        let x = x - 1.0;
231        let mut a = cof[0];
232        let t = x + g + 0.5;
233        for (i, &c) in cof.iter().enumerate().skip(1) {
234            a += c / (x + i as f64);
235        }
236        0.5 * (2.0 * std::f64::consts::PI).ln() + (x + 0.5) * t.ln() - t + a.ln()
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn fixed_effect_returns_the_weighted_average() {
246        let studies = vec![
247            Study { estimate: 0.5, se: 0.1 },
248            Study { estimate: 0.6, se: 0.1 },
249            Study { estimate: 0.55, se: 0.15 },
250        ];
251        let r = meta_fixed_effect(&studies).unwrap();
252        assert!(r.estimate > 0.5 && r.estimate < 0.6);
253        assert!(r.se > 0.0);
254        assert!(r.i_squared >= 0.0 && r.i_squared <= 1.0);
255    }
256
257    #[test]
258    fn random_effects_widens_ci_versus_fixed_effect_under_heterogeneity() {
259        let studies = vec![
260            Study { estimate: 0.5, se: 0.05 },
261            Study { estimate: 1.5, se: 0.05 },
262            Study { estimate: -0.2, se: 0.05 },
263        ];
264        let fe = meta_fixed_effect(&studies).unwrap();
265        let re = meta_random_effects(&studies).unwrap();
266        assert!(re.se >= fe.se);
267        assert!(re.tau_squared > 0.0);
268    }
269}