Modelar series temporales con tendencia utilizando modelos de árboles

Si te gusta  Skforecast , ayúdanos dándonos una estrella en  GitHub! ⭐️

Modelar series temporales con tendencia utilizando modelos de árboles

Joaquín Amat Rodrigo, Javier Escobar Ortiz
Septiembre, 2023 (última actualización Noviembre 2024)

Introducción

Los modelos basados en árboles, incluidos los árboles de decisión, random forests y gradient boosting machines (GBMs), son conocidos por su eficacia y su uso generalizado en multitud de aplicaciones de machine learning. Sin embargo, tienen limitaciones cuando se trata de extrapolar, es decir, hacer predicciones o estimaciones más allá del rango de los datos observado. Esta limitación resulta especialmente crítica cuando se trata de modelar series temporales que tienen tendencia. Debido a que estos modelos carecen de la capacidad para predecir valores más allá del rango observado durante el entrenamiento, sus valores pronosticados se desviarán de la tendencia subyacente.

Se han propuesto varias estrategias para hacer frente a este reto, y uno de los enfoques más comunes es el uso de la diferenciación. El proceso de diferenciación consiste en calcular las diferencias entre observaciones consecutivas de la serie temporal. En lugar de modelizar directamente los valores absolutos, la atención se centra en modelizar el ratio de cambio relativo. Una vez estimadas las predicciones, puede invertirse la transformación para obtener los valores en la escala original.

La librería skforecast, versión 0.10.0 o superior, introduce un nuevo argumento differentiation dentro de sus Forecasters para indicar que se debe aplicar un proceso de diferenciación antes de entrenar el modelo. Esto se consigue utilizando internamente una nueva clase llamada skforecast.preprocessing.TimeSeriesDifferentiator. Cabe destacar que todo el proceso de diferenciación se ha automatizado y sus efectos se invierten durante la fase de predicción. Esto garantiza que los valores predichos se encuentren en la misma escala que los datos originales.

Este documento muestra cómo la diferenciación permite modelizar series temporales con tendencia positiva utilizando modelos basados en árboles (random forest y gradient boosting xgboost).

Librerías

In [1]:
# Data manipulation
# ==============================================================================
import numpy as np
import pandas as pd

# Plots
# ==============================================================================
import matplotlib.pyplot as plt
plt.style.use('seaborn-v0_8-darkgrid')

# Modelling and Forecasting
# ==============================================================================
import skforecast
import sklearn
import xgboost
from xgboost import XGBRegressor
from sklearn.ensemble import RandomForestRegressor
from skforecast.recursive import ForecasterRecursive
from skforecast.model_selection import TimeSeriesFold
from skforecast.model_selection import backtesting_forecaster
from skforecast.preprocessing import TimeSeriesDifferentiator
from sklearn.metrics import mean_absolute_error

# Warnings configuration
# ==============================================================================
import warnings

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 xgboost: {xgboost.__version__}")
print(f"{color}Versión pandas: {pd.__version__}")
print(f"{color}Versión numpy: {np.__version__}")
Versión skforecast: 0.14.0
Versión scikit-learn: 1.5.2
Versión xgboost: 2.1.2
Versión pandas: 2.2.3
Versión numpy: 2.0.2

Datos

El conjunto de datos contiene el número de pasajeros mensuales de líneas aéreas internacionales, de 1949 a 1960.

In [2]:
# Descarga de datos
# ==============================================================================
url = (
    'https://raw.githubusercontent.com/JoaquinAmatRodrigo/Estadistica-machine-learning-python/'
    'master/data/AirPassengers.csv'
)
datos = pd.read_csv(url, sep=',')

# Preprocesado de datos
# ==============================================================================
datos['Date'] = pd.to_datetime(datos['Date'], format='%Y-%m')
datos = datos.set_index('Date')
datos = datos.asfreq('MS')
datos = datos['Passengers']
datos = datos.sort_index()
datos.head(4)
Out[2]:
Date
1949-01-01    112
1949-02-01    118
1949-03-01    132
1949-04-01    129
Freq: MS, Name: Passengers, dtype: int64

Se almacenan los mismos datos pero aplicando una diferenciación de orden 1 utilizando el TimeSeriesDifferentiator.

