Accueil / Articles / Débogage d’un petit GPT dans PyTorch : tests permettant d’isoler chaque défaillance.

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.

6828 mots

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.
  • Faillites de généralisation et de génération : l’entraînement fonctionne mais le modèle ne le fait pas. La perte de validation augmente, les échantillons tournent en boucle, la sortie ignore le prompt, ou le modèle reproduit des passages de l’entraînement.
  • 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 :

    1. Arrêtez l’exécution.
    2. Identifiez la dernière étape présentant une perte finie.
    3. Baissez le taux d’apprentissage.
    4. Activez le clippage des gradients.
  • Vérifiez à nouveau le masque causal.
  • Désactivez la précision mixte.
  • Examinez les paramètres et les gradients pour détecter des valeurs non finies.
  • 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.
  • Déboguez dans l’ordre du flux de données ; le test aller-retour, la vérification de plage et l’assertion de décalage d’un token permettent de détecter la plupart des erreurs de données.
  • Les logits doivent être de la forme [B, T, V], et un modèle neuf doit commencer aux alentours de log(vocab_size).
  • Le test du préfixe partagé prouve la causalité à travers le comportement.
  • Les vérifications de gradient et une comparaison des paramètres avant et après confirment que l’apprentissage est possible ; un surajustement sur un seul lot confirme le fonctionnement de tout le cycle.
  • Les vérifications de valeurs non finies et les mécanismes d’interception localisent les échecs numériques ; le clippage ne fait qu’en contenir certains.
  • Les courbes d’entraînement et de validation permettent de distinguer le surajustement du sous-ajustement, et tant la qualité du modèle que le processus de décodage influencent le texte généré.
  • Réconstruisez les modèles à partir de la configuration du point de contrôle et vérifiez le tokeniseur par son empreinte numérique.
  • 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

  • Construire un tokeniseur de codage par paires de bytes de zéro à zéro pour un petit GPT — Mettre en œuvre un tokeniseur BPE au niveau des caractères en Python, l’entraîner sur WikiText-2, le sauvegarder et en générer l’empreinte digitale, puis réentraîner un petit GPT à partir de séquences de tokens plus courtes et plus denses.
  • De GPT-1 vers les modèles de raisonnement : qu’est-ce que chaque génération a changé pour les développeurs — Suivre l’évolution de la famille GPT depuis son pré-entraînement en 2018 jusqu’aux modèles de raisonnement, identifier les idées ajoutées par chaque génération, et appeler les fonctionnalités de vision GPT-4o ainsi que les sorties structurées depuis Python.