• Saltar a la navegación principal
  • Saltar al contenido principal
Logo Codificando Bits

Codificando Bits

  • Academia
    • Rutas
    • Cursos
    • Proyectos
    • Tutoriales
  • Acceder
  • Suscribirse

Pronósticos sobre series de tiempo con Redes LSTM – parte 6: codificación de la variable temporal

En esta serie de tutoriales estamos viendo diferentes formas de hacer uso de las Redes LSTM para generar pronósticos sobre series de tiempo (lo que se conoce como forecasting).

Y en este sexto tutorial veremos cómo codificar las variables temporales para introducirlas a una Red LSTM para generar predicciones sobre Series de Tiempo.

Al final del tutorial se encuentran los enlaces para descargar el set de datos y el código fuente.

Video

En el canal de YouTube se encuentra el video de este tutorial:

Introducción

En los tutoriales anteriores hemos visto cómo realizar el pre-procesamiento de los datos y cómo entrenar y validar Redes LSTM para generar pronósticos sobre Series de Tiempo usando los enfoques univariado-unistep, univariado-multistep, multivariado-unistep y multivariado-multistep.

Como requisito para este tutorial sugiero revisar el desarrollo que hicimos en la parte 5 de esta serie, donde vimos [cómo implementar una Red LSTM multivariada-multistep] para generar predicciones sobre una Serie de Tiempo que en este caso es la temperatura de una región geográfica en particular.

En este tutorial complementaremos el desarrollo anterior añadiendo como variables de entrada al modelo las variables temporales.

Así que en este tutorial nos enfocaremos únicamente en los conceptos y el código requerido para entender cómo codificar de manera adecuada estas variables temporales y cómo usarlas para entrenar y validar el modelo junto con las variables que ya veníamos usando en los tutoriales anteriores.

Características de la Red LSTM

En el tutorial anterior vimos que la Red LSTM era alimentada con 13 covariables de entrada (temperatura, presión, velocidad del viento, etc.) y que a la salida generaba la predicción de 4 horas de temperatura a futuro. Por tanto se trataba de un modelo multivariado (múltiples variables de entrada) – multistep (múltiples instantes de tiempo en la predicción).

En este tutorial usaremos este mismo esquema de predicción, con la diferencia de que ahora añadiremos cuatro variables temporales a las 13 covariables originales usadas como entrada al modelo.

Para entender cuáles serán las variables temporales más adecuadas para generar las predicciones y además cómo las presentaremos al modelo, comencemos viendo algunas características básicas de dichas variables temporales.

Características básicas de las variables temporales

Recordemos, según lo visto en tutoriales anteriores, que el set de datos tiene un índice que contiene precisamente las variables temporales representadas en el formato año-mes-día horas:minutos y segundos.

Por ejemplo, imprimamos en pantalla el primer y último elementos del índice así como la diferencia temporal entre estos dos datos:

print(df.index[0])             # Primer elemento del índice
print(df.index[-1])            # Último elemento del índice
print(df.index[-1]-df.index[0])# Diferencia

Con lo cual obtenemos este resultado:

2009-01-01 01:00:00
2014-09-24 16:00:00
2092 days 15:00:00

Con lo anterior podemos verificar que nuestra serie de tiempo va del 1 de enero de 2009 al 24 de septiembre de 2014 y que cubre un periodo de 2092 días y 15 horas, es decir casi 6 años.

Además, podemos usar el método infer_freq() de la librería Pandas para verificar la frecuencia de nuestra Serie de Tiempo:

pd.infer_freq(df.index)

obteniendo como resultado:

'H'

El resultado anterior nos indica que la Serie de Tiempo tiene frecuencia horaria, es decir que por cada hora tenemos un registro.

Con el análisis anterior podemos ver que cada marca temporal tiene los datos de año, mes, día y hora.

El objetivo es entonces presentar esta información al modelo pero antes de determinar cómo hacerlo tenemos qué determinar cuáles de estas variables (año, mes, día, hora) son relevantes al momento de predecir la temperatura.

¿Qué elementos de la variable temporal resultan relevantes?

Podríamos presentar al modelo toda la información incluida en cada marca de tiempo (año, mes, día, hora) pero esto podría afectar el entrenamiento pues tendríamos muchas variables y no necesariamente todas resultarían siendo relevantes.

La pregunta es entonces, ¿cómo sabemos cuáles de estas variables temporales son más relevantes al momento de predecir la temperatura?

