Problemas con el Autoencoder LSTM

Dec 09 2020

TLDR:

Autoencoder no se adapta a la reconstrucción de series temporales y solo predice el valor promedio.

Configuración de preguntas:

Aquí hay un resumen de mi intento de un codificador automático secuencia a secuencia. Esta imagen fue tomada de este artículo:https://arxiv.org/pdf/1607.00148.pdf

Codificador: capa estándar LSTM. La secuencia de entrada se codifica en el estado oculto final.

Decodificador: LSTM Cell (¡creo!). Reconstruya la secuencia un elemento a la vez, comenzando con el último elemento x[N].

El algoritmo del decodificador es el siguiente para una secuencia de longitud N:

  1. Obtener el estado oculto inicial del decodificador hs[N]: simplemente use el estado oculto final del codificador.
  2. Reconstruir último elemento de la secuencia: x[N]= w.dot(hs[N]) + b.
  3. Mismo patrón para otros elementos: x[i]= w.dot(hs[i]) + b
  4. utilizar x[i]y hs[i]como entradas LSTMCellpara obtener x[i-1]yhs[i-1]

Ejemplo de trabajo mínimo:

Aquí está mi implementación, comenzando con el codificador:

class SeqEncoderLSTM(nn.Module):
    def __init__(self, n_features, latent_size):
        super(SeqEncoderLSTM, self).__init__()
        
        self.lstm = nn.LSTM(
            n_features, 
            latent_size, 
            batch_first=True)
        
    def forward(self, x):
        _, hs = self.lstm(x)
        return hs

Clase de decodificador:

class SeqDecoderLSTM(nn.Module):
    def __init__(self, emb_size, n_features):
        super(SeqDecoderLSTM, self).__init__()
        
        self.cell = nn.LSTMCell(n_features, emb_size)
        self.dense = nn.Linear(emb_size, n_features)
        
    def forward(self, hs_0, seq_len):
        
        x = torch.tensor([])
        
        # Final hidden and cell state from encoder
        hs_i, cs_i = hs_0
        
        # reconstruct first element with encoder output
        x_i = self.dense(hs_i)
        x = torch.cat([x, x_i])
        
        # reconstruct remaining elements
        for i in range(1, seq_len):
            hs_i, cs_i = self.cell(x_i, (hs_i, cs_i))
            x_i = self.dense(hs_i)
            x = torch.cat([x, x_i])
        return x

Uniendo los dos:

class LSTMEncoderDecoder(nn.Module):
    def __init__(self, n_features, emb_size):
        super(LSTMEncoderDecoder, self).__init__()
        self.n_features = n_features
        self.hidden_size = emb_size

        self.encoder = SeqEncoderLSTM(n_features, emb_size)
        self.decoder = SeqDecoderLSTM(emb_size, n_features)
    
    def forward(self, x):
        seq_len = x.shape[1]
        hs = self.encoder(x)
        hs = tuple([h.squeeze(0) for h in hs])
        out = self.decoder(hs, seq_len)
        return out.unsqueeze(0)        

Y aquí está mi función de entrenamiento:

def train_encoder(model, epochs, trainload, testload=None, criterion=nn.MSELoss(), optimizer=optim.Adam, lr=1e-6,  reverse=False):

    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    print(f'Training model on {device}')
    model = model.to(device)
    opt = optimizer(model.parameters(), lr)

    train_loss = []
    valid_loss = []

    for e in tqdm(range(epochs)):
        running_tl = 0
        running_vl = 0
        for x in trainload:
            x = x.to(device).float()
            opt.zero_grad()
            x_hat = model(x)
            if reverse:
                x = torch.flip(x, [1])
            loss = criterion(x_hat, x)
            loss.backward()
            opt.step()
            running_tl += loss.item()

        if testload is not None:
            model.eval()
            with torch.no_grad():
                for x in testload:
                    x = x.to(device).float()
                    loss = criterion(model(x), x)
                    running_vl += loss.item()
                valid_loss.append(running_vl / len(testload))
            model.train()
            
        train_loss.append(running_tl / len(trainload))
    
    return train_loss, valid_loss

Datos:

