Skip to main content

oxigdal_algorithms/vector/
robust_location.rs

1//! Robust spatial location estimators.
2//!
3//! Provides the geometric median (L1 median in 2-D and 3-D) via the Weiszfeld
4//! algorithm with Vardi-Zhang coincidence handling, the coordinate-wise L1
5//! median, and the arithmetic spatial mean.
6//!
7//! # References
8//!
9//! * Weiszfeld, E. (1937). Sur le point pour lequel la somme des distances de
10//!   n points donnés est minimum. *Tôhoku Math. J.*, 43, 355-386.
11//! * Vardi, Y. & Zhang, C.-H. (2000). The multivariate L1-median and associated
12//!   data depth. *PNAS*, 97(4), 1423-1426.
13
14/// Convergence options for the Weiszfeld iterative algorithm.
15#[derive(Debug, Clone)]
16pub struct RobustLocationOptions {
17    /// Maximum number of Weiszfeld iterations.  Default: 200.
18    pub max_iter: usize,
19
20    /// Convergence tolerance (L2 norm of the step vector). Default: 1e-10.
21    pub tol: f64,
22
23    /// Distance below which a data point is considered coincident with the
24    /// current estimate (triggers a perturbation to avoid division by zero).
25    /// Default: 1e-12.
26    pub coincidence_eps: f64,
27}
28
29impl Default for RobustLocationOptions {
30    fn default() -> Self {
31        Self {
32            max_iter: 200,
33            tol: 1e-10,
34            coincidence_eps: 1e-12,
35        }
36    }
37}
38
39impl RobustLocationOptions {
40    /// Set the maximum number of Weiszfeld iterations.
41    pub fn with_max_iter(mut self, v: usize) -> Self {
42        self.max_iter = v;
43        self
44    }
45
46    /// Set the convergence tolerance.
47    pub fn with_tol(mut self, v: f64) -> Self {
48        self.tol = v;
49        self
50    }
51
52    /// Set the coincidence epsilon.
53    pub fn with_coincidence_eps(mut self, v: f64) -> Self {
54        self.coincidence_eps = v;
55        self
56    }
57}
58
59// ---------------------------------------------------------------------------
60// Internal helpers
61// ---------------------------------------------------------------------------
62
63/// Compute the arithmetic mean of a slice of 2-D points.
64///
65/// Returns `None` if `points` is empty.
66fn mean_2d(points: &[(f64, f64)]) -> Option<(f64, f64)> {
67    let n = points.len();
68    if n == 0 {
69        return None;
70    }
71    let mut sx = 0.0_f64;
72    let mut sy = 0.0_f64;
73    for &(x, y) in points {
74        sx += x;
75        sy += y;
76    }
77    let n_f = n as f64;
78    Some((sx / n_f, sy / n_f))
79}
80
81/// Compute the arithmetic mean of a slice of 3-D points.
82///
83/// Returns `None` if `points` is empty.
84fn mean_3d(points: &[(f64, f64, f64)]) -> Option<(f64, f64, f64)> {
85    let n = points.len();
86    if n == 0 {
87        return None;
88    }
89    let mut sx = 0.0_f64;
90    let mut sy = 0.0_f64;
91    let mut sz = 0.0_f64;
92    for &(x, y, z) in points {
93        sx += x;
94        sy += y;
95        sz += z;
96    }
97    let n_f = n as f64;
98    Some((sx / n_f, sy / n_f, sz / n_f))
99}
100
101/// One Weiszfeld step in 2-D with per-point weights.
102///
103/// Returns `(new_estimate, coincidence_detected)`.
104fn weiszfeld_step_2d(
105    cx: f64,
106    cy: f64,
107    points: &[(f64, f64)],
108    weights: &[f64],
109    coincidence_eps: f64,
110) -> ((f64, f64), bool) {
111    let mut num_x = 0.0_f64;
112    let mut num_y = 0.0_f64;
113    let mut denom = 0.0_f64;
114    let mut coincident = false;
115
116    for (i, &(px, py)) in points.iter().enumerate() {
117        let w = weights[i];
118        let dx = px - cx;
119        let dy = py - cy;
120        let dist = (dx * dx + dy * dy).sqrt();
121        if dist < coincidence_eps {
122            coincident = true;
123            // Skip the coincident point — the Vardi-Zhang perturbation is
124            // applied at the call site before the next iteration.
125            continue;
126        }
127        let inv_d = w / dist;
128        num_x += px * inv_d;
129        num_y += py * inv_d;
130        denom += inv_d;
131    }
132
133    if denom == 0.0 {
134        // All points are coincident with the current estimate; return as-is.
135        return ((cx, cy), true);
136    }
137
138    ((num_x / denom, num_y / denom), coincident)
139}
140
141/// One Weiszfeld step in 3-D (unit weight for every point).
142///
143/// Returns `(new_estimate, coincidence_detected)`.
144fn weiszfeld_step_3d(
145    cx: f64,
146    cy: f64,
147    cz: f64,
148    points: &[(f64, f64, f64)],
149    coincidence_eps: f64,
150) -> ((f64, f64, f64), bool) {
151    let mut num_x = 0.0_f64;
152    let mut num_y = 0.0_f64;
153    let mut num_z = 0.0_f64;
154    let mut denom = 0.0_f64;
155    let mut coincident = false;
156
157    for &(px, py, pz) in points {
158        let dx = px - cx;
159        let dy = py - cy;
160        let dz = pz - cz;
161        let dist = (dx * dx + dy * dy + dz * dz).sqrt();
162        if dist < coincidence_eps {
163            coincident = true;
164            continue;
165        }
166        let inv_d = 1.0 / dist;
167        num_x += px * inv_d;
168        num_y += py * inv_d;
169        num_z += pz * inv_d;
170        denom += inv_d;
171    }
172
173    if denom == 0.0 {
174        return ((cx, cy, cz), true);
175    }
176
177    ((num_x / denom, num_y / denom, num_z / denom), coincident)
178}
179
180// ---------------------------------------------------------------------------
181// Public API — 2-D
182// ---------------------------------------------------------------------------
183
184/// Compute the geometric median of a set of 2-D points using default options.
185///
186/// The geometric median minimises the sum of Euclidean distances to all input
187/// points (L1 median in 2-D).  It is significantly more robust to outliers
188/// than the arithmetic mean.
189///
190/// Returns `None` for an empty input.  For a single point, that point is
191/// returned directly.  Convergence is guaranteed for non-coincident data; for
192/// coincident data, a Vardi-Zhang perturbation is applied automatically.
193pub fn geometric_median(points: &[(f64, f64)]) -> Option<(f64, f64)> {
194    geometric_median_with_options(points, &RobustLocationOptions::default())
195}
196
197/// Compute the geometric median with custom convergence options.
198///
199/// See [`geometric_median`] for full documentation.
200pub fn geometric_median_with_options(
201    points: &[(f64, f64)],
202    options: &RobustLocationOptions,
203) -> Option<(f64, f64)> {
204    let n = points.len();
205    match n {
206        0 => return None,
207        1 => return Some(points[0]),
208        _ => {}
209    }
210
211    // Unit weights.
212    let weights: Vec<f64> = vec![1.0; n];
213    weighted_geometric_median(points, &weights, options)
214}
215
216/// Compute the weighted geometric median of a set of 2-D points.
217///
218/// Each point `points[i]` carries the non-negative weight `weights[i]`.  The
219/// algorithm is equivalent to Weiszfeld's method but with per-point scaling of
220/// the distance reciprocals.
221///
222/// Returns `None` if:
223/// * `points` and `weights` have different lengths, or
224/// * the combined slice is empty.
225pub fn weighted_geometric_median(
226    points: &[(f64, f64)],
227    weights: &[f64],
228    options: &RobustLocationOptions,
229) -> Option<(f64, f64)> {
230    let n = points.len();
231    if n != weights.len() {
232        return None;
233    }
234    match n {
235        0 => return None,
236        1 => return Some(points[0]),
237        _ => {}
238    }
239
240    // Initial estimate: weighted arithmetic mean.
241    let total_w: f64 = weights.iter().sum();
242    let (mut cx, mut cy) = if total_w == 0.0 {
243        mean_2d(points)?
244    } else {
245        let mut sx = 0.0_f64;
246        let mut sy = 0.0_f64;
247        for (i, &(px, py)) in points.iter().enumerate() {
248            sx += weights[i] * px;
249            sy += weights[i] * py;
250        }
251        (sx / total_w, sy / total_w)
252    };
253
254    // Weiszfeld iterations.
255    for _ in 0..options.max_iter {
256        let ((nx, ny), coincident) =
257            weiszfeld_step_2d(cx, cy, points, weights, options.coincidence_eps);
258
259        if coincident {
260            // Vardi-Zhang practical approximation: perturb by coincidence_eps
261            // along the x-axis to escape the singular point, then retry.
262            let ((nx2, ny2), _) = weiszfeld_step_2d(
263                cx + options.coincidence_eps,
264                cy,
265                points,
266                weights,
267                options.coincidence_eps,
268            );
269            let dx = nx2 - cx;
270            let dy = ny2 - cy;
271            let step = (dx * dx + dy * dy).sqrt();
272            cx = nx2;
273            cy = ny2;
274            if step < options.tol {
275                break;
276            }
277            continue;
278        }
279
280        let dx = nx - cx;
281        let dy = ny - cy;
282        let step = (dx * dx + dy * dy).sqrt();
283        cx = nx;
284        cy = ny;
285        if step < options.tol {
286            break;
287        }
288    }
289
290    Some((cx, cy))
291}
292
293// ---------------------------------------------------------------------------
294// Public API — 3-D
295// ---------------------------------------------------------------------------
296
297/// Compute the geometric median of a set of 3-D points.
298///
299/// Equivalent to [`geometric_median`] but operating in three dimensions.
300/// Returns `None` for an empty input.
301pub fn geometric_median_3d(
302    points: &[(f64, f64, f64)],
303    options: &RobustLocationOptions,
304) -> Option<(f64, f64, f64)> {
305    let n = points.len();
306    match n {
307        0 => return None,
308        1 => return Some(points[0]),
309        _ => {}
310    }
311
312    // Initial estimate: arithmetic mean.
313    let (mut cx, mut cy, mut cz) = mean_3d(points)?;
314
315    // Weiszfeld iterations.
316    for _ in 0..options.max_iter {
317        let ((nx, ny, nz), coincident) =
318            weiszfeld_step_3d(cx, cy, cz, points, options.coincidence_eps);
319
320        if coincident {
321            // Perturb along x-axis.
322            let ((nx2, ny2, nz2), _) = weiszfeld_step_3d(
323                cx + options.coincidence_eps,
324                cy,
325                cz,
326                points,
327                options.coincidence_eps,
328            );
329            let dx = nx2 - cx;
330            let dy = ny2 - cy;
331            let dz = nz2 - cz;
332            let step = (dx * dx + dy * dy + dz * dz).sqrt();
333            cx = nx2;
334            cy = ny2;
335            cz = nz2;
336            if step < options.tol {
337                break;
338            }
339            continue;
340        }
341
342        let dx = nx - cx;
343        let dy = ny - cy;
344        let dz = nz - cz;
345        let step = (dx * dx + dy * dy + dz * dz).sqrt();
346        cx = nx;
347        cy = ny;
348        cz = nz;
349        if step < options.tol {
350            break;
351        }
352    }
353
354    Some((cx, cy, cz))
355}
356
357// ---------------------------------------------------------------------------
358// Public API — coordinate-wise L1 and arithmetic mean
359// ---------------------------------------------------------------------------
360
361/// Coordinate-wise L1 median (median-of-x, median-of-y).
362///
363/// This is **not** the true geometric median because it is computed
364/// independently per coordinate axis and is therefore not rotation-invariant.
365/// It is, however, extremely fast (O(n log n)) and provides a useful,
366/// breakdown-point-50% robust location estimate.
367///
368/// For an odd number of points, the median is the middle sorted value.  For an
369/// even number, the median is the arithmetic mean of the two middle values.
370///
371/// Returns `None` for an empty input.
372pub fn l1_median(points: &[(f64, f64)]) -> Option<(f64, f64)> {
373    let n = points.len();
374    if n == 0 {
375        return None;
376    }
377
378    let mut xs: Vec<f64> = points.iter().map(|&(x, _)| x).collect();
379    let mut ys: Vec<f64> = points.iter().map(|&(_, y)| y).collect();
380
381    // Partial sort is sufficient, but a full sort keeps this straightforward
382    // and is O(n log n) — acceptable for the sizes found in geospatial work.
383    xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
384    ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
385
386    let median_x = coordinate_median(&xs);
387    let median_y = coordinate_median(&ys);
388
389    Some((median_x, median_y))
390}
391
392/// Return the median of a **sorted** slice of `f64` values.
393///
394/// For odd length: the middle element.
395/// For even length: the average of the two middle elements.
396///
397/// # Panics
398///
399/// Panics if `sorted` is empty (callers must guard against this).
400fn coordinate_median(sorted: &[f64]) -> f64 {
401    let n = sorted.len();
402    debug_assert!(n > 0, "coordinate_median called on empty slice");
403    if n % 2 == 1 {
404        sorted[n / 2]
405    } else {
406        (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
407    }
408}
409
410/// Arithmetic spatial mean (centroid) of a set of 2-D points.
411///
412/// Equivalent to the centre of mass when every point has equal unit mass.
413/// Provided primarily to allow callers to compare the geometric median's
414/// robustness against outliers with that of the arithmetic mean.
415///
416/// Returns `None` for an empty input.
417pub fn spatial_mean(points: &[(f64, f64)]) -> Option<(f64, f64)> {
418    mean_2d(points)
419}
420
421// ---------------------------------------------------------------------------
422// Unit tests (kept internal; the full integration test suite lives under
423// `tests/robust_location_test.rs`).
424// ---------------------------------------------------------------------------
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn unit_mean_2d_empty() {
432        assert!(mean_2d(&[]).is_none());
433    }
434
435    #[test]
436    fn unit_mean_2d_single() {
437        let r = mean_2d(&[(3.0, 4.0)]).expect("non-empty slice should return Some");
438        assert!((r.0 - 3.0).abs() < 1e-12 && (r.1 - 4.0).abs() < 1e-12);
439    }
440
441    #[test]
442    fn unit_mean_2d_symmetric() {
443        let pts = [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0)];
444        let r = mean_2d(&pts).expect("non-empty slice should return Some");
445        assert!(r.0.abs() < 1e-12 && r.1.abs() < 1e-12);
446    }
447
448    #[test]
449    fn unit_coordinate_median_odd() {
450        let v = [1.0, 3.0, 5.0];
451        assert!((coordinate_median(&v) - 3.0).abs() < 1e-12);
452    }
453
454    #[test]
455    fn unit_coordinate_median_even() {
456        let v = [1.0, 3.0];
457        assert!((coordinate_median(&v) - 2.0).abs() < 1e-12);
458    }
459
460    #[test]
461    fn unit_geometric_median_options_builder() {
462        let opts = RobustLocationOptions::default()
463            .with_max_iter(50)
464            .with_tol(1e-8)
465            .with_coincidence_eps(1e-6);
466        assert_eq!(opts.max_iter, 50);
467        assert!((opts.tol - 1e-8).abs() < f64::EPSILON);
468        assert!((opts.coincidence_eps - 1e-6).abs() < f64::EPSILON);
469    }
470}