Work in progress

Contenu rédigé à partir des diapositives coursTransformers.pdf, faute de TP préexistant pour cette séance. Susceptible d’être ajusté.

XIII.2 - Travaux pratiques - Prédiction de séries temporelles avec un Transformer#

La séance précédente, nous avons entraîné un réseau récurrent (GRU) pour prédire la température future à partir des paramètres météorologiques passés. L’objectif de cette séance est de reprendre exactement le même problème, mais en remplaçant le réseau récurrent par un Transformer : nous implémenterons d’abord un mécanisme d’auto-attention « à la main », puis nous nous en servirons pour construire un petit encodeur Transformer avec PyTorch, que nous entraînerons et comparerons au GRU de la semaine dernière.

Les Transformers ont été introduits pour le traitement du langage (traduction automatique), mais l’architecture ne fait aucune hypothèse sur la nature des données en entrée : tout ce dont elle a besoin est une séquence de vecteurs. Une séquence de mesures météorologiques au cours du temps convient donc tout aussi bien qu’une séquence de mots — c’est cette généralité que nous allons exploiter ici, uniquement pour des séries temporelles (nous ne ferons pas de traitement du langage dans ce TP).

Rappel : préparation des données#

Nous reprenons le jeu de données météorologique et la mise en forme many-to-one de la fin de la séance précédente (prédiction de la température à l’instant \(T=6\) à partir des \(M=720\) observations passées, sous-échantillonnées d’un facteur \(6\), soit des séquences de longueur \(120\)). Pour le détail de ces choix, se reporter au TP précédent.

import os
import urllib.request

if not os.path.exists('meteo_dataset.csv'):
    urllib.request.urlretrieve('https://cedric.cnam.fr/vertigo/Cours/ml2/docs/meteo_dataset.csv', 'meteo_dataset.csv')
import pandas as pd
import matplotlib.pyplot as plt
import torch
from torch import nn
from torch.utils.data import Dataset, DataLoader

data = pd.read_csv('meteo_dataset.csv')

split_fraction = 0.80
split_idx = int(split_fraction * len(data))

train_data = data.loc[0:split_idx-1].values
val_data = data.loc[split_idx:].values

past_length = 720
future_step = 6
step_size = 6
sequence_length = int(past_length / step_size)
batch_size = 256

class FutureTimeseriesDataset(Dataset):
    """ Fenêtres glissantes de longueur `sequence_length` (échantillonnées tous les
        `sampling_rate` pas de temps), chacune associée à une unique étiquette (celle de
        `targets` à `future_step` pas de temps après la fin de la fenêtre). Voir le TP précédent. """
    def __init__(self, data, targets, sequence_length, sampling_rate, future_step):
        self.data = torch.as_tensor(data, dtype=torch.float32)
        self.targets = torch.as_tensor(targets, dtype=torch.float32)
        self.sequence_length = sequence_length
        self.sampling_rate = sampling_rate
        self.window_span = sequence_length * sampling_rate
        self.future_step = future_step

    def __len__(self):
        return len(self.data) - self.window_span - self.future_step

    def __getitem__(self, idx):
        x = self.data[idx: idx + self.window_span: self.sampling_rate]
        y = self.targets[idx + self.window_span + self.future_step]
        return x, y

dataset_train = FutureTimeseriesDataset(
    data=train_data, targets=train_data[:, 1],
    sequence_length=sequence_length, sampling_rate=step_size, future_step=future_step,
)
train_loader = DataLoader(dataset_train, batch_size=batch_size, shuffle=True)

dataset_val = FutureTimeseriesDataset(
    data=val_data, targets=val_data[:, 1],
    sequence_length=sequence_length, sampling_rate=step_size, future_step=future_step,
)
val_loader = DataLoader(dataset_val, batch_size=batch_size, shuffle=False)

input_dim = train_data.shape[1]
print(f"Longueur des séquences : {sequence_length}, dimension des observations : {input_dim}")

Pourquoi un Transformer pour une série temporelle ?#

Question

Pourquoi ne peut-on pas paralléliser le calcul des états cachés d’un réseau récurrent le long d’une séquence ? En quoi l’architecture Transformer permet-elle de lever cette limite ? On pourra aussi discuter de la taille du contexte pris en compte par chaque pas de temps dans les deux architectures.

Attention : implémentation « à la main »#

Comme pour la régression logistique et le perceptron des séances précédentes, nous allons commencer par implémenter nous-mêmes le calcul au cœur du Transformer — l’attention — avant de nous appuyer sur PyTorch pour la suite.