Una manera de lograr esto es usando la Transformada de Fourier, una herramienta matemática que nos permite conocer los componentes en frecuencia de una señal.

Los componentes en frecuencia son aquellos elementos de la señal que se repiten con una cierta periodicidad (cada cierto tiempo) y eso es lo que nos interesa encontrar en nuestra señal.

Entendamos la idea de la Transformada de Fourier con un ejemplo: supongamos que tenemos una señal cosenoidal, con una frecuencia de 100 Hz (es decir que oscila 100 veces por segundo).

Si analizamos la señal en el dominio del tiempo (es decir si la graficamos y ubicamos en el eje horizontal de la gráfica la variable tiempo) veremos que esta señal se repite periódicamente. Por ejemplo entre dos máximos consecutivos de la señal habrá una distancia de 0.01 segundos. Esto es lo que se conoce como el periodo de la señal.

Pero además de analizar la señal en el dominio del tiempo, lo podemos hacer en el dominio de la frecuencia.

Es decir que podemos usar la Transformada de Fourier para obtener lo que se conoce como el espectro en frecuencia de esta señal cosenoidal.

Si dibujamos este espectro en frecuencia tendremos ahora una gráfica en donde el eje horizontal será la frecuencia (en lugar del tiempo). Y en este caso veríamos que la gráfica contiene un único pico ubicado exactamente en 100 Hz, ¡que es precisamente la frecuencia de la señal!

Y acá es importante observar una relación interesante: al observar la señal en el dominio del tiempo dijimos que tenía un periodo de 0.01 segundos, que es exactamente el inverso (1/100) de la frecuencia observada en el dominio de la frecuencia. Es decir que al pasar del dominio del tiempo al dominio de la frecuencia hacemos la equivalencia inversa entre las variables temporales y las variables asociadas a la frecuencia.

Y lo interesante de la Transformada de Fourier es que nos permite determinar los componentes en frecuencia más relevantes de cualquier tipo de señal.

Así que podemos aplicar este mismo concepto para calcular y visualizar la Transformada de Fourier de nuestra Serie de Temperatura, sobre la cual queremos generar predicciones.

Al hacer esto lograremos determinar si los componentes más relevantes de la señal están al nivel de años, meses, días u horas, o si todos son igualmente relevantes.

Cálculo y visualización de la Transformada de Fourier para nuestra Serie de Tiempo

Veamos en primer lugar cómo calcular la Transformada de Fourier de nuestra Serie de Tiempo de Temperatura.

Comencemos importando la librería NumPy y extrayendo la Serie de Tiempo de Temperaturas de nuestro set de datos original:

import numpy as np

temp = df['T (degC)']

Ahora definamos las variables que caracterizan nuestra serie de tiempo:

  1. La cantidad de datos de la Serie
  2. Su frecuencia, que como vimos anteriormente es igual a 1 hora (por cada hora tendremos un registro de temperatura)
N = len(temp)      # Cantidad de muestras: [muestras]
muestras_hora = 1  # Número de muestras/horas: [muestras/h]

Y ahora sí podemos calcular la Transformada de Fourier, para lo cual haremos uso de la función rfft de NumPy:

tf = np.abs(np.fft.rfft(temp,N))

En el código anterior hemos obtenido la magnitud de dicha transformada de Fourier (np.abs()) debido a que matemáticamente esta transformada arroja valores complejos (con parte real e imaginaria).

Para poder graficar correctamente el espectro en frecuencia de la Serie de Temperatura, debemos definir los valores que estarán en el eje horizontal de la gráfica. Es decir que debemos crear un vector de frecuencias, para lo cual podemos hacer uso de la función rfftfreq() de NumPy:

frecs = np.fft.rfftfreq(N, d=1./muestras_hora)

Acá es importante observar que en el segundo argumento (d=1./muestras_hora) hemos usado precisamente la equivalencia inversa entre tiempo y frecuencia que mencionamos anteriormente.

Y listo, en este punto ya hemos calculado la Transformada de Fourier de nuestra Serie de Tiempo. Nos resta únicamente graficarla para ver su espectro en frecuencia.

Para ello usaremos la librería Plotly, que nos permite generar un gráfico interactivo (lo cual nos facilitará la visualización de los componentes en frecuencia más relevantes):

import plotly.express as px

fig = px.line(x=frecs, y=tf, log_x=True) # Gráfico básico
fig.update_layout(xaxis_title=f'Frecuencia (1/H)') # Etiqueta eje horizontal
fig.show() # Mostrar la figura

