Skip to main content

fdars_core/alignment/
elastic_depth.rs

1//! Amplitude + phase decomposed functional depth in the elastic metric.
2//!
3//! Combines amplitude and phase distance matrices to produce depth scores
4//! that reflect how central a curve is within a dataset, after factoring
5//! out phase variation.
6
7use super::pairwise::{amplitude_self_distance_matrix, phase_self_distance_matrix};
8use crate::error::FdarError;
9use crate::matrix::FdMatrix;
10
11/// Result of elastic depth computation.
12#[derive(Debug, Clone, PartialEq)]
13#[non_exhaustive]
14pub struct ElasticDepthResult {
15    /// Amplitude depth for each curve (n values in \[0, 1\]).
16    pub amplitude_depth: Vec<f64>,
17    /// Phase depth for each curve (n values in \[0, 1\]).
18    pub phase_depth: Vec<f64>,
19    /// Combined depth: geometric mean of amplitude and phase depths (n values).
20    pub combined_depth: Vec<f64>,
21    /// Amplitude distance matrix (n x n).
22    pub amplitude_distances: FdMatrix,
23    /// Phase distance matrix (n x n).
24    pub phase_distances: FdMatrix,
25}
26
27/// Compute amplitude + phase decomposed elastic depth.
28///
29/// For each curve, the depth is defined as an inverse-average-distance
30/// formulation (lens depth): `depth_i = 1 / (1 + mean_dist_to_others)`.
31/// The combined depth is the geometric mean of amplitude and phase depths.
32///
33/// # Arguments
34/// * `data` — Functional data matrix (n x m)
35/// * `argvals` — Evaluation points (length m)
36/// * `lambda` — Roughness penalty for elastic alignment (0.0 = no penalty)
37///
38/// # Errors
39/// Returns [`FdarError::InvalidDimension`] if `argvals` length does not match `m`
40/// or `n < 2`.
41#[must_use = "expensive computation whose result should not be discarded"]
42pub fn elastic_depth(
43    data: &FdMatrix,
44    argvals: &[f64],
45    lambda: f64,
46) -> Result<ElasticDepthResult, FdarError> {
47    let (n, m) = data.shape();
48
49    if argvals.len() != m {
50        return Err(FdarError::InvalidDimension {
51            parameter: "argvals",
52            expected: format!("{m}"),
53            actual: format!("{}", argvals.len()),
54        });
55    }
56    if n < 2 {
57        return Err(FdarError::InvalidDimension {
58            parameter: "data",
59            expected: "at least 2 rows".to_string(),
60            actual: format!("{n} rows"),
61        });
62    }
63
64    // Step 1 & 2: Compute distance matrices.
65    let amplitude_distances = amplitude_self_distance_matrix(data, argvals, lambda);
66    let phase_distances = phase_self_distance_matrix(data, argvals, lambda);
67
68    // Step 3: Convert distances to depths.
69    let amplitude_depth = distance_matrix_to_depth(&amplitude_distances, n);
70    let phase_depth = distance_matrix_to_depth(&phase_distances, n);
71
72    // Step 4: Combined depth = geometric mean.
73    let combined_depth: Vec<f64> = amplitude_depth
74        .iter()
75        .zip(phase_depth.iter())
76        .map(|(&a, &p)| (a * p).sqrt())
77        .collect();
78
79    Ok(ElasticDepthResult {
80        amplitude_depth,
81        phase_depth,
82        combined_depth,
83        amplitude_distances,
84        phase_distances,
85    })
86}
87
88/// Convert a symmetric distance matrix to depth values using inverse average distance.
89fn distance_matrix_to_depth(dist: &FdMatrix, n: usize) -> Vec<f64> {
90    (0..n)
91        .map(|i| {
92            let sum: f64 = (0..n).filter(|&j| j != i).map(|j| dist[(i, j)]).sum();
93            let mean_dist = sum / (n - 1) as f64;
94            1.0 / (1.0 + mean_dist)
95        })
96        .collect()
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::test_helpers::uniform_grid;
103
104    fn make_sine_data(n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
105        let t = uniform_grid(m);
106        let mut data_vec = vec![0.0; n * m];
107        for i in 0..n {
108            let phase = 0.05 * i as f64;
109            for j in 0..m {
110                data_vec[i + j * n] = ((t[j] + phase) * 4.0).sin();
111            }
112        }
113        let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
114        (data, t)
115    }
116
117    #[test]
118    fn elastic_depth_dimensions() {
119        let (data, t) = make_sine_data(4, 20);
120        let result = elastic_depth(&data, &t, 0.0).unwrap();
121        assert_eq!(result.amplitude_depth.len(), 4);
122        assert_eq!(result.phase_depth.len(), 4);
123        assert_eq!(result.combined_depth.len(), 4);
124        assert_eq!(result.amplitude_distances.shape(), (4, 4));
125        assert_eq!(result.phase_distances.shape(), (4, 4));
126    }
127
128    #[test]
129    fn elastic_depth_nonneg() {
130        let (data, t) = make_sine_data(4, 20);
131        let result = elastic_depth(&data, &t, 0.0).unwrap();
132        for &d in &result.amplitude_depth {
133            assert!((0.0..=1.0).contains(&d), "amplitude depth {d} out of [0,1]");
134        }
135        for &d in &result.phase_depth {
136            assert!((0.0..=1.0).contains(&d), "phase depth {d} out of [0,1]");
137        }
138        for &d in &result.combined_depth {
139            assert!((0.0..=1.0).contains(&d), "combined depth {d} out of [0,1]");
140        }
141    }
142
143    #[test]
144    fn elastic_depth_identical_curves_highest() {
145        // Build data: 3 identical curves + 1 different curve.
146        let m = 20;
147        let t = uniform_grid(m);
148        let n = 4;
149        let mut data_vec = vec![0.0; n * m];
150        for j in 0..m {
151            let v = (t[j] * 4.0).sin();
152            // Curves 0, 1, 2 are identical.
153            data_vec[j * n] = v;
154            data_vec[1 + j * n] = v;
155            data_vec[2 + j * n] = v;
156            // Curve 3 is very different.
157            data_vec[3 + j * n] = (t[j] * 12.0).cos() * 3.0;
158        }
159        let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
160        let result = elastic_depth(&data, &t, 0.0).unwrap();
161
162        // Identical curves should have higher depth than the outlier.
163        let min_identical = result.combined_depth[0]
164            .min(result.combined_depth[1])
165            .min(result.combined_depth[2]);
166        assert!(
167            min_identical > result.combined_depth[3],
168            "identical curves depth ({min_identical}) should exceed outlier depth ({})",
169            result.combined_depth[3]
170        );
171    }
172}