Inférence et génération#

Les chapitres précédents ont présenté l’architecture Transformer et le mécanisme de tokenisation qui transforme le texte brut en séquences d’identifiants numériques. Mais comment un LLM produit-il effectivement du texte ? La réponse tient en un mécanisme étonnamment simple dans son principe : le modèle prédit le prochain token en fonction de tous les tokens précédents, puis répète cette opération indéfiniment. Ce processus, appelé décodage autorégressif, est le moteur fondamental de toute génération de texte par un LLM.

Pourtant, la simplicité du principe masque une richesse de choix algorithmiques. À chaque pas de génération, le modèle produit une distribution de probabilité sur l’ensemble du vocabulaire — typiquement des dizaines de milliers de tokens. Comment choisir le prochain token dans cette distribution ? Faut-il toujours sélectionner le plus probable, ou introduire de l’aléatoire ? Comment contrôler le degré de « créativité » du modèle ? Ces questions ont donné naissance à une famille de stratégies de décodage — température, top-\(k\), nucleus sampling, beam search — dont le choix influence profondément la qualité, la diversité et la cohérence du texte généré.

Ce chapitre explore l’ensemble du pipeline de génération. Nous commencerons par formaliser le décodage autorégressif, puis examinerons les optimisations qui rendent l’inférence praticable à grande échelle (KV-cache), avant de détailler chaque stratégie de décodage avec des implémentations exécutables. Nous utiliserons GPT-2 (124M paramètres, ~500 Mo) comme modèle de démonstration, suffisamment léger pour s’exécuter sur une machine avec 8 Go de RAM.

Hide code cell source

# Import des librairies Python
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
import torch
import torch.nn.functional as F
from transformers import GPT2LMHeadModel, GPT2Tokenizer
from tqdm.notebook import trange, tqdm

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
torch.manual_seed(42)
np.random.seed(42)

Hide code cell source

# Chargement de GPT-2 small (124M paramètres, ~500 Mo)
tokenizer = GPT2Tokenizer.from_pretrained("gpt2")
model = GPT2LMHeadModel.from_pretrained("gpt2")
model.eval()

print(f"Paramètres : {sum(p.numel() for p in model.parameters()) / 1e6:.0f}M")
print(f"Vocabulaire : {tokenizer.vocab_size} tokens")
Paramètres : 124M
Vocabulaire : 50257 tokens

Décodage autorégressif#

Le décodage autorégressif est le mécanisme par lequel un LLM génère du texte token par token. À chaque étape, le modèle prend en entrée la séquence de tokens produite jusqu’à présent et calcule une distribution de probabilité sur le token suivant.

Définition 14 (Décodage autorégressif)

Un modèle de langage autorégressif factorise la probabilité d’une séquence \(\mathbf{x} = (x_1, x_2, \ldots, x_T)\) comme un produit de probabilités conditionnelles :

\[P(\mathbf{x}) = \prod_{t=1}^{T} P(x_t \mid x_1, x_2, \ldots, x_{t-1}) = \prod_{t=1}^{T} P(x_t \mid x_{<t})\]

À chaque pas \(t\), le modèle calcule un vecteur de logits \(\mathbf{z}_t \in \mathbb{R}^{|V|}\) (un score par token du vocabulaire \(V\)), puis convertit ces logits en probabilités via la fonction softmax :

\[P(x_t = v \mid x_{<t}) = \frac{\exp(z_{t,v})}{\sum_{v'=1}^{|V|} \exp(z_{t,v'})}\]

La boucle de génération procède ainsi :

  1. Encoder le prompt initial \((x_1, \ldots, x_n)\).

  2. Calculer les logits \(\mathbf{z}_{n+1}\).

  3. Sélectionner le token \(x_{n+1}\) selon une stratégie de décodage.

  4. Ajouter \(x_{n+1}\) à la séquence et répéter depuis l’étape 2.

  5. S’arrêter lorsqu’un token de fin (<eos>) est généré ou qu’une longueur maximale est atteinte.

La factorisation autorégressive est une conséquence directe de la règle de la chaîne en théorie des probabilités. Elle ne fait aucune hypothèse d’indépendance : chaque token dépend de tous les tokens précédents. C’est le masque causal dans l’attention du Transformer qui garantit cette propriété — le token à la position \(t\) ne peut « voir » que les positions \(1, \ldots, t-1\).

Hide code cell source

# Démonstration : distribution sur le prochain token
prompt = "The future of artificial intelligence"
input_ids = tokenizer.encode(prompt, return_tensors="pt")
with torch.no_grad():
    logits = model(input_ids).logits[0, -1, :]
    probs = F.softmax(logits, dim=-1)