Gran conjunto de datos de eventos extraídos de las noticias (ICEWS). Existen varias categorías que describen cada evento. Inicialmente codifiqué en caliente estas variables, expandiendo los datos a 274 dimensiones. Sin embargo, para depurar el modelo, lo reduje a una sola secuencia que tiene 14 pasos de tiempo y solo contiene 5 variables. Aquí está la secuencia que estoy tratando de sobreajustar:

tensor([[0.5122, 0.0360, 0.7027, 0.0721, 0.1892],
        [0.5177, 0.0833, 0.6574, 0.1204, 0.1389],
        [0.4643, 0.0364, 0.6242, 0.1576, 0.1818],
        [0.4375, 0.0133, 0.5733, 0.1867, 0.2267],
        [0.4838, 0.0625, 0.6042, 0.1771, 0.1562],
        [0.4804, 0.0175, 0.6798, 0.1053, 0.1974],
        [0.5030, 0.0445, 0.6712, 0.1438, 0.1404],
        [0.4987, 0.0490, 0.6699, 0.1536, 0.1275],
        [0.4898, 0.0388, 0.6704, 0.1330, 0.1579],
        [0.4711, 0.0390, 0.5877, 0.1532, 0.2201],
        [0.4627, 0.0484, 0.5269, 0.1882, 0.2366],
        [0.5043, 0.0807, 0.6646, 0.1429, 0.1118],
        [0.4852, 0.0606, 0.6364, 0.1515, 0.1515],
        [0.5279, 0.0629, 0.6886, 0.1514, 0.0971]], dtype=torch.float64)

Y aquí está la Datasetclase personalizada :

class TimeseriesDataSet(Dataset):
    def __init__(self, data, window, n_features, overlap=0):
        super().__init__()
        if isinstance(data, (np.ndarray)):
            data = torch.tensor(data)
        elif isinstance(data, (pd.Series, pd.DataFrame)):
            data = torch.tensor(data.copy().to_numpy())
        else: 
            raise TypeError(f"Data should be ndarray, series or dataframe. Found {type(data)}.")
        
        self.n_features = n_features
        self.seqs = torch.split(data, window)
        
    def __len__(self):
        return len(self.seqs)
    
    def __getitem__(self, idx):
        try:    
            return self.seqs[idx].view(-1, self.n_features)
        except TypeError:
            raise TypeError("Dataset only accepts integer index/slices, not lists/arrays.")

Problema:

El modelo solo aprende el promedio, no importa cuán complejo haga el modelo o cuánto tiempo lo entreno.

Reconstrucción prevista:

Real:

Mi investigación:

Este problema es idéntico al discutido en esta pregunta: el autoencoder LSTM siempre devuelve el promedio de la secuencia de entrada

El problema en ese caso terminó siendo que la función objetivo promediaba las series temporales objetivo antes de calcular la pérdida. Esto se debió a algunos errores de transmisión porque el autor no tenía las entradas del tamaño correcto para la función objetivo.

En mi caso, no veo que este sea el problema. He comprobado y comprobado que todas mis dimensiones / tamaños están alineados. Estoy en una pérdida.

Otras cosas que he probado

  1. He intentado esto con diferentes longitudes de secuencia, desde 7 pasos de tiempo hasta 100 pasos de tiempo.
  2. Lo he intentado con un número variado de variables en la serie temporal. Probé con univariante hasta las 274 variables que contienen los datos.
  3. Lo intenté con varios reductionparámetros en el nn.MSELossmódulo. El periódico lo pide sum, pero he probado ambos sumy mean. Ninguna diferencia.
  4. El documento requiere reconstruir la secuencia en orden inverso (ver gráfico arriba). Probé este método usando la flipudentrada original (después del entrenamiento pero antes de calcular la pérdida). Esto no hace ninguna diferencia.
  5. Intenté hacer el modelo más complejo agregando una capa LSTM adicional en el codificador.
  6. Intenté jugar con el espacio latente. Probé desde el 50% del número de funciones de entrada hasta el 150%.
  7. Intenté sobreajustar una sola secuencia (proporcionada en la sección de Datos anterior).

Pregunta:

¿Qué hace que mi modelo prediga el promedio y cómo lo soluciono?

Respuestas

7 SzymonMaszke Dec 16 2020 at 05:04

Bien, después de algunas depuraciones, creo que conozco las razones.

