Tokenisation et embeddings#

Le chapitre précédent a introduit l’architecture Transformer et les grands modèles de langage (LLM). Cependant, avant qu’un texte puisse être traité par un Transformer, il doit être converti en une séquence de nombres entiers — les token IDs — puis projeté dans un espace vectoriel continu via une matrice d’embeddings. Cette double transformation conditionne profondément les capacités et les limites de tout LLM : le choix du tokenizer détermine combien de tokens sont nécessaires pour représenter un texte, quels mots rares peuvent être gérés, et quelles langues sont favorisées ou pénalisées.

Le chapitre 24 du volume Apprentissage automatique a introduit les bases de la tokenisation et les embeddings statiques (Word2Vec, GloVe). Le présent chapitre approfondit ces concepts dans le contexte spécifique des LLM : algorithmes de tokenisation subword (BPE byte-level, WordPiece, SentencePiece), vocabulaires et tokens spéciaux des modèles modernes, distinction entre embeddings statiques et contextuels, et sentence embeddings pour représenter des phrases entières dans un espace vectoriel.

L’approche est pratique : nous utiliserons le tokenizer de GPT-2 (vocabulaire d’environ 1 Mo, aucun poids de modèle) et le modèle de sentence embeddings all-MiniLM-L6-v2 (environ 80 Mo) pour illustrer ces concepts par du code exécutable. L’empreinte mémoire totale reste bien inferieure à 1 Go.

Rappel et approfondissement : tokenisation subword#

La tokenisation par mots souffre d’un vocabulaire trop large et de l’impossibilité de traiter les mots hors vocabulaire (OOV), tandis que la tokenisation par caractères produit des séquences trop longues. La tokenisation par sous-mots (subword tokenization) est le compromis adopté par tous les LLM modernes : les mots fréquents restent intacts, les mots rares sont décomposés en sous-unités connues.

Définition 8 (Byte Pair Encoding au niveau des octets)

Le Byte Pair Encoding (BPE) au niveau des octets (byte-level BPE), tel qu’utilisé par GPT-2 et GPT-3, opère directement sur les octets bruts du texte encode en UTF-8, plutôt que sur des caractères Unicode. L’algorithme procède comme suit :

  1. Initialisation : le vocabulaire de base contient les 256 valeurs d’octets possibles (0x00 a 0xFF).

  2. Comptage des paires : pour chaque paire de tokens adjacents dans le corpus d’entrainement, compter le nombre d’occurrences.

  3. Fusion : fusionner la paire la plus fréquente en un nouveau token et l’ajouter au vocabulaire.

  4. Iteration : répéter les étapes 2-3 jusqu’à atteindre la taille de vocabulaire desirée \(|\mathcal{V}|\).

Le résultat est un vocabulaire de \(|\mathcal{V}|\) tokens, composé des 256 octets initiaux plus \(|\mathcal{V}| - 256\) tokens issus des fusions. Tout texte peut être tokenisé sans token inconnu, puisque le niveau de repli est l’octet individuel.

L’avantage du BPE byte-level est sa couverture universelle : il peut représenter n’importe quel texte dans n’importe quelle langue, y compris les emojis et les scripts rares, ce qui en fait le choix dominant pour les LLM multilingues.

Définition 9 (SentencePiece et le modèle unigram)

SentencePiece (Kudo et Richardson, 2018) est un framework de tokenisation qui traite le texte comme une séquence brute de caractères Unicode, sans pré-tokenisation par espaces. Il prend en charge deux algorithmes :

  1. BPE : identique è l’algorithme décrit ci-dessus, mais applique directement sur la chaine brute.

  2. Modèle unigram : part d’un vocabulaire initial très large et retire itérativement les tokens dont la suppression minimise la perte sur le corpus. La probabilité d’une segmentation \(\mathbf{x} = (x_1, \ldots, x_M)\) est :

\[P(\mathbf{x}) = \prod_{i=1}^{M} p(x_i)\]

ou \(p(x_i)\) est la probabilité unigram du token \(x_i\), estimée par maximum de vraisemblance. La segmentation optimale est trouvée par l’algorithme de Viterbi.

SentencePiece est utilisé par T5, LLaMA, Mistral et de nombreux modèles multilingues. L’espace est traité comme un caractère ordinaire (souvent noté _), ce qui rend la tokenisation entièrement réversible.

Remarque 7

Le BPE byte-level et SentencePiece diffèrent dans leur traitement des espaces et de la pré-tokenisation. Le BPE de GPT-2 applique d’abord une pré-tokenisation par regex qui sépare les mots, les nombres et la ponctuation, puis applique BPE sur chaque segment. SentencePiece, en revanche, traite le texte brut sans aucune pré-segmentation, ce qui le rend plus naturel pour les langues sans espaces (chinois, japonais, thai). Cette différence explique pourquoi les modèles basés sur SentencePiece tendent à avoir une meilleure couverture multilingue.

