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)]
27pub struct ChangepointResult {
28 pub changepoint: usize,
30 pub test_statistic: f64,
32 pub p_value: f64,
34 pub cusum_values: Vec<f64>,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq)]
40pub enum ChangepointType {
41 Amplitude,
43 Phase,
45 Fpca(FpcaChangepointMethod),
47}
48
49#[derive(Debug, Clone, Copy, PartialEq)]
51pub enum FpcaChangepointMethod {
52 Vertical,
53 Horizontal,
54 Joint,
55}
56
57#[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 let km = karcher_mean(data, argvals, max_iter, 1e-4, lambda);
93
94 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 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#[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 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 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#[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 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 let cusum_values = hotelling_cusum(&scores);
221 let (changepoint, test_statistic) = find_max_cusum(&cusum_values);
222
223 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
234fn 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
248fn 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 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 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
283fn 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
304fn 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
343fn 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; }
352 }
353 (max_idx, max_val)
354}
355
356fn 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
390fn 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 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 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 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 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}