Skip to main content

regression_diagnostics/mixed/
general.rs

1use nalgebra::{DMatrix, DVector};
2use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
3
4use super::Method;
5use crate::error::{RegressionError, Result};
6use crate::linalg::{dmatrix_from_rows, dvector_from_slice};
7use crate::optimize::nelder_mead;
8
9/// One **random-effect term**: a grouping factor plus the columns whose
10/// coefficients vary randomly across its groups.
11///
12/// * A **random intercept** for a factor is [`RandomEffect::intercept`] (a single
13///   column of ones).
14/// * A **random slope** (with intercept) passes a design with an intercept column
15///   and the slope covariate via [`RandomEffect::new`]; each group then gets its
16///   own correlated `(intercept, slope)` pair, `~ N(0, Σ)`.
17///
18/// Supplying several terms with **different** grouping factors gives a **crossed
19/// / nested** model.
20#[derive(Debug, Clone)]
21pub struct RandomEffect {
22    groups: Vec<usize>,
23    z: Array2<f64>,
24}
25
26impl RandomEffect {
27    /// A random **intercept** for the factor whose per-observation labels are
28    /// `groups` (arbitrary integers).
29    pub fn intercept(groups: &[usize]) -> Self {
30        let z = Array2::<f64>::ones((groups.len(), 1));
31        Self {
32            groups: groups.to_vec(),
33            z,
34        }
35    }
36
37    /// A general random-effect term: `groups` labels and a per-observation design
38    /// `z` (`n × k`) whose `k` columns have group-varying coefficients. Include a
39    /// column of ones for a random intercept alongside random slopes.
40    pub fn new(groups: &[usize], z: Array2<f64>) -> Self {
41        Self {
42            groups: groups.to_vec(),
43            z,
44        }
45    }
46}
47
48/// Internal per-term layout after densifying groups.
49struct TermLayout {
50    /// Densified group label per observation.
51    group_of: Vec<usize>,
52    /// Per-observation random-effect design (`n × k`).
53    z: Array2<f64>,
54    k: usize,
55    n_groups: usize,
56    /// Column offset of this term's block within the full `Z` / `b`.
57    offset: usize,
58    /// Offset of this term's parameters within `θ`.
59    param_offset: usize,
60}
61
62/// A fitted **general linear mixed model** with one or more random-effect terms —
63/// random slopes and/or crossed & nested grouping factors — estimated by REML
64/// (default) or ML.
65///
66/// The model is `y = Xβ + Zb + ε`, `b ~ N(0, G)`, `ε ~ N(0, σ²_e I)`, where `Z`
67/// and `G` are assembled from the supplied [`RandomEffect`] terms (`G` is
68/// block-diagonal, repeating each term's `k × k` covariance across its groups).
69/// Estimation profiles `β` (by GLS) and `σ²_e` out analytically and optimizes the
70/// remaining **relative covariance** parameters with a Nelder–Mead search, using
71/// a dense Cholesky solve of the `n × n` marginal covariance at each step.
72///
73/// For the single random-intercept case prefer the closed-form
74/// [`LinearMixedModel`](super::LinearMixedModel); this type handles everything
75/// beyond it, and reduces to it exactly for one intercept term.
76///
77/// # Scale
78///
79/// The dense solve is `O(n³)` — appropriate for the grouped datasets these
80/// diagnostics target, not for very large `n`.
81#[derive(Debug, Clone)]
82pub struct MixedModel {
83    coefficients: Array1<f64>,
84    cov_beta: Array2<f64>,
85    var_residual: f64,
86    /// Per-term estimated covariance matrices `Σ_term` (`k × k`).
87    term_cov: Vec<Array2<f64>>,
88    /// Per-term BLUPs, shape `n_groups × k`.
89    term_blups: Vec<Array2<f64>>,
90    log_likelihood: f64,
91    method: Method,
92    n: usize,
93    p: usize,
94    q: usize,
95}
96
97impl MixedModel {
98    /// Fit by REML. `X` holds the fixed effects (intercept included); `terms` are
99    /// the random-effect terms.
100    ///
101    /// # Errors
102    ///
103    /// * [`RegressionError::EmptyInput`] / [`RegressionError::ShapeMismatch`].
104    /// * [`RegressionError::NoResidualDegreesOfFreedom`] if `n ≤ p`.
105    /// * [`RegressionError::InvalidResponse`] if no terms are given.
106    /// * [`RegressionError::RankDeficient`] if the GLS system is singular.
107    pub fn new(x: Array2<f64>, y: Array1<f64>, terms: Vec<RandomEffect>) -> Result<Self> {
108        Self::with_method(x, y, terms, Method::Reml)
109    }
110
111    /// Like [`MixedModel::new`] with an explicit [`Method`].
112    pub fn with_method(
113        x: Array2<f64>,
114        y: Array1<f64>,
115        terms: Vec<RandomEffect>,
116        method: Method,
117    ) -> Result<Self> {
118        let n = x.nrows();
119        let p = x.ncols();
120        if n == 0 || p == 0 {
121            return Err(RegressionError::EmptyInput { what: "X" });
122        }
123        if y.len() != n {
124            return Err(RegressionError::ShapeMismatch {
125                what: "y length vs X rows",
126                expected: n,
127                got: y.len(),
128            });
129        }
130        if n <= p {
131            return Err(RegressionError::NoResidualDegreesOfFreedom {
132                n,
133                p,
134                df: n as isize - p as isize,
135            });
136        }
137        if terms.is_empty() {
138            return Err(RegressionError::InvalidResponse {
139                msg: "a mixed model needs at least one random-effect term".into(),
140            });
141        }
142
143        // Build per-term layout and the full Z (n × q).
144        let mut layouts = Vec::with_capacity(terms.len());
145        let mut q = 0usize;
146        let mut n_theta = 0usize;
147        for term in &terms {
148            if term.groups.len() != n || term.z.nrows() != n {
149                return Err(RegressionError::ShapeMismatch {
150                    what: "random-effect term length vs X rows",
151                    expected: n,
152                    got: term.groups.len().min(term.z.nrows()),
153                });
154            }
155            let group_of = densify(&term.groups);
156            let n_groups = group_of.iter().copied().max().map_or(0, |m| m + 1);
157            let k = term.z.ncols();
158            let n_params = k * (k + 1) / 2;
159            layouts.push(TermLayout {
160                group_of,
161                z: term.z.clone(),
162                k,
163                n_groups,
164                offset: q,
165                param_offset: n_theta,
166            });
167            q += n_groups * k;
168            n_theta += n_params;
169        }
170
171        // Full Z (n × q): observation i contributes term.z[i, c] into the column
172        // for (its group, component c) within the term's block.
173        let mut z_full = DMatrix::<f64>::zeros(n, q);
174        for lay in &layouts {
175            for i in 0..n {
176                let g = lay.group_of[i];
177                for c in 0..lay.k {
178                    z_full[(i, lay.offset + g * lay.k + c)] = lay.z[(i, c)];
179                }
180            }
181        }
182
183        let xd = dmatrix_from_rows(n, p, x.as_standard_layout().as_slice().unwrap());
184        let yd = dvector_from_slice(y.as_standard_layout().as_slice().unwrap());
185
186        let ctx = Ctx {
187            x: &xd,
188            y: &yd,
189            z: &z_full,
190            layouts: &layouts,
191            n,
192            p,
193            q,
194            method,
195        };
196
197        // Optimize the relative covariance parameters θ. Initialize each term's
198        // Cholesky factor to the identity (Δ = I).
199        let mut theta0 = vec![0.0; n_theta];
200        for lay in &layouts {
201            // Diagonal entries of L to 1, off-diagonals 0.
202            let mut idx = lay.param_offset;
203            for r in 0..lay.k {
204                for c in 0..=r {
205                    theta0[idx] = if r == c { 1.0 } else { 0.0 };
206                    idx += 1;
207                }
208            }
209        }
210        let obj = |t: &[f64]| ctx.objective(t).unwrap_or(f64::INFINITY);
211        let theta = nelder_mead(obj, &theta0, 0.2, 1e-10, 5000);
212
213        // Final quantities at θ̂.
214        let sol = ctx.solve(&theta)?;
215        let dof = match method {
216            Method::Reml => (n - p) as f64,
217            Method::Ml => n as f64,
218        };
219        let var_residual = sol.rmr / dof;
220
221        let coefficients = Array1::from_shape_fn(p, |j| sol.beta[j]);
222        let cov_beta = Array2::from_shape_fn((p, p), |(i, j)| sol.a_inv[(i, j)] * var_residual);
223
224        // Per-term covariance Σ = Δ·σ²_e and BLUPs b̂ = D Zᵀ M⁻¹ r.
225        let b = &sol.d * ctx.z.transpose() * &sol.minv_r; // q-vector (relative)
226        let mut term_cov = Vec::with_capacity(layouts.len());
227        let mut term_blups = Vec::with_capacity(layouts.len());
228        for lay in &layouts {
229            let delta = relative_covariance(&theta, lay);
230            let sigma = Array2::from_shape_fn((lay.k, lay.k), |(i, j)| delta[(i, j)] * var_residual);
231            term_cov.push(sigma);
232            let blup = Array2::from_shape_fn((lay.n_groups, lay.k), |(g, c)| {
233                b[lay.offset + g * lay.k + c]
234            });
235            term_blups.push(blup);
236        }
237
238        let log_likelihood = -0.5 * ctx.objective(&theta)? - ctx.log_const();
239
240        Ok(Self {
241            coefficients,
242            cov_beta,
243            var_residual,
244            term_cov,
245            term_blups,
246            log_likelihood,
247            method,
248            n,
249            p,
250            q,
251        })
252    }
253
254    /// Number of observations.
255    pub fn n_observations(&self) -> usize {
256        self.n
257    }
258
259    /// Number of fixed-effect coefficients.
260    pub fn n_parameters(&self) -> usize {
261        self.p
262    }
263
264    /// Total number of random-effect coefficients across all terms.
265    pub fn n_random_effects(&self) -> usize {
266        self.q
267    }
268
269    /// Number of random-effect terms.
270    pub fn n_terms(&self) -> usize {
271        self.term_cov.len()
272    }
273
274    /// Estimation method used.
275    pub fn method(&self) -> Method {
276        self.method
277    }
278
279    /// Fixed-effect coefficients `β̂`.
280    pub fn coefficients(&self) -> ArrayView1<'_, f64> {
281        self.coefficients.view()
282    }
283
284    /// Fixed-effect covariance `σ̂²_e (XᵀV⁻¹X)⁻¹`.
285    pub fn covariance(&self) -> ArrayView2<'_, f64> {
286        self.cov_beta.view()
287    }
288
289    /// Fixed-effect standard errors.
290    pub fn coefficient_standard_errors(&self) -> Array1<f64> {
291        Array1::from_shape_fn(self.p, |j| self.cov_beta[(j, j)].max(0.0).sqrt())
292    }
293
294    /// Residual (within-group) variance `σ̂²_e`.
295    pub fn residual_variance(&self) -> f64 {
296        self.var_residual
297    }
298
299    /// Estimated covariance matrix `Σ̂` (`k × k`) of random-effect term `t`; the
300    /// diagonal holds the intercept/slope variances, the off-diagonal their
301    /// covariance.
302    pub fn term_covariance(&self, t: usize) -> ArrayView2<'_, f64> {
303        self.term_cov[t].view()
304    }
305
306    /// BLUPs for random-effect term `t`, shape `n_groups × k` (row = group,
307    /// column = the term's component).
308    pub fn random_effects(&self, t: usize) -> ArrayView2<'_, f64> {
309        self.term_blups[t].view()
310    }
311
312    /// The profile log-likelihood (REML or ML) at the estimate.
313    pub fn log_likelihood(&self) -> f64 {
314        self.log_likelihood
315    }
316
317    /// AIC. Under ML the parameter count is `p` plus the number of covariance
318    /// parameters plus one for `σ²_e`; under REML only the covariance parameters
319    /// and `σ²_e` are counted.
320    pub fn aic(&self) -> f64 {
321        let n_cov: usize = self.term_cov.iter().map(|c| {
322            let k = c.nrows();
323            k * (k + 1) / 2
324        }).sum();
325        let k = match self.method {
326            Method::Ml => self.p as f64 + n_cov as f64 + 1.0,
327            Method::Reml => n_cov as f64 + 1.0,
328        };
329        -2.0 * self.log_likelihood + 2.0 * k
330    }
331}
332
333/// Cached matrices for the profiled-likelihood search.
334struct Ctx<'a> {
335    x: &'a DMatrix<f64>,
336    y: &'a DVector<f64>,
337    z: &'a DMatrix<f64>,
338    layouts: &'a [TermLayout],
339    n: usize,
340    p: usize,
341    q: usize,
342    method: Method,
343}
344
345struct GlsSolve {
346    beta: DVector<f64>,
347    a_inv: DMatrix<f64>,
348    rmr: f64,
349    /// `M⁻¹ r` (used to form the BLUPs).
350    minv_r: DVector<f64>,
351    /// The relative random-effect covariance `D` (`q × q`, block diagonal).
352    d: DMatrix<f64>,
353}
354
355impl Ctx<'_> {
356    /// Build the relative covariance `D`, then GLS-solve at `θ`.
357    fn solve(&self, theta: &[f64]) -> Result<GlsSolve> {
358        // D (q × q): block diagonal, per term Δ repeated across its groups.
359        let mut d = DMatrix::<f64>::zeros(self.q, self.q);
360        for lay in self.layouts {
361            let delta = relative_covariance(theta, lay);
362            for g in 0..lay.n_groups {
363                let base = lay.offset + g * lay.k;
364                for a in 0..lay.k {
365                    for b in 0..lay.k {
366                        d[(base + a, base + b)] = delta[(a, b)];
367                    }
368                }
369            }
370        }
371        // M = I + Z D Zᵀ.
372        let mut m = self.z * &d * self.z.transpose();
373        for i in 0..self.n {
374            m[(i, i)] += 1.0;
375        }
376        let chol = m.clone().cholesky().ok_or(RegressionError::RankDeficient)?;
377        let minv = chol.inverse();
378
379        let xtminv = self.x.transpose() * &minv; // p × n
380        let a = &xtminv * self.x; // p × p
381        let a_inv = a.try_inverse().ok_or(RegressionError::RankDeficient)?;
382        let beta = &a_inv * (&xtminv * self.y);
383        let r = self.y - self.x * &beta;
384        let minv_r = &minv * &r;
385        let rmr = (r.transpose() * &minv_r)[(0, 0)];
386
387        Ok(GlsSolve {
388            beta,
389            a_inv,
390            rmr,
391            minv_r,
392            d,
393        })
394    }
395
396    /// Profiled `−2ℓ` (up to the additive constant) to minimize over `θ`.
397    fn objective(&self, theta: &[f64]) -> Result<f64> {
398        // Recompute M and its Cholesky for the log-determinant.
399        let mut d = DMatrix::<f64>::zeros(self.q, self.q);
400        for lay in self.layouts {
401            let delta = relative_covariance(theta, lay);
402            for g in 0..lay.n_groups {
403                let base = lay.offset + g * lay.k;
404                for a in 0..lay.k {
405                    for b in 0..lay.k {
406                        d[(base + a, base + b)] = delta[(a, b)];
407                    }
408                }
409            }
410        }
411        let mut m = self.z * &d * self.z.transpose();
412        for i in 0..self.n {
413            m[(i, i)] += 1.0;
414        }
415        let chol = m.cholesky().ok_or(RegressionError::RankDeficient)?;
416        let ln_det_m = 2.0 * chol.l().diagonal().iter().map(|v| v.ln()).sum::<f64>();
417
418        let sol = self.solve(theta)?;
419        let dof = match self.method {
420            Method::Reml => (self.n - self.p) as f64,
421            Method::Ml => self.n as f64,
422        };
423        let sigma2 = (sol.rmr / dof).max(1e-300);
424        let mut obj = dof * sigma2.ln() + ln_det_m;
425        if self.method == Method::Reml {
426            // + ln det(Xᵀ M⁻¹ X) = − ln det(a_inv), since a_inv = (Xᵀ M⁻¹ X)⁻¹.
427            let det_ainv = sol.a_inv.determinant();
428            obj -= det_ainv.abs().max(1e-300).ln();
429        }
430        Ok(obj)
431    }
432
433    fn log_const(&self) -> f64 {
434        let dof = match self.method {
435            Method::Reml => (self.n - self.p) as f64,
436            Method::Ml => self.n as f64,
437        };
438        0.5 * dof * ((2.0 * std::f64::consts::PI).ln() + 1.0)
439    }
440}
441
442/// Assemble a term's relative covariance `Δ = L Lᵀ` from the free lower-triangular
443/// parameters in `θ`.
444fn relative_covariance(theta: &[f64], lay: &TermLayout) -> DMatrix<f64> {
445    let k = lay.k;
446    let mut l = DMatrix::<f64>::zeros(k, k);
447    let mut idx = lay.param_offset;
448    for r in 0..k {
449        for c in 0..=r {
450            l[(r, c)] = theta[idx];
451            idx += 1;
452        }
453    }
454    &l * l.transpose()
455}
456
457/// Remap arbitrary integer labels to `0..g`, preserving first-seen order.
458fn densify(labels: &[usize]) -> Vec<usize> {
459    let mut map = std::collections::BTreeMap::new();
460    labels
461        .iter()
462        .map(|&l| {
463            let next = map.len();
464            *map.entry(l).or_insert(next)
465        })
466        .collect()
467}