fdars_core/frechet/spaces/
spherical.rs1use crate::error::FdarError;
23use crate::frechet::MetricSpace;
24
25const MAX_ITER: usize = 50;
26const TOL: f64 = 1e-8;
27
28#[derive(Debug, Clone, PartialEq)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31pub struct SphericalSpace {
32 pub d: usize,
34}
35
36impl SphericalSpace {
37 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
71fn geodesic_distance(a: &[f64], b: &[f64]) -> f64 {
73 dot(a, b).clamp(-1.0, 1.0).acos()
74}
75
76fn 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
90fn 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 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 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}