Skip to main content

model_selection_rs/splitters/
time_series_split.rs

1//! Time-aware splitting: rolling-origin / expanding-window.
2
3use super::CvSplitter;
4use crate::error::{ModelSelectionError, Result};
5
6/// Time-series cross-validation (rolling-origin evaluation).
7///
8/// Successive splits grow their training window into the past while the test set
9/// is always the *next* chronological chunk. Order is never shuffled: for every
10/// split, every training index is strictly earlier than every test index (with
11/// an optional [`gap`](TimeSeriesSplit::with_gap) between them).
12///
13/// **Input convention:** samples are assumed to be in chronological row order.
14/// This splitter works purely on positions, not on explicit timestamps — sort
15/// your data by time before using it. (Positional order keeps the API simple and
16/// matches scikit-learn's `TimeSeriesSplit`; a timestamp-aware variant is
17/// intentionally out of scope.)
18///
19/// Configuration mirrors scikit-learn:
20/// * `n_splits` — number of train/test splits.
21/// * [`max_train_size`](TimeSeriesSplit::with_max_train_size) — cap the training
22///   window to a fixed size (a rolling window) instead of an ever-expanding one.
23/// * [`gap`](TimeSeriesSplit::with_gap) — drop this many samples between the end
24///   of train and the start of test, modelling a real-world delay before an
25///   outcome/label is known.
26/// * [`test_size`](TimeSeriesSplit::with_test_size) — samples per test set
27///   (defaults to `n_samples / (n_splits + 1)`).
28///
29/// ```
30/// use model_selection_rs::splitters::{CvSplitter, TimeSeriesSplit};
31///
32/// let tss = TimeSeriesSplit::new(3).unwrap();
33/// for (train, test) in tss.split(12).unwrap() {
34///     // train is always entirely before test
35///     assert!(train.iter().max() < test.iter().min());
36/// }
37/// ```
38#[derive(Debug, Clone)]
39pub struct TimeSeriesSplit {
40    n_splits: usize,
41    max_train_size: Option<usize>,
42    gap: usize,
43    test_size: Option<usize>,
44}
45
46impl TimeSeriesSplit {
47    /// Create a `TimeSeriesSplit` with `n_splits` expanding-window splits.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`ModelSelectionError::InvalidSplitCount`] if `n_splits < 1`.
52    pub fn new(n_splits: usize) -> Result<Self> {
53        if n_splits < 1 {
54            return Err(ModelSelectionError::InvalidSplitCount {
55                msg: format!("n_splits must be >= 1, got {n_splits}"),
56            });
57        }
58        Ok(Self {
59            n_splits,
60            max_train_size: None,
61            gap: 0,
62            test_size: None,
63        })
64    }
65
66    /// Cap the training window to `max_train_size` samples (rolling window).
67    #[must_use]
68    pub fn with_max_train_size(mut self, max_train_size: usize) -> Self {
69        self.max_train_size = Some(max_train_size);
70        self
71    }
72
73    /// Insert a `gap` of dropped samples between train and test.
74    #[must_use]
75    pub fn with_gap(mut self, gap: usize) -> Self {
76        self.gap = gap;
77        self
78    }
79
80    /// Fix the number of samples in each test set.
81    #[must_use]
82    pub fn with_test_size(mut self, test_size: usize) -> Self {
83        self.test_size = Some(test_size);
84        self
85    }
86}
87
88impl CvSplitter for TimeSeriesSplit {
89    fn split(&self, n_samples: usize) -> Result<Vec<(Vec<usize>, Vec<usize>)>> {
90        let n_folds = self.n_splits;
91        let test_size = self.test_size.unwrap_or_else(|| n_samples / (n_folds + 1));
92
93        if test_size == 0 {
94            return Err(ModelSelectionError::InsufficientTrainWindow {
95                msg: format!(
96                    "computed test_size is 0 for n_samples={n_samples}, n_splits={n_folds}; \
97                     supply more samples or a smaller n_splits"
98                ),
99            });
100        }
101
102        // The first test set starts here; each subsequent one is `test_size`
103        // later. We need room for every training window plus the gap.
104        let total_test = n_folds.checked_mul(test_size).ok_or_else(|| {
105            ModelSelectionError::InvalidSplitCount {
106                msg: "n_splits * test_size overflowed".to_string(),
107            }
108        })?;
109        if total_test >= n_samples {
110            return Err(ModelSelectionError::InsufficientTrainWindow {
111                msg: format!(
112                    "n_splits({n_folds}) * test_size({test_size}) = {total_test} leaves no room \
113                     for a training set in n_samples={n_samples}"
114                ),
115            });
116        }
117
118        let first_test_start = n_samples - total_test;
119        let mut splits = Vec::with_capacity(n_folds);
120        for fold in 0..n_folds {
121            let test_start = first_test_start + fold * test_size;
122            let test_end = test_start + test_size;
123
124            // Train ends `gap` samples before the test set begins.
125            let train_end = test_start.checked_sub(self.gap).ok_or_else(|| {
126                ModelSelectionError::InsufficientTrainWindow {
127                    msg: format!(
128                        "gap({}) is larger than the available history before fold {fold}",
129                        self.gap
130                    ),
131                }
132            })?;
133            if train_end == 0 {
134                return Err(ModelSelectionError::InsufficientTrainWindow {
135                    msg: format!(
136                        "fold {fold} has an empty training window (gap={}, test_size={test_size})",
137                        self.gap
138                    ),
139                });
140            }
141            let train_start = match self.max_train_size {
142                Some(mts) => train_end.saturating_sub(mts),
143                None => 0,
144            };
145
146            let train: Vec<usize> = (train_start..train_end).collect();
147            let test: Vec<usize> = (test_start..test_end).collect();
148            splits.push((train, test));
149        }
150        Ok(splits)
151    }
152
153    fn n_splits(&self) -> usize {
154        self.n_splits
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    /// The property that matters most: train strictly precedes test everywhere.
163    fn assert_chronological(splits: &[(Vec<usize>, Vec<usize>)], gap: usize) {
164        for (train, test) in splits {
165            let max_train = *train.iter().max().unwrap();
166            let min_test = *test.iter().min().unwrap();
167            assert!(max_train < min_test, "train index >= test index");
168            assert!(min_test - max_train > gap, "gap not respected");
169        }
170    }
171
172    #[test]
173    fn expanding_window_is_chronological() {
174        let tss = TimeSeriesSplit::new(4).unwrap();
175        let splits = tss.split(20).unwrap();
176        assert_eq!(splits.len(), 4);
177        assert_chronological(&splits, 0);
178        // Training set grows monotonically.
179        let train_lens: Vec<usize> = splits.iter().map(|(tr, _)| tr.len()).collect();
180        assert!(train_lens.windows(2).all(|w| w[0] < w[1]));
181    }
182
183    #[test]
184    fn fixed_window_caps_train_size() {
185        let tss = TimeSeriesSplit::new(3).unwrap().with_max_train_size(4);
186        let splits = tss.split(20).unwrap();
187        assert_chronological(&splits, 0);
188        assert!(splits.iter().all(|(tr, _)| tr.len() <= 4));
189    }
190
191    #[test]
192    fn gap_is_respected() {
193        let tss = TimeSeriesSplit::new(3).unwrap().with_gap(2);
194        let splits = tss.split(30).unwrap();
195        assert_chronological(&splits, 2);
196    }
197
198    #[test]
199    fn errors_when_not_enough_samples() {
200        let tss = TimeSeriesSplit::new(10).unwrap();
201        assert!(matches!(
202            tss.split(5),
203            Err(ModelSelectionError::InsufficientTrainWindow { .. })
204        ));
205    }
206
207    #[test]
208    fn custom_test_size() {
209        let tss = TimeSeriesSplit::new(3).unwrap().with_test_size(2);
210        let splits = tss.split(20).unwrap();
211        assert!(splits.iter().all(|(_, te)| te.len() == 2));
212        assert_chronological(&splits, 0);
213    }
214}