chronix_analytics/anomaly/
zscore.rs1use crate::compute::{simd_mean, simd_variance};
4
5use crate::anomaly::error::AnomalyError;
6use crate::anomaly::traits::{
7 AnomalyDetector, AnomalyScore, DetectorType, scale_floor, validate_lengths,
8};
9
10#[derive(serde::Serialize, serde::Deserialize)]
12pub struct ZScoreDetector {
13 threshold: f64,
14 mean: f64,
15 std_dev: f64,
16 fitted: bool,
17}
18
19impl ZScoreDetector {
20 pub fn new(threshold: Option<f64>) -> Self {
22 Self {
23 threshold: threshold.unwrap_or(3.0),
24 mean: 0.0,
25 std_dev: 0.0,
26 fitted: false,
27 }
28 }
29
30 #[inline]
38 fn score_value(&self, value: f64) -> f64 {
39 ((value - self.mean) / scale_floor(self.std_dev, self.mean)).abs()
40 }
41
42 #[inline]
44 fn normalize(raw: f64, threshold: f64) -> f64 {
45 let x = (raw - threshold) * 2.0;
47 1.0 / (1.0 + (-x).exp())
48 }
49}
50
51impl AnomalyDetector for ZScoreDetector {
52 #[tracing::instrument(skip_all, level = "debug")]
53 fn fit(&mut self, _timestamps: &[i64], values: &[f64]) -> Result<(), AnomalyError> {
54 let _start = std::time::Instant::now();
55 if values.len() < 3 {
56 return Err(AnomalyError::InsufficientData {
57 min: 3,
58 got: values.len(),
59 });
60 }
61 self.mean = simd_mean(values);
62 self.std_dev = simd_variance(values, self.mean).sqrt();
63 self.fitted = true;
64 metrics::histogram!("chronix_anomaly_fit_duration_seconds", "method" => "zscore")
65 .record(_start.elapsed().as_secs_f64());
66 Ok(())
67 }
68
69 #[tracing::instrument(skip_all, level = "debug")]
70 fn detect(
71 &mut self,
72 timestamps: &[i64],
73 values: &[f64],
74 ) -> Result<Vec<AnomalyScore>, AnomalyError> {
75 let _start = std::time::Instant::now();
76 validate_lengths(timestamps, values)?;
77 if !self.fitted {
78 return Err(AnomalyError::NotFitted);
79 }
80 let mut scores = Vec::with_capacity(values.len());
81 for (i, &v) in values.iter().enumerate() {
82 let raw = self.score_value(v);
83 let is_anomaly = raw > self.threshold;
84 scores.push(AnomalyScore {
85 timestamp: timestamps[i],
86 value: v,
87 score: Self::normalize(raw, self.threshold),
88 is_anomaly,
89 method: DetectorType::ZScore,
90 threshold: self.threshold,
91 details: format!("z={raw:.4}"),
92 });
93 }
94 let anomaly_count = scores.iter().filter(|s| s.is_anomaly).count();
95 metrics::counter!("chronix_anomaly_detected_total", "method" => "zscore")
96 .increment(anomaly_count as u64);
97 metrics::histogram!("chronix_anomaly_detect_duration_seconds", "method" => "zscore")
98 .record(_start.elapsed().as_secs_f64());
99 Ok(scores)
100 }
101
102 fn detect_point(&mut self, timestamp: i64, value: f64) -> Result<AnomalyScore, AnomalyError> {
103 if !self.fitted {
104 return Err(AnomalyError::NotFitted);
105 }
106 let raw = self.score_value(value);
107 Ok(AnomalyScore {
108 timestamp,
109 value,
110 score: Self::normalize(raw, self.threshold),
111 is_anomaly: raw > self.threshold,
112 method: DetectorType::ZScore,
113 threshold: self.threshold,
114 details: format!("z={raw:.4}"),
115 })
116 }
117
118 fn detector_type(&self) -> DetectorType {
119 DetectorType::ZScore
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 fn make_normal(n: usize, outlier_idx: &[usize], outlier_val: f64) -> (Vec<i64>, Vec<f64>) {
128 let ts: Vec<i64> = (0..n as i64).map(|i| i * 1_000_000_000).collect();
129 let mut vals: Vec<f64> = (0..n)
130 .map(|i| {
131 ((i as f64 * 0.1).sin() * 5.0) + 50.0
134 })
135 .collect();
136 for &idx in outlier_idx {
137 if idx < n {
138 vals[idx] = outlier_val;
139 }
140 }
141 (ts, vals)
142 }
143
144 #[test]
145 fn outliers_flagged() {
146 let (ts, vals) = make_normal(200, &[50, 150], 200.0);
147 let mut det = ZScoreDetector::new(Some(3.0));
148 det.fit(&ts, &vals).unwrap();
149 let scores = det.detect(&ts, &vals).unwrap();
150 assert!(scores[50].is_anomaly);
151 assert!(scores[150].is_anomaly);
152 let normals: usize = scores
154 .iter()
155 .enumerate()
156 .filter(|(i, _)| *i != 50 && *i != 150)
157 .filter(|(_, s)| !s.is_anomaly)
158 .count();
159 assert!(normals > 180);
160 }
161
162 #[test]
163 fn threshold_sensitivity() {
164 let (ts, vals) = make_normal(200, &[100], 100.0);
165 let mut loose = ZScoreDetector::new(Some(5.0));
166 let mut tight = ZScoreDetector::new(Some(1.5));
167 loose.fit(&ts, &vals).unwrap();
168 tight.fit(&ts, &vals).unwrap();
169 let s_loose = loose.detect(&ts, &vals).unwrap();
170 let s_tight = tight.detect(&ts, &vals).unwrap();
171 let count_loose: usize = s_loose.iter().filter(|s| s.is_anomaly).count();
172 let count_tight: usize = s_tight.iter().filter(|s| s.is_anomaly).count();
173 assert!(count_tight >= count_loose);
174 }
175
176 #[test]
177 fn detect_point_streaming() {
178 let (ts, vals) = make_normal(100, &[], 0.0);
179 let mut det = ZScoreDetector::new(None);
180 det.fit(&ts, &vals).unwrap();
181 let s = det.detect_point(999, 50.0).unwrap();
183 assert!(!s.is_anomaly);
184 let s = det.detect_point(1000, 500.0).unwrap();
186 assert!(s.is_anomaly);
187 }
188
189 #[test]
190 fn insufficient_data() {
191 let mut det = ZScoreDetector::new(None);
192 assert!(det.fit(&[0, 1], &[1.0, 2.0]).is_err());
193 }
194
195 #[test]
196 fn detect_mismatched_lengths_returns_error() {
197 let (ts, vals) = make_normal(100, &[], 0.0);
198 let mut det = ZScoreDetector::new(None);
199 det.fit(&ts, &vals).unwrap();
200 let result = det.detect(&ts[..50], &vals);
202 assert!(result.is_err());
203 let err = result.unwrap_err().to_string();
204 assert!(err.contains("timestamps length"), "error: {err}");
205 let result = det.detect(&ts, &vals[..50]);
207 assert!(result.is_err());
208 }
209}