Más sobre forecasting en: cienciadedatos.net


Introducción

Los modelos gradient boosting destacan dentro de la comunidad de machine learning debido a su capacidad para lograr excelentes resultados en una amplia variedad de casos de uso, incluyendo tanto la regresión como la clasificación. Aunque su uso en el forecasting de series temporales ha sido limitado, también pueden conseguir resultados muy competitivos en este ámbito. Algunas de las ventajas que presentan los modelos gradient boosting para forecasting son:

  • La facilidad con que pueden incorporarse al modelo variables exógenas, además de las autorregresivas.

  • La capacidad de capturar relaciones no lineales entre variables.

  • Alta escalabilidad, que permite a los modelos manejar grandes volúmenes de datos.

  • Algunas implementaciones permiten la inclusión de variables categóricas sin necesidad de codificación adicional, como la codificación one-hot.

A pesar de estas ventajas, el uso de modelos de machine learning para forecasting presenta varios retos que pueden hacer que el analista sea reticente a su uso, los principales son:

  • Reestructurar los datos para poder utilizarlos como si se tratara de un problema de regresión.

  • Dependiendo de cuántas predicciones futuras se necesiten (horizonte de predicción), puede ser necesario implementar un proceso iterativo en el que cada nueva predicción se base en las anteriores.

  • La validación de los modelos requiere de estrategias específicas como backtesting, walk-forward validation o time series cross-validation. No puede aplicarse la validación cruzada tradicional.

La librería skforecast ofrece soluciones automatizadas a estos retos, facilitando el uso y la validación de modelos de machine learning en problemas de forecasting. Skforecast es compatible con las implementaciones de gradient boosting más avanzadas, incluyendo XGBoost, LightGBM, CatBoost y HistGradientBoostingRegressor. Este documento muestra cómo utilizarlos para construir modelos de forecasting precisos.

Para garantizar una experiencia de aprendizaje fluida, se realiza una exploración inicial de los datos. A continuación, se explica paso a paso el proceso de modelización, empezando por un modelo recursivo que utiliza un regresor LightGBM y pasando por un modelo que incorpora variables exógenas y diversas estrategias de codificación. El documento concluye demostrando el uso de otras implementaciones de modelos de gradient boosting, como XGBoost, CatBoost y el HistGradientBoostingRegressor de scikit-learn.

✏️ Note

Los modelos de machine learning no siempre superan a los modelos propios del aprendizaje estadístico como AR, ARIMA o Exponential Smoothing. Cuál funciona mejor depende en gran medida de las características del caso de uso al que se apliquen. Consultar Modelos ARIMA y SARIMAX con python para aprender más sobre modelos estadísticos.

Otros ejemplos de cómo utilizar modelos de gradient boosting para forecasting puede encontrarse en el documento Forecasting de la demanda energética con machine learning y Modelos de forecasting globales.

Caso de uso

Los sistemas de bicicletas compartidas, también conocidos como sistemas de bicicletas públicas, facilitan la disponibilidad automática de bicicletas para que sean utilizadas temporalmente como medio de transporte. La mayoría de estos sistemas permiten recoger una bicicleta y devolverla en un punto diferente (estaciones o dockers), para que el usuario solo necesite tener la bicicleta en su posesión durante el desplazamiento. Uno de los principales retos en la gestión de estos sistemas es la necesidad de redistribuir las bicicletas para intentar que, en todas las estaciones, haya bicicletas disponibles a la vez que espacios libres para devoluciones.

Con el objetivo de mejorar la planificación y ejecución de la distribución de las bicicletas, se plantea crear un modelo capaz de predecir el número de usuarios para las siguientes 36 horas. De esta forma, a las 12h de cada día, la compañía encargada de gestionar las estaciones de alquiler podrá conocer la demanda prevista el resto del día (12 horas) y el siguiente día (24 horas).

A efectos ilustrativos, el ejemplo actual sólo modela una estación, sin embargo, el modelo puede ampliarse para cubrir múltiples estaciones utilizando global multi-series forecasting, mejorando así la gestión de los sistemas de bicicletas compartidas a mayor escala.

Librerías

Las librerías utilizadas en este documento son:

# Data processing
# ==============================================================================
import numpy as np
import pandas as pd
from astral.sun import sun
from astral import LocationInfo
from skforecast.datasets import fetch_dataset

# Plots
# ==============================================================================
import matplotlib.pyplot as plt
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
from skforecast.plot import plot_residuals, set_dark_theme
import plotly.graph_objects as go
import plotly.io as pio
import plotly.offline as poff
pio.templates.default = 'seaborn'
poff.init_notebook_mode(connected=True)
plt.style.use('seaborn-v0_8-darkgrid')
plt.rcParams.update({'font.size': 8})

# Modelling and Forecasting
# ==============================================================================
import xgboost
import lightgbm
import catboost
import sklearn
import shap
from xgboost import XGBRegressor
from lightgbm import LGBMRegressor
from catboost import CatBoostRegressor
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.preprocessing import OneHotEncoder, PolynomialFeatures
from skforecast.preprocessing import CalendarFeatures
from feature_engine.timeseries.forecasting import WindowFeatures
from sklearn.feature_selection import RFECV
from sklearn.compose import make_column_transformer, make_column_selector
import skforecast
from skforecast.recursive import ForecasterEquivalentDate, ForecasterRecursive
from skforecast.model_selection import (
    TimeSeriesFold,
    OneStepAheadFold,
    bayesian_search_forecaster,
    backtesting_forecaster,
)
from skforecast.preprocessing import RollingFeatures
from skforecast.feature_selection import select_features
from skforecast.stats import calculate_lag_autocorrelation
from skforecast.metrics import calculate_coverage
from skforecast.exceptions import IgnoredArgumentWarning

# Configuración warnings
# ==============================================================================
import warnings
warnings.filterwarnings('once')

color = '\033[1m\033[38;5;208m'
print(f'{color}Versión skforecast: {skforecast.__version__}')
print(f'{color}Versión scikit-learn: {sklearn.__version__}')
print(f'{color}Versión lightgbm: {lightgbm.__version__}')
print(f'{color}Versión xgboost: {xgboost.__version__}')
print(f'{color}Versión catboost: {catboost.__version__}')
print(f'{color}Versión pandas: {pd.__version__}')
print(f'{color}Versión numpy: {np.__version__}')
Versión skforecast: 0.25.0
Versión scikit-learn: 1.7.2
Versión lightgbm: 4.7.0
Versión xgboost: 3.4.0
Versión catboost: 1.2.10
Versión pandas: 2.3.3
Versión numpy: 2.4.6

Datos

Los datos empleados en este documento representan el uso, a nivel horario, del sistema de alquiler de bicicletas en la ciudad de Washington D.C. durante los años 2011 y 2012. Además del número de usuarios por hora, se dispone de información sobre las condiciones meteorológicas y sobre los días festivos. Los datos originales se han obtenido del UCI Machine Learning Repository y han sido procesados previamente (código) aplicando las siguientes modificaciones:

  • Columnas renombradas con nombres más descriptivos.

  • Categorías de la variable meteorológica renombradas. La categoría de heavy rain, se ha combinado con la de rain.

  • Variables de temperatura, humedad y viento desnormalizadas.

  • Creada variable date_time y establecida como índice.

  • Imputación de valores missing mediante forward fill.

# Descarga de datos
# ==============================================================================
datos = fetch_dataset('bike_sharing', raw=True)
╭───────────────────────────────── bike_sharing ──────────────────────────────────╮
│ Description:                                                                    │
│ Hourly usage of the bike share system in the city of Washington D.C. during the │
│ years 2011 and 2012. In addition to the number of users per hour, information   │
│ about weather conditions and holidays is available.                             │
│                                                                                 │
│ Source:                                                                         │
│ Fanaee-T,Hadi. (2013). Bike Sharing Dataset. UCI Machine Learning Repository.   │
│ https://doi.org/10.24432/C5W894.                                                │
│                                                                                 │
│ URL:                                                                            │
│ https://raw.githubusercontent.com/skforecast/skforecast-                        │
│ datasets/main/data/bike_sharing_dataset_clean.csv                               │
│                                                                                 │
│ Shape: 17544 rows x 12 columns                                                  │
╰─────────────────────────────────────────────────────────────────────────────────╯
# Preprocesado de datos (estableciendo índice y frecuencia)
# ==============================================================================
datos = datos[
    ['date_time', 'users', 'holiday', 'weather', 'temp', 'atemp', 'hum', 'windspeed']
].copy()
datos['date_time'] = pd.to_datetime(datos['date_time'], format='%Y-%m-%d %H:%M:%S')
datos = datos.set_index('date_time')
datos = datos.asfreq('h')
datos = datos.sort_index()
datos.head()
users holiday weather temp atemp hum windspeed
date_time
2011-01-01 00:00:00 16.0 0.0 clear 9.84 14.395 81.0 0.0
2011-01-01 01:00:00 40.0 0.0 clear 9.02 13.635 80.0 0.0
2011-01-01 02:00:00 32.0 0.0 clear 9.02 13.635 80.0 0.0
2011-01-01 03:00:00 13.0 0.0 clear 9.84 14.395 75.0 0.0
2011-01-01 04:00:00 1.0 0.0 clear 9.84 14.395 75.0 0.0

Con el objetivo de poder entrenar los modelos, hacer búsqueda de los mejores hiperparámetros y evaluar su capacidad predictiva, se reparten los datos en tres conjuntos: entrenamiento, validación y test.

# Separación de datos en entrenamiento, validación y test
# ==============================================================================
fin_train = '2012-04-30 23:59:00'
fin_validacion = '2012-08-31 23:59:00'
datos_train = datos.loc[: fin_train, :]
datos_val   = datos.loc[fin_train:fin_validacion, :]
datos_test  = datos.loc[fin_validacion:, :]

particiones = {'train': datos_train, 'validación': datos_val, 'test': datos_test}
for nombre, particion in particiones.items():
    print(
        f'Fechas {nombre:<10} : {particion.index.min()} --- '
        f'{particion.index.max()}  (n={len(particion)})'
    )
Fechas train      : 2011-01-01 00:00:00 --- 2012-04-30 23:00:00  (n=11664)
Fechas validación : 2012-05-01 00:00:00 --- 2012-08-31 23:00:00  (n=2952)
Fechas test       : 2012-09-01 00:00:00 --- 2012-12-31 23:00:00  (n=2928)

Exploración gráfica

La exploración gráfica de series temporales es una forma eficaz de identificar tendencias, patrones y variaciones estacionales. Esto, a su vez, ayuda a orientar la selección del modelo de forecasting más adecuado.

Representación de la serie temporal

Serie temporal completa

# Gráfico interactivo de la serie temporal
# ==============================================================================
fig = go.Figure()
particiones = {'Train': datos_train, 'Validation': datos_val, 'Test': datos_test}
for nombre, particion in particiones.items():
    fig.add_trace(
        go.Scatter(
            x=particion.index, y=particion['users'], mode='lines', name=nombre
        )
    )
