Skip to main content

regression_diagnostics/mixed/
lmm.rs

1use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
2
3use crate::error::{RegressionError, Result};
4use crate::linalg::dmatrix_from_rows;
5
6/// Estimation method for the variance components.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Method {
9    /// **Restricted** maximum likelihood — unbiased variance components; the
10    /// default and the standard choice for inference on the random effects.
11    Reml,
12    /// Ordinary maximum likelihood — variance components biased downward, but the
13    /// likelihoods are comparable across models with different fixed effects.
14    Ml,
15}
16
17/// A fitted **random-intercept linear mixed model**
18///
19/// `yᵢⱼ = xᵢⱼᵀβ + bⱼ + εᵢⱼ`,  `bⱼ ~ N(0, σ²_b)`,  `εᵢⱼ ~ N(0, σ²_e)`,
20///
21/// for observations `i` nested in groups `j` (one random intercept per group).
22/// The marginal covariance is `V = σ²_e I + σ²_b ZZᵀ`; because there is a single
23/// grouping factor, `V⁻¹` is block-diagonal in closed form, so the whole fit
24/// reduces to a **one-dimensional search** over the variance ratio
25/// `λ = σ²_b/σ²_e`, profiling out `β` (by GLS) and `σ²_e` analytically at each
26/// `λ`. [`Method::Reml`] (default) gives unbiased variance components.
27///
28/// The headline diagnostics are the **variance components**, the **intraclass
29/// correlation** `ICC = σ²_b/(σ²_b + σ²_e)` (the share of variance between
30/// groups, and the correlation of two observations in the same group), and the
31/// **BLUPs** — shrinkage-predicted group intercepts.
32#[derive(Debug, Clone)]
33pub struct LinearMixedModel {
34    coefficients: Array1<f64>,
35    cov_beta: Array2<f64>,
36    var_residual: f64,
37    var_group: f64,
38    lambda: f64,
39    blups: Array1<f64>,
40    log_likelihood: f64,
41    method: Method,
42    n: usize,
43    p: usize,
44    n_groups: usize,
45}
46
47impl LinearMixedModel {
48    /// Fit a random-intercept model of `y` on fixed-effect design `X` with group
49    /// labels `groups` (one per observation; arbitrary integer labels are
50    /// remapped internally), by REML.
51    ///
52    /// `X` carries the fixed effects including any intercept column, as
53    /// elsewhere in the crate.
54    ///
55    /// # Errors
56    ///
57    /// * [`RegressionError::EmptyInput`] / [`RegressionError::ShapeMismatch`].
58    /// * [`RegressionError::NoResidualDegreesOfFreedom`] if `n ≤ p`.
59    /// * [`RegressionError::InvalidResponse`] if there are fewer than two groups.
60    /// * [`RegressionError::RankDeficient`] if the GLS information is singular.
61    pub fn new(x: Array2<f64>, y: Array1<f64>, groups: &[usize]) -> Result<Self> {
62        Self::with_method(x, y, groups, Method::Reml)
63    }
64
65    /// Like [`LinearMixedModel::new`] with an explicit [`Method`].
66    pub fn with_method(
67        x: Array2<f64>,
68        y: Array1<f64>,
69        groups: &[usize],
70        method: Method,
71    ) -> Result<Self> {
72        let n = x.nrows();
73        let p = x.ncols();
74        if n == 0 || p == 0 {
75            return Err(RegressionError::EmptyInput { what: "X" });
76        }
77        if y.len() != n || groups.len() != n {
78            return Err(RegressionError::ShapeMismatch {
79                what: "y/groups length vs X rows",
80                expected: n,
81                got: y.len().min(groups.len()),
82            });
83        }
84        if n <= p {
85            return Err(RegressionError::NoResidualDegreesOfFreedom {
86                n,
87                p,
88                df: n as isize - p as isize,
89            });
90        }
91
92        // Densify group labels to 0..g-1 and collect per-group row indices.
93        let mut label_to_idx = std::collections::BTreeMap::new();
94        for &g in groups {
95            let next = label_to_idx.len();
96            label_to_idx.entry(g).or_insert(next);
97        }
98        let g = label_to_idx.len();
99        if g < 2 {
100            return Err(RegressionError::InvalidResponse {
101                msg: "a mixed model needs at least two groups".into(),
102            });
103        }
104        let mut group_rows: Vec<Vec<usize>> = vec![Vec::new(); g];
105        for (i, &lab) in groups.iter().enumerate() {
106            group_rows[label_to_idx[&lab]].push(i);
107        }
108
109        // Precompute the sufficient statistics reused at every λ.
110        let xtx = x.t().dot(&x); // p × p
111        let xty = x.t().dot(&y); // p
112        let yty: f64 = y.iter().map(|v| v * v).sum();
113        // Per-group column sums s_j (p) and response sums t_j, sizes n_j.
114        let mut s = vec![vec![0.0f64; p]; g];
115        let mut t = vec![0.0f64; g];
116        let mut sizes = vec![0usize; g];
117        for (j, rows) in group_rows.iter().enumerate() {
118            sizes[j] = rows.len();
119            for &i in rows {
120                t[j] += y[i];
121                for a in 0..p {
122                    s[j][a] += x[(i, a)];
123                }
124            }
125        }
126
127        let ctx = ProfileCtx {
128            xtx: &xtx,
129            xty: &xty,
130            yty,
131            s: &s,
132            t: &t,
133            sizes: &sizes,
134            n,
135            p,
136            g,
137            method,
138        };
139
140        // Minimize the profiled objective over η = λ/(1+λ) ∈ [0, 1) via golden
141        // section, so the whole non-negative range of λ maps to a bounded box.
142        let phi = (5.0_f64.sqrt() - 1.0) / 2.0;
143        let (mut lo, mut hi) = (0.0_f64, 1.0 - 1e-9);
144        let mut c = hi - phi * (hi - lo);
145        let mut d = lo + phi * (hi - lo);
146        let mut fc = ctx.objective(eta_to_lambda(c))?;
147        let mut fd = ctx.objective(eta_to_lambda(d))?;
148        for _ in 0..200 {
149            if fc < fd {
150                hi = d;
151                d = c;
152                fd = fc;
153                c = hi - phi * (hi - lo);
154                fc = ctx.objective(eta_to_lambda(c))?;
155            } else {
156                lo = c;
157                c = d;
158                fc = fd;
159                d = lo + phi * (hi - lo);
160                fd = ctx.objective(eta_to_lambda(d))?;
161            }
162            if (hi - lo) < 1e-10 {
163                break;
164            }
165        }
166        let eta_hat = 0.5 * (lo + hi);
167        let lambda = eta_to_lambda(eta_hat);
168
169        // Final quantities at λ̂.
170        let sol = ctx.solve(lambda)?;
171        let dof = match method {
172            Method::Reml => (n - p) as f64,
173            Method::Ml => n as f64,
174        };
175        let var_residual = sol.rmr / dof;
176        let var_group = lambda * var_residual;
177        let cov_beta = &sol.xtmx_inv * var_residual;
178
179        // BLUPs: b̂_j = (λ n_j)/(1 + λ n_j) · mean group residual.
180        let mut blups = Array1::<f64>::zeros(g);
181        let fitted_fixed = x.dot(&sol.beta);
182        for (j, rows) in group_rows.iter().enumerate() {
183            if rows.is_empty() {
184                continue;
185            }
186            let rbar: f64 = rows.iter().map(|&i| y[i] - fitted_fixed[i]).sum::<f64>()
187                / rows.len() as f64;
188            let nj = rows.len() as f64;
189            blups[j] = (lambda * nj / (1.0 + lambda * nj)) * rbar;
190        }
191
192        let log_likelihood = -0.5 * ctx.objective(lambda)? - ctx.log_const();
193
194        Ok(Self {
195            coefficients: sol.beta,
196            cov_beta,
197            var_residual,
198            var_group,
199            lambda,
200            blups,
201            log_likelihood,
202            method,
203            n,
204            p,
205            n_groups: g,
206        })
207    }
208
209    /// Number of observations.
210    pub fn n_observations(&self) -> usize {
211        self.n
212    }
213
214    /// Number of fixed-effect coefficients.
215    pub fn n_parameters(&self) -> usize {
216        self.p
217    }
218
219    /// Number of groups (levels of the random intercept).
220    pub fn n_groups(&self) -> usize {
221        self.n_groups
222    }
223
224    /// Estimation method used.
225    pub fn method(&self) -> Method {
226        self.method
227    }
228
229    /// Fixed-effect coefficients `β̂` (GLS at the estimated variance ratio).
230    pub fn coefficients(&self) -> ArrayView1<'_, f64> {
231        self.coefficients.view()
232    }
233
234    /// Fixed-effect covariance `σ̂²_e (XᵀV⁻¹X)⁻¹`.
235    pub fn covariance(&self) -> ArrayView2<'_, f64> {
236        self.cov_beta.view()
237    }
238
239    /// Fixed-effect standard errors.
240    pub fn coefficient_standard_errors(&self) -> Array1<f64> {
241        Array1::from_shape_fn(self.p, |j| self.cov_beta[(j, j)].max(0.0).sqrt())
242    }
243
244    /// Residual (within-group) variance `σ̂²_e`.
245    pub fn residual_variance(&self) -> f64 {
246        self.var_residual
247    }
248
249    /// Between-group (random-intercept) variance `σ̂²_b`.
250    pub fn group_variance(&self) -> f64 {
251        self.var_group
252    }
253
254    /// Estimated variance ratio `λ̂ = σ̂²_b / σ̂²_e`.
255    pub fn variance_ratio(&self) -> f64 {
256        self.lambda
257    }
258
259    /// **Intraclass correlation** `ICC = σ̂²_b / (σ̂²_b + σ̂²_e)` — the fraction of
260    /// total variance attributable to between-group differences, equivalently the
261    /// correlation between two observations in the same group.
262    pub fn icc(&self) -> f64 {
263        let total = self.var_group + self.var_residual;
264        if total > 0.0 {
265            self.var_group / total
266        } else {
267            f64::NAN
268        }
269    }
270
271    /// **BLUPs** — best linear unbiased predictors of the group random
272    /// intercepts `b̂_j`, indexed by densified group order (first-seen order of
273    /// the labels). Each is the group's mean residual shrunk toward zero by
274    /// `(λ n_j)/(1 + λ n_j)`.
275    pub fn random_effects(&self) -> ArrayView1<'_, f64> {
276        self.blups.view()
277    }
278
279    /// The profile log-likelihood (REML or ML per [`method`](Self::method)) at
280    /// the estimated variance components.
281    pub fn log_likelihood(&self) -> f64 {
282        self.log_likelihood
283    }
284
285    /// AIC. Under ML the parameter count is `p + 2` (fixed effects plus the two
286    /// variance components); under REML, where fixed effects are integrated out,
287    /// only the two variance components are counted.
288    pub fn aic(&self) -> f64 {
289        let k = match self.method {
290            Method::Ml => self.p as f64 + 2.0,
291            Method::Reml => 2.0,
292        };
293        -2.0 * self.log_likelihood + 2.0 * k
294    }
295}
296
297fn eta_to_lambda(eta: f64) -> f64 {
298    eta / (1.0 - eta)
299}
300
301/// Cached statistics for the profiled-likelihood search.
302struct ProfileCtx<'a> {
303    xtx: &'a Array2<f64>,
304    xty: &'a Array1<f64>,
305    yty: f64,
306    s: &'a [Vec<f64>],
307    t: &'a [f64],
308    sizes: &'a [usize],
309    n: usize,
310    p: usize,
311    g: usize,
312    method: Method,
313}
314
315struct Solve {
316    beta: Array1<f64>,
317    xtmx_inv: Array2<f64>,
318    rmr: f64,
319}
320
321impl ProfileCtx<'_> {
322    /// GLS solve at a given `λ`: β̂, `(XᵀMX)⁻¹`, and the residual form `rᵀMr`.
323    fn solve(&self, lambda: f64) -> Result<Solve> {
324        let p = self.p;
325        // XᵀMX = XᵀX − Σ_j c_j s_j s_jᵀ,  XᵀMy = Xᵀy − Σ_j c_j s_j t_j,
326        // yᵀMy = yᵀy − Σ_j c_j t_j²,  c_j = λ/(1 + λ n_j).
327        let mut xtmx = self.xtx.clone();
328        let mut xtmy = self.xty.clone();
329        let mut ytmy = self.yty;
330        for j in 0..self.g {
331            let nj = self.sizes[j] as f64;
332            let cj = lambda / (1.0 + lambda * nj);
333            if cj == 0.0 {
334                continue;
335            }
336            let sj = &self.s[j];
337            let tj = self.t[j];
338            for a in 0..p {
339                xtmy[a] -= cj * sj[a] * tj;
340                for b in 0..p {
341                    xtmx[(a, b)] -= cj * sj[a] * sj[b];
342                }
343            }
344            ytmy -= cj * tj * tj;
345        }
346
347        let dm = dmatrix_from_rows(p, p, xtmx.as_standard_layout().as_slice().unwrap());
348        let inv = dm.try_inverse().ok_or(RegressionError::RankDeficient)?;
349        let xtmx_inv = Array2::from_shape_fn((p, p), |(i, j)| inv[(i, j)]);
350        let beta = xtmx_inv.dot(&xtmy);
351        // rᵀMr = yᵀMy − β̂ᵀ XᵀMy.
352        let rmr = ytmy - beta.dot(&xtmy);
353        Ok(Solve {
354            beta,
355            xtmx_inv,
356            rmr,
357        })
358    }
359
360    /// The profiled objective `−2ℓ` (up to the additive constant from
361    /// [`log_const`]) to be minimized over `λ`.
362    fn objective(&self, lambda: f64) -> Result<f64> {
363        let sol = self.solve(lambda)?;
364        let dof = match self.method {
365            Method::Reml => (self.n - self.p) as f64,
366            Method::Ml => self.n as f64,
367        };
368        let sigma2 = (sol.rmr / dof).max(1e-300);
369        // ln|A| = Σ_j ln(1 + λ n_j).
370        let ln_det_a: f64 = (0..self.g)
371            .map(|j| (1.0 + lambda * self.sizes[j] as f64).ln())
372            .sum();
373        let mut obj = dof * sigma2.ln() + ln_det_a;
374        if self.method == Method::Reml {
375            // + ln det(XᵀMX): recompute the determinant from XᵀMX.
376            let p = self.p;
377            let mut xtmx = self.xtx.clone();
378            for j in 0..self.g {
379                let nj = self.sizes[j] as f64;
380                let cj = lambda / (1.0 + lambda * nj);
381                if cj == 0.0 {
382                    continue;
383                }
384                let sj = &self.s[j];
385                for a in 0..p {
386                    for b in 0..p {
387                        xtmx[(a, b)] -= cj * sj[a] * sj[b];
388                    }
389                }
390            }
391            let dm = dmatrix_from_rows(p, p, xtmx.as_standard_layout().as_slice().unwrap());
392            let det = dm.determinant();
393            obj += det.abs().max(1e-300).ln();
394        }
395        Ok(obj)
396    }
397
398    /// The additive constant so that `log_likelihood = −½·objective − log_const`.
399    fn log_const(&self) -> f64 {
400        let dof = match self.method {
401            Method::Reml => (self.n - self.p) as f64,
402            Method::Ml => self.n as f64,
403        };
404        0.5 * dof * ((2.0 * std::f64::consts::PI).ln() + 1.0)
405    }
406}