Forecasting con Redes LSTM - Parte 3: modelo univariado-unistep

En esta tercera parte de la serie veremos cómo implementar una Red LSTM para realizar predicciones sobre series de tiempo usando el enfoque univariado-unistep.

Al final del artículo se encuentra el enlace de descarga del código fuente y del set de datos para este tutorial.

¡Así que listo, comencemos!

Video

Como siempre, en el canal de YouTube se encuentra el video de este post:

Introducción

En el artículo anterior vimos cómo preparar los datos para implementar Redes LSTM al momento de realizar pronósticos sobre series de tiempo y allí hablamos específicamente del análisis exploratorio y el pre-procesamiento del dataset. El set de datos que allí obtuvimos será el punto de partida para este tutorial.

Así que en este tercer artículo de la serie nos enfocaremos en la implementación del primer tipo de modelo predictivo: una Red LSTM univariada-unistep.

La mayor parte del código que implementaremos en este tutorial será el punto de partida para los próximos tutoriales de esta serie. Así que veamos cómo usar Python y las librerías Tensorflow y Keras para implementar y poner a prueba este primer modelo.

El set de datos

Sugiero revisar el anterior artículo de esta serie en donde realizamos la preparación del set de datos, que es el punto de partida para esta implementación. Al final del artículo encontrarás el enlace de descarga de este dataset.

Comencemos haciendo la lectura de este set de datos, teniendo en cuenta que toda la implementación será ejecutada en Google Colab:

from google.colab import drive
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np

# Montar Google Drive
drive.mount('/gdrive')

# Leer set de datos
ruta = '/gdrive/MyDrive/02-CODIFICANDOBITS.COM/05-YouTube/02-Videos/2023-04-21-LSTMSeriesTiempo-PreparaciónDatos/release/data/'
df = pd.read_csv(ruta+'weather_dataset_preprocesado.csv')
df

En este caso usaremos la librería Pandas y en particular la función read_csv para realizar la lectura del set de datos.

Recordemos que este set de datos a sido pre-procesado para garantizar que no existen datos faltantes ni valores extremos y que existe una periodicidad de exactamente 1 hora entre muestras consecutivas.

Este set de datos contiene un total de 14 variables ambientales, entre las que se encuentra la temperatura del aire, la presión y la dirección y velocidad del viento, entre otras.

El problema a resolver

En este tutorial implementaremos una Red LSTM univariada-unistep.

El término “univariada” se refiere a que a la entrada del modelo introduciremos una única variable, mientras que a la salida también tendremos una única variable pero intentaremos predecir sólo 1 hora a futuro (de allí el término “unistep”).

En este y en los próximos tutoriales nos enfocaremos en la predicción de la temperatura del aire. Y en este caso en particular tendremos:

Veamos inicialmente cómo realizar el pre-procesamiento de los datos requerido para poder realizar el entrenamiento y prueba del modelo.

Pre-procesamiento de los datos

El objetivo de esta fase de pre-procesamiento es ajustar los datos al formato requerido por la Red LSTM para de esta forma poder entrenarla y validarla correctamente.

Esencialmente involucra tres fases:

  1. Partición del dataset en los subsets de entrenamiento, validación y prueba
  2. Generación del dataset supervisado (entradas y salidas del modelo)
  3. Escalamiento de los datos

Veamos entonces en detalle cada una de estas fases, comenzando con la partición de los datos.

Sets de entrenamiento, validación y prueba

El objetivo de la partición del dataset en los subsets de entrenamiento, validación y prueba es poder no sólo entrenar la Red LSTM sino validarla correctamente (verificando que no haya underfitting u overfitting).

En particular:

A diferencia de otros modelos de Machine Learning, en el caso de series de tiempo y las Redes LSTM se debe garantizar que se generan las particiones sin mezclar aleatoriamente los datos.

Lo anterior implica que los sets train, val y test deberán ser obtenidos garantizando que todos los registros son consecutivos.

Teniendo esto en cuenta haremos la partición de la siguiente forma:

