Skip to main content
Correct horizon-dependent bias in recursive multi-step forecasts by training a small residual model on top of an existing forecaster.

What you’ll learn

  • Why recursive multi-step forecasts develop bias as the horizon grows
  • How the Rectify strategy combines recursive (low-variance) and direct (low-bias) forecasting
  • How to compute horizon-indexed residuals from cross-validation output
  • How to align feature matrices with residuals for training correction models
  • How to apply fitted correction models to new forecasts using both per_horizon and horizon_aware modes

The problem: bias accumulates with horizon

Recursive forecasting trains a single one-step-ahead model and feeds its predictions back as inputs for longer horizons. It is fast and produces smooth multi-step trajectories, but for non-linear data-generating processes the bias compounds: each step uses a slightly biased prediction as input, which produces a more biased prediction for the next step. Direct forecasting avoids that problem by training a separate model per horizon. It is unbiased but high-variance and produces no continuity between horizons. The Rectify strategy by Ben Taieb & Hyndman (2012) keeps the recursive base forecast and adds a per-horizon correction: m^h(xt)=z^h(xt)+r^h(xt)\hat{m}_h(x_t) = \hat{z}_h(x_t) + \hat{r}_h(x_t) utilsforecast provides three building blocks for this — computing residuals, aligning features, and applying corrections. You bring the base forecaster and the correction regressor.

Install libraries

Generate synthetic time series

We use generate_series to create a panel of 8 daily series with weekly seasonality and a positive trend. The trend is what makes the example interesting: our base forecaster will be SeasonalNaive, which repeats the last observed week and has no way to capture trend. Its forecasts will drift further behind the actuals as the horizon grows — a textbook setup for rectify to fix.

Generate cross-validation forecasts

We need cross-validation output (forecasts plus actuals across multiple cutoffs) to train the correction models. StatsForecast.cross_validation() produces exactly that, with a cutoff column distinguishing each fold.
Each row is a (series, cutoff, ds) triple with the actual y and the SeasonalNaive forecast. With 4 cutoffs and a horizon of 7, every series contributes 28 training rows for the correction model.

Step 1: compute per-horizon residuals

compute_rectify_residuals joins actuals and forecasts on (id_col, time_col, cutoff_col) and computes actual - forecast per row. It also adds a 1-indexed horizon column based on the row position within each (id, cutoff) group.
We pass cv_df for both df and forecasts_df because the cross-validation output already contains both columns. The output is sorted by (unique_id, cutoff, ds) — the same order we sorted cv_df in. Keeping the orderings consistent matters for the next step.

Step 2: build features

The correction model needs a feature matrix with the same row count and order as residuals_df. Anything that explains residual variation is fair game — calendar effects, lags, or exogenous variables. To keep the tutorial focused, we use two simple features:
  • dow: day of week of the prediction date
  • lag_y: the most recent observed y per series, used as a level proxy

Step 3: align features with residuals

align_rectify_features partitions the residuals by horizon and slices the feature matrix accordingly. In per_horizon mode it returns a dict mapping each horizon h to a (X_h, {model: residuals_h}) tuple — one training set per horizon.

Step 4: train one correction model per horizon

Any object with a .predict(X) method works. Here we use LinearRegression, but Ridge, LightGBM, or a custom regressor would all be valid.

Step 5: forecast on the held-out window and rectify

We refit the base model on the full training set, predict the held-out horizon, then build a matching feature matrix and apply rectify. The corrected forecasts add the predicted residual back to each base prediction.

Step 6: compare base vs rectified MAE

Merge the held-out actuals with both forecast frames and use evaluate() to compute MAE per series.
SeasonalNaive systematically underestimates trended series, and the corrector learns that pattern from the CV residuals. On this run the rectified MAE drops from ~4.9 to ~0.3 — about a 14x reduction.

Horizon-aware mode: a single corrector for all horizons

per_horizon mode trains one correction model per horizon, which is the classic Rectify formulation. When training data is small or you want a single model to share information across horizons, horizon_aware mode appends the horizon as an extra feature column and trains one corrector for the full range.
A few notes on the API differences between the modes:
  • mode='horizon_aware' is keyword-only, so it must be passed by name
  • correction_models is flat ({model_name: regressor}) instead of nested by horizon
  • The horizon column is appended to the feature matrix before fitting, so the corrector sees horizon as just another feature

Custom column names

All three functions accept id_col and time_col parameters. compute_rectify_residuals additionally accepts target_col and cutoff_col. Pass them consistently across the pipeline if your data uses non-default names.

Key takeaways

  • Rectify is post-processing. Your base forecaster doesn’t need to change; the correction model lives on top of it.
  • Use cutoff_col when training from cross-validation output. Without it, repeated (id, ds) pairs across folds collide on the join.
  • Features must be row-aligned with the dataframe. Sort cv_df and the forecast frame consistently before extracting features and you’re safe.
  • per_horizon vs horizon_aware is a sample-size call. When you have plenty of folds, separate models per horizon usually win. When data is thin, one shared model with horizon as a feature does better.
  • Anything with a predict(X) method works. sklearn, lightgbm, xgboost, your own class.