1use ndarray::{Array1, ArrayView1, ArrayView2};
18use rand::seq::index::sample;
19use rand::{Rng, SeedableRng};
20use rand_chacha::ChaCha8Rng;
21
22use super::{Classifier, Regressor};
23use crate::error::{DmlError, Result};
24
25#[derive(Debug, Clone, Copy, PartialEq)]
27pub enum MaxFeatures {
28 All,
30 Sqrt,
32 Log2,
34 Fraction(f64),
36 Count(usize),
38}
39
40impl MaxFeatures {
41 fn resolve(self, p: usize) -> usize {
42 let k = match self {
43 MaxFeatures::All => p,
44 MaxFeatures::Sqrt => (p as f64).sqrt() as usize,
45 MaxFeatures::Log2 => (p as f64).log2() as usize,
46 MaxFeatures::Fraction(f) => (f * p as f64) as usize,
47 MaxFeatures::Count(k) => k,
48 };
49 k.clamp(1, p.max(1))
50 }
51}
52
53#[derive(Debug, Clone, Copy)]
54struct TreeParams {
55 max_depth: Option<usize>,
56 min_samples_split: usize,
57 min_samples_leaf: usize,
58 max_features: MaxFeatures,
59}
60
61#[derive(Debug, Clone, Copy)]
62enum Node {
63 Leaf {
64 value: f64,
65 },
66 Split {
67 feature: usize,
68 threshold: f64,
69 left: u32,
70 right: u32,
71 },
72}
73
74#[derive(Debug, Clone)]
75struct Tree {
76 nodes: Vec<Node>,
77}
78
79impl Tree {
80 fn predict_row(&self, row: ArrayView1<'_, f64>) -> f64 {
81 let mut i = 0;
82 loop {
83 match self.nodes[i] {
84 Node::Leaf { value } => return value,
85 Node::Split {
86 feature,
87 threshold,
88 left,
89 right,
90 } => {
91 i = if row[feature] <= threshold {
92 left
93 } else {
94 right
95 } as usize
96 }
97 }
98 }
99 }
100}
101
102struct TreeBuilder<'x, 'b> {
108 x: ArrayView2<'x, f64>,
109 y: &'b [f64],
110 w: &'b [f64],
111 params: TreeParams,
112 n_try: usize,
113 rng: ChaCha8Rng,
114 importance: Vec<f64>,
116}
117
118struct Best {
119 feature: usize,
120 threshold: f64,
121 gain: f64,
122}
123
124impl TreeBuilder<'_, '_> {
125 fn grow(mut self, rows: Vec<usize>) -> (Tree, Vec<f64>) {
126 let mut nodes = vec![Node::Leaf { value: 0.0 }];
127 let mut stack = vec![(0usize, rows, 0usize)];
129 while let Some((id, rows, depth)) = stack.pop() {
130 let (sw, swy, swy2) = rows.iter().fold((0.0, 0.0, 0.0), |(a, b, c), &i| {
131 let (wi, yi) = (self.w[i], self.y[i]);
132 (a + wi, b + wi * yi, c + wi * yi * yi)
133 });
134 let value = swy / sw;
135 let sse = swy2 - swy * swy / sw;
136 let splittable = rows.len() >= self.params.min_samples_split
137 && rows.len() >= 2 * self.params.min_samples_leaf
138 && self.params.max_depth.map_or(true, |d| depth < d)
139 && sse > 1e-12 * swy2.max(f64::MIN_POSITIVE);
140 let best = if splittable {
141 self.best_split(&rows, sw, swy)
142 } else {
143 None
144 };
145 let Some(best) = best else {
146 nodes[id] = Node::Leaf { value };
147 continue;
148 };
149 self.importance[best.feature] += best.gain;
150 let (left_rows, right_rows): (Vec<usize>, Vec<usize>) = rows
151 .into_iter()
152 .partition(|&i| self.x[[i, best.feature]] <= best.threshold);
153 let left = nodes.len();
154 nodes.push(Node::Leaf { value: 0.0 });
155 nodes.push(Node::Leaf { value: 0.0 });
156 nodes[id] = Node::Split {
157 feature: best.feature,
158 threshold: best.threshold,
159 left: left as u32,
160 right: (left + 1) as u32,
161 };
162 stack.push((left + 1, right_rows, depth + 1));
163 stack.push((left, left_rows, depth + 1));
164 }
165 (Tree { nodes }, self.importance)
166 }
167
168 fn best_split(&mut self, rows: &[usize], sw: f64, swy: f64) -> Option<Best> {
171 let p = self.x.ncols();
172 let parent = swy * swy / sw;
173 let min_leaf = self.params.min_samples_leaf;
174 let mut best: Option<Best> = None;
175 let mut sorted: Vec<(f64, usize)> = Vec::with_capacity(rows.len());
176 for feature in sample(&mut self.rng, p, self.n_try).into_iter() {
177 sorted.clear();
178 sorted.extend(rows.iter().map(|&i| (self.x[[i, feature]], i)));
179 sorted.sort_unstable_by(|a, b| a.0.total_cmp(&b.0));
180 if sorted[0].0 == sorted[sorted.len() - 1].0 {
181 continue; }
183 let (mut wl, mut sl) = (0.0, 0.0);
184 for k in 0..sorted.len() - 1 {
185 let i = sorted[k].1;
186 wl += self.w[i];
187 sl += self.w[i] * self.y[i];
188 let (v, v_next) = (sorted[k].0, sorted[k + 1].0);
189 let n_left = k + 1;
190 if v == v_next || n_left < min_leaf || sorted.len() - n_left < min_leaf {
191 continue;
192 }
193 let (wr, sr) = (sw - wl, swy - sl);
194 if wl <= 0.0 || wr <= 0.0 {
195 continue;
196 }
197 let gain = sl * sl / wl + sr * sr / wr - parent;
198 if best.as_ref().map_or(true, |b| gain > b.gain) {
199 let mid = v + (v_next - v) / 2.0;
201 let threshold = if mid < v_next { mid } else { v };
202 best = Some(Best {
203 feature,
204 threshold,
205 gain,
206 });
207 }
208 }
209 }
210 best.filter(|b| b.gain > 1e-12 * parent.abs().max(f64::MIN_POSITIVE))
211 }
212}
213
214#[derive(Debug, Clone)]
216struct Forest {
217 n_estimators: usize,
218 params: TreeParams,
219 bootstrap: bool,
220 max_samples: Option<f64>,
221 seed: u64,
222 trees: Vec<Tree>,
223 y_offset: f64,
224 n_features: usize,
225 importances: Option<Array1<f64>>,
226}
227
228impl Forest {
229 fn new(max_features: MaxFeatures) -> Self {
230 Self {
231 n_estimators: 100,
232 params: TreeParams {
233 max_depth: None,
234 min_samples_split: 2,
235 min_samples_leaf: 1,
236 max_features,
237 },
238 bootstrap: true,
239 max_samples: None,
240 seed: 0,
241 trees: Vec::new(),
242 y_offset: 0.0,
243 n_features: 0,
244 importances: None,
245 }
246 }
247
248 fn unfitted(&self) -> Self {
249 Self {
250 trees: Vec::new(),
251 importances: None,
252 ..self.clone()
253 }
254 }
255
256 fn validate(&self) -> Result<()> {
257 if self.n_estimators == 0 {
258 return Err(DmlError::InvalidArgument(
259 "n_estimators must be at least 1".into(),
260 ));
261 }
262 if self.params.min_samples_leaf == 0 || self.params.min_samples_split < 2 {
263 return Err(DmlError::InvalidArgument(
264 "min_samples_leaf must be ≥ 1 and min_samples_split ≥ 2".into(),
265 ));
266 }
267 if let MaxFeatures::Fraction(f) = self.params.max_features {
268 if !(f > 0.0 && f <= 1.0) {
269 return Err(DmlError::InvalidArgument(
270 "max_features fraction must be in (0, 1]".into(),
271 ));
272 }
273 }
274 if let Some(m) = self.max_samples {
275 if !(m > 0.0 && m <= 1.0) {
276 return Err(DmlError::InvalidArgument(
277 "max_samples must be in (0, 1]".into(),
278 ));
279 }
280 }
281 Ok(())
282 }
283
284 fn fit(
285 &mut self,
286 x: ArrayView2<'_, f64>,
287 y: ArrayView1<'_, f64>,
288 w: Option<ArrayView1<'_, f64>>,
289 ) -> Result<()> {
290 self.validate()?;
291 let (n, p) = x.dim();
292 if y.len() != n {
293 return Err(DmlError::DimensionMismatch("forest fit x/y rows".into()));
294 }
295 if n == 0 {
296 return Err(DmlError::InvalidArgument("empty training set".into()));
297 }
298 let weights: Vec<f64> = match w {
299 Some(w) => {
300 if w.len() != n {
301 return Err(DmlError::DimensionMismatch("sample weight length".into()));
302 }
303 if !w.iter().all(|&v| v >= 0.0 && v.is_finite()) || w.sum() <= 0.0 {
304 return Err(DmlError::InvalidArgument(
305 "sample weights must be non-negative, finite and not all zero".into(),
306 ));
307 }
308 w.to_vec()
309 }
310 None => vec![1.0; n],
311 };
312 let total_w: f64 = weights.iter().sum();
314 let offset = y.iter().zip(&weights).map(|(&a, &b)| a * b).sum::<f64>() / total_w;
315 let yc: Vec<f64> = y.iter().map(|&v| v - offset).collect();
316 let n_draw = self
317 .max_samples
318 .map_or(n, |m| ((m * n as f64).round() as usize).max(1));
319 let n_try = self.params.max_features.resolve(p);
320
321 let grow_tree = |t: usize| -> (Tree, Vec<f64>) {
322 let tree_seed = self
323 .seed
324 .wrapping_add((t as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15));
325 let mut rng = ChaCha8Rng::seed_from_u64(tree_seed);
326 let mut eff = vec![0.0; n];
327 if self.bootstrap {
328 for _ in 0..n_draw {
329 eff[rng.gen_range(0..n)] += 1.0;
330 }
331 for (e, &wi) in eff.iter_mut().zip(&weights) {
332 *e *= wi;
333 }
334 } else {
335 eff.clone_from(&weights);
336 }
337 let rows: Vec<usize> = (0..n).filter(|&i| eff[i] > 0.0).collect();
338 let rows = if rows.is_empty() {
339 (0..n).collect()
340 } else {
341 rows
342 };
343 TreeBuilder {
344 x,
345 y: &yc,
346 w: &eff,
347 params: self.params,
348 n_try,
349 rng,
350 importance: vec![0.0; p],
351 }
352 .grow(rows)
353 };
354
355 #[cfg(feature = "parallel")]
356 let grown: Vec<(Tree, Vec<f64>)> = {
357 use rayon::prelude::*;
358 (0..self.n_estimators)
359 .into_par_iter()
360 .map(grow_tree)
361 .collect()
362 };
363 #[cfg(not(feature = "parallel"))]
364 let grown: Vec<(Tree, Vec<f64>)> = (0..self.n_estimators).map(grow_tree).collect();
365
366 let mut importances = Array1::<f64>::zeros(p);
368 let mut counted = 0.0;
369 for (_, imp) in &grown {
370 let total: f64 = imp.iter().sum();
371 if total > 0.0 {
372 importances += &(Array1::from(imp.clone()) / total);
373 counted += 1.0;
374 }
375 }
376 if counted > 0.0 {
377 importances /= counted;
378 }
379 self.trees = grown.into_iter().map(|(t, _)| t).collect();
380 self.y_offset = offset;
381 self.n_features = p;
382 self.importances = Some(importances);
383 Ok(())
384 }
385
386 fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array1<f64>> {
387 if self.trees.is_empty() {
388 return Err(DmlError::NotFitted);
389 }
390 if x.ncols() != self.n_features {
391 return Err(DmlError::DimensionMismatch(format!(
392 "x has {} columns, the forest was fitted with {}",
393 x.ncols(),
394 self.n_features
395 )));
396 }
397 let per_row = |row: ArrayView1<'_, f64>| -> f64 {
398 self.trees.iter().map(|t| t.predict_row(row)).sum::<f64>() / self.trees.len() as f64
399 + self.y_offset
400 };
401 Ok(x.rows().into_iter().map(per_row).collect())
402 }
403}
404
405macro_rules! forest_builders {
407 () => {
408 pub fn n_estimators(mut self, n: usize) -> Self {
410 self.forest.n_estimators = n;
411 self
412 }
413
414 pub fn max_depth(mut self, depth: Option<usize>) -> Self {
416 self.forest.params.max_depth = depth;
417 self
418 }
419
420 pub fn min_samples_split(mut self, n: usize) -> Self {
422 self.forest.params.min_samples_split = n;
423 self
424 }
425
426 pub fn min_samples_leaf(mut self, n: usize) -> Self {
429 self.forest.params.min_samples_leaf = n;
430 self
431 }
432
433 pub fn max_features(mut self, m: MaxFeatures) -> Self {
435 self.forest.params.max_features = m;
436 self
437 }
438
439 pub fn bootstrap(mut self, bootstrap: bool) -> Self {
441 self.forest.bootstrap = bootstrap;
442 self
443 }
444
445 pub fn max_samples(mut self, fraction: Option<f64>) -> Self {
447 self.forest.max_samples = fraction;
448 self
449 }
450
451 pub fn seed(mut self, seed: u64) -> Self {
453 self.forest.seed = seed;
454 self
455 }
456
457 pub fn feature_importances(&self) -> Option<&Array1<f64>> {
459 self.forest.importances.as_ref()
460 }
461 };
462}
463
464#[derive(Debug, Clone)]
479pub struct RandomForestRegressor {
480 forest: Forest,
481}
482
483impl Default for RandomForestRegressor {
484 fn default() -> Self {
485 Self::new()
486 }
487}
488
489impl RandomForestRegressor {
490 pub fn new() -> Self {
491 Self {
492 forest: Forest::new(MaxFeatures::All),
493 }
494 }
495
496 forest_builders!();
497}
498
499impl Regressor for RandomForestRegressor {
500 fn fit(&mut self, x: ArrayView2<'_, f64>, y: ArrayView1<'_, f64>) -> Result<()> {
501 self.forest.fit(x, y, None)
502 }
503
504 fn fit_weighted(
505 &mut self,
506 x: ArrayView2<'_, f64>,
507 y: ArrayView1<'_, f64>,
508 w: ArrayView1<'_, f64>,
509 ) -> Result<()> {
510 self.forest.fit(x, y, Some(w))
511 }
512
513 fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array1<f64>> {
514 self.forest.predict(x)
515 }
516
517 fn fresh(&self) -> Box<dyn Regressor> {
518 Box::new(Self {
519 forest: self.forest.unfitted(),
520 })
521 }
522}
523
524#[derive(Debug, Clone)]
530pub struct RandomForestClassifier {
531 forest: Forest,
532}
533
534impl Default for RandomForestClassifier {
535 fn default() -> Self {
536 Self::new()
537 }
538}
539
540impl RandomForestClassifier {
541 pub fn new() -> Self {
542 Self {
543 forest: Forest::new(MaxFeatures::Sqrt),
544 }
545 }
546
547 forest_builders!();
548}
549
550impl Classifier for RandomForestClassifier {
551 fn fit(&mut self, x: ArrayView2<'_, f64>, y: ArrayView1<'_, f64>) -> Result<()> {
552 if !y.iter().all(|&v| v == 0.0 || v == 1.0) {
553 return Err(DmlError::InvalidArgument(
554 "RandomForestClassifier labels must be 0 or 1".into(),
555 ));
556 }
557 self.forest.fit(x, y, None)
558 }
559
560 fn predict_proba(&self, x: ArrayView2<'_, f64>) -> Result<Array1<f64>> {
561 Ok(self.forest.predict(x)?.mapv(|p| p.clamp(0.0, 1.0)))
563 }
564
565 fn fresh(&self) -> Box<dyn Classifier> {
566 Box::new(Self {
567 forest: self.forest.unfitted(),
568 })
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use ndarray::{array, Array2};
576
577 fn nonlinear(n: usize, seed: u64) -> (Array2<f64>, Array1<f64>) {
578 let mut rng = ChaCha8Rng::seed_from_u64(seed);
579 let x = Array2::from_shape_simple_fn((n, 3), || rng.gen::<f64>() * 4.0 - 2.0);
580 let y = x
581 .rows()
582 .into_iter()
583 .map(|r| r[0].sin() * 2.0 + (r[1] > 0.0) as u8 as f64)
584 .collect();
585 (x, y)
586 }
587
588 #[test]
589 fn single_tree_without_bootstrap_fits_a_step_exactly() {
590 let x = array![[0.0], [1.0], [2.0], [3.0], [4.0], [5.0]];
591 let y = array![1.0, 1.0, 1.0, 7.0, 7.0, 7.0];
592 let mut rf = RandomForestRegressor::new()
593 .n_estimators(1)
594 .bootstrap(false);
595 rf.fit(x.view(), y.view()).unwrap();
596 let pred = rf
597 .predict(array![[2.4], [2.6], [-10.0], [10.0]].view())
598 .unwrap();
599 assert_eq!(pred, array![1.0, 7.0, 1.0, 7.0]);
600 assert!(
602 matches!(rf.forest.trees[0].nodes[0], Node::Split { threshold, .. } if threshold == 2.5)
603 );
604 }
605
606 #[test]
607 fn forest_learns_a_nonlinear_function() {
608 let (x, y) = nonlinear(2000, 1);
609 let (x_test, y_test) = nonlinear(500, 2);
610 let mut rf = RandomForestRegressor::new()
611 .n_estimators(100)
612 .min_samples_leaf(3)
613 .seed(7);
614 rf.fit(x.view(), y.view()).unwrap();
615 let resid = &y_test - &rf.predict(x_test.view()).unwrap();
616 let r2 = 1.0
617 - resid.mapv(|e| e * e).sum()
618 / y_test.mapv(|v| (v - y_test.mean().unwrap()).powi(2)).sum();
619 assert!(r2 > 0.9, "out-of-sample R² = {r2}");
620 let imp = rf.feature_importances().unwrap();
622 assert!((imp.sum() - 1.0).abs() < 1e-9);
623 assert!(imp[2] < imp[0] && imp[2] < imp[1], "{imp}");
624 }
625
626 #[test]
627 fn same_seed_same_forest_and_seeds_matter() {
628 let (x, y) = nonlinear(300, 3);
629 let fit = |seed| {
630 let mut rf = RandomForestRegressor::new()
631 .n_estimators(20)
632 .max_features(MaxFeatures::Count(2))
633 .seed(seed);
634 rf.fit(x.view(), y.view()).unwrap();
635 rf.predict(x.view()).unwrap()
636 };
637 assert_eq!(fit(1), fit(1));
638 assert_ne!(fit(1), fit(2));
639 }
640
641 #[test]
642 fn weights_equal_row_duplication_without_bootstrap() {
643 let x = array![[0.0], [1.0], [2.0], [3.0]];
644 let y = array![0.0, 1.0, 5.0, 6.0];
645 let mut weighted = RandomForestRegressor::new()
646 .n_estimators(1)
647 .bootstrap(false)
648 .max_depth(Some(1));
649 weighted
650 .fit_weighted(x.view(), y.view(), array![3.0, 1.0, 1.0, 1.0].view())
651 .unwrap();
652 let mut dup = RandomForestRegressor::new()
653 .n_estimators(1)
654 .bootstrap(false)
655 .max_depth(Some(1));
656 let xd = array![[0.0], [0.0], [0.0], [1.0], [2.0], [3.0]];
657 let yd = array![0.0, 0.0, 0.0, 1.0, 5.0, 6.0];
658 dup.fit(xd.view(), yd.view()).unwrap();
659 let grid = array![[0.0], [1.5], [3.0]];
660 let diff = weighted.predict(grid.view()).unwrap() - dup.predict(grid.view()).unwrap();
661 assert!(diff.iter().all(|d| d.abs() < 1e-12));
662 }
663
664 #[test]
665 fn min_samples_leaf_and_depth_are_respected() {
666 let (x, y) = nonlinear(200, 4);
667 let mut stump = RandomForestRegressor::new()
668 .n_estimators(3)
669 .max_depth(Some(1));
670 stump.fit(x.view(), y.view()).unwrap();
671 assert!(stump.forest.trees.iter().all(|t| t.nodes.len() <= 3));
672 let mut big_leaves = RandomForestRegressor::new()
673 .n_estimators(1)
674 .bootstrap(false)
675 .min_samples_leaf(50);
676 big_leaves.fit(x.view(), y.view()).unwrap();
677 assert!(big_leaves.forest.trees[0].nodes.len() <= 7); }
679
680 #[test]
681 fn classifier_probabilities_track_the_truth() {
682 let mut rng = ChaCha8Rng::seed_from_u64(5);
683 let n = 3000;
684 let x = Array2::from_shape_simple_fn((n, 2), || rng.gen::<f64>() * 4.0 - 2.0);
685 let truth: Array1<f64> = x
686 .rows()
687 .into_iter()
688 .map(|r| 1.0 / (1.0 + (-2.0 * r[0]).exp()))
689 .collect();
690 let labels = truth.mapv(|p| f64::from(rng.gen::<f64>() < p));
691 let mut rf = RandomForestClassifier::new().min_samples_leaf(20).seed(1);
692 rf.fit(x.view(), labels.view()).unwrap();
693 let p = rf.predict_proba(x.view()).unwrap();
694 assert!(p.iter().all(|&v| (0.0..=1.0).contains(&v)));
695 let mae = (&p - &truth).mapv(f64::abs).mean().unwrap();
696 assert!(mae < 0.08, "mean |p̂ − p| = {mae}");
697 assert!(rf.fit(x.view(), (&labels * 2.0).view()).is_err());
698 }
699
700 #[test]
701 fn invalid_settings_and_unfitted_use_error() {
702 let x = array![[0.0], [1.0]];
703 let y = array![0.0, 1.0];
704 assert!(RandomForestRegressor::new()
705 .n_estimators(0)
706 .fit(x.view(), y.view())
707 .is_err());
708 assert!(RandomForestRegressor::new()
709 .max_features(MaxFeatures::Fraction(1.5))
710 .fit(x.view(), y.view())
711 .is_err());
712 assert_eq!(
713 RandomForestRegressor::new().predict(x.view()).unwrap_err(),
714 DmlError::NotFitted
715 );
716 assert_eq!(MaxFeatures::Sqrt.resolve(10), 3);
717 assert_eq!(MaxFeatures::Log2.resolve(1), 1);
718 assert_eq!(MaxFeatures::Count(50).resolve(4), 4);
719 }
720}