fig.update_layout(
    title  = 'Número de usuarios',
    xaxis_title='Fecha',
    yaxis_title='Usuarios',
    legend_title='Partición:',
    width=800,
    height=400,
    margin=dict(l=20, r=20, t=35, b=20),
    legend=dict(orientation='h', yanchor='top', y=1, xanchor='left', x=0.001)
)
#fig.update_xaxes(rangeslider_visible=True)
fig.show()
# Gráfico de la serie temporal con zoom
# ==============================================================================
zoom = ('2011-08-01 00:00:00', '2011-08-15 00:00:00')
fig, axs = plt.subplots(2, 1, figsize=(8, 4), gridspec_kw={'height_ratios': [1, 2]})
datos_train['users'].plot(ax=axs[0], label='train', alpha=0.5)
datos_val['users'].plot(ax=axs[0], label='validation', alpha=0.5)
datos_test['users'].plot(ax=axs[0], label='test', alpha=0.5)
axs[0].axvspan(zoom[0], zoom[1], color='blue', alpha=0.7)
axs[0].set_title('Número de usuarios')
axs[0].set_xlabel('')
# Zoom plot
datos.loc[zoom[0] : zoom[1], 'users'].plot(ax=axs[1], color='blue')
axs[1].set_title(f'Zoom: {zoom[0]} to {zoom[1]}', fontsize=10)
plt.tight_layout()
plt.show()

Gráficos de estacionalidad

Los gráficos estacionales son una herramienta útil para identificar patrones y tendencias estacionales en una serie temporal. Se crean agrupando las observaciones por estación (por ejemplo, mes, día de la semana u hora del día) y comparando la distribución de los valores dentro de cada grupo. En este caso, se utilizan diagramas de caja (boxplots) junto con la mediana de cada grupo.

# Estacionalidad anual, semanal y diaria
# ==============================================================================
set_dark_theme()
fig, axs = plt.subplots(2, 2, figsize=(8, 5), sharex=False, sharey=True)
axs = axs.ravel()
flierprops = dict(
    marker='o', markerfacecolor='white', markeredgecolor='black', markersize=4
)

# Distribución de usuarios por mes
datos['month'] = datos.index.month
datos.boxplot(column='users', by='month', ax=axs[0], flierprops=flierprops)
datos.groupby('month')['users'].median().plot(style='o-', linewidth=0.8, ax=axs[0])
axs[0].set_ylabel('Users')
axs[0].set_title('Distribución de usuarios por mes', fontsize=10)

# Distribución de usuarios por día de la semana
datos['week_day'] = datos.index.day_of_week + 1
datos.boxplot(column='users', by='week_day', ax=axs[1], flierprops=flierprops)
datos.groupby('week_day')['users'].median().plot(style='o-', linewidth=0.8, ax=axs[1])
axs[1].set_ylabel('Users')
axs[1].set_title('Distribución de usuarios por día de la semana', fontsize=10)

# Distribución de usuarios por hora del día
datos['hour_day'] = datos.index.hour + 1
datos.boxplot(column='users', by='hour_day', ax=axs[2], flierprops=flierprops)
datos.groupby('hour_day')['users'].median().plot(style='o-', linewidth=0.8, ax=axs[2])
axs[2].set_ylabel('Users')
axs[2].set_title('Distribución de usuarios por hora del día', fontsize=10)

# Distribución de usuarios por día de la semana y hora del día
mean_day_hour = datos.groupby(['week_day', 'hour_day'])['users'].mean()
mean_day_hour.plot(ax=axs[3])
axs[3].set(
    title       = 'Usuarios promedio',
    xticks      = [i * 24 for i in range(7)],
    xticklabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
    xlabel      = 'Día y hora',
    ylabel      = 'Users'
)
axs[3].title.set_size(10)

fig.suptitle('Gráficos de estacionalidad', fontsize=12)
fig.tight_layout()

Existe una clara diferencia entre los días entre semana y el fin de semana. También se observa un claro patrón intradiario, con diferente afluencia de usuarios dependiendo de la hora del día.

Gráficos de autocorrelación

Los gráficos de autocorrelación muestran la correlación entre una serie temporal y sus valores pasados. Son una herramienta útil para identificar el orden de un modelo autorregresivo, es decir, los valores pasados (lags) que se deben incluir en el modelo.

La función de autocorrelación (ACF) mide la correlación entre una serie temporal y sus valores pasados. La función de autocorrelación parcial (PACF) mide la correlación entre una serie temporal y sus valores pasados, pero solo después de eliminar las variaciones explicadas por los valores pasados intermedios.

# Gráfico autocorrelación
# ==============================================================================
fig, ax = plt.subplots(figsize=(5, 2))
plot_acf(datos['users'], ax=ax, lags=72, fft=True)
plt.show()
# Gráfico autocorrelación parcial
# ==============================================================================
fig, ax = plt.subplots(figsize=(5, 2))
plot_pacf(datos['users'], ax=ax, lags=72, method='burg')
plt.show()
# Top 10 lags con mayor autocorrelación parcial absoluta
# ==============================================================================
calculate_lag_autocorrelation(
    data    = datos['users'],
    n_lags  = 72,
    sort_by = 'partial_autocorrelation_abs'
).head(10)
lag partial_autocorrelation_abs partial_autocorrelation autocorrelation_abs autocorrelation
0 1 0.845172 0.845172 0.845172 0.845172
1 2 0.408186 -0.408186 0.597704 0.597704
2 23 0.354486 0.354486 0.708470 0.708470
3 22 0.342673 0.342673 0.520804 0.520804
4 25 0.331896 -0.331896 0.711256 0.711256
5 10 0.272315 -0.272315 0.046483 -0.046483
6 17 0.241529 0.241529 0.057267 -0.057267
7 19 0.198825 0.198825 0.159897 0.159897
8 21 0.193044 0.193044 0.373666 0.373666
9 3 0.181888 0.181888 0.409680 0.409680

Los resultados del estudio de autocorrelación indican una correlación significativa entre el número de usuarios en las horas anteriores, así como en los días previos. Esto significa que conocer el número de usuarios durante periodos específicos del pasado proporciona información útil para predecir el número de usuarios en el futuro.

Baseline

Al enfrentarse a un problema de forecasting, es recomendable disponer de un modelo de referencia (baseline). Se trata de un modelo muy sencillo que puede utilizarse como referencia para evaluar si merece la pena aplicar modelos más complejos.

Skforecast permite crear fácilmente un modelo de referencia con su clase ForecasterEquivalentDate (guía de usuario). Este modelo, también conocido como Seasonal Naive Forecasting, simplemente devuelve el valor observado en el mismo periodo de la temporada anterior (por ejemplo, el mismo día laboral de la semana anterior, la misma hora del día anterior, etc.).

A partir del análisis exploratorio realizado, el modelo de referencia será el que prediga cada hora utilizando el valor de la misma hora del día anterior.

✏️ Note

En las siguientes celdas de código, se entrena un modelo baseline y se evalúa su capacidad predictiva mediante un proceso de backtesting. Si este concepto es nuevo para ti, no te preocupes, se explicará en detalle a lo largo del documento. Por ahora, basta con saber que el proceso de backtesting consiste en entrenar el modelo con una cierta cantidad de datos y evaluar su capacidad predictiva con los datos que el modelo no ha visto. La métrica del error se utilizará como referencia para comparar la capacidad predictiva de los modelos más complejos que se implementarán a lo largo del documento.

# Crear un baseline: valor de la misma hora del día anterior
# ==============================================================================
forecaster = ForecasterEquivalentDate(
                offset    = pd.DateOffset(days=1),
                n_offsets = 1
             )

# Entrenamiento del forecaster
# ==============================================================================
forecaster.fit(y=datos.loc[:fin_validacion, 'users'])
forecaster

ForecasterEquivalentDate

General Information
  • Estimator: NoneType
  • Offset:
  • Number of offsets: 1
  • Aggregation function: mean
  • Window size: 24
  • Creation date: 2026-09-22 09:27:02
  • Last fit date: 2026-09-22 09:27:02
  • Skforecast version: 0.25.0
  • Python version: 3.13.14
  • Forecaster id: None
Training Information
  • Training range: [Timestamp('2011-01-01 00:00:00'), Timestamp('2012-08-31 23:00:00')]
  • Training index type: DatetimeIndex
  • Training index frequency: h

📖 API Reference    📝 User Guide

# Backtesting
# ==============================================================================
cv = TimeSeriesFold(steps = 36, initial_train_size = len(datos.loc[:fin_validacion]))
metrica_baseline, predicciones = backtesting_forecaster(
                                    forecaster = forecaster,
                                    y          = datos['users'],
                                    cv         = cv,
                                    metric     = 'mean_absolute_error'
                                )
metrica_baseline
mean_absolute_error
0 91.668716

El MAE del modelo baseline se utiliza como referencia para evaluar si merece la pena aplicar los modelos más complejos.

Forecasting con LightGBM

LightGBM es una implementación altamente eficiente del algoritmo gradient boosting, que se ha convertido en un referente en el campo del machine learning. La librería LightGBM incluye su propia API, así como la API de scikit-learn, lo que la hace compatible con skforecast.

En primer lugar, se entrena un modelo ForecasterRecursive utilizando valores pasados de la variable de respuesta (lags) y la media móvil como predictores. Posteriormente, se añaden variables exógenas al modelo y se evalúa la mejora de su rendimiento. Dado que los modelos de Gradient Boosting tienen un gran número de hiperparámetros, se realiza una Búsqueda Bayesiana utilizando la función bayesian_search_forecaster() para encontrar la mejor combinación de hiperparámetros y lags. Por último, se evalúa la capacidad predictiva del modelo mediante un proceso de backtesting.

Forecaster

# Crear el forecaster
# ==============================================================================
window_features = RollingFeatures(stats=['mean'], window_sizes=24 * 3)
forecaster = ForecasterRecursive(
                estimator       = LGBMRegressor(random_state=15926, verbose=-1),
                lags            = 72,
                window_features = window_features
             )

# Entrenar el forecaster
# ==============================================================================
forecaster.fit(y=datos.loc[:fin_validacion, 'users'])
forecaster

ForecasterRecursive

General Information
  • Estimator: LGBMRegressor
  • Lags: [ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72]
  • Window features: ['roll_mean_72']
  • Calendar features: None
  • Window size: 72
  • Series name: users
  • Exogenous included: False
  • Categorical features: auto
  • Weight function included: False
  • Differentiation order: None
  • Drop NaN from series: False
  • Creation date: 2026-09-22 09:27:03
  • Last fit date: 2026-09-22 09:27:08
  • Skforecast version: 0.25.0
  • Python version: 3.13.14
  • Forecaster id: None
Exogenous Variables

None

Data Transformations
  • Transformer for y: None
  • Transformer for exog: None
Training Information
  • Training range: [Timestamp('2011-01-01 00:00:00'), Timestamp('2012-08-31 23:00:00')]
  • Training index type: DatetimeIndex
  • Training index frequency: h
Estimator Parameters
    {'boosting_type': 'gbdt', 'class_weight': None, 'colsample_bytree': 1.0, 'importance_type': 'split', 'learning_rate': 0.1, 'max_depth': -1, 'min_child_samples': 20, 'min_child_weight': 0.001, 'min_split_gain': 0.0, 'n_estimators': 100, 'n_jobs': None, 'num_leaves': 31, 'objective': None, 'random_state': 15926, 'reg_alpha': 0.0, 'reg_lambda': 0.0, 'subsample': 1.0, 'subsample_for_bin': 200000, 'subsample_freq': 0, 'verbose': -1}
