Skip to main content

fdars_core/frechet/spaces/
spd.rs

1//! SPD covariance-matrix `MetricSpace` backend (FRE-02-01).
2//!
3//! Objects are symmetric positive-definite (SPD) matrices stored as flat
4//! column-major `Vec<f64>` of length `d*d` (element `(i, j)` at index `i + j*d`).
5//! The metric is selected via [`SpdMetric`]:
6//!
7//! * **Frobenius** — Euclidean distance on the flat matrices; weighted Fréchet
8//!   mean is the weighted average (SPD-closed under non-negative weights).
9//! * **Power-α** — distance `‖Aᵅ − Bᵅ‖_F / α` (matrix power via
10//!   [`nalgebra::SymmetricEigen`]); weighted mean `(Σ wᵢ Aᵢᵅ / Σ wᵢ)^{1/α}`.
11//!   `α = 1` reproduces the Frobenius metric exactly; smaller α approaches the
12//!   log-Euclidean geometry.
13//! * **Log-Cholesky** — distance/mean in log-Cholesky coordinates (strictly-lower
14//!   Cholesky entries as-is, log of the diagonal), then mapped back.
15//!
16//! # Divergence from R `frechet` 0.3.0
17//!
18//! R's SPD covariance-response geometry uses an affine-invariant (log-Euclidean)
19//! metric family; this backend offers the Frobenius / power-α / log-Cholesky
20//! family instead. Numeric results therefore differ; the capability (distance +
21//! weighted Fréchet mean per metric) matches.
22
23use crate::error::FdarError;
24use crate::frechet::MetricSpace;
25use crate::helpers::NUMERICAL_EPS;
26use nalgebra::DMatrix;
27
28/// The metric used by an [`SpdMatrixSpace`].
29#[derive(Debug, Clone, PartialEq)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31#[non_exhaustive]
32pub enum SpdMetric {
33    /// Element-wise Frobenius distance; weighted-average mean.
34    Frobenius,
35    /// Power-α metric with `α > 0`. `α = 1` ≡ Frobenius.
36    Power(f64),
37    /// Log-Cholesky metric.
38    LogCholesky,
39}
40
41/// SPD covariance-matrix response space (FRE-02-01).
42///
43/// Objects are `d×d` SPD matrices as flat column-major `Vec<f64>`.
44#[derive(Debug, Clone, PartialEq)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
46pub struct SpdMatrixSpace {
47    /// Matrix dimension `d` (objects are `d*d` flat vectors).
48    pub d: usize,
49    /// The metric on the SPD manifold.
50    pub metric: SpdMetric,
51}
52
53impl SpdMatrixSpace {
54    /// Construct an SPD matrix space of dimension `d` under `metric`.
55    ///
56    /// # Errors
57    /// [`FdarError::InvalidParameter`] if `d < 1`, or if `metric` is
58    /// `Power(α)` with `α <= 0`.
59    pub fn new(d: usize, metric: SpdMetric) -> Result<Self, FdarError> {
60        if d < 1 {
61            return Err(FdarError::InvalidParameter {
62                parameter: "d",
63                message: "matrix dimension must be >= 1".to_string(),
64            });
65        }
66        if let SpdMetric::Power(alpha) = metric {
67            if alpha <= 0.0 || !alpha.is_finite() {
68                return Err(FdarError::InvalidParameter {
69                    parameter: "alpha",
70                    message: "power-metric exponent must be > 0".to_string(),
71                });
72            }
73        }
74        Ok(Self { d, metric })
75    }
76
77    fn check_len(&self, obj: &[f64], name: &'static str) -> Result<(), FdarError> {
78        let dd = self.d * self.d;
79        if obj.len() != dd {
80            return Err(FdarError::InvalidDimension {
81                parameter: name,
82                expected: format!("{dd} elements (d*d)"),
83                actual: format!("{} elements", obj.len()),
84            });
85        }
86        Ok(())
87    }
88
89    fn validate_objects(&self, objects: &[Vec<f64>], weights: &[f64]) -> Result<(), FdarError> {
90        if objects.is_empty() {
91            return Err(FdarError::InvalidDimension {
92                parameter: "objects",
93                expected: "at least 1 object".to_string(),
94                actual: "0 objects".to_string(),
95            });
96        }
97        if weights.len() != objects.len() {
98            return Err(FdarError::InvalidDimension {
99                parameter: "weights",
100                expected: format!("{} weights (matching objects)", objects.len()),
101                actual: format!("{} weights", weights.len()),
102            });
103        }
104        for (i, o) in objects.iter().enumerate() {
105            if o.len() != self.d * self.d {
106                return Err(FdarError::InvalidDimension {
107                    parameter: "objects",
108                    expected: format!("each object has {} elements", self.d * self.d),
109                    actual: format!("object {i} has {} elements", o.len()),
110                });
111            }
112        }
113        Ok(())
114    }
115}
116
117/// Frobenius norm of the element-wise difference of two equal-length vectors.
118fn frobenius_norm_diff(a: &[f64], b: &[f64]) -> f64 {
119    a.iter()
120        .zip(b.iter())
121        .map(|(x, y)| (x - y) * (x - y))
122        .sum::<f64>()
123        .sqrt()
124}
125
126/// Normalized weighted average `Σ wᵢ oᵢ / Σ wᵢ` of equal-length vectors.
127fn weighted_average(
128    objects: &[Vec<f64>],
129    weights: &[f64],
130    len: usize,
131) -> Result<Vec<f64>, FdarError> {
132    let sw: f64 = weights.iter().sum();
133    if sw.abs() < NUMERICAL_EPS {
134        return Err(FdarError::ComputationFailed {
135            operation: "SpdMatrixSpace::weighted_frechet_mean",
136            detail: "sum of weights is ~0; cannot normalize the barycenter".to_string(),
137        });
138    }
139    let mut acc = vec![0.0f64; len];
140    for (o, &w) in objects.iter().zip(weights.iter()) {
141        for (k, ak) in acc.iter_mut().enumerate() {
142            *ak += w * o[k];
143        }
144    }
145    for x in &mut acc {
146        *x /= sw;
147    }
148    Ok(acc)
149}
150
151/// Matrix power `A^α` of a symmetric matrix via eigendecomposition
152/// (eigenvalues clamped to `>= 0` before `powf`). Returns flat column-major `d×d`.
153fn spd_power(mat_flat: &[f64], d: usize, alpha: f64) -> Vec<f64> {
154    let mut mat = DMatrix::from_column_slice(d, d, mat_flat);
155    for i in 0..d {
156        for j in (i + 1)..d {
157            let avg = 0.5 * (mat[(i, j)] + mat[(j, i)]);
158            mat[(i, j)] = avg;
159            mat[(j, i)] = avg;
160        }
161    }
162    let eig = nalgebra::SymmetricEigen::new(mat);
163    let mut result = vec![0.0f64; d * d];
164    for k in 0..d {
165        let lk = eig.eigenvalues[k].max(0.0).powf(alpha);
166        if lk == 0.0 {
167            continue;
168        }
169        for i in 0..d {
170            let vik = eig.eigenvectors[(i, k)];
171            for j in 0..d {
172                result[i + j * d] += vik * lk * eig.eigenvectors[(j, k)];
173            }
174        }
175    }
176    result
177}
178
179/// Log-Cholesky coordinates: strictly-lower Cholesky entries (row-major, `i>j`)
180/// followed by the log of the diagonal. Length `d*(d+1)/2`.
181fn log_cholesky_coords(mat_flat: &[f64], d: usize) -> Result<Vec<f64>, FdarError> {
182    // cholesky_factor takes a symmetric flat matrix; column-major == row-major here.
183    let l = crate::linalg::cholesky_factor(mat_flat, d)?; // row-major L: l[i*d + j]
184    let mut coords = Vec::with_capacity(d * (d + 1) / 2);
185    for i in 0..d {
186        for j in 0..i {
187            coords.push(l[i * d + j]);
188        }
189    }
190    for i in 0..d {
191        coords.push(l[i * d + i].ln());
192    }
193    Ok(coords)
194}
195
196/// Inverse of [`log_cholesky_coords`]: rebuild `L` then form `M = L Lᵀ`
197/// (flat column-major `d×d`).
198fn log_cholesky_reconstruct(coords: &[f64], d: usize) -> Vec<f64> {
199    let mut l = vec![0.0f64; d * d]; // row-major L
200    let mut idx = 0;
201    for i in 0..d {
202        for j in 0..i {
203            l[i * d + j] = coords[idx];
204            idx += 1;
205        }
206    }
207    for i in 0..d {
208        l[i * d + i] = coords[idx].exp();
209        idx += 1;
210    }
211    let mut result = vec![0.0f64; d * d];
212    for i in 0..d {
213        for k in 0..d {
214            let mut s = 0.0;
215            for j in 0..=i.min(k) {
216                s += l[i * d + j] * l[k * d + j];
217            }
218            result[i + k * d] = s;
219        }
220    }
221    result
222}
223
224impl MetricSpace for SpdMatrixSpace {
225    type Object = Vec<f64>;
226
227    fn distance(&self, a: &Self::Object, b: &Self::Object) -> Result<f64, FdarError> {
228        self.check_len(a, "a")?;
229        self.check_len(b, "b")?;
230        match self.metric {
231            SpdMetric::Frobenius => Ok(frobenius_norm_diff(a, b)),
232            SpdMetric::Power(alpha) => {
233                let pa = spd_power(a, self.d, alpha);
234                let pb = spd_power(b, self.d, alpha);
235                Ok(frobenius_norm_diff(&pa, &pb) / alpha)
236            }
237            SpdMetric::LogCholesky => {
238                let ca = log_cholesky_coords(a, self.d)?;
239                let cb = log_cholesky_coords(b, self.d)?;
240                Ok(frobenius_norm_diff(&ca, &cb))
241            }
242        }
243    }
244
245    fn weighted_frechet_mean(
246        &self,
247        objects: &[Self::Object],
248        weights: &[f64],
249    ) -> Result<Self::Object, FdarError> {
250        self.validate_objects(objects, weights)?;
251        let dd = self.d * self.d;
252        match self.metric {
253            SpdMetric::Frobenius => weighted_average(objects, weights, dd),
254            SpdMetric::Power(alpha) => {
255                let transformed: Vec<Vec<f64>> = objects
256                    .iter()
257                    .map(|o| spd_power(o, self.d, alpha))
258                    .collect();
259                let avg = weighted_average(&transformed, weights, dd)?;
260                Ok(spd_power(&avg, self.d, 1.0 / alpha))
261            }
262            SpdMetric::LogCholesky => {
263                let mut coords: Vec<Vec<f64>> = Vec::with_capacity(objects.len());
264                for o in objects {
265                    coords.push(log_cholesky_coords(o, self.d)?);
266                }
267                let ncoord = self.d * (self.d + 1) / 2;
268                let avg = weighted_average(&coords, weights, ncoord)?;
269                Ok(log_cholesky_reconstruct(&avg, self.d))
270            }
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    // 2×2 SPD helpers (flat column-major).
280    fn identity2() -> Vec<f64> {
281        vec![1.0, 0.0, 0.0, 1.0]
282    }
283
284    #[test]
285    fn spd_new_rejects_zero_dim() {
286        assert!(matches!(
287            SpdMatrixSpace::new(0, SpdMetric::Frobenius),
288            Err(FdarError::InvalidParameter { parameter: "d", .. })
289        ));
290        assert!(matches!(
291            SpdMatrixSpace::new(2, SpdMetric::Power(0.0)),
292            Err(FdarError::InvalidParameter {
293                parameter: "alpha",
294                ..
295            })
296        ));
297        assert!(matches!(
298            SpdMatrixSpace::new(2, SpdMetric::Power(-1.0)),
299            Err(FdarError::InvalidParameter {
300                parameter: "alpha",
301                ..
302            })
303        ));
304        // Non-finite exponents must be rejected (CR-01: Inf slipped past is_nan()).
305        assert!(matches!(
306            SpdMatrixSpace::new(2, SpdMetric::Power(f64::INFINITY)),
307            Err(FdarError::InvalidParameter {
308                parameter: "alpha",
309                ..
310            })
311        ));
312        assert!(matches!(
313            SpdMatrixSpace::new(2, SpdMetric::Power(f64::NAN)),
314            Err(FdarError::InvalidParameter {
315                parameter: "alpha",
316                ..
317            })
318        ));
319    }
320
321    #[test]
322    fn spd_frobenius_distance_of_identical_is_zero() {
323        let s = SpdMatrixSpace::new(2, SpdMetric::Frobenius).unwrap();
324        let a = vec![2.0, 0.5, 0.5, 3.0];
325        assert!(s.distance(&a, &a).unwrap() < 1e-12);
326    }
327
328    #[test]
329    fn spd_frobenius_mean_of_identical_recovers_matrix() {
330        let s = SpdMatrixSpace::new(2, SpdMetric::Frobenius).unwrap();
331        let a = vec![2.0, 0.5, 0.5, 3.0];
332        let m = s
333            .weighted_frechet_mean(&[a.clone(), a.clone(), a.clone()], &[0.2, 0.3, 0.5])
334            .unwrap();
335        for (x, y) in m.iter().zip(a.iter()) {
336            assert!((x - y).abs() < 1e-10);
337        }
338    }
339
340    #[test]
341    fn spd_rejects_dimension_mismatch() {
342        let s = SpdMatrixSpace::new(2, SpdMetric::Frobenius).unwrap();
343        let a = identity2();
344        let bad = vec![1.0, 0.0, 0.0]; // length 3, not 4
345        assert!(matches!(
346            s.distance(&a, &bad),
347            Err(FdarError::InvalidDimension { .. })
348        ));
349        assert!(matches!(
350            s.weighted_frechet_mean(&[], &[]),
351            Err(FdarError::InvalidDimension {
352                parameter: "objects",
353                ..
354            })
355        ));
356        assert!(matches!(
357            s.weighted_frechet_mean(std::slice::from_ref(&a), &[1.0, 2.0]),
358            Err(FdarError::InvalidDimension {
359                parameter: "weights",
360                ..
361            })
362        ));
363    }
364
365    #[test]
366    fn spd_power_alpha_one_equals_frobenius() {
367        let fro = SpdMatrixSpace::new(2, SpdMetric::Frobenius).unwrap();
368        let pow = SpdMatrixSpace::new(2, SpdMetric::Power(1.0)).unwrap();
369        let a = vec![2.0, 0.5, 0.5, 3.0];
370        let b = vec![1.5, -0.2, -0.2, 2.5];
371        let df = fro.distance(&a, &b).unwrap();
372        let dp = pow.distance(&a, &b).unwrap();
373        assert!((df - dp).abs() < 1e-10, "df={df} dp={dp}");
374    }
375
376    #[test]
377    fn spd_power_alpha_mean_of_identical_recovers() {
378        let s = SpdMatrixSpace::new(2, SpdMetric::Power(0.5)).unwrap();
379        let a = vec![2.0, 0.3, 0.3, 4.0];
380        let m = s
381            .weighted_frechet_mean(&[a.clone(), a.clone()], &[0.5, 0.5])
382            .unwrap();
383        for (x, y) in m.iter().zip(a.iter()) {
384            assert!((x - y).abs() < 1e-8, "x={x} y={y}");
385        }
386    }
387
388    #[test]
389    fn spd_log_cholesky_mean_identity_and_4i_is_2i() {
390        let s = SpdMatrixSpace::new(2, SpdMetric::LogCholesky).unwrap();
391        let i2 = vec![1.0, 0.0, 0.0, 1.0];
392        let four_i = vec![4.0, 0.0, 0.0, 4.0];
393        let m = s.weighted_frechet_mean(&[i2, four_i], &[0.5, 0.5]).unwrap();
394        let expected = [2.0, 0.0, 0.0, 2.0];
395        for (x, y) in m.iter().zip(expected.iter()) {
396            assert!((x - y).abs() < 1e-8, "x={x} y={y}");
397        }
398    }
399
400    #[test]
401    fn spd_log_cholesky_mean_of_identical_recovers() {
402        let s = SpdMatrixSpace::new(2, SpdMetric::LogCholesky).unwrap();
403        let a = vec![2.0, 0.5, 0.5, 3.0];
404        let m = s
405            .weighted_frechet_mean(&[a.clone(), a.clone()], &[0.4, 0.6])
406            .unwrap();
407        for (x, y) in m.iter().zip(a.iter()) {
408            assert!((x - y).abs() < 1e-10, "x={x} y={y}");
409        }
410    }
411
412    #[test]
413    fn spd_log_cholesky_rejects_non_pd() {
414        let s = SpdMatrixSpace::new(2, SpdMetric::LogCholesky).unwrap();
415        // Not positive-definite (zero matrix).
416        let bad = vec![0.0, 0.0, 0.0, 0.0];
417        let good = identity2();
418        assert!(matches!(
419            s.distance(&bad, &good),
420            Err(FdarError::ComputationFailed { .. })
421        ));
422    }
423}