Skip to main content

fdars_core/frechet/spaces/
correlation.rs

1//! Correlation-matrix `MetricSpace` backend (FRE-02-02).
2//!
3//! Objects are correlation matrices (SPD with unit diagonal) stored as flat
4//! column-major `Vec<f64>` of length `d*d`. The distance is the element-wise
5//! Frobenius norm; the weighted Fréchet mean is the weighted average projected
6//! back to a correlation matrix by unit-diagonal renormalization
7//! `M̄[i,j] = M[i,j] / sqrt(M[i,i]·M[j,j])`.
8//!
9//! # Divergence from R `frechet` 0.3.0
10//!
11//! R uses a correlation-manifold geometry; this backend uses the simpler
12//! Frobenius distance with a unit-diagonal-renormalization projection for the
13//! mean. Numeric results differ; the capability (distance + weighted mean on a
14//! correlation-response space) matches. The renormalization requires positive
15//! averaged diagonal entries — a non-positive diagonal returns an error rather
16//! than producing an invalid correlation matrix.
17
18use crate::error::FdarError;
19use crate::frechet::MetricSpace;
20use crate::helpers::NUMERICAL_EPS;
21
22/// Correlation-matrix response space (FRE-02-02).
23#[derive(Debug, Clone, PartialEq)]
24#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
25pub struct CorrelationMatrixSpace {
26    /// Matrix dimension `d` (objects are `d*d` flat vectors).
27    pub d: usize,
28}
29
30impl CorrelationMatrixSpace {
31    /// Construct a correlation-matrix space of dimension `d`.
32    ///
33    /// # Errors
34    /// [`FdarError::InvalidParameter`] if `d < 1`.
35    pub fn new(d: usize) -> Result<Self, FdarError> {
36        if d < 1 {
37            return Err(FdarError::InvalidParameter {
38                parameter: "d",
39                message: "matrix dimension must be >= 1".to_string(),
40            });
41        }
42        Ok(Self { d })
43    }
44
45    fn check_len(&self, obj: &[f64], name: &'static str) -> Result<(), FdarError> {
46        let dd = self.d * self.d;
47        if obj.len() != dd {
48            return Err(FdarError::InvalidDimension {
49                parameter: name,
50                expected: format!("{dd} elements (d*d)"),
51                actual: format!("{} elements", obj.len()),
52            });
53        }
54        Ok(())
55    }
56}
57
58impl MetricSpace for CorrelationMatrixSpace {
59    type Object = Vec<f64>;
60
61    fn distance(&self, a: &Self::Object, b: &Self::Object) -> Result<f64, FdarError> {
62        self.check_len(a, "a")?;
63        self.check_len(b, "b")?;
64        Ok(a.iter()
65            .zip(b.iter())
66            .map(|(x, y)| (x - y) * (x - y))
67            .sum::<f64>()
68            .sqrt())
69    }
70
71    fn weighted_frechet_mean(
72        &self,
73        objects: &[Self::Object],
74        weights: &[f64],
75    ) -> Result<Self::Object, FdarError> {
76        if objects.is_empty() {
77            return Err(FdarError::InvalidDimension {
78                parameter: "objects",
79                expected: "at least 1 object".to_string(),
80                actual: "0 objects".to_string(),
81            });
82        }
83        if weights.len() != objects.len() {
84            return Err(FdarError::InvalidDimension {
85                parameter: "weights",
86                expected: format!("{} weights (matching objects)", objects.len()),
87                actual: format!("{} weights", weights.len()),
88            });
89        }
90        let dd = self.d * self.d;
91        for (i, o) in objects.iter().enumerate() {
92            if o.len() != dd {
93                return Err(FdarError::InvalidDimension {
94                    parameter: "objects",
95                    expected: format!("each object has {dd} elements"),
96                    actual: format!("object {i} has {} elements", o.len()),
97                });
98            }
99        }
100        let sw: f64 = weights.iter().sum();
101        if sw.abs() < NUMERICAL_EPS {
102            return Err(FdarError::ComputationFailed {
103                operation: "CorrelationMatrixSpace::weighted_frechet_mean",
104                detail: "sum of weights is ~0; cannot normalize the barycenter".to_string(),
105            });
106        }
107        // Element-wise weighted average.
108        let mut m = vec![0.0f64; dd];
109        for (o, &w) in objects.iter().zip(weights.iter()) {
110            for (k, mk) in m.iter_mut().enumerate() {
111                *mk += w * o[k];
112            }
113        }
114        for x in &mut m {
115            *x /= sw;
116        }
117        // Renormalize to unit diagonal; guard positive diagonal entries.
118        let d = self.d;
119        for i in 0..d {
120            if m[i + i * d] <= 0.0 {
121                return Err(FdarError::ComputationFailed {
122                    operation: "CorrelationMatrixSpace::weighted_frechet_mean",
123                    detail: format!(
124                        "averaged diagonal entry {i} is non-positive ({}); cannot renormalize to a correlation matrix",
125                        m[i + i * d]
126                    ),
127                });
128            }
129        }
130        let mut result = vec![0.0f64; dd];
131        for i in 0..d {
132            for j in 0..d {
133                result[i + j * d] = m[i + j * d] / (m[i + i * d] * m[j + j * d]).sqrt();
134            }
135        }
136        Ok(result)
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    // A valid 2×2 correlation matrix with off-diagonal r (flat column-major).
145    fn corr2(r: f64) -> Vec<f64> {
146        vec![1.0, r, r, 1.0]
147    }
148
149    #[test]
150    fn correlation_distance_of_identical_is_zero() {
151        let s = CorrelationMatrixSpace::new(2).unwrap();
152        let a = corr2(0.4);
153        assert!(s.distance(&a, &a).unwrap() < 1e-12);
154    }
155
156    #[test]
157    fn correlation_mean_of_identical_recovers() {
158        let s = CorrelationMatrixSpace::new(2).unwrap();
159        let a = corr2(0.5);
160        let m = s
161            .weighted_frechet_mean(&[a.clone(), a.clone()], &[0.5, 0.5])
162            .unwrap();
163        for (x, y) in m.iter().zip(a.iter()) {
164            assert!((x - y).abs() < 1e-10, "x={x} y={y}");
165        }
166    }
167
168    #[test]
169    fn correlation_mean_has_unit_diagonal() {
170        let s = CorrelationMatrixSpace::new(2).unwrap();
171        let m = s
172            .weighted_frechet_mean(&[corr2(0.2), corr2(0.8)], &[0.3, 0.7])
173            .unwrap();
174        assert!((m[0] - 1.0).abs() < 1e-10);
175        assert!((m[3] - 1.0).abs() < 1e-10);
176    }
177
178    #[test]
179    fn correlation_rejects_non_positive_diagonal() {
180        // Signed weights whose average produces a non-positive diagonal.
181        let s = CorrelationMatrixSpace::new(2).unwrap();
182        let a = corr2(0.1);
183        let b = corr2(0.2);
184        // weights sum to 1 but drive diagonal negative: 2*a - 1*b has diagonal 2-1=1 (ok),
185        // use weights that make diagonal <= 0: -1 and 0.5 → sum -0.5, diagonal (-1+0.5)/-0.5 ... instead
186        // force via near-zero-diagonal average: weights 1 and -1 sum 0 → caught by weight guard.
187        // Use a degenerate object with zero diagonal.
188        let degen = vec![0.0, 0.0, 0.0, 1.0];
189        assert!(matches!(
190            s.weighted_frechet_mean(&[degen, a, b], &[1.0, 0.0, 0.0]),
191            Err(FdarError::ComputationFailed { .. })
192        ));
193    }
194
195    #[test]
196    fn correlation_rejects_dimension_mismatch() {
197        let s = CorrelationMatrixSpace::new(2).unwrap();
198        let a = corr2(0.3);
199        let bad = vec![1.0, 0.0, 0.0];
200        assert!(matches!(
201            s.distance(&a, &bad),
202            Err(FdarError::InvalidDimension { .. })
203        ));
204    }
205}