Skip to main content

regression_diagnostics/mixed/
glmm.rs

1use ndarray::{Array1, Array2, ArrayView1};
2use statrs::distribution::{ContinuousCDF, Normal};
3use statrs::function::gamma::ln_gamma;
4
5use crate::error::{RegressionError, Result};
6use crate::linalg::dmatrix_from_rows;
7use crate::optimize::{nelder_mead, numerical_hessian};
8
9/// Conditional response family for a [`GlmmFit`].
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum GlmmFamily {
12    /// **Poisson** counts with a log link.
13    Poisson,
14    /// **Bernoulli** `0/1` responses with a logit link.
15    Binomial,
16}
17
18impl GlmmFamily {
19    fn inverse_link(self, eta: f64) -> f64 {
20        match self {
21            GlmmFamily::Poisson => eta.exp(),
22            GlmmFamily::Binomial => {
23                if eta >= 0.0 {
24                    1.0 / (1.0 + (-eta).exp())
25                } else {
26                    let e = eta.exp();
27                    e / (1.0 + e)
28                }
29            }
30        }
31    }
32
33    /// Per-observation conditional log-likelihood `ℓ(yᵢ | ηᵢ)`.
34    fn loglik(self, y: f64, eta: f64) -> f64 {
35        match self {
36            GlmmFamily::Poisson => y * eta - eta.exp() - ln_gamma(y + 1.0),
37            GlmmFamily::Binomial => {
38                // y·η − ln(1 + e^η), stable.
39                let lse = if eta > 0.0 {
40                    eta + (-eta).exp().ln_1p()
41                } else {
42                    eta.exp().ln_1p()
43                };
44                y * eta - lse
45            }
46        }
47    }
48
49    /// GLM weight `wᵢ = −∂²ℓ/∂η² = V(μ)` at the canonical link (μ for Poisson,
50    /// μ(1−μ) for Bernoulli).
51    fn weight(self, mu: f64) -> f64 {
52        match self {
53            GlmmFamily::Poisson => mu,
54            GlmmFamily::Binomial => mu * (1.0 - mu),
55        }
56    }
57
58    fn validate(self, y: &Array1<f64>) -> Result<()> {
59        match self {
60            GlmmFamily::Poisson => {
61                for &v in y.iter() {
62                    if !v.is_finite() || v < 0.0 {
63                        return Err(RegressionError::InvalidResponse {
64                            msg: format!("Poisson GLMM response must be a non-negative count, found {v}"),
65                        });
66                    }
67                }
68            }
69            GlmmFamily::Binomial => {
70                for &v in y.iter() {
71                    if v != 0.0 && v != 1.0 {
72                        return Err(RegressionError::InvalidResponse {
73                            msg: format!("binomial GLMM response must be 0 or 1, found {v}"),
74                        });
75                    }
76                }
77            }
78        }
79        Ok(())
80    }
81}
82
83/// A fitted **random-intercept generalized linear mixed model** (GLMM),
84///
85/// `g(E[yᵢⱼ | bⱼ]) = xᵢⱼᵀβ + bⱼ`,  `bⱼ ~ N(0, σ_b²)`,
86///
87/// for a non-Gaussian conditional [`GlmmFamily`] (Poisson counts or Bernoulli
88/// outcomes) — the generalized counterpart to
89/// [`LinearMixedModel`](super::LinearMixedModel). The intractable integral over
90/// the random effects is handled by the **Laplace approximation**: an inner
91/// Newton loop finds each group's conditional mode, and an outer Nelder–Mead
92/// search maximizes the resulting approximate marginal likelihood over the fixed
93/// effects `β` and the random-intercept standard deviation `σ_b`.
94///
95/// Fixed-effect standard errors come from the numerical observed information of
96/// the Laplace log-likelihood. The random-intercept variance is reported as
97/// `σ_b`; as it shrinks toward zero the model approaches the corresponding plain
98/// GLM.
99#[derive(Debug, Clone)]
100pub struct GlmmFit {
101    family: GlmmFamily,
102    coefficients: Array1<f64>,
103    sigma_b: f64,
104    cov_beta: Array2<f64>,
105    blups: Array1<f64>,
106    log_likelihood: f64,
107    n: usize,
108    p: usize,
109    n_groups: usize,
110}
111
112impl GlmmFit {
113    /// Fit a random-intercept GLMM of `y` on fixed-effect design `X` with group
114    /// labels `groups`, under `family`, by Laplace-approximate maximum
115    /// likelihood.
116    ///
117    /// `X` carries the fixed effects including an intercept.
118    ///
119    /// # Errors
120    ///
121    /// * [`RegressionError::EmptyInput`] / [`RegressionError::ShapeMismatch`].
122    /// * [`RegressionError::InvalidResponse`] for out-of-support responses or
123    ///   fewer than two groups.
124    /// * [`RegressionError::NotConverged`] if the optimizer fails to find a finite
125    ///   optimum.
126    pub fn new(
127        x: Array2<f64>,
128        y: Array1<f64>,
129        groups: &[usize],
130        family: GlmmFamily,
131    ) -> Result<Self> {
132        let n = x.nrows();
133        let p = x.ncols();
134        if n == 0 || p == 0 {
135            return Err(RegressionError::EmptyInput { what: "X" });
136        }
137        if y.len() != n || groups.len() != n {
138            return Err(RegressionError::ShapeMismatch {
139                what: "y/groups length vs X rows",
140                expected: n,
141                got: y.len().min(groups.len()),
142            });
143        }
144        family.validate(&y)?;
145
146        // Densify groups and collect per-group row indices.
147        let mut map = std::collections::BTreeMap::new();
148        for &g in groups {
149            let next = map.len();
150            map.entry(g).or_insert(next);
151        }
152        let n_groups = map.len();
153        if n_groups < 2 {
154            return Err(RegressionError::InvalidResponse {
155                msg: "a GLMM needs at least two groups".into(),
156            });
157        }
158        let mut group_rows: Vec<Vec<usize>> = vec![Vec::new(); n_groups];
159        for (i, &lab) in groups.iter().enumerate() {
160            group_rows[map[&lab]].push(i);
161        }
162
163        // Parameter vector θ = (β, ln σ_b). Negative Laplace log-likelihood.
164        let neg_ll = |theta: &[f64]| -> f64 {
165            let sigma_b = theta[p].exp();
166            if !sigma_b.is_finite() || sigma_b <= 0.0 {
167                return f64::INFINITY;
168            }
169            match laplace_loglik(&x, &y, &group_rows, theta, sigma_b, family) {
170                Some((ll, _)) if ll.is_finite() => -ll,
171                _ => f64::INFINITY,
172            }
173        };
174
175        // Warm start: intercept-only mean on the link scale, β slopes 0, σ_b = 0.5.
176        let mut theta0 = vec![0.0; p + 1];
177        let ybar = y.sum() / n as f64;
178        theta0[0] = match family {
179            GlmmFamily::Poisson => ybar.max(1e-3).ln(),
180            GlmmFamily::Binomial => (ybar.clamp(1e-3, 1.0 - 1e-3)
181                / (1.0 - ybar.clamp(1e-3, 1.0 - 1e-3)))
182            .ln(),
183        };
184        theta0[p] = 0.5_f64.ln();
185
186        let neg_ll_ref = &neg_ll;
187        let theta = nelder_mead(neg_ll_ref, &theta0, 0.2, 1e-9, 8000);
188        let sigma_b = theta[p].exp();
189        let (ll, blups_vec) =
190            laplace_loglik(&x, &y, &group_rows, &theta, sigma_b, family).ok_or(
191                RegressionError::NotConverged {
192                    iterations: 8000,
193                    msg: "GLMM Laplace optimizer failed to find a finite optimum".into(),
194                },
195            )?;
196
197        // Fixed-effect covariance from the numerical observed information.
198        let grad = |t: &[f64]| -> Vec<f64> {
199            let mut g = vec![0.0; p + 1];
200            for j in 0..=p {
201                let h = 1e-5 * t[j].abs().max(1.0);
202                let mut tp = t.to_vec();
203                let mut tm = t.to_vec();
204                tp[j] += h;
205                tm[j] -= h;
206                g[j] = (neg_ll(&tp) - neg_ll(&tm)) / (2.0 * h);
207            }
208            g
209        };
210        let hess = numerical_hessian(grad, &theta);
211        let flat: Vec<f64> = hess.iter().flat_map(|r| r.iter().copied()).collect();
212        let cov_beta = match dmatrix_from_rows(p + 1, p + 1, &flat).try_inverse() {
213            Some(inv) => Array2::from_shape_fn((p, p), |(i, j)| inv[(i, j)]),
214            None => Array2::from_elem((p, p), f64::NAN),
215        };
216
217        let coefficients = Array1::from_shape_fn(p, |j| theta[j]);
218        let blups = Array1::from(blups_vec);
219
220        Ok(Self {
221            family,
222            coefficients,
223            sigma_b,
224            cov_beta,
225            blups,
226            log_likelihood: ll,
227            n,
228            p,
229            n_groups,
230        })
231    }
232
233    /// The conditional family.
234    pub fn family(&self) -> GlmmFamily {
235        self.family
236    }
237
238    /// Number of observations.
239    pub fn n_observations(&self) -> usize {
240        self.n
241    }
242
243    /// Number of fixed-effect coefficients.
244    pub fn n_parameters(&self) -> usize {
245        self.p
246    }
247
248    /// Number of groups.
249    pub fn n_groups(&self) -> usize {
250        self.n_groups
251    }
252
253    /// Fixed-effect coefficients `β̂` (link scale).
254    pub fn coefficients(&self) -> ArrayView1<'_, f64> {
255        self.coefficients.view()
256    }
257
258    /// Random-intercept standard deviation `σ̂_b`.
259    pub fn sigma_b(&self) -> f64 {
260        self.sigma_b
261    }
262
263    /// Random-intercept variance `σ̂_b²`.
264    pub fn group_variance(&self) -> f64 {
265        self.sigma_b * self.sigma_b
266    }
267
268    /// Fixed-effect covariance (inverse observed information of the Laplace
269    /// likelihood).
270    pub fn covariance(&self) -> ndarray::ArrayView2<'_, f64> {
271        self.cov_beta.view()
272    }
273
274    /// Fixed-effect standard errors.
275    pub fn coefficient_standard_errors(&self) -> Array1<f64> {
276        Array1::from_shape_fn(self.p, |j| self.cov_beta[(j, j)].max(0.0).sqrt())
277    }
278
279    /// Wald `z`-statistics `βⱼ / seⱼ`.
280    pub fn z_values(&self) -> Array1<f64> {
281        let se = self.coefficient_standard_errors();
282        Array1::from_shape_fn(self.p, |j| {
283            if se[j] > 0.0 {
284                self.coefficients[j] / se[j]
285            } else {
286                f64::NAN
287            }
288        })
289    }
290
291    /// Two-sided Wald p-values from the standard normal.
292    pub fn p_values(&self) -> Array1<f64> {
293        let z = self.z_values();
294        let normal = Normal::new(0.0, 1.0).expect("standard normal");
295        Array1::from_shape_fn(self.p, |j| {
296            if z[j].is_finite() {
297                2.0 * (1.0 - normal.cdf(z[j].abs()))
298            } else {
299                f64::NAN
300            }
301        })
302    }
303
304    /// Predicted (conditional-mode) random intercepts `b̂_j`, in densified group
305    /// order.
306    pub fn random_effects(&self) -> ArrayView1<'_, f64> {
307        self.blups.view()
308    }
309
310    /// The Laplace-approximate log-likelihood at the estimate.
311    pub fn log_likelihood(&self) -> f64 {
312        self.log_likelihood
313    }
314
315    /// AIC, `−2ℓ + 2(p + 1)` (fixed effects plus the variance component).
316    pub fn aic(&self) -> f64 {
317        -2.0 * self.log_likelihood + 2.0 * (self.p as f64 + 1.0)
318    }
319}
320
321/// Laplace log-likelihood at `θ = (β, ln σ_b)` with the given `sigma_b`, plus the
322/// conditional modes `û_j`. Returns `None` if the inner Newton diverges.
323fn laplace_loglik(
324    x: &Array2<f64>,
325    y: &Array1<f64>,
326    group_rows: &[Vec<usize>],
327    theta: &[f64],
328    sigma_b: f64,
329    family: GlmmFamily,
330) -> Option<(f64, Vec<f64>)> {
331    let p = x.ncols();
332    let s2 = sigma_b * sigma_b;
333    let mut total = 0.0;
334    let mut modes = Vec::with_capacity(group_rows.len());
335
336    for rows in group_rows {
337        // Fixed part ηˣ_i = x_iᵀβ.
338        let eta_fixed: Vec<f64> = rows
339            .iter()
340            .map(|&i| (0..p).map(|j| x[(i, j)] * theta[j]).sum::<f64>())
341            .collect();
342
343        // Inner Newton for the conditional mode u.
344        let mut u = 0.0;
345        for _ in 0..100 {
346            let mut grad = -u / s2;
347            let mut ws = 0.0;
348            for (k, &i) in rows.iter().enumerate() {
349                let eta = eta_fixed[k] + u;
350                let mu = family.inverse_link(eta);
351                grad += y[i] - mu;
352                ws += family.weight(mu);
353            }
354            let h = ws + 1.0 / s2;
355            let step = grad / h;
356            u += step;
357            if !u.is_finite() {
358                return None;
359            }
360            if step.abs() < 1e-12 {
361                break;
362            }
363        }
364
365        // Q_j(û) = Σ loglik_i(û) − û²/(2σ_b²); evaluate w_sum at û.
366        let mut q = -u * u / (2.0 * s2);
367        let mut w_sum = 0.0;
368        for (k, &i) in rows.iter().enumerate() {
369            let eta = eta_fixed[k] + u;
370            q += family.loglik(y[i], eta);
371            w_sum += family.weight(family.inverse_link(eta));
372        }
373        // ln L_j ≈ Q_j(û) − ½ ln(1 + σ_b² W_j).
374        let contrib = q - 0.5 * (1.0 + s2 * w_sum).ln();
375        if !contrib.is_finite() {
376            return None;
377        }
378        total += contrib;
379        modes.push(u);
380    }
381    Some((total, modes))
382}