use super::DecisionTreeClassifier;
use crate::error::Result;
#[derive(Debug, Clone)]
pub struct GradientBoostingClassifier {
n_estimators: usize,
learning_rate: f32,
max_depth: usize,
init_prediction: f32,
estimators: Vec<DecisionTreeClassifier>,
}
impl GradientBoostingClassifier {
#[must_use]
pub fn new() -> Self {
Self {
n_estimators: 100,
learning_rate: 0.1,
max_depth: 3,
init_prediction: 0.0,
estimators: Vec::new(),
}
}
#[must_use]
pub fn with_n_estimators(mut self, n_estimators: usize) -> Self {
self.n_estimators = n_estimators;
self
}
#[must_use]
pub fn with_learning_rate(mut self, learning_rate: f32) -> Self {
self.learning_rate = learning_rate;
self
}
#[must_use]
pub fn with_max_depth(mut self, max_depth: usize) -> Self {
self.max_depth = max_depth;
self
}
fn sigmoid(x: f32) -> f32 {
crate::nn::functional::sigmoid_scalar(x)
}
pub fn fit(&mut self, x: &crate::primitives::Matrix<f32>, y: &[usize]) -> Result<()> {
if x.n_rows() != y.len() {
return Err("x and y must have the same number of samples".into());
}
if x.n_rows() == 0 {
return Err("Cannot fit with 0 samples".into());
}
let n_samples = x.n_rows();
let y_float: Vec<f32> = y.iter().map(|&label| label as f32).collect();
let positive_count = y_float.iter().filter(|&&label| label == 1.0).count();
let p = positive_count as f32 / n_samples as f32;
self.init_prediction = if p > 0.0 && p < 1.0 {
(p / (1.0 - p)).ln()
} else if p >= 1.0 {
5.0 } else {
-5.0 };
let mut raw_predictions = vec![self.init_prediction; n_samples];
self.estimators = Vec::with_capacity(self.n_estimators);
for _ in 0..self.n_estimators {
let probabilities: Vec<f32> =
raw_predictions.iter().map(|&r| Self::sigmoid(r)).collect();
let residuals: Vec<f32> = y_float
.iter()
.zip(probabilities.iter())
.map(|(&yi, &pi)| yi - pi)
.collect();
let residual_labels = self.residuals_to_labels(&residuals);
let mut tree = DecisionTreeClassifier::new().with_max_depth(self.max_depth);
tree.fit(x, &residual_labels)?;
let tree_preds = tree.predict(x);
let tree_residuals: Vec<f32> = tree_preds
.iter()
.map(|&pred| if pred == 0 { -1.0 } else { 1.0 })
.collect();
for i in 0..n_samples {
raw_predictions[i] += self.learning_rate * tree_residuals[i];
}
self.estimators.push(tree);
}
Ok(())
}
#[allow(clippy::unused_self)]
fn residuals_to_labels(&self, residuals: &[f32]) -> Vec<usize> {
residuals.iter().map(|&r| usize::from(r >= 0.0)).collect()
}
pub fn predict(&self, x: &crate::primitives::Matrix<f32>) -> Result<Vec<usize>> {
let probas = self.predict_proba(x)?;
Ok(probas
.iter()
.map(|probs| usize::from(probs[1] >= 0.5))
.collect())
}
pub fn predict_proba(&self, x: &crate::primitives::Matrix<f32>) -> Result<Vec<Vec<f32>>> {
if self.estimators.is_empty() {
return Err("Model not trained yet".into());
}
let n_samples = x.n_rows();
let mut raw_predictions = vec![self.init_prediction; n_samples];
for tree in &self.estimators {
let tree_preds = tree.predict(x);
let tree_residuals: Vec<f32> = tree_preds
.iter()
.map(|&pred| if pred == 0 { -1.0 } else { 1.0 })
.collect();
for i in 0..n_samples {
raw_predictions[i] += self.learning_rate * tree_residuals[i];
}
}
Ok(raw_predictions
.iter()
.map(|&raw| {
let prob_class1 = Self::sigmoid(raw);
let prob_class0 = 1.0 - prob_class1;
vec![prob_class0, prob_class1]
})
.collect())
}
#[must_use]
pub fn n_estimators(&self) -> usize {
self.estimators.len()
}
#[must_use]
pub fn learning_rate(&self) -> f32 {
self.learning_rate
}
#[must_use]
pub fn max_depth(&self) -> usize {
self.max_depth
}
#[must_use]
pub fn configured_n_estimators(&self) -> usize {
self.n_estimators
}
#[must_use]
pub fn estimators(&self) -> &[DecisionTreeClassifier] {
&self.estimators
}
}
impl Default for GradientBoostingClassifier {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[path = "tests_gbm_contract.rs"]
mod tests_gbm_contract;
impl crate::traits::Estimator for GradientBoostingClassifier {
fn fit(
&mut self,
x: &crate::primitives::Matrix<f32>,
y: &crate::primitives::Vector<f32>,
) -> Result<()> {
let labels: Vec<usize> = y.as_slice().iter().map(|&v| v.round() as usize).collect();
GradientBoostingClassifier::fit(self, x, &labels)
}
fn predict(&self, x: &crate::primitives::Matrix<f32>) -> crate::primitives::Vector<f32> {
let labels: Vec<usize> =
GradientBoostingClassifier::predict(self, x).unwrap_or_else(|_| vec![0; x.shape().0]);
crate::primitives::Vector::from_vec(labels.into_iter().map(|l| l as f32).collect())
}
fn score(&self, x: &crate::primitives::Matrix<f32>, y: &crate::primitives::Vector<f32>) -> f32 {
let preds: Vec<usize> =
GradientBoostingClassifier::predict(self, x).unwrap_or_else(|_| vec![0; x.shape().0]);
let n = y.len();
if n == 0 {
return 0.0;
}
let correct = preds
.iter()
.zip(y.as_slice())
.filter(|(&p, &t)| p == t.round() as usize)
.count();
correct as f32 / n as f32
}
}