WordPiece, utilisé par BERT, est similaire a BPE mais sélectionne les fusions qui maximisent la vraisemblance d’un modèle de langue unigram plutôt que la simple fréquence de paires. La distinction principale réside dans la convention de notation : WordPiece marque les sous-mots en continuation par un préfixe ##, tandis que BPE et SentencePiece marquent le début de mot par un caractère spécial (_).

Hide code cell source

# Import des librairies Python
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from collections import Counter
from tqdm.notebook import trange, tqdm

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

Hide code cell source

# Implémentation pédagogique de BPE pas à pas
def bpe_learn(corpus, num_merges):
    """Apprend un vocabulaire BPE sur un corpus de mots avec fréquences."""
    vocab = {}
    for mot, freq in corpus.items():
        symboles = ' '.join(list(mot)) + ' </w>'
        vocab[symboles] = freq

    merges = []
    for step in range(num_merges):
        paires = Counter()
        for symboles, freq in vocab.items():
            tokens = symboles.split()
            for j in range(len(tokens) - 1):
                paires[(tokens[j], tokens[j + 1])] += freq
        if not paires:
            break
        meilleure = max(paires, key=paires.get)
        merges.append((meilleure, paires[meilleure]))
        bigram = ' '.join(meilleure)
        remplacement = ''.join(meilleure)
        nouveau_vocab = {}
        for symboles, freq in vocab.items():
            nouveau_vocab[symboles.replace(bigram, remplacement)] = freq
        vocab = nouveau_vocab
    return vocab, merges

corpus = {
    "nouveau": 8, "nouvelle": 5, "nouveaux": 3,
    "apprendre": 7, "apprentissage": 6,
    "machine": 10, "machines": 4,
}
vocab_final, fusions = bpe_learn(corpus, 10)

print("Fusions BPE (10 premières) :")
print("-" * 50)
for i, (paire, freq) in enumerate(fusions):
    gauche, droite = paire
    print(f"  Etape {i+1:2d} : '{gauche}' + '{droite}' -> "
          f"'{gauche}{droite}'  (frequence : {freq})")
Fusions BPE (10 premières) :
--------------------------------------------------
  Etape  1 : 'e' + '</w>' -> 'e</w>'  (frequence : 28)
  Etape  2 : 'n' + 'o' -> 'no'  (frequence : 16)
  Etape  3 : 'no' + 'u' -> 'nou'  (frequence : 16)
  Etape  4 : 'nou' + 'v' -> 'nouv'  (frequence : 16)
  Etape  5 : 'nouv' + 'e' -> 'nouve'  (frequence : 16)
  Etape  6 : 'm' + 'a' -> 'ma'  (frequence : 14)
  Etape  7 : 'ma' + 'c' -> 'mac'  (frequence : 14)
  Etape  8 : 'mac' + 'h' -> 'mach'  (frequence : 14)
  Etape  9 : 'mach' + 'i' -> 'machi'  (frequence : 14)
  Etape 10 : 'machi' + 'n' -> 'machin'  (frequence : 14)

Hide code cell source

# Visualisation du processus de fusion BPE
fig, ax = plt.subplots(figsize=(10, 5))
étapes = list(range(1, len(fusions) + 1))
frequences = [f for _, f in fusions]
labels = [f"'{p[0]}'+'{p[1]}'" for p, _ in fusions]

bars = ax.bar(étapes, frequences, color=sns.color_palette("viridis", len(étapes)),
              edgecolor='white', linewidth=0.8)
ax.set_xlabel("Etape de fusion")
ax.set_ylabel("Fréquence de la paire")
ax.set_title("Processus de fusion BPE : fréquence de la paire fusionnée à chaque étape")
ax.set_xticks(étapes)
for bar, label in zip(bars, labels):
    ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.3,
            label, ha='center', va='bottom', fontsize=7, rotation=35)
plt.show()
_images/cdee792031a3cbe759c534e1193a8f53b6184bd3a4df496d55333ad869fd421c.png

Tokenizers des LLM modernes#

Les LLM modernes utilisent tous une tokenisation subword, mais diffèrent par la taille de leur vocabulaire, le choix de l’algorithme et les tokens spéciaux.

Remarque 8