top20_probs, top20_indices = torch.topk(probs, 20)
top20_tokens = [tokenizer.decode(idx.item()) for idx in top20_indices]

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)
fig, ax = plt.subplots(figsize=(12, 5))
ax.barh(range(19, -1, -1), top20_probs.numpy(), color=sns.color_palette("muted", 20))
ax.set_yticks(range(19, -1, -1))
ax.set_yticklabels([repr(t) for t in top20_tokens])
ax.set_xlabel("Probabilité")
ax.set_title(f"Top-20 tokens après « {prompt} »")
for i, (p, t) in enumerate(zip(top20_probs.numpy(), top20_tokens)):
    ax.text(p + 0.002, 19 - i, f"{p:.3f}", va='center', fontsize=8)
plt.show()
_images/10f90b3cb514831b6d64782c218fa23db3afc4346c4898598cfa10a051134128.png

Remarque 12

La stratégie la plus simple est le décodage glouton (greedy decoding) : à chaque pas, on sélectionne le token de probabilité maximale \(x_t = \arg\max_v P(v \mid x_{<t})\). Cette approche est déterministe et rapide, mais elle produit souvent des textes répétitifs et peu naturels. Le décodage glouton ne garantit pas non plus de trouver la séquence globalement la plus probable, car il optimise localement à chaque pas sans considérer les conséquences à long terme.

KV-cache et optimisation de l’inférence#

Définition 15 (KV-cache)

Le KV-cache (Key-Value cache) est une technique d’optimisation qui évite de recalculer les matrices de clés (\(K\)) et de valeurs (\(V\)) pour les tokens déjà traités lors de la génération autorégressive. À chaque nouveau pas de génération :

  1. On calcule \(Q_t\), \(K_t\), \(V_t\) uniquement pour le nouveau token \(x_t\).

  2. On concatène \(K_t\) et \(V_t\) aux matrices \(K_{<t}\) et \(V_{<t}\) stockées en mémoire.

  3. On calcule l’attention entre \(Q_t\) (un seul vecteur) et le cache \([K_{<t}; K_t]\), \([V_{<t}; V_t]\) complet.

Sans KV-cache, chaque pas de génération nécessite de recalculer l’attention sur toute la séquence, soit une complexité de \(O(t \cdot d)\) par pas et \(O(T^2 \cdot d)\) au total pour \(T\) tokens. Avec le KV-cache, chaque pas coûte \(O(t \cdot d)\) mais on ne recalcule que pour le dernier token, réduisant le coût total à \(O(T \cdot d)\) au prix d’un stockage mémoire supplémentaire.

L’inférence autorégressive naïve — sans KV-cache — est extrêmement inefficace. À chaque pas \(t\), le Transformer doit réaliser une passe complète sur les \(t\) tokens de la séquence, recalculant les projections QKV et les produits d’attention pour tous les tokens, y compris ceux déjà traités aux pas précédents. Le coût total pour générer \(T\) tokens est donc \(O(T^2)\) en nombre d’opérations d’attention.

Remarque 13

La taille mémoire du KV-cache croît linéairement avec la longueur de séquence. Pour un modèle à \(L\) couches, \(H\) têtes d’attention et une dimension par tête \(d_h\), le cache stocke deux tenseurs (K et V) par couche et par tête :

\[\text{Mémoire KV-cache} = 2 \times L \times H \times d_h \times T \times \text{sizeof(dtype)}\]

Pour GPT-2 small (\(L=12\), \(H=12\), \(d_h=64\), dtype=float32) avec \(T = 1024\) tokens :

\[2 \times 12 \times 12 \times 64 \times 1024 \times 4 \approx 75~\text{Mo}\]

Pour des modèles plus grands (70B+ paramètres) avec des contextes longs (128k tokens), le KV-cache peut occuper plusieurs dizaines de Go, devenant le principal goulot d’étranglement mémoire de l’inférence.

Hide code cell source

# Schéma du KV-cache : matrices clé/valeur croissantes
fig, axes = plt.subplots(2, 2, figsize=(10, 9))
steps = [1, 3, 5, 8]
ck, cv = sns.color_palette("Blues", 10), sns.color_palette("Oranges", 10)

