Skip to content

Commit c31eebb

Browse files
committed
fix(xgboost): keep one row when subsample truncates the sample to zero (#444)
floor(n_samples * subsample) rounds down to 0 on a small dataset, so the tree was fit on an empty index set and 0..sorted_idxs.len() - 1 underflowed. Keep a minimum of one row, as scikit-learn does. Sample sizes of one row or more are unchanged.
1 parent 6e285b3 commit c31eebb

3 files changed

Lines changed: 103 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [0.6.12]
8+
### Fixed
9+
- `xgboost/xgb_regressor.rs`: `XGRegressor::fit` no longer panics with `attempt to subtract with overflow` when `subsample` is less than 1.0 on a small dataset (#444). `floor(n_samples * subsample)` truncates to 0 rows. This occurs with 3 rows at a ratio of 0.3, or with 1 row at any ratio below 1.0. The tree fit then read `sorted_idxs.len() - 1` on an empty index set. The sample size now keeps a minimum of one row, as scikit-learn does for its own `subsample` parameter. Sample sizes of one row or more are unchanged.
10+
711
## [0.6.11]
812
### Fixed
913
- `algorithm/neighbour/cosinepair.rs`: `CosinePair::query_row_top_k` now returns exact nearest neighbours whenever `approximate` is `false` (the default). Previously the query always sampled only `top_k` evenly strided candidate rows without documentation, and the bounded candidate heap evicted its closest entry, so the method could return the farthest of the sampled rows (#442). Strided sampling is now gated behind `CosinePairParameters { approximate: true, .. }` and is documented as approximate.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
name = "smartcore"
33
description = "Machine Learning in Rust."
44
homepage = "https://smartcorelib.github.io/"
5-
version = "0.6.11"
5+
version = "0.6.12"
66
authors = ["smartcore Developers"]
77
edition = "2024"
88
rust-version = "1.85"

src/xgboost/xgb_regressor.rs

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,8 @@ impl XGRegressorParameters {
491491
/// Sets the fraction of samples to be used for fitting individual base learners.
492492
///
493493
/// A value of less than 1.0 introduces randomness and helps prevent overfitting.
494+
/// The value must be in the range (0, 1]. Each tree gets `floor(n_samples * subsample)`
495+
/// rows, but a minimum of one row.
494496
pub fn with_subsample(mut self, subsample: f64) -> Self {
495497
self.subsample = subsample;
496498
self
@@ -599,14 +601,20 @@ impl<TX: Number + PartialOrd, TY: Number, X: Array2<TX>, Y: Array1<TY>> XGRegres
599601
}
600602

601603
/// Creates a random sample of indices without replacement.
604+
///
605+
/// The sample holds at least one index when the population is not empty. The tree fit
606+
/// needs a minimum of one row, thus the sample size cannot be zero.
602607
fn sample_without_replacement(
603608
population_size: usize,
604609
subsample_ratio: f64,
605610
rng: &mut impl Rng,
606611
) -> Vec<usize> {
607612
let mut indices: Vec<usize> = (0..population_size).collect();
608613
indices.shuffle(rng);
609-
indices.truncate((population_size as f64 * subsample_ratio) as usize);
614+
// `population_size * subsample_ratio` truncates to 0 for a small population, e.g. 3 rows
615+
// at a ratio of 0.3. Keep one row in that case, as scikit-learn does for its own
616+
// `subsample` parameter (#444).
617+
indices.truncate(((population_size as f64 * subsample_ratio) as usize).max(1));
610618
indices
611619
}
612620
}
@@ -762,6 +770,95 @@ mod tests {
762770
assert_eq!(predictions.unwrap().len(), 2);
763771
}
764772

773+
/// `subsample` < 1.0 must not panic when the sample size truncates to zero rows.
774+
#[test]
775+
fn test_subsample_smaller_than_one_sample_does_not_panic() {
776+
let x_vec = vec![vec![1.0, 1.0], vec![2.0, 1.0], vec![1.0, 2.0]];
777+
let x = DenseMatrix::from_2d_vec(&x_vec).unwrap();
778+
let y = vec![5.0, 7.0, 8.0];
779+
780+
// 3 samples * 0.3 truncates to 0 rows, so the tree is fit on an empty index set.
781+
let params = XGRegressorParameters::default()
782+
.with_n_estimators(5)
783+
.with_max_depth(3)
784+
.with_subsample(0.3);
785+
786+
let model = XGRegressor::fit(&x, &y, params);
787+
assert!(model.is_ok(), "Fit failed: {:?}", model.err());
788+
789+
let predictions: Vec<f64> = model.unwrap().predict(&x).unwrap();
790+
assert_eq!(predictions.len(), 3);
791+
assert!(predictions.iter().all(|p| p.is_finite()));
792+
}
793+
794+
/// A single-row dataset with any `subsample` < 1.0 also truncates to zero rows.
795+
#[test]
796+
fn test_subsample_on_single_row_does_not_panic() {
797+
let x_vec = vec![vec![1.0, 1.0]];
798+
let x = DenseMatrix::from_2d_vec(&x_vec).unwrap();
799+
let y = vec![5.0];
800+
801+
let params = XGRegressorParameters::default()
802+
.with_n_estimators(5)
803+
.with_max_depth(3)
804+
.with_subsample(0.9);
805+
806+
let model = XGRegressor::fit(&x, &y, params);
807+
assert!(model.is_ok(), "Fit failed: {:?}", model.err());
808+
809+
let predictions: Vec<f64> = model.unwrap().predict(&x).unwrap();
810+
assert_eq!(predictions.len(), 1);
811+
assert!(predictions[0].is_finite());
812+
}
813+
#[test]
814+
fn test_sample_without_replacement_clamps_to_one_row() {
815+
// (population, ratio): each pair gives `floor(population * ratio) == 0`.
816+
let cases = [(3, 0.3), (2, 0.4), (1, 0.9), (10, 0.05), (5, 1e-12)];
817+
818+
for (population, ratio) in cases {
819+
let mut rng = get_rng_impl(Some(42));
820+
let sample =
821+
XGRegressor::<f64, f64, DenseMatrix<f64>, Vec<f64>>::sample_without_replacement(
822+
population, ratio, &mut rng,
823+
);
824+
825+
assert_eq!(
826+
sample.len(),
827+
1,
828+
"population {population} at ratio {ratio} gave {sample:?}"
829+
);
830+
assert!(sample[0] < population);
831+
}
832+
}
833+
834+
/// Regression guard: the clamp must not change a sample size that is already one or more.
835+
/// A ratio of 0.8 must still take 80% of the rows, not all of them.
836+
#[test]
837+
fn test_sample_without_replacement_keeps_the_ratio_above_one_row() {
838+
// (population, ratio, expected sample size)
839+
let cases = [(2, 0.5, 1), (2, 1.0, 2), (10, 0.8, 8), (100, 0.8, 80)];
840+
841+
for (population, ratio, expected) in cases {
842+
let mut rng = get_rng_impl(Some(42));
843+
let mut sample =
844+
XGRegressor::<f64, f64, DenseMatrix<f64>, Vec<f64>>::sample_without_replacement(
845+
population, ratio, &mut rng,
846+
);
847+
848+
assert_eq!(
849+
sample.len(),
850+
expected,
851+
"population {population} at ratio {ratio} gave {sample:?}"
852+
);
853+
assert!(sample.iter().all(|&i| i < population));
854+
855+
// The indices must stay unique, i.e. the sample is without replacement.
856+
sample.sort_unstable();
857+
sample.dedup();
858+
assert_eq!(sample.len(), expected);
859+
}
860+
}
861+
765862
/// A "smoke test" to ensure the main XGRegressor can fit and predict on multidimensional data.
766863
#[test]
767864
fn test_xgregressor_fit_predict_multidimensional() {

0 commit comments

Comments
 (0)