Problemas do LSTM Autoencoder

Dec 09 2020

TLDR:

O Autoencoder não se ajusta à reconstrução da série temporal e apenas prevê o valor médio.

Configuração da pergunta:

Aqui está um resumo da minha tentativa de um autoencoder de sequência para sequência. Esta imagem foi tirada deste papel:https://arxiv.org/pdf/1607.00148.pdf

Codificador: camada LSTM padrão. A sequência de entrada é codificada no estado oculto final.

Decodificador: Célula LSTM (eu acho!). Reconstrua a sequência, um elemento de cada vez, começando com o último elemento x[N].

O algoritmo do decodificador é o seguinte para uma sequência de comprimento N:

  1. Obter estado oculto inicial do decodificador hs[N]: Basta usar o estado oculto final do codificador.
  2. Reconstruir último elemento na seqüência: x[N]= w.dot(hs[N]) + b.
  3. Mesmo padrão para outros elementos: x[i]= w.dot(hs[i]) + b
  4. usar x[i]e hs[i]como entradas LSTMCellpara obter x[i-1]ehs[i-1]

Exemplo de trabalho mínimo:

Aqui está minha implementação, começando com o 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

Classe 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

Trazendo os dois juntos:

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)        

E aqui está minha função de treinamento:

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

Dados:

Grande conjunto de dados de eventos retirados das notícias (ICEWS). Existem várias categorias que descrevem cada evento. Eu inicialmente codifiquei essas variáveis, expandindo os dados para 274 dimensões. No entanto, para depurar o modelo, cortei-o em uma única sequência de 14 passos de tempo e apenas 5 variáveis. Aqui está a sequência que estou tentando ajustar:

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)

E aqui está a Datasetclasse 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:

O modelo só aprende a média, não importa o quão complexo eu torne o modelo ou agora quanto tempo eu o treine.

Previsto / reconstrução:

Real:

Minha pesquisa:

Este problema é idêntico ao discutido nesta pergunta: o autencoder LSTM sempre retorna a média da sequência de entrada

O problema naquele caso acabou sendo que a função objetivo estava calculando a média da série de tempo alvo antes de calcular a perda. Isso ocorreu devido a alguns erros de transmissão porque o autor não tinha as entradas de tamanho certo para a função objetivo.

No meu caso, não acho que seja esse o problema. Eu verifiquei e verifiquei novamente se todas as minhas dimensões / tamanhos estão alinhados. Eu estou perdido.

Outras coisas que eu tentei

  1. Eu tentei isso com comprimentos de sequência variados de 7 passos de tempo a 100 passos de tempo.
  2. Eu tentei com um número variado de variáveis ​​na série temporal. Eu tentei com univariada todo o caminho para todas as 274 variáveis ​​que os dados contêm.
  3. Eu tentei com vários reductionparâmetros no nn.MSELossmódulo. O papel pede sum, mas tentei tanto sume mean. Sem diferença.
  4. O artigo pede a reconstrução da seqüência na ordem inversa (veja o gráfico acima). Eu tentei esse método usando o flipudna entrada original (após o treinamento, mas antes de calcular a perda). Isso não faz diferença.
  5. Tentei tornar o modelo mais complexo adicionando uma camada LSTM extra no codificador.
  6. Tentei brincar com o espaço latente. Tentei de 50% do número de entrada de recursos a 150%.
  7. Eu tentei overfitting em uma única sequência (fornecida na seção Dados acima).

Questão:

O que está fazendo com que meu modelo preveja a média e como faço para corrigir isso?

Respostas

7 SzymonMaszke Dec 16 2020 at 05:04

Ok, depois de alguma depuração, acho que sei os motivos.

TLDR

  • Você tenta prever o próximo valor do passo de tempo em vez da diferença entre o passo de tempo atual e o anterior
  • Seu hidden_featuresnúmero é muito pequeno, tornando o modelo incapaz de caber mesmo em uma única amostra

Análise

Código usado

Vamos começar com o código (o modelo é o mesmo):

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()

O que faz:

  • get_datafunciona com os dados que você forneceu se subtract=Falseou (se subtract=True) subtrair o valor do passo de tempo anterior do passo de tempo atual
  • O resto do código otimiza o modelo até que a 1e-4perda seja atingida (para que possamos comparar como a capacidade do modelo e seu aumento ajudam e o que acontece quando usamos a diferença de passos de tempo em vez de passos de tempo)

Iremos apenas variar HIDDEN_SIZEe SUBTRACTparâmetros!

SEM SUBTRATO, MODELO PEQUENO

  • HIDDEN_SIZE=5
  • SUBTRACT=False

Neste caso, obtemos uma linha reta. O modelo não é capaz de ajustar e compreender os fenômenos apresentados nos dados (portanto, as linhas planas que você mencionou).

