Skip to main content

solow_stats/
correlation_tools.rs

1//! Nearest positive-semidefinite correlation / covariance matrices and the
2//! correlation ↔ covariance helpers.
3//!
4//! Provides [`cov2corr`] / [`corr2cov`] (the reference `moment_helpers`),
5//! [`corr_clipped`] (single eigenvalue clip), [`corr_nearest`] (Higham-style
6//! iterative alternating projection), and [`cov_nearest`] which routes a
7//! covariance matrix through the chosen correlation correction.
8//!
9//! Mirrors the reference `…stats.correlation_tools` (`corr_nearest`,
10//! `corr_clipped`, `cov_nearest`) and `…stats.moment_helpers`
11//! (`cov2corr`, `corr2cov`).
12
13use ndarray::{Array1, Array2};
14use solow_core::error::{Error, Result};
15use solow_linalg::eigh;
16
17/// Convert a covariance matrix to a correlation matrix, `Cᵢⱼ / (σᵢ σⱼ)`.
18///
19/// Mirrors the reference `cov2corr`.
20pub fn cov2corr(cov: &Array2<f64>) -> Array2<f64> {
21    let (corr, _) = cov2corr_std(cov);
22    corr
23}
24
25/// Like [`cov2corr`] but also returns the per-variable standard deviations
26/// `σᵢ = sqrt(Cᵢᵢ)` (the reference `cov2corr(..., return_std=True)`).
27pub fn cov2corr_std(cov: &Array2<f64>) -> (Array2<f64>, Array1<f64>) {
28    let k = cov.nrows();
29    let std: Array1<f64> = Array1::from_iter((0..k).map(|i| cov[[i, i]].sqrt()));
30    let mut corr = Array2::<f64>::zeros((k, k));
31    for i in 0..k {
32        for j in 0..k {
33            corr[[i, j]] = cov[[i, j]] / (std[i] * std[j]);
34        }
35    }
36    (corr, std)
37}
38
39/// Convert a correlation matrix to a covariance matrix given standard
40/// deviations `std`: `Rᵢⱼ · σᵢ σⱼ`. Mirrors the reference `corr2cov`.
41pub fn corr2cov(corr: &Array2<f64>, std: &Array1<f64>) -> Array2<f64> {
42    let k = corr.nrows();
43    let mut cov = Array2::<f64>::zeros((k, k));
44    for i in 0..k {
45        for j in 0..k {
46            cov[[i, j]] = corr[[i, j]] * std[i] * std[j];
47        }
48    }
49    cov
50}
51
52/// Clip the eigenvalues of `x` from below at `value`, returning the rebuilt
53/// matrix `V diag(max(w, value)) Vᵀ` and whether any eigenvalue was clipped.
54fn clip_evals(x: &Array2<f64>, value: f64) -> Result<(Array2<f64>, bool)> {
55    let (w, v) = eigh(x)?;
56    let clipped = w.iter().any(|&e| e < value);
57    let k = w.len();
58    // x_new = V · diag(max(w, value)) · Vᵀ
59    let mut scaled = v.clone(); // columns scaled by clamped eigenvalues
60    for j in 0..k {
61        let ev = w[j].max(value);
62        for i in 0..k {
63            scaled[[i, j]] *= ev;
64        }
65    }
66    let x_new = scaled.dot(&v.t());
67    Ok((x_new, clipped))
68}
69
70/// Nearest PSD correlation matrix by a single eigenvalue clip plus rescaling so
71/// the diagonal is one.
72///
73/// If `corr` is already PSD at the given `threshold` the input is returned
74/// unchanged. Mirrors the reference `corr_clipped`.
75pub fn corr_clipped(corr: &Array2<f64>, threshold: f64) -> Result<Array2<f64>> {
76    let (x_new, clipped) = clip_evals(corr, threshold)?;
77    if !clipped {
78        return Ok(corr.clone());
79    }
80    // Rescale to unit diagonal: x / outer(d, d) with d = sqrt(diag).
81    let k = x_new.nrows();
82    let d: Vec<f64> = (0..k).map(|i| x_new[[i, i]].sqrt()).collect();
83    let mut out = Array2::<f64>::zeros((k, k));
84    for i in 0..k {
85        for j in 0..k {
86            out[[i, j]] = x_new[[i, j]] / (d[i] * d[j]);
87        }
88    }
89    Ok(out)
90}
91
92/// Nearest PSD correlation matrix by Higham-style alternating projection.
93///
94/// Iteratively clips the eigenvalues of `x − Δ` from below at `threshold`,
95/// resets the diagonal to one, and accumulates the correction `Δ`. Stops early
96/// once the clip leaves the matrix unchanged (already PSD at the threshold),
97/// otherwise runs up to `k · n_fact` iterations. Mirrors the reference
98/// `corr_nearest`.
99pub fn corr_nearest(corr: &Array2<f64>, threshold: f64, n_fact: usize) -> Result<Array2<f64>> {
100    let k = corr.nrows();
101    if corr.ncols() != k {
102        return Err(Error::Shape("matrix is not square".into()));
103    }
104    let mut diff = Array2::<f64>::zeros((k, k));
105    let mut x_new = corr.clone();
106    let max_iter = k * n_fact;
107    for _ in 0..max_iter {
108        let x_adj = &x_new - &diff;
109        let (x_psd, clipped) = clip_evals(&x_adj, threshold)?;
110        if !clipped {
111            x_new = x_psd;
112            break;
113        }
114        diff = &x_psd - &x_adj;
115        x_new = x_psd;
116        for i in 0..k {
117            x_new[[i, i]] = 1.0;
118        }
119    }
120    Ok(x_new)
121}
122
123/// Method used by [`cov_nearest`] for the correlation-matrix correction.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum NearestMethod {
126    /// Single eigenvalue clip ([`corr_clipped`]); fast, larger distance.
127    Clipped,
128    /// Iterative alternating projection ([`corr_nearest`]).
129    Nearest,
130}
131
132/// Nearest PSD covariance matrix, leaving the variances (diagonal) unchanged.
133///
134/// Converts `cov` to a correlation matrix, applies the chosen correction
135/// (`Clipped` → [`corr_clipped`], `Nearest` → [`corr_nearest`]), then converts
136/// back with the original standard deviations. Mirrors the reference
137/// `cov_nearest`.
138pub fn cov_nearest(
139    cov: &Array2<f64>,
140    method: NearestMethod,
141    threshold: f64,
142    n_fact: usize,
143) -> Result<Array2<f64>> {
144    let (corr, std) = cov2corr_std(cov);
145    let corr_fixed = match method {
146        NearestMethod::Clipped => corr_clipped(&corr, threshold)?,
147        NearestMethod::Nearest => corr_nearest(&corr, threshold, n_fact)?,
148    };
149    Ok(corr2cov(&corr_fixed, &std))
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use ndarray::array;
156
157    #[test]
158    fn cov2corr_roundtrip() {
159        let cov = array![[4.0, 2.0, 0.0], [2.0, 9.0, -3.0], [0.0, -3.0, 16.0]];
160        let (corr, std) = cov2corr_std(&cov);
161        assert!((corr[[0, 0]] - 1.0).abs() < 1e-12);
162        assert!((corr[[0, 1]] - 2.0 / (2.0 * 3.0)).abs() < 1e-12);
163        let back = corr2cov(&corr, &std);
164        for i in 0..3 {
165            for j in 0..3 {
166                assert!((back[[i, j]] - cov[[i, j]]).abs() < 1e-12);
167            }
168        }
169    }
170
171    #[test]
172    fn corr_nearest_makes_psd() {
173        // An indefinite "correlation" matrix.
174        let corr = array![[1.0, 0.9, -0.9], [0.9, 1.0, 0.9], [-0.9, 0.9, 1.0]];
175        let fixed = corr_nearest(&corr, 1e-7, 100).unwrap();
176        let (w, _) = eigh(&fixed).unwrap();
177        assert!(w[0] >= -1e-8, "smallest eigenvalue {} negative", w[0]);
178        for i in 0..3 {
179            assert!((fixed[[i, i]] - 1.0).abs() < 1e-6);
180        }
181    }
182
183    #[test]
184    fn corr_clipped_psd_passthrough() {
185        let corr = array![[1.0, 0.2], [0.2, 1.0]];
186        let fixed = corr_clipped(&corr, 1e-7).unwrap();
187        // Already PSD: returned unchanged.
188        for i in 0..2 {
189            for j in 0..2 {
190                assert!((fixed[[i, j]] - corr[[i, j]]).abs() < 1e-15);
191            }
192        }
193    }
194}