Skip to main content

fdars_core/depth/
spatial.rs

1//! Functional spatial depth measures (FSD and KFSD).
2
3use crate::helpers::simpsons_weights;
4use crate::iter_maybe_parallel;
5use crate::matrix::FdMatrix;
6#[cfg(feature = "parallel")]
7use rayon::iter::ParallelIterator;
8
9/// Compute Functional Spatial Depth for 1D functional data.
10///
11/// Uses L2 norm with Simpson's integration weights to match R's `depth.FSD()`.
12///
13/// # Arguments
14/// * `data_obj` - Data to compute depth for (nobj x n_points)
15/// * `data_ori` - Reference data (nori x n_points)
16/// * `argvals` - Optional evaluation grid; if None, uses uniform \[0,1\] grid
17#[must_use = "expensive computation whose result should not be discarded"]
18pub fn functional_spatial_1d(
19    data_obj: &FdMatrix,
20    data_ori: &FdMatrix,
21    argvals: Option<&[f64]>,
22) -> Vec<f64> {
23    let nobj = data_obj.nrows();
24    let nori = data_ori.nrows();
25    let n_points = data_obj.ncols();
26
27    if nobj == 0 || nori == 0 || n_points == 0 {
28        return Vec::new();
29    }
30
31    // Build integration weights from argvals
32    let default_argvals: Vec<f64>;
33    let weights = if let Some(av) = argvals {
34        simpsons_weights(av)
35    } else {
36        default_argvals = (0..n_points)
37            .map(|i| i as f64 / (n_points - 1).max(1) as f64)
38            .collect();
39        simpsons_weights(&default_argvals)
40    };
41
42    iter_maybe_parallel!(0..nobj)
43        .map(|i| {
44            let mut sum_unit = vec![0.0; n_points];
45
46            for j in 0..nori {
47                // Compute L2 norm with integration weights
48                let mut norm_sq = 0.0;
49                for t in 0..n_points {
50                    let d = data_ori[(j, t)] - data_obj[(i, t)];
51                    norm_sq += weights[t] * d * d;
52                }
53
54                let norm = norm_sq.sqrt();
55                if norm > 1e-10 {
56                    let inv_norm = 1.0 / norm;
57                    for t in 0..n_points {
58                        sum_unit[t] += (data_ori[(j, t)] - data_obj[(i, t)]) * inv_norm;
59                    }
60                }
61            }
62
63            // Compute L2 norm of average unit vector with integration weights
64            let mut avg_norm_sq = 0.0;
65            for t in 0..n_points {
66                let avg = sum_unit[t] / nori as f64;
67                avg_norm_sq += weights[t] * avg * avg;
68            }
69
70            1.0 - avg_norm_sq.sqrt()
71        })
72        .collect()
73}
74
75/// Compute Functional Spatial Depth for 2D functional data.
76#[must_use = "expensive computation whose result should not be discarded"]
77pub fn functional_spatial_2d(data_obj: &FdMatrix, data_ori: &FdMatrix) -> Vec<f64> {
78    functional_spatial_1d(data_obj, data_ori, None)
79}
80
81/// Compute kernel distance contribution for a single (j,k) pair.
82fn kernel_pair_contribution(j: usize, k: usize, m1: &FdMatrix, m2: &[f64]) -> Option<f64> {
83    let denom_j_sq = 2.0 - 2.0 * m2[j];
84    if denom_j_sq < 1e-20 {
85        return None;
86    }
87    let denom_k_sq = 2.0 - 2.0 * m2[k];
88    if denom_k_sq < 1e-20 {
89        return None;
90    }
91    let denom = denom_j_sq.sqrt() * denom_k_sq.sqrt();
92    if denom <= 1e-20 {
93        return None;
94    }
95    let m_ijk = (1.0 + m1[(j, k)] - m2[j] - m2[k]) / denom;
96    if m_ijk.is_finite() {
97        Some(m_ijk)
98    } else {
99        None
100    }
101}
102
103/// Accumulate the kernel spatial depth statistic for a single observation.
104/// Returns (total_sum, valid_count) from the double sum over reference pairs.
105///
106/// Exploits symmetry: `kernel_pair_contribution(j, k, m1, m2) == kernel_pair_contribution(k, j, m1, m2)`
107/// 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]))`
108/// is symmetric in j and k. Loops over the upper triangle only.
109fn kfsd_accumulate(m2: &[f64], m1: &FdMatrix, nori: usize) -> (f64, usize) {
110    let mut total_sum = 0.0;
111    let mut valid_count = 0;
112
113    // Diagonal contributions (j == k)
114    for j in 0..nori {
115        if let Some(val) = kernel_pair_contribution(j, j, m1, m2) {
116            total_sum += val;
117            valid_count += 1;
118        }
119    }
120
121    // Upper triangle contributions (j < k), counted twice by symmetry
122    for j in 0..nori {
123        for k in (j + 1)..nori {
124            if let Some(val) = kernel_pair_contribution(j, k, m1, m2) {
125                total_sum += 2.0 * val;
126                valid_count += 2;
127            }
128        }
129    }
130
131    (total_sum, valid_count)
132}
133
134/// Shared implementation for kernel functional spatial depth.
135/// Uses weighted L2 norm: sum_t weights[t] * (f(t) - g(t))^2.
136fn kfsd_weighted(data_obj: &FdMatrix, data_ori: &FdMatrix, h: f64, weights: &[f64]) -> Vec<f64> {
137    let nobj = data_obj.nrows();
138    let nori = data_ori.nrows();
139    let n_points = data_obj.ncols();
140    let h_sq = h * h;
141
142    // Pre-compute M1[j,k] = K(X_j, X_k) for reference data
143    let m1_upper: Vec<(usize, usize, f64)> = iter_maybe_parallel!(0..nori)
144        .flat_map(|j| {
145            ((j + 1)..nori)
146                .map(|k| {
147                    let mut sum = 0.0;
148                    for t in 0..n_points {
149                        let diff = data_ori[(j, t)] - data_ori[(k, t)];
150                        sum += weights[t] * diff * diff;
151                    }
152                    (j, k, (-sum / h_sq).exp())
153                })
154                .collect::<Vec<_>>()
155        })
156        .collect();
157
158    let mut m1 = FdMatrix::zeros(nori, nori);
159    for j in 0..nori {
160        m1[(j, j)] = 1.0;
161    }
162    for (j, k, kval) in m1_upper {
163        m1[(j, k)] = kval;
164        m1[(k, j)] = kval;
165    }
166
167    let nori_f64 = nori as f64;
168
169    iter_maybe_parallel!(0..nobj)
170        .map(|i| {
171            let m2: Vec<f64> = (0..nori)
172                .map(|j| {
173                    let mut sum = 0.0;
174                    for t in 0..n_points {
175                        let diff = data_obj[(i, t)] - data_ori[(j, t)];
176                        sum += weights[t] * diff * diff;
177                    }
178                    (-sum / h_sq).exp()
179                })
180                .collect();
181
182            let (total_sum, valid_count) = kfsd_accumulate(&m2, &m1, nori);
183
184            if valid_count > 0 && total_sum >= 0.0 {
185                1.0 - total_sum.sqrt() / nori_f64
186            } else if total_sum < 0.0 {
187                1.0
188            } else {
189                0.0
190            }
191        })
192        .collect()
193}
194
195/// Compute Kernel Functional Spatial Depth (KFSD) for 1D functional data.
196///
197/// Implements the RKHS-based formulation.
198#[must_use = "expensive computation whose result should not be discarded"]
199pub fn kernel_functional_spatial_1d(
200    data_obj: &FdMatrix,
201    data_ori: &FdMatrix,
202    argvals: &[f64],
203    h: f64,
204) -> Vec<f64> {
205    let nobj = data_obj.nrows();
206    let nori = data_ori.nrows();
207    let n_points = data_obj.ncols();
208
209    if nobj == 0 || nori == 0 || n_points == 0 {
210        return Vec::new();
211    }
212
213    let weights = simpsons_weights(argvals);
214    kfsd_weighted(data_obj, data_ori, h, &weights)
215}
216
217/// Compute Kernel Functional Spatial Depth (KFSD) for 2D functional data.
218#[must_use = "expensive computation whose result should not be discarded"]
219pub fn kernel_functional_spatial_2d(data_obj: &FdMatrix, data_ori: &FdMatrix, h: f64) -> Vec<f64> {
220    let nobj = data_obj.nrows();
221    let nori = data_ori.nrows();
222    let n_points = data_obj.ncols();
223
224    if nobj == 0 || nori == 0 || n_points == 0 {
225        return Vec::new();
226    }
227
228    let weights = vec![1.0; n_points];
229    kfsd_weighted(data_obj, data_ori, h, &weights)
230}