Skip to main content

solow_stats/
correlation.rs

1//! Pearson, Spearman, and Kendall correlation coefficients with
2//! two-sided p-values.
3
4use solow_core::{Error, Result};
5
6/// A correlation coefficient with its two-sided p-value.
7#[derive(Clone, Copy, Debug, PartialEq)]
8pub struct CorrelationResult {
9    /// The estimated coefficient in `[-1, 1]`.
10    pub statistic: f64,
11    /// Two-sided p-value under the null of no association.
12    pub pvalue: f64,
13}
14
15/// Pearson product-moment correlation.
16pub fn pearsonr(x: &[f64], y: &[f64]) -> Result<CorrelationResult> {
17    let n = x.len();
18    if n < 3 || y.len() != n {
19        return Err(Error::Value(
20            "pearsonr: need n ≥ 3 and matched lengths".into(),
21        ));
22    }
23    let mean_x: f64 = x.iter().sum::<f64>() / n as f64;
24    let mean_y: f64 = y.iter().sum::<f64>() / n as f64;
25    let mut sxy = 0.0_f64;
26    let mut sxx = 0.0_f64;
27    let mut syy = 0.0_f64;
28    for i in 0..n {
29        let dx = x[i] - mean_x;
30        let dy = y[i] - mean_y;
31        sxy += dx * dy;
32        sxx += dx * dx;
33        syy += dy * dy;
34    }
35    let denom = (sxx * syy).sqrt();
36    if denom < 1e-300 {
37        return Err(Error::Value(
38            "pearsonr: at least one column has zero variance".into(),
39        ));
40    }
41    let r = (sxy / denom).clamp(-1.0, 1.0);
42    // Two-sided p from a t(n − 2) distribution: t = r · sqrt((n − 2)/(1 − r²)).
43    let pvalue = if r.abs() >= 1.0 - 1e-14 {
44        0.0
45    } else {
46        let df = (n - 2) as f64;
47        let t = r * (df / (1.0 - r * r)).sqrt();
48        2.0 * student_t_survival(t.abs(), df)
49    };
50    Ok(CorrelationResult {
51        statistic: r,
52        pvalue,
53    })
54}
55
56/// Spearman rank correlation.
57pub fn spearmanr(x: &[f64], y: &[f64]) -> Result<CorrelationResult> {
58    if x.len() != y.len() || x.len() < 3 {
59        return Err(Error::Value(
60            "spearmanr: need n ≥ 3 and matched lengths".into(),
61        ));
62    }
63    let rx = ranks_with_ties(x);
64    let ry = ranks_with_ties(y);
65    pearsonr(&rx, &ry)
66}
67
68/// Kendall τ-b — with ties correction.
69pub fn kendalltau(x: &[f64], y: &[f64]) -> Result<CorrelationResult> {
70    let n = x.len();
71    if y.len() != n || n < 3 {
72        return Err(Error::Value(
73            "kendalltau: need n ≥ 3 and matched lengths".into(),
74        ));
75    }
76    let mut concordant = 0_i64;
77    let mut discordant = 0_i64;
78    let mut ties_x = 0_i64;
79    let mut ties_y = 0_i64;
80    for i in 0..n {
81        for j in (i + 1)..n {
82            let dx = x[i] - x[j];
83            let dy = y[i] - y[j];
84            let sx = dx.signum();
85            let sy = dy.signum();
86            if dx == 0.0 && dy == 0.0 {
87                // ignore joint ties
88            } else if dx == 0.0 {
89                ties_x += 1;
90            } else if dy == 0.0 {
91                ties_y += 1;
92            } else if sx == sy {
93                concordant += 1;
94            } else {
95                discordant += 1;
96            }
97        }
98    }
99    let n0 = n as f64 * (n as f64 - 1.0) / 2.0;
100    let tau_b = (concordant - discordant) as f64
101        / (((n0 - ties_x as f64) * (n0 - ties_y as f64))
102            .sqrt()
103            .max(1e-300));
104    // Two-sided normal-approximation p-value.
105    let var = (2.0 * (2.0 * n as f64 + 5.0)) / (9.0 * n as f64 * (n as f64 - 1.0));
106    let z = tau_b / var.sqrt();
107    let pvalue = 2.0 * standard_normal_survival(z.abs());
108    Ok(CorrelationResult {
109        statistic: tau_b,
110        pvalue,
111    })
112}
113
114fn ranks_with_ties(x: &[f64]) -> Vec<f64> {
115    let n = x.len();
116    let mut idx: Vec<usize> = (0..n).collect();
117    idx.sort_by(|&a, &b| x[a].partial_cmp(&x[b]).unwrap());
118    let mut ranks = vec![0.0_f64; n];
119    let mut i = 0;
120    while i < n {
121        let mut j = i;
122        while j + 1 < n && x[idx[j + 1]] == x[idx[i]] {
123            j += 1;
124        }
125        let avg = ((i + j) as f64 + 2.0) / 2.0; // 1-based
126        for k in i..=j {
127            ranks[idx[k]] = avg;
128        }
129        i = j + 1;
130    }
131    ranks
132}
133
134fn standard_normal_survival(z: f64) -> f64 {
135    0.5 * erfc(z / std::f64::consts::SQRT_2)
136}
137
138fn erfc(x: f64) -> f64 {
139    // Abramowitz-Stegun 7.1.26 approximation (max error ≈ 1.5e-7).
140    let a1 = 0.254_829_592;
141    let a2 = -0.284_496_736;
142    let a3 = 1.421_413_741;
143    let a4 = -1.453_152_027;
144    let a5 = 1.061_405_429;
145    let p = 0.327_591_1;
146    let sign = if x < 0.0 { -1.0 } else { 1.0 };
147    let ax = x.abs();
148    let t = 1.0 / (1.0 + p * ax);
149    let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-ax * ax).exp();
150    1.0 - sign * y
151}
152
153fn student_t_survival(t: f64, df: f64) -> f64 {
154    // Two-sided-safe survival S(t) = P(T > t) for T ~ t(df).
155    // Uses the incomplete-beta identity:
156    //   S(t) = 0.5 · I_{df/(df+t²)}(df/2, 1/2)  for t > 0.
157    if t <= 0.0 {
158        return 0.5;
159    }
160    let x = df / (df + t * t);
161    0.5 * regularised_incomplete_beta(x, df / 2.0, 0.5)
162}
163
164fn regularised_incomplete_beta(x: f64, a: f64, b: f64) -> f64 {
165    if x <= 0.0 {
166        return 0.0;
167    }
168    if x >= 1.0 {
169        return 1.0;
170    }
171    let ln_beta = ln_gamma(a) + ln_gamma(b) - ln_gamma(a + b);
172    let front = ((a * x.ln() + b * (1.0 - x).ln()) - ln_beta).exp() / a;
173    if x < (a + 1.0) / (a + b + 2.0) {
174        front * betacf(x, a, b)
175    } else {
176        1.0 - front * betacf(1.0 - x, b, a) * (front / front).max(1.0)
177    }
178}
179
180fn betacf(x: f64, a: f64, b: f64) -> f64 {
181    let mut c = 1.0_f64;
182    let qab = a + b;
183    let qap = a + 1.0;
184    let qam = a - 1.0;
185    let mut d = 1.0 - qab * x / qap;
186    if d.abs() < 1e-300 {
187        d = 1e-300;
188    }
189    d = 1.0 / d;
190    let mut h = d;
191    for m in 1..200 {
192        let mf = m as f64;
193        let two_m = 2.0 * mf;
194        let mut aa = mf * (b - mf) * x / ((qam + two_m) * (a + two_m));
195        d = 1.0 + aa * d;
196        if d.abs() < 1e-300 {
197            d = 1e-300;
198        }
199        c = 1.0 + aa / c;
200        if c.abs() < 1e-300 {
201            c = 1e-300;
202        }
203        d = 1.0 / d;
204        h *= d * c;
205        aa = -(a + mf) * (qab + mf) * x / ((a + two_m) * (qap + two_m));
206        d = 1.0 + aa * d;
207        if d.abs() < 1e-300 {
208            d = 1e-300;
209        }
210        c = 1.0 + aa / c;
211        if c.abs() < 1e-300 {
212            c = 1e-300;
213        }
214        d = 1.0 / d;
215        let delta = d * c;
216        h *= delta;
217        if (delta - 1.0).abs() < 3e-15 {
218            break;
219        }
220    }
221    h
222}
223
224fn ln_gamma(x: f64) -> f64 {
225    // Lanczos approximation.
226    let g = 7.0;
227    let cof = [
228        0.999_999_999_999_809_93,
229        676.520_368_121_885_1,
230        -1_259.139_216_722_402_8,
231        771.323_428_777_653_13,
232        -176.615_029_162_140_59,
233        12.507_343_278_686_905,
234        -0.138_571_095_265_720_12,
235        9.984_369_578_019_571_5e-6,
236        1.505_632_735_149_311_6e-7,
237    ];
238    if x < 0.5 {
239        std::f64::consts::PI.ln() - (std::f64::consts::PI * x).sin().ln() - ln_gamma(1.0 - x)
240    } else {
241        let x = x - 1.0;
242        let mut a = cof[0];
243        let t = x + g + 0.5;
244        for (i, &c) in cof.iter().enumerate().skip(1) {
245            a += c / (x + i as f64);
246        }
247        0.5 * (2.0 * std::f64::consts::PI).ln() + (x + 0.5) * t.ln() - t + a.ln()
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn pearsonr_recovers_perfect_positive_correlation() {
257        let x = vec![1.0_f64, 2.0, 3.0, 4.0, 5.0];
258        let y = vec![2.0_f64, 4.0, 6.0, 8.0, 10.0];
259        let r = pearsonr(&x, &y).unwrap();
260        assert!((r.statistic - 1.0).abs() < 1e-12);
261        assert!(r.pvalue < 1e-6);
262    }
263
264    #[test]
265    fn spearmanr_handles_ties_correctly() {
266        let x = vec![1.0_f64, 2.0, 2.0, 3.0, 4.0];
267        let y = vec![1.0_f64, 3.0, 3.0, 5.0, 7.0];
268        let r = spearmanr(&x, &y).unwrap();
269        assert!(r.statistic > 0.9);
270    }
271
272    #[test]
273    fn kendalltau_returns_a_value_in_the_valid_range() {
274        let x = vec![1.0_f64, 2.0, 3.0, 4.0, 5.0];
275        let y = vec![5.0_f64, 4.0, 3.0, 2.0, 1.0];
276        let r = kendalltau(&x, &y).unwrap();
277        assert!((r.statistic - (-1.0)).abs() < 1e-12);
278        assert!(r.pvalue < 0.1);
279    }
280}