Más sobre forecasting en: cienciadedatos.net
- Forecasting series temporales con machine learning
- Modelos ARIMA y SARIMAX
- Forecasting series temporales con gradient boosting: XGBoost, LightGBM y CatBoost
- Modelos de forecasting globales: modelado de múltiples series temporales con machine learning
- Forecasting de la demanda eléctrica con machine learning
- Forecasting con deep learning
- Forecasting de visitas a página web con machine learning
- Forecasting del precio de Bitcoin
- Forecasting probabilístico
- Forecasting de demanda intermitente
- Reducir el impacto del Covid en modelos de forecasting
- Modelar series temporales con tendencia utilizando modelos de árboles
Modelos de forecasting globales¶
En los modelos de series individuales (Local Forecasting Model), una única serie temporal se modela como una combinación lineal o no lineal de sus valores pasados y, opcionalmente, variables exógenas. Aunque este método proporciona una comprensión exhaustiva de cada serie, su escalabilidad puede verse comprometida cuando se dispone de un gran número de series.
Los modelos globales multiserie (Global Forecasting Model) consisten en un único modelo que tiene en cuenta varias series temporales de forma simultánea. Intentan captar los patrones comunes que rigen las series, mitigando así el ruido potencial que pueda introducir cada serie. Este enfoque requiere un único modelo independientemente del número de series, es fácil de mantener y puede conseguir mayor generalización, aunque potencialmente a costa de sacrificar cierta resolución individual. Se pueden distinguir dos estrategias de modelos de previsión global: Múltiples series temporales independientes y Múltiples series temporales dependientes.
Ventajas de los modelos multiseries
Es más fácil mantener y controlar un solo modelo que varios.
Dado que todas las series temporales se combinan durante el entrenamiento, cuando las series sean cortas (pocos datos) el modelo tendrá una mayor capacidad de aprendizaje al disponer de más observaciones.
Al combinar múltiples series temporales, el modelo puede aprender patrones más generalizables.
Desventajas de los modelos multiseries
Si las series no siguen la misma dinámica interna, el modelo puede aprender un patrón que no represente a ninguna de ellas.
Las series pueden enmascararse unas a otras, por lo que el modelo puede no predecirlas todas con el mismo rendimiento.
Es más exigente desde el punto de vista computacional (tiempo y recursos) entrenar y realizar backtesting de un modelo grande que de varios pequeños.
Múltiples series temporales independientes
En este escenario, cada serie temporal es independiente de las demás o, dicho de otro modo, los valores pasados de una serie no se utilizan como predictores de las otras series. ¿Por qué es útil entonces modelar todo junto? Aunque las series no dependen unas de otras, pueden seguir el mismo patrón intrínseco en cuanto a sus valores pasados y futuros. Por ejemplo, en una misma tienda, las ventas de los productos A y B pueden no estar relacionadas, pero siguen la misma dinámica, la de la tienda.

Para predecir los siguientes n steps, se sigue una estrategia recursiva, recursive multi-step forecasting.

Múltiples series temporales dependientes
Todas las series se modelan teniendo en cuenta que cada serie temporal depende no sólo de sus valores pasados, sino también de los valores pasados de las demás series. Se espera que el modelo no sólo aprenda la información de cada serie por separado, sino que también las relacione. Por ejemplo, las mediciones realizadas por todos los sensores (caudal, temperatura, presión...) instalados en una máquina industrial como un compresor. Series temporales multivariantes user guide.

