1. Réseaux de neurones artificiels : du neurone biologique à la fonction mathématique
1.1 L'unité minimale d'un réseau de neurones
Un réseau de neurones artificiels s'inspire du fonctionnement des neurones biologiques. Un neurone unique est, en essence, une fonction mathématique :
output = activation(w · x + b) Ici x est le vecteur d'entrée, w est le poids, b est le biais, et activation est une fonction non linéaire (couramment Sigmoid, ReLU ou GELU). Empilez des milliers de ces « neurones » en couches et reliez-les et vous obtenez la base du deep learning moderne.
1.2 Les trois éléments de l'entraînement
- Propagation avant (Forward propagation) : les données circulent de la couche d'entrée à la couche de sortie, produisant une prédiction.
- Fonction de perte (Loss function) : mesure l'écart entre la prédiction et la vérité de référence. La classification utilise l'entropie croisée ; la régression utilise l'erreur quadratique moyenne.
- Rétropropagation + descente de gradient (Backpropagation + gradient descent) : à partir de la sortie, on calcule la « contribution » (gradient) de chaque paramètre à la perte, puis on pousse le poids dans la direction opposée pour rendre la prochaine prédiction plus précise.
💡 Intuition clé : entraîner un réseau de neurones consiste essentiellement à « chercher dans un espace de paramètres à des milliards de dimensions une configuration de poids qui minimise la perte ». C'est pourquoi les LLM ont besoin d'énormes quantités de données et de calcul — l'espace des paramètres est immense.
2. Transformer : la pierre angulaire des LLM modernes
En 2017, Google a publié Attention Is All You Need, introduisant l'architecture Transformer et changeant la trajectoire de l'IA. Son innovation centrale : éliminer complètement la récurrence et s'appuyer purement sur le mécanisme d'attention pour traiter les séquences.
2.1 Pourquoi le Transformer a balayé le NLP
| Propriété | RNN / LSTM | Transformer |
|---|---|---|
| Dépendances longue distance | Tendance à oublier le contexte ancien | Connexion directe entre deux positions quelconques |
| Calcul parallèle | Strictement séquentiel | Traite toutes les positions à la fois |
| Vitesse d'entraînement | Lente | Rapide (pleine utilisation des GPU) |
| Extensibilité | Difficile au-delà de 10B paramètres | Support natif pour 100B+ paramètres |
2.2 Self-attention
L'idée centrale : pour chaque mot d'une séquence, « regarde » chaque autre mot et décide combien chacun compte pour lui.
Prenons cette phrase d'exemple — « Xiaoming est allé hier à Pékin en voyage d'affaires, et il est revenu aujourd'hui » :
- Chaque mot produit trois vecteurs : Query (Q), Key (K), Value (V).
- Pour « il », on calcule le produit scalaire de son Q avec le K de chaque autre mot pour obtenir un score de pertinence.
- On applique un Softmax pour obtenir les poids d'attention (p. ex., 0,8 pour « Xiaoming », 0,1 pour « Pékin »).
- On utilise ces poids pour faire une somme pondérée du V de chaque mot, produisant une représentation nouvelle, riche en contexte, de « il ».
Sous forme mathématique :
Attention(Q, K, V) = softmax(Q · K^T / √d_k) · V où d_k est la dimensionnalité du vecteur Key ; diviser par sa racine carrée empêche le produit scalaire de faire exploser le gradient du Softmax.
2.3 Attention multi-têtes (Multi-head attention)
L'attention multi-têtes est une self-attention dopée. Elle exécute en parallèle plusieurs opérations de self-attention sur différents « sous-espaces de représentation » et concatène les résultats. Cela permet au modèle de capturer simultanément plusieurs types de relations :
- Une tête peut se concentrer sur les relations syntaxiques (sujet-verbe-objet).
- Une autre sur les relations sémantiques (synonymes, antonymes).
- Une troisième sur la coréférence longue distance (à quel nom un pronom se réfère).
2.4 Encodage positionnel (Positional encoding)
Le Transformer traite chaque mot en parallèle, il perd donc par défaut l'information d'« ordre ». « Le chien mord l'homme » et « L'homme mord le chien » ont des tokens identiques — seul l'ordre diffère. L'encodage positionnel ajoute un « vecteur de position » unique à l'embedding de chaque mot pour que le modèle sache où il se trouve dans la séquence.
Le Transformer original utilisait des encodages positionnels sinusoïdaux/cosinus fixes. Les modèles ultérieurs (LLaMA, Qwen) utilisent RoPE (Rotary Position Embeddings), qui gère des séquences beaucoup plus longues.
3. L'architecture complète du Transformer
Un bloc Transformer standard comporte deux moitiés :
input → [LayerNorm → Multi-Head Attention → Residual]
→ [LayerNorm → Feed-Forward Network (FFN) → Residual]
→ output - Multi-Head Attention : trouve les « relations » — permet à chaque mot de voir chaque autre mot.
- Feed-Forward Network (FFN) : fait le « traitement » — applique une transformation non linéaire à chaque mot individuellement pour extraire des caractéristiques de plus haut niveau.
- Connexion résiduelle : ajoute l'entrée directement à la sortie, évitant les gradients qui s'évanouissent et permettant des réseaux profonds.
- LayerNorm : stabilise l'entraînement et prévient les explosions numériques.
La famille GPT (ChatGPT, DeepSeek, Qwen) n'est tout simplement que des dizaines à des centaines de tels blocs empilés en un Transformer Decoder-only.
4. Pré-entraînement, SFT, RLHF : la recette d'entraînement en trois étapes
4.1 Pré-entraînement
On entraîne sur un vaste corpus de texte d'internet (des billions de tokens) pour que le modèle apprenne à « prédire le token suivant ». C'est l'étape la plus longue et la plus coûteuse — DeepSeek-V3 a utilisé 14,8T tokens de données d'entraînement et a coûté environ 5,57M USD (location de GPU).
4.2 Réglage fin supervisé (SFT)
On entraîne sur un petit ensemble de paires « instruction-réponse » curées par des humains et de haute qualité (des dizaines de milliers) pour que le modèle apprenne à « comprendre la demande et répondre en conséquence ». Le volume est bien inférieur à celui du pré-entraînement, mais les exigences de qualité sont extrêmes.
4.3 Apprentissage par renforcement à partir de feedback humain (RLHF)
Les humains classent les différentes réponses du modèle ; on entraîne un modèle de récompense sur ces classements, puis on utilise le RL (typiquement PPO ou DPO) pour pousser le modèle vers des réponses mieux classées.
5. Pourquoi avons-nous besoin de clusters de GPU ?
Entraîner un modèle à 100B paramètres — un seul passage forward + backward requiert :
- Embeddings des tokens d'entrée (params × longueur de séquence)
- Matrices d'attention (longueur de séquence² × nombre de têtes)
- Gradients (params)
- État de l'optimiseur (momentum, variance — 3-4× params)
Rien de tout cela ne tient dans un seul GPU (80GB), nous avons donc besoin de plusieurs stratégies parallèles :
| Parallélisme | Idée | Framework représentatif |
|---|---|---|
| Données parallèle | Répartit les données entre les GPU ; chacun exécute le modèle complet | PyTorch DDP |
| Modèle parallèle | Place différentes couches sur différents GPU | Megatron-LM |
| Pipeline parallèle | Découpe le modèle en étapes ; chaque GPU traite un micro-batch différent | GPipe, PipeDream |
| Tensor parallèle | Répartit une seule opération matricielle entre les GPU | Megatron-LM |
6. Des LLM aux Agents : le saut en capacité
Une fois que les LLM sont devenus suffisamment puissants, les gens ont commencé à ajouter des « extras » : le modèle peut non seulement « parler » mais aussi « faire ». C'est le point de départ de l'AI Agent :
- ReAct (raisonner + agir) : le modèle réfléchit d'abord à ce qu'il faut faire, puis décide quel outil appeler.
- Function calling : le modèle émet des « requêtes d'appel d'outil » structurées exécutées par un système externe.
- Collaboration multi-Agent : plusieurs Agents jouent différents rôles et se coordonnent pour accomplir une tâche.
7. Résumé du chapitre
- ✅ Le Transformer a remplacé la récurrence par le mécanisme d'attention, permettant un entraînement parallèle et la modélisation longue distance.
- ✅ La self-attention permet à chaque mot de « voir » chaque autre ; l'attention multi-têtes capture plusieurs relations à la fois.
- ✅ Pré-entraînement + SFT + RLHF est la recette en trois étapes pour entraîner un LLM.
- ✅ Les modèles à 100B paramètres exigent des clusters de GPU et une combinaison de stratégies parallèles.
8. Pratique : construire un Mini Transformer à partir de zéro
Maintenant que la théorie est couverte, cette section vous guide dans la construction d'un Transformer Decoder-only à 6 couches en PyTorch à partir de zéro, entraîné sur un petit corpus de texte. Le code complet fait environ 250 lignes et tourne sur CPU ou GPU — l'objectif est de rendre visible chaque rouage du Transformer.
8.1 Objectifs et environnement
- Implémenter un Transformer Decoder-only à 6 couches avec ~1,2M paramètres.
- Entraîner un modèle de langage au niveau du caractère sur un petit corpus de ~500 lignes et observer la perte chuter.
- Fournir deux scripts exécutables :
train.pyetsample.py.
Configuration de l'environnement (2 lignes) :
pip install torch numpy
python -c "import torch; print('torch', torch.__version__)" 8.2 Préparation des données
Nous utiliserons un petit fragment de données pseudo style poésie Tang (vous pouvez le remplacer par votre propre input.txt) :
cat > data/input.txt << 'EOF'
Quiet night thoughts
Before my bed a pool of light,
Can it be frost upon the ground?
Eyes up, I find the moon so bright,
Head down, I think of home I've found.
Spring dawn
Spring slumber knows no dawn so soon,
The birds' song fills the air with tune.
Last night the wind and rain did blow,
How many flowers have let go?
EOF
wc -c data/input.txt Un modèle au niveau du caractère se contente de lire le fichier entier comme une longue chaîne — pas besoin de tokenizer.
8.3 Code complet : train.py
"""Mini Transformer : Decoder-only à 6 couches, modèle de langage au niveau du caractère.
Usage :
python train.py
"""
import math
import time
from pathlib import Path
import torch
import torch.nn as nn
import torch.nn.functional as F
# ---------- 1. Data loader ----------
class CharDataset:
"""Découpe un fichier texte en échantillons d'entraînement de longueur (block_size)."""
def __init__(self, path: str, block_size: int = 64):
self.text = Path(path).read_text(encoding="utf-8")
self.chars = sorted(set(self.text))
self.vocab_size = len(self.chars)
self.stoi = {c: i for i, c in enumerate(self.chars)}
self.itos = {i: c for i, c in enumerate(self.chars)}
self.block_size = block_size
self.data = torch.tensor([self.stoi[c] for c in self.text], dtype=torch.long)
print(f"[data] chars={self.vocab_size} len={len(self.data)}")
def get_batch(self, batch_size: int = 32):
ix = torch.randint(0, len(self.data) - self.block_size - 1, (batch_size,))
x = torch.stack([self.data[i : i + self.block_size] for i in ix])
y = torch.stack([self.data[i + 1 : i + 1 + self.block_size] for i in ix])
return x, y
# ---------- 2. Composants du modèle ----------
class PositionalEncoding(nn.Module):
"""Encodage positionnel sinusoïdal, figé."""
def __init__(self, d_model: int, max_len: int = 512):
super().__init__()
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len).unsqueeze(1).float()
div_term = torch.exp(torch.arange(0, d_model, 2).float() * -(math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
self.register_buffer("pe", pe.unsqueeze(0)) # (1, max_len, d_model)
def forward(self, x):
return x + self.pe[:, : x.size(1)]
class CausalSelfAttention(nn.Module):
"""Self-attention causale multi-têtes (utilisée dans le Decoder)."""
def __init__(self, d_model: int, n_heads: int, max_len: int = 512, dropout: float = 0.1):
super().__init__()
assert d_model % n_heads == 0
self.n_heads = n_heads
self.head_dim = d_model // n_heads
self.qkv = nn.Linear(d_model, 3 * d_model)
self.proj = nn.Linear(d_model, d_model)
self.dropout = nn.Dropout(dropout)
# masque causal : triangle supérieur (hors diagonale) est -inf
mask = torch.triu(torch.ones(max_len, max_len), diagonal=1).bool()
self.register_buffer("mask", mask)
def forward(self, x):
B, T, C = x.shape
qkv = self.qkv(x).reshape(B, T, 3, self.n_heads, self.head_dim).permute(2, 0, 3, 1, 4)
q, k, v = qkv[0], qkv[1], qkv[2] # (B, h, T, head_dim)
att = (q @ k.transpose(-2, -1)) / math.sqrt(self.head_dim)
att = att.masked_fill(self.mask[:T, :T], float("-inf"))
att = F.softmax(att, dim=-1)
att = self.dropout(att)
out = (att @ v).transpose(1, 2).reshape(B, T, C)
return self.proj(out)
class FeedForward(nn.Module):
"""FFN du Transformer : GELU + Linear + Dropout."""
def __init__(self, d_model: int, d_ff: int, dropout: float = 0.1):
super().__init__()
self.net = nn.Sequential(
nn.Linear(d_model, d_ff),
nn.GELU(),
nn.Linear(d_ff, d_model),
nn.Dropout(dropout),
)
def forward(self, x):
return self.net(x)
class Block(nn.Module):
"""Bloc Decoder Pre-LN : Attention -> FFN, chacun avec résiduel."""
def __init__(self, d_model: int, n_heads: int, d_ff: int, max_len: int, dropout: float):
super().__init__()
self.ln1 = nn.LayerNorm(d_model)
self.attn = CausalSelfAttention(d_model, n_heads, max_len, dropout)
self.ln2 = nn.LayerNorm(d_model)
self.ffn = FeedForward(d_model, d_ff, dropout)
def forward(self, x):
x = x + self.attn(self.ln1(x))
x = x + self.ffn(self.ln2(x))
return x
class MiniTransformer(nn.Module):
"""Transformer Decoder-only à 6 couches, modèle de langage au niveau du caractère."""
def __init__(
self,
vocab_size: int,
d_model: int = 128,
n_heads: int = 4,
d_ff: int = 512,
n_layers: int = 6,
max_len: int = 256,
dropout: float = 0.1,
):
super().__init__()
self.tok_emb = nn.Embedding(vocab_size, d_model)
self.pos_enc = PositionalEncoding(d_model, max_len)
self.blocks = nn.ModuleList(
[Block(d_model, n_heads, d_ff, max_len, dropout) for _ in range(n_layers)]
)
self.ln_f = nn.LayerNorm(d_model)
self.head = nn.Linear(d_model, vocab_size, bias=False)
# Weight tying : l'embedding d'entrée partage les poids avec la tête de sortie
self.head.weight = self.tok_emb.weight
self.apply(self._init_weights)
def _init_weights(self, m):
if isinstance(m, nn.Linear):
nn.init.normal_(m.weight, std=0.02)
if m.bias is not None:
nn.init.zeros_(m.bias)
elif isinstance(m, nn.Embedding):
nn.init.normal_(m.weight, std=0.02)
def forward(self, idx, targets=None):
x = self.tok_emb(idx)
x = self.pos_enc(x)
for blk in self.blocks:
x = blk(x)
x = self.ln_f(x)
logits = self.head(x)
loss = None
if targets is not None:
loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1))
return logits, loss
@torch.no_grad()
def generate(self, idx, max_new_tokens: int = 100, temperature: float = 1.0, top_k: int = 20):
"""Génération autoregressive avec température + échantillonnage top-k."""
for _ in range(max_new_tokens):
idx_cond = idx[:, -self.pos_enc.pe.size(1) :]
logits, _ = self(idx_cond)
logits = logits[:, -1, :] / temperature
if top_k:
v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
logits[logits < v[:, [-1]]] = float("-inf")
probs = F.softmax(logits, dim=-1)
nxt = torch.multinomial(probs, num_samples=1)
idx = torch.cat([idx, nxt], dim=1)
return idx
# ---------- 3. Boucle d'entraînement ----------
def main():
torch.manual_seed(0)
ds = CharDataset("data/input.txt", block_size=64)
device = "cuda" if torch.cuda.is_available() else "cpu"
model = MiniTransformer(vocab_size=ds.vocab_size).to(device)
print(f"[model] params={sum(p.numel() for p in model.parameters()) / 1e6:.2f}M device={device}")
opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.1)
steps = 1000
batch_size = 32
t0 = time.time()
for step in range(steps):
x, y = ds.get_batch(batch_size)
x, y = x.to(device), y.to(device)
_, loss = model(x, y)
opt.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
if step % 100 == 0 or step == steps - 1:
dt = time.time() - t0
print(f"step {step:4d} loss={loss.item():.4f} elapsed={dt:.1f}s")
torch.save({"model": model.state_dict(), "stoi": ds.stoi, "itos": ds.itos}, "ckpt.pt")
print("[done] saved ckpt.pt")
if __name__ == "__main__":
main() 8.4 Script d'inférence : sample.py
"""Génère du texte à partir d'un ckpt.pt entraîné."""
import torch
from train import MiniTransformer
ckpt = torch.load("ckpt.pt", map_location="cpu")
stoi, itos = ckpt["stoi"], ckpt["itos"]
model = MiniTransformer(vocab_size=len(itos))
model.load_state_dict(ckpt["model"])
model.eval()
prompt = "Quiet night thoughts\n"
idx = torch.tensor([[stoi[c] for c in prompt]], dtype=torch.long)
out = model.generate(idx, max_new_tokens=120, temperature=0.8, top_k=10)[0].tolist()
print("=== generated ===")
print("".join(itos[i] for i in out)) 8.5 Exécutez-le et sortie attendue
mkdir -p data
# (écrivez l'input.txt de la section 8.2)
python train.py Sortie attendue (CPU ~30-60s, MPS/GPU quelques secondes) :
[data] chars=28 len=87
[model] params=1.21M device=cpu
step 0 loss=3.3321 elapsed=0.0s
step 100 loss=2.7102 elapsed=2.1s
step 200 loss=2.3504 elapsed=4.0s
...
step 900 loss=1.8421 elapsed=32.5s
step 999 loss=1.7903 elapsed=36.0s
[done] saved ckpt.pt
python sample.py
=== generated ===
Quiet night thoughts
Before my bed a pool of light,
Can it be frost upon the ground?
Eyes up, I find the moon so bright,
Head down, I think of home I've found. 8.6 Conseils de réglage
- La perte ne descend pas : données trop courtes → utilisez un
input.txtplus long (p. ex. un corpus complet de poésie Tang) ; ajustez LR à 1e-4 / 5e-4. - La génération s'effondre sur un caractère : température trop basse — poussez à 0,8-1,2 ; montez top_k à 20-50.
- Mémoire insuffisante : baissez
batch_sizeà 8, ou exécutez en demi-précision avectorch.amp.autocast. - Besoin de plus de vitesse : mettez
d_model=96,n_layers=4pour descendre à ~400K paramètres.
8.7 Et après ?
- Un GPT complet : ajoutez l'échantillonnage
top-p (nucleus), KV-cache pour une inférence plus rapide. - Architecture LLaMA : remplacez LayerNorm par
RMSNorm, PE sinusoïdal parRoPE, FFN GELU parSwiGLU, et ajoutezGQApour réduire la mémoire. - Entraînement multi-GPU :
torchrun --nproc_per_node=2 train.pyavecDistributedDataParallel. - Utilisez un vrai tokenizer : remplacez
CharDatasetpartiktoken(GPT-2 BPE) ousentencepiece.