#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CostFunction {
L2,
Normal,
}
impl CostFunction {
fn params_per_segment(self) -> usize {
match self {
CostFunction::L2 => 1,
CostFunction::Normal => 2,
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum Penalty {
Bic,
Custom(f64),
}
pub struct Pelt {
cost: CostFunction,
penalty: Penalty,
min_segment_len: usize,
}
#[derive(Debug, Clone)]
pub struct PeltResult {
pub changepoints: Vec<usize>,
}
#[derive(Debug, Clone)]
pub struct MultiPeltResult {
pub changepoints: Vec<usize>,
}
impl Pelt {
pub fn new(cost: CostFunction, penalty: Penalty) -> Option<Self> {
Self::with_min_segment_len(cost, penalty, 2)
}
pub fn with_min_segment_len(
cost: CostFunction,
penalty: Penalty,
min_segment_len: usize,
) -> Option<Self> {
if let Penalty::Custom(p) = penalty {
if !p.is_finite() || p <= 0.0 {
return None;
}
}
if min_segment_len < 2 {
return None;
}
Some(Self {
cost,
penalty,
min_segment_len,
})
}
pub fn detect(&self, data: &[f64]) -> PeltResult {
let n = data.len();
if n < 2 * self.min_segment_len {
return PeltResult {
changepoints: Vec::new(),
};
}
let penalty_value = self.resolve_penalty(n);
let mut cum_sum = vec![0.0_f64; n + 1];
let mut cum_sum_sq = vec![0.0_f64; n + 1];
for i in 0..n {
cum_sum[i + 1] = cum_sum[i] + data[i];
cum_sum_sq[i + 1] = cum_sum_sq[i] + data[i] * data[i];
}
let mut f = vec![0.0_f64; n + 1];
f[0] = -penalty_value;
let mut last_change = vec![0_usize; n + 1];
let mut candidates: Vec<usize> = vec![0];
for t in self.min_segment_len..=n {
let mut best_cost = f64::INFINITY;
let mut best_tau = 0;
for &tau in &candidates {
let seg_len = t - tau;
if seg_len < self.min_segment_len {
continue;
}
let cost = self.segment_cost(&cum_sum, &cum_sum_sq, tau, t);
let total = f[tau] + cost + penalty_value;
if total < best_cost {
best_cost = total;
best_tau = tau;
}
}
f[t] = best_cost;
last_change[t] = best_tau;
candidates.retain(|&tau| {
let seg_len = t - tau;
if seg_len < self.min_segment_len {
return true; }
let cost = self.segment_cost(&cum_sum, &cum_sum_sq, tau, t);
f[tau] + cost < f[t] + penalty_value
});
candidates.push(t);
}
let mut changepoints = Vec::new();
let mut t = n;
while t > 0 {
let tau = last_change[t];
if tau > 0 {
changepoints.push(tau);
}
t = tau;
}
changepoints.sort_unstable();
PeltResult { changepoints }
}
pub fn detect_multi(&self, signals: &[&[f64]]) -> Option<MultiPeltResult> {
if signals.is_empty() {
return Some(MultiPeltResult {
changepoints: Vec::new(),
});
}
let n = signals[0].len();
if signals.iter().any(|s| s.len() != n) {
return None;
}
if n < 2 * self.min_segment_len {
return Some(MultiPeltResult {
changepoints: Vec::new(),
});
}
let n_channels = signals.len();
let penalty_value = self.resolve_penalty(n) * n_channels as f64;
let mut cum_sums: Vec<Vec<f64>> = Vec::with_capacity(n_channels);
let mut cum_sum_sqs: Vec<Vec<f64>> = Vec::with_capacity(n_channels);
for signal in signals {
let mut cs = vec![0.0_f64; n + 1];
let mut css = vec![0.0_f64; n + 1];
for i in 0..n {
cs[i + 1] = cs[i] + signal[i];
css[i + 1] = css[i] + signal[i] * signal[i];
}
cum_sums.push(cs);
cum_sum_sqs.push(css);
}
let mut f = vec![0.0_f64; n + 1];
f[0] = -penalty_value;
let mut last_change = vec![0_usize; n + 1];
let mut candidates: Vec<usize> = vec![0];
for t in self.min_segment_len..=n {
let mut best_cost = f64::INFINITY;
let mut best_tau = 0;
for &tau in &candidates {
let seg_len = t - tau;
if seg_len < self.min_segment_len {
continue;
}
let cost: f64 = (0..n_channels)
.map(|ch| self.segment_cost(&cum_sums[ch], &cum_sum_sqs[ch], tau, t))
.sum();
let total = f[tau] + cost + penalty_value;
if total < best_cost {
best_cost = total;
best_tau = tau;
}
}
f[t] = best_cost;
last_change[t] = best_tau;
candidates.retain(|&tau| {
let seg_len = t - tau;
if seg_len < self.min_segment_len {
return true;
}
let cost: f64 = (0..n_channels)
.map(|ch| self.segment_cost(&cum_sums[ch], &cum_sum_sqs[ch], tau, t))
.sum();
f[tau] + cost < f[t] + penalty_value
});
candidates.push(t);
}
let mut changepoints = Vec::new();
let mut t = n;
while t > 0 {
let tau = last_change[t];
if tau > 0 {
changepoints.push(tau);
}
t = tau;
}
changepoints.sort_unstable();
Some(MultiPeltResult { changepoints })
}
fn resolve_penalty(&self, n: usize) -> f64 {
match self.penalty {
Penalty::Bic => {
let p = self.cost.params_per_segment() as f64;
p * (n as f64).ln()
}
Penalty::Custom(val) => val,
}
}
fn segment_cost(&self, cum_sum: &[f64], cum_sum_sq: &[f64], start: usize, end: usize) -> f64 {
let seg_len = (end - start) as f64;
let sum = cum_sum[end] - cum_sum[start];
let sum_sq = cum_sum_sq[end] - cum_sum_sq[start];
let mean = sum / seg_len;
match self.cost {
CostFunction::L2 => {
sum_sq - seg_len * mean * mean
}
CostFunction::Normal => {
let variance = (sum_sq - seg_len * mean * mean) / seg_len;
if variance <= 0.0 {
seg_len * (f64::MIN_POSITIVE).ln()
} else {
seg_len * variance.ln()
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pelt_valid_construction() {
assert!(Pelt::new(CostFunction::L2, Penalty::Bic).is_some());
assert!(Pelt::new(CostFunction::Normal, Penalty::Bic).is_some());
assert!(Pelt::new(CostFunction::L2, Penalty::Custom(10.0)).is_some());
}
#[test]
fn test_pelt_invalid_custom_penalty() {
assert!(Pelt::new(CostFunction::L2, Penalty::Custom(0.0)).is_none());
assert!(Pelt::new(CostFunction::L2, Penalty::Custom(-1.0)).is_none());
assert!(Pelt::new(CostFunction::L2, Penalty::Custom(f64::NAN)).is_none());
assert!(Pelt::new(CostFunction::L2, Penalty::Custom(f64::INFINITY)).is_none());
}
#[test]
fn test_pelt_invalid_min_segment_len() {
assert!(Pelt::with_min_segment_len(CostFunction::L2, Penalty::Bic, 0).is_none());
assert!(Pelt::with_min_segment_len(CostFunction::L2, Penalty::Bic, 1).is_none());
assert!(Pelt::with_min_segment_len(CostFunction::L2, Penalty::Bic, 2).is_some());
}
#[test]
fn test_pelt_empty_data() {
let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
let result = pelt.detect(&[]);
assert!(result.changepoints.is_empty());
}
#[test]
fn test_pelt_too_short_data() {
let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
let result = pelt.detect(&[1.0, 2.0, 3.0]);
assert!(result.changepoints.is_empty());
}
#[test]
fn test_pelt_constant_data_no_changepoint() {
let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
let data = vec![5.0; 100];
let result = pelt.detect(&data);
assert!(
result.changepoints.is_empty(),
"constant data should have no changepoints, got {:?}",
result.changepoints
);
}
#[test]
fn test_pelt_normal_cost_constant_data() {
let pelt = Pelt::new(CostFunction::Normal, Penalty::Bic).expect("valid");
let data = vec![5.0; 100];
let result = pelt.detect(&data);
assert!(
result.changepoints.is_empty(),
"constant data should have no changepoints with Normal cost, got {:?}",
result.changepoints
);
}
#[test]
fn test_pelt_single_mean_shift_l2() {
let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
let mut data = vec![0.0; 50];
data.extend(vec![5.0; 50]);
let result = pelt.detect(&data);
assert_eq!(
result.changepoints.len(),
1,
"expected 1 changepoint, got {:?}",
result.changepoints
);
assert!(
(result.changepoints[0] as i64 - 50).unsigned_abs() <= 2,
"changepoint should be near index 50, got {}",
result.changepoints[0]
);
}
#[test]
fn test_pelt_single_mean_shift_normal() {
let pelt = Pelt::new(CostFunction::Normal, Penalty::Bic).expect("valid");
let mut data = vec![0.0; 50];
data.extend(vec![5.0; 50]);
let result = pelt.detect(&data);
assert_eq!(
result.changepoints.len(),
1,
"expected 1 changepoint with Normal cost, got {:?}",
result.changepoints
);
assert!(
(result.changepoints[0] as i64 - 50).unsigned_abs() <= 2,
"changepoint should be near index 50, got {}",
result.changepoints[0]
);
}
#[test]
fn test_pelt_two_changepoints() {
let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
let mut data = vec![0.0; 40];
data.extend(vec![5.0; 40]);
data.extend(vec![0.0; 40]);
let result = pelt.detect(&data);
assert_eq!(
result.changepoints.len(),
2,
"expected 2 changepoints, got {:?}",
result.changepoints
);
assert!(
(result.changepoints[0] as i64 - 40).unsigned_abs() <= 2,
"first changepoint near 40, got {}",
result.changepoints[0]
);
assert!(
(result.changepoints[1] as i64 - 80).unsigned_abs() <= 2,
"second changepoint near 80, got {}",
result.changepoints[1]
);
}
#[test]
fn test_pelt_three_changepoints() {
let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
let mut data = vec![0.0; 30];
data.extend(vec![4.0; 30]);
data.extend(vec![-2.0; 30]);
data.extend(vec![3.0; 30]);
let result = pelt.detect(&data);
assert_eq!(
result.changepoints.len(),
3,
"expected 3 changepoints, got {:?}",
result.changepoints
);
for i in 1..result.changepoints.len() {
assert!(
result.changepoints[i] > result.changepoints[i - 1],
"changepoints should be strictly increasing"
);
}
}
#[test]
fn test_pelt_variance_change_normal_cost() {
let pelt = Pelt::new(CostFunction::Normal, Penalty::Bic).expect("valid");
let mut data = Vec::with_capacity(200);
for i in 0..100 {
data.push(if i % 2 == 0 { 0.1 } else { -0.1 });
}
for i in 0..100 {
data.push(if i % 2 == 0 { 5.0 } else { -5.0 });
}
let result = pelt.detect(&data);
assert!(
!result.changepoints.is_empty(),
"Normal cost should detect variance change"
);
let cp = result.changepoints[0];
assert!(
(cp as i64 - 100).unsigned_abs() <= 5,
"variance changepoint should be near 100, got {}",
cp
);
}
#[test]
fn test_pelt_higher_penalty_fewer_changepoints() {
let mut data = vec![0.0; 30];
data.extend(vec![2.0; 30]);
data.extend(vec![0.0; 30]);
let pelt_low = Pelt::new(CostFunction::L2, Penalty::Custom(1.0)).expect("valid");
let pelt_high = Pelt::new(CostFunction::L2, Penalty::Custom(100.0)).expect("valid");
let result_low = pelt_low.detect(&data);
let result_high = pelt_high.detect(&data);
assert!(
result_low.changepoints.len() >= result_high.changepoints.len(),
"higher penalty should produce fewer or equal changepoints: low={}, high={}",
result_low.changepoints.len(),
result_high.changepoints.len()
);
}
#[test]
fn test_pelt_custom_min_segment_len() {
let mut data = vec![0.0; 50];
data.extend(vec![10.0; 50]);
let pelt = Pelt::with_min_segment_len(CostFunction::L2, Penalty::Bic, 10).expect("valid");
let result = pelt.detect(&data);
assert_eq!(
result.changepoints.len(),
1,
"should detect changepoint with min_segment_len=10"
);
let mut boundaries = vec![0];
boundaries.extend_from_slice(&result.changepoints);
boundaries.push(data.len());
for i in 1..boundaries.len() {
let seg_len = boundaries[i] - boundaries[i - 1];
assert!(
seg_len >= 10,
"segment length {} is less than min_segment_len=10",
seg_len
);
}
}
#[test]
fn test_pelt_exact_small_example() {
let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
let data = [0.0, 0.0, 10.0, 10.0];
let result = pelt.detect(&data);
assert_eq!(
result.changepoints.len(),
1,
"expected 1 changepoint in [0,0,10,10], got {:?}",
result.changepoints
);
assert_eq!(
result.changepoints[0], 2,
"changepoint should be at index 2"
);
}
#[test]
fn test_pelt_changepoints_sorted() {
let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
let mut data = vec![0.0; 25];
data.extend(vec![5.0; 25]);
data.extend(vec![-3.0; 25]);
data.extend(vec![7.0; 25]);
let result = pelt.detect(&data);
for i in 1..result.changepoints.len() {
assert!(
result.changepoints[i] > result.changepoints[i - 1],
"changepoints must be strictly increasing: {:?}",
result.changepoints
);
}
}
#[test]
fn test_pelt_bic_penalty_scales() {
let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
let mut data = vec![0.0; 50];
data.extend(vec![0.5; 50]);
let result = pelt.detect(&data);
let _ = result;
}
#[test]
fn test_pelt_segments_cover_data() {
let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
let mut data = vec![0.0; 30];
data.extend(vec![5.0; 30]);
data.extend(vec![0.0; 30]);
let result = pelt.detect(&data);
let mut boundaries = vec![0];
boundaries.extend_from_slice(&result.changepoints);
boundaries.push(data.len());
for i in 1..boundaries.len() {
assert!(
boundaries[i] > boundaries[i - 1],
"segments must not have zero length"
);
}
assert_eq!(
*boundaries.last().expect("non-empty boundaries"),
data.len(),
"segments must cover entire data"
);
}
#[test]
fn test_pelt_downward_shift() {
let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
let mut data = vec![10.0; 50];
data.extend(vec![2.0; 50]);
let result = pelt.detect(&data);
assert_eq!(result.changepoints.len(), 1, "should detect downward shift");
assert!(
(result.changepoints[0] as i64 - 50).unsigned_abs() <= 2,
"changepoint should be near index 50, got {}",
result.changepoints[0]
);
}
#[test]
fn test_cost_function_params() {
assert_eq!(CostFunction::L2.params_per_segment(), 1);
assert_eq!(CostFunction::Normal.params_per_segment(), 2);
}
#[test]
fn test_pelt_multi_single_channel_matches_univariate() {
let pelt = Pelt::new(CostFunction::L2, Penalty::Custom(5.0)).expect("valid");
let mut data = vec![0.0; 50];
data.extend(vec![5.0; 50]);
let uni = pelt.detect(&data);
let multi = pelt.detect_multi(&[&data]).expect("valid");
assert_eq!(uni.changepoints, multi.changepoints);
}
#[test]
fn test_pelt_multi_two_channels() {
let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
let a: Vec<f64> = [vec![0.0; 50], vec![5.0; 50]].concat();
let b: Vec<f64> = [vec![0.0; 50], vec![3.0; 50]].concat();
let result = pelt.detect_multi(&[&a, &b]).expect("valid");
assert_eq!(
result.changepoints.len(),
1,
"expected 1 changepoint, got {:?}",
result.changepoints
);
assert!(
(result.changepoints[0] as i64 - 50).unsigned_abs() <= 2,
"changepoint near 50, got {}",
result.changepoints[0]
);
}
#[test]
fn test_pelt_multi_inconsistent_lengths() {
let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
let a = vec![0.0; 50];
let b = vec![0.0; 30];
assert!(pelt.detect_multi(&[&a[..], &b[..]]).is_none());
}
#[test]
fn test_pelt_multi_empty_signals() {
let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
let result = pelt.detect_multi(&[]).expect("valid");
assert!(result.changepoints.is_empty());
}
#[test]
fn test_pelt_multi_three_channels_two_changepoints() {
let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
let a: Vec<f64> = [vec![0.0; 40], vec![5.0; 40], vec![0.0; 40]].concat();
let b: Vec<f64> = [vec![0.0; 40], vec![3.0; 40], vec![0.0; 40]].concat();
let c: Vec<f64> = [vec![0.0; 40], vec![4.0; 40], vec![0.0; 40]].concat();
let result = pelt.detect_multi(&[&a, &b, &c]).expect("valid");
assert_eq!(
result.changepoints.len(),
2,
"expected 2 changepoints, got {:?}",
result.changepoints
);
}
#[test]
fn test_pelt_multi_short_data() {
let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
let a = [1.0, 2.0];
let b = [3.0, 4.0];
let result = pelt.detect_multi(&[&a, &b]).expect("valid");
assert!(result.changepoints.is_empty());
}
}