Problèmes d'encodeur automatique LSTM

Dec 09 2020

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:

  1. Obtenir l'état caché initial du décodeur hs[N]: utilisez simplement l'état caché final de l'encodeur.
  2. Reconstruire dernier élément de la séquence: x[N]= w.dot(hs[N]) + b.
  3. Même modèle pour les autres éléments: x[i]= w.dot(hs[i]) + b
  4. utiliser x[i]et hs[i]comme entrées LSTMCellpour obtenir x[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 Datasetclasse 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

  1. J'ai essayé cela avec des longueurs de séquence variées allant de 7 pas de temps à 100 pas de temps.
  2. 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.
  3. J'ai essayé avec divers reductionparamètres sur le nn.MSELossmodule. Le papier demande sum, mais j'ai essayé les deux sumet mean. Aucune différence.
  4. L'article appelle à reconstruire la séquence dans l'ordre inverse (voir graphique ci-dessus). J'ai essayé cette méthode en utilisant l' flipudentrée d'origine (après l'entraînement mais avant de calculer la perte). Cela ne fait aucune différence.
  5. J'ai essayé de rendre le modèle plus complexe en ajoutant une couche LSTM supplémentaire dans l'encodeur.
  6. J'ai essayé de jouer avec l'espace latent. J'ai essayé de 50% du nombre de fonctionnalités d'entrée à 150%.
  7. 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

7 SzymonMaszke Dec 16 2020 at 05:04

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_featuresnumé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_datasoit fonctionne sur les données que vous avez fournies si subtract=Falseou (si subtract=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-4perte 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_SIZEque les SUBTRACTparamè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 942quelques é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 215ité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 300je 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 timestepsdans un état de codeur unique. Donc, essentiellement, nous devrions accumuler les timestepsidentité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_timestepspour 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, ResNetles 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 100temps, une seule fonctionnalité
  • La valeur initiale du pas de temps est 10000
  • Les autres valeurs de pas de temps varient 1au plus

Que ferait le réseau de neurones (quel est le plus simple ici)? Il rejetterait probablement ce 1changement ou un moindre changement comme bruit et prédirait simplement 1000pour chacun d'entre eux (surtout si une certaine régularisation est en place), car il 1/1000n'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.