for ax_idx, t in enumerate(steps):
    ax = axes[ax_idx // 2, ax_idx % 2]
    d = 4
    for row in range(t):
        for col in range(d):
            ax.add_patch(plt.Rectangle((col * 0.5, (t-1-row) * 0.5 + 2.5),
                         0.45, 0.45, color=ck[min(row+2, 9)], ec='white', lw=0.5))
            ax.add_patch(plt.Rectangle((col * 0.5 + 2.5, (t-1-row) * 0.5 + 2.5),
                         0.45, 0.45, color=cv[min(row+2, 9)], ec='white', lw=0.5))
    ax.text(1.0, t*0.5+2.8, "K", ha='center', fontsize=10, weight='bold', color='steelblue')
    ax.text(3.5, t*0.5+2.8, "V", ha='center', fontsize=10, weight='bold', color='darkorange')
    ax.set_xlim(-0.3, 5.2); ax.set_ylim(1.8, 8)
    ax.set_title(f"Pas t = {t}", fontsize=11); ax.set_aspect('equal'); ax.axis('off')

fig.suptitle("Croissance du KV-cache au fil de la génération", fontsize=13, y=1.02)
plt.show()
_images/28603ef3678e676d060c772cb777f9995d3fef05d7dad60ce03ba1be26daca86.png

Hide code cell source

# Latence simulée : avec et sans KV-cache
T = np.arange(10, 520, 10)
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(T, 0.0002 * T**2, label="Sans KV-cache — $O(T^2)$", linewidth=2, color='#E24A33')
ax.plot(T, 0.05 * T, label="Avec KV-cache — $O(T)$", linewidth=2, color='#55A868')
ax.set_xlabel("Nombre de tokens générés $T$")
ax.set_ylabel("Latence cumulée (unités arbitraires)")
ax.set_title("Impact du KV-cache sur la latence de génération")
ax.legend(fontsize=11)
ax.fill_between(T, 0.05 * T, 0.0002 * T**2, alpha=0.08, color='gray')
plt.show()
_images/43c2fa56772be22c8cc7ca786952216509365a89d269ac0e846652e2f6d3d25c.png

Remarque 14

Il est important de distinguer latence et débit (throughput) en inférence LLM. La latence est le temps pour générer une séquence complète (critique pour les applications interactives). Le débit est le nombre de tokens générés par seconde sur l’ensemble des requêtes (critique pour les systèmes à fort trafic). Le KV-cache améliore la latence par requête mais augmente la consommation mémoire, ce qui peut réduire le nombre de requêtes traitables en parallèle et donc le débit global. Ce compromis mémoire-calcul est central dans l’optimisation des systèmes de serving LLM.

Remarque 15

Le décodage spéculatif (speculative decoding) est une technique récente pour accélérer l’inférence. Un petit modèle « brouillon » (draft model) génère rapidement \(k\) tokens candidats, puis le grand modèle vérifie ces propositions en une seule passe forward (parallèle). Les tokens acceptés sont conservés ; le processus reprend au premier token rejeté. Cette approche peut accélérer la génération de 2 à 3 fois sans changer la distribution de sortie, à condition que le modèle brouillon soit suffisamment aligné avec le grand modèle.

Température et softmax#

La température est le paramètre le plus fondamental pour contrôler le comportement de la génération. Elle modifie la « netteté » de la distribution de probabilité sur le vocabulaire.

Définition 16 (Température)

La température \(T > 0\) est un scalaire qui divise les logits avant l’application du softmax. Pour un vecteur de logits \(\mathbf{z} \in \mathbb{R}^{|V|}\), la distribution modifiée par la température est :

\[p_i = \frac{\exp(z_i / T)}{\sum_{j=1}^{|V|} \exp(z_j / T)}\]

Trois régimes se distinguent :

  • \(T \to 0\) : la distribution converge vers un Dirac sur le token de logit maximal (décodage glouton).

  • \(T = 1\) : la distribution originale du modèle est préservée.

  • \(T \to +\infty\) : la distribution converge vers une loi uniforme sur le vocabulaire.

Propriété 3 (Température et entropie)

L”entropie \(H\) de la distribution de sortie est une fonction croissante de la température \(T\) :

\[H(T) = -\sum_{i=1}^{|V|} p_i(T) \log p_i(T)\]
  • Quand \(T \to 0\), \(H \to 0\) (certitude maximale, un seul token a toute la masse).

  • Quand \(T \to +\infty\), \(H \to \log |V|\) (entropie maximale, distribution uniforme).

  • À \(T = 1\), l’entropie reflète l’incertitude « naturelle » du modèle.

Une température élevée augmente la diversité mais réduit la cohérence ; une température basse produit des textes prévisibles mais potentiellement répétitifs.

Hide code cell source

# Effet de la température sur la distribution de probabilité
prompt = "The key to understanding deep learning is"
input_ids = tokenizer.encode(prompt, return_tensors="pt")
with torch.no_grad():
    logits = model(input_ids).logits[0, -1, :]

fig, axes = plt.subplots(2, 2, figsize=(14, 10))
palette = sns.color_palette("muted", 4)

for idx, (T, ax) in enumerate(zip([0.1, 0.5, 1.0, 2.0], axes.flat)):
    probs = F.softmax(logits / T, dim=-1)
    top_p, top_i = torch.topk(probs, 20)
    tokens = [tokenizer.decode(i.item()) for i in top_i]
    H = -(probs * torch.log(probs + 1e-12)).sum().item()
    ax.barh(range(19, -1, -1), top_p.numpy(), color=palette[idx])
    ax.set_yticks(range(19, -1, -1))
    ax.set_yticklabels([repr(t) for t in tokens], fontsize=8)
    ax.set_xlabel("Probabilité")
    ax.set_title(f"$T = {T}$ — Entropie $H = {H:.2f}$")
    ax.set_xlim(0, min(1.0, top_p[0].item() * 1.3))

fig.suptitle(f"Distribution top-20 après « {prompt} »", fontsize=13, y=1.01)
plt.subplots_adjust(hspace=0.35, wspace=0.35)
plt.show()
_images/001de1b48d3228c480e8c0249d094ceae284b6ba54f4de5aa2c041f07543f11f.png

Hide code cell source

# Génération de texte à différentes températures avec GPT-2
prompt = "Artificial intelligence will"
input_ids = tokenizer.encode(prompt, return_tensors="pt")
attention_mask = torch.ones_like(input_ids)
print(f"Prompt : « {prompt} »\n" + "=" * 60)
for T in [0.3, 0.7, 1.0, 1.5]:
    torch.manual_seed(42)
    out = model.generate(input_ids, attention_mask=attention_mask, max_new_tokens=50, do_sample=True,
                         temperature=T, top_k=0, top_p=1.0,
                         pad_token_id=tokenizer.eos_token_id)
    print(f"\nT = {T} :\n{tokenizer.decode(out[0], skip_special_tokens=True)}")
Prompt : « Artificial intelligence will »
============================================================
T = 0.3 :
Artificial intelligence will be able to learn from the past, and will be able to learn from the future.

The technology will be able to learn from the past, and will be able to learn from the future. The technology will be able to learn from the
T = 0.7 :
Artificial intelligence will be used to work with the algorithms to solve problems that are already in the human brain.

"We're seeing a huge shift in the way we think about the world," says Griffin Parkman, head of global analytics at Deloitte.
T = 1.0 :
Artificial intelligence will continue to expand to create new theories and more tools to solve real problems like terrorism and disease we can't even get to. For now, ACRI's American Bureau of Public Affairs believes AI and AI cost $20B as a result of his PhD
T = 1.5 :
Artificial intelligence will materialise worldwide a furnace containing theories operating more underground parts than ever before: once again talking weirder farewell for 2010)." >>>>>> It ACRI Thomas Conglees Sal Meielve Dal Griffin Park Alexandria cost cmempp North car sm--- hist

