Skip to main content

fdars_core/frechet/spaces/
spherical.rs

1//! Spherical-data `MetricSpace` backend (FRE-02-03).
2//!
3//! Objects are unit vectors on the sphere `Sᵈ⁻¹`, stored as `Vec<f64>` of length
4//! `d`. The metric is the geodesic (great-circle) distance
5//! `arccos(clamp(⟨a,b⟩, −1, 1))`; the weighted Fréchet mean is the intrinsic
6//! (Karcher) mean, computed by Riemannian gradient descent using the exponential
7//! and logarithm maps, initialized at the normalized extrinsic (weighted-average)
8//! mean.
9//!
10//! Callers must supply unit vectors; unit-norm is not re-checked per call for
11//! performance. Antipodally-balanced inputs (whose extrinsic mean is the zero
12//! vector, or whose Karcher log map hits an antipode) return an error rather than
13//! an ill-defined mean.
14//!
15//! # Divergence from R `frechet` 0.3.0
16//!
17//! R's spherical Fréchet mean uses its own initialization and stopping rule; this
18//! backend uses an extrinsic-mean initialization with `max_iter = 50` and
19//! `tol = 1e-8`. The geodesic geometry is identical; iterates may differ at the
20//! last few digits.
21
22use crate::error::FdarError;
23use crate::frechet::MetricSpace;
24
25const MAX_ITER: usize = 50;
26const TOL: f64 = 1e-8;
27
28/// Spherical-data response space on `Sᵈ⁻¹` (FRE-02-03).
29#[derive(Debug, Clone, PartialEq)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31pub struct SphericalSpace {
32    /// Ambient dimension `d` (objects are unit vectors of length `d` on `Sᵈ⁻¹`).
33    pub d: usize,
34}
35
36impl SphericalSpace {
37    /// Construct a spherical space of ambient dimension `d`.
38    ///
39    /// # Errors
40    /// [`FdarError::InvalidParameter`] if `d < 1`.
41    pub fn new(d: usize) -> Result<Self, FdarError> {
42        if d < 1 {
43            return Err(FdarError::InvalidParameter {
44                parameter: "d",
45                message: "ambient dimension must be >= 1".to_string(),
46            });
47        }
48        Ok(Self { d })
49    }
50
51    fn check_len(&self, obj: &[f64], name: &'static str) -> Result<(), FdarError> {
52        if obj.len() != self.d {
53            return Err(FdarError::InvalidDimension {
54                parameter: name,
55                expected: format!("{} elements", self.d),
56                actual: format!("{} elements", obj.len()),
57            });
58        }
59        Ok(())
60    }
61}
62
63fn dot(a: &[f64], b: &[f64]) -> f64 {
64    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
65}
66
67fn norm(v: &[f64]) -> f64 {
68    v.iter().map(|x| x * x).sum::<f64>().sqrt()
69}
70
71/// Geodesic (great-circle) distance between two unit vectors.
72fn geodesic_distance(a: &[f64], b: &[f64]) -> f64 {
73    dot(a, b).clamp(-1.0, 1.0).acos()
74}
75
76/// Exponential map: move from `x` along tangent `v`, staying on the sphere.
77fn exp_map(x: &[f64], v: &[f64]) -> Vec<f64> {
78    let nv = norm(v);
79    if nv < 1e-12 {
80        return x.to_vec();
81    }
82    let c = nv.cos();
83    let s = nv.sin() / nv;
84    x.iter()
85        .zip(v.iter())
86        .map(|(xi, vi)| c * xi + s * vi)
87        .collect()
88}
89
90/// Logarithm map: tangent vector at `x` pointing toward `y`.
91fn log_map(x: &[f64], y: &[f64]) -> Result<Vec<f64>, FdarError> {
92    let theta = dot(x, y).clamp(-1.0, 1.0).acos();
93    if theta < 1e-12 {
94        return Ok(vec![0.0; x.len()]);
95    }
96    if theta > std::f64::consts::PI - 1e-8 {
97        return Err(FdarError::ComputationFailed {
98            operation: "SphericalSpace::weighted_frechet_mean",
99            detail: "antipodal points have a non-unique logarithm; Karcher mean is undefined"
100                .to_string(),
101        });
102    }
103    let scale = theta / theta.sin();
104    let ct = theta.cos();
105    Ok(x.iter()
106        .zip(y.iter())
107        .map(|(xi, yi)| scale * (yi - ct * xi))
108        .collect())
109}
110
111impl MetricSpace for SphericalSpace {
112    type Object = Vec<f64>;
113
114    fn distance(&self, a: &Self::Object, b: &Self::Object) -> Result<f64, FdarError> {
115        self.check_len(a, "a")?;
116        self.check_len(b, "b")?;
117        Ok(geodesic_distance(a, b))
118    }
119
120    fn weighted_frechet_mean(
121        &self,
122        objects: &[Self::Object],
123        weights: &[f64],
124    ) -> Result<Self::Object, FdarError> {
125        if objects.is_empty() {
126            return Err(FdarError::InvalidDimension {
127                parameter: "objects",
128                expected: "at least 1 object".to_string(),
129                actual: "0 objects".to_string(),
130            });
131        }
132        if weights.len() != objects.len() {
133            return Err(FdarError::InvalidDimension {
134                parameter: "weights",
135                expected: format!("{} weights (matching objects)", objects.len()),
136                actual: format!("{} weights", weights.len()),
137            });
138        }
139        for (i, o) in objects.iter().enumerate() {
140            if o.len() != self.d {
141                return Err(FdarError::InvalidDimension {
142                    parameter: "objects",
143                    expected: format!("each object has {} elements", self.d),
144                    actual: format!("object {i} has {} elements", o.len()),
145                });
146            }
147        }
148
149        // Extrinsic initialization: normalized weighted average.
150        let mut x = vec![0.0f64; self.d];
151        for (o, &w) in objects.iter().zip(weights.iter()) {
152            for (k, xk) in x.iter_mut().enumerate() {
153                *xk += w * o[k];
154            }
155        }
156        let nx = norm(&x);
157        if nx < 1e-14 {
158            return Err(FdarError::ComputationFailed {
159                operation: "SphericalSpace::weighted_frechet_mean",
160                detail:
161                    "extrinsic mean is ~0 (antipodally-balanced input); Karcher mean is undefined"
162                        .to_string(),
163            });
164        }
165        for xk in &mut x {
166            *xk /= nx;
167        }
168
169        // Riemannian gradient descent.
170        for _ in 0..MAX_ITER {
171            let mut g = vec![0.0f64; self.d];
172            for (o, &w) in objects.iter().zip(weights.iter()) {
173                let lm = log_map(&x, o)?;
174                for (k, gk) in g.iter_mut().enumerate() {
175                    *gk += w * lm[k];
176                }
177            }
178            if norm(&g) < TOL {
179                return Ok(x);
180            }
181            x = exp_map(&x, &g);
182            let nx = norm(&x);
183            if nx < 1e-14 {
184                return Err(FdarError::ComputationFailed {
185                    operation: "SphericalSpace::weighted_frechet_mean",
186                    detail: "Karcher iterate collapsed to the origin".to_string(),
187                });
188            }
189            for xk in &mut x {
190                *xk /= nx;
191            }
192        }
193        Err(FdarError::ComputationFailed {
194            operation: "SphericalSpace::weighted_frechet_mean",
195            detail: "Karcher mean did not converge in 50 iterations".to_string(),
196        })
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use std::f64::consts::PI;
204
205    #[test]
206    fn spherical_geodesic_antipodal_is_pi() {
207        let s = SphericalSpace::new(2).unwrap();
208        let a = vec![1.0, 0.0];
209        let b = vec![-1.0, 0.0];
210        assert!((s.distance(&a, &b).unwrap() - PI).abs() < 1e-12);
211    }
212
213    #[test]
214    fn spherical_geodesic_identical_is_zero() {
215        let s = SphericalSpace::new(3).unwrap();
216        let a = vec![0.0, 1.0, 0.0];
217        assert!(s.distance(&a, &a).unwrap() < 1e-12);
218    }
219
220    #[test]
221    fn spherical_karcher_midpoint() {
222        let s = SphericalSpace::new(2).unwrap();
223        let a = vec![1.0, 0.0];
224        let b = vec![0.1f64.cos(), 0.1f64.sin()];
225        let m = s.weighted_frechet_mean(&[a, b], &[0.5, 0.5]).unwrap();
226        let expected = [0.05f64.cos(), 0.05f64.sin()];
227        for (x, y) in m.iter().zip(expected.iter()) {
228            assert!((x - y).abs() < 1e-6, "x={x} y={y}");
229        }
230    }
231
232    #[test]
233    fn spherical_karcher_of_identical_recovers() {
234        let s = SphericalSpace::new(3).unwrap();
235        let a = {
236            let raw = [0.3f64, -0.4, 0.5];
237            let n = raw.iter().map(|x| x * x).sum::<f64>().sqrt();
238            raw.iter().map(|x| x / n).collect::<Vec<_>>()
239        };
240        let m = s
241            .weighted_frechet_mean(&[a.clone(), a.clone(), a.clone()], &[0.2, 0.3, 0.5])
242            .unwrap();
243        for (x, y) in m.iter().zip(a.iter()) {
244            assert!((x - y).abs() < 1e-8, "x={x} y={y}");
245        }
246    }
247
248    #[test]
249    fn spherical_karcher_antipodal_balanced_fails() {
250        let s = SphericalSpace::new(2).unwrap();
251        let a = vec![1.0, 0.0];
252        let b = vec![-1.0, 0.0];
253        assert!(matches!(
254            s.weighted_frechet_mean(&[a, b], &[0.5, 0.5]),
255            Err(FdarError::ComputationFailed { .. })
256        ));
257    }
258
259    #[test]
260    fn spherical_rejects_dimension_mismatch() {
261        let s = SphericalSpace::new(2).unwrap();
262        let a = vec![1.0, 0.0];
263        let bad = vec![1.0, 0.0, 0.0];
264        assert!(matches!(
265            s.distance(&a, &bad),
266            Err(FdarError::InvalidDimension { .. })
267        ));
268    }
269}