Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
61 changes: 61 additions & 0 deletions src/ensemble/base_forest_regressor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ impl<TX: Number + FloatNumber + PartialOrd, TY: Number, X: Array2<TX>, 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
Expand Down Expand Up @@ -223,6 +229,7 @@ impl<TX: Number + FloatNumber + PartialOrd, TY: Number, X: Array2<TX>, Y: Array1
#[cfg(test)]
mod tests {
use super::*;
use crate::linalg::basic::arrays::Array;
use crate::linalg::basic::matrix::DenseMatrix;

#[test]
Expand All @@ -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<f64> = 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<f64> = 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);
}
}
33 changes: 33 additions & 0 deletions src/ensemble/random_forest_classifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,12 @@ impl<TX: FloatNumber + PartialOrd, TY: Number + Ord, X: Array2<TX>, Y: Array1<TY
if x_nrows != y_ncols {
return Err(Failed::fit("Number of rows in X should = len(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 mut yi: Vec<usize> = vec![0; y_ncols];
let classes = y.unique();
Expand Down Expand Up @@ -619,6 +625,7 @@ impl<TX: FloatNumber + PartialOrd, TY: Number + Ord, X: Array2<TX>, Y: Array1<TY
#[cfg(test)]
mod tests {
use super::*;
use crate::linalg::basic::arrays::Array;
use crate::linalg::basic::matrix::DenseMatrix;
use crate::metrics::*;

Expand Down Expand Up @@ -779,6 +786,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<u32> = 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
Expand Down
195 changes: 141 additions & 54 deletions src/preprocessing/numerical.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,76 +138,41 @@ impl<T: Number + RealNumber, M: Array2<T>> UnsupervisedEstimator<M, StandardScal
/// standard deviation to one.
impl<T: Number + RealNumber, M: Array2<T>> Transformer<M> for StandardScaler<T> {
fn transform(&self, x: &M) -> Result<M, Failed> {
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<T, M>(columns: Vec<M>) -> Option<M>
where
T: Number + RealNumber,
M: Array2<T>,
{
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() {
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading