Skip to main content

ferrolearn_tree/
random_forest.rs

1//! Random forest classifiers and regressors.
2//!
3//! This module provides [`RandomForestClassifier`] and [`RandomForestRegressor`],
4//! which build ensembles of decision trees using bootstrap sampling and random
5//! feature subsets (bagging). Trees are built in parallel via `rayon`.
6//!
7//! # Examples
8//!
9//! ```
10//! use ferrolearn_tree::RandomForestClassifier;
11//! use ferrolearn_core::{Fit, Predict};
12//! use ndarray::{array, Array1, Array2};
13//!
14//! let x = Array2::from_shape_vec((8, 2), vec![
15//!     1.0, 2.0,  2.0, 3.0,  3.0, 3.0,  4.0, 4.0,
16//!     5.0, 6.0,  6.0, 7.0,  7.0, 8.0,  8.0, 9.0,
17//! ]).unwrap();
18//! let y = array![0, 0, 0, 0, 1, 1, 1, 1];
19//!
20//! let model = RandomForestClassifier::<f64>::new()
21//!     .with_n_estimators(10)
22//!     .with_random_state(42);
23//! let fitted = model.fit(&x, &y).unwrap();
24//! let preds = fitted.predict(&x).unwrap();
25//! ```
26//!
27//! ## REQ status
28//!
29//! Binary (R-DEFER-2): SHIPPED = impl + non-test consumer + tests + green
30//! verification; NOT-STARTED = open blocker `#`. `RandomForestClassifier`/
31//! `RandomForestRegressor` are re-exported at the crate root + registered as PyO3
32//! bindings (non-test consumers). The forest is RNG-driven (bootstrap +
33//! per-tree `random_state`): exact node-for-node ensemble parity with sklearn at
34//! a given `random_state` is the DOCUMENTED numpy-RNG boundary (#673, same class
35//! as SGD shuffle / extra_tree / libsvm CV) — verification pins the DETERMINISTIC
36//! contract (param surface/defaults, the soft-vote/mean aggregation,
37//! `predict_proba` mean, ferrolearn-internal reproducibility). Pins in
38//! `tests/divergence_random_forest.rs`. See `.design/tree/random_forest.md`.
39//!
40//! | REQ | Status | Evidence |
41//! |---|---|---|
42//! | REQ-1 (param surface & defaults) | SHIPPED | `n_estimators=100`, clf `max_features=Sqrt`/`criterion=Gini`, reg `max_features=All`, `random_state=None` — match `_forest.py:1170`/`:1555` (live-verified). Pinned by `defaults_classifier_match_sklearn`/`defaults_regressor_match_sklearn`. Missing params (regressor `criterion`/`bootstrap`/`max_samples`/`oob_score`/`class_weight`/tree-param passthrough) tracked NOT-STARTED #671. |
43//! | REQ-2 (bootstrap / max_samples / bootstrap toggle) | NOT-STARTED | open prereq blocker #672. With-replacement draw exists but no `max_samples`/`bootstrap=False`; sampling RNG is the #673 boundary. |
44//! | REQ-3 (per-tree fit) | SHIPPED | each tree delegates to the oracle-verified `decision_tree.rs` build on a bootstrap sample with `max_features` subsampling. |
45//! | REQ-4 (classifier soft-vote predict) | SHIPPED | `FittedRandomForestClassifier::predict` returns `classes_[argmax(predict_proba)]` (SOFT vote, `_forest.py:904-907`), consistent with `predict_proba`. Pinned by `divergence_predict_is_soft_vote_argmax_of_proba` (predict == argmax(predict_proba) for all rows). |
46//! | REQ-5 (predict_proba mean / regressor mean) | SHIPPED | `predict_proba` = mean of per-tree probas (rows sum to 1), regressor `predict` = mean of tree predictions. Pinned by `predict_proba_rows_sum_to_one` + `regressor_predict_is_mean_constant_target`. |
47//! | REQ-6 (feature_importances_ mean-normalize) | SHIPPED | normalized mean of per-tree importances (`HasFeatureImportances`, consumed by ensemble + PyO3); all-stumps zero edge tracked #674. |
48//! | REQ-7 (oob_score_ / oob_decision_function_) | NOT-STARTED | open prereq blocker #675. No OOB scoring. |
49//! | REQ-8 (class_weight + balanced_subsample) | NOT-STARTED | open prereq blocker #676. |
50//! | REQ-9 (random_state determinism) | SHIPPED | `random_state` seeds the per-tree builds; same seed ⇒ identical forest (`random_state_reproducible`). Exact numpy-MT cross-impl parity is the DOCUMENTED RNG boundary #673. |
51//! | REQ-10 (ferray substrate) | NOT-STARTED | open prereq blocker #677. Imports `ndarray`/`rand::StdRng`, not `ferray-core`/`ferray::random` (R-SUBSTRATE). |
52
53use ferrolearn_core::error::FerroError;
54use ferrolearn_core::introspection::{HasClasses, HasFeatureImportances};
55use ferrolearn_core::pipeline::{FittedPipelineEstimator, PipelineEstimator};
56use ferrolearn_core::traits::{Fit, Predict};
57use ndarray::{Array1, Array2};
58use num_traits::{Float, FromPrimitive, ToPrimitive};
59use rand::SeedableRng;
60use rand::rngs::StdRng;
61use rayon::prelude::*;
62use serde::{Deserialize, Serialize};
63
64use crate::decision_tree::{
65    self, ClassificationCriterion, Node, build_classification_tree_per_split_features,
66    build_regression_tree_per_split_features, compute_feature_importances,
67};
68
69// ---------------------------------------------------------------------------
70// MaxFeatures
71// ---------------------------------------------------------------------------
72
73/// Strategy for selecting the number of features considered at each split.
74#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
75pub enum MaxFeatures {
76    /// Use the square root of the total number of features (default for classifiers).
77    Sqrt,
78    /// Use the log2 of the total number of features.
79    Log2,
80    /// Use all features (default for regressors).
81    All,
82    /// Use a specific number of features.
83    Fixed(usize),
84    /// Use a fraction of the total number of features.
85    Fraction(f64),
86}
87
88/// Resolve the `MaxFeatures` strategy to a concrete number.
89fn resolve_max_features(strategy: MaxFeatures, n_features: usize) -> usize {
90    let result = match strategy {
91        MaxFeatures::Sqrt => (n_features as f64).sqrt().ceil() as usize,
92        MaxFeatures::Log2 => (n_features as f64).log2().ceil().max(1.0) as usize,
93        MaxFeatures::All => n_features,
94        MaxFeatures::Fixed(n) => n.min(n_features),
95        MaxFeatures::Fraction(f) => ((n_features as f64) * f).ceil() as usize,
96    };
97    result.max(1).min(n_features)
98}
99
100/// Internal tree parameter struct reused from decision_tree.
101///
102/// Re-created here to avoid leaking internal details; the crate-internal
103/// struct is the same shape.
104fn make_tree_params(
105    max_depth: Option<usize>,
106    min_samples_split: usize,
107    min_samples_leaf: usize,
108) -> decision_tree::TreeParams {
109    decision_tree::TreeParams {
110        max_depth,
111        min_samples_split,
112        min_samples_leaf,
113    }
114}
115
116// ---------------------------------------------------------------------------
117// RandomForestClassifier
118// ---------------------------------------------------------------------------
119
120/// Random forest classifier.
121///
122/// Builds an ensemble of decision tree classifiers, each trained on a
123/// bootstrap sample with a random subset of features considered at each split.
124/// Final predictions are made by majority vote.
125///
126/// # Type Parameters
127///
128/// - `F`: The floating-point type (`f32` or `f64`).
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct RandomForestClassifier<F> {
131    /// Number of trees in the forest.
132    pub n_estimators: usize,
133    /// Maximum depth of each tree. `None` means unlimited.
134    pub max_depth: Option<usize>,
135    /// Strategy for the number of features considered at each split.
136    pub max_features: MaxFeatures,
137    /// Minimum number of samples required to split an internal node.
138    pub min_samples_split: usize,
139    /// Minimum number of samples required in a leaf node.
140    pub min_samples_leaf: usize,
141    /// Random seed for reproducibility. `None` means non-deterministic.
142    pub random_state: Option<u64>,
143    /// Splitting criterion.
144    pub criterion: ClassificationCriterion,
145    _marker: std::marker::PhantomData<F>,
146}
147
148impl<F: Float> RandomForestClassifier<F> {
149    /// Create a new `RandomForestClassifier` with default settings.
150    ///
151    /// Defaults: `n_estimators = 100`, `max_depth = None`,
152    /// `max_features = Sqrt`, `min_samples_split = 2`,
153    /// `min_samples_leaf = 1`, `random_state = None`,
154    /// `criterion = Gini`.
155    #[must_use]
156    pub fn new() -> Self {
157        Self {
158            n_estimators: 100,
159            max_depth: None,
160            max_features: MaxFeatures::Sqrt,
161            min_samples_split: 2,
162            min_samples_leaf: 1,
163            random_state: None,
164            criterion: ClassificationCriterion::Gini,
165            _marker: std::marker::PhantomData,
166        }
167    }
168
169    /// Set the number of trees.
170    #[must_use]
171    pub fn with_n_estimators(mut self, n_estimators: usize) -> Self {
172        self.n_estimators = n_estimators;
173        self
174    }
175
176    /// Set the maximum tree depth.
177    #[must_use]
178    pub fn with_max_depth(mut self, max_depth: Option<usize>) -> Self {
179        self.max_depth = max_depth;
180        self
181    }
182
183    /// Set the maximum features strategy.
184    #[must_use]
185    pub fn with_max_features(mut self, max_features: MaxFeatures) -> Self {
186        self.max_features = max_features;
187        self
188    }
189
190    /// Set the minimum number of samples to split a node.
191    #[must_use]
192    pub fn with_min_samples_split(mut self, min_samples_split: usize) -> Self {
193        self.min_samples_split = min_samples_split;
194        self
195    }
196
197    /// Set the minimum number of samples in a leaf.
198    #[must_use]
199    pub fn with_min_samples_leaf(mut self, min_samples_leaf: usize) -> Self {
200        self.min_samples_leaf = min_samples_leaf;
201        self
202    }
203
204    /// Set the random seed for reproducibility.
205    #[must_use]
206    pub fn with_random_state(mut self, seed: u64) -> Self {
207        self.random_state = Some(seed);
208        self
209    }
210
211    /// Set the splitting criterion.
212    #[must_use]
213    pub fn with_criterion(mut self, criterion: ClassificationCriterion) -> Self {
214        self.criterion = criterion;
215        self
216    }
217}
218
219impl<F: Float> Default for RandomForestClassifier<F> {
220    fn default() -> Self {
221        Self::new()
222    }
223}
224
225// ---------------------------------------------------------------------------
226// FittedRandomForestClassifier
227// ---------------------------------------------------------------------------
228
229/// A fitted random forest classifier.
230///
231/// Stores the ensemble of fitted decision trees and aggregates their
232/// predictions by majority vote.
233#[derive(Debug, Clone)]
234pub struct FittedRandomForestClassifier<F> {
235    /// Individual tree node vectors.
236    trees: Vec<Vec<Node<F>>>,
237    /// Sorted unique class labels.
238    classes: Vec<usize>,
239    /// Number of features.
240    n_features: usize,
241    /// Per-feature importance scores (mean decrease in impurity, normalised).
242    feature_importances: Array1<F>,
243}
244
245impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, Array1<usize>> for RandomForestClassifier<F> {
246    type Fitted = FittedRandomForestClassifier<F>;
247    type Error = FerroError;
248
249    /// Fit the random forest by building `n_estimators` decision trees in parallel.
250    ///
251    /// Each tree is trained on a bootstrap sample of the data, considering only
252    /// a random subset of features at each split.
253    ///
254    /// # Errors
255    ///
256    /// Returns [`FerroError::ShapeMismatch`] if `x` and `y` have different
257    /// numbers of samples.
258    /// Returns [`FerroError::InsufficientSamples`] if there are no samples.
259    /// Returns [`FerroError::InvalidParameter`] if `n_estimators` is 0.
260    fn fit(
261        &self,
262        x: &Array2<F>,
263        y: &Array1<usize>,
264    ) -> Result<FittedRandomForestClassifier<F>, FerroError> {
265        let (n_samples, n_features) = x.dim();
266
267        if n_samples != y.len() {
268            return Err(FerroError::ShapeMismatch {
269                expected: vec![n_samples],
270                actual: vec![y.len()],
271                context: "y length must match number of samples in X".into(),
272            });
273        }
274        if n_samples == 0 {
275            return Err(FerroError::InsufficientSamples {
276                required: 1,
277                actual: 0,
278                context: "RandomForestClassifier requires at least one sample".into(),
279            });
280        }
281        if self.n_estimators == 0 {
282            return Err(FerroError::InvalidParameter {
283                name: "n_estimators".into(),
284                reason: "must be at least 1".into(),
285            });
286        }
287
288        // Determine unique classes.
289        let mut classes: Vec<usize> = y.iter().copied().collect();
290        classes.sort_unstable();
291        classes.dedup();
292        let n_classes = classes.len();
293
294        let y_mapped: Vec<usize> = y
295            .iter()
296            .map(|&c| classes.iter().position(|&cl| cl == c).unwrap())
297            .collect();
298
299        let max_features_n = resolve_max_features(self.max_features, n_features);
300        let params = make_tree_params(
301            self.max_depth,
302            self.min_samples_split,
303            self.min_samples_leaf,
304        );
305        let criterion = self.criterion;
306
307        // Generate per-tree seeds sequentially for determinism, then dispatch in parallel.
308        let tree_seeds: Vec<u64> = if let Some(seed) = self.random_state {
309            let mut master_rng = StdRng::seed_from_u64(seed);
310            (0..self.n_estimators)
311                .map(|_| {
312                    use rand::RngCore;
313                    master_rng.next_u64()
314                })
315                .collect()
316        } else {
317            (0..self.n_estimators)
318                .map(|_| {
319                    use rand::RngCore;
320                    rand::rng().next_u64()
321                })
322                .collect()
323        };
324
325        // Build trees in parallel.
326        //
327        // Each tree gets:
328        //   - a bootstrap sample of rows (Breiman bagging),
329        //   - per-split random feature sampling of size `max_features_n`
330        //     drawn afresh at every split node (Breiman 2001 RF, sklearn
331        //     parity). The previous implementation pre-sampled a single
332        //     `max_features_n` feature subset per tree which severely
333        //     limited each tree's capacity at large p.
334        let trees: Vec<Vec<Node<F>>> = tree_seeds
335            .par_iter()
336            .map(|&seed| {
337                let mut bootstrap_rng = StdRng::seed_from_u64(seed);
338
339                let bootstrap_indices: Vec<usize> = (0..n_samples)
340                    .map(|_| {
341                        use rand::RngCore;
342                        (bootstrap_rng.next_u64() as usize) % n_samples
343                    })
344                    .collect();
345
346                // Use a separate, derived seed for the per-split feature
347                // RNG so that bootstrap sampling and feature sampling are
348                // statistically independent.
349                use rand::RngCore;
350                let split_seed = bootstrap_rng.next_u64();
351
352                build_classification_tree_per_split_features(
353                    x,
354                    &y_mapped,
355                    n_classes,
356                    &bootstrap_indices,
357                    max_features_n,
358                    &params,
359                    criterion,
360                    split_seed,
361                )
362            })
363            .collect();
364
365        // Aggregate feature importances across trees.
366        let mut total_importances = Array1::<F>::zeros(n_features);
367        for tree_nodes in &trees {
368            let tree_imp = compute_feature_importances(tree_nodes, n_features, n_samples);
369            total_importances = total_importances + tree_imp;
370        }
371        let imp_sum: F = total_importances
372            .iter()
373            .copied()
374            .fold(F::zero(), |a, b| a + b);
375        if imp_sum > F::zero() {
376            total_importances.mapv_inplace(|v| v / imp_sum);
377        }
378
379        Ok(FittedRandomForestClassifier {
380            trees,
381            classes,
382            n_features,
383            feature_importances: total_importances,
384        })
385    }
386}
387
388impl<F: Float + Send + Sync + 'static> FittedRandomForestClassifier<F> {
389    /// Returns a reference to the individual tree node vectors.
390    #[must_use]
391    pub fn trees(&self) -> &[Vec<Node<F>>] {
392        &self.trees
393    }
394
395    /// Returns the number of features the model was trained on.
396    #[must_use]
397    pub fn n_features(&self) -> usize {
398        self.n_features
399    }
400
401    /// Mean accuracy on the given test data and labels.
402    /// Equivalent to sklearn's `ClassifierMixin.score`.
403    ///
404    /// # Errors
405    ///
406    /// Returns [`FerroError::ShapeMismatch`] if `x.nrows() != y.len()` or
407    /// the feature count does not match the training data.
408    pub fn score(&self, x: &Array2<F>, y: &Array1<usize>) -> Result<F, FerroError> {
409        if x.nrows() != y.len() {
410            return Err(FerroError::ShapeMismatch {
411                expected: vec![x.nrows()],
412                actual: vec![y.len()],
413                context: "y length must match number of samples in X".into(),
414            });
415        }
416        let preds = self.predict(x)?;
417        Ok(crate::mean_accuracy(&preds, y))
418    }
419
420    /// Predict class probabilities for each sample by averaging per-tree
421    /// class distributions across the forest. Equivalent to sklearn's
422    /// `RandomForestClassifier.predict_proba`.
423    ///
424    /// Returns an `(n_samples, n_classes)` array. Each row sums to 1.
425    /// When a leaf does not carry a class distribution, it contributes a
426    /// one-hot vote at the leaf's predicted class.
427    ///
428    /// # Errors
429    ///
430    /// Returns [`FerroError::ShapeMismatch`] if the number of features
431    /// does not match the training data.
432    pub fn predict_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
433        if x.ncols() != self.n_features {
434            return Err(FerroError::ShapeMismatch {
435                expected: vec![self.n_features],
436                actual: vec![x.ncols()],
437                context: "number of features must match fitted model".into(),
438            });
439        }
440        let n_samples = x.nrows();
441        let n_classes = self.classes.len();
442        let n_trees_f = F::from(self.trees.len()).unwrap();
443        let mut proba = Array2::<F>::zeros((n_samples, n_classes));
444
445        for i in 0..n_samples {
446            let row = x.row(i);
447            for tree_nodes in &self.trees {
448                let leaf_idx = decision_tree::traverse(tree_nodes, &row);
449                match &tree_nodes[leaf_idx] {
450                    Node::Leaf {
451                        class_distribution: Some(dist),
452                        ..
453                    } => {
454                        for (j, &p) in dist.iter().enumerate().take(n_classes) {
455                            proba[[i, j]] = proba[[i, j]] + p;
456                        }
457                    }
458                    Node::Leaf { value, .. } => {
459                        let class_idx = value.to_f64().map_or(0, |f| f.round() as usize);
460                        if class_idx < n_classes {
461                            proba[[i, class_idx]] = proba[[i, class_idx]] + F::one();
462                        }
463                    }
464                    _ => {}
465                }
466            }
467            for j in 0..n_classes {
468                proba[[i, j]] = proba[[i, j]] / n_trees_f;
469            }
470        }
471        Ok(proba)
472    }
473
474    /// Element-wise log of [`predict_proba`](Self::predict_proba). Mirrors
475    /// sklearn's `ClassifierMixin.predict_log_proba`.
476    ///
477    /// # Errors
478    ///
479    /// Forwards any error from [`predict_proba`](Self::predict_proba).
480    pub fn predict_log_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
481        let proba = self.predict_proba(x)?;
482        Ok(crate::log_proba(&proba))
483    }
484}
485
486impl<F: Float + Send + Sync + 'static> Predict<Array2<F>> for FittedRandomForestClassifier<F> {
487    type Output = Array1<usize>;
488    type Error = FerroError;
489
490    /// Predict class labels by SOFT voting across all trees.
491    ///
492    /// Mirrors `ForestClassifier.predict`
493    /// (`sklearn/ensemble/_forest.py:904-907`):
494    /// `self.classes_.take(np.argmax(proba, axis=1), axis=0)` — the argmax of
495    /// the mean of per-tree `predict_proba` (NOT a hard per-tree-label
496    /// majority). Routes through [`predict_proba`](Self::predict_proba) so the
497    /// two are consistent. Ties resolve to the lowest class index, matching
498    /// `np.argmax`.
499    ///
500    /// # Errors
501    ///
502    /// Returns [`FerroError::ShapeMismatch`] if the number of features does
503    /// not match the fitted model.
504    fn predict(&self, x: &Array2<F>) -> Result<Array1<usize>, FerroError> {
505        let proba = self.predict_proba(x)?;
506        let n_samples = proba.nrows();
507        let n_classes = proba.ncols();
508        let mut predictions = Array1::zeros(n_samples);
509
510        for i in 0..n_samples {
511            // np.argmax tie-break: first (lowest) index of the maximum.
512            let mut best = 0usize;
513            let mut best_v = proba[[i, 0]];
514            for j in 1..n_classes {
515                let v = proba[[i, j]];
516                if v > best_v {
517                    best_v = v;
518                    best = j;
519                }
520            }
521            predictions[i] = self.classes[best];
522        }
523
524        Ok(predictions)
525    }
526}
527
528impl<F: Float + Send + Sync + 'static> HasFeatureImportances<F>
529    for FittedRandomForestClassifier<F>
530{
531    fn feature_importances(&self) -> &Array1<F> {
532        &self.feature_importances
533    }
534}
535
536impl<F: Float + Send + Sync + 'static> HasClasses for FittedRandomForestClassifier<F> {
537    fn classes(&self) -> &[usize] {
538        &self.classes
539    }
540
541    fn n_classes(&self) -> usize {
542        self.classes.len()
543    }
544}
545
546// Pipeline integration.
547impl<F: Float + ToPrimitive + FromPrimitive + Send + Sync + 'static> PipelineEstimator<F>
548    for RandomForestClassifier<F>
549{
550    fn fit_pipeline(
551        &self,
552        x: &Array2<F>,
553        y: &Array1<F>,
554    ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
555        let y_usize: Array1<usize> = y.mapv(|v| v.to_usize().unwrap_or(0));
556        let fitted = self.fit(x, &y_usize)?;
557        Ok(Box::new(FittedForestClassifierPipelineAdapter(fitted)))
558    }
559}
560
561/// Pipeline adapter for `FittedRandomForestClassifier<F>`.
562struct FittedForestClassifierPipelineAdapter<F: Float + Send + Sync + 'static>(
563    FittedRandomForestClassifier<F>,
564);
565
566impl<F: Float + ToPrimitive + FromPrimitive + Send + Sync + 'static> FittedPipelineEstimator<F>
567    for FittedForestClassifierPipelineAdapter<F>
568{
569    fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
570        let preds = self.0.predict(x)?;
571        Ok(preds.mapv(|v| F::from_usize(v).unwrap_or_else(F::nan)))
572    }
573}
574
575// ---------------------------------------------------------------------------
576// RandomForestRegressor
577// ---------------------------------------------------------------------------
578
579/// Random forest regressor.
580///
581/// Builds an ensemble of decision tree regressors, each trained on a
582/// bootstrap sample with a random subset of features considered at each split.
583/// Final predictions are the mean across all trees.
584///
585/// # Type Parameters
586///
587/// - `F`: The floating-point type (`f32` or `f64`).
588#[derive(Debug, Clone, Serialize, Deserialize)]
589pub struct RandomForestRegressor<F> {
590    /// Number of trees in the forest.
591    pub n_estimators: usize,
592    /// Maximum depth of each tree. `None` means unlimited.
593    pub max_depth: Option<usize>,
594    /// Strategy for the number of features considered at each split.
595    pub max_features: MaxFeatures,
596    /// Minimum number of samples required to split an internal node.
597    pub min_samples_split: usize,
598    /// Minimum number of samples required in a leaf node.
599    pub min_samples_leaf: usize,
600    /// Random seed for reproducibility. `None` means non-deterministic.
601    pub random_state: Option<u64>,
602    _marker: std::marker::PhantomData<F>,
603}
604
605impl<F: Float> RandomForestRegressor<F> {
606    /// Create a new `RandomForestRegressor` with default settings.
607    ///
608    /// Defaults: `n_estimators = 100`, `max_depth = None`,
609    /// `max_features = All`, `min_samples_split = 2`,
610    /// `min_samples_leaf = 1`, `random_state = None`.
611    #[must_use]
612    pub fn new() -> Self {
613        Self {
614            n_estimators: 100,
615            max_depth: None,
616            max_features: MaxFeatures::All,
617            min_samples_split: 2,
618            min_samples_leaf: 1,
619            random_state: None,
620            _marker: std::marker::PhantomData,
621        }
622    }
623
624    /// Set the number of trees.
625    #[must_use]
626    pub fn with_n_estimators(mut self, n_estimators: usize) -> Self {
627        self.n_estimators = n_estimators;
628        self
629    }
630
631    /// Set the maximum tree depth.
632    #[must_use]
633    pub fn with_max_depth(mut self, max_depth: Option<usize>) -> Self {
634        self.max_depth = max_depth;
635        self
636    }
637
638    /// Set the maximum features strategy.
639    #[must_use]
640    pub fn with_max_features(mut self, max_features: MaxFeatures) -> Self {
641        self.max_features = max_features;
642        self
643    }
644
645    /// Set the minimum number of samples to split a node.
646    #[must_use]
647    pub fn with_min_samples_split(mut self, min_samples_split: usize) -> Self {
648        self.min_samples_split = min_samples_split;
649        self
650    }
651
652    /// Set the minimum number of samples in a leaf.
653    #[must_use]
654    pub fn with_min_samples_leaf(mut self, min_samples_leaf: usize) -> Self {
655        self.min_samples_leaf = min_samples_leaf;
656        self
657    }
658
659    /// Set the random seed for reproducibility.
660    #[must_use]
661    pub fn with_random_state(mut self, seed: u64) -> Self {
662        self.random_state = Some(seed);
663        self
664    }
665}
666
667impl<F: Float> Default for RandomForestRegressor<F> {
668    fn default() -> Self {
669        Self::new()
670    }
671}
672
673// ---------------------------------------------------------------------------
674// FittedRandomForestRegressor
675// ---------------------------------------------------------------------------
676
677/// A fitted random forest regressor.
678///
679/// Stores the ensemble of fitted decision trees and aggregates their
680/// predictions by averaging.
681#[derive(Debug, Clone)]
682pub struct FittedRandomForestRegressor<F> {
683    /// Individual tree node vectors.
684    trees: Vec<Vec<Node<F>>>,
685    /// Number of features.
686    n_features: usize,
687    /// Per-feature importance scores (mean decrease in impurity, normalised).
688    feature_importances: Array1<F>,
689}
690
691impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, Array1<F>> for RandomForestRegressor<F> {
692    type Fitted = FittedRandomForestRegressor<F>;
693    type Error = FerroError;
694
695    /// Fit the random forest regressor.
696    ///
697    /// # Errors
698    ///
699    /// Returns [`FerroError::ShapeMismatch`] if `x` and `y` have different
700    /// numbers of samples.
701    /// Returns [`FerroError::InsufficientSamples`] if there are no samples.
702    /// Returns [`FerroError::InvalidParameter`] if `n_estimators` is 0.
703    fn fit(
704        &self,
705        x: &Array2<F>,
706        y: &Array1<F>,
707    ) -> Result<FittedRandomForestRegressor<F>, FerroError> {
708        let (n_samples, n_features) = x.dim();
709
710        if n_samples != y.len() {
711            return Err(FerroError::ShapeMismatch {
712                expected: vec![n_samples],
713                actual: vec![y.len()],
714                context: "y length must match number of samples in X".into(),
715            });
716        }
717        if n_samples == 0 {
718            return Err(FerroError::InsufficientSamples {
719                required: 1,
720                actual: 0,
721                context: "RandomForestRegressor requires at least one sample".into(),
722            });
723        }
724        if self.n_estimators == 0 {
725            return Err(FerroError::InvalidParameter {
726                name: "n_estimators".into(),
727                reason: "must be at least 1".into(),
728            });
729        }
730
731        let max_features_n = resolve_max_features(self.max_features, n_features);
732        let params = make_tree_params(
733            self.max_depth,
734            self.min_samples_split,
735            self.min_samples_leaf,
736        );
737
738        // Generate per-tree seeds sequentially.
739        let tree_seeds: Vec<u64> = if let Some(seed) = self.random_state {
740            let mut master_rng = StdRng::seed_from_u64(seed);
741            (0..self.n_estimators)
742                .map(|_| {
743                    use rand::RngCore;
744                    master_rng.next_u64()
745                })
746                .collect()
747        } else {
748            (0..self.n_estimators)
749                .map(|_| {
750                    use rand::RngCore;
751                    rand::rng().next_u64()
752                })
753                .collect()
754        };
755
756        // Build trees in parallel — per-split feature sampling (Breiman RF,
757        // sklearn parity); see RandomForestClassifier::fit for full notes.
758        let trees: Vec<Vec<Node<F>>> = tree_seeds
759            .par_iter()
760            .map(|&seed| {
761                let mut bootstrap_rng = StdRng::seed_from_u64(seed);
762
763                let bootstrap_indices: Vec<usize> = (0..n_samples)
764                    .map(|_| {
765                        use rand::RngCore;
766                        (bootstrap_rng.next_u64() as usize) % n_samples
767                    })
768                    .collect();
769
770                use rand::RngCore;
771                let split_seed = bootstrap_rng.next_u64();
772
773                build_regression_tree_per_split_features(
774                    x,
775                    y,
776                    &bootstrap_indices,
777                    max_features_n,
778                    &params,
779                    split_seed,
780                )
781            })
782            .collect();
783
784        // Aggregate feature importances.
785        let mut total_importances = Array1::<F>::zeros(n_features);
786        for tree_nodes in &trees {
787            let tree_imp = compute_feature_importances(tree_nodes, n_features, n_samples);
788            total_importances = total_importances + tree_imp;
789        }
790        let imp_sum: F = total_importances
791            .iter()
792            .copied()
793            .fold(F::zero(), |a, b| a + b);
794        if imp_sum > F::zero() {
795            total_importances.mapv_inplace(|v| v / imp_sum);
796        }
797
798        Ok(FittedRandomForestRegressor {
799            trees,
800            n_features,
801            feature_importances: total_importances,
802        })
803    }
804}
805
806impl<F: Float + Send + Sync + 'static> FittedRandomForestRegressor<F> {
807    /// Returns a reference to the individual tree node vectors.
808    #[must_use]
809    pub fn trees(&self) -> &[Vec<Node<F>>] {
810        &self.trees
811    }
812
813    /// Returns the number of features the model was trained on.
814    #[must_use]
815    pub fn n_features(&self) -> usize {
816        self.n_features
817    }
818
819    /// R² coefficient of determination on the given test data.
820    /// Equivalent to sklearn's `RegressorMixin.score`.
821    ///
822    /// # Errors
823    ///
824    /// Returns [`FerroError::ShapeMismatch`] if `x.nrows() != y.len()` or
825    /// the feature count does not match the training data.
826    pub fn score(&self, x: &Array2<F>, y: &Array1<F>) -> Result<F, FerroError> {
827        if x.nrows() != y.len() {
828            return Err(FerroError::ShapeMismatch {
829                expected: vec![x.nrows()],
830                actual: vec![y.len()],
831                context: "y length must match number of samples in X".into(),
832            });
833        }
834        let preds = self.predict(x)?;
835        Ok(crate::r2_score(&preds, y))
836    }
837}
838
839impl<F: Float + Send + Sync + 'static> Predict<Array2<F>> for FittedRandomForestRegressor<F> {
840    type Output = Array1<F>;
841    type Error = FerroError;
842
843    /// Predict target values by averaging across all trees.
844    ///
845    /// # Errors
846    ///
847    /// Returns [`FerroError::ShapeMismatch`] if the number of features does
848    /// not match the fitted model.
849    fn predict(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
850        if x.ncols() != self.n_features {
851            return Err(FerroError::ShapeMismatch {
852                expected: vec![self.n_features],
853                actual: vec![x.ncols()],
854                context: "number of features must match fitted model".into(),
855            });
856        }
857
858        let n_samples = x.nrows();
859        let n_trees_f = F::from(self.trees.len()).unwrap();
860        let mut predictions = Array1::zeros(n_samples);
861
862        for i in 0..n_samples {
863            let row = x.row(i);
864            let mut sum = F::zero();
865
866            for tree_nodes in &self.trees {
867                let leaf_idx = decision_tree::traverse(tree_nodes, &row);
868                if let Node::Leaf { value, .. } = tree_nodes[leaf_idx] {
869                    sum = sum + value;
870                }
871            }
872
873            predictions[i] = sum / n_trees_f;
874        }
875
876        Ok(predictions)
877    }
878}
879
880impl<F: Float + Send + Sync + 'static> HasFeatureImportances<F> for FittedRandomForestRegressor<F> {
881    fn feature_importances(&self) -> &Array1<F> {
882        &self.feature_importances
883    }
884}
885
886// Pipeline integration.
887impl<F: Float + Send + Sync + 'static> PipelineEstimator<F> for RandomForestRegressor<F> {
888    fn fit_pipeline(
889        &self,
890        x: &Array2<F>,
891        y: &Array1<F>,
892    ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
893        let fitted = self.fit(x, y)?;
894        Ok(Box::new(fitted))
895    }
896}
897
898impl<F: Float + Send + Sync + 'static> FittedPipelineEstimator<F>
899    for FittedRandomForestRegressor<F>
900{
901    fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
902        self.predict(x)
903    }
904}
905
906// ---------------------------------------------------------------------------
907// Tests
908// ---------------------------------------------------------------------------
909
910#[cfg(test)]
911mod tests {
912    use super::*;
913    use approx::assert_relative_eq;
914    use ndarray::array;
915
916    // -- Classifier tests --
917
918    #[test]
919    fn test_forest_classifier_simple() {
920        let x = Array2::from_shape_vec(
921            (8, 2),
922            vec![
923                1.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 5.0, 6.0, 6.0, 7.0, 7.0, 8.0, 8.0, 9.0,
924            ],
925        )
926        .unwrap();
927        let y = array![0, 0, 0, 0, 1, 1, 1, 1];
928
929        let model = RandomForestClassifier::<f64>::new()
930            .with_n_estimators(20)
931            .with_random_state(42);
932        let fitted = model.fit(&x, &y).unwrap();
933        let preds = fitted.predict(&x).unwrap();
934
935        assert_eq!(preds.len(), 8);
936        for i in 0..4 {
937            assert_eq!(preds[i], 0);
938        }
939        for i in 4..8 {
940            assert_eq!(preds[i], 1);
941        }
942    }
943
944    #[test]
945    fn test_forest_classifier_reproducibility() {
946        let x = Array2::from_shape_vec(
947            (8, 2),
948            vec![
949                1.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 5.0, 6.0, 6.0, 7.0, 7.0, 8.0, 8.0, 9.0,
950            ],
951        )
952        .unwrap();
953        let y = array![0, 0, 0, 0, 1, 1, 1, 1];
954
955        let model = RandomForestClassifier::<f64>::new()
956            .with_n_estimators(10)
957            .with_random_state(123);
958
959        let fitted1 = model.fit(&x, &y).unwrap();
960        let fitted2 = model.fit(&x, &y).unwrap();
961
962        let preds1 = fitted1.predict(&x).unwrap();
963        let preds2 = fitted2.predict(&x).unwrap();
964
965        assert_eq!(preds1, preds2);
966    }
967
968    #[test]
969    fn test_forest_classifier_feature_importances() {
970        let x = Array2::from_shape_vec(
971            (10, 3),
972            vec![
973                1.0, 0.0, 0.0, 2.0, 0.0, 0.0, 3.0, 0.0, 0.0, 4.0, 0.0, 0.0, 5.0, 0.0, 0.0, 6.0,
974                0.0, 0.0, 7.0, 0.0, 0.0, 8.0, 0.0, 0.0, 9.0, 0.0, 0.0, 10.0, 0.0, 0.0,
975            ],
976        )
977        .unwrap();
978        let y = array![0, 0, 0, 0, 0, 1, 1, 1, 1, 1];
979
980        let model = RandomForestClassifier::<f64>::new()
981            .with_n_estimators(20)
982            .with_max_features(MaxFeatures::All)
983            .with_random_state(42);
984        let fitted = model.fit(&x, &y).unwrap();
985        let importances = fitted.feature_importances();
986
987        assert_eq!(importances.len(), 3);
988        assert!(importances[0] > importances[1]);
989        assert!(importances[0] > importances[2]);
990    }
991
992    #[test]
993    fn test_forest_classifier_has_classes() {
994        let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
995        let y = array![0, 1, 2, 0, 1, 2];
996
997        let model = RandomForestClassifier::<f64>::new()
998            .with_n_estimators(5)
999            .with_random_state(0);
1000        let fitted = model.fit(&x, &y).unwrap();
1001
1002        assert_eq!(fitted.classes(), &[0, 1, 2]);
1003        assert_eq!(fitted.n_classes(), 3);
1004    }
1005
1006    #[test]
1007    fn test_forest_classifier_shape_mismatch_fit() {
1008        let x = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1009        let y = array![0, 1];
1010
1011        let model = RandomForestClassifier::<f64>::new().with_n_estimators(5);
1012        assert!(model.fit(&x, &y).is_err());
1013    }
1014
1015    #[test]
1016    fn test_forest_classifier_shape_mismatch_predict() {
1017        let x =
1018            Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
1019        let y = array![0, 0, 1, 1];
1020
1021        let model = RandomForestClassifier::<f64>::new()
1022            .with_n_estimators(5)
1023            .with_random_state(0);
1024        let fitted = model.fit(&x, &y).unwrap();
1025
1026        let x_bad = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1027        assert!(fitted.predict(&x_bad).is_err());
1028    }
1029
1030    #[test]
1031    fn test_forest_classifier_empty_data() {
1032        let x = Array2::<f64>::zeros((0, 2));
1033        let y = Array1::<usize>::zeros(0);
1034
1035        let model = RandomForestClassifier::<f64>::new().with_n_estimators(5);
1036        assert!(model.fit(&x, &y).is_err());
1037    }
1038
1039    #[test]
1040    fn test_forest_classifier_zero_estimators() {
1041        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1042        let y = array![0, 0, 1, 1];
1043
1044        let model = RandomForestClassifier::<f64>::new().with_n_estimators(0);
1045        assert!(model.fit(&x, &y).is_err());
1046    }
1047
1048    #[test]
1049    fn test_forest_classifier_single_tree() {
1050        let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1051        let y = array![0, 0, 0, 1, 1, 1];
1052
1053        let model = RandomForestClassifier::<f64>::new()
1054            .with_n_estimators(1)
1055            .with_max_features(MaxFeatures::All)
1056            .with_random_state(42);
1057        let fitted = model.fit(&x, &y).unwrap();
1058        let preds = fitted.predict(&x).unwrap();
1059
1060        assert_eq!(preds.len(), 6);
1061    }
1062
1063    #[test]
1064    fn test_forest_classifier_pipeline_integration() {
1065        let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1066        let y = Array1::from_vec(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0]);
1067
1068        let model = RandomForestClassifier::<f64>::new()
1069            .with_n_estimators(5)
1070            .with_random_state(42);
1071        let fitted = model.fit_pipeline(&x, &y).unwrap();
1072        let preds = fitted.predict_pipeline(&x).unwrap();
1073        assert_eq!(preds.len(), 6);
1074    }
1075
1076    #[test]
1077    fn test_forest_classifier_max_depth() {
1078        let x =
1079            Array2::from_shape_vec((8, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
1080        let y = array![0, 0, 0, 0, 1, 1, 1, 1];
1081
1082        let model = RandomForestClassifier::<f64>::new()
1083            .with_n_estimators(10)
1084            .with_max_depth(Some(1))
1085            .with_max_features(MaxFeatures::All)
1086            .with_random_state(42);
1087        let fitted = model.fit(&x, &y).unwrap();
1088        let preds = fitted.predict(&x).unwrap();
1089
1090        assert_eq!(preds.len(), 8);
1091    }
1092
1093    // -- Regressor tests --
1094
1095    #[test]
1096    fn test_forest_regressor_simple() {
1097        let x =
1098            Array2::from_shape_vec((8, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
1099        let y = array![1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0];
1100
1101        let model = RandomForestRegressor::<f64>::new()
1102            .with_n_estimators(50)
1103            .with_random_state(42);
1104        let fitted = model.fit(&x, &y).unwrap();
1105        let preds = fitted.predict(&x).unwrap();
1106
1107        assert_eq!(preds.len(), 8);
1108        for i in 0..4 {
1109            assert!(preds[i] < 3.0, "Expected ~1.0, got {}", preds[i]);
1110        }
1111        for i in 4..8 {
1112            assert!(preds[i] > 3.0, "Expected ~5.0, got {}", preds[i]);
1113        }
1114    }
1115
1116    #[test]
1117    fn test_forest_regressor_reproducibility() {
1118        let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1119        let y = array![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
1120
1121        let model = RandomForestRegressor::<f64>::new()
1122            .with_n_estimators(10)
1123            .with_random_state(99);
1124
1125        let fitted1 = model.fit(&x, &y).unwrap();
1126        let fitted2 = model.fit(&x, &y).unwrap();
1127
1128        let preds1 = fitted1.predict(&x).unwrap();
1129        let preds2 = fitted2.predict(&x).unwrap();
1130
1131        for (p1, p2) in preds1.iter().zip(preds2.iter()) {
1132            assert_relative_eq!(*p1, *p2, epsilon = 1e-10);
1133        }
1134    }
1135
1136    #[test]
1137    fn test_forest_regressor_feature_importances() {
1138        let x = Array2::from_shape_vec(
1139            (8, 2),
1140            vec![
1141                1.0, 0.0, 2.0, 0.0, 3.0, 0.0, 4.0, 0.0, 5.0, 0.0, 6.0, 0.0, 7.0, 0.0, 8.0, 0.0,
1142            ],
1143        )
1144        .unwrap();
1145        let y = array![1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0];
1146
1147        let model = RandomForestRegressor::<f64>::new()
1148            .with_n_estimators(20)
1149            .with_max_features(MaxFeatures::All)
1150            .with_random_state(42);
1151        let fitted = model.fit(&x, &y).unwrap();
1152        let importances = fitted.feature_importances();
1153
1154        assert_eq!(importances.len(), 2);
1155        assert!(importances[0] > importances[1]);
1156    }
1157
1158    #[test]
1159    fn test_forest_regressor_shape_mismatch_fit() {
1160        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
1161        let y = array![1.0, 2.0];
1162
1163        let model = RandomForestRegressor::<f64>::new().with_n_estimators(5);
1164        assert!(model.fit(&x, &y).is_err());
1165    }
1166
1167    #[test]
1168    fn test_forest_regressor_shape_mismatch_predict() {
1169        let x =
1170            Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
1171        let y = array![1.0, 2.0, 3.0, 4.0];
1172
1173        let model = RandomForestRegressor::<f64>::new()
1174            .with_n_estimators(5)
1175            .with_random_state(0);
1176        let fitted = model.fit(&x, &y).unwrap();
1177
1178        let x_bad = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1179        assert!(fitted.predict(&x_bad).is_err());
1180    }
1181
1182    #[test]
1183    fn test_forest_regressor_empty_data() {
1184        let x = Array2::<f64>::zeros((0, 2));
1185        let y = Array1::<f64>::zeros(0);
1186
1187        let model = RandomForestRegressor::<f64>::new().with_n_estimators(5);
1188        assert!(model.fit(&x, &y).is_err());
1189    }
1190
1191    #[test]
1192    fn test_forest_regressor_zero_estimators() {
1193        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1194        let y = array![1.0, 2.0, 3.0, 4.0];
1195
1196        let model = RandomForestRegressor::<f64>::new().with_n_estimators(0);
1197        assert!(model.fit(&x, &y).is_err());
1198    }
1199
1200    #[test]
1201    fn test_forest_regressor_pipeline_integration() {
1202        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1203        let y = array![1.0, 2.0, 3.0, 4.0];
1204
1205        let model = RandomForestRegressor::<f64>::new()
1206            .with_n_estimators(5)
1207            .with_random_state(42);
1208        let fitted = model.fit_pipeline(&x, &y).unwrap();
1209        let preds = fitted.predict_pipeline(&x).unwrap();
1210        assert_eq!(preds.len(), 4);
1211    }
1212
1213    #[test]
1214    fn test_forest_regressor_max_features_strategies() {
1215        let x = Array2::from_shape_vec(
1216            (8, 4),
1217            vec![
1218                1.0, 2.0, 3.0, 4.0, 2.0, 3.0, 4.0, 5.0, 3.0, 4.0, 5.0, 6.0, 4.0, 5.0, 6.0, 7.0,
1219                5.0, 6.0, 7.0, 8.0, 6.0, 7.0, 8.0, 9.0, 7.0, 8.0, 9.0, 10.0, 8.0, 9.0, 10.0, 11.0,
1220            ],
1221        )
1222        .unwrap();
1223        let y = array![1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0];
1224
1225        for strategy in &[
1226            MaxFeatures::Sqrt,
1227            MaxFeatures::Log2,
1228            MaxFeatures::All,
1229            MaxFeatures::Fixed(2),
1230            MaxFeatures::Fraction(0.5),
1231        ] {
1232            let model = RandomForestRegressor::<f64>::new()
1233                .with_n_estimators(5)
1234                .with_max_features(*strategy)
1235                .with_random_state(42);
1236            let fitted = model.fit(&x, &y).unwrap();
1237            let preds = fitted.predict(&x).unwrap();
1238            assert_eq!(preds.len(), 8);
1239        }
1240    }
1241
1242    // -- MaxFeatures resolution tests --
1243
1244    #[test]
1245    fn test_resolve_max_features_sqrt() {
1246        assert_eq!(resolve_max_features(MaxFeatures::Sqrt, 9), 3);
1247        assert_eq!(resolve_max_features(MaxFeatures::Sqrt, 10), 4);
1248        assert_eq!(resolve_max_features(MaxFeatures::Sqrt, 1), 1);
1249    }
1250
1251    #[test]
1252    fn test_resolve_max_features_log2() {
1253        assert_eq!(resolve_max_features(MaxFeatures::Log2, 8), 3);
1254        assert_eq!(resolve_max_features(MaxFeatures::Log2, 1), 1);
1255    }
1256
1257    #[test]
1258    fn test_resolve_max_features_all() {
1259        assert_eq!(resolve_max_features(MaxFeatures::All, 10), 10);
1260        assert_eq!(resolve_max_features(MaxFeatures::All, 1), 1);
1261    }
1262
1263    #[test]
1264    fn test_resolve_max_features_fixed() {
1265        assert_eq!(resolve_max_features(MaxFeatures::Fixed(3), 10), 3);
1266        assert_eq!(resolve_max_features(MaxFeatures::Fixed(20), 10), 10);
1267    }
1268
1269    #[test]
1270    fn test_resolve_max_features_fraction() {
1271        assert_eq!(resolve_max_features(MaxFeatures::Fraction(0.5), 10), 5);
1272        assert_eq!(resolve_max_features(MaxFeatures::Fraction(0.1), 10), 1);
1273    }
1274
1275    #[test]
1276    fn test_forest_classifier_f32_support() {
1277        let x = Array2::from_shape_vec((6, 1), vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1278        let y = array![0, 0, 0, 1, 1, 1];
1279
1280        let model = RandomForestClassifier::<f32>::new()
1281            .with_n_estimators(5)
1282            .with_random_state(42);
1283        let fitted = model.fit(&x, &y).unwrap();
1284        let preds = fitted.predict(&x).unwrap();
1285        assert_eq!(preds.len(), 6);
1286    }
1287
1288    #[test]
1289    fn test_forest_regressor_f32_support() {
1290        let x = Array2::from_shape_vec((4, 1), vec![1.0f32, 2.0, 3.0, 4.0]).unwrap();
1291        let y = Array1::from_vec(vec![1.0f32, 2.0, 3.0, 4.0]);
1292
1293        let model = RandomForestRegressor::<f32>::new()
1294            .with_n_estimators(5)
1295            .with_random_state(42);
1296        let fitted = model.fit(&x, &y).unwrap();
1297        let preds = fitted.predict(&x).unwrap();
1298        assert_eq!(preds.len(), 4);
1299    }
1300}