Aller au contenu
English

Entraîner un tout petit modèle de langage

  • Étude de cas 4
  • 120 min
  • Facultative · 1 étude de cas requise pour le certificat

Ce que vous saurez faire

Entraînez un modèle de langage à partir de zéro, sur un portable, et regardez-le apprendre à écrire. Vous construisez un petit décodeur avec les éléments de ce cours et l’entraînez sur six pièces de Shakespeare. Vous le comparez à de simples références de base par comptage, sur du texte qu’il n’a jamais vu. Vous pouvez ensuite chiffrer ce qu’il a appris et ce qui le sépare d’un véritable grand modèle de langage.

Étude de casPièce de portfolio

Durée
Environ 2 h
S’exécute dans
Colab, Kaggle ou VS Code
Compétences utilisées
  • Jetons de caractères
  • Découpage en blocs contigus
  • Références de base par comptage
  • Décodeur avec un masque causal
  • Échantillons pendant l'entraînement
  • Température
  • Enregistrer et recharger

Les carnets sont en anglais. Colab et Kaggle ouvrent votre propre copie. Pour utiliser VS Code, téléchargez le carnet depuis GitHub. Vous êtes bloqué ? Consultez le carnet de solution.

Le carnet (notebook) Jupyter exécute chaque étape de cette page, avec le même code et les mêmes sorties. Il contient aussi quatre tâches avec des vérifications. Sa cellule de préparation télécharge le texte (environ 0,9 Mo). L’entraînement prend environ 3 minutes sur le Mac mini partagé qui a servi à créer cette page. Les cinq exécutions ont pris de 168 à 212 secondes avec 2 fils d’exécution du processeur. Le carnet entier s’est exécuté en environ 3 minutes. Un ordinateur plus lent, comme la version gratuite de Colab, peut prendre deux ou trois fois plus de temps.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 120 minutes

Ce que vous devez savoir

Cette étude de cas utilise surtout les modules 5 à 7. Voici les leçons les plus importantes :

Ce qu’il vous faut sur votre ordinateur (seulement pour le parcours local; le carnet n’en a pas besoin)

  • Le dossier ticket-router avec son .venv, de Installer PyTorch : parcours 2. Cette étude de cas nécessite seulement PyTorch; elle n’utilise ni les billets ni ticketnet.py.
  • Le carnet, téléchargé à partir du lien ci-dessus, dans VS Code ou JupyterLab. Sa cellule de préparation crée un nouveau dossier tiny-lm. Pour recommencer, supprimez ce dossier.

Testé avec Python 3.12 et PyTorch 2.14.1 sur le processeur (macOS, 2 fils d’exécution), dans Jupyter après un nouveau démarrage. Windows et Linux n’ont pas été testés.

Nadia dirige les activités éducatives d’un musée des sciences municipal. Elle prépare une exposition intitulée « Comment un robot conversationnel apprend-il à écrire? ». Les visiteurs demandent souvent si les robots conversationnels comprennent ce qu’ils écrivent. Nadia veut leur montrer la réponse, pas seulement l’afficher sur un panneau.

Son idée : un petit ordinateur dans le hall entraîne un modèle de langage. Un écran montre ce qu’il écrit au début, après quelques minutes et à la fin. Ben, un scientifique des données bénévole au musée, le construit. Nadia pose quatre conditions :

  • Il fonctionne sur un ordinateur ordinaire, sans carte graphique ni Internet, et s’entraîne en quelques minutes pour qu’un visiteur puisse observer.
  • Le texte peut être utilisé et montré en public librement. Le musée ne peut pas utiliser du texte qui appartient à quelqu’un.
  • Les progrès sont réels. L’exposition ne doit pas montrer une illusion. Ben doit comparer le modèle à quelque chose de plus simple, sur du texte absent de son entraînement.
  • L’écart est présenté honnêtement. Un panneau doit expliquer ce qui sépare ce modèle des robots conversationnels que les visiteurs utilisent.

Ben choisit Shakespeare : tout le monde reconnaît la forme d’une pièce, et le texte est libre depuis des siècles.

Vous construirez un modèle de langage au niveau des caractères. Ce transformeur décodeur lit jusqu’à 64 caractères et prédit le prochain. Il apprend avec l’objectif de prédiction du prochain jeton présenté dans Les objectifs d’entraînement. Vous le comparez à des références de base par comptage et lisez des échantillons provenant de quatre étapes de l’entraînement. Ensuite, vous essayez la température et rechargez le modèle dans un nouveau processus pour écrire à partir de votre propre requête (prompt).

Le texte contient six tragédies de William Shakespeare : Hamlet, Jules César, Le roi Lear, Macbeth, Othello et Roméo et Juliette. Ben a comparé cinq textes possibles, dont Orgueil et préjugés, Les aventures de Sherlock Holmes et Les contes de Grimm. Il a choisi les pièces parce que leur forme se reconnaît facilement. Le nom d’un personnage apparaît en majuscules sur sa propre ligne, suivi d’une réplique en lignes courtes. Les progrès d’un modèle qui apprend cette forme se voient d’un coup d’œil.

Ben charge le texte et affiche les premières lignes de Hamlet :

text = Path("shakespeare_tragedies.txt").read_text(encoding="utf-8")
print(f"{len(text):,} characters, {len(text.splitlines()):,} lines")
start = text.index("BARNARDO.\nWho’s there?")
print(text[start:start + 196])

La sortie :

850,875 characters, 33,143 lines
BARNARDO.
Who’s there?
FRANCISCO.
Nay, answer me. Stand and unfold yourself.
BARNARDO.
Long live the King!
FRANCISCO.
Barnardo?
BARNARDO.
He.
FRANCISCO.
You come most carefully upon your hour

Ce que cela nous apprend. Le texte contient environ 850 000 caractères. Chaque réplique commence par un nom en majuscules suivi d’un point, sur sa propre ligne. C’est cette forme qu’il faut chercher dans les échantillons.

Le vocabulaire d’un modèle de langage est la liste des symboles qu’il peut lire et écrire. Ben compte donc les caractères. Il compte aussi les lignes de personnages avec une expression régulière : une ligne de majuscules qui se termine par un point.

import re
from collections import Counter
counts = Counter(text)
print(len(counts), "different characters")
print("most common:", [(c, n) for c, n in counts.most_common(8)])
print("rarest: ", [(c, n) for c, n in counts.most_common()[-5:]])
SPEAKER = re.compile(r"^([A-Z][A-Z’ ]+)\.$", flags=re.M) # a line like "HAMLET."
print("speaker lines:", len(SPEAKER.findall(text)), "| different speakers:", len(set(SPEAKER.findall(text))))

La sortie :

75 different characters
most common: [(' ', 128959), ('e', 70034), ('t', 51382), ('o', 48881), ('a', 42744), ('s', 38314), ('h', 37847), ('n', 37325)]
rarest: [('X', 23), ('&', 16), ('æ', 7), ('\t', 2), ('à', 1)]
speaker lines: 5647 | different speakers: 184

Ce que cela nous apprend. Le texte entier utilise seulement 75 caractères différents, dont certains sont très rares : à apparaît une fois. Le texte ne contient aucun chiffre et utilise l’apostrophe courbe ’, jamais l’apostrophe droite '. Ce détail sera important à la fin. Il y a 5 647 répliques de 184 personnages différents.

  • L’unité : une position dans le texte. L’entrée contient les caractères qui la précèdent, jusqu’à 64. La cible est le prochain caractère.
  • Les étiquettes : aucune n’est écrite par une personne. Le texte sert de corrigé, comme dans Les objectifs d’entraînement.
  • La mesure : la perte de validation. C’est l’entropie croisée moyenne par caractère, −ln p du véritable prochain caractère, sur du texte absent de l’entraînement (La perte : à quel point le réseau se trompe-t-il?). Plus elle est basse, mieux c’est. Voici une façon de la lire : une perte L signifie que le modèle hésite autant qu’entre eL caractères également probables.
  • Ce qu’il faut aussi pour l’exposition : des échantillons qu’un visiteur peut lire. La perte sert de preuve pour Ben, et les échantillons servent de preuve pour les visiteurs de Nadia. L’étude de cas utilise les deux.

