1use faer::{
9 Mat, MatRef,
10 linalg::solvers::{DenseSolveCore, PartialPivLu},
11};
12
13use crate::core::math::distributions::t_pval_two_sided;
14use crate::core::math::linear_algebra::spd_log_det;
15use crate::core::math::optimise::brent_fmin;
16use crate::prelude::*;
17
18const DEFAULT_LAMBDA_MAX: f64 = 1.0e6;
30
31const BRACKET_EDGE: f64 = 0.99;
34
35const DEFAULT_LAMBDA_TOL: f64 = 1.0e-8;
44
45const SINGULAR_LAMBDA: f64 = 1.0e-8;
50
51const GRAD_STEP: f64 = 1.0e-5;
53
54const HESSIAN_STEP: f64 = 1.0e-4;
56
57const THETA_STEP_FLOOR: f64 = 1.0;
65
66const SIGMA_STEP_FLOOR: f64 = 1.0e-300;
68
69#[derive(Clone, Debug)]
78pub struct RandomInterceptStats {
79 pub xtx: Mat<f64>,
81 pub xty: Vec<f64>,
83 pub yty: f64,
85 pub n: usize,
87 pub p: usize,
89 pub group_n: Vec<f64>,
92 pub group_s: Mat<f64>,
94 pub group_t: Vec<f64>,
96}
97
98impl RandomInterceptStats {
99 pub fn from_design<T: BixverseFloat>(
117 x: MatRef<T>,
118 y: &[T],
119 groups: &[usize],
120 n_groups: usize,
121 ) -> Result<Self, CoreErrors> {
122 let n = x.nrows();
123 let p = x.ncols();
124 if y.len() != n {
125 return Err(CoreErrors::ShapeMismatch {
126 expected: (n, p),
127 got: (y.len(), p),
128 });
129 }
130 if groups.len() != n {
131 return Err(CoreErrors::ShapeMismatch {
132 expected: (n, p),
133 got: (groups.len(), p),
134 });
135 }
136 if p == 0 {
137 return Err(CoreErrors::InvalidArgument(
138 "the design matrix has no columns.".to_string(),
139 ));
140 }
141
142 let xf = Mat::<f64>::from_fn(n, p, |i, j| x[(i, j)].to_f64().unwrap_or(f64::NAN));
143 let yf: Vec<f64> = y.iter().map(|v| v.to_f64().unwrap_or(f64::NAN)).collect();
144
145 if yf.iter().any(|v| !v.is_finite())
149 || (0..n).any(|i| (0..p).any(|j| !xf[(i, j)].is_finite()))
150 {
151 return Err(CoreErrors::InvalidArgument(
152 "the design or response contains a non-finite value.".to_string(),
153 ));
154 }
155
156 let mut counts = vec![0.0_f64; n_groups];
157 let mut sums = Mat::<f64>::zeros(n_groups, p);
158 let mut tots = vec![0.0_f64; n_groups];
159 for (i, &g) in groups.iter().enumerate() {
160 if g >= n_groups {
161 return Err(CoreErrors::InvalidArgument(format!(
162 "group code {g} is outside 0..{n_groups}."
163 )));
164 }
165 counts[g] += 1.0;
166 tots[g] += yf[i];
167 for j in 0..p {
168 sums[(g, j)] += xf[(i, j)];
169 }
170 }
171
172 let kept: Vec<usize> = (0..n_groups).filter(|&g| counts[g] > 0.0).collect();
175 if kept.len() < 2 {
176 return Err(CoreErrors::InvalidArgument(format!(
177 "a random intercept needs at least two non-empty groups; got {}.",
178 kept.len()
179 )));
180 }
181 let group_n: Vec<f64> = kept.iter().map(|&g| counts[g]).collect();
182 let group_t: Vec<f64> = kept.iter().map(|&g| tots[g]).collect();
183 let group_s = Mat::<f64>::from_fn(kept.len(), p, |i, j| sums[(kept[i], j)]);
184
185 let xtx = xf.transpose() * &xf;
186 let xty: Vec<f64> = (0..p)
187 .map(|j| (0..n).map(|i| xf[(i, j)] * yf[i]).sum())
188 .collect();
189 let yty: f64 = yf.iter().map(|v| v * v).sum();
190
191 Ok(Self {
192 xtx,
193 xty,
194 yty,
195 n,
196 p,
197 group_n,
198 group_s,
199 group_t,
200 })
201 }
202
203 pub fn n_groups(&self) -> usize {
209 self.group_n.len()
210 }
211}
212
213#[derive(Clone, Debug)]
219struct GlsFit {
220 a_inv: Mat<f64>,
222 log_det_a: f64,
224 log_det_h: f64,
226 beta: Vec<f64>,
228 rss: f64,
230}
231
232fn gls_solve(s: &RandomInterceptStats, lambda: f64) -> Result<GlsFit, CoreErrors> {
244 let p = s.p;
245 let g = s.n_groups();
246
247 let mut a = s.xtx.clone();
248 let mut b = s.xty.clone();
249 let mut yhy = s.yty;
250 let mut log_det_h = 0.0;
251
252 for k in 0..g {
253 let nk = s.group_n[k];
254 let shrink = lambda / (1.0 + nk * lambda);
255 log_det_h += (1.0 + nk * lambda).ln();
256 if shrink == 0.0 {
257 continue;
258 }
259 let tk = s.group_t[k];
260 for i in 0..p {
261 let si = s.group_s[(k, i)];
262 b[i] -= shrink * si * tk;
263 for j in 0..p {
264 a[(i, j)] -= shrink * si * s.group_s[(k, j)];
265 }
266 }
267 yhy -= shrink * tk * tk;
268 }
269
270 let Some(log_det_a) = spd_log_det(a.as_ref()) else {
274 return Err(CoreErrors::InvalidArgument(
275 "the fixed-effect design is rank deficient; X'H^-1 X is not positive definite."
276 .to_string(),
277 ));
278 };
279
280 let lu: PartialPivLu<f64> = a.partial_piv_lu();
281 let a_inv = lu.inverse();
282
283 let beta: Vec<f64> = (0..p)
284 .map(|i| (0..p).map(|j| a_inv[(i, j)] * b[j]).sum())
285 .collect();
286 let cross: f64 = beta.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
288 let quad: f64 = (0..p)
289 .map(|i| (0..p).map(|j| beta[i] * a[(i, j)] * beta[j]).sum::<f64>())
290 .sum();
291 let rss = yhy - 2.0 * cross + quad;
292
293 Ok(GlsFit {
294 log_det_a,
295 a_inv,
296 log_det_h,
297 beta,
298 rss,
299 })
300}
301
302fn profiled_reml(s: &RandomInterceptStats, lambda: f64) -> f64 {
317 let Ok(fit) = gls_solve(s, lambda) else {
318 return f64::INFINITY;
319 };
320 let df = (s.n - s.p) as f64;
321 if !(fit.rss.is_finite() && fit.rss > 0.0) {
322 return f64::INFINITY;
323 }
324 let sigma_sq = fit.rss / df;
325 df * sigma_sq.ln() + fit.log_det_h + fit.log_det_a + df + df * (2.0 * std::f64::consts::PI).ln()
326}
327
328fn reml_criterion(s: &RandomInterceptStats, theta: f64, sigma: f64) -> f64 {
348 let Ok(fit) = gls_solve(s, theta * theta) else {
349 return f64::INFINITY;
350 };
351 let df = (s.n - s.p) as f64;
352 let sigma_sq = sigma * sigma;
353 if !(sigma_sq.is_finite() && sigma_sq > 0.0) {
354 return f64::INFINITY;
355 }
356 df * sigma_sq.ln()
357 + fit.log_det_h
358 + fit.log_det_a
359 + fit.rss / sigma_sq
360 + df * (2.0 * std::f64::consts::PI).ln()
361}
362
363fn coefficient_variance(s: &RandomInterceptStats, theta: f64, sigma: f64, coef: usize) -> f64 {
376 match gls_solve(s, theta * theta) {
377 Ok(fit) => fit.a_inv[(coef, coef)] * sigma * sigma,
378 Err(_) => f64::NAN,
379 }
380}
381
382#[derive(Clone, Copy, Debug)]
388pub struct RandomInterceptParams {
389 pub lambda_max: f64,
391 pub lambda_tol: f64,
393 pub satterthwaite: bool,
399}
400
401impl RandomInterceptParams {
402 pub fn new(lambda_max: f64, lambda_tol: f64, satterthwaite: bool) -> Self {
414 Self {
415 lambda_max,
416 lambda_tol,
417 satterthwaite,
418 }
419 }
420}
421
422impl Default for RandomInterceptParams {
423 fn default() -> Self {
424 Self {
425 lambda_max: DEFAULT_LAMBDA_MAX,
426 lambda_tol: DEFAULT_LAMBDA_TOL,
427 satterthwaite: true,
428 }
429 }
430}
431
432#[derive(Clone, Debug)]
438pub struct RandomInterceptFit {
439 pub beta: Vec<f64>,
441 pub se: Vec<f64>,
443 pub t_stat: Vec<f64>,
445 pub df: Vec<f64>,
449 pub p_value: Vec<f64>,
451 pub sigma_sq: f64,
453 pub sigma_b_sq: f64,
455 pub lambda: f64,
457 pub singular: bool,
461 pub bracket_exhausted: bool,
463}
464
465fn reml_hessian(s: &RandomInterceptStats, theta: f64, sigma: f64) -> [f64; 4] {
485 let phi = [theta, sigma];
486 let step = [
487 HESSIAN_STEP * phi[0].abs().max(THETA_STEP_FLOOR),
488 HESSIAN_STEP * phi[1].abs().max(SIGMA_STEP_FLOOR),
489 ];
490 let eval = |a: f64, b: f64| reml_criterion(s, a, b);
491 let at = |d0: f64, d1: f64| eval(phi[0] + d0, phi[1] + d1);
492
493 let mut h = [0.0_f64; 4];
494 for i in 0..2 {
495 for j in 0..2 {
496 let (mut pp, mut pm, mut mp, mut mm) = ([0.0; 2], [0.0; 2], [0.0; 2], [0.0; 2]);
497 pp[i] += step[i];
498 pp[j] += step[j];
499 pm[i] += step[i];
500 pm[j] -= step[j];
501 mp[i] -= step[i];
502 mp[j] += step[j];
503 mm[i] -= step[i];
504 mm[j] -= step[j];
505 h[i * 2 + j] = (at(pp[0], pp[1]) - at(pm[0], pm[1]) - at(mp[0], mp[1])
506 + at(mm[0], mm[1]))
507 / (4.0 * step[i] * step[j]);
508 }
509 }
510 let off = 0.5 * (h[1] + h[2]);
511 h[1] = off;
512 h[2] = off;
513 h
514}
515
516fn satterthwaite_df(
542 s: &RandomInterceptStats,
543 theta: f64,
544 sigma: f64,
545 coef: usize,
546 variance: f64,
547 hessian: &[f64; 4],
548) -> Option<f64> {
549 let det = hessian[0] * hessian[3] - hessian[1] * hessian[2];
550 if !det.is_finite() || det.abs() < f64::EPSILON {
551 return None;
552 }
553 let a = [
555 2.0 * hessian[3] / det,
556 -2.0 * hessian[1] / det,
557 -2.0 * hessian[2] / det,
558 2.0 * hessian[0] / det,
559 ];
560
561 let phi = [theta, sigma];
562 let mut grad = [0.0_f64; 2];
563 for (i, gi) in grad.iter_mut().enumerate() {
564 let floor = if i == 0 {
565 THETA_STEP_FLOOR
566 } else {
567 SIGMA_STEP_FLOOR
568 };
569 let h = GRAD_STEP * phi[i].abs().max(floor);
570 let mut up = phi;
571 let mut down = phi;
572 up[i] += h;
573 down[i] -= h;
574 let f_up = coefficient_variance(s, up[0], up[1], coef);
575 let f_down = coefficient_variance(s, down[0], down[1], coef);
576 *gi = (f_up - f_down) / (2.0 * h);
577 }
578
579 let denom =
580 grad[0] * (a[0] * grad[0] + a[1] * grad[1]) + grad[1] * (a[2] * grad[0] + a[3] * grad[1]);
581 if !denom.is_finite() || denom <= 0.0 {
582 return None;
583 }
584 let nu = 2.0 * variance * variance / denom;
585 if nu.is_finite() && nu > 0.0 {
586 Some(nu)
587 } else {
588 None
589 }
590}
591
592pub fn fit_random_intercept(
615 stats: &RandomInterceptStats,
616 params: Option<RandomInterceptParams>,
617) -> Result<RandomInterceptFit, CoreErrors> {
618 let params = params.unwrap_or_default();
619 if stats.n <= stats.p {
620 return Err(CoreErrors::InvalidArgument(format!(
621 "the model has {} observations and {} fixed effects, so no residual degrees of \
622 freedom.",
623 stats.n, stats.p
624 )));
625 }
626 if stats.n_groups() < 2 {
627 return Err(CoreErrors::InvalidArgument(format!(
628 "a random intercept needs at least two groups; got {}.",
629 stats.n_groups()
630 )));
631 }
632
633 let lambda_hat = brent_fmin(
634 0.0,
635 params.lambda_max,
636 |lambda| profiled_reml(stats, lambda),
637 params.lambda_tol,
638 );
639 let lambda = if profiled_reml(stats, 0.0) <= profiled_reml(stats, lambda_hat) {
642 0.0
643 } else {
644 lambda_hat
645 };
646 let singular = lambda <= SINGULAR_LAMBDA;
647 let lambda = if singular { 0.0 } else { lambda };
648 let bracket_exhausted = lambda >= BRACKET_EDGE * params.lambda_max;
649
650 let fit = gls_solve(stats, lambda)?;
651 let df_resid = (stats.n - stats.p) as f64;
652 let sigma_sq = fit.rss / df_resid;
653 let sigma = sigma_sq.sqrt();
654 let theta = lambda.sqrt();
655
656 let mut se = Vec::with_capacity(stats.p);
657 let mut t_stat = Vec::with_capacity(stats.p);
658 let mut df = Vec::with_capacity(stats.p);
659 let mut p_value = Vec::with_capacity(stats.p);
660
661 let hessian = if params.satterthwaite && !bracket_exhausted {
662 Some(reml_hessian(stats, theta, sigma))
663 } else {
664 None
665 };
666
667 for j in 0..stats.p {
668 let variance = fit.a_inv[(j, j)] * sigma_sq;
669 let error = variance.max(0.0).sqrt();
670 let t = fit.beta[j] / error;
671 let dof = match hessian.as_ref() {
672 Some(h) => satterthwaite_df(stats, theta, sigma, j, variance, h).unwrap_or(df_resid),
673 None if bracket_exhausted => f64::NAN,
674 None => df_resid,
675 };
676 se.push(error);
677 t_stat.push(t);
678 p_value.push(if t.is_finite() && dof.is_finite() && dof > 0.0 {
679 t_pval_two_sided(t, dof)?
680 } else {
681 f64::NAN
682 });
683 df.push(dof);
684 }
685
686 Ok(RandomInterceptFit {
687 beta: fit.beta,
688 se,
689 t_stat,
690 df,
691 p_value,
692 sigma_sq,
693 sigma_b_sq: lambda * sigma_sq,
694 lambda,
695 singular,
696 bracket_exhausted,
697 })
698}
699
700#[cfg(test)]
705mod tests {
706 use super::*;
707 use approx::assert_relative_eq;
708
709 struct Unbalanced {
717 groups: Vec<usize>,
718 x: Vec<f64>,
719 q: Vec<f64>,
720 tq: Vec<f64>,
721 y: Vec<f64>,
722 }
723
724 fn unbalanced_fixture() -> Unbalanced {
725 let sizes = [7usize, 4, 9, 5, 6, 3, 8];
726 let groups: Vec<usize> = sizes
727 .iter()
728 .enumerate()
729 .flat_map(|(g, &n)| std::iter::repeat_n(g, n))
730 .collect();
731 let x_s = [-0.326, 0.552, -0.675, 0.214, 0.311, 1.174, 0.619];
732 let t_s = [-0.113, 0.917, -0.223, 0.526, -0.795, 1.428, -1.467];
733 let q = vec![
734 -0.237, -0.193, -0.85, 0.058, -0.818, -2.05, -0.164, 0.709, -0.268, -1.464, 0.744,
735 -1.41, 0.467, -0.119, 0.467, 0.498, 0.895, 0.279, 1.008, -2.073, 1.19, -0.724, 0.168,
736 0.92, -1.672, 0.448, 0.482, 0.758, -2.319, -0.46, -1.105, 0.403, 0.569, -0.706, -0.29,
737 -1.484, -1.15, -0.274, 0.578, -1.397, 0.749, -1.051,
738 ];
739 let y = vec![
740 0.259, 0.493, 0.205, -0.184, 0.052, -0.586, 0.157, 3.132, 2.818, 2.013, 2.035, -1.141,
741 0.156, 0.86, -0.012, 0.992, 1.018, 2.104, 1.877, -0.491, 0.352, -1.243, 0.654, 0.629,
742 0.532, 1.59, 2.183, 2.135, -0.048, 0.467, -0.042, 0.71, 0.026, -0.242, 2.009, 0.925,
743 0.14, 1.248, 0.987, 2.105, 2.29, 1.272,
744 ];
745 let x: Vec<f64> = groups.iter().map(|&g| x_s[g]).collect();
746 let tq: Vec<f64> = groups.iter().map(|&g| t_s[g]).collect();
747 Unbalanced {
748 groups,
749 x,
750 q,
751 tq,
752 y,
753 }
754 }
755
756 fn design(cols: &[&[f64]]) -> Mat<f64> {
758 let n = cols[0].len();
759 Mat::from_fn(
760 n,
761 cols.len() + 1,
762 |i, j| {
763 if j == 0 { 1.0 } else { cols[j - 1][i] }
764 },
765 )
766 }
767
768 #[test]
771 fn test_random_intercept_matches_lmer_full_model() {
772 let f = unbalanced_fixture();
773 let x = design(&[&f.x, &f.q, &f.tq]);
774 let stats = RandomInterceptStats::from_design(x.as_ref(), &f.y, &f.groups, 7).unwrap();
775 let fit = fit_random_intercept(&stats, None).unwrap();
776
777 assert!(!fit.singular);
778 assert_relative_eq!(fit.lambda, 2.0532733375608516, max_relative = 1e-6);
779 assert_relative_eq!(fit.sigma_sq.sqrt(), 0.6577853719340665, max_relative = 1e-7);
780 assert_relative_eq!(
781 fit.sigma_b_sq.sqrt(),
782 0.9425569392646459,
783 max_relative = 1e-6
784 );
785
786 let est = [
787 0.837_003_209_793_895_5,
788 0.480_850_405_566_252_4,
789 0.494_990_133_154_51,
790 -0.29819131253327946,
791 ];
792 let se = [
793 0.41114768465916934,
794 0.690_886_295_671_207_5,
795 0.11240202592823408,
796 0.42743196063127253,
797 ];
798 let df = [
799 3.6710855587773943,
800 3.6751754158502474,
801 33.965_389_734_957_95,
802 3.751_245_840_530_402,
803 ];
804 let p = [
805 0.11774406230189201,
806 0.527_923_812_686_889_6,
807 0.00010056269552554244,
808 0.526_216_644_969_979_1,
809 ];
810 for j in 0..4 {
811 assert_relative_eq!(fit.beta[j], est[j], max_relative = 1e-7);
812 assert_relative_eq!(fit.se[j], se[j], max_relative = 1e-6);
813 assert_relative_eq!(fit.df[j], df[j], max_relative = 1e-5);
814 assert_relative_eq!(fit.p_value[j], p[j], max_relative = 1e-5);
815 }
816 }
817
818 #[test]
820 fn test_random_intercept_matches_lmer_group_level_predictor() {
821 let f = unbalanced_fixture();
822 let x = design(&[&f.x]);
823 let stats = RandomInterceptStats::from_design(x.as_ref(), &f.y, &f.groups, 7).unwrap();
824 let fit = fit_random_intercept(&stats, None).unwrap();
825
826 assert_relative_eq!(fit.lambda, 1.003_725_272_087_444, max_relative = 1e-6);
827 assert_relative_eq!(fit.beta[0], 0.749_953_089_282_345_8, max_relative = 1e-7);
828 assert_relative_eq!(fit.beta[1], 0.387_648_044_668_823_9, max_relative = 1e-7);
829 assert_relative_eq!(fit.se[1], 0.591_762_242_340_748, max_relative = 1e-6);
830 assert_relative_eq!(fit.df[1], 4.544_957_359_657_38, max_relative = 1e-5);
831 assert_relative_eq!(fit.p_value[1], 0.544_109_067_513_398_2, max_relative = 1e-5);
832 }
833
834 #[test]
838 fn test_random_intercept_matches_lmer_within_group_predictor() {
839 let f = unbalanced_fixture();
840 let x = design(&[&f.q]);
841 let stats = RandomInterceptStats::from_design(x.as_ref(), &f.y, &f.groups, 7).unwrap();
842 let fit = fit_random_intercept(&stats, None).unwrap();
843
844 assert_relative_eq!(fit.lambda, 1.579109868473656, max_relative = 1e-6);
845 assert_relative_eq!(fit.beta[1], 0.490_443_581_359_349_5, max_relative = 1e-7);
846 assert_relative_eq!(fit.se[1], 0.112_131_054_175_273_7, max_relative = 1e-6);
847 assert_relative_eq!(fit.df[1], 34.303_696_450_666_13, max_relative = 1e-5);
848 assert_relative_eq!(fit.p_value[1], 0.00010799519833758655, max_relative = 1e-4);
849 }
850
851 fn balanced_fixture() -> (Vec<usize>, Vec<f64>, Vec<f64>) {
853 let groups: Vec<usize> = (0..6).flat_map(|g| std::iter::repeat_n(g, 5)).collect();
854 let x_s = [-0.859, 0.512, -1.75, -0.74, 1.836, 0.597];
855 let y = vec![
856 0.056, -0.985, 1.037, 1.177, 0.63, 2.577, 0.817, 1.419, 3.018, 1.283, 3.407, 1.125,
857 2.492, 2.931, 1.936, 1.861, 1.881, 1.743, 1.391, 1.295, 2.359, 2.216, 1.244, 4.311,
858 3.43, 1.275, -0.281, -0.844, 0.103, 0.872,
859 ];
860 let x: Vec<f64> = groups.iter().map(|&g| x_s[g]).collect();
861 (groups, x, y)
862 }
863
864 #[test]
868 fn test_random_intercept_matches_the_balanced_closed_form() {
869 let (groups, _x, y) = balanced_fixture();
870 let n = y.len();
871 let per = 5.0;
872 let x = Mat::<f64>::from_fn(n, 1, |_, _| 1.0);
873 let stats = RandomInterceptStats::from_design(x.as_ref(), &y, &groups, 6).unwrap();
874 let fit = fit_random_intercept(&stats, None).unwrap();
875
876 let grand: f64 = y.iter().sum::<f64>() / n as f64;
878 let mut ss_between = 0.0;
879 let mut ss_within = 0.0;
880 for g in 0..6 {
881 let members: Vec<f64> = (0..n).filter(|&i| groups[i] == g).map(|i| y[i]).collect();
882 let mean = members.iter().sum::<f64>() / per;
883 ss_between += per * (mean - grand).powi(2);
884 ss_within += members.iter().map(|v| (v - mean).powi(2)).sum::<f64>();
885 }
886 let ms_between = ss_between / 5.0;
887 let ms_within = ss_within / 24.0;
888
889 assert_relative_eq!(fit.sigma_sq, ms_within, max_relative = 1e-8);
890 assert_relative_eq!(
891 fit.sigma_b_sq,
892 (ms_between - ms_within) / per,
893 max_relative = 1e-7
894 );
895 assert_relative_eq!(fit.df[0], 5.0, max_relative = 1e-6);
897 }
898
899 #[test]
902 fn test_random_intercept_balanced_with_a_group_level_predictor() {
903 let (groups, x_col, y) = balanced_fixture();
904 let x = design(&[&x_col]);
905 let stats = RandomInterceptStats::from_design(x.as_ref(), &y, &groups, 6).unwrap();
906 let fit = fit_random_intercept(&stats, None).unwrap();
907
908 assert_relative_eq!(fit.beta[0], 1.5336703600487156, max_relative = 1e-7);
909 assert_relative_eq!(fit.beta[1], 0.11589643636704265, max_relative = 1e-6);
910 assert_relative_eq!(fit.se[1], 0.39246534408354555, max_relative = 1e-6);
911 assert_relative_eq!(fit.df[1], 4.0, max_relative = 1e-5);
912 assert_relative_eq!(fit.p_value[1], 0.782_455_988_812_368_8, max_relative = 1e-5);
913 }
914
915 #[test]
922 fn test_random_intercept_singular_fit() {
923 let groups: Vec<usize> = (0..5).flat_map(|g| std::iter::repeat_n(g, 6)).collect();
924 let x_s = [-0.629, -0.871, 0.046, 0.672, -0.262];
925 let y = vec![
926 -0.497, 0.279, 0.384, 0.069, -0.578, 1.314, -1.261, -0.727, -0.231, 0.608, 1.022,
927 0.944, 0.945, 0.849, -0.728, -0.984, 0.774, -0.494, -0.452, 0.556, 0.564, 0.358,
928 -0.984, -0.678, 0.779, 0.148, -0.203, -1.111, 2.31, 0.67,
929 ];
930 let x_col: Vec<f64> = groups.iter().map(|&g| x_s[g]).collect();
931 let x = design(&[&x_col]);
932 let stats = RandomInterceptStats::from_design(x.as_ref(), &y, &groups, 5).unwrap();
933 let fit = fit_random_intercept(&stats, None).unwrap();
934
935 assert!(fit.singular);
936 assert_eq!(fit.lambda, 0.0);
937 assert_eq!(fit.sigma_b_sq, 0.0);
938 assert_relative_eq!(
939 fit.sigma_sq.sqrt(),
940 0.854_108_154_752_631_7,
941 max_relative = 1e-8
942 );
943 assert_relative_eq!(fit.beta[0], 0.091_701_676_247_745_51, max_relative = 1e-7);
944 assert_relative_eq!(fit.beta[1], -0.14271227850696577, max_relative = 1e-7);
945 assert_relative_eq!(fit.se[1], 0.28871102086056694, max_relative = 1e-7);
946 assert_relative_eq!(fit.df[1], 28.0, max_relative = 1e-6);
948 assert_relative_eq!(fit.p_value[1], 0.624_944_411_719_467_6, max_relative = 1e-6);
949 }
950
951 #[test]
954 fn test_random_intercept_without_satterthwaite() {
955 let f = unbalanced_fixture();
956 let x = design(&[&f.x, &f.q, &f.tq]);
957 let stats = RandomInterceptStats::from_design(x.as_ref(), &f.y, &f.groups, 7).unwrap();
958 let with = fit_random_intercept(&stats, None).unwrap();
959 let without = fit_random_intercept(
960 &stats,
961 Some(RandomInterceptParams {
962 satterthwaite: false,
963 ..Default::default()
964 }),
965 )
966 .unwrap();
967
968 for j in 0..4 {
969 assert_eq!(with.beta[j], without.beta[j]);
970 assert_eq!(with.se[j], without.se[j]);
971 assert_eq!(without.df[j], 38.0);
972 }
973 assert!((with.df[2] - 38.0).abs() < 5.0);
975 assert!(with.df[1] < 5.0);
977 }
978
979 #[test]
982 fn test_random_intercept_ignores_empty_groups() {
983 let f = unbalanced_fixture();
984 let x = design(&[&f.x, &f.q]);
985 let tight = RandomInterceptStats::from_design(x.as_ref(), &f.y, &f.groups, 7).unwrap();
986 let widened = RandomInterceptStats::from_design(x.as_ref(), &f.y, &f.groups, 20).unwrap();
987 assert_eq!(tight.n_groups(), widened.n_groups());
988 let a = fit_random_intercept(&tight, None).unwrap();
989 let b = fit_random_intercept(&widened, None).unwrap();
990 assert_eq!(a.beta, b.beta);
991 assert_eq!(a.df, b.df);
992 }
993
994 #[test]
1002 fn test_random_intercept_df_is_scale_invariant() {
1003 let f = unbalanced_fixture();
1004 let x = design(&[&f.x, &f.q, &f.tq]);
1005 let base = {
1006 let stats = RandomInterceptStats::from_design(x.as_ref(), &f.y, &f.groups, 7).unwrap();
1007 fit_random_intercept(&stats, None).unwrap()
1008 };
1009
1010 for scale in [1e-6, 1e-3, 1e3, 1e6] {
1011 let scaled: Vec<f64> = f.y.iter().map(|v| v * scale).collect();
1012 let stats =
1013 RandomInterceptStats::from_design(x.as_ref(), &scaled, &f.groups, 7).unwrap();
1014 let fit = fit_random_intercept(&stats, None).unwrap();
1015
1016 assert_relative_eq!(fit.lambda, base.lambda, max_relative = 1e-6);
1017 for j in 0..4 {
1018 assert_relative_eq!(fit.df[j], base.df[j], max_relative = 1e-4);
1019 assert_relative_eq!(fit.p_value[j], base.p_value[j], max_relative = 1e-4);
1020 assert_relative_eq!(fit.beta[j], base.beta[j] * scale, max_relative = 1e-8);
1022 }
1023 }
1024 }
1025
1026 #[test]
1028 fn test_random_intercept_flags_an_exhausted_bracket() {
1029 let groups: Vec<usize> = (0..8).flat_map(|g| std::iter::repeat_n(g, 5)).collect();
1030 let y: Vec<f64> = groups
1032 .iter()
1033 .enumerate()
1034 .map(|(i, &g)| g as f64 + (i % 5) as f64 * 1e-7)
1035 .collect();
1036 let x = Mat::<f64>::from_fn(y.len(), 1, |_, _| 1.0);
1037 let stats = RandomInterceptStats::from_design(x.as_ref(), &y, &groups, 8).unwrap();
1038 let fit = fit_random_intercept(&stats, None).unwrap();
1039
1040 assert!(fit.bracket_exhausted, "lambda was {}", fit.lambda);
1041 assert!(fit.df[0].is_nan());
1042 assert!(fit.p_value[0].is_nan());
1043 assert!(fit.beta[0].is_finite());
1045 assert_relative_eq!(fit.beta[0], 3.5, max_relative = 1e-6);
1046 }
1047
1048 #[test]
1051 fn test_random_intercept_accepts_a_small_scale_design() {
1052 let f = unbalanced_fixture();
1053 for scale in [1e-7, 1e-9] {
1054 let scaled_x: Vec<f64> = f.x.iter().map(|v| v * scale).collect();
1055 let scaled_q: Vec<f64> = f.q.iter().map(|v| v * scale).collect();
1056 let x = Mat::<f64>::from_fn(f.y.len(), 3, |i, j| match j {
1057 0 => scale,
1058 1 => scaled_x[i],
1059 _ => scaled_q[i],
1060 });
1061 let stats = RandomInterceptStats::from_design(x.as_ref(), &f.y, &f.groups, 7).unwrap();
1062 assert!(
1063 fit_random_intercept(&stats, None).is_ok(),
1064 "rejected a full-rank design scaled by {scale}"
1065 );
1066 }
1067 }
1068
1069 #[test]
1070 fn test_random_intercept_rejects_bad_input() {
1071 let f = unbalanced_fixture();
1072 let x = design(&[&f.x]);
1073 assert!(RandomInterceptStats::from_design(x.as_ref(), &f.y[..10], &f.groups, 7).is_err());
1075 assert!(RandomInterceptStats::from_design(x.as_ref(), &f.y, &f.groups, 3).is_err());
1077 let one = vec![0usize; f.y.len()];
1079 assert!(RandomInterceptStats::from_design(x.as_ref(), &f.y, &one, 1).is_err());
1080 let dup = design(&[&f.x, &f.x]);
1082 let stats = RandomInterceptStats::from_design(dup.as_ref(), &f.y, &f.groups, 7).unwrap();
1083 assert!(fit_random_intercept(&stats, None).is_err());
1084 let mut poisoned = f.y.clone();
1087 poisoned[3] = f64::NAN;
1088 assert!(RandomInterceptStats::from_design(x.as_ref(), &poisoned, &f.groups, 7).is_err());
1089 let single = RandomInterceptStats {
1091 xtx: Mat::<f64>::from_fn(2, 2, |i, j| if i == j { 4.0 } else { 1.0 }),
1092 xty: vec![1.0, 2.0],
1093 yty: 10.0,
1094 n: 4,
1095 p: 2,
1096 group_n: vec![4.0],
1097 group_s: Mat::<f64>::from_fn(1, 2, |_, _| 1.0),
1098 group_t: vec![1.0],
1099 };
1100 assert!(fit_random_intercept(&single, None).is_err());
1101 }
1102}