This document explains how model evaluation (backtesting) works internally in Chap, with a focus on the expanding window cross-validation strategy used to split time series data.
The evaluation pipeline answers the question: “How well does a model predict disease cases on data it has not seen?”
It does this by:
Because disease surveillance data is a time series, we cannot use random train/test splits. Instead, Chap uses expanding window cross-validation, where the training data always precedes the test data chronologically.
The evaluation flow from entry point to results:
flowchart TB
Create["Evaluation.create()<br/>evaluation.py"]
Split["train_test_generator()<br/>dataset_splitting.py<br/>returns (train_set, test_generator)"]
Backtest["backtest()<br/>prediction_evaluator.py"]
Loop["for each split:<br/>retrain estimator if scheduled<br/>predictor.predict(historic, future)<br/>merge predictions with ground truth<br/>yield DataSet(SamplesWithTruth)"]
Wrap["Evaluation.from_samples_with_truth()<br/>wraps results in an Evaluation object"]
Create --> Split
Split --> Backtest
Backtest --> Loop
Loop --> Wrap
Standard k-fold cross-validation randomly assigns data points to folds. This is invalid for time series because:
Chap uses an expanding window approach where:
n_retrain evenly spaced split points, with n_retrain=1 training once at the beginningThe key parameters are:
The train_test_generator function computes splits from the end of the dataset working backwards:
split_idx = -(prediction_length + (n_test_sets - 1) * stride + 1)
This ensures the last test window ends at the final period of the dataset.
Consider a dataset with 20 monthly periods (indices 0-19), prediction_length=3, n_test_sets=3, stride=1:
split_idx = -(3 + (3 - 1) * 1 + 1) = -6 -> index 14
Split 0: historic = [0..14], future = [15, 16, 17]
Split 1: historic = [0..15], future = [16, 17, 18]
Split 2: historic = [0..16], future = [17, 18, 19]
Train set = [0..14] (same periods as split 0 historic data, but kept as the dedicated training set)
Visually, with T = train, H = extra historic context, F = future/test:
Period: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
Train: T T T T T T T T T T T T T T T
Split 0: T T T T T T T T T T T T T T T F F F
Split 1: T T T T T T T T T T T T T T T H F F F
Split 2: T T T T T T T T T T T T T T T H H F F F
Note how the historic data expands with each split while the future window slides forward. At split 0, backtest() trains on the dedicated train_set; any later retrains use the expanding historic data for that split.
For each test split, the predictor receives:
chap_core/assessment/dataset_splitting.pyHandles splitting datasets into train/test portions:
train_test_generator() – main function implementing expanding window splitstrain_test_split() – single split at one time pointsplit_test_train_on_period() – generates splits at multiple split pointsget_split_points_for_data_set() – computes evenly-spaced split pointschap_core/assessment/prediction_evaluator.pyRuns the model and collects predictions:
backtest() – trains or retrains the model at n_retrain evenly spaced split points and yields predictions for each splitevaluate_model() – full evaluation with GluonTS metrics and PDF reportchap_core/assessment/evaluation.pyHigh-level evaluation abstraction:
Evaluation.create() – end-to-end factory: runs backtest and wraps resultsEvaluation.from_samples_with_truth() – builds evaluation from raw prediction resultsEvaluation.to_file() / from_file() – NetCDF serialization for sharing resultsEvaluation.create()Step-by-step walkthrough of what happens when Evaluation.create() is called (e.g. from the CLI chap eval command):
train_test_generator() computes the split index and creates:
backtest() is called with the estimator, training set, test generator, number of test sets, and n_retrainSamplesWithTruth objectsfrom_samples_with_truth() assembles an Evaluation object containing:
Backtest with all forecasts and observationsEvaluation can then be exported to NetCDF, used for metric computation, or visualized