Para realizar esta partición implementaremos la función train_val_test_split que tomará como entrada la serie de tiempo (columna T (degC) del dataset) y los porcentajes correspondientes a los sets de entrenamiento, validación y prueba:

def train_val_test_split(serie, tr_size=0.8, vl_size=0.1, ts_size=0.1 ):
    # Definir número de datos en cada subserie
    N = serie.shape[0]
    Ntrain = int(tr_size*N)  # Número de datos de entrenamiento
    Nval = int(vl_size*N)    # Número de datos de validación
    Ntst = N - Ntrain - Nval # Número de datos de prueba

    # Realizar partición
    train = serie[0:Ntrain]
    val = serie[Ntrain:Ntrain+Nval]
    test = serie[Ntrain+Nval:]

    return train, val, test

En la función anterior podemos ver que la partición se realiza primero definiendo la cantidad de datos equivalente al porcentaje correspondiente (Ntrain, Nval y Ntst) y luego tomando las porciones correspondientes de la serie original para crear los subsets de entrenamiento (serie[0:Ntrain]), validación (serie[Ntrain:Ntrain+Nval]) y prueba (serie[Ntrain+Nval:]).

Habiendo creado esta función, simplemente debemos llamarla para crear los tres subsets de datos:

tr, vl, ts = train_val_test_split(df['T (degC)'])

# Imprimir en pantalla el tamaño de cada subset
print(f'Tamaño set de entrenamiento: {tr.shape}')
print(f'Tamaño set de validación: {vl.shape}')
print(f'Tamaño set de prueba: {ts.shape}')

Al ejecutar estas líneas de código tendremos las variables tr, vl y ts que contendrán los sets de entrenamiento, validación y prueba respectivamente. Al imprimir en pantalla el tamaño de estos arreglos obtendremos:

Tamaño set de entrenamiento: (40179,)
Tamaño set de validación: (5022,)
Tamaño set de prueba: (5023,)

tamaños que corresponden respectivamente al 80%, 10% y 10% del set de datos original.

Veamos entonces cómo llevar a cabo la siguiente fase del pre-procesamiento: la generación del dataset supervisado.

Dataset supervisado

El objetivo de esta fase es ajustar nuestros sets de entrenamiento, validación y prueba al formato requerido por la Red LSTM para realizar el entrenamiento y posteriormente las predicciones.

Esto implica generar los arreglos X (entrada al modelo) y Y (salida del modelo) de forma tal que al crear y entrenar la Red LSTM esta aprenda a predecir la temperatura de forma adecuada.

En este caso decimos que debemos crear el dataset supervisado puesto que debemos presentar al modelo tanto la entrada (X) como la salida (Y) y cuando entrenamos un modelo presentando tanto las entradas como las salidas estamos usando precisamente un enfoque de aprendizaje supervisado.

Según lo mencionado anteriormente, para este modelo predictivo usaremos el enfoque univariado-unistep, y especificamente:

Lo anterior quiere decir que si por ejemplo introducimos al modelo las horas 1 a 24 del set de entrenamiento, la salida correspondiente será la hora número 25. Pero si ingresamos las horas 2 a 25 el modelo deberá predecir la hora 26 y así sucesivamente.

Teniendo en cuenta lo especificado en la documentación de las Redes LSTM en TensorFlow/Keras, debemos estructurar nuestros sets de la siguiente manera:

donde:

Teniendo en cuenta lo anterior, podemos concluir que cada subset (entrenamiento, validación y prueba) deberá ser pre-procesado para obtener los arreglos X (entradas) y Y (salidas) los cuales tendrán estos tamaños:

Veamos en detalle cómo hacer esta implementación.

Primero crearemos dos listas de Python vacías en donde almacenaremos los arreglos X y Y:

X, Y = [], []

A continuación haremos un barrido sobre la serie de tiempo (variable array) usando bloques consecutivos de 25 horas (24 horas de entrada, 1 hora a predecir) y almacenaremos cada uno de estos bloques en las variables X y Y. Así por ejemplo:

