1use crate::error::{NumRs2Error, Result};
28use scirs2_core::ndarray::{s, Array1, Array2, ArrayView1};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum CostFunction {
37 L2,
39 L1,
41 NormalLogLikelihood,
43}
44
45pub fn segment_cost(segment: &ArrayView1<f64>, cost_fn: CostFunction) -> Result<f64> {
54 let n = segment.len();
55 if n == 0 {
56 return Ok(0.0);
57 }
58
59 match cost_fn {
60 CostFunction::L2 => {
61 let mean = segment.iter().sum::<f64>() / n as f64;
62 let cost = segment.iter().map(|&x| (x - mean).powi(2)).sum::<f64>();
63 Ok(cost)
64 }
65 CostFunction::L1 => {
66 let mut sorted: Vec<f64> = segment.iter().copied().collect();
67 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
68 let median = if n.is_multiple_of(2) {
69 (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
70 } else {
71 sorted[n / 2]
72 };
73 let cost = segment.iter().map(|&x| (x - median).abs()).sum::<f64>();
74 Ok(cost)
75 }
76 CostFunction::NormalLogLikelihood => {
77 let nf = n as f64;
78 let mean = segment.iter().sum::<f64>() / nf;
79 let variance = segment.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / nf;
80 if variance <= 1e-15 {
81 return Ok(nf * 50.0);
83 }
84 Ok(0.5 * nf * (2.0 * std::f64::consts::PI * variance).ln() + 0.5 * nf)
86 }
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum PenaltyType {
97 BIC,
99 AIC,
101 MBIC,
103 Manual,
105}
106
107pub fn compute_penalty(penalty_type: PenaltyType, n: usize, manual_value: f64) -> f64 {
114 let nf = n as f64;
115 match penalty_type {
116 PenaltyType::BIC => nf.ln(),
117 PenaltyType::AIC => 2.0,
118 PenaltyType::MBIC => 3.0 * nf.ln(),
119 PenaltyType::Manual => manual_value,
120 }
121}
122
123#[derive(Debug, Clone)]
129pub struct SegmentStats {
130 pub start: usize,
132 pub end: usize,
134 pub mean: f64,
136 pub variance: f64,
138 pub count: usize,
140}
141
142pub fn compute_segment_statistics(
151 data: &ArrayView1<f64>,
152 change_points: &[usize],
153) -> Result<Vec<SegmentStats>> {
154 let n = data.len();
155 if n == 0 {
156 return Ok(Vec::new());
157 }
158
159 let mut boundaries = vec![0];
160 for &cp in change_points {
161 if cp > 0 && cp < n {
162 boundaries.push(cp);
163 }
164 }
165 boundaries.push(n);
166 boundaries.sort_unstable();
167 boundaries.dedup();
168
169 let mut stats = Vec::with_capacity(boundaries.len() - 1);
170 for w in boundaries.windows(2) {
171 let start = w[0];
172 let end = w[1];
173 let count = end - start;
174 if count == 0 {
175 continue;
176 }
177 let segment = data.slice(s![start..end]);
178 let mean = segment.iter().sum::<f64>() / count as f64;
179 let variance = if count > 1 {
180 segment.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / count as f64
181 } else {
182 0.0
183 };
184 stats.push(SegmentStats {
185 start,
186 end,
187 mean,
188 variance,
189 count,
190 });
191 }
192
193 Ok(stats)
194}
195
196#[derive(Debug, Clone)]
202pub struct ChangePointResult {
203 pub locations: Vec<usize>,
205 pub costs: Vec<f64>,
207 pub total_cost: f64,
209}
210
211#[derive(Debug, Clone)]
213pub struct ChangePointSummary {
214 pub n_changepoints: usize,
216 pub locations: Vec<usize>,
218 pub segments: Vec<SegmentStats>,
220 pub total_cost: f64,
222}
223
224pub fn summarize_changepoints(
230 data: &ArrayView1<f64>,
231 result: &ChangePointResult,
232) -> Result<ChangePointSummary> {
233 let segments = compute_segment_statistics(data, &result.locations)?;
234 Ok(ChangePointSummary {
235 n_changepoints: result.locations.len(),
236 locations: result.locations.clone(),
237 segments,
238 total_cost: result.total_cost,
239 })
240}
241
242#[derive(Debug, Clone)]
260pub struct Cusum {
261 pub target: f64,
263 pub threshold: f64,
265 pub drift: f64,
267}
268
269impl Cusum {
270 pub fn new(target: f64, threshold: f64, drift: f64) -> Self {
277 Self {
278 target,
279 threshold,
280 drift,
281 }
282 }
283
284 pub fn detect(&self, data: &ArrayView1<f64>) -> Result<ChangePointResult> {
288 if data.is_empty() {
289 return Ok(ChangePointResult {
290 locations: Vec::new(),
291 costs: Vec::new(),
292 total_cost: 0.0,
293 });
294 }
295
296 let mut locations = Vec::new();
297 let mut costs = Vec::new();
298 let mut s_high = 0.0_f64;
299 let mut s_low = 0.0_f64;
300
301 for (i, &x) in data.iter().enumerate() {
302 s_high = (s_high + (x - self.target) - self.drift).max(0.0);
303 s_low = (s_low - (x - self.target) - self.drift).max(0.0);
304
305 if s_high > self.threshold {
306 locations.push(i);
307 costs.push(s_high);
308 s_high = 0.0;
309 } else if s_low > self.threshold {
310 locations.push(i);
311 costs.push(s_low);
312 s_low = 0.0;
313 }
314 }
315
316 let total_cost = costs.iter().sum();
317 Ok(ChangePointResult {
318 locations,
319 costs,
320 total_cost,
321 })
322 }
323
324 pub fn forward_cusum(&self, data: &ArrayView1<f64>) -> Result<(Array1<f64>, Array1<f64>)> {
328 let n = data.len();
329 if n == 0 {
330 return Ok((Array1::zeros(0), Array1::zeros(0)));
331 }
332
333 let mut s_high = Array1::zeros(n);
334 let mut s_low = Array1::zeros(n);
335
336 s_high[0] = ((data[0] - self.target) - self.drift).max(0.0);
337 s_low[0] = (-(data[0] - self.target) - self.drift).max(0.0);
338
339 for i in 1..n {
340 s_high[i] = (s_high[i - 1] + (data[i] - self.target) - self.drift).max(0.0);
341 s_low[i] = (s_low[i - 1] - (data[i] - self.target) - self.drift).max(0.0);
342 }
343
344 Ok((s_high, s_low))
345 }
346
347 pub fn backward_cusum(&self, data: &ArrayView1<f64>) -> Result<(Array1<f64>, Array1<f64>)> {
351 let n = data.len();
352 if n == 0 {
353 return Ok((Array1::zeros(0), Array1::zeros(0)));
354 }
355
356 let mut s_high = Array1::zeros(n);
357 let mut s_low = Array1::zeros(n);
358
359 let last = n - 1;
360 s_high[last] = ((data[last] - self.target) - self.drift).max(0.0);
361 s_low[last] = (-(data[last] - self.target) - self.drift).max(0.0);
362
363 for i in (0..last).rev() {
364 s_high[i] = (s_high[i + 1] + (data[i] - self.target) - self.drift).max(0.0);
365 s_low[i] = (s_low[i + 1] - (data[i] - self.target) - self.drift).max(0.0);
366 }
367
368 Ok((s_high, s_low))
369 }
370
371 pub fn control_limits(&self, n: usize) -> (Array1<f64>, Array1<f64>) {
376 let upper = Array1::from_elem(n, self.threshold);
377 let lower = Array1::from_elem(n, self.threshold);
378 (upper, lower)
379 }
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub enum ChangeType {
389 Mean,
391 Variance,
393 MeanAndVariance,
395}
396
397#[derive(Debug, Clone)]
410pub struct Pelt {
411 pub penalty: f64,
413 pub min_size: usize,
415 pub cost_fn: CostFunction,
417 pub penalty_type: PenaltyType,
419}
420
421impl Pelt {
422 pub fn new(penalty: f64, min_size: usize) -> Self {
428 Self {
429 penalty,
430 min_size: min_size.max(1),
431 cost_fn: CostFunction::NormalLogLikelihood,
432 penalty_type: PenaltyType::Manual,
433 }
434 }
435
436 pub fn with_options(penalty_type: PenaltyType, cost_fn: CostFunction, min_size: usize) -> Self {
440 Self {
441 penalty: 0.0, min_size: min_size.max(1),
443 cost_fn,
444 penalty_type,
445 }
446 }
447
448 pub fn detect(
450 &self,
451 data: &ArrayView1<f64>,
452 change_type: ChangeType,
453 ) -> Result<ChangePointResult> {
454 match change_type {
455 ChangeType::Mean => self.detect_mean_change(data),
456 ChangeType::Variance => self.detect_variance_change(data),
457 ChangeType::MeanAndVariance => self.detect_mean_variance_change(data),
458 }
459 }
460
461 pub fn detect_mean_change(&self, data: &ArrayView1<f64>) -> Result<ChangePointResult> {
463 self.pelt_core(data, CostFunction::L2)
464 }
465
466 pub fn detect_variance_change(&self, data: &ArrayView1<f64>) -> Result<ChangePointResult> {
468 self.pelt_core(data, CostFunction::NormalLogLikelihood)
469 }
470
471 pub fn detect_mean_variance_change(&self, data: &ArrayView1<f64>) -> Result<ChangePointResult> {
473 self.pelt_core(data, CostFunction::NormalLogLikelihood)
474 }
475
476 fn pelt_core(
478 &self,
479 data: &ArrayView1<f64>,
480 cost_fn: CostFunction,
481 ) -> Result<ChangePointResult> {
482 let n = data.len();
483 if n < 2 * self.min_size {
484 return Err(NumRs2Error::ValueError(
485 "Insufficient data for change point detection".to_string(),
486 ));
487 }
488
489 let penalty = if self.penalty_type == PenaltyType::Manual {
491 self.penalty
492 } else {
493 compute_penalty(self.penalty_type, n, self.penalty)
494 };
495
496 let mut f = vec![f64::INFINITY; n + 1];
499 let mut last_cp = vec![0_usize; n + 1]; f[0] = -penalty;
501
502 let mut candidates: Vec<usize> = vec![0];
504
505 for t in self.min_size..=n {
506 let mut best_cost = f64::INFINITY;
507 let mut best_s = 0_usize;
508
509 for &s_val in &candidates {
510 if t - s_val >= self.min_size {
511 let seg = data.slice(s![s_val..t]);
512 let c = segment_cost(&seg, cost_fn)?;
513 let total = f[s_val] + c + penalty;
514 if total < best_cost {
515 best_cost = total;
516 best_s = s_val;
517 }
518 }
519 }
520
521 if best_cost.is_infinite() {
522 candidates.push(t);
524 continue;
525 }
526
527 f[t] = best_cost;
528 last_cp[t] = best_s;
529
530 candidates.retain(|&s_val| {
532 if t - s_val < self.min_size {
533 return true;
534 }
535 if let Ok(c) = segment_cost(&data.slice(s![s_val..t]), cost_fn) {
536 f[s_val] + c + penalty <= best_cost
537 } else {
538 false
539 }
540 });
541
542 candidates.push(t);
543 }
544
545 let mut locations = Vec::new();
547 let mut idx = n;
548 while idx > 0 {
549 let prev = last_cp[idx];
550 if prev > 0 {
551 locations.push(prev);
552 }
553 if prev >= idx {
554 break;
555 }
556 idx = prev;
557 }
558 locations.sort_unstable();
559 locations.dedup();
560
561 let costs = self.compute_costs_for_segments(data, &locations, cost_fn)?;
563 let total_cost = if f[n].is_finite() { f[n] } else { 0.0 };
564
565 Ok(ChangePointResult {
566 locations,
567 costs,
568 total_cost,
569 })
570 }
571
572 fn compute_costs_for_segments(
574 &self,
575 data: &ArrayView1<f64>,
576 change_points: &[usize],
577 cost_fn: CostFunction,
578 ) -> Result<Vec<f64>> {
579 let n = data.len();
580 let mut costs = Vec::new();
581 let mut start = 0;
582
583 for &cp in change_points {
584 if cp > start && cp <= n {
585 let seg = data.slice(s![start..cp]);
586 costs.push(segment_cost(&seg, cost_fn)?);
587 start = cp;
588 }
589 }
590
591 if start < n {
592 let seg = data.slice(s![start..n]);
593 costs.push(segment_cost(&seg, cost_fn)?);
594 }
595
596 Ok(costs)
597 }
598}
599
600#[derive(Debug, Clone)]
616pub struct BinarySegmentation {
617 pub max_changepoints: usize,
619 pub threshold: f64,
621 pub min_size: usize,
623 pub cost_fn: CostFunction,
625}
626
627impl BinarySegmentation {
628 pub fn new(max_changepoints: usize, threshold: f64, min_size: usize) -> Self {
630 Self {
631 max_changepoints,
632 threshold,
633 min_size: min_size.max(1),
634 cost_fn: CostFunction::L2,
635 }
636 }
637
638 pub fn with_cost(
640 max_changepoints: usize,
641 threshold: f64,
642 min_size: usize,
643 cost_fn: CostFunction,
644 ) -> Self {
645 Self {
646 max_changepoints,
647 threshold,
648 min_size: min_size.max(1),
649 cost_fn,
650 }
651 }
652
653 pub fn detect(&self, data: &ArrayView1<f64>) -> Result<ChangePointResult> {
655 let n = data.len();
656 if n < 2 * self.min_size {
657 return Ok(ChangePointResult {
658 locations: Vec::new(),
659 costs: Vec::new(),
660 total_cost: 0.0,
661 });
662 }
663
664 let mut change_points = Vec::new();
665 let mut segments: Vec<(usize, usize)> = vec![(0, n)];
666 let mut total_cost = 0.0;
667
668 for _ in 0..self.max_changepoints {
669 let mut best_seg_idx = None;
670 let mut best_cp = 0;
671 let mut best_gain = 0.0_f64;
672
673 for (idx, &(start, end)) in segments.iter().enumerate() {
674 if end - start < 2 * self.min_size {
675 continue;
676 }
677
678 if let Ok((cp, gain)) = self.find_best_split(data, start, end) {
679 if gain > best_gain {
680 best_gain = gain;
681 best_cp = cp;
682 best_seg_idx = Some(idx);
683 }
684 }
685 }
686
687 if best_gain < self.threshold {
688 break;
689 }
690
691 if let Some(idx) = best_seg_idx {
692 let (start, end) = segments[idx];
693 segments.remove(idx);
694 segments.push((start, best_cp));
695 segments.push((best_cp, end));
696 change_points.push(best_cp);
697 total_cost += best_gain;
698 } else {
699 break;
700 }
701 }
702
703 change_points.sort_unstable();
704
705 let costs = if change_points.is_empty() {
706 Vec::new()
707 } else {
708 vec![total_cost / change_points.len() as f64; change_points.len()]
709 };
710
711 Ok(ChangePointResult {
712 locations: change_points,
713 costs,
714 total_cost,
715 })
716 }
717
718 fn find_best_split(
722 &self,
723 data: &ArrayView1<f64>,
724 start: usize,
725 end: usize,
726 ) -> Result<(usize, f64)> {
727 if end - start < 2 * self.min_size {
728 return Err(NumRs2Error::ValueError("Segment too small".to_string()));
729 }
730
731 let full_seg = data.slice(s![start..end]);
732 let full_cost = segment_cost(&full_seg, self.cost_fn)?;
733
734 let mut best_cp = start + self.min_size;
735 let mut best_gain = f64::NEG_INFINITY;
736
737 for t in (start + self.min_size)..(end - self.min_size + 1) {
738 let left = data.slice(s![start..t]);
739 let right = data.slice(s![t..end]);
740
741 let left_cost = segment_cost(&left, self.cost_fn)?;
742 let right_cost = segment_cost(&right, self.cost_fn)?;
743
744 let gain = full_cost - left_cost - right_cost;
745 if gain > best_gain {
746 best_gain = gain;
747 best_cp = t;
748 }
749 }
750
751 Ok((best_cp, best_gain))
752 }
753}
754
755#[derive(Debug, Clone)]
777pub struct BayesianChangePoint {
778 pub hazard_rate: f64,
780 pub prior_mean: f64,
782 pub prior_var: f64,
784 pub obs_var: f64,
786 pub threshold: f64,
788}
789
790impl BayesianChangePoint {
791 pub fn new(hazard_rate: f64, threshold: f64) -> Self {
798 Self {
799 hazard_rate: hazard_rate.clamp(1e-10, 1.0 - 1e-10),
800 prior_mean: 0.0,
801 prior_var: 1.0,
802 obs_var: 1.0,
803 threshold: threshold.clamp(0.0, 1.0),
804 }
805 }
806
807 pub fn with_prior(
809 hazard_rate: f64,
810 threshold: f64,
811 prior_mean: f64,
812 prior_var: f64,
813 obs_var: f64,
814 ) -> Self {
815 Self {
816 hazard_rate: hazard_rate.clamp(1e-10, 1.0 - 1e-10),
817 prior_mean,
818 prior_var: prior_var.max(1e-10),
819 obs_var: obs_var.max(1e-10),
820 threshold: threshold.clamp(0.0, 1.0),
821 }
822 }
823
824 pub fn detect(&self, data: &ArrayView1<f64>) -> Result<ChangePointResult> {
837 let n = data.len();
838 if n < 2 {
839 return Ok(ChangePointResult {
840 locations: Vec::new(),
841 costs: Vec::new(),
842 total_cost: 0.0,
843 });
844 }
845
846 let (rlp, _cp_probs) = self.compute_run_length_distribution(data)?;
847
848 let mut map_rls = vec![0_usize; n + 1];
850 let mut map_probs = vec![0.0_f64; n + 1];
851 for t in 0..=n {
852 let mut best_r = 0_usize;
853 let mut best_p = 0.0_f64;
854 let max_r = t.min(n);
855 for r in 0..=max_r {
856 if rlp[[t, r]] > best_p {
857 best_p = rlp[[t, r]];
858 best_r = r;
859 }
860 }
861 map_rls[t] = best_r;
862 map_probs[t] = best_p;
863 }
864
865 let mut locations = Vec::new();
866 let mut costs = Vec::new();
867
868 for t in 2..=n {
871 let prev_rl = map_rls[t - 1];
872 let curr_rl = map_rls[t];
873
874 let expected_rl = prev_rl + 1;
878 if expected_rl > 3 && curr_rl < expected_rl / 2 && map_probs[t] >= self.threshold {
879 let cp_location = t.saturating_sub(curr_rl);
881
882 let already_found = locations
884 .iter()
885 .any(|&loc: &usize| loc.abs_diff(cp_location) < 5);
886
887 if !already_found && cp_location > 0 && cp_location < n {
888 locations.push(cp_location);
889 costs.push(map_probs[t]);
890 }
891 }
892 }
893
894 let total_cost = costs.iter().sum();
895 Ok(ChangePointResult {
896 locations,
897 costs,
898 total_cost,
899 })
900 }
901
902 pub fn compute_run_length_distribution(
909 &self,
910 data: &ArrayView1<f64>,
911 ) -> Result<(Array2<f64>, Array1<f64>)> {
912 let n = data.len();
913 if n == 0 {
914 return Ok((Array2::zeros((0, 0)), Array1::zeros(0)));
915 }
916
917 let mut rlp = Array2::zeros((n + 1, n + 1));
920 rlp[[0, 0]] = 1.0; let mut cp_probs = Array1::zeros(n + 1);
923
924 let mut mu = vec![self.prior_mean; n + 1]; let mut sigma2 = vec![self.prior_var; n + 1]; for t in 0..n {
930 let x = data[t];
931
932 let mut pred_probs = vec![0.0_f64; t + 1];
934 for r in 0..=t {
935 let pred_var = sigma2[r] + self.obs_var;
937 let diff = x - mu[r];
938 let log_pred = -0.5 * (2.0 * std::f64::consts::PI * pred_var).ln()
939 - 0.5 * diff * diff / pred_var;
940 pred_probs[r] = log_pred.exp();
941 }
942
943 let h = self.hazard_rate;
945 let mut new_rlp = vec![0.0_f64; t + 2];
946
947 for r in 0..=t {
949 new_rlp[r + 1] += rlp[[t, r]] * pred_probs[r] * (1.0 - h);
950 }
951
952 let mut cp_sum = 0.0;
954 for r in 0..=t {
955 cp_sum += rlp[[t, r]] * pred_probs[r] * h;
956 }
957 new_rlp[0] = cp_sum;
958
959 let evidence: f64 = new_rlp.iter().sum();
961 if evidence > 1e-300 {
962 for r in 0..=(t + 1) {
963 rlp[[t + 1, r]] = new_rlp[r] / evidence;
964 }
965 }
966
967 cp_probs[t + 1] = if evidence > 1e-300 {
968 new_rlp[0] / evidence
969 } else {
970 0.0
971 };
972
973 let mut new_mu = vec![self.prior_mean; t + 2];
976 let mut new_sigma2 = vec![self.prior_var; t + 2];
977
978 for r in 0..=t {
979 let kalman_gain = sigma2[r] / (sigma2[r] + self.obs_var);
980 new_mu[r + 1] = mu[r] + kalman_gain * (x - mu[r]);
981 new_sigma2[r + 1] = sigma2[r] * (1.0 - kalman_gain);
982 }
983 new_mu[0] = self.prior_mean;
985 new_sigma2[0] = self.prior_var;
986
987 mu = new_mu;
988 sigma2 = new_sigma2;
989 }
990
991 let rlp_out = rlp.slice(s![0..=n, 0..=n]).to_owned();
993 let cp_out = cp_probs.slice(s![0..=n]).to_owned();
994
995 Ok((rlp_out, cp_out))
996 }
997
998 pub fn hazard(&self, _run_length: usize) -> f64 {
1000 self.hazard_rate
1001 }
1002}
1003
1004#[cfg(test)]
1009mod tests {
1010 use super::*;
1011 use scirs2_core::ndarray::Array1;
1012
1013 fn step_signal(n1: usize, n2: usize, mean1: f64, mean2: f64) -> Array1<f64> {
1017 let mut data = vec![mean1; n1];
1018 data.extend(vec![mean2; n2]);
1019 Array1::from_vec(data)
1020 }
1021
1022 fn add_noise(data: &mut [f64]) {
1024 for (i, v) in data.iter_mut().enumerate() {
1025 *v += 0.01 * ((i as f64 * 1.23456).sin());
1026 }
1027 }
1028
1029 #[test]
1033 fn test_cusum_single_mean_shift() {
1034 let mut data = vec![0.0; 50];
1035 data.extend(vec![3.0; 50]);
1036 add_noise(&mut data);
1037 let arr = Array1::from_vec(data);
1038
1039 let cusum = Cusum::new(0.0, 8.0, 0.5);
1040 let result = cusum
1041 .detect(&arr.view())
1042 .expect("CUSUM detect should succeed");
1043
1044 assert!(
1045 !result.locations.is_empty(),
1046 "CUSUM should detect at least one change point"
1047 );
1048 let first = result.locations[0];
1050 assert!(
1051 (40..=70).contains(&first),
1052 "First change point {first} should be near 50"
1053 );
1054 }
1055
1056 #[test]
1060 fn test_cusum_forward_backward() {
1061 let mut data = vec![0.0; 30];
1062 data.extend(vec![4.0; 30]);
1063 add_noise(&mut data);
1064 let arr = Array1::from_vec(data);
1065
1066 let cusum = Cusum::new(0.0, 5.0, 0.5);
1067 let (fwd_high, fwd_low) = cusum
1068 .forward_cusum(&arr.view())
1069 .expect("forward CUSUM should succeed");
1070 let (bwd_high, bwd_low) = cusum
1071 .backward_cusum(&arr.view())
1072 .expect("backward CUSUM should succeed");
1073
1074 assert_eq!(fwd_high.len(), 60);
1075 assert_eq!(bwd_high.len(), 60);
1076
1077 assert!(
1079 fwd_high[59] > fwd_high[0],
1080 "Forward CUSUM should rise after shift"
1081 );
1082 assert!(
1084 bwd_high[0] > bwd_high[59],
1085 "Backward CUSUM should detect shift from the other direction"
1086 );
1087 }
1088
1089 #[test]
1093 fn test_cusum_control_limits() {
1094 let cusum = Cusum::new(0.0, 5.0, 0.5);
1095 let (upper, lower) = cusum.control_limits(100);
1096 assert_eq!(upper.len(), 100);
1097 assert_eq!(lower.len(), 100);
1098 for i in 0..100 {
1099 assert!((upper[i] - 5.0).abs() < 1e-10);
1100 assert!((lower[i] - 5.0).abs() < 1e-10);
1101 }
1102 }
1103
1104 #[test]
1108 fn test_cusum_no_change() {
1109 let mut data = vec![5.0; 100];
1110 add_noise(&mut data);
1111 let arr = Array1::from_vec(data);
1112
1113 let cusum = Cusum::new(5.0, 50.0, 0.5);
1115 let result = cusum.detect(&arr.view()).expect("CUSUM should succeed");
1116
1117 assert!(
1118 result.locations.is_empty(),
1119 "CUSUM should not detect changes in stationary data with high threshold"
1120 );
1121 }
1122
1123 #[test]
1127 fn test_pelt_single_mean_change() {
1128 let mut raw = vec![0.0; 50];
1129 raw.extend(vec![5.0; 50]);
1130 add_noise(&mut raw);
1131 let data = Array1::from_vec(raw);
1132
1133 let pelt = Pelt::new(15.0, 5);
1134 let result = pelt
1135 .detect_mean_change(&data.view())
1136 .expect("PELT mean change should succeed");
1137
1138 assert!(
1139 !result.locations.is_empty(),
1140 "PELT should detect at least one change point"
1141 );
1142 let closest = result
1144 .locations
1145 .iter()
1146 .min_by_key(|&&cp| ((cp as i64) - 50).unsigned_abs())
1147 .copied();
1148 if let Some(cp) = closest {
1149 assert!(
1150 (cp as i64 - 50).unsigned_abs() < 10,
1151 "Closest change point {cp} should be near 50"
1152 );
1153 }
1154 }
1155
1156 #[test]
1160 fn test_pelt_multiple_changepoints() {
1161 let mut raw = vec![0.0; 40];
1162 raw.extend(vec![5.0; 40]);
1163 raw.extend(vec![10.0; 40]);
1164 add_noise(&mut raw);
1165 let data = Array1::from_vec(raw);
1166
1167 let pelt = Pelt::new(15.0, 5);
1168 let result = pelt
1169 .detect_mean_change(&data.view())
1170 .expect("PELT should succeed");
1171
1172 assert!(
1173 result.locations.len() >= 2,
1174 "PELT should detect at least 2 change points, got {}",
1175 result.locations.len()
1176 );
1177 }
1178
1179 #[test]
1183 fn test_pelt_variance_change() {
1184 let mut raw = Vec::with_capacity(100);
1186 for i in 0..50 {
1187 raw.push(5.0 + 0.05 * (i as f64 * 0.73).sin());
1188 }
1189 for i in 50..100 {
1190 raw.push(5.0 + 3.0 * (i as f64 * 0.73).sin());
1191 }
1192 let data = Array1::from_vec(raw);
1193
1194 let pelt = Pelt::new(10.0, 5);
1195 let result = pelt
1196 .detect_variance_change(&data.view())
1197 .expect("PELT variance change should succeed");
1198
1199 assert!(result.total_cost.is_finite(), "Total cost should be finite");
1200 }
1201
1202 #[test]
1206 fn test_pelt_bic_penalty() {
1207 let mut raw = vec![0.0; 60];
1208 raw.extend(vec![6.0; 60]);
1209 add_noise(&mut raw);
1210 let data = Array1::from_vec(raw);
1211
1212 let pelt = Pelt::with_options(PenaltyType::BIC, CostFunction::L2, 5);
1213 let result = pelt
1214 .detect(&data.view(), ChangeType::Mean)
1215 .expect("PELT with BIC should succeed");
1216
1217 assert!(
1218 !result.locations.is_empty(),
1219 "PELT with BIC should detect change"
1220 );
1221 }
1222
1223 #[test]
1227 fn test_pelt_aic_penalty() {
1228 let mut raw = vec![0.0; 50];
1229 raw.extend(vec![5.0; 50]);
1230 add_noise(&mut raw);
1231 let data = Array1::from_vec(raw);
1232
1233 let pelt = Pelt::with_options(PenaltyType::AIC, CostFunction::L2, 5);
1234 let result = pelt
1235 .detect(&data.view(), ChangeType::Mean)
1236 .expect("PELT with AIC should succeed");
1237
1238 assert!(
1239 !result.locations.is_empty(),
1240 "PELT with AIC should detect change"
1241 );
1242 }
1243
1244 #[test]
1248 fn test_pelt_no_change_stationary() {
1249 let mut raw = vec![5.0; 100];
1250 add_noise(&mut raw);
1251 let data = Array1::from_vec(raw);
1252
1253 let pelt = Pelt::new(100.0, 10); let result = pelt
1255 .detect_mean_change(&data.view())
1256 .expect("PELT should succeed on stationary data");
1257
1258 assert!(
1259 result.locations.is_empty(),
1260 "PELT should not detect changes in stationary data with high penalty, got {:?}",
1261 result.locations
1262 );
1263 }
1264
1265 #[test]
1269 fn test_pelt_insufficient_data() {
1270 let data = Array1::from_vec(vec![1.0, 2.0, 3.0]);
1271 let pelt = Pelt::new(5.0, 5);
1272 let result = pelt.detect_mean_change(&data.view());
1273 assert!(result.is_err(), "PELT should fail with insufficient data");
1274 }
1275
1276 #[test]
1280 fn test_binseg_basic_detection() {
1281 let mut raw = vec![0.0; 50];
1282 raw.extend(vec![5.0; 50]);
1283 add_noise(&mut raw);
1284 let data = Array1::from_vec(raw);
1285
1286 let bs = BinarySegmentation::new(5, 5.0, 5);
1287 let result = bs.detect(&data.view()).expect("BinSeg should succeed");
1288
1289 assert!(!result.locations.is_empty(), "BinSeg should detect change");
1290 }
1291
1292 #[test]
1296 fn test_binseg_multiple_changepoints() {
1297 let mut raw = vec![0.0; 40];
1298 raw.extend(vec![4.0; 40]);
1299 raw.extend(vec![8.0; 40]);
1300 add_noise(&mut raw);
1301 let data = Array1::from_vec(raw);
1302
1303 let bs = BinarySegmentation::new(10, 5.0, 5);
1304 let result = bs.detect(&data.view()).expect("BinSeg should succeed");
1305
1306 assert!(
1307 result.locations.len() >= 2,
1308 "BinSeg should detect at least 2 change points, got {}",
1309 result.locations.len()
1310 );
1311 }
1312
1313 #[test]
1317 fn test_pelt_vs_binseg_comparison() {
1318 let mut raw = vec![0.0; 50];
1319 raw.extend(vec![5.0; 50]);
1320 add_noise(&mut raw);
1321 let data = Array1::from_vec(raw);
1322
1323 let pelt = Pelt::new(10.0, 5);
1324 let pelt_result = pelt
1325 .detect_mean_change(&data.view())
1326 .expect("PELT should succeed");
1327
1328 let bs = BinarySegmentation::new(5, 5.0, 5);
1329 let bs_result = bs.detect(&data.view()).expect("BinSeg should succeed");
1330
1331 assert!(
1333 !pelt_result.locations.is_empty(),
1334 "PELT should detect change"
1335 );
1336 assert!(
1337 !bs_result.locations.is_empty(),
1338 "BinSeg should detect change"
1339 );
1340
1341 let pelt_near_50 = pelt_result
1343 .locations
1344 .iter()
1345 .any(|&cp| (cp as i64 - 50).unsigned_abs() < 15);
1346 let bs_near_50 = bs_result
1347 .locations
1348 .iter()
1349 .any(|&cp| (cp as i64 - 50).unsigned_abs() < 15);
1350
1351 assert!(pelt_near_50, "PELT should find change near 50");
1352 assert!(bs_near_50, "BinSeg should find change near 50");
1353 }
1354
1355 #[test]
1359 fn test_bayesian_online_detection() {
1360 let mut raw = vec![0.0; 50];
1361 raw.extend(vec![5.0; 50]);
1362 add_noise(&mut raw);
1363 let data = Array1::from_vec(raw);
1364
1365 let bayes = BayesianChangePoint::with_prior(0.05, 0.3, 0.0, 10.0, 1.0);
1366 let result = bayes
1367 .detect(&data.view())
1368 .expect("Bayesian detection should succeed");
1369
1370 assert!(
1372 !result.locations.is_empty(),
1373 "Bayesian should detect at least one change point"
1374 );
1375 }
1376
1377 #[test]
1381 fn test_bayesian_run_length_distribution() {
1382 let mut raw = vec![0.0; 30];
1383 raw.extend(vec![5.0; 30]);
1384 add_noise(&mut raw);
1385 let data = Array1::from_vec(raw);
1386
1387 let bayes = BayesianChangePoint::with_prior(0.1, 0.3, 0.0, 10.0, 1.0);
1388 let (rlp, cp_probs) = bayes
1389 .compute_run_length_distribution(&data.view())
1390 .expect("Run length computation should succeed");
1391
1392 assert_eq!(rlp.nrows(), 61); assert_eq!(cp_probs.len(), 61);
1394
1395 for t in 1..=60 {
1397 let row_sum: f64 = rlp.row(t).iter().sum();
1398 assert!(
1399 (row_sum - 1.0).abs() < 0.1,
1400 "Row {t} sum = {row_sum} should be near 1.0"
1401 );
1402 }
1403 }
1404
1405 #[test]
1409 fn test_bayesian_hazard_function() {
1410 let bayes = BayesianChangePoint::new(0.05, 0.5);
1411 assert!((bayes.hazard(0) - 0.05).abs() < 1e-10);
1413 assert!((bayes.hazard(100) - 0.05).abs() < 1e-10);
1414 assert!((bayes.hazard(1000) - 0.05).abs() < 1e-10);
1415 }
1416
1417 #[test]
1421 fn test_cost_functions() {
1422 let data = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
1423
1424 let l2 = segment_cost(&data.view(), CostFunction::L2).expect("L2 cost should succeed");
1425 assert!(l2 > 0.0, "L2 cost should be positive for non-constant data");
1426
1427 let l1 = segment_cost(&data.view(), CostFunction::L1).expect("L1 cost should succeed");
1428 assert!(l1 > 0.0, "L1 cost should be positive for non-constant data");
1429
1430 let nll = segment_cost(&data.view(), CostFunction::NormalLogLikelihood)
1431 .expect("NLL cost should succeed");
1432 assert!(nll.is_finite(), "NLL cost should be finite");
1433 }
1434
1435 #[test]
1439 fn test_segment_statistics() {
1440 let data = step_signal(50, 50, 0.0, 10.0);
1441 let change_points = vec![50];
1442 let stats = compute_segment_statistics(&data.view(), &change_points)
1443 .expect("segment stats should succeed");
1444
1445 assert_eq!(stats.len(), 2);
1446 assert!((stats[0].mean - 0.0).abs() < 1e-10);
1447 assert!((stats[1].mean - 10.0).abs() < 1e-10);
1448 assert_eq!(stats[0].count, 50);
1449 assert_eq!(stats[1].count, 50);
1450 }
1451
1452 #[test]
1456 fn test_changepoint_summary() {
1457 let data = step_signal(50, 50, 2.0, 8.0);
1458 let result = ChangePointResult {
1459 locations: vec![50],
1460 costs: vec![10.0],
1461 total_cost: 10.0,
1462 };
1463
1464 let summary =
1465 summarize_changepoints(&data.view(), &result).expect("summarize should succeed");
1466
1467 assert_eq!(summary.n_changepoints, 1);
1468 assert_eq!(summary.segments.len(), 2);
1469 assert!((summary.segments[0].mean - 2.0).abs() < 1e-10);
1470 assert!((summary.segments[1].mean - 8.0).abs() < 1e-10);
1471 }
1472
1473 #[test]
1477 fn test_penalty_functions() {
1478 let n = 100;
1479 let bic = compute_penalty(PenaltyType::BIC, n, 0.0);
1480 let aic = compute_penalty(PenaltyType::AIC, n, 0.0);
1481 let mbic = compute_penalty(PenaltyType::MBIC, n, 0.0);
1482 let manual = compute_penalty(PenaltyType::Manual, n, 42.0);
1483
1484 assert!((bic - (100.0_f64).ln()).abs() < 1e-10);
1485 assert!((aic - 2.0).abs() < 1e-10);
1486 assert!((mbic - 3.0 * (100.0_f64).ln()).abs() < 1e-10);
1487 assert!((manual - 42.0).abs() < 1e-10);
1488
1489 assert!(bic < mbic);
1491 }
1492
1493 #[test]
1497 fn test_edge_case_short_series() {
1498 let data = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
1499
1500 let pelt = Pelt::new(10.0, 2);
1502 let result = pelt.detect_mean_change(&data.view());
1503 assert!(result.is_ok());
1505
1506 let bs = BinarySegmentation::new(3, 0.5, 2);
1508 let result = bs.detect(&data.view());
1509 assert!(result.is_ok());
1510 }
1511
1512 #[test]
1516 fn test_edge_case_change_at_boundary() {
1517 let mut early = vec![10.0; 5];
1519 early.extend(vec![0.0; 95]);
1520 add_noise(&mut early);
1521 let data = Array1::from_vec(early);
1522
1523 let pelt = Pelt::new(10.0, 3);
1524 let result = pelt
1525 .detect_mean_change(&data.view())
1526 .expect("PELT should succeed");
1527 assert!(
1529 !result.locations.is_empty(),
1530 "Should detect change near beginning"
1531 );
1532
1533 let mut late = vec![0.0; 95];
1535 late.extend(vec![10.0; 5]);
1536 add_noise(&mut late);
1537 let data_late = Array1::from_vec(late);
1538
1539 let result_late = pelt
1540 .detect_mean_change(&data_late.view())
1541 .expect("PELT should succeed on late change");
1542 assert!(
1543 !result_late.locations.is_empty(),
1544 "Should detect change near end"
1545 );
1546 }
1547
1548 #[test]
1552 fn test_l1_cost_function() {
1553 let data = Array1::from_vec(vec![1.0, 1.0, 1.0, 10.0, 1.0]);
1554 let cost = segment_cost(&data.view(), CostFunction::L1).expect("L1 cost should succeed");
1555 assert!(
1557 (cost - 9.0).abs() < 1e-10,
1558 "L1 cost should be 9.0, got {cost}"
1559 );
1560 }
1561
1562 #[test]
1566 fn test_binseg_l1_cost() {
1567 let mut raw = vec![0.0; 50];
1568 raw.extend(vec![5.0; 50]);
1569 add_noise(&mut raw);
1570 let data = Array1::from_vec(raw);
1571
1572 let bs = BinarySegmentation::with_cost(5, 5.0, 5, CostFunction::L1);
1573 let result = bs.detect(&data.view()).expect("BinSeg L1 should succeed");
1574
1575 assert!(
1576 !result.locations.is_empty(),
1577 "BinSeg with L1 cost should detect change"
1578 );
1579 }
1580
1581 #[test]
1585 fn test_pelt_detect_with_change_type() {
1586 let mut raw = vec![0.0; 50];
1587 raw.extend(vec![5.0; 50]);
1588 add_noise(&mut raw);
1589 let data = Array1::from_vec(raw);
1590
1591 let pelt = Pelt::new(10.0, 5);
1592
1593 let mean_result = pelt
1594 .detect(&data.view(), ChangeType::Mean)
1595 .expect("Mean detect should succeed");
1596 let var_result = pelt
1597 .detect(&data.view(), ChangeType::Variance)
1598 .expect("Variance detect should succeed");
1599 let mv_result = pelt
1600 .detect(&data.view(), ChangeType::MeanAndVariance)
1601 .expect("MeanAndVariance detect should succeed");
1602
1603 assert!(mean_result.total_cost.is_finite());
1604 assert!(var_result.total_cost.is_finite());
1605 assert!(mv_result.total_cost.is_finite());
1606 }
1607
1608 #[test]
1612 fn test_empty_data() {
1613 let data = Array1::<f64>::zeros(0);
1614
1615 let cusum = Cusum::new(0.0, 5.0, 0.5);
1616 let result = cusum
1617 .detect(&data.view())
1618 .expect("CUSUM empty should succeed");
1619 assert!(result.locations.is_empty());
1620
1621 let bayes = BayesianChangePoint::new(0.1, 0.5);
1622 let result = bayes
1623 .detect(&data.view())
1624 .expect("Bayes empty should succeed");
1625 assert!(result.locations.is_empty());
1626 }
1627}