use crate::error::GreenersError;
use crate::linalg::LinalgInverse as _;
use crate::{DataFrame, Formula, InferenceType};
use ndarray::{Array1, Array2, Axis};
use statrs::distribution::{Continuous, ContinuousCDF, Normal};
use std::fmt;
#[derive(Debug)]
pub struct BinaryModelResult {
pub model_name: String, pub params: Array1<f64>,
pub std_errors: Array1<f64>,
pub z_values: Array1<f64>,
pub p_values: Array1<f64>,
pub iterations: usize,
pub log_likelihood: f64,
pub pseudo_r2: f64, pub(crate) _x_data: Option<Array2<f64>>,
pub inference_type: InferenceType, pub variable_names: Option<Vec<String>>,
pub omitted_vars: Vec<(usize, String)>,
}
impl fmt::Display for BinaryModelResult {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(
f,
"\n{:=^78}",
format!(" {} Regression Results (MLE) ", self.model_name)
)?;
writeln!(
f,
"{:<20} {:>15} || {:<20} {:>15.4}",
"Dep. Variable:", "y", "Log-Likelihood:", self.log_likelihood
)?;
writeln!(
f,
"{:<20} {:>15} || {:<20} {:>15.4}",
"Model:", self.model_name, "Pseudo R-sq:", self.pseudo_r2
)?;
writeln!(
f,
"{:<20} {:>15} || {:<20} {:>15}",
"Method:", "Newton-Raphson", "Iterations:", self.iterations
)?;
writeln!(f, "\n{:-^78}", "")?;
writeln!(
f,
"{:<10} | {:>10} | {:>10} | {:>8} | {:>8}",
"Variable", "coef", "std err", "z", "P>|z|"
)?;
writeln!(f, "{:-^78}", "")?;
let total = self.params.len() + self.omitted_vars.len();
let mut fit_idx = 0usize;
for pos in 0..total {
if let Some((_, name)) = self.omitted_vars.iter().find(|(p, _)| *p == pos) {
writeln!(f, "{:<10} | (omitted)", name)?;
} else {
let var_name = if let Some(ref names) = self.variable_names {
if fit_idx < names.len() {
names[fit_idx].clone()
} else {
format!("x{}", fit_idx)
}
} else {
format!("x{}", fit_idx)
};
writeln!(
f,
"{:<10} | {:>10.4} | {:>10.4} | {:>8.3} | {:>8.3}",
var_name, self.params[fit_idx], self.std_errors[fit_idx], self.z_values[fit_idx], self.p_values[fit_idx]
)?;
fit_idx += 1;
}
}
writeln!(f, "{:=^78}", "")?;
for (_, name) in &self.omitted_vars {
writeln!(f, "note: {} omitted because of collinearity", name)?;
}
Ok(())
}
}
pub struct Logit;
impl Logit {
pub fn from_formula(
formula: &Formula,
data: &DataFrame,
) -> Result<BinaryModelResult, GreenersError> {
let (y, x) = data.to_design_matrix(formula)?;
let var_names = data.formula_var_names(formula)?;
Self::fit_with_names(&y, &x, Some(var_names))
}
pub fn fit(y: &Array1<f64>, x: &Array2<f64>) -> Result<BinaryModelResult, GreenersError> {
Self::fit_with_names(y, x, None)
}
pub fn fit_with_names(
y: &Array1<f64>,
x: &Array2<f64>,
variable_names: Option<Vec<String>>,
) -> Result<BinaryModelResult, GreenersError> {
let n = x.nrows();
if y.iter().any(|v| !v.is_finite()) || x.iter().any(|v| !v.is_finite()) {
return Err(GreenersError::InvalidOperation(
"Input data contains NaN or Inf values".into(),
));
}
let (x_to_use, variable_names, omitted_positioned) = if let Some(ref names) = variable_names {
let cr = crate::linalg::drop_collinear(x, names, 1e-10);
if cr.omitted.is_empty() {
(x.clone(), variable_names, vec![])
} else {
(cr.x_clean, Some(cr.clean_names), cr.omitted)
}
} else {
(x.clone(), variable_names, vec![])
};
let x = &x_to_use;
let k_clean = x.ncols();
if n <= k_clean {
return Err(GreenersError::ShapeMismatch(
"Degrees of freedom <= 0 after removing collinear variables".into(),
));
}
let mut beta = Array1::<f64>::zeros(k_clean);
let tol = 1e-6;
let max_iter = 100;
let mut diff = 1.0;
let mut iter = 0;
let mut log_likelihood = 0.0;
while diff > tol && iter < max_iter {
let xb = x.dot(&beta);
let p = xb.mapv(|val| 1.0 / (1.0 + (-val).exp()));
let error = y - &p;
let gradient = x.t().dot(&error);
let w_diag = &p * &(1.0 - &p);
let mut x_weighted = x.to_owned();
for (i, mut row) in x_weighted.axis_iter_mut(Axis(0)).enumerate() {
row *= w_diag[i];
}
let hessian = -x.t().dot(&x_weighted);
let neg_hessian = -hessian;
let inv_neg_hessian = match neg_hessian.inv() {
Ok(mat) => mat,
Err(_) => return Err(GreenersError::OptimizationFailed),
};
let change = inv_neg_hessian.dot(&gradient);
beta = &beta + &change;
diff = change.mapv(|v| v.powi(2)).sum().sqrt();
iter += 1;
log_likelihood = 0.0;
for i in 0..n {
let prob = p[i].clamp(1e-10, 1.0 - 1e-10);
if y[i] > 0.5 {
log_likelihood += prob.ln();
} else {
log_likelihood += (1.0 - prob).ln();
}
}
}
if iter == max_iter {
return Err(GreenersError::OptimizationFailed);
}
let xb = x.dot(&beta);
let p = xb.mapv(|val| 1.0 / (1.0 + (-val).exp()));
let w_diag = &p * &(1.0 - &p);
let mut x_weighted = x.to_owned();
for (i, mut row) in x_weighted.axis_iter_mut(Axis(0)).enumerate() {
row *= w_diag[i];
}
let neg_hessian = x.t().dot(&x_weighted);
let cov_matrix = neg_hessian.inv()?;
let std_errors = cov_matrix.diag().mapv(f64::sqrt);
let z_values = &beta / &std_errors;
let normal_dist = Normal::new(0.0, 1.0).unwrap();
let p_values = z_values.mapv(|z| 2.0 * (1.0 - normal_dist.cdf(z.abs())));
let y_mean = y.mean().unwrap_or(0.5);
let ll_null = (n as f64) * (y_mean * y_mean.ln() + (1.0 - y_mean) * (1.0 - y_mean).ln());
let pseudo_r2 = 1.0 - (log_likelihood / ll_null);
Ok(BinaryModelResult {
model_name: "Logit".to_string(),
params: beta,
std_errors,
z_values,
p_values,
iterations: iter,
log_likelihood,
pseudo_r2,
_x_data: Some(x.to_owned()),
inference_type: InferenceType::Normal, variable_names,
omitted_vars: omitted_positioned,
})
}
}
impl BinaryModelResult {
pub fn average_marginal_effects(&self, x: &Array2<f64>) -> Result<Array1<f64>, GreenersError> {
let n = x.nrows();
let k = x.ncols();
let mut ame = Array1::<f64>::zeros(k);
for i in 0..n {
let x_i = x.row(i);
let xb = x_i.dot(&self.params);
let density = if self.model_name == "Logit" {
let exp_xb = xb.exp();
exp_xb / (1.0 + exp_xb).powi(2)
} else {
let normal = Normal::new(0.0, 1.0).unwrap();
normal.pdf(xb)
};
for j in 0..k {
ame[j] += self.params[j] * density;
}
}
ame /= n as f64;
Ok(ame)
}
pub fn marginal_effects_at_means(&self, x: &Array2<f64>) -> Result<Array1<f64>, GreenersError> {
let k = x.ncols();
let x_means = x.mean_axis(Axis(0)).unwrap();
let xb_mean = x_means.dot(&self.params);
let density = if self.model_name == "Logit" {
let exp_xb = xb_mean.exp();
exp_xb / (1.0 + exp_xb).powi(2)
} else {
let normal = Normal::new(0.0, 1.0).unwrap();
normal.pdf(xb_mean)
};
let mut mem = Array1::<f64>::zeros(k);
for j in 0..k {
mem[j] = self.params[j] * density;
}
Ok(mem)
}
pub fn predict_proba(&self, x: &Array2<f64>) -> Array1<f64> {
let xb = x.dot(&self.params);
if self.model_name == "Logit" {
xb.mapv(|val| 1.0 / (1.0 + (-val).exp()))
} else {
let normal = Normal::new(0.0, 1.0).unwrap();
xb.mapv(|val| normal.cdf(val))
}
}
pub fn ame_confidence_intervals(
&self,
x: &Array2<f64>,
_alpha: f64,
) -> Result<(Array1<f64>, Array1<f64>), GreenersError> {
let ame = self.average_marginal_effects(x)?;
let k = ame.len();
let z_crit = 1.96;
let n = x.nrows();
let mut avg_density = 0.0;
for i in 0..n {
let x_i = x.row(i);
let xb = x_i.dot(&self.params);
let density = if self.model_name == "Logit" {
let exp_xb = xb.exp();
exp_xb / (1.0 + exp_xb).powi(2)
} else {
let normal = Normal::new(0.0, 1.0).unwrap();
normal.pdf(xb)
};
avg_density += density;
}
avg_density /= n as f64;
let mut lower = Array1::<f64>::zeros(k);
let mut upper = Array1::<f64>::zeros(k);
for j in 0..k {
let me_se = self.std_errors[j] * avg_density;
lower[j] = ame[j] - z_crit * me_se;
upper[j] = ame[j] + z_crit * me_se;
}
Ok((lower, upper))
}
pub fn model_stats(&self) -> (f64, f64, f64, f64) {
let k = self.params.len() as f64;
let aic = -2.0 * self.log_likelihood + 2.0 * k;
let bic = -2.0 * self.log_likelihood + k * (self.iterations as f64).ln();
(aic, bic, self.log_likelihood, self.pseudo_r2)
}
}
pub struct Probit;
impl Probit {
pub fn from_formula(
formula: &Formula,
data: &DataFrame,
) -> Result<BinaryModelResult, GreenersError> {
let (y, x) = data.to_design_matrix(formula)?;
let var_names = data.formula_var_names(formula)?;
Self::fit_with_names(&y, &x, Some(var_names))
}
pub fn fit(y: &Array1<f64>, x: &Array2<f64>) -> Result<BinaryModelResult, GreenersError> {
Self::fit_with_names(y, x, None)
}
pub fn fit_with_names(
y: &Array1<f64>,
x: &Array2<f64>,
variable_names: Option<Vec<String>>,
) -> Result<BinaryModelResult, GreenersError> {
let n = x.nrows();
if y.iter().any(|v| !v.is_finite()) || x.iter().any(|v| !v.is_finite()) {
return Err(GreenersError::InvalidOperation(
"Input data contains NaN or Inf values".into(),
));
}
let (x_to_use, variable_names, omitted_positioned) = if let Some(ref names) = variable_names {
let cr = crate::linalg::drop_collinear(x, names, 1e-10);
if cr.omitted.is_empty() {
(x.clone(), variable_names, vec![])
} else {
(cr.x_clean, Some(cr.clean_names), cr.omitted)
}
} else {
(x.clone(), variable_names, vec![])
};
let x = &x_to_use;
let k_clean = x.ncols();
if n <= k_clean {
return Err(GreenersError::ShapeMismatch(
"Degrees of freedom <= 0 after removing collinear variables".into(),
));
}
let mut beta = Array1::<f64>::zeros(k_clean);
let normal_dist = Normal::new(0.0, 1.0).unwrap();
let tol = 1e-6;
let max_iter = 100;
let mut diff = 1.0;
let mut iter = 0;
let mut log_likelihood = 0.0;
while diff > tol && iter < max_iter {
let xb = x.dot(&beta);
let mut p = Array1::<f64>::zeros(n);
let mut f = Array1::<f64>::zeros(n);
for i in 0..n {
let val = xb[i];
p[i] = normal_dist.cdf(val).clamp(1e-10, 1.0 - 1e-10);
f[i] = normal_dist.pdf(val);
}
let numerator = f.clone();
let denominator = &p * &(1.0 - &p);
let weight_factor = &numerator / &denominator;
let error = y - &p;
let score_term = &error * &weight_factor;
let gradient = x.t().dot(&score_term);
let w_diag = (&f * &f) / &denominator;
let mut x_weighted = x.to_owned();
for (i, mut row) in x_weighted.axis_iter_mut(Axis(0)).enumerate() {
row *= w_diag[i];
}
let hessian = -x.t().dot(&x_weighted);
let neg_hessian = -hessian;
let inv_neg_hessian = match neg_hessian.inv() {
Ok(mat) => mat,
Err(_) => return Err(GreenersError::OptimizationFailed),
};
let change = inv_neg_hessian.dot(&gradient);
beta = &beta + &change;
diff = change.mapv(|v| v.powi(2)).sum().sqrt();
iter += 1;
log_likelihood = 0.0;
for i in 0..n {
if y[i] > 0.5 {
log_likelihood += p[i].ln();
} else {
log_likelihood += (1.0 - p[i]).ln();
}
}
}
if iter == max_iter {
return Err(GreenersError::OptimizationFailed);
}
let xb = x.dot(&beta);
let mut w_diag = Array1::<f64>::zeros(n);
for i in 0..n {
let val = xb[i];
let p_val = normal_dist.cdf(val).clamp(1e-10, 1.0 - 1e-10);
let f_val = normal_dist.pdf(val);
w_diag[i] = (f_val * f_val) / (p_val * (1.0 - p_val));
}
let mut x_weighted = x.to_owned();
for (i, mut row) in x_weighted.axis_iter_mut(Axis(0)).enumerate() {
row *= w_diag[i];
}
let neg_hessian = x.t().dot(&x_weighted);
let cov_matrix = neg_hessian.inv()?;
let std_errors = cov_matrix.diag().mapv(f64::sqrt);
let z_values = &beta / &std_errors;
let p_values = z_values.mapv(|z| 2.0 * (1.0 - normal_dist.cdf(z.abs())));
let y_mean = y.mean().unwrap_or(0.5);
let ll_null = (n as f64) * (y_mean * y_mean.ln() + (1.0 - y_mean) * (1.0 - y_mean).ln());
let pseudo_r2 = 1.0 - (log_likelihood / ll_null);
Ok(BinaryModelResult {
model_name: "Probit".to_string(),
params: beta,
std_errors,
z_values,
p_values,
iterations: iter,
log_likelihood,
pseudo_r2,
_x_data: Some(x.to_owned()),
inference_type: InferenceType::Normal, variable_names,
omitted_vars: omitted_positioned,
})
}
}