fdars_core/spm/partial.rs
1//! Partial-domain monitoring for functional data.
2//!
3//! Monitors processes where only a partial observation of the functional
4//! domain is available (e.g., real-time monitoring of an ongoing process).
5//! Supports three strategies for handling the unobserved domain:
6//! - Conditional expectation (BLUP)
7//! - Partial projection (scaled inner products)
8//! - Zero padding
9//!
10//! # Mathematical framework
11//!
12//! The BLUP (Best Linear Unbiased Predictor) conditional expectation formula is:
13//!
14//! xi_hat = Lambda · Phi_obs^T · (Phi_obs · Lambda · Phi_obs^T + sigma^2 I)^{-1} · y_obs
15//!
16//! where Lambda = diag(eigenvalues) is the ncomp x ncomp prior covariance of
17//! the FPC scores, Phi_obs is the eigenfunction matrix restricted to observed
18//! grid points (n_obs x ncomp), sigma^2 is estimated from the median SPE
19//! (robust to outliers), and y_obs is the centered partial observation. Under
20//! Gaussian assumptions, this is the BLUP (Yao et al., 2005, section 3,
21//! pp. 580--583, Eq. 6).
22//!
23//! The domain fraction is computed as (argvals\[n_obs-1\] - argvals\[0\]) /
24//! (argvals\[m-1\] - argvals\[0\]), representing the proportion of the domain
25//! range covered by observed points. For a single observed point (range = 0),
26//! the point-count fraction n_obs/m is used as a fallback, consistent with
27//! the PACE framework where prediction from a single observation reduces to
28//! the marginal BLUP.
29//!
30//! # Numerical stability
31//!
32//! The ncomp x ncomp system matrix Phi_obs · Lambda · Phi_obs^T + sigma^2 I
33//! (equivalently, Lambda^{-1} + sigma^{-2} Phi_obs^T Phi_obs via the Woodbury
34//! identity) is solved via Cholesky factorization. If the matrix is near-singular
35//! (condition number proxy diag_max/diag_min > 10^12), progressive Tikhonov
36//! regularization is applied: the diagonal loading is increased by factors of 10
37//! until the factorization succeeds (up to 5 retries).
38//!
39//! # References
40//!
41//! - Yao, F., Muller, H.-G. & Wang, J.-L. (2005). Functional data analysis
42//! for sparse longitudinal data. *Journal of the American Statistical
43//! Association*, 100(470), 577--590, section 3, pp. 580--583 (PACE framework
44//! and BLUP derivation).
45
46use crate::error::FdarError;
47use crate::helpers::simpsons_weights;
48use crate::matrix::FdMatrix;
49
50use super::phase::SpmChart;
51use super::stats::hotelling_t2;
52
53/// Strategy for handling unobserved domain.
54///
55/// # Strategy comparison
56///
57/// - **ConditionalExpectation** (recommended): Best accuracy when the FPCA model
58/// is well-specified. Uses BLUP to predict scores from partial observations.
59/// Degrades gracefully as domain fraction decreases.
60/// - **PartialProjection**: Computationally cheapest. Scales inner products by
61/// domain fraction. Acceptable when domain fraction > 0.7.
62/// - **ZeroPad**: Simplest baseline. Fills unobserved region with the mean
63/// function. Biases scores toward zero for small domain fractions.
64#[derive(Debug, Clone, PartialEq)]
65#[non_exhaustive]
66pub enum DomainCompletion {
67 /// Partial inner products scaled by domain fraction.
68 PartialProjection,
69 /// Best Linear Unbiased Predictor (Yao et al., 2005).
70 ConditionalExpectation,
71 /// Pad unobserved region with the mean function.
72 ZeroPad,
73}
74
75/// Configuration for partial-domain monitoring.
76///
77/// For domain fractions below 0.3, all strategies produce increasingly
78/// uncertain estimates. The conditional expectation (BLUP) degrades most
79/// gracefully due to its optimal shrinkage properties.
80///
81/// Construct via `PartialDomainConfig::default()`, then assign the fields you need (e.g. `let mut c = PartialDomainConfig::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.
82#[non_exhaustive]
83#[derive(Debug, Clone, PartialEq)]
84pub struct PartialDomainConfig {
85 /// Number of principal components (default 5).
86 pub ncomp: usize,
87 /// Significance level (default 0.05).
88 pub alpha: f64,
89 /// Domain completion strategy (default: ConditionalExpectation).
90 pub completion: DomainCompletion,
91 /// Tikhonov regularization strength for ill-conditioned systems (default 1e-10).
92 ///
93 /// Applied as Tikhonov regularization (diagonal loading) when the M matrix
94 /// condition number proxy exceeds 1e12. Increase to 1e-6 if you observe
95 /// numerical warnings or NaN in scores.
96 pub regularization_eps: f64,
97}
98
99impl Default for PartialDomainConfig {
100 fn default() -> Self {
101 Self {
102 ncomp: 5,
103 alpha: 0.05,
104 completion: DomainCompletion::ConditionalExpectation,
105 regularization_eps: 1e-10,
106 }
107 }
108}
109
110/// Result of partial-domain monitoring for a single observation.
111#[derive(Debug, Clone, PartialEq)]
112#[non_exhaustive]
113pub struct PartialMonitorResult {
114 /// Estimated FPC scores.
115 pub scores: Vec<f64>,
116 /// T-squared statistic.
117 pub t2: f64,
118 /// Whether T-squared exceeds the control limit.
119 pub t2_alarm: bool,
120 /// Fraction of domain observed.
121 pub domain_fraction: f64,
122 /// Completed curve (if using ConditionalExpectation or ZeroPad).
123 pub completed_curve: Option<Vec<f64>>,
124}
125
126/// Monitor a single partially-observed curve.
127///
128/// # Arguments
129/// * `chart` - Phase I SPM chart
130/// * `partial_values` - Observed values (length m, but only first `n_observed` are used)
131/// * `argvals` - Full grid points (length m)
132/// * `n_observed` - Number of observed grid points (from the start of the domain)
133/// * `config` - Partial domain configuration
134///
135/// For domain fractions below 0.3, conditional expectation accuracy degrades
136/// significantly. Consider collecting more of the domain or using a dedicated
137/// early-detection method.
138///
139/// # Example
140///
141/// ```
142/// use fdars_core::matrix::FdMatrix;
143/// use fdars_core::spm::phase::{spm_phase1, SpmConfig};
144/// use fdars_core::spm::partial::{spm_monitor_partial, PartialDomainConfig, DomainCompletion};
145/// let data = FdMatrix::from_column_major(
146/// (0..200).map(|i| (i as f64 * 0.1).sin()).collect(), 20, 10
147/// ).unwrap();
148/// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
149/// let chart = spm_phase1(&data, &argvals, &{ let mut cfg = SpmConfig::default(); cfg.ncomp = 2; cfg }).unwrap();
150/// let partial_values = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.0, 0.0, 0.0, 0.0, 0.0];
151/// let mut config = PartialDomainConfig::default();
152/// config.ncomp = 2;
153/// config.completion = DomainCompletion::ZeroPad;
154/// let result = spm_monitor_partial(&chart, &partial_values, &argvals, 5, &config).unwrap();
155/// assert!(result.domain_fraction > 0.0);
156/// ```
157///
158/// # Errors
159///
160/// Returns [`FdarError::InvalidDimension`] if dimensions are inconsistent.
161/// Returns [`FdarError::InvalidParameter`] if `n_observed` is 0, if `argvals`
162/// is not sorted, or if the computed domain fraction is out of range.
163#[must_use = "monitoring result should not be discarded"]
164pub fn spm_monitor_partial(
165 chart: &SpmChart,
166 partial_values: &[f64],
167 argvals: &[f64],
168 n_observed: usize,
169 config: &PartialDomainConfig,
170) -> Result<PartialMonitorResult, FdarError> {
171 let m = chart.fpca.mean.len();
172 if argvals.len() != m {
173 return Err(FdarError::InvalidDimension {
174 parameter: "argvals",
175 expected: format!("{m}"),
176 actual: format!("{}", argvals.len()),
177 });
178 }
179 if partial_values.len() < n_observed {
180 return Err(FdarError::InvalidDimension {
181 parameter: "partial_values",
182 expected: format!("at least {n_observed} values"),
183 actual: format!("{} values", partial_values.len()),
184 });
185 }
186 if n_observed == 0 {
187 return Err(FdarError::InvalidParameter {
188 parameter: "n_observed",
189 message: "n_observed must be at least 1".to_string(),
190 });
191 }
192 // Ensure argvals is sorted (strictly increasing endpoints)
193 if m > 1 && argvals[0] >= argvals[m - 1] {
194 return Err(FdarError::InvalidParameter {
195 parameter: "argvals",
196 message: "argvals must be sorted (first element must be less than last)".to_string(),
197 });
198 }
199
200 let ncomp = config.ncomp.min(chart.eigenvalues.len());
201 // Domain fraction: ratio of observed domain range to full range.
202 // For n_observed=1, the range is zero so we fall back to point-count fraction.
203 let domain_fraction = if m <= 1 {
204 1.0
205 } else {
206 let range_fraction =
207 (argvals[n_observed.min(m) - 1] - argvals[0]) / (argvals[m - 1] - argvals[0]);
208 if range_fraction > 0.0 {
209 range_fraction
210 } else {
211 // Single observed point: use point-count fraction as fallback.
212 // The point-count fraction n_obs/m is used when the range-based
213 // fraction is zero (single observed point). This is consistent
214 // with the PACE framework (Yao et al., 2005) where prediction
215 // from a single observation reduces to the marginal BLUP.
216 n_observed as f64 / m as f64
217 }
218 };
219 if !(0.0..=1.0).contains(&domain_fraction) {
220 return Err(FdarError::InvalidParameter {
221 parameter: "domain_fraction",
222 message: format!("computed domain_fraction must be in [0, 1], got {domain_fraction}"),
223 });
224 }
225
226 let (scores, completed_curve) = match &config.completion {
227 DomainCompletion::ConditionalExpectation => conditional_expectation(
228 chart,
229 partial_values,
230 argvals,
231 n_observed,
232 ncomp,
233 config.regularization_eps,
234 )?,
235 DomainCompletion::PartialProjection => {
236 let scores = partial_projection(chart, partial_values, argvals, n_observed, ncomp)?;
237 (scores, None)
238 }
239 DomainCompletion::ZeroPad => zero_pad(chart, partial_values, argvals, n_observed, ncomp)?,
240 };
241
242 // Compute T-squared
243 let eigenvalues = &chart.eigenvalues[..ncomp];
244 let score_mat = FdMatrix::from_column_major(scores.clone(), 1, ncomp)?;
245 let t2_vec = hotelling_t2(&score_mat, eigenvalues)?;
246 let t2 = t2_vec[0];
247 let t2_alarm = t2 > chart.t2_limit.ucl;
248
249 Ok(PartialMonitorResult {
250 scores,
251 t2,
252 t2_alarm,
253 domain_fraction,
254 completed_curve,
255 })
256}
257
258/// Monitor a batch of partially-observed curves.
259///
260/// # Arguments
261/// * `chart` - Phase I SPM chart
262/// * `partial_data` - Slice of (values, n_observed) pairs
263/// * `argvals` - Full grid points (length m)
264/// * `config` - Partial domain configuration
265///
266/// # Errors
267///
268/// Returns errors from individual monitoring calls.
269#[must_use = "monitoring results should not be discarded"]
270pub fn spm_monitor_partial_batch(
271 chart: &SpmChart,
272 partial_data: &[(&[f64], usize)],
273 argvals: &[f64],
274 config: &PartialDomainConfig,
275) -> Result<Vec<PartialMonitorResult>, FdarError> {
276 partial_data
277 .iter()
278 .map(|(values, n_obs)| spm_monitor_partial(chart, values, argvals, *n_obs, config))
279 .collect()
280}
281
282// -- Completion strategies ------------------------------------------------
283
284/// Conditional Expectation (BLUP) following Yao et al. (2005, section 3, Eq. 6):
285///
286/// The posterior score estimate under Gaussian assumptions is:
287/// xi_hat = Lambda · Phi_obs^T · (Phi_obs · Lambda · Phi_obs^T + sigma^2 I)^{-1} · y_obs
288///
289/// Implemented via the Woodbury identity as the equivalent small system:
290/// M · xi_hat = sigma^{-2} Phi_obs^T y_obs
291/// where M = Lambda^{-1} + sigma^{-2} Phi_obs^T Phi_obs (ncomp x ncomp).
292///
293/// Components:
294/// - Lambda = diag(eigenvalues) (ncomp x ncomp): prior score covariance
295/// - Phi_obs = rotation[0..n_observed, :] (n_observed x ncomp): eigenfunctions at observed points
296/// - y_obs = partial_values[0..n_observed] - mean[0..n_observed]: centered observations
297/// - sigma^2: measurement noise, estimated from the median Phase I SPE divided by m
298/// (robust to outliers; Yao et al. recommend median-based estimation)
299fn conditional_expectation(
300 chart: &SpmChart,
301 partial_values: &[f64],
302 _argvals: &[f64],
303 n_observed: usize,
304 ncomp: usize,
305 reg_eps: f64,
306) -> Result<(Vec<f64>, Option<Vec<f64>>), FdarError> {
307 let m = chart.fpca.mean.len();
308 let n_obs = n_observed.min(m);
309
310 // Centered observed values
311 let y_obs: Vec<f64> = (0..n_obs)
312 .map(|j| partial_values[j] - chart.fpca.mean[j])
313 .collect();
314
315 // Estimate sigma^2 from median SPE (robust to outliers, Yao et al. 2005)
316 let sigma2 = if !chart.spe_phase1.is_empty() {
317 let mut sorted_spe = chart.spe_phase1.clone();
318 sorted_spe.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
319 let mid = sorted_spe.len() / 2;
320 let median_spe = if sorted_spe.len() % 2 == 0 {
321 (sorted_spe[mid - 1] + sorted_spe[mid]) / 2.0
322 } else {
323 sorted_spe[mid]
324 };
325 // Normalize by grid size to get per-point variance
326 (median_spe / m as f64).max(1e-10)
327 } else {
328 1e-6
329 };
330
331 // Compute Phi_obs^T y_obs (ncomp vector)
332 let phi_t_y: Vec<f64> = (0..ncomp)
333 .map(|l| {
334 (0..n_obs)
335 .map(|j| chart.fpca.rotation[(j, l)] * y_obs[j])
336 .sum::<f64>()
337 })
338 .collect();
339
340 // Use the Woodbury identity to work with the small ncomp x ncomp system:
341 // M = Lambda^{-1} + sigma^{-2} Phi_obs^T Phi_obs
342 // scores = M^{-1} sigma^{-2} Phi_obs^T y_obs
343 let mut m_matrix = vec![0.0_f64; ncomp * ncomp];
344 for l in 0..ncomp {
345 // Diagonal: Lambda^{-1}
346 m_matrix[l * ncomp + l] += 1.0 / chart.eigenvalues[l];
347 // Add sigma^{-2} Phi^T Phi
348 for k in 0..ncomp {
349 let phi_t_phi: f64 = (0..n_obs)
350 .map(|j| chart.fpca.rotation[(j, l)] * chart.fpca.rotation[(j, k)])
351 .sum();
352 m_matrix[l * ncomp + k] += phi_t_phi / sigma2;
353 }
354 }
355
356 // Check condition number via diagonal ratio (cheap proxy)
357 let mut diag_min = f64::INFINITY;
358 let mut diag_max = 0.0_f64;
359 for l in 0..ncomp {
360 let d = m_matrix[l * ncomp + l];
361 if d > 0.0 {
362 diag_min = diag_min.min(d);
363 diag_max = diag_max.max(d);
364 }
365 }
366 if diag_max > 0.0 && diag_max / diag_min > 1e12 {
367 // Ill-conditioned: add Tikhonov regularization
368 let reg = diag_max * reg_eps;
369 for l in 0..ncomp {
370 m_matrix[l * ncomp + l] += reg;
371 }
372 }
373
374 // Right-hand side: sigma^{-2} Phi^T y
375 let rhs: Vec<f64> = phi_t_y.iter().map(|&v| v / sigma2).collect();
376
377 // Solve M * scores = rhs using Cholesky (M is SPD).
378 // If Cholesky fails (near-indefinite), retry with progressively stronger regularization.
379 let scores = match solve_spd(&m_matrix, &rhs, ncomp) {
380 Ok(s) => s,
381 Err(_) => {
382 // Retry with stronger Tikhonov regularization
383 let mut m_reg = m_matrix.clone();
384 let mut reg_strength = diag_max * 1e-8;
385 let mut result = None;
386 for _ in 0..5 {
387 for l in 0..ncomp {
388 m_reg[l * ncomp + l] = m_matrix[l * ncomp + l] + reg_strength;
389 }
390 if let Ok(s) = solve_spd(&m_reg, &rhs, ncomp) {
391 result = Some(s);
392 break;
393 }
394 reg_strength *= 10.0;
395 }
396 result.ok_or(FdarError::ComputationFailed {
397 operation: "conditional_expectation",
398 detail: "Cholesky failed even with strong regularization".to_string(),
399 })?
400 }
401 };
402
403 // Reconstruct the full curve: mean + Phi * scores
404 let mut completed = chart.fpca.mean.clone();
405 for j in 0..m {
406 for l in 0..ncomp {
407 completed[j] += chart.fpca.rotation[(j, l)] * scores[l];
408 }
409 }
410
411 Ok((scores, Some(completed)))
412}
413
414/// Partial projection: scale inner products by domain fraction.
415///
416/// The scaling factor (full_total / partial_total) compensates for the reduced
417/// integration domain, assuming the eigenfunction structure is approximately
418/// uniform across the domain. This is a first-order correction that degrades
419/// when eigenfunctions have localized support outside the observed region.
420fn partial_projection(
421 chart: &SpmChart,
422 partial_values: &[f64],
423 argvals: &[f64],
424 n_observed: usize,
425 ncomp: usize,
426) -> Result<Vec<f64>, FdarError> {
427 let m = chart.fpca.mean.len();
428 let n_obs = n_observed.min(m);
429
430 // Centered observed values
431 let y_obs: Vec<f64> = (0..n_obs)
432 .map(|j| partial_values[j] - chart.fpca.mean[j])
433 .collect();
434
435 // Integration weights for partial domain
436 let partialargvals = &argvals[..n_obs];
437 let weights = simpsons_weights(partialargvals);
438
439 // Full domain weights (for normalization)
440 let full_weights = simpsons_weights(argvals);
441 let full_total: f64 = full_weights.iter().sum();
442 let partial_total: f64 = weights.iter().sum();
443
444 // Scale factor: full_domain_width / partial_domain_width
445 let scale = if partial_total > 0.0 {
446 full_total / partial_total
447 } else {
448 1.0
449 };
450
451 // Compute scores as scaled partial inner products
452 let scores: Vec<f64> = (0..ncomp)
453 .map(|l| {
454 let ip: f64 = (0..n_obs)
455 .map(|j| y_obs[j] * chart.fpca.rotation[(j, l)] * weights[j])
456 .sum();
457 ip * scale
458 })
459 .collect();
460
461 Ok(scores)
462}
463
464/// Zero-pad: fill unobserved with mean, project normally.
465fn zero_pad(
466 chart: &SpmChart,
467 partial_values: &[f64],
468 _argvals: &[f64],
469 n_observed: usize,
470 ncomp: usize,
471) -> Result<(Vec<f64>, Option<Vec<f64>>), FdarError> {
472 let m = chart.fpca.mean.len();
473 let n_obs = n_observed.min(m);
474
475 // Build padded curve: observed values + mean for unobserved
476 let mut padded = chart.fpca.mean.clone();
477 padded[..n_obs].copy_from_slice(&partial_values[..n_obs]);
478
479 // Center and project
480 let mut centered = vec![0.0; m];
481 for j in 0..m {
482 centered[j] = padded[j] - chart.fpca.mean[j];
483 }
484
485 let scores: Vec<f64> = (0..ncomp)
486 .map(|l| {
487 (0..m)
488 .map(|j| centered[j] * chart.fpca.rotation[(j, l)] * chart.fpca.weights[j])
489 .sum()
490 })
491 .collect();
492
493 Ok((scores, Some(padded)))
494}
495
496// -- Small linear algebra helpers -----------------------------------------
497
498/// Solve A*x = b where A is symmetric positive definite, via Cholesky.
499fn solve_spd(a: &[f64], b: &[f64], n: usize) -> Result<Vec<f64>, FdarError> {
500 // Cholesky factorization: A = L L^T
501 let mut l = vec![0.0_f64; n * n];
502
503 for j in 0..n {
504 let mut sum = 0.0;
505 for k in 0..j {
506 sum += l[j * n + k] * l[j * n + k];
507 }
508 let diag = a[j * n + j] - sum;
509 if diag <= 0.0 || diag.is_nan() {
510 return Err(FdarError::ComputationFailed {
511 operation: "cholesky",
512 detail: "matrix is not positive definite".to_string(),
513 });
514 }
515 l[j * n + j] = diag.sqrt();
516
517 for i in (j + 1)..n {
518 let mut sum = 0.0;
519 for k in 0..j {
520 sum += l[i * n + k] * l[j * n + k];
521 }
522 l[i * n + j] = (a[i * n + j] - sum) / l[j * n + j];
523 }
524 }
525
526 // Forward substitution: L y = b
527 let mut y = vec![0.0_f64; n];
528 for i in 0..n {
529 let mut sum = 0.0;
530 for k in 0..i {
531 sum += l[i * n + k] * y[k];
532 }
533 y[i] = (b[i] - sum) / l[i * n + i];
534 }
535
536 // Back substitution: L^T x = y
537 let mut x = vec![0.0_f64; n];
538 for i in (0..n).rev() {
539 let mut sum = 0.0;
540 for k in (i + 1)..n {
541 sum += l[k * n + i] * x[k];
542 }
543 x[i] = (y[i] - sum) / l[i * n + i];
544 }
545
546 Ok(x)
547}