Más sobre forecasting en: cienciadedatos.net


No description has been provided for this image

Introducción

La mayoría de los modelos de forecasting tratan de predecir el valor más probable de una serie temporal, una técnica conocida como point-forecasting. Aunque es útil, no proporciona información sobre la incertidumbre asociada a las predicciones.

El forecasting probabilístico, a diferencia del point-forecasting, es una familia de técnicas que permiten predecir la distribución esperada en lugar de un único valor. Este enfoque proporciona más información al permitir estimar intervalos de predicción, es decir, rangos dentro de los cuales es probable que se encuentre el valor real. Más formalmente, un intervalo de predicción define el intervalo dentro del cual se espera encontrar el valor verdadero de la variable de respuesta con una probabilidad determinada.

La estimación de los intervalos de predicción en el forecasting es un reto, ya que muchos métodos bien establecidos para la regresión y el forecasting single-step (en el que se predice únicamente el siguiente valor de la serie) no son directamente aplicables al forecasting multi-step (en el que se predicen múltiples valores futuros). Además, hay que encontrar un equilibrio entre dos parámetros clave: la cobertura y la amplitud del intervalo. En la práctica, se intenta alcanzar un cierto nivel de cobertura (por ejemplo, el 80 %) manteniendo los intervalos de predicción lo más estrechos posible y el error de predicción del modelo lo más bajo posible.

Este documento se inspira en Carl McBride Ellis (2024). Predicción probabilística I: temperatura. Concurso Kaggle. En la competición, las predicciones se realizan con un paso de antelación (single-step), lo que significa que cada predicción se genera para el siguiente paso temporal basándose en valores anteriores conocidos. En esta adaptación, sin embargo, se utiliza un enfoque multipaso (multi-step), en el que se predicen varios pasos temporales futuros. Este escenario es más complejo pero a menudo es necesario para aplicaciones del mundo real.

La tarea consiste en estimar los intervalos de predicción de la variable objetivo, denominada OT, durante el periodo de test (del 2018-04-04 al 2018-06-26, 84 días). Cada día se predicen las 24 horas siguientes, lo que significa que cada día se pronostican 24 valores. Este proceso se repite diariamente hasta el final del periodo, lo que da como resultado un total de 2013 predicciones (el último día solo incluye 21 horas). Se estiman intervalos de predicción centrales (con la misma probabilidad en cada cola) con niveles de cobertura del 10 %, 20 %, 30 %, 40 %, 50 %, 60 %, 70 %, 80 %, 90 % y 95 %, y se evalúa su cobertura.

Skforecast implementa varios métodos para el forecasting probabilístico:

Librerías

Las librerías utilizadas en este cuaderno son:

# Procesamiento de datos
# ==============================================================================
import numpy as np
import pandas as pd
from skforecast.datasets import fetch_dataset

# Gráficos
# ==============================================================================
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)

# Modelado y 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
# ==============================================================================
import warnings
warnings.filterwarnings('once')
print('Versión skforecast:', skforecast.__version__)
Versión skforecast: 0.27.0

Datos

Los datos de una estación transformadora de electricidad se recopilaron entre julio de 2016 y junio de 2018 (69680 valores, uno cada 15 minutos, agregados a frecuencia horaria en este documento). Para cada fecha se dispone de 7 variables: el valor a predecir, «Temperatura del aceite (OT)», y 6 variables sobre la carga de potencia externa: High UseFul Load (HUFL), High UseLess Load (HULL), Middle UseFul Load (MUFL), Middle UseLess Load (MULL), Low UseFul Load (LUFL) y 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

# Descarga de datos
# ==============================================================================
datos = fetch_dataset(name="ett_m2")
datos = datos.resample(rule="1h", closed="left", label="right").mean()
datos
╭──────────────────────────────────────────────────── 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

