Skip to main content

chronix_analytics/forecast/
cross_validation.rs

1//! Time-series cross-validation for forecast model evaluation.
2//!
3//! Standard k-fold cross-validation is inappropriate for time-series
4//! data because it breaks temporal ordering.  This module implements
5//! **expanding-window** (anchored) and **sliding-window** walk-forward
6//! cross-validation, which respect the arrow of time.
7//!
8//! # Example
9//!
10//! ```no_run
11//! use chronix_analytics::forecast::cross_validation::{CrossValidator, CrossValidationMode};
12//! use chronix_analytics::forecast::{SesModel, ForecastModel};
13//!
14//! let timestamps: Vec<i64> = (0..120).map(|i| i * 1_000_000_000).collect();
15//! let values: Vec<f64> = (0..120).map(|i| 10.0 + i as f64 * 0.5).collect();
16//!
17//! let cv = CrossValidator::new(
18//!     CrossValidationMode::ExpandingWindow,
19//!     30,   // minimum training size
20//!     10,   // prediction horizon per fold
21//!     5,    // number of folds
22//! );
23//!
24//! let results = cv.evaluate(
25//!     &timestamps,
26//!     &values,
27//!     || Box::new(SesModel::new(None)),
28//! ).unwrap();
29//!
30//! assert_eq!(results.folds.len(), 5);
31//! assert!(results.mean_rmse > 0.0);
32//! ```
33
34use crate::forecast::diagnostics;
35use crate::forecast::error::ForecastError;
36use crate::forecast::traits::ForecastModel;
37
38// ── Configuration ──────────────────────────────────────────────────
39
40/// Walk-forward cross-validation mode.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum CrossValidationMode {
43    /// Expanding window — each fold's training set starts at index 0
44    /// and grows by `step` observations.
45    ExpandingWindow,
46    /// Sliding window — each fold's training set is a fixed-size
47    /// window that slides forward by `step` observations.
48    SlidingWindow,
49}
50
51/// Cross-validation configuration.
52#[derive(Debug, Clone)]
53pub struct CrossValidator {
54    /// Mode: expanding or sliding window.
55    pub mode: CrossValidationMode,
56    /// Minimum number of training observations.
57    pub min_train_size: usize,
58    /// Number of steps to predict (test window).
59    pub horizon: usize,
60    /// Number of folds.
61    pub n_folds: usize,
62}
63
64impl CrossValidator {
65    /// Create a new cross-validator.
66    #[must_use]
67    pub fn new(
68        mode: CrossValidationMode,
69        min_train_size: usize,
70        horizon: usize,
71        n_folds: usize,
72    ) -> Self {
73        Self {
74            mode,
75            min_train_size,
76            horizon,
77            n_folds,
78        }
79    }
80
81    /// Evaluate a forecast model using walk-forward cross-validation.
82    ///
83    /// `model_factory` is called once per fold to create a fresh model
84    /// instance — this avoids carry-over state between folds.
85    ///
86    /// # Errors
87    ///
88    /// Returns an error if the data is too short for the requested
89    /// configuration or if any fold's fit/predict fails.
90    pub fn evaluate<F>(
91        &self,
92        timestamps: &[i64],
93        values: &[f64],
94        model_factory: F,
95    ) -> Result<CrossValidationResult, ForecastError>
96    where
97        F: Fn() -> Box<dyn ForecastModel>,
98    {
99        let n = values.len();
100        if n != timestamps.len() {
101            return Err(ForecastError::InvalidInput(
102                "timestamps and values must have the same length".into(),
103            ));
104        }
105
106        let required = self.min_train_size + self.horizon;
107        if n < required {
108            return Err(ForecastError::InvalidInput(format!(
109                "need at least {} observations (min_train={} + horizon={}), got {}",
110                required, self.min_train_size, self.horizon, n
111            )));
112        }
113
114        // Calculate step size so that folds are evenly spaced.
115        let available = n - self.min_train_size - self.horizon;
116        let step = if self.n_folds <= 1 {
117            0
118        } else {
119            available / (self.n_folds - 1).max(1)
120        };
121
122        let mut folds = Vec::with_capacity(self.n_folds);
123
124        for fold_idx in 0..self.n_folds {
125            let train_end = self.min_train_size + fold_idx * step;
126            if train_end + self.horizon > n {
127                break;
128            }
129
130            let train_start = match self.mode {
131                CrossValidationMode::ExpandingWindow => 0,
132                CrossValidationMode::SlidingWindow => train_end.saturating_sub(self.min_train_size),
133            };
134
135            let test_start = train_end;
136            let test_end = (train_end + self.horizon).min(n);
137
138            let train_ts = &timestamps[train_start..train_end];
139            let train_vals = &values[train_start..train_end];
140            let test_vals = &values[test_start..test_end];
141
142            let mut model = model_factory();
143            model.fit(train_ts, train_vals)?;
144
145            let forecast = model.predict(test_end - test_start)?;
146            let predicted = &forecast.values[..test_vals.len().min(forecast.values.len())];
147            let actual = &test_vals[..predicted.len()];
148
149            let rmse = diagnostics::rmse(actual, predicted).unwrap_or(f64::NAN);
150            let mae = diagnostics::mae(actual, predicted).unwrap_or(f64::NAN);
151            let mape = diagnostics::mape(actual, predicted);
152            let smape = diagnostics::smape(actual, predicted);
153
154            folds.push(FoldResult {
155                fold_index: fold_idx,
156                train_size: train_end - train_start,
157                test_size: actual.len(),
158                rmse,
159                mae,
160                mape,
161                smape,
162            });
163        }
164
165        if folds.is_empty() {
166            return Err(ForecastError::InvalidInput(
167                "no valid folds could be constructed".into(),
168            ));
169        }
170
171        let mean_rmse = folds.iter().map(|f| f.rmse).sum::<f64>() / folds.len() as f64;
172        let mean_mae = folds.iter().map(|f| f.mae).sum::<f64>() / folds.len() as f64;
173        let mean_mape = {
174            let vals: Vec<f64> = folds.iter().filter_map(|f| f.mape).collect();
175            if vals.is_empty() {
176                None
177            } else {
178                Some(vals.iter().sum::<f64>() / vals.len() as f64)
179            }
180        };
181        let mean_smape = {
182            let vals: Vec<f64> = folds.iter().filter_map(|f| f.smape).collect();
183            if vals.is_empty() {
184                None
185            } else {
186                Some(vals.iter().sum::<f64>() / vals.len() as f64)
187            }
188        };
189
190        Ok(CrossValidationResult {
191            folds,
192            mean_rmse,
193            mean_mae,
194            mean_mape,
195            mean_smape,
196        })
197    }
198}
199
200// ── Results ────────────────────────────────────────────────────────
201
202/// Metrics from a single cross-validation fold.
203#[derive(Debug, Clone)]
204pub struct FoldResult {
205    /// Zero-based fold index.
206    pub fold_index: usize,
207    /// Number of training observations for this fold.
208    pub train_size: usize,
209    /// Number of test observations for this fold.
210    pub test_size: usize,
211    /// RMSE for this fold.
212    pub rmse: f64,
213    /// MAE for this fold.
214    pub mae: f64,
215    /// MAPE for this fold (if computable).
216    pub mape: Option<f64>,
217    /// sMAPE for this fold (if computable).
218    pub smape: Option<f64>,
219}
220
221/// Aggregate cross-validation results across all folds.
222#[derive(Debug, Clone)]
223pub struct CrossValidationResult {
224    /// Per-fold results.
225    pub folds: Vec<FoldResult>,
226    /// Mean RMSE across folds.
227    pub mean_rmse: f64,
228    /// Mean MAE across folds.
229    pub mean_mae: f64,
230    /// Mean MAPE across folds (if any fold computed it).
231    pub mean_mape: Option<f64>,
232    /// Mean sMAPE across folds (if any fold computed it).
233    pub mean_smape: Option<f64>,
234}
235
236// ── Tests ──────────────────────────────────────────────────────────
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use crate::forecast::SesModel;
242
243    fn make_linear_data(n: usize) -> (Vec<i64>, Vec<f64>) {
244        let ts: Vec<i64> = (0..n).map(|i| i as i64 * 1_000_000_000).collect();
245        let vals: Vec<f64> = (0..n).map(|i| 10.0 + i as f64 * 0.5).collect();
246        (ts, vals)
247    }
248
249    #[test]
250    fn expanding_window_produces_folds() {
251        let (ts, vals) = make_linear_data(100);
252        let cv = CrossValidator::new(CrossValidationMode::ExpandingWindow, 30, 10, 5);
253        let result = cv
254            .evaluate(&ts, &vals, || Box::new(SesModel::new(None)))
255            .unwrap();
256
257        assert_eq!(result.folds.len(), 5);
258        assert!(result.mean_rmse > 0.0);
259        assert!(result.mean_mae > 0.0);
260
261        // Training sizes should be non-decreasing (expanding).
262        for i in 1..result.folds.len() {
263            assert!(result.folds[i].train_size >= result.folds[i - 1].train_size);
264        }
265    }
266
267    #[test]
268    fn sliding_window_constant_train_size() {
269        let (ts, vals) = make_linear_data(100);
270        let cv = CrossValidator::new(CrossValidationMode::SlidingWindow, 30, 10, 5);
271        let result = cv
272            .evaluate(&ts, &vals, || Box::new(SesModel::new(None)))
273            .unwrap();
274
275        assert_eq!(result.folds.len(), 5);
276        for fold in &result.folds {
277            assert_eq!(
278                fold.train_size, 30,
279                "sliding window should keep constant train size"
280            );
281        }
282    }
283
284    #[test]
285    fn too_short_data_returns_error() {
286        let (ts, vals) = make_linear_data(10);
287        let cv = CrossValidator::new(CrossValidationMode::ExpandingWindow, 30, 10, 5);
288        let err = cv.evaluate(&ts, &vals, || Box::new(SesModel::new(None)));
289        assert!(err.is_err());
290    }
291
292    #[test]
293    fn single_fold() {
294        let (ts, vals) = make_linear_data(50);
295        let cv = CrossValidator::new(CrossValidationMode::ExpandingWindow, 30, 10, 1);
296        let result = cv
297            .evaluate(&ts, &vals, || Box::new(SesModel::new(None)))
298            .unwrap();
299        assert_eq!(result.folds.len(), 1);
300        assert_eq!(result.folds[0].train_size, 30);
301    }
302
303    #[test]
304    fn metrics_are_finite() {
305        let (ts, vals) = make_linear_data(120);
306        let cv = CrossValidator::new(CrossValidationMode::ExpandingWindow, 30, 10, 5);
307        let result = cv
308            .evaluate(&ts, &vals, || Box::new(SesModel::new(None)))
309            .unwrap();
310
311        assert!(result.mean_rmse.is_finite());
312        assert!(result.mean_mae.is_finite());
313        if let Some(mape) = result.mean_mape {
314            assert!(mape.is_finite());
315        }
316        if let Some(smape) = result.mean_smape {
317            assert!(smape.is_finite());
318        }
319    }
320}