Skip to main content

fdars_core/spm/
frcc.rs

1//! Functional Regression Control Chart (FRCC).
2//!
3//! Monitors a functional response after adjusting for known scalar covariates
4//! using function-on-scalar regression (FOSR). The residuals are then monitored
5//! via FPCA-based T-squared and SPE statistics.
6//!
7//! This is useful when the process output (functional) depends on known inputs
8//! (scalar predictors), and we want to detect deviations beyond what the inputs
9//! explain.
10//!
11//! # Model assessment
12//!
13//! R-squared = 1 - SSR/SST measures the proportion of response variance explained
14//! by the FRCC model (computed pointwise across grid points and summed). For
15//! monitoring, R-squared > 0.7 indicates excellent model fit; R-squared 0.5--0.7
16//! is adequate; R-squared 0.3--0.5 is marginal (covariate adjustment helps but
17//! residual variation is large); R-squared < 0.3 suggests the functional
18//! predictors have weak predictive power and monitoring may be ineffective
19//! compared to standard `spm_phase1`.
20//!
21//! After building the FRCC chart, verify `fosr_r_squared` to assess model
22//! quality. R-squared values: > 0.5 (strong adjustment), 0.3--0.5 (moderate),
23//! 0.1--0.3 (weak), < 0.1 (rejected by default threshold).
24//!
25//! # Residual assumptions
26//!
27//! The monitoring assumes regression residuals are independent across observations.
28//! Autocorrelated residuals (e.g., from time-series data or batch-to-batch effects)
29//! inflate SPE alarm rates because the empirical SPE distribution underestimates the
30//! true variability. Check residual autocorrelation using the lag-1 sample
31//! autocorrelation of the SPE sequence and consider pre-whitening (e.g., fitting an
32//! AR(1) model to the residual scores) if significant.
33//!
34//! The SPE control limit assumes residuals are approximately independent across
35//! observations. If the residuals exhibit temporal autocorrelation, consider using
36//! bootstrap control limits via `spe_limit_robust()`.
37//!
38//! # References
39//!
40//! - Capezza, C., Lepore, A., Menafoglio, A., Palumbo, B. & Vantini, S.
41//!   (2020). Control charts for monitoring ship operating conditions and
42//!   CO2 emissions based on scalar-on-function regression. *Applied
43//!   Stochastic Models in Business and Industry*, 36(3), 477--500,
44//!   section 3.1 (FRCC construction), section 4 (monitoring procedure).
45
46use crate::error::FdarError;
47use crate::function_on_scalar::{fosr, predict_fosr, FosrResult};
48use crate::matrix::FdMatrix;
49use crate::regression::{fdata_to_pc, FpcaResult};
50
51use super::control::{spe_control_limit, t2_control_limit, ControlLimit};
52use super::phase::{center_data, centered_reconstruct, split_indices};
53use super::stats::{hotelling_t2, spe_univariate};
54
55/// Configuration for FRCC chart construction.
56///
57/// Construct via `FrccConfig::default()`, then assign the fields you need (e.g. `let mut c = FrccConfig::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.
58#[non_exhaustive]
59#[derive(Debug, Clone, PartialEq)]
60pub struct FrccConfig {
61    /// Number of principal components for residual FPCA (default 5).
62    pub ncomp: usize,
63    /// FOSR smoothing parameter; controls roughness penalty on β(t) (default 1e-4).
64    /// Larger values produce smoother coefficient functions. Typical range: [1e-6, 1e-2].
65    /// Use cross-validation if unsure.
66    pub fosr_lambda: f64,
67    /// Significance level (default 0.05).
68    pub alpha: f64,
69    /// Fraction of data for tuning (default 0.5).
70    ///
71    /// Must be in (0, 1). The tuning set is used for FOSR fitting and R-squared
72    /// assessment; the calibration set (1 - tuning_fraction) is used for FPCA
73    /// and control limit estimation. Larger tuning fractions give more stable
74    /// FOSR estimates but fewer calibration observations for control limits.
75    /// For n < 50, consider tuning_fraction = 0.4 to ensure adequate calibration.
76    /// For n > 200, tuning_fraction = 0.6 may improve FOSR stability.
77    pub tuning_fraction: f64,
78    /// Random seed (default 42).
79    pub seed: u64,
80    /// Minimum FOSR R² required to proceed (default 0.1).
81    /// If the FOSR model explains less than this fraction of variance,
82    /// frcc_phase1 returns an error suggesting standard SPM instead.
83    ///
84    /// Default 0.1 is a lenient threshold that catches only clearly useless
85    /// models. For production use, consider 0.2--0.3. An R² of 0.3 means
86    /// predictors explain 30% of functional variance --- enough for
87    /// meaningful covariate adjustment.
88    pub min_r_squared: f64,
89}
90
91impl Default for FrccConfig {
92    fn default() -> Self {
93        Self {
94            ncomp: 5,
95            fosr_lambda: 1e-4,
96            alpha: 0.05,
97            tuning_fraction: 0.5,
98            seed: 42,
99            min_r_squared: 0.1,
100        }
101    }
102}
103
104/// Phase I FRCC chart.
105#[derive(Debug, Clone, PartialEq)]
106#[non_exhaustive]
107pub struct FrccChart {
108    /// Fitted FOSR model.
109    pub fosr: FosrResult,
110    /// FPCA on calibration residuals.
111    pub residual_fpca: FpcaResult,
112    /// Eigenvalues from residual FPCA.
113    pub eigenvalues: Vec<f64>,
114    /// T-squared control limit.
115    pub t2_limit: ControlLimit,
116    /// SPE control limit.
117    pub spe_limit: ControlLimit,
118    /// Coefficient of determination (R²) for the FOSR model on the tuning set.
119    /// Values near 0 suggest the predictors explain little variance, and
120    /// the FRCC may not add value over a standard SPM chart.
121    pub fosr_r_squared: f64,
122    /// Configuration used.
123    pub config: FrccConfig,
124}
125
126/// Result of FRCC monitoring.
127#[derive(Debug, Clone, PartialEq)]
128#[non_exhaustive]
129pub struct FrccMonitorResult {
130    /// T-squared values.
131    pub t2: Vec<f64>,
132    /// SPE values.
133    pub spe: Vec<f64>,
134    /// T-squared alarm flags.
135    pub t2_alarm: Vec<bool>,
136    /// SPE alarm flags.
137    pub spe_alarm: Vec<bool>,
138    /// Residual scores.
139    pub residual_scores: FdMatrix,
140}
141
142/// Compute residuals: observed - predicted.
143fn compute_residuals(observed: &FdMatrix, predicted: &FdMatrix) -> FdMatrix {
144    let (n, m) = observed.shape();
145    let mut residuals = FdMatrix::zeros(n, m);
146    for i in 0..n {
147        for j in 0..m {
148            residuals[(i, j)] = observed[(i, j)] - predicted[(i, j)];
149        }
150    }
151    residuals
152}
153
154/// Build a Functional Regression Control Chart from Phase I data.
155///
156/// 1. Splits data into tuning and calibration sets
157/// 2. Fits FOSR on tuning set
158/// 3. Computes residuals on calibration set
159/// 4. Runs FPCA on calibration residuals
160/// 5. Computes T-squared and SPE control limits
161///
162/// The R² is computed on the tuning set, not the calibration set, to avoid
163/// optimistic bias. The tuning set is used for both FOSR fitting and R²
164/// assessment.
165///
166/// The SPE control limit assumes approximately independent residuals. For
167/// processes with temporal structure in the residuals, use `spe_limit_robust()`
168/// with bootstrap method.
169///
170/// # Arguments
171/// * `y_curves` - Functional response (n x m)
172/// * `predictors` - Scalar predictors (n x p)
173/// * `argvals` - Grid points (length m)
174/// * `config` - FRCC configuration
175///
176/// # Errors
177///
178/// Returns errors from FOSR fitting, FPCA, or control limit estimation.
179///
180/// # Example
181/// ```no_run
182/// use fdars_core::matrix::FdMatrix;
183/// use fdars_core::spm::frcc::{frcc_phase1, FrccConfig};
184/// let n = 60;
185/// let m = 10;
186/// let y = FdMatrix::from_column_major(
187///     (0..n*m).map(|i| (i as f64 * 0.1).sin()).collect(), n, m
188/// ).unwrap();
189/// let pred = FdMatrix::from_column_major(
190///     (0..n).map(|i| i as f64 / n as f64).collect(), n, 1
191/// ).unwrap();
192/// let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m-1) as f64).collect();
193/// let mut config = FrccConfig::default();
194/// config.min_r_squared = 0.0;
195/// let chart = frcc_phase1(&y, &pred, &argvals, &config).unwrap();
196/// assert!(chart.fosr_r_squared >= 0.0);
197/// ```
198#[must_use = "expensive computation whose result should not be discarded"]
199pub fn frcc_phase1(
200    y_curves: &FdMatrix,
201    predictors: &FdMatrix,
202    argvals: &[f64],
203    config: &FrccConfig,
204) -> Result<FrccChart, FdarError> {
205    let (n, m) = y_curves.shape();
206    let p = predictors.ncols();
207
208    if n < 6 {
209        return Err(FdarError::InvalidDimension {
210            parameter: "y_curves",
211            expected: "at least 6 observations".to_string(),
212            actual: format!("{n} observations"),
213        });
214    }
215    if predictors.nrows() != n {
216        return Err(FdarError::InvalidDimension {
217            parameter: "predictors",
218            expected: format!("{n} rows"),
219            actual: format!("{} rows", predictors.nrows()),
220        });
221    }
222    if argvals.len() != m {
223        return Err(FdarError::InvalidDimension {
224            parameter: "argvals",
225            expected: format!("{m}"),
226            actual: format!("{}", argvals.len()),
227        });
228    }
229    if config.tuning_fraction <= 0.0 || config.tuning_fraction >= 1.0 {
230        return Err(FdarError::InvalidParameter {
231            parameter: "tuning_fraction",
232            message: format!(
233                "tuning_fraction must be in (0, 1), got {}",
234                config.tuning_fraction
235            ),
236        });
237    }
238
239    // Split
240    let (tune_idx, cal_idx) = split_indices(n, config.tuning_fraction, config.seed);
241
242    let tune_y = crate::cv::subset_rows(y_curves, &tune_idx);
243    let tune_x = crate::cv::subset_rows(predictors, &tune_idx);
244    let cal_y = crate::cv::subset_rows(y_curves, &cal_idx);
245    let cal_x = crate::cv::subset_rows(predictors, &cal_idx);
246
247    let n_tune = tune_y.nrows();
248    let n_cal = cal_y.nrows();
249
250    if n_cal < 2 {
251        return Err(FdarError::InvalidDimension {
252            parameter: "y_curves",
253            expected: "calibration set with at least 2 observations".to_string(),
254            actual: format!("{n_cal} observations in calibration set"),
255        });
256    }
257
258    // Ensure enough observations for FOSR (needs n >= p + 2)
259    if n_tune < p + 2 {
260        return Err(FdarError::InvalidDimension {
261            parameter: "y_curves",
262            expected: format!("tuning set with at least {} observations", p + 2),
263            actual: format!("{n_tune} observations in tuning set"),
264        });
265    }
266
267    // FOSR on tuning set
268    if config.fosr_lambda < 0.0 {
269        return Err(FdarError::InvalidParameter {
270            parameter: "fosr_lambda",
271            message: format!(
272                "fosr_lambda must be non-negative, got {}",
273                config.fosr_lambda
274            ),
275        });
276    }
277    let fosr_lambda = config.fosr_lambda;
278    let fosr_result = fosr(&tune_y, &tune_x, fosr_lambda)?;
279
280    // Compute R² on tuning set.
281    // R² is computed pointwise across all grid points, implicitly assuming a
282    // uniform grid. For non-uniform grids, this gives equal weight to each
283    // discrete point rather than integrating over the domain. For grids with
284    // > 3x variation in spacing, consider computing a weighted R² using
285    // Simpson's weights on argvals. The pointwise R² here is adequate for
286    // uniform or near-uniform grids.
287    //
288    // The pointwise R² treats each grid point equally, which is appropriate
289    // for uniform or near-uniform grids. For strongly non-uniform grids, the
290    // functional R² (integrating with quadrature weights) would be more
291    // appropriate but is not currently implemented. In practice, the
292    // difference is small when the grid has < 3x variation in spacing.
293    let tune_predicted = predict_fosr(&fosr_result, &tune_x);
294    let fosr_r_squared = {
295        let (n_t, m_t) = tune_y.shape();
296        let mut ss_res = 0.0;
297        let mut ss_tot = 0.0;
298        // Per-point mean for total SS
299        for j in 0..m_t {
300            let col_mean: f64 = (0..n_t).map(|i| tune_y[(i, j)]).sum::<f64>() / n_t as f64;
301            for i in 0..n_t {
302                ss_res += (tune_y[(i, j)] - tune_predicted[(i, j)]).powi(2);
303                ss_tot += (tune_y[(i, j)] - col_mean).powi(2);
304            }
305        }
306        if ss_tot > 0.0 {
307            1.0 - ss_res / ss_tot
308        } else {
309            0.0
310        }
311    };
312
313    if fosr_r_squared < config.min_r_squared {
314        return Err(FdarError::ComputationFailed {
315            operation: "frcc_phase1",
316            detail: format!(
317                "FOSR R² = {fosr_r_squared:.4}; below threshold {:.4}. \
318                 Consider: (a) adding more predictors, (b) increasing the training \
319                 set size, (c) reducing fosr_lambda for a less smooth fit, or \
320                 (d) using standard `spm_phase1` instead. Low R² means the \
321                 predictors explain little variance, so covariate adjustment \
322                 provides minimal benefit and may introduce estimation noise.",
323                config.min_r_squared
324            ),
325        });
326    }
327
328    // Predict on calibration set and compute residuals
329    let cal_predicted = predict_fosr(&fosr_result, &cal_x);
330    let cal_residuals = compute_residuals(&cal_y, &cal_predicted);
331
332    // FPCA on calibration residuals
333    let ncomp = config.ncomp.min(n_cal - 1).min(m);
334    let residual_fpca = fdata_to_pc(&cal_residuals, ncomp, argvals)?;
335    let actual_ncomp = residual_fpca.scores.ncols();
336
337    // Eigenvalues
338    let eigenvalues: Vec<f64> = residual_fpca
339        .singular_values
340        .iter()
341        .take(actual_ncomp)
342        .map(|&sv| sv * sv / (n_cal as f64 - 1.0))
343        .collect();
344
345    // T² on calibration scores: computed to verify FPCA but not used for
346    // control limits (which use chi² quantiles instead of empirical limits).
347    let _t2_cal = hotelling_t2(&residual_fpca.scores, &eigenvalues)?;
348
349    // SPE on calibration residuals
350    let cal_resid_centered = center_data(&cal_residuals, &residual_fpca.mean);
351    let cal_resid_recon = centered_reconstruct(&residual_fpca, &residual_fpca.scores, actual_ncomp);
352    let spe_cal = spe_univariate(&cal_resid_centered, &cal_resid_recon, argvals)?;
353
354    // Control limits
355    let t2_limit = t2_control_limit(actual_ncomp, config.alpha)?;
356    let spe_limit = spe_control_limit(&spe_cal, config.alpha)?;
357
358    Ok(FrccChart {
359        fosr: fosr_result,
360        residual_fpca,
361        eigenvalues,
362        t2_limit,
363        spe_limit,
364        fosr_r_squared,
365        config: config.clone(),
366    })
367}
368
369/// Monitor new data against a Functional Regression Control Chart.
370///
371/// 1. Predicts functional response from FOSR model
372/// 2. Computes residuals
373/// 3. Projects residuals through FPCA
374/// 4. Computes T-squared and SPE
375///
376/// # Arguments
377/// * `chart` - Phase I FRCC chart
378/// * `new_y` - New functional response (n_new x m)
379/// * `new_predictors` - New scalar predictors (n_new x p)
380/// * `argvals` - Grid points (length m)
381///
382/// # Errors
383///
384/// Returns errors from FOSR prediction, FPCA projection, or statistic computation.
385#[must_use = "monitoring result should not be discarded"]
386pub fn frcc_monitor(
387    chart: &FrccChart,
388    new_y: &FdMatrix,
389    new_predictors: &FdMatrix,
390    argvals: &[f64],
391) -> Result<FrccMonitorResult, FdarError> {
392    let m = chart.residual_fpca.mean.len();
393    if new_y.ncols() != m {
394        return Err(FdarError::InvalidDimension {
395            parameter: "new_y",
396            expected: format!("{m} columns"),
397            actual: format!("{} columns", new_y.ncols()),
398        });
399    }
400    if new_y.nrows() != new_predictors.nrows() {
401        return Err(FdarError::InvalidDimension {
402            parameter: "new_predictors",
403            expected: format!("{} rows", new_y.nrows()),
404            actual: format!("{} rows", new_predictors.nrows()),
405        });
406    }
407    // Verify predictor count matches the FOSR model (beta has p rows, one per predictor).
408    let expected_p = chart.fosr.beta.nrows();
409    if new_predictors.ncols() != expected_p {
410        return Err(FdarError::InvalidDimension {
411            parameter: "new_predictors",
412            expected: format!("{expected_p} columns (predictors)"),
413            actual: format!("{} columns", new_predictors.ncols()),
414        });
415    }
416
417    let ncomp = chart.eigenvalues.len();
418
419    // Predict and compute residuals
420    let predicted = predict_fosr(&chart.fosr, new_predictors);
421    let residuals = compute_residuals(new_y, &predicted);
422
423    // Project residuals through FPCA
424    let residual_scores = chart.residual_fpca.project(&residuals)?;
425
426    // T-squared
427    let t2 = hotelling_t2(&residual_scores, &chart.eigenvalues)?;
428
429    // SPE
430    let resid_centered = center_data(&residuals, &chart.residual_fpca.mean);
431    let resid_recon = centered_reconstruct(&chart.residual_fpca, &residual_scores, ncomp);
432    let spe = spe_univariate(&resid_centered, &resid_recon, argvals)?;
433
434    // Alarms
435    let t2_alarm: Vec<bool> = t2.iter().map(|&v| v > chart.t2_limit.ucl).collect();
436    let spe_alarm: Vec<bool> = spe.iter().map(|&v| v > chart.spe_limit.ucl).collect();
437
438    Ok(FrccMonitorResult {
439        t2,
440        spe,
441        t2_alarm,
442        spe_alarm,
443        residual_scores,
444    })
445}