Le texte de validation doit être du texte que le modèle n’a jamais vu. Ben fait une seule coupure : les premiers 90 % servent à l’entraînement, les derniers 10 % à la validation.

cut = int(len(text) * 0.9)
train_text, valid_text = text[:cut], text[cut:]
print(f"train {len(train_text):,} characters | valid {len(valid_text):,} characters")
print("valid starts:", repr(valid_text[:60]))

La sortie :

train 765,787 characters | valid 85,088 characters
valid starts: ' to your father’s? We’ll to dinner thither.\n\nROMEO.\nI will f'

Pourquoi ne pas mélanger les lignes et en prendre 10 % au hasard, comme avec les billets? Parce que les lignes voisines ne sont pas indépendantes. Un vers poursuit le précédent, et une réplique répond à celle qui la précède. Si la ligne 2 d’une réplique sert à l’entraînement et la ligne 3 à la validation, le modèle connaît presque tout le contexte de la réponse. La perte de validation mesurerait alors la mémorisation de ces scènes, pas la capacité à écrire du nouveau texte. Une seule coupure garde la validation dans un bloc de texte que le modèle n’a jamais lu. C’est la même fuite de données que dans Données d’entraînement, de validation et de test du cours [Apprentissage automatique : du problème à un modèle fiable].

Ce que cela nous apprend. La validation correspond à la deuxième moitié de Roméo et Juliette, à partir du milieu d’une phrase de l’acte 2. Elle partage le style et certains noms avec l’entraînement. Elle mesure donc la capacité à produire « d’autres passages de ces pièces », pas n’importe quel texte anglais. Il n’y a pas de données de test distinctes. Ben ne fait qu’un choix que la perte de validation pourrait biaiser : la taille du modèle. Il indique donc cette limite.

La situation familière. Une machine à écrire possède environ 80 touches, et chaque texte anglais tapé dessus utilise seulement ces touches. Vous ne rencontrez jamais de touche inconnue.

Le problème. Le système d’acheminement des billets utilisait des jetons de mots. Chaque mot absent de son vocabulaire devenait <unk> (Jetons et plongements). Un modèle qui écrit ne peut pas fonctionner avec <unk>. Il doit pouvoir écrire des noms comme « Guildenstern » et des formes anciennes comme « lov’d ».

L’idée. Utilisez un jeton par caractère. Le vocabulaire est alors minuscule, et aucun mot n’est inconnu, puisque tous les mots sont faits de caractères. Les grands modèles de langage utilisent plutôt des jetons de sous-mots. Les mots courants forment un seul jeton, et les mots rares se divisent en fragments courants. Du texte aux jetons, dans le cours [Les LLM : comment ils fonctionnent et comment en choisir un], montre comment. Un outil de segmentation en sous-mots apprend à partir d’une très grande collection de textes. Il ajouterait une deuxième étape peu familière. Les caractères sont le choix le plus simple pour écrire n’importe quoi, et ils rendent l’orthographe du modèle visible. Le coût est la longueur : une phrase produit plusieurs fois plus de jetons. La fenêtre de 64 caractères du modèle contient donc seulement environ 12 mots.

chars = sorted(set(train_text)) # the vocabulary: every character of the training text
stoi = {c: i for i, c in enumerate(chars)} # character -> ID
def encode(s):
return [stoi[c] for c in s]
def decode(ids):
return "".join(chars[i] for i in ids)
print(len(chars), "characters in the vocabulary")
print("in valid but not in train:", sorted(set(valid_text) - set(chars)))
print(repr("".join(chars)))
print(encode("ROMEO."), "->", repr(decode(encode("ROMEO."))))
train_ids = torch.tensor(encode(train_text))
valid_ids = torch.tensor(encode(valid_text))
show_shape("train_ids", train_ids)
show_shape("valid_ids", valid_ids)

La sortie :

75 characters in the vocabulary
in valid but not in train: []
'\t\n !&(),-.:;?ABCDEFGHIJKLMNOPQRSTUVWXYZ[]_abcdefghijklmnopqrstuvwxyzàæ—‘’“”'
[30, 27, 25, 17, 27, 9] -> 'ROMEO.'
train_ids shape (765787,)
valid_ids shape (85088,)

Ce que cela nous apprend. Le vocabulaire provient seulement du texte d’entraînement, une habitude présentée dans Déboguer l’expérience. Ici, chaque caractère de validation se trouve aussi dans l’entraînement; rien n’est donc perdu. « ROMEO. » devient six identifiants, et decode (décoder) les reconvertit en texte identique. Tout le texte d’entraînement forme maintenant une longue rangée de 765 787 nombres. show_shape() est la fonction auxiliaire du carnet qui affiche la forme d’un tenseur.

Avant tout réseau, Ben se demande jusqu’où le comptage peut aller. Voici trois références de base, chacune avec sa perte de validation :

  1. Tous les caractères également probables : la perte est ln 75 = 4,317. Un modèle qui n’a rien appris obtient ce résultat.
  2. Fréquences des caractères : prédire chaque caractère selon sa proportion dans le texte d’entraînement, peu importe ce qui précède.
  3. Comptage des paires (un modèle de bigrammes) : compter combien de fois chaque caractère suit chacun des autres dans l’entraînement. Prédire le prochain caractère à partir du caractère courant seulement. « Bigramme » signifie « deux lettres ».
import math
V = len(chars)
print("1. every character equally likely:", round(math.log(V), 3))
counts = torch.bincount(train_ids, minlength=V).float() + 1 # +1: no character has chance 0
p_char = counts / counts.sum()
print("2. character frequencies: ", round(float(-p_char[valid_ids].log().mean()), 3))
pairs = torch.ones(V, V) # +1 for every pair, as above
pairs.index_put_((train_ids[:-1], train_ids[1:]), torch.ones(len(train_ids) - 1), accumulate=True)
p_next = pairs / pairs.sum(1, keepdim=True) # row = this character, column = next
bigram_loss = float(-p_next[valid_ids[:-1], valid_ids[1:]].log().mean())
print("3. pair counts (bigram): ", round(bigram_loss, 3))

La sortie :

1. every character equally likely: 4.317
2. character frequencies: 3.313
3. pair counts (bigram): 2.499

Le + 1 donne à chaque caractère et à chaque paire un compte d’au moins un. Sans lui, une paire absente de l’entraînement aurait une probabilité de 0. Une seule paire comme celle-là dans la validation rendrait la perte infinie. index_put_(..., accumulate=True) ajoute 1 à la cellule de chaque paire du texte. La ligne correspond au caractère, la colonne au caractère qui le suit.

Qu’écrit le modèle de comptage des paires? Il choisit chaque prochain caractère au hasard, selon les probabilités calculées par comptage :

def bigram_generate(prompt, length=200, seed=0):
g = torch.Generator().manual_seed(seed)
ids = encode(prompt)
for _ in range(length):
ids.append(int(torch.multinomial(p_next[ids[-1]], 1, generator=g)))
return decode(ids)
print(bigram_generate("ROMEO.\n"))

