Skip to main content

dml_core/learner/
forest.rs

1//! Random forests (Breiman, 2001) built from CART regression trees.
2//!
3//! Every tree is grown on a bootstrap sample, choosing each split among a
4//! random subset of features by the largest reduction in (weighted) squared
5//! error. Bootstrap draws are represented as integer sample weights, so
6//! user-supplied weights ([`Regressor::fit_weighted`]) come for free.
7//!
8//! For 0/1 labels the squared-error criterion selects exactly the same splits
9//! as the Gini criterion (node variance `p(1−p)` is half the Gini impurity),
10//! so [`RandomForestClassifier`] reuses the same trees and averages the leaf
11//! class-1 frequencies, as scikit-learn does.
12//!
13//! Trees are independent and each has its own seed derived from the forest
14//! seed, so fitting is parallel (with the `parallel` feature) yet results are
15//! identical whatever the number of threads.
16
17use 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/// Number of features considered at each split.
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub enum MaxFeatures {
28    /// All `p` features (scikit-learn's regressor default, `max_features=1.0`).
29    All,
30    /// `⌊√p⌋` (scikit-learn's classifier default).
31    Sqrt,
32    /// `⌊log₂ p⌋`
33    Log2,
34    /// `⌊f·p⌋` for `0 < f ≤ 1`.
35    Fraction(f64),
36    /// A fixed number of features.
37    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
102/// Grows one tree. `w` holds the effective weight of every row (user weight
103/// times bootstrap count; zero for rows not drawn) and `y` is centred.
104///
105/// Two lifetimes: `'x` for the caller's feature matrix and `'b` for the
106/// per-tree buffers, which live much shorter.
107struct 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    /// Total squared-error reduction per feature.
115    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        // Explicit stack instead of recursion: deep trees cannot overflow.
128        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    /// Best split among `n_try` random features: maximise the reduction in
169    /// weighted SSE, `S_L²/W_L + S_R²/W_R − S²/W`.
170    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; // constant in this node
182            }
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                    // Midpoint, unless rounding makes it equal the upper value.
200                    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/// The shared forest engine behind the regressor and the classifier.
215#[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        // Centre y so the split arithmetic does not lose precision to a large mean.
313        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        // Mean decrease in impurity, each tree normalised to sum to one.
367        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
405/// Builder methods shared by the regressor and the classifier.
406macro_rules! forest_builders {
407    () => {
408        /// Number of trees (default 100).
409        pub fn n_estimators(mut self, n: usize) -> Self {
410            self.forest.n_estimators = n;
411            self
412        }
413
414        /// Maximum tree depth (default: unlimited).
415        pub fn max_depth(mut self, depth: Option<usize>) -> Self {
416            self.forest.params.max_depth = depth;
417            self
418        }
419
420        /// Minimum number of rows required to split a node (default 2).
421        pub fn min_samples_split(mut self, n: usize) -> Self {
422            self.forest.params.min_samples_split = n;
423            self
424        }
425
426        /// Minimum number of distinct rows in each leaf (default 1). Values
427        /// around 5–20 give smoother nuisance estimates for DML.
428        pub fn min_samples_leaf(mut self, n: usize) -> Self {
429            self.forest.params.min_samples_leaf = n;
430            self
431        }
432
433        /// Features considered per split.
434        pub fn max_features(mut self, m: MaxFeatures) -> Self {
435            self.forest.params.max_features = m;
436            self
437        }
438
439        /// Grow each tree on a bootstrap sample (default `true`).
440        pub fn bootstrap(mut self, bootstrap: bool) -> Self {
441            self.forest.bootstrap = bootstrap;
442            self
443        }
444
445        /// Bootstrap sample size as a fraction of `n` (default 1.0).
446        pub fn max_samples(mut self, fraction: Option<f64>) -> Self {
447            self.forest.max_samples = fraction;
448            self
449        }
450
451        /// Seed for bootstrap draws and feature sampling (default 0).
452        pub fn seed(mut self, seed: u64) -> Self {
453            self.forest.seed = seed;
454            self
455        }
456
457        /// Mean decrease in impurity per feature (sums to one), after fitting.
458        pub fn feature_importances(&self) -> Option<&Array1<f64>> {
459            self.forest.importances.as_ref()
460        }
461    };
462}
463
464/// Random forest regressor (squared-error CART trees on bootstrap samples).
465///
466/// Defaults match scikit-learn's `RandomForestRegressor`: 100 trees, all
467/// features per split, unlimited depth, `min_samples_leaf = 1`.
468///
469/// ```
470/// use dml_core::{MaxFeatures, RandomForestRegressor};
471/// let rf = RandomForestRegressor::new()
472///     .n_estimators(200)
473///     .min_samples_leaf(5)
474///     .max_features(MaxFeatures::Fraction(0.5))
475///     .seed(42);
476/// # let _ = rf;
477/// ```
478#[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/// Random forest classifier for 0/1 labels; `predict_proba` averages the
525/// class-1 frequency of the leaves.
526///
527/// Defaults match scikit-learn's `RandomForestClassifier`: 100 trees,
528/// `√p` features per split, unlimited depth, `min_samples_leaf = 1`.
529#[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        // Leaf means of 0/1 labels are already in [0, 1]; clamp rounding noise.
562        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        // The only split is at the midpoint 2.5 between the two groups.
601        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        // x₂ is pure noise: it should get the least importance.
621        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); // at most 4 leaves of ≥ 50 rows
678    }
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}