7  Modelos de Machine Learning

Nesse capítulo vamos ver as maneiras de usar modelos de Machine Learning para forecasting. Aqui é onde mais acontecem erros de novos praticantes, pois muitas vezes tentam aplicar modelos de ML diretamente na série temporal.

Para usar um modelo de ML, precisamos transformar a série temporal em um problema de regressão tradicional. Isso é feito criando janelas deslizantes (sliding windows) da série temporal, onde cada janela é usada como uma amostra de treinamento para o modelo de ML.

Ou seja, se temos uma série temporal \((y_t)\), podemos criar janelas de tamanho \(n\) e usar os valores \(y_{t-n}, y_{t-n+1}, \ldots, y_{t-1}\) como características (features) para prever o valor \(y_t\).

img/reduction.png

Para prever mais de um passo à frente, existem duas abordagens:

  1. Previsão recursive: se queremos prever \(h\) passos à frente, podemos usar o modelo para prever \(y_{t+1}\), depois usar essa previsão para prever \(y_{t+2}\), e assim por diante, até \(y_{t+h}\). Isso pode levar a erros acumulados, pois cada previsão depende das previsões anteriores.
  2. Previsão direta: em vez de prever um passo de cada vez, podemos treinar o modelo para prever todos os \(h\) passos à frente de uma vez. Isso pode ser feito usando um modelo para cada \(h\) ou usando um modelo que prevê um vetor de \(h\) valores.

A verdade é que as duas abordagens podem ser vistas como uma: a previsão recursiva pode ser vista como uma previsão direta para \(h=1\).

Code
from tsbook.datasets.retail import SyntheticRetail
from sktime.utils.plotting import plot_series
from sktime.forecasting.naive import NaiveForecaster

dataset = SyntheticRetail("univariate")
y_train, X_train, y_test, X_test = dataset.load(
    "y_train", "X_train", "y_test", "X_test"
)

7.1 O problema da tendência

A tendência em séries temporais é como um constante problema de data drift:

Code
_X = [y_train.iloc[i : i + 7] for i in range(0, 700)]

_X_test = [y_train.iloc[i : i + 7] for i in range(700, 800)]


def set_index(x):
    x.index = range(len(x))
    return x


_X = [set_index(x) for x in _X]
_X_test = [set_index(x) for x in _X_test]

import matplotlib.pyplot as plt

fig, ax = plt.subplots()
for x in _X:
    ax.plot(x, color="gray", alpha=0.3)
for x in _X_test:
    ax.plot(x, color="red", alpha=0.3)

# Add legend, with 1 red line for test and 1 gray for train
from matplotlib.lines import Line2D

legend_handles = [
    Line2D([0], [0], color="gray", alpha=0.3, lw=2, label="Treino"),
    Line2D([0], [0], color="red", alpha=0.3, lw=2, label="Teste"),
]
ax.legend(handles=legend_handles, loc="best")
ax.set_title("Série original - Magnitudes diferentes para cada janela")
fig.show()

Quando criamos nossas janelas e olhamos treine e teste, esse problema fica claro. A informação de uma série em treino não é util para prever a série de teste, pois elas estão em magnitudes diferentes.

Uma possível solução para isso é normalizar cada janela, dividindo pelo valor médio da janela. Assim, todas as janelas ficam na mesma escala:

Code
_X = [x / x.mean() for x in _X]
_X_test = [x / x.mean() for x in _X_test]

fig, ax = plt.subplots()
for x in _X:
    ax.plot(x, color="gray", alpha=0.3)
for x in _X_test:
    ax.plot(x, color="red", alpha=0.3)

ax.legend(handles=legend_handles, loc="best")
ax.set_title("Série normalizada")
fig.show()

e podemos prever sem problemas.

Outra possibilidade é a diferenciação, como já vimos em capítulos anteriores. A diferenciação remove a tendência da série, tornando-a estacionária.

7.2 Usando modelos de ML com sktime

Primeiro, vamos import ReductionForecaster, que é a classe que implementa a abordagem de janelas deslizantes para usar modelos de ML em séries temporais. Vamos testar um primeiro caso sem nenhum tipo de preprocessamento, apenas criando as janelas:

from tsbook.forecasting.reduction import ReductionForecaster
from lightgbm import LGBMRegressor

model = ReductionForecaster(
    LGBMRegressor(n_estimators=100, random_state=42),
    window_length=30,
)

model.fit(y_train, X=X_train)
[LightGBM] [Info] Auto-choosing col-wise multi-threading, the overhead of testing was 0.000266 seconds.
You can set `force_col_wise=true` to remove the overhead.
[LightGBM] [Info] Total Bins 7652
[LightGBM] [Info] Number of data points in the train set: 1618, number of used features: 31
[LightGBM] [Info] Start training from score 580.323857
ReductionForecaster(estimator=LGBMRegressor(random_state=42), window_length=30)
Please rerun this cell to show the HTML repr or trust the notebook.
y_pred = model.predict(fh=y_test.index, X=X_test)
plot_series(y_train, y_test, y_pred, labels=["Treino", "Teste", "Previsão com ML"])
plt.show()

