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, 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 degree >= n {
return Err(invalid(
OP,
format!("degree {degree} needs more than {degree} points, got {n}"),
));
}
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"));
}
check_logit_opts(OP, opts)?;
let classes: Vec<usize> = y
.iter()
.enumerate()
.map(|(i, v)| {
v.as_outcome()
.map(usize::from)
.ok_or_else(|| invalid(OP, format!("y[{i}] is not a 0/1 outcome")))
})
.collect::<Result<_, _>>()?;
let successes = classes.iter().filter(|&&c| c == 1).count();
if successes == 0 || successes == n {
return Err(invalid(
OP,
"y is constant (all successes or all failures): the coefficients are not identified",
));
}
let data = categorical_data(OP, &classes, x, add_intercept, opts)?;
let p = data.design.first().map_or(0, Vec::len);
if n <= p {
return Err(invalid(
OP,
format!("need more observations than parameters: n = {n}, p = {p}"),
));
}
let fit = fit_multinomial(OP, &data, add_intercept, opts, false)?;
let log_likelihood = fit.eval.ll;
let null_log_likelihood = categorical_null_log_likelihood(&data.counts);
let fitted_probabilities = data
.design
.iter()
.map(|row| sigmoid(dot_f64(row, &fit.params)))
.collect();
Ok(Logit {
pseudo_r_squared: 1.0 - log_likelihood / null_log_likelihood,
deviance: -2.0 * log_likelihood,
coefficients: fit.params,
standard_errors: fit.wald.se,
z_values: fit.wald.z,
p_values: fit.wald.p,
log_likelihood,
null_log_likelihood,
iterations: fit.iterations,
converged: fit.converged,
fitted_probabilities,
cov_params: fit.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",
"df_model = 0: the null model has as many parameters as the fit, there is nothing to test",
));
}
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"));
}
check_logit_opts(op, opts)?;
if x.len() != n {
return Err(invalid(
op,
format!("y has {n} observations but x has {} rows", x.len()),
));
}
let max_label = y.iter().copied().max().unwrap_or(0);
if max_label == 0 {
return Err(invalid(
op,
"y is constant (every observation is in category 0): need at least two categories",
));
}
if max_label >= n {
let mut seen = vec![false; n];
for &c in y {
if c < n {
seen[c] = true;
}
}
let j = seen.iter().position(|s| !s).unwrap_or(n);
return Err(invalid(
op,
format!(
"category {j} has no observations: y must take every value in 0..={max_label} (statsmodels relabels the observed values with np.unique; an explicitly declared empty level fails there too)"
),
));
}
let k = max_label + 1;
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 check_logit_opts(op: &'static str, opts: &LogitOpts) -> Result<(), SymplexError> {
if opts.max_iter == 0 {
return Err(invalid(op, "max_iter must be positive"));
}
if !(opts.tol > 0.0 && opts.tol.is_finite()) {
return Err(invalid(
op,
format!("tol must be finite and positive, got {}", opts.tol),
));
}
Ok(())
}
struct Standardization {
center: Vec<f64>,
scale: Vec<f64>,
constant: Option<(usize, f64)>,
}
impl Standardization {
fn new(design: &[Vec<f64>], thresholds_absorb_shift: bool) -> Self {
let p = design.first().map_or(0, Vec::len);
let n = design.len() as f64;
let is_constant = |a: usize| design.iter().all(|r| r[a] == design[0][a]);
let constant = (0..p)
.find(|&a| design[0][a] != 0.0 && is_constant(a))
.map(|a| (a, design[0][a]));
let centre = thresholds_absorb_shift || constant.is_some();
let mut center = vec![0.0; p];
let mut scale = vec![1.0; p];
for a in 0..p {
if is_constant(a) {
continue;
}
let m = if centre {
design
.iter()
.enumerate()
.fold(0.0, |m, (i, r)| m + (r[a] - m) / (i + 1) as f64)
} else {
0.0
};
let top = design.iter().fold(0.0_f64, |t, r| t.max((r[a] - m).abs()));
let s = top
* (design
.iter()
.map(|r| ((r[a] - m) / top).powi(2))
.sum::<f64>()
/ n)
.sqrt();
if m.is_finite() && s > 0.0 && s.is_finite() && (1.0 / s).is_finite() {
center[a] = m;
scale[a] = s;
}
}
Self {
center,
scale,
constant,
}
}
fn apply(&self, design: &[Vec<f64>]) -> Vec<Vec<f64>> {
design
.iter()
.map(|r| {
r.iter()
.zip(self.center.iter().zip(&self.scale))
.map(|(v, (m, s))| (v - m) / s)
.collect()
})
.collect()
}
fn linear_predictor_map(&self) -> Vec<Vec<f64>> {
let p = self.scale.len();
let mut a_map = vec![vec![0.0; p]; p];
for (a, (row, s)) in a_map.iter_mut().zip(&self.scale).enumerate() {
row[a] = 1.0 / s;
}
if let Some((c, v)) = self.constant {
let shifts = self.center.iter().zip(&self.scale).enumerate();
for (a, (m, s)) in shifts.filter(|&(a, _)| a != c) {
a_map[c][a] = -m / (s * v);
}
}
a_map
}
fn ordered_map(&self, n_thresholds: usize) -> Vec<Vec<f64>> {
let p = self.scale.len();
let q = p + n_thresholds;
let shifts: Vec<f64> = self
.center
.iter()
.zip(&self.scale)
.map(|(m, s)| m / s)
.collect();
let mut a_map = vec![vec![0.0; q]; q];
for (j, row) in a_map.iter_mut().enumerate() {
if j < p {
row[j] = 1.0 / self.scale[j];
} else {
row[j] = 1.0;
row[..p].copy_from_slice(&shifts);
}
}
a_map
}
}
fn block_diagonal(block: &[Vec<f64>], k: usize) -> Vec<Vec<f64>> {
let p = block.len();
let mut out = vec![vec![0.0; p * k]; p * k];
for e in 0..k {
for (a, row) in block.iter().enumerate() {
out[e * p + a][e * p..(e + 1) * p].copy_from_slice(row);
}
}
out
}
fn map_vector(a_map: &[Vec<f64>], v: &[f64]) -> Vec<f64> {
a_map.iter().map(|row| dot_f64(row, v)).collect()
}
struct RootInformation {
r: Vec<Vec<f64>>,
diag: Vec<f64>,
}
const ROOT_PIVOT_REL_TOL: f64 = 1e-6;
impl RootInformation {
fn new(q: usize) -> Self {
Self {
r: vec![vec![0.0; q]; q],
diag: vec![0.0; q],
}
}
fn add_row(&mut self, z: &mut [f64]) {
let q = self.diag.len();
for (d, v) in self.diag.iter_mut().zip(z.iter()) {
*d += v * v;
}
for j in 0..q {
if z[j] == 0.0 {
continue;
}
let row = &mut self.r[j];
let h = row[j].hypot(z[j]);
let (c, s) = (row[j] / h, z[j] / h);
row[j] = h;
for (rt, zt) in row[j + 1..].iter_mut().zip(&mut z[j + 1..]) {
let (r0, z0) = (*rt, *zt);
*rt = c * r0 + s * z0;
*zt = c * z0 - s * r0;
}
}
}
fn covariance_factor(&self, a_map: &[Vec<f64>]) -> Option<Vec<Vec<f64>>> {
let q = self.diag.len();
if (0..q).any(|j| {
let rjj = self.r[j][j];
!(rjj.is_finite() && rjj > ROOT_PIVOT_REL_TOL * self.diag[j].sqrt())
}) {
return None;
}
let r_inv_t: Vec<Vec<f64>> = (0..q)
.map(|c| {
let mut x = vec![0.0; q];
for i in (0..=c).rev() {
let e = if i == c { 1.0 } else { 0.0 };
let s = e - dot_f64(&self.r[i][i + 1..=c], &x[i + 1..=c]);
x[i] = s / self.r[i][i];
}
x
})
.collect();
Some(
a_map
.iter()
.map(|row| r_inv_t.iter().map(|col| dot_f64(row, col)).collect())
.collect(),
)
}
}
fn mapped_wald_summary(
op: &'static str,
root: &RootInformation,
a_map: &[Vec<f64>],
params: &[f64],
) -> Result<WaldSummary, SymplexError> {
let b = root.covariance_factor(a_map).ok_or_else(|| {
failed(
op,
"the Hessian at the estimate is singular: the standard errors are undefined",
)
})?;
let q = b.len();
let mut cov = vec![vec![0.0; q]; q];
for i in 0..q {
for j in 0..=i {
let v = dot_f64(&b[i], &b[j]);
cov[i][j] = v;
cov[j][i] = v;
}
}
let se: Vec<f64> = b
.iter()
.map(|row| {
let top = row.iter().fold(0.0_f64, |t, v| t.max(v.abs()));
if top == 0.0 || !top.is_finite() {
return top;
}
top * row.iter().map(|v| (v / top).powi(2)).sum::<f64>().sqrt()
})
.collect();
let z: Vec<f64> = params.iter().zip(&se).map(|(b, s)| b / s).collect();
let p = z.iter().map(|z| normal_two_sided(*z)).collect();
Ok(WaldSummary { cov, se, z, p })
}
fn mnlogit_root_information(design: &[Vec<f64>], k: usize, beta: &[f64]) -> RootInformation {
let p = design.first().map_or(0, Vec::len);
let m = (k - 1) * p;
let mut root = RootInformation::new(m);
let coefficients: Vec<Vec<f64>> = beta.chunks(p.max(1)).map(<[f64]>::to_vec).collect();
let mut z = vec![0.0; m];
let mut tail = vec![0.0; k + 1];
for row in design {
let pr = softmax_probabilities(&coefficients, row);
tail[k] = pr[0];
for j in (1..k).rev() {
tail[j] = tail[j + 1] + pr[j];
}
for col in 1..k {
if tail[col + 1] == 0.0 {
continue;
}
let root_share = (pr[col] / tail[col]).sqrt();
let root_next = tail[col + 1].sqrt();
z.fill(0.0);
for j in col..k {
let l_jc = if j == col {
root_share * root_next
} else {
-(pr[j] / root_next) * root_share
};
for (a, xa) in row.iter().enumerate() {
z[(j - 1) * p + a] = l_jc * xa;
}
}
root.add_row(&mut z);
}
}
root
}
fn ologit_root_information(
design: &[Vec<f64>],
y: &[usize],
k: usize,
par: &[f64],
) -> RootInformation {
let p = design.first().map_or(0, Vec::len);
let q = p + k - 1;
let (beta, theta) = par.split_at(p);
let mut root = RootInformation::new(q);
let mut z = vec![0.0; q];
for (row, &c) in design.iter().zip(y) {
let eta = dot_f64(row, beta);
let has_upp = c < k - 1;
let has_low = c > 0;
let t = ordered_term(
has_low.then(|| theta[c - 1] - eta),
has_upp.then(|| theta[c] - eta),
);
for (weight, threshold) in [
(t.s_upp, has_upp.then_some(c)),
(t.s_low, has_low.then(|| c - 1)),
] {
if let Some(j) = threshold {
let w = weight.sqrt();
z.fill(0.0);
for (a, xa) in row.iter().enumerate() {
z[a] = -w * xa;
}
z[p + j] = w;
root.add_row(&mut z);
}
}
if has_upp && has_low && t.q > 0.0 {
let w = t.q.sqrt();
z.fill(0.0);
z[p + c] = w;
z[p + c - 1] = -w;
root.add_row(&mut z);
}
}
root
}
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 (numerically: collinear or nearly collinear regressors): 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 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];
let mut rest = 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();
let mut prefix = 0.0;
for j in 0..k {
rest[j] = prefix;
prefix += pr[j];
}
let mut suffix = 0.0;
for j in (0..k).rev() {
rest[j] += suffix;
suffix += pr[j];
}
for j in 1..k {
let r = if c == j { rest[j] } else { -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 = if j == l {
pr[j] * rest[j]
} else {
-pr[j] * 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 fit = fit_multinomial(OP, &data, add_intercept, opts, true)?;
let by_category = |v: &[f64]| -> Vec<Vec<f64>> { v.chunks(p).map(<[f64]>::to_vec).collect() };
let log_likelihood = fit.eval.ll;
let null_log_likelihood = categorical_null_log_likelihood(&data.counts);
let coefficients = by_category(&fit.params);
let fitted_probabilities = data
.design
.iter()
.map(|row| softmax_probabilities(&coefficients, row))
.collect();
Ok(MnLogit {
coefficients,
standard_errors: by_category(&fit.wald.se),
z_values: by_category(&fit.wald.z),
p_values: by_category(&fit.wald.p),
log_likelihood,
null_log_likelihood,
pseudo_r_squared: 1.0 - log_likelihood / null_log_likelihood,
iterations: fit.iterations,
converged: fit.converged,
cov_params: fit.wald.cov,
n_categories: k,
n_params: p,
fitted_probabilities,
nobs: n,
df_model: (k - 1) * (p - 1),
df_resid: n - m,
added_intercept: add_intercept,
flat_coefficients: fit.params,
flat_standard_errors: fit.wald.se,
})
}
struct MultinomialFit {
params: Vec<f64>,
wald: WaldSummary,
eval: CategoricalEval,
iterations: usize,
converged: bool,
}
fn fit_multinomial(
op: &'static str,
data: &CategoricalData,
add_intercept: bool,
opts: &LogitOpts,
per_category: bool,
) -> Result<MultinomialFit, SymplexError> {
let k = data.counts.len();
let p = data.design.first().map_or(0, Vec::len);
let change = Standardization::new(&data.design, false);
let design = change.apply(&data.design);
let a_map = block_diagonal(&change.linear_predictor_map(), k - 1);
let evaluate = |gamma: &[f64]| Some(mnlogit_evaluate(&design, &data.y, k, gamma));
let culprit = |gamma: &[f64]| {
diverging_coefficient(
&map_vector(&a_map, gamma),
p,
&data.rms,
add_intercept,
per_category,
)
};
let out = newton_categorical(
op,
opts,
&data.y,
vec![0.0; (k - 1) * p],
&evaluate,
&culprit,
)?;
let params = map_vector(&a_map, &out.params);
let root = mnlogit_root_information(&design, k, &out.params);
let wald = mapped_wald_summary(op, &root, &a_map, ¶ms)?;
Ok(MultinomialFit {
params,
wald,
eval: out.eval,
iterations: out.iterations,
converged: out.converged,
})
}
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
}
}
fn softmax_probabilities(coefficients: &[Vec<f64>], row: &[f64]) -> Vec<f64> {
let mut eta: Vec<f64> = std::iter::once(0.0)
.chain(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()
}
impl MnLogit {
fn probabilities(&self, row: &[f64]) -> Vec<f64> {
softmax_probabilities(&self.coefficients, row)
}
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 log1mexp(a: f64) -> f64 {
if a <= std::f64::consts::LN_2 {
(-(-a).exp_m1()).ln()
} else {
(-(-a).exp()).ln_1p()
}
}
struct OrderedTerm {
log_p: f64,
g_upp: f64,
g_low: f64,
d_eta: f64,
s_upp: f64,
s_low: f64,
q: f64,
}
fn ordered_term(low: Option<f64>, upp: Option<f64>) -> OrderedTerm {
let (sig_neg_u, s_upp, log_upp) = upp.map_or((0.0, 0.0, 0.0), |u| {
let neg = sigmoid(-u);
(neg, sigmoid(u) * neg, log_sigmoid(u))
});
let (sig_l, s_low, log_low) = low.map_or((0.0, 0.0, 0.0), |l| {
let pos = sigmoid(l);
(pos, pos * sigmoid(-l), log_sigmoid(-l))
});
let (r, log_gap) = match (low, upp) {
(Some(l), Some(u)) => {
let a = u - l;
(1.0 / a.exp_m1(), log1mexp(a))
}
_ => (0.0, 0.0),
};
OrderedTerm {
log_p: log_upp + log_low + log_gap,
g_upp: sig_neg_u + r,
g_low: -(sig_l + r),
d_eta: sig_neg_u - sig_l,
s_upp,
s_low,
q: r * (1.0 + r),
}
}
fn ordered_probability(low: Option<f64>, upp: Option<f64>) -> f64 {
let below_upp = upp.map_or(1.0, sigmoid);
let above_low = low.map_or(1.0, |l| sigmoid(-l));
let gap = match (low, upp) {
(Some(l), Some(u)) => -(l - u).exp_m1(),
_ => 1.0,
};
below_upp * above_low * gap
}
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].partial_cmp(&w[0]) != Some(std::cmp::Ordering::Greater))
{
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());
for (row, &c) in design.iter().zip(y) {
let eta = dot_f64(row, beta);
let has_upp = c < k - 1;
let has_low = c > 0;
let t = ordered_term(
has_low.then(|| theta[c - 1] - eta),
has_upp.then(|| theta[c] - eta),
);
ll += t.log_p;
let s_eta = t.s_upp + t.s_low;
for (a, xa) in row.iter().enumerate() {
score[a] -= xa * t.d_eta;
for (b, xb) in row.iter().enumerate() {
info[a][b] += xa * xb * s_eta;
}
if has_upp {
info[a][p + c] -= xa * t.s_upp;
info[p + c][a] -= xa * t.s_upp;
}
if has_low {
info[a][p + c - 1] -= xa * t.s_low;
info[p + c - 1][a] -= xa * t.s_low;
}
}
if has_upp {
score[p + c] += t.g_upp;
info[p + c][p + c] += t.s_upp + t.q;
}
if has_low {
score[p + c - 1] += t.g_low;
info[p + c - 1][p + c - 1] += t.s_low + t.q;
}
if has_upp && has_low {
info[p + c][p + c - 1] -= t.q;
info[p + c - 1][p + c] -= t.q;
}
probs.push(ordered_probabilities(theta, eta));
}
Some(CategoricalEval {
ll,
score,
info,
probs,
})
}
fn ordered_probabilities(theta: &[f64], eta: f64) -> Vec<f64> {
let k = theta.len() + 1;
(0..k)
.map(|j| {
ordered_probability(
(j > 0).then(|| theta[j - 1] - eta),
(j < k - 1).then(|| theta[j] - eta),
)
})
.collect()
}
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;
start[p + j] = (cum as f64 / (n - cum) as f64).ln();
}
let change = Standardization::new(&data.design, true);
let design = change.apply(&data.design);
let a_map = change.ordered_map(k - 1);
let evaluate = |par: &[f64]| ologit_evaluate(&design, &data.y, k, par);
let culprit = |par: &[f64]| {
diverging_coefficient(&map_vector(&a_map, par)[..p], p, &data.rms, false, false)
};
let out = newton_categorical(OP, opts, &data.y, start, &evaluate, &culprit)?;
let params = map_vector(&a_map, &out.params);
let root = ologit_root_information(&design, &data.y, k, &out.params);
let wald = mapped_wald_summary(OP, &root, &a_map, ¶ms)?;
let log_likelihood = out.eval.ll;
let null_log_likelihood = categorical_null_log_likelihood(&data.counts);
let (beta, theta) = params.split_at(p);
let fitted_probabilities = data
.design
.iter()
.map(|row| ordered_probabilities(theta, dot_f64(row, beta)))
.collect();
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,
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_term_between_nearly_tied_thresholds() {
let t = ordered_term(Some(1.0), Some(1.0 + 2f64.powi(-40)));
let rel = |a: f64, b: f64| ((a - b) / b).abs();
assert!(rel(t.log_p, -29.352_410_597_434_468) < 1e-15, "{}", t.log_p);
assert!(rel(t.g_upp, 1_099_511_627_775.768_9) < 1e-12, "{}", t.g_upp);
assert!(rel(t.g_low, -1_099_511_627_776.231) < 1e-12, "{}", t.g_low);
}
#[test]
fn ologit_derivatives_stay_finite_when_a_probability_underflows() {
let x = vec![vec![0.0], vec![1.0], vec![-800.0]];
let y = [0, 1, 1];
let e = ologit_evaluate(&x, &y, 2, &[10.0, 0.0]).unwrap();
assert!(e.ll.is_finite() && e.ll < -7_999.0, "{}", e.ll);
assert!(e.score.iter().all(|v| v.is_finite()), "{:?}", e.score);
assert!(
e.info.iter().flatten().all(|v| v.is_finite()),
"{:?}",
e.info
);
let lone = ologit_evaluate(&x[2..], &y[2..], 2, &[10.0, 0.0]).unwrap();
assert_eq!(lone.score[1], -1.0);
}
#[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));
}
}