use std::collections::VecDeque;
use super::chi2::{chi2_ppf, chi2_sf};
use super::gpd::{gpd_fit_pwm, gpd_sf};
use super::linalg::{cholesky, mahal2, solve_sym, top_factors};
use super::parade::Parade;
use crate::core::TimeSeries;
use crate::error::Result;
use crate::models::laplace::dist::GaussianMixture;
use crate::models::laplace::LaplaceForecaster;
#[derive(Clone, Copy, Debug)]
pub enum ScatterMode {
Factor { factors: usize, dfloor: f64 },
Shrink { delta: f64 },
}
#[derive(Clone, Debug)]
pub struct MahalanobisConfig {
pub k: usize,
pub alpha: f64,
pub scatter: ScatterMode,
pub guard_p: f64,
pub adapt_after: usize,
pub pot_level: f64,
pub min_exc: usize,
}
impl MahalanobisConfig {
pub fn new(k: usize) -> Self {
Self {
k,
alpha: 0.02,
scatter: ScatterMode::Factor {
factors: 1,
dfloor: 1e-3,
},
guard_p: 0.99,
adapt_after: 10,
pot_level: 0.98,
min_exc: 30,
}
}
pub fn with_alpha(mut self, alpha: f64) -> Self {
self.alpha = alpha;
self
}
pub fn with_scatter(mut self, s: ScatterMode) -> Self {
self.scatter = s;
self
}
pub fn with_guard_p(mut self, g: f64) -> Self {
self.guard_p = g;
self
}
pub fn with_pot_level(mut self, p: f64) -> Self {
self.pot_level = p;
self
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct AnomalyOutput {
pub d2: Option<f64>,
pub p_value: Option<f64>,
pub run: usize,
}
pub struct MahalanobisScorer {
pub cfg: MahalanobisConfig,
mu: Vec<f64>,
sigma: Vec<f64>,
m2: f64,
v2: f64,
exc: VecDeque<f64>,
zeta: f64,
run: usize,
nm: f64,
nv: f64,
n_exc: VecDeque<f64>,
n_zeta: f64,
n_n: usize,
last: AnomalyOutput,
k_effective: usize,
}
pub struct MahalanobisDetector {
parade: Parade,
scorer: MahalanobisScorer,
pend1: Option<GaussianMixture>,
skipped: usize,
}
const EXC_CAP: usize = 250;
const NLP_Z_THRESH: f64 = 2.33; const D2_EXCESS_CAP: f64 = 50.0;
const NLP_EXCESS_CAP: f64 = 50.0;
const NLP_WINSOR_SIGMAS: f64 = 6.0;
impl MahalanobisScorer {
pub fn new(cfg: MahalanobisConfig) -> Self {
Self::with_k(cfg.k, cfg)
}
pub fn with_k(k_effective: usize, cfg: MahalanobisConfig) -> Self {
assert!(cfg.alpha > 0.0 && cfg.alpha < 1.0);
assert!(cfg.guard_p > 0.0 && cfg.guard_p < 1.0);
assert!(cfg.pot_level > 0.0 && cfg.pot_level < 1.0);
assert!(
cfg.min_exc >= 2,
"min_exc < 2 divides by zero in the GPD fit"
);
let mut sigma = vec![0.0f64; k_effective * k_effective];
for i in 0..k_effective {
sigma[i * k_effective + i] = 1.0;
}
Self {
m2: k_effective as f64,
v2: 2.0 * k_effective as f64,
zeta: 1.0 - cfg.pot_level,
n_zeta: 1.0 - cfg.pot_level,
mu: vec![0.0; k_effective],
sigma,
exc: VecDeque::with_capacity(EXC_CAP),
run: 0,
nm: 0.0,
nv: 1.0,
n_exc: VecDeque::with_capacity(EXC_CAP),
n_n: 0,
last: AnomalyOutput::default(),
k_effective,
cfg,
}
}
pub fn score_z(&mut self, z: &[f64], nlp: Option<f64>) -> AnomalyOutput {
let k = self.k_effective;
assert_eq!(z.len(), k, "z length must match k_effective");
let v: Vec<f64> = (0..k).map(|i| z[i] - self.mu[i]).collect();
let d2 = self.compute_d2(&v);
self.last.d2 = Some(d2);
let m2 = self.m2.max(1e-9);
let v2 = self.v2.max(1e-9);
let c = (v2 / (2.0 * m2)).max(1e-9);
let nu = (2.0 * m2 * m2 / v2).clamp(0.5, 1000.0);
let t_pot = c * chi2_ppf(self.cfg.pot_level, nu);
let t_scale = t_pot.max(1e-9);
let mut p_value = if d2 > t_pot && self.exc.len() >= self.cfg.min_exc {
let exc_v: Vec<f64> = self.exc.iter().copied().collect();
let (gamma, sigma_g) = gpd_fit_pwm(&exc_v);
(self.zeta.max(1e-12) * gpd_sf((d2 - t_pot) / t_scale, gamma, sigma_g)).min(1.0)
} else {
chi2_sf(d2 / c, nu)
};
if let Some(nlp_val) = nlp {
if self.n_n >= self.cfg.min_exc {
let ns = self.nv.max(1e-12).sqrt();
let t_n = self.nm + NLP_Z_THRESH * ns;
if nlp_val > t_n && self.n_exc.len() >= self.cfg.min_exc {
let n_exc_v: Vec<f64> = self.n_exc.iter().copied().collect();
let (g2, s2) = gpd_fit_pwm(&n_exc_v);
let denom = (t_n - self.nm).max(1e-9);
let p_n = self.n_zeta.max(1e-12) * gpd_sf((nlp_val - t_n) / denom, g2, s2);
p_value = p_value.min(2.0 * p_n);
}
}
}
self.last.p_value = Some(p_value.clamp(1e-300, 1.0));
let q_guard = c * chi2_ppf(self.cfg.guard_p, nu);
let w = if d2 > q_guard {
self.run += 1;
if self.run > self.cfg.adapt_after {
1.0
} else {
q_guard / d2
}
} else {
self.run = 0;
1.0
};
self.last.run = self.run;
let a = self.cfg.alpha * w;
let d2n = if w == 1.0 { d2 } else { d2.min(q_guard) };
let dm = d2n - self.m2;
self.m2 += self.cfg.alpha * dm;
self.v2 = (1.0 - self.cfg.alpha) * self.v2 + self.cfg.alpha * dm * (d2n - self.m2);
let aw = self.cfg.alpha * w;
let exceed = if d2 > t_pot { 1.0 } else { 0.0 };
self.zeta = (1.0 - aw) * self.zeta + aw * exceed;
if d2 > t_pot {
let e = ((d2 - t_pot) / t_scale).min(D2_EXCESS_CAP);
self.exc.push_back(e);
if self.exc.len() > EXC_CAP {
self.exc.pop_front();
}
}
if let Some(nlp_val) = nlp {
self.n_n += 1;
let ns = self.nv.max(1e-12).sqrt();
let t_n = self.nm + NLP_Z_THRESH * ns;
let nw = nlp_val.min(self.nm + NLP_WINSOR_SIGMAS * ns);
let dn = nw - self.nm;
self.nm += self.cfg.alpha * dn;
self.nv = (1.0 - self.cfg.alpha) * self.nv + self.cfg.alpha * dn * (nw - self.nm);
let n_exceed = if nlp_val > t_n { 1.0 } else { 0.0 };
self.n_zeta = (1.0 - aw) * self.n_zeta + aw * n_exceed;
if nlp_val > t_n {
let denom = (t_n - self.nm).max(1e-9);
let e = ((nlp_val - t_n) / denom).min(NLP_EXCESS_CAP);
self.n_exc.push_back(e);
if self.n_exc.len() > EXC_CAP {
self.n_exc.pop_front();
}
}
}
let delta_pre: Vec<f64> = (0..k).map(|i| z[i] - self.mu[i]).collect();
for i in 0..k {
self.mu[i] += a * delta_pre[i];
}
let delta_post: Vec<f64> = (0..k).map(|i| z[i] - self.mu[i]).collect();
for i in 0..k {
for j in 0..k {
self.sigma[i * k + j] =
(1.0 - a) * self.sigma[i * k + j] + a * delta_pre[i] * delta_post[j];
}
}
self.last
}
pub fn blank(&mut self) {
self.last.d2 = None;
self.last.p_value = None;
}
pub fn last(&self) -> &AnomalyOutput {
&self.last
}
fn compute_d2(&self, v: &[f64]) -> f64 {
let k = self.k_effective;
match self.cfg.scatter {
ScatterMode::Factor { factors, dfloor } => {
let fac = top_factors(&self.sigma, k, factors);
let mean_diag: f64 = (0..k).map(|i| self.sigma[i * k + i]).sum::<f64>() / k as f64;
let floor = (dfloor * mean_diag).max(1e-12);
let d: Vec<f64> = (0..k)
.map(|i| {
let diag = self.sigma[i * k + i];
let sub: f64 = fac.iter().map(|(lam, w)| lam * w[i] * w[i]).sum();
(diag - sub).max(floor)
})
.collect();
let q1: f64 = (0..k).map(|i| v[i] * v[i] / d[i]).sum();
if fac.is_empty() {
return q1;
}
let r = fac.len();
let b: Vec<f64> = fac
.iter()
.map(|(_, w)| (0..k).map(|i| w[i] * v[i] / d[i]).sum::<f64>())
.collect();
let mut big_b = vec![0.0f64; r * r];
for a_ in 0..r {
big_b[a_ * r + a_] = 1.0 / fac[a_].0;
for c_ in a_..r {
let g: f64 = (0..k).map(|i| fac[a_].1[i] * fac[c_].1[i] / d[i]).sum();
big_b[a_ * r + c_] += g;
if c_ != a_ {
big_b[c_ * r + a_] += g;
}
}
}
let x = solve_sym(&big_b, &b, r);
q1 - (0..r).map(|j| b[j] * x[j]).sum::<f64>()
}
ScatterMode::Shrink { delta } => {
let ssh: Vec<f64> = (0..k)
.flat_map(|i| {
(0..k).map(move |j| {
(1.0 - delta) * self.sigma[i * k + j] + if i == j { delta } else { 0.0 }
})
})
.collect();
let l = cholesky(&ssh, k, 1e-12);
mahal2(&l, v, k)
}
}
}
}
impl MahalanobisDetector {
pub fn fit_and_wrap(
base: LaplaceForecaster,
series: &TimeSeries,
cfg: MahalanobisConfig,
) -> Result<Self> {
assert!(cfg.k >= 1);
let parade = Parade::fit_and_wrap(base, series, cfg.k)?;
Ok(Self::from_parade(parade, cfg))
}
pub fn from_parade(parade: Parade, cfg: MahalanobisConfig) -> Self {
let scorer = MahalanobisScorer::new(cfg);
Self {
parade,
scorer,
pend1: None,
skipped: 0,
}
}
pub fn observe(&mut self, y: f64) -> Result<()> {
if !y.is_finite() {
self.skipped += 1;
self.scorer.blank();
return Ok(());
}
let nlp = self.pend1.as_ref().map(|d| {
let lp = d.logpdf(y);
if lp.is_finite() {
-lp
} else {
1e6
}
});
self.parade.observe(y)?;
self.pend1 = self.parade.pending_one_step().cloned();
let z_opt = self.parade.z();
let z: Vec<f64> = if z_opt.iter().all(|v| v.is_some()) {
z_opt.iter().map(|v| v.unwrap()).collect()
} else {
self.scorer.blank();
return Ok(());
};
self.scorer.score_z(&z, nlp);
Ok(())
}
pub fn state(&self) -> &AnomalyOutput {
self.scorer.last()
}
pub fn forecast_dist(&self, h: usize) -> Result<Vec<GaussianMixture>> {
self.parade.forecast_dist(h)
}
pub fn parade(&self) -> &Parade {
&self.parade
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::TimeSeries;
use chrono::{Duration, TimeZone, Utc};
fn synthetic_iid_gaussian(n: usize) -> Vec<f64> {
(0..n)
.map(|i| {
let seed = (i as u64)
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1);
let u1 = ((seed >> 33) as f64 / (1u64 << 31) as f64).clamp(1e-12, 1.0 - 1e-12);
let seed2 = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
let u2 = ((seed2 >> 33) as f64 / (1u64 << 31) as f64).clamp(1e-12, 1.0 - 1e-12);
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
})
.collect()
}
fn ts_from(vals: Vec<f64>) -> TimeSeries {
let base = Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap();
let stamps: Vec<_> = (0..vals.len())
.map(|i| base + Duration::hours(i as i64))
.collect();
TimeSeries::univariate(stamps, vals).unwrap()
}
#[test]
fn detector_warmup_returns_none() {
let vals = synthetic_iid_gaussian(200);
let train = ts_from(vals[..150].to_vec());
let base = LaplaceForecaster::new().auto();
let cfg = MahalanobisConfig::new(4);
let mut det = MahalanobisDetector::fit_and_wrap(base, &train, cfg).unwrap();
for i in 0..3 {
det.observe(vals[150 + i]).unwrap();
assert!(det.state().d2.is_none(), "tick {i} should be warmup");
}
det.observe(vals[153]).unwrap();
assert!(det.state().d2.is_some());
assert!(det.state().p_value.is_some());
}
#[test]
fn detector_flags_injected_spike() {
let mut vals = synthetic_iid_gaussian(500);
let spike_idx = 400;
vals[spike_idx] = 20.0;
let train = ts_from(vals[..200].to_vec());
let base = LaplaceForecaster::new().auto();
let cfg = MahalanobisConfig::new(4);
let mut det = MahalanobisDetector::fit_and_wrap(base, &train, cfg).unwrap();
let mut min_p_before = 1.0;
for i in 200..spike_idx {
det.observe(vals[i]).unwrap();
if let Some(p) = det.state().p_value {
if p < min_p_before {
min_p_before = p;
}
}
}
det.observe(vals[spike_idx]).unwrap();
let p_spike = det.state().p_value.unwrap();
assert!(
p_spike < 0.01,
"spike p-value {p_spike} should be < 0.01 (min pre-spike was {min_p_before})",
);
assert!(det.state().run >= 1);
}
#[test]
fn detector_p_value_uniform_ish_under_null() {
let vals = synthetic_iid_gaussian(3000);
let train = ts_from(vals[..500].to_vec());
let base = LaplaceForecaster::new().auto();
let cfg = MahalanobisConfig::new(4);
let mut det = MahalanobisDetector::fit_and_wrap(base, &train, cfg).unwrap();
let mut ps = Vec::new();
for i in 500..vals.len() {
det.observe(vals[i]).unwrap();
if let Some(p) = det.state().p_value {
ps.push(p);
}
}
assert!(
ps.len() > 2000,
"expected > 2000 p-values, got {}",
ps.len()
);
let mid: usize = ps.iter().filter(|p| **p > 0.1 && **p < 0.9).count();
let low: usize = ps.iter().filter(|p| **p < 0.01).count();
let mid_frac = mid as f64 / ps.len() as f64;
let low_frac = low as f64 / ps.len() as f64;
assert!(
mid_frac > 0.3,
"only {mid_frac:.2} of p-values in (0.1, 0.9)"
);
assert!(
low_frac < 0.25,
"false-alarm rate {low_frac:.3} is too high",
);
}
}