# Gráfico de la serie
# ==============================================================================
set_dark_theme()
plt.rcParams['lines.linewidth'] = 0.5
fig, ax = plt.subplots(figsize=(8, 3))
ax.plot(datos['OT'])
ax.set_title('OT');
No description has been provided for this image
# Gráfico de las series exógenas
# ==============================================================================
columnas_grafico = [
    'HUFL', 'HULL', 'MUFL', 'MULL', 'LUFL', 'LULL'
]
colores = plt.rcParams['axes.prop_cycle'].by_key()['color']
colores = colores[1:] + ["violet", "magenta"]    
fig, axs = plt.subplots(6, 1, figsize=(8, 8), sharex=True)
for i, columna in enumerate(columnas_grafico):
    axs[i].plot(datos[columna], label=columna, color=colores[i])
    axs[i].legend(loc='lower left', fontsize=8)
    axs[i].tick_params(axis='both', labelsize=8)
No description has been provided for this image
# Autocorrelación parcial
# ==============================================================================
n_lags = 24 * 7
columnas_grafico = ['OT', 'HUFL', 'HULL', 'MUFL', 'MULL', 'LUFL', 'LULL']
pacf_df = []
fig, axs = plt.subplots(4, 2, figsize=(8, 6))
axs = axs.ravel()
for i, columna in enumerate(columnas_grafico):
    valores_pacf = pacf(datos[columna], nlags=n_lags)
    valores_pacf = pd.DataFrame({
        'lag': range(1, len(valores_pacf)),
        'value': valores_pacf[1:],
        'variable': columna
    })
    pacf_df.append(valores_pacf)
    plot_pacf(datos[columna], lags=n_lags, ax=axs[i], method='burg')
    axs[i].set_title(columna, fontsize=10)
    axs[i].set_ylim(-0.5, 1.1)
plt.tight_layout()
No description has been provided for this image
# Top n lags con mayor autocorrelación parcial por variable
# ==============================================================================
n = 10
lags_principales = set()
for valores_pacf in pacf_df:
    variable = valores_pacf['variable'].iloc[0]
    lags = valores_pacf.nlargest(n, 'value')['lag'].tolist()
    lags_principales.update(lags)
    print(f"{variable}: {lags}")
lags_principales = list(lags_principales)
print(f"\nTodos los lags: {lags_principales}")
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]

Todos los lags: [1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24, 42]

La serie objetivo y las series externas exhiben dinámicas similares con varios lags que pueden utilizarse como predictores.

Feature engineering

Se crean variables exógenas adicionales basadas en:

  • Valores rezagados (lags) de las series exógenas.

  • Estadísticas móviles de las series exógenas.

  • Características del calendario.

⚠ Warning

En este documento se asume que las series adicionales son conocidas en el futuro. Esto significa que sus valores son conocidos en el momento de la predicción, lo que permite utilizarlos como variables exógenas en el modelo de forecasting.

# Variables de calendario
# ==============================================================================
variables_a_extraer = ['year', 'month', 'week', 'day_of_week', 'hour']
calendar_transformer = DatetimeFeatures(
    variables           = 'index',
    features_to_extract = variables_a_extraer,
    drop_original       = False,
)

# Lags de las variables exógenas
# ==============================================================================
lag_transformer = LagFeatures(
    variables = ["HUFL", "MUFL", "MULL", "HULL", "LUFL", "LULL"],
    periods   = lags_principales,
)

# Estadísticas móviles de las variables exógenas
# ==============================================================================
wf_transformer = WindowFeatures(
    variables   = ["HUFL", "MUFL", "MULL", "HULL", "LUFL", "LULL"],
    window      = ["1D", "7D"],
    functions   = ["mean", "max", "min"],
    freq        = "1h",
)

# Codificación de variables cíclicas
# ==============================================================================
variables_a_codificar = ['year', 'month', 'week', 'day_of_week', 'hour']
valores_maximos = {
    "month": 12,
    "week": 52,
    "day_of_week": 7,
    "hour": 24,
}
codificador_ciclico = CyclicalFeatures(
                        variables     = variables_a_codificar,
                        max_values    = valores_maximos,
                        drop_original = True
                   )

