Skip to main content

fdars_core/spm/
ewma.rs

1//! EWMA (Exponentially Weighted Moving Average) smoothing for SPM.
2//!
3//! Applies EWMA smoothing to FPC scores before computing monitoring statistics.
4//! This increases sensitivity to small persistent shifts in the process.
5//! The T-squared statistic uses adjusted eigenvalues that account for the
6//! variance reduction from smoothing.
7//!
8//! # Comparison with other charts
9//!
10//! For detecting small persistent shifts (< 1 sigma), EWMA with lambda around 0.1
11//! outperforms Shewhart-type T-squared charts. For large shifts (> 2 sigma),
12//! standard T-squared is preferable as EWMA introduces detection lag. See also
13//! [`spm_mewma_monitor`](super::mewma::spm_mewma_monitor) for multivariate EWMA
14//! and [`spm_amewma_monitor`](super::amewma::spm_amewma_monitor) for adaptive
15//! smoothing.
16//!
17//! # Choosing lambda
18//!
19//! The smoothing parameter `lambda` controls the trade-off between sensitivity
20//! and robustness. Small lambda (0.05--0.1) detects persistent small shifts;
21//! large lambda (0.3--0.5) detects sudden large shifts. `lambda = 0.2` is a
22//! common default balancing both. For ARL_0 ~ 370, typical (lambda, L) pairs:
23//! (0.05, 2.615), (0.10, 2.814), (0.20, 2.962), (0.50, 3.071). See Lucas &
24//! Saccucci (1990), Table 3.
25//!
26//! # References
27//!
28//! - Roberts, S.W. (1959). Control chart tests based on geometric moving
29//!   averages, §2, pp. 241--243. *Technometrics*, 1(3), 239--250.
30//! - Lucas, J.M. & Saccucci, M.S. (1990). Exponentially weighted moving
31//!   average control schemes: properties and enhancements. *Technometrics*,
32//!   32(1), 1--12.
33//! - Lowry, C.A., Woodall, W.H., Champ, C.W. & Rigdon, S.E. (1992). A
34//!   multivariate exponentially weighted moving average control chart,
35//!   Eq. 4, p. 48. *Technometrics*, 34(1), 46--53.
36
37use crate::error::FdarError;
38use crate::matrix::FdMatrix;
39
40use super::chi_squared::chi2_quantile;
41use super::phase::SpmChart;
42use super::stats::{hotelling_t2, spe_univariate};
43
44/// Configuration for EWMA monitoring.
45#[derive(Debug, Clone, PartialEq)]
46pub struct EwmaConfig {
47    /// EWMA smoothing parameter in (0, 1] (default 0.2).
48    /// `lambda = 1` gives raw scores (no smoothing).
49    ///
50    /// `lambda` controls the trade-off between sensitivity and robustness.
51    /// Small lambda (0.05--0.1) detects persistent small shifts; large lambda
52    /// (0.3--0.5) detects sudden large shifts. `lambda = 0.2` is a common
53    /// default balancing both. For ARL_0 ~ 370, typical (lambda, L) pairs:
54    /// (0.05, 2.615), (0.10, 2.814), (0.20, 2.962), (0.50, 3.071). See Lucas
55    /// & Saccucci (1990), Table 3.
56    pub lambda: f64,
57    /// Number of principal components (default 5).
58    pub ncomp: usize,
59    /// Significance level (default 0.05).
60    pub alpha: f64,
61    /// Use exact time-dependent covariance correction (default false).
62    /// When true, the T² statistic accounts for the EWMA startup transient
63    /// where variance grows from 0 to the asymptotic value (Lowry et al., 1992).
64    /// This prevents inflated T² values at early time steps.
65    ///
66    /// Recommended `true` for sequential monitoring with n < 50 observations.
67    /// For large Phase II batches, the asymptotic approximation (`false`) is
68    /// adequate and slightly faster.
69    pub exact_covariance: bool,
70}
71
72impl Default for EwmaConfig {
73    fn default() -> Self {
74        Self {
75            lambda: 0.2,
76            ncomp: 5,
77            alpha: 0.05,
78            exact_covariance: false,
79        }
80    }
81}
82
83/// Result of EWMA-based monitoring.
84#[derive(Debug, Clone, PartialEq)]
85#[non_exhaustive]
86pub struct EwmaMonitorResult {
87    /// Smoothed score matrix (n x ncomp).
88    pub smoothed_scores: FdMatrix,
89    /// T-squared values computed on smoothed scores.
90    pub t2: Vec<f64>,
91    /// SPE values (from reconstruction error, not smoothed).
92    ///
93    /// Computed from raw (unsmoothed) scores to preserve instantaneous fault
94    /// detection. See module doc for rationale.
95    pub spe: Vec<f64>,
96    /// T-squared control limit for EWMA.
97    ///
98    /// When using asymptotic covariance (`exact_covariance: false`), this UCL
99    /// may be slightly liberal during the first ~20 observations because the
100    /// chi-squared limit ignores the EWMA startup transient. Set
101    /// `exact_covariance: true` for precise startup behavior.
102    pub t2_limit: f64,
103    /// SPE control limit.
104    pub spe_limit: f64,
105    /// T-squared alarm flags.
106    pub t2_alarm: Vec<bool>,
107    /// SPE alarm flags.
108    pub spe_alarm: Vec<bool>,
109}
110
111/// Apply EWMA smoothing to a sequence of score vectors.
112///
113/// `Z_t = lambda * xi_t + (1 - lambda) * Z_{t-1}`, with `Z_0 = lambda * xi_0`.
114///
115/// The EWMA recursion is the impulse response form of a first-order IIR filter
116/// with transfer function `H(z) = lambda / (1 - (1-lambda) * z^{-1})`. The
117/// asymptotic covariance `lambda/(2-lambda) * Lambda` over-estimates the true
118/// variance for the first ~`ceil(1/lambda)` observations. For `lambda = 0.2`,
119/// the asymptotic approximation reaches 95% of its steady-state accuracy
120/// after t ~ 15 observations.
121///
122/// # Arguments
123/// * `scores` - Score matrix (n x ncomp), rows are sequential observations
124/// * `lambda` - Smoothing parameter in (0, 1]
125///
126/// # Errors
127///
128/// Returns [`FdarError::InvalidParameter`] if `lambda` is not in (0, 1].
129///
130/// # Example
131/// ```
132/// use fdars_core::matrix::FdMatrix;
133/// use fdars_core::spm::ewma::ewma_scores;
134/// let scores = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 0.5, 1.0, 1.5], 3, 2).unwrap();
135/// let smoothed = ewma_scores(&scores, 0.2).unwrap();
136/// assert_eq!(smoothed.shape(), (3, 2));
137/// // First row: lambda * score (since Z_0 = lambda * x_0)
138/// assert!((smoothed[(0, 0)] - 0.2).abs() < 1e-10);
139/// ```
140pub fn ewma_scores(scores: &FdMatrix, lambda: f64) -> Result<FdMatrix, FdarError> {
141    if lambda <= 0.0 || lambda > 1.0 {
142        return Err(FdarError::InvalidParameter {
143            parameter: "lambda",
144            message: format!("lambda must be in (0, 1], got {lambda}"),
145        });
146    }
147
148    let (n, ncomp) = scores.shape();
149    let mut smoothed = FdMatrix::zeros(n, ncomp);
150
151    // Z_0 = lambda * xi_0 (since Z_{-1} = 0)
152    if n > 0 {
153        for k in 0..ncomp {
154            smoothed[(0, k)] = lambda * scores[(0, k)];
155        }
156    }
157
158    for i in 1..n {
159        for k in 0..ncomp {
160            smoothed[(i, k)] = lambda * scores[(i, k)] + (1.0 - lambda) * smoothed[(i - 1, k)];
161        }
162    }
163
164    Ok(smoothed)
165}
166
167/// Run EWMA-based monitoring on sequential functional data.
168///
169/// 1. Projects each observation through the chart's FPCA
170/// 2. Applies EWMA smoothing to the scores
171/// 3. Computes T-squared on smoothed scores with adjusted eigenvalues
172/// 4. Computes SPE from reconstruction error (unsmoothed)
173/// 5. Sets limits from chi-squared distribution
174///
175/// When using asymptotic covariance (`exact_covariance: false`), the UCL may
176/// be slightly liberal during the first ~20 observations. Set
177/// `exact_covariance: true` for precise startup behavior.
178///
179/// # Notes
180///
181/// SPE is computed from unsmoothed (raw) scores rather than the EWMA-smoothed
182/// scores. EWMA smoothing would spread reconstruction error across time,
183/// reducing the diagnostic specificity of SPE alarms. The smoothed T-squared
184/// catches persistent mean shifts while raw SPE catches instantaneous model
185/// violations.
186///
187/// # Arguments
188/// * `chart` - Phase I SPM chart
189/// * `sequential_data` - Sequential functional data (n x m), rows in time order
190/// * `argvals` - Grid points (length m)
191/// * `config` - EWMA configuration
192///
193/// # Errors
194///
195/// Returns errors from projection, EWMA smoothing, or statistic computation.
196///
197/// # Example
198/// ```
199/// use fdars_core::matrix::FdMatrix;
200/// use fdars_core::spm::phase::{spm_phase1, SpmConfig};
201/// use fdars_core::spm::ewma::{spm_ewma_monitor, EwmaConfig};
202/// let data = FdMatrix::from_column_major(
203///     (0..200).map(|i| (i as f64 * 0.1).sin()).collect(), 20, 10
204/// ).unwrap();
205/// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
206/// let chart = spm_phase1(&data, &argvals, &SpmConfig { ncomp: 2, ..SpmConfig::default() }).unwrap();
207/// let new_data = FdMatrix::from_column_major(
208///     (0..50).map(|i| (i as f64 * 0.1).sin()).collect(), 5, 10
209/// ).unwrap();
210/// let result = spm_ewma_monitor(&chart, &new_data, &argvals, &EwmaConfig::default()).unwrap();
211/// assert_eq!(result.t2.len(), 5);
212/// ```
213#[must_use = "monitoring result should not be discarded"]
214pub fn spm_ewma_monitor(
215    chart: &SpmChart,
216    sequential_data: &FdMatrix,
217    argvals: &[f64],
218    config: &EwmaConfig,
219) -> Result<EwmaMonitorResult, FdarError> {
220    let m = chart.fpca.mean.len();
221    if sequential_data.ncols() != m {
222        return Err(FdarError::InvalidDimension {
223            parameter: "sequential_data",
224            expected: format!("{m} columns"),
225            actual: format!("{} columns", sequential_data.ncols()),
226        });
227    }
228
229    let ncomp = chart.eigenvalues.len().min(config.ncomp);
230    if ncomp == 0 {
231        return Err(FdarError::InvalidParameter {
232            parameter: "ncomp",
233            message: "ncomp must be at least 1".to_string(),
234        });
235    }
236
237    // Project all observations
238    let scores = chart.fpca.project(sequential_data)?;
239
240    // Truncate to ncomp columns if needed.
241    // NOTE: This pattern is intentionally duplicated across ewma, amewma, and
242    // mewma modules for clarity—each monitor owns its projection pipeline and
243    // the inline truncation keeps the data flow explicit.
244    let scores = if scores.ncols() > ncomp {
245        let n = scores.nrows();
246        let mut trunc = FdMatrix::zeros(n, ncomp);
247        for i in 0..n {
248            for k in 0..ncomp {
249                trunc[(i, k)] = scores[(i, k)];
250            }
251        }
252        trunc
253    } else {
254        scores
255    };
256    let actual_ncomp = scores.ncols();
257
258    // EWMA smooth
259    let smoothed = ewma_scores(&scores, config.lambda)?;
260
261    // Adjusted eigenvalues for EWMA: lambda_adj = eigenvalue * lambda / (2 - lambda)
262    let adj_eigenvalues: Vec<f64> = chart
263        .eigenvalues
264        .iter()
265        .take(actual_ncomp)
266        .map(|&ev| ev * config.lambda / (2.0 - config.lambda))
267        .collect();
268
269    // T-squared on smoothed scores
270    let n = smoothed.nrows();
271    let t2 = if config.exact_covariance {
272        // Exact variance formula: Var(Z_t) = λ/(2-λ) · [1 - (1-λ)^{2(t+1)}] · eigenvalue.
273        // See Lowry et al. (1992), Equation (4).
274        let lambda = config.lambda;
275        let one_minus_lambda = 1.0 - lambda;
276        let lambda_factor = lambda / (2.0 - lambda);
277        let mut stats = Vec::with_capacity(n);
278        for i in 0..n {
279            let t = (i + 1) as f64;
280            let time_factor = 1.0 - one_minus_lambda.powf(2.0 * t);
281            let mut t2_val = 0.0;
282            for l in 0..actual_ncomp {
283                let adj_ev = lambda_factor * time_factor * chart.eigenvalues[l];
284                let z = smoothed[(i, l)];
285                t2_val += z * z / adj_ev;
286            }
287            stats.push(t2_val);
288        }
289        stats
290    } else {
291        hotelling_t2(&smoothed, &adj_eigenvalues)?
292    };
293
294    // SPE from reconstruction error (uses raw scores, not smoothed).
295    // SPE is computed from unsmoothed scores because EWMA smoothing would
296    // spread reconstruction error across time, reducing the diagnostic
297    // specificity of SPE alarms. Smoothed T-squared catches persistent mean
298    // shifts; raw SPE catches instantaneous model violations.
299    let centered = {
300        let n = sequential_data.nrows();
301        let mut c = FdMatrix::zeros(n, m);
302        for i in 0..n {
303            for j in 0..m {
304                c[(i, j)] = sequential_data[(i, j)] - chart.fpca.mean[j];
305            }
306        }
307        c
308    };
309
310    let recon_centered = {
311        let n = scores.nrows();
312        let mut r = FdMatrix::zeros(n, m);
313        for i in 0..n {
314            for j in 0..m {
315                let mut val = 0.0;
316                for k in 0..actual_ncomp {
317                    val += scores[(i, k)] * chart.fpca.rotation[(j, k)];
318                }
319                r[(i, j)] = val;
320            }
321        }
322        r
323    };
324
325    let spe = spe_univariate(&centered, &recon_centered, argvals)?;
326
327    // Control limits
328    let t2_limit = chi2_quantile(1.0 - config.alpha, actual_ncomp);
329    let spe_limit = chart.spe_limit.ucl;
330
331    // Alarms
332    let t2_alarm: Vec<bool> = t2.iter().map(|&v| v > t2_limit).collect();
333    let spe_alarm: Vec<bool> = spe.iter().map(|&v| v > spe_limit).collect();
334
335    Ok(EwmaMonitorResult {
336        smoothed_scores: smoothed,
337        t2,
338        spe,
339        t2_limit,
340        spe_limit,
341        t2_alarm,
342        spe_alarm,
343    })
344}