use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
use crate::error::{RegressionError, Result};
use crate::linalg::dmatrix_from_rows;
const PROB_EPS: f64 = 1e-12;
#[derive(Debug, Clone)]
pub struct PenalizedLogisticFit {
x: Array2<f64>,
y: Array1<f64>,
lambda: f64,
coefficients: Array1<f64>,
probabilities: Array1<f64>,
cov: Array2<f64>,
log_likelihood: f64,
effective_df: f64,
intercept_col: Option<usize>,
iterations: usize,
n: usize,
p: usize,
}
impl PenalizedLogisticFit {
pub fn new(x: Array2<f64>, y: Array1<f64>, lambda: f64) -> Result<Self> {
Self::with_options(x, y, lambda, 100, 1e-10)
}
pub fn with_options(
x: Array2<f64>,
y: Array1<f64>,
lambda: 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(),
});
}
if lambda < 0.0 || lambda.is_nan() {
return Err(RegressionError::InvalidParameter {
msg: format!("penalty lambda must be >= 0, got {lambda}"),
});
}
let (mut saw0, mut saw1) = (false, 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".into(),
});
}
let intercept_col = detect_constant_column(&x);
let pen: Vec<f64> = (0..p)
.map(|j| if Some(j) == intercept_col { 0.0 } else { 1.0 })
.collect();
let mut beta = Array1::<f64>::zeros(p);
let mut probabilities = Array1::<f64>::zeros(n);
let mut weights = Array1::<f64>::zeros(n);
let mut xtwx_pen_inv = 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 mut grad = x.t().dot(&resid);
for j in 0..p {
grad[j] -= lambda * pen[j] * beta[j];
}
let mut a = Array2::<f64>::zeros((p, p));
for r in 0..p {
for c in r..p {
let mut s = 0.0;
for i in 0..n {
s += x[(i, r)] * weights[i] * x[(i, c)];
}
a[(r, c)] = s;
a[(c, r)] = s;
}
}
for j in 0..p {
a[(j, j)] += lambda * pen[j];
}
let a_dm = dmatrix_from_rows(p, p, a.as_standard_layout().as_slice().unwrap());
let inv = a_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 + δ
xtwx_pen_inv = inv_arr;
let step = delta.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
if !beta.iter().all(|v| v.is_finite()) {
return Err(RegressionError::NotConverged {
iterations,
msg: "coefficients diverging".into(),
});
}
if step < tol {
converged = true;
break;
}
}
if !converged {
return Err(RegressionError::NotConverged {
iterations,
msg: "penalized IRLS did not reach tolerance".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 mut xtwx = Array2::<f64>::zeros((p, p));
for r in 0..p {
for c in r..p {
let mut s = 0.0;
for i in 0..n {
s += x[(i, r)] * weights[i] * x[(i, c)];
}
xtwx[(r, c)] = s;
xtwx[(c, r)] = s;
}
}
let effective_df = (0..p)
.map(|i| (0..p).map(|kk| xtwx_pen_inv[(i, kk)] * xtwx[(kk, i)]).sum::<f64>())
.sum();
let mid = xtwx.dot(&xtwx_pen_inv);
let cov = xtwx_pen_inv.dot(&mid);
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,
lambda,
coefficients: beta,
probabilities,
cov,
log_likelihood,
effective_df,
intercept_col,
iterations,
n,
p,
})
}
pub fn lambda(&self) -> f64 {
self.lambda
}
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 covariance(&self) -> ArrayView2<'_, f64> {
self.cov.view()
}
pub fn log_likelihood(&self) -> f64 {
self.log_likelihood
}
pub fn effective_df(&self) -> f64 {
self.effective_df
}
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 residual_deviance(&self) -> f64 {
-2.0 * self.log_likelihood
}
pub fn aic(&self) -> f64 {
-2.0 * self.log_likelihood + 2.0 * self.effective_df
}
pub fn bic(&self) -> f64 {
-2.0 * self.log_likelihood + (self.n as f64).ln() * self.effective_df
}
}
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
}