exog_transformer = make_pipeline(
                        calendar_transformer,
                        lag_transformer,
                        wf_transformer,
                        codificador_ciclico
                   )
display(exog_transformer)

datos = exog_transformer.fit_transform(datos)
# Eliminación de valores nulos introducidos al crear los lags
datos = datos.dropna()
exog_features = datos.columns.difference(['OT']).tolist()
display(datos.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.
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

Intervalos de predicción con residuos bootstrap

Partición de los datos

# División de los datos en train-validación-test
# ==============================================================================
fin_train = '2017-10-01 23:59:00'
fin_validacion = '2018-04-03 23:59:00'
datos_train = datos.loc[: fin_train, :]
datos_val   = datos.loc[fin_train:fin_validacion, :]
datos_test  = datos.loc[fin_validacion:, :]

print(f"Fechas train      : {datos_train.index.min()} --- {datos_train.index.max()}  (n={len(datos_train)})")
print(f"Fechas validación : {datos_val.index.min()} --- {datos_val.index.max()}  (n={len(datos_val)})")
print(f"Fechas test       : {datos_test.index.min()} --- {datos_test.index.max()}  (n={len(datos_test)})")
Fechas train      : 2016-07-02 19:00:00 --- 2017-10-01 23:00:00  (n=10949)
Fechas validación : 2017-10-02 00:00:00 --- 2018-04-03 23:00:00  (n=4416)
Fechas test       : 2018-04-04 00:00:00 --- 2018-06-26 20:00:00  (n=2013)
# Gráfico de las particiones
# ==============================================================================
fig, ax = plt.subplots(figsize=(8, 3))
ax.plot(datos_train['OT'], label='Train')
ax.plot(datos_val['OT'], label='Validación')
ax.plot(datos_test['OT'], label='Test')
ax.set_title('OT')
ax.legend();
No description has been provided for this image
# Gráfico de las particiones tras aplicar diferenciación
# ==============================================================================
fig, ax = plt.subplots(figsize=(8, 3))
ax.plot(datos_train['OT'].diff(1), label='Train')
ax.plot(datos_val['OT'].diff(1), label='Validación')
ax.plot(datos_test['OT'].diff(1), label='Test')
ax.set_title('OT diferenciada')
ax.legend();
No description has been provided for this image

Forecaster

Se crea un objeto ForecasterRecursive utilizando un modelo de regresión lineal con penalización Ridge.

# Crear forecaster
# ==============================================================================
window_features = RollingFeatures(stats=['mean', 'min', 'max'], window_sizes=24)
forecaster = ForecasterRecursive(
                 estimator       = Ridge(random_state=15926),
                 lags            = lags_principales,
                 differentiation = 1,
                 window_features = window_features,
                 binner_kwargs   = {'n_bins': 10}
             )

Selección de predictores

# Selección de predictores (autorregresivos y exógenas) con scikit-learn RFECV
# ==============================================================================
estimator = Ridge(random_state=15926)

selector = RFECV(
    estimator=estimator, step=1, cv=3, min_features_to_select=25, n_jobs=-1
)

lags_seleccionados, wf_seleccionadas, exog_seleccionadas, _ = select_features(
    forecaster      = forecaster,
    selector        = selector,
    y               = datos.loc[:fin_train, 'OT'],
    exog            = datos.loc[:fin_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) : []
# Establecer los lags seleccionados
# ==============================================================================
forecaster.set_lags(lags_seleccionados)

Búsqueda de hiperparámetros

# Búsqueda de hiperparámetros
# ==============================================================================
cv = TimeSeriesFold(
        initial_train_size = len(datos.loc[:fin_train, :]),
        steps              = 24,  # todas las horas del día siguiente
     )

def search_space(trial):
    search_space  = {
        'alpha': trial.suggest_float('alpha', 1e-7, 1e+3, log=True)
    }
    return search_space

resultados_busqueda, frozen_trial = bayesian_search_forecaster(
                                   forecaster   = forecaster,
                                   y            = datos.loc[:fin_validacion, 'OT'],
                                   exog         = datos.loc[:fin_validacion, exog_seleccionadas],
                                   search_space = search_space,
                                   cv           = cv,
                                   metric       = 'mean_absolute_error',
                                   n_trials     = 20
                               )
best_params = resultados_busqueda['params'].iloc[0]
best_lags = resultados_busqueda['lags'].iloc[0]
resultados_busqueda.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

Predicción de los intervalos

Tras seleccionar el modelo óptimo, incluidos sus predictores e hiperparámetros, se realiza un proceso de backtesting utilizando el conjunto de validación. Este proceso genera residuos fuera de la muestra (out-of-sample), que luego se utilizan para estimar los intervalos de predicción para el conjunto de test.

# Backtesting con datos de validación para obtener residuos out-sample
# ==============================================================================
cv = TimeSeriesFold(
        initial_train_size = len(datos.loc[:fin_train, :]),
        steps              = 24,  # todas las horas del día siguiente
     )

metrica_val, predicciones_val = backtesting_forecaster(
                                  forecaster = forecaster,
                                  y          = datos.loc[:fin_validacion, 'OT'],
                                  exog       = datos.loc[:fin_validacion, exog_seleccionadas],
                                  cv         = cv,
                                  metric     = 'mean_absolute_error'
                              )
display(metrica_val)
fig, ax = plt.subplots(figsize=(8, 3))
datos.loc[fin_train:fin_validacion, 'OT'].plot(ax=ax, label='Valor real')
predicciones_val['pred'].plot(ax=ax, label='Predicción')
ax.set_title("Backtesting en los datos de validación")
ax.legend();
mean_absolute_error n_evaluated
0 2.35766 4416
No description has been provided for this image
# Distribución de los residuos out-sample
# ==============================================================================
residuos = datos.loc[predicciones_val.index, 'OT'] - predicciones_val['pred']
print(pd.Series(np.where(residuos < 0, 'negativo', 'positivo')).value_counts())
plt.rcParams.update({'font.size': 8})
_ = plot_residuals(residuals=residuos, figsize=(7, 4))
negativo    2351
positivo    2065
Name: count, dtype: int64
No description has been provided for this image
# Almacenar residuos out-sample en el forecaster
# ==============================================================================
forecaster.fit(y=datos.loc[:fin_train, 'OT'], exog=datos.loc[:fin_train, exog_seleccionadas])
forecaster.set_out_sample_residuals(
    y_true = datos.loc[predicciones_val.index, 'OT'], 
    y_pred = predicciones_val['pred']
)

✎ Nota

Cuando se almacenan nuevos residuos en el forecaster, los residuos se agrupan según la magnitud de las predicciones a las que corresponden. Más tarde, en el proceso de bootstrapping, los residuos se muestrean de la partición correspondiente, asegurando que la distribución de los residuos sea consistente con las predicciones. Esto suele permitir obtener intervalos de predicción más precisos, ya que los residuos están más alineados con las predicciones a las que corresponden. Como el forecaster utiliza differentiation=1, los residuos y los bins se refieren a la serie diferenciada (el cambio horario predicho), no a los valores originales. Para obtener más información sobre cómo se utilizan los residuos en la estimación de intervalos, visite Probabilistic forecasting: prediction intervals and prediction distribution.

# Intervalos de los bins de residuos (condicionados a los valores predichos) y número de residuos almacenados
# ==============================================================================
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
# Distribución de los residuos out-sample por bin
# ==============================================================================
residuos_out_sample_por_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))
residuos_out_sample_por_bin_df.boxplot(
    ax=ax,
    flierprops=dict(marker='o', markerfacecolor='white', markeredgecolor='black', markersize=4)
)
ax.set_title("Distribución de los residuos por bin", fontsize=12)
ax.set_xlabel("Bin", fontsize=10)
ax.set_ylabel("Residuos", fontsize=10)
plt.show();
No description has been provided for this image
# Backtesting con intervalos de predicción en datos de test usando residuos out-sample
# ==============================================================================
cv = TimeSeriesFold(
        initial_train_size = len(datos.loc[:fin_validacion, :]),
        steps              = 24,  # todas las horas del día siguiente
     )

