use crate::error::{Error, TreeError};
use crate::machine_learning::validation::{
check_is_fitted, preliminary_check, validate_predict_input,
};
use crate::math::{entropy, gini, variance};
use crate::parallel_gates::{sort_scan_min_elems, tree_traversal_min_visits};
use crate::{Deserialize, Serialize};
use ahash::AHashMap;
use ndarray::{Array1, Array2, ArrayBase, ArrayView1, Axis, Data, Ix1, Ix2};
use ndarray_rand::rand::{Rng, rngs::StdRng};
use rayon::prelude::{IntoParallelIterator, ParallelIterator};
#[cfg(feature = "show_progress")]
use indicatif::ProgressBar;
const DECISION_TREE_ASSUMED_DEPTH: usize = 16;
enum Split {
Numeric {
feature: usize,
threshold: f64,
left: Vec<usize>,
right: Vec<usize>,
},
Categorical {
feature: usize,
partitions: Vec<(String, Vec<usize>)>,
},
}
fn category_key(value: f64) -> String {
format!("{}", (value * 1e6).round() / 1e6)
}
fn split_information(counts: &[f64], total: f64) -> f64 {
let mut info = 0.0;
for &c in counts {
if c > 0.0 {
let p = c / total;
info -= p * p.log2();
}
}
info
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
pub enum Algorithm {
ID3,
C45,
CART,
}
impl Algorithm {
fn supports_regression(&self) -> bool {
matches!(self, Algorithm::CART)
}
fn allows_multiway_categorical(&self) -> bool {
matches!(self, Algorithm::ID3 | Algorithm::C45)
}
fn classification_impurity(&self, y: &ArrayView1<f64>) -> f64 {
match self {
Algorithm::CART => gini(y),
Algorithm::ID3 | Algorithm::C45 => entropy(y),
}
}
fn impurity_from_counts(&self, counts: &[f64], n_side: f64) -> f64 {
match self {
Algorithm::CART => {
let mut sum_sq = 0.0;
for &c in counts {
if c > 0.0 {
let p = c / n_side;
sum_sq += p * p;
}
}
1.0 - sum_sq
}
Algorithm::ID3 | Algorithm::C45 => {
let mut ent = 0.0;
for &c in counts {
if c > 0.0 {
let p = c / n_side;
ent -= p * p.log2();
}
}
ent
}
}
}
fn selection_score(&self, impurity_decrease: f64, counts: &[f64], total: f64) -> Option<f64> {
match self {
Algorithm::C45 => {
let split_info = split_information(counts, total);
(split_info > f64::EPSILON).then(|| impurity_decrease / split_info)
}
Algorithm::ID3 | Algorithm::CART => Some(impurity_decrease),
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Deserialize, Serialize)]
pub struct DecisionTreeParams {
pub max_depth: Option<usize>,
pub min_samples_split: usize,
pub min_samples_leaf: usize,
pub min_impurity_decrease: f64,
pub random_state: Option<u64>,
}
impl Default for DecisionTreeParams {
fn default() -> Self {
Self {
max_depth: None,
min_samples_split: 2,
min_samples_leaf: 1,
min_impurity_decrease: 0.0,
random_state: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub enum NodeType {
Internal {
feature_index: usize,
threshold: f64,
categories: Option<Vec<String>>,
},
Leaf {
value: f64,
class: Option<usize>,
probabilities: Option<Vec<f64>>,
},
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Node {
pub node_type: NodeType,
pub left: Option<Box<Node>>,
pub right: Option<Box<Node>>,
pub children: Option<AHashMap<String, Box<Node>>>,
}
impl Node {
pub fn new_leaf(value: f64, class: Option<usize>, probabilities: Option<Vec<f64>>) -> Self {
Self {
node_type: NodeType::Leaf {
value,
class,
probabilities,
},
left: None,
right: None,
children: None,
}
}
pub fn new_internal(feature_index: usize, threshold: f64) -> Self {
Self {
node_type: NodeType::Internal {
feature_index,
threshold,
categories: None,
},
left: None,
right: None,
children: None,
}
}
pub fn new_categorical(feature_index: usize, categories: Vec<String>) -> Self {
Self {
node_type: NodeType::Internal {
feature_index,
threshold: 0.0, categories: Some(categories),
},
left: None,
right: None,
children: Some(AHashMap::new()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DecisionTree {
algorithm: Algorithm,
root: Option<Box<Node>>,
n_features: usize,
n_classes: Option<usize>,
params: DecisionTreeParams,
is_classifier: bool,
categorical_features: Vec<usize>,
}
impl DecisionTree {
pub fn new(algorithm: Algorithm, is_classifier: bool) -> Result<Self, Error> {
if !is_classifier && !algorithm.supports_regression() {
return Err(Error::invalid_input(
"Only CART algorithm is supported for regression tasks",
));
}
Ok(Self {
algorithm,
root: None,
n_features: 0,
n_classes: None,
params: DecisionTreeParams::default(),
is_classifier,
categorical_features: Vec::new(),
})
}
pub fn with_max_depth(mut self, max_depth: usize) -> Self {
self.params.max_depth = Some(max_depth);
self
}
pub fn with_min_samples_split(mut self, min_samples_split: usize) -> Result<Self, Error> {
if min_samples_split < 2 {
return Err(Error::invalid_parameter(
"min_samples_split",
"must be at least 2",
));
}
self.params.min_samples_split = min_samples_split;
Ok(self)
}
pub fn with_min_samples_leaf(mut self, min_samples_leaf: usize) -> Result<Self, Error> {
if min_samples_leaf < 1 {
return Err(Error::invalid_parameter(
"min_samples_leaf",
"must be at least 1",
));
}
self.params.min_samples_leaf = min_samples_leaf;
Ok(self)
}
pub fn with_min_impurity_decrease(mut self, min_impurity_decrease: f64) -> Result<Self, Error> {
if min_impurity_decrease < 0.0 || !min_impurity_decrease.is_finite() {
return Err(Error::invalid_parameter(
"min_impurity_decrease",
format!(
"must be non-negative and finite, got {}",
min_impurity_decrease
),
));
}
self.params.min_impurity_decrease = min_impurity_decrease;
Ok(self)
}
pub fn with_random_state(mut self, seed: u64) -> Self {
self.params.random_state = Some(seed);
self
}
get_field!(get_algorithm, algorithm, Algorithm);
get_field!(get_n_features, n_features, usize);
get_field!(get_n_classes, n_classes, Option<usize>);
get_field!(get_parameters, params, DecisionTreeParams);
pub fn get_root(&self) -> Option<&Node> {
self.root.as_deref()
}
get_field!(get_is_classifier, is_classifier, bool);
pub fn set_categorical_features(&mut self, features: Vec<usize>) -> &mut Self {
self.categorical_features = features;
self
}
pub fn get_categorical_features(&self) -> &[usize] {
&self.categorical_features
}
pub fn fit<S>(
&mut self,
x: &ArrayBase<S, Ix2>,
y: &ArrayBase<S, Ix1>,
) -> Result<&mut Self, Error>
where
S: Data<Elem = f64> + Send + Sync,
{
preliminary_check(x, Some(y))?;
if self.params.min_samples_leaf > self.params.min_samples_split {
return Err(Error::invalid_parameter(
"min_samples_leaf",
format!(
"min_samples_leaf ({}) cannot be greater than min_samples_split ({})",
self.params.min_samples_leaf, self.params.min_samples_split
),
));
}
if x.nrows() < self.params.min_samples_split {
return Err(Error::invalid_input(format!(
"Number of samples ({}) is less than min_samples_split ({})",
x.nrows(),
self.params.min_samples_split
)));
}
if x.ncols() == 0 {
return Err(Error::invalid_input(
"Input data must have at least one feature",
));
}
self.n_features = x.ncols();
if self.is_classifier {
let max_class = y
.iter()
.max_by(|a, b| a.partial_cmp(b).unwrap())
.ok_or_else(|| Error::computation("Cannot determine max class"))?;
for &label in y.iter() {
if label < 0.0 || label.fract() != 0.0 {
return Err(Error::invalid_input(
"Class labels must be non-negative integers starting from 0",
));
}
}
self.n_classes = Some((*max_class as usize) + 1);
}
#[cfg(feature = "show_progress")]
let estimated_max_depth = self.params.max_depth.unwrap_or(20).min(20);
#[cfg(feature = "show_progress")]
let estimated_nodes = (1 << (estimated_max_depth + 1)) - 1;
#[cfg(feature = "show_progress")]
let progress_bar = {
let pb = crate::create_progress_bar(
estimated_nodes as u64,
"[{elapsed_precise}] {bar:40} {pos} nodes | Depth: {msg}",
);
pb.set_message("0");
pb
};
let mut rng = crate::random::make_rng_opt(self.params.random_state);
let indices: Vec<usize> = (0..x.nrows()).collect();
self.root = Some(Box::new(self.build_tree(
x,
y,
&indices,
0,
&mut rng,
#[cfg(feature = "show_progress")]
&progress_bar,
)?));
#[cfg(feature = "show_progress")]
progress_bar
.finish_with_message(format!("{}", self.count_nodes(self.root.as_ref().unwrap())));
Ok(self)
}
fn build_tree<S>(
&self,
x: &ArrayBase<S, Ix2>,
y: &ArrayBase<S, Ix1>,
indices: &[usize],
depth: usize,
rng: &mut Option<StdRng>,
#[cfg(feature = "show_progress")] progress_bar: &ProgressBar,
) -> Result<Node, Error>
where
S: Data<Elem = f64> + Send + Sync,
{
#[cfg(feature = "show_progress")]
progress_bar.inc(1);
#[cfg(feature = "show_progress")]
progress_bar.set_message(format!("{}", depth));
let n_samples = indices.len();
if n_samples < self.params.min_samples_split
|| (self.params.max_depth.is_some() && depth >= self.params.max_depth.unwrap())
|| self.is_pure(y, indices)
{
return Ok(self.create_leaf(y, indices));
}
let split_result = self.find_best_split(x, y, indices, rng)?;
let Some((split, impurity_decrease)) = split_result else {
return Ok(self.create_leaf(y, indices));
};
let node_weight = n_samples as f64 / x.nrows() as f64;
if node_weight * impurity_decrease < self.params.min_impurity_decrease {
return Ok(self.create_leaf(y, indices));
}
match split {
Split::Numeric {
feature,
threshold,
left,
right,
} => {
if left.len() < self.params.min_samples_leaf
|| right.len() < self.params.min_samples_leaf
{
return Ok(self.create_leaf(y, indices));
}
let mut node = Node::new_internal(feature, threshold);
node.left = Some(Box::new(self.build_tree(
x,
y,
&left,
depth + 1,
rng,
#[cfg(feature = "show_progress")]
progress_bar,
)?));
node.right = Some(Box::new(self.build_tree(
x,
y,
&right,
depth + 1,
rng,
#[cfg(feature = "show_progress")]
progress_bar,
)?));
Ok(node)
}
Split::Categorical {
feature,
partitions,
} => {
let adequate_branches = partitions
.iter()
.filter(|(_, idx)| idx.len() >= self.params.min_samples_leaf)
.count();
if adequate_branches < 2 {
return Ok(self.create_leaf(y, indices));
}
let keys: Vec<String> = partitions.iter().map(|(key, _)| key.clone()).collect();
let mut node = Node::new_categorical(feature, keys);
node.left = Some(Box::new(self.create_leaf(y, indices)));
let children = node.children.as_mut().unwrap();
for (key, idx) in partitions {
let child = self.build_tree(
x,
y,
&idx,
depth + 1,
rng,
#[cfg(feature = "show_progress")]
progress_bar,
)?;
children.insert(key, Box::new(child));
}
Ok(node)
}
}
}
fn find_best_split<S>(
&self,
x: &ArrayBase<S, Ix2>,
y: &ArrayBase<S, Ix1>,
indices: &[usize],
rng: &mut Option<StdRng>,
) -> Result<Option<(Split, f64)>, Error>
where
S: Data<Elem = f64> + Send + Sync,
{
let parent_impurity = self.calculate_impurity(y, indices);
let allow_categorical = self.algorithm.allows_multiway_categorical();
let process_feature = |feature_idx: usize| -> Option<(f64, Split, f64)> {
if allow_categorical && self.categorical_features.contains(&feature_idx) {
self.evaluate_categorical_split(x, y, indices, feature_idx, parent_impurity)
} else {
self.evaluate_numeric_split(x, y, indices, feature_idx, parent_impurity)
}
};
let sort_work = indices.len().saturating_mul(self.n_features);
let candidates: Vec<(f64, Split, f64)> = if sort_work >= sort_scan_min_elems() {
(0..self.n_features)
.into_par_iter()
.filter_map(process_feature)
.collect()
} else {
(0..self.n_features).filter_map(process_feature).collect()
};
let best = Self::select_best_split(candidates, rng);
Ok(best.map(|(_score, split, impurity_decrease)| (split, impurity_decrease)))
}
fn select_best_split(
mut candidates: Vec<(f64, Split, f64)>,
rng: &mut Option<StdRng>,
) -> Option<(f64, Split, f64)> {
let max_score = candidates
.iter()
.map(|(score, _, _)| *score)
.filter(|score| !score.is_nan())
.max_by(|a, b| a.partial_cmp(b).unwrap())?;
let tied: Vec<usize> = candidates
.iter()
.enumerate()
.filter(|(_, (score, _, _))| *score == max_score)
.map(|(i, _)| i)
.collect();
let chosen = match rng {
Some(r) => tied[r.random_range(0..tied.len())],
None => *tied.last().unwrap(),
};
Some(candidates.swap_remove(chosen))
}
fn evaluate_numeric_split<S>(
&self,
x: &ArrayBase<S, Ix2>,
y: &ArrayBase<S, Ix1>,
indices: &[usize],
feature_idx: usize,
parent_impurity: f64,
) -> Option<(f64, Split, f64)>
where
S: Data<Elem = f64>,
{
let n = indices.len();
if n < 2 {
return None;
}
let n_f = n as f64;
let mut order: Vec<(f64, usize)> =
indices.iter().map(|&i| (x[[i, feature_idx]], i)).collect();
order.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let mut best_score = f64::NEG_INFINITY;
let mut best: Option<(usize, f64, f64)> = None;
let min_leaf = self.params.min_samples_leaf;
let consider = |pos: usize,
imp_left: f64,
imp_right: f64,
best_score: &mut f64,
best: &mut Option<(usize, f64, f64)>| {
if pos < min_leaf || n - pos < min_leaf {
return;
}
let n_left = pos as f64;
let n_right = (n - pos) as f64;
let weighted = (n_left / n_f) * imp_left + (n_right / n_f) * imp_right;
let impurity_decrease = parent_impurity - weighted;
if let Some(score) =
self.algorithm
.selection_score(impurity_decrease, &[n_left, n_right], n_f)
&& score > *best_score
{
*best_score = score;
let threshold = (order[pos - 1].0 + order[pos].0) / 2.0;
*best = Some((pos, threshold, impurity_decrease));
}
};
if self.is_classifier {
let n_classes = self.n_classes.unwrap();
let mut left_counts = vec![0.0_f64; n_classes];
let mut right_counts = vec![0.0_f64; n_classes];
for &(_, idx) in &order {
right_counts[y[idx] as usize] += 1.0;
}
for pos in 1..n {
let cls = y[order[pos - 1].1] as usize;
left_counts[cls] += 1.0;
right_counts[cls] -= 1.0;
if order[pos - 1].0 == order[pos].0 {
continue;
}
let imp_left = self
.algorithm
.impurity_from_counts(&left_counts, pos as f64);
let imp_right = self
.algorithm
.impurity_from_counts(&right_counts, (n - pos) as f64);
consider(pos, imp_left, imp_right, &mut best_score, &mut best);
}
} else {
let total_sum: f64 = order.iter().map(|&(_, idx)| y[idx]).sum();
let total_sumsq: f64 = order.iter().map(|&(_, idx)| y[idx] * y[idx]).sum();
let mut left_sum = 0.0;
let mut left_sumsq = 0.0;
for pos in 1..n {
let val = y[order[pos - 1].1];
left_sum += val;
left_sumsq += val * val;
if order[pos - 1].0 == order[pos].0 {
continue;
}
let n_left = pos as f64;
let n_right = (n - pos) as f64;
let right_sum = total_sum - left_sum;
let right_sumsq = total_sumsq - left_sumsq;
let var_left = (left_sumsq / n_left - (left_sum / n_left).powi(2)).max(0.0);
let var_right = (right_sumsq / n_right - (right_sum / n_right).powi(2)).max(0.0);
consider(pos, var_left, var_right, &mut best_score, &mut best);
}
}
best.map(|(pos, threshold, decrease)| {
let left: Vec<usize> = order[..pos].iter().map(|&(_, idx)| idx).collect();
let right: Vec<usize> = order[pos..].iter().map(|&(_, idx)| idx).collect();
(
best_score,
Split::Numeric {
feature: feature_idx,
threshold,
left,
right,
},
decrease,
)
})
}
fn evaluate_categorical_split<S>(
&self,
x: &ArrayBase<S, Ix2>,
y: &ArrayBase<S, Ix1>,
indices: &[usize],
feature_idx: usize,
parent_impurity: f64,
) -> Option<(f64, Split, f64)>
where
S: Data<Elem = f64>,
{
let mut groups: AHashMap<String, Vec<usize>> = AHashMap::new();
for &idx in indices {
groups
.entry(category_key(x[[idx, feature_idx]]))
.or_default()
.push(idx);
}
if groups.len() < 2 {
return None;
}
let n = indices.len() as f64;
let mut weighted = 0.0;
let mut counts: Vec<f64> = Vec::with_capacity(groups.len());
let mut partitions: Vec<(String, Vec<usize>)> = Vec::with_capacity(groups.len());
for (key, group) in groups {
let n_group = group.len() as f64;
counts.push(n_group);
weighted += (n_group / n) * self.calculate_impurity(y, &group);
partitions.push((key, group));
}
let impurity_decrease = parent_impurity - weighted;
let score = self
.algorithm
.selection_score(impurity_decrease, &counts, n)?;
Some((
score,
Split::Categorical {
feature: feature_idx,
partitions,
},
impurity_decrease,
))
}
fn calculate_impurity<S>(&self, y: &ArrayBase<S, Ix1>, indices: &[usize]) -> f64
where
S: Data<Elem = f64>,
{
if indices.is_empty() {
return 0.0;
}
if self.is_classifier {
let subset = y.select(Axis(0), indices);
self.algorithm.classification_impurity(&subset.view())
} else {
self.calculate_mse(y, indices)
}
}
fn calculate_mse<S>(&self, y: &ArrayBase<S, Ix1>, indices: &[usize]) -> f64
where
S: Data<Elem = f64>,
{
let subset: Array1<f64> = indices.iter().map(|&i| y[i]).collect();
variance(&subset)
}
fn is_pure<S>(&self, y: &ArrayBase<S, Ix1>, indices: &[usize]) -> bool
where
S: Data<Elem = f64>,
{
if indices.is_empty() {
return true;
}
let first_value = y[indices[0]];
indices.iter().all(|&i| (y[i] - first_value).abs() < 1e-10)
}
fn create_leaf<S>(&self, y: &ArrayBase<S, Ix1>, indices: &[usize]) -> Node
where
S: Data<Elem = f64>,
{
if self.is_classifier {
let n_classes = self.n_classes.unwrap();
let mut class_counts = vec![0.0; n_classes];
for &idx in indices {
let class = y[idx] as usize;
class_counts[class] += 1.0;
}
let majority_class = class_counts
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
.map(|(idx, _)| idx)
.unwrap_or(0);
let total = indices.len() as f64;
let probabilities: Vec<f64> = class_counts.iter().map(|&count| count / total).collect();
Node::new_leaf(
majority_class as f64,
Some(majority_class),
Some(probabilities),
)
} else {
let mean = indices.iter().map(|&i| y[i]).sum::<f64>() / indices.len() as f64;
Node::new_leaf(mean, None, None)
}
}
pub fn predict_one(&self, x: &[f64]) -> Result<f64, Error> {
if self.root.is_none() {
return Err(Error::not_fitted("DecisionTree"));
}
if x.len() != self.n_features {
return Err(Error::dimension_mismatch(self.n_features, x.len()));
}
self.traverse_tree(self.root.as_ref().unwrap(), x)
}
fn traverse_tree(&self, node: &Node, x: &[f64]) -> Result<f64, Error> {
match &node.node_type {
NodeType::Leaf { value, .. } => Ok(*value),
NodeType::Internal {
feature_index,
threshold,
categories,
} => {
if categories.is_some() {
let key = category_key(x[*feature_index]);
if let Some(child) = node.children.as_ref().and_then(|c| c.get(&key)) {
return self.traverse_tree(child, x);
}
return match &node.left {
Some(fallback) => self.traverse_tree(fallback, x),
None => Err(Error::Tree(TreeError::CorruptStructure(
"Categorical node has no matching child and no fallback",
))),
};
}
if x[*feature_index] <= *threshold {
if let Some(ref left) = node.left {
self.traverse_tree(left, x)
} else {
Err(Error::Tree(TreeError::CorruptStructure(
"Missing left child",
)))
}
} else {
if let Some(ref right) = node.right {
self.traverse_tree(right, x)
} else {
Err(Error::Tree(TreeError::CorruptStructure(
"Missing right child",
)))
}
}
}
}
}
pub fn predict<S>(&self, x: &ArrayBase<S, Ix2>) -> Result<Array1<f64>, Error>
where
S: Data<Elem = f64> + Send + Sync,
{
check_is_fitted(self.root.is_some(), "DecisionTree")?;
validate_predict_input(x, self.n_features)?;
let visit_work = x.nrows().saturating_mul(DECISION_TREE_ASSUMED_DEPTH);
let predictions: Result<Vec<f64>, Error> = if visit_work >= tree_traversal_min_visits() {
x.axis_iter(Axis(0))
.into_par_iter()
.map(|row| {
let row_slice = row.to_vec();
self.predict_one(&row_slice)
})
.collect()
} else {
x.axis_iter(Axis(0))
.map(|row| {
let row_slice = row.to_vec();
self.predict_one(&row_slice)
})
.collect()
};
Ok(Array1::from_vec(predictions?))
}
pub fn fit_predict<S>(
&mut self,
x_train: &ArrayBase<S, Ix2>,
y_train: &ArrayBase<S, Ix1>,
) -> Result<Array1<f64>, Error>
where
S: Data<Elem = f64> + Send + Sync,
{
self.fit(x_train, y_train)?;
self.predict(x_train)
}
pub fn predict_proba<S>(&self, x: &ArrayBase<S, Ix2>) -> Result<Array2<f64>, Error>
where
S: Data<Elem = f64>,
{
if !self.is_classifier {
return Err(Error::Tree(TreeError::NotClassificationTree));
}
check_is_fitted(self.root.is_some(), "DecisionTree")?;
validate_predict_input(x, self.n_features)?;
let n_classes = self.n_classes.unwrap();
let probabilities: Result<Vec<Vec<f64>>, Error> =
if x.nrows().saturating_mul(DECISION_TREE_ASSUMED_DEPTH) >= tree_traversal_min_visits()
{
x.axis_iter(Axis(0))
.into_par_iter()
.map(|row| {
let row_slice = row.to_vec();
self.predict_proba_one(&row_slice)
})
.collect()
} else {
x.axis_iter(Axis(0))
.map(|row| {
let row_slice = row.to_vec();
self.predict_proba_one(&row_slice)
})
.collect()
};
let probabilities = probabilities?;
let mut result = Array2::zeros((x.nrows(), n_classes));
for (i, proba) in probabilities.iter().enumerate() {
for (j, &p) in proba.iter().enumerate() {
result[[i, j]] = p;
}
}
Ok(result)
}
pub fn predict_proba_one(&self, x: &[f64]) -> Result<Vec<f64>, Error> {
if !self.is_classifier {
return Err(Error::Tree(TreeError::NotClassificationTree));
}
if self.root.is_none() {
return Err(Error::not_fitted("DecisionTree"));
}
if x.len() != self.n_features {
return Err(Error::dimension_mismatch(self.n_features, x.len()));
}
self.get_probabilities(self.root.as_ref().unwrap(), x)
}
fn get_probabilities(&self, node: &Node, x: &[f64]) -> Result<Vec<f64>, Error> {
match &node.node_type {
NodeType::Leaf { probabilities, .. } => {
probabilities.as_ref().cloned().ok_or_else(|| {
Error::Tree(TreeError::CorruptStructure("No probabilities in leaf node"))
})
}
NodeType::Internal {
feature_index,
threshold,
categories,
} => {
if categories.is_some() {
let key = category_key(x[*feature_index]);
if let Some(child) = node.children.as_ref().and_then(|c| c.get(&key)) {
return self.get_probabilities(child, x);
}
return match &node.left {
Some(fallback) => self.get_probabilities(fallback, x),
None => Err(Error::Tree(TreeError::CorruptStructure(
"Categorical node has no matching child and no fallback",
))),
};
}
if x[*feature_index] <= *threshold {
if let Some(ref left) = node.left {
self.get_probabilities(left, x)
} else {
Err(Error::Tree(TreeError::CorruptStructure(
"Missing left child",
)))
}
} else {
if let Some(ref right) = node.right {
self.get_probabilities(right, x)
} else {
Err(Error::Tree(TreeError::CorruptStructure(
"Missing right child",
)))
}
}
}
}
}
pub fn generate_tree_structure(&self) -> Result<String, Error> {
if self.root.is_none() {
return Err(Error::not_fitted("DecisionTree"));
}
let mut output = String::new();
output.push_str("Decision Tree Structure:\n");
self.print_node(self.root.as_ref().unwrap(), &mut output, "", true);
Ok(output)
}
#[cfg(feature = "show_progress")]
fn count_nodes(&self, node: &Node) -> usize {
let mut count = 1; match &node.node_type {
NodeType::Leaf { .. } => count,
NodeType::Internal { .. } => {
if let Some(ref left) = node.left {
count += self.count_nodes(left);
}
if let Some(ref right) = node.right {
count += self.count_nodes(right);
}
if let Some(ref children) = node.children {
for child in children.values() {
count += self.count_nodes(child);
}
}
count
}
}
}
fn print_node(&self, node: &Node, output: &mut String, prefix: &str, is_last: bool) {
let connector = if is_last { "└── " } else { "├── " };
output.push_str(&format!("{}{}", prefix, connector));
match &node.node_type {
NodeType::Leaf {
value,
class,
probabilities,
} => {
if self.is_classifier {
output.push_str(&format!("Leaf: class={}", class.unwrap()));
if let Some(probs) = probabilities {
output.push_str(&format!(" probs={:?}", probs));
}
} else {
output.push_str(&format!("Leaf: value={:.4}", value));
}
output.push('\n');
}
NodeType::Internal {
feature_index,
threshold,
categories,
} => {
let new_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
if categories.is_some() {
output.push_str(&format!(
"Split: feature[{}] (categorical)\n",
feature_index
));
let mut branches: Vec<(String, &Node)> = node
.children
.as_ref()
.map(|c| c.iter().map(|(k, v)| (k.clone(), v.as_ref())).collect())
.unwrap_or_default();
branches.sort_by(|a, b| a.0.cmp(&b.0));
let total = branches.len() + node.left.is_some() as usize;
for (i, (key, child)) in branches.into_iter().enumerate() {
let last = i + 1 == total;
let connector = if last { "└── " } else { "├── " };
output.push_str(&format!("{}{}= {}:\n", new_prefix, connector, key));
let child_prefix =
format!("{}{}", new_prefix, if last { " " } else { "│ " });
self.print_node(child, output, &child_prefix, true);
}
if let Some(ref fallback) = node.left {
output.push_str(&format!("{}└── = (default):\n", new_prefix));
let child_prefix = format!("{} ", new_prefix);
self.print_node(fallback, output, &child_prefix, true);
}
} else {
output.push_str(&format!(
"Split: feature[{}] <= {:.4}\n",
feature_index, threshold
));
if let Some(ref left) = node.left {
self.print_node(left, output, &new_prefix, false);
}
if let Some(ref right) = node.right {
self.print_node(right, output, &new_prefix, true);
}
}
}
}
}
model_save_and_load_methods!(DecisionTree);
}
#[cfg(test)]
mod tests {
use super::*;
fn numeric_candidate(score: f64, feature: usize) -> (f64, Split, f64) {
(
score,
Split::Numeric {
feature,
threshold: 0.0,
left: Vec::new(),
right: Vec::new(),
},
score,
)
}
#[test]
fn select_best_split_ignores_nan_and_picks_finite_best() {
let candidates = vec![
numeric_candidate(f64::NAN, 0),
numeric_candidate(0.25, 1),
numeric_candidate(0.75, 2), numeric_candidate(f64::NAN, 3),
];
let mut rng: Option<StdRng> = None;
let (score, split, _) = DecisionTree::select_best_split(candidates, &mut rng)
.expect("a finite-scoring split exists");
assert_eq!(score, 0.75);
match split {
Split::Numeric { feature, .. } => assert_eq!(feature, 2),
_ => panic!("expected the finite best numeric split"),
}
}
#[test]
fn select_best_split_all_nan_scores_is_none() {
let candidates = vec![
numeric_candidate(f64::NAN, 0),
numeric_candidate(f64::NAN, 1),
];
let mut rng: Option<StdRng> = None;
assert!(DecisionTree::select_best_split(candidates, &mut rng).is_none());
}
#[test]
fn category_key_collapses_subkey_noise_but_separates_distinct_values() {
assert_eq!(category_key(1.0000001), category_key(1.0000002));
assert_eq!(category_key(1.0000001), "1");
assert_ne!(category_key(1.0), category_key(2.0));
assert_eq!(category_key(1.0), "1");
assert_eq!(category_key(2.0), "2");
}
#[test]
fn split_information_even_two_way_is_one_bit_and_single_branch_is_zero() {
assert!((split_information(&[2.0, 2.0], 4.0) - 1.0).abs() < 1e-12);
assert!(split_information(&[4.0], 4.0).abs() < 1e-12);
}
#[test]
fn selection_score_c45_none_on_degenerate_split_info() {
assert!(
Algorithm::C45.selection_score(0.5, &[4.0], 4.0).is_none(),
"C4.5 must reject a split whose intrinsic value is ~0"
);
let ratio = Algorithm::C45
.selection_score(0.4, &[2.0, 2.0], 4.0)
.expect("non-degenerate split has a gain ratio");
assert!((ratio - 0.4).abs() < 1e-12);
assert_eq!(Algorithm::ID3.selection_score(0.4, &[4.0], 4.0), Some(0.4));
assert_eq!(Algorithm::CART.selection_score(0.4, &[4.0], 4.0), Some(0.4));
}
#[test]
fn traverse_tree_missing_left_child_is_corrupt_structure() {
let tree = DecisionTree::new(Algorithm::CART, true).unwrap();
let mut node = Node::new_internal(0, 0.5);
node.right = Some(Box::new(Node::new_leaf(1.0, Some(1), Some(vec![0.0, 1.0]))));
let err = tree.traverse_tree(&node, &[0.0]).unwrap_err();
assert!(
matches!(err, Error::Tree(TreeError::CorruptStructure(_))),
"expected CorruptStructure for missing left child, got {err:?}"
);
}
#[test]
fn traverse_tree_missing_right_child_is_corrupt_structure() {
let tree = DecisionTree::new(Algorithm::CART, true).unwrap();
let mut node = Node::new_internal(0, 0.5);
node.left = Some(Box::new(Node::new_leaf(0.0, Some(0), Some(vec![1.0, 0.0]))));
let err = tree.traverse_tree(&node, &[1.0]).unwrap_err();
assert!(
matches!(err, Error::Tree(TreeError::CorruptStructure(_))),
"expected CorruptStructure for missing right child, got {err:?}"
);
}
#[test]
fn traverse_tree_categorical_no_match_no_fallback_is_corrupt_structure() {
let tree = DecisionTree::new(Algorithm::CART, true).unwrap();
let node = Node::new_categorical(0, vec!["0".to_string()]);
let err = tree.traverse_tree(&node, &[0.0]).unwrap_err();
assert!(
matches!(err, Error::Tree(TreeError::CorruptStructure(_))),
"expected CorruptStructure for categorical node with no child and no fallback, got {err:?}"
);
}
#[test]
fn get_probabilities_leaf_without_probabilities_is_corrupt_structure() {
let tree = DecisionTree::new(Algorithm::CART, true).unwrap();
let leaf = Node::new_leaf(0.0, Some(0), None);
let err = tree.get_probabilities(&leaf, &[0.0]).unwrap_err();
assert!(
matches!(err, Error::Tree(TreeError::CorruptStructure(_))),
"expected CorruptStructure for a leaf missing probabilities, got {err:?}"
);
}
#[test]
fn get_probabilities_categorical_no_match_no_fallback_is_corrupt_structure() {
let tree = DecisionTree::new(Algorithm::CART, true).unwrap();
let node = Node::new_categorical(0, vec!["0".to_string()]);
let err = tree.get_probabilities(&node, &[0.0]).unwrap_err();
assert!(
matches!(err, Error::Tree(TreeError::CorruptStructure(_))),
"expected CorruptStructure for categorical get_probabilities with no child and no fallback, got {err:?}"
);
}
}