💡 Tip
Este es el primero de una serie de documentos sobre modelos de forecasting globales:
- Modelos de forecasting globales: modelado de múltiples series temporales con machine learning
- Forecasting escalable: modelado de miles de series temporales con un único modelo global
- Modelos de forecasting globales: Análisis comparativo de modelos de una y múltiples series
- Modelos de forecasting globales: Guía paso a paso con Kaggle Sticker Sales
Series con la misma longitud¶
En este primer ejemplo, se utilizan múltiples series temporales de la misma longitud. El objetivo es comparar los resultados de un modelo global con los de un modelo individual para cada serie al predecir los próximos 7 días de ventas para 50 productos diferentes en una tienda, utilizando los 5 años de historial disponible. Los datos se han obtenido del Store Item Demand Forecasting Challenge. Este conjunto de datos contiene 913,000 transacciones de ventas desde el 01/01/2013 hasta el 31/12/2017 para 50 productos (SKU) en 10 tiendas.
Librerías¶
# Manipulación de datos
# ==============================================================================
import numpy as np
import pandas as pd
# Gráficos
# ==============================================================================
import matplotlib.pyplot as plt
from skforecast.plot import set_dark_theme
from tqdm.notebook import tqdm
# Modelado y Forecasting
# ==============================================================================
import sklearn
import skforecast
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.preprocessing import StandardScaler
from skforecast.recursive import ForecasterRecursive, ForecasterRecursiveMultiSeries
from skforecast.model_selection import (
TimeSeriesFold,
OneStepAheadFold,
backtesting_forecaster,
bayesian_search_forecaster,
backtesting_forecaster_multiseries,
bayesian_search_forecaster_multiseries
)
from skforecast.preprocessing import (
RollingFeatures,
reshape_series_long_to_dict,
reshape_exog_long_to_dict
)
from skforecast.exceptions import OneStepAheadValidationWarning
# 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 pandas: {pd.__version__}')
print(f'{color}Versión numpy: {np.__version__}')
Versión skforecast: 0.27.0 Versión scikit-learn: 1.9.1 Versión pandas: 2.3.3 Versión numpy: 2.5.4
Datos¶
# Lectura de datos
# ======================================================================================
data = pd.read_csv('./train_stores_kaggle.csv')
display(data.head())
print(f'Shape: {data.shape}')
| date | store | item | sales | |
|---|---|---|---|---|
| 0 | 2013-01-01 | 1 | 1 | 13 |
| 1 | 2013-01-02 | 1 | 1 | 11 |
| 2 | 2013-01-03 | 1 | 1 | 14 |
| 3 | 2013-01-04 | 1 | 1 | 13 |
| 4 | 2013-01-05 | 1 | 1 | 10 |
Shape: (913000, 4)
# Preparación datos
# ======================================================================================
selected_store = 2 # Seleccionar una tienda
# Se seleccionan todos los items, modificar `selected_items` para usar un subconjunto
selected_items = data.item.unique()
data = data[
(data['store'] == selected_store) & (data['item'].isin(selected_items))
].copy()
data['date'] = pd.to_datetime(data['date'], format='%Y-%m-%d')
data = pd.pivot_table(
data = data,
values = 'sales',
index = 'date',
columns = 'item'
)
data.columns.name = None
data.columns = [f'item_{col}' for col in data.columns]
data = data.asfreq('1D')
data.head(4)
| item_1 | item_2 | item_3 | item_4 | item_5 | item_6 | item_7 | item_8 | item_9 | item_10 | ... | item_41 | item_42 | item_43 | item_44 | item_45 | item_46 | item_47 | item_48 | item_49 | item_50 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| date | |||||||||||||||||||||
| 2013-01-01 | 12.0 | 41.0 | 19.0 | 21.0 | 4.0 | 34.0 | 39.0 | 49.0 | 28.0 | 51.0 | ... | 11.0 | 25.0 | 36.0 | 12.0 | 45.0 | 43.0 | 12.0 | 45.0 | 29.0 | 43.0 |
| 2013-01-02 | 16.0 | 33.0 | 32.0 | 14.0 | 6.0 | 40.0 | 47.0 | 42.0 | 21.0 | 56.0 | ... | 19.0 | 21.0 | 35.0 | 25.0 | 50.0 | 52.0 | 13.0 | 37.0 | 25.0 | 57.0 |
| 2013-01-03 | 16.0 | 46.0 | 26.0 | 12.0 | 12.0 | 41.0 | 43.0 | 46.0 | 29.0 | 46.0 | ... | 23.0 | 20.0 | 52.0 | 18.0 | 56.0 | 30.0 | 5.0 | 45.0 | 30.0 | 45.0 |
| 2013-01-04 | 20.0 | 50.0 | 34.0 | 17.0 | 16.0 | 41.0 | 44.0 | 55.0 | 32.0 | 56.0 | ... | 15.0 | 28.0 | 50.0 | 24.0 | 57.0 | 46.0 | 19.0 | 32.0 | 20.0 | 45.0 |
4 rows × 50 columns
El dataset se divide en 3 particiones: una para el entrenamiento, otra para la validación y otra para test.
# Separación datos train-validation-test
# ======================================================================================
end_train = '2016-05-31 23:59:00'
end_val = '2017-05-31 23:59:00'
data_train = data.loc[:end_train, :].copy()
data_val = data.loc[end_train:end_val, :].copy()
data_test = data.loc[end_val:, :].copy()
print(
f'Fechas train : {data_train.index.min()} --- '
f'{data_train.index.max()} (n={len(data_train)})'
)
print(
f'Fechas validación : {data_val.index.min()} --- '
f'{data_val.index.max()} (n={len(data_val)})'
)
print(
f'Fechas test : {data_test.index.min()} --- '
f'{data_test.index.max()} (n={len(data_test)})'
)
Fechas train : 2013-01-01 00:00:00 --- 2016-05-31 00:00:00 (n=1247) Fechas validación : 2016-06-01 00:00:00 --- 2017-05-31 00:00:00 (n=365) Fechas test : 2017-06-01 00:00:00 --- 2017-12-31 00:00:00 (n=214)
Se representan cuatro de las series para comprender sus tendencias y patrones. Se recomienda al lector que grafique más de ellas para comprenderlas en profundidad.
# Gráfico series temporales
# ======================================================================================
set_dark_theme()
fig, axs = plt.subplots(4, 1, figsize=(7, 5), sharex=True)
data.iloc[:, :4].plot(
legend = True,
subplots = True,
title = f'Ventas de la tienda {selected_store}',
ax = axs,
linewidth = 1
)
for ax in axs:
ax.axvline(pd.to_datetime(end_train), color='white', linestyle='--', linewidth=1.5)
ax.axvline(pd.to_datetime(end_val), color='white', linestyle='--', linewidth=1.5)
fig.tight_layout()
plt.show()
Forecasting individual para cada item¶
Se entrena un modelo Gradient Boosting Machine (GBM) para cada producto, utilizando las ventas de los últimos 14 días, así como las ventas promedio, máxima y mínima de los últimos 7 días (RollingFeatures) como predictores. El rendimiento del modelo para los siguientes 7 días se evalúa mediante backtesting utilizando el Error Absoluto Medio (MAE) como métrica de evaluación. Las particiones se definen con TimeSeriesFold y la evaluación se realiza con backtesting_forecaster. Finalmente, se compara el rendimiento de estos modelos individuales con el de un modelo global entrenado con todas las series.
# Entrenar y realizar backtesting de un modelo para cada item
# ======================================================================================
items = []
mae_values = []
cv = TimeSeriesFold(
steps = 7,
initial_train_size = len(data_train) + len(data_val),
refit = False,
)
for item in tqdm(data.columns):
# Definir el forecaster
window_features = RollingFeatures(stats=['mean', 'min', 'max'], window_sizes=7)
forecaster = ForecasterRecursive(
estimator = HistGradientBoostingRegressor(random_state=8523),
lags = 14,
window_features = window_features
)
# Backtesting forecaster
metric, _ = backtesting_forecaster(
forecaster = forecaster,
y = data[item],
cv = cv,
metric = 'mean_absolute_error',
show_progress = False
)
items.append(item)
mae_values.append(metric.at[0, 'mean_absolute_error'])
# Resultados
uni_series_mae = pd.Series(
data = mae_values,
index = items,
name = 'uni_series_mae'
)
uni_series_mae.head()
item_1 6.004406 item_2 10.104921 item_3 8.678198 item_4 5.528955 item_5 5.096925 Name: uni_series_mae, dtype: float64
Modelo global¶
Se entrena un modelo global (ForecasterRecursiveMultiSeries) con las mismas características que los modelos individuales. Su rendimiento se evalúa con el mismo proceso de backtesting, esta vez con backtesting_forecaster_multiseries, y se compara con el de los modelos individuales. Dado que las series se pasan como un DataFrame, skforecast muestra un InputTypeWarning indicando que un diccionario de pandas.Series es un formato de entrada más eficiente; este formato se describe en la sección Series de diferente longitud y distintas variables exógenas.
# Entrenamiento y backtesting con un único modelo para todos los items
# ======================================================================================
# Definir el forecaster
estimator = HistGradientBoostingRegressor(random_state=8523)
window_features = RollingFeatures(stats=['mean', 'min', 'max'], window_sizes=7)
forecaster_ms = ForecasterRecursiveMultiSeries(
estimator = estimator,
lags = 14,
encoding = 'ordinal',
transformer_series = StandardScaler(),
window_features = window_features,
)
# Backtesting forecaster para todos los items
cv = TimeSeriesFold(
steps = 7,
initial_train_size = len(data_train) + len(data_val),
refit = False,
)
multi_series_mae, predictions_ms = backtesting_forecaster_multiseries(
forecaster = forecaster_ms,
series = data,
cv = cv,
metric = 'mean_absolute_error',
add_aggregated_metric = False,
)
# Resultados
display(multi_series_mae.head(3))
print('')
display(predictions_ms.head(3))
╭────────────────────────────────── InputTypeWarning ──────────────────────────────────╮ │ Passing a DataFrame (either wide or long format) as `series` requires additional │ │ internal transformations, which can increase computational time. It is recommended │ │ to use a dictionary of pandas Series instead. For more details, see: │ │ https://skforecast.org/latest/user_guides/independent-multi-time-series-forecasting. │ │ html#input-data │ │ │ │ Category : skforecast.exceptions.InputTypeWarning │ │ Location : │ │ C:\Users\Joaquin\miniconda3\envs\skforecast_27_py13\Lib\site-packages\skforecast\uti │ │ ls\utils.py:4425 │ │ Suppress : warnings.simplefilter('ignore', category=InputTypeWarning) │ ╰──────────────────────────────────────────────────────────────────────────────────────╯
| levels | mean_absolute_error | n_evaluated | |
|---|---|---|---|
| 0 | item_1 | 5.512613 | 214 |
| 1 | item_2 | 9.218515 | 214 |
| 2 | item_3 | 7.310630 | 214 |
| level | fold | step | y_true | pred | |
|---|---|---|---|---|---|
| 2017-06-01 | item_1 | 0 | 1 | 41.0 | 35.781356 |
| 2017-06-01 | item_2 | 0 | 1 | 116.0 | 93.461083 |
| 2017-06-01 | item_3 | 0 | 1 | 70.0 | 62.066491 |
Comparación¶
# Diferencia de la métrica de backtesting para cada item
# ======================================================================================
multi_series_mae = (
multi_series_mae.set_index('levels')['mean_absolute_error']
.rename('multi_series_mae')
)
results = pd.concat((uni_series_mae, multi_series_mae), axis=1)
results['improvement'] = results['uni_series_mae'] - results['multi_series_mae']
results['improvement_(%)'] = (
100 * results['improvement'] / results['uni_series_mae']
)
results = results.round(2)
results.style.bar(subset=['improvement_(%)'], align='mid', color=['#d65f5f', '#5fba7d'])
| uni_series_mae | multi_series_mae | improvement | improvement_(%) | |
|---|---|---|---|---|
| item_1 | 6.000000 | 5.510000 | 0.490000 | 8.190000 |
| item_2 | 10.100000 | 9.220000 | 0.890000 | 8.770000 |
| item_3 | 8.680000 | 7.310000 | 1.370000 | 15.760000 |
| item_4 | 5.530000 | 5.010000 | 0.520000 | 9.390000 |
| item_5 | 5.100000 | 4.660000 | 0.440000 | 8.620000 |
| item_6 | 10.930000 | 9.760000 | 1.170000 | 10.730000 |
| item_7 | 10.240000 | 9.860000 | 0.380000 | 3.740000 |
| item_8 | 11.930000 | 10.540000 | 1.390000 | 11.620000 |
| item_9 | 9.410000 | 8.730000 | 0.680000 | 7.270000 |
| item_10 | 11.680000 | 10.450000 | 1.230000 | 10.520000 |
| item_11 | 11.140000 | 10.390000 | 0.750000 | 6.740000 |
| item_12 | 11.750000 | 10.790000 | 0.960000 | 8.170000 |
| item_13 | 11.970000 | 11.320000 | 0.650000 | 5.420000 |
| item_14 | 10.200000 | 9.490000 | 0.710000 | 6.960000 |
| item_15 | 12.620000 | 11.510000 | 1.100000 | 8.750000 |
| item_16 | 6.000000 | 5.870000 | 0.140000 | 2.260000 |
| item_17 | 7.610000 | 7.160000 | 0.460000 | 5.980000 |
| item_18 | 12.620000 | 12.000000 | 0.620000 | 4.880000 |
| item_19 | 7.810000 | 7.320000 | 0.490000 | 6.290000 |
| item_20 | 8.510000 | 7.740000 | 0.770000 | 9.030000 |
| item_21 | 8.530000 | 8.170000 | 0.370000 | 4.300000 |
| item_22 | 11.550000 | 10.700000 | 0.860000 | 7.430000 |
| item_23 | 7.490000 | 6.810000 | 0.670000 | 9.010000 |
| item_24 | 10.240000 | 9.820000 | 0.430000 | 4.150000 |
| item_25 | 12.550000 | 11.650000 | 0.900000 | 7.160000 |
| item_26 | 9.350000 | 8.590000 | 0.760000 | 8.160000 |
| item_27 | 5.480000 | 5.250000 | 0.230000 | 4.140000 |
| item_28 | 12.850000 | 11.710000 | 1.140000 | 8.900000 |
| item_29 | 11.150000 | 10.240000 | 0.910000 | 8.140000 |
| item_30 | 8.400000 | 7.850000 | 0.550000 | 6.560000 |
| item_31 | 10.630000 | 10.230000 | 0.400000 | 3.790000 |
| item_32 | 9.900000 | 9.130000 | 0.770000 | 7.800000 |
| item_33 | 9.820000 | 9.130000 | 0.690000 | 7.050000 |
| item_34 | 6.340000 | 5.830000 | 0.510000 | 7.990000 |
| item_35 | 11.040000 | 10.160000 | 0.880000 | 8.000000 |
| item_36 | 11.920000 | 10.480000 | 1.440000 | 12.080000 |
| item_37 | 6.510000 | 6.070000 | 0.430000 | 6.680000 |
| item_38 | 11.750000 | 11.190000 | 0.550000 | 4.720000 |
| item_39 | 8.240000 | 7.260000 | 0.980000 | 11.860000 |
| item_40 | 7.100000 | 6.620000 | 0.480000 | 6.740000 |
| item_41 | 5.670000 | 5.250000 | 0.420000 | 7.330000 |
| item_42 | 7.470000 | 7.040000 | 0.430000 | 5.770000 |
| item_43 | 8.470000 | 8.590000 | -0.120000 | -1.430000 |
| item_44 | 6.980000 | 6.420000 | 0.560000 | 7.960000 |
| item_45 | 12.490000 | 11.720000 | 0.770000 | 6.200000 |
| item_46 | 10.390000 | 9.770000 | 0.620000 | 5.950000 |
| item_47 | 5.500000 | 4.990000 | 0.510000 | 9.320000 |
| item_48 | 9.440000 | 8.320000 | 1.110000 | 11.780000 |
| item_49 | 6.300000 | 5.910000 | 0.390000 | 6.200000 |
| item_50 | 11.920000 | 10.690000 | 1.230000 | 10.300000 |
# Mejora media de todos los items
# ======================================================================================
results[['improvement', 'improvement_(%)']].agg(['mean', 'min', 'max'])
| improvement | improvement_(%) | |
|---|---|---|
| mean | 0.7016 | 7.4626 |
| min | -0.1200 | -1.4300 |
| max | 1.4400 | 15.7600 |
# Número de series con mejora positiva y negativa
# ======================================================================================
pd.Series(
np.where(results['improvement_(%)'] < 0, 'negative', 'positive')
).value_counts()
positive 49 negative 1 Name: count, dtype: int64
El modelo global logra una mejora media de aproximadamente el 7.5% en comparación con el uso de un modelo individual para cada serie. Para 49 de las 50 series, el error de predicción evaluado mediante backtesting es menor cuando se utiliza el modelo global, y en la restante es solo un 1.4% mayor. Este caso de uso demuestra que un modelo multiserie puede tener ventajas sobre varios modelos individuales a la hora de predecir series temporales que siguen una dinámica similar. Además de las mejoras potenciales en la predicción, también es importante tener en cuenta el beneficio de tener un único modelo que mantener.
⚠️ Advertencia
Esta comparación se ha realizado sin optimizar los hiperparámetros del modelo. Consulte la sección Ajuste de hiperparámetros y selección de lags para comprobar que las conclusiones se mantienen cuando los modelos se ajustan con la mejor combinación de hiperparámetros y lags.
Series de diferente longitud y distintas variables exógenas¶
En escenarios en los que se tienen que modelar múltiples series, es habitual que las series tengan longitudes distintas debido a diferencias en los momentos de inicio del registro de los datos. Para hacer frente a este escenario, la clase ForecasterRecursiveMultiSeries permite modelizar simultáneamente series temporales de distintas longitudes y con distintas variables exógenas. En estos casos, los datos deben estructurarse en una de las siguientes formas:
En un
diccionariodepandas.Series: las claves del diccionario son los nombres de las series y los valores son las propias series. Todas las series deben ser del tipopandas.Series, tener un índiceDatetimeIndexy la misma frecuencia.En un
pandas.DataFrameconMultiIndex: el primer nivel del índice contiene el nombre de la serie y el segundo nivel es el índice temporal. El segundo nivel debe ser unDatetimeIndexy todas las series deben tener la misma frecuencia.
| Series values | Permitido |
|---|---|
[NaN, NaN, NaN, NaN, 4, 5, 6, 7, 8, 9] |
✔️ |
[0, 1, 2, 3, 4, 5, 6, 7, 8, NaN] |
✔️ |
[0, 1, 2, 3, 4, NaN, 6, 7, 8, 9] |
✔️ |
[NaN, NaN, 2, 3, 4, NaN, 6, 7, 8, 9] |
✔️ |
Cuando se utilizan variables exógenas diferentes para cada serie o cuando las variables exógenas son las mismas pero tienen valores diferentes para cada serie, deben utilizarse alguna de las siguientes opciones:
En un diccionario: Las claves del diccionario son los nombres de las series y los valores son las propias variables exógenas. Todas las variables exógenas deben ser de tipo
pandas.DataFrameopandas.Series.En un
pandas.DataFrameconMultiIndex: el primer nivel del índice debe contener el nombre de la serie y el segundo nivel es el índice temporal. Todas las variables exógenas deben ser del tipopandas.DataFrameopandas.Series.
Si los datos no siguen estas estructuras, skforecast proporciona dos funciones para transformar los datos: reshape_series_long_to_dict y reshape_exog_long_to_dict.
💡 Consejo
En términos de rendimiento, el uso de un diccionario es más eficiente que un DataFrame de pandas, ya sea en formato ancho o largo, especialmente para conjuntos de datos más grandes. Esto se debe a que los diccionarios permiten un acceso y manipulación más rápidos de series temporales individuales, sin la sobrecarga estructural asociada a los DataFrames.
Datos¶
Los datos de este ejemplo son cinco series temporales diarias de demostración (id_1000 a id_1004) de distinta longitud, junto con variables exógenas de calendario (día de la semana) y meteorológicas (temperatura del aire y velocidad del viento). Los datos están almacenados en "formato largo" en un único DataFrame. La columna series_id identifica la serie a la que pertenece cada observación. La columna timestamp contiene la fecha de la observación, y la columna value contiene el valor de la serie en esa fecha. Cada serie temporal tiene una longitud diferente.
Las variables exógenas se almacenan en un DataFrame separado, también en "formato largo". La columna series_id identifica la serie a la que pertenece cada observación. La columna timestamp contiene la fecha de la observación, y las columnas restantes contienen los valores de las variables exógenas en esa fecha.
# Lectura series con diferentes longitudes y variables exógenas
# ==============================================================================
series = pd.read_csv(
'https://raw.githubusercontent.com/skforecast/skforecast-datasets/main/data/demo_multi_series.csv'
)
exog = pd.read_csv(
'https://raw.githubusercontent.com/skforecast/skforecast-datasets/main/data/demo_multi_series_exog.csv'
)
series['timestamp'] = pd.to_datetime(series['timestamp'])
exog['timestamp'] = pd.to_datetime(exog['timestamp'])
display(series.head())
display(exog.head())
| series_id | timestamp | value | |
|---|---|---|---|
| 0 | id_1000 | 2016-01-01 | 1012.500694 |
| 1 | id_1000 | 2016-01-02 | 1158.500099 |
| 2 | id_1000 | 2016-01-03 | 983.000099 |
| 3 | id_1000 | 2016-01-04 | 1675.750496 |
| 4 | id_1000 | 2016-01-05 | 1586.250694 |
| series_id | timestamp | sin_day_of_week | cos_day_of_week | air_temperature | wind_speed | |
|---|---|---|---|---|---|---|
| 0 | id_1000 | 2016-01-01 | -0.433884 | -0.900969 | 6.416639 | 4.040115 |
| 1 | id_1000 | 2016-01-02 | -0.974928 | -0.222521 | 6.366474 | 4.530395 |
| 2 | id_1000 | 2016-01-03 | -0.781831 | 0.623490 | 6.555272 | 3.273064 |
| 3 | id_1000 | 2016-01-04 | 0.000000 | 1.000000 | 6.704778 | 4.865404 |
| 4 | id_1000 | 2016-01-05 | 0.781831 | 0.623490 | 2.392998 | 5.228913 |
Cuando las series tienen longitudes diferentes, se recomienda transformar los datos en un diccionario (también se admite un DataFrame en formato largo con MultiIndex). Las claves del diccionario son los nombres de las series y los valores son las propias series. Para ello, se utiliza la función reshape_series_long_to_dict, que toma el DataFrame en "formato largo" y devuelve un diccionario de series.
Del mismo modo, cuando las variables exógenas son diferentes (valores o variables) para cada serie, se recomienda almacenarlas en un diccionario. Las claves del diccionario son los nombres de las series y los valores son las propias variables exógenas. Se utiliza la función reshape_exog_long_to_dict, que toma el DataFrame en "formato largo" y devuelve un diccionario de variables exógenas.
# Transformar series y exog a diccionarios
# ==============================================================================
series_dict = reshape_series_long_to_dict(
data = series,
series_id = 'series_id',
index = 'timestamp',
values = 'value',
freq = 'D'
)
exog_dict = reshape_exog_long_to_dict(
data = exog,
series_id = 'series_id',
index = 'timestamp',
freq = 'D'
)
╭──────────────────────────────── MissingValuesWarning ────────────────────────────────╮ │ Series 'id_1003' is incomplete. NaNs have been introduced after setting the │ │ frequency. │ │ │ │ Category : skforecast.exceptions.MissingValuesWarning │ │ Location : │ │ C:\Users\Joaquin\miniconda3\envs\skforecast_27_py13\Lib\site-packages\skforecast\pre │ │ processing\_preprocessing.py:592 │ │ Suppress : warnings.simplefilter('ignore', category=MissingValuesWarning) │ ╰──────────────────────────────────────────────────────────────────────────────────────╯
El MissingValuesWarning indica que la serie 'id_1003' tiene huecos: las fechas que faltan se rellenan con NaN al establecer la frecuencia diaria (73 valores en el conjunto de entrenamiento y 73 en el de test, ver la descripción de cada serie más abajo). Las filas cuyo valor objetivo es NaN siempre se eliminan de las matrices de entrenamiento. El argumento dropna_from_series controla el resto: con True, también se elimina toda fila con al menos un NaN entre los predictores; con False (el valor utilizado en este ejemplo), esas filas se mantienen. En este ejemplo es relevante porque a las series 'id_1000' y 'id_1003' les faltan algunas variables exógenas, por lo que todas sus filas tienen al menos un NaN y se descartarían con True. Mantenerlas es posible porque HistGradientBoostingRegressor admite valores faltantes.
Algunas variables exógenas se omiten en las series 'id_1000' y 'id_1003' para ilustrar que se pueden utilizar diferentes variables exógenas para cada serie.
# Eliminar algunas variables exógenas de las series 'id_1000' y 'id_1003'
# ==============================================================================
exog_dict['id_1000'] = exog_dict['id_1000'].drop(
columns=['air_temperature', 'wind_speed']
)
exog_dict['id_1003'] = exog_dict['id_1003'].drop(columns=['cos_day_of_week'])
# Particiones de entrenamiento y test
# ==============================================================================
end_train = '2016-07-31 23:59:00'
series_dict_train = {k: v.loc[:end_train] for k, v in series_dict.items()}
exog_dict_train = {k: v.loc[:end_train] for k, v in exog_dict.items()}
series_dict_test = {k: v.loc[end_train:] for k, v in series_dict.items()}
exog_dict_test = {k: v.loc[end_train:] for k, v in exog_dict.items()}
# Gráfico series
# ==============================================================================
colors = plt.rcParams['axes.prop_cycle'].by_key()['color']
fig, axs = plt.subplots(5, 1, figsize=(8, 4), sharex=True)
for i, s in enumerate(series_dict.values()):
axs[i].plot(s, label=s.name, color=colors[i])
axs[i].legend(loc='upper right', fontsize=8)
axs[i].tick_params(axis='both', labelsize=8)
axs[i].axvline(
pd.to_datetime(end_train), color='white', linestyle='--', linewidth=1
)
fig.tight_layout()
plt.show()
# Descripción de cada serie
# ==============================================================================
for k in series_dict.keys():
print(f'{k}:')
try:
print(
f'\tTrain: len={len(series_dict_train[k])}, {series_dict_train[k].index[0]}'
f' --- {series_dict_train[k].index[-1]} '
f' (missing={series_dict_train[k].isnull().sum()})'
)
except IndexError:
print('\tTrain: len=0')
try:
print(
f'\tTest : len={len(series_dict_test[k])}, {series_dict_test[k].index[0]}'
f' --- {series_dict_test[k].index[-1]} '
f' (missing={series_dict_test[k].isnull().sum()})'
)
except IndexError:
print('\tTest : len=0')
id_1000: Train: len=213, 2016-01-01 00:00:00 --- 2016-07-31 00:00:00 (missing=0) Test : len=153, 2016-08-01 00:00:00 --- 2016-12-31 00:00:00 (missing=0) id_1001: Train: len=30, 2016-07-02 00:00:00 --- 2016-07-31 00:00:00 (missing=0) Test : len=153, 2016-08-01 00:00:00 --- 2016-12-31 00:00:00 (missing=0) id_1002: Train: len=183, 2016-01-01 00:00:00 --- 2016-07-01 00:00:00 (missing=0) Test : len=0 id_1003: Train: len=213, 2016-01-01 00:00:00 --- 2016-07-31 00:00:00 (missing=73) Test : len=153, 2016-08-01 00:00:00 --- 2016-12-31 00:00:00 (missing=73) id_1004: Train: len=91, 2016-05-02 00:00:00 --- 2016-07-31 00:00:00 (missing=0) Test : len=31, 2016-08-01 00:00:00 --- 2016-08-31 00:00:00 (missing=0)
# Variables exógenas de cada serie
# ==============================================================================
for k in series_dict.keys():
print(f'{k}:')
print(f'\t{exog_dict[k].columns.to_list()}')
id_1000: ['sin_day_of_week', 'cos_day_of_week'] id_1001: ['sin_day_of_week', 'cos_day_of_week', 'air_temperature', 'wind_speed'] id_1002: ['sin_day_of_week', 'cos_day_of_week', 'air_temperature', 'wind_speed'] id_1003: ['sin_day_of_week', 'air_temperature', 'wind_speed'] id_1004: ['sin_day_of_week', 'cos_day_of_week', 'air_temperature', 'wind_speed']
Modelo global¶
# Fit forecaster
# ==============================================================================
estimator = HistGradientBoostingRegressor(random_state=123, max_depth=5)
window_features = RollingFeatures(stats=['mean', 'min', 'max'], window_sizes=7)
forecaster = ForecasterRecursiveMultiSeries(
estimator = estimator,
lags = 14,
window_features = window_features,
encoding = 'ordinal',
dropna_from_series = False
)
forecaster.fit(series=series_dict_train, exog=exog_dict_train, suppress_warnings=True)
forecaster
ForecasterRecursiveMultiSeries
General Information
- Estimator: HistGradientBoostingRegressor
- Lags: [ 1 2 3 4 5 6 7 8 9 10 11 12 13 14]
- Window features: ['roll_mean_7', 'roll_min_7', 'roll_max_7']
- Calendar features: None
- Window size: 14
- Series encoding: ordinal
- Exogenous included: True
- Categorical features: auto
- Weight function included: False
- Series weights: None
- Differentiation order: None
- Drop NaN from series: False
- Creation date: 2026-11-11 21:30:28
- Last fit date: 2026-11-11 21:30:28
- Skforecast version: 0.27.0
- Python version: 3.13.18
- Forecaster id: None
Exogenous Variables
sin_day_of_week, cos_day_of_week, air_temperature, wind_speed
Data Transformations
- Transformer for series: None
- Transformer for exog: None
Training Information
- Series names (levels): id_1000, id_1001, id_1002, id_1003, id_1004
- Training range: 'id_1000': ['2016-01-01', '2016-07-31'], 'id_1001': ['2016-07-02', '2016-07-31'], 'id_1002': ['2016-01-01', '2016-07-01'], 'id_1003': ['2016-01-01', '2016-07-31'], 'id_1004': ['2016-05-02', '2016-07-31']
- Training index type: DatetimeIndex
- Training index frequency: D
Estimator Parameters
-
{'categorical_features': 'from_dtype', 'early_stopping': 'auto', 'interaction_cst': None, 'l2_regularization': 0.0, 'learning_rate': 0.1, 'loss': 'squared_error', 'max_bins': 255, 'max_depth': 5, 'max_features': 1.0, 'max_iter': 100, 'max_leaf_nodes': 31, 'min_samples_leaf': 20, 'monotonic_cst': None, 'n_iter_no_change': 10, 'quantile': None, 'random_state': 123, 'scoring': 'loss', 'tol': 1e-07, 'validation_fraction': 0.1, 'verbose': 0, 'warm_start': False}
Fit Kwargs
-
{}
Sólo se pueden predecir juntas las series cuya última ventana de datos (last_window) termine en el mismo índice fecha-hora. Si levels = None, se excluyen de la predicción las series que no alcanzan el índice máximo. En este ejemplo, la serie 'id_1002' se excluye.
# Predict
# ==============================================================================
predicciones = forecaster.predict(steps=7, exog=exog_dict_test, suppress_warnings=True)
predicciones
| level | pred | |
|---|---|---|
| 2016-08-01 | id_1000 | 1434.796453 |
| 2016-08-01 | id_1001 | 3087.267824 |
| 2016-08-01 | id_1003 | 2713.304570 |
| 2016-08-01 | id_1004 | 7047.329003 |
| 2016-08-02 | id_1000 | 1460.038838 |
| 2016-08-02 | id_1001 | 3590.816304 |
| 2016-08-02 | id_1003 | 2282.892693 |
| 2016-08-02 | id_1004 | 7244.170674 |
| 2016-08-03 | id_1000 | 1397.298376 |
| 2016-08-03 | id_1001 | 3342.230060 |
| 2016-08-03 | id_1003 | 2040.577134 |
| 2016-08-03 | id_1004 | 8238.670449 |
| 2016-08-04 | id_1000 | 1364.350567 |
| 2016-08-04 | id_1001 | 3295.089642 |
| 2016-08-04 | id_1003 | 2047.429891 |
| 2016-08-04 | id_1004 | 9004.924781 |
| 2016-08-05 | id_1000 | 1315.912259 |
| 2016-08-05 | id_1001 | 3187.266550 |
| 2016-08-05 | id_1003 | 2125.307896 |
| 2016-08-05 | id_1004 | 8829.729109 |
| 2016-08-06 | id_1000 | 958.355479 |
| 2016-08-06 | id_1001 | 2675.879194 |
| 2016-08-06 | id_1003 | 2119.344835 |
| 2016-08-06 | id_1004 | 6614.262906 |
| 2016-08-07 | id_1000 | 958.530378 |
| 2016-08-07 | id_1001 | 2000.102709 |
| 2016-08-07 | id_1003 | 2730.302981 |
| 2016-08-07 | id_1004 | 5631.517923 |
Backtesting¶
Cuando las series tienen longitudes diferentes, una serie sólo se predice en las particiones (folds) en las que su última ventana está disponible. La serie 'id_1002' termina antes del periodo de test, por lo que no tiene predicciones y su métrica es NaN. La serie 'id_1004' termina el 2016-08-31, a mitad de un fold, por lo que sus predicciones continúan hasta el final de ese fold. Las fechas sin valores observados (incluidos los huecos de 'id_1003') se ignoran al calcular la métrica.
# Backtesting
# ==============================================================================
cv = TimeSeriesFold(
steps = 7,
initial_train_size = end_train,
refit = False,
)
metrics_levels, backtest_predictions = backtesting_forecaster_multiseries(
forecaster = forecaster,
series = series_dict,
exog = exog_dict,
cv = cv,
metric = 'mean_absolute_error',
add_aggregated_metric = False,
verbose = True,
suppress_warnings = True
)
display(metrics_levels)
display(backtest_predictions)
Information of folds
--------------------
Number of observations used for initial training: 213
Number of observations used for backtesting: 153
Number of folds: 22
Number skipped folds: 0
Number of steps per fold: 7
Number of steps to exclude between last observed data (last window) and predictions (gap): 0
Last fold only includes 6 observations.
Fold: 0
Training: 2016-01-01 00:00:00 -- 2016-07-31 00:00:00 (n=213)
Validation: 2016-08-01 00:00:00 -- 2016-08-07 00:00:00 (n=7)
Fold: 1
Training: No training in this fold
Validation: 2016-08-08 00:00:00 -- 2016-08-14 00:00:00 (n=7)
Fold: 2
Training: No training in this fold
Validation: 2016-08-15 00:00:00 -- 2016-08-21 00:00:00 (n=7)
Fold: 3
Training: No training in this fold
Validation: 2016-08-22 00:00:00 -- 2016-08-28 00:00:00 (n=7)
Fold: 4
Training: No training in this fold
Validation: 2016-08-29 00:00:00 -- 2016-09-04 00:00:00 (n=7)
Fold: 5
Training: No training in this fold
Validation: 2016-09-05 00:00:00 -- 2016-09-11 00:00:00 (n=7)
Fold: 6
Training: No training in this fold
Validation: 2016-09-12 00:00:00 -- 2016-09-18 00:00:00 (n=7)
Fold: 7
Training: No training in this fold
Validation: 2016-09-19 00:00:00 -- 2016-09-25 00:00:00 (n=7)
Fold: 8
Training: No training in this fold
Validation: 2016-09-26 00:00:00 -- 2016-10-02 00:00:00 (n=7)
Fold: 9
Training: No training in this fold
Validation: 2016-10-03 00:00:00 -- 2016-10-09 00:00:00 (n=7)
Fold: 10
Training: No training in this fold
Validation: 2016-10-10 00:00:00 -- 2016-10-16 00:00:00 (n=7)
Fold: 11
Training: No training in this fold
Validation: 2016-10-17 00:00:00 -- 2016-10-23 00:00:00 (n=7)
Fold: 12
Training: No training in this fold
Validation: 2016-10-24 00:00:00 -- 2016-10-30 00:00:00 (n=7)
Fold: 13
Training: No training in this fold
Validation: 2016-10-31 00:00:00 -- 2016-11-06 00:00:00 (n=7)
Fold: 14
Training: No training in this fold
Validation: 2016-11-07 00:00:00 -- 2016-11-13 00:00:00 (n=7)
Fold: 15
Training: No training in this fold
Validation: 2016-11-14 00:00:00 -- 2016-11-20 00:00:00 (n=7)
Fold: 16
Training: No training in this fold
Validation: 2016-11-21 00:00:00 -- 2016-11-27 00:00:00 (n=7)
Fold: 17
Training: No training in this fold
Validation: 2016-11-28 00:00:00 -- 2016-12-04 00:00:00 (n=7)
Fold: 18
Training: No training in this fold
Validation: 2016-12-05 00:00:00 -- 2016-12-11 00:00:00 (n=7)
Fold: 19
Training: No training in this fold
Validation: 2016-12-12 00:00:00 -- 2016-12-18 00:00:00 (n=7)
Fold: 20
Training: No training in this fold
Validation: 2016-12-19 00:00:00 -- 2016-12-25 00:00:00 (n=7)
Fold: 21
Training: No training in this fold
Validation: 2016-12-26 00:00:00 -- 2016-12-31 00:00:00 (n=6)
| levels | mean_absolute_error | n_evaluated | |
|---|---|---|---|
| 0 | id_1000 | 150.894697 | 153 |
| 1 | id_1001 | 863.158413 | 153 |
| 2 | id_1002 | NaN | 0 |
| 3 | id_1003 | 265.134384 | 80 |
| 4 | id_1004 | 921.148294 | 31 |
| level | fold | step | y_true | pred | |
|---|---|---|---|---|---|
| 2016-08-01 | id_1000 | 0 | 1 | 1555.500793 | 1434.796453 |
| 2016-08-01 | id_1001 | 0 | 1 | 2844.000000 | 3087.267824 |
| 2016-08-01 | id_1003 | 0 | 1 | 2618.750099 | 2713.304570 |
| 2016-08-01 | id_1004 | 0 | 1 | 8768.003021 | 7047.329003 |
| 2016-08-02 | id_1000 | 0 | 2 | 1477.750496 | 1460.038838 |
| ... | ... | ... | ... | ... | ... |
| 2016-12-30 | id_1001 | 21 | 5 | 108.000000 | 1082.426104 |
| 2016-12-30 | id_1003 | 21 | 5 | 1939.250000 | 1924.893045 |
| 2016-12-31 | id_1000 | 21 | 6 | 1134.000000 | 1334.693872 |
| 2016-12-31 | id_1001 | 21 | 6 | 125.000000 | 1057.469518 |
| 2016-12-31 | id_1003 | 21 | 6 | 1964.500000 | 1908.610089 |
494 rows × 5 columns
# Gráfico predicciones backtesting
# ==============================================================================
colors = plt.rcParams['axes.prop_cycle'].by_key()['color']
fig, axs = plt.subplots(5, 1, figsize=(8, 4), sharex=True)
for i, s in enumerate(series_dict.keys()):
axs[i].plot(series_dict[s], label=series_dict[s].name, color=colors[i])
axs[i].axvline(
pd.to_datetime(end_train), color='white', linestyle='--', linewidth=1
)
axs[i].plot(
backtest_predictions.loc[backtest_predictions['level'] == s, 'pred'],
label='prediction',
color='white',
)
axs[i].legend(loc='upper left', fontsize=8)
axs[i].tick_params(axis='both', labelsize=8)
fig.tight_layout()
plt.show()
⚠️ Advertencia
Las variables de calendario (sin_day_of_week, cos_day_of_week) se conocen de antemano, pero air_temperature y wind_speed son valores observados. En un escenario real de forecasting, sus valores futuros no se conocen en el momento de la predicción y deben sustituirse por predicciones meteorológicas, que tienen su propio error. Por lo tanto, las predicciones y las métricas de backtesting mostradas anteriormente son optimistas.
Al permitir la modelización de series temporales de diferentes longitudes y con diferentes variables exógenas, la clase ForecasterRecursiveMultiSeries proporciona una herramienta flexible y potente para utilizar toda la información disponible para entrenar los modelos.
Optimización de hiperparámetros y lags¶
En el primer ejemplo de este documento, la comparación entre forecaster se ha realizado sin optimizar los hiperparámetros de los estimadores. Para una comparación justa, se utiliza una estrategia de búsqueda bayesiana (Bayesian search, bayesian_search_forecaster y bayesian_search_forecaster_multiseries) con el fin de seleccionar la mejor configuración para cada forecaster. Véase más información en hyperparameter tuning and lags selection.
✏️ Nota
Optimizar los hiperparámetros de múltiples modelos puede ser computacionalmente costoso. Para acelerar el proceso, la evaluación de cada configuración candidata se realiza utilizando one-step-ahead (OneStepAheadFold) en lugar de backtesting. Para más detalles sobre las ventajas y limitaciones de este enfoque, consulte One-step-ahead validation.
# Búsqueda de hiperparámetros y backtesting de un modelo para cada item
# ==============================================================================
items = []
mae_values = []
def search_space(trial):
search_space = {
'lags' : trial.suggest_categorical('lags', [7, 14]),
'max_iter' : trial.suggest_int('max_iter', 100, 500),
'max_depth' : trial.suggest_categorical('max_depth', [None, 5, 6, 7, 8, 9, 10]),
'learning_rate' : trial.suggest_float('learning_rate', 0.01, 0.1, log=True)
}
return search_space
cv_search = OneStepAheadFold(initial_train_size=len(data_train))
cv_backtesting = TimeSeriesFold(
steps = 7,
initial_train_size = len(data_train) + len(data_val),
refit = False,
)
warnings.simplefilter('ignore', category=OneStepAheadValidationWarning)
for item in tqdm(data.columns):
window_features = RollingFeatures(stats=['mean', 'min', 'max'], window_sizes=7)
forecaster = ForecasterRecursive(
estimator = HistGradientBoostingRegressor(random_state=123),
lags = 14,
window_features = window_features
)
# return_best=True (por defecto): tras la búsqueda, el forecaster se configura y
# reentrena con la mejor configuración encontrada
bayesian_search_forecaster(
forecaster = forecaster,
y = data.loc[:end_val, item],
cv = cv_search,
search_space = search_space,
n_trials = 10,
metric = 'mean_absolute_error',
show_progress = False
)
metric, _ = backtesting_forecaster(
forecaster = forecaster,
y = data[item],
cv = cv_backtesting,
metric = 'mean_absolute_error',
show_progress = False
)
items.append(item)
mae_values.append(metric.at[0, 'mean_absolute_error'])
uni_series_mae = pd.Series(
data = mae_values,
index = items,
name = 'uni_series_mae'
)
# Búsqueda de hiperparámetros y backtesting de un modelo global para todos los items
# ==============================================================================
# search_space, cv_search, cv_backtesting y window_features son los mismos que
# en la búsqueda de los modelos individuales
estimator = HistGradientBoostingRegressor(random_state=123)
forecaster_ms = ForecasterRecursiveMultiSeries(
estimator = estimator,
lags = 14,
window_features = window_features,
transformer_series = StandardScaler(),
encoding = 'ordinal'
)
# return_best=True (por defecto): tras la búsqueda, el forecaster se configura y
# reentrena con la mejor configuración encontrada
bayesian_search_forecaster_multiseries(
forecaster = forecaster_ms,
series = data.loc[:end_val, :],
cv = cv_search,
search_space = search_space,
n_trials = 20,
metric = 'mean_absolute_error',
show_progress = False
)
multi_series_mae, _ = backtesting_forecaster_multiseries(
forecaster = forecaster_ms,
series = data,
cv = cv_backtesting,
metric = 'mean_absolute_error',
add_aggregated_metric = False,
)
╭────────────────────────────────── InputTypeWarning ──────────────────────────────────╮ │ Passing a DataFrame (either wide or long format) as `series` requires additional │ │ internal transformations, which can increase computational time. It is recommended │ │ to use a dictionary of pandas Series instead. For more details, see: │ │ https://skforecast.org/latest/user_guides/independent-multi-time-series-forecasting. │ │ html#input-data │ │ │ │ Category : skforecast.exceptions.InputTypeWarning │ │ Location : │ │ C:\Users\Joaquin\miniconda3\envs\skforecast_27_py13\Lib\site-packages\skforecast\uti │ │ ls\utils.py:4425 │ │ Suppress : warnings.simplefilter('ignore', category=InputTypeWarning) │ ╰──────────────────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────────── InputTypeWarning ──────────────────────────────────╮ │ Passing a DataFrame (either wide or long format) as `series` requires additional │ │ internal transformations, which can increase computational time. It is recommended │ │ to use a dictionary of pandas Series instead. For more details, see: │ │ https://skforecast.org/latest/user_guides/independent-multi-time-series-forecasting. │ │ html#input-data │ │ │ │ Category : skforecast.exceptions.InputTypeWarning │ │ Location : │ │ C:\Users\Joaquin\miniconda3\envs\skforecast_27_py13\Lib\site-packages\skforecast\uti │ │ ls\utils.py:4425 │ │ Suppress : warnings.simplefilter('ignore', category=InputTypeWarning) │ ╰──────────────────────────────────────────────────────────────────────────────────────╯
# Diferencia de la métrica de backtesting para cada item
# ==============================================================================
multi_series_mae = (
multi_series_mae.set_index('levels')['mean_absolute_error']
.rename('multi_series_mae')
)
results = pd.concat((uni_series_mae, multi_series_mae), axis=1)
results['improvement'] = results['uni_series_mae'] - results['multi_series_mae']
results['improvement_(%)'] = (
100 * results['improvement'] / results['uni_series_mae']
)
results = results.round(2)
# Mejora media de todos los items
# ==============================================================================
results[['improvement', 'improvement_(%)']].agg(['mean', 'min', 'max'])
| improvement | improvement_(%) | |
|---|---|---|
| mean | 0.4862 | 5.2346 |
| min | -0.0200 | -0.2800 |
| max | 1.2800 | 13.6400 |
# Número de series con mejora positiva y negativa
# ==============================================================================
pd.Series(
np.where(results['improvement_(%)'] < 0, 'negative', 'positive')
).value_counts()
positive 48 negative 2 Name: count, dtype: int64
Tras optimizar los lags e hiperparámetros de ambas aproximaciones, la mejora media del modelo multiserie se reduce del 7.5% al 5.2%. Aun así, el modelo multiserie sigue proporcionando mejores resultados para 48 de los 50 items, y en los otros dos la diferencia es despreciable.
Selección de predictores¶
La selección de predictores es el proceso de seleccionar un subconjunto de predictores relevantes (variables) para su uso en la construcción del modelo. Las técnicas de selección de predictores se utilizan por varias razones: para simplificar los modelos y hacerlos más fáciles de interpretar, para reducir el tiempo de entrenamiento, para evitar los problemas de dimensionalidad, para mejorar la generalización reduciendo el sobreajuste (formalmente, la reducción de la varianza), entre otros.
Skforecast es compatible con los métodos de selección implementados en scikit-learn. Existen varios métodos de selección de características, pero los más comunes son:
Recursive feature elimination (RFE)
Sequential Feature Selection (SFS)
Feature selection based on threshold (SelectFromModel)
Consulte la guía de usuario de selección de predictores para ver ejemplos de cómo aplicar estos métodos a los forecasters de skforecast con las funciones select_features y select_features_multiseries.
💡 Consejo
La selección de predictores es una herramienta poderosa para mejorar el rendimiento de los modelos de machine learning. Sin embargo, es computacionalmente costosa y puede llevar tiempo. Dado que el objetivo es encontrar el mejor subconjunto de variables, no el mejor modelo, no es necesario utilizar todo el conjunto de datos o un modelo muy complejo. En su lugar, se recomienda utilizar un pequeño subconjunto de datos y un modelo simple. Una vez que se haya identificado el mejor subconjunto de variables, el modelo puede entrenarse utilizando todo el conjunto de datos y una configuración más compleja.
Pesos en forecasting multiseries¶
Los pesos se utilizan para controlar la influencia que tiene cada observación en el entrenamiento del modelo. ForecasterRecursiveMultiSeries acepta dos tipos de pesos:
series_weightscontrola la importancia relativa de cada serie. Si una serie tiene el doble de peso que las demás, las observaciones de esa serie influyen el doble en el entrenamiento. Cuanto mayor sea el peso de una serie en relación con las demás, más se centrará el modelo en intentar aprender esa serie.weight_funccontrola la importancia relativa de cada observación en función del índice. Por ejemplo, una función que asigna un peso menor a ciertas fechas.
Si se indican los dos tipos de pesos, estos se multiplican para crear los pesos finales como se muestra en la figura. El sample_weight resultante no puede contener valores negativos.

