Module 01 · Fondamentaux

Du Perceptron au Transformer :
Le substrat des LLM modernes

Ce chapitre explique « comment l'IA fonctionne réellement » avec le minimum de mathématiques avancées. Que vous soyez du produit, de l'ingénierie ou de la vente, après avoir parcouru ce chapitre vous serez capable de suivre toute discussion ultérieure sur les modèles et les frameworks.

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

💡 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 / LSTMTransformer
Dépendances longue distanceTendance à oublier le contexte ancienConnexion directe entre deux positions quelconques
Calcul parallèleStrictement séquentielTraite toutes les positions à la fois
Vitesse d'entraînementLenteRapide (pleine utilisation des GPU)
ExtensibilitéDifficile au-delà de 10B paramètresSupport 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 » :

  1. Chaque mot produit trois vecteurs : Query (Q), Key (K), Value (V).
  2. Pour « il », on calcule le produit scalaire de son Q avec le K de chaque autre mot pour obtenir un score de pertinence.
  3. On applique un Softmax pour obtenir les poids d'attention (p. ex., 0,8 pour « Xiaoming », 0,1 pour « Pékin »).
  4. 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

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 :

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

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 :

Rien de tout cela ne tient dans un seul GPU (80GB), nous avons donc besoin de plusieurs stratégies parallèles :

ParallélismeIdéeFramework représentatif
Données parallèleRépartit les données entre les GPU ; chacun exécute le modèle completPyTorch DDP
Modèle parallèlePlace différentes couches sur différents GPUMegatron-LM
Pipeline parallèleDécoupe le modèle en étapes ; chaque GPU traite un micro-batch différentGPipe, PipeDream
Tensor parallèleRépartit une seule opération matricielle entre les GPUMegatron-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 :

7. Résumé du chapitre

📚 Prochain chapitre : Évolution de l'Agent — Du LLM à l'Agentic Agent

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

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

8.7 Et après ?

📚 Précédent : 7. Résumé du chapitre · Prochain chapitre : Évolution de l'Agent — Du LLM à l'Agentic Agent