Analyse approfondie des Transformers
En 2017, Google a publié l’article « Attention Is All You Need », introduisant l’architecture Transformer. Personne ne s’attendait à ce que cet article change complètement le domaine de l’IA.
Les GPT, Claude, Gemini, Llama d’aujourd’hui... presque tous les grands modèles de langage grand public sont essentiellement des variantes du Transformer.
Comprenez le Transformer, et vous comprenez 90% des LLM modernes.
Dans cet article, nous allons plonger dans chaque composant du Transformer : auto-attention, attention multi-têtes, encodage positionnel, réseaux feed-forward, normalisation de couche... en couvrant non seulement le « quoi » mais aussi le « pourquoi ».
C’est l’un des modules les plus approfondis sur le plan technique. Nous utiliserons du code pour démontrer les calculs essentiels, en veillant à ce que vous ne compreniez pas seulement les formules, mais que vous puissiez aussi les implémenter vous-même.
Préquel du Transformer : limites des RNN
Avant le Transformer, les tâches de séquence (telles que la traduction et la génération de texte) étaient principalement traitées par des RNN (réseaux de neurones récurrents) et leurs variantes LSTM et GRU.
Comment fonctionne un RNN
L'idée centrale d'un RNN est de « traiter un par un » : la séquence d'entrée entre dans le réseau mot par mot, et la sortie à chaque étape contient des informations sur tous les mots précédents.
Exemple
# Démonstration simplifiée de la propagation avant d'un RNN
# ============================================
import math
class SimpleRNN:
"""Implémentation simplifiée d'un RNN pour démontrer le principe"""
def __init__(self, input_size: int, hidden_size: int):
"""Initialiser les paramètres du RNN"""
import random
random.seed(42) # Définir la graine aléatoire pour la reproductibilité
# Poids de l'entrée vers la couche cachée
self.Wx = [[random.uniform(-0.1, 0.1) for _ in range(hidden_size)]
for _ in range(input_size)]
# Poids de la couche cachée vers la couche cachée
self.Wh = [[random.uniform(-0.1, 0.1) for _ in range(hidden_size)]
for _ in range(hidden_size)]
# Biais
self.b = [0.0 for _ in range(hidden_size)]
def step(self, x: list, h_prev: list) -> liste:
"""RNN à une seule étape : prend en entrée x et l'état caché précédent h_prev, produit le nouvel état caché"""
hidden_size = len(h_prev)
h_new = [0.0 for _ in range(hidden_size)]
# h_new = tanh(Wx·x + Wh·h_prev + b)
for i in range(hidden_size):
# Calculer Wx·x
wx_sum = sum(x[j] * self.Wx[j][i] for j in range(len(x)))
# Calculer Wh·h_prev
wh_sum = sum(h_prev[j] * self.Wh[j][i] for j in range(hidden_size))
# Ajouter le biais, passer par tanh
h_new[i] = math.tanh(wx_sum + wh_sum + self.b[i])
return h_new
def forward(self, sequence: list) -> list:
"""Traiter la séquence complète"""
hidden_size = len(self.b)
h = [0.0 for _ in range(hidden_size)] # État caché initial
hidden_states = []
for x in sequence:
h = self.step(x, h)
hidden_states.append(h)
return hidden_states
# Test : démontrer avec une séquence de vecteurs simple
input_size = 4
hidden_size = 3
rnn = SimpleRNN(input_size, hidden_size)
# Supposons que la séquence d'entrée comporte 4 mots, chacun représenté par un vecteur à 4 dimensions
sequence = [
[1.0, 0.0, 0.0, 0.0], # Mot 1
[0.0, 1.0, 0.0, 0.0], # Mot 2
[0.0, 0.0, 1.0, 0.0], # Mot 3
[0.0, 0.0, 0.0, 1.0], # Mot 4
]
hidden_states = rnn.forward(sequence)
print("Exemple de démo RNN simple")
print("=" * 40)
for i, h in enumerate(hidden_states):
print(f"État caché à l'étape {i+1} : {[f'{v:.4f}' for v in h]}")
# Sortie :
# Exemple de démonstration RNN simple
# ========================================
# État caché à l'étape 1 : ['0.0204', '-0.0434', '0.0556']
# État caché à l'étape 2 : ['-0.0061', '-0.0529', '0.0775']
# État caché à l'étape 3 : ['0.0641', '-0.0409', '0.0655']
# État caché à l'étape 4 : ['0.0143', '-0.0805', '0.0277']
Le RNN semble raisonnable, mais il a trois problèmes fatals :
Problème 1 : Gradients évanescents, difficulté à mémoriser les dépendances à long terme
Le RNN est une structure en « chaîne » ; le gradient à chaque étape doit se rétropropager jusqu'à la première étape. Après des multiplications répétées, le gradient décroît exponentiellement, devenant presque 0.
Par exemple, dans la phrase « Je suis allé à Paris en 2010, ... c'était ma ville préférée » — « ville » doit se référer à « Paris », mais il y a trop de mots entre les deux, donc les RNN ont du mal à apprendre de telles dépendances à long terme.
LSTM et GRU atténuent ce problème, mais ne le résolvent pas complètement.
Problème 2 : Impossible de calculer en parallèle, entraînement lent
Un RNN doit attendre la fin de l’étape t-1 avant de pouvoir calculer l’étape t. Cela signifie :
- Même avec 100 GPU, on ne peut calculer qu’un mot à la fois
- Plus la séquence est longue, plus le temps d’entraînement est long
- Difficile à mettre à l’échelle pour de très grands ensembles de données
Problème 3 : les informations antérieures sont facilement « écrasées »
L’état caché d’un RNN est mis à jour étape par étape, et les informations ultérieures ne cessent d’écraser les informations antérieures. Les informations importantes au début d’une phrase peuvent être largement « diluées » à la fin.
L’émergence du Transformer a résolu les trois problèmes d’un seul coup.
| Fonctionnalité | RNN/LSTM | Transformer |
|---|---|---|
| Méthode de calcul | Séquentiel, étape par étape | Parallèle, tout calculer en une seule fois |
| Dépendances à longue portée | Faible, disparition du gradient | Connexion forte et directe à n'importe quelle distance |
| Informations positionnelles | Naturellement séquentiel | Nécessite un encodage positionnel |
| Vitesse d'entraînement | Lent | Rapide (parallélisable) |
Mécanisme d'auto-attention
L'auto-attention est le cœur du Transformer. Son idée est simple :Chaque mot doit « communiquer » avec tous les mots de la phrase, voir lesquels sont importants pour lui, puis effectuer une somme pondérée en fonction de l'importance.
Regardez d'abord le diagramme d'architecture global :
Compréhension intuitive de Q, K, V
L'auto-attention utilise trois vecteurs pour décrire chaque mot :
- Q (Query)——« Qui est-ce que je recherche ? »—— le mot auquel ce mot veut prêter attention
- K (Key)——« Qui suis-je ? »—— l’identité de ce mot
- V (Value)——« Qu’est-ce que j’ai ? »—— le contenu réel de ce mot
Le processus de calcul est :
- Chaque mot utilise son Q pour « s’apparier » avec le K de tous les mots, obtenant des scores d’attention
- Utiliser Softmax pour normaliser les scores afin que leur somme soit égale à 1
- Utiliser les scores normalisés pour pondérer et additionner les V de tous les mots
Le schéma rend cela plus clair :

