evaluate.backtest()
Backtest a forecasting model on a moving window of (train, test) data.
Usage
evaluate.backtest(
rng_key,
model_fn,
data,
covariates,
*,
forecast_fn,
in_sample_fn=None,
metrics=None,
per_window_metrics=None,
transform=None,
window_type=None,
train_window=None,
min_train_window=1,
test_window=None,
min_test_window=1,
stride=1,
num_samples=100,
batch_size=None,
eval_train=False,
keep_predictions=False,
reuse_model=True
)Fitting and forecasting are delegated entirely to user-supplied closures rather than an OOP forecaster: forecast_fn fits model on the training window and forecasts the test horizon, and the optional in_sample_fn fits and scores the in-sample fit. Both closures own their own inference backend (SVI, MCMC, or anything else), so backtest itself has no dependency on how a model is fit.
forecast_fn has the call signature (see ForecastFn):
forecast_fn(
rng_key, model, train_data, train_covariates, full_covariates,
num_samples, *, batch_size=None,
) -> draws # shape (num_samples, *batch, t2 - t1, obs)where full_covariates spans the full window, covariates[..., t0:t2, :] (train followed by test), matching what the model needs to run the forecast horizon. The optional in_sample_fn has the call signature (see InSampleFn):
in_sample_fn(
rng_key, model, train_data, train_covariates, num_samples, *, batch_size=None,
) -> draws # shape (num_samples, *batch, t1 - t0, obs)batch_size is forwarded unchanged into both closures so a chunked implementation can bound its own device memory. A closure may return draws committed to host memory (e.g. via device="host", to cap peak accelerator usage): every metric in DEFAULT_METRICS accepts a host-committed pred or truth (or both), in any mix and regardless of batch_size, moving a host-committed operand to device memory first where needed. Returning draws already on-device still avoids the extra host-to-device hop for metrics scored every window.
A minimal forecast_fn built on plain NumPyro (AutoNormal + SVI.run + Predictive):
import numpyro
from jax import random
from numpyro.infer import SVI, Predictive, Trace_ELBO
from numpyro.infer.autoguide import AutoNormal
def forecast_fn(
rng_key,
model,
train_data,
train_covariates,
full_covariates,
num_samples,
*,
batch_size=None,
):
guide = AutoNormal(model)
svi = SVI(model, guide, numpyro.optim.Adam(0.01), Trace_ELBO())
key_fit, key_post, key_pred = random.split(rng_key, 3)
state = svi.run(key_fit, 1_000, train_covariates, train_data, progress_bar=False)
posterior = guide.sample_posterior(key_post, state.params, sample_shape=(num_samples,))
predictive = Predictive(model, posterior_samples=posterior, return_sites=["forecast"])
return predictive(key_pred, full_covariates, train_data)["forecast"]Parameters
rng_key: Array-
Base PRNG key, used for every window (matching Pyro). Each window splits it once,
forecast_key, train_key = random.split(rng_key):forecast_fnreceivesforecast_keyandin_sample_fnreceivestrain_key. The split does not depend oneval_train, so turning in-sample scoring on does not change the out-of-sample draws. model_fn: ModelFactory-
Factory returning a fresh ForecastModel per window.
data: Array-
Dataset with time at axis
-2. covariates: Array-
Covariates with time at axis
-2(same duration asdata). forecast_fn: ForecastFn-
Closure that fits
modelon the training window and forecasts the test horizon (see ForecastFn and the contract above). in_sample_fn: InSampleFn | None = None-
Optional closure that fits
modelon the training window and scores its in-sample fit (see InSampleFn and the contract above). Required wheneval_train=True. It receives its own child ofrng_key(seerng_key), never a key thatforecast_fncan derive from its own. metrics: Mapping[str, Metric] | None = None-
Mapping of metric name to function; defaults to DEFAULT_METRICS. Each function takes
(pred, truth)and returns a scalar array (see Metric); bind any metric-specific parameters withfunctools.partial(), e.g.{**DEFAULT_METRICS, "coverage": partial(eval_coverage, alpha=0.8)}. Each window is scored through evaluate_forecast(), so the integer draws of a count model need no cast and every metric receives floating arrays.transformreceives the draws asforecast_fnreturned them;keep_predictionsstores them aftertransform. per_window_metrics: Callable[[int, int, int], Mapping[str, Metric]] | None = None-
Optional
(t0, t1, t2) -> Mapping[str, Metric]callable producing extra metrics merged overmetricsfor each window. Use it for window-dependent metrics such as a MASE scaled by that window’s training data (numpyro_forecast.metrics.make_mase()). transform: Callable[[Array, Array], tuple[Array, Array]] | None = None-
Optional
(pred, truth) -> (pred, truth)applied before metrics. It runs before the metrics and receives the forecast/in-sample closure’s draws as-is, so a transform that does its own array math against a device-resident operand must convert a host-committedpredortruthfirst, e.g. withnumpy.asarray()(or move it back onto a device explicitly). window_type: WindowType | None = None-
Windowing strategy. If
None(default) it is inferred fromtrain_window:"expanding"whentrain_windowisNoneand"rolling"when it is set, matching the historical behavior. Pass"expanding"to always train on all history fromt0 = 0, or"rolling"to hold the training length fixed attrain_windowand slide it forward."expanding"andtrain_windoware mutually exclusive, and"rolling"requirestrain_window(both validated). train_window: int | None = None-
Training window size; if
Nonethe window expands from the start. Required forwindow_type="rolling". min_train_window: int = 1-
Minimum training window size for the expanding strategy (used when
train_windowisNone). test_window: int | None = None-
Test window size; if
Noneforecasts to the end of the data. min_test_window: int = 1-
Minimum test window size when
test_windowisNone. stride: int = 1-
Step between successive train/test splits.
num_samples: int = 100-
Number of forecast samples per window. Must be positive, and at least 2 when
metricscontains eval_crps() (as DEFAULT_METRICS does) or afunctools.partial()of it. Both rules are checked before the first window is fitted. A CRPS inside another wrapper (alambdaor ajax.jitfunction) or added byper_window_metricsis not checked up front: it fails only when it runs. batch_size: int | None = None-
Optional chunk size forwarded to
forecast_fnandin_sample_fn(see the contract above). eval_train: bool = False-
If
True, also score the in-sample posterior predictive over each training window with the samemetricsand store them inBacktestResult.train_metrics. Requiresin_sample_fn. keep_predictions: bool = False-
If
True, store each window’s out-of-sample forecast samples (aftertransform) onBacktestResult.prediction. Defaults toFalseto avoid retaining large Monte Carlo arrays. reuse_model: bool = True-
When
True(default) and the windowing strategy is rolling, the model instance returned by the firstmodel_fn()call is reused for every window, somodel_fnruns once. Ignored for expanding windows and whenFalse(model_fnthen runs once per window). It does not cache compiled programs: each forecast() or predict_in_sample() call thatforecast_fnorin_sample_fnmakes compiles once per window, and so does an SVI fit; for a single fused fit and forecast over all windows use backtest_vectorized(). Kept for API stability: it only matters for amodel_fnthat is expensive or stateful.
Returns
list[BacktestResult]- One result per backtest window.
Raises
ValueError-
If
dataorcovariateshas fewer than two axes (time must be at axis-2), if their durations differ, ifeval_train=Truebutin_sample_fnisNone, or ifnum_samplesis too small (seenum_samples). Also if a metric returns a value that is not 0-d or does not have a bool, integer or floating dtype (see evaluate_forecast()): the error names the metric and comes when the first window is scored, after its fit, because a metric may run on the host here and so is not traced before the fit. BacktestWindowError-
If
window_typeandtrain_windowdisagree ("rolling"without atrain_window, or"expanding"with one).