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