In [3]:
# Datos diferenciados
# ==============================================================================
diferenciator = TimeSeriesDifferentiator(order=1)
datos_diff = diferenciator.fit_transform(datos.to_numpy())
datos_diff = pd.Series(datos_diff, index=datos.index).dropna()
datos_diff.head(4)
Out[3]:
Date
1949-02-01     6.0
1949-03-01    14.0
1949-04-01    -3.0
1949-05-01    -8.0
Freq: MS, dtype: float64
In [4]:
# Partición de datos train-test
# ==============================================================================
end_train = '1955-12-01 23:59:59'
print(
    f"Fechas entrenamiento : {datos.index.min()} --- {datos.loc[:end_train].index.max()}  " 
    f"(n={len(datos.loc[:end_train])})")
print(
    f"Fechas test          : {datos.loc[end_train:].index.min()} --- {datos.index.max()}  "
    f"(n={len(datos.loc[end_train:])})")

# Plot
# ==============================================================================
fig, axs = plt.subplots(1, 2, figsize=(11, 2.5))
axs = axs.ravel()
datos.loc[:end_train].plot(ax=axs[0], label='train')
datos.loc[end_train:].plot(ax=axs[0], label='test')
axs[0].legend()
axs[0].set_title('Datos originales')

datos_diff.loc[:end_train].plot(ax=axs[1], label='train')
datos_diff.loc[end_train:].plot(ax=axs[1], label='test')
axs[1].legend()
axs[1].set_title('Datos diferenciados');
Fechas entrenamiento : 1949-01-01 00:00:00 --- 1955-12-01 00:00:00  (n=84)
Fechas test          : 1956-01-01 00:00:00 --- 1960-12-01 00:00:00  (n=60)

Forecasting con Random Forest y Gradient Boosting

Se crean dos forecasters, uno con un regresor RandomForestRegressor de scikit-learn y otro con XGBoost. Ambos se entrenan con datos de 1949-01-01 a 1956-01-01 y generan predicciones para los próximos 60 meses (5 años).

In [5]:
# Forecasting sin diferenciación (serie original)
# ==============================================================================
steps = len(datos.loc[end_train:])

# Forecasters
forecaster_rf = ForecasterRecursive(
                    regressor = RandomForestRegressor(random_state=963),
                    lags      = 12
                )
forecaster_gb = ForecasterRecursive(
                    regressor = XGBRegressor(random_state=963),
                    lags      = 12
                )

# Entrenamiento
forecaster_rf.fit(datos.loc[:end_train])
forecaster_gb.fit(datos.loc[:end_train])

# Predicción
predicciones_rf = forecaster_rf.predict(steps=steps)
predicciones_gb = forecaster_gb.predict(steps=steps)

# Error
error_rf = mean_absolute_error(datos.loc[end_train:], predicciones_rf)
error_gb = mean_absolute_error(datos.loc[end_train:], predicciones_gb)
print(f"Error (MAE) Random Forest: {error_rf:.2f}")
print(f"Error (MAE) Gradient Boosting: {error_gb:.2f}")

# Plot
fig, ax = plt.subplots(figsize=(7, 3), sharex=True, sharey=True)
datos.loc[:end_train].plot(ax=ax, label='train')
datos.loc[end_train:].plot(ax=ax, label='test')
predicciones_rf.plot(ax=ax, label='Random Forest')
predicciones_gb.plot(ax=ax, label='Gradient Boosting')
ax.set_title(f'Forecasting sin diferenciación')
ax.set_xlabel('')
ax.legend();
Error (MAE) Random Forest: 66.10
Error (MAE) Gradient Boosting: 55.38

El gráfico muestra que ninguno de los modelos es capaz de seguir la tendencia en sus predicciones. Tras unos pocos pasos, las predicciones se vuelven casi constantes cerca del máximo observado en los datos de entrenamiento.

A continuación, se entrenan dos nuevos forecasters utilizando la misma configuración, pero con el argumento differentiation = 1. Esto activa el proceso interno de diferenciar (orden 1) las series temporales antes de entrenar el modelo, e invierte la diferenciación (también conocida como integración) para los valores predichos.

In [6]:
# Forecasting con diferenciación	
# ==============================================================================
steps = len(datos.loc[end_train:])

# Forecasters
forecaster_rf = ForecasterRecursive(
                    regressor       = RandomForestRegressor(random_state=963),
                    lags            = 12,
                    differentiation = 1
                )
forecaster_gb = ForecasterRecursive(
                    regressor       = XGBRegressor(random_state=963),
                    lags            = 12,
                    differentiation = 1
                )