Lo anterior lo podemos implementar en unas pocas líneas de código:

fils, cols = array.shape[0], 1

for i in range(fils-input_length-output_length):
	X.append(array[i:i+INPUT_LENGTH,0:cols])
    Y.append(array[i+input_length:i+input_length+output_length,-1].reshape(output_length,1))

donde fils es el número de filas de la serie de tiempo (es decir el número total de datos) y cols en este caso es igual a 1 (pues tenemos sólo una variable: la temperatura)

Una vez realizado el barrido las listas X y Y contendrán nuestro dataset supervisado. Lo único que nos resta es convertirlas en arreglos de NumPy, el formato requerido por la Red LSTM que implementaremos en TensorFlow/Keras:

X = np.array(X)
Y = np.array(Y)

Y podemos combinar todas las porciones de código anteriores en una sola función (crear_dataset_supervisado) que implementa esta fase de pre-procesamiento:

def crear_dataset_supervisado(array, input_length, output_length):

    # Inicialización
    X, Y = [], []    # Listados que contendrán los datos de entrada y salida del modelo
    shape = array.shape
    if len(shape)==1: # Si tenemos sólo una serie (univariado)
        fils, cols = array.shape[0], 1
        array = array.reshape(fils,cols)
    else: # Multivariado
        fils, cols = array.shape

    # Generar los arreglos
    for i in range(fils-input_length-output_length):
        X.append(array[i:i+INPUT_LENGTH,0:cols])
        Y.append(array[i+input_length:i+input_length+output_length,-1].reshape(output_length,1))
    
    # Convertir listas a arreglos de NumPy
    X = np.array(X)
    Y = np.array(Y)
    
    return X, Y

Donde he agregado a esta función un par de líneas de código adicionales:

    else: # Multivariado
        fils, cols = array.shape

lo que nos permitirá realizar el mismo procesamiento pero para modelos que contengan múltiples variables de entrada (como los que veremos en los próximos tutoriales de esta serie).

Lo único que nos resta es llamar esta función para crear los datasets supervisados para los subsets de entrenamiento, validación y prueba obtenidos en la fase anterior:

# Definición de los hiperparámetros INPUT_LENGTH y OUTPUT_LENGTH
INPUT_LENGTH = 24    # Registros de 24 horas consecutivas a la entrada
OUTPUT_LENGTH = 1    # El modelo va a predecir 1 hora a futuro

# Datasets supervisados para entrenamiento (x_tr, y_tr), validación
# (x_vl, y_vl) y prueba (x_ts, y_ts)
x_tr, y_tr = crear_dataset_supervisado(tr.values, INPUT_LENGTH, OUTPUT_LENGTH)
x_vl, y_vl = crear_dataset_supervisado(vl.values, INPUT_LENGTH, OUTPUT_LENGTH)
x_ts, y_ts = crear_dataset_supervisado(ts.values, INPUT_LENGTH, OUTPUT_LENGTH)

Finalmente, podemos imprimir en pantalla el tamaño de cada uno de los arreglos que acabamos de crear:

print('Tamaños entrada (BATCHES x INPUT_LENGTH x FEATURES) y de salida (BATCHES x OUTPUT_LENGTH x FEATURES)')
print(f'Set de entrenamiento - x_tr: {x_tr.shape}, y_tr: {y_tr.shape}')
print(f'Set de validación - x_vl: {x_vl.shape}, y_vl: {y_vl.shape}')
print(f'Set de prueba - x_ts: {x_ts.shape}, y_ts: {y_ts.shape}')

obteniendo este resultado:

Tamaños entrada (BATCHES x INPUT_LENGTH x FEATURES) y de salida (BATCHES x OUTPUT_LENGTH x FEATURES)
Set de entrenamiento - x_tr: (40154, 24, 1), y_tr: (40154, 1, 1)
Set de validación - x_vl: (4997, 24, 1), y_vl: (4997, 1, 1)
Set de prueba - x_ts: (4998, 24, 1), y_ts: (4998, 1, 1)

