1use 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#[derive(Debug, Clone, PartialEq)]
27#[non_exhaustive]
28pub struct ChangepointResult {
29 pub changepoint: usize,
31 pub test_statistic: f64,
33 pub p_value: f64,
35 pub cusum_values: Vec<f64>,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq)]
41#[non_exhaustive]
42pub enum ChangepointType {
43 Amplitude,
45 Phase,
47 Fpca(FpcaChangepointMethod),
49}
50
51#[derive(Debug, Clone, Copy, PartialEq)]
53#[non_exhaustive]
54pub enum FpcaChangepointMethod {
55 Vertical,
56 Horizontal,
57 Joint,
58}
59
60#[must_use = "expensive computation whose result should not be discarded"]
77pub fn elastic_amp_changepoint(
78 data: &FdMatrix,
79 argvals: &[f64],
80 lambda: f64,
81 max_iter: usize,
82 n_mc: usize,
83 seed: u64,
84) -> Result<ChangepointResult, crate::FdarError> {
85 let (n, m) = data.shape();
86 if n < 4 || m < 2 || argvals.len() != m {
87 return Err(crate::FdarError::InvalidDimension {
88 parameter: "data",
89 expected: "n >= 4, m >= 2, argvals.len() == m".to_string(),
90 actual: format!("n={}, m={}, argvals.len()={}", n, m, argvals.len()),
91 });
92 }
93
94 let km = karcher_mean(data, argvals, max_iter, 1e-4, lambda);
96
97 let weights = simpsons_weights(argvals);
99 let cusum_values = functional_cusum(&km.aligned_data, &weights);
100
101 let (changepoint, test_statistic) = find_max_cusum(&cusum_values);
102
103 let p_value = mc_pvalue_permutation(test_statistic, &km.aligned_data, &weights, n_mc, seed);
105
106 Ok(ChangepointResult {
107 changepoint,
108 test_statistic,
109 p_value,
110 cusum_values,
111 })
112}
113
114#[must_use = "expensive computation whose result should not be discarded"]
120pub fn elastic_ph_changepoint(
121 data: &FdMatrix,
122 argvals: &[f64],
123 lambda: f64,
124 max_iter: usize,
125 n_mc: usize,
126 seed: u64,
127) -> Result<ChangepointResult, crate::FdarError> {
128 let (n, m) = data.shape();
129 if n < 4 || m < 2 || argvals.len() != m {
130 return Err(crate::FdarError::InvalidDimension {
131 parameter: "data",
132 expected: "n >= 4, m >= 2, argvals.len() == m".to_string(),
133 actual: format!("n={}, m={}, argvals.len()={}", n, m, argvals.len()),
134 });
135 }
136
137 let km = karcher_mean(data, argvals, max_iter, 1e-4, lambda);
138
139 let shooting = compute_shooting_vectors(&km, argvals).ok_or_else(|| {
141 crate::FdarError::ComputationFailed {
142 operation: "compute_shooting_vectors",
143 detail: "failed to compute shooting vectors from warps; alignment may have produced degenerate warping functions".to_string(),
144 }
145 })?;
146
147 let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
148 let weights = simpsons_weights(&time);
149 let cusum_values = functional_cusum(&shooting, &weights);
150 let (changepoint, test_statistic) = find_max_cusum(&cusum_values);
151
152 let p_value = mc_pvalue_permutation(test_statistic, &shooting, &weights, n_mc, seed);
154
155 Ok(ChangepointResult {
156 changepoint,
157 test_statistic,
158 p_value,
159 cusum_values,
160 })
161}
162
163#[must_use = "expensive computation whose result should not be discarded"]
179pub fn elastic_fpca_changepoint(
180 data: &FdMatrix,
181 argvals: &[f64],
182 pca_method: FpcaChangepointMethod,
183 ncomp: usize,
184 lambda: f64,
185 max_iter: usize,
186 n_mc: usize,
187 seed: u64,
188) -> Result<ChangepointResult, crate::FdarError> {
189 let (n, m) = data.shape();
190 if n < 4 || m < 2 || argvals.len() != m || ncomp < 1 {
191 return Err(crate::FdarError::InvalidDimension {
192 parameter: "data",
193 expected: "n >= 4, m >= 2, argvals.len() == m, ncomp >= 1".to_string(),
194 actual: format!(
195 "n={}, m={}, argvals.len()={}, ncomp={}",
196 n,
197 m,
198 argvals.len(),
199 ncomp
200 ),
201 });
202 }
203
204 let km = karcher_mean(data, argvals, max_iter, 1e-4, lambda);
205
206 let scores = match pca_method {
208 FpcaChangepointMethod::Vertical => {
209 let fpca = vert_fpca(&km, argvals, ncomp)?;
210 fpca.scores
211 }
212 FpcaChangepointMethod::Horizontal => {
213 let fpca = horiz_fpca(&km, argvals, ncomp)?;
214 fpca.scores
215 }
216 FpcaChangepointMethod::Joint => {
217 let fpca = joint_fpca(&km, argvals, ncomp, None)?;
218 fpca.scores
219 }
220 };
221
222 let cusum_values = hotelling_cusum(&scores);
224 let (changepoint, test_statistic) = find_max_cusum(&cusum_values);
225
226 let p_value = mc_pvalue_permutation_hotelling(test_statistic, &scores, n_mc, seed);
228
229 Ok(ChangepointResult {
230 changepoint,
231 test_statistic,
232 p_value,
233 cusum_values,
234 })
235}
236
237fn compute_shooting_vectors(km: &KarcherMeanResult, argvals: &[f64]) -> Option<FdMatrix> {
241 let (n, m) = km.gammas.shape();
242 if n < 2 || m < 2 {
243 return None;
244 }
245 let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
246 let psis = warps_to_normalized_psi(&km.gammas, argvals);
247 let mu_psi = sphere_karcher_mean(&psis, &time, 30);
248 Some(shooting_vectors_from_psis(&psis, &mu_psi, &time))
249}
250
251fn functional_cusum(data: &FdMatrix, weights: &[f64]) -> Vec<f64> {
255 let (n, m) = data.shape();
256 let mut cusum_values = Vec::with_capacity(n - 1);
257
258 let mut cumsum = vec![0.0; m];
260 let mut total = vec![0.0; m];
261 for i in 0..n {
262 for j in 0..m {
263 total[j] += data[(i, j)];
264 }
265 }
266
267 for k in 1..n {
268 for j in 0..m {
269 cumsum[j] += data[(k - 1, j)];
270 }
271
272 let ratio = k as f64 / n as f64;
274 let mut s = 0.0;
275 for j in 0..m {
276 let diff = cumsum[j] - ratio * total[j];
277 s += weights[j] * diff * diff;
278 }
279 s /= (n * n) as f64;
280 cusum_values.push(s);
281 }
282
283 cusum_values
284}
285
286fn regularized_cov_inverse(scores: &FdMatrix, mean: &[f64], n: usize, p: usize) -> DMatrix<f64> {
288 let mut cov = DMatrix::zeros(p, p);
289 for i in 0..n {
290 for j1 in 0..p {
291 for j2 in 0..p {
292 cov[(j1, j2)] += (scores[(i, j1)] - mean[j1]) * (scores[(i, j2)] - mean[j2]);
293 }
294 }
295 }
296 cov /= (n - 1) as f64;
297 for j in 0..p {
298 cov[(j, j)] += 1e-6;
299 }
300 if let Some(chol) = cov.cholesky() {
301 chol.inverse()
302 } else {
303 DMatrix::identity(p, p)
304 }
305}
306
307fn hotelling_cusum(scores: &FdMatrix) -> Vec<f64> {
309 let (n, p) = scores.shape();
310
311 let mut mean = vec![0.0; p];
312 for k in 0..p {
313 for i in 0..n {
314 mean[k] += scores[(i, k)];
315 }
316 mean[k] /= n as f64;
317 }
318
319 let cov_inv = regularized_cov_inverse(scores, &mean, n, p);
320
321 let mut cumsum = vec![0.0; p];
322 let mut total = vec![0.0; p];
323 for i in 0..n {
324 for k in 0..p {
325 total[k] += scores[(i, k)];
326 }
327 }
328
329 let mut cusum_values = Vec::with_capacity(n - 1);
330 for k in 1..n {
331 for j in 0..p {
332 cumsum[j] += scores[(k - 1, j)];
333 }
334 let ratio = k as f64 / n as f64;
335 let mut delta = nalgebra::DVector::zeros(p);
336 for j in 0..p {
337 delta[j] = (cumsum[j] - ratio * total[j]) / n as f64;
338 }
339 let hotelling = (&delta.transpose() * &cov_inv * &delta)[(0, 0)];
340 cusum_values.push(hotelling);
341 }
342
343 cusum_values
344}
345
346fn find_max_cusum(cusum_values: &[f64]) -> (usize, f64) {
348 let mut max_val = f64::NEG_INFINITY;
349 let mut max_idx = 0;
350 for (i, &v) in cusum_values.iter().enumerate() {
351 if v > max_val {
352 max_val = v;
353 max_idx = i + 1; }
355 }
356 (max_idx, max_val)
357}
358
359fn mc_pvalue_permutation(
364 test_stat: f64,
365 data: &FdMatrix,
366 weights: &[f64],
367 n_mc: usize,
368 seed: u64,
369) -> f64 {
370 let (n, m) = data.shape();
371 let mut rng = StdRng::seed_from_u64(seed);
372 let mut indices: Vec<usize> = (0..n).collect();
373 let mut count_exceed = 0usize;
374
375 let mut shuffled = FdMatrix::zeros(n, m);
376 for _ in 0..n_mc {
377 indices.shuffle(&mut rng);
378 for (new_i, &orig_i) in indices.iter().enumerate() {
379 for j in 0..m {
380 shuffled[(new_i, j)] = data[(orig_i, j)];
381 }
382 }
383 let cusum = functional_cusum(&shuffled, weights);
384 let (_, max_val) = find_max_cusum(&cusum);
385 if max_val >= test_stat {
386 count_exceed += 1;
387 }
388 }
389
390 (count_exceed as f64 + 1.0) / (n_mc as f64 + 1.0)
391}
392
393fn mc_pvalue_permutation_hotelling(
397 test_stat: f64,
398 scores: &FdMatrix,
399 n_mc: usize,
400 seed: u64,
401) -> f64 {
402 let (n, p) = scores.shape();
403 let mut rng = StdRng::seed_from_u64(seed);
404 let mut indices: Vec<usize> = (0..n).collect();
405 let mut count_exceed = 0usize;
406
407 let mut shuffled = FdMatrix::zeros(n, p);
408 for _ in 0..n_mc {
409 indices.shuffle(&mut rng);
410 for (new_i, &orig_i) in indices.iter().enumerate() {
411 for j in 0..p {
412 shuffled[(new_i, j)] = scores[(orig_i, j)];
413 }
414 }
415 let cusum = hotelling_cusum(&shuffled);
416 let (_, max_val) = find_max_cusum(&cusum);
417 if max_val >= test_stat {
418 count_exceed += 1;
419 }
420 }
421
422 (count_exceed as f64 + 1.0) / (n_mc as f64 + 1.0)
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use std::f64::consts::PI;
429
430 fn generate_changepoint_data(n: usize, m: usize, cp: usize) -> (FdMatrix, Vec<f64>) {
431 let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
432 let mut data = FdMatrix::zeros(n, m);
433
434 for i in 0..n {
435 let amp = if i < cp { 1.0 } else { 2.0 };
436 for j in 0..m {
437 data[(i, j)] = amp * (2.0 * PI * t[j]).sin();
438 }
439 }
440 (data, t)
441 }
442
443 #[test]
444 fn test_amp_changepoint_detects_shift() {
445 let n = 30;
446 let m = 51;
447 let cp = 15;
448 let (data, t) = generate_changepoint_data(n, m, cp);
449
450 let result = elastic_amp_changepoint(&data, &t, 0.0, 5, 199, 42);
451 assert!(result.is_ok(), "amp changepoint should succeed");
452
453 let res = result.unwrap();
454 assert!(
455 res.cusum_values.len() == n - 1,
456 "Should have n-1 CUSUM values"
457 );
458 assert!(
459 (res.changepoint as i64 - cp as i64).abs() <= 5,
460 "Detected changepoint {} should be near true cp {}",
461 res.changepoint,
462 cp
463 );
464 assert!(
465 res.p_value < 0.05,
466 "Clear amplitude shift should be significant, got p={}",
467 res.p_value
468 );
469 }
470
471 #[test]
472 fn test_ph_changepoint_basic() {
473 let n = 30;
474 let m = 51;
475 let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
476 let mut data = FdMatrix::zeros(n, m);
477
478 for i in 0..n {
479 let shift = if i < 15 { 0.0 } else { 0.15 };
480 for j in 0..m {
481 data[(i, j)] = (2.0 * PI * (t[j] + shift)).sin();
482 }
483 }
484
485 let result = elastic_ph_changepoint(&data, &t, 0.0, 5, 100, 42);
486 assert!(result.is_ok());
487 }
488
489 #[test]
490 fn test_fpca_changepoint_basic() {
491 let n = 30;
492 let m = 51;
493 let cp = 15;
494 let (data, t) = generate_changepoint_data(n, m, cp);
495
496 let result = elastic_fpca_changepoint(
497 &data,
498 &t,
499 FpcaChangepointMethod::Vertical,
500 3,
501 0.0,
502 5,
503 100,
504 42,
505 );
506 assert!(result.is_ok(), "fpca changepoint should succeed");
507 }
508
509 #[test]
510 fn test_changepoint_no_change() {
511 let n = 20;
513 let m = 51;
514 let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
515 let mut data = FdMatrix::zeros(n, m);
516 for i in 0..n {
517 for j in 0..m {
518 data[(i, j)] = (2.0 * PI * t[j]).sin();
519 }
520 }
521
522 let res = elastic_amp_changepoint(&data, &t, 0.0, 5, 200, 42)
523 .expect("should succeed for identical curves");
524 assert!(
525 res.p_value > 0.1,
526 "No change should not be significant, got p={}",
527 res.p_value
528 );
529 }
530
531 #[test]
532 fn test_pvalue_permutation_discriminates() {
533 let m = 51;
534 let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
535
536 let (data_signal, _) = generate_changepoint_data(30, m, 15);
538 let res_signal =
539 elastic_amp_changepoint(&data_signal, &t, 0.0, 5, 199, 99).expect("should succeed");
540 assert!(
541 res_signal.p_value < 0.05,
542 "Strong signal should give small p, got {}",
543 res_signal.p_value
544 );
545
546 let mut data_null = FdMatrix::zeros(30, m);
548 for i in 0..30 {
549 for j in 0..m {
550 data_null[(i, j)] = (2.0 * PI * t[j]).sin();
551 }
552 }
553 let res_null =
554 elastic_amp_changepoint(&data_null, &t, 0.0, 5, 199, 99).expect("should succeed");
555 assert!(
556 res_null.p_value > 0.1,
557 "No signal should give large p, got {}",
558 res_null.p_value
559 );
560 }
561
562 #[test]
563 fn test_invalid_input() {
564 let data = FdMatrix::zeros(2, 5);
565 let t: Vec<f64> = (0..5).map(|i| i as f64 / 4.0).collect();
566 assert!(elastic_amp_changepoint(&data, &t, 0.0, 5, 100, 42).is_err());
568 }
569
570 #[test]
571 fn test_fpca_changepoint_horizontal() {
572 let n = 30;
573 let m = 51;
574 let cp = 15;
575 let (data, t) = generate_changepoint_data(n, m, cp);
576
577 let result = elastic_fpca_changepoint(
578 &data,
579 &t,
580 FpcaChangepointMethod::Horizontal,
581 3,
582 0.0,
583 5,
584 100,
585 42,
586 );
587 assert!(
588 result.is_ok(),
589 "fpca changepoint (horizontal) should succeed"
590 );
591
592 let res = result.unwrap();
593 assert_eq!(res.cusum_values.len(), n - 1);
594 }
595
596 #[test]
597 fn test_fpca_changepoint_joint() {
598 let n = 30;
599 let m = 51;
600 let cp = 15;
601 let (data, t) = generate_changepoint_data(n, m, cp);
602
603 let result =
604 elastic_fpca_changepoint(&data, &t, FpcaChangepointMethod::Joint, 3, 0.0, 5, 100, 42);
605 assert!(result.is_ok(), "fpca changepoint (joint) should succeed");
606
607 let res = result.unwrap();
608 assert_eq!(res.cusum_values.len(), n - 1);
609 }
610
611 #[test]
612 fn test_changepoint_seed_determinism() {
613 let n = 30;
614 let m = 51;
615 let cp = 15;
616 let (data, t) = generate_changepoint_data(n, m, cp);
617
618 let res1 = elastic_amp_changepoint(&data, &t, 0.0, 5, 199, 42).expect("should succeed");
619 let res2 = elastic_amp_changepoint(&data, &t, 0.0, 5, 199, 42).expect("should succeed");
620
621 assert_eq!(res1.changepoint, res2.changepoint);
622 assert!((res1.p_value - res2.p_value).abs() < 1e-10);
623 }
624
625 #[test]
626 fn test_pvalue_in_unit_interval() {
627 let n = 30;
628 let m = 51;
629 let cp = 15;
630 let (data, t) = generate_changepoint_data(n, m, cp);
631
632 let res_amp =
634 elastic_amp_changepoint(&data, &t, 0.0, 5, 100, 42).expect("amp should succeed");
635 assert!(
636 (0.0..=1.0).contains(&res_amp.p_value),
637 "amp p-value {} should be in [0, 1]",
638 res_amp.p_value
639 );
640
641 let res_ph =
643 elastic_ph_changepoint(&data, &t, 0.0, 5, 100, 42).expect("phase should succeed");
644 assert!(
645 (0.0..=1.0).contains(&res_ph.p_value),
646 "phase p-value {} should be in [0, 1]",
647 res_ph.p_value
648 );
649
650 let res_fpca = elastic_fpca_changepoint(
652 &data,
653 &t,
654 FpcaChangepointMethod::Vertical,
655 3,
656 0.0,
657 5,
658 100,
659 42,
660 )
661 .expect("fpca should succeed");
662 assert!(
663 (0.0..=1.0).contains(&res_fpca.p_value),
664 "fpca p-value {} should be in [0, 1]",
665 res_fpca.p_value
666 );
667 }
668
669 #[test]
670 fn test_fpca_changepoint_discriminates_signal() {
671 let m = 51;
672 let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
673
674 let (data_signal, _) = generate_changepoint_data(30, m, 15);
676 let res_signal = elastic_fpca_changepoint(
677 &data_signal,
678 &t,
679 FpcaChangepointMethod::Vertical,
680 3,
681 0.0,
682 5,
683 199,
684 99,
685 )
686 .expect("fpca signal should succeed");
687 assert!(
688 res_signal.p_value < 0.1,
689 "Strong FPCA signal should give small p, got {}",
690 res_signal.p_value
691 );
692
693 let mut data_null = FdMatrix::zeros(30, m);
695 for i in 0..30 {
696 for j in 0..m {
697 data_null[(i, j)] = (2.0 * PI * t[j]).sin();
698 }
699 }
700 let res_null = elastic_fpca_changepoint(
701 &data_null,
702 &t,
703 FpcaChangepointMethod::Vertical,
704 3,
705 0.0,
706 5,
707 199,
708 99,
709 )
710 .expect("fpca null should succeed");
711 assert!(
712 res_null.p_value > 0.1,
713 "No FPCA signal should give large p, got {}",
714 res_null.p_value
715 );
716 }
717
718 #[test]
719 fn test_fpca_changepoint_seed_determinism() {
720 let n = 30;
721 let m = 51;
722 let cp = 15;
723 let (data, t) = generate_changepoint_data(n, m, cp);
724
725 let res1 = elastic_fpca_changepoint(
726 &data,
727 &t,
728 FpcaChangepointMethod::Vertical,
729 3,
730 0.0,
731 5,
732 100,
733 42,
734 )
735 .expect("should succeed");
736 let res2 = elastic_fpca_changepoint(
737 &data,
738 &t,
739 FpcaChangepointMethod::Vertical,
740 3,
741 0.0,
742 5,
743 100,
744 42,
745 )
746 .expect("should succeed");
747
748 assert_eq!(res1.changepoint, res2.changepoint);
749 assert!(
750 (res1.p_value - res2.p_value).abs() < 1e-10,
751 "FPCA p-values should be deterministic with same seed"
752 );
753 }
754}