use scirs2_core::ndarray::Array1;
use crate::anomaly::AnomalyMethod as LegacyAnomalyMethod;
use crate::error::{Result, TimeSeriesError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InternalMethod {
ZScore,
MAD,
IQR,
Other, }
impl From<LegacyAnomalyMethod> for InternalMethod {
fn from(m: LegacyAnomalyMethod) -> Self {
match m {
LegacyAnomalyMethod::ZScore => InternalMethod::ZScore,
LegacyAnomalyMethod::ModifiedZScore => InternalMethod::MAD,
LegacyAnomalyMethod::InterquartileRange => InternalMethod::IQR,
_ => InternalMethod::ZScore,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnomalyMethod {
ZScore,
MAD,
IQR,
}
impl From<AnomalyMethod> for InternalMethod {
fn from(m: AnomalyMethod) -> Self {
match m {
AnomalyMethod::ZScore => InternalMethod::ZScore,
AnomalyMethod::MAD => InternalMethod::MAD,
AnomalyMethod::IQR => InternalMethod::IQR,
}
}
}
#[derive(Debug, Clone)]
pub struct AnomalyDetector {
method: InternalMethod,
threshold: f64,
}
impl Default for AnomalyDetector {
fn default() -> Self {
Self::default_new()
}
}
impl AnomalyDetector {
pub fn new() -> Self {
Self::default_new()
}
pub fn with_params(method: AnomalyMethod, threshold: f64) -> Self {
AnomalyDetector {
method: method.into(),
threshold,
}
}
fn default_new() -> Self {
AnomalyDetector {
method: InternalMethod::ZScore,
threshold: 3.0,
}
}
pub fn with_method(mut self, method: LegacyAnomalyMethod) -> Self {
self.method = method.into();
self
}
pub fn with_own_method(mut self, method: AnomalyMethod) -> Self {
self.method = method.into();
self
}
pub fn with_threshold(mut self, threshold: f64) -> Self {
self.threshold = threshold;
self
}
pub fn detect(&self, series: &Array1<f64>) -> Result<Array1<f64>> {
let n = series.len();
if n == 0 {
return Ok(Array1::zeros(0));
}
if n < 3 {
return Err(TimeSeriesError::InsufficientData {
message: "Anomaly detection requires at least 3 data points".to_string(),
required: 3,
actual: n,
});
}
let indices = match self.method {
InternalMethod::ZScore | InternalMethod::Other => self.detect_zscore(series)?,
InternalMethod::MAD => self.detect_mad(series)?,
InternalMethod::IQR => self.detect_iqr(series)?,
};
let mut scores = Array1::<f64>::zeros(n);
for idx in indices {
scores[idx] = 1.0;
}
Ok(scores)
}
pub fn detect_indices(&self, series: &Array1<f64>) -> Result<Vec<usize>> {
let n = series.len();
if n == 0 {
return Ok(vec![]);
}
if n < 3 {
return Err(TimeSeriesError::InsufficientData {
message: "Anomaly detection requires at least 3 data points".to_string(),
required: 3,
actual: n,
});
}
match self.method {
InternalMethod::ZScore | InternalMethod::Other => self.detect_zscore(series),
InternalMethod::MAD => self.detect_mad(series),
InternalMethod::IQR => self.detect_iqr(series),
}
}
fn detect_zscore(&self, series: &Array1<f64>) -> Result<Vec<usize>> {
let n = series.len();
let mean = series.iter().sum::<f64>() / n as f64;
let variance = series.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n as f64;
let std_dev = variance.sqrt();
if std_dev < 1e-300 {
return Ok(vec![]);
}
Ok((0..n)
.filter(|&i| ((series[i] - mean) / std_dev).abs() > self.threshold)
.collect())
}
fn detect_mad(&self, series: &Array1<f64>) -> Result<Vec<usize>> {
let n = series.len();
let mut sorted: Vec<f64> = series.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median = if n % 2 == 1 {
sorted[n / 2]
} else {
(sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
};
let mut abs_devs: Vec<f64> = series.iter().map(|&x| (x - median).abs()).collect();
abs_devs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mad = if n % 2 == 1 {
abs_devs[n / 2]
} else {
(abs_devs[n / 2 - 1] + abs_devs[n / 2]) / 2.0
};
let sigma_hat = 1.4826 * mad;
if sigma_hat < 1e-300 {
return Ok(vec![]);
}
Ok((0..n)
.filter(|&i| ((series[i] - median) / sigma_hat).abs() > self.threshold)
.collect())
}
fn detect_iqr(&self, series: &Array1<f64>) -> Result<Vec<usize>> {
let n = series.len();
let mut sorted: Vec<f64> = series.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let q1 = percentile(&sorted, 25.0);
let q3 = percentile(&sorted, 75.0);
let iqr = q3 - q1;
if iqr < 1e-300 {
return Ok(vec![]);
}
let lower = q1 - self.threshold * iqr;
let upper = q3 + self.threshold * iqr;
Ok((0..n)
.filter(|&i| series[i] < lower || series[i] > upper)
.collect())
}
}
fn percentile(sorted: &[f64], pct: f64) -> f64 {
let n = sorted.len();
if n == 0 {
return 0.0;
}
if n == 1 {
return sorted[0];
}
let rank = pct / 100.0 * (n - 1) as f64;
let lo = rank.floor() as usize;
let hi = (lo + 1).min(n - 1);
let frac = rank - lo as f64;
sorted[lo] * (1.0 - frac) + sorted[hi] * frac
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::Array1;
fn make_normal_series(n: usize) -> Array1<f64> {
Array1::from_iter((0..n).map(|i| {
let h1 = (i as u64).wrapping_mul(1103515245).wrapping_add(12345) & 0x7fffffff;
let h2 = ((i as u64).wrapping_add(7))
.wrapping_mul(1103515245)
.wrapping_add(12345)
& 0x7fffffff;
let u1 = (h1 as f64 / 0x7fffffff_u64 as f64).max(1e-12);
let u2 = h2 as f64 / 0x7fffffff_u64 as f64;
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
}))
}
#[test]
fn test_anomaly_zscore_detects_outliers() {
let data_base = make_normal_series(97);
let mut data: Vec<f64> = data_base.to_vec();
data.push(10.0); data.push(-10.0); data.push(8.0); let series = Array1::from_vec(data);
let detector = AnomalyDetector::with_params(AnomalyMethod::ZScore, 3.0);
let scores = detector.detect(&series).expect("detect failed");
assert!(
scores[97] > 0.5 && scores[98] > 0.5 && scores[99] > 0.5,
"Expected indices 97,98,99 to be anomalies: scores[97]={}, scores[98]={}, scores[99]={}",
scores[97], scores[98], scores[99]
);
}
#[test]
fn test_anomaly_mad_detects_outliers() {
let data_base = make_normal_series(97);
let mut data: Vec<f64> = data_base.to_vec();
data.push(15.0);
data.push(-15.0);
data.push(12.0);
let series = Array1::from_vec(data);
let detector = AnomalyDetector::with_params(AnomalyMethod::MAD, 3.5);
let scores = detector.detect(&series).expect("detect failed");
let detected_any = scores[97] > 0.5 || scores[98] > 0.5 || scores[99] > 0.5;
assert!(
detected_any,
"Expected at least one outlier detected with MAD: scores[97]={}, [98]={}, [99]={}",
scores[97], scores[98], scores[99]
);
}
}