1use crate::error::FdarError;
21use crate::fdata;
22use crate::helpers::{gaussian_kernel, simpsons_weights};
23use crate::matrix::FdMatrix;
24use crate::regression::{fdata_to_pc_1d, 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 = fdata::deriv_1d(data, argvals, nderiv);
232 fdata_to_pc_1d(&deriv, ncomp, argvals)
233}
234
235#[must_use = "dynamical_correlation returns the scalar association; ignoring it wastes the computation"]
269pub fn dynamical_correlation(
270 x: &FdMatrix,
271 y: &FdMatrix,
272 argvals: &[f64],
273) -> Result<f64, FdarError> {
274 let (nx, mx) = x.shape();
275 let (ny, my) = y.shape();
276 if nx != ny {
277 return Err(FdarError::InvalidDimension {
278 parameter: "y",
279 expected: format!("{nx} rows (matching x)"),
280 actual: format!("{ny} rows"),
281 });
282 }
283 if mx != my {
284 return Err(FdarError::InvalidDimension {
285 parameter: "y",
286 expected: format!("{mx} columns (matching x)"),
287 actual: format!("{my} columns"),
288 });
289 }
290 if argvals.len() != mx {
291 return Err(FdarError::InvalidDimension {
292 parameter: "argvals",
293 expected: format!("{mx} elements"),
294 actual: format!("{} elements", argvals.len()),
295 });
296 }
297 if nx < 2 {
298 return Err(FdarError::InvalidDimension {
299 parameter: "x",
300 expected: ">= 2 rows".to_string(),
301 actual: nx.to_string(),
302 });
303 }
304 let m = mx;
305 let n = nx;
306 let domain_length = argvals[m - 1] - argvals[0];
307 if domain_length <= 0.0 || domain_length.is_nan() {
308 return Err(FdarError::InvalidParameter {
309 parameter: "argvals",
310 message: "argvals must be increasing with positive domain length".to_string(),
311 });
312 }
313 let w = simpsons_weights(argvals);
314
315 let mut xc1 = x.clone();
317 let mut yc1 = y.clone();
318 for (src, dst) in [(x, &mut xc1), (y, &mut yc1)] {
319 for i in 0..n {
320 let aver: f64 = (0..m).map(|j| src[(i, j)] * w[j]).sum::<f64>() / domain_length;
321 for j in 0..m {
322 dst[(i, j)] -= aver;
323 }
324 }
325 }
326
327 let center_pop = |mat: &mut FdMatrix| {
329 for j in 0..m {
330 let mean_j: f64 = (0..n).map(|i| mat[(i, j)]).sum::<f64>() / n as f64;
331 for i in 0..n {
332 mat[(i, j)] -= mean_j;
333 }
334 }
335 };
336 center_pop(&mut xc1);
337 center_pop(&mut yc1);
338
339 let standardize = |mat: &mut FdMatrix| {
341 for i in 0..n {
342 let norm_sq: f64 =
343 (0..m).map(|j| mat[(i, j)].powi(2) * w[j]).sum::<f64>() / domain_length;
344 let norm = norm_sq.sqrt();
345 if norm < 1e-15 {
346 for j in 0..m {
347 mat[(i, j)] = 0.0;
348 }
349 } else {
350 for j in 0..m {
351 mat[(i, j)] /= norm;
352 }
353 }
354 }
355 };
356 standardize(&mut xc1);
357 standardize(&mut yc1);
358
359 let total: f64 = (0..n)
361 .map(|i| {
362 let z: f64 = (0..m)
363 .map(|j| xc1[(i, j)] * yc1[(i, j)] * w[j])
364 .sum::<f64>()
365 / domain_length;
366 z
367 })
368 .sum();
369 Ok(total / n as f64)
370}
371
372#[must_use = "fsvd returns the cross-FPCA result; ignoring it wastes the computation"]
405pub fn fsvd(
406 x: &FdMatrix,
407 argvals_x: &[f64],
408 y: &FdMatrix,
409 argvals_y: &[f64],
410 ncomp: usize,
411) -> Result<FsvdResult, FdarError> {
412 let (nx, p) = x.shape();
413 let (ny, q) = y.shape();
414 if nx != ny {
415 return Err(FdarError::InvalidDimension {
416 parameter: "y",
417 expected: format!("{nx} rows (matching x)"),
418 actual: format!("{ny} rows"),
419 });
420 }
421 if nx < 2 {
422 return Err(FdarError::InvalidDimension {
423 parameter: "x",
424 expected: ">= 2 rows".to_string(),
425 actual: nx.to_string(),
426 });
427 }
428 if argvals_x.len() != p {
429 return Err(FdarError::InvalidDimension {
430 parameter: "argvals_x",
431 expected: format!("{p} elements"),
432 actual: format!("{} elements", argvals_x.len()),
433 });
434 }
435 if argvals_y.len() != q {
436 return Err(FdarError::InvalidDimension {
437 parameter: "argvals_y",
438 expected: format!("{q} elements"),
439 actual: format!("{} elements", argvals_y.len()),
440 });
441 }
442 if ncomp < 1 {
443 return Err(FdarError::InvalidParameter {
444 parameter: "ncomp",
445 message: format!("ncomp must be >= 1, got {ncomp}"),
446 });
447 }
448 let k = ncomp.min(p).min(q);
449
450 let c = cross_covariance(x, y)?;
452
453 let wx = simpsons_weights(argvals_x);
455 let wy = simpsons_weights(argvals_y);
456 let sqrt_wx: Vec<f64> = wx.iter().map(|v| v.sqrt()).collect();
457 let sqrt_wy: Vec<f64> = wy.iter().map(|v| v.sqrt()).collect();
458
459 let mut cw = FdMatrix::zeros(p, q);
461 for s in 0..p {
462 for t in 0..q {
463 cw[(s, t)] = sqrt_wx[s] * c[(s, t)] * sqrt_wy[t];
464 }
465 }
466
467 let gram_on_right = q <= p; let eigen = if gram_on_right {
474 DMatrix::from_fn(q, q, |a, b| {
476 (0..p).map(|s| cw[(s, a)] * cw[(s, b)]).sum::<f64>()
477 })
478 } else {
479 DMatrix::from_fn(p, p, |a, b| {
481 (0..q).map(|t| cw[(a, t)] * cw[(b, t)]).sum::<f64>()
482 })
483 }
484 .symmetric_eigen();
485 let mut pairs: Vec<(f64, usize)> = (0..eigen.eigenvalues.len())
486 .map(|idx| (eigen.eigenvalues[idx], idx))
487 .collect();
488 pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
489 let pairs: Vec<(f64, usize)> = pairs.into_iter().take(k).collect();
490
491 let mut singular_values = Vec::with_capacity(k);
493 let mut left = FdMatrix::zeros(p, k);
494 let mut right = FdMatrix::zeros(q, k);
495 for (comp, &(lam, col_idx)) in pairs.iter().enumerate() {
496 let sigma = lam.max(0.0).sqrt();
497 singular_values.push(sigma);
498 let mut uk = vec![0.0_f64; p];
500 let mut vk = vec![0.0_f64; q];
501 if gram_on_right {
502 for t in 0..q {
504 vk[t] = eigen.eigenvectors[(t, col_idx)];
505 }
506 if sigma > 1e-12 {
507 for s in 0..p {
508 uk[s] = (0..q).map(|t| cw[(s, t)] * vk[t]).sum::<f64>() / sigma;
509 }
510 }
511 } else {
512 for s in 0..p {
514 uk[s] = eigen.eigenvectors[(s, col_idx)];
515 }
516 if sigma > 1e-12 {
517 for t in 0..q {
518 vk[t] = (0..p).map(|s| cw[(s, t)] * uk[s]).sum::<f64>() / sigma;
519 }
520 }
521 }
522 for s in 0..p {
524 left[(s, comp)] = if sqrt_wx[s] > 1e-15 {
525 uk[s] / sqrt_wx[s]
526 } else {
527 uk[s]
528 };
529 }
530 for t in 0..q {
531 right[(t, comp)] = if sqrt_wy[t] > 1e-15 {
532 vk[t] / sqrt_wy[t]
533 } else {
534 vk[t]
535 };
536 }
537 }
538
539 for comp in 0..k {
541 let s_max = (0..p)
542 .max_by(|&a, &b| {
543 left[(a, comp)]
544 .abs()
545 .partial_cmp(&left[(b, comp)].abs())
546 .unwrap_or(std::cmp::Ordering::Equal)
547 })
548 .unwrap_or(0);
549 if left[(s_max, comp)] < 0.0 {
550 for s in 0..p {
551 left[(s, comp)] = -left[(s, comp)];
552 }
553 for t in 0..q {
554 right[(t, comp)] = -right[(t, comp)];
555 }
556 }
557 }
558
559 let xc = fdata::center_1d(x);
561 let yc = fdata::center_1d(y);
562 let mut left_scores = FdMatrix::zeros(nx, k);
563 let mut right_scores = FdMatrix::zeros(nx, k);
564 for comp in 0..k {
565 for i in 0..nx {
566 left_scores[(i, comp)] = (0..p)
567 .map(|s| xc[(i, s)] * left[(s, comp)] * wx[s])
568 .sum::<f64>();
569 right_scores[(i, comp)] = (0..q)
570 .map(|t| yc[(i, t)] * right[(t, comp)] * wy[t])
571 .sum::<f64>();
572 }
573 }
574
575 Ok(FsvdResult {
576 singular_values,
577 left_functions: left,
578 right_functions: right,
579 left_scores,
580 right_scores,
581 })
582}
583
584pub(crate) fn gaussian_smooth_cov(cov: &FdMatrix, argvals: &[f64], bandwidth: f64) -> FdMatrix {
589 let m = argvals.len();
590 let mut kernel = vec![0.0_f64; m * m];
592 for a in 0..m {
593 let mut row_sum = 0.0;
594 for b in 0..m {
595 let kv = gaussian_kernel((argvals[a] - argvals[b]).abs(), bandwidth);
596 kernel[a + b * m] = kv;
597 row_sum += kv;
598 }
599 if row_sum > 1e-15 {
600 for b in 0..m {
601 kernel[a + b * m] /= row_sum;
602 }
603 }
604 }
605 let mut tmp = FdMatrix::zeros(m, m);
607 for i in 0..m {
608 for j in 0..m {
609 let mut acc = 0.0;
610 for a in 0..m {
611 acc += kernel[i + a * m] * cov[(a, j)];
612 }
613 tmp[(i, j)] = acc;
614 }
615 }
616 let mut out = FdMatrix::zeros(m, m);
618 for i in 0..m {
619 for j in 0..m {
620 let mut acc = 0.0;
621 for b in 0..m {
622 acc += tmp[(i, b)] * kernel[j + b * m];
623 }
624 out[(i, j)] = acc;
625 }
626 }
627 out
628}
629
630#[must_use = "ssvd returns the smoothed FPCA result; ignoring it wastes the computation"]
666pub fn ssvd(
667 data: &FdMatrix,
668 ncomp: usize,
669 argvals: &[f64],
670 bandwidth: f64,
671) -> Result<FpcaResult, FdarError> {
672 let (n, m) = data.shape();
673 if n == 0 {
674 return Err(FdarError::InvalidDimension {
675 parameter: "data",
676 expected: "n > 0 rows".to_string(),
677 actual: format!("n = {n}"),
678 });
679 }
680 if m == 0 {
681 return Err(FdarError::InvalidDimension {
682 parameter: "data",
683 expected: "m > 0 columns".to_string(),
684 actual: format!("m = {m}"),
685 });
686 }
687 if argvals.len() != m {
688 return Err(FdarError::InvalidDimension {
689 parameter: "argvals",
690 expected: format!("{m} elements"),
691 actual: format!("{} elements", argvals.len()),
692 });
693 }
694 if ncomp < 1 {
695 return Err(FdarError::InvalidParameter {
696 parameter: "ncomp",
697 message: format!("ncomp must be >= 1, got {ncomp}"),
698 });
699 }
700 if bandwidth < 0.0 || bandwidth.is_nan() {
701 return Err(FdarError::InvalidParameter {
702 parameter: "bandwidth",
703 message: format!("bandwidth must be >= 0, got {bandwidth}"),
704 });
705 }
706
707 let (centered, means) = {
709 let c = fdata::center_1d(data);
710 let mut means = vec![0.0; m];
711 for j in 0..m {
712 means[j] = (0..n).map(|i| data[(i, j)]).sum::<f64>() / n as f64;
713 }
714 (c, means)
715 };
716
717 let emp = fdata::functional_covariance(data)?;
719
720 let smooth_cov = if bandwidth <= 1e-10 {
722 emp
723 } else {
724 gaussian_smooth_cov(&emp, argvals, bandwidth)
725 };
726
727 let w = simpsons_weights(argvals);
729 let sqrt_w: Vec<f64> = w.iter().map(|v| v.sqrt()).collect();
730 let eigen = DMatrix::from_fn(m, m, |row, col| {
733 sqrt_w[row] * smooth_cov[(row, col)] * sqrt_w[col]
734 })
735 .symmetric_eigen();
736 let mut pairs: Vec<(f64, usize)> = (0..eigen.eigenvalues.len())
737 .map(|k| (eigen.eigenvalues[k], k))
738 .collect();
739 pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
740 let pairs: Vec<(f64, usize)> = pairs
741 .into_iter()
742 .filter(|&(lam, _)| lam > 0.0)
743 .take(ncomp)
744 .collect();
745 let actual = pairs.len();
746
747 let denom = (n - 1) as f64;
748 let mut singular_values = Vec::with_capacity(actual);
749 let mut rotation = FdMatrix::zeros(m, actual);
750 for (comp, &(lam, col_idx)) in pairs.iter().enumerate() {
751 singular_values.push((lam * denom).sqrt());
753 for j in 0..m {
754 let raw = eigen.eigenvectors[(j, col_idx)];
755 rotation[(j, comp)] = if sqrt_w[j] > 1e-15 {
756 raw / sqrt_w[j]
757 } else {
758 raw
759 };
760 }
761 }
762 for comp in 0..actual {
764 let j_max = (0..m)
765 .max_by(|&a, &b| {
766 rotation[(a, comp)]
767 .abs()
768 .partial_cmp(&rotation[(b, comp)].abs())
769 .unwrap_or(std::cmp::Ordering::Equal)
770 })
771 .unwrap_or(0);
772 if rotation[(j_max, comp)] < 0.0 {
773 for j in 0..m {
774 rotation[(j, comp)] = -rotation[(j, comp)];
775 }
776 }
777 }
778
779 let mut scores = FdMatrix::zeros(n, actual);
781 for comp in 0..actual {
782 for i in 0..n {
783 scores[(i, comp)] = (0..m)
784 .map(|j| centered[(i, j)] * rotation[(j, comp)] * w[j])
785 .sum::<f64>();
786 }
787 }
788
789 Ok(FpcaResult {
790 singular_values,
791 rotation,
792 scores,
793 mean: means,
794 centered,
795 weights: w,
796 })
797}
798
799#[cfg(test)]
800mod tests {
801 use super::*;
802
803 fn weighted_l2_sq(c: &[f64], w: &[f64]) -> f64 {
805 c.iter().zip(w.iter()).map(|(&v, &wj)| v * v * wj).sum()
806 }
807
808 fn approx(a: f64, b: f64, tol: f64) -> bool {
809 (a - b).abs() < tol
810 }
811
812 #[test]
815 fn test_cross_cov_shape() {
816 let x = FdMatrix::from_column_major((0..8).map(|i| i as f64).collect(), 4, 2).unwrap();
818 let y =
819 FdMatrix::from_column_major((0..12).map(|i| (i as f64).sin()).collect(), 4, 3).unwrap();
820 let c = cross_covariance(&x, &y).unwrap();
821 assert_eq!(c.shape(), (2, 3));
822 }
823
824 #[test]
825 fn test_cross_cov_self() {
826 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)
828 .unwrap();
829 let c = cross_covariance(&x, &x).unwrap();
830 let fc = fdata::functional_covariance(&x).unwrap();
831 assert_eq!(c.shape(), fc.shape());
832 for s in 0..2 {
833 for t in 0..2 {
834 assert!(
835 approx(c[(s, t)], fc[(s, t)], 1e-12),
836 "c[{s},{t}]={} fc={}",
837 c[(s, t)],
838 fc[(s, t)]
839 );
840 }
841 }
842 }
843
844 #[test]
845 fn test_cross_cov_hand_computed() {
846 let x = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0, 6.0, 8.0], 3, 2).unwrap();
850 let y = FdMatrix::from_column_major(vec![2.0, 4.0, 6.0, 10.0, 10.0, 13.0], 3, 2).unwrap();
851 let c = cross_covariance(&x, &y).unwrap();
852 assert!(approx(c[(0, 0)], 2.0, 1e-12));
858 assert!(approx(c[(0, 1)], 1.5, 1e-12));
859 assert!(approx(c[(1, 0)], 4.0, 1e-12));
860 assert!(approx(c[(1, 1)], 3.0, 1e-12));
861 }
862
863 #[test]
864 fn test_cross_cov_errors() {
865 let x = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0], 2, 2).unwrap();
866 let y3 = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], 3, 2).unwrap();
868 assert!(cross_covariance(&x, &y3).is_err());
869 let x1 = FdMatrix::from_column_major(vec![1.0, 2.0], 1, 2).unwrap();
871 let y1 = FdMatrix::from_column_major(vec![3.0, 4.0], 1, 2).unwrap();
872 assert!(cross_covariance(&x1, &y1).is_err());
873 let x0 = FdMatrix::zeros(3, 0);
875 let y0 = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 3, 1).unwrap();
876 assert!(cross_covariance(&x0, &y0).is_err());
877 }
878
879 #[test]
882 fn test_fpca_der_nderiv0() {
883 let data = FdMatrix::from_column_major(
885 (0..40)
886 .map(|i| (i as f64 * 0.13).sin() + (i as f64 * 0.02))
887 .collect(),
888 5,
889 8,
890 )
891 .unwrap();
892 let argvals: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
893 let a = fpca_der(&data, 3, &argvals, 0).unwrap();
894 let b = fdata_to_pc_1d(&data, 3, &argvals).unwrap();
895 assert_eq!(a.singular_values.len(), b.singular_values.len());
896 for k in 0..a.singular_values.len() {
897 assert!(
898 approx(a.singular_values[k], b.singular_values[k], 1e-12),
899 "sv[{k}]: {} vs {}",
900 a.singular_values[k],
901 b.singular_values[k]
902 );
903 }
904 let (m, nc) = a.rotation.shape();
905 for j in 0..m {
906 for k in 0..nc {
907 assert!(approx(a.rotation[(j, k)], b.rotation[(j, k)], 1e-12));
908 }
909 }
910 }
911
912 #[test]
913 fn test_fpca_der() {
914 let m = 40usize;
918 let n = 6usize;
919 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
920 let amps = [0.5, 1.0, 1.5, 2.0, 2.5, 3.0];
921 let mut vals = vec![0.0; n * m];
922 for j in 0..m {
923 for i in 0..n {
924 let t = argvals[j];
925 vals[i + j * n] = amps[i] * (2.0 * std::f64::consts::PI * t).sin();
926 }
927 }
928 let data = FdMatrix::from_column_major(vals, n, m).unwrap();
929 let res = fpca_der(&data, 1, &argvals, 1).unwrap();
930
931 let deriv = fdata::deriv_1d(&data, &argvals, 1);
934 let dc = fdata::center_1d(&deriv);
936 let mut sse = 0.0;
937 let mut sst = 0.0;
938 for i in 0..n {
939 for j in 0..m {
940 let recon = res.scores[(i, 0)] * res.rotation[(j, 0)];
941 let actual = dc[(i, j)];
942 sse += (actual - recon).powi(2);
943 sst += actual.powi(2);
944 }
945 }
946 assert!(
948 sse / sst < 1e-6,
949 "relative reconstruction error {}",
950 sse / sst
951 );
952 }
953
954 #[test]
955 fn test_fpca_der_errors() {
956 let argvals: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
957 let data = FdMatrix::from_column_major((0..40).map(|i| i as f64).collect(), 5, 8).unwrap();
958 assert!(fpca_der(&FdMatrix::zeros(0, 0), 1, &[], 1).is_err());
960 assert!(fpca_der(&data, 1, &argvals[..7], 1).is_err());
962 assert!(fpca_der(&data, 0, &argvals, 1).is_err());
964 let thin = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 3, 1).unwrap();
966 assert!(fpca_der(&thin, 1, &[0.0], 1).is_err());
967 }
968
969 fn sine_sample(n: usize, m: usize, seedish: f64) -> (FdMatrix, Vec<f64>) {
972 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
973 let mut vals = vec![0.0; n * m];
974 for i in 0..n {
975 for j in 0..m {
976 let t = argvals[j];
977 vals[i + j * n] =
978 ((i as f64 + 1.0) * seedish * t).sin() + 0.3 * (i as f64 + 1.0) * t;
979 }
980 }
981 (FdMatrix::from_column_major(vals, n, m).unwrap(), argvals)
982 }
983
984 #[test]
985 fn test_dyncorr_identical() {
986 let (data, argvals) = sine_sample(6, 20, 6.0);
987 let r = dynamical_correlation(&data, &data, &argvals).unwrap();
988 assert!(approx(r, 1.0, 1e-10), "dyncorr(x,x) = {r}");
989 }
990
991 #[test]
992 fn test_dyncorr_negated() {
993 let (data, argvals) = sine_sample(6, 20, 5.0);
994 let (n, m) = data.shape();
995 let mut neg = data.clone();
996 for i in 0..n {
997 for j in 0..m {
998 neg[(i, j)] = -data[(i, j)];
999 }
1000 }
1001 let r = dynamical_correlation(&data, &neg, &argvals).unwrap();
1002 assert!(approx(r, -1.0, 1e-10), "dyncorr(x,-x) = {r}");
1003 }
1004
1005 #[test]
1006 fn test_dyncorr_range() {
1007 let (x, argvals) = sine_sample(7, 25, 4.0);
1008 let (y, _) = sine_sample(7, 25, 9.0);
1009 let r = dynamical_correlation(&x, &y, &argvals).unwrap();
1010 assert!(
1011 (-1.0 - 1e-9..=1.0 + 1e-9).contains(&r),
1012 "dyncorr out of range: {r}"
1013 );
1014 }
1015
1016 #[test]
1017 fn test_dyncorr_errors() {
1018 let (x, argvals) = sine_sample(5, 10, 3.0);
1019 let (y6, _) = sine_sample(6, 10, 3.0);
1021 assert!(dynamical_correlation(&x, &y6, &argvals).is_err());
1022 let (yc, _) = sine_sample(5, 12, 3.0);
1024 assert!(dynamical_correlation(&x, &yc, &argvals).is_err());
1025 assert!(dynamical_correlation(&x, &x, &argvals[..9]).is_err());
1027 let (x1, a1) = sine_sample(1, 10, 3.0);
1029 assert!(dynamical_correlation(&x1, &x1, &a1).is_err());
1030 }
1031
1032 #[test]
1035 fn test_fsvd_unit_norm() {
1036 let (x, ax) = sine_sample(6, 15, 4.0);
1037 let (y, ay) = sine_sample(6, 12, 7.0);
1038 let res = fsvd(&x, &ax, &y, &ay, 2).unwrap();
1039 let wx = simpsons_weights(&ax);
1040 let wy = simpsons_weights(&ay);
1041 for comp in 0..res.singular_values.len() {
1042 let ln: f64 = (0..15)
1043 .map(|s| res.left_functions[(s, comp)].powi(2) * wx[s])
1044 .sum();
1045 let rn: f64 = (0..12)
1046 .map(|t| res.right_functions[(t, comp)].powi(2) * wy[t])
1047 .sum();
1048 assert!(approx(ln, 1.0, 1e-8), "left norm[{comp}]={ln}");
1049 assert!(approx(rn, 1.0, 1e-8), "right norm[{comp}]={rn}");
1050 }
1051 }
1052
1053 #[test]
1054 fn test_fsvd_rank1() {
1055 let n = 8usize;
1057 let px = 20usize;
1058 let qy = 18usize;
1059 let ax: Vec<f64> = (0..px).map(|i| i as f64 / (px as f64 - 1.0)).collect();
1060 let ay: Vec<f64> = (0..qy).map(|i| i as f64 / (qy as f64 - 1.0)).collect();
1061 let amps: Vec<f64> = (0..n).map(|i| 0.5 + i as f64 * 0.4).collect();
1062 let mut xv = vec![0.0; n * px];
1063 let mut yv = vec![0.0; n * qy];
1064 for i in 0..n {
1065 for j in 0..px {
1066 xv[i + j * n] = amps[i] * (std::f64::consts::PI * ax[j]).sin();
1067 }
1068 for j in 0..qy {
1069 yv[i + j * n] = amps[i] * (std::f64::consts::PI * ay[j]).cos();
1070 }
1071 }
1072 let x = FdMatrix::from_column_major(xv, n, px).unwrap();
1073 let y = FdMatrix::from_column_major(yv, n, qy).unwrap();
1074 let res = fsvd(&x, &ax, &y, &ay, 3).unwrap();
1075
1076 assert!(
1078 res.singular_values[0] > 1e6 * res.singular_values[1].max(1e-14),
1079 "not rank-1 dominant: {:?}",
1080 res.singular_values
1081 );
1082
1083 let c = cross_covariance(&x, &y).unwrap();
1087 let mut sse = 0.0;
1088 let mut sst = 0.0;
1089 for s in 0..px {
1090 for t in 0..qy {
1091 let mut recon = 0.0;
1092 for k in 0..res.singular_values.len() {
1093 recon += res.singular_values[k]
1094 * res.left_functions[(s, k)]
1095 * res.right_functions[(t, k)];
1096 }
1097 sse += (c[(s, t)] - recon).powi(2);
1098 sst += c[(s, t)].powi(2);
1099 }
1100 }
1101 assert!(
1102 sse / sst < 1e-6,
1103 "cross-cov reconstruction rel err {}",
1104 sse / sst
1105 );
1106 }
1107
1108 #[test]
1109 fn test_fsvd_wide_left_gram_branch() {
1110 let n = 8usize;
1113 let px = 14usize;
1114 let qy = 22usize;
1115 let ax: Vec<f64> = (0..px).map(|i| i as f64 / (px as f64 - 1.0)).collect();
1116 let ay: Vec<f64> = (0..qy).map(|i| i as f64 / (qy as f64 - 1.0)).collect();
1117 let amps: Vec<f64> = (0..n).map(|i| 0.5 + i as f64 * 0.4).collect();
1118 let mut xv = vec![0.0; n * px];
1119 let mut yv = vec![0.0; n * qy];
1120 for i in 0..n {
1121 for j in 0..px {
1122 xv[i + j * n] = amps[i] * (std::f64::consts::PI * ax[j]).sin();
1123 }
1124 for j in 0..qy {
1125 yv[i + j * n] = amps[i] * (std::f64::consts::PI * ay[j]).cos();
1126 }
1127 }
1128 let x = FdMatrix::from_column_major(xv, n, px).unwrap();
1129 let y = FdMatrix::from_column_major(yv, n, qy).unwrap();
1130 let res = fsvd(&x, &ax, &y, &ay, 2).unwrap();
1131 assert_eq!(res.left_functions.shape(), (px, 2));
1132 assert_eq!(res.right_functions.shape(), (qy, 2));
1133 let wx = simpsons_weights(&ax);
1135 let wy = simpsons_weights(&ay);
1136 assert!(approx(
1137 weighted_l2_sq(res.left_functions.column(0), &wx),
1138 1.0,
1139 1e-8
1140 ));
1141 assert!(approx(
1142 weighted_l2_sq(res.right_functions.column(0), &wy),
1143 1.0,
1144 1e-8
1145 ));
1146 let c = cross_covariance(&x, &y).unwrap();
1148 let mut sse = 0.0;
1149 let mut sst = 0.0;
1150 for s in 0..px {
1151 for t in 0..qy {
1152 let mut recon = 0.0;
1153 for k in 0..res.singular_values.len() {
1154 recon += res.singular_values[k]
1155 * res.left_functions[(s, k)]
1156 * res.right_functions[(t, k)];
1157 }
1158 sse += (c[(s, t)] - recon).powi(2);
1159 sst += c[(s, t)].powi(2);
1160 }
1161 }
1162 assert!(
1163 sse / sst < 1e-6,
1164 "wide reconstruction rel err {}",
1165 sse / sst
1166 );
1167 }
1168
1169 #[test]
1170 fn test_fsvd_errors() {
1171 let (x, ax) = sine_sample(5, 10, 3.0);
1172 let (y, ay) = sine_sample(5, 8, 4.0);
1173 let (y6, ay6) = sine_sample(6, 8, 4.0);
1175 assert!(fsvd(&x, &ax, &y6, &ay6, 1).is_err());
1176 assert!(fsvd(&x, &ax, &y, &ay, 0).is_err());
1178 assert!(fsvd(&x, &ax[..9], &y, &ay, 1).is_err());
1180 }
1181
1182 #[test]
1185 fn test_ssvd_dense_limit() {
1186 let (data, argvals) = sine_sample(8, 20, 5.0);
1187 let a = ssvd(&data, 3, &argvals, 1e-12).unwrap();
1188 let b = fdata_to_pc_1d(&data, 3, &argvals).unwrap();
1189 assert_eq!(a.singular_values.len(), b.singular_values.len());
1190 for k in 0..a.singular_values.len() {
1191 let rel = (a.singular_values[k] - b.singular_values[k]).abs()
1192 / b.singular_values[k].abs().max(1e-12);
1193 assert!(
1194 rel < 1e-4,
1195 "sv[{k}] dense-limit mismatch: {} vs {} (rel {})",
1196 a.singular_values[k],
1197 b.singular_values[k],
1198 rel
1199 );
1200 }
1201 }
1202
1203 #[test]
1204 fn test_ssvd_orthonormality() {
1205 let (data, argvals) = sine_sample(8, 20, 5.0);
1206 let res = ssvd(&data, 3, &argvals, 0.05).unwrap();
1207 let w = simpsons_weights(&argvals);
1208 let nc = res.rotation.shape().1;
1209 for a in 0..nc {
1210 for b in 0..nc {
1211 let ip: f64 = (0..20)
1212 .map(|j| res.rotation[(j, a)] * res.rotation[(j, b)] * w[j])
1213 .sum();
1214 let expected = if a == b { 1.0 } else { 0.0 };
1215 assert!(approx(ip, expected, 1e-6), "⟨φ{a},φ{b}⟩={ip}");
1216 }
1217 }
1218 }
1219
1220 #[test]
1221 fn test_ssvd_errors() {
1222 let (data, argvals) = sine_sample(5, 10, 3.0);
1223 assert!(ssvd(&data, 0, &argvals, 0.1).is_err());
1225 assert!(ssvd(&FdMatrix::zeros(0, 0), 1, &[], 0.1).is_err());
1227 assert!(ssvd(&data, 1, &argvals[..9], 0.1).is_err());
1229 assert!(ssvd(&data, 1, &argvals, -0.1).is_err());
1231 }
1232
1233 #[test]
1236 fn smoke_reexports() {
1237 let (x, argvals) = sine_sample(4, 6, 4.0);
1239 let (y, ay) = sine_sample(4, 6, 6.0);
1240 let _c = crate::cross_covariance(&x, &y).unwrap();
1241 let _f = crate::fpca_der(&x, 1, &argvals, 1).unwrap();
1242 let _d = crate::dynamical_correlation(&x, &y, &argvals).unwrap();
1243 let _s = crate::fsvd(&x, &argvals, &y, &ay, 1).unwrap();
1244 let _v = crate::ssvd(&x, 1, &argvals, 0.1).unwrap();
1245 let _first: &crate::FsvdResult = &_s;
1247 let w = simpsons_weights(&argvals);
1249 let _ = weighted_l2_sq(x.column(0), &w);
1250 }
1251}