Problèmes d'encodeur automatique LSTM
TLDR:
L'encodeur automatique ne convient pas à la reconstruction de séries temporelles et ne prédit que la valeur moyenne.
Configuration de la question:
Voici un résumé de ma tentative d'auto-encodeur séquence à séquence. Cette image est tirée de ce papier:https://arxiv.org/pdf/1607.00148.pdf

Encodeur: couche LSTM standard. La séquence d'entrée est codée dans l'état caché final.
Décodeur: LSTM Cell (je pense!). Reconstruisez la séquence élément par élément, en commençant par le dernier élément x[N]
.
L'algorithme du décodeur est le suivant pour une séquence de longueur N
:
- Obtenir l'état caché initial du décodeur
hs[N]
: utilisez simplement l'état caché final de l'encodeur. - Reconstruire dernier élément de la séquence:
x[N]= w.dot(hs[N]) + b
. - Même modèle pour les autres éléments:
x[i]= w.dot(hs[i]) + b
- utiliser
x[i]
eths[i]
comme entréesLSTMCell
pour obtenirx[i-1]
eths[i-1]
Exemple de travail minimum:
Voici mon implémentation, en commençant par l'encodeur:
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 décodeur:
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
Rassembler les deux:
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)
Et voici ma fonction d'entraînement:
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
Les données:
Grand ensemble de données d'événements extraits des actualités (ICEWS). Différentes catégories existent pour décrire chaque événement. J'ai initialement encodé ces variables à chaud, étendant les données à 274 dimensions. Cependant, afin de déboguer le modèle, je l'ai réduit à une seule séquence de 14 pas de temps et ne contient que 5 variables. Voici la séquence que j'essaye de surajuster:
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)
Et voici la Dataset
classe personnalisée :
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.")
Problème:
Le modèle n'apprend que la moyenne, quelle que soit la complexité que je fais du modèle ou maintenant, je l'entraîne.
Prévu / Reconstruction:

Réel:

Ma recherche:
Ce problème est identique à celui discuté dans cette question: l' autoencoder LSTM renvoie toujours la moyenne de la séquence d'entrée
Le problème dans ce cas était finalement que la fonction objectif calculait la moyenne des séries temporelles cibles avant de calculer la perte. Cela était dû à certaines erreurs de diffusion parce que l'auteur n'avait pas les entrées de bonne taille pour la fonction objectif.
Dans mon cas, je ne vois pas que ce soit le problème. J'ai vérifié et vérifié que toutes mes dimensions / tailles correspondent. Je n'arrive pas.
Autres choses que j'ai essayées
- J'ai essayé cela avec des longueurs de séquence variées allant de 7 pas de temps à 100 pas de temps.
- J'ai essayé avec un nombre varié de variables dans la série chronologique. J'ai essayé avec univarié jusqu'à toutes les 274 variables que les données contiennent.
- J'ai essayé avec divers
reduction
paramètres sur lenn.MSELoss
module. Le papier demandesum
, mais j'ai essayé les deuxsum
etmean
. Aucune différence. - L'article appelle à reconstruire la séquence dans l'ordre inverse (voir graphique ci-dessus). J'ai essayé cette méthode en utilisant l'
flipud
entrée d'origine (après l'entraînement mais avant de calculer la perte). Cela ne fait aucune différence. - J'ai essayé de rendre le modèle plus complexe en ajoutant une couche LSTM supplémentaire dans l'encodeur.
- J'ai essayé de jouer avec l'espace latent. J'ai essayé de 50% du nombre de fonctionnalités d'entrée à 150%.
- J'ai essayé de surappliquer une seule séquence (fournie dans la section Données ci-dessus).
Question:
Qu'est-ce qui pousse mon modèle à prévoir la moyenne et comment la corriger?
Réponses
D'accord, après quelques débogages, je pense que je connais les raisons.
TLDR
- Vous essayez de prédire la valeur du prochain pas au lieu de la différence entre le pas actuel et le précédent
- Votre
hidden_features
numéro est trop petit, ce qui rend le modèle incapable de contenir même un seul échantillon
Une analyse
Code utilisé
Commençons par le code (le modèle est le même):
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()
Ce qu'il fait:
get_data
soit fonctionne sur les données que vous avez fournies sisubtract=False
ou (sisubtract=True
) il soustrait la valeur du pas de temps précédent du pas de temps actuel- Le reste du code optimise le modèle jusqu'à ce que la
1e-4
perte atteigne (afin que nous puissions comparer comment la capacité du modèle et son augmentation aident et ce qui se passe lorsque nous utilisons la différence des pas de temps au lieu des pas de temps)
Nous ne varierons HIDDEN_SIZE
que les SUBTRACT
paramètres!
AUCUN SOUSTRAIT, PETIT MODÈLE
HIDDEN_SIZE=5
SUBTRACT=False
Dans ce cas, nous obtenons une ligne droite. Le modèle est incapable d'ajuster et de saisir les phénomènes présentés dans les données (d'où les lignes plates que vous avez mentionnées).

Limite de 1000 itérations atteinte
SOUSTRAIT, PETIT MODÈLE
HIDDEN_SIZE=5
SUBTRACT=True
Les cibles sont désormais loin des lignes plates , mais le modèle ne peut pas s'adapter en raison d'une capacité trop petite.