Exemple 9 (Effet de la température sur la génération)

Avec le même prompt « Artificial intelligence will » et la même graine aléatoire :

  • \(T = 0.3\) : texte très conservateur, choix lexicaux prévisibles, phrases grammaticalement correctes mais peu originales. Le modèle reste « proche du centre » de sa distribution.

  • \(T = 0.7\) : bon compromis entre cohérence et diversité. C’est une valeur couramment utilisée en pratique pour les tâches créatives.

  • \(T = 1.0\) : distribution originale du modèle. Diversité accrue, mais apparition possible d’associations de mots inattendues.

  • \(T = 1.5\) : haute température. Le texte devient plus erratique, avec des transitions thématiques brusques et des formulations peu naturelles.

En pratique :

# Tâches factuelles (résumé, traduction) : T basse
model.generate(..., temperature=0.2)

# Tâches créatives (écriture, brainstorming) : T modérée
model.generate(..., temperature=0.7)

Stratégies de sampling : top-\(k\) et nucleus (top-\(p\))#

La température contrôle la forme globale de la distribution, mais elle ne résout pas un problème fondamental : même avec une température raisonnable, la longue traîne du vocabulaire contient des milliers de tokens à probabilité très faible mais non nulle. Occasionnellement, l’un de ces tokens improbables est échantillonné, produisant une rupture de cohérence dans le texte. Les stratégies de filtrage permettent d’éliminer cette longue traîne.

Définition 17 (Filtrage top-\(k\))

Le filtrage top-\(k\) restreint l’échantillonnage aux \(k\) tokens de plus haute probabilité. Formellement, soit \(V_k\) l’ensemble des \(k\) tokens de plus grands logits. La distribution filtrée est :

