Skip to main content

solow_gee/
gee.rs

1//! Generalized estimating equations (GEE).
2//!
3//! GEE extends generalized linear models to clustered / longitudinal data by
4//! positing a *working* correlation structure within each cluster.  The mean
5//! parameters are estimated by Fisher scoring on the estimating equations,
6//! while the correlation (association) parameter is re-estimated between mean
7//! updates.  Inference uses the robust *sandwich* covariance, which remains
8//! valid even when the working correlation is misspecified; a model-based
9//! ("naive") covariance is also reported.
10
11use ndarray::{Array1, Array2};
12use solow_core::error::{Error, Result};
13use solow_distributions::norm_sf;
14use solow_glm::{Family, Glm, Link};
15use solow_linalg::{inv, solve};
16
17/// The within-cluster working correlation structure.
18#[derive(Clone, Copy, Debug, PartialEq)]
19pub enum CovStruct {
20    /// Observations within a cluster are treated as uncorrelated (the working
21    /// correlation is the identity).  GEE then reduces to a GLM for the point
22    /// estimates, but inference still uses the cluster-robust sandwich.
23    Independence,
24    /// A single common correlation `ρ` between every pair of observations in a
25    /// cluster (compound symmetry).
26    Exchangeable,
27}
28
29/// A GEE model awaiting estimation.
30#[derive(Clone, Debug)]
31pub struct Gee {
32    endog: Array1<f64>,
33    exog: Array2<f64>,
34    /// Row indices for each cluster, in order of first appearance of the group
35    /// label in the input data.
36    groups: Vec<Vec<usize>>,
37    family: Family,
38    link: Link,
39    cov_struct: CovStruct,
40    /// Degrees of freedom subtracted when normalizing the scale (defaults to
41    /// the number of mean parameters, matching the reference).
42    ddof_scale: f64,
43    maxiter: usize,
44    /// Convergence tolerance on the L2 norm of the score equations.
45    ctol: f64,
46}
47
48impl Gee {
49    /// Build a GEE with the family's canonical link.
50    ///
51    /// `group_labels` assigns each observation to a cluster; rows that share a
52    /// label form one cluster.  Clusters are ordered by first appearance.
53    pub fn new(
54        endog: Array1<f64>,
55        exog: Array2<f64>,
56        group_labels: &[i64],
57        family: Family,
58        cov_struct: CovStruct,
59    ) -> Result<Self> {
60        let link = family.default_link();
61        Self::with_link(endog, exog, group_labels, family, link, cov_struct)
62    }
63
64    /// Build a GEE with an explicit link.
65    pub fn with_link(
66        endog: Array1<f64>,
67        exog: Array2<f64>,
68        group_labels: &[i64],
69        family: Family,
70        link: Link,
71        cov_struct: CovStruct,
72    ) -> Result<Self> {
73        let n = endog.len();
74        if n != exog.nrows() {
75            return Err(Error::Shape("endog length != exog rows".into()));
76        }
77        if group_labels.len() != n {
78            return Err(Error::Shape("group_labels length != endog length".into()));
79        }
80        let groups = group_indices(group_labels);
81        let p = exog.ncols();
82        Ok(Gee {
83            endog,
84            exog,
85            groups,
86            family,
87            link,
88            cov_struct,
89            ddof_scale: p as f64,
90            maxiter: 300,
91            ctol: 1e-10,
92        })
93    }
94
95    /// Set the maximum number of Fisher-scoring iterations.
96    pub fn maxiter(mut self, m: usize) -> Self {
97        self.maxiter = m;
98        self
99    }
100
101    /// Set the convergence tolerance on the score-equation norm.
102    pub fn ctol(mut self, t: f64) -> Self {
103        self.ctol = t;
104        self
105    }
106
107    /// Number of observations.
108    fn nobs(&self) -> usize {
109        self.endog.len()
110    }
111
112    /// `dμ/dη` at the given linear predictor (the inverse-link derivative).
113    fn inverse_deriv(&self, eta: f64) -> f64 {
114        let mu = self.link.inverse(eta);
115        1.0 / self.link.deriv(mu)
116    }
117
118    /// Group-wise expected values `μ` and linear predictors `η` for `params`.
119    fn cached_means(&self, params: &Array1<f64>) -> Vec<(Array1<f64>, Array1<f64>)> {
120        self.groups
121            .iter()
122            .map(|idx| {
123                let m = idx.len();
124                let mut eta = Array1::<f64>::zeros(m);
125                let mut mu = Array1::<f64>::zeros(m);
126                for (k, &i) in idx.iter().enumerate() {
127                    let mut lp = 0.0;
128                    for j in 0..self.exog.ncols() {
129                        lp += self.exog[[i, j]] * params[j];
130                    }
131                    eta[k] = lp;
132                    mu[k] = self.link.inverse(lp);
133                }
134                (mu, eta)
135            })
136            .collect()
137    }
138
139    /// The mean-structure derivative `D = ∂μ/∂β` for a cluster:
140    /// row `k` is `exog[k] · (dμ/dη)`.
141    fn mean_deriv(&self, idx: &[usize], eta: &Array1<f64>) -> Array2<f64> {
142        let p = self.exog.ncols();
143        let mut dmat = Array2::<f64>::zeros((idx.len(), p));
144        for (k, &i) in idx.iter().enumerate() {
145            let idl = self.inverse_deriv(eta[k]);
146            for j in 0..p {
147                dmat[[k, j]] = self.exog[[i, j]] * idl;
148            }
149        }
150        dmat
151    }
152
153    /// Working covariance `V = S · R · S` for a cluster, where `S = diag(sdev)`
154    /// and `R` is the working correlation matrix implied by `cov_struct`.
155    fn working_cov(&self, sdev: &Array1<f64>, dep: f64) -> Array2<f64> {
156        let m = sdev.len();
157        let mut v = Array2::<f64>::zeros((m, m));
158        for a in 0..m {
159            for b in 0..m {
160                let r = if a == b {
161                    1.0
162                } else {
163                    match self.cov_struct {
164                        CovStruct::Independence => 0.0,
165                        CovStruct::Exchangeable => dep,
166                    }
167                };
168                v[[a, b]] = r * sdev[a] * sdev[b];
169            }
170        }
171        v
172    }
173
174    /// One Fisher-scoring update of the mean parameters.
175    ///
176    /// Returns `(update, score)` where `params + update` is the next iterate and
177    /// `score = Σ Dᵀ V⁻¹ (y − μ)` is the estimating-equation value *before* the
178    /// update (used for the convergence test).
179    fn update_mean_params(
180        &self,
181        cached: &[(Array1<f64>, Array1<f64>)],
182        dep: f64,
183    ) -> Result<(Array1<f64>, Array1<f64>)> {
184        let p = self.exog.ncols();
185        let mut bmat = Array2::<f64>::zeros((p, p));
186        let mut score = Array1::<f64>::zeros(p);
187        for (gi, idx) in self.groups.iter().enumerate() {
188            let (mu, eta) = &cached[gi];
189            let resid: Array1<f64> = self
190                .endog_group(idx)
191                .iter()
192                .zip(mu.iter())
193                .map(|(y, m)| y - m)
194                .collect();
195            let dmat = self.mean_deriv(idx, eta);
196            let sdev = mu.mapv(|m| self.family.variance(m).sqrt());
197            let vmat = self.working_cov(&sdev, dep);
198
199            // V⁻¹ D and V⁻¹ r via a single linear solve.
200            let vinv_d = solve_spd(&vmat, &dmat)?;
201            let vinv_r = solve(&vmat, &resid)?;
202
203            bmat += &dmat.t().dot(&vinv_d);
204            score += &dmat.t().dot(&vinv_r);
205        }
206        let update = solve(&bmat, &score)?;
207        Ok((update, score))
208    }
209
210    /// Endog values for a cluster.
211    fn endog_group(&self, idx: &[usize]) -> Array1<f64> {
212        idx.iter().map(|&i| self.endog[i]).collect()
213    }
214
215    /// Update the exchangeable correlation parameter (compound symmetry) from
216    /// the current standardized residuals, matching the reference normalization.
217    fn update_dep(&self, cached: &[(Array1<f64>, Array1<f64>)]) -> f64 {
218        if self.cov_struct == CovStruct::Independence {
219            return 0.0;
220        }
221        let nobs = self.nobs() as f64;
222        let ddof = self.ddof_scale;
223        let mut residsq_sum = 0.0;
224        let mut scale = 0.0;
225        let mut fsum1 = 0.0;
226        let mut fsum2 = 0.0;
227        let mut n_pairs = 0.0;
228        for (gi, idx) in self.groups.iter().enumerate() {
229            let (mu, _) = &cached[gi];
230            let y = self.endog_group(idx);
231            let resid: Array1<f64> = y
232                .iter()
233                .zip(mu.iter())
234                .map(|(yy, m)| (yy - m) / self.family.variance(*m).sqrt())
235                .collect();
236            let ssr: f64 = resid.iter().map(|r| r * r).sum();
237            scale += ssr;
238            fsum1 += idx.len() as f64;
239            let rsum: f64 = resid.sum();
240            residsq_sum += (rsum * rsum - ssr) / 2.0;
241            let ngrp = resid.len() as f64;
242            let npr = 0.5 * ngrp * (ngrp - 1.0);
243            fsum2 += npr;
244            n_pairs += npr;
245        }
246        if n_pairs == 0.0 {
247            // No within-cluster pairs (all singletons): association undefined.
248            return 0.0;
249        }
250        scale /= fsum1 * (nobs - ddof) / nobs;
251        residsq_sum /= scale;
252        residsq_sum / (fsum2 * (n_pairs - ddof) / n_pairs)
253    }
254
255    /// Estimate the dispersion/scale. Fixed at 1 for Poisson/Binomial.
256    fn estimate_scale(&self, cached: &[(Array1<f64>, Array1<f64>)]) -> f64 {
257        if self.family.fixed_scale() {
258            return 1.0;
259        }
260        let nobs = self.nobs() as f64;
261        let ddof = self.ddof_scale;
262        let mut scale = 0.0;
263        let mut fsum = 0.0;
264        for (gi, idx) in self.groups.iter().enumerate() {
265            let (mu, _) = &cached[gi];
266            let y = self.endog_group(idx);
267            for (yy, m) in y.iter().zip(mu.iter()) {
268                let r = (yy - m) / self.family.variance(*m).sqrt();
269                scale += r * r;
270            }
271            fsum += idx.len() as f64;
272        }
273        scale /= fsum * (nobs - ddof) / nobs;
274        scale
275    }
276
277    /// Robust (sandwich) and naive (model-based) covariance matrices, and the
278    /// center matrix of the sandwich.
279    fn covmat(
280        &self,
281        cached: &[(Array1<f64>, Array1<f64>)],
282        dep: f64,
283    ) -> Result<(Array2<f64>, Array2<f64>)> {
284        let p = self.exog.ncols();
285        let mut bmat = Array2::<f64>::zeros((p, p));
286        let mut cmat = Array2::<f64>::zeros((p, p));
287        for (gi, idx) in self.groups.iter().enumerate() {
288            let (mu, eta) = &cached[gi];
289            let resid: Array1<f64> = self
290                .endog_group(idx)
291                .iter()
292                .zip(mu.iter())
293                .map(|(y, m)| y - m)
294                .collect();
295            let dmat = self.mean_deriv(idx, eta);
296            let sdev = mu.mapv(|m| self.family.variance(m).sqrt());
297            let vmat = self.working_cov(&sdev, dep);
298
299            let vinv_d = solve_spd(&vmat, &dmat)?;
300            let vinv_r = solve(&vmat, &resid)?;
301
302            bmat += &dmat.t().dot(&vinv_d);
303            let dvinv_resid = dmat.t().dot(&vinv_r);
304            // Outer product of the per-cluster score contribution.
305            for a in 0..p {
306                for b in 0..p {
307                    cmat[[a, b]] += dvinv_resid[a] * dvinv_resid[b];
308                }
309            }
310        }
311        let scale = self.estimate_scale(cached);
312        let bmati = inv(&bmat)?;
313        let cov_naive = &bmati * scale;
314        let cov_robust = bmati.dot(&cmat).dot(&bmati);
315        Ok((cov_robust, cov_naive))
316    }
317
318    /// Fit the model.
319    pub fn fit(&self) -> Result<GeeResults> {
320        let p = self.exog.ncols();
321
322        // Starting values from a plain GLM fit (matching the reference).
323        let glm = Glm::with_link(
324            self.endog.clone(),
325            self.exog.clone(),
326            self.family,
327            self.link,
328        )?
329        .fit()?;
330        let mut params = glm.params.clone();
331
332        let mut cached = self.cached_means(&params);
333        let mut dep = 0.0;
334        let mut score_norm = f64::INFINITY;
335        let mut num_assoc_updates = 0usize;
336        let mut converged = false;
337
338        for _ in 0..self.maxiter {
339            let (update, score) = self.update_mean_params(&cached, dep)?;
340            params = &params + &update;
341            cached = self.cached_means(&params);
342
343            score_norm = score.iter().map(|s| s * s).sum::<f64>().sqrt();
344
345            let update_dep = self.cov_struct != CovStruct::Independence;
346            if score_norm < self.ctol && (num_assoc_updates > 0 || !update_dep) {
347                converged = true;
348                break;
349            }
350
351            if update_dep {
352                dep = self.update_dep(&cached);
353                num_assoc_updates += 1;
354            } else {
355                converged = score_norm < self.ctol;
356                if converged {
357                    break;
358                }
359            }
360        }
361
362        let (cov_robust, cov_naive) = self.covmat(&cached, dep)?;
363        let scale = self.estimate_scale(&cached);
364
365        let bse: Array1<f64> = (0..p).map(|j| cov_robust[[j, j]].sqrt()).collect();
366        let bse_naive: Array1<f64> = (0..p).map(|j| cov_naive[[j, j]].sqrt()).collect();
367
368        let tvalues: Array1<f64> = params.iter().zip(bse.iter()).map(|(b, s)| b / s).collect();
369        let pvalues: Array1<f64> = tvalues.mapv(|t| 2.0 * norm_sf(t.abs()));
370
371        let fitted: Array1<f64> = {
372            let mut f = Array1::<f64>::zeros(self.nobs());
373            for (gi, idx) in self.groups.iter().enumerate() {
374                let (mu, _) = &cached[gi];
375                for (k, &i) in idx.iter().enumerate() {
376                    f[i] = mu[k];
377                }
378            }
379            f
380        };
381
382        Ok(GeeResults {
383            params,
384            bse,
385            bse_naive,
386            tvalues,
387            pvalues,
388            cov_robust,
389            cov_naive,
390            dep_params: dep,
391            scale,
392            fittedvalues: fitted,
393            score_norm,
394            converged,
395        })
396    }
397}
398
399/// Fitted GEE results.
400#[derive(Clone, Debug)]
401pub struct GeeResults {
402    /// Estimated mean-structure parameters `β`.
403    pub params: Array1<f64>,
404    /// Robust (sandwich) standard errors.
405    pub bse: Array1<f64>,
406    /// Naive (model-based) standard errors.
407    pub bse_naive: Array1<f64>,
408    /// `params / bse` (robust).
409    pub tvalues: Array1<f64>,
410    /// Two-sided normal p-values from the robust z-statistics.
411    pub pvalues: Array1<f64>,
412    /// Robust sandwich covariance matrix of `params`.
413    pub cov_robust: Array2<f64>,
414    /// Naive model-based covariance matrix of `params`.
415    pub cov_naive: Array2<f64>,
416    /// Estimated working-correlation (association) parameter; `0` for
417    /// independence.
418    pub dep_params: f64,
419    /// Estimated dispersion/scale (`1` for Poisson/Binomial).
420    pub scale: f64,
421    /// Fitted means `μ` in input row order.
422    pub fittedvalues: Array1<f64>,
423    /// L2 norm of the score equations at convergence.
424    pub score_norm: f64,
425    /// Whether the score-norm tolerance was met.
426    pub converged: bool,
427}
428
429/// Group input rows by label, preserving first-appearance order of labels.
430fn group_indices(labels: &[i64]) -> Vec<Vec<usize>> {
431    let mut order: Vec<i64> = Vec::new();
432    let mut groups: Vec<Vec<usize>> = Vec::new();
433    for (i, &lab) in labels.iter().enumerate() {
434        match order.iter().position(|&l| l == lab) {
435            Some(pos) => groups[pos].push(i),
436            None => {
437                order.push(lab);
438                groups.push(vec![i]);
439            }
440        }
441    }
442    groups
443}
444
445/// Solve `A X = B` for a matrix right-hand side `B`, column by column.
446fn solve_spd(a: &Array2<f64>, b: &Array2<f64>) -> Result<Array2<f64>> {
447    let (m, k) = b.dim();
448    let mut out = Array2::<f64>::zeros((m, k));
449    for j in 0..k {
450        let col = b.column(j).to_owned();
451        let sol = solve(a, &col)?;
452        for i in 0..m {
453            out[[i, j]] = sol[i];
454        }
455    }
456    Ok(out)
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use ndarray::array;
463
464    #[test]
465    fn group_indices_preserves_order() {
466        let g = group_indices(&[5, 5, 2, 2, 5]);
467        assert_eq!(g, vec![vec![0, 1, 4], vec![2, 3]]);
468    }
469
470    #[test]
471    fn independence_poisson_matches_glm_params() {
472        // With Independence working correlation, GEE point estimates equal the
473        // GLM (Poisson) MLE.
474        let x = array![
475            [1.0, 0.0],
476            [1.0, 1.0],
477            [1.0, 2.0],
478            [1.0, 3.0],
479            [1.0, 4.0],
480            [1.0, 5.0],
481        ];
482        let y = array![1.0, 2.0, 3.0, 5.0, 8.0, 13.0];
483        let groups = [0i64, 0, 1, 1, 2, 2];
484        let gee = Gee::new(
485            y.clone(),
486            x.clone(),
487            &groups,
488            Family::Poisson,
489            CovStruct::Independence,
490        )
491        .unwrap();
492        let res = gee.fit().unwrap();
493        let glm = Glm::new(y, x, Family::Poisson).unwrap().fit().unwrap();
494        for j in 0..2 {
495            assert!((res.params[j] - glm.params[j]).abs() < 1e-8);
496        }
497        assert!(res.converged);
498        assert_eq!(res.dep_params, 0.0);
499    }
500
501    #[test]
502    fn exchangeable_reduces_to_independence_when_no_within_corr() {
503        // When every cluster is a singleton, the exchangeable association
504        // parameter is undefined (no pairs) and falls back to 0, so the
505        // exchangeable fit coincides with the independence fit.
506        let x = array![
507            [1.0, 0.5],
508            [1.0, -0.5],
509            [1.0, 1.0],
510            [1.0, -1.0],
511            [1.0, 0.2],
512            [1.0, -0.2],
513        ];
514        let y = array![3.0, 1.0, 4.0, 1.0, 5.0, 2.0];
515        let groups = [0i64, 1, 2, 3, 4, 5]; // all singletons
516        let exch = Gee::new(
517            y.clone(),
518            x.clone(),
519            &groups,
520            Family::Poisson,
521            CovStruct::Exchangeable,
522        )
523        .unwrap()
524        .fit()
525        .unwrap();
526        let indep = Gee::new(y, x, &groups, Family::Poisson, CovStruct::Independence)
527            .unwrap()
528            .fit()
529            .unwrap();
530        assert!(exch.converged && indep.converged);
531        for j in 0..exch.params.len() {
532            assert!((exch.params[j] - indep.params[j]).abs() < 1e-9);
533        }
534    }
535}