Traitement du langage naturel#

Le langage est le vêtement de la pensée.

— Samuel Johnson, Vies des poètes anglais

Le traitement du langage naturel (Natural Language Processing, NLP) est le domaine de l’intelligence artificielle qui vise à donner aux machines la capacité de comprendre, d’interpréter et de générer le langage humain. Contrairement aux données numériques structurées rencontrées dans les chapitres précédents, le texte est une donnée non structurée, discrète, de longueur variable et profondément ambiguë. Un même mot peut avoir plusieurs sens (polysémie), une même idée peut s’exprimer de mille façons (paraphrase), et la compréhension d’une phrase nécessite souvent des connaissances extérieures au texte (pragmatique). Ces défis font du NLP l’un des domaines les plus stimulants de l’apprentissage automatique.

Ce chapitre s’appuie sur les architectures récurrentes (chapitre 20) et l’architecture Transformer (chapitre 23) pour présenter les concepts fondamentaux du NLP moderne : la tokenisation, les représentations vectorielles des mots, les modèles de langue, les modèles pré-entraînés (BERT, GPT, T5) et le fine-tuning. Nous utiliserons l’écosystème Hugging Face pour illustrer ces concepts par du code exécutable.

Hide code cell source

import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import torch
import torch.nn as nn
import torch.nn.functional as F

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

Introduction : le texte comme donnée#

Pourquoi le texte est difficile#

Les données textuelles présentent des caractéristiques fondamentalement différentes des données tabulaires ou des images :

  • Discrétion : le texte est composé de symboles discrets (caractères, mots), non de valeurs continues.

  • Longueur variable : les phrases, paragraphes et documents n’ont pas de taille fixe.

  • Structure hiérarchique : caractères \(\to\) mots \(\to\) phrases \(\to\) paragraphes \(\to\) documents.

  • Ambiguïté : un même mot peut changer de sens selon le contexte (avocat : fruit ou profession).

  • Dépendances à longue portée : le sens d’un pronom peut dépendre d’un référent éloigné dans le texte.

Définition 274 (Traitement du langage naturel)

