Skip to main content

u_analytics/detection/
cusum.rs

1//! Cumulative Sum (CUSUM) chart for detecting small persistent shifts in process mean.
2//!
3//! # Algorithm
4//!
5//! Given observations x_1, ..., x_n with known process mean mu_0 and standard
6//! deviation sigma, the standardized values are:
7//!
8//! ```text
9//! z_i = (x_i - mu_0) / sigma
10//! ```
11//!
12//! The upper and lower CUSUM statistics are:
13//!
14//! ```text
15//! S_H(i) = max(0, S_H(i-1) + z_i - k)
16//! S_L(i) = max(0, S_L(i-1) - z_i - k)
17//! ```
18//!
19//! A signal is generated when `S_H(i) > h` or `S_L(i) > h`.
20//!
21//! # Parameters
22//!
23//! - **k**: reference value (allowance), typically 0.5 (designed to detect a 1-sigma shift)
24//! - **h**: decision interval, typically 4 or 5
25//!
26//! # Reference
27//!
28//! Page, E.S. (1954). "Continuous inspection schemes", *Biometrika* 41(1-2), pp. 100-115.
29
30/// CUSUM chart parameters and state.
31///
32/// Implements the tabular (two-sided) CUSUM procedure for detecting
33/// both upward and downward shifts in a process mean.
34///
35/// # Examples
36///
37/// ```
38/// use u_analytics::detection::Cusum;
39///
40/// let cusum = Cusum::new(10.0, 1.0).unwrap();
41/// // In-control data
42/// let data = [10.1, 9.8, 10.2, 9.9, 10.0, 10.1, 9.7, 10.3];
43/// let results = cusum.analyze(&data);
44/// assert_eq!(results.len(), data.len());
45/// assert!(cusum.signal_points(&data).is_empty());
46/// ```
47///
48/// # Reference
49///
50/// Page, E.S. (1954). "Continuous inspection schemes", *Biometrika* 41(1-2).
51pub struct Cusum {
52    /// Target process mean (mu_0).
53    target: f64,
54    /// Known process standard deviation (sigma).
55    sigma: f64,
56    /// Reference value (allowance), default 0.5.
57    k: f64,
58    /// Decision interval, default 5.0.
59    h: f64,
60}
61
62/// Result of CUSUM analysis for a single observation.
63#[derive(Debug, Clone)]
64pub struct CusumResult {
65    /// Upper cumulative sum S_H(i).
66    pub s_upper: f64,
67    /// Lower cumulative sum S_L(i).
68    pub s_lower: f64,
69    /// Whether the decision interval was exceeded (S_H > h or S_L > h).
70    pub signal: bool,
71    /// Index of this observation in the data sequence.
72    pub index: usize,
73}
74
75impl Cusum {
76    /// Creates a new CUSUM chart with the given target mean and standard deviation.
77    ///
78    /// Uses default parameters k=0.5 (detects 1-sigma shift) and h=5.0.
79    ///
80    /// # Returns
81    ///
82    /// `None` if `sigma` is not positive or finite, or if `target` is not finite.
83    ///
84    /// # Reference
85    ///
86    /// Page (1954), *Biometrika* 41(1-2). Default k=0.5 is optimal for detecting
87    /// a shift of 1 sigma; h=5 gives ARL_0 ~ 465.
88    pub fn new(target: f64, sigma: f64) -> Option<Self> {
89        Self::with_params(target, sigma, 0.5, 5.0)
90    }
91
92    /// Creates a CUSUM chart with custom k and h parameters.
93    ///
94    /// # Parameters
95    ///
96    /// - `target`: Process target mean (mu_0)
97    /// - `sigma`: Process standard deviation (must be positive and finite)
98    /// - `k`: Reference value / allowance (must be non-negative and finite)
99    /// - `h`: Decision interval (must be positive and finite)
100    ///
101    /// # Returns
102    ///
103    /// `None` if any parameter is invalid.
104    pub fn with_params(target: f64, sigma: f64, k: f64, h: f64) -> Option<Self> {
105        if !target.is_finite() {
106            return None;
107        }
108        if !sigma.is_finite() || sigma <= 0.0 {
109            return None;
110        }
111        if !k.is_finite() || k < 0.0 {
112            return None;
113        }
114        if !h.is_finite() || h <= 0.0 {
115            return None;
116        }
117        Some(Self {
118            target,
119            sigma,
120            k,
121            h,
122        })
123    }
124
125    /// Analyzes a sequence of observations and returns CUSUM statistics for each point.
126    ///
127    /// The upper CUSUM detects upward shifts; the lower CUSUM detects downward shifts.
128    /// Both are initialized to zero. Non-finite values in the data are skipped
129    /// (their index is still consumed).
130    ///
131    /// # Examples
132    ///
133    /// ```
134    /// use u_analytics::detection::Cusum;
135    ///
136    /// let cusum = Cusum::new(10.0, 1.0).unwrap();
137    /// // Data with upward shift
138    /// let mut data: Vec<f64> = vec![10.0; 10];
139    /// data.extend(vec![12.0; 10]); // shift of 2 sigma
140    /// let signals = cusum.signal_points(&data);
141    /// assert!(!signals.is_empty()); // shift detected
142    /// ```
143    ///
144    /// # Complexity
145    ///
146    /// Time: O(n), Space: O(n)
147    pub fn analyze(&self, data: &[f64]) -> Vec<CusumResult> {
148        let mut results = Vec::with_capacity(data.len());
149        let mut s_upper = 0.0_f64;
150        let mut s_lower = 0.0_f64;
151
152        for (i, &x) in data.iter().enumerate() {
153            if !x.is_finite() {
154                // Non-finite observations: carry forward previous CUSUM values, no signal.
155                results.push(CusumResult {
156                    s_upper,
157                    s_lower,
158                    signal: false,
159                    index: i,
160                });
161                continue;
162            }
163
164            let z = (x - self.target) / self.sigma;
165            s_upper = (s_upper + z - self.k).max(0.0);
166            s_lower = (s_lower - z - self.k).max(0.0);
167
168            let signal = s_upper > self.h || s_lower > self.h;
169
170            results.push(CusumResult {
171                s_upper,
172                s_lower,
173                signal,
174                index: i,
175            });
176        }
177
178        results
179    }
180
181    /// Returns the indices of observations where a CUSUM signal occurred.
182    ///
183    /// A signal occurs when the upper or lower cumulative sum exceeds the
184    /// decision interval h.
185    ///
186    /// # Complexity
187    ///
188    /// Time: O(n), Space: O(k) where k is the number of signal points
189    pub fn signal_points(&self, data: &[f64]) -> Vec<usize> {
190        self.analyze(data)
191            .into_iter()
192            .filter(|r| r.signal)
193            .map(|r| r.index)
194            .collect()
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn test_cusum_in_control_no_signals() {
204        // Data centered around target with small noise — should produce no signals.
205        let target = 10.0;
206        let sigma = 1.0;
207        let cusum = Cusum::new(target, sigma).expect("valid params");
208
209        // 50 points right at the target: z_i = 0 for all i
210        let data: Vec<f64> = vec![target; 50];
211        let results = cusum.analyze(&data);
212
213        assert_eq!(results.len(), 50);
214        for r in &results {
215            assert!(
216                !r.signal,
217                "no signals expected for in-control data at index {}",
218                r.index
219            );
220            assert!(
221                r.s_upper.abs() < 1e-10,
222                "s_upper should be 0 when data == target"
223            );
224            assert!(
225                r.s_lower.abs() < 1e-10,
226                "s_lower should be 0 when data == target"
227            );
228        }
229    }
230
231    #[test]
232    fn test_cusum_in_control_with_noise() {
233        // Small symmetric deviations should not trigger signals with h=5.
234        let target = 50.0;
235        let sigma = 2.0;
236        let cusum = Cusum::new(target, sigma).expect("valid params");
237
238        // Alternating +0.3sigma and -0.3sigma around target
239        let data: Vec<f64> = (0..100)
240            .map(|i| {
241                if i % 2 == 0 {
242                    target + 0.3 * sigma
243                } else {
244                    target - 0.3 * sigma
245                }
246            })
247            .collect();
248
249        let signals = cusum.signal_points(&data);
250        assert!(
251            signals.is_empty(),
252            "symmetric noise of 0.3sigma should not trigger CUSUM signals"
253        );
254    }
255
256    #[test]
257    fn test_cusum_step_shift_detected() {
258        // Step shift of +2sigma at index 20.
259        let target = 100.0;
260        let sigma = 5.0;
261        let cusum = Cusum::new(target, sigma).expect("valid params");
262
263        let mut data = vec![target; 50];
264        // Introduce a +2sigma shift starting at index 20
265        for x in data.iter_mut().skip(20) {
266            *x = target + 2.0 * sigma;
267        }
268
269        let signals = cusum.signal_points(&data);
270        assert!(
271            !signals.is_empty(),
272            "CUSUM should detect a 2-sigma step shift"
273        );
274
275        // First signal should occur near the shift point (within a few samples)
276        let first_signal = signals[0];
277        assert!(
278            first_signal >= 20,
279            "signal should not appear before the shift at index 20, got {}",
280            first_signal
281        );
282        assert!(
283            first_signal <= 30,
284            "first signal should appear soon after shift at index 20, got {}",
285            first_signal
286        );
287    }
288
289    #[test]
290    fn test_cusum_downward_shift_detected() {
291        // Step shift of -2sigma at index 15.
292        let target = 50.0;
293        let sigma = 3.0;
294        let cusum = Cusum::new(target, sigma).expect("valid params");
295
296        let mut data = vec![target; 40];
297        for x in data.iter_mut().skip(15) {
298            *x = target - 2.0 * sigma;
299        }
300
301        let results = cusum.analyze(&data);
302        let signals: Vec<usize> = results
303            .iter()
304            .filter(|r| r.signal)
305            .map(|r| r.index)
306            .collect();
307        assert!(
308            !signals.is_empty(),
309            "CUSUM should detect a -2sigma downward shift"
310        );
311
312        // Check that the lower CUSUM accumulated (not the upper)
313        let first_signal_result = results.iter().find(|r| r.signal).expect("signal exists");
314        assert!(
315            first_signal_result.s_lower > first_signal_result.s_upper,
316            "lower CUSUM should be larger for downward shift"
317        );
318    }
319
320    #[test]
321    fn test_cusum_custom_params() {
322        let cusum = Cusum::with_params(0.0, 1.0, 0.25, 4.0);
323        assert!(cusum.is_some(), "valid custom params should succeed");
324
325        let cusum = cusum.expect("valid params");
326        // k=0.25 is more sensitive, h=4.0 is a tighter threshold
327        // A 1-sigma shift should be detected faster than with default params
328        let mut data = vec![0.0; 30];
329        for x in data.iter_mut().skip(10) {
330            *x = 1.0; // 1-sigma shift
331        }
332
333        let signals = cusum.signal_points(&data);
334        assert!(
335            !signals.is_empty(),
336            "k=0.25, h=4.0 should detect a 1-sigma shift"
337        );
338    }
339
340    #[test]
341    fn test_cusum_known_arl_k05_h5() {
342        // With k=0.5 and h=5, a 1-sigma shift should have ARL ~ 8-10.
343        // We verify that a sustained 1-sigma shift triggers within ~15 points.
344        let cusum = Cusum::new(0.0, 1.0).expect("valid params");
345
346        // All data points shifted by +1 sigma
347        let data: Vec<f64> = vec![1.0; 20];
348        let signals = cusum.signal_points(&data);
349
350        assert!(
351            !signals.is_empty(),
352            "1-sigma shift should trigger within 20 observations"
353        );
354        // The first signal should appear within about 15 observations
355        // (theoretical ARL ~ 8.38 for k=0.5, h=5, delta=1)
356        assert!(
357            signals[0] <= 15,
358            "first signal should appear within ~15 observations for ARL ~8, got {}",
359            signals[0]
360        );
361    }
362
363    #[test]
364    fn test_cusum_empty_data() {
365        let cusum = Cusum::new(0.0, 1.0).expect("valid params");
366        let results = cusum.analyze(&[]);
367        assert!(
368            results.is_empty(),
369            "empty data should produce empty results"
370        );
371
372        let signals = cusum.signal_points(&[]);
373        assert!(signals.is_empty(), "empty data should produce no signals");
374    }
375
376    #[test]
377    fn test_cusum_single_point() {
378        let cusum = Cusum::new(0.0, 1.0).expect("valid params");
379
380        let results = cusum.analyze(&[0.0]);
381        assert_eq!(results.len(), 1);
382        assert!(
383            !results[0].signal,
384            "single in-control point should not signal"
385        );
386
387        let results = cusum.analyze(&[100.0]);
388        assert_eq!(results.len(), 1);
389        // z = 100, s_upper = 100 - 0.5 = 99.5 > 5 → signal
390        assert!(results[0].signal, "extreme single point should signal");
391    }
392
393    #[test]
394    fn test_cusum_invalid_params() {
395        // sigma must be positive
396        assert!(Cusum::new(0.0, 0.0).is_none());
397        assert!(Cusum::new(0.0, -1.0).is_none());
398        assert!(Cusum::new(0.0, f64::NAN).is_none());
399        assert!(Cusum::new(0.0, f64::INFINITY).is_none());
400
401        // target must be finite
402        assert!(Cusum::new(f64::NAN, 1.0).is_none());
403        assert!(Cusum::new(f64::INFINITY, 1.0).is_none());
404
405        // k must be non-negative
406        assert!(Cusum::with_params(0.0, 1.0, -0.1, 5.0).is_none());
407
408        // h must be positive
409        assert!(Cusum::with_params(0.0, 1.0, 0.5, 0.0).is_none());
410        assert!(Cusum::with_params(0.0, 1.0, 0.5, -1.0).is_none());
411    }
412
413    #[test]
414    fn test_cusum_non_finite_data_skipped() {
415        let cusum = Cusum::new(0.0, 1.0).expect("valid params");
416        let data = [0.0, f64::NAN, 0.0, f64::INFINITY, 0.0];
417        let results = cusum.analyze(&data);
418        assert_eq!(results.len(), 5);
419        // Non-finite points should not produce signals
420        assert!(!results[1].signal);
421        assert!(!results[3].signal);
422    }
423
424    #[test]
425    fn test_cusum_s_upper_s_lower_non_negative() {
426        // CUSUM statistics should always be >= 0 by construction.
427        let cusum = Cusum::new(50.0, 5.0).expect("valid params");
428        let data: Vec<f64> = (0..100)
429            .map(|i| 50.0 + (i as f64 * 0.1).sin() * 3.0)
430            .collect();
431        let results = cusum.analyze(&data);
432        for r in &results {
433            assert!(
434                r.s_upper >= 0.0,
435                "s_upper must be non-negative at index {}",
436                r.index
437            );
438            assert!(
439                r.s_lower >= 0.0,
440                "s_lower must be non-negative at index {}",
441                r.index
442            );
443        }
444    }
445}