Fit Kwargs
    {}

📖 API Reference    📝 User Guide

# Predicciones
# ==============================================================================
forecaster.predict(steps=10)
2012-09-01 00:00:00    116.239441
2012-09-01 01:00:00     73.583478
2012-09-01 02:00:00     40.534411
2012-09-01 03:00:00     15.124854
2012-09-01 04:00:00      7.020997
2012-09-01 05:00:00     18.878984
2012-09-01 06:00:00     58.002715
2012-09-01 07:00:00    148.788547
2012-09-01 08:00:00    316.935805
2012-09-01 09:00:00    360.946476
Freq: h, Name: pred, dtype: float64

Backtesting

Para obtener una estimación robusta de la capacidad predictiva del modelo, se realiza un proceso de backtesting. El proceso de backtesting consiste en generar una predicción para cada observación del conjunto de test, siguiendo el mismo procedimiento que se seguiría si el modelo estuviese en producción, y finalmente comparar el valor predicho con el valor real.

Se recomienda revisar la documentación de la función backtesting_forecaster para comprender mejor sus capacidades. Esto ayudará a utilizar todo su potencial para analizar la capacidad predictiva del modelo.

# Backtest del modelo con los datos de test
# ==============================================================================
cv = TimeSeriesFold(steps = 36, initial_train_size = len(datos.loc[:fin_validacion]))
metrica, predicciones = backtesting_forecaster(
                            forecaster = forecaster,
                            y          = datos['users'],
                            cv         = cv,
                            metric     = 'mean_absolute_error'
                        )
predicciones.head()
fold pred
2012-09-01 00:00:00 0 116.239441
2012-09-01 01:00:00 0 73.583478
2012-09-01 02:00:00 0 40.534411
2012-09-01 03:00:00 0 15.124854
2012-09-01 04:00:00 0 7.020997
# Error de backtest
# ==============================================================================
metrica
mean_absolute_error
0 70.424957

El modelo autorregresivo alcanza un MAE de 70.4, inferior al del modelo de referencia (91.7).

Variables exógenas

Hasta ahora, sólo se han utilizado como predictores los valores pasados (lags) de la serie temporal. Sin embargo, es posible incluir otras variables como predictores. Estas variables se conocen como variables exógenas (features) y su uso puede mejorar la capacidad predictiva del modelo. Un punto muy importante que hay que tener en cuenta es que los valores de las variables exógenas deben conocerse en el momento de la predicción.

Ejemplos habituales de variables exógenas son aquellas obtenidas del calendario, como el día de la semana, el mes, el año o los días festivos. Las variables meteorológicas como la temperatura, la humedad y el viento también entran en esta categoría, al igual que las variables económicas como la inflación y los tipos de interés.

⚠️ Warning

Las variables exógenas deben conocerse en el momento de la predicción. Por ejemplo, si se utiliza la temperatura como variable exógena, el valor de la temperatura para la hora siguiente debe conocerse en el momento de la previsión. Si no se conoce el valor de la temperatura, la predicción no será posible.

Las variables meteorológicas deben utilizarse con precaución. Cuando el modelo se pone en producción, las condiciones meteorológicas futuras no se conocen, sino que son predicciones realizadas por los servicios meteorológicos. Al tratarse de predicciones, introducen errores en el modelo de previsión. Como consecuencia, es probable que las predicciones del modelo empeoren. Una forma de anticiparse a este problema, y conocer (no evitar) el rendimiento esperado del modelo, es utilizar las previsiones meteorológicas disponibles en el momento en que se entrena el modelo, en lugar de las condiciones reales registradas.

💡 Tip

Algunos aspectos del calendario, como las horas o los días, son cíclicos. Por ejemplo, la hora del día va de 0 a 23 horas. Este tipo de variables pueden tratarse de varias formas, cada una con sus ventajas e inconvenientes.

  • Un enfoque consiste en utilizar las variables directamente como valores numéricos sin ninguna transformación. Este método evita crear variables nuevas, pero puede imponer un orden lineal incorrecto a los valores. Por ejemplo, la hora 23 de un día y la hora 00 del siguiente están muy alejadas en su representación lineal, cuando en realidad sólo hay una hora de diferencia entre ellas.
  • Otra posibilidad es tratar las variables cíclicas como variables categóricas para evitar imponer un orden lineal. Sin embargo, este enfoque puede provocar la pérdida de la información cíclica inherente a la variable.
  • Existe una tercera forma de tratar las variables cíclicas que suele preferirse a los otros dos métodos. Se trata de transformar las variables utilizando el seno y el coseno de su periodo. Este método genera solo dos nuevas variables que captan la ciclicidad de los datos con mayor precisión que los dos métodos anteriores, ya que preserva el orden natural de la variable y evita imponer un orden lineal.

Desde la versión 0.23.0, skforecast incluye el argumento calendar_features en la mayoría de los forecasters, lo que facilita la incorporación de variables cíclicas del calendario directamente en el pipeline de predicción sin necesidad de ningún preprocesamiento externo. Dicho esto, sigue siendo posible incluirlas como variables exógenas si así se prefiere.

Para información más detallada sobre las diferentes formas de tratar las variables cíclicas, consultar el documento Cyclical features in time series forecasting.

Variables de calendario y meteorológicas

A continuación, se crean variables exógenas basadas en información del calendario, las horas de salida y puesta del sol, la temperatura y los días festivos. Estas nuevas variables se añaden a los conjuntos de entrenamiento, validación y test, y se utilizan como predictores en el modelo autorregresivo.

# Variables basadas en el calendario, con encoding cíclico
# ==============================================================================
calendar_transformer = CalendarFeatures(
    features = ['month', 'week', 'day_of_week', 'hour'],
    encoding = 'cyclical',
    keep_original_columns = False,
)
variables_calendario = calendar_transformer.fit_transform(datos)
variables_calendario.head(2)
month_sin month_cos week_sin week_cos day_of_week_sin day_of_week_cos hour_sin hour_cos
date_time
2011-01-01 00:00:00 0.5 0.866025 -0.118273 0.992981 -0.974928 -0.222521 0.000000 1.000000
2011-01-01 01:00:00 0.5 0.866025 -0.118273 0.992981 -0.974928 -0.222521 0.258819 0.965926
# Variables basadas en la luz solar
# ==============================================================================
location = LocationInfo(
    name      = 'Washington D.C.',
    region    = 'USA',
    latitude  = 38.89,
    longitude = -77.04,
    timezone  = 'America/New_York'
)
# El amanecer y el atardecer se calculan una vez por día y se asignan a cada hora
days = datos.index.normalize().unique()
sun_times = [
    sun(location.observer, date=day, tzinfo=location.timezone) for day in days
]
sunrise_hour = pd.Series([s['sunrise'] for s in sun_times], index=days)
sunset_hour = pd.Series([s['sunset'] for s in sun_times], index=days)
sunrise_hour = sunrise_hour.dt.round('h').dt.hour.reindex(datos.index, method='ffill')
sunset_hour = sunset_hour.dt.round('h').dt.hour.reindex(datos.index, method='ffill')
sunrise_hour_sin = np.sin(2 * np.pi * sunrise_hour / 24)
sunrise_hour_cos = np.cos(2 * np.pi * sunrise_hour / 24)
sunset_hour_sin = np.sin(2 * np.pi * sunset_hour / 24)
sunset_hour_cos = np.cos(2 * np.pi * sunset_hour / 24)

daylight_hours = sunset_hour - sunrise_hour
is_daylight = np.where(
    (datos.index.hour >= sunrise_hour) & (datos.index.hour < sunset_hour), 1, 0,
)

variables_solares = pd.DataFrame({
                        'sunrise_hour_sin': sunrise_hour_sin,
                        'sunrise_hour_cos': sunrise_hour_cos,
                        'sunset_hour_sin': sunset_hour_sin,
                        'sunset_hour_cos': sunset_hour_cos,
                        'daylight_hours': daylight_hours,
                        'is_daylight': is_daylight
                     })
variables_solares.head(2)
sunrise_hour_sin sunrise_hour_cos sunset_hour_sin sunset_hour_cos daylight_hours is_daylight
date_time
2011-01-01 00:00:00 0.965926 -0.258819 -0.965926 -0.258819 10 0
2011-01-01 01:00:00 0.965926 -0.258819 -0.965926 -0.258819 10 0
# Variables basadas en festivos
# ==============================================================================
variables_festivos = datos[['holiday']].astype(int)
variables_festivos['holiday_previous_day'] = variables_festivos['holiday'].shift(24)
variables_festivos['holiday_next_day'] = variables_festivos['holiday'].shift(-24)
variables_festivos.head(2)
holiday holiday_previous_day holiday_next_day
date_time
2011-01-01 00:00:00 0 NaN 0.0
2011-01-01 01:00:00 0 NaN 0.0
# Variables basadas en temperatura
# ==============================================================================
wf_transformer = WindowFeatures(
    variables = ['temp'],
    window    = ['1D', '7D'],
    functions = ['mean', 'max', 'min'],
    freq      = 'h',
)
variables_temp = wf_transformer.fit_transform(datos[['temp']])
variables_temp.head(2)
temp temp_window_1D_mean temp_window_1D_max temp_window_1D_min temp_window_7D_mean temp_window_7D_max temp_window_7D_min
date_time
2011-01-01 00:00:00 9.84 NaN NaN NaN NaN NaN NaN
2011-01-01 01:00:00 9.02 9.84 9.84 9.84 9.84 9.84 9.84
# Unión de variables exógenas
# ==============================================================================
assert all(variables_calendario.index == variables_solares.index)
assert all(variables_calendario.index == variables_festivos.index)
assert all(variables_calendario.index == variables_temp.index)
variables_exogenas = pd.concat([
    variables_calendario,
    variables_solares,
    variables_temp,
    variables_festivos
], axis=1)

# Debido a la creación de ventanas móviles, hay valores faltantes al principio
# de la serie. Y debido a holiday_next_day hay valores faltantes al final.
variables_exogenas = variables_exogenas.iloc[7 * 24:, :]
variables_exogenas = variables_exogenas.iloc[:-24, :]
variables_exogenas.head(3)
month_sin month_cos week_sin week_cos day_of_week_sin day_of_week_cos hour_sin hour_cos sunrise_hour_sin sunrise_hour_cos ... temp temp_window_1D_mean temp_window_1D_max temp_window_1D_min temp_window_7D_mean temp_window_7D_max temp_window_7D_min holiday holiday_previous_day holiday_next_day
date_time
2011-01-08 00:00:00 0.5 0.866025 0.118273 0.992981 -0.974928 -0.222521 0.000000 1.000000 0.965926 -0.258819 ... 7.38 8.063333 9.02 6.56 10.127976 18.86 4.92 0 0.0 0.0
2011-01-08 01:00:00 0.5 0.866025 0.118273 0.992981 -0.974928 -0.222521 0.258819 0.965926 0.965926 -0.258819 ... 7.38 8.029167 9.02 6.56 10.113333 18.86 4.92 0 0.0 0.0
2011-01-08 02:00:00 0.5 0.866025 0.118273 0.992981 -0.974928 -0.222521 0.500000 0.866025 0.965926 -0.258819 ... 7.38 7.995000 9.02 6.56 10.103571 18.86 4.92 0 0.0 0.0

