Débogage d’un petit GPT dans PyTorch : tests permettant d’isoler chaque défaillance.
Un flux de travail étape par étape pour déboguer un GPT au niveau des caractères dans PyTorch, allant des IDs de tokens et du décalage cible aux gradients, aux pertes NaN et aux points de contrôle.
Les métriques d’évaluation telles que la perte, la perplexité et le taux de répétition indiquent qu’un petit modèle GPT se comporte mal, mais rarement pourquoi. La réponse tentante consiste à ajuster le taux d’apprentissage, à ajouter une couche et à espérer. Ce guide remplace cette approche hasardeuse par un flux de travail reproductible pour un GPT compact au niveau des caractères (Mini-GPT) entraîné sur WikiText-2 : il permet de relier chaque symptôme à des causes probables, d’effectuer des vérifications ciblées à chaque étape du processus et de résoudre les problèmes dans l’ordre où ils surviennent. Vous obtenez ainsi un ensemble d’affirmations et un script de diagnostic à exécuter avant chaque entraînement coûteux.
Pourquoi un pipeline GPT peut être incorrect sans planter
L’entraînement d’un modèle de langage enchaîne de nombreuses transformations, chacune utilisant la sortie de la précédente :
Raw dataset
→ cleaned text
→ tokenizer
→ token IDs
→ training batches
→ embeddings
→ Transformer blocks
→ vocabulary logits
→ cross-entropy loss
→ gradients
→ optimizer
→ checkpoints
→ generation
Un défaut n’importe où contamine tout ce qui suit. Supposons que les cibles ne soient pas décalées d’une position par rapport aux entrées :
Input: The cat
Target: The cat
Le réseau est désormais récompensé pour reproduire le token qu’il voit déjà plutôt que de prédire le suivant. Rien ne plante, et la perte peut encore diminuer, car copier est facile. Le modèle optimise simplement l’objectif incorrect. C’est là la différence fondamentale avec le débogage d’un service web, où une valeur de retour erronée brise généralement un test ou une page :
Un code qui s’exécute jusqu’au bout peut encore entraîner un modèle défectueux.
Travaillez du début au bout de la chaîne d’opérations
Vérifiez les étapes dans l’ordre où les données y passent :
1. Environment
2. Files
3. Tokenizer
4. Token IDs
5. Training batches
6. Model shapes
7. Initial loss
8. Gradients
9. Optimizer
10. Validation behavior
11. Checkpoints
12. Generation
Évaluer la qualité de la génération avant que la chaîne de traitement des données ne soit confirmée est une perte de temps, car un échantillon défectueux peut provenir de n’importe quelle des onze étapes précédentes. À chaque étape, posez une question ciblée et répondez-y par un test qui réussit ou échoue.
Quatre familles d’échecs
Presque tous les problèmes rencontrés avec un modèle de ce type relèvent d’un des quatre groupes, et connaître le groupe permet de réduire la recherche.
- Faillites de correction : le code est logiquement incorrect. Les cibles ne sont pas décalées, le masque causal permet aux positions d’accéder à des tokens ultérieurs, la perte utilise des tenseurs avec une structure incorrecte, ou les IDs du tokeniseur ne correspondent pas au vocabulaire pour lequel le modèle a été conçu.
- Faillites numériques : les calculs deviennent instables. La perte vire à
NaN, les gradients explosent, les logits dépassent leurs limites et atteignent l’infini, ou softmax reçoit une ligne sans valeurs valides. - Faillites d’optimisation : l’implémentation est correcte mais l’apprentissage est inefficace, car le taux d’apprentissage est trop élevé ou trop bas, le modèle est trop petit, ou la durée de l’exécution est insuffisante.
Commencez par une configuration de débogage minimale
Le débogage lors d’une exécution complète transforme chaque hypothèse en une attente interminable. Définissez un modèle petit qui puisse mémoriser quelques exemples en quelques secondes :
debug_config = MiniGPTConfig(
vocab_size=tokenizer.vocab_size,
block_size=32,
embedding_dim=64,
num_heads=4,
num_layers=2,
expansion_factor=4,
dropout=0.0,
)
Associez-le à un petit lot :
debug_batch_size = 8
et à une exécution courte :
debug_steps = 200
Le dropout est délibérément désactivé :
dropout = 0.0
Le dropout met à zéro les activations aléatoires, ce qui fait diverger des exécutions identiques. Le désactiver (en même temps qu’une graine fixe) rend chaque test reproductible. Restaurez les paramètres de production une fois que le pipeline fonctionne correctement.
Vérifiez l’environnement de exécution
Au préalable, avant de toucher au modèle, affichez les versions de Python et PyTorch ainsi que le fait que CUDA ou le backend Metal d’Apple (MPS) soit utilisable :
import platform
import torch
print("Python:", platform.python_version())
print("PyTorch:", torch.__version__)
print("CUDA available:", torch.cuda.is_available())
if hasattr(torch.backends, "mps"):
print(
"MPS available:",
torch.backends.mps.is_available(),
)
Un outil d’aide choisit le meilleur dispositif, en privilégiant CUDA, puis MPS, et enfin le CPU. La vérification avec hasattr permet au code de fonctionner sur des versions plus anciennes ne disposant pas d’un backend MPS :
def get_device():
if torch.cuda.is_available():
return torch.device("cuda")
if (
hasattr(torch.backends, "mps")
and torch.backends.mps.is_available()
):
return torch.device("mps")
return torch.device("cpu")
Appelez-le une fois et enregistrez le résultat :
device = get_device()
print("Selected device:", device)
Lorsque l’entraînement est étrangement lent, c’est souvent cette ligne qui en est la cause : la tâche attendait une GPU mais a dû recourir au CPU en raison d’un problème de pilote ou d’installation.
Vérifiez que tous les fichiers d’entrée soient présents
Vérifiez que la définition du tokeniseur ainsi que les ensembles de données d’entraînement, de validation et de test codés existent ; arrêtez-vous rapidement en cas d’erreur FileNotFoundError claire, plutôt que de rencontrer une erreur confuse au cœur du cycle d’entraînement :
from pathlib import Path
required_paths = [
Path("tokenizer/char_tokenizer.json"),
Path("data/encoded/train_ids.pt"),
Path("data/encoded/val_ids.pt"),
Path("data/encoded/test_ids.pt"),
]
for path in required_paths:
if not path.exists():
raise FileNotFoundError(
f"Required file not found: {path}"
)
print("Found:", path)
Affichez également les tailles :
for path in required_paths:
print(
path,
path.stat().st_size,
"bytes",
)
Un fichier vide ou anormalement petit indique généralement qu’une tâche de prétraitement a été interrompue, laissant un artefact tronqué.
Tester le tokeniseur en isolation
Chargez le tokeniseur de caractères :
tokenizer = CharTokenizer.from_file(
"tokenizer/char_tokenizer.json"
)
Vérifiez la taille du vocabulaire ainsi que les deux extrémités de la liste des caractères pour détecter d’éventuelles absences ou altérations :
print("Vocabulary size:", tokenizer.vocab_size)
print("First tokens:", tokenizer.chars[:20])
print("Last tokens:", tokenizer.chars[-20:])
La propriété aller-retour
Un tokeniseur sans perte restitue exactement l’entrée après encodage et décodage. L’affichage avec repr révèle des caractères invisibles tels que les espaces en fin de chaîne :
sample = "The history of"
encoded = tokenizer.encode(sample)
decoded = tokenizer.decode(encoded)
print("Encoded:", encoded)
print("Decoded:", repr(decoded))
assert decoded == sample
L’invariant vérifié :
decode(encode(text)) = text
Une erreur signifie qu’au moins un caractère ne peut pas être représenté fidèlement, généralement un symbole absent du vocabulaire. La méthode encode dans le script complet déclenche alors une KeyError au lieu de l’ignorer silencieusement, ce qui est souhaitable.
Attraper les incohérences entre le tokeniseur et le point de contrôle
Des tailles de vocabulaire égales sont nécessaires mais pas suffisantes. Deux vocabulaires pouvant contenir 100 caractères chacun peuvent tout de même attribuer des IDs différents :
Tokenizer A: "a" → 10
Tokenizer B: "a" → 24
Un modèle entraîné avec une mise en correspondance donnée et servi avec une autre génère du texte sans sens, bien que la forme de chaque tenseur semble correcte. Il convient de persister le vocabulaire avec le point de contrôle, ou au moins son empreinte digitale. Un hash SHA-256 de la liste de caractères serialisée fonctionne ; régler correctement les paramètres separators et ensure_ascii garantit que la même liste sera toujours serialisée en les mêmes octets :
import hashlib
import json
def tokenizer_fingerprint(chars):
payload = json.dumps(
chars,
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(
payload
).hexdigest()
Calculez-le pour le tokeniseur chargé :
fingerprint = tokenizer_fingerprint(
tokenizer.chars
)
print("Tokenizer fingerprint:", fingerprint)
Stockez-le dans le dictionnaire de points de contrôle lors du sauvegarde :
checkpoint[
"tokenizer_fingerprint"
] = fingerprint
Lors du chargement, comparez et refusez de continuer en cas de différence. La condition is not None permet toujours au chargement de points de contrôle plus anciens sans empreinte digitale :
saved_fingerprint = checkpoint.get(
"tokenizer_fingerprint"
)
if (
saved_fingerprint is not None
and saved_fingerprint != fingerprint
):
raise ValueError(
"Checkpoint and tokenizer do not match"
)
Inspectez les IDs de tokens codés
Chargez la partie d’entraînement sur le CPU sous forme d’entiers de 64 bits, comme l’exigent les embeddings de type et la croisée-entropie :
train_ids = torch.load(
"data/encoded/train_ids.pt",
map_location="cpu",
).long()
Affichez la forme, le type de données et l’intervalle :
print("Shape:", train_ids.shape)
print("Dtype:", train_ids.dtype)
print("Minimum ID:", train_ids.min().item())
print("Maximum ID:", train_ids.max().item())
Chaque ID doit se trouver à l’intérieur du vocabulaire :
0 ≤ token ID < vocabulary size
En tant qu’assertions :
assert train_ids.min().item() >= 0
assert (
train_ids.max().item()
< tokenizer.vocab_size
)
Un ID en dehors de la plage perturbe la recherche d’incorporation, et sur une GPU l’erreur peut apparaître sous forme d’une assertion opaque du côté du dispositif, éloignée de sa cause réelle. Les raisons typiques sont un fichier de tokenisation incorrect, des fichiers codés endommagés, un vocabulaire réconstruit après l’encodage, ou un traitement incohérent des tokens spéciaux.
Lire les données stockées sous forme de texte
Même des nombres dans la plage peuvent encore coder le mauvais texte, il faut donc décoder quelques centaines d’IDs et les lire :
sample_ids = train_ids[:500]
sample_text = tokenizer.decode(
sample_ids.tolist()
)
print(sample_text)
Vous devriez voir du WikiText lisible, avec sa mise en forme habituelle, ses sauts de ligne, ses titres et sa ponctuation, sans séquences de caractères répétés ou endommagés. Si l’échantillon ne correspond pas, arrêtez-vous : aucune modification du modèle ne peut compenser un tokeniseur ou un ensemble de données défectueux.
Vérifier le décalage d’un token entre les entrées et les cibles
Créez manuellement un exemple, en faisant commencer la fenêtre cible une position plus tard :
block_size = 32
start = 100
inputs = train_ids[
start:
start + block_size
]
targets = train_ids[
start + 1:
start + block_size + 1
]
Décodez les deux pour les comparer :
input_text = tokenizer.decode(
inputs.tolist()
)
target_text = tokenizer.decode(
targets.tolist()
)
print("Input: ", repr(input_text))
print("Target:", repr(target_text))
La cible doit ressembler à l’entrée, avec son premier caractère supprimé et un nouveau caractère ajouté. Ensuite, vérifiez la relation entre les tenseurs :
assert torch.equal(
inputs[1:],
targets[:-1],
)
Quelques vérifications dans le projet permettent de détecter des bugs plus graves. L’invariant :
inputs[1:] == targets[:-1]
Chaque position de cible contient le token qui suit la position d’entrée correspondante, ce dont a besoin la prédiction du token suivant.
Le bug des tronçons identiques
L’erreur classique consiste à utiliser les mêmes bornes pour les deux tronçons :
inputs = data[
start:
start + block_size
]
targets = data[
start:
start + block_size
]
Le modèle apprend alors une correspondance identité :
Current token → current token
Commencer le tronçon de cible un token plus tard résout ce problème :
targets = data[
start + 1:
start + block_size + 1
]
et restaure la tâche prévue :
Current context → next token
Un signe caractéristique de ce bug est une perte qui diminue de manière suspectement rapide dès le début.
Vérifier les formes des lots, les types de données et les dispositifs
Prendre un échantillon d’un lot réel :
inputs, targets = get_batch(
data=train_ids,
batch_size=8,
block_size=32,
device=device,
)
Imprimer tout ce qui pourrait être incorrect :
print("Input shape:", inputs.shape)
print("Target shape:", targets.shape)
print("Input dtype:", inputs.dtype)
print("Target dtype:", targets.dtype)
print("Input device:", inputs.device)
print("Target device:", targets.device)
Pour une taille de lot de 8 et une taille de bloc de 32, on s’attend à :
Input shape: [8, 32]
Target shape: [8, 32]
Dtype: torch.int64
Device: same as model
Rendre ces attentes permanentes :
assert inputs.shape == targets.shape
assert inputs.dtype == torch.long
assert targets.dtype == torch.long
assert inputs.device == device
assert targets.device == device
Corriger les incohérences de dispositif
Cette erreur apparaît constamment dans le travail avec PyTorch :
Expected all tensors to be on the same device
Une opération a reçu des tenseurs sur des dispositifs différents, tels que le CPU et la GPU. Afficher où se trouvent les paramètres et le lot :
model_device = next(
model.parameters()
).device
print("Model device:", model_device)
print("Input device:", inputs.device)
Déplacer explicitement le modèle et chaque lot :
model = model.to(device)
inputs = inputs.to(device)
targets = targets.to(device)
Une source plus subtile se cache à l’intérieur du modèle : torch.arange utilise par défaut le CPU, il faut donc prendre le dispositif des tokens reçus :
positions = torch.arange(
sequence_length,
device=token_ids.device,
)
Sinon, l’ajout d’embeddings de position aux embeddings de tokens sur CUDA ou MPS échoue. Lier le dispositif à l’entrée permet également de garder le modèle portable.
Vérifier le passage en avant
Exécuter un lot avec des cibles afin que le modèle retourne des logits et une perte :
logits, loss = model(
inputs,
targets,
)
Les examiner :
print("Logits shape:", logits.shape)
print("Loss shape:", loss.shape)
print("Loss value:", loss.item())
Les logits nécessitent un score par entrée du vocabulaire pour chaque position, et la perte doit être un scalaire :
assert logits.shape == (
inputs.size(0),
inputs.size(1),
tokenizer.vocab_size,
)
assert loss.ndim == 0
Des logits de forme [B, V, T] indiquent qu’une transposition ou un reshaping a mis les dimensions dans le mauvais ordre. Comme F.cross_entropy accepte les scores de classe dans la deuxième dimension, un tenseur mal ordonné peut parfois atteindre la fonction de perte sans erreur et calculer quelque chose d’absurde.
Comparer la perte initiale à la valeur de référence aléatoire
log(V):
import math
expected_loss = math.log(
tokenizer.vocab_size
)
print("Expected loss:", expected_loss)
print("Actual loss:", loss.item())
Prouver que le masque causal fonctionne
Un GPT doit prédire chaque position uniquement à partir des tokens précédents. Créez deux séquences ayant un préfixe commun mais des suffixes différents ; si le modèle est causal, les logits du préfixe doivent correspondre. Le mode d’évaluation désactive le dropout afin que le hasard n’intervienne pas :
model.eval()
prefix_length = 8
sequence_length = 16
sequence_a = torch.randint(
0,
tokenizer.vocab_size,
(1, sequence_length),
device=device,
)
sequence_b = sequence_a.clone()
sequence_b[
:,
prefix_length:
] = torch.randint(
0,
tokenizer.vocab_size,
(
1,
sequence_length - prefix_length,
),
device=device,
)
Exécutez les deux sans gradients :
with torch.no_grad():
logits_a, _ = model(sequence_a)
logits_b, _ = model(sequence_b)
Mesurez la plus grande différence de préfixe :
prefix_difference = (
logits_a[:, :prefix_length, :]
- logits_b[:, :prefix_length, :]
).abs().max().item()
print(
"Maximum prefix difference:",
prefix_difference,
)
Vérifiez l’égalité dans une petite tolérance permettant d’absorber le bruit en virgule flottante :
assert torch.allclose(
logits_a[:, :prefix_length, :],
logits_b[:, :prefix_length, :],
atol=1e-5,
)
Une défaillance signifie que des positions ultérieures s’infiltrent dans celles précédentes, généralement parce que le masque manque, est appliqué à la mauvaise dimension ou a été construit à partir du mauvais triangle. Tester le comportement de bout en bout est plus fiable que d’examiner uniquement le tenseur du masque.
Surapprentissage sur un seul lot
Si vous adoptez une technique de ce guide, choisissez celle-ci :
fixed_inputs, fixed_targets = get_batch(
data=train_ids,
batch_size=8,
block_size=32,
device=device,
)
debug_config = MiniGPTConfig(
vocab_size=tokenizer.vocab_size,
block_size=32,
embedding_dim=64,
num_heads=4,
num_layers=2,
expansion_factor=4,
dropout=0.0,
)
debug_model = MiniGPT(
debug_config
).to(device)
optimizer = torch.optim.AdamW(
debug_model.parameters(),
lr=1e-3,
)
for step in range(500):
optimizer.zero_grad(
set_to_none=True
)
_, debug_loss = debug_model(
fixed_inputs,
fixed_targets,
)
debug_loss.backward()
optimizer.step()
if step % 50 == 0:
print(
step,
debug_loss.item(),
)
Vérifiez que les gradients atteignent tous les paramètres
optimizer.zero_grad(
set_to_none=True
)
_, loss = model(
inputs,
targets,
)
loss.backward()
Rapportez chaque paramètre entraînable dont le .grad est encore égal à None, ainsi que la norme pour les autres :
for name, parameter in (
model.named_parameters()
):
if not parameter.requires_grad:
continue
if parameter.grad is None:
print(
"NO GRADIENT:",
name,
)
else:
print(
name,
parameter.grad.norm().item(),
)
Un gradient manquant signifie généralement une couche définie dans __init__ mais non utilisée dans forward, un appel accidentel à .detach(), une branche de calcul qui saute une composante, une perte calculée à partir d’un tenseur déconnecté, ou la valeur requires_grad=False.
Suivez la norme globale du gradient
Les normes par paramètre permettent de détecter les couches inutilisées ; un chiffre global permet de suivre la stabilité au fil du temps. Cette fonction combine toutes les normes L2 des gradients, celles mêmes utilisées pour le clipping :
def calculate_gradient_norm(model):
squared_norm = 0.0
for parameter in model.parameters():
if parameter.grad is None:
continue
parameter_norm = (
parameter.grad
.detach()
.norm(2)
.item()
)
squared_norm += (
parameter_norm ** 2
)
return squared_norm ** 0.5
Enregistrez-la après chaque passe en arrière :
gradient_norm = (
calculate_gradient_norm(model)
)
print("Gradient norm:", gradient_norm)
Faites attention aux normes qui sont exactement nulles, extrêmement élevées ou fluctuantes, ainsi qu’aux valeurs NaN ou inf.
Détectez tôt les valeurs NaN et infinies
def assert_finite_tensor(
tensor,
name,
):
if not torch.isfinite(
tensor
).all():
raise FloatingPointError(
f"{name} contains NaN or infinity"
)
Appliquez-le aux logits et à la perte :
assert_finite_tensor(
logits,
"logits",
)
assert_finite_tensor(
loss,
"loss",
)
ainsi qu’à chaque gradient après l’appel de backward() :
for name, parameter in (
model.named_parameters()
):
if parameter.grad is not None:
assert_finite_tensor(
parameter.grad,
f"gradient for {name}",
)
Vérifier plusieurs points permet de repérer l’endroit où apparaissent les premiers nombres invalides, ce qui est bien plus utile que de détecter une perte NaN des centaines d’étapes plus tard.
Pourquoi la perte devient-elle NaN
Causes fréquentes : un taux d’apprentissage trop élevé, des gradients explosifs, une ligne d’attention où toutes les positions sont masquées, des entrées softmax invalides, un débordement en précision mixte, des paramètres déjà corrompus, une division par zéro, un logarithme de zéro ou d’une valeur négative, ainsi que des logits infinis. Lorsque cela se produit :
- Arrêtez l’exécution.
- Identifiez la dernière étape présentant une perte finie.
- Baissez le taux d’apprentissage.
- Activez le clippage des gradients.
N’continuez jamais l’itération une fois que les paramètres contiennent NaN ; chaque mise à jour propage la corruption, il vaut donc mieux reprendre depuis le dernier point de contrôle valide.
Utilisez le clippage des gradients comme mesure de précaution
Le clippage rééchelle les gradients dont la norme combinée dépasse un seuil, il doit donc être appliqué entre backward() et optimizer.step():
loss.backward()
gradient_norm = (
torch.nn.utils.clip_grad_norm_(
model.parameters(),
max_norm=1.0,
)
)
optimizer.step()
clip_grad_norm_ renvoie la norme mesurée avant le clippage, ce qui sert également à surveiller la situation :
print(
"Gradient norm before clipping:",
float(gradient_norm),
)
Si le seuil est dépassé presque à chaque étape, le clippage masque un problème tel qu’un taux d’apprentissage excessif ou une instabilité numérique. Il protège contre des lots défectueux occasionnels, mais ne remplace pas un taux d’apprentissage adapté.
Vérifier que l’optimiseur modifie les poids
Copyez un paramètre avant mise à jour. La méthode .clone() est essentielle : sans elle, le paramètre before partage la mémoire avec l’autre et se modifie également :
parameter_name, parameter = next(
model.named_parameters()
)
before = parameter.detach().clone()
Exécutez une étape d’entraînement :
optimizer.zero_grad(
set_to_none=True
)
_, loss = model(
inputs,
targets,
)
loss.backward()
optimizer.step()
Mesurez la variation :
after = parameter.detach()
maximum_change = (
after - before
).abs().max().item()
print(
"Maximum parameter change:",
maximum_change,
)
et assurez-vous qu’il y ait une modification :
assert maximum_change > 0
Des poids inchangés indiquent un taux d’apprentissage nul, un optimiseur créé sans les paramètres du modèle (par exemple avant que le modèle ne soit remplacé), l’absence de gradients, l’absence d’appel à optimizer.step(), ou des paramètres gelés.
Ajuster le taux d’apprentissage dans les deux sens
Un taux trop élevé se manifeste par une augmentation rapide de la perte ou des fluctuations extrêmes, des normes de gradient très élevées, une perte NaN, et des échantillons qui ne s’améliorent jamais. Une première solution consiste à réduire ce taux, par exemple :
max_learning_rate = 1e-4
plutôt que :
max_learning_rate = 1e-3
Enregistrez le taux d’apprentissage à côté de la perte. Avec un phase d’échauffement, l’instabilité commence souvent exactement au pic, ce que seul un graphique de perte ne révèle pas.
Un taux trop bas se manifeste différemment : la perte diminue très lentement bien que des gradients existent, les paramètres ne bougent presque pas, et même le test sur un seul lot nécessite de nombreux pas. Augmentez-le alors, par exemple à :
max_learning_rate = 3e-4
plutôt que :
max_learning_rate = 1e-5
Aucune valeur n’est universellement correcte ; elle varie en fonction de la taille du modèle et du lot, de l’optimiseur ainsi que du jeu de données. Effectuez des expériences courtes où seul le taux d’apprentissage change.
Une liste de contrôle pour une perte qui ne diminue pas
Résolvez ces questions dans l’ordre. Données :
Are targets shifted by one token?
Are token IDs within range?
Does decoded input look correct?
Modèle :
Are logits shaped [B, T, V]?
Is the causal mask valid?
Are positions on the correct device?
Perte :
Does cross-entropy receive raw logits?
Are logits and targets flattened correctly?
Passer les probabilités softmax à cross_entropy est une erreur classique, car la fonction applique elle-même le log-softmax. Gradients :
Do all important parameters receive gradients?
Are gradient norms finite and nonzero?
Optimiseur :
Is the learning rate positive?
Does optimizer.step() run?
Do parameters change?
Capacité :
Can the model overfit one batch?
This order avoids random trial and error.
Cet ordre élimine une catégorie de causes à la fois au lieu de recourir à l’essai-erreur.
Reconnaître le surapprentissage et le sous-apprentissage
Le surapprentissage se manifeste par des courbes qui s’éloignent l’une de l’autre :
Training loss: continues decreasing
Validation loss: stops decreasing or increases
Quantifier l’écart :
generalization_gap = (
validation_loss
- training_loss
)
Les solutions incluent de conserver le meilleur point d’arrêt de validation, d’augmenter le taux de dropout ou la dégradation des poids, de réduire la taille du modèle, d’ajouter plus de données ou des données plus variées, ainsi que d’arrêter plus tôt. Choisissez le point d’arrêt en utilisant la partition de validation ; utiliser la partition de test introduit des biais et surestime la note finale.
Le sous-apprentissage se manifeste par deux courbes qui restent élevées ensemble :
Training loss: remains high
Validation loss: remains similarly high
Les raisons probables sont une capacité insuffisante, un entraînement trop court, un taux d’apprentissage trop faible, une fenêtre de contexte trop courte, des données trop difficiles pour l’architecture, ou une tokenisation qui gaspille le contexte. Les solutions possibles incluent davantage d’itérations, une dimension d’embedding plus élevée, plus de couches Transformer, une fenêtre de contexte plus longue, un encodage par paires de bytes (BPE) au lieu de caractères, ainsi que un réglage différent du taux d’apprentissage. Exécutez d’abord à nouveau le test en lot unique : si le modèle ne parvient pas à mémoriser un seul lot, le problème réside dans la correction ou l’optimisation, et non dans la capacité.
Diagnostic de la génération répétitive
La répétition se manifeste de la manière suivante :
the the the the
ou, avec les marqueurs de titre de WikiText :
= = = = = = =
Les causes incluent le décodage gourmand, une température très basse, un top-k très petit, un modèle sous-entraîné ou surapprenti, des structures répétées dans les données, ainsi qu’une fenêtre de contexte trop courte. Essayez un échantillonnage plus équilibré :
temperature = 0.8
top_k = 20
top_p = 0.9
Une pénalité de répétition peut être utile si elle reste modérée :
repetition_penalty = 1.05
Dans les modèles de caractères, une pénalité trop forte dissuade la réutilisation des lettres et gâche rapidement l’orthographe. Si chaque configuration de décodage continue à itérer, le point faible réside dans le modèle et non dans l’échantillonneur. Pour en savoir plus sur l’interaction de ces paramètres, consultez notre guide sur la température, top-k et top-p.
Diagnostic de la génération chaotique
L’échec inverse produit des symboles erronés, des mots déformés, une ponctuation excessive, des changements soudains de sujet et des chaînes illisibles. Les causes probables sont une température élevée, l’absence de filtrage top-k ou top-p, un tokeniseur inadapté, le mauvais point de vérification, un modèle sous-entraîné avec une perte de validation élevée, ou des poids qui n’ont jamais été chargés. Essayez un échantillonnage plus strict :
temperature = 0.6
top_k = 10
top_p = 0.9
Vérifier que les poids proviennent bien du point de contrôle :
model.load_state_dict(
checkpoint["model_state_dict"]
)
et que le mécanisme de dropout est désactivé pendant l’échantillonnage :
model.eval()
Lorsque la sortie ignore le prompt
Le prompt peut être très court ou différent des données d’entraînement ; le modèle peut être petit, insuffisamment entraîné, faible en ce qui concerne les dépendances à long terme, ou limité par un contexte court ; de plus, les tokens de caractères rendent les motifs sémantiques plus difficiles à apprendre. Tester des prompts plus longs, de style WikiText. Comparer un prompt minimal :
"The "
avec un prompt plus riche :
"The history of the city began"
Le second donne au modèle beaucoup plus d’éléments sur lesquels s’appuyer. Si les continuations continuent de dévier, examiner la perte de validation et l’implémentation de l’attention.
Points de contrôle qui refusent de se charger
Les erreurs sont familières :
Missing key(s) in state_dict
Unexpected key(s) in state_dict
Size mismatch
Cela signifie que le modèle que vous avez construit n’est pas celui que vous avez enregistré : sa configuration, le nombre de couches, la dimension des embeddings, la taille du vocabulaire ou les paramètres de poids ont changé, des classes ou des attributs ont été renommés, ou un état d’optimiseur provenant d’une autre architecture est chargé. Vérifiez la configuration enregistrée :
print(
checkpoint["config"]
)
Construisez le modèle à partir d’elle plutôt qu’avec les paramètres par défaut actuels :
config = MiniGPTConfig(
**checkpoint["config"]
)
model = MiniGPT(config)
Ensuite, chargez le dictionnaire d’état. Construire un modèle à partir d’une nouvelle configuration tout en espérant que les anciens poids s’adaptent provoque la plupart de ces erreurs.
Liste des clés manquantes et inattendues
Uniquement à des fins de diagnostic, chargez les données de manière non stricte et affichez les incohérences :
load_result = model.load_state_dict(
checkpoint["model_state_dict"],
strict=False,
)
print(
"Missing keys:",
load_result.missing_keys,
)
print(
"Unexpected keys:",
load_result.unexpected_keys,
)
Les listes révèlent généralement la cause, comme un sous-module ayant été renommé. Pour l’inférence ou la reprise de l’entraînement, assurez-vous d’un chargement strict afin qu’un point de contrôle incompatible échoue clairement plutôt que de laisser les couches avec des valeurs initiales aléatoires.
Déplacer l’état de l’optimiseur vers le bon dispositif
Après la restauration d’un optimiseur, ses tenseurs internes (tels que les estimations de moment d’AdamW) peuvent se trouver sur un dispositif différent de celui du modèle. Ce outil déplace chaque tenseur présent dans l’état :
def move_optimizer_to_device(
optimizer,
device,
):
for state in optimizer.state.values():
for key, value in state.items():
if torch.is_tensor(value):
state[key] = value.to(
device
)
Appelez-le juste après le chargement :
optimizer.load_state_dict(
checkpoint[
"optimizer_state_dict"
]
)
move_optimizer_to_device(
optimizer,
device,
)
Cela est particulièrement important lors du sauvegardage sur une machine et de la reprise sur une autre, par exemple de CUDA à MPS ou CPU.
Regrouper les vérifications essentielles en une fonction de santé
Rassemblez les assertions les plus importantes dans une seule fonction : le type et la plage des tokens, le décalage cible, la forme des logits, une perte finie, ainsi qu’une comparaison avec la valeur de référence aléatoire :
def run_model_health_checks(
model,
tokenizer,
train_ids,
device,
):
model.eval()
assert train_ids.dtype == torch.long
assert train_ids.min().item() >= 0
assert (
train_ids.max().item()
< tokenizer.vocab_size
)
batch_size = 4
block_size = min(
32,
model.config.block_size,
)
inputs, targets = get_batch(
data=train_ids,
batch_size=batch_size,
block_size=block_size,
device=device,
)
assert inputs.shape == targets.shape
assert torch.equal(
inputs[:, 1:],
targets[:, :-1],
)
with torch.no_grad():
logits, loss = model(
inputs,
targets,
)
assert logits.shape == (
batch_size,
block_size,
tokenizer.vocab_size,
)
assert torch.isfinite(loss)
expected_loss = math.log(
tokenizer.vocab_size
)
print("Current loss:", loss.item())
print(
"Random baseline:",
expected_loss,
)
print("Model health checks passed.")
Pour un point de contrôle entraîné, la perte doit clairement être inférieure à la valeur de référence ; sinon les poids n’ont pas été chargés ou le tokeniseur ne correspond pas.
Localiser les problèmes numériques avec des hooks forward
Lorsqu’un NaN apparaît à un endroit inconnu, les hooks forward inspectent la sortie de chaque module au cours du passage. Ce hook gère les tenseurs et tuples uniques et lance une exception indiquant le nom de la classe du module dès qu’il détecte la première valeur non finie :
def finite_output_hook(
module,
inputs,
output,
):
tensors = []
if torch.is_tensor(output):
tensors = [output]
elif isinstance(output, tuple):
tensors = [
item
for item in output
if torch.is_tensor(item)
]
for tensor in tensors:
if not torch.isfinite(
tensor
).all():
raise FloatingPointError(
"Nonfinite output detected in "
f"{module.__class__.__name__}"
)
Attachez-le à chaque module linéaire, de normalisation de couche et d’incorporation, en conservant les identifiants correspondants :
hooks = []
for module in model.modules():
if isinstance(
module,
(
torch.nn.Linear,
torch.nn.LayerNorm,
torch.nn.Embedding,
),
):
hooks.append(
module.register_forward_hook(
finite_output_hook
)
)
Exécutez un passage forward ; comme les couches s’exécutent dans l’ordre, la première exception indique le premier type de couche défaillante. Ensuite, supprimez les hooks :
for hook in hooks:
hook.remove()
Les hooks s’exécutent à chaque appel direct et ralentissent le modèle, il faut donc les utiliser uniquement lors de la recherche d’une erreur. Pour obtenir le chemin exact du module, enregistrez les noms fournis par named_modules() au moment de l’enregistrement.
Un script de diagnostic complet
Toutes les vérifications sont regroupées en un seul outil en ligne de commande. Enregistrez-le sous :
debug_mini_gpt.py
Le script définit un CharTokenizer minimal, des mécanismes de sélection du dispositif, une empreinte digitale, un outil d’aide au regroupement des données et des utilitaires numériques. Il construit le modèle à partir de la configuration propre au fichier de sauvegarde, rejette tout tokenizer dont la taille du vocabulaire diffère, puis exécute huit tests numérotés : aller-retour du tokenizer, plage de tokens, décalage des lots, passe avant, indépendance causale, gradients, mise à jour de l’optimiseur, et éventuellement surapprentissage avec un seul lot sur un modèle de débogage frais, tout cela sous une graine fixe. Deux détails méritent d’être notés : le test du tokenizer décode les IDs stockés puis les réencode afin de valider le véritable ensemble de données, et le test de l’optimiseur utilise une instance fraîche d’AdamW afin que des états obsolètes ne puissent pas interférer.
import argparse
import hashlib
import json
import math
from pathlib import Path
import torch
from mini_gpt import MiniGPT
from mini_gpt import MiniGPTConfig
class CharTokenizer:
def __init__(self, chars):
self.chars = chars
self.vocab_size = len(chars)
self.stoi = {
char: index
for index, char in enumerate(chars)
}
self.itos = {
index: char
for index, char in enumerate(chars)
}
@classmethod
def from_file(cls, path):
with open(
path,
"r",
encoding="utf-8",
) as file:
data = json.load(file)
return cls(data["chars"])
def encode(self, text):
return [
self.stoi[char]
for char in text
]
def decode(self, token_ids):
return "".join(
self.itos[int(token_id)]
for token_id in token_ids
)
def get_device():
if torch.cuda.is_available():
return torch.device("cuda")
if (
hasattr(torch.backends, "mps")
and torch.backends.mps.is_available()
):
return torch.device("mps")
return torch.device("cpu")
def tokenizer_fingerprint(chars):
payload = json.dumps(
chars,
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(
payload
).hexdigest()
def get_batch(
data,
batch_size,
block_size,
device,
):
start_positions = torch.randint(
low=0,
high=len(data) - block_size,
size=(batch_size,),
)
inputs = torch.stack([
data[
position:
position + block_size
]
for position in start_positions
])
targets = torch.stack([
data[
position + 1:
position + block_size + 1
]
for position in start_positions
])
return (
inputs.to(device),
targets.to(device),
)
def assert_finite_tensor(
tensor,
name,
):
if not torch.isfinite(
tensor
).all():
raise FloatingPointError(
f"{name} contains NaN or infinity"
)
def calculate_gradient_norm(model):
squared_norm = 0.0
for parameter in model.parameters():
if parameter.grad is None:
continue
norm = (
parameter.grad
.detach()
.norm(2)
.item()
)
squared_norm += norm ** 2
return squared_norm ** 0.5
def load_model(
checkpoint_path,
device,
):
checkpoint = torch.load(
checkpoint_path,
map_location=device,
)
config = MiniGPTConfig(
**checkpoint["config"]
)
model = MiniGPT(config)
model.load_state_dict(
checkpoint["model_state_dict"]
)
model = model.to(device)
return model, checkpoint
def test_tokenizer(
tokenizer,
train_ids,
):
print("\n1. Testing tokenizer")
print(
"Vocabulary size:",
tokenizer.vocab_size,
)
print(
"Tokenizer fingerprint:",
tokenizer_fingerprint(
tokenizer.chars
),
)
sample_ids = train_ids[:300]
sample_text = tokenizer.decode(
sample_ids.tolist()
)
round_trip_ids = tokenizer.encode(
sample_text
)
assert round_trip_ids == (
sample_ids.tolist()
)
print("Decoded sample:")
print(repr(sample_text))
print(
"Tokenizer round-trip test passed."
)
def test_token_ids(
tokenizer,
train_ids,
):
print("\n2. Testing token IDs")
print("Shape:", train_ids.shape)
print("Dtype:", train_ids.dtype)
minimum_id = train_ids.min().item()
maximum_id = train_ids.max().item()
print("Minimum ID:", minimum_id)
print("Maximum ID:", maximum_id)
assert minimum_id >= 0
assert maximum_id < (
tokenizer.vocab_size
)
print("Token ID test passed.")
def test_batch(
tokenizer,
train_ids,
block_size,
device,
):
print("\n3. Testing batches")
inputs, targets = get_batch(
data=train_ids,
batch_size=4,
block_size=block_size,
device=device,
)
print("Input shape:", inputs.shape)
print("Target shape:", targets.shape)
assert inputs.shape == targets.shape
assert inputs.dtype == torch.long
assert targets.dtype == torch.long
assert torch.equal(
inputs[:, 1:],
targets[:, :-1],
)
input_text = tokenizer.decode(
inputs[0].cpu().tolist()
)
target_text = tokenizer.decode(
targets[0].cpu().tolist()
)
print("Input sample:")
print(repr(input_text))
print("Target sample:")
print(repr(target_text))
print("Batch shift test passed.")
return inputs, targets
def test_forward_pass(
model,
tokenizer,
inputs,
targets,
):
print("\n4. Testing forward pass")
model.eval()
with torch.no_grad():
logits, loss = model(
inputs,
targets,
)
print("Logits shape:", logits.shape)
print("Loss:", loss.item())
assert logits.shape == (
inputs.size(0),
inputs.size(1),
tokenizer.vocab_size,
)
assert_finite_tensor(
logits,
"logits",
)
assert_finite_tensor(
loss,
"loss",
)
print(
"Random baseline:",
math.log(tokenizer.vocab_size),
)
print("Forward-pass test passed.")
def test_future_independence(
model,
tokenizer,
device,
):
print(
"\n5. Testing causal independence"
)
model.eval()
sequence_length = min(
16,
model.config.block_size,
)
prefix_length = (
sequence_length // 2
)
sequence_a = torch.randint(
0,
tokenizer.vocab_size,
(1, sequence_length),
device=device,
)
sequence_b = sequence_a.clone()
sequence_b[
:,
prefix_length:
] = torch.randint(
0,
tokenizer.vocab_size,
(
1,
sequence_length
- prefix_length,
),
device=device,
)
with torch.no_grad():
logits_a, _ = model(sequence_a)
logits_b, _ = model(sequence_b)
difference = (
logits_a[
:,
:prefix_length,
:,
]
- logits_b[
:,
:prefix_length,
:,
]
).abs().max().item()
print(
"Maximum shared-prefix difference:",
difference,
)
assert torch.allclose(
logits_a[
:,
:prefix_length,
:,
],
logits_b[
:,
:prefix_length,
:,
],
atol=1e-5,
)
print(
"Causal independence test passed."
)
def test_gradients(
model,
inputs,
targets,
):
print("\n6. Testing gradients")
model.train()
model.zero_grad(
set_to_none=True
)
_, loss = model(
inputs,
targets,
)
loss.backward()
missing_gradients = []
nonfinite_gradients = []
for name, parameter in (
model.named_parameters()
):
if not parameter.requires_grad:
continue
if parameter.grad is None:
missing_gradients.append(name)
continue
if not torch.isfinite(
parameter.grad
).all():
nonfinite_gradients.append(
name
)
print(
"Gradient norm:",
calculate_gradient_norm(model),
)
if missing_gradients:
print(
"Missing gradients:",
missing_gradients,
)
if nonfinite_gradients:
print(
"Nonfinite gradients:",
nonfinite_gradients,
)
assert not missing_gradients
assert not nonfinite_gradients
print("Gradient test passed.")
def test_optimizer_update(
model,
inputs,
targets,
):
print("\n7. Testing optimizer update")
optimizer = torch.optim.AdamW(
model.parameters(),
lr=1e-3,
)
name, parameter = next(
model.named_parameters()
)
before = parameter.detach().clone()
optimizer.zero_grad(
set_to_none=True
)
_, loss = model(
inputs,
targets,
)
loss.backward()
torch.nn.utils.clip_grad_norm_(
model.parameters(),
max_norm=1.0,
)
optimizer.step()
maximum_change = (
parameter.detach() - before
).abs().max().item()
print("Tracked parameter:", name)
print(
"Maximum parameter change:",
maximum_change,
)
assert maximum_change > 0
print(
"Optimizer update test passed."
)
def run_single_batch_overfit(
tokenizer,
train_ids,
device,
steps,
):
print(
"\n8. Running single-batch "
"overfitting test"
)
config = MiniGPTConfig(
vocab_size=tokenizer.vocab_size,
block_size=32,
embedding_dim=64,
num_heads=4,
num_layers=2,
expansion_factor=4,
dropout=0.0,
)
model = MiniGPT(config).to(device)
inputs, targets = get_batch(
data=train_ids,
batch_size=8,
block_size=config.block_size,
device=device,
)
optimizer = torch.optim.AdamW(
model.parameters(),
lr=1e-3,
)
initial_loss = None
final_loss = None
for step in range(steps):
optimizer.zero_grad(
set_to_none=True
)
_, loss = model(
inputs,
targets,
)
if initial_loss is None:
initial_loss = loss.item()
assert_finite_tensor(
loss,
"single-batch loss",
)
loss.backward()
torch.nn.utils.clip_grad_norm_(
model.parameters(),
max_norm=1.0,
)
optimizer.step()
final_loss = loss.item()
if (
step % 50 == 0
or step == steps - 1
):
print(
f"Step {step:4d}: "
f"loss {final_loss:.4f}"
)
print(
"Initial loss:",
initial_loss,
)
print(
"Final loss:",
final_loss,
)
assert final_loss < initial_loss
print(
"Single-batch overfitting "
"test passed."
)
def parse_args():
parser = argparse.ArgumentParser(
description=(
"Run Mini-GPT diagnostic tests"
)
)
parser.add_argument(
"--checkpoint",
type=str,
default=(
"checkpoints/mini_gpt_best.pt"
),
)
parser.add_argument(
"--tokenizer",
type=str,
default=(
"tokenizer/char_tokenizer.json"
),
)
parser.add_argument(
"--train-data",
type=str,
default=(
"data/encoded/train_ids.pt"
),
)
parser.add_argument(
"--overfit-steps",
type=int,
default=300,
)
parser.add_argument(
"--skip-overfit",
action="store_true",
)
return parser.parse_args()
def main():
args = parse_args()
torch.manual_seed(42)
device = get_device()
print("Using device:", device)
tokenizer = CharTokenizer.from_file(
args.tokenizer
)
train_ids = torch.load(
args.train_data,
map_location="cpu",
).long()
checkpoint_path = Path(
args.checkpoint
)
if not checkpoint_path.exists():
raise FileNotFoundError(
f"Checkpoint not found: "
f"{checkpoint_path}"
)
model, checkpoint = load_model(
checkpoint_path=checkpoint_path,
device=device,
)
if (
tokenizer.vocab_size
!= model.config.vocab_size
):
raise ValueError(
"Tokenizer and model vocabulary "
"sizes do not match"
)
print(
"Checkpoint step:",
checkpoint.get("step"),
)
test_tokenizer(
tokenizer,
train_ids,
)
test_token_ids(
tokenizer,
train_ids,
)
test_block_size = min(
32,
model.config.block_size,
)
inputs, targets = test_batch(
tokenizer=tokenizer,
train_ids=train_ids,
block_size=test_block_size,
device=device,
)
test_forward_pass(
model=model,
tokenizer=tokenizer,
inputs=inputs,
targets=targets,
)
test_future_independence(
model=model,
tokenizer=tokenizer,
device=device,
)
test_gradients(
model=model,
inputs=inputs,
targets=targets,
)
test_optimizer_update(
model=model,
inputs=inputs,
targets=targets,
)
if not args.skip_overfit:
run_single_batch_overfit(
tokenizer=tokenizer,
train_ids=train_ids,
device=device,
steps=args.overfit_steps,
)
print(
"\nAll requested diagnostics passed."
)
if __name__ == "__main__":
main()
Les dernières versions de PyTorch ont modifié le comportement par défaut de torch.load pour ne charger que les poids, donc en fonction de votre version et du contenu des fichiers de sauvegarde, vous devrez peut-être définir explicitement weights_only ; consultez la documentation actuelle.
Exécution des diagnostics
Exécutez l’ensemble complet avec les chemins par défaut :
python debug_mini_gpt.py
Évitez l’étape de surapprentissage pour un contrôle rapide. Le paramètre correspondant est --skip-overfit, avec deux tirets en tête :
python debug_mini_gpt.py - skip-overfit
Utilisez un autre fichier de sauvegarde :
python debug_mini_gpt.py \
--checkpoint checkpoints/mini_gpt_latest.pt
Donnez au test de surapprentissage davantage d’étapes :
python debug_mini_gpt.py \
--overfit-steps 500
Le backslash final permet de poursuivre une commande dans les shells de type Unix ; si le vôtre ne le prend pas en charge, placez la commande sur une seule ligne.
L’ordre des opérations de débogage en pratique
Lorsque le modèle ne fonctionne pas correctement, suivez ces étapes sans sauter des phases.
Étapes 1 à 5 : données et formes
Lisez les données décodées :
Does the tokenized dataset decode correctly?
Vérifiez le décalage cible :
Does inputs[:, 1:] equal targets[:, :-1]?
Vérifiez la plage des tokens :
Are all IDs between 0 and vocab_size - 1?
Vérifiez les formes des tenseurs :
Inputs: [B, T]
Targets: [B, T]
Logits: [B, T, V]
Vérifiez la perte initiale :
Is it near log(vocab_size) for a new model?
Étapes 6 à 10 : comportement du modèle et entraînement
Vérifiez l’indépendance causale :
Can changing the future affect prefix logits?
La seule réponse acceptable est non. Vérifiez les gradients :
Are gradients present, finite, and nonzero?
Vérifiez les mises à jour des paramètres :
Does optimizer.step() change weights?
Mémorisez un lot :
Can the model memorize a tiny fixed batch?
Ne lancez l’entraînement complet qu’alors :
Only after all earlier tests pass should you invest in a long training run.
Listes de contrôle pour chaque phase
Au préalable de l’entraînement :
□ Dataset files exist
□ Tokenizer round-trip works
□ Token IDs are within vocabulary range
□ Decoded data looks correct
□ Inputs and targets are shifted by one
□ Batch tensors use torch.long
□ Model and batch use the same device
□ Logits have shape [B, T, V]
□ Initial loss is near log(V)
□ Future-independence test passes
□ All important parameters receive gradients
□ Optimizer changes parameters
□ Model can overfit one batch
Pendant l’entraînement :
□ Loss remains finite
□ Gradient norms remain finite
□ Learning rate follows the intended schedule
□ Training loss decreases
□ Validation loss is evaluated in eval mode
□ Best checkpoint updates when validation improves
□ Samples become more structured
□ Best checkpoint is loaded
□ Matching tokenizer is loaded
□ Model is in evaluation mode
□ Context is cropped to block size
□ Only final-position logits are sampled
□ Temperature is positive
□ Top-k does not exceed vocabulary size
□ Repetition is measured, not only observed
Habitudes qui rendent le débogage plus difficile
Changer de nombreuses paramétrisations en même temps
Si une seule expérience modifie l’ensemble de ces éléments en même temps, on ne peut attribuer le résultat à aucun d’eux :
Learning rate
Batch size
Dropout
Model size
Context length
Changer une seule variable majeure par expérience.
Juger uniquement d’après les échantillons
Un texte de mauvaise qualité peut résulter d’un entraînement insuffisant, d’une décodage déficient, de l’utilisation du mauvais point de contrôle ou du mauvais tokeniseur, d’un surajustement ou d’un sous-ajustement, et les échantillons ne permettent pas de distinguer ces causes. Il convient d’examiner d’abord les métriques et les tests du pipeline.
Supprimer les avertissements
Les avertissements mentionnant des tenseurs réorganisés, un recours à un autre dispositif, des valeurs NaN ou infinies, ou des clés de point de contrôle non correspondantes indiquent souvent des bugs réels ; il faut les comprendre avant de les supprimer.
Sauter les tests de base
Au préalable d’un exécution longue, commencer par quelque chose de simple :
Tiny model
Tiny batch
Short context
Few training steps
Puis augmenter progressivement l’échelle.
Considérer le clipping comme une solution
Le clipping peut absorber une mise à jour trop importante, mais un clipping constant indique qu’un problème plus profond nécessite une attention particulière : le taux d’apprentissage, l’initialisation, l’échelle de la perte, la précision numérique ou des anomalies dans les données.
Exercices : perturber délibérément le pipeline
On fait plus confiance à un test après l’avoir vu échouer, c’est pourquoi chaque exercice introduit une erreur connue.
Supprimer le décalage cible
Rendre les entrées et les cibles identiques, vérifier que le test par lots échoue, puis rétablir le décalage.
Injecter un token hors plage
Fixer l’ID d’un token à :
tokenizer.vocab_size
La vérification de plage doit échouer, car le plus haut ID valide est :
vocab_size - 1
Désactiver le masque causal
Supprimer temporairement le masque et exécuter le test d’indépendance future ; modifier uniquement le suffixe devrait maintenant changer les logits du préfixe.
Utiliser un taux d’apprentissage absurde
Fixer :
learning_rate = 0.1
Surveillez la perte, la norme du gradient, les valeurs des paramètres ainsi que les vérifications de non-finité, et gardez la durée d’exécution courte.
Geler le modèle
Appliquez ce qui suit et observez comment les tests du gradient et de l’optimiseur le rapportent :
for parameter in model.parameters():
parameter.requires_grad = False
Charger dans une architecture incorrecte
Chargez un point de vérification dans un modèle qui diffère sur l’un de ces aspects, puis examinez les erreurs liées aux clés manquantes, aux clés inattendues et aux incohérences de taille :
Vocabulary size
Embedding dimension
Number of layers
Comparer les paramètres de dropout
Exécutez le test à lot unique avec les deux valeurs et comparez la vitesse à laquelle chacune se mémorise :
dropout = 0.0
dropout = 0.2
Écrire un rapport de débogage
Enregistrez ces résultats au format JSON afin de pouvoir comparer les exécutions et reproduire les échecs :
Tokenizer fingerprint
Vocabulary size
Token range
Batch shape
Initial loss
Expected baseline
Gradient norm
Missing gradients
Parameter update size
Single-batch final loss
Points clés
- Un travail qui se termine sans erreur peut néanmoins apprendre la tâche incorrecte.
[B, T, V], et un modèle neuf doit commencer aux alentours de log(vocab_size).Le flux complet :
Environment
→ files
→ tokenizer
→ token IDs
→ batch shifting
→ shapes
→ initial loss
→ causal independence
→ gradients
→ optimizer updates
→ one-batch overfitting
→ full training
→ evaluation
→ generation
Derrière tout cela se trouve une règle simple : ne pas déboguer par intuition ; écrire un test qui isole une hypothèse, la vérifier, puis passer à autre chose.
La prochaine étape logique consiste en une meilleure représentation des données. Les tokens de caractères génèrent de longues séquences, tandis que les vocabulaires de mots deviennent extrêmement volumineux ; l’encodage par paires de bytes apprend à fusionner les séquences de caractères fréquentes, raccourcissant ainsi les séquences afin que la même fenêtre de contexte puisse contenir plus de texte. L’adoption de cette méthode implique l’entraînement des mécanismes de fusion, la réencodage du WikiText-2 ainsi que le réajustement du vocabulaire du modèle, et toutes les vérifications restent inchangées :
Character tokens
→ learned subword merges
→ shorter sequences
→ better use of the context window
Lectures complémentaires
- Le prochain boucle de token : un modèle mental des LLMs avant d’utiliser des agents — Découvrez comment fonctionnent les tokens, les fenêtres de contexte, l’échantillonnage et le cycle de génération, à travers de petits exemples en Python hors ligne qui expliquent l’existence de RAG, ReAct et LangGraph.
- De un chatbot à un seul nœud vers un agent géré par MCP dans LangGraph — Construisez une application LangGraph couche par couche : états et réducteurs, arêtes, boucles d’outils, threads sauvegardés, trois modes de streaming, ainsi que des outils fournis via MCP.