fdars-core 0.42.0

Functional Data Analysis algorithms in Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! Multivariate Functional Principal Component Analysis (MFPCA).
//!
//! Extends univariate FPCA to handle multiple functional variables
//! observed on potentially different grids. Variables are optionally
//! weighted by their inverse standard deviation before joint SVD.
//!
//! # Algorithm
//!
//! 1. Center each variable by its column means.
//! 2. Standardize each variable by its scale (sqrt of mean column variance)
//!    when `weighted = true`. This ensures variables on different scales
//!    contribute equally to the joint decomposition.
//! 3. Horizontally concatenate the centered/scaled variables into an
//!    n × (sum m_p) matrix and perform truncated SVD.
//! 4. Extract scores as U * S and eigenfunctions from V. Eigenfunctions
//!    are back-scaled by the per-variable scale factor when reconstructing,
//!    so that reconstruction returns values on the original measurement scale.
//!
//! # Projection accuracy
//!
//! The projection error ||X - X_hat||² is bounded by sum_{l > ncomp} lambda_l
//! (the sum of discarded eigenvalues). This provides an a priori error bound
//! for choosing ncomp: retain enough components so that the discarded
//! eigenvalue tail is acceptably small relative to the total variance.
//!
//! # Scale threshold
//!
//! Variables with scale < 1e-12 (relative to the maximum scale across all
//! variables) are treated as constant and receive unit weight (scale = 1.0).
//! This prevents numerical issues from near-zero denominators in the
//! standardization step.
//!
//! # References
//!
//! - Happ, C. & Greven, S. (2018). Multivariate functional principal component
//!   analysis for data observed on different (dimensional) domains. *Journal of
//!   the American Statistical Association*, 113(522), 649-659. The weighting
//!   scheme follows Section 2.2 (standardization by variable-specific scale)
//!   and the eigendecomposition is described in Section 2.3, Eq. 3-5.

use crate::error::FdarError;
use crate::matrix::FdMatrix;
use nalgebra::SVD;

/// Configuration for multivariate FPCA.
///
/// 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.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct MfpcaConfig {
    /// Number of principal components to extract (default 5).
    pub ncomp: usize,
    /// Whether to weight each variable by 1/std_dev before SVD (default true).
    pub weighted: bool,
}

impl Default for MfpcaConfig {
    fn default() -> Self {
        Self {
            ncomp: 5,
            weighted: true,
        }
    }
}

/// Result of multivariate FPCA.
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct MfpcaResult {
    /// Score matrix (n x ncomp).
    pub scores: FdMatrix,
    /// Eigenfunctions split by variable (one FdMatrix per variable, each m_p x ncomp).
    pub eigenfunctions: Vec<FdMatrix>,
    /// Eigenvalues (length ncomp): squared singular values divided by (n-1).
    pub eigenvalues: Vec<f64>,
    /// Per-variable mean functions.
    pub means: Vec<Vec<f64>>,
    /// Per-variable standard deviations (sqrt of mean column variance).
    pub scales: Vec<f64>,
    /// Grid sizes per variable.
    pub grid_sizes: Vec<usize>,
    /// Combined rotation matrix (sum(m_p) x ncomp) — internal use for projection.
    pub(super) combined_rotation: FdMatrix,
    /// Threshold below which a variable's scale is treated as 1.0 (avoids division by near-zero).
    pub(super) scale_threshold: f64,
}

impl MfpcaResult {
    /// Project new multivariate functional data onto the MFPCA score space.
    ///
    /// Each element of `new_data` is an n_new x m_p matrix for variable p.
    ///
    /// # Errors
    ///
    /// Returns [`FdarError::InvalidDimension`] if the number of variables or
    /// their grid sizes do not match the training data.
    pub fn project(&self, new_data: &[&FdMatrix]) -> Result<FdMatrix, FdarError> {
        if new_data.len() != self.means.len() {
            return Err(FdarError::InvalidDimension {
                parameter: "new_data",
                expected: format!("{} variables", self.means.len()),
                actual: format!("{} variables", new_data.len()),
            });
        }

        // Early check: total columns across all variables must match the
        // combined rotation matrix rows (i.e., the sum of training grid sizes).
        let total_input_cols: usize = new_data.iter().map(|v| v.ncols()).sum();
        let expected_total: usize = self.grid_sizes.iter().sum();
        if total_input_cols != expected_total {
            return Err(FdarError::InvalidDimension {
                parameter: "new_data",
                expected: format!("{expected_total} total columns across all variables"),
                actual: format!("{total_input_cols} total columns"),
            });
        }

        let n_new = new_data[0].nrows();
        let ncomp = self.scores.ncols();
        let total_cols: usize = self.grid_sizes.iter().sum();

        // Center, scale, and stack
        let mut stacked = FdMatrix::zeros(n_new, total_cols);
        let mut col_offset = 0;
        for (p, &var) in new_data.iter().enumerate() {
            let m_p = self.grid_sizes[p];
            if var.ncols() != m_p {
                return Err(FdarError::InvalidDimension {
                    parameter: "new_data",
                    expected: format!("{m_p} columns for variable {p}"),
                    actual: format!("{} columns", var.ncols()),
                });
            }
            if var.nrows() != n_new {
                return Err(FdarError::InvalidDimension {
                    parameter: "new_data",
                    expected: format!("{n_new} rows for all variables"),
                    actual: format!("{} rows for variable {p}", var.nrows()),
                });
            }
            let scale = if self.scales[p] >= self.scale_threshold {
                self.scales[p]
            } else {
                1.0
            };
            for i in 0..n_new {
                for j in 0..m_p {
                    let centered = var[(i, j)] - self.means[p][j];
                    stacked[(i, col_offset + j)] = centered / scale;
                }
            }
            col_offset += m_p;
        }

        // Multiply by combined rotation: scores = stacked * rotation
        let mut scores = FdMatrix::zeros(n_new, ncomp);
        for i in 0..n_new {
            for k in 0..ncomp {
                let mut sum = 0.0;
                for j in 0..total_cols {
                    sum += stacked[(i, j)] * self.combined_rotation[(j, k)];
                }
                scores[(i, k)] = sum;
            }
        }
        Ok(scores)
    }

