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::{
14    band_1d, epigraph_index_1d, extremal_depth_1d, extreme_rank_length_depth_1d, fraiman_muniz_1d,
15    half_region_depth_1d, hypograph_index_1d, linfinity_depth_1d, modified_band_1d,
16    modified_half_region_depth_1d, modified_hypograph_index_1d, random_projection_1d_seeded,
17    total_variation_depth_1d,
18};
19use crate::error::FdarError;
20use crate::matrix::FdMatrix;
21
22/// Depth measure selector for [`functional_depth`] and [`functional_boxplot`].
23///
24/// Each variant maps 1:1 to an existing self-depth call:
25/// - `FraimanMuniz { scale }` → `fraiman_muniz_1d(data, data, scale)`
26/// - `Band` → `band_1d(data, data)`
27/// - `ModifiedBand` → `modified_band_1d(data, data)`
28/// - `RandomProjection { nproj, seed }` → `random_projection_1d_seeded(data, data, nproj, Some(seed))`
29#[derive(Debug, Clone, Copy, PartialEq)]
30#[non_exhaustive]
31pub enum DepthMethod {
32    /// Fraiman-Muniz depth. `scale` toggles the scaled `2·min(Fn, 1−Fn)` form.
33    FraimanMuniz {
34        /// Whether to scale the depth values.
35        scale: bool,
36    },
37    /// Band depth (BD).
38    Band,
39    /// Modified band depth (MBD).
40    ModifiedBand,
41    /// Random projection depth. `seed` makes results bit-reproducible.
42    RandomProjection {
43        /// Number of random projection directions.
44        nproj: usize,
45        /// RNG seed for deterministic projections.
46        seed: u64,
47    },
48    /// Hypograph index (HI). Global indicator: fraction of reference curves globally
49    /// at or below the object curve. Requires n >= 2. [CITED: roahd::HI]
50    HypographIndex,
51    /// Modified hypograph index (MHI). Pointwise average: fraction of time the object
52    /// curve dominates each reference curve. [CITED: roahd::MHI]
53    ModifiedHypographIndex,
54    /// Epigraph index (EI, un-modified). Global indicator: fraction of reference curves
55    /// globally at or above the object curve. Requires n >= 2. [CITED: roahd::EI]
56    EpigraphIndex,
57    /// Half-region depth (HRD) = min(EI, HI) over the global indicators. Requires n >= 2.
58    /// [CITED: roahd::HRD]
59    HalfRegion,
60    /// Modified half-region depth (MHRD) = min(MEI, MHI) over the pointwise modified
61    /// indices. [CITED: roahd::MHRD]
62    ModifiedHalfRegion,
63    /// Extremal depth (Narisetty & Nair 2016). Rank-ordering measure; requires n >= 3.
64    /// [CITED: fdaoutlier::extremal_depth]
65    Extremal,
66    /// Extreme-rank-length depth. Lexicographic rank-vector ordering; requires n >= 2.
67    /// [CITED: fdaoutlier::extreme_rank_length]
68    ExtremeRankLength,
69    /// L-infinity (sup-norm) depth. Valid for n >= 1. [CITED: fdaoutlier::linfinity_depth]
70    LInfinity,
71    /// Total variation depth + MSSI (Huang & Sun 2019). Dispatches the TVD (magnitude)
72    /// component; requires n >= 3. [CITED: fdaoutlier::total_variation_depth]
73    TotalVariation,
74}
75
76/// Compute the **self-depth** of every curve in `data` w.r.t. the sample.
77///
78/// Passes `data` as both the object and reference matrix and dispatches to the
79/// underlying depth function selected by `method`. Returns one depth per curve
80/// (`Vec<f64>` of length `data.nrows()`).
81///
82/// # Errors
83/// - `InvalidDimension` if `data` has zero rows or zero columns, or if
84///   `Band`/`ModifiedBand` is requested with fewer than 2 curves (a band needs
85///   two reference curves).
86/// - `InvalidParameter` if `RandomProjection { nproj: 0, .. }` is requested.
87pub fn functional_depth(data: &FdMatrix, method: DepthMethod) -> Result<Vec<f64>, FdarError> {
88    let (n, m) = (data.nrows(), data.ncols());
89    if n == 0 || m == 0 {
90        return Err(FdarError::InvalidDimension {
91            parameter: "data",
92            expected: "non-empty matrix (nrows > 0 and ncols > 0)".to_string(),
93            actual: format!("{n}x{m}"),
94        });
95    }
96
97    let depths = match method {
98        DepthMethod::FraimanMuniz { scale } => fraiman_muniz_1d(data, data, scale),
99        DepthMethod::Band => {
100            if n < 2 {
101                return Err(FdarError::InvalidDimension {
102                    parameter: "data",
103                    expected: "at least 2 curves for band depth".to_string(),
104                    actual: format!("{n}"),
105                });
106            }
107            band_1d(data, data)
108        }
109        DepthMethod::ModifiedBand => {
110            if n < 2 {
111                return Err(FdarError::InvalidDimension {
112                    parameter: "data",
113                    expected: "at least 2 curves for modified band depth".to_string(),
114                    actual: format!("{n}"),
115                });
116            }
117            modified_band_1d(data, data)
118        }
119        DepthMethod::RandomProjection { nproj, seed } => {
120            if nproj == 0 {
121                return Err(FdarError::InvalidParameter {
122                    parameter: "nproj",
123                    message: "must be >= 1".to_string(),
124                });
125            }
126            random_projection_1d_seeded(data, data, nproj, Some(seed))
127        }
128        DepthMethod::HypographIndex => {
129            if n < 2 {
130                return Err(FdarError::InvalidDimension {
131                    parameter: "data",
132                    expected: "at least 2 curves for hypograph index".to_string(),
133                    actual: format!("{n}"),
134                });
135            }
136            hypograph_index_1d(data, data)?
137        }
138        DepthMethod::ModifiedHypographIndex => modified_hypograph_index_1d(data, data)?,
139        DepthMethod::EpigraphIndex => {
140            if n < 2 {
141                return Err(FdarError::InvalidDimension {
142                    parameter: "data",
143                    expected: "at least 2 curves for epigraph index".to_string(),
144                    actual: format!("{n}"),
145                });
146            }
147            epigraph_index_1d(data, data)?
148        }
149        DepthMethod::HalfRegion => {
150            if n < 2 {
151                return Err(FdarError::InvalidDimension {
152                    parameter: "data",
153                    expected: "at least 2 curves for half-region depth".to_string(),
154                    actual: format!("{n}"),
155                });
156            }
157            half_region_depth_1d(data, data)?
158        }
159        DepthMethod::ModifiedHalfRegion => {
160            if n < 2 {
161                return Err(FdarError::InvalidDimension {
162                    parameter: "data",
163                    expected: "at least 2 curves for modified half-region depth".to_string(),
164                    actual: format!("{n}"),
165                });
166            }
167            modified_half_region_depth_1d(data, data)?
168        }
169        DepthMethod::Extremal => {
170            if n < 3 {
171                return Err(FdarError::InvalidDimension {
172                    parameter: "data",
173                    expected: "at least 3 curves for extremal depth".to_string(),
174                    actual: format!("{n}"),
175                });
176            }
177            extremal_depth_1d(data, data)?
178        }
179        DepthMethod::ExtremeRankLength => {
180            if n < 2 {
181                return Err(FdarError::InvalidDimension {
182                    parameter: "data",
183                    expected: "at least 2 curves for extreme-rank-length depth".to_string(),
184                    actual: format!("{n}"),
185                });
186            }
187            extreme_rank_length_depth_1d(data, data)?
188        }
189        DepthMethod::LInfinity => linfinity_depth_1d(data, data)?,
190        DepthMethod::TotalVariation => {
191            if n < 3 {
192                return Err(FdarError::InvalidDimension {
193                    parameter: "data",
194                    expected: "at least 3 curves for total variation depth".to_string(),
195                    actual: format!("{n}"),
196                });
197            }
198            total_variation_depth_1d(data, data)?.tvd
199        }
200    };
201
202    Ok(depths)
203}
204
205/// Numeric outputs of a depth-fence functional boxplot (no plotting).
206///
207/// All curve vectors have length `data.ncols()` (one value per evaluation point);
208/// `depths` has length `data.nrows()` and `outliers` holds flagged row indices.
209#[derive(Debug, Clone, PartialEq)]
210#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
211#[non_exhaustive]
212pub struct FunctionalBoxplotResult {
213    /// The deepest (median) curve's values across all evaluation points.
214    pub median: Vec<f64>,
215    /// Pointwise lower bound of the 50% central region.
216    pub central_lower: Vec<f64>,
217    /// Pointwise upper bound of the 50% central region.
218    pub central_upper: Vec<f64>,
219    /// Pointwise lower whisker (central region inflated by `factor × width`).
220    pub whisker_lower: Vec<f64>,
221    /// Pointwise upper whisker (central region inflated by `factor × width`).
222    pub whisker_upper: Vec<f64>,
223    /// Row indices of curves that exceed the fence at any evaluation point.
224    pub outliers: Vec<usize>,
225    /// Per-curve self-depth used to rank curves.
226    pub depths: Vec<f64>,
227}
228
229/// Canonical López-Pintado–Romo depth-fence functional boxplot (numeric only).
230///
231/// Ranks curves by [`functional_depth`], takes the deepest curve as the median,
232/// builds the 50% central region as the pointwise envelope of the deepest half,
233/// inflates it by `factor × (central width)` to form the whiskers/fence, and
234/// flags any curve that exceeds the fence at any evaluation point as an outlier.
235///
236/// The recommended defaults are `method = DepthMethod::ModifiedBand` and
237/// `factor = 1.5`; the caller passes both explicitly.
238///
239/// # Errors
240/// - `InvalidDimension` if `data` is empty or has fewer than 2 curves.
241/// - `InvalidParameter` if `factor` is negative or not finite.
242/// - Propagates errors from [`functional_depth`].
243pub fn functional_boxplot(
244    data: &FdMatrix,
245    method: DepthMethod,
246    factor: f64,
247) -> Result<FunctionalBoxplotResult, FdarError> {
248    let (n, m) = (data.nrows(), data.ncols());
249    if n == 0 || m == 0 {
250        return Err(FdarError::InvalidDimension {
251            parameter: "data",
252            expected: "non-empty matrix (nrows > 0 and ncols > 0)".to_string(),
253            actual: format!("{n}x{m}"),
254        });
255    }
256    if n < 2 {
257        return Err(FdarError::InvalidDimension {
258            parameter: "data",
259            expected: "at least 2 curves for a functional boxplot".to_string(),
260            actual: format!("{n}"),
261        });
262    }
263    if !factor.is_finite() || factor < 0.0 {
264        return Err(FdarError::InvalidParameter {
265            parameter: "factor",
266            message: "must be a finite value >= 0.0".to_string(),
267        });
268    }
269
270    let depths = functional_depth(data, method)?;
271
272    // Median = deepest curve (argmax of depth; ties broken by lowest index).
273    let mut median_row = 0usize;
274    for i in 1..n {
275        if depths[i] > depths[median_row] {
276            median_row = i;
277        }
278    }
279    let median: Vec<f64> = (0..m).map(|t| data[(median_row, t)]).collect();
280
281    // Deepest 50% of rows (ceil(n/2)), ties broken by index for determinism.
282    let half = n.div_ceil(2);
283    let mut order: Vec<usize> = (0..n).collect();
284    order.sort_by(|&a, &b| {
285        depths[b]
286            .partial_cmp(&depths[a])
287            .unwrap_or(std::cmp::Ordering::Equal)
288            .then(a.cmp(&b))
289    });
290    let central_rows = &order[..half];
291
292    // Central region = pointwise min/max over the deepest-half rows.
293    let mut central_lower = vec![f64::INFINITY; m];
294    let mut central_upper = vec![f64::NEG_INFINITY; m];
295    for &i in central_rows {
296        for t in 0..m {
297            let v = data[(i, t)];
298            if v < central_lower[t] {
299                central_lower[t] = v;
300            }
301            if v > central_upper[t] {
302                central_upper[t] = v;
303            }
304        }
305    }
306
307    // Whiskers = central region inflated by factor × width at each t.
308    let mut whisker_lower = vec![0.0; m];
309    let mut whisker_upper = vec![0.0; m];
310    for t in 0..m {
311        let width = central_upper[t] - central_lower[t];
312        whisker_lower[t] = central_lower[t] - factor * width;
313        whisker_upper[t] = central_upper[t] + factor * width;
314    }
315
316    // Outliers = any curve exceeding the fence at any evaluation point.
317    let mut outliers = Vec::new();
318    for i in 0..n {
319        let mut flagged = false;
320        for t in 0..m {
321            let v = data[(i, t)];
322            if v < whisker_lower[t] || v > whisker_upper[t] {
323                flagged = true;
324                break;
325            }
326        }
327        if flagged {
328            outliers.push(i);
329        }
330    }
331
332    Ok(FunctionalBoxplotResult {
333        median,
334        central_lower,
335        central_upper,
336        whisker_lower,
337        whisker_upper,
338        outliers,
339        depths,
340    })
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    /// Small deterministic sample: `n` mild sinusoids on an `m`-point grid.
348    fn sample(n: usize, m: usize) -> FdMatrix {
349        let mut col_major = vec![0.0; n * m];
350        for i in 0..n {
351            for t in 0..m {
352                let x = t as f64 / (m as f64 - 1.0);
353                // element (i, t) at index i + t*n (column-major)
354                col_major[i + t * n] = (x * std::f64::consts::PI).sin() + 0.05 * i as f64;
355            }
356        }
357        FdMatrix::from_column_major(col_major, n, m).unwrap()
358    }
359
360    #[test]
361    fn fraiman_muniz_dispatch_equals_underlying() {
362        let data = sample(6, 12);
363        for scale in [true, false] {
364            let got = functional_depth(&data, DepthMethod::FraimanMuniz { scale }).unwrap();
365            let want = fraiman_muniz_1d(&data, &data, scale);
366            assert_eq!(got, want);
367            assert_eq!(got.len(), data.nrows());
368        }
369    }
370
371    #[test]
372    fn band_dispatch_equals_underlying() {
373        let data = sample(6, 12);
374        let got = functional_depth(&data, DepthMethod::Band).unwrap();
375        assert_eq!(got, band_1d(&data, &data));
376        assert_eq!(got.len(), 6);
377    }
378
379    #[test]
380    fn modified_band_dispatch_equals_underlying() {
381        let data = sample(6, 12);
382        let got = functional_depth(&data, DepthMethod::ModifiedBand).unwrap();
383        assert_eq!(got, modified_band_1d(&data, &data));
384        assert_eq!(got.len(), 6);
385    }
386
387    #[test]
388    fn random_projection_dispatch_equals_underlying_and_is_reproducible() {
389        let data = sample(6, 12);
390        let method = DepthMethod::RandomProjection {
391            nproj: 20,
392            seed: 42,
393        };
394        let got = functional_depth(&data, method).unwrap();
395        let want = random_projection_1d_seeded(&data, &data, 20, Some(42));
396        assert_eq!(got, want);
397        // Two dispatch calls with the same seed are bit-identical.
398        let got2 = functional_depth(&data, method).unwrap();
399        assert_eq!(got, got2);
400    }
401
402    #[test]
403    fn empty_matrix_returns_err() {
404        let empty = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
405        assert!(functional_depth(&empty, DepthMethod::FraimanMuniz { scale: true }).is_err());
406    }
407
408    #[test]
409    fn too_few_curves_for_band_returns_err() {
410        let one = sample(1, 8);
411        assert!(functional_depth(&one, DepthMethod::Band).is_err());
412        assert!(functional_depth(&one, DepthMethod::ModifiedBand).is_err());
413    }
414
415    #[test]
416    fn zero_nproj_returns_err() {
417        let data = sample(6, 12);
418        assert!(
419            functional_depth(&data, DepthMethod::RandomProjection { nproj: 0, seed: 1 }).is_err()
420        );
421    }
422
423    // --- functional_boxplot ---
424
425    /// Inlier sinusoids plus one gross-outlier curve at row `outlier_idx`.
426    fn sample_with_outlier(n: usize, m: usize, outlier_idx: usize) -> FdMatrix {
427        let mut col_major = vec![0.0; n * m];
428        for i in 0..n {
429            for t in 0..m {
430                let x = t as f64 / (m as f64 - 1.0);
431                let base = (x * std::f64::consts::PI).sin();
432                let val = if i == outlier_idx {
433                    base + 100.0 // gross vertical shift far outside the band
434                } else {
435                    base + 0.01 * i as f64 // tight inliers
436                };
437                col_major[i + t * n] = val;
438            }
439        }
440        FdMatrix::from_column_major(col_major, n, m).unwrap()
441    }
442
443    #[test]
444    fn boxplot_flags_planted_outlier_and_spares_inliers() {
445        let outlier_idx = 3;
446        let data = sample_with_outlier(8, 15, outlier_idx);
447        let res = functional_boxplot(&data, DepthMethod::ModifiedBand, 1.5).unwrap();
448        assert!(res.outliers.contains(&outlier_idx));
449        for i in 0..8 {
450            if i != outlier_idx {
451                assert!(!res.outliers.contains(&i), "inlier {i} wrongly flagged");
452            }
453        }
454    }
455
456    #[test]
457    fn boxplot_median_equals_deepest_and_central_brackets_median() {
458        let data = sample_with_outlier(8, 15, 3);
459        let res = functional_boxplot(&data, DepthMethod::ModifiedBand, 1.5).unwrap();
460        // Median row = argmax depth.
461        let mut deepest = 0usize;
462        for i in 1..res.depths.len() {
463            if res.depths[i] > res.depths[deepest] {
464                deepest = i;
465            }
466        }
467        let expected_median: Vec<f64> = (0..data.ncols()).map(|t| data[(deepest, t)]).collect();
468        assert_eq!(res.median, expected_median);
469        for t in 0..data.ncols() {
470            assert!(res.central_lower[t] <= res.median[t] + 1e-12);
471            assert!(res.median[t] <= res.central_upper[t] + 1e-12);
472        }
473    }
474
475    #[test]
476    fn boxplot_fence_contains_central_region() {
477        let data = sample_with_outlier(8, 15, 3);
478        let res = functional_boxplot(&data, DepthMethod::ModifiedBand, 1.5).unwrap();
479        for t in 0..data.ncols() {
480            assert!(res.whisker_lower[t] <= res.central_lower[t] + 1e-12);
481            assert!(res.whisker_upper[t] >= res.central_upper[t] - 1e-12);
482        }
483    }
484
485    #[test]
486    fn boxplot_random_projection_is_seed_reproducible() {
487        let data = sample_with_outlier(8, 15, 3);
488        let method = DepthMethod::RandomProjection { nproj: 25, seed: 7 };
489        let a = functional_boxplot(&data, method, 1.5).unwrap();
490        let b = functional_boxplot(&data, method, 1.5).unwrap();
491        assert_eq!(a, b);
492    }
493
494    #[test]
495    fn boxplot_invalid_input_returns_err() {
496        let empty = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
497        assert!(functional_boxplot(&empty, DepthMethod::ModifiedBand, 1.5).is_err());
498        let single = sample(1, 8);
499        assert!(functional_boxplot(&single, DepthMethod::ModifiedBand, 1.5).is_err());
500        let data = sample(6, 12);
501        assert!(functional_boxplot(&data, DepthMethod::ModifiedBand, -1.0).is_err());
502    }
503
504    // --- Phase 28 all-variant coverage ---
505
506    /// The nine parameter-free depth measures added across plans 28-01..28-03.
507    const NEW_VARIANTS: [DepthMethod; 9] = [
508        DepthMethod::HalfRegion,
509        DepthMethod::ModifiedHalfRegion,
510        DepthMethod::HypographIndex,
511        DepthMethod::ModifiedHypographIndex,
512        DepthMethod::EpigraphIndex,
513        DepthMethod::Extremal,
514        DepthMethod::ExtremeRankLength,
515        DepthMethod::LInfinity,
516        DepthMethod::TotalVariation,
517    ];
518
519    #[test]
520    fn all_nine_new_variants_round_trip() {
521        let data = sample(6, 12); // n >= 3 so every guard is satisfied
522        for method in NEW_VARIANTS {
523            let got = functional_depth(&data, method).unwrap();
524            assert_eq!(got.len(), data.nrows(), "wrong length for {method:?}");
525        }
526    }
527
528    #[test]
529    fn existing_variants_unchanged_regression() {
530        // The additive change must not alter the four pre-existing variants.
531        let data = sample(6, 12);
532        assert!(functional_depth(&data, DepthMethod::FraimanMuniz { scale: false }).is_ok());
533        assert!(functional_depth(&data, DepthMethod::Band).is_ok());
534        assert!(functional_depth(&data, DepthMethod::ModifiedBand).is_ok());
535        assert!(
536            functional_depth(&data, DepthMethod::RandomProjection { nproj: 10, seed: 1 }).is_ok()
537        );
538    }
539
540    #[test]
541    fn min_n_guards_return_err_without_panic() {
542        // Single curve: every measure needing >= 2 (and >= 3) rejects it.
543        let single = sample(1, 8);
544        for method in [
545            DepthMethod::Band,
546            DepthMethod::HalfRegion,
547            DepthMethod::ModifiedHalfRegion,
548            DepthMethod::HypographIndex,
549            DepthMethod::EpigraphIndex,
550            DepthMethod::ExtremeRankLength,
551            DepthMethod::Extremal,
552            DepthMethod::TotalVariation,
553        ] {
554            assert!(
555                functional_depth(&single, method).is_err(),
556                "{method:?} should reject n=1"
557            );
558        }
559        // n=2 rejects the n>=3 measures.
560        let two = sample(2, 8);
561        assert!(functional_depth(&two, DepthMethod::Extremal).is_err());
562        assert!(functional_depth(&two, DepthMethod::TotalVariation).is_err());
563        // Empty matrix rejected by a representative new variant.
564        let empty = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
565        assert!(functional_depth(&empty, DepthMethod::LInfinity).is_err());
566    }
567}