Skip to main content

chronix_analytics/anomaly/
cusum.rs

1//! CUSUM (Cumulative Sum) change-point detector.
2//!
3//! Implements the tabular (two-sided) CUSUM algorithm for detecting
4//! shifts in the mean of a sequential process.  Maintains running
5//! cumulative sums for positive and negative shifts; a change point
6//! is flagged when either sum exceeds a decision threshold `h`.
7//!
8//! Reference: Page, E. S. (1954). "Continuous inspection schemes."
9//! Biometrika, 41(1/2), 100-115.
10//!
11//! # Algorithm
12//!
13//! Given a reference (target) mean μ₀ and a minimum detectable shift
14//! magnitude `k` (slack value):
15//!
16//! ```text
17//! S⁺ₜ = max(0, S⁺ₜ₋₁ + (xₜ - μ₀) - k)
18//! S⁻ₜ = max(0, S⁻ₜ₋₁ - (xₜ - μ₀) - k)
19//! ```
20//!
21//! A change point is signalled when `S⁺ₜ > h` (upward shift) or
22//! `S⁻ₜ > h` (downward shift).
23
24use crate::compute::simd_mean;
25
26use crate::anomaly::error::AnomalyError;
27use crate::anomaly::traits::{AnomalyDetector, AnomalyScore, DetectorType, validate_lengths};
28
29/// Two-sided tabular CUSUM detector.
30///
31/// Detects shifts in the mean of a time series by accumulating
32/// deviations from a reference mean.  Suitable for streaming
33/// (O(1) per point after fitting).
34#[derive(serde::Serialize, serde::Deserialize)]
35pub struct CusumDetector {
36    /// Decision threshold — triggers alarm when cumulative sum exceeds this.
37    h: f64,
38    /// Slack (allowance) value — minimum shift magnitude to detect.
39    /// Commonly set to half the expected shift: k = δ/2.
40    k: f64,
41    /// Reference mean (μ₀), estimated during fit.
42    target_mean: f64,
43    /// Reference std dev, used for score normalization.
44    target_std: f64,
45    /// Running positive cumulative sum.
46    s_pos: f64,
47    /// Running negative cumulative sum.
48    s_neg: f64,
49    /// Whether to reset cumulative sums after an alarm.
50    ///
51    /// - `true`  (default): Western Electric variant — resets S⁺/S⁻ to 0
52    ///   after each alarm, making CUSUM re-arm for the next independent shift.
53    /// - `false`: Page (1954) standard — cumulative sums continue growing,
54    ///   capturing sustained or multi-step shifts.
55    reset_after_alarm: bool,
56    /// Whether the detector has been fitted.
57    fitted: bool,
58}
59
60impl CusumDetector {
61    /// Creates a new CUSUM detector.
62    ///
63    /// - `h`: Decision threshold (default: 4.0 × σ). Higher = fewer false alarms.
64    /// - `k`: Slack parameter (default: 0.5 × σ). The minimum shift to detect.
65    pub fn new(h: Option<f64>, k: Option<f64>) -> Self {
66        Self {
67            h: h.unwrap_or(0.0), // will be set from data if 0
68            k: k.unwrap_or(0.0), // will be set from data if 0
69            target_mean: 0.0,
70            target_std: 0.0,
71            s_pos: 0.0,
72            s_neg: 0.0,
73            reset_after_alarm: true,
74            fitted: false,
75        }
76    }
77
78    /// Set whether cumulative sums are reset after each alarm.
79    ///
80    /// `true` = Western Electric variant (default); `false` = Page (1954).
81    #[must_use]
82    pub fn with_reset_after_alarm(mut self, reset: bool) -> Self {
83        self.reset_after_alarm = reset;
84        self
85    }
86
87    /// Normalize the raw CUSUM statistic into [0, 1] using a sigmoid.
88    #[inline]
89    fn normalize(raw: f64, h: f64) -> f64 {
90        let x = (raw / h.max(1e-15) - 1.0) * 4.0;
91        1.0 / (1.0 + (-x).exp())
92    }
93}
94
95impl AnomalyDetector for CusumDetector {
96    fn fit(&mut self, _timestamps: &[i64], values: &[f64]) -> Result<(), AnomalyError> {
97        if values.len() < 10 {
98            return Err(AnomalyError::InsufficientData {
99                min: 10,
100                got: values.len(),
101            });
102        }
103        let _start = std::time::Instant::now();
104
105        self.target_mean = simd_mean(values);
106
107        // Compute std dev
108        let variance = values
109            .iter()
110            .map(|&v| (v - self.target_mean).powi(2))
111            .sum::<f64>()
112            / (values.len() - 1) as f64;
113        self.target_std = variance.sqrt().max(1e-15);
114
115        // Default h = 4σ (common choice for ARL₀ ≈ 170)
116        // Floor values prevent underflow when σ ≈ 0
117        // (constant training data). Without floors, h ≈ 0 causes
118        // every subsequent point to trigger a false alarm.
119        if self.h <= 0.0 {
120            self.h = (4.0 * self.target_std).max(1e-3);
121        }
122        // Default k = 0.5σ (detects ~1σ shifts optimally)
123        if self.k <= 0.0 {
124            self.k = (0.5 * self.target_std).max(1e-4);
125        }
126
127        // Reset cumulative sums
128        self.s_pos = 0.0;
129        self.s_neg = 0.0;
130        self.fitted = true;
131
132        metrics::histogram!("chronix_anomaly_fit_duration_seconds", "method" => "cusum")
133            .record(_start.elapsed().as_secs_f64());
134        Ok(())
135    }
136
137    fn detect(
138        &mut self,
139        timestamps: &[i64],
140        values: &[f64],
141    ) -> Result<Vec<AnomalyScore>, AnomalyError> {
142        validate_lengths(timestamps, values)?;
143        if !self.fitted {
144            return Err(AnomalyError::NotFitted);
145        }
146        let _start = std::time::Instant::now();
147
148        // Reset cumulative sums for batch detection
149        self.s_pos = 0.0;
150        self.s_neg = 0.0;
151
152        let mut scores = Vec::with_capacity(values.len());
153        for (i, &v) in values.iter().enumerate() {
154            let deviation = v - self.target_mean;
155            self.s_pos = (self.s_pos + deviation - self.k).max(0.0);
156            self.s_neg = (self.s_neg - deviation - self.k).max(0.0);
157
158            let raw = self.s_pos.max(self.s_neg);
159            let is_anomaly = raw > self.h;
160
161            let direction = if self.s_pos > self.s_neg {
162                "upward"
163            } else {
164                "downward"
165            };
166
167            scores.push(AnomalyScore {
168                timestamp: timestamps[i],
169                value: v,
170                score: Self::normalize(raw, self.h),
171                is_anomaly,
172                method: DetectorType::Cusum,
173                threshold: self.h,
174                details: format!(
175                    "S+={:.4} S-={:.4} shift={direction}",
176                    self.s_pos, self.s_neg
177                ),
178            });
179
180            // Configurable reset — Western Electric (default) or Page.
181            if is_anomaly && self.reset_after_alarm {
182                self.s_pos = 0.0;
183                self.s_neg = 0.0;
184            }
185        }
186
187        let anomaly_count = scores.iter().filter(|s| s.is_anomaly).count();
188        metrics::counter!("chronix_anomaly_detected_total", "method" => "cusum")
189            .increment(anomaly_count as u64);
190        metrics::histogram!("chronix_anomaly_detect_duration_seconds", "method" => "cusum")
191            .record(_start.elapsed().as_secs_f64());
192        Ok(scores)
193    }
194
195    fn detect_point(&mut self, timestamp: i64, value: f64) -> Result<AnomalyScore, AnomalyError> {
196        if !self.fitted {
197            return Err(AnomalyError::NotFitted);
198        }
199
200        let deviation = value - self.target_mean;
201        self.s_pos = (self.s_pos + deviation - self.k).max(0.0);
202        self.s_neg = (self.s_neg - deviation - self.k).max(0.0);
203
204        let raw = self.s_pos.max(self.s_neg);
205        let is_anomaly = raw > self.h;
206
207        let direction = if self.s_pos > self.s_neg {
208            "upward"
209        } else {
210            "downward"
211        };
212
213        let score = AnomalyScore {
214            timestamp,
215            value,
216            score: Self::normalize(raw, self.h),
217            is_anomaly,
218            method: DetectorType::Cusum,
219            threshold: self.h,
220            details: format!(
221                "S+={:.4} S-={:.4} shift={direction}",
222                self.s_pos, self.s_neg
223            ),
224        };
225
226        // Configurable reset.
227        if is_anomaly && self.reset_after_alarm {
228            self.s_pos = 0.0;
229            self.s_neg = 0.0;
230        }
231
232        Ok(score)
233    }
234
235    fn detector_type(&self) -> DetectorType {
236        DetectorType::Cusum
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    /// Deterministic pseudo-random noise in [-amplitude, amplitude].
245    fn noise(i: usize, amplitude: f64) -> f64 {
246        let mut z = (i as u64)
247            .wrapping_mul(0x9e3779b97f4a7c15)
248            .wrapping_add(0xbf58476d1ce4e5b9);
249        z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
250        z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
251        z = z ^ (z >> 31);
252        let u = (z as f64) / (u64::MAX as f64) * 2.0 - 1.0;
253        u * amplitude
254    }
255
256    #[test]
257    fn cusum_detects_mean_shift() {
258        // Use explicit h/k so detection is independent of noise distribution.
259        // h=5.0, k=1.0 → requires cumulative deviation > 5.0 to alarm.
260        let n = 200usize;
261        let ts: Vec<i64> = (0..n).map(|i| i as i64 * 1_000_000_000).collect();
262        let mut vals: Vec<f64> = Vec::with_capacity(n);
263        for i in 0..n {
264            if i < 100 {
265                vals.push(100.0 + noise(i, 0.5));
266            } else {
267                vals.push(115.0 + noise(i, 0.5)); // shifted up by 15
268            }
269        }
270
271        let mut det = CusumDetector::new(Some(5.0), Some(1.0));
272        det.fit(&ts[..100], &vals[..100]).unwrap();
273
274        let scores = det.detect(&ts, &vals).unwrap();
275        assert_eq!(scores.len(), n);
276
277        // Stable portion: noise is ±0.5, k=1.0 → deviation-k < 0 → S stays 0
278        let anomalies_first_half: Vec<_> = scores[..100].iter().filter(|s| s.is_anomaly).collect();
279        assert!(
280            anomalies_first_half.is_empty(),
281            "no anomalies expected in stable portion, got {}",
282            anomalies_first_half.len()
283        );
284
285        // Shifted portion: deviation ≈15, 15-1=14 per step → triggers in 1 step
286        let anomalies_second_half: Vec<_> = scores[100..].iter().filter(|s| s.is_anomaly).collect();
287        assert!(
288            !anomalies_second_half.is_empty(),
289            "expected at least one change point after mean shift"
290        );
291    }
292
293    #[test]
294    fn cusum_detects_downward_shift() {
295        let n = 200usize;
296        let ts: Vec<i64> = (0..n).map(|i| i as i64 * 1_000_000_000).collect();
297        let mut vals: Vec<f64> = Vec::with_capacity(n);
298        for i in 0..n {
299            if i < 100 {
300                vals.push(100.0 + noise(i, 0.5));
301            } else {
302                vals.push(85.0 + noise(i, 0.5)); // dropped by 15
303            }
304        }
305
306        let mut det = CusumDetector::new(Some(5.0), Some(1.0));
307        det.fit(&ts[..100], &vals[..100]).unwrap();
308
309        let scores = det.detect(&ts, &vals).unwrap();
310        let second_half_anomalies: Vec<_> = scores[100..].iter().filter(|s| s.is_anomaly).collect();
311        assert!(
312            !second_half_anomalies.is_empty(),
313            "expected downward shift detection"
314        );
315        assert!(
316            second_half_anomalies
317                .iter()
318                .any(|s| s.details.contains("downward")),
319            "expected downward direction label"
320        );
321    }
322
323    #[test]
324    fn cusum_stable_no_false_alarms() {
325        // Stationary data with noise ±0.5, explicit k=1.0 → all deviations
326        // are below k, so CUSUM stays at 0.
327        let n = 200usize;
328        let ts: Vec<i64> = (0..n).map(|i| i as i64 * 1_000_000_000).collect();
329        let vals: Vec<f64> = (0..n).map(|i| 50.0 + noise(i, 0.5)).collect();
330
331        let mut det = CusumDetector::new(Some(5.0), Some(1.0));
332        det.fit(&ts, &vals).unwrap();
333
334        let scores = det.detect(&ts, &vals).unwrap();
335        let anomalies: Vec<_> = scores.iter().filter(|s| s.is_anomaly).collect();
336        assert!(
337            anomalies.is_empty(),
338            "no anomalies expected on stationary data, got {}",
339            anomalies.len()
340        );
341    }
342
343    #[test]
344    fn cusum_streaming_detect_point() {
345        let n = 200usize;
346        let ts: Vec<i64> = (0..n).map(|i| i as i64 * 1_000_000_000).collect();
347        let mut vals: Vec<f64> = Vec::with_capacity(n);
348        for i in 0..n {
349            if i < 100 {
350                vals.push(50.0 + noise(i, 0.3));
351            } else {
352                vals.push(65.0 + noise(i, 0.3)); // +15 shift
353            }
354        }
355        let mut det = CusumDetector::new(Some(5.0), Some(1.0));
356        det.fit(&ts[..100], &vals[..100]).unwrap();
357
358        let mut detected_any = false;
359        for (i, &v) in vals.iter().enumerate() {
360            let score = det.detect_point(ts[i], v).unwrap();
361            if i >= 100 && score.is_anomaly {
362                detected_any = true;
363            }
364        }
365        assert!(detected_any, "streaming detect should catch the shift");
366    }
367
368    #[test]
369    fn cusum_insufficient_data() {
370        let mut det = CusumDetector::new(None, None);
371        let result = det.fit(&[1, 2, 3], &[1.0, 2.0, 3.0]);
372        assert!(result.is_err());
373    }
374
375    #[test]
376    fn cusum_custom_thresholds() {
377        let det = CusumDetector::new(Some(10.0), Some(2.0));
378        assert_eq!(det.h, 10.0);
379        assert_eq!(det.k, 2.0);
380    }
381
382    #[test]
383    fn cusum_not_fitted_errors() {
384        let mut det = CusumDetector::new(None, None);
385        let result = det.detect(&[1], &[1.0]);
386        assert!(matches!(result, Err(AnomalyError::NotFitted)));
387        let result = det.detect_point(1, 1.0);
388        assert!(matches!(result, Err(AnomalyError::NotFitted)));
389    }
390
391    #[test]
392    fn cusum_score_in_unit_range() {
393        let n = 200usize;
394        let ts: Vec<i64> = (0..n).map(|i| i as i64 * 1_000_000_000).collect();
395        let mut vals: Vec<f64> = Vec::with_capacity(n);
396        for i in 0..n {
397            if i < 100 {
398                vals.push(50.0 + noise(i, 0.3));
399            } else {
400                vals.push(65.0 + noise(i, 0.3));
401            }
402        }
403
404        let mut det = CusumDetector::new(None, None);
405        det.fit(&ts[..100], &vals[..100]).unwrap();
406
407        let scores = det.detect(&ts, &vals).unwrap();
408        for s in &scores {
409            assert!(
410                (0.0..=1.0).contains(&s.score),
411                "score {} out of [0,1] range",
412                s.score
413            );
414        }
415    }
416}