    /// Reconstruct multivariate functional data from scores.
    ///
    /// Returns one FdMatrix per variable (n x m_p).
    ///
    /// # Errors
    ///
    /// Returns [`FdarError::InvalidParameter`] if `ncomp` exceeds available components.
    pub fn reconstruct(&self, scores: &FdMatrix, ncomp: usize) -> Result<Vec<FdMatrix>, FdarError> {
        let max_comp = self.combined_rotation.ncols().min(scores.ncols());
        if ncomp == 0 || ncomp > max_comp {
            return Err(FdarError::InvalidParameter {
                parameter: "ncomp",
                message: format!("ncomp={ncomp} must be in 1..={max_comp}"),
            });
        }

        let n = scores.nrows();
        let total_cols: usize = self.grid_sizes.iter().sum();

        // Reconstruct in combined space: stacked_recon = scores * rotation^T
        let mut stacked = FdMatrix::zeros(n, total_cols);
        for i in 0..n {
            for j in 0..total_cols {
                let mut val = 0.0;
                for k in 0..ncomp {
                    val += scores[(i, k)] * self.combined_rotation[(j, k)];
                }
                stacked[(i, j)] = val;
            }
        }

        // Split by variable, un-scale, and add means
        let mut result = Vec::with_capacity(self.means.len());
        let mut col_offset = 0;
        for (p, m_p) in self.grid_sizes.iter().enumerate() {
            let scale = if self.scales[p] >= self.scale_threshold {
                self.scales[p]
            } else {
                1.0
            };
            let mut var_mat = FdMatrix::zeros(n, *m_p);
            for i in 0..n {
                for j in 0..*m_p {
                    var_mat[(i, j)] = stacked[(i, col_offset + j)] * scale + self.means[p][j];
                }
            }
            col_offset += m_p;
            result.push(var_mat);
        }
        Ok(result)
    }
}