Claramente, tivemos o problema que mencionamos anteriormente.

7.2.1 Solução 1: Diferenciação

Uma solução é usar a diferenciação para remover a tendência da série.

from sktime.transformations.series.difference import Differencer

regressor = LGBMRegressor(n_estimators=100, random_state=42)

model = Differencer() * ReductionForecaster(
    regressor,
    window_length=30,
    steps_ahead=1,
)

model.fit(y_train, X=X_train)
[LightGBM] [Info] Auto-choosing col-wise multi-threading, the overhead of testing was 0.000252 seconds.
You can set `force_col_wise=true` to remove the overhead.
[LightGBM] [Info] Total Bins 7638
[LightGBM] [Info] Number of data points in the train set: 1618, number of used features: 31
[LightGBM] [Info] Start training from score 1.496292
TransformedTargetForecaster(steps=[Differencer(),
                                   ReductionForecaster(estimator=LGBMRegressor(random_state=42),
                                                       window_length=30)])
Please rerun this cell to show the HTML repr or trust the notebook.
y_pred_diff = model.predict(fh=y_test.index, X=X_test)

plot_series(
    y_train, y_test, y_pred_diff, labels=["Treino", "Teste", "Previsão com ML + Diferença"]
)
plt.show()

Aqui, já vemos uma melhora significativa. Mas tem algo que podemos melhorar para realizar a diferenciação? Sim. Lembre que essa série tem um padrão multiplicativo. Então, antes de aplicar a diferenciação, podemos aplicar uma transformação logarítmica para estabilizar a variância:

from sktime.transformations.series.boxcox import LogTransformer

model_log = LogTransformer() * model

model_log.fit(y_train, X=X_train)
y_pred_log_diff = model_log.predict(fh=y_test.index, X=X_test)
plot_series(
    y_train,
    y_test,
    y_pred_log_diff,
    labels=["Treino", "Teste", "Previsão com ML + Log + Diferença"],
)
[LightGBM] [Info] Auto-choosing col-wise multi-threading, the overhead of testing was 0.000217 seconds.
You can set `force_col_wise=true` to remove the overhead.
[LightGBM] [Info] Total Bins 7652
[LightGBM] [Info] Number of data points in the train set: 1618, number of used features: 31
[LightGBM] [Info] Start training from score 0.002516

7.2.2 Solução 2: Normalização por janela

A diferenciação aumenta o ruído da série, o que pode dificultar o trabalho do modelo de ML. Outra opção é normalizar em cada janela. A classe ReductionForecaster tem um parâmetro chamado normalization_strategy, que pode ser usado para determinar a estratégia de normalização. Vamos usar a estratégia divide_mean, que divide cada janela pelo seu valor médio.

model = ReductionForecaster(
    regressor,
    window_length=30,
    steps_ahead=1,
    normalization_strategy="divide_mean",
)

model.fit(y_train, X=X_train)
y_pred_norm = model.predict(fh=y_test.index, X=X_test)

plot_series(
    y_train, y_test, y_pred_norm, labels=["Treino", "Teste", "Previsão com ML + Normalização"]
)
[LightGBM] [Info] Auto-choosing col-wise multi-threading, the overhead of testing was 0.000261 seconds.
You can set `force_col_wise=true` to remove the overhead.
[LightGBM] [Info] Total Bins 7652
[LightGBM] [Info] Number of data points in the train set: 1618, number of used features: 31
[LightGBM] [Info] Start training from score 1.043527

7.2.3 Modelo direto e recursivo

Podemos fazer um conjunto de modelos, um para cada passo à frente. Abaixo, definimos steps_ahead=12, o que significa que o modelo vai prever 12 passos à frente diretamente.

model = ReductionForecaster(
    regressor,
    window_length=30,
    steps_ahead=12,
    normalization_strategy="divide_mean",
)

