chronix_analytics/forecast/
cross_validation.rs1use crate::forecast::diagnostics;
35use crate::forecast::error::ForecastError;
36use crate::forecast::traits::ForecastModel;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum CrossValidationMode {
43 ExpandingWindow,
46 SlidingWindow,
49}
50
51#[derive(Debug, Clone)]
53pub struct CrossValidator {
54 pub mode: CrossValidationMode,
56 pub min_train_size: usize,
58 pub horizon: usize,
60 pub n_folds: usize,
62}
63
64impl CrossValidator {
65 #[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 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 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 = ×tamps[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#[derive(Debug, Clone)]
204pub struct FoldResult {
205 pub fold_index: usize,
207 pub train_size: usize,
209 pub test_size: usize,
211 pub rmse: f64,
213 pub mae: f64,
215 pub mape: Option<f64>,
217 pub smape: Option<f64>,
219}
220
221#[derive(Debug, Clone)]
223pub struct CrossValidationResult {
224 pub folds: Vec<FoldResult>,
226 pub mean_rmse: f64,
228 pub mean_mae: f64,
230 pub mean_mape: Option<f64>,
232 pub mean_smape: Option<f64>,
234}
235
236#[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 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}