La sortie :

ROMEO.
Than thiflsiapan sdl I avees [_]fo l f be—Wes me worcacothtt cas d f t t d, thakn ndulifolld t ’d heu in far be
FI’l, Shar uckncathinorath whal ce s; ke t;
I isk s athowonoulld t.
Prklolt, freue the

Ce que cela nous apprend. Compter les paires réduit l’incertitude d’un choix parmi 75 caractères à environ 12 (e2,499 ≈ 12,2). L’échantillon contient de courts « mots », des espaces, des sauts de ligne et quelques vrais mots (« Than », « me », « far », « be »). Il ne peut pas écrire de mots plus longs, car il ne voit qu’un seul caractère précédent. 2,499 est le nombre à battre. Si un transformeur ne peut pas battre une table de comptage des paires, ses paramètres n’en valent pas la peine.

Ben construit un décodeur avec les éléments de ce cours. Une table de caractères donne à chaque caractère une rangée de 128 nombres, comme les plongements (embeddings) de mots (Jetons et plongements). Une table de positions ajoute une rangée pour chacune des 64 positions. Trois blocs de transformeur avec le masque causal suivent. Chaque position lit donc seulement son propre caractère et les caractères précédents. Une normalisation finale et une couche de sortie donnent un score pour chacun des 75 caractères à chaque position.

Un flux de gauche à droite avec la forme à chaque étape, pour un lot de 32 fenêtres. Fenêtre de texte de 64 caractères. Identifiants des caractères, forme 32 par 64. Table de caractères plus table de positions, forme 32 par 64 par 128. Trois blocs de transformeur avec le masque causal, forme 32 par 64 par 128. Normalisation et couche de sortie, un score par caractère, forme 32 par 64 par 75. Perte : comparer avec la fenêtre décalée d'un caractère vers la droite.
Le tout petit modèle de langage, avec les formes des tenseurs pour un lot de 32 fenêtres de 64 caractères. Les plongements et les blocs avec le masque causal sont les éléments des modules 5 et 6.
Description textuelle du diagramme

Le schéma forme une chaîne de six boîtes, de gauche à droite. 1 : une fenêtre de texte de 64 caractères, comme « ROMEO. », un saut de ligne et « I will ». 2 : les identifiants des caractères, forme (32, 64) : 32 fenêtres de 64 identifiants. 3 : la table de caractères plus la table de positions, forme (32, 64, 128). 4 : trois blocs de transformeur avec le masque causal, forme (32, 64, 128). 5 : une normalisation et la couche de sortie, qui donnent un score pour chaque caractère, forme (32, 64, 75). 6 : la perte, qui compare les scores à la même fenêtre décalée d’un caractère vers la droite. Les boîtes 3 et 4 sont mises en évidence : ce sont les éléments des modules 5 et 6.

from torch import nn
class TinyLM(nn.Module):
"""Character + position vectors -> blocks with a causal mask -> a score for every character."""
def __init__(self, vocab_size, dim=128, heads=4, blocks=3, context=64, dropout=0.1):
super().__init__()
self.context = context
self.embed = nn.Embedding(vocab_size, dim) # one row of numbers per character
self.position = nn.Embedding(context, dim) # one row of numbers per position
block = nn.TransformerEncoderLayer(dim, heads, 4 * dim, dropout=dropout,
batch_first=True, norm_first=True)
self.blocks = nn.TransformerEncoder(block, blocks, enable_nested_tensor=False)
self.norm = nn.LayerNorm(dim)
self.out = nn.Linear(dim, vocab_size) # a score for every character
def forward(self, ids): # ids: (batch, n)
n = ids.shape[1]
h = self.embed(ids) + self.position(torch.arange(n)) # (batch, n, dim)
mask = nn.Transformer.generate_square_subsequent_mask(n)
h = self.blocks(h, mask=mask, is_causal=True) # each position reads only the past
return self.out(self.norm(h)) # (batch, n, vocab_size)
torch.manual_seed(0)
model = TinyLM(V)
for name, part in [("character table", model.embed), ("position table", model.position),
("3 blocks", model.blocks), ("final norm", model.norm), ("output layer", model.out)]:
print(f"{name:16s} {sum(p.numel() for p in part.parameters()):>8,}")
print(f"{'total':16s} {sum(p.numel() for p in model.parameters()):>8,}")

La sortie :

character table 9,600
position table 8,192
3 blocks 594,816
final norm 256
output layer 9,675
total 622,539

Ce que cela nous apprend. C’est le NextWordModel de Les objectifs d’entraînement, avec des caractères plutôt que des mots, un bloc supplémentaire et une normalisation finale. Comme dans cette leçon, nn.TransformerEncoderLayer avec un masque causal fonctionne comme un bloc décodeur. Les trois blocs contiennent 96 % des 622 539 paramètres. La table de caractères est petite (75 × 128 = 9 600), car le vocabulaire est petit.

Un exemple d’entraînement est une fenêtre : 64 caractères pris à un endroit aléatoire du texte d’entraînement. Les réponses sont les mêmes 64 caractères décalés d’une position vers la droite. Chaque position a donc son propre prochain caractère à prédire.

def get_batch(ids, generator, batch_size=32, context=64):
"""batch_size random windows of the text, and the same windows moved one character to the right."""
starts = torch.randint(len(ids) - context - 1, (batch_size,), generator=generator)
x = torch.stack([ids[s:s + context] for s in starts])
y = torch.stack([ids[s + 1:s + context + 1] for s in starts])
return x, y
x, y = get_batch(train_ids, torch.Generator().manual_seed(0))
show_shape("x", x)
show_shape("y", y)
print("x[0]:", repr(decode(x[0].tolist())[:40]))
print("y[0]:", repr(decode(y[0].tolist())[:40]))
loss_fn = nn.CrossEntropyLoss()
with torch.no_grad():
scores = model(x)
show_shape("scores", scores)
print("untrained loss:", round(loss_fn(scores.reshape(-1, V), y.reshape(-1)).item(), 3), "| ln 75 =", round(math.log(V), 3))

La sortie :

x shape (32, 64)
y shape (32, 64)
x[0]: 'eak.\n\nSECOND CITIZEN.\nI will hear Cassiu'
y[0]: 'ak.\n\nSECOND CITIZEN.\nI will hear Cassius'
scores shape (32, 64, 75)
untrained loss: 4.442 | ln 75 = 4.317

Ce que cela nous apprend. Un lot contient 32 × 64 = 2 048 prédictions. y[0] est x[0] décalé d’un caractère; la réponse après « Cassiu » est donc « s ». Les scores ont une rangée de 75 pour chaque position. La perte du modèle non entraîné, 4,442, est proche de ln 75. Il devine presque uniformément, comme le réseau de billets non entraîné le faisait avec ln 5.

Pour écrire, le modèle répète une étape, comme dans Entraînement et inférence : évaluer le prochain caractère, en choisir un, l’ajouter, recommencer. Le modèle lit au maximum ses 64 derniers caractères.

@torch.no_grad()
def generate(model, prompt, length=200, temperature=0.8, seed=0):
"""The generation loop: score the next character, choose one, add it, repeat."""
model.eval()
g = torch.Generator().manual_seed(seed)
ids = encode(prompt)
for _ in range(length):
scores = model(torch.tensor([ids[-model.context:]]))[0, -1] # the last position's scores
if temperature == 0:
next_id = int(scores.argmax()) # greedy
else:
probs = torch.softmax(scores / temperature, dim=-1)
next_id = int(torch.multinomial(probs, 1, generator=g))
ids.append(next_id)
return decode(ids)