model.fit(y_train, X=X_train)
y_pred_norm_direct = model.predict(fh=y_test.index, X=X_test)
[LightGBM] [Info] Auto-choosing col-wise multi-threading, the overhead of testing was 0.000226 seconds.
You can set `force_col_wise=true` to remove the overhead.
[LightGBM] [Info] Total Bins 7652
[LightGBM] [Info] Number of data points in the train set: 1618, number of used features: 31
[LightGBM] [Info] Start training from score 1.043527
[LightGBM] [Info] Auto-choosing col-wise multi-threading, the overhead of testing was 0.000236 seconds.
You can set `force_col_wise=true` to remove the overhead.
[LightGBM] [Info] Total Bins 7652
[LightGBM] [Info] Number of data points in the train set: 1617, number of used features: 31
[LightGBM] [Info] Start training from score 1.047267
[LightGBM] [Info] Auto-choosing col-wise multi-threading, the overhead of testing was 0.000258 seconds.
You can set `force_col_wise=true` to remove the overhead.
[LightGBM] [Info] Total Bins 7652
[LightGBM] [Info] Number of data points in the train set: 1616, number of used features: 31
[LightGBM] [Info] Start training from score 1.050587
[LightGBM] [Info] Auto-choosing col-wise multi-threading, the overhead of testing was 0.000264 seconds.
You can set `force_col_wise=true` to remove the overhead.
[LightGBM] [Info] Total Bins 7652
[LightGBM] [Info] Number of data points in the train set: 1615, number of used features: 31
[LightGBM] [Info] Start training from score 1.053028
[LightGBM] [Info] Auto-choosing col-wise multi-threading, the overhead of testing was 0.000234 seconds.
You can set `force_col_wise=true` to remove the overhead.
[LightGBM] [Info] Total Bins 7652
[LightGBM] [Info] Number of data points in the train set: 1614, number of used features: 31
[LightGBM] [Info] Start training from score 1.055194
[LightGBM] [Info] Auto-choosing col-wise multi-threading, the overhead of testing was 0.000278 seconds.
You can set `force_col_wise=true` to remove the overhead.
[LightGBM] [Info] Total Bins 7652
[LightGBM] [Info] Number of data points in the train set: 1613, number of used features: 31
[LightGBM] [Info] Start training from score 1.057457
[LightGBM] [Info] Auto-choosing row-wise multi-threading, the overhead of testing was 0.000106 seconds.
You can set `force_row_wise=true` to remove the overhead.
And if memory is not enough, you can set `force_col_wise=true`.
[LightGBM] [Info] Total Bins 7652
[LightGBM] [Info] Number of data points in the train set: 1612, number of used features: 31
[LightGBM] [Info] Start training from score 1.060191
[LightGBM] [Info] Auto-choosing col-wise multi-threading, the overhead of testing was 0.000229 seconds.
You can set `force_col_wise=true` to remove the overhead.
[LightGBM] [Info] Total Bins 7652
[LightGBM] [Info] Number of data points in the train set: 1611, number of used features: 31
[LightGBM] [Info] Start training from score 1.063314
[LightGBM] [Info] Auto-choosing col-wise multi-threading, the overhead of testing was 0.000210 seconds.
You can set `force_col_wise=true` to remove the overhead.
[LightGBM] [Info] Total Bins 7652
[LightGBM] [Info] Number of data points in the train set: 1610, number of used features: 31
[LightGBM] [Info] Start training from score 1.066550
[LightGBM] [Info] Auto-choosing col-wise multi-threading, the overhead of testing was 0.000209 seconds.
You can set `force_col_wise=true` to remove the overhead.
[LightGBM] [Info] Total Bins 7652
[LightGBM] [Info] Number of data points in the train set: 1609, number of used features: 31
[LightGBM] [Info] Start training from score 1.069536
[LightGBM] [Info] Auto-choosing col-wise multi-threading, the overhead of testing was 0.000210 seconds.
You can set `force_col_wise=true` to remove the overhead.
[LightGBM] [Info] Total Bins 7652
[LightGBM] [Info] Number of data points in the train set: 1608, number of used features: 31
[LightGBM] [Info] Start training from score 1.072317
[LightGBM] [Info] Auto-choosing row-wise multi-threading, the overhead of testing was 0.000100 seconds.
You can set `force_row_wise=true` to remove the overhead.
And if memory is not enough, you can set `force_col_wise=true`.
[LightGBM] [Info] Total Bins 7652
[LightGBM] [Info] Number of data points in the train set: 1607, number of used features: 31
[LightGBM] [Info] Start training from score 1.074787
plot_series(
    y_train,
    y_test,
    y_pred_norm_direct,
    labels=["Treino", "Teste", "Previsão com ML + Normalização"],
)

Podemos comparar o MAPE de todos os modelos:

from sktime.performance_metrics.forecasting import MeanAbsolutePercentageError

mape = MeanAbsolutePercentageError()

results = {}
for _y_pred, label in zip(
    [
        y_pred,
        y_pred_diff,
        y_pred_log_diff,
        y_pred_norm,
        y_pred_norm_direct,
    ],
    [
        "ML",
        "ML + Diferença",
        "ML + Log + Diferença",
        "ML + Normalização",
        "ML + Normalização + Direto",
    ],
):
    results[label] = mape(y_test, _y_pred)

import pandas as pd

results = pd.DataFrame.from_dict(results, orient="index", columns=["MAPE"])
results.sort_values("MAPE")
MAPE
ML + Normalização + Direto 0.145813
ML + Log + Diferença 0.153906
ML + Normalização 0.156855
ML + Diferença 0.231855
ML 0.234599