Skip to main content

u_analytics/detection/
ewma.rs

1//! Exponentially Weighted Moving Average (EWMA) chart for detecting small shifts.
2//!
3//! # Algorithm
4//!
5//! The EWMA statistic is defined as:
6//!
7//! ```text
8//! Z_i = lambda * x_i + (1 - lambda) * Z_{i-1},   Z_0 = mu_0
9//! ```
10//!
11//! Time-varying (exact) control limits:
12//!
13//! ```text
14//! UCL_i = mu_0 + L * sigma * sqrt(lambda / (2 - lambda) * (1 - (1 - lambda)^(2*i)))
15//! LCL_i = mu_0 - L * sigma * sqrt(lambda / (2 - lambda) * (1 - (1 - lambda)^(2*i)))
16//! ```
17//!
18//! Asymptotic limits (as i -> infinity):
19//!
20//! ```text
21//! UCL = mu_0 + L * sigma * sqrt(lambda / (2 - lambda))
22//! LCL = mu_0 - L * sigma * sqrt(lambda / (2 - lambda))
23//! ```
24//!
25//! # Parameters
26//!
27//! - **lambda**: smoothing constant in (0, 1]. Smaller values give more weight
28//!   to historical data and are better at detecting small shifts.
29//!   Typical range: 0.05-0.25.
30//! - **L**: control limit width factor in multiples of sigma. Typical: 2.7-3.0.
31//!
32//! # Reference
33//!
34//! Roberts, S.W. (1959). "Control Chart Tests Based on Geometric Moving Averages",
35//! *Technometrics* 1(3), pp. 239-250.
36
37/// EWMA chart parameters.
38///
39/// Implements the Exponentially Weighted Moving Average control chart for
40/// detecting small sustained shifts in the process mean.
41///
42/// # Examples
43///
44/// ```
45/// use u_analytics::detection::Ewma;
46///
47/// let ewma = Ewma::new(50.0, 2.0).unwrap();
48/// // In-control data
49/// let data = [50.5, 49.8, 50.2, 49.9, 50.0, 50.1, 49.7, 50.3];
50/// let results = ewma.analyze(&data);
51/// assert_eq!(results.len(), data.len());
52/// assert!(ewma.signal_points(&data).is_empty());
53/// ```
54///
55/// # Reference
56///
57/// Roberts, S.W. (1959). "Control Chart Tests Based on Geometric Moving Averages",
58/// *Technometrics* 1(3).
59pub struct Ewma {
60    /// Target process mean (mu_0).
61    target: f64,
62    /// Known process standard deviation (sigma).
63    sigma: f64,
64    /// Smoothing constant (0 < lambda <= 1).
65    lambda: f64,
66    /// Control limit width factor (L), default 3.0.
67    l_factor: f64,
68}
69
70/// Result of EWMA analysis for a single observation.
71#[derive(Debug, Clone)]
72pub struct EwmaResult {
73    /// EWMA statistic Z_i.
74    pub ewma: f64,
75    /// Upper control limit at this point (time-varying).
76    pub ucl: f64,
77    /// Lower control limit at this point (time-varying).
78    pub lcl: f64,
79    /// Whether the EWMA statistic exceeds the control limits.
80    pub signal: bool,
81    /// Index of this observation in the data sequence.
82    pub index: usize,
83}
84
85impl Ewma {
86    /// Creates a new EWMA chart with the given target mean and standard deviation.
87    ///
88    /// Uses default parameters lambda=0.2 and L=3.0.
89    ///
90    /// # Returns
91    ///
92    /// `None` if `sigma` is not positive or finite, or if `target` is not finite.
93    ///
94    /// # Reference
95    ///
96    /// Roberts (1959), *Technometrics* 1(3). Default lambda=0.2, L=3.0 provides
97    /// good sensitivity for detecting shifts of 0.5-2.0 sigma.
98    pub fn new(target: f64, sigma: f64) -> Option<Self> {
99        Self::with_params(target, sigma, 0.2, 3.0)
100    }
101
102    /// Creates an EWMA chart with custom parameters.
103    ///
104    /// # Parameters
105    ///
106    /// - `target`: Process target mean (mu_0, must be finite)
107    /// - `sigma`: Process standard deviation (must be positive and finite)
108    /// - `lambda`: Smoothing constant (must be in (0, 1])
109    /// - `l_factor`: Control limit width factor (must be positive and finite)
110    ///
111    /// # Returns
112    ///
113    /// `None` if any parameter is invalid.
114    pub fn with_params(target: f64, sigma: f64, lambda: f64, l_factor: f64) -> Option<Self> {
115        if !target.is_finite() {
116            return None;
117        }
118        if !sigma.is_finite() || sigma <= 0.0 {
119            return None;
120        }
121        if !lambda.is_finite() || lambda <= 0.0 || lambda > 1.0 {
122            return None;
123        }
124        if !l_factor.is_finite() || l_factor <= 0.0 {
125            return None;
126        }
127        Some(Self {
128            target,
129            sigma,
130            lambda,
131            l_factor,
132        })
133    }
134
135    /// Computes the time-varying control limit half-width at observation index `i`.
136    ///
137    /// The exact formula is:
138    /// ```text
139    /// L * sigma * sqrt(lambda / (2 - lambda) * (1 - (1 - lambda)^(2*i)))
140    /// ```
141    ///
142    /// This accounts for the reduced variance of the EWMA statistic in early
143    /// observations, providing tighter limits initially that widen toward the
144    /// asymptotic value.
145    ///
146    /// # Reference
147    ///
148    /// Roberts (1959), equation for exact EWMA variance.
149    fn control_limit_half_width(&self, i: usize) -> f64 {
150        let asymptotic_var = self.lambda / (2.0 - self.lambda);
151        let decay = (1.0 - self.lambda).powi(2 * i as i32);
152        let time_varying_var = asymptotic_var * (1.0 - decay);
153        self.l_factor * self.sigma * time_varying_var.sqrt()
154    }
155
156    /// Analyzes a sequence of observations and returns EWMA statistics for each point.
157    ///
158    /// The EWMA statistic is initialized to the target mean (Z_0 = mu_0).
159    /// Non-finite values in the data are skipped (the previous EWMA value
160    /// is carried forward).
161    ///
162    /// # Examples
163    ///
164    /// ```
165    /// use u_analytics::detection::Ewma;
166    ///
167    /// let ewma = Ewma::new(50.0, 2.0).unwrap();
168    /// // Data with shift
169    /// let mut data: Vec<f64> = vec![50.0; 10];
170    /// data.extend(vec![55.0; 10]); // large shift
171    /// let signals = ewma.signal_points(&data);
172    /// assert!(!signals.is_empty()); // shift detected
173    /// ```
174    ///
175    /// # Complexity
176    ///
177    /// Time: O(n), Space: O(n)
178    pub fn analyze(&self, data: &[f64]) -> Vec<EwmaResult> {
179        let mut results = Vec::with_capacity(data.len());
180        let mut z = self.target;
181
182        for (i, &x) in data.iter().enumerate() {
183            if !x.is_finite() {
184                // Non-finite observations: carry forward the previous EWMA value.
185                let half_width = self.control_limit_half_width(i + 1);
186                results.push(EwmaResult {
187                    ewma: z,
188                    ucl: self.target + half_width,
189                    lcl: self.target - half_width,
190                    signal: false,
191                    index: i,
192                });
193                continue;
194            }
195
196            z = self.lambda * x + (1.0 - self.lambda) * z;
197
198            // i is 0-based, but the control limit formula uses 1-based indexing
199            let half_width = self.control_limit_half_width(i + 1);
200            let ucl = self.target + half_width;
201            let lcl = self.target - half_width;
202            let signal = z > ucl || z < lcl;
203
204            results.push(EwmaResult {
205                ewma: z,
206                ucl,
207                lcl,
208                signal,
209                index: i,
210            });
211        }
212
213        results
214    }
215
216    /// Returns the indices of observations where an EWMA signal occurred.
217    ///
218    /// A signal occurs when the EWMA statistic exceeds the upper or lower
219    /// control limit.
220    ///
221    /// # Complexity
222    ///
223    /// Time: O(n), Space: O(k) where k is the number of signal points
224    pub fn signal_points(&self, data: &[f64]) -> Vec<usize> {
225        self.analyze(data)
226            .into_iter()
227            .filter(|r| r.signal)
228            .map(|r| r.index)
229            .collect()
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn test_ewma_in_control_stays_near_target() {
239        // All data at the target — EWMA should remain exactly at the target.
240        let target = 25.0;
241        let sigma = 2.0;
242        let ewma = Ewma::new(target, sigma).expect("valid params");
243
244        let data: Vec<f64> = vec![target; 50];
245        let results = ewma.analyze(&data);
246
247        assert_eq!(results.len(), 50);
248        for r in &results {
249            assert!(
250                (r.ewma - target).abs() < 1e-10,
251                "EWMA should stay at target when data == target, got {} at index {}",
252                r.ewma,
253                r.index
254            );
255            assert!(
256                !r.signal,
257                "no signals expected for in-control data at index {}",
258                r.index
259            );
260        }
261    }
262
263    #[test]
264    fn test_ewma_in_control_with_noise() {
265        // Small symmetric deviations should not trigger signals.
266        let target = 100.0;
267        let sigma = 5.0;
268        let ewma = Ewma::new(target, sigma).expect("valid params");
269
270        // Alternating +0.2sigma and -0.2sigma
271        let data: Vec<f64> = (0..100)
272            .map(|i| {
273                if i % 2 == 0 {
274                    target + 0.2 * sigma
275                } else {
276                    target - 0.2 * sigma
277                }
278            })
279            .collect();
280
281        let signals = ewma.signal_points(&data);
282        assert!(
283            signals.is_empty(),
284            "symmetric noise of 0.2sigma should not trigger EWMA signals"
285        );
286    }
287
288    #[test]
289    fn test_ewma_gradual_drift_detected() {
290        // Gradual linear drift: x_i = target + 0.1*sigma*i
291        let target = 0.0;
292        let sigma = 1.0;
293        let ewma = Ewma::new(target, sigma).expect("valid params");
294
295        let data: Vec<f64> = (0..100).map(|i| target + 0.1 * sigma * i as f64).collect();
296
297        let signals = ewma.signal_points(&data);
298        assert!(
299            !signals.is_empty(),
300            "EWMA should detect gradual linear drift"
301        );
302    }
303
304    #[test]
305    fn test_ewma_lambda_1_degenerates_to_shewhart() {
306        // When lambda=1, Z_i = x_i (no smoothing), so EWMA degenerates to
307        // individual observations plotted against fixed limits.
308        let target = 50.0;
309        let sigma = 5.0;
310        let l_factor = 3.0;
311        let ewma = Ewma::with_params(target, sigma, 1.0, l_factor).expect("valid params");
312
313        let data = [50.0, 55.0, 45.0, 70.0, 30.0];
314        let results = ewma.analyze(&data);
315
316        for (i, r) in results.iter().enumerate() {
317            assert!(
318                (r.ewma - data[i]).abs() < 1e-10,
319                "with lambda=1, EWMA should equal the data point: Z_{}={} vs x_{}={}",
320                i,
321                r.ewma,
322                i,
323                data[i]
324            );
325        }
326
327        // With lambda=1, the asymptotic factor sqrt(lambda/(2-lambda)) = sqrt(1/1) = 1
328        // Limits should be target +/- L*sigma = 50 +/- 15
329        // Point 70 should signal (70 > 65), point 30 should signal (30 < 35)
330        assert!(results[3].signal, "70 should exceed UCL of ~65");
331        assert!(results[4].signal, "30 should exceed LCL of ~35");
332    }
333
334    #[test]
335    fn test_ewma_time_varying_limits_converge() {
336        // Verify that the time-varying control limits converge to asymptotic limits.
337        let target = 0.0;
338        let sigma = 1.0;
339        let lambda = 0.2;
340        let l_factor = 3.0;
341        let ewma = Ewma::with_params(target, sigma, lambda, l_factor).expect("valid params");
342
343        // Asymptotic half-width: L * sigma * sqrt(lambda / (2 - lambda))
344        let asymptotic_hw = l_factor * sigma * (lambda / (2.0 - lambda)).sqrt();
345
346        // Generate 200 points at target to get the limit evolution
347        let data: Vec<f64> = vec![target; 200];
348        let results = ewma.analyze(&data);
349
350        // Early limit should be smaller than asymptotic
351        let first_hw = results[0].ucl - target;
352        assert!(
353            first_hw < asymptotic_hw,
354            "initial limit half-width {} should be less than asymptotic {}",
355            first_hw,
356            asymptotic_hw
357        );
358
359        // Late limit should be very close to asymptotic
360        let last_hw = results[199].ucl - target;
361        assert!(
362            (last_hw - asymptotic_hw).abs() < 1e-6,
363            "limit at i=200 should be close to asymptotic: got {}, expected {}",
364            last_hw,
365            asymptotic_hw
366        );
367
368        // Limits should be monotonically non-decreasing
369        for i in 1..results.len() {
370            assert!(
371                results[i].ucl >= results[i - 1].ucl - 1e-15,
372                "UCL should be non-decreasing: UCL[{}]={} < UCL[{}]={}",
373                i,
374                results[i].ucl,
375                i - 1,
376                results[i - 1].ucl
377            );
378        }
379    }
380
381    #[test]
382    fn test_ewma_limits_symmetric() {
383        // UCL and LCL should be symmetric around the target.
384        let target = 42.0;
385        let sigma = 3.0;
386        let ewma = Ewma::new(target, sigma).expect("valid params");
387
388        let data: Vec<f64> = vec![target; 20];
389        let results = ewma.analyze(&data);
390
391        for r in &results {
392            let ucl_dist = r.ucl - target;
393            let lcl_dist = target - r.lcl;
394            assert!(
395                (ucl_dist - lcl_dist).abs() < 1e-12,
396                "limits should be symmetric: UCL-target={}, target-LCL={}",
397                ucl_dist,
398                lcl_dist
399            );
400        }
401    }
402
403    #[test]
404    fn test_ewma_empty_data() {
405        let ewma = Ewma::new(0.0, 1.0).expect("valid params");
406        let results = ewma.analyze(&[]);
407        assert!(
408            results.is_empty(),
409            "empty data should produce empty results"
410        );
411
412        let signals = ewma.signal_points(&[]);
413        assert!(signals.is_empty(), "empty data should produce no signals");
414    }
415
416    #[test]
417    fn test_ewma_single_point() {
418        let ewma = Ewma::new(0.0, 1.0).expect("valid params");
419
420        let results = ewma.analyze(&[0.0]);
421        assert_eq!(results.len(), 1);
422        assert!(
423            !results[0].signal,
424            "single in-control point should not signal"
425        );
426
427        // Extreme single point
428        let results = ewma.analyze(&[100.0]);
429        assert_eq!(results.len(), 1);
430        // Z_1 = 0.2*100 + 0.8*0 = 20
431        // UCL_1 = 0 + 3*1*sqrt(0.2/1.8 * (1 - 0.8^2)) = 3*sqrt(0.1111*0.36) = 3*0.2 = 0.6
432        // 20 > 0.6 → signal
433        assert!(results[0].signal, "extreme single point should signal");
434    }
435
436    #[test]
437    fn test_ewma_invalid_params() {
438        // sigma must be positive
439        assert!(Ewma::new(0.0, 0.0).is_none());
440        assert!(Ewma::new(0.0, -1.0).is_none());
441        assert!(Ewma::new(0.0, f64::NAN).is_none());
442        assert!(Ewma::new(0.0, f64::INFINITY).is_none());
443
444        // target must be finite
445        assert!(Ewma::new(f64::NAN, 1.0).is_none());
446        assert!(Ewma::new(f64::INFINITY, 1.0).is_none());
447
448        // lambda must be in (0, 1]
449        assert!(Ewma::with_params(0.0, 1.0, 0.0, 3.0).is_none());
450        assert!(Ewma::with_params(0.0, 1.0, -0.1, 3.0).is_none());
451        assert!(Ewma::with_params(0.0, 1.0, 1.1, 3.0).is_none());
452
453        // l_factor must be positive
454        assert!(Ewma::with_params(0.0, 1.0, 0.2, 0.0).is_none());
455        assert!(Ewma::with_params(0.0, 1.0, 0.2, -1.0).is_none());
456    }
457
458    #[test]
459    fn test_ewma_non_finite_data_skipped() {
460        let ewma = Ewma::new(0.0, 1.0).expect("valid params");
461        let data = [0.0, f64::NAN, 0.0, f64::INFINITY, 0.0];
462        let results = ewma.analyze(&data);
463        assert_eq!(results.len(), 5);
464        // Non-finite points should not produce signals
465        assert!(!results[1].signal);
466        assert!(!results[3].signal);
467    }
468
469    #[test]
470    fn test_ewma_step_shift_detected() {
471        // Step shift of +2sigma at index 20.
472        let target = 0.0;
473        let sigma = 1.0;
474        let ewma = Ewma::new(target, sigma).expect("valid params");
475
476        let mut data = vec![0.0; 50];
477        for x in data.iter_mut().skip(20) {
478            *x = 2.0; // +2sigma shift
479        }
480
481        let signals = ewma.signal_points(&data);
482        assert!(
483            !signals.is_empty(),
484            "EWMA should detect a 2-sigma step shift"
485        );
486
487        let first_signal = signals[0];
488        assert!(
489            first_signal >= 20,
490            "signal should not appear before the shift, got {}",
491            first_signal
492        );
493    }
494
495    #[test]
496    fn test_ewma_small_lambda_more_smoothing() {
497        // Smaller lambda gives more weight to history, so the EWMA responds
498        // more slowly. After a step shift, lambda=0.05 should accumulate
499        // slower than lambda=0.25.
500        let target = 0.0;
501        let sigma = 1.0;
502
503        let ewma_slow = Ewma::with_params(target, sigma, 0.05, 3.0).expect("valid params");
504        let ewma_fast = Ewma::with_params(target, sigma, 0.25, 3.0).expect("valid params");
505
506        // Step shift of +2sigma at index 10
507        let mut data = vec![0.0; 30];
508        for x in data.iter_mut().skip(10) {
509            *x = 2.0;
510        }
511
512        let results_slow = ewma_slow.analyze(&data);
513        let results_fast = ewma_fast.analyze(&data);
514
515        // After a few points post-shift, the fast EWMA should be closer to the shifted mean
516        let z_slow_15 = results_slow[15].ewma;
517        let z_fast_15 = results_fast[15].ewma;
518
519        assert!(
520            z_fast_15 > z_slow_15,
521            "fast EWMA (lambda=0.25) should respond faster: z_fast={} > z_slow={}",
522            z_fast_15,
523            z_slow_15
524        );
525    }
526}