Forecasting con Redes LSTM - Parte 2: preparación de los datos

En este artículo comenzaremos a ver el primer paso antes de implementar cualquiera de estos modelos predictivos, así que veremos cómo realizar el análisis exploratorio y el pre-procesamiento del set de datos.

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 un panorama general de los diferentes tipos de predicción que podemos usar al procesar series de tiempo con Redes LSTM.

En este artículo nos enfocaremos en el primer paso antes de implementar cualquiera de estos modelos predictivos, así que veremos cómo realizar el análisis exploratorio y el pre-procesamiento del set de datos.

El set de datos

Usaremos el weather dataset, un set de datos del Instituto Planck para Biogeoquímica que contiene el registro histórico de diferentes variables climáticas.

En particular este dataset:

En este caso usaremos datos recolectados entre 2009 y 2015.

El problema a resolver

Como en todo proyecto de Ciencia de Datos y Machine Learning, el punto de partida es tener claro el problema que queremos resolver.

En esta serie de artículos lo que buscamos es desarrollar modelos predictivos basados en Redes LSTM usando los enfoques:

  1. Univariado + single-step
  2. Univariado + multi-step
  3. Multivariado + single-step
  4. Multivariado + multi-step

Implementaremos estos modelos predictivos en los próximos artículos de la serie, pero el punto de partida en cualquiera de estos casos es garantizar la integridad de nuestro dataset.

Lo anterior quiere decir que debemos asegurarnos de que nuestro dataset ha sido pre-procesado adecuadamente y que los datos resultarán adecuados para implementar las diferentes Redes LSTM.

Este es precisamente el objetivo de este artículo, así que comencemos realizando la lectura del set de datos.

Lectura del set de datos

Comencemos importando las librerías requeridas para nuestro proyecto así como realizando la lectura del set de datos (que se encuentra almacenado en formato CSV).

En particular importaremos las librerías drive de Google Colab así como las librerías Pandas y Matplotlib.

La lectura del dataset la realizaremos usando la función read_csv de Pandas:

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

# 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.csv')
df

Con lo anterior podemos verificar que el set de datos contiene 50.278 registros y 15 variables, entre las que se encuentran la temperatura del aire, la presión y la velocidad y dirección del viento entre otras.

Procesamiento inicial del dataset

La primera fase de procesamiento consiste en convertir la columna Date Time del formato object (string y datos numéricos) al formato datetime.

Esto facilitará la visualización del set de datos y el análisis exploratorio, así como el pre-procesamiento.

Primero verifiquemos que el tipo de dato de esta columna es object:

df['Date Time'].dtype

lo que nos arroja como resultado:

dtype('O')

Para convertir esta columna del formato object al formato datetime podemos usar la función to_datetime de la librería Pandas:

df['datetime'] = pd.to_datetime(
    df['Date Time'],
    format = '%d.%m.%Y %H:%M:%S'
)

Una vez realizada la conversión, podemos fijar esta columna datetime como el nuevo índice de nuestro DataFrame de Pandas, usando la función set_index:

df = df.set_index('datetime')

Adicionalmente debemos garantizar que la variable tiempo de nuestra serie (precisamente el índice datetime) está organizada cronológicamente de manera ascendente. Para esto podemos usar la función sort_index de Pandas:

df.sort_index(inplace=True)

Finalmente, podemos eliminar la columna Date Time que se encontraba en el dataset original, usando la función drop de la librería Pandas:

df = df.drop(columns=['Date Time'])

¡Perfecto! En este punto ya tenemos un set de datos en donde el índice está en el formato deseado (datetime) y donde además las marcas de tiempo tienen un orden cronológico ascendente.

Con esto ya estamos listos para la siguiente fase: el análisis exploratorio de nuestro dataset.

Análisis exploratorio

Paso 1: comprender las variables del set de datos

El primer paso en este análisis es entender las variables de nuestro dataset.

Si revisamos el dataset obtenido en la fase anterior, encontraremos un total de 14 variables:

Además, al momento de implementar los modelos predictivos tendremos dos tipos de variables:

  1. La variable a predecir: que es simplemente la variable que queremos pronosticar y que será la salida de cada uno de los modelos LSTM. En este caso esta variable a predecir es la temperatura (columna T (degC) de nuestro dataset).

  2. Las variables predictoras (o covariables): que será(n) la(s) variable(s) de entrada a los diferentes modelos LSTM y a partir de las cuáles dichos modelos podrán realizar la predicción. En este caso las covariables son todas las columnas de nuestro dataset (incluyendo la misma temperatura a predecir - T (degC) - así como posiblemente la variable tiempo - índice datetime -).

