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/LSTMTransformer
Méthode de calculSéquentiel, étape par étapeParallèle, tout calculer en une seule fois
Dépendances à longue portéeFaible, disparition du gradientConnexion forte et directe à n'importe quelle distance
Informations positionnellesNaturellement séquentielNécessite un encodage positionnel
Vitesse d'entraînementLentRapide (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 :

  1. Chaque mot utilise son Q pour « s’apparier » avec le K de tous les mots, obtenant des scores d’attention
  2. Utiliser Softmax pour normaliser les scores afin que leur somme soit égale à 1
  3. Utiliser les scores normalisés pour pondérer et additionner les V de tous les mots

Le schéma rend cela plus clair :

自注意力 QKV 计算流程图

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

  1. Divisez Q/K/V en h parties le long de la dernière dimension (h est le nombre de têtes).
  2. Chaque tête effectue indépendamment l'auto-attention
  3. Concaténez les sorties de toutes les têtes
  4. 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 positionnelPrincipeAvantagesModèles représentatifs
Encodage positionnel sinusoïdalfonctions sin/cosExtrapolable, calcul simpleTransformer original, BERT
Encodage positionnel apprenableEntraîner directement un embeddingFort pouvoir expressifGPT-1/2
RoPERotation de Q/KPositions relatives, bonnes performancesLLaMA, PaLM
ALiBiAjouter un biais aux scores d’attentionSimple, adapté aux longues séquencesMPT, 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:

  1. Auto-attention masquée: ne peut regarder que les mots précédents (ne peut pas voir le futur)
  2. 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)
ArchitectureComposantType d'attentionModèle représentatifTâches applicables
Encodeur-DécodeurEncodeur + DécodeurBidirectionnel + masqué + croiséT5, BARTTraduction, résumé
Décodeur seulDécodeurMasqué (causal)GPT, LLaMA, ClaudeGénération de texte
Encodeur seulEncodeurBidirectionnelBERT, RoBERTaClassification, 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 :

  1. Calcul par blocs: Diviser Q/K/V en petits blocs et calculer bloc par bloc
  2. Softmax en ligne: Ne pas sauvegarder la matrice d'attention complète ; utiliser des astuces mathématiques pour la recalculer pendant la rétropropagation
  3. 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

  1. ID de jetons d’entrée → plongement de mots
  2. Ajouter l’encodage positionnel
  3. Traverser N couches Transformer
  4. LayerNorm final
  5. Couche linéaire projetant vers la taille du vocabulaire
  6. Softmax pour obtenir la probabilité du jeton suivant

Hyperparamètres clés

HyperparamètreSignificationGPT-2 (124M)LLaMA-7BGPT-3 (175B)
d_modelDimension du modèle768409612288
num_headsNombre de têtes d'attention123296
num_layersNombre de couches123296
d_ffDimension intermédiaire du FFN30721100849152
context_lenLongueur du contexte10242048/40962048

Guide d'implémentation du code

Si vous souhaitez implémenter un Transformer simplifié à partir de zéro, les étapes sont :

  1. Implémenter la normalisation de couche
  2. Implémenter l'auto-attention (avec masquage)
  3. Implémenter l'attention multi-têtes
  4. Implémenter le FFN
  5. Implémenter la couche Transformer
  6. Implémenter l'encodage positionnel
  7. Assembler le modèle complet
  8. 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.

La 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.

Autres extensions