Con lo cual podemos verificar que los sets X y Y tienen los tamaños especificados por la librería TensorFlow/Keras. En particular:

Y además cada dato de entrenamiento, validación y prueba tendrá un tamaño de 24x1 (INPUT_LENGTH x FEATURES) para la entrada y de 1x1 (OUTPUT_LENGTH x FEATURES) para la salida.

Y con esto ya hemos creado nuestros datasets supervisados. Nos resta la última fase del pre-procesamiento que consiste en el escalamiento de los datos.

Escalamiento de los datos

Antes de entrenar cualquier modelo de Deep Learning, incluyendo las Redes LSTM, debemos garantizar que las variables que alimentan el modelo se encuentran en el mismo rango de valores. Esto facilitará la convergencia del algoritmo de optimización usado durante el entrenamiento, lo que a su vez mejorará las predicciones obtenidas con el modelo entrenado.

Aunque existen varias estrategias de escalamiento (como la normalización o la estandarización), en este tutorial usaremos una estrategia simple: escalaremos los valores al rango de -1 a 1.

Así por ejemplo, si las temperaturas mínima y máxima están en el rango de -20 °C a 35 °C, al hacer el escalamiento estos valores estarán en el rango de -1 a 1.

Lo anterior implica que la Red LSTM será entrenada con valores escalados y que, por tanto, generará predicciones dentro de esta escala (-1 a 1). Por esta razón, una vez entrenado el modelo y al momento de generar las predicciones tendremos que realizar un escalamiento inverso, llevando las predicciones del rango de -1 a 1 al rango normal de temperaturas.

Para realizar este escalamiento usaremos la función MinMaxScaler de la librería Scikit-learn:

from sklearn.preprocessing import MinMaxScaler

Y lo que haremos será implementar una función (escalar_dataset) que nos permitirá escalar el set de datos tanto para esta implementación (predicción univariada-unistep) como para los demás tipos de predicciones que vendrán en los próximos tutoriales.

La función escalar_dataset tomará como entrada un diccionario de Python que tendrá los siguientes pares key-value:

En primer lugar, y para garantizar que la función podrá ser usada con una o múltiples variables de entrada, determinamos el número total de características de entrada:

NFEATS = data_input['x_tr'].shape[2]

Posteriormente generamos un listado de scalers (de tipo MinMaxScaler) que contendrá un total de NFEATS escaladores (uno por cada variable de entrada):

scalers = [MinMaxScaler(feature_range=(-1,1)) for i in range(NFEATS)]

A continuación crearemos un total de 6 arreglos de NumPy de ceros en donde almacenaremos posteriormente los datasets escalados. Cada uno de estos arreglos tendrá el mismo tamaño del correspondiente set de entrenamiento, prueba o validación:

x_tr_s = np.zeros(data_input['x_tr'].shape)
x_vl_s = np.zeros(data_input['x_vl'].shape)
x_ts_s = np.zeros(data_input['x_ts'].shape)
y_tr_s = np.zeros(data_input['y_tr'].shape)
y_vl_s = np.zeros(data_input['y_vl'].shape)
y_ts_s = np.zeros(data_input['y_ts'].shape)

A continuación realizamos el escalamiento. Es importante tener en cuenta que los valores mínimo y máximo de referencia serán los presentes en el set de entrenamiento, y que estos valores se usarán para posteriormente escalar los sets de prueba y validación.

Para escalar el set de entrenamiento con sus propios valores máximo y mínimo usamos el método fit_transform que automáticamente calcula dichos valores extremos y realiza la transformación del set de entrenamiento.

Posteriormente usaremos el método transform sobre los sets de prueba y validación, para tomar los valores máximo y mínimo del set de entrenamiento y realizar la transformación correspondiente.

Teniendo esto en cuenta, realizamos primero el escalamiento de los datos de entrada:

