1use crate::error::FdarError;
21use crate::fdata;
22use crate::helpers::{gaussian_kernel, simpsons_weights};
23use crate::matrix::FdMatrix;
24use crate::regression::{fdata_to_pc, FpcaResult};
25use nalgebra::DMatrix;
26
27#[derive(Debug, Clone, PartialEq)]
39#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
40#[non_exhaustive]
41pub struct FsvdResult {
42 pub singular_values: Vec<f64>,
45 pub left_functions: FdMatrix,
48 pub right_functions: FdMatrix,
51 pub left_scores: FdMatrix,
53 pub right_scores: FdMatrix,
55}
56
57#[must_use = "cross_covariance returns the surface; ignoring it wastes the computation"]
90pub fn cross_covariance(x: &FdMatrix, y: &FdMatrix) -> Result<FdMatrix, FdarError> {
91 let (nx, p) = x.shape();
92 let (ny, q) = y.shape();
93
94 if nx != ny {
95 return Err(FdarError::InvalidDimension {
96 parameter: "y",
97 expected: format!("{nx} rows (matching x)"),
98 actual: format!("{ny} rows"),
99 });
100 }
101 if nx < 2 {
102 return Err(FdarError::InvalidDimension {
103 parameter: "x",
104 expected: ">= 2 rows".to_string(),
105 actual: nx.to_string(),
106 });
107 }
108 if p == 0 || q == 0 {
109 return Err(FdarError::InvalidDimension {
110 parameter: if p == 0 { "x" } else { "y" },
111 expected: ">= 1 column".to_string(),
112 actual: format!("p = {p}, q = {q}"),
113 });
114 }
115 p.checked_mul(q)
117 .ok_or_else(|| FdarError::InvalidParameter {
118 parameter: "x",
119 message: format!(
120 "p={p}, q={q} too large: p*q would overflow usize (max {})",
121 usize::MAX
122 ),
123 })?;
124
125 let xc = fdata::center_1d(x);
127 let yc = fdata::center_1d(y);
128
129 let denom = (nx - 1) as f64;
130 let mut cov = FdMatrix::zeros(p, q);
131 for s in 0..p {
132 let cxs = xc.column(s);
133 for t in 0..q {
134 let cyt = yc.column(t);
135 let val: f64 = cxs
136 .iter()
137 .zip(cyt.iter())
138 .map(|(&a, &b)| a * b)
139 .sum::<f64>()
140 / denom;
141 cov[(s, t)] = val;
142 }
143 }
144 Ok(cov)
145}
146
147#[must_use = "fpca_der returns the derivative FPCA result; ignoring it wastes the computation"]
189pub fn fpca_der(
190 data: &FdMatrix,
191 ncomp: usize,
192 argvals: &[f64],
193 nderiv: usize,
194) -> Result<FpcaResult, FdarError> {
195 let (n, m) = data.shape();
196 if n == 0 {
198 return Err(FdarError::InvalidDimension {
199 parameter: "data",
200 expected: "n > 0 rows".to_string(),
201 actual: format!("n = {n}"),
202 });
203 }
204 if m == 0 {
205 return Err(FdarError::InvalidDimension {
206 parameter: "data",
207 expected: "m > 0 columns".to_string(),
208 actual: format!("m = {m}"),
209 });
210 }
211 if argvals.len() != m {
212 return Err(FdarError::InvalidDimension {
213 parameter: "argvals",
214 expected: format!("{m} elements"),
215 actual: format!("{} elements", argvals.len()),
216 });
217 }
218 if ncomp < 1 {
219 return Err(FdarError::InvalidParameter {
220 parameter: "ncomp",
221 message: format!("ncomp must be >= 1, got {ncomp}"),
222 });
223 }
224 if nderiv > 0 && m < 2 {
225 return Err(FdarError::InvalidParameter {
226 parameter: "data",
227 message: format!("need >= 2 columns for a numerical derivative, got m = {m}"),
228 });
229 }
230
231 let deriv_mat = match fdata::deriv(data, fdata::DerivDomain::OneD { argvals, nderiv }) {
232 fdata::DerivResult::OneD(m) => m,
233 _ => unreachable!("1D domain yields a 1D result"),
234 };
235 fdata_to_pc(&deriv_mat, ncomp, argvals)
236}
237
238#[must_use = "dynamical_correlation returns the scalar association; ignoring it wastes the computation"]
272pub fn dynamical_correlation(
273 x: &FdMatrix,
274 y: &FdMatrix,
275 argvals: &[f64],
276) -> Result<f64, FdarError> {
277 let (nx, mx) = x.shape();
278 let (ny, my) = y.shape();
279 if nx != ny {
280 return Err(FdarError::InvalidDimension {
281 parameter: "y",
282 expected: format!("{nx} rows (matching x)"),
283 actual: format!("{ny} rows"),
284 });
285 }
286 if mx != my {
287 return Err(FdarError::InvalidDimension {
288 parameter: "y",
289 expected: format!("{mx} columns (matching x)"),
290 actual: format!("{my} columns"),
291 });
292 }
293 if argvals.len() != mx {
294 return Err(FdarError::InvalidDimension {
295 parameter: "argvals",
296 expected: format!("{mx} elements"),
297 actual: format!("{} elements", argvals.len()),
298 });
299 }
300 if nx < 2 {
301 return Err(FdarError::InvalidDimension {
302 parameter: "x",
303 expected: ">= 2 rows".to_string(),
304 actual: nx.to_string(),
305 });
306 }
307 let m = mx;
308 let n = nx;
309 let domain_length = argvals[m - 1] - argvals[0];
310 if domain_length <= 0.0 || domain_length.is_nan() {
311 return Err(FdarError::InvalidParameter {
312 parameter: "argvals",
313 message: "argvals must be increasing with positive domain length".to_string(),
314 });
315 }
316 let w = simpsons_weights(argvals);
317
318 let mut xc1 = x.clone();
320 let mut yc1 = y.clone();
321 for (src, dst) in [(x, &mut xc1), (y, &mut yc1)] {
322 for i in 0..n {
323 let aver: f64 = (0..m).map(|j| src[(i, j)] * w[j]).sum::<f64>() / domain_length;
324 for j in 0..m {
325 dst[(i, j)] -= aver;
326 }
327 }
328 }
329
330 let center_pop = |mat: &mut FdMatrix| {
332 for j in 0..m {
333 let mean_j: f64 = (0..n).map(|i| mat[(i, j)]).sum::<f64>() / n as f64;
334 for i in 0..n {
335 mat[(i, j)] -= mean_j;
336 }
337 }
338 };
339 center_pop(&mut xc1);
340 center_pop(&mut yc1);
341
342 let standardize = |mat: &mut FdMatrix| {
344 for i in 0..n {
345 let norm_sq: f64 =
346 (0..m).map(|j| mat[(i, j)].powi(2) * w[j]).sum::<f64>() / domain_length;
347 let norm = norm_sq.sqrt();
348 if norm < 1e-15 {
349 for j in 0..m {
350 mat[(i, j)] = 0.0;
351 }
352 } else {
353 for j in 0..m {
354 mat[(i, j)] /= norm;
355 }
356 }
357 }
358 };
359 standardize(&mut xc1);
360 standardize(&mut yc1);
361
362 let total: f64 = (0..n)
364 .map(|i| {
365 let z: f64 = (0..m)
366 .map(|j| xc1[(i, j)] * yc1[(i, j)] * w[j])
367 .sum::<f64>()
368 / domain_length;
369 z
370 })
371 .sum();
372 Ok(total / n as f64)
373}
374
375#[must_use = "fsvd returns the cross-FPCA result; ignoring it wastes the computation"]
408pub fn fsvd(
409 x: &FdMatrix,
410 argvals_x: &[f64],
411 y: &FdMatrix,
412 argvals_y: &[f64],
413 ncomp: usize,
414) -> Result<FsvdResult, FdarError> {
415 let (nx, p) = x.shape();
416 let (ny, q) = y.shape();
417 if nx != ny {
418 return Err(FdarError::InvalidDimension {
419 parameter: "y",
420 expected: format!("{nx} rows (matching x)"),
421 actual: format!("{ny} rows"),
422 });
423 }
424 if nx < 2 {
425 return Err(FdarError::InvalidDimension {
426 parameter: "x",
427 expected: ">= 2 rows".to_string(),
428 actual: nx.to_string(),
429 });
430 }
431 if argvals_x.len() != p {
432 return Err(FdarError::InvalidDimension {
433 parameter: "argvals_x",
434 expected: format!("{p} elements"),
435 actual: format!("{} elements", argvals_x.len()),
436 });
437 }
438 if argvals_y.len() != q {
439 return Err(FdarError::InvalidDimension {
440 parameter: "argvals_y",
441 expected: format!("{q} elements"),
442 actual: format!("{} elements", argvals_y.len()),
443 });
444 }
445 if ncomp < 1 {
446 return Err(FdarError::InvalidParameter {
447 parameter: "ncomp",
448 message: format!("ncomp must be >= 1, got {ncomp}"),
449 });
450 }
451 let k = ncomp.min(p).min(q);
452
453 let c = cross_covariance(x, y)?;
455
456 let wx = simpsons_weights(argvals_x);
458 let wy = simpsons_weights(argvals_y);
459 let sqrt_wx: Vec<f64> = wx.iter().map(|v| v.sqrt()).collect();
460 let sqrt_wy: Vec<f64> = wy.iter().map(|v| v.sqrt()).collect();
461
462 let mut cw = FdMatrix::zeros(p, q);
464 for s in 0..p {
465 for t in 0..q {
466 cw[(s, t)] = sqrt_wx[s] * c[(s, t)] * sqrt_wy[t];
467 }
468 }
469
470 let gram_on_right = q <= p; let eigen = if gram_on_right {
477 DMatrix::from_fn(q, q, |a, b| {
479 (0..p).map(|s| cw[(s, a)] * cw[(s, b)]).sum::<f64>()
480 })
481 } else {
482 DMatrix::from_fn(p, p, |a, b| {
484 (0..q).map(|t| cw[(a, t)] * cw[(b, t)]).sum::<f64>()
485 })
486 }
487 .symmetric_eigen();
488 let mut pairs: Vec<(f64, usize)> = (0..eigen.eigenvalues.len())
489 .map(|idx| (eigen.eigenvalues[idx], idx))
490 .collect();
491 pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
492 let pairs: Vec<(f64, usize)> = pairs.into_iter().take(k).collect();
493
494 let mut singular_values = Vec::with_capacity(k);
496 let mut left = FdMatrix::zeros(p, k);
497 let mut right = FdMatrix::zeros(q, k);
498 for (comp, &(lam, col_idx)) in pairs.iter().enumerate() {
499 let sigma = lam.max(0.0).sqrt();
500 singular_values.push(sigma);
501 let mut uk = vec![0.0_f64; p];
503 let mut vk = vec![0.0_f64; q];
504 if gram_on_right {
505 for t in 0..q {
507 vk[t] = eigen.eigenvectors[(t, col_idx)];
508 }
509 if sigma > 1e-12 {
510 for s in 0..p {
511 uk[s] = (0..q).map(|t| cw[(s, t)] * vk[t]).sum::<f64>() / sigma;
512 }
513 }
514 } else {
515 for s in 0..p {
517 uk[s] = eigen.eigenvectors[(s, col_idx)];
518 }
519 if sigma > 1e-12 {
520 for t in 0..q {
521 vk[t] = (0..p).map(|s| cw[(s, t)] * uk[s]).sum::<f64>() / sigma;
522 }
523 }
524 }
525 for s in 0..p {
527 left[(s, comp)] = if sqrt_wx[s] > 1e-15 {
528 uk[s] / sqrt_wx[s]
529 } else {
530 uk[s]
531 };
532 }
533 for t in 0..q {
534 right[(t, comp)] = if sqrt_wy[t] > 1e-15 {
535 vk[t] / sqrt_wy[t]
536 } else {
537 vk[t]
538 };
539 }
540 }
541
542 for comp in 0..k {
544 let s_max = (0..p)
545 .max_by(|&a, &b| {
546 left[(a, comp)]
547 .abs()
548 .partial_cmp(&left[(b, comp)].abs())
549 .unwrap_or(std::cmp::Ordering::Equal)
550 })
551 .unwrap_or(0);
552 if left[(s_max, comp)] < 0.0 {
553 for s in 0..p {
554 left[(s, comp)] = -left[(s, comp)];
555 }
556 for t in 0..q {
557 right[(t, comp)] = -right[(t, comp)];
558 }
559 }
560 }
561
562 let xc = fdata::center_1d(x);
564 let yc = fdata::center_1d(y);
565 let mut left_scores = FdMatrix::zeros(nx, k);
566 let mut right_scores = FdMatrix::zeros(nx, k);
567 for comp in 0..k {
568 for i in 0..nx {
569 left_scores[(i, comp)] = (0..p)
570 .map(|s| xc[(i, s)] * left[(s, comp)] * wx[s])
571 .sum::<f64>();
572 right_scores[(i, comp)] = (0..q)
573 .map(|t| yc[(i, t)] * right[(t, comp)] * wy[t])
574 .sum::<f64>();
575 }
576 }
577
578 Ok(FsvdResult {
579 singular_values,
580 left_functions: left,
581 right_functions: right,
582 left_scores,
583 right_scores,
584 })
585}
586
587pub(crate) fn gaussian_smooth_cov(cov: &FdMatrix, argvals: &[f64], bandwidth: f64) -> FdMatrix {
592 let m = argvals.len();
593 let mut kernel = vec![0.0_f64; m * m];
595 for a in 0..m {
596 let mut row_sum = 0.0;
597 for b in 0..m {
598 let kv = gaussian_kernel((argvals[a] - argvals[b]).abs(), bandwidth);
599 kernel[a + b * m] = kv;
600 row_sum += kv;
601 }
602 if row_sum > 1e-15 {
603 for b in 0..m {
604 kernel[a + b * m] /= row_sum;
605 }
606 }
607 }
608 let mut tmp = FdMatrix::zeros(m, m);
610 for i in 0..m {
611 for j in 0..m {
612 let mut acc = 0.0;
613 for a in 0..m {
614 acc += kernel[i + a * m] * cov[(a, j)];
615 }
616 tmp[(i, j)] = acc;
617 }
618 }
619 let mut out = FdMatrix::zeros(m, m);
621 for i in 0..m {
622 for j in 0..m {
623 let mut acc = 0.0;
624 for b in 0..m {
625 acc += tmp[(i, b)] * kernel[j + b * m];
626 }
627 out[(i, j)] = acc;
628 }
629 }
630 out
631}
632
633#[must_use = "ssvd returns the smoothed FPCA result; ignoring it wastes the computation"]
669pub fn ssvd(
670 data: &FdMatrix,
671 ncomp: usize,
672 argvals: &[f64],
673 bandwidth: f64,
674) -> Result<FpcaResult, FdarError> {
675 let (n, m) = data.shape();
676 if n == 0 {
677 return Err(FdarError::InvalidDimension {
678 parameter: "data",
679 expected: "n > 0 rows".to_string(),
680 actual: format!("n = {n}"),
681 });
682 }
683 if m == 0 {
684 return Err(FdarError::InvalidDimension {
685 parameter: "data",
686 expected: "m > 0 columns".to_string(),
687 actual: format!("m = {m}"),
688 });
689 }
690 if argvals.len() != m {
691 return Err(FdarError::InvalidDimension {
692 parameter: "argvals",
693 expected: format!("{m} elements"),
694 actual: format!("{} elements", argvals.len()),
695 });
696 }
697 if ncomp < 1 {
698 return Err(FdarError::InvalidParameter {
699 parameter: "ncomp",
700 message: format!("ncomp must be >= 1, got {ncomp}"),
701 });
702 }
703 if bandwidth < 0.0 || bandwidth.is_nan() {
704 return Err(FdarError::InvalidParameter {
705 parameter: "bandwidth",
706 message: format!("bandwidth must be >= 0, got {bandwidth}"),
707 });
708 }
709
710 let (centered, means) = {
712 let c = fdata::center_1d(data);
713 let mut means = vec![0.0; m];
714 for j in 0..m {
715 means[j] = (0..n).map(|i| data[(i, j)]).sum::<f64>() / n as f64;
716 }
717 (c, means)
718 };
719
720 let emp = fdata::functional_covariance(data)?;
722
723 let smooth_cov = if bandwidth <= 1e-10 {
725 emp
726 } else {
727 gaussian_smooth_cov(&emp, argvals, bandwidth)
728 };
729
730 let w = simpsons_weights(argvals);
732 let sqrt_w: Vec<f64> = w.iter().map(|v| v.sqrt()).collect();
733 let eigen = DMatrix::from_fn(m, m, |row, col| {
736 sqrt_w[row] * smooth_cov[(row, col)] * sqrt_w[col]
737 })
738 .symmetric_eigen();
739 let mut pairs: Vec<(f64, usize)> = (0..eigen.eigenvalues.len())
740 .map(|k| (eigen.eigenvalues[k], k))
741 .collect();
742 pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
743 let pairs: Vec<(f64, usize)> = pairs
744 .into_iter()
745 .filter(|&(lam, _)| lam > 0.0)
746 .take(ncomp)
747 .collect();
748 let actual = pairs.len();
749
750 let denom = (n - 1) as f64;
751 let mut singular_values = Vec::with_capacity(actual);
752 let mut rotation = FdMatrix::zeros(m, actual);
753 for (comp, &(lam, col_idx)) in pairs.iter().enumerate() {
754 singular_values.push((lam * denom).sqrt());
756 for j in 0..m {
757 let raw = eigen.eigenvectors[(j, col_idx)];
758 rotation[(j, comp)] = if sqrt_w[j] > 1e-15 {
759 raw / sqrt_w[j]
760 } else {
761 raw
762 };
763 }
764 }
765 for comp in 0..actual {
767 let j_max = (0..m)
768 .max_by(|&a, &b| {
769 rotation[(a, comp)]
770 .abs()
771 .partial_cmp(&rotation[(b, comp)].abs())
772 .unwrap_or(std::cmp::Ordering::Equal)
773 })
774 .unwrap_or(0);
775 if rotation[(j_max, comp)] < 0.0 {
776 for j in 0..m {
777 rotation[(j, comp)] = -rotation[(j, comp)];
778 }
779 }
780 }
781
782 let mut scores = FdMatrix::zeros(n, actual);
784 for comp in 0..actual {
785 for i in 0..n {
786 scores[(i, comp)] = (0..m)
787 .map(|j| centered[(i, j)] * rotation[(j, comp)] * w[j])
788 .sum::<f64>();
789 }
790 }
791
792 Ok(FpcaResult {
793 singular_values,
794 rotation,
795 scores,
796 mean: means,
797 centered,
798 weights: w,
799 })
800}
801
802#[cfg(test)]
803mod tests {
804 use super::*;
805
806 fn weighted_l2_sq(c: &[f64], w: &[f64]) -> f64 {
808 c.iter().zip(w.iter()).map(|(&v, &wj)| v * v * wj).sum()
809 }
810
811 fn approx(a: f64, b: f64, tol: f64) -> bool {
812 (a - b).abs() < tol
813 }
814
815 #[test]
818 fn test_cross_cov_shape() {
819 let x = FdMatrix::from_column_major((0..8).map(|i| i as f64).collect(), 4, 2).unwrap();
821 let y =
822 FdMatrix::from_column_major((0..12).map(|i| (i as f64).sin()).collect(), 4, 3).unwrap();
823 let c = cross_covariance(&x, &y).unwrap();
824 assert_eq!(c.shape(), (2, 3));
825 }
826
827 #[test]
828 fn test_cross_cov_self() {
829 let x = FdMatrix::from_column_major(vec![1.0, 2.0, 5.0, 3.0, 0.0, 4.0, 2.0, 7.0], 4, 2)
831 .unwrap();
832 let c = cross_covariance(&x, &x).unwrap();
833 let fc = fdata::functional_covariance(&x).unwrap();
834 assert_eq!(c.shape(), fc.shape());
835 for s in 0..2 {
836 for t in 0..2 {
837 assert!(
838 approx(c[(s, t)], fc[(s, t)], 1e-12),
839 "c[{s},{t}]={} fc={}",
840 c[(s, t)],
841 fc[(s, t)]
842 );
843 }
844 }
845 }
846
847 #[test]
848 fn test_cross_cov_hand_computed() {
849 let x = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0, 6.0, 8.0], 3, 2).unwrap();
853 let y = FdMatrix::from_column_major(vec![2.0, 4.0, 6.0, 10.0, 10.0, 13.0], 3, 2).unwrap();
854 let c = cross_covariance(&x, &y).unwrap();
855 assert!(approx(c[(0, 0)], 2.0, 1e-12));
861 assert!(approx(c[(0, 1)], 1.5, 1e-12));
862 assert!(approx(c[(1, 0)], 4.0, 1e-12));
863 assert!(approx(c[(1, 1)], 3.0, 1e-12));
864 }
865
866 #[test]
867 fn test_cross_cov_errors() {
868 let x = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0], 2, 2).unwrap();
869 let y3 = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], 3, 2).unwrap();
871 assert!(cross_covariance(&x, &y3).is_err());
872 let x1 = FdMatrix::from_column_major(vec![1.0, 2.0], 1, 2).unwrap();
874 let y1 = FdMatrix::from_column_major(vec![3.0, 4.0], 1, 2).unwrap();
875 assert!(cross_covariance(&x1, &y1).is_err());
876 let x0 = FdMatrix::zeros(3, 0);
878 let y0 = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 3, 1).unwrap();
879 assert!(cross_covariance(&x0, &y0).is_err());
880 }
881
882 #[test]
885 fn test_fpca_der_nderiv0() {
886 let data = FdMatrix::from_column_major(
888 (0..40)
889 .map(|i| (i as f64 * 0.13).sin() + (i as f64 * 0.02))
890 .collect(),
891 5,
892 8,
893 )
894 .unwrap();
895 let argvals: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
896 let a = fpca_der(&data, 3, &argvals, 0).unwrap();
897 let b = fdata_to_pc(&data, 3, &argvals).unwrap();
898 assert_eq!(a.singular_values.len(), b.singular_values.len());
899 for k in 0..a.singular_values.len() {
900 assert!(
901 approx(a.singular_values[k], b.singular_values[k], 1e-12),
902 "sv[{k}]: {} vs {}",
903 a.singular_values[k],
904 b.singular_values[k]
905 );
906 }
907 let (m, nc) = a.rotation.shape();
908 for j in 0..m {
909 for k in 0..nc {
910 assert!(approx(a.rotation[(j, k)], b.rotation[(j, k)], 1e-12));
911 }
912 }
913 }
914
915 #[test]
916 fn test_fpca_der() {
917 let m = 40usize;
921 let n = 6usize;
922 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
923 let amps = [0.5, 1.0, 1.5, 2.0, 2.5, 3.0];
924 let mut vals = vec![0.0; n * m];
925 for j in 0..m {
926 for i in 0..n {
927 let t = argvals[j];
928 vals[i + j * n] = amps[i] * (2.0 * std::f64::consts::PI * t).sin();
929 }
930 }
931 let data = FdMatrix::from_column_major(vals, n, m).unwrap();
932 let res = fpca_der(&data, 1, &argvals, 1).unwrap();
933
934 let deriv = match fdata::deriv(
937 &data,
938 fdata::DerivDomain::OneD {
939 argvals: &argvals,
940 nderiv: 1,
941 },
942 ) {
943 fdata::DerivResult::OneD(m) => m,
944 _ => unreachable!("1D domain yields a 1D result"),
945 };
946 let dc = fdata::center_1d(&deriv);
948 let mut sse = 0.0;
949 let mut sst = 0.0;
950 for i in 0..n {
951 for j in 0..m {
952 let recon = res.scores[(i, 0)] * res.rotation[(j, 0)];
953 let actual = dc[(i, j)];
954 sse += (actual - recon).powi(2);
955 sst += actual.powi(2);
956 }
957 }
958 assert!(
960 sse / sst < 1e-6,
961 "relative reconstruction error {}",
962 sse / sst
963 );
964 }
965
966 #[test]
967 fn test_fpca_der_errors() {
968 let argvals: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
969 let data = FdMatrix::from_column_major((0..40).map(|i| i as f64).collect(), 5, 8).unwrap();
970 assert!(fpca_der(&FdMatrix::zeros(0, 0), 1, &[], 1).is_err());
972 assert!(fpca_der(&data, 1, &argvals[..7], 1).is_err());
974 assert!(fpca_der(&data, 0, &argvals, 1).is_err());
976 let thin = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 3, 1).unwrap();
978 assert!(fpca_der(&thin, 1, &[0.0], 1).is_err());
979 }
980
981 fn sine_sample(n: usize, m: usize, seedish: f64) -> (FdMatrix, Vec<f64>) {
984 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
985 let mut vals = vec![0.0; n * m];
986 for i in 0..n {
987 for j in 0..m {
988 let t = argvals[j];
989 vals[i + j * n] =
990 ((i as f64 + 1.0) * seedish * t).sin() + 0.3 * (i as f64 + 1.0) * t;
991 }
992 }
993 (FdMatrix::from_column_major(vals, n, m).unwrap(), argvals)
994 }
995
996 #[test]
997 fn test_dyncorr_identical() {
998 let (data, argvals) = sine_sample(6, 20, 6.0);
999 let r = dynamical_correlation(&data, &data, &argvals).unwrap();
1000 assert!(approx(r, 1.0, 1e-10), "dyncorr(x,x) = {r}");
1001 }
1002
1003 #[test]
1004 fn test_dyncorr_negated() {
1005 let (data, argvals) = sine_sample(6, 20, 5.0);
1006 let (n, m) = data.shape();
1007 let mut neg = data.clone();
1008 for i in 0..n {
1009 for j in 0..m {
1010 neg[(i, j)] = -data[(i, j)];
1011 }
1012 }
1013 let r = dynamical_correlation(&data, &neg, &argvals).unwrap();
1014 assert!(approx(r, -1.0, 1e-10), "dyncorr(x,-x) = {r}");
1015 }
1016
1017 #[test]
1018 fn test_dyncorr_range() {
1019 let (x, argvals) = sine_sample(7, 25, 4.0);
1020 let (y, _) = sine_sample(7, 25, 9.0);
1021 let r = dynamical_correlation(&x, &y, &argvals).unwrap();
1022 assert!(
1023 (-1.0 - 1e-9..=1.0 + 1e-9).contains(&r),
1024 "dyncorr out of range: {r}"
1025 );
1026 }
1027
1028 #[test]
1029 fn test_dyncorr_errors() {
1030 let (x, argvals) = sine_sample(5, 10, 3.0);
1031 let (y6, _) = sine_sample(6, 10, 3.0);
1033 assert!(dynamical_correlation(&x, &y6, &argvals).is_err());
1034 let (yc, _) = sine_sample(5, 12, 3.0);
1036 assert!(dynamical_correlation(&x, &yc, &argvals).is_err());
1037 assert!(dynamical_correlation(&x, &x, &argvals[..9]).is_err());
1039 let (x1, a1) = sine_sample(1, 10, 3.0);
1041 assert!(dynamical_correlation(&x1, &x1, &a1).is_err());
1042 }
1043
1044 #[test]
1047 fn test_fsvd_unit_norm() {
1048 let (x, ax) = sine_sample(6, 15, 4.0);
1049 let (y, ay) = sine_sample(6, 12, 7.0);
1050 let res = fsvd(&x, &ax, &y, &ay, 2).unwrap();
1051 let wx = simpsons_weights(&ax);
1052 let wy = simpsons_weights(&ay);
1053 for comp in 0..res.singular_values.len() {
1054 let ln: f64 = (0..15)
1055 .map(|s| res.left_functions[(s, comp)].powi(2) * wx[s])
1056 .sum();
1057 let rn: f64 = (0..12)
1058 .map(|t| res.right_functions[(t, comp)].powi(2) * wy[t])
1059 .sum();
1060 assert!(approx(ln, 1.0, 1e-8), "left norm[{comp}]={ln}");
1061 assert!(approx(rn, 1.0, 1e-8), "right norm[{comp}]={rn}");
1062 }
1063 }
1064
1065 #[test]
1066 fn test_fsvd_rank1() {
1067 let n = 8usize;
1069 let px = 20usize;
1070 let qy = 18usize;
1071 let ax: Vec<f64> = (0..px).map(|i| i as f64 / (px as f64 - 1.0)).collect();
1072 let ay: Vec<f64> = (0..qy).map(|i| i as f64 / (qy as f64 - 1.0)).collect();
1073 let amps: Vec<f64> = (0..n).map(|i| 0.5 + i as f64 * 0.4).collect();
1074 let mut xv = vec![0.0; n * px];
1075 let mut yv = vec![0.0; n * qy];
1076 for i in 0..n {
1077 for j in 0..px {
1078 xv[i + j * n] = amps[i] * (std::f64::consts::PI * ax[j]).sin();
1079 }
1080 for j in 0..qy {
1081 yv[i + j * n] = amps[i] * (std::f64::consts::PI * ay[j]).cos();
1082 }
1083 }
1084 let x = FdMatrix::from_column_major(xv, n, px).unwrap();
1085 let y = FdMatrix::from_column_major(yv, n, qy).unwrap();
1086 let res = fsvd(&x, &ax, &y, &ay, 3).unwrap();
1087
1088 assert!(
1090 res.singular_values[0] > 1e6 * res.singular_values[1].max(1e-14),
1091 "not rank-1 dominant: {:?}",
1092 res.singular_values
1093 );
1094
1095 let c = cross_covariance(&x, &y).unwrap();
1099 let mut sse = 0.0;
1100 let mut sst = 0.0;
1101 for s in 0..px {
1102 for t in 0..qy {
1103 let mut recon = 0.0;
1104 for k in 0..res.singular_values.len() {
1105 recon += res.singular_values[k]
1106 * res.left_functions[(s, k)]
1107 * res.right_functions[(t, k)];
1108 }
1109 sse += (c[(s, t)] - recon).powi(2);
1110 sst += c[(s, t)].powi(2);
1111 }
1112 }
1113 assert!(
1114 sse / sst < 1e-6,
1115 "cross-cov reconstruction rel err {}",
1116 sse / sst
1117 );
1118 }
1119
1120 #[test]
1121 fn test_fsvd_wide_left_gram_branch() {
1122 let n = 8usize;
1125 let px = 14usize;
1126 let qy = 22usize;
1127 let ax: Vec<f64> = (0..px).map(|i| i as f64 / (px as f64 - 1.0)).collect();
1128 let ay: Vec<f64> = (0..qy).map(|i| i as f64 / (qy as f64 - 1.0)).collect();
1129 let amps: Vec<f64> = (0..n).map(|i| 0.5 + i as f64 * 0.4).collect();
1130 let mut xv = vec![0.0; n * px];
1131 let mut yv = vec![0.0; n * qy];
1132 for i in 0..n {
1133 for j in 0..px {
1134 xv[i + j * n] = amps[i] * (std::f64::consts::PI * ax[j]).sin();
1135 }
1136 for j in 0..qy {
1137 yv[i + j * n] = amps[i] * (std::f64::consts::PI * ay[j]).cos();
1138 }
1139 }
1140 let x = FdMatrix::from_column_major(xv, n, px).unwrap();
1141 let y = FdMatrix::from_column_major(yv, n, qy).unwrap();
1142 let res = fsvd(&x, &ax, &y, &ay, 2).unwrap();
1143 assert_eq!(res.left_functions.shape(), (px, 2));
1144 assert_eq!(res.right_functions.shape(), (qy, 2));
1145 let wx = simpsons_weights(&ax);
1147 let wy = simpsons_weights(&ay);
1148 assert!(approx(
1149 weighted_l2_sq(res.left_functions.column(0), &wx),
1150 1.0,
1151 1e-8
1152 ));
1153 assert!(approx(
1154 weighted_l2_sq(res.right_functions.column(0), &wy),
1155 1.0,
1156 1e-8
1157 ));
1158 let c = cross_covariance(&x, &y).unwrap();
1160 let mut sse = 0.0;
1161 let mut sst = 0.0;
1162 for s in 0..px {
1163 for t in 0..qy {
1164 let mut recon = 0.0;
1165 for k in 0..res.singular_values.len() {
1166 recon += res.singular_values[k]
1167 * res.left_functions[(s, k)]
1168 * res.right_functions[(t, k)];
1169 }
1170 sse += (c[(s, t)] - recon).powi(2);
1171 sst += c[(s, t)].powi(2);
1172 }
1173 }
1174 assert!(
1175 sse / sst < 1e-6,
1176 "wide reconstruction rel err {}",
1177 sse / sst
1178 );
1179 }
1180
1181 #[test]
1182 fn test_fsvd_errors() {
1183 let (x, ax) = sine_sample(5, 10, 3.0);
1184 let (y, ay) = sine_sample(5, 8, 4.0);
1185 let (y6, ay6) = sine_sample(6, 8, 4.0);
1187 assert!(fsvd(&x, &ax, &y6, &ay6, 1).is_err());
1188 assert!(fsvd(&x, &ax, &y, &ay, 0).is_err());
1190 assert!(fsvd(&x, &ax[..9], &y, &ay, 1).is_err());
1192 }
1193
1194 #[test]
1197 fn test_ssvd_dense_limit() {
1198 let (data, argvals) = sine_sample(8, 20, 5.0);
1199 let a = ssvd(&data, 3, &argvals, 1e-12).unwrap();
1200 let b = fdata_to_pc(&data, 3, &argvals).unwrap();
1201 assert_eq!(a.singular_values.len(), b.singular_values.len());
1202 for k in 0..a.singular_values.len() {
1203 let rel = (a.singular_values[k] - b.singular_values[k]).abs()
1204 / b.singular_values[k].abs().max(1e-12);
1205 assert!(
1206 rel < 1e-4,
1207 "sv[{k}] dense-limit mismatch: {} vs {} (rel {})",
1208 a.singular_values[k],
1209 b.singular_values[k],
1210 rel
1211 );
1212 }
1213 }
1214
1215 #[test]
1216 fn test_ssvd_orthonormality() {
1217 let (data, argvals) = sine_sample(8, 20, 5.0);
1218 let res = ssvd(&data, 3, &argvals, 0.05).unwrap();
1219 let w = simpsons_weights(&argvals);
1220 let nc = res.rotation.shape().1;
1221 for a in 0..nc {
1222 for b in 0..nc {
1223 let ip: f64 = (0..20)
1224 .map(|j| res.rotation[(j, a)] * res.rotation[(j, b)] * w[j])
1225 .sum();
1226 let expected = if a == b { 1.0 } else { 0.0 };
1227 assert!(approx(ip, expected, 1e-6), "⟨φ{a},φ{b}⟩={ip}");
1228 }
1229 }
1230 }
1231
1232 #[test]
1233 fn test_ssvd_errors() {
1234 let (data, argvals) = sine_sample(5, 10, 3.0);
1235 assert!(ssvd(&data, 0, &argvals, 0.1).is_err());
1237 assert!(ssvd(&FdMatrix::zeros(0, 0), 1, &[], 0.1).is_err());
1239 assert!(ssvd(&data, 1, &argvals[..9], 0.1).is_err());
1241 assert!(ssvd(&data, 1, &argvals, -0.1).is_err());
1243 }
1244
1245 #[test]
1248 fn smoke_reexports() {
1249 let (x, argvals) = sine_sample(4, 6, 4.0);
1251 let (y, ay) = sine_sample(4, 6, 6.0);
1252 let _c = crate::cross_covariance(&x, &y).unwrap();
1253 let _f = crate::fpca_der(&x, 1, &argvals, 1).unwrap();
1254 let _d = crate::dynamical_correlation(&x, &y, &argvals).unwrap();
1255 let _s = crate::fsvd(&x, &argvals, &y, &ay, 1).unwrap();
1256 let _v = crate::ssvd(&x, 1, &argvals, 0.1).unwrap();
1257 let _first: &crate::FsvdResult = &_s;
1259 let w = simpsons_weights(&argvals);
1261 let _ = weighted_l2_sq(x.column(0), &w);
1262 }
1263}