Limite de 1000 itérations atteinte
AUCUN SOUSTRAIT, MODÈLE PLUS GRAND
HIDDEN_SIZE=100
SUBTRACT=False
Cela s'est beaucoup amélioré et notre objectif a été atteint après 942
quelques étapes. Fini les lignes plates, la capacité du modèle semble assez bonne (pour cet exemple unique!)

SOUSTRACT, MODÈLE PLUS GRAND
HIDDEN_SIZE=100
SUBTRACT=True
Bien que le graphique ne soit pas si joli, nous sommes arrivés à la perte souhaitée après seulement des 215
itérations.

finalement
- Utilisez généralement la différence des pas de temps au lieu des pas de temps (ou une autre transformation, voir ici pour plus d'informations à ce sujet). Dans d'autres cas, le réseau neuronal essaiera de simplement ... copier la sortie de l'étape précédente (car c'est la chose la plus simple à faire). Certains minima seront trouvés de cette façon et en sortir nécessitera plus de capacité.
- Lorsque vous utilisez la différence entre les pas temporels, il n'y a aucun moyen "d'extrapoler" la tendance à partir du pas temporel précédent; le réseau neuronal doit apprendre comment la fonction varie réellement
- Utilisez un modèle plus grand (pour l'ensemble de données, vous devriez essayer quelque chose comme
300
je pense), mais vous pouvez simplement régler celui-ci. - N'utilisez pas
flipud
. Utilisez des LSTM bidirectionnels, de cette façon, vous pouvez obtenir des informations sur les passes avant et arrière de LSTM (à ne pas confondre avec backprop!). Cela devrait également augmenter votre score
Des questions
D'accord, question 1: Vous dites que pour la variable x dans la série chronologique, je devrais entraîner le modèle à apprendre x [i] - x [i-1] plutôt que la valeur de x [i]? Est-ce que j'interprète correctement?
Oui, exactement. La différence supprime l'envie du réseau de neurones de trop baser ses prédictions sur le pas de temps passé (en obtenant simplement la dernière valeur et peut-être en la modifiant un peu)
Question 2: Vous avez dit que mes calculs pour zéro goulot d'étranglement étaient incorrects. Mais, par exemple, disons que j'utilise un simple réseau dense comme encodeur automatique. Obtenir le bon goulot d'étranglement dépend en effet des données. Mais si vous rendez le goulot d'étranglement de la même taille que l'entrée, vous obtenez la fonction d'identité.
Oui, en supposant qu'il n'y a pas de non-linéarité impliquée qui rend la chose plus difficile (voir ici pour un cas similaire). Dans le cas des LSTM, il y a des non-linéaires, c'est un point.
Un autre est que nous nous accumulons timesteps
dans un état de codeur unique. Donc, essentiellement, nous devrions accumuler les timesteps
identités dans un seul état caché et cellulaire, ce qui est hautement improbable.
Un dernier point, selon la longueur de la séquence, les LSTM ont tendance à oublier certaines des informations les moins pertinentes (c'est ce pour quoi ils ont été conçus, pas seulement pour se souvenir de tout), donc encore plus improbable.
Est-ce que num_features * num_timesteps n'est pas un goulot de bouteille de la même taille que l'entrée, et donc ne devrait-il pas faciliter l'apprentissage de l'identité du modèle?
C'est le cas, mais cela suppose que vous avez num_timesteps
pour chaque point de données, ce qui est rarement le cas, pourrait être ici. À propos de l'identité et pourquoi il est difficile de faire avec les non-linéarités pour le réseau, il a été répondu ci-dessus.
Un dernier point, sur les fonctions d'identité; si elles étaient réellement faciles à apprendre, ResNet
les architectures auraient peu de chances de réussir. Le réseau pourrait converger vers l'identité et apporter de «petites corrections» à la sortie sans lui, ce qui n'est pas le cas.
Je suis curieux de savoir: "utilisez toujours la différence des pas temporels au lieu des pas temporels" Cela semble avoir un effet de normalisation en rapprochant toutes les fonctionnalités, mais je ne comprends pas pourquoi c'est essentiel? Avoir un modèle plus grand semblait être la solution et la soustraction est juste utile.
La clé ici était, en effet, l'augmentation de la capacité du modèle. L'astuce de soustraction dépend vraiment des données. Imaginons une situation extrême:
- Nous avons des pas de
100
temps, une seule fonctionnalité - La valeur initiale du pas de temps est
10000
- Les autres valeurs de pas de temps varient
1
au plus
Que ferait le réseau de neurones (quel est le plus simple ici)? Il rejetterait probablement ce 1
changement ou un moindre changement comme bruit et prédirait simplement 1000
pour chacun d'entre eux (surtout si une certaine régularisation est en place), car il 1/1000
n'y a pas grand chose à faire.
Et si on soustrait? La perte [0, 1]
totale du réseau neuronal est dans la marge pour chaque pas de temps au lieu de [0, 1001]
, par conséquent, il est plus grave de se tromper.
Et oui, il est lié à la normalisation dans un certain sens venu à y penser.