Self-Attention en Détail

Le mécanisme de self-attention est le cœur battant des modèles transformers. C'est ce qui permet aux LLM de comprendre les relations complexes entre les mots, même lorsqu'ils sont éloignés dans le texte.

Les Matrices Q, K, V

L'attention se base sur trois matrices fondamentales :

Concept : Analogie de la Bibliothèque Imaginez une bibliothèque : la Query est votre question, les Keys sont les titres des livres, et les Values sont le contenu des livres. L'attention calcule quels livres sont pertinents pour votre question.

La Formule de l'Attention

La formule mathématique de l'attention est élégante et puissante :

Attention(Q, K, V) = softmax(QK^T / √d_k) × V

Où :
- QK^T : calcule les scores de similarité (produit scalaire)
- √d_k : facteur d'échelle (dimension des clés)
- softmax : normalise les scores en probabilités
- × V : pondère les valeurs selon les probabilités
                
Important : Le Scaling Factor Le terme √d_k est crucial ! Sans lui, pour de grandes dimensions, les produits scalaires deviennent trop grands et le softmax sature. C'est pourquoi on parle de "scaled dot-product attention".

Multi-Head Attention

Au lieu d'une seule attention, les transformers utilisent plusieurs "têtes" d'attention en parallèle. Chaque tête apprend à se concentrer sur différents aspects :

     Input Embedding [d_model=768]
            |
    ┌───────┼───────┬───────┬───────┐
    │       │       │       │       │
  Head1  Head2  Head3  ...  Head12
  [64]   [64]   [64]       [64]
    │       │       │       │       │
    └───────┴───────┴───────┴───────┘
            |
        Concat [768]
            |
       Linear [768]
            |
         Output
                

Masked Attention (Causal LM)

Pour les modèles génératifs, on utilise un masque triangulaire qui empêche chaque token de "voir" les tokens futurs. C'est essentiel pour l'auto-régression :

Matrice d'attention sans masque :
  Le  chat mange
Le  ✓   ✓    ✓
chat ✓  ✓    ✓
mange ✓  ✓   ✓

Avec masque causal :
  Le  chat mange
Le  ✓   ✗    ✗
chat ✓  ✓    ✗
mange ✓  ✓   ✓
                

Implémentation PyTorch

import torch
import torch.nn as nn
import math

class SelfAttention(nn.Module):
    def __init__(self, d_model, num_heads):
        super().__init__()
        assert d_model % num_heads == 0

        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads

        # Projections linéaires pour Q, K, V
        self.W_q = nn.Linear(d_model, d_model)
        self.W_k = nn.Linear(d_model, d_model)
        self.W_v = nn.Linear(d_model, d_model)
        self.W_o = nn.Linear(d_model, d_model)

    def split_heads(self, x):
        batch_size, seq_len, d_model = x.size()
        return x.view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)

    def forward(self, x, mask=None):
        batch_size, seq_len, d_model = x.size()

        # Projections Q, K, V
        Q = self.split_heads(self.W_q(x))  # (batch, heads, seq, d_k)
        K = self.split_heads(self.W_k(x))
        V = self.split_heads(self.W_v(x))

        # Scaled dot-product attention
        scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k)

        # Appliquer le masque causal si nécessaire
        if mask is not None:
            scores = scores.masked_fill(mask == 0, float('-inf'))

        attention_weights = torch.softmax(scores, dim=-1)
        attention_output = torch.matmul(attention_weights, V)

        # Recombiner les têtes
        attention_output = attention_output.transpose(1, 2).contiguous()
        attention_output = attention_output.view(batch_size, seq_len, d_model)

        # Projection finale
        output = self.W_o(attention_output)

        return output, attention_weights

# Exemple d'utilisation
d_model = 512
num_heads = 8
seq_len = 10
batch_size = 2

attention = SelfAttention(d_model, num_heads)
x = torch.randn(batch_size, seq_len, d_model)

# Créer un masque causal
mask = torch.tril(torch.ones(seq_len, seq_len)).unsqueeze(0).unsqueeze(0)

output, weights = attention(x, mask)
print(f"Output shape: {output.shape}")  # (2, 10, 512)
print(f"Attention weights shape: {weights.shape}")  # (2, 8, 10, 10)
                
Conseil du Mentor (30 ans d'expérience) J'ai vu l'évolution de RNN → LSTM → Attention → Transformers. Le génie de l'attention est sa parallélisation : contrairement aux RNN séquentiels, toutes les positions sont traitées simultanément. C'est ce qui a permis l'explosion des LLM. Mais attention à la complexité O(n²) en mémoire : c'est le bottleneck pour les longs contextes !

Visualisation de l'Attention

Voici comment visualiser ce que "regarde" chaque tête d'attention :

import matplotlib.pyplot as plt
import seaborn as sns

def visualize_attention(attention_weights, sentence):
    """
    Visualise les poids d'attention pour une tête
    attention_weights: (seq_len, seq_len)
    sentence: liste de tokens
    """
    plt.figure(figsize=(10, 8))
    sns.heatmap(
        attention_weights.detach().numpy(),
        xticklabels=sentence,
        yticklabels=sentence,
        cmap='viridis',
        cbar=True,
        square=True
    )
    plt.xlabel('Keys')
    plt.ylabel('Queries')
    plt.title('Attention Weights Heatmap')
    plt.tight_layout()
    plt.savefig('attention_viz.png', dpi=300)
    plt.show()

# Exemple
sentence = ["Le", "chat", "noir", "dort", "paisiblement"]
# weights[0, 0] = premier batch, première tête
visualize_attention(weights[0, 0], sentence)
                
Astuce Pratique Pour debugger votre attention, vérifiez ces points :
1. Les poids d'attention somment à 1 (softmax)
2. Le masque causal est correctement appliqué
3. Les gradients ne explosent pas (gradient clipping)
4. La variance des poids reste raisonnable

Points Clés à Retenir

Positional Encoding & RoPE

Contrairement aux RNN qui traitent séquentiellement, les transformers traitent tous les tokens simultanément. Mais comment faire comprendre au modèle l'ordre des mots ? C'est le rôle du positional encoding.

Le Problème de l'Ordre

Sans information de position, "Le chat mange la souris" et "La souris mange le chat" auraient exactement la même représentation ! Le positional encoding encode la position de chaque token.

Concept : Trois Approches 1. Learned Positional Embeddings : Position apprise comme un embedding classique
2. Sinusoidal Encoding : Formule mathématique fixe (Transformer original)
3. Rotary Position Embeddings (RoPE) : Rotation dans l'espace des embeddings (Llama, Mistral)

Sinusoidal Encoding (Original Transformer)

Le papier "Attention Is All You Need" (2017) utilise des fonctions sinus/cosinus :

PE(pos, 2i)   = sin(pos / 10000^(2i/d_model))
PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))

