1use 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#[cfg(any(not(feature = "linalg"), test))]
19use nalgebra::SVD;
20
21#[derive(Debug, Clone, PartialEq)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24#[non_exhaustive]
25pub struct FpcaResult {
26 pub singular_values: Vec<f64>,
28 pub rotation: FdMatrix,
30 pub scores: FdMatrix,
32 pub mean: Vec<f64>,
34 pub centered: FdMatrix,
36 pub weights: Vec<f64>,
38}
39
40impl FpcaResult {
41 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 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
173fn 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
203fn 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#[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#[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 let weights = simpsons_weights(argvals);
326 let sqrt_weights: Vec<f64> = weights.iter().map(|w| w.sqrt()).collect();
327
328 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 #[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 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 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 fix_svd_signs(&mut rotation, &mut scores, ncomp);
382
383 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#[derive(Debug, Clone, PartialEq)]
404#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
405#[non_exhaustive]
406pub struct PlsResult {
407 pub weights: FdMatrix,
409 pub scores: FdMatrix,
411 pub loadings: FdMatrix,
413 pub x_means: Vec<f64>,
415 pub integration_weights: Vec<f64>,
417}
418
419impl PlsResult {
420 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 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 let mut scores = FdMatrix::zeros(n, ncomp);
482 for k in 0..ncomp {
483 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 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
505fn 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
527fn 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
541fn 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
555fn 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
565fn 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#[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 let int_w = simpsons_weights(argvals);
660
661 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 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#[derive(Debug, Clone, PartialEq)]
709#[non_exhaustive]
710#[cfg(feature = "linalg")]
711pub struct RidgeResult {
712 pub coefficients: Vec<f64>,
714 pub intercept: f64,
716 pub fitted_values: Vec<f64>,
718 pub residuals: Vec<f64>,
720 pub r_squared: f64,
722 pub lambda: f64,
724 pub error: Option<String>,
726}
727
728#[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 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 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 let coefs = fitted.coefficients();
783 let coefficients: Vec<f64> = (0..coefs.nrows()).map(|i| coefs[i]).collect();
784
785 let intercept = fitted.intercept().unwrap_or(0.0);
787
788 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 let residuals: Vec<f64> = y
800 .iter()
801 .zip(fitted_values.iter())
802 .map(|(&yi, &yhat)| yi - yhat)
803 .collect();
804
805 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 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 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 #[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 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 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 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 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 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 let ncomp = n.min(m);
936 let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
937
938 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 #[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 let faer = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
974
975 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 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; }
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 #[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 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 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 let result = fdata_to_pls_1d(&x, &[0.0; 5], 2, &t);
1084 assert!(result.is_err());
1085
1086 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 #[cfg(feature = "linalg")]
1095 #[test]
1096 fn test_ridge_regression_fit_basic() {
1097 let n = 50;
1098 let m = 5;
1099
1100 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 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 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 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 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 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]; let result = fdata_to_pls_1d(&data, &y, 2, &t);
1294 let _ = result;
1297 }
1298
1299 #[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 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); assert!(fpca.project(&wrong_m).is_err());
1345 }
1346
1347 #[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 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 assert!(fpca.reconstruct(&fpca.scores, 0).is_err());
1406 assert!(fpca.reconstruct(&fpca.scores, 10).is_err());
1408 }
1409
1410 #[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 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); 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 assert_eq!(pls.x_means.len(), m);
1471 }
1472
1473 #[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 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 #[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 assert_eq!(fpca.weights.len(), m);
1518 assert!(fpca.weights.iter().all(|&w| w > 0.0));
1519
1520 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 #[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 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 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 for k in 0..2 {
1601 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}