# Entramiento
forecaster_rf.fit(datos.loc[:end_train])
forecaster_gb.fit(datos.loc[:end_train])

# Predicción
predicciones_rf = forecaster_rf.predict(steps=steps)
predicciones_gb = forecaster_gb.predict(steps=steps)

# Error
error_rf = mean_absolute_error(datos.loc[end_train:], predicciones_rf)
error_gb = mean_absolute_error(datos.loc[end_train:], predicciones_gb)
print(f"Error (MAE) Random Forest: {error_rf:.2f}")
print(f"Error (MAE) Gradient Boosting: {error_gb:.2f}")

# Plot
fig, ax = plt.subplots(figsize=(7, 3), sharex=True, sharey=True)
datos.loc[:end_train].plot(ax=ax, label='train')
datos.loc[end_train:].plot(ax=ax, label='test')
predicciones_rf.plot(ax=ax, label='Random Forest')
predicciones_gb.plot(ax=ax, label='Gradient Boosting')
ax.set_title(f'Forecasting con diferenciación')
ax.set_xlabel('')
ax.legend();
Error (MAE) Random Forest: 53.76
Error (MAE) Gradient Boosting: 29.77

Esta vez, ambos modelos son capaces de seguir la tendencia en sus predicciones.

Detalles de la diferenciación de series temporales

El ejemplo anterior muestra lo fácil que es introducir la diferenciación en el proceso de forecasting gracias a las funcionalidades disponibles en skforecast. Sin embargo, hay que aplicar varias transformaciones no triviales para conseguir una interacción fluida.

En los siguientes apartados, se presenta la clase TimeSeriesDifferentiator y se muestran sus principales ventajas.

  • La clase es capaz de diferenciaciar e integración (diferenciación inversa) cualquier serie temporal.

  • Explicación de por qué la gestión interna de la diferenciación tiene ventajas sobre el enfoque tradicional de pre-transformar toda la serie temporal antes de iniciar el entrenamiento del modelo.

  • Cómo gestionar la diferenciación cuando se aplica el Forecaster a nuevos datos que no siguen inmediatamente a los datos de entrenamiento).

TimeSeriesDifferentiator

TimeSeriesDifferentiator es un clase que sigue la API de sklearn de preprocesamiento. Esto significa que tiene el método fit, transform, fit_transform y inverse_transform.

In [7]:
# Diferenciación con TimeSeriesDifferentiator
# ==============================================================================
y = np.array([5, 8, 12, 10, 14, 17, 21, 19], dtype=float)
diferenciador = TimeSeriesDifferentiator()
diferenciador.fit(y)
y_diff = diferenciador.transform(y)

print(f"Serie original    : {y}")
print(f"Serie diferenciada: {y_diff}")
Serie original    : [ 5.  8. 12. 10. 14. 17. 21. 19.]
Serie diferenciada: [nan  3.  4. -2.  4.  3.  4. -2.]

La diferenciación puede revertirse (integración) con el método inverse_transform, recuperando así la serie original.

In [8]:
# Revertir transformación
# ==============================================================================
diferenciador.inverse_transform(y_diff)
Out[8]:
array([ 5.,  8., 12., 10., 14., 17., 21., 19.])

Warning

El proceso de transformación inversa inverse_transform es aplicable exclusivamente a las mismas series temporales que han sido previamente diferenciadas utilizando el mismo objeto TimeSeriesDifferentiator. Esta restricción surge de la necesidad de utilizar los n valores iniciales de la serie temporal (n igual al orden de diferenciación) para revertir efectivamente la diferenciación. Estos valores se capturan durante la invocación del método fit.


✎ Nota

Un método adicional inverse_transform_next_window está disponible en la clase TimeSeriesDifferentiator. Este método está pensado para ser utilizado dentro de los Forecasters para invertir la diferenciación de los valores predichos. Si el regresor dentro del Forecaster está entrenado con una serie temporal diferenciada, entonces sus predicciones también lo estarán. El método inverse_transform_next_window permite devolver las predicciones a la escala original, suponiendo que siguen inmediatamente después de los últimos valores observados (last_window).

Diferenciación interna frente a preprocesamiento

Los Forecasters gestionan internamente el proceso de diferenciación, por lo que no es necesario preprocesar las series temporales ni postprocesar sus predicciones. Esto tiene varias ventajas, pero antes de entrar en ellas, se comparan los resultados de ambos enfoques.