Où :
- pos : position du token (0, 1, 2, ...)
- i : dimension de l'embedding
- d_model : taille totale de l'embedding (ex: 512)
                
import torch
import numpy as np

def sinusoidal_positional_encoding(max_len, d_model):
    """
    Crée les positional encodings sinusoïdaux
    """
    pe = torch.zeros(max_len, d_model)
    position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)

    div_term = torch.exp(
        torch.arange(0, d_model, 2).float() *
        (-np.log(10000.0) / d_model)
    )

    pe[:, 0::2] = torch.sin(position * div_term)
    pe[:, 1::2] = torch.cos(position * div_term)

    return pe

# Exemple
max_len = 512
d_model = 512
pe = sinusoidal_positional_encoding(max_len, d_model)

print(f"PE shape: {pe.shape}")  # (512, 512)

# Visualisation
import matplotlib.pyplot as plt
plt.figure(figsize=(12, 6))
plt.imshow(pe[:50, :50], cmap='RdBu', aspect='auto')
plt.xlabel('Dimension')
plt.ylabel('Position')
plt.title('Sinusoidal Positional Encoding')
plt.colorbar()
plt.savefig('sinusoidal_pe.png')
                

Rotary Position Embeddings (RoPE)

RoPE est utilisé par les modèles modernes (Llama 2/3, Mistral, Qwen). Au lieu d'ajouter une position, RoPE applique une rotation dans l'espace complexe :

Espace 2D simplifié :

Sans RoPE :              Avec RoPE :

   q1 •                     q1 ↻
                                 ⤸
   k1 •                         • k1
                            (rotation θ)

La rotation encode la position relative entre tokens
                
Avantages de RoPE 1. Position relative : Capture naturellement la distance entre tokens
2. Extrapolation : Fonctionne mieux sur des séquences plus longues que l'entraînement
3. Efficacité : Pas d'ajout de paramètres supplémentaires
4. Long contexte : Permet l'extension via RoPE scaling
import torch
import torch.nn as nn

