Skip to main content

solow_gee/
categorical.rs

1//! Marginal GEE regression for **categorical** (nominal / ordinal) responses.
2//!
3//! A multinomial outcome with `K` distinct levels is analyzed by expanding
4//! every observation into `ncut = K − 1` binary indicator rows and fitting a
5//! GEE to the expanded data.  Two response geometries are supported:
6//!
7//! * [`NominalGee`] — *unordered* categories.  Each cut `j` (a category other
8//!   than the reference / largest level) gets its **own** coefficient block, so
9//!   the expanded design is block-diagonal (`kron(eⱼ, xᵢ)`) and the cut
10//!   probabilities are coupled through the multinomial-logit link
11//!   `μ_j = e^{η_j} / (1 + Σ_k e^{η_k})`.  The indicator is `I(yᵢ = cutⱼ)`.
12//!
13//! * [`OrdinalGee`] — *ordered* categories.  A single covariate-effect vector
14//!   is shared across cuts, augmented by one intercept per cut, and the
15//!   ordinary logit link `μ = 1/(1+e^{−η})` is applied per row.  The indicator
16//!   is `I(yᵢ > cutⱼ)` (a proportional-odds cumulative model).
17//!
18//! Within a single original observation the `ncut` indicators are
19//! (deterministically) correlated; between observations the working
20//! association is either [`CategoricalCov::Independence`] (zero) or
21//! [`CategoricalCov::GlobalOddsRatio`] (the Heagerty–Zeger / Lumley global
22//! odds-ratio structure, estimated by iteratively matching pooled `2×2`
23//! cut-point tables).  Inference uses the cluster-robust sandwich covariance.
24//!
25//! Validated against an authoritative reference (`NominalGEE` / `OrdinalGEE`
26//! with `GlobalOddsRatio`).
27
28use ndarray::{Array1, Array2};
29use solow_core::error::{Error, Result};
30use solow_distributions::norm_sf;
31use solow_glm::{Family, Glm, Link};
32use solow_linalg::{inv, solve};
33
34/// Between-observation working association for categorical GEE.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum CategoricalCov {
37    /// Indicators from *different* original observations are uncorrelated.
38    /// There is no association parameter to estimate.
39    Independence,
40    /// The global odds-ratio structure of Heagerty–Zeger (ordinal) and Lumley:
41    /// a single odds ratio governs the joint distribution of every
42    /// between-observation indicator pair, estimated by matching pooled
43    /// cut-point `2×2` tables.
44    GlobalOddsRatio,
45}
46
47/// Whether the categorical response is nominal (unordered) or ordinal.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49enum Kind {
50    Nominal,
51    Ordinal,
52}
53
54/// A nominal-response (unordered multinomial) marginal GEE awaiting estimation.
55#[derive(Clone, Debug)]
56pub struct NominalGee {
57    inner: CategoricalGee,
58}
59
60/// An ordinal-response (proportional-odds cumulative) marginal GEE awaiting
61/// estimation.
62#[derive(Clone, Debug)]
63pub struct OrdinalGee {
64    inner: CategoricalGee,
65}
66
67impl NominalGee {
68    /// Build a nominal GEE.
69    ///
70    /// `exog` is the *original* design (typically including an intercept
71    /// column); `group_labels` assigns each observation to a cluster.  The
72    /// response `endog` must take a small number of distinct values; the
73    /// largest is treated as the reference category.
74    pub fn new(
75        endog: Array1<f64>,
76        exog: Array2<f64>,
77        group_labels: &[i64],
78        cov: CategoricalCov,
79    ) -> Result<Self> {
80        Ok(NominalGee {
81            inner: CategoricalGee::new(Kind::Nominal, endog, exog, group_labels, cov)?,
82        })
83    }
84
85    /// Set the maximum number of outer (Fisher-scoring) iterations.
86    pub fn maxiter(mut self, m: usize) -> Self {
87        self.inner.maxiter = m;
88        self
89    }
90
91    /// Set the convergence tolerance on the score-equation norm.
92    pub fn ctol(mut self, t: f64) -> Self {
93        self.inner.ctol = t;
94        self
95    }
96
97    /// Fit the model.
98    pub fn fit(&self) -> Result<CategoricalGeeResults> {
99        self.inner.fit()
100    }
101}
102
103impl OrdinalGee {
104    /// Build an ordinal GEE.
105    ///
106    /// `exog` is the *original* design and **must not** contain an intercept
107    /// column — one intercept per cut point is appended automatically (an
108    /// intercept in `exog` would make the augmented design rank-deficient).
109    pub fn new(
110        endog: Array1<f64>,
111        exog: Array2<f64>,
112        group_labels: &[i64],
113        cov: CategoricalCov,
114    ) -> Result<Self> {
115        Ok(OrdinalGee {
116            inner: CategoricalGee::new(Kind::Ordinal, endog, exog, group_labels, cov)?,
117        })
118    }
119
120    /// Set the maximum number of outer (Fisher-scoring) iterations.
121    pub fn maxiter(mut self, m: usize) -> Self {
122        self.inner.maxiter = m;
123        self
124    }
125
126    /// Set the convergence tolerance on the score-equation norm.
127    pub fn ctol(mut self, t: f64) -> Self {
128        self.inner.ctol = t;
129        self
130    }
131
132    /// Fit the model.
133    pub fn fit(&self) -> Result<CategoricalGeeResults> {
134        self.inner.fit()
135    }
136}
137
138/// Shared machinery for nominal / ordinal categorical GEE.
139#[derive(Clone, Debug)]
140struct CategoricalGee {
141    kind: Kind,
142    cov: CategoricalCov,
143    /// Number of cut points, `K − 1`.
144    ncut: usize,
145    /// Width of the *expanded* design (number of mean parameters).
146    nparam: usize,
147    /// Expanded design, one block of `ncut` rows per original observation, in
148    /// cluster (sorted group-label) order.
149    exog: Array2<f64>,
150    /// Expanded binary indicators aligned with `exog`.
151    endog: Array1<f64>,
152    /// For each cluster, the row indices into `exog`/`endog` (already in
153    /// expanded form, so a length-`m` cluster has `m·ncut` rows).
154    groups: Vec<Vec<usize>>,
155    /// For each cluster, the number of *original* observations in it.
156    group_nobs: Vec<usize>,
157    /// Total number of expanded rows.
158    nrows: usize,
159    maxiter: usize,
160    ctol: f64,
161}
162
163impl CategoricalGee {
164    fn new(
165        kind: Kind,
166        endog: Array1<f64>,
167        exog: Array2<f64>,
168        group_labels: &[i64],
169        cov: CategoricalCov,
170    ) -> Result<Self> {
171        let n = endog.len();
172        if n != exog.nrows() {
173            return Err(Error::Shape("endog length != exog rows".into()));
174        }
175        if group_labels.len() != n {
176            return Err(Error::Shape("group_labels length != endog length".into()));
177        }
178
179        // Distinct outcome levels in ascending order; cuts drop the largest.
180        let mut levels: Vec<f64> = endog.iter().copied().collect();
181        levels.sort_by(|a, b| a.total_cmp(b));
182        levels.dedup();
183        if levels.len() < 2 {
184            return Err(Error::Shape("endog must have at least two levels".into()));
185        }
186        let ncut = levels.len() - 1;
187        let cuts = &levels[..ncut];
188        let p = exog.ncols();
189
190        let nparam = match kind {
191            Kind::Nominal => ncut * p,
192            Kind::Ordinal => ncut + p,
193        };
194
195        // Clusters in sorted group-label order (matching the reference's
196        // `np.unique` grouping), preserving original row order within a group.
197        let mut order: Vec<i64> = group_labels.to_vec();
198        order.sort_unstable();
199        order.dedup();
200        let mut orig_groups: Vec<Vec<usize>> = vec![Vec::new(); order.len()];
201        for (i, &lab) in group_labels.iter().enumerate() {
202            let pos = order.binary_search(&lab).unwrap();
203            orig_groups[pos].push(i);
204        }
205
206        // Build the expanded design row-block by row-block, walking clusters in
207        // order so that expanded rows for one cluster are contiguous.
208        let width = match kind {
209            Kind::Nominal => ncut * p,
210            Kind::Ordinal => ncut + p,
211        };
212        let nrows = ncut * n;
213        let mut exog_out = Array2::<f64>::zeros((nrows, width));
214        let mut endog_out = Array1::<f64>::zeros(nrows);
215        let mut groups: Vec<Vec<usize>> = Vec::with_capacity(order.len());
216        let mut group_nobs: Vec<usize> = Vec::with_capacity(order.len());
217
218        let mut jrow = 0usize;
219        for og in &orig_groups {
220            let mut rows: Vec<usize> = Vec::with_capacity(og.len() * ncut);
221            for &i in og {
222                let yval = endog[i];
223                for (cix, &cut) in cuts.iter().enumerate() {
224                    match kind {
225                        Kind::Ordinal => {
226                            // Per-cut intercepts then the original covariates.
227                            exog_out[[jrow, cix]] = 1.0;
228                            for c in 0..p {
229                                exog_out[[jrow, ncut + c]] = exog[[i, c]];
230                            }
231                            endog_out[jrow] = if yval > cut { 1.0 } else { 0.0 };
232                        }
233                        Kind::Nominal => {
234                            // kron(e_cix, x_i): block `cix` of width p.
235                            let base = cix * p;
236                            for c in 0..p {
237                                exog_out[[jrow, base + c]] = exog[[i, c]];
238                            }
239                            endog_out[jrow] = if yval == cut { 1.0 } else { 0.0 };
240                        }
241                    }
242                    rows.push(jrow);
243                    jrow += 1;
244                }
245            }
246            group_nobs.push(og.len());
247            groups.push(rows);
248        }
249
250        Ok(CategoricalGee {
251            kind,
252            cov,
253            ncut,
254            nparam,
255            exog: exog_out,
256            endog: endog_out,
257            groups,
258            group_nobs,
259            nrows,
260            maxiter: 300,
261            ctol: 1e-10,
262        })
263    }
264
265    /// Linear predictor for a cluster's expanded rows.
266    fn lin_pred(&self, idx: &[usize], params: &Array1<f64>) -> Array1<f64> {
267        let mut lpr = Array1::<f64>::zeros(idx.len());
268        for (k, &r) in idx.iter().enumerate() {
269            let mut s = 0.0;
270            for j in 0..self.nparam {
271                s += self.exog[[r, j]] * params[j];
272            }
273            lpr[k] = s;
274        }
275        lpr
276    }
277
278    /// Mean (expected indicator) for a cluster given its linear predictor.
279    ///
280    /// For ordinal models the logit link is applied per row; for nominal
281    /// models the `ncut` indicators of each original observation are coupled
282    /// through the shared multinomial normalizer.
283    fn mean(&self, lpr: &Array1<f64>) -> Array1<f64> {
284        match self.kind {
285            Kind::Ordinal => lpr.mapv(|e| 1.0 / (1.0 + (-e).exp())),
286            Kind::Nominal => {
287                let mut mu = Array1::<f64>::zeros(lpr.len());
288                let nobs = lpr.len() / self.ncut;
289                for o in 0..nobs {
290                    let base = o * self.ncut;
291                    let mut denom = 1.0;
292                    for k in 0..self.ncut {
293                        denom += lpr[base + k].exp();
294                    }
295                    for k in 0..self.ncut {
296                        mu[base + k] = lpr[base + k].exp() / denom;
297                    }
298                }
299                mu
300            }
301        }
302    }
303
304    /// Mean-structure derivative `D = ∂μ/∂β` for a cluster's expanded rows.
305    ///
306    /// Both geometries use the same *row-wise* derivative
307    /// `D[r,j] = μ_r (1 − μ_r) · exog[r,j]`.  For ordinal models this is the
308    /// logit inverse-link derivative; for nominal models the reference applies
309    /// the identical row-wise form (the multinomial coupling enters only the
310    /// mean `μ` and the working covariance, not this Jacobian), so we match it.
311    fn mean_deriv(&self, idx: &[usize], mu: &Array1<f64>) -> Array2<f64> {
312        let m = idx.len();
313        let mut d = Array2::<f64>::zeros((m, self.nparam));
314        for (k, &r) in idx.iter().enumerate() {
315            let idl = mu[k] * (1.0 - mu[k]);
316            for j in 0..self.nparam {
317                d[[k, j]] = self.exog[[r, j]] * idl;
318            }
319        }
320        d
321    }
322
323    /// The working covariance matrix `V` for a cluster, given the expected
324    /// indicators `mu` and the current global odds ratio `dep`.
325    ///
326    /// `V` is block structured by original observation: the within-observation
327    /// block is deterministic, while between-observation blocks are zero for
328    /// [`CategoricalCov::Independence`] or filled from the global odds-ratio
329    /// joint-probability formula otherwise.
330    fn working_cov(&self, gi: usize, mu: &Array1<f64>, dep: f64) -> Array2<f64> {
331        let m = mu.len();
332        let nobs = self.group_nobs[gi];
333        let mut v = Array2::<f64>::zeros((m, m));
334
335        if self.cov == CategoricalCov::GlobalOddsRatio {
336            // Full E[YY'] from the global odds ratio, then subtract the mean
337            // outer product; the within-observation blocks are overwritten
338            // below with their deterministic values.
339            let eyy = self.get_eyy(mu, dep);
340            for a in 0..m {
341                for b in 0..m {
342                    v[[a, b]] = eyy[[a, b]] - mu[a] * mu[b];
343                }
344            }
345        }
346
347        // Within-observation blocks (size ncut), deterministic for both covs.
348        for o in 0..nobs {
349            let base = o * self.ncut;
350            for a in 0..self.ncut {
351                for b in 0..self.ncut {
352                    let ea = mu[base + a];
353                    let eb = mu[base + b];
354                    let val = match self.kind {
355                        Kind::Ordinal => ea.min(eb) - ea * eb,
356                        Kind::Nominal => {
357                            if a == b {
358                                ea - ea * ea
359                            } else {
360                                -ea * eb
361                            }
362                        }
363                    };
364                    v[[base + a, base + b]] = val;
365                }
366            }
367        }
368        v
369    }
370
371    /// `E[YY']` under the global odds-ratio model for a cluster, before any
372    /// within-observation correction (handled by [`Self::working_cov`]).
373    fn get_eyy(&self, mu: &Array1<f64>, dep: f64) -> Array2<f64> {
374        let m = mu.len();
375        let mut eyy = Array2::<f64>::zeros((m, m));
376        if dep == 1.0 {
377            for a in 0..m {
378                for b in 0..m {
379                    eyy[[a, b]] = mu[a] * mu[b];
380                }
381            }
382            return eyy;
383        }
384        let or = dep;
385        for a in 0..m {
386            for b in 0..m {
387                let psum = mu[a] + mu[b];
388                let pprod = mu[a] * mu[b];
389                let pfac =
390                    ((1.0 + psum * (or - 1.0)).powi(2) + 4.0 * or * (1.0 - or) * pprod).sqrt();
391                eyy[[a, b]] = (1.0 + psum * (or - 1.0) - pfac) / (2.0 * (or - 1.0));
392            }
393        }
394        eyy
395    }
396
397    /// One Fisher-scoring update of the mean parameters; returns the update and
398    /// the current score (before the update) for the convergence test.
399    fn update_mean_params(
400        &self,
401        params: &Array1<f64>,
402        dep: f64,
403    ) -> Result<(Array1<f64>, Array1<f64>)> {
404        let (bmat, _, score) = self.accumulate(params, dep)?;
405        let update = solve(&bmat, &score)?;
406        Ok((update, score))
407    }
408
409    /// Accumulate the bread `B = Σ DᵀV⁻¹D`, the sandwich center
410    /// `C = Σ (DᵀV⁻¹r)(DᵀV⁻¹r)ᵀ`, and the score `Σ DᵀV⁻¹r`.
411    fn accumulate(
412        &self,
413        params: &Array1<f64>,
414        dep: f64,
415    ) -> Result<(Array2<f64>, Array2<f64>, Array1<f64>)> {
416        let p = self.nparam;
417        let mut bmat = Array2::<f64>::zeros((p, p));
418        let mut cmat = Array2::<f64>::zeros((p, p));
419        let mut score = Array1::<f64>::zeros(p);
420
421        for (gi, idx) in self.groups.iter().enumerate() {
422            if idx.is_empty() {
423                continue;
424            }
425            let lpr = self.lin_pred(idx, params);
426            let mu = self.mean(&lpr);
427            let resid: Array1<f64> = idx
428                .iter()
429                .zip(mu.iter())
430                .map(|(&r, m)| self.endog[r] - m)
431                .collect();
432            let dmat = self.mean_deriv(idx, &mu);
433            let vmat = self.working_cov(gi, &mu, dep);
434
435            let vinv_d = solve_mat(&vmat, &dmat)?;
436            let vinv_r = solve(&vmat, &resid)?;
437
438            bmat += &dmat.t().dot(&vinv_d);
439            let dvinv_resid = dmat.t().dot(&vinv_r);
440            score += &dvinv_resid;
441            for a in 0..p {
442                for b in 0..p {
443                    cmat[[a, b]] += dvinv_resid[a] * dvinv_resid[b];
444                }
445            }
446        }
447        Ok((bmat, cmat, score))
448    }
449
450    /// Crude (marginal) global odds ratio: pool every between-observation
451    /// cut-point pair into `2×2` tables of *observed* indicators and take the
452    /// inverse-variance-weighted (pooled) odds ratio.
453    fn observed_crude_oddsratio(&self) -> f64 {
454        // tables[(k2,k1)] for 0 <= k2 <= k1 < ncut.
455        let mut tables = self.empty_tables();
456        for (gi, idx) in self.groups.iter().enumerate() {
457            let nobs = self.group_nobs[gi];
458            let y: Array1<f64> = idx.iter().map(|&r| self.endog[r]).collect();
459            self.accumulate_tables(&mut tables, &y, &y, nobs);
460        }
461        pooled_odds_ratio(&tables)
462    }
463
464    /// Allocate the per-cut-pair `2×2` contingency tables (lower triangle).
465    fn empty_tables(&self) -> Vec<[[f64; 2]; 2]> {
466        let mut n = 0;
467        for k1 in 0..self.ncut {
468            n += k1 + 1;
469        }
470        vec![[[0.0; 2]; 2]; n]
471    }
472
473    /// Linear index of cut-pair `(k2, k1)` with `k2 <= k1` in the lower-triangle
474    /// table list (matching the construction order in [`Self::empty_tables`]).
475    fn pair_index(&self, k2: usize, k1: usize) -> usize {
476        // pairs ordered by k1 ascending, then k2 in 0..=k1.
477        let mut base = 0;
478        for k in 0..k1 {
479            base += k + 1;
480        }
481        base + k2
482    }
483
484    /// Add a cluster's between-observation contributions to the pooled tables.
485    ///
486    /// `eyy11[a,b]` is the joint probability (or observed product) that both
487    /// indicators are 1; `ey_a`/`ey_b` are the marginal expectations used to
488    /// derive the 10/01/00 cells.  For the *observed* crude ratio pass both
489    /// `eyy` and the marginals are the realized 0/1 indicators.
490    fn accumulate_tables(
491        &self,
492        tables: &mut [[[f64; 2]; 2]],
493        ya: &Array1<f64>,
494        yb: &Array1<f64>,
495        nobs: usize,
496    ) {
497        // Between-subject lower-triangle pairs (i1 > i2).
498        for i1 in 0..nobs {
499            for i2 in 0..i1 {
500                for k1 in 0..self.ncut {
501                    for k2 in 0..=k1 {
502                        let a = i1 * self.ncut + k1;
503                        let b = i2 * self.ncut + k2;
504                        let p11 = ya[a] * yb[b];
505                        let p10 = ya[a] * (1.0 - yb[b]);
506                        let p01 = (1.0 - ya[a]) * yb[b];
507                        let p00 = (1.0 - ya[a]) * (1.0 - yb[b]);
508                        let t = &mut tables[self.pair_index(k2, k1)];
509                        t[1][1] += p11;
510                        t[1][0] += p10;
511                        t[0][1] += p01;
512                        t[0][0] += p00;
513                    }
514                }
515            }
516        }
517    }
518
519    /// One global-odds-ratio update: rebuild the pooled tables from the current
520    /// model-implied joint probabilities and rescale `dep` by
521    /// `crude_or / expected_or`.
522    fn update_dep(&self, params: &Array1<f64>, dep: f64, crude_or: f64) -> f64 {
523        // No between-observation pairs anywhere => nothing to update.
524        if self.group_nobs.iter().all(|&m| m <= 1) {
525            return dep;
526        }
527        let mut tables = self.empty_tables();
528        for (gi, idx) in self.groups.iter().enumerate() {
529            let nobs = self.group_nobs[gi];
530            if nobs <= 1 {
531                continue;
532            }
533            let lpr = self.lin_pred(idx, params);
534            let mu = self.mean(&lpr);
535            let eyy = self.get_eyy(&mu, dep);
536            // Build expectation-based 2x2 cells directly.
537            for i1 in 0..nobs {
538                for i2 in 0..i1 {
539                    for k1 in 0..self.ncut {
540                        for k2 in 0..=k1 {
541                            let a = i1 * self.ncut + k1;
542                            let b = i2 * self.ncut + k2;
543                            let e11 = eyy[[a, b]];
544                            let e10 = mu[a] - e11;
545                            let e01 = mu[b] - e11;
546                            let e00 = 1.0 - (e11 + e10 + e01);
547                            let t = &mut tables[self.pair_index(k2, k1)];
548                            t[1][1] += e11;
549                            t[1][0] += e10;
550                            t[0][1] += e01;
551                            t[0][0] += e00;
552                        }
553                    }
554                }
555            }
556        }
557        let cor_expval = pooled_odds_ratio(&tables);
558        let new_dep = dep * crude_or / cor_expval;
559        if new_dep.is_finite() {
560            new_dep
561        } else {
562            1.0
563        }
564    }
565
566    /// Starting parameters: the GLM (binomial-logit) fit of the expanded
567    /// indicators on the expanded design.  This coincides with the reference's
568    /// Independence-GEE starting fit for the mean parameters.
569    fn starting_params(&self) -> Result<Array1<f64>> {
570        let glm = Glm::with_link(
571            self.endog.clone(),
572            self.exog.clone(),
573            Family::Binomial,
574            Link::Logit,
575        )?
576        .fit()?;
577        Ok(glm.params)
578    }
579
580    /// Fit the model.
581    fn fit(&self) -> Result<CategoricalGeeResults> {
582        let mut params = self.starting_params()?;
583
584        let update_dep =
585            self.cov == CategoricalCov::GlobalOddsRatio && self.group_nobs.iter().any(|&m| m > 1);
586        // The crude OR is fixed across iterations and seeds `dep`.
587        let crude_or = if update_dep {
588            self.observed_crude_oddsratio()
589        } else {
590            1.0
591        };
592        let mut dep = if update_dep { crude_or } else { 1.0 };
593
594        let mut score_norm = f64::INFINITY;
595        let mut num_assoc_updates = 0usize;
596        let mut converged = false;
597
598        for _ in 0..self.maxiter {
599            let (update, score) = self.update_mean_params(&params, dep)?;
600            params = &params + &update;
601            score_norm = score.iter().map(|s| s * s).sum::<f64>().sqrt();
602
603            if score_norm < self.ctol && (num_assoc_updates > 0 || !update_dep) {
604                converged = true;
605                break;
606            }
607
608            if update_dep {
609                dep = self.update_dep(&params, dep, crude_or);
610                num_assoc_updates += 1;
611            } else {
612                converged = score_norm < self.ctol;
613                if converged {
614                    break;
615                }
616            }
617        }
618
619        // Covariances.  Note categorical GEE has unit scale (binary variance),
620        // so `cov_naive = B⁻¹` and `cov_robust = B⁻¹ C B⁻¹`.
621        let (bmat, cmat, _) = self.accumulate(&params, dep)?;
622        let bmati = inv(&bmat)?;
623        let cov_naive = bmati.clone();
624        let cov_robust = bmati.dot(&cmat).dot(&bmati);
625
626        let p = self.nparam;
627        let bse: Array1<f64> = (0..p).map(|j| cov_robust[[j, j]].sqrt()).collect();
628        let bse_naive: Array1<f64> = (0..p).map(|j| cov_naive[[j, j]].sqrt()).collect();
629        let tvalues: Array1<f64> = params.iter().zip(bse.iter()).map(|(b, s)| b / s).collect();
630        let pvalues: Array1<f64> = tvalues.mapv(|t| 2.0 * norm_sf(t.abs()));
631
632        // Fitted values in expanded-row order (cluster order, as stored).
633        let mut fitted = Array1::<f64>::zeros(self.nrows);
634        for idx in &self.groups {
635            let lpr = self.lin_pred(idx, &params);
636            let mu = self.mean(&lpr);
637            for (k, &r) in idx.iter().enumerate() {
638                fitted[r] = mu[k];
639            }
640        }
641
642        Ok(CategoricalGeeResults {
643            params,
644            bse,
645            bse_naive,
646            tvalues,
647            pvalues,
648            cov_robust,
649            cov_naive,
650            dep_params: if update_dep { dep } else { 0.0 },
651            scale: 1.0,
652            fittedvalues: fitted,
653            ncut: self.ncut,
654            score_norm,
655            converged,
656        })
657    }
658}
659
660/// Fitted results of a categorical ([`NominalGee`] / [`OrdinalGee`]) GEE.
661#[derive(Clone, Debug)]
662pub struct CategoricalGeeResults {
663    /// Estimated mean-structure parameters.  For nominal models the layout is
664    /// block-by-cut (`[cut₀ coefs, cut₁ coefs, …]`); for ordinal models it is
665    /// `[intercept₀, …, interceptₙ₋₁, covariate coefs]`.
666    pub params: Array1<f64>,
667    /// Robust (sandwich) standard errors.
668    pub bse: Array1<f64>,
669    /// Naive (model-based) standard errors.
670    pub bse_naive: Array1<f64>,
671    /// `params / bse` (robust).
672    pub tvalues: Array1<f64>,
673    /// Two-sided normal p-values from the robust z-statistics.
674    pub pvalues: Array1<f64>,
675    /// Robust sandwich covariance of `params`.
676    pub cov_robust: Array2<f64>,
677    /// Naive model-based covariance of `params`.
678    pub cov_naive: Array2<f64>,
679    /// Estimated global odds ratio; `0` for the independence working
680    /// association.
681    pub dep_params: f64,
682    /// Dispersion/scale (always `1` for the binary indicator model).
683    pub scale: f64,
684    /// Fitted indicator means in expanded-row order.
685    pub fittedvalues: Array1<f64>,
686    /// Number of cut points (`K − 1`).
687    pub ncut: usize,
688    /// L2 norm of the score equations at convergence.
689    pub score_norm: f64,
690    /// Whether the score-norm tolerance was met.
691    pub converged: bool,
692}
693
694/// Inverse-variance-weighted pooled odds ratio of a list of `2×2` tables.
695fn pooled_odds_ratio(tables: &[[[f64; 2]; 2]]) -> f64 {
696    if tables.is_empty() {
697        return 1.0;
698    }
699    let mut log_or = Vec::with_capacity(tables.len());
700    let mut var = Vec::with_capacity(tables.len());
701    for t in tables {
702        let lor = t[1][1].ln() + t[0][0].ln() - t[0][1].ln() - t[1][0].ln();
703        log_or.push(lor);
704        var.push(1.0 / t[1][1] + 1.0 / t[0][0] + 1.0 / t[0][1] + 1.0 / t[1][0]);
705    }
706    let wts: Vec<f64> = var.iter().map(|v| 1.0 / v).collect();
707    let wtsum: f64 = wts.iter().sum();
708    let log_pooled: f64 = wts
709        .iter()
710        .zip(log_or.iter())
711        .map(|(w, e)| (w / wtsum) * e)
712        .sum();
713    log_pooled.exp()
714}
715
716/// Solve `A X = B` column by column for a matrix right-hand side.
717fn solve_mat(a: &Array2<f64>, b: &Array2<f64>) -> Result<Array2<f64>> {
718    let (m, k) = b.dim();
719    let mut out = Array2::<f64>::zeros((m, k));
720    for j in 0..k {
721        let col = b.column(j).to_owned();
722        let sol = solve(a, &col)?;
723        for i in 0..m {
724            out[[i, j]] = sol[i];
725        }
726    }
727    Ok(out)
728}
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733    use ndarray::array;
734
735    #[test]
736    fn ordinal_expands_indicators() {
737        // 3 levels {0,1,2} => ncut=2, indicators I(y>0), I(y>1).
738        let y = array![0.0, 1.0, 2.0];
739        let x = array![[0.5], [1.0], [-0.5]];
740        let groups = [0i64, 0, 1];
741        let m = CategoricalGee::new(Kind::Ordinal, y, x, &groups, CategoricalCov::Independence)
742            .unwrap();
743        assert_eq!(m.ncut, 2);
744        // y=0 => (0,0); y=1 => (1,0); y=2 => (1,1).
745        assert_eq!(m.endog.to_vec(), vec![0.0, 0.0, 1.0, 0.0, 1.0, 1.0]);
746        // Intercept columns are the first ncut columns.
747        assert_eq!(m.nparam, 2 + 1);
748    }
749
750    #[test]
751    fn nominal_expands_indicators() {
752        // 3 levels => ncut=2, indicators I(y==0), I(y==1).
753        let y = array![0.0, 1.0, 2.0];
754        let x = array![[1.0, 0.5], [1.0, 1.0], [1.0, -0.5]];
755        let groups = [0i64, 0, 1];
756        let m = CategoricalGee::new(Kind::Nominal, y, x, &groups, CategoricalCov::Independence)
757            .unwrap();
758        assert_eq!(m.ncut, 2);
759        assert_eq!(m.nparam, 2 * 2);
760        // y=0 => (1,0); y=1 => (0,1); y=2 => (0,0).
761        assert_eq!(m.endog.to_vec(), vec![1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
762    }
763
764    #[test]
765    fn nominal_mean_matches_softmax() {
766        // The multinomial-logit mean couples the ncut indicators of one
767        // observation: μ_k = e^{η_k} / (1 + Σ_j e^{η_j}), summing to < 1.
768        let y = array![0.0, 1.0, 2.0];
769        let x = array![[1.0], [1.0], [1.0]];
770        let groups = [0i64, 0, 0];
771        let m = CategoricalGee::new(Kind::Nominal, y, x, &groups, CategoricalCov::Independence)
772            .unwrap();
773        // One observation, ncut=2 rows; η = (0.5, -0.3).
774        let lpr = array![0.5_f64, -0.3];
775        let mu = m.mean(&lpr);
776        let denom = 1.0 + 0.5_f64.exp() + (-0.3_f64).exp();
777        assert!((mu[0] - 0.5_f64.exp() / denom).abs() < 1e-12);
778        assert!((mu[1] - (-0.3_f64).exp() / denom).abs() < 1e-12);
779        assert!(mu[0] + mu[1] < 1.0);
780    }
781
782    #[test]
783    fn ordinal_mean_is_logit() {
784        let y = array![0.0, 1.0, 2.0];
785        let x = array![[0.5], [1.0], [-0.5]];
786        let groups = [0i64, 0, 1];
787        let m = CategoricalGee::new(Kind::Ordinal, y, x, &groups, CategoricalCov::Independence)
788            .unwrap();
789        let lpr = array![0.7_f64, -1.2];
790        let mu = m.mean(&lpr);
791        assert!((mu[0] - 1.0 / (1.0 + (-0.7_f64).exp())).abs() < 1e-12);
792        assert!((mu[1] - 1.0 / (1.0 + 1.2_f64.exp())).abs() < 1e-12);
793    }
794}