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(
121                "meta_analysis: SEs must be finite and > 0".into(),
122            ));
123        }
124    }
125    Ok(())
126}
127
128fn fixed_effect_stats(studies: &[Study]) -> (f64, Vec<f64>, f64, f64) {
129    let mut weights = Vec::with_capacity(studies.len());
130    let mut sum_w = 0.0_f64;
131    let mut sum_wy = 0.0_f64;
132    for s in studies {
133        let w = 1.0 / (s.se * s.se);
134        weights.push(w);
135        sum_w += w;
136        sum_wy += w * s.estimate;
137    }
138    let pooled = sum_wy / sum_w;
139    let pooled_se = (1.0 / sum_w).sqrt();
140    // Cochran's Q.
141    let mut q = 0.0_f64;
142    for (i, s) in studies.iter().enumerate() {
143        q += weights[i] * (s.estimate - pooled).powi(2);
144    }
145    (q, weights, pooled, pooled_se)
146}
147
148fn ci_95(estimate: f64, se: f64) -> (f64, f64) {
149    (
150        estimate - 1.959963984540054 * se,
151        estimate + 1.959963984540054 * se,
152    )
153}
154
155fn i_squared(q: f64, df: f64) -> f64 {
156    if q <= df || df <= 0.0 {
157        return 0.0;
158    }
159    ((q - df) / q).clamp(0.0, 1.0)
160}
161
162fn chi2_survival(x: f64, df: f64) -> f64 {
163    if x <= 0.0 || df <= 0.0 {
164        return 1.0;
165    }
166    1.0 - lower_regularised_gamma(df / 2.0, x / 2.0)
167}
168
169fn lower_regularised_gamma(s: f64, x: f64) -> f64 {
170    if x < 0.0 || s <= 0.0 {
171        return 0.0;
172    }
173    if x < s + 1.0 {
174        gamma_series(s, x)
175    } else {
176        1.0 - gamma_continued_fraction(s, x)
177    }
178}
179
180fn gamma_series(s: f64, x: f64) -> f64 {
181    let mut sum = 1.0 / s;
182    let mut term = sum;
183    for n in 1..200 {
184        term *= x / (s + n as f64);
185        sum += term;
186        if term.abs() < sum.abs() * 3e-15 {
187            break;
188        }
189    }
190    sum * (-x + s * x.ln() - ln_gamma(s)).exp()
191}
192
193fn gamma_continued_fraction(s: f64, x: f64) -> f64 {
194    let mut b = x + 1.0 - s;
195    let mut c = 1.0 / 1e-300;
196    let mut d = 1.0 / b;
197    let mut h = d;
198    for i in 1..200 {
199        let an = -(i as f64) * (i as f64 - s);
200        b += 2.0;
201        d = an * d + b;
202        if d.abs() < 1e-300 {
203            d = 1e-300;
204        }
205        c = b + an / c;
206        if c.abs() < 1e-300 {
207            c = 1e-300;
208        }
209        d = 1.0 / d;
210        let delta = d * c;
211        h *= delta;
212        if (delta - 1.0).abs() < 3e-15 {
213            break;
214        }
215    }
216    (-x + s * x.ln() - ln_gamma(s)).exp() * h
217}
218
219fn ln_gamma(x: f64) -> f64 {
220    let g = 7.0;
221    let cof = [
222        0.999_999_999_999_809_93,
223        676.520_368_121_885_1,
224        -1_259.139_216_722_402_8,
225        771.323_428_777_653_13,
226        -176.615_029_162_140_59,
227        12.507_343_278_686_905,
228        -0.138_571_095_265_720_12,
229        9.984_369_578_019_571_5e-6,
230        1.505_632_735_149_311_6e-7,
231    ];
232    if x < 0.5 {
233        std::f64::consts::PI.ln() - (std::f64::consts::PI * x).sin().ln() - ln_gamma(1.0 - x)
234    } else {
235        let x = x - 1.0;
236        let mut a = cof[0];
237        let t = x + g + 0.5;
238        for (i, &c) in cof.iter().enumerate().skip(1) {
239            a += c / (x + i as f64);
240        }
241        0.5 * (2.0 * std::f64::consts::PI).ln() + (x + 0.5) * t.ln() - t + a.ln()
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn fixed_effect_returns_the_weighted_average() {
251        let studies = vec![
252            Study {
253                estimate: 0.5,
254                se: 0.1,
255            },
256            Study {
257                estimate: 0.6,
258                se: 0.1,
259            },
260            Study {
261                estimate: 0.55,
262                se: 0.15,
263            },
264        ];
265        let r = meta_fixed_effect(&studies).unwrap();
266        assert!(r.estimate > 0.5 && r.estimate < 0.6);
267        assert!(r.se > 0.0);
268        assert!(r.i_squared >= 0.0 && r.i_squared <= 1.0);
269    }
270
271    #[test]
272    fn random_effects_widens_ci_versus_fixed_effect_under_heterogeneity() {
273        let studies = vec![
274            Study {
275                estimate: 0.5,
276                se: 0.05,
277            },
278            Study {
279                estimate: 1.5,
280                se: 0.05,
281            },
282            Study {
283                estimate: -0.2,
284                se: 0.05,
285            },
286        ];
287        let fe = meta_fixed_effect(&studies).unwrap();
288        let re = meta_random_effects(&studies).unwrap();
289        assert!(re.se >= fe.se);
290        assert!(re.tau_squared > 0.0);
291    }
292}