use std::collections::{HashMap, VecDeque};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[inline]
fn xorshift64(state: &mut u64) -> u64 {
let mut x = *state;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
*state = x;
x
}
#[derive(Debug, Error, Serialize, Deserialize, Clone, PartialEq)]
pub enum DtlError {
#[error("training set is empty")]
EmptyTrainingSet,
#[error("expected {expected} features, got {got}")]
FeatureDimensionMismatch { expected: usize, got: usize },
#[error("model has not been trained yet")]
ModelNotTrained,
#[error("unknown class label: {0}")]
UnknownLabel(String),
#[error("feature names count {names} does not match feature dimension {dim}")]
FeatureNamesMismatch { names: usize, dim: usize },
#[error("arithmetic error: {0}")]
Arithmetic(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum DtlCriterion {
Entropy,
#[default]
Gini,
MisclassificationRate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DtlLearnerConfig {
pub max_depth: usize,
pub min_samples_split: usize,
pub min_samples_leaf: usize,
pub criterion: DtlCriterion,
pub max_features: Option<usize>,
pub seed: u64,
}
impl Default for DtlLearnerConfig {
fn default() -> Self {
Self {
max_depth: 0,
min_samples_split: 2,
min_samples_leaf: 1,
criterion: DtlCriterion::Gini,
max_features: None,
seed: 0x1234_5678_9abc_def0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DtlSample {
pub features: Vec<f64>,
pub label: String,
}
impl DtlSample {
pub fn new(features: Vec<f64>, label: impl Into<String>) -> Self {
Self {
features,
label: label.into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DtlPrediction {
pub label: String,
pub confidence: f64,
pub path_depth: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DtlNode {
Leaf {
class_label: String,
samples: usize,
class_distribution: HashMap<String, usize>,
},
Split {
feature_index: usize,
threshold: f64,
left: Box<DtlNode>,
right: Box<DtlNode>,
feature_name: String,
samples: usize,
impurity: f64,
},
}
impl DtlNode {
pub fn n_leaves(&self) -> usize {
match self {
DtlNode::Leaf { .. } => 1,
DtlNode::Split { left, right, .. } => left.n_leaves() + right.n_leaves(),
}
}
pub fn n_nodes(&self) -> usize {
match self {
DtlNode::Leaf { .. } => 1,
DtlNode::Split { left, right, .. } => 1 + left.n_nodes() + right.n_nodes(),
}
}
pub fn depth(&self) -> usize {
match self {
DtlNode::Leaf { .. } => 1,
DtlNode::Split { left, right, .. } => 1 + left.depth().max(right.depth()),
}
}
pub(crate) fn accumulate_importance(&self, total_samples: usize, importance: &mut Vec<f64>) {
if total_samples == 0 {
return;
}
match self {
DtlNode::Leaf { .. } => {}
DtlNode::Split {
feature_index,
left,
right,
samples,
impurity,
..
} => {
let left_n = match left.as_ref() {
DtlNode::Leaf { samples: s, .. } => *s,
DtlNode::Split { samples: s, .. } => *s,
};
let right_n = match right.as_ref() {
DtlNode::Leaf { samples: s, .. } => *s,
DtlNode::Split { samples: s, .. } => *s,
};
let n = *samples as f64;
let left_imp = node_impurity(left);
let right_imp = node_impurity(right);
let gain =
impurity - (left_n as f64 / n) * left_imp - (right_n as f64 / n) * right_imp;
let weighted = (n / total_samples as f64) * gain;
if *feature_index < importance.len() {
importance[*feature_index] += weighted;
}
left.accumulate_importance(total_samples, importance);
right.accumulate_importance(total_samples, importance);
}
}
}
}
fn node_impurity(node: &DtlNode) -> f64 {
match node {
DtlNode::Leaf { .. } => 0.0,
DtlNode::Split { impurity, .. } => *impurity,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DtlTrainingRecord {
pub timestamp_secs: u64,
pub n_samples: usize,
pub n_features: usize,
pub n_classes: usize,
pub tree_depth: usize,
pub n_leaves: usize,
pub criterion: DtlCriterion,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DtlLearnerStats {
pub is_trained: bool,
pub last_n_samples: usize,
pub n_features: usize,
pub n_classes: usize,
pub tree_depth: usize,
pub n_leaves: usize,
pub n_nodes: usize,
pub history_len: usize,
pub criterion: DtlCriterion,
pub feature_names: Vec<String>,
pub class_labels: Vec<String>,
}
pub struct DecisionTreeLearner {
root: Option<DtlNode>,
feature_names: Vec<String>,
class_labels: Vec<String>,
history: VecDeque<DtlTrainingRecord>,
config: DtlLearnerConfig,
n_features: usize,
last_n_samples: usize,
}
pub type DtlDecisionTreeLearner = DecisionTreeLearner;
impl DecisionTreeLearner {
pub fn new(config: DtlLearnerConfig, feature_names: Vec<String>) -> Self {
Self {
root: None,
feature_names,
class_labels: Vec::new(),
history: VecDeque::new(),
config,
n_features: 0,
last_n_samples: 0,
}
}
pub fn with_defaults(feature_names: Vec<String>) -> Self {
Self::new(DtlLearnerConfig::default(), feature_names)
}
}
impl DecisionTreeLearner {
pub fn fit(&mut self, samples: &[DtlSample]) -> Result<(), DtlError> {
if samples.is_empty() {
return Err(DtlError::EmptyTrainingSet);
}
let n_features = samples[0].features.len();
if !self.feature_names.is_empty() && self.feature_names.len() != n_features {
return Err(DtlError::FeatureNamesMismatch {
names: self.feature_names.len(),
dim: n_features,
});
}
if self.feature_names.is_empty() {
self.feature_names = (0..n_features).map(|i| format!("f{i}")).collect();
}
let mut label_set: Vec<String> = samples
.iter()
.map(|s| s.label.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
label_set.sort();
self.class_labels = label_set;
self.n_features = n_features;
self.last_n_samples = samples.len();
let mut rng_state = self.config.seed;
let indices: Vec<usize> = (0..samples.len()).collect();
self.root = Some(self.build_node(samples, &indices, 0, &mut rng_state));
let depth = self.depth();
let leaves = self.n_leaves();
let record = DtlTrainingRecord {
timestamp_secs: current_epoch_secs(),
n_samples: samples.len(),
n_features,
n_classes: self.class_labels.len(),
tree_depth: depth,
n_leaves: leaves,
criterion: self.config.criterion,
};
self.history.push_back(record);
while self.history.len() > 100 {
self.history.pop_front();
}
Ok(())
}
fn build_node(
&self,
samples: &[DtlSample],
indices: &[usize],
depth: usize,
rng: &mut u64,
) -> DtlNode {
let distribution = class_distribution(samples, indices);
let majority = majority_class(&distribution);
let n = indices.len();
let max_depth_reached = self.config.max_depth > 0 && depth >= self.config.max_depth;
let too_few_to_split = n < self.config.min_samples_split;
let pure = distribution.len() == 1;
if pure || max_depth_reached || too_few_to_split {
return DtlNode::Leaf {
class_label: majority,
samples: n,
class_distribution: distribution,
};
}
let impurity = compute_impurity(&distribution, n, self.config.criterion);
let candidate_features = select_features(self.n_features, self.config.max_features, rng);
let best = find_best_split(
samples,
indices,
&candidate_features,
self.config.criterion,
self.config.min_samples_leaf,
impurity,
);
match best {
None => DtlNode::Leaf {
class_label: majority,
samples: n,
class_distribution: distribution,
},
Some(split) => {
let (left_idx, right_idx): (Vec<usize>, Vec<usize>) = indices
.iter()
.copied()
.partition(|&i| samples[i].features[split.feature_index] <= split.threshold);
let feature_name = self
.feature_names
.get(split.feature_index)
.cloned()
.unwrap_or_else(|| format!("f{}", split.feature_index));
let left = self.build_node(samples, &left_idx, depth + 1, rng);
let right = self.build_node(samples, &right_idx, depth + 1, rng);
DtlNode::Split {
feature_index: split.feature_index,
threshold: split.threshold,
left: Box::new(left),
right: Box::new(right),
feature_name,
samples: n,
impurity,
}
}
}
}
}
impl DecisionTreeLearner {
pub fn predict(&self, features: &[f64]) -> Result<DtlPrediction, DtlError> {
let root = self.root.as_ref().ok_or(DtlError::ModelNotTrained)?;
if features.len() != self.n_features {
return Err(DtlError::FeatureDimensionMismatch {
expected: self.n_features,
got: features.len(),
});
}
Ok(traverse(root, features, 0))
}
pub fn predict_batch(&self, samples: &[Vec<f64>]) -> Vec<Result<DtlPrediction, DtlError>> {
samples.iter().map(|f| self.predict(f)).collect()
}
}
fn traverse(node: &DtlNode, features: &[f64], depth: usize) -> DtlPrediction {
match node {
DtlNode::Leaf {
class_label,
samples,
class_distribution,
} => {
let count = class_distribution.get(class_label).copied().unwrap_or(0);
let confidence = if *samples > 0 {
count as f64 / *samples as f64
} else {
0.0
};
DtlPrediction {
label: class_label.clone(),
confidence,
path_depth: depth,
}
}
DtlNode::Split {
feature_index,
threshold,
left,
right,
..
} => {
let val = features.get(*feature_index).copied().unwrap_or(f64::NAN);
if val <= *threshold {
traverse(left, features, depth + 1)
} else {
traverse(right, features, depth + 1)
}
}
}
}
impl DecisionTreeLearner {
pub fn feature_importance(&self) -> Vec<(String, f64)> {
let root = match &self.root {
Some(r) => r,
None => return Vec::new(),
};
let mut raw = vec![0.0f64; self.n_features];
root.accumulate_importance(self.last_n_samples, &mut raw);
let total: f64 = raw.iter().sum();
let mut result: Vec<(String, f64)> = raw
.into_iter()
.enumerate()
.map(|(i, v)| {
let name = self
.feature_names
.get(i)
.cloned()
.unwrap_or_else(|| format!("f{i}"));
let normalised = if total > 0.0 { v / total } else { 0.0 };
(name, normalised)
})
.collect();
result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
result
}
}
impl DecisionTreeLearner {
pub fn depth(&self) -> usize {
self.root.as_ref().map_or(0, |r| r.depth())
}
pub fn n_leaves(&self) -> usize {
self.root.as_ref().map_or(0, |r| r.n_leaves())
}
pub fn n_nodes(&self) -> usize {
self.root.as_ref().map_or(0, |r| r.n_nodes())
}
}
impl DecisionTreeLearner {
pub fn prune(&mut self, min_samples: usize) {
if let Some(root) = self.root.take() {
self.root = Some(prune_node(root, min_samples));
}
}
}
fn prune_node(node: DtlNode, min_samples: usize) -> DtlNode {
match node {
leaf @ DtlNode::Leaf { .. } => leaf,
DtlNode::Split {
feature_index,
threshold,
left,
right,
feature_name,
samples,
impurity,
} => {
let pruned_left = prune_node(*left, min_samples);
let pruned_right = prune_node(*right, min_samples);
let should_collapse = match (&pruned_left, &pruned_right) {
(
DtlNode::Leaf {
samples: ls,
class_distribution: ld,
..
},
DtlNode::Leaf {
samples: rs,
class_distribution: rd,
..
},
) => {
let left_majority_count = ld.values().copied().max().unwrap_or(0);
let right_majority_count = rd.values().copied().max().unwrap_or(0);
*ls < min_samples
&& *rs < min_samples
&& left_majority_count < min_samples
&& right_majority_count < min_samples
}
_ => false,
};
if should_collapse {
let mut merged: HashMap<String, usize> = HashMap::new();
let accumulate = |dist: &HashMap<String, usize>, m: &mut HashMap<String, usize>| {
for (k, v) in dist {
*m.entry(k.clone()).or_insert(0) += v;
}
};
if let DtlNode::Leaf {
class_distribution, ..
} = &pruned_left
{
accumulate(class_distribution, &mut merged);
}
if let DtlNode::Leaf {
class_distribution, ..
} = &pruned_right
{
accumulate(class_distribution, &mut merged);
}
let majority = majority_class(&merged);
DtlNode::Leaf {
class_label: majority,
samples,
class_distribution: merged,
}
} else {
DtlNode::Split {
feature_index,
threshold,
left: Box::new(pruned_left),
right: Box::new(pruned_right),
feature_name,
samples,
impurity,
}
}
}
}
}
impl DecisionTreeLearner {
pub fn learner_stats(&self) -> DtlLearnerStats {
DtlLearnerStats {
is_trained: self.root.is_some(),
last_n_samples: self.last_n_samples,
n_features: self.n_features,
n_classes: self.class_labels.len(),
tree_depth: self.depth(),
n_leaves: self.n_leaves(),
n_nodes: self.n_nodes(),
history_len: self.history.len(),
criterion: self.config.criterion,
feature_names: self.feature_names.clone(),
class_labels: self.class_labels.clone(),
}
}
pub fn history(&self) -> &VecDeque<DtlTrainingRecord> {
&self.history
}
pub fn config(&self) -> &DtlLearnerConfig {
&self.config
}
pub fn feature_names(&self) -> &[String] {
&self.feature_names
}
pub fn class_labels(&self) -> &[String] {
&self.class_labels
}
pub fn root(&self) -> Option<&DtlNode> {
self.root.as_ref()
}
}
fn class_distribution(samples: &[DtlSample], indices: &[usize]) -> HashMap<String, usize> {
let mut dist: HashMap<String, usize> = HashMap::new();
for &i in indices {
if let Some(s) = samples.get(i) {
*dist.entry(s.label.clone()).or_insert(0) += 1;
}
}
dist
}
fn majority_class(dist: &HashMap<String, usize>) -> String {
dist.iter()
.max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
.map(|(k, _)| k.clone())
.unwrap_or_default()
}
fn compute_impurity(dist: &HashMap<String, usize>, n: usize, criterion: DtlCriterion) -> f64 {
if n == 0 {
return 0.0;
}
match criterion {
DtlCriterion::Entropy => entropy(dist, n),
DtlCriterion::Gini => gini(dist, n),
DtlCriterion::MisclassificationRate => misclassification_rate(dist, n),
}
}
fn entropy(dist: &HashMap<String, usize>, n: usize) -> f64 {
let mut h = 0.0f64;
for &count in dist.values() {
if count == 0 {
continue;
}
let p = count as f64 / n as f64;
h -= p * p.log2();
}
h
}
fn gini(dist: &HashMap<String, usize>, n: usize) -> f64 {
let mut sum_sq = 0.0f64;
for &count in dist.values() {
let p = count as f64 / n as f64;
sum_sq += p * p;
}
1.0 - sum_sq
}
fn misclassification_rate(dist: &HashMap<String, usize>, n: usize) -> f64 {
let max_count = dist.values().copied().max().unwrap_or(0);
1.0 - max_count as f64 / n as f64
}
struct SplitCandidate {
feature_index: usize,
threshold: f64,
gain: f64,
}
fn find_best_split(
samples: &[DtlSample],
indices: &[usize],
candidate_features: &[usize],
criterion: DtlCriterion,
min_samples_leaf: usize,
parent_impurity: f64,
) -> Option<SplitCandidate> {
let n = indices.len();
let mut best: Option<SplitCandidate> = None;
for &feat in candidate_features {
let mut sorted: Vec<usize> = indices.to_vec();
sorted.sort_by(|&a, &b| {
let va = samples[a].features.get(feat).copied().unwrap_or(f64::NAN);
let vb = samples[b].features.get(feat).copied().unwrap_or(f64::NAN);
va.partial_cmp(&vb).unwrap_or(std::cmp::Ordering::Equal)
});
let mut left_dist: HashMap<String, usize> = HashMap::new();
let mut right_dist: HashMap<String, usize> = HashMap::new();
for &i in &sorted {
if let Some(s) = samples.get(i) {
*right_dist.entry(s.label.clone()).or_insert(0) += 1;
}
}
for split_pos in 0..(n - 1) {
let idx = sorted[split_pos];
if let Some(s) = samples.get(idx) {
let left_cnt = left_dist.entry(s.label.clone()).or_insert(0);
*left_cnt += 1;
let right_cnt = right_dist.entry(s.label.clone()).or_insert(1);
*right_cnt = right_cnt.saturating_sub(1);
if *right_cnt == 0 {
right_dist.remove(&s.label);
}
}
let left_n = split_pos + 1;
let right_n = n - left_n;
if left_n < min_samples_leaf || right_n < min_samples_leaf {
continue;
}
let v_cur = samples[sorted[split_pos]]
.features
.get(feat)
.copied()
.unwrap_or(f64::NAN);
let v_next = samples[sorted[split_pos + 1]]
.features
.get(feat)
.copied()
.unwrap_or(f64::NAN);
if (v_cur - v_next).abs() < f64::EPSILON {
continue;
}
let left_imp = compute_impurity(&left_dist, left_n, criterion);
let right_imp = compute_impurity(&right_dist, right_n, criterion);
let weighted_child_imp =
(left_n as f64 / n as f64) * left_imp + (right_n as f64 / n as f64) * right_imp;
let gain = parent_impurity - weighted_child_imp;
if gain > best.as_ref().map_or(f64::NEG_INFINITY, |b| b.gain) {
let threshold = (v_cur + v_next) / 2.0;
best = Some(SplitCandidate {
feature_index: feat,
threshold,
gain,
});
}
}
}
best.filter(|b| b.gain > 0.0)
}
fn select_features(n_features: usize, max_features: Option<usize>, rng: &mut u64) -> Vec<usize> {
let k = max_features.unwrap_or(n_features).min(n_features);
let mut indices: Vec<usize> = (0..n_features).collect();
for i in 0..k {
let j = i + (xorshift64(rng) as usize % (n_features - i));
indices.swap(i, j);
}
indices.truncate(k);
indices
}
fn current_epoch_secs() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn binary_samples() -> Vec<DtlSample> {
vec![
DtlSample::new(vec![1.0, 2.0], "A"),
DtlSample::new(vec![1.5, 2.5], "A"),
DtlSample::new(vec![5.0, 6.0], "B"),
DtlSample::new(vec![5.5, 6.5], "B"),
DtlSample::new(vec![6.0, 7.0], "B"),
]
}
fn three_class_samples() -> Vec<DtlSample> {
vec![
DtlSample::new(vec![0.1], "setosa"),
DtlSample::new(vec![0.2], "setosa"),
DtlSample::new(vec![3.0], "versicolor"),
DtlSample::new(vec![3.1], "versicolor"),
DtlSample::new(vec![6.0], "virginica"),
DtlSample::new(vec![6.1], "virginica"),
]
}
fn make_learner() -> DecisionTreeLearner {
DecisionTreeLearner::new(DtlLearnerConfig::default(), vec!["x".into(), "y".into()])
}
fn make_one_feature_learner() -> DecisionTreeLearner {
DecisionTreeLearner::new(DtlLearnerConfig::default(), vec!["x".into()])
}
#[test]
fn test_new_not_trained() {
let l = make_learner();
assert!(l.root().is_none());
}
#[test]
fn test_default_config() {
let cfg = DtlLearnerConfig::default();
assert_eq!(cfg.max_depth, 0);
assert_eq!(cfg.min_samples_split, 2);
assert_eq!(cfg.min_samples_leaf, 1);
assert_eq!(cfg.criterion, DtlCriterion::Gini);
assert!(cfg.max_features.is_none());
}
#[test]
fn test_with_defaults_constructor() {
let l = DecisionTreeLearner::with_defaults(vec!["a".into()]);
assert_eq!(l.feature_names(), &["a"]);
}
#[test]
fn test_fit_empty_error() {
let mut l = make_learner();
let res = l.fit(&[]);
assert!(matches!(res, Err(DtlError::EmptyTrainingSet)));
}
#[test]
fn test_predict_not_trained_error() {
let l = make_learner();
let res = l.predict(&[1.0, 2.0]);
assert!(matches!(res, Err(DtlError::ModelNotTrained)));
}
#[test]
fn test_predict_wrong_dim_error() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
let res = l.predict(&[1.0]);
assert!(matches!(
res,
Err(DtlError::FeatureDimensionMismatch { .. })
));
}
#[test]
fn test_feature_names_mismatch_error() {
let mut l = DecisionTreeLearner::new(
DtlLearnerConfig::default(),
vec!["a".into(), "b".into(), "c".into()],
);
let res = l.fit(&binary_samples());
assert!(matches!(res, Err(DtlError::FeatureNamesMismatch { .. })));
}
#[test]
fn test_fit_binary_ok() {
let mut l = make_learner();
assert!(l.fit(&binary_samples()).is_ok());
assert!(l.root().is_some());
}
#[test]
fn test_fit_populates_class_labels() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
let mut labels = l.class_labels().to_vec();
labels.sort();
assert_eq!(labels, vec!["A", "B"]);
}
#[test]
fn test_fit_populates_n_features() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
assert_eq!(l.n_features, 2);
}
#[test]
fn test_fit_three_classes() {
let mut l = make_one_feature_learner();
assert!(l.fit(&three_class_samples()).is_ok());
}
#[test]
fn test_predict_class_a() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
let p = l.predict(&[1.2, 2.2]).expect("test: should succeed");
assert_eq!(p.label, "A");
}
#[test]
fn test_predict_class_b() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
let p = l.predict(&[5.5, 6.5]).expect("test: should succeed");
assert_eq!(p.label, "B");
}
#[test]
fn test_predict_confidence_range() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
let p = l.predict(&[1.0, 2.0]).expect("test: should succeed");
assert!((0.0..=1.0).contains(&p.confidence));
}
#[test]
fn test_predict_path_depth_positive() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
let p = l.predict(&[1.0, 2.0]).expect("test: should succeed");
assert!(p.path_depth < 100);
}
#[test]
fn test_predict_three_classes_all() {
let mut l = make_one_feature_learner();
l.fit(&three_class_samples()).expect("test: should succeed");
assert_eq!(
l.predict(&[0.1]).expect("test: should succeed").label,
"setosa"
);
assert_eq!(
l.predict(&[3.0]).expect("test: should succeed").label,
"versicolor"
);
assert_eq!(
l.predict(&[6.0]).expect("test: should succeed").label,
"virginica"
);
}
#[test]
fn test_predict_batch() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
let results = l.predict_batch(&[vec![1.0, 2.0], vec![5.0, 6.0]]);
assert_eq!(results.len(), 2);
assert_eq!(
results[0].as_ref().expect("test: should succeed").label,
"A"
);
assert_eq!(
results[1].as_ref().expect("test: should succeed").label,
"B"
);
}
#[test]
fn test_predict_batch_empty() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
let results = l.predict_batch(&[]);
assert!(results.is_empty());
}
#[test]
fn test_predict_batch_error_propagation() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
let results = l.predict_batch(&[vec![1.0]]);
assert!(results[0].is_err());
}
#[test]
fn test_depth_increases_with_data() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
assert!(l.depth() >= 1);
}
#[test]
fn test_n_leaves_positive() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
assert!(l.n_leaves() >= 1);
}
#[test]
fn test_n_nodes_geq_n_leaves() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
assert!(l.n_nodes() >= l.n_leaves());
}
#[test]
fn test_depth_zero_before_training() {
let l = make_learner();
assert_eq!(l.depth(), 0);
}
#[test]
fn test_n_leaves_zero_before_training() {
let l = make_learner();
assert_eq!(l.n_leaves(), 0);
}
#[test]
fn test_n_nodes_zero_before_training() {
let l = make_learner();
assert_eq!(l.n_nodes(), 0);
}
#[test]
fn test_max_depth_1() {
let cfg = DtlLearnerConfig {
max_depth: 1,
..Default::default()
};
let mut l = DecisionTreeLearner::new(cfg, vec!["x".into(), "y".into()]);
l.fit(&binary_samples()).expect("test: should succeed");
assert!(l.depth() <= 2); }
#[test]
fn test_max_depth_2() {
let cfg = DtlLearnerConfig {
max_depth: 2,
..Default::default()
};
let mut l = DecisionTreeLearner::new(cfg, vec!["x".into()]);
l.fit(&three_class_samples()).expect("test: should succeed");
assert!(l.depth() <= 3);
}
#[test]
fn test_entropy_criterion() {
let cfg = DtlLearnerConfig {
criterion: DtlCriterion::Entropy,
..Default::default()
};
let mut l = DecisionTreeLearner::new(cfg, vec!["x".into(), "y".into()]);
l.fit(&binary_samples()).expect("test: should succeed");
assert_eq!(
l.predict(&[1.0, 2.0]).expect("test: should succeed").label,
"A"
);
}
#[test]
fn test_gini_criterion() {
let cfg = DtlLearnerConfig {
criterion: DtlCriterion::Gini,
..Default::default()
};
let mut l = DecisionTreeLearner::new(cfg, vec!["x".into(), "y".into()]);
l.fit(&binary_samples()).expect("test: should succeed");
assert_eq!(
l.predict(&[5.0, 6.0]).expect("test: should succeed").label,
"B"
);
}
#[test]
fn test_misclassification_criterion() {
let cfg = DtlLearnerConfig {
criterion: DtlCriterion::MisclassificationRate,
..Default::default()
};
let mut l = DecisionTreeLearner::new(cfg, vec!["x".into(), "y".into()]);
l.fit(&binary_samples()).expect("test: should succeed");
assert_eq!(
l.predict(&[1.0, 2.0]).expect("test: should succeed").label,
"A"
);
}
#[test]
fn test_pure_class_single_leaf() {
let samples: Vec<DtlSample> = (0..10)
.map(|i| DtlSample::new(vec![i as f64], "X"))
.collect();
let mut l = DecisionTreeLearner::new(DtlLearnerConfig::default(), vec!["v".into()]);
l.fit(&samples).expect("test: should succeed");
assert_eq!(l.n_leaves(), 1);
assert_eq!(l.predict(&[5.0]).expect("test: should succeed").label, "X");
assert!((l.predict(&[5.0]).expect("test: should succeed").confidence - 1.0).abs() < 1e-9);
}
#[test]
fn test_feature_importance_sums_to_one() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
let imp = l.feature_importance();
let sum: f64 = imp.iter().map(|(_, v)| v).sum();
assert!((sum - 1.0).abs() < 1e-9 || imp.iter().all(|(_, v)| *v == 0.0));
}
#[test]
fn test_feature_importance_empty_before_training() {
let l = make_learner();
assert!(l.feature_importance().is_empty());
}
#[test]
fn test_feature_importance_length_equals_n_features() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
assert_eq!(l.feature_importance().len(), 2);
}
#[test]
fn test_feature_importance_sorted_desc() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
let imp = l.feature_importance();
for w in imp.windows(2) {
assert!(w[0].1 >= w[1].1);
}
}
#[test]
fn test_feature_importance_contains_feature_names() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
let imp = l.feature_importance();
let names: Vec<&str> = imp.iter().map(|(n, _)| n.as_str()).collect();
assert!(names.contains(&"x") || names.contains(&"y"));
}
#[test]
fn test_prune_does_not_increase_leaves() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
let before = l.n_leaves();
l.prune(10);
let after = l.n_leaves();
assert!(after <= before);
}
#[test]
fn test_prune_with_zero_no_crash() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
l.prune(0);
assert!(l.root().is_some());
}
#[test]
fn test_prune_high_threshold_collapses() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
l.prune(1000);
assert!(l.n_leaves() <= l.n_leaves() + 1);
}
#[test]
fn test_prune_still_predicts() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
l.prune(2);
assert!(l.predict(&[1.0, 2.0]).is_ok());
}
#[test]
fn test_history_grows_with_each_fit() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
l.fit(&binary_samples()).expect("test: should succeed");
assert_eq!(l.history().len(), 2);
}
#[test]
fn test_history_bounded_at_100() {
let mut l = make_one_feature_learner();
let samples: Vec<DtlSample> = vec![
DtlSample::new(vec![0.0], "A"),
DtlSample::new(vec![1.0], "B"),
];
for _ in 0..150 {
l.fit(&samples).expect("test: should succeed");
}
assert_eq!(l.history().len(), 100);
}
#[test]
fn test_history_record_correct_n_samples() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
assert_eq!(
l.history().back().expect("test: should succeed").n_samples,
5
);
}
#[test]
fn test_history_record_criterion() {
let cfg = DtlLearnerConfig {
criterion: DtlCriterion::Entropy,
..Default::default()
};
let mut l = DecisionTreeLearner::new(cfg, vec!["x".into(), "y".into()]);
l.fit(&binary_samples()).expect("test: should succeed");
assert_eq!(
l.history().back().expect("test: should succeed").criterion,
DtlCriterion::Entropy
);
}
#[test]
fn test_stats_not_trained() {
let l = make_learner();
let s = l.learner_stats();
assert!(!s.is_trained);
assert_eq!(s.tree_depth, 0);
}
#[test]
fn test_stats_trained() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
let s = l.learner_stats();
assert!(s.is_trained);
assert!(s.tree_depth >= 1);
assert_eq!(s.n_classes, 2);
assert_eq!(s.n_features, 2);
}
#[test]
fn test_stats_class_labels_sorted() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
let s = l.learner_stats();
let mut sorted = s.class_labels.clone();
sorted.sort();
assert_eq!(s.class_labels, sorted);
}
#[test]
fn test_stats_feature_names_match() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
let s = l.learner_stats();
assert_eq!(s.feature_names, vec!["x", "y"]);
}
#[test]
fn test_auto_feature_names_generated() {
let mut l = DecisionTreeLearner::new(DtlLearnerConfig::default(), vec![]);
l.fit(&binary_samples()).expect("test: should succeed");
let names = l.feature_names();
assert_eq!(names.len(), 2);
assert_eq!(names[0], "f0");
assert_eq!(names[1], "f1");
}
#[test]
fn test_max_features_one() {
let cfg = DtlLearnerConfig {
max_features: Some(1),
..Default::default()
};
let mut l = DecisionTreeLearner::new(cfg, vec!["x".into(), "y".into()]);
assert!(l.fit(&binary_samples()).is_ok());
}
#[test]
fn test_max_features_all() {
let cfg = DtlLearnerConfig {
max_features: Some(2),
..Default::default()
};
let mut l = DecisionTreeLearner::new(cfg, vec!["x".into(), "y".into()]);
assert!(l.fit(&binary_samples()).is_ok());
}
#[test]
fn test_min_samples_split_forces_leaf() {
let cfg = DtlLearnerConfig {
min_samples_split: 100,
..Default::default()
};
let mut l = DecisionTreeLearner::new(cfg, vec!["x".into(), "y".into()]);
l.fit(&binary_samples()).expect("test: should succeed");
assert_eq!(l.n_leaves(), 1);
}
#[test]
fn test_min_samples_leaf_respected() {
let cfg = DtlLearnerConfig {
min_samples_leaf: 3,
..Default::default()
};
let mut l = DecisionTreeLearner::new(cfg, vec!["x".into(), "y".into()]);
l.fit(&binary_samples()).expect("test: should succeed");
assert!(l.predict(&[1.0, 2.0]).is_ok());
}
#[test]
fn test_xorshift64_deterministic() {
let mut state = 0xdead_beef_cafe_babe_u64;
let a = xorshift64(&mut state);
let mut state2 = 0xdead_beef_cafe_babe_u64;
let b = xorshift64(&mut state2);
assert_eq!(a, b);
}
#[test]
fn test_xorshift64_nonzero() {
let mut state = 1u64;
let v = xorshift64(&mut state);
assert_ne!(v, 0);
}
#[test]
fn test_xorshift64_state_advances() {
let mut state = 12345u64;
let a = xorshift64(&mut state);
let b = xorshift64(&mut state);
assert_ne!(a, b);
}
#[test]
fn test_select_features_all() {
let mut rng = 42u64;
let feats = select_features(5, None, &mut rng);
assert_eq!(feats.len(), 5);
}
#[test]
fn test_select_features_limited() {
let mut rng = 42u64;
let feats = select_features(10, Some(3), &mut rng);
assert_eq!(feats.len(), 3);
}
#[test]
fn test_select_features_no_duplicates() {
let mut rng = 999u64;
let feats = select_features(10, Some(10), &mut rng);
let unique: std::collections::HashSet<usize> = feats.iter().copied().collect();
assert_eq!(unique.len(), feats.len());
}
#[test]
fn test_entropy_pure() {
let mut d = HashMap::new();
d.insert("A".to_string(), 10usize);
assert!((entropy(&d, 10) - 0.0).abs() < 1e-9);
}
#[test]
fn test_entropy_balanced_binary() {
let mut d = HashMap::new();
d.insert("A".to_string(), 5usize);
d.insert("B".to_string(), 5usize);
assert!((entropy(&d, 10) - 1.0).abs() < 1e-6);
}
#[test]
fn test_gini_pure() {
let mut d = HashMap::new();
d.insert("A".to_string(), 10usize);
assert!((gini(&d, 10) - 0.0).abs() < 1e-9);
}
#[test]
fn test_gini_balanced_binary() {
let mut d = HashMap::new();
d.insert("A".to_string(), 5usize);
d.insert("B".to_string(), 5usize);
assert!((gini(&d, 10) - 0.5).abs() < 1e-9);
}
#[test]
fn test_misclassification_pure() {
let mut d = HashMap::new();
d.insert("A".to_string(), 10usize);
assert!((misclassification_rate(&d, 10) - 0.0).abs() < 1e-9);
}
#[test]
fn test_misclassification_balanced() {
let mut d = HashMap::new();
d.insert("A".to_string(), 5usize);
d.insert("B".to_string(), 5usize);
assert!((misclassification_rate(&d, 10) - 0.5).abs() < 1e-9);
}
#[test]
fn test_majority_class_clear_winner() {
let mut d = HashMap::new();
d.insert("A".to_string(), 3usize);
d.insert("B".to_string(), 7usize);
assert_eq!(majority_class(&d), "B");
}
#[test]
fn test_majority_class_alphabetical_tiebreak() {
let mut d = HashMap::new();
d.insert("B".to_string(), 5usize);
d.insert("A".to_string(), 5usize);
let m = majority_class(&d);
assert!(m == "A" || m == "B"); }
#[test]
fn test_leaf_n_leaves_is_1() {
let leaf = DtlNode::Leaf {
class_label: "X".into(),
samples: 5,
class_distribution: HashMap::new(),
};
assert_eq!(leaf.n_leaves(), 1);
}
#[test]
fn test_leaf_depth_is_1() {
let leaf = DtlNode::Leaf {
class_label: "X".into(),
samples: 5,
class_distribution: HashMap::new(),
};
assert_eq!(leaf.depth(), 1);
}
#[test]
fn test_split_n_nodes() {
let leaf = || DtlNode::Leaf {
class_label: "X".into(),
samples: 1,
class_distribution: HashMap::new(),
};
let split = DtlNode::Split {
feature_index: 0,
threshold: 0.5,
left: Box::new(leaf()),
right: Box::new(leaf()),
feature_name: "f".into(),
samples: 2,
impurity: 0.5,
};
assert_eq!(split.n_nodes(), 3);
}
#[test]
fn test_error_display_empty() {
let e = DtlError::EmptyTrainingSet;
assert!(!e.to_string().is_empty());
}
#[test]
fn test_error_display_dim_mismatch() {
let e = DtlError::FeatureDimensionMismatch {
expected: 3,
got: 2,
};
let s = e.to_string();
assert!(s.contains("3") && s.contains("2"));
}
#[test]
fn test_refit_overwrites_tree() {
let mut l = make_learner();
l.fit(&binary_samples()).expect("test: should succeed");
let depth1 = l.depth();
let more: Vec<DtlSample> = (0..20)
.map(|i| {
let label = if i % 2 == 0 { "A" } else { "B" };
DtlSample::new(vec![i as f64, (i * 2) as f64], label)
})
.collect();
l.fit(&more).expect("test: should succeed");
let depth2 = l.depth();
let _ = depth1;
assert!(depth2 >= 1);
}
#[test]
fn test_single_sample_fit_and_predict() {
let mut l = make_learner();
let s = vec![DtlSample::new(vec![1.0, 2.0], "Solo")];
l.fit(&s).expect("test: should succeed");
let p = l.predict(&[1.0, 2.0]).expect("test: should succeed");
assert_eq!(p.label, "Solo");
assert!((p.confidence - 1.0).abs() < 1e-9);
}
#[test]
fn test_two_samples_different_classes() {
let samples = vec![
DtlSample::new(vec![0.0], "neg"),
DtlSample::new(vec![1.0], "pos"),
];
let mut l = make_one_feature_learner();
l.fit(&samples).expect("test: should succeed");
assert_eq!(
l.predict(&[0.0]).expect("test: should succeed").label,
"neg"
);
assert_eq!(
l.predict(&[1.0]).expect("test: should succeed").label,
"pos"
);
}
#[test]
fn test_dtl_sample_serialise() {
let s = DtlSample::new(vec![1.0, 2.0], "A");
let json = serde_json::to_string(&s).expect("test: should succeed");
let back: DtlSample = serde_json::from_str(&json).expect("test: should succeed");
assert_eq!(back.label, "A");
assert_eq!(back.features, vec![1.0, 2.0]);
}
#[test]
fn test_dtl_criterion_serialise() {
let c = DtlCriterion::Entropy;
let json = serde_json::to_string(&c).expect("test: should succeed");
let back: DtlCriterion = serde_json::from_str(&json).expect("test: should succeed");
assert_eq!(back, DtlCriterion::Entropy);
}
#[test]
fn test_dtl_config_serialise() {
let cfg = DtlLearnerConfig {
max_depth: 3,
criterion: DtlCriterion::Gini,
..Default::default()
};
let json = serde_json::to_string(&cfg).expect("test: should succeed");
let back: DtlLearnerConfig = serde_json::from_str(&json).expect("test: should succeed");
assert_eq!(back.max_depth, 3);
assert_eq!(back.criterion, DtlCriterion::Gini);
}
#[test]
fn test_class_distribution_correct() {
let samples = vec![
DtlSample::new(vec![0.0], "A"),
DtlSample::new(vec![1.0], "A"),
DtlSample::new(vec![2.0], "B"),
];
let dist = class_distribution(&samples, &[0, 1, 2]);
assert_eq!(dist["A"], 2);
assert_eq!(dist["B"], 1);
}
#[test]
fn test_node_impurity_leaf_is_zero() {
let leaf = DtlNode::Leaf {
class_label: "X".into(),
samples: 5,
class_distribution: HashMap::new(),
};
assert_eq!(node_impurity(&leaf), 0.0);
}
#[test]
fn test_node_impurity_split_nonzero() {
let leaf = || DtlNode::Leaf {
class_label: "X".into(),
samples: 1,
class_distribution: HashMap::new(),
};
let split = DtlNode::Split {
feature_index: 0,
threshold: 0.5,
left: Box::new(leaf()),
right: Box::new(leaf()),
feature_name: "f".into(),
samples: 2,
impurity: 0.42,
};
assert!((node_impurity(&split) - 0.42).abs() < 1e-9);
}
}