use super::CvSplitter;
use crate::error::{ModelSelectionError, Result};
#[derive(Debug, Clone)]
pub struct TimeSeriesSplit {
n_splits: usize,
max_train_size: Option<usize>,
gap: usize,
test_size: Option<usize>,
}
impl TimeSeriesSplit {
pub fn new(n_splits: usize) -> Result<Self> {
if n_splits < 1 {
return Err(ModelSelectionError::InvalidSplitCount {
msg: format!("n_splits must be >= 1, got {n_splits}"),
});
}
Ok(Self {
n_splits,
max_train_size: None,
gap: 0,
test_size: None,
})
}
#[must_use]
pub fn with_max_train_size(mut self, max_train_size: usize) -> Self {
self.max_train_size = Some(max_train_size);
self
}
#[must_use]
pub fn with_gap(mut self, gap: usize) -> Self {
self.gap = gap;
self
}
#[must_use]
pub fn with_test_size(mut self, test_size: usize) -> Self {
self.test_size = Some(test_size);
self
}
}
impl CvSplitter for TimeSeriesSplit {
fn split(&self, n_samples: usize) -> Result<Vec<(Vec<usize>, Vec<usize>)>> {
let n_folds = self.n_splits;
let test_size = self.test_size.unwrap_or_else(|| n_samples / (n_folds + 1));
if test_size == 0 {
return Err(ModelSelectionError::InsufficientTrainWindow {
msg: format!(
"computed test_size is 0 for n_samples={n_samples}, n_splits={n_folds}; \
supply more samples or a smaller n_splits"
),
});
}
let total_test = n_folds.checked_mul(test_size).ok_or_else(|| {
ModelSelectionError::InvalidSplitCount {
msg: "n_splits * test_size overflowed".to_string(),
}
})?;
if total_test >= n_samples {
return Err(ModelSelectionError::InsufficientTrainWindow {
msg: format!(
"n_splits({n_folds}) * test_size({test_size}) = {total_test} leaves no room \
for a training set in n_samples={n_samples}"
),
});
}
let first_test_start = n_samples - total_test;
let mut splits = Vec::with_capacity(n_folds);
for fold in 0..n_folds {
let test_start = first_test_start + fold * test_size;
let test_end = test_start + test_size;
let train_end = test_start.checked_sub(self.gap).ok_or_else(|| {
ModelSelectionError::InsufficientTrainWindow {
msg: format!(
"gap({}) is larger than the available history before fold {fold}",
self.gap
),
}
})?;
if train_end == 0 {
return Err(ModelSelectionError::InsufficientTrainWindow {
msg: format!(
"fold {fold} has an empty training window (gap={}, test_size={test_size})",
self.gap
),
});
}
let train_start = match self.max_train_size {
Some(mts) => train_end.saturating_sub(mts),
None => 0,
};
let train: Vec<usize> = (train_start..train_end).collect();
let test: Vec<usize> = (test_start..test_end).collect();
splits.push((train, test));
}
Ok(splits)
}
fn n_splits(&self) -> usize {
self.n_splits
}
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_chronological(splits: &[(Vec<usize>, Vec<usize>)], gap: usize) {
for (train, test) in splits {
let max_train = *train.iter().max().unwrap();
let min_test = *test.iter().min().unwrap();
assert!(max_train < min_test, "train index >= test index");
assert!(min_test - max_train > gap, "gap not respected");
}
}
#[test]
fn expanding_window_is_chronological() {
let tss = TimeSeriesSplit::new(4).unwrap();
let splits = tss.split(20).unwrap();
assert_eq!(splits.len(), 4);
assert_chronological(&splits, 0);
let train_lens: Vec<usize> = splits.iter().map(|(tr, _)| tr.len()).collect();
assert!(train_lens.windows(2).all(|w| w[0] < w[1]));
}
#[test]
fn fixed_window_caps_train_size() {
let tss = TimeSeriesSplit::new(3).unwrap().with_max_train_size(4);
let splits = tss.split(20).unwrap();
assert_chronological(&splits, 0);
assert!(splits.iter().all(|(tr, _)| tr.len() <= 4));
}
#[test]
fn gap_is_respected() {
let tss = TimeSeriesSplit::new(3).unwrap().with_gap(2);
let splits = tss.split(30).unwrap();
assert_chronological(&splits, 2);
}
#[test]
fn errors_when_not_enough_samples() {
let tss = TimeSeriesSplit::new(10).unwrap();
assert!(matches!(
tss.split(5),
Err(ModelSelectionError::InsufficientTrainWindow { .. })
));
}
#[test]
fn custom_test_size() {
let tss = TimeSeriesSplit::new(3).unwrap().with_test_size(2);
let splits = tss.split(20).unwrap();
assert!(splits.iter().all(|(_, te)| te.len() == 2));
assert_chronological(&splits, 0);
}
}