1use crate::error::FdarError;
51use crate::function_on_scalar::penalty_matrix;
52use crate::helpers::simpsons_weights;
53use crate::linalg::{cholesky_factor, cholesky_forward_back, cholesky_solve};
54use crate::matrix::FdMatrix;
55use nalgebra::DMatrix;
56
57#[non_exhaustive]
66#[derive(Debug, Clone, PartialEq)]
67#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
68pub enum PeerPenalty {
69 Ridge,
71 Difference { order: usize },
75 Decree(Vec<f64>, usize),
85}
86
87impl Default for PeerPenalty {
88 fn default() -> Self {
89 PeerPenalty::Difference { order: 2 }
90 }
91}
92
93#[non_exhaustive]
98#[derive(Debug, Clone, Default, PartialEq)]
99#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
100pub enum LambdaChoice {
101 Fixed(f64),
103 #[default]
107 Gcv,
108 Reml,
113}
114
115#[non_exhaustive]
117#[derive(Debug, Clone, PartialEq)]
118#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
119pub enum LambdaMethod {
120 Fixed,
122 Gcv,
124 Reml,
126}
127
128#[non_exhaustive]
132#[derive(Debug, Clone, Default, PartialEq)]
133#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
134pub struct PeerConfig {
135 pub penalty: PeerPenalty,
137 pub lambda: LambdaChoice,
143}
144
145#[derive(Debug, Clone, PartialEq)]
150#[non_exhaustive]
151#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
152#[must_use = "expensive computation whose result should not be discarded"]
153pub struct PeerResult {
154 pub beta: Vec<f64>,
156 pub intercept: f64,
165 pub w_bar: Vec<f64>,
169 pub fitted_values: Vec<f64>,
171 pub effective_df: f64,
173 pub lambda: f64,
175 pub penalty_type: PeerPenalty,
177 pub gcv: Option<f64>,
180 pub lambda_method: LambdaMethod,
182}
183
184#[derive(Debug, Clone, PartialEq)]
192#[non_exhaustive]
193#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
194#[must_use = "expensive computation whose result should not be discarded"]
195pub struct LocalPeerResult {
196 pub beta: Vec<f64>,
198 pub intercept: f64,
200 pub w_bar: Vec<f64>,
204 pub fitted_values: Vec<f64>,
206 pub sigma2_subject: f64,
209 pub sigma2_resid: f64,
211 pub n_subjects: usize,
213 pub lambda: f64,
215 pub penalty_type: PeerPenalty,
217 pub gcv: Option<f64>,
220 pub lambda_method: LambdaMethod,
222}
223
224pub fn peer(
250 data: &FdMatrix,
251 y: &[f64],
252 argvals: &[f64],
253 config: &PeerConfig,
254) -> Result<PeerResult, FdarError> {
255 let (n, m) = data.shape();
256
257 if n < 2 {
259 return Err(FdarError::InvalidDimension {
262 parameter: "data",
263 expected: "at least 2 observations".to_string(),
264 actual: format!("{n} rows"),
265 });
266 }
267 if m < 3 {
268 return Err(FdarError::InvalidDimension {
269 parameter: "data",
270 expected: "at least 3 evaluation points (m >= 3)".to_string(),
271 actual: format!("{m} columns"),
272 });
273 }
274 if argvals.len() != m {
275 return Err(FdarError::InvalidDimension {
276 parameter: "argvals",
277 expected: format!("{m}"),
278 actual: format!("{}", argvals.len()),
279 });
280 }
281 if y.len() != n {
282 return Err(FdarError::InvalidDimension {
283 parameter: "y",
284 expected: format!("{n}"),
285 actual: format!("{}", y.len()),
286 });
287 }
288 if y.iter().any(|v| !v.is_finite()) {
291 return Err(FdarError::InvalidParameter {
292 parameter: "y",
293 message: "response contains non-finite values (NaN/Inf)".to_string(),
294 });
295 }
296 if argvals.iter().any(|v| !v.is_finite()) {
297 return Err(FdarError::InvalidParameter {
298 parameter: "argvals",
299 message: "argvals contains non-finite values (NaN/Inf)".to_string(),
300 });
301 }
302 if argvals.windows(2).any(|w| w[1] <= w[0]) {
305 return Err(FdarError::InvalidParameter {
306 parameter: "argvals",
307 message: "argvals must be strictly increasing".to_string(),
308 });
309 }
310
311 let w = simpsons_weights(argvals);
313
314 let mut wmat = FdMatrix::zeros(n, m);
316 for i in 0..n {
317 for j in 0..m {
318 wmat[(i, j)] = data[(i, j)] * w[j];
319 }
320 }
321
322 let y_bar: f64 = y.iter().sum::<f64>() / n as f64;
324 let yc: Vec<f64> = y.iter().map(|&yi| yi - y_bar).collect();
325
326 let w_bar: Vec<f64> = (0..m)
327 .map(|j| (0..n).map(|i| wmat[(i, j)]).sum::<f64>() / n as f64)
328 .collect();
329
330 let mut wc = FdMatrix::zeros(n, m);
331 for i in 0..n {
332 for j in 0..m {
333 wc[(i, j)] = wmat[(i, j)] - w_bar[j];
334 }
335 }
336
337 let q = build_q(m, &config.penalty)?;
339
340 let mut wtw = vec![0.0_f64; m * m];
342 for j in 0..m {
343 for k in j..m {
344 let s: f64 = (0..n).map(|i| wc[(i, j)] * wc[(i, k)]).sum();
345 wtw[j * m + k] = s;
346 wtw[k * m + j] = s;
347 }
348 }
349 let wty: Vec<f64> = (0..m)
350 .map(|j| (0..n).map(|i| wc[(i, j)] * yc[i]).sum())
351 .collect();
352
353 let (lambda, gcv_score, lambda_method) = match &config.lambda {
355 LambdaChoice::Fixed(lam) => (*lam, None, LambdaMethod::Fixed),
356 LambdaChoice::Gcv => {
357 let (lam, g) = select_lambda_gcv_peer(&wc, &yc, &wtw, &wty, &q, m, n);
358 (lam, Some(g), LambdaMethod::Gcv)
359 }
360 LambdaChoice::Reml => {
361 let lam = select_lambda_reml_peer(&wc, &yc, &q, m, n);
362 (lam, None, LambdaMethod::Reml)
363 }
364 };
365
366 let mut a = vec![0.0_f64; m * m];
368 for i in 0..m * m {
369 a[i] = wtw[i] + lambda * q[i];
370 }
371 let beta = cholesky_solve(&a, &wty, m)?;
372
373 if beta.iter().any(|v| !v.is_finite()) {
375 return Err(FdarError::ComputationFailed {
376 operation: "peer",
377 detail: "non-finite coefficient (singular penalized system)".into(),
378 });
379 }
380
381 let effective_df = compute_peer_trace_hat(&wtw, &q, lambda, m, n);
383
384 let fitted_values: Vec<f64> = (0..n)
386 .map(|i| y_bar + (0..m).map(|j| wc[(i, j)] * beta[j]).sum::<f64>())
387 .collect();
388
389 Ok(PeerResult {
390 beta,
391 intercept: y_bar,
392 w_bar,
393 fitted_values,
394 effective_df,
395 lambda,
396 penalty_type: config.penalty.clone(),
397 gcv: gcv_score,
398 lambda_method,
399 })
400}
401
402pub fn lpeer(
461 data: &FdMatrix,
462 y: &[f64],
463 argvals: &[f64],
464 subject_map: &[usize],
465 config: &PeerConfig,
466) -> Result<LocalPeerResult, FdarError> {
467 let (n, m) = data.shape();
468
469 if n < 2 {
471 return Err(FdarError::InvalidDimension {
472 parameter: "data",
473 expected: "at least 2 observations".to_string(),
474 actual: format!("{n} rows"),
475 });
476 }
477 if m < 3 {
478 return Err(FdarError::InvalidDimension {
479 parameter: "data",
480 expected: "at least 3 evaluation points (m >= 3)".to_string(),
481 actual: format!("{m} columns"),
482 });
483 }
484 if argvals.len() != m {
485 return Err(FdarError::InvalidDimension {
486 parameter: "argvals",
487 expected: format!("{m}"),
488 actual: format!("{}", argvals.len()),
489 });
490 }
491 if y.len() != n {
492 return Err(FdarError::InvalidDimension {
493 parameter: "y",
494 expected: format!("{n}"),
495 actual: format!("{}", y.len()),
496 });
497 }
498 if subject_map.len() != n {
499 return Err(FdarError::InvalidDimension {
500 parameter: "subject_map",
501 expected: format!("{n}"),
502 actual: format!("{}", subject_map.len()),
503 });
504 }
505 if y.iter().any(|v| !v.is_finite()) {
506 return Err(FdarError::InvalidParameter {
507 parameter: "y",
508 message: "response contains non-finite values (NaN/Inf)".to_string(),
509 });
510 }
511 if argvals.iter().any(|v| !v.is_finite()) {
512 return Err(FdarError::InvalidParameter {
513 parameter: "argvals",
514 message: "argvals contains non-finite values (NaN/Inf)".to_string(),
515 });
516 }
517 if argvals.windows(2).any(|w| w[1] <= w[0]) {
518 return Err(FdarError::InvalidParameter {
519 parameter: "argvals",
520 message: "argvals must be strictly increasing".to_string(),
521 });
522 }
523
524 let (sm_dense, n_subjects) = crate::famm::build_subject_map(subject_map);
526 if n_subjects < 2 {
527 return Err(FdarError::InvalidParameter {
528 parameter: "subject_map",
529 message: "at least 2 distinct subjects required for lpeer".to_string(),
530 });
531 }
532
533 let w = simpsons_weights(argvals);
535 let mut wmat = FdMatrix::zeros(n, m);
536 for i in 0..n {
537 for j in 0..m {
538 wmat[(i, j)] = data[(i, j)] * w[j];
539 }
540 }
541
542 let y_bar: f64 = y.iter().sum::<f64>() / n as f64;
543 let yc: Vec<f64> = y.iter().map(|&yi| yi - y_bar).collect();
544
545 let w_bar: Vec<f64> = (0..m)
547 .map(|j| (0..n).map(|i| wmat[(i, j)]).sum::<f64>() / n as f64)
548 .collect();
549
550 let mut wc = FdMatrix::zeros(n, m);
551 for i in 0..n {
552 for j in 0..m {
553 wc[(i, j)] = wmat[(i, j)] - w_bar[j];
554 }
555 }
556
557 let q = build_q(m, &config.penalty)?;
559
560 let mut wtw = vec![0.0_f64; m * m];
561 for j in 0..m {
562 for k in j..m {
563 let s: f64 = (0..n).map(|i| wc[(i, j)] * wc[(i, k)]).sum();
564 wtw[j * m + k] = s;
565 wtw[k * m + j] = s;
566 }
567 }
568 let wty: Vec<f64> = (0..m)
569 .map(|j| (0..n).map(|i| wc[(i, j)] * yc[i]).sum())
570 .collect();
571
572 let (lambda, gcv_score, lambda_method) = match &config.lambda {
573 LambdaChoice::Fixed(lam) => (*lam, None, LambdaMethod::Fixed),
574 LambdaChoice::Gcv => {
575 let (lam, g) = select_lambda_gcv_peer(&wc, &yc, &wtw, &wty, &q, m, n);
576 (lam, Some(g), LambdaMethod::Gcv)
577 }
578 LambdaChoice::Reml => {
579 let lam = select_lambda_reml_peer(&wc, &yc, &q, m, n);
580 (lam, None, LambdaMethod::Reml)
581 }
582 };
583
584 let ncomp = (n - 1).min(m).min(10);
586 let fpca = crate::regression::fdata_to_pc(data, ncomp, argvals)?;
587 let scores = &fpca.scores; let result =
593 crate::famm::fit_scalar_mixed_model(&yc, &sm_dense, n_subjects, Some(scores), ncomp);
594
595 if result.gamma.iter().any(|v| !v.is_finite()) {
597 return Err(FdarError::ComputationFailed {
598 operation: "lpeer",
599 detail: "non-finite gamma from mixed model".into(),
600 });
601 }
602
603 let su = result.sigma2_u;
611 let se = result.sigma2_eps;
612 let mut gcnt = vec![0usize; n_subjects];
614 for &g in &sm_dense {
615 gcnt[g] += 1;
616 }
617 let sigma_inv = |v: &[f64]| -> Vec<f64> {
619 let mut gsum = vec![0.0_f64; n_subjects];
620 for i in 0..n {
621 gsum[sm_dense[i]] += v[i];
622 }
623 (0..n)
624 .map(|i| {
625 let g = sm_dense[i];
626 let shrink = su / (se + gcnt[g] as f64 * su);
627 (v[i] - shrink * gsum[g]) / se
628 })
629 .collect::<Vec<f64>>()
630 };
631 let mut sinv_s = vec![0.0_f64; n * ncomp];
633 for k in 0..ncomp {
634 let col: Vec<f64> = (0..n).map(|i| scores[(i, k)]).collect();
635 let sc = sigma_inv(&col);
636 for i in 0..n {
637 sinv_s[i * ncomp + k] = sc[i];
638 }
639 }
640 let sinv_yc = sigma_inv(&yc);
642 let mut a_mat = vec![0.0_f64; ncomp * ncomp];
643 let mut b_vec = vec![0.0_f64; ncomp];
644 for k in 0..ncomp {
645 for l in 0..ncomp {
646 a_mat[k * ncomp + l] = (0..n).map(|i| scores[(i, k)] * sinv_s[i * ncomp + l]).sum();
647 }
648 b_vec[k] = (0..n).map(|i| scores[(i, k)] * sinv_yc[i]).sum();
649 }
650 let mut q_phi = vec![0.0_f64; m * ncomp];
653 for i in 0..m {
654 for l in 0..ncomp {
655 q_phi[i * ncomp + l] = (0..m)
656 .map(|jj| q[i * m + jj] * fpca.rotation[(jj, l)])
657 .sum();
658 }
659 }
660 for k in 0..ncomp {
662 for l in 0..ncomp {
663 let p_kl: f64 = (0..m)
664 .map(|i| fpca.rotation[(i, k)] * q_phi[i * ncomp + l])
665 .sum();
666 a_mat[k * ncomp + l] += lambda * p_kl;
667 }
668 }
669 let gamma = cholesky_solve(&a_mat, &b_vec, ncomp).unwrap_or_else(|_| result.gamma.clone());
671
672 let beta: Vec<f64> = (0..m)
674 .map(|j| {
675 (0..ncomp.min(gamma.len()))
676 .map(|k| gamma[k] * fpca.rotation[(j, k)])
677 .sum()
678 })
679 .collect();
680
681 if beta.iter().any(|v| !v.is_finite()) {
682 return Err(FdarError::ComputationFailed {
683 operation: "lpeer",
684 detail: "non-finite beta after back-projection".into(),
685 });
686 }
687
688 let base = y_bar - w_bar.iter().zip(&beta).map(|(wb, b)| wb * b).sum::<f64>();
690 let fitted_values: Vec<f64> = (0..n)
691 .map(|i| base + (0..m).map(|j| data[(i, j)] * w[j] * beta[j]).sum::<f64>())
692 .collect();
693
694 Ok(LocalPeerResult {
695 beta,
696 intercept: y_bar,
697 w_bar,
698 fitted_values,
699 sigma2_subject: result.sigma2_u,
700 sigma2_resid: result.sigma2_eps,
701 n_subjects,
702 lambda,
703 penalty_type: config.penalty.clone(),
704 gcv: gcv_score,
705 lambda_method,
706 })
707}
708
709impl LocalPeerResult {
710 pub fn predict(&self, new_data: &FdMatrix, argvals: &[f64]) -> Result<Vec<f64>, FdarError> {
724 peer_predict_core(&self.beta, self.intercept, &self.w_bar, new_data, argvals)
725 }
726}
727
728fn build_q(m: usize, penalty: &PeerPenalty) -> Result<Vec<f64>, FdarError> {
734 match penalty {
735 PeerPenalty::Ridge => {
736 let mut q = vec![0.0_f64; m * m];
737 for i in 0..m {
738 q[i * m + i] = 1.0;
739 }
740 Ok(q)
741 }
742 PeerPenalty::Difference { order: 2 } => Ok(penalty_matrix(m)),
743 PeerPenalty::Difference { order } => Err(FdarError::InvalidParameter {
744 parameter: "penalty",
745 message: format!(
746 "Difference order {order} unsupported; only order 2 is available in this release"
747 ),
748 }),
749 PeerPenalty::Decree(q_raw, p_q) => {
750 if *p_q != m || q_raw.len() != m * m {
751 return Err(FdarError::InvalidDimension {
752 parameter: "penalty",
753 expected: format!("{m}x{m} ({} elems)", m * m),
754 actual: format!("{p_q}x{p_q} ({} elems)", q_raw.len()),
755 });
756 }
757 Ok(q_raw.clone())
758 }
759 }
760}
761
762fn compute_peer_trace_hat(wtw: &[f64], q: &[f64], lambda: f64, m: usize, n: usize) -> f64 {
768 let mut a = vec![0.0_f64; m * m];
769 for i in 0..m * m {
770 a[i] = wtw[i] + lambda * q[i];
771 }
772 let Ok(l) = cholesky_factor(&a, m) else {
773 return m as f64; };
775 let mut trace = 0.0_f64;
776 for j in 0..m {
777 let col: Vec<f64> = (0..m).map(|i| wtw[i * m + j]).collect();
779 let z = cholesky_forward_back(&l, &col, m);
780 trace += z[j];
781 }
782 trace.min(n as f64)
783}
784
785fn gcv_lambda_grid() -> Vec<f64> {
790 (0..40)
791 .map(|i| 10.0_f64.powf(-6.0 + 10.0 * i as f64 / 39.0))
792 .collect()
793}
794
795fn select_lambda_gcv_peer(
808 wc: &FdMatrix,
809 yc: &[f64],
810 wtw: &[f64],
811 wty: &[f64],
812 q: &[f64],
813 m: usize,
814 n: usize,
815) -> (f64, f64) {
816 let grid = gcv_lambda_grid();
817 let mut best_lam = grid[0];
818 let mut best_gcv = f64::INFINITY;
819
820 for &lam in &grid {
821 let mut a = vec![0.0_f64; m * m];
823 for i in 0..m * m {
824 a[i] = wtw[i] + lam * q[i];
825 }
826 let Ok(beta) = cholesky_solve(&a, wty, m) else {
828 continue;
829 };
830 let rss: f64 = (0..n)
832 .map(|i| {
833 let yhat: f64 = (0..m).map(|j| wc[(i, j)] * beta[j]).sum();
834 (yc[i] - yhat).powi(2)
835 })
836 .sum();
837 let trh = compute_peer_trace_hat(wtw, q, lam, m, n);
839 let denom = n as f64 - trh;
841 if denom <= 0.0 {
842 continue;
843 }
844 let gcv = n as f64 * rss / (denom * denom);
845 if gcv < best_gcv {
847 best_gcv = gcv;
848 best_lam = lam;
849 }
850 }
851 (best_lam, best_gcv)
852}
853
854fn select_lambda_reml_peer(wc: &FdMatrix, yc: &[f64], q: &[f64], m: usize, n: usize) -> f64 {
870 let q_mat = DMatrix::from_row_slice(m, m, q);
872 let eigen = q_mat.symmetric_eigen();
873
874 let mut idx_sorted: Vec<usize> = (0..m).collect();
875 idx_sorted.sort_by(|&a, &b| {
876 eigen.eigenvalues[a]
877 .partial_cmp(&eigen.eigenvalues[b])
878 .unwrap_or(std::cmp::Ordering::Equal)
879 });
880
881 let max_ev = idx_sorted
882 .iter()
883 .map(|&i| eigen.eigenvalues[i].abs())
884 .fold(0.0_f64, f64::max);
885 let tol = 1e-8 * max_ev.max(1.0);
886
887 let null_idx: Vec<usize> = idx_sorted
888 .iter()
889 .copied()
890 .filter(|&i| eigen.eigenvalues[i].abs() < tol)
891 .collect();
892 let range_idx: Vec<usize> = idx_sorted
893 .iter()
894 .copied()
895 .filter(|&i| eigen.eigenvalues[i].abs() >= tol)
896 .collect();
897 let s = null_idx.len();
898 let r = range_idx.len();
899
900 if r == 0 {
902 return 1e-4; }
904
905 let mut z_null = vec![0.0_f64; n * s];
909 let mut z_range = vec![0.0_f64; n * r];
910 for i in 0..n {
911 for (col, &ev_idx) in null_idx.iter().enumerate() {
912 let mut val = 0.0;
913 for j in 0..m {
914 val += wc[(i, j)] * eigen.eigenvectors[(j, ev_idx)];
915 }
916 z_null[i * s + col] = val;
917 }
918 for (col, &ev_idx) in range_idx.iter().enumerate() {
919 let mut val = 0.0;
920 for j in 0..m {
921 val += wc[(i, j)] * eigen.eigenvectors[(j, ev_idx)];
922 }
923 z_range[i * r + col] = val;
924 }
925 }
926
927 let mut ztz_range = vec![0.0_f64; r * r];
929 for a in 0..r {
930 for b in a..r {
931 let s_val: f64 = (0..n)
932 .map(|i| z_range[i * r + a] * z_range[i * r + b])
933 .sum();
934 ztz_range[a * r + b] = s_val;
935 ztz_range[b * r + a] = s_val;
936 }
937 }
938
939 let y_mean = yc.iter().sum::<f64>() / n as f64;
941 let y_var = yc.iter().map(|&v| (v - y_mean).powi(2)).sum::<f64>() / (n - 1).max(1) as f64;
942 let mut sigma2_e = y_var.max(1e-12);
943 let mut sigma2_u = (sigma2_e * 0.1).max(1e-12);
944
945 let mut alpha = vec![0.0_f64; s];
947 if s > 0 {
948 let mut ztz_null = vec![0.0_f64; s * s];
950 for a in 0..s {
951 for b in a..s {
952 let sv: f64 = (0..n).map(|i| z_null[i * s + a] * z_null[i * s + b]).sum();
953 ztz_null[a * s + b] = sv;
954 ztz_null[b * s + a] = sv;
955 }
956 }
957 for diag in 0..s {
958 ztz_null[diag * s + diag] += 1e-10;
959 }
960 let zty_null: Vec<f64> = (0..s)
962 .map(|col| (0..n).map(|i| z_null[i * s + col] * yc[i]).sum())
963 .collect();
964 if let Ok(a_init) = cholesky_solve(&ztz_null, &zty_null, s) {
965 alpha = a_init;
966 }
967 }
968
969 for _iter in 0..100 {
971 let su_old = sigma2_u;
972 let se_old = sigma2_e;
973
974 let r_alpha: Vec<f64> = if s > 0 {
976 (0..n)
977 .map(|i| {
978 let za: f64 = (0..s).map(|col| z_null[i * s + col] * alpha[col]).sum();
979 yc[i] - za
980 })
981 .collect()
982 } else {
983 yc.to_vec()
984 };
985
986 let mut big_m = vec![0.0_f64; r * r];
988 for idx in 0..r * r {
989 big_m[idx] = ztz_range[idx] / sigma2_e;
990 }
991 for diag in 0..r {
992 big_m[diag * r + diag] += 1.0 / sigma2_u;
993 }
994
995 let l_m = match cholesky_factor(&big_m, r) {
1000 Ok(l) => l,
1001 Err(_) => {
1002 sigma2_u = su_old;
1003 sigma2_e = se_old;
1004 break;
1005 }
1006 };
1007
1008 let mut sigma_b = vec![0.0_f64; r * r];
1010 let mut trace_sigma_b = 0.0_f64;
1011 for col in 0..r {
1012 let mut e_col = vec![0.0_f64; r];
1013 e_col[col] = 1.0;
1014 let sol = cholesky_forward_back(&l_m, &e_col, r);
1015 for row in 0..r {
1016 sigma_b[row * r + col] = sol[row];
1017 }
1018 trace_sigma_b += sol[col];
1019 }
1020
1021 let ztr: Vec<f64> = (0..r)
1023 .map(|col| (0..n).map(|i| z_range[i * r + col] * r_alpha[i]).sum())
1024 .collect();
1025
1026 let b_hat: Vec<f64> = (0..r)
1028 .map(|row| {
1029 (0..r)
1030 .map(|col| sigma_b[row * r + col] * ztr[col])
1031 .sum::<f64>()
1032 / sigma2_e
1033 })
1034 .collect();
1035
1036 let b_sq: f64 = b_hat.iter().map(|&v| v * v).sum();
1038 let sigma2_u_new = (b_sq + trace_sigma_b) / r as f64;
1039
1040 let resid: Vec<f64> = (0..n)
1042 .map(|i| {
1043 let zb: f64 = (0..r).map(|col| z_range[i * r + col] * b_hat[col]).sum();
1044 r_alpha[i] - zb
1045 })
1046 .collect();
1047
1048 let tr_zsz: f64 = (0..r)
1050 .map(|a| {
1051 (0..r)
1052 .map(|j| sigma_b[a * r + j] * ztz_range[j * r + a])
1053 .sum::<f64>()
1054 })
1055 .sum();
1056
1057 let resid_sq: f64 = resid.iter().map(|&v| v * v).sum();
1058 let sigma2_e_new = (resid_sq + tr_zsz) / n as f64;
1059
1060 let sigma2_u_c = sigma2_u_new.max(1e-12);
1064 let sigma2_e_c = sigma2_e_new.max(1e-12);
1065
1066 if s > 0 {
1070 let ratio = sigma2_e_c / sigma2_u_c;
1072 let mut k_mat = ztz_range.clone();
1073 for diag in 0..r {
1074 k_mat[diag * r + diag] += ratio;
1075 }
1076 if let Ok(l_k) = cholesky_factor(&k_mat, r) {
1078 let mut gls_lhs = vec![0.0_f64; s * s]; let mut gls_rhs = vec![0.0_f64; s]; let zr_yc: Vec<f64> = (0..r)
1085 .map(|col| (0..n).map(|i| z_range[i * r + col] * yc[i]).sum())
1086 .collect();
1087 let kinv_zr_yc = cholesky_forward_back(&l_k, &zr_yc, r);
1089 let sinv_yc: Vec<f64> = (0..n)
1091 .map(|i| {
1092 let zk: f64 = (0..r)
1093 .map(|col| z_range[i * r + col] * kinv_zr_yc[col])
1094 .sum();
1095 (yc[i] - zk) / sigma2_e_c
1096 })
1097 .collect();
1098
1099 for col_null in 0..s {
1100 let zr_zn: Vec<f64> = (0..r)
1102 .map(|col| {
1103 (0..n)
1104 .map(|i| z_range[i * r + col] * z_null[i * s + col_null])
1105 .sum()
1106 })
1107 .collect();
1108 let kinv_zr_zn = cholesky_forward_back(&l_k, &zr_zn, r);
1110 let sinv_zn: Vec<f64> = (0..n)
1112 .map(|i| {
1113 let zk: f64 = (0..r)
1114 .map(|col| z_range[i * r + col] * kinv_zr_zn[col])
1115 .sum();
1116 (z_null[i * s + col_null] - zk) / sigma2_e_c
1117 })
1118 .collect();
1119
1120 for row_null in 0..s {
1122 let v: f64 = (0..n).map(|i| z_null[i * s + row_null] * sinv_zn[i]).sum();
1123 gls_lhs[row_null * s + col_null] = v;
1124 }
1125 let rhs_val: f64 = (0..n).map(|i| z_null[i * s + col_null] * sinv_yc[i]).sum();
1127 gls_rhs[col_null] = rhs_val;
1128 }
1129
1130 for diag in 0..s {
1132 gls_lhs[diag * s + diag] += 1e-10;
1133 }
1134
1135 if let Ok(alpha_new) = cholesky_solve(&gls_lhs, &gls_rhs, s) {
1136 alpha = alpha_new;
1137 }
1138 }
1139 }
1140
1141 sigma2_u = sigma2_u_c;
1143 sigma2_e = sigma2_e_c;
1144
1145 let delta = (sigma2_u_new - su_old).abs() + (sigma2_e_new - se_old).abs();
1150 if delta < 1e-8 * (su_old + se_old) {
1151 break;
1152 }
1153 }
1154
1155 (sigma2_e / sigma2_u).max(1e-15)
1157}
1158
1159fn peer_predict_core(
1172 beta: &[f64],
1173 intercept: f64,
1174 w_bar: &[f64],
1175 new_data: &FdMatrix,
1176 argvals: &[f64],
1177) -> Result<Vec<f64>, FdarError> {
1178 let (n_new, m_new) = new_data.shape();
1179 let m = beta.len();
1180 if m_new != m {
1181 return Err(FdarError::InvalidDimension {
1182 parameter: "new_data",
1183 expected: format!("{m} columns (training grid length)"),
1184 actual: format!("{m_new}"),
1185 });
1186 }
1187 if argvals.len() != m {
1188 return Err(FdarError::InvalidDimension {
1189 parameter: "argvals",
1190 expected: format!("{m}"),
1191 actual: format!("{}", argvals.len()),
1192 });
1193 }
1194 if argvals.windows(2).any(|w| w[1] <= w[0]) {
1197 return Err(FdarError::InvalidParameter {
1198 parameter: "argvals",
1199 message: "argvals must be strictly increasing".to_string(),
1200 });
1201 }
1202 debug_assert_eq!(w_bar.len(), m, "w_bar length must equal beta length");
1205 let w = simpsons_weights(argvals);
1206 let base: f64 = intercept - w_bar.iter().zip(beta).map(|(wb, b)| wb * b).sum::<f64>();
1207 let preds: Vec<f64> = (0..n_new)
1208 .map(|i| {
1209 base + (0..m)
1210 .map(|j| new_data[(i, j)] * w[j] * beta[j])
1211 .sum::<f64>()
1212 })
1213 .collect();
1214 Ok(preds)
1215}
1216
1217impl PeerResult {
1218 pub fn predict(&self, new_data: &FdMatrix, argvals: &[f64]) -> Result<Vec<f64>, FdarError> {
1233 peer_predict_core(&self.beta, self.intercept, &self.w_bar, new_data, argvals)
1234 }
1235}
1236
1237#[cfg(test)]
1242mod tests {
1243 use super::*;
1244 use crate::test_helpers::uniform_grid;
1245
1246 fn hash_unit(k: u64) -> f64 {
1249 let mut z = k
1250 .wrapping_add(0x9E37_79B9_7F4A_7C15)
1251 .wrapping_mul(0xBF58_476D_1CE4_E5B9);
1252 z = (z ^ (z >> 30)).wrapping_mul(0x94D0_49BB_1331_11EB);
1253 z ^= z >> 31;
1254 ((z >> 11) as f64 / (1_u64 << 53) as f64) * 2.0 - 1.0
1255 }
1256
1257 fn make_fixture() -> (FdMatrix, Vec<f64>, Vec<f64>, Vec<f64>) {
1267 let (n, m) = (200_usize, 40_usize);
1268 let t = uniform_grid(m);
1269 let true_beta: Vec<f64> = t
1270 .iter()
1271 .map(|&ti| (std::f64::consts::PI * ti).sin())
1272 .collect();
1273 let w = simpsons_weights(&t);
1274
1275 let mut data = FdMatrix::zeros(n, m);
1276 let mut y = vec![0.0_f64; n];
1277 for i in 0..n {
1278 for j in 0..m {
1279 let xi = hash_unit((i * m + j) as u64);
1280 data[(i, j)] = xi;
1281 y[i] += xi * true_beta[j] * w[j];
1282 }
1283 y[i] += 0.005 * hash_unit(1_000_000 + i as u64);
1285 }
1286 (data, y, t, true_beta)
1287 }
1288
1289 #[test]
1294 fn test_peer_difference_beta_recovery() {
1295 let (data, y, t, true_beta) = make_fixture();
1296 let config = PeerConfig {
1297 penalty: PeerPenalty::Difference { order: 2 },
1298 lambda: LambdaChoice::Fixed(1e-4),
1299 };
1300 let result = peer(&data, &y, &t, &config).expect("peer() should succeed");
1301
1302 let max_err = result
1303 .beta
1304 .iter()
1305 .zip(true_beta.iter())
1306 .map(|(a, b)| (a - b).abs())
1307 .fold(0.0_f64, f64::max);
1308 assert!(
1309 max_err < 0.1,
1310 "Difference beta recovery error: {max_err} >= 0.1"
1311 );
1312 assert!(
1313 result.fitted_values.iter().all(|v| v.is_finite()),
1314 "fitted_values contain non-finite values"
1315 );
1316 }
1317
1318 #[test]
1319 fn test_peer_result_shape() {
1320 let (data, y, t, _) = make_fixture();
1321 let m = t.len();
1322 let n = y.len();
1323 let config = PeerConfig {
1324 penalty: PeerPenalty::Difference { order: 2 },
1325 lambda: LambdaChoice::Fixed(1e-4),
1326 };
1327 let result = peer(&data, &y, &t, &config).expect("peer() should succeed");
1328
1329 assert_eq!(result.beta.len(), m, "beta length should be m={m}");
1330 assert_eq!(
1331 result.fitted_values.len(),
1332 n,
1333 "fitted_values length should be n={n}"
1334 );
1335 assert!(
1336 result.effective_df.is_finite() && result.effective_df > 0.0,
1337 "effective_df={} must be finite and positive",
1338 result.effective_df
1339 );
1340 assert!(
1341 (result.lambda - 1e-4).abs() < 1e-15,
1342 "lambda should be 1e-4, got {}",
1343 result.lambda
1344 );
1345 assert_eq!(
1346 result.penalty_type,
1347 PeerPenalty::Difference { order: 2 },
1348 "penalty_type should be Difference{{order:2}}"
1349 );
1350
1351 let y_bar = y.iter().sum::<f64>() / n as f64;
1352 assert!(
1353 (result.intercept - y_bar).abs() < 1e-9,
1354 "intercept={} should equal mean(y)={y_bar} within 1e-9",
1355 result.intercept
1356 );
1357 }
1358
1359 #[test]
1364 fn test_peer_ridge_fits() {
1365 let (data, y, t, _) = make_fixture();
1366 let m = t.len();
1367 let config = PeerConfig {
1368 penalty: PeerPenalty::Ridge,
1369 lambda: LambdaChoice::Fixed(1e-4),
1370 };
1371 let result = peer(&data, &y, &t, &config).expect("peer() with Ridge should succeed");
1372 assert_eq!(result.beta.len(), m);
1373 assert!(
1374 result.beta.iter().all(|v| v.is_finite()),
1375 "Ridge beta contains non-finite values"
1376 );
1377 assert!(
1378 result.fitted_values.iter().all(|v| v.is_finite()),
1379 "Ridge fitted_values contain non-finite values"
1380 );
1381 }
1382
1383 #[test]
1384 fn test_peer_decree_fits() {
1385 let (data, y, t, _) = make_fixture();
1386 let m = t.len();
1387 let q_flat = penalty_matrix(m);
1389 let config = PeerConfig {
1390 penalty: PeerPenalty::Decree(q_flat, m),
1391 lambda: LambdaChoice::Fixed(1e-4),
1392 };
1393 let result = peer(&data, &y, &t, &config).expect("peer() with Decree should succeed");
1394 assert_eq!(result.beta.len(), m);
1395 assert!(
1396 result.beta.iter().all(|v| v.is_finite()),
1397 "Decree beta contains non-finite values"
1398 );
1399 assert!(
1400 result.fitted_values.iter().all(|v| v.is_finite()),
1401 "Decree fitted_values contain non-finite values"
1402 );
1403 }
1404
1405 #[test]
1406 fn test_peer_difference_order_rejected() {
1407 let (data, y, t, _) = make_fixture();
1408 let config = PeerConfig {
1409 penalty: PeerPenalty::Difference { order: 3 },
1410 lambda: LambdaChoice::Fixed(1e-4),
1411 };
1412 let err = peer(&data, &y, &t, &config).expect_err("order 3 should be rejected");
1413 assert!(
1414 matches!(err, FdarError::InvalidParameter { .. }),
1415 "expected InvalidParameter, got {err:?}"
1416 );
1417 }
1418
1419 #[test]
1424 fn test_peer_decree_distinct_from_roughness() {
1425 let (data, y, t, _) = make_fixture();
1426 let m = t.len();
1427 let lambda = LambdaChoice::Fixed(1.0);
1429
1430 let config_diff = PeerConfig {
1432 penalty: PeerPenalty::Difference { order: 2 },
1433 lambda: lambda.clone(),
1434 };
1435 let res_diff = peer(&data, &y, &t, &config_diff).expect("Difference{2} fit should succeed");
1436
1437 let b = m / 2;
1441 let mut q_partition = vec![0.0_f64; m * m];
1442 for i in 0..m.saturating_sub(2) {
1443 if i + 1 == b || i + 2 == b || i == b {
1445 continue;
1446 }
1447 let coeffs = [(i, 1.0_f64), (i + 1, -2.0), (i + 2, 1.0)];
1448 for &(r, cr) in &coeffs {
1449 for &(c, cc) in &coeffs {
1450 q_partition[r * m + c] += cr * cc;
1451 }
1452 }
1453 }
1454 for i in 0..m {
1456 q_partition[i * m + i] += 1e-6;
1457 }
1458
1459 let config_dec = PeerConfig {
1460 penalty: PeerPenalty::Decree(q_partition, m),
1461 lambda,
1462 };
1463 let res_dec =
1464 peer(&data, &y, &t, &config_dec).expect("Decree partition fit should succeed");
1465
1466 assert!(
1468 res_diff.beta.iter().all(|v| v.is_finite()),
1469 "Difference beta contains non-finite"
1470 );
1471 assert!(
1472 res_dec.beta.iter().all(|v| v.is_finite()),
1473 "Decree beta contains non-finite"
1474 );
1475
1476 let max_diff = res_diff
1478 .beta
1479 .iter()
1480 .zip(res_dec.beta.iter())
1481 .map(|(a, b)| (a - b).abs())
1482 .fold(0.0_f64, f64::max);
1483 assert!(
1484 max_diff > 1e-3,
1485 "Decree partition Q should yield a β(t) distinct from Difference{{2}} \
1486 by > 1e-3; got max_diff={max_diff}"
1487 );
1488 }
1489
1490 #[test]
1495 fn test_peer_decree_wrong_dim() {
1496 let (data, y, t, _) = make_fixture();
1497 let m = t.len();
1498 let config = PeerConfig {
1500 penalty: PeerPenalty::Decree(vec![0.0; (m - 1) * (m - 1)], m - 1),
1501 lambda: LambdaChoice::Fixed(1.0),
1502 };
1503 let err = peer(&data, &y, &t, &config).expect_err("wrong-dim Decree should return Err");
1504 assert!(
1505 matches!(err, FdarError::InvalidDimension { .. }),
1506 "expected InvalidDimension, got {err:?}"
1507 );
1508 }
1509
1510 #[test]
1511 fn test_peer_argvals_mismatch() {
1512 let (data, y, t, _) = make_fixture();
1513 let m = t.len();
1514 let argvals_bad: Vec<f64> = (0..=m).map(|i| i as f64 / m as f64).collect();
1516 let config = PeerConfig::default();
1517 let err = peer(&data, &y, &argvals_bad, &config)
1518 .expect_err("mismatched argvals should return Err");
1519 assert!(
1520 matches!(err, FdarError::InvalidDimension { .. }),
1521 "expected InvalidDimension, got {err:?}"
1522 );
1523 }
1524
1525 #[test]
1526 fn test_peer_no_nan_all_families() {
1527 let (data, y, t, _) = make_fixture();
1528 let m = t.len();
1529 let lambda = LambdaChoice::Fixed(1.0);
1530
1531 let config_ridge = PeerConfig {
1533 penalty: PeerPenalty::Ridge,
1534 lambda: lambda.clone(),
1535 };
1536 let res_ridge = peer(&data, &y, &t, &config_ridge).expect("Ridge should succeed");
1537 assert!(
1538 res_ridge.beta.iter().all(|v| v.is_finite()),
1539 "Ridge beta contains NaN/Inf"
1540 );
1541 assert!(
1542 res_ridge.fitted_values.iter().all(|v| v.is_finite()),
1543 "Ridge fitted_values contains NaN/Inf"
1544 );
1545 assert!(
1546 res_ridge.effective_df.is_finite(),
1547 "Ridge effective_df is not finite"
1548 );
1549
1550 let config_diff = PeerConfig {
1552 penalty: PeerPenalty::Difference { order: 2 },
1553 lambda: lambda.clone(),
1554 };
1555 let res_diff = peer(&data, &y, &t, &config_diff).expect("Difference{2} should succeed");
1556 assert!(
1557 res_diff.beta.iter().all(|v| v.is_finite()),
1558 "Difference beta contains NaN/Inf"
1559 );
1560 assert!(
1561 res_diff.fitted_values.iter().all(|v| v.is_finite()),
1562 "Difference fitted_values contains NaN/Inf"
1563 );
1564 assert!(
1565 res_diff.effective_df.is_finite(),
1566 "Difference effective_df is not finite"
1567 );
1568
1569 let q_flat = penalty_matrix(m);
1571 let config_dec = PeerConfig {
1572 penalty: PeerPenalty::Decree(q_flat, m),
1573 lambda,
1574 };
1575 let res_dec = peer(&data, &y, &t, &config_dec).expect("Decree should succeed");
1576 assert!(
1577 res_dec.beta.iter().all(|v| v.is_finite()),
1578 "Decree beta contains NaN/Inf"
1579 );
1580 assert!(
1581 res_dec.fitted_values.iter().all(|v| v.is_finite()),
1582 "Decree fitted_values contains NaN/Inf"
1583 );
1584 assert!(
1585 res_dec.effective_df.is_finite(),
1586 "Decree effective_df is not finite"
1587 );
1588 }
1589
1590 #[test]
1595 fn test_peer_stores_w_bar_for_prediction() {
1596 let (data, y, t, _) = make_fixture();
1599 let (n, m) = data.shape();
1600 let w = simpsons_weights(&t);
1601 let config = PeerConfig {
1602 penalty: PeerPenalty::Difference { order: 2 },
1603 lambda: LambdaChoice::Fixed(1e-4),
1604 };
1605 let result = peer(&data, &y, &t, &config).expect("peer() should succeed");
1606
1607 assert_eq!(result.w_bar.len(), m, "w_bar length must equal m");
1608
1609 let base = result.intercept
1610 - result
1611 .w_bar
1612 .iter()
1613 .zip(&result.beta)
1614 .map(|(wb, b)| wb * b)
1615 .sum::<f64>();
1616 for i in 0..n {
1617 let pred = base
1618 + (0..m)
1619 .map(|j| data[(i, j)] * w[j] * result.beta[j])
1620 .sum::<f64>();
1621 assert!(
1622 (pred - result.fitted_values[i]).abs() < 1e-9,
1623 "prediction reconstruction mismatch at row {i}: {pred} vs {}",
1624 result.fitted_values[i]
1625 );
1626 }
1627 }
1628
1629 #[test]
1630 fn test_peer_rejects_non_monotonic_argvals() {
1631 let (data, y, t, _) = make_fixture();
1632 let mut bad = t.clone();
1633 bad.swap(0, 1); let config = PeerConfig::default();
1635 let res = peer(&data, &y, &bad, &config);
1636 assert!(
1637 matches!(res, Err(FdarError::InvalidParameter { .. })),
1638 "non-monotonic argvals must be rejected, got {res:?}"
1639 );
1640 }
1641
1642 #[test]
1643 fn test_peer_rejects_single_observation() {
1644 let m = 5;
1645 let t = uniform_grid(m);
1646 let mut data = FdMatrix::zeros(1, m);
1647 for j in 0..m {
1648 data[(0, j)] = 1.0 + j as f64;
1649 }
1650 let y = vec![1.0];
1651 let res = peer(&data, &y, &t, &PeerConfig::default());
1652 assert!(
1653 matches!(res, Err(FdarError::InvalidDimension { .. })),
1654 "single observation must be rejected, got {res:?}"
1655 );
1656 }
1657
1658 #[test]
1659 fn test_peer_rejects_non_finite_y() {
1660 let (data, mut y, t, _) = make_fixture();
1661 y[0] = f64::NAN;
1662 let res = peer(&data, &y, &t, &PeerConfig::default());
1663 assert!(
1664 matches!(res, Err(FdarError::InvalidParameter { .. })),
1665 "non-finite y must be rejected, got {res:?}"
1666 );
1667 }
1668
1669 #[test]
1674 fn test_peer_gcv_deterministic() {
1675 let (data, y, t, _) = make_fixture();
1676 let config = PeerConfig {
1677 penalty: PeerPenalty::Difference { order: 2 },
1678 lambda: LambdaChoice::Gcv,
1679 };
1680 let r1 = peer(&data, &y, &t, &config).expect("first GCV call should succeed");
1681 let r2 = peer(&data, &y, &t, &config).expect("second GCV call should succeed");
1682 assert_eq!(
1683 r1.lambda, r2.lambda,
1684 "GCV lambda must be bit-exact across two runs: {} vs {}",
1685 r1.lambda, r2.lambda
1686 );
1687 assert_eq!(r1.lambda_method, LambdaMethod::Gcv);
1688 assert!(r1.gcv.is_some(), "GCV result must have gcv score");
1689 }
1690
1691 #[test]
1692 fn test_peer_gcv_recovers_beta() {
1693 let (data, y, t, true_beta) = make_fixture();
1694 let config = PeerConfig {
1695 penalty: PeerPenalty::Difference { order: 2 },
1696 lambda: LambdaChoice::Gcv,
1697 };
1698 let result = peer(&data, &y, &t, &config).expect("GCV peer() should succeed");
1699
1700 assert!(
1702 result.lambda > 1e-10 && result.lambda < 1e6,
1703 "GCV lambda should be non-degenerate, got {}",
1704 result.lambda
1705 );
1706 assert!(result.gcv.is_some(), "gcv field must be Some for Gcv");
1708 assert_eq!(result.lambda_method, LambdaMethod::Gcv);
1709
1710 let max_err = result
1712 .beta
1713 .iter()
1714 .zip(true_beta.iter())
1715 .map(|(a, b)| (a - b).abs())
1716 .fold(0.0_f64, f64::max);
1717 assert!(max_err < 0.15, "GCV beta recovery error: {max_err} >= 0.15");
1718 }
1719
1720 #[test]
1725 fn test_peer_reml_deterministic() {
1726 let (data, y, t, _) = make_fixture();
1727 let config = PeerConfig {
1728 penalty: PeerPenalty::Difference { order: 2 },
1729 lambda: LambdaChoice::Reml,
1730 };
1731 let r1 = peer(&data, &y, &t, &config).expect("first REML call should succeed");
1732 let r2 = peer(&data, &y, &t, &config).expect("second REML call should succeed");
1733 assert_eq!(
1734 r1.lambda, r2.lambda,
1735 "REML lambda must be bit-exact across two runs: {} vs {}",
1736 r1.lambda, r2.lambda
1737 );
1738 assert_eq!(r1.lambda_method, LambdaMethod::Reml);
1739 assert!(r1.gcv.is_none(), "REML result must have gcv == None");
1740 }
1741
1742 #[test]
1743 fn test_peer_reml_lambda_positive() {
1744 let (data, y, t, _) = make_fixture();
1745 let config = PeerConfig {
1746 penalty: PeerPenalty::Difference { order: 2 },
1747 lambda: LambdaChoice::Reml,
1748 };
1749 let result = peer(&data, &y, &t, &config).expect("REML peer() should succeed");
1750
1751 assert!(
1752 result.lambda > 0.0 && result.lambda.is_finite(),
1753 "REML lambda must be positive and finite, got {}",
1754 result.lambda
1755 );
1756 assert!(result.gcv.is_none(), "gcv must be None for Reml");
1757 assert_eq!(result.lambda_method, LambdaMethod::Reml);
1758 }
1759
1760 #[test]
1761 fn test_peer_reml_gcv_beta_agreement() {
1762 let (data, y, t, true_beta) = make_fixture(); let config_gcv = PeerConfig {
1771 penalty: PeerPenalty::Difference { order: 2 },
1772 lambda: LambdaChoice::Gcv,
1773 };
1774 let config_reml = PeerConfig {
1775 penalty: PeerPenalty::Difference { order: 2 },
1776 lambda: LambdaChoice::Reml,
1777 };
1778
1779 let res_gcv = peer(&data, &y, &t, &config_gcv).expect("GCV fit should succeed");
1780 let res_reml = peer(&data, &y, &t, &config_reml).expect("REML fit should succeed");
1781
1782 let reml_err = res_reml
1784 .beta
1785 .iter()
1786 .zip(true_beta.iter())
1787 .map(|(a, b)| (a - b).abs())
1788 .fold(0.0_f64, f64::max);
1789 assert!(
1790 reml_err < 0.15,
1791 "REML beta recovery error: {reml_err} >= 0.15"
1792 );
1793
1794 let beta_diff = res_gcv
1796 .beta
1797 .iter()
1798 .zip(res_reml.beta.iter())
1799 .map(|(a, b)| (a - b).abs())
1800 .fold(0.0_f64, f64::max);
1801 assert!(
1802 beta_diff < 0.2,
1803 "REML vs GCV beta disagreement: {beta_diff} >= 0.2 (lambdas: gcv={}, reml={})",
1804 res_gcv.lambda,
1805 res_reml.lambda
1806 );
1807 }
1808
1809 #[test]
1815 fn test_peer_predict_self_consistent() {
1816 let (data, y, t, _) = make_fixture();
1818 let config = PeerConfig {
1819 penalty: PeerPenalty::Difference { order: 2 },
1820 lambda: LambdaChoice::Fixed(1e-4),
1821 };
1822 let result = peer(&data, &y, &t, &config).expect("peer() should succeed");
1823 let preds = result
1824 .predict(&data, &t)
1825 .expect("predict on training data should succeed");
1826 assert_eq!(preds.len(), result.fitted_values.len());
1827 for (i, (p, f)) in preds.iter().zip(&result.fitted_values).enumerate() {
1828 assert!(
1829 (p - f).abs() < 1e-9,
1830 "predict vs fitted_values mismatch at row {i}: {p} vs {f}"
1831 );
1832 }
1833 }
1834
1835 #[test]
1836 fn test_predict_wrong_ncols() {
1837 let (data, y, t, _) = make_fixture();
1839 let (_, m) = data.shape();
1840 let config = PeerConfig {
1841 penalty: PeerPenalty::Ridge,
1842 lambda: LambdaChoice::Fixed(1e-4),
1843 };
1844 let result = peer(&data, &y, &t, &config).expect("peer() should succeed");
1845
1846 let mut bad_data = FdMatrix::zeros(5, m - 1);
1848 for i in 0..5 {
1849 for j in 0..(m - 1) {
1850 bad_data[(i, j)] = hash_unit((i * m + j) as u64);
1851 }
1852 }
1853 let err = result
1854 .predict(&bad_data, &t[..m - 1])
1855 .expect_err("wrong ncols should return Err");
1856 assert!(
1857 matches!(err, FdarError::InvalidDimension { .. }),
1858 "expected InvalidDimension, got {err:?}"
1859 );
1860 }
1861
1862 #[test]
1863 fn test_predict_new_curves_finite() {
1864 let (data, y, t, _) = make_fixture();
1866 let (n, m) = data.shape();
1867 let config = PeerConfig {
1868 penalty: PeerPenalty::Ridge,
1869 lambda: LambdaChoice::Fixed(1e-4),
1870 };
1871 let result = peer(&data, &y, &t, &config).expect("peer() should succeed");
1872
1873 let mut new_data = FdMatrix::zeros(n, m);
1875 for i in 0..n {
1876 for j in 0..m {
1877 new_data[(i, j)] = hash_unit((100_000 + i * m + j) as u64) + 10.0;
1878 }
1879 }
1880 let preds = result
1881 .predict(&new_data, &t)
1882 .expect("predict on new curves should succeed");
1883 assert!(
1884 preds.iter().all(|v| v.is_finite()),
1885 "predictions on fresh curves contain non-finite values"
1886 );
1887 }
1888
1889 #[test]
1890 fn test_peer_reml_ridge_and_zeroq_edges() {
1891 let (data, y, t, _) = make_fixture();
1892 let m = t.len();
1893
1894 let config_ridge = PeerConfig {
1896 penalty: PeerPenalty::Ridge,
1897 lambda: LambdaChoice::Reml,
1898 };
1899 let res_ridge =
1900 peer(&data, &y, &t, &config_ridge).expect("REML with Ridge (s=0) should not panic");
1901 assert!(
1902 res_ridge.lambda > 0.0 && res_ridge.lambda.is_finite(),
1903 "Ridge REML lambda must be positive finite, got {}",
1904 res_ridge.lambda
1905 );
1906
1907 let zero_q = vec![0.0_f64; m * m];
1909 let config_zero = PeerConfig {
1910 penalty: PeerPenalty::Decree(zero_q, m),
1911 lambda: LambdaChoice::Reml,
1912 };
1913 let res_zero =
1914 peer(&data, &y, &t, &config_zero).expect("REML with zero-Q should not panic");
1915 assert!(
1916 (res_zero.lambda - 1e-4).abs() < 1e-15,
1917 "zero-Q REML fallback lambda should be 1e-4, got {}",
1918 res_zero.lambda
1919 );
1920 }
1921
1922 fn make_lpeer_fixture() -> (FdMatrix, Vec<f64>, Vec<f64>, Vec<usize>, f64) {
1933 let (n_subjects, obs_per, m) = (20_usize, 10_usize, 10_usize);
1934 let n = n_subjects * obs_per;
1935 let t = uniform_grid(m);
1936 let true_beta: Vec<f64> = t
1937 .iter()
1938 .map(|&ti| (std::f64::consts::PI * ti).sin())
1939 .collect();
1940 let w = simpsons_weights(&t);
1941 let sigma2_u_true = 1.0_f64;
1942
1943 let mut data = FdMatrix::zeros(n, m);
1944 let mut y = vec![0.0_f64; n];
1945 let mut subject_map = vec![0_usize; n];
1946
1947 for s in 0..n_subjects {
1948 let u_s = hash_unit(s as u64) * sigma2_u_true.sqrt();
1950 for obs in 0..obs_per {
1951 let i = s * obs_per + obs;
1952 subject_map[i] = s;
1953 for j in 0..m {
1954 let xi = hash_unit((i * m + j) as u64);
1955 data[(i, j)] = xi;
1956 y[i] += xi * true_beta[j] * w[j];
1957 }
1958 y[i] += u_s; y[i] += 0.02 * hash_unit(1_000_000 + i as u64);
1961 }
1962 }
1963 (data, y, t, subject_map, sigma2_u_true)
1964 }
1965
1966 #[test]
1967 fn test_lpeer_variance_non_negative() {
1968 let (data, y, t, subject_map, _) = make_lpeer_fixture();
1969 let config = PeerConfig {
1970 penalty: PeerPenalty::Ridge,
1971 lambda: LambdaChoice::Fixed(1e-3),
1972 };
1973 let fit = lpeer(&data, &y, &t, &subject_map, &config)
1974 .expect("lpeer() should succeed on longitudinal fixture");
1975 assert!(
1976 fit.sigma2_subject >= 0.0,
1977 "sigma2_subject must be non-negative, got {}",
1978 fit.sigma2_subject
1979 );
1980 assert!(
1981 fit.sigma2_resid >= 0.0,
1982 "sigma2_resid must be non-negative, got {}",
1983 fit.sigma2_resid
1984 );
1985 }
1986
1987 #[test]
1988 fn test_lpeer_beta_recovery() {
1989 let (data, y, t, subject_map, _) = make_lpeer_fixture();
1990 let m = t.len();
1991 let true_beta: Vec<f64> = t
1992 .iter()
1993 .map(|&ti| (std::f64::consts::PI * ti).sin())
1994 .collect();
1995 let config = PeerConfig {
1996 penalty: PeerPenalty::Ridge,
1997 lambda: LambdaChoice::Fixed(1e-3),
1998 };
1999 let fit = lpeer(&data, &y, &t, &subject_map, &config)
2000 .expect("lpeer() should succeed on longitudinal fixture");
2001 assert_eq!(fit.beta.len(), m);
2002 let max_err = fit
2003 .beta
2004 .iter()
2005 .zip(true_beta.iter())
2006 .map(|(a, b)| (a - b).abs())
2007 .fold(0.0_f64, f64::max);
2008 assert!(
2009 max_err < 0.5,
2010 "lpeer β(t) recovery error vs sin(πt): {max_err} >= 0.5"
2011 );
2012 }
2013
2014 #[test]
2015 fn test_lpeer_lambda_regularizes() {
2016 let (data, y, t, subject_map, _) = make_lpeer_fixture();
2019 let cfg_light = PeerConfig {
2020 penalty: PeerPenalty::Difference { order: 2 },
2021 lambda: LambdaChoice::Fixed(0.0),
2022 };
2023 let cfg_heavy = PeerConfig {
2024 penalty: PeerPenalty::Difference { order: 2 },
2025 lambda: LambdaChoice::Fixed(1e6),
2026 };
2027 let light = lpeer(&data, &y, &t, &subject_map, &cfg_light).expect("light λ fit");
2028 let heavy = lpeer(&data, &y, &t, &subject_map, &cfg_heavy).expect("heavy λ fit");
2029 let max_diff = light
2030 .beta
2031 .iter()
2032 .zip(heavy.beta.iter())
2033 .map(|(a, b)| (a - b).abs())
2034 .fold(0.0_f64, f64::max);
2035 assert!(
2036 max_diff > 1e-3,
2037 "λ does not regularize β(t): max|β(λ=0) − β(λ=1e6)| = {max_diff} <= 1e-3"
2038 );
2039 assert!(heavy.beta.iter().all(|v| v.is_finite()));
2040 }
2041
2042 #[test]
2043 fn test_lpeer_sigma2_tracks_injection() {
2044 let (data, y, t, subject_map, sigma2_u_true) = make_lpeer_fixture();
2045 let config = PeerConfig {
2046 penalty: PeerPenalty::Ridge,
2047 lambda: LambdaChoice::Fixed(1e-3),
2048 };
2049 let fit = lpeer(&data, &y, &t, &subject_map, &config)
2050 .expect("lpeer() should succeed on longitudinal fixture");
2051 assert!(
2053 fit.sigma2_subject > 0.1 && fit.sigma2_subject < 5.0,
2054 "lpeer sigma2_subject={} should track injected {sigma2_u_true} within (0.1, 5.0)",
2055 fit.sigma2_subject
2056 );
2057 }
2058
2059 #[test]
2060 fn test_lpeer_invalid_subject_map() {
2061 let (data, y, t, _, _) = make_lpeer_fixture();
2062 let n = y.len();
2063 let bad_map: Vec<usize> = (0..n - 1).map(|i| i / 5).collect();
2065 let config = PeerConfig {
2066 penalty: PeerPenalty::Ridge,
2067 lambda: LambdaChoice::Fixed(1e-3),
2068 };
2069 let err = lpeer(&data, &y, &t, &bad_map, &config)
2070 .expect_err("wrong-length subject_map should return Err");
2071 assert!(
2072 matches!(err, FdarError::InvalidDimension { .. }),
2073 "expected InvalidDimension, got {err:?}"
2074 );
2075 }
2076
2077 #[test]
2078 fn test_lpeer_single_subject_rejected() {
2079 let (data, y, t, _, _) = make_lpeer_fixture();
2080 let n = y.len();
2081 let single_map = vec![0_usize; n];
2083 let config = PeerConfig {
2084 penalty: PeerPenalty::Ridge,
2085 lambda: LambdaChoice::Fixed(1e-3),
2086 };
2087 let err = lpeer(&data, &y, &t, &single_map, &config)
2088 .expect_err("single-subject map should be rejected");
2089 assert!(
2090 matches!(err, FdarError::InvalidParameter { .. }),
2091 "expected InvalidParameter, got {err:?}"
2092 );
2093 }
2094
2095 #[test]
2096 fn test_lpeer_predict_self_consistent() {
2097 let (data, y, t, subject_map, _) = make_lpeer_fixture();
2099 let config = PeerConfig {
2100 penalty: PeerPenalty::Ridge,
2101 lambda: LambdaChoice::Fixed(1e-3),
2102 };
2103 let fit = lpeer(&data, &y, &t, &subject_map, &config)
2104 .expect("lpeer() should succeed on longitudinal fixture");
2105 let preds = fit
2106 .predict(&data, &t)
2107 .expect("LocalPeerResult::predict on training data should succeed");
2108 assert_eq!(preds.len(), fit.fitted_values.len());
2109 for (i, (p, f)) in preds.iter().zip(&fit.fitted_values).enumerate() {
2110 assert!(
2111 (p - f).abs() < 1e-9,
2112 "LocalPeerResult::predict vs fitted_values mismatch at row {i}: {p} vs {f}"
2113 );
2114 }
2115 }
2116
2117 #[test]
2122 fn test_crate_root_exports_compile() {
2123 use crate::error::FdarError;
2129
2130 let _peer_fn: fn(&FdMatrix, &[f64], &[f64], &PeerConfig) -> Result<PeerResult, FdarError> =
2132 crate::peer::peer;
2133 let _lpeer_fn: fn(
2134 &FdMatrix,
2135 &[f64],
2136 &[f64],
2137 &[usize],
2138 &PeerConfig,
2139 ) -> Result<LocalPeerResult, FdarError> = crate::peer::lpeer;
2140
2141 let _lc = LambdaChoice::Fixed(1.0);
2143 let _lm = LambdaMethod::Fixed;
2144 let _pp = PeerPenalty::Ridge;
2145 let _ = PeerConfig {
2146 penalty: _pp,
2147 lambda: _lc,
2148 };
2149 let _ = std::mem::size_of::<PeerResult>();
2151 let _ = std::mem::size_of::<LocalPeerResult>();
2152 }
2153
2154 #[test]
2155 fn test_prelude_exports_compile() {
2156 use crate::error::FdarError;
2159 use crate::prelude::*;
2160
2161 let _peer_fn: fn(&FdMatrix, &[f64], &[f64], &PeerConfig) -> Result<PeerResult, FdarError> =
2162 peer;
2163 let _lpeer_fn: fn(
2164 &FdMatrix,
2165 &[f64],
2166 &[f64],
2167 &[usize],
2168 &PeerConfig,
2169 ) -> Result<LocalPeerResult, FdarError> = lpeer;
2170
2171 let _lc = LambdaChoice::Gcv;
2172 let _lm = LambdaMethod::Gcv;
2173 let _pp = PeerPenalty::Ridge;
2174 let _ = PeerConfig {
2175 penalty: _pp,
2176 lambda: _lc,
2177 };
2178 let _ = std::mem::size_of::<PeerResult>();
2179 let _ = std::mem::size_of::<LocalPeerResult>();
2180 }
2181}