Pour chaque pas de temps \(i\) d’une séquence, on définit trois vecteurs obtenus par projection linéaire de l’observation \(x_i\) :

  • une query \(q_i = x_i W^Q\) : ce que ce pas de temps cherche comme information,

  • une key \(k_i = x_i W^K\) : ce que ce pas de temps a à offrir,

  • une value \(v_i = x_i W^V\) : le contenu qui sera effectivement agrégé.

En pratique, on empile les \(q_i\), \(k_i\), \(v_i\) de toute la séquence dans des matrices \(Q\), \(K\), \(V\) (une ligne par pas de temps), ce qui permet de calculer l’attention de tous les pas de temps simultanément :

(8)#\[ \text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right) V \]

où \(d_k\) est la dimension des vecteurs key (la division par \(\sqrt{d_k}\) évite que les scores ne deviennent trop grands en valeur absolue, ce qui saturerait le softmax).

Question

Que représente la matrice \(QK^T\) avant l’application du softmax ? Quelles sont ses dimensions si \(Q\) et \(K\) sont de dimensions \((L, d_k)\), \(L\) étant la longueur de la séquence ? Qu’obtient-on après avoir appliqué le softmax le long de chaque ligne ?

Question

Compléter la fonction scaled_dot_product_attention ci-dessous, qui implémente l’équation (8). On rappelle que le softmax d’une matrice, appliqué le long de l’axe -1 (les colonnes), s’écrit torch.exp(x) / torch.sum(torch.exp(x), dim=-1, keepdim=True).

Vérifions notre implémentation sur une vraie fenêtre de notre jeu de données météorologique. Pour rester simple, on projette directement les observations (dimension 7) vers des queries, keys et values de petite dimension à l’aide de matrices aléatoires fixées (nous n’avons pas encore appris \(W^Q\), \(W^K\), \(W^V\) par descente de gradient, ce qui sera le rôle de PyTorch dans la suite) :

x_train = train_data  # alias, comme dans le TP précédent

torch.manual_seed(0)
d_qkv = 8

window = torch.as_tensor(x_train[:sequence_length], dtype=torch.float32)  # une fenêtre de 120 pas de temps, 7 variables
# Standardisation (moyenne nulle, écart-type unitaire) : les grandeurs physiques
# brutes (pression ~1000, densité ~1300...) ont des échelles très différentes et
# feraient déborder l'exponentielle du softmax si on les projetait telles quelles.
window = (window - window.mean(dim=0)) / window.std(dim=0)

W_Q = torch.randn(input_dim, d_qkv)
W_K = torch.randn(input_dim, d_qkv)
W_V = torch.randn(input_dim, d_qkv)

Q = window @ W_Q
K = window @ W_K
V = window @ W_V

Z, alpha = scaled_dot_product_attention(Q, K, V)
print(f"Z : {Z.shape}, alpha : {alpha.shape}, somme de la première ligne de alpha : {alpha[0].sum():.3f}")

plt.figure(figsize=(6, 5))
plt.imshow(alpha.numpy(), cmap='viridis')
plt.colorbar(label="Poids d'attention")
plt.xlabel("Pas de temps (key)")
plt.ylabel("Pas de temps (query)")
plt.title("Matrice d'attention (poids aléatoires, non entraînés)")
plt.show()

Question

Les poids \(W^Q\), \(W^K\), \(W^V\) utilisés ci-dessus sont aléatoires, pas appris. À quoi vous attendez-vous à ce que ressemble la matrice d’attention obtenue ? Est-ce cohérent avec la figure obtenue ?

Bloc Transformer avec PyTorch#

En pratique, on n’utilise pas une seule tête d’attention mais plusieurs en parallèle (multi-head attention) : chaque tête peut ainsi apprendre à se concentrer sur des aspects différents de la séquence. Réimplémenter le multi-head attention à la main serait long ; nous utiliserons la couche nn.MultiheadAttention, qui implémente exactement l’équation (8) (avec plusieurs têtes, et les projections \(W^Q\), \(W^K\), \(W^V\) apprises).

Un bloc encodeur Transformer complet (voir le cours) combine cette attention avec une normalisation par couche (layer normalization), des connexions résiduelles, et un petit réseau feed-forward appliqué indépendamment à chaque pas de temps :

Question