for i in range(NFEATS):
    x_tr_s[:,:,i] = scalers[i].fit_transform(x_tr[:,:,i])
    x_vl_s[:,:,i] = scalers[i].transform(x_vl[:,:,i])
    x_ts_s[:,:,i] = scalers[i].transform(x_ts[:,:,i])

donde para el caso de este tutorial (entradas univariadas) el bloque for realizará sólo una iteración (pues NFEATS=1).

Y a continuación usamos la misma lógica para el escalamiento de los sets de salida, teniendo en cuenta que la lista scalers contendrá sólo 1 escalador, el cuál está indexado como scalers[-1]:

y_tr_s[:,:,0] = scalers[-1].fit_transform(y_tr[:,:,0])
y_vl_s[:,:,0] = scalers[-1].transform(y_vl[:,:,0])
y_ts_s[:,:,0] = scalers[-1].transform(y_ts[:,:,0])

Finalmente, debemos conformar el de salidadata_scaledque tendrá la misma estructura del de entrada a la función pero que contendrá los datasets escalados:

data_scaled = {
    'x_tr_s': x_tr_s, 'y_tr_s': y_tr_s,
    'x_vl_s': x_vl_s, 'y_vl_s': y_vl_s,
    'x_ts_s': x_ts_s, 'y_ts_s': y_ts_s,
    }

Así que si combinamos todas las porciones de código que acabamos de ver, tendremos la implementación completa de la función escalar_dataset:

from sklearn.preprocessing import MinMaxScaler

def escalar_dataset(data_input):
    NFEATS = data_input['x_tr'].shape[2]

    # Generar listado con "scalers"
    scalers = [MinMaxScaler(feature_range=(-1,1)) for i in range(NFEATS)]

    # Arreglos que contendrán los datasets escalados
    x_tr_s = np.zeros(data_input['x_tr'].shape)
    x_vl_s = np.zeros(data_input['x_vl'].shape)
    x_ts_s = np.zeros(data_input['x_ts'].shape)
    y_tr_s = np.zeros(data_input['y_tr'].shape)
    y_vl_s = np.zeros(data_input['y_vl'].shape)
    y_ts_s = np.zeros(data_input['y_ts'].shape)

    # Escalamiento: se usarán los min/max del set de entrenamiento para
    # escalar la totalidad de los datasets

    # Escalamiento Xs
    for i in range(NFEATS):
        x_tr_s[:,:,i] = scalers[i].fit_transform(x_tr[:,:,i])
        x_vl_s[:,:,i] = scalers[i].transform(x_vl[:,:,i])
        x_ts_s[:,:,i] = scalers[i].transform(x_ts[:,:,i])
    
    # Escalamiento Ys
    y_tr_s[:,:,0] = scalers[-1].fit_transform(y_tr[:,:,0])
    y_vl_s[:,:,0] = scalers[-1].transform(y_vl[:,:,0])
    y_ts_s[:,:,0] = scalers[-1].transform(y_ts[:,:,0])

    # Conformar ` de salida
    data_scaled = {
        'x_tr_s': x_tr_s, 'y_tr_s': y_tr_s,
        'x_vl_s': x_vl_s, 'y_vl_s': y_vl_s,
        'x_ts_s': x_ts_s, 'y_ts_s': y_ts_s,
    }

    return data_scaled, scalers[0]

donde adicionalmnte vemos que la función retorna el escalador usado para los datos de salida (scalers[0]) pues, como mencionamos anteriormente, las predicciones generadas por el modelo entrenado estarán en la escala de -1 a 1 y tendremos que usar este escalador para realizar la transformación inversa y obtener las predicciones en el rango de temperatura normal.

Muy bien, habiendo creado esta función lo único que nos resta es llamarla para realizar el escalamiento de nuestro set de datos.