Limite de 1000 iterações atingido

SUBTRAIR, MODELO PEQUENO

  • HIDDEN_SIZE=5
  • SUBTRACT=True

Os alvos agora estão longe de ser linhas planas , mas o modelo não pode caber devido à capacidade muito pequena.

Limite de 1000 iterações atingido

SEM SUBTRATO, MODELO MAIOR

  • HIDDEN_SIZE=100
  • SUBTRACT=False

Ficou muito melhor e nosso alvo foi atingido após alguns 942passos. Não há mais linhas planas, a capacidade do modelo parece muito boa (para este único exemplo!)

SUBTRAIR, MODELO MAIOR

  • HIDDEN_SIZE=100
  • SUBTRACT=True

Embora o gráfico não pareça tão bonito, chegamos à perda desejada após apenas 215iterações.

Finalmente

  • Normalmente use a diferença de passos de tempo em vez de passos de tempo (ou alguma outra transformação, veja aqui para mais informações sobre isso). Em outros casos, a rede neural tentará simplesmente ... copiar a saída da etapa anterior (já que é a coisa mais fácil de fazer). Alguns mínimos serão encontrados desta forma e sair dele exigirá mais capacidade.
  • Quando você usa a diferença entre os passos de tempo, não há como "extrapolar" a tendência do passo de tempo anterior; rede neural tem que aprender como a função realmente varia
  • Use um modelo maior (para todo o conjunto de dados, você deve tentar algo como 300eu acho), mas você pode simplesmente ajustar esse.
  • Não use flipud. Use LSTMs bidirecionais, desta forma você pode obter informações da passagem para frente e para trás do LSTM (não confundir com backprop!). Isso também deve aumentar sua pontuação

Questões

Ok, pergunta 1: você está dizendo que para a variável x na série temporal, devo treinar o modelo para aprender x [i] - x [i-1] ao invés do valor de x [i]? Estou interpretando corretamente?

Sim, exatamente. A diferença remove o desejo da rede neural de basear demais suas previsões no intervalo de tempo passado (simplesmente obtendo o último valor e talvez mudando-o um pouco)

Pergunta 2: Você disse que meus cálculos para gargalo zero estavam incorretos. Mas, por exemplo, digamos que estou usando uma rede densa simples como um codificador automático. Obter o gargalo certo realmente depende dos dados. Mas se você deixar o gargalo do mesmo tamanho da entrada, obterá a função de identidade.

Sim, assumindo que não há não linearidade envolvida, o que torna a coisa mais difícil (veja aqui um caso semelhante). No caso de LSTMs, existem não linearites, esse é um ponto.

Outra é que estamos acumulando timestepsem um estado de codificador único. Então, essencialmente, teríamos que acumular timestepsidentidades em um único estado oculto e celular, o que é altamente improvável.

Um último ponto, dependendo da duração da sequência, os LSTMs estão propensos a esquecer algumas das informações menos relevantes (é para isso que foram projetados, não apenas para lembrar de tudo), portanto, ainda mais improvável.

Num_features * num_timesteps não é um gargalo do mesmo tamanho da entrada e, portanto, não deveria facilitar o aprendizado da identidade do modelo?

É, mas assume que você tem num_timestepspara cada ponto de dados, o que raramente é o caso, pode estar aqui. Sobre a identidade e porque é difícil fazer com não linearidades para a rede foi respondida acima.

Um último ponto, sobre funções de identidade; se fossem realmente fáceis de aprender, ResNetseria improvável que as arquiteturas fossem bem-sucedidas. A rede pode convergir para a identidade e fazer "pequenas correções" na saída sem ela, o que não é o caso.

Estou curioso sobre a afirmação: "sempre use a diferença de passos de tempo em vez de passos de tempo" Parece ter algum efeito de normalização ao reunir todos os recursos, mas não entendo por que isso é importante. Ter um modelo maior parecia ser a solução e o substrato está apenas ajudando.

A chave aqui era, de fato, aumentar a capacidade do modelo. O truque de subtração depende realmente dos dados. Vamos imaginar uma situação extrema:

  • Temos 100passos de tempo, recurso único
  • O valor inicial do tempo é 10000
  • Outros valores de intervalo de tempo variam 1no máximo

O que a rede neural faria (o que é mais fácil aqui)? Provavelmente, ele descartaria essa 1mudança ou uma menor como ruído e apenas preveria 1000para todos eles (especialmente se alguma regularização estiver em vigor), já que estar desligado 1/1000não é muito.

E se subtrairmos? A perda total da rede neural está na [0, 1]margem de cada intervalo de tempo [0, 1001], em vez de , portanto, é mais grave estar errado.

E sim, está conectado à normalização de alguma forma, venha para pensar sobre isso.