1use crate::error::FdarError;
25use crate::fpca_variants::gaussian_smooth_cov;
26use crate::helpers::simpsons_weights;
27use crate::irreg_fdata::kernels::kernel_gaussian;
28use crate::irreg_fdata::{cov_irreg, mean_irreg, IrregFdata, KernelType};
29use crate::matrix::FdMatrix;
30use crate::pace_fpca::{pace_fpca, PaceFpcaConfig, PaceFpcaResult};
31use nalgebra::DMatrix;
32
33fn validate_grid(grid: &[f64]) -> Result<(), FdarError> {
35 if grid.len() < 2 {
36 return Err(FdarError::InvalidDimension {
37 parameter: "grid",
38 expected: ">= 2 points".to_string(),
39 actual: grid.len().to_string(),
40 });
41 }
42 if grid.windows(2).any(|w| w[0] >= w[1]) {
43 return Err(FdarError::InvalidParameter {
44 parameter: "grid",
45 message: "grid must be strictly increasing".to_string(),
46 });
47 }
48 Ok(())
49}
50
51fn psd_project(cov: &FdMatrix, grid: &[f64]) -> Result<FdMatrix, FdarError> {
56 let m = grid.len();
57 let w = simpsons_weights(grid);
58 let sqrt_w: Vec<f64> = w.iter().map(|v| v.sqrt()).collect();
59
60 let mut c_scaled = vec![0.0_f64; m * m];
61 for col in 0..m {
62 for row in 0..m {
63 c_scaled[row + col * m] = sqrt_w[row] * cov[(row, col)] * sqrt_w[col];
64 }
65 }
66 let eigen = DMatrix::from_column_slice(m, m, &c_scaled).symmetric_eigen();
67
68 let mut cov_data = vec![0.0_f64; m * m];
69 for k in 0..eigen.eigenvalues.len() {
70 let lam = eigen.eigenvalues[k];
71 if lam <= 0.0 {
72 continue; }
74 let mut phi = vec![0.0_f64; m];
76 for j in 0..m {
77 let raw = eigen.eigenvectors[(j, k)];
78 phi[j] = if sqrt_w[j] > 1e-15 {
79 raw / sqrt_w[j]
80 } else {
81 raw
82 };
83 }
84 for j in 0..m {
85 for i in 0..m {
86 cov_data[i + j * m] += lam * phi[i] * phi[j];
87 }
88 }
89 }
90 FdMatrix::from_column_major(cov_data, m, m).map_err(|e| FdarError::ComputationFailed {
91 operation: "face_covariance PSD projection",
92 detail: e.to_string(),
93 })
94}
95
96#[must_use = "face_covariance returns the covariance surface; ignoring it wastes the computation"]
128pub fn face_covariance(
129 ifd: &IrregFdata,
130 grid: &[f64],
131 bandwidth: f64,
132) -> Result<FdMatrix, FdarError> {
133 if ifd.n_obs() == 0 {
134 return Err(FdarError::InvalidDimension {
135 parameter: "ifd",
136 expected: ">= 1 observation".to_string(),
137 actual: "0".to_string(),
138 });
139 }
140 validate_grid(grid)?;
141 if !bandwidth.is_finite() || bandwidth <= 0.0 {
142 return Err(FdarError::InvalidParameter {
143 parameter: "bandwidth",
144 message: format!("bandwidth must be finite and > 0, got {bandwidth}"),
145 });
146 }
147
148 let raw_cov = cov_irreg(ifd, grid, grid, bandwidth);
149 let smooth_cov = gaussian_smooth_cov(&raw_cov, grid, bandwidth);
150 psd_project(&smooth_cov, grid)
151}
152
153#[derive(Debug, Clone, PartialEq)]
163#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
164#[non_exhaustive]
165pub struct MfaceCovResult {
166 pub block_cov: FdMatrix,
168 pub grids: Vec<Vec<f64>>,
170 pub offsets: Vec<usize>,
172}
173
174impl MfaceCovResult {
175 #[must_use]
182 pub fn block(&self, p: usize, q: usize) -> FdMatrix {
183 let gp = self.grids[p].len();
184 let gq = self.grids[q].len();
185 let op = self.offsets[p];
186 let oq = self.offsets[q];
187 let mut out = FdMatrix::zeros(gp, gq);
188 for col in 0..gq {
189 for row in 0..gp {
190 out[(row, col)] = self.block_cov[(op + row, oq + col)];
191 }
192 }
193 out
194 }
195}
196
197fn cross_cov_surface(
203 off_p: &[usize],
204 t_p: &[f64],
205 c_p: &[f64],
206 off_q: &[usize],
207 t_q: &[f64],
208 c_q: &[f64],
209 n: usize,
210 s_grid: &[f64],
211 t_grid: &[f64],
212 bandwidth: f64,
213) -> FdMatrix {
214 let ns = s_grid.len();
215 let nt = t_grid.len();
216 let mut data = vec![0.0_f64; ns * nt];
217 for (si, &s) in s_grid.iter().enumerate() {
218 for (ti, &t) in t_grid.iter().enumerate() {
219 let mut sum_w = 0.0;
220 let mut sum_p = 0.0;
221 for i in 0..n {
222 let (ps, pe) = (off_p[i], off_p[i + 1]);
223 let (qs, qe) = (off_q[i], off_q[i + 1]);
224 for j1 in ps..pe {
225 let w1 = kernel_gaussian((t_p[j1] - s) / bandwidth);
226 for j2 in qs..qe {
227 let w2 = kernel_gaussian((t_q[j2] - t) / bandwidth);
228 let w = w1 * w2;
229 sum_w += w;
230 sum_p += w * c_p[j1] * c_q[j2];
231 }
232 }
233 }
234 data[si + ti * ns] = if sum_w > 0.0 { sum_p / sum_w } else { 0.0 };
235 }
236 }
237 FdMatrix::from_column_major(data, ns, nt).expect("dimension invariant: ns*nt")
238}
239
240#[must_use = "mface_covariance returns the block covariance; ignoring it wastes the computation"]
263pub fn mface_covariance(
264 variables: &[IrregFdata],
265 grids: &[Vec<f64>],
266 bandwidth: f64,
267) -> Result<MfaceCovResult, FdarError> {
268 let p_count = variables.len();
269 if p_count < 2 {
270 return Err(FdarError::InvalidDimension {
271 parameter: "variables",
272 expected: ">= 2 variables".to_string(),
273 actual: p_count.to_string(),
274 });
275 }
276 if grids.len() != p_count {
277 return Err(FdarError::InvalidDimension {
278 parameter: "grids",
279 expected: format!("{p_count} grids (one per variable)"),
280 actual: grids.len().to_string(),
281 });
282 }
283 if !bandwidth.is_finite() || bandwidth <= 0.0 {
284 return Err(FdarError::InvalidParameter {
285 parameter: "bandwidth",
286 message: format!("bandwidth must be finite and > 0, got {bandwidth}"),
287 });
288 }
289 let n = variables[0].n_obs();
290 for (p, var) in variables.iter().enumerate() {
291 if var.n_obs() != n {
292 return Err(FdarError::InvalidDimension {
293 parameter: "variables",
294 expected: format!("all variables observed on {n} subjects"),
295 actual: format!("variable {p} has {} observations", var.n_obs()),
296 });
297 }
298 if grids[p].len() < 2 {
299 return Err(FdarError::InvalidDimension {
300 parameter: "grids",
301 expected: ">= 2 points per grid".to_string(),
302 actual: format!("grid {p} has {} points", grids[p].len()),
303 });
304 }
305 }
306
307 let mut offsets = Vec::with_capacity(p_count);
309 let mut g_total = 0usize;
310 for g in grids {
311 offsets.push(g_total);
312 g_total += g.len();
313 }
314 g_total
315 .checked_mul(g_total)
316 .ok_or_else(|| FdarError::ComputationFailed {
317 operation: "mface_covariance block allocation",
318 detail: format!("G_total={g_total}: G_total² overflows usize"),
319 })?;
320
321 let centered: Vec<Vec<f64>> = variables
323 .iter()
324 .map(|var| {
325 let mean = mean_irreg(var, &var.argvals, bandwidth, KernelType::Gaussian);
326 var.values
327 .iter()
328 .zip(mean.iter())
329 .map(|(&v, &m)| v - m)
330 .collect()
331 })
332 .collect();
333
334 let mut block_data = vec![0.0_f64; g_total * g_total];
335
336 for p in 0..p_count {
338 let diag = face_covariance(&variables[p], &grids[p], bandwidth)?;
339 let op = offsets[p];
340 let gp = grids[p].len();
341 for col in 0..gp {
342 for row in 0..gp {
343 block_data[(op + row) + (op + col) * g_total] = diag[(row, col)];
344 }
345 }
346 }
347
348 for p in 0..p_count {
350 for q in (p + 1)..p_count {
351 let cross = cross_cov_surface(
352 &variables[p].offsets,
353 &variables[p].argvals,
354 ¢ered[p],
355 &variables[q].offsets,
356 &variables[q].argvals,
357 ¢ered[q],
358 n,
359 &grids[p],
360 &grids[q],
361 bandwidth,
362 );
363 let op = offsets[p];
364 let oq = offsets[q];
365 let gp = grids[p].len();
366 let gq = grids[q].len();
367 for col in 0..gq {
368 for row in 0..gp {
369 let val = cross[(row, col)];
370 block_data[(op + row) + (oq + col) * g_total] = val;
371 block_data[(oq + col) + (op + row) * g_total] = val; }
373 }
374 }
375 }
376
377 let block_cov = FdMatrix::from_column_major(block_data, g_total, g_total).map_err(|e| {
378 FdarError::ComputationFailed {
379 operation: "mface_covariance block assembly",
380 detail: e.to_string(),
381 }
382 })?;
383 Ok(MfaceCovResult {
384 block_cov,
385 grids: grids.to_vec(),
386 offsets,
387 })
388}
389
390#[must_use = "face_trajectory returns fitted trajectories + bands; ignoring it wastes the computation"]
407pub fn face_trajectory(
408 data: &IrregFdata,
409 config: &PaceFpcaConfig,
410) -> Result<PaceFpcaResult, FdarError> {
411 pace_fpca(data, config)
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use nalgebra::Cholesky;
418 use rand::rngs::StdRng;
419 use rand::SeedableRng;
420 use rand_distr::{Distribution, StandardNormal};
421
422 fn sparse_sample() -> (IrregFdata, Vec<f64>) {
424 let argvals = vec![
425 vec![0.0, 0.3, 0.6, 1.0],
426 vec![0.1, 0.5, 0.9],
427 vec![0.0, 0.4, 0.7, 0.95],
428 vec![0.2, 0.6],
429 vec![0.05, 0.45, 0.85],
430 vec![0.15, 0.55, 0.9],
431 vec![0.0, 0.5, 1.0],
432 vec![0.3, 0.7],
433 vec![0.1, 0.4, 0.8],
434 vec![0.25, 0.65, 0.95],
435 ];
436 let values: Vec<Vec<f64>> = argvals
437 .iter()
438 .enumerate()
439 .map(|(i, ts)| {
440 let a = 0.5 + i as f64 * 0.1;
441 ts.iter()
442 .map(|&t| a * (std::f64::consts::PI * t).sin())
443 .collect()
444 })
445 .collect();
446 let grid: Vec<f64> = (0..11).map(|i| i as f64 / 10.0).collect();
447 (IrregFdata::from_lists(&argvals, &values), grid)
448 }
449
450 fn min_eigenvalue(cov: &FdMatrix) -> f64 {
451 let (m, _) = cov.shape();
452 let dm = DMatrix::from_fn(m, m, |i, j| cov[(i, j)]);
453 dm.symmetric_eigen()
454 .eigenvalues
455 .iter()
456 .cloned()
457 .fold(f64::INFINITY, f64::min)
458 }
459
460 #[test]
461 fn test_face_covariance_shape() {
462 let (ifd, grid) = sparse_sample();
463 let cov = face_covariance(&ifd, &grid, 0.3).unwrap();
464 let m = grid.len();
465 assert_eq!(cov.shape(), (m, m));
466 for i in 0..m {
468 for j in 0..m {
469 assert!(
470 (cov[(i, j)] - cov[(j, i)]).abs() < 1e-9,
471 "not symmetric at ({i},{j})"
472 );
473 }
474 }
475 assert!(min_eigenvalue(&cov) >= -1e-9, "not PSD");
477 }
478
479 #[test]
480 fn test_face_covariance_dense_limit() {
481 let m = 31usize;
484 let grid: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
485 let kernel = DMatrix::from_fn(m, m, |i, j| (-(grid[i] - grid[j]).abs()).exp());
486 let chol = Cholesky::new(kernel).expect("OU kernel is PD");
487 let l = chol.l();
488
489 let n = 200usize;
490 let mut rng = StdRng::seed_from_u64(42);
491 let mut argvals_list = Vec::with_capacity(n);
492 let mut values_list = Vec::with_capacity(n);
493 for _ in 0..n {
494 let z: Vec<f64> = (0..m).map(|_| StandardNormal.sample(&mut rng)).collect();
495 let zvec = nalgebra::DVector::from_vec(z);
496 let x = &l * zvec; argvals_list.push(grid.clone());
498 values_list.push(x.iter().copied().collect());
499 }
500 let ifd = IrregFdata::from_lists(&argvals_list, &values_list);
501
502 let cov = face_covariance(&ifd, &grid, 0.05).unwrap();
503 let mut max_err = 0.0_f64;
504 for si in 0..m {
505 for ti in 0..m {
506 let truth = (-(grid[si] - grid[ti]).abs()).exp();
507 max_err = max_err.max((cov[(si, ti)] - truth).abs());
508 }
509 }
510 assert!(
516 max_err < 0.30,
517 "dense-limit max error {max_err} exceeds tolerance"
518 );
519 }
520
521 #[test]
522 fn test_face_covariance_errors() {
523 let (ifd, grid) = sparse_sample();
524 let empty = IrregFdata::from_lists(&[], &[]);
526 assert!(face_covariance(&empty, &grid, 0.3).is_err());
527 assert!(face_covariance(&ifd, &[], 0.3).is_err());
529 assert!(face_covariance(&ifd, &[0.5], 0.3).is_err());
530 assert!(face_covariance(&ifd, &[0.0, 0.5, 0.4], 0.3).is_err());
532 assert!(face_covariance(&ifd, &grid, 0.0).is_err());
534 assert!(face_covariance(&ifd, &grid, -1.0).is_err());
535 assert!(face_covariance(&ifd, &grid, f64::NAN).is_err());
536 assert!(face_covariance(&ifd, &grid, f64::INFINITY).is_err());
537 }
538
539 fn two_var_sample(n: usize, m: usize, seed: u64) -> (Vec<IrregFdata>, Vec<Vec<f64>>, f64) {
544 let grid: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
545 let mut rng = StdRng::seed_from_u64(seed);
546 let amps: Vec<f64> = (0..n)
547 .map(|_| {
548 let z: f64 = StandardNormal.sample(&mut rng);
549 1.0 + z
550 })
551 .collect();
552 let abar = amps.iter().sum::<f64>() / n as f64;
553 let lambda_pop = amps.iter().map(|&a| (a - abar).powi(2)).sum::<f64>() / n as f64;
554
555 let mut ax = Vec::with_capacity(n);
556 let mut vx = Vec::with_capacity(n);
557 let mut ay = Vec::with_capacity(n);
558 let mut vy = Vec::with_capacity(n);
559 for &a in &s {
560 ax.push(grid.clone());
561 vx.push(
562 grid.iter()
563 .map(|&t| a * (std::f64::consts::PI * t).sin())
564 .collect(),
565 );
566 ay.push(grid.clone());
567 vy.push(
568 grid.iter()
569 .map(|&t| a * (std::f64::consts::PI * t).cos())
570 .collect(),
571 );
572 }
573 let vars = vec![
574 IrregFdata::from_lists(&ax, &vx),
575 IrregFdata::from_lists(&ay, &vy),
576 ];
577 (vars, vec![grid.clone(), grid], lambda_pop)
578 }
579
580 #[test]
581 fn test_mface_shape() {
582 let (vars, grids, _) = two_var_sample(20, 9, 7);
583 let res = mface_covariance(&vars, &grids, 0.15).unwrap();
584 let g0 = grids[0].len();
585 let g1 = grids[1].len();
586 let gt = g0 + g1;
587 assert_eq!(res.block_cov.shape(), (gt, gt));
588 assert_eq!(res.offsets, vec![0, g0]);
589 for i in 0..gt {
591 for j in 0..gt {
592 assert!(
593 (res.block_cov[(i, j)] - res.block_cov[(j, i)]).abs() < 1e-9,
594 "block matrix not symmetric at ({i},{j})"
595 );
596 }
597 }
598 let f0 = face_covariance(&vars[0], &grids[0], 0.15).unwrap();
600 let b00 = res.block(0, 0);
601 for i in 0..g0 {
602 for j in 0..g0 {
603 assert!((b00[(i, j)] - f0[(i, j)]).abs() < 1e-9);
604 }
605 }
606 let b01 = res.block(0, 1);
608 let b10 = res.block(1, 0);
609 assert_eq!(b01.shape(), (g0, g1));
610 assert_eq!(b10.shape(), (g1, g0));
611 for i in 0..g0 {
612 for j in 0..g1 {
613 assert!((b01[(i, j)] - b10[(j, i)]).abs() < 1e-9);
614 }
615 }
616 }
617
618 #[test]
619 fn test_mface_known_structure() {
620 let m = 21usize;
622 let (vars, grids, lambda) = two_var_sample(200, m, 11);
623 let res = mface_covariance(&vars, &grids, 0.08).unwrap();
624 let b01 = res.block(0, 1);
625 let grid = &grids[0];
626 let mut max_err = 0.0_f64;
627 for si in 0..m {
628 for ti in 0..m {
629 let truth = lambda
630 * (std::f64::consts::PI * grid[si]).sin()
631 * (std::f64::consts::PI * grid[ti]).cos();
632 max_err = max_err.max((b01[(si, ti)] - truth).abs());
633 }
634 }
635 assert!(
637 max_err < 0.4,
638 "mface cross-block max error {max_err} exceeds tolerance"
639 );
640 }
641
642 #[test]
643 fn test_mface_errors() {
644 let (vars, grids, _) = two_var_sample(10, 6, 3);
645 assert!(mface_covariance(&[], &[], 0.1).is_err());
647 assert!(mface_covariance(&vars[..1], &grids[..1], 0.1).is_err());
648 assert!(mface_covariance(&vars, &grids[..1], 0.1).is_err());
650 let (vars_a, grids_a, _) = two_var_sample(10, 6, 5);
652 let (vars_b, _, _) = two_var_sample(8, 6, 6);
653 let mixed = vec![vars_a[0].clone(), vars_b[0].clone()];
654 assert!(mface_covariance(&mixed, &grids_a, 0.1).is_err());
655 let short_grids = vec![vec![0.5], grids[1].clone()];
657 assert!(mface_covariance(&vars, &short_grids, 0.1).is_err());
658 assert!(mface_covariance(&vars, &grids, 0.0).is_err());
660 assert!(mface_covariance(&vars, &grids, f64::NAN).is_err());
661 }
662
663 #[test]
664 fn test_mface_three_vars() {
665 let n = 15usize;
668 let (v01, g01, _) = two_var_sample(n, 6, 21);
669 let (v2only, _, _) = two_var_sample(n, 8, 22);
671 let grid3: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
672 let vars = vec![v01[0].clone(), v01[1].clone(), v2only[0].clone()];
673 let grids = vec![g01[0].clone(), g01[1].clone(), grid3];
674 let res = mface_covariance(&vars, &grids, 0.15).unwrap();
675
676 let gt: usize = grids.iter().map(std::vec::Vec::len).sum();
677 assert_eq!(res.block_cov.shape(), (gt, gt));
678 assert_eq!(res.offsets, vec![0, 6, 12]);
679 for i in 0..gt {
681 for j in 0..gt {
682 assert!((res.block_cov[(i, j)] - res.block_cov[(j, i)]).abs() < 1e-9);
683 }
684 }
685 for p in 0..3 {
688 let diag = face_covariance(&vars[p], &grids[p], 0.15).unwrap();
689 let bpp = res.block(p, p);
690 for i in 0..grids[p].len() {
691 for j in 0..grids[p].len() {
692 assert!((bpp[(i, j)] - diag[(i, j)]).abs() < 1e-9);
693 }
694 }
695 for q in (p + 1)..3 {
696 let bpq = res.block(p, q);
697 let bqp = res.block(q, p);
698 assert_eq!(bpq.shape(), (grids[p].len(), grids[q].len()));
699 for i in 0..grids[p].len() {
700 for j in 0..grids[q].len() {
701 assert!((bpq[(i, j)] - bqp[(j, i)]).abs() < 1e-9);
702 }
703 }
704 }
705 }
706 }
707
708 fn dense_sample(n: usize, m: usize, seed: u64) -> (IrregFdata, Vec<f64>, Vec<Vec<f64>>) {
711 let grid: Vec<f64> = (0..m).map(|i| i as f64 / (m as f64 - 1.0)).collect();
712 let mut rng = StdRng::seed_from_u64(seed);
713 let mut argvals = Vec::with_capacity(n);
714 let mut values = Vec::with_capacity(n);
715 let mut truth = Vec::with_capacity(n);
716 for _ in 0..n {
717 let z: f64 = StandardNormal.sample(&mut rng);
718 let a = 1.0 + 0.5 * z;
719 let curve: Vec<f64> = grid
720 .iter()
721 .map(|&t| a * (std::f64::consts::PI * t).sin())
722 .collect();
723 argvals.push(grid.clone());
724 values.push(curve.clone());
725 truth.push(curve);
726 }
727 (IrregFdata::from_lists(&argvals, &values), grid, truth)
728 }
729
730 #[test]
731 fn test_face_trajectory_delegation() {
732 let (data, grid, _) = dense_sample(15, 21, 1);
733 let config = PaceFpcaConfig {
734 ncomp: 2,
735 bandwidth: 0.1,
736 sigma2: 0.01,
737 work_grid: grid,
738 alpha: 0.05,
739 };
740 let a = face_trajectory(&data, &config).unwrap();
741 let b = pace_fpca(&data, &config).unwrap();
742 assert!(a == b, "face_trajectory must delegate to pace_fpca exactly");
743 }
744
745 #[test]
746 fn test_face_trajectory_bands() {
747 let m = 41usize;
748 let (data, grid, truth) = dense_sample(25, m, 2);
749 let config = PaceFpcaConfig {
750 ncomp: 2,
751 bandwidth: 0.1,
752 sigma2: 0.01,
753 work_grid: grid,
754 alpha: 0.05,
755 };
756 let res = face_trajectory(&data, &config).unwrap();
757 let n = truth.len();
758 let mut inside = 0usize;
759 let mut total = 0usize;
760 for i in 0..n {
761 for j in 0..m {
762 let lo = res.fitted_lower[(i, j)];
763 let hi = res.fitted_upper[(i, j)];
764 if truth[i][j] >= lo && truth[i][j] <= hi {
765 inside += 1;
766 }
767 total += 1;
768 }
769 }
770 let frac = inside as f64 / total as f64;
771 assert!(frac >= 0.85, "only {frac} of true points inside bands");
775 }
776
777 #[test]
780 fn test_reexports() {
781 use crate::{face_covariance, face_trajectory, mface_covariance, MfaceCovResult};
784 let (vars, grids, _) = two_var_sample(12, 7, 99);
785 let _diag = face_covariance(&vars[0], &grids[0], 0.15).unwrap();
786 let res: MfaceCovResult = mface_covariance(&vars, &grids, 0.15).unwrap();
787 let _ = res.block(0, 1);
788 let config = PaceFpcaConfig {
789 ncomp: 1,
790 bandwidth: 0.15,
791 sigma2: 0.01,
792 work_grid: grids[0].clone(),
793 alpha: 0.05,
794 };
795 let _traj = face_trajectory(&vars[0], &config).unwrap();
796 }
797}