En primer lugar creamos el ` de entrada a la función:

data_in = {
    'x_tr': x_tr, 'y_tr': y_tr,
    'x_vl': x_vl, 'y_vl': y_vl,
    'x_ts': x_ts, 'y_ts': y_ts,
}

y a continuación llamamos la función:

data_s, scaler = escalar_dataset(data_in)

¡Y listo! Las variables data_s y scaler contendrán el set de datos escalado y el escalador.

Implementemos un bloque de código adicional para extraer los datos escalados del data_s`:

x_tr_s, y_tr_s = data_s['x_tr_s'], data_s['y_tr_s']
x_vl_s, y_vl_s = data_s['x_vl_s'], data_s['y_vl_s']
x_ts_s, y_ts_s = data_s['x_ts_s'], data_s['y_ts_s']

¡Y con esto ya estamos listos para la implementación de la Red LSTM!

Creación y entrenamiento de la Red LSTM

La Red LSTM tendrá como entrada los arreglos x (x_tr, x_vl o x_ts), cada uno de tamaño BATCHES X INPUT_LENGTH X FEATURES, y como salidas tendrá los arreglos y (y_tr, y_vl o y_ts), cada uno de tamaño BATCHES X OUTPUT_LENGTH X FEATURES.

Recordemos que en ambos casos (entradas y salidas) la número de características (FEATURES) será igual a 1 (una variable de entrada - una variable de salida), mientras que a la entrada el INPUT_LENGTH serán 24 horas consecutivas y se intentará predecir 1 hora (OUTPUT_LENGTH) a la salida del modelo.

Teniendo esto en cuenta, veamos cómo crear la Red LSTM usando la librería TensorFlow/Keras.

Creación de la Red LSTM

Comencemos importando los módulos Sequential, LSTM y Dense requeridos para la creación del modelo:

from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import LSTM, Dense

Además importaremos el módulo RMSprop que usaremos como optimizador durante el entrenamiento del modelo:

from tensorflow.keras.optimizers import RMSprop

Finalmente, importaremos la librería Tensorflow con el alias tf:

import tensorflow as tf

Habiendo importado las librerías, lo primero que debemos hacer es fijar la semilla del generador de números aleatorios a un valor fijo. Esto garantizará que cada vez que ejecutemos el código los parámetros de la Red LSTM serán inicializados a un valor aleatorio pero este siempre será el mismo. Esto garantizará la reproducibilidad del entrenamiento:

tf.random.set_seed(123)

Además, como realizaremos el entrenamiento usando la GPU proporcionada por Google Colab, debemos activar la opción enable_op_determinism() para garantizar que a pesar de realizar procesamiento en paralelo (a través de la GPU) los resultados serán muy similares cada vez que ejecutemos el entrenamiento:

tf.config.experimental.enable_op_determinism()

Muy bien. Ahora definamos el número de unidades del modelo (que corresponde al tamaño del estado oculto y a la celda de memoria de la Red LSTM ):

N_UNITS = 128

Este número de unidades se convertirá en un hiperparámetro del modelo y se sugiere afinarlo para tratar de incrementar el desempeño de la Red entrenada.

Definamos también el tamaño de cada dato de entrada, que almacenaremos en la variable INPUT_SHAPE:

INPUT_SHAPE = (x_tr_s.shape[1], x_tr_s.shape[2])

Como lo mencionamos anteriormente, cada uno de los datos de entrada será un arreglo de tamaño 24 (horas de entrada) x 1 (feature).

Ahora sí implementemos el modelo usando la lógica explicada en el tutorial de Keras, donde primero crearemos un contenedor vacío (usando el módulo Sequential) y posteriormente de manera secuencial agregaremos la Red LSTM (usando el módulo LSTM) y la capa de salida (usando el módulo Dense):

modelo = Sequential()
modelo.add(LSTM(N_UNITS, input_shape=INPUT_SHAPE))
modelo.add(Dense(OUTPUT_LENGTH, activation='linear'))

Observemos que la capa de salida hemos usado una función de activación lineal (activation='linear') pues la predicción de temperaturas que queremos implementar es como tal una tarea de Regresión.

Compilación del modelo

