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