fdars_core/alignment/
outlier.rs1use super::karcher::karcher_mean;
7use super::pairwise::{amplitude_distance, elastic_distance, phase_distance_pair};
8use super::robust_karcher::{karcher_median, RobustKarcherConfig};
9use crate::error::FdarError;
10use crate::matrix::FdMatrix;
11
12#[non_exhaustive]
16#[derive(Debug, Clone, PartialEq)]
17pub struct ElasticOutlierConfig {
18 pub lambda: f64,
20 pub alpha: f64,
23 pub use_median: bool,
26}
27
28impl Default for ElasticOutlierConfig {
29 fn default() -> Self {
30 Self {
31 lambda: 0.0,
32 alpha: 0.05,
33 use_median: true,
34 }
35 }
36}
37
38#[derive(Debug, Clone, PartialEq)]
40#[non_exhaustive]
41pub struct ElasticOutlierResult {
42 pub outlier_indices: Vec<usize>,
44 pub distances: Vec<f64>,
46 pub threshold: f64,
48 pub amplitude_distances: Vec<f64>,
50 pub phase_distances: Vec<f64>,
52}
53
54#[must_use = "expensive computation whose result should not be discarded"]
69pub fn elastic_outlier_detection(
70 data: &FdMatrix,
71 argvals: &[f64],
72 config: &ElasticOutlierConfig,
73) -> Result<ElasticOutlierResult, FdarError> {
74 let (n, m) = data.shape();
75
76 if argvals.len() != m {
77 return Err(FdarError::InvalidDimension {
78 parameter: "argvals",
79 expected: format!("{m}"),
80 actual: format!("{}", argvals.len()),
81 });
82 }
83 if n < 2 {
84 return Err(FdarError::InvalidDimension {
85 parameter: "data",
86 expected: "at least 2 rows".to_string(),
87 actual: format!("{n} rows"),
88 });
89 }
90
91 let reference = if config.use_median {
93 let median_config = RobustKarcherConfig {
94 max_iter: 15,
95 tol: 1e-3,
96 lambda: config.lambda,
97 trim_fraction: 0.1,
98 };
99 let result = karcher_median(data, argvals, &median_config)?;
100 result.mean
101 } else {
102 let result = karcher_mean(data, argvals, 15, 1e-3, config.lambda);
103 result.mean
104 };
105
106 let distances: Vec<f64> = (0..n)
108 .map(|i| {
109 let fi = data.row(i);
110 elastic_distance(&reference, &fi, argvals, config.lambda)
111 })
112 .collect();
113
114 let amplitude_distances: Vec<f64> = (0..n)
116 .map(|i| {
117 let fi = data.row(i);
118 amplitude_distance(&reference, &fi, argvals, config.lambda)
119 })
120 .collect();
121
122 let phase_distances: Vec<f64> = (0..n)
123 .map(|i| {
124 let fi = data.row(i);
125 phase_distance_pair(&reference, &fi, argvals, config.lambda)
126 })
127 .collect();
128
129 let threshold = tukey_fence(&distances);
131
132 let outlier_indices: Vec<usize> = (0..n).filter(|&i| distances[i] > threshold).collect();
134
135 Ok(ElasticOutlierResult {
136 outlier_indices,
137 distances,
138 threshold,
139 amplitude_distances,
140 phase_distances,
141 })
142}
143
144fn tukey_fence(values: &[f64]) -> f64 {
146 let n = values.len();
147 if n < 4 {
148 return values.iter().copied().fold(f64::NEG_INFINITY, f64::max) + 1.0;
150 }
151
152 let mut sorted = values.to_vec();
153 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
154
155 let q1 = percentile_sorted(&sorted, 25.0);
156 let q3 = percentile_sorted(&sorted, 75.0);
157 let iqr = q3 - q1;
158
159 q3 + 1.5 * iqr
160}
161
162fn percentile_sorted(sorted: &[f64], pct: f64) -> f64 {
164 let n = sorted.len();
165 if n == 0 {
166 return 0.0;
167 }
168 if n == 1 {
169 return sorted[0];
170 }
171
172 let rank = pct / 100.0 * (n - 1) as f64;
173 let lo = rank.floor() as usize;
174 let hi = rank.ceil() as usize;
175 let frac = rank - lo as f64;
176
177 if lo >= n || hi >= n {
178 sorted[n - 1]
179 } else {
180 sorted[lo] * (1.0 - frac) + sorted[hi] * frac
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use crate::test_helpers::uniform_grid;
188
189 fn make_clean_data(n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
190 let t = uniform_grid(m);
191 let mut data_vec = vec![0.0; n * m];
192 for i in 0..n {
193 let phase = 0.02 * i as f64;
194 for j in 0..m {
195 data_vec[i + j * n] = ((t[j] + phase) * 4.0).sin();
196 }
197 }
198 let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
199 (data, t)
200 }
201
202 #[test]
203 fn outlier_detection_no_outliers() {
204 let (data, t) = make_clean_data(6, 20);
205 let config = ElasticOutlierConfig::default();
206 let result = elastic_outlier_detection(&data, &t, &config).unwrap();
207
208 assert_eq!(result.distances.len(), 6);
209 assert_eq!(result.amplitude_distances.len(), 6);
210 assert_eq!(result.phase_distances.len(), 6);
211 assert!(result.threshold > 0.0);
212
213 assert!(
215 result.outlier_indices.len() <= 1,
216 "clean data should have at most 1 outlier, got {}",
217 result.outlier_indices.len()
218 );
219 }
220
221 #[test]
222 fn outlier_detection_finds_extreme() {
223 let m = 20;
224 let t = uniform_grid(m);
225 let n = 8;
226 let mut data_vec = vec![0.0; n * m];
227
228 for i in 0..7 {
230 let phase = 0.02 * i as f64;
231 for j in 0..m {
232 data_vec[i + j * n] = ((t[j] + phase) * 4.0).sin();
233 }
234 }
235 for j in 0..m {
237 data_vec[7 + j * n] = (t[j] * 20.0).cos() * 10.0;
238 }
239 let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
240
241 let config = ElasticOutlierConfig::default();
242 let result = elastic_outlier_detection(&data, &t, &config).unwrap();
243
244 assert!(
246 result.outlier_indices.contains(&7),
247 "should detect curve 7 as outlier, detected: {:?}",
248 result.outlier_indices
249 );
250
251 assert!(
253 result.distances[7] > result.threshold,
254 "outlier distance ({}) should exceed threshold ({})",
255 result.distances[7],
256 result.threshold
257 );
258 }
259
260 #[test]
261 fn outlier_detection_config_default() {
262 let cfg = ElasticOutlierConfig::default();
263 assert!((cfg.lambda - 0.0).abs() < f64::EPSILON);
264 assert!((cfg.alpha - 0.05).abs() < f64::EPSILON);
265 assert!(cfg.use_median);
266 }
267}