Skip to main content

fdars_core/depth/
dispatch.rs

1//! Unified depth dispatcher and depth-fence functional boxplot.
2//!
3//! This module provides a single [`functional_depth`] entry point that computes
4//! the **self-depth** of a sample (each curve's depth with respect to the sample
5//! itself, i.e. `data_obj == data_ori`) by dispatching to the existing depth
6//! functions via the [`DepthMethod`] selector. It also provides the canonical
7//! López-Pintado–Romo depth-fence [`functional_boxplot`], which produces numeric
8//! central-region / whisker / outlier-flag outputs (no plotting).
9//!
10//! The dispatcher only *wraps* the underlying depth functions — their signatures
11//! are unchanged.
12
13use crate::depth::{band_1d, fraiman_muniz_1d, modified_band_1d, random_projection_1d_seeded};
14use crate::error::FdarError;
15use crate::matrix::FdMatrix;
16
17/// Depth measure selector for [`functional_depth`] and [`functional_boxplot`].
18///
19/// Each variant maps 1:1 to an existing self-depth call:
20/// - `FraimanMuniz { scale }` → `fraiman_muniz_1d(data, data, scale)`
21/// - `Band` → `band_1d(data, data)`
22/// - `ModifiedBand` → `modified_band_1d(data, data)`
23/// - `RandomProjection { nproj, seed }` → `random_projection_1d_seeded(data, data, nproj, Some(seed))`
24#[derive(Debug, Clone, Copy, PartialEq)]
25#[non_exhaustive]
26pub enum DepthMethod {
27    /// Fraiman-Muniz depth. `scale` toggles the scaled `2·min(Fn, 1−Fn)` form.
28    FraimanMuniz {
29        /// Whether to scale the depth values.
30        scale: bool,
31    },
32    /// Band depth (BD).
33    Band,
34    /// Modified band depth (MBD).
35    ModifiedBand,
36    /// Random projection depth. `seed` makes results bit-reproducible.
37    RandomProjection {
38        /// Number of random projection directions.
39        nproj: usize,
40        /// RNG seed for deterministic projections.
41        seed: u64,
42    },
43}
44
45/// Compute the **self-depth** of every curve in `data` w.r.t. the sample.
46///
47/// Passes `data` as both the object and reference matrix and dispatches to the
48/// underlying depth function selected by `method`. Returns one depth per curve
49/// (`Vec<f64>` of length `data.nrows()`).
50///
51/// # Errors
52/// - `InvalidDimension` if `data` has zero rows or zero columns, or if
53///   `Band`/`ModifiedBand` is requested with fewer than 2 curves (a band needs
54///   two reference curves).
55/// - `InvalidParameter` if `RandomProjection { nproj: 0, .. }` is requested.
56pub fn functional_depth(data: &FdMatrix, method: DepthMethod) -> Result<Vec<f64>, FdarError> {
57    let (n, m) = (data.nrows(), data.ncols());
58    if n == 0 || m == 0 {
59        return Err(FdarError::InvalidDimension {
60            parameter: "data",
61            expected: "non-empty matrix (nrows > 0 and ncols > 0)".to_string(),
62            actual: format!("{n}x{m}"),
63        });
64    }
65
66    let depths = match method {
67        DepthMethod::FraimanMuniz { scale } => fraiman_muniz_1d(data, data, scale),
68        DepthMethod::Band => {
69            if n < 2 {
70                return Err(FdarError::InvalidDimension {
71                    parameter: "data",
72                    expected: "at least 2 curves for band depth".to_string(),
73                    actual: format!("{n}"),
74                });
75            }
76            band_1d(data, data)
77        }
78        DepthMethod::ModifiedBand => {
79            if n < 2 {
80                return Err(FdarError::InvalidDimension {
81                    parameter: "data",
82                    expected: "at least 2 curves for modified band depth".to_string(),
83                    actual: format!("{n}"),
84                });
85            }
86            modified_band_1d(data, data)
87        }
88        DepthMethod::RandomProjection { nproj, seed } => {
89            if nproj == 0 {
90                return Err(FdarError::InvalidParameter {
91                    parameter: "nproj",
92                    message: "must be >= 1".to_string(),
93                });
94            }
95            random_projection_1d_seeded(data, data, nproj, Some(seed))
96        }
97    };
98
99    Ok(depths)
100}
101
102/// Numeric outputs of a depth-fence functional boxplot (no plotting).
103///
104/// All curve vectors have length `data.ncols()` (one value per evaluation point);
105/// `depths` has length `data.nrows()` and `outliers` holds flagged row indices.
106#[derive(Debug, Clone, PartialEq)]
107#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
108#[non_exhaustive]
109pub struct FunctionalBoxplotResult {
110    /// The deepest (median) curve's values across all evaluation points.
111    pub median: Vec<f64>,
112    /// Pointwise lower bound of the 50% central region.
113    pub central_lower: Vec<f64>,
114    /// Pointwise upper bound of the 50% central region.
115    pub central_upper: Vec<f64>,
116    /// Pointwise lower whisker (central region inflated by `factor × width`).
117    pub whisker_lower: Vec<f64>,
118    /// Pointwise upper whisker (central region inflated by `factor × width`).
119    pub whisker_upper: Vec<f64>,
120    /// Row indices of curves that exceed the fence at any evaluation point.
121    pub outliers: Vec<usize>,
122    /// Per-curve self-depth used to rank curves.
123    pub depths: Vec<f64>,
124}
125
126/// Canonical López-Pintado–Romo depth-fence functional boxplot (numeric only).
127///
128/// Ranks curves by [`functional_depth`], takes the deepest curve as the median,
129/// builds the 50% central region as the pointwise envelope of the deepest half,
130/// inflates it by `factor × (central width)` to form the whiskers/fence, and
131/// flags any curve that exceeds the fence at any evaluation point as an outlier.
132///
133/// The recommended defaults are `method = DepthMethod::ModifiedBand` and
134/// `factor = 1.5`; the caller passes both explicitly.
135///
136/// # Errors
137/// - `InvalidDimension` if `data` is empty or has fewer than 2 curves.
138/// - `InvalidParameter` if `factor` is negative or not finite.
139/// - Propagates errors from [`functional_depth`].
140pub fn functional_boxplot(
141    data: &FdMatrix,
142    method: DepthMethod,
143    factor: f64,
144) -> Result<FunctionalBoxplotResult, FdarError> {
145    let (n, m) = (data.nrows(), data.ncols());
146    if n == 0 || m == 0 {
147        return Err(FdarError::InvalidDimension {
148            parameter: "data",
149            expected: "non-empty matrix (nrows > 0 and ncols > 0)".to_string(),
150            actual: format!("{n}x{m}"),
151        });
152    }
153    if n < 2 {
154        return Err(FdarError::InvalidDimension {
155            parameter: "data",
156            expected: "at least 2 curves for a functional boxplot".to_string(),
157            actual: format!("{n}"),
158        });
159    }
160    if !factor.is_finite() || factor < 0.0 {
161        return Err(FdarError::InvalidParameter {
162            parameter: "factor",
163            message: "must be a finite value >= 0.0".to_string(),
164        });
165    }
166
167    let depths = functional_depth(data, method)?;
168
169    // Median = deepest curve (argmax of depth; ties broken by lowest index).
170    let mut median_row = 0usize;
171    for i in 1..n {
172        if depths[i] > depths[median_row] {
173            median_row = i;
174        }
175    }
176    let median: Vec<f64> = (0..m).map(|t| data[(median_row, t)]).collect();
177
178    // Deepest 50% of rows (ceil(n/2)), ties broken by index for determinism.
179    let half = n.div_ceil(2);
180    let mut order: Vec<usize> = (0..n).collect();
181    order.sort_by(|&a, &b| {
182        depths[b]
183            .partial_cmp(&depths[a])
184            .unwrap_or(std::cmp::Ordering::Equal)
185            .then(a.cmp(&b))
186    });
187    let central_rows = &order[..half];
188
189    // Central region = pointwise min/max over the deepest-half rows.
190    let mut central_lower = vec![f64::INFINITY; m];
191    let mut central_upper = vec![f64::NEG_INFINITY; m];
192    for &i in central_rows {
193        for t in 0..m {
194            let v = data[(i, t)];
195            if v < central_lower[t] {
196                central_lower[t] = v;
197            }
198            if v > central_upper[t] {
199                central_upper[t] = v;
200            }
201        }
202    }
203
204    // Whiskers = central region inflated by factor × width at each t.
205    let mut whisker_lower = vec![0.0; m];
206    let mut whisker_upper = vec![0.0; m];
207    for t in 0..m {
208        let width = central_upper[t] - central_lower[t];
209        whisker_lower[t] = central_lower[t] - factor * width;
210        whisker_upper[t] = central_upper[t] + factor * width;
211    }
212
213    // Outliers = any curve exceeding the fence at any evaluation point.
214    let mut outliers = Vec::new();
215    for i in 0..n {
216        let mut flagged = false;
217        for t in 0..m {
218            let v = data[(i, t)];
219            if v < whisker_lower[t] || v > whisker_upper[t] {
220                flagged = true;
221                break;
222            }
223        }
224        if flagged {
225            outliers.push(i);
226        }
227    }
228
229    Ok(FunctionalBoxplotResult {
230        median,
231        central_lower,
232        central_upper,
233        whisker_lower,
234        whisker_upper,
235        outliers,
236        depths,
237    })
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    /// Small deterministic sample: `n` mild sinusoids on an `m`-point grid.
245    fn sample(n: usize, m: usize) -> FdMatrix {
246        let mut col_major = vec![0.0; n * m];
247        for i in 0..n {
248            for t in 0..m {
249                let x = t as f64 / (m as f64 - 1.0);
250                // element (i, t) at index i + t*n (column-major)
251                col_major[i + t * n] = (x * std::f64::consts::PI).sin() + 0.05 * i as f64;
252            }
253        }
254        FdMatrix::from_column_major(col_major, n, m).unwrap()
255    }
256
257    #[test]
258    fn fraiman_muniz_dispatch_equals_underlying() {
259        let data = sample(6, 12);
260        for scale in [true, false] {
261            let got = functional_depth(&data, DepthMethod::FraimanMuniz { scale }).unwrap();
262            let want = fraiman_muniz_1d(&data, &data, scale);
263            assert_eq!(got, want);
264            assert_eq!(got.len(), data.nrows());
265        }
266    }
267
268    #[test]
269    fn band_dispatch_equals_underlying() {
270        let data = sample(6, 12);
271        let got = functional_depth(&data, DepthMethod::Band).unwrap();
272        assert_eq!(got, band_1d(&data, &data));
273        assert_eq!(got.len(), 6);
274    }
275
276    #[test]
277    fn modified_band_dispatch_equals_underlying() {
278        let data = sample(6, 12);
279        let got = functional_depth(&data, DepthMethod::ModifiedBand).unwrap();
280        assert_eq!(got, modified_band_1d(&data, &data));
281        assert_eq!(got.len(), 6);
282    }
283
284    #[test]
285    fn random_projection_dispatch_equals_underlying_and_is_reproducible() {
286        let data = sample(6, 12);
287        let method = DepthMethod::RandomProjection {
288            nproj: 20,
289            seed: 42,
290        };
291        let got = functional_depth(&data, method).unwrap();
292        let want = random_projection_1d_seeded(&data, &data, 20, Some(42));
293        assert_eq!(got, want);
294        // Two dispatch calls with the same seed are bit-identical.
295        let got2 = functional_depth(&data, method).unwrap();
296        assert_eq!(got, got2);
297    }
298
299    #[test]
300    fn empty_matrix_returns_err() {
301        let empty = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
302        assert!(functional_depth(&empty, DepthMethod::FraimanMuniz { scale: true }).is_err());
303    }
304
305    #[test]
306    fn too_few_curves_for_band_returns_err() {
307        let one = sample(1, 8);
308        assert!(functional_depth(&one, DepthMethod::Band).is_err());
309        assert!(functional_depth(&one, DepthMethod::ModifiedBand).is_err());
310    }
311
312    #[test]
313    fn zero_nproj_returns_err() {
314        let data = sample(6, 12);
315        assert!(
316            functional_depth(&data, DepthMethod::RandomProjection { nproj: 0, seed: 1 }).is_err()
317        );
318    }
319
320    // --- functional_boxplot ---
321
322    /// Inlier sinusoids plus one gross-outlier curve at row `outlier_idx`.
323    fn sample_with_outlier(n: usize, m: usize, outlier_idx: usize) -> FdMatrix {
324        let mut col_major = vec![0.0; n * m];
325        for i in 0..n {
326            for t in 0..m {
327                let x = t as f64 / (m as f64 - 1.0);
328                let base = (x * std::f64::consts::PI).sin();
329                let val = if i == outlier_idx {
330                    base + 100.0 // gross vertical shift far outside the band
331                } else {
332                    base + 0.01 * i as f64 // tight inliers
333                };
334                col_major[i + t * n] = val;
335            }
336        }
337        FdMatrix::from_column_major(col_major, n, m).unwrap()
338    }
339
340    #[test]
341    fn boxplot_flags_planted_outlier_and_spares_inliers() {
342        let outlier_idx = 3;
343        let data = sample_with_outlier(8, 15, outlier_idx);
344        let res = functional_boxplot(&data, DepthMethod::ModifiedBand, 1.5).unwrap();
345        assert!(res.outliers.contains(&outlier_idx));
346        for i in 0..8 {
347            if i != outlier_idx {
348                assert!(!res.outliers.contains(&i), "inlier {i} wrongly flagged");
349            }
350        }
351    }
352
353    #[test]
354    fn boxplot_median_equals_deepest_and_central_brackets_median() {
355        let data = sample_with_outlier(8, 15, 3);
356        let res = functional_boxplot(&data, DepthMethod::ModifiedBand, 1.5).unwrap();
357        // Median row = argmax depth.
358        let mut deepest = 0usize;
359        for i in 1..res.depths.len() {
360            if res.depths[i] > res.depths[deepest] {
361                deepest = i;
362            }
363        }
364        let expected_median: Vec<f64> = (0..data.ncols()).map(|t| data[(deepest, t)]).collect();
365        assert_eq!(res.median, expected_median);
366        for t in 0..data.ncols() {
367            assert!(res.central_lower[t] <= res.median[t] + 1e-12);
368            assert!(res.median[t] <= res.central_upper[t] + 1e-12);
369        }
370    }
371
372    #[test]
373    fn boxplot_fence_contains_central_region() {
374        let data = sample_with_outlier(8, 15, 3);
375        let res = functional_boxplot(&data, DepthMethod::ModifiedBand, 1.5).unwrap();
376        for t in 0..data.ncols() {
377            assert!(res.whisker_lower[t] <= res.central_lower[t] + 1e-12);
378            assert!(res.whisker_upper[t] >= res.central_upper[t] - 1e-12);
379        }
380    }
381
382    #[test]
383    fn boxplot_random_projection_is_seed_reproducible() {
384        let data = sample_with_outlier(8, 15, 3);
385        let method = DepthMethod::RandomProjection { nproj: 25, seed: 7 };
386        let a = functional_boxplot(&data, method, 1.5).unwrap();
387        let b = functional_boxplot(&data, method, 1.5).unwrap();
388        assert_eq!(a, b);
389    }
390
391    #[test]
392    fn boxplot_invalid_input_returns_err() {
393        let empty = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
394        assert!(functional_boxplot(&empty, DepthMethod::ModifiedBand, 1.5).is_err());
395        let single = sample(1, 8);
396        assert!(functional_boxplot(&single, DepthMethod::ModifiedBand, 1.5).is_err());
397        let data = sample(6, 12);
398        assert!(functional_boxplot(&data, DepthMethod::ModifiedBand, -1.0).is_err());
399    }
400}