Skip to main content

model_selection_rs/splitters/
mod.rs

1//! Cross-validation splitters.
2//!
3//! Every splitter implements the single [`CvSplitter`] trait, which yields
4//! `(train_indices, test_indices)` pairs — **indices only**, never materialized
5//! data copies, matching scikit-learn's memory-efficient convention. Callers
6//! apply the indices to their data with ndarray fancy indexing
7//! ([`ndarray::ArrayBase::select`]).
8//!
9//! # Design note: how label- and group-aware splitters fit one trait
10//!
11//! Milestone 1 of the project plan left one question open: should stratified /
12//! group splitters need a *separate* trait taking `y` / `groups`, or can a
13//! single trait serve everything? This crate resolves it in favour of **one
14//! trait**. Label- and group-aware splitters
15//! ([`StratifiedKFold`], [`GroupKFold`], [`StratifiedGroupKFold`],
16//! [`StratifiedShuffleSplit`], [`RepeatedStratifiedKFold`]) take their labels /
17//! groups at **construction time** and store them, then implement the same
18//! [`CvSplitter::split`] as everything else.
19//!
20//! The upside is uniformity: [`cross_validate`](crate::evaluate::cross_validate),
21//! [`learning_curve`](crate::evaluate::learning_curve) and friends accept *any*
22//! `S: CvSplitter` with no special cases. The cost is that a stratified splitter
23//! owns a copy of its label array — cheap in practice, since you always have `y`
24//! in scope when you set up a CV loop, and labels are one small column.
25//!
26//! # Fallibility
27//!
28//! The plan sketched `split` as infallible (`-> Vec<..>`). It is promoted to
29//! `-> Result<Vec<..>>` here because validation (too few samples, an impossible
30//! time-series window, a label array whose length disagrees with `n_samples`)
31//! genuinely can fail and a library should surface that rather than panic. The
32//! per-split index math itself never fails once validation passes.
33
34use crate::error::Result;
35
36mod group_kfold;
37mod kfold;
38mod leave_one_out;
39mod repeated;
40mod shuffle_split;
41mod stratified_group_kfold;
42mod stratified_kfold;
43mod stratified_shuffle_split;
44mod time_series_split;
45
46pub use group_kfold::GroupKFold;
47pub use kfold::KFold;
48pub use leave_one_out::LeaveOneOut;
49pub use repeated::{RepeatedKFold, RepeatedStratifiedKFold};
50pub use shuffle_split::{ShuffleSplit, SubsetSize};
51pub use stratified_group_kfold::StratifiedGroupKFold;
52pub use stratified_kfold::StratifiedKFold;
53pub use stratified_shuffle_split::StratifiedShuffleSplit;
54pub use time_series_split::TimeSeriesSplit;
55
56/// A cross-validation splitting strategy.
57///
58/// Implementors return `(train, test)` index pairs for a dataset of
59/// `n_samples` rows. The indices are into the original row order; apply them
60/// with [`ndarray::ArrayBase::select`].
61///
62/// ```
63/// use model_selection_rs::splitters::{CvSplitter, KFold};
64///
65/// let kf = KFold::new(3).unwrap();
66/// let splits = kf.split(6).unwrap();
67/// assert_eq!(splits.len(), 3);
68/// for (train, test) in &splits {
69///     assert_eq!(train.len() + test.len(), 6);
70/// }
71/// ```
72pub trait CvSplitter {
73    /// Produce every `(train_indices, test_indices)` pair for `n_samples` rows.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`ModelSelectionError`](crate::error::ModelSelectionError) if the
78    /// configuration cannot produce valid splits for `n_samples` (for example
79    /// more folds than samples, or — for stored-label splitters — an
80    /// `n_samples` that disagrees with the stored label array length).
81    fn split(&self, n_samples: usize) -> Result<Vec<(Vec<usize>, Vec<usize>)>>;
82
83    /// Number of splits this strategy yields.
84    fn n_splits(&self) -> usize;
85}
86
87/// Split `n_samples` indices into `k` contiguous, near-equal folds.
88///
89/// The first `n % k` folds receive one extra element, matching the fold-size
90/// convention used by scikit-learn's `KFold`. Returns the fold *boundaries* as
91/// `(start, end)` half-open ranges over a `0..n_samples` index space; callers
92/// map those ranges onto whatever (possibly shuffled) index order they hold.
93pub(crate) fn fold_bounds(n_samples: usize, k: usize) -> Vec<(usize, usize)> {
94    let base = n_samples / k;
95    let remainder = n_samples % k;
96    let mut bounds = Vec::with_capacity(k);
97    let mut start = 0;
98    for fold in 0..k {
99        let size = base + usize::from(fold < remainder);
100        bounds.push((start, start + size));
101        start += size;
102    }
103    bounds
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    /// A trivial splitter used to prove the trait's bounds work end-to-end
111    /// before any real splitter is layered on top (plan Milestone 1 DoD).
112    struct PassthroughSplitter;
113
114    impl CvSplitter for PassthroughSplitter {
115        fn split(&self, n_samples: usize) -> Result<Vec<(Vec<usize>, Vec<usize>)>> {
116            // Everything is "train", nothing is "test" — the point is only to
117            // exercise the trait object / generic bounds, not to be useful.
118            Ok(vec![((0..n_samples).collect(), Vec::new())])
119        }
120        fn n_splits(&self) -> usize {
121            1
122        }
123    }
124
125    #[test]
126    fn passthrough_works_as_trait_object() {
127        let s: &dyn CvSplitter = &PassthroughSplitter;
128        let splits = s.split(5).unwrap();
129        assert_eq!(s.n_splits(), 1);
130        assert_eq!(splits[0].0, vec![0, 1, 2, 3, 4]);
131        assert!(splits[0].1.is_empty());
132    }
133
134    #[test]
135    fn fold_bounds_distributes_remainder_to_leading_folds() {
136        // 7 into 3 -> sizes 3, 2, 2
137        assert_eq!(fold_bounds(7, 3), vec![(0, 3), (3, 5), (5, 7)]);
138        // exact division -> equal sizes
139        assert_eq!(fold_bounds(6, 3), vec![(0, 2), (2, 4), (4, 6)]);
140    }
141}