Una vez creada la Red LSTM, el siguiente paso a llevar a cabo es su compilación, que consiste simplemente en definir la función de error a usar durante el entrenamiento así como el optimizador.

Como función de error usaremos la raíz cuadrada del error cuadrático medio (o RMSE por sus siglas en Inglés: Root Mean Square Error) que es simplemente el resultado de calcular las diferencias cuadráticas entre las temperaturas reales y las predichas (en el rango de -1 a 1), sumar dichas diferencias y promediarlas y posteriormente obtener la raíz cuadrada de este resultado:

$RMSE = \sqrt{\frac{\sum_i (y_i-\hat{y_i})^2}{N}}$

donde:

La ventaja de usar el RMSE es que los errores calculados estarán en las mismas unidades de la variable a predecir (en este caso en °C).

Como esta función no está incluida por defecto en la librería TensorFlow, debemos definirla inicialmente para luego poder usarla durante el entrenamiento. Para ello haremos uso de las funciones square, reduce_mean y sqrt de TensorFlow:

def root_mean_squared_error(y_true, y_pred):
    rmse = tf.math.sqrt(tf.math.reduce_mean(tf.square(y_pred-y_true)))
    return rmse

A continuación crearemos nuestro optimizador, que hará uso del algoritmo RMSprop y donde definiremos una tasa de aprendizaje de 0.00005. Esta tasa de aprendizaje será también un hiperparámetro del modelo y se sugiere afinarla para intentar mejorar la calidad de las predicciones:

optimizador = RMSprop(learning_rate=5e-5)

Con la función de error y el optimizador ya definidos, podemos compilar el modelo usando el método compile:

modelo.compile(
    optimizer = optimizador,
    loss = root_mean_squared_error,
)

¡Perfecto! El siguiente paso es llevar a cabo el entrenamiento de nuestro modelo.

Entrenamiento del modelo

Comenzaremos definiendo el número de iteraciones de entrenamiento (EPOCHS) y el tamaño del lote (BATCH_SIZE) que serán también hiperparámetros del modelo que se deberían afinar posteriormente:

EPOCHS = 80
BATCH_SIZE = 256

A continuación entrenamos el modelo usando el método fit. Los resultados de este entrenamiento serán almacenados en la variable historia:

historia = modelo.fit(
    x = x_tr_s,
    y = y_tr_s,
    batch_size = BATCH_SIZE,
    epochs = EPOCHS,
    validation_data = (x_vl_s, y_vl_s),
    verbose=2
)

Al completar las 80 iteraciones de entrenamiento podremos generar una gráfica de la variable historia para obtener las curvas del comportamiento de la pérdida con los sets de entrenamiento y validación.

Tras obtener esta gráfica podremos observar que:

Con lo anterior podemos verificar que el entrenamiento se ha realizado de forma adecuada y que la Red LSTM no tiene overfitting.

Con el modelo entrenado ya estamos listos para evaluar su desempeño.

Desempeño del modelo entrenado

Suponiendo que los hiperparámetros del modelo ya han sido afinados y que ya hemos realizado el entrenamiento con estos hiperparámetros, el siguiente paso es verificar su desempeño usando el set de datos que hasta el momento no hemos presentado a la Red: el set de prueba.

Podemos entonces calcular el desempeño del modelo obtenido para el set de prueba y compararlo con el desempeño obtenido con los sets de entrenamiento y validación.

Para ello podemos simplemente calcular el RMSE con cada subset usando el método evaluate, que permite tomar el modelo entrenado, generar las predicciones para cada subset y calcular el RMSE correspondiente:

rmse_tr = modelo.evaluate(x=x_tr_s, y=y_tr_s, verbose=0)
rmse_vl = modelo.evaluate(x=x_vl_s, y=y_vl_s, verbose=0)
rmse_ts = modelo.evaluate(x=x_ts_s, y=y_ts_s, verbose=0)

Podemos entonces imprimir en pantalla el RMSE obtenido para cada subset:

