use super::ChangePointDetector;
use std::f64::consts::PI;
pub struct Bocpd {
pub expected_run_length: f64,
pub map_drop_min: usize,
pub mu0: f64,
pub kappa0: f64,
pub alpha0: f64,
pub beta0: f64,
}
impl Default for Bocpd {
fn default() -> Self {
Self {
expected_run_length: 100.0,
map_drop_min: 2,
mu0: 0.0,
kappa0: 1.0,
alpha0: 1.0,
beta0: 1.0,
}
}
}
impl ChangePointDetector for Bocpd {
fn name(&self) -> &'static str {
"bocpd"
}
fn detect(&self, series: &[(f64, f64)]) -> Vec<f64> {
if series.len() < 2 {
return Vec::new();
}
let hazard = 1.0 / self.expected_run_length.max(1.0);
let mut mu = vec![self.mu0];
let mut kappa = vec![self.kappa0];
let mut alpha = vec![self.alpha0];
let mut beta = vec![self.beta0];
let mut run_posterior: Vec<f64> = vec![1.0];
let mut cps = Vec::new();
let mut prev_map: Option<usize> = None;
for (t, &(ts, x)) in series.iter().enumerate() {
let mut pred = Vec::with_capacity(run_posterior.len());
for i in 0..run_posterior.len() {
pred.push(student_t_pdf(
x,
mu[i],
beta[i] * (kappa[i] + 1.0) / (alpha[i] * kappa[i]),
2.0 * alpha[i],
));
}
let mut new_post = vec![0.0; run_posterior.len() + 1];
for i in 0..run_posterior.len() {
new_post[i + 1] = run_posterior[i] * pred[i] * (1.0 - hazard);
}
new_post[0] = run_posterior
.iter()
.zip(pred.iter())
.map(|(r, p)| r * p * hazard)
.sum();
let z: f64 = new_post.iter().sum();
if z > 0.0 {
for p in &mut new_post {
*p /= z;
}
} else {
new_post[0] = 1.0;
}
let mut new_mu = vec![self.mu0];
let mut new_kappa = vec![self.kappa0];
let mut new_alpha = vec![self.alpha0];
let mut new_beta = vec![self.beta0];
for i in 0..run_posterior.len() {
let k1 = kappa[i] + 1.0;
new_mu.push((kappa[i] * mu[i] + x) / k1);
new_kappa.push(k1);
new_alpha.push(alpha[i] + 0.5);
let diff = x - mu[i];
new_beta.push(beta[i] + kappa[i] * diff * diff / (2.0 * k1));
}
mu = new_mu;
kappa = new_kappa;
alpha = new_alpha;
beta = new_beta;
run_posterior = new_post;
let (map, _) = run_posterior
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, v)| (i, *v))
.unwrap_or((0, 0.0));
if t > 0 {
if let Some(pm) = prev_map {
if pm >= self.map_drop_min && map + self.map_drop_min <= pm {
cps.push(ts);
}
}
}
prev_map = Some(map);
const MAX_RUN: usize = 200;
if run_posterior.len() > MAX_RUN {
run_posterior.truncate(MAX_RUN);
mu.truncate(MAX_RUN);
kappa.truncate(MAX_RUN);
alpha.truncate(MAX_RUN);
beta.truncate(MAX_RUN);
let z: f64 = run_posterior.iter().sum();
if z > 0.0 {
for p in &mut run_posterior {
*p /= z;
}
}
}
}
cps
}
}
fn student_t_pdf(x: f64, mu: f64, s2: f64, nu: f64) -> f64 {
let s = s2.max(f64::MIN_POSITIVE).sqrt();
let z = (x - mu) / s;
let log_num = ln_gamma((nu + 1.0) * 0.5) - ln_gamma(nu * 0.5);
let log_den = 0.5 * (nu * PI).ln() + ((nu + 1.0) * 0.5) * (1.0 + z * z / nu).ln();
(log_num - log_den).exp() / s
}
fn ln_gamma(x: f64) -> f64 {
const P: [f64; 9] = [
0.999_999_999_999_809_9,
676.520_368_121_885,
-1_259.139_216_722_403,
771.323_428_777_653_1,
-176.615_029_162_140_6,
12.507_343_278_686_905,
-0.138_571_095_265_720_1,
9.984_369_578_019_572e-6,
1.505_632_735_149_311_7e-7,
];
const G: f64 = 7.0;
if x < 0.5 {
(PI / (PI * x).sin()).ln() - ln_gamma(1.0 - x)
} else {
let x = x - 1.0;
let mut a = P[0];
for (i, &p) in P.iter().enumerate().skip(1) {
a += p / (x + i as f64);
}
let t = x + G + 0.5;
0.5 * (2.0 * PI).ln() + (x + 0.5) * t.ln() - t + a.ln()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flags_mean_shift() {
let mut series = Vec::new();
for i in 0..80 {
series.push((i as f64, 0.0));
}
for i in 80..160 {
series.push((i as f64, 5.0));
}
let cps = Bocpd::default().detect(&series);
assert!(!cps.is_empty(), "BOCPD should flag a 5σ shift");
}
#[test]
fn quiet_on_constant() {
let series: Vec<(f64, f64)> = (0..100).map(|i| (i as f64, 0.0)).collect();
let cps = Bocpd::default().detect(&series);
assert!(
cps.len() <= 2,
"BOCPD should be quiet on constants, got {cps:?}"
);
}
}