In [9]:
# Diferenciación de la serie en el proprocesamiento
# ==============================================================================
diferenciador = TimeSeriesDifferentiator(order=1)
datos_diff = diferenciador.fit_transform(datos.to_numpy())
datos_diff = pd.Series(datos_diff, index=datos.index).dropna()

forecaster = ForecasterRecursive(
                 regressor = RandomForestRegressor(random_state=963),
                 lags      = 15
             )
forecaster.fit(y=datos_diff.loc[:end_train])
predicciones_diff = forecaster.predict(steps=steps)

# Invertir diferenciación para obtener predicciones en la escala original
last_value_train = datos.loc[:end_train].iloc[[-1]]
predicciones_1 = pd.concat([last_value_train, predicciones_diff]).cumsum()[1:]
predicciones_1 = predicciones_1.asfreq('MS')
predicciones_1.name = 'pred'
predicciones_1.head(5)
Out[9]:
1956-01-01    303.18
1956-02-01    293.70
1956-03-01    322.68
1956-04-01    326.52
1956-05-01    326.79
Freq: MS, Name: pred, dtype: float64
In [10]:
# Serie temporal diferenciada internamente por el forecaster
# ==============================================================================
forecaster = ForecasterRecursive(
                 regressor       = RandomForestRegressor(random_state=963),
                 lags            = 15,
                 differentiation = 1
             )
forecaster.fit(y=datos.loc[:end_train])
predicciones_2 = forecaster.predict(steps=steps)
predicciones_2.head(5)
Out[10]:
1956-01-01    303.18
1956-02-01    293.70
1956-03-01    322.68
1956-04-01    326.52
1956-05-01    326.79
Freq: MS, Name: pred, dtype: float64
In [11]:
# Verificar que las predicciones son iguales
# ==============================================================================
pd.testing.assert_series_equal(predicciones_1, predicciones_2)

A continuación, los resultados del proceso de backtesting se someten a un análisis comparativo. Esta comparación es más compleja que la anterior, ya que el proceso de deshacer la diferenciación debe realizarse por separado para cada partición del backtesting.

In [12]:
# Backtesting con la serie temporal diferenciada en el preprocesamiento
# ==============================================================================
forecaster_1 = ForecasterRecursive(
                   regressor = RandomForestRegressor(random_state=963),
                   lags      = 15
               )
cv = TimeSeriesFold(
        steps              = 5,
        initial_train_size = len(datos_diff.loc[:end_train]),
        refit              = True,
        fixed_train_size   = False,
     )
_, predicciones_1 = backtesting_forecaster(
                        forecaster    = forecaster_1,
                        y             = datos_diff,
                        cv            = cv,
                        metric        = 'mean_squared_error',
                        n_jobs        = 'auto',
                        verbose       = False,
                        show_progress = True  
                    )

# Invertir diferenciación para obtener predicciones en la escala original. Las
# predicciones de cada partición deben revertirse individualmente. Un id se añade
# a cada predicción para identificar la partición a la que pertenece.
predicciones_1 = predicciones_1.rename(columns={'pred': 'pred_diff'})
folds = len(predicciones_1) / cv.steps
folds = int(np.ceil(folds))
predicciones_1['backtesting_fold_id'] = np.repeat(range(folds), cv.steps)[:len(predicciones_1)]

# Sumar el valor observado anteriormente (solo a la primera predicción de cada partición)
valores_anteriores = datos.shift(1).loc[predicciones_1.index].iloc[::cv.steps]
valores_anteriores.name = 'valor_anterior'
predicciones_1 = predicciones_1.merge(
                    valores_anteriores,
                    left_index  = True,
                    right_index = True,
                    how         = 'left'
                )
predicciones_1 = predicciones_1.fillna(0)
predicciones_1['suma_valores'] = (
    predicciones_1['pred_diff'] + predicciones_1['valor_anterior']
)

# Revert differentiation using the cumulative sum by fold
predicciones_1['pred'] = (
    predicciones_1
    .groupby('backtesting_fold_id')
    .apply(lambda x: x['suma_valores'].cumsum(), include_groups=False)
    .to_numpy()
)

predicciones_1.head(5)
Out[12]:
pred_diff backtesting_fold_id valor_anterior suma_valores pred
1956-01-01 25.18 0 278.0 303.18 303.18
1956-02-01 -9.48 0 0.0 -9.48 293.70
1956-03-01 28.98 0 0.0 28.98 322.68
1956-04-01 3.84 0 0.0 3.84 326.52
1956-05-01 0.27 0 0.0 0.27 326.79
In [13]:
# Backtesting con la serie temporal diferenciada internamente por el forecaster
# ==============================================================================
forecaster_2 = ForecasterRecursive(
                   regressor       = RandomForestRegressor(random_state=963),
                   lags            = 15,
                   differentiation = 1
               )
