1use 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#[cfg(any(not(feature = "linalg"), test))]
44use nalgebra::SVD;
45
46#[derive(Debug, Clone, PartialEq)]
48#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
49#[non_exhaustive]
50pub struct FpcaResult {
51 pub singular_values: Vec<f64>,
53 pub rotation: FdMatrix,
55 pub scores: FdMatrix,
57 pub mean: Vec<f64>,
59 pub centered: FdMatrix,
61 pub weights: Vec<f64>,
63}
64
65impl FpcaResult {
66 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 #[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 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#[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
253pub(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
278fn 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
303fn 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#[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#[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 let weights = simpsons_weights(argvals);
426 let sqrt_weights: Vec<f64> = weights.iter().map(|w| w.sqrt()).collect();
427
428 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 #[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 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 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 fix_svd_signs(&mut rotation, &mut scores, ncomp);
482
483 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#[derive(Debug, Clone, PartialEq)]
504#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
505#[non_exhaustive]
506pub struct PlsResult {
507 pub weights: FdMatrix,
509 pub scores: FdMatrix,
511 pub loadings: FdMatrix,
513 pub x_means: Vec<f64>,
515 pub integration_weights: Vec<f64>,
517}
518
519impl PlsResult {
520 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 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 let mut scores = FdMatrix::zeros(n, ncomp);
582 for k in 0..ncomp {
583 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 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
605fn 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
627fn 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
641fn 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
655fn 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
665fn 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#[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 let int_w = simpsons_weights(argvals);
760
761 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 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#[derive(Debug, Clone, PartialEq)]
809#[non_exhaustive]
810#[cfg(feature = "linalg")]
811pub struct RidgeResult {
812 pub coefficients: Vec<f64>,
814 pub intercept: f64,
816 pub fitted_values: Vec<f64>,
818 pub residuals: Vec<f64>,
820 pub r_squared: f64,
822 pub lambda: f64,
824 pub error: Option<String>,
826}
827
828#[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 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 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 let coefs = fitted.coefficients();
883 let coefficients: Vec<f64> = (0..coefs.nrows()).map(|i| coefs[i]).collect();
884
885 let intercept = fitted.intercept().unwrap_or(0.0);
887
888 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 let residuals: Vec<f64> = y
900 .iter()
901 .zip(fitted_values.iter())
902 .map(|(&yi, &yhat)| yi - yhat)
903 .collect();
904
905 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 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 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 #[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 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 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 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 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 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 let ncomp = n.min(m);
1036 let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1037
1038 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 #[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 let faer = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1074
1075 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 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; }
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 #[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 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 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 let result = fdata_to_pls_1d(&x, &[0.0; 5], 2, &t);
1184 assert!(result.is_err());
1185
1186 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 #[cfg(feature = "linalg")]
1195 #[test]
1196 fn test_ridge_regression_fit_basic() {
1197 let n = 50;
1198 let m = 5;
1199
1200 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 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 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 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 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 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]; let result = fdata_to_pls_1d(&data, &y, 2, &t);
1394 let _ = result;
1397 }
1398
1399 #[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 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); assert!(fpca.project(&wrong_m).is_err());
1445 }
1446
1447 #[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 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 assert!(fpca.reconstruct(&fpca.scores, 0).is_err());
1506 assert!(fpca.reconstruct(&fpca.scores, 10).is_err());
1508 }
1509
1510 #[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 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); 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 assert_eq!(pls.x_means.len(), m);
1571 }
1572
1573 #[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 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 #[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 assert_eq!(fpca.weights.len(), m);
1618 assert!(fpca.weights.iter().all(|&w| w > 0.0));
1619
1620 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 #[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 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 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 for k in 0..2 {
1701 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 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 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 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 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 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}