3 rows × 24 columns

Interacción entre variables

En muchos casos, las variables exógenas no son independientes. Más bien, su efecto sobre la variable objetivo depende del valor de otras variables. Por ejemplo, el efecto de la hora del día sobre el número de usuarios de bicicletas depende del día de la semana: los picos asociados a los desplazamientos al trabajo que se observan en los días laborables desaparecen durante el fin de semana. La interacción entre las variables exógenas puede captarse mediante nuevas variables que se obtienen multiplicando entre sí las variables existentes. Estas interacciones se obtienen fácilmente con la clase PolynomialFeatures de scikit-learn.

Para mantener un número manejable de nuevas variables, las interacciones se crean únicamente entre las variables cíclicas de calendario y de luz solar (12 variables, que dan lugar a 66 interacciones por pares).

# Interacción entre variables exógenas
# ==============================================================================
transformer_poly = PolynomialFeatures(
                        degree           = 2,
                        interaction_only = True,
                        include_bias     = False
                    ).set_output(transform='pandas')
# Las interacciones se crean únicamente entre las variables cíclicas
poly_cols = [
    'month_sin',
    'month_cos',
    'week_sin',
    'week_cos',
    'day_of_week_sin',
    'day_of_week_cos',
    'hour_sin',
    'hour_cos',
    'sunrise_hour_sin',
    'sunrise_hour_cos',
    'sunset_hour_sin',
    'sunset_hour_cos',
]
variables_poly = transformer_poly.fit_transform(variables_exogenas[poly_cols])
variables_poly = variables_poly.drop(columns=poly_cols)
variables_poly.columns = [f'poly_{col}' for col in variables_poly.columns]
variables_poly.columns = variables_poly.columns.str.replace(' ', '__')
assert all(variables_exogenas.index == variables_poly.index)
variables_exogenas = pd.concat([variables_exogenas, variables_poly], axis=1)
variables_exogenas.head(3)
month_sin month_cos week_sin week_cos day_of_week_sin day_of_week_cos hour_sin hour_cos sunrise_hour_sin sunrise_hour_cos ... poly_hour_cos__sunrise_hour_sin poly_hour_cos__sunrise_hour_cos poly_hour_cos__sunset_hour_sin poly_hour_cos__sunset_hour_cos poly_sunrise_hour_sin__sunrise_hour_cos poly_sunrise_hour_sin__sunset_hour_sin poly_sunrise_hour_sin__sunset_hour_cos poly_sunrise_hour_cos__sunset_hour_sin poly_sunrise_hour_cos__sunset_hour_cos poly_sunset_hour_sin__sunset_hour_cos
date_time
2011-01-08 00:00:00 0.5 0.866025 0.118273 0.992981 -0.974928 -0.222521 0.000000 1.000000 0.965926 -0.258819 ... 0.965926 -0.258819 -0.965926 -0.258819 -0.25 -0.933013 -0.25 0.25 0.066987 0.25
2011-01-08 01:00:00 0.5 0.866025 0.118273 0.992981 -0.974928 -0.222521 0.258819 0.965926 0.965926 -0.258819 ... 0.933013 -0.250000 -0.933013 -0.250000 -0.25 -0.933013 -0.25 0.25 0.066987 0.25
2011-01-08 02:00:00 0.5 0.866025 0.118273 0.992981 -0.974928 -0.222521 0.500000 0.866025 0.965926 -0.258819 ... 0.836516 -0.224144 -0.836516 -0.224144 -0.25 -0.933013 -0.25 0.25 0.066987 0.25

3 rows × 90 columns

Variables categóricas

Existen varios enfoques para incorporar variables categóricas en LightGBM (y otras implementaciones de gradient boosting):

  • Una opción es transformar los datos convirtiendo los valores categóricos en valores numéricos utilizando métodos como la codificación one hot o la codificación ordinal. Este enfoque es aplicable a todos los modelos de aprendizaje automático.

  • LightGBM puede manejar variables categóricas internamente sin necesidad de preprocesamiento.

No hay un método que sea siempre mejor que los otros. Las reglas generales son:

  • Cuando la cardinalidad de las variables categóricas es alta (muchos valores diferentes), es mejor utilizar el soporte nativo para variables categóricas que utilizar la codificación one-hot.

  • Con datos codificados con one hot encoding, se necesitan más puntos de división (es decir, más profundidad) para recuperar una división equivalente a la que podría obtenerse con un solo punto de división utilizando el tratamiento nativo.

  • Cuando una variable categórica se convierte en múltiples variables dummy utilizando one hot encoding, su importancia se diluye, haciendo que el análisis de la importancia de las características sea más complejo de interpretar.

# Almacenar las variables categóricas como tipo "category"
# ==============================================================================
datos['weather'] = datos['weather'].astype('category')

One hot encoding

ColumnTransformers en scikit-learn proporcionan una potente forma de definir transformaciones y aplicarlas a variables específicas. Al encapsular las transformaciones en un objeto ColumnTransformer, se puede pasar a un Forecaster utilizando el argumento transformer_exog.

✏️ Note

Es posible aplicar una transformación a todo el conjunto de datos independientemente del forecaster. Sin embargo, es crucial asegurarse de que las transformaciones se aprenden sólo a partir de los datos de entrenamiento para evitar fugas de información (leakage). Además, la misma transformación debe aplicarse a los datos de entrada durante la predicción. Por lo tanto, es aconsejable incluir la transformación en el forecaster para que se gestione internamente. Este enfoque garantiza la coherencia en la aplicación de las transformaciones y reduce la probabilidad de errores.

# Transformación con codificación one-hot
# ==============================================================================
one_hot_encoder = make_column_transformer(
    (
        OneHotEncoder(sparse_output=False, drop='if_binary'),
        make_column_selector(dtype_include=['category', 'object']),
    ),
    remainder='passthrough',
    verbose_feature_names_out=False,
).set_output(transform='pandas')
# Crear un forecaster con un transformer para las variables exógenas
# ==============================================================================
forecaster = ForecasterRecursive(
                estimator        = LGBMRegressor(random_state=15926, verbose=-1),
                lags             = 72,
                window_features  = window_features,
                transformer_exog = one_hot_encoder
             )

Para examinar cómo se transforman los datos, se puede utilizar el método create_train_X_y() y generar las matrices que el forecaster utiliza para entrenar el modelo. Este método permite conocer las manipulaciones específicas de los datos que se producen durante el proceso de entrenamiento.

# Mostrar matrices de entrenamiento
# ==============================================================================
exog_cols = ['weather']
X_train, y_train = forecaster.create_train_X_y(
                        y    = datos.loc[:fin_validacion, 'users'],
                        exog = datos.loc[:fin_validacion, exog_cols]
                   )
X_train.head(3)
lag_1 lag_2 lag_3 lag_4 lag_5 lag_6 lag_7 lag_8 lag_9 lag_10 ... lag_67 lag_68 lag_69 lag_70 lag_71 lag_72 roll_mean_72 weather_clear weather_mist weather_rain
date_time
2011-01-04 00:00:00 12.0 20.0 52.0 52.0 110.0 157.0 157.0 76.0 72.0 77.0 ... 1.0 1.0 13.0 32.0 40.0 16.0 43.638889 1.0 0.0 0.0
2011-01-04 01:00:00 5.0 12.0 20.0 52.0 52.0 110.0 157.0 157.0 76.0 72.0 ... 2.0 1.0 1.0 13.0 32.0 40.0 43.486111 1.0 0.0 0.0
2011-01-04 02:00:00 2.0 5.0 12.0 20.0 52.0 52.0 110.0 157.0 157.0 76.0 ... 3.0 2.0 1.0 1.0 13.0 32.0 42.958333 1.0 0.0 0.0

3 rows × 76 columns

La estrategia One Hot Encoder se ha mostrado con fines didácticos. Para el resto del documento, sin embargo, se utiliza el soporte nativo para variables categóricas.

Implementación nativa para variables categóricas

Desde la versión 0.22.0, skforecast proporciona un parámetro categorical_features que maneja automáticamente la codificación y configura de forma nativa los estimadores de gradient boosting (XGBoost, LightGBM, CatBoost e HistGradientBoosting), sin necesidad de pipelines de codificación manuales o parámetros específicos del estimador. Con su valor por defecto, 'auto', todas las columnas exógenas no numéricas se detectan y se tratan como categóricas. Este es el enfoque recomendado para la mayoría de los casos de uso y es el que se utiliza en este documento.

✏️ Note

Internamente, el forecaster aplica un OrdinalEncoder a las variables categóricas, convirtiéndolas en códigos numéricos. Esto evita errores al ajustar el estimador interno. Para los estimadores que admiten de forma nativa las variables categóricas (como LightGBM, XGBoost, CatBoost y HistGradientBoosting), el forecaster también pasa la lista de índices de columnas categóricas al estimador, para que se traten como categóricas en lugar de numéricas. Para todos los demás estimadores, los valores codificados como enteros se pasan tal cual y se tratan como variables numéricas continuas.

# Crear un forecaster con detección automática de variables categóricas
# ==============================================================================
forecaster = ForecasterRecursive(
                estimator            = LGBMRegressor(random_state=15926, verbose=-1),
                lags                 = 72,
                window_features      = window_features,
                categorical_features = 'auto'
             )

Esta es la estrategia que se utilizará en el resto del documento.

Evaluar el modelo con variables exógenas

Se entrena de nuevo el forecaster, pero esta vez, las variables exógenas también se incluyen como predictores. Para las variables categóricas, se utiliza la implementación nativa.

# Selección de variables exógenas a incluir en el modelo
# ==============================================================================
exog_cols = []
# Variables cíclicas: columnas que terminan con _sin o _cos. Sus interacciones
# (columnas que empiezan con poly_) también terminan con _sin o _cos, por lo que
# quedan seleccionadas
exog_cols.extend(variables_exogenas.filter(regex='_sin$|_cos$').columns.tolist())
# Columnas que empiezan con temp_ son seleccionadas
exog_cols.extend(variables_exogenas.filter(regex='^temp_.*').columns.tolist())
# Columnas que empiezan con holiday_ son seleccionadas
exog_cols.extend(variables_exogenas.filter(regex='^holiday_.*').columns.tolist())
exog_cols.extend(['daylight_hours', 'is_daylight', 'temp', 'holiday', 'weather'])

variables_exogenas = variables_exogenas.filter(exog_cols, axis=1)
# Combinar variables exógenas y target en el mismo dataframe
# ==============================================================================
datos = datos[['users', 'weather']].merge(
            variables_exogenas,
            left_index  = True,
            right_index = True,
            how         = 'inner'  # Solo fechas para las que hay datos exógenos
        )