metrica, predicciones = backtesting_forecaster(
                          forecaster              = forecaster,
                          y                       = datos['OT'],
                          exog                    = datos[exog_seleccionadas],
                          cv                      = cv,
                          metric                  = 'mean_absolute_error',
                          interval                = [0.1, 0.9],  # 80% intervalo de predicción
                          n_boot                  = 150,
                          interval_method         = 'bootstrapping',
                          use_in_sample_residuals = False,  # Usar residuos out-sample 
                          use_binned_residuals    = True,   # Intervalos condicionados a los valores predichos
                      )
predicciones
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

# Gráfico de los intervalos de predicción
# ==============================================================================
plt.rcParams['lines.linewidth'] = 1
fig, ax = plt.subplots(figsize=(9, 4))
plot_prediction_intervals(
    predictions     = predicciones,
    y_true          = datos_test,
    target_variable = "OT",
    initial_x_zoom  = None,
    title           = "Valor real vs predicción en los datos de test",
    xaxis_title     = "Fecha",
    yaxis_title     = "OT",
    ax              = ax
)
fill_between_obj = ax.collections[0]
fill_between_obj.set_facecolor('white')
fill_between_obj.set_alpha(0.3)

# Cobertura del intervalo predicho
# ==============================================================================
cobertura = calculate_coverage(
    y_true      = datos.loc[fin_validacion:, 'OT'],
    lower_bound = predicciones["lower_bound"], 
    upper_bound = predicciones["upper_bound"]
)
print(f"Cobertura del intervalo predicho: {round(100 * cobertura, 2)} %")