class RotaryPositionalEmbedding(nn.Module):
    def __init__(self, dim, max_seq_len=2048, base=10000):
        super().__init__()
        self.dim = dim
        self.max_seq_len = max_seq_len
        self.base = base

        # Pré-calculer les fréquences
        inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2).float() / dim))
        self.register_buffer('inv_freq', inv_freq)

        # Cache pour les positions
        self._seq_len_cached = None
        self._cos_cached = None
        self._sin_cached = None

    def _update_cache(self, seq_len, device):
        if seq_len != self._seq_len_cached:
            self._seq_len_cached = seq_len
            t = torch.arange(seq_len, device=device).type_as(self.inv_freq)
            freqs = torch.einsum('i,j->ij', t, self.inv_freq)
            emb = torch.cat((freqs, freqs), dim=-1)
            self._cos_cached = emb.cos()
            self._sin_cached = emb.sin()

    def rotate_half(self, x):
        """Rotation par paires (x1, x2) → (-x2, x1)"""
        x1, x2 = x[..., :x.shape[-1]//2], x[..., x.shape[-1]//2:]
        return torch.cat((-x2, x1), dim=-1)

    def forward(self, q, k):
        seq_len = q.shape[1]
        self._update_cache(seq_len, q.device)

        # Appliquer la rotation
        q_rotated = (q * self._cos_cached) + (self.rotate_half(q) * self._sin_cached)
        k_rotated = (k * self._cos_cached) + (self.rotate_half(k) * self._sin_cached)

        return q_rotated, k_rotated

# Exemple d'utilisation
batch_size = 2
seq_len = 128
num_heads = 8
head_dim = 64

rope = RotaryPositionalEmbedding(dim=head_dim)

q = torch.randn(batch_size, seq_len, num_heads, head_dim)
k = torch.randn(batch_size, seq_len, num_heads, head_dim)

q_rot, k_rot = rope(q, k)
print(f"Q rotated shape: {q_rot.shape}")
                

ALiBi (Attention with Linear Biases)

Utilisé par BLOOM, ALiBi ajoute simplement un biais linéaire aux scores d'attention :

scores = Q @ K^T + ALiBi_bias

ALiBi_bias pour distance d :
Head 1: -1 * d
Head 2: -2 * d
Head 3: -4 * d
...

Effet : pénalise les tokens distants de manière progressive
                
Méthode Modèles Avantages Limitations
Sinusoidal GPT-2, BERT Simple, pas de paramètres Position absolue, extrapolation limitée
RoPE Llama, Mistral, Qwen Position relative, excellente extrapolation Légèrement plus complexe
ALiBi BLOOM, MPT Très simple, bon pour long contexte Performance légèrement inférieure

RoPE Scaling pour Contextes Longs

Pour étendre le contexte au-delà de la longueur d'entraînement, on peut "scaler" RoPE :

# RoPE standard (entraîné sur 4k tokens)
base_freq = 10000

# Linear Scaling (8k tokens)
scaled_freq = base_freq * 2

# NTK-Aware Scaling (meilleure qualité)
alpha = 2  # facteur d'extension
new_base = base_freq * alpha^(d/(d-2))

# Dynamic NTK (utilisé par Llama 2 Long)
# Ajuste automatiquement selon la longueur
                
Conseil du Mentor RoPE est une innovation majeure. Avant, étendre le contexte d'un modèle nécessitait un réentraînement complet. Avec RoPE scaling, on peut passer de 4k à 32k tokens avec juste du fine-tuning ! J'ai vu Llama 2 passer à 100k contexte grâce à cela. C'est l'équivalent de lire un livre entier en une passe.

Implémentation Complète avec RoPE

class MultiHeadAttentionWithRoPE(nn.Module):
    def __init__(self, d_model, num_heads, max_seq_len=2048):
        super().__init__()
        self.num_heads = num_heads
        self.d_k = d_model // num_heads

        self.W_q = nn.Linear(d_model, d_model)
        self.W_k = nn.Linear(d_model, d_model)
        self.W_v = nn.Linear(d_model, d_model)
        self.W_o = nn.Linear(d_model, d_model)

        self.rope = RotaryPositionalEmbedding(self.d_k, max_seq_len)

    def forward(self, x, mask=None):
        batch_size, seq_len, d_model = x.size()

        # Projections Q, K, V
        Q = self.W_q(x).view(batch_size, seq_len, self.num_heads, self.d_k)
        K = self.W_k(x).view(batch_size, seq_len, self.num_heads, self.d_k)
        V = self.W_v(x).view(batch_size, seq_len, self.num_heads, self.d_k)

        # Appliquer RoPE sur Q et K
        Q, K = self.rope(Q, K)

        # Transpose pour attention
        Q = Q.transpose(1, 2)  # (batch, heads, seq, d_k)
        K = K.transpose(1, 2)
        V = V.transpose(1, 2)

        # Scaled dot-product attention
        scores = torch.matmul(Q, K.transpose(-2, -1)) / (self.d_k ** 0.5)

        if mask is not None:
            scores = scores.masked_fill(mask == 0, float('-inf'))

        attn_weights = torch.softmax(scores, dim=-1)
        output = torch.matmul(attn_weights, V)

        # Recombiner
        output = output.transpose(1, 2).contiguous()
        output = output.view(batch_size, seq_len, d_model)

        return self.W_o(output)
                

Points Clés

Feed-Forward & Normalisation

Après l'attention, chaque bloc transformer contient un réseau feed-forward et des couches de normalisation. Ces composants sont cruciaux pour la capacité du modèle.

Architecture d'un Bloc Transformer

Input X
   │
   ├─────────────┐ (Residual Connection)
   │             │
 LayerNorm     │
   │             │
 Multi-Head    │
 Attention     │
   │             │
   └──────(+)────┘
        │
   ├─────────────┐ (Residual Connection)
   │             │
 LayerNorm     │
   │             │
 Feed-Forward  │
   │             │
   └──────(+)────┘
        │
     Output
                

Feed-Forward Network (FFN)

Le FFN est un MLP simple à 2 couches. Dans le Transformer original :

FFN(x) = max(0, xW₁ + b₁)W₂ + b₂

Dimensions :
- Input: d_model (ex: 512)
- Hidden: d_ff = 4 × d_model (ex: 2048)
- Output: d_model (ex: 512)

Activation : ReLU, GELU, ou SwiGLU
                
import torch.nn as nn
import torch.nn.functional as F

class FeedForward(nn.Module):
    """FFN classique (GPT-2, BERT style)"""
    def __init__(self, d_model, d_ff, dropout=0.1):
        super().__init__()
        self.w1 = nn.Linear(d_model, d_ff)
        self.w2 = nn.Linear(d_ff, d_model)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        # x: (batch, seq_len, d_model)
        x = self.w1(x)              # (batch, seq_len, d_ff)
        x = F.gelu(x)               # Activation GELU
        x = self.dropout(x)
        x = self.w2(x)              # (batch, seq_len, d_model)
        x = self.dropout(x)
        return x

# Exemple
d_model = 768
d_ff = 3072  # 4 × d_model
ffn = FeedForward(d_model, d_ff)

x = torch.randn(2, 128, d_model)
output = ffn(x)
print(f"Output shape: {output.shape}")  # (2, 128, 768)
                

SwiGLU : L'Activation de Llama

Llama, Mistral et autres modèles récents utilisent SwiGLU (Swish-Gated Linear Unit) au lieu de ReLU/GELU :

SwiGLU Formula SwiGLU(x) = (Swish(xW_gate) ⊙ xW_up) W_down

Où Swish(x) = x × sigmoid(x)
⊙ = multiplication élément par élément (gating)
class SwiGLU(nn.Module):
    """FFN avec SwiGLU (Llama style)"""
    def __init__(self, d_model, d_ff=None):
        super().__init__()
        if d_ff is None:
            # Llama utilise ~2.7x au lieu de 4x
            d_ff = int(2 * (4 * d_model) / 3)
            # Arrondir au multiple de 256 supérieur
            d_ff = 256 * ((d_ff + 256 - 1) // 256)

        self.w_gate = nn.Linear(d_model, d_ff, bias=False)
        self.w_up = nn.Linear(d_model, d_ff, bias=False)
        self.w_down = nn.Linear(d_ff, d_model, bias=False)

    def forward(self, x):
        # Swish activation (aussi appelé SiLU)
        gate = F.silu(self.w_gate(x))  # (batch, seq, d_ff)
        up = self.w_up(x)               # (batch, seq, d_ff)

        # Gating : multiplication élément par élément
        hidden = gate * up

        # Projection vers d_model
        output = self.w_down(hidden)
        return output

# Exemple Llama-like
d_model = 4096
swiglu = SwiGLU(d_model)

x = torch.randn(1, 128, d_model)
output = swiglu(x)
print(f"Output shape: {output.shape}")  # (1, 128, 4096)

# Vérifier la dimension intermédiaire
print(f"FFN hidden dim: {swiglu.w_gate.out_features}")  # ~11008 pour Llama 7B
                
Pourquoi SwiGLU ? 1. Performance : +1-2% de qualité vs GELU
2. Gating : Contrôle adaptatif de l'information
3. Smooth gradient : Meilleur pour l'entraînement
4. Empirique : Prouvé efficace sur des milliards de tokens

Layer Normalization

LayerNorm normalise chaque exemple individuellement (contrairement à BatchNorm qui normalise par batch) :

LayerNorm(x) = γ × (x - μ) / √(σ² + ε) + β

Où :
- μ = moyenne sur les features (d_model)
- σ² = variance sur les features
- γ, β = paramètres apprenables (scale & shift)
- ε = constante pour stabilité numérique (1e-5)
                
class LayerNorm(nn.Module):
    """LayerNorm classique"""
    def __init__(self, d_model, eps=1e-5):
        super().__init__()
        self.eps = eps
        self.gamma = nn.Parameter(torch.ones(d_model))
        self.beta = nn.Parameter(torch.zeros(d_model))

    def forward(self, x):
        # x: (batch, seq_len, d_model)
        mean = x.mean(dim=-1, keepdim=True)
        var = x.var(dim=-1, keepdim=True, unbiased=False)

        # Normalisation
        x_norm = (x - mean) / torch.sqrt(var + self.eps)

        # Scale and shift
        return self.gamma * x_norm + self.beta
                

RMSNorm : La Simplification de Llama

Llama utilise RMSNorm (Root Mean Square Norm), une version simplifiée sans centrage :

RMSNorm(x) = γ × x / RMS(x)

Où RMS(x) = √(1/n × Σx²)

Différences avec LayerNorm :
- Pas de soustraction de la moyenne (μ)
- Pas de paramètre β (shift)
- Plus rapide et plus stable
                
class RMSNorm(nn.Module):
    """RMSNorm (Llama style)"""
    def __init__(self, d_model, eps=1e-6):
        super().__init__()
        self.eps = eps
        self.weight = nn.Parameter(torch.ones(d_model))

    def forward(self, x):
        # x: (batch, seq_len, d_model)
        # Calculer RMS
        rms = torch.sqrt(torch.mean(x ** 2, dim=-1, keepdim=True) + self.eps)

        # Normaliser et scale
        x_norm = x / rms
        return self.weight * x_norm

# Comparaison performance
import time

d_model = 4096
batch_size = 8
seq_len = 2048

x = torch.randn(batch_size, seq_len, d_model).cuda()

# LayerNorm
ln = nn.LayerNorm(d_model).cuda()
start = time.time()
for _ in range(100):
    _ = ln(x)
ln_time = time.time() - start

# RMSNorm
rms = RMSNorm(d_model).cuda()
start = time.time()
for _ in range(100):
    _ = rms(x)
rms_time = time.time() - start

print(f"LayerNorm: {ln_time:.4f}s")
print(f"RMSNorm: {rms_time:.4f}s")
print(f"Speedup: {ln_time/rms_time:.2f}x")
                

Pre-Norm vs Post-Norm

Post-Norm (Transformer original) :
   X → Attention → Add → LayerNorm → FFN → Add → LayerNorm → Y

Pre-Norm (GPT, Llama) :
   X → LayerNorm → Attention → Add → LayerNorm → FFN → Add → Y

Avantage Pre-Norm : stabilité d'entraînement, gradients plus propres
                
Conseil du Mentor J'ai debuggé des centaines de modèles. Le passage de Post-Norm à Pre-Norm a été un game-changer. Avant, entraîner un gros modèle était un cauchemar de gradient explosion. Pre-Norm + RMSNorm (Llama) rend l'entraînement beaucoup plus stable. Ajoutez gradient clipping et vous avez un setup robuste.

Bloc Transformer Complet

class TransformerBlock(nn.Module):
    """Bloc Transformer complet (Llama-style)"""
    def __init__(self, d_model, num_heads, d_ff=None, dropout=0.1):
        super().__init__()

        # Attention
        self.attention = MultiHeadAttentionWithRoPE(d_model, num_heads)
        self.attn_norm = RMSNorm(d_model)

        # Feed-Forward
        self.ffn = SwiGLU(d_model, d_ff)
        self.ffn_norm = RMSNorm(d_model)

        self.dropout = nn.Dropout(dropout)

    def forward(self, x, mask=None):
        # Attention block (Pre-Norm)
        residual = x
        x = self.attn_norm(x)
        x = self.attention(x, mask)
        x = self.dropout(x)
        x = residual + x  # Residual connection

        # FFN block (Pre-Norm)
        residual = x
        x = self.ffn_norm(x)
        x = self.ffn(x)
        x = self.dropout(x)
        x = residual + x  # Residual connection

        return x

# Stack de N blocs
class TransformerStack(nn.Module):
    def __init__(self, num_layers, d_model, num_heads):
        super().__init__()
        self.layers = nn.ModuleList([
            TransformerBlock(d_model, num_heads)
            for _ in range(num_layers)
        ])
        self.final_norm = RMSNorm(d_model)

    def forward(self, x, mask=None):
        for layer in self.layers:
            x = layer(x, mask)
        x = self.final_norm(x)
        return x

# Exemple : Llama-7B like
num_layers = 32
d_model = 4096
num_heads = 32

model = TransformerStack(num_layers, d_model, num_heads)
print(f"Total parameters: {sum(p.numel() for p in model.parameters()) / 1e9:.2f}B")
                
Composant Transformer Original Llama 2/3
Normalisation LayerNorm (Post-Norm) RMSNorm (Pre-Norm)
Activation FFN ReLU SwiGLU
FFN Size 4 × d_model ~2.7 × d_model
Position Sinusoidal RoPE

Points Clés

Tokenization

La tokenization est l'étape cruciale qui transforme le texte en nombres. Le choix de l'algorithme et de la taille du vocabulaire impacte directement les performances du modèle.

Pourquoi Tokenizer ?

Trois Approches Historiques 1. Character-level : "hello" → ['h','e','l','l','o'] (vocab ~100)
2. Word-level : "hello world" → ['hello','world'] (vocab ~50k-1M)
3. Subword : "hello" → ['hello'], "unhappiness" → ['un','happiness'] (vocab ~32k-100k)

Les subwords sont le meilleur compromis : vocabulaire raisonnable + capacité à gérer les mots rares.

Byte Pair Encoding (BPE)

BPE est utilisé par GPT-2, GPT-3, GPT-4. C'est un algorithme glouton qui merge itérativement les paires de tokens les plus fréquentes.

Algorithme BPE :

1. Initialisation : chaque byte/caractère est un token
   "low" → ['l', 'o', 'w']

2. Compter les paires consécutives
   "low low low lower" →
   ('l','o'): 4 fois
   ('o','w'): 4 fois
   ('w',' '): 3 fois
   ...

3. Merger la paire la plus fréquente
   ('l','o') → 'lo'
   "low" → ['lo', 'w']

4. Répéter jusqu'à atteindre la taille de vocab désirée
                
# Installation
# pip install tokenizers

from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import Whitespace

# Créer et entraîner un tokenizer BPE
tokenizer = Tokenizer(BPE(unk_token="[UNK]"))
tokenizer.pre_tokenizer = Whitespace()

trainer = BpeTrainer(
    vocab_size=30000,
    min_frequency=2,
    special_tokens=["[UNK]", "[PAD]", "[CLS]", "[SEP]", "[MASK]"]
)

# Entraîner sur des fichiers texte
files = ["data/train.txt"]
tokenizer.train(files, trainer)

# Sauvegarder
tokenizer.save("my_bpe_tokenizer.json")

# Utiliser
output = tokenizer.encode("Hello, how are you?")
print(f"Tokens: {output.tokens}")
print(f"IDs: {output.ids}")

# Exemple de sortie :
# Tokens: ['Hello', ',', 'how', 'are', 'you', '?']
# IDs: [15496, 11, 703, 389, 345, 30]
                

SentencePiece (Llama, Mistral)

SentencePiece traite le texte comme une séquence de bytes bruts, sans pré-tokenization. Utilisé par la plupart des modèles modernes.

Avantages de SentencePiece 1. Language-agnostic : Pas de notion d'espace (fonctionne pour chinois, japonais)
2. Reversible : Encode/decode est parfaitement réversible
3. Byte-fallback : Gère tout caractère Unicode
4. Efficace : Implémentation C++ rapide
# pip install sentencepiece

import sentencepiece as spm

# Entraîner un tokenizer
spm.SentencePieceTrainer.train(
    input='data/train.txt',
    model_prefix='my_sp_model',
    vocab_size=32000,
    model_type='bpe',  # ou 'unigram'
    character_coverage=0.9995,  # couvre 99.95% des caractères
    num_threads=16,
    train_extremely_large_corpus=True,
    # Tokens spéciaux
    pad_id=0,
    unk_id=1,
    bos_id=2,  # beginning of sequence
    eos_id=3,  # end of sequence
    user_defined_symbols=['<|im_start|>', '<|im_end|>']  # pour chat format
)

# Charger et utiliser
sp = spm.SentencePieceProcessor()
sp.load('my_sp_model.model')

# Encoder
text = "Hello world! 你好世界"
ids = sp.encode(text, out_type=int)
tokens = sp.encode(text, out_type=str)

print(f"IDs: {ids}")
print(f"Tokens: {tokens}")

# Decoder
decoded = sp.decode(ids)
print(f"Decoded: {decoded}")

# Info sur le vocab
print(f"Vocab size: {sp.vocab_size()}")
print(f"Token 1000: {sp.id_to_piece(1000)}")
                

Tiktoken (OpenAI)

Tiktoken est le tokenizer ultra-rapide d'OpenAI (GPT-3.5, GPT-4), écrit en Rust.

# pip install tiktoken

import tiktoken

# Charger un tokenizer pré-entraîné
# GPT-4
enc = tiktoken.get_encoding("cl100k_base")

# Encoder
text = "Hello world! This is a test."
tokens = enc.encode(text)
print(f"Tokens: {tokens}")
print(f"Num tokens: {len(tokens)}")

# Decoder
decoded = enc.decode(tokens)
print(f"Decoded: {decoded}")

# Voir les tokens individuels
for token_id in tokens:
    token_bytes = enc.decode_single_token_bytes(token_id)
    print(f"{token_id}: {token_bytes}")

# Compter les tokens (utile pour API)
def count_tokens(text, model="gpt-4"):
    encoding = tiktoken.encoding_for_model(model)
    return len(encoding.encode(text))

prompt = "Explain quantum computing in simple terms."
num_tokens = count_tokens(prompt)
print(f"Prompt tokens: {num_tokens}")
                

Comparaison des Tokenizers

Tokenizer Modèles Vocab Size Particularités
BPE (GPT-2) GPT-2, GPT-3 50,257 Byte-level BPE
tiktoken GPT-3.5, GPT-4 100,277 Optimisé pour code, multilingue
SentencePiece Llama, Mistral, Qwen 32,000 Language-agnostic, byte-fallback
WordPiece BERT 30,522 Max likelihood, ## prefix

Impact de la Taille du Vocabulaire

Exemple : "International cooperation"

Vocab 10k :  ['Int', 'ernation', 'al', 'co', 'oper', 'ation'] → 6 tokens
Vocab 32k :  ['International', 'cooperation'] → 2 tokens
Vocab 100k : ['International', 'cooperation'] → 2 tokens

Trade-offs :
✓ Vocab plus grand → Moins de tokens → Contexte plus long
✗ Vocab plus grand → Embedding matrix plus grande → Plus de paramètres
✗ Vocab plus grand → Tokens rares mal appris
                
Conseil du Mentor La tokenization est souvent négligée mais cruciale. J'ai vu des gains de 10-15% juste en optimisant le tokenizer pour le domaine cible. Par exemple, pour du code, tiktoken inclut des tokens spécifiques Python/JS. Pour la médecine, entraîner un tokenizer sur du corpus médical améliore drastiquement la compression. Règle d'or : 1 token ≈ 0.75 mot en anglais, 1-1.5 mots en français.

Tokenization pour Différentes Langues

import tiktoken

enc = tiktoken.get_encoding("cl100k_base")

# Comparer l'efficacité par langue
texts = {
    "English": "The quick brown fox jumps over the lazy dog.",
    "French": "Le renard brun rapide saute par-dessus le chien paresseux.",
    "Chinese": "快速的棕色狐狸跳过懒狗。",
    "Arabic": "الثعلب البني السريع يقفز فوق الكلب الكسول.",
    "Code": "def factorial(n):\n    return 1 if n == 0 else n * factorial(n-1)"
}

for lang, text in texts.items():
    tokens = enc.encode(text)
    ratio = len(tokens) / len(text.split())
    print(f"{lang:10} | Chars: {len(text):3} | Tokens: {len(tokens):3} | Ratio: {ratio:.2f}")

# Résultat typique :
# English    | Chars:  44 | Tokens:  10 | Ratio: 1.11
# French     | Chars:  62 | Tokens:  15 | Ratio: 1.67
# Chinese    | Chars:  15 | Tokens:  14 | Ratio: 14.00
# Arabic     | Chars:  50 | Tokens:  23 | Ratio: 2.88
# Code       | Chars:  70 | Tokens:  24 | Ratio: 3.43
                

Tokens Spéciaux

Les modèles utilisent des tokens spéciaux pour structurer les conversations et marquer les séquences :

Llama 2 Chat :
[INST] <>
You are a helpful assistant.
<>

What is the capital of France? [/INST] The capital of France is Paris.

GPT-4 (ChatML format) :
<|im_start|>system
You are a helpful assistant.
<|im_end|>
<|im_start|>user
What is the capital of France?
<|im_end|>
<|im_start|>assistant
The capital of France is Paris.
<|im_end|>

Mistral Instruct :
[INST] What is the capital of France? [/INST] The capital of France is Paris.
                

Entraîner son Propre Tokenizer

from tokenizers import Tokenizer, models, trainers, pre_tokenizers, processors

# Créer un tokenizer BPE from scratch
tokenizer = Tokenizer(models.BPE())

# Pré-tokenization (split sur espaces et ponctuation)
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)

# Entraîner
trainer = trainers.BpeTrainer(
    vocab_size=32000,
    min_frequency=2,
    special_tokens=["<|endoftext|>", "<|padding|>"],
    show_progress=True
)

files = ["data/my_corpus.txt"]
tokenizer.train(files, trainer)

# Post-processing (ajouter tokens spéciaux)
tokenizer.post_processor = processors.ByteLevel(trim_offsets=False)

# Sauvegarder
tokenizer.save("my_custom_tokenizer.json")

# Tester
text = "This is my domain-specific tokenizer!"
output = tokenizer.encode(text)
print(f"Tokens: {output.tokens}")
print(f"IDs: {output.ids}")
                

Points Clés

Scaling Laws & Chinchilla

Comment décider de la taille d'un modèle et du nombre de tokens d'entraînement ? Les scaling laws fournissent des équations prédictives pour optimiser cette décision.

Kaplan Scaling Laws (2020)

OpenAI a découvert que la perte (loss) d'un LLM suit une loi de puissance prévisible :

L(N, D) = (Nc / N)^αN + (Dc / D)^αD + E

Où :
- L : perte (loss) du modèle
- N : nombre de paramètres (non-embedding)
- D : nombre de tokens d'entraînement
- Nc, Dc, E : constantes empiriques
- αN ≈ 0.076, αD ≈ 0.095

Conclusion : Loss diminue avec N et D selon des power laws
                
Observation Clé de Kaplan Pour un budget compute fixe, il vaut mieux augmenter la taille du modèle N que le nombre de tokens D. Ratio optimal selon Kaplan : N^0.73 tokens par paramètre.

Exemple : Modèle 1B params → entraîner sur ~20B tokens

Chinchilla Optimal (2022)

DeepMind a révisé les Kaplan laws avec Chinchilla et découvert que les modèles étaient largement sous-entraînés !

Kaplan (2020) :           Chinchilla (2022) :

GPT-3: 175B params        Chinchilla: 70B params
       300B tokens               1.4T tokens

Même performance, 2.5× moins de paramètres !
                
Loi de Chinchilla Pour un compute optimal, le nombre de tokens D devrait être environ 20× le nombre de paramètres N.

Formule : D = 20 × N

Exemples :
- 7B params → 140B tokens
- 13B params → 260B tokens
- 70B params → 1.4T tokens
Modèle Paramètres Tokens Entraînement Ratio Optimal Chinchilla ?
GPT-3 175B 300B 1.7× ❌ Sous-entraîné
Chinchilla 70B 1.4T 20× ✅ Optimal
Llama 2 7B 7B 2T 286× ✅ Sur-entraîné (inference)
Llama 2 70B 70B 2T 28× ✅ Proche optimal
Mistral 7B 7B ≈2T 286× ✅ Sur-entraîné
Conseil du Mentor Chinchilla a bouleversé l'industrie ! Avant, on pensait "plus gros = meilleur". Maintenant, on sait qu'un Llama 7B entraîné sur 2T tokens bat un GPT-3 175B sous-entraîné. Pour vos projets : si budget limité pour l'inférence, sur-entraînez un petit modèle. Si budget limité pour l'entraînement, suivez Chinchilla optimal.

Calcul du Compute (FLOPs)

Le nombre de FLOPs (floating point operations) pour entraîner un modèle est approximativement :

FLOPs ≈ 6 × N × D

Où :
- N : nombre de paramètres
- D : nombre de tokens d'entraînement

Le facteur 6 vient de :
- 2 FLOPs pour forward pass (1 multiply-add = 2 FLOPs)
- 4 FLOPs pour backward pass (2× forward pour gradients)
                
def compute_flops(num_params, num_tokens):
    """
    Calcule les FLOPs nécessaires pour entraîner un modèle
    """
    flops = 6 * num_params * num_tokens
    return flops

def flops_to_gpu_days(flops, gpu_tflops):
    """
    Convertit FLOPs en jours-GPU

    Args:
        flops: nombre de FLOPs
        gpu_tflops: TFLOPs de la GPU (ex: A100 = 312 TFLOPs en FP16)
    """
    seconds = flops / (gpu_tflops * 1e12)
    days = seconds / 86400
    return days

# Exemple : Llama 2 7B
num_params = 7e9
num_tokens = 2e12

total_flops = compute_flops(num_params, num_tokens)
print(f"Total FLOPs: {total_flops:.2e}")
print(f"Total PFLOPs: {total_flops/1e15:.2f}")

# Sur A100 (312 TFLOPs en FP16)
gpu_days = flops_to_gpu_days(total_flops, 312)
print(f"GPU-days (A100): {gpu_days:.0f}")

# Sur H100 (989 TFLOPs en FP16)
gpu_days_h100 = flops_to_gpu_days(total_flops, 989)
print(f"GPU-days (H100): {gpu_days_h100:.0f}")

# Coût approximatif (A100 ≈ $3/heure sur cloud)
cost_usd = (gpu_days / 1) * 24 * 3
print(f"Coût estimé (A100 cloud): ${cost_usd:,.0f}")

# Résultats :
# Total FLOPs: 8.40e+22
# Total PFLOPs: 84.00
# GPU-days (A100): 3122
# GPU-days (H100): 985
# Coût estimé (A100 cloud): $224,784
                

Optimisation du Budget

Selon votre contrainte principale, voici comment choisir N et D :

Contrainte : INFERENCE COST (déploiement à grande échelle)
→ Minimiser N, maximiser D
→ Petit modèle sur-entraîné
→ Exemple : Llama 7B sur 2T tokens

Contrainte : TRAINING BUDGET (entraînement one-shot)
→ Suivre Chinchilla optimal (D = 20N)
→ Exemple : 70B sur 1.4T tokens

Contrainte : MEILLEURE QUALITÉ (recherche)
→ Maximiser N et D
→ Exemple : GPT-4 (rumeur: 1.7T params, beaucoup de tokens)
                
import numpy as np
import matplotlib.pyplot as plt

def chinchilla_optimal(compute_budget_flops):
    """
    Calcule N et D optimaux pour un budget compute donné
    selon la loi de Chinchilla
    """
    # D = 20 * N (Chinchilla)
    # FLOPs = 6 * N * D = 6 * N * 20N = 120N²
    # N = sqrt(FLOPs / 120)

    N = np.sqrt(compute_budget_flops / 120)
    D = 20 * N

    return N, D

# Exemple : Budget de 1e23 FLOPs (≈ Llama 2 70B)
budget = 1e23

N_opt, D_opt = chinchilla_optimal(budget)
print(f"Optimal selon Chinchilla :")
print(f"  Paramètres : {N_opt/1e9:.1f}B")
print(f"  Tokens : {D_opt/1e12:.2f}T")

# Comparer avec différentes stratégies
strategies = {
    "Kaplan (sous-entraîné)": (175e9, 300e9),
    "Chinchilla (optimal)": (N_opt, D_opt),
    "Llama 2 (sur-entraîné)": (70e9, 2e12),
}

for name, (N, D) in strategies.items():
    flops = 6 * N * D
    ratio = D / N
    print(f"\n{name}:")
    print(f"  N: {N/1e9:.1f}B, D: {D/1e12:.2f}T")
    print(f"  Ratio D/N: {ratio:.1f}×")
    print(f"  FLOPs: {flops:.2e}")

# Visualisation
plt.figure(figsize=(10, 6))
params = np.logspace(9, 12, 100)  # 1B to 1T

for budget_name, budget_flops in [("1e22 (7B optimal)", 1e22),
                                    ("1e23 (70B optimal)", 1e23)]:
    tokens = budget_flops / (6 * params)
    plt.loglog(params/1e9, tokens/1e12, label=budget_name)

# Chinchilla line
plt.loglog(params/1e9, 20*params/1e12, 'r--', label='Chinchilla (D=20N)', linewidth=2)

plt.xlabel('Paramètres (B)')
plt.ylabel('Tokens (T)')
plt.title('Scaling Laws: Paramètres vs Tokens')
plt.legend()
plt.grid(True, alpha=0.3)
plt.savefig('scaling_laws.png', dpi=300)
                

Implications Pratiques

Recommandations Selon le Use Case 1. Recherche / Benchmark
→ Suivre Chinchilla optimal (D = 20N)
→ Meilleur rapport qualité/compute

2. Production / API commerciale
→ Sur-entraîner des petits modèles (D = 200-300N)
→ Llama 7B style : moins cher à déployer

3. Fine-tuning / Domaine spécifique
→ Partir d'un modèle sur-entraîné (Llama, Mistral)
→ Fine-tune sur 10-100M tokens domaine

4. Budget ultra-limité
→ Utiliser des modèles pré-entraînés
→ Fine-tune avec LoRA/QLoRA

Au-delà de Chinchilla

Les recherches récentes (2023-2024) montrent que les lois de scaling continuent de tenir, mais avec des nuances :

Points Clés

Pre-Training Pipeline

L'entraînement d'un LLM nécessite des pétaoctets de données de qualité. Découvrons le pipeline complet.

Sources de Données

# Télécharger Common Crawl
import requests
from warcio.archiveiterator import ArchiveIterator

def download_common_crawl(warc_url):
    response = requests.get(warc_url, stream=True)
    for record in ArchiveIterator(response.raw):
        if record.rec_type == 'response':
            url = record.rec_headers.get_header('WARC-Target-URI')
            content = record.content_stream().read()
            yield url, content

# Exemple
warc_url = "https://data.commoncrawl.org/crawl-data/CC-MAIN-2024-10/segments/.../warc/..."
for url, html in download_common_crawl(warc_url):
    print(f"Downloaded: {url}")
                

Alignment: RLHF, DPO, Constitutional AI

Un LLM pré-entraîné est puissant mais pas aligné avec les intentions humaines. L'alignment le rend utile, sûr et contrôlable.

RLHF (Reinforcement Learning from Human Feedback)

Pipeline en 3 étapes utilisé par InstructGPT, ChatGPT, Claude :

  1. Supervised Fine-Tuning (SFT) : Entraîner sur des démonstrations humaines
  2. Reward Model (RM) : Entraîner un modèle de préférence sur des comparaisons
  3. RL with PPO : Optimiser la policy avec Proximal Policy Optimization
Méthode Complexité Performance Utilisé par
RLHF Élevée (3 étapes) Excellent ChatGPT, Claude
DPO Moyenne (2 étapes) Très bon Llama 2, Mistral
Constitutional AI Moyenne Bon + Safe Claude 2/3

Quiz Module 3.1 : Architecture des LLM

Testez vos connaissances sur l'architecture profonde des LLM !

Question 1 : Self-Attention

Pourquoi utilise-t-on le facteur d'échelle √d_k dans la formule de l'attention ?

Question 2 : RoPE

Quel est l'avantage principal de RoPE vs sinusoidal encoding ?

Question 3 : SwiGLU

Pourquoi Llama utilise SwiGLU au lieu de ReLU ?

Question 4 : Tokenization

Quelle est la taille de vocabulaire typique pour un LLM moderne ?

Question 5 : Chinchilla Law

Selon Chinchilla, combien de tokens doit-on utiliser pour entraîner un modèle de 10B paramètres de manière optimale ?

Question 6 : Pre-Norm vs Post-Norm

Quel est l'avantage de Pre-Norm (utilisé par GPT, Llama) ?

Question 7 : Multi-Head Attention

Pourquoi utiliser plusieurs têtes d'attention au lieu d'une seule ?

Question 8 : DPO vs RLHF

Quel est l'avantage principal de DPO ?

Principes Fondamentaux du Prompt Engineering

Le prompt engineering est l'art de communiquer efficacement avec les LLM. Un bon prompt peut faire la différence entre une réponse médiocre et excellente.

Les 5 C du Prompt Engineering

  1. Clarity (Clarté) : Instructions précises et non ambiguës
  2. Context (Contexte) : Fournir le background nécessaire
  3. Constraints (Contraintes) : Limites, format, longueur
  4. Cues (Indices) : Exemples, patterns à suivre
  5. Check (Vérification) : Demander validation ou réflexion

Framework CRISPE

C - Capacity and Role : "Agis en tant qu'expert Python..."
R - Insight : "Je veux créer une API REST..."
I - Statement : "Génère le code avec FastAPI..."
S - Personality : "Utilise un style professionnel..."
P - Parameters : "Format JSON, 50 lignes max..."
E - Experiment : "Propose 2 alternatives..."
                
# Exemple de prompt CRISPE complet
prompt = """
Agis en tant qu'expert en architecture logicielle avec 15 ans d'expérience (Capacity).

Je développe une plateforme e-commerce qui doit gérer 10000 requêtes/seconde
avec une latence <100ms. J'hésite entre architecture microservices et monolithe (Insight).

Analyse ces deux approches et recommande la meilleure solution pour mon cas (Statement).

Sois pragmatique et concis, avec des arguments chiffrés (Personality).

Format :
- Avantages/Inconvénients de chaque approche (tableau)
- Recommandation finale justifiée
- Maximum 300 mots (Parameters)

Si pertinent, propose une architecture hybride (Experiment).
"""
                

10 Patterns de Prompting

Pattern Usage Exemple
Persona Définir un rôle "Agis comme un professeur de physique..."
Chain-of-Thought Raisonnement "Explique ton raisonnement étape par étape"
Few-Shot Exemples Donner 2-3 exemples input/output
Template Structure fixe "Réponds toujours: Contexte | Analyse | Recommandation"
Constraint Limites claires "Max 50 mots, sans jargon technique"

Examen Final - Phase 3

Cet examen évalue vos connaissances complètes sur les LLM, leur architecture, et leur utilisation pratique.

Question 1 : Architecture Complète

Décrivez les composants principaux d'un bloc Transformer et leur rôle.

Question 2 : Projet Pratique

Vous devez déployer un chatbot pour le support client. Quelle approche choisir ?

Projet Final

Créez une application complète qui :

Critères d'Évaluation - Architecture technique (30%)
- Qualité du code (25%)
- Prompt engineering (20%)
- Performance et optimisation (15%)
- Documentation (10%)

Chain-of-Thought & Reasoning

Le Chain-of-Thought (CoT) améliore drastiquement les capacités de raisonnement des LLM en les forçant à décomposer leur réflexion.

Zero-Shot CoT

Simplement ajouter "Let's think step by step" améliore les performances de 10-30% sur des tâches de raisonnement !

Few-Shot & In-Context Learning

Les LLM peuvent apprendre de nouveaux patterns en voyant seulement quelques exemples dans le prompt.

Structured Output & JSON Mode

Forcer un LLM à produire du JSON valide est essentiel pour l'intégration dans des applications.

System Prompts Architecturaux

Le system prompt définit la personnalité, les règles et les capacités de l'assistant.

Prompt Injection & Sécurité

Les attaques par injection de prompt sont un risque majeur pour les applications LLM en production.

Attention : Sécurité Critique Ne JAMAIS faire confiance aux inputs utilisateurs dans un système LLM sans validation rigoureuse.

Évaluation de Prompts

Comment mesurer objectivement la qualité d'un prompt ? Plusieurs techniques existent.

Quiz Module 3.2 : Prompt Engineering

Testez vos compétences en prompt engineering !

Chatbots & Assistants

Construire un chatbot performant nécessite une gestion avancée du contexte et de la mémoire.

Summarization & Extraction

Les LLM excellent dans l'extraction d'informations et la synthèse de documents.

Génération de Code

Les LLM révolutionnent le développement : completion, refactoring, tests, review.

Traduction & Multilingue

Les LLM modernes sont des traducteurs exceptionnels grâce à leur entraînement multilingue.

LLM pour la Data

Text-to-SQL et analyse de données en langage naturel démocratisent l'accès aux données.

Function Calling

Function calling permet aux LLM d'interagir avec des outils externes : APIs, bases de données, calculatrices.

Lab: Application Complète

Créons ensemble une application full-stack avec LLM, RAG, API et interface utilisateur.

Architecture du Lab

┌─────────────┐
│   Gradio    │ ← Interface utilisateur
│   Frontend  │
└──────┬──────┘
       │
┌──────▼──────┐
│   FastAPI   │ ← API REST
│   Backend   │
└──────┬──────┘
       │
   ┌───┴───┬─────────┬─────────┐
   │       │         │         │
┌──▼──┐ ┌─▼───┐ ┌──▼────┐ ┌──▼──┐
│ LLM │ │ RAG │ │SQLite│ │Cache│
└─────┘ └─────┘ └──────┘ └─────┘