1use crate::error::FdarError;
24use crate::frechet::MetricSpace;
25use crate::helpers::NUMERICAL_EPS;
26use nalgebra::DMatrix;
27
28#[derive(Debug, Clone, PartialEq)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31#[non_exhaustive]
32pub enum SpdMetric {
33 Frobenius,
35 Power(f64),
37 LogCholesky,
39}
40
41#[derive(Debug, Clone, PartialEq)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
46pub struct SpdMatrixSpace {
47 pub d: usize,
49 pub metric: SpdMetric,
51}
52
53impl SpdMatrixSpace {
54 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
117fn 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
126fn 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
151fn 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
179fn log_cholesky_coords(mat_flat: &[f64], d: usize) -> Result<Vec<f64>, FdarError> {
182 let l = crate::linalg::cholesky_factor(mat_flat, d)?; 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
196fn log_cholesky_reconstruct(coords: &[f64], d: usize) -> Vec<f64> {
199 let mut l = vec![0.0f64; d * d]; 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 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 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]; 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 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}