Skip to main content

fdars_core/depth/
spatial.rs

1//! Functional spatial depth measures (FSD and KFSD).
2
3use crate::dim::Dim;
4use crate::helpers::simpsons_weights;
5use crate::iter_maybe_parallel;
6use crate::matrix::FdMatrix;
7#[cfg(feature = "parallel")]
8use rayon::iter::ParallelIterator;
9
10/// Compute Functional Spatial Depth (FSD), dispatching on [`Dim`].
11///
12/// Uses the L2 norm with Simpson's integration weights (matches R's `depth.FSD()`).
13/// `Dim::One` uses `argvals` (or a uniform grid when `None`); `Dim::Two` treats each row as a
14/// flattened surface and always uses the uniform grid (matching the former `functional_spatial_2d`).
15///
16/// # Arguments
17/// * `data_obj` - Data to compute depth for (nobj x n_points)
18/// * `data_ori` - Reference data (nori x n_points)
19/// * `argvals` - Optional evaluation grid for `Dim::One`; `None` uses a uniform grid
20/// * `dim` - Dimensionality selector ([`Dim::One`] or [`Dim::Two`])
21#[must_use = "expensive computation whose result should not be discarded"]
22pub fn functional_spatial(
23    data_obj: &FdMatrix,
24    data_ori: &FdMatrix,
25    argvals: Option<&[f64]>,
26    dim: Dim,
27) -> Vec<f64> {
28    match dim {
29        Dim::One => functional_spatial_impl(data_obj, data_ori, argvals),
30        Dim::Two => functional_spatial_impl(data_obj, data_ori, None),
31    }
32}
33
34/// Compute Functional Spatial Depth for 1D functional data.
35///
36/// Uses L2 norm with Simpson's integration weights to match R's `depth.FSD()`.
37///
38/// # Arguments
39/// * `data_obj` - Data to compute depth for (nobj x n_points)
40/// * `data_ori` - Reference data (nori x n_points)
41/// * `argvals` - Optional evaluation grid; if None, uses uniform \[0,1\] grid
42fn functional_spatial_impl(
43    data_obj: &FdMatrix,
44    data_ori: &FdMatrix,
45    argvals: Option<&[f64]>,
46) -> Vec<f64> {
47    let nobj = data_obj.nrows();
48    let nori = data_ori.nrows();
49    let n_points = data_obj.ncols();
50
51    if nobj == 0 || nori == 0 || n_points == 0 {
52        return Vec::new();
53    }
54
55    // Build integration weights from argvals
56    let default_argvals: Vec<f64>;
57    let weights = if let Some(av) = argvals {
58        simpsons_weights(av)
59    } else {
60        default_argvals = (0..n_points)
61            .map(|i| i as f64 / (n_points - 1).max(1) as f64)
62            .collect();
63        simpsons_weights(&default_argvals)
64    };
65
66    iter_maybe_parallel!(0..nobj)
67        .map(|i| {
68            let mut sum_unit = vec![0.0; n_points];
69
70            for j in 0..nori {
71                // Compute L2 norm with integration weights
72                let mut norm_sq = 0.0;
73                for t in 0..n_points {
74                    let d = data_ori[(j, t)] - data_obj[(i, t)];
75                    norm_sq += weights[t] * d * d;
76                }
77
78                let norm = norm_sq.sqrt();
79                if norm > 1e-10 {
80                    let inv_norm = 1.0 / norm;
81                    for t in 0..n_points {
82                        sum_unit[t] += (data_ori[(j, t)] - data_obj[(i, t)]) * inv_norm;
83                    }
84                }
85            }
86
87            // Compute L2 norm of average unit vector with integration weights
88            let mut avg_norm_sq = 0.0;
89            for t in 0..n_points {
90                let avg = sum_unit[t] / nori as f64;
91                avg_norm_sq += weights[t] * avg * avg;
92            }
93
94            1.0 - avg_norm_sq.sqrt()
95        })
96        .collect()
97}
98
99/// Compute kernel distance contribution for a single (j,k) pair.
100fn kernel_pair_contribution(j: usize, k: usize, m1: &FdMatrix, m2: &[f64]) -> Option<f64> {
101    let denom_j_sq = 2.0 - 2.0 * m2[j];
102    if denom_j_sq < 1e-20 {
103        return None;
104    }
105    let denom_k_sq = 2.0 - 2.0 * m2[k];
106    if denom_k_sq < 1e-20 {
107        return None;
108    }
109    let denom = denom_j_sq.sqrt() * denom_k_sq.sqrt();
110    if denom <= 1e-20 {
111        return None;
112    }
113    let m_ijk = (1.0 + m1[(j, k)] - m2[j] - m2[k]) / denom;
114    if m_ijk.is_finite() {
115        Some(m_ijk)
116    } else {
117        None
118    }
119}
120
121/// Accumulate the kernel spatial depth statistic for a single observation.
122/// Returns (total_sum, valid_count) from the double sum over reference pairs.
123///
124/// Exploits symmetry: `kernel_pair_contribution(j, k, m1, m2) == kernel_pair_contribution(k, j, m1, m2)`
125/// since m1 is symmetric and the formula `(1 + m1[j][k] - m2[j] - m2[k]) / (sqrt(2-2*m2[j]) * sqrt(2-2*m2[k]))`
126/// is symmetric in j and k. Loops over the upper triangle only.
127fn kfsd_accumulate(m2: &[f64], m1: &FdMatrix, nori: usize) -> (f64, usize) {
128    let mut total_sum = 0.0;
129    let mut valid_count = 0;
130
131    // Diagonal contributions (j == k)
132    for j in 0..nori {
133        if let Some(val) = kernel_pair_contribution(j, j, m1, m2) {
134            total_sum += val;
135            valid_count += 1;
136        }
137    }
138
139    // Upper triangle contributions (j < k), counted twice by symmetry
140    for j in 0..nori {
141        for k in (j + 1)..nori {
142            if let Some(val) = kernel_pair_contribution(j, k, m1, m2) {
143                total_sum += 2.0 * val;
144                valid_count += 2;
145            }
146        }
147    }
148
149    (total_sum, valid_count)
150}
151
152/// Shared implementation for kernel functional spatial depth.
153/// Uses weighted L2 norm: sum_t weights[t] * (f(t) - g(t))^2.
154fn kfsd_weighted(data_obj: &FdMatrix, data_ori: &FdMatrix, h: f64, weights: &[f64]) -> Vec<f64> {
155    let nobj = data_obj.nrows();
156    let nori = data_ori.nrows();
157    let n_points = data_obj.ncols();
158    let h_sq = h * h;
159
160    // Pre-compute M1[j,k] = K(X_j, X_k) for reference data
161    let m1_upper: Vec<(usize, usize, f64)> = iter_maybe_parallel!(0..nori)
162        .flat_map(|j| {
163            ((j + 1)..nori)
164                .map(|k| {
165                    let mut sum = 0.0;
166                    for t in 0..n_points {
167                        let diff = data_ori[(j, t)] - data_ori[(k, t)];
168                        sum += weights[t] * diff * diff;
169                    }
170                    (j, k, (-sum / h_sq).exp())
171                })
172                .collect::<Vec<_>>()
173        })
174        .collect();
175
176    let mut m1 = FdMatrix::zeros(nori, nori);
177    for j in 0..nori {
178        m1[(j, j)] = 1.0;
179    }
180    for (j, k, kval) in m1_upper {
181        m1[(j, k)] = kval;
182        m1[(k, j)] = kval;
183    }
184
185    let nori_f64 = nori as f64;
186
187    iter_maybe_parallel!(0..nobj)
188        .map(|i| {
189            let m2: Vec<f64> = (0..nori)
190                .map(|j| {
191                    let mut sum = 0.0;
192                    for t in 0..n_points {
193                        let diff = data_obj[(i, t)] - data_ori[(j, t)];
194                        sum += weights[t] * diff * diff;
195                    }
196                    (-sum / h_sq).exp()
197                })
198                .collect();
199
200            let (total_sum, valid_count) = kfsd_accumulate(&m2, &m1, nori);
201
202            if valid_count > 0 && total_sum >= 0.0 {
203                1.0 - total_sum.sqrt() / nori_f64
204            } else if total_sum < 0.0 {
205                1.0
206            } else {
207                0.0
208            }
209        })
210        .collect()
211}
212
213/// Compute Kernel Functional Spatial Depth (KFSD), dispatching on [`Dim`].
214///
215/// RKHS-based depth. `Dim::One` uses Simpson's weights derived from `argvals`; `Dim::Two` uses
216/// uniform weights over the flattened surface grid (matching the former `kernel_functional_spatial_2d`).
217///
218/// # Arguments
219/// * `data_obj` - Data to compute depth for
220/// * `data_ori` - Reference data
221/// * `argvals` - Evaluation grid for `Dim::One` (`Some`); ignored for `Dim::Two` (pass `None`)
222/// * `h` - Kernel bandwidth
223/// * `dim` - Dimensionality selector ([`Dim::One`] or [`Dim::Two`])
224#[must_use = "expensive computation whose result should not be discarded"]
225pub fn kernel_functional_spatial(
226    data_obj: &FdMatrix,
227    data_ori: &FdMatrix,
228    argvals: Option<&[f64]>,
229    h: f64,
230    dim: Dim,
231) -> Vec<f64> {
232    match dim {
233        Dim::One => {
234            // Mirror `functional_spatial`: when no grid is supplied, fall back to a
235            // uniform [0,1] grid so `Dim::One` + `None` is well-defined (the former
236            // `kernel_functional_spatial_1d` required an explicit `argvals`, so this
237            // input combination did not exist before consolidation).
238            let default_argvals: Vec<f64>;
239            let av = match argvals {
240                Some(a) => a,
241                None => {
242                    let n_points = data_obj.ncols();
243                    default_argvals = (0..n_points)
244                        .map(|i| i as f64 / (n_points - 1).max(1) as f64)
245                        .collect();
246                    &default_argvals
247                }
248            };
249            kernel_functional_spatial_1d_impl(data_obj, data_ori, av, h)
250        }
251        Dim::Two => kernel_functional_spatial_2d_impl(data_obj, data_ori, h),
252    }
253}
254
255/// Compute Kernel Functional Spatial Depth (KFSD) for 1D functional data.
256///
257/// Implements the RKHS-based formulation.
258fn kernel_functional_spatial_1d_impl(
259    data_obj: &FdMatrix,
260    data_ori: &FdMatrix,
261    argvals: &[f64],
262    h: f64,
263) -> Vec<f64> {
264    let nobj = data_obj.nrows();
265    let nori = data_ori.nrows();
266    let n_points = data_obj.ncols();
267
268    if nobj == 0 || nori == 0 || n_points == 0 {
269        return Vec::new();
270    }
271
272    let weights = simpsons_weights(argvals);
273    kfsd_weighted(data_obj, data_ori, h, &weights)
274}
275
276/// Compute Kernel Functional Spatial Depth (KFSD) for 2D functional data.
277fn kernel_functional_spatial_2d_impl(data_obj: &FdMatrix, data_ori: &FdMatrix, h: f64) -> Vec<f64> {
278    let nobj = data_obj.nrows();
279    let nori = data_ori.nrows();
280    let n_points = data_obj.ncols();
281
282    if nobj == 0 || nori == 0 || n_points == 0 {
283        return Vec::new();
284    }
285
286    let weights = vec![1.0; n_points];
287    kfsd_weighted(data_obj, data_ori, h, &weights)
288}