use scirs2_core::ndarray::Array1;
use crate::error::{Result, TimeSeriesError};
#[derive(Debug, Clone)]
pub struct PELTDetector {
penalty: f64,
min_size: usize,
}
impl PELTDetector {
pub fn new(penalty: f64) -> Self {
PELTDetector {
penalty,
min_size: 2,
}
}
pub fn with_min_size(penalty: f64, min_size: usize) -> Self {
PELTDetector {
penalty,
min_size: min_size.max(1),
}
}
pub fn detect(&self, series: &Array1<f64>) -> Result<Vec<usize>> {
let n = series.len();
if n < 2 {
return Err(TimeSeriesError::InsufficientData {
message: "PELT requires at least 2 data points".to_string(),
required: 2,
actual: n,
});
}
let (prefix_sum, prefix_sum2) = compute_prefix_sums(series);
let mut f = vec![f64::INFINITY; n + 1];
f[0] = -self.penalty;
let mut last_cp = vec![0usize; n + 1];
let mut candidates: Vec<usize> = vec![0];
for t in 1..=n {
let mut best_cost = f64::INFINITY;
let mut best_tau = 0;
let mut new_candidates = Vec::new();
for &tau in &candidates {
if t - tau < self.min_size {
new_candidates.push(tau);
continue;
}
let seg_cost = gaussian_cost(&prefix_sum, &prefix_sum2, tau, t);
let total = f[tau] + seg_cost + self.penalty;
if total < best_cost {
best_cost = total;
best_tau = tau;
}
new_candidates.push(tau);
}
f[t] = best_cost;
last_cp[t] = best_tau;
candidates = new_candidates
.into_iter()
.filter(|&tau| {
if t - tau < self.min_size {
return true; }
let seg_cost = gaussian_cost(&prefix_sum, &prefix_sum2, tau, t);
f[tau] + seg_cost + self.penalty <= f[t]
})
.collect();
candidates.push(t);
}
let mut change_points = Vec::new();
let mut t = n;
loop {
let tau = last_cp[t];
if tau == 0 {
break;
}
change_points.push(tau);
t = tau;
}
change_points.reverse();
Ok(change_points)
}
}
fn compute_prefix_sums(series: &Array1<f64>) -> (Vec<f64>, Vec<f64>) {
let n = series.len();
let mut prefix_sum = vec![0.0f64; n + 1];
let mut prefix_sum2 = vec![0.0f64; n + 1];
for i in 0..n {
prefix_sum[i + 1] = prefix_sum[i] + series[i];
prefix_sum2[i + 1] = prefix_sum2[i] + series[i] * series[i];
}
(prefix_sum, prefix_sum2)
}
fn gaussian_cost(prefix_sum: &[f64], prefix_sum2: &[f64], start: usize, end: usize) -> f64 {
let len = end - start;
if len == 0 {
return 0.0;
}
if len == 1 {
return 0.0; }
let sum = prefix_sum[end] - prefix_sum[start];
let sum2 = prefix_sum2[end] - prefix_sum2[start];
let n = len as f64;
let variance = (sum2 - sum * sum / n) / n;
if variance < 1e-300 {
return 0.0;
}
n / 2.0 * variance.ln() + n / 2.0
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::Array1;
#[test]
fn test_pelt_detects_two_change_points() {
let mut data = vec![0.0f64; 50];
data.extend(vec![5.0f64; 50]);
data.extend(vec![0.0f64; 50]);
let series = Array1::from_vec(data);
let detector = PELTDetector::with_min_size(10.0, 2);
let cps = detector.detect(&series).expect("PELT detect failed");
assert!(
!cps.is_empty(),
"Expected at least one change point, got none"
);
let has_cp_near_50 = cps.iter().any(|&cp| (cp as isize - 50).abs() <= 3);
let has_cp_near_100 = cps.iter().any(|&cp| (cp as isize - 100).abs() <= 3);
assert!(
has_cp_near_50,
"Expected change point near 50, got: {cps:?}"
);
assert!(
has_cp_near_100,
"Expected change point near 100, got: {cps:?}"
);
}
#[test]
fn test_pelt_no_change_points_iid() {
let n = 100;
let series: Array1<f64> = Array1::from_iter((0..n).map(|i| {
let h1 = (i as u64).wrapping_mul(1103515245).wrapping_add(12345) & 0x7fffffff;
let h2 = ((i as u64).wrapping_add(7))
.wrapping_mul(1103515245)
.wrapping_add(12345)
& 0x7fffffff;
let u1 = (h1 as f64 / 0x7fffffff_u64 as f64).max(1e-12);
let u2 = h2 as f64 / 0x7fffffff_u64 as f64;
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
}));
let detector = PELTDetector::with_min_size(20.0, 5);
let cps = detector.detect(&series).expect("PELT detect failed");
assert!(
cps.len() <= 2,
"Expected ≤ 2 change points on iid data, got {}: {cps:?}",
cps.len()
);
}
}