En este caso hemos usado el argumento log_x=True al momento de generar el gráfico para indicarle a Plotly que el eje horizontal (asociado a las frecuencias) tendrá una escala logarítmica. Esto se debe a que nuestra Serie de Tiempo tiene una duración de casi 6 años, pero que en una misma gráfica nos interesa ver, además de los años, los meses, los días e incluso las horas.

Además, es importante tener en cuenta que nuestra Serie de Tiempo tiene un eje de frecuencia dado en unidades de 1/hora, precisamente porque cada hora tenemos un registro.

Al generar esta gráfica podemos observar dos picos ubicados en estas frecuencias:

  1. 119.4648𝑒−6 [1/H]
  2. 0.0416733 [1/H]

Estos son los componentes más relevantes de nuestra señal de Temperatura. Así que lo único que nos resta es realizar la transformación inversa para determinar a qué componentes temporales corresponden estos picos observados en el espectro en frecuencia.

Para lo anterior podemos usar este código, para que Pytho realice el cálculo inverso e imprima el resultado en pantalla:

frec1 = 119.4648e-6
frec2 = 0.0416733

print(f'La frecuencia {frec1} equivale a {1/frec1} horas')
print(f'La frecuencia {frec2} equivale a {1/frec2} horas')

obteniendo como resultado:

La frecuencia 0.0001194648 equivale a 8370.66650594987 horas
La frecuencia 0.0416733 equivale a 23.996179808174535 horas

Vemos que la segunda frecuencia equivale prácticamente a 24 horas, es decir 1 día. ¡Este es precisamente uno de los primeros componentes relevantes de importancia para nuestra señal!

Por otra parte, la primera frecuencia equivale a 8370.66 horas. Hagamos la conversión a años teniendo en cuenta que:

  • 1 año tiene en promedio 365.25 días (para incluir años bisiestos)
  • 1 día tiene 24 horas

Así que, 8370.6665 horas equivalen a:

$8370.6665 \text{ horas} x \frac{1 \text{ día}}{24 \text{ horas}} x \frac{1 \text{ año}}{365.25 \text{ días}}$:

resultado = (1/frec1)*(1/24)*(1/365.25)
print(f'La frecuencia {frec1} equivale a {resultado} años')

obteniendo como resultado:

La frecuencia 0.0001194648 equivale a 0.9549014950889653 años

¡Es decir prácticamente un año!

Así que, como resultado de todo este análisis a través de la Transformada de Fourier, podemos concluir que las variables día y año son las más relevantes en nuestra Serie de Tiempo.

Y con esto nos podemos enfocar en la manera de codificarlas.

Codificación de las variables temporales

Bien, en este punto ya hemos determinado que las variables día y año de cada marca temporal en nuestro set de datos son las que resultan más relevantes en el comportamiento de la temperatura.

La pregunta ahora es ¿cómo podemos codificarlas?

El objetivo de esta codificación es representar estas variables temporales de manera adecuada para que la Red LSTM pueda aprender a detectar relaciones entre dichas variables y la temperatura a predecir.

Y para garantizar esto la codificación debe cumplir con estos requerimientos:

  1. Debe tener en cuenta el comportamiento cíclico/periódico de la señal a predecir (es decir la temperatura)
  2. Debe haber continuidad en la representación numérica que usemos para el año y el día

Sin embargo, si codificamos cada año y día simplemente con los valores originales (2009, 2010, 2011, etc.; 1, 2, 3, …, 29, 30, etc.) tendremos estos inconvenientes:

  1. La representación numérica original no tiene un comportamiento cíclico (ni para los años ni para los días)
  2. La representación numérica original contiene saltos (no es continua), especialmente para los días

Es decir que la codificación original para los años y los días no cumple ninguno de los requisitos establecidos anteriormente. Esta representación dificulta al modelo LSTM encontrar patrones que relacionen las variables temporales con la temperatura.

Una solución usada comúnmente en estos casos es representar las variables temporales con senos y cosenos, con esto:

  1. Se añade a la variable temporal un comportamiento cíclico
  2. No habrá saltos, pues tanto el seno como el coseno son señales que no tienen cambios abruptos

Por ejemplo, veamos cómo inicialmente la codificación de los días usando la función seno.

Comencemos convirtiendo cada marca de tiempo de nuestra serie a su representación equivalente en segundos. Para ello usamos el método Timestamp de Pandas junto con la función map, aplicados sobre el índice de nuestra Serie de Tiempo:

