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 g_dim = q.min(p);
472 let mut gram = vec![0.0_f64; g_dim * g_dim];
473 if gram_on_right {
474 for a in 0..q {
476 for b in 0..q {
477 gram[a + b * q] = (0..p).map(|s| cw[(s, a)] * cw[(s, b)]).sum();
478 }
479 }
480 } else {
481 for a in 0..p {
483 for b in 0..p {
484 gram[a + b * p] = (0..q).map(|t| cw[(a, t)] * cw[(b, t)]).sum();
485 }
486 }
487 }
488 let eigen = DMatrix::from_column_slice(g_dim, g_dim, &gram).symmetric_eigen();
489 let mut pairs: Vec<(f64, usize)> = (0..eigen.eigenvalues.len())
490 .map(|idx| (eigen.eigenvalues[idx], idx))
491 .collect();
492 pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
493 let pairs: Vec<(f64, usize)> = pairs.into_iter().take(k).collect();
494
495 let mut singular_values = Vec::with_capacity(k);
497 let mut left = FdMatrix::zeros(p, k);
498 let mut right = FdMatrix::zeros(q, k);
499 for (comp, &(lam, col_idx)) in pairs.iter().enumerate() {
500 let sigma = lam.max(0.0).sqrt();
501 singular_values.push(sigma);
502 let mut uk = vec![0.0_f64; p];
504 let mut vk = vec![0.0_f64; q];
505 if gram_on_right {
506 for t in 0..q {
508 vk[t] = eigen.eigenvectors[(t, col_idx)];
509 }
510 if sigma > 1e-12 {
511 for s in 0..p {
512 uk[s] = (0..q).map(|t| cw[(s, t)] * vk[t]).sum::<f64>() / sigma;
513 }
514 }
515 } else {
516 for s in 0..p {
518 uk[s] = eigen.eigenvectors[(s, col_idx)];
519 }
520 if sigma > 1e-12 {
521 for t in 0..q {
522 vk[t] = (0..p).map(|s| cw[(s, t)] * uk[s]).sum::<f64>() / sigma;
523 }
524 }
525 }
526 for s in 0..p {
528 left[(s, comp)] = if sqrt_wx[s] > 1e-15 {
529 uk[s] / sqrt_wx[s]
530 } else {
531 uk[s]
532 };
533 }
534 for t in 0..q {
535 right[(t, comp)] = if sqrt_wy[t] > 1e-15 {
536 vk[t] / sqrt_wy[t]
537 } else {
538 vk[t]
539 };
540 }
541 }
542
543 for comp in 0..k {
545 let s_max = (0..p)
546 .max_by(|&a, &b| {
547 left[(a, comp)]
548 .abs()
549 .partial_cmp(&left[(b, comp)].abs())
550 .unwrap_or(std::cmp::Ordering::Equal)
551 })
552 .unwrap_or(0);
553 if left[(s_max, comp)] < 0.0 {
554 for s in 0..p {
555 left[(s, comp)] = -left[(s, comp)];
556 }
557 for t in 0..q {
558 right[(t, comp)] = -right[(t, comp)];
559 }
560 }
561 }
562
563 let xc = fdata::center_1d(x);
565 let yc = fdata::center_1d(y);
566 let mut left_scores = FdMatrix::zeros(nx, k);
567 let mut right_scores = FdMatrix::zeros(nx, k);
568 for comp in 0..k {
569 for i in 0..nx {
570 left_scores[(i, comp)] = (0..p)
571 .map(|s| xc[(i, s)] * left[(s, comp)] * wx[s])
572 .sum::<f64>();
573 right_scores[(i, comp)] = (0..q)
574 .map(|t| yc[(i, t)] * right[(t, comp)] * wy[t])
575 .sum::<f64>();
576 }
577 }
578
579 Ok(FsvdResult {
580 singular_values,
581 left_functions: left,
582 right_functions: right,
583 left_scores,
584 right_scores,
585 })
586}
587
588pub(crate) fn gaussian_smooth_cov(cov: &FdMatrix, argvals: &[f64], bandwidth: f64) -> FdMatrix {
593 let m = argvals.len();
594 let mut kernel = vec![0.0_f64; m * m];
596 for a in 0..m {
597 let mut row_sum = 0.0;
598 for b in 0..m {
599 let kv = gaussian_kernel((argvals[a] - argvals[b]).abs(), bandwidth);
600 kernel[a + b * m] = kv;
601 row_sum += kv;
602 }
603 if row_sum > 1e-15 {
604 for b in 0..m {
605 kernel[a + b * m] /= row_sum;
606 }
607 }
608 }
609 let mut tmp = FdMatrix::zeros(m, m);
611 for i in 0..m {
612 for j in 0..m {
613 let mut acc = 0.0;
614 for a in 0..m {
615 acc += kernel[i + a * m] * cov[(a, j)];
616 }
617 tmp[(i, j)] = acc;
618 }
619 }
620 let mut out = FdMatrix::zeros(m, m);
622 for i in 0..m {
623 for j in 0..m {
624 let mut acc = 0.0;
625 for b in 0..m {
626 acc += tmp[(i, b)] * kernel[j + b * m];
627 }
628 out[(i, j)] = acc;
629 }
630 }
631 out
632}
633
634#[must_use = "ssvd returns the smoothed FPCA result; ignoring it wastes the computation"]
670pub fn ssvd(
671 data: &FdMatrix,
672 ncomp: usize,
673 argvals: &[f64],
674 bandwidth: f64,
675) -> Result<FpcaResult, FdarError> {
676 let (n, m) = data.shape();
677 if n == 0 {
678 return Err(FdarError::InvalidDimension {
679 parameter: "data",
680 expected: "n > 0 rows".to_string(),
681 actual: format!("n = {n}"),
682 });
683 }
684 if m == 0 {
685 return Err(FdarError::InvalidDimension {
686 parameter: "data",
687 expected: "m > 0 columns".to_string(),
688 actual: format!("m = {m}"),
689 });
690 }
691 if argvals.len() != m {
692 return Err(FdarError::InvalidDimension {
693 parameter: "argvals",
694 expected: format!("{m} elements"),
695 actual: format!("{} elements", argvals.len()),
696 });
697 }
698 if ncomp < 1 {
699 return Err(FdarError::InvalidParameter {
700 parameter: "ncomp",
701 message: format!("ncomp must be >= 1, got {ncomp}"),
702 });
703 }
704 if bandwidth < 0.0 || bandwidth.is_nan() {
705 return Err(FdarError::InvalidParameter {
706 parameter: "bandwidth",
707 message: format!("bandwidth must be >= 0, got {bandwidth}"),
708 });
709 }
710
711 let (centered, means) = {
713 let c = fdata::center_1d(data);
714 let mut means = vec![0.0; m];
715 for j in 0..m {
716 means[j] = (0..n).map(|i| data[(i, j)]).sum::<f64>() / n as f64;
717 }
718 (c, means)
719 };
720
721 let emp = fdata::functional_covariance(data)?;
723
724 let smooth_cov = if bandwidth <= 1e-10 {
726 emp
727 } else {
728 gaussian_smooth_cov(&emp, argvals, bandwidth)
729 };
730
731 let w = simpsons_weights(argvals);
733 let sqrt_w: Vec<f64> = w.iter().map(|v| v.sqrt()).collect();
734 let mut c_scaled = vec![0.0_f64; m * m];
735 for col in 0..m {
736 for row in 0..m {
737 c_scaled[row + col * m] = sqrt_w[row] * smooth_cov[(row, col)] * sqrt_w[col];
738 }
739 }
740 let eigen = DMatrix::from_column_slice(m, m, &c_scaled).symmetric_eigen();
741 let mut pairs: Vec<(f64, usize)> = (0..eigen.eigenvalues.len())
742 .map(|k| (eigen.eigenvalues[k], k))
743 .collect();
744 pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
745 let pairs: Vec<(f64, usize)> = pairs
746 .into_iter()
747 .filter(|&(lam, _)| lam > 0.0)
748 .take(ncomp)
749 .collect();
750 let actual = pairs.len();
751
752 let denom = (n - 1) as f64;
753 let mut singular_values = Vec::with_capacity(actual);
754 let mut rotation = FdMatrix::zeros(m, actual);
755 for (comp, &(lam, col_idx)) in pairs.iter().enumerate() {
756 singular_values.push((lam * denom).sqrt());
758 for j in 0..m {
759 let raw = eigen.eigenvectors[(j, col_idx)];
760 rotation[(j, comp)] = if sqrt_w[j] > 1e-15 {
761 raw / sqrt_w[j]
762 } else {
763 raw
764 };
765 }
766 }
767 for comp in 0..actual {
769 let j_max = (0..m)
770 .max_by(|&a, &b| {
771 rotation[(a, comp)]
772 .abs()
773 .partial_cmp(&rotation[(b, comp)].abs())
774 .unwrap_or(std::cmp::Ordering::Equal)
775 })
776 .unwrap_or(0);
777 if rotation[(j_max, comp)] < 0.0 {
778 for j in 0..m {
779 rotation[(j, comp)] = -rotation[(j, comp)];
780 }
781 }
782 }
783
784 let mut scores = FdMatrix::zeros(n, actual);
786 for comp in 0..actual {
787 for i in 0..n {
788 scores[(i, comp)] = (0..m)
789 .map(|j| centered[(i, j)] * rotation[(j, comp)] * w[j])
790 .sum::<f64>();
791 }
792 }
793
794 Ok(FpcaResult {
795 singular_values,
796 rotation,
797 scores,
798 mean: means,
799 centered,
800 weights: w,
801 })
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807
808 fn weighted_l2_sq(c: &[f64], w: &[f64]) -> f64 {
810 c.iter().zip(w.iter()).map(|(&v, &wj)| v * v * wj).sum()
811 }
812
813 fn approx(a: f64, b: f64, tol: f64) -> bool {
814 (a - b).abs() < tol
815 }
816
817 #[test]
820 fn test_cross_cov_shape() {
821 let x = FdMatrix::from_column_major((0..8).map(|i| i as f64).collect(), 4, 2).unwrap();
823 let y =
824 FdMatrix::from_column_major((0..12).map(|i| (i as f64).sin()).collect(), 4, 3).unwrap();
825 let c = cross_covariance(&x, &y).unwrap();
826 assert_eq!(c.shape(), (2, 3));
827 }
828
829 #[test]
830 fn test_cross_cov_self() {
831 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)
833 .unwrap();
834 let c = cross_covariance(&x, &x).unwrap();
835 let fc = fdata::functional_covariance(&x).unwrap();
836 assert_eq!(c.shape(), fc.shape());
837 for s in 0..2 {
838 for t in 0..2 {
839 assert!(
840 approx(c[(s, t)], fc[(s, t)], 1e-12),
841 "c[{s},{t}]={} fc={}",
842 c[(s, t)],
843 fc[(s, t)]
844 );
845 }
846 }
847 }
848
849 #[test]
850 fn test_cross_cov_hand_computed() {
851 let x = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0, 6.0, 8.0], 3, 2).unwrap();
855 let y = FdMatrix::from_column_major(vec![2.0, 4.0, 6.0, 10.0, 10.0, 13.0], 3, 2).unwrap();
856 let c = cross_covariance(&x, &y).unwrap();
857 assert!(approx(c[(0, 0)], 2.0, 1e-12));
863 assert!(approx(c[(0, 1)], 1.5, 1e-12));
864 assert!(approx(c[(1, 0)], 4.0, 1e-12));
865 assert!(approx(c[(1, 1)], 3.0, 1e-12));
866 }
867
868 #[test]
869 fn test_cross_cov_errors() {
870 let x = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0], 2, 2).unwrap();
871 let y3 = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], 3, 2).unwrap();
873 assert!(cross_covariance(&x, &y3).is_err());
874 let x1 = FdMatrix::from_column_major(vec![1.0, 2.0], 1, 2).unwrap();
876 let y1 = FdMatrix::from_column_major(vec![3.0, 4.0], 1, 2).unwrap();
877 assert!(cross_covariance(&x1, &y1).is_err());
878 let x0 = FdMatrix::zeros(3, 0);
880 let y0 = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 3, 1).unwrap();
881 assert!(cross_covariance(&x0, &y0).is_err());
882 }
883
884 #[test]
887 fn test_fpca_der_nderiv0() {
888 let data = FdMatrix::from_column_major(
890 (0..40)
891 .map(|i| (i as f64 * 0.13).sin() + (i as f64 * 0.02))
892 .collect(),
893 5,
894 8,
895 )
896 .unwrap();
897 let argvals: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
898 let a = fpca_der(&data, 3, &argvals, 0).unwrap();
899 let b = fdata_to_pc_1d(&data, 3, &argvals).unwrap();
900 assert_eq!(a.singular_values.len(), b.singular_values.len());
901 for k in 0..a.singular_values.len() {
902 assert!(
903 approx(a.singular_values[k], b.singular_values[k], 1e-12),
904 "sv[{k}]: {} vs {}",
905 a.singular_values[k],
906 b.singular_values[k]
907 );
908 }
909 let (m, nc) = a.rotation.shape();
910 for j in 0..m {
911 for k in 0..nc {
912 assert!(approx(a.rotation[(j, k)], b.rotation[(j, k)], 1e-12));
913 }
914 }
915 }
916
917 #[test]
918 fn test_fpca_der() {
919 let m = 40usize;
923 let n = 6usize;
924 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
925 let amps = [0.5, 1.0, 1.5, 2.0, 2.5, 3.0];
926 let mut vals = vec![0.0; n * m];
927 for j in 0..m {
928 for i in 0..n {
929 let t = argvals[j];
930 vals[i + j * n] = amps[i] * (2.0 * std::f64::consts::PI * t).sin();
931 }
932 }
933 let data = FdMatrix::from_column_major(vals, n, m).unwrap();
934 let res = fpca_der(&data, 1, &argvals, 1).unwrap();
935
936 let deriv = fdata::deriv_1d(&data, &argvals, 1);
939 let dc = fdata::center_1d(&deriv);
941 let mut sse = 0.0;
942 let mut sst = 0.0;
943 for i in 0..n {
944 for j in 0..m {
945 let recon = res.scores[(i, 0)] * res.rotation[(j, 0)];
946 let actual = dc[(i, j)];
947 sse += (actual - recon).powi(2);
948 sst += actual.powi(2);
949 }
950 }
951 assert!(
953 sse / sst < 1e-6,
954 "relative reconstruction error {}",
955 sse / sst
956 );
957 }
958
959 #[test]
960 fn test_fpca_der_errors() {
961 let argvals: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
962 let data = FdMatrix::from_column_major((0..40).map(|i| i as f64).collect(), 5, 8).unwrap();
963 assert!(fpca_der(&FdMatrix::zeros(0, 0), 1, &[], 1).is_err());
965 assert!(fpca_der(&data, 1, &argvals[..7], 1).is_err());
967 assert!(fpca_der(&data, 0, &argvals, 1).is_err());
969 let thin = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0], 3, 1).unwrap();
971 assert!(fpca_der(&thin, 1, &[0.0], 1).is_err());
972 }
973
974 fn sine_sample(n: usize, m: usize, seedish: f64) -> (FdMatrix, Vec<f64>) {
977 let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
978 let mut vals = vec![0.0; n * m];
979 for i in 0..n {
980 for j in 0..m {
981 let t = argvals[j];
982 vals[i + j * n] =
983 ((i as f64 + 1.0) * seedish * t).sin() + 0.3 * (i as f64 + 1.0) * t;
984 }
985 }
986 (FdMatrix::from_column_major(vals, n, m).unwrap(), argvals)
987 }
988
989 #[test]
990 fn test_dyncorr_identical() {
991 let (data, argvals) = sine_sample(6, 20, 6.0);
992 let r = dynamical_correlation(&data, &data, &argvals).unwrap();
993 assert!(approx(r, 1.0, 1e-10), "dyncorr(x,x) = {r}");
994 }
995
996 #[test]
997 fn test_dyncorr_negated() {
998 let (data, argvals) = sine_sample(6, 20, 5.0);
999 let (n, m) = data.shape();
1000 let mut neg = data.clone();
1001 for i in 0..n {
1002 for j in 0..m {
1003 neg[(i, j)] = -data[(i, j)];
1004 }
1005 }
1006 let r = dynamical_correlation(&data, &neg, &argvals).unwrap();
1007 assert!(approx(r, -1.0, 1e-10), "dyncorr(x,-x) = {r}");
1008 }
1009
1010 #[test]
1011 fn test_dyncorr_range() {
1012 let (x, argvals) = sine_sample(7, 25, 4.0);
1013 let (y, _) = sine_sample(7, 25, 9.0);
1014 let r = dynamical_correlation(&x, &y, &argvals).unwrap();
1015 assert!(
1016 (-1.0 - 1e-9..=1.0 + 1e-9).contains(&r),
1017 "dyncorr out of range: {r}"
1018 );
1019 }
1020
1021 #[test]
1022 fn test_dyncorr_errors() {
1023 let (x, argvals) = sine_sample(5, 10, 3.0);
1024 let (y6, _) = sine_sample(6, 10, 3.0);
1026 assert!(dynamical_correlation(&x, &y6, &argvals).is_err());
1027 let (yc, _) = sine_sample(5, 12, 3.0);
1029 assert!(dynamical_correlation(&x, &yc, &argvals).is_err());
1030 assert!(dynamical_correlation(&x, &x, &argvals[..9]).is_err());
1032 let (x1, a1) = sine_sample(1, 10, 3.0);
1034 assert!(dynamical_correlation(&x1, &x1, &a1).is_err());
1035 }
1036
1037 #[test]
1040 fn test_fsvd_unit_norm() {
1041 let (x, ax) = sine_sample(6, 15, 4.0);
1042 let (y, ay) = sine_sample(6, 12, 7.0);
1043 let res = fsvd(&x, &ax, &y, &ay, 2).unwrap();
1044 let wx = simpsons_weights(&ax);
1045 let wy = simpsons_weights(&ay);
1046 for comp in 0..res.singular_values.len() {
1047 let ln: f64 = (0..15)
1048 .map(|s| res.left_functions[(s, comp)].powi(2) * wx[s])
1049 .sum();
1050 let rn: f64 = (0..12)
1051 .map(|t| res.right_functions[(t, comp)].powi(2) * wy[t])
1052 .sum();
1053 assert!(approx(ln, 1.0, 1e-8), "left norm[{comp}]={ln}");
1054 assert!(approx(rn, 1.0, 1e-8), "right norm[{comp}]={rn}");
1055 }
1056 }
1057
1058 #[test]
1059 fn test_fsvd_rank1() {
1060 let n = 8usize;
1062 let px = 20usize;
1063 let qy = 18usize;
1064 let ax: Vec<f64> = (0..px).map(|i| i as f64 / (px as f64 - 1.0)).collect();
1065 let ay: Vec<f64> = (0..qy).map(|i| i as f64 / (qy as f64 - 1.0)).collect();
1066 let amps: Vec<f64> = (0..n).map(|i| 0.5 + i as f64 * 0.4).collect();
1067 let mut xv = vec![0.0; n * px];
1068 let mut yv = vec![0.0; n * qy];
1069 for i in 0..n {
1070 for j in 0..px {
1071 xv[i + j * n] = amps[i] * (std::f64::consts::PI * ax[j]).sin();
1072 }
1073 for j in 0..qy {
1074 yv[i + j * n] = amps[i] * (std::f64::consts::PI * ay[j]).cos();
1075 }
1076 }
1077 let x = FdMatrix::from_column_major(xv, n, px).unwrap();
1078 let y = FdMatrix::from_column_major(yv, n, qy).unwrap();
1079 let res = fsvd(&x, &ax, &y, &ay, 3).unwrap();
1080
1081 assert!(
1083 res.singular_values[0] > 1e6 * res.singular_values[1].max(1e-14),
1084 "not rank-1 dominant: {:?}",
1085 res.singular_values
1086 );
1087
1088 let c = cross_covariance(&x, &y).unwrap();
1092 let mut sse = 0.0;
1093 let mut sst = 0.0;
1094 for s in 0..px {
1095 for t in 0..qy {
1096 let mut recon = 0.0;
1097 for k in 0..res.singular_values.len() {
1098 recon += res.singular_values[k]
1099 * res.left_functions[(s, k)]
1100 * res.right_functions[(t, k)];
1101 }
1102 sse += (c[(s, t)] - recon).powi(2);
1103 sst += c[(s, t)].powi(2);
1104 }
1105 }
1106 assert!(
1107 sse / sst < 1e-6,
1108 "cross-cov reconstruction rel err {}",
1109 sse / sst
1110 );
1111 }
1112
1113 #[test]
1114 fn test_fsvd_wide_left_gram_branch() {
1115 let n = 8usize;
1118 let px = 14usize;
1119 let qy = 22usize;
1120 let ax: Vec<f64> = (0..px).map(|i| i as f64 / (px as f64 - 1.0)).collect();
1121 let ay: Vec<f64> = (0..qy).map(|i| i as f64 / (qy as f64 - 1.0)).collect();
1122 let amps: Vec<f64> = (0..n).map(|i| 0.5 + i as f64 * 0.4).collect();
1123 let mut xv = vec![0.0; n * px];
1124 let mut yv = vec![0.0; n * qy];
1125 for i in 0..n {
1126 for j in 0..px {
1127 xv[i + j * n] = amps[i] * (std::f64::consts::PI * ax[j]).sin();
1128 }
1129 for j in 0..qy {
1130 yv[i + j * n] = amps[i] * (std::f64::consts::PI * ay[j]).cos();
1131 }
1132 }
1133 let x = FdMatrix::from_column_major(xv, n, px).unwrap();
1134 let y = FdMatrix::from_column_major(yv, n, qy).unwrap();
1135 let res = fsvd(&x, &ax, &y, &ay, 2).unwrap();
1136 assert_eq!(res.left_functions.shape(), (px, 2));
1137 assert_eq!(res.right_functions.shape(), (qy, 2));
1138 let wx = simpsons_weights(&ax);
1140 let wy = simpsons_weights(&ay);
1141 assert!(approx(
1142 weighted_l2_sq(res.left_functions.column(0), &wx),
1143 1.0,
1144 1e-8
1145 ));
1146 assert!(approx(
1147 weighted_l2_sq(res.right_functions.column(0), &wy),
1148 1.0,
1149 1e-8
1150 ));
1151 let c = cross_covariance(&x, &y).unwrap();
1153 let mut sse = 0.0;
1154 let mut sst = 0.0;
1155 for s in 0..px {
1156 for t in 0..qy {
1157 let mut recon = 0.0;
1158 for k in 0..res.singular_values.len() {
1159 recon += res.singular_values[k]
1160 * res.left_functions[(s, k)]
1161 * res.right_functions[(t, k)];
1162 }
1163 sse += (c[(s, t)] - recon).powi(2);
1164 sst += c[(s, t)].powi(2);
1165 }
1166 }
1167 assert!(
1168 sse / sst < 1e-6,
1169 "wide reconstruction rel err {}",
1170 sse / sst
1171 );
1172 }
1173
1174 #[test]
1175 fn test_fsvd_errors() {
1176 let (x, ax) = sine_sample(5, 10, 3.0);
1177 let (y, ay) = sine_sample(5, 8, 4.0);
1178 let (y6, ay6) = sine_sample(6, 8, 4.0);
1180 assert!(fsvd(&x, &ax, &y6, &ay6, 1).is_err());
1181 assert!(fsvd(&x, &ax, &y, &ay, 0).is_err());
1183 assert!(fsvd(&x, &ax[..9], &y, &ay, 1).is_err());
1185 }
1186
1187 #[test]
1190 fn test_ssvd_dense_limit() {
1191 let (data, argvals) = sine_sample(8, 20, 5.0);
1192 let a = ssvd(&data, 3, &argvals, 1e-12).unwrap();
1193 let b = fdata_to_pc_1d(&data, 3, &argvals).unwrap();
1194 assert_eq!(a.singular_values.len(), b.singular_values.len());
1195 for k in 0..a.singular_values.len() {
1196 let rel = (a.singular_values[k] - b.singular_values[k]).abs()
1197 / b.singular_values[k].abs().max(1e-12);
1198 assert!(
1199 rel < 1e-4,
1200 "sv[{k}] dense-limit mismatch: {} vs {} (rel {})",
1201 a.singular_values[k],
1202 b.singular_values[k],
1203 rel
1204 );
1205 }
1206 }
1207
1208 #[test]
1209 fn test_ssvd_orthonormality() {
1210 let (data, argvals) = sine_sample(8, 20, 5.0);
1211 let res = ssvd(&data, 3, &argvals, 0.05).unwrap();
1212 let w = simpsons_weights(&argvals);
1213 let nc = res.rotation.shape().1;
1214 for a in 0..nc {
1215 for b in 0..nc {
1216 let ip: f64 = (0..20)
1217 .map(|j| res.rotation[(j, a)] * res.rotation[(j, b)] * w[j])
1218 .sum();
1219 let expected = if a == b { 1.0 } else { 0.0 };
1220 assert!(approx(ip, expected, 1e-6), "⟨φ{a},φ{b}⟩={ip}");
1221 }
1222 }
1223 }
1224
1225 #[test]
1226 fn test_ssvd_errors() {
1227 let (data, argvals) = sine_sample(5, 10, 3.0);
1228 assert!(ssvd(&data, 0, &argvals, 0.1).is_err());
1230 assert!(ssvd(&FdMatrix::zeros(0, 0), 1, &[], 0.1).is_err());
1232 assert!(ssvd(&data, 1, &argvals[..9], 0.1).is_err());
1234 assert!(ssvd(&data, 1, &argvals, -0.1).is_err());
1236 }
1237
1238 #[test]
1241 fn smoke_reexports() {
1242 let (x, argvals) = sine_sample(4, 6, 4.0);
1244 let (y, ay) = sine_sample(4, 6, 6.0);
1245 let _c = crate::cross_covariance(&x, &y).unwrap();
1246 let _f = crate::fpca_der(&x, 1, &argvals, 1).unwrap();
1247 let _d = crate::dynamical_correlation(&x, &y, &argvals).unwrap();
1248 let _s = crate::fsvd(&x, &argvals, &y, &ay, 1).unwrap();
1249 let _v = crate::ssvd(&x, 1, &argvals, 0.1).unwrap();
1250 let _first: &crate::FsvdResult = &_s;
1252 let w = simpsons_weights(&argvals);
1254 let _ = weighted_l2_sq(x.column(0), &w);
1255 }
1256}