@torch.no_grad() désactive les gradients pour toute la fonction, et model.eval() désactive le dropout (désactivation aléatoire de neurones). torch.multinomial tire un caractère selon les probabilités données. Le générateur g, initialisé avec une graine, rend le tirage reproductible. Le modèle non entraîné écrit du bruit comme qlFT-t[KOLsn)àNR;sWfC….

La boucle d’entraînement suit les cinq étapes de La boucle d’entraînement. Toutes les 100 étapes, Ben mesure la perte sur 20 lots fixes de texte d’entraînement et 20 de texte de validation. Les lots restent les mêmes pour permettre de comparer les courbes. Aux étapes 0, 300, 1 000 et 3 000, il enregistre un échantillon de 1 000 caractères. Chacun commence par « ROMEO. » et un saut de ligne, avec une graine de 0 et une température de 0,8.

import time
STEPS, EVAL_EVERY, SAMPLE_AT = 3000, 100, (0, 300, 1000, 3000)
@torch.no_grad()
def estimate(model, ids, batches=20):
"""Mean loss on 20 fixed random batches (the same batches every time)."""
model.eval()
g = torch.Generator().manual_seed(1)
losses = [loss_fn(model(x).reshape(-1, V), y.reshape(-1)).item()
for x, y in (get_batch(ids, g) for _ in range(batches))]
return sum(losses) / len(losses)
torch.manual_seed(0)
model = TinyLM(V)
optimizer = torch.optim.AdamW(model.parameters(), lr=2e-3)
batches = torch.Generator().manual_seed(0)
history, samples = [], {}
started = time.perf_counter()
for step in range(STEPS + 1):
if step % EVAL_EVERY == 0:
history.append({"step": step, "train": round(estimate(model, train_ids), 4),
"valid": round(estimate(model, valid_ids), 4)})
if step % 500 == 0:
print(history[-1], f"{time.perf_counter() - started:.0f} s")
if step in SAMPLE_AT:
samples[step] = generate(model, "ROMEO.\n", length=1000, temperature=0.8, seed=0)
if step == STEPS:
break
model.train() # dropout on for training
x, y = get_batch(train_ids, batches)
optimizer.zero_grad()
loss = loss_fn(model(x).reshape(-1, V), y.reshape(-1))
loss.backward()
optimizer.step()
print(f"trained {STEPS} steps in {time.perf_counter() - started:.0f} s")

La sortie :

{'step': 0, 'train': 4.4342, 'valid': 4.4412} 0 s
{'step': 500, 'train': 1.8886, 'valid': 1.9545} 33 s
{'step': 1000, 'train': 1.6678, 'valid': 1.7638} 60 s
{'step': 1500, 'train': 1.5865, 'valid': 1.6757} 88 s
{'step': 2000, 'train': 1.5315, 'valid': 1.6259} 115 s
{'step': 2500, 'train': 1.5025, 'valid': 1.6037} 143 s
{'step': 3000, 'train': 1.4781, 'valid': 1.5777} 171 s
trained 3000 steps in 172 s

Les pertes sont identiques à chaque exécution sur cet ordinateur; les secondes varient selon la charge (168 à 212 sur cinq exécutions). AdamW est Adam avec une légère décroissance des poids (Limiter le surapprentissage). Après un court essai, Ben a choisi un taux d’apprentissage de 0,002, soit deux fois la valeur par défaut du cours. L’entraînement tient ainsi en quelques minutes. Il n’y a pas d’époques : 3 000 étapes de 32 fenêtres aléatoires donnent 6 144 000 prédictions. Cela représente environ 8 prédictions par caractère d’entraînement.

  • Texte d'entraînement
  • Texte de validation
  • Fréquences des caractères (pointillés)
  • Comptages de paires (pointillés)
Perte d'entraînement et de validation du tout petit modèle de langage toutes les 100 étapes (moyenne de 20 lots fixes). Les deux références de base par comptage portent sur le texte de validation. Le modèle dépasse les comptages de paires à l'étape 100 et s'améliore encore à l'étape 3 000.
Description textuelle du graphique

Un graphique linéaire montre l’étape d’entraînement, de 0 à 3 000, sur l’axe horizontal. L’axe vertical montre la perte par caractère, de 1 à 4,6. Deux lignes horizontales en pointillés représentent les références de base sur le texte de validation : fréquences des caractères à 3,313 et comptages de paires à 2,499. Les courbes d’entraînement et de validation commencent toutes deux vers 4,44. Elles descendent très vite. À l’étape 100, la perte de validation atteint 2,474, juste sous les comptages de paires. À l’étape 300, elle atteint 2,101. Ensuite, elles descendent plus lentement : validation à 1,764 à l’étape 1 000, 1,626 à l’étape 2 000 et 1,578 à l’étape 3 000. La courbe d’entraînement reste légèrement sous celle de validation, avec un écart de 0,10 à la fin. Les deux descendent encore.

Ce que cela nous apprend. Lisez les courbes comme dans Lots, époques et validation. Les deux descendent ensemble, et la perte de validation baisse encore à l’étape 3 000. Le modèle ne fait donc pas encore de surapprentissage, et la dernière étape est la meilleure. Plus d’étapes aideraient un peu, mais prendraient plus de minutes. L’écart d’environ 0,1 entre les courbes est faible.

Lisez maintenant ce que le modèle a écrit à chaque étape, toujours avec la même requête, la même graine et la même température :

for step, sample in samples.items():
print(f"--- step {step} ---")
print(sample[:230])

La sortie :