Paso 2: visualizar el set de datos

Para entender cómo estan distribuidas las variables de nuestro dataset podemos realizar una gráfica de cada una de estas series de tiempo.

En este caso podemos usar el método plot incluído en el DataFrame de Pandas (df):

# Columnas del dataset
cols = df.columns

# Dibujar la totalidad de registros
N = df.shape[0]       # Número de registros
plots = df[cols][0:N] # Series de tiempo individuales
plots.index = df.index[0:N] # Variable tiempo
_ = plots.plot(subplots=True, figsize=(12,16))

Con lo anterior podremos generar un total de 14 gráficas (una por cada columna del dataset) y podemos extraer algunas observaciones iniciales:

Con esta visualización inicial podemos ir a la siguiente fase: el análisis de datos faltantes que puedan estar presentes en nuestro dataset.

Paso 3: análisis de datos faltantes

Los datos faltantes serán simplemente aquellas celdas dentro del set de datos que no contienen cantidades numéricas.

Usualmente estas celdas contendrán valores tipo NaN (o Not A Number) y las podemos encontrar fácilmente usando la función isna() de Pandas:

print('Cantidad de NaNs:')
for column in df:
    nans = df[column].isna().sum()
    print(f'\tColumna {column}: {nans}')

Con las anteriores líneas de código podemos analizar cada columna del dataset y calcular e imprimir en pantalla la cantidad total de datos faltantes, obteniendo este resultado:

Cantidad de NaNs:
	Columna p (mbar): 0
	Columna T (degC): 0
	Columna Tpot (K): 0
	Columna Tdew (degC): 0
	Columna rh (%): 0
	Columna VPmax (mbar): 8
	Columna VPact (mbar): 0
	Columna VPdef (mbar): 0
	Columna sh (g/kg): 0
	Columna H2OC (mmol/mol): 13
	Columna rho (g/m**3): 6
	Columna wv (m/s): 0
	Columna max. wv (m/s): 0
	Columna wd (deg): 0

Podemos ver que la mayoría de las columnas no contienen datos faltantes, exceptuando:

En un momento realizaremos el manejo de estos datos faltantes.

Por ahora continuemos con el último paso del análisis exploratorio: el análisis de la periodicidad del dataset.

Paso 4: Análisis de la periodicidad del dataset

En este paso debemos verificar si entre muestras consecutivas del set de datos existe una diferencia temporal de exactamente 1 hora.

Esta verificación es clave para garantizar que los modelos LSTM que implementaremos más adelante aprenderán a detectar adecuadamente los patrones en la serie de tiempo.

Para verificar esta periodicidad podemos simplemente tomar el índice del dataset, que se encuentra en formato datetime y usar la función diff para calcular las diferencias entre instantes de tiempo consecutivos.

Idealmente estas diferencias deberían ser de exactamente 3.600 segundos (o 1 hora) en todos los casos:

df_time_diffs = df.index.to_series().diff().dt.total_seconds()
print(df_time_diffs.value_counts())

Al ejecutar la última línea de código anterior (print(df_time_diffs.value_counts())) podremos ver en pantalla el conteo de datos y sus correspondientes diferencias de tiempo (en segundos) entre muestras consecutivas:

3600.0    50189
1800.0       61
0.0          24
4200.0        2
4800.0        1
Name: datetime, dtype: int64

Con esta parte del análisis podemos concluir que 50.189 registros tienen una periodicidad correcta de 3.600 s (1 hora). Sin embargo, observamos que algunos registros no tienen la periodicidad esperada; en particular:

En estos casos tendremos que llevar a cabo un procesamiento adicional para corregir este comportamiento.

En este punto ya hemos llevado a cabo las principales fases del análisis exploratorio y podemos concluir que nuestro set de datos no contiene outliers y que tendremos que escalar o estandarizar las series de tiempo antes de introducirlas a los modelos predictivos.

Sin embargo también pudimos observar que algunas series de tiempo contienen datos faltantes y en algunos registros no se tiene una periodicidad de 1 hora. En la próxima sección nos enfocaremos en corregir estos dos comportamientos.

Pre-procesamiento

En esta fase nos enfocaremos en dos tareas:

Veamos en primer lugar cómo realizar el manejo de datos faltantes en este caso particular.

Paso 1: manejo de datos faltantes

Como lo vimos hace un momento durante el análisis exploratorio, algunas columnas de nuestro dataset contienen datos faltantes. En particular:

