use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
use statrs::distribution::{ContinuousCDF, Normal};
use crate::error::{RegressionError, Result};
use crate::linalg::dmatrix_from_rows;
const PROB_EPS: f64 = 1e-12;
#[derive(Debug, Clone)]
pub struct LogisticFit {
x: Array2<f64>,
y: Array1<f64>,
coefficients: Array1<f64>,
probabilities: Array1<f64>,
weights: Array1<f64>,
cov: Array2<f64>,
log_likelihood: f64,
intercept_col: Option<usize>,
iterations: usize,
n: usize,
p: usize,
}
impl LogisticFit {
pub fn new(x: Array2<f64>, y: Array1<f64>) -> Result<Self> {
Self::with_options(x, y, 100, 1e-10)
}
pub fn with_options(x: Array2<f64>, y: Array1<f64>, max_iter: usize, tol: f64) -> Result<Self> {
let n = x.nrows();
let p = x.ncols();
if n == 0 || p == 0 {
return Err(RegressionError::EmptyInput { what: "X" });
}
if y.len() != n {
return Err(RegressionError::ShapeMismatch {
what: "y length vs X rows",
expected: n,
got: y.len(),
});
}
let mut saw0 = false;
let mut saw1 = false;
for &v in y.iter() {
if v == 0.0 {
saw0 = true;
} else if v == 1.0 {
saw1 = true;
} else {
return Err(RegressionError::InvalidResponse {
msg: format!("response must be 0 or 1, found {v}"),
});
}
}
if !(saw0 && saw1) {
return Err(RegressionError::InvalidResponse {
msg: "response is entirely one class; the fit is not identifiable".into(),
});
}
let intercept_col = detect_constant_column(&x);
let mut beta = Array1::<f64>::zeros(p);
let mut probabilities = Array1::<f64>::zeros(n);
let mut weights = Array1::<f64>::zeros(n);
let mut cov = Array2::<f64>::zeros((p, p));
let mut iterations = 0usize;
let mut converged = false;
while iterations < max_iter {
iterations += 1;
let eta = x.dot(&beta);
for i in 0..n {
let pi = sigmoid(eta[i]).clamp(PROB_EPS, 1.0 - PROB_EPS);
probabilities[i] = pi;
weights[i] = pi * (1.0 - pi);
}
let resid = &y - &probabilities;
let grad = x.t().dot(&resid); let mut xtwx = Array2::<f64>::zeros((p, p));
for a in 0..p {
for b in a..p {
let mut s = 0.0;
for i in 0..n {
s += x[(i, a)] * weights[i] * x[(i, b)];
}
xtwx[(a, b)] = s;
xtwx[(b, a)] = s;
}
}
let xtwx_dm = dmatrix_from_rows(p, p, xtwx.as_standard_layout().as_slice().unwrap());
let inv = xtwx_dm
.try_inverse()
.ok_or(RegressionError::RankDeficient)?;
let inv_arr = Array2::from_shape_fn((p, p), |(i, j)| inv[(i, j)]);
let delta = inv_arr.dot(&grad);
beta = &beta + δ
cov = inv_arr;
let step = delta.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
if !beta.iter().all(|v| v.is_finite()) || beta.iter().any(|v| v.abs() > 1e8) {
return Err(RegressionError::NotConverged {
iterations,
msg: "coefficients diverging (likely perfect separation)".into(),
});
}
if step < tol {
converged = true;
break;
}
}
if !converged {
return Err(RegressionError::NotConverged {
iterations,
msg: "IRLS did not reach tolerance (possible quasi-separation)".into(),
});
}
let eta = x.dot(&beta);
for i in 0..n {
let pi = sigmoid(eta[i]).clamp(PROB_EPS, 1.0 - PROB_EPS);
probabilities[i] = pi;
weights[i] = pi * (1.0 - pi);
}
let log_likelihood = (0..n)
.map(|i| {
let pi = probabilities[i];
y[i] * pi.ln() + (1.0 - y[i]) * (1.0 - pi).ln()
})
.sum();
Ok(Self {
x,
y,
coefficients: beta,
probabilities,
weights,
cov,
log_likelihood,
intercept_col,
iterations,
n,
p,
})
}
pub fn n_observations(&self) -> usize {
self.n
}
pub fn n_parameters(&self) -> usize {
self.p
}
pub fn has_intercept(&self) -> bool {
self.intercept_col.is_some()
}
pub fn iterations(&self) -> usize {
self.iterations
}
pub fn design_matrix(&self) -> ArrayView2<'_, f64> {
self.x.view()
}
pub fn response(&self) -> ArrayView1<'_, f64> {
self.y.view()
}
pub fn coefficients(&self) -> ArrayView1<'_, f64> {
self.coefficients.view()
}
pub fn fitted_probabilities(&self) -> ArrayView1<'_, f64> {
self.probabilities.view()
}
pub fn weights(&self) -> ArrayView1<'_, f64> {
self.weights.view()
}
pub fn covariance(&self) -> ArrayView2<'_, f64> {
self.cov.view()
}
pub fn log_likelihood(&self) -> f64 {
self.log_likelihood
}
pub fn coefficient_standard_errors(&self) -> Array1<f64> {
Array1::from_shape_fn(self.p, |j| self.cov[(j, j)].max(0.0).sqrt())
}
pub fn z_values(&self) -> Array1<f64> {
let se = self.coefficient_standard_errors();
Array1::from_shape_fn(self.p, |j| {
if se[j] > 0.0 {
self.coefficients[j] / se[j]
} else {
f64::NAN
}
})
}
pub fn p_values(&self) -> Array1<f64> {
let z = self.z_values();
let normal = Normal::new(0.0, 1.0).expect("standard normal");
Array1::from_shape_fn(self.p, |j| {
if z[j].is_finite() {
2.0 * (1.0 - normal.cdf(z[j].abs()))
} else {
f64::NAN
}
})
}
pub fn predict_proba(&self, x: ArrayView2<'_, f64>) -> Array1<f64> {
x.dot(&self.coefficients).mapv(sigmoid)
}
}
fn sigmoid(z: f64) -> f64 {
if z >= 0.0 {
1.0 / (1.0 + (-z).exp())
} else {
let e = z.exp();
e / (1.0 + e)
}
}
fn detect_constant_column(x: &Array2<f64>) -> Option<usize> {
for (j, col) in x.columns().into_iter().enumerate() {
let first = col[0];
let scale = first.abs().max(1.0);
if col.iter().all(|&v| (v - first).abs() <= 1e-12 * scale) {
return Some(j);
}
}
None
}