cv = TimeSeriesFold(
        steps              = 5,
        initial_train_size = len(datos.loc[:end_train]),
        refit              = True,
        fixed_train_size   = False,
        differentiation    = 1
     )
_, predicciones_2 = backtesting_forecaster(
                        forecaster    = forecaster_2,
                        y             = datos,
                        cv            = cv,
                        metric        = 'mean_squared_error',
                        n_jobs        = 'auto',
                        verbose       = False,
                        show_progress = True  
                    )

predicciones_2.head(5)
Out[13]:
pred
1956-01-01 303.18
1956-02-01 293.70
1956-03-01 322.68
1956-04-01 326.52
1956-05-01 326.79
In [14]:
# Validar que las predicciones son iguales
# ==============================================================================
pd.testing.assert_series_equal(predicciones_1['pred'], predicciones_2['pred'])

Como se ha demostrado, los valores son equivalentes cuando se diferencian las series temporales con un paso de preprocesamiento o cuando se permite que el Forecaster gestione la diferenciación internamente, ¿por qué la segunda alternativa es mejor?

  • Al gestionar el Forecaster internamente todas las transformaciones garantiza que se apliquen las mismas transformaciones cuando el modelo se ejecuta con nuevos datos.

  • Cuando el modelo se aplica a nuevos datos que no siguen inmediatamente a los datos de entrenamiento (por ejemplo, si un modelo no se vuelve a entrenar antes de cada fase de predicción), el Forecaster aumenta automáticamente el tamaño de la última ventana (last_window) necesaria para generar los predictores, además de aplicar la diferenciación a los datos entrantes e invertirla en las predicciones finales.

Estas transformaciones no son triviales y son muy propensas a errores, por lo que skforecast intenta evitar complicar en exceso la ya de por sí difícil tarea de predecir series temporales.

Información de sesión

In [15]:
import session_info
session_info.show(html=False)
-----
matplotlib          3.9.0
numpy               2.0.2
pandas              2.2.3
session_info        1.0.0
skforecast          0.14.0
sklearn             1.5.2
xgboost             2.1.2
-----
IPython             8.25.0
jupyter_client      8.6.2
jupyter_core        5.7.2
notebook            6.4.12
-----
Python 3.12.4 | packaged by Anaconda, Inc. | (main, Jun 18 2024, 15:03:56) [MSC v.1929 64 bit (AMD64)]
Windows-11-10.0.26100-SP0
-----
Session information updated at 2024-11-04 20:28

Bibliografía

Hyndman, R.J., & Athanasopoulos, G. (2021) Forecasting: principles and practice, 3rd edition, OTexts: Melbourne, Australia.

Time Series Analysis and Forecasting with ADAM Ivan Svetunkov

Python for Finance: Mastering Data-Driven Finance

Forecasting: theory and practice

Instrucciones para citar

¿Cómo citar este documento?

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

Modelar series temporales con tendencia utilizando modelos de árboles por Joaquín Amat Rodrigo y Javier Escobar Ortiz, disponible bajo una licencia Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0 DEED) en https://www.cienciadedatos.net/documentos/py49-modelar-tendencia-en-series-temporales-modelos-de-arboles.html

¿Cómo citar skforecast?

Si utilizas skforecast en tu investigación o publicación, te lo agradeceríamos mucho que lo cites. ¡Muchas gracias!

Zenodo:

Amat Rodrigo, Joaquin, & Escobar Ortiz, Javier. (2024). skforecast (v0.14.0). Zenodo. https://doi.org/10.5281/zenodo.8382788

APA:

Amat Rodrigo, J., & Escobar Ortiz, J. (2024). skforecast (Version 0.14.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.14.0}, month = {11}, year = {2024}, license = {BSD-3-Clause}, url = {https://skforecast.org/}, doi = {10.5281/zenodo.8382788} }


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

Mantener un sitio web tiene unos costes elevados, tu contribución me ayudará a seguir generando contenido divulgativo gratuito. ¡Muchísimas gracias! 😊


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.

  • NoComercial: No puedes utilizar el material para fines comerciales.

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