mod estimators;
mod node;
mod splitter;
pub use estimators::{DecisionTreeClassifier, DecisionTreeRegressor};
use node::ValueSummary;
use splitter::SkBestSplitter;
use crate::error::{DatarustError, Result};
use crate::label_space::LabelSpace;
use crate::matrix::Matrix;
use crate::traits::ParamValue;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Criterion {
Gini,
Entropy,
SquaredError,
}
impl Criterion {
pub fn as_str(&self) -> &'static str {
match self {
Criterion::Gini => "gini",
Criterion::Entropy => "entropy",
Criterion::SquaredError => "squared_error",
}
}
}
impl std::str::FromStr for Criterion {
type Err = DatarustError;
fn from_str(s: &str) -> Result<Self> {
match s {
"gini" => Ok(Criterion::Gini),
"entropy" => Ok(Criterion::Entropy),
"squared_error" | "mse" => Ok(Criterion::SquaredError),
other => Err(DatarustError::InvalidConfig(format!(
"unknown criterion '{other}' (expected gini, entropy, or squared_error)"
))),
}
}
}
impl std::fmt::Display for Criterion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum MaxFeatures {
All,
Sqrt,
Log2,
Count(usize),
}
impl MaxFeatures {
pub fn resolve(&self, n_features: usize) -> usize {
if n_features == 0 {
return 0;
}
let n = match self {
MaxFeatures::All => n_features,
MaxFeatures::Sqrt => ((n_features as f64).sqrt() as usize).max(1),
MaxFeatures::Log2 => ((n_features as f64).log2() as usize).max(1),
MaxFeatures::Count(k) => (*k).clamp(1, n_features),
};
n.min(n_features).max(1)
}
}
impl std::str::FromStr for MaxFeatures {
type Err = DatarustError;
fn from_str(s: &str) -> Result<Self> {
match s {
"all" => Ok(MaxFeatures::All),
"sqrt" | "auto" => Ok(MaxFeatures::Sqrt),
"log2" => Ok(MaxFeatures::Log2),
other => Err(DatarustError::InvalidConfig(format!(
"unknown max_features '{other}' (expected all, sqrt, or log2)"
))),
}
}
}
#[derive(Debug, Clone, PartialEq)]
struct TreeOptions {
max_depth: Option<usize>,
min_samples_split: usize,
min_samples_leaf: usize,
min_impurity_decrease: f64,
max_features: MaxFeatures,
random_state: u64,
}
impl Default for TreeOptions {
fn default() -> Self {
TreeOptions {
max_depth: None,
min_samples_split: 2,
min_samples_leaf: 1,
min_impurity_decrease: 0.0,
max_features: MaxFeatures::All,
random_state: 0,
}
}
}
impl TreeOptions {
fn validate(&self) -> Result<()> {
if let Some(d) = self.max_depth {
if d == 0 {
return Err(DatarustError::InvalidConfig(
"max_depth must be at least 1".into(),
));
}
}
if self.min_samples_split < 2 {
return Err(DatarustError::InvalidConfig(
"min_samples_split must be at least 2".into(),
));
}
if self.min_samples_leaf == 0 {
return Err(DatarustError::InvalidConfig(
"min_samples_leaf must be at least 1".into(),
));
}
if self.min_impurity_decrease.is_nan() || self.min_impurity_decrease < 0.0 {
return Err(DatarustError::InvalidConfig(
"min_impurity_decrease must be non-negative (positive infinity is allowed)".into(),
));
}
if self.random_state > u32::MAX as u64 {
return Err(DatarustError::InvalidConfig(
"random_state must fit in sklearn's 32-bit seed range".into(),
));
}
if matches!(self.max_features, MaxFeatures::Count(0)) {
return Err(DatarustError::InvalidConfig(
"max_features count must be at least 1".into(),
));
}
Ok(())
}
fn set_shared_param(&mut self, name: &str, value: ParamValue) -> Result<bool> {
match (name, value) {
("min_samples_split", ParamValue::Int(v)) => {
if v < 2 {
return Err(DatarustError::InvalidConfig(
"min_samples_split must be at least 2".into(),
));
}
self.min_samples_split = v;
Ok(true)
}
("min_samples_leaf", ParamValue::Int(v)) => {
if v == 0 {
return Err(DatarustError::InvalidConfig(
"min_samples_leaf must be at least 1".into(),
));
}
self.min_samples_leaf = v;
Ok(true)
}
("min_impurity_decrease", ParamValue::Float(v)) => {
if v.is_nan() || v < 0.0 {
return Err(DatarustError::InvalidConfig(
"min_impurity_decrease must be non-negative".into(),
));
}
self.min_impurity_decrease = v;
Ok(true)
}
("random_state", ParamValue::Int(v)) => {
if (v as u64) > u32::MAX as u64 {
return Err(DatarustError::InvalidConfig(
"random_state must fit in sklearn's 32-bit seed range".into(),
));
}
self.random_state = v as u64;
Ok(true)
}
_ => Ok(false),
}
}
fn shared_params(&self) -> Vec<(&'static str, ParamValue)> {
vec![
("min_samples_split", ParamValue::Int(self.min_samples_split)),
("min_samples_leaf", ParamValue::Int(self.min_samples_leaf)),
(
"min_impurity_decrease",
ParamValue::Float(self.min_impurity_decrease),
),
("random_state", ParamValue::Int(self.random_state as usize)),
]
}
}
pub(crate) const TREE_LEAF: usize = usize::MAX;
#[derive(Debug, Clone)]
pub(crate) struct TreeNode {
pub feature: usize,
pub threshold: f64,
pub left: usize,
pub right: usize,
pub impurity: f64,
pub weighted_n: f64,
pub impurity_improvement: f64,
pub n_samples: usize,
pub value: ValueSummary,
}
impl TreeNode {
pub fn is_leaf(&self) -> bool {
self.left == TREE_LEAF
}
}
#[derive(Debug, Clone)]
pub struct TreeStructure {
pub(crate) nodes: Vec<TreeNode>,
pub(crate) classes: Option<LabelSpace>,
pub(crate) n_features: usize,
}
impl TreeStructure {
pub(crate) fn new(nodes: Vec<TreeNode>, classes: Option<LabelSpace>) -> Self {
let n_features = nodes
.iter()
.filter(|n| !n.is_leaf())
.map(|n| n.feature + 1)
.max()
.unwrap_or(0);
TreeStructure {
nodes,
classes,
n_features,
}
}
pub fn n_nodes(&self) -> usize {
self.nodes.len()
}
pub fn get_depth(&self) -> usize {
fn walk(nodes: &[TreeNode], idx: usize) -> usize {
let node = &nodes[idx];
if node.is_leaf() {
0
} else {
1 + walk(nodes, node.left).max(walk(nodes, node.right))
}
}
if self.nodes.is_empty() {
0
} else {
walk(&self.nodes, 0)
}
}
pub fn n_leaves(&self) -> usize {
self.nodes.iter().filter(|n| n.is_leaf()).count()
}
pub fn classes(&self) -> Option<&[f64]> {
self.classes.as_ref().map(|ls| ls.labels())
}
pub fn leaf_for(&self, row: &[f64]) -> Result<usize> {
if self.nodes.is_empty() {
return Err(DatarustError::InvalidInput("tree has no nodes".into()));
}
let feature_count = self.n_features;
if row.len() < feature_count {
return Err(DatarustError::ShapeMismatch {
expected: format!("at least {feature_count} features"),
actual: format!("{} features", row.len()),
});
}
if let Some((index, value)) = row
.iter()
.copied()
.enumerate()
.find(|(_, value)| !value.is_finite())
{
return Err(DatarustError::InvalidInput(format!(
"non-finite prediction value {value} at feature {index}"
)));
}
let mut idx = 0usize;
loop {
let node = self.nodes.get(idx).ok_or_else(|| {
DatarustError::InvalidInput("tree contains an invalid child index".into())
})?;
if node.is_leaf() {
return Ok(idx);
}
let next = if row[node.feature] <= node.threshold {
node.left
} else {
node.right
};
if next == TREE_LEAF {
return Err(DatarustError::InvalidInput(
"internal tree node has a leaf sentinel child".into(),
));
}
idx = next;
}
}
pub fn node_feature(&self, idx: usize) -> Option<usize> {
let node = self.nodes.get(idx)?;
(!node.is_leaf()).then_some(node.feature)
}
pub fn root_class_counts(&self) -> Option<Vec<f64>> {
match &self.nodes[0].value {
ValueSummary::Classification(h) => Some(h.counts.clone()),
ValueSummary::Regression { .. } => None,
}
}
pub fn node_threshold(&self, idx: usize) -> Option<f64> {
let node = self.nodes.get(idx)?;
(!node.is_leaf()).then_some(node.threshold)
}
pub fn node_regression_value(&self, idx: usize) -> Option<f64> {
match &self.nodes.get(idx)?.value {
ValueSummary::Regression { sum, n, .. } => Some(sum / *n),
ValueSummary::Classification(_) => None,
}
}
pub fn node_impurity(&self, idx: usize) -> Option<f64> {
self.nodes.get(idx).map(|n| n.impurity)
}
pub fn node_improvement(&self, idx: usize) -> Option<f64> {
self.nodes.get(idx).map(|n| n.impurity_improvement)
}
pub fn node_n_samples(&self, idx: usize) -> Option<usize> {
self.nodes.get(idx).map(|n| n.n_samples)
}
pub fn feature_importances(&self, n_features: usize) -> Vec<f64> {
let mut importances = vec![0.0; n_features];
let nodes = &self.nodes;
for node in nodes {
if !node.is_leaf() {
let left = &nodes[node.left];
let right = &nodes[node.right];
importances[node.feature] += f64::mul_add(
node.weighted_n,
node.impurity,
-(left.weighted_n * left.impurity),
) - right.weighted_n * right.impurity;
}
}
let root_weighted = nodes[0].weighted_n;
for v in &mut importances {
*v /= root_weighted;
}
let normalizer = numpy_pairwise_sum(&importances);
if normalizer > 0.0 {
for v in &mut importances {
*v /= normalizer;
}
}
importances
}
}
pub(crate) fn numpy_pairwise_sum(a: &[f64]) -> f64 {
const PW_BLOCKSIZE: usize = 128;
let n = a.len();
if n < 8 {
let mut res = 0.0;
for &v in a {
res += v;
}
res
} else if n <= PW_BLOCKSIZE {
let mut r = [0.0f64; 8];
r.copy_from_slice(&a[..8]);
let mut i = 8;
let unrolled_end = n - (n % 8);
while i < unrolled_end {
r[0] += a[i];
r[1] += a[i + 1];
r[2] += a[i + 2];
r[3] += a[i + 3];
r[4] += a[i + 4];
r[5] += a[i + 5];
r[6] += a[i + 6];
r[7] += a[i + 7];
i += 8;
}
let mut res = ((r[0] + r[1]) + (r[2] + r[3])) + ((r[4] + r[5]) + (r[6] + r[7]));
while i < n {
res += a[i];
i += 1;
}
res
} else {
let mut n2 = n / 2;
n2 -= n2 % 8;
numpy_pairwise_sum(&a[..n2]) + numpy_pairwise_sum(&a[n2..])
}
}
struct StackRecord {
start: usize,
end: usize,
depth: usize,
parent: usize,
is_left: bool,
impurity: f64,
n_constant_features: usize,
}
struct TreeBuilder<'a> {
splitter: SkBestSplitter<'a>,
options: TreeOptions,
min_weight_leaf: f64,
nodes: Vec<TreeNode>,
}
impl<'a> TreeBuilder<'a> {
fn new_with_weights(
x: &'a Matrix,
y: &'a [f64],
labels: Option<&'a [usize]>,
n_classes: usize,
criterion: Criterion,
options: TreeOptions,
sample_weights: Option<Vec<f64>>,
) -> Self {
let splitter = SkBestSplitter::new_with_weights(
x,
y,
labels,
n_classes,
criterion,
options.max_features,
options.min_samples_leaf,
0.0, options.random_state,
sample_weights,
);
TreeBuilder {
splitter,
options,
min_weight_leaf: 0.0,
nodes: Vec::new(),
}
}
fn add_node(&mut self, node: TreeNode) -> usize {
self.nodes.push(node);
self.nodes.len() - 1
}
fn build(&mut self) {
let n_node_samples = self.splitter.n_samples();
let max_depth = self.options.max_depth.unwrap_or(usize::MAX);
let min_samples_split = self.options.min_samples_split;
let min_samples_leaf = self.options.min_samples_leaf;
let min_weight_leaf = self.min_weight_leaf;
let min_impurity_decrease = self.options.min_impurity_decrease;
let mut stack = vec![StackRecord {
start: 0,
end: n_node_samples,
depth: 0,
parent: TREE_LEAF,
is_left: false,
impurity: f64::INFINITY,
n_constant_features: 0,
}];
let mut first = true;
while let Some(record) = stack.pop() {
let start = record.start;
let end = record.end;
let depth = record.depth;
let parent = record.parent;
let is_left = record.is_left;
let mut n_constant_features = record.n_constant_features;
let n_node_samples = end - start;
let weighted_n_node_samples = self.splitter.node_reset(start, end);
let mut is_leaf = depth >= max_depth
|| n_node_samples < min_samples_split
|| n_node_samples < min_samples_leaf.saturating_mul(2)
|| weighted_n_node_samples < 2.0 * min_weight_leaf;
let node_impurity = if first {
first = false;
self.splitter.node_impurity()
} else {
record.impurity
};
is_leaf = is_leaf || node_impurity <= f64::EPSILON;
let mut split = splitter::SplitRecord::init(end);
if !is_leaf {
let (s, n_const) = self.splitter.node_split(node_impurity, n_constant_features);
split = s;
n_constant_features = n_const;
is_leaf = is_leaf
|| split.pos >= end
|| (split.improvement + f64::EPSILON < min_impurity_decrease);
}
let node_id = self.add_node(TreeNode {
feature: split.feature,
threshold: if is_leaf { 0.0 } else { split.threshold },
left: TREE_LEAF,
right: TREE_LEAF,
impurity: node_impurity,
weighted_n: weighted_n_node_samples,
impurity_improvement: if is_leaf { 0.0 } else { split.improvement },
n_samples: n_node_samples,
value: self.splitter.node_value(),
});
if parent != TREE_LEAF {
let parent_node = &mut self.nodes[parent];
if is_left {
parent_node.left = node_id;
} else {
parent_node.right = node_id;
}
}
if !is_leaf {
stack.push(StackRecord {
start: split.pos,
end,
depth: depth + 1,
parent: node_id,
is_left: false,
impurity: split.impurity_right,
n_constant_features,
});
stack.push(StackRecord {
start,
end: split.pos,
depth: depth + 1,
parent: node_id,
is_left: true,
impurity: split.impurity_left,
n_constant_features,
});
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::Predictor;
use estimators::{DecisionTreeClassifier, DecisionTreeRegressor};
#[test]
fn perfect_axis_aligned_separation() {
let x = Matrix::new(vec![
vec![1.0, 5.0],
vec![2.0, 6.0],
vec![3.0, 7.0],
vec![8.0, 1.0],
vec![9.0, 2.0],
vec![10.0, 3.0],
])
.unwrap();
let y = vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
let mut clf = DecisionTreeClassifier::new();
clf.fit(&x, &y).unwrap();
assert_eq!(clf.predict(&x).unwrap(), y);
let tree = clf.tree().unwrap();
assert_eq!(tree.n_leaves(), 2);
assert_eq!(tree.get_depth(), 1);
let feature = tree.node_feature(0).unwrap();
let threshold = tree.node_threshold(0).unwrap();
assert_eq!(feature, 1);
assert_eq!(threshold, 4.0);
}
#[test]
fn gini_vs_entropy_split_choice() {
let x = Matrix::new(vec![vec![1.0], vec![2.0], vec![3.0], vec![4.0]]).unwrap();
let y = vec![0.0, 0.0, 1.0, 1.0];
for criterion in [Criterion::Gini, Criterion::Entropy] {
let mut clf = DecisionTreeClassifier::new().with_criterion(criterion);
clf.fit(&x, &y).unwrap();
assert_eq!(clf.predict(&x).unwrap(), y);
}
}
#[test]
fn max_depth_limits_growth() {
let x = Matrix::new(vec![
vec![1.0],
vec![2.0],
vec![3.0],
vec![4.0],
vec![5.0],
vec![6.0],
])
.unwrap();
let y = vec![0.0, 0.0, 1.0, 0.0, 1.0, 1.0];
let mut clf = DecisionTreeClassifier::new().with_max_depth(Some(1));
clf.fit(&x, &y).unwrap();
assert_eq!(clf.tree().unwrap().get_depth(), 1);
assert!(clf.predict(&x).unwrap() != y);
}
#[test]
fn min_samples_leaf_forces_leaf_merging() {
let x = Matrix::new(vec![vec![1.0], vec![2.0], vec![3.0], vec![4.0]]).unwrap();
let y = vec![0.0, 0.0, 1.0, 1.0];
let mut clf = DecisionTreeClassifier::new().with_min_samples_leaf(3);
clf.fit(&x, &y).unwrap();
assert_eq!(clf.tree().unwrap().n_nodes(), 1);
}
#[test]
fn regression_reduces_variance_monotonically() {
let x = Matrix::new(vec![
vec![1.0],
vec![2.0],
vec![3.0],
vec![10.0],
vec![11.0],
vec![12.0],
])
.unwrap();
let y = vec![1.0, 2.0, 1.5, 10.0, 11.0, 10.5];
let mut reg = DecisionTreeRegressor::new();
reg.fit(&x, &y).unwrap();
let pred = reg.predict(&x).unwrap();
for i in 0..y.len() {
assert!((pred[i] - y[i]).abs() < 1e-12);
}
let r2 = crate::traits::Regressor::score(®, &x, &y).unwrap();
assert!((r2 - 1.0).abs() < 1e-12);
}
#[test]
fn feature_importances_sum_to_one() {
let x = Matrix::new(vec![
vec![1.0, 9.0],
vec![2.0, 8.0],
vec![3.0, 7.0],
vec![9.0, 1.0],
vec![8.0, 2.0],
])
.unwrap();
let y = vec![0.0, 0.0, 0.0, 1.0, 1.0];
let mut clf = DecisionTreeClassifier::new();
clf.fit(&x, &y).unwrap();
let imp = clf.feature_importances().unwrap();
let total: f64 = imp.iter().sum();
assert!((total - 1.0).abs() < 1e-12);
}
#[test]
fn unfitted_predict_errors() {
let clf = DecisionTreeClassifier::new();
let x = Matrix::new(vec![vec![1.0]]).unwrap();
assert!(clf.predict(&x).is_err());
assert!(!clf.is_fitted());
}
#[test]
fn label_validation_via_label_space() {
let x = Matrix::new(vec![vec![1.0], vec![2.0]]).unwrap();
let y = vec![0.0, 0.5];
let mut clf = DecisionTreeClassifier::new();
assert!(clf.fit(&x, &y).is_err());
}
#[test]
fn shape_mismatch_is_reported() {
let x = Matrix::new(vec![vec![1.0], vec![2.0]]).unwrap();
let mut clf = DecisionTreeClassifier::new();
assert!(clf.fit(&x, &[0.0]).is_err());
}
#[test]
fn predict_proba_rows_sum_to_one() {
let x = Matrix::new(vec![
vec![1.0, 5.0],
vec![2.0, 6.0],
vec![8.0, 1.0],
vec![9.0, 2.0],
])
.unwrap();
let y = vec![0.0, 0.0, 1.0, 1.0];
let mut clf = DecisionTreeClassifier::new();
clf.fit(&x, &y).unwrap();
let proba = crate::traits::PredictProba::predict_proba(&clf, &x).unwrap();
assert_eq!(proba.ncols(), 2);
for i in 0..proba.nrows() {
let s: f64 = (0..2).map(|j| proba.get(i, j)).sum();
assert!((s - 1.0).abs() < 1e-12);
}
}
#[test]
fn deterministic_given_random_state() {
let x = Matrix::new(vec![
vec![1.0, 5.0, 2.0],
vec![2.0, 6.0, 3.0],
vec![3.0, 7.0, 4.0],
vec![8.0, 1.0, 9.0],
vec![9.0, 2.0, 8.0],
vec![10.0, 3.0, 7.0],
])
.unwrap();
let y = vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
let build = || {
let mut clf = DecisionTreeClassifier::new()
.with_max_features(MaxFeatures::Count(1))
.with_random_state(7);
clf.fit(&x, &y).unwrap();
clf.predict(&x).unwrap()
};
assert_eq!(build(), build());
}
#[test]
fn pure_node_never_splits() {
let x = Matrix::new(vec![vec![1.0], vec![2.0], vec![3.0], vec![4.0]]).unwrap();
let y = vec![1.0, 1.0, 1.0, 1.0];
let mut clf = DecisionTreeClassifier::new();
clf.fit(&x, &y).unwrap();
let tree = clf.tree().unwrap();
assert_eq!(tree.n_nodes(), 1);
assert_eq!(tree.n_leaves(), 1);
}
#[test]
fn criterion_and_max_features_parse_from_str() {
assert_eq!("gini".parse::<Criterion>().unwrap(), Criterion::Gini);
assert_eq!(
"squared_error".parse::<Criterion>().unwrap(),
Criterion::SquaredError
);
assert!("bogus".parse::<Criterion>().is_err());
assert_eq!("sqrt".parse::<MaxFeatures>().unwrap(), MaxFeatures::Sqrt);
assert!("nope".parse::<MaxFeatures>().is_err());
}
}