datos = datos.astype({col: np.float32 for col in datos.select_dtypes('number').columns})
datos_train = datos.loc[: fin_train, :].copy()
datos_val   = datos.loc[fin_train:fin_validacion, :].copy()
datos_test  = datos.loc[fin_validacion:, :].copy()

✏️ Note

Debido al inner join, se pierden los primeros 7 días y las últimas 24 horas de la serie (tienen valores ausentes en las variables de ventanas móviles y de festivos). Como consecuencia, el backtesting en el conjunto de test tiene una partición menos que en las secciones anteriores (81 en lugar de 82). El efecto sobre la métrica de error es despreciable.

# Backtesting en los datos de test incluyendo las variables exógenas
# ==============================================================================
cv = TimeSeriesFold(steps = 36, initial_train_size = len(datos.loc[:fin_validacion]))
metrica, predicciones = backtesting_forecaster(
                            forecaster = forecaster,
                            y          = datos['users'],
                            exog       = datos[exog_cols],
                            cv         = cv,
                            metric     = 'mean_absolute_error'
                        )
metrica
mean_absolute_error
0 49.442977

La incorporación de variables exógenas mejora la capacidad predictiva del modelo: el MAE se reduce de 70.4 a 49.4. Dado que ambos forecasters utilizan los mismos lags, window features y estimador, la mejora puede atribuirse a las variables exógenas.

Optimización de hiperparámetros

El ForecasterRecursive entrenado utiliza los primeros 72 lags y un modelo LGBMRegressor con los hiperparámetros por defecto. Sin embargo, no hay ninguna razón por la que estos valores sean los más adecuados. Para encontrar los mejores hiperparámetros, se realiza una Búsqueda Bayesiana con la función bayesian_search_forecaster(). La búsqueda se lleva a cabo utilizando el mismo proceso de backtesting que antes, pero cada vez, el modelo se entrena con diferentes combinaciones de hiperparámetros y lags. Es importante señalar que la búsqueda de hiperparámetros debe realizarse utilizando el conjunto de validación, nunca con los datos de test.

La búsqueda se realiza probando cada combinación de hiperparámetros y retardos del siguiente modo:

  1. Entrenar el modelo utilizando sólo el conjunto de entrenamiento.

  2. El modelo se evalúa utilizando el conjunto de validación mediante backtesting.

  3. Seleccionar la combinación de hiperparámetros y retardos que proporcione el menor error.

  4. Volver a entrenar el modelo con la mejor combinación encontrada, esta vez utilizando tanto los datos de entrenamiento como los de validación.

Siguiendo estos pasos, se puede obtener un modelo con hiperparámetros optimizados y evitar el sobreajuste.

✏️ Note

El proceso de búsqueda de hiperparámetros puede requerir una cantidad notable de tiempo, sobre todo si se utiliza una estrategia de validación basada en backtesting (TimeSeriesFold). Una alternativa más rápida consiste en utilizar una estrategia de validación basada en predicciones one-step-ahead (OneStepAheadFold). Esta estrategia es más rápida pero puede no ser tan precisa como la validación basada en backtesting. Para obtener una descripción más detallada de los pros y los contras de cada estrategia, consulte la sección backtesting vs one-step-ahead.

# Búsqueda de hiperparámetros
# ==============================================================================
forecaster = ForecasterRecursive(
                estimator            = LGBMRegressor(random_state=15926, verbose=-1),
                lags                 = 72,
                window_features      = window_features,
                categorical_features = 'auto'
             )

# Lags grid
lags_grid = [48, 72, [1, 2, 3, 23, 24, 25, 167, 168, 169]]

# Espacio de búsqueda de hiperparámetros
def search_space(trial):
    return {
        'n_estimators'    : trial.suggest_int('n_estimators', 300, 1000, step=100),
        'max_depth'       : trial.suggest_int('max_depth', 3, 10),
        'min_data_in_leaf': trial.suggest_int('min_data_in_leaf', 25, 500),
        'learning_rate'   : trial.suggest_float('learning_rate', 0.01, 0.5),
        'feature_fraction': trial.suggest_float('feature_fraction', 0.5, 1),
        'max_bin'         : trial.suggest_int('max_bin', 50, 250),
        'reg_alpha'       : trial.suggest_float('reg_alpha', 0, 1),
        'reg_lambda'      : trial.suggest_float('reg_lambda', 0, 1),
        'lags'            : trial.suggest_categorical('lags', lags_grid)
    }

# Particiones de entrenamiento y validación
cv_search = TimeSeriesFold(steps = 36, initial_train_size = len(datos_train))