tiempo_s = df.index.map(pd.Timestamp.timestamp)

Con lo anterior, cada marca de tiempo (en el formato año-mes-día horas:minutos:segundos) es representada simplemente en segundos.

Si, por ejemplo, imprimimos en pantalla la distancia temporal entre dos instantes de tiempo consecutivos:

tiempo_s[2]-tiempo_s[1]

obtendremos exactamente 3.600 segundos es decir 1 hora. Así que no estamos cambiando nuestra variable temporal, sólo la estamos representando de una forma más conveniente.

Ahora representemos un día en su equivalente en segundos (resultado de multiplicar 24 horas por 60 minutos y por 60 segundos):

dia = 24*60*60

Y con esto ya podemos representar cada día usando la función seno, que tendrá precisamente un periodo de 1 día:

frec_dia = 1/dia # Frecuencia de la función
dia_sin = np.sin(tiempo_s * 2 * np.pi * frec_dia)

¡Y listo, ya tenemos representados los días de nuestra serie usando la función seno!

Vemos que esta función:

  1. Es continua (no contiene saltos)
  2. Es periódica: la hora 0 equivale a la hora 24, la hora 1 a la hora 25, etc.

Es decir que esta representación satisface las dos condiciones establecidas inicialmente.

Sin embargo, el inconveniente de usar la función seno es que varias horas son codificadas con el mismo valor. Por ejemplo, las horas 0, 10 y 24 son codificadas con el valor 0.25. Esto no resulta conveniente, pues la idea es que la Red LSTM aprenda a identificar las horas de manera única.

Para resolver el problema anterior se puede usar tanto representación senoidal como cosenoidal:

dia_cos = np.cos(tiempo_s * 2 * np.pi * frec_dia)

De esta forma, cada instante de tiempo es codificado con un par de valores únicos para cada hora.

Teniendo esto claro, ahora sí podemos codificar las variables temporales (año y día) usando senos y cosenos.

Almacenaremos estas codificaciones en nuevas columnas: dia_sin, dia_cos, year_sin, year_cos.

Y acá es importante observar que no es necesario combinar días (seno y coseno) y años (seno y coseno) en una misma variable. Durante el entrenamiento la Red LSTM aprenderá a combinarlas de la manera más adecuada.

Podemos usar el mismo código anterior, teniendo en cuenta que en el caso de los años la frecuencia será diferente a la usada para los días.

Este sería entonces el código requerido:

year = (365.2425)*dia # Valor de un año en segundos

# Representación seno/coseno de los días
df['dia_sin'] = np.sin(tiempo_s * 2 * np.pi * frec_dia)
df['dia_cos'] = np.cos(tiempo_s * 2 * np.pi * frec_dia)

# Representación seno/coseno de los años
frec_year = 1/year
df['year_sin'] = np.sin(tiempo_s * 2 * np.pi * frec_year)
df['year_cos'] = np.cos(tiempo_s * 2 * np.pi * frec_year)

¡Y listo, ya tenemos las variables temporales codificadas de manera adecuada!

En el código anterior hemos usado el valor 365.2425 al momento de calcular la cantidad de segundos en un año. Lo anterior teniendo en cuenta que existirán años bisiestos y por tanto la duración de un año no será de exactamente 365 días.

En este punto ya tenemos prácticamente todo listo para construir el modelo. Lo único que nos falta es realizar una ligera modificación a la función de escalamiento de nuestros datos.

Modificaciones a la función de escalamiento

Las cuatro variables que acabamos de crear ya se encuentran en el rango de -1 a 1 (pues precisamente las funciones seno y coseno se encuentran en este rango). Así que no resulta necesario realizar el escalamiento de estas 4 variables.

Así que tenemos que introducir una ligera modificación a la función escalar_dataset() que usamos en los tutoriales anteriores.

Comencemos imprimiendo en pantalla los índices de las diferentes columnas de nuestro set de datos (almacenado en la variable tr y correspondiente al set de entrenamiento):

for i, col in enumerate(tr.columns):
    print(i,col)

obteniendo:

0 p (mbar)
1 T (degC)
2 Tpot (K)
3 Tdew (degC)
4 rh (%)
5 VPmax (mbar)
6 VPact (mbar)
7 VPdef (mbar)
8 sh (g/kg)
9 H2OC (mmol/mol)
10 rho (g/m**3)
11 dia_sin
12 dia_cos
13 year_sin
14 year_cos
15 Wx
16 Wy

