#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum OutlierMethod {
IQR,
ZScore,
ModifiedZScore,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct OutlierResult {
pub outlier_indices: Vec<usize>,
pub scores: Vec<f64>,
pub threshold: f64,
pub method: OutlierMethod,
}
impl OutlierResult {
pub fn outlier_count(&self) -> usize {
self.outlier_indices.len()
}
pub fn is_outlier(&self, index: usize) -> bool {
self.outlier_indices.contains(&index)
}
pub fn outlier_percentage(&self) -> f64 {
if self.scores.is_empty() {
0.0
} else {
100.0 * self.outlier_indices.len() as f64 / self.scores.len() as f64
}
}
}
#[derive(Debug, Clone)]
pub struct OutlierConfig {
pub method: OutlierMethod,
pub threshold: f64,
}
impl Default for OutlierConfig {
fn default() -> Self {
Self {
method: OutlierMethod::IQR,
threshold: 1.5, }
}
}
impl OutlierConfig {
pub fn iqr(multiplier: f64) -> Self {
Self {
method: OutlierMethod::IQR,
threshold: multiplier,
}
}
pub fn z_score(threshold: f64) -> Self {
Self {
method: OutlierMethod::ZScore,
threshold,
}
}
pub fn modified_z_score(threshold: f64) -> Self {
Self {
method: OutlierMethod::ModifiedZScore,
threshold,
}
}
}
pub fn detect_outliers(series: &[f64], config: &OutlierConfig) -> OutlierResult {
if series.is_empty() {
return OutlierResult {
outlier_indices: Vec::new(),
scores: Vec::new(),
threshold: config.threshold,
method: config.method,
};
}
let (scores, threshold) = match config.method {
OutlierMethod::IQR => compute_iqr_scores(series, config.threshold),
OutlierMethod::ZScore => compute_z_scores(series, config.threshold),
OutlierMethod::ModifiedZScore => compute_modified_z_scores(series, config.threshold),
};
let outlier_indices: Vec<usize> = scores
.iter()
.enumerate()
.filter(|(_, &score)| score > threshold)
.map(|(i, _)| i)
.collect();
OutlierResult {
outlier_indices,
scores,
threshold,
method: config.method,
}
}
pub fn detect_outliers_auto(series: &[f64]) -> OutlierResult {
detect_outliers(series, &OutlierConfig::default())
}
fn compute_iqr_scores(series: &[f64], multiplier: f64) -> (Vec<f64>, f64) {
let mut sorted: Vec<f64> = series.iter().filter(|x| x.is_finite()).copied().collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = sorted.len();
if n < 4 {
return (vec![0.0; series.len()], 1.0);
}
let q1 = sorted[n / 4];
let q3 = sorted[3 * n / 4];
let iqr = q3 - q1;
let lower_bound = q1 - multiplier * iqr;
let upper_bound = q3 + multiplier * iqr;
let scores: Vec<f64> = series
.iter()
.map(|&x| {
if x < lower_bound {
(lower_bound - x) / iqr.max(1e-10)
} else if x > upper_bound {
(x - upper_bound) / iqr.max(1e-10)
} else {
0.0
}
})
.collect();
(scores, 0.0) }
fn compute_z_scores(series: &[f64], threshold: f64) -> (Vec<f64>, f64) {
let n = series.len();
if n < 2 {
return (vec![0.0; n], threshold);
}
let mean: f64 = series.iter().sum::<f64>() / n as f64;
let variance: f64 = series.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
let std_dev = variance.sqrt();
if std_dev < 1e-10 {
return (vec![0.0; n], threshold);
}
let scores: Vec<f64> = series
.iter()
.map(|x| ((x - mean) / std_dev).abs())
.collect();
(scores, threshold)
}
fn compute_modified_z_scores(series: &[f64], threshold: f64) -> (Vec<f64>, f64) {
let n = series.len();
if n < 2 {
return (vec![0.0; n], threshold);
}
let mut sorted: Vec<f64> = series.iter().filter(|x| x.is_finite()).copied().collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median = if sorted.len() % 2 == 0 {
(sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) / 2.0
} else {
sorted[sorted.len() / 2]
};
let mut abs_deviations: Vec<f64> = series.iter().map(|x| (x - median).abs()).collect();
abs_deviations.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mad = if abs_deviations.len() % 2 == 0 {
(abs_deviations[abs_deviations.len() / 2 - 1] + abs_deviations[abs_deviations.len() / 2])
/ 2.0
} else {
abs_deviations[abs_deviations.len() / 2]
};
let k = 0.6745;
let scaled_mad = mad / k;
if scaled_mad < 1e-10 {
return (vec![0.0; n], threshold);
}
let scores: Vec<f64> = series
.iter()
.map(|x| ((x - median) / scaled_mad).abs())
.collect();
(scores, threshold)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_outliers_iqr() {
let mut series: Vec<f64> = (0..100).map(|i| 10.0 + (i as f64 * 0.1).sin()).collect();
series[50] = 100.0; series[75] = -50.0;
let result = detect_outliers_auto(&series);
assert!(result.outlier_count() >= 2);
assert!(result.is_outlier(50));
assert!(result.is_outlier(75));
}
#[test]
fn detect_outliers_z_score() {
let mut series: Vec<f64> = (0..100).map(|_| 10.0).collect();
series[50] = 100.0;
let config = OutlierConfig::z_score(3.0);
let result = detect_outliers(&series, &config);
assert!(result.is_outlier(50));
}
#[test]
fn detect_outliers_modified_z_score() {
let mut series: Vec<f64> = (0..100).map(|i| 10.0 + (i as f64 * 0.1).sin()).collect();
series[25] = 100.0;
series[50] = -50.0;
let config = OutlierConfig::modified_z_score(3.0);
let result = detect_outliers(&series, &config);
assert!(result.outlier_count() >= 1);
assert!(result.is_outlier(25) || result.is_outlier(50));
}
#[test]
fn detect_outliers_no_outliers() {
let series: Vec<f64> = (0..100).map(|i| 10.0 + 0.01 * i as f64).collect();
let result = detect_outliers_auto(&series);
assert!(result.outlier_count() <= 2);
}
#[test]
fn detect_outliers_empty_series() {
let series: Vec<f64> = vec![];
let result = detect_outliers_auto(&series);
assert_eq!(result.outlier_count(), 0);
}
#[test]
fn detect_outliers_constant_series() {
let series = vec![5.0; 100];
let result = detect_outliers_auto(&series);
assert_eq!(result.outlier_count(), 0);
}
#[test]
fn outlier_result_methods() {
let result = OutlierResult {
outlier_indices: vec![10, 50, 90],
scores: vec![0.0; 100],
threshold: 3.0,
method: OutlierMethod::ZScore,
};
assert_eq!(result.outlier_count(), 3);
assert!(result.is_outlier(10));
assert!(!result.is_outlier(11));
assert!((result.outlier_percentage() - 3.0).abs() < 1e-10);
}
#[test]
fn config_methods() {
let iqr = OutlierConfig::iqr(2.0);
assert_eq!(iqr.method, OutlierMethod::IQR);
assert!((iqr.threshold - 2.0).abs() < 1e-10);
let z = OutlierConfig::z_score(2.5);
assert_eq!(z.method, OutlierMethod::ZScore);
assert!((z.threshold - 2.5).abs() < 1e-10);
let mod_z = OutlierConfig::modified_z_score(3.0);
assert_eq!(mod_z.method, OutlierMethod::ModifiedZScore);
}
#[test]
fn default_config() {
let config = OutlierConfig::default();
assert_eq!(config.method, OutlierMethod::IQR);
assert!((config.threshold - 1.5).abs() < 1e-10);
}
#[test]
fn detect_outliers_extreme_values() {
let mut series: Vec<f64> = vec![10.0; 100];
series[0] = f64::MAX / 2.0;
let result = detect_outliers_auto(&series);
assert!(result.is_outlier(0));
}
}