Skip to main content

chronix_analytics/anomaly/
moving_average.rs

1//! Moving Average Residual anomaly detector.
2//!
3//! Computes residuals against a **causal** sliding moving average — the mean
4//! of the `window_size` points *preceding* the one being scored — and flags
5//! points whose residual Z-Score exceeds the threshold.
6//!
7//! # One residual definition
8//!
9//! Excluding the scored point matters: for a window of `w`, including it
10//! shrinks the residual by a factor of `(w-1)/w` (25 % at `w = 5`), so a
11//! batch definition that includes it and a streaming definition that excludes
12//! it disagree on the z-score of the same point by that factor and flag
13//! different points. The causal form is the only one a streaming detector can
14//! compute, so it is the one both paths use, and [`detect`] is literally a
15//! fold of [`detect_point`].
16//!
17//! [`detect`]: AnomalyDetector::detect
18//! [`detect_point`]: AnomalyDetector::detect_point
19
20use crate::anomaly::error::AnomalyError;
21use crate::anomaly::traits::{
22    AnomalyDetector, AnomalyScore, DetectorType, scale_floor, validate_lengths,
23};
24
25/// Detector that compares values against their moving average.
26#[derive(serde::Serialize, serde::Deserialize)]
27pub struct MovingAverageResidualDetector {
28    window_size: usize,
29    threshold: f64,
30    // Fitted residual stats
31    residual_mean: f64,
32    residual_std: f64,
33    // Ring buffer for streaming
34    ring: Vec<f64>,
35    ring_sum: f64,
36    ring_pos: usize,
37    ring_count: usize,
38    /// Pushes since `ring_sum` was last recomputed from the ring itself.
39    since_recompute: u32,
40    fitted: bool,
41}
42
43impl MovingAverageResidualDetector {
44    /// Creates a new detector.  `window_size` defaults to 24, `threshold` to 3.0.
45    ///
46    /// A zero `window_size` is silently clamped to 1 to prevent modulo-by-zero.
47    pub fn new(window_size: Option<usize>, threshold: Option<f64>) -> Self {
48        let ws = window_size.unwrap_or(24).max(1);
49        Self {
50            window_size: ws,
51            threshold: threshold.unwrap_or(3.0),
52            residual_mean: 0.0,
53            residual_std: 0.0,
54            ring: vec![0.0; ws],
55            ring_sum: 0.0,
56            ring_pos: 0,
57            ring_count: 0,
58            since_recompute: 0,
59            fitted: false,
60        }
61    }
62
63    /// Causal moving-average residuals: `r_i = v_i − mean(v_{i−w..i})`, the
64    /// average of the `w` points **before** `i`.
65    ///
66    /// Returns `values.len() − 1` residuals, for `i = 1..n`. `v_0` has no
67    /// preceding window and therefore no residual at all; reporting it as
68    /// `0.0` — which is what a window that includes the scored point does at
69    /// `i = 0` — puts a fabricated exact zero into the calibration sample,
70    /// pulling the fitted mean toward zero and shrinking the fitted spread.
71    fn causal_ma_residuals(values: &[f64], window: usize) -> Vec<f64> {
72        let mut residuals = Vec::with_capacity(values.len().saturating_sub(1));
73        let mut sum = 0.0;
74        for (i, &v) in values.iter().enumerate() {
75            if i > 0 {
76                let count = i.min(window);
77                residuals.push(v - sum / count as f64);
78            }
79            sum += v;
80            if i >= window {
81                sum -= values[i - window];
82            }
83            // Periodic FP drift recomputation every 1024 steps.
84            //
85            // This is a **count-based** recomputation strategy rather than
86            // an error-based one (e.g. Kahan summation or checking the
87            // accumulated drift magnitude). The fixed 1024-step interval
88            // is chosen for simplicity and predictable overhead:
89            //   Keeps worst-case relative error below ~1e-12 for typical
90            //     time-series magnitudes.
91            //   Matches the strategy in RollingCorrelation and
92            //     RollingStatExpr for consistency across the codebase.
93            //   Avoids the per-step branch cost of error-based approaches.
94            if i > 0 && i % 1024 == 0 {
95                let start = (i + 1).saturating_sub(window);
96                sum = values[start..=i].iter().sum();
97            }
98        }
99        residuals
100    }
101
102    /// Empty the rolling window, so the next observation starts a stream.
103    fn reset_window(&mut self) {
104        self.ring = vec![0.0; self.window_size];
105        self.ring_sum = 0.0;
106        self.ring_pos = 0;
107        self.ring_count = 0;
108        self.since_recompute = 0;
109    }
110
111    /// Push one observation into the rolling window, evicting the oldest.
112    fn push(&mut self, value: f64) {
113        if self.ring_count >= self.window_size {
114            self.ring_sum -= self.ring[self.ring_pos];
115        } else {
116            self.ring_count += 1;
117        }
118        self.ring[self.ring_pos] = value;
119        self.ring_sum += value;
120        self.ring_pos = (self.ring_pos + 1) % self.window_size;
121
122        // Same count-based drift recomputation as the batch path.
123        self.since_recompute += 1;
124        if self.since_recompute >= 1024 {
125            self.since_recompute = 0;
126            self.ring_sum = self.ring[..self.ring_count.min(self.window_size)]
127                .iter()
128                .sum();
129        }
130    }
131
132    /// Absolute z-score of one residual. σ is floored relative to the
133    /// residual mean (see [`scale_floor`]) so a constant training window
134    /// scores float noise near 0 instead of at `inf`.
135    #[inline]
136    fn score_residual(&self, residual: f64) -> f64 {
137        ((residual - self.residual_mean) / scale_floor(self.residual_std, self.residual_mean)).abs()
138    }
139
140    #[inline]
141    fn normalize(raw: f64, threshold: f64) -> f64 {
142        let x = (raw - threshold) * 2.0;
143        1.0 / (1.0 + (-x).exp())
144    }
145}
146
147impl AnomalyDetector for MovingAverageResidualDetector {
148    fn fit(&mut self, _timestamps: &[i64], values: &[f64]) -> Result<(), AnomalyError> {
149        let _start = std::time::Instant::now();
150        if values.len() < self.window_size {
151            return Err(AnomalyError::InsufficientData {
152                min: self.window_size,
153                got: values.len(),
154            });
155        }
156
157        // Calibrate on full-window residuals only. The first `window − 1`
158        // are computed against a partial window, so they are systematically
159        // larger in absolute terms and are not comparable with the ones the
160        // detector will score. Including them made a pure linear drift look
161        // anomalous: the residual of a ramp is constant once the window is
162        // full, so the whole apparent spread came from the warm-up, and
163        // every steady-state point then sat several of those "sigmas" from
164        // the warm-up mean.
165        let all = Self::causal_ma_residuals(values, self.window_size);
166        let warm_up = self.window_size.saturating_sub(1).min(all.len());
167        let residuals = if all.len() - warm_up >= 2 {
168            &all[warm_up..]
169        } else {
170            &all[..]
171        };
172        let n = residuals.len() as f64;
173        self.residual_mean = residuals.iter().sum::<f64>() / n;
174        // Use Bessel's correction (n-1) for consistency with simd_std_dev
175        // and features::zscore across the codebase.
176        let denom = if n > 1.0 { n - 1.0 } else { n };
177        self.residual_std = (residuals
178            .iter()
179            .map(|&r| (r - self.residual_mean).powi(2))
180            .sum::<f64>()
181            / denom)
182            .sqrt();
183
184        // Seed the streaming window with the training tail, so a
185        // `detect_point` that continues the fitted series has a full window.
186        self.reset_window();
187        let start = values.len().saturating_sub(self.window_size);
188        for &v in &values[start..] {
189            self.push(v);
190        }
191        self.fitted = true;
192        metrics::histogram!("chronix_anomaly_fit_duration_seconds", "method" => "moving_average")
193            .record(_start.elapsed().as_secs_f64());
194        Ok(())
195    }
196
197    fn detect(
198        &mut self,
199        timestamps: &[i64],
200        values: &[f64],
201    ) -> Result<Vec<AnomalyScore>, AnomalyError> {
202        let _start = std::time::Instant::now();
203        validate_lengths(timestamps, values)?;
204        if !self.fitted {
205            return Err(AnomalyError::NotFitted);
206        }
207        // The first `window_size` points of the batch come back as
208        // "warm-up" and are never anomalies; see `detect_point`.
209        //
210        // A batch is scored as **its own** causal stream: the rolling
211        // window is reset and warmed from the batch's leading points, so
212        // point `i` is compared with the points before it in this batch.
213        //
214        // Without the reset the window carried over from `fit`, so
215        // re-scoring the training data compared its first points with the
216        // *end* of the series — on any trending series that is a residual
217        // the size of the whole trend, and every early point came back an
218        // anomaly. `detect` remains a fold of `detect_point`; the reset is
219        // what makes the fold start where the batch does.
220        self.reset_window();
221        let mut scores = Vec::with_capacity(values.len());
222        for (i, &v) in values.iter().enumerate() {
223            scores.push(self.detect_point(timestamps[i], v)?);
224        }
225        let anomaly_count = scores.iter().filter(|s| s.is_anomaly).count();
226        metrics::counter!("chronix_anomaly_detected_total", "method" => "moving_average")
227            .increment(anomaly_count as u64);
228        metrics::histogram!("chronix_anomaly_detect_duration_seconds", "method" => "moving_average").record(_start.elapsed().as_secs_f64());
229        Ok(scores)
230    }
231
232    fn detect_point(&mut self, timestamp: i64, value: f64) -> Result<AnomalyScore, AnomalyError> {
233        if !self.fitted {
234            return Err(AnomalyError::NotFitted);
235        }
236        let count = self.ring_count.min(self.window_size);
237        // A point whose window is not yet full cannot be judged: its
238        // residual is computed against fewer neighbours than every residual
239        // the detector was calibrated on, so it is systematically smaller
240        // and comparing the two is a units error. The detector says
241        // "warming up" instead of inventing a verdict — which is what it
242        // used to do, flagging the first `window` points of every stream
243        // whose level was not flat.
244        if count < self.window_size {
245            self.push(value);
246            return Ok(AnomalyScore {
247                timestamp,
248                value,
249                score: 0.0,
250                is_anomaly: false,
251                method: DetectorType::MovingAverageResidual,
252                threshold: self.threshold,
253                details: format!("warm-up {count}/{}", self.window_size),
254            });
255        }
256        let residual = value - self.ring_sum / count as f64;
257        let raw = self.score_residual(residual);
258        // The window has to *move*. Without this the "moving" average stayed
259        // frozen at the last training window forever, so a stream that drifted
260        // away from its training level scored a residual that grew without
261        // bound and every point after the drift was an anomaly.
262        self.push(value);
263        Ok(AnomalyScore {
264            timestamp,
265            value,
266            score: Self::normalize(raw, self.threshold),
267            is_anomaly: raw > self.threshold,
268            method: DetectorType::MovingAverageResidual,
269            threshold: self.threshold,
270            details: format!("residual={residual:.4} z={raw:.4}"),
271        })
272    }
273
274    fn detector_type(&self) -> DetectorType {
275        DetectorType::MovingAverageResidual
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn detect_sudden_shift() {
285        let ts: Vec<i64> = (0..200).map(|i| i * 1_000_000_000).collect();
286        let mut vals: Vec<f64> = vec![50.0; 200];
287        // Sudden shift at point 100
288        vals[100] = 200.0;
289
290        let mut det = MovingAverageResidualDetector::new(Some(24), Some(3.0));
291        det.fit(&ts, &vals).unwrap();
292        let scores = det.detect(&ts, &vals).unwrap();
293        assert!(scores[100].is_anomaly);
294    }
295
296    #[test]
297    fn gradual_drift_adapts() {
298        let ts: Vec<i64> = (0..200).map(|i| i * 1_000_000_000).collect();
299        // Gradual linear drift
300        let vals: Vec<f64> = (0..200).map(|i| 50.0 + i as f64 * 0.1).collect();
301        let mut det = MovingAverageResidualDetector::new(Some(24), Some(3.0));
302        det.fit(&ts, &vals).unwrap();
303        let scores = det.detect(&ts, &vals).unwrap();
304        let anomaly_count = scores.iter().filter(|s| s.is_anomaly).count();
305        // Gradual drift should not produce many anomalies
306        assert!(
307            anomaly_count < 10,
308            "too many false positives: {anomaly_count}"
309        );
310    }
311
312    #[test]
313    fn streaming_detect_point() {
314        let ts: Vec<i64> = (0..100).map(|i| i * 1_000_000_000).collect();
315        let vals: Vec<f64> = vec![50.0; 100];
316        let mut det = MovingAverageResidualDetector::new(Some(10), None);
317        det.fit(&ts, &vals).unwrap();
318        let normal = det.detect_point(999, 50.0).unwrap();
319        assert!(!normal.is_anomaly);
320        let spike = det.detect_point(1000, 500.0).unwrap();
321        assert!(spike.is_anomaly);
322    }
323
324    #[test]
325    fn insufficient_data() {
326        let mut det = MovingAverageResidualDetector::new(Some(24), None);
327        let ts: Vec<i64> = (0..10).collect();
328        let vals: Vec<f64> = vec![1.0; 10];
329        assert!(det.fit(&ts, &vals).is_err());
330    }
331}