Le traitement du langage naturel (NLP) est le sous-domaine de l’intelligence artificielle dédié à l’analyse et à la génération automatique du langage humain. Formellement, étant donné un texte \(\mathbf{x} = (x_1, x_2, \ldots, x_T)\) composé de \(T\) unités linguistiques (tokens), le NLP cherche à apprendre des fonctions \(f\) permettant de :

  • Comprendre : extraire une représentation structurée \(f(\mathbf{x}) = \mathbf{z} \in \mathbb{R}^d\) capturant le sens du texte.

  • Générer : produire une séquence \(\mathbf{y} = (y_1, \ldots, y_{T'})\) conditionnée par un contexte.

  • Transformer : convertir un texte d’entrée en un texte de sortie (traduction, résumé, reformulation).

Le pipeline classique du NLP#

Avant l’ère des modèles pré-entraînés, le NLP suivait un pipeline linéaire : segmentation du texte en phrases, tokenisation, étiquetage morpho-syntaxique (POS tagging), lemmatisation, extraction de caractéristiques manuelles (sac de mots, TF-IDF), puis application d’un classifieur. Aujourd’hui, les modèles de type Transformer apprennent directement des représentations à partir du texte brut, mais la tokenisation reste une étape cruciale.

Hide code cell source

# Illustration : pipeline classique vs. pipeline moderne
fig, axes = plt.subplots(2, 1, figsize=(9, 7))

# Pipeline classique
etapes_classique = [
    "Texte brut", "Tokenisation", "Lemmatisation",
    "TF-IDF", "Classifieur\n(SVM, NB)"
]
y_pos = np.arange(len(etapes_classique))
colors_c = sns.color_palette("Blues_r", len(etapes_classique))
axes[0].barh(y_pos, [1]*len(etapes_classique), color=colors_c, edgecolor='white')
for i, etape in enumerate(etapes_classique):
    axes[0].text(0.5, i, etape, ha='center', va='center', fontsize=10, fontweight='bold')
axes[0].set_xlim(0, 1); axes[0].axis('off')
axes[0].set_title("Pipeline classique", fontsize=12, fontweight='bold')

# Pipeline moderne
etapes_moderne = [
    "Texte brut", "Tokenisation\n(subword)", "Modèle\npré-entraîné",
    "Fine-tuning", "Prédiction"
]
colors_m = sns.color_palette("Oranges_r", len(etapes_moderne))
axes[1].barh(y_pos, [1]*len(etapes_moderne), color=colors_m, edgecolor='white')
for i, etape in enumerate(etapes_moderne):
    axes[1].text(0.5, i, etape, ha='center', va='center', fontsize=10, fontweight='bold')
axes[1].set_xlim(0, 1); axes[1].axis('off')
axes[1].set_title("Pipeline moderne (Transformers)", fontsize=12, fontweight='bold')

plt.suptitle("Évolution du pipeline NLP", fontsize=14, fontweight='bold', y=1.02)
plt.tight_layout()
plt.show()
_images/53752eb98e094353e7f9d5909b40953900ce767156d2d7430051460f75ad9d35.png

Tokenisation#

La tokenisation est l’opération qui convertit une chaîne de caractères en une séquence d’unités élémentaires — les tokens — que le modèle peut traiter. C’est la toute première étape de tout pipeline NLP, et son choix influence profondément les performances du système.

Tokenisation par mots (word-level)#

L’approche la plus intuitive consiste à découper le texte en mots, par exemple en utilisant les espaces et la ponctuation comme délimiteurs.

Définition 275 (Tokenisation par mots)

La tokenisation par mots (word-level tokenization) segmente un texte en mots individuels :

\[\text{"Le chat mange."} \longrightarrow [\text{"Le"}, \text{"chat"}, \text{"mange"}, \text{"."}]\]

Le vocabulaire \(\mathcal{V}\) est l’ensemble de tous les tokens distincts rencontrés dans le corpus d’entraînement. Sa taille \(|\mathcal{V}|\) est un hyperparamètre crucial.

Cette approche souffre de plusieurs limitations :

  • Vocabulaire ouvert : tout mot absent du vocabulaire d’entraînement est inconnu (out-of-vocabulary, OOV).

  • Formes fléchies : mange, mangeait, mangerons sont des tokens distincts, sans lien explicite.

  • Taille du vocabulaire : un corpus en français contient facilement plus de 500 000 formes distinctes.

Hide code cell source

# Tokenisation simple par mots
texte = "Le traitement du langage naturel est un domaine fascinant de l'intelligence artificielle."

# Tokenisation naïve par espaces
tokens_espaces = texte.split()
print(f"Tokenisation par espaces ({len(tokens_espaces)} tokens) :")
print(tokens_espaces)

# Tokenisation plus fine avec ponctuation
import re
tokens_regex = re.findall(r"\w+|[^\w\s]", texte)
print(f"\nTokenisation avec ponctuation ({len(tokens_regex)} tokens) :")
print(tokens_regex)
Tokenisation par espaces (12 tokens) :
['Le', 'traitement', 'du', 'langage', 'naturel', 'est', 'un', 'domaine', 'fascinant', 'de', "l'intelligence", 'artificielle.']

Tokenisation avec ponctuation (15 tokens) :
['Le', 'traitement', 'du', 'langage', 'naturel', 'est', 'un', 'domaine', 'fascinant', 'de', 'l', "'", 'intelligence', 'artificielle', '.']

Tokenisation par caractères (character-level)#

À l’autre extrême, on peut tokeniser chaque caractère individuellement. Le vocabulaire est très petit (quelques dizaines de symboles), mais les séquences deviennent très longues, rendant l’apprentissage des dépendances à longue portée difficile.

Hide code cell source

# Tokenisation par caractères
tokens_chars = list(texte)
vocab_chars = sorted(set(tokens_chars))
print(f"Tokenisation par caractères ({len(tokens_chars)} tokens) :")
print(tokens_chars[:30], "...")
print(f"\nVocabulaire : {len(vocab_chars)} caractères uniques")
Tokenisation par caractères (89 tokens) :
['L', 'e', ' ', 't', 'r', 'a', 'i', 't', 'e', 'm', 'e', 'n', 't', ' ', 'd', 'u', ' ', 'l', 'a', 'n', 'g', 'a', 'g', 'e', ' ', 'n', 'a', 't', 'u', 'r'] ...

Vocabulaire : 19 caractères uniques

Tokenisation par sous-mots (subword)#

La tokenisation par sous-mots (subword tokenization) constitue le compromis optimal entre les niveaux mot et caractère. Elle décompose les mots rares en sous-unités fréquentes, tout en conservant les mots courants intacts.

Définition 276 (Tokenisation par sous-mots)

La tokenisation par sous-mots segmente le texte en unités intermédiaires entre le mot et le caractère. Un mot courant reste intact, tandis qu’un mot rare est décomposé :

\[\text{"anticonstitutionnellement"} \longrightarrow [\text{"anti"}, \text{"constitution"}, \text{"nelle"}, \text{"ment"}]\]

Les trois algorithmes principaux sont :

  1. BPE (Byte Pair Encoding) : fusionne itérativement les paires de symboles les plus fréquentes.

  2. WordPiece : similaire à BPE, mais sélectionne les fusions maximisant la vraisemblance du modèle de langue.

  3. SentencePiece : traite le texte comme une séquence brute d’octets, sans pré-tokenisation par espaces.

Exemple 30 (Algorithme BPE pas à pas)

Considérons le vocabulaire initial de caractères et le corpus :

Mot

Fréquence

l o w

5

l o w e r

2

n e w

6

w i d e r

3

Itération 1 : la paire la plus fréquente est \((l, o)\) avec \(5 + 2 = 7\) occurrences \(\to\) fusion en lo.

Itération 2 : la paire \((lo, w)\) a \(5 + 2 = 7\) occurrences \(\to\) fusion en low.

Itération 3 : la paire \((n, e)\) a \(6\) occurrences \(\to\) fusion en ne.

On continue jusqu’à atteindre la taille de vocabulaire souhaitée.

Hide code cell source

# Illustration de BPE simplifié
def bpe_simple(corpus, num_merges):
    """Implémentation simplifiée de Byte Pair Encoding."""
    # Initialiser le vocabulaire avec des caractères
    vocab = {}
    for mot, freq in corpus.items():
        symboles = ' '.join(list(mot)) + ' </w>'
        vocab[symboles] = freq

    historique = []
    for i in range(num_merges):
        # Compter les paires
        paires = {}
        for symboles, freq in vocab.items():
            tokens = symboles.split()
            for j in range(len(tokens) - 1):
                paire = (tokens[j], tokens[j + 1])
                paires[paire] = paires.get(paire, 0) + freq

        if not paires:
            break

        # Trouver la paire la plus fréquente
        meilleure = max(paires, key=paires.get)
        historique.append((meilleure, paires[meilleure]))

        # Fusionner la paire dans le vocabulaire
        nouveau_vocab = {}
        bigram = ' '.join(meilleure)
        remplacement = ''.join(meilleure)
        for symboles, freq in vocab.items():
            nouveau = symboles.replace(bigram, remplacement)
            nouveau_vocab[nouveau] = freq
        vocab = nouveau_vocab

    return vocab, historique

corpus = {"low": 5, "lower": 2, "new": 6, "wider": 3}
vocab_final, fusions = bpe_simple(corpus, 5)

print("Fusions BPE :")
for i, (paire, freq) in enumerate(fusions):
    print(f"  Étape {i+1} : {paire[0]} + {paire[1]}{''.join(paire)} (fréquence : {freq})")

print("\nVocabulaire final :")
for symboles, freq in vocab_final.items():
    print(f"  {symboles:25s}  (fréquence : {freq})")
Fusions BPE :
  Étape 1 : w + </w> → w</w> (fréquence : 11)
  Étape 2 : l + o → lo (fréquence : 7)
  Étape 3 : n + e → ne (fréquence : 6)
  Étape 4 : ne + w</w> → new</w> (fréquence : 6)
  Étape 5 : lo + w</w> → low</w> (fréquence : 5)

Vocabulaire final :
  low</w>                    (fréquence : 5)
  lo w e r </w>              (fréquence : 2)
  new</w>                    (fréquence : 6)
  w i d e r </w>             (fréquence : 3)

Remarque 239

Le choix de la stratégie de tokenisation a un impact majeur sur les performances. Les modèles modernes utilisent presque exclusivement la tokenisation par sous-mots : BERT utilise WordPiece avec un vocabulaire de 30 000 tokens, GPT-2 utilise BPE avec 50 257 tokens, et T5 utilise SentencePiece avec 32 000 tokens. Ce compromis permet de gérer les mots rares sans exploser la taille du vocabulaire.

Représentations vectorielles des mots#

Pour qu’un réseau de neurones puisse traiter du texte, il faut convertir les tokens discrets en vecteurs numériques. La qualité de cette représentation vectorielle — appelée embedding ou plongement — est déterminante pour les performances du modèle.

Encodage one-hot#

L’approche la plus simple consiste à représenter chaque token par un vecteur one-hot.

Définition 277 (Encodage one-hot)

Étant donné un vocabulaire \(\mathcal{V}\) de taille \(V\), l”encodage one-hot associe à chaque token \(w_i \in \mathcal{V}\) un vecteur \(\mathbf{e}_i \in \{0, 1\}^V\) tel que :

\[\begin{split}(\mathbf{e}_i)_j = \begin{cases} 1 & \text{si } j = i \\ 0 & \text{sinon} \end{cases}\end{split}\]

Ce codage présente deux limitations majeures :

  1. Dimensionnalité : les vecteurs sont de dimension \(V\) (typiquement \(10^4\) à \(10^5\)), ce qui est extrêmement creux.

  2. Absence de sémantique : \(\langle \mathbf{e}_i, \mathbf{e}_j \rangle = 0\) pour tout \(i \neq j\), donc roi est aussi distant de reine que de table.

Hide code cell source

# Illustration des limites du one-hot
vocabulaire = ["roi", "reine", "homme", "femme", "table", "chaise"]
V = len(vocabulaire)

# Encodage one-hot
one_hot = np.eye(V)

# Matrice de similarité cosinus (= identité pour one-hot)
sim_matrix = one_hot @ one_hot.T

fig, ax = plt.subplots(figsize=(6, 5))
sns.heatmap(sim_matrix, annot=True, fmt='.0f', cmap='YlOrRd',
            xticklabels=vocabulaire, yticklabels=vocabulaire, ax=ax,
            linewidths=0.5, square=True)
ax.set_title("Similarité cosinus avec encodage one-hot", fontsize=12)
plt.tight_layout()
plt.show()
print("Avec le one-hot, tous les mots sont également distants les uns des autres.")
_images/a0b2b14a449ff1e75e019a9338a9bb1a6076546b1567c3ae124ca40f4f08a599.png
Avec le one-hot, tous les mots sont également distants les uns des autres.

Word2Vec#

Le modèle Word2Vec, proposé par Mikolov et al. en 2013, a révolutionné le NLP en montrant qu’il est possible d’apprendre des représentations vectorielles denses et sémantiques des mots à partir de grands corpus non annotés, en exploitant une idée simple : un mot est caractérisé par la compagnie qu’il tient (hypothèse distributionnelle de Firth, 1957).

Définition 278 (Word2Vec)

Word2Vec apprend un plongement \(\mathbf{w} \in \mathbb{R}^d\) pour chaque mot du vocabulaire, où \(d \ll V\) (typiquement \(d = 100\) à \(300\)). Deux architectures sont proposées :

Skip-gram : prédit les mots du contexte à partir du mot central. Étant donné un mot cible \(w_t\), le modèle maximise la probabilité des mots voisins dans une fenêtre de taille \(c\) :

\[\begin{split}\mathcal{J}_{\text{skip-gram}} = \frac{1}{T} \sum_{t=1}^{T} \sum_{\substack{-c \leq j \leq c \\ j \neq 0}} \log p(w_{t+j} \mid w_t)\end{split}\]

où la probabilité est définie par un softmax :

\[p(w_O \mid w_I) = \frac{\exp(\mathbf{u}_{w_O}^\top \mathbf{v}_{w_I})}{\sum_{w=1}^{V} \exp(\mathbf{u}_w^\top \mathbf{v}_{w_I})}\]

CBOW (Continuous Bag of Words) : prédit le mot central à partir de la moyenne des vecteurs de contexte :

\[\begin{split}\mathcal{J}_{\text{CBOW}} = \frac{1}{T} \sum_{t=1}^{T} \log p\!\left(w_t \;\middle|\; \frac{1}{2c}\sum_{\substack{-c \leq j \leq c \\ j \neq 0}} \mathbf{v}_{w_{t+j}}\right)\end{split}\]

Remarque 240

Le calcul du dénominateur du softmax nécessite une somme sur tout le vocabulaire \(V\), ce qui est prohibitif. L”échantillonnage négatif (negative sampling) remplace ce calcul par une classification binaire : pour chaque paire positive \((w_t, w_{t+j})\), on échantillonne \(k\) mots négatifs. L’objectif devient :

\[\mathcal{J}_{\text{NEG}} = \log \sigma(\mathbf{u}_{w_O}^\top \mathbf{v}_{w_I}) + \sum_{i=1}^{k} \mathbb{E}_{w_i \sim P_n(w)} \left[\log \sigma(-\mathbf{u}_{w_i}^\top \mathbf{v}_{w_I})\right]\]

\(P_n(w) \propto f(w)^{3/4}\) est la distribution de bruit basée sur la fréquence des mots.

Hide code cell source

# Implémentation simplifiée de Skip-gram avec échantillonnage négatif
class SkipGramNeg(nn.Module):
    """Skip-gram avec échantillonnage négatif."""
    def __init__(self, vocab_size, embed_dim):
        super().__init__()
        self.center_embeddings = nn.Embedding(vocab_size, embed_dim)
        self.context_embeddings = nn.Embedding(vocab_size, embed_dim)
        # Initialisation
        nn.init.xavier_uniform_(self.center_embeddings.weight)
        nn.init.zeros_(self.context_embeddings.weight)

    def forward(self, center, context, negatives):
        """
        center: (batch,) indices des mots centraux
        context: (batch,) indices des mots contexte (positifs)
        negatives: (batch, k) indices des mots négatifs
        """
        v = self.center_embeddings(center)          # (batch, d)
        u_pos = self.context_embeddings(context)     # (batch, d)
        u_neg = self.context_embeddings(negatives)   # (batch, k, d)

        # Score positif
        pos_score = torch.sum(v * u_pos, dim=1)      # (batch,)
        pos_loss = -F.logsigmoid(pos_score)

        # Scores négatifs
        neg_score = torch.bmm(u_neg, v.unsqueeze(2)).squeeze(2)  # (batch, k)
        neg_loss = -F.logsigmoid(-neg_score).sum(dim=1)

        return (pos_loss + neg_loss).mean()

# Démonstration
vocab_size, embed_dim = 1000, 50
model_sg = SkipGramNeg(vocab_size, embed_dim)
print(f"Skip-gram : {sum(p.numel() for p in model_sg.parameters()):,} paramètres")
print(f"  - Embeddings centraux : {vocab_size} × {embed_dim} = {vocab_size * embed_dim:,}")
print(f"  - Embeddings contexte : {vocab_size} × {embed_dim} = {vocab_size * embed_dim:,}")
Skip-gram : 100,000 paramètres
  - Embeddings centraux : 1000 × 50 = 50,000
  - Embeddings contexte : 1000 × 50 = 50,000

GloVe#

GloVe (Global Vectors for Word Representation), proposé par Pennington et al. en 2014, combine les avantages des méthodes par co-occurrence (comme LSA) et des méthodes prédictives (comme Word2Vec). L’idée centrale est de factoriser la matrice de co-occurrence des mots.

Définition 279 (GloVe)

Soit \(X_{ij}\) le nombre de fois où le mot \(j\) apparaît dans le contexte du mot \(i\) dans le corpus. GloVe minimise la fonction de coût pondérée :

\[\mathcal{J}_{\text{GloVe}} = \sum_{i,j=1}^{V} f(X_{ij}) \left(\mathbf{w}_i^\top \tilde{\mathbf{w}}_j + b_i + \tilde{b}_j - \log X_{ij}\right)^2\]

\(\mathbf{w}_i, \tilde{\mathbf{w}}_j \in \mathbb{R}^d\) sont les vecteurs du mot et du contexte, \(b_i, \tilde{b}_j\) sont des biais, et \(f\) est une fonction de pondération qui limite l’influence des co-occurrences très fréquentes :

\[\begin{split}f(x) = \begin{cases} (x / x_{\max})^\alpha & \text{si } x < x_{\max} \\ 1 & \text{sinon} \end{cases}\end{split}\]

avec typiquement \(x_{\max} = 100\) et \(\alpha = 3/4\).

Hide code cell source

# Illustration de la fonction de pondération de GloVe
x = np.linspace(0, 150, 300)
x_max = 100
alpha = 0.75
f_x = np.where(x < x_max, (x / x_max) ** alpha, 1.0)

fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(x, f_x, linewidth=2, color='steelblue')
ax.axvline(x=x_max, color='coral', linestyle='--', alpha=0.7, label=f'$x_{{\\max}} = {x_max}$')
ax.set_xlabel("$X_{ij}$ (fréquence de co-occurrence)")
ax.set_ylabel("$f(X_{ij})$")
ax.set_title("Fonction de pondération de GloVe ($\\alpha = 3/4$)")
ax.legend()
plt.tight_layout()
plt.show()
_images/689b51160d187dd89ee53c87f38e1e2c120f7647726766be6fc6dfe3dae865d7.png

Propriétés des embeddings#

Les embeddings de mots capturent des régularités sémantiques et syntaxiques remarquables, que l’on peut explorer par des opérations algébriques simples dans l’espace vectoriel.

Exemple 31 (Analogies vectorielles)

Les embeddings de Word2Vec et GloVe permettent de résoudre des analogies de la forme A est à B ce que C est à D par simple arithmétique vectorielle :

\[\mathbf{v}_{\text{roi}} - \mathbf{v}_{\text{homme}} + \mathbf{v}_{\text{femme}} \approx \mathbf{v}_{\text{reine}}\]
\[\mathbf{v}_{\text{Paris}} - \mathbf{v}_{\text{France}} + \mathbf{v}_{\text{Allemagne}} \approx \mathbf{v}_{\text{Berlin}}\]

Ces régularités émergent de l’entraînement sur de grands corpus sans aucune supervision explicite de ces relations.

Hide code cell source

# Simulation : embeddings jouets montrant les propriétés d'analogie
np.random.seed(42)

# Créer des embeddings simulés avec des relations sémantiques
mots = ["roi", "reine", "homme", "femme", "prince", "princesse",
        "Paris", "France", "Berlin", "Allemagne", "Rome", "Italie"]

# Dimensions sémantiques latentes : [royauté, genre, géographie, pays]
embeddings_dict = {
    "roi":       np.array([1.0, -0.8,  0.0,  0.0]) + 0.1*np.random.randn(4),
    "reine":     np.array([1.0,  0.8,  0.0,  0.0]) + 0.1*np.random.randn(4),
    "homme":     np.array([0.0, -0.8,  0.0,  0.0]) + 0.1*np.random.randn(4),
    "femme":     np.array([0.0,  0.8,  0.0,  0.0]) + 0.1*np.random.randn(4),
    "prince":    np.array([0.7, -0.6,  0.0,  0.0]) + 0.1*np.random.randn(4),
    "princesse": np.array([0.7,  0.6,  0.0,  0.0]) + 0.1*np.random.randn(4),
    "Paris":     np.array([0.0,  0.0,  1.0, -0.5]) + 0.1*np.random.randn(4),
    "France":    np.array([0.0,  0.0,  0.0, -0.5]) + 0.1*np.random.randn(4),
    "Berlin":    np.array([0.0,  0.0,  1.0,  0.5]) + 0.1*np.random.randn(4),
    "Allemagne": np.array([0.0,  0.0,  0.0,  0.5]) + 0.1*np.random.randn(4),
    "Rome":      np.array([0.0,  0.0,  1.0,  0.0]) + 0.1*np.random.randn(4),
    "Italie":    np.array([0.0,  0.0,  0.0,  0.0]) + 0.1*np.random.randn(4),
}

# Matrice de similarité cosinus
mat = np.array([embeddings_dict[m] for m in mots])
norms = np.linalg.norm(mat, axis=1, keepdims=True)
sim = (mat @ mat.T) / (norms @ norms.T)

fig, ax = plt.subplots(figsize=(10, 8))
sns.heatmap(sim, annot=True, fmt='.2f', cmap='RdBu_r', center=0,
            xticklabels=mots, yticklabels=mots, ax=ax,
            linewidths=0.5, square=True, vmin=-1, vmax=1)
ax.set_title("Similarité cosinus entre embeddings simulés", fontsize=12)
plt.tight_layout()
plt.show()
_images/3b9f8f9f9ad8b7796835225af973523ca61949a9421a870664bf06507a9a1b77.png

Hide code cell source

# Test d'analogie : roi - homme + femme ≈ reine
def analogie(a, b, c, embeddings, top_k=3):
    """Résout l'analogie a:b :: c:? par arithmétique vectorielle."""
    vec_cible = embeddings[b] - embeddings[a] + embeddings[c]
    scores = {}
    for mot, vec in embeddings.items():
        if mot in {a, b, c}:
            continue
        cos_sim = np.dot(vec_cible, vec) / (np.linalg.norm(vec_cible) * np.linalg.norm(vec))
        scores[mot] = cos_sim
    tri = sorted(scores.items(), key=lambda x: x[1], reverse=True)
    return tri[:top_k]

print("Analogie : roi - homme + femme = ?")
for mot, score in analogie("homme", "roi", "femme", embeddings_dict):
    print(f"  {mot:15s}  (similarité : {score:.3f})")

print("\nAnalogie : France - Paris + Berlin = ?")
for mot, score in analogie("Paris", "France", "Berlin", embeddings_dict):
    print(f"  {mot:15s}  (similarité : {score:.3f})")
Analogie : roi - homme + femme = ?
  reine            (similarité : 0.959)
  princesse        (similarité : 0.956)
  prince           (similarité : 0.330)

Analogie : France - Paris + Berlin = ?
  Allemagne        (similarité : 0.882)
  Italie           (similarité : 0.538)
  roi              (similarité : 0.266)

Visualisation par réduction de dimensionnalité#

Comme introduit au chapitre 12, les techniques de réduction de dimensionnalité comme t-SNE et PCA permettent de projeter les embeddings de haute dimension dans un espace 2D pour les visualiser.

Hide code cell source

# Visualisation 2D des embeddings simulés avec PCA
from sklearn.decomposition import PCA

mat_embed = np.array([embeddings_dict[m] for m in mots])
pca = PCA(n_components=2)
coords = pca.fit_transform(mat_embed)

fig, ax = plt.subplots(figsize=(10, 7))
couleurs = ['#E24A33' if m in ["roi","reine","prince","princesse","homme","femme"]
            else '#4C72B0' for m in mots]
ax.scatter(coords[:, 0], coords[:, 1], c=couleurs, s=100, zorder=5)
for i, mot in enumerate(mots):
    ax.annotate(mot, (coords[i, 0], coords[i, 1]),
                textcoords="offset points", xytext=(8, 5), fontsize=11)

# Flèches d'analogie
idx = {m: i for i, m in enumerate(mots)}
style = dict(arrowstyle='->', color='gray', lw=1.5, connectionstyle='arc3,rad=0.1')
ax.annotate('', xy=coords[idx['reine']], xytext=coords[idx['roi']],
            arrowprops=style)
ax.annotate('', xy=coords[idx['femme']], xytext=coords[idx['homme']],
            arrowprops=style)

ax.set_xlabel(f"PC1 ({pca.explained_variance_ratio_[0]:.1%} de variance)")
ax.set_ylabel(f"PC2 ({pca.explained_variance_ratio_[1]:.1%} de variance)")
ax.set_title("Visualisation PCA des embeddings de mots")
plt.tight_layout()
plt.show()
_images/38ef37d344fd2d96370601e5d69483b7101ef8cb5e8ab1f29b2013b323a8c3d1.png

Modèles de langue#

Un modèle de langue est un modèle probabiliste qui assigne une probabilité à toute séquence de mots. Il est au coeur du NLP moderne, car les modèles pré-entraînés (BERT, GPT) sont fondamentalement des modèles de langue.

Modèles N-gram#

Définition 280 (Modèle de langue N-gram)

Un modèle de langue assigne une probabilité à une séquence de mots \(\mathbf{w} = (w_1, \ldots, w_T)\) :

\[p(\mathbf{w}) = \prod_{t=1}^{T} p(w_t \mid w_1, \ldots, w_{t-1})\]

Un modèle N-gram approxime cette probabilité en supposant que la distribution de \(w_t\) ne dépend que des \(N-1\) mots précédents (hypothèse de Markov d’ordre \(N-1\)) :

\[p(w_t \mid w_1, \ldots, w_{t-1}) \approx p(w_t \mid w_{t-N+1}, \ldots, w_{t-1})\]

Les probabilités sont estimées par comptage sur le corpus :

\[p(w_t \mid w_{t-N+1}, \ldots, w_{t-1}) = \frac{\text{count}(w_{t-N+1}, \ldots, w_t)}{\text{count}(w_{t-N+1}, \ldots, w_{t-1})}\]

Les modèles N-gram sont simples et rapides, mais limités : ils ne capturent pas les dépendances au-delà de la fenêtre de \(N-1\) mots, et souffrent de la parcimonie des données (de nombreux N-grams n’apparaissent jamais dans le corpus).

Modèles de langue neuronaux#

Les modèles de langue neuronaux remplacent la table de comptage par un réseau de neurones qui apprend des représentations continues. Initialement basés sur des RNN (chapitre 20), les modèles de langue modernes reposent sur l’architecture Transformer (chapitre 23).

Hide code cell source

# Modèle de langue simple avec RNN
class LanguageModelRNN(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim)
        self.rnn = nn.LSTM(embed_dim, hidden_dim, batch_first=True)
        self.fc = nn.Linear(hidden_dim, vocab_size)

    def forward(self, x):
        emb = self.embedding(x)            # (batch, seq_len, embed_dim)
        output, _ = self.rnn(emb)           # (batch, seq_len, hidden_dim)
        logits = self.fc(output)            # (batch, seq_len, vocab_size)
        return logits

vocab_size, embed_dim, hidden_dim = 5000, 128, 256
lm = LanguageModelRNN(vocab_size, embed_dim, hidden_dim)
print(f"Modèle de langue RNN : {sum(p.numel() for p in lm.parameters()):,} paramètres")

# Exemple d'entrée
x = torch.randint(0, vocab_size, (2, 20))  # batch de 2 séquences de 20 tokens
logits = lm(x)
print(f"Entrée : {x.shape} → Logits : {logits.shape}")
Modèle de langue RNN : 2,320,264 paramètres
Entrée : torch.Size([2, 20]) → Logits : torch.Size([2, 20, 5000])

Perplexité#

La perplexité est la métrique standard pour évaluer les modèles de langue.

Définition 281 (Perplexité)

La perplexité d’un modèle de langue sur une séquence test \(\mathbf{w} = (w_1, \ldots, w_T)\) est définie comme l’exponentielle de l’entropie croisée moyenne :

\[\text{PPL}(\mathbf{w}) = \exp\!\left(-\frac{1}{T} \sum_{t=1}^{T} \log p(w_t \mid w_{<t})\right)\]

La perplexité peut s’interpréter comme le nombre moyen de choix que le modèle hésite entre à chaque position. Un modèle parfait (qui prédit avec certitude le bon mot) a une perplexité de 1. Un modèle aléatoire uniforme sur un vocabulaire de \(V\) mots a une perplexité de \(V\).

Hide code cell source

# Illustration de la perplexité
def calculer_perplexite(log_probs):
    """Calcule la perplexité à partir des log-probabilités."""
    return np.exp(-np.mean(log_probs))

# Simuler des modèles de qualités différentes
np.random.seed(42)
T = 100
V = 10000

# Bon modèle : haute probabilité pour le bon token
log_probs_bon = np.log(np.random.beta(5, 1, T) * 0.8 + 0.1)
# Modèle moyen
log_probs_moyen = np.log(np.random.beta(2, 3, T) * 0.5 + 0.01)
# Modèle aléatoire
log_probs_aleatoire = np.full(T, np.log(1.0 / V))

modeles = {
    "Bon modèle": log_probs_bon,
    "Modèle moyen": log_probs_moyen,
    "Modèle aléatoire": log_probs_aleatoire,
}

fig, ax = plt.subplots(figsize=(8, 5))
noms = list(modeles.keys())
ppl_values = [calculer_perplexite(lp) for lp in modeles.values()]
couleurs = ['#55A868', '#DD8452', '#E24A33']
bars = ax.barh(noms, ppl_values, color=couleurs, edgecolor='white', height=0.5)
ax.set_xlabel("Perplexité (échelle log)")
ax.set_xscale('log')
ax.set_title("Comparaison de la perplexité de trois modèles")
for bar, val in zip(bars, ppl_values):
    ax.text(bar.get_width() * 1.1, bar.get_y() + bar.get_height()/2,
            f'{val:.1f}', ha='left', va='center', fontsize=11)
plt.tight_layout()
plt.show()
_images/b662b228fd9116972f99cb4a3e5032705c7b1d181db610046f9f65951705c630.png

Modèles pré-entraînés#

La révolution majeure du NLP depuis 2018 est le paradigme pré-entraînement puis fine-tuning (pre-train then fine-tune). Des modèles massifs sont d’abord pré-entraînés sur d’immenses corpus de texte non annoté (apprentissage auto-supervisé), puis adaptés (fine-tuned) à des tâches spécifiques avec un jeu de données étiqueté beaucoup plus petit.

BERT#

Définition 282 (BERT)

BERT (Bidirectional Encoder Representations from Transformers), proposé par Devlin et al. en 2018, est un encodeur Transformer pré-entraîné avec deux objectifs :

  1. Modélisation de langue masquée (Masked Language Modeling, MLM) : 15 % des tokens sont masqués aléatoirement, et le modèle doit les prédire à partir du contexte bidirectionnel :

\[\mathcal{L}_{\text{MLM}} = -\sum_{i \in \mathcal{M}} \log p(w_i \mid \mathbf{w}_{\setminus \mathcal{M}})\]

\(\mathcal{M}\) est l’ensemble des positions masquées.

  1. Prédiction de phrase suivante (Next Sentence Prediction, NSP) : le modèle prédit si deux segments de texte se suivent dans le corpus original.

L’architecture est un encodeur Transformer empilé :

  • BERT-base : 12 couches, 768 dimensions cachées, 12 têtes d’attention, 110M paramètres.

  • BERT-large : 24 couches, 1024 dimensions, 16 têtes, 340M paramètres.

Hide code cell source

# Illustration du masquage MLM
phrase = ["Le", "chat", "noir", "dort", "sur", "le", "canapé", "rouge"]

np.random.seed(3)
mask_prob = 0.15
masques = np.random.random(len(phrase)) < mask_prob
# S'assurer qu'au moins un token est masqué
if not any(masques):
    masques[np.random.randint(len(phrase))] = True

phrase_masquee = []
for mot, masque in zip(phrase, masques):
    if masque:
        phrase_masquee.append("[MASK]")
    else:
        phrase_masquee.append(mot)

print("Phrase originale :", " ".join(phrase))
print("Phrase masquée   :", " ".join(phrase_masquee))
print(f"\nObjectif MLM : prédire les tokens masqués à partir du contexte bidirectionnel.")
print(f"Tokens masqués : {[phrase[i] for i, m in enumerate(masques) if m]}")
Phrase originale : Le chat noir dort sur le canapé rouge
Phrase masquée   : Le chat noir dort sur le [MASK] rouge

Objectif MLM : prédire les tokens masqués à partir du contexte bidirectionnel.
Tokens masqués : ['canapé']

GPT#

Définition 283 (GPT)

GPT (Generative Pre-trained Transformer), proposé par Radford et al. en 2018, est un décodeur Transformer pré-entraîné avec un objectif de modélisation de langue autoregressif :

\[\mathcal{L}_{\text{GPT}} = -\sum_{t=1}^{T} \log p(w_t \mid w_1, \ldots, w_{t-1})\]

Contrairement à BERT, GPT ne voit que le contexte à gauche (passé) grâce à un masque d’attention causal. Cette contrainte autoregressive rend GPT naturellement adapté à la génération de texte.

Les versions successives diffèrent principalement par leur taille :

  • GPT-1 : 12 couches, 117M paramètres.

  • GPT-2 : 48 couches, 1.5B paramètres.

  • GPT-3 : 96 couches, 175B paramètres.

T5#

Définition 284 (T5)

T5 (Text-to-Text Transfer Transformer), proposé par Raffel et al. en 2020, reformule toutes les tâches NLP comme des problèmes de texte-vers-texte (text-to-text). L’architecture est un Transformer encodeur-décodeur complet.

Exemples de reformulation :

  • Classification : entrée = "sentiment: Ce film est excellent" \(\to\) sortie = "positif"

  • Traduction : entrée = "translate English to French: Hello" \(\to\) sortie = "Bonjour"

  • Résumé : entrée = "summarize: [long texte]" \(\to\) sortie = "[résumé]"

Le pré-entraînement utilise un objectif de débruitage (span corruption) sur le corpus C4 (Colossal Clean Crawled Corpus).

Remarque 241

Le tableau suivant résume les différences architecturales entre les trois familles de modèles pré-entraînés :

Caractéristique

BERT

GPT

T5

Architecture

Encodeur seul

Décodeur seul

Encodeur-Décodeur

Pré-entraînement

MLM + NSP

Autorégressif

Span corruption

Contexte

Bidirectionnel

Gauche → droite

Bidirectionnel (enc.) + autorégressif (déc.)

Force

Compréhension

Génération

Polyvalence

Taille (base)

110M params

117M params

220M params

Cas d’usage

Classification, NER, QA extractif

Génération, complétion

Toute tâche seq2seq

Hide code cell source

# Visualisation comparative des architectures
fig, axes = plt.subplots(3, 1, figsize=(9, 14))

architectures = [
    ("BERT\n(Encodeur)", "Bidirectionnel", '#4C72B0'),
    ("GPT\n(Décodeur)", "Autorégressif\n(gauche → droite)", '#DD8452'),
    ("T5\n(Enc-Déc)", "Bidirectionnel +\nAutorégressif", '#55A868'),
]

for ax, (nom, mode, couleur) in zip(axes, architectures):
    # Matrice d'attention schématique
    n = 6
    if "Encodeur" in nom and "Déc" not in nom:
        mask = np.ones((n, n))  # Attention complète
    elif "Décodeur" in nom and "Enc" not in nom:
        mask = np.tril(np.ones((n, n)))  # Masque causal
    else:
        mask = np.ones((n, n))
        mask[n//2:, :n//2] = 1  # Encodeur bidirectionnel
        mask[n//2:, n//2:] = np.tril(np.ones((n//2, n//2)))  # Décodeur causal

    sns.heatmap(mask, ax=ax, cmap=[couleur + '33', couleur], cbar=False,
                linewidths=1, linecolor='white', square=True)
    ax.set_title(f"{nom}\n{mode}", fontsize=11, fontweight='bold')
    ax.set_xlabel("Position clé"); ax.set_ylabel("Position requête")
    ax.set_xticks([]); ax.set_yticks([])

plt.suptitle("Masques d'attention des trois architectures", fontsize=13, fontweight='bold', y=1.02)
plt.tight_layout()
plt.show()
_images/056611e35ea2dbbb5ac6dabf8eb9af3513a18c897f22aca331ee558610bcca71.png

Fine-tuning#

Le paradigme de l’apprentissage par transfert en NLP#

Le fine-tuning consiste à adapter un modèle pré-entraîné à une tâche spécifique en continuant l’entraînement sur un jeu de données étiqueté, typiquement beaucoup plus petit que le corpus de pré-entraînement. C’est l’équivalent NLP du transfer learning étudié pour les CNN au chapitre 19.

Définition 285 (Fine-tuning)

Le fine-tuning d’un modèle pré-entraîné \(f_\theta\) pour une tâche cible consiste à :

  1. Initialiser le modèle avec les poids pré-entraînés \(\theta_{\text{pre}}\).

  2. Ajouter une tête de classification (classification head) adaptée à la tâche : typiquement une couche linéaire \(\mathbf{W} \in \mathbb{R}^{d \times C}\) au-dessus de la représentation du token [CLS].

  3. Entraîner l’ensemble des paramètres \((\theta, \mathbf{W})\) sur le jeu de données étiqueté avec un taux d’apprentissage faible (\(\eta \sim 2 \times 10^{-5}\) à \(5 \times 10^{-5}\)).

La fonction de coût pour une tâche de classification à \(C\) classes est l’entropie croisée :

\[\mathcal{L}_{\text{fine-tune}} = -\frac{1}{N}\sum_{i=1}^{N} \sum_{c=1}^{C} y_{ic} \log \hat{y}_{ic}\]

\(\hat{y}_{ic} = \text{softmax}(\mathbf{W}\, f_\theta(\mathbf{x}_i)_{[\text{CLS}]})_c\).

Hide code cell source

# Architecture conceptuelle du fine-tuning de BERT
class BertClassifier(nn.Module):
    """Schéma simplifié d'un classifieur basé sur BERT."""
    def __init__(self, hidden_dim, num_classes, vocab_size=30000,
                 num_layers=12, num_heads=12):
        super().__init__()
        # Simuler l'encodeur BERT (simplifié)
        self.embedding = nn.Embedding(vocab_size, hidden_dim)
        encoder_layer = nn.TransformerEncoderLayer(
            d_model=hidden_dim, nhead=num_heads,
            dim_feedforward=hidden_dim * 4, batch_first=True
        )
        self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers)

        # Tête de classification (ajoutée pour le fine-tuning)
        self.classifier = nn.Sequential(
            nn.Dropout(0.1),
            nn.Linear(hidden_dim, num_classes)
        )

    def forward(self, input_ids):
        x = self.embedding(input_ids)       # (batch, seq_len, hidden_dim)
        x = self.encoder(x)                  # (batch, seq_len, hidden_dim)
        cls_output = x[:, 0, :]              # Représentation du token [CLS]
        logits = self.classifier(cls_output)  # (batch, num_classes)
        return logits

# Compter les paramètres
model_cls = BertClassifier(hidden_dim=768, num_classes=3, num_layers=12, num_heads=12)
total = sum(p.numel() for p in model_cls.parameters())
classif = sum(p.numel() for p in model_cls.classifier.parameters())
print(f"Paramètres totaux : {total:,}")
print(f"Paramètres de la tête de classification : {classif:,}")
print(f"Proportion de nouveaux paramètres : {classif/total:.4%}")
Paramètres totaux : 108,096,771
Paramètres de la tête de classification : 2,307
Proportion de nouveaux paramètres : 0.0021%

Remarque 242

Quelques bonnes pratiques pour le fine-tuning :

  • Taux d’apprentissage : utiliser un taux faible (\(2 \times 10^{-5}\)) pour ne pas détruire les représentations pré-entraînées.

  • Échauffement (warmup) : augmenter progressivement le taux d’apprentissage pendant les premières itérations.

  • Nombre d’époques : 2 à 5 époques suffisent généralement pour le fine-tuning.

  • Gel des couches (freezing) : on peut geler les premières couches du modèle et ne fine-tuner que les couches supérieures, surtout si les données sont peu nombreuses.

  • Taux d’apprentissage discriminatif : appliquer des taux d’apprentissage décroissants selon la profondeur des couches.

Exemple : analyse de sentiments#

L’analyse de sentiments — déterminer si un texte exprime une opinion positive, négative ou neutre — est l’une des tâches NLP les plus courantes et un excellent cas d’application du fine-tuning.

Hide code cell source

# Simulation d'un fine-tuning pour l'analyse de sentiments
# (version pédagogique sans téléchargement de modèle)

# Données simulées
textes = [
    "Ce film est absolument magnifique, j'ai adoré chaque minute.",
    "Quelle déception, le scénario est vraiment mauvais.",
    "Un chef-d'œuvre du cinéma français, émouvant et brillant.",
    "Je me suis ennuyé du début à la fin, à éviter.",
    "Correct sans plus, quelques bonnes scènes mais trop long.",
    "Extraordinaire ! Les acteurs sont incroyables.",
    "Nul, je regrette d'avoir payé ma place.",
    "Un bon divertissement, agréable à regarder.",
]
labels = [1, 0, 1, 0, 0, 1, 0, 1]  # 1=positif, 0=négatif

# Simuler les embeddings BERT (représentation [CLS])
np.random.seed(42)
hidden_dim = 768
X_cls = np.random.randn(len(textes), hidden_dim)
# Ajouter un signal sémantique simulé
for i, label in enumerate(labels):
    X_cls[i, :10] += (2 * label - 1) * 1.5

# Fine-tuning d'un classifieur linéaire sur les représentations [CLS]
X_tensor = torch.tensor(X_cls, dtype=torch.float32)
y_tensor = torch.tensor(labels, dtype=torch.long)

classifier = nn.Linear(hidden_dim, 2)
optimizer = torch.optim.Adam(classifier.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()

losses = []
for epoch in range(100):
    logits = classifier(X_tensor)
    loss = criterion(logits, y_tensor)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    losses.append(loss.item())

fig, axes = plt.subplots(2, 1, figsize=(9, 7))

# Courbe de perte
axes[0].plot(losses, color='steelblue', linewidth=1.5)
axes[0].set_xlabel("Époque"); axes[0].set_ylabel("Perte (entropie croisée)")
axes[0].set_title("Fine-tuning : courbe de perte")

# Prédictions
with torch.no_grad():
    preds = classifier(X_tensor).argmax(dim=1).numpy()
couleurs = ['#55A868' if p == l else '#E24A33' for p, l in zip(preds, labels)]
axes[1].barh(range(len(textes)), preds, color=couleurs, edgecolor='white')
axes[1].set_yticks(range(len(textes)))
axes[1].set_yticklabels([t[:40] + "..." for t in textes], fontsize=8)
axes[1].set_xlabel("Prédiction (0=négatif, 1=positif)")
axes[1].set_title("Prédictions après fine-tuning")

plt.tight_layout()
plt.show()
print(f"Précision sur l'entraînement : {(preds == labels).mean():.0%}")
_images/54d05269539d799112e1804f47b6dd1e9e9555a870ea25a30456226411791f64.png
Précision sur l'entraînement : 100%

Hugging Face : l’écosystème du NLP moderne#

La bibliothèque Hugging Face Transformers est devenue l’outil de référence pour le NLP appliqué. Elle fournit une interface unifiée pour des milliers de modèles pré-entraînés, des tokenizers optimisés et des pipelines prêts à l’emploi.

L’API Pipeline#

L’API pipeline permet d’utiliser un modèle pré-entraîné en une seule ligne de code, en encapsulant la tokenisation, l’inférence et le post-traitement.

Exemple 32 (Utilisation de l’API Pipeline)

from transformers import pipeline

# Analyse de sentiments
classifieur = pipeline("sentiment-analysis")
resultat = classifieur("Ce film est vraiment excellent !")
# [{'label': 'POSITIVE', 'score': 0.9998}]

# Génération de texte
generateur = pipeline("text-generation", model="gpt2")
texte = generateur("Le traitement du langage naturel", max_length=50)

# Question-réponse
qa = pipeline("question-answering")
resultat = qa(question="Qui a inventé BERT ?",
              context="BERT a été proposé par Devlin et al. chez Google en 2018.")
# {'answer': 'Devlin et al. chez Google', 'score': 0.92}

Les pipelines disponibles incluent : sentiment-analysis, ner, question-answering, summarization, translation, text-generation, fill-mask, zero-shot-classification, entre autres.

Tokenizers Hugging Face#

Hide code cell source

# Démonstration d'un tokenizer (simulation sans téléchargement)
# Simuler le comportement d'un tokenizer WordPiece de type BERT

class TokenizerSimule:
    """Simulation pédagogique d'un tokenizer WordPiece."""
    def __init__(self):
        # Vocabulaire simplifié
        self.vocab = {
            "[PAD]": 0, "[UNK]": 1, "[CLS]": 2, "[SEP]": 3, "[MASK]": 4,
            "le": 5, "chat": 6, "mange": 7, "du": 8, "poisson": 9,
            "la": 10, "souris": 11, "dort": 12, "sur": 13, "canapé": 14,
            "##s": 15, "##ent": 16, "anti": 17, "constitution": 18,
            "##nelle": 19, "##ment": 20, "un": 21, "une": 22, "est": 23,
            "très": 24, "bon": 25, "##ne": 26,
        }
        self.id2token = {v: k for k, v in self.vocab.items()}

    def tokenize(self, texte):
        mots = texte.lower().split()
        tokens = ["[CLS]"]
        for mot in mots:
            if mot in self.vocab:
                tokens.append(mot)
            else:
                # Simulation de décomposition subword
                found = False
                for i in range(len(mot), 0, -1):
                    if mot[:i] in self.vocab:
                        tokens.append(mot[:i])
                        reste = mot[i:]
                        while reste:
                            sub = "##" + reste
                            if sub in self.vocab:
                                tokens.append(sub)
                                reste = ""
                            else:
                                tokens.append("[UNK]")
                                reste = ""
                        found = True
                        break
                if not found:
                    tokens.append("[UNK]")
        tokens.append("[SEP]")
        return tokens

    def encode(self, texte):
        tokens = self.tokenize(texte)
        return [self.vocab.get(t, 1) for t in tokens]

tok = TokenizerSimule()
phrase = "Le chat mange du poisson"
tokens = tok.tokenize(phrase)
ids = tok.encode(phrase)

print(f"Texte    : {phrase}")
print(f"Tokens   : {tokens}")
print(f"IDs      : {ids}")
print(f"Longueur : {len(tokens)} tokens")
Texte    : Le chat mange du poisson
Tokens   : ['[CLS]', 'le', 'chat', 'mange', 'du', 'poisson', '[SEP]']
IDs      : [2, 5, 6, 7, 8, 9, 3]
Longueur : 7 tokens

Remarque 243

Les tokenizers modernes ajoutent des tokens spéciaux qui jouent des rôles précis :

  • [CLS] : placé en début de séquence, sa représentation finale sert pour la classification.

  • [SEP] : sépare deux segments (utile pour les tâches de paires de phrases).

  • [MASK] : remplace un token dans l’objectif MLM de BERT.

  • [PAD] : complète les séquences courtes pour former des batches de taille fixe.

Ces tokens sont essentiels au bon fonctionnement des modèles pré-entraînés.

Le Model Hub#

Le Model Hub de Hugging Face héberge plus de 500 000 modèles pré-entraînés couvrant des dizaines de langues et de tâches. Pour le français, les modèles les plus utilisés sont :

  • CamemBERT : un modèle BERT entraîné sur un large corpus en français (138 Go de texte).

  • FlauBERT : un autre modèle de type BERT pour le français.

  • Mistral / LLaMA : des modèles de type GPT multilingues avec de bonnes performances en français.

Exemple 33 (Fine-tuning avec Hugging Face Transformers)

Voici le schéma typique d’un fine-tuning avec la bibliothèque Transformers :

from transformers import (
    AutoTokenizer, AutoModelForSequenceClassification, Trainer, TrainingArguments
)
from datasets import load_dataset

# 1. Charger les données
dataset = load_dataset("allocine")  # Critiques de films en français

# 2. Charger le modèle et le tokenizer pré-entraînés
model_name = "camembert-base"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=2)

# 3. Tokeniser le dataset
def preprocess(examples):
    return tokenizer(examples["review"], truncation=True, padding="max_length", max_length=256)
tokenized = dataset.map(preprocess, batched=True)

# 4. Configurer l'entraînement
args = TrainingArguments(
    output_dir="./resultats",
    num_train_epochs=3,
    per_device_train_batch_size=16,
    learning_rate=2e-5,
    warmup_steps=500,
    evaluation_strategy="epoch",
)

# 5. Entraîner
trainer = Trainer(model=model, args=args, train_dataset=tokenized["train"],
                  eval_dataset=tokenized["test"])
trainer.train()

Ce code fine-tune CamemBERT pour la classification de sentiments sur le dataset Allociné (critiques de films en français) en quelques dizaines de lignes.

Hide code cell source

# Visualisation du workflow Hugging Face
fig, ax = plt.subplots(figsize=(14, 5))
ax.set_xlim(0, 10); ax.set_ylim(0, 4)
ax.axis('off')

# Étapes du workflow
etapes = [
    (1, 3, "Model Hub\n[1] Choisir\nun modèle", '#4C72B0'),
    (3, 3, "Tokenizer\n[2] Préparer\nles données", '#DD8452'),
    (5, 3, "Modèle\n[3] Forward\npass", '#55A868'),
    (7, 3, "Trainer\n[4] Fine-\ntuning", '#8B6DAF'),
    (9, 3, "Pipeline\n[5] Déploie-\nment", '#E24A33'),
]

for x, y, texte, couleur in etapes:
    rect = plt.Rectangle((x-0.8, y-1), 1.6, 2, facecolor=couleur,
                          alpha=0.2, edgecolor=couleur, linewidth=2, zorder=2)
    ax.add_patch(rect)
    ax.text(x, y, texte, ha='center', va='center', fontsize=9, fontweight='bold')

# Flèches
for i in range(len(etapes) - 1):
    ax.annotate('', xy=(etapes[i+1][0]-0.8, etapes[i+1][1]),
                xytext=(etapes[i][0]+0.8, etapes[i][1]),
                arrowprops=dict(arrowstyle='->', color='gray', lw=2))

ax.set_title("Workflow Hugging Face Transformers", fontsize=14, fontweight='bold', pad=15)
plt.tight_layout()
plt.show()
_images/28b5f98cf1ec7402b18bdbc338a77904289dfc565bfc590c966a9cdd04f9ace0.png

Applications du NLP#

Le NLP couvre un spectre très large d’applications. Cette section présente les tâches les plus importantes avec leurs formulations et leurs modèles associés.

Classification de texte#

La classification de texte consiste à attribuer une ou plusieurs catégories à un document. C’est la tâche NLP la plus courante, avec des applications comme l’analyse de sentiments, la détection de spam, la catégorisation de documents et la détection de langue.

Exemple 34 (Tâches de classification de texte)

Tâche

Entrée

Sortie

Modèle typique

Analyse de sentiments

« Ce restaurant est excellent »

Positif

BERT fine-tuné

Détection de spam

« Gagnez 1000€ maintenant ! »

Spam

CamemBERT fine-tuné

Catégorisation

Article de presse

Sport / Politique / …

RoBERTa fine-tuné

Inférence textuelle

Prémisse + Hypothèse

Contradiction / Implication / Neutre

BERT fine-tuné

Reconnaissance d’entités nommées (NER)#

La reconnaissance d’entités nommées (Named Entity Recognition, NER) consiste à identifier et classifier les entités mentionnées dans un texte : personnes, organisations, lieux, dates, montants, etc.

Définition 286 (Reconnaissance d’entités nommées)

Le NER est une tâche de classification au niveau du token : pour chaque token \(w_t\) d’une séquence, le modèle prédit une étiquette \(y_t \in \{\text{O}, \text{B-PER}, \text{I-PER}, \text{B-ORG}, \text{I-ORG}, \text{B-LOC}, \text{I-LOC}, \ldots\}\) selon le schéma BIO (Begin, Inside, Outside) :

\[\hat{y}_t = \arg\max_c \; \text{softmax}(\mathbf{W} \cdot f_\theta(\mathbf{x})_t + \mathbf{b})_c\]

Exemple :

Token

Victor

Hugo

est

à

Besançon

Étiquette

B-PER

I-PER

O

O

O

B-LOC

Hide code cell source

# Illustration du NER
phrase_ner = "Victor Hugo est né à Besançon en 1802."
tokens_ner = phrase_ner.split()
etiquettes = ["B-PER", "I-PER", "O", "O", "O", "B-LOC", "O", "B-DATE"]

# Couleurs par type d'entité
couleur_map = {
    "PER": "#E24A33", "LOC": "#4C72B0", "DATE": "#55A868", "O": "#CCCCCC"
}

fig, ax = plt.subplots(figsize=(14, 2.5))
ax.set_xlim(-0.5, len(tokens_ner) - 0.5); ax.set_ylim(-0.5, 1.5)
ax.axis('off')

for i, (token, etiq) in enumerate(zip(tokens_ner, etiquettes)):
    # Déterminer la couleur
    if etiq == "O":
        c = couleur_map["O"]
    else:
        typ = etiq.split("-")[1]
        c = couleur_map.get(typ, "#CCCCCC")

    rect = plt.Rectangle((i-0.45, 0), 0.9, 1, facecolor=c, alpha=0.3,
                          edgecolor=c, linewidth=2)
    ax.add_patch(rect)
    ax.text(i, 0.5, token, ha='center', va='center', fontsize=12, fontweight='bold')
    ax.text(i, -0.3, etiq, ha='center', va='center', fontsize=9, color=c, fontweight='bold')

ax.set_title("Reconnaissance d'entités nommées (NER)", fontsize=12, pad=10)
plt.tight_layout()
plt.show()
_images/d8f6e7ae621f4e8ea4b56dba47efe89801dd2b1ee4d8a9987c6c5d862deb67b3.png

Question-réponse (Question Answering)#

La tâche de question-réponse consiste à extraire ou générer une réponse à une question à partir d’un contexte donné.

Exemple 35 (Types de question-réponse)

Il existe principalement deux paradigmes :

  1. QA extractif : la réponse est un extrait (span) du contexte. Le modèle prédit les positions de début et de fin de la réponse.

    • Contexte : « BERT a été proposé par Devlin et al. en 2018 chez Google. »

    • Question : « Quand BERT a-t-il été proposé ? »

    • Réponse : « en 2018 » (positions 8 à 9)

  2. QA génératif : la réponse est générée librement, sans contrainte d’extraction. Les modèles T5 et GPT sont adaptés à cette approche.

Résumé automatique et traduction#

Le résumé automatique et la traduction automatique sont des tâches de type séquence-vers-séquence (seq2seq) naturellement traitées par les architectures encodeur-décodeur (T5, mBART) ou les modèles autorégressifs (GPT). Comme étudié au chapitre 23, le mécanisme d’attention croisée du Transformer est particulièrement adapté à ces tâches, car il permet au décodeur de se concentrer sur les parties pertinentes de la séquence d’entrée.

Hide code cell source

# Résumé des tâches NLP et modèles associés
taches = [
    "Classification\nde texte",
    "NER",
    "Question-\nréponse",
    "Résumé\nautomatique",
    "Traduction",
    "Génération\nde texte",
]
modeles = [
    "BERT, CamemBERT",
    "BERT + CRF",
    "BERT (extractif)\nT5 (génératif)",
    "T5, BART,\nPegasus",
    "T5, mBART,\nMarianMT",
    "GPT, LLaMA,\nMistral",
]
complexites = [1, 2, 3, 4, 4, 5]  # Complexité relative

fig, ax = plt.subplots(figsize=(12, 5))
colors = sns.color_palette("viridis", len(taches))
bars = ax.barh(range(len(taches)), complexites, color=colors,
               edgecolor='white', height=0.6)

for i, (tache, modele) in enumerate(zip(taches, modeles)):
    ax.text(-0.3, i, tache, ha='right', va='center', fontsize=9, fontweight='bold')
    ax.text(complexites[i] + 0.15, i, modele, ha='left', va='center', fontsize=8,
            color='gray')

ax.set_xlim(-2.5, 8)
ax.set_yticks([])
ax.set_xlabel("Complexité relative de la tâche")
ax.set_title("Panorama des tâches NLP et modèles associés", fontsize=13, fontweight='bold')
ax.axvline(x=0, color='gray', linewidth=0.5)
plt.tight_layout()
plt.show()
_images/ff285b681d2bc479e6d89df7e9b543c21f6c8b8897f8f3a8b852905270425a17.png

Hide code cell source

# Chronologie des avancées majeures en NLP
fig, ax = plt.subplots(figsize=(14, 3.5))
ax.set_xlim(2012, 2025); ax.set_ylim(-1.5, 2)
ax.axis('off')
ax.set_title("Chronologie des avancées majeures en NLP", fontsize=12, pad=15)

events = [
    (2013, "Word2Vec\n(Mikolov)", '#4C72B0'),
    (2014, "GloVe\n(Pennington)", '#55A868'),
    (2017, "Transformer\n(Vaswani)", '#DD8452'),
    (2018, "BERT / GPT\n(Devlin / Radford)", '#E24A33'),
    (2019, "GPT-2 / T5\n(Radford / Raffel)", '#8B6DAF'),
    (2020, "GPT-3\n(Brown)", '#C44E52'),
    (2022, "ChatGPT\n(OpenAI)", '#937860'),
    (2023, "LLaMA / Mistral", '#4C72B0'),
]

ax.axhline(y=0, color='gray', linewidth=2, alpha=0.3, xmin=0.02, xmax=0.98)
for i, (year, label, color) in enumerate(events):
    y_offset = 0.6 if i % 2 == 0 else -0.8
    ax.plot(year, 0, 'o', color=color, markersize=10, zorder=5)
    ax.annotate(f"{year}\n{label}", xy=(year, 0),
                xytext=(year, y_offset),
                fontsize=7.5, ha='center', va='bottom' if y_offset > 0 else 'top',
                color=color, fontweight='bold',
                arrowprops=dict(arrowstyle='-', color=color, alpha=0.5))

plt.tight_layout()
plt.show()
_images/d89a8dba933b56c4010be068d5f258518f0747b824f113d2085591494118c1b0.png

Résumé#

Ce chapitre a présenté les concepts fondamentaux du traitement du langage naturel, depuis la représentation du texte jusqu’aux modèles pré-entraînés modernes.

  1. La tokenisation est la première étape de tout pipeline NLP. La tokenisation par sous-mots (BPE, WordPiece, SentencePiece) offre le meilleur compromis entre gestion du vocabulaire ouvert et longueur des séquences.

  2. Les représentations vectorielles des mots (Word2Vec, GloVe) permettent de capturer la sémantique dans un espace continu de faible dimension, où les relations entre mots se traduisent par des régularités géométriques (analogies, similarités).

  3. Les modèles de langue assignent des probabilités aux séquences de mots. Des modèles N-gram aux réseaux de neurones, leur qualité se mesure par la perplexité.

  4. Les modèles pré-entraînés — BERT (encodeur bidirectionnel), GPT (décodeur autorégressif) et T5 (encodeur-décodeur) — ont transformé le NLP en montrant que des représentations universelles du langage peuvent être apprises de manière auto-supervisée sur de grands corpus.

  5. Le fine-tuning permet d’adapter ces modèles massifs à des tâches spécifiques avec peu de données étiquetées, constituant le paradigme dominant du NLP moderne.

  6. L’écosystème Hugging Face (Transformers, Model Hub, Datasets) fournit une interface unifiée et accessible pour exploiter ces modèles en quelques lignes de code.

  7. Les applications du NLP couvrent un spectre très large : classification, NER, question-réponse, résumé, traduction et génération de texte. La tendance actuelle est aux grands modèles de langue (Large Language Models, LLM) de plus en plus polyvalents.

Remarque 244

Le NLP a connu une accélération sans précédent depuis l’introduction du Transformer (chapitre 23). Les frontières entre comprendre et générer du texte s’estompent avec l’émergence de modèles de plus en plus grands et polyvalents. Cependant, les concepts fondamentaux — tokenisation, embeddings, modèles de langue, fine-tuning — restent les briques élémentaires indispensables pour comprendre, utiliser et évaluer ces systèmes. La maîtrise de ces fondamentaux permet de naviguer avec discernement dans un domaine en évolution rapide.