Skip to main content

bixverse_core/core/math/
mixed_model.rs

1//! Linear mixed model with a single random intercept.
2//!
3//! Fits `y = X beta + Z b + e` with `b ~ N(0, sigma_b^2 I_G)`,
4//! `e ~ N(0, sigma^2 I_n)` and one grouping factor, by REML, and reports Wald
5//! tests on the fixed effects with Satterthwaite denominator degrees of
6//! freedom. That is `lmer(y ~ (1 | g) + ...)` read through `lmerTest`.
7
8use 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
18////////////
19// Consts //
20////////////
21
22/// Upper end of the bracket searched for the variance ratio.
23///
24/// `lambda` is between-group over within-group variance. `lme4` will happily
25/// report ratios in the millions when a grouping absorbs almost all the
26/// variation, so this is deliberately generous. Hitting it is still detected
27/// and reported rather than silently accepted: see
28/// [RandomInterceptFit::bracket_exhausted].
29const DEFAULT_LAMBDA_MAX: f64 = 1.0e6;
30
31/// Fraction of the bracket above which the fit is treated as having run out of
32/// room, and the inference withheld.
33const BRACKET_EDGE: f64 = 0.99;
34
35/// Convergence tolerance handed to Brent for the variance ratio.
36///
37/// Tighter than R's `optimize` default, because this is `lmer`'s only free
38/// parameter and everything downstream inherits its error. Do not tighten
39/// further: Brent stops at `sqrt(eps) * |x| + tol / 3`, so past roughly `1e-7`
40/// the first term dominates and a smaller tolerance buys iterations rather
41/// than digits. At this setting the ratio agrees with `lme4` to eight
42/// significant figures and the fixed effects to eleven.
43const DEFAULT_LAMBDA_TOL: f64 = 1.0e-8;
44
45/// Below this the variance ratio is treated as exactly zero.
46///
47/// `lme4` calls the same situation a singular fit. The model has collapsed to
48/// ordinary least squares, which is a legitimate answer, not a failure.
49const SINGULAR_LAMBDA: f64 = 1.0e-8;
50
51/// Relative step for the numerical gradient of the coefficient variance.
52const GRAD_STEP: f64 = 1.0e-5;
53
54/// Relative step for the numerical Hessian of the REML criterion.
55const HESSIAN_STEP: f64 = 1.0e-4;
56
57/// Absolute floor on the `theta` finite-difference step.
58///
59/// `theta` is a ratio of standard deviations, so it is dimensionless and
60/// `O(1)`, and this floor is what lets the central difference straddle the
61/// `theta = 0` boundary. `sigma` gets no such floor: it carries the units of
62/// the response, so flooring its step at an absolute value would make the
63/// degrees of freedom depend on the scale of `y`, which they are invariant to.
64const THETA_STEP_FLOOR: f64 = 1.0;
65
66/// Floor on the `sigma` step, present only to keep a degenerate fit finite.
67const SIGMA_STEP_FLOOR: f64 = 1.0e-300;
68
69//////////////////////////
70// RandomInterceptStats //
71//////////////////////////
72
73/// Sufficient statistics for a single-random-intercept fit.
74///
75/// Everything the REML criterion needs, reduced from the data once. See the
76/// module documentation for why this is the whole story.
77#[derive(Clone, Debug)]
78pub struct RandomInterceptStats {
79    /// `X'X`, `p x p`.
80    pub xtx: Mat<f64>,
81    /// `X'y`, length `p`.
82    pub xty: Vec<f64>,
83    /// `y'y`.
84    pub yty: f64,
85    /// Number of observations.
86    pub n: usize,
87    /// Number of fixed-effect columns.
88    pub p: usize,
89    /// Observations per group. Empty groups are dropped on construction, so
90    /// every entry is positive.
91    pub group_n: Vec<f64>,
92    /// Column sums of `X` within each group, `G x p`.
93    pub group_s: Mat<f64>,
94    /// Sum of `y` within each group, length `G`.
95    pub group_t: Vec<f64>,
96}
97
98impl RandomInterceptStats {
99    /// Reduces a design and response to the sufficient statistics.
100    ///
101    /// One `O(n p^2)` pass. Groups with no observations are dropped rather than
102    /// counted, so a caller may pass a level set wider than the data.
103    ///
104    /// ### Params
105    ///
106    /// * `x` - Design matrix, `n x p`. Include the intercept column yourself.
107    /// * `y` - Response, length `n`
108    /// * `groups` - Level code per observation, each in `0..n_groups`
109    /// * `n_groups` - Number of levels
110    ///
111    /// ### Returns
112    ///
113    /// The statistics, or [CoreErrors::ShapeMismatch] when the arguments
114    /// disagree in length and [CoreErrors::InvalidArgument] for an
115    /// out-of-range level code or fewer than two non-empty groups.
116    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        // A non-finite value would otherwise reach the optimiser, which reports
146        // `INFINITY` at every lambda, falls back to the boundary, and hands back
147        // a result labelled as a perfectly ordinary singular fit.
148        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        // drop empty levels; they contribute nothing and would put a zero in
173        // the log-determinant.
174        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    /// Number of groups.
204    ///
205    /// ### Returns
206    ///
207    /// The count of non-empty levels.
208    pub fn n_groups(&self) -> usize {
209        self.group_n.len()
210    }
211}
212
213//////////////////////////////////
214// Generalised least square fit //
215//////////////////////////////////
216
217/// Generalised least squares quantities at one variance ratio.
218#[derive(Clone, Debug)]
219struct GlsFit {
220    /// `(X'H^-1 X)^-1`.
221    a_inv: Mat<f64>,
222    /// `log |X'H^-1 X|`.
223    log_det_a: f64,
224    /// `sum_g log(1 + n_g lambda)`, which is `log |H|`.
225    log_det_h: f64,
226    /// The GLS coefficients.
227    beta: Vec<f64>,
228    /// `r' H^-1 r` at those coefficients.
229    rss: f64,
230}
231
232/// Solves the GLS system at a given variance ratio.
233///
234/// ### Params
235///
236/// * `s` - Sufficient statistics
237/// * `lambda` - Variance ratio `sigma_b^2 / sigma^2`, non-negative
238///
239/// ### Returns
240///
241/// The [GlsFit], or [CoreErrors::InvalidArgument] when `X'H^-1 X` is
242/// numerically singular, which means the design is rank deficient.
243fn 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    // X'H^-1 X is positive definite for a full-rank design, so a Cholesky both
271    // gives the log-determinant and doubles as the rank test. Working in logs
272    // keeps it finite for a design that is merely badly scaled.
273    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    // r'H^-1 r = y'H^-1 y - 2 beta' X'H^-1 y + beta' X'H^-1 X beta.
287    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
302/// REML criterion profiled over the residual variance.
303///
304/// `-2 log L_REML` with `sigma^2` replaced by its conditional maximiser, so the
305/// only free parameter left is the variance ratio.
306///
307/// ### Params
308///
309/// * `s` - Sufficient statistics
310/// * `lambda` - Variance ratio
311///
312/// ### Returns
313///
314/// The criterion, or `f64::INFINITY` where the solve fails, so an optimiser
315/// walks away from it rather than stopping.
316fn 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
328/// REML criterion in `lmerTest`'s parametrisation, unprofiled.
329///
330/// `-2 log L_REML` as a function of `(theta, sigma)`, where
331/// `theta = sigma_b / sigma`. Two things about this matter and neither is
332/// arbitrary. The Satterthwaite denominator comes from a *numerical* Hessian,
333/// which is not invariant to reparametrisation even though the degrees of
334/// freedom are, so it has to be taken in the same coordinates `lmerTest` uses.
335/// And squaring `theta` makes the criterion even in it, which is what lets the
336/// central differences below work unchanged at the `theta = 0` boundary.
337///
338/// ### Params
339///
340/// * `s` - Sufficient statistics
341/// * `theta` - Ratio of the between-group to the residual standard deviation
342/// * `sigma` - Residual standard deviation
343///
344/// ### Returns
345///
346/// The criterion, or `f64::INFINITY` where the solve fails.
347fn 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
363/// Variance of one fixed-effect contrast, as a function of `(theta, sigma)`.
364///
365/// ### Params
366///
367/// * `s` - Sufficient statistics
368/// * `theta` - Standard deviation ratio
369/// * `sigma` - Residual standard deviation
370/// * `coef` - Index of the coefficient
371///
372/// ### Returns
373///
374/// `sigma^2 (X'H^-1 X)^-1_[coef, coef]`, or `f64::NAN` where the solve fails.
375fn 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///////////////////////////
383// RandomInterceptParams //
384///////////////////////////
385
386/// Tuning knobs for [fit_random_intercept].
387#[derive(Clone, Copy, Debug)]
388pub struct RandomInterceptParams {
389    /// Upper end of the bracket searched for the variance ratio.
390    pub lambda_max: f64,
391    /// Convergence tolerance for the variance ratio.
392    pub lambda_tol: f64,
393    /// Compute Satterthwaite degrees of freedom. Turning this off leaves `df`
394    /// at the residual value `n - p` and skips a numerical Hessian and one
395    /// gradient per coefficient, which is worth doing when fitting at scale and
396    /// the group count is large enough that the two agree anyway. An exhausted
397    /// bracket still yields `NaN` regardless of this flag.
398    pub satterthwaite: bool,
399}
400
401impl RandomInterceptParams {
402    /// Builds a parameter set.
403    ///
404    /// ### Params
405    ///
406    /// * `lambda_max` - Upper end of the variance-ratio bracket
407    /// * `lambda_tol` - Convergence tolerance for the variance ratio
408    /// * `satterthwaite` - Compute Satterthwaite degrees of freedom
409    ///
410    /// ### Returns
411    ///
412    /// The parameter set.
413    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////////////////////////
433// RandomInterceptFit //
434////////////////////////
435
436/// A fitted single-random-intercept model.
437#[derive(Clone, Debug)]
438pub struct RandomInterceptFit {
439    /// Fixed-effect coefficients.
440    pub beta: Vec<f64>,
441    /// Standard errors of the coefficients.
442    pub se: Vec<f64>,
443    /// Wald t statistics.
444    pub t_stat: Vec<f64>,
445    /// Denominator degrees of freedom per coefficient. Satterthwaite by
446    /// default, `n - p` when it is switched off, and `NaN` when the variance
447    /// ratio ran past its bracket, which withholds the inference either way.
448    pub df: Vec<f64>,
449    /// Two-sided p-values.
450    pub p_value: Vec<f64>,
451    /// Residual variance.
452    pub sigma_sq: f64,
453    /// Between-group variance.
454    pub sigma_b_sq: f64,
455    /// The fitted variance ratio `sigma_b^2 / sigma^2`.
456    pub lambda: f64,
457    /// Whether the fit landed on the `lambda = 0` boundary. `lme4` calls this
458    /// singular. The model has collapsed to ordinary least squares; the
459    /// coefficients are still the right answer for the data.
460    pub singular: bool,
461    /// Whether the search ran out of bracket at the far end.
462    pub bracket_exhausted: bool,
463}
464
465//////////////////////////
466// Random intercept fit //
467//////////////////////////
468
469/// Numerical Hessian of the REML criterion in `(theta, sigma)`.
470///
471/// Central differences on the four corner points, which is the same scheme
472/// `lmerTest` reaches for through `numDeriv`. Symmetrised, because the two
473/// mixed partials differ at the level of the truncation error.
474///
475/// ### Params
476///
477/// * `s` - Sufficient statistics
478/// * `theta` - Fitted standard deviation ratio
479/// * `sigma` - Fitted residual standard deviation
480///
481/// ### Returns
482///
483/// The `2 x 2` Hessian, row-major.
484fn 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
516/// Satterthwaite denominator degrees of freedom for one coefficient.
517///
518/// `nu = 2 C^2 / (g' A g)` with `C` the coefficient variance, `g` its gradient
519/// in `(theta, sigma)`, and `A = 2 Hess^-1` the asymptotic covariance of the
520/// variance parameters.
521///
522/// At a singular fit this returns the residual degrees of freedom on its own,
523/// with no special case: the criterion and the coefficient variance both depend
524/// on `theta` only through `theta^2`, so the `theta` component of the gradient
525/// vanishes at the boundary and the formula collapses to the ordinary least
526/// squares answer.
527///
528/// ### Params
529///
530/// * `s` - Sufficient statistics
531/// * `theta` - Fitted standard deviation ratio
532/// * `sigma` - Fitted residual standard deviation
533/// * `coef` - Index of the coefficient
534/// * `variance` - Its variance at the fitted parameters
535/// * `hessian` - The `2 x 2` REML Hessian, row-major
536///
537/// ### Returns
538///
539/// The degrees of freedom, or `None` when the Hessian is singular or the
540/// denominator collapses, in which case the caller should fall back.
541fn 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    // A = 2 * Hess^-1, spelled out because it is only 2 x 2.
554    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
592/// Fits a single-random-intercept linear mixed model by REML.
593///
594/// The variance ratio is found by Brent on the profiled criterion, then the
595/// coefficients, standard errors and Satterthwaite degrees of freedom follow in
596/// closed form. Cost is `O(iterations * G p^2)` given the statistics, with `G`
597/// the number of groups and `p` the number of fixed effects: nothing here
598/// touches the observations again.
599///
600/// ### Params
601///
602/// * `stats` - Sufficient statistics from [RandomInterceptStats::from_design]
603/// * `params` - Optional [RandomInterceptParams]; the default otherwise
604///
605/// ### Returns
606///
607/// The [RandomInterceptFit], or [CoreErrors::InvalidArgument] for a rank
608/// deficient design or a model with no residual degrees of freedom.
609///
610/// ### References
611///
612/// Kuznetsova, Brockhoff & Christensen, Journal of Statistical Software 82(13),
613/// 2017
614pub 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    // Brent never evaluates the ends of the bracket, and the boundary is where
640    // a grouping that explains nothing lands. Take it if it wins.
641    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///////////
701// Tests //
702///////////
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707    use approx::assert_relative_eq;
708
709    // Initial implementation focusses on DIALOGUE.
710    // Every reference value below came out of lme4 3.1-x with lmerTest on this
711    // machine, printed with "%.17g".
712
713    /// The DIALOGUE shape: `y` and `q` vary per observation, `x` and `tq` are
714    /// group-level and constant within a group, and the groups are unbalanced.
715    /// Seven groups of sizes 7, 4, 9, 5, 6, 3, 8.
716    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    /// Assembles a design from an intercept plus the named columns.
757    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    /// The full DIALOGUE-shaped model against
769    /// `lmer(y ~ (1 | samples) + x + q + tq)`.
770    #[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    /// Only the group-level predictor: `lmer(y ~ (1 | samples) + x)`.
819    #[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    /// Only the within-group covariate: `lmer(y ~ (1 | samples) + q)`. This is
835    /// the opposite extreme for Satterthwaite, and the dof lands near the
836    /// residual count rather than the group count.
837    #[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    /// Balanced design, six groups of five.
852    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    /// The balanced case has a closed-form REML solution, so this gate needs no
865    /// reference implementation at all: `sigma^2` is the within-group mean
866    /// square and `sigma_b^2` is `(MS_between - MS_within) / n_per_group`.
867    #[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        // One-way mean squares, computed here rather than quoted.
877        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        // And the intercept gets the containment dof, G - 1.
896        assert_relative_eq!(fit.df[0], 5.0, max_relative = 1e-6);
897    }
898
899    /// A group-level predictor on a balanced design gets `G - 2` degrees of
900    /// freedom, which is what `lmerTest` reports.
901    #[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    /// No between-group variance at all: the optimum sits on the boundary,
916    /// `lme4` calls the fit singular, and the model collapses to ordinary least
917    /// squares with the residual degrees of freedom.
918    ///
919    /// The dof falls out of the general formula rather than a special case,
920    /// because the criterion depends on `theta` only through `theta^2`.
921    #[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        // n - p = 30 - 2.
947        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    /// Switching Satterthwaite off leaves the estimates alone and falls back to
952    /// the residual degrees of freedom.
953    #[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        // The within-group covariate is the one the two nearly agree on.
974        assert!((with.df[2] - 38.0).abs() < 5.0);
975        // The group-level ones are nowhere near it.
976        assert!(with.df[1] < 5.0);
977    }
978
979    /// Empty levels are dropped rather than counted, so widening the level set
980    /// changes nothing.
981    #[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    /// The degrees of freedom must not depend on the scale of the response.
995    ///
996    /// `y -> c*y` leaves `lambda` and every dof mathematically unchanged. This
997    /// caught a real bug: flooring the `sigma` finite-difference step at an
998    /// absolute 1.0 made the step a 15x perturbation once `sigma` fell below
999    /// 1e-4, and the reported dof silently collapsed. DIALOGUE feeds this
1000    /// arbitrarily scaled projections, so the small-`sigma` regime is normal.
1001    #[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                // The estimates themselves scale linearly.
1021                assert_relative_eq!(fit.beta[j], base.beta[j] * scale, max_relative = 1e-8);
1022            }
1023        }
1024    }
1025
1026    /// A variance ratio past the bracket withholds the inference.
1027    #[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        // Group means an order of magnitude apart, jitter at 1e-7.
1031        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        // The estimate itself is still meaningful and still reported.
1044        assert!(fit.beta[0].is_finite());
1045        assert_relative_eq!(fit.beta[0], 3.5, max_relative = 1e-6);
1046    }
1047
1048    /// A well-conditioned design must not be called rank deficient just for
1049    /// being small.
1050    #[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        // Response and design of different lengths.
1074        assert!(RandomInterceptStats::from_design(x.as_ref(), &f.y[..10], &f.groups, 7).is_err());
1075        // Group code past the declared level count.
1076        assert!(RandomInterceptStats::from_design(x.as_ref(), &f.y, &f.groups, 3).is_err());
1077        // A single group leaves nothing for the random intercept to explain.
1078        let one = vec![0usize; f.y.len()];
1079        assert!(RandomInterceptStats::from_design(x.as_ref(), &f.y, &one, 1).is_err());
1080        // A duplicated column is rank deficient.
1081        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        // A non-finite entry must error rather than be reported as a singular
1085        // fit, which is what it silently looked like before.
1086        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        // A single group leaves the random intercept unidentified.
1090        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}