1use crate::alignment::{srsf_inverse, srsf_transform, KarcherMeanResult};
13
14use crate::matrix::FdMatrix;
15use crate::warping::{exp_map_sphere, inv_exp_map_sphere, l2_norm_l2, psi_to_gam};
16use nalgebra::SVD;
17
18#[derive(Debug, Clone, PartialEq)]
22#[non_exhaustive]
23pub struct VertFpcaResult {
24 pub scores: FdMatrix,
26 pub eigenfunctions_q: FdMatrix,
28 pub eigenfunctions_f: FdMatrix,
30 pub eigenvalues: Vec<f64>,
32 pub cumulative_variance: Vec<f64>,
34 pub mean_q: Vec<f64>,
36}
37
38#[derive(Debug, Clone, PartialEq)]
40#[non_exhaustive]
41pub struct HorizFpcaResult {
42 pub scores: FdMatrix,
44 pub eigenfunctions_psi: FdMatrix,
46 pub eigenfunctions_gam: FdMatrix,
48 pub eigenvalues: Vec<f64>,
50 pub cumulative_variance: Vec<f64>,
52 pub mean_psi: Vec<f64>,
54 pub shooting_vectors: FdMatrix,
56}
57
58#[derive(Debug, Clone, PartialEq)]
60#[non_exhaustive]
61pub struct JointFpcaResult {
62 pub scores: FdMatrix,
64 pub eigenvalues: Vec<f64>,
66 pub cumulative_variance: Vec<f64>,
68 pub balance_c: f64,
70 pub vert_component: FdMatrix,
72 pub horiz_component: FdMatrix,
74}
75
76pub fn vert_fpca(
90 karcher: &KarcherMeanResult,
91 argvals: &[f64],
92 ncomp: usize,
93) -> Result<VertFpcaResult, crate::FdarError> {
94 let (n, m) = karcher.aligned_data.shape();
95 if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
96 return Err(crate::FdarError::InvalidDimension {
97 parameter: "aligned_data/argvals",
98 expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
99 actual: format!(
100 "n={}, m={}, ncomp={}, argvals.len()={}",
101 n,
102 m,
103 ncomp,
104 argvals.len()
105 ),
106 });
107 }
108 let ncomp = ncomp.min(n - 1).min(m);
109 let m_aug = m + 1;
110
111 let qn = match &karcher.aligned_srsfs {
112 Some(srsfs) => srsfs.clone(),
113 None => srsf_transform(&karcher.aligned_data, argvals),
114 };
115
116 let q_aug = build_augmented_srsfs(&qn, &karcher.aligned_data, n, m);
117
118 let (_, mean_q) = center_matrix(&q_aug, n, m_aug);
120 let k_mat = build_symmetric_covariance(&q_aug, &mean_q, n, m_aug);
121
122 let svd = SVD::new(k_mat, true, true);
123 let u_cov = svd
124 .u
125 .as_ref()
126 .ok_or_else(|| crate::FdarError::ComputationFailed {
127 operation: "SVD",
128 detail: "SVD failed to compute U matrix; check for constant or zero-variance aligned functions".to_string(),
129 })?;
130
131 let eigenvalues: Vec<f64> = svd.singular_values.iter().take(ncomp).copied().collect();
132 let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
133
134 let mut eigenfunctions_q = FdMatrix::zeros(ncomp, m_aug);
136 for k in 0..ncomp {
137 for j in 0..m_aug {
138 eigenfunctions_q[(k, j)] = u_cov[(j, k)];
139 }
140 }
141
142 let scores = project_onto_eigenvectors(&q_aug, &mean_q, u_cov, n, m_aug, ncomp);
144
145 let mut eigenfunctions_f = FdMatrix::zeros(ncomp, m);
147 for k in 0..ncomp {
148 let q_k: Vec<f64> = (0..m)
149 .map(|j| mean_q[j] + eigenfunctions_q[(k, j)])
150 .collect();
151 let aug_val = mean_q[m] + eigenfunctions_q[(k, m)];
152 let f0 = aug_val.signum() * aug_val * aug_val;
153 let f_k = srsf_inverse(&q_k, argvals, f0);
154 for j in 0..m {
155 eigenfunctions_f[(k, j)] = f_k[j];
156 }
157 }
158
159 Ok(VertFpcaResult {
160 scores,
161 eigenfunctions_q,
162 eigenfunctions_f,
163 eigenvalues,
164 cumulative_variance,
165 mean_q,
166 })
167}
168
169pub fn horiz_fpca(
183 karcher: &KarcherMeanResult,
184 argvals: &[f64],
185 ncomp: usize,
186) -> Result<HorizFpcaResult, crate::FdarError> {
187 let (n, m) = karcher.gammas.shape();
188 if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
189 return Err(crate::FdarError::InvalidDimension {
190 parameter: "gammas/argvals",
191 expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
192 actual: format!(
193 "n={}, m={}, ncomp={}, argvals.len()={}",
194 n,
195 m,
196 ncomp,
197 argvals.len()
198 ),
199 });
200 }
201 let ncomp = ncomp.min(n - 1).min(m);
202
203 let t0 = argvals[0];
204 let domain = argvals[m - 1] - t0;
205 let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
206
207 let psis = warps_to_normalized_psi(&karcher.gammas, argvals);
208 let mu_psi = sphere_karcher_mean(&psis, &time, 50);
209 let shooting = shooting_vectors_from_psis(&psis, &mu_psi, &time);
210
211 let (centered, _mean_v) = center_matrix(&shooting, n, m);
213
214 let svd = SVD::new(centered.to_dmatrix(), true, true);
215 let v_t = svd
216 .v_t
217 .as_ref()
218 .ok_or_else(|| crate::FdarError::ComputationFailed {
219 operation: "SVD",
220 detail:
221 "SVD failed to compute V^T matrix; check for constant or zero-variance functions"
222 .to_string(),
223 })?;
224 let (scores, eigenvalues) = svd_scores_and_eigenvalues(&svd, ncomp, n).ok_or_else(|| {
225 crate::FdarError::ComputationFailed {
226 operation: "SVD",
227 detail: "SVD failed to compute scores; try reducing ncomp or check for degenerate input data".to_string(),
228 }
229 })?;
230 let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
231
232 let mut eigenfunctions_psi = FdMatrix::zeros(ncomp, m);
234 for k in 0..ncomp {
235 for j in 0..m {
236 eigenfunctions_psi[(k, j)] = v_t[(k, j)];
237 }
238 }
239
240 let mut eigenfunctions_gam = FdMatrix::zeros(ncomp, m);
242 for k in 0..ncomp {
243 let v_k: Vec<f64> = (0..m).map(|j| eigenfunctions_psi[(k, j)]).collect();
244 let psi_k = exp_map_sphere(&mu_psi, &v_k, &time);
245 let gam_k = psi_to_gam(&psi_k, &time);
246 for j in 0..m {
247 eigenfunctions_gam[(k, j)] = t0 + gam_k[j] * domain;
248 }
249 }
250
251 Ok(HorizFpcaResult {
252 scores,
253 eigenfunctions_psi,
254 eigenfunctions_gam,
255 eigenvalues,
256 cumulative_variance,
257 mean_psi: mu_psi,
258 shooting_vectors: shooting,
259 })
260}
261
262pub fn joint_fpca(
275 karcher: &KarcherMeanResult,
276 argvals: &[f64],
277 ncomp: usize,
278 balance_c: Option<f64>,
279) -> Result<JointFpcaResult, crate::FdarError> {
280 let (n, m) = karcher.aligned_data.shape();
281 if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
282 return Err(crate::FdarError::InvalidDimension {
283 parameter: "aligned_data/argvals",
284 expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
285 actual: format!(
286 "n={}, m={}, ncomp={}, argvals.len()={}",
287 n,
288 m,
289 ncomp,
290 argvals.len()
291 ),
292 });
293 }
294
295 let _vert = vert_fpca(karcher, argvals, ncomp)?;
296 let horiz = horiz_fpca(karcher, argvals, ncomp)?;
297
298 let m_aug = m + 1;
299 let ncomp = ncomp.min(n - 1);
300
301 let qn = match &karcher.aligned_srsfs {
302 Some(srsfs) => srsfs.clone(),
303 None => srsf_transform(&karcher.aligned_data, argvals),
304 };
305 let q_aug = build_augmented_srsfs(&qn, &karcher.aligned_data, n, m);
306 let (q_centered, _mean_q) = center_matrix(&q_aug, n, m_aug);
307
308 let shooting = &horiz.shooting_vectors;
309 let c = match balance_c {
310 Some(c) => c,
311 None => optimize_balance_c(karcher, argvals, &q_centered, shooting, ncomp),
312 };
313
314 let combined = build_combined_representation(&q_centered, shooting, c, n, m_aug, m);
316
317 let svd = SVD::new(combined.to_dmatrix(), true, true);
318 let v_t = svd
319 .v_t
320 .as_ref()
321 .ok_or_else(|| crate::FdarError::ComputationFailed {
322 operation: "SVD",
323 detail:
324 "SVD failed to compute V^T matrix; check for constant or zero-variance functions"
325 .to_string(),
326 })?;
327 let (scores, eigenvalues) = svd_scores_and_eigenvalues(&svd, ncomp, n).ok_or_else(|| {
328 crate::FdarError::ComputationFailed {
329 operation: "SVD",
330 detail: "SVD failed to compute scores; try reducing ncomp or check for degenerate input data".to_string(),
331 }
332 })?;
333 let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
334
335 let (vert_component, horiz_component) = split_joint_eigenvectors(v_t, ncomp, m_aug, m);
337
338 Ok(JointFpcaResult {
339 scores,
340 eigenvalues,
341 cumulative_variance,
342 balance_c: c,
343 vert_component,
344 horiz_component,
345 })
346}
347
348pub fn vert_fpca_from_alignment(
367 aligned_data: &FdMatrix,
368 aligned_srsfs: Option<&FdMatrix>,
369 argvals: &[f64],
370 ncomp: usize,
371) -> Result<VertFpcaResult, crate::FdarError> {
372 let (n, m) = aligned_data.shape();
373 if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
374 return Err(crate::FdarError::InvalidDimension {
375 parameter: "aligned_data/argvals",
376 expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
377 actual: format!(
378 "n={}, m={}, ncomp={}, argvals.len()={}",
379 n,
380 m,
381 ncomp,
382 argvals.len()
383 ),
384 });
385 }
386 let ncomp = ncomp.min(n - 1).min(m);
387 let m_aug = m + 1;
388
389 let qn = match aligned_srsfs {
390 Some(srsfs) => srsfs.clone(),
391 None => srsf_transform(aligned_data, argvals),
392 };
393
394 let q_aug = build_augmented_srsfs(&qn, aligned_data, n, m);
395
396 let (_, mean_q) = center_matrix(&q_aug, n, m_aug);
397 let k_mat = build_symmetric_covariance(&q_aug, &mean_q, n, m_aug);
398
399 let svd = SVD::new(k_mat, true, true);
400 let u_cov = svd
401 .u
402 .as_ref()
403 .ok_or_else(|| crate::FdarError::ComputationFailed {
404 operation: "SVD",
405 detail: "SVD failed to compute U matrix; check for constant or zero-variance aligned functions".to_string(),
406 })?;
407
408 let eigenvalues: Vec<f64> = svd.singular_values.iter().take(ncomp).copied().collect();
409 let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
410
411 let mut eigenfunctions_q = FdMatrix::zeros(ncomp, m_aug);
412 for k in 0..ncomp {
413 for j in 0..m_aug {
414 eigenfunctions_q[(k, j)] = u_cov[(j, k)];
415 }
416 }
417
418 let scores = project_onto_eigenvectors(&q_aug, &mean_q, u_cov, n, m_aug, ncomp);
419
420 let mut eigenfunctions_f = FdMatrix::zeros(ncomp, m);
421 for k in 0..ncomp {
422 let q_k: Vec<f64> = (0..m)
423 .map(|j| mean_q[j] + eigenfunctions_q[(k, j)])
424 .collect();
425 let aug_val = mean_q[m] + eigenfunctions_q[(k, m)];
426 let f0 = aug_val.signum() * aug_val * aug_val;
427 let f_k = srsf_inverse(&q_k, argvals, f0);
428 for j in 0..m {
429 eigenfunctions_f[(k, j)] = f_k[j];
430 }
431 }
432
433 Ok(VertFpcaResult {
434 scores,
435 eigenfunctions_q,
436 eigenfunctions_f,
437 eigenvalues,
438 cumulative_variance,
439 mean_q,
440 })
441}
442
443pub fn horiz_fpca_from_alignment(
453 gammas: &FdMatrix,
454 argvals: &[f64],
455 ncomp: usize,
456) -> Result<HorizFpcaResult, crate::FdarError> {
457 let (n, m) = gammas.shape();
458 if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
459 return Err(crate::FdarError::InvalidDimension {
460 parameter: "gammas/argvals",
461 expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
462 actual: format!(
463 "n={}, m={}, ncomp={}, argvals.len()={}",
464 n,
465 m,
466 ncomp,
467 argvals.len()
468 ),
469 });
470 }
471 let ncomp = ncomp.min(n - 1).min(m);
472
473 let t0 = argvals[0];
474 let domain = argvals[m - 1] - t0;
475 let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
476
477 let psis = warps_to_normalized_psi(gammas, argvals);
478 let mu_psi = sphere_karcher_mean(&psis, &time, 50);
479 let shooting = shooting_vectors_from_psis(&psis, &mu_psi, &time);
480
481 let (centered, _mean_v) = center_matrix(&shooting, n, m);
482
483 let svd = SVD::new(centered.to_dmatrix(), true, true);
484 let v_t = svd
485 .v_t
486 .as_ref()
487 .ok_or_else(|| crate::FdarError::ComputationFailed {
488 operation: "SVD",
489 detail:
490 "SVD failed to compute V^T matrix; check for constant or zero-variance functions"
491 .to_string(),
492 })?;
493 let (scores, eigenvalues) = svd_scores_and_eigenvalues(&svd, ncomp, n).ok_or_else(|| {
494 crate::FdarError::ComputationFailed {
495 operation: "SVD",
496 detail: "SVD failed to compute scores; try reducing ncomp or check for degenerate input data".to_string(),
497 }
498 })?;
499 let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
500
501 let mut eigenfunctions_psi = FdMatrix::zeros(ncomp, m);
502 for k in 0..ncomp {
503 for j in 0..m {
504 eigenfunctions_psi[(k, j)] = v_t[(k, j)];
505 }
506 }
507
508 let mut eigenfunctions_gam = FdMatrix::zeros(ncomp, m);
509 for k in 0..ncomp {
510 let v_k: Vec<f64> = (0..m).map(|j| eigenfunctions_psi[(k, j)]).collect();
511 let psi_k = exp_map_sphere(&mu_psi, &v_k, &time);
512 let gam_k = psi_to_gam(&psi_k, &time);
513 for j in 0..m {
514 eigenfunctions_gam[(k, j)] = t0 + gam_k[j] * domain;
515 }
516 }
517
518 Ok(HorizFpcaResult {
519 scores,
520 eigenfunctions_psi,
521 eigenfunctions_gam,
522 eigenvalues,
523 cumulative_variance,
524 mean_psi: mu_psi,
525 shooting_vectors: shooting,
526 })
527}
528
529pub fn joint_fpca_from_alignment(
542 aligned_data: &FdMatrix,
543 aligned_srsfs: Option<&FdMatrix>,
544 gammas: &FdMatrix,
545 argvals: &[f64],
546 ncomp: usize,
547 balance_c: Option<f64>,
548) -> Result<JointFpcaResult, crate::FdarError> {
549 let (n, m) = aligned_data.shape();
550 if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m {
551 return Err(crate::FdarError::InvalidDimension {
552 parameter: "aligned_data/argvals",
553 expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m".to_string(),
554 actual: format!(
555 "n={}, m={}, ncomp={}, argvals.len()={}",
556 n,
557 m,
558 ncomp,
559 argvals.len()
560 ),
561 });
562 }
563
564 let horiz = horiz_fpca_from_alignment(gammas, argvals, ncomp)?;
565
566 let m_aug = m + 1;
567 let ncomp = ncomp.min(n - 1);
568
569 let qn = match aligned_srsfs {
570 Some(srsfs) => srsfs.clone(),
571 None => srsf_transform(aligned_data, argvals),
572 };
573 let q_aug = build_augmented_srsfs(&qn, aligned_data, n, m);
574 let (q_centered, _mean_q) = center_matrix(&q_aug, n, m_aug);
575
576 let shooting = &horiz.shooting_vectors;
577 let c = match balance_c {
578 Some(c) => c,
579 None => optimize_balance_c_raw(&q_centered, shooting, ncomp, m_aug, m),
580 };
581
582 let combined = build_combined_representation(&q_centered, shooting, c, n, m_aug, m);
583
584 let svd = SVD::new(combined.to_dmatrix(), true, true);
585 let v_t = svd
586 .v_t
587 .as_ref()
588 .ok_or_else(|| crate::FdarError::ComputationFailed {
589 operation: "SVD",
590 detail:
591 "SVD failed to compute V^T matrix; check for constant or zero-variance functions"
592 .to_string(),
593 })?;
594 let (scores, eigenvalues) = svd_scores_and_eigenvalues(&svd, ncomp, n).ok_or_else(|| {
595 crate::FdarError::ComputationFailed {
596 operation: "SVD",
597 detail: "SVD failed to compute scores; try reducing ncomp or check for degenerate input data".to_string(),
598 }
599 })?;
600 let cumulative_variance = cumulative_variance_from_eigenvalues(&eigenvalues);
601
602 let (vert_component, horiz_component) = split_joint_eigenvectors(v_t, ncomp, m_aug, m);
603
604 Ok(JointFpcaResult {
605 scores,
606 eigenvalues,
607 cumulative_variance,
608 balance_c: c,
609 vert_component,
610 horiz_component,
611 })
612}
613
614fn cumulative_variance_from_eigenvalues(eigenvalues: &[f64]) -> Vec<f64> {
618 let total_var: f64 = eigenvalues.iter().sum();
619 let mut cum = Vec::with_capacity(eigenvalues.len());
620 let mut running = 0.0;
621 for ev in eigenvalues {
622 running += ev;
623 cum.push(if total_var > 0.0 {
624 running / total_var
625 } else {
626 0.0
627 });
628 }
629 cum
630}
631
632pub(crate) fn warps_to_normalized_psi(gammas: &FdMatrix, argvals: &[f64]) -> Vec<Vec<f64>> {
634 let (n, m) = gammas.shape();
635 let t0 = argvals[0];
636 let domain = argvals[m - 1] - t0;
637 let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
638 let binsize = 1.0 / (m - 1) as f64;
639
640 (0..n)
641 .map(|i| {
642 let gam_01: Vec<f64> = (0..m).map(|j| (gammas[(i, j)] - t0) / domain).collect();
643 let mut grad = vec![0.0; m];
644 grad[0] = (gam_01[1] - gam_01[0]) / binsize;
645 for j in 1..m - 1 {
646 grad[j] = (gam_01[j + 1] - gam_01[j - 1]) / (2.0 * binsize);
647 }
648 grad[m - 1] = (gam_01[m - 1] - gam_01[m - 2]) / binsize;
649 let mut psi: Vec<f64> = grad.iter().map(|&g| g.max(0.0).sqrt()).collect();
650 let norm = l2_norm_l2(&psi, &time);
651 if norm > 1e-10 {
652 for v in &mut psi {
653 *v /= norm;
654 }
655 }
656 psi
657 })
658 .collect()
659}
660
661pub(crate) fn sphere_karcher_mean(psis: &[Vec<f64>], time: &[f64], max_iter: usize) -> Vec<f64> {
663 let n = psis.len();
664 let m = psis[0].len();
665
666 let mut mu_psi = vec![0.0; m];
668 for psi in psis {
669 for j in 0..m {
670 mu_psi[j] += psi[j];
671 }
672 }
673 for j in 0..m {
674 mu_psi[j] /= n as f64;
675 }
676 normalize_to_sphere(&mut mu_psi, time);
677
678 for _ in 0..max_iter {
680 let mean_v = mean_tangent_vector(psis, &mu_psi, time);
681 let step_norm = l2_norm_l2(&mean_v, time);
682 if step_norm < 1e-8 {
683 break;
684 }
685 mu_psi = exp_map_sphere(&mu_psi, &mean_v, time);
686 normalize_to_sphere(&mut mu_psi, time);
687 }
688
689 mu_psi
690}
691
692pub(crate) fn shooting_vectors_from_psis(
694 psis: &[Vec<f64>],
695 mu_psi: &[f64],
696 time: &[f64],
697) -> FdMatrix {
698 let n = psis.len();
699 let m = psis[0].len();
700 let mut shooting = FdMatrix::zeros(n, m);
701 for i in 0..n {
702 let v = inv_exp_map_sphere(mu_psi, &psis[i], time);
703 for j in 0..m {
704 shooting[(i, j)] = v[j];
705 }
706 }
707 shooting
708}
709
710pub(crate) fn build_augmented_srsfs(
712 qn: &FdMatrix,
713 aligned_data: &FdMatrix,
714 n: usize,
715 m: usize,
716) -> FdMatrix {
717 let id = m / 2;
718 let m_aug = m + 1;
719 let mut q_aug = FdMatrix::zeros(n, m_aug);
720 for i in 0..n {
721 for j in 0..m {
722 q_aug[(i, j)] = qn[(i, j)];
723 }
724 let fid = aligned_data[(i, id)];
725 q_aug[(i, m)] = fid.signum() * fid.abs().sqrt();
726 }
727 q_aug
728}
729
730pub(crate) fn center_matrix(mat: &FdMatrix, n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
732 let mut mean = vec![0.0; m];
733 for j in 0..m {
734 for i in 0..n {
735 mean[j] += mat[(i, j)];
736 }
737 mean[j] /= n as f64;
738 }
739 let mut centered = FdMatrix::zeros(n, m);
740 for i in 0..n {
741 for j in 0..m {
742 centered[(i, j)] = mat[(i, j)] - mean[j];
743 }
744 }
745 (centered, mean)
746}
747
748fn svd_scores_and_eigenvalues(
750 svd: &SVD<f64, nalgebra::Dyn, nalgebra::Dyn>,
751 ncomp: usize,
752 n: usize,
753) -> Option<(FdMatrix, Vec<f64>)> {
754 let u = svd.u.as_ref()?;
755 let eigenvalues: Vec<f64> = svd
756 .singular_values
757 .iter()
758 .take(ncomp)
759 .map(|&s| s * s / (n - 1) as f64)
760 .collect();
761 let mut scores = FdMatrix::zeros(n, ncomp);
762 for k in 0..ncomp {
763 let sv = svd.singular_values[k];
764 for i in 0..n {
765 scores[(i, k)] = u[(i, k)] * sv;
766 }
767 }
768 Some((scores, eigenvalues))
769}
770
771fn split_joint_eigenvectors(
773 v_t: &nalgebra::DMatrix<f64>,
774 ncomp: usize,
775 m_aug: usize,
776 m: usize,
777) -> (FdMatrix, FdMatrix) {
778 let mut vert_component = FdMatrix::zeros(ncomp, m_aug);
779 let mut horiz_component = FdMatrix::zeros(ncomp, m);
780 for k in 0..ncomp {
781 for j in 0..m_aug {
782 vert_component[(k, j)] = v_t[(k, j)];
783 }
784 for j in 0..m {
785 horiz_component[(k, j)] = v_t[(k, m_aug + j)];
786 }
787 }
788 (vert_component, horiz_component)
789}
790
791fn build_symmetric_covariance(
793 data: &FdMatrix,
794 mean: &[f64],
795 n: usize,
796 d: usize,
797) -> nalgebra::DMatrix<f64> {
798 let nf = (n - 1) as f64;
799 let mut k_mat = nalgebra::DMatrix::zeros(d, d);
800 for i in 0..n {
801 for p in 0..d {
802 let dp = data[(i, p)] - mean[p];
803 for q in p..d {
804 k_mat[(p, q)] += dp * (data[(i, q)] - mean[q]);
805 }
806 }
807 }
808 for p in 0..d {
809 k_mat[(p, p)] /= nf;
810 for q in (p + 1)..d {
811 k_mat[(p, q)] /= nf;
812 k_mat[(q, p)] = k_mat[(p, q)];
813 }
814 }
815 k_mat
816}
817
818fn project_onto_eigenvectors(
820 data: &FdMatrix,
821 mean: &[f64],
822 u_cov: &nalgebra::DMatrix<f64>,
823 n: usize,
824 d: usize,
825 ncomp: usize,
826) -> FdMatrix {
827 let mut scores = FdMatrix::zeros(n, ncomp);
828 for k in 0..ncomp {
829 for i in 0..n {
830 let mut s = 0.0;
831 for j in 0..d {
832 s += (data[(i, j)] - mean[j]) * u_cov[(j, k)];
833 }
834 scores[(i, k)] = s;
835 }
836 }
837 scores
838}
839
840fn normalize_to_sphere(mu: &mut [f64], time: &[f64]) {
842 let norm = l2_norm_l2(mu, time);
843 if norm > 1e-10 {
844 for v in mu.iter_mut() {
845 *v /= norm;
846 }
847 }
848}
849
850fn mean_tangent_vector(psis: &[Vec<f64>], mu_psi: &[f64], time: &[f64]) -> Vec<f64> {
852 let n = psis.len();
853 let m = mu_psi.len();
854 let mut mean_v = vec![0.0; m];
855 for psi in psis {
856 let v = inv_exp_map_sphere(mu_psi, psi, time);
857 for j in 0..m {
858 mean_v[j] += v[j];
859 }
860 }
861 for j in 0..m {
862 mean_v[j] /= n as f64;
863 }
864 mean_v
865}
866
867fn build_combined_representation(
869 q_centered: &FdMatrix,
870 shooting: &FdMatrix,
871 c: f64,
872 n: usize,
873 m_aug: usize,
874 m: usize,
875) -> FdMatrix {
876 let combined_dim = m_aug + m;
877 let mut combined = FdMatrix::zeros(n, combined_dim);
878 for i in 0..n {
879 for j in 0..m_aug {
880 combined[(i, j)] = q_centered[(i, j)];
881 }
882 for j in 0..m {
883 combined[(i, m_aug + j)] = c * shooting[(i, j)];
884 }
885 }
886 combined
887}
888
889fn optimize_balance_c(
893 _karcher: &KarcherMeanResult,
894 _argvals: &[f64],
895 q_centered: &FdMatrix,
896 shooting: &FdMatrix,
897 ncomp: usize,
898) -> f64 {
899 let m_aug = q_centered.ncols();
900 let m = shooting.ncols();
901 optimize_balance_c_raw(q_centered, shooting, ncomp, m_aug, m)
902}
903
904fn optimize_balance_c_raw(
906 q_centered: &FdMatrix,
907 shooting: &FdMatrix,
908 ncomp: usize,
909 m_aug: usize,
910 m: usize,
911) -> f64 {
912 let n = shooting.nrows();
913 let combined_dim = m_aug + m;
914
915 let golden_ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
916 let mut a = 0.0_f64;
917 let mut b = 10.0_f64;
918
919 let eval_c = |c: f64| -> f64 {
920 let mut combined = FdMatrix::zeros(n, combined_dim);
921 for i in 0..n {
922 for j in 0..m_aug {
923 combined[(i, j)] = q_centered[(i, j)];
924 }
925 for j in 0..m {
926 combined[(i, m_aug + j)] = c * shooting[(i, j)];
927 }
928 }
929
930 let svd = SVD::new(combined.to_dmatrix(), true, true);
931 if let (Some(_u), Some(_v_t)) = (svd.u.as_ref(), svd.v_t.as_ref()) {
932 let nc = ncomp.min(svd.singular_values.len());
933 let total_var: f64 = svd.singular_values.iter().map(|&s| s * s).sum();
935 let explained: f64 = svd.singular_values.iter().take(nc).map(|&s| s * s).sum();
936 total_var - explained
937 } else {
938 f64::INFINITY
939 }
940 };
941
942 for _ in 0..20 {
943 let c1 = b - golden_ratio * (b - a);
944 let c2 = a + golden_ratio * (b - a);
945 if eval_c(c1) < eval_c(c2) {
946 b = c2;
947 } else {
948 a = c1;
949 }
950 }
951
952 (a + b) / 2.0
953}
954
955#[cfg(test)]
956mod tests {
957 use super::*;
958 use crate::alignment::karcher_mean;
959 use std::f64::consts::PI;
960
961 fn generate_test_data(n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
962 let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
963 let mut data = FdMatrix::zeros(n, m);
964 for i in 0..n {
965 let shift = 0.1 * (i as f64 - n as f64 / 2.0);
966 let scale = 1.0 + 0.2 * (i as f64 / n as f64);
967 for j in 0..m {
968 data[(i, j)] = scale * (2.0 * PI * (t[j] + shift)).sin();
969 }
970 }
971 (data, t)
972 }
973
974 #[test]
975 fn test_vert_fpca_basic() {
976 let (data, t) = generate_test_data(15, 51);
977 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
978 let result = vert_fpca(&km, &t, 3);
979 assert!(result.is_ok(), "vert_fpca should succeed");
980
981 let res = result.unwrap();
982 assert_eq!(res.scores.shape(), (15, 3));
983 assert_eq!(res.eigenvalues.len(), 3);
984 assert_eq!(res.eigenfunctions_q.shape(), (3, 52)); assert_eq!(res.eigenfunctions_f.shape(), (3, 51));
986
987 for ev in &res.eigenvalues {
989 assert!(*ev >= -1e-10, "Eigenvalue should be non-negative: {}", ev);
990 }
991 for i in 1..res.eigenvalues.len() {
992 assert!(
993 res.eigenvalues[i] <= res.eigenvalues[i - 1] + 1e-10,
994 "Eigenvalues should be decreasing"
995 );
996 }
997
998 for i in 1..res.cumulative_variance.len() {
1000 assert!(res.cumulative_variance[i] >= res.cumulative_variance[i - 1] - 1e-10);
1001 }
1002 assert!(*res.cumulative_variance.last().unwrap() <= 1.0 + 1e-10);
1003 }
1004
1005 #[test]
1006 fn test_horiz_fpca_basic() {
1007 let (data, t) = generate_test_data(15, 51);
1008 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1009 let result = horiz_fpca(&km, &t, 3);
1010 assert!(result.is_ok(), "horiz_fpca should succeed");
1011
1012 let res = result.unwrap();
1013 assert_eq!(res.scores.shape(), (15, 3));
1014 assert_eq!(res.eigenvalues.len(), 3);
1015 assert_eq!(res.eigenfunctions_psi.shape(), (3, 51));
1016 assert_eq!(res.shooting_vectors.shape(), (15, 51));
1017 }
1018
1019 #[test]
1020 fn test_joint_fpca_basic() {
1021 let (data, t) = generate_test_data(15, 51);
1022 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1023 let result = joint_fpca(&km, &t, 3, Some(1.0));
1024 assert!(result.is_ok(), "joint_fpca should succeed");
1025
1026 let res = result.unwrap();
1027 assert_eq!(res.scores.shape(), (15, 3));
1028 assert_eq!(res.eigenvalues.len(), 3);
1029 assert!(res.balance_c >= 0.0);
1030 }
1031
1032 #[test]
1033 fn test_joint_fpca_optimize_c() {
1034 let (data, t) = generate_test_data(15, 51);
1035 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1036 let result = joint_fpca(&km, &t, 3, None);
1037 assert!(
1038 result.is_ok(),
1039 "joint_fpca with C optimization should succeed"
1040 );
1041 }
1042
1043 #[test]
1044 fn test_vert_fpca_invalid_input() {
1045 let data = FdMatrix::zeros(1, 10); let t: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
1047 let km = KarcherMeanResult {
1048 mean: vec![0.0; 10],
1049 mean_srsf: vec![0.0; 10],
1050 gammas: FdMatrix::zeros(1, 10),
1051 aligned_data: data,
1052 n_iter: 0,
1053 converged: true,
1054 aligned_srsfs: None,
1055 };
1056 assert!(vert_fpca(&km, &t, 3).is_err());
1057 }
1058
1059 #[test]
1060 fn test_horiz_fpca_eigenvalue_properties() {
1061 let (data, t) = generate_test_data(15, 51);
1062 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1063 let res = horiz_fpca(&km, &t, 3).expect("horiz_fpca should succeed");
1064
1065 for ev in &res.eigenvalues {
1067 assert!(*ev >= -1e-10, "Eigenvalue should be non-negative: {}", ev);
1068 }
1069 for i in 1..res.eigenvalues.len() {
1071 assert!(
1072 res.eigenvalues[i] <= res.eigenvalues[i - 1] + 1e-10,
1073 "Eigenvalues should be decreasing"
1074 );
1075 }
1076 for i in 1..res.cumulative_variance.len() {
1078 assert!(res.cumulative_variance[i] >= res.cumulative_variance[i - 1] - 1e-10);
1079 }
1080 assert!(*res.cumulative_variance.last().unwrap() <= 1.0 + 1e-10);
1081 }
1082
1083 #[test]
1084 fn test_joint_fpca_eigenvalue_properties() {
1085 let (data, t) = generate_test_data(15, 51);
1086 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1087 let res = joint_fpca(&km, &t, 3, Some(1.0)).expect("joint_fpca should succeed");
1088
1089 for ev in &res.eigenvalues {
1091 assert!(*ev >= -1e-10, "Eigenvalue should be non-negative: {}", ev);
1092 }
1093 for i in 1..res.eigenvalues.len() {
1095 assert!(
1096 res.eigenvalues[i] <= res.eigenvalues[i - 1] + 1e-10,
1097 "Eigenvalues should be decreasing"
1098 );
1099 }
1100 for i in 1..res.cumulative_variance.len() {
1102 assert!(res.cumulative_variance[i] >= res.cumulative_variance[i - 1] - 1e-10);
1103 }
1104 assert!(*res.cumulative_variance.last().unwrap() <= 1.0 + 1e-10);
1105 }
1106
1107 #[test]
1108 fn test_vert_fpca_ncomp_sensitivity() -> Result<(), crate::error::FdarError> {
1109 let (data, t) = generate_test_data(15, 51);
1110 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1111
1112 for &ncomp in &[1, 2, 5, 10] {
1113 let res = vert_fpca(&km, &t, ncomp)?;
1114 assert_eq!(res.scores.shape(), (15, ncomp));
1115 assert_eq!(res.eigenvalues.len(), ncomp);
1116 assert_eq!(res.eigenfunctions_q.shape(), (ncomp, 52));
1117 assert_eq!(res.eigenfunctions_f.shape(), (ncomp, 51));
1118 assert_eq!(res.cumulative_variance.len(), ncomp);
1119 }
1120 Ok(())
1121 }
1122
1123 #[test]
1124 fn test_vert_fpca_score_orthogonality() {
1125 let (data, t) = generate_test_data(15, 51);
1126 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1127 let res = vert_fpca(&km, &t, 3).expect("vert_fpca should succeed");
1128
1129 let n = 15;
1130 for k1 in 0..3 {
1132 for k2 in (k1 + 1)..3 {
1133 let dot: f64 = (0..n)
1134 .map(|i| res.scores[(i, k1)] * res.scores[(i, k2)])
1135 .sum();
1136 let norm1: f64 = (0..n)
1137 .map(|i| res.scores[(i, k1)].powi(2))
1138 .sum::<f64>()
1139 .sqrt();
1140 let norm2: f64 = (0..n)
1141 .map(|i| res.scores[(i, k2)].powi(2))
1142 .sum::<f64>()
1143 .sqrt();
1144 if norm1 > 1e-10 && norm2 > 1e-10 {
1145 let cos_angle = (dot / (norm1 * norm2)).abs();
1146 assert!(
1147 cos_angle < 0.15,
1148 "Score components {} and {} should be approximately orthogonal, cos={}",
1149 k1,
1150 k2,
1151 cos_angle
1152 );
1153 }
1154 }
1155 }
1156 }
1157
1158 #[test]
1161 fn test_vert_fpca_from_alignment_matches_original() {
1162 let (data, t) = generate_test_data(15, 51);
1163 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1164
1165 let original = vert_fpca(&km, &t, 3).expect("vert_fpca should succeed");
1166 let from_aln = vert_fpca_from_alignment(&km.aligned_data, km.aligned_srsfs.as_ref(), &t, 3)
1167 .expect("vert_fpca_from_alignment should succeed");
1168
1169 assert_eq!(original.scores.shape(), from_aln.scores.shape());
1170 assert_eq!(original.eigenvalues.len(), from_aln.eigenvalues.len());
1171 for (a, b) in original.eigenvalues.iter().zip(&from_aln.eigenvalues) {
1172 assert!((a - b).abs() < 1e-10, "eigenvalues should match");
1173 }
1174 }
1175
1176 #[test]
1177 fn test_vert_fpca_from_alignment_without_srsfs() {
1178 let (data, t) = generate_test_data(15, 51);
1179 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1180
1181 let res = vert_fpca_from_alignment(&km.aligned_data, None, &t, 3)
1182 .expect("vert_fpca_from_alignment without srsfs should succeed");
1183 assert_eq!(res.scores.shape(), (15, 3));
1184 }
1185
1186 #[test]
1187 fn test_horiz_fpca_from_alignment_matches_original() {
1188 let (data, t) = generate_test_data(15, 51);
1189 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1190
1191 let original = horiz_fpca(&km, &t, 3).expect("horiz_fpca should succeed");
1192 let from_aln = horiz_fpca_from_alignment(&km.gammas, &t, 3)
1193 .expect("horiz_fpca_from_alignment should succeed");
1194
1195 assert_eq!(original.scores.shape(), from_aln.scores.shape());
1196 assert_eq!(original.eigenvalues.len(), from_aln.eigenvalues.len());
1197 for (a, b) in original.eigenvalues.iter().zip(&from_aln.eigenvalues) {
1198 assert!((a - b).abs() < 1e-10, "eigenvalues should match");
1199 }
1200 }
1201
1202 #[test]
1203 fn test_joint_fpca_from_alignment_matches_original() {
1204 let (data, t) = generate_test_data(15, 51);
1205 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1206
1207 let original = joint_fpca(&km, &t, 3, Some(1.0)).expect("joint_fpca should succeed");
1208 let from_aln = joint_fpca_from_alignment(
1209 &km.aligned_data,
1210 km.aligned_srsfs.as_ref(),
1211 &km.gammas,
1212 &t,
1213 3,
1214 Some(1.0),
1215 )
1216 .expect("joint_fpca_from_alignment should succeed");
1217
1218 assert_eq!(original.scores.shape(), from_aln.scores.shape());
1219 assert_eq!(original.eigenvalues.len(), from_aln.eigenvalues.len());
1220 for (a, b) in original.eigenvalues.iter().zip(&from_aln.eigenvalues) {
1221 assert!((a - b).abs() < 1e-10, "eigenvalues should match");
1222 }
1223 }
1224
1225 #[test]
1226 fn test_joint_fpca_from_alignment_optimize_c() {
1227 let (data, t) = generate_test_data(15, 51);
1228 let km = karcher_mean(&data, &t, 10, 1e-4, 0.0);
1229
1230 let res = joint_fpca_from_alignment(
1231 &km.aligned_data,
1232 km.aligned_srsfs.as_ref(),
1233 &km.gammas,
1234 &t,
1235 3,
1236 None,
1237 )
1238 .expect("joint_fpca_from_alignment with C optimization should succeed");
1239 assert_eq!(res.scores.shape(), (15, 3));
1240 assert!(res.balance_c >= 0.0);
1241 }
1242
1243 #[test]
1244 fn test_from_alignment_invalid_input() {
1245 let data = FdMatrix::zeros(1, 10);
1246 let t: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
1247
1248 assert!(vert_fpca_from_alignment(&data, None, &t, 3).is_err());
1249 assert!(horiz_fpca_from_alignment(&data, &t, 3).is_err());
1250 assert!(joint_fpca_from_alignment(&data, None, &data, &t, 3, Some(1.0)).is_err());
1251 }
1252}