use ndarray::{Array1, Array2};
use solow_core::error::{Error, Result};
use solow_distributions::norm_sf;
use solow_glm::{Family, Glm, Link};
use solow_linalg::{inv, solve};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CategoricalCov {
Independence,
GlobalOddsRatio,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Kind {
Nominal,
Ordinal,
}
#[derive(Clone, Debug)]
pub struct NominalGee {
inner: CategoricalGee,
}
#[derive(Clone, Debug)]
pub struct OrdinalGee {
inner: CategoricalGee,
}
impl NominalGee {
pub fn new(
endog: Array1<f64>,
exog: Array2<f64>,
group_labels: &[i64],
cov: CategoricalCov,
) -> Result<Self> {
Ok(NominalGee {
inner: CategoricalGee::new(Kind::Nominal, endog, exog, group_labels, cov)?,
})
}
pub fn maxiter(mut self, m: usize) -> Self {
self.inner.maxiter = m;
self
}
pub fn ctol(mut self, t: f64) -> Self {
self.inner.ctol = t;
self
}
pub fn fit(&self) -> Result<CategoricalGeeResults> {
self.inner.fit()
}
}
impl OrdinalGee {
pub fn new(
endog: Array1<f64>,
exog: Array2<f64>,
group_labels: &[i64],
cov: CategoricalCov,
) -> Result<Self> {
Ok(OrdinalGee {
inner: CategoricalGee::new(Kind::Ordinal, endog, exog, group_labels, cov)?,
})
}
pub fn maxiter(mut self, m: usize) -> Self {
self.inner.maxiter = m;
self
}
pub fn ctol(mut self, t: f64) -> Self {
self.inner.ctol = t;
self
}
pub fn fit(&self) -> Result<CategoricalGeeResults> {
self.inner.fit()
}
}
#[derive(Clone, Debug)]
struct CategoricalGee {
kind: Kind,
cov: CategoricalCov,
ncut: usize,
nparam: usize,
exog: Array2<f64>,
endog: Array1<f64>,
groups: Vec<Vec<usize>>,
group_nobs: Vec<usize>,
nrows: usize,
maxiter: usize,
ctol: f64,
}
impl CategoricalGee {
fn new(
kind: Kind,
endog: Array1<f64>,
exog: Array2<f64>,
group_labels: &[i64],
cov: CategoricalCov,
) -> Result<Self> {
let n = endog.len();
if n != exog.nrows() {
return Err(Error::Shape("endog length != exog rows".into()));
}
if group_labels.len() != n {
return Err(Error::Shape("group_labels length != endog length".into()));
}
let mut levels: Vec<f64> = endog.iter().copied().collect();
levels.sort_by(|a, b| a.total_cmp(b));
levels.dedup();
if levels.len() < 2 {
return Err(Error::Shape("endog must have at least two levels".into()));
}
let ncut = levels.len() - 1;
let cuts = &levels[..ncut];
let p = exog.ncols();
let nparam = match kind {
Kind::Nominal => ncut * p,
Kind::Ordinal => ncut + p,
};
let mut order: Vec<i64> = group_labels.to_vec();
order.sort_unstable();
order.dedup();
let mut orig_groups: Vec<Vec<usize>> = vec![Vec::new(); order.len()];
for (i, &lab) in group_labels.iter().enumerate() {
let pos = order.binary_search(&lab).unwrap();
orig_groups[pos].push(i);
}
let width = match kind {
Kind::Nominal => ncut * p,
Kind::Ordinal => ncut + p,
};
let nrows = ncut * n;
let mut exog_out = Array2::<f64>::zeros((nrows, width));
let mut endog_out = Array1::<f64>::zeros(nrows);
let mut groups: Vec<Vec<usize>> = Vec::with_capacity(order.len());
let mut group_nobs: Vec<usize> = Vec::with_capacity(order.len());
let mut jrow = 0usize;
for og in &orig_groups {
let mut rows: Vec<usize> = Vec::with_capacity(og.len() * ncut);
for &i in og {
let yval = endog[i];
for (cix, &cut) in cuts.iter().enumerate() {
match kind {
Kind::Ordinal => {
exog_out[[jrow, cix]] = 1.0;
for c in 0..p {
exog_out[[jrow, ncut + c]] = exog[[i, c]];
}
endog_out[jrow] = if yval > cut { 1.0 } else { 0.0 };
}
Kind::Nominal => {
let base = cix * p;
for c in 0..p {
exog_out[[jrow, base + c]] = exog[[i, c]];
}
endog_out[jrow] = if yval == cut { 1.0 } else { 0.0 };
}
}
rows.push(jrow);
jrow += 1;
}
}
group_nobs.push(og.len());
groups.push(rows);
}
Ok(CategoricalGee {
kind,
cov,
ncut,
nparam,
exog: exog_out,
endog: endog_out,
groups,
group_nobs,
nrows,
maxiter: 300,
ctol: 1e-10,
})
}
fn lin_pred(&self, idx: &[usize], params: &Array1<f64>) -> Array1<f64> {
let mut lpr = Array1::<f64>::zeros(idx.len());
for (k, &r) in idx.iter().enumerate() {
let mut s = 0.0;
for j in 0..self.nparam {
s += self.exog[[r, j]] * params[j];
}
lpr[k] = s;
}
lpr
}
fn mean(&self, lpr: &Array1<f64>) -> Array1<f64> {
match self.kind {
Kind::Ordinal => lpr.mapv(|e| 1.0 / (1.0 + (-e).exp())),
Kind::Nominal => {
let mut mu = Array1::<f64>::zeros(lpr.len());
let nobs = lpr.len() / self.ncut;
for o in 0..nobs {
let base = o * self.ncut;
let mut denom = 1.0;
for k in 0..self.ncut {
denom += lpr[base + k].exp();
}
for k in 0..self.ncut {
mu[base + k] = lpr[base + k].exp() / denom;
}
}
mu
}
}
}
fn mean_deriv(&self, idx: &[usize], mu: &Array1<f64>) -> Array2<f64> {
let m = idx.len();
let mut d = Array2::<f64>::zeros((m, self.nparam));
for (k, &r) in idx.iter().enumerate() {
let idl = mu[k] * (1.0 - mu[k]);
for j in 0..self.nparam {
d[[k, j]] = self.exog[[r, j]] * idl;
}
}
d
}
fn working_cov(&self, gi: usize, mu: &Array1<f64>, dep: f64) -> Array2<f64> {
let m = mu.len();
let nobs = self.group_nobs[gi];
let mut v = Array2::<f64>::zeros((m, m));
if self.cov == CategoricalCov::GlobalOddsRatio {
let eyy = self.get_eyy(mu, dep);
for a in 0..m {
for b in 0..m {
v[[a, b]] = eyy[[a, b]] - mu[a] * mu[b];
}
}
}
for o in 0..nobs {
let base = o * self.ncut;
for a in 0..self.ncut {
for b in 0..self.ncut {
let ea = mu[base + a];
let eb = mu[base + b];
let val = match self.kind {
Kind::Ordinal => ea.min(eb) - ea * eb,
Kind::Nominal => {
if a == b {
ea - ea * ea
} else {
-ea * eb
}
}
};
v[[base + a, base + b]] = val;
}
}
}
v
}
fn get_eyy(&self, mu: &Array1<f64>, dep: f64) -> Array2<f64> {
let m = mu.len();
let mut eyy = Array2::<f64>::zeros((m, m));
if dep == 1.0 {
for a in 0..m {
for b in 0..m {
eyy[[a, b]] = mu[a] * mu[b];
}
}
return eyy;
}
let or = dep;
for a in 0..m {
for b in 0..m {
let psum = mu[a] + mu[b];
let pprod = mu[a] * mu[b];
let pfac =
((1.0 + psum * (or - 1.0)).powi(2) + 4.0 * or * (1.0 - or) * pprod).sqrt();
eyy[[a, b]] = (1.0 + psum * (or - 1.0) - pfac) / (2.0 * (or - 1.0));
}
}
eyy
}
fn update_mean_params(
&self,
params: &Array1<f64>,
dep: f64,
) -> Result<(Array1<f64>, Array1<f64>)> {
let (bmat, _, score) = self.accumulate(params, dep)?;
let update = solve(&bmat, &score)?;
Ok((update, score))
}
fn accumulate(
&self,
params: &Array1<f64>,
dep: f64,
) -> Result<(Array2<f64>, Array2<f64>, Array1<f64>)> {
let p = self.nparam;
let mut bmat = Array2::<f64>::zeros((p, p));
let mut cmat = Array2::<f64>::zeros((p, p));
let mut score = Array1::<f64>::zeros(p);
for (gi, idx) in self.groups.iter().enumerate() {
if idx.is_empty() {
continue;
}
let lpr = self.lin_pred(idx, params);
let mu = self.mean(&lpr);
let resid: Array1<f64> = idx
.iter()
.zip(mu.iter())
.map(|(&r, m)| self.endog[r] - m)
.collect();
let dmat = self.mean_deriv(idx, &mu);
let vmat = self.working_cov(gi, &mu, dep);
let vinv_d = solve_mat(&vmat, &dmat)?;
let vinv_r = solve(&vmat, &resid)?;
bmat += &dmat.t().dot(&vinv_d);
let dvinv_resid = dmat.t().dot(&vinv_r);
score += &dvinv_resid;
for a in 0..p {
for b in 0..p {
cmat[[a, b]] += dvinv_resid[a] * dvinv_resid[b];
}
}
}
Ok((bmat, cmat, score))
}
fn observed_crude_oddsratio(&self) -> f64 {
let mut tables = self.empty_tables();
for (gi, idx) in self.groups.iter().enumerate() {
let nobs = self.group_nobs[gi];
let y: Array1<f64> = idx.iter().map(|&r| self.endog[r]).collect();
self.accumulate_tables(&mut tables, &y, &y, nobs);
}
pooled_odds_ratio(&tables)
}
fn empty_tables(&self) -> Vec<[[f64; 2]; 2]> {
let mut n = 0;
for k1 in 0..self.ncut {
n += k1 + 1;
}
vec![[[0.0; 2]; 2]; n]
}
fn pair_index(&self, k2: usize, k1: usize) -> usize {
let mut base = 0;
for k in 0..k1 {
base += k + 1;
}
base + k2
}
fn accumulate_tables(
&self,
tables: &mut [[[f64; 2]; 2]],
ya: &Array1<f64>,
yb: &Array1<f64>,
nobs: usize,
) {
for i1 in 0..nobs {
for i2 in 0..i1 {
for k1 in 0..self.ncut {
for k2 in 0..=k1 {
let a = i1 * self.ncut + k1;
let b = i2 * self.ncut + k2;
let p11 = ya[a] * yb[b];
let p10 = ya[a] * (1.0 - yb[b]);
let p01 = (1.0 - ya[a]) * yb[b];
let p00 = (1.0 - ya[a]) * (1.0 - yb[b]);
let t = &mut tables[self.pair_index(k2, k1)];
t[1][1] += p11;
t[1][0] += p10;
t[0][1] += p01;
t[0][0] += p00;
}
}
}
}
}
fn update_dep(&self, params: &Array1<f64>, dep: f64, crude_or: f64) -> f64 {
if self.group_nobs.iter().all(|&m| m <= 1) {
return dep;
}
let mut tables = self.empty_tables();
for (gi, idx) in self.groups.iter().enumerate() {
let nobs = self.group_nobs[gi];
if nobs <= 1 {
continue;
}
let lpr = self.lin_pred(idx, params);
let mu = self.mean(&lpr);
let eyy = self.get_eyy(&mu, dep);
for i1 in 0..nobs {
for i2 in 0..i1 {
for k1 in 0..self.ncut {
for k2 in 0..=k1 {
let a = i1 * self.ncut + k1;
let b = i2 * self.ncut + k2;
let e11 = eyy[[a, b]];
let e10 = mu[a] - e11;
let e01 = mu[b] - e11;
let e00 = 1.0 - (e11 + e10 + e01);
let t = &mut tables[self.pair_index(k2, k1)];
t[1][1] += e11;
t[1][0] += e10;
t[0][1] += e01;
t[0][0] += e00;
}
}
}
}
}
let cor_expval = pooled_odds_ratio(&tables);
let new_dep = dep * crude_or / cor_expval;
if new_dep.is_finite() {
new_dep
} else {
1.0
}
}
fn starting_params(&self) -> Result<Array1<f64>> {
let glm = Glm::with_link(
self.endog.clone(),
self.exog.clone(),
Family::Binomial,
Link::Logit,
)?
.fit()?;
Ok(glm.params)
}
fn fit(&self) -> Result<CategoricalGeeResults> {
let mut params = self.starting_params()?;
let update_dep =
self.cov == CategoricalCov::GlobalOddsRatio && self.group_nobs.iter().any(|&m| m > 1);
let crude_or = if update_dep {
self.observed_crude_oddsratio()
} else {
1.0
};
let mut dep = if update_dep { crude_or } else { 1.0 };
let mut score_norm = f64::INFINITY;
let mut num_assoc_updates = 0usize;
let mut converged = false;
for _ in 0..self.maxiter {
let (update, score) = self.update_mean_params(¶ms, dep)?;
params = ¶ms + &update;
score_norm = score.iter().map(|s| s * s).sum::<f64>().sqrt();
if score_norm < self.ctol && (num_assoc_updates > 0 || !update_dep) {
converged = true;
break;
}
if update_dep {
dep = self.update_dep(¶ms, dep, crude_or);
num_assoc_updates += 1;
} else {
converged = score_norm < self.ctol;
if converged {
break;
}
}
}
let (bmat, cmat, _) = self.accumulate(¶ms, dep)?;
let bmati = inv(&bmat)?;
let cov_naive = bmati.clone();
let cov_robust = bmati.dot(&cmat).dot(&bmati);
let p = self.nparam;
let bse: Array1<f64> = (0..p).map(|j| cov_robust[[j, j]].sqrt()).collect();
let bse_naive: Array1<f64> = (0..p).map(|j| cov_naive[[j, j]].sqrt()).collect();
let tvalues: Array1<f64> = params.iter().zip(bse.iter()).map(|(b, s)| b / s).collect();
let pvalues: Array1<f64> = tvalues.mapv(|t| 2.0 * norm_sf(t.abs()));
let mut fitted = Array1::<f64>::zeros(self.nrows);
for idx in &self.groups {
let lpr = self.lin_pred(idx, ¶ms);
let mu = self.mean(&lpr);
for (k, &r) in idx.iter().enumerate() {
fitted[r] = mu[k];
}
}
Ok(CategoricalGeeResults {
params,
bse,
bse_naive,
tvalues,
pvalues,
cov_robust,
cov_naive,
dep_params: if update_dep { dep } else { 0.0 },
scale: 1.0,
fittedvalues: fitted,
ncut: self.ncut,
score_norm,
converged,
})
}
}
#[derive(Clone, Debug)]
pub struct CategoricalGeeResults {
pub params: Array1<f64>,
pub bse: Array1<f64>,
pub bse_naive: Array1<f64>,
pub tvalues: Array1<f64>,
pub pvalues: Array1<f64>,
pub cov_robust: Array2<f64>,
pub cov_naive: Array2<f64>,
pub dep_params: f64,
pub scale: f64,
pub fittedvalues: Array1<f64>,
pub ncut: usize,
pub score_norm: f64,
pub converged: bool,
}
fn pooled_odds_ratio(tables: &[[[f64; 2]; 2]]) -> f64 {
if tables.is_empty() {
return 1.0;
}
let mut log_or = Vec::with_capacity(tables.len());
let mut var = Vec::with_capacity(tables.len());
for t in tables {
let lor = t[1][1].ln() + t[0][0].ln() - t[0][1].ln() - t[1][0].ln();
log_or.push(lor);
var.push(1.0 / t[1][1] + 1.0 / t[0][0] + 1.0 / t[0][1] + 1.0 / t[1][0]);
}
let wts: Vec<f64> = var.iter().map(|v| 1.0 / v).collect();
let wtsum: f64 = wts.iter().sum();
let log_pooled: f64 = wts
.iter()
.zip(log_or.iter())
.map(|(w, e)| (w / wtsum) * e)
.sum();
log_pooled.exp()
}
fn solve_mat(a: &Array2<f64>, b: &Array2<f64>) -> Result<Array2<f64>> {
let (m, k) = b.dim();
let mut out = Array2::<f64>::zeros((m, k));
for j in 0..k {
let col = b.column(j).to_owned();
let sol = solve(a, &col)?;
for i in 0..m {
out[[i, j]] = sol[i];
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::array;
#[test]
fn ordinal_expands_indicators() {
let y = array![0.0, 1.0, 2.0];
let x = array![[0.5], [1.0], [-0.5]];
let groups = [0i64, 0, 1];
let m = CategoricalGee::new(Kind::Ordinal, y, x, &groups, CategoricalCov::Independence)
.unwrap();
assert_eq!(m.ncut, 2);
assert_eq!(m.endog.to_vec(), vec![0.0, 0.0, 1.0, 0.0, 1.0, 1.0]);
assert_eq!(m.nparam, 2 + 1);
}
#[test]
fn nominal_expands_indicators() {
let y = array![0.0, 1.0, 2.0];
let x = array![[1.0, 0.5], [1.0, 1.0], [1.0, -0.5]];
let groups = [0i64, 0, 1];
let m = CategoricalGee::new(Kind::Nominal, y, x, &groups, CategoricalCov::Independence)
.unwrap();
assert_eq!(m.ncut, 2);
assert_eq!(m.nparam, 2 * 2);
assert_eq!(m.endog.to_vec(), vec![1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
}
#[test]
fn nominal_mean_matches_softmax() {
let y = array![0.0, 1.0, 2.0];
let x = array![[1.0], [1.0], [1.0]];
let groups = [0i64, 0, 0];
let m = CategoricalGee::new(Kind::Nominal, y, x, &groups, CategoricalCov::Independence)
.unwrap();
let lpr = array![0.5_f64, -0.3];
let mu = m.mean(&lpr);
let denom = 1.0 + 0.5_f64.exp() + (-0.3_f64).exp();
assert!((mu[0] - 0.5_f64.exp() / denom).abs() < 1e-12);
assert!((mu[1] - (-0.3_f64).exp() / denom).abs() < 1e-12);
assert!(mu[0] + mu[1] < 1.0);
}
#[test]
fn ordinal_mean_is_logit() {
let y = array![0.0, 1.0, 2.0];
let x = array![[0.5], [1.0], [-0.5]];
let groups = [0i64, 0, 1];
let m = CategoricalGee::new(Kind::Ordinal, y, x, &groups, CategoricalCov::Independence)
.unwrap();
let lpr = array![0.7_f64, -1.2];
let mu = m.mean(&lpr);
assert!((mu[0] - 1.0 / (1.0 + (-0.7_f64).exp())).abs() < 1e-12);
assert!((mu[1] - 1.0 / (1.0 + 1.2_f64.exp())).abs() < 1e-12);
}
}