1use super::nonparametric::{compute_pairwise_distances, gaussian_kernel, select_bandwidth_loo};
54use crate::error::FdarError;
55use crate::matrix::FdMatrix;
56use crate::regression::{fdata_to_pc_1d, FpcaResult};
57use crate::smoothing::{nadaraya_watson, optim_bandwidth, CvCriterion};
58
59#[derive(Debug, Clone, PartialEq)]
65#[non_exhaustive]
66#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
67pub struct FamConfig {
68 pub ncomp: usize,
70 pub bandwidth: f64,
72 pub kernel: String,
74 pub n_grid_bandwidth: usize,
76}
77
78impl Default for FamConfig {
79 fn default() -> Self {
80 Self {
81 ncomp: 0,
82 bandwidth: 0.0,
83 kernel: "gaussian".to_string(),
84 n_grid_bandwidth: 20,
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq)]
91#[non_exhaustive]
92#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
93pub struct GkamConfig {
94 pub bandwidth: f64,
96 pub kernel: String,
98 pub max_iter: usize,
100 pub epsilon: f64,
102}
103
104impl Default for GkamConfig {
105 fn default() -> Self {
106 Self {
107 bandwidth: 0.0,
108 kernel: "gaussian".to_string(),
109 max_iter: 50,
110 epsilon: 1e-6,
111 }
112 }
113}
114
115#[derive(Debug, Clone, PartialEq)]
117#[non_exhaustive]
118#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
119pub struct GsamConfig {
120 pub ncomp: usize,
122 pub bandwidth: f64,
124 pub kernel: String,
126 pub n_grid_bandwidth: usize,
128}
129
130impl Default for GsamConfig {
131 fn default() -> Self {
132 Self {
133 ncomp: 0,
134 bandwidth: 0.0,
135 kernel: "gaussian".to_string(),
136 n_grid_bandwidth: 20,
137 }
138 }
139}
140
141#[derive(Debug, Clone, PartialEq)]
147#[non_exhaustive]
148#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
149pub struct FamResult {
150 pub fitted_values: Vec<f64>,
152 pub residuals: Vec<f64>,
154 pub component_fits: Vec<Vec<f64>>,
158 pub intercept: f64,
160 pub bandwidths: Vec<f64>,
163 pub ncomp: usize,
165 pub r_squared: f64,
167 pub fpca: FpcaResult,
169}
170
171#[derive(Debug, Clone, PartialEq)]
173#[non_exhaustive]
174#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
175pub struct GkamResult {
176 pub fitted_values: Vec<f64>,
178 pub residuals: Vec<f64>,
180 pub component_fits: Vec<Vec<f64>>,
182 pub intercept: f64,
184 pub bandwidths: Vec<f64>,
186 pub iterations: usize,
188 pub converged: bool,
190 pub r_squared: f64,
192}
193
194#[derive(Debug, Clone, PartialEq)]
196#[non_exhaustive]
197#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
198pub struct GsamResult {
199 pub fitted_values: Vec<f64>,
201 pub residuals: Vec<f64>,
203 pub component_fits: Vec<Vec<f64>>,
207 pub intercept: f64,
209 pub bandwidths: Vec<f64>,
212 pub ncomp: usize,
214 pub r_squared: f64,
216 pub fpca: FpcaResult,
218}
219
220fn resolve_ncomp_additive(
234 ncomp: usize,
235 n: usize,
236 m: usize,
237 data: &FdMatrix,
238 y: &[f64],
239 argvals: &[f64],
240 kernel: &str,
241 n_grid: usize,
242) -> Result<usize, FdarError> {
243 let max_ncomp = n.min(m);
244 if ncomp == 0 {
245 let cap = max_ncomp.clamp(1, 10);
249 let fpca_full = fdata_to_pc_1d(data, cap, argvals)?;
250 let mu_y = y.iter().sum::<f64>() / n as f64;
251 let mut best_ncomp = 1usize;
252 let mut best_gcv = f64::INFINITY;
253 let mut component_fits_acc: Vec<Vec<f64>> = Vec::with_capacity(cap);
255
256 for j in 0..cap {
257 let xi_j: Vec<f64> = (0..n).map(|i| fpca_full.scores[(i, j)]).collect();
258 let partial: Vec<f64> = (0..n)
260 .map(|i| {
261 let prior_sum: f64 = component_fits_acc.iter().map(|cf| cf[i]).sum();
262 y[i] - mu_y - prior_sum
263 })
264 .collect();
265 let bw_result =
266 optim_bandwidth(&xi_j, &partial, None, CvCriterion::Gcv, kernel, n_grid);
267 let gcv_j = bw_result.value;
268 let fit_j = nadaraya_watson(&xi_j, &partial, &xi_j, bw_result.h_opt, kernel)
270 .unwrap_or_else(|_| vec![0.0; n]);
271 component_fits_acc.push(fit_j);
272 if gcv_j < best_gcv {
273 best_gcv = gcv_j;
274 best_ncomp = j + 1; }
276 }
277 Ok(best_ncomp)
278 } else if ncomp > max_ncomp {
279 Err(FdarError::InvalidParameter {
280 parameter: "config.ncomp",
281 message: format!(
282 "ncomp ({ncomp}) exceeds min(n, m) = {max_ncomp}; reduce ncomp or provide more data"
283 ),
284 })
285 } else {
286 Ok(ncomp)
287 }
288}
289
290#[allow(clippy::too_many_arguments)]
298fn fpc_additive_smooth(
299 fpca: &FpcaResult,
300 y: &[f64],
301 n: usize,
302 ncomp: usize,
303 bandwidth: f64,
304 kernel: &str,
305 n_grid: usize,
306 scalar_covariates: Option<&FdMatrix>,
307) -> Result<(Vec<Vec<f64>>, Vec<f64>, f64, Vec<f64>, Vec<f64>, f64), FdarError> {
308 let mu_y = y.iter().sum::<f64>() / n as f64;
309
310 let p_scalar = scalar_covariates.map_or(0, FdMatrix::ncols);
312 let total_comp = ncomp + p_scalar;
313
314 let mut all_scores: Vec<Vec<f64>> = Vec::with_capacity(total_comp);
316 for k in 0..ncomp {
317 all_scores.push((0..n).map(|i| fpca.scores[(i, k)]).collect());
318 }
319 if let Some(sc) = scalar_covariates {
320 for j in 0..p_scalar {
321 all_scores.push((0..n).map(|i| sc[(i, j)]).collect());
322 }
323 }
324
325 let mut component_fits: Vec<Vec<f64>> = vec![vec![0.0; n]; total_comp];
327 let mut bandwidths = vec![0.0_f64; total_comp];
328
329 for k in 0..total_comp {
330 let partial: Vec<f64> = (0..n)
332 .map(|i| {
333 let others: f64 = (0..total_comp)
334 .filter(|&j| j != k)
335 .map(|j| component_fits[j][i])
336 .sum();
337 y[i] - mu_y - others
338 })
339 .collect();
340
341 let xi_k = &all_scores[k];
342 let h = if bandwidth > 0.0 {
343 bandwidth
344 } else {
345 optim_bandwidth(xi_k, &partial, None, CvCriterion::Gcv, kernel, n_grid).h_opt
346 };
347 bandwidths[k] = h;
348
349 component_fits[k] = nadaraya_watson(xi_k, &partial, xi_k, h, kernel)?;
351 }
352
353 let fitted_values: Vec<f64> = (0..n)
355 .map(|i| mu_y + (0..total_comp).map(|k| component_fits[k][i]).sum::<f64>())
356 .collect();
357 let residuals: Vec<f64> = y
358 .iter()
359 .zip(&fitted_values)
360 .map(|(&yi, &yh)| yi - yh)
361 .collect();
362
363 let (r_squared, _) = super::compute_r_squared(y, &residuals, total_comp);
365
366 Ok((
370 component_fits,
371 bandwidths,
372 mu_y,
373 fitted_values,
374 residuals,
375 r_squared,
376 ))
377}
378
379#[must_use = "expensive computation whose result should not be discarded"]
430pub fn fam(
431 data: &FdMatrix,
432 y: &[f64],
433 argvals: &[f64],
434 scalar_covariates: Option<&FdMatrix>,
435 config: &FamConfig,
436) -> Result<FamResult, FdarError> {
437 let (n, m) = data.shape();
438
439 if n == 0 {
441 return Err(FdarError::InvalidDimension {
442 parameter: "data",
443 expected: "at least 1 row".to_string(),
444 actual: "0".to_string(),
445 });
446 }
447 if m == 0 {
448 return Err(FdarError::InvalidDimension {
449 parameter: "data",
450 expected: "at least 1 column".to_string(),
451 actual: "0".to_string(),
452 });
453 }
454 if y.len() != n {
455 return Err(FdarError::InvalidDimension {
456 parameter: "y",
457 expected: format!("{n}"),
458 actual: format!("{}", y.len()),
459 });
460 }
461 if argvals.len() != m {
462 return Err(FdarError::InvalidDimension {
463 parameter: "argvals",
464 expected: format!("{m}"),
465 actual: format!("{}", argvals.len()),
466 });
467 }
468 if let Some(sc) = scalar_covariates {
469 if sc.nrows() != n {
470 return Err(FdarError::InvalidDimension {
471 parameter: "scalar_covariates",
472 expected: format!("{n} rows"),
473 actual: format!("{} rows", sc.nrows()),
474 });
475 }
476 }
477
478 let ncomp = resolve_ncomp_additive(
480 config.ncomp,
481 n,
482 m,
483 data,
484 y,
485 argvals,
486 &config.kernel,
487 config.n_grid_bandwidth,
488 )?;
489
490 let fpca = fdata_to_pc_1d(data, ncomp, argvals)?;
492
493 let p_scalar = scalar_covariates.map_or(0, FdMatrix::ncols);
495 let total_comp = ncomp + p_scalar;
496 let (component_fits_all, bandwidths_all, intercept, fitted_values, residuals, r_squared) =
497 fpc_additive_smooth(
498 &fpca,
499 y,
500 n,
501 ncomp,
502 config.bandwidth,
503 &config.kernel,
504 config.n_grid_bandwidth,
505 scalar_covariates,
506 )?;
507
508 let component_fits: Vec<Vec<f64>> = component_fits_all.into_iter().take(total_comp).collect();
510 let bandwidths: Vec<f64> = bandwidths_all.into_iter().take(total_comp).collect();
511
512 Ok(FamResult {
513 fitted_values,
514 residuals,
515 component_fits,
516 intercept,
517 bandwidths,
518 ncomp,
519 r_squared,
520 fpca,
521 })
522}
523
524#[must_use = "expensive computation whose result should not be discarded"]
564pub fn fregre_gkam(
565 predictors: &[&FdMatrix],
566 y: &[f64],
567 argvals_list: &[&[f64]],
568 scalar_covariates: Option<&FdMatrix>,
569 config: &GkamConfig,
570) -> Result<GkamResult, FdarError> {
571 let n = y.len();
572
573 if n == 0 {
575 return Err(FdarError::InvalidDimension {
576 parameter: "y",
577 expected: "at least 1 observation".to_string(),
578 actual: "0".to_string(),
579 });
580 }
581 if predictors.is_empty() {
582 return Err(FdarError::InvalidDimension {
583 parameter: "predictors",
584 expected: "at least 1 functional predictor".to_string(),
585 actual: "0".to_string(),
586 });
587 }
588 if predictors.len() != argvals_list.len() {
589 return Err(FdarError::InvalidDimension {
590 parameter: "argvals_list",
591 expected: format!("{} (matching predictors.len())", predictors.len()),
592 actual: format!("{}", argvals_list.len()),
593 });
594 }
595 for (k, pred) in predictors.iter().enumerate() {
596 if pred.nrows() != n {
597 return Err(FdarError::InvalidDimension {
598 parameter: "predictors[k].nrows()",
599 expected: format!("{n} (y.len())"),
600 actual: format!("{} for predictor {k}", pred.nrows()),
601 });
602 }
603 if argvals_list[k].len() != pred.ncols() {
604 return Err(FdarError::InvalidDimension {
605 parameter: "argvals_list[k]",
606 expected: format!("{} (predictors[k].ncols())", pred.ncols()),
607 actual: format!("{} for predictor {k}", argvals_list[k].len()),
608 });
609 }
610 }
611 if let Some(sc) = scalar_covariates {
612 if sc.nrows() != n {
613 return Err(FdarError::InvalidDimension {
614 parameter: "scalar_covariates",
615 expected: format!("{n} rows"),
616 actual: format!("{} rows", sc.nrows()),
617 });
618 }
619 }
620
621 let q = predictors.len();
622 let p_scalar = scalar_covariates.map_or(0, FdMatrix::ncols);
623 let total_comp = q + p_scalar;
624
625 let mu_y = y.iter().sum::<f64>() / n as f64;
626
627 let dist_matrices: Vec<Vec<f64>> = predictors
629 .iter()
630 .zip(argvals_list.iter())
631 .map(|(pred, argvals)| compute_pairwise_distances(pred, argvals))
632 .collect();
633
634 let bandwidths_func: Vec<f64> = if config.bandwidth > 0.0 {
636 vec![config.bandwidth; q]
637 } else {
638 dist_matrices
639 .iter()
640 .map(|dists| select_bandwidth_loo(dists, y, n, None))
641 .collect()
642 };
643
644 let scalar_dists: Vec<Vec<f64>> = if let Some(sc) = scalar_covariates {
646 (0..p_scalar)
647 .map(|j| {
648 let mut d = vec![0.0_f64; n * n];
649 for i in 0..n {
650 for jj in (i + 1)..n {
651 let diff = sc[(i, j)] - sc[(jj, j)];
652 let dist = diff.abs();
653 d[i * n + jj] = dist;
654 d[jj * n + i] = dist;
655 }
656 }
657 d
658 })
659 .collect()
660 } else {
661 Vec::new()
662 };
663
664 let scalar_bandwidths: Vec<f64> = if p_scalar > 0 {
665 if config.bandwidth > 0.0 {
666 vec![config.bandwidth; p_scalar]
667 } else {
668 scalar_dists
669 .iter()
670 .map(|dists| select_bandwidth_loo(dists, y, n, None))
671 .collect()
672 }
673 } else {
674 Vec::new()
675 };
676
677 let mut all_bandwidths = bandwidths_func.clone();
679 all_bandwidths.extend_from_slice(&scalar_bandwidths);
680
681 let mut component_fits = vec![vec![0.0_f64; n]; total_comp];
683 let mut converged = false;
684 let mut iterations = 0;
685
686 for iter in 0..config.max_iter {
688 let mut max_delta = 0.0_f64;
689
690 for k in 0..q {
692 let h_k = all_bandwidths[k];
693 let dists_k = &dist_matrices[k];
694
695 let adjusted: Vec<f64> = (0..n)
697 .map(|i| {
698 let others: f64 = (0..total_comp)
699 .filter(|&j| j != k)
700 .map(|j| component_fits[j][i])
701 .sum();
702 y[i] - mu_y - others
703 })
704 .collect();
705
706 let new_fk: Vec<f64> = (0..n)
708 .map(|i| {
709 let mut num = 0.0_f64;
710 let mut den = 0.0_f64;
711 for j in 0..n {
712 let w = gaussian_kernel(dists_k[i * n + j], h_k);
713 num += w * adjusted[j];
714 den += w;
715 }
716 if den > 1e-15 {
717 num / den
718 } else {
719 adjusted[i]
720 }
721 })
722 .collect();
723
724 let delta = component_fits[k]
726 .iter()
727 .zip(&new_fk)
728 .map(|(old, &new)| (old - new).abs())
729 .fold(0.0_f64, f64::max);
730 max_delta = max_delta.max(delta);
731 component_fits[k] = new_fk;
732 }
733
734 for s_idx in 0..p_scalar {
736 let k = q + s_idx;
737 let h_k = all_bandwidths[k];
738 let dists_k = &scalar_dists[s_idx];
739
740 let adjusted: Vec<f64> = (0..n)
741 .map(|i| {
742 let others: f64 = (0..total_comp)
743 .filter(|&j| j != k)
744 .map(|j| component_fits[j][i])
745 .sum();
746 y[i] - mu_y - others
747 })
748 .collect();
749
750 let new_fk: Vec<f64> = (0..n)
751 .map(|i| {
752 let mut num = 0.0_f64;
753 let mut den = 0.0_f64;
754 for j in 0..n {
755 let w = gaussian_kernel(dists_k[i * n + j], h_k);
756 num += w * adjusted[j];
757 den += w;
758 }
759 if den > 1e-15 {
760 num / den
761 } else {
762 adjusted[i]
763 }
764 })
765 .collect();
766
767 let delta = component_fits[k]
768 .iter()
769 .zip(&new_fk)
770 .map(|(old, &new)| (old - new).abs())
771 .fold(0.0_f64, f64::max);
772 max_delta = max_delta.max(delta);
773 component_fits[k] = new_fk;
774 }
775
776 iterations = iter + 1;
777 if max_delta < config.epsilon {
778 converged = true;
779 break;
780 }
781 }
782
783 let fitted_values: Vec<f64> = (0..n)
785 .map(|i| mu_y + (0..total_comp).map(|k| component_fits[k][i]).sum::<f64>())
786 .collect();
787 let residuals: Vec<f64> = y
788 .iter()
789 .zip(&fitted_values)
790 .map(|(&yi, &yh)| yi - yh)
791 .collect();
792 let (r_squared, _) = super::compute_r_squared(y, &residuals, total_comp);
793
794 Ok(GkamResult {
795 fitted_values,
796 residuals,
797 component_fits,
798 intercept: mu_y,
799 bandwidths: all_bandwidths,
800 iterations,
801 converged,
802 r_squared,
803 })
804}
805
806#[must_use = "expensive computation whose result should not be discarded"]
842pub fn fregre_gsam(
843 data: &FdMatrix,
844 y: &[f64],
845 argvals: &[f64],
846 scalar_covariates: Option<&FdMatrix>,
847 config: &GsamConfig,
848) -> Result<GsamResult, FdarError> {
849 let (n, m) = data.shape();
850
851 if n == 0 {
853 return Err(FdarError::InvalidDimension {
854 parameter: "data",
855 expected: "at least 1 row".to_string(),
856 actual: "0".to_string(),
857 });
858 }
859 if m == 0 {
860 return Err(FdarError::InvalidDimension {
861 parameter: "data",
862 expected: "at least 1 column".to_string(),
863 actual: "0".to_string(),
864 });
865 }
866 if y.len() != n {
867 return Err(FdarError::InvalidDimension {
868 parameter: "y",
869 expected: format!("{n}"),
870 actual: format!("{}", y.len()),
871 });
872 }
873 if argvals.len() != m {
874 return Err(FdarError::InvalidDimension {
875 parameter: "argvals",
876 expected: format!("{m}"),
877 actual: format!("{}", argvals.len()),
878 });
879 }
880 if let Some(sc) = scalar_covariates {
881 if sc.nrows() != n {
882 return Err(FdarError::InvalidDimension {
883 parameter: "scalar_covariates",
884 expected: format!("{n} rows"),
885 actual: format!("{} rows", sc.nrows()),
886 });
887 }
888 }
889
890 let ncomp = resolve_ncomp_additive(
892 config.ncomp,
893 n,
894 m,
895 data,
896 y,
897 argvals,
898 &config.kernel,
899 config.n_grid_bandwidth,
900 )?;
901
902 let fpca = fdata_to_pc_1d(data, ncomp, argvals)?;
904
905 let p_scalar = scalar_covariates.map_or(0, FdMatrix::ncols);
907 let total_comp = ncomp + p_scalar;
908 let (component_fits_all, bandwidths_all, intercept, fitted_values, residuals, r_squared) =
909 fpc_additive_smooth(
910 &fpca,
911 y,
912 n,
913 ncomp,
914 config.bandwidth,
915 &config.kernel,
916 config.n_grid_bandwidth,
917 scalar_covariates,
918 )?;
919
920 let component_fits: Vec<Vec<f64>> = component_fits_all.into_iter().take(total_comp).collect();
921 let bandwidths: Vec<f64> = bandwidths_all.into_iter().take(total_comp).collect();
922
923 Ok(GsamResult {
924 fitted_values,
925 residuals,
926 component_fits,
927 intercept,
928 bandwidths,
929 ncomp,
930 r_squared,
931 fpca,
932 })
933}
934
935#[derive(Debug, Clone, Copy, PartialEq)]
946#[non_exhaustive]
947#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
948pub enum VarSelectPenalty {
949 GroupLasso,
951 GroupMcp,
954 GroupScad,
958 Ls,
960}
961
962#[derive(Debug, Clone, PartialEq)]
964#[non_exhaustive]
965#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
966pub struct VarSelectConfig {
967 pub ncomp: usize,
969 pub penalty: VarSelectPenalty,
971 pub lambda: f64,
973 pub max_iter: usize,
975 pub epsilon: f64,
977 pub lambda_n_grid: usize,
979}
980
981impl Default for VarSelectConfig {
982 fn default() -> Self {
983 Self {
984 ncomp: 3,
985 penalty: VarSelectPenalty::GroupLasso,
986 lambda: 0.0,
987 max_iter: 100,
988 epsilon: 1e-5,
989 lambda_n_grid: 20,
990 }
991 }
992}
993
994#[derive(Debug, Clone, PartialEq)]
996#[non_exhaustive]
997#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
998pub struct VarSelectResult {
999 pub active_predictors: Vec<bool>,
1001 pub coefficients: Vec<Vec<f64>>,
1003 pub fitted_values: Vec<f64>,
1005 pub residuals: Vec<f64>,
1007 pub intercept: f64,
1009 pub lambda: f64,
1011 pub r_squared: f64,
1013 pub iterations: usize,
1015 pub converged: bool,
1017 pub fpcas: Vec<FpcaResult>,
1019}
1020
1021#[derive(Debug, Clone, Copy, PartialEq)]
1023#[non_exhaustive]
1024#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1025pub enum PermTestStatistic {
1026 R2,
1028 FittedNorm,
1030 ComponentNorm,
1032}
1033
1034#[derive(Debug, Clone, PartialEq)]
1036#[non_exhaustive]
1037#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1038pub struct PermTestConfig {
1039 pub n_perm: usize,
1041 pub seed: u64,
1043 pub statistic: PermTestStatistic,
1045}
1046
1047impl Default for PermTestConfig {
1048 fn default() -> Self {
1049 Self {
1050 n_perm: 999,
1051 seed: 42,
1052 statistic: PermTestStatistic::R2,
1053 }
1054 }
1055}
1056
1057#[derive(Debug, Clone, PartialEq)]
1059#[non_exhaustive]
1060pub struct PermTestResult {
1061 pub p_value: f64,
1065 pub observed_statistic: f64,
1067 pub null_statistics: Vec<f64>,
1069 pub n_perm_success: usize,
1071}
1072
1073#[derive(Debug, Clone, PartialEq)]
1075#[non_exhaustive]
1076#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1077pub struct HistoryIndexConfig {
1078 pub window: f64,
1080 pub n_lags: usize,
1082 pub bandwidth: f64,
1084 pub kernel: String,
1086}
1087
1088impl Default for HistoryIndexConfig {
1089 fn default() -> Self {
1090 Self {
1091 window: 1.0,
1092 n_lags: 20,
1093 bandwidth: 0.0,
1094 kernel: "gaussian".to_string(),
1095 }
1096 }
1097}
1098
1099#[derive(Debug, Clone, PartialEq)]
1101#[non_exhaustive]
1102#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1103pub struct HistoryIndexResult {
1104 pub fitted_values: Vec<f64>,
1106 pub residuals: Vec<f64>,
1108 pub intercept: f64,
1110 pub slope: f64,
1112 pub gamma: Vec<f64>,
1114 pub lag_grid: Vec<f64>,
1116 pub history_scores: Vec<f64>,
1118 pub r_squared: f64,
1120}
1121
1122#[must_use = "expensive computation whose result should not be discarded"]
1188pub fn variable_selection(
1189 predictors: &[&FdMatrix],
1190 y: &[f64],
1191 argvals_list: &[&[f64]],
1192 scalar_covariates: Option<&FdMatrix>,
1193 config: &VarSelectConfig,
1194) -> Result<VarSelectResult, FdarError> {
1195 match config.penalty {
1197 VarSelectPenalty::GroupMcp | VarSelectPenalty::GroupScad => {
1198 return Err(FdarError::InvalidParameter {
1199 parameter: "config.penalty",
1200 message: "GroupMcp and GroupScad are not yet implemented; use GroupLasso"
1201 .to_string(),
1202 });
1203 }
1204 VarSelectPenalty::GroupLasso | VarSelectPenalty::Ls => {}
1205 }
1206
1207 let n = y.len();
1208
1209 if predictors.is_empty() {
1211 return Err(FdarError::InvalidDimension {
1212 parameter: "predictors",
1213 expected: "at least 1 functional predictor".to_string(),
1214 actual: "0".to_string(),
1215 });
1216 }
1217 if predictors.len() != argvals_list.len() {
1218 return Err(FdarError::InvalidDimension {
1219 parameter: "argvals_list",
1220 expected: format!("{} (matching predictors.len())", predictors.len()),
1221 actual: format!("{}", argvals_list.len()),
1222 });
1223 }
1224 for (p, pred) in predictors.iter().enumerate() {
1225 if pred.nrows() != n {
1226 return Err(FdarError::InvalidDimension {
1227 parameter: "predictors[p].nrows()",
1228 expected: format!("{n} (y.len())"),
1229 actual: format!("{} for predictor {p}", pred.nrows()),
1230 });
1231 }
1232 }
1233
1234 let big_p = predictors.len();
1235 let mu_y = y.iter().sum::<f64>() / n as f64;
1236
1237 let ncomp_per = if config.ncomp == 0 { 3 } else { config.ncomp };
1239
1240 let mut fpcas: Vec<FpcaResult> = Vec::with_capacity(big_p);
1241 let mut score_groups: Vec<Vec<Vec<f64>>> = Vec::with_capacity(big_p); for p in 0..big_p {
1244 let pred = predictors[p];
1245 let argvals = argvals_list[p];
1246 let (np, mp) = pred.shape();
1247 let k_p = ncomp_per.min(np.min(mp).saturating_sub(1).max(1));
1248 let fpca_p = fdata_to_pc_1d(pred, k_p, argvals)?;
1249 let k_actual = fpca_p.scores.ncols();
1250 let group_scores: Vec<Vec<f64>> = (0..k_actual)
1251 .map(|k| (0..n).map(|i| fpca_p.scores[(i, k)]).collect())
1252 .collect();
1253 score_groups.push(group_scores);
1254 fpcas.push(fpca_p);
1255 }
1256
1257 if config.penalty == VarSelectPenalty::Ls {
1259 return variable_selection_ls(y, n, mu_y, big_p, fpcas, score_groups, scalar_covariates);
1260 }
1261
1262 let k_sizes: Vec<usize> = score_groups.iter().map(|g| g.len()).collect();
1265
1266 let y_centered: Vec<f64> = y.iter().map(|&yi| yi - mu_y).collect();
1268 let lambda_max = k_sizes
1269 .iter()
1270 .zip(score_groups.iter())
1271 .map(|(&k_g, group)| {
1272 let norm_sq: f64 = group
1273 .iter()
1274 .map(|col| {
1275 let xgty: f64 = col.iter().zip(&y_centered).map(|(&x, &yc)| x * yc).sum();
1276 xgty * xgty
1277 })
1278 .sum::<f64>();
1279 norm_sq.sqrt() / (k_g as f64).sqrt()
1280 })
1281 .fold(0.0_f64, f64::max)
1282 .max(1e-10); let lambda = if config.lambda > 0.0 {
1286 config.lambda
1287 } else {
1288 select_group_lasso_lambda(
1289 y,
1290 &y_centered,
1291 mu_y,
1292 n,
1293 &score_groups,
1294 &k_sizes,
1295 lambda_max,
1296 config.lambda_n_grid,
1297 config.max_iter,
1298 config.epsilon,
1299 scalar_covariates,
1300 )
1301 };
1302
1303 let (coefficients, iterations, converged) = group_lasso_cd(
1305 y,
1306 &y_centered,
1307 mu_y,
1308 n,
1309 &score_groups,
1310 &k_sizes,
1311 lambda,
1312 config.max_iter,
1313 config.epsilon,
1314 scalar_covariates,
1315 )?;
1316
1317 let fitted_values: Vec<f64> = compute_varselect_fitted(
1319 n,
1320 mu_y,
1321 &score_groups,
1322 &coefficients,
1323 scalar_covariates,
1324 big_p,
1325 );
1326 let residuals: Vec<f64> = y
1327 .iter()
1328 .zip(&fitted_values)
1329 .map(|(&yi, &yh)| yi - yh)
1330 .collect();
1331 let (r_squared, _) = super::compute_r_squared(y, &residuals, k_sizes.iter().sum::<usize>());
1332
1333 let active_predictors: Vec<bool> = coefficients[..big_p]
1334 .iter()
1335 .map(|beta_g| {
1336 let norm: f64 = beta_g.iter().map(|&b| b * b).sum::<f64>();
1337 norm.sqrt() > config.epsilon
1338 })
1339 .collect();
1340
1341 Ok(VarSelectResult {
1342 active_predictors,
1343 coefficients,
1344 fitted_values,
1345 residuals,
1346 intercept: mu_y,
1347 lambda,
1348 r_squared,
1349 iterations,
1350 converged,
1351 fpcas,
1352 })
1353}
1354
1355fn variable_selection_ls(
1357 y: &[f64],
1358 n: usize,
1359 mu_y: f64,
1360 big_p: usize,
1361 fpcas: Vec<FpcaResult>,
1362 score_groups: Vec<Vec<Vec<f64>>>,
1363 scalar_covariates: Option<&FdMatrix>,
1364) -> Result<VarSelectResult, FdarError> {
1365 let k_sizes: Vec<usize> = score_groups.iter().map(|g| g.len()).collect();
1366 let p_scalar = scalar_covariates.map_or(0, FdMatrix::ncols);
1367 let total_cols = k_sizes.iter().sum::<usize>() + p_scalar;
1368 let mut x_flat = vec![0.0_f64; n * total_cols];
1370 let mut col_offset = 0;
1371 for grp in &score_groups {
1372 for col in grp {
1373 for (i, &v) in col.iter().enumerate() {
1374 x_flat[col_offset * n + i] = v;
1375 }
1376 col_offset += 1;
1377 }
1378 }
1379 if let Some(sc) = scalar_covariates {
1380 for j in 0..p_scalar {
1381 for i in 0..n {
1382 x_flat[col_offset * n + i] = sc[(i, j)];
1383 }
1384 col_offset += 1;
1385 }
1386 }
1387 let x_mat = FdMatrix::from_column_major(x_flat, n, total_cols).map_err(|e| {
1388 FdarError::ComputationFailed {
1389 operation: "variable_selection_ls design matrix",
1390 detail: e.to_string(),
1391 }
1392 })?;
1393 let y_centered: Vec<f64> = y.iter().map(|&yi| yi - mu_y).collect();
1394 let xtx = super::compute_xtx(&x_mat);
1395 let xty: Vec<f64> = (0..total_cols)
1396 .map(|k| {
1397 x_mat
1398 .column(k)
1399 .iter()
1400 .zip(&y_centered)
1401 .map(|(&xv, &yv)| xv * yv)
1402 .sum::<f64>()
1403 })
1404 .collect();
1405 let l = super::cholesky_factor(&xtx, total_cols).map_err(|_| FdarError::ComputationFailed {
1406 operation: "variable_selection_ls cholesky",
1407 detail: "design matrix is singular".to_string(),
1408 })?;
1409 let flat_coeffs = super::cholesky_forward_back(&l, &xty, total_cols);
1410
1411 let mut coefficients: Vec<Vec<f64>> = Vec::with_capacity(big_p + 1);
1413 let mut offset = 0;
1414 for &k_g in &k_sizes {
1415 coefficients.push(flat_coeffs[offset..offset + k_g].to_vec());
1416 offset += k_g;
1417 }
1418 coefficients.push(flat_coeffs[offset..offset + p_scalar].to_vec());
1420
1421 let fitted_values = compute_varselect_fitted(
1422 n,
1423 mu_y,
1424 &score_groups,
1425 &coefficients,
1426 scalar_covariates,
1427 big_p,
1428 );
1429 let residuals: Vec<f64> = y
1430 .iter()
1431 .zip(&fitted_values)
1432 .map(|(&yi, &yh)| yi - yh)
1433 .collect();
1434 let (r_squared, _) = super::compute_r_squared(y, &residuals, total_cols);
1435 let active_predictors = vec![true; big_p];
1436 Ok(VarSelectResult {
1437 active_predictors,
1438 coefficients,
1439 fitted_values,
1440 residuals,
1441 intercept: mu_y,
1442 lambda: 0.0,
1443 r_squared,
1444 iterations: 1,
1445 converged: true,
1446 fpcas,
1447 })
1448}
1449
1450fn compute_varselect_fitted(
1452 n: usize,
1453 mu_y: f64,
1454 score_groups: &[Vec<Vec<f64>>],
1455 coefficients: &[Vec<f64>],
1456 scalar_covariates: Option<&FdMatrix>,
1457 big_p: usize,
1458) -> Vec<f64> {
1459 let p_scalar = scalar_covariates.map_or(0, FdMatrix::ncols);
1460 (0..n)
1461 .map(|i| {
1462 let mut yhat = mu_y;
1463 for p in 0..big_p {
1464 for (k, col) in score_groups[p].iter().enumerate() {
1465 yhat += coefficients[p][k] * col[i];
1466 }
1467 }
1468 if let Some(sc) = scalar_covariates {
1469 for j in 0..p_scalar {
1470 yhat += coefficients[big_p][j] * sc[(i, j)];
1471 }
1472 }
1473 yhat
1474 })
1475 .collect()
1476}
1477
1478#[allow(clippy::too_many_arguments)]
1487fn select_group_lasso_lambda(
1488 y: &[f64],
1489 _y_centered: &[f64],
1490 _mu_y: f64,
1491 n: usize,
1492 score_groups: &[Vec<Vec<f64>>],
1493 _k_sizes: &[usize],
1494 lambda_max: f64,
1495 n_grid: usize,
1496 max_iter: usize,
1497 epsilon: f64,
1498 scalar_covariates: Option<&FdMatrix>,
1499) -> f64 {
1500 let grid_size = n_grid.max(2);
1501 let big_p = score_groups.len();
1502
1503 let n_folds = 5_usize.min(n).max(2);
1505
1506 let fold_of: Vec<usize> = (0..n).map(|i| i % n_folds).collect();
1509
1510 let mut best_lambda = lambda_max * 0.1;
1511 let mut best_cv_err = f64::INFINITY;
1512
1513 for gi in 0..grid_size {
1514 let frac = (gi as f64 + 1.0) / grid_size as f64;
1515 let lam = lambda_max * (0.01_f64.powf(1.0 - frac)); let mut cv_sq_err = 0.0_f64;
1518 let mut cv_count = 0usize;
1519
1520 for fold in 0..n_folds {
1521 let train_idx: Vec<usize> = (0..n).filter(|&i| fold_of[i] != fold).collect();
1523 let val_idx: Vec<usize> = (0..n).filter(|&i| fold_of[i] == fold).collect();
1524 if train_idx.is_empty() || val_idx.is_empty() {
1525 continue;
1526 }
1527
1528 let n_tr = train_idx.len();
1529 let mu_tr = train_idx.iter().map(|&i| y[i]).sum::<f64>() / n_tr as f64;
1530 let y_tr_centered: Vec<f64> = train_idx.iter().map(|&i| y[i] - mu_tr).collect();
1531 let y_tr: Vec<f64> = train_idx.iter().map(|&i| y[i]).collect();
1532
1533 let sg_tr: Vec<Vec<Vec<f64>>> = score_groups
1535 .iter()
1536 .map(|grp| {
1537 grp.iter()
1538 .map(|col| train_idx.iter().map(|&i| col[i]).collect())
1539 .collect()
1540 })
1541 .collect();
1542
1543 let sc_tr_mat: Option<FdMatrix> = scalar_covariates.and_then(|sc| {
1545 let p_sc = sc.ncols();
1546 let mut cm = vec![0.0_f64; n_tr * p_sc];
1547 for (row, &orig_i) in train_idx.iter().enumerate() {
1548 for j in 0..p_sc {
1549 cm[j * n_tr + row] = sc[(orig_i, j)];
1550 }
1551 }
1552 FdMatrix::from_column_major(cm, n_tr, p_sc).ok()
1553 });
1554
1555 let k_sizes_tr: Vec<usize> = sg_tr.iter().map(|g| g.len()).collect();
1556
1557 let fit_result = group_lasso_cd(
1558 &y_tr,
1559 &y_tr_centered,
1560 mu_tr,
1561 n_tr,
1562 &sg_tr,
1563 &k_sizes_tr,
1564 lam,
1565 max_iter,
1566 epsilon,
1567 sc_tr_mat.as_ref(),
1568 );
1569
1570 if let Ok((coeffs_tr, _, _)) = fit_result {
1571 for &i in &val_idx {
1573 let mut yhat = mu_tr;
1574 for p in 0..big_p {
1575 for (k, col) in score_groups[p].iter().enumerate() {
1576 yhat += coeffs_tr[p][k] * col[i];
1577 }
1578 }
1579 if let Some(sc) = scalar_covariates {
1580 let p_sc = sc.ncols();
1581 for j in 0..p_sc {
1582 yhat += coeffs_tr[big_p][j] * sc[(i, j)];
1583 }
1584 }
1585 let err = y[i] - yhat;
1586 cv_sq_err += err * err;
1587 cv_count += 1;
1588 }
1589 }
1590 }
1591
1592 if cv_count > 0 {
1593 let cv_mse = cv_sq_err / cv_count as f64;
1594 if cv_mse < best_cv_err {
1595 best_cv_err = cv_mse;
1596 best_lambda = lam;
1597 }
1598 }
1599 }
1600 best_lambda
1601}
1602
1603#[allow(clippy::too_many_arguments)]
1609fn group_lasso_cd(
1610 _y: &[f64],
1611 y_centered: &[f64],
1612 _mu_y: f64,
1613 n: usize,
1614 score_groups: &[Vec<Vec<f64>>],
1615 k_sizes: &[usize],
1616 lambda: f64,
1617 max_iter: usize,
1618 epsilon: f64,
1619 scalar_covariates: Option<&FdMatrix>,
1620) -> Result<(Vec<Vec<f64>>, usize, bool), FdarError> {
1621 let big_p = score_groups.len();
1622 let p_scalar = scalar_covariates.map_or(0, FdMatrix::ncols);
1623
1624 let mut beta_groups: Vec<Vec<f64>> = score_groups
1626 .iter()
1627 .map(|grp| vec![0.0_f64; grp.len()])
1628 .collect();
1629 let mut beta_scalar: Vec<f64> = vec![0.0_f64; p_scalar];
1630
1631 let mut converged = false;
1632 let mut iterations = 0;
1633
1634 for _iter in 0..max_iter {
1635 let mut max_delta = 0.0_f64;
1636
1637 for p in 0..big_p {
1639 let k_g = k_sizes[p];
1640 let group = &score_groups[p];
1641
1642 let partial: Vec<f64> = (0..n)
1644 .map(|i| {
1645 let mut res = y_centered[i];
1646 for q in 0..big_p {
1647 if q != p {
1648 for (k, col) in score_groups[q].iter().enumerate() {
1649 res -= beta_groups[q][k] * col[i];
1650 }
1651 }
1652 }
1653 if let Some(sc) = scalar_covariates {
1654 for j in 0..p_scalar {
1655 res -= beta_scalar[j] * sc[(i, j)];
1656 }
1657 }
1658 res
1659 })
1660 .collect();
1661
1662 let mut xtx_g = vec![0.0_f64; k_g * k_g];
1665 let mut xty_g = vec![0.0_f64; k_g];
1666 for a in 0..k_g {
1667 for b in 0..k_g {
1668 let dot: f64 = group[a]
1669 .iter()
1670 .zip(&group[b])
1671 .map(|(&xa, &xb)| xa * xb)
1672 .sum();
1673 xtx_g[a * k_g + b] = dot;
1674 }
1675 xty_g[a] = group[a]
1676 .iter()
1677 .zip(&partial)
1678 .map(|(&xa, &pa)| xa * pa)
1679 .sum();
1680 }
1681
1682 let beta_ols =
1683 crate::linalg::cholesky_solve(&xtx_g, &xty_g, k_g).unwrap_or_else(|_| {
1684 let diag_sum: f64 = (0..k_g).map(|d| xtx_g[d * k_g + d].abs()).sum();
1688 let delta = (1e-6 * diag_sum / k_g as f64).max(1e-8);
1689 let mut xtx_ridge = xtx_g.clone();
1690 for d in 0..k_g {
1691 xtx_ridge[d * k_g + d] += delta;
1692 }
1693 crate::linalg::cholesky_solve(&xtx_ridge, &xty_g, k_g)
1694 .unwrap_or_else(|_| vec![0.0; k_g]) });
1696
1697 let norm_ols: f64 = beta_ols.iter().map(|&b| b * b).sum::<f64>().sqrt();
1699 let threshold = lambda * (k_g as f64).sqrt();
1700 let scale = if norm_ols > 1e-15 {
1701 (1.0 - threshold / norm_ols).max(0.0)
1702 } else {
1703 0.0
1704 };
1705
1706 let new_beta: Vec<f64> = beta_ols.iter().map(|&b| b * scale).collect();
1707
1708 let delta = new_beta
1710 .iter()
1711 .zip(&beta_groups[p])
1712 .map(|(&nb, &ob)| (nb - ob).abs())
1713 .fold(0.0_f64, f64::max);
1714 max_delta = max_delta.max(delta);
1715 beta_groups[p] = new_beta;
1716 }
1717
1718 if let Some(sc) = scalar_covariates {
1720 for j in 0..p_scalar {
1721 let partial_j: Vec<f64> = (0..n)
1722 .map(|i| {
1723 let mut res = y_centered[i];
1724 for p in 0..big_p {
1725 for (k, col) in score_groups[p].iter().enumerate() {
1726 res -= beta_groups[p][k] * col[i];
1727 }
1728 }
1729 for jj in 0..p_scalar {
1730 if jj != j {
1731 res -= beta_scalar[jj] * sc[(i, jj)];
1732 }
1733 }
1734 res
1735 })
1736 .collect();
1737 let col_j: Vec<f64> = (0..n).map(|i| sc[(i, j)]).collect();
1738 let xjxj: f64 = col_j.iter().map(|&v| v * v).sum();
1739 let xjy: f64 = col_j.iter().zip(&partial_j).map(|(&x, &p)| x * p).sum();
1740 let new_bj = if xjxj > 1e-15 { xjy / xjxj } else { 0.0 };
1741 let delta = (new_bj - beta_scalar[j]).abs();
1742 max_delta = max_delta.max(delta);
1743 beta_scalar[j] = new_bj;
1744 }
1745 }
1746
1747 iterations = _iter + 1;
1748 if max_delta < epsilon {
1749 converged = true;
1750 break;
1751 }
1752 }
1753
1754 let mut coefficients: Vec<Vec<f64>> = beta_groups;
1755 coefficients.push(beta_scalar);
1756 Ok((coefficients, iterations, converged))
1757}
1758
1759#[must_use = "expensive computation whose result should not be discarded"]
1820pub fn permutation_test_fam(
1821 data: &FdMatrix,
1822 y: &[f64],
1823 argvals: &[f64],
1824 scalar_covariates: Option<&FdMatrix>,
1825 config: &FamConfig,
1826 perm_config: &PermTestConfig,
1827) -> Result<PermTestResult, FdarError> {
1828 if perm_config.n_perm == 0 {
1829 return Err(FdarError::InvalidParameter {
1830 parameter: "perm_config.n_perm",
1831 message: "n_perm must be >= 1 for a meaningful permutation test".to_string(),
1832 });
1833 }
1834
1835 let original_fit = fam(data, y, argvals, scalar_covariates, config)?;
1837
1838 let observed_statistic = extract_perm_stat(&original_fit, perm_config.statistic);
1839
1840 use rand::prelude::*;
1842 let mut rng = StdRng::seed_from_u64(perm_config.seed);
1843
1844 let n_perm = perm_config.n_perm;
1845 let mut null_statistics: Vec<f64> = Vec::with_capacity(n_perm);
1846 let mut n_ge = 0usize;
1847 let mut n_perm_success = 0usize;
1848
1849 let mut y_perm: Vec<f64> = y.to_vec();
1850
1851 for _ in 0..n_perm {
1852 y_perm.copy_from_slice(y);
1854 y_perm.shuffle(&mut rng);
1855
1856 match fam(data, &y_perm, argvals, scalar_covariates, config) {
1857 Ok(perm_fit) => {
1858 let t_perm = extract_perm_stat(&perm_fit, perm_config.statistic);
1859 null_statistics.push(t_perm);
1860 if t_perm >= observed_statistic {
1861 n_ge += 1;
1862 }
1863 n_perm_success += 1;
1864 }
1865 Err(_) => {
1866 }
1868 }
1869 }
1870
1871 let p_value = (n_ge + 1) as f64 / (n_perm_success + 1) as f64;
1874
1875 Ok(PermTestResult {
1876 p_value,
1877 observed_statistic,
1878 null_statistics,
1879 n_perm_success,
1880 })
1881}
1882
1883fn extract_perm_stat(fit: &FamResult, stat: PermTestStatistic) -> f64 {
1885 match stat {
1886 PermTestStatistic::R2 => fit.r_squared,
1887 PermTestStatistic::FittedNorm => {
1888 fit.fitted_values.iter().map(|&v| v * v).sum::<f64>().sqrt()
1889 }
1890 PermTestStatistic::ComponentNorm => fit
1891 .component_fits
1892 .iter()
1893 .map(|cf| cf.iter().map(|&v| v * v).sum::<f64>().sqrt())
1894 .sum::<f64>(),
1895 }
1896}
1897
1898#[must_use = "expensive computation whose result should not be discarded"]
1962pub fn history_index(
1963 data: &FdMatrix,
1964 y: &[f64],
1965 argvals: &[f64],
1966 config: &HistoryIndexConfig,
1967) -> Result<HistoryIndexResult, FdarError> {
1968 let (n, m) = data.shape();
1969
1970 if n == 0 {
1972 return Err(FdarError::InvalidDimension {
1973 parameter: "data",
1974 expected: "at least 1 row".to_string(),
1975 actual: "0".to_string(),
1976 });
1977 }
1978 if m == 0 {
1979 return Err(FdarError::InvalidDimension {
1980 parameter: "data",
1981 expected: "at least 1 column".to_string(),
1982 actual: "0".to_string(),
1983 });
1984 }
1985 if y.len() != n {
1986 return Err(FdarError::InvalidDimension {
1987 parameter: "y",
1988 expected: format!("{n}"),
1989 actual: format!("{}", y.len()),
1990 });
1991 }
1992 if argvals.len() != m {
1993 return Err(FdarError::InvalidDimension {
1994 parameter: "argvals",
1995 expected: format!("{m}"),
1996 actual: format!("{}", argvals.len()),
1997 });
1998 }
1999
2000 let argvals_min = argvals.first().copied().unwrap_or(0.0);
2001 let argvals_max = argvals.last().copied().unwrap_or(0.0);
2002 let argvals_range = argvals_max - argvals_min;
2003
2004 if config.window <= 0.0 || config.window > argvals_range {
2005 return Err(FdarError::InvalidParameter {
2006 parameter: "config.window",
2007 message: format!(
2008 "window ({:.6}) must be positive and <= argvals range ({:.6})",
2009 config.window, argvals_range
2010 ),
2011 });
2012 }
2013
2014 let n_lags = config.n_lags.max(1);
2015 let delta_u = config.window / n_lags as f64;
2016 let big_t = argvals_max;
2017
2018 let lag_grid: Vec<f64> = (0..n_lags).map(|l| l as f64 * delta_u).collect();
2020
2021 let x_lag: Vec<Vec<f64>> = (0..n)
2027 .map(|i| {
2028 lag_grid
2029 .iter()
2030 .map(|&u_l| {
2031 let t_target = big_t - u_l;
2032 let j = argvals
2034 .partition_point(|&v| v < t_target)
2035 .saturating_sub(1)
2036 .min(m - 1);
2037 data[(i, j)]
2038 })
2039 .collect()
2040 })
2041 .collect();
2042
2043 let mu_y = y.iter().sum::<f64>() / n as f64;
2046 let y_centered: Vec<f64> = y.iter().map(|&yi| yi - mu_y).collect();
2047
2048 let gamma_signal: Vec<f64> = lag_grid
2051 .iter()
2052 .enumerate()
2053 .map(|(l, _)| {
2054 let x_col: Vec<f64> = (0..n).map(|i| x_lag[i][l]).collect();
2055 let x_mean = x_col.iter().sum::<f64>() / n as f64;
2056 let xx: f64 = x_col.iter().map(|&v| (v - x_mean).powi(2)).sum();
2057 let xy: f64 = x_col
2058 .iter()
2059 .zip(&y_centered)
2060 .map(|(&x, &yc)| (x - x_mean) * yc)
2061 .sum();
2062 if xx > 1e-15 {
2063 xy / xx
2064 } else {
2065 0.0
2066 }
2067 })
2068 .collect();
2069
2070 let h_gamma = if config.bandwidth > 0.0 {
2072 config.bandwidth
2073 } else {
2074 let bw_result = optim_bandwidth(
2075 &lag_grid,
2076 &gamma_signal,
2077 None,
2078 CvCriterion::Gcv,
2079 &config.kernel,
2080 20,
2081 );
2082 bw_result.h_opt.max(delta_u) };
2084
2085 let gamma_raw = nadaraya_watson(&lag_grid, &gamma_signal, &lag_grid, h_gamma, &config.kernel)?;
2086
2087 let norm_sq: f64 = gamma_raw.iter().map(|&g| g * g).sum::<f64>() * delta_u;
2089 let norm = norm_sq.sqrt();
2090 let gamma: Vec<f64> = if norm > 1e-15 {
2091 gamma_raw.iter().map(|&g| g / norm).collect()
2092 } else {
2093 vec![1.0 / (n_lags as f64).sqrt(); n_lags]
2094 };
2095
2096 let history_scores: Vec<f64> = (0..n)
2098 .map(|i| {
2099 gamma
2100 .iter()
2101 .enumerate()
2102 .map(|(l, &g)| g * x_lag[i][l] * delta_u)
2103 .sum()
2104 })
2105 .collect();
2106
2107 let score_mean = history_scores.iter().sum::<f64>() / n as f64;
2109 let sxx: f64 = history_scores
2110 .iter()
2111 .map(|&s| (s - score_mean).powi(2))
2112 .sum();
2113 let sxy: f64 = history_scores
2114 .iter()
2115 .zip(y.iter())
2116 .map(|(&s, &yi)| (s - score_mean) * yi)
2117 .sum();
2118 let slope = if sxx > 1e-15 { sxy / sxx } else { 0.0 };
2119 let intercept = mu_y - slope * score_mean;
2120
2121 let fitted_values: Vec<f64> = history_scores
2122 .iter()
2123 .map(|&s| intercept + slope * s)
2124 .collect();
2125 let residuals: Vec<f64> = y
2126 .iter()
2127 .zip(&fitted_values)
2128 .map(|(&yi, &yh)| yi - yh)
2129 .collect();
2130 let (r_squared, _) = super::compute_r_squared(y, &residuals, 2);
2131
2132 Ok(HistoryIndexResult {
2133 fitted_values,
2134 residuals,
2135 intercept,
2136 slope,
2137 gamma,
2138 lag_grid,
2139 history_scores,
2140 r_squared,
2141 })
2142}
2143
2144#[cfg(test)]
2149mod tests {
2150 use super::*;
2151 use crate::test_helpers::uniform_grid;
2152
2153 fn make_sine_data(n: usize, m: usize, freq_scale: f64) -> FdMatrix {
2155 let data: Vec<f64> = (0..n)
2156 .flat_map(|i| {
2157 (0..m).map(move |j| {
2158 let t = j as f64 / (m - 1) as f64;
2159 (freq_scale * (i as f64 + 1.0) * t).sin()
2160 })
2161 })
2162 .collect();
2163 let mut cm = vec![0.0_f64; n * m];
2165 for i in 0..n {
2166 for j in 0..m {
2167 cm[j * n + i] = data[i * m + j];
2168 }
2169 }
2170 FdMatrix::from_column_major(cm, n, m).unwrap()
2171 }
2172
2173 #[test]
2178 fn fam_synthetic_recovery() {
2179 let n = 50;
2181 let m = 20;
2182 let argvals = uniform_grid(m);
2183
2184 let data = make_sine_data(n, m, 1.0);
2186 let fpca = fdata_to_pc_1d(&data, 2, &argvals).unwrap();
2188 let y: Vec<f64> = (0..n)
2189 .map(|i| {
2190 let xi1 = fpca.scores[(i, 0)];
2191 let xi2 = fpca.scores[(i, 1)];
2192 let noise = (i as f64 * 0.31).sin() * 0.05;
2194 xi1.sin() + xi2 * xi2 + noise
2195 })
2196 .collect();
2197
2198 let config = FamConfig {
2199 ncomp: 2,
2200 bandwidth: 0.0,
2201 ..Default::default()
2202 };
2203 let result = fam(&data, &y, &argvals, None, &config).unwrap();
2204
2205 assert!(
2207 result.r_squared > 0.75,
2208 "expected R² > 0.75, got {}",
2209 result.r_squared
2210 );
2211
2212 let y_mean = y.iter().sum::<f64>() / n as f64;
2214 let ss_y: f64 = y.iter().map(|&yi| (yi - y_mean).powi(2)).sum::<f64>();
2215 let ss_res: f64 = result.residuals.iter().map(|r| r * r).sum();
2216 let rel_err = (ss_res / ss_y).sqrt();
2217 assert!(
2218 rel_err < 0.30,
2219 "expected relative fitted error < 0.30, got {rel_err:.4}"
2220 );
2221 }
2222
2223 #[test]
2224 fn fam_decomposition_identity() {
2225 let n = 30;
2227 let m = 15;
2228 let argvals = uniform_grid(m);
2229 let data = make_sine_data(n, m, 1.5);
2230 let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.2).cos()).collect();
2231 let config = FamConfig {
2232 ncomp: 2,
2233 ..Default::default()
2234 };
2235 let result = fam(&data, &y, &argvals, None, &config).unwrap();
2236
2237 for i in 0..n {
2238 let reconstructed = result.fitted_values[i] + result.residuals[i];
2239 assert!(
2240 (reconstructed - y[i]).abs() < 1e-9,
2241 "decomposition failed at i={i}: fitted={} residual={} sum={} y={}",
2242 result.fitted_values[i],
2243 result.residuals[i],
2244 reconstructed,
2245 y[i]
2246 );
2247 }
2248 }
2249
2250 #[test]
2251 fn fam_output_shapes() {
2252 let n = 25;
2253 let m = 12;
2254 let argvals = uniform_grid(m);
2255 let data = make_sine_data(n, m, 1.0);
2256 let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
2257 let config = FamConfig {
2258 ncomp: 3,
2259 ..Default::default()
2260 };
2261 let result = fam(&data, &y, &argvals, None, &config).unwrap();
2262
2263 assert_eq!(result.ncomp, 3, "ncomp field should be 3");
2264 assert_eq!(
2265 result.component_fits.len(),
2266 3,
2267 "component_fits.len() should equal ncomp"
2268 );
2269 for (k, cf) in result.component_fits.iter().enumerate() {
2270 assert_eq!(cf.len(), n, "component_fits[{k}] should have length n={n}");
2271 }
2272 assert_eq!(
2273 result.bandwidths.len(),
2274 3,
2275 "bandwidths.len() should equal ncomp"
2276 );
2277 assert_eq!(result.fitted_values.len(), n);
2278 assert_eq!(result.residuals.len(), n);
2279 }
2280
2281 #[test]
2282 fn fam_invalid_dimension() {
2283 let n = 20;
2284 let m = 10;
2285 let argvals = uniform_grid(m);
2286 let data = make_sine_data(n, m, 1.0);
2287 let y_ok: Vec<f64> = (0..n).map(|i| i as f64).collect();
2288 let config = FamConfig {
2289 ncomp: 2,
2290 ..Default::default()
2291 };
2292
2293 let empty_data = FdMatrix::zeros(0, m);
2295 let err = fam(&empty_data, &y_ok, &argvals, None, &config);
2296 assert!(err.is_err(), "empty data should return Err");
2297 match err.unwrap_err() {
2298 FdarError::InvalidDimension { parameter, .. } => {
2299 assert_eq!(parameter, "data");
2300 }
2301 e => panic!("expected InvalidDimension, got {e:?}"),
2302 }
2303
2304 let y_wrong: Vec<f64> = vec![1.0; n + 5];
2306 let err = fam(&data, &y_wrong, &argvals, None, &config);
2307 assert!(err.is_err(), "mismatched y length should return Err");
2308 match err.unwrap_err() {
2309 FdarError::InvalidDimension { parameter, .. } => {
2310 assert_eq!(parameter, "y");
2311 }
2312 e => panic!("expected InvalidDimension, got {e:?}"),
2313 }
2314
2315 let argvals_wrong: Vec<f64> = uniform_grid(m + 3);
2317 let err = fam(&data, &y_ok, &argvals_wrong, None, &config);
2318 assert!(err.is_err(), "mismatched argvals should return Err");
2319 match err.unwrap_err() {
2320 FdarError::InvalidDimension { parameter, .. } => {
2321 assert_eq!(parameter, "argvals");
2322 }
2323 e => panic!("expected InvalidDimension, got {e:?}"),
2324 }
2325 }
2326
2327 #[test]
2332 fn gkam_r2_synthetic() {
2333 let n = 40;
2336 let m = 15;
2337 let argvals = uniform_grid(m);
2338
2339 let mut cm = vec![0.0_f64; n * m];
2341 for i in 0..n {
2342 let amp = (i as f64 + 1.0) / n as f64; for j in 0..m {
2344 let t = j as f64 / (m - 1) as f64;
2345 cm[j * n + i] = amp * (std::f64::consts::PI * 2.0 * t).sin();
2347 }
2348 }
2349 let data = FdMatrix::from_column_major(cm, n, m).unwrap();
2350
2351 let y: Vec<f64> = (0..n)
2354 .map(|i| {
2355 let amp = (i as f64 + 1.0) / n as f64;
2356 let noise = (i as f64 * 0.23).sin() * 0.002;
2358 amp * amp + noise
2359 })
2360 .collect();
2361
2362 let config = GkamConfig {
2363 max_iter: 20,
2364 epsilon: 1e-4,
2365 ..Default::default()
2366 };
2367 let result = fregre_gkam(&[&data], &y, &[&argvals], None, &config).unwrap();
2368
2369 assert!(
2370 result.r_squared > 0.70,
2371 "expected R² > 0.70, got {}",
2372 result.r_squared
2373 );
2374 }
2375
2376 #[test]
2377 fn gkam_convergence() {
2378 let n = 25;
2380 let m = 10;
2381 let argvals = uniform_grid(m);
2382 let data = make_sine_data(n, m, 1.0);
2383 let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.1).sin()).collect();
2384
2385 let config = GkamConfig {
2386 max_iter: 50,
2387 epsilon: 1e-4,
2388 ..Default::default()
2389 };
2390 let result = fregre_gkam(&[&data], &y, &[&argvals], None, &config).unwrap();
2391
2392 assert!(
2393 result.converged,
2394 "expected convergence, got iterations={}",
2395 result.iterations
2396 );
2397 assert!(
2398 result.iterations <= config.max_iter,
2399 "iterations {} > max_iter {}",
2400 result.iterations,
2401 config.max_iter
2402 );
2403 }
2404
2405 #[test]
2406 fn gkam_invalid_inputs() {
2407 let n = 20;
2408 let m = 10;
2409 let argvals = uniform_grid(m);
2410 let data = make_sine_data(n, m, 1.0);
2411 let y_ok: Vec<f64> = (0..n).map(|i| i as f64).collect();
2412 let config = GkamConfig::default();
2413
2414 let err = fregre_gkam(&[], &y_ok, &[], None, &config);
2416 assert!(err.is_err(), "empty predictors should return Err");
2417
2418 let data_wrong = make_sine_data(n + 5, m, 1.0);
2420 let err = fregre_gkam(&[&data_wrong], &y_ok, &[&argvals], None, &config);
2421 assert!(err.is_err(), "mismatched n should return Err");
2422 match err.unwrap_err() {
2423 FdarError::InvalidDimension { .. } => {}
2424 e => panic!("expected InvalidDimension, got {e:?}"),
2425 }
2426
2427 let err = fregre_gkam(&[&data], &y_ok, &[], None, &config);
2429 assert!(
2430 err.is_err(),
2431 "argvals_list length mismatch should return Err"
2432 );
2433 }
2434
2435 #[test]
2440 fn gsam_matches_fam_identity() {
2441 let n = 40;
2443 let m = 16;
2444 let argvals = uniform_grid(m);
2445 let data = make_sine_data(n, m, 1.0);
2446 let fpca_ref = fdata_to_pc_1d(&data, 2, &argvals).unwrap();
2447 let y: Vec<f64> = (0..n)
2448 .map(|i| {
2449 let xi1 = fpca_ref.scores[(i, 0)];
2450 let xi2 = fpca_ref.scores[(i, 1)];
2451 xi1 + xi2 * xi2 + (i as f64 * 0.23).sin() * 0.02
2452 })
2453 .collect();
2454
2455 let fam_config = FamConfig {
2456 ncomp: 2,
2457 bandwidth: 0.5, kernel: "gaussian".to_string(),
2459 n_grid_bandwidth: 20,
2460 };
2461 let gsam_config = GsamConfig {
2462 ncomp: 2,
2463 bandwidth: 0.5,
2464 kernel: "gaussian".to_string(),
2465 n_grid_bandwidth: 20,
2466 };
2467
2468 let fam_res = fam(&data, &y, &argvals, None, &fam_config).unwrap();
2469 let gsam_res = fregre_gsam(&data, &y, &argvals, None, &gsam_config).unwrap();
2470
2471 for i in 0..n {
2472 let diff = (fam_res.fitted_values[i] - gsam_res.fitted_values[i]).abs();
2473 assert!(
2474 diff < 1e-6,
2475 "fam vs gsam mismatch at i={i}: fam={} gsam={} diff={diff:.2e}",
2476 fam_res.fitted_values[i],
2477 gsam_res.fitted_values[i]
2478 );
2479 }
2480 }
2481
2482 #[test]
2483 fn gsam_ncomp_too_large() {
2484 let n = 15;
2485 let m = 8;
2486 let argvals = uniform_grid(m);
2487 let data = make_sine_data(n, m, 1.0);
2488 let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
2489
2490 let config = GsamConfig {
2492 ncomp: 100,
2493 ..Default::default()
2494 };
2495 let err = fregre_gsam(&data, &y, &argvals, None, &config);
2496 assert!(err.is_err(), "ncomp > min(n,m) should return Err");
2497 match err.unwrap_err() {
2498 FdarError::InvalidParameter { parameter, .. } => {
2499 assert_eq!(parameter, "config.ncomp");
2500 }
2501 e => panic!("expected InvalidParameter, got {e:?}"),
2502 }
2503 }
2504
2505 #[test]
2506 fn gsam_output_shapes() {
2507 let n = 30;
2508 let m = 10;
2509 let argvals = uniform_grid(m);
2510 let data = make_sine_data(n, m, 1.0);
2511 let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.1).sin()).collect();
2512
2513 let config = GsamConfig {
2514 ncomp: 3,
2515 ..Default::default()
2516 };
2517 let result = fregre_gsam(&data, &y, &argvals, None, &config).unwrap();
2518
2519 assert_eq!(result.ncomp, 3);
2520 assert_eq!(
2521 result.component_fits.len(),
2522 3,
2523 "component_fits.len() should equal ncomp"
2524 );
2525 assert_eq!(result.fitted_values.len(), n);
2526 }
2527
2528 #[test]
2533 fn varselect_active_subset_recovery() {
2534 let n = 100;
2541 let m = 30;
2542 let argvals = uniform_grid(m);
2543
2544 let make_orth = |p_idx: usize| -> FdMatrix {
2548 let mut cm = vec![0.0_f64; n * m];
2549 for i in 0..n {
2550 let amp = (std::f64::consts::PI * (p_idx + 1) as f64 * i as f64 / n as f64).sin();
2552 for j in 0..m {
2553 let t = j as f64 / (m - 1) as f64;
2554 cm[j * n + i] = amp * (std::f64::consts::PI * 2.0 * t).cos();
2556 }
2557 }
2558 FdMatrix::from_column_major(cm, n, m).unwrap()
2559 };
2560
2561 let preds: Vec<FdMatrix> = (0..5).map(make_orth).collect();
2562 let pred_refs: Vec<&FdMatrix> = preds.iter().collect();
2563 let argvals_list: Vec<&[f64]> = (0..5).map(|_| argvals.as_slice()).collect();
2564
2565 let y: Vec<f64> = (0..n)
2568 .map(|i| {
2569 let a0 = (std::f64::consts::PI * i as f64 / n as f64).sin();
2570 let a2 = (std::f64::consts::PI * 3.0 * i as f64 / n as f64).sin();
2571 5.0 * a0 + 3.0 * a2 + (i as f64 * 0.31).sin() * 0.01
2572 })
2573 .collect();
2574
2575 let config = VarSelectConfig {
2576 ncomp: 1,
2577 lambda_n_grid: 20,
2578 ..Default::default()
2579 };
2580 let result = variable_selection(&pred_refs, &y, &argvals_list, None, &config).unwrap();
2581
2582 assert_eq!(
2583 result.active_predictors.len(),
2584 5,
2585 "should have 5 active_predictors entries"
2586 );
2587 assert!(
2589 result.active_predictors[0],
2590 "predictor 0 should be active, got {:?}",
2591 result.active_predictors
2592 );
2593 assert!(
2594 result.active_predictors[2],
2595 "predictor 2 should be active, got {:?}",
2596 result.active_predictors
2597 );
2598 assert!(
2600 !result.active_predictors[1],
2601 "predictor 1 should be inactive, got {:?}",
2602 result.active_predictors
2603 );
2604 assert!(
2605 !result.active_predictors[3],
2606 "predictor 3 should be inactive, got {:?}",
2607 result.active_predictors
2608 );
2609 assert!(
2610 !result.active_predictors[4],
2611 "predictor 4 should be inactive, got {:?}",
2612 result.active_predictors
2613 );
2614 assert!(
2616 result.r_squared > 0.5,
2617 "expected R² > 0.5, got {}",
2618 result.r_squared
2619 );
2620 }
2621
2622 #[test]
2623 fn varselect_lambda_max_zeros() {
2624 let n = 30;
2627 let m = 10;
2628 let argvals = uniform_grid(m);
2629 let preds: Vec<FdMatrix> = (0..3usize).map(|_| make_sine_data(n, m, 1.0)).collect();
2630 let pred_refs: Vec<&FdMatrix> = preds.iter().collect();
2631 let argvals_list: Vec<&[f64]> = (0..3).map(|_| argvals.as_slice()).collect();
2632 let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.1).sin()).collect();
2633
2634 let config = VarSelectConfig {
2636 ncomp: 2,
2637 lambda: 1e6, ..Default::default()
2639 };
2640 let result = variable_selection(&pred_refs, &y, &argvals_list, None, &config).unwrap();
2641
2642 assert!(
2644 result.active_predictors.iter().all(|&a| !a),
2645 "expected all inactive at lambda=1e6, got {:?}",
2646 result.active_predictors
2647 );
2648 }
2649
2650 #[test]
2651 fn varselect_invalid_inputs() {
2652 let n = 20;
2653 let m = 10;
2654 let argvals = uniform_grid(m);
2655 let data = make_sine_data(n, m, 1.0);
2656 let y_ok: Vec<f64> = (0..n).map(|i| i as f64).collect();
2657 let config = VarSelectConfig {
2658 ncomp: 2,
2659 ..Default::default()
2660 };
2661
2662 let err = variable_selection(&[], &y_ok, &[], None, &config);
2664 assert!(err.is_err(), "empty predictors should return Err");
2665 match err.unwrap_err() {
2666 FdarError::InvalidDimension { .. } => {}
2667 e => panic!("expected InvalidDimension, got {e:?}"),
2668 }
2669
2670 let data_wrong = make_sine_data(n + 5, m, 1.0);
2672 let err = variable_selection(&[&data_wrong], &y_ok, &[&argvals], None, &config);
2673 assert!(err.is_err(), "mismatched n should return Err");
2674 match err.unwrap_err() {
2675 FdarError::InvalidDimension { .. } => {}
2676 e => panic!("expected InvalidDimension, got {e:?}"),
2677 }
2678
2679 let err = variable_selection(&[&data], &y_ok, &[], None, &config);
2681 assert!(err.is_err(), "argvals_list mismatch should return Err");
2682
2683 let config_mcp = VarSelectConfig {
2685 penalty: VarSelectPenalty::GroupMcp,
2686 ..config.clone()
2687 };
2688 let err = variable_selection(&[&data], &y_ok, &[&argvals], None, &config_mcp);
2689 assert!(err.is_err(), "GroupMcp should return Err");
2690 match err.unwrap_err() {
2691 FdarError::InvalidParameter { parameter, .. } => {
2692 assert_eq!(parameter, "config.penalty");
2693 }
2694 e => panic!("expected InvalidParameter, got {e:?}"),
2695 }
2696 }
2697
2698 #[test]
2703 fn perm_seeded_reproducibility() {
2704 let n = 30;
2706 let m = 12;
2707 let argvals = uniform_grid(m);
2708 let data = make_sine_data(n, m, 1.0);
2709 let fpca = fdata_to_pc_1d(&data, 1, &argvals).unwrap();
2710 let y: Vec<f64> = (0..n)
2711 .map(|i| fpca.scores[(i, 0)] * 2.0 + (i as f64 * 0.31).sin() * 0.05)
2712 .collect();
2713
2714 let fam_cfg = FamConfig {
2715 ncomp: 1,
2716 ..Default::default()
2717 };
2718 let perm_cfg = PermTestConfig {
2719 n_perm: 19,
2720 seed: 42,
2721 statistic: PermTestStatistic::R2,
2722 };
2723
2724 let r1 = permutation_test_fam(&data, &y, &argvals, None, &fam_cfg, &perm_cfg).unwrap();
2725 let r2 = permutation_test_fam(&data, &y, &argvals, None, &fam_cfg, &perm_cfg).unwrap();
2726 assert_eq!(
2727 r1.p_value, r2.p_value,
2728 "same seed should give same p_value: {} vs {}",
2729 r1.p_value, r2.p_value
2730 );
2731 assert_eq!(
2732 r1.null_statistics, r2.null_statistics,
2733 "same seed should give same null distribution"
2734 );
2735 }
2736
2737 #[test]
2738 fn perm_pvalue_range() {
2739 let n = 20;
2741 let m = 8;
2742 let argvals = uniform_grid(m);
2743 let data = make_sine_data(n, m, 1.0);
2744 let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
2745 let fam_cfg = FamConfig {
2746 ncomp: 1,
2747 ..Default::default()
2748 };
2749 let perm_cfg = PermTestConfig {
2750 n_perm: 9,
2751 seed: 0,
2752 statistic: PermTestStatistic::FittedNorm,
2753 };
2754 let result = permutation_test_fam(&data, &y, &argvals, None, &fam_cfg, &perm_cfg).unwrap();
2755 assert!(
2756 (0.0..=1.0).contains(&result.p_value),
2757 "p_value out of [0,1]: {}",
2758 result.p_value
2759 );
2760 }
2761
2762 #[test]
2763 fn perm_detects_true_effect() {
2764 let n = 40;
2767 let m = 15;
2768 let argvals = uniform_grid(m);
2769 let data = make_sine_data(n, m, 1.0);
2770 let fpca = fdata_to_pc_1d(&data, 1, &argvals).unwrap();
2771
2772 let y_signal: Vec<f64> = (0..n)
2774 .map(|i| {
2775 let xi1 = fpca.scores[(i, 0)];
2776 2.0 * xi1 + (i as f64 * 0.17).sin() * 0.02
2777 })
2778 .collect();
2779
2780 let y_null: Vec<f64> = (0..n).map(|i| (i as f64 * 0.37).sin() * 0.3).collect();
2782
2783 let fam_cfg = FamConfig {
2784 ncomp: 1,
2785 ..Default::default()
2786 };
2787 let perm_cfg = PermTestConfig {
2788 n_perm: 99,
2789 seed: 42,
2790 statistic: PermTestStatistic::R2,
2791 };
2792
2793 let r_signal =
2794 permutation_test_fam(&data, &y_signal, &argvals, None, &fam_cfg, &perm_cfg).unwrap();
2795 let r_null =
2796 permutation_test_fam(&data, &y_null, &argvals, None, &fam_cfg, &perm_cfg).unwrap();
2797
2798 assert!(
2799 r_signal.p_value < 0.1,
2800 "expected p < 0.1 under true effect, got p={}",
2801 r_signal.p_value
2802 );
2803 assert!(
2804 r_null.p_value > 0.1,
2805 "expected p > 0.1 under the null, got p={}",
2806 r_null.p_value
2807 );
2808 }
2809
2810 #[test]
2815 fn history_index_synthetic_recovery() {
2816 let n = 50;
2820 let m = 30;
2821 let argvals: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
2823
2824 let mut cm = vec![0.0_f64; n * m];
2826 for i in 0..n {
2827 let amp = (i as f64 + 1.0) / n as f64;
2828 for j in 0..m {
2829 let t = j as f64 / (m - 1) as f64;
2830 cm[j * n + i] = amp * (std::f64::consts::PI * 2.0 * t).sin();
2831 }
2832 }
2833 let data = FdMatrix::from_column_major(cm, n, m).unwrap();
2834
2835 let window = 0.5_f64;
2838 let n_lags = 10;
2839 let delta_u = window / n_lags as f64;
2840 let big_t = argvals.last().copied().unwrap();
2841 let y: Vec<f64> = (0..n)
2842 .map(|i| {
2843 (0..n_lags)
2844 .map(|l| {
2845 let u_l = l as f64 * delta_u;
2846 let t_target = big_t - u_l;
2847 let j = argvals
2848 .partition_point(|&v| v < t_target)
2849 .saturating_sub(1)
2850 .min(m - 1);
2851 data[(i, j)] * delta_u
2852 })
2853 .sum::<f64>()
2854 })
2855 .collect();
2856
2857 let config = HistoryIndexConfig {
2858 window,
2859 n_lags,
2860 bandwidth: 0.0,
2861 kernel: "gaussian".to_string(),
2862 };
2863 let result = history_index(&data, &y, &argvals, &config).unwrap();
2864
2865 assert!(
2866 result.r_squared > 0.70,
2867 "expected R² > 0.70, got {}",
2868 result.r_squared
2869 );
2870 let g_mean = result.gamma.iter().sum::<f64>() / n_lags as f64;
2872 let g_std = (result
2873 .gamma
2874 .iter()
2875 .map(|&g| (g - g_mean).powi(2))
2876 .sum::<f64>()
2877 / n_lags as f64)
2878 .sqrt();
2879 let cv = if g_mean.abs() > 1e-10 {
2880 g_std / g_mean.abs()
2881 } else {
2882 0.0
2883 };
2884 assert!(
2885 cv < 2.0,
2886 "gamma should be approximately uniform (CV < 2.0), got CV={}",
2887 cv
2888 );
2889 }
2890
2891 #[test]
2892 fn history_index_window_too_large() {
2893 let n = 20;
2894 let m = 10;
2895 let argvals = uniform_grid(m); let data = make_sine_data(n, m, 1.0);
2897 let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
2898
2899 let config = HistoryIndexConfig {
2901 window: 2.0,
2902 n_lags: 10,
2903 ..Default::default()
2904 };
2905 let err = history_index(&data, &y, &argvals, &config);
2906 assert!(err.is_err(), "window > argvals range should return Err");
2907 match err.unwrap_err() {
2908 FdarError::InvalidParameter { parameter, .. } => {
2909 assert_eq!(parameter, "config.window");
2910 }
2911 e => panic!("expected InvalidParameter, got {e:?}"),
2912 }
2913 }
2914
2915 #[test]
2916 fn history_index_output_shapes() {
2917 let n = 25;
2918 let m = 15;
2919 let argvals = uniform_grid(m); let data = make_sine_data(n, m, 1.0);
2921 let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.2).cos()).collect();
2922
2923 let n_lags = 12;
2924 let config = HistoryIndexConfig {
2925 window: 0.5,
2926 n_lags,
2927 ..Default::default()
2928 };
2929 let result = history_index(&data, &y, &argvals, &config).unwrap();
2930
2931 assert_eq!(
2932 result.gamma.len(),
2933 n_lags,
2934 "gamma.len() should equal n_lags"
2935 );
2936 assert_eq!(
2937 result.lag_grid.len(),
2938 n_lags,
2939 "lag_grid.len() should equal n_lags"
2940 );
2941 assert_eq!(
2942 result.fitted_values.len(),
2943 n,
2944 "fitted_values.len() should equal n"
2945 );
2946 assert_eq!(
2947 result.history_scores.len(),
2948 n,
2949 "history_scores.len() should equal n"
2950 );
2951 }
2952
2953 #[test]
2958 fn gkam_empty_y_returns_err() {
2959 let m = 10;
2961 let argvals = uniform_grid(m);
2962 let empty_data = FdMatrix::zeros(0, m);
2964 let y_empty: Vec<f64> = vec![];
2965 let config = GkamConfig::default();
2966
2967 let result = fregre_gkam(&[&empty_data], &y_empty, &[&argvals], None, &config);
2968 assert!(
2969 result.is_err(),
2970 "fregre_gkam with empty y should return Err, got Ok"
2971 );
2972 match result.unwrap_err() {
2973 FdarError::InvalidDimension { parameter, .. } => {
2974 assert_eq!(parameter, "y", "error should report parameter='y'");
2975 }
2976 e => panic!("expected InvalidDimension(y), got {e:?}"),
2977 }
2978 }
2979
2980 #[test]
2985 fn fam_scalar_covariates_component_fits_len() {
2986 let n = 30;
2989 let m = 12;
2990 let argvals = uniform_grid(m);
2991 let data = make_sine_data(n, m, 1.0);
2992 let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.2).cos()).collect();
2993
2994 let p_scalar = 2_usize;
2996 let sc_vals: Vec<f64> = (0..n * p_scalar).map(|k| (k as f64 * 0.1).sin()).collect();
2997 let mut sc_cm = vec![0.0_f64; n * p_scalar];
2999 for row in 0..n {
3000 for col in 0..p_scalar {
3001 sc_cm[col * n + row] = sc_vals[row * p_scalar + col];
3002 }
3003 }
3004 let sc = FdMatrix::from_column_major(sc_cm, n, p_scalar).unwrap();
3005
3006 let ncomp = 2;
3007 let config = FamConfig {
3008 ncomp,
3009 ..Default::default()
3010 };
3011 let result = fam(&data, &y, &argvals, Some(&sc), &config).unwrap();
3012
3013 let expected_len = ncomp + p_scalar;
3014 assert_eq!(
3015 result.component_fits.len(),
3016 expected_len,
3017 "component_fits.len() should be ncomp + p_scalar = {expected_len}, got {}",
3018 result.component_fits.len()
3019 );
3020 assert_eq!(
3021 result.bandwidths.len(),
3022 expected_len,
3023 "bandwidths.len() should be ncomp + p_scalar = {expected_len}, got {}",
3024 result.bandwidths.len()
3025 );
3026 }
3027
3028 #[test]
3029 fn gsam_scalar_covariates_component_fits_len() {
3030 let n = 30;
3032 let m = 12;
3033 let argvals = uniform_grid(m);
3034 let data = make_sine_data(n, m, 1.0);
3035 let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.3).sin()).collect();
3036
3037 let p_scalar = 2_usize;
3038 let mut sc_cm = vec![0.0_f64; n * p_scalar];
3039 for row in 0..n {
3040 for col in 0..p_scalar {
3041 sc_cm[col * n + row] = ((row * p_scalar + col) as f64 * 0.15).cos();
3042 }
3043 }
3044 let sc = FdMatrix::from_column_major(sc_cm, n, p_scalar).unwrap();
3045
3046 let ncomp = 2;
3047 let config = GsamConfig {
3048 ncomp,
3049 ..Default::default()
3050 };
3051 let result = fregre_gsam(&data, &y, &argvals, Some(&sc), &config).unwrap();
3052
3053 let expected_len = ncomp + p_scalar;
3054 assert_eq!(
3055 result.component_fits.len(),
3056 expected_len,
3057 "component_fits.len() should be ncomp + p_scalar = {expected_len}, got {}",
3058 result.component_fits.len()
3059 );
3060 assert_eq!(
3061 result.bandwidths.len(),
3062 expected_len,
3063 "bandwidths.len() should be ncomp + p_scalar = {expected_len}, got {}",
3064 result.bandwidths.len()
3065 );
3066 }
3067
3068 #[test]
3073 fn perm_zero_nperm_returns_err() {
3074 let n = 20;
3076 let m = 8;
3077 let argvals = uniform_grid(m);
3078 let data = make_sine_data(n, m, 1.0);
3079 let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
3080 let fam_cfg = FamConfig {
3081 ncomp: 1,
3082 ..Default::default()
3083 };
3084 let perm_cfg = PermTestConfig {
3085 n_perm: 0,
3086 seed: 42,
3087 statistic: PermTestStatistic::R2,
3088 };
3089 let result = permutation_test_fam(&data, &y, &argvals, None, &fam_cfg, &perm_cfg);
3090 assert!(result.is_err(), "n_perm=0 should return Err");
3091 match result.unwrap_err() {
3092 FdarError::InvalidParameter { parameter, .. } => {
3093 assert_eq!(parameter, "perm_config.n_perm");
3094 }
3095 e => panic!("expected InvalidParameter, got {e:?}"),
3096 }
3097 }
3098}