Skip to main content

fdars_core/spm/
mfpca.rs

1//! Multivariate Functional Principal Component Analysis (MFPCA).
2//!
3//! Extends univariate FPCA to handle multiple functional variables
4//! observed on potentially different grids. Variables are optionally
5//! weighted by their inverse standard deviation before joint SVD.
6//!
7//! # Algorithm
8//!
9//! 1. Center each variable by its column means.
10//! 2. Standardize each variable by its scale (sqrt of mean column variance)
11//!    when `weighted = true`. This ensures variables on different scales
12//!    contribute equally to the joint decomposition.
13//! 3. Horizontally concatenate the centered/scaled variables into an
14//!    n × (sum m_p) matrix and perform truncated SVD.
15//! 4. Extract scores as U * S and eigenfunctions from V. Eigenfunctions
16//!    are back-scaled by the per-variable scale factor when reconstructing,
17//!    so that reconstruction returns values on the original measurement scale.
18//!
19//! # Projection accuracy
20//!
21//! The projection error ||X - X_hat||² is bounded by sum_{l > ncomp} lambda_l
22//! (the sum of discarded eigenvalues). This provides an a priori error bound
23//! for choosing ncomp: retain enough components so that the discarded
24//! eigenvalue tail is acceptably small relative to the total variance.
25//!
26//! # Scale threshold
27//!
28//! Variables with scale < 1e-12 (relative to the maximum scale across all
29//! variables) are treated as constant and receive unit weight (scale = 1.0).
30//! This prevents numerical issues from near-zero denominators in the
31//! standardization step.
32//!
33//! # References
34//!
35//! - Happ, C. & Greven, S. (2018). Multivariate functional principal component
36//!   analysis for data observed on different (dimensional) domains. *Journal of
37//!   the American Statistical Association*, 113(522), 649-659. The weighting
38//!   scheme follows Section 2.2 (standardization by variable-specific scale)
39//!   and the eigendecomposition is described in Section 2.3, Eq. 3-5.
40
41use crate::error::FdarError;
42use crate::matrix::FdMatrix;
43use nalgebra::SVD;
44
45/// Configuration for multivariate FPCA.
46///
47/// Construct via `MfpcaConfig::default()`, then assign the fields you need (e.g. `let mut c = MfpcaConfig::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.
48#[non_exhaustive]
49#[derive(Debug, Clone, PartialEq)]
50#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
51pub struct MfpcaConfig {
52    /// Number of principal components to extract (default 5).
53    pub ncomp: usize,
54    /// Whether to weight each variable by 1/std_dev before SVD (default true).
55    pub weighted: bool,
56}
57
58impl Default for MfpcaConfig {
59    fn default() -> Self {
60        Self {
61            ncomp: 5,
62            weighted: true,
63        }
64    }
65}
66
67/// Result of multivariate FPCA.
68#[derive(Debug, Clone, PartialEq)]
69#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
70#[non_exhaustive]
71pub struct MfpcaResult {
72    /// Score matrix (n x ncomp).
73    pub scores: FdMatrix,
74    /// Eigenfunctions split by variable (one FdMatrix per variable, each m_p x ncomp).
75    pub eigenfunctions: Vec<FdMatrix>,
76    /// Eigenvalues (length ncomp): squared singular values divided by (n-1).
77    pub eigenvalues: Vec<f64>,
78    /// Per-variable mean functions.
79    pub means: Vec<Vec<f64>>,
80    /// Per-variable standard deviations (sqrt of mean column variance).
81    pub scales: Vec<f64>,
82    /// Grid sizes per variable.
83    pub grid_sizes: Vec<usize>,
84    /// Combined rotation matrix (sum(m_p) x ncomp) — internal use for projection.
85    pub(super) combined_rotation: FdMatrix,
86    /// Threshold below which a variable's scale is treated as 1.0 (avoids division by near-zero).
87    pub(super) scale_threshold: f64,
88}
89
90impl MfpcaResult {
91    /// Project new multivariate functional data onto the MFPCA score space.
92    ///
93    /// Each element of `new_data` is an n_new x m_p matrix for variable p.
94    ///
95    /// # Errors
96    ///
97    /// Returns [`FdarError::InvalidDimension`] if the number of variables or
98    /// their grid sizes do not match the training data.
99    pub fn project(&self, new_data: &[&FdMatrix]) -> Result<FdMatrix, FdarError> {
100        if new_data.len() != self.means.len() {
101            return Err(FdarError::InvalidDimension {
102                parameter: "new_data",
103                expected: format!("{} variables", self.means.len()),
104                actual: format!("{} variables", new_data.len()),
105            });
106        }
107
108        // Early check: total columns across all variables must match the
109        // combined rotation matrix rows (i.e., the sum of training grid sizes).
110        let total_input_cols: usize = new_data.iter().map(|v| v.ncols()).sum();
111        let expected_total: usize = self.grid_sizes.iter().sum();
112        if total_input_cols != expected_total {
113            return Err(FdarError::InvalidDimension {
114                parameter: "new_data",
115                expected: format!("{expected_total} total columns across all variables"),
116                actual: format!("{total_input_cols} total columns"),
117            });
118        }
119
120        let n_new = new_data[0].nrows();
121        let ncomp = self.scores.ncols();
122        let total_cols: usize = self.grid_sizes.iter().sum();
123
124        // Center, scale, and stack
125        let mut stacked = FdMatrix::zeros(n_new, total_cols);
126        let mut col_offset = 0;
127        for (p, &var) in new_data.iter().enumerate() {
128            let m_p = self.grid_sizes[p];
129            if var.ncols() != m_p {
130                return Err(FdarError::InvalidDimension {
131                    parameter: "new_data",
132                    expected: format!("{m_p} columns for variable {p}"),
133                    actual: format!("{} columns", var.ncols()),
134                });
135            }
136            if var.nrows() != n_new {
137                return Err(FdarError::InvalidDimension {
138                    parameter: "new_data",
139                    expected: format!("{n_new} rows for all variables"),
140                    actual: format!("{} rows for variable {p}", var.nrows()),
141                });
142            }
143            let scale = if self.scales[p] >= self.scale_threshold {
144                self.scales[p]
145            } else {
146                1.0
147            };
148            for i in 0..n_new {
149                for j in 0..m_p {
150                    let centered = var[(i, j)] - self.means[p][j];
151                    stacked[(i, col_offset + j)] = centered / scale;
152                }
153            }
154            col_offset += m_p;
155        }
156
157        // Multiply by combined rotation: scores = stacked * rotation
158        let mut scores = FdMatrix::zeros(n_new, ncomp);
159        for i in 0..n_new {
160            for k in 0..ncomp {
161                let mut sum = 0.0;
162                for j in 0..total_cols {
163                    sum += stacked[(i, j)] * self.combined_rotation[(j, k)];
164                }
165                scores[(i, k)] = sum;
166            }
167        }
168        Ok(scores)
169    }
170
171    /// Reconstruct multivariate functional data from scores.
172    ///
173    /// Returns one FdMatrix per variable (n x m_p).
174    ///
175    /// # Errors
176    ///
177    /// Returns [`FdarError::InvalidParameter`] if `ncomp` exceeds available components.
178    pub fn reconstruct(&self, scores: &FdMatrix, ncomp: usize) -> Result<Vec<FdMatrix>, FdarError> {
179        let max_comp = self.combined_rotation.ncols().min(scores.ncols());
180        if ncomp == 0 || ncomp > max_comp {
181            return Err(FdarError::InvalidParameter {
182                parameter: "ncomp",
183                message: format!("ncomp={ncomp} must be in 1..={max_comp}"),
184            });
185        }
186
187        let n = scores.nrows();
188        let total_cols: usize = self.grid_sizes.iter().sum();
189
190        // Reconstruct in combined space: stacked_recon = scores * rotation^T
191        let mut stacked = FdMatrix::zeros(n, total_cols);
192        for i in 0..n {
193            for j in 0..total_cols {
194                let mut val = 0.0;
195                for k in 0..ncomp {
196                    val += scores[(i, k)] * self.combined_rotation[(j, k)];
197                }
198                stacked[(i, j)] = val;
199            }
200        }
201
202        // Split by variable, un-scale, and add means
203        let mut result = Vec::with_capacity(self.means.len());
204        let mut col_offset = 0;
205        for (p, m_p) in self.grid_sizes.iter().enumerate() {
206            let scale = if self.scales[p] >= self.scale_threshold {
207                self.scales[p]
208            } else {
209                1.0
210            };
211            let mut var_mat = FdMatrix::zeros(n, *m_p);
212            for i in 0..n {
213                for j in 0..*m_p {
214                    var_mat[(i, j)] = stacked[(i, col_offset + j)] * scale + self.means[p][j];
215                }
216            }
217            col_offset += m_p;
218            result.push(var_mat);
219        }
220        Ok(result)
221    }
222}
223
224/// Perform multivariate FPCA on multiple functional variables.
225///
226/// # Arguments
227/// * `variables` - Slice of n x m_p matrices, one per functional variable
228/// * `config` - MFPCA configuration
229///
230/// # Example
231///
232/// ```
233/// use fdars_core::matrix::FdMatrix;
234/// use fdars_core::spm::mfpca::{mfpca, MfpcaConfig};
235/// let var1 = FdMatrix::from_column_major(vec![1.0,2.0,3.0,4.0,5.0,6.0], 3, 2).unwrap();
236/// let var2 = FdMatrix::from_column_major(vec![0.5,1.5,2.5,3.5,4.5,5.5], 3, 2).unwrap();
237/// let mut config = MfpcaConfig::default();
238/// config.ncomp = 2;
239/// config.weighted = true;
240/// let result = mfpca(&[&var1, &var2], &config).unwrap();
241/// assert_eq!(result.eigenvalues.len(), 2);
242/// assert!(result.eigenvalues[0] >= result.eigenvalues[1]);
243/// ```
244///
245/// # Errors
246///
247/// Returns [`FdarError::InvalidDimension`] if no variables are provided or
248/// variables have inconsistent row counts. Returns [`FdarError::ComputationFailed`]
249/// if the SVD fails.
250#[must_use = "expensive computation whose result should not be discarded"]
251pub fn mfpca(variables: &[&FdMatrix], config: &MfpcaConfig) -> Result<MfpcaResult, FdarError> {
252    if variables.is_empty() {
253        return Err(FdarError::InvalidDimension {
254            parameter: "variables",
255            expected: "at least 1 variable".to_string(),
256            actual: "0 variables".to_string(),
257        });
258    }
259
260    let n = variables[0].nrows();
261    if n < 2 {
262        return Err(FdarError::InvalidDimension {
263            parameter: "variables",
264            expected: "at least 2 observations".to_string(),
265            actual: format!("{n} observations"),
266        });
267    }
268
269    for (p, var) in variables.iter().enumerate() {
270        if var.nrows() != n {
271            return Err(FdarError::InvalidDimension {
272                parameter: "variables",
273                expected: format!("{n} rows for all variables"),
274                actual: format!("{} rows for variable {p}", var.nrows()),
275            });
276        }
277    }
278
279    let grid_sizes: Vec<usize> = variables.iter().map(|v| v.ncols()).collect();
280    let total_cols: usize = grid_sizes.iter().sum();
281    let ncomp = config.ncomp.min(n).min(total_cols);
282
283    // Step 1: Center each variable and compute scale
284    let mut means: Vec<Vec<f64>> = Vec::with_capacity(variables.len());
285    let mut scales: Vec<f64> = Vec::with_capacity(variables.len());
286
287    for var in variables.iter() {
288        let (_, m_p) = var.shape();
289        let mut mean = vec![0.0; m_p];
290        for j in 0..m_p {
291            let col = var.column(j);
292            mean[j] = col.iter().sum::<f64>() / n as f64;
293        }
294
295        // Scale = sqrt(mean of column variances)
296        let mut mean_var = 0.0;
297        for j in 0..m_p {
298            let col = var.column(j);
299            let var_j: f64 =
300                col.iter().map(|&v| (v - mean[j]).powi(2)).sum::<f64>() / (n as f64 - 1.0);
301            mean_var += var_j;
302        }
303        mean_var /= m_p as f64;
304        let scale = mean_var.sqrt();
305
306        means.push(mean);
307        scales.push(scale);
308    }
309
310    // Use relative threshold for scale: 1e-12 * max(scales).
311    // Variables with scale below this threshold contribute negligible variance
312    // and are effectively constant. Treating them as unscaled avoids division
313    // by near-zero values.
314    let max_scale = scales.iter().cloned().fold(0.0_f64, f64::max);
315    let scale_threshold = 1e-12 * max_scale.max(1e-15); // floor to avoid 0
316
317    // Step 2: Build stacked matrix (n x total_cols)
318    let mut stacked = FdMatrix::zeros(n, total_cols);
319    let mut col_offset = 0;
320    for (p, var) in variables.iter().enumerate() {
321        let m_p = grid_sizes[p];
322        let scale = if config.weighted && scales[p] > scale_threshold {
323            scales[p]
324        } else {
325            1.0
326        };
327        // Update scales to reflect actual scaling applied
328        if !config.weighted {
329            scales[p] = 1.0;
330        }
331        for i in 0..n {
332            for j in 0..m_p {
333                let centered = var[(i, j)] - means[p][j];
334                stacked[(i, col_offset + j)] = centered / scale;
335            }
336        }
337        col_offset += m_p;
338    }
339
340    // Step 3: SVD on stacked matrix
341    let svd = SVD::new(stacked.to_dmatrix(), true, true);
342
343    let v_t = svd
344        .v_t
345        .as_ref()
346        .ok_or_else(|| FdarError::ComputationFailed {
347            operation: "MFPCA SVD",
348            detail: "SVD failed to produce V_t matrix".to_string(),
349        })?;
350
351    let u = svd.u.as_ref().ok_or_else(|| FdarError::ComputationFailed {
352        operation: "MFPCA SVD",
353        detail: "SVD failed to produce U matrix".to_string(),
354    })?;
355
356    // Step 4: Extract components
357    let singular_values: Vec<f64> = svd.singular_values.iter().take(ncomp).copied().collect();
358
359    // Eigenvalues = sv^2 / (n - 1)
360    let eigenvalues: Vec<f64> = singular_values
361        .iter()
362        .map(|&sv| sv * sv / (n as f64 - 1.0))
363        .collect();
364
365    // Combined rotation: V[:, :ncomp] (total_cols x ncomp)
366    let mut combined_rotation = FdMatrix::zeros(total_cols, ncomp);
367    for k in 0..ncomp {
368        for j in 0..total_cols {
369            combined_rotation[(j, k)] = v_t[(k, j)];
370        }
371    }
372
373    // Scores: U * S (n x ncomp)
374    let mut scores = FdMatrix::zeros(n, ncomp);
375    for k in 0..ncomp {
376        let sv_k = singular_values[k];
377        for i in 0..n {
378            scores[(i, k)] = u[(i, k)] * sv_k;
379        }
380    }
381
382    // Step 5: Split eigenfunctions by variable
383    let mut eigenfunctions = Vec::with_capacity(variables.len());
384    let mut col_off = 0;
385    for m_p in &grid_sizes {
386        let mut ef = FdMatrix::zeros(*m_p, ncomp);
387        for k in 0..ncomp {
388            for j in 0..*m_p {
389                ef[(j, k)] = combined_rotation[(col_off + j, k)];
390            }
391        }
392        col_off += m_p;
393        eigenfunctions.push(ef);
394    }
395
396    Ok(MfpcaResult {
397        scores,
398        eigenfunctions,
399        eigenvalues,
400        means,
401        scales,
402        grid_sizes,
403        combined_rotation,
404        scale_threshold,
405    })
406}