diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7715aed7..bf7bad03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,7 +73,7 @@ jobs: - name: Stable Build run: cargo build --no-default-features ${{ matrix.features }} - name: Tests - if: matrix.features == '--features ndarray-bindings' + if: matrix.features == '--features ndarray-bindings' || matrix.features == '--features datasets' run: cargo test --no-default-features ${{ matrix.features }} msrv: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d0dc5d7..75dba18c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - In the `...Regressor`s the features are always shuffled even when `mtry` equals the number of attributes. This changes the constructed trees. +### Added +- `make_blobs_with_seed`, `make_circles_with_seed` and `make_moons_with_seed` take a `seed: Option` for the generator RNG. With `Some(seed)` the output is reproducible with all features. + +### Changed +- **Breaking**: the `datasets` feature no longer enables `std_rand` (#416). The toy datasets now compile without `rand/std` and the OS RNG. The generators `make_blobs`, `make_circles` and `make_moons` now use the crate RNG. With `std_rand` the RNG takes its seed from OS entropy, as before. Without `std_rand` the RNG uses a fixed seed, thus the output is the same for each call. To keep the previous behaviour, enable `std_rand` together with `datasets`. This change must go in a minor version bump. + ## [0.6.16] ### Fixed - `predict`, `predict_proba`, `predict_oob`, `predict_matrix` and `decision_function` now return `Err(Failed)` instead of panicking when `fit` has not run (#469, #470). Models return the same unfitted error even after deserialization, when the state fields are `None`. The guarded methods cover the decision trees, the random forests, the extra-trees regressor, the KNN classifier and regressor, `SVC`, `MultiClassSVC`, `SVR`, the linear models, the naive Bayes classifiers and `XGRegressor`. diff --git a/Cargo.toml b/Cargo.toml index 4ec3a4b5..aa1130b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,8 +47,11 @@ serde = ["dep:serde", "dep:typetag"] ndarray-bindings = ["dep:ndarray"] # Sample datasets (iris, boston, digits, ...) and dataset generators. -# Enables std_rand and serde because datasets ship serialized bundles. -datasets = ["std_rand", "serde"] +# Enables serde because datasets ship serialized bundles. Does not enable +# std_rand: the generators use the crate RNG (SmallRng without std_rand). +# Without std_rand, make_blobs/make_circles/make_moons use a fixed seed and give +# the same output for each call. Add std_rand for non-deterministic generators. +datasets = ["serde"] # Use the standard library RNG (StdRng / thread_rng) instead of SmallRng. # Required for non-deterministic seeding without an explicit seed. diff --git a/src/dataset/generator.rs b/src/dataset/generator.rs index 37f9c236..00876482 100644 --- a/src/dataset/generator.rs +++ b/src/dataset/generator.rs @@ -1,9 +1,14 @@ //! # Dataset Generators //! +//! The generators get their RNG from `get_rng_impl(None)`. With the `std_rand` +//! feature the RNG takes its seed from OS entropy. Without `std_rand` the RNG +//! uses a fixed seed (or `getrandom` with the `js` feature), thus the +//! `datasets` feature does not need `rand/std`. use rand::distr::Distribution; use rand::distr::Uniform; use crate::dataset::Dataset; +use crate::rand_custom::get_rng_impl; /// Sample from N(mean, std) via Box-Muller transform using only rand 0.10 #[inline] @@ -16,15 +21,32 @@ fn sample_normal(mean: f32, std: f32, rng: &mut impl rand::Rng) -> f32 { } /// Generate `num_centers` clusters of normally distributed points +/// +/// The RNG comes from `get_rng_impl(None)`. With the `std_rand` feature the output +/// is random for each call. Without `std_rand` (and without `js`) the RNG uses a +/// fixed seed, thus each call gives the same output. Use [`make_blobs_with_seed`] to +/// select the seed. #[must_use] pub fn make_blobs( num_samples: usize, num_features: usize, num_centers: usize, +) -> Dataset { + make_blobs_with_seed(num_samples, num_features, num_centers, None) +} + +/// Same as [`make_blobs`], but uses `seed` for the RNG. With `Some(seed)` the output is +/// reproducible with all features. With `None` the behaviour is the same as [`make_blobs`]. +#[must_use] +pub fn make_blobs_with_seed( + num_samples: usize, + num_features: usize, + num_centers: usize, + seed: Option, ) -> Dataset { let center_box = Uniform::new(-10.0f32, 10.0f32).expect("Invalid uniform range"); let cluster_std = 1.0f32; - let mut rng = rand::rng(); + let mut rng = get_rng_impl(seed); // Pre-compute cluster centers (one mean per feature per cluster) let centers: Vec> = (0..num_centers) @@ -58,8 +80,33 @@ pub fn make_blobs( } /// Make a large circle containing a smaller circle in 2d. +/// +/// The RNG comes from `get_rng_impl(None)`. With the `std_rand` feature the output +/// is random for each call. Without `std_rand` (and without `js`) the RNG uses a +/// fixed seed, thus each call gives the same output. Use [`make_circles_with_seed`] to +/// select the seed. +/// +/// # Panics +/// +/// Panics if `factor` is not in the range `0..1`. #[must_use] pub fn make_circles(num_samples: usize, factor: f32, noise: f32) -> Dataset { + make_circles_with_seed(num_samples, factor, noise, None) +} + +/// Same as [`make_circles`], but uses `seed` for the RNG. With `Some(seed)` the output is +/// reproducible with all features. With `None` the behaviour is the same as [`make_circles`]. +/// +/// # Panics +/// +/// Panics if `factor` is not in the range `0..1`. +#[must_use] +pub fn make_circles_with_seed( + num_samples: usize, + factor: f32, + noise: f32, + seed: Option, +) -> Dataset { if !(0.0..1.0).contains(&factor) { panic!("'factor' has to be between 0 and 1."); } @@ -70,7 +117,7 @@ pub fn make_circles(num_samples: usize, factor: f32, noise: f32) -> Dataset = Vec::with_capacity(num_samples * 2); let mut y: Vec = Vec::with_capacity(num_samples); @@ -99,15 +146,31 @@ pub fn make_circles(num_samples: usize, factor: f32, noise: f32) -> Dataset Dataset { + make_moons_with_seed(num_samples, noise, None) +} + +/// Same as [`make_moons`], but uses `seed` for the RNG. With `Some(seed)` the output is +/// reproducible with all features. With `None` the behaviour is the same as [`make_moons`]. +#[must_use] +pub fn make_moons_with_seed( + num_samples: usize, + noise: f32, + seed: Option, +) -> Dataset { let num_samples_out = num_samples / 2; let num_samples_in = num_samples - num_samples_out; let linspace_out = linspace(0.0, std::f32::consts::PI, num_samples_out); let linspace_in = linspace(0.0, std::f32::consts::PI, num_samples_in); - let mut rng = rand::rng(); + let mut rng = get_rng_impl(seed); let mut x: Vec = Vec::with_capacity(num_samples * 2); let mut y: Vec = Vec::with_capacity(num_samples); @@ -194,4 +257,69 @@ mod tests { assert_eq!(dataset.num_features, 2); assert_eq!(dataset.num_samples, 10); } + + #[cfg(not(any(feature = "std_rand", feature = "js")))] + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn test_generators_without_std_rand_are_reproducible() { + // without std_rand the generators use a fixed-seed SmallRng + assert_eq!(make_blobs(10, 2, 3).data, make_blobs(10, 2, 3).data); + assert_eq!( + make_circles(10, 0.5, 0.05).data, + make_circles(10, 0.5, 0.05).data + ); + assert_eq!(make_moons(10, 0.05).data, make_moons(10, 0.05).data); + // a `None` seed gives the same output as the plain function + assert_eq!( + make_blobs_with_seed(10, 2, 3, None).data, + make_blobs(10, 2, 3).data + ); + assert_eq!( + make_circles_with_seed(10, 0.5, 0.05, None).data, + make_circles(10, 0.5, 0.05).data + ); + assert_eq!( + make_moons_with_seed(10, 0.05, None).data, + make_moons(10, 0.05).data + ); + } + + #[cfg(feature = "std_rand")] + #[test] + fn test_generators_with_std_rand_are_random() { + // with std_rand the generators take the seed from OS entropy + assert_ne!(make_blobs(100, 2, 3).data, make_blobs(100, 2, 3).data); + assert_ne!( + make_circles(100, 0.5, 0.05).data, + make_circles(100, 0.5, 0.05).data + ); + assert_ne!(make_moons(100, 0.05).data, make_moons(100, 0.05).data); + } + + #[cfg_attr( + all(target_arch = "wasm32", not(target_os = "wasi")), + wasm_bindgen_test::wasm_bindgen_test + )] + #[test] + fn test_generators_with_seed_are_reproducible() { + assert_eq!( + make_blobs_with_seed(10, 2, 3, Some(42)).data, + make_blobs_with_seed(10, 2, 3, Some(42)).data + ); + assert_ne!( + make_blobs_with_seed(10, 2, 3, Some(1)).data, + make_blobs_with_seed(10, 2, 3, Some(2)).data + ); + assert_eq!( + make_circles_with_seed(10, 0.5, 0.05, Some(42)).data, + make_circles_with_seed(10, 0.5, 0.05, Some(42)).data + ); + assert_eq!( + make_moons_with_seed(10, 0.05, Some(42)).data, + make_moons_with_seed(10, 0.05, Some(42)).data + ); + } } diff --git a/src/readers/io_testing.rs b/src/readers/io_testing.rs index 0bd0e07f..e7cad3ed 100644 --- a/src/readers/io_testing.rs +++ b/src/readers/io_testing.rs @@ -1,13 +1,16 @@ //! This module contains functionality to test IO. It has both functions that write //! to the file-system for end-to-end tests, but also abstractions to avoid this by //! reading from strings instead. -use rand::distr::{Alphanumeric, SampleString}; use std::fs; use std::io::Bytes; use std::io::Read; use std::io::{Chain, IoSliceMut, Take, Write}; +use std::sync::atomic::{AtomicUsize, Ordering}; -/// Writing out a temporary csv file at a random location and cleaning +/// Counter that makes each temporary file name unique in the process. +static TEMPORARY_FILE_COUNTER: AtomicUsize = AtomicUsize::new(0); + +/// Writing out a temporary csv file at a unique location and cleaning /// it up on `Drop`. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct TemporaryTextFile { @@ -16,7 +19,17 @@ pub struct TemporaryTextFile { impl TemporaryTextFile { pub fn new(contents: &str) -> std::io::Result { let test_text_file = TemporaryTextFile { - random_path: Alphanumeric.sample_string(&mut rand::rng(), 16), + // Process id and counter give a unique name without an OS RNG, + // thus the tests do not need the `std_rand` feature. + // The file is in the OS temporary directory, not in the working directory. + random_path: std::env::temp_dir() + .join(format!( + "smartcore_tmp_{}_{}", + std::process::id(), + TEMPORARY_FILE_COUNTER.fetch_add(1, Ordering::Relaxed) + )) + .to_string_lossy() + .into_owned(), }; string_to_file(contents, &test_text_file.random_path)?; Ok(test_text_file)