TLDR

  • Intenta predecir el siguiente valor del paso de tiempo en lugar de la diferencia entre el paso de tiempo actual y el anterior
  • Su hidden_featuresnúmero es demasiado pequeño, por lo que el modelo no puede ajustarse ni siquiera a una sola muestra.

Análisis

Código utilizado

Comencemos con el código (el modelo es el mismo):

import seaborn as sns
import matplotlib.pyplot as plt

def get_data(subtract: bool = False):
    # (1, 14, 5)
    input_tensor = torch.tensor(
        [
            [0.5122, 0.0360, 0.7027, 0.0721, 0.1892],
            [0.5177, 0.0833, 0.6574, 0.1204, 0.1389],
            [0.4643, 0.0364, 0.6242, 0.1576, 0.1818],
            [0.4375, 0.0133, 0.5733, 0.1867, 0.2267],
            [0.4838, 0.0625, 0.6042, 0.1771, 0.1562],
            [0.4804, 0.0175, 0.6798, 0.1053, 0.1974],
            [0.5030, 0.0445, 0.6712, 0.1438, 0.1404],
            [0.4987, 0.0490, 0.6699, 0.1536, 0.1275],
            [0.4898, 0.0388, 0.6704, 0.1330, 0.1579],
            [0.4711, 0.0390, 0.5877, 0.1532, 0.2201],
            [0.4627, 0.0484, 0.5269, 0.1882, 0.2366],
            [0.5043, 0.0807, 0.6646, 0.1429, 0.1118],
            [0.4852, 0.0606, 0.6364, 0.1515, 0.1515],
            [0.5279, 0.0629, 0.6886, 0.1514, 0.0971],
        ]
    ).unsqueeze(0)

    if subtract:
        initial_values = input_tensor[:, 0, :]
        input_tensor -= torch.roll(input_tensor, 1, 1)
        input_tensor[:, 0, :] = initial_values
    return input_tensor


if __name__ == "__main__":
    torch.manual_seed(0)

    HIDDEN_SIZE = 10
    SUBTRACT = False

    input_tensor = get_data(SUBTRACT)
    model = LSTMEncoderDecoder(input_tensor.shape[-1], HIDDEN_SIZE)
    optimizer = torch.optim.Adam(model.parameters())
    criterion = torch.nn.MSELoss()
    for i in range(1000):
        outputs = model(input_tensor)
        loss = criterion(outputs, input_tensor)
        loss.backward()
        optimizer.step()
        optimizer.zero_grad()
        print(f"{i}: {loss}")
        if loss < 1e-4:
            break

    # Plotting
    sns.lineplot(data=outputs.detach().numpy().squeeze())
    sns.lineplot(data=input_tensor.detach().numpy().squeeze())
    plt.show()

Que hace:

  • get_datao funciona con los datos que proporcionó si subtract=Falseo (si subtract=True) resta el valor del paso de tiempo anterior del paso de tiempo actual
  • El resto del código optimiza el modelo hasta que se 1e-4alcanza la pérdida (para que podamos comparar cómo ayuda la capacidad del modelo y su aumento y qué sucede cuando usamos la diferencia de pasos de tiempo en lugar de pasos de tiempo)

¡Solo variaremos HIDDEN_SIZEy SUBTRACTparámetros!

SIN RESTA, MODELO PEQUEÑO

  • HIDDEN_SIZE=5
  • SUBTRACT=False

En este caso obtenemos una línea recta. El modelo no puede ajustarse y comprender los fenómenos presentados en los datos (por lo tanto, las líneas planas que mencionó).

Se alcanzó el límite de 1000 iteraciones

RESTA, MODELO PEQUEÑO

  • HIDDEN_SIZE=5
  • SUBTRACT=True

Los objetivos ahora están lejos de las líneas planas , pero el modelo no puede ajustarse debido a una capacidad demasiado pequeña.

Se alcanzó el límite de 1000 iteraciones

SIN RESTA, MODELO MÁS GRANDE

  • HIDDEN_SIZE=100
  • SUBTRACT=False

Se puso mucho mejor y nuestro objetivo fue alcanzado después de los 942pasos. No más líneas planas, la capacidad del modelo parece bastante fina (¡para este único ejemplo!)

RESTA, MODELO MÁS GRANDE

  • HIDDEN_SIZE=100
  • SUBTRACT=True