La taille du vocabulaire \(|\mathcal{V}|\) est un compromis fondamental :

  • Vocabulaire petit (\(|\mathcal{V}| \approx 32\,000\), LLaMA) : les mots sont souvent décomposés en plusieurs tokens, ce qui allonge les séquences et augmente le coût en attention (\(O(T^2)\)). En revanche, la matrice d’embeddings \(E \in \mathbb{R}^{|\mathcal{V}| \times d}\) est plus compacte.

  • Vocabulaire grand (\(|\mathcal{V}| \approx 100\,000\), Claude) : les mots courants dans de nombreuses langues sont représentés par un seul token, ce qui raccourcit les séquences. La matrice d’embeddings est plus volumineuse, mais le gain en longueur de séquence compense largement.

Modèle

\(\lvert\mathcal{V}\rvert\)

Algorithme

Note

GPT-2, GPT-3

50 257

BPE byte-level

256 octets + 50 000 fusions + 1 token special

BERT

30 522

WordPiece

Vocabulaire anglais-centrique

LLaMA 2

32 000

SentencePiece (BPE)

Optimisé pour l’anglais

LLaMA 3

128 000

SentencePiece (BPE)

Vocabulaire multilingue élargi

Claude

~100 000

BPE byte-level

Bon équilibre multilingue

Mistral

32 000

SentencePiece (BPE)

Similaire à LLaMA 2

Hide code cell source

# Charger le tokenizer GPT-2 (~1 Mo, pas de poids de modele)
from transformers import GPT2Tokenizer
tokenizer = GPT2Tokenizer.from_pretrained("gpt2")
print(f"Taille du vocabulaire GPT-2 : {tokenizer.vocab_size:,} tokens")
Taille du vocabulaire GPT-2 : 50,257 tokens

Hide code cell source

# Tokeniser du texte en francais et en anglais
texte_fr = "L'apprentissage automatique transforme notre compréhension de l'intelligence artificielle."
texte_en = "Machine learning transforms our understanding of artificial intelligence."
for label, texte in [("Français", texte_fr), ("Anglais", texte_en)]:
    toks = tokenizer.tokenize(texte)
    print(f"=== {label} ({len(toks)} tokens) ===")
    print(f"  Texte  : {texte}")
    print(f"  Tokens : {toks}")
    print(f"  IDs    : {tokenizer.encode(texte)}\n")
print(f"Ratio français/anglais : {len(tokenizer.tokenize(texte_fr))/len(tokenizer.tokenize(texte_en)):.2f}x")
=== Français (25 tokens) ===
  Texte  : L'apprentissage automatique transforme notre compréhension de l'intelligence artificielle.
  Tokens : ['L', "'", 'app', 'rent', 'iss', 'age', 'Ġautom', 'at', 'ique', 'Ġtransform', 'e', 'Ġnot', 're', 'Ġcompr', 'é', 'hens', 'ion', 'Ġde', 'Ġl', "'", 'intelligence', 'Ġartific', 'iel', 'le', '.']
  IDs    : [43, 6, 1324, 1156, 747, 496, 3557, 265, 2350, 6121, 68, 407, 260, 12084, 2634, 5135, 295, 390, 300, 6, 32683, 29829, 8207, 293, 13]

=== Anglais (9 tokens) ===
  Texte  : Machine learning transforms our understanding of artificial intelligence.
  Tokens : ['Machine', 'Ġlearning', 'Ġtransforms', 'Ġour', 'Ġunderstanding', 'Ġof', 'Ġartificial', 'Ġintelligence', '.']
  IDs    : [37573, 4673, 31408, 674, 4547, 286, 11666, 4430, 13]

Ratio français/anglais : 2.78x

Remarque 9

La fertilité (fertility) d’un tokenizer est le nombre moyen de tokens produits par mot. Elle varie considérablement selon la langue : un tokenizer entrainé principalement sur de l’anglais produit typiquement 1.0 à 1.3 tokens par mot en anglais, mais 1.5 à 2.5 tokens par mot en francais, et 3 à 5 tokens par caractère en chinois ou en arabe. Cette asymétrie a des conséquences directes :

  • Coût : à nombre de tokens facturés identique, un texte français ou chinois coûte plus cher à traiter.

  • Fenêtre de contexte : le même texte occupe plus de tokens, laissant moins de place pour le contexte.

  • Qualité : un mot fragmenté en de nombreux sous-tokens est plus difficile à traiter pour le modèle.

Hide code cell source

# Comparaison de la fertilité sur plusieurs langues
textes = {
    "Anglais": "Artificial intelligence is transforming every aspect of modern society.",
    "Français": "L'intelligence artificielle transforme chaque aspect de la société moderne.",
    "Allemand": "Kunstliche Intelligenz verandert jeden Aspekt der modernen Gesellschaft.",
    "Espagnol": "La inteligencia artificial esta transformando cada aspecto de la sociedad moderna.",
    "Chinois": "人工智能正在改变现代社会的方方面面。",
}

