use crate::error::Error;
pub use crate::machine_learning::RegularizationType;
use crate::machine_learning::validation::{
preliminary_check, validate_learning_rate, validate_max_iterations, validate_predict_input,
validate_regularization_type, validate_tolerance,
};
use crate::math::matmul::gemv_par_auto;
use crate::math::{logistic_loss, sigmoid};
use crate::parallel_gates::{cheap_map_f64_parallel_threshold, exp_map_f64_parallel_threshold};
use crate::{Deserialize, Serialize};
use ndarray::{Array1, Array2, ArrayBase, ArrayView2, Axis, Data, Ix1, Ix2, s};
use rayon::prelude::{IndexedParallelIterator, IntoParallelIterator, ParallelIterator};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LogisticRegression {
weights: Option<Array1<f64>>,
fit_intercept: bool,
learning_rate: f64,
max_iter: usize,
tol: f64,
n_iter: Option<usize>,
regularization_type: Option<RegularizationType>,
}
impl Default for LogisticRegression {
fn default() -> Self {
LogisticRegression {
weights: None,
fit_intercept: true,
learning_rate: 0.01,
max_iter: 100,
tol: 1e-4,
n_iter: None,
regularization_type: None,
}
}
}
impl LogisticRegression {
pub fn new(
fit_intercept: bool,
learning_rate: f64,
max_iterations: usize,
tolerance: f64,
) -> Result<Self, Error> {
validate_learning_rate(learning_rate)?;
validate_max_iterations(max_iterations)?;
validate_tolerance(tolerance)?;
Ok(LogisticRegression {
weights: None,
fit_intercept,
learning_rate,
max_iter: max_iterations,
tol: tolerance,
n_iter: None,
regularization_type: None,
})
}
pub fn with_regularization(
mut self,
regularization: RegularizationType,
) -> Result<Self, Error> {
validate_regularization_type(Some(regularization))?;
self.regularization_type = Some(regularization);
Ok(self)
}
get_field!(get_fit_intercept, fit_intercept, bool);
get_field!(get_learning_rate, learning_rate, f64);
get_field!(get_max_iterations, max_iter, usize);
get_field!(get_tolerance, tol, f64);
get_field!(get_actual_iterations, n_iter, Option<usize>);
get_field!(
get_regularization_type,
regularization_type,
Option<RegularizationType>
);
get_field_as_ref!(get_weights, weights, Option<&Array1<f64>>);
pub fn fit<S>(
&mut self,
x: &ArrayBase<S, Ix2>,
y: &ArrayBase<S, Ix1>,
) -> Result<&mut Self, Error>
where
S: Data<Elem = f64>,
{
preliminary_check(x, Some(y))?;
for &val in y.iter() {
if val != 0.0 && val != 1.0 {
return Err(Error::invalid_input(
"Target vector must contain only 0 or 1",
));
}
}
let (n_samples, mut n_features) = x.dim();
let x_train_view: ArrayView2<f64>;
let _x_train_owned: Option<Array2<f64>>;
if self.fit_intercept {
n_features += 1;
let mut x_with_bias = Array2::ones((n_samples, n_features));
x_with_bias.slice_mut(s![.., 1..]).assign(x);
_x_train_owned = Some(x_with_bias);
x_train_view = _x_train_owned.as_ref().unwrap().view();
} else {
_x_train_owned = None;
x_train_view = x.view();
}
let mut weights = Array1::zeros(n_features);
let mut prev_cost = f64::INFINITY;
#[cfg(feature = "show_progress")]
let mut final_cost = prev_cost;
let mut n_iter = 0;
#[cfg(feature = "show_progress")]
let progress_bar = {
let pb = crate::create_progress_bar(
self.max_iter as u64,
"[{elapsed_precise}] {bar:40} {pos}/{len} | Loss: {msg}",
);
pb.set_message(format!("{:.6}", f64::INFINITY));
pb
};
while n_iter < self.max_iter {
n_iter += 1;
let predictions = gemv_par_auto(&x_train_view, &weights);
let mut sigmoid_preds = predictions.clone();
if n_samples >= exp_map_f64_parallel_threshold() {
sigmoid_preds.par_mapv_inplace(sigmoid);
} else {
sigmoid_preds.mapv_inplace(sigmoid);
}
let errors = &sigmoid_preds - y;
let mut gradients = gemv_par_auto(&x_train_view.t(), &errors) / n_samples as f64;
if gradients.iter().any(|&val| !val.is_finite()) {
#[cfg(feature = "show_progress")]
progress_bar.finish_with_message("Error: NaN or infinite gradients");
return Err(Error::non_finite("gradient calculation"));
}
if let Some(reg_type) = &self.regularization_type {
let start_idx = if self.fit_intercept { 1 } else { 0 };
match reg_type {
RegularizationType::L1(regularization_strength) => {
for i in start_idx..n_features {
let sign = if weights[i] > 0.0 {
1.0
} else if weights[i] < 0.0 {
-1.0
} else {
0.0
};
gradients[i] += regularization_strength * sign;
}
}
RegularizationType::L2(regularization_strength) => {
for i in start_idx..n_features {
gradients[i] += regularization_strength * weights[i];
}
}
}
}
let mut cost = logistic_loss(&predictions, y);
if let Some(reg_type) = &self.regularization_type {
let start_idx = if self.fit_intercept { 1 } else { 0 };
match reg_type {
RegularizationType::L1(regularization_strength) => {
let l1_penalty: f64 =
weights.slice(s![start_idx..]).mapv(|w| w.abs()).sum();
cost += regularization_strength * l1_penalty;
}
RegularizationType::L2(regularization_strength) => {
let l2_penalty: f64 = weights.slice(s![start_idx..]).mapv(|w| w * w).sum();
cost += regularization_strength * l2_penalty / 2.0;
}
}
}
weights.scaled_add(-self.learning_rate, &gradients);
if weights.iter().any(|&val| !val.is_finite()) {
#[cfg(feature = "show_progress")]
progress_bar.finish_with_message("Error: NaN or infinite weights");
return Err(Error::non_finite("weight update"));
}
#[cfg(feature = "show_progress")]
{
final_cost = cost;
}
if !cost.is_finite() {
#[cfg(feature = "show_progress")]
progress_bar.finish_with_message("Error: NaN or infinite cost");
return Err(Error::non_finite("cost calculation"));
}
#[cfg(feature = "show_progress")]
{
progress_bar.set_message(format!("{:.6}", cost));
progress_bar.inc(1);
}
if (prev_cost - cost).abs() < self.tol {
break;
}
prev_cost = cost;
}
#[cfg(feature = "show_progress")]
let convergence_status = if n_iter < self.max_iter {
"Converged"
} else {
"Max iterations"
};
#[cfg(feature = "show_progress")]
progress_bar.finish_with_message(format!(
"{:.6} | {} | Iterations: {}",
final_cost, convergence_status, n_iter
));
self.weights = Some(weights);
self.n_iter = Some(n_iter);
Ok(self)
}
pub fn predict<S>(&self, x: &ArrayBase<S, Ix2>) -> Result<Array1<i32>, Error>
where
S: Data<Elem = f64>,
{
let probs = self.predict_proba(x)?;
Ok(probs.mapv(|prob| if prob >= 0.5 { 1 } else { 0 }))
}
pub fn predict_proba<S>(&self, x: &ArrayBase<S, Ix2>) -> Result<Array1<f64>, Error>
where
S: Data<Elem = f64>,
{
let weights = self
.weights
.as_ref()
.ok_or_else(|| Error::not_fitted("LogisticRegression"))?;
let expected_features = if self.fit_intercept {
weights.len() - 1
} else {
weights.len()
};
validate_predict_input(x, expected_features)?;
let probs = if self.fit_intercept {
let (n_samples, n_features) = x.dim();
let mut x_with_bias = Array2::ones((n_samples, n_features + 1));
x_with_bias.slice_mut(s![.., 1..]).assign(x);
self.sigmoid_decision(&x_with_bias)
} else {
self.sigmoid_decision(x)
};
if probs.iter().any(|&val| !val.is_finite()) {
return Err(Error::non_finite("probability calculation"));
}
Ok(probs)
}
fn sigmoid_decision<S>(&self, x: &ArrayBase<S, Ix2>) -> Array1<f64>
where
S: Data<Elem = f64>,
{
let weights = self.weights.as_ref().unwrap();
let mut predictions = gemv_par_auto(x, weights);
if predictions.len() >= exp_map_f64_parallel_threshold() {
predictions.par_mapv_inplace(sigmoid);
} else {
predictions.mapv_inplace(sigmoid);
}
predictions
}
pub fn fit_predict<S>(
&mut self,
train_x: &ArrayBase<S, Ix2>,
train_y: &ArrayBase<S, Ix1>,
) -> Result<Array1<i32>, Error>
where
S: Data<Elem = f64>,
{
self.fit(train_x, train_y)?;
self.predict(train_x)
}
model_save_and_load_methods!(LogisticRegression);
}
pub fn generate_polynomial_features<S>(x: &ArrayBase<S, Ix2>, degree: usize) -> Array2<f64>
where
S: Data<Elem = f64> + Send + Sync,
{
let (n_samples, n_features) = x.dim();
let n_output_features = {
let mut count = 0;
for d in 1..=degree {
let mut term = 1;
for i in 0..d {
term = term * (n_features + i) / (i + 1);
}
count += term;
}
count
};
let mut result = Array2::<f64>::zeros((n_samples, n_output_features));
if n_samples.saturating_mul(n_features) >= cheap_map_f64_parallel_threshold() {
result
.axis_iter_mut(Axis(0))
.into_par_iter()
.enumerate()
.for_each(|(i, mut row)| {
for j in 0..n_features {
row[j] = x[[i, j]];
}
});
} else {
result
.axis_iter_mut(Axis(0))
.enumerate()
.for_each(|(i, mut row)| {
for j in 0..n_features {
row[j] = x[[i, j]];
}
});
}
if degree >= 2 {
let mut col_idx = n_features;
#[allow(clippy::too_many_arguments)]
fn add_combinations<S>(
x: &ArrayBase<S, Ix2>,
result: &mut Array2<f64>,
col_idx: &mut usize,
n_features: usize,
degree: usize,
current_degree: usize,
start_feature: usize,
combination: &mut Vec<usize>,
) where
S: Data<Elem = f64> + Send + Sync,
{
if current_degree == degree {
let current_col = *col_idx;
*col_idx += 1;
if result.nrows().saturating_mul(combination.len())
>= cheap_map_f64_parallel_threshold()
{
result
.axis_iter_mut(Axis(0))
.into_par_iter()
.enumerate()
.for_each(|(i, mut row)| {
let mut value = 1.0;
for &feat_idx in combination.iter() {
value *= x[[i, feat_idx]];
}
row[current_col] = value;
});
} else {
result
.axis_iter_mut(Axis(0))
.enumerate()
.for_each(|(i, mut row)| {
let mut value = 1.0;
for &feat_idx in combination.iter() {
value *= x[[i, feat_idx]];
}
row[current_col] = value;
});
}
return;
}
for j in start_feature..n_features {
combination.push(j);
add_combinations(
x,
result,
col_idx,
n_features,
degree,
current_degree + 1,
j,
combination,
);
combination.pop();
}
}
for d in 2..=degree {
add_combinations(
x,
&mut result,
&mut col_idx,
n_features,
d,
0,
0,
&mut vec![],
);
}
}
result
}