Aunque el gráfico no se ve tan bonito, llegamos a la pérdida deseada después de solo 215iteraciones.

Finalmente

  • Por lo general, use la diferencia de pasos de tiempo en lugar de pasos de tiempo (o alguna otra transformación, consulte aquí para obtener más información al respecto). En otros casos, la red neuronal intentará simplemente ... copiar la salida del paso anterior (ya que eso es lo más fácil de hacer). De esta manera se encontrarán algunos mínimos y salir de él requerirá más capacidad.
  • Cuando utiliza la diferencia entre pasos de tiempo, no hay forma de "extrapolar" la tendencia del paso de tiempo anterior; La red neuronal tiene que aprender cómo varía realmente la función.
  • Use un modelo más grande (para todo el conjunto de datos, debería probar algo como 300creo), pero simplemente puede ajustar ese.
  • No lo use flipud. Use LSTM bidireccionales, de esta manera puede obtener información de la pasada hacia adelante y hacia atrás de LSTM (¡no confundir con backprop!). Esto también debería aumentar tu puntuación

Preguntas

Bien, pregunta 1: ¿Estás diciendo que para la variable x en la serie de tiempo, debería entrenar el modelo para aprender x [i] - x [i-1] en lugar del valor de x [i]? ¿Estoy interpretando correctamente?

Sí exactamente. La diferencia elimina el impulso de la red neuronal de basar demasiado sus predicciones en el paso de tiempo pasado (simplemente obteniendo el último valor y tal vez cambiándolo un poco)

Pregunta 2: Dijo que mis cálculos para el cuello de botella cero eran incorrectos. Pero, por ejemplo, digamos que estoy usando una red densa simple como codificador automático. Conseguir el cuello de botella correcto depende de los datos. Pero si hace que el cuello de botella tenga el mismo tamaño que la entrada, obtiene la función de identidad.

Sí, asumiendo que no hay una no linealidad involucrada que lo haga más difícil (ver aquí un caso similar). En el caso de los LSTM, hay no lineales, ese es un punto.

Otro es que nos estamos acumulando timestepsen un estado de codificador único. Entonces, esencialmente, tendríamos que acumular timestepsidentidades en un solo estado oculto y celular, lo cual es muy poco probable.

Un último punto, dependiendo de la longitud de la secuencia, los LSTM son propensos a olvidar parte de la información menos relevante (para eso fueron diseñados, no solo para recordar todo), por lo que es aún más improbable.

¿Num_features * num_timesteps no es un cuello de botella del mismo tamaño que la entrada y, por lo tanto, no debería facilitar que el modelo aprenda la identidad?

Lo es, pero asume que tiene num_timestepspara cada punto de datos, lo que rara vez es el caso, podría estar aquí. Sobre la identidad y por qué es difícil hacerlo con las no linealidades para la red, se respondió anteriormente.

Un último punto, sobre las funciones de identidad; si fueran realmente fáciles de aprender, ResNetsería poco probable que las arquitecturas tuvieran éxito. La red podría converger hacia la identidad y realizar "pequeños arreglos" en la salida sin ella, lo que no es el caso.

Tengo curiosidad por la afirmación: "siempre use la diferencia de pasos de tiempo en lugar de pasos de tiempo". Parece tener algún efecto de normalización al acercar todas las características, pero no entiendo por qué esto es clave. Tener un modelo más grande parecía ser la solución y la sustracción solo está ayudando.

La clave aquí fue, de hecho, aumentar la capacidad del modelo. El truco de resta depende realmente de los datos. Imaginemos una situación extrema:

  • Tenemos 100intervalos de tiempo, función única
  • El valor del paso de tiempo inicial es 10000
  • Otros valores de intervalo de tiempo varían 1como máximo

¿Qué haría la red neuronal (qué es lo más fácil aquí)? Probablemente, descartaría este 1o un cambio más pequeño como ruido y solo predeciría 1000para todos ellos (especialmente si existe alguna regularización), ya que estar fuera de lugar 1/1000no es mucho.

¿Y si restamos? La pérdida total de la red neuronal está en el [0, 1]margen para cada paso de tiempo en lugar de [0, 1001], por lo tanto, es más grave estar equivocado.

Y sí, está conectado con la normalización en cierto sentido, ahora que lo pienso.