Skip to main content

dml_core/learner/
boosting.rs

1//! Gradient boosting (Friedman, 2001) with CART regression trees.
2//!
3//! Each stage fits a shallow tree to the negative gradient of the loss and
4//! adds it, scaled by the learning rate, to the current prediction. This
5//! follows scikit-learn's `GradientBoostingRegressor` / `Classifier`:
6//!
7//! - **Regression** (squared error): start from the (weighted) mean; trees
8//!   are fitted to the residuals and their leaves predict mean residuals.
9//! - **Classification** (log loss): start from the log-odds of the class-1
10//!   share; trees are fitted to `y − p`, then every leaf takes one Newton
11//!   step `Σ w(y − p) / Σ w·p(1 − p)`.
12//!
13//! Optional row subsampling (stochastic gradient boosting) and random feature
14//! subsets are seeded per stage, so results are reproducible.
15
16use ndarray::{Array1, ArrayView1, ArrayView2};
17use rand::seq::index::sample;
18use rand::SeedableRng;
19use rand_chacha::ChaCha8Rng;
20
21use super::forest::{MaxFeatures, Node, Tree, TreeBuilder, TreeParams};
22use super::{Classifier, Regressor};
23use crate::error::{DmlError, Result};
24
25/// Settings shared by the regressor and the classifier.
26#[derive(Debug, Clone)]
27struct Booster {
28    n_estimators: usize,
29    learning_rate: f64,
30    params: TreeParams,
31    subsample: f64,
32    seed: u64,
33    trees: Vec<Tree>,
34    init: f64,
35    n_features: usize,
36    importances: Option<Array1<f64>>,
37}
38
39/// The loss being boosted.
40#[derive(Debug, Clone, Copy, PartialEq)]
41enum Loss {
42    SquaredError,
43    LogLoss,
44}
45
46fn sigmoid(v: f64) -> f64 {
47    if v >= 0.0 {
48        1.0 / (1.0 + (-v).exp())
49    } else {
50        let e = v.exp();
51        e / (1.0 + e)
52    }
53}
54
55impl Booster {
56    fn new() -> Self {
57        Self {
58            n_estimators: 100,
59            learning_rate: 0.1,
60            params: TreeParams {
61                max_depth: Some(3),
62                min_samples_split: 2,
63                min_samples_leaf: 1,
64                max_features: MaxFeatures::All,
65            },
66            subsample: 1.0,
67            seed: 0,
68            trees: Vec::new(),
69            init: 0.0,
70            n_features: 0,
71            importances: None,
72        }
73    }
74
75    fn unfitted(&self) -> Self {
76        Self {
77            trees: Vec::new(),
78            importances: None,
79            ..self.clone()
80        }
81    }
82
83    fn validate(&self) -> Result<()> {
84        if self.n_estimators == 0 {
85            return Err(DmlError::InvalidArgument(
86                "n_estimators must be at least 1".into(),
87            ));
88        }
89        if !(self.learning_rate > 0.0 && self.learning_rate.is_finite()) {
90            return Err(DmlError::InvalidArgument(
91                "learning_rate must be positive".into(),
92            ));
93        }
94        if !(self.subsample > 0.0 && self.subsample <= 1.0) {
95            return Err(DmlError::InvalidArgument(
96                "subsample must be in (0, 1]".into(),
97            ));
98        }
99        if self.params.min_samples_leaf == 0 || self.params.min_samples_split < 2 {
100            return Err(DmlError::InvalidArgument(
101                "min_samples_leaf must be ≥ 1 and min_samples_split ≥ 2".into(),
102            ));
103        }
104        if let MaxFeatures::Fraction(f) = self.params.max_features {
105            if !(f > 0.0 && f <= 1.0) {
106                return Err(DmlError::InvalidArgument(
107                    "max_features fraction must be in (0, 1]".into(),
108                ));
109            }
110        }
111        Ok(())
112    }
113
114    fn fit(
115        &mut self,
116        loss: Loss,
117        x: ArrayView2<'_, f64>,
118        y: ArrayView1<'_, f64>,
119        w: Option<ArrayView1<'_, f64>>,
120    ) -> Result<()> {
121        self.validate()?;
122        let (n, p) = x.dim();
123        if y.len() != n {
124            return Err(DmlError::DimensionMismatch("boosting fit x/y rows".into()));
125        }
126        if n == 0 {
127            return Err(DmlError::InvalidArgument("empty training set".into()));
128        }
129        let w: Vec<f64> = match w {
130            Some(w) => {
131                if w.len() != n {
132                    return Err(DmlError::DimensionMismatch("sample weight length".into()));
133                }
134                if !w.iter().all(|&v| v >= 0.0 && v.is_finite()) || w.sum() <= 0.0 {
135                    return Err(DmlError::InvalidArgument(
136                        "sample weights must be non-negative, finite and not all zero".into(),
137                    ));
138                }
139                w.to_vec()
140            }
141            None => vec![1.0; n],
142        };
143        let total_w: f64 = w.iter().sum();
144        let mean = y.iter().zip(&w).map(|(&a, &b)| a * b).sum::<f64>() / total_w;
145        self.init = match loss {
146            Loss::SquaredError => mean,
147            Loss::LogLoss => {
148                if !(mean > 0.0 && mean < 1.0) {
149                    return Err(DmlError::InvalidArgument(
150                        "GradientBoostingClassifier needs both classes in the training data".into(),
151                    ));
152                }
153                (mean / (1.0 - mean)).ln()
154            }
155        };
156        let n_try = self.params.max_features.resolve(p);
157        let n_sub = ((self.subsample * n as f64) as usize).max(1);
158        let mut raw = vec![self.init; n];
159        let mut grad = vec![0.0; n];
160        let mut importance = vec![0.0; p];
161        self.trees = Vec::with_capacity(self.n_estimators);
162
163        for m in 0..self.n_estimators {
164            let stage_seed = self
165                .seed
166                .wrapping_add((m as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15));
167            let mut rng = ChaCha8Rng::seed_from_u64(stage_seed);
168            // Negative gradient: residual (squared error) or y − p (log loss).
169            for i in 0..n {
170                grad[i] = match loss {
171                    Loss::SquaredError => y[i] - raw[i],
172                    Loss::LogLoss => y[i] - sigmoid(raw[i]),
173                };
174            }
175            let mut rows: Vec<usize> = if n_sub < n {
176                sample(&mut rng, n, n_sub).into_vec()
177            } else {
178                (0..n).collect()
179            };
180            rows.sort_unstable();
181            rows.retain(|&i| w[i] > 0.0);
182            let (mut tree, gains) = TreeBuilder {
183                x,
184                y: &grad,
185                w: &w,
186                params: self.params,
187                n_try,
188                rng,
189                importance: vec![0.0; p],
190            }
191            .grow(rows.clone());
192
193            if loss == Loss::LogLoss {
194                newton_leaf_values(&mut tree, x, &rows, &grad, &raw, &w);
195            }
196            for (acc, g) in importance.iter_mut().zip(&gains) {
197                *acc += g;
198            }
199            for (i, r) in raw.iter_mut().enumerate() {
200                *r += self.learning_rate * tree.predict_row(x.row(i));
201            }
202            self.trees.push(tree);
203        }
204        let total: f64 = importance.iter().sum();
205        self.importances = Some(if total > 0.0 {
206            Array1::from(importance) / total
207        } else {
208            Array1::zeros(p)
209        });
210        self.n_features = p;
211        Ok(())
212    }
213
214    /// Raw additive score `init + lr·Σ tree(x)`.
215    fn decision(&self, x: ArrayView2<'_, f64>) -> Result<Array1<f64>> {
216        if self.trees.is_empty() {
217            return Err(DmlError::NotFitted);
218        }
219        if x.ncols() != self.n_features {
220            return Err(DmlError::DimensionMismatch(format!(
221                "x has {} columns, the model was fitted with {}",
222                x.ncols(),
223                self.n_features
224            )));
225        }
226        Ok(x.rows()
227            .into_iter()
228            .map(|row| {
229                self.init
230                    + self.learning_rate
231                        * self.trees.iter().map(|t| t.predict_row(row)).sum::<f64>()
232            })
233            .collect())
234    }
235}
236
237/// Log-loss Newton step per leaf over the in-bag `rows` (as scikit-learn's
238/// `_update_terminal_regions`): `Σ w·g / Σ w·p(1 − p)`, or 0 if the
239/// denominator vanishes.
240fn newton_leaf_values(
241    tree: &mut Tree,
242    x: ArrayView2<'_, f64>,
243    rows: &[usize],
244    grad: &[f64],
245    raw: &[f64],
246    w: &[f64],
247) {
248    let mut num = vec![0.0; tree.nodes.len()];
249    let mut den = vec![0.0; tree.nodes.len()];
250    for &i in rows {
251        let leaf = tree.leaf_index(x.row(i));
252        let p = sigmoid(raw[i]);
253        num[leaf] += w[i] * grad[i];
254        den[leaf] += w[i] * p * (1.0 - p);
255    }
256    for (k, node) in tree.nodes.iter_mut().enumerate() {
257        if let Node::Leaf { value } = node {
258            *value = if den[k].abs() < 1e-150 {
259                0.0
260            } else {
261                num[k] / den[k]
262            };
263        }
264    }
265}
266
267/// Builder methods shared by both boosting models.
268macro_rules! boosting_builders {
269    () => {
270        /// Number of boosting stages (default 100).
271        pub fn n_estimators(mut self, n: usize) -> Self {
272            self.booster.n_estimators = n;
273            self
274        }
275
276        /// Shrinkage applied to every tree (default 0.1). Smaller values need
277        /// more stages but usually generalise better.
278        pub fn learning_rate(mut self, rate: f64) -> Self {
279            self.booster.learning_rate = rate;
280            self
281        }
282
283        /// Maximum depth of each tree (default 3); `None` for unlimited.
284        pub fn max_depth(mut self, depth: Option<usize>) -> Self {
285            self.booster.params.max_depth = depth;
286            self
287        }
288
289        /// Minimum number of rows to split a node (default 2).
290        pub fn min_samples_split(mut self, n: usize) -> Self {
291            self.booster.params.min_samples_split = n;
292            self
293        }
294
295        /// Minimum number of rows in each leaf (default 1).
296        pub fn min_samples_leaf(mut self, n: usize) -> Self {
297            self.booster.params.min_samples_leaf = n;
298            self
299        }
300
301        /// Features considered per split (default: all).
302        pub fn max_features(mut self, m: MaxFeatures) -> Self {
303            self.booster.params.max_features = m;
304            self
305        }
306
307        /// Fraction of rows drawn (without replacement) for each stage
308        /// (default 1.0); below 1 gives stochastic gradient boosting.
309        pub fn subsample(mut self, fraction: f64) -> Self {
310            self.booster.subsample = fraction;
311            self
312        }
313
314        /// Seed for row subsampling and feature subsets (default 0).
315        pub fn seed(mut self, seed: u64) -> Self {
316            self.booster.seed = seed;
317            self
318        }
319
320        /// Total impurity reduction per feature over all trees (sums to one).
321        pub fn feature_importances(&self) -> Option<&Array1<f64>> {
322            self.booster.importances.as_ref()
323        }
324
325        /// Number of fitted trees.
326        pub fn n_trees(&self) -> usize {
327            self.booster.trees.len()
328        }
329    };
330}
331
332/// Gradient boosting for regression (squared error). Defaults match
333/// scikit-learn's `GradientBoostingRegressor`: 100 stages, learning rate 0.1,
334/// depth-3 trees.
335///
336/// ```
337/// use dml_core::GradientBoostingRegressor;
338/// let gb = GradientBoostingRegressor::new()
339///     .n_estimators(300)
340///     .learning_rate(0.05)
341///     .max_depth(Some(4))
342///     .subsample(0.8)
343///     .seed(1);
344/// # let _ = gb;
345/// ```
346#[derive(Debug, Clone)]
347pub struct GradientBoostingRegressor {
348    booster: Booster,
349}
350
351impl Default for GradientBoostingRegressor {
352    fn default() -> Self {
353        Self::new()
354    }
355}
356
357impl GradientBoostingRegressor {
358    pub fn new() -> Self {
359        Self {
360            booster: Booster::new(),
361        }
362    }
363
364    boosting_builders!();
365}
366
367impl Regressor for GradientBoostingRegressor {
368    fn fit(&mut self, x: ArrayView2<'_, f64>, y: ArrayView1<'_, f64>) -> Result<()> {
369        self.booster.fit(Loss::SquaredError, x, y, None)
370    }
371
372    fn fit_weighted(
373        &mut self,
374        x: ArrayView2<'_, f64>,
375        y: ArrayView1<'_, f64>,
376        w: ArrayView1<'_, f64>,
377    ) -> Result<()> {
378        self.booster.fit(Loss::SquaredError, x, y, Some(w))
379    }
380
381    fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array1<f64>> {
382        self.booster.decision(x)
383    }
384
385    fn fresh(&self) -> Box<dyn Regressor> {
386        Box::new(Self {
387            booster: self.booster.unfitted(),
388        })
389    }
390}
391
392/// Gradient boosting for 0/1 classification (log loss). Defaults match
393/// scikit-learn's `GradientBoostingClassifier`.
394#[derive(Debug, Clone)]
395pub struct GradientBoostingClassifier {
396    booster: Booster,
397}
398
399impl Default for GradientBoostingClassifier {
400    fn default() -> Self {
401        Self::new()
402    }
403}
404
405impl GradientBoostingClassifier {
406    pub fn new() -> Self {
407        Self {
408            booster: Booster::new(),
409        }
410    }
411
412    boosting_builders!();
413}
414
415impl Classifier for GradientBoostingClassifier {
416    fn fit(&mut self, x: ArrayView2<'_, f64>, y: ArrayView1<'_, f64>) -> Result<()> {
417        if !y.iter().all(|&v| v == 0.0 || v == 1.0) {
418            return Err(DmlError::InvalidArgument(
419                "GradientBoostingClassifier labels must be 0 or 1".into(),
420            ));
421        }
422        self.booster.fit(Loss::LogLoss, x, y, None)
423    }
424
425    fn predict_proba(&self, x: ArrayView2<'_, f64>) -> Result<Array1<f64>> {
426        Ok(self.booster.decision(x)?.mapv(sigmoid))
427    }
428
429    fn fresh(&self) -> Box<dyn Classifier> {
430        Box::new(Self {
431            booster: self.booster.unfitted(),
432        })
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use ndarray::{array, Array2};
440    use rand::Rng;
441
442    fn nonlinear(n: usize, seed: u64) -> (Array2<f64>, Array1<f64>) {
443        let mut rng = ChaCha8Rng::seed_from_u64(seed);
444        let x = Array2::from_shape_simple_fn((n, 3), || rng.gen::<f64>() * 4.0 - 2.0);
445        let y = x
446            .rows()
447            .into_iter()
448            .map(|r| (2.0 * r[0]).sin() + r[1] * r[1] - 0.5 * r[2] + 0.1 * (rng.gen::<f64>() - 0.5))
449            .collect();
450        (x, y)
451    }
452
453    fn r2(y: &Array1<f64>, pred: &Array1<f64>) -> f64 {
454        let mean = y.mean().unwrap();
455        1.0 - (y - pred).mapv(|e| e * e).sum() / y.mapv(|v| (v - mean).powi(2)).sum()
456    }
457
458    #[test]
459    fn learns_a_nonlinear_function() {
460        let (x, y) = nonlinear(2000, 1);
461        let (x_test, y_test) = nonlinear(500, 2);
462        let mut gb = GradientBoostingRegressor::new().n_estimators(300);
463        gb.fit(x.view(), y.view()).unwrap();
464        let score = r2(&y_test, &gb.predict(x_test.view()).unwrap());
465        assert!(score > 0.95, "R² = {score}");
466        assert_eq!(gb.n_trees(), 300);
467        assert!((gb.feature_importances().unwrap().sum() - 1.0).abs() < 1e-9);
468    }
469
470    #[test]
471    fn more_stages_reduce_training_error() {
472        let (x, y) = nonlinear(500, 3);
473        let train_r2 = |n| {
474            let mut gb = GradientBoostingRegressor::new().n_estimators(n);
475            gb.fit(x.view(), y.view()).unwrap();
476            r2(&y, &gb.predict(x.view()).unwrap())
477        };
478        let (a, b, c) = (train_r2(10), train_r2(50), train_r2(200));
479        assert!(a < b && b < c, "{a} {b} {c}");
480    }
481
482    #[test]
483    fn single_stump_with_unit_rate_fits_a_step() {
484        let x = array![[0.0], [1.0], [2.0], [3.0]];
485        let y = array![1.0, 1.0, 5.0, 5.0];
486        let mut gb = GradientBoostingRegressor::new()
487            .n_estimators(1)
488            .learning_rate(1.0)
489            .max_depth(Some(1));
490        gb.fit(x.view(), y.view()).unwrap();
491        assert_eq!(gb.predict(x.view()).unwrap(), y);
492    }
493
494    #[test]
495    fn deterministic_and_subsampling_changes_the_fit() {
496        let (x, y) = nonlinear(400, 4);
497        let fit = |sub: f64, seed: u64| {
498            let mut gb = GradientBoostingRegressor::new()
499                .n_estimators(30)
500                .subsample(sub)
501                .seed(seed);
502            gb.fit(x.view(), y.view()).unwrap();
503            gb.predict(x.view()).unwrap()
504        };
505        assert_eq!(fit(0.7, 1), fit(0.7, 1));
506        assert_ne!(fit(0.7, 1), fit(0.7, 2));
507        assert_eq!(fit(1.0, 1), fit(1.0, 2)); // no randomness without subsampling or feature subsets
508    }
509
510    #[test]
511    fn weights_equal_row_duplication() {
512        let x = array![[0.0], [1.0], [2.0], [3.0], [4.0]];
513        let y = array![0.0, 1.0, 3.0, 2.0, 5.0];
514        let mut weighted = GradientBoostingRegressor::new().n_estimators(5);
515        weighted
516            .fit_weighted(x.view(), y.view(), array![2.0, 1.0, 1.0, 3.0, 1.0].view())
517            .unwrap();
518        let xd = array![[0.0], [0.0], [1.0], [2.0], [3.0], [3.0], [3.0], [4.0]];
519        let yd = array![0.0, 0.0, 1.0, 3.0, 2.0, 2.0, 2.0, 5.0];
520        let mut dup = GradientBoostingRegressor::new()
521            .n_estimators(5)
522            .min_samples_leaf(1);
523        dup.fit(xd.view(), yd.view()).unwrap();
524        let diff = weighted.predict(x.view()).unwrap() - dup.predict(x.view()).unwrap();
525        assert!(diff.iter().all(|d| d.abs() < 1e-12), "{diff}");
526    }
527
528    #[test]
529    fn classifier_tracks_true_probabilities() {
530        let mut rng = ChaCha8Rng::seed_from_u64(5);
531        let n = 4000;
532        let x = Array2::from_shape_simple_fn((n, 2), || rng.gen::<f64>() * 4.0 - 2.0);
533        let truth: Array1<f64> = x
534            .rows()
535            .into_iter()
536            .map(|r| sigmoid(2.0 * r[0] - r[1] * r[1] + 0.5))
537            .collect();
538        let labels = truth.mapv(|p| f64::from(rng.gen::<f64>() < p));
539        let mut gb = GradientBoostingClassifier::new()
540            .n_estimators(150)
541            .learning_rate(0.05);
542        gb.fit(x.view(), labels.view()).unwrap();
543        let p = gb.predict_proba(x.view()).unwrap();
544        assert!(p.iter().all(|&v| v > 0.0 && v < 1.0));
545        let mae = (&p - &truth).mapv(f64::abs).mean().unwrap();
546        assert!(mae < 0.06, "mean |p̂ − p| = {mae}");
547        assert!(gb.fit(x.view(), (&labels * 0.0).view()).is_err()); // one class only
548    }
549
550    #[test]
551    fn invalid_settings_and_unfitted_use_error() {
552        let x = array![[0.0], [1.0]];
553        let y = array![0.0, 1.0];
554        assert!(GradientBoostingRegressor::new()
555            .learning_rate(0.0)
556            .fit(x.view(), y.view())
557            .is_err());
558        assert!(GradientBoostingRegressor::new()
559            .subsample(1.5)
560            .fit(x.view(), y.view())
561            .is_err());
562        assert_eq!(
563            GradientBoostingRegressor::new()
564                .predict(x.view())
565                .unwrap_err(),
566            DmlError::NotFitted
567        );
568    }
569}