diff --git a/Cargo.toml b/Cargo.toml index bdbb9135..12ca418c 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.12" +version = "0.6.13" authors = ["smartcore Developers"] edition = "2024" rust-version = "1.85" diff --git a/src/ensemble/base_forest_regressor.rs b/src/ensemble/base_forest_regressor.rs index b6dbb59e..51eb2c41 100644 --- a/src/ensemble/base_forest_regressor.rs +++ b/src/ensemble/base_forest_regressor.rs @@ -89,6 +89,12 @@ impl, Y: Array1 if n_rows != y.shape() { return Err(Failed::fit("Number of rows in X should = len(y)")); } + if n_rows == 0 || num_attributes == 0 { + return Err(Failed::because( + FailedError::ParametersError, + "Training data must contain at least one sample and one feature.", + )); + } let mtry = parameters .m @@ -223,6 +229,7 @@ impl, Y: Array1 #[cfg(test)] mod tests { use super::*; + use crate::linalg::basic::arrays::Array; use crate::linalg::basic::matrix::DenseMatrix; #[test] @@ -244,4 +251,58 @@ mod tests { assert_eq!(regressor.trees.unwrap().len(), 5); assert!(regressor.samples.is_some()); } + + #[test] + fn test_fit_on_empty_data_returns_error() { + // 2 rows x 2 features — values are arbitrary; only the empty-row case is under test + let full = DenseMatrix::from_2d_vec(&vec![vec![1.0, 2.0], vec![3.0, 4.0]]).unwrap(); + let empty = full.take(&[] as &[usize], 0); + assert_eq!(empty.shape(), (0, 2)); + + let y: Vec = vec![]; + let result = BaseForestRegressor::fit( + &empty, + &y, + BaseForestRegressorParameters { + max_depth: None, + min_samples_leaf: 1, + min_samples_split: 2, + n_trees: 5, + m: None, + keep_samples: false, + seed: 0, + bootstrap: true, + splitter: crate::tree::base_tree_regressor::Splitter::Best, + }, + ); + assert!(result.is_err()); + assert_eq!(result.err().unwrap().error(), FailedError::ParametersError); + } + + #[test] + fn test_fit_on_zero_features_returns_error() { + // 2 rows x 2 features — values are arbitrary; only the zero-feature case is under test + let full = DenseMatrix::from_2d_vec(&vec![vec![1.0, 2.0], vec![3.0, 4.0]]).unwrap(); + let no_features = full.take(&[] as &[usize], 1); + assert_eq!(no_features.shape(), (2, 0)); + + let y: Vec = vec![1.0, 2.0]; + let result = BaseForestRegressor::fit( + &no_features, + &y, + BaseForestRegressorParameters { + max_depth: None, + min_samples_leaf: 1, + min_samples_split: 2, + n_trees: 5, + m: None, + keep_samples: false, + seed: 0, + bootstrap: true, + splitter: crate::tree::base_tree_regressor::Splitter::Best, + }, + ); + assert!(result.is_err()); + assert_eq!(result.err().unwrap().error(), FailedError::ParametersError); + } } diff --git a/src/ensemble/random_forest_classifier.rs b/src/ensemble/random_forest_classifier.rs index 78d80631..676450d2 100644 --- a/src/ensemble/random_forest_classifier.rs +++ b/src/ensemble/random_forest_classifier.rs @@ -461,6 +461,12 @@ impl, Y: Array1 = vec![0; y_ncols]; let classes = y.unique(); @@ -619,6 +625,7 @@ impl, Y: Array1 = vec![]; + let result = RandomForestClassifier::fit( + &empty, + &y, + RandomForestClassifierParameters { + criterion: SplitCriterion::Gini, + max_depth: None, + min_samples_leaf: 1, + min_samples_split: 2, + n_trees: 10, + m: None, + keep_samples: false, + seed: 0, + }, + ); + assert!(result.is_err()); + assert_eq!(result.err().unwrap().error(), FailedError::ParametersError); + } + #[cfg_attr( all(target_arch = "wasm32", not(target_os = "wasi")), wasm_bindgen_test::wasm_bindgen_test diff --git a/src/preprocessing/numerical.rs b/src/preprocessing/numerical.rs index 148a2a14..3c37e0c0 100644 --- a/src/preprocessing/numerical.rs +++ b/src/preprocessing/numerical.rs @@ -138,76 +138,41 @@ impl> UnsupervisedEstimator> Transformer for StandardScaler { fn transform(&self, x: &M) -> Result { - let (_, n_cols) = x.shape(); - if n_cols != self.means.len() { + let (nrows, ncols) = x.shape(); + if ncols != self.means.len() { return Err(Failed::because( FailedError::TransformFailed, &format!( "Expected {} columns, but got {} columns instead.", self.means.len(), - n_cols, + ncols, ), )); } - Ok(build_matrix_from_columns( - self.means - .iter() - .zip(self.stds.iter()) - .enumerate() - .map(|(column_index, (column_mean, column_std))| { - x.take_column(column_index) - .sub_scalar(T::from(self.adjust_column_mean(*column_mean)).unwrap()) - .div_scalar(T::from(self.adjust_column_std(*column_std)).unwrap()) - }) - .collect(), - ) - .unwrap()) - } -} - -/// From a collection of matrices, that contain columns, construct -/// a matrix by stacking the columns horizontally. -fn build_matrix_from_columns(columns: Vec) -> Option -where - T: Number + RealNumber, - M: Array2, -{ - columns.first().cloned().map(|output_matrix| { - columns - .iter() - .skip(1) - .fold(output_matrix, |current_matrix, new_colum| { - current_matrix.h_stack(new_colum) + let mut output = M::fill(nrows, ncols, T::zero()); + let col_params: Vec<(T, T)> = (0..ncols) + .map(|j| { + let mean = T::from(self.adjust_column_mean(self.means[j])).unwrap(); + let std = T::from(self.adjust_column_std(self.stds[j])).unwrap(); + (mean, std) }) - }) + .collect(); + for i in 0..nrows { + for (j, &(mean, std)) in col_params.iter().enumerate() { + let val = *x.get((i, j)); + output.set((i, j), (val - mean) / std); + } + } + Ok(output) + } } #[cfg(test)] mod tests { mod helper_functionality { - use super::super::{build_matrix_from_columns, ensure_std_valid}; - use crate::linalg::basic::matrix::DenseMatrix; - - #[test] - fn combine_three_columns() { - assert_eq!( - build_matrix_from_columns(vec![ - DenseMatrix::from_2d_vec(&vec![vec![1.0], vec![1.0], vec![1.0],]).unwrap(), - DenseMatrix::from_2d_vec(&vec![vec![2.0], vec![2.0], vec![2.0],]).unwrap(), - DenseMatrix::from_2d_vec(&vec![vec![3.0], vec![3.0], vec![3.0],]).unwrap() - ]), - Some( - DenseMatrix::from_2d_vec(&vec![ - vec![1.0, 2.0, 3.0], - vec![1.0, 2.0, 3.0], - vec![1.0, 2.0, 3.0] - ]) - .unwrap() - ) - ) - } + use super::super::ensure_std_valid; #[test] fn negative_value_should_be_replace_with_minimal_positive_value() { @@ -426,6 +391,128 @@ mod tests { ) } + /// Verify transform correctness across all parameter combinations. + #[test] + fn transform_all_parameter_combinations() { + let data = DenseMatrix::from_2d_vec(&vec![ + vec![1.0, 10.0, 100.0], + vec![2.0, 20.0, 200.0], + vec![3.0, 30.0, 300.0], + vec![4.0, 40.0, 400.0], + ]) + .unwrap(); + + // Default: with_mean=true, with_std=true + // std = population std: sqrt(mean of squared deviations) + // For [1,2,3,4]: mean=2.5, pop_std = sqrt(5/4) ≈ 1.1180339887 + let scaler = StandardScaler::fit(&data, StandardScalerParameters::default()).unwrap(); + let result = scaler.transform(&data).unwrap(); + let expected = DenseMatrix::from_2d_vec(&vec![ + vec![ + -1.3416407864998738, + -1.3416407864998738, + -1.3416407864998738, + ], + vec![ + -0.4472135954999579, + -0.4472135954999579, + -0.4472135954999579, + ], + vec![0.4472135954999579, 0.4472135954999579, 0.4472135954999579], + vec![1.3416407864998738, 1.3416407864998738, 1.3416407864998738], + ]) + .unwrap(); + assert!( + result.approximate_eq(&expected, 1e-10), + "Default transform failed:\n{result}\nexpected:\n{expected}" + ); + + // with_mean=true, with_std=false + let scaler = StandardScaler::fit( + &data, + StandardScalerParameters { + with_mean: true, + with_std: false, + }, + ) + .unwrap(); + let result = scaler.transform(&data).unwrap(); + let expected = DenseMatrix::from_2d_vec(&vec![ + vec![-1.5, -15.0, -150.0], + vec![-0.5, -5.0, -50.0], + vec![0.5, 5.0, 50.0], + vec![1.5, 15.0, 150.0], + ]) + .unwrap(); + assert!( + result.approximate_eq(&expected, 1e-10), + "with_mean=true, with_std=false transform failed:\n{result}\nexpected:\n{expected}" + ); + + // with_mean=false, with_std=true: (x - 0) / std = x / std + let scaler = StandardScaler::fit( + &data, + StandardScalerParameters { + with_mean: false, + with_std: true, + }, + ) + .unwrap(); + let result = scaler.transform(&data).unwrap(); + let expected = DenseMatrix::from_2d_vec(&vec![ + vec![0.8944271909999159, 0.8944271909999159, 0.8944271909999159], + vec![1.7888543819998317, 1.7888543819998317, 1.7888543819998317], + vec![2.6832815729997477, 2.6832815729997477, 2.6832815729997477], + vec![3.5777087639996634, 3.5777087639996634, 3.5777087639996634], + ]) + .unwrap(); + assert!( + result.approximate_eq(&expected, 1e-10), + "with_mean=false, with_std=true transform failed:\n{result}\nexpected:\n{expected}" + ); + + // with_mean=false, with_std=false (passthrough) + let scaler = StandardScaler::fit( + &data, + StandardScalerParameters { + with_mean: false, + with_std: false, + }, + ) + .unwrap(); + let result = scaler.transform(&data).unwrap(); + assert!( + result.approximate_eq(&data, 1e-10), + "with_mean=false, with_std=false should return data unchanged:\n{result}\nexpected:\n{data}" + ); + + // Zero-variance column mixed with normal columns + let mixed = DenseMatrix::from_2d_vec(&vec![ + vec![1.0, 5.0], + vec![2.0, 5.0], + vec![3.0, 5.0], + vec![4.0, 5.0], + ]) + .unwrap(); + let scaler = StandardScaler::fit(&mixed, StandardScalerParameters::default()).unwrap(); + let result = scaler.transform(&mixed).unwrap(); + let expected = DenseMatrix::from_2d_vec(&vec![ + vec![-1.3416407864998738, 0.0], + vec![-0.4472135954999579, 0.0], + vec![0.4472135954999579, 0.0], + vec![1.3416407864998738, 0.0], + ]) + .unwrap(); + assert!( + result.approximate_eq(&expected, 1e-10), + "Zero-variance mixed column transform failed:\n{result}\nexpected:\n{expected}" + ); + + // Column count mismatch returns error: scaler expects 2 cols, data has 1 + let narrow = DenseMatrix::from_2d_vec(&vec![vec![1.0]]).unwrap(); + assert!(scaler.transform(&narrow).is_err()); + } + /// Same as `fit_for_random_values` test, but using a `StandardScaler` that has been /// serialized and deserialized. #[cfg_attr( diff --git a/src/tree/base_tree_regressor.rs b/src/tree/base_tree_regressor.rs index 24229b06..fb03e01c 100644 --- a/src/tree/base_tree_regressor.rs +++ b/src/tree/base_tree_regressor.rs @@ -9,7 +9,7 @@ use rand::seq::SliceRandom; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; -use crate::error::Failed; +use crate::error::{Failed, FailedError}; use crate::linalg::basic::arrays::{Array1, Array2, MutArrayView1}; use crate::numbers::basenum::Number; use crate::rand_custom::get_rng_impl; @@ -184,6 +184,12 @@ impl, Y: Array1> if x_nrows != y.shape() { return Err(Failed::fit("Size of x should equal size of y")); } + if x_nrows == 0 || num_attributes == 0 { + return Err(Failed::because( + FailedError::ParametersError, + "Training data must contain at least one sample and one feature.", + )); + } let samples = vec![1; x_nrows]; BaseTreeRegressor::fit_weak_learner(x, y, samples, num_attributes, parameters) @@ -541,3 +547,56 @@ impl, Y: Array1> true } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::linalg::basic::arrays::Array; + use crate::linalg::basic::matrix::DenseMatrix; + + #[test] + fn test_fit_on_empty_data_returns_error() { + // 2 rows x 2 features — values are arbitrary; only the empty-row case is under test + let full = DenseMatrix::from_2d_vec(&vec![vec![1.0_f64, 2.0], vec![3.0, 4.0]]).unwrap(); + let empty = full.take(&[] as &[usize], 0); + assert_eq!(empty.shape(), (0, 2)); + + let y: Vec = vec![]; + let result = BaseTreeRegressor::fit( + &empty, + &y, + BaseTreeRegressorParameters { + max_depth: None, + min_samples_leaf: 1, + min_samples_split: 2, + seed: None, + splitter: Splitter::Best, + }, + ); + assert!(result.is_err()); + assert_eq!(result.err().unwrap().error(), FailedError::ParametersError); + } + + #[test] + fn test_fit_on_zero_features_returns_error() { + // 2 rows x 2 features — values are arbitrary; only the zero-feature case is under test + let full = DenseMatrix::from_2d_vec(&vec![vec![1.0_f64, 2.0], vec![3.0, 4.0]]).unwrap(); + let no_features = full.take(&[] as &[usize], 1); + assert_eq!(no_features.shape(), (2, 0)); + + let y = vec![1.0_f64, 2.0]; + let result = BaseTreeRegressor::fit( + &no_features, + &y, + BaseTreeRegressorParameters { + max_depth: None, + min_samples_leaf: 1, + min_samples_split: 2, + seed: None, + splitter: Splitter::Best, + }, + ); + assert!(result.is_err()); + assert_eq!(result.err().unwrap().error(), FailedError::ParametersError); + } +} diff --git a/src/tree/decision_tree_classifier.rs b/src/tree/decision_tree_classifier.rs index 8953d31a..595063a8 100644 --- a/src/tree/decision_tree_classifier.rs +++ b/src/tree/decision_tree_classifier.rs @@ -76,7 +76,7 @@ use rand::seq::SliceRandom; use serde::{Deserialize, Serialize}; use crate::api::{Predictor, SupervisedEstimator}; -use crate::error::Failed; +use crate::error::{Failed, FailedError}; use crate::linalg::basic::arrays::MutArray; use crate::linalg::basic::arrays::{Array1, Array2, MutArrayView1}; use crate::linalg::basic::matrix::DenseMatrix; @@ -552,6 +552,12 @@ impl, Y: Array1> if x_nrows != y.shape() { return Err(Failed::fit("Size of x should equal size of y")); } + if x_nrows == 0 || num_attributes == 0 { + return Err(Failed::because( + FailedError::ParametersError, + "Training data must contain at least one sample and one feature.", + )); + } let samples = vec![1; x_nrows]; DecisionTreeClassifier::fit_weak_learner(x, y, samples, num_attributes, parameters) @@ -1116,6 +1122,32 @@ mod tests { assert!(fail.is_err()); } + #[test] + fn test_fit_on_empty_data_returns_error() { + // 2 rows x 2 features — values are arbitrary; only the empty-row case is under test + let full = DenseMatrix::from_2d_vec(&vec![vec![1.0_f64, 1.0], vec![0.0, 1.0]]).unwrap(); + let empty = full.take(&[] as &[usize], 0); + assert_eq!(empty.shape(), (0, 2)); + + let y: Vec = vec![]; + let result = DecisionTreeClassifier::fit(&empty, &y, Default::default()); + assert!(result.is_err()); + assert_eq!(result.err().unwrap().error(), FailedError::ParametersError); + } + + #[test] + fn test_fit_on_zero_features_returns_error() { + // 2 rows x 2 features — values are arbitrary; only the zero-feature case is under test + let full = DenseMatrix::from_2d_vec(&vec![vec![1.0_f64, 1.0], vec![0.0, 1.0]]).unwrap(); + let no_features = full.take(&[] as &[usize], 1); + assert_eq!(no_features.shape(), (2, 0)); + + let y: Vec = vec![0, 1]; + let result = DecisionTreeClassifier::fit(&no_features, &y, Default::default()); + assert!(result.is_err()); + assert_eq!(result.err().unwrap().error(), FailedError::ParametersError); + } + #[cfg_attr( all(target_arch = "wasm32", not(target_os = "wasi")), wasm_bindgen_test::wasm_bindgen_test diff --git a/src/xgboost/xgb_regressor.rs b/src/xgboost/xgb_regressor.rs index 3f97ee73..e71c83db 100644 --- a/src/xgboost/xgb_regressor.rs +++ b/src/xgboost/xgb_regressor.rs @@ -534,11 +534,11 @@ impl, Y: Array1> XGRegres )); } - let (n_samples, _) = data.shape(); - if n_samples == 0 { + let (n_samples, n_features) = data.shape(); + if n_samples == 0 || n_features == 0 { return Err(Failed::because( FailedError::ParametersError, - "Training data must have at least one row.", + "Training data must contain at least one sample and one feature.", )); } @@ -826,6 +826,7 @@ mod tests { #[test] fn test_fit_on_empty_data_returns_error() { + // 2 rows x 2 features — values are arbitrary; only the empty-row case is under test let full = DenseMatrix::from_2d_vec(&vec![vec![1.0, 1.0], vec![2.0, 1.0]]).unwrap(); let empty = full.take(&[] as &[usize], 0); assert_eq!(empty.shape(), (0, 2)); @@ -836,6 +837,18 @@ mod tests { assert_eq!(model.err().unwrap().error(), FailedError::ParametersError); } + #[test] + fn test_fit_on_zero_features_returns_error() { + let full = DenseMatrix::from_2d_vec(&vec![vec![1.0, 1.0], vec![2.0, 1.0]]).unwrap(); + let no_features = full.take(&[] as &[usize], 1); + assert_eq!(no_features.shape(), (2, 0)); + + let y = vec![1.0, 2.0]; + let model = XGRegressor::fit(&no_features, &y, XGRegressorParameters::default()); + assert!(model.is_err()); + assert_eq!(model.err().unwrap().error(), FailedError::ParametersError); + } + #[test] fn test_sample_without_replacement_clamps_to_one_row() { // (population, ratio): each pair gives `floor(population * ratio) == 0`.