langues, nb_tokens = [], []
for langue, texte in textes.items():
    n = len(tokenizer.tokenize(texte))
    langues.append(langue); nb_tokens.append(n)
    print(f"{langue:10s} : {n:3d} tokens")

fig, ax = plt.subplots(figsize=(10, 5))
bars = ax.barh(langues, nb_tokens, color=sns.color_palette("Set2", len(langues)),
               edgecolor='white', height=0.6)
ax.set_xlabel("Nombre de tokens (GPT-2 tokenizer)")
ax.set_title("Fertilité du tokenizer GPT-2 : même phrase en 5 langues")
for bar, n in zip(bars, nb_tokens):
    ax.text(bar.get_width() + 0.5, bar.get_y() + bar.get_height() / 2,
            str(n), ha='left', va='center', fontsize=11, fontweight='bold')
ax.axvline(x=nb_tokens[0], color='gray', linestyle='--', alpha=0.4)
plt.show()
Anglais    :  11 tokens
Français   :  20 tokens
Allemand   :  24 tokens
Espagnol   :  21 tokens
Chinois    :  36 tokens
_images/d90914351a41e2e5940634541b1a512632b9150e3edc99b1700006498e6cbde8.png

Exemple 5 (Tokens spéciaux dans les LLM)

Les LLM utilisent des tokens spéciaux qui ne correspondent pas à du texte mais servent à structurer l’entrée et la sortie :

Token

Rôle

Modèles

<BOS> / <s>

Début de séquence (Beginning of Sequence)

LLaMA, Mistral

<EOS> / </s>

Fin de séquence (End of Sequence)

GPT-2, LLaMA

<PAD>

Remplissage pour aligner les longueurs dans un batch

BERT, T5

<UNK>

Token inconnu (rare avec BPE byte-level)

BERT

[CLS] / [SEP]

Classification et séparation de segments

BERT

`<

endoftext

>`

