model_selection_rs/splitters/
time_series_split.rs1use super::CvSplitter;
4use crate::error::{ModelSelectionError, Result};
5
6#[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 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 #[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 #[must_use]
75 pub fn with_gap(mut self, gap: usize) -> Self {
76 self.gap = gap;
77 self
78 }
79
80 #[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 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 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 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 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}