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};
12use nalgebra::SVD;
13
14#[derive(Debug, Clone, PartialEq)]
16#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
17#[non_exhaustive]
18pub struct FpcaResult {
19 pub singular_values: Vec<f64>,
21 pub rotation: FdMatrix,
23 pub scores: FdMatrix,
25 pub mean: Vec<f64>,
27 pub centered: FdMatrix,
29 pub weights: Vec<f64>,
31}
32
33impl FpcaResult {
34 pub fn project(&self, data: &FdMatrix) -> Result<FdMatrix, FdarError> {
75 let (n, m) = data.shape();
76 let ncomp = self.rotation.ncols();
77 if m != self.mean.len() {
78 return Err(FdarError::InvalidDimension {
79 parameter: "data",
80 expected: format!("{} columns", self.mean.len()),
81 actual: format!("{m} columns"),
82 });
83 }
84
85 let mut scores = FdMatrix::zeros(n, ncomp);
86 for i in 0..n {
87 for k in 0..ncomp {
88 let mut sum = 0.0;
89 for j in 0..m {
90 sum += (data[(i, j)] - self.mean[j]) * self.rotation[(j, k)] * self.weights[j];
91 }
92 scores[(i, k)] = sum;
93 }
94 }
95 Ok(scores)
96 }
97
98 pub fn reconstruct(&self, scores: &FdMatrix, ncomp: usize) -> Result<FdMatrix, FdarError> {
136 let (n, p) = scores.shape();
137 let m = self.mean.len();
138 let max_comp = self.rotation.ncols().min(p);
139 if ncomp == 0 {
140 return Err(FdarError::InvalidParameter {
141 parameter: "ncomp",
142 message: "ncomp must be >= 1".to_string(),
143 });
144 }
145 if ncomp > max_comp {
146 return Err(FdarError::InvalidParameter {
147 parameter: "ncomp",
148 message: format!("ncomp={ncomp} exceeds available components ({max_comp})"),
149 });
150 }
151
152 let mut recon = FdMatrix::zeros(n, m);
153 for i in 0..n {
154 for j in 0..m {
155 let mut val = self.mean[j];
156 for k in 0..ncomp {
157 val += scores[(i, k)] * self.rotation[(j, k)];
158 }
159 recon[(i, j)] = val;
160 }
161 }
162 Ok(recon)
163 }
164}
165
166fn center_columns(data: &FdMatrix) -> (FdMatrix, Vec<f64>) {
168 let (n, m) = data.shape();
169 let mut centered = FdMatrix::zeros(n, m);
170 let mut means = vec![0.0; m];
171 for j in 0..m {
172 let col = data.column(j);
173 let mean = col.iter().sum::<f64>() / n as f64;
174 means[j] = mean;
175 let out_col = centered.column_mut(j);
176 for i in 0..n {
177 out_col[i] = col[i] - mean;
178 }
179 }
180 (centered, means)
181}
182
183fn extract_pc_components(
185 svd: &SVD<f64, nalgebra::Dyn, nalgebra::Dyn>,
186 n: usize,
187 m: usize,
188 ncomp: usize,
189) -> Option<(Vec<f64>, FdMatrix, FdMatrix)> {
190 let singular_values: Vec<f64> = svd.singular_values.iter().take(ncomp).copied().collect();
191
192 let v_t = svd.v_t.as_ref()?;
193 let mut rotation = FdMatrix::zeros(m, ncomp);
194 for k in 0..ncomp {
195 for j in 0..m {
196 rotation[(j, k)] = v_t[(k, j)];
197 }
198 }
199
200 let u = svd.u.as_ref()?;
201 let mut scores = FdMatrix::zeros(n, ncomp);
202 for k in 0..ncomp {
203 let sv_k = singular_values[k];
204 for i in 0..n {
205 scores[(i, k)] = u[(i, k)] * sv_k;
206 }
207 }
208
209 Some((singular_values, rotation, scores))
210}
211
212#[must_use = "expensive computation whose result should not be discarded"]
249pub fn fdata_to_pc_1d(
250 data: &FdMatrix,
251 ncomp: usize,
252 argvals: &[f64],
253) -> Result<FpcaResult, FdarError> {
254 let (n, m) = data.shape();
255 if n == 0 {
256 return Err(FdarError::InvalidDimension {
257 parameter: "data",
258 expected: "n > 0 rows".to_string(),
259 actual: format!("n = {n}"),
260 });
261 }
262 if m == 0 {
263 return Err(FdarError::InvalidDimension {
264 parameter: "data",
265 expected: "m > 0 columns".to_string(),
266 actual: format!("m = {m}"),
267 });
268 }
269 if argvals.len() != m {
270 return Err(FdarError::InvalidDimension {
271 parameter: "argvals",
272 expected: format!("{m} elements"),
273 actual: format!("{} elements", argvals.len()),
274 });
275 }
276 if ncomp < 1 {
277 return Err(FdarError::InvalidParameter {
278 parameter: "ncomp",
279 message: format!("ncomp must be >= 1, got {ncomp}"),
280 });
281 }
282
283 let ncomp = ncomp.min(n).min(m);
284 let (centered, means) = center_columns(data);
285
286 let weights = simpsons_weights(argvals);
288 let sqrt_weights: Vec<f64> = weights.iter().map(|w| w.sqrt()).collect();
289
290 let mut weighted = centered.clone();
292 for i in 0..n {
293 for j in 0..m {
294 weighted[(i, j)] *= sqrt_weights[j];
295 }
296 }
297
298 let svd = SVD::new(weighted.to_dmatrix(), true, true);
299 let (singular_values, mut rotation, scores) =
300 extract_pc_components(&svd, n, m, ncomp).ok_or_else(|| FdarError::ComputationFailed {
301 operation: "SVD",
302 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(),
303 })?;
304
305 for k in 0..ncomp {
307 for j in 0..m {
308 if sqrt_weights[j] > 1e-15 {
309 rotation[(j, k)] /= sqrt_weights[j];
310 }
311 }
312 }
313
314 Ok(FpcaResult {
315 singular_values,
316 rotation,
317 scores,
318 mean: means,
319 centered,
320 weights,
321 })
322}
323
324#[derive(Debug, Clone, PartialEq)]
326#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
327#[non_exhaustive]
328pub struct PlsResult {
329 pub weights: FdMatrix,
331 pub scores: FdMatrix,
333 pub loadings: FdMatrix,
335 pub x_means: Vec<f64>,
337 pub integration_weights: Vec<f64>,
339}
340
341impl PlsResult {
342 pub fn project(&self, data: &FdMatrix) -> Result<FdMatrix, FdarError> {
384 let (n, m) = data.shape();
385 let ncomp = self.weights.ncols();
386 if m != self.x_means.len() {
387 return Err(FdarError::InvalidDimension {
388 parameter: "data",
389 expected: format!("{} columns", self.x_means.len()),
390 actual: format!("{m} columns"),
391 });
392 }
393
394 let mut x_cen = FdMatrix::zeros(n, m);
396 for j in 0..m {
397 for i in 0..n {
398 x_cen[(i, j)] = data[(i, j)] - self.x_means[j];
399 }
400 }
401
402 let mut scores = FdMatrix::zeros(n, ncomp);
404 for k in 0..ncomp {
405 for i in 0..n {
407 let mut sum = 0.0;
408 for j in 0..m {
409 sum += x_cen[(i, j)] * self.weights[(j, k)] * self.integration_weights[j];
410 }
411 scores[(i, k)] = sum;
412 }
413
414 for j in 0..m {
416 let p_jk = self.loadings[(j, k)];
417 for i in 0..n {
418 x_cen[(i, j)] -= scores[(i, k)] * p_jk;
419 }
420 }
421 }
422
423 Ok(scores)
424 }
425}
426
427fn pls_compute_weights(x_cen: &FdMatrix, y_cen: &[f64], int_w: &[f64]) -> Vec<f64> {
429 let (n, m) = x_cen.shape();
430 let mut w: Vec<f64> = (0..m)
431 .map(|j| {
432 let mut sum = 0.0;
433 for i in 0..n {
434 sum += x_cen[(i, j)] * y_cen[i];
435 }
436 sum * int_w[j]
437 })
438 .collect();
439
440 let w_norm: f64 = w.iter().map(|&wi| wi * wi).sum::<f64>().sqrt();
441 if w_norm > 1e-10 {
442 for wi in &mut w {
443 *wi /= w_norm;
444 }
445 }
446 w
447}
448
449fn pls_compute_scores(x_cen: &FdMatrix, w: &[f64], int_w: &[f64]) -> Vec<f64> {
451 let (n, m) = x_cen.shape();
452 (0..n)
453 .map(|i| {
454 let mut sum = 0.0;
455 for j in 0..m {
456 sum += x_cen[(i, j)] * w[j] * int_w[j];
457 }
458 sum
459 })
460 .collect()
461}
462
463fn pls_compute_loadings(x_cen: &FdMatrix, t: &[f64], t_norm_sq: f64, int_w: &[f64]) -> Vec<f64> {
465 let (n, m) = x_cen.shape();
466 (0..m)
467 .map(|j| {
468 let mut sum = 0.0;
469 for i in 0..n {
470 sum += x_cen[(i, j)] * t[i];
471 }
472 sum * int_w[j] / t_norm_sq.max(1e-10)
473 })
474 .collect()
475}
476
477fn pls_deflate_x(x_cen: &mut FdMatrix, t: &[f64], p: &[f64]) {
479 let (n, m) = x_cen.shape();
480 for j in 0..m {
481 for i in 0..n {
482 x_cen[(i, j)] -= t[i] * p[j];
483 }
484 }
485}
486
487fn pls_nipals_step(
489 k: usize,
490 x_cen: &mut FdMatrix,
491 y_cen: &mut [f64],
492 weights: &mut FdMatrix,
493 scores: &mut FdMatrix,
494 loadings: &mut FdMatrix,
495 int_w: &[f64],
496) {
497 let n = x_cen.nrows();
498 let m = x_cen.ncols();
499
500 let w = pls_compute_weights(x_cen, y_cen, int_w);
501 let t = pls_compute_scores(x_cen, &w, int_w);
502 let t_norm_sq: f64 = t.iter().map(|&ti| ti * ti).sum();
503 let p = pls_compute_loadings(x_cen, &t, t_norm_sq, int_w);
504
505 for j in 0..m {
506 weights[(j, k)] = w[j];
507 loadings[(j, k)] = p[j];
508 }
509 for i in 0..n {
510 scores[(i, k)] = t[i];
511 }
512
513 pls_deflate_x(x_cen, &t, &p);
514 let t_y: f64 = t.iter().zip(y_cen.iter()).map(|(&ti, &yi)| ti * yi).sum();
515 let q = t_y / t_norm_sq.max(1e-10);
516 for i in 0..n {
517 y_cen[i] -= t[i] * q;
518 }
519}
520
521#[must_use = "expensive computation whose result should not be discarded"]
536pub fn fdata_to_pls_1d(
537 data: &FdMatrix,
538 y: &[f64],
539 ncomp: usize,
540 argvals: &[f64],
541) -> Result<PlsResult, FdarError> {
542 let (n, m) = data.shape();
543 if n == 0 {
544 return Err(FdarError::InvalidDimension {
545 parameter: "data",
546 expected: "n > 0 rows".to_string(),
547 actual: format!("n = {n}"),
548 });
549 }
550 if m == 0 {
551 return Err(FdarError::InvalidDimension {
552 parameter: "data",
553 expected: "m > 0 columns".to_string(),
554 actual: format!("m = {m}"),
555 });
556 }
557 if y.len() != n {
558 return Err(FdarError::InvalidDimension {
559 parameter: "y",
560 expected: format!("length {n}"),
561 actual: format!("length {}", y.len()),
562 });
563 }
564 if argvals.len() != m {
565 return Err(FdarError::InvalidDimension {
566 parameter: "argvals",
567 expected: format!("{m} elements"),
568 actual: format!("{} elements", argvals.len()),
569 });
570 }
571 if ncomp < 1 {
572 return Err(FdarError::InvalidParameter {
573 parameter: "ncomp",
574 message: format!("ncomp must be >= 1, got {ncomp}"),
575 });
576 }
577
578 let ncomp = ncomp.min(n).min(m);
579
580 let int_w = simpsons_weights(argvals);
582
583 let x_means: Vec<f64> = (0..m)
585 .map(|j| {
586 let col = data.column(j);
587 let sum: f64 = col.iter().sum();
588 sum / n as f64
589 })
590 .collect();
591
592 let y_mean: f64 = y.iter().sum::<f64>() / n as f64;
593
594 let mut x_cen = FdMatrix::zeros(n, m);
595 for j in 0..m {
596 for i in 0..n {
597 x_cen[(i, j)] = data[(i, j)] - x_means[j];
598 }
599 }
600
601 let mut y_cen: Vec<f64> = y.iter().map(|&yi| yi - y_mean).collect();
602
603 let mut weights = FdMatrix::zeros(m, ncomp);
604 let mut scores = FdMatrix::zeros(n, ncomp);
605 let mut loadings = FdMatrix::zeros(m, ncomp);
606
607 for k in 0..ncomp {
609 pls_nipals_step(
610 k,
611 &mut x_cen,
612 &mut y_cen,
613 &mut weights,
614 &mut scores,
615 &mut loadings,
616 &int_w,
617 );
618 }
619
620 Ok(PlsResult {
621 weights,
622 scores,
623 loadings,
624 x_means,
625 integration_weights: int_w,
626 })
627}
628
629#[derive(Debug, Clone, PartialEq)]
631#[non_exhaustive]
632#[cfg(feature = "linalg")]
633pub struct RidgeResult {
634 pub coefficients: Vec<f64>,
636 pub intercept: f64,
638 pub fitted_values: Vec<f64>,
640 pub residuals: Vec<f64>,
642 pub r_squared: f64,
644 pub lambda: f64,
646 pub error: Option<String>,
648}
649
650#[cfg(feature = "linalg")]
658#[must_use = "expensive computation whose result should not be discarded"]
659pub fn ridge_regression_fit(
660 x: &FdMatrix,
661 y: &[f64],
662 lambda: f64,
663 with_intercept: bool,
664) -> RidgeResult {
665 let (n, m) = x.shape();
666 if n == 0 || m == 0 || y.len() != n {
667 return RidgeResult {
668 coefficients: Vec::new(),
669 intercept: 0.0,
670 fitted_values: Vec::new(),
671 residuals: Vec::new(),
672 r_squared: 0.0,
673 lambda,
674 error: Some("Invalid input dimensions".to_string()),
675 };
676 }
677
678 let x_faer = faer::Mat::from_fn(n, m, |i, j| x[(i, j)]);
680 let y_faer = faer::Col::from_fn(n, |i| y[i]);
681
682 let regressor = RidgeRegressor::builder()
684 .with_intercept(with_intercept)
685 .lambda(lambda)
686 .build();
687
688 let fitted = match regressor.fit(&x_faer, &y_faer) {
689 Ok(f) => f,
690 Err(e) => {
691 return RidgeResult {
692 coefficients: Vec::new(),
693 intercept: 0.0,
694 fitted_values: Vec::new(),
695 residuals: Vec::new(),
696 r_squared: 0.0,
697 lambda,
698 error: Some(format!("Fit failed: {e:?}")),
699 }
700 }
701 };
702
703 let coefs = fitted.coefficients();
705 let coefficients: Vec<f64> = (0..coefs.nrows()).map(|i| coefs[i]).collect();
706
707 let intercept = fitted.intercept().unwrap_or(0.0);
709
710 let mut fitted_values = vec![0.0; n];
712 for i in 0..n {
713 let mut pred = intercept;
714 for j in 0..m {
715 pred += x[(i, j)] * coefficients[j];
716 }
717 fitted_values[i] = pred;
718 }
719
720 let residuals: Vec<f64> = y
722 .iter()
723 .zip(fitted_values.iter())
724 .map(|(&yi, &yhat)| yi - yhat)
725 .collect();
726
727 let y_mean: f64 = y.iter().sum::<f64>() / n as f64;
729 let ss_tot: f64 = y.iter().map(|&yi| (yi - y_mean).powi(2)).sum();
730 let ss_res: f64 = residuals.iter().map(|&r| r.powi(2)).sum();
731 let r_squared = if ss_tot > 0.0 {
732 1.0 - ss_res / ss_tot
733 } else {
734 0.0
735 };
736
737 RidgeResult {
738 coefficients,
739 intercept,
740 fitted_values,
741 residuals,
742 r_squared,
743 lambda,
744 error: None,
745 }
746}
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751 use std::f64::consts::PI;
752
753 fn generate_test_fdata(n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
755 let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
756
757 let mut data = FdMatrix::zeros(n, m);
759 for i in 0..n {
760 let phase = (i as f64 / n as f64) * PI;
761 for j in 0..m {
762 data[(i, j)] = (2.0 * PI * t[j] + phase).sin();
763 }
764 }
765
766 (data, t)
767 }
768
769 #[test]
772 fn test_fdata_to_pc_1d_basic() {
773 let n = 20;
774 let m = 50;
775 let ncomp = 3;
776 let (data, t) = generate_test_fdata(n, m);
777
778 let result = fdata_to_pc_1d(&data, ncomp, &t);
779 assert!(result.is_ok());
780
781 let fpca = result.unwrap();
782 assert_eq!(fpca.singular_values.len(), ncomp);
783 assert_eq!(fpca.rotation.shape(), (m, ncomp));
784 assert_eq!(fpca.scores.shape(), (n, ncomp));
785 assert_eq!(fpca.mean.len(), m);
786 assert_eq!(fpca.centered.shape(), (n, m));
787 }
788
789 #[test]
790 fn test_fdata_to_pc_1d_singular_values_decreasing() {
791 let n = 20;
792 let m = 50;
793 let ncomp = 5;
794 let (data, t) = generate_test_fdata(n, m);
795
796 let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
797
798 for i in 1..fpca.singular_values.len() {
800 assert!(
801 fpca.singular_values[i] <= fpca.singular_values[i - 1] + 1e-10,
802 "Singular values should be decreasing"
803 );
804 }
805 }
806
807 #[test]
808 fn test_fdata_to_pc_1d_centered_has_zero_mean() {
809 let n = 20;
810 let m = 50;
811 let (data, t) = generate_test_fdata(n, m);
812
813 let fpca = fdata_to_pc_1d(&data, 3, &t).unwrap();
814
815 for j in 0..m {
817 let col_mean: f64 = (0..n).map(|i| fpca.centered[(i, j)]).sum::<f64>() / n as f64;
818 assert!(
819 col_mean.abs() < 1e-10,
820 "Centered data should have zero column mean"
821 );
822 }
823 }
824
825 #[test]
826 fn test_fdata_to_pc_1d_ncomp_limits() {
827 let n = 10;
828 let m = 50;
829 let (data, t) = generate_test_fdata(n, m);
830
831 let fpca = fdata_to_pc_1d(&data, 20, &t).unwrap();
833 assert!(fpca.singular_values.len() <= n);
834 }
835
836 #[test]
837 fn test_fdata_to_pc_1d_invalid_input() {
838 let empty = FdMatrix::zeros(0, 50);
840 let t50: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
841 let result = fdata_to_pc_1d(&empty, 3, &t50);
842 assert!(result.is_err());
843
844 let (data, t) = generate_test_fdata(10, 50);
846 let result = fdata_to_pc_1d(&data, 0, &t);
847 assert!(result.is_err());
848 }
849
850 #[test]
851 fn test_fdata_to_pc_1d_reconstruction() {
852 let n = 10;
853 let m = 30;
854 let (data, t) = generate_test_fdata(n, m);
855
856 let ncomp = n.min(m);
858 let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
859
860 for i in 0..n {
862 for j in 0..m {
863 let mut reconstructed = 0.0;
864 for k in 0..ncomp {
865 let score = fpca.scores[(i, k)];
866 let loading = fpca.rotation[(j, k)];
867 reconstructed += score * loading;
868 }
869 let original_centered = fpca.centered[(i, j)];
870 assert!(
871 (reconstructed - original_centered).abs() < 0.1,
872 "Reconstruction error at ({}, {}): {} vs {}",
873 i,
874 j,
875 reconstructed,
876 original_centered
877 );
878 }
879 }
880 }
881
882 #[test]
885 fn test_fdata_to_pls_1d_basic() {
886 let n = 20;
887 let m = 30;
888 let ncomp = 3;
889 let (x, t) = generate_test_fdata(n, m);
890
891 let y: Vec<f64> = (0..n).map(|i| (i as f64 / n as f64) + 0.1).collect();
893
894 let result = fdata_to_pls_1d(&x, &y, ncomp, &t);
895 assert!(result.is_ok());
896
897 let pls = result.unwrap();
898 assert_eq!(pls.weights.shape(), (m, ncomp));
899 assert_eq!(pls.scores.shape(), (n, ncomp));
900 assert_eq!(pls.loadings.shape(), (m, ncomp));
901 }
902
903 #[test]
904 fn test_fdata_to_pls_1d_weights_normalized() {
905 let n = 20;
906 let m = 30;
907 let ncomp = 2;
908 let (x, t) = generate_test_fdata(n, m);
909 let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
910
911 let pls = fdata_to_pls_1d(&x, &y, ncomp, &t).unwrap();
912
913 for k in 0..ncomp {
915 let norm: f64 = (0..m)
916 .map(|j| pls.weights[(j, k)].powi(2))
917 .sum::<f64>()
918 .sqrt();
919 assert!(
920 (norm - 1.0).abs() < 0.1,
921 "Weight vector {} should be unit norm, got {}",
922 k,
923 norm
924 );
925 }
926 }
927
928 #[test]
929 fn test_fdata_to_pls_1d_invalid_input() {
930 let (x, t) = generate_test_fdata(10, 30);
931
932 let result = fdata_to_pls_1d(&x, &[0.0; 5], 2, &t);
934 assert!(result.is_err());
935
936 let y = vec![0.0; 10];
938 let result = fdata_to_pls_1d(&x, &y, 0, &t);
939 assert!(result.is_err());
940 }
941
942 #[cfg(feature = "linalg")]
945 #[test]
946 fn test_ridge_regression_fit_basic() {
947 let n = 50;
948 let m = 5;
949
950 let mut x = FdMatrix::zeros(n, m);
952 for i in 0..n {
953 for j in 0..m {
954 x[(i, j)] = (i as f64 + j as f64) / (n + m) as f64;
955 }
956 }
957
958 let y: Vec<f64> = (0..n)
960 .map(|i| {
961 let mut sum = 0.0;
962 for j in 0..m {
963 sum += x[(i, j)];
964 }
965 sum + 0.01 * (i as f64 % 10.0)
966 })
967 .collect();
968
969 let result = ridge_regression_fit(&x, &y, 0.1, true);
970
971 assert!(result.error.is_none(), "Ridge should fit without error");
972 assert_eq!(result.coefficients.len(), m);
973 assert_eq!(result.fitted_values.len(), n);
974 assert_eq!(result.residuals.len(), n);
975 }
976
977 #[cfg(feature = "linalg")]
978 #[test]
979 fn test_ridge_regression_fit_r_squared() {
980 let n = 50;
981 let m = 3;
982
983 let x = FdMatrix::from_column_major(
984 (0..n * m).map(|i| i as f64 / (n * m) as f64).collect(),
985 n,
986 m,
987 )
988 .unwrap();
989 let y: Vec<f64> = (0..n).map(|i| i as f64 / n as f64).collect();
990
991 let result = ridge_regression_fit(&x, &y, 0.01, true);
992
993 assert!(
994 result.r_squared > 0.5,
995 "R-squared should be high, got {}",
996 result.r_squared
997 );
998 assert!(result.r_squared <= 1.0 + 1e-10, "R-squared should be <= 1");
999 }
1000
1001 #[cfg(feature = "linalg")]
1002 #[test]
1003 fn test_ridge_regression_fit_regularization() {
1004 let n = 30;
1005 let m = 10;
1006
1007 let x = FdMatrix::from_column_major(
1008 (0..n * m)
1009 .map(|i| ((i * 17) % 100) as f64 / 100.0)
1010 .collect(),
1011 n,
1012 m,
1013 )
1014 .unwrap();
1015 let y: Vec<f64> = (0..n).map(|i| (i as f64).sin()).collect();
1016
1017 let low_lambda = ridge_regression_fit(&x, &y, 0.001, true);
1018 let high_lambda = ridge_regression_fit(&x, &y, 100.0, true);
1019
1020 let norm_low: f64 = low_lambda
1021 .coefficients
1022 .iter()
1023 .map(|c| c.powi(2))
1024 .sum::<f64>()
1025 .sqrt();
1026 let norm_high: f64 = high_lambda
1027 .coefficients
1028 .iter()
1029 .map(|c| c.powi(2))
1030 .sum::<f64>()
1031 .sqrt();
1032
1033 assert!(
1034 norm_high <= norm_low + 1e-6,
1035 "Higher lambda should shrink coefficients: {} vs {}",
1036 norm_high,
1037 norm_low
1038 );
1039 }
1040
1041 #[cfg(feature = "linalg")]
1042 #[test]
1043 fn test_ridge_regression_fit_residuals() {
1044 let n = 20;
1045 let m = 3;
1046
1047 let x = FdMatrix::from_column_major(
1048 (0..n * m).map(|i| i as f64 / (n * m) as f64).collect(),
1049 n,
1050 m,
1051 )
1052 .unwrap();
1053 let y: Vec<f64> = (0..n).map(|i| i as f64 / n as f64).collect();
1054
1055 let result = ridge_regression_fit(&x, &y, 0.1, true);
1056
1057 for i in 0..n {
1058 let expected_resid = y[i] - result.fitted_values[i];
1059 assert!(
1060 (result.residuals[i] - expected_resid).abs() < 1e-10,
1061 "Residual mismatch at {}",
1062 i
1063 );
1064 }
1065 }
1066
1067 #[cfg(feature = "linalg")]
1068 #[test]
1069 fn test_ridge_regression_fit_no_intercept() {
1070 let n = 30;
1071 let m = 5;
1072
1073 let x = FdMatrix::from_column_major(
1074 (0..n * m).map(|i| i as f64 / (n * m) as f64).collect(),
1075 n,
1076 m,
1077 )
1078 .unwrap();
1079 let y: Vec<f64> = (0..n).map(|i| i as f64 / n as f64).collect();
1080
1081 let result = ridge_regression_fit(&x, &y, 0.1, false);
1082
1083 assert!(result.error.is_none());
1084 assert!(
1085 result.intercept.abs() < 1e-10,
1086 "Intercept should be 0, got {}",
1087 result.intercept
1088 );
1089 }
1090
1091 #[cfg(feature = "linalg")]
1092 #[test]
1093 fn test_ridge_regression_fit_invalid_input() {
1094 let empty = FdMatrix::zeros(0, 5);
1095 let result = ridge_regression_fit(&empty, &[], 0.1, true);
1096 assert!(result.error.is_some());
1097
1098 let x = FdMatrix::zeros(10, 10);
1099 let y = vec![0.0; 5];
1100 let result = ridge_regression_fit(&x, &y, 0.1, true);
1101 assert!(result.error.is_some());
1102 }
1103
1104 #[test]
1105 fn test_all_zero_fpca() {
1106 let n = 5;
1108 let m = 20;
1109 let data = FdMatrix::zeros(n, m);
1110 let t: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1111 let result = fdata_to_pc_1d(&data, 2, &t);
1112 if let Ok(res) = result {
1114 assert_eq!(res.scores.nrows(), n);
1115 for &sv in &res.singular_values {
1116 assert!(
1117 sv.abs() < 1e-10,
1118 "All-zero data should have zero singular values"
1119 );
1120 }
1121 }
1122 }
1123
1124 #[test]
1125 fn test_n1_pca() {
1126 let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 1, 3).unwrap();
1128 let t = vec![0.0, 0.5, 1.0];
1129 let result = fdata_to_pc_1d(&data, 1, &t);
1130 let _ = result;
1133 }
1134
1135 #[test]
1136 fn test_constant_y_pls() {
1137 let n = 10;
1138 let m = 20;
1139 let data_vec: Vec<f64> = (0..n * m).map(|i| (i as f64 * 0.1).sin()).collect();
1140 let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
1141 let t: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1142 let y = vec![5.0; n]; let result = fdata_to_pls_1d(&data, &y, 2, &t);
1144 let _ = result;
1147 }
1148
1149 #[test]
1152 fn test_fpca_project_shape() {
1153 let n = 20;
1154 let m = 30;
1155 let ncomp = 3;
1156 let (data, t) = generate_test_fdata(n, m);
1157 let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1158
1159 let new_data = FdMatrix::zeros(5, m);
1160 let scores = fpca.project(&new_data).unwrap();
1161 assert_eq!(scores.shape(), (5, ncomp));
1162 }
1163
1164 #[test]
1165 fn test_fpca_project_reproduces_training_scores() {
1166 let n = 20;
1167 let m = 30;
1168 let ncomp = 3;
1169 let (data, t) = generate_test_fdata(n, m);
1170 let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1171
1172 let scores = fpca.project(&data).unwrap();
1174 for i in 0..n {
1175 for k in 0..ncomp {
1176 assert!(
1177 (scores[(i, k)] - fpca.scores[(i, k)]).abs() < 1e-8,
1178 "Score mismatch at ({}, {}): {} vs {}",
1179 i,
1180 k,
1181 scores[(i, k)],
1182 fpca.scores[(i, k)]
1183 );
1184 }
1185 }
1186 }
1187
1188 #[test]
1189 fn test_fpca_project_dimension_mismatch() {
1190 let (data, t) = generate_test_fdata(20, 30);
1191 let fpca = fdata_to_pc_1d(&data, 3, &t).unwrap();
1192
1193 let wrong_m = FdMatrix::zeros(5, 20); assert!(fpca.project(&wrong_m).is_err());
1195 }
1196
1197 #[test]
1200 fn test_fpca_reconstruct_shape() {
1201 let n = 10;
1202 let m = 30;
1203 let ncomp = 5;
1204 let (data, t) = generate_test_fdata(n, m);
1205 let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1206
1207 let recon = fpca.reconstruct(&fpca.scores, 3).unwrap();
1208 assert_eq!(recon.shape(), (n, m));
1209 }
1210
1211 #[test]
1212 fn test_fpca_reconstruct_full_matches_original() {
1213 let n = 10;
1214 let m = 30;
1215 let ncomp = n.min(m);
1216 let (data, t) = generate_test_fdata(n, m);
1217 let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1218
1219 let recon = fpca.reconstruct(&fpca.scores, ncomp).unwrap();
1221 for i in 0..n {
1222 for j in 0..m {
1223 assert!(
1224 (recon[(i, j)] - data[(i, j)]).abs() < 0.1,
1225 "Reconstruction error at ({}, {}): {} vs {}",
1226 i,
1227 j,
1228 recon[(i, j)],
1229 data[(i, j)]
1230 );
1231 }
1232 }
1233 }
1234
1235 #[test]
1236 fn test_fpca_reconstruct_fewer_components() {
1237 let n = 20;
1238 let m = 30;
1239 let ncomp = 5;
1240 let (data, t) = generate_test_fdata(n, m);
1241 let fpca = fdata_to_pc_1d(&data, ncomp, &t).unwrap();
1242
1243 let recon2 = fpca.reconstruct(&fpca.scores, 2).unwrap();
1244 let recon5 = fpca.reconstruct(&fpca.scores, 5).unwrap();
1245 assert_eq!(recon2.shape(), (n, m));
1246 assert_eq!(recon5.shape(), (n, m));
1247 }
1248
1249 #[test]
1250 fn test_fpca_reconstruct_invalid_ncomp() {
1251 let (data, t) = generate_test_fdata(10, 30);
1252 let fpca = fdata_to_pc_1d(&data, 3, &t).unwrap();
1253
1254 assert!(fpca.reconstruct(&fpca.scores, 0).is_err());
1256 assert!(fpca.reconstruct(&fpca.scores, 10).is_err());
1258 }
1259
1260 #[test]
1263 fn test_pls_project_shape() {
1264 let n = 20;
1265 let m = 30;
1266 let ncomp = 3;
1267 let (x, t) = generate_test_fdata(n, m);
1268 let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
1269 let pls = fdata_to_pls_1d(&x, &y, ncomp, &t).unwrap();
1270
1271 let new_x = FdMatrix::zeros(5, m);
1272 let scores = pls.project(&new_x).unwrap();
1273 assert_eq!(scores.shape(), (5, ncomp));
1274 }
1275
1276 #[test]
1277 fn test_pls_project_reproduces_training_scores() {
1278 let n = 20;
1279 let m = 30;
1280 let ncomp = 3;
1281 let (x, t) = generate_test_fdata(n, m);
1282 let y: Vec<f64> = (0..n).map(|i| (i as f64 / n as f64) + 0.1).collect();
1283 let pls = fdata_to_pls_1d(&x, &y, ncomp, &t).unwrap();
1284
1285 let scores = pls.project(&x).unwrap();
1287 for i in 0..n {
1288 for k in 0..ncomp {
1289 assert!(
1290 (scores[(i, k)] - pls.scores[(i, k)]).abs() < 1e-8,
1291 "Score mismatch at ({}, {}): {} vs {}",
1292 i,
1293 k,
1294 scores[(i, k)],
1295 pls.scores[(i, k)]
1296 );
1297 }
1298 }
1299 }
1300
1301 #[test]
1302 fn test_pls_project_dimension_mismatch() {
1303 let (x, t) = generate_test_fdata(20, 30);
1304 let y: Vec<f64> = (0..20).map(|i| i as f64).collect();
1305 let pls = fdata_to_pls_1d(&x, &y, 3, &t).unwrap();
1306
1307 let wrong_m = FdMatrix::zeros(5, 20); assert!(pls.project(&wrong_m).is_err());
1309 }
1310
1311 #[test]
1312 fn test_pls_x_means_stored() {
1313 let n = 20;
1314 let m = 30;
1315 let (x, t) = generate_test_fdata(n, m);
1316 let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
1317 let pls = fdata_to_pls_1d(&x, &y, 3, &t).unwrap();
1318
1319 assert_eq!(pls.x_means.len(), m);
1321 }
1322
1323 #[test]
1327 fn fpca_project_recovers_original_scores() {
1328 let n = 15;
1329 let m = 40;
1330 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1331 let vals: Vec<f64> = (0..n)
1332 .flat_map(|i| {
1333 argvals
1334 .iter()
1335 .map(move |&t| (2.0 * PI * t).sin() + 0.3 * i as f64 * t)
1336 })
1337 .collect();
1338 let data = FdMatrix::from_column_major(vals, n, m).unwrap();
1339 let fpca = fdata_to_pc_1d(&data, 3, &argvals).unwrap();
1340
1341 let projected = fpca.project(&data).unwrap();
1343 for i in 0..n {
1344 for k in 0..3 {
1345 let diff = (fpca.scores[(i, k)] - projected[(i, k)]).abs();
1346 assert!(
1347 diff < 1e-8,
1348 "project score [{i},{k}] mismatch: orig={:.6}, proj={:.6}",
1349 fpca.scores[(i, k)],
1350 projected[(i, k)]
1351 );
1352 }
1353 }
1354 }
1355
1356 #[test]
1358 fn fpca_weights_are_stored() {
1359 let m = 50;
1360 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1361 let data =
1362 FdMatrix::from_column_major((0..150).map(|i| (i as f64 * 0.1).sin()).collect(), 3, m)
1363 .unwrap();
1364 let fpca = fdata_to_pc_1d(&data, 2, &argvals).unwrap();
1365
1366 assert_eq!(fpca.weights.len(), m);
1368 assert!(fpca.weights.iter().all(|&w| w > 0.0));
1369
1370 let sum: f64 = fpca.weights.iter().sum();
1372 assert!(
1373 (sum - 1.0).abs() < 0.01,
1374 "weight sum should ≈ 1.0, got {sum}"
1375 );
1376 }
1377
1378 #[test]
1380 fn fpca_variance_explained_grid_invariant() {
1381 use rand::rngs::StdRng;
1382 use rand::{Rng, SeedableRng};
1383
1384 let n = 20;
1385 let mut rng = StdRng::seed_from_u64(99);
1386 let coeffs: Vec<(f64, f64)> = (0..n)
1387 .map(|_| (rng.gen_range(-1.0..1.0), rng.gen_range(-1.0..1.0)))
1388 .collect();
1389
1390 let make_data = |m: usize| -> (FdMatrix, Vec<f64>) {
1391 let t: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1392 let mut vals = vec![0.0; n * m];
1393 for (i, &(a, b)) in coeffs.iter().enumerate() {
1394 for (j, &tj) in t.iter().enumerate() {
1395 vals[i + j * n] = a * (2.0 * PI * tj).sin() + b * (4.0 * PI * tj).cos();
1396 }
1397 }
1398 (FdMatrix::from_column_major(vals, n, m).unwrap(), t)
1399 };
1400
1401 let (d1, t1) = make_data(41);
1402 let (d2, t2) = make_data(201);
1403 let f1 = fdata_to_pc_1d(&d1, 2, &t1).unwrap();
1404 let f2 = fdata_to_pc_1d(&d2, 2, &t2).unwrap();
1405
1406 let total1: f64 = f1.singular_values.iter().map(|s| s * s).sum();
1407 let total2: f64 = f2.singular_values.iter().map(|s| s * s).sum();
1408 let pve1 = f1.singular_values[0].powi(2) / total1;
1409 let pve2 = f2.singular_values[0].powi(2) / total2;
1410
1411 assert!(
1412 (pve1 - pve2).abs() < 0.05,
1413 "variance explained differs: coarse={pve1:.4}, fine={pve2:.4}"
1414 );
1415 }
1416
1417 #[test]
1418 fn fpca_scores_invariant_to_grid_density() {
1419 use rand::rngs::StdRng;
1420 use rand::{Rng, SeedableRng};
1421
1422 let n = 20;
1423
1424 let mut rng = StdRng::seed_from_u64(42);
1426 let coeffs: Vec<(f64, f64)> = (0..n)
1427 .map(|_| (rng.gen_range(-1.0..1.0), rng.gen_range(-1.0..1.0)))
1428 .collect();
1429
1430 let make_data = |m: usize| -> (FdMatrix, Vec<f64>) {
1432 let t: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1433 let mut vals = vec![0.0; n * m];
1434 for (i, &(a, b)) in coeffs.iter().enumerate() {
1435 for (j, &tj) in t.iter().enumerate() {
1436 vals[i + j * n] = a * (2.0 * PI * tj).sin() + b * (4.0 * PI * tj).cos();
1437 }
1438 }
1439 let data = FdMatrix::from_column_major(vals, n, m).unwrap();
1440 (data, t)
1441 };
1442
1443 let (data1, t1) = make_data(51);
1444 let (data2, t2) = make_data(201);
1445
1446 let fpca1 = fdata_to_pc_1d(&data1, 2, &t1).unwrap();
1447 let fpca2 = fdata_to_pc_1d(&data2, 2, &t2).unwrap();
1448
1449 for k in 0..2 {
1451 let dot: f64 = (0..n)
1453 .map(|i| fpca1.scores[(i, k)] * fpca2.scores[(i, k)])
1454 .sum();
1455 let sign = if dot >= 0.0 { 1.0 } else { -1.0 };
1456
1457 for i in 0..n {
1458 let s1 = fpca1.scores[(i, k)];
1459 let s2 = sign * fpca2.scores[(i, k)];
1460 let rel_diff = if s1.abs() > 1e-6 {
1461 (s1 - s2).abs() / s1.abs()
1462 } else {
1463 (s1 - s2).abs()
1464 };
1465 assert!(
1466 rel_diff < 0.10,
1467 "score [{i},{k}] differs: coarse={s1:.4}, fine={s2:.4}, rel_diff={rel_diff:.4}"
1468 );
1469 }
1470 }
1471 }
1472}