use crate::error::{NumRs2Error, Result};
use scirs2_core::ndarray::{s, Array1, Array2, ArrayView1};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CostFunction {
L2,
L1,
NormalLogLikelihood,
}
pub fn segment_cost(segment: &ArrayView1<f64>, cost_fn: CostFunction) -> Result<f64> {
let n = segment.len();
if n == 0 {
return Ok(0.0);
}
match cost_fn {
CostFunction::L2 => {
let mean = segment.iter().sum::<f64>() / n as f64;
let cost = segment.iter().map(|&x| (x - mean).powi(2)).sum::<f64>();
Ok(cost)
}
CostFunction::L1 => {
let mut sorted: Vec<f64> = segment.iter().copied().collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median = if n.is_multiple_of(2) {
(sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
} else {
sorted[n / 2]
};
let cost = segment.iter().map(|&x| (x - median).abs()).sum::<f64>();
Ok(cost)
}
CostFunction::NormalLogLikelihood => {
let nf = n as f64;
let mean = segment.iter().sum::<f64>() / nf;
let variance = segment.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / nf;
if variance <= 1e-15 {
return Ok(nf * 50.0);
}
Ok(0.5 * nf * (2.0 * std::f64::consts::PI * variance).ln() + 0.5 * nf)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PenaltyType {
BIC,
AIC,
MBIC,
Manual,
}
pub fn compute_penalty(penalty_type: PenaltyType, n: usize, manual_value: f64) -> f64 {
let nf = n as f64;
match penalty_type {
PenaltyType::BIC => nf.ln(),
PenaltyType::AIC => 2.0,
PenaltyType::MBIC => 3.0 * nf.ln(),
PenaltyType::Manual => manual_value,
}
}
#[derive(Debug, Clone)]
pub struct SegmentStats {
pub start: usize,
pub end: usize,
pub mean: f64,
pub variance: f64,
pub count: usize,
}
pub fn compute_segment_statistics(
data: &ArrayView1<f64>,
change_points: &[usize],
) -> Result<Vec<SegmentStats>> {
let n = data.len();
if n == 0 {
return Ok(Vec::new());
}
let mut boundaries = vec![0];
for &cp in change_points {
if cp > 0 && cp < n {
boundaries.push(cp);
}
}
boundaries.push(n);
boundaries.sort_unstable();
boundaries.dedup();
let mut stats = Vec::with_capacity(boundaries.len() - 1);
for w in boundaries.windows(2) {
let start = w[0];
let end = w[1];
let count = end - start;
if count == 0 {
continue;
}
let segment = data.slice(s![start..end]);
let mean = segment.iter().sum::<f64>() / count as f64;
let variance = if count > 1 {
segment.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / count as f64
} else {
0.0
};
stats.push(SegmentStats {
start,
end,
mean,
variance,
count,
});
}
Ok(stats)
}
#[derive(Debug, Clone)]
pub struct ChangePointResult {
pub locations: Vec<usize>,
pub costs: Vec<f64>,
pub total_cost: f64,
}
#[derive(Debug, Clone)]
pub struct ChangePointSummary {
pub n_changepoints: usize,
pub locations: Vec<usize>,
pub segments: Vec<SegmentStats>,
pub total_cost: f64,
}
pub fn summarize_changepoints(
data: &ArrayView1<f64>,
result: &ChangePointResult,
) -> Result<ChangePointSummary> {
let segments = compute_segment_statistics(data, &result.locations)?;
Ok(ChangePointSummary {
n_changepoints: result.locations.len(),
locations: result.locations.clone(),
segments,
total_cost: result.total_cost,
})
}
#[derive(Debug, Clone)]
pub struct Cusum {
pub target: f64,
pub threshold: f64,
pub drift: f64,
}
impl Cusum {
pub fn new(target: f64, threshold: f64, drift: f64) -> Self {
Self {
target,
threshold,
drift,
}
}
pub fn detect(&self, data: &ArrayView1<f64>) -> Result<ChangePointResult> {
if data.is_empty() {
return Ok(ChangePointResult {
locations: Vec::new(),
costs: Vec::new(),
total_cost: 0.0,
});
}
let mut locations = Vec::new();
let mut costs = Vec::new();
let mut s_high = 0.0_f64;
let mut s_low = 0.0_f64;
for (i, &x) in data.iter().enumerate() {
s_high = (s_high + (x - self.target) - self.drift).max(0.0);
s_low = (s_low - (x - self.target) - self.drift).max(0.0);
if s_high > self.threshold {
locations.push(i);
costs.push(s_high);
s_high = 0.0;
} else if s_low > self.threshold {
locations.push(i);
costs.push(s_low);
s_low = 0.0;
}
}
let total_cost = costs.iter().sum();
Ok(ChangePointResult {
locations,
costs,
total_cost,
})
}
pub fn forward_cusum(&self, data: &ArrayView1<f64>) -> Result<(Array1<f64>, Array1<f64>)> {
let n = data.len();
if n == 0 {
return Ok((Array1::zeros(0), Array1::zeros(0)));
}
let mut s_high = Array1::zeros(n);
let mut s_low = Array1::zeros(n);
s_high[0] = ((data[0] - self.target) - self.drift).max(0.0);
s_low[0] = (-(data[0] - self.target) - self.drift).max(0.0);
for i in 1..n {
s_high[i] = (s_high[i - 1] + (data[i] - self.target) - self.drift).max(0.0);
s_low[i] = (s_low[i - 1] - (data[i] - self.target) - self.drift).max(0.0);
}
Ok((s_high, s_low))
}
pub fn backward_cusum(&self, data: &ArrayView1<f64>) -> Result<(Array1<f64>, Array1<f64>)> {
let n = data.len();
if n == 0 {
return Ok((Array1::zeros(0), Array1::zeros(0)));
}
let mut s_high = Array1::zeros(n);
let mut s_low = Array1::zeros(n);
let last = n - 1;
s_high[last] = ((data[last] - self.target) - self.drift).max(0.0);
s_low[last] = (-(data[last] - self.target) - self.drift).max(0.0);
for i in (0..last).rev() {
s_high[i] = (s_high[i + 1] + (data[i] - self.target) - self.drift).max(0.0);
s_low[i] = (s_low[i + 1] - (data[i] - self.target) - self.drift).max(0.0);
}
Ok((s_high, s_low))
}
pub fn control_limits(&self, n: usize) -> (Array1<f64>, Array1<f64>) {
let upper = Array1::from_elem(n, self.threshold);
let lower = Array1::from_elem(n, self.threshold);
(upper, lower)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeType {
Mean,
Variance,
MeanAndVariance,
}
#[derive(Debug, Clone)]
pub struct Pelt {
pub penalty: f64,
pub min_size: usize,
pub cost_fn: CostFunction,
pub penalty_type: PenaltyType,
}
impl Pelt {
pub fn new(penalty: f64, min_size: usize) -> Self {
Self {
penalty,
min_size: min_size.max(1),
cost_fn: CostFunction::NormalLogLikelihood,
penalty_type: PenaltyType::Manual,
}
}
pub fn with_options(penalty_type: PenaltyType, cost_fn: CostFunction, min_size: usize) -> Self {
Self {
penalty: 0.0, min_size: min_size.max(1),
cost_fn,
penalty_type,
}
}
pub fn detect(
&self,
data: &ArrayView1<f64>,
change_type: ChangeType,
) -> Result<ChangePointResult> {
match change_type {
ChangeType::Mean => self.detect_mean_change(data),
ChangeType::Variance => self.detect_variance_change(data),
ChangeType::MeanAndVariance => self.detect_mean_variance_change(data),
}
}
pub fn detect_mean_change(&self, data: &ArrayView1<f64>) -> Result<ChangePointResult> {
self.pelt_core(data, CostFunction::L2)
}
pub fn detect_variance_change(&self, data: &ArrayView1<f64>) -> Result<ChangePointResult> {
self.pelt_core(data, CostFunction::NormalLogLikelihood)
}
pub fn detect_mean_variance_change(&self, data: &ArrayView1<f64>) -> Result<ChangePointResult> {
self.pelt_core(data, CostFunction::NormalLogLikelihood)
}
fn pelt_core(
&self,
data: &ArrayView1<f64>,
cost_fn: CostFunction,
) -> Result<ChangePointResult> {
let n = data.len();
if n < 2 * self.min_size {
return Err(NumRs2Error::ValueError(
"Insufficient data for change point detection".to_string(),
));
}
let penalty = if self.penalty_type == PenaltyType::Manual {
self.penalty
} else {
compute_penalty(self.penalty_type, n, self.penalty)
};
let mut f = vec![f64::INFINITY; n + 1];
let mut last_cp = vec![0_usize; n + 1]; f[0] = -penalty;
let mut candidates: Vec<usize> = vec![0];
for t in self.min_size..=n {
let mut best_cost = f64::INFINITY;
let mut best_s = 0_usize;
for &s_val in &candidates {
if t - s_val >= self.min_size {
let seg = data.slice(s![s_val..t]);
let c = segment_cost(&seg, cost_fn)?;
let total = f[s_val] + c + penalty;
if total < best_cost {
best_cost = total;
best_s = s_val;
}
}
}
if best_cost.is_infinite() {
candidates.push(t);
continue;
}
f[t] = best_cost;
last_cp[t] = best_s;
candidates.retain(|&s_val| {
if t - s_val < self.min_size {
return true;
}
if let Ok(c) = segment_cost(&data.slice(s![s_val..t]), cost_fn) {
f[s_val] + c + penalty <= best_cost
} else {
false
}
});
candidates.push(t);
}
let mut locations = Vec::new();
let mut idx = n;
while idx > 0 {
let prev = last_cp[idx];
if prev > 0 {
locations.push(prev);
}
if prev >= idx {
break;
}
idx = prev;
}
locations.sort_unstable();
locations.dedup();
let costs = self.compute_costs_for_segments(data, &locations, cost_fn)?;
let total_cost = if f[n].is_finite() { f[n] } else { 0.0 };
Ok(ChangePointResult {
locations,
costs,
total_cost,
})
}
fn compute_costs_for_segments(
&self,
data: &ArrayView1<f64>,
change_points: &[usize],
cost_fn: CostFunction,
) -> Result<Vec<f64>> {
let n = data.len();
let mut costs = Vec::new();
let mut start = 0;
for &cp in change_points {
if cp > start && cp <= n {
let seg = data.slice(s![start..cp]);
costs.push(segment_cost(&seg, cost_fn)?);
start = cp;
}
}
if start < n {
let seg = data.slice(s![start..n]);
costs.push(segment_cost(&seg, cost_fn)?);
}
Ok(costs)
}
}
#[derive(Debug, Clone)]
pub struct BinarySegmentation {
pub max_changepoints: usize,
pub threshold: f64,
pub min_size: usize,
pub cost_fn: CostFunction,
}
impl BinarySegmentation {
pub fn new(max_changepoints: usize, threshold: f64, min_size: usize) -> Self {
Self {
max_changepoints,
threshold,
min_size: min_size.max(1),
cost_fn: CostFunction::L2,
}
}
pub fn with_cost(
max_changepoints: usize,
threshold: f64,
min_size: usize,
cost_fn: CostFunction,
) -> Self {
Self {
max_changepoints,
threshold,
min_size: min_size.max(1),
cost_fn,
}
}
pub fn detect(&self, data: &ArrayView1<f64>) -> Result<ChangePointResult> {
let n = data.len();
if n < 2 * self.min_size {
return Ok(ChangePointResult {
locations: Vec::new(),
costs: Vec::new(),
total_cost: 0.0,
});
}
let mut change_points = Vec::new();
let mut segments: Vec<(usize, usize)> = vec![(0, n)];
let mut total_cost = 0.0;
for _ in 0..self.max_changepoints {
let mut best_seg_idx = None;
let mut best_cp = 0;
let mut best_gain = 0.0_f64;
for (idx, &(start, end)) in segments.iter().enumerate() {
if end - start < 2 * self.min_size {
continue;
}
if let Ok((cp, gain)) = self.find_best_split(data, start, end) {
if gain > best_gain {
best_gain = gain;
best_cp = cp;
best_seg_idx = Some(idx);
}
}
}
if best_gain < self.threshold {
break;
}
if let Some(idx) = best_seg_idx {
let (start, end) = segments[idx];
segments.remove(idx);
segments.push((start, best_cp));
segments.push((best_cp, end));
change_points.push(best_cp);
total_cost += best_gain;
} else {
break;
}
}
change_points.sort_unstable();
let costs = if change_points.is_empty() {
Vec::new()
} else {
vec![total_cost / change_points.len() as f64; change_points.len()]
};
Ok(ChangePointResult {
locations: change_points,
costs,
total_cost,
})
}
fn find_best_split(
&self,
data: &ArrayView1<f64>,
start: usize,
end: usize,
) -> Result<(usize, f64)> {
if end - start < 2 * self.min_size {
return Err(NumRs2Error::ValueError("Segment too small".to_string()));
}
let full_seg = data.slice(s![start..end]);
let full_cost = segment_cost(&full_seg, self.cost_fn)?;
let mut best_cp = start + self.min_size;
let mut best_gain = f64::NEG_INFINITY;
for t in (start + self.min_size)..(end - self.min_size + 1) {
let left = data.slice(s![start..t]);
let right = data.slice(s![t..end]);
let left_cost = segment_cost(&left, self.cost_fn)?;
let right_cost = segment_cost(&right, self.cost_fn)?;
let gain = full_cost - left_cost - right_cost;
if gain > best_gain {
best_gain = gain;
best_cp = t;
}
}
Ok((best_cp, best_gain))
}
}
#[derive(Debug, Clone)]
pub struct BayesianChangePoint {
pub hazard_rate: f64,
pub prior_mean: f64,
pub prior_var: f64,
pub obs_var: f64,
pub threshold: f64,
}
impl BayesianChangePoint {
pub fn new(hazard_rate: f64, threshold: f64) -> Self {
Self {
hazard_rate: hazard_rate.clamp(1e-10, 1.0 - 1e-10),
prior_mean: 0.0,
prior_var: 1.0,
obs_var: 1.0,
threshold: threshold.clamp(0.0, 1.0),
}
}
pub fn with_prior(
hazard_rate: f64,
threshold: f64,
prior_mean: f64,
prior_var: f64,
obs_var: f64,
) -> Self {
Self {
hazard_rate: hazard_rate.clamp(1e-10, 1.0 - 1e-10),
prior_mean,
prior_var: prior_var.max(1e-10),
obs_var: obs_var.max(1e-10),
threshold: threshold.clamp(0.0, 1.0),
}
}
pub fn detect(&self, data: &ArrayView1<f64>) -> Result<ChangePointResult> {
let n = data.len();
if n < 2 {
return Ok(ChangePointResult {
locations: Vec::new(),
costs: Vec::new(),
total_cost: 0.0,
});
}
let (rlp, _cp_probs) = self.compute_run_length_distribution(data)?;
let mut map_rls = vec![0_usize; n + 1];
let mut map_probs = vec![0.0_f64; n + 1];
for t in 0..=n {
let mut best_r = 0_usize;
let mut best_p = 0.0_f64;
let max_r = t.min(n);
for r in 0..=max_r {
if rlp[[t, r]] > best_p {
best_p = rlp[[t, r]];
best_r = r;
}
}
map_rls[t] = best_r;
map_probs[t] = best_p;
}
let mut locations = Vec::new();
let mut costs = Vec::new();
for t in 2..=n {
let prev_rl = map_rls[t - 1];
let curr_rl = map_rls[t];
let expected_rl = prev_rl + 1;
if expected_rl > 3 && curr_rl < expected_rl / 2 && map_probs[t] >= self.threshold {
let cp_location = t.saturating_sub(curr_rl);
let already_found = locations
.iter()
.any(|&loc: &usize| loc.abs_diff(cp_location) < 5);
if !already_found && cp_location > 0 && cp_location < n {
locations.push(cp_location);
costs.push(map_probs[t]);
}
}
}
let total_cost = costs.iter().sum();
Ok(ChangePointResult {
locations,
costs,
total_cost,
})
}
pub fn compute_run_length_distribution(
&self,
data: &ArrayView1<f64>,
) -> Result<(Array2<f64>, Array1<f64>)> {
let n = data.len();
if n == 0 {
return Ok((Array2::zeros((0, 0)), Array1::zeros(0)));
}
let mut rlp = Array2::zeros((n + 1, n + 1));
rlp[[0, 0]] = 1.0;
let mut cp_probs = Array1::zeros(n + 1);
let mut mu = vec![self.prior_mean; n + 1]; let mut sigma2 = vec![self.prior_var; n + 1];
for t in 0..n {
let x = data[t];
let mut pred_probs = vec![0.0_f64; t + 1];
for r in 0..=t {
let pred_var = sigma2[r] + self.obs_var;
let diff = x - mu[r];
let log_pred = -0.5 * (2.0 * std::f64::consts::PI * pred_var).ln()
- 0.5 * diff * diff / pred_var;
pred_probs[r] = log_pred.exp();
}
let h = self.hazard_rate;
let mut new_rlp = vec![0.0_f64; t + 2];
for r in 0..=t {
new_rlp[r + 1] += rlp[[t, r]] * pred_probs[r] * (1.0 - h);
}
let mut cp_sum = 0.0;
for r in 0..=t {
cp_sum += rlp[[t, r]] * pred_probs[r] * h;
}
new_rlp[0] = cp_sum;
let evidence: f64 = new_rlp.iter().sum();
if evidence > 1e-300 {
for r in 0..=(t + 1) {
rlp[[t + 1, r]] = new_rlp[r] / evidence;
}
}
cp_probs[t + 1] = if evidence > 1e-300 {
new_rlp[0] / evidence
} else {
0.0
};
let mut new_mu = vec![self.prior_mean; t + 2];
let mut new_sigma2 = vec![self.prior_var; t + 2];
for r in 0..=t {
let kalman_gain = sigma2[r] / (sigma2[r] + self.obs_var);
new_mu[r + 1] = mu[r] + kalman_gain * (x - mu[r]);
new_sigma2[r + 1] = sigma2[r] * (1.0 - kalman_gain);
}
new_mu[0] = self.prior_mean;
new_sigma2[0] = self.prior_var;
mu = new_mu;
sigma2 = new_sigma2;
}
let rlp_out = rlp.slice(s![0..=n, 0..=n]).to_owned();
let cp_out = cp_probs.slice(s![0..=n]).to_owned();
Ok((rlp_out, cp_out))
}
pub fn hazard(&self, _run_length: usize) -> f64 {
self.hazard_rate
}
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::Array1;
fn step_signal(n1: usize, n2: usize, mean1: f64, mean2: f64) -> Array1<f64> {
let mut data = vec![mean1; n1];
data.extend(vec![mean2; n2]);
Array1::from_vec(data)
}
fn add_noise(data: &mut [f64]) {
for (i, v) in data.iter_mut().enumerate() {
*v += 0.01 * ((i as f64 * 1.23456).sin());
}
}
#[test]
fn test_cusum_single_mean_shift() {
let mut data = vec![0.0; 50];
data.extend(vec![3.0; 50]);
add_noise(&mut data);
let arr = Array1::from_vec(data);
let cusum = Cusum::new(0.0, 8.0, 0.5);
let result = cusum
.detect(&arr.view())
.expect("CUSUM detect should succeed");
assert!(
!result.locations.is_empty(),
"CUSUM should detect at least one change point"
);
let first = result.locations[0];
assert!(
(40..=70).contains(&first),
"First change point {first} should be near 50"
);
}
#[test]
fn test_cusum_forward_backward() {
let mut data = vec![0.0; 30];
data.extend(vec![4.0; 30]);
add_noise(&mut data);
let arr = Array1::from_vec(data);
let cusum = Cusum::new(0.0, 5.0, 0.5);
let (fwd_high, fwd_low) = cusum
.forward_cusum(&arr.view())
.expect("forward CUSUM should succeed");
let (bwd_high, bwd_low) = cusum
.backward_cusum(&arr.view())
.expect("backward CUSUM should succeed");
assert_eq!(fwd_high.len(), 60);
assert_eq!(bwd_high.len(), 60);
assert!(
fwd_high[59] > fwd_high[0],
"Forward CUSUM should rise after shift"
);
assert!(
bwd_high[0] > bwd_high[59],
"Backward CUSUM should detect shift from the other direction"
);
}
#[test]
fn test_cusum_control_limits() {
let cusum = Cusum::new(0.0, 5.0, 0.5);
let (upper, lower) = cusum.control_limits(100);
assert_eq!(upper.len(), 100);
assert_eq!(lower.len(), 100);
for i in 0..100 {
assert!((upper[i] - 5.0).abs() < 1e-10);
assert!((lower[i] - 5.0).abs() < 1e-10);
}
}
#[test]
fn test_cusum_no_change() {
let mut data = vec![5.0; 100];
add_noise(&mut data);
let arr = Array1::from_vec(data);
let cusum = Cusum::new(5.0, 50.0, 0.5);
let result = cusum.detect(&arr.view()).expect("CUSUM should succeed");
assert!(
result.locations.is_empty(),
"CUSUM should not detect changes in stationary data with high threshold"
);
}
#[test]
fn test_pelt_single_mean_change() {
let mut raw = vec![0.0; 50];
raw.extend(vec![5.0; 50]);
add_noise(&mut raw);
let data = Array1::from_vec(raw);
let pelt = Pelt::new(15.0, 5);
let result = pelt
.detect_mean_change(&data.view())
.expect("PELT mean change should succeed");
assert!(
!result.locations.is_empty(),
"PELT should detect at least one change point"
);
let closest = result
.locations
.iter()
.min_by_key(|&&cp| ((cp as i64) - 50).unsigned_abs())
.copied();
if let Some(cp) = closest {
assert!(
(cp as i64 - 50).unsigned_abs() < 10,
"Closest change point {cp} should be near 50"
);
}
}
#[test]
fn test_pelt_multiple_changepoints() {
let mut raw = vec![0.0; 40];
raw.extend(vec![5.0; 40]);
raw.extend(vec![10.0; 40]);
add_noise(&mut raw);
let data = Array1::from_vec(raw);
let pelt = Pelt::new(15.0, 5);
let result = pelt
.detect_mean_change(&data.view())
.expect("PELT should succeed");
assert!(
result.locations.len() >= 2,
"PELT should detect at least 2 change points, got {}",
result.locations.len()
);
}
#[test]
fn test_pelt_variance_change() {
let mut raw = Vec::with_capacity(100);
for i in 0..50 {
raw.push(5.0 + 0.05 * (i as f64 * 0.73).sin());
}
for i in 50..100 {
raw.push(5.0 + 3.0 * (i as f64 * 0.73).sin());
}
let data = Array1::from_vec(raw);
let pelt = Pelt::new(10.0, 5);
let result = pelt
.detect_variance_change(&data.view())
.expect("PELT variance change should succeed");
assert!(result.total_cost.is_finite(), "Total cost should be finite");
}
#[test]
fn test_pelt_bic_penalty() {
let mut raw = vec![0.0; 60];
raw.extend(vec![6.0; 60]);
add_noise(&mut raw);
let data = Array1::from_vec(raw);
let pelt = Pelt::with_options(PenaltyType::BIC, CostFunction::L2, 5);
let result = pelt
.detect(&data.view(), ChangeType::Mean)
.expect("PELT with BIC should succeed");
assert!(
!result.locations.is_empty(),
"PELT with BIC should detect change"
);
}
#[test]
fn test_pelt_aic_penalty() {
let mut raw = vec![0.0; 50];
raw.extend(vec![5.0; 50]);
add_noise(&mut raw);
let data = Array1::from_vec(raw);
let pelt = Pelt::with_options(PenaltyType::AIC, CostFunction::L2, 5);
let result = pelt
.detect(&data.view(), ChangeType::Mean)
.expect("PELT with AIC should succeed");
assert!(
!result.locations.is_empty(),
"PELT with AIC should detect change"
);
}
#[test]
fn test_pelt_no_change_stationary() {
let mut raw = vec![5.0; 100];
add_noise(&mut raw);
let data = Array1::from_vec(raw);
let pelt = Pelt::new(100.0, 10); let result = pelt
.detect_mean_change(&data.view())
.expect("PELT should succeed on stationary data");
assert!(
result.locations.is_empty(),
"PELT should not detect changes in stationary data with high penalty, got {:?}",
result.locations
);
}
#[test]
fn test_pelt_insufficient_data() {
let data = Array1::from_vec(vec![1.0, 2.0, 3.0]);
let pelt = Pelt::new(5.0, 5);
let result = pelt.detect_mean_change(&data.view());
assert!(result.is_err(), "PELT should fail with insufficient data");
}
#[test]
fn test_binseg_basic_detection() {
let mut raw = vec![0.0; 50];
raw.extend(vec![5.0; 50]);
add_noise(&mut raw);
let data = Array1::from_vec(raw);
let bs = BinarySegmentation::new(5, 5.0, 5);
let result = bs.detect(&data.view()).expect("BinSeg should succeed");
assert!(!result.locations.is_empty(), "BinSeg should detect change");
}
#[test]
fn test_binseg_multiple_changepoints() {
let mut raw = vec![0.0; 40];
raw.extend(vec![4.0; 40]);
raw.extend(vec![8.0; 40]);
add_noise(&mut raw);
let data = Array1::from_vec(raw);
let bs = BinarySegmentation::new(10, 5.0, 5);
let result = bs.detect(&data.view()).expect("BinSeg should succeed");
assert!(
result.locations.len() >= 2,
"BinSeg should detect at least 2 change points, got {}",
result.locations.len()
);
}
#[test]
fn test_pelt_vs_binseg_comparison() {
let mut raw = vec![0.0; 50];
raw.extend(vec![5.0; 50]);
add_noise(&mut raw);
let data = Array1::from_vec(raw);
let pelt = Pelt::new(10.0, 5);
let pelt_result = pelt
.detect_mean_change(&data.view())
.expect("PELT should succeed");
let bs = BinarySegmentation::new(5, 5.0, 5);
let bs_result = bs.detect(&data.view()).expect("BinSeg should succeed");
assert!(
!pelt_result.locations.is_empty(),
"PELT should detect change"
);
assert!(
!bs_result.locations.is_empty(),
"BinSeg should detect change"
);
let pelt_near_50 = pelt_result
.locations
.iter()
.any(|&cp| (cp as i64 - 50).unsigned_abs() < 15);
let bs_near_50 = bs_result
.locations
.iter()
.any(|&cp| (cp as i64 - 50).unsigned_abs() < 15);
assert!(pelt_near_50, "PELT should find change near 50");
assert!(bs_near_50, "BinSeg should find change near 50");
}
#[test]
fn test_bayesian_online_detection() {
let mut raw = vec![0.0; 50];
raw.extend(vec![5.0; 50]);
add_noise(&mut raw);
let data = Array1::from_vec(raw);
let bayes = BayesianChangePoint::with_prior(0.05, 0.3, 0.0, 10.0, 1.0);
let result = bayes
.detect(&data.view())
.expect("Bayesian detection should succeed");
assert!(
!result.locations.is_empty(),
"Bayesian should detect at least one change point"
);
}
#[test]
fn test_bayesian_run_length_distribution() {
let mut raw = vec![0.0; 30];
raw.extend(vec![5.0; 30]);
add_noise(&mut raw);
let data = Array1::from_vec(raw);
let bayes = BayesianChangePoint::with_prior(0.1, 0.3, 0.0, 10.0, 1.0);
let (rlp, cp_probs) = bayes
.compute_run_length_distribution(&data.view())
.expect("Run length computation should succeed");
assert_eq!(rlp.nrows(), 61); assert_eq!(cp_probs.len(), 61);
for t in 1..=60 {
let row_sum: f64 = rlp.row(t).iter().sum();
assert!(
(row_sum - 1.0).abs() < 0.1,
"Row {t} sum = {row_sum} should be near 1.0"
);
}
}
#[test]
fn test_bayesian_hazard_function() {
let bayes = BayesianChangePoint::new(0.05, 0.5);
assert!((bayes.hazard(0) - 0.05).abs() < 1e-10);
assert!((bayes.hazard(100) - 0.05).abs() < 1e-10);
assert!((bayes.hazard(1000) - 0.05).abs() < 1e-10);
}
#[test]
fn test_cost_functions() {
let data = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
let l2 = segment_cost(&data.view(), CostFunction::L2).expect("L2 cost should succeed");
assert!(l2 > 0.0, "L2 cost should be positive for non-constant data");
let l1 = segment_cost(&data.view(), CostFunction::L1).expect("L1 cost should succeed");
assert!(l1 > 0.0, "L1 cost should be positive for non-constant data");
let nll = segment_cost(&data.view(), CostFunction::NormalLogLikelihood)
.expect("NLL cost should succeed");
assert!(nll.is_finite(), "NLL cost should be finite");
}
#[test]
fn test_segment_statistics() {
let data = step_signal(50, 50, 0.0, 10.0);
let change_points = vec![50];
let stats = compute_segment_statistics(&data.view(), &change_points)
.expect("segment stats should succeed");
assert_eq!(stats.len(), 2);
assert!((stats[0].mean - 0.0).abs() < 1e-10);
assert!((stats[1].mean - 10.0).abs() < 1e-10);
assert_eq!(stats[0].count, 50);
assert_eq!(stats[1].count, 50);
}
#[test]
fn test_changepoint_summary() {
let data = step_signal(50, 50, 2.0, 8.0);
let result = ChangePointResult {
locations: vec![50],
costs: vec![10.0],
total_cost: 10.0,
};
let summary =
summarize_changepoints(&data.view(), &result).expect("summarize should succeed");
assert_eq!(summary.n_changepoints, 1);
assert_eq!(summary.segments.len(), 2);
assert!((summary.segments[0].mean - 2.0).abs() < 1e-10);
assert!((summary.segments[1].mean - 8.0).abs() < 1e-10);
}
#[test]
fn test_penalty_functions() {
let n = 100;
let bic = compute_penalty(PenaltyType::BIC, n, 0.0);
let aic = compute_penalty(PenaltyType::AIC, n, 0.0);
let mbic = compute_penalty(PenaltyType::MBIC, n, 0.0);
let manual = compute_penalty(PenaltyType::Manual, n, 42.0);
assert!((bic - (100.0_f64).ln()).abs() < 1e-10);
assert!((aic - 2.0).abs() < 1e-10);
assert!((mbic - 3.0 * (100.0_f64).ln()).abs() < 1e-10);
assert!((manual - 42.0).abs() < 1e-10);
assert!(bic < mbic);
}
#[test]
fn test_edge_case_short_series() {
let data = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
let pelt = Pelt::new(10.0, 2);
let result = pelt.detect_mean_change(&data.view());
assert!(result.is_ok());
let bs = BinarySegmentation::new(3, 0.5, 2);
let result = bs.detect(&data.view());
assert!(result.is_ok());
}
#[test]
fn test_edge_case_change_at_boundary() {
let mut early = vec![10.0; 5];
early.extend(vec![0.0; 95]);
add_noise(&mut early);
let data = Array1::from_vec(early);
let pelt = Pelt::new(10.0, 3);
let result = pelt
.detect_mean_change(&data.view())
.expect("PELT should succeed");
assert!(
!result.locations.is_empty(),
"Should detect change near beginning"
);
let mut late = vec![0.0; 95];
late.extend(vec![10.0; 5]);
add_noise(&mut late);
let data_late = Array1::from_vec(late);
let result_late = pelt
.detect_mean_change(&data_late.view())
.expect("PELT should succeed on late change");
assert!(
!result_late.locations.is_empty(),
"Should detect change near end"
);
}
#[test]
fn test_l1_cost_function() {
let data = Array1::from_vec(vec![1.0, 1.0, 1.0, 10.0, 1.0]);
let cost = segment_cost(&data.view(), CostFunction::L1).expect("L1 cost should succeed");
assert!(
(cost - 9.0).abs() < 1e-10,
"L1 cost should be 9.0, got {cost}"
);
}
#[test]
fn test_binseg_l1_cost() {
let mut raw = vec![0.0; 50];
raw.extend(vec![5.0; 50]);
add_noise(&mut raw);
let data = Array1::from_vec(raw);
let bs = BinarySegmentation::with_cost(5, 5.0, 5, CostFunction::L1);
let result = bs.detect(&data.view()).expect("BinSeg L1 should succeed");
assert!(
!result.locations.is_empty(),
"BinSeg with L1 cost should detect change"
);
}
#[test]
fn test_pelt_detect_with_change_type() {
let mut raw = vec![0.0; 50];
raw.extend(vec![5.0; 50]);
add_noise(&mut raw);
let data = Array1::from_vec(raw);
let pelt = Pelt::new(10.0, 5);
let mean_result = pelt
.detect(&data.view(), ChangeType::Mean)
.expect("Mean detect should succeed");
let var_result = pelt
.detect(&data.view(), ChangeType::Variance)
.expect("Variance detect should succeed");
let mv_result = pelt
.detect(&data.view(), ChangeType::MeanAndVariance)
.expect("MeanAndVariance detect should succeed");
assert!(mean_result.total_cost.is_finite());
assert!(var_result.total_cost.is_finite());
assert!(mv_result.total_cost.is_finite());
}
#[test]
fn test_empty_data() {
let data = Array1::<f64>::zeros(0);
let cusum = Cusum::new(0.0, 5.0, 0.5);
let result = cusum
.detect(&data.view())
.expect("CUSUM empty should succeed");
assert!(result.locations.is_empty());
let bayes = BayesianChangePoint::new(0.1, 0.5);
let result = bayes
.detect(&data.view())
.expect("Bayes empty should succeed");
assert!(result.locations.is_empty());
}
}