use crate::error::{AprenderError, Result};
use crate::primitives::{Matrix, Vector};
#[derive(Debug, Clone)]
pub struct BayesianLogisticRegression {
prior_precision: f32,
learning_rate: f32,
max_iter: usize,
tol: f32,
coefficients_map: Option<Vec<f32>>,
posterior_covariance: Option<Vec<Vec<f32>>>,
}
impl BayesianLogisticRegression {
#[must_use]
pub fn new(prior_precision: f32) -> Self {
Self {
prior_precision,
learning_rate: 0.1, max_iter: 1000,
tol: 1e-4,
coefficients_map: None,
posterior_covariance: None,
}
}
#[must_use]
pub fn with_learning_rate(mut self, lr: f32) -> Self {
self.learning_rate = lr;
self
}
#[must_use]
pub fn with_max_iter(mut self, max_iter: usize) -> Self {
self.max_iter = max_iter;
self
}
#[must_use]
pub fn with_tolerance(mut self, tol: f32) -> Self {
self.tol = tol;
self
}
fn sigmoid(z: f32) -> f32 {
crate::nn::functional::sigmoid_scalar(z)
}
#[must_use]
pub fn coefficients_map(&self) -> Option<&[f32]> {
self.coefficients_map.as_deref()
}
#[must_use]
pub fn posterior_covariance(&self) -> Option<&[Vec<f32>]> {
self.posterior_covariance.as_deref()
}
#[allow(clippy::needless_range_loop)]
pub fn fit(&mut self, x: &Matrix<f32>, y: &Vector<f32>) -> Result<()> {
let n = x.n_rows();
let p = x.n_cols();
if n != y.len() {
return Err(AprenderError::DimensionMismatch {
expected: format!("{n} samples in X"),
actual: format!("{} samples in y", y.len()),
});
}
for &label in y.as_slice() {
if label != 0.0 && label != 1.0 {
return Err(AprenderError::Other(format!(
"Labels must be 0.0 or 1.0, got {label}"
)));
}
}
let mut beta = vec![0.0_f32; p];
for iter in 0..self.max_iter {
let mut predictions = Vec::with_capacity(n);
for i in 0..n {
let mut z = 0.0_f32;
for j in 0..p {
z += x.get(i, j) * beta[j];
}
predictions.push(Self::sigmoid(z));
}
let mut gradient = vec![0.0_f32; p];
for j in 0..p {
let mut grad_j = 0.0_f32;
for i in 0..n {
grad_j += x.get(i, j) * (y[i] - predictions[i]);
}
grad_j /= n as f32;
grad_j -= self.prior_precision * beta[j];
gradient[j] = grad_j;
}
let mut max_update = 0.0_f32;
for j in 0..p {
let update = self.learning_rate * gradient[j];
beta[j] += update;
max_update = max_update.max(update.abs());
}
if max_update < self.tol {
break;
}
if iter == self.max_iter - 1 {
return Err(AprenderError::Other(format!(
"MAP estimation did not converge in {} iterations",
self.max_iter
)));
}
}
self.coefficients_map = Some(beta.clone());
let mut hessian = vec![vec![0.0_f32; p]; p];
let mut predictions = Vec::with_capacity(n);
for i in 0..n {
let mut z = 0.0_f32;
for j in 0..p {
z += x.get(i, j) * beta[j];
}
predictions.push(Self::sigmoid(z));
}
for i in 0..p {
for j in 0..p {
let mut h_ij = 0.0_f32;
for k in 0..n {
let w_k = predictions[k] * (1.0 - predictions[k]);
h_ij += x.get(k, i) * w_k * x.get(k, j);
}
hessian[i][j] = h_ij;
}
hessian[i][i] += self.prior_precision;
}
self.posterior_covariance = Some(hessian);
Ok(())
}
#[allow(clippy::needless_range_loop)]
pub fn predict_proba(&self, x_test: &Matrix<f32>) -> Result<Vector<f32>> {
let beta = self.coefficients_map.as_ref().ok_or_else(|| {
AprenderError::Other("Model not fitted yet. Call fit() first.".into())
})?;
let n = x_test.n_rows();
let p = x_test.n_cols();
if p != beta.len() {
return Err(AprenderError::DimensionMismatch {
expected: format!("{} features", beta.len()),
actual: format!("{p} columns in x_test"),
});
}
let mut probas = Vec::with_capacity(n);
for i in 0..n {
let mut z = 0.0_f32;
for j in 0..p {
z += x_test.get(i, j) * beta[j];
}
probas.push(Self::sigmoid(z));
}
Ok(Vector::from_vec(probas))
}
pub fn predict(&self, x_test: &Matrix<f32>) -> Result<Vector<f32>> {
let probas = self.predict_proba(x_test)?;
let labels: Vec<f32> = probas
.as_slice()
.iter()
.map(|&p| if p >= 0.5 { 1.0 } else { 0.0 })
.collect();
Ok(Vector::from_vec(labels))
}
#[allow(clippy::needless_range_loop)]
pub fn predict_proba_interval(
&self,
x_test: &Matrix<f32>,
level: f32,
) -> Result<(Vector<f32>, Vector<f32>)> {
let beta = self.coefficients_map.as_ref().ok_or_else(|| {
AprenderError::Other("Model not fitted yet. Call fit() first.".into())
})?;
let hessian = self
.posterior_covariance
.as_ref()
.ok_or_else(|| AprenderError::Other("Posterior covariance not available.".into()))?;
let n = x_test.n_rows();
let p = x_test.n_cols();
if p != beta.len() {
return Err(AprenderError::DimensionMismatch {
expected: format!("{} features", beta.len()),
actual: format!("{p} columns in x_test"),
});
}
let hessian_matrix = Matrix::from_vec(p, p, hessian.iter().flatten().copied().collect())
.map_err(|e| AprenderError::Other(format!("Hessian matrix error: {e}")))?;
let z_score = match level {
x if (x - 0.95).abs() < 0.01 => 1.96_f32,
x if (x - 0.90).abs() < 0.01 => 1.645_f32,
x if (x - 0.99).abs() < 0.01 => 2.576_f32,
_ => {
1.96_f32
}
};
let mut lower_bounds = Vec::with_capacity(n);
let mut upper_bounds = Vec::with_capacity(n);
for i in 0..n {
let mut z_mean = 0.0_f32;
for j in 0..p {
z_mean += x_test.get(i, j) * beta[j];
}
let x_i = (0..p).map(|j| x_test.get(i, j)).collect::<Vec<_>>();
let x_i_vec = Vector::from_vec(x_i.clone());
let v = hessian_matrix
.cholesky_solve(&x_i_vec)
.map_err(|e| AprenderError::Other(format!("Cholesky solve failed: {e}")))?;
let mut z_var = 0.0_f32;
for j in 0..p {
z_var += v[j] * x_i[j];
}
if z_var < 0.0 {
z_var = 0.0;
}
let z_std = z_var.sqrt();
let z_lower = z_mean - z_score * z_std;
let z_upper = z_mean + z_score * z_std;
let p_lower = Self::sigmoid(z_lower);
let p_upper = Self::sigmoid(z_upper);
lower_bounds.push(p_lower);
upper_bounds.push(p_upper);
}
Ok((
Vector::from_vec(lower_bounds),
Vector::from_vec(upper_bounds),
))
}
}
#[cfg(test)]
#[path = "logistic_tests.rs"]
mod tests;