# Área del intervalo
# ==============================================================================
area = (predicciones["upper_bound"] - predicciones["lower_bound"]).sum()
print(f"Área del intervalo: {round(area, 2)}")

display(metrica)
Cobertura del intervalo predicho: 81.17 %
Área del intervalo: 20577.13
mean_absolute_error coverage interval_width n_evaluated
0 2.837599 0.811724 10.222123 2013
No description has been provided for this image
# Gráfico interactivo
# ==============================================================================
fig = go.Figure([
        go.Scatter(name='Predicción', x=predicciones.index, y=predicciones['pred'], mode='lines'),
        go.Scatter(name='Valor real', x=datos_test.index, y=datos_test['OT'], mode='lines'),
        go.Scatter(
            name='Límite superior', x=predicciones.index, y=predicciones['upper_bound'],
            mode='lines', marker=dict(color="#444"), line=dict(width=0), showlegend=False
        ),
        go.Scatter(
            name='Límite inferior', x=predicciones.index, y=predicciones['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="Valor real vs predicción en los datos de test",
    xaxis_title= "Fecha",
    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()

Estimación de múltiples intervalos

La función backtesting_forecaster no solo permite estimar un único intervalo de predicción, sino también múltiples cuantiles (percentiles) a partir de los cuales se pueden construir varios intervalos de predicción. Esto es útil para evaluar la calidad de los intervalos de predicción en un rango de probabilidades. Además, apenas tiene coste computacional adicional respecto a estimar un único intervalo.

A continuación, se estiman intervalos de predicción para varios niveles de cobertura nominal: 10 %, 20 %, 30 %, 40 %, 50 %, 60 %, 70 %, 80 %, 90 % y 95 %, y se evalúa su cobertura real.

# Intervalos de predicción para diferentes coberturas nominales
# ==============================================================================
cuantiles = [
    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
]
intervalos = [
    [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]
]
coberturas_observadas = []
areas_observadas = []

metrica, predicciones = backtesting_forecaster(
                          forecaster              = forecaster,
                          y                       = datos['OT'],
                          exog                    = datos[exog_seleccionadas],
                          cv                      = cv,
                          metric                  = 'mean_absolute_error',
                          interval                = cuantiles,
                          n_boot                  = 150,
                          interval_method         = 'bootstrapping',
                          use_in_sample_residuals = False,  # Usar residuos out-sample
                          use_binned_residuals    = True,   # Intervalos condicionados a los valores predichos
                      )
predicciones.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

# Calcular cobertura y área observada para cada intervalo
# ==============================================================================
for intervalo in intervalos:
    cobertura_observada = calculate_coverage(
                            y_true      = datos.loc[fin_validacion:, 'OT'],
                            lower_bound = predicciones[f"q_{intervalo[0]}"], 
                            upper_bound = predicciones[f"q_{intervalo[1]}"]
                        )
    area_observada = (predicciones[f"q_{intervalo[1]}"] - predicciones[f"q_{intervalo[0]}"]).sum()
    coberturas_observadas.append(100 * cobertura_observada)
    areas_observadas.append(area_observada)

resultados = pd.DataFrame({
              'Intervalo': intervalos,
              'Cobertura nominal': [100 * (intervalo[1] - intervalo[0]) for intervalo in intervalos],
              'Cobertura observada': coberturas_observadas,
              'Área': areas_observadas
          })
resultados.round(2)
Intervalo Cobertura nominal Cobertura observada Área
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

La cobertura empírica es cercana a la nominal en los intervalos más amplios (por ejemplo, 81.2% para el intervalo del 80% y 93.2% para el del 95%). Los intervalos intermedios son algo conservadores: 66.0% para el intervalo del 60% y 46.3% para el del 40%. En conjunto, los intervalos están razonablemente bien calibrados y el modelo es capaz de estimar la incertidumbre asociada a las predicciones.

Información de sesión

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 17:39

Instrucciones para citar

¿Cómo citar este documento?

Si utilizas este documento o alguna parte de él, te agradecemos que lo cites. ¡Muchas gracias!

Forecasting probabilístico: intervalos de predicción para forecasting multi-step por Joaquín Amat Rodrigo y Javier Escobar Ortiz, disponible bajo una licencia Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0 DEED) en https://www.cienciadedatos.net/documentos/py60-forecasting-probabilistico-intervalos-prediccion-forecasting-multi-step.html

¿Cómo citar skforecast?

Si utilizas skforecast, te agradeceríamos mucho que lo cites. ¡Muchas gracias!

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} }

¿Te ha gustado el artículo? Tu ayuda es importante

Tu contribución me ayudará a seguir generando contenido divulgativo gratuito. ¡Muchísimas gracias! 😊

No description has been provided for this image

No description has been provided for this image

Become a GitHub Sponsor Become a GitHub Sponsor

Creative Commons Licence

Este documento creado por Joaquín Amat Rodrigo y Javier Escobar Ortiz tiene licencia Attribution-NonCommercial-ShareAlike 4.0 International.

Se permite:

  • Compartir: copiar y redistribuir el material en cualquier medio o formato.

  • Adaptar: remezclar, transformar y crear a partir del material.

Bajo los siguientes términos:

  • Atribución: Debes otorgar el crédito adecuado, proporcionar un enlace a la licencia e indicar si se realizaron cambios. Puedes hacerlo de cualquier manera razonable, pero no de una forma que sugiera que el licenciante te respalda o respalda tu uso.

  • No-Comercial: No puedes utilizar el material para fines comerciales.

  • Compartir-Igual: Si remezclas, transformas o creas a partir del material, debes distribuir tus contribuciones bajo la misma licencia que el original.