1use crate::error::FdarError;
14use crate::iter_maybe_parallel;
15use crate::linalg::{
16 cholesky_factor as linalg_cholesky_factor,
17 cholesky_forward_back as linalg_cholesky_forward_back,
18};
19use crate::matrix::FdMatrix;
20use crate::regression::fdata_to_pc_1d;
21#[cfg(feature = "parallel")]
22use rayon::iter::ParallelIterator;
23
24#[derive(Debug, Clone, PartialEq)]
26#[non_exhaustive]
27pub struct FmmResult {
28 pub mean_function: Vec<f64>,
30 pub beta_functions: FdMatrix,
32 pub random_effects: FdMatrix,
34 pub fitted: FdMatrix,
36 pub residuals: FdMatrix,
38 pub random_variance: Vec<f64>,
40 pub sigma2_eps: f64,
42 pub sigma2_u: Vec<f64>,
44 pub ncomp: usize,
46 pub n_subjects: usize,
48 pub eigenvalues: Vec<f64>,
50}
51
52#[derive(Debug, Clone, PartialEq)]
54#[non_exhaustive]
55pub struct FmmTestResult {
56 pub f_statistics: Vec<f64>,
58 pub p_values: Vec<f64>,
60}
61
62#[must_use = "expensive computation whose result should not be discarded"]
88pub fn fmm(
89 data: &FdMatrix,
90 subject_ids: &[usize],
91 covariates: Option<&FdMatrix>,
92 ncomp: usize,
93) -> Result<FmmResult, FdarError> {
94 let n_total = data.nrows();
95 let m = data.ncols();
96 if n_total == 0 || m == 0 {
97 return Err(FdarError::InvalidDimension {
98 parameter: "data",
99 expected: "non-empty matrix".to_string(),
100 actual: format!("{n_total} x {m}"),
101 });
102 }
103 if subject_ids.len() != n_total {
104 return Err(FdarError::InvalidDimension {
105 parameter: "subject_ids",
106 expected: format!("length {n_total}"),
107 actual: format!("length {}", subject_ids.len()),
108 });
109 }
110 if ncomp == 0 {
111 return Err(FdarError::InvalidParameter {
112 parameter: "ncomp",
113 message: "must be >= 1".to_string(),
114 });
115 }
116
117 let (subject_map, n_subjects) = build_subject_map(subject_ids);
119
120 let argvals: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1).max(1) as f64).collect();
122 let fpca = fdata_to_pc_1d(data, ncomp, &argvals)?;
123 let k = fpca.scores.ncols(); let p = covariates.map_or(0, super::matrix::FdMatrix::ncols);
127 let ComponentResults {
128 gamma,
129 u_hat,
130 sigma2_u,
131 sigma2_eps,
132 } = fit_all_components(
133 &fpca.scores,
134 &subject_map,
135 n_subjects,
136 covariates,
137 p,
138 k,
139 n_total,
140 m,
141 );
142
143 let beta_functions = recover_beta_functions(&gamma, &fpca.rotation, p, m, k);
145 let random_effects = recover_random_effects(&u_hat, &fpca.rotation, n_subjects, m, k);
146
147 let random_variance = compute_random_variance(&random_effects, n_subjects, m);
149
150 let (fitted, residuals) = compute_fitted_residuals(
152 data,
153 &fpca.mean,
154 &beta_functions,
155 &random_effects,
156 covariates,
157 &subject_map,
158 n_total,
159 m,
160 p,
161 );
162
163 let eigenvalues: Vec<f64> = fpca
164 .singular_values
165 .iter()
166 .map(|&sv| sv * sv / n_total as f64)
167 .collect();
168
169 Ok(FmmResult {
170 mean_function: fpca.mean,
171 beta_functions,
172 random_effects,
173 fitted,
174 residuals,
175 random_variance,
176 sigma2_eps,
177 sigma2_u,
178 ncomp: k,
179 n_subjects,
180 eigenvalues,
181 })
182}
183
184pub(crate) fn build_subject_map(subject_ids: &[usize]) -> (Vec<usize>, usize) {
186 let mut unique_ids: Vec<usize> = subject_ids.to_vec();
187 unique_ids.sort_unstable();
188 unique_ids.dedup();
189 let n_subjects = unique_ids.len();
190
191 let map: Vec<usize> = subject_ids
192 .iter()
193 .map(|id| unique_ids.iter().position(|u| u == id).unwrap_or(0))
194 .collect();
195
196 (map, n_subjects)
197}
198
199struct ComponentResults {
201 gamma: Vec<Vec<f64>>, u_hat: Vec<Vec<f64>>, sigma2_u: Vec<f64>, sigma2_eps: f64, }
206
207#[allow(clippy::too_many_arguments)]
212fn fit_all_components(
213 scores: &FdMatrix,
214 subject_map: &[usize],
215 n_subjects: usize,
216 covariates: Option<&FdMatrix>,
217 p: usize,
218 k: usize,
219 n_total: usize,
220 m: usize,
221) -> ComponentResults {
222 let h = if m > 1 { 1.0 / (m - 1) as f64 } else { 1.0 };
225 let score_scale = h.sqrt();
226
227 let per_comp: Vec<ScalarMixedResult> = iter_maybe_parallel!(0..k)
229 .map(|comp| {
230 let comp_scores: Vec<f64> = (0..n_total)
231 .map(|i| scores[(i, comp)] * score_scale)
232 .collect();
233 fit_scalar_mixed_model(&comp_scores, subject_map, n_subjects, covariates, p)
234 })
235 .collect();
236
237 let mut gamma = vec![vec![0.0; k]; p];
239 let mut u_hat = vec![vec![0.0; k]; n_subjects];
240 let mut sigma2_u = vec![0.0; k];
241 let mut sigma2_eps_total = 0.0;
242
243 for (comp, result) in per_comp.iter().enumerate() {
244 for j in 0..p {
245 gamma[j][comp] = result.gamma[j] / score_scale;
246 }
247 for s in 0..n_subjects {
248 u_hat[s][comp] = result.u_hat[s] / score_scale;
249 }
250 sigma2_u[comp] = result.sigma2_u;
251 sigma2_eps_total += result.sigma2_eps;
252 }
253 let sigma2_eps = sigma2_eps_total / k as f64;
254
255 ComponentResults {
256 gamma,
257 u_hat,
258 sigma2_u,
259 sigma2_eps,
260 }
261}
262
263pub(crate) struct ScalarMixedResult {
265 pub(crate) gamma: Vec<f64>, pub(crate) u_hat: Vec<f64>, pub(crate) sigma2_u: f64, pub(crate) sigma2_eps: f64, }
270
271pub(crate) struct SubjectStructure {
273 pub(crate) counts: Vec<usize>,
274 pub(crate) obs: Vec<Vec<usize>>,
275}
276
277impl SubjectStructure {
278 pub(crate) fn new(subject_map: &[usize], n_subjects: usize, n: usize) -> Self {
279 let mut counts = vec![0usize; n_subjects];
280 let mut obs: Vec<Vec<usize>> = vec![Vec::new(); n_subjects];
281 for i in 0..n {
282 let s = subject_map[i];
283 counts[s] += 1;
284 obs[s].push(i);
285 }
286 Self { counts, obs }
287 }
288}
289
290fn shrinkage_weights(ss: &SubjectStructure, sigma2_u: f64, sigma2_e: f64) -> Vec<f64> {
292 ss.counts
293 .iter()
294 .map(|&c| {
295 let ns = c as f64;
296 if ns < 1.0 {
297 0.0
298 } else {
299 sigma2_u / (sigma2_u + sigma2_e / ns)
300 }
301 })
302 .collect()
303}
304
305fn gls_update_gamma(
309 cov: &FdMatrix,
310 p: usize,
311 ss: &SubjectStructure,
312 weights: &[f64],
313 y: &[f64],
314 sigma2_e: f64,
315) -> Option<Vec<f64>> {
316 let n_subjects = ss.counts.len();
317 let mut xtvinvx = vec![0.0; p * p];
318 let mut xtvinvy = vec![0.0; p];
319 let inv_e = 1.0 / sigma2_e;
320
321 for s in 0..n_subjects {
322 let ns = ss.counts[s] as f64;
323 if ns < 1.0 {
324 continue;
325 }
326 let (x_sum, y_sum) = subject_sums(cov, y, &ss.obs[s], p);
327 accumulate_gls_terms(
328 cov,
329 y,
330 &ss.obs[s],
331 &x_sum,
332 y_sum,
333 weights[s],
334 ns,
335 inv_e,
336 p,
337 &mut xtvinvx,
338 &mut xtvinvy,
339 );
340 }
341
342 for j in 0..p {
343 xtvinvx[j * p + j] += 1e-10;
344 }
345 cholesky_solve(&xtvinvx, &xtvinvy, p)
346}
347
348fn subject_sums(cov: &FdMatrix, y: &[f64], obs: &[usize], p: usize) -> (Vec<f64>, f64) {
350 let mut x_sum = vec![0.0; p];
351 let mut y_sum = 0.0;
352 for &i in obs {
353 for r in 0..p {
354 x_sum[r] += cov[(i, r)];
355 }
356 y_sum += y[i];
357 }
358 (x_sum, y_sum)
359}
360
361fn accumulate_gls_terms(
363 cov: &FdMatrix,
364 y: &[f64],
365 obs: &[usize],
366 x_sum: &[f64],
367 y_sum: f64,
368 w_s: f64,
369 ns: f64,
370 inv_e: f64,
371 p: usize,
372 xtvinvx: &mut [f64],
373 xtvinvy: &mut [f64],
374) {
375 for &i in obs {
376 let vinv_y = inv_e * (y[i] - w_s * y_sum / ns);
377 for r in 0..p {
378 xtvinvy[r] += cov[(i, r)] * vinv_y;
379 for c in r..p {
380 let vinv_xc = inv_e * (cov[(i, c)] - w_s * x_sum[c] / ns);
381 let val = cov[(i, r)] * vinv_xc;
382 xtvinvx[r * p + c] += val;
383 if r != c {
384 xtvinvx[c * p + r] += val;
385 }
386 }
387 }
388 }
389}
390
391fn reml_variance_update(
396 residuals: &[f64],
397 ss: &SubjectStructure,
398 weights: &[f64],
399 sigma2_u: f64,
400 p: usize,
401) -> (f64, f64) {
402 let n_subjects = ss.counts.len();
403 let n: usize = ss.counts.iter().sum();
404 let mut sigma2_u_new = 0.0;
405 let mut sigma2_e_new = 0.0;
406
407 for s in 0..n_subjects {
408 let ns = ss.counts[s] as f64;
409 if ns < 1.0 {
410 continue;
411 }
412 let w_s = weights[s];
413 let mean_r_s: f64 = ss.obs[s].iter().map(|&i| residuals[i]).sum::<f64>() / ns;
414 let u_hat_s = w_s * mean_r_s;
415 let cond_var_s = sigma2_u * (1.0 - w_s);
416
417 sigma2_u_new += u_hat_s * u_hat_s + cond_var_s;
418 for &i in &ss.obs[s] {
419 sigma2_e_new += (residuals[i] - u_hat_s).powi(2);
420 }
421 sigma2_e_new += ns * cond_var_s;
422 }
423
424 let denom_e = (n.saturating_sub(p)).max(1) as f64;
426
427 (
428 (sigma2_u_new / n_subjects as f64).max(1e-15),
429 (sigma2_e_new / denom_e).max(1e-15),
430 )
431}
432
433pub(crate) fn fit_scalar_mixed_model(
439 y: &[f64],
440 subject_map: &[usize],
441 n_subjects: usize,
442 covariates: Option<&FdMatrix>,
443 p: usize,
444) -> ScalarMixedResult {
445 let n = y.len();
446 let ss = SubjectStructure::new(subject_map, n_subjects, n);
447
448 let gamma_init = estimate_fixed_effects(y, covariates, p, n);
450 let residuals_init = compute_ols_residuals(y, covariates, &gamma_init, p, n);
451 let (mut sigma2_u, mut sigma2_e) =
452 estimate_variance_components(&residuals_init, subject_map, n_subjects, n);
453
454 if sigma2_e < 1e-15 {
455 sigma2_e = 1e-6;
456 }
457 if sigma2_u < 1e-15 {
458 sigma2_u = sigma2_e * 0.1;
459 }
460
461 let mut gamma = gamma_init;
462
463 for _iter in 0..50 {
464 let sigma2_u_old = sigma2_u;
465 let sigma2_e_old = sigma2_e;
466
467 let weights = shrinkage_weights(&ss, sigma2_u, sigma2_e);
468
469 if let Some(cov) = covariates.filter(|_| p > 0) {
470 if let Some(g) = gls_update_gamma(cov, p, &ss, &weights, y, sigma2_e) {
471 gamma = g;
472 }
473 }
474
475 let r = compute_ols_residuals(y, covariates, &gamma, p, n);
476 (sigma2_u, sigma2_e) = reml_variance_update(&r, &ss, &weights, sigma2_u, p);
477
478 let delta = (sigma2_u - sigma2_u_old).abs() + (sigma2_e - sigma2_e_old).abs();
479 if delta < 1e-10 * (sigma2_u_old + sigma2_e_old) {
480 break;
481 }
482 }
483
484 let final_residuals = compute_ols_residuals(y, covariates, &gamma, p, n);
485 let u_hat = compute_blup(
486 &final_residuals,
487 subject_map,
488 n_subjects,
489 sigma2_u,
490 sigma2_e,
491 );
492
493 ScalarMixedResult {
494 gamma,
495 u_hat,
496 sigma2_u,
497 sigma2_eps: sigma2_e,
498 }
499}
500
501fn estimate_fixed_effects(
503 y: &[f64],
504 covariates: Option<&FdMatrix>,
505 p: usize,
506 n: usize,
507) -> Vec<f64> {
508 if p == 0 || covariates.is_none() {
509 return Vec::new();
510 }
511 let cov = covariates.expect("checked: covariates is Some");
512
513 let mut xtx = vec![0.0; p * p];
515 let mut xty = vec![0.0; p];
516 for i in 0..n {
517 for r in 0..p {
518 xty[r] += cov[(i, r)] * y[i];
519 for s in r..p {
520 let val = cov[(i, r)] * cov[(i, s)];
521 xtx[r * p + s] += val;
522 if r != s {
523 xtx[s * p + r] += val;
524 }
525 }
526 }
527 }
528 for j in 0..p {
530 xtx[j * p + j] += 1e-8;
531 }
532
533 cholesky_solve(&xtx, &xty, p).unwrap_or(vec![0.0; p])
534}
535
536fn cholesky_solve(a: &[f64], b: &[f64], p: usize) -> Option<Vec<f64>> {
539 let l = linalg_cholesky_factor(a, p).ok()?;
540 Some(linalg_cholesky_forward_back(&l, b, p))
541}
542
543fn compute_ols_residuals(
545 y: &[f64],
546 covariates: Option<&FdMatrix>,
547 gamma: &[f64],
548 p: usize,
549 n: usize,
550) -> Vec<f64> {
551 let mut residuals = y.to_vec();
552 if p > 0 {
553 if let Some(cov) = covariates {
554 for i in 0..n {
555 for j in 0..p {
556 residuals[i] -= cov[(i, j)] * gamma[j];
557 }
558 }
559 }
560 }
561 residuals
562}
563
564fn estimate_variance_components(
568 residuals: &[f64],
569 subject_map: &[usize],
570 n_subjects: usize,
571 n: usize,
572) -> (f64, f64) {
573 let mut subject_sums = vec![0.0; n_subjects];
575 let mut subject_counts = vec![0usize; n_subjects];
576 for i in 0..n {
577 let s = subject_map[i];
578 subject_sums[s] += residuals[i];
579 subject_counts[s] += 1;
580 }
581 let subject_means: Vec<f64> = subject_sums
582 .iter()
583 .zip(&subject_counts)
584 .map(|(&s, &c)| if c > 0 { s / c as f64 } else { 0.0 })
585 .collect();
586
587 let mut ss_within = 0.0;
589 for i in 0..n {
590 let s = subject_map[i];
591 ss_within += (residuals[i] - subject_means[s]).powi(2);
592 }
593 let df_within = n.saturating_sub(n_subjects);
594
595 let grand_mean = residuals.iter().sum::<f64>() / n as f64;
597 let mut ss_between = 0.0;
598 for s in 0..n_subjects {
599 ss_between += subject_counts[s] as f64 * (subject_means[s] - grand_mean).powi(2);
600 }
601
602 let sigma2_eps = if df_within > 0 {
603 ss_within / df_within as f64
604 } else {
605 1e-6
606 };
607
608 let n_bar = n as f64 / n_subjects.max(1) as f64;
610 let df_between = n_subjects.saturating_sub(1).max(1);
611 let ms_between = ss_between / df_between as f64;
612 let sigma2_u = ((ms_between - sigma2_eps) / n_bar).max(0.0);
613
614 (sigma2_u, sigma2_eps)
615}
616
617fn compute_blup(
621 residuals: &[f64],
622 subject_map: &[usize],
623 n_subjects: usize,
624 sigma2_u: f64,
625 sigma2_eps: f64,
626) -> Vec<f64> {
627 let mut subject_sums = vec![0.0; n_subjects];
628 let mut subject_counts = vec![0usize; n_subjects];
629 for (i, &r) in residuals.iter().enumerate() {
630 let s = subject_map[i];
631 subject_sums[s] += r;
632 subject_counts[s] += 1;
633 }
634
635 (0..n_subjects)
636 .map(|s| {
637 let ni = subject_counts[s] as f64;
638 if ni < 1.0 {
639 return 0.0;
640 }
641 let mean_r = subject_sums[s] / ni;
642 let shrinkage = sigma2_u / (sigma2_u + sigma2_eps / ni).max(1e-15);
643 shrinkage * mean_r
644 })
645 .collect()
646}
647
648fn recover_beta_functions(
654 gamma: &[Vec<f64>],
655 rotation: &FdMatrix,
656 p: usize,
657 m: usize,
658 k: usize,
659) -> FdMatrix {
660 let mut beta = FdMatrix::zeros(p, m);
661 for j in 0..p {
662 for t in 0..m {
663 let mut val = 0.0;
664 for comp in 0..k {
665 val += gamma[j][comp] * rotation[(t, comp)];
666 }
667 beta[(j, t)] = val;
668 }
669 }
670 beta
671}
672
673pub(crate) fn recover_random_effects(
675 u_hat: &[Vec<f64>],
676 rotation: &FdMatrix,
677 n_subjects: usize,
678 m: usize,
679 k: usize,
680) -> FdMatrix {
681 let mut re = FdMatrix::zeros(n_subjects, m);
682 for s in 0..n_subjects {
683 for t in 0..m {
684 let mut val = 0.0;
685 for comp in 0..k {
686 val += u_hat[s][comp] * rotation[(t, comp)];
687 }
688 re[(s, t)] = val;
689 }
690 }
691 re
692}
693
694fn compute_random_variance(random_effects: &FdMatrix, n_subjects: usize, m: usize) -> Vec<f64> {
696 (0..m)
697 .map(|t| {
698 let mean: f64 =
699 (0..n_subjects).map(|s| random_effects[(s, t)]).sum::<f64>() / n_subjects as f64;
700 let var: f64 = (0..n_subjects)
701 .map(|s| (random_effects[(s, t)] - mean).powi(2))
702 .sum::<f64>()
703 / n_subjects.max(1) as f64;
704 var
705 })
706 .collect()
707}
708
709fn compute_fitted_residuals(
711 data: &FdMatrix,
712 mean_function: &[f64],
713 beta_functions: &FdMatrix,
714 random_effects: &FdMatrix,
715 covariates: Option<&FdMatrix>,
716 subject_map: &[usize],
717 n_total: usize,
718 m: usize,
719 p: usize,
720) -> (FdMatrix, FdMatrix) {
721 let mut fitted = FdMatrix::zeros(n_total, m);
722 let mut residuals = FdMatrix::zeros(n_total, m);
723
724 for i in 0..n_total {
725 let s = subject_map[i];
726 for t in 0..m {
727 let mut val = mean_function[t] + random_effects[(s, t)];
728 if p > 0 {
729 if let Some(cov) = covariates {
730 for j in 0..p {
731 val += cov[(i, j)] * beta_functions[(j, t)];
732 }
733 }
734 }
735 fitted[(i, t)] = val;
736 residuals[(i, t)] = data[(i, t)] - val;
737 }
738 }
739
740 (fitted, residuals)
741}
742
743#[must_use = "prediction result should not be discarded"]
755pub fn fmm_predict(result: &FmmResult, new_covariates: Option<&FdMatrix>) -> FdMatrix {
756 let m = result.mean_function.len();
757 let n_new = new_covariates.map_or(1, super::matrix::FdMatrix::nrows);
758 let p = result.beta_functions.nrows();
759
760 let mut predicted = FdMatrix::zeros(n_new, m);
761 for i in 0..n_new {
762 for t in 0..m {
763 let mut val = result.mean_function[t];
764 if let Some(cov) = new_covariates {
765 for j in 0..p {
766 val += cov[(i, j)] * result.beta_functions[(j, t)];
767 }
768 }
769 predicted[(i, t)] = val;
770 }
771 }
772 predicted
773}
774
775#[must_use = "expensive computation whose result should not be discarded"]
798pub fn fmm_test_fixed(
799 data: &FdMatrix,
800 subject_ids: &[usize],
801 covariates: &FdMatrix,
802 ncomp: usize,
803 n_perm: usize,
804 seed: u64,
805) -> Result<FmmTestResult, FdarError> {
806 let n_total = data.nrows();
807 let m = data.ncols();
808 let p = covariates.ncols();
809 if n_total == 0 {
810 return Err(FdarError::InvalidDimension {
811 parameter: "data",
812 expected: "non-empty matrix".to_string(),
813 actual: format!("{n_total} rows"),
814 });
815 }
816 if p == 0 {
817 return Err(FdarError::InvalidDimension {
818 parameter: "covariates",
819 expected: "at least 1 column".to_string(),
820 actual: "0 columns".to_string(),
821 });
822 }
823
824 let result = fmm(data, subject_ids, Some(covariates), ncomp)?;
826
827 let observed_stats = compute_integrated_beta_sq(&result.beta_functions, p, m);
829
830 let (f_statistics, p_values) = permutation_test(
832 data,
833 subject_ids,
834 covariates,
835 ncomp,
836 n_perm,
837 seed,
838 &observed_stats,
839 p,
840 m,
841 );
842
843 Ok(FmmTestResult {
844 f_statistics,
845 p_values,
846 })
847}
848
849fn compute_integrated_beta_sq(beta: &FdMatrix, p: usize, m: usize) -> Vec<f64> {
851 let h = if m > 1 { 1.0 / (m - 1) as f64 } else { 1.0 };
852 (0..p)
853 .map(|j| {
854 let ss: f64 = (0..m).map(|t| beta[(j, t)].powi(2)).sum();
855 ss * h
856 })
857 .collect()
858}
859
860fn permutation_test(
862 data: &FdMatrix,
863 subject_ids: &[usize],
864 covariates: &FdMatrix,
865 ncomp: usize,
866 n_perm: usize,
867 seed: u64,
868 observed_stats: &[f64],
869 p: usize,
870 m: usize,
871) -> (Vec<f64>, Vec<f64>) {
872 use rand::prelude::*;
873 let n_total = data.nrows();
874 let mut rng = StdRng::seed_from_u64(seed);
878 let mut n_ge = vec![0usize; p];
879
880 for _ in 0..n_perm {
881 let mut perm_indices: Vec<usize> = (0..n_total).collect();
883 perm_indices.shuffle(&mut rng);
884 let perm_cov = permute_rows(covariates, &perm_indices);
885
886 if let Ok(perm_result) = fmm(data, subject_ids, Some(&perm_cov), ncomp) {
887 let perm_stats = compute_integrated_beta_sq(&perm_result.beta_functions, p, m);
888 for j in 0..p {
889 if perm_stats[j] >= observed_stats[j] {
890 n_ge[j] += 1;
891 }
892 }
893 }
894 }
895
896 let p_values: Vec<f64> = n_ge
897 .iter()
898 .map(|&count| (count + 1) as f64 / (n_perm + 1) as f64)
899 .collect();
900 let f_statistics = observed_stats.to_vec();
901
902 (f_statistics, p_values)
903}
904
905fn permute_rows(mat: &FdMatrix, indices: &[usize]) -> FdMatrix {
907 let n = indices.len();
908 let m = mat.ncols();
909 let mut result = FdMatrix::zeros(n, m);
910 for (new_i, &old_i) in indices.iter().enumerate() {
911 for j in 0..m {
912 result[(new_i, j)] = mat[(old_i, j)];
913 }
914 }
915 result
916}
917
918#[derive(Debug, Clone, PartialEq)]
926#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
927pub struct DenseFlmmConfig {
928 pub ncomp: usize,
930 pub max_iter: usize,
932 pub tol: f64,
934 pub random_slopes: bool,
941}
942
943impl Default for DenseFlmmConfig {
944 fn default() -> Self {
945 Self {
946 ncomp: 3,
947 max_iter: 50,
948 tol: 1e-10,
949 random_slopes: false,
950 }
951 }
952}
953
954#[derive(Debug, Clone, PartialEq)]
966#[non_exhaustive]
967#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
968pub struct DenseFlmmResult {
969 pub mean_function: Vec<f64>,
971 pub beta_functions: FdMatrix,
973 pub random_effects: FdMatrix,
975 pub fitted: FdMatrix,
977 pub residuals: FdMatrix,
979 pub random_variance: Vec<f64>,
981 pub sigma2_eps: f64,
988 pub sigma2_u: Vec<f64>,
990 pub sigma2_slope: Vec<f64>,
992 pub ncomp: usize,
994 pub n_subjects: usize,
996 pub eigenvalues: Vec<f64>,
998 pub n_iter: usize,
1000 pub converged: bool,
1002}
1003
1004#[must_use = "expensive computation whose result should not be discarded"]
1039pub fn dense_flmm(
1040 data: &FdMatrix,
1041 subject_ids: &[usize],
1042 covariates: Option<&FdMatrix>,
1043 config: &DenseFlmmConfig,
1044) -> Result<DenseFlmmResult, FdarError> {
1045 let n_total = data.nrows();
1046 let m = data.ncols();
1047 if n_total == 0 || m == 0 {
1048 return Err(FdarError::InvalidDimension {
1049 parameter: "data",
1050 expected: "non-empty matrix".to_string(),
1051 actual: format!("{n_total} x {m}"),
1052 });
1053 }
1054 if subject_ids.len() != n_total {
1055 return Err(FdarError::InvalidDimension {
1056 parameter: "subject_ids",
1057 expected: format!("length {n_total}"),
1058 actual: format!("length {}", subject_ids.len()),
1059 });
1060 }
1061 if config.ncomp == 0 {
1062 return Err(FdarError::InvalidParameter {
1063 parameter: "ncomp",
1064 message: "must be >= 1".to_string(),
1065 });
1066 }
1067 if config.max_iter == 0 {
1068 return Err(FdarError::InvalidParameter {
1069 parameter: "max_iter",
1070 message: "must be >= 1".to_string(),
1071 });
1072 }
1073 if config.random_slopes {
1074 return Err(FdarError::InvalidParameter {
1075 parameter: "random_slopes",
1076 message: "random slope estimation is not yet implemented; \
1077 use random_slopes: false"
1078 .to_string(),
1079 });
1080 }
1081
1082 let (subject_map, n_subjects) = build_subject_map(subject_ids);
1083
1084 let argvals: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1).max(1) as f64).collect();
1085 let fpca = fdata_to_pc_1d(data, config.ncomp, &argvals)?;
1086 let k = fpca.scores.ncols();
1087
1088 let p = covariates.map_or(0, super::matrix::FdMatrix::ncols);
1089
1090 let h = if m > 1 { 1.0 / (m - 1) as f64 } else { 1.0 };
1092 let score_scale = h.sqrt();
1093
1094 let per_comp: Vec<ScalarMixedResultWithMeta> = iter_maybe_parallel!(0..k)
1096 .map(|comp| {
1097 let comp_scores: Vec<f64> = (0..n_total)
1098 .map(|i| fpca.scores[(i, comp)] * score_scale)
1099 .collect();
1100 fit_scalar_mixed_model_tracked(
1101 &comp_scores,
1102 &subject_map,
1103 n_subjects,
1104 covariates,
1105 p,
1106 config.max_iter,
1107 config.tol,
1108 )
1109 })
1110 .collect();
1111
1112 let mut gamma = vec![vec![0.0; k]; p];
1114 let mut u_hat = vec![vec![0.0; k]; n_subjects];
1115 let mut sigma2_u = vec![0.0; k];
1116 let mut sigma2_eps_total = 0.0;
1117 let mut all_converged = true;
1118 let mut max_n_iter = 0usize;
1119
1120 for (comp, r) in per_comp.iter().enumerate() {
1121 for j in 0..p {
1122 gamma[j][comp] = r.result.gamma[j] / score_scale;
1123 }
1124 for s in 0..n_subjects {
1125 u_hat[s][comp] = r.result.u_hat[s] / score_scale;
1126 }
1127 sigma2_u[comp] = r.result.sigma2_u;
1128 sigma2_eps_total += r.result.sigma2_eps;
1129 if !r.converged {
1130 all_converged = false;
1131 }
1132 if r.n_iter > max_n_iter {
1133 max_n_iter = r.n_iter;
1134 }
1135 }
1136 let sigma2_eps = if k > 0 {
1137 sigma2_eps_total / k as f64
1138 } else {
1139 0.0
1140 };
1141
1142 let beta_functions = recover_beta_functions(&gamma, &fpca.rotation, p, m, k);
1143 let random_effects = recover_random_effects(&u_hat, &fpca.rotation, n_subjects, m, k);
1144 let random_variance = compute_random_variance(&random_effects, n_subjects, m);
1145
1146 let (fitted, residuals) = compute_fitted_residuals(
1147 data,
1148 &fpca.mean,
1149 &beta_functions,
1150 &random_effects,
1151 covariates,
1152 &subject_map,
1153 n_total,
1154 m,
1155 p,
1156 );
1157
1158 let eigenvalues: Vec<f64> = fpca
1159 .singular_values
1160 .iter()
1161 .map(|&sv| sv * sv / n_total as f64)
1162 .collect();
1163
1164 let sigma2_slope = vec![0.0; k];
1166
1167 Ok(DenseFlmmResult {
1168 mean_function: fpca.mean,
1169 beta_functions,
1170 random_effects,
1171 fitted,
1172 residuals,
1173 random_variance,
1174 sigma2_eps,
1175 sigma2_u,
1176 sigma2_slope,
1177 ncomp: k,
1178 n_subjects,
1179 eigenvalues,
1180 n_iter: max_n_iter,
1181 converged: all_converged,
1182 })
1183}
1184
1185struct ScalarMixedResultWithMeta {
1187 result: ScalarMixedResult,
1188 n_iter: usize,
1189 converged: bool,
1190}
1191
1192fn fit_scalar_mixed_model_tracked(
1194 y: &[f64],
1195 subject_map: &[usize],
1196 n_subjects: usize,
1197 covariates: Option<&FdMatrix>,
1198 p: usize,
1199 max_iter: usize,
1200 tol: f64,
1201) -> ScalarMixedResultWithMeta {
1202 let n = y.len();
1203 let ss = SubjectStructure::new(subject_map, n_subjects, n);
1204
1205 let gamma_init = estimate_fixed_effects(y, covariates, p, n);
1206 let residuals_init = compute_ols_residuals(y, covariates, &gamma_init, p, n);
1207 let (mut sigma2_u, mut sigma2_e) =
1208 estimate_variance_components(&residuals_init, subject_map, n_subjects, n);
1209
1210 if sigma2_e < 1e-15 {
1211 sigma2_e = 1e-6;
1212 }
1213 if sigma2_u < 1e-15 {
1214 sigma2_u = sigma2_e * 0.1;
1215 }
1216
1217 let mut gamma = gamma_init;
1218 let mut converged = false;
1219 let mut n_iter = 0usize;
1220
1221 for _iter in 0..max_iter {
1222 n_iter += 1;
1223 let sigma2_u_old = sigma2_u;
1224 let sigma2_e_old = sigma2_e;
1225
1226 let weights = shrinkage_weights(&ss, sigma2_u, sigma2_e);
1227
1228 if let Some(cov) = covariates.filter(|_| p > 0) {
1229 if let Some(g) = gls_update_gamma(cov, p, &ss, &weights, y, sigma2_e) {
1230 gamma = g;
1231 }
1232 }
1233
1234 let r = compute_ols_residuals(y, covariates, &gamma, p, n);
1235 (sigma2_u, sigma2_e) = reml_variance_update(&r, &ss, &weights, sigma2_u, p);
1236
1237 let delta = (sigma2_u - sigma2_u_old).abs() + (sigma2_e - sigma2_e_old).abs();
1238 if delta < tol * (sigma2_u_old + sigma2_e_old) {
1239 converged = true;
1240 break;
1241 }
1242 }
1243
1244 let final_residuals = compute_ols_residuals(y, covariates, &gamma, p, n);
1245 let u_hat = compute_blup(
1246 &final_residuals,
1247 subject_map,
1248 n_subjects,
1249 sigma2_u,
1250 sigma2_e,
1251 );
1252
1253 ScalarMixedResultWithMeta {
1254 result: ScalarMixedResult {
1255 gamma,
1256 u_hat,
1257 sigma2_u,
1258 sigma2_eps: sigma2_e,
1259 },
1260 n_iter,
1261 converged,
1262 }
1263}
1264
1265#[derive(Debug, Clone, PartialEq)]
1273#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1274pub struct MultiFammConfig {
1275 pub ncomp: usize,
1277 pub max_iter: usize,
1279 pub tol: f64,
1281}
1282
1283impl Default for MultiFammConfig {
1284 fn default() -> Self {
1285 Self {
1286 ncomp: 3,
1287 max_iter: 50,
1288 tol: 1e-10,
1289 }
1290 }
1291}
1292
1293#[derive(Debug, Clone, PartialEq)]
1304#[non_exhaustive]
1305#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1306pub struct MultiFammResult {
1307 pub components: Vec<DenseFlmmResult>,
1309 pub stacked_fitted: FdMatrix,
1311 pub stacked_residuals: FdMatrix,
1313 pub n_dims: usize,
1315}
1316
1317#[must_use = "expensive computation whose result should not be discarded"]
1340pub fn multi_famm(
1341 data: &[FdMatrix],
1342 subject_ids: &[usize],
1343 covariates: Option<&FdMatrix>,
1344 config: &MultiFammConfig,
1345) -> Result<MultiFammResult, FdarError> {
1346 let n_dims = data.len();
1347 if n_dims == 0 {
1348 return Err(FdarError::InvalidDimension {
1349 parameter: "data",
1350 expected: "at least one response dimension".to_string(),
1351 actual: "0 dimensions".to_string(),
1352 });
1353 }
1354
1355 let n_total = data[0].nrows();
1356 let m = data[0].ncols();
1357
1358 if n_total == 0 || m == 0 {
1359 return Err(FdarError::InvalidDimension {
1360 parameter: "data",
1361 expected: "non-empty matrix".to_string(),
1362 actual: format!("{n_total} x {m}"),
1363 });
1364 }
1365
1366 for (d, dim) in data.iter().enumerate().skip(1) {
1368 if dim.ncols() != m {
1369 return Err(FdarError::InvalidDimension {
1370 parameter: "data",
1371 expected: format!("all dimensions share ncols = {m}"),
1372 actual: format!("dimension {d} has ncols = {}", dim.ncols()),
1373 });
1374 }
1375 if dim.nrows() != n_total {
1376 return Err(FdarError::InvalidDimension {
1377 parameter: "data",
1378 expected: format!("all dimensions share nrows = {n_total}"),
1379 actual: format!("dimension {d} has nrows = {}", dim.nrows()),
1380 });
1381 }
1382 }
1383
1384 let dense_cfg = DenseFlmmConfig {
1386 ncomp: config.ncomp,
1387 max_iter: config.max_iter,
1388 tol: config.tol,
1389 random_slopes: false,
1390 };
1391
1392 let mut components: Vec<DenseFlmmResult> = Vec::with_capacity(n_dims);
1394 for dim_data in data.iter() {
1395 let result = dense_flmm(dim_data, subject_ids, covariates, &dense_cfg)?;
1396 components.push(result);
1397 }
1398
1399 let stacked_rows = n_total * n_dims;
1401 let mut stacked_fitted_data = vec![0.0; stacked_rows * m];
1402 let mut stacked_residuals_data = vec![0.0; stacked_rows * m];
1403
1404 for (d, comp) in components.iter().enumerate() {
1405 for i in 0..n_total {
1406 let row = d * n_total + i;
1407 for t in 0..m {
1408 stacked_fitted_data[row + t * stacked_rows] = comp.fitted[(i, t)];
1410 stacked_residuals_data[row + t * stacked_rows] = comp.residuals[(i, t)];
1411 }
1412 }
1413 }
1414
1415 let stacked_fitted = FdMatrix::from_column_major(stacked_fitted_data, stacked_rows, m)
1416 .map_err(|_| FdarError::ComputationFailed {
1417 operation: "multi_famm stacking",
1418 detail: "failed to build stacked_fitted matrix".to_string(),
1419 })?;
1420 let stacked_residuals = FdMatrix::from_column_major(stacked_residuals_data, stacked_rows, m)
1421 .map_err(|_| FdarError::ComputationFailed {
1422 operation: "multi_famm stacking",
1423 detail: "failed to build stacked_residuals matrix".to_string(),
1424 })?;
1425
1426 Ok(MultiFammResult {
1427 components,
1428 stacked_fitted,
1429 stacked_residuals,
1430 n_dims,
1431 })
1432}
1433
1434#[derive(Debug, Clone, PartialEq)]
1442#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1443pub struct FastFmmConfig {
1444 pub smooth_window: usize,
1452 pub max_iter: usize,
1454 pub tol: f64,
1456 pub compute_inference: bool,
1465}
1466
1467impl Default for FastFmmConfig {
1468 fn default() -> Self {
1469 Self {
1470 smooth_window: 3,
1471 max_iter: 30,
1472 tol: 1e-8,
1473 compute_inference: true,
1474 }
1475 }
1476}
1477
1478#[derive(Debug, Clone, PartialEq)]
1487#[non_exhaustive]
1488#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1489pub struct FastFmmResult {
1490 pub beta_matrix: FdMatrix,
1492 pub t_stats: FdMatrix,
1494 pub p_values: FdMatrix,
1496 pub sigma2_eps: Vec<f64>,
1498 pub sigma2_u: Vec<f64>,
1500 pub n_grid: usize,
1502}
1503
1504#[must_use = "expensive computation whose result should not be discarded"]
1524pub fn fast_fmm(
1525 data: &FdMatrix,
1526 subject_ids: &[usize],
1527 covariates: Option<&FdMatrix>,
1528 config: &FastFmmConfig,
1529) -> Result<FastFmmResult, FdarError> {
1530 let n_total = data.nrows();
1531 let m = data.ncols();
1532 if n_total == 0 || m == 0 {
1533 return Err(FdarError::InvalidDimension {
1534 parameter: "data",
1535 expected: "non-empty matrix".to_string(),
1536 actual: format!("{n_total} x {m}"),
1537 });
1538 }
1539 if subject_ids.len() != n_total {
1540 return Err(FdarError::InvalidDimension {
1541 parameter: "subject_ids",
1542 expected: format!("length {n_total}"),
1543 actual: format!("length {}", subject_ids.len()),
1544 });
1545 }
1546 if config.smooth_window == 0 {
1547 return Err(FdarError::InvalidParameter {
1548 parameter: "smooth_window",
1549 message: "must be >= 1 (use 1 for no smoothing)".to_string(),
1550 });
1551 }
1552 if config.max_iter == 0 {
1553 return Err(FdarError::InvalidParameter {
1554 parameter: "max_iter",
1555 message: "must be >= 1".to_string(),
1556 });
1557 }
1558
1559 let (subject_map, n_subjects) = build_subject_map(subject_ids);
1560 let p = covariates.map_or(0, super::matrix::FdMatrix::ncols);
1561
1562 struct PointwiseResult {
1564 gamma: Vec<f64>, sigma2_u: f64, sigma2_eps: f64, }
1568
1569 let per_point: Vec<PointwiseResult> = iter_maybe_parallel!(0..m)
1572 .map(|t| {
1573 let y_t: Vec<f64> = data.column(t).to_vec();
1574 let r = fit_scalar_mixed_model_tracked(
1575 &y_t,
1576 &subject_map,
1577 n_subjects,
1578 covariates,
1579 p,
1580 config.max_iter,
1581 config.tol,
1582 );
1583 PointwiseResult {
1584 gamma: r.result.gamma,
1585 sigma2_u: r.result.sigma2_u,
1586 sigma2_eps: r.result.sigma2_eps,
1587 }
1588 })
1589 .collect();
1590
1591 let mut raw_beta_data = vec![0.0; p * m]; let mut sigma2_eps_vec = vec![0.0; m];
1594 let mut sigma2_u_vec = vec![0.0; m];
1595
1596 for (t, pt) in per_point.iter().enumerate() {
1597 for j in 0..p {
1599 raw_beta_data[j + t * p] = pt.gamma.get(j).copied().unwrap_or(0.0);
1600 }
1601 sigma2_eps_vec[t] = pt.sigma2_eps;
1602 sigma2_u_vec[t] = pt.sigma2_u;
1603 }
1604
1605 let w = if config.smooth_window % 2 == 0 {
1610 config.smooth_window + 1
1611 } else {
1612 config.smooth_window
1613 };
1614 let mut smoothed_beta_data = raw_beta_data.clone();
1615 if w > 1 && m > 1 {
1616 let half = w / 2;
1617 for j in 0..p {
1618 for t in 0..m {
1619 let lo = t.saturating_sub(half);
1620 let hi = (t + half + 1).min(m);
1621 let count = (hi - lo) as f64;
1622 let sum: f64 = (lo..hi).map(|tt| raw_beta_data[j + tt * p]).sum();
1623 smoothed_beta_data[j + t * p] = sum / count;
1624 }
1625 }
1626 }
1627
1628 let beta_matrix = if p > 0 {
1630 FdMatrix::from_column_major(smoothed_beta_data, p, m).map_err(|_| {
1631 FdarError::ComputationFailed {
1632 operation: "fast_fmm",
1633 detail: "failed to build beta_matrix".to_string(),
1634 }
1635 })?
1636 } else {
1637 FdMatrix::zeros(0, m)
1638 };
1639
1640 let (t_stats, p_values) = if config.compute_inference && p > 0 {
1642 let xtx_inv_diag = compute_xtx_inv_diag(covariates, p, n_total);
1646
1647 let mut t_data = vec![0.0f64; p * m];
1648 let mut pv_data = vec![1.0f64; p * m];
1649
1650 for j in 0..p {
1651 for t in 0..m {
1652 let beta_jt = beta_matrix[(j, t)];
1653 let se_sq = sigma2_eps_vec[t] * xtx_inv_diag.get(j).copied().unwrap_or(1.0);
1654 let se = se_sq.sqrt().max(1e-15);
1655 let t_stat = beta_jt / se;
1656 let pval = 2.0 * normal_sf(t_stat.abs());
1657 t_data[j + t * p] = t_stat;
1658 pv_data[j + t * p] = pval.clamp(0.0, 1.0);
1659 }
1660 }
1661
1662 let ts = FdMatrix::from_column_major(t_data, p, m).map_err(|_| {
1663 FdarError::ComputationFailed {
1664 operation: "fast_fmm",
1665 detail: "failed to build t_stats".to_string(),
1666 }
1667 })?;
1668 let pv = FdMatrix::from_column_major(pv_data, p, m).map_err(|_| {
1669 FdarError::ComputationFailed {
1670 operation: "fast_fmm",
1671 detail: "failed to build p_values".to_string(),
1672 }
1673 })?;
1674 (ts, pv)
1675 } else {
1676 (FdMatrix::zeros(p, m), ones_fdmatrix(p, m))
1678 };
1679
1680 Ok(FastFmmResult {
1681 beta_matrix,
1682 t_stats,
1683 p_values,
1684 sigma2_eps: sigma2_eps_vec,
1685 sigma2_u: sigma2_u_vec,
1686 n_grid: m,
1687 })
1688}
1689
1690fn compute_xtx_inv_diag(covariates: Option<&FdMatrix>, p: usize, n: usize) -> Vec<f64> {
1692 let Some(cov) = covariates else {
1693 return vec![1.0; p];
1694 };
1695 let mut xtx = vec![0.0; p * p];
1696 for i in 0..n {
1697 for r in 0..p {
1698 for s in r..p {
1699 let val = cov[(i, r)] * cov[(i, s)];
1700 xtx[r * p + s] += val;
1701 if r != s {
1702 xtx[s * p + r] += val;
1703 }
1704 }
1705 }
1706 }
1707 for j in 0..p {
1708 xtx[j * p + j] += 1e-8;
1709 }
1710 if let Some(inv) = cholesky_invert(&xtx, p) {
1712 (0..p).map(|j| inv[j * p + j].max(1e-15)).collect()
1713 } else {
1714 (0..p)
1716 .map(|j| {
1717 let d = xtx[j * p + j];
1718 if d > 1e-15 {
1719 1.0 / d
1720 } else {
1721 1.0
1722 }
1723 })
1724 .collect()
1725 }
1726}
1727
1728fn cholesky_invert(a: &[f64], p: usize) -> Option<Vec<f64>> {
1730 let l = linalg_cholesky_factor(a, p).ok()?;
1731 let mut inv = vec![0.0; p * p];
1733 let mut e = vec![0.0; p];
1734 for j in 0..p {
1735 e.fill(0.0);
1736 e[j] = 1.0;
1737 let col = linalg_cholesky_forward_back(&l, &e, p);
1738 for i in 0..p {
1739 inv[i * p + j] = col[i];
1740 }
1741 }
1742 Some(inv)
1743}
1744
1745fn normal_sf(x: f64) -> f64 {
1747 0.5 * erfc(x / core::f64::consts::SQRT_2)
1749}
1750
1751fn erfc(x: f64) -> f64 {
1753 if x < 0.0 {
1755 return 2.0 - erfc(-x);
1756 }
1757 let t = 1.0 / (1.0 + 0.3275911 * x);
1759 let poly = t
1760 * (0.254_829_592
1761 + t * (-0.284_496_736
1762 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429))));
1763 poly * (-x * x).exp()
1764}
1765
1766fn ones_fdmatrix(p: usize, m: usize) -> FdMatrix {
1768 if p == 0 || m == 0 {
1769 return FdMatrix::zeros(p, m);
1770 }
1771 let data = vec![1.0f64; p * m];
1772 FdMatrix::from_column_major(data, p, m).unwrap_or_else(|_| FdMatrix::zeros(p, m))
1773}
1774
1775#[cfg(test)]
1780mod tests {
1781 use super::*;
1782 use crate::test_helpers::uniform_grid;
1783 use std::f64::consts::PI;
1784
1785 fn generate_fmm_data(
1788 n_subjects: usize,
1789 n_visits: usize,
1790 m: usize,
1791 ) -> (FdMatrix, Vec<usize>, FdMatrix, Vec<f64>) {
1792 let t = uniform_grid(m);
1793 let n_total = n_subjects * n_visits;
1794 let mut col_major = vec![0.0; n_total * m];
1795 let mut subject_ids = vec![0usize; n_total];
1796 let mut cov_data = vec![0.0; n_total];
1797
1798 for s in 0..n_subjects {
1799 let z = s as f64 / n_subjects as f64; let subject_effect = 0.5 * (s as f64 - n_subjects as f64 / 2.0); for v in 0..n_visits {
1803 let obs = s * n_visits + v;
1804 subject_ids[obs] = s;
1805 cov_data[obs] = z;
1806 let noise_scale = 0.05;
1807
1808 for (j, &tj) in t.iter().enumerate() {
1809 let mu = (2.0 * PI * tj).sin();
1811 let fixed = z * tj * 3.0;
1812 let random = subject_effect * (2.0 * PI * tj).cos() * 0.3;
1813 let noise = noise_scale * ((obs * 7 + j * 3) % 100) as f64 / 100.0;
1814 col_major[obs + j * n_total] = mu + fixed + random + noise;
1815 }
1816 }
1817 }
1818
1819 let data = FdMatrix::from_column_major(col_major, n_total, m).unwrap();
1820 let covariates = FdMatrix::from_column_major(cov_data, n_total, 1).unwrap();
1821 (data, subject_ids, covariates, t)
1822 }
1823
1824 #[test]
1825 fn test_fmm_basic() {
1826 let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 50);
1827 let result = fmm(&data, &subject_ids, Some(&covariates), 3).unwrap();
1828
1829 assert_eq!(result.mean_function.len(), 50);
1830 assert_eq!(result.beta_functions.nrows(), 1); assert_eq!(result.beta_functions.ncols(), 50);
1832 assert_eq!(result.random_effects.nrows(), 10);
1833 assert_eq!(result.fitted.nrows(), 30);
1834 assert_eq!(result.residuals.nrows(), 30);
1835 assert_eq!(result.n_subjects, 10);
1836 }
1837
1838 #[test]
1839 fn test_fmm_fitted_plus_residuals_equals_data() {
1840 let (data, subject_ids, covariates, _t) = generate_fmm_data(8, 3, 40);
1841 let result = fmm(&data, &subject_ids, Some(&covariates), 3).unwrap();
1842
1843 let n = data.nrows();
1844 let m = data.ncols();
1845 for i in 0..n {
1846 for t in 0..m {
1847 let reconstructed = result.fitted[(i, t)] + result.residuals[(i, t)];
1848 assert!(
1849 (reconstructed - data[(i, t)]).abs() < 1e-8,
1850 "Fitted + residual should equal data at ({}, {}): {} vs {}",
1851 i,
1852 t,
1853 reconstructed,
1854 data[(i, t)]
1855 );
1856 }
1857 }
1858 }
1859
1860 #[test]
1861 fn test_fmm_random_variance_positive() {
1862 let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 50);
1863 let result = fmm(&data, &subject_ids, Some(&covariates), 3).unwrap();
1864
1865 for &v in &result.random_variance {
1866 assert!(v >= 0.0, "Random variance should be non-negative");
1867 }
1868 }
1869
1870 #[test]
1871 fn test_fmm_no_covariates() {
1872 let (data, subject_ids, _cov, _t) = generate_fmm_data(8, 3, 40);
1873 let result = fmm(&data, &subject_ids, None, 3).unwrap();
1874
1875 assert_eq!(result.beta_functions.nrows(), 0);
1876 assert_eq!(result.n_subjects, 8);
1877 assert_eq!(result.fitted.nrows(), 24);
1878 }
1879
1880 #[test]
1881 fn test_fmm_predict() {
1882 let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 50);
1883 let result = fmm(&data, &subject_ids, Some(&covariates), 3).unwrap();
1884
1885 let new_cov = FdMatrix::from_column_major(vec![0.5], 1, 1).unwrap();
1887 let predicted = fmm_predict(&result, Some(&new_cov));
1888
1889 assert_eq!(predicted.nrows(), 1);
1890 assert_eq!(predicted.ncols(), 50);
1891
1892 for t in 0..50 {
1894 assert!(predicted[(0, t)].is_finite());
1895 assert!(
1896 predicted[(0, t)].abs() < 20.0,
1897 "Predicted value too extreme at t={}: {}",
1898 t,
1899 predicted[(0, t)]
1900 );
1901 }
1902 }
1903
1904 #[test]
1905 fn test_fmm_test_fixed_detects_effect() {
1906 let (data, subject_ids, covariates, _t) = generate_fmm_data(15, 3, 40);
1907
1908 let result = fmm_test_fixed(&data, &subject_ids, &covariates, 3, 99, 42).unwrap();
1909
1910 assert_eq!(result.f_statistics.len(), 1);
1911 assert_eq!(result.p_values.len(), 1);
1912 assert!(
1913 result.p_values[0] < 0.1,
1914 "Should detect covariate effect, got p={}",
1915 result.p_values[0]
1916 );
1917 }
1918
1919 #[test]
1920 fn test_fmm_test_fixed_no_effect() {
1921 let n_subjects = 10;
1922 let n_visits = 3;
1923 let m = 40;
1924 let t = uniform_grid(m);
1925 let n_total = n_subjects * n_visits;
1926
1927 let mut col_major = vec![0.0; n_total * m];
1929 let mut subject_ids = vec![0usize; n_total];
1930 let mut cov_data = vec![0.0; n_total];
1931
1932 for s in 0..n_subjects {
1933 for v in 0..n_visits {
1934 let obs = s * n_visits + v;
1935 subject_ids[obs] = s;
1936 cov_data[obs] = s as f64 / n_subjects as f64;
1937 for (j, &tj) in t.iter().enumerate() {
1938 col_major[obs + j * n_total] =
1939 (2.0 * PI * tj).sin() + 0.1 * ((obs * 7 + j * 3) % 100) as f64 / 100.0;
1940 }
1941 }
1942 }
1943
1944 let data = FdMatrix::from_column_major(col_major, n_total, m).unwrap();
1945 let covariates = FdMatrix::from_column_major(cov_data, n_total, 1).unwrap();
1946
1947 let result = fmm_test_fixed(&data, &subject_ids, &covariates, 3, 99, 42).unwrap();
1948 assert!(
1949 result.p_values[0] > 0.05,
1950 "Should not detect effect, got p={}",
1951 result.p_values[0]
1952 );
1953 }
1954
1955 #[test]
1956 fn test_fmm_invalid_input() {
1957 let data = FdMatrix::zeros(0, 0);
1958 assert!(fmm(&data, &[], None, 1).is_err());
1959
1960 let data = FdMatrix::zeros(10, 50);
1961 let ids = vec![0; 5]; assert!(fmm(&data, &ids, None, 1).is_err());
1963 }
1964
1965 #[test]
1966 fn test_fmm_single_visit_per_subject() {
1967 let n = 10;
1968 let m = 40;
1969 let t = uniform_grid(m);
1970 let mut col_major = vec![0.0; n * m];
1971 let subject_ids: Vec<usize> = (0..n).collect();
1972
1973 for i in 0..n {
1974 for (j, &tj) in t.iter().enumerate() {
1975 col_major[i + j * n] = (2.0 * PI * tj).sin();
1976 }
1977 }
1978 let data = FdMatrix::from_column_major(col_major, n, m).unwrap();
1979
1980 let result = fmm(&data, &subject_ids, None, 2).unwrap();
1982 assert_eq!(result.n_subjects, n);
1983 assert_eq!(result.fitted.nrows(), n);
1984 }
1985
1986 #[test]
1987 fn test_build_subject_map() {
1988 let (map, n) = build_subject_map(&[5, 5, 10, 10, 20]);
1989 assert_eq!(n, 3);
1990 assert_eq!(map, vec![0, 0, 1, 1, 2]);
1991 }
1992
1993 #[test]
1994 fn test_variance_components_positive() {
1995 let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 50);
1996 let result = fmm(&data, &subject_ids, Some(&covariates), 3).unwrap();
1997
1998 assert!(result.sigma2_eps >= 0.0);
1999 for &s in &result.sigma2_u {
2000 assert!(s >= 0.0);
2001 }
2002 }
2003
2004 #[test]
2009 fn test_fmm_ncomp_zero_returns_error() {
2010 let (data, subject_ids, _cov, _t) = generate_fmm_data(5, 2, 20);
2011 let err = fmm(&data, &subject_ids, None, 0).unwrap_err();
2012 match err {
2013 FdarError::InvalidParameter { parameter, .. } => {
2014 assert_eq!(parameter, "ncomp");
2015 }
2016 other => panic!("Expected InvalidParameter, got {:?}", other),
2017 }
2018 }
2019
2020 #[test]
2021 fn test_fmm_single_component() {
2022 let (data, subject_ids, covariates, _t) = generate_fmm_data(8, 3, 30);
2024 let result = fmm(&data, &subject_ids, Some(&covariates), 1).unwrap();
2025
2026 assert_eq!(result.ncomp, 1);
2027 assert_eq!(result.sigma2_u.len(), 1);
2028 assert_eq!(result.eigenvalues.len(), 1);
2029 assert_eq!(result.mean_function.len(), 30);
2030 for i in 0..data.nrows() {
2032 for t in 0..data.ncols() {
2033 let diff = (result.fitted[(i, t)] + result.residuals[(i, t)] - data[(i, t)]).abs();
2034 assert!(diff < 1e-8);
2035 }
2036 }
2037 }
2038
2039 #[test]
2040 fn test_fmm_two_subjects() {
2041 let n_subjects = 2;
2043 let n_visits = 5;
2044 let m = 20;
2045 let t = uniform_grid(m);
2046 let n_total = n_subjects * n_visits;
2047 let mut col_major = vec![0.0; n_total * m];
2048 let mut subject_ids = vec![0usize; n_total];
2049
2050 for s in 0..n_subjects {
2051 for v in 0..n_visits {
2052 let obs = s * n_visits + v;
2053 subject_ids[obs] = s;
2054 for (j, &tj) in t.iter().enumerate() {
2055 col_major[obs + j * n_total] =
2056 (2.0 * PI * tj).sin() + (s as f64) * 0.5 + 0.01 * v as f64;
2057 }
2058 }
2059 }
2060 let data = FdMatrix::from_column_major(col_major, n_total, m).unwrap();
2061 let result = fmm(&data, &subject_ids, None, 2).unwrap();
2062
2063 assert_eq!(result.n_subjects, 2);
2064 assert_eq!(result.random_effects.nrows(), 2);
2065 assert_eq!(result.fitted.nrows(), n_total);
2066 }
2067
2068 #[test]
2069 fn test_fmm_predict_no_covariates() {
2070 let (data, subject_ids, _cov, _t) = generate_fmm_data(6, 3, 30);
2071 let result = fmm(&data, &subject_ids, None, 2).unwrap();
2072
2073 let predicted = fmm_predict(&result, None);
2075 assert_eq!(predicted.nrows(), 1);
2076 assert_eq!(predicted.ncols(), 30);
2077 for t in 0..30 {
2078 let diff = (predicted[(0, t)] - result.mean_function[t]).abs();
2079 assert!(
2080 diff < 1e-12,
2081 "Without covariates, prediction should equal mean"
2082 );
2083 }
2084 }
2085
2086 #[test]
2087 fn test_fmm_predict_multiple_new_subjects() {
2088 let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 40);
2089 let result = fmm(&data, &subject_ids, Some(&covariates), 3).unwrap();
2090
2091 let new_cov = FdMatrix::from_column_major(vec![0.1, 0.5, 0.9], 3, 1).unwrap();
2093 let predicted = fmm_predict(&result, Some(&new_cov));
2094
2095 assert_eq!(predicted.nrows(), 3);
2096 assert_eq!(predicted.ncols(), 40);
2097
2098 for i in 0..3 {
2100 for t in 0..40 {
2101 assert!(predicted[(i, t)].is_finite());
2102 }
2103 }
2104
2105 let diff_01: f64 = (0..40)
2107 .map(|t| (predicted[(0, t)] - predicted[(1, t)]).powi(2))
2108 .sum();
2109 assert!(
2110 diff_01 > 1e-10,
2111 "Different covariates should yield different predictions"
2112 );
2113 }
2114
2115 #[test]
2116 fn test_fmm_eigenvalues_decreasing() {
2117 let (data, subject_ids, _cov, _t) = generate_fmm_data(10, 3, 50);
2118 let result = fmm(&data, &subject_ids, None, 5).unwrap();
2119
2120 for i in 1..result.eigenvalues.len() {
2122 assert!(
2123 result.eigenvalues[i] <= result.eigenvalues[i - 1] + 1e-10,
2124 "Eigenvalues should be non-increasing: {} > {}",
2125 result.eigenvalues[i],
2126 result.eigenvalues[i - 1]
2127 );
2128 }
2129 }
2130
2131 #[test]
2132 fn test_fmm_random_effects_sum_near_zero() {
2133 let (data, subject_ids, covariates, _t) = generate_fmm_data(20, 3, 40);
2135 let result = fmm(&data, &subject_ids, Some(&covariates), 3).unwrap();
2136
2137 let m = result.mean_function.len();
2138 for t in 0..m {
2139 let sum: f64 = (0..result.n_subjects)
2140 .map(|s| result.random_effects[(s, t)])
2141 .sum();
2142 let mean_abs: f64 = (0..result.n_subjects)
2143 .map(|s| result.random_effects[(s, t)].abs())
2144 .sum::<f64>()
2145 / result.n_subjects as f64;
2146 if mean_abs > 1e-10 {
2148 assert!(
2149 (sum / result.n_subjects as f64).abs() < mean_abs * 2.0,
2150 "Random effects should roughly center around zero at t={}: sum={}, mean_abs={}",
2151 t,
2152 sum,
2153 mean_abs
2154 );
2155 }
2156 }
2157 }
2158
2159 #[test]
2160 fn test_fmm_subject_ids_mismatch_error() {
2161 let data = FdMatrix::zeros(10, 20);
2162 let ids = vec![0; 7]; let err = fmm(&data, &ids, None, 1).unwrap_err();
2164 match err {
2165 FdarError::InvalidDimension { parameter, .. } => {
2166 assert_eq!(parameter, "subject_ids");
2167 }
2168 other => panic!("Expected InvalidDimension, got {:?}", other),
2169 }
2170 }
2171
2172 #[test]
2173 fn test_fmm_test_fixed_empty_data_error() {
2174 let data = FdMatrix::zeros(0, 0);
2175 let covariates = FdMatrix::zeros(0, 1);
2176 let err = fmm_test_fixed(&data, &[], &covariates, 1, 10, 42).unwrap_err();
2177 match err {
2178 FdarError::InvalidDimension { parameter, .. } => {
2179 assert_eq!(parameter, "data");
2180 }
2181 other => panic!("Expected InvalidDimension for data, got {:?}", other),
2182 }
2183 }
2184
2185 #[test]
2186 fn test_fmm_test_fixed_zero_covariates_error() {
2187 let data = FdMatrix::zeros(10, 20);
2188 let ids = vec![0; 10];
2189 let covariates = FdMatrix::zeros(10, 0);
2190 let err = fmm_test_fixed(&data, &ids, &covariates, 1, 10, 42).unwrap_err();
2191 match err {
2192 FdarError::InvalidDimension { parameter, .. } => {
2193 assert_eq!(parameter, "covariates");
2194 }
2195 other => panic!("Expected InvalidDimension for covariates, got {:?}", other),
2196 }
2197 }
2198
2199 #[test]
2200 fn test_build_subject_map_single_subject() {
2201 let (map, n) = build_subject_map(&[42, 42, 42]);
2202 assert_eq!(n, 1);
2203 assert_eq!(map, vec![0, 0, 0]);
2204 }
2205
2206 #[test]
2207 fn test_build_subject_map_non_contiguous_ids() {
2208 let (map, n) = build_subject_map(&[100, 200, 100, 300, 200]);
2209 assert_eq!(n, 3);
2210 assert_eq!(map, vec![0, 1, 0, 2, 1]);
2212 }
2213
2214 #[test]
2215 fn test_fmm_many_components_clamped() {
2216 let (data, subject_ids, _cov, _t) = generate_fmm_data(5, 3, 20);
2218 let n_total = data.nrows();
2219 let result = fmm(&data, &subject_ids, None, 100).unwrap();
2221 assert!(
2222 result.ncomp <= n_total.min(20),
2223 "ncomp should be clamped: got {}",
2224 result.ncomp
2225 );
2226 assert!(result.ncomp >= 1);
2227 }
2228
2229 #[test]
2230 fn test_fmm_residuals_small_with_enough_components() {
2231 let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 30);
2233 let result = fmm(&data, &subject_ids, Some(&covariates), 5).unwrap();
2234
2235 let n = data.nrows();
2236 let m = data.ncols();
2237 let mut data_ss = 0.0_f64;
2238 let mut resid_ss = 0.0_f64;
2239 for i in 0..n {
2240 for t in 0..m {
2241 data_ss += data[(i, t)].powi(2);
2242 resid_ss += result.residuals[(i, t)].powi(2);
2243 }
2244 }
2245
2246 let r_squared = 1.0 - resid_ss / data_ss;
2248 assert!(
2249 r_squared > 0.5,
2250 "R-squared should be high with enough components: {}",
2251 r_squared
2252 );
2253 }
2254
2255 #[test]
2260 fn test_dense_flmm_basic() {
2261 let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 30);
2262 let cfg = DenseFlmmConfig::default();
2263 let result = dense_flmm(&data, &subject_ids, Some(&covariates), &cfg).unwrap();
2264 assert_eq!(result.ncomp, cfg.ncomp);
2265 assert_eq!(result.n_subjects, 10);
2266 assert_eq!(result.mean_function.len(), 30);
2267 assert_eq!(result.beta_functions.ncols(), 30);
2268 assert_eq!(result.random_variance.len(), 30);
2269 assert_eq!(result.sigma2_u.len(), cfg.ncomp);
2270 assert_eq!(result.sigma2_slope.len(), cfg.ncomp);
2272 assert!(result.sigma2_slope.iter().all(|&v| v == 0.0));
2273 }
2274
2275 #[test]
2276 fn test_dense_flmm_fitted_plus_residuals_equals_data() {
2277 let (data, subject_ids, covariates, _t) = generate_fmm_data(8, 3, 24);
2278 let cfg = DenseFlmmConfig::default();
2279 let result = dense_flmm(&data, &subject_ids, Some(&covariates), &cfg).unwrap();
2280 let n = data.nrows();
2281 let m = data.ncols();
2282 for i in 0..n {
2283 for j in 0..m {
2284 let recon = result.fitted[(i, j)] + result.residuals[(i, j)];
2285 assert!(
2286 (recon - data[(i, j)]).abs() < 1e-6,
2287 "fitted+residuals must equal data at ({i},{j})"
2288 );
2289 }
2290 }
2291 }
2292
2293 #[test]
2294 fn test_dense_flmm_recovers_signal_and_positive_variance() {
2295 let (data, subject_ids, covariates, _t) = generate_fmm_data(12, 4, 30);
2296 let cfg = DenseFlmmConfig {
2297 ncomp: 4,
2298 ..Default::default()
2299 };
2300 let result = dense_flmm(&data, &subject_ids, Some(&covariates), &cfg).unwrap();
2301 let n = data.nrows();
2303 let m = data.ncols();
2304 let mut col_means = vec![0.0; m];
2305 for j in 0..m {
2306 for i in 0..n {
2307 col_means[j] += data[(i, j)];
2308 }
2309 col_means[j] /= n as f64;
2310 }
2311 let (mut base_ss, mut resid_ss) = (0.0_f64, 0.0_f64);
2312 for i in 0..n {
2313 for j in 0..m {
2314 base_ss += (data[(i, j)] - col_means[j]).powi(2);
2315 resid_ss += result.residuals[(i, j)].powi(2);
2316 }
2317 }
2318 assert!(
2319 resid_ss < 0.5 * base_ss,
2320 "mixed model should explain most variance: resid={resid_ss}, base={base_ss}"
2321 );
2322 assert!(result.sigma2_u.iter().any(|&v| v > 0.0));
2324 }
2325
2326 #[test]
2327 fn test_dense_flmm_invalid_inputs() {
2328 let cfg = DenseFlmmConfig::default();
2329 let empty = FdMatrix::from_column_major(vec![], 0, 0).unwrap();
2330 assert!(dense_flmm(&empty, &[], None, &cfg).is_err());
2331
2332 let (data, subject_ids, _cov, _t) = generate_fmm_data(4, 2, 10);
2333 let bad_ids = vec![0usize; subject_ids.len() + 1];
2335 assert!(dense_flmm(&data, &bad_ids, None, &cfg).is_err());
2336
2337 let bad_cfg = DenseFlmmConfig {
2339 ncomp: 0,
2340 ..Default::default()
2341 };
2342 assert!(dense_flmm(&data, &subject_ids, None, &bad_cfg).is_err());
2343 }
2344
2345 #[test]
2350 fn test_multi_famm_basic() {
2351 let (d0, subject_ids, cov, _t) = generate_fmm_data(10, 3, 20);
2352 let (d1, _s1, _c1, _t1) = generate_fmm_data(10, 3, 20);
2353 let cfg = MultiFammConfig {
2354 ncomp: 3,
2355 max_iter: 50,
2356 tol: 1e-10,
2357 };
2358 let result = multi_famm(&[d0, d1], &subject_ids, Some(&cov), &cfg).unwrap();
2359 assert_eq!(result.n_dims, 2);
2360 assert_eq!(result.components.len(), 2);
2361 assert_eq!(result.stacked_fitted.nrows(), 2 * subject_ids.len());
2363 assert_eq!(result.stacked_residuals.nrows(), 2 * subject_ids.len());
2364 }
2365
2366 #[test]
2367 fn test_multi_famm_invalid_inputs() {
2368 let cfg = MultiFammConfig {
2369 ncomp: 3,
2370 max_iter: 50,
2371 tol: 1e-10,
2372 };
2373 assert!(multi_famm(&[], &[], None, &cfg).is_err());
2375
2376 let (d0, subject_ids, cov, _t) = generate_fmm_data(6, 2, 20);
2378 let (d1, _s1, _c1, _t1) = generate_fmm_data(6, 2, 25);
2379 assert!(multi_famm(&[d0, d1], &subject_ids, Some(&cov), &cfg).is_err());
2380 }
2381
2382 #[test]
2387 fn test_fast_fmm_basic() {
2388 let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 20);
2389 let cfg = FastFmmConfig::default();
2390 let result = fast_fmm(&data, &subject_ids, Some(&covariates), &cfg).unwrap();
2391 assert_eq!(result.n_grid, 20);
2392 assert_eq!(result.beta_matrix.ncols(), 20);
2393 assert_eq!(result.p_values.ncols(), 20);
2394 assert_eq!(result.sigma2_eps.len(), 20);
2395 for i in 0..result.p_values.nrows() {
2397 for j in 0..result.p_values.ncols() {
2398 let p = result.p_values[(i, j)];
2399 assert!((0.0..=1.0).contains(&p), "p-value out of range: {p}");
2400 assert!(result.t_stats[(i, j)].is_finite());
2401 }
2402 }
2403 }
2404
2405 #[test]
2406 fn test_fast_fmm_invalid_inputs() {
2407 let (data, subject_ids, _cov, _t) = generate_fmm_data(4, 2, 10);
2408 let bad_cfg = FastFmmConfig {
2410 smooth_window: 0,
2411 ..Default::default()
2412 };
2413 assert!(fast_fmm(&data, &subject_ids, None, &bad_cfg).is_err());
2414
2415 let cfg = FastFmmConfig::default();
2417 let bad_ids = vec![0usize; subject_ids.len() + 1];
2418 assert!(fast_fmm(&data, &bad_ids, None, &cfg).is_err());
2419 }
2420
2421 #[test]
2426 fn test_dense_flmm_converged() {
2427 let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 20);
2428 let cfg = DenseFlmmConfig {
2430 max_iter: 100,
2431 ..Default::default()
2432 };
2433 let result = dense_flmm(&data, &subject_ids, Some(&covariates), &cfg).unwrap();
2434 assert!(result.converged, "should converge with 100 iterations");
2435
2436 let tight_cfg = DenseFlmmConfig {
2439 max_iter: 1,
2440 tol: 1e-30,
2441 ..Default::default()
2442 };
2443 let result2 = dense_flmm(&data, &subject_ids, Some(&covariates), &tight_cfg).unwrap();
2444 assert_eq!(result2.n_iter, 1, "expected exactly 1 iteration");
2445 }
2449
2450 #[test]
2455 fn test_fast_fmm_detects_effect() {
2456 let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 20);
2457 let cfg = FastFmmConfig {
2458 compute_inference: true,
2459 ..Default::default()
2460 };
2461 let result = fast_fmm(&data, &subject_ids, Some(&covariates), &cfg).unwrap();
2462 let norm_sq: f64 = (0..result.beta_matrix.ncols())
2465 .map(|t| result.beta_matrix[(0, t)].powi(2))
2466 .sum();
2467 assert!(
2468 norm_sq > 0.0,
2469 "beta_matrix row 0 should be non-zero for data with a real covariate effect"
2470 );
2471 let max_abs_t: f64 = (0..result.t_stats.ncols())
2473 .map(|t| result.t_stats[(0, t)].abs())
2474 .fold(0.0_f64, f64::max);
2475 assert!(
2476 max_abs_t > 0.5,
2477 "expected a noticeable t-stat somewhere on the grid, got max |t|={max_abs_t}"
2478 );
2479 }
2480
2481 #[test]
2486 fn test_fast_fmm_empty_data_error() {
2487 let empty = FdMatrix::zeros(0, 0);
2488 let cfg = FastFmmConfig::default();
2489 let err = fast_fmm(&empty, &[], None, &cfg).unwrap_err();
2490 match err {
2491 FdarError::InvalidDimension { parameter, .. } => {
2492 assert_eq!(parameter, "data");
2493 }
2494 other => panic!("Expected InvalidDimension for data, got {:?}", other),
2495 }
2496 }
2497
2498 #[test]
2503 fn test_fast_fmm_max_iter_takes_effect() {
2504 let (data, subject_ids, covariates, _t) = generate_fmm_data(10, 3, 20);
2505 let cfg_tight = FastFmmConfig {
2508 max_iter: 1,
2509 tol: 1e-30,
2510 compute_inference: false,
2511 ..Default::default()
2512 };
2513 let cfg_full = FastFmmConfig {
2514 max_iter: 100,
2515 tol: 1e-10,
2516 compute_inference: false,
2517 ..Default::default()
2518 };
2519 let r1 = fast_fmm(&data, &subject_ids, Some(&covariates), &cfg_tight).unwrap();
2520 let r2 = fast_fmm(&data, &subject_ids, Some(&covariates), &cfg_full).unwrap();
2521 let same = r1
2523 .sigma2_eps
2524 .iter()
2525 .zip(&r2.sigma2_eps)
2526 .all(|(a, b)| (a - b).abs() < 1e-12);
2527 assert!(
2528 !same,
2529 "1-iter and 100-iter fast_fmm should produce different sigma2_eps (max_iter is now wired)"
2530 );
2531 }
2532
2533 #[test]
2538 fn test_fast_fmm_even_smooth_window() {
2539 let (data, subject_ids, covariates, _t) = generate_fmm_data(6, 3, 15);
2540 let cfg_even = FastFmmConfig {
2542 smooth_window: 4,
2543 compute_inference: false,
2544 ..Default::default()
2545 };
2546 let result = fast_fmm(&data, &subject_ids, Some(&covariates), &cfg_even).unwrap();
2547 assert_eq!(result.n_grid, 15);
2548 for j in 0..result.beta_matrix.nrows() {
2549 for t in 0..result.beta_matrix.ncols() {
2550 assert!(result.beta_matrix[(j, t)].is_finite());
2551 }
2552 }
2553 let cfg_odd = FastFmmConfig {
2555 smooth_window: 5,
2556 compute_inference: false,
2557 ..Default::default()
2558 };
2559 let result_odd = fast_fmm(&data, &subject_ids, Some(&covariates), &cfg_odd).unwrap();
2560 for j in 0..result.beta_matrix.nrows() {
2561 for t in 0..result.beta_matrix.ncols() {
2562 assert!(
2563 (result.beta_matrix[(j, t)] - result_odd.beta_matrix[(j, t)]).abs() < 1e-12,
2564 "even window 4 should produce identical output to odd window 5 (rounded up)"
2565 );
2566 }
2567 }
2568 }
2569
2570 #[test]
2575 fn test_dense_flmm_random_slopes_errors() {
2576 let (data, subject_ids, covariates, _t) = generate_fmm_data(6, 2, 15);
2577 let cfg = DenseFlmmConfig {
2578 random_slopes: true,
2579 ..Default::default()
2580 };
2581 let err = dense_flmm(&data, &subject_ids, Some(&covariates), &cfg).unwrap_err();
2582 match err {
2583 FdarError::InvalidParameter { parameter, .. } => {
2584 assert_eq!(parameter, "random_slopes");
2585 }
2586 other => panic!(
2587 "Expected InvalidParameter for random_slopes, got {:?}",
2588 other
2589 ),
2590 }
2591 }
2592
2593 #[test]
2598 fn test_dense_flmm_max_iter_zero_errors() {
2599 let (data, subject_ids, _cov, _t) = generate_fmm_data(4, 2, 10);
2600 let cfg = DenseFlmmConfig {
2601 max_iter: 0,
2602 ..Default::default()
2603 };
2604 let err = dense_flmm(&data, &subject_ids, None, &cfg).unwrap_err();
2605 match err {
2606 FdarError::InvalidParameter { parameter, .. } => {
2607 assert_eq!(parameter, "max_iter");
2608 }
2609 other => panic!("Expected InvalidParameter for max_iter, got {:?}", other),
2610 }
2611 }
2612
2613 #[test]
2614 fn test_fast_fmm_max_iter_zero_errors() {
2615 let (data, subject_ids, _cov, _t) = generate_fmm_data(4, 2, 10);
2616 let cfg = FastFmmConfig {
2617 max_iter: 0,
2618 ..Default::default()
2619 };
2620 let err = fast_fmm(&data, &subject_ids, None, &cfg).unwrap_err();
2621 match err {
2622 FdarError::InvalidParameter { parameter, .. } => {
2623 assert_eq!(parameter, "max_iter");
2624 }
2625 other => panic!("Expected InvalidParameter for max_iter, got {:?}", other),
2626 }
2627 }
2628}