Más sobre forecasting en: cienciadedatos.net
- Forecasting series temporales con machine learning
- Modelos ARIMA y SARIMAX
- Forecasting series temporales con gradient boosting: XGBoost, LightGBM y CatBoost
- Global Forecasting: Multi-series forecasting
- Forecasting de la demanda eléctrica con machine learning
- Forecasting con deep learning
- Forecasting de visitas a página web con machine learning
- Forecasting del precio de Bitcoin
- Forecasting probabilístico
- Forecasting de demanda intermitente
- Reducir el impacto del Covid en modelos de forecasting
- Modelar series temporales con tendencia utilizando modelos de árboles
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:
💡 Consejo
Este es el segundo de una serie de documentos sobre 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');
# 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)
# 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()
# 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.
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
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();
# 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();
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 |
# 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
# 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();
# 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 |
# 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! 😊
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.