Dado el reducido número de datos faltantes, podemos usar una simple interpolación para completarlos.

Para esto podemos simplemente usar el método interpolate(method='linear') de la librería Pandas:

columns = ['VPmax (mbar)', 'H2OC (mmol/mol)', 'rho (g/m**3)']

for column in columns:
    df[column] = df[column].interpolate(method='linear')

Tras esta interpolación podemos verificar si los datos faltantes fueron interpolados correctamente:

print('Cantidad de NaNs:')
for column in df:
    nans = df[column].isna().sum()
    print(f'\tColumna {column}: {nans}')

Obteniendo el siguiente resultado:

Cantidad de NaNs:
	Columna p (mbar): 0
	Columna T (degC): 0
	Columna Tpot (K): 0
	Columna Tdew (degC): 0
	Columna rh (%): 0
	Columna VPmax (mbar): 0
	Columna VPact (mbar): 0
	Columna VPdef (mbar): 0
	Columna sh (g/kg): 0
	Columna H2OC (mmol/mol): 0
	Columna rho (g/m**3): 0
	Columna wv (m/s): 0
	Columna max. wv (m/s): 0
	Columna wd (deg): 0

Con lo cual verificamos que nuestro dataset ya no contiene datos faltantes.

El siguiente paso es ajustar la periodicidad en algunos de los registros.

Paso 2: ajuste de periodicidad

Durante el análisis exploratorio vimos que la mayoría de los registros (50.189) tienen una periodicidad de exactamente 1 hora. Sin embargo, unos pocos registros tienen periodicidades ligeramente por encima o por debajo de este valor.

En este segundo paso del pre-procesamiento corregiremos este comportamiento.

Comenzaremos corrigiendo aquellos registros cuyas diferencias temporales (entre pares consecutivos de registros) son exactamente iguales a 0.0 segundos. Estas diferencias de 0 s indican que la marca temporal (fecha y hora de cada registro) es exactamente la misma, lo cual nos indica que simplemente se trata de registros repetidos.

Para eliminar estos registros repetidos podemos usar el método drop_duplicates de Pandas:

df.drop_duplicates(keep='first', inplace=True, ignore_index=False)

Y tras ejecutar esta línea de código podemos recalcular las diferencias temporales consecutivas:

df_time_diffs = df.index.to_series().diff().dt.total_seconds()
print(df_time_diffs.value_counts())

con lo cual podemos verificar que ya no existen registros con diferencias de 0 s:

3600.0    50189
1800.0       61
4200.0        2
4800.0        1
Name: datetime, dtype: int64

Muy bien. Ahora nos enfocaremos en los registros con diferencias de 1.800 s (61 registros), 4.200 s (2 registros) y 4.800 s (1 registro).

Para corregir estos comportamientos podemos usar el método asfreq de Pandas, que nos permite re-interpolar el dataset con la periodicidad requerida de 1 hora.

Durante esta reinterpolación estaremos insertando nuevos registros, así que usaremos la opción bfill para que el nuevo registro insertado tenga un valor igual al registro inmediatamente anterior:

df2 = df.asfreq(freq='H', method='bfill')

Finalmente, verifiquemos que la periodicidad del DataFrame resultante (df2) es exactamente 3.600 s en todos los pares de registros consecutivos:

df_time_diffs = df2.index.to_series().diff().dt.total_seconds()
print(df_time_diffs.value_counts())

obteniendo como resultado:

3600.0    50223
Name: datetime, dtype: int64

¡Excelente! El set de datos ya tiene la periodicidad requerida para la posterior implementación de los modelos LSTM.

En este punto ya contamos con un dataset íntegro (no contiene datos faltantes ni outliers y tiene una periodicidad de exactamente 1 hora), que resulta adecuado para las implementaciones que realizaremos en los próximos artículos.

Lo único que nos resta antes de finalizar esta fase de preparación de los datos es almacenar nuestro dataset en formato CSV:

df2.to_csv(ruta+'weather_dataset_preprocesado.csv')

Enlace de descarga código y set de datos

Conclusión

Aunque cada proyecto tendrá sus particularidades, en general el análisis exploratorio y pre-procesamiento de la serie de tiempo requerido para trabajar con Redes LSTM implican:

El dataset que acabamos de preparar será el punto de partida para todos los modelos de Redes LSTM que implementaremos más adelante, comenzando en el próximo artículo con el modelo más simple: el modelo univariado + uni-step para predicciones sobre series de tiempo.