--- step 0 ---
ROMEO.
qlFT-t[KOLsn)àNR;sWfCIVYvnjZs!H_fc)H‘( )&—tKokvwXPoEcaZc”htH?Iaà;Dw:XkTkZdackn.kOvnvu“:QKQUvcDN’;]Hiu ij:[’e YZ
szI’U,TSDDDvuvsncwtFzPorACn‘._pUUu“hs?;kozt;—ahO[k&æ!a
’M-I;Mey[æ[Y.zEànku;lM-E:Hhuk:Yqjr?ahà)àxiTMu:u“JeJ:M”MJ
--- step 300 ---
ROMEO.
That the lon, ands for aver bed for wo bed youre worcack hit cased cakerst,
Thathinguce on don ’d hin in far be
seave cand thas cathe heatn whal comsicke thy hisk siand worne here.
PORONEMAER.
De thereake, bid unt ther if
--- step 1000 ---
ROMEO.
That the long and did wave seen for which thou well.
How hithin the with damen.
DERIGO.
Where ’t he bid mad be seam you have staw the ray winplace sick the Caesar. and wither’s the Parus thou huse, I have abus unto me:
No
--- step 3000 ---
ROMEO.
I will not bare here I am the world walk,—
SECOND SENGUILDENSTERN.
Thou dock thou do my lov’d ’twas bid mad be sea a cannot conce
For may anyprace safel to the knows,
That eyes the caultree, thus great his bid untony him?

Ce que cela nous apprend. Lisez les quatre échantillons dans l’ordre; c’est ce que présente l’exposition.

  • Étape 0 : chaque caractère a à peu près la même probabilité. Le modèle écrit donc du bruit, y compris des caractères rares.
  • Étape 300, après environ 20 secondes : la forme d’une pièce est déjà là. Des vers, une ligne vide, puis un nom en majuscules suivi d’un point. Le nom, « PORONEMAER », est inventé, et la plupart des mots longs ne sont pas de vrais mots (« worcack », « cakerst »). Les mots courts courants sont corrects : « the », « for », « bed », « here ».
  • Étape 1 000 : davantage de vrais mots (« long », « seen », « which », « thou », « Caesar »). « DERIGO » ressemble à Roderigo, dans Othello.
  • Étape 3 000 : un premier vers qui ressemble à de la poésie : « I will not bare here I am the world walk,— ». Des formes anciennes apparaissent, comme « lov’d » et « ’twas ». Le personnage « SECOND SENGUILDENSTERN » combine deux modèles de vrais noms : « SECOND CITIZEN » et « GUILDENSTERN », dans Hamlet. Cela ne veut rien dire.

Les échantillons partagent des fragments, comme « bid mad be sea », car la même graine donne les mêmes tirages aléatoires.

Les courbes utilisent 20 lots. Pour la comparaison finale, Ben mesure chaque modèle sur tout le texte de validation, avec la même mesure :

@torch.no_grad()
def full_loss(model, ids):
"""Loss on the whole text, in windows of 64 characters, with dropout off."""
model.eval()
n = (len(ids) - 1) // model.context * model.context
x, y = ids[:n].view(-1, model.context), ids[1:n + 1].view(-1, model.context)
total = sum(loss_fn(model(xb).reshape(-1, V), yb.reshape(-1)).item() * yb.numel()
for xb, yb in zip(x.split(256), y.split(256)))
return total / n
results = {"every character equally likely": math.log(V),
"character frequencies": float(-p_char[valid_ids].log().mean()),
"pair counts (bigram)": bigram_loss,
"tiny transformer": full_loss(model, valid_ids)}
for name, loss in results.items():
print(f"{name:32s} valid loss {loss:.3f} ~ a choice among {math.exp(loss):4.1f} characters")
print(f"{'tiny transformer, training text':32s} loss {full_loss(model, train_ids):.3f}")

La sortie :

every character equally likely valid loss 4.317 ~ a choice among 75.0 characters
character frequencies valid loss 3.313 ~ a choice among 27.5 characters
pair counts (bigram) valid loss 2.499 ~ a choice among 12.2 characters
tiny transformer valid loss 1.585 ~ a choice among 4.9 characters
tiny transformer, training text loss 1.485
Perte de validation sur tout le texte réservé (la seconde moitié de Romeo and Juliet). Le tout petit transformeur se situe bien sous les deux références de base par comptage.
Description textuelle du graphique

Un graphique à colonnes comporte quatre colonnes. Tous égaux (chaque caractère a la même probabilité) : 4,317. Fréquence (fréquences des caractères) : 3,313. Paires (comptages de paires, le modèle à bigrammes) : 2,499. Tout petit transformeur : 1,585, mis en évidence. Chaque colonne est plus basse que la précédente, et celle du transformeur est de loin la plus courte.

Ce que cela nous apprend. Le tout petit transformeur gagne nettement : 1,585 contre 2,499 pour les comptages de paires. En mots simples, son incertitude sur le prochain caractère ressemble à un choix parmi environ 5 caractères, contre 12 pour les comptages de paires. Il gagne parce qu’il lit jusqu’à 64 caractères en arrière, plutôt qu’un seul. Il sait donc que « Guildenst » est suivi de « e », et qu’une ligne vide est suivie d’un nom. Dans cette mesure, chaque fenêtre repart de zéro. Ses premiers caractères ont donc peu de contexte. Cela rend la comparaison un peu plus difficile pour le transformeur, pas plus facile. La perte sur le texte d’entraînement, 1,485, est seulement 0,1 plus faible : il n’a pas appris ce texte par cœur.

C’est l’inverse du système d’acheminement des billets, où les comptages de mots suffisaient et 4 700 billets étaient trop peu pour un transformeur. Ici, la tâche exige de tenir compte de l’ordre : compter les caractères ne dit rien sur ce qui vient ensuite.

La température divise les scores avant softmax, comme dans Entraînement et inférence. Sous 1, les caractères probables deviennent encore plus probables. Au-dessus de 1, les probabilités deviennent plus égales. À 0, le modèle choisit toujours le caractère le plus probable : c’est le choix glouton. Voici d’abord les probabilités du modèle pour le prochain caractère :

prompt = "LEAR.\nGive me the "
with torch.no_grad():
model.eval()
scores = model(torch.tensor([encode(prompt)]))[0, -1]
probs = torch.softmax(scores, dim=-1)
for p, i in zip(*probs.topk(8)):
print(f"{chars[i]!r:6s} {p.item():.3f}")

La sortie :

'm' 0.121
's' 0.080
'c' 0.068
't' 0.065
'b' 0.062
'w' 0.061
'p' 0.055
'h' 0.043
Probabilités du modèle pour le caractère après « LEAR. » et « Give me the », à une température de 1. Après une espace, plusieurs premières lettres sont possibles; la plus probable, m, n'a que 12 %.
Description textuelle du graphique

Un graphique à colonnes montre les huit prochains caractères les plus probables après « Give me the » : m 12 %, s 8 %, c 7 %, t 7 %, b 6 %, w 6 %, p 5 %, h 4 %. Ce sont toutes des premières lettres de mots, et aucune ne dépasse beaucoup les autres. Les 20 caractères les plus probables représentent ensemble 88 % de la probabilité.

Après une espace, le modèle hésite : plusieurs mots peuvent suivre « Give me the ». Le choix glouton prend toujours m. L’échantillonnage à une température de 1 prend m environ une fois sur huit seulement. Voici maintenant la même requête et la même graine à quatre températures :

for t in (0, 0.5, 1.0, 1.5):
print(f"--- temperature {t} ---")
print(generate(model, "LEAR.\n", length=160, temperature=t, seed=0))

La sortie :

--- temperature 0 ---
LEAR.
I have be the more that that the more of the more of the more of the more of the more of the more of the more of the more of the more of the more of the more of
--- temperature 0.5 ---
LEAR.
I will not be and flower,
The world will be the moor, and the as the corrow
That the diffly come; he be bear of sea a can a like which is not place so king;
If
--- temperature 1.0 ---
LEAR.
That the long and dlaw, villain!
[_Gloucesternerorcestion, castlemand, damn thou dull clov’d ’tward in my worthy.
[_Exeunt._]
FIRST CITIZEN.
What ketter hi
--- temperature 1.5 ---
LEAR.
Than thy lon, ands,
Comev’d brinforly- but boy; my cament teach’d
be knocd, and him unmigit com’d him in my worthy.’
, Shall villatizan asnaveplace sackott;—I’l

Ce que cela nous apprend. À 0, le modèle répète « the more of » sans fin : le même piège que le texte glouton de SmolLM2 au module 7. À 0,5, les mots sont presque tous réels, mais ordinaires. À 1,0, le modèle écrit une indication scénique, [_Exeunt._] (ils sortent), avec le balisage de la pièce. Il en écrit aussi une déformée juste avant. À 1,5, l’orthographe se dégrade complètement. La section suivante mesure ce compromis.

Il est facile de trop interpréter les échantillons. Ben compte donc ce qu’un lecteur vérifie en premier. Pour chaque échantillon de 1 000 caractères, il mesure trois proportions. La proportion de mots qui apparaissent aussi dans le texte d’entraînement. La proportion de mots différents (une faible proportion indique de la répétition). Et la proportion de noms de personnages qui prennent réellement la parole dans les pièces.

train_words = set(re.findall(r"[a-z’]+", train_text.lower()))
speakers = set(SPEAKER.findall(train_text))
def judge(sample):
"""What a reader checks first: real words, real speaker names, variety."""
words = re.findall(r"[a-z’]+", sample.lower())
names = SPEAKER.findall(sample)
return {"words": len(words),
"real words": round(sum(w in train_words for w in words) / max(len(words), 1), 2),
"different words": round(len(set(words)) / max(len(words), 1), 2),
"speaker lines": len(names),
"real names": round(sum(n in speakers for n in names) / max(len(names), 1), 2)}
rows = {"valid text (real Shakespeare)": judge(valid_text[:1000]),
"pair counts (bigram)": judge(bigram_generate("ROMEO.\n", length=1000))}
rows.update({f"transformer, step {s}": judge(samples[s]) for s in SAMPLE_AT[1:]})
rows.update({f"temperature {t}": judge(generate(model, "ROMEO.\n", length=1000, temperature=t, seed=0))
for t in (0, 0.5, 1.5)})
for name, row in rows.items():
print(f"{name:30s}", row)

La sortie :

valid text (real Shakespeare) {'words': 193, 'real words': 0.95, 'different words': 0.6, 'speaker lines': 7, 'real names': 1.0}
pair counts (bigram) {'words': 184, 'real words': 0.18, 'different words': 0.79, 'speaker lines': 3, 'real names': 0.33}
transformer, step 300 {'words': 201, 'real words': 0.49, 'different words': 0.78, 'speaker lines': 3, 'real names': 0.33}
transformer, step 1000 {'words': 204, 'real words': 0.78, 'different words': 0.65, 'speaker lines': 8, 'real names': 0.75}
transformer, step 3000 {'words': 202, 'real words': 0.88, 'different words': 0.59, 'speaker lines': 6, 'real names': 0.83}
temperature 0 {'words': 251, 'real words': 1.0, 'different words': 0.04, 'speaker lines': 1, 'real names': 1.0}
temperature 0.5 {'words': 206, 'real words': 0.95, 'different words': 0.5, 'speaker lines': 7, 'real names': 1.0}
temperature 1.5 {'words': 155, 'real words': 0.54, 'different words': 0.92, 'speaker lines': 7, 'real names': 0.43}
  • Proportion de vrais mots
  • Proportion de mots différents
Un échantillon de 1 000 caractères à chaque température, produit par le modèle entraîné (0,8 correspond à l'échantillon de fin d'entraînement). Température basse : vrais mots, répétés. Température élevée : mots variés, souvent mal orthographiés. Le vrai Shakespeare obtient 95 % et 60 %.
Description textuelle du graphique

Un graphique linéaire montre la température, de 0 à 1,5, sur l’axe horizontal. L’axe vertical montre une proportion de 0 % à 100 %. La courbe des vrais mots commence à 100 % à une température de 0. Elle atteint ensuite 95 % à 0,5, 88 % à 0,8 et 54 % à 1,5. La courbe des mots différents commence à 4 % à une température de 0. Elle atteint ensuite 50 % à 0,5, 59 % à 0,8 et 92 % à 1,5. Les deux courbes se croisent entre 0,8 et 1,5. À titre de comparaison, les 1 000 premiers caractères du vrai texte de validation contiennent 95 % de vrais mots et 60 % de mots différents.

Ce que cela nous apprend. Ce sont des échantillons uniques d’environ 200 mots chacun; traitez donc les nombres comme des approximations. La tendance reste claire.

Ce que le modèle réussit :

  • Forme des lignes et mise en page. Répliques, lignes vides, noms en majuscules suivis d’un point et indications scéniques dans [_ … _], dès l’étape 300.
  • Orthographe des mots courants. À l’étape 3 000 et à une température de 0,8, 88 % de ses mots sont de vrais mots des pièces. Les comptages de paires en donnent 18 %. À une température de 0,5, il atteint 95 %, comme le vrai Shakespeare.
  • Ponctuation et formes anciennes. Virgules en fin de ligne, « ’twas », « lov’d », « thou ».
  • Variété à une température de 0,8. 59 % de mots différents, près des 60 % du vrai Shakespeare.

Ce qu’il rate :

  • Le sens. Aucune phrase ne dit quoi que ce soit. « Thou dock thou do my lov’d ’twas bid mad be sea a cannot conce » ressemble à une pièce, mais ne veut rien dire.
  • Qui parle. Il mélange les personnages de différentes pièces, et environ un nom de personnage sur six est inventé.
  • Les faits. Il n’a aucune connaissance à consulter, seulement des motifs de caractères. Ben pose une question sur un fait des pièces : Macbeth a tué le roi Duncan.
question = "HORATIO.\nWho killed King Duncan, and why?\n\nHAMLET.\n"
print(generate(model, question, length=150, temperature=0.5, seed=0))

La sortie :

HORATIO.
Who killed King Duncan, and why?
HAMLET.
The most long and did make the place of the thou worst the traitor man the content to me.
BRUTUS.
What what he see a can a like which is not place so

Ce que cela nous apprend. Le modèle poursuit parfaitement la mise en page : Hamlet répond, puis Brutus, d’une autre pièce, parle. « Traitor » correspond au ton. Il n’y a pas de réponse, parce que le modèle ne fait que poursuivre le texte. Un grand modèle de langage peut répondre parce qu’il a vu des milliers de milliards de jetons. Il a ensuite été ajusté pour répondre aux questions. Les deux manquent ici.

Le modèle à l’étape 3 000 est le modèle final : la perte de validation baissait encore, donc la dernière étape est la meilleure. Pour l’exposition, il doit fonctionner hors du carnet Jupyter. Ben enregistre les poids et tout ce qu’il faut pour les utiliser, comme dans Enregistrer et examiner les résultats :

import hashlib, json
torch.save(model.state_dict(), "tiny_lm.pt")
info = {"chars": chars, "config": {"dim": 128, "heads": 4, "blocks": 3, "context": 64, "dropout": 0.1},
"text": "shakespeare_tragedies.txt",
"text_sha256": hashlib.sha256(Path("shakespeare_tragedies.txt").read_bytes()).hexdigest(),
"split": "first 90% train, last 10% valid", "steps": STEPS, "seed": 0,
"valid_loss": round(results["tiny transformer"], 4), "torch": torch.__version__}
Path("tiny_lm.json").write_text(json.dumps(info, ensure_ascii=False, indent=1), encoding="utf-8")
for f in ("tiny_lm.pt", "tiny_lm.json"):
print(f"{f:13s} {Path(f).stat().st_size / 1000:,.1f} kB")

La sortie :

tiny_lm.pt 2,504.4 kB
tiny_lm.json 0.9 kB

Les poids occupent 2,5 Mo : 622 539 paramètres × 4 octets. Le vocabulaire doit les accompagner : une liste dans un autre ordre rendrait chaque échantillon incompréhensible, sans erreur. Ben écrit ensuite generate.py pour l’ordinateur de l’exposition. Ce fichier contient sa propre copie de la classe TinyLM, car un state_dict contient seulement des nombres, pas du code :

generate.py
"""Write text with the saved tiny language model, in a new process.
python generate.py "PROMPT" [--length 200] [--temperature 0.8] [--seed 0]
"""
import argparse
import json
import sys
import torch
from torch import nn
class TinyLM(nn.Module):
... # the same class as above
parser = argparse.ArgumentParser()
parser.add_argument("prompt")
parser.add_argument("--length", type=int, default=200)
parser.add_argument("--temperature", type=float, default=0.8)
parser.add_argument("--seed", type=int, default=0)
args = parser.parse_args()
torch.set_num_threads(2)
info = json.load(open("tiny_lm.json", encoding="utf-8"))
chars = info["chars"]
stoi = {c: i for i, c in enumerate(chars)}
unknown = sorted(set(args.prompt) - set(chars))
if unknown:
sys.exit(f"These characters are not in the vocabulary: {unknown}")
model = TinyLM(len(chars), **info["config"])
model.load_state_dict(torch.load("tiny_lm.pt"))
model.eval()
g = torch.Generator().manual_seed(args.seed)
ids = [stoi[c] for c in args.prompt]
with torch.no_grad():
for _ in range(args.length):
scores = model(torch.tensor([ids[-model.context:]]))[0, -1]
if args.temperature == 0:
next_id = int(scores.argmax())
else:
probs = torch.softmax(scores / args.temperature, dim=-1)
next_id = int(torch.multinomial(probs, 1, generator=g))
ids.append(next_id)
print("".join(chars[i] for i in ids))

Le carnet écrit le fichier complet. Voici maintenant une nouvelle requête, dans un nouveau processus Python, comparée au résultat du generate() du carnet :

import subprocess, sys
prompt = "MACBETH.\nWhat news"
run = subprocess.run([sys.executable, "generate.py", prompt, "--length", "150", "--seed", "1"],
capture_output=True, text=True, check=True) # a fresh Python process
print(run.stdout)
print("same as in this notebook:", run.stdout.rstrip("\n") == generate(model, prompt, length=150, seed=1))

La sortie :

MACBETH.
What news when, sir, they blood the bosiness,
That when daughters in place to the head be watch the done fight,
Look my sight father, she is positites of thee,
same as in this notebook: True

Ce que cela nous apprend. Deux fichiers et un programme suffisent. Un nouveau processus reconstruit le modèle et écrit exactement le même texte que le carnet, avec la même requête et la même graine. Dans le carnet, la dernière tâche vous demande de faire la même chose avec votre propre requête.

Erreur courante

Une requête contenant un caractère que le modèle n'a jamais vu

Symptôme : un visiteur tape ROMEO., un saut de ligne et What's the news? (quelles sont les nouvelles?) sur un clavier ordinaire. Le programme s’arrête :

exit code 1 | These characters are not in the vocabulary: ["'"]

Cause : les pièces utilisent l’apostrophe courbe ’. L’apostrophe droite ' du clavier n’apparaît jamais dans le texte. Elle n’a donc ni identifiant ni ligne dans la table des caractères. Il en va de même pour les chiffres et le guillemet double droit. Un modèle de caractères n’a pas de <unk> : il peut lire seulement les 75 caractères utilisés pour son entraînement.

Correction : indiquez les caractères manquants, comme le fait generate.py, plutôt que de planter avec une KeyError. Pour l’exposition, Ben remplace ' par ’ avant la génération. Ne supprimez pas les caractères inconnus sans avertissement : la requête changerait sans que personne le sache.

Ce que cela signifie pour un vrai système d’IA : chaque modèle lit seulement ce que son outil de segmentation en jetons peut encoder. Les grands modèles utilisent des fragments allant jusqu’à un seul octet, pour qu’aucune entrée ne soit impossible. Du texte aux jetons le montre. Même ainsi, ils lisent moins bien les textes rares dans les données d’entraînement.

La dernière condition de Nadia concerne l’affiche de l’exposition. Ben compare son modèle à SmolLM2-135M, le plus petit modèle d’une vraie famille, présenté dans Entraînement et inférence. Ses chiffres de préentraînement viennent de sa fiche de modèle, citée dans Les objectifs d’entraînement.

Ce tout petit modèle SmolLM2-135M Combien de fois plus
Paramètres 622 539 134 515 008 environ 216
Blocs de transformeur 3 30 10
Nombres par jeton 128 576 4,5
Vocabulaire 75 caractères 49 152 jetons de sous-mots environ 655
Contexte (jetons lus en une fois) 64 caractères 8 192 jetons 128
Prédictions d’entraînement 6 144 000 (765 787 caractères, chacun lu environ 8 fois) 2 000 milliards de jetons environ 325 000
Matériel d’entraînement 2 fils d’exécution CPU, environ 3 minutes 64 GPU H100 de centre de données —
Après le préentraînement rien rien : c’est un modèle de base. Les modèles de conversation ajoutent un ajustement aux instructions. —

La ligne d’entraînement compare les prédictions : 3 000 étapes × 32 fenêtres × 64 caractères pour le tout petit modèle. Pour SmolLM2, c’est 2 000 milliards de jetons. Chaque jeton de SmolLM2 représente habituellement plusieurs caractères. L’écart en quantité de texte est donc encore plus grand.

Ce que cela nous apprend. SmolLM2-135M est petit pour un modèle moderne. Il est pourtant environ 216 fois plus grand et entraîné sur environ 325 000 fois plus d’exemples. Malgré cela, le module 7 a montré qu’il divague et ne peut pas acheminer un billet à partir d’une requête. Le tout petit modèle montre honnêtement le mécanisme d’un grand modèle de langage : même objectif, même masque causal et même boucle de génération. Il ne montre pas la capacité, qui vient de l’échelle et de l’entraînement après le préentraînement. L’affiche de Ben dit : « Ce modèle a appris l’apparence d’une pièce de Shakespeare en trois minutes. Un agent conversationnel utilise la même idée, avec des centaines de milliers de fois plus de texte et beaucoup plus de paramètres. »

  • La forme d’abord, l’orthographe ensuite, le sens jamais. Mise en page à l’étape 300 (environ 20 secondes), mots courants à l’étape 1 000, formes anciennes à l’étape 3 000. Quelques minutes de plus n’apporteraient ni sens ni faits.
  • Le contexte bat le comptage : 1,585 contre 2,499 pour les comptages de paires. La température échange la justesse contre la variété : 95 % de vrais mots à 0,5, 54 % à 1,5.
  • Limites. Il y a un échantillon par réglage; les proportions de mots et de noms sont donc approximatives. La validation utilise la seconde moitié d’une seule pièce, qui partage des noms avec l’entraînement. Elle mesure donc « encore du même type », pas l’anglais en général. Il n’y a pas de données de test. Le temps disponible a arrêté l’entraînement, pas la courbe. Les nombres viennent d’un seul ordinateur et d’une seule graine. Un autre ordinateur peut donner des pertes légèrement différentes. Ses échantillons peuvent aussi changer dès le premier tirage différent.
  • Prochaines étapes. Entraînez plus longtemps et arrêtez quand la perte de validation cesse de baisser. Essayez un contexte de 128 caractères, puis comparez la perte et le temps. Entraînez sur un autre auteur et comparez les échantillons.

À qui appartient le texte? Les pièces sont de Shakespeare et appartiennent au domaine public. Tout le monde peut les copier, les utiliser pour l’entraînement et montrer les résultats. L’affiche du musée nomme quand même la source : « Entraîné sur six tragédies de William Shakespeare provenant de Project Gutenberg. » Le domaine public est un statut juridique, pas une raison de cacher la provenance du texte.

À quoi peut servir un tel modèle : enseignement et expositions comme celle de Nadia; expériences sur la taille du modèle, la température et le contexte; œuvres d’art clairement indiquées comme produites par une machine.

À quoi il ne doit pas servir :

  • Pas comme du Shakespeare. Son texte n’est ni du « Shakespeare perdu » ni une citation. Indiquez que chaque échantillon est généré.
  • Pas pour donner des réponses. Il ne connaît aucun fait, comme l’a montré la question sur Duncan. Il ne doit jamais alimenter une zone de questions qui ressemble à un agent conversationnel.
  • Pas sur le texte d’un auteur vivant sans permission. La même recette de 3 minutes imiterait son style à partir de ses livres ou publications. Cela soulève des questions de droit d’auteur, de consentement et d’usurpation d’identité que le texte du domaine public évite. Vérifiez d’abord la licence de tout texte, comme dans Licences et conditions d’utilisation des données du cours sur les grands modèles de langage.
  • Pas comme générateur public sans filtrage. Les pièces contiennent de la violence, des insultes et les préjugés de leur époque, et le modèle les recombine. Pour un écran public, une personne examine d’abord les échantillons fixes. Le générateur en direct démarre seulement à partir de requêtes choisies par le personnel.

Cette étude de cas montre que vous comprenez le fonctionnement interne d’un modèle de langage. Personnalisez-la avec un changement, puis publiez-la. Par exemple, choisissez un autre auteur du domaine public, un contexte de 128 caractères ou un entraînement plus long.

Quoi mettre dans le dépôt :

  • README.md (modèle ci-dessous), avec le graphique de perte et les quatre échantillons.
  • Le carnet (notebook) Jupyter, exécuté du début à la fin, avec ses sorties.
  • generate.py et tiny_lm.json. Ajoutez tiny_lm.pt (2,5 Mo) seulement avec la version de PyTorch qui l’a enregistré. Le lecteur peut aussi le recréer avec le carnet en quelques minutes.
  • La source et le statut du texte, avec son SHA-256 : « Six tragédies de William Shakespeare, tirées du livre numérique no 100 de Project Gutenberg. Domaine public aux États-Unis. En-tête et licence de Project Gutenberg retirés. » Ne mettez pas le nom Project Gutenberg dans une copie modifiée du texte lui-même.

Un modèle de README :

# A tiny character-level language model, trained on Shakespeare on a CPU
## Question
What can a 0.6-million-parameter decoder learn from six plays in 3 minutes on a CPU?
## Data and status
Six Shakespeare tragedies (850,875 characters), Project Gutenberg eBook #100,
public domain in the USA; header and licence removed. SHA-256 59bd62e6...94b4.
Split: first 90% train, last 10% validation (one cut, no shuffling).
## Model
Characters as tokens (75). Character + position embeddings, 3 transformer
blocks (128 dims, 4 heads) with a causal mask, 622,539 parameters.
AdamW, lr 0.002, 3,000 steps of 32 x 64 characters, seed 0.
## Results (validation loss per character)
Equally likely 4.317 | character frequencies 3.313 | pair counts 2.499 |
tiny transformer 1.585. Samples at steps 0, 300, 1,000, 3,000 in the notebook.
## What it learned, and what it did not
Layout by step 300; 88% real words at step 3,000 (temperature 0.8).
No meaning, no facts. Compared with SmolLM2-135M: 216x fewer parameters,
about 325,000x fewer training predictions.
## Limits
One seed, one computer; validation is the end of one play; no test set.

Comment le décrire en entrevue, en deux phrases : « J’ai entraîné de zéro un transformeur à 600 000 paramètres qui prédit des caractères sur six pièces de Shakespeare, en trois minutes sur un processeur. Il a largement surpassé les comptes de paires sur du texte réservé, et j’ai mesuré ce qu’il n’apprend pas, le sens et les faits, en le comparant à un vrai petit modèle de langage. »

Pour aller plus loin :

  • Entraînez le modèle pendant 6 000 étapes et tracez les courbes. Faites d’abord une prédiction : la perte de validation continuera-t-elle à baisser? La perte d’entraînement s’en éloignera-t-elle?
  • Changez le contexte pour 16 caractères, puis pour 128. Comment la perte, le temps et les échantillons changent-ils?
  • Chargez SmolLM2-135M et calculez sa perte sur le texte de validation par caractère. Divisez sa perte totale par le nombre de caractères, pas de jetons. Comparez-la à 1,585.

Vérifiez votre compréhension

Ben divise le texte en une seule coupure, sans mélanger les lignes. Pourquoi? Quel problème poserait le choix aléatoire de 10 % des lignes pour la validation?

Afficher la réponse

Les lignes voisines dépendent les unes des autres : un vers poursuit le précédent, et une réplique répond à la précédente. Avec des lignes choisies au hasard, la plupart des lignes de validation auraient leurs voisines dans les données d’entraînement. Le modèle aurait vu presque le même contexte. La perte de validation mesurerait donc la mémorisation de ces scènes, pas la capacité à écrire du nouveau texte. Elle semblerait trop bonne. Une seule coupure garde la validation dans un bloc que le modèle n’a jamais lu. C’est la même fuite qu’avec des données de test qui partagent de l’information avec l’entraînement, comme dans le cours d’apprentissage automatique.

Vérifiez votre compréhension

Le tout petit transformeur a une perte de validation de 1,585, contre 2,499 pour les comptes de paires. Expliquez ces deux nombres en mots simples, et pourquoi le transformeur peut faire tellement mieux.

Afficher la réponse

La perte est la surprise moyenne devant le vrai caractère suivant, −ln p. Le nombre e élevé à la puissance de la perte donne un nombre de choix également probables. C’est environ 12 caractères pour les comptes de paires et environ 5 pour le transformeur. Les comptes de paires ne voient que le caractère actuel. Après « Guildenst », ils ne connaissent donc que « t ». Le transformeur remonte jusqu’à 64 caractères grâce à l’attention et au masque causal. Il peut donc utiliser le mot entier, la ligne et le nom du personnage qui parle. Ce contexte réduit son incertitude.

Vérifiez votre compréhension

Un visiteur dit : ce modèle écrit comme Shakespeare, donc un agent conversationnel, c'est la même chose, mais en plus grand. Qu'est-ce qui est juste et qu'est-ce qui manque dans cette phrase?

Afficher la réponse

Ce qui est juste : un agent conversationnel utilise le même mécanisme. C’est un décodeur entraîné à prédire le jeton suivant, avec un masque causal et la même boucle de génération. Ce qui manque : l’échelle et l’entraînement supplémentaire. SmolLM2-135M, un petit modèle réel, a environ 216 fois plus de paramètres. Il a été entraîné sur 2 000 milliards de jetons, soit environ 325 000 fois les prédictions de ce modèle. Cet entraînement utilisait 64 processeurs graphiques de centre de données. Un agent conversationnel est encore bien plus grand. Après le préentraînement, il est ajusté pour suivre des instructions. Le tout petit modèle a appris la mise en page et l’orthographe, mais ni le sens ni les faits. Il a répondu à une question sur King Duncan par une réplique qui ne dit rien.

Récapitulatif

  • Vous avez construit un décodeur à 622 539 paramètres qui prédit des caractères, à partir des éléments du cours : une table de caractères, une table de positions, 3 blocs avec le masque causal et une couche de sortie.
  • Vous avez divisé le texte en une seule coupure, car les lignes voisines causent des fuites d’information. Vous avez construit le vocabulaire uniquement à partir des données d’entraînement.
  • Les références de base par comptage ont fixé la cible : 3,313 pour les fréquences des caractères, 2,499 pour les comptes de paires. Le transformeur a atteint 1,585 sur le texte réservé après environ 3 minutes sur un processeur.
  • Les échantillons ont montré l’ordre d’apprentissage : la mise en page à l’étape 300, les mots à l’étape 1 000, les formes anciennes à l’étape 3 000. Le sens et les faits ne sont jamais venus. La température permet un compromis entre les vrais mots et la variété.
  • Vous avez enregistré les poids avec le vocabulaire et les réglages. Vous les avez rechargés dans un nouveau processus et obtenu le même résultat.
  • Comparé à SmolLM2-135M : environ 216 fois moins de paramètres et environ 325 000 fois moins de prédictions d’entraînement. Cet écart, et non une idée différente, sépare ce modèle d’un vrai LLM.

Ensuite, le travail final réunit les notions du cours dans votre propre projet.

Leçon suivante : Travail final