Problemas do LSTM Autoencoder
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
:
- Obter estado oculto inicial do decodificador
hs[N]
: Basta usar o estado oculto final do codificador. - Reconstruir último elemento na seqüência:
x[N]= w.dot(hs[N]) + b
. - Mesmo padrão para outros elementos:
x[i]= w.dot(hs[i]) + b
- usar
x[i]
ehs[i]
como entradasLSTMCell
para obterx[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 Dataset
classe 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
- Eu tentei isso com comprimentos de sequência variados de 7 passos de tempo a 100 passos de tempo.
- 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.
- Eu tentei com vários
reduction
parâmetros nonn.MSELoss
módulo. O papel pedesum
, mas tentei tantosum
emean
. Sem diferença. - O artigo pede a reconstrução da seqüência na ordem inversa (veja o gráfico acima). Eu tentei esse método usando o
flipud
na entrada original (após o treinamento, mas antes de calcular a perda). Isso não faz diferença. - Tentei tornar o modelo mais complexo adicionando uma camada LSTM extra no codificador.
- Tentei brincar com o espaço latente. Tentei de 50% do número de entrada de recursos a 150%.
- 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
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_features
nú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_data
funciona com os dados que você forneceu sesubtract=False
ou (sesubtract=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-4
perda 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_SIZE
e SUBTRACT
parâ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 942
passos. 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 215
iteraçõ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
300
eu 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 timesteps
em um estado de codificador único. Então, essencialmente, teríamos que acumular timesteps
identidades 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_timesteps
para 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, ResNet
seria 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
100
passos de tempo, recurso único - O valor inicial do tempo é
10000
- Outros valores de intervalo de tempo variam
1
no máximo
O que a rede neural faria (o que é mais fácil aqui)? Provavelmente, ele descartaria essa 1
mudança ou uma menor como ruído e apenas preveria 1000
para todos eles (especialmente se alguma regularização estiver em vigor), já que estar desligado 1/1000
nã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.