Skip to main content

model_selection_rs/splitters/
shuffle_split.rs

1//! Random-permutation train/test splitting (Monte-Carlo cross-validation).
2
3use rand::rngs::StdRng;
4use rand::seq::SliceRandom;
5use rand::SeedableRng;
6
7use super::CvSplitter;
8use crate::error::{ModelSelectionError, Result};
9
10/// How to size a train or test subset: an absolute count or a fraction of the
11/// dataset.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub enum SubsetSize {
14    /// A fixed number of samples.
15    Count(usize),
16    /// A fraction in `(0.0, 1.0)` of the total sample count.
17    Fraction(f64),
18}
19
20impl SubsetSize {
21    /// Resolve to an absolute sample count against `n_samples`.
22    pub(crate) fn resolve(self, n_samples: usize) -> usize {
23        match self {
24            SubsetSize::Count(c) => c,
25            SubsetSize::Fraction(f) => (f * n_samples as f64).round() as usize,
26        }
27    }
28}
29
30/// Random-permutation cross-validation.
31///
32/// Yields `n_splits` independent train/test splits, each formed by permuting the
33/// samples and slicing off a test set and a training set. Unlike
34/// [`KFold`](super::KFold), the splits are **not** guaranteed to partition the
35/// dataset — the same sample may be tested in several splits or none, and (if
36/// `train_size + test_size < n`) some samples may be left out of a given split
37/// entirely.
38///
39/// ```
40/// use model_selection_rs::splitters::{CvSplitter, ShuffleSplit, SubsetSize};
41///
42/// let ss = ShuffleSplit::new(5)
43///     .with_test_size(SubsetSize::Fraction(0.25))
44///     .with_seed(0);
45/// let splits = ss.split(40).unwrap();
46/// assert_eq!(splits.len(), 5);
47/// assert_eq!(splits[0].1.len(), 10); // 25% of 40
48/// ```
49#[derive(Debug, Clone)]
50pub struct ShuffleSplit {
51    n_splits: usize,
52    test_size: SubsetSize,
53    train_size: Option<SubsetSize>,
54    seed: u64,
55}
56
57impl ShuffleSplit {
58    /// Create a `ShuffleSplit` with `n_splits` splits, a default test size of
59    /// 10%, and the remainder used for training.
60    #[must_use]
61    pub fn new(n_splits: usize) -> Self {
62        Self {
63            n_splits,
64            test_size: SubsetSize::Fraction(0.1),
65            train_size: None,
66            seed: 0,
67        }
68    }
69
70    /// Set the test-subset size.
71    #[must_use]
72    pub fn with_test_size(mut self, test_size: SubsetSize) -> Self {
73        self.test_size = test_size;
74        self
75    }
76
77    /// Set the train-subset size (defaults to "everything not in test").
78    #[must_use]
79    pub fn with_train_size(mut self, train_size: SubsetSize) -> Self {
80        self.train_size = Some(train_size);
81        self
82    }
83
84    /// Set the base RNG seed.
85    #[must_use]
86    pub fn with_seed(mut self, seed: u64) -> Self {
87        self.seed = seed;
88        self
89    }
90
91    /// Resolve `(n_train, n_test)` for a dataset of `n_samples`, validating that
92    /// both are non-empty and fit.
93    pub(crate) fn resolve_sizes(&self, n_samples: usize) -> Result<(usize, usize)> {
94        let n_test = self.test_size.resolve(n_samples);
95        let n_train = match self.train_size {
96            Some(ts) => ts.resolve(n_samples),
97            None => n_samples.saturating_sub(n_test),
98        };
99        if n_test == 0 || n_train == 0 {
100            return Err(ModelSelectionError::InvalidSplitCount {
101                msg: format!(
102                    "resolved train={n_train}, test={n_test}; both must be >= 1 \
103                     (n_samples={n_samples})"
104                ),
105            });
106        }
107        if n_train + n_test > n_samples {
108            return Err(ModelSelectionError::NotEnoughSamples {
109                needed: n_train + n_test,
110                got: n_samples,
111            });
112        }
113        Ok((n_train, n_test))
114    }
115}
116
117impl CvSplitter for ShuffleSplit {
118    fn split(&self, n_samples: usize) -> Result<Vec<(Vec<usize>, Vec<usize>)>> {
119        let (n_train, n_test) = self.resolve_sizes(n_samples)?;
120        let mut splits = Vec::with_capacity(self.n_splits);
121        for i in 0..self.n_splits {
122            // A distinct but deterministic permutation per split.
123            let mut rng = StdRng::seed_from_u64(self.seed.wrapping_add(i as u64));
124            let mut indices: Vec<usize> = (0..n_samples).collect();
125            indices.shuffle(&mut rng);
126            let test: Vec<usize> = indices[..n_test].to_vec();
127            let train: Vec<usize> = indices[n_test..n_test + n_train].to_vec();
128            splits.push((train, test));
129        }
130        Ok(splits)
131    }
132
133    fn n_splits(&self) -> usize {
134        self.n_splits
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use std::collections::HashSet;
142
143    #[test]
144    fn sizes_honoured_and_disjoint() {
145        let ss = ShuffleSplit::new(4)
146            .with_test_size(SubsetSize::Count(5))
147            .with_train_size(SubsetSize::Count(10))
148            .with_seed(3);
149        for (train, test) in ss.split(30).unwrap() {
150            assert_eq!(train.len(), 10);
151            assert_eq!(test.len(), 5);
152            let tr: HashSet<_> = train.iter().collect();
153            let te: HashSet<_> = test.iter().collect();
154            assert!(tr.is_disjoint(&te));
155        }
156    }
157
158    #[test]
159    fn fraction_test_size() {
160        let ss = ShuffleSplit::new(2).with_test_size(SubsetSize::Fraction(0.2));
161        let splits = ss.split(50).unwrap();
162        assert!(splits.iter().all(|(_, te)| te.len() == 10));
163    }
164
165    #[test]
166    fn deterministic_for_seed() {
167        let a = ShuffleSplit::new(3).with_seed(11).split(20).unwrap();
168        let b = ShuffleSplit::new(3).with_seed(11).split(20).unwrap();
169        assert_eq!(a, b);
170    }
171
172    #[test]
173    fn errors_when_sizes_dont_fit() {
174        let ss = ShuffleSplit::new(2)
175            .with_test_size(SubsetSize::Count(20))
176            .with_train_size(SubsetSize::Count(20));
177        assert!(matches!(
178            ss.split(30),
179            Err(ModelSelectionError::NotEnoughSamples { .. })
180        ));
181    }
182}