use num_traits::{One, Signed, Zero};
use super::common::{
WaldSummary, check_confidence, chi_squared_sf, ex, ex_usize, f_sf, information_cholesky,
invalid, normal_two_sided, qu, t_two_sided, wald_summary, z_two_sided,
};
use super::data::Q;
use super::hypothesis::{self, Alternative, TestResult};
use crate::api::context::Context;
use crate::api::expr::Ex;
use crate::base::dense_f64::{self, dot as dot_f64};
use crate::base::errors::SymplexError;
use crate::base::interval::Interval;
use crate::base::numeric::ratio_to_f64;
use crate::domains::exact_matrix::QMatrix;
fn failed(op: &'static str, reason: impl Into<String>) -> SymplexError {
SymplexError::computation_failed(op, reason)
}
fn to_f64(op: &'static str, q: &Q) -> Result<f64, SymplexError> {
ratio_to_f64(q).ok_or_else(|| failed(op, format!("{q} does not fit in an f64")))
}
fn dot(a: &[Q], b: &[Q]) -> Q {
a.iter().zip(b).fold(Q::zero(), |acc, (x, y)| acc + x * y)
}
fn quadratic_form(m: &QMatrix, v: &[Q]) -> Q {
let mut acc = Q::zero();
for (a, va) in v.iter().enumerate() {
for (b, vb) in v.iter().enumerate() {
if let Some(mab) = m.try_get(a, b) {
acc += va * mab * vb;
}
}
}
acc
}
fn column_vector(op: &'static str, v: &[Q]) -> Result<QMatrix, SymplexError> {
QMatrix::new(v.iter().map(|q| vec![q.clone()]).collect())
.map_err(|e| invalid(op, e.to_string()))
}
fn student_two_sided(ctx: &Context, df: usize, t_squared: &Q) -> Ex {
if t_squared.is_zero() {
return ctx.one();
}
let nu = qu(df);
let z = &nu / (t_squared + &nu);
ex(ctx, &z).betainc_regularized(&ex(ctx, &(nu / qu(2))), &ctx.rational(1, 2), &ctx.zero())
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Design {
intercept: bool,
columns: Vec<Vec<Q>>,
}
impl Design {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn intercept(mut self) -> Self {
self.intercept = true;
self
}
#[must_use]
pub fn column(mut self, values: &[Q]) -> Self {
self.columns.push(values.to_vec());
self
}
#[must_use]
pub fn has_intercept(&self) -> bool {
self.intercept
}
#[must_use]
pub fn n_columns(&self) -> usize {
self.columns.len()
}
pub fn rows(&self, n: usize) -> Result<Vec<Vec<Q>>, SymplexError> {
const OP: &str = "Design::rows";
for (j, c) in self.columns.iter().enumerate() {
if c.len() != n {
return Err(invalid(
OP,
format!("column {j} has {} entries, expected {n}", c.len()),
));
}
}
Ok((0..n)
.map(|i| self.columns.iter().map(|c| c[i].clone()).collect())
.collect())
}
pub fn fit(&self, y: &[Q]) -> Result<Ols, SymplexError> {
let rows = self.rows(y.len())?;
ols(y, &rows, self.intercept)
}
pub fn fit_weighted(&self, y: &[Q], weights: &[Q]) -> Result<Ols, SymplexError> {
let rows = self.rows(y.len())?;
wls(y, &rows, weights, self.intercept)
}
}
fn build_design(
op: &'static str,
x: &[Vec<Q>],
n: usize,
add_intercept: bool,
) -> Result<QMatrix, SymplexError> {
if x.len() != n {
return Err(invalid(
op,
format!("y has {n} observations but x has {} rows", x.len()),
));
}
let k = x.first().map_or(0, Vec::len);
if let Some((i, r)) = x.iter().enumerate().find(|(_, r)| r.len() != k) {
return Err(invalid(
op,
format!("row {i} of x has {} entries, expected {k}", r.len()),
));
}
if k + usize::from(add_intercept) == 0 {
return Err(invalid(
op,
"the design has no columns: pass at least one regressor or add_intercept = true",
));
}
let rows = x
.iter()
.map(|r| {
let mut row = Vec::with_capacity(k + 1);
if add_intercept {
row.push(Q::one());
}
row.extend(r.iter().cloned());
row
})
.collect();
QMatrix::new(rows).map_err(|e| invalid(op, e.to_string()))
}
fn has_constant_column(x: &QMatrix) -> bool {
let explicit = (0..x.ncols()).any(|j| {
let c = x.col(j);
c.first()
.is_some_and(|c0| !c0.is_zero() && c.iter().all(|v| v == c0))
});
if explicit {
return true;
}
let ones = QMatrix::new(vec![vec![Q::one()]; x.nrows()]);
match ones.and_then(|o| QMatrix::hstack(&[&o, x])) {
Ok(aug) => aug.rank() == x.rank(),
Err(_) => false,
}
}
fn normal_equations(
op: &'static str,
x: &QMatrix,
y: &[Q],
weights: Option<&[Q]>,
) -> Result<(Vec<Q>, QMatrix), SymplexError> {
let p = x.ncols();
let weight = |i: usize| {
weights
.and_then(|w| w.get(i).cloned())
.unwrap_or_else(Q::one)
};
let wx = QMatrix::new(
x.rows()
.enumerate()
.map(|(i, r)| {
let wi = weight(i);
r.iter().map(|v| v * &wi).collect()
})
.collect(),
)
.map_err(|e| invalid(op, e.to_string()))?;
let wy: Vec<Q> = y.iter().enumerate().map(|(i, v)| v * weight(i)).collect();
let xt = x.transpose();
let xtwx = xt.matmul(&wx)?;
let xtwy = xt.matmul(&column_vector(op, &wy)?)?;
let xtx_inv = xtwx.inv().map_err(|_| {
invalid(
op,
format!(
"the design matrix is rank deficient (rank {} of {p} columns): drop a collinear regressor",
x.rank()
),
)
})?;
let beta = xtx_inv.matmul(&xtwy)?.col(0);
Ok((beta, xtx_inv))
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AnovaTable {
pub ss_model: Q,
pub df_model: usize,
pub ms_model: Q,
pub ss_resid: Q,
pub df_resid: usize,
pub ms_resid: Q,
pub ss_total: Q,
pub df_total: usize,
pub f: Q,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Ols {
pub coefficients: Vec<Q>,
pub fitted: Vec<Q>,
pub residuals: Vec<Q>,
pub ssr: Q,
pub ess: Q,
pub tss: Q,
pub r_squared: Q,
pub adjusted_r_squared: Q,
pub df_model: usize,
pub df_resid: usize,
pub mse_resid: Q,
pub cov_params: QMatrix,
design: QMatrix,
y: Vec<Q>,
weights: Option<Vec<Q>>,
xtx_inv: QMatrix,
has_constant: bool,
added_intercept: bool,
}
fn fit_least_squares(
op: &'static str,
y: &[Q],
x: &[Vec<Q>],
weights: Option<&[Q]>,
add_intercept: bool,
) -> Result<Ols, SymplexError> {
let n = y.len();
if n == 0 {
return Err(invalid(op, "y is empty"));
}
let design = build_design(op, x, n, add_intercept)?;
let p = design.ncols();
if n <= p {
return Err(invalid(
op,
format!("need more observations than parameters: n = {n}, p = {p}"),
));
}
if let Some(w) = weights {
if w.len() != n {
return Err(invalid(
op,
format!("weights has {} entries, expected {n}", w.len()),
));
}
if let Some((i, wi)) = w.iter().enumerate().find(|(_, wi)| !wi.is_positive()) {
return Err(invalid(
op,
format!("weights must be positive, got {wi} at index {i}"),
));
}
}
let (coefficients, xtx_inv) = normal_equations(op, &design, y, weights)?;
let fitted: Vec<Q> = design.rows().map(|r| dot(r, &coefficients)).collect();
let residuals: Vec<Q> = y.iter().zip(&fitted).map(|(a, b)| a - b).collect();
let weight = |i: usize| {
weights
.and_then(|w| w.get(i).cloned())
.unwrap_or_else(Q::one)
};
let ssr = residuals
.iter()
.enumerate()
.fold(Q::zero(), |acc, (i, e)| acc + weight(i) * e * e);
let has_constant = add_intercept || has_constant_column(&design);
let tss = if has_constant {
let sum_w = (0..n).fold(Q::zero(), |acc, i| acc + weight(i));
let ybar = y
.iter()
.enumerate()
.fold(Q::zero(), |acc, (i, v)| acc + weight(i) * v)
/ sum_w;
y.iter().enumerate().fold(Q::zero(), |acc, (i, v)| {
let d = v - &ybar;
acc + weight(i) * &d * &d
})
} else {
y.iter()
.enumerate()
.fold(Q::zero(), |acc, (i, v)| acc + weight(i) * v * v)
};
if tss.is_zero() {
return Err(invalid(
op,
"the response is constant (zero total sum of squares): R² is undefined",
));
}
let ess = &tss - &ssr;
let r_squared = Q::one() - &ssr / &tss;
let k_constant = usize::from(has_constant);
let df_model = p - k_constant;
let df_resid = n - p;
let adjusted_r_squared = Q::one() - qu(n - k_constant) / qu(df_resid) * (Q::one() - &r_squared);
let mse_resid = &ssr / qu(df_resid);
let cov_params = xtx_inv.scale(&mse_resid);
Ok(Ols {
coefficients,
fitted,
residuals,
ssr,
ess,
tss,
r_squared,
adjusted_r_squared,
df_model,
df_resid,
mse_resid,
cov_params,
design,
y: y.to_vec(),
weights: weights.map(<[Q]>::to_vec),
xtx_inv,
has_constant,
added_intercept: add_intercept,
})
}
pub fn ols(y: &[Q], x: &[Vec<Q>], add_intercept: bool) -> Result<Ols, SymplexError> {
fit_least_squares("ols", y, x, None, add_intercept)
}
pub fn wls(y: &[Q], x: &[Vec<Q>], weights: &[Q], add_intercept: bool) -> Result<Ols, SymplexError> {
fit_least_squares("wls", y, x, Some(weights), add_intercept)
}
pub fn simple_linear_regression(x: &[Q], y: &[Q]) -> Result<Ols, SymplexError> {
const OP: &str = "simple_linear_regression";
if x.len() != y.len() {
return Err(invalid(
OP,
format!(
"x and y must have the same length ({} and {})",
x.len(),
y.len()
),
));
}
let rows: Vec<Vec<Q>> = x.iter().map(|v| vec![v.clone()]).collect();
fit_least_squares(OP, y, &rows, None, true)
}
pub fn polyfit(x: &[Q], y: &[Q], degree: usize) -> Result<Vec<Q>, SymplexError> {
const OP: &str = "polyfit";
if x.len() != y.len() {
return Err(invalid(
OP,
format!(
"x and y must have the same length ({} and {})",
x.len(),
y.len()
),
));
}
let n = x.len();
if n < degree + 1 {
return Err(invalid(
OP,
format!(
"degree {degree} needs at least {} points, got {n}",
degree + 1
),
));
}
let rows: Vec<Vec<Q>> = x
.iter()
.map(|v| {
let mut row = Vec::with_capacity(degree + 1);
let mut power = Q::one();
row.push(power.clone());
for _ in 0..degree {
power *= v;
row.push(power.clone());
}
row
})
.collect();
let design = QMatrix::new(rows).map_err(|e| invalid(OP, e.to_string()))?;
let (beta, _) = normal_equations(OP, &design, y, None)?;
Ok(beta)
}
pub fn hat_matrix(design: &QMatrix) -> Result<QMatrix, SymplexError> {
const OP: &str = "hat_matrix";
let xt = design.transpose();
let xtx_inv = xt.matmul(design)?.inv().map_err(|_| {
invalid(
OP,
format!(
"the design matrix is rank deficient (rank {} of {} columns)",
design.rank(),
design.ncols()
),
)
})?;
design.matmul(&xtx_inv)?.matmul(&xt)
}
pub fn vif(x: &[Vec<Q>]) -> Result<Vec<Q>, SymplexError> {
const OP: &str = "vif";
let n = x.len();
if n == 0 {
return Err(invalid(OP, "x is empty"));
}
let k = x[0].len();
if let Some((i, r)) = x.iter().enumerate().find(|(_, r)| r.len() != k) {
return Err(invalid(
OP,
format!("row {i} of x has {} entries, expected {k}", r.len()),
));
}
(0..k)
.map(|j| {
let target: Vec<Q> = x.iter().map(|r| r[j].clone()).collect();
let others: Vec<Vec<Q>> = x
.iter()
.map(|r| {
r.iter()
.enumerate()
.filter(|&(c, _)| c != j)
.map(|(_, v)| v.clone())
.collect()
})
.collect();
let fit = fit_least_squares(OP, &target, &others, None, true)
.map_err(|e| invalid(OP, format!("column {j}: {e}")))?;
if fit.r_squared.is_one() {
return Err(invalid(
OP,
format!(
"column {j} is an exact linear combination of the others (infinite VIF)"
),
));
}
Ok((Q::one() - fit.r_squared).recip())
})
.collect()
}
#[must_use]
pub fn r_squared_from_correlation(r: &Ex) -> Ex {
r.powi(2).simplify()
}
pub fn slope_from_correlation(r: &Ex, sd_x: &Ex, sd_y: &Ex) -> Result<Ex, SymplexError> {
if sd_x.as_rational().is_some_and(|q| q.is_zero()) {
return Err(invalid(
"slope_from_correlation",
"the standard deviation of x is zero",
));
}
Ok((r * sd_y / sd_x).simplify())
}
impl Ols {
#[must_use]
pub fn design(&self) -> &QMatrix {
&self.design
}
#[must_use]
pub fn nobs(&self) -> usize {
self.design.nrows()
}
#[must_use]
pub fn n_params(&self) -> usize {
self.design.ncols()
}
#[must_use]
pub fn normalized_cov_params(&self) -> &QMatrix {
&self.xtx_inv
}
#[must_use]
pub fn has_constant(&self) -> bool {
self.has_constant
}
#[must_use]
pub fn weights(&self) -> Option<&[Q]> {
self.weights.as_deref()
}
fn weight(&self, i: usize) -> Q {
self.weights
.as_ref()
.and_then(|w| w.get(i).cloned())
.unwrap_or_else(Q::one)
}
#[must_use]
pub fn residual_standard_error(&self, ctx: &Context) -> Ex {
ex(ctx, &self.mse_resid).sqrt().simplify()
}
#[must_use]
pub fn standard_errors(&self, ctx: &Context) -> Vec<Ex> {
self.cov_params
.diagonal()
.iter()
.map(|v| ex(ctx, v).sqrt().simplify())
.collect()
}
fn require_residual_variance(&self, op: &'static str) -> Result<(), SymplexError> {
if self.ssr.is_zero() {
return Err(invalid(
op,
"the fit is perfect (SSR = 0): σ̂² = 0 and the statistic is undefined",
));
}
Ok(())
}
pub fn t_statistics(&self, ctx: &Context) -> Result<Vec<Ex>, SymplexError> {
self.require_residual_variance("t_statistics")?;
Ok(self
.coefficients
.iter()
.zip(self.cov_params.diagonal())
.map(|(b, v)| (ex(ctx, b) / ex(ctx, &v).sqrt()).simplify())
.collect())
}
pub fn p_values(&self, ctx: &Context) -> Result<Vec<Ex>, SymplexError> {
self.require_residual_variance("p_values")?;
Ok(self
.coefficients
.iter()
.zip(self.cov_params.diagonal())
.map(|(b, v)| student_two_sided(ctx, self.df_resid, &(b * b / v)))
.collect())
}
pub fn p_values_log10(&self, ctx: &Context) -> Result<Vec<f64>, SymplexError> {
self.p_values(ctx)?
.iter()
.map(hypothesis::p_value_log10_of)
.collect()
}
pub fn coefficient_tests(&self, ctx: &Context) -> Result<Vec<TestResult>, SymplexError> {
let stats = self.t_statistics(ctx)?;
let ps = self.p_values(ctx)?;
Ok(stats
.into_iter()
.zip(ps)
.map(|(statistic, p_value)| TestResult {
statistic,
p_value,
df: Some(ex_usize(ctx, self.df_resid)),
alternative: Alternative::TwoSided,
})
.collect())
}
pub fn f_statistic(&self) -> Result<Q, SymplexError> {
const OP: &str = "f_statistic";
if self.df_model == 0 {
return Err(invalid(
OP,
"the model has no regressors besides the constant (df_model = 0)",
));
}
self.require_residual_variance(OP)?;
Ok((&self.ess / qu(self.df_model)) / &self.mse_resid)
}
pub fn f_test(&self, ctx: &Context) -> Result<TestResult, SymplexError> {
let f = self.f_statistic()?;
Ok(TestResult {
statistic: ex(ctx, &f),
p_value: f_sf(ctx, self.df_model, self.df_resid, &f),
df: Some(ex_usize(ctx, self.df_resid)),
alternative: Alternative::Greater,
})
}
pub fn anova_table(&self) -> Result<AnovaTable, SymplexError> {
let f = self.f_statistic()?;
Ok(AnovaTable {
ss_model: self.ess.clone(),
df_model: self.df_model,
ms_model: &self.ess / qu(self.df_model),
ss_resid: self.ssr.clone(),
df_resid: self.df_resid,
ms_resid: self.mse_resid.clone(),
ss_total: self.tss.clone(),
df_total: self.df_model + self.df_resid,
f,
})
}
pub fn conf_int(&self, confidence: f64) -> Result<Vec<Interval<f64>>, SymplexError> {
const OP: &str = "conf_int";
check_confidence(OP, confidence)?;
let t = t_two_sided(OP, self.df_resid as f64, confidence)?;
self.coefficients
.iter()
.zip(self.cov_params.diagonal())
.map(|(b, v)| {
let b = to_f64(OP, b)?;
let se = to_f64(OP, &v)?.sqrt();
Ok(Interval::closed(b - t * se, b + t * se))
})
.collect()
}
fn design_row(&self, op: &'static str, x_row: &[Q]) -> Result<Vec<Q>, SymplexError> {
let k = self.n_params() - usize::from(self.added_intercept);
if x_row.len() != k {
return Err(invalid(
op,
format!(
"x_row has {} entries, expected {k} (the regressors without the intercept)",
x_row.len()
),
));
}
let mut row = Vec::with_capacity(k + 1);
if self.added_intercept {
row.push(Q::one());
}
row.extend(x_row.iter().cloned());
Ok(row)
}
pub fn predict(&self, x_row: &[Q]) -> Result<Q, SymplexError> {
let row = self.design_row("predict", x_row)?;
Ok(dot(&row, &self.coefficients))
}
fn interval_parts(
&self,
op: &'static str,
x_row: &[Q],
confidence: f64,
) -> Result<(f64, f64, f64), SymplexError> {
check_confidence(op, confidence)?;
let row = self.design_row(op, x_row)?;
let yhat = to_f64(op, &dot(&row, &self.coefficients))?;
let factor = to_f64(op, &quadratic_form(&self.xtx_inv, &row))?;
let t = t_two_sided(op, self.df_resid as f64, confidence)?;
Ok((yhat, factor, t))
}
pub fn confidence_interval_mean_response(
&self,
x_row: &[Q],
confidence: f64,
) -> Result<Interval<f64>, SymplexError> {
const OP: &str = "confidence_interval_mean_response";
let (yhat, factor, t) = self.interval_parts(OP, x_row, confidence)?;
let se = (to_f64(OP, &self.mse_resid)? * factor).sqrt();
Ok(Interval::closed(yhat - t * se, yhat + t * se))
}
pub fn prediction_interval(
&self,
x_row: &[Q],
confidence: f64,
) -> Result<Interval<f64>, SymplexError> {
const OP: &str = "prediction_interval";
let (yhat, factor, t) = self.interval_parts(OP, x_row, confidence)?;
let se = (to_f64(OP, &self.mse_resid)? * (1.0 + factor)).sqrt();
Ok(Interval::closed(yhat - t * se, yhat + t * se))
}
pub fn hat_matrix(&self) -> Result<QMatrix, SymplexError> {
const OP: &str = "hat_matrix";
let xtw = QMatrix::new(
self.design
.rows()
.enumerate()
.map(|(i, r)| {
let w = self.weight(i);
r.iter().map(|v| v * &w).collect()
})
.collect(),
)
.map_err(|e| failed(OP, e.to_string()))?
.transpose();
self.design.matmul(&self.xtx_inv)?.matmul(&xtw)
}
#[must_use]
pub fn leverage(&self) -> Vec<Q> {
self.design
.rows()
.enumerate()
.map(|(i, r)| self.weight(i) * quadratic_form(&self.xtx_inv, r))
.collect()
}
pub fn cooks_distance(&self) -> Result<Vec<Q>, SymplexError> {
const OP: &str = "cooks_distance";
self.require_residual_variance(OP)?;
let p = qu(self.n_params());
self.leverage()
.iter()
.zip(&self.residuals)
.enumerate()
.map(|(i, (h, e))| {
let one_minus = Q::one() - h;
if one_minus.is_zero() {
return Err(invalid(
OP,
format!("observation {i} has leverage 1: Cook's distance is undefined"),
));
}
Ok(self.weight(i) * e * e * h / (&p * &self.mse_resid * &one_minus * &one_minus))
})
.collect()
}
pub fn durbin_watson(&self) -> Result<Q, SymplexError> {
let denom = self.residuals.iter().fold(Q::zero(), |acc, e| acc + e * e);
if denom.is_zero() {
return Err(invalid(
"durbin_watson",
"every residual is zero: the statistic is undefined",
));
}
let num = self.residuals.windows(2).fold(Q::zero(), |acc, w| {
let d = &w[1] - &w[0];
acc + &d * &d
});
Ok(num / denom)
}
pub fn log_likelihood(&self, ctx: &Context) -> Result<Ex, SymplexError> {
const OP: &str = "log_likelihood";
if self.ssr.is_zero() {
return Err(invalid(
OP,
"the fit is perfect (SSR = 0): the Gaussian log-likelihood is unbounded",
));
}
let n = self.nobs();
let half_n = ex(ctx, &(qu(n) / qu(2)));
let two_pi = ctx.int(2) * ctx.pi();
let mut llf = -half_n * (two_pi.ln() + ex(ctx, &(&self.ssr / qu(n))).ln() + ctx.one());
if let Some(w) = &self.weights {
let sum_ln = w.iter().fold(ctx.zero(), |acc, wi| acc + ex(ctx, wi).ln());
llf += ctx.rational(1, 2) * sum_ln;
}
Ok(llf)
}
pub fn aic(&self, ctx: &Context) -> Result<Ex, SymplexError> {
let llf = self.log_likelihood(ctx)?;
Ok(ctx.int(2) * ex_usize(ctx, self.n_params()) - ctx.int(2) * llf)
}
pub fn bic(&self, ctx: &Context) -> Result<Ex, SymplexError> {
let llf = self.log_likelihood(ctx)?;
Ok(ex_usize(ctx, self.n_params()) * ex_usize(ctx, self.nobs()).ln() - ctx.int(2) * llf)
}
#[must_use]
pub fn response(&self) -> &[Q] {
&self.y
}
}
pub trait BinaryOutcome: Copy {
fn as_outcome(self) -> Option<bool>;
}
impl BinaryOutcome for bool {
fn as_outcome(self) -> Option<bool> {
Some(self)
}
}
impl BinaryOutcome for u8 {
fn as_outcome(self) -> Option<bool> {
match self {
0 => Some(false),
1 => Some(true),
_ => None,
}
}
}
impl BinaryOutcome for i64 {
fn as_outcome(self) -> Option<bool> {
match self {
0 => Some(false),
1 => Some(true),
_ => None,
}
}
}
impl BinaryOutcome for f64 {
fn as_outcome(self) -> Option<bool> {
if self == 0.0 {
Some(false)
} else if self == 1.0 {
Some(true)
} else {
None
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LogitOpts {
pub max_iter: usize,
pub tol: f64,
}
impl Default for LogitOpts {
fn default() -> Self {
Self {
max_iter: 100,
tol: 1e-10,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Logit {
pub coefficients: Vec<f64>,
pub standard_errors: Vec<f64>,
pub z_values: Vec<f64>,
pub p_values: Vec<f64>,
pub log_likelihood: f64,
pub null_log_likelihood: f64,
pub pseudo_r_squared: f64,
pub deviance: f64,
pub iterations: usize,
pub converged: bool,
pub fitted_probabilities: Vec<f64>,
pub cov_params: Vec<Vec<f64>>,
pub nobs: usize,
pub df_model: usize,
pub df_resid: usize,
added_intercept: bool,
}
fn sigmoid(eta: f64) -> f64 {
if eta >= 0.0 {
1.0 / (1.0 + (-eta).exp())
} else {
let e = eta.exp();
e / (1.0 + e)
}
}
fn softplus(x: f64) -> f64 {
if x > 0.0 {
x + (-x).exp().ln_1p()
} else {
x.exp().ln_1p()
}
}
pub fn logit<B: BinaryOutcome>(
y: &[B],
x: &[Vec<f64>],
add_intercept: bool,
opts: &LogitOpts,
) -> Result<Logit, SymplexError> {
const OP: &str = "logit";
let n = y.len();
if n == 0 {
return Err(invalid(OP, "y is empty"));
}
if opts.max_iter == 0 {
return Err(invalid(OP, "max_iter must be positive"));
}
if opts.tol.is_nan() || opts.tol <= 0.0 {
return Err(invalid(
OP,
format!("tol must be positive, got {}", opts.tol),
));
}
let yb: Vec<f64> = y
.iter()
.enumerate()
.map(|(i, v)| {
v.as_outcome()
.map(|b| if b { 1.0 } else { 0.0 })
.ok_or_else(|| invalid(OP, format!("y[{i}] is not a 0/1 outcome")))
})
.collect::<Result<_, _>>()?;
let successes = yb.iter().filter(|v| **v == 1.0).count();
if successes == 0 || successes == n {
return Err(invalid(
OP,
"y is constant (all successes or all failures): the coefficients are not identified",
));
}
if x.len() != n {
return Err(invalid(
OP,
format!("y has {n} observations but x has {} rows", x.len()),
));
}
let k = x.first().map_or(0, Vec::len);
if let Some((i, r)) = x.iter().enumerate().find(|(_, r)| r.len() != k) {
return Err(invalid(
OP,
format!("row {i} of x has {} entries, expected {k}", r.len()),
));
}
if let Some((i, j)) = x
.iter()
.enumerate()
.find_map(|(i, r)| r.iter().position(|v| !v.is_finite()).map(|j| (i, j)))
{
return Err(invalid(OP, format!("x[{i}][{j}] is not finite")));
}
let p = k + usize::from(add_intercept);
if p == 0 {
return Err(invalid(
OP,
"the design has no columns: pass at least one regressor or add_intercept = true",
));
}
if n <= p {
return Err(invalid(
OP,
format!("need more observations than parameters: n = {n}, p = {p}"),
));
}
let design: Vec<Vec<f64>> = x
.iter()
.map(|r| {
let mut row = Vec::with_capacity(p);
if add_intercept {
row.push(1.0);
}
row.extend_from_slice(r);
row
})
.collect();
let score_and_information = |beta: &[f64], probs: &mut [f64]| {
let mut g = vec![0.0; p];
let mut h = vec![vec![0.0; p]; p];
for (i, row) in design.iter().enumerate() {
let pi = sigmoid(dot_f64(row, beta));
probs[i] = pi;
let r = yb[i] - pi;
let w = pi * (1.0 - pi);
for a in 0..p {
g[a] += row[a] * r;
for b in 0..p {
h[a][b] += w * row[a] * row[b];
}
}
}
(g, h)
};
let mut beta = vec![0.0; p];
let mut probs = vec![0.5; n];
let mut converged = false;
let mut iterations = 0;
for iter in 1..=opts.max_iter {
iterations = iter;
let (g, h) = score_and_information(&beta, &mut probs);
let Some(l) = information_cholesky(&h) else {
return Err(if iter == 1 {
invalid(
OP,
"the design matrix is rank deficient: drop a collinear regressor",
)
} else {
failed(
OP,
"the Hessian became singular: complete or quasi-complete separation, the maximum-likelihood estimate does not exist",
)
});
};
let step = dense_f64::cholesky_solve(&l, p, &g);
for (b, s) in beta.iter_mut().zip(&step) {
*b += s;
}
if beta.iter().any(|b| !b.is_finite()) {
return Err(failed(
OP,
"the coefficients diverged: perfect separation, the maximum-likelihood estimate does not exist",
));
}
let max_dev = probs
.iter()
.zip(&yb)
.fold(0.0_f64, |m, (pi, yi)| m.max((pi - yi).abs()));
if max_dev <= 1e-8 {
return Err(failed(
OP,
"perfect separation: every observation is predicted exactly (|p̂ − y| ≤ 1e-8), the maximum-likelihood estimate does not exist",
));
}
let max_step = step.iter().fold(0.0_f64, |m, s| m.max(s.abs()));
let scale = beta.iter().fold(1.0_f64, |m, b| m.max(b.abs()));
if max_step <= opts.tol * scale {
converged = true;
break;
}
}
let (_, h) = score_and_information(&beta, &mut probs);
if !converged {
let degenerate = probs.iter().any(|pi| pi * (1.0 - pi) < 1e-10);
if degenerate {
return Err(failed(
OP,
format!(
"no convergence in {} iterations while fitted probabilities reached 0 or 1: complete or quasi-complete separation, the maximum-likelihood estimate does not exist",
opts.max_iter
),
));
}
}
let wald = wald_summary(&h, &beta).ok_or_else(|| {
failed(
OP,
"the Hessian at the estimate is singular: the standard errors are undefined",
)
})?;
let log_likelihood = design
.iter()
.zip(&yb)
.map(|(row, yi)| {
let eta = dot_f64(row, &beta);
if *yi == 1.0 {
-softplus(-eta)
} else {
-softplus(eta)
}
})
.sum::<f64>();
let ybar = successes as f64 / n as f64;
let null_log_likelihood = n as f64 * (ybar * ybar.ln() + (1.0 - ybar) * (1.0 - ybar).ln());
Ok(Logit {
pseudo_r_squared: 1.0 - log_likelihood / null_log_likelihood,
deviance: -2.0 * log_likelihood,
coefficients: beta,
standard_errors: wald.se,
z_values: wald.z,
p_values: wald.p,
log_likelihood,
null_log_likelihood,
iterations,
converged,
fitted_probabilities: probs,
cov_params: wald.cov,
nobs: n,
df_model: p - 1,
df_resid: n - p,
added_intercept: add_intercept,
})
}
pub(crate) fn chi_squared_test_result(
ctx: &Context,
statistic: f64,
df: usize,
alternative: Alternative,
) -> Result<TestResult, SymplexError> {
let positive = statistic > 0.0;
let statistic = ctx.from_f64(statistic)?;
let p_value = if positive {
chi_squared_sf(ctx, df, &statistic)
} else {
ctx.one()
};
Ok(TestResult {
statistic,
p_value,
df: Some(ex_usize(ctx, df)),
alternative,
})
}
pub trait LikelihoodFit {
fn log_likelihood(&self) -> f64;
fn null_log_likelihood(&self) -> f64;
fn n_params(&self) -> usize;
fn nobs(&self) -> usize;
fn df_model(&self) -> usize;
#[must_use]
fn llr(&self) -> f64 {
2.0 * (self.log_likelihood() - self.null_log_likelihood())
}
#[must_use]
fn pseudo_r_squared(&self) -> f64 {
1.0 - self.log_likelihood() / self.null_log_likelihood()
}
#[must_use]
fn aic(&self) -> f64 {
-2.0 * self.log_likelihood() + 2.0 * self.n_params() as f64
}
#[must_use]
fn bic(&self) -> f64 {
-2.0 * self.log_likelihood() + self.n_params() as f64 * (self.nobs() as f64).ln()
}
fn llr_test(&self, ctx: &Context) -> Result<TestResult, SymplexError> {
let df = self.df_model();
if df == 0 {
return Err(invalid(
"llr_test",
"the model has no regressors besides the constant (df_model = 0)",
));
}
chi_squared_test_result(ctx, self.llr(), df, Alternative::Greater)
}
}
pub trait WaldFit {
fn coefficients(&self) -> &[f64];
fn standard_errors(&self) -> &[f64];
#[must_use]
fn z_values(&self) -> Vec<f64> {
self.coefficients()
.iter()
.zip(self.standard_errors())
.map(|(b, s)| b / s)
.collect()
}
#[must_use]
fn p_values(&self) -> Vec<f64> {
self.z_values().into_iter().map(normal_two_sided).collect()
}
fn conf_int(&self, confidence: f64) -> Result<Vec<Interval<f64>>, SymplexError> {
check_confidence("conf_int", confidence)?;
let z = z_two_sided(confidence);
Ok(self
.coefficients()
.iter()
.zip(self.standard_errors())
.map(|(b, se)| Interval::closed(b - z * se, b + z * se))
.collect())
}
}
impl LikelihoodFit for Logit {
fn log_likelihood(&self) -> f64 {
self.log_likelihood
}
fn null_log_likelihood(&self) -> f64 {
self.null_log_likelihood
}
fn n_params(&self) -> usize {
self.coefficients.len()
}
fn nobs(&self) -> usize {
self.nobs
}
fn df_model(&self) -> usize {
self.df_model
}
}
impl WaldFit for Logit {
fn coefficients(&self) -> &[f64] {
&self.coefficients
}
fn standard_errors(&self) -> &[f64] {
&self.standard_errors
}
}
impl Logit {
#[must_use]
pub fn n_params(&self) -> usize {
<Self as LikelihoodFit>::n_params(self)
}
fn design_row(&self, op: &'static str, x_row: &[f64]) -> Result<Vec<f64>, SymplexError> {
let k = self.n_params() - usize::from(self.added_intercept);
if x_row.len() != k {
return Err(invalid(
op,
format!(
"x_row has {} entries, expected {k} (the regressors without the intercept)",
x_row.len()
),
));
}
if let Some(j) = x_row.iter().position(|v| !v.is_finite()) {
return Err(invalid(op, format!("x_row[{j}] is not finite")));
}
let mut row = Vec::with_capacity(k + 1);
if self.added_intercept {
row.push(1.0);
}
row.extend_from_slice(x_row);
Ok(row)
}
pub fn predict_proba(&self, x_row: &[f64]) -> Result<f64, SymplexError> {
let row = self.design_row("predict_proba", x_row)?;
Ok(sigmoid(dot_f64(&row, &self.coefficients)))
}
pub fn predict_log_odds(&self, x_row: &[f64]) -> Result<f64, SymplexError> {
let row = self.design_row("predict_log_odds", x_row)?;
Ok(dot_f64(&row, &self.coefficients))
}
#[must_use]
pub fn odds_ratios(&self) -> Vec<f64> {
self.coefficients.iter().map(|b| b.exp()).collect()
}
pub fn conf_int(&self, confidence: f64) -> Result<Vec<Interval<f64>>, SymplexError> {
<Self as WaldFit>::conf_int(self, confidence)
}
#[must_use]
pub fn llr(&self) -> f64 {
<Self as LikelihoodFit>::llr(self)
}
pub fn llr_test(&self, ctx: &Context) -> Result<TestResult, SymplexError> {
<Self as LikelihoodFit>::llr_test(self, ctx)
}
#[must_use]
pub fn aic(&self) -> f64 {
<Self as LikelihoodFit>::aic(self)
}
#[must_use]
pub fn bic(&self) -> f64 {
<Self as LikelihoodFit>::bic(self)
}
}
struct CategoricalData {
y: Vec<usize>,
counts: Vec<usize>,
design: Vec<Vec<f64>>,
rms: Vec<f64>,
}
fn categorical_data(
op: &'static str,
y: &[usize],
x: &[Vec<f64>],
add_intercept: bool,
opts: &LogitOpts,
) -> Result<CategoricalData, SymplexError> {
let n = y.len();
if n == 0 {
return Err(invalid(op, "y is empty"));
}
if opts.max_iter == 0 {
return Err(invalid(op, "max_iter must be positive"));
}
if opts.tol.is_nan() || opts.tol <= 0.0 {
return Err(invalid(
op,
format!("tol must be positive, got {}", opts.tol),
));
}
if x.len() != n {
return Err(invalid(
op,
format!("y has {n} observations but x has {} rows", x.len()),
));
}
let k = y.iter().max().map_or(0, |m| m + 1);
if k < 2 {
return Err(invalid(
op,
"y is constant (every observation is in category 0): need at least two categories",
));
}
let mut counts = vec![0usize; k];
for &c in y {
counts[c] += 1;
}
if let Some(j) = counts.iter().position(|&c| c == 0) {
return Err(invalid(
op,
format!(
"category {j} has no observations: y must take every value in 0..{k} (statsmodels relabels the observed values with np.unique; an explicitly declared empty level fails there too)"
),
));
}
let cols = x.first().map_or(0, Vec::len);
if let Some((i, r)) = x.iter().enumerate().find(|(_, r)| r.len() != cols) {
return Err(invalid(
op,
format!("row {i} of x has {} entries, expected {cols}", r.len()),
));
}
if let Some((i, j)) = x
.iter()
.enumerate()
.find_map(|(i, r)| r.iter().position(|v| !v.is_finite()).map(|j| (i, j)))
{
return Err(invalid(op, format!("x[{i}][{j}] is not finite")));
}
let p = cols + usize::from(add_intercept);
if p == 0 {
return Err(invalid(
op,
"the design has no columns: pass at least one regressor",
));
}
let design: Vec<Vec<f64>> = x
.iter()
.map(|r| {
let mut row = Vec::with_capacity(p);
if add_intercept {
row.push(1.0);
}
row.extend_from_slice(r);
row
})
.collect();
let rms: Vec<f64> = (0..p)
.map(|a| {
let s: f64 = design.iter().map(|r| r[a] * r[a]).sum();
let v = (s / n as f64).sqrt();
if v > 0.0 { v } else { 1.0 }
})
.collect();
Ok(CategoricalData {
y: y.to_vec(),
counts,
design,
rms,
})
}
fn categorical_null_log_likelihood(counts: &[usize]) -> f64 {
let n = counts.iter().sum::<usize>() as f64;
counts.iter().map(|&c| c as f64 * (c as f64 / n).ln()).sum()
}
fn max_own_category_miss(probs: &[Vec<f64>], y: &[usize]) -> f64 {
probs
.iter()
.zip(y)
.fold(0.0_f64, |m, (pr, &c)| m.max(1.0 - pr[c]))
}
fn has_degenerate_probability(probs: &[Vec<f64>]) -> bool {
probs
.iter()
.any(|pr| pr.iter().any(|&v| !(1e-10..=1.0 - 1e-10).contains(&v)))
}
fn diverging_coefficient(
beta: &[f64],
p: usize,
rms: &[f64],
added_intercept: bool,
per_category: bool,
) -> String {
let mut best = 0;
let mut best_scaled = -1.0;
for (idx, b) in beta.iter().enumerate() {
let scaled = b.abs() * rms[idx % p];
if scaled > best_scaled {
best_scaled = scaled;
best = idx;
}
}
let a = best % p;
let column = if added_intercept {
if a == 0 {
"the intercept".to_string()
} else {
format!("covariate {}", a - 1)
}
} else {
format!("covariate {a}")
};
if per_category {
format!("{column} of category {}", best / p + 1)
} else {
column
}
}
struct CategoricalEval {
ll: f64,
score: Vec<f64>,
info: Vec<Vec<f64>>,
probs: Vec<Vec<f64>>,
}
struct NewtonOutcome {
params: Vec<f64>,
eval: CategoricalEval,
iterations: usize,
converged: bool,
}
fn newton_categorical(
op: &'static str,
opts: &LogitOpts,
y: &[usize],
start: Vec<f64>,
evaluate: &dyn Fn(&[f64]) -> Option<CategoricalEval>,
culprit: &dyn Fn(&[f64]) -> String,
) -> Result<NewtonOutcome, SymplexError> {
let mut params = start;
let mut cur = evaluate(¶ms)
.ok_or_else(|| failed(op, "the starting point is infeasible (internal invariant)"))?;
let mut converged = false;
let mut iterations = 0;
for iter in 1..=opts.max_iter {
iterations = iter;
let Some(l) = information_cholesky(&cur.info) else {
return Err(if iter == 1 {
invalid(
op,
"the design matrix is rank deficient: drop a collinear regressor",
)
} else {
failed(
op,
format!(
"the Hessian became singular: complete or quasi-complete separation, the maximum-likelihood estimate does not exist ({} is diverging)",
culprit(¶ms)
),
)
});
};
let dir = dense_f64::cholesky_solve(&l, cur.info.len(), &cur.score);
let max_step = dir.iter().fold(0.0_f64, |m, s| m.max(s.abs()));
let scale = params.iter().fold(1.0_f64, |m, b| m.max(b.abs()));
let mut t = 1.0;
let mut accepted = None;
for _ in 0..40 {
let trial: Vec<f64> = params.iter().zip(&dir).map(|(b, d)| b + t * d).collect();
if let Some(next) = evaluate(&trial)
&& next.ll.is_finite()
&& next.ll >= cur.ll - 1e-10 * (1.0 + cur.ll.abs())
{
accepted = Some((trial, next));
break;
}
t *= 0.5;
}
let Some((trial, next)) = accepted else {
return Err(failed(
op,
"no step along the Newton direction increases the log-likelihood (the likelihood is not locally concave here)",
));
};
params = trial;
cur = next;
if params.iter().any(|b| !b.is_finite()) {
return Err(failed(
op,
format!(
"the coefficients diverged: perfect separation, the maximum-likelihood estimate does not exist ({} is diverging)",
culprit(¶ms)
),
));
}
if max_own_category_miss(&cur.probs, y) <= 1e-8 {
return Err(failed(
op,
format!(
"perfect separation: every observation is predicted exactly (1 − π̂ ≤ 1e-8), the maximum-likelihood estimate does not exist ({} is diverging)",
culprit(¶ms)
),
));
}
if t == 1.0 && max_step <= opts.tol * scale {
converged = true;
break;
}
}
if !converged && has_degenerate_probability(&cur.probs) {
return Err(failed(
op,
format!(
"no convergence in {} iterations while fitted probabilities reached 0 or 1: complete or quasi-complete separation, the maximum-likelihood estimate does not exist ({} is diverging)",
opts.max_iter,
culprit(¶ms)
),
));
}
Ok(NewtonOutcome {
params,
eval: cur,
iterations,
converged,
})
}
fn wald_summary_or_singular(
op: &'static str,
info: &[Vec<f64>],
params: &[f64],
) -> Result<WaldSummary, SymplexError> {
wald_summary(info, params).ok_or_else(|| {
failed(
op,
"the Hessian at the estimate is singular: the standard errors are undefined",
)
})
}
fn categorical_design_row(
op: &'static str,
x_row: &[f64],
p: usize,
added_intercept: bool,
) -> Result<Vec<f64>, SymplexError> {
let k = p - usize::from(added_intercept);
if x_row.len() != k {
return Err(invalid(
op,
format!(
"x_row has {} entries, expected {k} (the regressors without the intercept)",
x_row.len()
),
));
}
if let Some(j) = x_row.iter().position(|v| !v.is_finite()) {
return Err(invalid(op, format!("x_row[{j}] is not finite")));
}
let mut row = Vec::with_capacity(p);
if added_intercept {
row.push(1.0);
}
row.extend_from_slice(x_row);
Ok(row)
}
fn modal_category(probs: &[f64]) -> usize {
let mut best = 0;
for (j, &v) in probs.iter().enumerate() {
if v > probs[best] {
best = j;
}
}
best
}
#[derive(Clone, Debug, PartialEq)]
pub struct MnLogit {
pub coefficients: Vec<Vec<f64>>,
pub standard_errors: Vec<Vec<f64>>,
pub z_values: Vec<Vec<f64>>,
pub p_values: Vec<Vec<f64>>,
pub log_likelihood: f64,
pub null_log_likelihood: f64,
pub pseudo_r_squared: f64,
pub iterations: usize,
pub converged: bool,
pub cov_params: Vec<Vec<f64>>,
pub n_categories: usize,
pub n_params: usize,
pub fitted_probabilities: Vec<Vec<f64>>,
pub nobs: usize,
pub df_model: usize,
pub df_resid: usize,
added_intercept: bool,
flat_coefficients: Vec<f64>,
flat_standard_errors: Vec<f64>,
}
fn mnlogit_evaluate(design: &[Vec<f64>], y: &[usize], k: usize, beta: &[f64]) -> CategoricalEval {
let p = design.first().map_or(0, Vec::len);
let m = (k - 1) * p;
let mut ll = 0.0;
let mut score = vec![0.0; m];
let mut info = vec![vec![0.0; m]; m];
let mut probs = Vec::with_capacity(design.len());
let mut eta = vec![0.0; k];
for (row, &c) in design.iter().zip(y) {
eta[0] = 0.0;
for j in 1..k {
eta[j] = dot_f64(row, &beta[(j - 1) * p..j * p]);
}
let mx = eta.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let mut pr: Vec<f64> = eta.iter().map(|e| (e - mx).exp()).collect();
let s: f64 = pr.iter().sum();
for v in &mut pr {
*v /= s;
}
ll += eta[c] - mx - s.ln();
for j in 1..k {
let r = f64::from(u8::from(c == j)) - pr[j];
let base = (j - 1) * p;
for (a, xa) in row.iter().enumerate() {
score[base + a] += xa * r;
}
for l in 1..k {
let w = pr[j] * (f64::from(u8::from(j == l)) - pr[l]);
let base_l = (l - 1) * p;
for (a, xa) in row.iter().enumerate() {
for (b, xb) in row.iter().enumerate() {
info[base + a][base_l + b] += w * xa * xb;
}
}
}
}
probs.push(pr);
}
CategoricalEval {
ll,
score,
info,
probs,
}
}
pub fn mnlogit(
y: &[usize],
x: &[Vec<f64>],
add_intercept: bool,
opts: &LogitOpts,
) -> Result<MnLogit, SymplexError> {
const OP: &str = "mnlogit";
let data = categorical_data(OP, y, x, add_intercept, opts)?;
let n = data.y.len();
let k = data.counts.len();
let p = data.design.first().map_or(0, Vec::len);
let m = (k - 1) * p;
if n <= m {
return Err(invalid(
OP,
format!("need more observations than parameters: n = {n}, (k − 1)·p = {m}"),
));
}
let evaluate = |beta: &[f64]| Some(mnlogit_evaluate(&data.design, &data.y, k, beta));
let culprit = |beta: &[f64]| diverging_coefficient(beta, p, &data.rms, add_intercept, true);
let out = newton_categorical(OP, opts, &data.y, vec![0.0; m], &evaluate, &culprit)?;
let wald = wald_summary_or_singular(OP, &out.eval.info, &out.params)?;
let by_category = |v: &[f64]| -> Vec<Vec<f64>> { v.chunks(p).map(<[f64]>::to_vec).collect() };
let log_likelihood = out.eval.ll;
let null_log_likelihood = categorical_null_log_likelihood(&data.counts);
Ok(MnLogit {
coefficients: by_category(&out.params),
standard_errors: by_category(&wald.se),
z_values: by_category(&wald.z),
p_values: by_category(&wald.p),
log_likelihood,
null_log_likelihood,
pseudo_r_squared: 1.0 - log_likelihood / null_log_likelihood,
iterations: out.iterations,
converged: out.converged,
cov_params: wald.cov,
n_categories: k,
n_params: p,
fitted_probabilities: out.eval.probs,
nobs: n,
df_model: (k - 1) * (p - 1),
df_resid: n - m,
added_intercept: add_intercept,
flat_coefficients: out.params,
flat_standard_errors: wald.se,
})
}
impl LikelihoodFit for MnLogit {
fn log_likelihood(&self) -> f64 {
self.log_likelihood
}
fn null_log_likelihood(&self) -> f64 {
self.null_log_likelihood
}
fn n_params(&self) -> usize {
(self.n_categories - 1) * self.n_params
}
fn nobs(&self) -> usize {
self.nobs
}
fn df_model(&self) -> usize {
self.df_model
}
}
impl WaldFit for MnLogit {
fn coefficients(&self) -> &[f64] {
&self.flat_coefficients
}
fn standard_errors(&self) -> &[f64] {
&self.flat_standard_errors
}
}
impl MnLogit {
fn probabilities(&self, row: &[f64]) -> Vec<f64> {
let mut eta: Vec<f64> = std::iter::once(0.0)
.chain(self.coefficients.iter().map(|b| dot_f64(row, b)))
.collect();
let mx = eta.iter().copied().fold(f64::NEG_INFINITY, f64::max);
for e in &mut eta {
*e = (*e - mx).exp();
}
let s: f64 = eta.iter().sum();
eta.into_iter().map(|e| e / s).collect()
}
pub fn predict_proba(&self, x_row: &[f64]) -> Result<Vec<f64>, SymplexError> {
let row =
categorical_design_row("predict_proba", x_row, self.n_params, self.added_intercept)?;
Ok(self.probabilities(&row))
}
pub fn predict(&self, x_row: &[f64]) -> Result<usize, SymplexError> {
let row = categorical_design_row("predict", x_row, self.n_params, self.added_intercept)?;
Ok(modal_category(&self.probabilities(&row)))
}
#[must_use]
pub fn relative_risk_ratios(&self) -> Vec<Vec<f64>> {
self.coefficients
.iter()
.map(|b| b.iter().map(|v| v.exp()).collect())
.collect()
}
pub fn conf_int(&self, confidence: f64) -> Result<Vec<Vec<Interval<f64>>>, SymplexError> {
let flat = <Self as WaldFit>::conf_int(self, confidence)?;
Ok(flat
.chunks(self.n_params.max(1))
.map(<[Interval<f64>]>::to_vec)
.collect())
}
#[must_use]
pub fn llr(&self) -> f64 {
<Self as LikelihoodFit>::llr(self)
}
pub fn llr_test(&self, ctx: &Context) -> Result<TestResult, SymplexError> {
<Self as LikelihoodFit>::llr_test(self, ctx)
}
#[must_use]
pub fn aic(&self) -> f64 {
<Self as LikelihoodFit>::aic(self)
}
#[must_use]
pub fn bic(&self) -> f64 {
<Self as LikelihoodFit>::bic(self)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct OrderedLogit {
pub thresholds: Vec<f64>,
pub coefficients: Vec<f64>,
pub standard_errors: Vec<f64>,
pub z_values: Vec<f64>,
pub p_values: Vec<f64>,
pub log_likelihood: f64,
pub null_log_likelihood: f64,
pub pseudo_r_squared: f64,
pub iterations: usize,
pub converged: bool,
pub cov_params: Vec<Vec<f64>>,
pub fitted_probabilities: Vec<Vec<f64>>,
pub nobs: usize,
pub n_categories: usize,
pub df_model: usize,
pub df_resid: usize,
}
fn log_sigmoid(z: f64) -> f64 {
-softplus(-z)
}
fn ologit_evaluate(
design: &[Vec<f64>],
y: &[usize],
k: usize,
par: &[f64],
) -> Option<CategoricalEval> {
let p = design.first().map_or(0, Vec::len);
let q = p + k - 1;
let (beta, theta) = par.split_at(p);
if theta
.windows(2)
.any(|w| w[1] <= w[0] || w[1].is_nan() || w[0].is_nan())
{
return None;
}
let mut ll = 0.0;
let mut score = vec![0.0; q];
let mut info = vec![vec![0.0; q]; q];
let mut probs = Vec::with_capacity(design.len());
let mut dp = vec![0.0; q];
for (row, &c) in design.iter().zip(y) {
let eta = dot_f64(row, beta);
let upp = (c < k - 1).then(|| theta[c] - eta);
let low = (c > 0).then(|| theta[c - 1] - eta);
let log_p = match (low, upp) {
(None, Some(u)) => log_sigmoid(u),
(Some(l), None) => log_sigmoid(-l),
(Some(l), Some(u)) => log_sigmoid(u) + log_sigmoid(-l) + (-(l - u).exp()).ln_1p(),
(None, None) => 0.0,
};
let prob = log_p.exp();
ll += log_p;
let density = |z: Option<f64>| -> [f64; 2] {
z.map_or([0.0, 0.0], |z| {
let f = sigmoid(z);
let d = f * (1.0 - f);
[d, d * (1.0 - 2.0 * f)]
})
};
let [f_upp, df_upp] = density(upp);
let [f_low, df_low] = density(low);
dp.fill(0.0);
for (a, xa) in row.iter().enumerate() {
dp[a] = -xa * (f_upp - f_low);
}
if c < k - 1 {
dp[p + c] = f_upp;
}
if c > 0 {
dp[p + c - 1] = -f_low;
}
for (s, d) in score.iter_mut().zip(&dp) {
*s += d / prob;
}
let mut add = |r: usize, s: usize, d2p: f64| {
let h = d2p / prob - dp[r] * dp[s] / (prob * prob);
info[r][s] -= h;
};
for (a, xa) in row.iter().enumerate() {
for (b, xb) in row.iter().enumerate() {
add(a, b, xa * xb * (df_upp - df_low));
}
if c < k - 1 {
add(a, p + c, -xa * df_upp);
add(p + c, a, -xa * df_upp);
}
if c > 0 {
add(a, p + c - 1, xa * df_low);
add(p + c - 1, a, xa * df_low);
}
}
if c < k - 1 {
add(p + c, p + c, df_upp);
}
if c > 0 {
add(p + c - 1, p + c - 1, -df_low);
}
if c < k - 1 && c > 0 {
add(p + c, p + c - 1, 0.0);
add(p + c - 1, p + c, 0.0);
}
probs.push(ordered_probabilities(theta, eta));
}
Some(CategoricalEval {
ll,
score,
info,
probs,
})
}
fn ordered_probabilities(theta: &[f64], eta: f64) -> Vec<f64> {
let mut out = Vec::with_capacity(theta.len() + 1);
let mut prev = 0.0;
for t in theta {
let cum = sigmoid(t - eta);
out.push((cum - prev).max(0.0));
prev = cum;
}
out.push((1.0 - prev).max(0.0));
out
}
pub fn ologit(y: &[usize], x: &[Vec<f64>], opts: &LogitOpts) -> Result<OrderedLogit, SymplexError> {
const OP: &str = "ologit";
let data = categorical_data(OP, y, x, false, opts)?;
let n = data.y.len();
let k = data.counts.len();
let p = data.design.first().map_or(0, Vec::len);
let q = p + k - 1;
if let Some(a) = (0..p).find(|&a| data.design.iter().all(|r| r[a] == data.design[0][a])) {
return Err(invalid(
OP,
format!(
"column {a} of x is constant: the thresholds play the intercept's role, drop it"
),
));
}
if n <= q {
return Err(invalid(
OP,
format!("need more observations than parameters: n = {n}, p + k − 1 = {q}"),
));
}
let mut start = vec![0.0; q];
let mut cum = 0usize;
for (j, &c) in data.counts.iter().take(k - 1).enumerate() {
cum += c;
let f = cum as f64 / n as f64;
start[p + j] = (f / (1.0 - f)).ln();
}
let evaluate = |par: &[f64]| ologit_evaluate(&data.design, &data.y, k, par);
let culprit = |par: &[f64]| diverging_coefficient(&par[..p], p, &data.rms, false, false);
let out = newton_categorical(OP, opts, &data.y, start, &evaluate, &culprit)?;
let wald = wald_summary_or_singular(OP, &out.eval.info, &out.params)?;
let log_likelihood = out.eval.ll;
let null_log_likelihood = categorical_null_log_likelihood(&data.counts);
let (beta, theta) = out.params.split_at(p);
Ok(OrderedLogit {
thresholds: theta.to_vec(),
coefficients: beta.to_vec(),
standard_errors: wald.se,
z_values: wald.z,
p_values: wald.p,
log_likelihood,
null_log_likelihood,
pseudo_r_squared: 1.0 - log_likelihood / null_log_likelihood,
iterations: out.iterations,
converged: out.converged,
cov_params: wald.cov,
fitted_probabilities: out.eval.probs,
nobs: n,
n_categories: k,
df_model: p,
df_resid: n - q,
})
}
impl LikelihoodFit for OrderedLogit {
fn log_likelihood(&self) -> f64 {
self.log_likelihood
}
fn null_log_likelihood(&self) -> f64 {
self.null_log_likelihood
}
fn n_params(&self) -> usize {
self.coefficients.len() + self.thresholds.len()
}
fn nobs(&self) -> usize {
self.nobs
}
fn df_model(&self) -> usize {
self.df_model
}
}
impl WaldFit for OrderedLogit {
fn coefficients(&self) -> &[f64] {
&self.coefficients
}
fn standard_errors(&self) -> &[f64] {
let p = self.coefficients.len().min(self.standard_errors.len());
&self.standard_errors[..p]
}
}
impl OrderedLogit {
#[must_use]
pub fn n_params(&self) -> usize {
<Self as LikelihoodFit>::n_params(self)
}
fn linear_predictor(&self, op: &'static str, x_row: &[f64]) -> Result<f64, SymplexError> {
let row = categorical_design_row(op, x_row, self.coefficients.len(), false)?;
Ok(dot_f64(&row, &self.coefficients))
}
pub fn predict_proba(&self, x_row: &[f64]) -> Result<Vec<f64>, SymplexError> {
let eta = self.linear_predictor("predict_proba", x_row)?;
Ok(ordered_probabilities(&self.thresholds, eta))
}
pub fn cumulative_proba(&self, x_row: &[f64]) -> Result<Vec<f64>, SymplexError> {
let eta = self.linear_predictor("cumulative_proba", x_row)?;
Ok(self
.thresholds
.iter()
.map(|t| sigmoid(t - eta))
.chain(std::iter::once(1.0))
.collect())
}
pub fn predict(&self, x_row: &[f64]) -> Result<usize, SymplexError> {
let eta = self.linear_predictor("predict", x_row)?;
Ok(modal_category(&ordered_probabilities(
&self.thresholds,
eta,
)))
}
#[must_use]
pub fn odds_ratios(&self) -> Vec<f64> {
self.coefficients.iter().map(|b| b.exp()).collect()
}
pub fn conf_int(&self, confidence: f64) -> Result<Vec<Interval<f64>>, SymplexError> {
<Self as WaldFit>::conf_int(self, confidence)
}
#[must_use]
pub fn llr(&self) -> f64 {
<Self as LikelihoodFit>::llr(self)
}
pub fn llr_test(&self, ctx: &Context) -> Result<TestResult, SymplexError> {
<Self as LikelihoodFit>::llr_test(self, ctx)
}
#[must_use]
pub fn aic(&self) -> f64 {
<Self as LikelihoodFit>::aic(self)
}
#[must_use]
pub fn bic(&self) -> f64 {
<Self as LikelihoodFit>::bic(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn o1() -> (Vec<usize>, Vec<Vec<f64>>) {
let y = vec![0, 0, 1, 0, 1, 1, 2, 1, 2, 2, 0, 1, 2, 2, 1, 0, 0, 1, 2, 2];
let x = (0..20).map(|i| vec![f64::from(i) / 10.0]).collect();
(y, x)
}
fn m2() -> (Vec<usize>, Vec<Vec<f64>>) {
let y = vec![
0, 3, 1, 1, 2, 1, 2, 0, 3, 3, 0, 3, 0, 1, 1, 2, 2, 3, 0, 1, 2, 3, 3, 3, 0, 3, 2, 3, 2,
2,
];
let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let x2 = [
3.0, 1.0, 4.0, 1.0, 5.0, 2.0, 6.0, 5.0, 3.0, 5.0, 8.0, 9.0, 7.0, 9.0, 3.0, 2.0, 3.0,
8.0, 4.0, 6.0, 2.0, 6.0, 4.0, 3.0, 3.0, 8.0, 3.0, 2.0, 7.0, 9.0,
];
let x = (0..30).map(|i| vec![1.0, x1[i % 6], x2[i]]).collect();
(y, x)
}
fn numeric_gradient(f: &dyn Fn(&[f64]) -> f64, at: &[f64], h: f64) -> Vec<f64> {
(0..at.len())
.map(|j| {
let mut up = at.to_vec();
let mut dn = at.to_vec();
up[j] += h;
dn[j] -= h;
(f(&up) - f(&dn)) / (2.0 * h)
})
.collect()
}
#[test]
fn ologit_score_is_the_gradient_of_the_log_likelihood() {
let (y, x) = o1();
let at = [0.8, -0.2, 1.1];
let e = ologit_evaluate(&x, &y, 3, &at).unwrap();
let ll = |par: &[f64]| ologit_evaluate(&x, &y, 3, par).unwrap().ll;
for (s, n) in e.score.iter().zip(numeric_gradient(&ll, &at, 1e-6)) {
assert!((s - n).abs() < 1e-6, "score {s} vs numeric {n}");
}
}
#[test]
fn ologit_information_is_minus_the_hessian() {
let (y, x) = o1();
let at = [0.8, -0.2, 1.1];
let e = ologit_evaluate(&x, &y, 3, &at).unwrap();
for r in 0..3 {
let score_r = |par: &[f64]| ologit_evaluate(&x, &y, 3, par).unwrap().score[r];
let row = numeric_gradient(&score_r, &at, 1e-6);
for (s, numeric) in row.iter().enumerate() {
assert!(
(e.info[r][s] + numeric).abs() < 1e-5,
"info[{r}][{s}] = {} vs −∂²ℓ = {}",
e.info[r][s],
-numeric
);
assert!((e.info[r][s] - e.info[s][r]).abs() < 1e-12);
}
}
}
#[test]
fn ologit_rejects_disordered_thresholds() {
let (y, x) = o1();
assert!(ologit_evaluate(&x, &y, 3, &[0.1, 1.0, 1.0]).is_none());
assert!(ologit_evaluate(&x, &y, 3, &[0.1, 1.0, 0.5]).is_none());
assert!(ologit_evaluate(&x, &y, 3, &[0.1, 0.5, 1.0]).is_some());
}
#[test]
fn mnlogit_score_and_information_match_finite_differences() {
let (y, x) = m2();
let at = [-0.5, 0.3, -0.1, -1.0, 0.6, 0.0, -1.2, 0.7, -0.05];
let e = mnlogit_evaluate(&x, &y, 4, &at);
let ll = |b: &[f64]| mnlogit_evaluate(&x, &y, 4, b).ll;
for (s, n) in e.score.iter().zip(numeric_gradient(&ll, &at, 1e-6)) {
assert!((s - n).abs() < 1e-5, "score {s} vs numeric {n}");
}
for r in 0..9 {
let score_r = |b: &[f64]| mnlogit_evaluate(&x, &y, 4, b).score[r];
let row = numeric_gradient(&score_r, &at, 1e-6);
for (s, numeric) in row.iter().enumerate() {
assert!(
(e.info[r][s] + numeric).abs() < 1e-4,
"info[{r}][{s}] = {} vs −∂²ℓ = {}",
e.info[r][s],
-numeric
);
}
}
}
#[test]
fn ordered_probabilities_sum_to_one() {
let pr = ordered_probabilities(&[-1.0, 0.5, 2.0], 0.3);
assert_eq!(pr.len(), 4);
assert!((pr.iter().sum::<f64>() - 1.0).abs() < 1e-15);
assert!(pr.iter().all(|v| *v > 0.0));
}
}