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)]
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#[derive(Debug, Clone, Copy, PartialEq)]
59pub enum CovKernel {
60 Bartlett,
61 Parzen,
62 FlatTop,
63 Simple,
64}
65
66pub 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 let km = karcher_mean(data, argvals, max_iter, 1e-4, lambda);
101
102 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 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
119pub 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 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 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
160pub 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 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 let cusum_values = hotelling_cusum(&scores);
210 let (changepoint, test_statistic) = find_max_cusum(&cusum_values);
211
212 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
223fn 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
237fn 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 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 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
272fn 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
293fn 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
332fn 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; }
341 }
342 (max_idx, max_val)
343}
344
345fn 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
379fn 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 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 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 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 assert!(
557 elastic_amp_changepoint(&data, &t, 0.0, 5, 100, CovKernel::Simple, None, 42).is_none()
558 );
559 }
560}