`<

system

>, <

Exemple avec GPT-2 :

tokenizer = GPT2Tokenizer.from_pretrained("gpt2")
texte = "Bonjour le monde"
ids = tokenizer.encode(texte)
# Décoder token par token
for tid in ids:
    print(f"  ID {tid:6d} -> '{tokenizer.decode([tid])}'")

Hide code cell source

# Tokens spéciaux et décodage token par token
print(f"Token EOS : '{tokenizer.eos_token}' (ID: {tokenizer.eos_token_id})")
print(f"Token BOS : '{tokenizer.bos_token}' (ID: {tokenizer.bos_token_id})")

texte = "Bonjour le monde"
ids = tokenizer.encode(texte)
print(f"\nTexte : '{texte}' -> IDs : {ids}")
for tid in ids:
    print(f"  ID {tid:6d} -> '{tokenizer.decode([tid])}'")
Token EOS : '<|endoftext|>' (ID: 50256)
Token BOS : '<|endoftext|>' (ID: 50256)

Texte : 'Bonjour le monde' -> IDs : [20682, 73, 454, 443, 285, 14378]
  ID  20682 -> 'Bon'
  ID     73 -> 'j'
  ID    454 -> 'our'
  ID    443 -> ' le'
  ID    285 -> ' m'
  ID  14378 -> 'onde'

Exemple 6 (Tokenisation GPT-2 en détail)

Le tokenizer de GPT-2 utilise un BPE byte-level avec un vocabulaire de 50 257 tokens. Voici comment il segmente différents types de texte :

tokenizer = GPT2Tokenizer.from_pretrained("gpt2")

# Mot courant en anglais : un seul token
tokenizer.tokenize("hello")       # ['hello']

# Mot français avec accent : potentiellement fragmenté
tokenizer.tokenize("reussite")    # ['re', 'uss', 'ite']

# Mot rare : fortement decoupé
tokenizer.tokenize("anticonstitutionnellement")
# ['ant', 'icon', 'stit', 'ution', 'n', 'ellement']

Le préfixe G (lettre G avec un espace avant) dans l’affichage des tokens GPT-2 représente un espace. Cela reflète le fait que GPT-2 encode les espaces comme faisant partie du token suivant.

Du token à l’embedding contextuel#

Une fois le texte tokenisé en une séquence d’identifiants \((t_1, \ldots, t_T)\), chaque token est projeté dans un espace vectoriel continu par une matrice d’embeddings \(E \in \mathbb{R}^{|\mathcal{V}| \times d}\). Le vecteur initial \(\mathbf{e}_i = E[t_i]\) est un embedding statique, identique quel que soit le contexte.

Définition 10 (Embedding contextuel)

Un embedding contextuel est une représentation vectorielle d’un token qui dépend de l’ensemble de la séquence dans laquelle il apparait. Formellement, étant donné une séquence de tokens \((t_1, \ldots, t_T)\) et un modèle Transformer à \(L\) couches, l’embedding contextuel du token \(t_i\) à la couche \(l\) est :

\[\mathbf{h}_i^{(l)} = \text{TransformerBlock}^{(l)}(\mathbf{h}_1^{(l-1)}, \ldots, \mathbf{h}_T^{(l-1)})_i\]

avec \(\mathbf{h}_i^{(0)} = E[t_i] + PE_i\) (embedding statique + encodage positionnel). Chaque couche Transformer raffine la représentation en intégrant l’information contextuelle via le mécanisme d’auto-attention. Le même mot « avocat » recevra des représentations différentes dans :

  • « L”avocat plaide devant le tribunal. » (profession)

  • « L”avocat est mûr, je vais faire du guacamole. » (fruit)

Contrairement aux embeddings statiques (Word2Vec, GloVe) ou \(\mathbf{v}_{\text{avocat}}\) est un vecteur unique, les embeddings contextuels produisent \(\mathbf{h}_{\text{avocat}}^{(L)}\) différent selon le contexte.

Les embeddings statiques capturent le sens moyen d’un mot sur l’ensemble du corpus, tandis que les embeddings contextuels capturent le sens spécifique dans chaque contexte d’utilisation. Cette distinction est l’une des avancées fondamentales des modèles modernes.

Remarque 10

Pour obtenir une représentation unique d’une séquence entière (une phrase, un paragraphe) à partir des embeddings contextuels des tokens individuels, on utilise une stratégie de pooling :

  • CLS pooling : prendre le vecteur du token spécial [CLS] placé en début de séquence (approche de BERT). Ce token est entrainé pour agréger l’information de toute la séquence via l’auto-attention.

  • Mean pooling : calculer la moyenne des embeddings de tous les tokens : \(\mathbf{h}_{\text{phrase}} = \frac{1}{T} \sum_{i=1}^{T} \mathbf{h}_i^{(L)}\). Souvent supérieur au CLS pooling pour les sentence embeddings.

  • Max pooling : prendre le maximum élément par élément : \((\mathbf{h}_{\text{phrase}})_j = \max_i (\mathbf{h}_i^{(L)})_j\).

Le mean pooling est aujourd’hui la stratégie dominante pour les sentence embeddings, car elle donne un poids égal à tous les tokens de la séquence.

Propriété 2 (Structure de l’espace d’embeddings)

L’espace des embeddings contextuels possède des propriétés géométriques remarquables :

  1. Anisotropie : les embeddings des LLM tendent à occuper un cône étroit dans \(\mathbb{R}^d\), ce qui signifie que la similarité cosinus moyenne entre deux tokens quelconques est souvent élevée (\(\sim 0.5\) a \(0.8\)). Cela peut fausser les recherches par similarité si l’on n’applique pas de normalisation.

  2. Clustering sémantique : les tokens sémantiquement proches forment des clusters. Les couches profondes du Transformer produisent des représentations ou la structure sémantique est plus marquée que les structures syntaxiques.

  3. Linearité des relations : comme pour Word2Vec, certaines relations sémantiques se traduisent par des directions linéaires dans l’espace contextuel.

Sentence embeddings et sentence-transformers#

Les embeddings contextuels produits par BERT ou GPT ne sont pas directement utilisables pour comparer des phrases. Le pooling naif des représentations de BERT donne des sentence embeddings de mauvaise qualité : les cosinus entre phrases quelconques sont presque tous dans \([0.6, 1.0]\), rendant la discrimination impossible.

Définition 11 (Sentence embedding)

Un sentence embedding est un vecteur \(\mathbf{s} \in \mathbb{R}^d\) représentant le sens d’une phrase entière dans un espace vectoriel ou la proximité géométrique correspond à la similarité sémantique :

\[\text{sim}(\text{phrase}_A, \text{phrase}_B) \approx \cos(\mathbf{s}_A, \mathbf{s}_B) = \frac{\mathbf{s}_A \cdot \mathbf{s}_B}{\|\mathbf{s}_A\| \cdot \|\mathbf{s}_B\|}\]

Deux paraphrases ont une similarité proche de 1, deux phrases sans rapport une similarité proche de 0, et des phrases partiellement liées une similarité intermediaire.

Définition 12 (Similarité cosinus)

La similarité cosinus entre deux vecteurs \(\mathbf{a}, \mathbf{b} \in \mathbb{R}^d\) est définie par :

\[\cos(\mathbf{a}, \mathbf{b}) = \frac{\mathbf{a} \cdot \mathbf{b}}{\|\mathbf{a}\| \cdot \|\mathbf{b}\|}\]

Elle mesure l’angle entre les deux vecteurs, indépendamment de leur norme : \(\cos(\mathbf{a}, \mathbf{b}) \in [-1, 1]\). Une valeur de 1 indique des vecteurs colinéaires (sémantiquement identiques), 0 des vecteurs orthogonaux (sans lien), et \(-1\) des vecteurs opposés. La similarité cosinus est préférée à la distance euclidienne pour les embeddings de haute dimension, car elle est invariante par mise à l’échelle.

Définition 13 (Apprentissage contrastif)

L”apprentissage contrastif (contrastive learning) est le paradigme d’entrainement des sentence embeddings. Le modèle apprend à rapprocher les représentations de phrases similaires (positives) et à éloigner celles de phrases dissimilaires (négatives). La perte typique (Multiple Negatives Ranking Loss) est :

\[\mathcal{L} = -\log \frac{\exp(\cos(\mathbf{s}_i, \mathbf{s}_i^+) / \tau)}{\sum_{j=1}^{N} \exp(\cos(\mathbf{s}_i, \mathbf{s}_j^+) / \tau)}\]

ou \(\mathbf{s}_i^+\) est l’embedding de la phrase positive, \(\tau\) la température, et les autres phrases du batch servent de négatifs. Sentence-BERT (Reimers et Gurevych, 2019) a été le premier modèle à appliquer cette approche à BERT.

Remarque 11

Les sentence embeddings modernes utilisent souvent la technique Matryoshka Representation Learning (MRL), qui entraine le modèle de sorte que les \(k\) premières dimensions de l’embedding (pour tout \(k \leq d\)) soient elles-mêmes un embedding valide de qualité degradée. Cela permet d’adapter dynamiquement la dimension de stockage au budget mémoire. Le modèle all-MiniLM-L6-v2 ne supporte pas MRL, mais des modèles plus récents comme nomic-embed-text-v1.5 l’intègrent.

Hide code cell source

# Charger le modèle de sentence embeddings (~80 Mo)
import logging
from sentence_transformers import SentenceTransformer

logging.getLogger("sentence_transformers").setLevel(logging.ERROR)
model = SentenceTransformer('all-MiniLM-L6-v2')
print(f"Modèle chargé : all-MiniLM-L6-v2")
print(f"Dimension des embeddings : {model.get_sentence_embedding_dimension()}")
BertModel LOAD REPORT from: sentence-transformers/all-MiniLM-L6-v2
Key                     | Status     |  | 
------------------------+------------+--+-
embeddings.position_ids | UNEXPECTED |  | 

Notes:
- UNEXPECTED	:can be ignored when loading from different task/architecture; not ok if you expect identical arch.
Modèle chargé : all-MiniLM-L6-v2
Dimension des embeddings : 384

Hide code cell source

# Encoder des phrases sur des sujets variés
phrases = [
    # IA
    "Les réseaux de neurones apprennent à partir de données.",
    "L'intelligence artificielle transforme de nombreux secteurs.",
    "Le deep learning utilise des couches de neurones empilées.",
    # Cuisine
    "La tarte aux pommes se prépare avec de la pâte brisée.",
    "Il faut faire revenir les oignons dans un peu d'huile.",
    "La cuisine française est reputée dans le monde entier.",
    # Sport
    "Le marathon de Paris rassemble des milliers de coureurs.",
    "L'entrainement physique améliore les performances sportives.",
    "Le football est le sport le plus populaire au monde.",
    # Astronomie
    "Les étoiles naissent dans des nuages de gaz et de poussière.",
    "La Voie lactée contient des centaines de milliards d'étoiles.",
    "Les télescopes spatiaux observent l'univers lointain.",
    # Musique
    "Le piano est un instrument à cordes frappées.",
    "Mozart a composé plus de 600 oeuvres musicales.",
    "La symphonie est une forme musicale pour orchestre.",
]
sujets = ["IA"] * 3 + ["Cuisine"] * 3 + ["Sport"] * 3 + ["Astronomie"] * 3 + ["Musique"] * 3

embeddings = model.encode(phrases, show_progress_bar=False)
print(f"Forme de la matrice d'embeddings : {embeddings.shape}")
print(f"  -> {len(phrases)} phrases x {embeddings.shape[1]} dimensions")
Forme de la matrice d'embeddings : (15, 384)
  -> 15 phrases x 384 dimensions

Hide code cell source

# Heatmap de similarité cosinus
from sklearn.metrics.pairwise import cosine_similarity

sim_matrix = cosine_similarity(embeddings)
labels_courts = [p[:42] + "..." if len(p) > 42 else p for p in phrases]

fig, ax = plt.subplots(figsize=(12, 10))
sns.heatmap(sim_matrix, annot=True, fmt=".2f", cmap="YlOrRd",
            xticklabels=labels_courts, yticklabels=labels_courts,
            ax=ax, linewidths=0.5, square=True, vmin=0, vmax=1,
            annot_kws={"fontsize": 6})
ax.set_xticklabels(ax.get_xticklabels(), rotation=45, ha='right', fontsize=7)
ax.set_yticklabels(ax.get_yticklabels(), fontsize=7)
ax.set_title("Matrice de similarité cosinus entre sentence embeddings", fontsize=12)
plt.show()
_images/c0657a6e25a89e18fc81b951f7f2d0c93e1b794f90d45275b38ab230108a6d7a.png

Hide code cell source

# Démonstration de recherche sémantique
def recherche_semantique(requete, corpus, corpus_emb, model, top_k=3):
    """Retourne les top-k phrases les plus similaires à la requête."""
    q_emb = model.encode([requete], show_progress_bar=False)
    scores = cosine_similarity(q_emb, corpus_emb)[0]
    idx = np.argsort(scores)[::-1][:top_k]
    return [(corpus[i], scores[i]) for i in idx]

for requete in ["Comment fonctionne l'apprentissage profond ?",
                "Quelle est la recette du gâteau au chocolat ?",
                "Combien y a-t-il de planètes dans notre galaxie ?"]:
    print(f"Requête : \"{requete}\"")
    for i, (phrase, score) in enumerate(recherche_semantique(requete, phrases, embeddings, model)):
        print(f"  {i+1}. [{score:.3f}] {phrase}")
    print()
Requête : "Comment fonctionne l'apprentissage profond ?"
  1. [0.439] L'entrainement physique améliore les performances sportives.
  2. [0.433] Les réseaux de neurones apprennent à partir de données.
  3. [0.409] Le marathon de Paris rassemble des milliers de coureurs.

Requête : "Quelle est la recette du gâteau au chocolat ?"
  1. [0.536] La Voie lactée contient des centaines de milliards d'étoiles.
  2. [0.514] La cuisine française est reputée dans le monde entier.
  3. [0.502] Le marathon de Paris rassemble des milliers de coureurs.

Requête : "Combien y a-t-il de planètes dans notre galaxie ?"
  1. [0.515] La Voie lactée contient des centaines de milliards d'étoiles.
  2. [0.485] Le marathon de Paris rassemble des milliers de coureurs.
  3. [0.473] Les télescopes spatiaux observent l'univers lointain.

Visualisation des espaces vectoriels#

La visualisation des embeddings en deux dimensions permet de vérifier que l’espace vectoriel capture la structure sémantique attendue. Les techniques de réduction de dimensionnalité les plus utilisées sont la PCA (linéaire, préservant les distances globales) et t-SNE ou UMAP (non linéaires, préservant les structures locales).

Hide code cell source

# PCA des sentence embeddings
from sklearn.decomposition import PCA

pca = PCA(n_components=2)
coords_pca = pca.fit_transform(embeddings)
palette_sujets = {"IA": "#4C72B0", "Cuisine": "#DD8452", "Sport": "#55A868",
                  "Astronomie": "#C44E52", "Musique": "#8B6DAF"}

fig, ax = plt.subplots(figsize=(10, 8))
for sujet, couleur in palette_sujets.items():
    idx = [i for i, s in enumerate(sujets) if s == sujet]
    ax.scatter(coords_pca[idx, 0], coords_pca[idx, 1], c=couleur, label=sujet,
               s=120, edgecolor='white', linewidth=0.8, zorder=5)
    for i in idx:
        ax.annotate(phrases[i][:35] + "...", (coords_pca[i, 0], coords_pca[i, 1]),
                    textcoords="offset points", xytext=(8, 5), fontsize=6.5, alpha=0.85)
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("PCA des sentence embeddings (all-MiniLM-L6-v2)")
ax.legend(title="Sujet", fontsize=9, title_fontsize=10)
plt.show()
_images/eebff85ae77dc6f69db99a0e4d8983d26e042f2fb563416088d58318c2901ff2.png

Exemple 8 (Clustering de sentence embeddings)

Les sentence embeddings peuvent être utilisés directement comme entrée d’un algorithme de clustering (K-Means, DBSCAN, clustering hiérarchique). Cela permet de découvrir automatiquement les thèmes présents dans un corpus, sans aucune annotation manuelle. La qualité du clustering dépend directement de la qualité des embeddings : un bon modèle de sentence embeddings produira des clusters bien separés qui correspondent aux sujets réels.

from sklearn.cluster import KMeans

# Clustering automatique des phrases
kmeans = KMeans(n_clusters=5, random_state=42, n_init=10)
clusters = kmeans.fit_predict(embeddings)

# Chaque cluster correspond approximativement à un sujet
for c in range(5):
    print(f"\nCluster {c} :")
    for i, phrase in enumerate(phrases):
        if clusters[i] == c:
            print(f"  - {phrase}")

Hide code cell source

# Clustering K-Means et comparaison avec les sujets reels
from sklearn.cluster import KMeans
from sklearn.metrics import adjusted_rand_score

clusters_pred = KMeans(n_clusters=5, random_state=42, n_init=10).fit_predict(embeddings)

fig, axes = plt.subplots(1, 2, figsize=(13, 6))
for sujet, couleur in palette_sujets.items():
    idx = [i for i, s in enumerate(sujets) if s == sujet]
    axes[0].scatter(coords_pca[idx, 0], coords_pca[idx, 1], c=couleur,
                     label=sujet, s=120, edgecolor='white', linewidth=0.8)
axes[0].set_title("Sujets reels"); axes[0].legend(fontsize=8)
axes[0].set_xlabel(f"PC1 ({pca.explained_variance_ratio_[0]:.1%})")

couleurs_cluster = sns.color_palette("Set2", 5)
for c in range(5):
    idx = [i for i in range(len(clusters_pred)) if clusters_pred[i] == c]
    axes[1].scatter(coords_pca[idx, 0], coords_pca[idx, 1], c=[couleurs_cluster[c]],
                     label=f"Cluster {c}", s=120, edgecolor='white', linewidth=0.8)
axes[1].set_title("Clusters K-Means (k=5)"); axes[1].legend(fontsize=8)
axes[1].set_xlabel(f"PC1 ({pca.explained_variance_ratio_[0]:.1%})")

plt.suptitle("Comparaison : sujets reels vs. clustering automatique", fontsize=13)
plt.show()

sujets_num = [list(palette_sujets.keys()).index(s) for s in sujets]
print(f"Adjusted Rand Index : {adjusted_rand_score(sujets_num, clusters_pred):.3f}")
_images/4ba62315af8da05ca34c9ad639ce5b6d47ea9a425b5520331efcec1a83d97757.png
Adjusted Rand Index : 0.381

Résumé#

Ce chapitre a approfondi les deux premières étapes du pipeline de tout LLM : la tokenisation et les embeddings, en allant bien au-delà de l’introduction du chapitre 24 du volume précédent.

  1. La tokenisation subword est l’approche universelle des LLM modernes. Le BPE byte-level (GPT-2/3, Claude) fusionne itérativement les paires d’octets les plus fréquentes, garantissant une couverture universelle de tous les textes. WordPiece (BERT) et SentencePiece (LLaMA, T5, Mistral) offrent des alternatives avec des propriétés multilingues légèrement différentes.

  2. Les vocabulaires des LLM varient de 32 000 (LLaMA 2) a 128 000 tokens (LLaMA 3). La taille du vocabulaire est un compromis entre la compacité des séquences et la taille de la matrice d’embeddings. Les tokens spéciaux (BOS, EOS, PAD, tokens de rôle) structurent l’entrée et la sortie du modèle.

  3. La fertilité d’un tokenizer varie considérablement selon la langue. Un tokenizer entrainé principalement sur de l’anglais pénalise les autres langues en produisant des séquences plus longues, ce qui a des implications en termes de coût, de fenêtre de contexte et de qualité.

  4. Les embeddings contextuels produits par les couches Transformer constituent une avancée fondamentale par rapport aux embeddings statiques (Word2Vec, GloVe). Un même token reçoit des représentations différentes selon son contexte, capturant ainsi la polysémie et les nuances sémantiques.

  5. Les sentence embeddings (Sentence-BERT, all-MiniLM-L6-v2) permettent de représenter des phrases entières dans un espace vectoriel où la similarité cosinus correspond à la similarité sémantique. Ils sont entrainés par apprentissage contrastif et permettent la recherche sémantique en temps quasi-constant.

  6. La visualisation par PCA ou t-SNE/UMAP confirme que les sentence embeddings capturent la structure thématique des textes. Le clustering automatique (K-Means) sur ces embeddings retrouve les sujets sans supervision, ce qui illustre la richesse des représentations apprises.