use super::cost::{segment_cost, CostFunction};
#[derive(Debug, Clone)]
pub struct PeltConfig {
pub cost_fn: CostFunction,
pub penalty: f64,
pub min_segment_length: usize,
}
impl Default for PeltConfig {
fn default() -> Self {
Self {
cost_fn: CostFunction::L2,
penalty: 1.0,
min_segment_length: 2,
}
}
}
impl PeltConfig {
pub fn with_bic_penalty(n: usize) -> Self {
Self {
penalty: (n as f64).ln(),
..Default::default()
}
}
pub fn with_aic_penalty() -> Self {
Self {
penalty: 2.0,
..Default::default()
}
}
pub fn cost_function(mut self, cost_fn: CostFunction) -> Self {
self.cost_fn = cost_fn;
self
}
pub fn penalty(mut self, penalty: f64) -> Self {
self.penalty = penalty;
self
}
pub fn min_segment_length(mut self, min_len: usize) -> Self {
self.min_segment_length = min_len.max(1);
self
}
}
#[derive(Debug, Clone)]
pub struct PeltResult {
pub changepoints: Vec<usize>,
pub segments: Vec<(usize, usize)>,
pub cost: f64,
pub n_changepoints: usize,
}
impl PeltResult {
pub fn segment_for_index(&self, index: usize) -> Option<(usize, usize)> {
self.segments
.iter()
.find(|&&(start, end)| index >= start && index < end)
.copied()
}
pub fn segment_means(&self, series: &[f64]) -> Vec<f64> {
self.segments
.iter()
.map(|&(start, end)| {
let segment = &series[start..end];
if segment.is_empty() {
f64::NAN
} else {
segment.iter().sum::<f64>() / segment.len() as f64
}
})
.collect()
}
}
pub fn pelt_detect(series: &[f64], config: &PeltConfig) -> PeltResult {
let n = series.len();
if n < 2 * config.min_segment_length {
return PeltResult {
changepoints: Vec::new(),
segments: vec![(0, n)],
cost: if n > 0 {
segment_cost(series, config.cost_fn)
} else {
0.0
},
n_changepoints: 0,
};
}
let mut f = vec![f64::INFINITY; n + 1];
f[0] = -config.penalty;
let mut cp: Vec<usize> = vec![0; n + 1];
let mut candidates: Vec<usize> = vec![0];
let cum_sum: Vec<f64> = std::iter::once(0.0)
.chain(series.iter().scan(0.0, |acc, &x| {
*acc += x;
Some(*acc)
}))
.collect();
let cum_sum_sq: Vec<f64> = std::iter::once(0.0)
.chain(series.iter().scan(0.0, |acc, &x| {
*acc += x * x;
Some(*acc)
}))
.collect();
let cum_ixy: Vec<f64> = std::iter::once(0.0)
.chain(series.iter().enumerate().scan(0.0, |acc, (i, &x)| {
*acc += i as f64 * x;
Some(*acc)
}))
.collect();
for t in config.min_segment_length..=n {
let mut best_cost = f64::INFINITY;
let mut best_cp = 0;
for &s in &candidates {
if t - s >= config.min_segment_length {
let seg_cost = compute_segment_cost_fast(
s,
t,
&cum_sum,
&cum_sum_sq,
&cum_ixy,
config.cost_fn,
series,
);
let total = f[s] + seg_cost + config.penalty;
if total < best_cost {
best_cost = total;
best_cp = s;
}
}
}
f[t] = best_cost;
cp[t] = best_cp;
candidates.retain(|&s| {
if t - s < config.min_segment_length {
return true;
}
let seg_cost = compute_segment_cost_fast(
s,
t,
&cum_sum,
&cum_sum_sq,
&cum_ixy,
config.cost_fn,
series,
);
f[s] + seg_cost <= f[t]
});
candidates.push(t);
}
let mut changepoints = Vec::new();
let mut t = n;
while t > 0 {
let prev = cp[t];
if prev > 0 {
changepoints.push(prev);
}
t = prev;
}
changepoints.reverse();
let mut segments = Vec::new();
let mut start = 0;
for &cp_idx in &changepoints {
segments.push((start, cp_idx));
start = cp_idx;
}
segments.push((start, n));
let total_cost: f64 = segments
.iter()
.map(|&(s, e)| segment_cost(&series[s..e], config.cost_fn))
.sum();
PeltResult {
n_changepoints: changepoints.len(),
changepoints,
segments,
cost: total_cost,
}
}
fn compute_segment_cost_fast(
start: usize,
end: usize,
cum_sum: &[f64],
cum_sum_sq: &[f64],
cum_ixy: &[f64],
cost_fn: CostFunction,
series: &[f64],
) -> f64 {
let n = end - start;
if n == 0 {
return 0.0;
}
let n_f64 = n as f64;
match cost_fn {
CostFunction::L2 | CostFunction::Normal | CostFunction::MeanVariance => {
let sum_y = cum_sum[end] - cum_sum[start];
let sum_y2 = cum_sum_sq[end] - cum_sum_sq[start];
let mean = sum_y / n_f64;
let l2 = sum_y2 - n_f64 * mean * mean;
match cost_fn {
CostFunction::Normal if n >= 2 => {
let var = l2 / n_f64;
if var > 1e-10 {
n_f64 * var.ln()
} else {
0.0
}
}
CostFunction::MeanVariance if n >= 2 => {
let var = l2 / n_f64;
if var > 1e-10 {
n_f64 * (1.0 + var.ln())
} else {
n_f64
}
}
_ => l2.max(0.0),
}
}
CostFunction::LinearTrend => {
if n < 2 {
return 0.0;
}
let sum_x = n_f64 * (n_f64 - 1.0) / 2.0;
let sum_x2 = n_f64 * (n_f64 - 1.0) * (2.0 * n_f64 - 1.0) / 6.0;
let sum_y = cum_sum[end] - cum_sum[start];
let sum_y2 = cum_sum_sq[end] - cum_sum_sq[start];
let sum_xy =
(cum_ixy[end] - cum_ixy[start]) - (start as f64) * (cum_sum[end] - cum_sum[start]);
let ss_xx = sum_x2 - sum_x * sum_x / n_f64;
let ss_yy = sum_y2 - sum_y * sum_y / n_f64;
let ss_xy = sum_xy - sum_x * sum_y / n_f64;
if ss_xx.abs() < 1e-10 {
ss_yy.max(0.0)
} else {
(ss_yy - ss_xy * ss_xy / ss_xx).max(0.0)
}
}
_ => segment_cost(&series[start..end], cost_fn),
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn pelt_no_changepoint() {
let series = vec![5.0; 20];
let config = PeltConfig::default().penalty(10.0);
let result = pelt_detect(&series, &config);
assert_eq!(result.n_changepoints, 0);
assert_eq!(result.segments.len(), 1);
assert_eq!(result.segments[0], (0, 20));
}
#[test]
fn pelt_one_clear_changepoint() {
let mut series = vec![0.0; 10];
series.extend(vec![10.0; 10]);
let config = PeltConfig::default().penalty(2.0);
let result = pelt_detect(&series, &config);
assert_eq!(result.n_changepoints, 1);
assert_eq!(result.changepoints[0], 10);
assert_eq!(result.segments, vec![(0, 10), (10, 20)]);
}
#[test]
fn pelt_two_changepoints() {
let mut series = vec![0.0; 10];
series.extend(vec![10.0; 10]);
series.extend(vec![0.0; 10]);
let config = PeltConfig::default().penalty(2.0);
let result = pelt_detect(&series, &config);
assert_eq!(result.n_changepoints, 2);
assert!(result.changepoints.contains(&10));
assert!(result.changepoints.contains(&20));
}
#[test]
fn pelt_short_series() {
let series = vec![1.0, 2.0, 3.0];
let config = PeltConfig::default();
let result = pelt_detect(&series, &config);
assert_eq!(result.n_changepoints, 0);
}
#[test]
fn pelt_empty_series() {
let series: Vec<f64> = vec![];
let config = PeltConfig::default();
let result = pelt_detect(&series, &config);
assert_eq!(result.n_changepoints, 0);
assert!(result.changepoints.is_empty());
}
#[test]
fn pelt_high_penalty_no_changepoints() {
let mut series = vec![0.0; 10];
series.extend(vec![100.0; 10]);
let config = PeltConfig::default().penalty(100000.0);
let result = pelt_detect(&series, &config);
assert_eq!(result.n_changepoints, 0);
}
#[test]
fn pelt_low_penalty_many_changepoints() {
let series: Vec<f64> = (0..50).map(|i| i as f64 + ((i * 7) % 3) as f64).collect();
let config = PeltConfig::default().penalty(0.01);
let result = pelt_detect(&series, &config);
let _ = result.n_changepoints;
}
#[test]
fn pelt_config_bic() {
let config = PeltConfig::with_bic_penalty(100);
assert_relative_eq!(config.penalty, 100.0_f64.ln(), epsilon = 1e-10);
}
#[test]
fn pelt_config_aic() {
let config = PeltConfig::with_aic_penalty();
assert_relative_eq!(config.penalty, 2.0, epsilon = 1e-10);
}
#[test]
fn pelt_config_builder() {
let config = PeltConfig::default()
.cost_function(CostFunction::L1)
.penalty(5.0)
.min_segment_length(5);
assert_eq!(config.cost_fn, CostFunction::L1);
assert_relative_eq!(config.penalty, 5.0, epsilon = 1e-10);
assert_eq!(config.min_segment_length, 5);
}
#[test]
fn pelt_segment_means() {
let mut series = vec![1.0; 5];
series.extend(vec![10.0; 5]);
let config = PeltConfig::default().penalty(1.0);
let result = pelt_detect(&series, &config);
let means = result.segment_means(&series);
assert_eq!(means.len(), 2);
assert_relative_eq!(means[0], 1.0, epsilon = 1e-10);
assert_relative_eq!(means[1], 10.0, epsilon = 1e-10);
}
#[test]
fn pelt_segment_for_index() {
let mut series = vec![0.0; 10];
series.extend(vec![10.0; 10]);
let config = PeltConfig::default().penalty(1.0);
let result = pelt_detect(&series, &config);
assert_eq!(result.segment_for_index(5), Some((0, 10)));
assert_eq!(result.segment_for_index(15), Some((10, 20)));
}
#[test]
fn pelt_min_segment_length() {
let mut series = vec![0.0; 2];
series.extend(vec![100.0; 18]);
let config = PeltConfig::default().penalty(1.0).min_segment_length(5);
let result = pelt_detect(&series, &config);
for cp in &result.changepoints {
assert!(*cp >= 5);
}
}
#[test]
fn pelt_linear_trend_detects_slope_change() {
let mut series: Vec<f64> = (0..50).map(|i| i as f64).collect();
series.extend((0..50).map(|i| 100.0 - i as f64));
let config = PeltConfig::default()
.cost_function(CostFunction::LinearTrend)
.penalty(100.0);
let result = pelt_detect(&series, &config);
assert!(result.n_changepoints >= 1);
let cp = result.changepoints[0];
assert!(
(45..=55).contains(&cp),
"Expected changepoint near 50, got {}",
cp
);
}
#[test]
fn pelt_linear_trend_no_change_for_constant_slope() {
let series: Vec<f64> = (0..100).map(|i| 2.0 * i as f64 + 5.0).collect();
let config = PeltConfig::default()
.cost_function(CostFunction::LinearTrend)
.penalty(50.0);
let result = pelt_detect(&series, &config);
assert_eq!(result.n_changepoints, 0);
}
#[test]
fn pelt_mean_variance_detects_variance_shift() {
use rand::{rngs::StdRng, Rng, SeedableRng};
let mut rng = StdRng::seed_from_u64(42);
let mut series: Vec<f64> = (0..50).map(|_| 10.0 + rng.gen_range(-1.0..1.0)).collect();
series.extend((0..50).map(|_| 10.0 + rng.gen_range(-10.0..10.0)));
let config = PeltConfig::default()
.cost_function(CostFunction::MeanVariance)
.penalty(50.0);
let result = pelt_detect(&series, &config);
assert!(result.n_changepoints >= 1);
}
#[test]
fn pelt_mean_variance_detects_joint_change() {
let mut series: Vec<f64> = vec![
-0.5, 0.3, -0.2, 0.8, -0.1, 0.4, -0.6, 0.2, -0.3, 0.5, -0.4, 0.1, -0.7, 0.6, -0.2, 0.3, -0.5, 0.4, -0.1, 0.2,
];
series.extend(vec![
8.0, 12.0, 7.0, 13.0, 9.0, 11.0, 6.0, 14.0, 8.0, 12.0, 7.0, 13.0, 8.0, 12.0, 9.0, 11.0, 6.0, 14.0, 7.0, 13.0,
]);
let config = PeltConfig::default()
.cost_function(CostFunction::MeanVariance)
.penalty(5.0);
let result = pelt_detect(&series, &config);
assert!(result.n_changepoints >= 1);
let cp = result.changepoints[0];
assert!(
(15..=25).contains(&cp),
"Expected changepoint near 20, got {}",
cp
);
}
#[test]
fn pelt_cusum_detects_sustained_shift() {
let mut series: Vec<f64> = vec![
0.1, -0.2, 0.3, -0.1, 0.2, -0.3, 0.1, -0.2, 0.3, -0.1, 0.0, 0.1, -0.1, 0.2, -0.2, 0.1,
-0.3, 0.2, -0.1, 0.0,
];
series.extend(vec![
5.1, 4.9, 5.2, 4.8, 5.0, 5.1, 4.9, 5.2, 4.8, 5.0, 5.1, 4.9, 5.2, 4.8, 5.0, 5.1, 4.9,
5.2, 4.8, 5.0,
]);
let config = PeltConfig::default()
.cost_function(CostFunction::Cusum)
.penalty(2.0);
let result = pelt_detect(&series, &config);
assert!(result.n_changepoints >= 1);
let cp = result.changepoints[0];
assert!(
(15..=25).contains(&cp),
"Expected changepoint near 20, got {}",
cp
);
}
#[test]
fn pelt_cusum_no_change_for_balanced() {
let series: Vec<f64> = (0..40)
.map(|i| if i % 2 == 0 { 1.0 } else { -1.0 })
.collect();
let config = PeltConfig::default()
.cost_function(CostFunction::Cusum)
.penalty(5.0);
let result = pelt_detect(&series, &config);
assert!(result.n_changepoints <= 2);
}
}