Skip to main content

solow_stats/
dist_dependence.rs

1//! Distance covariance and distance correlation (Székely et al., 2007).
2//!
3//! The distance covariance `dCov(X, Y)` and distance correlation `dCor(X, Y)`
4//! measure dependence between two random vectors of arbitrary (possibly
5//! different) dimension. Unlike the Pearson correlation they are zero *iff* the
6//! variables are independent, and so detect nonlinear and non-monotone
7//! dependence. This module mirrors the reference
8//! `stats.dist_dependence_measures`.
9//!
10//! For `n` observations let `a_{ij} = ‖x_i − x_j‖` and `b_{ij} = ‖y_i − y_j‖`
11//! be the euclidean-distance matrices, doubly-centered to `A` and `B`:
12//!
13//! ```text
14//! A_{ij} = a_{ij} − ā_{i·} − ā_{·j} + ā_{··}
15//! ```
16//!
17//! Then `dCov = sqrt(mean(A ∘ B))`, the distance variances are
18//! `dVar_x = sqrt(mean(A ∘ A))`, `dVar_y = sqrt(mean(B ∘ B))`, and
19//! `dCor = dCov / sqrt(dVar_x · dVar_y)`. The basic dCov-test statistic is
20//! `n · dCov²`. All quantities are closed-form functions of the distance
21//! matrices, so they reproduce the reference to machine precision.
22
23use ndarray::{Array1, Array2};
24use solow_core::{Error, Result};
25use solow_distributions::norm_cdf;
26
27/// The full battery of distance-dependence statistics for a pair `(X, Y)`.
28#[derive(Debug, Clone)]
29pub struct DistDependStat {
30    /// The basic test statistic `n · dCov²`.
31    pub test_statistic: f64,
32    /// The distance correlation `dCor(X, Y)` in `[0, 1]`.
33    pub distance_correlation: f64,
34    /// The distance covariance `dCov(X, Y)`.
35    pub distance_covariance: f64,
36    /// The distance variance of `X`, `dVar_x`.
37    pub dvar_x: f64,
38    /// The distance variance of `Y`, `dVar_y`.
39    pub dvar_y: f64,
40    /// `S = ā_{··} · b̄_{··}`, the product of the grand means of the two
41    /// distance matrices (used by the asymptotic test).
42    pub s: f64,
43}
44
45/// Result of the asymptotic distance-covariance (dCov) test of independence.
46#[derive(Debug, Clone)]
47pub struct DcovTest {
48    /// The asymptotic test statistic `sqrt(n · dCov² / S)`.
49    pub statistic: f64,
50    /// Two-sided p-value from the standard normal approximation.
51    pub pvalue: f64,
52}
53
54/// Euclidean pairwise-distance matrix of the rows of `x` (`n × n`).
55fn distance_matrix(x: &Array2<f64>) -> Array2<f64> {
56    let (n, k) = x.dim();
57    let mut d = Array2::<f64>::zeros((n, n));
58    for i in 0..n {
59        for j in (i + 1)..n {
60            let mut s = 0.0;
61            for c in 0..k {
62                let diff = x[[i, c]] - x[[j, c]];
63                s += diff * diff;
64            }
65            let dist = s.sqrt();
66            d[[i, j]] = dist;
67            d[[j, i]] = dist;
68        }
69    }
70    d
71}
72
73/// Double-centering: `A_{ij} = a_{ij} − rowmean_i − colmean_j + grandmean`.
74/// Returns `(centered, grand_mean)`.
75fn double_center(a: &Array2<f64>) -> (Array2<f64>, f64) {
76    let n = a.nrows();
77    let nf = n as f64;
78    // Column means (axis 0) and row means (axis 1). The matrix is symmetric so
79    // row means equal column means, but we follow the reference layout exactly.
80    let mut row_means = Array1::<f64>::zeros(n);
81    let mut col_means = Array1::<f64>::zeros(n);
82    for i in 0..n {
83        let mut rs = 0.0;
84        let mut cs = 0.0;
85        for j in 0..n {
86            rs += a[[i, j]]; // row i sum (mean over axis 1)
87            cs += a[[j, i]]; // column i sum (mean over axis 0)
88        }
89        row_means[i] = rs / nf;
90        col_means[i] = cs / nf;
91    }
92    let grand: f64 = a.iter().sum::<f64>() / (nf * nf);
93    let mut out = Array2::<f64>::zeros((n, n));
94    for i in 0..n {
95        for j in 0..n {
96            // Reference: A = a - a_row_means - a_col_means + a_mean, with
97            // a_row_means broadcast over rows (axis-0 reduction, length n,
98            // indexed by column j) and a_col_means over columns (axis-1
99            // reduction, indexed by row i).
100            out[[i, j]] = a[[i, j]] - col_means[j] - row_means[i] + grand;
101        }
102    }
103    (out, grand)
104}
105
106fn hadamard_mean(a: &Array2<f64>, b: &Array2<f64>) -> f64 {
107    let n = a.nrows();
108    let mut s = 0.0;
109    for i in 0..n {
110        for j in 0..n {
111            s += a[[i, j]] * b[[i, j]];
112        }
113    }
114    s / (n * n) as f64
115}
116
117/// Compute every distance-dependence statistic for matched samples `x` and `y`.
118///
119/// Each of `x` and `y` is an `n × p` matrix whose rows are observations and
120/// whose columns are the components of the random vector. The two must share the
121/// number of rows (observations) but may differ in the number of columns.
122pub fn distance_statistics(x: &Array2<f64>, y: &Array2<f64>) -> Result<DistDependStat> {
123    let n = x.nrows();
124    if y.nrows() != n {
125        return Err(Error::Shape(
126            "x and y must have the same number of observations (rows)".into(),
127        ));
128    }
129    if n == 0 {
130        return Err(Error::Value("empty sample".into()));
131    }
132    let a = distance_matrix(x);
133    let b = distance_matrix(y);
134    let (ac, a_mean) = double_center(&a);
135    let (bc, b_mean) = double_center(&b);
136
137    let s = a_mean * b_mean;
138    let dcov = hadamard_mean(&ac, &bc).sqrt();
139    let dvar_x = hadamard_mean(&ac, &ac).sqrt();
140    let dvar_y = hadamard_mean(&bc, &bc).sqrt();
141    let dcor = dcov / (dvar_x * dvar_y).sqrt();
142    let test_statistic = n as f64 * dcov * dcov;
143
144    Ok(DistDependStat {
145        test_statistic,
146        distance_correlation: dcor,
147        distance_covariance: dcov,
148        dvar_x,
149        dvar_y,
150        s,
151    })
152}
153
154/// Empirical distance covariance `dCov(X, Y)`.
155pub fn distance_covariance(x: &Array2<f64>, y: &Array2<f64>) -> Result<f64> {
156    Ok(distance_statistics(x, y)?.distance_covariance)
157}
158
159/// Empirical distance correlation `dCor(X, Y)` in `[0, 1]`.
160pub fn distance_correlation(x: &Array2<f64>, y: &Array2<f64>) -> Result<f64> {
161    Ok(distance_statistics(x, y)?.distance_correlation)
162}
163
164/// Empirical distance variance of `X`, `dVar(X) = dCov(X, X)`.
165pub fn distance_variance(x: &Array2<f64>) -> Result<f64> {
166    Ok(distance_statistics(x, x)?.distance_covariance)
167}
168
169/// Asymptotic distance-covariance (dCov) test of independence.
170///
171/// Returns the statistic `sqrt(n · dCov² / S)` and its two-sided standard-normal
172/// p-value (the reference `_asymptotic_pvalue`).
173pub fn distance_covariance_test(x: &Array2<f64>, y: &Array2<f64>) -> Result<DcovTest> {
174    let stats = distance_statistics(x, y)?;
175    let statistic = (stats.test_statistic / stats.s).sqrt();
176    let pvalue = (1.0 - norm_cdf(statistic)) * 2.0;
177    Ok(DcovTest { statistic, pvalue })
178}
179
180/// Reshape a 1-D sample into an `n × 1` matrix (the column-vector form expected
181/// by [`distance_statistics`]).
182pub fn as_column(v: &Array1<f64>) -> Array2<f64> {
183    let n = v.len();
184    let mut out = Array2::<f64>::zeros((n, 1));
185    for i in 0..n {
186        out[[i, 0]] = v[i];
187    }
188    out
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use ndarray::array;
195
196    #[test]
197    fn dcor_of_identical_is_one() {
198        let x = array![1.0, 2.0, 3.0, 5.0, 8.0];
199        let xc = as_column(&x);
200        let st = distance_statistics(&xc, &xc).unwrap();
201        assert!((st.distance_correlation - 1.0).abs() < 1e-12);
202        // dVar_x == dVar_y == dCov when x == y.
203        assert!((st.dvar_x - st.distance_covariance).abs() < 1e-12);
204        assert!((st.dvar_y - st.distance_covariance).abs() < 1e-12);
205    }
206
207    #[test]
208    fn dcor_in_unit_interval() {
209        let x = array![0.0, 1.0, 2.0, 3.0, 4.0, 5.0];
210        let y = array![1.0, 0.5, 2.2, -1.0, 3.3, 0.1];
211        let st = distance_statistics(&as_column(&x), &as_column(&y)).unwrap();
212        assert!((0.0..=1.0).contains(&st.distance_correlation));
213        assert!(st.distance_covariance >= 0.0);
214    }
215
216    #[test]
217    fn mismatched_lengths_error() {
218        let x = as_column(&array![1.0, 2.0, 3.0]);
219        let y = as_column(&array![1.0, 2.0]);
220        assert!(distance_statistics(&x, &y).is_err());
221    }
222}