Compléter la méthode forward du bloc Transformer ci-dessous. On veillera à :

  • appliquer l’attention multi-tête self.att en self-attention, c’est-à-dire avec la même séquence inputs comme query, key et value (nn.MultiheadAttention renvoie un couple (sortie, poids d'attention)),

  • ajouter une connexion résiduelle entre inputs et la sortie de l’attention, puis normaliser (self.layernorm1),

  • appliquer le réseau feed-forward self.ffn (déjà défini), puis à nouveau une connexion résiduelle et une normalisation (self.layernorm2).

Question

Contrairement aux couches récurrentes ou convolutives, l’attention ne fait aucune différence entre un pas de temps et un autre : permuter l’ordre des pas de temps en entrée donnerait exactement la même sortie (à la permutation près). Pourtant, l’ordre temporel est évidemment important pour une série temporelle. Comment le Transformer original résout-il ce problème ? Quelle solution retient-on en pratique, d’après le cours ?

class PositionalEmbedding(nn.Module):
    def __init__(self, sequence_length, d_model):
        super().__init__()
        self.position_embedding = nn.Embedding(num_embeddings=sequence_length, embedding_dim=d_model)

    def forward(self, inputs):
        positions = torch.arange(inputs.shape[1], device=inputs.device)
        return inputs + self.position_embedding(positions)

Construction et entraînement du modèle complet#

Nous pouvons maintenant assembler le modèle complet : une projection linéaire des observations vers la dimension \(d_{\text{model}}\) du Transformer, l’ajout de l’encodage positionnel, un bloc Transformer, puis une réduction de la séquence en un unique vecteur (par moyenne sur l’axe temporel, l’équivalent PyTorch de GlobalAveragePooling1D) suivie d’une couche de sortie pour la régression.

Question

Compléter la définition du modèle ci-dessous (une classe héritant de nn.Module). On utilisera d_model = 32, num_heads = 4 et ff_dim = 32 pour le bloc Transformer, et on réduira la séquence de sortie du bloc en faisant la moyenne sur l’axe temporel (.mean(dim=1)) avant la couche de sortie. On créera aussi un optimiseur Adam (pas d’apprentissage \(0{,}001\)) et l’erreur quadratique moyenne (nn.MSELoss) comme fonction de coût, comme pour le GRU de la semaine dernière.

Entraînons ce modèle, comme pour le GRU, pendant 5 époques :

epochs = 5

for epoch in range(epochs):
    model.train()
    for x, y in train_loader:
        y_pred = model(x)
        loss = criterion(y_pred, y)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

    model.eval()
    with torch.no_grad():
        val_losses = [criterion(model(x), y).item() for x, y in val_loader]
    print(f"Epoch {epoch}: loss (train) = {loss.item():.4f}, loss (val) = {sum(val_losses)/len(val_losses):.4f}")

Évaluation et comparaison avec le GRU#

def show_plot(sequence, prediction, ground_truth, delta=future_step/step_size, title=None):
    plt.title(title)
    plt.plot(sequence.flatten(), ".-", label="Température passée")
    plt.plot(len(sequence) + delta, prediction, "go", label="Prédiction")
    plt.plot(len(sequence) + delta, ground_truth, "rx", label="Vraie température")
    plt.legend()
    plt.xlim(-1, len(sequence) + delta + 3)
    plt.xlabel("Pas de temps")
    plt.show()

model.eval()
with torch.no_grad():
    x, y = next(iter(val_loader))
    y_pred = model(x)
    for i in range(5):
        show_plot(x[i][:, 1].numpy(), y_pred[i].item(), y[i].item(), title="Prédiction de la température (Transformer)")
mean_absolute_error = nn.L1Loss()
error = 0

model.eval()
with torch.no_grad():
    for x, y in val_loader:
        y_pred = model(x)
        error += mean_absolute_error(y_pred, y).item()

print(f"Erreur absolue moyenne (Transformer) : {error/len(val_loader):.5f}")

Question

Comparer l’erreur absolue moyenne obtenue ici à celle du GRU de la semaine dernière, sur la même tâche de prédiction. Le Transformer fait-il mieux ? Comparer aussi le nombre de paramètres des deux modèles (calculé avec sum(p.numel() for p in model.parameters())) ainsi que, qualitativement, le temps d’entraînement d’une époque. Ce résultat vous semble-t-il cohérent avec la taille du jeu de données utilisé ?

Question

À faire chez vous pour approfondir, si vous avez le temps.

Empiler plusieurs blocs Transformer (par exemple 2 ou 3) à la suite les uns des autres, comme le prévoit l’architecture originale. Les performances s’améliorent-elles ? Vous pouvez également essayer de remplacer le GlobalAveragePooling1D par la sortie du seul dernier pas de temps de la séquence (transformer_out[:, -1, :]) : quel effet cela a-t-il ?