Skip to main content

fdars_core/spm/
iterative.rs

1//! Iterative Phase I chart construction for SPM.
2//!
3//! Repeatedly builds SPM charts and removes out-of-control observations
4//! until convergence, producing a cleaner in-control reference dataset.
5//! This addresses the common problem of Phase I data contamination
6//! where outliers distort the FPCA and control limits.
7//!
8//! # Convergence properties
9//!
10//! The iterative Phase I procedure converges when no new outliers are removed
11//! between iterations. Convergence is guaranteed in at most n iterations (each
12//! iteration removes at least one outlier or terminates). In practice, 3--5
13//! iterations suffice for typical contamination levels (5--15% outliers).
14//! Non-convergence (oscillation) can occur when the contamination fraction
15//! is near the breakdown point of the underlying T-squared / SPE statistics.
16//!
17//! # Breakdown point
18//!
19//! The procedure's breakdown point depends on the initial T-squared threshold.
20//! With alpha = 0.05 and chi-squared limits, the expected breakdown is roughly
21//! 50% for the T-squared statistic (Rousseeuw & Leroy, 1987, section 1.3,
22//! pp. 10--12). For contamination above the breakdown point, consider robust
23//! initialization via projection pursuit or minimum covariance determinant
24//! (MCD) before applying the iterative procedure.
25//!
26//! # References
27//!
28//! - Sullivan, J.H. & Woodall, W.H. (1996). A comparison of multivariate
29//!   control charts for individual observations. *Journal of Quality
30//!   Technology*, 28(4), 398--408, section 3 (iterative Phase I procedure).
31//! - Chenouri, S., Steiner, S.H. & Variyath, A.M. (2009). A multivariate
32//!   robust control chart for individual observations. *Journal of Quality
33//!   Technology*, 41(3), 259--271, section 2 (robust alternatives).
34//! - Rousseeuw, P.J. & Leroy, A.M. (1987). *Robust Regression and Outlier
35//!   Detection*. Wiley, section 1.3, pp. 10--12 (breakdown point),
36//!   section 4.1, pp. 116--119 (iterative reweighting).
37
38use crate::error::FdarError;
39use crate::matrix::FdMatrix;
40
41use super::phase::{spm_monitor, spm_phase1, SpmChart, SpmConfig};
42
43/// Configuration for iterative Phase I chart construction.
44///
45/// The iterative approach assumes outliers are a minority of the data.
46/// When more than `max_removal_fraction` of the original data would be
47/// removed, the procedure stops early, preserving the remaining data
48/// for analysis.
49///
50/// Construct via `IterativePhase1Config::default()`, then assign the fields you need (e.g. `let mut c = IterativePhase1Config::default(); c.field = …;`). This struct is `#[non_exhaustive]`, so external crates cannot build it with a struct literal — not even functional-update `..Default::default()` form.
51#[non_exhaustive]
52#[derive(Debug, Clone, PartialEq)]
53pub struct IterativePhase1Config {
54    /// Base SPM configuration.
55    pub spm: SpmConfig,
56    /// Maximum number of iterations (default 10).
57    pub max_iterations: usize,
58    /// Remove observations exceeding the T-squared limit (default true).
59    pub remove_t2_outliers: bool,
60    /// Remove observations exceeding the SPE limit (default true).
61    pub remove_spe_outliers: bool,
62    /// Maximum cumulative fraction of original data that can be removed (default 0.3).
63    /// Iteration stops if the next removal batch would push the total removed
64    /// count above this fraction of the original dataset size.
65    ///
66    /// This acts as a safeguard against breakdown: if more than 30% of the data
67    /// is flagged, the in-control model is likely misspecified rather than there
68    /// being isolated outliers (Rousseeuw & Leroy, 1987, section 4.1, pp. 116--119).
69    ///
70    /// If removal rates don't decrease across iterations (e.g., oscillating
71    /// around 0.3--0.5), the process likely has sustained non-stationarity
72    /// rather than isolated outliers. Consider increasing `alpha` or
73    /// investigating the data for structural changes.
74    pub max_removal_fraction: f64,
75}
76
77impl Default for IterativePhase1Config {
78    fn default() -> Self {
79        Self {
80            spm: SpmConfig::default(),
81            max_iterations: 10,
82            remove_t2_outliers: true,
83            remove_spe_outliers: true,
84            max_removal_fraction: 0.3,
85        }
86    }
87}
88
89/// Result of iterative Phase I chart construction.
90#[derive(Debug, Clone, PartialEq)]
91#[non_exhaustive]
92pub struct IterativePhase1Result {
93    /// Final SPM chart after outlier removal.
94    pub chart: SpmChart,
95    /// Number of iterations performed.
96    pub n_iterations: usize,
97    /// Indices of removed observations (relative to original data).
98    pub removed_indices: Vec<usize>,
99    /// Number of observations remaining.
100    pub n_remaining: usize,
101    /// History of observations removed per iteration.
102    pub removal_history: Vec<Vec<usize>>,
103    /// Fraction of observations removed per iteration (convergence diagnostic).
104    /// A decreasing sequence indicates convergence. Rates > 0.5 at any
105    /// iteration suggest the control limits may be too tight or the process
106    /// is genuinely unstable.
107    pub removal_rates: Vec<f64>,
108}
109
110/// Iteratively build a Phase I SPM chart by removing out-of-control observations.
111///
112/// Standard Phase I (`spm_phase1`) builds the chart once. However, if the training
113/// data contains outliers, the chart may be contaminated. This function repeatedly:
114///
115/// 1. Builds a chart from the current clean data
116/// 2. Monitors all current data against the chart
117/// 3. Removes observations flagged as out-of-control
118/// 4. Repeats until no more observations are removed or the maximum number of
119///    iterations is reached
120///
121/// # Arguments
122/// * `data` - In-control functional data (n x m)
123/// * `argvals` - Grid points (length m)
124/// * `config` - Iterative Phase I configuration
125///
126/// # Example
127///
128/// ```
129/// use fdars_core::matrix::FdMatrix;
130/// use fdars_core::spm::iterative::{spm_phase1_iterative, IterativePhase1Config};
131/// use fdars_core::spm::phase::SpmConfig;
132/// let data = FdMatrix::from_column_major(
133///     (0..200).map(|i| (i as f64 * 0.1).sin()).collect(), 20, 10
134/// ).unwrap();
135/// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
136/// let mut spm = SpmConfig::default();
137/// spm.ncomp = 2;
138/// let mut config = IterativePhase1Config::default();
139/// config.spm = spm;
140/// let result = spm_phase1_iterative(&data, &argvals, &config).unwrap();
141/// assert!(result.n_iterations <= config.max_iterations);
142/// ```
143///
144/// # Errors
145///
146/// Returns `FdarError::InvalidParameter` if `max_iterations < 1` or
147/// `max_removal_fraction` is not in (0, 1]. Dimension errors are propagated
148/// from `spm_phase1`.
149#[must_use = "expensive computation whose result should not be discarded"]
150pub fn spm_phase1_iterative(
151    data: &FdMatrix,
152    argvals: &[f64],
153    config: &IterativePhase1Config,
154) -> Result<IterativePhase1Result, FdarError> {
155    // Validate iterative-specific parameters.
156    // alpha must be in (0, 1). Smaller alpha values (e.g., 0.01) produce wider
157    // control limits and remove fewer observations per iteration, yielding a more
158    // conservative procedure. Larger alpha (e.g., 0.10) is more aggressive and
159    // converges faster but risks removing in-control observations (masking).
160    // The default alpha = 0.05 balances sensitivity and specificity for typical
161    // contamination levels (5--15%).
162    if config.spm.alpha <= 0.0 || config.spm.alpha >= 1.0 {
163        return Err(FdarError::InvalidParameter {
164            parameter: "alpha",
165            message: format!("alpha must be in (0, 1), got {}", config.spm.alpha),
166        });
167    }
168    if config.max_iterations < 1 {
169        return Err(FdarError::InvalidParameter {
170            parameter: "max_iterations",
171            message: format!(
172                "max_iterations must be at least 1, got {}",
173                config.max_iterations
174            ),
175        });
176    }
177    if config.max_removal_fraction <= 0.0 || config.max_removal_fraction > 1.0 {
178        return Err(FdarError::InvalidParameter {
179            parameter: "max_removal_fraction",
180            message: format!(
181                "max_removal_fraction must be in (0, 1], got {}",
182                config.max_removal_fraction
183            ),
184        });
185    }
186
187    let n_original = data.nrows();
188    let mut remaining_indices: Vec<usize> = (0..n_original).collect();
189    let mut all_removed: Vec<usize> = vec![];
190    let mut removal_history: Vec<Vec<usize>> = vec![];
191    let mut removal_rates: Vec<f64> = vec![];
192
193    let mut chart = None;
194
195    for _ in 0..config.max_iterations {
196        // Build chart from current data
197        let current_data = crate::cv::subset_rows(data, &remaining_indices);
198        let current_chart = spm_phase1(&current_data, argvals, &config.spm)?;
199
200        // Monitor the same data against the chart
201        let monitor = spm_monitor(&current_chart, &current_data, argvals)?;
202
203        // Identify out-of-control observations
204        let n_current = remaining_indices.len();
205        let mut flagged_local: Vec<usize> = Vec::new();
206        for i in 0..n_current {
207            let is_flagged = (config.remove_t2_outliers && monitor.t2_alarm[i])
208                || (config.remove_spe_outliers && monitor.spe_alarm[i]);
209            if is_flagged {
210                flagged_local.push(i);
211            }
212        }
213
214        // Converged: no observations flagged
215        if flagged_local.is_empty() {
216            chart = Some(current_chart);
217            break;
218        }
219
220        // Check cumulative removal: total removed so far (including this batch)
221        // against the maximum allowed fraction of the ORIGINAL dataset.
222        // The 0.5 removal rate threshold is a practical heuristic: if more
223        // than half the remaining data is flagged in one iteration, the
224        // in-control model is likely misspecified rather than there being
225        // individual outliers. This aligns with the breakdown point of
226        // classical outlier detection methods (Rousseeuw & Leroy, 1987).
227        let total_removed = all_removed.len() + flagged_local.len();
228        if total_removed as f64 / n_original as f64 > config.max_removal_fraction {
229            chart = Some(current_chart);
230            break;
231        }
232
233        // Check if remaining after removal would be too few
234        let n_after = n_current - flagged_local.len();
235        if n_after < 4 {
236            chart = Some(current_chart);
237            break;
238        }
239
240        // Map flagged local indices back to original indices
241        let flagged_original: Vec<usize> = flagged_local
242            .iter()
243            .map(|&i| remaining_indices[i])
244            .collect();
245
246        // Update remaining_indices by removing flagged ones
247        let flagged_set: std::collections::HashSet<usize> = flagged_local.iter().copied().collect();
248        remaining_indices = remaining_indices
249            .iter()
250            .enumerate()
251            .filter(|(local_i, _)| !flagged_set.contains(local_i))
252            .map(|(_, &orig_i)| orig_i)
253            .collect();
254
255        let removal_rate = flagged_original.len() as f64 / n_current as f64;
256        removal_rates.push(removal_rate);
257        all_removed.extend_from_slice(&flagged_original);
258        removal_history.push(flagged_original);
259    }
260
261    // Build the final chart if we exhausted iterations without converging
262    let final_chart = match chart {
263        Some(c) => c,
264        None => {
265            let final_data = crate::cv::subset_rows(data, &remaining_indices);
266            spm_phase1(&final_data, argvals, &config.spm)?
267        }
268    };
269
270    Ok(IterativePhase1Result {
271        chart: final_chart,
272        n_iterations: removal_history.len(),
273        removed_indices: all_removed,
274        n_remaining: remaining_indices.len(),
275        removal_history,
276        removal_rates,
277    })
278}