use rust_physics_engine::learn::tree::{
decision_tree_fit, entropy, feature_importance, forest_predict, gbm_predict, gini,
gradient_boosting_lite, random_forest_fit, regression_tree_fit, tree_predict,
tree_predict_value, Tree, TreeNode,
};
use rust_physics_engine::monte_carlo::Rng;
fn design(rng: &mut Rng, n: usize, dim: usize) -> Vec<Vec<f64>> {
(0..n).map(|_| (0..dim).map(|_| 4.0 * rng.next_f64() - 2.0).collect()).collect()
}
fn regions(x: &[Vec<f64>]) -> Vec<usize> {
x.iter().map(|p| usize::from(p[0] > 0.0) + 2 * usize::from(p[p.len() - 1] > 0.0)).collect()
}
fn named(tree: &Tree, x: &[Vec<f64>]) -> usize {
x.iter()
.map(|p| tree_predict(tree, p).unwrap())
.collect::<std::collections::BTreeSet<_>>()
.len()
}
#[test]
fn prop_the_impurity_measures_are_exact_at_their_extremes() {
let mut rng = Rng::new(0x2f80_c1a4);
for _ in 0..40 {
let k = 2 + (rng.below(7)) as usize;
let mut pure = vec![0usize; k];
pure[(rng.below(k as u64)) as usize] = 1 + (rng.below(20)) as usize;
assert_eq!(gini(&pure), 0.0);
assert_eq!(entropy(&pure), 0.0);
let count = 1 + (rng.below(15)) as usize;
let uniform = vec![count; k];
assert!((gini(&uniform) - (1.0 - 1.0 / k as f64)).abs() < 1e-14);
assert!((entropy(&uniform) - (k as f64).ln()).abs() < 1e-14);
let arbitrary: Vec<usize> = (0..k).map(|_| (rng.below(30)) as usize).collect();
if arbitrary.iter().sum::<usize>() > 0 {
assert!(gini(&arbitrary) <= gini(&uniform) + 1e-14);
assert!(entropy(&arbitrary) <= entropy(&uniform) + 1e-14);
assert!(gini(&arbitrary) >= 0.0);
assert!(entropy(&arbitrary) >= 0.0);
let nonzero = arbitrary.iter().filter(|&&c| c > 0).count();
assert_eq!(gini(&arbitrary) == 0.0, nonzero == 1);
assert_eq!(entropy(&arbitrary) == 0.0, nonzero == 1);
}
let mut shuffled = arbitrary.clone();
shuffled.reverse();
assert!((gini(&shuffled) - gini(&arbitrary)).abs() < 1e-14);
let doubled: Vec<usize> = arbitrary.iter().map(|c| c * 2).collect();
if doubled.iter().sum::<usize>() > 0 {
assert!((gini(&doubled) - gini(&arbitrary)).abs() < 1e-14);
assert!((entropy(&doubled) - entropy(&arbitrary)).abs() < 1e-14);
}
}
}
#[test]
fn prop_depth_bounds_what_a_tree_can_say() {
let mut rng = Rng::new(0x51d3_708b);
for _ in 0..20 {
let n = 30 + (rng.below(50)) as usize;
let dim = 2 + (rng.below(3)) as usize;
let x = design(&mut rng, n, dim);
let y = regions(&x);
let mut previous = 0.0;
for depth in 1..=5usize {
let tree = decision_tree_fit(&x, &y, depth, 1).unwrap();
assert!(
named(&tree, &x) <= 1usize << depth,
"depth {depth} named more classes than it has leaves"
);
let accuracy = x
.iter()
.enumerate()
.filter(|(i, p)| tree_predict(&tree, p).unwrap() == y[*i])
.count() as f64
/ n as f64;
assert!(accuracy >= previous - 1e-12, "depth {depth} was less accurate");
previous = accuracy;
}
let full = decision_tree_fit(&x, &y, usize::MAX, 1).unwrap();
for (i, p) in x.iter().enumerate() {
assert_eq!(tree_predict(&full, p).unwrap(), y[i], "point {i}");
}
}
}
#[test]
fn prop_a_tree_is_invariant_to_rescaling_any_feature() {
let mut rng = Rng::new(0x7ac0_1e36);
for _ in 0..25 {
let n = 25 + (rng.below(40)) as usize;
let dim = 2 + (rng.below(3)) as usize;
let x = design(&mut rng, n, dim);
let y = regions(&x);
let base = decision_tree_fit(&x, &y, 5, 2).unwrap();
let column = (rng.below(dim as u64)) as usize;
let factor = 0.001 + 1000.0 * rng.next_f64();
let shift = 20.0 * rng.next_gaussian();
let scaled: Vec<Vec<f64>> = x
.iter()
.map(|p| {
let mut q = p.clone();
q[column] = q[column] * factor + shift;
q
})
.collect();
let other = decision_tree_fit(&scaled, &y, 5, 2).unwrap();
assert_eq!(base.nodes.len(), other.nodes.len(), "the structure changed");
for (i, p) in x.iter().enumerate() {
assert_eq!(
tree_predict(&base, p).unwrap(),
tree_predict(&other, &scaled[i]).unwrap(),
"rescaling column {column} by {factor} moved point {i}"
);
}
let a = feature_importance(&base);
let b = feature_importance(&other);
for j in 0..dim {
assert!((a[j] - b[j]).abs() < 1e-9, "importance of column {j} changed");
}
}
}
#[test]
fn prop_importances_are_nonnegative_and_conserve_the_total() {
let mut rng = Rng::new(0x0d47_29e1);
for _ in 0..25 {
let n = 30 + (rng.below(40)) as usize;
let dim = 2 + (rng.below(4)) as usize;
let x = design(&mut rng, n, dim);
let y = regions(&x);
let tree = decision_tree_fit(&x, &y, 6, 2).unwrap();
let importance = feature_importance(&tree);
assert_eq!(importance.len(), dim);
assert!(importance.iter().all(|&v| v >= 0.0));
let root = match tree.nodes[0] {
TreeNode::Split { samples, .. } => samples as f64,
TreeNode::Leaf { .. } => continue,
};
let mut total = 0.0;
for node in &tree.nodes {
if let TreeNode::Split { decrease, samples, feature, .. } = node {
assert!(*decrease > 0.0, "a split with decrease {decrease} was taken");
assert!(*feature < dim);
total += *samples as f64 / root * decrease;
}
}
assert!(
(importance.iter().sum::<f64>() - total).abs() < 1e-10 * total.max(1.0),
"the importances did not sum to the total decrease"
);
let padded: Vec<Vec<f64>> =
x.iter().map(|p| { let mut q = p.clone(); q.push(rng.next_gaussian()); q }).collect();
let wider = decision_tree_fit(&padded, &y, 3, 5).unwrap();
let scores = feature_importance(&wider);
let real = scores[0].max(scores[dim - 1]);
assert!(scores[dim] <= real, "noise outranked every real feature");
}
}
#[test]
fn prop_a_forest_votes_and_leaves_a_third_of_the_data_out() {
let mut rng = Rng::new(0x63b1_4c07);
for _ in 0..12 {
let n = 40 + (rng.below(40)) as usize;
let dim = 2 + (rng.below(3)) as usize;
let x = design(&mut rng, n, dim);
let y = regions(&x);
let trees = 8 + (rng.below(12)) as usize;
let forest = random_forest_fit(&x, &y, trees, 8, 1, 0, &mut rng).unwrap();
assert_eq!(forest.trees.len(), trees);
assert_eq!(forest.out_of_bag.len(), trees);
for bag in &forest.out_of_bag {
let fraction = bag.len() as f64 / n as f64;
assert!((0.15..0.55).contains(&fraction), "out-of-bag fraction {fraction}");
assert!(bag.iter().all(|&i| i < n));
}
let mut shuffled = forest.clone();
shuffled.trees.reverse();
for p in x.iter().take(10) {
let vote = forest_predict(&forest, p).unwrap();
assert!(y.contains(&vote), "a label nobody had was voted for");
assert_eq!(vote, forest_predict(&shuffled, p).unwrap(), "order changed the vote");
}
}
}
#[test]
fn prop_boosting_walks_its_loss_down_and_a_stump_of_no_depth_does_nothing() {
let mut rng = Rng::new(0x18ba_5d92);
for _ in 0..15 {
let n = 30 + (rng.below(40)) as usize;
let dim = 1 + (rng.below(3)) as usize;
let x = design(&mut rng, n, dim);
let y: Vec<f64> = x.iter().map(|p| p[0].sin() + 0.1 * rng.next_gaussian()).collect();
let rate = 0.05 + 0.9 * rng.next_f64();
let rounds = 5 + (rng.below(20)) as usize;
let model = gradient_boosting_lite(&x, &y, rounds, rate, 2).unwrap();
assert_eq!(model.loss_history.len(), rounds + 1);
for w in model.loss_history.windows(2) {
assert!(w[1] <= w[0] + 1e-12, "the loss rose from {} to {}", w[0], w[1]);
}
let mean = y.iter().sum::<f64>() / n as f64;
let variance = y.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / n as f64;
assert!((model.loss_history[0] - variance).abs() < 1e-10);
assert!((model.base - mean).abs() < 1e-12);
let recomputed: f64 = x
.iter()
.zip(&y)
.map(|(p, t)| {
let e = gbm_predict(&model, p).unwrap() - t;
e * e
})
.sum::<f64>()
/ n as f64;
assert!(
(recomputed - model.loss_history[rounds]).abs() < 1e-9,
"predict disagreed with the recorded loss"
);
let inert = gradient_boosting_lite(&x, &y, 4, 1.0, 0).unwrap();
for w in inert.loss_history.windows(2) {
assert!((w[1] - w[0]).abs() < 1e-12, "a zero-depth round changed the loss");
}
}
}
#[test]
fn prop_a_regression_tree_predicts_within_the_range_it_was_given() {
let mut rng = Rng::new(0x4ec7_1130);
for _ in 0..25 {
let n = 20 + (rng.below(40)) as usize;
let dim = 1 + (rng.below(3)) as usize;
let x = design(&mut rng, n, dim);
let y: Vec<f64> = (0..n).map(|_| 10.0 * rng.next_gaussian()).collect();
let tree = regression_tree_fit(&x, &y, 4, 2).unwrap();
let lo = y.iter().copied().fold(f64::INFINITY, f64::min);
let hi = y.iter().copied().fold(f64::NEG_INFINITY, f64::max);
for p in x.iter() {
let got = tree_predict_value(&tree, p).unwrap();
assert!(got >= lo - 1e-12 && got <= hi + 1e-12, "prediction {got} left [{lo}, {hi}]");
}
for _ in 0..10 {
let far: Vec<f64> = (0..dim).map(|_| 1e6 * rng.next_gaussian()).collect();
let got = tree_predict_value(&tree, &far).unwrap();
assert!(
got >= lo - 1e-12 && got <= hi + 1e-12,
"a point far outside the data predicted {got}"
);
}
let full = regression_tree_fit(&x, &y, usize::MAX, 1).unwrap();
let distinct: std::collections::BTreeSet<Vec<u64>> =
x.iter().map(|p| p.iter().map(|v| v.to_bits()).collect()).collect();
if distinct.len() == n {
for (i, p) in x.iter().enumerate() {
assert!(
(tree_predict_value(&full, p).unwrap() - y[i]).abs() < 1e-9,
"point {i} was not reproduced"
);
}
}
}
}