from datasets import load_dataset
train = load_dataset("roneneldan/TinyStories", split="train[:1%]")
val = load_dataset("roneneldan/TinyStories", split="validation[:10%]")
textes_train = train["text"]
textes_val = val["text"]TD NLP - Transformers
1 Un modèle de langage avec un transformer
Ce TD fait suite au TD NLP - Bases. On reprend le même dataset (TinyStories) et le même tokenizer caractère, mais on remplace le réseau feedforward par un transformer. On change ensuite de tokenizer.
1.1 Exercice 1 : remplacer le FFN par un transformer
Reprendre le chargement des données et le tokenizer caractère du TD précédent (fonctions encode et decode). On ajoute au vocabulaire deux tokens spéciaux, comme on l’avait fait pour <UNK> : <BOS> (beginning of sequence) et <EOS> (end of sequence), qui marqueront le début et la fin de chaque histoire.
1.1.1 Le dataset
Le FFN du TD précédent prenait 10 tokens et prédisait un seul token. Un transformer prend une séquence de \(T\) tokens et renvoie une séquence de \(T\) vecteurs : on peut donc prédire le token suivant à chaque position.
Un exemple est maintenant un couple de séquences de longueur \(w\), décalées de 1 :
\[ x = (x_i, \dots, x_{i+w-1}), \qquad y = (x_{i+1}, \dots, x_{i+w}) \]
Comme au TD précédent, on concatène toutes les histoires en un seul tenseur d’ids, mais en encadrant chaque histoire par <BOS> et <EOS> :
\[ \texttt{<BOS>}\; \text{histoire}_1 \;\texttt{<EOS>}\; \texttt{<BOS>}\; \text{histoire}_2 \;\texttt{<EOS>}\; \texttt{<BOS>}\; \text{histoire}_3 \;\dots \]
- Construire ce tenseur pour le train et la validation. Écrire une classe
Datasetdont l’élément \(i\) est le couple (data[i:i+w],data[i+1:i+w+1]), avec \(w = 128\). - Combien de prédictions fait-on pour un seul exemple ? Pourquoi est-ce plus efficace que le dataset du TD précédent ?
- La sortie en position \(t\) doit prédire \(x_{t+1}\) en ne regardant que \(x_1, \dots, x_t\). Quel mécanisme du cours permet de le garantir ?
- Une fenêtre peut contenir la fin d’une histoire,
<EOS><BOS>, puis le début de la suivante. Les tokens de la nouvelle histoire peuvent-ils porter attention à l’ancienne ? Est-ce grave ? Comment modifier le masque d’attention pour l’interdire ?
1.1.2 Le modèle
| Étape | Couche | Taille de la sortie |
|---|---|---|
| entrée | ids des tokens | \((B, T)\) |
| embedding | nn.Embedding(vocab_size, d) + nn.Embedding(w, d) (position) |
\((B, T, d)\) |
| transformer | \(L\) couches nn.TransformerEncoderLayer, avec masque causal |
\((B, T, d)\) |
| normalisation | nn.LayerNorm(d) |
\((B, T, d)\) |
| head | nn.Linear(d, vocab_size) |
\((B, T, \text{vocab\_size})\) |
On propose \(d = 128\), \(h = 4\) têtes, \(L = 4\) couches.
Indices : on empile les couches à la main, comme dans le cours.
# dans __init__
self.couches = nn.ModuleList([
nn.TransformerEncoderLayer(d, h, dim_feedforward=4 * d, batch_first=True, norm_first=True)
for _ in range(L)])
self.ln_finale = nn.LayerNorm(d)
# dans forward
mask = torch.triu(torch.ones(T, T, dtype=torch.bool, device=x.device), diagonal=1)
for couche in self.couches:
e = couche(e, src_mask=mask)Optionnel : couche(e, src_mask=mask, is_causal=True) indique à PyTorch que le masque est causal, ce qui lui permet d’utiliser une implémentation plus rapide de l’attention. Le masque doit quand même être fourni.
- L’encodage positionnel est ici appris : une deuxième table d’embedding, indexée par la position \(0, \dots, w-1\), ajoutée à l’embedding du token. Pourquoi en a-t-on besoin ?
- On utilise
norm_first=True: de quelle variante du cours s’agit-il ? Pourquoi ajoute-t-on une LayerNorm finale avant la head ? - Coder le modèle. Combien a-t-il de paramètres ? Comparer au FFN du TD précédent.
1.1.3 Entraînement
- Entraîner avec
AdamW(lr=1e-3) et des batchs de 64, pendant quelques milliers d’itérations. Suivre la loss de validation et la comparer à celle du FFN. - Sans masque. Relancer quelques centaines d’itérations en retirant le masque causal. Que devient la loss d’entraînement ? Pourquoi ce modèle est-il inutilisable pour générer du texte ?
- Loss par position. Calculer la loss de validation séparément pour chaque position \(t = 0, \dots, w-1\) de la fenêtre, et la tracer. Expliquer la forme de la courbe. La comparaison avec le FFN de la question 8 est-elle équitable ?
1.1.4 Génération
- Adapter la fonction
generate(prompt, n, T)du TD précédent. Est-il encore nécessaire de compléter les prompts courts ? Pourquoi faut-il ne garder que les \(w\) derniers tokens comme contexte ? - Générer à partir de
"Once upon a time"pour les températures \(0\), \(0.5\) et \(1\). Comparer au FFN. - Générer à partir de
"Once upon a time, there was a girl named Mia.". Le nom du personnage réapparaît-il plus loin ? Pourquoi le FFN ne pouvait-il pas le faire ? - Générer une histoire complète : partir d’un prompt réduit au seul token
<BOS>, et s’arrêter quand le modèle génère<EOS>. Qu’apprend le modèle aux positions où l’entrée est<BOS>? Et à celles où l’entrée est<EOS>?
1.2 Exercice 2 : changer de tokenizer
Le tokenizer caractère donne des séquences très longues. On entraîne un tokenizer BPE (comme celui de GPT-2, mais avec un petit vocabulaire) sur nos histoires :
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("gpt2").train_new_from_iterator(textes_train, vocab_size=1000)Quelle est la taille du vocabulaire ? Afficher les tokens de la première phrase avec
tokenizer.tokenize(...). Que représente le caractèreĠ? Quels mots sont découpés en plusieurs tokens ?Combien de tokens contient le train ? Combien de caractères y a-t-il en moyenne par token ? Combien de caractères couvre maintenant une fenêtre de \(w = 128\) tokens ?
Reprendre l’exercice 1 avec ce tokenizer, sans changer l’architecture.
Le token de fin d’histoire est maintenant
tokenizer.eos_token(<|endoftext|>), hérité de GPT-2. Le tokenizer de GPT-2 n’a pas de token de début, on l’ajoute :tokenizer.add_special_tokens({"bos_token": "<|startoftext|>"})On peut ensuite insérer
tokenizer.bos_tokenettokenizer.eos_tokendirectement dans le texte avant de tokeniser :ids = tokenizer([tokenizer.bos_token + h + tokenizer.eos_token for h in textes_train])["input_ids"] data_train = torch.tensor([i for histoire in ids for i in histoire])Quelles couches changent de taille ? Combien de paramètres a le modèle ?
Comparer les loss. La loss par token des deux modèles est-elle comparable ? Pourquoi ? Pour comparer, convertir en loss par caractère : \[ \text{loss par caractère} = \text{loss par token} \times \frac{\text{nombre de tokens}}{\text{nombre de caractères}} \] Quel modèle est le meilleur ?
Comparer les textes générés. Le modèle fait-il encore des fautes d’orthographe ?
Recommencer avec
vocab_sizeégal à 300 puis 4000. Quels sont les avantages et inconvénients d’un grand vocabulaire ? Pourquoi le tokenizer de GPT-2 (50 257 tokens) serait-il un mauvais choix ici ?
2 Pour aller plus loin
2.1 Exercice 3 : visualiser l’attention
Afficher la matrice d’attention de chaque tête pour une phrase courte. Les poids ne sont pas renvoyés par nn.TransformerEncoderLayer, mais on peut appeler directement son module d’attention :
x_norm = layer.norm1(x) # avec norm_first=True, la LN est appliquée avant l'attention
_, poids = layer.self_attn(x_norm, x_norm, x_norm, attn_mask=mask,
need_weights=True, average_attn_weights=False) # (B, h, T, T)Retrouve-t-on des têtes qui regardent le token précédent, ou le début d’un mot ?
2.2 Exercice 4 : transformer from scratch
Remplacer nn.TransformerEncoderLayer par votre propre implémentation, y compris celle du mécanisme d’attention multi-tête. Vérifier qu’on obtient des loss similaires.