Más información sobre weights in multi-series forecasting y weighted time series forecasting con skforecast.
En este ejemplo, item_1 tiene una mayor importancia relativa entre series (pesa 3 veces más que el resto de series), y las observaciones entre '2013-12-01' y '2014-01-31' se consideran no representativas y se les aplica un peso de 0. Como solo item_1 está incluida en series_weights, skforecast lanza un IgnoredArgumentWarning para informar de que el resto de series reciben un peso de 1; es el comportamiento esperado.
# Pesos en forecasting multiseries
# ======================================================================================
# Pesos de cada serie
series_weights = {'item_1': 3.0} # Las series ausentes en el dict tienen peso 1
# Pesos de cada observación (por índice)
def custom_weights(index):
"""
Devuelve 0 si el índice está entre '2013-12-01' y '2014-01-31', 1 en caso contrario.
"""
weights = np.where(
(index >= '2013-12-01') & (index <= '2014-01-31'),
0,
1
)
return weights
forecaster = ForecasterRecursiveMultiSeries(
estimator = HistGradientBoostingRegressor(random_state=123),
lags = 14,
transformer_series = StandardScaler(),
weight_func = custom_weights,
series_weights = series_weights
)
forecaster.fit(series=data)
forecaster.predict(steps=7).head(3)
╭────────────────────────────────── InputTypeWarning ──────────────────────────────────╮ │ Passing a DataFrame (either wide or long format) as `series` requires additional │ │ internal transformations, which can increase computational time. It is recommended │ │ to use a dictionary of pandas Series instead. For more details, see: │ │ https://skforecast.org/latest/user_guides/independent-multi-time-series-forecasting. │ │ html#input-data │ │ │ │ Category : skforecast.exceptions.InputTypeWarning │ │ Location : │ │ C:\Users\Joaquin\miniconda3\envs\skforecast_27_py13\Lib\site-packages\skforecast\uti │ │ ls\utils.py:4425 │ │ Suppress : warnings.simplefilter('ignore', category=InputTypeWarning) │ ╰──────────────────────────────────────────────────────────────────────────────────────╯
╭─────────────────────────────── IgnoredArgumentWarning ───────────────────────────────╮ │ {'item_40', 'item_9', 'item_5', 'item_28', 'item_29', 'item_31', 'item_22', │ │ 'item_13', 'item_19', 'item_20', 'item_45', 'item_47', 'item_41', 'item_48', │ │ 'item_15', 'item_6', 'item_10', 'item_27', 'item_32', 'item_17', 'item_42', │ │ 'item_33', 'item_39', 'item_14', 'item_43', 'item_26', 'item_18', 'item_44', │ │ 'item_35', 'item_49', 'item_2', 'item_46', 'item_11', 'item_37', 'item_16', │ │ 'item_24', 'item_30', 'item_34', 'item_21', 'item_36', 'item_7', 'item_8', │ │ 'item_38', 'item_23', 'item_50', 'item_12', 'item_3', 'item_25', 'item_4'} not │ │ present in `series_weights`. A weight of 1 is given to all their samples. │ │ │ │ Category : skforecast.exceptions.IgnoredArgumentWarning │ │ Location : │ │ C:\Users\Joaquin\miniconda3\envs\skforecast_27_py13\Lib\site-packages\skforecast\rec │ │ ursive\_forecaster_recursive_multiseries.py:2227 │ │ Suppress : warnings.simplefilter('ignore', category=IgnoredArgumentWarning) │ ╰──────────────────────────────────────────────────────────────────────────────────────╯
| level | pred | |
|---|---|---|
| 2018-01-01 | item_1 | 20.663257 |
| 2018-01-01 | item_2 | 55.695596 |
| 2018-01-01 | item_3 | 35.783077 |
✏️ Nota
Se puede pasar un diccionario a weight_func para aplicar diferentes funciones a cada serie. Si una serie no está presente en el diccionario, tendrá peso 1.
Predecir nuevas series (series desconocidas)¶
ForecasterRecursiveMultiSeries permite predecir series desconocidas (series no vistas durante el proceso de entrenamiento). Pueden darse dos escenarios:
Existen datos históricos para la nueva serie: El usuario debe proporcionar un
DataFramecon los datos históricos necesarios para crear los predictores (lags y rolling) utilizados durante el proceso de predicción. EsteDataFramedebe contener una columna por cada serie que se desee predecir. Es posible incluir tanto series vistas durante el entrenamiento como series nuevas (no vistas).No existen datos históricos para la nueva serie: Este escenario es similar, pero la
last_windowpara las nuevas series está compuesta completamente por valoresNaN. Este enfoque solo es posible cuando se utilizan estimadores que soportan valores faltantes, ya que todos los lags seránNaN.
En ambos casos, las variables exógenas pueden estar disponibles o no. Si no están disponibles, se establecerán automáticamente como NaN. Como las nuevas series no se han visto durante el entrenamiento, su identificador tampoco puede codificarse: también se establece como NaN, y skforecast lanza un UnknownLevelWarning para informar de ello. Por tanto, el estimador debe admitir valores faltantes (HistGradientBoostingRegressor los admite).
✏️ Nota
Las predicciones de series desconocidas se construyen a partir de los valores de su última ventana (last window). Los estimadores basados en árboles no pueden extrapolar más allá del rango de valores visto durante el entrenamiento, por lo que las nuevas series deben estar en una escala similar a la de los datos de entrenamiento; de lo contrario, todas saturan el modelo y reciben predicciones casi idénticas. Cuando no se dispone de datos históricos, todas las series desconocidas comparten los mismos predictores (todos los valores NaN), por lo que reciben predicciones idénticas.
# Predecir nuevas series con datos históricos disponibles
# ==============================================================================
# Crear un dataframe con las últimas 14 observaciones de la nueva serie
last_window_new = pd.DataFrame(
{
'series_new_1': [25.0, 31.0, 28.0, 35.0, 41.0, 38.0, 26.0,
27.0, 33.0, 29.0, 36.0, 43.0, 40.0, 28.0],
'series_new_2': [65.0, 78.0, 72.0, 83.0, 95.0, 91.0, 68.0,
67.0, 80.0, 74.0, 86.0, 98.0, 92.0, 70.0]
},
index=pd.date_range(end=data.index[-1], periods=14, freq='D'),
)
forecaster.predict(steps=5, last_window=last_window_new)
╭──────────────────────────────── UnknownLevelWarning ─────────────────────────────────╮ │ `levels` {'series_new_2', 'series_new_1'} were not included in training. Unknown │ │ levels are encoded as NaN, which may cause the prediction to fail if the estimator │ │ does not accept NaN values. │ │ │ │ Category : skforecast.exceptions.UnknownLevelWarning │ │ Location : │ │ C:\Users\Joaquin\miniconda3\envs\skforecast_27_py13\Lib\site-packages\skforecast\uti │ │ ls\utils.py:1502 │ │ Suppress : warnings.simplefilter('ignore', category=UnknownLevelWarning) │ ╰──────────────────────────────────────────────────────────────────────────────────────╯
| level | pred | |
|---|---|---|
| 2018-01-01 | series_new_1 | 33.090028 |
| 2018-01-01 | series_new_2 | 67.291398 |
| 2018-01-02 | series_new_1 | 37.125181 |
| 2018-01-02 | series_new_2 | 76.880932 |
| 2018-01-03 | series_new_1 | 36.017066 |
| 2018-01-03 | series_new_2 | 76.429767 |
| 2018-01-04 | series_new_1 | 42.044894 |
| 2018-01-04 | series_new_2 | 87.338898 |
| 2018-01-05 | series_new_1 | 45.824647 |
| 2018-01-05 | series_new_2 | 97.292583 |
# Predecir nuevas series sin datos históricos disponibles
# ==============================================================================
# Crear un dataframe con la última ventana de la nueva serie con valores NaN
last_window_new = pd.DataFrame(
{
'series_new_1': np.nan,
'series_new_2': np.nan
},
index=pd.date_range(end=data.index[-1], periods=14, freq='D'),
)
forecaster.predict(steps=5, last_window=last_window_new)
╭──────────────────────────────── UnknownLevelWarning ─────────────────────────────────╮ │ `levels` {'series_new_2', 'series_new_1'} were not included in training. Unknown │ │ levels are encoded as NaN, which may cause the prediction to fail if the estimator │ │ does not accept NaN values. │ │ │ │ Category : skforecast.exceptions.UnknownLevelWarning │ │ Location : │ │ C:\Users\Joaquin\miniconda3\envs\skforecast_27_py13\Lib\site-packages\skforecast\uti │ │ ls\utils.py:1502 │ │ Suppress : warnings.simplefilter('ignore', category=UnknownLevelWarning) │ ╰──────────────────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────── MissingValuesWarning ────────────────────────────────╮ │ `last_window` has missing values. Most of machine learning models do not allow │ │ missing values. Prediction method may either raise an error or return NaN │ │ predictions. │ │ │ │ Category : skforecast.exceptions.MissingValuesWarning │ │ Location : │ │ C:\Users\Joaquin\miniconda3\envs\skforecast_27_py13\Lib\site-packages\skforecast\uti │ │ ls\utils.py:1602 │ │ Suppress : warnings.simplefilter('ignore', category=MissingValuesWarning) │ ╰──────────────────────────────────────────────────────────────────────────────────────╯
| level | pred | |
|---|---|---|
| 2018-01-01 | series_new_1 | 61.763950 |
| 2018-01-01 | series_new_2 | 61.763950 |
| 2018-01-02 | series_new_1 | 59.735558 |
| 2018-01-02 | series_new_2 | 59.735558 |
| 2018-01-03 | series_new_1 | 58.899508 |
| 2018-01-03 | series_new_2 | 58.899508 |
| 2018-01-04 | series_new_1 | 58.901234 |
| 2018-01-04 | series_new_2 | 58.901234 |
| 2018-01-05 | series_new_1 | 58.901234 |
| 2018-01-05 | series_new_2 | 58.901234 |
Conclusiones¶
Este documento ha mostrado cómo los modelos de forecasting globales pueden utilizarse para modelar múltiples series temporales con skforecast:
Un único modelo global entrenado con las 50 series de ventas superó a los modelos individuales en 49 de las 50 series, con una mejora media de aproximadamente el 7.5% (5.2% tras optimizar los hiperparámetros de ambas aproximaciones, cuando es mejor en 48 de las 50 series). Esto confirma que un modelo multiserie puede tener ventajas sobre varios modelos individuales al predecir series temporales que siguen dinámicas similares.
ForecasterRecursiveMultiSeriespuede combinar series de diferente longitud, con valores faltantes y con distintas variables exógenas para cada serie.La influencia de cada serie y de cada observación en el modelo puede controlarse con
series_weightsyweight_func.Una vez entrenado, un modelo global puede predecir series no vistas durante el entrenamiento, siempre que se disponga de su historia reciente o el estimador admita valores faltantes.
Más allá de las posibles mejoras en la capacidad predictiva, también es importante tener en cuenta la ventaja de tener un único modelo que mantener.
Información de sesión¶
import session_info
session_info.show(html=False)
----- matplotlib 3.11.2 numpy 2.5.4 pandas 2.3.3 session_info v1.0.1 skforecast 0.27.0 sklearn 1.9.1 tqdm 4.70.1 ----- IPython 9.17.1 jupyter_client 8.10.0 jupyter_core 5.9.1 ----- Python 3.13.18 | packaged by conda-forge | (main, Oct 26 2026, 11:30:40) [MSC v.1944 64 bit (AMD64)] Windows-11-10.0.26200-SP0 ----- Session information updated at 2026-11-11 21:37
Instrucciones para citar¶
¿Cómo citar este documento?
Si utilizas este documento o alguna parte de él, te agradecemos que lo cites. ¡Muchas gracias!
Modelos de forecasting globales: modelado de múltiples series temporales con machine learning por Joaquín Amat Rodrigo and Javier Escobar Ortiz, disponible con licencia Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0 DEED) en https://www.cienciadedatos.net/documentos/py44-multi-series-forecasting-skforecast-español.html
¿Cómo citar skforecast?
Si utilizas skforecast, te agradeceríamos mucho que lo cites. ¡Muchas gracias!
Zenodo:
Amat Rodrigo, Joaquin, & Escobar Ortiz, Javier. (2026). skforecast (v0.27.0). Zenodo. https://doi.org/10.5281/zenodo.8382787
APA:
Amat Rodrigo, J., & Escobar Ortiz, J. (2026). skforecast (Version 0.27.0) [Computer software]. https://doi.org/10.5281/zenodo.8382787
BibTeX:
@software{skforecast, author = {Amat Rodrigo, Joaquin and Escobar Ortiz, Javier}, title = {skforecast}, version = {0.27.0}, month = {11}, year = {2026}, license = {BSD-3-Clause}, url = {https://skforecast.org/}, doi = {10.5281/zenodo.8382787} }
¿Te ha gustado el artículo? Tu ayuda es importante
Tu contribución me ayudará a seguir generando contenido divulgativo gratuito. ¡Muchísimas gracias! 😊
Este documento creado por Joaquín Amat Rodrigo y Javier Escobar Ortiz tiene licencia Attribution-NonCommercial-ShareAlike 4.0 International.
Se permite:
-
Compartir: copiar y redistribuir el material en cualquier medio o formato.
-
Adaptar: remezclar, transformar y crear a partir del material.
Bajo los siguientes términos:
-
Atribución: Debes otorgar el crédito adecuado, proporcionar un enlace a la licencia e indicar si se realizaron cambios. Puedes hacerlo de cualquier manera razonable, pero no de una forma que sugiera que el licenciante te respalda o respalda tu uso.
-
No-Comercial: No puedes utilizar el material para fines comerciales.
-
Compartir-Igual: Si remezclas, transformas o creas a partir del material, debes distribuir tus contribuciones bajo la misma licencia que el original.
