1use crate::error::FdarError;
30use crate::helpers::simpsons_weights;
31use crate::matrix::FdMatrix;
32#[cfg(feature = "linalg")]
33use anofox_regression::solvers::RidgeRegressor;
34#[cfg(feature = "linalg")]
35use anofox_regression::{FittedRegressor, Regressor};
36#[cfg(feature = "linalg")]
37use faer::linalg::solvers::Svd as FaerSvd;
38#[cfg(feature = "linalg")]
39use faer::MatRef;
40#[cfg(any(not(feature = "linalg"), test))]
43use nalgebra::SVD;
44
45#[derive(Debug, Clone, PartialEq)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48#[non_exhaustive]
49pub struct FpcaResult {
50 pub singular_values: Vec<f64>,
52 pub rotation: FdMatrix,
54 pub scores: FdMatrix,
56 pub mean: Vec<f64>,
58 pub centered: FdMatrix,
60 pub weights: Vec<f64>,
62}
63
64impl FpcaResult {
65 pub fn project(&self, data: &FdMatrix) -> Result<FdMatrix, FdarError> {
106 let (n, m) = data.shape();
107 let ncomp = self.rotation.ncols();
108 if m != self.mean.len() {
109 return Err(FdarError::InvalidDimension {
110 parameter: "data",
111 expected: format!("{} columns", self.mean.len()),
112 actual: format!("{m} columns"),
113 });
114 }
115
116 let mut scores = FdMatrix::zeros(n, ncomp);
117 for i in 0..n {
118 for k in 0..ncomp {
119 let mut sum = 0.0;
120 for j in 0..m {
121 sum += (data[(i, j)] - self.mean[j]) * self.rotation[(j, k)] * self.weights[j];
122 }
123 scores[(i, k)] = sum;
124 }
125 }
126 Ok(scores)
127 }
128
129 pub fn reconstruct(&self, scores: &FdMatrix, ncomp: usize) -> Result<FdMatrix, FdarError> {
167 let (n, p) = scores.shape();
168 let m = self.mean.len();
169 let max_comp = self.rotation.ncols().min(p);
170 if ncomp == 0 {
171 return Err(FdarError::InvalidParameter {
172 parameter: "ncomp",
173 message: "ncomp must be >= 1".to_string(),
174 });
175 }
176 if ncomp > max_comp {
177 return Err(FdarError::InvalidParameter {
178 parameter: "ncomp",
179 message: format!("ncomp={ncomp} exceeds available components ({max_comp})"),
180 });
181 }
182
183 let mut recon = FdMatrix::zeros(n, m);
184 for i in 0..n {
185 for j in 0..m {
186 let mut val = self.mean[j];
187 for k in 0..ncomp {
188 val += scores[(i, k)] * self.rotation[(j, k)];
189 }
190 recon[(i, j)] = val;
191 }
192 }
193 Ok(recon)
194 }
195}
196
197pub(crate) fn dominant_sign_negative(col: &FdMatrix, k: usize, nrows: usize) -> bool {
211 let j_max = (0..nrows)
212 .max_by(|&a, &b| {
213 col[(a, k)]
214 .abs()
215 .partial_cmp(&col[(b, k)].abs())
216 .unwrap_or(std::cmp::Ordering::Equal)
217 })
218 .unwrap_or(0);
219 col[(j_max, k)] < 0.0
220}
221
222fn fix_svd_signs(rotation: &mut FdMatrix, scores: &mut FdMatrix, ncomp: usize) {
233 let m = rotation.nrows();
234 let n = scores.nrows();
235 for k in 0..ncomp {
236 if dominant_sign_negative(rotation, k, m) {
237 for j in 0..m {
238 rotation[(j, k)] = -rotation[(j, k)];
239 }
240 for i in 0..n {
241 scores[(i, k)] = -scores[(i, k)];
242 }
243 }
244 }
245}
246
247fn center_columns(data: &FdMatrix) -> (FdMatrix, Vec<f64>) {
249 let (n, m) = data.shape();
250 let mut centered = FdMatrix::zeros(n, m);
251 let mut means = vec![0.0; m];
252 for j in 0..m {
253 let col = data.column(j);
254 let mean = col.iter().sum::<f64>() / n as f64;
255 means[j] = mean;
256 let out_col = centered.column_mut(j);
257 for i in 0..n {
258 out_col[i] = col[i] - mean;
259 }
260 }
261 (centered, means)
262}
263
264#[cfg(any(not(feature = "linalg"), test))]
266fn extract_pc_components(
267 svd: &SVD<f64, nalgebra::Dyn, nalgebra::Dyn>,
268 n: usize,
269 m: usize,
270 ncomp: usize,
271) -> Option<(Vec<f64>, FdMatrix, FdMatrix)> {
272 let singular_values: Vec<f64> = svd.singular_values.iter().take(ncomp).copied().collect();
273
274 let v_t = svd.v_t.as_ref()?;
275 let mut rotation = FdMatrix::zeros(m, ncomp);
276 for k in 0..ncomp {
277 for j in 0..m {
278 rotation[(j, k)] = v_t[(k, j)];
279 }
280 }
281
282 let u = svd.u.as_ref()?;
283 let mut scores = FdMatrix::zeros(n, ncomp);
284 for k in 0..ncomp {
285 let sv_k = singular_values[k];
286 for i in 0..n {
287 scores[(i, k)] = u[(i, k)] * sv_k;
288 }
289 }
290
291 Some((singular_values, rotation, scores))
292}
293
294#[must_use = "expensive computation whose result should not be discarded"]
331pub fn fdata_to_pc_1d(
332 data: &FdMatrix,
333 ncomp: usize,
334 argvals: &[f64],
335) -> Result<FpcaResult, FdarError> {
336 let (n, m) = data.shape();
337 if n == 0 {
338 return Err(FdarError::InvalidDimension {
339 parameter: "data",
340 expected: "n > 0 rows".to_string(),
341 actual: format!("n = {n}"),
342 });
343 }
344 if m == 0 {
345 return Err(FdarError::InvalidDimension {
346 parameter: "data",
347 expected: "m > 0 columns".to_string(),
348 actual: format!("m = {m}"),
349 });
350 }
351 if argvals.len() != m {
352 return Err(FdarError::InvalidDimension {
353 parameter: "argvals",
354 expected: format!("{m} elements"),
355 actual: format!("{} elements", argvals.len()),
356 });
357 }
358 if ncomp < 1 {
359 return Err(FdarError::InvalidParameter {
360 parameter: "ncomp",
361 message: format!("ncomp must be >= 1, got {ncomp}"),
362 });
363 }
364
365 let ncomp = ncomp.min(n).min(m);
366 let (centered, means) = center_columns(data);
367
368 let weights = simpsons_weights(argvals);
370 let sqrt_weights: Vec<f64> = weights.iter().map(|w| w.sqrt()).collect();
371
372 let mut weighted = centered.clone();
374 for i in 0..n {
375 for j in 0..m {
376 weighted[(i, j)] *= sqrt_weights[j];
377 }
378 }
379
380 #[cfg(feature = "linalg")]
383 let (singular_values, mut rotation, mut scores) = {
384 let mat_ref = MatRef::<f64>::from_column_major_slice(weighted.as_slice(), n, m);
385 let svd = FaerSvd::new_thin(mat_ref).map_err(|_| FdarError::ComputationFailed {
386 operation: "SVD (faer)",
387 detail:
388 "faer thin_svd failed; try reducing ncomp or check for zero-variance columns in the data"
389 .to_string(),
390 })?;
391 let s_col = svd.S().column_vector();
392 let singular_values: Vec<f64> = s_col.iter().take(ncomp).copied().collect();
393 let mut rotation = FdMatrix::zeros(m, ncomp);
396 for k in 0..ncomp {
397 for j in 0..m {
398 rotation[(j, k)] = svd.V()[(j, k)];
399 }
400 }
401 let mut scores = FdMatrix::zeros(n, ncomp);
403 for k in 0..ncomp {
404 let sv_k = singular_values[k];
405 for i in 0..n {
406 scores[(i, k)] = svd.U()[(i, k)] * sv_k;
407 }
408 }
409 (singular_values, rotation, scores)
410 };
411
412 #[cfg(not(feature = "linalg"))]
413 let (singular_values, mut rotation, mut scores) = {
414 let svd = SVD::new(weighted.to_dmatrix(), true, true);
415 let (sv, rot, sc) =
416 extract_pc_components(&svd, n, m, ncomp).ok_or_else(|| FdarError::ComputationFailed {
417 operation: "SVD",
418 detail: "failed to extract U or V_t from SVD decomposition; try reducing ncomp or check for zero-variance columns in the data".to_string(),
419 })?;
420 (sv, rot, sc)
421 };
422
423 fix_svd_signs(&mut rotation, &mut scores, ncomp);
426
427 for k in 0..ncomp {
429 for j in 0..m {
430 if sqrt_weights[j] > 1e-15 {
431 rotation[(j, k)] /= sqrt_weights[j];
432 }
433 }
434 }
435
436 Ok(FpcaResult {
437 singular_values,
438 rotation,
439 scores,
440 mean: means,
441 centered,
442 weights,
443 })
444}
445
446#[derive(Debug, Clone, PartialEq)]
448#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
449#[non_exhaustive]
450pub struct PlsResult {
451 pub weights: FdMatrix,
453 pub scores: FdMatrix,
455 pub loadings: FdMatrix,
457 pub x_means: Vec<f64>,
459 pub integration_weights: Vec<f64>,
461}
462
463impl PlsResult {
464 pub fn project(&self, data: &FdMatrix) -> Result<FdMatrix, FdarError> {
506 let (n, m) = data.shape();
507 let ncomp = self.weights.ncols();
508 if m != self.x_means.len() {
509 return Err(FdarError::InvalidDimension {
510 parameter: "data",
511 expected: format!("{} columns", self.x_means.len()),
512 actual: format!("{m} columns"),
513 });
514 }
515
516 let mut x_cen = FdMatrix::zeros(n, m);
518 for j in 0..m {
519 for i in 0..n {
520 x_cen[(i, j)] = data[(i, j)] - self.x_means[j];
521 }
522 }
523
524 let mut scores = FdMatrix::zeros(n, ncomp);
526 for k in 0..ncomp {
527 for i in 0..n {
529 let mut sum = 0.0;
530 for j in 0..m {
531 sum += x_cen[(i, j)] * self.weights[(j, k)] * self.integration_weights[j];
532 }
533 scores[(i, k)] = sum;
534 }
535
536 for j in 0..m {
538 let p_jk = self.loadings[(j, k)];
539 for i in 0..n {
540 x_cen[(i, j)] -= scores[(i, k)] * p_jk;
541 }
542 }
543 }
544
545 Ok(scores)
546 }
547}
548
549fn pls_compute_weights(x_cen: &FdMatrix, y_cen: &[f64], int_w: &[f64]) -> Vec<f64> {
551 let (n, m) = x_cen.shape();
552 let mut w: Vec<f64> = (0..m)
553 .map(|j| {
554 let mut sum = 0.0;
555 for i in 0..n {
556 sum += x_cen[(i, j)] * y_cen[i];
557 }
558 sum * int_w[j]
559 })
560 .collect();
561
562 let w_norm: f64 = w.iter().map(|&wi| wi * wi).sum::<f64>().sqrt();
563 if w_norm > 1e-10 {
564 for wi in &mut w {
565 *wi /= w_norm;
566 }
567 }
568 w
569}
570
571fn pls_compute_scores(x_cen: &FdMatrix, w: &[f64], int_w: &[f64]) -> Vec<f64> {
573 let (n, m) = x_cen.shape();
574 (0..n)
575 .map(|i| {
576 let mut sum = 0.0;
577 for j in 0..m {
578 sum += x_cen[(i, j)] * w[j] * int_w[j];
579 }
580 sum
581 })
582 .collect()
583}
584
585fn pls_compute_loadings(x_cen: &FdMatrix, t: &[f64], t_norm_sq: f64, int_w: &[f64]) -> Vec<f64> {
587 let (n, m) = x_cen.shape();
588 (0..m)
589 .map(|j| {
590 let mut sum = 0.0;
591 for i in 0..n {
592 sum += x_cen[(i, j)] * t[i];
593 }
594 sum * int_w[j] / t_norm_sq.max(1e-10)
595 })
596 .collect()
597}
598
599fn pls_deflate_x(x_cen: &mut FdMatrix, t: &[f64], p: &[f64]) {
601 let (n, m) = x_cen.shape();
602 for j in 0..m {
603 for i in 0..n {
604 x_cen[(i, j)] -= t[i] * p[j];
605 }
606 }
607}
608
609fn pls_nipals_step(
611 k: usize,
612 x_cen: &mut FdMatrix,
613 y_cen: &mut [f64],
614 weights: &mut FdMatrix,
615 scores: &mut FdMatrix,
616 loadings: &mut FdMatrix,
617 int_w: &[f64],
618) {
619 let n = x_cen.nrows();
620 let m = x_cen.ncols();
621
622 let w = pls_compute_weights(x_cen, y_cen, int_w);
623 let t = pls_compute_scores(x_cen, &w, int_w);
624 let t_norm_sq: f64 = t.iter().map(|&ti| ti * ti).sum();
625 let p = pls_compute_loadings(x_cen, &t, t_norm_sq, int_w);
626
627 for j in 0..m {
628 weights[(j, k)] = w[j];
629 loadings[(j, k)] = p[j];
630 }
631 for i in 0..n {
632 scores[(i, k)] = t[i];
633 }
634
635 pls_deflate_x(x_cen, &t, &p);
636 let t_y: f64 = t.iter().zip(y_cen.iter()).map(|(&ti, &yi)| ti * yi).sum();
637 let q = t_y / t_norm_sq.max(1e-10);
638 for i in 0..n {
639 y_cen[i] -= t[i] * q;
640 }
641}
642
643#[must_use = "expensive computation whose result should not be discarded"]
658pub fn fdata_to_pls_1d(
659 data: &FdMatrix,
660 y: &[f64],
661 ncomp: usize,
662 argvals: &[f64],
663) -> Result<PlsResult, FdarError> {
664 let (n, m) = data.shape();
665 if n == 0 {
666 return Err(FdarError::InvalidDimension {
667 parameter: "data",
668 expected: "n > 0 rows".to_string(),
669 actual: format!("n = {n}"),
670 });
671 }
672 if m == 0 {
673 return Err(FdarError::InvalidDimension {
674 parameter: "data",
675 expected: "m > 0 columns".to_string(),
676 actual: format!("m = {m}"),
677 });
678 }
679 if y.len() != n {
680 return Err(FdarError::InvalidDimension {
681 parameter: "y",
682 expected: format!("length {n}"),
683 actual: format!("length {}", y.len()),
684 });
685 }
686 if argvals.len() != m {
687 return Err(FdarError::InvalidDimension {
688 parameter: "argvals",
689 expected: format!("{m} elements"),
690 actual: format!("{} elements", argvals.len()),
691 });
692 }
693 if ncomp < 1 {
694 return Err(FdarError::InvalidParameter {
695 parameter: "ncomp",
696 message: format!("ncomp must be >= 1, got {ncomp}"),
697 });
698 }
699
700 let ncomp = ncomp.min(n).min(m);
701
702 let int_w = simpsons_weights(argvals);
704
705 let x_means: Vec<f64> = (0..m)
707 .map(|j| {
708 let col = data.column(j);
709 let sum: f64 = col.iter().sum();
710 sum / n as f64
711 })
712 .collect();
713
714 let y_mean: f64 = y.iter().sum::<f64>() / n as f64;
715
716 let mut x_cen = FdMatrix::zeros(n, m);
717 for j in 0..m {
718 for i in 0..n {
719 x_cen[(i, j)] = data[(i, j)] - x_means[j];
720 }
721 }
722
723 let mut y_cen: Vec<f64> = y.iter().map(|&yi| yi - y_mean).collect();
724
725 let mut weights = FdMatrix::zeros(m, ncomp);
726 let mut scores = FdMatrix::zeros(n, ncomp);
727 let mut loadings = FdMatrix::zeros(m, ncomp);
728
729 for k in 0..ncomp {
731 pls_nipals_step(
732 k,
733 &mut x_cen,
734 &mut y_cen,
735 &mut weights,
736 &mut scores,
737 &mut loadings,
738 &int_w,
739 );
740 }
741
742 Ok(PlsResult {
743 weights,
744 scores,
745 loadings,
746 x_means,
747 integration_weights: int_w,
748 })
749}
750
751#[derive(Debug, Clone, PartialEq)]
753#[non_exhaustive]
754#[cfg(feature = "linalg")]
755pub struct RidgeResult {
756 pub coefficients: Vec<f64>,
758 pub intercept: f64,
760 pub fitted_values: Vec<f64>,
762 pub residuals: Vec<f64>,
764 pub r_squared: f64,
766 pub lambda: f64,
768 pub error: Option<String>,
770}
771
772#[cfg(feature = "linalg")]
780#[must_use = "expensive computation whose result should not be discarded"]
781pub fn ridge_regression_fit(
782 x: &FdMatrix,
783 y: &[f64],
784 lambda: f64,
785 with_intercept: bool,
786) -> RidgeResult {
787 let (n, m) = x.shape();
788 if n == 0 || m == 0 || y.len() != n {
789 return RidgeResult {
790 coefficients: Vec::new(),
791 intercept: 0.0,
792 fitted_values: Vec::new(),
793 residuals: Vec::new(),
794 r_squared: 0.0,
795 lambda,
796 error: Some("Invalid input dimensions".to_string()),
797 };
798 }
799
800 let x_faer = faer::Mat::from_fn(n, m, |i, j| x[(i, j)]);
802 let y_faer = faer::Col::from_fn(n, |i| y[i]);
803
804 let regressor = RidgeRegressor::builder()
806 .with_intercept(with_intercept)
807 .lambda(lambda)
808 .build();
809
810 let fitted = match regressor.fit(&x_faer, &y_faer) {
811 Ok(f) => f,
812 Err(e) => {
813 return RidgeResult {
814 coefficients: Vec::new(),
815 intercept: 0.0,
816 fitted_values: Vec::new(),
817 residuals: Vec::new(),
818 r_squared: 0.0,
819 lambda,
820 error: Some(format!("Fit failed: {e:?}")),
821 }
822 }
823 };
824
825 let coefs = fitted.coefficients();
827 let coefficients: Vec<f64> = (0..coefs.nrows()).map(|i| coefs[i]).collect();
828
829 let intercept = fitted.intercept().unwrap_or(0.0);
831
832 let mut fitted_values = vec![0.0; n];
834 for i in 0..n {
835 let mut pred = intercept;
836 for j in 0..m {
837 pred += x[(i, j)] * coefficients[j];
838 }
839 fitted_values[i] = pred;
840 }
841
842 let residuals: Vec<f64> = y
844 .iter()
845 .zip(fitted_values.iter())
846 .map(|(&yi, &yhat)| yi - yhat)
847 .collect();
848
849 let y_mean: f64 = y.iter().sum::<f64>() / n as f64;
851 let ss_tot: f64 = y.iter().map(|&yi| (yi - y_mean).powi(2)).sum();
852 let ss_res: f64 = residuals.iter().map(|&r| r.powi(2)).sum();
853 let r_squared = if ss_tot > 0.0 {
854 1.0 - ss_res / ss_tot
855 } else {
856 0.0
857 };
858
859 RidgeResult {
860 coefficients,
861 intercept,
862 fitted_values,
863 residuals,
864 r_squared,
865 lambda,
866 error: None,
867 }
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873 use std::f64::consts::PI;
874
875 fn generate_test_fdata(n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
877 let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
878
879 let mut data = FdMatrix::zeros(n, m);
881 for i in 0..n {
882 let phase = (i as f64 / n as f64) * PI;
883 for j in 0..m {
884 data[(i, j)] = (2.0 * PI * t[j] + phase).sin();
885 }
886 }
887
888 (data, t)
889 }
890
891 #[test]
894 fn test_fdata_to_pc_1d_basic() {
895 let n = 20;
896 let m = 50;
897 let ncomp = 3;
898 let (data, t) = generate_test_fdata(n, m);
899
900 let result = fdata_to_pc_1d(&data, ncomp, &t);
901 assert!(result.is_ok());
902
903 let fpca = result.unwrap();
904 assert_eq!(fpca.singular_values.len(), ncomp);
905 assert_eq!(fpca.rotation.shape(), (m, ncomp));
906 assert_eq!(fpca.scores.shape(), (n, ncomp));
907 assert_eq!(fpca.mean.len(), m);
908 assert_eq!(fpca.centered.shape(), (n, m));
909 }
910
911 #[test]
912 fn test_fdata_to_pc_1d_singular_values_decreasing() {
913 let n = 20;
914 let m = 50;
915 let ncomp = 5;
916 let (data, t) = generate_test_fdata(n, m);
917
918 let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
919
920 for i in 1..fpca.singular_values.len() {
922 assert!(
923 fpca.singular_values[i] <= fpca.singular_values[i - 1] + 1e-10,
924 "Singular values should be decreasing"
925 );
926 }
927 }
928
929 #[test]
930 fn test_fdata_to_pc_1d_centered_has_zero_mean() {
931 let n = 20;
932 let m = 50;
933 let (data, t) = generate_test_fdata(n, m);
934
935 let fpca = fdata_to_pc_1d(&data, 3, &t).unwrap();
936
937 for j in 0..m {
939 let col_mean: f64 = (0..n).map(|i| fpca.centered[(i, j)]).sum::<f64>() / n as f64;
940 assert!(
941 col_mean.abs() < 1e-10,
942 "Centered data should have zero column mean"
943 );
944 }
945 }
946
947 #[test]
948 fn test_fdata_to_pc_1d_ncomp_limits() {
949 let n = 10;
950 let m = 50;
951 let (data, t) = generate_test_fdata(n, m);
952
953 let fpca = fdata_to_pc_1d(&data, 20, &t).unwrap();
955 assert!(fpca.singular_values.len() <= n);
956 }
957
958 #[test]
959 fn test_fdata_to_pc_1d_invalid_input() {
960 let empty = FdMatrix::zeros(0, 50);
962 let t50: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
963 let result = fdata_to_pc_1d(&empty, 3, &t50);
964 assert!(result.is_err());
965
966 let (data, t) = generate_test_fdata(10, 50);
968 let result = fdata_to_pc_1d(&data, 0, &t);
969 assert!(result.is_err());
970 }
971
972 #[test]
973 fn test_fdata_to_pc_1d_reconstruction() {
974 let n = 10;
975 let m = 30;
976 let (data, t) = generate_test_fdata(n, m);
977
978 let ncomp = n.min(m);
980 let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
981
982 for i in 0..n {
984 for j in 0..m {
985 let mut reconstructed = 0.0;
986 for k in 0..ncomp {
987 let score = fpca.scores[(i, k)];
988 let loading = fpca.rotation[(j, k)];
989 reconstructed += score * loading;
990 }
991 let original_centered = fpca.centered[(i, j)];
992 assert!(
993 (reconstructed - original_centered).abs() < 0.1,
994 "Reconstruction error at ({}, {}): {} vs {}",
995 i,
996 j,
997 reconstructed,
998 original_centered
999 );
1000 }
1001 }
1002 }
1003
1004 #[cfg(all(test, feature = "linalg"))]
1009 #[test]
1010 fn test_faer_svd_matches_nalgebra() {
1011 let n = 30;
1012 let m = 40;
1013 let ncomp = 5;
1014 let (data, t) = generate_test_fdata(n, m);
1015
1016 let faer = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1018
1019 let ncomp_eff = ncomp.min(n).min(m);
1023 let (_centered, _means) = center_columns(&data);
1024 let weights = simpsons_weights(&t);
1025 let sqrt_weights: Vec<f64> = weights.iter().map(|w| w.sqrt()).collect();
1026 let mut weighted = _centered.clone();
1027 for i in 0..n {
1028 for j in 0..m {
1029 weighted[(i, j)] *= sqrt_weights[j];
1030 }
1031 }
1032 let svd = nalgebra::SVD::new(weighted.to_dmatrix(), true, true);
1033 let (ref_sv, mut ref_rotation, mut ref_scores) =
1034 extract_pc_components(&svd, n, m, ncomp_eff).unwrap();
1035 fix_svd_signs(&mut ref_rotation, &mut ref_scores, ncomp_eff);
1036 for k in 0..ncomp_eff {
1037 for j in 0..m {
1038 if sqrt_weights[j] > 1e-15 {
1039 ref_rotation[(j, k)] /= sqrt_weights[j];
1040 }
1041 }
1042 }
1043
1044 let s1 = faer.singular_values[0];
1046 let tol = 1e-8 * s1;
1047 for k in 0..ncomp_eff {
1048 if faer.singular_values[k] < 1e-8 * s1 {
1049 continue; }
1051 assert!(
1052 (faer.singular_values[k] - ref_sv[k]).abs() < tol,
1053 "singular_value[{k}] mismatch: faer={}, nalgebra={}",
1054 faer.singular_values[k],
1055 ref_sv[k]
1056 );
1057 for j in 0..m {
1058 assert!(
1059 (faer.rotation[(j, k)] - ref_rotation[(j, k)]).abs() < tol,
1060 "rotation[{j},{k}] mismatch: faer={}, nalgebra={}",
1061 faer.rotation[(j, k)],
1062 ref_rotation[(j, k)]
1063 );
1064 }
1065 for i in 0..n {
1066 assert!(
1067 (faer.scores[(i, k)] - ref_scores[(i, k)]).abs() < tol,
1068 "scores[{i},{k}] mismatch: faer={}, nalgebra={}",
1069 faer.scores[(i, k)],
1070 ref_scores[(i, k)]
1071 );
1072 }
1073 }
1074 }
1075
1076 #[test]
1079 fn test_fdata_to_pls_1d_basic() {
1080 let n = 20;
1081 let m = 30;
1082 let ncomp = 3;
1083 let (x, t) = generate_test_fdata(n, m);
1084
1085 let y: Vec<f64> = (0..n).map(|i| (i as f64 / n as f64) + 0.1).collect();
1087
1088 let result = fdata_to_pls_1d(&x, &y, ncomp, &t);
1089 assert!(result.is_ok());
1090
1091 let pls = result.unwrap();
1092 assert_eq!(pls.weights.shape(), (m, ncomp));
1093 assert_eq!(pls.scores.shape(), (n, ncomp));
1094 assert_eq!(pls.loadings.shape(), (m, ncomp));
1095 }
1096
1097 #[test]
1098 fn test_fdata_to_pls_1d_weights_normalized() {
1099 let n = 20;
1100 let m = 30;
1101 let ncomp = 2;
1102 let (x, t) = generate_test_fdata(n, m);
1103 let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
1104
1105 let pls = fdata_to_pls_1d(&x, &y, ncomp, &t).unwrap();
1106
1107 for k in 0..ncomp {
1109 let norm: f64 = (0..m)
1110 .map(|j| pls.weights[(j, k)].powi(2))
1111 .sum::<f64>()
1112 .sqrt();
1113 assert!(
1114 (norm - 1.0).abs() < 0.1,
1115 "Weight vector {} should be unit norm, got {}",
1116 k,
1117 norm
1118 );
1119 }
1120 }
1121
1122 #[test]
1123 fn test_fdata_to_pls_1d_invalid_input() {
1124 let (x, t) = generate_test_fdata(10, 30);
1125
1126 let result = fdata_to_pls_1d(&x, &[0.0; 5], 2, &t);
1128 assert!(result.is_err());
1129
1130 let y = vec![0.0; 10];
1132 let result = fdata_to_pls_1d(&x, &y, 0, &t);
1133 assert!(result.is_err());
1134 }
1135
1136 #[cfg(feature = "linalg")]
1139 #[test]
1140 fn test_ridge_regression_fit_basic() {
1141 let n = 50;
1142 let m = 5;
1143
1144 let mut x = FdMatrix::zeros(n, m);
1146 for i in 0..n {
1147 for j in 0..m {
1148 x[(i, j)] = (i as f64 + j as f64) / (n + m) as f64;
1149 }
1150 }
1151
1152 let y: Vec<f64> = (0..n)
1154 .map(|i| {
1155 let mut sum = 0.0;
1156 for j in 0..m {
1157 sum += x[(i, j)];
1158 }
1159 sum + 0.01 * (i as f64 % 10.0)
1160 })
1161 .collect();
1162
1163 let result = ridge_regression_fit(&x, &y, 0.1, true);
1164
1165 assert!(result.error.is_none(), "Ridge should fit without error");
1166 assert_eq!(result.coefficients.len(), m);
1167 assert_eq!(result.fitted_values.len(), n);
1168 assert_eq!(result.residuals.len(), n);
1169 }
1170
1171 #[cfg(feature = "linalg")]
1172 #[test]
1173 fn test_ridge_regression_fit_r_squared() {
1174 let n = 50;
1175 let m = 3;
1176
1177 let x = FdMatrix::from_column_major(
1178 (0..n * m).map(|i| i as f64 / (n * m) as f64).collect(),
1179 n,
1180 m,
1181 )
1182 .unwrap();
1183 let y: Vec<f64> = (0..n).map(|i| i as f64 / n as f64).collect();
1184
1185 let result = ridge_regression_fit(&x, &y, 0.01, true);
1186
1187 assert!(
1188 result.r_squared > 0.5,
1189 "R-squared should be high, got {}",
1190 result.r_squared
1191 );
1192 assert!(result.r_squared <= 1.0 + 1e-10, "R-squared should be <= 1");
1193 }
1194
1195 #[cfg(feature = "linalg")]
1196 #[test]
1197 fn test_ridge_regression_fit_regularization() {
1198 let n = 30;
1199 let m = 10;
1200
1201 let x = FdMatrix::from_column_major(
1202 (0..n * m)
1203 .map(|i| ((i * 17) % 100) as f64 / 100.0)
1204 .collect(),
1205 n,
1206 m,
1207 )
1208 .unwrap();
1209 let y: Vec<f64> = (0..n).map(|i| (i as f64).sin()).collect();
1210
1211 let low_lambda = ridge_regression_fit(&x, &y, 0.001, true);
1212 let high_lambda = ridge_regression_fit(&x, &y, 100.0, true);
1213
1214 let norm_low: f64 = low_lambda
1215 .coefficients
1216 .iter()
1217 .map(|c| c.powi(2))
1218 .sum::<f64>()
1219 .sqrt();
1220 let norm_high: f64 = high_lambda
1221 .coefficients
1222 .iter()
1223 .map(|c| c.powi(2))
1224 .sum::<f64>()
1225 .sqrt();
1226
1227 assert!(
1228 norm_high <= norm_low + 1e-6,
1229 "Higher lambda should shrink coefficients: {} vs {}",
1230 norm_high,
1231 norm_low
1232 );
1233 }
1234
1235 #[cfg(feature = "linalg")]
1236 #[test]
1237 fn test_ridge_regression_fit_residuals() {
1238 let n = 20;
1239 let m = 3;
1240
1241 let x = FdMatrix::from_column_major(
1242 (0..n * m).map(|i| i as f64 / (n * m) as f64).collect(),
1243 n,
1244 m,
1245 )
1246 .unwrap();
1247 let y: Vec<f64> = (0..n).map(|i| i as f64 / n as f64).collect();
1248
1249 let result = ridge_regression_fit(&x, &y, 0.1, true);
1250
1251 for i in 0..n {
1252 let expected_resid = y[i] - result.fitted_values[i];
1253 assert!(
1254 (result.residuals[i] - expected_resid).abs() < 1e-10,
1255 "Residual mismatch at {}",
1256 i
1257 );
1258 }
1259 }
1260
1261 #[cfg(feature = "linalg")]
1262 #[test]
1263 fn test_ridge_regression_fit_no_intercept() {
1264 let n = 30;
1265 let m = 5;
1266
1267 let x = FdMatrix::from_column_major(
1268 (0..n * m).map(|i| i as f64 / (n * m) as f64).collect(),
1269 n,
1270 m,
1271 )
1272 .unwrap();
1273 let y: Vec<f64> = (0..n).map(|i| i as f64 / n as f64).collect();
1274
1275 let result = ridge_regression_fit(&x, &y, 0.1, false);
1276
1277 assert!(result.error.is_none());
1278 assert!(
1279 result.intercept.abs() < 1e-10,
1280 "Intercept should be 0, got {}",
1281 result.intercept
1282 );
1283 }
1284
1285 #[cfg(feature = "linalg")]
1286 #[test]
1287 fn test_ridge_regression_fit_invalid_input() {
1288 let empty = FdMatrix::zeros(0, 5);
1289 let result = ridge_regression_fit(&empty, &[], 0.1, true);
1290 assert!(result.error.is_some());
1291
1292 let x = FdMatrix::zeros(10, 10);
1293 let y = vec![0.0; 5];
1294 let result = ridge_regression_fit(&x, &y, 0.1, true);
1295 assert!(result.error.is_some());
1296 }
1297
1298 #[test]
1299 fn test_all_zero_fpca() {
1300 let n = 5;
1302 let m = 20;
1303 let data = FdMatrix::zeros(n, m);
1304 let t: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1305 let result = fdata_to_pc_1d(&data, 2, &t);
1306 if let Ok(res) = result {
1308 assert_eq!(res.scores.nrows(), n);
1309 for &sv in &res.singular_values {
1310 assert!(
1311 sv.abs() < 1e-10,
1312 "All-zero data should have zero singular values"
1313 );
1314 }
1315 }
1316 }
1317
1318 #[test]
1319 fn test_n1_pca() {
1320 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
1322 let t = vec![0.0, 0.5, 1.0];
1323 let result = fdata_to_pc_1d(&data, 1, &t);
1324 let _ = result;
1327 }
1328
1329 #[test]
1330 fn test_constant_y_pls() {
1331 let n = 10;
1332 let m = 20;
1333 let data_vec: Vec<f64> = (0..n * m).map(|i| (i as f64 * 0.1).sin()).collect();
1334 let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
1335 let t: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1336 let y = vec![5.0; n]; let result = fdata_to_pls_1d(&data, &y, 2, &t);
1338 let _ = result;
1341 }
1342
1343 #[test]
1346 fn test_fpca_project_shape() {
1347 let n = 20;
1348 let m = 30;
1349 let ncomp = 3;
1350 let (data, t) = generate_test_fdata(n, m);
1351 let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1352
1353 let new_data = FdMatrix::zeros(5, m);
1354 let scores = fpca.project(&new_data).unwrap();
1355 assert_eq!(scores.shape(), (5, ncomp));
1356 }
1357
1358 #[test]
1359 fn test_fpca_project_reproduces_training_scores() {
1360 let n = 20;
1361 let m = 30;
1362 let ncomp = 3;
1363 let (data, t) = generate_test_fdata(n, m);
1364 let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1365
1366 let scores = fpca.project(&data).unwrap();
1368 for i in 0..n {
1369 for k in 0..ncomp {
1370 assert!(
1371 (scores[(i, k)] - fpca.scores[(i, k)]).abs() < 1e-8,
1372 "Score mismatch at ({}, {}): {} vs {}",
1373 i,
1374 k,
1375 scores[(i, k)],
1376 fpca.scores[(i, k)]
1377 );
1378 }
1379 }
1380 }
1381
1382 #[test]
1383 fn test_fpca_project_dimension_mismatch() {
1384 let (data, t) = generate_test_fdata(20, 30);
1385 let fpca = fdata_to_pc_1d(&data, 3, &t).unwrap();
1386
1387 let wrong_m = FdMatrix::zeros(5, 20); assert!(fpca.project(&wrong_m).is_err());
1389 }
1390
1391 #[test]
1394 fn test_fpca_reconstruct_shape() {
1395 let n = 10;
1396 let m = 30;
1397 let ncomp = 5;
1398 let (data, t) = generate_test_fdata(n, m);
1399 let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1400
1401 let recon = fpca.reconstruct(&fpca.scores, 3).unwrap();
1402 assert_eq!(recon.shape(), (n, m));
1403 }
1404
1405 #[test]
1406 fn test_fpca_reconstruct_full_matches_original() {
1407 let n = 10;
1408 let m = 30;
1409 let ncomp = n.min(m);
1410 let (data, t) = generate_test_fdata(n, m);
1411 let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1412
1413 let recon = fpca.reconstruct(&fpca.scores, ncomp).unwrap();
1415 for i in 0..n {
1416 for j in 0..m {
1417 assert!(
1418 (recon[(i, j)] - data[(i, j)]).abs() < 0.1,
1419 "Reconstruction error at ({}, {}): {} vs {}",
1420 i,
1421 j,
1422 recon[(i, j)],
1423 data[(i, j)]
1424 );
1425 }
1426 }
1427 }
1428
1429 #[test]
1430 fn test_fpca_reconstruct_fewer_components() {
1431 let n = 20;
1432 let m = 30;
1433 let ncomp = 5;
1434 let (data, t) = generate_test_fdata(n, m);
1435 let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1436
1437 let recon2 = fpca.reconstruct(&fpca.scores, 2).unwrap();
1438 let recon5 = fpca.reconstruct(&fpca.scores, 5).unwrap();
1439 assert_eq!(recon2.shape(), (n, m));
1440 assert_eq!(recon5.shape(), (n, m));
1441 }
1442
1443 #[test]
1444 fn test_fpca_reconstruct_invalid_ncomp() {
1445 let (data, t) = generate_test_fdata(10, 30);
1446 let fpca = fdata_to_pc_1d(&data, 3, &t).unwrap();
1447
1448 assert!(fpca.reconstruct(&fpca.scores, 0).is_err());
1450 assert!(fpca.reconstruct(&fpca.scores, 10).is_err());
1452 }
1453
1454 #[test]
1457 fn test_pls_project_shape() {
1458 let n = 20;
1459 let m = 30;
1460 let ncomp = 3;
1461 let (x, t) = generate_test_fdata(n, m);
1462 let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
1463 let pls = fdata_to_pls_1d(&x, &y, ncomp, &t).unwrap();
1464
1465 let new_x = FdMatrix::zeros(5, m);
1466 let scores = pls.project(&new_x).unwrap();
1467 assert_eq!(scores.shape(), (5, ncomp));
1468 }
1469
1470 #[test]
1471 fn test_pls_project_reproduces_training_scores() {
1472 let n = 20;
1473 let m = 30;
1474 let ncomp = 3;
1475 let (x, t) = generate_test_fdata(n, m);
1476 let y: Vec<f64> = (0..n).map(|i| (i as f64 / n as f64) + 0.1).collect();
1477 let pls = fdata_to_pls_1d(&x, &y, ncomp, &t).unwrap();
1478
1479 let scores = pls.project(&x).unwrap();
1481 for i in 0..n {
1482 for k in 0..ncomp {
1483 assert!(
1484 (scores[(i, k)] - pls.scores[(i, k)]).abs() < 1e-8,
1485 "Score mismatch at ({}, {}): {} vs {}",
1486 i,
1487 k,
1488 scores[(i, k)],
1489 pls.scores[(i, k)]
1490 );
1491 }
1492 }
1493 }
1494
1495 #[test]
1496 fn test_pls_project_dimension_mismatch() {
1497 let (x, t) = generate_test_fdata(20, 30);
1498 let y: Vec<f64> = (0..20).map(|i| i as f64).collect();
1499 let pls = fdata_to_pls_1d(&x, &y, 3, &t).unwrap();
1500
1501 let wrong_m = FdMatrix::zeros(5, 20); assert!(pls.project(&wrong_m).is_err());
1503 }
1504
1505 #[test]
1506 fn test_pls_x_means_stored() {
1507 let n = 20;
1508 let m = 30;
1509 let (x, t) = generate_test_fdata(n, m);
1510 let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
1511 let pls = fdata_to_pls_1d(&x, &y, 3, &t).unwrap();
1512
1513 assert_eq!(pls.x_means.len(), m);
1515 }
1516
1517 #[test]
1521 fn fpca_project_recovers_original_scores() {
1522 let n = 15;
1523 let m = 40;
1524 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1525 let vals: Vec<f64> = (0..n)
1526 .flat_map(|i| {
1527 argvals
1528 .iter()
1529 .map(move |&t| (2.0 * PI * t).sin() + 0.3 * i as f64 * t)
1530 })
1531 .collect();
1532 let data = FdMatrix::from_column_major(vals, n, m).unwrap();
1533 let fpca = fdata_to_pc_1d(&data, 3, &argvals).unwrap();
1534
1535 let projected = fpca.project(&data).unwrap();
1537 for i in 0..n {
1538 for k in 0..3 {
1539 let diff = (fpca.scores[(i, k)] - projected[(i, k)]).abs();
1540 assert!(
1541 diff < 1e-8,
1542 "project score [{i},{k}] mismatch: orig={:.6}, proj={:.6}",
1543 fpca.scores[(i, k)],
1544 projected[(i, k)]
1545 );
1546 }
1547 }
1548 }
1549
1550 #[test]
1552 fn fpca_weights_are_stored() {
1553 let m = 50;
1554 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1555 let data =
1556 FdMatrix::from_column_major((0..150).map(|i| (i as f64 * 0.1).sin()).collect(), 3, m)
1557 .unwrap();
1558 let fpca = fdata_to_pc_1d(&data, 2, &argvals).unwrap();
1559
1560 assert_eq!(fpca.weights.len(), m);
1562 assert!(fpca.weights.iter().all(|&w| w > 0.0));
1563
1564 let sum: f64 = fpca.weights.iter().sum();
1566 assert!(
1567 (sum - 1.0).abs() < 0.01,
1568 "weight sum should ≈ 1.0, got {sum}"
1569 );
1570 }
1571
1572 #[test]
1574 fn fpca_variance_explained_grid_invariant() {
1575 use rand::rngs::StdRng;
1576 use rand::{Rng, SeedableRng};
1577
1578 let n = 20;
1579 let mut rng = StdRng::seed_from_u64(99);
1580 let coeffs: Vec<(f64, f64)> = (0..n)
1581 .map(|_| (rng.gen_range(-1.0..1.0), rng.gen_range(-1.0..1.0)))
1582 .collect();
1583
1584 let make_data = |m: usize| -> (FdMatrix, Vec<f64>) {
1585 let t: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1586 let mut vals = vec![0.0; n * m];
1587 for (i, &(a, b)) in coeffs.iter().enumerate() {
1588 for (j, &tj) in t.iter().enumerate() {
1589 vals[i + j * n] = a * (2.0 * PI * tj).sin() + b * (4.0 * PI * tj).cos();
1590 }
1591 }
1592 (FdMatrix::from_column_major(vals, n, m).unwrap(), t)
1593 };
1594
1595 let (d1, t1) = make_data(41);
1596 let (d2, t2) = make_data(201);
1597 let f1 = fdata_to_pc_1d(&d1, 2, &t1).unwrap();
1598 let f2 = fdata_to_pc_1d(&d2, 2, &t2).unwrap();
1599
1600 let total1: f64 = f1.singular_values.iter().map(|s| s * s).sum();
1601 let total2: f64 = f2.singular_values.iter().map(|s| s * s).sum();
1602 let pve1 = f1.singular_values[0].powi(2) / total1;
1603 let pve2 = f2.singular_values[0].powi(2) / total2;
1604
1605 assert!(
1606 (pve1 - pve2).abs() < 0.05,
1607 "variance explained differs: coarse={pve1:.4}, fine={pve2:.4}"
1608 );
1609 }
1610
1611 #[test]
1612 fn fpca_scores_invariant_to_grid_density() {
1613 use rand::rngs::StdRng;
1614 use rand::{Rng, SeedableRng};
1615
1616 let n = 20;
1617
1618 let mut rng = StdRng::seed_from_u64(42);
1620 let coeffs: Vec<(f64, f64)> = (0..n)
1621 .map(|_| (rng.gen_range(-1.0..1.0), rng.gen_range(-1.0..1.0)))
1622 .collect();
1623
1624 let make_data = |m: usize| -> (FdMatrix, Vec<f64>) {
1626 let t: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1627 let mut vals = vec![0.0; n * m];
1628 for (i, &(a, b)) in coeffs.iter().enumerate() {
1629 for (j, &tj) in t.iter().enumerate() {
1630 vals[i + j * n] = a * (2.0 * PI * tj).sin() + b * (4.0 * PI * tj).cos();
1631 }
1632 }
1633 let data = FdMatrix::from_column_major(vals, n, m).unwrap();
1634 (data, t)
1635 };
1636
1637 let (data1, t1) = make_data(51);
1638 let (data2, t2) = make_data(201);
1639
1640 let fpca1 = fdata_to_pc_1d(&data1, 2, &t1).unwrap();
1641 let fpca2 = fdata_to_pc_1d(&data2, 2, &t2).unwrap();
1642
1643 for k in 0..2 {
1645 let dot: f64 = (0..n)
1647 .map(|i| fpca1.scores[(i, k)] * fpca2.scores[(i, k)])
1648 .sum();
1649 let sign = if dot >= 0.0 { 1.0 } else { -1.0 };
1650
1651 for i in 0..n {
1652 let s1 = fpca1.scores[(i, k)];
1653 let s2 = sign * fpca2.scores[(i, k)];
1654 let rel_diff = if s1.abs() > 1e-6 {
1655 (s1 - s2).abs() / s1.abs()
1656 } else {
1657 (s1 - s2).abs()
1658 };
1659 assert!(
1660 rel_diff < 0.10,
1661 "score [{i},{k}] differs: coarse={s1:.4}, fine={s2:.4}, rel_diff={rel_diff:.4}"
1662 );
1663 }
1664 }
1665 }
1666}