Skip to main content

fdars_core/alignment/
robust_karcher.rs

1//! Robust alternatives to the Karcher mean: median and trimmed mean.
2//!
3//! The standard Karcher mean is sensitive to outlier curves. This module
4//! provides two robust alternatives:
5//!
6//! - [`karcher_median`] — Geometric median via iteratively reweighted
7//!   Karcher mean (Weiszfeld algorithm on the elastic manifold).
8//! - [`robust_karcher_mean`] — Trimmed Karcher mean that removes the
9//!   most distant curves before averaging.
10
11use super::karcher::karcher_mean;
12use super::pairwise::elastic_distance;
13use super::set::align_to_target;
14use super::srsf::srsf_single;
15use crate::error::FdarError;
16use crate::matrix::FdMatrix;
17
18/// Configuration for robust Karcher estimation.
19///
20/// Construct via `RobustKarcherConfig::default()`, then assign the fields you need (e.g. `let mut c = RobustKarcherConfig::default(); c.field = …;`). This struct is `#[non_exhaustive]`, so external crates cannot build it with a struct literal — not even functional-update `..Default::default()` form.
21#[non_exhaustive]
22#[derive(Debug, Clone, PartialEq)]
23pub struct RobustKarcherConfig {
24    /// Maximum number of outer iterations.
25    pub max_iter: usize,
26    /// Convergence tolerance (relative change in SRSF).
27    pub tol: f64,
28    /// Roughness penalty for elastic alignment (0.0 = no penalty).
29    pub lambda: f64,
30    /// Fraction of most-distant curves to trim (for trimmed mean).
31    pub trim_fraction: f64,
32}
33
34impl Default for RobustKarcherConfig {
35    fn default() -> Self {
36        Self {
37            max_iter: 20,
38            tol: 1e-3,
39            lambda: 0.0,
40            trim_fraction: 0.1,
41        }
42    }
43}
44
45/// Result of robust Karcher estimation.
46#[derive(Debug, Clone, PartialEq)]
47#[non_exhaustive]
48pub struct RobustKarcherResult {
49    /// Robust mean/median curve.
50    pub mean: Vec<f64>,
51    /// SRSF of the robust mean/median.
52    pub mean_srsf: Vec<f64>,
53    /// Warping functions for all curves (n x m).
54    pub gammas: FdMatrix,
55    /// All curves aligned to the robust mean/median (n x m).
56    pub aligned_data: FdMatrix,
57    /// Per-curve weights (1/distance for median, 0/1 for trimmed).
58    pub weights: Vec<f64>,
59    /// Number of iterations performed.
60    pub n_iter: usize,
61    /// Whether the algorithm converged.
62    pub converged: bool,
63}
64
65/// Compute the Karcher median via the Weiszfeld algorithm on the elastic manifold.
66///
67/// The geometric median minimizes the sum of elastic distances to all curves,
68/// rather than the sum of squared distances (as with the mean). This makes it
69/// robust to outlier curves.
70///
71/// # Algorithm
72/// 1. Initialize with standard Karcher mean (1 iteration) as starting point.
73/// 2. Iterative Weiszfeld loop:
74///    a. Align all curves to the current median estimate.
75///    b. Compute elastic distances.
76///    c. Set weights w_i = 1 / max(d_i, epsilon), normalize.
77///    d. Compute weighted pointwise mean of aligned curves.
78///    e. Check convergence (relative change in SRSF).
79///
80/// # Arguments
81/// * `data`    — Functional data matrix (n x m).
82/// * `argvals` — Evaluation points (length m).
83/// * `config`  — Configuration parameters.
84///
85/// # Errors
86/// Returns [`FdarError::InvalidDimension`] if `argvals` length does not match `m`
87/// or `n < 2`.
88#[must_use = "expensive computation whose result should not be discarded"]
89pub fn karcher_median(
90    data: &FdMatrix,
91    argvals: &[f64],
92    config: &RobustKarcherConfig,
93) -> Result<RobustKarcherResult, FdarError> {
94    let (n, m) = data.shape();
95    validate_inputs(n, m, argvals)?;
96
97    // Step 1: Initialize with a quick Karcher mean (1 iteration).
98    let init = karcher_mean(data, argvals, 1, config.tol, config.lambda);
99    let mut current_mean = init.mean;
100
101    let mut converged = false;
102    let mut n_iter = 0;
103    let mut weights = vec![1.0 / n as f64; n];
104    let mut alignment_result = align_to_target(data, &current_mean, argvals, config.lambda);
105
106    // Step 2: Weiszfeld iterations.
107    for iter in 0..config.max_iter {
108        n_iter = iter + 1;
109
110        // Compute elastic distances.
111        let distances: Vec<f64> = (0..n)
112            .map(|i| {
113                let fi = data.row(i);
114                elastic_distance(&current_mean, &fi, argvals, config.lambda)
115            })
116            .collect();
117
118        // Compute weights: w_i = 1 / max(d_i, epsilon).
119        let epsilon = 1e-10;
120        let raw_weights: Vec<f64> = distances.iter().map(|&d| 1.0 / d.max(epsilon)).collect();
121        let w_sum: f64 = raw_weights.iter().sum();
122        weights = raw_weights.iter().map(|&w| w / w_sum).collect();
123
124        // Weighted pointwise mean of aligned curves.
125        let mut new_mean = vec![0.0; m];
126        for i in 0..n {
127            for j in 0..m {
128                new_mean[j] += weights[i] * alignment_result.aligned_data[(i, j)];
129            }
130        }
131
132        // Check convergence.
133        let old_srsf = srsf_single(&current_mean, argvals);
134        let new_srsf = srsf_single(&new_mean, argvals);
135        let rel = relative_srsf_change(&old_srsf, &new_srsf);
136
137        current_mean = new_mean;
138
139        if rel < config.tol {
140            converged = true;
141            // Final alignment to converged median.
142            alignment_result = align_to_target(data, &current_mean, argvals, config.lambda);
143            break;
144        }
145
146        // Re-align to updated median.
147        alignment_result = align_to_target(data, &current_mean, argvals, config.lambda);
148    }
149
150    let mean_srsf = srsf_single(&current_mean, argvals);
151
152    Ok(RobustKarcherResult {
153        mean: current_mean,
154        mean_srsf,
155        gammas: alignment_result.gammas,
156        aligned_data: alignment_result.aligned_data,
157        weights,
158        n_iter,
159        converged,
160    })
161}
162
163/// Compute a trimmed Karcher mean by removing the most distant curves.
164///
165/// Computes the standard Karcher mean, identifies and removes the top
166/// `trim_fraction` of curves by elastic distance, then recomputes the
167/// Karcher mean on the remaining curves. All curves (including trimmed
168/// ones) are re-aligned to the robust mean for the final output.
169///
170/// # Arguments
171/// * `data`    — Functional data matrix (n x m).
172/// * `argvals` — Evaluation points (length m).
173/// * `config`  — Configuration parameters.
174///
175/// # Errors
176/// Returns [`FdarError::InvalidDimension`] if `argvals` length does not match `m`
177/// or `n < 2`.
178/// Returns [`FdarError::InvalidParameter`] if `trim_fraction` is not in \[0, 1).
179#[must_use = "expensive computation whose result should not be discarded"]
180pub fn robust_karcher_mean(
181    data: &FdMatrix,
182    argvals: &[f64],
183    config: &RobustKarcherConfig,
184) -> Result<RobustKarcherResult, FdarError> {
185    let (n, m) = data.shape();
186    validate_inputs(n, m, argvals)?;
187
188    if !(0.0..1.0).contains(&config.trim_fraction) {
189        return Err(FdarError::InvalidParameter {
190            parameter: "trim_fraction",
191            message: format!("must be in [0, 1), got {}", config.trim_fraction),
192        });
193    }
194
195    // Step 1: Compute standard Karcher mean.
196    let initial_mean = karcher_mean(data, argvals, config.max_iter, config.tol, config.lambda);
197
198    // Step 2: Compute elastic distances from the mean.
199    let distances: Vec<f64> = (0..n)
200        .map(|i| {
201            let fi = data.row(i);
202            elastic_distance(&initial_mean.mean, &fi, argvals, config.lambda)
203        })
204        .collect();
205
206    // Step 3: Sort by distance, identify curves to trim.
207    let n_trim = ((n as f64) * config.trim_fraction).ceil() as usize;
208    let n_keep = n.saturating_sub(n_trim).max(2); // Keep at least 2 curves.
209
210    let mut indexed_distances: Vec<(usize, f64)> =
211        distances.iter().enumerate().map(|(i, &d)| (i, d)).collect();
212    indexed_distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
213
214    let kept_indices: Vec<usize> = indexed_distances
215        .iter()
216        .take(n_keep)
217        .map(|&(i, _)| i)
218        .collect();
219
220    // Step 4: Set weights.
221    let mut weights = vec![0.0; n];
222    for &idx in &kept_indices {
223        weights[idx] = 1.0;
224    }
225
226    // Step 5: Recompute Karcher mean on the kept subset.
227    let kept_data = subset_rows_from_indices(data, &kept_indices);
228    let robust_mean = karcher_mean(
229        &kept_data,
230        argvals,
231        config.max_iter,
232        config.tol,
233        config.lambda,
234    );
235
236    // Step 6: Re-align ALL curves (including trimmed) to the robust mean.
237    let final_alignment = align_to_target(data, &robust_mean.mean, argvals, config.lambda);
238
239    let mean_srsf = srsf_single(&robust_mean.mean, argvals);
240
241    Ok(RobustKarcherResult {
242        mean: robust_mean.mean,
243        mean_srsf,
244        gammas: final_alignment.gammas,
245        aligned_data: final_alignment.aligned_data,
246        weights,
247        n_iter: robust_mean.n_iter,
248        converged: robust_mean.converged,
249    })
250}
251
252/// Validate common input dimensions.
253fn validate_inputs(n: usize, m: usize, argvals: &[f64]) -> Result<(), FdarError> {
254    if argvals.len() != m {
255        return Err(FdarError::InvalidDimension {
256            parameter: "argvals",
257            expected: format!("{m}"),
258            actual: format!("{}", argvals.len()),
259        });
260    }
261    if n < 2 {
262        return Err(FdarError::InvalidDimension {
263            parameter: "data",
264            expected: "at least 2 rows".to_string(),
265            actual: format!("{n} rows"),
266        });
267    }
268    Ok(())
269}
270
271/// Compute relative change between successive SRSFs.
272fn relative_srsf_change(q_old: &[f64], q_new: &[f64]) -> f64 {
273    let diff_norm: f64 = q_old
274        .iter()
275        .zip(q_new.iter())
276        .map(|(&a, &b)| (a - b).powi(2))
277        .sum::<f64>()
278        .sqrt();
279    let old_norm: f64 = q_old.iter().map(|&v| v * v).sum::<f64>().sqrt().max(1e-10);
280    diff_norm / old_norm
281}
282
283use crate::cv::subset_rows as subset_rows_from_indices;
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::test_helpers::uniform_grid;
289
290    fn make_sine_data(n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
291        let t = uniform_grid(m);
292        let mut data_vec = vec![0.0; n * m];
293        for i in 0..n {
294            let phase = 0.03 * i as f64;
295            for j in 0..m {
296                data_vec[i + j * n] = ((t[j] + phase) * 4.0).sin();
297            }
298        }
299        let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
300        (data, t)
301    }
302
303    #[test]
304    fn karcher_median_basic() {
305        let (data, t) = make_sine_data(5, 20);
306        let config = RobustKarcherConfig {
307            max_iter: 5,
308            ..Default::default()
309        };
310        let result = karcher_median(&data, &t, &config).unwrap();
311        assert_eq!(result.mean.len(), 20);
312        assert_eq!(result.mean_srsf.len(), 20);
313        assert_eq!(result.gammas.shape(), (5, 20));
314        assert_eq!(result.aligned_data.shape(), (5, 20));
315        assert_eq!(result.weights.len(), 5);
316        assert!(result.n_iter >= 1);
317    }
318
319    #[test]
320    fn karcher_median_robust_to_outlier() {
321        let m = 20;
322        let t = uniform_grid(m);
323        let n = 6;
324        let mut data_vec = vec![0.0; n * m];
325
326        // 5 clean curves (slight phase shifts).
327        for i in 0..5 {
328            let phase = 0.02 * i as f64;
329            for j in 0..m {
330                data_vec[i + j * n] = ((t[j] + phase) * 4.0).sin();
331            }
332        }
333        // 1 extreme outlier.
334        for j in 0..m {
335            data_vec[5 + j * n] = (t[j] * 20.0).cos() * 5.0;
336        }
337        let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
338
339        // Compute standard mean and median.
340        let std_mean = karcher_mean(&data, &t, 5, 1e-3, 0.0);
341        let median_config = RobustKarcherConfig {
342            max_iter: 5,
343            ..Default::default()
344        };
345        let median_result = karcher_median(&data, &t, &median_config).unwrap();
346
347        // Compute a clean reference (mean of just the clean curves).
348        let clean_data = subset_rows_from_indices(&data, &[0, 1, 2, 3, 4]);
349        let clean_mean = karcher_mean(&clean_data, &t, 5, 1e-3, 0.0);
350
351        // Median should be closer to the clean mean than the standard mean is.
352        let d_std = pointwise_l2(&std_mean.mean, &clean_mean.mean);
353        let d_median = pointwise_l2(&median_result.mean, &clean_mean.mean);
354        assert!(
355            d_median <= d_std + 1e-6,
356            "median distance to clean ({d_median:.4}) should be <= standard mean distance ({d_std:.4})"
357        );
358    }
359
360    #[test]
361    fn robust_trimmed_removes_outliers() {
362        let m = 20;
363        let t = uniform_grid(m);
364        let n = 6;
365        let mut data_vec = vec![0.0; n * m];
366
367        // 5 clean curves.
368        for i in 0..5 {
369            let phase = 0.02 * i as f64;
370            for j in 0..m {
371                data_vec[i + j * n] = ((t[j] + phase) * 4.0).sin();
372            }
373        }
374        // 1 extreme outlier.
375        for j in 0..m {
376            data_vec[5 + j * n] = (t[j] * 20.0).cos() * 5.0;
377        }
378        let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
379
380        let config = RobustKarcherConfig {
381            max_iter: 5,
382            trim_fraction: 0.2, // Trim top 20% (= 2 curves out of 6).
383            ..Default::default()
384        };
385        let result = robust_karcher_mean(&data, &t, &config).unwrap();
386
387        // The outlier (index 5) should have weight 0.0.
388        assert!(
389            result.weights[5] < 1e-10,
390            "outlier weight should be 0, got {}",
391            result.weights[5]
392        );
393
394        // At least some curves should have weight 1.0.
395        let n_kept: usize = result.weights.iter().filter(|&&w| w > 0.5).count();
396        assert!(n_kept >= 4, "should keep at least 4 curves, got {n_kept}");
397    }
398
399    #[test]
400    fn robust_config_default() {
401        let cfg = RobustKarcherConfig::default();
402        assert_eq!(cfg.max_iter, 20);
403        assert!((cfg.tol - 1e-3).abs() < f64::EPSILON);
404        assert!((cfg.lambda - 0.0).abs() < f64::EPSILON);
405        assert!((cfg.trim_fraction - 0.1).abs() < f64::EPSILON);
406    }
407
408    /// Simple pointwise L2 distance between two curves.
409    fn pointwise_l2(a: &[f64], b: &[f64]) -> f64 {
410        a.iter()
411            .zip(b.iter())
412            .map(|(&x, &y)| (x - y).powi(2))
413            .sum::<f64>()
414            .sqrt()
415    }
416}