1use ferrolearn_core::error::FerroError;
48use ferrolearn_core::introspection::{HasClasses, HasFeatureImportances};
49use ferrolearn_core::pipeline::{FittedPipelineEstimator, PipelineEstimator};
50use ferrolearn_core::traits::{Fit, Predict};
51use ndarray::{Array1, Array2};
52use num_traits::{Float, FromPrimitive, ToPrimitive};
53use rand::SeedableRng;
54use rand::rngs::StdRng;
55use rand::seq::index::sample as rand_sample_indices;
56use serde::{Deserialize, Serialize};
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69pub enum ClassificationCriterion {
70 Gini,
72 Entropy,
74 LogLoss,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86pub enum RegressionCriterion {
87 Mse,
90 FriedmanMse,
95 AbsoluteError,
99 Poisson,
104}
105
106#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
126pub enum ClassWeight<F> {
127 #[default]
131 None,
132 Balanced,
136 Explicit(Vec<(usize, F)>),
140}
141
142fn compute_class_weight<F: Float>(cw: &ClassWeight<F>, classes: &[usize], y: &[usize]) -> Vec<F> {
158 match cw {
159 ClassWeight::None => vec![F::one(); classes.len()],
160 ClassWeight::Balanced => {
161 let n_samples = F::from(y.len()).unwrap_or_else(F::zero);
162 let n_classes = F::from(classes.len()).unwrap_or_else(F::one);
163 classes
164 .iter()
165 .map(|&c| {
166 let count = y.iter().filter(|&&label| label == c).count();
167 let count_f = F::from(count).unwrap_or_else(F::one);
168 if count_f > F::zero() {
169 n_samples / (n_classes * count_f)
170 } else {
171 F::one()
172 }
173 })
174 .collect()
175 }
176 ClassWeight::Explicit(map) => classes
177 .iter()
178 .map(|&c| {
179 map.iter()
180 .find(|(label, _)| *label == c)
181 .map_or_else(F::one, |(_, w)| *w)
182 })
183 .collect(),
184 }
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
196pub enum Node<F> {
197 Split {
199 feature: usize,
201 threshold: F,
203 left: usize,
205 right: usize,
207 impurity_decrease: F,
209 n_samples: usize,
211 },
212 Leaf {
214 value: F,
216 class_distribution: Option<Vec<F>>,
218 n_samples: usize,
220 },
221}
222
223#[derive(Debug, Clone, Copy)]
233pub(crate) struct TreeParams {
234 pub(crate) max_depth: Option<usize>,
235 pub(crate) min_samples_split: usize,
236 pub(crate) min_samples_leaf: usize,
237}
238
239#[derive(Debug, Clone, Copy)]
249pub(crate) struct ImpurityGate<F> {
250 pub(crate) n_total: usize,
251 pub(crate) threshold: F,
252}
253
254impl<F: Float> ImpurityGate<F> {
255 fn disabled(n_total: usize) -> Self {
260 Self {
261 n_total,
262 threshold: F::zero(),
263 }
264 }
265
266 fn rejects(&self, improvement: F) -> bool {
271 improvement + F::epsilon() < self.threshold
272 }
273}
274
275#[derive(Debug, Clone)]
289struct NodeMeta<F> {
290 impurity: F,
293 n_samples: usize,
295 value: F,
298 distribution: Option<Vec<F>>,
301 missing_go_to_left: bool,
314}
315
316struct ClassificationData<'a, F> {
318 x: &'a Array2<F>,
319 y: &'a [usize],
320 n_classes: usize,
321 feature_indices: Option<&'a [usize]>,
325 max_features_per_split: Option<usize>,
329 criterion: ClassificationCriterion,
330 sample_weight: Option<&'a [F]>,
337 min_weight_leaf: F,
343}
344
345struct RegressionData<'a, F> {
347 x: &'a Array2<F>,
348 y: &'a Array1<F>,
349 feature_indices: Option<&'a [usize]>,
350 max_features_per_split: Option<usize>,
352 criterion: RegressionCriterion,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct DecisionTreeClassifier<F> {
369 pub max_depth: Option<usize>,
371 pub min_samples_split: usize,
373 pub min_samples_leaf: usize,
375 pub min_weight_fraction_leaf: F,
383 pub min_impurity_decrease: F,
391 pub ccp_alpha: F,
399 pub max_leaf_nodes: Option<usize>,
408 pub criterion: ClassificationCriterion,
410 pub class_weight: ClassWeight<F>,
416 _marker: std::marker::PhantomData<F>,
417}
418
419impl<F: Float> DecisionTreeClassifier<F> {
420 #[must_use]
428 pub fn new() -> Self {
429 Self {
430 max_depth: None,
431 min_samples_split: 2,
432 min_samples_leaf: 1,
433 min_weight_fraction_leaf: F::zero(),
434 min_impurity_decrease: F::zero(),
435 ccp_alpha: F::zero(),
436 max_leaf_nodes: None,
437 criterion: ClassificationCriterion::Gini,
438 class_weight: ClassWeight::None,
439 _marker: std::marker::PhantomData,
440 }
441 }
442
443 #[must_use]
445 pub fn with_max_depth(mut self, max_depth: Option<usize>) -> Self {
446 self.max_depth = max_depth;
447 self
448 }
449
450 #[must_use]
452 pub fn with_min_samples_split(mut self, min_samples_split: usize) -> Self {
453 self.min_samples_split = min_samples_split;
454 self
455 }
456
457 #[must_use]
459 pub fn with_min_samples_leaf(mut self, min_samples_leaf: usize) -> Self {
460 self.min_samples_leaf = min_samples_leaf;
461 self
462 }
463
464 #[must_use]
467 pub fn with_min_weight_fraction_leaf(mut self, min_weight_fraction_leaf: F) -> Self {
468 self.min_weight_fraction_leaf = min_weight_fraction_leaf;
469 self
470 }
471
472 #[must_use]
475 pub fn with_min_impurity_decrease(mut self, min_impurity_decrease: F) -> Self {
476 self.min_impurity_decrease = min_impurity_decrease;
477 self
478 }
479
480 #[must_use]
483 pub fn with_ccp_alpha(mut self, ccp_alpha: F) -> Self {
484 self.ccp_alpha = ccp_alpha;
485 self
486 }
487
488 #[must_use]
492 pub fn with_max_leaf_nodes(mut self, max_leaf_nodes: Option<usize>) -> Self {
493 self.max_leaf_nodes = max_leaf_nodes;
494 self
495 }
496
497 #[must_use]
499 pub fn with_criterion(mut self, criterion: ClassificationCriterion) -> Self {
500 self.criterion = criterion;
501 self
502 }
503
504 #[must_use]
511 pub fn with_class_weight(mut self, class_weight: ClassWeight<F>) -> Self {
512 self.class_weight = class_weight;
513 self
514 }
515}
516
517impl<F: Float> Default for DecisionTreeClassifier<F> {
518 fn default() -> Self {
519 Self::new()
520 }
521}
522
523#[derive(Debug, Clone)]
533pub struct FittedDecisionTreeClassifier<F> {
534 nodes: Vec<Node<F>>,
536 classes: Vec<usize>,
538 n_features: usize,
540 feature_importances: Array1<F>,
542 missing_go_to_left: Vec<bool>,
547}
548
549impl<F: Float + Send + Sync + 'static> FittedDecisionTreeClassifier<F> {
550 #[must_use]
552 pub fn nodes(&self) -> &[Node<F>] {
553 &self.nodes
554 }
555
556 #[must_use]
558 pub fn n_features(&self) -> usize {
559 self.n_features
560 }
561
562 pub fn predict_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
571 if x.ncols() != self.n_features {
572 return Err(FerroError::ShapeMismatch {
573 expected: vec![self.n_features],
574 actual: vec![x.ncols()],
575 context: "number of features must match fitted model".into(),
576 });
577 }
578 reject_infinite(x)?;
579 let n_samples = x.nrows();
580 let n_classes = self.classes.len();
581 let mut proba = Array2::zeros((n_samples, n_classes));
582 for i in 0..n_samples {
583 let row = x.row(i);
584 let leaf = traverse_tree(&self.nodes, &self.missing_go_to_left, &row);
585 if let Node::Leaf {
586 class_distribution: Some(ref dist),
587 ..
588 } = self.nodes[leaf]
589 {
590 for (j, &p) in dist.iter().enumerate() {
591 proba[[i, j]] = p;
592 }
593 }
594 }
595 Ok(proba)
596 }
597
598 pub fn score(&self, x: &Array2<F>, y: &Array1<usize>) -> Result<F, FerroError> {
606 if x.nrows() != y.len() {
607 return Err(FerroError::ShapeMismatch {
608 expected: vec![x.nrows()],
609 actual: vec![y.len()],
610 context: "y length must match number of samples in X".into(),
611 });
612 }
613 let preds = self.predict(x)?;
614 Ok(crate::mean_accuracy(&preds, y))
615 }
616
617 pub fn predict_log_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
624 let proba = self.predict_proba(x)?;
625 Ok(crate::log_proba(&proba))
626 }
627}
628
629impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, Array1<usize>> for DecisionTreeClassifier<F> {
630 type Fitted = FittedDecisionTreeClassifier<F>;
631 type Error = FerroError;
632
633 fn fit(
642 &self,
643 x: &Array2<F>,
644 y: &Array1<usize>,
645 ) -> Result<FittedDecisionTreeClassifier<F>, FerroError> {
646 let (n_samples, n_features) = x.dim();
647
648 if n_samples != y.len() {
649 return Err(FerroError::ShapeMismatch {
650 expected: vec![n_samples],
651 actual: vec![y.len()],
652 context: "y length must match number of samples in X".into(),
653 });
654 }
655 if n_samples == 0 {
656 return Err(FerroError::InsufficientSamples {
657 required: 1,
658 actual: 0,
659 context: "DecisionTreeClassifier requires at least one sample".into(),
660 });
661 }
662 if self.min_samples_split < 2 {
663 return Err(FerroError::InvalidParameter {
664 name: "min_samples_split".into(),
665 reason: "must be at least 2".into(),
666 });
667 }
668 if self.min_samples_leaf < 1 {
669 return Err(FerroError::InvalidParameter {
670 name: "min_samples_leaf".into(),
671 reason: "must be at least 1".into(),
672 });
673 }
674 reject_infinite(x)?;
677
678 let mut classes: Vec<usize> = y.iter().copied().collect();
680 classes.sort_unstable();
681 classes.dedup();
682 let n_classes = classes.len();
683
684 let y_mapped: Vec<usize> = y
686 .iter()
687 .map(|&c| classes.iter().position(|&cl| cl == c).unwrap())
688 .collect();
689
690 let indices: Vec<usize> = (0..n_samples).collect();
691
692 let y_vec: Vec<usize> = y.iter().copied().collect();
697 let per_class_weight = compute_class_weight(&self.class_weight, &classes, &y_vec);
698 let use_weights = self.class_weight != ClassWeight::None;
699 let sample_weight: Option<Vec<F>> = if use_weights {
700 Some(y_mapped.iter().map(|&c| per_class_weight[c]).collect())
701 } else {
702 None
703 };
704 let total_weight: F = sample_weight.as_ref().map_or_else(
710 || F::from(n_samples).unwrap_or_else(F::one),
711 |w| w.iter().fold(F::zero(), |a, &b| a + b),
712 );
713 let min_weight_leaf = self.min_weight_fraction_leaf * total_weight;
714
715 let data = ClassificationData {
716 x,
717 y: &y_mapped,
718 n_classes,
719 feature_indices: None,
720 max_features_per_split: None,
721 criterion: self.criterion,
722 sample_weight: sample_weight.as_deref(),
723 min_weight_leaf,
724 };
725 let params = TreeParams {
731 max_depth: self.max_depth,
732 min_samples_split: self.min_samples_split,
733 min_samples_leaf: if use_weights {
734 self.min_samples_leaf
735 } else {
736 effective_min_samples_leaf(
737 self.min_samples_leaf,
738 self.min_weight_fraction_leaf,
739 n_samples,
740 )
741 },
742 };
743 let gate = ImpurityGate {
744 n_total: n_samples,
745 threshold: self.min_impurity_decrease,
746 };
747
748 let prune = self.ccp_alpha > F::zero();
753 let mut nodes: Vec<Node<F>> = Vec::new();
754 let mut meta: Vec<NodeMeta<F>> = Vec::new();
755 if let Some(max_leaf_nodes) = self.max_leaf_nodes {
756 build_classification_tree_best_first(
760 &data,
761 &indices,
762 &mut nodes,
763 Some(&mut meta),
764 ¶ms,
765 &gate,
766 max_leaf_nodes,
767 );
768 } else {
769 build_classification_tree(
770 &data,
771 &indices,
772 &mut nodes,
773 Some(&mut meta),
774 0,
775 ¶ms,
776 &gate,
777 None,
778 );
779 }
780
781 if prune {
782 let (pruned_nodes, pruned_meta) = prune_ccp(&nodes, &meta, n_samples, self.ccp_alpha);
783 nodes = pruned_nodes;
784 meta = pruned_meta;
785 }
786
787 let missing_go_to_left = missing_directions(&meta);
788 let feature_importances = compute_feature_importances(&nodes, n_features, n_samples);
789
790 Ok(FittedDecisionTreeClassifier {
791 nodes,
792 classes,
793 n_features,
794 feature_importances,
795 missing_go_to_left,
796 })
797 }
798}
799
800fn missing_directions<F>(meta: &[NodeMeta<F>]) -> Vec<bool> {
803 meta.iter().map(|m| m.missing_go_to_left).collect()
804}
805
806impl<F: Float + Send + Sync + 'static> Predict<Array2<F>> for FittedDecisionTreeClassifier<F> {
807 type Output = Array1<usize>;
808 type Error = FerroError;
809
810 fn predict(&self, x: &Array2<F>) -> Result<Array1<usize>, FerroError> {
817 if x.ncols() != self.n_features {
818 return Err(FerroError::ShapeMismatch {
819 expected: vec![self.n_features],
820 actual: vec![x.ncols()],
821 context: "number of features must match fitted model".into(),
822 });
823 }
824 reject_infinite(x)?;
825 let n_samples = x.nrows();
826 let mut predictions = Array1::zeros(n_samples);
827 for i in 0..n_samples {
828 let row = x.row(i);
829 let leaf = traverse_tree(&self.nodes, &self.missing_go_to_left, &row);
830 if let Node::Leaf { value, .. } = self.nodes[leaf] {
831 predictions[i] = float_to_usize(value);
832 }
833 }
834 Ok(predictions)
835 }
836}
837
838impl<F: Float + Send + Sync + 'static> HasFeatureImportances<F>
839 for FittedDecisionTreeClassifier<F>
840{
841 fn feature_importances(&self) -> &Array1<F> {
842 &self.feature_importances
843 }
844}
845
846impl<F: Float + Send + Sync + 'static> HasClasses for FittedDecisionTreeClassifier<F> {
847 fn classes(&self) -> &[usize] {
848 &self.classes
849 }
850
851 fn n_classes(&self) -> usize {
852 self.classes.len()
853 }
854}
855
856impl<F: Float + ToPrimitive + FromPrimitive + Send + Sync + 'static> PipelineEstimator<F>
858 for DecisionTreeClassifier<F>
859{
860 fn fit_pipeline(
861 &self,
862 x: &Array2<F>,
863 y: &Array1<F>,
864 ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
865 let y_usize: Array1<usize> = y.mapv(|v| v.to_usize().unwrap_or(0));
866 let fitted = self.fit(x, &y_usize)?;
867 Ok(Box::new(FittedClassifierPipelineAdapter(fitted)))
868 }
869}
870
871struct FittedClassifierPipelineAdapter<F: Float + Send + Sync + 'static>(
873 FittedDecisionTreeClassifier<F>,
874);
875
876impl<F: Float + ToPrimitive + FromPrimitive + Send + Sync + 'static> FittedPipelineEstimator<F>
877 for FittedClassifierPipelineAdapter<F>
878{
879 fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
880 let preds = self.0.predict(x)?;
881 Ok(preds.mapv(|v| F::from_usize(v).unwrap_or_else(F::nan)))
882 }
883}
884
885#[derive(Debug, Clone, Serialize, Deserialize)]
898pub struct DecisionTreeRegressor<F> {
899 pub max_depth: Option<usize>,
901 pub min_samples_split: usize,
903 pub min_samples_leaf: usize,
905 pub min_weight_fraction_leaf: F,
913 pub min_impurity_decrease: F,
921 pub ccp_alpha: F,
929 pub max_leaf_nodes: Option<usize>,
938 pub criterion: RegressionCriterion,
940 _marker: std::marker::PhantomData<F>,
941}
942
943impl<F: Float> DecisionTreeRegressor<F> {
944 #[must_use]
951 pub fn new() -> Self {
952 Self {
953 max_depth: None,
954 min_samples_split: 2,
955 min_samples_leaf: 1,
956 min_weight_fraction_leaf: F::zero(),
957 min_impurity_decrease: F::zero(),
958 ccp_alpha: F::zero(),
959 max_leaf_nodes: None,
960 criterion: RegressionCriterion::Mse,
961 _marker: std::marker::PhantomData,
962 }
963 }
964
965 #[must_use]
967 pub fn with_max_depth(mut self, max_depth: Option<usize>) -> Self {
968 self.max_depth = max_depth;
969 self
970 }
971
972 #[must_use]
974 pub fn with_min_samples_split(mut self, min_samples_split: usize) -> Self {
975 self.min_samples_split = min_samples_split;
976 self
977 }
978
979 #[must_use]
981 pub fn with_min_samples_leaf(mut self, min_samples_leaf: usize) -> Self {
982 self.min_samples_leaf = min_samples_leaf;
983 self
984 }
985
986 #[must_use]
989 pub fn with_min_weight_fraction_leaf(mut self, min_weight_fraction_leaf: F) -> Self {
990 self.min_weight_fraction_leaf = min_weight_fraction_leaf;
991 self
992 }
993
994 #[must_use]
997 pub fn with_min_impurity_decrease(mut self, min_impurity_decrease: F) -> Self {
998 self.min_impurity_decrease = min_impurity_decrease;
999 self
1000 }
1001
1002 #[must_use]
1005 pub fn with_ccp_alpha(mut self, ccp_alpha: F) -> Self {
1006 self.ccp_alpha = ccp_alpha;
1007 self
1008 }
1009
1010 #[must_use]
1014 pub fn with_max_leaf_nodes(mut self, max_leaf_nodes: Option<usize>) -> Self {
1015 self.max_leaf_nodes = max_leaf_nodes;
1016 self
1017 }
1018
1019 #[must_use]
1021 pub fn with_criterion(mut self, criterion: RegressionCriterion) -> Self {
1022 self.criterion = criterion;
1023 self
1024 }
1025}
1026
1027impl<F: Float> Default for DecisionTreeRegressor<F> {
1028 fn default() -> Self {
1029 Self::new()
1030 }
1031}
1032
1033#[derive(Debug, Clone)]
1041pub struct FittedDecisionTreeRegressor<F> {
1042 nodes: Vec<Node<F>>,
1044 n_features: usize,
1046 feature_importances: Array1<F>,
1048 missing_go_to_left: Vec<bool>,
1053}
1054
1055impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, Array1<F>> for DecisionTreeRegressor<F> {
1056 type Fitted = FittedDecisionTreeRegressor<F>;
1057 type Error = FerroError;
1058
1059 fn fit(
1068 &self,
1069 x: &Array2<F>,
1070 y: &Array1<F>,
1071 ) -> Result<FittedDecisionTreeRegressor<F>, FerroError> {
1072 let (n_samples, n_features) = x.dim();
1073
1074 if n_samples != y.len() {
1075 return Err(FerroError::ShapeMismatch {
1076 expected: vec![n_samples],
1077 actual: vec![y.len()],
1078 context: "y length must match number of samples in X".into(),
1079 });
1080 }
1081 if n_samples == 0 {
1082 return Err(FerroError::InsufficientSamples {
1083 required: 1,
1084 actual: 0,
1085 context: "DecisionTreeRegressor requires at least one sample".into(),
1086 });
1087 }
1088 if self.min_samples_split < 2 {
1089 return Err(FerroError::InvalidParameter {
1090 name: "min_samples_split".into(),
1091 reason: "must be at least 2".into(),
1092 });
1093 }
1094 if self.min_samples_leaf < 1 {
1095 return Err(FerroError::InvalidParameter {
1096 name: "min_samples_leaf".into(),
1097 reason: "must be at least 1".into(),
1098 });
1099 }
1100 reject_infinite(x)?;
1103
1104 if self.criterion == RegressionCriterion::Poisson {
1110 if y.iter().any(|&v| v < F::zero()) {
1111 return Err(FerroError::InvalidParameter {
1112 name: "y".into(),
1113 reason: "Some value(s) of y are negative which is not allowed for Poisson \
1114 regression."
1115 .into(),
1116 });
1117 }
1118 let sum_y = y.iter().fold(F::zero(), |a, &b| a + b);
1119 if sum_y <= F::zero() {
1120 return Err(FerroError::InvalidParameter {
1121 name: "y".into(),
1122 reason: "Sum of y is not positive which is necessary for Poisson regression."
1123 .into(),
1124 });
1125 }
1126 }
1127
1128 let indices: Vec<usize> = (0..n_samples).collect();
1129
1130 let data = RegressionData {
1131 x,
1132 y,
1133 feature_indices: None,
1134 max_features_per_split: None,
1135 criterion: self.criterion,
1136 };
1137 let params = TreeParams {
1140 max_depth: self.max_depth,
1141 min_samples_split: self.min_samples_split,
1142 min_samples_leaf: effective_min_samples_leaf(
1143 self.min_samples_leaf,
1144 self.min_weight_fraction_leaf,
1145 n_samples,
1146 ),
1147 };
1148 let gate = ImpurityGate {
1149 n_total: n_samples,
1150 threshold: self.min_impurity_decrease,
1151 };
1152
1153 let prune = self.ccp_alpha > F::zero();
1156 let mut nodes: Vec<Node<F>> = Vec::new();
1157 let mut meta: Vec<NodeMeta<F>> = Vec::new();
1158 if let Some(max_leaf_nodes) = self.max_leaf_nodes {
1159 build_regression_tree_best_first(
1161 &data,
1162 &indices,
1163 &mut nodes,
1164 Some(&mut meta),
1165 ¶ms,
1166 &gate,
1167 max_leaf_nodes,
1168 );
1169 } else {
1170 build_regression_tree(
1171 &data,
1172 &indices,
1173 &mut nodes,
1174 Some(&mut meta),
1175 0,
1176 ¶ms,
1177 &gate,
1178 None,
1179 );
1180 }
1181
1182 if prune {
1183 let (pruned_nodes, pruned_meta) = prune_ccp(&nodes, &meta, n_samples, self.ccp_alpha);
1184 nodes = pruned_nodes;
1185 meta = pruned_meta;
1186 }
1187
1188 let missing_go_to_left = missing_directions(&meta);
1189 let feature_importances = compute_feature_importances(&nodes, n_features, n_samples);
1190
1191 Ok(FittedDecisionTreeRegressor {
1192 nodes,
1193 n_features,
1194 feature_importances,
1195 missing_go_to_left,
1196 })
1197 }
1198}
1199
1200impl<F: Float + Send + Sync + 'static> FittedDecisionTreeRegressor<F> {
1201 #[must_use]
1203 pub fn nodes(&self) -> &[Node<F>] {
1204 &self.nodes
1205 }
1206
1207 #[must_use]
1209 pub fn n_features(&self) -> usize {
1210 self.n_features
1211 }
1212
1213 pub fn score(&self, x: &Array2<F>, y: &Array1<F>) -> Result<F, FerroError> {
1221 if x.nrows() != y.len() {
1222 return Err(FerroError::ShapeMismatch {
1223 expected: vec![x.nrows()],
1224 actual: vec![y.len()],
1225 context: "y length must match number of samples in X".into(),
1226 });
1227 }
1228 let preds = self.predict(x)?;
1229 Ok(crate::r2_score(&preds, y))
1230 }
1231}
1232
1233impl<F: Float + Send + Sync + 'static> Predict<Array2<F>> for FittedDecisionTreeRegressor<F> {
1234 type Output = Array1<F>;
1235 type Error = FerroError;
1236
1237 fn predict(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
1244 if x.ncols() != self.n_features {
1245 return Err(FerroError::ShapeMismatch {
1246 expected: vec![self.n_features],
1247 actual: vec![x.ncols()],
1248 context: "number of features must match fitted model".into(),
1249 });
1250 }
1251 reject_infinite(x)?;
1252 let n_samples = x.nrows();
1253 let mut predictions = Array1::zeros(n_samples);
1254 for i in 0..n_samples {
1255 let row = x.row(i);
1256 let leaf = traverse_tree(&self.nodes, &self.missing_go_to_left, &row);
1257 if let Node::Leaf { value, .. } = self.nodes[leaf] {
1258 predictions[i] = value;
1259 }
1260 }
1261 Ok(predictions)
1262 }
1263}
1264
1265impl<F: Float + Send + Sync + 'static> HasFeatureImportances<F> for FittedDecisionTreeRegressor<F> {
1266 fn feature_importances(&self) -> &Array1<F> {
1267 &self.feature_importances
1268 }
1269}
1270
1271impl<F: Float + Send + Sync + 'static> PipelineEstimator<F> for DecisionTreeRegressor<F> {
1273 fn fit_pipeline(
1274 &self,
1275 x: &Array2<F>,
1276 y: &Array1<F>,
1277 ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
1278 let fitted = self.fit(x, y)?;
1279 Ok(Box::new(fitted))
1280 }
1281}
1282
1283impl<F: Float + Send + Sync + 'static> FittedPipelineEstimator<F>
1284 for FittedDecisionTreeRegressor<F>
1285{
1286 fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
1287 self.predict(x)
1288 }
1289}
1290
1291fn feature_threshold<F: Float>() -> F {
1306 F::from(1e-7).unwrap_or_else(F::epsilon)
1307}
1308
1309fn effective_min_samples_leaf<F: Float>(
1320 min_samples_leaf: usize,
1321 min_weight_fraction_leaf: F,
1322 n_total: usize,
1323) -> usize {
1324 if min_weight_fraction_leaf <= F::zero() {
1325 return min_samples_leaf;
1326 }
1327 let min_weight_leaf = min_weight_fraction_leaf * F::from(n_total).unwrap_or_else(F::one);
1328 let ceil_weight = min_weight_leaf.ceil().to_usize().unwrap_or(0);
1331 min_samples_leaf.max(ceil_weight)
1332}
1333
1334fn reject_infinite<F: Float>(x: &Array2<F>) -> Result<(), FerroError> {
1345 if x.iter().any(|v| v.is_infinite()) {
1346 return Err(FerroError::InvalidParameter {
1347 name: "X".into(),
1348 reason: "Input X contains infinity or a value too large for dtype.".into(),
1349 });
1350 }
1351 Ok(())
1352}
1353
1354fn sort_indices_by_feature<F: Float>(idxs: &mut [usize], x: &Array2<F>, feat: usize) {
1360 use std::cmp::Ordering;
1361 idxs.sort_by(|&a, &b| {
1362 let va = x[[a, feat]];
1363 let vb = x[[b, feat]];
1364 match (va.is_nan(), vb.is_nan()) {
1370 (true, true) => Ordering::Equal,
1371 (true, false) => Ordering::Greater,
1372 (false, true) => Ordering::Less,
1373 (false, false) => va.partial_cmp(&vb).unwrap_or(Ordering::Equal),
1374 }
1375 });
1376}
1377
1378fn traverse_tree<F: Float>(
1387 nodes: &[Node<F>],
1388 missing: &[bool],
1389 sample: &ndarray::ArrayView1<F>,
1390) -> usize {
1391 let mut idx = 0;
1392 loop {
1393 match &nodes[idx] {
1394 Node::Split {
1395 feature,
1396 threshold,
1397 left,
1398 right,
1399 ..
1400 } => {
1401 let v = sample[*feature];
1402 idx = if v.is_nan() {
1403 if missing.get(idx).copied().unwrap_or(false) {
1404 *left
1405 } else {
1406 *right
1407 }
1408 } else if v <= *threshold {
1409 *left
1410 } else {
1411 *right
1412 };
1413 }
1414 Node::Leaf { .. } => return idx,
1415 }
1416 }
1417}
1418
1419pub(crate) fn traverse<F: Float>(nodes: &[Node<F>], sample: &ndarray::ArrayView1<F>) -> usize {
1426 traverse_tree(nodes, &[], sample)
1427}
1428
1429fn float_to_usize<F: Float>(v: F) -> usize {
1431 v.to_f64().map_or(0, |f| f.round() as usize)
1432}
1433
1434fn gini_impurity<F: Float>(class_counts: &[usize], total: usize) -> F {
1436 if total == 0 {
1437 return F::zero();
1438 }
1439 let total_f = F::from(total).unwrap();
1440 let mut impurity = F::one();
1441 for &count in class_counts {
1442 let p = F::from(count).unwrap() / total_f;
1443 impurity = impurity - p * p;
1444 }
1445 impurity
1446}
1447
1448fn entropy_impurity<F: Float>(class_counts: &[usize], total: usize) -> F {
1450 if total == 0 {
1451 return F::zero();
1452 }
1453 let total_f = F::from(total).unwrap();
1454 let mut ent = F::zero();
1455 for &count in class_counts {
1456 if count > 0 {
1457 let p = F::from(count).unwrap() / total_f;
1458 ent = ent - p * p.ln();
1459 }
1460 }
1461 ent
1462}
1463
1464fn mean_value<F: Float>(y: &Array1<F>, indices: &[usize]) -> F {
1466 if indices.is_empty() {
1467 return F::zero();
1468 }
1469 let sum: F = indices.iter().map(|&i| y[i]).fold(F::zero(), |a, b| a + b);
1470 sum / F::from(indices.len()).unwrap()
1471}
1472
1473fn median_value<F: Float>(y: &Array1<F>, indices: &[usize]) -> F {
1481 let n = indices.len();
1482 if n == 0 {
1483 return F::zero();
1484 }
1485 let mut vals: Vec<F> = indices.iter().map(|&i| y[i]).collect();
1486 vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1487 let mid = n / 2;
1488 if n % 2 == 1 {
1489 vals[mid]
1490 } else {
1491 (vals[mid - 1] + vals[mid]) / F::from(2.0).unwrap_or_else(F::one)
1492 }
1493}
1494
1495fn mae_for_indices<F: Float>(y: &Array1<F>, indices: &[usize]) -> F {
1498 let n = indices.len();
1499 if n == 0 {
1500 return F::zero();
1501 }
1502 let median = median_value(y, indices);
1503 let sum_abs: F = indices
1504 .iter()
1505 .map(|&i| (y[i] - median).abs())
1506 .fold(F::zero(), |a, b| a + b);
1507 sum_abs / F::from(n).unwrap_or_else(F::one)
1508}
1509
1510fn poisson_deviance_for_indices<F: Float>(y: &Array1<F>, indices: &[usize]) -> F {
1516 let n = indices.len();
1517 if n == 0 {
1518 return F::zero();
1519 }
1520 let n_f = F::from(n).unwrap_or_else(F::one);
1521 let sum: F = indices.iter().map(|&i| y[i]).fold(F::zero(), |a, b| a + b);
1522 if sum <= F::epsilon() {
1523 return F::infinity();
1524 }
1525 let mean = sum / n_f;
1526 let mut loss = F::zero();
1527 for &i in indices {
1528 let yi = y[i];
1529 if yi > F::zero() {
1531 loss = loss + yi * (yi / mean).ln();
1532 }
1533 }
1534 loss / n_f
1535}
1536
1537fn mse_for_indices<F: Float>(y: &Array1<F>, indices: &[usize], mean: F) -> F {
1539 if indices.is_empty() {
1540 return F::zero();
1541 }
1542 let sum_sq: F = indices
1543 .iter()
1544 .map(|&i| {
1545 let diff = y[i] - mean;
1546 diff * diff
1547 })
1548 .fold(F::zero(), |a, b| a + b);
1549 sum_sq / F::from(indices.len()).unwrap()
1550}
1551
1552fn regression_leaf_value<F: Float>(
1559 y: &Array1<F>,
1560 indices: &[usize],
1561 criterion: RegressionCriterion,
1562) -> F {
1563 match criterion {
1564 RegressionCriterion::AbsoluteError => median_value(y, indices),
1565 RegressionCriterion::Mse
1566 | RegressionCriterion::FriedmanMse
1567 | RegressionCriterion::Poisson => mean_value(y, indices),
1568 }
1569}
1570
1571fn regression_node_impurity<F: Float>(
1574 y: &Array1<F>,
1575 indices: &[usize],
1576 criterion: RegressionCriterion,
1577) -> F {
1578 match criterion {
1579 RegressionCriterion::Mse | RegressionCriterion::FriedmanMse => {
1582 mse_for_indices(y, indices, mean_value(y, indices))
1583 }
1584 RegressionCriterion::AbsoluteError => mae_for_indices(y, indices),
1585 RegressionCriterion::Poisson => poisson_deviance_for_indices(y, indices),
1586 }
1587}
1588
1589fn compute_impurity<F: Float>(
1591 class_counts: &[usize],
1592 total: usize,
1593 criterion: ClassificationCriterion,
1594) -> F {
1595 match criterion {
1596 ClassificationCriterion::Gini => gini_impurity(class_counts, total),
1597 ClassificationCriterion::Entropy | ClassificationCriterion::LogLoss => {
1600 entropy_impurity(class_counts, total)
1601 }
1602 }
1603}
1604
1605fn weighted_gini_impurity<F: Float>(weighted_counts: &[F], total: F) -> F {
1615 if total <= F::zero() {
1616 return F::zero();
1617 }
1618 let mut impurity = F::one();
1619 for &w in weighted_counts {
1620 let p = w / total;
1621 impurity = impurity - p * p;
1622 }
1623 impurity
1624}
1625
1626fn weighted_entropy_impurity<F: Float>(weighted_counts: &[F], total: F) -> F {
1631 if total <= F::zero() {
1632 return F::zero();
1633 }
1634 let mut ent = F::zero();
1635 for &w in weighted_counts {
1636 if w > F::zero() {
1637 let p = w / total;
1638 ent = ent - p * p.ln();
1639 }
1640 }
1641 ent
1642}
1643
1644fn weighted_compute_impurity<F: Float>(
1649 weighted_counts: &[F],
1650 total: F,
1651 criterion: ClassificationCriterion,
1652) -> F {
1653 match criterion {
1654 ClassificationCriterion::Gini => weighted_gini_impurity(weighted_counts, total),
1655 ClassificationCriterion::Entropy | ClassificationCriterion::LogLoss => {
1656 weighted_entropy_impurity(weighted_counts, total)
1657 }
1658 }
1659}
1660
1661fn weighted_class_counts<F: Float>(
1665 indices: &[usize],
1666 y: &[usize],
1667 n_classes: usize,
1668 sample_weight: &[F],
1669) -> (Vec<F>, F) {
1670 let mut counts = vec![F::zero(); n_classes];
1671 let mut total = F::zero();
1672 for &i in indices {
1673 let w = sample_weight[i];
1674 counts[y[i]] = counts[y[i]] + w;
1675 total = total + w;
1676 }
1677 (counts, total)
1678}
1679
1680fn weighted_classification_node_value<F: Float>(
1686 weighted_counts: &[F],
1687 total: F,
1688) -> (usize, Vec<F>) {
1689 let majority_class = {
1690 let mut best = 0usize;
1691 let mut best_w = F::neg_infinity();
1692 for (i, &w) in weighted_counts.iter().enumerate() {
1693 if w > best_w {
1696 best_w = w;
1697 best = i;
1698 }
1699 }
1700 best
1701 };
1702 let denom = if total > F::zero() { total } else { F::one() };
1703 let distribution: Vec<F> = weighted_counts.iter().map(|&w| w / denom).collect();
1704 (majority_class, distribution)
1705}
1706
1707fn make_weighted_classification_leaf<F: Float>(
1711 nodes: &mut Vec<Node<F>>,
1712 meta: Option<&mut Vec<NodeMeta<F>>>,
1713 weighted_counts: &[F],
1714 total: F,
1715 n_samples: usize,
1716 criterion: ClassificationCriterion,
1717) -> usize {
1718 let (majority_class, distribution) =
1719 weighted_classification_node_value::<F>(weighted_counts, total);
1720 let value = F::from(majority_class).unwrap_or_else(F::zero);
1721
1722 let idx = nodes.len();
1723 nodes.push(Node::Leaf {
1724 value,
1725 class_distribution: Some(distribution.clone()),
1726 n_samples,
1727 });
1728 if let Some(meta) = meta {
1729 meta.push(NodeMeta {
1730 impurity: weighted_compute_impurity::<F>(weighted_counts, total, criterion),
1731 n_samples,
1732 value,
1733 distribution: Some(distribution),
1734 missing_go_to_left: false,
1736 });
1737 }
1738 idx
1739}
1740
1741fn emit_classification_leaf<F: Float>(
1747 data: &ClassificationData<'_, F>,
1748 indices: &[usize],
1749 nodes: &mut Vec<Node<F>>,
1750 meta: Option<&mut Vec<NodeMeta<F>>>,
1751 class_counts: &[usize],
1752 n_samples: usize,
1753) -> usize {
1754 if let Some(sw) = data.sample_weight {
1755 let (wc, total) = weighted_class_counts(indices, data.y, data.n_classes, sw);
1756 make_weighted_classification_leaf(nodes, meta, &wc, total, n_samples, data.criterion)
1757 } else {
1758 make_classification_leaf(
1759 nodes,
1760 meta,
1761 class_counts,
1762 data.n_classes,
1763 n_samples,
1764 data.criterion,
1765 )
1766 }
1767}
1768
1769fn classification_node_value<F: Float>(
1776 class_counts: &[usize],
1777 n_classes: usize,
1778 n_samples: usize,
1779) -> (usize, Vec<F>) {
1780 let majority_class = {
1781 let mut best = 0usize;
1782 let mut best_count = 0usize;
1783 for (i, &count) in class_counts.iter().enumerate() {
1784 if count > best_count {
1785 best_count = count;
1786 best = i;
1787 }
1788 }
1789 best
1790 };
1791
1792 let total_f = if n_samples > 0 {
1793 F::from(n_samples).unwrap_or_else(F::one)
1794 } else {
1795 F::one()
1796 };
1797 let distribution: Vec<F> = (0..n_classes)
1798 .map(|c| F::from(class_counts[c]).unwrap_or_else(F::zero) / total_f)
1799 .collect();
1800 (majority_class, distribution)
1801}
1802
1803fn make_classification_leaf<F: Float>(
1809 nodes: &mut Vec<Node<F>>,
1810 meta: Option<&mut Vec<NodeMeta<F>>>,
1811 class_counts: &[usize],
1812 n_classes: usize,
1813 n_samples: usize,
1814 criterion: ClassificationCriterion,
1815) -> usize {
1816 let (majority_class, distribution) =
1817 classification_node_value::<F>(class_counts, n_classes, n_samples);
1818 let value = F::from(majority_class).unwrap_or_else(F::zero);
1819
1820 let idx = nodes.len();
1821 nodes.push(Node::Leaf {
1822 value,
1823 class_distribution: Some(distribution.clone()),
1824 n_samples,
1825 });
1826 if let Some(meta) = meta {
1827 meta.push(NodeMeta {
1828 impurity: compute_impurity::<F>(class_counts, n_samples, criterion),
1829 n_samples,
1830 value,
1831 distribution: Some(distribution),
1832 missing_go_to_left: false,
1834 });
1835 }
1836 idx
1837}
1838
1839#[allow(
1843 clippy::too_many_arguments,
1844 reason = "recursive builder threads data/nodes/prune-meta/params/gate/rng; bundling would obscure the recursion"
1845)]
1846fn build_classification_tree<F: Float>(
1847 data: &ClassificationData<'_, F>,
1848 indices: &[usize],
1849 nodes: &mut Vec<Node<F>>,
1850 mut meta: Option<&mut Vec<NodeMeta<F>>>,
1851 depth: usize,
1852 params: &TreeParams,
1853 gate: &ImpurityGate<F>,
1854 mut rng: Option<&mut StdRng>,
1855) -> usize {
1856 let n = indices.len();
1857
1858 let mut class_counts = vec![0usize; data.n_classes];
1859 for &i in indices {
1860 class_counts[data.y[i]] += 1;
1861 }
1862
1863 let should_stop = n < params.min_samples_split
1868 || params.max_depth.is_some_and(|d| depth >= d)
1869 || class_counts.iter().filter(|&&c| c > 0).count() <= 1;
1870
1871 if should_stop {
1872 return emit_classification_leaf(
1873 data,
1874 indices,
1875 nodes,
1876 meta.as_deref_mut(),
1877 &class_counts,
1878 n,
1879 );
1880 }
1881
1882 let best =
1885 find_best_classification_split(data, indices, params.min_samples_leaf, rng.as_deref_mut());
1886
1887 let gated = best.filter(|&(_, _, best_impurity_decrease, _)| {
1894 let n_total_f = F::from(gate.n_total).unwrap_or_else(F::one);
1895 let improvement = best_impurity_decrease / n_total_f;
1896 !gate.rejects(improvement)
1897 });
1898
1899 if let Some((best_feature, best_threshold, best_impurity_decrease, missing_go_to_left)) = gated
1900 {
1901 let (left_indices, right_indices): (Vec<usize>, Vec<usize>) = partition_with_missing(
1902 indices,
1903 data.x,
1904 best_feature,
1905 best_threshold,
1906 missing_go_to_left,
1907 );
1908
1909 let node_idx = nodes.len();
1910 nodes.push(Node::Leaf {
1911 value: F::zero(),
1912 class_distribution: None,
1913 n_samples: 0,
1914 }); if let Some(meta) = meta.as_deref_mut() {
1919 meta.push(NodeMeta {
1920 impurity: F::zero(),
1921 n_samples: 0,
1922 value: F::zero(),
1923 distribution: None,
1924 missing_go_to_left: false,
1925 });
1926 }
1927
1928 let left_idx = build_classification_tree(
1929 data,
1930 &left_indices,
1931 nodes,
1932 meta.as_deref_mut(),
1933 depth + 1,
1934 params,
1935 gate,
1936 rng.as_deref_mut(),
1937 );
1938 let right_idx = build_classification_tree(
1939 data,
1940 &right_indices,
1941 nodes,
1942 meta.as_deref_mut(),
1943 depth + 1,
1944 params,
1945 gate,
1946 rng,
1947 );
1948
1949 nodes[node_idx] = Node::Split {
1950 feature: best_feature,
1951 threshold: best_threshold,
1952 left: left_idx,
1953 right: right_idx,
1954 impurity_decrease: best_impurity_decrease,
1955 n_samples: n,
1956 };
1957
1958 if let Some(meta) = meta {
1959 let (majority_class, distribution, impurity) = if let Some(sw) = data.sample_weight {
1960 let (wc, total) = weighted_class_counts(indices, data.y, data.n_classes, sw);
1961 let (mc, dist) = weighted_classification_node_value::<F>(&wc, total);
1962 (
1963 mc,
1964 dist,
1965 weighted_compute_impurity::<F>(&wc, total, data.criterion),
1966 )
1967 } else {
1968 let (mc, dist) = classification_node_value::<F>(&class_counts, data.n_classes, n);
1969 (
1970 mc,
1971 dist,
1972 compute_impurity::<F>(&class_counts, n, data.criterion),
1973 )
1974 };
1975 meta[node_idx] = NodeMeta {
1976 impurity,
1977 n_samples: n,
1978 value: F::from(majority_class).unwrap_or_else(F::zero),
1979 distribution: Some(distribution),
1980 missing_go_to_left,
1981 };
1982 }
1983
1984 node_idx
1985 } else {
1986 emit_classification_leaf(data, indices, nodes, meta, &class_counts, n)
1987 }
1988}
1989
1990fn find_best_classification_split<F: Float>(
2000 data: &ClassificationData<'_, F>,
2001 indices: &[usize],
2002 min_samples_leaf: usize,
2003 rng: Option<&mut StdRng>,
2004) -> Option<(usize, F, F, bool)> {
2005 let n = indices.len();
2006 let n_f = F::from(n).unwrap_or_else(F::one);
2007 let n_features = data.x.ncols();
2008
2009 let mut parent_counts = vec![0usize; data.n_classes];
2010 for &i in indices {
2011 parent_counts[data.y[i]] += 1;
2012 }
2013
2014 let (parent_impurity, weighted_parent) = match data.sample_weight {
2019 Some(sw) => {
2020 let (wc, total) = weighted_class_counts(indices, data.y, data.n_classes, sw);
2021 (
2022 weighted_compute_impurity::<F>(&wc, total, data.criterion),
2023 Some((wc, total, sw)),
2024 )
2025 }
2026 None => (
2027 compute_impurity::<F>(&parent_counts, n, data.criterion),
2028 None,
2029 ),
2030 };
2031
2032 let mut best_score = F::neg_infinity();
2033 let mut best_feature = 0;
2034 let mut best_threshold = F::zero();
2035 let mut best_missing_left = false;
2036 let mut best_weighted_n = n_f;
2040
2041 let candidate_features: Vec<usize> = match (data.max_features_per_split, rng) {
2048 (Some(k), Some(rng)) => {
2049 let k = k.min(n_features).max(1);
2050 rand_sample_indices(rng, n_features, k).into_vec()
2051 }
2052 _ => match data.feature_indices {
2053 Some(feat) => feat.to_vec(),
2054 None => (0..n_features).collect(),
2055 },
2056 };
2057
2058 let threshold_band = feature_threshold::<F>();
2059
2060 for feat in candidate_features {
2061 let mut sorted_indices: Vec<usize> = indices.to_vec();
2062 sort_indices_by_feature(&mut sorted_indices, data.x, feat);
2066 let n_missing = sorted_indices
2067 .iter()
2068 .filter(|&&i| data.x[[i, feat]].is_nan())
2069 .count();
2070 let n_nonmissing = n - n_missing;
2071 if n_nonmissing == 0 {
2074 continue;
2075 }
2076 let has_missing = n_missing > 0;
2077
2078 let feat_min = data.x[[sorted_indices[0], feat]];
2082 let feat_max = data.x[[sorted_indices[n_nonmissing - 1], feat]];
2083 if feat_max <= feat_min + threshold_band {
2084 continue;
2085 }
2086
2087 let (missing_w_counts, missing_w) = match weighted_parent.as_ref() {
2090 Some((_, _, sw)) => {
2091 let mut mc = vec![F::zero(); data.n_classes];
2092 let mut mw = F::zero();
2093 for &i in &sorted_indices[n_nonmissing..] {
2094 mc[data.y[i]] = mc[data.y[i]] + sw[i];
2095 mw = mw + sw[i];
2096 }
2097 (mc, mw)
2098 }
2099 None => (Vec::new(), F::zero()),
2100 };
2101 let mut missing_counts = vec![0usize; data.n_classes];
2102 for &i in &sorted_indices[n_nonmissing..] {
2103 missing_counts[data.y[i]] += 1;
2104 }
2105
2106 let nonmissing_parent_counts: Vec<usize> = parent_counts
2111 .iter()
2112 .zip(missing_counts.iter())
2113 .map(|(p, m)| p - m)
2114 .collect();
2115 let nonmissing_weighted_parent: Vec<F> = match weighted_parent.as_ref() {
2116 Some((wc, _, _)) => wc
2117 .iter()
2118 .zip(missing_w_counts.iter())
2119 .map(|(p, m)| *p - *m)
2120 .collect(),
2121 None => Vec::new(),
2122 };
2123
2124 let n_searches = if has_missing { 2 } else { 1 };
2129
2130 for search in 0..n_searches {
2131 let missing_to_left = search == 1;
2132
2133 let mut left_counts = vec![0usize; data.n_classes];
2138 let mut right_counts = nonmissing_parent_counts.clone();
2139 let mut left_w_counts = vec![F::zero(); data.n_classes];
2140 let mut right_w_counts = nonmissing_weighted_parent.clone();
2141 let mut left_nm = 0usize;
2142 let mut left_w_nm = F::zero();
2143
2144 for split_pos in 0..n_nonmissing - 1 {
2145 let idx = sorted_indices[split_pos];
2146 let cls = data.y[idx];
2147 left_counts[cls] += 1;
2148 right_counts[cls] -= 1;
2149 left_nm += 1;
2150 if let Some((_, _, sw)) = weighted_parent.as_ref() {
2151 let w = sw[idx];
2152 left_w_counts[cls] = left_w_counts[cls] + w;
2153 right_w_counts[cls] = right_w_counts[cls] - w;
2154 left_w_nm = left_w_nm + w;
2155 }
2156
2157 let next_idx = sorted_indices[split_pos + 1];
2160 if data.x[[next_idx, feat]] <= data.x[[idx, feat]] + threshold_band {
2161 continue;
2162 }
2163
2164 let (left_n, right_n) = if missing_to_left {
2166 (left_nm + n_missing, n_nonmissing - left_nm)
2167 } else {
2168 (left_nm, n_nonmissing - left_nm + n_missing)
2169 };
2170
2171 if left_n < min_samples_leaf || right_n < min_samples_leaf {
2174 continue;
2175 }
2176
2177 let (impurity_decrease, weighted_n) = if let Some((_, total_w, _)) =
2178 weighted_parent.as_ref()
2179 {
2180 let right_w_nm = *total_w - missing_w - left_w_nm;
2183 let (lc, rc, left_w, right_w) = combine_missing_weighted(
2184 &left_w_counts,
2185 &right_w_counts,
2186 left_w_nm,
2187 right_w_nm,
2188 &missing_w_counts,
2189 missing_w,
2190 missing_to_left,
2191 );
2192 if left_w < data.min_weight_leaf || right_w < data.min_weight_leaf {
2195 continue;
2196 }
2197 let left_impurity = weighted_compute_impurity::<F>(&lc, left_w, data.criterion);
2198 let right_impurity =
2199 weighted_compute_impurity::<F>(&rc, right_w, data.criterion);
2200 let denom = if *total_w > F::zero() {
2201 *total_w
2202 } else {
2203 F::one()
2204 };
2205 let weighted_child =
2206 (left_w * left_impurity + right_w * right_impurity) / denom;
2207 (parent_impurity - weighted_child, *total_w)
2208 } else {
2209 let (lc, rc) = combine_missing_counts(
2210 &left_counts,
2211 &right_counts,
2212 &missing_counts,
2213 missing_to_left,
2214 );
2215 let left_impurity = compute_impurity::<F>(&lc, left_n, data.criterion);
2216 let right_impurity = compute_impurity::<F>(&rc, right_n, data.criterion);
2217 let left_weight = F::from(left_n).unwrap_or_else(F::one) / n_f;
2218 let right_weight = F::from(right_n).unwrap_or_else(F::one) / n_f;
2219 let weighted_child_impurity =
2220 left_weight * left_impurity + right_weight * right_impurity;
2221 (parent_impurity - weighted_child_impurity, n_f)
2222 };
2223
2224 if impurity_decrease > best_score {
2225 best_score = impurity_decrease;
2226 best_feature = feat;
2227 best_weighted_n = weighted_n;
2228 best_missing_left = if has_missing { missing_to_left } else { false };
2229 let two = F::from(2.0).unwrap_or_else(F::one);
2230 best_threshold = (data.x[[idx, feat]] + data.x[[next_idx, feat]]) / two;
2231 }
2232 }
2233 }
2234
2235 if has_missing {
2239 let left_n = n_nonmissing;
2240 let right_n = n_missing;
2241 if left_n >= min_samples_leaf && right_n >= min_samples_leaf {
2242 let candidate = if let Some((pwc, total_w, _)) = weighted_parent.as_ref() {
2243 let left_w = *total_w - missing_w;
2244 let right_w = missing_w;
2245 if left_w >= data.min_weight_leaf && right_w >= data.min_weight_leaf {
2246 let mut lc = pwc.clone();
2247 for (c, m) in lc.iter_mut().zip(missing_w_counts.iter()) {
2248 *c = *c - *m;
2249 }
2250 let left_impurity =
2251 weighted_compute_impurity::<F>(&lc, left_w, data.criterion);
2252 let right_impurity = weighted_compute_impurity::<F>(
2253 &missing_w_counts,
2254 right_w,
2255 data.criterion,
2256 );
2257 let denom = if *total_w > F::zero() {
2258 *total_w
2259 } else {
2260 F::one()
2261 };
2262 let weighted_child =
2263 (left_w * left_impurity + right_w * right_impurity) / denom;
2264 Some((parent_impurity - weighted_child, *total_w))
2265 } else {
2266 None
2267 }
2268 } else {
2269 let mut lc = parent_counts.clone();
2270 for (c, m) in lc.iter_mut().zip(missing_counts.iter()) {
2271 *c -= *m;
2272 }
2273 let left_impurity = compute_impurity::<F>(&lc, left_n, data.criterion);
2274 let right_impurity =
2275 compute_impurity::<F>(&missing_counts, right_n, data.criterion);
2276 let left_weight = F::from(left_n).unwrap_or_else(F::one) / n_f;
2277 let right_weight = F::from(right_n).unwrap_or_else(F::one) / n_f;
2278 let weighted_child_impurity =
2279 left_weight * left_impurity + right_weight * right_impurity;
2280 Some((parent_impurity - weighted_child_impurity, n_f))
2281 };
2282
2283 if let Some((decrease, weighted_n)) = candidate
2284 && decrease > best_score
2285 {
2286 best_score = decrease;
2287 best_feature = feat;
2288 best_weighted_n = weighted_n;
2289 best_missing_left = false;
2290 best_threshold = F::infinity();
2291 }
2292 }
2293 }
2294 }
2295
2296 if best_score >= F::zero() {
2305 Some((
2312 best_feature,
2313 best_threshold,
2314 best_score * best_weighted_n,
2315 best_missing_left,
2316 ))
2317 } else {
2318 None
2319 }
2320}
2321
2322fn combine_missing_counts(
2325 left_counts: &[usize],
2326 right_counts: &[usize],
2327 missing_counts: &[usize],
2328 missing_to_left: bool,
2329) -> (Vec<usize>, Vec<usize>) {
2330 let mut lc = left_counts.to_vec();
2331 let mut rc = right_counts.to_vec();
2332 if missing_to_left {
2333 for (c, m) in lc.iter_mut().zip(missing_counts.iter()) {
2334 *c += *m;
2335 }
2336 } else {
2337 for (c, m) in rc.iter_mut().zip(missing_counts.iter()) {
2338 *c += *m;
2339 }
2340 }
2341 (lc, rc)
2342}
2343
2344#[allow(
2347 clippy::too_many_arguments,
2348 reason = "threads both children's weighted counts + masses plus the missing block"
2349)]
2350fn combine_missing_weighted<F: Float>(
2351 left_w_counts: &[F],
2352 right_w_counts: &[F],
2353 left_w: F,
2354 right_w: F,
2355 missing_w_counts: &[F],
2356 missing_w: F,
2357 missing_to_left: bool,
2358) -> (Vec<F>, Vec<F>, F, F) {
2359 let mut lc = left_w_counts.to_vec();
2360 let mut rc = right_w_counts.to_vec();
2361 if missing_to_left {
2362 for (c, m) in lc.iter_mut().zip(missing_w_counts.iter()) {
2363 *c = *c + *m;
2364 }
2365 (lc, rc, left_w + missing_w, right_w)
2366 } else {
2367 for (c, m) in rc.iter_mut().zip(missing_w_counts.iter()) {
2368 *c = *c + *m;
2369 }
2370 (lc, rc, left_w, right_w + missing_w)
2371 }
2372}
2373
2374fn partition_with_missing<F: Float>(
2386 indices: &[usize],
2387 x: &Array2<F>,
2388 feature: usize,
2389 threshold: F,
2390 missing_go_to_left: bool,
2391) -> (Vec<usize>, Vec<usize>) {
2392 indices.iter().partition(|&&i| {
2393 let v = x[[i, feature]];
2394 if v.is_nan() {
2395 missing_go_to_left
2396 } else {
2397 v <= threshold
2398 }
2399 })
2400}
2401
2402fn push_regression_leaf<F: Float>(
2405 nodes: &mut Vec<Node<F>>,
2406 meta: Option<&mut Vec<NodeMeta<F>>>,
2407 value: F,
2408 impurity: F,
2409 n_samples: usize,
2410) -> usize {
2411 let idx = nodes.len();
2412 nodes.push(Node::Leaf {
2413 value,
2414 class_distribution: None,
2415 n_samples,
2416 });
2417 if let Some(meta) = meta {
2418 meta.push(NodeMeta {
2419 impurity,
2420 n_samples,
2421 value,
2422 distribution: None,
2423 missing_go_to_left: false,
2425 });
2426 }
2427 idx
2428}
2429
2430#[allow(
2432 clippy::too_many_arguments,
2433 reason = "recursive builder threads data/nodes/prune-meta/params/gate/rng; bundling would obscure the recursion"
2434)]
2435fn build_regression_tree<F: Float>(
2436 data: &RegressionData<'_, F>,
2437 indices: &[usize],
2438 nodes: &mut Vec<Node<F>>,
2439 mut meta: Option<&mut Vec<NodeMeta<F>>>,
2440 depth: usize,
2441 params: &TreeParams,
2442 gate: &ImpurityGate<F>,
2443 mut rng: Option<&mut StdRng>,
2444) -> usize {
2445 let n = indices.len();
2446 let leaf_value = regression_leaf_value(data.y, indices, data.criterion);
2449
2450 let should_stop = n < params.min_samples_split || params.max_depth.is_some_and(|d| depth >= d);
2451
2452 if should_stop {
2453 let imp = meta.as_deref().map_or(F::zero(), |_| {
2456 regression_node_impurity(data.y, indices, data.criterion)
2457 });
2458 return push_regression_leaf(nodes, meta, leaf_value, imp, n);
2459 }
2460
2461 let parent_impurity = regression_node_impurity(data.y, indices, data.criterion);
2462 if parent_impurity <= F::epsilon() {
2463 return push_regression_leaf(nodes, meta, leaf_value, parent_impurity, n);
2464 }
2465
2466 let best =
2467 find_best_regression_split(data, indices, params.min_samples_leaf, rng.as_deref_mut());
2468
2469 let gated = best.filter(|&(_, _, best_impurity_decrease, _)| {
2478 let denom = match data.criterion {
2479 RegressionCriterion::FriedmanMse => n,
2480 _ => gate.n_total,
2481 };
2482 let denom_f = F::from(denom).unwrap_or_else(F::one);
2483 let improvement = best_impurity_decrease / denom_f;
2484 !gate.rejects(improvement)
2485 });
2486
2487 if let Some((best_feature, best_threshold, best_impurity_decrease, missing_go_to_left)) = gated
2488 {
2489 let (left_indices, right_indices): (Vec<usize>, Vec<usize>) = partition_with_missing(
2490 indices,
2491 data.x,
2492 best_feature,
2493 best_threshold,
2494 missing_go_to_left,
2495 );
2496
2497 let node_idx = nodes.len();
2498 nodes.push(Node::Leaf {
2499 value: F::zero(),
2500 class_distribution: None,
2501 n_samples: 0,
2502 }); if let Some(meta) = meta.as_deref_mut() {
2506 meta.push(NodeMeta {
2507 impurity: F::zero(),
2508 n_samples: 0,
2509 value: F::zero(),
2510 distribution: None,
2511 missing_go_to_left: false,
2512 });
2513 }
2514
2515 let left_idx = build_regression_tree(
2516 data,
2517 &left_indices,
2518 nodes,
2519 meta.as_deref_mut(),
2520 depth + 1,
2521 params,
2522 gate,
2523 rng.as_deref_mut(),
2524 );
2525 let right_idx = build_regression_tree(
2526 data,
2527 &right_indices,
2528 nodes,
2529 meta.as_deref_mut(),
2530 depth + 1,
2531 params,
2532 gate,
2533 rng,
2534 );
2535
2536 nodes[node_idx] = Node::Split {
2537 feature: best_feature,
2538 threshold: best_threshold,
2539 left: left_idx,
2540 right: right_idx,
2541 impurity_decrease: best_impurity_decrease,
2542 n_samples: n,
2543 };
2544
2545 if let Some(meta) = meta {
2546 meta[node_idx] = NodeMeta {
2547 impurity: parent_impurity,
2548 n_samples: n,
2549 value: leaf_value,
2550 distribution: None,
2551 missing_go_to_left,
2552 };
2553 }
2554
2555 node_idx
2556 } else {
2557 push_regression_leaf(nodes, meta, leaf_value, parent_impurity, n)
2558 }
2559}
2560
2561fn find_best_regression_split<F: Float>(
2568 data: &RegressionData<'_, F>,
2569 indices: &[usize],
2570 min_samples_leaf: usize,
2571 rng: Option<&mut StdRng>,
2572) -> Option<(usize, F, F, bool)> {
2573 let n = indices.len();
2574 let n_f = F::from(n).unwrap_or_else(F::one);
2575 let n_features = data.x.ncols();
2576
2577 let parent_sum: F = indices
2578 .iter()
2579 .map(|&i| data.y[i])
2580 .fold(F::zero(), |a, b| a + b);
2581 let parent_sum_sq: F = indices
2582 .iter()
2583 .map(|&i| data.y[i] * data.y[i])
2584 .fold(F::zero(), |a, b| a + b);
2585 let parent_mse = parent_sum_sq / n_f - (parent_sum / n_f) * (parent_sum / n_f);
2586 let parent_impurity = regression_node_impurity(data.y, indices, data.criterion);
2590
2591 let mut best_score = F::neg_infinity();
2592 let mut best_feature = 0;
2593 let mut best_threshold = F::zero();
2594 let mut best_missing_left = false;
2595
2596 let candidate_features: Vec<usize> = match (data.max_features_per_split, rng) {
2597 (Some(k), Some(rng)) => {
2598 let k = k.min(n_features).max(1);
2599 rand_sample_indices(rng, n_features, k).into_vec()
2600 }
2601 _ => match data.feature_indices {
2602 Some(feat) => feat.to_vec(),
2603 None => (0..n_features).collect(),
2604 },
2605 };
2606
2607 let threshold_band = feature_threshold::<F>();
2608
2609 for feat in candidate_features {
2610 let mut sorted_indices: Vec<usize> = indices.to_vec();
2611 sort_indices_by_feature(&mut sorted_indices, data.x, feat);
2614 let n_missing = sorted_indices
2615 .iter()
2616 .filter(|&&i| data.x[[i, feat]].is_nan())
2617 .count();
2618 let n_nonmissing = n - n_missing;
2619 if n_nonmissing == 0 {
2620 continue;
2621 }
2622 let has_missing = n_missing > 0;
2623 let missing_slice = &sorted_indices[n_nonmissing..];
2624
2625 let feat_min = data.x[[sorted_indices[0], feat]];
2628 let feat_max = data.x[[sorted_indices[n_nonmissing - 1], feat]];
2629 if feat_max <= feat_min + threshold_band {
2630 continue;
2631 }
2632
2633 let missing_sum: F = missing_slice
2635 .iter()
2636 .map(|&i| data.y[i])
2637 .fold(F::zero(), |a, b| a + b);
2638 let missing_sum_sq: F = missing_slice
2639 .iter()
2640 .map(|&i| data.y[i] * data.y[i])
2641 .fold(F::zero(), |a, b| a + b);
2642
2643 let n_searches = if has_missing { 2 } else { 1 };
2647
2648 for search in 0..n_searches {
2649 let missing_to_left = search == 1;
2650
2651 let mut left_sum = F::zero();
2652 let mut left_sum_sq = F::zero();
2653 let mut left_nm: usize = 0;
2654
2655 for split_pos in 0..n_nonmissing - 1 {
2656 let idx = sorted_indices[split_pos];
2657 let val = data.y[idx];
2658 left_sum = left_sum + val;
2659 left_sum_sq = left_sum_sq + val * val;
2660 left_nm += 1;
2661
2662 let next_idx = sorted_indices[split_pos + 1];
2665 if data.x[[next_idx, feat]] <= data.x[[idx, feat]] + threshold_band {
2666 continue;
2667 }
2668
2669 let (left_n, right_n) = if missing_to_left {
2671 (left_nm + n_missing, n_nonmissing - left_nm)
2672 } else {
2673 (left_nm, n_nonmissing - left_nm + n_missing)
2674 };
2675 if left_n < min_samples_leaf || right_n < min_samples_leaf {
2676 continue;
2677 }
2678
2679 let score = regression_split_score(
2680 data,
2681 &sorted_indices,
2682 n_nonmissing,
2683 missing_slice,
2684 left_nm,
2685 left_sum,
2686 left_sum_sq,
2687 missing_sum,
2688 missing_sum_sq,
2689 parent_sum,
2690 parent_sum_sq,
2691 parent_mse,
2692 parent_impurity,
2693 n_f,
2694 missing_to_left,
2695 );
2696
2697 if score > best_score {
2698 best_score = score;
2699 best_feature = feat;
2700 best_missing_left = if has_missing { missing_to_left } else { false };
2701 best_threshold = (data.x[[idx, feat]] + data.x[[next_idx, feat]])
2702 / F::from(2.0).unwrap_or_else(F::one);
2703 }
2704 }
2705 }
2706
2707 if has_missing {
2710 let left_n = n_nonmissing;
2711 let right_n = n_missing;
2712 if left_n >= min_samples_leaf && right_n >= min_samples_leaf {
2713 let left_slice = &sorted_indices[..n_nonmissing];
2714 let nm_sum = parent_sum - missing_sum;
2715 let nm_sum_sq = parent_sum_sq - missing_sum_sq;
2716 let score = regression_partitioned_score(
2717 data,
2718 left_slice,
2719 missing_slice,
2720 nm_sum,
2721 nm_sum_sq,
2722 missing_sum,
2723 missing_sum_sq,
2724 parent_mse,
2725 parent_impurity,
2726 n_f,
2727 );
2728 if score > best_score {
2729 best_score = score;
2730 best_feature = feat;
2731 best_missing_left = false;
2732 best_threshold = F::infinity();
2733 }
2734 }
2735 }
2736 }
2737
2738 if best_score > F::zero() {
2739 Some((
2740 best_feature,
2741 best_threshold,
2742 best_score * n_f,
2743 best_missing_left,
2744 ))
2745 } else {
2746 None
2747 }
2748}
2749
2750#[allow(
2763 clippy::too_many_arguments,
2764 reason = "threads running sums + the missing block + parent stats for all four criteria"
2765)]
2766fn regression_split_score<F: Float>(
2767 data: &RegressionData<'_, F>,
2768 sorted_indices: &[usize],
2769 n_nonmissing: usize,
2770 missing_slice: &[usize],
2771 left_nm: usize,
2772 left_sum: F,
2773 left_sum_sq: F,
2774 missing_sum: F,
2775 missing_sum_sq: F,
2776 parent_sum: F,
2777 parent_sum_sq: F,
2778 parent_mse: F,
2779 parent_impurity: F,
2780 n_f: F,
2781 missing_to_left: bool,
2782) -> F {
2783 let right_nm_sum = parent_sum - left_sum - missing_sum;
2786 let right_nm_sum_sq = parent_sum_sq - left_sum_sq - missing_sum_sq;
2787
2788 let (l_sum, l_sum_sq, r_sum, r_sum_sq) = if missing_to_left {
2790 (
2791 left_sum + missing_sum,
2792 left_sum_sq + missing_sum_sq,
2793 right_nm_sum,
2794 right_nm_sum_sq,
2795 )
2796 } else {
2797 (
2798 left_sum,
2799 left_sum_sq,
2800 right_nm_sum + missing_sum,
2801 right_nm_sum_sq + missing_sum_sq,
2802 )
2803 };
2804
2805 match data.criterion {
2806 RegressionCriterion::Mse | RegressionCriterion::FriedmanMse => {
2807 let nm_left = left_nm
2808 + if missing_to_left {
2809 missing_slice.len()
2810 } else {
2811 0
2812 };
2813 let nm_right = (n_nonmissing - left_nm)
2814 + if missing_to_left {
2815 0
2816 } else {
2817 missing_slice.len()
2818 };
2819 let left_n_f = F::from(nm_left).unwrap_or_else(F::one);
2820 let right_n_f = F::from(nm_right).unwrap_or_else(F::one);
2821 match data.criterion {
2822 RegressionCriterion::FriedmanMse => {
2823 let diff = right_n_f * l_sum - left_n_f * r_sum;
2824 diff * diff / (left_n_f * right_n_f * n_f)
2825 }
2826 _ => {
2827 let left_mean = l_sum / left_n_f;
2828 let left_mse = l_sum_sq / left_n_f - left_mean * left_mean;
2829 let right_mean = r_sum / right_n_f;
2830 let right_mse = r_sum_sq / right_n_f - right_mean * right_mean;
2831 let weighted_child_mse = (left_n_f * left_mse + right_n_f * right_mse) / n_f;
2832 parent_mse - weighted_child_mse
2833 }
2834 }
2835 }
2836 RegressionCriterion::AbsoluteError | RegressionCriterion::Poisson => {
2837 let (left_idx, right_idx) =
2840 build_regression_children(sorted_indices, n_nonmissing, left_nm, missing_to_left);
2841 regression_partitioned_score(
2842 data,
2843 &left_idx,
2844 &right_idx,
2845 F::zero(),
2846 F::zero(),
2847 F::zero(),
2848 F::zero(),
2849 parent_mse,
2850 parent_impurity,
2851 n_f,
2852 )
2853 }
2854 }
2855}
2856
2857fn build_regression_children(
2861 sorted_indices: &[usize],
2862 n_nonmissing: usize,
2863 left_nm: usize,
2864 missing_to_left: bool,
2865) -> (Vec<usize>, Vec<usize>) {
2866 let nm_left = &sorted_indices[..left_nm];
2867 let nm_right = &sorted_indices[left_nm..n_nonmissing];
2868 let missing = &sorted_indices[n_nonmissing..];
2869 let mut left: Vec<usize> = nm_left.to_vec();
2870 let mut right: Vec<usize> = nm_right.to_vec();
2871 if missing_to_left {
2872 left.extend_from_slice(missing);
2873 } else {
2874 right.extend_from_slice(missing);
2875 }
2876 (left, right)
2877}
2878
2879#[allow(
2885 clippy::too_many_arguments,
2886 reason = "shared scorer over explicit child index lists for all four criteria"
2887)]
2888fn regression_partitioned_score<F: Float>(
2889 data: &RegressionData<'_, F>,
2890 left_idx: &[usize],
2891 right_idx: &[usize],
2892 _left_sum: F,
2893 _left_sum_sq: F,
2894 _missing_sum: F,
2895 _missing_sum_sq: F,
2896 parent_mse: F,
2897 parent_impurity: F,
2898 n_f: F,
2899) -> F {
2900 let left_n_f = F::from(left_idx.len()).unwrap_or_else(F::one);
2901 let right_n_f = F::from(right_idx.len()).unwrap_or_else(F::one);
2902 match data.criterion {
2903 RegressionCriterion::Mse => {
2904 let left_mean = mean_value(data.y, left_idx);
2905 let left_mse = mse_for_indices(data.y, left_idx, left_mean);
2906 let right_mean = mean_value(data.y, right_idx);
2907 let right_mse = mse_for_indices(data.y, right_idx, right_mean);
2908 let weighted_child_mse = (left_n_f * left_mse + right_n_f * right_mse) / n_f;
2909 parent_mse - weighted_child_mse
2910 }
2911 RegressionCriterion::FriedmanMse => {
2912 let left_sum = mean_value(data.y, left_idx) * left_n_f;
2913 let right_sum = mean_value(data.y, right_idx) * right_n_f;
2914 let diff = right_n_f * left_sum - left_n_f * right_sum;
2915 diff * diff / (left_n_f * right_n_f * n_f)
2916 }
2917 RegressionCriterion::AbsoluteError => {
2918 let left_mae = mae_for_indices(data.y, left_idx);
2919 let right_mae = mae_for_indices(data.y, right_idx);
2920 let weighted_child_mae = (left_n_f * left_mae + right_n_f * right_mae) / n_f;
2921 parent_impurity - weighted_child_mae
2922 }
2923 RegressionCriterion::Poisson => {
2924 let left_dev = poisson_deviance_for_indices(data.y, left_idx);
2925 let right_dev = poisson_deviance_for_indices(data.y, right_idx);
2926 let weighted_child_dev = (left_n_f * left_dev + right_n_f * right_dev) / n_f;
2927 parent_impurity - weighted_child_dev
2928 }
2929 }
2930}
2931
2932pub(crate) fn compute_feature_importances<F: Float>(
2934 nodes: &[Node<F>],
2935 n_features: usize,
2936 _total_samples: usize,
2937) -> Array1<F> {
2938 let mut importances = Array1::zeros(n_features);
2939 for node in nodes {
2940 if let Node::Split {
2941 feature,
2942 impurity_decrease,
2943 ..
2944 } = node
2945 {
2946 importances[*feature] = importances[*feature] + *impurity_decrease;
2947 }
2948 }
2949 let total: F = importances.iter().copied().fold(F::zero(), |a, b| a + b);
2950 if total > F::zero() {
2951 importances.mapv_inplace(|v| v / total);
2952 }
2953 importances
2954}
2955
2956pub(crate) fn aggregate_tree_importances<F: Float>(
2971 trees: &[Vec<Node<F>>],
2972 feature_indices: Option<&[Vec<usize>]>,
2973 weights: Option<&[F]>,
2974 n_features: usize,
2975) -> Array1<F> {
2976 let mut total_imp = Array1::<F>::zeros(n_features);
2977 for (t, nodes) in trees.iter().enumerate() {
2978 let w = weights.map_or(F::one(), |ws| ws[t]);
2979 for node in nodes {
2980 if let Node::Split {
2981 feature,
2982 impurity_decrease,
2983 ..
2984 } = node
2985 {
2986 let original_feature = match feature_indices {
2987 Some(map) => map[t][*feature],
2988 None => *feature,
2989 };
2990 total_imp[original_feature] = total_imp[original_feature] + w * *impurity_decrease;
2991 }
2992 }
2993 }
2994 let total: F = total_imp.iter().copied().fold(F::zero(), |a, b| a + b);
2995 if total > F::zero() {
2996 total_imp.mapv_inplace(|v| v / total);
2997 }
2998 total_imp
2999}
3000
3001#[allow(clippy::too_many_arguments)]
3009pub(crate) fn build_classification_tree_with_feature_subset<F: Float>(
3010 x: &Array2<F>,
3011 y: &[usize],
3012 n_classes: usize,
3013 indices: &[usize],
3014 feature_indices: &[usize],
3015 params: &TreeParams,
3016 criterion: ClassificationCriterion,
3017) -> Vec<Node<F>> {
3018 let data = ClassificationData {
3019 x,
3020 y,
3021 n_classes,
3022 feature_indices: Some(feature_indices),
3023 max_features_per_split: None,
3024 criterion,
3025 sample_weight: None,
3027 min_weight_leaf: F::zero(),
3028 };
3029 let mut nodes = Vec::new();
3030 let gate = ImpurityGate::disabled(indices.len());
3031 build_classification_tree(&data, indices, &mut nodes, None, 0, params, &gate, None);
3034 nodes
3035}
3036
3037#[allow(clippy::too_many_arguments)]
3058pub(crate) fn build_weighted_classification_tree_with_feature_subset<F: Float>(
3059 x: &Array2<F>,
3060 y: &[usize],
3061 n_classes: usize,
3062 sample_weight: &[F],
3063 feature_indices: &[usize],
3064 params: &TreeParams,
3065 criterion: ClassificationCriterion,
3066) -> Vec<Node<F>> {
3067 let n_samples = y.len();
3068 let data = ClassificationData {
3069 x,
3070 y,
3071 n_classes,
3072 feature_indices: Some(feature_indices),
3073 max_features_per_split: None,
3074 criterion,
3075 sample_weight: Some(sample_weight),
3077 min_weight_leaf: F::zero(),
3080 };
3081 let indices: Vec<usize> = (0..n_samples).collect();
3083 let mut nodes = Vec::new();
3084 let gate = ImpurityGate::disabled(indices.len());
3085 build_classification_tree(&data, &indices, &mut nodes, None, 0, params, &gate, None);
3086 nodes
3087}
3088
3089#[allow(clippy::too_many_arguments)]
3097pub(crate) fn build_classification_tree_per_split_features<F: Float>(
3098 x: &Array2<F>,
3099 y: &[usize],
3100 n_classes: usize,
3101 indices: &[usize],
3102 max_features: usize,
3103 params: &TreeParams,
3104 criterion: ClassificationCriterion,
3105 seed: u64,
3106) -> Vec<Node<F>> {
3107 let data = ClassificationData {
3108 x,
3109 y,
3110 n_classes,
3111 feature_indices: None,
3112 max_features_per_split: Some(max_features),
3113 criterion,
3114 sample_weight: None,
3116 min_weight_leaf: F::zero(),
3117 };
3118 let mut rng = StdRng::seed_from_u64(seed);
3119 let mut nodes = Vec::new();
3120 let gate = ImpurityGate::disabled(indices.len());
3121 build_classification_tree(
3122 &data,
3123 indices,
3124 &mut nodes,
3125 None,
3126 0,
3127 params,
3128 &gate,
3129 Some(&mut rng),
3130 );
3131 nodes
3132}
3133
3134pub(crate) fn build_regression_tree_with_feature_subset<F: Float>(
3136 x: &Array2<F>,
3137 y: &Array1<F>,
3138 indices: &[usize],
3139 feature_indices: &[usize],
3140 params: &TreeParams,
3141) -> Vec<Node<F>> {
3142 let data = RegressionData {
3143 x,
3144 y,
3145 feature_indices: Some(feature_indices),
3146 max_features_per_split: None,
3147 criterion: RegressionCriterion::Mse,
3149 };
3150 let mut nodes = Vec::new();
3151 let gate = ImpurityGate::disabled(indices.len());
3152 build_regression_tree(&data, indices, &mut nodes, None, 0, params, &gate, None);
3154 nodes
3155}
3156
3157pub(crate) fn build_regression_tree_per_split_features<F: Float>(
3162 x: &Array2<F>,
3163 y: &Array1<F>,
3164 indices: &[usize],
3165 max_features: usize,
3166 params: &TreeParams,
3167 seed: u64,
3168) -> Vec<Node<F>> {
3169 let data = RegressionData {
3170 x,
3171 y,
3172 feature_indices: None,
3173 max_features_per_split: Some(max_features),
3174 criterion: RegressionCriterion::Mse,
3176 };
3177 let mut rng = StdRng::seed_from_u64(seed);
3178 let mut nodes = Vec::new();
3179 let gate = ImpurityGate::disabled(indices.len());
3180 build_regression_tree(
3181 &data,
3182 indices,
3183 &mut nodes,
3184 None,
3185 0,
3186 params,
3187 &gate,
3188 Some(&mut rng),
3189 );
3190 nodes
3191}
3192
3193struct FrontierRecord<F> {
3208 arena_idx: usize,
3210 indices: Vec<usize>,
3212 depth: usize,
3214 improvement: F,
3217 split: Option<(usize, F, F, bool)>,
3222 seq: u64,
3226}
3227
3228enum BuildNode<F> {
3232 Split {
3234 feature: usize,
3235 threshold: F,
3236 impurity_decrease: F,
3237 n_samples: usize,
3238 left: usize,
3239 right: usize,
3240 meta: Option<NodeMeta<F>>,
3242 },
3243 Leaf {
3245 value: F,
3246 class_distribution: Option<Vec<F>>,
3247 n_samples: usize,
3248 meta: Option<NodeMeta<F>>,
3249 },
3250}
3251
3252fn serialize_best_first_arena<F: Float>(
3258 arena: &[BuildNode<F>],
3259 record_meta: bool,
3260) -> (Vec<Node<F>>, Vec<NodeMeta<F>>) {
3261 let mut nodes: Vec<Node<F>> = Vec::with_capacity(arena.len());
3262 let mut meta: Vec<NodeMeta<F>> = Vec::new();
3263 if arena.is_empty() {
3264 return (nodes, meta);
3265 }
3266 let mut stack: Vec<(usize, usize)> = Vec::new();
3268 let root_slot = nodes.len();
3269 nodes.push(placeholder_leaf::<F>());
3270 if record_meta {
3271 meta.push(NodeMeta {
3272 impurity: F::zero(),
3273 n_samples: 0,
3274 value: F::zero(),
3275 distribution: None,
3276 missing_go_to_left: false,
3277 });
3278 }
3279 stack.push((0usize, root_slot));
3280
3281 while let Some((arena_idx, slot)) = stack.pop() {
3282 match &arena[arena_idx] {
3283 BuildNode::Leaf {
3284 value,
3285 class_distribution,
3286 n_samples,
3287 meta: node_meta,
3288 } => {
3289 nodes[slot] = Node::Leaf {
3290 value: *value,
3291 class_distribution: class_distribution.clone(),
3292 n_samples: *n_samples,
3293 };
3294 if record_meta && let Some(m) = node_meta {
3295 meta[slot] = m.clone();
3296 }
3297 }
3298 BuildNode::Split {
3299 feature,
3300 threshold,
3301 impurity_decrease,
3302 n_samples,
3303 left,
3304 right,
3305 meta: node_meta,
3306 } => {
3307 let left_slot = nodes.len();
3308 nodes.push(placeholder_leaf::<F>());
3309 let right_slot = nodes.len();
3310 nodes.push(placeholder_leaf::<F>());
3311 if record_meta {
3312 meta.push(NodeMeta {
3313 impurity: F::zero(),
3314 n_samples: 0,
3315 value: F::zero(),
3316 distribution: None,
3317 missing_go_to_left: false,
3318 });
3319 meta.push(NodeMeta {
3320 impurity: F::zero(),
3321 n_samples: 0,
3322 value: F::zero(),
3323 distribution: None,
3324 missing_go_to_left: false,
3325 });
3326 }
3327 nodes[slot] = Node::Split {
3328 feature: *feature,
3329 threshold: *threshold,
3330 left: left_slot,
3331 right: right_slot,
3332 impurity_decrease: *impurity_decrease,
3333 n_samples: *n_samples,
3334 };
3335 if record_meta && let Some(m) = node_meta {
3336 meta[slot] = m.clone();
3337 }
3338 stack.push((*right, right_slot));
3340 stack.push((*left, left_slot));
3341 }
3342 }
3343 }
3344 (nodes, meta)
3345}
3346
3347#[allow(
3356 clippy::type_complexity,
3357 reason = "frontier is a plain Vec scanned linearly; a BinaryHeap would need an Ord wrapper for F"
3358)]
3359fn pop_best_frontier<F: Float>(frontier: &mut Vec<FrontierRecord<F>>) -> Option<FrontierRecord<F>> {
3360 if frontier.is_empty() {
3361 return None;
3362 }
3363 let mut best = 0usize;
3364 for i in 1..frontier.len() {
3365 let cur = &frontier[i];
3366 let cur_best = &frontier[best];
3367 let better = match cur.improvement.partial_cmp(&cur_best.improvement) {
3368 Some(std::cmp::Ordering::Greater) => true,
3369 Some(std::cmp::Ordering::Equal) => cur.seq < cur_best.seq,
3370 _ => false,
3371 };
3372 if better {
3373 best = i;
3374 }
3375 }
3376 Some(frontier.swap_remove(best))
3377}
3378
3379#[allow(
3385 clippy::too_many_arguments,
3386 reason = "mirrors the depth-first builder's argument set plus max_leaf_nodes"
3387)]
3388fn build_classification_tree_best_first<F: Float>(
3389 data: &ClassificationData<'_, F>,
3390 indices: &[usize],
3391 nodes: &mut Vec<Node<F>>,
3392 meta: Option<&mut Vec<NodeMeta<F>>>,
3393 params: &TreeParams,
3394 gate: &ImpurityGate<F>,
3395 max_leaf_nodes: usize,
3396) {
3397 let record_meta = meta.is_some();
3398 let mut arena: Vec<BuildNode<F>> = Vec::new();
3399 let n_total_f = F::from(gate.n_total).unwrap_or_else(F::one);
3400 let mut seq: u64 = 0;
3401
3402 let evaluate =
3406 |arena_idx: usize, node_indices: Vec<usize>, depth: usize, seq: u64| -> FrontierRecord<F> {
3407 let n = node_indices.len();
3408 let mut class_counts = vec![0usize; data.n_classes];
3409 for &i in &node_indices {
3410 class_counts[data.y[i]] += 1;
3411 }
3412 let cannot_split = n < params.min_samples_split
3413 || params.max_depth.is_some_and(|d| depth >= d)
3414 || class_counts.iter().filter(|&&c| c > 0).count() <= 1;
3415
3416 let split = if cannot_split {
3417 None
3418 } else {
3419 find_best_classification_split(data, &node_indices, params.min_samples_leaf, None)
3421 .filter(|&(_, _, best_impurity_decrease, _)| {
3422 let improvement = best_impurity_decrease / n_total_f;
3423 !gate.rejects(improvement)
3424 })
3425 };
3426
3427 let improvement = split.map_or(F::zero(), |(_, _, bid, _)| bid / n_total_f);
3428 FrontierRecord {
3429 arena_idx,
3430 indices: node_indices,
3431 depth,
3432 improvement,
3433 split,
3434 seq,
3435 }
3436 };
3437
3438 arena.push(placeholder_build_leaf::<F>());
3440 let mut frontier: Vec<FrontierRecord<F>> = vec![evaluate(0, indices.to_vec(), 0, seq)];
3441 seq += 1;
3442
3443 let mut max_split_nodes = max_leaf_nodes.saturating_sub(1) as isize;
3447
3448 while let Some(record) = pop_best_frontier(&mut frontier) {
3449 let is_leaf = record.split.is_none() || max_split_nodes <= 0;
3450
3451 if is_leaf {
3452 arena[record.arena_idx] =
3453 make_classification_build_leaf(data, &record.indices, record_meta);
3454 continue;
3455 }
3456
3457 max_split_nodes -= 1;
3459 let (best_feature, best_threshold, best_impurity_decrease, missing_go_to_left) =
3460 match record.split {
3461 Some(s) => s,
3462 None => continue,
3463 };
3464 let (left_indices, right_indices): (Vec<usize>, Vec<usize>) = partition_with_missing(
3465 &record.indices,
3466 data.x,
3467 best_feature,
3468 best_threshold,
3469 missing_go_to_left,
3470 );
3471
3472 let left_slot = arena.len();
3473 arena.push(placeholder_build_leaf::<F>());
3474 let right_slot = arena.len();
3475 arena.push(placeholder_build_leaf::<F>());
3476
3477 let node_meta = if record_meta {
3478 let n = record.indices.len();
3479 let (majority_class, distribution, impurity) = if let Some(sw) = data.sample_weight {
3480 let (wc, total) =
3481 weighted_class_counts(&record.indices, data.y, data.n_classes, sw);
3482 let (mc, dist) = weighted_classification_node_value::<F>(&wc, total);
3483 (
3484 mc,
3485 dist,
3486 weighted_compute_impurity::<F>(&wc, total, data.criterion),
3487 )
3488 } else {
3489 let mut class_counts = vec![0usize; data.n_classes];
3490 for &i in &record.indices {
3491 class_counts[data.y[i]] += 1;
3492 }
3493 let (mc, dist) = classification_node_value::<F>(&class_counts, data.n_classes, n);
3494 (
3495 mc,
3496 dist,
3497 compute_impurity::<F>(&class_counts, n, data.criterion),
3498 )
3499 };
3500 Some(NodeMeta {
3501 impurity,
3502 n_samples: n,
3503 value: F::from(majority_class).unwrap_or_else(F::zero),
3504 distribution: Some(distribution),
3505 missing_go_to_left,
3506 })
3507 } else {
3508 None
3509 };
3510
3511 arena[record.arena_idx] = BuildNode::Split {
3512 feature: best_feature,
3513 threshold: best_threshold,
3514 impurity_decrease: best_impurity_decrease,
3515 n_samples: record.indices.len(),
3516 left: left_slot,
3517 right: right_slot,
3518 meta: node_meta,
3519 };
3520
3521 frontier.push(evaluate(left_slot, left_indices, record.depth + 1, seq));
3522 seq += 1;
3523 frontier.push(evaluate(right_slot, right_indices, record.depth + 1, seq));
3524 seq += 1;
3525 }
3526
3527 let (built, built_meta) = serialize_best_first_arena(&arena, record_meta);
3528 *nodes = built;
3529 if let Some(meta) = meta {
3530 *meta = built_meta;
3531 }
3532}
3533
3534fn make_classification_build_leaf<F: Float>(
3539 data: &ClassificationData<'_, F>,
3540 node_indices: &[usize],
3541 record_meta: bool,
3542) -> BuildNode<F> {
3543 let n = node_indices.len();
3544 let (majority_class, distribution, impurity) = if let Some(sw) = data.sample_weight {
3545 let (wc, total) = weighted_class_counts(node_indices, data.y, data.n_classes, sw);
3546 let (mc, dist) = weighted_classification_node_value::<F>(&wc, total);
3547 (
3548 mc,
3549 dist,
3550 weighted_compute_impurity::<F>(&wc, total, data.criterion),
3551 )
3552 } else {
3553 let mut class_counts = vec![0usize; data.n_classes];
3554 for &i in node_indices {
3555 class_counts[data.y[i]] += 1;
3556 }
3557 let (mc, dist) = classification_node_value::<F>(&class_counts, data.n_classes, n);
3558 (
3559 mc,
3560 dist,
3561 compute_impurity::<F>(&class_counts, n, data.criterion),
3562 )
3563 };
3564 let value = F::from(majority_class).unwrap_or_else(F::zero);
3565 let meta = if record_meta {
3566 Some(NodeMeta {
3567 impurity,
3568 n_samples: n,
3569 value,
3570 distribution: Some(distribution.clone()),
3571 missing_go_to_left: false,
3573 })
3574 } else {
3575 None
3576 };
3577 BuildNode::Leaf {
3578 value,
3579 class_distribution: Some(distribution),
3580 n_samples: n,
3581 meta,
3582 }
3583}
3584
3585#[allow(
3591 clippy::too_many_arguments,
3592 reason = "mirrors the depth-first builder's argument set plus max_leaf_nodes"
3593)]
3594fn build_regression_tree_best_first<F: Float>(
3595 data: &RegressionData<'_, F>,
3596 indices: &[usize],
3597 nodes: &mut Vec<Node<F>>,
3598 meta: Option<&mut Vec<NodeMeta<F>>>,
3599 params: &TreeParams,
3600 gate: &ImpurityGate<F>,
3601 max_leaf_nodes: usize,
3602) {
3603 let record_meta = meta.is_some();
3604 let mut arena: Vec<BuildNode<F>> = Vec::new();
3605 let mut seq: u64 = 0;
3606
3607 let evaluate =
3608 |arena_idx: usize, node_indices: Vec<usize>, depth: usize, seq: u64| -> FrontierRecord<F> {
3609 let n = node_indices.len();
3610 let parent_impurity = regression_node_impurity(data.y, &node_indices, data.criterion);
3611 let cannot_split = n < params.min_samples_split
3612 || params.max_depth.is_some_and(|d| depth >= d)
3613 || parent_impurity <= F::epsilon();
3614
3615 let split = if cannot_split {
3616 None
3617 } else {
3618 find_best_regression_split(data, &node_indices, params.min_samples_leaf, None)
3619 .filter(|&(_, _, best_impurity_decrease, _)| {
3620 let denom = match data.criterion {
3623 RegressionCriterion::FriedmanMse => n,
3624 _ => gate.n_total,
3625 };
3626 let denom_f = F::from(denom).unwrap_or_else(F::one);
3627 let improvement = best_impurity_decrease / denom_f;
3628 !gate.rejects(improvement)
3629 })
3630 };
3631
3632 let improvement = split.map_or(F::zero(), |(feat, threshold, bid, mgl)| {
3641 stable_regression_improvement(
3642 data,
3643 &node_indices,
3644 feat,
3645 threshold,
3646 mgl,
3647 bid,
3648 gate.n_total,
3649 )
3650 });
3651 FrontierRecord {
3652 arena_idx,
3653 indices: node_indices,
3654 depth,
3655 improvement,
3656 split,
3657 seq,
3658 }
3659 };
3660
3661 arena.push(placeholder_build_leaf::<F>());
3662 let mut frontier: Vec<FrontierRecord<F>> = vec![evaluate(0, indices.to_vec(), 0, seq)];
3663 seq += 1;
3664
3665 let mut max_split_nodes = max_leaf_nodes.saturating_sub(1) as isize;
3666
3667 while let Some(record) = pop_best_frontier(&mut frontier) {
3668 let is_leaf = record.split.is_none() || max_split_nodes <= 0;
3669
3670 if is_leaf {
3671 arena[record.arena_idx] =
3672 make_regression_build_leaf(data, &record.indices, record_meta);
3673 continue;
3674 }
3675
3676 max_split_nodes -= 1;
3677 let (best_feature, best_threshold, best_impurity_decrease, missing_go_to_left) =
3678 match record.split {
3679 Some(s) => s,
3680 None => continue,
3681 };
3682 let (left_indices, right_indices): (Vec<usize>, Vec<usize>) = partition_with_missing(
3683 &record.indices,
3684 data.x,
3685 best_feature,
3686 best_threshold,
3687 missing_go_to_left,
3688 );
3689
3690 let left_slot = arena.len();
3691 arena.push(placeholder_build_leaf::<F>());
3692 let right_slot = arena.len();
3693 arena.push(placeholder_build_leaf::<F>());
3694
3695 let node_meta = if record_meta {
3696 Some(NodeMeta {
3697 impurity: regression_node_impurity(data.y, &record.indices, data.criterion),
3698 n_samples: record.indices.len(),
3699 value: regression_leaf_value(data.y, &record.indices, data.criterion),
3700 distribution: None,
3701 missing_go_to_left,
3702 })
3703 } else {
3704 None
3705 };
3706
3707 arena[record.arena_idx] = BuildNode::Split {
3708 feature: best_feature,
3709 threshold: best_threshold,
3710 impurity_decrease: best_impurity_decrease,
3711 n_samples: record.indices.len(),
3712 left: left_slot,
3713 right: right_slot,
3714 meta: node_meta,
3715 };
3716
3717 frontier.push(evaluate(left_slot, left_indices, record.depth + 1, seq));
3718 seq += 1;
3719 frontier.push(evaluate(right_slot, right_indices, record.depth + 1, seq));
3720 seq += 1;
3721 }
3722
3723 let (built, built_meta) = serialize_best_first_arena(&arena, record_meta);
3724 *nodes = built;
3725 if let Some(meta) = meta {
3726 *meta = built_meta;
3727 }
3728}
3729
3730fn stable_regression_improvement<F: Float>(
3743 data: &RegressionData<'_, F>,
3744 node_indices: &[usize],
3745 feature: usize,
3746 threshold: F,
3747 missing_go_to_left: bool,
3748 naive_bid: F,
3749 n_total: usize,
3750) -> F {
3751 let n = node_indices.len();
3752 let n_f = F::from(n).unwrap_or_else(F::one);
3753 let n_total_f = F::from(n_total).unwrap_or_else(F::one);
3754 let (left, right): (Vec<usize>, Vec<usize>) =
3755 partition_with_missing(node_indices, data.x, feature, threshold, missing_go_to_left);
3756 let n_l = F::from(left.len()).unwrap_or_else(F::one);
3757 let n_r = F::from(right.len()).unwrap_or_else(F::one);
3758
3759 match data.criterion {
3760 RegressionCriterion::Mse => {
3761 let parent_var = centered_variance(data.y, node_indices);
3762 let left_var = centered_variance(data.y, &left);
3763 let right_var = centered_variance(data.y, &right);
3764 let inner = parent_var - (n_l / n_f) * left_var - (n_r / n_f) * right_var;
3765 (n_f / n_total_f) * inner
3766 }
3767 RegressionCriterion::FriedmanMse => {
3768 let sum_l = mean_value(data.y, &left) * n_l;
3772 let sum_r = mean_value(data.y, &right) * n_r;
3773 let diff = n_r * sum_l - n_l * sum_r;
3774 diff * diff / (n_l * n_r * n_f)
3775 }
3776 RegressionCriterion::AbsoluteError | RegressionCriterion::Poisson => naive_bid / n_total_f,
3779 }
3780}
3781
3782fn centered_variance<F: Float>(y: &Array1<F>, indices: &[usize]) -> F {
3785 let n = indices.len();
3786 if n == 0 {
3787 return F::zero();
3788 }
3789 let mean = mean_value(y, indices);
3790 let sum_sq: F = indices
3791 .iter()
3792 .map(|&i| {
3793 let d = y[i] - mean;
3794 d * d
3795 })
3796 .fold(F::zero(), |a, b| a + b);
3797 sum_sq / F::from(n).unwrap_or_else(F::one)
3798}
3799
3800fn make_regression_build_leaf<F: Float>(
3802 data: &RegressionData<'_, F>,
3803 node_indices: &[usize],
3804 record_meta: bool,
3805) -> BuildNode<F> {
3806 let n = node_indices.len();
3807 let value = regression_leaf_value(data.y, node_indices, data.criterion);
3808 let meta = if record_meta {
3809 Some(NodeMeta {
3810 impurity: regression_node_impurity(data.y, node_indices, data.criterion),
3811 n_samples: n,
3812 value,
3813 distribution: None,
3814 missing_go_to_left: false,
3816 })
3817 } else {
3818 None
3819 };
3820 BuildNode::Leaf {
3821 value,
3822 class_distribution: None,
3823 n_samples: n,
3824 meta,
3825 }
3826}
3827
3828fn placeholder_build_leaf<F: Float>() -> BuildNode<F> {
3831 BuildNode::Leaf {
3832 value: F::zero(),
3833 class_distribution: None,
3834 n_samples: 0,
3835 meta: None,
3836 }
3837}
3838
3839const CCP_NO_PARENT: usize = usize::MAX;
3846
3847fn prune_ccp<F: Float>(
3868 nodes: &[Node<F>],
3869 meta: &[NodeMeta<F>],
3870 n_total: usize,
3871 ccp_alpha: F,
3872) -> (Vec<Node<F>>, Vec<NodeMeta<F>>) {
3873 let n_nodes = nodes.len();
3874 if n_nodes <= 1 {
3875 return (nodes.to_vec(), meta.to_vec());
3876 }
3877 let total_w = F::from(n_total).unwrap_or_else(F::one);
3878
3879 let mut parent = vec![CCP_NO_PARENT; n_nodes];
3881 let mut r_node = vec![F::zero(); n_nodes];
3882 for (i, node) in nodes.iter().enumerate() {
3883 let m = &meta[i];
3884 r_node[i] = m.impurity * F::from(m.n_samples).unwrap_or_else(F::zero) / total_w;
3885 if let Node::Split { left, right, .. } = node {
3886 parent[*left] = i;
3887 parent[*right] = i;
3888 }
3889 }
3890
3891 let mut leaves_in_subtree = vec![false; n_nodes];
3894 let mut in_subtree = vec![true; n_nodes];
3895 for (i, node) in nodes.iter().enumerate() {
3896 if matches!(node, Node::Leaf { .. }) {
3897 leaves_in_subtree[i] = true;
3898 }
3899 }
3900
3901 let mut r_branch = vec![F::zero(); n_nodes];
3904 let mut n_leaves = vec![0usize; n_nodes];
3905 for leaf in 0..n_nodes {
3906 if !leaves_in_subtree[leaf] {
3907 continue;
3908 }
3909 r_branch[leaf] = r_node[leaf];
3910 let current_r = r_node[leaf];
3911 let mut idx = leaf;
3912 while idx != 0 {
3913 let p = parent[idx];
3914 if p == CCP_NO_PARENT {
3915 break;
3916 }
3917 r_branch[p] = r_branch[p] + current_r;
3918 n_leaves[p] += 1;
3919 idx = p;
3920 }
3921 }
3922
3923 let mut candidate_nodes = vec![false; n_nodes];
3925 for i in 0..n_nodes {
3926 candidate_nodes[i] = !leaves_in_subtree[i];
3927 }
3928
3929 while candidate_nodes[0] {
3931 let mut effective_alpha = F::infinity();
3934 let mut pruned_idx = 0usize;
3935 for i in 0..n_nodes {
3936 if !candidate_nodes[i] {
3937 continue;
3938 }
3939 let denom = n_leaves[i].saturating_sub(1);
3940 if denom == 0 {
3941 continue;
3942 }
3943 let subtree_alpha = (r_node[i] - r_branch[i]) / F::from(denom).unwrap_or_else(F::one);
3944 if subtree_alpha < effective_alpha {
3945 effective_alpha = subtree_alpha;
3946 pruned_idx = i;
3947 }
3948 }
3949
3950 if ccp_alpha < effective_alpha {
3953 break;
3954 }
3955
3956 let mut stack = vec![pruned_idx];
3958 while let Some(idx) = stack.pop() {
3959 if !in_subtree[idx] {
3960 continue;
3961 }
3962 candidate_nodes[idx] = false;
3963 leaves_in_subtree[idx] = false;
3964 in_subtree[idx] = false;
3965 if let Node::Split { left, right, .. } = nodes[idx] {
3966 stack.push(left);
3967 stack.push(right);
3968 }
3969 }
3970 leaves_in_subtree[pruned_idx] = true;
3972 in_subtree[pruned_idx] = true;
3973
3974 let n_pruned_leaves = n_leaves[pruned_idx].saturating_sub(1);
3976 n_leaves[pruned_idx] = 0;
3977 let r_diff = r_node[pruned_idx] - r_branch[pruned_idx];
3978 r_branch[pruned_idx] = r_node[pruned_idx];
3979
3980 let mut idx = parent[pruned_idx];
3981 while idx != CCP_NO_PARENT {
3982 n_leaves[idx] = n_leaves[idx].saturating_sub(n_pruned_leaves);
3983 r_branch[idx] = r_branch[idx] + r_diff;
3984 idx = parent[idx];
3985 }
3986 }
3987
3988 rebuild_pruned_tree(nodes, meta, &in_subtree, &leaves_in_subtree)
3989}
3990
3991fn rebuild_pruned_tree<F: Float>(
4006 nodes: &[Node<F>],
4007 meta: &[NodeMeta<F>],
4008 in_subtree: &[bool],
4009 leaves_in_subtree: &[bool],
4010) -> (Vec<Node<F>>, Vec<NodeMeta<F>>) {
4011 let mut new_nodes: Vec<Node<F>> = Vec::new();
4012 let mut new_meta: Vec<NodeMeta<F>> = Vec::new();
4013 let mut stack: Vec<(usize, usize)> = Vec::new();
4015
4016 let root_slot = new_nodes.len();
4017 new_nodes.push(placeholder_leaf::<F>());
4018 new_meta.push(meta[0].clone());
4019 stack.push((0usize, root_slot));
4020
4021 while let Some((old_idx, slot)) = stack.pop() {
4022 if !in_subtree[old_idx] {
4023 continue;
4024 }
4025 new_meta[slot] = meta[old_idx].clone();
4028 let is_leaf = leaves_in_subtree[old_idx] || matches!(nodes[old_idx], Node::Leaf { .. });
4029 if is_leaf {
4030 let m = &meta[old_idx];
4031 new_nodes[slot] = Node::Leaf {
4032 value: m.value,
4033 class_distribution: m.distribution.clone(),
4034 n_samples: m.n_samples,
4035 };
4036 } else if let Node::Split {
4037 feature,
4038 threshold,
4039 left,
4040 right,
4041 impurity_decrease,
4042 n_samples,
4043 } = nodes[old_idx]
4044 {
4045 let left_slot = new_nodes.len();
4047 new_nodes.push(placeholder_leaf::<F>());
4048 new_meta.push(placeholder_meta::<F>());
4049 let right_slot = new_nodes.len();
4050 new_nodes.push(placeholder_leaf::<F>());
4051 new_meta.push(placeholder_meta::<F>());
4052 new_nodes[slot] = Node::Split {
4053 feature,
4054 threshold,
4055 left: left_slot,
4056 right: right_slot,
4057 impurity_decrease,
4058 n_samples,
4059 };
4060 stack.push((right, right_slot));
4063 stack.push((left, left_slot));
4064 }
4065 }
4066 (new_nodes, new_meta)
4067}
4068
4069fn placeholder_meta<F: Float>() -> NodeMeta<F> {
4071 NodeMeta {
4072 impurity: F::zero(),
4073 n_samples: 0,
4074 value: F::zero(),
4075 distribution: None,
4076 missing_go_to_left: false,
4077 }
4078}
4079
4080fn placeholder_leaf<F: Float>() -> Node<F> {
4082 Node::Leaf {
4083 value: F::zero(),
4084 class_distribution: None,
4085 n_samples: 0,
4086 }
4087}
4088
4089#[cfg(test)]
4094mod tests {
4095 use super::*;
4096 use approx::assert_relative_eq;
4097 use ndarray::array;
4098
4099 #[test]
4102 fn test_classifier_simple_binary() {
4103 let x = Array2::from_shape_vec(
4104 (6, 2),
4105 vec![1.0, 2.0, 2.0, 3.0, 3.0, 3.0, 5.0, 6.0, 6.0, 7.0, 7.0, 8.0],
4106 )
4107 .unwrap();
4108 let y = array![0, 0, 0, 1, 1, 1];
4109
4110 let model = DecisionTreeClassifier::<f64>::new();
4111 let fitted = model.fit(&x, &y).unwrap();
4112 let preds = fitted.predict(&x).unwrap();
4113
4114 assert_eq!(preds.len(), 6);
4115 for i in 0..3 {
4116 assert_eq!(preds[i], 0);
4117 }
4118 for i in 3..6 {
4119 assert_eq!(preds[i], 1);
4120 }
4121 }
4122
4123 #[test]
4124 fn test_classifier_single_class() {
4125 let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
4126 let y = array![0, 0, 0];
4127
4128 let model = DecisionTreeClassifier::<f64>::new();
4129 let fitted = model.fit(&x, &y).unwrap();
4130 let preds = fitted.predict(&x).unwrap();
4131
4132 assert_eq!(preds, array![0, 0, 0]);
4133 }
4134
4135 #[test]
4136 fn test_classifier_max_depth_1() {
4137 let x =
4138 Array2::from_shape_vec((8, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
4139 let y = array![0, 0, 0, 0, 1, 1, 1, 1];
4140
4141 let model = DecisionTreeClassifier::<f64>::new().with_max_depth(Some(1));
4142 let fitted = model.fit(&x, &y).unwrap();
4143 let preds = fitted.predict(&x).unwrap();
4144
4145 for i in 0..4 {
4146 assert_eq!(preds[i], 0);
4147 }
4148 for i in 4..8 {
4149 assert_eq!(preds[i], 1);
4150 }
4151 }
4152
4153 #[test]
4154 fn test_classifier_min_samples_split() {
4155 let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
4156 let y = array![0, 0, 0, 1, 1, 1];
4157
4158 let model = DecisionTreeClassifier::<f64>::new().with_min_samples_split(7);
4159 let fitted = model.fit(&x, &y).unwrap();
4160 let preds = fitted.predict(&x).unwrap();
4161
4162 let majority = preds[0];
4163 for &p in &preds {
4164 assert_eq!(p, majority);
4165 }
4166 }
4167
4168 #[test]
4169 fn test_classifier_min_samples_leaf() {
4170 let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
4171 let y = array![0, 0, 0, 1, 1, 1];
4172
4173 let model = DecisionTreeClassifier::<f64>::new().with_min_samples_leaf(4);
4174 let fitted = model.fit(&x, &y).unwrap();
4175 let preds = fitted.predict(&x).unwrap();
4176
4177 let majority = preds[0];
4178 for &p in &preds {
4179 assert_eq!(p, majority);
4180 }
4181 }
4182
4183 #[test]
4184 fn test_classifier_gini_vs_entropy() {
4185 let x = Array2::from_shape_vec(
4186 (8, 2),
4187 vec![
4188 1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 2.0, 2.0, 5.0, 5.0, 5.0, 6.0, 6.0, 5.0, 6.0, 6.0,
4189 ],
4190 )
4191 .unwrap();
4192 let y = array![0, 0, 0, 0, 1, 1, 1, 1];
4193
4194 let gini_model =
4195 DecisionTreeClassifier::<f64>::new().with_criterion(ClassificationCriterion::Gini);
4196 let entropy_model =
4197 DecisionTreeClassifier::<f64>::new().with_criterion(ClassificationCriterion::Entropy);
4198
4199 let fitted_gini = gini_model.fit(&x, &y).unwrap();
4200 let fitted_entropy = entropy_model.fit(&x, &y).unwrap();
4201
4202 let preds_gini = fitted_gini.predict(&x).unwrap();
4203 let preds_entropy = fitted_entropy.predict(&x).unwrap();
4204
4205 assert_eq!(preds_gini, y);
4206 assert_eq!(preds_entropy, y);
4207 }
4208
4209 #[test]
4210 fn test_classifier_predict_proba() {
4211 let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
4212 let y = array![0, 0, 0, 1, 1, 1];
4213
4214 let model = DecisionTreeClassifier::<f64>::new();
4215 let fitted = model.fit(&x, &y).unwrap();
4216 let proba = fitted.predict_proba(&x).unwrap();
4217
4218 assert_eq!(proba.dim(), (6, 2));
4219 for i in 0..6 {
4220 let row_sum: f64 = proba.row(i).iter().sum();
4221 assert_relative_eq!(row_sum, 1.0, epsilon = 1e-10);
4222 }
4223 }
4224
4225 #[test]
4226 fn test_classifier_shape_mismatch_fit() {
4227 let x = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
4228 let y = array![0, 1];
4229
4230 let model = DecisionTreeClassifier::<f64>::new();
4231 assert!(model.fit(&x, &y).is_err());
4232 }
4233
4234 #[test]
4235 fn test_classifier_shape_mismatch_predict() {
4236 let x =
4237 Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
4238 let y = array![0, 0, 1, 1];
4239
4240 let model = DecisionTreeClassifier::<f64>::new();
4241 let fitted = model.fit(&x, &y).unwrap();
4242
4243 let x_bad = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
4244 assert!(fitted.predict(&x_bad).is_err());
4245 }
4246
4247 #[test]
4248 fn test_classifier_empty_data() {
4249 let x = Array2::<f64>::zeros((0, 2));
4250 let y = Array1::<usize>::zeros(0);
4251
4252 let model = DecisionTreeClassifier::<f64>::new();
4253 assert!(model.fit(&x, &y).is_err());
4254 }
4255
4256 #[test]
4257 fn test_classifier_feature_importances() {
4258 let x = Array2::from_shape_vec(
4259 (8, 2),
4260 vec![
4261 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,
4262 ],
4263 )
4264 .unwrap();
4265 let y = array![0, 0, 0, 0, 1, 1, 1, 1];
4266
4267 let model = DecisionTreeClassifier::<f64>::new();
4268 let fitted = model.fit(&x, &y).unwrap();
4269 let importances = fitted.feature_importances();
4270
4271 assert_eq!(importances.len(), 2);
4272 assert!(importances[0] > 0.0);
4273 let sum: f64 = importances.iter().sum();
4274 assert_relative_eq!(sum, 1.0, epsilon = 1e-10);
4275 }
4276
4277 #[test]
4278 fn test_classifier_has_classes() {
4279 let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
4280 let y = array![0, 1, 2, 0, 1, 2];
4281
4282 let model = DecisionTreeClassifier::<f64>::new();
4283 let fitted = model.fit(&x, &y).unwrap();
4284
4285 assert_eq!(fitted.classes(), &[0, 1, 2]);
4286 assert_eq!(fitted.n_classes(), 3);
4287 }
4288
4289 #[test]
4290 fn test_classifier_invalid_min_samples_split() {
4291 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
4292 let y = array![0, 0, 1, 1];
4293
4294 let model = DecisionTreeClassifier::<f64>::new().with_min_samples_split(1);
4295 assert!(model.fit(&x, &y).is_err());
4296 }
4297
4298 #[test]
4299 fn test_classifier_invalid_min_samples_leaf() {
4300 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
4301 let y = array![0, 0, 1, 1];
4302
4303 let model = DecisionTreeClassifier::<f64>::new().with_min_samples_leaf(0);
4304 assert!(model.fit(&x, &y).is_err());
4305 }
4306
4307 #[test]
4308 fn test_classifier_multiclass() {
4309 let x = Array2::from_shape_vec((9, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0])
4310 .unwrap();
4311 let y = array![0, 0, 0, 1, 1, 1, 2, 2, 2];
4312
4313 let model = DecisionTreeClassifier::<f64>::new();
4314 let fitted = model.fit(&x, &y).unwrap();
4315 let preds = fitted.predict(&x).unwrap();
4316
4317 assert_eq!(preds, y);
4318 }
4319
4320 #[test]
4321 fn test_classifier_pipeline_integration() {
4322 let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
4323 let y = Array1::from_vec(vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0]);
4324
4325 let model = DecisionTreeClassifier::<f64>::new();
4326 let fitted = model.fit_pipeline(&x, &y).unwrap();
4327 let preds = fitted.predict_pipeline(&x).unwrap();
4328 assert_eq!(preds.len(), 6);
4329 }
4330
4331 #[test]
4334 fn test_regressor_simple() {
4335 let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
4336 let y = array![1.0, 2.0, 3.0, 4.0, 5.0];
4337
4338 let model = DecisionTreeRegressor::<f64>::new();
4339 let fitted = model.fit(&x, &y).unwrap();
4340 let preds = fitted.predict(&x).unwrap();
4341
4342 for (p, &actual) in preds.iter().zip(y.iter()) {
4343 assert_relative_eq!(*p, actual, epsilon = 1e-10);
4344 }
4345 }
4346
4347 #[test]
4348 fn test_regressor_max_depth() {
4349 let x =
4350 Array2::from_shape_vec((8, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
4351 let y = array![1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0];
4352
4353 let model = DecisionTreeRegressor::<f64>::new().with_max_depth(Some(1));
4354 let fitted = model.fit(&x, &y).unwrap();
4355 let preds = fitted.predict(&x).unwrap();
4356
4357 for i in 0..4 {
4358 assert_relative_eq!(preds[i], 1.0, epsilon = 1e-10);
4359 }
4360 for i in 4..8 {
4361 assert_relative_eq!(preds[i], 5.0, epsilon = 1e-10);
4362 }
4363 }
4364
4365 #[test]
4366 fn test_regressor_constant_target() {
4367 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
4368 let y = array![3.0, 3.0, 3.0, 3.0];
4369
4370 let model = DecisionTreeRegressor::<f64>::new();
4371 let fitted = model.fit(&x, &y).unwrap();
4372 let preds = fitted.predict(&x).unwrap();
4373
4374 for &p in &preds {
4375 assert_relative_eq!(p, 3.0, epsilon = 1e-10);
4376 }
4377 }
4378
4379 #[test]
4380 fn test_regressor_shape_mismatch_fit() {
4381 let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
4382 let y = array![1.0, 2.0];
4383
4384 let model = DecisionTreeRegressor::<f64>::new();
4385 assert!(model.fit(&x, &y).is_err());
4386 }
4387
4388 #[test]
4389 fn test_regressor_shape_mismatch_predict() {
4390 let x =
4391 Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
4392 let y = array![1.0, 2.0, 3.0, 4.0];
4393
4394 let model = DecisionTreeRegressor::<f64>::new();
4395 let fitted = model.fit(&x, &y).unwrap();
4396
4397 let x_bad = Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
4398 assert!(fitted.predict(&x_bad).is_err());
4399 }
4400
4401 #[test]
4402 fn test_regressor_empty_data() {
4403 let x = Array2::<f64>::zeros((0, 2));
4404 let y = Array1::<f64>::zeros(0);
4405
4406 let model = DecisionTreeRegressor::<f64>::new();
4407 assert!(model.fit(&x, &y).is_err());
4408 }
4409
4410 #[test]
4411 fn test_regressor_feature_importances() {
4412 let x = Array2::from_shape_vec(
4413 (8, 2),
4414 vec![
4415 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,
4416 ],
4417 )
4418 .unwrap();
4419 let y = array![1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0];
4420
4421 let model = DecisionTreeRegressor::<f64>::new();
4422 let fitted = model.fit(&x, &y).unwrap();
4423 let importances = fitted.feature_importances();
4424
4425 assert_eq!(importances.len(), 2);
4426 assert!(importances[0] > 0.0);
4427 let sum: f64 = importances.iter().sum();
4428 assert_relative_eq!(sum, 1.0, epsilon = 1e-10);
4429 }
4430
4431 #[test]
4432 fn test_regressor_min_samples_split() {
4433 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
4434 let y = array![1.0, 2.0, 3.0, 4.0];
4435
4436 let model = DecisionTreeRegressor::<f64>::new().with_min_samples_split(5);
4437 let fitted = model.fit(&x, &y).unwrap();
4438 let preds = fitted.predict(&x).unwrap();
4439
4440 let mean = 2.5;
4441 for &p in &preds {
4442 assert_relative_eq!(p, mean, epsilon = 1e-10);
4443 }
4444 }
4445
4446 #[test]
4447 fn test_regressor_pipeline_integration() {
4448 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
4449 let y = array![1.0, 2.0, 3.0, 4.0];
4450
4451 let model = DecisionTreeRegressor::<f64>::new();
4452 let fitted = model.fit_pipeline(&x, &y).unwrap();
4453 let preds = fitted.predict_pipeline(&x).unwrap();
4454 assert_eq!(preds.len(), 4);
4455 }
4456
4457 #[test]
4458 fn test_regressor_f32_support() {
4459 let x = Array2::from_shape_vec((4, 1), vec![1.0f32, 2.0, 3.0, 4.0]).unwrap();
4460 let y = Array1::from_vec(vec![1.0f32, 2.0, 3.0, 4.0]);
4461
4462 let model = DecisionTreeRegressor::<f32>::new();
4463 let fitted = model.fit(&x, &y).unwrap();
4464 let preds = fitted.predict(&x).unwrap();
4465 assert_eq!(preds.len(), 4);
4466 }
4467
4468 #[test]
4469 fn test_classifier_f32_support() {
4470 let x = Array2::from_shape_vec((6, 1), vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
4471 let y = array![0, 0, 0, 1, 1, 1];
4472
4473 let model = DecisionTreeClassifier::<f32>::new();
4474 let fitted = model.fit(&x, &y).unwrap();
4475 let preds = fitted.predict(&x).unwrap();
4476 assert_eq!(preds.len(), 6);
4477 }
4478
4479 #[test]
4482 fn test_gini_impurity_pure() {
4483 let counts = vec![5, 0];
4484 let imp: f64 = gini_impurity(&counts, 5);
4485 assert_relative_eq!(imp, 0.0, epsilon = 1e-10);
4486 }
4487
4488 #[test]
4489 fn test_gini_impurity_balanced() {
4490 let counts = vec![5, 5];
4491 let imp: f64 = gini_impurity(&counts, 10);
4492 assert_relative_eq!(imp, 0.5, epsilon = 1e-10);
4493 }
4494
4495 #[test]
4496 fn test_entropy_pure() {
4497 let counts = vec![5, 0];
4498 let ent: f64 = entropy_impurity(&counts, 5);
4499 assert_relative_eq!(ent, 0.0, epsilon = 1e-10);
4500 }
4501
4502 #[test]
4503 fn test_entropy_balanced() {
4504 let counts = vec![5, 5];
4505 let ent: f64 = entropy_impurity(&counts, 10);
4506 assert_relative_eq!(ent, 2.0f64.ln(), epsilon = 1e-10);
4507 }
4508
4509 fn reg_alt_fixture() -> (Array2<f64>, Array1<f64>) {
4520 let x = array![[1.0], [2.0], [3.0], [4.0], [5.0], [6.0], [7.0], [8.0]];
4521 let y = array![1.0, 1.2, 0.9, 1.1, 5.0, 5.2, 4.9, 5.1];
4522 (x, y)
4523 }
4524
4525 fn reg_root_split(fitted: &FittedDecisionTreeRegressor<f64>) -> Option<(usize, f64)> {
4528 if let Node::Split {
4529 feature, threshold, ..
4530 } = fitted.nodes()[0]
4531 {
4532 Some((feature, threshold))
4533 } else {
4534 None
4535 }
4536 }
4537
4538 fn assert_reg_predict(
4540 fitted: &FittedDecisionTreeRegressor<f64>,
4541 x: &Array2<f64>,
4542 expected: &[f64],
4543 ) {
4544 let res = fitted.predict(x);
4545 assert!(res.is_ok(), "predict failed: {:?}", res.as_ref().err());
4546 let preds = res.unwrap_or_else(|_| Array1::zeros(0));
4547 for (p, e) in preds.iter().zip(expected.iter()) {
4548 assert_relative_eq!(*p, *e, epsilon = 1e-9);
4549 }
4550 }
4551
4552 #[test]
4565 fn test_classifier_log_loss_is_entropy_alias() {
4566 let x = array![
4567 [1.0, 2.0],
4568 [2.0, 3.0],
4569 [3.0, 3.0],
4570 [5.0, 6.0],
4571 [6.0, 7.0],
4572 [7.0, 8.0],
4573 [1.5, 5.0],
4574 [6.5, 2.0],
4575 [3.0, 1.0]
4576 ];
4577 let y = array![0usize, 0, 0, 1, 1, 1, 2, 2, 0];
4578
4579 let entropy = match DecisionTreeClassifier::<f64>::new()
4580 .with_criterion(ClassificationCriterion::Entropy)
4581 .fit(&x, &y)
4582 {
4583 Ok(f) => f,
4584 Err(e) => {
4585 #[allow(
4586 clippy::assertions_on_constants,
4587 reason = "test-only failure-path assertion; no cheap fitted-model fallback"
4588 )]
4589 {
4590 assert!(false, "entropy fit failed: {e}");
4591 }
4592 return;
4593 }
4594 };
4595 let log_loss = match DecisionTreeClassifier::<f64>::new()
4596 .with_criterion(ClassificationCriterion::LogLoss)
4597 .fit(&x, &y)
4598 {
4599 Ok(f) => f,
4600 Err(e) => {
4601 #[allow(
4602 clippy::assertions_on_constants,
4603 reason = "test-only failure-path assertion; no cheap fitted-model fallback"
4604 )]
4605 {
4606 assert!(false, "log_loss fit failed: {e}");
4607 }
4608 return;
4609 }
4610 };
4611
4612 let res_e = entropy.predict(&x);
4613 assert!(res_e.is_ok(), "entropy predict failed");
4614 let pred_e = res_e.unwrap_or_else(|_| Array1::zeros(0));
4615 let res_l = log_loss.predict(&x);
4616 assert!(res_l.is_ok(), "log_loss predict failed");
4617 let pred_l = res_l.unwrap_or_else(|_| Array1::zeros(0));
4618 assert_eq!(pred_e, pred_l, "log_loss predictions must equal entropy");
4620 let fe = entropy.feature_importances();
4621 let fl = log_loss.feature_importances();
4622 for (a, b) in fe.iter().zip(fl.iter()) {
4623 assert_relative_eq!(*a, *b, epsilon = 1e-12);
4624 }
4625 assert_eq!(pred_e, y);
4627 assert!(
4628 matches!(entropy.nodes()[0], Node::Split { .. }),
4629 "expected a split at the root"
4630 );
4631 if let Node::Split {
4632 feature, threshold, ..
4633 } = entropy.nodes()[0]
4634 {
4635 assert_eq!(feature, 1, "entropy root feature (sklearn: 1)");
4636 assert_relative_eq!(threshold, 5.5, epsilon = 1e-9);
4637 }
4638 assert_relative_eq!(fe[0], 0.137_946_433_630_985_85, epsilon = 1e-9);
4639 assert_relative_eq!(fe[1], 0.862_053_566_369_014_2, epsilon = 1e-9);
4640 }
4641
4642 #[test]
4650 fn test_regressor_friedman_mse_oracle() {
4651 let (x, y) = reg_alt_fixture();
4652 let fitted = match DecisionTreeRegressor::<f64>::new()
4653 .with_criterion(RegressionCriterion::FriedmanMse)
4654 .with_max_depth(Some(2))
4655 .fit(&x, &y)
4656 {
4657 Ok(f) => f,
4658 Err(e) => {
4659 #[allow(
4660 clippy::assertions_on_constants,
4661 reason = "test-only failure-path assertion; no cheap fitted-model fallback"
4662 )]
4663 {
4664 assert!(false, "fit failed: {e}");
4665 }
4666 return;
4667 }
4668 };
4669 let root = reg_root_split(&fitted);
4670 assert_eq!(root, Some((0, 4.5)), "friedman_mse root (sklearn: 0, 4.5)");
4671 assert_reg_predict(&fitted, &x, &[1.1, 1.1, 1.0, 1.0, 5.1, 5.1, 5.0, 5.0]);
4672 }
4673
4674 #[test]
4680 fn test_regressor_absolute_error_median_leaves_oracle() {
4681 let (x, y) = reg_alt_fixture();
4682 let fitted = match DecisionTreeRegressor::<f64>::new()
4683 .with_criterion(RegressionCriterion::AbsoluteError)
4684 .with_max_depth(Some(2))
4685 .fit(&x, &y)
4686 {
4687 Ok(f) => f,
4688 Err(e) => {
4689 #[allow(
4690 clippy::assertions_on_constants,
4691 reason = "test-only failure-path assertion; no cheap fitted-model fallback"
4692 )]
4693 {
4694 assert!(false, "fit failed: {e}");
4695 }
4696 return;
4697 }
4698 };
4699 let root = reg_root_split(&fitted);
4700 assert_eq!(
4701 root,
4702 Some((0, 4.5)),
4703 "absolute_error root (sklearn: 0, 4.5)"
4704 );
4705 assert_reg_predict(&fitted, &x, &[1.0, 1.1, 1.1, 1.1, 5.0, 5.1, 5.1, 5.1]);
4707 }
4708
4709 #[test]
4716 fn test_regressor_poisson_oracle() {
4717 let (x, y) = reg_alt_fixture();
4718 let fitted = match DecisionTreeRegressor::<f64>::new()
4719 .with_criterion(RegressionCriterion::Poisson)
4720 .with_max_depth(Some(2))
4721 .fit(&x, &y)
4722 {
4723 Ok(f) => f,
4724 Err(e) => {
4725 #[allow(
4726 clippy::assertions_on_constants,
4727 reason = "test-only failure-path assertion; no cheap fitted-model fallback"
4728 )]
4729 {
4730 assert!(false, "fit failed: {e}");
4731 }
4732 return;
4733 }
4734 };
4735 let root = reg_root_split(&fitted);
4736 assert_eq!(root, Some((0, 4.5)), "poisson root (sklearn: 0, 4.5)");
4737 assert_reg_predict(&fitted, &x, &[1.1, 1.1, 1.0, 1.0, 5.1, 5.1, 5.0, 5.0]);
4738 }
4739
4740 #[test]
4743 fn test_regressor_poisson_rejects_non_positive_y() {
4744 let x = array![[1.0], [2.0], [3.0], [4.0]];
4745 let y_neg = array![1.0, -0.5, 2.0, 3.0];
4746 let res = DecisionTreeRegressor::<f64>::new()
4747 .with_criterion(RegressionCriterion::Poisson)
4748 .fit(&x, &y_neg);
4749 assert!(res.is_err(), "poisson must reject negative y");
4750
4751 let y_zero = array![0.0, 0.0, 0.0, 0.0];
4752 let res0 = DecisionTreeRegressor::<f64>::new()
4753 .with_criterion(RegressionCriterion::Poisson)
4754 .fit(&x, &y_zero);
4755 assert!(res0.is_err(), "poisson must reject sum(y) <= 0");
4756 }
4757
4758 fn clf_prune_fixture() -> (Array2<f64>, Array1<usize>) {
4779 let x = array![
4780 [1.0, 2.0],
4781 [2.0, 3.0],
4782 [3.0, 3.0],
4783 [5.0, 6.0],
4784 [6.0, 7.0],
4785 [7.0, 8.0],
4786 [1.5, 5.0],
4787 [6.5, 2.0],
4788 [3.0, 1.0]
4789 ];
4790 let y = array![0usize, 0, 0, 1, 1, 1, 2, 2, 0];
4791 (x, y)
4792 }
4793
4794 fn assert_clf_predict(
4796 fitted: &FittedDecisionTreeClassifier<f64>,
4797 x: &Array2<f64>,
4798 expected: &[usize],
4799 ) {
4800 let res = fitted.predict(x);
4801 assert!(res.is_ok(), "predict failed: {:?}", res.as_ref().err());
4802 let preds = res.unwrap_or_else(|_| Array1::zeros(0));
4803 assert_eq!(preds.as_slice().unwrap_or(&[]), expected);
4804 }
4805
4806 #[test]
4809 fn test_classifier_min_impurity_decrease_default_node_count_7() {
4810 let (x, y) = clf_prune_fixture();
4811 let fitted = match DecisionTreeClassifier::<f64>::new().fit(&x, &y) {
4812 Ok(f) => f,
4813 Err(e) => {
4814 #[allow(
4815 clippy::assertions_on_constants,
4816 reason = "test-only failure-path assertion; no cheap fitted-model fallback"
4817 )]
4818 {
4819 assert!(false, "fit failed: {e}");
4820 }
4821 return;
4822 }
4823 };
4824 assert_eq!(fitted.nodes().len(), 7, "default node_count (sklearn: 7)");
4825 assert_clf_predict(&fitted, &x, &[0, 0, 0, 1, 1, 1, 2, 2, 0]);
4826 }
4827
4828 #[test]
4831 fn test_classifier_min_impurity_decrease_0_2_node_count_3() {
4832 let (x, y) = clf_prune_fixture();
4833 let fitted = match DecisionTreeClassifier::<f64>::new()
4834 .with_min_impurity_decrease(0.2)
4835 .fit(&x, &y)
4836 {
4837 Ok(f) => f,
4838 Err(e) => {
4839 #[allow(
4840 clippy::assertions_on_constants,
4841 reason = "test-only failure-path assertion; no cheap fitted-model fallback"
4842 )]
4843 {
4844 assert!(false, "fit failed: {e}");
4845 }
4846 return;
4847 }
4848 };
4849 assert_eq!(
4850 fitted.nodes().len(),
4851 3,
4852 "node_count at mid=0.2 (sklearn: 3)"
4853 );
4854 assert_clf_predict(&fitted, &x, &[0, 0, 0, 1, 1, 1, 0, 0, 0]);
4855 }
4856
4857 #[test]
4860 fn test_classifier_min_impurity_decrease_0_5_node_count_1() {
4861 let (x, y) = clf_prune_fixture();
4862 let fitted = match DecisionTreeClassifier::<f64>::new()
4863 .with_min_impurity_decrease(0.5)
4864 .fit(&x, &y)
4865 {
4866 Ok(f) => f,
4867 Err(e) => {
4868 #[allow(
4869 clippy::assertions_on_constants,
4870 reason = "test-only failure-path assertion; no cheap fitted-model fallback"
4871 )]
4872 {
4873 assert!(false, "fit failed: {e}");
4874 }
4875 return;
4876 }
4877 };
4878 assert_eq!(
4879 fitted.nodes().len(),
4880 1,
4881 "node_count at mid=0.5 (sklearn: 1)"
4882 );
4883 assert!(
4884 matches!(fitted.nodes()[0], Node::Leaf { .. }),
4885 "root must be a leaf at mid=0.5"
4886 );
4887 assert_clf_predict(&fitted, &x, &[0, 0, 0, 0, 0, 0, 0, 0, 0]);
4888 }
4889
4890 #[test]
4894 fn test_classifier_min_weight_fraction_leaf_0_25_node_count_5() {
4895 let (x, y) = clf_prune_fixture();
4896 let fitted = match DecisionTreeClassifier::<f64>::new()
4897 .with_min_weight_fraction_leaf(0.25)
4898 .fit(&x, &y)
4899 {
4900 Ok(f) => f,
4901 Err(e) => {
4902 #[allow(
4903 clippy::assertions_on_constants,
4904 reason = "test-only failure-path assertion; no cheap fitted-model fallback"
4905 )]
4906 {
4907 assert!(false, "fit failed: {e}");
4908 }
4909 return;
4910 }
4911 };
4912 assert_eq!(
4913 fitted.nodes().len(),
4914 5,
4915 "node_count at mwfl=0.25 (sklearn: 5)"
4916 );
4917 assert_clf_predict(&fitted, &x, &[0, 0, 0, 1, 1, 1, 0, 0, 0]);
4918 }
4919
4920 #[test]
4924 fn test_effective_min_samples_leaf_fold() {
4925 assert_eq!(effective_min_samples_leaf::<f64>(1, 0.0, 9), 1);
4927 assert_eq!(effective_min_samples_leaf::<f64>(1, 0.25, 9), 3);
4929 assert_eq!(effective_min_samples_leaf::<f64>(1, 0.25, 8), 2);
4931 assert_eq!(effective_min_samples_leaf::<f64>(5, 0.25, 9), 5);
4933 }
4934
4935 #[test]
4967 fn test_classifier_ccp_alpha_default_node_count_7() {
4968 let (x, y) = clf_prune_fixture();
4969 let fitted = match DecisionTreeClassifier::<f64>::new()
4970 .with_ccp_alpha(0.0)
4971 .fit(&x, &y)
4972 {
4973 Ok(f) => f,
4974 Err(e) => {
4975 #[allow(
4976 clippy::assertions_on_constants,
4977 reason = "test-only failure-path assertion; no cheap fitted-model fallback"
4978 )]
4979 {
4980 assert!(false, "fit failed: {e}");
4981 }
4982 return;
4983 }
4984 };
4985 assert_eq!(
4986 fitted.nodes().len(),
4987 7,
4988 "ccp_alpha=0.0 node_count (sklearn: 7)"
4989 );
4990 assert_clf_predict(&fitted, &x, &[0, 0, 0, 1, 1, 1, 2, 2, 0]);
4991 }
4992
4993 #[test]
4996 fn test_classifier_ccp_alpha_0_1_node_count_7() {
4997 let (x, y) = clf_prune_fixture();
4998 let fitted = match DecisionTreeClassifier::<f64>::new()
4999 .with_ccp_alpha(0.1)
5000 .fit(&x, &y)
5001 {
5002 Ok(f) => f,
5003 Err(e) => {
5004 #[allow(
5005 clippy::assertions_on_constants,
5006 reason = "test-only failure-path assertion; no cheap fitted-model fallback"
5007 )]
5008 {
5009 assert!(false, "fit failed: {e}");
5010 }
5011 return;
5012 }
5013 };
5014 assert_eq!(
5015 fitted.nodes().len(),
5016 7,
5017 "ccp_alpha=0.1 node_count (sklearn: 7)"
5018 );
5019 assert_clf_predict(&fitted, &x, &[0, 0, 0, 1, 1, 1, 2, 2, 0]);
5020 }
5021
5022 #[test]
5025 fn test_classifier_ccp_alpha_0_3_node_count_3() {
5026 let (x, y) = clf_prune_fixture();
5027 let fitted = match DecisionTreeClassifier::<f64>::new()
5028 .with_ccp_alpha(0.3)
5029 .fit(&x, &y)
5030 {
5031 Ok(f) => f,
5032 Err(e) => {
5033 #[allow(
5034 clippy::assertions_on_constants,
5035 reason = "test-only failure-path assertion; no cheap fitted-model fallback"
5036 )]
5037 {
5038 assert!(false, "fit failed: {e}");
5039 }
5040 return;
5041 }
5042 };
5043 assert_eq!(
5044 fitted.nodes().len(),
5045 3,
5046 "ccp_alpha=0.3 node_count (sklearn: 3)"
5047 );
5048 assert_clf_predict(&fitted, &x, &[0, 0, 0, 1, 1, 1, 0, 0, 0]);
5049 }
5050
5051 #[test]
5054 fn test_regressor_ccp_alpha_default_node_count_15() {
5055 let (x, y) = reg_alt_fixture();
5056 let fitted = match DecisionTreeRegressor::<f64>::new()
5057 .with_ccp_alpha(0.0)
5058 .fit(&x, &y)
5059 {
5060 Ok(f) => f,
5061 Err(e) => {
5062 #[allow(
5063 clippy::assertions_on_constants,
5064 reason = "test-only failure-path assertion; no cheap fitted-model fallback"
5065 )]
5066 {
5067 assert!(false, "fit failed: {e}");
5068 }
5069 return;
5070 }
5071 };
5072 assert_eq!(
5073 fitted.nodes().len(),
5074 15,
5075 "ccp_alpha=0.0 node_count (sklearn: 15)"
5076 );
5077 assert_reg_predict(&fitted, &x, &[1.0, 1.2, 0.9, 1.1, 5.0, 5.2, 4.9, 5.1]);
5078 }
5079
5080 #[test]
5083 fn test_regressor_ccp_alpha_0_001_node_count_15() {
5084 let (x, y) = reg_alt_fixture();
5085 let fitted = match DecisionTreeRegressor::<f64>::new()
5086 .with_ccp_alpha(0.001)
5087 .fit(&x, &y)
5088 {
5089 Ok(f) => f,
5090 Err(e) => {
5091 #[allow(
5092 clippy::assertions_on_constants,
5093 reason = "test-only failure-path assertion; no cheap fitted-model fallback"
5094 )]
5095 {
5096 assert!(false, "fit failed: {e}");
5097 }
5098 return;
5099 }
5100 };
5101 assert_eq!(
5102 fitted.nodes().len(),
5103 15,
5104 "ccp_alpha=0.001 node_count (sklearn: 15)"
5105 );
5106 }
5107
5108 #[test]
5112 fn test_regressor_ccp_alpha_0_05_node_count_3() {
5113 let (x, y) = reg_alt_fixture();
5114 let fitted = match DecisionTreeRegressor::<f64>::new()
5115 .with_ccp_alpha(0.05)
5116 .fit(&x, &y)
5117 {
5118 Ok(f) => f,
5119 Err(e) => {
5120 #[allow(
5121 clippy::assertions_on_constants,
5122 reason = "test-only failure-path assertion; no cheap fitted-model fallback"
5123 )]
5124 {
5125 assert!(false, "fit failed: {e}");
5126 }
5127 return;
5128 }
5129 };
5130 assert_eq!(
5131 fitted.nodes().len(),
5132 3,
5133 "ccp_alpha=0.05 node_count (sklearn: 3)"
5134 );
5135 assert_reg_predict(
5136 &fitted,
5137 &x,
5138 &[1.05, 1.05, 1.05, 1.05, 5.05, 5.05, 5.05, 5.05],
5139 );
5140 }
5141
5142 #[test]
5146 fn test_regressor_squared_error_unchanged() {
5147 let (x, y) = reg_alt_fixture();
5148 let fitted = match DecisionTreeRegressor::<f64>::new()
5149 .with_criterion(RegressionCriterion::Mse)
5150 .with_max_depth(Some(2))
5151 .fit(&x, &y)
5152 {
5153 Ok(f) => f,
5154 Err(e) => {
5155 #[allow(
5156 clippy::assertions_on_constants,
5157 reason = "test-only failure-path assertion; no cheap fitted-model fallback"
5158 )]
5159 {
5160 assert!(false, "fit failed: {e}");
5161 }
5162 return;
5163 }
5164 };
5165 let root = reg_root_split(&fitted);
5166 assert_eq!(root, Some((0, 4.5)));
5167 assert_reg_predict(&fitted, &x, &[1.1, 1.1, 1.0, 1.0, 5.1, 5.1, 5.0, 5.0]);
5168 }
5169
5170 fn clf_n_leaves(fitted: &FittedDecisionTreeClassifier<f64>) -> usize {
5198 fitted
5199 .nodes()
5200 .iter()
5201 .filter(|n| matches!(n, Node::Leaf { .. }))
5202 .count()
5203 }
5204
5205 fn reg_n_leaves(fitted: &FittedDecisionTreeRegressor<f64>) -> usize {
5207 fitted
5208 .nodes()
5209 .iter()
5210 .filter(|n| matches!(n, Node::Leaf { .. }))
5211 .count()
5212 }
5213
5214 fn fit_clf_max_leaf(
5215 x: &Array2<f64>,
5216 y: &Array1<usize>,
5217 k: Option<usize>,
5218 ) -> FittedDecisionTreeClassifier<f64> {
5219 match DecisionTreeClassifier::<f64>::new()
5220 .with_max_leaf_nodes(k)
5221 .fit(x, y)
5222 {
5223 Ok(f) => f,
5224 Err(_) => FittedDecisionTreeClassifier {
5225 nodes: vec![placeholder_leaf::<f64>()],
5226 classes: vec![0],
5227 n_features: x.ncols(),
5228 feature_importances: Array1::zeros(x.ncols()),
5229 missing_go_to_left: vec![false],
5230 },
5231 }
5232 }
5233
5234 #[test]
5237 fn test_classifier_max_leaf_nodes_2() {
5238 let (x, y) = clf_prune_fixture();
5239 let fitted = fit_clf_max_leaf(&x, &y, Some(2));
5240 assert_eq!(fitted.nodes().len(), 3, "node_count at k=2 (sklearn: 3)");
5241 assert_eq!(clf_n_leaves(&fitted), 2, "n_leaves at k=2 (sklearn: 2)");
5242 assert_clf_predict(&fitted, &x, &[0, 0, 0, 1, 1, 1, 0, 0, 0]);
5243 }
5244
5245 #[test]
5248 fn test_classifier_max_leaf_nodes_3() {
5249 let (x, y) = clf_prune_fixture();
5250 let fitted = fit_clf_max_leaf(&x, &y, Some(3));
5251 assert_eq!(fitted.nodes().len(), 5, "node_count at k=3 (sklearn: 5)");
5252 assert_eq!(clf_n_leaves(&fitted), 3, "n_leaves at k=3 (sklearn: 3)");
5253 assert_clf_predict(&fitted, &x, &[0, 0, 0, 1, 1, 1, 0, 2, 0]);
5254 }
5255
5256 #[test]
5259 fn test_classifier_max_leaf_nodes_4_equals_unlimited() {
5260 let (x, y) = clf_prune_fixture();
5261 let fitted = fit_clf_max_leaf(&x, &y, Some(4));
5262 assert_eq!(fitted.nodes().len(), 7, "node_count at k=4 (sklearn: 7)");
5263 assert_eq!(clf_n_leaves(&fitted), 4, "n_leaves at k=4 (sklearn: 4)");
5264 assert_clf_predict(&fitted, &x, &[0, 0, 0, 1, 1, 1, 2, 2, 0]);
5265 }
5266
5267 #[test]
5270 fn test_classifier_max_leaf_nodes_none_unchanged() {
5271 let (x, y) = clf_prune_fixture();
5272 let fitted = fit_clf_max_leaf(&x, &y, None);
5273 assert_eq!(fitted.nodes().len(), 7, "node_count at k=None (sklearn: 7)");
5274 assert_clf_predict(&fitted, &x, &[0, 0, 0, 1, 1, 1, 2, 2, 0]);
5275 }
5276
5277 fn fit_reg_max_leaf(
5278 x: &Array2<f64>,
5279 y: &Array1<f64>,
5280 k: Option<usize>,
5281 ) -> FittedDecisionTreeRegressor<f64> {
5282 match DecisionTreeRegressor::<f64>::new()
5283 .with_max_leaf_nodes(k)
5284 .fit(x, y)
5285 {
5286 Ok(f) => f,
5287 Err(_) => FittedDecisionTreeRegressor {
5288 nodes: vec![placeholder_leaf::<f64>()],
5289 n_features: x.ncols(),
5290 feature_importances: Array1::zeros(x.ncols()),
5291 missing_go_to_left: vec![false],
5292 },
5293 }
5294 }
5295
5296 #[test]
5299 fn test_regressor_max_leaf_nodes_2() {
5300 let (x, y) = reg_alt_fixture();
5301 let fitted = fit_reg_max_leaf(&x, &y, Some(2));
5302 assert_eq!(fitted.nodes().len(), 3, "node_count at k=2 (sklearn: 3)");
5303 assert_eq!(reg_n_leaves(&fitted), 2, "n_leaves at k=2 (sklearn: 2)");
5304 assert_reg_predict(
5305 &fitted,
5306 &x,
5307 &[1.05, 1.05, 1.05, 1.05, 5.05, 5.05, 5.05, 5.05],
5308 );
5309 }
5310
5311 #[test]
5314 fn test_regressor_max_leaf_nodes_3() {
5315 let (x, y) = reg_alt_fixture();
5316 let fitted = fit_reg_max_leaf(&x, &y, Some(3));
5317 assert_eq!(fitted.nodes().len(), 5, "node_count at k=3 (sklearn: 5)");
5318 assert_eq!(reg_n_leaves(&fitted), 3, "n_leaves at k=3 (sklearn: 3)");
5319 assert_reg_predict(&fitted, &x, &[1.05, 1.05, 1.05, 1.05, 5.1, 5.1, 5.0, 5.0]);
5320 }
5321
5322 #[test]
5325 fn test_regressor_max_leaf_nodes_4() {
5326 let (x, y) = reg_alt_fixture();
5327 let fitted = fit_reg_max_leaf(&x, &y, Some(4));
5328 assert_eq!(fitted.nodes().len(), 7, "node_count at k=4 (sklearn: 7)");
5329 assert_eq!(reg_n_leaves(&fitted), 4, "n_leaves at k=4 (sklearn: 4)");
5330 assert_reg_predict(&fitted, &x, &[1.05, 1.05, 1.05, 1.05, 5.0, 5.2, 5.0, 5.0]);
5331 }
5332
5333 #[test]
5336 fn test_regressor_max_leaf_nodes_5() {
5337 let (x, y) = reg_alt_fixture();
5338 let fitted = fit_reg_max_leaf(&x, &y, Some(5));
5339 assert_eq!(fitted.nodes().len(), 9, "node_count at k=5 (sklearn: 9)");
5340 assert_eq!(reg_n_leaves(&fitted), 5, "n_leaves at k=5 (sklearn: 5)");
5341 assert_reg_predict(&fitted, &x, &[1.05, 1.05, 1.05, 1.05, 5.0, 5.2, 4.9, 5.1]);
5342 }
5343
5344 #[test]
5347 fn test_regressor_max_leaf_nodes_none_unchanged() {
5348 let (x, y) = reg_alt_fixture();
5349 let fitted = fit_reg_max_leaf(&x, &y, None);
5350 assert_eq!(
5351 fitted.nodes().len(),
5352 15,
5353 "node_count at k=None (sklearn: 15)"
5354 );
5355 assert_reg_predict(&fitted, &x, &[1.0, 1.2, 0.9, 1.1, 5.0, 5.2, 4.9, 5.1]);
5356 }
5357
5358 fn cw_fixture() -> (Array2<f64>, Array1<usize>) {
5381 let x = Array2::from_shape_vec(
5382 (8, 2),
5383 vec![
5384 1.0, 0.0, 1.5, 0.0, 2.0, 0.0, 1.2, 0.0, 2.2, 0.0, 5.0, 0.0, 6.0, 0.0, 7.0, 0.0,
5385 ],
5386 )
5387 .unwrap_or_else(|_| Array2::zeros((8, 2)));
5388 let y = array![0, 0, 0, 1, 1, 1, 1, 1];
5389 (x, y)
5390 }
5391
5392 #[allow(
5395 clippy::type_complexity,
5396 reason = "test helper bundling the oracle-compared quantities"
5397 )]
5398 fn cw_fit(cw: ClassWeight<f64>) -> Result<((usize, f64), Vec<usize>, Vec<f64>), FerroError> {
5399 let (x, y) = cw_fixture();
5400 let model = DecisionTreeClassifier::<f64>::new()
5401 .with_max_depth(Some(1))
5402 .with_class_weight(cw);
5403 let fitted = model.fit(&x, &y)?;
5404 let root = match fitted.nodes().first() {
5405 Some(Node::Split {
5406 feature, threshold, ..
5407 }) => (*feature, *threshold),
5408 _ => (usize::MAX, f64::NAN),
5409 };
5410 let preds = fitted.predict(&x)?.to_vec();
5411 let proba0 = fitted.predict_proba(&x)?.row(0).to_vec();
5412 Ok((root, preds, proba0))
5413 }
5414
5415 #[test]
5416 fn test_classifier_class_weight_none_oracle() {
5417 let res = cw_fit(ClassWeight::None);
5418 assert!(res.is_ok(), "fit with class_weight=None must succeed");
5419 let (root, preds, proba0) = res.unwrap_or_else(|_| ((0, 0.0), vec![], vec![]));
5420 assert_eq!(root.0, 0, "None root feature (sklearn: 0)");
5421 assert_relative_eq!(root.1, 2.1, max_relative = 1e-2);
5422 assert_eq!(preds, vec![0, 0, 0, 0, 1, 1, 1, 1], "None predict");
5423 assert_relative_eq!(proba0[0], 0.75, max_relative = 1e-2);
5424 assert_relative_eq!(proba0[1], 0.25, max_relative = 1e-2);
5425 }
5426
5427 #[test]
5428 fn test_classifier_class_weight_explicit_oracle() {
5429 let res = cw_fit(ClassWeight::Explicit(vec![(0, 1.0), (1, 5.0)]));
5432 assert!(res.is_ok(), "fit with explicit class_weight must succeed");
5433 let (root, preds, proba0) = res.unwrap_or_else(|_| ((0, 0.0), vec![], vec![]));
5434 assert_eq!(root.0, 0, "explicit root feature (sklearn: 0)");
5435 assert_relative_eq!(root.1, 1.1, max_relative = 1e-2);
5436 assert_eq!(preds, vec![0, 1, 1, 1, 1, 1, 1, 1], "explicit predict");
5437 assert_relative_eq!(proba0[0], 1.0, max_relative = 1e-2);
5438 assert!(proba0[1].abs() < 1e-2, "explicit proba[0][1] ≈ 0");
5439 }
5440
5441 #[test]
5442 fn test_classifier_class_weight_balanced_oracle() {
5443 let res = cw_fit(ClassWeight::Balanced);
5446 assert!(res.is_ok(), "fit with class_weight=balanced must succeed");
5447 let (root, preds, proba0) = res.unwrap_or_else(|_| ((0, 0.0), vec![], vec![]));
5448 assert_eq!(root.0, 0, "balanced root feature (sklearn: 0)");
5449 assert_relative_eq!(root.1, 2.1, max_relative = 1e-2);
5450 assert_eq!(preds, vec![0, 0, 0, 0, 1, 1, 1, 1], "balanced predict");
5451 assert_relative_eq!(proba0[0], 0.833_333_333_333, max_relative = 1e-2);
5452 assert_relative_eq!(proba0[1], 0.166_666_666_666, max_relative = 1e-2);
5453 }
5454
5455 #[test]
5458 fn test_compute_class_weight_balanced() {
5459 let classes = vec![0usize, 1];
5461 let y = vec![0usize, 0, 0, 1, 1, 1, 1, 1];
5462 let bal = compute_class_weight::<f64>(&ClassWeight::Balanced, &classes, &y);
5463 assert_relative_eq!(bal[0], 8.0 / (2.0 * 3.0), max_relative = 1e-12);
5464 assert_relative_eq!(bal[1], 8.0 / (2.0 * 5.0), max_relative = 1e-12);
5465 let none = compute_class_weight::<f64>(&ClassWeight::None, &classes, &y);
5466 assert_relative_eq!(none[0], 1.0, max_relative = 1e-12);
5467 assert_relative_eq!(none[1], 1.0, max_relative = 1e-12);
5468 let exp = compute_class_weight::<f64>(&ClassWeight::Explicit(vec![(1, 5.0)]), &classes, &y);
5469 assert_relative_eq!(exp[0], 1.0, max_relative = 1e-12);
5470 assert_relative_eq!(exp[1], 5.0, max_relative = 1e-12);
5471 }
5472}