fdars_core/spm/profile.rs
1//! Profile monitoring for functional data.
2//!
3//! Monitors the relationship between scalar predictors and functional
4//! responses over time using Function-on-Scalar Regression (FOSR).
5//! Detects changes in the coefficient functions beta(t) via FPCA and T-squared.
6//!
7//! # Mathematical framework
8//!
9//! The functional response model is y_i(t) = x_i^T beta(t) + epsilon_i(t),
10//! where beta(t) = [beta_1(t), ..., beta_p(t)]^T are coefficient functions.
11//! Rolling FOSR estimates beta(t) within each window, producing a sequence
12//! of vectorized coefficient functions. FPCA extracts the dominant modes of
13//! variation in the beta(t) sequence, and T-squared monitors for shifts
14//! in the score distribution.
15//!
16//! Beta coefficients from rolling windows are vectorized column-major:
17//! [beta_1(t_1), ..., beta_1(t_m), beta_2(t_1), ..., beta_2(t_m), ...]
18//! to form the FPCA input matrix. This preserves the functional structure
19//! within each predictor.
20//!
21//! **Note on overlapping windows:** When `step_size < window_size`, consecutive
22//! windows share observations, inducing serial correlation in the beta(t) estimates.
23//! The `effective_n_windows` field in [`ProfileChart`] provides a Bartlett-style
24//! correction for the effective degrees of freedom. Specifically, for overlapping
25//! windows with step_size < window_size, the effective number of independent
26//! windows is reduced. The Bartlett correction n_eff = n_windows / (1 + 2|rho_1|)
27//! accounts for AR(1) autocorrelation rho_1 in the windowed statistics, where
28//! rho_1 is estimated from the lag-1 autocorrelation of the T-squared sequence
29//! (Bartlett, 1946, section 3, pp. 31--33).
30//!
31//! # References
32//!
33//! - Bartlett, M.S. (1946). On the theoretical specification of sampling
34//! properties of autocorrelated time series. *Journal of the Royal
35//! Statistical Society B*, 8(1), 27--41, section 3, pp. 31--33.
36//! - Ledolter, J. & Swersey, A.J. (2007). *Testing 1-2-3: Experimental
37//! design with applications in marketing and service operations*.
38//! Stanford University Press, Ch. 6 (profile monitoring).
39
40use crate::error::FdarError;
41use crate::function_on_scalar::{fosr, FosrResult};
42use crate::matrix::FdMatrix;
43use crate::regression::{fdata_to_pc, FpcaResult};
44use crate::spm::control::{t2_control_limit, ControlLimit};
45use crate::spm::stats::hotelling_t2;
46
47/// Configuration for profile monitoring.
48///
49/// # Parameter guidance
50///
51/// - `window_size`: Must be >= p + 2 where p is the number of predictors.
52/// Larger windows give more stable beta(t) estimates but reduce temporal resolution.
53/// Typical: 20--50 for slowly varying processes. Recommended `window_size` is
54/// 5p to 10p where p is the number of predictors, to ensure stable FOSR
55/// estimation within each window (the design matrix X^T X has condition number
56/// roughly proportional to window_size / p).
57/// - `step_size`: Controls window overlap. step_size = window_size gives no overlap
58/// (independent windows); step_size = 1 gives maximum overlap (smoothest tracking
59/// but highest autocorrelation). Typical: window_size/2 or window_size/4.
60///
61/// Construct via `ProfileMonitorConfig::default()`, then assign the fields you need (e.g. `let mut c = ProfileMonitorConfig::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.
62#[non_exhaustive]
63#[derive(Debug, Clone, PartialEq)]
64pub struct ProfileMonitorConfig {
65 /// FOSR smoothing parameter (default 1e-4).
66 pub fosr_lambda: f64,
67 /// Number of principal components for beta-function FPCA (default 3).
68 /// Typically 2--5 suffices since coefficient functions are smoother than
69 /// raw data. Use `select_ncomp()` on the beta eigenvalues for data-driven
70 /// selection.
71 pub ncomp: usize,
72 /// Significance level (default 0.05).
73 pub alpha: f64,
74 /// Window size for rolling FOSR (default 20).
75 pub window_size: usize,
76 /// Step size between windows (default 1).
77 pub step_size: usize,
78}
79
80impl Default for ProfileMonitorConfig {
81 fn default() -> Self {
82 Self {
83 fosr_lambda: 1e-4,
84 ncomp: 3,
85 alpha: 0.05,
86 window_size: 20,
87 step_size: 1,
88 }
89 }
90}
91
92/// Phase I profile monitoring chart.
93///
94/// When `effective_n_windows` is much smaller than the actual number of
95/// windows, the chi-squared UCL may need adjustment. A practical approach:
96/// multiply the UCL by `effective_n_windows / n_windows` to approximate a
97/// Bonferroni-like correction.
98#[derive(Debug, Clone, PartialEq)]
99#[non_exhaustive]
100pub struct ProfileChart {
101 /// FOSR result from the full reference data.
102 pub reference_fosr: FosrResult,
103 /// FPCA of the rolling beta coefficient functions.
104 pub beta_fpca: FpcaResult,
105 /// Eigenvalues: sv² / (n_windows - 1).
106 pub eigenvalues: Vec<f64>,
107 /// T-squared control limit for beta monitoring.
108 pub t2_limit: ControlLimit,
109 /// Lag-1 autocorrelation of the Phase I T-squared statistics from rolling windows.
110 /// High values (> 0.5) indicate serial correlation from window overlap.
111 ///
112 /// Computed from the Phase I T-squared statistic sequence. Values |rho_1| > 0.3
113 /// indicate substantial serial correlation; `effective_n_windows` will be
114 /// notably reduced. The estimator uses the unbiased sample variance (n-1
115 /// denominator) for both variance and covariance terms.
116 pub lag1_autocorrelation: f64,
117 /// Effective number of independent windows (Bartlett correction for overlap).
118 /// When step_size < window_size, consecutive windows overlap and are
119 /// correlated, reducing the effective degrees of freedom.
120 ///
121 /// Computed as n_eff = n_windows / (1 + 2|rho_1|), which is the Bartlett
122 /// (1946, section 3) formula for AR(1) processes. For overlap fraction
123 /// f = 1 - step_size/window_size, the lag-1 autocorrelation is approximately
124 /// f, so n_eff ~ n_windows / (1 + 2f). This is conservative (underestimates
125 /// n_eff) for non-AR(1) dependence structures.
126 ///
127 /// Use this to assess whether the chi-squared UCL is reliable. When
128 /// `effective_n_windows` < 20, consider widening the control limit or
129 /// using bootstrap limits instead.
130 pub effective_n_windows: f64,
131 /// Configuration used.
132 pub config: ProfileMonitorConfig,
133}
134
135/// Result of Phase II profile monitoring.
136#[derive(Debug, Clone, PartialEq)]
137#[non_exhaustive]
138pub struct ProfileMonitorResult {
139 /// Per-window beta coefficient matrices (vectorized).
140 pub betas: FdMatrix,
141 /// T-squared values for each window.
142 pub t2: Vec<f64>,
143 /// T-squared alarm flags.
144 pub t2_alarm: Vec<bool>,
145 /// FPC scores for the beta functions.
146 pub beta_scores: FdMatrix,
147}
148
149/// Build a Phase I profile monitoring chart.
150///
151/// 1. Fits FOSR on the full training data to get reference β(t)
152/// 2. Rolls windows over the data, fitting FOSR per window
153/// 3. Vectorizes the β(t) from each window
154/// 4. Runs FPCA on the vectorized betas
155/// 5. Computes T-squared control limits
156///
157/// When `step_size > window_size`, windows do not overlap and some observations
158/// fall between windows (not monitored).
159///
160/// # Arguments
161/// * `y_curves` - Response functional data (n × m)
162/// * `predictors` - Scalar predictors (n × p)
163/// * `argvals` - Grid points (length m)
164/// * `config` - Profile monitoring configuration
165///
166/// # Errors
167///
168/// Returns errors from FOSR or FPCA computation.
169///
170/// # Example
171/// ```no_run
172/// use fdars_core::matrix::FdMatrix;
173/// use fdars_core::spm::profile::{profile_phase1, ProfileMonitorConfig};
174/// let n = 50;
175/// let m = 10;
176/// let y = FdMatrix::from_column_major(
177/// (0..n*m).map(|i| (i as f64 * 0.1).sin()).collect(), n, m
178/// ).unwrap();
179/// let pred = FdMatrix::from_column_major(
180/// (0..n).map(|i| i as f64 / n as f64).collect(), n, 1
181/// ).unwrap();
182/// let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m-1) as f64).collect();
183/// let mut config = ProfileMonitorConfig::default();
184/// config.window_size = 10;
185/// config.step_size = 5;
186/// config.ncomp = 2;
187/// let chart = profile_phase1(&y, &pred, &argvals, &config).unwrap();
188/// assert!(chart.eigenvalues.len() >= 1);
189/// ```
190#[must_use = "expensive computation whose result should not be discarded"]
191pub fn profile_phase1(
192 y_curves: &FdMatrix,
193 predictors: &FdMatrix,
194 argvals: &[f64],
195 config: &ProfileMonitorConfig,
196) -> Result<ProfileChart, FdarError> {
197 let (n, m) = y_curves.shape();
198 if predictors.nrows() != n {
199 return Err(FdarError::InvalidDimension {
200 parameter: "predictors",
201 expected: format!("{n} rows"),
202 actual: format!("{} rows", predictors.nrows()),
203 });
204 }
205 if argvals.len() != m {
206 return Err(FdarError::InvalidDimension {
207 parameter: "argvals",
208 expected: format!("{m}"),
209 actual: format!("{}", argvals.len()),
210 });
211 }
212 if config.step_size == 0 {
213 return Err(FdarError::InvalidParameter {
214 parameter: "step_size",
215 message: "step_size must be at least 1".to_string(),
216 });
217 }
218 if config.window_size < 3 {
219 return Err(FdarError::InvalidParameter {
220 parameter: "window_size",
221 message: format!("window_size must be >= 3, got {}", config.window_size),
222 });
223 }
224 if config.window_size > n {
225 return Err(FdarError::InvalidParameter {
226 parameter: "window_size",
227 message: format!(
228 "window_size ({}) exceeds data size ({n})",
229 config.window_size
230 ),
231 });
232 }
233
234 // Early check: ensure enough windows before expensive FOSR fitting.
235 let expected_n_windows = if n >= config.window_size {
236 (n - config.window_size) / config.step_size + 1
237 } else {
238 0
239 };
240 if expected_n_windows < 4 {
241 return Err(FdarError::InvalidDimension {
242 parameter: "data",
243 expected: "enough data for at least 4 windows".to_string(),
244 actual: format!(
245 "{expected_n_windows} windows (n={n}, window_size={}, step_size={})",
246 config.window_size, config.step_size
247 ),
248 });
249 }
250
251 // Fit reference FOSR on full data
252 let reference_fosr = fosr(y_curves, predictors, config.fosr_lambda)?;
253
254 // Rolling windows: extract per-window FOSR betas
255 let beta_vecs = rolling_betas(y_curves, predictors, config)?;
256 let n_windows = beta_vecs.nrows();
257
258 if n_windows < 4 {
259 return Err(FdarError::InvalidDimension {
260 parameter: "data",
261 expected: "enough data for at least 4 windows".to_string(),
262 actual: format!("{n_windows} windows"),
263 });
264 }
265
266 // FPCA on vectorized betas
267 let ncomp = config.ncomp.min(n_windows - 1).min(beta_vecs.ncols());
268 let beta_m = beta_vecs.ncols();
269 let beta_argvals: Vec<f64> = (0..beta_m)
270 .map(|j| j as f64 / (beta_m - 1).max(1) as f64)
271 .collect();
272 let beta_fpca = fdata_to_pc(&beta_vecs, ncomp, &beta_argvals)?;
273 let actual_ncomp = beta_fpca.scores.ncols();
274
275 // Eigenvalues
276 let eigenvalues: Vec<f64> = beta_fpca
277 .singular_values
278 .iter()
279 .take(actual_ncomp)
280 .map(|&sv| sv * sv / (n_windows as f64 - 1.0))
281 .collect();
282
283 // Compute Phase I T² for autocorrelation diagnostic
284 let phase1_t2 = hotelling_t2(&beta_fpca.scores, &eigenvalues)?;
285 // Lag-1 autocorrelation using unbiased sample variance (n-1 denominator).
286 // Both variance and covariance use the (n-1) denominator. The ratio
287 // ρ₁ = cov/var is invariant to this choice, but (n-1) gives the unbiased
288 // sample variance.
289 let lag1_autocorrelation = if phase1_t2.len() > 2 {
290 let n_t2 = phase1_t2.len();
291 let mean_t2 = phase1_t2.iter().sum::<f64>() / n_t2 as f64;
292 let var_t2: f64 = phase1_t2
293 .iter()
294 .map(|&v| (v - mean_t2).powi(2))
295 .sum::<f64>()
296 / (n_t2 - 1) as f64;
297 if var_t2 > 0.0 {
298 let cov1: f64 = (0..n_t2 - 1)
299 .map(|i| (phase1_t2[i] - mean_t2) * (phase1_t2[i + 1] - mean_t2))
300 .sum::<f64>()
301 / (n_t2 - 1) as f64;
302 (cov1 / var_t2).clamp(-1.0, 1.0)
303 } else {
304 0.0
305 }
306 } else {
307 0.0
308 };
309
310 // Bartlett (1946) effective sample size: n_eff = n / (1 + 2|ρ₁|).
311 // See Bartlett, M.S. (1946), Section 3.
312 // The Bartlett (1946) formula n_eff = n/(1 + 2ρ₁) is derived for
313 // AR(1) processes and provides a first-order correction for general
314 // stationary processes. For rolling-window statistics with overlap
315 // fraction f = 1 - step_size/window_size, the lag-1 autocorrelation
316 // is approximately f, so n_eff ≈ n/(1 + 2f). This is conservative
317 // (underestimates n_eff) for non-AR(1) dependence structures.
318 let effective_n_windows = if lag1_autocorrelation.abs() > 0.01 {
319 // Use absolute value: both positive (overlapping windows) and negative
320 // (anti-correlated) autocorrelation reduce the effective degrees of freedom.
321 let bartlett_factor = (1.0 + 2.0 * lag1_autocorrelation.abs()).max(1.0);
322 (n_windows as f64 / bartlett_factor).max(2.0)
323 } else {
324 n_windows as f64
325 };
326
327 // Control limit
328 let t2_limit = t2_control_limit(actual_ncomp, config.alpha)?;
329
330 Ok(ProfileChart {
331 reference_fosr,
332 beta_fpca,
333 eigenvalues,
334 t2_limit,
335 lag1_autocorrelation,
336 effective_n_windows,
337 config: config.clone(),
338 })
339}
340
341/// Monitor new data against a Phase I profile chart.
342///
343/// 1. Fits FOSR per rolling window on new data
344/// 2. Vectorizes β(t) and projects onto Phase I beta-FPCA
345/// 3. Computes T-squared
346///
347/// # Arguments
348/// * `chart` - Phase I profile chart
349/// * `new_y` - New response functional data (n × m)
350/// * `new_predictors` - New scalar predictors (n × p)
351/// * `argvals` - Grid points (length m)
352/// * `config` - Profile monitoring configuration
353///
354/// # Errors
355///
356/// Returns errors from FOSR or projection.
357#[must_use = "monitoring result should not be discarded"]
358pub fn profile_monitor(
359 chart: &ProfileChart,
360 new_y: &FdMatrix,
361 new_predictors: &FdMatrix,
362 _argvals: &[f64],
363 config: &ProfileMonitorConfig,
364) -> Result<ProfileMonitorResult, FdarError> {
365 if config.step_size == 0 {
366 return Err(FdarError::InvalidParameter {
367 parameter: "step_size",
368 message: "step_size must be at least 1".to_string(),
369 });
370 }
371 let n = new_y.nrows();
372 if new_predictors.nrows() != n {
373 return Err(FdarError::InvalidDimension {
374 parameter: "new_predictors",
375 expected: format!("{n} rows"),
376 actual: format!("{} rows", new_predictors.nrows()),
377 });
378 }
379 // Validate that new_y grid size matches the reference FOSR grid (m)
380 let expected_m = chart.reference_fosr.beta.ncols();
381 if new_y.ncols() != expected_m {
382 return Err(FdarError::InvalidDimension {
383 parameter: "new_y",
384 expected: format!("{expected_m} columns (grid points)"),
385 actual: format!("{} columns", new_y.ncols()),
386 });
387 }
388
389 // Rolling windows on new data
390 let beta_vecs = rolling_betas(new_y, new_predictors, config)?;
391
392 // Project onto Phase I FPCA
393 let beta_scores = chart.beta_fpca.project(&beta_vecs)?;
394
395 // T-squared
396 let t2 = hotelling_t2(&beta_scores, &chart.eigenvalues)?;
397
398 // Alarms
399 let t2_alarm: Vec<bool> = t2.iter().map(|&v| v > chart.t2_limit.ucl).collect();
400
401 Ok(ProfileMonitorResult {
402 betas: beta_vecs,
403 t2,
404 t2_alarm,
405 beta_scores,
406 })
407}
408
409/// Extract vectorized FOSR betas from rolling windows.
410///
411/// Each window must have sufficient rank for FOSR fitting. If a window fails
412/// (e.g., due to collinear predictors), the function returns an error.
413/// Ensure `window_size` is large enough relative to `p` (number of predictors).
414fn rolling_betas(
415 y_curves: &FdMatrix,
416 predictors: &FdMatrix,
417 config: &ProfileMonitorConfig,
418) -> Result<FdMatrix, FdarError> {
419 let n = y_curves.nrows();
420 let m = y_curves.ncols();
421 let p = predictors.ncols();
422
423 if config.window_size < p + 2 {
424 return Err(FdarError::InvalidParameter {
425 parameter: "window_size",
426 message: format!(
427 "window_size ({}) must be >= p + 2 = {} for FOSR fitting",
428 config.window_size,
429 p + 2
430 ),
431 });
432 }
433
434 let mut windows = Vec::new();
435 let mut start = 0;
436 while start + config.window_size <= n {
437 windows.push(start);
438 start += config.step_size;
439 }
440
441 if windows.is_empty() {
442 return Err(FdarError::InvalidDimension {
443 parameter: "data",
444 expected: format!(
445 "at least {} observations for one window",
446 config.window_size
447 ),
448 actual: format!("{n} observations"),
449 });
450 }
451
452 // Beta coefficients are vectorized in row-major order:
453 // β₁(t₁),...,β₁(t_m),β₂(t₁),...,β₂(t_m).
454 // Each row of beta_mat represents one window's complete coefficient function.
455 let beta_len = p * m;
456 let n_windows = windows.len();
457 let mut beta_mat = FdMatrix::zeros(n_windows, beta_len);
458
459 for (w_idx, &win_start) in windows.iter().enumerate() {
460 // Extract window data
461 let mut y_window = FdMatrix::zeros(config.window_size, m);
462 let mut pred_window = FdMatrix::zeros(config.window_size, p);
463 for i in 0..config.window_size {
464 for j in 0..m {
465 y_window[(i, j)] = y_curves[(win_start + i, j)];
466 }
467 for j in 0..p {
468 pred_window[(i, j)] = predictors[(win_start + i, j)];
469 }
470 }
471
472 // Fit FOSR
473 let fosr_result = fosr(&y_window, &pred_window, config.fosr_lambda)?;
474
475 // Vectorize beta (p × m) into row of beta_mat
476 // fosr_result.beta is p × m where row j = βⱼ(t)
477 for j in 0..p {
478 for t in 0..m {
479 beta_mat[(w_idx, j * m + t)] = fosr_result.beta[(j, t)];
480 }
481 }
482 }
483
484 Ok(beta_mat)
485}