Acá vemos que las columnas con índices del 11 al 14 son las que contienen las variables temporales codificadas.

Entonces debemos escalar todas las demás columnas exceptuando aquellas con los índices del 11 al 14. Esta es precisamente la modificación que debemos introducir a la función escalar_datos() y este sería entonces el código actualizado:

def escalar_dataset(data_input, col_ref):
    '''Escala el dataset en el rango de -1 a 1.

    Entradas:
    - data_input: diccionario con los dataset de entrada y salida del modelo
    (data_input = {'x_tr':x_tr, 'y_tr':y_tr, 'x_vl':x_vl, 'y_vl':y_vl,
                    'y_ts':y_ts})
    - col_ref: parámetro adicional para especificar la columna que contiene
      la variable a predecir


    Retorna:
    - data_scaled: diccionario con los datasets de entrada y salida escalados
      (tiene la misma estructura del diccionario de entrada)
    - scaler: el escalador usado (requerido para las predicciones)
    '''

    # *** Implementación adicional: determinar el índice de la columna
    # que contiene la variable a predecir
    col_ref = df.columns.get_loc(col_ref)

    # Número de instantes de tiempo de entrada y de covariables
    NSAMPLES = data_input['x_tr'].shape[1]
    NFEATS = data_input['x_tr'].shape[2]

    # Generar listado con "scalers" (1 por cada covariable de entrada)
    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: porción de código modificada para no escalar las
    # columnas con las variables temporales (columnas 11->14)
    # *************************************************
    FEAT_IDS = list(range(11)) + list(range(15,17)) # Columnas 0->10 + 15-16
    for i in FEAT_IDS:
        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])

    # *************************************************
    # Y dejar las columnas con variables temporales (13->16) intactas
    # *************************************************
    FEATS_TIME = list(range(11,15)) # Columnas 11-14
    x_tr_s[:,:,FEATS_TIME] = x_tr[:,:,FEATS_TIME]
    x_vl_s[:,:,FEATS_TIME] = x_vl[:,:,FEATS_TIME]
    x_ts_s[:,:,FEATS_TIME] = x_ts[:,:,FEATS_TIME]


    # Escalamiento Ys (teniendo en cuenta "col_ind")
    y_tr_s[:,:,0] = scalers[col_ref].fit_transform(y_tr[:,:,0])
    y_vl_s[:,:,0] = scalers[col_ref].transform(y_vl[:,:,0])
    y_ts_s[:,:,0] = scalers[col_ref].transform(y_ts[:,:,0])

    # Conformar diccionario 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[col_ref]

¡Y listo, en este punto ya tenemos la totalidad del código requerido para poder entrenar y validar la Red LSTM!

Las porciones restantes del código son idénticas a las usadas en los tutoriales anteriores, así que no requieren modificación alguna.

Así que en este punto ya hemos completado el objetivo de este tutorial, que consistía en realizar la codificación de las variables temporales para alimentar el modelo predictivo basado en Redes LSTM.

Enlaces de descarga código fuente y set de datos

▼ El notebook de este tutorial

▼ El set de datos de este tutorial

Conclusiones

En este tutorial hemos visto todos los detalles a tener en cuenta para la codificación de las variables temporales antes de introducirlas como variable predictora a una Red LSTM.

Sugiero realizar el ajuste de hiperparámetros de este modelo para intentar mejorar el desempeño al momento de generar las predicciones.

En el próximo tutorial veremos cómo realizar el análisis de importancia de características a partir del modelo que acabamos de entrenar.

Los demás artículos y tutoriales de esta serie

  • Pronósticos sobre series de tiempo con Redes LSTM (artículo introductorio)
  • Pronósticos sobre series de tiempo con Redes LSTM: parte 1 – preparación de los datos
  • Pronósticos sobre series de tiempo con Redes LSTM: parte 2 – predicción univariada-unistep

© Codificando Bits, LLC | Blog | Contacto | Servicios | Acerca de | Políticas

¿Has olvidado la contraseña?
¿Has perdido tu contraseña? Por favor, introduce tu nombre de usuario o dirección de correo electrónico. Recibirás por correo electrónico un enlace para crear una nueva contraseña.
body::-webkit-scrollbar { width: 7px; } body::-webkit-scrollbar-track { border-radius: 10px; background: #f0f0f0; } body::-webkit-scrollbar-thumb { border-radius: 50px; background: #dfdbdb }