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;
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(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
888impl FosrResult {
889 pub fn predict(&self, new_predictors: &FdMatrix) -> FdMatrix {
891 predict_fosr(self, new_predictors)
892 }
893}
894
895#[cfg(test)]
900mod tests {
901 use super::*;
902 use crate::test_helpers::uniform_grid;
903 use std::f64::consts::PI;
904
905 fn generate_fosr_data(n: usize, m: usize) -> (FdMatrix, FdMatrix) {
906 let t = uniform_grid(m);
907 let mut y = FdMatrix::zeros(n, m);
908 let mut z = FdMatrix::zeros(n, 2);
909
910 for i in 0..n {
911 let age = (i as f64) / (n as f64);
912 let group = if i % 2 == 0 { 1.0 } else { 0.0 };
913 z[(i, 0)] = age;
914 z[(i, 1)] = group;
915 for j in 0..m {
916 let mu = (2.0 * PI * t[j]).sin();
918 let beta1 = t[j]; let beta2 = (4.0 * PI * t[j]).cos(); y[(i, j)] = mu
921 + age * beta1
922 + group * beta2
923 + 0.05 * ((i * 13 + j * 7) % 100) as f64 / 100.0;
924 }
925 }
926 (y, z)
927 }
928
929 #[test]
932 fn test_fosr_basic() {
933 let (y, z) = generate_fosr_data(30, 50);
934 let result = fosr(&y, &z, 0.0);
935 assert!(result.is_ok());
936 let fit = result.unwrap();
937 assert_eq!(fit.intercept.len(), 50);
938 assert_eq!(fit.beta.shape(), (2, 50));
939 assert_eq!(fit.fitted.shape(), (30, 50));
940 assert_eq!(fit.residuals.shape(), (30, 50));
941 assert!(fit.r_squared >= 0.0);
942 }
943
944 #[test]
945 fn test_fosr_with_penalty() {
946 let (y, z) = generate_fosr_data(30, 50);
947 let fit0 = fosr(&y, &z, 0.0).unwrap();
948 let fit1 = fosr(&y, &z, 1.0).unwrap();
949 assert_eq!(fit0.beta.shape(), (2, 50));
951 assert_eq!(fit1.beta.shape(), (2, 50));
952 }
953
954 #[test]
955 fn test_fosr_auto_lambda() {
956 let (y, z) = generate_fosr_data(30, 50);
957 let fit = fosr(&y, &z, -1.0).unwrap();
958 assert!(fit.lambda >= 0.0);
959 }
960
961 #[test]
962 fn test_fosr_fitted_plus_residuals_equals_y() {
963 let (y, z) = generate_fosr_data(30, 50);
964 let fit = fosr(&y, &z, 0.0).unwrap();
965 for i in 0..30 {
966 for t in 0..50 {
967 let reconstructed = fit.fitted[(i, t)] + fit.residuals[(i, t)];
968 assert!(
969 (reconstructed - y[(i, t)]).abs() < 1e-10,
970 "ŷ + r should equal y at ({}, {})",
971 i,
972 t
973 );
974 }
975 }
976 }
977
978 #[test]
979 fn test_fosr_pointwise_r_squared_valid() {
980 let (y, z) = generate_fosr_data(30, 50);
981 let fit = fosr(&y, &z, 0.0).unwrap();
982 for &r2 in &fit.r_squared_t {
983 assert!(
984 (-0.01..=1.0 + 1e-10).contains(&r2),
985 "R²(t) out of range: {}",
986 r2
987 );
988 }
989 }
990
991 #[test]
992 fn test_fosr_se_positive() {
993 let (y, z) = generate_fosr_data(30, 50);
994 let fit = fosr(&y, &z, 0.0).unwrap();
995 for j in 0..2 {
996 for t in 0..50 {
997 assert!(
998 fit.beta_se[(j, t)] >= 0.0 && fit.beta_se[(j, t)].is_finite(),
999 "SE should be non-negative finite"
1000 );
1001 }
1002 }
1003 }
1004
1005 #[test]
1006 fn test_fosr_invalid_input() {
1007 let y = FdMatrix::zeros(2, 50);
1008 let z = FdMatrix::zeros(2, 1);
1009 assert!(fosr(&y, &z, 0.0).is_err());
1010 }
1011
1012 #[test]
1015 fn test_predict_fosr_on_training_data() {
1016 let (y, z) = generate_fosr_data(30, 50);
1017 let fit = fosr(&y, &z, 0.0).unwrap();
1018 let preds = predict_fosr(&fit, &z);
1019 assert_eq!(preds.shape(), (30, 50));
1020 for i in 0..30 {
1021 for t in 0..50 {
1022 assert!(
1023 (preds[(i, t)] - fit.fitted[(i, t)]).abs() < 1e-8,
1024 "Prediction on training data should match fitted"
1025 );
1026 }
1027 }
1028 }
1029
1030 #[test]
1034 fn test_fanova_two_groups() {
1035 let n = 40;
1036 let m = 50;
1037 let t = uniform_grid(m);
1038
1039 let mut data = FdMatrix::zeros(n, m);
1040 let mut groups = vec![0usize; n];
1041 for i in 0..n {
1042 groups[i] = if i < n / 2 { 0 } else { 1 };
1043 for j in 0..m {
1044 let base = (2.0 * PI * t[j]).sin();
1045 let effect = if groups[i] == 1 { 0.5 * t[j] } else { 0.0 };
1046 data[(i, j)] = base + effect + 0.01 * (i as f64 * 0.1).sin();
1047 }
1048 }
1049
1050 let result = fanova_seeded(&data, &groups, 200, 42);
1051 assert!(result.is_ok());
1052 let res = result.unwrap();
1053 assert_eq!(res.n_groups, 2);
1054 assert_eq!(res.group_means.shape(), (2, m));
1055 assert_eq!(res.f_statistic_t.len(), m);
1056 assert!(res.p_value >= 0.0 && res.p_value <= 1.0);
1057 assert!(
1059 res.p_value < 0.1,
1060 "Should detect group effect, got p={}",
1061 res.p_value
1062 );
1063 }
1064
1065 #[test]
1067 fn test_fanova_no_effect() {
1068 let n = 40;
1069 let m = 50;
1070 let t = uniform_grid(m);
1071
1072 let mut data = FdMatrix::zeros(n, m);
1073 let mut groups = vec![0usize; n];
1074 for i in 0..n {
1075 groups[i] = if i < n / 2 { 0 } else { 1 };
1076 for j in 0..m {
1077 data[(i, j)] =
1079 (2.0 * PI * t[j]).sin() + 0.1 * ((i * 7 + j * 3) % 100) as f64 / 100.0;
1080 }
1081 }
1082
1083 let result = fanova_seeded(&data, &groups, 200, 42);
1084 assert!(result.is_ok());
1085 let res = result.unwrap();
1086 assert!(
1088 res.p_value > 0.05,
1089 "Should not detect effect, got p={}",
1090 res.p_value
1091 );
1092 }
1093
1094 #[test]
1096 fn test_fanova_three_groups() {
1097 let n = 30;
1098 let m = 50;
1099 let t = uniform_grid(m);
1100
1101 let mut data = FdMatrix::zeros(n, m);
1102 let mut groups = vec![0usize; n];
1103 for i in 0..n {
1104 groups[i] = i % 3;
1105 for j in 0..m {
1106 let effect = match groups[i] {
1107 0 => 0.0,
1108 1 => 0.5 * t[j],
1109 _ => -0.3 * (2.0 * PI * t[j]).cos(),
1110 };
1111 data[(i, j)] = (2.0 * PI * t[j]).sin() + effect + 0.01 * (i as f64 * 0.1).sin();
1112 }
1113 }
1114
1115 let result = fanova_seeded(&data, &groups, 200, 42);
1116 assert!(result.is_ok());
1117 let res = result.unwrap();
1118 assert_eq!(res.n_groups, 3);
1119 }
1120
1121 #[test]
1123 fn test_fanova_invalid_input() {
1124 let data = FdMatrix::zeros(10, 50);
1125 let groups = vec![0; 10]; assert!(fanova_seeded(&data, &groups, 100, 42).is_err());
1127
1128 let groups = vec![0; 5]; assert!(fanova_seeded(&data, &groups, 100, 42).is_err());
1130 }
1131}