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!
Tabla de contenido
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:
- La variable temperatura como entrada al modelo. En este caso introduciremos registros continuos de 24 horas de temperatura a la Red LSTM.
- La misma variable temperatura será la salida al modelo y la idea es que la Red LSTM aprenda a predecir la hora 25.
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:
- Partición del dataset en los subsets de entrenamiento, validación y prueba
- Generación del dataset supervisado (entradas y salidas del modelo)
- 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:
- El set de entrenamiento (que en adelante será la variable
train
) se usará para encontrar los parámetros del modelo - El set de validación (
val
) nos permitirá verificar que no haya underfitting u overfitting del modelo y adicionalmente nos permitirá ajustar los hiperparámetros (como la tasa de aprendizaje, el número de iteraciones de entrenamiento y el tamaño del lote, entre otros). - El set de prueba (
test
) permitirá poner a prueba el mejor modelo encontrado durante el entrenamiento y la validación y nos permitirá evaluar la capacidad del modelo de generalizar (es decir de generar predicciones sobre datos que nunca antes ha visto).
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:
- El set
train
contendrá la primera porción de la serie de tiempo (variable temperatura) correspondiente al 80% de la totalidad de los datos. - El set
val
contendrá la segunda porción de la serie de tiempo y correspondiente al 10% de la totalidad de los datos. - Finalmente, el set
test
contendrá la última porción de la serie de tiempo y correspondiente al 10% restante.
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:
- Ingresaremos al modelo registros continuos de 24 horas para la variable temperatura y
- El modelo deberá aprender a predecir una hora a futuro
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:
- Entrada (X): arreglo de tamaño batches x input_length x features
- Salida (Y): arreglo de tamaño batches x output_length x features
donde:
- batches es el número total de datos de entrada y de salida. Por ejemplo, el set de entrenamiento tiene un total de 40.179 datos pero estos deberán ser divididos en bloques consecutivos de 25 registros (24 horas de entrada + 1 hora de salida). Cada uno de estos bloques será un dato de entrenamiento y los batches serán simplemente el número total de bloques de 25 horas.
- input_length es el número de registros consecutivos que usaremos a la entrada del modelo. En nuestro caso este valor es igual a 24 (horas) y esto se convertirá en un hiperparámetro del modelo que se podrá modificar y que impactará el desempeño del modelo obtenido.
- features es simplemente el número de variables de entrada o de salida que usaremos. Como estamos usando un enfoque univariado y a la salida haremos la predicción únicamente de la variable temperatura, en ambos casos tendremos features = 1
- output_length es el número de horas a futuro que queremos predecir con el modelo. Por tratarse de un enfoque unistep, en este caso tendremos output_length = 1
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:
- X: batches x 24 x 1
- Y: batches x 1 x 1
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:
- En la primera iteración crearemos el bloque 1. Para ello tomaremos del registro 0 al 23 de
array
y lo almacenaremos como un elemento de la listaX
, mientras que el registro 24 quedará almacenado como un elemento de la listaY
- En la segunda iteración crearemos el bloque 2. Para ello nos desplazaremos una muestra a la derecha con respecto al primer bloque, es decir que tomaremos del registro 1 al 24 de
array
y lo almacenaremos como un elemento de la listaX
, mientras que el registro 25 quedará almacenado como un elemento de la listaY
- Para las demás iteraciones seguiremos la misma lógica de la iteración anterior hasta haber recorrido la totalidad de la serie de tiempo
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:
- El set de entrenamiento (
x_tr
,y_tr
) tendrá un total de 40.154 batches (o datos de entrenamiento) - El set de validación (
x_vl
,y_vl
) tendrá un total de 4.997 batches (o datos de validación) y - El set de prueba (
x_ts
,y_ts
) tendrá un total de 4.998 batches (o datos de prueba)
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:
x_tr
: set de entrenamiento a la entrada del modeloy_tr
: set de entrenamiento a la salida del modelox_vl
: set de validación a la entrada del modeloy_vl
: set de validación a la salida del modelox_ts
: set de prueba a la entrada del modeloy_ts
: set de prueba a la salida del modelo
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 salida
data_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:
- $y$ es la temperatura real (en el rango de -1 a 1)
- $\hat{y}$ es la temperatura predicha (en el rango de -1 a 1)
- $N$ es el número total de predicciones
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:
- La pérdida se reduce progresivamente en ambos casos a medida que aumentan las iteraciones
- El nivel de pérdida en la iteración 80 es similar en ambos casos (aproximadamente 0.02 con los sets de entrenamiento y validación)
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:
- Uno o múltiples registros de 24 horas de temperatura escalados en el rango de -1 a 1 (variable
x
) - El modelo entrenado (variable
model
) - Y el escalador (variable
scaler
) requerido para realizar el escalamiento inverso de la predicción generada por el modelo
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].