From 7c93d12dde36f12b5bbd1f51e4aee8aad7cd26a8 Mon Sep 17 00:00:00 2001 From: "Lorenzo (Mec-iS)" Date: Wed, 23 Sep 2026 09:48:14 +0100 Subject: [PATCH] test(tree): pin min_samples_split boundary and assert depth after #464 Add min_samples_split_boundary regression tests for DecisionTreeClassifier and BaseTreeRegressor: a node holding exactly min_samples_split samples must still split, and a node with fewer samples must stay a leaf. The full_depth tests now also assert tree depth (3), guarding the public DecisionTreeClassifier::depth accessor. Bump patch version to 0.6.15. Fixes #465 --- CHANGELOG.md | 4 ++++ Cargo.toml | 2 +- src/tree/base_tree_regressor.rs | 33 ++++++++++++++++++++++++++++ src/tree/decision_tree_classifier.rs | 33 ++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22c44f6e..ad3be010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.15] +### Fixed +- `tree`: regression tests for the tree growth fix from #464. New `min_samples_split_boundary` tests pin the split rule for `DecisionTreeClassifier` and `BaseTreeRegressor`: a node that holds exactly `min_samples_split` samples must still split, and a node with fewer samples must stay a leaf. The `full_depth` tests now also assert the tree `depth` (3), which guards the public `DecisionTreeClassifier::depth` accessor against silent regressions. Library code is unchanged. + ## [0.6.14] ### Added - `decomposition/lda.rs`: `LDA`, linear discriminant analysis for supervised dimensionality reduction (#136). It projects the data onto the directions that best separate the classes, keeping `min(n_classes - 1, n_features)` components by default, and implements the `Transformer` interface next to `PCA`. Directions match scikit-learn's `LinearDiscriminantAnalysis(solver="eigen")` up to sign. diff --git a/Cargo.toml b/Cargo.toml index 4fe4ded9..017358be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "smartcore" description = "Machine Learning in Rust." homepage = "https://smartcorelib.github.io/" -version = "0.6.14" +version = "0.6.15" authors = ["smartcore Developers"] edition = "2024" rust-version = "1.85" diff --git a/src/tree/base_tree_regressor.rs b/src/tree/base_tree_regressor.rs index 04bca6c4..ceed7e89 100644 --- a/src/tree/base_tree_regressor.rs +++ b/src/tree/base_tree_regressor.rs @@ -622,6 +622,39 @@ mod tests { let y_expected = vec![1.0, 2.0, 6.5, 6.5, 11.50, 11.50]; let y_hat = tree.predict(&x).expect("Predict should work"); assert_eq!(tree.nodes().len(), 7); + assert_eq!(tree.depth, 3); assert!(mean_absolute_error(&y_expected, &y_hat) < 1e-9); } + + #[test] + fn min_samples_split_boundary() { + // A node that holds exactly `min_samples_split` samples must still split. + let x = DenseMatrix::from_2d_vec(&vec![vec![1.0_f64], vec![2.0], vec![3.0]]).unwrap(); + let y = vec![1.0f64, 2.0, 6.0]; + + let parameters = BaseTreeRegressorParameters { + max_depth: None, + min_samples_leaf: 1, + min_samples_split: 3, + seed: None, + splitter: Splitter::Best, + }; + + let tree = BaseTreeRegressor::fit(&x, &y, parameters).expect("Fit should work"); + assert_eq!(tree.nodes().len(), 3); + assert_eq!(tree.depth, 2); + + // A node with fewer than `min_samples_split` samples must stay a leaf. + let parameters = BaseTreeRegressorParameters { + max_depth: None, + min_samples_leaf: 1, + min_samples_split: 4, + seed: None, + splitter: Splitter::Best, + }; + + let tree = BaseTreeRegressor::fit(&x, &y, parameters).expect("Fit should work"); + assert_eq!(tree.nodes().len(), 1); + assert_eq!(tree.depth, 0); + } } diff --git a/src/tree/decision_tree_classifier.rs b/src/tree/decision_tree_classifier.rs index de516f4f..7e2bf959 100644 --- a/src/tree/decision_tree_classifier.rs +++ b/src/tree/decision_tree_classifier.rs @@ -1078,11 +1078,44 @@ mod tests { let tree = DecisionTreeClassifier::fit(&x, &y, parameters).expect("Fit should work"); let y_hat = tree.predict(&x).expect("Predict should work"); assert_eq!(tree.nodes().len(), 7); + assert_eq!(tree.depth(), 3); // Tree should have 5 out of 6 examples correct assert!((accuracy(&y, &y_hat) - 5.0 / 6.0).abs() < 1e-9); } + #[test] + fn min_samples_split_boundary() { + // A node that holds exactly `min_samples_split` samples must still split. + let x = DenseMatrix::from_2d_vec(&vec![vec![1.0_f64], vec![2.0], vec![3.0]]).unwrap(); + let y = vec![0usize, 0, 1]; + + let parameters = DecisionTreeClassifierParameters { + max_depth: None, + min_samples_leaf: 1, + min_samples_split: 3, + seed: None, + criterion: SplitCriterion::Gini, + }; + + let tree = DecisionTreeClassifier::fit(&x, &y, parameters).expect("Fit should work"); + assert_eq!(tree.nodes().len(), 3); + assert_eq!(tree.depth(), 2); + + // A node with fewer than `min_samples_split` samples must stay a leaf. + let parameters = DecisionTreeClassifierParameters { + max_depth: None, + min_samples_leaf: 1, + min_samples_split: 4, + seed: None, + criterion: SplitCriterion::Gini, + }; + + let tree = DecisionTreeClassifier::fit(&x, &y, parameters).expect("Fit should work"); + assert_eq!(tree.nodes().len(), 1); + assert_eq!(tree.depth(), 0); + } + #[cfg_attr( all(target_arch = "wasm32", not(target_os = "wasi")), wasm_bindgen_test::wasm_bindgen_test