results_search, frozen_trial = bayesian_search_forecaster(
    forecaster    = forecaster,
    y             = datos.loc[:fin_validacion, 'users'],
    exog          = datos.loc[:fin_validacion, exog_cols],
    cv            = cv_search,
    search_space  = search_space,
    metric        = 'mean_absolute_error',
    n_trials      = 20,
    return_best   = True
)
best_params = results_search['params'].iat[0]
best_params = best_params | {'random_state': 15926, 'verbose': -1}
best_lags   = results_search['lags'].iat[0]
# Resultados de la búsqueda
# ==============================================================================
results_search.head(3)
trial_number lags params mean_absolute_error n_estimators max_depth min_data_in_leaf learning_rate feature_fraction max_bin reg_alpha reg_lambda
0 11 [1, 2, 3, 23, 24, 25, 167, 168, 169] {'n_estimators': 700, 'max_depth': 7, 'min_dat... 58.787095 700.0 7.0 217.0 0.250853 0.988359 222.0 0.562898 0.793247
1 10 [1, 2, 3, 23, 24, 25, 167, 168, 169] {'n_estimators': 600, 'max_depth': 8, 'min_dat... 58.921308 600.0 8.0 261.0 0.250013 0.966802 236.0 0.614362 0.556628
2 13 [1, 2, 3, 23, 24, 25, 167, 168, 169] {'n_estimators': 300, 'max_depth': 9, 'min_dat... 59.665895 300.0 9.0 201.0 0.421666 0.913824 193.0 0.425998 0.719827

Al indicar return_best = True, el objeto forecaster se actualiza automáticamente con la mejor configuración encontrada y se reentrena con todos los datos proporcionados a la búsqueda (conjuntos de entrenamiento y validación). El conjunto de test no se utiliza en ningún momento, por lo que puede emplearse para obtener una estimación no sesgada del rendimiento del modelo final.

Una vez identificada la mejor combinación de hiperparámetros utilizando los datos de validación, se evalúa la capacidad predictiva del modelo cuando se aplica al conjunto de test.

# Backtesting en los datos de test
# ==============================================================================
cv = TimeSeriesFold(steps = 36, initial_train_size = len(datos.loc[:fin_validacion]))
metrica, predicciones = backtesting_forecaster(
                            forecaster = forecaster,
                            y          = datos['users'],
                            exog       = datos[exog_cols],
                            cv         = cv,
                            metric     = 'mean_absolute_error'
                        )
metrica
mean_absolute_error
0 51.623907

Con los hiperparámetros y lags seleccionados, el MAE en test es de 51.6, que no mejora el 49.4 obtenido con los hiperparámetros por defecto y 72 lags. Es un buen recordatorio de que la optimización de hiperparámetros no garantiza una mejora en datos nuevos: la búsqueda se ha mantenido muy reducida (20 iteraciones) y la configuración se elige según su rendimiento en el periodo de validación, que puede diferir del periodo de test. En un proyecto real, se recomienda realizar una búsqueda más exhaustiva.

# Gráfico predicciones vs valor real
# ==============================================================================
fig = go.Figure()
trace1 = go.Scatter(
    x=datos_test.index, y=datos_test['users'], name='test', mode='lines'
)
trace2 = go.Scatter(
    x=predicciones.index, y=predicciones['pred'], name='prediction', mode='lines'
)
fig.add_trace(trace1)
fig.add_trace(trace2)
fig.update_layout(
    title='Valor real vs predicciones',
    xaxis_title='Date time',
    yaxis_title='Users',
    width=800,
    height=400,
    margin=dict(l=20, r=20, t=35, b=20),
    legend=dict(orientation='h', yanchor='top', y=1.1, xanchor='left', x=0.001)
)
fig.show()

Selección de predictores

La selección de predictores (feature selection) es el proceso de identificar un subconjunto de predictores relevantes para su uso en la creación del modelo. Es un paso importante en el proceso de machine learning, ya que puede ayudar a reducir el sobreajuste, mejorar la precisión del modelo y reducir el tiempo de entrenamiento. Dado que los estimadores subyacentes de skforecast siguen la API de scikit-learn, es posible utilizar los métodos de selección de predictores disponibles en scikit-learn. Dos de los métodos más populares son Recursive Feature Elimination y Sequential Feature Selection. Skforecast facilita su uso con forecasters mediante la función select_features() (guía de usuario).

💡 Tip

La selección de predictores es una herramienta potente para mejorar el rendimiento de los modelos de machine learning. Sin embargo, es computacionalmente costosa y puede requerir mucho tiempo. Dado que el objetivo es encontrar el mejor subconjunto de predictores, no el mejor modelo, no es necesario utilizar todos los datos disponibles ni un modelo muy complejo. En su lugar, se recomienda utilizar un pequeño subconjunto de los datos y un modelo sencillo. Una vez identificados los mejores predictores, el modelo puede entrenarse utilizando todo el conjunto de datos y una configuración más compleja.

# Crear forecaster
# ==============================================================================
estimator = LGBMRegressor(
    n_estimators = 100,
    max_depth    = 5,
    random_state = 15926,
    verbose      = -1
)

forecaster = ForecasterRecursive(
    estimator            = estimator,
    lags                 = best_lags,
    window_features      = window_features,
    categorical_features = 'auto'
)

# Eliminación recursiva de predictores con validación cruzada
# ==============================================================================
warnings.filterwarnings('ignore', message='X does not have valid feature names.*')
selector = RFECV(
    estimator = estimator,
    step      = 1,
    cv        = 3,
)
lags_seleccionados, wf_seleccionadas, exog_seleccionadas, _ = select_features(
    forecaster      = forecaster,
    selector        = selector,
    y               = datos_train['users'],
    exog            = datos_train[exog_cols],
    select_only     = None,
    force_inclusion = None,
    subsample       = 0.5,
    random_state    = 123,
    verbose         = True,
)
Recursive feature elimination (RFECV)
-------------------------------------
Total number of records available: 11327
Total number of records used for feature selection: 5663
Number of features available: 101
    Lags            (n=9)
    Window features (n=1)
    Exog            (n=91)
    Calendar        (n=0)
Number of features selected: 40
    Lags            (n=9) : [1, 2, 3, 23, 24, 25, 167, 168, 169]
    Window features (n=1) : ['roll_mean_72']
    Exog            (n=30) : ['week_sin', 'hour_sin', 'hour_cos', 'poly_month_sin__week_sin', 'poly_month_sin__hour_sin', 'poly_month_cos__week_sin', 'poly_week_sin__day_of_week_sin', 'poly_week_sin__day_of_week_cos', 'poly_week_sin__hour_sin', 'poly_week_sin__sunrise_hour_cos', 'poly_week_sin__sunset_hour_cos', 'poly_week_cos__day_of_week_sin', 'poly_week_cos__day_of_week_cos', 'poly_week_cos__hour_cos', 'poly_day_of_week_sin__hour_sin', 'poly_day_of_week_sin__hour_cos', 'poly_day_of_week_sin__sunset_hour_sin', 'poly_day_of_week_cos__hour_sin', 'poly_day_of_week_cos__hour_cos', 'poly_day_of_week_cos__sunset_hour_sin', 'poly_hour_sin__hour_cos', 'poly_hour_sin__sunrise_hour_sin', 'poly_hour_sin__sunset_hour_sin', 'poly_hour_sin__sunset_hour_cos', 'poly_hour_cos__sunrise_hour_sin', 'poly_hour_cos__sunset_hour_sin', 'temp_window_1D_mean', 'temp_window_7D_mean', 'temp', 'weather']
    Calendar        (n=0) : []

El RFECV de scikit-learn empieza entrenando un modelo con todos los predictores disponibles y calculando la importancia de cada uno en base a los atributos como coef_ o feature_importances_. A continuación, se elimina el predictor menos importante y se realiza una validación cruzada para calcular el rendimiento del modelo con los predictores restantes. Este proceso se repite hasta que la eliminación de predictores adicionales no mejora la métrica de rendimiento elegida o se alcanza el min_features_to_select.

El resultado final es un subconjunto de predictores que idealmente equilibra la simplicidad del modelo y su capacidad predictiva, determinada por el proceso de validación cruzada.

⚠️ Warning

La validación cruzada que utiliza internamente RFECV (cv=3) es un K-fold estándar sobre las filas de la matriz de entrenamiento, por lo que no respeta el orden temporal de las observaciones. En este caso es aceptable porque su único propósito es ordenar y seleccionar predictores, y solo se utiliza la partición de entrenamiento. Sin embargo, las métricas obtenidas durante este proceso son optimistas y nunca deben presentarse como el rendimiento del modelo de forecasting, que siempre debe estimarse mediante backtesting con datos no utilizados en la selección.

El forecaster se entrena y evalúa de nuevo utilizando el conjunto de predictores seleccionados.

# Crear forecaster con los predictores seleccionados
# ==============================================================================
# Las window features se incluyen solo si han sido seleccionadas
wf_finales = window_features if wf_seleccionadas else None
forecaster = ForecasterRecursive(
    estimator            = LGBMRegressor(**best_params),
    lags                 = lags_seleccionados,
    window_features      = wf_finales,
    categorical_features = 'auto'
)

# Backtesting con los predictores seleccionados y los datos de test
# ==============================================================================
cv = TimeSeriesFold(steps = 36, initial_train_size = len(datos.loc[:fin_validacion]))
metrica_lgbm, predicciones = backtesting_forecaster(
    forecaster = forecaster,
    y          = datos['users'],
    exog       = datos[exog_seleccionadas],
    cv         = cv,
    metric     = 'mean_absolute_error'
)
metrica_lgbm
mean_absolute_error
0 49.615162

El número de predictores se ha reducido de 101 a 40 y el MAE en test ha pasado de 51.6 a 49.6. El modelo es ahora mucho más simple, lo que hace que sea más rápido de entrenar y menos propenso al sobreajuste, sin pérdida de capacidad predictiva. Para el resto del documento, el modelo se entrenará utilizando sólo las variables exógenas seleccionadas.

# Actualizar las variables exógenas utilizadas
# ==============================================================================
exog_cols = exog_seleccionadas

Forecasting probabilístico: intervalos de predicción

Un intervalo de predicción define el intervalo dentro del cual es de esperar que se encuentre el verdadero valor de la variable respuesta con una determinada probabilidad. Skforecast implementa varios métodos para el forecasting probabilístico:

El siguiente código muestra cómo generar intervalos de predicción utilizando conformal prediction.

El argumento interval se utiliza para especificar la probabilidad de cobertura deseada de los intervalos. En este caso, interval se establece en [0.05, 0.95], lo que significa una cobertura teórica del 90%.

# Crear y entrenar forecaster
# ==============================================================================
forecaster = ForecasterRecursive(
    estimator            = LGBMRegressor(**best_params),
    lags                 = best_lags,
    window_features      = window_features,
    categorical_features = 'auto',
    binner_kwargs        = {'n_bins': 5}
)
forecaster.fit(
    y    = datos.loc[:fin_train, 'users'],
    exog = datos.loc[:fin_train, exog_cols],
    store_in_sample_residuals = True
)
# Predicción de intervalos
# ==============================================================================
# Como el modelo ha sido entrenado con variables exógenas, se tienen que pasar
# para las predicciones.
predicciones = forecaster.predict_interval(
    exog     = datos.loc[fin_train:, exog_cols],
    steps    = 36,
    interval = [0.05, 0.95],
    method   = 'conformal',
)
predicciones.head()
pred lower_bound upper_bound
2012-05-01 00:00:00 25.068601 9.002009 41.135193
2012-05-01 01:00:00 1.297958 -6.094340 8.690257
2012-05-01 02:00:00 -2.524842 -9.917141 4.867456
2012-05-01 03:00:00 -2.940578 -10.332876 4.451721
2012-05-01 04:00:00 7.613551 0.221252 15.005849

✏️ Note

El número de usuarios no puede ser negativo, pero ni el modelo ni los intervalos conformales conocen esta restricción, por lo que algunas predicciones y límites inferiores quedan por debajo de cero. En una aplicación real, las predicciones y los límites pueden simplemente truncarse en cero, por ejemplo con predicciones.clip(lower=0).

Por defecto, los intervalos se calculan utilizando los residuos in-sample (residuos del conjunto de entrenamiento). Sin embargo, esto puede dar lugar a intervalos demasiado estrechos (demasiado optimistas). Para evitarlo, se utiliza el método set_out_sample_residuals() para almacenar residuos out-sample calculados mediante backtesting con un conjunto de validación.

Si además de los valores reales, se le pasan las correspondientes predicciones al método set_out_sample_residuals(), los residuos se agrupan en intervalos (bins) según la magnitud de la predicción a la que están asociados (5 bins en este ejemplo, ver binner_kwargs). La amplitud del intervalo conformal se calcula entonces por separado para cada bin, de forma que se adapta al nivel de la predicción: intervalos más estrechos en las horas con pocos usuarios y más amplios en las horas punta. Esto ayuda a mejorar la cobertura a la vez que se mantienen los intervalos lo más estrechos posible.

# Backtesting con los datos de validación para obtener residuos out-sample
# ==============================================================================
cv = TimeSeriesFold(steps = 36, initial_train_size = len(datos.loc[:fin_train]))
_, predicciones_val = backtesting_forecaster(
    forecaster = forecaster,
    y          = datos.loc[:fin_validacion, 'users'],
    exog       = datos.loc[:fin_validacion, exog_cols],
    cv         = cv,
    metric     = 'mean_absolute_error'
)
# Distribución de los residuos out-sample
# ==============================================================================
residuals = datos.loc[predicciones_val.index, 'users'] - predicciones_val['pred']
print(pd.Series(np.where(residuals < 0, 'negative', 'positive')).value_counts())
plt.rcParams.update({'font.size': 8})
_ = plot_residuals(
        y_true = datos.loc[predicciones_val.index, 'users'],
        y_pred = predicciones_val['pred'],
        figsize=(7, 4)
    )
positive    1853
negative    1099
Name: count, dtype: int64

Los residuos out-sample no están centrados en cero: el 63% son positivos, lo que significa que el modelo tiende a infraestimar el número de usuarios en el periodo de validación (una explicación plausible es la tendencia creciente de la serie, que los modelos basados en árboles no pueden extrapolar). Los intervalos conformales son simétricos respecto a la predicción, por lo que este sesgo es una de las razones por las que la cobertura empírica puede diferir de la nominal.

# Almacenar residuos out-sample en el forecaster
# ==============================================================================
forecaster.set_out_sample_residuals(
    y_true = datos.loc[predicciones_val.index, 'users'],
    y_pred = predicciones_val['pred']
)

A continuación, se ejecuta el proceso de backtesting para estimar los intervalos de predicción en el conjunto de test. Se indica el argumento use_in_sample_residuals en False para que se utilicen los residuos out-sample almacenados previamente y use_binned_residuals en True para que la amplitud de cada intervalo esté condicionada al rango del valor predicho.

# Backtesting con intervalos de predicción en test usando out-sample residuals
# ==============================================================================
cv = TimeSeriesFold(steps = 36, initial_train_size = len(datos.loc[:fin_validacion]))
metrica, predicciones = backtesting_forecaster(
   forecaster              = forecaster,
   y                       = datos['users'],
   exog                    = datos[exog_cols],
   cv                      = cv,
   metric                  = 'mean_absolute_error',
   interval                = [0.05, 0.95],  # 90% intervalo de predicción
   interval_method         = 'conformal',
   use_in_sample_residuals = False,  # Usar out-sample residuals
   use_binned_residuals    = True,   # Usar residuos condicionados al valor predicho
)
predicciones.head(5)
fold pred lower_bound upper_bound
2012-09-01 00:00:00 0 160.980005 88.588697 233.371313
2012-09-01 01:00:00 0 121.984026 49.592718 194.375334
2012-09-01 02:00:00 0 97.535964 25.144656 169.927272
2012-09-01 03:00:00 0 53.969403 15.488815 92.449990
2012-09-01 04:00:00 0 28.084216 -10.396371 66.564804
# Gráfico intervalos de predicción vs valor real
# ==============================================================================
fig = go.Figure([
    go.Scatter(
        name='Prediction', x=predicciones.index, y=predicciones['pred'], mode='lines'
    ),
    go.Scatter(
        name='Real value', x=datos_test.index, y=datos_test['users'], mode='lines',
    ),
    go.Scatter(
        name='Upper Bound', x=predicciones.index, y=predicciones['upper_bound'],
        mode='lines', marker=dict(color='#444'), line=dict(width=0), showlegend=False
    ),
    go.Scatter(
        name='Lower Bound', x=predicciones.index, y=predicciones['lower_bound'],
        mode='lines', marker=dict(color='#444'), line=dict(width=0),
        fillcolor='rgba(68, 68, 68, 0.3)', fill='tonexty', showlegend=False
    )
])
fig.update_layout(
    title='Valor real vs predicciones en los datos de test',
    xaxis_title='Date time',
    yaxis_title='users',
    width=800,
    height=400,
    margin=dict(l=20, r=20, t=35, b=20),
    hovermode='x',
    legend=dict(orientation='h', yanchor='top', y=1.1, xanchor='left', x=0.001),
    # Zoom inicial en el eje x del 1 al 10 de octubre
    xaxis=dict(range=['2012-10-01', '2012-10-10'])
)
fig.show()
# Cobertura del intervalo en los datos de test
# ==============================================================================
cobertura = calculate_coverage(
              y_true       = datos.loc[fin_validacion:, 'users'],
              lower_bound  = predicciones['lower_bound'],
              upper_bound  = predicciones['upper_bound']
            )
area = (predicciones['upper_bound'] - predicciones['lower_bound']).sum()
print(f'Área total del intervalo: {round(area, 2)}')
print(f'Cobertura del intervalo predicho: {round(100 * cobertura, 2)} %')
Área total del intervalo: 608129.09
Cobertura del intervalo predicho: 88.43 %

La cobertura observada en el conjunto de test (88.4%) es ligeramente inferior a la cobertura teórica esperada (90%). Esto significa que los intervalos contienen el valor real con menor probabilidad de la esperada.

✏️ Note

Para información detallada de las funcionalidades de forecasting probabilístico ofrecidas por skforecast visitar: Forecasting probabilístico con machine learning.

Explicabilidad del modelo

Debido a la naturaleza compleja de muchos de los actuales modelos de machine learning, a menudo funcionan como cajas negras, lo que dificulta entender por qué han hecho una predicción u otra. Las técnicas de explicabilidad pretenden desmitificar estos modelos, proporcionando información sobre su funcionamiento interno y ayudando a generar confianza, mejorar la transparencia y cumplir los requisitos normativos en diversos ámbitos. Mejorar la explicabilidad de los modelos no sólo ayuda a comprender su comportamiento, sino también a identificar sesgos, mejorar su rendimiento y permitir a las partes interesadas tomar decisiones más informadas basadas en los conocimientos del machine learning.

Skforecast es compatible con algunos de los métodos de explicabilidad más populares: model-specific feature importances, SHAP values, and partial dependence plots.

# Crear y entrenar el forecaster
# ==============================================================================
forecaster = ForecasterRecursive(
    estimator            = LGBMRegressor(**best_params),
    lags                 = best_lags,
    window_features      = window_features,
    categorical_features = 'auto',
)
forecaster.fit(
    y    = datos.loc[:fin_validacion, 'users'],
    exog = datos.loc[:fin_validacion, exog_cols]
)

Model-specific feature importance

# Extraer importancia de los predictores
# ==============================================================================
importancia = forecaster.get_feature_importances()
importancia.head(10)
feature importance
0 lag_1 1211
7 lag_168 1009
4 lag_24 808
8 lag_169 727
6 lag_167 696
5 lag_25 641
3 lag_23 631
1 lag_2 617
2 lag_3 530
9 roll_mean_72 427

⚠️ Warning

El método get_feature_importances() sólo devuelve valores si el estimador del forecaster tiene el atributo coef_ o feature_importances_, que son los nombres utilizados por scikit-learn y por las librerías que siguen su API.

Shap values

Los valores SHAP (SHapley Additive exPlanations) son un método muy utilizado para explicar los modelos de machine learning, ya que ayudan a comprender cómo influyen las variables y los valores en las predicciones de forma visual y cuantitativa.

Se puede obtener un análisis SHAP a partir de modelos skforecast con sólo dos elementos:

  • El estimador interno del forecaster.

  • Las matrices de entrenamiento creadas a partir de la serie temporal y variables exógenas, utilizadas para ajustar el forecaster.

Aprovechando estos dos componentes, los usuarios pueden crear explicaciones interpretables para sus modelos de skforecast. Estas explicaciones pueden utilizarse para verificar la fiabilidad del modelo, identificar los factores más significativos que contribuyen a las predicciones y comprender mejor la relación subyacente entre las variables de entrada y la variable objetivo.

# Matrices de entrenamiento utilizadas por el forecaster para entrenar el estimador
# ==============================================================================
X_train, y_train = forecaster.create_train_X_y(
                        y    = datos.loc[:fin_validacion, 'users'],
                        exog = datos.loc[:fin_validacion, exog_cols]
                    )
display(X_train.head(3))
display(y_train.head(3))
lag_1 lag_2 lag_3 lag_23 lag_24 lag_25 lag_167 lag_168 lag_169 roll_mean_72 ... poly_hour_sin__hour_cos poly_hour_sin__sunrise_hour_sin poly_hour_sin__sunset_hour_sin poly_hour_sin__sunset_hour_cos poly_hour_cos__sunrise_hour_sin poly_hour_cos__sunset_hour_sin temp_window_1D_mean temp_window_7D_mean temp weather
date_time
2011-01-15 01:00:00 28.0 27.0 36.0 1.0 5.0 14.0 16.0 16.0 25.0 55.736111 ... 0.250000 0.250000 -0.250000 -0.066987 0.933013 -0.933013 6.594167 6.535595 6.56 1.0
2011-01-15 02:00:00 20.0 28.0 27.0 1.0 1.0 5.0 7.0 16.0 16.0 55.930556 ... 0.433013 0.482963 -0.482963 -0.129410 0.836516 -0.836516 6.696667 6.530715 6.56 1.0
2011-01-15 03:00:00 12.0 20.0 28.0 1.0 1.0 1.0 1.0 7.0 16.0 56.083333 ... 0.500000 0.683013 -0.683013 -0.183013 0.683013 -0.683013 6.799167 6.525833 6.56 1.0

3 rows × 40 columns

date_time
2011-01-15 01:00:00    20.0
2011-01-15 02:00:00    12.0
2011-01-15 03:00:00     8.0
Freq: h, Name: y, dtype: float32
# Crear SHAP explainer (para modelos basados en árboles)
# ==============================================================================
explainer = shap.TreeExplainer(forecaster.estimator)

# Se selecciona una muestra del 50% de los datos para acelerar el cálculo
rng = np.random.default_rng(seed=785412)
sample = rng.choice(X_train.index, size=int(len(X_train)*0.5), replace=False)
X_train_sample = X_train.loc[sample, :]
shap_values = explainer.shap_values(X_train_sample)

✏️ Note

La librería Shap cuenta con varios Explainers, cada uno diseñado para un tipo de modelo diferente. El shap.TreeExplainer explainer se utiliza para modelos basados en árboles, como el LGBMRegressor utilizado en este ejemplo. Para más información, consultar la documentación de SHAP.

# Shap summary plot (top 10)
# ==============================================================================
shap.initjs()
shap.summary_plot(shap_values, X_train_sample, max_display=10, show=False)
fig, ax = plt.gcf(), plt.gca()
ax.set_title('SHAP Summary plot')
ax.tick_params(labelsize=8, colors='white')
fig.set_size_inches(8, 4.5)

Los valores SHAP no solo permiten interpretar el comportamiento general del modelo, sino que también son una herramienta poderosa para analizar predicciones individuales. Esto resulta especialmente útil cuando se quiere entender cómo se ha generado una predicción específica y qué variables han contribuido a ella.

Para llevar a cabo este análisis, es necesario acceder a los valores de los predictores (lags, window features y variables exógenas) en el momento de la predicción. Esto puede lograrse utilizando el método create_predict_X() o bien activando el argumento return_predictors=True en la función backtesting_forecaster().

Supóngase que se quiere entender la predicción obtenida durante el backtesting para la fecha 2012-10-06 12:00:00.

# Backtesting indicando que se devuelvan los predictores
# ==============================================================================
cv = TimeSeriesFold(steps = 36, initial_train_size = len(datos.loc[:fin_validacion]))
metrica, predicciones = backtesting_forecaster(
   forecaster              = forecaster,
   y                       = datos['users'],
   exog                    = datos[exog_cols],
   cv                      = cv,
   metric                  = 'mean_absolute_error',
   return_predictors       =  True,
)

Al indicar return_predictors=True, se obtiene un DataFrame con el valor predicho ('pred'), la partición en la que se encuentra ('fold') y el valor de los predictores (lags, window features y variables exógenas) utilizados para realizar cada predicción.

predicciones.head(3)
fold pred lag_1 lag_2 lag_3 lag_23 lag_24 lag_25 lag_167 lag_168 ... poly_hour_sin__hour_cos poly_hour_sin__sunrise_hour_sin poly_hour_sin__sunset_hour_sin poly_hour_sin__sunset_hour_cos poly_hour_cos__sunrise_hour_sin poly_hour_cos__sunset_hour_sin temp_window_1D_mean temp_window_7D_mean temp weather
2012-09-01 00:00:00 0 160.980005 174.000000 277.000000 303.0 32.0 82.0 152.0 115.0 135.0 ... 0.000000 0.000000 -0.000000 0.00000 0.965926 -0.866025 31.330833 28.714643 30.340000 0.0
2012-09-01 01:00:00 0 121.984026 160.980005 174.000000 277.0 20.0 32.0 82.0 79.0 115.0 ... 0.250000 0.250000 -0.224144 0.12941 0.933013 -0.836516 31.433332 28.724405 29.520000 0.0
2012-09-01 02:00:00 0 97.535964 121.984026 160.980005 174.0 5.0 20.0 32.0 38.0 79.0 ... 0.433013 0.482963 -0.433013 0.25000 0.836516 -0.750000 31.501667 28.734167 28.700001 0.0

3 rows × 42 columns

# Waterfall para una predicción concreta
# ==============================================================================
# Asegurar que los tipos son los mismos que en los datos de entrenamiento
predicciones = predicciones.astype(datos[exog_cols].dtypes)
iloc_predicted_date = predicciones.index.get_loc('2012-10-06 12:00:00')
shap_values_single = explainer(predicciones.iloc[:, 2:])
shap.plots.waterfall(shap_values_single[iloc_predicted_date], show=False)
fig = plt.gcf()
fig.set_size_inches(8, 3.5)
fig.axes[0].tick_params(labelsize=8)
plt.show()
# Force plot para una predicción concreta
# ==============================================================================
shap.force_plot(
    base_value  = shap_values_single.base_values[iloc_predicted_date],
    shap_values = shap_values_single.values[iloc_predicted_date],
    features    = predicciones.iloc[iloc_predicted_date, 2:],
)
Visualization omitted, Javascript library not loaded!
Have you run `initjs()` in this notebook? If this notebook was from another user you must also trust this notebook (File -> Trust notebook). If you are viewing this notebook on github the Javascript has been stripped for security. If you are using JupyterLab this error is because a JupyterLab extension has not yet been written.

XGBoost, HistGradientBoostingRegressor, CatBoost

Desde el éxito del Gradient Boosting como algoritmo de machine learning, se han desarrollado varias implementaciones. Además de LightGBM, otras tres muy populares son: XGBoost, HistGradientBoostingRegressor y CatBoost. Todas ellas son compatibles con skforecast.

Las siguientes secciones muestran cómo utilizar estas implementaciones para crear modelos de forecasting, haciendo hincapié en el uso de su soporte nativo para características categóricas. Esta vez se utiliza una estrategia de validación one-step-ahead (OneStepAheadFold) para acelerar la búsqueda de hiperparámetros.

# Particiones utilizadas para la búsqueda de hiperparámetros y backtesting
# ==============================================================================
cv_search = OneStepAheadFold(initial_train_size = len(datos_train))
cv_backtesting = TimeSeriesFold(
    steps = 36, initial_train_size = len(datos.loc[:fin_validacion])
)

XGBoost

# Crear forecaster
# ==============================================================================
forecaster = ForecasterRecursive(
    estimator            = XGBRegressor(tree_method='hist', random_state=123),
    lags                 = 24,
    window_features      = window_features,
    categorical_features = 'auto'
)
# Búsqueda de hiperparámetros
# ==============================================================================
warnings.simplefilter('ignore', category=IgnoredArgumentWarning)

# Se reutiliza el grid de lags (`lags_grid`) definido en la búsqueda de LightGBM
# Espacio de búsqueda de hiperparámetros
def search_space(trial):
    return {
        'n_estimators'    : trial.suggest_int('n_estimators', 300, 1000, step=100),
        'max_depth'       : trial.suggest_int('max_depth', 3, 10),
        'learning_rate'   : trial.suggest_float('learning_rate', 0.01, 1),
        'subsample'       : trial.suggest_float('subsample', 0.1, 1),
        'colsample_bytree': trial.suggest_float('colsample_bytree', 0.1, 1),
        'gamma'           : trial.suggest_float('gamma', 0, 1),
        'reg_alpha'       : trial.suggest_float('reg_alpha', 0, 1),
        'reg_lambda'      : trial.suggest_float('reg_lambda', 0, 1),
        'lags'            : trial.suggest_categorical('lags', lags_grid)
    }

results_search, frozen_trial = bayesian_search_forecaster(
    forecaster   = forecaster,
    y            = datos.loc[:fin_validacion, 'users'],
    exog         = datos.loc[:fin_validacion, exog_cols],
    cv           = cv_search,
    search_space = search_space,
    metric       = 'mean_absolute_error',
    n_trials     = 20
)
╭─────────────────────────── OneStepAheadValidationWarning ────────────────────────────╮
│ One-step-ahead predictions are used for faster model comparison, but they may not    │
│ fully represent multi-step prediction performance. It is recommended to backtest the │
│ final model for a more accurate multi-step performance estimate.                     │
│                                                                                      │
│ Category : skforecast.exceptions.OneStepAheadValidationWarning                       │
│ Location :                                                                           │
│ C:\Users\Joaquin\miniconda3\envs\skforecast_24_py13\Lib\site-packages\skforecast\mod │
│ el_selection\_utils.py:650                                                           │
│ Suppress : warnings.simplefilter('ignore', category=OneStepAheadValidationWarning)   │
╰──────────────────────────────────────────────────────────────────────────────────────╯
# Backtesting con datos de test
# ==============================================================================
metrica_xgboost, predicciones = backtesting_forecaster(
    forecaster = forecaster,
    y          = datos['users'],
    exog       = datos[exog_cols],
    cv         = cv_backtesting,
    metric     = 'mean_absolute_error'
)
metrica_xgboost
mean_absolute_error
0 50.983486

HistGradientBoostingRegressor

# Crear forecaster
# ==============================================================================
forecaster = ForecasterRecursive(
    estimator            = HistGradientBoostingRegressor(random_state=123),
    lags                 = 24,
    window_features      = window_features,
    categorical_features = 'auto'
)
# Búsqueda de hiperparámetros
# ==============================================================================
# Se reutiliza el grid de lags (`lags_grid`) definido en la búsqueda de LightGBM
# Espacio de búsqueda de hiperparámetros
def search_space(trial):
    return {
        'max_iter'          : trial.suggest_int('max_iter', 300, 1000, step=100),
        'max_depth'         : trial.suggest_int('max_depth', 3, 10),
        'learning_rate'     : trial.suggest_float('learning_rate', 0.01, 1),
        'min_samples_leaf'  : trial.suggest_int('min_samples_leaf', 1, 20),
        'l2_regularization' : trial.suggest_float('l2_regularization', 0, 1),
        'lags'              : trial.suggest_categorical('lags', lags_grid)
    }

results_search, frozen_trial = bayesian_search_forecaster(
    forecaster    = forecaster,
    y             = datos.loc[:fin_validacion, 'users'],
    exog          = datos.loc[:fin_validacion, exog_cols],
    cv            = cv_search,
    search_space  = search_space,
    metric        = 'mean_absolute_error',
    n_trials      = 20
)
╭─────────────────────────── OneStepAheadValidationWarning ────────────────────────────╮
│ One-step-ahead predictions are used for faster model comparison, but they may not    │
│ fully represent multi-step prediction performance. It is recommended to backtest the │
│ final model for a more accurate multi-step performance estimate.                     │
│                                                                                      │
│ Category : skforecast.exceptions.OneStepAheadValidationWarning                       │
│ Location :                                                                           │
│ C:\Users\Joaquin\miniconda3\envs\skforecast_24_py13\Lib\site-packages\skforecast\mod │
│ el_selection\_utils.py:650                                                           │
│ Suppress : warnings.simplefilter('ignore', category=OneStepAheadValidationWarning)   │
╰──────────────────────────────────────────────────────────────────────────────────────╯
# Backtesting con datos de test
# ==============================================================================
metrica_histgb, predicciones = backtesting_forecaster(
    forecaster = forecaster,
    y          = datos['users'],
    exog       = datos[exog_cols],
    cv         = cv_backtesting,
    metric     = 'mean_absolute_error'
)
metrica_histgb
mean_absolute_error
0 49.546575

CatBoost

# Crear forecaster
# ==============================================================================
forecaster = ForecasterRecursive(
    estimator            = CatBoostRegressor(
                                random_state=123,
                                silent=True,
                                allow_writing_files=False,
                                boosting_type = 'Plain',         # Faster training
                                leaf_estimation_iterations = 3,  # Faster training
                                one_hot_max_size = 10,           # Faster training
                            ),
    lags                 = 24,
    window_features      = window_features,
    categorical_features = 'auto'
)
# Búsqueda de hiperparámetros
# ==============================================================================
# Se reutiliza el grid de lags (`lags_grid`) definido en la búsqueda de LightGBM
# Espacio de búsqueda de hiperparámetros
def search_space(trial):
    return {
        'n_estimators'  : trial.suggest_int('n_estimators', 100, 1000, step=100),
        'max_depth'     : trial.suggest_int('max_depth', 3, 10),
        'learning_rate' : trial.suggest_float('learning_rate', 0.01, 1),
        'lags'          : trial.suggest_categorical('lags', lags_grid)
    }

results_search, frozen_trial = bayesian_search_forecaster(
    forecaster    = forecaster,
    y             = datos.loc[:fin_validacion, 'users'],
    exog          = datos.loc[:fin_validacion, exog_cols],
    cv            = cv_search,
    search_space  = search_space,
    metric        = 'mean_absolute_error',
    n_trials      = 20
)
╭─────────────────────────── OneStepAheadValidationWarning ────────────────────────────╮
│ One-step-ahead predictions are used for faster model comparison, but they may not    │
│ fully represent multi-step prediction performance. It is recommended to backtest the │
│ final model for a more accurate multi-step performance estimate.                     │
│                                                                                      │
│ Category : skforecast.exceptions.OneStepAheadValidationWarning                       │
│ Location :                                                                           │
│ C:\Users\Joaquin\miniconda3\envs\skforecast_24_py13\Lib\site-packages\skforecast\mod │
│ el_selection\_utils.py:650                                                           │
│ Suppress : warnings.simplefilter('ignore', category=OneStepAheadValidationWarning)   │
╰──────────────────────────────────────────────────────────────────────────────────────╯
# Backtesting con datos de test
# ==============================================================================
metrica_catboost, predicciones = backtesting_forecaster(
    forecaster = forecaster,
    y          = datos['users'],
    exog       = datos[exog_cols],
    cv         = cv_backtesting,
    metric     = 'mean_absolute_error'
)
metrica_catboost
mean_absolute_error
0 44.507595

Conclusión

# Comparación del MAE en test
# ==============================================================================
metricas = pd.concat(
    [metrica_baseline, metrica_lgbm, metrica_xgboost, metrica_histgb, metrica_catboost]
)
metricas.index = [
    'Baseline',
    'LGBMRegressor',
    'XGBRegressor',
    'HistGradientBoostingRegressor',
    'CatBoostRegressor',
]
metricas.round(2).sort_values(by='mean_absolute_error')
mean_absolute_error
CatBoostRegressor 44.51
HistGradientBoostingRegressor 49.55
LGBMRegressor 49.62
XGBRegressor 50.98
Baseline 91.67
  • Utilizar modelos Gradient Boosting en problemas de forecasting es muy sencillo gracias a las funcionalidades ofrecidas por skforecast.

  • Todos los modelos de gradient boosting superan claramente al modelo de referencia (MAE de 91.7). CatBoost consigue el menor error en test (44.5), seguido de HistGradientBoostingRegressor (49.5), LightGBM (49.6) y XGBoost (51.0). Las diferencias entre implementaciones son pequeñas en comparación con la ganancia obtenida al añadir variables exógenas.

  • Como se ha mostrado en este documento, la incorporación de variables exógenas como predictores puede mejorar en gran medida la capacidad predictiva del modelo (el MAE pasa de 70.4 a 49.4 con la misma configuración de LightGBM).

  • Las variables categóricas pueden incluirse fácilmente como variables exógenas sin necesidad de preprocesamiento. Esto es posible gracias al soporte nativo para variables categóricas ofrecido por LightGBM, XGBoost, CatBoost y HistGradientBoostingRegressor.

  • La comparación entre implementaciones es solo ilustrativa: los hiperparámetros de LightGBM se han optimizado mediante backtesting y las variables exógenas se han seleccionado con un modelo LightGBM, mientras que los otros estimadores se han optimizado con validación one-step-ahead y reutilizan ese subconjunto de variables. En todos los casos la búsqueda se ha limitado a 20 iteraciones.

✏️ Note

Para ilustrar el proceso, la búsqueda de hiperparámetros se ha mantenido reducida. Sin embargo, para obtener resultados óptimos, puede ser necesario realizar una búsqueda más amplia para cada modelo.

Información de sesión

import session_info
session_info.show(html=False)
-----
astral              3.2
catboost            1.2.10
feature_engine      1.9.4
lightgbm            4.7.0
matplotlib          3.10.9
numpy               2.4.6
optuna              4.9.0
pandas              2.3.3
plotly              6.9.0
session_info        v1.0.1
shap                0.52.0
skforecast          0.25.0
sklearn             1.7.2
statsmodels         0.14.6
xgboost             3.4.0
-----
IPython             9.15.0
jupyter_client      8.9.1
jupyter_core        5.9.1
-----
Python 3.13.14 | packaged by conda-forge | (main, Jun 12 2026, 09:44:26) [MSC v.1944 64 bit (AMD64)]
Windows-11-10.0.26200-SP0
-----
Session information updated at 2026-09-22 09:31

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 series temporales con gradient boosting: Skforecast, XGBoost, LightGBM y CatBoost por Joaquín Amat Rodrigo y Javier Escobar Ortiz, disponible bajo licencia Attribution-NonCommercial-ShareAlike 4.0 International en https://www.cienciadedatos.net/documentos/py39-forecasting-series-temporales-con-skforecast-xgboost-lightgbm-catboost.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.25.0). Zenodo. https://doi.org/10.5281/zenodo.8382788

APA:

Amat Rodrigo, J., & Escobar Ortiz, J. (2026). skforecast (Version 0.25.0) [Computer software]. https://doi.org/10.5281/zenodo.8382788

BibTeX:

@software{skforecast, author = {Amat Rodrigo, Joaquin and Escobar Ortiz, Javier}, title = {skforecast}, version = {0.25.0}, month = {09}, year = {2026}, license = {BSD-3-Clause}, url = {https://skforecast.org/}, doi = {10.5281/zenodo.8382788} }

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

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

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.