Formule de calcul du score d’attention
La formule complète est :
Attention(Q, K, V) = softmax( Q·Kᵀ / √dₖ ) · V
Implémentons-le étape par étape en Python pur :
Exemple
# Implémentation en Python pur de Self-Attention
# ============================================
import math
def softmax(x: list) -> list:
"""Calculer Softmax : transformer un ensemble de nombres en une distribution de probabilité dont la somme vaut 1"""
# Soustraire la valeur maximale pour éviter un débordement numérique
max_val = max(x)
exp_x = [math.exp(v - max_val) for v in x]
sum_exp = sum(exp_x)
return [v / sum_exp for v in exp_x]
def matrix_multiply(A: list, B: list) -> list:
"""Multiplication matricielle : A est m×n, B est n×p, la sortie est m×p"""
m = len(A)
n = len(B)
p = len(B[0])
result = [[0.0 for _ in range(p)] for _ in range(m)]
for i in range(m):
for j in range(p):
for k in range(n):
result[i][j] += A[i][k] * B[k][j]
return result
def transpose(matrix: list) -> list:
"""Transposée de matrice"""
return list(map(list, zip(*matrix)))
class SelfAttention:
"""Implémentation en Python pur d'une couche d'auto-attention"""
def __init__(self, d_model: int, d_k: int):
"""
Initialiser la couche d'auto-attention
d_model: dimension d'entrée/sortie
d_k: dimension de Q/K (généralement d_k = d_model / num_heads)
"""
import random
random.seed(42) # exemple : graine aléatoire fixe
self.d_model = d_model
self.d_k = d_k
# Initialiser les matrices de projection pour Q, K, V
# En pratique, celles-ci sont entraînées
self.Wq = [[random.normalvariate(0, 0.1) for _ in range(d_k)]
for _ in range(d_model)]
self.Wk = [[random.normalvariate(0, 0.1) for _ in range(d_k)]
for _ in range(d_model)]
self.Wv = [[random.normalvariate(0, 0.1) for _ in range(d_k)]
for _ in range(d_model)]
def forward(self, X: list) -> list:
"""
Propagation avant de l'auto-attention
X : séquence d'entrée, forme [seq_len, d_model]
Retourne : séquence de sortie, forme [seq_len, d_k]
"""
seq_len = len(X)
# Étape 1 : calculer Q, K, V
# Q = X·Wq, K = X·Wk, V = X·Wv
Q = matrix_multiply(X, self.Wq)
K = matrix_multiply(X, self.Wk)
V = matrix_multiply(X, self.Wv)
print(" Forme de Q :", len(Q), "×", len(Q[0]))
print(" Forme de K :", len(K), "×", len(K[0]))
print(" Forme de V :", len(V), "×", len(V[0]))
# Étape 2 : Calculer les scores d'attention Q·Kᵀ
K_transposed = transpose(K)
scores = matrix_multiply(Q, K_transposed)
print("\nScores d'attention bruts :")
for i, row in enumerate(scores):
print(fLigne {i} : {[f'{v:.4f}' for v in row]})
# Étape 3 : Mise à l'échelle : diviser par √d_k
# Cela permet d'éviter que Softmax n'entre dans la région de saturation du gradient.
scale_factor = math.sqrt(self.d_k)
scores_scaled = [[v / scale_factor for v in row] for row in scores]
print("\nScores d'attention mis à l'échelle :)
for i, row in enumerate(scores_scaled):
print(fLigne {i} : {[f'{v:.4f}' for v in row]})
# Étape 4 : Normalisation Softmax
attention_weights = []
for row in scores_scaled:
attention_weights.append(softmax(row))
print("\nPoids d'attention (après Softmax) : ")
for i, row in enumerate(attention_weights):
print(fLigne {i} : {[f'{v:.4f}' for v in row]})
# Étape 5 : somme pondérée V
output = matrix_multiply(attention_weights, V)
return output, attention_weights
# ============================================
# Démo : exécuter l'auto-attention avec un exemple simple
# ============================================
# Supposons que nous ayons une phrase de 4 mots, chaque mot étant représenté par un vecteur de dimension 8
# Pour la démonstration, nous utilisons one-hot + un petit bruit aléatoire
d_model = 8
d_k = 6
# Séquence d'entrée : 4 mots, chacun de dimension 8
X = [
[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], # Mot 0
[0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], # Mot 1
[0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], # Mot 2
[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0], # Mot 3
]
print("=" * 50)
print("Exemple de démonstration d'auto-attention")
print("=" * 50)
print(f"Longueur de la séquence d'entrée : {len(X)}")
print(f"Dimension d'entrée : {len(X)
print()
attn = SelfAttention(d_model=d_model, d_k=d_k)
output, attention_weights = attn.forward(X)
print("\n" + "=" * 50)
print("Sortie de l'auto-attention :")
print("=" * 50)
for i, row in enumerate(output):
print(f"Sortie du mot {i} : {[f'{v:.4f}' for v in row]}")
Exécutez ce code et vous verrez le processus complet de calcul : génération Q/K/V, calcul des scores d'attention, mise à l'échelle, Softmax, et enfin la sortie pondérée.
Pourquoi diviser par √dₖ ?
C'est un détail important. En supposant que chaque élément de Q et K est une variable aléatoire de moyenne 0 et de variance 1, la variance de leur produit scalaire est dₖ.
Si dₖ est grand (par exemple, 512), les valeurs du produit scalaire deviennent très grandes, ce qui fait entrer Softmax dans la "région de saturation" — le gradient est presque 0, et l'entraînement ne peut pas progresser.
Après division par √dₖ, la variance revient à 1, et le gradient de Softmax redevient sain.
L'attention à produit scalaire mise à l'échelle est l'une des techniques clés pour un entraînement stable des Transformers.
Attention multi-têtes
L'auto-attention à une seule tête est puissante, mais elle a un problème : chaque mot ne peut « prêter attention » qu'une seule fois. Et si nous voulions qu'un mot prête attention à plusieurs aspects différents en même temps ?
Par exemple, dans la phrase « Au zoo, le tigre poursuit le lapin » :
- « Tigre » pourrait avoir besoin d'une tête pour prêter attention à « zoo » (lieu)
- Une autre tête pour prêter attention à « lapin » (objet de l'action)
- La troisième tête se concentre sur « poursuit » (action).
L'attention multi-têtes divise Q/K/V en plusieurs « têtes », chacune apprenant un schéma d'attention différent, puis les concatène.
Étapes de calcul de l'attention multi-têtes
- Divisez Q/K/V en h parties le long de la dernière dimension (h est le nombre de têtes).
- Chaque tête effectue indépendamment l'auto-attention
- Concaténez les sorties de toutes les têtes
- Passez par une couche de projection linéaire pour obtenir la sortie finale
La formule est :
MultiHead(Q, K, V) = Concat(head₁, head₂, ..., headₕ) · Wₒ 其中 headᵢ = Attention(Q·Wqᵢ, K·Wkᵢ, V·Wvᵢ)
Exemple
# Implémentation en Python pur de l'attention multi-têtes
# ============================================
import math
import random
random.seed(42) # exemple : graine aléatoire fixe
def softmax(x: list) -> list:
"""Calcule le Softmax"""
max_val = max(x)
exp_x = [math.exp(v - max_val) for v in x]
sum_exp = sum(exp_x)
return [v / sum_exp for v in exp_x]
def matrix_multiply(A: list, B: list) -> list:
"""Multiplication matricielle"""
m = len(A)
n = len(B)
p = len(B[0])
result = [[0.0 for _ in range(p)] for _ in range(m)]
for i in range(m):
for j in range(p):
for k in range(n):
result[i][j] += A[i][k] * B[k][j]
return result
def transpose(matrix: list) -> list:
"""Transposée de matrice"""
return list(map(list, zip(*matrix)))
class MultiHeadAttention:
"""Implémentation en Python pur de la couche d'attention multi-têtes"""
def __init__(self, d_model: int, num_heads: int):
"""
Initialise l'attention multi-têtes
d_model : dimension du modèle (doit être divisible par num_heads)
num_heads : nombre de têtes d'attention
"""
assert d_model % num_heads == 0, "d_model doit être divisible par num_heads"
self.d_model = d_model
self.num_heads = num_heads
self.d_k = d_model // num_heads # dimension de chaque tête
# initialiser les matrices de projection
self.Wq = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
self.Wk = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
self.Wv = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
self.Wo = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
def split_heads(self, X: list) -> list:
"""
Diviser la dernière dimension en num_heads têtes
Forme d'entrée : [seq_len, d_model]
Forme de sortie : [num_heads, seq_len, d_k]
"""
seq_len = len(X)
# Redimensionner : [seq_len, num_heads, d_k]
reshaped = []
for row in X:
head_rows = []
for h in range(self.num_heads):
start = h * self.d_k
end = start + self.d_k
head_rows.append(row[start:end])
reshaped.append(head_rows)
# Transposition : [num_heads, seq_len, d_k]
result = []
for h in range(self.num_heads):
head_data = [reshaped[i][h] for i in range(seq_len)]
result.append(head_data)
return result
def combine_heads(self, heads: list) -> list:
"""
Concaténer les têtes à nouveau ensemble
Forme d'entrée : [num_heads, seq_len, d_k]
Forme de sortie : [seq_len, d_model]
"""
seq_len = len(heads[0])
result = []
for i in range(seq_len):
combined = []
for h in range(self.num_heads):
combined.extend(heads[h][i])
result.append(combined)
return result
def attention_single_head(self, Q: list, K: list, V: list) -> list:
"""Auto-attention à tête unique"""
# Q·K^T
scores = matrix_multiply(Q, transpose(K))
# Mise à l'échelle
scale = math.sqrt(self.d_k)
scores_scaled = [[v / scale for v in row] for row in scores]
# Softmax
weights = [softmax(row) for row in scores_scaled]
# Somme pondérée de V
output = matrix_multiply(weights, V)
return output, weights
def forward(self, X: list) -> list:
"""Passe avant de l'attention multi-têtes"""
seq_len = len(X)
# Étape 1 : calculer Q, K, V
Q = matrix_multiply(X, self.Wq)
K = matrix_multiply(X, self.Wk)
V = matrix_multiply(X, self.Wv)
# Étape 2 : Diviser en plusieurs têtes
Q_heads = self.split_heads(Q)
K_heads = self.split_heads(K)
V_heads = self.split_heads(V)
# Étape 3 : Chaque tête effectue l'auto-attention indépendamment
output_heads = []
all_weights = []
for h in range(self.num_heads):
out, weights = self.attention_single_head(
Q_heads[h], K_heads[h], V_heads[h]
)
output_heads.append(out)
all_weights.append(weights)
# Étape 4 : Concaténer toutes les têtes
combined = self.combine_heads(output_heads)
# Étape 5 : Projection linéaire finale
output = matrix_multiply(combined, self.Wo)
return output, all_weights
# ============================================
# Démontrer l'attention multi-têtes
# ============================================
d_model = 12
num_heads = 3 # 12 / 3 = 4 dimensions par tête
# Séquence d'entrée : 4 jetons, chacun avec 12 dimensions
X = [
[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
]
print("=" * 50)
print("Exemple de démonstration d'attention multi-têtes")
print("=" * 50)
print(f"d_model = {d_model}, num_heads = {num_heads}")
print(f"Dimension par tête : d_k = {d_model // num_heads}")
print()
mha = MultiHeadAttention(d_model=d_model, num_heads=num_heads)
output, all_weights = mha.forward(X)
print("\n"Poids d'attention pour chaque tête :")
for h in range(num_heads):
print(f"\n"Tête {h} :")
for i, row in enumerate(all_weights[h]):
print(f" Position {i}: {[f'{v:.4f}' for v in row]}")
print("\n" + "=" * 50)
print("Forme de sortie finale de l'attention multi-têtes :", len(output), "×", len(output[0]))
print("=" * 50)
for i, row in enumerate(output):
print(f"Position {i}: {[f'{v:.4f}' for v in row[:6]]}...")
La puissance de l’attention multi-têtes réside dans :Différentes têtes apprennent automatiquement différents types de motifs d’attention.
Par exemple, dans les tâches de traduction :
- Certaines têtes se concentrent sur les relations syntaxiques (sujet-verbe-objet)
- Certaines têtes se concentrent sur la coréférence (à quel nom un pronom se réfère)
- Certaines têtes se concentrent sur les mots voisins locaux
Ces motifs ne sont pas conçus manuellement ; ils sont appris automatiquement par le modèle pendant l’entraînement.
Encodage positionnel
L’auto-attention a un problème : c’est un modèle "sac de mots" — l’ordre des mots ne l’intéresse pas. "I love you" et "You love me" peuvent sembler identiques à l’auto-attention.
Mais l’ordre est crucial pour le langage. Nous devons "injecter" des informations positionnelles dans le modèle.
Encodage positionnel sinusoïdal
Le Transformer original utilise un encodage positionnel sinusoïdal :
PE(pos, 2i) = sin(pos / 10000^(2i/d_model)) PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))
Où :
- pos est la position (0, 1, 2, ...)
- i est l'indice de dimension
- d_model est la dimension du modèle
Cette conception est ingénieuse :
- Chaque position a un encodage unique
- Les positions relatives peuvent être représentées par des combinaisons linéaires (sin(a+b) = sin a cos b + cos a sin b)
- Il peut extrapoler à des séquences plus longues que celles vues pendant l'entraînement
Exemple
# Implémenter l'encodage positionnel sinusoïdal
# ============================================
import math
def sinusoidal_positional_encoding(seq_len: int, d_model: int) -> liste:
"""
Calculer l'encodage positionnel sinusoïdal
Retourne : matrice d'encodage positionnel de forme [seq_len, d_model]
"""
pe = [[0.0 for _ in range(d_model)] for _ in range(seq_len)]
for pos in range(seq_len):
for i in range(0, d_model, 2):
# Utiliser sin pour les dimensions paires
div_term = math.pow(10000, 2 * i / d_model)
pe[pos][i] = math.sin(pos / div_term)
# Utiliser cos pour les dimensions impaires (si la dimension suivante existe)
if i + 1 < d_model:
pe[pos][i + 1] = math.cos(pos / div_term)
return pe
# Démo : générer l'encodage positionnel
seq_len = 10
d_model = 8
pe = sinusoidal_positional_encoding(seq_len, d_model)
print("=" * 50)
print("Exemple de démonstration d'encodage positionnel sinusoïdal")
print("=" * 50)
print(f"Longueur de séquence : {seq_len}, Dimension : {d_model}")
print()
for pos in range(seq_len):
print(f"Position {pos:2d} : {[f'{v:.4f}' for v in pe[pos]]}")
# Examiner la relation des positions relatives
print("\n" + "=" * 50)
print("Vérifier la relation linéaire des positions relatives")
print("=" * 50)
print("Différence d'encodage entre la position 0 et la position 1 :")
diff = [pe[1][i] - pe[0][i] for i in range(d_model)]
print([f'{v:.4f}' for v in diff])
RoPE : encodage positionnel rotatif
Bien que l'encodage positionnel sinusoïdal soit classique, de meilleures solutions sont apparues plus tard. Les grands modèles dominants actuels (comme LLaMA) utilisentRoPE (plongement positionnel rotatif)。
L'idée de RoPE est :faire tourner l'information de position dans Query et Key.
Concrètement, on fait tourner chaque paire de dimensions de Q et K :
[ q₁, q₂, q₃, q₄, ... ] ↓ 旋转 [ q₁·cos mθ - q₂·sin mθ, q₁·sin mθ + q₂·cos mθ, q₃·cos mθ' - q₄·sin mθ', q₃·sin mθ' + q₄·cos mθ', ... ]
Avantages de RoPE :
- Contient naturellement l'information de position relative
- Bonne extrapolation (fonctionne pour des longueurs au-delà de l'entraînement)
- A généralement de meilleures performances que l'encodage positionnel sinusoïdal
ALiBi : atténuation de l'attention selon la distance
Une autre approche simple et efficace estALiBi (attention avec biais linéaires)。
Il n'ajoute pas d'encodage positionnel, mais ajoute directement un biais aux scores d'attention : plus les éléments sont éloignés, plus le score est faible.
L'avantage d'ALiBi est sa simplicité, et il est particulièrement adapté à l'extrapolation de longueur.
| Méthodes d'encodage positionnel | Principe | Avantages | Modèles représentatifs |
|---|---|---|---|
| Encodage positionnel sinusoïdal | fonctions sin/cos | Extrapolable, calcul simple | Transformer original, BERT |
| Encodage positionnel apprenable | Entraîner directement un embedding | Fort pouvoir expressif | GPT-1/2 |
| RoPE | Rotation de Q/K | Positions relatives, bonnes performances | LLaMA, PaLM |
| ALiBi | Ajouter un biais aux scores d’attention | Simple, adapté aux longues séquences | MPT, BLOOM |
Couche de réseau feed-forward (FFN)
Dans chaque couche Transformer, après l’attention, il y a aussi un réseau feed-forward.
Structure du FFN
Le FFN du Transformer original est :
FFN(x) = max(0, x·W₁ + b₁) · W₂ + b₂
C’est-à-dire : couche linéaire → activation ReLU → couche linéaire.
Habituellement, la dimension intermédiaire est plus grande que la dimension d’entrée (par ex., d_model=512, dimension intermédiaire=2048).
ReLU vs SwiGLU
L'original utilisait ReLU, mais désormais l'approche dominante estSwiGLU:
SwiGLU(x) = (x·W₁ ⊗ sigmoid(x·V₁)) · W₂
où ⊗ est la multiplication élément par élément.
SwiGLU est plus performant que ReLU sur de nombreuses tâches ; LLaMA, PaLM, etc., l'utilisent tous.
Pourquoi le FFN est-il important ?
- L'attention « agrège des informations », le FFN « traite des informations »
- Le FFN a un grand nombre de paramètres et est l'endroit principal où le modèle « mémorise » les connaissances
- Sans FFN, la capacité d'expression du Transformer serait grandement réduite
Exemple
# Implémenter FFN : version ReLU et version SwiGLU
# ============================================
import math
import random
random.seed(42) # exemple
def relu(x: float) -> float:
"""Fonction d'activation ReLU : max(0, x)"""
return max(0.0, x)
def sigmoid(x: float) -> float:
"""Fonction d'activation sigmoïde"""
if x < -50: # Empêcher le débordement numérique
return 0.0
return 1.0 / (1.0 + math.exp(-x))
def linear(x: list, W: list, b: list) -> liste:
"""Couche linéaire : y = x·W + b"""
output_size = len(b)
output = [0.0 for _ in range(output_size)]
for i in range(output_size):
sum_wx = sum(x[j] * W[j][i] for j in range(len(x)))
output[i] = sum_wx + b[i]
return output
class FFNReLU:
"""Version ReLU de FFN"""
def __init__(self, d_model: int, d_ff: int):
self.d_model = d_model
self.d_ff = d_ff
# Initialiser les paramètres de manière aléatoire (obtenus par l'entraînement en pratique)
self.W1 = [[random.normalvariate(0, 0.1) for _ in range(d_ff)]
for _ in range(d_model)]
self.b1 = [0.0 for _ in range(d_ff)]
self.W2 = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_ff)]
self.b2 = [0.0 for _ in range(d_model)]
def forward(self, x: list) -> list:
"""Passe avant"""
# Première couche : linéaire + ReLU
hidden = linear(x, self.W1, self.b1)
hidden = [relu(v) for v in hidden]
# Deuxième couche : linéaire
output = linear(hidden, self.W2, self.b2)
return output
class FFNSwiGLU:
"""Version SwiGLU de FFN (un choix plus moderne)"""
def __init__(self, d_model: int, d_ff: int):
self.d_model = d_model
self.d_ff = d_ff
# SwiGLU nécessite deux couches linéaires pour le gating, plus une couche de sortie
self.W1 = [[random.normalvariate(0, 0.1) for _ in range(d_ff)]
for _ in range(d_model)]
self.V1 = [[random.normalvariate(0, 0.1) for _ in range(d_ff)]
for _ in range(d_model)]
self.W2 = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_ff)]
def forward(self, x: list) -> list:
"""Passe avant : SwiGLU"""
# Calculer deux transformations linéaires
x1 = linear(x, self.W1, [0.0 for _ in range(self.d_ff)])
x2 = linear(x, self.V1, [0.0 for _ in range(self.d_ff)])
# SwiGLU:x1 * sigmoid(x2)
hidden = [a * sigmoid(b) for a, b in zip(x1, x2)]
# Couche de sortie
output = linear(hidden, self.W2, [0.0 for _ in range(self.d_model)])
return output
# Démonstration du FFN
d_model = 8
d_ff = 16
# Entrée de test
x = [0.1, -0.2, 0.3, -0.4, 0.5, -0.6, 0.7, -0.8]
print("=" * 50)
print("Exemple de démo FFN")
print("=" * 50)
print(f"Entrée : {[f'{v:.4f}' for v in x]}")
ffn_relu = FFNReLU(d_model, d_ff)
output_relu = ffn_relu.forward(x)
print(f"\n"Sortie FFN ReLU : {[f'{v:.4f}' for v in output_relu]}")
ffn_swiglu = FFNSwiGLU(d_model, d_ff)
output_swiglu = ffn_swiglu.forward(x)
print(f"Sortie FFN SwiGLU : {[f'{v:.4f}' for v in output_swiglu]}")
Normalisation de couche (Layer Norm)
Il y a un autre composant important dans le Transformer : la normalisation de couche.
Ce que fait la normalisation de couche
La normalisation de couche normalise les caractéristiques de chaque échantillon pour obtenir une moyenne de 0 et une variance de 1 :
LN(x) = γ ⊗ ((x - μ) / √σ²) + β
où :
- μ est la moyenne de x, σ² est la variance de x
- γ (échelle) et β (décalage) sont des paramètres apprenables
- ⊗ est une multiplication élément par élément
Exemple
# Implémenter la normalisation de couche
# ============================================
import math
def layer_norm(x: list, gamma: list, beta: list, eps: float = 1e-6) -> list:
"""
Normalisation par couche
x : vecteur d'entrée
gamma : paramètre de mise à l'échelle
beta : paramètre de décalage
eps : petite constante pour éviter la division par zéro
"""
# Calculer la moyenne
mean = sum(x) / len(x)
# Calculer la variance
var = sum((v - mean) ** 2 for v in x) / len(x)
# Normalisation + mise à l'échelle et décalage
normalized = []
for xi, gi, bi in zip(x, gamma, beta):
normalized.append(gi * (xi - mean) / math.sqrt(var + eps) + bi)
return normalized
# Démontrer la normalisation de couche
d_model = 6
# Vecteur d'entrée
x = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
# gamma initialisé à 1, beta initialisé à 0
gamma = [1.0 for _ in range(d_model)]
beta = [0.0 for _ in range(d_model)]
print("=" * 50)
print("Exemple de démo de normalisation de couche")
print("=" * 50)
print(f"Entrée : {[f'{v:.4f}' for v in x]}")
output = layer_norm(x, gamma, beta)
print(f"Sortie : {[f'{v:.4f}' for v in output]}")
# Vérifier la moyenne et la variance
mean_out = sum(output) / len(output)
var_out = sum((v - mean_out) ** 2 for v in output) / len(output)
print(f"\n"Moyenne de sortie : {mean_out:.4f}")
print(f"Variance de sortie : {var_out:.4f}")
Pre-Norm vs Post-Norm
La normalisation de couche a deux méthodes de placement :
- Post-Normsous-couche → résidu → normalisation (Transformer original)
- Pre-Norm: normalisation → sous-couche → résidu (désormais courant)
L'entraînement avec Pre-Norm est plus stable, et presque tous les grands modèles l'utilisent désormais.
RMS Norm
RMS NormIl s'agit d'une version simplifiée de la normalisation de couche, ne calculant que la moyenne quadratique, sans soustraire la moyenne :
RMS(x) = sqrt(mean(x²)) RMSNorm(x) = γ ⊗ (x / RMS(x))
RMS Norm est plus rapide à calculer, avec des résultats similaires ; comme LLaMA l'utilise.
Architecture encodeur-décodeur vs décodeur uniquement
Le Transformer original est une architecture Encodeur-Décodeur, mais la plupart des grands modèles de langage actuels sont Décodeur uniquement.
Encodeur-Décodeur: utilisé pour des tâches comme la traduction
Architecture Encodeur-Décodeur:
- Encodeur : lit la séquence d'entrée (par exemple, une phrase en langue source) et génère une représentation de contexte
- Décodeur : génère la séquence cible (par exemple, le résultat de la traduction) en fonction de la sortie de l'Encodeur
Il existe deux types d'attention dans le Décodeur:
- Auto-attention masquée: ne peut regarder que les mots précédents (ne peut pas voir le futur)
- Attention croisée: pour prêter attention à la sortie de l'Encodeur
Tâches appropriées : traduction automatique, reconnaissance vocale, résumé (nécessite de « réécrire » l'entrée en sortie).
Décodeur uniquement: série GPT, utilisé pour la génération
L'architecture Décodeur uniquement ne possède qu'un Décodeur, pas d'Encodeur. Ses caractéristiques:
- Uniquement de l'auto-attention masquée (attention causale)
- Génération autorégressive : produit un mot à la fois
- Adapté au paradigme pré-entraînement + fine-tuning
Les GPT, LLaMA, Claude, Gemini... actuels sont presque tous Decoder-Only.
Encoder-Only : série BERT, utilisée pour la compréhension
L'architecture Encoder-Only ne comporte qu'un encodeur. Ses caractéristiques :
- Attention bidirectionnelle : chaque mot peut voir tous les mots
- Adapté aux tâches de compréhension : classification, reconnaissance d'entités nommées, question-réponse extractive
- Pré-entraîné avec « Masked Language Model » (MLM)
| Architecture | Composant | Type d'attention | Modèle représentatif | Tâches applicables |
|---|---|---|---|---|
| Encodeur-Décodeur | Encodeur + Décodeur | Bidirectionnel + masqué + croisé | T5, BART | Traduction, résumé |
| Décodeur seul | Décodeur | Masqué (causal) | GPT, LLaMA, Claude | Génération de texte |
| Encodeur seul | Encodeur | Bidirectionnel | BERT, RoBERTa | Classification, NER |
Pourquoi le Decoder-Only est-il devenu courant ? Parce qu'il est simple, et que lors d'un pré-entraînement à grande échelle, sa capacité de génération est extrêmement forte. "Une solution unique" fonctionne en fait mieux que "une conception spécialisée".
Flash Attention : rendre le calcul d'attention rapide et économe en mémoire
Les complexités temporelle et mémoire de l'auto-attention standard sont toutes deux O(n²) ; lorsque la séquence devient longue (par ex., n=8192), c'est lent et la mémoire GPU est insuffisante.
Flash AttentionC'est une optimisation clé ; son apparition en 2022 a rendu l'entraînement sur longues séquences réalisable.
Goulot d'étranglement mémoire de l'attention standard
L'auto-attention standard doit sauvegarder les résultats intermédiaires (matrices de poids d'attention) pour la rétropropagation. Pour n=8192, cela représente 8192×8192 ≈ 67 millions d'éléments, ce qui à lui seul occupe des dizaines de Go de mémoire GPU.
Principes d'optimisation de Flash Attention
Idée centrale de Flash Attention :
- Calcul par blocs: Diviser Q/K/V en petits blocs et calculer bloc par bloc
- Softmax en ligne: Ne pas sauvegarder la matrice d'attention complète ; utiliser des astuces mathématiques pour la recalculer pendant la rétropropagation
- Tirer parti de la SRAM: Placer les blocs dans le cache haute vitesse du GPU (SRAM) pour réduire les accès à la HBM
Le résultat :
- La complexité mémoire passe de O(n²) à O(n)
- 2-4x plus rapide
- Précision identique (mathématiquement équivalent)
Presque tout l’entraînement des grands modèles utilise désormais Flash Attention ou ses variantes (Flash Attention v2, Memory-Efficient Attention, etc.).
KV Cache : principes de l’accélération de l’inférence
Une fois l’optimisation de l’entraînement terminée, l’inférence (génération) doit aussi être optimisée.KV CacheC’est la clé de l’accélération de l’inférence.
Problème : calcul répétitif dans la génération autorégressive
Pendant la génération autorégressive, chaque fois qu’un nouveau token est généré, les K et V de tous les tokens précédents doivent être recalculés — c’est très inefficace.
Par exemple, lors de la génération du 100e token, les K/V des 99 premiers tokens ont déjà été calculés et n’ont pas besoin d’être recalculés.
Fonctionnement du KV Cache
L’idée du KV Cache est simple :
- Mettre en cache les K et V de toutes les positions précédentes
- Lors de la génération d’un nouveau token, ne calculez que le K/V du nouveau token et ajoutez-le au cache
- Utilisez le K/V complet pour l’attention
Ainsi, le calcul par étape passe de O(n²) à O(n), ce qui améliore grandement la vitesse de génération.
Exemple
# Démontrer le principe du KV Cache
# ============================================
import math
import random
random.seed(42)
def softmax(x):
max_val = max(x)
exp_x = [math.exp(v - max_val) for v in x]
sum_exp = sum(exp_x)
return [v / sum_exp for v in exp_x]
def matmul(A, B):
m = len(A)
n = len(B)
p = len(B[0])
result = [[0.0 for _ in range(p)] for _ in range(m)]
for i in range(m):
for j in range(p):
for k in range(n):
result[i][j] += A[i][k] * B[k][j]
return result
def transpose(matrix):
return list(map(list, zip(*matrix)))
class SimpleAttentionWithKVCache:
"""Auto-attention simple avec KV Cache"""
def __init__(self, d_model: int):
self.d_model = d_model
self.Wq = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
self.Wk = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
self.Wv = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
for _ in range(d_model)]
def forward(self, x_new: list, kv_cache: tuple = None) -> tuple:
"""
Génération en une étape avec KV Cache
x_new : nouvelle entrée courante (position unique)
kv_cache : K et V précédents, (K_cache, V_cache)
Renvoie : (output, nouveau kv_cache)
"""
# Calculer Q, K, V pour la position actuelle
q = matmul([x_new], self.Wq)
k = matmul([x_new], self.Wk)
v = matmul([x_new], self.Wv)
# Mettre à jour le KV Cache
if kv_cache is None:
K_full = k
V_full = v
else:
K_cache, V_cache = kv_cache
K_full = K_cache + k
V_full = V_cache + v
# Attention : utiliser uniquement le Q de la position actuelle
scores = matmul(q, transpose(K_full))
scale = math.sqrt(self.d_model)
scores_scaled = [[v / scale for v in row] for row in scores]
weights = [softmax(row) for row in scores_scaled]
output = matmul(weights, V_full)
return output[0], (K_full, V_full)
# Démontrer le processus de génération du KV Cache
d_model = 4
# Simuler 4 étapes de génération
inputs = [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
]
attn = SimpleAttentionWithKVCache(d_model)
print("=" * 50)
print("Exemple de démonstration de KV Cache")
print("=" * 50)
kv_cache = None
for step, x in enumerate(inputs):
output, kv_cache = attn.forward(x, kv_cache)
K_cache, V_cache = kv_cache
print(f"\n"Étape {step} :")
print(f" Taille du KV Cache : {len(K_cache)} positions")
print(f" Sortie : {[f'{v:.4f}' for v in output]}")
print("\n" + "=" * 50)
print("Remarque : chaque étape ne calcule que les K/V de la position actuelle,")
print("puis l'ajoute au Cache, pas besoin de recalculer les précédents !")
print("=" * 50)
KV Cache a un léger coût : il nécessite de la mémoire GPU supplémentaire pour stocker K et V. Pour un modèle 7B avec un contexte de 4k, le KV Cache occupe environ 1-2GB de mémoire GPU — cela en vaut la peine.
Il existe également des optimisations plus avancées :
- Multi-Query Attention: Plusieurs têtes partagent K/V
- Attention à requêtes groupées: partage groupé de K/V
Ces derniers réduisent encore l’utilisation de la mémoire et améliorent la vitesse d’inférence.
Résumé : pipeline complet Transformer
Rassemblons tous les composants et résumons le flux complet du Transformer.
Une couche Transformer de type décodeur uniquement
x → LayerNorm → Masked Multi-Head Attention → 残差连接 →
→ LayerNorm → FFN → 残差连接 → 输出
Modèle complet de type décodeur uniquement
- ID de jetons d’entrée → plongement de mots
- Ajouter l’encodage positionnel
- Traverser N couches Transformer
- LayerNorm final
- Couche linéaire projetant vers la taille du vocabulaire
- Softmax pour obtenir la probabilité du jeton suivant
Hyperparamètres clés
| Hyperparamètre | Signification | GPT-2 (124M) | LLaMA-7B | GPT-3 (175B) |
|---|---|---|---|---|
| d_model | Dimension du modèle | 768 | 4096 | 12288 |
| num_heads | Nombre de têtes d'attention | 12 | 32 | 96 |
| num_layers | Nombre de couches | 12 | 32 | 96 |
| d_ff | Dimension intermédiaire du FFN | 3072 | 11008 | 49152 |
| context_len | Longueur du contexte | 1024 | 2048/4096 | 2048 |
Guide d'implémentation du code
Si vous souhaitez implémenter un Transformer simplifié à partir de zéro, les étapes sont :
- Implémenter la normalisation de couche
- Implémenter l'auto-attention (avec masquage)
- Implémenter l'attention multi-têtes
- Implémenter le FFN
- Implémenter la couche Transformer
- Implémenter l'encodage positionnel
- Assembler le modèle complet
- Implémenter la boucle d'entraînement
En pratique, on utilise des frameworks comme PyTorch plutôt que du Python pur. L'implémentation en Python pur présentée ici sert à démontrer les principes et à vous aider à comprendre ce que fait chaque étape.
Autres extensionsLa meilleure façon de comprendre Transformer est de l'implémenter vous-même. Même une version simplifiée vous donnera une nouvelle perspective sur de nombreux détails.