1use crate::error::FdarError;
15use crate::iter_maybe_parallel;
16use crate::linalg::{cholesky_factor, cholesky_forward_back, compute_xtx};
17use crate::matrix::FdMatrix;
18use crate::regression::fdata_to_pc_1d;
19#[cfg(feature = "parallel")]
20use rayon::iter::ParallelIterator;
21
22#[derive(Debug, Clone, PartialEq)]
28#[non_exhaustive]
29pub struct FosrResult {
30 pub intercept: Vec<f64>,
32 pub beta: FdMatrix,
34 pub fitted: FdMatrix,
36 pub residuals: FdMatrix,
38 pub r_squared_t: Vec<f64>,
40 pub r_squared: f64,
42 pub beta_se: FdMatrix,
44 pub lambda: f64,
46 pub gcv: f64,
48}
49
50#[derive(Debug, Clone, PartialEq)]
52#[non_exhaustive]
53pub struct FosrFpcResult {
54 pub intercept: Vec<f64>,
56 pub beta: FdMatrix,
58 pub fitted: FdMatrix,
60 pub residuals: FdMatrix,
62 pub r_squared_t: Vec<f64>,
64 pub r_squared: f64,
66 pub beta_scores: Vec<Vec<f64>>,
68 pub ncomp: usize,
70}
71
72#[derive(Debug, Clone, PartialEq)]
74#[non_exhaustive]
75pub struct FanovaResult {
76 pub group_means: FdMatrix,
78 pub overall_mean: Vec<f64>,
80 pub f_statistic_t: Vec<f64>,
82 pub global_statistic: f64,
84 pub p_value: f64,
86 pub n_perm: usize,
88 pub n_groups: usize,
90 pub group_labels: Vec<usize>,
92}
93
94pub(crate) fn penalty_matrix(m: usize) -> Vec<f64> {
100 if m < 3 {
101 return vec![0.0; m * m];
102 }
103 let mut dtd = vec![0.0; m * m];
106 for i in 0..m - 2 {
107 let coeffs = [(i, 1.0), (i + 1, -2.0), (i + 2, 1.0)];
109 for &(r, cr) in &coeffs {
110 for &(c, cc) in &coeffs {
111 dtd[r * m + c] += cr * cc;
112 }
113 }
114 }
115 dtd
116}
117
118fn penalized_solve(
122 xtx: &[f64],
123 xty: &FdMatrix,
124 penalty: &[f64],
125 lambda: f64,
126) -> Result<FdMatrix, FdarError> {
127 let p = xty.nrows();
128 let m = xty.ncols();
129
130 let mut a = vec![0.0; p * p];
132 for i in 0..p * p {
133 a[i] = xtx[i] + lambda * penalty[i];
134 }
135
136 let l = cholesky_factor(&a, p)?;
138
139 let mut beta = FdMatrix::zeros(p, m);
141 for t in 0..m {
142 let b: Vec<f64> = (0..p).map(|j| xty[(j, t)]).collect();
143 let x = cholesky_forward_back(&l, &b, p);
144 for j in 0..p {
145 beta[(j, t)] = x[j];
146 }
147 }
148 Ok(beta)
149}
150
151pub(crate) fn pointwise_r_squared(data: &FdMatrix, fitted: &FdMatrix) -> Vec<f64> {
153 let (n, m) = data.shape();
154 (0..m)
155 .map(|t| {
156 let mean_t: f64 = (0..n).map(|i| data[(i, t)]).sum::<f64>() / n as f64;
157 let ss_tot: f64 = (0..n).map(|i| (data[(i, t)] - mean_t).powi(2)).sum();
158 let ss_res: f64 = (0..n)
159 .map(|i| (data[(i, t)] - fitted[(i, t)]).powi(2))
160 .sum();
161 if ss_tot > 1e-15 {
162 1.0 - ss_res / ss_tot
163 } else {
164 0.0
165 }
166 })
167 .collect()
168}
169
170fn compute_fosr_gcv(residuals: &FdMatrix, trace_h: f64) -> f64 {
172 let (n, m) = residuals.shape();
173 let denom = (1.0 - trace_h / n as f64).max(1e-10);
174 let ss_res: f64 = (0..n)
175 .flat_map(|i| (0..m).map(move |t| residuals[(i, t)].powi(2)))
176 .sum();
177 ss_res / (n as f64 * m as f64 * denom * denom)
178}
179
180pub(crate) fn build_fosr_design(predictors: &FdMatrix, n: usize) -> FdMatrix {
205 let p = predictors.ncols();
206 let p_total = p + 1;
207 let mut design = FdMatrix::zeros(n, p_total);
208 for i in 0..n {
209 design[(i, 0)] = 1.0;
210 for j in 0..p {
211 design[(i, 1 + j)] = predictors[(i, j)];
212 }
213 }
214 design
215}
216
217pub(crate) fn compute_xty_matrix(design: &FdMatrix, data: &FdMatrix) -> FdMatrix {
219 let (n, m) = data.shape();
220 let p_total = design.ncols();
221 let mut xty = FdMatrix::zeros(p_total, m);
222 for j in 0..p_total {
223 for t in 0..m {
224 let mut s = 0.0;
225 for i in 0..n {
226 s += design[(i, j)] * data[(i, t)];
227 }
228 xty[(j, t)] = s;
229 }
230 }
231 xty
232}
233
234fn drop_intercept_rows(full: &FdMatrix, p: usize, m: usize) -> FdMatrix {
236 let mut out = FdMatrix::zeros(p, m);
237 for j in 0..p {
238 for t in 0..m {
239 out[(j, t)] = full[(j + 1, t)];
240 }
241 }
242 out
243}
244
245#[must_use = "expensive computation whose result should not be discarded"]
275pub fn fosr(data: &FdMatrix, predictors: &FdMatrix, lambda: f64) -> Result<FosrResult, FdarError> {
276 let (n, m) = data.shape();
277 let p = predictors.ncols();
278 if m == 0 {
279 return Err(FdarError::InvalidDimension {
280 parameter: "data",
281 expected: "at least 1 column (grid points)".to_string(),
282 actual: "0 columns".to_string(),
283 });
284 }
285 if predictors.nrows() != n {
286 return Err(FdarError::InvalidDimension {
287 parameter: "predictors",
288 expected: format!("{n} rows (matching data)"),
289 actual: format!("{} rows", predictors.nrows()),
290 });
291 }
292 if n < p + 2 {
293 return Err(FdarError::InvalidDimension {
294 parameter: "data",
295 expected: format!("at least {} observations (p + 2)", p + 2),
296 actual: format!("{n} observations"),
297 });
298 }
299
300 let design = build_fosr_design(predictors, n);
301 let p_total = design.ncols();
302 let xtx = compute_xtx(&design);
303 let xty = compute_xty_matrix(&design, data);
304 let penalty = penalty_matrix(p_total);
305
306 let lambda = if lambda < 0.0 {
307 select_lambda_gcv(&xtx, &xty, &penalty, data, &design)
308 } else {
309 lambda
310 };
311
312 let beta = penalized_solve(&xtx, &xty, &penalty, lambda)?;
313 let (fitted, residuals) = compute_fosr_fitted(&design, &beta, data);
314
315 let r_squared_t = pointwise_r_squared(data, &fitted);
316 let r_squared = r_squared_t.iter().sum::<f64>() / m as f64;
317 let beta_se = compute_beta_se(&xtx, &penalty, lambda, &residuals, p_total, n);
318 let trace_h = compute_trace_hat(&xtx, &penalty, lambda, p_total, n);
319 let gcv = compute_fosr_gcv(&residuals, trace_h);
320
321 let intercept: Vec<f64> = (0..m).map(|t| beta[(0, t)]).collect();
322
323 Ok(FosrResult {
324 intercept,
325 beta: drop_intercept_rows(&beta, p, m),
326 fitted,
327 residuals,
328 r_squared_t,
329 r_squared,
330 beta_se: drop_intercept_rows(&beta_se, p, m),
331 lambda,
332 gcv,
333 })
334}
335
336fn compute_fosr_fitted(
338 design: &FdMatrix,
339 beta: &FdMatrix,
340 data: &FdMatrix,
341) -> (FdMatrix, FdMatrix) {
342 let (n, m) = data.shape();
343 let p_total = design.ncols();
344 let rows: Vec<(Vec<f64>, Vec<f64>)> = iter_maybe_parallel!(0..n)
345 .map(|i| {
346 let mut fitted_row = vec![0.0; m];
347 let mut resid_row = vec![0.0; m];
348 for t in 0..m {
349 let mut yhat = 0.0;
350 for j in 0..p_total {
351 yhat += design[(i, j)] * beta[(j, t)];
352 }
353 fitted_row[t] = yhat;
354 resid_row[t] = data[(i, t)] - yhat;
355 }
356 (fitted_row, resid_row)
357 })
358 .collect();
359 let mut fitted = FdMatrix::zeros(n, m);
360 let mut residuals = FdMatrix::zeros(n, m);
361 for (i, (fr, rr)) in rows.into_iter().enumerate() {
362 for t in 0..m {
363 fitted[(i, t)] = fr[t];
364 residuals[(i, t)] = rr[t];
365 }
366 }
367 (fitted, residuals)
368}
369
370fn select_lambda_gcv(
372 xtx: &[f64],
373 xty: &FdMatrix,
374 penalty: &[f64],
375 data: &FdMatrix,
376 design: &FdMatrix,
377) -> f64 {
378 let lambdas = [0.0, 1e-6, 1e-4, 1e-2, 0.1, 1.0, 10.0, 100.0, 1000.0];
379 let p_total = design.ncols();
380 let n = design.nrows();
381
382 let mut best_lambda = 0.0;
383 let mut best_gcv = f64::INFINITY;
384
385 for &lam in &lambdas {
386 let Ok(beta) = penalized_solve(xtx, xty, penalty, lam) else {
387 continue;
388 };
389 let (_, residuals) = compute_fosr_fitted(design, &beta, data);
390 let trace_h = compute_trace_hat(xtx, penalty, lam, p_total, n);
391 let gcv = compute_fosr_gcv(&residuals, trace_h);
392 if gcv < best_gcv {
393 best_gcv = gcv;
394 best_lambda = lam;
395 }
396 }
397 best_lambda
398}
399
400fn compute_trace_hat(xtx: &[f64], penalty: &[f64], lambda: f64, p: usize, n: usize) -> f64 {
402 let mut a = vec![0.0; p * p];
403 for i in 0..p * p {
404 a[i] = xtx[i] + lambda * penalty[i];
405 }
406 let Ok(l) = cholesky_factor(&a, p) else {
409 return p as f64; };
411
412 let mut trace = 0.0;
414 for j in 0..p {
415 let col: Vec<f64> = (0..p).map(|i| xtx[i * p + j]).collect();
416 let z = cholesky_forward_back(&l, &col, p);
417 trace += z[j]; }
419 trace.min(n as f64)
420}
421
422fn compute_beta_se(
424 xtx: &[f64],
425 penalty: &[f64],
426 lambda: f64,
427 residuals: &FdMatrix,
428 p: usize,
429 n: usize,
430) -> FdMatrix {
431 let m = residuals.ncols();
432 let mut a = vec![0.0; p * p];
433 for i in 0..p * p {
434 a[i] = xtx[i] + lambda * penalty[i];
435 }
436 let Ok(l) = cholesky_factor(&a, p) else {
437 return FdMatrix::zeros(p, m);
438 };
439
440 let a_inv_diag: Vec<f64> = (0..p)
442 .map(|j| {
443 let mut ej = vec![0.0; p];
444 ej[j] = 1.0;
445 let v = cholesky_forward_back(&l, &ej, p);
446 v[j]
447 })
448 .collect();
449
450 let df = (n - p).max(1) as f64;
451 let mut se = FdMatrix::zeros(p, m);
452 for t in 0..m {
453 let sigma2_t: f64 = (0..n).map(|i| residuals[(i, t)].powi(2)).sum::<f64>() / df;
454 for j in 0..p {
455 se[(j, t)] = (sigma2_t * a_inv_diag[j]).max(0.0).sqrt();
456 }
457 }
458 se
459}
460
461fn regress_scores_on_design(
469 design: &FdMatrix,
470 scores: &FdMatrix,
471 n: usize,
472 k: usize,
473 p_total: usize,
474) -> Result<Vec<Vec<f64>>, FdarError> {
475 let xtx = compute_xtx(design);
476 let l = cholesky_factor(&xtx, p_total)?;
477
478 let gamma_all: Vec<Vec<f64>> = (0..k)
479 .map(|comp| {
480 let mut xts = vec![0.0; p_total];
481 for j in 0..p_total {
482 for i in 0..n {
483 xts[j] += design[(i, j)] * scores[(i, comp)];
484 }
485 }
486 cholesky_forward_back(&l, &xts, p_total)
487 })
488 .collect();
489 Ok(gamma_all)
490}
491
492fn reconstruct_beta_fpc(
494 gamma_all: &[Vec<f64>],
495 rotation: &FdMatrix,
496 p: usize,
497 k: usize,
498 m: usize,
499) -> FdMatrix {
500 let mut beta = FdMatrix::zeros(p, m);
501 for j in 0..p {
502 for t in 0..m {
503 let mut val = 0.0;
504 for comp in 0..k {
505 val += gamma_all[comp][1 + j] * rotation[(t, comp)];
506 }
507 beta[(j, t)] = val;
508 }
509 }
510 beta
511}
512
513fn compute_intercept_fpc(
515 mean: &[f64],
516 gamma_all: &[Vec<f64>],
517 rotation: &FdMatrix,
518 k: usize,
519 m: usize,
520) -> Vec<f64> {
521 let mut intercept = mean.to_vec();
522 for t in 0..m {
523 for comp in 0..k {
524 intercept[t] += gamma_all[comp][0] * rotation[(t, comp)];
525 }
526 }
527 intercept
528}
529
530fn extract_beta_scores(gamma_all: &[Vec<f64>], p: usize, k: usize, m: usize) -> Vec<Vec<f64>> {
532 let h = if m > 1 { 1.0 / (m - 1) as f64 } else { 1.0 };
533 let score_scale = h.sqrt();
534 (0..p)
535 .map(|j| {
536 (0..k)
537 .map(|comp| gamma_all[comp][1 + j] * score_scale)
538 .collect()
539 })
540 .collect()
541}
542
543#[must_use = "expensive computation whose result should not be discarded"]
562pub fn fosr_fpc(
563 data: &FdMatrix,
564 predictors: &FdMatrix,
565 ncomp: usize,
566) -> Result<FosrFpcResult, FdarError> {
567 let (n, m) = data.shape();
568 let p = predictors.ncols();
569 if m == 0 {
570 return Err(FdarError::InvalidDimension {
571 parameter: "data",
572 expected: "at least 1 column (grid points)".to_string(),
573 actual: "0 columns".to_string(),
574 });
575 }
576 if predictors.nrows() != n {
577 return Err(FdarError::InvalidDimension {
578 parameter: "predictors",
579 expected: format!("{n} rows (matching data)"),
580 actual: format!("{} rows", predictors.nrows()),
581 });
582 }
583 if n < p + 2 {
584 return Err(FdarError::InvalidDimension {
585 parameter: "data",
586 expected: format!("at least {} observations (p + 2)", p + 2),
587 actual: format!("{n} observations"),
588 });
589 }
590 if ncomp == 0 {
591 return Err(FdarError::InvalidParameter {
592 parameter: "ncomp",
593 message: "number of FPC components must be at least 1".to_string(),
594 });
595 }
596
597 let argvals: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1).max(1) as f64).collect();
598 let fpca = fdata_to_pc_1d(data, ncomp, &argvals)?;
599 let k = fpca.scores.ncols();
600 let p_total = p + 1;
601 let design = build_fosr_design(predictors, n);
602
603 let gamma_all = regress_scores_on_design(&design, &fpca.scores, n, k, p_total)?;
604 let beta = reconstruct_beta_fpc(&gamma_all, &fpca.rotation, p, k, m);
605 let intercept = compute_intercept_fpc(&fpca.mean, &gamma_all, &fpca.rotation, k, m);
606
607 let (fitted, residuals) = compute_fosr_fpc_fitted(data, &intercept, &beta, predictors);
608 let r_squared_t = pointwise_r_squared(data, &fitted);
609 let r_squared = r_squared_t.iter().sum::<f64>() / m as f64;
610 let beta_scores = extract_beta_scores(&gamma_all, p, k, m);
611
612 Ok(FosrFpcResult {
613 intercept,
614 beta,
615 fitted,
616 residuals,
617 r_squared_t,
618 r_squared,
619 beta_scores,
620 ncomp: k,
621 })
622}
623
624fn compute_fosr_fpc_fitted(
626 data: &FdMatrix,
627 intercept: &[f64],
628 beta: &FdMatrix,
629 predictors: &FdMatrix,
630) -> (FdMatrix, FdMatrix) {
631 let (n, m) = data.shape();
632 let p = predictors.ncols();
633 let mut fitted = FdMatrix::zeros(n, m);
634 let mut residuals = FdMatrix::zeros(n, m);
635 for i in 0..n {
636 for t in 0..m {
637 let mut yhat = intercept[t];
638 for j in 0..p {
639 yhat += predictors[(i, j)] * beta[(j, t)];
640 }
641 fitted[(i, t)] = yhat;
642 residuals[(i, t)] = data[(i, t)] - yhat;
643 }
644 }
645 (fitted, residuals)
646}
647
648#[must_use = "prediction result should not be discarded"]
654pub fn predict_fosr(result: &FosrResult, new_predictors: &FdMatrix) -> FdMatrix {
655 let n_new = new_predictors.nrows();
656 let m = result.intercept.len();
657 let p = result.beta.nrows();
658
659 let mut predicted = FdMatrix::zeros(n_new, m);
660 for i in 0..n_new {
661 for t in 0..m {
662 let mut yhat = result.intercept[t];
663 for j in 0..p {
664 yhat += new_predictors[(i, j)] * result.beta[(j, t)];
665 }
666 predicted[(i, t)] = yhat;
667 }
668 }
669 predicted
670}
671
672pub(crate) fn compute_group_means(
682 data: &FdMatrix,
683 groups: &[usize],
684 labels: &[usize],
685) -> (FdMatrix, Vec<f64>) {
686 let (n, m) = data.shape();
687 let k = labels.len();
688 let mut group_means = FdMatrix::zeros(k, m);
689 let mut counts = vec![0usize; k];
690
691 for i in 0..n {
692 let g = labels.iter().position(|&l| l == groups[i]).unwrap_or(0);
693 counts[g] += 1;
694 for t in 0..m {
695 group_means[(g, t)] += data[(i, t)];
696 }
697 }
698 for g in 0..k {
699 if counts[g] > 0 {
700 for t in 0..m {
701 group_means[(g, t)] /= counts[g] as f64;
702 }
703 }
704 }
705
706 let overall_mean: Vec<f64> = (0..m)
707 .map(|t| (0..n).map(|i| data[(i, t)]).sum::<f64>() / n as f64)
708 .collect();
709
710 (group_means, overall_mean)
711}
712
713fn pointwise_f_statistic(
715 data: &FdMatrix,
716 groups: &[usize],
717 labels: &[usize],
718 group_means: &FdMatrix,
719 overall_mean: &[f64],
720) -> Vec<f64> {
721 let (n, m) = data.shape();
722 let k = labels.len();
723 let mut counts = vec![0usize; k];
724 for &g in groups {
725 let idx = labels.iter().position(|&l| l == g).unwrap_or(0);
726 counts[idx] += 1;
727 }
728
729 (0..m)
730 .map(|t| {
731 let ss_between: f64 = (0..k)
732 .map(|g| counts[g] as f64 * (group_means[(g, t)] - overall_mean[t]).powi(2))
733 .sum();
734 let ss_within: f64 = (0..n)
735 .map(|i| {
736 let g = labels.iter().position(|&l| l == groups[i]).unwrap_or(0);
737 (data[(i, t)] - group_means[(g, t)]).powi(2)
738 })
739 .sum();
740 let ms_between = ss_between / (k as f64 - 1.0).max(1.0);
741 let ms_within = ss_within / (n as f64 - k as f64).max(1.0);
742 if ms_within > 1e-15 {
743 ms_between / ms_within
744 } else {
745 0.0
746 }
747 })
748 .collect()
749}
750
751fn global_f_statistic(f_t: &[f64]) -> f64 {
753 f_t.iter().sum::<f64>() / f_t.len() as f64
754}
755
756pub(crate) fn integrated_f_statistic(data: &FdMatrix, groups: &[usize], labels: &[usize]) -> f64 {
767 let (group_means, overall_mean) = compute_group_means(data, groups, labels);
768 let f_t = pointwise_f_statistic(data, groups, labels, &group_means, &overall_mean);
769 global_f_statistic(&f_t)
770}
771
772#[must_use = "expensive computation whose result should not be discarded"]
804pub fn fanova_seeded(
805 data: &FdMatrix,
806 groups: &[usize],
807 n_perm: usize,
808 seed: u64,
809) -> Result<FanovaResult, FdarError> {
810 let (n, m) = data.shape();
811 if m == 0 {
812 return Err(FdarError::InvalidDimension {
813 parameter: "data",
814 expected: "at least 1 column (grid points)".to_string(),
815 actual: "0 columns".to_string(),
816 });
817 }
818 if groups.len() != n {
819 return Err(FdarError::InvalidDimension {
820 parameter: "groups",
821 expected: format!("{n} elements (matching data rows)"),
822 actual: format!("{} elements", groups.len()),
823 });
824 }
825 if n < 3 {
826 return Err(FdarError::InvalidDimension {
827 parameter: "data",
828 expected: "at least 3 observations".to_string(),
829 actual: format!("{n} observations"),
830 });
831 }
832
833 let mut labels: Vec<usize> = groups.to_vec();
834 labels.sort_unstable();
835 labels.dedup();
836 let n_groups = labels.len();
837 if n_groups < 2 {
838 return Err(FdarError::InvalidParameter {
839 parameter: "groups",
840 message: format!("at least 2 distinct groups required, but only {n_groups} found"),
841 });
842 }
843
844 let (group_means, overall_mean) = compute_group_means(data, groups, &labels);
845 let f_t = pointwise_f_statistic(data, groups, &labels, &group_means, &overall_mean);
846 let observed_stat = global_f_statistic(&f_t);
847
848 let n_perm = n_perm.max(1);
850 let mut n_ge = 0usize;
851 let mut perm_groups = groups.to_vec();
852
853 let mut rng_state: u64 = seed;
858 for _ in 0..n_perm {
859 for i in (1..n).rev() {
861 rng_state = rng_state
862 .wrapping_mul(6_364_136_223_846_793_005)
863 .wrapping_add(1);
864 let j = (rng_state >> 33) as usize % (i + 1);
865 perm_groups.swap(i, j);
866 }
867
868 let perm_stat = integrated_f_statistic(data, &perm_groups, &labels);
869 if perm_stat >= observed_stat {
870 n_ge += 1;
871 }
872 }
873
874 let p_value = (n_ge as f64 + 1.0) / (n_perm as f64 + 1.0);
875
876 Ok(FanovaResult {
877 group_means,
878 overall_mean,
879 f_statistic_t: f_t,
880 global_statistic: observed_stat,
881 p_value,
882 n_perm,
883 n_groups,
884 group_labels: labels,
885 })
886}
887
888#[deprecated(
913 since = "0.30.0",
914 note = "use `fanova_seeded` for reproducible permutation p-values; `fanova` delegates with the legacy fixed seed 42"
915)]
916#[must_use = "expensive computation whose result should not be discarded"]
917pub fn fanova(data: &FdMatrix, groups: &[usize], n_perm: usize) -> Result<FanovaResult, FdarError> {
918 fanova_seeded(data, groups, n_perm, 42)
919}
920
921impl FosrResult {
922 pub fn predict(&self, new_predictors: &FdMatrix) -> FdMatrix {
924 predict_fosr(self, new_predictors)
925 }
926}
927
928#[cfg(test)]
933mod tests {
934 use super::*;
935 use crate::test_helpers::uniform_grid;
936 use std::f64::consts::PI;
937
938 fn generate_fosr_data(n: usize, m: usize) -> (FdMatrix, FdMatrix) {
939 let t = uniform_grid(m);
940 let mut y = FdMatrix::zeros(n, m);
941 let mut z = FdMatrix::zeros(n, 2);
942
943 for i in 0..n {
944 let age = (i as f64) / (n as f64);
945 let group = if i % 2 == 0 { 1.0 } else { 0.0 };
946 z[(i, 0)] = age;
947 z[(i, 1)] = group;
948 for j in 0..m {
949 let mu = (2.0 * PI * t[j]).sin();
951 let beta1 = t[j]; let beta2 = (4.0 * PI * t[j]).cos(); y[(i, j)] = mu
954 + age * beta1
955 + group * beta2
956 + 0.05 * ((i * 13 + j * 7) % 100) as f64 / 100.0;
957 }
958 }
959 (y, z)
960 }
961
962 #[test]
965 fn test_fosr_basic() {
966 let (y, z) = generate_fosr_data(30, 50);
967 let result = fosr(&y, &z, 0.0);
968 assert!(result.is_ok());
969 let fit = result.unwrap();
970 assert_eq!(fit.intercept.len(), 50);
971 assert_eq!(fit.beta.shape(), (2, 50));
972 assert_eq!(fit.fitted.shape(), (30, 50));
973 assert_eq!(fit.residuals.shape(), (30, 50));
974 assert!(fit.r_squared >= 0.0);
975 }
976
977 #[test]
978 fn test_fosr_with_penalty() {
979 let (y, z) = generate_fosr_data(30, 50);
980 let fit0 = fosr(&y, &z, 0.0).unwrap();
981 let fit1 = fosr(&y, &z, 1.0).unwrap();
982 assert_eq!(fit0.beta.shape(), (2, 50));
984 assert_eq!(fit1.beta.shape(), (2, 50));
985 }
986
987 #[test]
988 fn test_fosr_auto_lambda() {
989 let (y, z) = generate_fosr_data(30, 50);
990 let fit = fosr(&y, &z, -1.0).unwrap();
991 assert!(fit.lambda >= 0.0);
992 }
993
994 #[test]
995 fn test_fosr_fitted_plus_residuals_equals_y() {
996 let (y, z) = generate_fosr_data(30, 50);
997 let fit = fosr(&y, &z, 0.0).unwrap();
998 for i in 0..30 {
999 for t in 0..50 {
1000 let reconstructed = fit.fitted[(i, t)] + fit.residuals[(i, t)];
1001 assert!(
1002 (reconstructed - y[(i, t)]).abs() < 1e-10,
1003 "ŷ + r should equal y at ({}, {})",
1004 i,
1005 t
1006 );
1007 }
1008 }
1009 }
1010
1011 #[test]
1012 fn test_fosr_pointwise_r_squared_valid() {
1013 let (y, z) = generate_fosr_data(30, 50);
1014 let fit = fosr(&y, &z, 0.0).unwrap();
1015 for &r2 in &fit.r_squared_t {
1016 assert!(
1017 (-0.01..=1.0 + 1e-10).contains(&r2),
1018 "R²(t) out of range: {}",
1019 r2
1020 );
1021 }
1022 }
1023
1024 #[test]
1025 fn test_fosr_se_positive() {
1026 let (y, z) = generate_fosr_data(30, 50);
1027 let fit = fosr(&y, &z, 0.0).unwrap();
1028 for j in 0..2 {
1029 for t in 0..50 {
1030 assert!(
1031 fit.beta_se[(j, t)] >= 0.0 && fit.beta_se[(j, t)].is_finite(),
1032 "SE should be non-negative finite"
1033 );
1034 }
1035 }
1036 }
1037
1038 #[test]
1039 fn test_fosr_invalid_input() {
1040 let y = FdMatrix::zeros(2, 50);
1041 let z = FdMatrix::zeros(2, 1);
1042 assert!(fosr(&y, &z, 0.0).is_err());
1043 }
1044
1045 #[test]
1048 fn test_predict_fosr_on_training_data() {
1049 let (y, z) = generate_fosr_data(30, 50);
1050 let fit = fosr(&y, &z, 0.0).unwrap();
1051 let preds = predict_fosr(&fit, &z);
1052 assert_eq!(preds.shape(), (30, 50));
1053 for i in 0..30 {
1054 for t in 0..50 {
1055 assert!(
1056 (preds[(i, t)] - fit.fitted[(i, t)]).abs() < 1e-8,
1057 "Prediction on training data should match fitted"
1058 );
1059 }
1060 }
1061 }
1062
1063 #[allow(deprecated)]
1067 #[test]
1068 fn test_fanova_two_groups() {
1069 let n = 40;
1070 let m = 50;
1071 let t = uniform_grid(m);
1072
1073 let mut data = FdMatrix::zeros(n, m);
1074 let mut groups = vec![0usize; n];
1075 for i in 0..n {
1076 groups[i] = if i < n / 2 { 0 } else { 1 };
1077 for j in 0..m {
1078 let base = (2.0 * PI * t[j]).sin();
1079 let effect = if groups[i] == 1 { 0.5 * t[j] } else { 0.0 };
1080 data[(i, j)] = base + effect + 0.01 * (i as f64 * 0.1).sin();
1081 }
1082 }
1083
1084 let result = fanova(&data, &groups, 200);
1085 assert!(result.is_ok());
1086 let res = result.unwrap();
1087 assert_eq!(res.n_groups, 2);
1088 assert_eq!(res.group_means.shape(), (2, m));
1089 assert_eq!(res.f_statistic_t.len(), m);
1090 assert!(res.p_value >= 0.0 && res.p_value <= 1.0);
1091 assert!(
1093 res.p_value < 0.1,
1094 "Should detect group effect, got p={}",
1095 res.p_value
1096 );
1097 }
1098
1099 #[allow(deprecated)]
1101 #[test]
1102 fn test_fanova_no_effect() {
1103 let n = 40;
1104 let m = 50;
1105 let t = uniform_grid(m);
1106
1107 let mut data = FdMatrix::zeros(n, m);
1108 let mut groups = vec![0usize; n];
1109 for i in 0..n {
1110 groups[i] = if i < n / 2 { 0 } else { 1 };
1111 for j in 0..m {
1112 data[(i, j)] =
1114 (2.0 * PI * t[j]).sin() + 0.1 * ((i * 7 + j * 3) % 100) as f64 / 100.0;
1115 }
1116 }
1117
1118 let result = fanova(&data, &groups, 200);
1119 assert!(result.is_ok());
1120 let res = result.unwrap();
1121 assert!(
1123 res.p_value > 0.05,
1124 "Should not detect effect, got p={}",
1125 res.p_value
1126 );
1127 }
1128
1129 #[allow(deprecated)]
1131 #[test]
1132 fn test_fanova_three_groups() {
1133 let n = 30;
1134 let m = 50;
1135 let t = uniform_grid(m);
1136
1137 let mut data = FdMatrix::zeros(n, m);
1138 let mut groups = vec![0usize; n];
1139 for i in 0..n {
1140 groups[i] = i % 3;
1141 for j in 0..m {
1142 let effect = match groups[i] {
1143 0 => 0.0,
1144 1 => 0.5 * t[j],
1145 _ => -0.3 * (2.0 * PI * t[j]).cos(),
1146 };
1147 data[(i, j)] = (2.0 * PI * t[j]).sin() + effect + 0.01 * (i as f64 * 0.1).sin();
1148 }
1149 }
1150
1151 let result = fanova(&data, &groups, 200);
1152 assert!(result.is_ok());
1153 let res = result.unwrap();
1154 assert_eq!(res.n_groups, 3);
1155 }
1156
1157 #[allow(deprecated)]
1159 #[test]
1160 fn test_fanova_invalid_input() {
1161 let data = FdMatrix::zeros(10, 50);
1162 let groups = vec![0; 10]; assert!(fanova(&data, &groups, 100).is_err());
1164
1165 let groups = vec![0; 5]; assert!(fanova(&data, &groups, 100).is_err());
1167 }
1168}