Skip to main content

fdars_core/alignment/
shape_ci.rs

1//! Bootstrap confidence intervals for curve shapes in the elastic metric.
2
3use rand::Rng;
4
5use super::karcher::karcher_mean;
6use super::pairwise::elastic_align_pair;
7use crate::error::FdarError;
8use crate::iter_maybe_parallel;
9use crate::matrix::FdMatrix;
10#[cfg(feature = "parallel")]
11use rayon::iter::ParallelIterator;
12
13// ─── Types ──────────────────────────────────────────────────────────────────
14
15/// Configuration for shape bootstrap confidence intervals.
16#[derive(Debug, Clone, PartialEq)]
17pub struct ShapeCiConfig {
18    /// Number of bootstrap resamples.
19    pub n_bootstrap: usize,
20    /// Confidence level (e.g., 0.95 for 95% CI).
21    pub confidence_level: f64,
22    /// Roughness penalty for elastic alignment.
23    pub lambda: f64,
24    /// Maximum Karcher mean iterations.
25    pub max_iter: usize,
26    /// Convergence tolerance for the Karcher mean.
27    pub tol: f64,
28    /// Random seed for reproducibility.
29    pub seed: u64,
30}
31
32impl Default for ShapeCiConfig {
33    fn default() -> Self {
34        Self {
35            n_bootstrap: 200,
36            confidence_level: 0.95,
37            lambda: 0.0,
38            max_iter: 15,
39            tol: 1e-3,
40            seed: 42,
41        }
42    }
43}
44
45/// Result of shape bootstrap confidence interval computation.
46#[derive(Debug, Clone, PartialEq)]
47#[non_exhaustive]
48pub struct ShapeCiResult {
49    /// Karcher mean of the full sample.
50    pub mean: Vec<f64>,
51    /// Pointwise lower confidence band (length m).
52    pub lower_band: Vec<f64>,
53    /// Pointwise upper confidence band (length m).
54    pub upper_band: Vec<f64>,
55    /// Bootstrap Karcher means (n_bootstrap x m).
56    pub bootstrap_means: FdMatrix,
57}
58
59// ─── Public API ─────────────────────────────────────────────────────────────
60
61/// Compute bootstrap confidence intervals for the elastic Karcher mean.
62///
63/// Resamples the input curves with replacement, computes the Karcher mean
64/// of each bootstrap sample, aligns each bootstrap mean to the full-sample
65/// mean, and derives pointwise confidence bands from the empirical quantiles.
66///
67/// # Arguments
68/// * `data`    - Functional data matrix (n x m).
69/// * `argvals` - Evaluation points (length m).
70/// * `config`  - Bootstrap configuration.
71///
72/// # Errors
73/// Returns [`FdarError::InvalidDimension`] if `n < 3` or `argvals` length
74/// does not match `m`.
75/// Returns [`FdarError::InvalidParameter`] if `confidence_level` is not in
76/// `(0, 1)` or `n_bootstrap < 1`.
77#[must_use = "expensive computation whose result should not be discarded"]
78pub fn shape_confidence_interval(
79    data: &FdMatrix,
80    argvals: &[f64],
81    config: &ShapeCiConfig,
82) -> Result<ShapeCiResult, FdarError> {
83    let (n, m) = data.shape();
84
85    // ── Validation ──
86    if argvals.len() != m {
87        return Err(FdarError::InvalidDimension {
88            parameter: "argvals",
89            expected: format!("{m}"),
90            actual: format!("{}", argvals.len()),
91        });
92    }
93    if n < 3 {
94        return Err(FdarError::InvalidDimension {
95            parameter: "data",
96            expected: "at least 3 rows".to_string(),
97            actual: format!("{n} rows"),
98        });
99    }
100    if config.confidence_level <= 0.0 || config.confidence_level >= 1.0 {
101        return Err(FdarError::InvalidParameter {
102            parameter: "confidence_level",
103            message: format!("must be in (0, 1), got {}", config.confidence_level),
104        });
105    }
106    if config.n_bootstrap < 1 {
107        return Err(FdarError::InvalidParameter {
108            parameter: "n_bootstrap",
109            message: format!("must be >= 1, got {}", config.n_bootstrap),
110        });
111    }
112
113    // ── Full-sample Karcher mean ──
114    let full_karcher = karcher_mean(data, argvals, config.max_iter, config.tol, config.lambda);
115
116    // ── Bootstrap loop ──
117    let boot_means: Vec<Vec<f64>> = iter_maybe_parallel!(0..config.n_bootstrap)
118        .map(|b| {
119            let mut rng = crate::helpers::seed_for_thread(config.seed, b);
120
121            // Resample n indices with replacement
122            let indices: Vec<usize> = (0..n).map(|_| rng.gen_range(0..n)).collect();
123
124            // Build bootstrap matrix
125            let mut boot_data = FdMatrix::zeros(n, m);
126            for (row, &idx) in indices.iter().enumerate() {
127                for j in 0..m {
128                    boot_data[(row, j)] = data[(idx, j)];
129                }
130            }
131
132            // Compute bootstrap Karcher mean
133            let boot_karcher = karcher_mean(
134                &boot_data,
135                argvals,
136                config.max_iter,
137                config.tol,
138                config.lambda,
139            );
140
141            // Align bootstrap mean to full-sample mean
142            let aligned = elastic_align_pair(
143                &full_karcher.mean,
144                &boot_karcher.mean,
145                argvals,
146                config.lambda,
147            );
148
149            aligned.f_aligned
150        })
151        .collect();
152
153    // ── Build bootstrap_means matrix ──
154    let mut bootstrap_means = FdMatrix::zeros(config.n_bootstrap, m);
155    for (b, bm) in boot_means.iter().enumerate() {
156        for j in 0..m {
157            bootstrap_means[(b, j)] = bm[j];
158        }
159    }
160
161    // ── Pointwise confidence bands ──
162    let alpha = 1.0 - config.confidence_level;
163    let mut lower_band = vec![0.0; m];
164    let mut upper_band = vec![0.0; m];
165
166    for j in 0..m {
167        let mut col_vals: Vec<f64> = (0..config.n_bootstrap)
168            .map(|b| bootstrap_means[(b, j)])
169            .collect();
170        col_vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
171
172        lower_band[j] = quantile_sorted(&col_vals, alpha / 2.0);
173        upper_band[j] = quantile_sorted(&col_vals, 1.0 - alpha / 2.0);
174    }
175
176    Ok(ShapeCiResult {
177        mean: full_karcher.mean,
178        lower_band,
179        upper_band,
180        bootstrap_means,
181    })
182}
183
184use crate::helpers::quantile_sorted;
185
186// ─── Tests ──────────────────────────────────────────────────────────────────
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::simulation::{sim_fundata, EFunType, EValType};
192    use crate::test_helpers::uniform_grid;
193
194    fn make_data(n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
195        let t = uniform_grid(m);
196        let data = sim_fundata(n, &t, 3, EFunType::Fourier, EValType::Exponential, Some(99));
197        (data, t)
198    }
199
200    #[test]
201    fn shape_ci_band_contains_mean() {
202        let (data, t) = make_data(8, 20);
203        let config = ShapeCiConfig {
204            n_bootstrap: 30,
205            confidence_level: 0.95,
206            max_iter: 5,
207            tol: 1e-2,
208            ..Default::default()
209        };
210        let result = shape_confidence_interval(&data, &t, &config).unwrap();
211        let m = t.len();
212        for j in 0..m {
213            assert!(
214                result.lower_band[j] <= result.mean[j] + 1e-6
215                    && result.mean[j] <= result.upper_band[j] + 1e-6,
216                "mean[{j}]={} not in [{}, {}]",
217                result.mean[j],
218                result.lower_band[j],
219                result.upper_band[j],
220            );
221        }
222    }
223
224    #[test]
225    fn shape_ci_band_width_positive() {
226        let (data, t) = make_data(8, 20);
227        let config = ShapeCiConfig {
228            n_bootstrap: 30,
229            confidence_level: 0.95,
230            max_iter: 5,
231            tol: 1e-2,
232            ..Default::default()
233        };
234        let result = shape_confidence_interval(&data, &t, &config).unwrap();
235        let m = t.len();
236        let n_positive = (0..m)
237            .filter(|&j| result.upper_band[j] > result.lower_band[j] + 1e-12)
238            .count();
239        assert!(
240            n_positive > m / 2,
241            "upper > lower for only {n_positive}/{m} points, expected > {}/{}",
242            m / 2,
243            m
244        );
245    }
246
247    #[test]
248    fn shape_ci_bootstrap_means_shape() {
249        let (data, t) = make_data(6, 20);
250        let n_boot = 15;
251        let config = ShapeCiConfig {
252            n_bootstrap: n_boot,
253            confidence_level: 0.90,
254            max_iter: 3,
255            tol: 1e-2,
256            ..Default::default()
257        };
258        let result = shape_confidence_interval(&data, &t, &config).unwrap();
259        assert_eq!(result.bootstrap_means.shape(), (n_boot, t.len()));
260    }
261
262    #[test]
263    fn shape_ci_rejects_too_few_curves() {
264        let t = uniform_grid(20);
265        let data = FdMatrix::zeros(2, 20);
266        let config = ShapeCiConfig::default();
267        assert!(shape_confidence_interval(&data, &t, &config).is_err());
268    }
269}