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, PartialEq)]
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// ─── Amplitude Changepoint ──────────────────────────────────────────────────
58
59/// Detect a changepoint in the amplitude of functional data.
60///
61/// 1. Karcher mean alignment
62/// 2. CUSUM statistic on aligned curves
63/// 3. k* = argmax(S_n), T_n = max(S_n)
64/// 4. Monte Carlo p-value via permutation testing
65///
66/// # Arguments
67/// * `data` — Functional data (n × m), ordered by time/index
68/// * `argvals` — Evaluation points (length m)
69/// * `lambda` — Alignment penalty
70/// * `max_iter` — Karcher mean iterations
71/// * `n_mc` — Number of Monte Carlo simulations for p-value
72/// * `seed` — Random seed for reproducibility
73#[must_use = "expensive computation whose result should not be discarded"]
74pub fn elastic_amp_changepoint(
75    data: &FdMatrix,
76    argvals: &[f64],
77    lambda: f64,
78    max_iter: usize,
79    n_mc: usize,
80    seed: u64,
81) -> Result<ChangepointResult, crate::FdarError> {
82    let (n, m) = data.shape();
83    if n < 4 || m < 2 || argvals.len() != m {
84        return Err(crate::FdarError::InvalidDimension {
85            parameter: "data",
86            expected: "n >= 4, m >= 2, argvals.len() == m".to_string(),
87            actual: format!("n={}, m={}, argvals.len()={}", n, m, argvals.len()),
88        });
89    }
90
91    // Alignment
92    let km = karcher_mean(data, argvals, max_iter, 1e-4, lambda);
93
94    // CUSUM on aligned curves
95    let weights = simpsons_weights(argvals);
96    let cusum_values = functional_cusum(&km.aligned_data, &weights);
97
98    let (changepoint, test_statistic) = find_max_cusum(&cusum_values);
99
100    // Monte Carlo p-value via permutation
101    let p_value = mc_pvalue_permutation(test_statistic, &km.aligned_data, &weights, n_mc, seed);
102
103    Ok(ChangepointResult {
104        changepoint,
105        test_statistic,
106        p_value,
107        cusum_values,
108    })
109}
110
111// ─── Phase Changepoint ──────────────────────────────────────────────────────
112
113/// Detect a changepoint in the phase (warping) of functional data.
114///
115/// Same as amplitude changepoint but on shooting vectors from the warping functions.
116#[must_use = "expensive computation whose result should not be discarded"]
117pub fn elastic_ph_changepoint(
118    data: &FdMatrix,
119    argvals: &[f64],
120    lambda: f64,
121    max_iter: usize,
122    n_mc: usize,
123    seed: u64,
124) -> Result<ChangepointResult, crate::FdarError> {
125    let (n, m) = data.shape();
126    if n < 4 || m < 2 || argvals.len() != m {
127        return Err(crate::FdarError::InvalidDimension {
128            parameter: "data",
129            expected: "n >= 4, m >= 2, argvals.len() == m".to_string(),
130            actual: format!("n={}, m={}, argvals.len()={}", n, m, argvals.len()),
131        });
132    }
133
134    let km = karcher_mean(data, argvals, max_iter, 1e-4, lambda);
135
136    // Compute shooting vectors from warps (live on uniform [0,1] grid, not argvals)
137    let shooting = compute_shooting_vectors(&km, argvals).ok_or_else(|| {
138        crate::FdarError::ComputationFailed {
139            operation: "compute_shooting_vectors",
140            detail: "failed to compute shooting vectors from warps".to_string(),
141        }
142    })?;
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    Ok(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
175#[must_use = "expensive computation whose result should not be discarded"]
176pub fn elastic_fpca_changepoint(
177    data: &FdMatrix,
178    argvals: &[f64],
179    pca_method: FpcaChangepointMethod,
180    ncomp: usize,
181    lambda: f64,
182    max_iter: usize,
183    n_mc: usize,
184    seed: u64,
185) -> Result<ChangepointResult, crate::FdarError> {
186    let (n, m) = data.shape();
187    if n < 4 || m < 2 || argvals.len() != m || ncomp < 1 {
188        return Err(crate::FdarError::InvalidDimension {
189            parameter: "data",
190            expected: "n >= 4, m >= 2, argvals.len() == m, ncomp >= 1".to_string(),
191            actual: format!(
192                "n={}, m={}, argvals.len()={}, ncomp={}",
193                n,
194                m,
195                argvals.len(),
196                ncomp
197            ),
198        });
199    }
200
201    let km = karcher_mean(data, argvals, max_iter, 1e-4, lambda);
202
203    // Get PC scores
204    let scores = match pca_method {
205        FpcaChangepointMethod::Vertical => {
206            let fpca = vert_fpca(&km, argvals, ncomp)?;
207            fpca.scores
208        }
209        FpcaChangepointMethod::Horizontal => {
210            let fpca = horiz_fpca(&km, argvals, ncomp)?;
211            fpca.scores
212        }
213        FpcaChangepointMethod::Joint => {
214            let fpca = joint_fpca(&km, argvals, ncomp, None)?;
215            fpca.scores
216        }
217    };
218
219    // Hotelling CUSUM on scores
220    let cusum_values = hotelling_cusum(&scores);
221    let (changepoint, test_statistic) = find_max_cusum(&cusum_values);
222
223    // Monte Carlo p-value via permutation
224    let p_value = mc_pvalue_permutation_hotelling(test_statistic, &scores, n_mc, seed);
225
226    Ok(ChangepointResult {
227        changepoint,
228        test_statistic,
229        p_value,
230        cusum_values,
231    })
232}
233
234// ─── Internal Helpers ───────────────────────────────────────────────────────
235
236/// Compute shooting vectors from warping functions.
237fn compute_shooting_vectors(km: &KarcherMeanResult, argvals: &[f64]) -> Option<FdMatrix> {
238    let (n, m) = km.gammas.shape();
239    if n < 2 || m < 2 {
240        return None;
241    }
242    let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
243    let psis = warps_to_normalized_psi(&km.gammas, argvals);
244    let mu_psi = sphere_karcher_mean(&psis, &time, 30);
245    Some(shooting_vectors_from_psis(&psis, &mu_psi, &time))
246}
247
248/// Functional CUSUM: S_n(k) = (1/n²) Σ_j w_j * (cumsum_j - (k/n)*total_j)²
249///
250/// Uses Simpson's quadrature weights for spatial integration.
251fn functional_cusum(data: &FdMatrix, weights: &[f64]) -> Vec<f64> {
252    let (n, m) = data.shape();
253    let mut cusum_values = Vec::with_capacity(n - 1);
254
255    // Running sum
256    let mut cumsum = vec![0.0; m];
257    let mut total = vec![0.0; m];
258    for i in 0..n {
259        for j in 0..m {
260            total[j] += data[(i, j)];
261        }
262    }
263
264    for k in 1..n {
265        for j in 0..m {
266            cumsum[j] += data[(k - 1, j)];
267        }
268
269        // S_n(k) = (1/n²) Σ_j w_j * (cumsum_j - (k/n)*total_j)²
270        let ratio = k as f64 / n as f64;
271        let mut s = 0.0;
272        for j in 0..m {
273            let diff = cumsum[j] - ratio * total[j];
274            s += weights[j] * diff * diff;
275        }
276        s /= (n * n) as f64;
277        cusum_values.push(s);
278    }
279
280    cusum_values
281}
282
283/// Compute sample covariance matrix with regularization, returning its inverse.
284fn regularized_cov_inverse(scores: &FdMatrix, mean: &[f64], n: usize, p: usize) -> DMatrix<f64> {
285    let mut cov = DMatrix::zeros(p, p);
286    for i in 0..n {
287        for j1 in 0..p {
288            for j2 in 0..p {
289                cov[(j1, j2)] += (scores[(i, j1)] - mean[j1]) * (scores[(i, j2)] - mean[j2]);
290            }
291        }
292    }
293    cov /= (n - 1) as f64;
294    for j in 0..p {
295        cov[(j, j)] += 1e-6;
296    }
297    if let Some(chol) = cov.cholesky() {
298        chol.inverse()
299    } else {
300        DMatrix::identity(p, p)
301    }
302}
303
304/// Hotelling CUSUM on multivariate scores: S_n(k) = (1/n) Δ_k' Σ^{-1} Δ_k
305fn hotelling_cusum(scores: &FdMatrix) -> Vec<f64> {
306    let (n, p) = scores.shape();
307
308    let mut mean = vec![0.0; p];
309    for k in 0..p {
310        for i in 0..n {
311            mean[k] += scores[(i, k)];
312        }
313        mean[k] /= n as f64;
314    }
315
316    let cov_inv = regularized_cov_inverse(scores, &mean, n, p);
317
318    let mut cumsum = vec![0.0; p];
319    let mut total = vec![0.0; p];
320    for i in 0..n {
321        for k in 0..p {
322            total[k] += scores[(i, k)];
323        }
324    }
325
326    let mut cusum_values = Vec::with_capacity(n - 1);
327    for k in 1..n {
328        for j in 0..p {
329            cumsum[j] += scores[(k - 1, j)];
330        }
331        let ratio = k as f64 / n as f64;
332        let mut delta = nalgebra::DVector::zeros(p);
333        for j in 0..p {
334            delta[j] = (cumsum[j] - ratio * total[j]) / n as f64;
335        }
336        let hotelling = (&delta.transpose() * &cov_inv * &delta)[(0, 0)];
337        cusum_values.push(hotelling);
338    }
339
340    cusum_values
341}
342
343/// Find index and value of maximum CUSUM.
344fn find_max_cusum(cusum_values: &[f64]) -> (usize, f64) {
345    let mut max_val = f64::NEG_INFINITY;
346    let mut max_idx = 0;
347    for (i, &v) in cusum_values.iter().enumerate() {
348        if v > max_val {
349            max_val = v;
350            max_idx = i + 1; // 1-indexed changepoint
351        }
352    }
353    (max_idx, max_val)
354}
355
356/// Monte Carlo p-value via permutation testing for functional CUSUM.
357///
358/// Shuffles the row order of `data`, recomputes `functional_cusum`, and compares
359/// max(cusum) to the observed test statistic. Scale-invariant by construction.
360fn mc_pvalue_permutation(
361    test_stat: f64,
362    data: &FdMatrix,
363    weights: &[f64],
364    n_mc: usize,
365    seed: u64,
366) -> f64 {
367    let (n, m) = data.shape();
368    let mut rng = StdRng::seed_from_u64(seed);
369    let mut indices: Vec<usize> = (0..n).collect();
370    let mut count_exceed = 0usize;
371
372    let mut shuffled = FdMatrix::zeros(n, m);
373    for _ in 0..n_mc {
374        indices.shuffle(&mut rng);
375        for (new_i, &orig_i) in indices.iter().enumerate() {
376            for j in 0..m {
377                shuffled[(new_i, j)] = data[(orig_i, j)];
378            }
379        }
380        let cusum = functional_cusum(&shuffled, weights);
381        let (_, max_val) = find_max_cusum(&cusum);
382        if max_val >= test_stat {
383            count_exceed += 1;
384        }
385    }
386
387    (count_exceed as f64 + 1.0) / (n_mc as f64 + 1.0)
388}
389
390/// Monte Carlo p-value via permutation testing for Hotelling CUSUM.
391///
392/// Shuffles row order of scores, recomputes `hotelling_cusum`, and compares.
393fn mc_pvalue_permutation_hotelling(
394    test_stat: f64,
395    scores: &FdMatrix,
396    n_mc: usize,
397    seed: u64,
398) -> f64 {
399    let (n, p) = scores.shape();
400    let mut rng = StdRng::seed_from_u64(seed);
401    let mut indices: Vec<usize> = (0..n).collect();
402    let mut count_exceed = 0usize;
403
404    let mut shuffled = FdMatrix::zeros(n, p);
405    for _ in 0..n_mc {
406        indices.shuffle(&mut rng);
407        for (new_i, &orig_i) in indices.iter().enumerate() {
408            for j in 0..p {
409                shuffled[(new_i, j)] = scores[(orig_i, j)];
410            }
411        }
412        let cusum = hotelling_cusum(&shuffled);
413        let (_, max_val) = find_max_cusum(&cusum);
414        if max_val >= test_stat {
415            count_exceed += 1;
416        }
417    }
418
419    (count_exceed as f64 + 1.0) / (n_mc as f64 + 1.0)
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use std::f64::consts::PI;
426
427    fn generate_changepoint_data(n: usize, m: usize, cp: usize) -> (FdMatrix, Vec<f64>) {
428        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
429        let mut data = FdMatrix::zeros(n, m);
430
431        for i in 0..n {
432            let amp = if i < cp { 1.0 } else { 2.0 };
433            for j in 0..m {
434                data[(i, j)] = amp * (2.0 * PI * t[j]).sin();
435            }
436        }
437        (data, t)
438    }
439
440    #[test]
441    fn test_amp_changepoint_detects_shift() {
442        let n = 30;
443        let m = 51;
444        let cp = 15;
445        let (data, t) = generate_changepoint_data(n, m, cp);
446
447        let result = elastic_amp_changepoint(&data, &t, 0.0, 5, 199, 42);
448        assert!(result.is_ok(), "amp changepoint should succeed");
449
450        let res = result.unwrap();
451        assert!(
452            res.cusum_values.len() == n - 1,
453            "Should have n-1 CUSUM values"
454        );
455        assert!(
456            (res.changepoint as i64 - cp as i64).abs() <= 5,
457            "Detected changepoint {} should be near true cp {}",
458            res.changepoint,
459            cp
460        );
461        assert!(
462            res.p_value < 0.05,
463            "Clear amplitude shift should be significant, got p={}",
464            res.p_value
465        );
466    }
467
468    #[test]
469    fn test_ph_changepoint_basic() {
470        let n = 30;
471        let m = 51;
472        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
473        let mut data = FdMatrix::zeros(n, m);
474
475        for i in 0..n {
476            let shift = if i < 15 { 0.0 } else { 0.15 };
477            for j in 0..m {
478                data[(i, j)] = (2.0 * PI * (t[j] + shift)).sin();
479            }
480        }
481
482        let result = elastic_ph_changepoint(&data, &t, 0.0, 5, 100, 42);
483        assert!(result.is_ok());
484    }
485
486    #[test]
487    fn test_fpca_changepoint_basic() {
488        let n = 30;
489        let m = 51;
490        let cp = 15;
491        let (data, t) = generate_changepoint_data(n, m, cp);
492
493        let result = elastic_fpca_changepoint(
494            &data,
495            &t,
496            FpcaChangepointMethod::Vertical,
497            3,
498            0.0,
499            5,
500            100,
501            42,
502        );
503        assert!(result.is_ok(), "fpca changepoint should succeed");
504    }
505
506    #[test]
507    fn test_changepoint_no_change() {
508        // All curves identical → weak test statistic, high p-value
509        let n = 20;
510        let m = 51;
511        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
512        let mut data = FdMatrix::zeros(n, m);
513        for i in 0..n {
514            for j in 0..m {
515                data[(i, j)] = (2.0 * PI * t[j]).sin();
516            }
517        }
518
519        let result = elastic_amp_changepoint(&data, &t, 0.0, 5, 200, 42);
520        if let Ok(res) = result {
521            assert!(
522                res.p_value > 0.1,
523                "No change should not be significant, got p={}",
524                res.p_value
525            );
526        }
527    }
528
529    #[test]
530    fn test_pvalue_permutation_discriminates() {
531        let m = 51;
532        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
533
534        // Strong signal: amplitude doubles at midpoint
535        let (data_signal, _) = generate_changepoint_data(30, m, 15);
536        let res_signal =
537            elastic_amp_changepoint(&data_signal, &t, 0.0, 5, 199, 99).expect("should succeed");
538        assert!(
539            res_signal.p_value < 0.05,
540            "Strong signal should give small p, got {}",
541            res_signal.p_value
542        );
543
544        // No signal: all curves identical
545        let mut data_null = FdMatrix::zeros(30, m);
546        for i in 0..30 {
547            for j in 0..m {
548                data_null[(i, j)] = (2.0 * PI * t[j]).sin();
549            }
550        }
551        let res_null =
552            elastic_amp_changepoint(&data_null, &t, 0.0, 5, 199, 99).expect("should succeed");
553        assert!(
554            res_null.p_value > 0.1,
555            "No signal should give large p, got {}",
556            res_null.p_value
557        );
558    }
559
560    #[test]
561    fn test_invalid_input() {
562        let data = FdMatrix::zeros(2, 5);
563        let t: Vec<f64> = (0..5).map(|i| i as f64 / 4.0).collect();
564        // n=2 < 4 → should return None
565        assert!(elastic_amp_changepoint(&data, &t, 0.0, 5, 100, 42).is_err());
566    }
567
568    #[test]
569    fn test_fpca_changepoint_horizontal() {
570        let n = 30;
571        let m = 51;
572        let cp = 15;
573        let (data, t) = generate_changepoint_data(n, m, cp);
574
575        let result = elastic_fpca_changepoint(
576            &data,
577            &t,
578            FpcaChangepointMethod::Horizontal,
579            3,
580            0.0,
581            5,
582            100,
583            42,
584        );
585        assert!(
586            result.is_ok(),
587            "fpca changepoint (horizontal) should succeed"
588        );
589
590        let res = result.unwrap();
591        assert_eq!(res.cusum_values.len(), n - 1);
592    }
593
594    #[test]
595    fn test_fpca_changepoint_joint() {
596        let n = 30;
597        let m = 51;
598        let cp = 15;
599        let (data, t) = generate_changepoint_data(n, m, cp);
600
601        let result =
602            elastic_fpca_changepoint(&data, &t, FpcaChangepointMethod::Joint, 3, 0.0, 5, 100, 42);
603        assert!(result.is_ok(), "fpca changepoint (joint) should succeed");
604
605        let res = result.unwrap();
606        assert_eq!(res.cusum_values.len(), n - 1);
607    }
608
609    #[test]
610    fn test_changepoint_seed_determinism() {
611        let n = 30;
612        let m = 51;
613        let cp = 15;
614        let (data, t) = generate_changepoint_data(n, m, cp);
615
616        let res1 = elastic_amp_changepoint(&data, &t, 0.0, 5, 199, 42).expect("should succeed");
617        let res2 = elastic_amp_changepoint(&data, &t, 0.0, 5, 199, 42).expect("should succeed");
618
619        assert_eq!(res1.changepoint, res2.changepoint);
620        assert!((res1.p_value - res2.p_value).abs() < 1e-10);
621    }
622}