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}