\[\begin{split}p'_i = \begin{cases} \dfrac{p_i}{\sum_{j \in V_k} p_j} & \text{si } i \in V_k \\ 0 & \text{sinon} \end{cases}\end{split}\]

Valeurs typiques : \(k \in [10, 50]\). Le défaut historique est \(k = 50\).

Définition 18 (Filtrage nucleus (top-\(p\)))

Le filtrage nucleus (top-\(p\) sampling), introduit par Holtzman et al. (2020), sélectionne dynamiquement le plus petit ensemble de tokens \(V_p\) dont la probabilité cumulée dépasse un seuil \(p\) :

\[V_p = \arg\min_{V' \subseteq V} |V'| \quad \text{tel que} \quad \sum_{i \in V'} p_i \geq p\]

où les tokens sont triés par probabilité décroissante. La distribution est ensuite renormalisée sur \(V_p\).

L’avantage par rapport au top-\(k\) est l”adaptabilité : lorsque le modèle est confiant (distribution piquée), peu de tokens sont retenus ; lorsqu’il hésite (distribution plate), davantage de tokens sont considérés. Valeur typique : \(p \in [0.9, 0.95]\).

Hide code cell source

# Démonstration du filtrage top-k : avant / après
prompt = "The scientist discovered that"
input_ids = tokenizer.encode(prompt, return_tensors="pt")
with torch.no_grad():
    logits = model(input_ids).logits[0, -1, :]
    probs = F.softmax(logits, dim=-1)

k = 10
top_probs, top_indices = torch.topk(probs, 30)
top_tokens = [tokenizer.decode(idx.item()) for idx in top_indices]
filtered = top_probs.clone(); filtered[k:] = 0; filtered = filtered / filtered.sum()

fig, axes = plt.subplots(1, 2, figsize=(14, 7))
for ax, vals, title in [(axes[0], top_probs[:30].numpy(), "Distribution originale (top-30)"),
                         (axes[1], filtered[:30].numpy(), f"Après filtrage top-$k$ ($k={k}$)")]:
    colors = [sns.color_palette("muted")[2] if i < k else '#dddddd' for i in range(30)] \
             if "Après" in title else [sns.color_palette("muted")[0]] * 30
    ax.barh(range(29, -1, -1), vals, color=colors)
    ax.set_yticks(range(29, -1, -1))
    ax.set_yticklabels([repr(t) for t in top_tokens], fontsize=7)
    ax.set_xlabel("Probabilité"); ax.set_title(title)
axes[1].axhline(y=29-k+0.5, color='red', linestyle='--', alpha=0.7, label=f"Seuil $k={k}$")
axes[1].legend()
plt.subplots_adjust(wspace=0.4)
plt.show()
_images/b48988818b4319824a3d2a91f2dbe74e7e1452cf1ddc2cca57c3b0b7bf8dd484.png

Hide code cell source

# Courbe de probabilité cumulée et seuil nucleus (top-p)
sorted_probs, sorted_indices = torch.sort(probs, descending=True)
cumulative = torch.cumsum(sorted_probs, dim=0)
sorted_tokens = [tokenizer.decode(idx.item()) for idx in sorted_indices]
p_threshold = 0.9
cutoff_idx = (cumulative >= p_threshold).nonzero(as_tuple=True)[0][0].item()

fig, ax = plt.subplots(figsize=(12, 5))
n_show = 50
ax.bar(range(n_show), sorted_probs[:n_show].numpy(), color=sns.color_palette("muted")[0],
       alpha=0.6, label="Probabilité individuelle")
ax.plot(range(n_show), cumulative[:n_show].numpy(), 'o-', color='#E24A33',
        markersize=3, linewidth=2, label="Probabilité cumulée")
ax.axhline(y=p_threshold, color='green', linestyle='--', linewidth=1.5,
           label=f"Seuil $p = {p_threshold}$")
ax.axvline(x=cutoff_idx, color='green', linestyle=':', alpha=0.7)
ax.fill_between(range(cutoff_idx+1), sorted_probs[:cutoff_idx+1].numpy(),
                alpha=0.2, color='green', label=f"Nucleus ($|V_p| = {cutoff_idx+1}$ tokens)")
ax.set_xlabel("Rang du token (trié par probabilité décroissante)")
ax.set_ylabel("Probabilité")
ax.set_title(f"Filtrage nucleus (top-$p$) — prompt : « {prompt} »")
ax.legend(fontsize=9)
plt.show()
print(f"Tokens retenus par top-p = {p_threshold} : {cutoff_idx + 1}")
_images/b780a19e184c68c94d59ee4a5f2ae671727ec1968433bf135e50a287dd75933a.png
Tokens retenus par top-p = 0.9 : 3124

Exemple 10 (Top-\(k\) vs top-\(p\) : comportement adaptatif)

Considérons deux situations :

Cas 1 — Distribution piquée (le modèle est confiant) :

  • Le token le plus probable a \(p_1 = 0.85\).

  • Top-\(k\) (\(k=50\)) : retient 50 tokens, dont 49 sont quasi inutiles.

  • Top-\(p\) (\(p=0.9\)) : retient seulement 2–3 tokens, éliminant le bruit.

Cas 2 — Distribution plate (le modèle hésite) :

  • Les 30 premiers tokens ont chacun \(\approx 0.03\).

  • Top-\(k\) (\(k=10\)) : ne retient que 10 tokens, soit \(\approx 30\%\) de la masse.

  • Top-\(p\) (\(p=0.9\)) : retient \(\approx 30\) tokens, préservant la diversité.

# Top-p s'adapte automatiquement à la confiance du modèle
output = model.generate(
    input_ids,
    do_sample=True,
    top_p=0.92,        # nucleus sampling
    temperature=0.8,   # combiné avec une température modérée
    max_new_tokens=50,
)

En pratique, top-\(p\) et top-\(k\) sont souvent combinés : on applique d’abord le filtrage top-\(k\), puis le filtrage top-\(p\) sur les tokens restants.

Remarque 16

Les systèmes de production modernes combinent généralement température, top-\(p\) et pénalité de répétition en un pipeline de post-traitement des logits. L’ordre d’application est : (1) pénalité de répétition sur les logits bruts, (2) division par la température, (3) filtrage top-\(k\), (4) filtrage top-\(p\), (5) échantillonnage. L’API d’OpenAI, par exemple, expose temperature et top_p comme paramètres principaux et recommande de ne modifier qu’un seul à la fois pour un contrôle prévisible.

Beam search et ses variantes#

Les stratégies de sampling introduisent de l’aléatoire pour favoriser la diversité. Le beam search adopte l’approche opposée : il explore systématiquement plusieurs hypothèses en parallèle pour trouver les séquences les plus probables.

Propriété 4 (Complexité du beam search)

Le beam search de largeur \(B\) sur une séquence de \(T\) tokens avec un vocabulaire de taille \(|V|\) a une complexité temporelle de \(O(B \times T \times |V|)\) et une complexité mémoire de \(O(B \times T)\) pour stocker les hypothèses. En pratique, \(B\) est petit (\(B \in [2, 10]\)) et le surcoût par rapport au décodage glouton reste modéré. Au-delà de \(B \approx 10\), le gain en qualité devient marginal tandis que le coût augmente linéairement.

Hide code cell source

# Visualisation d'un arbre de beam search simplifié
fig, ax = plt.subplots(figsize=(14, 7))
ax.set_xlim(-0.5, 4.5); ax.set_ylim(-0.5, 6.5); ax.axis('off')

tree = {
    0: [(0, 3.0, "<start>", 0.0, None)],
    1: [(1, 5.0, "The", -0.5, (0, 3.0)), (1, 3.0, "A", -1.2, (0, 3.0)),
        (1, 1.0, "In", -1.8, (0, 3.0))],
    2: [(2, 5.5, "future", -1.1, (1, 5.0)), (2, 4.0, "world", -1.6, (1, 5.0)),
        (2, 2.5, "new", -2.0, (1, 3.0))],
    3: [(3, 5.5, "of", -1.5, (2, 5.5)), (3, 3.5, "is", -2.3, (2, 5.5)),
        (3, 1.5, "era", -2.8, (2, 2.5))],
}
colors = sns.color_palette("muted", 4)
for step, nodes in tree.items():
    for (x, y, token, score, parent) in nodes:
        if parent is not None:
            ax.annotate('', xy=(x-0.15, y), xytext=(parent[0]+0.15, parent[1]),
                        arrowprops=dict(arrowstyle='->', color='gray', lw=1.2))
        bbox = dict(boxstyle="round,pad=0.3", facecolor=colors[step], alpha=0.8, ec='gray')
        ax.text(x, y, f"{token}\n({score:.1f})", ha='center', va='center',
                fontsize=9, bbox=bbox, weight='bold')

ax.set_title("Beam search ($B = 3$) — arbre d'hypothèses", fontsize=13, pad=15)
for i, label in enumerate(["Pas 0", "Pas 1", "Pas 2", "Pas 3"]):
    ax.add_patch(mpatches.FancyBboxPatch((3.5, 6.0-i*0.5), 0.6, 0.3,
                 boxstyle="round,pad=0.05", facecolor=colors[i], alpha=0.8))
    ax.text(4.3, 6.15-i*0.5, label, fontsize=9, va='center')
plt.show()
_images/7902c9ea93fc7fd4a77991c07a57bb6ba9a6cf99387adca0f054b42abaad91a9.png

Remarque 17

Le beam search et le décodage glouton souffrent d’un problème de répétition : le modèle tend à produire des boucles où la même phrase ou le même motif se répète indéfiniment. Plusieurs mécanismes atténuent ce problème :

  • Pénalité de répétition (repetition penalty) : diviser les logits des tokens déjà générés par un facteur \(\theta > 1\).

  • Pénalité de n-grammes : interdire la génération de n-grammes déjà présents dans le texte (typiquement \(n = 3\) ou \(4\)).

  • Beam search diversifié (diverse beam search) : ajouter un terme de pénalité encourageant les différents beams à explorer des régions distinctes de l’espace des séquences.

Hide code cell source

# Génération comparative : greedy, sampling, top-k, top-p, beam search
prompt = "The most important discovery in science"
input_ids = tokenizer.encode(prompt, return_tensors="pt")
attention_mask = torch.ones_like(input_ids)
strategies = {
    "Greedy": dict(do_sample=False, num_beams=1),
    "Sampling (T=0.8)": dict(do_sample=True, temperature=0.8, top_k=0, top_p=1.0),
    "Top-k (k=40)": dict(do_sample=True, temperature=0.8, top_k=40),
    "Top-p (p=0.92)": dict(do_sample=True, temperature=0.8, top_p=0.92),
    "Beam search (B=5)": dict(do_sample=False, num_beams=5, early_stopping=True),
}
print(f"Prompt : « {prompt} »\n" + "=" * 65)
for name, params in strategies.items():
    torch.manual_seed(42)
    out = model.generate(input_ids, attention_mask=attention_mask, max_new_tokens=50,
                         pad_token_id=tokenizer.eos_token_id, **params)
    print(f"\n[{name}]\n{tokenizer.decode(out[0], skip_special_tokens=True)}")
Prompt : « The most important discovery in science »
=================================================================
[Greedy]
The most important discovery in science is that the universe is not a single, singular, singular, singular, singular, singular, singular, singular, singular, singular, singular, singular, singular, singular, singular, singular, singular, singular, singular, singular, singular, singular
[Sampling (T=0.8)]
The most important discovery in science is that the same molecular activity in neurons will manifest in different tissues. In order to understand these mechanisms it is necessary to act on the inside of neurons. In particular, several types of neurons have been shown to be activated as a response to certain molecular
[Top-k (k=40)]
The most important discovery in science is that the same molecular activity in neurons will affect our physiology. The activity of neurons in the brain can influence our cognitive abilities. The brain has many activities and its activity can be altered by different medications. We are able to change our physiology in different
[Top-p (p=0.92)]
The most important discovery in science is that the same molecular machinery in neurons will work in different tissues. In order for the same kind of machine to work in the same body of cells, it needs to be able to break down the genetic material. This means, for example, that
[Beam search (B=5)]
The most important discovery in science is that there is no such thing as a 'good' or 'bad' way of looking at the universe.

In fact, there is no such thing as a 'good' or 'bad' way of looking at the universe.

Exemple 11 (Trace d’un beam search)

Considérons un beam search avec \(B = 2\) sur le prompt « The cat ». À chaque pas, le modèle évalue toutes les extensions possibles et ne conserve que les \(B\) meilleures.

Pas 1 — Extensions de « The cat » :

Candidat

Log-prob cumulée

« The cat sat »

\(-0.8\)

« The cat is »

\(-1.0\)

~~« The cat the »~~

~~\(-3.2\)~~

Beam retenu : {« The cat sat », « The cat is »}

Pas 2 — Extensions des deux hypothèses :

Candidat

Log-prob cumulée

« The cat sat on »

\(-1.3\)

« The cat is a »

\(-1.5\)

~~« The cat sat the »~~

~~\(-2.9\)~~

~~« The cat is the »~~

~~\(-2.1\)~~

Beam retenu : {« The cat sat on », « The cat is a »}

Le beam search explore ainsi un espace de séquences beaucoup plus large que le décodage glouton, sans le coût prohibitif d’une recherche exhaustive.

Comparaison des stratégies#

Le choix de la stratégie de décodage dépend de la tâche, du modèle et des contraintes opérationnelles. Le tableau suivant résume les caractéristiques de chaque approche.

Stratégie

Déterministe ?

Qualité

Diversité

Coût relatif

Cas d’usage typique

Greedy

Oui

Moyenne

Nulle

\(1\times\)

Prototypage rapide

Sampling (\(T\))

Non

Variable

Haute

\(1\times\)

Écriture créative

Top-\(k\)

Non

Bonne

Modérée

\(1\times\)

Génération généraliste

Top-\(p\) (nucleus)

Non

Bonne

Modérée-haute

\(1\times\)

Dialogue, rédaction

Beam search (\(B\))

Oui

Haute

Faible

\(B\times\)

Traduction, résumé

Hide code cell source

# Visualisation radar des stratégies de décodage
cats = ['Qualité', 'Diversité', 'Cohérence', 'Vitesse', 'Simplicité']
scores = {'Greedy': [3,1,4,5,5], 'Sampling': [3,5,2,5,4],
          'Top-k': [4,4,3,5,4], 'Top-p': [4,4,4,5,3], 'Beam search': [5,2,5,2,3]}
angles = np.linspace(0, 2*np.pi, len(cats), endpoint=False).tolist() + [0]

fig, ax = plt.subplots(figsize=(8, 8), subplot_kw=dict(polar=True))
palette = sns.color_palette("muted", 5)
for idx, (name, s) in enumerate(scores.items()):
    vals = s + s[:1]
    ax.plot(angles, vals, 'o-', linewidth=2, label=name, color=palette[idx])
    ax.fill(angles, vals, alpha=0.08, color=palette[idx])
ax.set_xticks(angles[:-1]); ax.set_xticklabels(cats, fontsize=11)
ax.set_ylim(0, 5.5); ax.set_yticks([1,2,3,4,5])
ax.set_title("Comparaison des stratégies de décodage", fontsize=13, pad=20)
ax.legend(loc='upper right', bbox_to_anchor=(1.3, 1.1), fontsize=10)
plt.show()
_images/d4c6e3ea6c118a766b71f755c7e397a6a38e1f7a726e731130fade5161aed22b.png

Le décodage glouton est le plus rapide et le plus simple, mais il sacrifie toute diversité et tend à produire des textes répétitifs. Le sampling avec température offre un contrôle direct sur la diversité, mais sans filtrage, la longue traîne du vocabulaire peut produire des incohérences. Le top-\(k\) est un filtre simple et efficace, mais son seuil fixe ne s’adapte pas à la confiance variable du modèle. Le top-\(p\) (nucleus) résout cette limitation par un seuil adaptatif — c’est la stratégie la plus utilisée dans les systèmes de production modernes (ChatGPT, Claude, etc.), souvent combinée avec une température modérée. Le beam search maximise la probabilité de la séquence complète, ce qui le rend préférable pour les tâches structurées (traduction, résumé) où la qualité prévaut sur la créativité, mais son coût est proportionnel à la largeur \(B\).

Hide code cell source

# Entropie effective de chaque stratégie (simulation sur 500 distributions aléatoires)
np.random.seed(42)

def entropy(p):
    p = p[p > 0]
    return -np.sum(p * np.log(p + 1e-12))

n_ctx, V = 500, 200
entropies = {n: [] for n in ['Original', 'T=0.5', 'T=1.5', 'Top-k=10', 'Top-p=0.9']}

for _ in range(n_ctx):
    z = np.random.randn(V) * 2
    p0 = np.exp(z) / np.exp(z).sum()
    entropies['Original'].append(entropy(p0))
    for T, name in [(0.5, 'T=0.5'), (1.5, 'T=1.5')]:
        p = np.exp(z / T); p /= p.sum()
        entropies[name].append(entropy(p))
    # Top-k=10
    idx = np.argsort(p0)[-10:]; pk = np.zeros(V); pk[idx] = p0[idx]; pk /= pk.sum()
    entropies['Top-k=10'].append(entropy(pk))
    # Top-p=0.9
    si = np.argsort(p0)[::-1]; c = np.cumsum(p0[si]); cut = np.searchsorted(c, 0.9) + 1
    pp = np.zeros(V); pp[si[:cut]] = p0[si[:cut]]; pp /= pp.sum()
    entropies['Top-p=0.9'].append(entropy(pp))

fig, ax = plt.subplots(figsize=(10, 5))
bp = ax.boxplot([entropies[k] for k in entropies], tick_labels=list(entropies.keys()),
                patch_artist=True, widths=0.5)
for patch, color in zip(bp['boxes'], sns.color_palette("muted", 5)):
    patch.set_facecolor(color); patch.set_alpha(0.7)
ax.set_ylabel("Entropie (nats)")
ax.set_title("Distribution de l'entropie effective selon la stratégie de décodage")
plt.show()
_images/148193d506f99a63eae609506b3e4155053aca9ea3e12450484a35bf5943d083.png

Résumé#

Ce chapitre a présenté le pipeline complet de génération de texte par un LLM, depuis le mécanisme autorégressif fondamental jusqu’aux stratégies de décodage sophistiquées.

Remarque 18

Points clés à retenir :

  1. Le décodage autorégressif génère du texte token par token, chaque token étant conditionné sur tous les précédents : \(P(x_t \mid x_{<t})\). La boucle de génération itère jusqu’à un token de fin ou une longueur maximale.

  2. Le KV-cache est une optimisation essentielle qui réduit la complexité de l’inférence de \(O(T^2)\) à \(O(T)\) en stockant les matrices de clés et valeurs déjà calculées, au prix d’une consommation mémoire linéaire en la longueur de séquence.

  3. La température contrôle la netteté de la distribution softmax : \(T \to 0\) produit un décodage glouton (déterministe), \(T \to +\infty\) une distribution uniforme (aléatoire). L’entropie est une fonction croissante de \(T\).

  4. Le filtrage top-\(k\) restreint l’échantillonnage aux \(k\) tokens les plus probables, éliminant la longue traîne mais avec un seuil fixe inadapté à la confiance variable du modèle.

  5. Le filtrage nucleus (top-\(p\)) sélectionne dynamiquement le plus petit ensemble de tokens dont la probabilité cumulée dépasse \(p\), offrant un seuil adaptatif supérieur au top-\(k\).

  6. Le beam search explore \(B\) hypothèses en parallèle pour maximiser la probabilité de la séquence complète, avec un compromis qualité-coût proportionnel à la largeur du beam.

  7. En pratique, les systèmes modernes combinent typiquement température, top-\(p\) et pénalité de répétition. Le beam search reste préféré pour les tâches structurées (traduction, résumé).

Le chapitre suivant aborde l”utilisation des API de LLM, où ces paramètres de génération sont directement exposés à l’utilisateur.