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.
Correction
Dans un réseau récurrent, l’état caché \(h_t\) au pas de temps \(t\) est une fonction de \(h_{t-1}\) : il faut donc avoir terminé le calcul de tous les états précédents avant de pouvoir calculer \(h_t\). Le calcul est intrinsèquement séquentiel et ne peut pas être parallélisé le long de l’axe temporel, ce qui le rend lent sur de longues séquences.
Un Transformer, lui, ne calcule pas de récurrence : chaque position de la séquence est mise à jour simultanément par attention sur toutes les autres positions (calculées à partir de la même entrée), ce qui se résume à des produits matriciels parallélisables sur GPU.
Par ailleurs, dans un GRU l’information sur un pas de temps lointain ne peut atteindre le pas de temps courant qu’en transitant par tous les états intermédiaires (et s’atténue en général avec la distance), alors qu’avec l’attention, chaque pas de temps a un accès direct à tous les autres dès la première couche : le champ réceptif est immédiatement global.
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 :
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 ?
Correction
\(QK^T\) contient, pour chaque paire de pas de temps \((i, j)\), le produit scalaire \(q_i \cdot k_j\), c’est-à-dire une mesure de similarité entre la « requête » du pas de temps \(i\) et la « clé » offerte par le pas de temps \(j\). Sa dimension est \((L, L)\).
Après softmax le long de chaque ligne, chaque ligne \(i\) devient une distribution de probabilité sur les \(L\) pas de temps : ce sont les poids d’attention \(\alpha_{ij}\), qui indiquent dans quelle mesure le pas de temps \(i\) « regarde » chacun des pas de temps \(j\) (y compris lui-même) pour construire sa nouvelle représentation.
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).
Correction
def scaled_dot_product_attention(Q, K, V):
""" Entrées :
- Q : matrice des queries, (L, d_k)
- K : matrice des keys, (L, d_k)
- V : matrice des values, (L, d_v)
Renvoie :
- Z : sortie de l'attention, (L, d_v)
- alpha : poids d'attention, (L, L)
"""
d_k = Q.shape[-1]
scores = torch.matmul(Q, K.T) / (d_k ** 0.5)
alpha = torch.exp(scores) / torch.sum(torch.exp(scores), dim=-1, keepdim=True)
Z = torch.matmul(alpha, V)
return Z, alpha
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 ?
Correction
Avec des poids aléatoires non entraînés, il n’y a aucune raison qu’un pas de temps « attende » particulièrement un autre pas de temps plutôt qu’un autre : les poids d’attention devraient être à peu près uniformes sur chaque ligne (proches de \(1/L\) partout), ce que l’on observe effectivement — la matrice n’affiche pas de structure particulière. C’est seulement une fois les matrices \(W^Q\), \(W^K\), \(W^V\) apprises par descente de gradient, en optimisant une tâche donnée, que des motifs d’attention informatifs (par exemple une périodicité journalière) pourraient émerger.
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.atten self-attention, c’est-à-dire avec la même séquenceinputscomme query, key et value (nn.MultiheadAttentionrenvoie un couple(sortie, poids d'attention)),ajouter une connexion résiduelle entre
inputset 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).
Correction
class TransformerBlock(nn.Module):
def __init__(self, d_model, num_heads, ff_dim):
super().__init__()
self.att = nn.MultiheadAttention(embed_dim=d_model, num_heads=num_heads, batch_first=True)
self.ffn = nn.Sequential(
nn.Linear(d_model, ff_dim),
nn.ReLU(),
nn.Linear(ff_dim, d_model),
)
self.layernorm1 = nn.LayerNorm(d_model, eps=1e-6)
self.layernorm2 = nn.LayerNorm(d_model, eps=1e-6)
def forward(self, inputs):
attn_output, _ = self.att(inputs, inputs, inputs)
out1 = self.layernorm1(inputs + attn_output)
ffn_output = self.ffn(out1)
out2 = self.layernorm2(out1 + ffn_output)
return out2
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 ?
Correction
Le Transformer original ajoute à chaque vecteur d’entrée un encodage positionnel (positional encoding), un vecteur qui dépend uniquement de la position dans la séquence, calculé à partir de fonctions sinus et cosinus de fréquences différentes. En pratique, il est souvent plus simple d’apprendre directement un vecteur par position (un embedding de position), au même titre que les autres paramètres du réseau, par descente de gradient — c’est cette seconde solution que nous utiliserons ci-dessous.
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.
Correction
d_model = 32
num_heads = 4
ff_dim = 32
learning_rate = 0.001
class TransformerModel(nn.Module):
def __init__(self, input_dim, sequence_length, d_model, num_heads, ff_dim):
super().__init__()
self.projection = nn.Linear(input_dim, d_model)
self.pos_embedding = PositionalEmbedding(sequence_length, d_model)
self.transformer_block = TransformerBlock(d_model, num_heads, ff_dim)
self.output = nn.Linear(d_model, 1)
def forward(self, x):
projected = self.projection(x)
encoded = self.pos_embedding(projected)
transformer_out = self.transformer_block(encoded)
pooled = transformer_out.mean(dim=1)
return self.output(pooled).squeeze(-1)
model = TransformerModel(input_dim, sequence_length, d_model, num_heads, ff_dim)
criterion = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
n_params = sum(p.numel() for p in model.parameters())
print(model)
print(f"Nombre de paramètres : {n_params}")
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é ?
Correction
Les résultats précis dépendent de l’initialisation aléatoire et peuvent varier d’une exécution à l’autre, mais on observe en général des performances du même ordre de grandeur entre le GRU et le Transformer sur cette tâche, le GRU ayant parfois un léger avantage. Ce n’est pas surprenant : les Transformers sont réputés pour nécessiter davantage de données (et souvent un pré-entraînement) pour exploiter pleinement leur capacité, en raison d’un biais inductif plus faible que celui des réseaux récurrents (qui, eux, supposent explicitement une structure séquentielle). Sur un unique jeu de données météorologique de taille modeste et avec seulement 5 époques d’entraînement, l’avantage principal du Transformer — la parallélisation, donc la vitesse d’entraînement sur de longues séquences et de gros volumes de données — ne se traduit pas nécessairement par un net avantage en performance.
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 ?