print('Comparativo desempeños:')
print(f'  RMSE train:\t {rmse_tr:.3f}')
print(f'  RMSE val:\t {rmse_vl:.3f}')
print(f'  RMSE test:\t {rmse_ts:.3f}')

obteniendo como resultado:

Comparativo desempeños:
  RMSE train:0.025
  RMSE val:	 0.023
  RMSE test: 0.028

En este análisis podemos verificar que los RMSEs obtenidos son comparables (del orden de 0.02 o 2% del pico máximo de 1 °C en la temperatura escalada), lo que nos permite concluir que el modelo generaliza bastante bien (es decir que genera predicciones adecuadas sobre un set de datos que no había visto previamente: el set de entrenamiento).

Predicciones (forecasting) con el modelo entrenado

Bien, teniendo claro que el modelo generaliza bastante bien, lo único que nos resta es ponerlo a prueba generando predicciones.

La idea en este caso es tomar los datos de prueba (registros continuos de 24 horas en cada ejemplo de prueba), presentarlos al modelo y obtener predicciones 1 hora a futuro de la temperatura.

Para ello implementaremos la función predecir, que tomará como entradas:

En primer lugar usaremos el método predict para generar con el modelo entrenado las predicciones escaladas (en el rango de -1 a 1). El resultado de esta predicción será almacenado en la variable y_pred_s.

A continuación tomaremos dichas predicciones escaladas y realizaremos la transformación inversa usando el método inverse_transform del escalador, con lo cual tendremos las predicciones en los niveles reales de temperatura.

La implementación completa de la función es entonces la siguiente:

def predecir(x, model, scaler):
    # Calcular predicción escalada en el rango de -1 a 1
    y_pred_s = model.predict(x,verbose=0)

    # Llevar la predicción a la escala original
    y_pred = scaler.inverse_transform(y_pred_s)

    return y_pred.flatten()

En donde en la última línea hemos usado el método flatten para garantizar que las predicciones son un arreglo unidimensional de NumPy.

Ahora sólo nos resta llamar la función predecir para obtener cada una de las predicciones sobre el set de prueba:

y_ts_pred = predecir(x_ts_s, modelo, scaler)

Para analizar el comportamiento de estas predicciones (5.023 en total, una por cada dato de prueba) podemos simplemente generar una gráfica con las diferencias entre cada par de temperaturas reales y predichas:

N = len(y_ts_pred)    # Número de predicciones (tamaño del set de prueba)
ndato = np.linspace(1,N,N)

# Cálculo de errores simples
errores = y_ts.flatten()-y_ts_pred
plt.plot(errores);

Al generar esta gráfica podemos verificar que en promedio los errores están alrededor de 0°C, lo cual quiere decir que en promedio las temperaturas predichas por el modelo se acercan bastante a los valores reales.

Sin embargo en algunos casos particulares los errores pueden llegar a ser de -7 °C del lado negativo y de +4 °C del lado positivo.

De nuevo, se podría intentar mejorar este aspecto del desempeño afinando los hiperparámetros del modelo.

Enlace de descarga código y set de datos

Conclusión

Muy bien, en este tutorial hemos entrenado un primer modelo de pronóstico sobre series de tiempo haciendo uso de las Redes LSTM y del enfoque más sencillo de todos: el enfoque univariado + uni-step.

Hemos visto además los principales elementos a tener en cuenta para el pre-procesamiento de los datos así como los detalles para la implementación y entrenamiento del modelo.

Cuando evaluamos el desempeño del modelo entrenado, usando el set de prueba, verificamos que la Red LSTM logra generalizar adecuadamente, aunque algunos errores en la predicción son relativamente altos.

En términos generales debemos tener en cuenta que el modelo implementado en este tutorial debería ser afinado para intentar mejorar este desempeño.

En el próximo artículo de esta serie tomaremos como base el código implementado en este tutorial para implementar el segundo enfoque predictivo. Así que implementaremos una Red LSTM para realizar [pronósticos del tipo univariado + multi-step].