Skip to main content

fdars_core/
elastic_changepoint.rs

1//! Elastic changepoint detection for functional data streams.
2//!
3//! Implements the methods from Tucker & Yarger (2023) for detecting changepoints
4//! in the amplitude, phase, or joint structure of functional data.
5//!
6//! Key capabilities:
7//! - [`elastic_amp_changepoint`] — Amplitude changepoint via CUSUM on aligned curves
8//! - [`elastic_ph_changepoint`] — Phase changepoint via CUSUM on shooting vectors
9//! - [`elastic_fpca_changepoint`] — FPCA-based changepoint via Hotelling CUSUM
10
11use crate::alignment::{karcher_mean, KarcherMeanResult};
12use crate::elastic_fpca::{
13    horiz_fpca, joint_fpca, shooting_vectors_from_psis, sphere_karcher_mean, vert_fpca,
14    warps_to_normalized_psi,
15};
16use crate::helpers::simpsons_weights;
17use crate::matrix::FdMatrix;
18use nalgebra::DMatrix;
19use rand::rngs::StdRng;
20use rand::seq::SliceRandom;
21use rand::SeedableRng;
22
23// ─── Types ──────────────────────────────────────────────────────────────────
24
25/// Result of changepoint detection.
26#[derive(Debug, Clone)]
27pub struct ChangepointResult {
28    /// Estimated changepoint location (index in 1..n-1).
29    pub changepoint: usize,
30    /// Test statistic value.
31    pub test_statistic: f64,
32    /// Monte Carlo p-value.
33    pub p_value: f64,
34    /// CUSUM values for k=1..n-1.
35    pub cusum_values: Vec<f64>,
36}
37
38/// Type of changepoint to detect.
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub enum ChangepointType {
41    /// Amplitude changepoint (on aligned curves).
42    Amplitude,
43    /// Phase changepoint (on warping functions).
44    Phase,
45    /// FPCA-based changepoint.
46    Fpca(FpcaChangepointMethod),
47}
48
49/// FPCA method for changepoint detection.
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub enum FpcaChangepointMethod {
52    Vertical,
53    Horizontal,
54    Joint,
55}
56
57/// Kernel for long-run covariance estimation.
58#[derive(Debug, Clone, Copy, PartialEq)]
59pub enum CovKernel {
60    Bartlett,
61    Parzen,
62    FlatTop,
63    Simple,
64}
65
66// ─── Amplitude Changepoint ──────────────────────────────────────────────────
67
68/// Detect a changepoint in the amplitude of functional data.
69///
70/// 1. Karcher mean alignment
71/// 2. CUSUM statistic on aligned curves
72/// 3. k* = argmax(S_n), T_n = max(S_n)
73/// 4. Monte Carlo p-value via permutation testing
74///
75/// # Arguments
76/// * `data` — Functional data (n × m), ordered by time/index
77/// * `argvals` — Evaluation points (length m)
78/// * `lambda` — Alignment penalty
79/// * `max_iter` — Karcher mean iterations
80/// * `n_mc` — Number of Monte Carlo simulations for p-value
81/// * `_cov_kernel` — Unused (kept for backward compatibility)
82/// * `_cov_bandwidth` — Unused (kept for backward compatibility)
83/// * `seed` — Random seed for reproducibility
84pub fn elastic_amp_changepoint(
85    data: &FdMatrix,
86    argvals: &[f64],
87    lambda: f64,
88    max_iter: usize,
89    n_mc: usize,
90    _cov_kernel: CovKernel,
91    _cov_bandwidth: Option<usize>,
92    seed: u64,
93) -> Option<ChangepointResult> {
94    let (n, m) = data.shape();
95    if n < 4 || m < 2 || argvals.len() != m {
96        return None;
97    }
98
99    // Alignment
100    let km = karcher_mean(data, argvals, max_iter, 1e-4, lambda);
101
102    // CUSUM on aligned curves
103    let weights = simpsons_weights(argvals);
104    let cusum_values = functional_cusum(&km.aligned_data, &weights);
105
106    let (changepoint, test_statistic) = find_max_cusum(&cusum_values);
107
108    // Monte Carlo p-value via permutation
109    let p_value = mc_pvalue_permutation(test_statistic, &km.aligned_data, &weights, n_mc, seed);
110
111    Some(ChangepointResult {
112        changepoint,
113        test_statistic,
114        p_value,
115        cusum_values,
116    })
117}
118
119// ─── Phase Changepoint ──────────────────────────────────────────────────────
120
121/// Detect a changepoint in the phase (warping) of functional data.
122///
123/// Same as amplitude changepoint but on shooting vectors from the warping functions.
124pub fn elastic_ph_changepoint(
125    data: &FdMatrix,
126    argvals: &[f64],
127    lambda: f64,
128    max_iter: usize,
129    n_mc: usize,
130    _cov_kernel: CovKernel,
131    _cov_bandwidth: Option<usize>,
132    seed: u64,
133) -> Option<ChangepointResult> {
134    let (n, m) = data.shape();
135    if n < 4 || m < 2 || argvals.len() != m {
136        return None;
137    }
138
139    let km = karcher_mean(data, argvals, max_iter, 1e-4, lambda);
140
141    // Compute shooting vectors from warps (live on uniform [0,1] grid, not argvals)
142    let shooting = compute_shooting_vectors(&km, argvals)?;
143
144    let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
145    let weights = simpsons_weights(&time);
146    let cusum_values = functional_cusum(&shooting, &weights);
147    let (changepoint, test_statistic) = find_max_cusum(&cusum_values);
148
149    // Monte Carlo p-value via permutation
150    let p_value = mc_pvalue_permutation(test_statistic, &shooting, &weights, n_mc, seed);
151
152    Some(ChangepointResult {
153        changepoint,
154        test_statistic,
155        p_value,
156        cusum_values,
157    })
158}
159
160// ─── FPCA Changepoint ───────────────────────────────────────────────────────
161
162/// Detect a changepoint using FPCA scores with Hotelling CUSUM.
163///
164/// Uses PC scores from vert/horiz/joint FPCA for a Hotelling-type test.
165///
166/// # Arguments
167/// * `data` — Functional data (n × m)
168/// * `argvals` — Evaluation points (length m)
169/// * `pca_method` — Which FPCA variant to use
170/// * `ncomp` — Number of principal components
171/// * `lambda` — Alignment penalty
172/// * `max_iter` — Karcher mean iterations
173/// * `n_mc` — Monte Carlo simulations
174/// * `seed` — Random seed
175pub fn elastic_fpca_changepoint(
176    data: &FdMatrix,
177    argvals: &[f64],
178    pca_method: FpcaChangepointMethod,
179    ncomp: usize,
180    lambda: f64,
181    max_iter: usize,
182    n_mc: usize,
183    seed: u64,
184) -> Option<ChangepointResult> {
185    let (n, m) = data.shape();
186    if n < 4 || m < 2 || argvals.len() != m || ncomp < 1 {
187        return None;
188    }
189
190    let km = karcher_mean(data, argvals, max_iter, 1e-4, lambda);
191
192    // Get PC scores
193    let scores = match pca_method {
194        FpcaChangepointMethod::Vertical => {
195            let fpca = vert_fpca(&km, argvals, ncomp)?;
196            fpca.scores
197        }
198        FpcaChangepointMethod::Horizontal => {
199            let fpca = horiz_fpca(&km, argvals, ncomp)?;
200            fpca.scores
201        }
202        FpcaChangepointMethod::Joint => {
203            let fpca = joint_fpca(&km, argvals, ncomp, None)?;
204            fpca.scores
205        }
206    };
207
208    // Hotelling CUSUM on scores
209    let cusum_values = hotelling_cusum(&scores);
210    let (changepoint, test_statistic) = find_max_cusum(&cusum_values);
211
212    // Monte Carlo p-value via permutation
213    let p_value = mc_pvalue_permutation_hotelling(test_statistic, &scores, n_mc, seed);
214
215    Some(ChangepointResult {
216        changepoint,
217        test_statistic,
218        p_value,
219        cusum_values,
220    })
221}
222
223// ─── Internal Helpers ───────────────────────────────────────────────────────
224
225/// Compute shooting vectors from warping functions.
226fn compute_shooting_vectors(km: &KarcherMeanResult, argvals: &[f64]) -> Option<FdMatrix> {
227    let (n, m) = km.gammas.shape();
228    if n < 2 || m < 2 {
229        return None;
230    }
231    let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
232    let psis = warps_to_normalized_psi(&km.gammas, argvals);
233    let mu_psi = sphere_karcher_mean(&psis, &time, 30);
234    Some(shooting_vectors_from_psis(&psis, &mu_psi, &time))
235}
236
237/// Functional CUSUM: S_n(k) = (1/n²) Σ_j w_j * (cumsum_j - (k/n)*total_j)²
238///
239/// Uses Simpson's quadrature weights for spatial integration.
240fn functional_cusum(data: &FdMatrix, weights: &[f64]) -> Vec<f64> {
241    let (n, m) = data.shape();
242    let mut cusum_values = Vec::with_capacity(n - 1);
243
244    // Running sum
245    let mut cumsum = vec![0.0; m];
246    let mut total = vec![0.0; m];
247    for i in 0..n {
248        for j in 0..m {
249            total[j] += data[(i, j)];
250        }
251    }
252
253    for k in 1..n {
254        for j in 0..m {
255            cumsum[j] += data[(k - 1, j)];
256        }
257
258        // S_n(k) = (1/n²) Σ_j w_j * (cumsum_j - (k/n)*total_j)²
259        let ratio = k as f64 / n as f64;
260        let mut s = 0.0;
261        for j in 0..m {
262            let diff = cumsum[j] - ratio * total[j];
263            s += weights[j] * diff * diff;
264        }
265        s /= (n * n) as f64;
266        cusum_values.push(s);
267    }
268
269    cusum_values
270}
271
272/// Compute sample covariance matrix with regularization, returning its inverse.
273fn regularized_cov_inverse(scores: &FdMatrix, mean: &[f64], n: usize, p: usize) -> DMatrix<f64> {
274    let mut cov = DMatrix::zeros(p, p);
275    for i in 0..n {
276        for j1 in 0..p {
277            for j2 in 0..p {
278                cov[(j1, j2)] += (scores[(i, j1)] - mean[j1]) * (scores[(i, j2)] - mean[j2]);
279            }
280        }
281    }
282    cov /= (n - 1) as f64;
283    for j in 0..p {
284        cov[(j, j)] += 1e-6;
285    }
286    if let Some(chol) = cov.cholesky() {
287        chol.inverse()
288    } else {
289        DMatrix::identity(p, p)
290    }
291}
292
293/// Hotelling CUSUM on multivariate scores: S_n(k) = (1/n) Δ_k' Σ^{-1} Δ_k
294fn hotelling_cusum(scores: &FdMatrix) -> Vec<f64> {
295    let (n, p) = scores.shape();
296
297    let mut mean = vec![0.0; p];
298    for k in 0..p {
299        for i in 0..n {
300            mean[k] += scores[(i, k)];
301        }
302        mean[k] /= n as f64;
303    }
304
305    let cov_inv = regularized_cov_inverse(scores, &mean, n, p);
306
307    let mut cumsum = vec![0.0; p];
308    let mut total = vec![0.0; p];
309    for i in 0..n {
310        for k in 0..p {
311            total[k] += scores[(i, k)];
312        }
313    }
314
315    let mut cusum_values = Vec::with_capacity(n - 1);
316    for k in 1..n {
317        for j in 0..p {
318            cumsum[j] += scores[(k - 1, j)];
319        }
320        let ratio = k as f64 / n as f64;
321        let mut delta = nalgebra::DVector::zeros(p);
322        for j in 0..p {
323            delta[j] = (cumsum[j] - ratio * total[j]) / n as f64;
324        }
325        let hotelling = (&delta.transpose() * &cov_inv * &delta)[(0, 0)];
326        cusum_values.push(hotelling);
327    }
328
329    cusum_values
330}
331
332/// Find index and value of maximum CUSUM.
333fn find_max_cusum(cusum_values: &[f64]) -> (usize, f64) {
334    let mut max_val = f64::NEG_INFINITY;
335    let mut max_idx = 0;
336    for (i, &v) in cusum_values.iter().enumerate() {
337        if v > max_val {
338            max_val = v;
339            max_idx = i + 1; // 1-indexed changepoint
340        }
341    }
342    (max_idx, max_val)
343}
344
345/// Monte Carlo p-value via permutation testing for functional CUSUM.
346///
347/// Shuffles the row order of `data`, recomputes `functional_cusum`, and compares
348/// max(cusum) to the observed test statistic. Scale-invariant by construction.
349fn mc_pvalue_permutation(
350    test_stat: f64,
351    data: &FdMatrix,
352    weights: &[f64],
353    n_mc: usize,
354    seed: u64,
355) -> f64 {
356    let (n, m) = data.shape();
357    let mut rng = StdRng::seed_from_u64(seed);
358    let mut indices: Vec<usize> = (0..n).collect();
359    let mut count_exceed = 0usize;
360
361    let mut shuffled = FdMatrix::zeros(n, m);
362    for _ in 0..n_mc {
363        indices.shuffle(&mut rng);
364        for (new_i, &orig_i) in indices.iter().enumerate() {
365            for j in 0..m {
366                shuffled[(new_i, j)] = data[(orig_i, j)];
367            }
368        }
369        let cusum = functional_cusum(&shuffled, weights);
370        let (_, max_val) = find_max_cusum(&cusum);
371        if max_val >= test_stat {
372            count_exceed += 1;
373        }
374    }
375
376    (count_exceed as f64 + 1.0) / (n_mc as f64 + 1.0)
377}
378
379/// Monte Carlo p-value via permutation testing for Hotelling CUSUM.
380///
381/// Shuffles row order of scores, recomputes `hotelling_cusum`, and compares.
382fn mc_pvalue_permutation_hotelling(
383    test_stat: f64,
384    scores: &FdMatrix,
385    n_mc: usize,
386    seed: u64,
387) -> f64 {
388    let (n, p) = scores.shape();
389    let mut rng = StdRng::seed_from_u64(seed);
390    let mut indices: Vec<usize> = (0..n).collect();
391    let mut count_exceed = 0usize;
392
393    let mut shuffled = FdMatrix::zeros(n, p);
394    for _ in 0..n_mc {
395        indices.shuffle(&mut rng);
396        for (new_i, &orig_i) in indices.iter().enumerate() {
397            for j in 0..p {
398                shuffled[(new_i, j)] = scores[(orig_i, j)];
399            }
400        }
401        let cusum = hotelling_cusum(&shuffled);
402        let (_, max_val) = find_max_cusum(&cusum);
403        if max_val >= test_stat {
404            count_exceed += 1;
405        }
406    }
407
408    (count_exceed as f64 + 1.0) / (n_mc as f64 + 1.0)
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use std::f64::consts::PI;
415
416    fn generate_changepoint_data(n: usize, m: usize, cp: usize) -> (FdMatrix, Vec<f64>) {
417        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
418        let mut data = FdMatrix::zeros(n, m);
419
420        for i in 0..n {
421            let amp = if i < cp { 1.0 } else { 2.0 };
422            for j in 0..m {
423                data[(i, j)] = amp * (2.0 * PI * t[j]).sin();
424            }
425        }
426        (data, t)
427    }
428
429    #[test]
430    fn test_amp_changepoint_detects_shift() {
431        let n = 30;
432        let m = 51;
433        let cp = 15;
434        let (data, t) = generate_changepoint_data(n, m, cp);
435
436        let result = elastic_amp_changepoint(&data, &t, 0.0, 5, 199, CovKernel::Bartlett, None, 42);
437        assert!(result.is_some(), "amp changepoint should succeed");
438
439        let res = result.unwrap();
440        assert!(
441            res.cusum_values.len() == n - 1,
442            "Should have n-1 CUSUM values"
443        );
444        assert!(
445            (res.changepoint as i64 - cp as i64).abs() <= 5,
446            "Detected changepoint {} should be near true cp {}",
447            res.changepoint,
448            cp
449        );
450        assert!(
451            res.p_value < 0.05,
452            "Clear amplitude shift should be significant, got p={}",
453            res.p_value
454        );
455    }
456
457    #[test]
458    fn test_ph_changepoint_basic() {
459        let n = 30;
460        let m = 51;
461        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
462        let mut data = FdMatrix::zeros(n, m);
463
464        for i in 0..n {
465            let shift = if i < 15 { 0.0 } else { 0.15 };
466            for j in 0..m {
467                data[(i, j)] = (2.0 * PI * (t[j] + shift)).sin();
468            }
469        }
470
471        let result = elastic_ph_changepoint(&data, &t, 0.0, 5, 100, CovKernel::Bartlett, None, 42);
472        assert!(result.is_some());
473    }
474
475    #[test]
476    fn test_fpca_changepoint_basic() {
477        let n = 30;
478        let m = 51;
479        let cp = 15;
480        let (data, t) = generate_changepoint_data(n, m, cp);
481
482        let result = elastic_fpca_changepoint(
483            &data,
484            &t,
485            FpcaChangepointMethod::Vertical,
486            3,
487            0.0,
488            5,
489            100,
490            42,
491        );
492        assert!(result.is_some(), "fpca changepoint should succeed");
493    }
494
495    #[test]
496    fn test_changepoint_no_change() {
497        // All curves identical → weak test statistic, high p-value
498        let n = 20;
499        let m = 51;
500        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
501        let mut data = FdMatrix::zeros(n, m);
502        for i in 0..n {
503            for j in 0..m {
504                data[(i, j)] = (2.0 * PI * t[j]).sin();
505            }
506        }
507
508        let result = elastic_amp_changepoint(&data, &t, 0.0, 5, 200, CovKernel::Bartlett, None, 42);
509        if let Some(res) = result {
510            assert!(
511                res.p_value > 0.1,
512                "No change should not be significant, got p={}",
513                res.p_value
514            );
515        }
516    }
517
518    #[test]
519    fn test_pvalue_permutation_discriminates() {
520        let m = 51;
521        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
522
523        // Strong signal: amplitude doubles at midpoint
524        let (data_signal, _) = generate_changepoint_data(30, m, 15);
525        let res_signal =
526            elastic_amp_changepoint(&data_signal, &t, 0.0, 5, 199, CovKernel::Bartlett, None, 99)
527                .expect("should succeed");
528        assert!(
529            res_signal.p_value < 0.05,
530            "Strong signal should give small p, got {}",
531            res_signal.p_value
532        );
533
534        // No signal: all curves identical
535        let mut data_null = FdMatrix::zeros(30, m);
536        for i in 0..30 {
537            for j in 0..m {
538                data_null[(i, j)] = (2.0 * PI * t[j]).sin();
539            }
540        }
541        let res_null =
542            elastic_amp_changepoint(&data_null, &t, 0.0, 5, 199, CovKernel::Bartlett, None, 99)
543                .expect("should succeed");
544        assert!(
545            res_null.p_value > 0.1,
546            "No signal should give large p, got {}",
547            res_null.p_value
548        );
549    }
550
551    #[test]
552    fn test_invalid_input() {
553        let data = FdMatrix::zeros(2, 5);
554        let t: Vec<f64> = (0..5).map(|i| i as f64 / 4.0).collect();
555        // n=2 < 4 → should return None
556        assert!(
557            elastic_amp_changepoint(&data, &t, 0.0, 5, 100, CovKernel::Simple, None, 42).is_none()
558        );
559    }
560}