Skip to main content

fdars_core/
regression.rs

1//! Regression functions for functional data.
2//!
3//! This module provides functional PCA, PLS, and ridge regression.
4//!
5//! # Canonical result-field vocabulary (API-01)
6//!
7//! [`FpcaResult`] defines the canonical vocabulary reused across the crate's FPCA-based
8//! result structs:
9//!
10//! | Field | Meaning |
11//! |-------|---------|
12//! | `scores` | FPC scores, `n × ncomp` (observations projected onto the component basis) |
13//! | `rotation` | Loadings / eigenfunctions, `m × ncomp` (the component basis) |
14//! | `mean` | Estimated mean function, length `m` |
15//! | `weights` | Integration weights for the functional inner product, length `m` |
16//! | `singular_values` | Singular values of the centered data (component scale) |
17//! | `centered` | The mean-centered data, `n × m` |
18//!
19//! Response-carrying result structs (e.g. [`crate::fts::FtsmResult`],
20//! [`crate::boosting_regression::BoostFosrResult`], and the FOSR result types) expose the
21//! fitted response under the field name `fitted`; this is the accepted canonical name for a
22//! fitted-response field across the crate (the historical `fitted_values` spelling is *not*
23//! introduced). No field is renamed here — the cross-model unification layer is provided by
24//! the [`crate::explain_generic::FpcPredictor`] trait, which abstracts over the concrete
25//! result structs so callers need not depend on any single struct's field names. Any future
26//! field *rename* would be a breaking change and is deferred to the 1.0-readiness milestone
27//! (APIB-01).
28
29use crate::error::FdarError;
30use crate::helpers::simpsons_weights;
31use crate::matrix::FdMatrix;
32#[cfg(feature = "linalg")]
33use anofox_regression::solvers::RidgeRegressor;
34#[cfg(feature = "linalg")]
35use anofox_regression::{FittedRegressor, Regressor};
36#[cfg(feature = "linalg")]
37use faer::linalg::solvers::Svd as FaerSvd;
38#[cfg(feature = "linalg")]
39use faer::MatRef;
40// nalgebra SVD is the production path only when `linalg` is disabled, but the
41// `linalg`-gated equivalence test also computes a reference nalgebra SVD inline.
42#[cfg(any(not(feature = "linalg"), test))]
43use nalgebra::SVD;
44
45/// Result of functional PCA.
46#[derive(Debug, Clone, PartialEq)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48#[non_exhaustive]
49pub struct FpcaResult {
50    /// Singular values
51    pub singular_values: Vec<f64>,
52    /// Rotation matrix (loadings), m x ncomp
53    pub rotation: FdMatrix,
54    /// Scores matrix, n x ncomp
55    pub scores: FdMatrix,
56    /// Mean function
57    pub mean: Vec<f64>,
58    /// Centered data, n x m
59    pub centered: FdMatrix,
60    /// Integration weights used for the functional inner product
61    pub weights: Vec<f64>,
62}
63
64impl FpcaResult {
65    /// Project new functional data onto the FPC score space.
66    ///
67    /// Centers the input data by subtracting the mean function estimated
68    /// during FPCA, then multiplies by the rotation (loadings) matrix to
69    /// obtain FPC scores for the new observations.
70    ///
71    /// # Arguments
72    /// * `data` - Matrix (n_new x m) of new observations
73    ///
74    /// # Errors
75    ///
76    /// Returns [`FdarError::InvalidDimension`] if the number of columns in
77    /// `data` does not match the length of the mean vector (i.e. the number
78    /// of evaluation points used during FPCA).
79    ///
80    /// # Examples
81    ///
82    /// ```
83    /// use fdars_core::matrix::FdMatrix;
84    /// use fdars_core::regression::fdata_to_pc_1d;
85    ///
86    /// let data = FdMatrix::from_column_major(
87    ///     (0..50).map(|i| (i as f64 * 0.1).sin()).collect(),
88    ///     5, 10,
89    /// ).unwrap();
90    /// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
91    /// let fpca = fdata_to_pc_1d(&data, 3, &argvals).unwrap();
92    ///
93    /// // Project the original data (scores should match)
94    /// let scores = fpca.project(&data).unwrap();
95    /// assert_eq!(scores.shape(), (5, 3));
96    ///
97    /// // Project new data
98    /// let new_data = FdMatrix::from_column_major(
99    ///     (0..20).map(|i| (i as f64 * 0.2).cos()).collect(),
100    ///     2, 10,
101    /// ).unwrap();
102    /// let new_scores = fpca.project(&new_data).unwrap();
103    /// assert_eq!(new_scores.shape(), (2, 3));
104    /// ```
105    pub fn project(&self, data: &FdMatrix) -> Result<FdMatrix, FdarError> {
106        let (n, m) = data.shape();
107        let ncomp = self.rotation.ncols();
108        if m != self.mean.len() {
109            return Err(FdarError::InvalidDimension {
110                parameter: "data",
111                expected: format!("{} columns", self.mean.len()),
112                actual: format!("{m} columns"),
113            });
114        }
115
116        let mut scores = FdMatrix::zeros(n, ncomp);
117        for i in 0..n {
118            for k in 0..ncomp {
119                let mut sum = 0.0;
120                for j in 0..m {
121                    sum += (data[(i, j)] - self.mean[j]) * self.rotation[(j, k)] * self.weights[j];
122                }
123                scores[(i, k)] = sum;
124            }
125        }
126        Ok(scores)
127    }
128
129    /// Reconstruct functional data from FPC scores.
130    ///
131    /// Computes the approximation of functional data using the first
132    /// `ncomp` principal components:
133    /// `data[i, j] = mean[j] + sum_k scores[i, k] * rotation[j, k]`
134    ///
135    /// # Arguments
136    /// * `scores` - Score matrix (n x p) where p >= `ncomp`
137    /// * `ncomp` - Number of components to use for reconstruction
138    ///
139    /// # Errors
140    ///
141    /// Returns [`FdarError::InvalidParameter`] if `ncomp` is zero or exceeds
142    /// the number of columns in `scores` or the number of available components
143    /// in the rotation matrix.
144    ///
145    /// # Examples
146    ///
147    /// ```
148    /// use fdars_core::matrix::FdMatrix;
149    /// use fdars_core::regression::fdata_to_pc_1d;
150    ///
151    /// let data = FdMatrix::from_column_major(
152    ///     (0..100).map(|i| (i as f64 * 0.1).sin()).collect(),
153    ///     10, 10,
154    /// ).unwrap();
155    /// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
156    /// let fpca = fdata_to_pc_1d(&data, 5, &argvals).unwrap();
157    ///
158    /// // Reconstruct using all 5 components
159    /// let recon = fpca.reconstruct(&fpca.scores, 5).unwrap();
160    /// assert_eq!(recon.shape(), (10, 10));
161    ///
162    /// // Reconstruct using fewer components
163    /// let recon2 = fpca.reconstruct(&fpca.scores, 2).unwrap();
164    /// assert_eq!(recon2.shape(), (10, 10));
165    /// ```
166    pub fn reconstruct(&self, scores: &FdMatrix, ncomp: usize) -> Result<FdMatrix, FdarError> {
167        let (n, p) = scores.shape();
168        let m = self.mean.len();
169        let max_comp = self.rotation.ncols().min(p);
170        if ncomp == 0 {
171            return Err(FdarError::InvalidParameter {
172                parameter: "ncomp",
173                message: "ncomp must be >= 1".to_string(),
174            });
175        }
176        if ncomp > max_comp {
177            return Err(FdarError::InvalidParameter {
178                parameter: "ncomp",
179                message: format!("ncomp={ncomp} exceeds available components ({max_comp})"),
180            });
181        }
182
183        let mut recon = FdMatrix::zeros(n, m);
184        for i in 0..n {
185            for j in 0..m {
186                let mut val = self.mean[j];
187                for k in 0..ncomp {
188                    val += scores[(i, k)] * self.rotation[(j, k)];
189                }
190                recon[(i, j)] = val;
191            }
192        }
193        Ok(recon)
194    }
195}
196
197/// Shared SVD/eigendecomposition sign-DECISION core (CONS-01).
198///
199/// For column `k` of `col` (an `nrows`-row column-major matrix), returns `true`
200/// iff the entry with the largest absolute value is strictly negative — the
201/// canonical "make the dominant entry positive" convention. This is the single
202/// home for the sign rule: both [`fix_svd_signs`] (FPCA rotation+scores, a
203/// two-matrix lockstep flip) and `pace_fpca::eigendecompose_cov` (eigenfunctions
204/// only, a single-matrix flip) gate their flips from this decision, so the rule
205/// cannot drift between the two sites.
206///
207/// The max-abs tie-break is `max_by` on `.abs().partial_cmp()` with
208/// `unwrap_or(Equal)` and an empty-range fallback of index `0` — preserved
209/// verbatim so every call site stays bit-identical.
210pub(crate) fn dominant_sign_negative(col: &FdMatrix, k: usize, nrows: usize) -> bool {
211    let j_max = (0..nrows)
212        .max_by(|&a, &b| {
213            col[(a, k)]
214                .abs()
215                .partial_cmp(&col[(b, k)].abs())
216                .unwrap_or(std::cmp::Ordering::Equal)
217        })
218        .unwrap_or(0);
219    col[(j_max, k)] < 0.0
220}
221
222/// Fix sign ambiguity of SVD: for each component k, ensure the element of the
223/// rotation column with largest absolute value is positive. Flip both the
224/// rotation column and the scores column consistently when negative.
225///
226/// This deterministic convention must be applied to BOTH the faer and nalgebra
227/// SVD paths so that `test_faer_svd_matches_nalgebra` is reproducible.
228/// Apply BEFORE the sqrt_weights unscaling loop.
229///
230/// The sign DECISION is delegated to [`dominant_sign_negative`] (the shared
231/// CONS-01 core); this function owns only the two-matrix lockstep flip.
232fn fix_svd_signs(rotation: &mut FdMatrix, scores: &mut FdMatrix, ncomp: usize) {
233    let m = rotation.nrows();
234    let n = scores.nrows();
235    for k in 0..ncomp {
236        if dominant_sign_negative(rotation, k, m) {
237            for j in 0..m {
238                rotation[(j, k)] = -rotation[(j, k)];
239            }
240            for i in 0..n {
241                scores[(i, k)] = -scores[(i, k)];
242            }
243        }
244    }
245}
246
247/// Center columns of a matrix and return (centered_matrix, column_means).
248fn center_columns(data: &FdMatrix) -> (FdMatrix, Vec<f64>) {
249    let (n, m) = data.shape();
250    let mut centered = FdMatrix::zeros(n, m);
251    let mut means = vec![0.0; m];
252    for j in 0..m {
253        let col = data.column(j);
254        let mean = col.iter().sum::<f64>() / n as f64;
255        means[j] = mean;
256        let out_col = centered.column_mut(j);
257        for i in 0..n {
258            out_col[i] = col[i] - mean;
259        }
260    }
261    (centered, means)
262}
263
264/// Extract rotation (V) and scores (U*S) from SVD results.
265#[cfg(any(not(feature = "linalg"), test))]
266fn extract_pc_components(
267    svd: &SVD<f64, nalgebra::Dyn, nalgebra::Dyn>,
268    n: usize,
269    m: usize,
270    ncomp: usize,
271) -> Option<(Vec<f64>, FdMatrix, FdMatrix)> {
272    let singular_values: Vec<f64> = svd.singular_values.iter().take(ncomp).copied().collect();
273
274    let v_t = svd.v_t.as_ref()?;
275    let mut rotation = FdMatrix::zeros(m, ncomp);
276    for k in 0..ncomp {
277        for j in 0..m {
278            rotation[(j, k)] = v_t[(k, j)];
279        }
280    }
281
282    let u = svd.u.as_ref()?;
283    let mut scores = FdMatrix::zeros(n, ncomp);
284    for k in 0..ncomp {
285        let sv_k = singular_values[k];
286        for i in 0..n {
287            scores[(i, k)] = u[(i, k)] * sv_k;
288        }
289    }
290
291    Some((singular_values, rotation, scores))
292}
293
294/// Perform functional PCA via SVD on centered data with integration weights.
295///
296/// Uses Simpson's-rule weights derived from `argvals` so that the resulting
297/// scores represent functional inner products and are invariant to grid
298/// density.
299///
300/// # Arguments
301/// * `data` - Matrix (n x m): n observations, m evaluation points
302/// * `ncomp` - Number of components to extract
303/// * `argvals` - Evaluation grid points (length m)
304///
305/// # Errors
306///
307/// Returns [`FdarError::InvalidDimension`] if `data` has zero rows or zero
308/// columns, or if `argvals.len() != m`.
309/// Returns [`FdarError::InvalidParameter`] if `ncomp` is zero.
310/// Returns [`FdarError::ComputationFailed`] if the SVD decomposition fails to
311/// produce U or V_t matrices.
312///
313/// # Examples
314///
315/// ```
316/// use fdars_core::matrix::FdMatrix;
317/// use fdars_core::regression::fdata_to_pc_1d;
318///
319/// // 5 curves, each evaluated at 10 points
320/// let data = FdMatrix::from_column_major(
321///     (0..50).map(|i| (i as f64 * 0.1).sin()).collect(),
322///     5, 10,
323/// ).unwrap();
324/// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
325/// let result = fdata_to_pc_1d(&data, 3, &argvals).unwrap();
326/// assert_eq!(result.scores.shape(), (5, 3));
327/// assert_eq!(result.rotation.shape(), (10, 3));
328/// assert_eq!(result.mean.len(), 10);
329/// ```
330#[must_use = "expensive computation whose result should not be discarded"]
331pub fn fdata_to_pc_1d(
332    data: &FdMatrix,
333    ncomp: usize,
334    argvals: &[f64],
335) -> Result<FpcaResult, FdarError> {
336    let (n, m) = data.shape();
337    if n == 0 {
338        return Err(FdarError::InvalidDimension {
339            parameter: "data",
340            expected: "n > 0 rows".to_string(),
341            actual: format!("n = {n}"),
342        });
343    }
344    if m == 0 {
345        return Err(FdarError::InvalidDimension {
346            parameter: "data",
347            expected: "m > 0 columns".to_string(),
348            actual: format!("m = {m}"),
349        });
350    }
351    if argvals.len() != m {
352        return Err(FdarError::InvalidDimension {
353            parameter: "argvals",
354            expected: format!("{m} elements"),
355            actual: format!("{} elements", argvals.len()),
356        });
357    }
358    if ncomp < 1 {
359        return Err(FdarError::InvalidParameter {
360            parameter: "ncomp",
361            message: format!("ncomp must be >= 1, got {ncomp}"),
362        });
363    }
364
365    let ncomp = ncomp.min(n).min(m);
366    let (centered, means) = center_columns(data);
367
368    // Compute integration weights for functional inner product
369    let weights = simpsons_weights(argvals);
370    let sqrt_weights: Vec<f64> = weights.iter().map(|w| w.sqrt()).collect();
371
372    // Scale centered data by sqrt(weights) for weighted SVD
373    let mut weighted = centered.clone();
374    for i in 0..n {
375        for j in 0..m {
376            weighted[(i, j)] *= sqrt_weights[j];
377        }
378    }
379
380    // Feature-gated SVD: faer thin_svd (zero-copy MatRef) under `linalg`,
381    // retained nalgebra path under `cfg(not(feature = "linalg"))`.
382    #[cfg(feature = "linalg")]
383    let (singular_values, mut rotation, mut scores) = {
384        let mat_ref = MatRef::<f64>::from_column_major_slice(weighted.as_slice(), n, m);
385        let svd = FaerSvd::new_thin(mat_ref).map_err(|_| FdarError::ComputationFailed {
386            operation: "SVD (faer)",
387            detail:
388                "faer thin_svd failed; try reducing ncomp or check for zero-variance columns in the data"
389                    .to_string(),
390        })?;
391        let s_col = svd.S().column_vector();
392        let singular_values: Vec<f64> = s_col.iter().take(ncomp).copied().collect();
393        // faer V is m×ncomp (right singular vectors in columns — not transposed)
394        // rotation[(j, k)] = V[(j, k)] directly (un-transposed, unlike nalgebra v_t[(k,j)])
395        let mut rotation = FdMatrix::zeros(m, ncomp);
396        for k in 0..ncomp {
397            for j in 0..m {
398                rotation[(j, k)] = svd.V()[(j, k)];
399            }
400        }
401        // faer U is n×ncomp; scores = U * diag(S)
402        let mut scores = FdMatrix::zeros(n, ncomp);
403        for k in 0..ncomp {
404            let sv_k = singular_values[k];
405            for i in 0..n {
406                scores[(i, k)] = svd.U()[(i, k)] * sv_k;
407            }
408        }
409        (singular_values, rotation, scores)
410    };
411
412    #[cfg(not(feature = "linalg"))]
413    let (singular_values, mut rotation, mut scores) = {
414        let svd = SVD::new(weighted.to_dmatrix(), true, true);
415        let (sv, rot, sc) =
416            extract_pc_components(&svd, n, m, ncomp).ok_or_else(|| FdarError::ComputationFailed {
417                operation: "SVD",
418                detail: "failed to extract U or V_t from SVD decomposition; try reducing ncomp or check for zero-variance columns in the data".to_string(),
419            })?;
420        (sv, rot, sc)
421    };
422
423    // Reconcile singular-vector signs: apply BEFORE the sqrt_weights unscaling loop.
424    // Covers both cfg branches via a single shared call site.
425    fix_svd_signs(&mut rotation, &mut scores, ncomp);
426
427    // Unscale loadings: divide by sqrt(weights) to get actual eigenfunctions
428    for k in 0..ncomp {
429        for j in 0..m {
430            if sqrt_weights[j] > 1e-15 {
431                rotation[(j, k)] /= sqrt_weights[j];
432            }
433        }
434    }
435
436    Ok(FpcaResult {
437        singular_values,
438        rotation,
439        scores,
440        mean: means,
441        centered,
442        weights,
443    })
444}
445
446/// Result of PLS regression.
447#[derive(Debug, Clone, PartialEq)]
448#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
449#[non_exhaustive]
450pub struct PlsResult {
451    /// Weight vectors, m x ncomp
452    pub weights: FdMatrix,
453    /// Score vectors, n x ncomp
454    pub scores: FdMatrix,
455    /// Loading vectors, m x ncomp
456    pub loadings: FdMatrix,
457    /// Column means of the training data, length m
458    pub x_means: Vec<f64>,
459    /// Integration weights for the functional inner product
460    pub integration_weights: Vec<f64>,
461}
462
463impl PlsResult {
464    /// Project new functional data onto the PLS score space.
465    ///
466    /// Centers the input data by subtracting the column means estimated
467    /// during PLS fitting, then iteratively projects and deflates through
468    /// each PLS component using the stored weight and loading vectors.
469    ///
470    /// # Arguments
471    /// * `data` - Matrix (n_new x m) of new observations
472    ///
473    /// # Errors
474    ///
475    /// Returns [`FdarError::InvalidDimension`] if the number of columns in
476    /// `data` does not match the number of predictor variables used during
477    /// PLS fitting.
478    ///
479    /// # Examples
480    ///
481    /// ```
482    /// use fdars_core::matrix::FdMatrix;
483    /// use fdars_core::regression::fdata_to_pls_1d;
484    ///
485    /// let x = FdMatrix::from_column_major(
486    ///     (0..100).map(|i| (i as f64 * 0.1).sin()).collect(),
487    ///     10, 10,
488    /// ).unwrap();
489    /// let y: Vec<f64> = (0..10).map(|i| i as f64 * 0.5).collect();
490    /// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
491    /// let pls = fdata_to_pls_1d(&x, &y, 3, &argvals).unwrap();
492    ///
493    /// // Project the original data
494    /// let scores = pls.project(&x).unwrap();
495    /// assert_eq!(scores.shape(), (10, 3));
496    ///
497    /// // Project new data
498    /// let new_x = FdMatrix::from_column_major(
499    ///     (0..20).map(|i| (i as f64 * 0.2).cos()).collect(),
500    ///     2, 10,
501    /// ).unwrap();
502    /// let new_scores = pls.project(&new_x).unwrap();
503    /// assert_eq!(new_scores.shape(), (2, 3));
504    /// ```
505    pub fn project(&self, data: &FdMatrix) -> Result<FdMatrix, FdarError> {
506        let (n, m) = data.shape();
507        let ncomp = self.weights.ncols();
508        if m != self.x_means.len() {
509            return Err(FdarError::InvalidDimension {
510                parameter: "data",
511                expected: format!("{} columns", self.x_means.len()),
512                actual: format!("{m} columns"),
513            });
514        }
515
516        // Center data
517        let mut x_cen = FdMatrix::zeros(n, m);
518        for j in 0..m {
519            for i in 0..n {
520                x_cen[(i, j)] = data[(i, j)] - self.x_means[j];
521            }
522        }
523
524        // Iteratively project and deflate through each component
525        let mut scores = FdMatrix::zeros(n, ncomp);
526        for k in 0..ncomp {
527            // Compute scores: t = X_cen * W * w_k (weighted inner product)
528            for i in 0..n {
529                let mut sum = 0.0;
530                for j in 0..m {
531                    sum += x_cen[(i, j)] * self.weights[(j, k)] * self.integration_weights[j];
532                }
533                scores[(i, k)] = sum;
534            }
535
536            // Deflate: X_cen -= t * p_k'
537            for j in 0..m {
538                let p_jk = self.loadings[(j, k)];
539                for i in 0..n {
540                    x_cen[(i, j)] -= scores[(i, k)] * p_jk;
541                }
542            }
543        }
544
545        Ok(scores)
546    }
547}
548
549/// Compute PLS weight vector: w = X'y / ||X'y|| (with integration weights)
550fn pls_compute_weights(x_cen: &FdMatrix, y_cen: &[f64], int_w: &[f64]) -> Vec<f64> {
551    let (n, m) = x_cen.shape();
552    let mut w: Vec<f64> = (0..m)
553        .map(|j| {
554            let mut sum = 0.0;
555            for i in 0..n {
556                sum += x_cen[(i, j)] * y_cen[i];
557            }
558            sum * int_w[j]
559        })
560        .collect();
561
562    let w_norm: f64 = w.iter().map(|&wi| wi * wi).sum::<f64>().sqrt();
563    if w_norm > 1e-10 {
564        for wi in &mut w {
565            *wi /= w_norm;
566        }
567    }
568    w
569}
570
571/// Compute PLS scores: t = X * W * w (weighted inner product)
572fn pls_compute_scores(x_cen: &FdMatrix, w: &[f64], int_w: &[f64]) -> Vec<f64> {
573    let (n, m) = x_cen.shape();
574    (0..n)
575        .map(|i| {
576            let mut sum = 0.0;
577            for j in 0..m {
578                sum += x_cen[(i, j)] * w[j] * int_w[j];
579            }
580            sum
581        })
582        .collect()
583}
584
585/// Compute PLS loadings: p = X't / (t't) (with integration weights)
586fn pls_compute_loadings(x_cen: &FdMatrix, t: &[f64], t_norm_sq: f64, int_w: &[f64]) -> Vec<f64> {
587    let (n, m) = x_cen.shape();
588    (0..m)
589        .map(|j| {
590            let mut sum = 0.0;
591            for i in 0..n {
592                sum += x_cen[(i, j)] * t[i];
593            }
594            sum * int_w[j] / t_norm_sq.max(1e-10)
595        })
596        .collect()
597}
598
599/// Deflate X by removing the rank-1 component t * p'
600fn pls_deflate_x(x_cen: &mut FdMatrix, t: &[f64], p: &[f64]) {
601    let (n, m) = x_cen.shape();
602    for j in 0..m {
603        for i in 0..n {
604            x_cen[(i, j)] -= t[i] * p[j];
605        }
606    }
607}
608
609/// Execute one NIPALS step: compute weights/scores/loadings and deflate X and y.
610fn pls_nipals_step(
611    k: usize,
612    x_cen: &mut FdMatrix,
613    y_cen: &mut [f64],
614    weights: &mut FdMatrix,
615    scores: &mut FdMatrix,
616    loadings: &mut FdMatrix,
617    int_w: &[f64],
618) {
619    let n = x_cen.nrows();
620    let m = x_cen.ncols();
621
622    let w = pls_compute_weights(x_cen, y_cen, int_w);
623    let t = pls_compute_scores(x_cen, &w, int_w);
624    let t_norm_sq: f64 = t.iter().map(|&ti| ti * ti).sum();
625    let p = pls_compute_loadings(x_cen, &t, t_norm_sq, int_w);
626
627    for j in 0..m {
628        weights[(j, k)] = w[j];
629        loadings[(j, k)] = p[j];
630    }
631    for i in 0..n {
632        scores[(i, k)] = t[i];
633    }
634
635    pls_deflate_x(x_cen, &t, &p);
636    let t_y: f64 = t.iter().zip(y_cen.iter()).map(|(&ti, &yi)| ti * yi).sum();
637    let q = t_y / t_norm_sq.max(1e-10);
638    for i in 0..n {
639        y_cen[i] -= t[i] * q;
640    }
641}
642
643/// Perform PLS via NIPALS algorithm with integration weights.
644///
645/// # Arguments
646/// * `data` - Matrix (n x m): n observations, m evaluation points
647/// * `y` - Response vector (length n)
648/// * `ncomp` - Number of components to extract
649/// * `argvals` - Evaluation grid points (length m)
650///
651/// # Errors
652///
653/// Returns [`FdarError::InvalidDimension`] if `data` has zero rows or zero
654/// columns, if `y.len()` does not equal the number of rows in `data`,
655/// or if `argvals.len() != m`.
656/// Returns [`FdarError::InvalidParameter`] if `ncomp` is zero.
657#[must_use = "expensive computation whose result should not be discarded"]
658pub fn fdata_to_pls_1d(
659    data: &FdMatrix,
660    y: &[f64],
661    ncomp: usize,
662    argvals: &[f64],
663) -> Result<PlsResult, FdarError> {
664    let (n, m) = data.shape();
665    if n == 0 {
666        return Err(FdarError::InvalidDimension {
667            parameter: "data",
668            expected: "n > 0 rows".to_string(),
669            actual: format!("n = {n}"),
670        });
671    }
672    if m == 0 {
673        return Err(FdarError::InvalidDimension {
674            parameter: "data",
675            expected: "m > 0 columns".to_string(),
676            actual: format!("m = {m}"),
677        });
678    }
679    if y.len() != n {
680        return Err(FdarError::InvalidDimension {
681            parameter: "y",
682            expected: format!("length {n}"),
683            actual: format!("length {}", y.len()),
684        });
685    }
686    if argvals.len() != m {
687        return Err(FdarError::InvalidDimension {
688            parameter: "argvals",
689            expected: format!("{m} elements"),
690            actual: format!("{} elements", argvals.len()),
691        });
692    }
693    if ncomp < 1 {
694        return Err(FdarError::InvalidParameter {
695            parameter: "ncomp",
696            message: format!("ncomp must be >= 1, got {ncomp}"),
697        });
698    }
699
700    let ncomp = ncomp.min(n).min(m);
701
702    // Compute integration weights
703    let int_w = simpsons_weights(argvals);
704
705    // Center X and y
706    let x_means: Vec<f64> = (0..m)
707        .map(|j| {
708            let col = data.column(j);
709            let sum: f64 = col.iter().sum();
710            sum / n as f64
711        })
712        .collect();
713
714    let y_mean: f64 = y.iter().sum::<f64>() / n as f64;
715
716    let mut x_cen = FdMatrix::zeros(n, m);
717    for j in 0..m {
718        for i in 0..n {
719            x_cen[(i, j)] = data[(i, j)] - x_means[j];
720        }
721    }
722
723    let mut y_cen: Vec<f64> = y.iter().map(|&yi| yi - y_mean).collect();
724
725    let mut weights = FdMatrix::zeros(m, ncomp);
726    let mut scores = FdMatrix::zeros(n, ncomp);
727    let mut loadings = FdMatrix::zeros(m, ncomp);
728
729    // NIPALS algorithm
730    for k in 0..ncomp {
731        pls_nipals_step(
732            k,
733            &mut x_cen,
734            &mut y_cen,
735            &mut weights,
736            &mut scores,
737            &mut loadings,
738            &int_w,
739        );
740    }
741
742    Ok(PlsResult {
743        weights,
744        scores,
745        loadings,
746        x_means,
747        integration_weights: int_w,
748    })
749}
750
751/// Result of ridge regression fit.
752#[derive(Debug, Clone, PartialEq)]
753#[non_exhaustive]
754#[cfg(feature = "linalg")]
755pub struct RidgeResult {
756    /// Coefficients
757    pub coefficients: Vec<f64>,
758    /// Intercept
759    pub intercept: f64,
760    /// Fitted values
761    pub fitted_values: Vec<f64>,
762    /// Residuals
763    pub residuals: Vec<f64>,
764    /// R-squared
765    pub r_squared: f64,
766    /// Lambda used
767    pub lambda: f64,
768    /// Error message if any
769    pub error: Option<String>,
770}
771
772/// Fit ridge regression.
773///
774/// # Arguments
775/// * `x` - Predictor matrix (n x m)
776/// * `y` - Response vector
777/// * `lambda` - Regularization parameter
778/// * `with_intercept` - Whether to include intercept
779#[cfg(feature = "linalg")]
780#[must_use = "expensive computation whose result should not be discarded"]
781pub fn ridge_regression_fit(
782    x: &FdMatrix,
783    y: &[f64],
784    lambda: f64,
785    with_intercept: bool,
786) -> RidgeResult {
787    let (n, m) = x.shape();
788    if n == 0 || m == 0 || y.len() != n {
789        return RidgeResult {
790            coefficients: Vec::new(),
791            intercept: 0.0,
792            fitted_values: Vec::new(),
793            residuals: Vec::new(),
794            r_squared: 0.0,
795            lambda,
796            error: Some("Invalid input dimensions".to_string()),
797        };
798    }
799
800    // Convert to faer Mat format
801    let x_faer = faer::Mat::from_fn(n, m, |i, j| x[(i, j)]);
802    let y_faer = faer::Col::from_fn(n, |i| y[i]);
803
804    // Build and fit the ridge regressor
805    let regressor = RidgeRegressor::builder()
806        .with_intercept(with_intercept)
807        .lambda(lambda)
808        .build();
809
810    let fitted = match regressor.fit(&x_faer, &y_faer) {
811        Ok(f) => f,
812        Err(e) => {
813            return RidgeResult {
814                coefficients: Vec::new(),
815                intercept: 0.0,
816                fitted_values: Vec::new(),
817                residuals: Vec::new(),
818                r_squared: 0.0,
819                lambda,
820                error: Some(format!("Fit failed: {e:?}")),
821            }
822        }
823    };
824
825    // Extract coefficients
826    let coefs = fitted.coefficients();
827    let coefficients: Vec<f64> = (0..coefs.nrows()).map(|i| coefs[i]).collect();
828
829    // Get intercept
830    let intercept = fitted.intercept().unwrap_or(0.0);
831
832    // Compute fitted values
833    let mut fitted_values = vec![0.0; n];
834    for i in 0..n {
835        let mut pred = intercept;
836        for j in 0..m {
837            pred += x[(i, j)] * coefficients[j];
838        }
839        fitted_values[i] = pred;
840    }
841
842    // Compute residuals
843    let residuals: Vec<f64> = y
844        .iter()
845        .zip(fitted_values.iter())
846        .map(|(&yi, &yhat)| yi - yhat)
847        .collect();
848
849    // Compute R-squared
850    let y_mean: f64 = y.iter().sum::<f64>() / n as f64;
851    let ss_tot: f64 = y.iter().map(|&yi| (yi - y_mean).powi(2)).sum();
852    let ss_res: f64 = residuals.iter().map(|&r| r.powi(2)).sum();
853    let r_squared = if ss_tot > 0.0 {
854        1.0 - ss_res / ss_tot
855    } else {
856        0.0
857    };
858
859    RidgeResult {
860        coefficients,
861        intercept,
862        fitted_values,
863        residuals,
864        r_squared,
865        lambda,
866        error: None,
867    }
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873    use std::f64::consts::PI;
874
875    /// Generate functional data with known structure for testing
876    fn generate_test_fdata(n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
877        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
878
879        // Create n curves: sine waves with varying phase
880        let mut data = FdMatrix::zeros(n, m);
881        for i in 0..n {
882            let phase = (i as f64 / n as f64) * PI;
883            for j in 0..m {
884                data[(i, j)] = (2.0 * PI * t[j] + phase).sin();
885            }
886        }
887
888        (data, t)
889    }
890
891    // ============== FPCA tests ==============
892
893    #[test]
894    fn test_fdata_to_pc_1d_basic() {
895        let n = 20;
896        let m = 50;
897        let ncomp = 3;
898        let (data, t) = generate_test_fdata(n, m);
899
900        let result = fdata_to_pc_1d(&data, ncomp, &t);
901        assert!(result.is_ok());
902
903        let fpca = result.unwrap();
904        assert_eq!(fpca.singular_values.len(), ncomp);
905        assert_eq!(fpca.rotation.shape(), (m, ncomp));
906        assert_eq!(fpca.scores.shape(), (n, ncomp));
907        assert_eq!(fpca.mean.len(), m);
908        assert_eq!(fpca.centered.shape(), (n, m));
909    }
910
911    #[test]
912    fn test_fdata_to_pc_1d_singular_values_decreasing() {
913        let n = 20;
914        let m = 50;
915        let ncomp = 5;
916        let (data, t) = generate_test_fdata(n, m);
917
918        let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
919
920        // Singular values should be in decreasing order
921        for i in 1..fpca.singular_values.len() {
922            assert!(
923                fpca.singular_values[i] <= fpca.singular_values[i - 1] + 1e-10,
924                "Singular values should be decreasing"
925            );
926        }
927    }
928
929    #[test]
930    fn test_fdata_to_pc_1d_centered_has_zero_mean() {
931        let n = 20;
932        let m = 50;
933        let (data, t) = generate_test_fdata(n, m);
934
935        let fpca = fdata_to_pc_1d(&data, 3, &t).unwrap();
936
937        // Column means of centered data should be zero
938        for j in 0..m {
939            let col_mean: f64 = (0..n).map(|i| fpca.centered[(i, j)]).sum::<f64>() / n as f64;
940            assert!(
941                col_mean.abs() < 1e-10,
942                "Centered data should have zero column mean"
943            );
944        }
945    }
946
947    #[test]
948    fn test_fdata_to_pc_1d_ncomp_limits() {
949        let n = 10;
950        let m = 50;
951        let (data, t) = generate_test_fdata(n, m);
952
953        // Request more components than n - should cap at n
954        let fpca = fdata_to_pc_1d(&data, 20, &t).unwrap();
955        assert!(fpca.singular_values.len() <= n);
956    }
957
958    #[test]
959    fn test_fdata_to_pc_1d_invalid_input() {
960        // Empty data
961        let empty = FdMatrix::zeros(0, 50);
962        let t50: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
963        let result = fdata_to_pc_1d(&empty, 3, &t50);
964        assert!(result.is_err());
965
966        // Zero components
967        let (data, t) = generate_test_fdata(10, 50);
968        let result = fdata_to_pc_1d(&data, 0, &t);
969        assert!(result.is_err());
970    }
971
972    #[test]
973    fn test_fdata_to_pc_1d_reconstruction() {
974        let n = 10;
975        let m = 30;
976        let (data, t) = generate_test_fdata(n, m);
977
978        // Use all components for perfect reconstruction
979        let ncomp = n.min(m);
980        let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
981
982        // Reconstruct: X_centered = scores * rotation^T
983        for i in 0..n {
984            for j in 0..m {
985                let mut reconstructed = 0.0;
986                for k in 0..ncomp {
987                    let score = fpca.scores[(i, k)];
988                    let loading = fpca.rotation[(j, k)];
989                    reconstructed += score * loading;
990                }
991                let original_centered = fpca.centered[(i, j)];
992                assert!(
993                    (reconstructed - original_centered).abs() < 0.1,
994                    "Reconstruction error at ({}, {}): {} vs {}",
995                    i,
996                    j,
997                    reconstructed,
998                    original_centered
999                );
1000            }
1001        }
1002    }
1003
1004    /// Numerical equivalence: under `linalg` the faer thin_svd path must match
1005    /// the retained nalgebra path within `1e-8·σ₁` on significant components.
1006    /// Near-zero (noise) components are excluded — their singular vectors are
1007    /// numerically ambiguous and legitimately differ between backends.
1008    #[cfg(all(test, feature = "linalg"))]
1009    #[test]
1010    fn test_faer_svd_matches_nalgebra() {
1011        let n = 30;
1012        let m = 40;
1013        let ncomp = 5;
1014        let (data, t) = generate_test_fdata(n, m);
1015
1016        // faer path (active under `linalg`)
1017        let faer = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1018
1019        // Reference: reproduce the nalgebra path inline, running through the
1020        // identical center → sqrt(weights) scale → SVD → fix_svd_signs →
1021        // unscale sequence so both use the same sign convention.
1022        let ncomp_eff = ncomp.min(n).min(m);
1023        let (_centered, _means) = center_columns(&data);
1024        let weights = simpsons_weights(&t);
1025        let sqrt_weights: Vec<f64> = weights.iter().map(|w| w.sqrt()).collect();
1026        let mut weighted = _centered.clone();
1027        for i in 0..n {
1028            for j in 0..m {
1029                weighted[(i, j)] *= sqrt_weights[j];
1030            }
1031        }
1032        let svd = nalgebra::SVD::new(weighted.to_dmatrix(), true, true);
1033        let (ref_sv, mut ref_rotation, mut ref_scores) =
1034            extract_pc_components(&svd, n, m, ncomp_eff).unwrap();
1035        fix_svd_signs(&mut ref_rotation, &mut ref_scores, ncomp_eff);
1036        for k in 0..ncomp_eff {
1037            for j in 0..m {
1038                if sqrt_weights[j] > 1e-15 {
1039                    ref_rotation[(j, k)] /= sqrt_weights[j];
1040                }
1041            }
1042        }
1043
1044        // Compare per significant component (sv[k] >= 1e-8 * sv[0]).
1045        let s1 = faer.singular_values[0];
1046        let tol = 1e-8 * s1;
1047        for k in 0..ncomp_eff {
1048            if faer.singular_values[k] < 1e-8 * s1 {
1049                continue; // noise component — excluded
1050            }
1051            assert!(
1052                (faer.singular_values[k] - ref_sv[k]).abs() < tol,
1053                "singular_value[{k}] mismatch: faer={}, nalgebra={}",
1054                faer.singular_values[k],
1055                ref_sv[k]
1056            );
1057            for j in 0..m {
1058                assert!(
1059                    (faer.rotation[(j, k)] - ref_rotation[(j, k)]).abs() < tol,
1060                    "rotation[{j},{k}] mismatch: faer={}, nalgebra={}",
1061                    faer.rotation[(j, k)],
1062                    ref_rotation[(j, k)]
1063                );
1064            }
1065            for i in 0..n {
1066                assert!(
1067                    (faer.scores[(i, k)] - ref_scores[(i, k)]).abs() < tol,
1068                    "scores[{i},{k}] mismatch: faer={}, nalgebra={}",
1069                    faer.scores[(i, k)],
1070                    ref_scores[(i, k)]
1071                );
1072            }
1073        }
1074    }
1075
1076    // ============== PLS tests ==============
1077
1078    #[test]
1079    fn test_fdata_to_pls_1d_basic() {
1080        let n = 20;
1081        let m = 30;
1082        let ncomp = 3;
1083        let (x, t) = generate_test_fdata(n, m);
1084
1085        // Create y with some relationship to x
1086        let y: Vec<f64> = (0..n).map(|i| (i as f64 / n as f64) + 0.1).collect();
1087
1088        let result = fdata_to_pls_1d(&x, &y, ncomp, &t);
1089        assert!(result.is_ok());
1090
1091        let pls = result.unwrap();
1092        assert_eq!(pls.weights.shape(), (m, ncomp));
1093        assert_eq!(pls.scores.shape(), (n, ncomp));
1094        assert_eq!(pls.loadings.shape(), (m, ncomp));
1095    }
1096
1097    #[test]
1098    fn test_fdata_to_pls_1d_weights_normalized() {
1099        let n = 20;
1100        let m = 30;
1101        let ncomp = 2;
1102        let (x, t) = generate_test_fdata(n, m);
1103        let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
1104
1105        let pls = fdata_to_pls_1d(&x, &y, ncomp, &t).unwrap();
1106
1107        // Weight vectors should be approximately unit norm
1108        for k in 0..ncomp {
1109            let norm: f64 = (0..m)
1110                .map(|j| pls.weights[(j, k)].powi(2))
1111                .sum::<f64>()
1112                .sqrt();
1113            assert!(
1114                (norm - 1.0).abs() < 0.1,
1115                "Weight vector {} should be unit norm, got {}",
1116                k,
1117                norm
1118            );
1119        }
1120    }
1121
1122    #[test]
1123    fn test_fdata_to_pls_1d_invalid_input() {
1124        let (x, t) = generate_test_fdata(10, 30);
1125
1126        // Wrong y length
1127        let result = fdata_to_pls_1d(&x, &[0.0; 5], 2, &t);
1128        assert!(result.is_err());
1129
1130        // Zero components
1131        let y = vec![0.0; 10];
1132        let result = fdata_to_pls_1d(&x, &y, 0, &t);
1133        assert!(result.is_err());
1134    }
1135
1136    // ============== Ridge regression tests ==============
1137
1138    #[cfg(feature = "linalg")]
1139    #[test]
1140    fn test_ridge_regression_fit_basic() {
1141        let n = 50;
1142        let m = 5;
1143
1144        // Create X with known structure
1145        let mut x = FdMatrix::zeros(n, m);
1146        for i in 0..n {
1147            for j in 0..m {
1148                x[(i, j)] = (i as f64 + j as f64) / (n + m) as f64;
1149            }
1150        }
1151
1152        // Create y = sum of x columns + noise
1153        let y: Vec<f64> = (0..n)
1154            .map(|i| {
1155                let mut sum = 0.0;
1156                for j in 0..m {
1157                    sum += x[(i, j)];
1158                }
1159                sum + 0.01 * (i as f64 % 10.0)
1160            })
1161            .collect();
1162
1163        let result = ridge_regression_fit(&x, &y, 0.1, true);
1164
1165        assert!(result.error.is_none(), "Ridge should fit without error");
1166        assert_eq!(result.coefficients.len(), m);
1167        assert_eq!(result.fitted_values.len(), n);
1168        assert_eq!(result.residuals.len(), n);
1169    }
1170
1171    #[cfg(feature = "linalg")]
1172    #[test]
1173    fn test_ridge_regression_fit_r_squared() {
1174        let n = 50;
1175        let m = 3;
1176
1177        let x = FdMatrix::from_column_major(
1178            (0..n * m).map(|i| i as f64 / (n * m) as f64).collect(),
1179            n,
1180            m,
1181        )
1182        .unwrap();
1183        let y: Vec<f64> = (0..n).map(|i| i as f64 / n as f64).collect();
1184
1185        let result = ridge_regression_fit(&x, &y, 0.01, true);
1186
1187        assert!(
1188            result.r_squared > 0.5,
1189            "R-squared should be high, got {}",
1190            result.r_squared
1191        );
1192        assert!(result.r_squared <= 1.0 + 1e-10, "R-squared should be <= 1");
1193    }
1194
1195    #[cfg(feature = "linalg")]
1196    #[test]
1197    fn test_ridge_regression_fit_regularization() {
1198        let n = 30;
1199        let m = 10;
1200
1201        let x = FdMatrix::from_column_major(
1202            (0..n * m)
1203                .map(|i| ((i * 17) % 100) as f64 / 100.0)
1204                .collect(),
1205            n,
1206            m,
1207        )
1208        .unwrap();
1209        let y: Vec<f64> = (0..n).map(|i| (i as f64).sin()).collect();
1210
1211        let low_lambda = ridge_regression_fit(&x, &y, 0.001, true);
1212        let high_lambda = ridge_regression_fit(&x, &y, 100.0, true);
1213
1214        let norm_low: f64 = low_lambda
1215            .coefficients
1216            .iter()
1217            .map(|c| c.powi(2))
1218            .sum::<f64>()
1219            .sqrt();
1220        let norm_high: f64 = high_lambda
1221            .coefficients
1222            .iter()
1223            .map(|c| c.powi(2))
1224            .sum::<f64>()
1225            .sqrt();
1226
1227        assert!(
1228            norm_high <= norm_low + 1e-6,
1229            "Higher lambda should shrink coefficients: {} vs {}",
1230            norm_high,
1231            norm_low
1232        );
1233    }
1234
1235    #[cfg(feature = "linalg")]
1236    #[test]
1237    fn test_ridge_regression_fit_residuals() {
1238        let n = 20;
1239        let m = 3;
1240
1241        let x = FdMatrix::from_column_major(
1242            (0..n * m).map(|i| i as f64 / (n * m) as f64).collect(),
1243            n,
1244            m,
1245        )
1246        .unwrap();
1247        let y: Vec<f64> = (0..n).map(|i| i as f64 / n as f64).collect();
1248
1249        let result = ridge_regression_fit(&x, &y, 0.1, true);
1250
1251        for i in 0..n {
1252            let expected_resid = y[i] - result.fitted_values[i];
1253            assert!(
1254                (result.residuals[i] - expected_resid).abs() < 1e-10,
1255                "Residual mismatch at {}",
1256                i
1257            );
1258        }
1259    }
1260
1261    #[cfg(feature = "linalg")]
1262    #[test]
1263    fn test_ridge_regression_fit_no_intercept() {
1264        let n = 30;
1265        let m = 5;
1266
1267        let x = FdMatrix::from_column_major(
1268            (0..n * m).map(|i| i as f64 / (n * m) as f64).collect(),
1269            n,
1270            m,
1271        )
1272        .unwrap();
1273        let y: Vec<f64> = (0..n).map(|i| i as f64 / n as f64).collect();
1274
1275        let result = ridge_regression_fit(&x, &y, 0.1, false);
1276
1277        assert!(result.error.is_none());
1278        assert!(
1279            result.intercept.abs() < 1e-10,
1280            "Intercept should be 0, got {}",
1281            result.intercept
1282        );
1283    }
1284
1285    #[cfg(feature = "linalg")]
1286    #[test]
1287    fn test_ridge_regression_fit_invalid_input() {
1288        let empty = FdMatrix::zeros(0, 5);
1289        let result = ridge_regression_fit(&empty, &[], 0.1, true);
1290        assert!(result.error.is_some());
1291
1292        let x = FdMatrix::zeros(10, 10);
1293        let y = vec![0.0; 5];
1294        let result = ridge_regression_fit(&x, &y, 0.1, true);
1295        assert!(result.error.is_some());
1296    }
1297
1298    #[test]
1299    fn test_all_zero_fpca() {
1300        // All-zero data: centering leaves zeros, SVD should return trivial result
1301        let n = 5;
1302        let m = 20;
1303        let data = FdMatrix::zeros(n, m);
1304        let t: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1305        let result = fdata_to_pc_1d(&data, 2, &t);
1306        // Should not panic; may return Ok with zero singular values
1307        if let Ok(res) = result {
1308            assert_eq!(res.scores.nrows(), n);
1309            for &sv in &res.singular_values {
1310                assert!(
1311                    sv.abs() < 1e-10,
1312                    "All-zero data should have zero singular values"
1313                );
1314            }
1315        }
1316    }
1317
1318    #[test]
1319    fn test_n1_pca() {
1320        // Single observation: centering leaves all zeros, SVD may return trivial result
1321        let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
1322        let t = vec![0.0, 0.5, 1.0];
1323        let result = fdata_to_pc_1d(&data, 1, &t);
1324        // With n=1, centering leaves all zeros, so SVD may fail or return trivial result
1325        // Just ensure no panic
1326        let _ = result;
1327    }
1328
1329    #[test]
1330    fn test_constant_y_pls() {
1331        let n = 10;
1332        let m = 20;
1333        let data_vec: Vec<f64> = (0..n * m).map(|i| (i as f64 * 0.1).sin()).collect();
1334        let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
1335        let t: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1336        let y = vec![5.0; n]; // Constant response
1337        let result = fdata_to_pls_1d(&data, &y, 2, &t);
1338        // Constant y → centering makes y all zeros, PLS may fail
1339        // Just ensure no panic
1340        let _ = result;
1341    }
1342
1343    // ============== FpcaResult::project tests ==============
1344
1345    #[test]
1346    fn test_fpca_project_shape() {
1347        let n = 20;
1348        let m = 30;
1349        let ncomp = 3;
1350        let (data, t) = generate_test_fdata(n, m);
1351        let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1352
1353        let new_data = FdMatrix::zeros(5, m);
1354        let scores = fpca.project(&new_data).unwrap();
1355        assert_eq!(scores.shape(), (5, ncomp));
1356    }
1357
1358    #[test]
1359    fn test_fpca_project_reproduces_training_scores() {
1360        let n = 20;
1361        let m = 30;
1362        let ncomp = 3;
1363        let (data, t) = generate_test_fdata(n, m);
1364        let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1365
1366        // Projecting the training data should reproduce the original scores
1367        let scores = fpca.project(&data).unwrap();
1368        for i in 0..n {
1369            for k in 0..ncomp {
1370                assert!(
1371                    (scores[(i, k)] - fpca.scores[(i, k)]).abs() < 1e-8,
1372                    "Score mismatch at ({}, {}): {} vs {}",
1373                    i,
1374                    k,
1375                    scores[(i, k)],
1376                    fpca.scores[(i, k)]
1377                );
1378            }
1379        }
1380    }
1381
1382    #[test]
1383    fn test_fpca_project_dimension_mismatch() {
1384        let (data, t) = generate_test_fdata(20, 30);
1385        let fpca = fdata_to_pc_1d(&data, 3, &t).unwrap();
1386
1387        let wrong_m = FdMatrix::zeros(5, 20); // wrong number of columns
1388        assert!(fpca.project(&wrong_m).is_err());
1389    }
1390
1391    // ============== FpcaResult::reconstruct tests ==============
1392
1393    #[test]
1394    fn test_fpca_reconstruct_shape() {
1395        let n = 10;
1396        let m = 30;
1397        let ncomp = 5;
1398        let (data, t) = generate_test_fdata(n, m);
1399        let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1400
1401        let recon = fpca.reconstruct(&fpca.scores, 3).unwrap();
1402        assert_eq!(recon.shape(), (n, m));
1403    }
1404
1405    #[test]
1406    fn test_fpca_reconstruct_full_matches_original() {
1407        let n = 10;
1408        let m = 30;
1409        let ncomp = n.min(m);
1410        let (data, t) = generate_test_fdata(n, m);
1411        let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1412
1413        // Full reconstruction should recover original data
1414        let recon = fpca.reconstruct(&fpca.scores, ncomp).unwrap();
1415        for i in 0..n {
1416            for j in 0..m {
1417                assert!(
1418                    (recon[(i, j)] - data[(i, j)]).abs() < 0.1,
1419                    "Reconstruction error at ({}, {}): {} vs {}",
1420                    i,
1421                    j,
1422                    recon[(i, j)],
1423                    data[(i, j)]
1424                );
1425            }
1426        }
1427    }
1428
1429    #[test]
1430    fn test_fpca_reconstruct_fewer_components() {
1431        let n = 20;
1432        let m = 30;
1433        let ncomp = 5;
1434        let (data, t) = generate_test_fdata(n, m);
1435        let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1436
1437        let recon2 = fpca.reconstruct(&fpca.scores, 2).unwrap();
1438        let recon5 = fpca.reconstruct(&fpca.scores, 5).unwrap();
1439        assert_eq!(recon2.shape(), (n, m));
1440        assert_eq!(recon5.shape(), (n, m));
1441    }
1442
1443    #[test]
1444    fn test_fpca_reconstruct_invalid_ncomp() {
1445        let (data, t) = generate_test_fdata(10, 30);
1446        let fpca = fdata_to_pc_1d(&data, 3, &t).unwrap();
1447
1448        // Zero components
1449        assert!(fpca.reconstruct(&fpca.scores, 0).is_err());
1450        // More components than available
1451        assert!(fpca.reconstruct(&fpca.scores, 10).is_err());
1452    }
1453
1454    // ============== PlsResult::project tests ==============
1455
1456    #[test]
1457    fn test_pls_project_shape() {
1458        let n = 20;
1459        let m = 30;
1460        let ncomp = 3;
1461        let (x, t) = generate_test_fdata(n, m);
1462        let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
1463        let pls = fdata_to_pls_1d(&x, &y, ncomp, &t).unwrap();
1464
1465        let new_x = FdMatrix::zeros(5, m);
1466        let scores = pls.project(&new_x).unwrap();
1467        assert_eq!(scores.shape(), (5, ncomp));
1468    }
1469
1470    #[test]
1471    fn test_pls_project_reproduces_training_scores() {
1472        let n = 20;
1473        let m = 30;
1474        let ncomp = 3;
1475        let (x, t) = generate_test_fdata(n, m);
1476        let y: Vec<f64> = (0..n).map(|i| (i as f64 / n as f64) + 0.1).collect();
1477        let pls = fdata_to_pls_1d(&x, &y, ncomp, &t).unwrap();
1478
1479        // Projecting the training data should reproduce the original scores
1480        let scores = pls.project(&x).unwrap();
1481        for i in 0..n {
1482            for k in 0..ncomp {
1483                assert!(
1484                    (scores[(i, k)] - pls.scores[(i, k)]).abs() < 1e-8,
1485                    "Score mismatch at ({}, {}): {} vs {}",
1486                    i,
1487                    k,
1488                    scores[(i, k)],
1489                    pls.scores[(i, k)]
1490                );
1491            }
1492        }
1493    }
1494
1495    #[test]
1496    fn test_pls_project_dimension_mismatch() {
1497        let (x, t) = generate_test_fdata(20, 30);
1498        let y: Vec<f64> = (0..20).map(|i| i as f64).collect();
1499        let pls = fdata_to_pls_1d(&x, &y, 3, &t).unwrap();
1500
1501        let wrong_m = FdMatrix::zeros(5, 20); // wrong number of columns
1502        assert!(pls.project(&wrong_m).is_err());
1503    }
1504
1505    #[test]
1506    fn test_pls_x_means_stored() {
1507        let n = 20;
1508        let m = 30;
1509        let (x, t) = generate_test_fdata(n, m);
1510        let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
1511        let pls = fdata_to_pls_1d(&x, &y, 3, &t).unwrap();
1512
1513        // x_means should be stored and have correct length
1514        assert_eq!(pls.x_means.len(), m);
1515    }
1516
1517    // ============== Regression tests for issue #22 ==============
1518
1519    /// Regression test: projection of original data recovers original scores.
1520    #[test]
1521    fn fpca_project_recovers_original_scores() {
1522        let n = 15;
1523        let m = 40;
1524        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1525        let vals: Vec<f64> = (0..n)
1526            .flat_map(|i| {
1527                argvals
1528                    .iter()
1529                    .map(move |&t| (2.0 * PI * t).sin() + 0.3 * i as f64 * t)
1530            })
1531            .collect();
1532        let data = FdMatrix::from_column_major(vals, n, m).unwrap();
1533        let fpca = fdata_to_pc_1d(&data, 3, &argvals).unwrap();
1534
1535        // project the training data — should match original scores
1536        let projected = fpca.project(&data).unwrap();
1537        for i in 0..n {
1538            for k in 0..3 {
1539                let diff = (fpca.scores[(i, k)] - projected[(i, k)]).abs();
1540                assert!(
1541                    diff < 1e-8,
1542                    "project score [{i},{k}] mismatch: orig={:.6}, proj={:.6}",
1543                    fpca.scores[(i, k)],
1544                    projected[(i, k)]
1545                );
1546            }
1547        }
1548    }
1549
1550    /// Regression test: weights are stored and have correct properties.
1551    #[test]
1552    fn fpca_weights_are_stored() {
1553        let m = 50;
1554        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1555        let data =
1556            FdMatrix::from_column_major((0..150).map(|i| (i as f64 * 0.1).sin()).collect(), 3, m)
1557                .unwrap();
1558        let fpca = fdata_to_pc_1d(&data, 2, &argvals).unwrap();
1559
1560        // Weights should exist and be positive
1561        assert_eq!(fpca.weights.len(), m);
1562        assert!(fpca.weights.iter().all(|&w| w > 0.0));
1563
1564        // Weights should sum to approximately the domain length (1.0 for [0,1])
1565        let sum: f64 = fpca.weights.iter().sum();
1566        assert!(
1567            (sum - 1.0).abs() < 0.01,
1568            "weight sum should ≈ 1.0, got {sum}"
1569        );
1570    }
1571
1572    /// Regression test: variance explained is consistent across grid densities.
1573    #[test]
1574    fn fpca_variance_explained_grid_invariant() {
1575        use rand::rngs::StdRng;
1576        use rand::{Rng, SeedableRng};
1577
1578        let n = 20;
1579        let mut rng = StdRng::seed_from_u64(99);
1580        let coeffs: Vec<(f64, f64)> = (0..n)
1581            .map(|_| (rng.gen_range(-1.0..1.0), rng.gen_range(-1.0..1.0)))
1582            .collect();
1583
1584        let make_data = |m: usize| -> (FdMatrix, Vec<f64>) {
1585            let t: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1586            let mut vals = vec![0.0; n * m];
1587            for (i, &(a, b)) in coeffs.iter().enumerate() {
1588                for (j, &tj) in t.iter().enumerate() {
1589                    vals[i + j * n] = a * (2.0 * PI * tj).sin() + b * (4.0 * PI * tj).cos();
1590                }
1591            }
1592            (FdMatrix::from_column_major(vals, n, m).unwrap(), t)
1593        };
1594
1595        let (d1, t1) = make_data(41);
1596        let (d2, t2) = make_data(201);
1597        let f1 = fdata_to_pc_1d(&d1, 2, &t1).unwrap();
1598        let f2 = fdata_to_pc_1d(&d2, 2, &t2).unwrap();
1599
1600        let total1: f64 = f1.singular_values.iter().map(|s| s * s).sum();
1601        let total2: f64 = f2.singular_values.iter().map(|s| s * s).sum();
1602        let pve1 = f1.singular_values[0].powi(2) / total1;
1603        let pve2 = f2.singular_values[0].powi(2) / total2;
1604
1605        assert!(
1606            (pve1 - pve2).abs() < 0.05,
1607            "variance explained differs: coarse={pve1:.4}, fine={pve2:.4}"
1608        );
1609    }
1610
1611    #[test]
1612    fn fpca_scores_invariant_to_grid_density() {
1613        use rand::rngs::StdRng;
1614        use rand::{Rng, SeedableRng};
1615
1616        let n = 20;
1617
1618        // Generate random coefficients once
1619        let mut rng = StdRng::seed_from_u64(42);
1620        let coeffs: Vec<(f64, f64)> = (0..n)
1621            .map(|_| (rng.gen_range(-1.0..1.0), rng.gen_range(-1.0..1.0)))
1622            .collect();
1623
1624        // Helper: generate data on a given grid from the same analytic expression
1625        let make_data = |m: usize| -> (FdMatrix, Vec<f64>) {
1626            let t: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1627            let mut vals = vec![0.0; n * m];
1628            for (i, &(a, b)) in coeffs.iter().enumerate() {
1629                for (j, &tj) in t.iter().enumerate() {
1630                    vals[i + j * n] = a * (2.0 * PI * tj).sin() + b * (4.0 * PI * tj).cos();
1631                }
1632            }
1633            let data = FdMatrix::from_column_major(vals, n, m).unwrap();
1634            (data, t)
1635        };
1636
1637        let (data1, t1) = make_data(51);
1638        let (data2, t2) = make_data(201);
1639
1640        let fpca1 = fdata_to_pc_1d(&data1, 2, &t1).unwrap();
1641        let fpca2 = fdata_to_pc_1d(&data2, 2, &t2).unwrap();
1642
1643        // Scores should be approximately the same (allow sign flip per component)
1644        for k in 0..2 {
1645            // Determine sign: use the sign of the dot product between score vectors
1646            let dot: f64 = (0..n)
1647                .map(|i| fpca1.scores[(i, k)] * fpca2.scores[(i, k)])
1648                .sum();
1649            let sign = if dot >= 0.0 { 1.0 } else { -1.0 };
1650
1651            for i in 0..n {
1652                let s1 = fpca1.scores[(i, k)];
1653                let s2 = sign * fpca2.scores[(i, k)];
1654                let rel_diff = if s1.abs() > 1e-6 {
1655                    (s1 - s2).abs() / s1.abs()
1656                } else {
1657                    (s1 - s2).abs()
1658                };
1659                assert!(
1660                    rel_diff < 0.10,
1661                    "score [{i},{k}] differs: coarse={s1:.4}, fine={s2:.4}, rel_diff={rel_diff:.4}"
1662                );
1663            }
1664        }
1665    }
1666}