use crate::error::{MathError, Result};
use crate::matrix::Matrix;
#[derive(Debug, Clone)]
pub struct LogitOptions {
pub max_iter: usize,
pub tol: f64,
}
impl Default for LogitOptions {
fn default() -> Self {
LogitOptions { max_iter: 100, tol: 1e-10 }
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct LogitFit {
pub coefficients: Vec<f64>,
pub std_errors: Vec<f64>,
pub log_likelihood: f64,
pub iterations: usize,
pub converged: 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)
}
}
pub fn predict_proba(coefficients: &[f64], features: &[f64]) -> Result<f64> {
if coefficients.len() != features.len() + 1 {
return Err(MathError::InvalidArgument(format!(
"predict_proba: {} coefficients need {} features",
coefficients.len(),
coefficients.len() - 1
)));
}
let eta = coefficients[0]
+ coefficients[1..]
.iter()
.zip(features.iter())
.map(|(b, x)| b * x)
.sum::<f64>();
Ok(sigmoid(eta))
}
#[allow(clippy::needless_range_loop)] pub fn logistic_regression(x: &[&[f64]], y: &[f64], opts: &LogitOptions) -> Result<LogitFit> {
if y.is_empty() {
return Err(MathError::InvalidArgument("logistic_regression: no observations".into()));
}
if y.iter().any(|&v| v != 0.0 && v != 1.0) {
return Err(MathError::InvalidArgument(
"logistic_regression: responses must be exactly 0 or 1".into(),
));
}
for (j, col) in x.iter().enumerate() {
if col.len() != y.len() {
return Err(MathError::InvalidArgument(format!(
"logistic_regression: predictor {} has {} values, expected {}",
j + 1,
col.len(),
y.len()
)));
}
}
let m = y.len();
let np = x.len() + 1;
let design = |i: usize, c: usize| if c == 0 { 1.0 } else { x[c - 1][i] };
let mut beta = vec![0.0; np];
let mut iterations = 0usize;
let mut converged = false;
for _ in 0..opts.max_iter {
iterations += 1;
let mut xtwx = vec![0.0; np * np];
let mut xtwz = vec![0.0; np];
for i in 0..m {
let mut eta = 0.0;
for c in 0..np {
eta += beta[c] * design(i, c);
}
let p = sigmoid(eta);
let w = (p * (1.0 - p)).max(1e-10);
let z = eta + (y[i] - p) / w;
for a in 0..np {
let xa = design(i, a);
xtwz[a] += xa * w * z;
for b in a..np {
let v = xa * design(i, b) * w;
xtwx[a * np + b] += v;
if a != b {
xtwx[b * np + a] += v;
}
}
}
}
for d in 0..np {
xtwx[d * np + d] += 1e-10;
}
let next = Matrix::from_row_major(np, np, xtwx)?.solve(&xtwz)?;
let scale = 1.0 + beta.iter().fold(0.0f64, |mx, v| mx.max(v.abs()));
let step_inf = beta
.iter()
.zip(next.iter())
.fold(0.0f64, |mx, (a, b)| mx.max((a - b).abs()));
beta = next;
if step_inf <= opts.tol * scale {
converged = true;
break;
}
}
let mut log_likelihood = 0.0;
let mut xtwx = vec![0.0; np * np];
for i in 0..m {
let mut eta = 0.0;
for c in 0..np {
eta += beta[c] * design(i, c);
}
let p = sigmoid(eta);
log_likelihood += if y[i] == 1.0 {
-((-eta).exp().ln_1p())
} else {
-(eta.exp().ln_1p())
};
let w = (p * (1.0 - p)).max(1e-10);
for a in 0..np {
for b in a..np {
let v = design(i, a) * design(i, b) * w;
xtwx[a * np + b] += v;
if a != b {
xtwx[b * np + a] += v;
}
}
}
}
let mut std_errors = Vec::with_capacity(np);
if let Ok(cov) = Matrix::from_row_major(np, np, xtwx)?.inverse() {
for d in 0..np {
std_errors.push((cov[(d, d)]).max(0.0).sqrt());
}
} else {
std_errors = vec![f64::NAN; np];
}
Ok(LogitFit {
coefficients: beta,
std_errors,
log_likelihood,
iterations,
converged,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol * (1.0 + b.abs().max(a.abs()))
}
#[test]
fn balanced_two_point_closed_form() {
let x = [&[1.0, 1.0, 1.0, 2.0, 2.0, 2.0][..]];
let y = [0.0, 0.0, 1.0, 0.0, 1.0, 1.0];
let fit = logistic_regression(&x, &y, &LogitOptions::default()).unwrap();
assert!(fit.converged);
assert!(close(fit.coefficients[0], -3.0 * std::f64::consts::LN_2, 1e-8));
assert!(close(fit.coefficients[1], 2.0 * std::f64::consts::LN_2, 1e-8));
assert!(close(fit.std_errors[1], 3.0f64.sqrt(), 1e-6), "se={}", fit.std_errors[1]);
assert!(close(predict_proba(&fit.coefficients, &[1.0]).unwrap(), 1.0 / 3.0, 1e-8));
assert!(close(predict_proba(&fit.coefficients, &[2.0]).unwrap(), 2.0 / 3.0, 1e-8));
let expect_ll = 4.0 * std::f64::consts::LN_2 - 6.0 * (3.0f64).ln();
assert!(close(fit.log_likelihood, expect_ll, 1e-8), "ll={}", fit.log_likelihood);
}
#[test]
fn balanced_overlap_gives_null_model() {
let x = [&[1.0, 1.0, 2.0, 2.0][..]];
let y = [0.0, 1.0, 0.0, 1.0];
let fit = logistic_regression(&x, &y, &LogitOptions::default()).unwrap();
assert!(fit.converged);
for c in &fit.coefficients {
assert!(c.abs() < 1e-8, "coef={c}");
}
}
#[test]
fn score_equation_zero_at_convergence() {
let x = [
&[0.5, 1.2, 2.0, 2.7, 3.1, 4.0, 4.8, 5.5][..],
&[1.0, 0.3, 1.7, 0.9, 2.2, 1.1, 0.5, 2.0][..],
];
let y = [0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0];
let fit = logistic_regression(&x, &y, &LogitOptions::default()).unwrap();
assert!(fit.converged);
let mut grad = vec![0.0; fit.coefficients.len()];
for i in 0..y.len() {
grad[0] += y[i];
for (j, col) in x.iter().enumerate() {
grad[j + 1] += y[i] * col[i];
}
}
for i in 0..y.len() {
let features: Vec<f64> = x.iter().map(|col| col[i]).collect();
let p = predict_proba(&fit.coefficients, &features).unwrap();
grad[0] -= p;
for (j, col) in x.iter().enumerate() {
grad[j + 1] -= p * col[i];
}
}
for (j, g) in grad.iter().enumerate() {
assert!(g.abs() < 1e-7, "grad[{j}]={g}");
}
}
#[test]
fn coefficient_sign_follows_direction() {
let up = [&[1.0, 2.0, 3.0, 4.0][..]];
let y_up = [0.0, 0.0, 1.0, 1.0];
let fit = logistic_regression(&up, &y_up, &LogitOptions::default()).unwrap();
assert!(fit.coefficients[1] > 0.0);
let y_down = [1.0, 1.0, 0.0, 0.0];
let fit2 = logistic_regression(&up, &y_down, &LogitOptions::default()).unwrap();
assert!(fit2.coefficients[1] < 0.0);
}
#[test]
fn complete_separation_diverges_but_predicts_correct_side() {
let x = [&[1.0, 2.0, 3.0, 4.0][..]];
let y = [0.0, 0.0, 1.0, 1.0];
let fit = logistic_regression(&x, &y, &LogitOptions::default()).unwrap();
assert!(
fit.coefficients[1] > 10.0,
"separation should push the slope out: {:?}",
fit.coefficients
);
assert!(fit.coefficients[1] > 0.0);
assert!(predict_proba(&fit.coefficients, &[1.0]).unwrap() < 0.5);
assert!(predict_proba(&fit.coefficients, &[4.0]).unwrap() > 0.5);
}
#[test]
fn multivariate_gradient_and_prediction() {
let x = [
&[0.0, 0.0, 1.0, 1.0, 2.0, 2.0, 3.0, 3.0, 4.0, 4.0, 5.0, 5.0][..],
&[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0][..],
];
let y = [0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 1.0];
let fit = logistic_regression(&x, &y, &LogitOptions::default()).unwrap();
assert!(fit.converged);
assert!(close(fit.coefficients[0], -std::f64::consts::LN_2, 1e-8));
assert!(close(fit.coefficients[1], 0.0, 1e-6));
assert!(close(fit.coefficients[2], 2.0 * std::f64::consts::LN_2, 1e-8));
assert_eq!(fit.coefficients.len(), 3);
assert_eq!(fit.std_errors.len(), 3);
for (&a, &b) in x[0].iter().zip(x[1].iter()) {
let p = predict_proba(&fit.coefficients, &[a, b]).unwrap();
assert!((0.0..=1.0).contains(&p));
}
assert!(fit.log_likelihood.is_finite() && fit.log_likelihood < 0.0);
let mut grad = vec![0.0; 3];
for i in 0..y.len() {
let p = predict_proba(&fit.coefficients, &[x[0][i], x[1][i]]).unwrap();
grad[0] += y[i] - p;
grad[1] += (y[i] - p) * x[0][i];
grad[2] += (y[i] - p) * x[1][i];
}
for (j, g) in grad.iter().enumerate() {
assert!(g.abs() < 1e-6, "grad[{j}]={g}");
}
}
#[test]
fn validation_errors() {
let x = [&[1.0, 2.0][..]];
assert!(logistic_regression(&x, &[], &LogitOptions::default()).is_err());
assert!(logistic_regression(&x, &[0.0, 0.5], &LogitOptions::default()).is_err());
assert!(logistic_regression(&[&[1.0, 2.0, 3.0]], &[0.0, 1.0], &LogitOptions::default())
.is_err());
assert!(predict_proba(&[0.0, 1.0], &[1.0, 2.0]).is_err());
}
}