Skip to main content

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}