More about forecasting
- ARIMA and SARIMAX models with python
- Time series forecasting with machine learning
- Forecasting time series with gradient boosting: XGBoost, LightGBM and CatBoost
- Forecasting time series with XGBoost
- Probabilistic forecasting
- Forecasting with deep learning
- Forecasting energy demand with machine learning
- Forecasting web traffic with machine learning
- Intermittent demand forecasting
- Modelling time series trend with tree-based models
- Bitcoin price prediction with Python
- Stacking ensemble of machine learning models to improve forecasting
- Interpretable forecasting models
- Mitigating the Impact of Covid on forecasting Models
- Forecasting time series with missing values
Introduction¶
When trying to anticipate future values, most forecasting models aim to predict the most likely future value, a technique known as point forecasting. While useful, it does not provide information about the model's confidence or the uncertainty associated with the prediction.
Probabilistic forecasting, as opposed to point-forecasting, is a family of techniques that allow for predicting the expected distribution of the outcome rather than a single value. This approach offers richer insights by enabling the creation of prediction intervals: ranges within which the true value is likely to fall. More formally, a prediction interval defines the interval within which the true value of the response variable is expected to be found with a given probability.
Estimating prediction intervals in forecasting is challenging, since many well-established methods for regression and one-step-ahead forecasts are not directly applicable when predicting multiple steps ahead. Additionally, there is a trade-off between two key metrics: coverage and interval width. Ideally, we want to achieve a certain level of coverage (e.g. 80%) while keeping the prediction intervals as narrow as possible and the model's prediction error as low as possible.
This document is inspired by Carl McBride Ellis (2024). Probabilistic Forecasting I: Temperature. Kaggle competition. In the competition, predictions are made one-step-ahead, meaning each prediction is generated for the next time step based on known previous values. In this adaptation, however, a multi-step-ahead approach is used, where multiple future time steps are predicted simultaneously. This method is more challenging and requires a more sophisticated model but is often necessary for real-world applications.
The task is to estimate the prediction intervals for the target variable, labeled OT, over the test period (from 2018-04-04 to 2018-06-26, 84 days). Each day, the next 24 hours are predicted, meaning 24 values are forecasted each day. This process is repeated daily until the end of the period, resulting in a total of 2,013 predictions (the last day only includes 21 hours). Central prediction intervals (with the same probability in each tail) with coverage levels of 10%, 20%, 30%, 40%, 50%, 60%, 70%, 80%, 90%, and 95% are estimated, and their coverage is evaluated.
Skforecast implements several methods for probabilistic forecasting:
💡 Tip
This is the second in a series of documents on probabilistic forecasting.
Libraries¶
The libraries used in this notebook are:
# Data processing
# ==============================================================================
import numpy as np
import pandas as pd
from skforecast.datasets import fetch_dataset
# Plots
# ==============================================================================
import matplotlib.pyplot as plt
from skforecast.plot import set_dark_theme, plot_prediction_intervals, plot_residuals
from statsmodels.graphics.tsaplots import plot_pacf
from statsmodels.tsa.stattools import pacf
import plotly.graph_objects as go
import plotly.io as pio
import plotly.offline as poff
pio.templates.default = "seaborn"
pio.renderers.default = 'notebook'
poff.init_notebook_mode(connected=True)
# Modelling and Forecasting
# ==============================================================================
import skforecast
from sklearn.linear_model import Ridge
from sklearn.feature_selection import RFECV
from sklearn.pipeline import make_pipeline
from feature_engine.datetime import DatetimeFeatures
from feature_engine.creation import CyclicalFeatures
from feature_engine.timeseries.forecasting import LagFeatures, WindowFeatures
from skforecast.recursive import ForecasterRecursive
from skforecast.model_selection import TimeSeriesFold, bayesian_search_forecaster, backtesting_forecaster
from skforecast.feature_selection import select_features
from skforecast.preprocessing import RollingFeatures
from skforecast.metrics import calculate_coverage
# Warnings configuration
# ==============================================================================
import warnings
warnings.filterwarnings('once')
print('Version skforecast:', skforecast.__version__)
Version skforecast: 0.27.0
Data¶
Data from an electricity transformer station was collected between July 2016 and June 2018 (69,680 data points, one every 15 minutes, aggregated to hourly frequency in this document). Each data point consists of 8 features, including the date of the point, the predictive value "Oil Temperature (OT)", and 6 different types of external power load features: High UseFul Load (HUFL), High UseLess Load (HULL), Middle UseFul Load (MUFL), Middle UseLess Load (MULL), Low UseFul Load (LUFL), Low UseLess Load(LULL).
Zhou, Haoyi & Zhang, Shanghang & Peng, Jieqi & Zhang, Shuai & Li, Jianxin & Xiong, Hui & Zhang, Wancai. (2020). Informer: Beyond Efficient Transformer for Long Sequence Time-Series Forecasting. 10.48550/arXiv.2012.07436. https://github.com/zhouhaoyi/ETDataset
# Load data
# ==============================================================================
data = fetch_dataset(name="ett_m2")
data = data.resample(rule="1h", closed="left", label="right").mean()
data
╭──────────────────────────────────────────────────── ett_m2 ─────────────────────────────────────────────────────╮ │ Description: │ │ Data from an electricity transformer station was collected from 2016-07-01 to 2018-06-26 (69,680 data points │ │ every 15 minutes). Each data point consists of 8 features, including the date of the point, the predictive │ │ value "Oil Temperature (OT)", and 6 different types of external power load features: High UseFul Load (HUFL), │ │ High UseLess Load (HULL), Middle UseFul Load (MUFL), Middle UseLess Load (MULL), Low UseFul Load (LUFL), Low │ │ UseLess Load (LULL). │ │ │ │ Source: │ │ Zhou, Haoyi & Zhang, Shanghang & Peng, Jieqi & Zhang, Shuai & Li, Jianxin & Xiong, Hui & Zhang, Wancai. (2020). │ │ Informer: Beyond Efficient Transformer for Long Sequence Time-Series Forecasting. │ │ [10.48550/arXiv.2012.07436](https://arxiv.org/abs/2012.07436). https://github.com/zhouhaoyi/ETDataset │ │ │ │ URL: │ │ https://raw.githubusercontent.com/skforecast/skforecast-datasets/main/data/ETTm2.csv │ │ │ │ Shape: 69680 rows x 7 columns │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
| HUFL | HULL | MUFL | MULL | LUFL | LULL | OT | |
|---|---|---|---|---|---|---|---|
| date | |||||||
| 2016-07-01 01:00:00 | 38.784501 | 10.88975 | 34.753500 | 8.55100 | 4.12575 | 1.26050 | 37.838250 |
| 2016-07-01 02:00:00 | 36.041249 | 9.44475 | 32.696001 | 7.13700 | 3.59025 | 0.62900 | 36.849250 |
| 2016-07-01 03:00:00 | 38.240000 | 11.41350 | 35.343501 | 9.10725 | 3.06000 | 0.31175 | 35.915750 |
| 2016-07-01 04:00:00 | 37.800250 | 11.45525 | 34.881000 | 9.28850 | 3.04400 | 0.60750 | 32.839375 |
| 2016-07-01 05:00:00 | 36.501750 | 10.49200 | 33.708250 | 8.65150 | 2.64400 | 0.00000 | 31.466125 |
| ... | ... | ... | ... | ... | ... | ... | ... |
| 2018-06-26 16:00:00 | 39.517250 | 11.45500 | 50.616000 | 12.08950 | -10.64275 | -1.28475 | 47.744249 |
| 2018-06-26 17:00:00 | 39.140499 | 11.32975 | 49.865250 | 11.84825 | -10.33100 | -1.35400 | 48.183498 |
| 2018-06-26 18:00:00 | 42.428501 | 12.85850 | 53.591250 | 13.33575 | -10.95475 | -1.41800 | 47.853999 |
| 2018-06-26 19:00:00 | 43.036000 | 12.87925 | 54.241250 | 13.33575 | -10.91200 | -1.41800 | 46.535750 |
| 2018-06-26 20:00:00 | 40.543500 | 11.16175 | 51.721500 | 11.76800 | -11.22400 | -1.41800 | 45.601625 |
17420 rows × 7 columns
# Plot target time series
# ==============================================================================
set_dark_theme()
plt.rcParams['lines.linewidth'] = 0.5
fig, ax = plt.subplots(figsize=(8, 3))
ax.plot(data['OT'])
ax.set_title('OT');
# Plot Exogenous features
# ==============================================================================
cols_to_plot = ['HUFL', 'HULL', 'MUFL', 'MULL', 'LUFL', 'LULL']
colors = plt.rcParams['axes.prop_cycle'].by_key()['color']
colors = colors[1:] + ["violet", "magenta"]
fig, axs = plt.subplots(6, 1, figsize=(8, 8), sharex=True)
for i, col in enumerate(cols_to_plot):
axs[i].plot(data[col], label=col, color=colors[i])
axs[i].legend(loc='lower left', fontsize=8)
axs[i].tick_params(axis='both', labelsize=8)
# Partial autocorrelation values and plots
# ==============================================================================
n_lags = 24 * 7
cols_to_plot = ['OT', 'HUFL', 'HULL', 'MUFL', 'MULL', 'LUFL', 'LULL']
pacf_df = []
fig, axs = plt.subplots(4, 2, figsize=(8, 6))
axs = axs.ravel()
for i, col in enumerate(cols_to_plot):
pacf_values = pacf(data[col], nlags=n_lags)
pacf_values = pd.DataFrame({
'lag': range(1, len(pacf_values)),
'value': pacf_values[1:],
'variable': col
})
pacf_df.append(pacf_values)
plot_pacf(data[col], lags=n_lags, ax=axs[i], method='burg')
axs[i].set_title(col, fontsize=10)
axs[i].set_ylim(-0.5, 1.1)
plt.tight_layout()
# Top n lags with highest partial autocorrelation per variable
# ==============================================================================
n = 10
top_lags = set()
for pacf_values in pacf_df:
variable = pacf_values['variable'].iloc[0]
lags = pacf_values.nlargest(n, 'value')['lag'].tolist()
top_lags.update(lags)
print(f"{variable}: {lags}")
top_lags = list(top_lags)
print(f"\nAll lags: {top_lags}")
OT: [1, 15, 16, 14, 3, 6, 17, 13, 4, 5] HUFL: [1, 11, 15, 19, 14, 17, 18, 12, 23, 9] HULL: [1, 3, 2, 15, 12, 23, 11, 14, 24, 4] MUFL: [1, 15, 11, 14, 17, 19, 12, 18, 9, 16] MULL: [1, 3, 2, 24, 11, 15, 23, 12, 17, 4] LUFL: [1, 2, 20, 18, 9, 17, 10, 16, 19, 21] LULL: [1, 3, 14, 4, 18, 11, 9, 42, 19, 12] All lags: [1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24, 42]
The target series and the external series exhibit similar dynamics with several lagged correlations that can be used as predictors.
Feature engineering¶
Additional exogenous features are created based on:
Lagged values of the exogenous series.
Rolling statistics of the exogenous series.
Calendar features.
⚠ Warning
Throughout this document, it is assumed that the additional series are known into the future. This means that their values are known at the time of prediction, which allows them to be used as exogenous variables in the forecasting model.
# Calendar features
# ==============================================================================
features_to_extract = ['year', 'month', 'week', 'day_of_week', 'hour']
calendar_transformer = DatetimeFeatures(
variables = 'index',
features_to_extract = features_to_extract,
drop_original = False,
)
# Lags of exogenous variables
# ==============================================================================
lag_transformer = LagFeatures(
variables = ["HUFL", "MUFL", "MULL", "HULL", "LUFL", "LULL"],
periods = top_lags,
)
# Rolling features for exogenous variables
# ==============================================================================
wf_transformer = WindowFeatures(
variables = ["HUFL", "MUFL", "MULL", "HULL", "LUFL", "LULL"],
window = ["1D", "7D"],
functions = ["mean", "max", "min"],
freq = "1h",
)
# Cyclical encoding of calendar features
# ==============================================================================
features_to_encode = ['year', 'month', 'week', 'day_of_week', 'hour']
max_values = {
"month": 12,
"week": 52,
"day_of_week": 7,
"hour": 24,
}
cyclical_encoder = CyclicalFeatures(
variables = features_to_encode,
max_values = max_values,
drop_original = True
)
exog_transformer = make_pipeline(
calendar_transformer,
lag_transformer,
wf_transformer,
cyclical_encoder
)
display(exog_transformer)
data = exog_transformer.fit_transform(data)
# Remove rows with NaNs created by lag features
data = data.dropna()
exog_features = data.columns.difference(['OT']).tolist()
display(data.head(3))
Pipeline(steps=[('datetimefeatures',
DatetimeFeatures(drop_original=False,
features_to_extract=['year', 'month', 'week',
'day_of_week', 'hour'],
variables='index')),
('lagfeatures',
LagFeatures(periods=[1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14,
15, 16, 17, 18, 19, 20, 21, 23, 24, 42],
variables=['HUFL', 'MUFL', 'MULL', 'HULL', 'LUFL',
'LULL'])),
('windowfeatures',
WindowFeatures(freq='1h', functions=['mean', 'max', 'min'],
variables=['HUFL', 'MUFL', 'MULL', 'HULL',
'LUFL', 'LULL'],
window=['1D', '7D'])),
('cyclicalfeatures',
CyclicalFeatures(drop_original=True,
max_values={'day_of_week': 7, 'hour': 24,
'month': 12, 'week': 52},
variables=['year', 'month', 'week',
'day_of_week', 'hour']))])In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
Parameters
| variables | 'index' | |
| features_to_extract | ['year', 'month', ...] | |
| drop_original | False | |
| missing_values | 'raise' | |
| dayfirst | False | |
| yearfirst | False | |
| utc | None | |
| format | None |
Parameters
| variables | ['HUFL', 'MUFL', ...] | |
| periods | [1, 2, ...] | |
| freq | None | |
| fill_value | None | |
| sort_index | True | |
| missing_values | 'raise' | |
| drop_original | False | |
| drop_na | False |
Parameters
| variables | ['HUFL', 'MUFL', ...] | |
| window | ['1D', '7D'] | |
| functions | ['mean', 'max', ...] | |
| freq | '1h' | |
| min_periods | None | |
| periods | 1 | |
| sort_index | True | |
| missing_values | 'raise' | |
| drop_original | False | |
| drop_na | False |
Parameters
| variables | ['year', 'month', ...] | |
| max_values | {'day_of_week': 7, 'hour': 24, 'month': 12, 'week': 52} | |
| drop_original | True |
| HUFL | HULL | MUFL | MULL | LUFL | LULL | OT | year | HUFL_lag_1 | MUFL_lag_1 | ... | LULL_window_7D_max | LULL_window_7D_min | month_sin | month_cos | week_sin | week_cos | day_of_week_sin | day_of_week_cos | hour_sin | hour_cos | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| date | |||||||||||||||||||||
| 2016-07-02 19:00:00 | 33.6330 | 9.08900 | 29.874751 | 6.95600 | 3.753 | 0.61025 | 28.719500 | 2016 | 32.5440 | 29.017001 | ... | 1.2605 | 0.0 | -0.5 | -0.866025 | 1.224647e-16 | -1.0 | -0.974928 | -0.222521 | -0.965926 | 0.258819 |
| 2016-07-02 20:00:00 | 31.7065 | 7.72750 | 27.884500 | 5.63600 | 3.753 | 0.67425 | 29.103875 | 2016 | 33.6330 | 29.874751 | ... | 1.2605 | 0.0 | -0.5 | -0.866025 | 1.224647e-16 | -1.0 | -0.974928 | -0.222521 | -0.866025 | 0.500000 |
| 2016-07-02 21:00:00 | 31.8110 | 7.81125 | 27.362000 | 5.18025 | 4.435 | 1.42075 | 29.598500 | 2016 | 31.7065 | 27.884500 | ... | 1.2605 | 0.0 | -0.5 | -0.866025 | 1.224647e-16 | -1.0 | -0.974928 | -0.222521 | -0.707107 | 0.707107 |
3 rows × 184 columns
Forecasting intervals using bootstrapped residuals¶
Data partition¶
# Split train-validation-test
# ==============================================================================
end_train = '2017-10-01 23:59:00'
end_validation = '2018-04-03 23:59:00'
data_train = data.loc[: end_train, :]
data_val = data.loc[end_train:end_validation, :]
data_test = data.loc[end_validation:, :]
print(f"Dates train : {data_train.index.min()} --- {data_train.index.max()} (n={len(data_train)})")
print(f"Dates validation : {data_val.index.min()} --- {data_val.index.max()} (n={len(data_val)})")
print(f"Dates test : {data_test.index.min()} --- {data_test.index.max()} (n={len(data_test)})")
Dates train : 2016-07-02 19:00:00 --- 2017-10-01 23:00:00 (n=10949) Dates validation : 2017-10-02 00:00:00 --- 2018-04-03 23:00:00 (n=4416) Dates test : 2018-04-04 00:00:00 --- 2018-06-26 20:00:00 (n=2013)
# Plot partitions
# ==============================================================================
fig, ax = plt.subplots(figsize=(8, 3))
ax.plot(data_train['OT'], label='Train')
ax.plot(data_val['OT'], label='Validation')
ax.plot(data_test['OT'], label='Test')
ax.set_title('OT')
ax.legend();
# Plot partitions after differencing
# ==============================================================================
fig, ax = plt.subplots(figsize=(8, 3))
ax.plot(data_train['OT'].diff(1), label='Train')
ax.plot(data_val['OT'].diff(1), label='Validation')
ax.plot(data_test['OT'].diff(1), label='Test')
ax.set_title('Differenced OT')
ax.legend();
Forecaster¶
A ForecasterRecursive is created using a Ridge regression model.
# Create forecaster
# ==============================================================================
window_features = RollingFeatures(stats=['mean', 'min', 'max'], window_sizes=24)
forecaster = ForecasterRecursive(
estimator = Ridge(random_state=15926),
lags = top_lags,
differentiation = 1,
window_features = window_features,
binner_kwargs = {'n_bins': 10}
)
Feature selection¶
# Feature selection (autoregressive and exog) with scikit-learn RFECV
# ==============================================================================
estimator = Ridge(random_state=15926)
selector = RFECV(
estimator=estimator, step=1, cv=3, min_features_to_select=25, n_jobs=-1
)
selected_lags, selected_window_features, selected_exog, _ = select_features(
forecaster = forecaster,
selector = selector,
y = data.loc[:end_train, 'OT'],
exog = data.loc[:end_train, exog_features],
select_only = None,
force_inclusion = None,
subsample = 0.5,
random_state = 123,
verbose = True,
)
Feature selection (RFECV)
-------------------------
Total number of records available: 10906
Total number of records used for feature selection: 5453
Number of features available: 208
Lags (n=22)
Window features (n=3)
Exog (n=183)
Calendar (n=0)
Number of features selected: 67
Lags (n=16) : [1, 4, 5, 9, 10, 11, 12, 13, 17, 18, 19, 20, 21, 23, 24, 42]
Window features (n=3) : ['roll_mean_24', 'roll_min_24', 'roll_max_24']
Exog (n=48) : ['HUFL', 'HUFL_lag_1', 'HUFL_lag_2', 'HUFL_lag_3', 'HULL', 'HULL_lag_1', 'LUFL', 'LUFL_lag_1', 'LUFL_lag_10', 'LUFL_lag_13', 'LUFL_lag_14', 'LUFL_lag_15', 'LUFL_lag_17', 'LUFL_lag_2', 'LUFL_lag_23', 'LUFL_lag_3', 'LUFL_lag_6', 'LUFL_window_1D_max', 'LULL', 'LULL_lag_1', 'LULL_lag_12', 'LULL_lag_14', 'LULL_lag_15', 'LULL_lag_16', 'LULL_lag_23', 'LULL_lag_4', 'LULL_window_1D_mean', 'MUFL', 'MUFL_lag_1', 'MUFL_lag_17', 'MUFL_lag_18', 'MUFL_lag_2', 'MUFL_lag_3', 'MUFL_lag_4', 'MUFL_lag_5', 'MULL', 'MULL_lag_1', 'MULL_lag_11', 'MULL_lag_18', 'MULL_lag_2', 'MULL_lag_20', 'MULL_lag_5', 'MULL_window_1D_mean', 'hour_cos', 'hour_sin', 'month_cos', 'week_cos', 'week_sin']
Calendar (n=0) : []
# Set the selected lags
# ==============================================================================
forecaster.set_lags(selected_lags)
Hyperparameter search¶
# Hyperparameters search
# ==============================================================================
cv = TimeSeriesFold(
initial_train_size = len(data.loc[:end_train, :]),
steps = 24, # all hours of the next day
)
def search_space(trial):
search_space = {
'alpha': trial.suggest_float('alpha', 1e-7, 1e+3, log=True)
}
return search_space
results_search, frozen_trial = bayesian_search_forecaster(
forecaster = forecaster,
y = data.loc[:end_validation, 'OT'],
exog = data.loc[:end_validation, selected_exog],
search_space = search_space,
cv = cv,
metric = 'mean_absolute_error',
n_trials = 20
)
best_params = results_search['params'].iloc[0]
best_lags = results_search['lags'].iloc[0]
results_search.head()
| trial_number | lags | params | mean_absolute_error | n_evaluated | alpha | |
|---|---|---|---|---|---|---|
| 0 | 11 | [1, 4, 5, 9, 10, 11, 12, 13, 17, 18, 19, 20, 2... | {'alpha': 10.459233205986758} | 2.357660 | 4416 | 10.459233 |
| 1 | 13 | [1, 4, 5, 9, 10, 11, 12, 13, 17, 18, 19, 20, 2... | {'alpha': 10.00349797240784} | 2.357680 | 4416 | 10.003498 |
| 2 | 14 | [1, 4, 5, 9, 10, 11, 12, 13, 17, 18, 19, 20, 2... | {'alpha': 9.156384566458613} | 2.357720 | 4416 | 9.156385 |
| 3 | 18 | [1, 4, 5, 9, 10, 11, 12, 13, 17, 18, 19, 20, 2... | {'alpha': 7.779779661896649} | 2.357805 | 4416 | 7.779780 |
| 4 | 15 | [1, 4, 5, 9, 10, 11, 12, 13, 17, 18, 19, 20, 2... | {'alpha': 24.56282872477627} | 2.358271 | 4416 | 24.562829 |
Forecasting with prediction intervals¶
After selecting the optimal model, including its features and hyperparameters, a backtesting process is performed using the validation set. This process generates out-of-sample residuals, which are then used to estimate prediction intervals for the test set.
# Backtesting on validation data to obtain out-sample residuals
# ==============================================================================
cv = TimeSeriesFold(
initial_train_size = len(data.loc[:end_train, :]),
steps = 24, # all hours of the next day
)
metric_val, predictions_val = backtesting_forecaster(
forecaster = forecaster,
y = data.loc[:end_validation, 'OT'],
exog = data.loc[:end_validation, selected_exog],
cv = cv,
metric = 'mean_absolute_error'
)
display(metric_val)
fig, ax = plt.subplots(figsize=(8, 3))
data.loc[end_train:end_validation, 'OT'].plot(ax=ax, label='Real value')
predictions_val['pred'].plot(ax=ax, label='prediction')
ax.set_title("Backtesting on validation data")
ax.legend();
| mean_absolute_error | n_evaluated | |
|---|---|---|
| 0 | 2.35766 | 4416 |
# Out-sample residuals distribution
# ==============================================================================
residuals = data.loc[predictions_val.index, 'OT'] - predictions_val['pred']
print(pd.Series(np.where(residuals < 0, 'negative', 'positive')).value_counts())
plt.rcParams.update({'font.size': 8})
_ = plot_residuals(residuals=residuals, figsize=(7, 4))
negative 2351 positive 2065 Name: count, dtype: int64
# Store out-sample residuals in the forecaster
# ==============================================================================
forecaster.fit(y=data.loc[:end_train, 'OT'], exog=data.loc[:end_train, selected_exog])
forecaster.set_out_sample_residuals(
y_true = data.loc[predictions_val.index, 'OT'],
y_pred = predictions_val['pred']
)
✎ Note
When storing new residuals in the forecaster, the residuals are binned according to the magnitude of the predictions they correspond to. Later, in the bootstrapping process, the residuals are sampled from the corresponding bin, ensuring that the distribution of the residuals is consistent with the predictions. This usually allows for more accurate prediction intervals, as the residuals are more closely aligned with the predictions they correspond to. Since the forecaster uses differentiation=1, the residuals and the bins refer to the differenced series (the predicted hourly change), not to the original values. For more information about how residuals are used in interval estimation visit Probabilistic forecasting: prediction intervals and prediction distribution.
# Intervals of the residual bins (conditioned on predicted values) and number of residuals stored
# ==============================================================================
for k, v in forecaster.binner_intervals_.items():
print(f"{k}: {v} n={len(forecaster.out_sample_residuals_by_bin_[k])}")
0: (-3.9345706486063547, -1.1115428690207936) n=400 1: (-1.1115428690207936, -0.7180268230214213) n=519 2: (-0.7180268230214213, -0.514457492771264) n=471 3: (-0.514457492771264, -0.34519907574758674) n=470 4: (-0.34519907574758674, -0.19152889266123893) n=378 5: (-0.19152889266123893, -0.0052982402956782) n=343 6: (-0.0052982402956782, 0.30249750665225156) n=346 7: (0.30249750665225156, 0.7576635428237091) n=468 8: (0.7576635428237091, 1.4732411804155194) n=541 9: (1.4732411804155194, 5.829537425307816) n=479
# Distribution of the residual by bin
# ==============================================================================
out_sample_residuals_by_bin_df = pd.DataFrame(
dict(
[(k, pd.Series(v))
for k, v in forecaster.out_sample_residuals_by_bin_.items()]
)
)
fig, ax = plt.subplots(figsize=(6, 3))
out_sample_residuals_by_bin_df.boxplot(
ax=ax,
flierprops=dict(marker='o', markerfacecolor='white', markeredgecolor='black', markersize=4)
)
ax.set_title("Distribution of residuals by bin", fontsize=12)
ax.set_xlabel("Bin", fontsize=10)
ax.set_ylabel("Residuals", fontsize=10)
plt.show();
# Backtesting with prediction intervals in test data using out-sample residuals
# ==============================================================================
cv = TimeSeriesFold(
initial_train_size = len(data.loc[:end_validation, :]),
steps = 24, # all hours of the next day
)
metric, predictions = backtesting_forecaster(
forecaster = forecaster,
y = data['OT'],
exog = data[selected_exog],
cv = cv,
metric = 'mean_absolute_error',
interval = [0.1, 0.9], # 80% prediction interval
n_boot = 150,
interval_method = 'bootstrapping',
use_in_sample_residuals = False, # Use out-sample residuals
use_binned_residuals = True, # Intervals conditioned to the predicted values
)
predictions
| fold | step | y_true | pred | lower_bound | upper_bound | |
|---|---|---|---|---|---|---|
| 2018-04-04 00:00:00 | 0 | 1 | 32.674500 | 32.830865 | 32.204703 | 33.499142 |
| 2018-04-04 01:00:00 | 0 | 2 | 31.575750 | 32.053753 | 31.064581 | 33.278773 |
| 2018-04-04 02:00:00 | 0 | 3 | 29.763125 | 31.287415 | 29.785852 | 32.880086 |
| 2018-04-04 03:00:00 | 0 | 4 | 27.895500 | 30.439909 | 28.301974 | 32.778209 |
| 2018-04-04 04:00:00 | 0 | 5 | 26.302375 | 29.692928 | 27.567249 | 32.397872 |
| ... | ... | ... | ... | ... | ... | ... |
| 2018-06-26 16:00:00 | 83 | 17 | 47.744249 | 51.092961 | 41.936847 | 58.257612 |
| 2018-06-26 17:00:00 | 83 | 18 | 48.183498 | 50.500898 | 40.974397 | 58.021437 |
| 2018-06-26 18:00:00 | 83 | 19 | 47.853999 | 49.571743 | 39.704157 | 57.297756 |
| 2018-06-26 19:00:00 | 83 | 20 | 46.535750 | 48.381211 | 38.891161 | 57.068544 |
| 2018-06-26 20:00:00 | 83 | 21 | 45.601625 | 47.137730 | 38.175465 | 56.146989 |
2013 rows × 6 columns
# Plot intervals
# ==============================================================================
plt.rcParams['lines.linewidth'] = 1
fig, ax = plt.subplots(figsize=(9, 4))
plot_prediction_intervals(
predictions = predictions,
y_true = data_test,
target_variable = "OT",
initial_x_zoom = None,
title = "Real value vs prediction in test data",
xaxis_title = "Date time",
yaxis_title = "OT",
ax = ax
)
fill_between_obj = ax.collections[0]
fill_between_obj.set_facecolor('white')
fill_between_obj.set_alpha(0.3)
# Predicted interval coverage (on test data)
# ==============================================================================
coverage = calculate_coverage(
y_true = data.loc[end_validation:, 'OT'],
lower_bound = predictions["lower_bound"],
upper_bound = predictions["upper_bound"]
)
print(f"Predicted interval coverage: {round(100 * coverage, 2)} %")
# Area of the interval
# ==============================================================================
area = (predictions["upper_bound"] - predictions["lower_bound"]).sum()
print(f"Area of the interval: {round(area, 2)}")
display(metric)
Predicted interval coverage: 81.17 % Area of the interval: 20577.13
| mean_absolute_error | coverage | interval_width | n_evaluated | |
|---|---|---|---|---|
| 0 | 2.837599 | 0.811724 | 10.222123 | 2013 |
# Interactive plot
# ==============================================================================
fig = go.Figure([
go.Scatter(name='Prediction', x=predictions.index, y=predictions['pred'], mode='lines'),
go.Scatter(name='Real value', x=data_test.index, y=data_test['OT'], mode='lines'),
go.Scatter(
name='Upper Bound', x=predictions.index, y=predictions['upper_bound'],
mode='lines', marker=dict(color="#444"), line=dict(width=0), showlegend=False
),
go.Scatter(
name='Lower Bound', x=predictions.index, y=predictions['lower_bound'],
marker=dict(color="#444"), line=dict(width=0), mode='lines',
fillcolor='rgba(68, 68, 68, 0.3)', fill='tonexty', showlegend=False
)
])
fig.update_layout(
title="Real value vs prediction in test data",
xaxis_title= "Date time",
yaxis_title= "OT",
width=800,
height=400,
margin=dict(l=10, r=10, t=35, b=10),
hovermode="x",
legend=dict(orientation="h", yanchor="top", y=1.1, xanchor="left", x=0.001)
)
fig.show()
Estimation of multiple intervals¶
The backtesting_forecaster function not only allows to estimate a single prediction interval, but also to estimate multiple quantiles (percentiles) from which multiple prediction intervals can be constructed. This is useful to evaluate the quality of the prediction intervals for a range of probabilities. Furthermore, it has almost no additional computational cost compared to estimating a single interval.
Next, several percentiles are predicted and, from these, prediction intervals are created for different nominal coverage levels (10%, 20%, 30%, 40%, 50%, 60%, 70%, 80%, 90% and 95%), and their empirical coverage is assessed.
# Prediction intervals for different nominal coverage levels
# ==============================================================================
quantiles = [
0.025, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5, 0.55, 0.6,
0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 0.975
]
intervals = [
[0.025, 0.975], [0.05, 0.95], [0.1, 0.9], [0.15, 0.85], [0.2, 0.8],
[0.25, 0.75], [0.3, 0.7], [0.35, 0.65], [0.4, 0.6], [0.45, 0.55]
]
observed_coverages = []
observed_areas = []
metric, predictions = backtesting_forecaster(
forecaster = forecaster,
y = data['OT'],
exog = data[selected_exog],
cv = cv,
metric = 'mean_absolute_error',
interval = quantiles,
n_boot = 150,
interval_method = 'bootstrapping',
use_in_sample_residuals = False, # Use out-sample residuals
use_binned_residuals = True, # Intervals conditioned to the predicted values
)
predictions.head()
| fold | step | y_true | pred | q_0.025 | q_0.05 | q_0.1 | q_0.15 | q_0.2 | q_0.25 | ... | q_0.55 | q_0.6 | q_0.65 | q_0.7 | q_0.75 | q_0.8 | q_0.85 | q_0.9 | q_0.95 | q_0.975 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2018-04-04 00:00:00 | 0 | 1 | 32.674500 | 32.830865 | 31.747589 | 31.888159 | 32.204703 | 32.428729 | 32.607772 | 32.648201 | ... | 33.020394 | 33.083472 | 33.153693 | 33.229708 | 33.293452 | 33.360058 | 33.432757 | 33.499142 | 33.732756 | 33.871891 |
| 2018-04-04 01:00:00 | 0 | 2 | 31.575750 | 32.053753 | 29.912835 | 30.242552 | 31.064581 | 31.315311 | 31.500531 | 31.659462 | ... | 32.432137 | 32.586368 | 32.729751 | 32.850999 | 32.976130 | 33.101965 | 33.203354 | 33.278773 | 33.584921 | 33.780873 |
| 2018-04-04 02:00:00 | 0 | 3 | 29.763125 | 31.287415 | 28.283764 | 28.958232 | 29.785852 | 30.158655 | 30.508106 | 30.776184 | ... | 32.067054 | 32.122330 | 32.334674 | 32.481446 | 32.561645 | 32.655714 | 32.792461 | 32.880086 | 33.298629 | 33.596965 |
| 2018-04-04 03:00:00 | 0 | 4 | 27.895500 | 30.439909 | 26.925620 | 27.348321 | 28.301974 | 29.216764 | 29.689450 | 30.044454 | ... | 31.407986 | 31.582764 | 31.728682 | 31.918240 | 32.062807 | 32.329272 | 32.558055 | 32.778209 | 33.120262 | 33.404011 |
| 2018-04-04 04:00:00 | 0 | 5 | 26.302375 | 29.692928 | 25.844058 | 26.500916 | 27.567249 | 28.641596 | 28.979421 | 29.421660 | ... | 30.903089 | 31.048588 | 31.269068 | 31.504261 | 31.726675 | 32.023279 | 32.187345 | 32.397872 | 32.742495 | 33.178369 |
5 rows × 25 columns
# Calculate coverage and area for each interval
# ==============================================================================
for interval in intervals:
observed_coverage = calculate_coverage(
y_true = data.loc[end_validation:, 'OT'],
lower_bound = predictions[f"q_{interval[0]}"],
upper_bound = predictions[f"q_{interval[1]}"]
)
observed_area = (predictions[f"q_{interval[1]}"] - predictions[f"q_{interval[0]}"]).sum()
observed_coverages.append(100 * observed_coverage)
observed_areas.append(observed_area)
results = pd.DataFrame({
'Interval': intervals,
'Nominal coverage': [100 * (interval[1] - interval[0]) for interval in intervals],
'Observed coverage': observed_coverages,
'Area': observed_areas
})
results.round(2)
| Interval | Nominal coverage | Observed coverage | Area | |
|---|---|---|---|---|
| 0 | [0.025, 0.975] | 95.0 | 93.19 | 32591.93 |
| 1 | [0.05, 0.95] | 90.0 | 89.42 | 26809.98 |
| 2 | [0.1, 0.9] | 80.0 | 81.17 | 20577.13 |
| 3 | [0.15, 0.85] | 70.0 | 73.62 | 16548.14 |
| 4 | [0.2, 0.8] | 60.0 | 65.97 | 13357.03 |
| 5 | [0.25, 0.75] | 50.0 | 56.28 | 10639.53 |
| 6 | [0.3, 0.7] | 40.0 | 46.30 | 8218.48 |
| 7 | [0.35, 0.65] | 30.0 | 35.37 | 6012.37 |
| 8 | [0.4, 0.6] | 20.0 | 23.00 | 3960.01 |
| 9 | [0.45, 0.55] | 10.0 | 10.78 | 1963.43 |
The empirical coverage is close to the nominal coverage for the widest intervals (for example, 81.2% for the 80% interval and 93.2% for the 95% interval). The intermediate intervals are somewhat conservative: 66.0% for the 60% interval and 46.3% for the 40% interval. Overall, the intervals are reasonably well calibrated, and the model is able to estimate the uncertainty associated with the predictions.
Session information¶
import session_info
session_info.show(html=False)
----- feature_engine 1.9.4 matplotlib 3.11.2 numpy 2.5.4 optuna 5.0.0 pandas 2.3.3 plotly 7.1.0 session_info v1.0.1 skforecast 0.27.0 sklearn 1.9.1 statsmodels 0.15.0 ----- IPython 9.17.1 jupyter_client 8.10.0 jupyter_core 5.9.1 ----- Python 3.13.18 | packaged by conda-forge | (main, Oct 26 2026, 11:30:40) [MSC v.1944 64 bit (AMD64)] Windows-11-10.0.26200-SP0 ----- Session information updated at 2026-11-12 14:06
Citation¶
How to cite this document
If you use this document or any part of it, please acknowledge the source, thank you!
Probabilistic forecasting: prediction intervals for multi-step time series forecasting by Joaquín Amat Rodrigo and Javier Escobar Ortiz, available under Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0 DEED) at https://cienciadedatos.net/documentos/py60-probabilistic-forecasting-prediction-intervals-multi-step-forecasting.html
How to cite skforecast
If you use skforecast for a publication, we would appreciate if you cite the published software.
Zenodo:
Amat Rodrigo, Joaquin, & Escobar Ortiz, Javier. (2026). skforecast (v0.27.0). Zenodo. https://doi.org/10.5281/zenodo.8382787
APA:
Amat Rodrigo, J., & Escobar Ortiz, J. (2026). skforecast (Version 0.27.0) [Computer software]. https://doi.org/10.5281/zenodo.8382787
BibTeX:
@software{skforecast, author = {Amat Rodrigo, Joaquin and Escobar Ortiz, Javier}, title = {skforecast}, version = {0.27.0}, month = {11}, year = {2026}, license = {BSD-3-Clause}, url = {https://skforecast.org/}, doi = {10.5281/zenodo.8382787} }
Did you like the article? Your support is important
Your contribution will help me to continue generating free educational content. Many thanks! 😊
This work by Joaquín Amat Rodrigo and Javier Escobar Ortiz is licensed under a Attribution-NonCommercial-ShareAlike 4.0 International.
Allowed:
-
Share: copy and redistribute the material in any medium or format.
-
Adapt: remix, transform, and build upon the material.
Under the following terms:
-
Attribution: You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use.
-
NonCommercial: You may not use the material for commercial purposes.
-
ShareAlike: If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original.
