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"]
791pub fn fanova(data: &FdMatrix, groups: &[usize], n_perm: usize) -> Result<FanovaResult, FdarError> {
792 let (n, m) = data.shape();
793 if m == 0 {
794 return Err(FdarError::InvalidDimension {
795 parameter: "data",
796 expected: "at least 1 column (grid points)".to_string(),
797 actual: "0 columns".to_string(),
798 });
799 }
800 if groups.len() != n {
801 return Err(FdarError::InvalidDimension {
802 parameter: "groups",
803 expected: format!("{n} elements (matching data rows)"),
804 actual: format!("{} elements", groups.len()),
805 });
806 }
807 if n < 3 {
808 return Err(FdarError::InvalidDimension {
809 parameter: "data",
810 expected: "at least 3 observations".to_string(),
811 actual: format!("{n} observations"),
812 });
813 }
814
815 let mut labels: Vec<usize> = groups.to_vec();
816 labels.sort_unstable();
817 labels.dedup();
818 let n_groups = labels.len();
819 if n_groups < 2 {
820 return Err(FdarError::InvalidParameter {
821 parameter: "groups",
822 message: format!("at least 2 distinct groups required, but only {n_groups} found"),
823 });
824 }
825
826 let (group_means, overall_mean) = compute_group_means(data, groups, &labels);
827 let f_t = pointwise_f_statistic(data, groups, &labels, &group_means, &overall_mean);
828 let observed_stat = global_f_statistic(&f_t);
829
830 let n_perm = n_perm.max(1);
832 let mut n_ge = 0usize;
833 let mut perm_groups = groups.to_vec();
834
835 let mut rng_state: u64 = 42;
837 for _ in 0..n_perm {
838 for i in (1..n).rev() {
840 rng_state = rng_state
841 .wrapping_mul(6_364_136_223_846_793_005)
842 .wrapping_add(1);
843 let j = (rng_state >> 33) as usize % (i + 1);
844 perm_groups.swap(i, j);
845 }
846
847 let perm_stat = integrated_f_statistic(data, &perm_groups, &labels);
848 if perm_stat >= observed_stat {
849 n_ge += 1;
850 }
851 }
852
853 let p_value = (n_ge as f64 + 1.0) / (n_perm as f64 + 1.0);
854
855 Ok(FanovaResult {
856 group_means,
857 overall_mean,
858 f_statistic_t: f_t,
859 global_statistic: observed_stat,
860 p_value,
861 n_perm,
862 n_groups,
863 group_labels: labels,
864 })
865}
866
867impl FosrResult {
868 pub fn predict(&self, new_predictors: &FdMatrix) -> FdMatrix {
870 predict_fosr(self, new_predictors)
871 }
872}
873
874#[cfg(test)]
879mod tests {
880 use super::*;
881 use crate::test_helpers::uniform_grid;
882 use std::f64::consts::PI;
883
884 fn generate_fosr_data(n: usize, m: usize) -> (FdMatrix, FdMatrix) {
885 let t = uniform_grid(m);
886 let mut y = FdMatrix::zeros(n, m);
887 let mut z = FdMatrix::zeros(n, 2);
888
889 for i in 0..n {
890 let age = (i as f64) / (n as f64);
891 let group = if i % 2 == 0 { 1.0 } else { 0.0 };
892 z[(i, 0)] = age;
893 z[(i, 1)] = group;
894 for j in 0..m {
895 let mu = (2.0 * PI * t[j]).sin();
897 let beta1 = t[j]; let beta2 = (4.0 * PI * t[j]).cos(); y[(i, j)] = mu
900 + age * beta1
901 + group * beta2
902 + 0.05 * ((i * 13 + j * 7) % 100) as f64 / 100.0;
903 }
904 }
905 (y, z)
906 }
907
908 #[test]
911 fn test_fosr_basic() {
912 let (y, z) = generate_fosr_data(30, 50);
913 let result = fosr(&y, &z, 0.0);
914 assert!(result.is_ok());
915 let fit = result.unwrap();
916 assert_eq!(fit.intercept.len(), 50);
917 assert_eq!(fit.beta.shape(), (2, 50));
918 assert_eq!(fit.fitted.shape(), (30, 50));
919 assert_eq!(fit.residuals.shape(), (30, 50));
920 assert!(fit.r_squared >= 0.0);
921 }
922
923 #[test]
924 fn test_fosr_with_penalty() {
925 let (y, z) = generate_fosr_data(30, 50);
926 let fit0 = fosr(&y, &z, 0.0).unwrap();
927 let fit1 = fosr(&y, &z, 1.0).unwrap();
928 assert_eq!(fit0.beta.shape(), (2, 50));
930 assert_eq!(fit1.beta.shape(), (2, 50));
931 }
932
933 #[test]
934 fn test_fosr_auto_lambda() {
935 let (y, z) = generate_fosr_data(30, 50);
936 let fit = fosr(&y, &z, -1.0).unwrap();
937 assert!(fit.lambda >= 0.0);
938 }
939
940 #[test]
941 fn test_fosr_fitted_plus_residuals_equals_y() {
942 let (y, z) = generate_fosr_data(30, 50);
943 let fit = fosr(&y, &z, 0.0).unwrap();
944 for i in 0..30 {
945 for t in 0..50 {
946 let reconstructed = fit.fitted[(i, t)] + fit.residuals[(i, t)];
947 assert!(
948 (reconstructed - y[(i, t)]).abs() < 1e-10,
949 "ŷ + r should equal y at ({}, {})",
950 i,
951 t
952 );
953 }
954 }
955 }
956
957 #[test]
958 fn test_fosr_pointwise_r_squared_valid() {
959 let (y, z) = generate_fosr_data(30, 50);
960 let fit = fosr(&y, &z, 0.0).unwrap();
961 for &r2 in &fit.r_squared_t {
962 assert!(
963 (-0.01..=1.0 + 1e-10).contains(&r2),
964 "R²(t) out of range: {}",
965 r2
966 );
967 }
968 }
969
970 #[test]
971 fn test_fosr_se_positive() {
972 let (y, z) = generate_fosr_data(30, 50);
973 let fit = fosr(&y, &z, 0.0).unwrap();
974 for j in 0..2 {
975 for t in 0..50 {
976 assert!(
977 fit.beta_se[(j, t)] >= 0.0 && fit.beta_se[(j, t)].is_finite(),
978 "SE should be non-negative finite"
979 );
980 }
981 }
982 }
983
984 #[test]
985 fn test_fosr_invalid_input() {
986 let y = FdMatrix::zeros(2, 50);
987 let z = FdMatrix::zeros(2, 1);
988 assert!(fosr(&y, &z, 0.0).is_err());
989 }
990
991 #[test]
994 fn test_predict_fosr_on_training_data() {
995 let (y, z) = generate_fosr_data(30, 50);
996 let fit = fosr(&y, &z, 0.0).unwrap();
997 let preds = predict_fosr(&fit, &z);
998 assert_eq!(preds.shape(), (30, 50));
999 for i in 0..30 {
1000 for t in 0..50 {
1001 assert!(
1002 (preds[(i, t)] - fit.fitted[(i, t)]).abs() < 1e-8,
1003 "Prediction on training data should match fitted"
1004 );
1005 }
1006 }
1007 }
1008
1009 #[test]
1012 fn test_fanova_two_groups() {
1013 let n = 40;
1014 let m = 50;
1015 let t = uniform_grid(m);
1016
1017 let mut data = FdMatrix::zeros(n, m);
1018 let mut groups = vec![0usize; n];
1019 for i in 0..n {
1020 groups[i] = if i < n / 2 { 0 } else { 1 };
1021 for j in 0..m {
1022 let base = (2.0 * PI * t[j]).sin();
1023 let effect = if groups[i] == 1 { 0.5 * t[j] } else { 0.0 };
1024 data[(i, j)] = base + effect + 0.01 * (i as f64 * 0.1).sin();
1025 }
1026 }
1027
1028 let result = fanova(&data, &groups, 200);
1029 assert!(result.is_ok());
1030 let res = result.unwrap();
1031 assert_eq!(res.n_groups, 2);
1032 assert_eq!(res.group_means.shape(), (2, m));
1033 assert_eq!(res.f_statistic_t.len(), m);
1034 assert!(res.p_value >= 0.0 && res.p_value <= 1.0);
1035 assert!(
1037 res.p_value < 0.1,
1038 "Should detect group effect, got p={}",
1039 res.p_value
1040 );
1041 }
1042
1043 #[test]
1044 fn test_fanova_no_effect() {
1045 let n = 40;
1046 let m = 50;
1047 let t = uniform_grid(m);
1048
1049 let mut data = FdMatrix::zeros(n, m);
1050 let mut groups = vec![0usize; n];
1051 for i in 0..n {
1052 groups[i] = if i < n / 2 { 0 } else { 1 };
1053 for j in 0..m {
1054 data[(i, j)] =
1056 (2.0 * PI * t[j]).sin() + 0.1 * ((i * 7 + j * 3) % 100) as f64 / 100.0;
1057 }
1058 }
1059
1060 let result = fanova(&data, &groups, 200);
1061 assert!(result.is_ok());
1062 let res = result.unwrap();
1063 assert!(
1065 res.p_value > 0.05,
1066 "Should not detect effect, got p={}",
1067 res.p_value
1068 );
1069 }
1070
1071 #[test]
1072 fn test_fanova_three_groups() {
1073 let n = 30;
1074 let m = 50;
1075 let t = uniform_grid(m);
1076
1077 let mut data = FdMatrix::zeros(n, m);
1078 let mut groups = vec![0usize; n];
1079 for i in 0..n {
1080 groups[i] = i % 3;
1081 for j in 0..m {
1082 let effect = match groups[i] {
1083 0 => 0.0,
1084 1 => 0.5 * t[j],
1085 _ => -0.3 * (2.0 * PI * t[j]).cos(),
1086 };
1087 data[(i, j)] = (2.0 * PI * t[j]).sin() + effect + 0.01 * (i as f64 * 0.1).sin();
1088 }
1089 }
1090
1091 let result = fanova(&data, &groups, 200);
1092 assert!(result.is_ok());
1093 let res = result.unwrap();
1094 assert_eq!(res.n_groups, 3);
1095 }
1096
1097 #[test]
1098 fn test_fanova_invalid_input() {
1099 let data = FdMatrix::zeros(10, 50);
1100 let groups = vec![0; 10]; assert!(fanova(&data, &groups, 100).is_err());
1102
1103 let groups = vec![0; 5]; assert!(fanova(&data, &groups, 100).is_err());
1105 }
1106}