1use 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#[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#[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 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 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
237fn 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
267macro_rules! boosting_builders {
269 () => {
270 pub fn n_estimators(mut self, n: usize) -> Self {
272 self.booster.n_estimators = n;
273 self
274 }
275
276 pub fn learning_rate(mut self, rate: f64) -> Self {
279 self.booster.learning_rate = rate;
280 self
281 }
282
283 pub fn max_depth(mut self, depth: Option<usize>) -> Self {
285 self.booster.params.max_depth = depth;
286 self
287 }
288
289 pub fn min_samples_split(mut self, n: usize) -> Self {
291 self.booster.params.min_samples_split = n;
292 self
293 }
294
295 pub fn min_samples_leaf(mut self, n: usize) -> Self {
297 self.booster.params.min_samples_leaf = n;
298 self
299 }
300
301 pub fn max_features(mut self, m: MaxFeatures) -> Self {
303 self.booster.params.max_features = m;
304 self
305 }
306
307 pub fn subsample(mut self, fraction: f64) -> Self {
310 self.booster.subsample = fraction;
311 self
312 }
313
314 pub fn seed(mut self, seed: u64) -> Self {
316 self.booster.seed = seed;
317 self
318 }
319
320 pub fn feature_importances(&self) -> Option<&Array1<f64>> {
322 self.booster.importances.as_ref()
323 }
324
325 pub fn n_trees(&self) -> usize {
327 self.booster.trees.len()
328 }
329 };
330}
331
332#[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#[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)); }
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()); }
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}