/// Perform multivariate FPCA on multiple functional variables.
///
/// # Arguments
/// * `variables` - Slice of n x m_p matrices, one per functional variable
/// * `config` - MFPCA configuration
///
/// # Example
///
/// ```
/// use fdars_core::matrix::FdMatrix;
/// use fdars_core::spm::mfpca::{mfpca, MfpcaConfig};
/// let var1 = FdMatrix::from_column_major(vec![1.0,2.0,3.0,4.0,5.0,6.0], 3, 2).unwrap();
/// let var2 = FdMatrix::from_column_major(vec![0.5,1.5,2.5,3.5,4.5,5.5], 3, 2).unwrap();
/// let mut config = MfpcaConfig::default();
/// config.ncomp = 2;
/// config.weighted = true;
/// let result = mfpca(&[&var1, &var2], &config).unwrap();
/// assert_eq!(result.eigenvalues.len(), 2);
/// assert!(result.eigenvalues[0] >= result.eigenvalues[1]);
/// ```
///
/// # Errors
///
/// Returns [`FdarError::InvalidDimension`] if no variables are provided or
/// variables have inconsistent row counts. Returns [`FdarError::ComputationFailed`]
/// if the SVD fails.
#[must_use = "expensive computation whose result should not be discarded"]
pub fn mfpca(variables: &[&FdMatrix], config: &MfpcaConfig) -> Result<MfpcaResult, FdarError> {
    if variables.is_empty() {
        return Err(FdarError::InvalidDimension {
            parameter: "variables",
            expected: "at least 1 variable".to_string(),
            actual: "0 variables".to_string(),
        });
    }

    let n = variables[0].nrows();
    if n < 2 {
        return Err(FdarError::InvalidDimension {
            parameter: "variables",
            expected: "at least 2 observations".to_string(),
            actual: format!("{n} observations"),
        });
    }

    for (p, var) in variables.iter().enumerate() {
        if var.nrows() != n {
            return Err(FdarError::InvalidDimension {
                parameter: "variables",
                expected: format!("{n} rows for all variables"),
                actual: format!("{} rows for variable {p}", var.nrows()),
            });
        }
    }

    let grid_sizes: Vec<usize> = variables.iter().map(|v| v.ncols()).collect();
    let total_cols: usize = grid_sizes.iter().sum();
    let ncomp = config.ncomp.min(n).min(total_cols);

    // Step 1: Center each variable and compute scale
    let mut means: Vec<Vec<f64>> = Vec::with_capacity(variables.len());
    let mut scales: Vec<f64> = Vec::with_capacity(variables.len());

    for var in variables.iter() {
        let (_, m_p) = var.shape();
        let mut mean = vec![0.0; m_p];
        for j in 0..m_p {
            let col = var.column(j);
            mean[j] = col.iter().sum::<f64>() / n as f64;
        }

        // Scale = sqrt(mean of column variances)
        let mut mean_var = 0.0;
        for j in 0..m_p {
            let col = var.column(j);
            let var_j: f64 =
                col.iter().map(|&v| (v - mean[j]).powi(2)).sum::<f64>() / (n as f64 - 1.0);
            mean_var += var_j;
        }
        mean_var /= m_p as f64;
        let scale = mean_var.sqrt();

        means.push(mean);
        scales.push(scale);
    }

    // Use relative threshold for scale: 1e-12 * max(scales).
    // Variables with scale below this threshold contribute negligible variance
    // and are effectively constant. Treating them as unscaled avoids division
    // by near-zero values.
    let max_scale = scales.iter().cloned().fold(0.0_f64, f64::max);
    let scale_threshold = 1e-12 * max_scale.max(1e-15); // floor to avoid 0

    // Step 2: Build stacked matrix (n x total_cols)
    let mut stacked = FdMatrix::zeros(n, total_cols);
    let mut col_offset = 0;
    for (p, var) in variables.iter().enumerate() {
        let m_p = grid_sizes[p];
        let scale = if config.weighted && scales[p] > scale_threshold {
            scales[p]
        } else {
            1.0
        };
        // Update scales to reflect actual scaling applied
        if !config.weighted {
            scales[p] = 1.0;
        }
        for i in 0..n {
            for j in 0..m_p {
                let centered = var[(i, j)] - means[p][j];
                stacked[(i, col_offset + j)] = centered / scale;
            }
        }
        col_offset += m_p;
    }

    // Step 3: SVD on stacked matrix
    let svd = SVD::new(stacked.to_dmatrix(), true, true);

    let v_t = svd
        .v_t
        .as_ref()
        .ok_or_else(|| FdarError::ComputationFailed {
            operation: "MFPCA SVD",
            detail: "SVD failed to produce V_t matrix".to_string(),
        })?;

    let u = svd.u.as_ref().ok_or_else(|| FdarError::ComputationFailed {
        operation: "MFPCA SVD",
        detail: "SVD failed to produce U matrix".to_string(),
    })?;

    // Step 4: Extract components
    let singular_values: Vec<f64> = svd.singular_values.iter().take(ncomp).copied().collect();

    // Eigenvalues = sv^2 / (n - 1)
    let eigenvalues: Vec<f64> = singular_values
        .iter()
        .map(|&sv| sv * sv / (n as f64 - 1.0))
        .collect();

    // Combined rotation: V[:, :ncomp] (total_cols x ncomp)
    let mut combined_rotation = FdMatrix::zeros(total_cols, ncomp);
    for k in 0..ncomp {
        for j in 0..total_cols {
            combined_rotation[(j, k)] = v_t[(k, j)];
        }
    }

    // Scores: U * S (n x ncomp)
    let mut scores = FdMatrix::zeros(n, ncomp);
    for k in 0..ncomp {
        let sv_k = singular_values[k];
        for i in 0..n {
            scores[(i, k)] = u[(i, k)] * sv_k;
        }
    }

    // Step 5: Split eigenfunctions by variable
    let mut eigenfunctions = Vec::with_capacity(variables.len());
    let mut col_off = 0;
    for m_p in &grid_sizes {
        let mut ef = FdMatrix::zeros(*m_p, ncomp);
        for k in 0..ncomp {
            for j in 0..*m_p {
                ef[(j, k)] = combined_rotation[(col_off + j, k)];
            }
        }
        col_off += m_p;
        eigenfunctions.push(ef);
    }

    Ok(MfpcaResult {
        scores,
        eigenfunctions,
        eigenvalues,
        means,
        scales,
        grid_sizes,
        combined_rotation,
        scale_threshold,
    })
}