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}