Startseite / Artikel / Fehlersuche bei einem kleinen GPT in PyTorch: Tests, die jeden Fehler isolieren.

Fehlersuche bei einem kleinen GPT in PyTorch: Tests, die jeden Fehler isolieren.

Ein schrittweiser Arbeitsablauf zur Fehlersuche bei einem GPT auf Zeichenebene in PyTorch, von Token-IDs und Zielverschiebungen bis hin zu Gradienten, NaN-Verlusten und Checkpoints.

6828 Wörter

Bewertungsmetriken wie Verlust, Perplexität und Wiederholungsrate deuten darauf hin, dass ein kleines GPT-Modell falsch funktioniert, aber nur selten warum. Die verlockende Reaktion besteht darin, die Lernrate anzupassen, eine Schicht hinzuzufügen und zu hoffen. Dieser Leitfaden ersetzt dieses Raten durch einen wiederholbaren Arbeitsablauf für ein kompaktes, zeichenbasierendes GPT (Mini-GPT), das auf WikiText-2 trainiert wird: Jedes Symptom wird mit wahrscheinlichen Ursachen in Verbindung gebracht, für jede Pipeline-Phase werden gezielte Überprüfungen durchgeführt und Probleme in der Reihenfolge ihres Auftretens behoben. Am Ende erhält man eine Reihe von Aussagen sowie ein Diagnosescript, das vor jeder aufwändigen Trainingsaufgabe ausgeführt werden kann.

Warum eine GPT-Pipeline fehlerhaft sein kann, ohne abzustürzen

Das Training eines Sprachmodells beinhaltet viele Transformationen, wobei jede die Ausgabe der vorherigen verwendet:

Raw dataset
→ cleaned text
→ tokenizer
→ token IDs
→ training batches
→ embeddings
→ Transformer blocks
→ vocabulary logits
→ cross-entropy loss
→ gradients
→ optimizer
→ checkpoints
→ generation

Ein Defekt an irgendeiner Stelle beeinträchtigt alles danach. Angenommen, die Zielwerte sind nicht um eine Position gegenüber den Eingaben verschoben:

Input: The cat
Target: The cat

Das Netzwerk wird nun dafür belohnt, den bereits bekannten Token zu reproduzieren, anstatt den nächsten vorherzusagen. Es tritt nichts Falsches auf, und der Verlust kann weiter sinken, da das Kopieren einfach ist. Das Modell optimiert lediglich das falsche Ziel. Das ist der entscheidende Unterschied zur Fehlersuche bei einem Webdienst, wo ein falscher Rückgabewert in der Regel einen Test oder eine Seite zum Scheitern bringt:

Codeteile, die bis zum Ende ausgeführt werden, können trotzdem ein defektes Modell trainieren.

Arbeiten Sie vom Anfang bis zum Ende der Pipeline

Überprüfen Sie die einzelnen Schritte in der Reihenfolge, in der die Daten durch sie fließen:

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

Die Beurteilung der Generierungsqualität, bevor die Datenpipeline bestätigt wurde, ist zeitraubend, da ein schlechtes Beispiel aus einem der elf früheren Schritte stammen könnte. In jedem Schritt sollten Sie sich eine spezifische Frage stellen und diese mit einer Prüfung beantworten, die entweder erfolgreich oder fehlgeschlagen ist.

Vier Arten von Fehlern

Fast jedes Problem mit einem solchen Modell fällt in eine von vier Kategorien, und das Wissen um die Kategorie eingrenzt die Suche.

  • Korrektheitsfehler: Der Code ist logisch falsch. Die Ziele werden nicht verschoben, die kausale Maske ermöglicht es Positionen, späteren Tokenn zuzugreifen, der Verlust verwendet Tensoren mit falscher Struktur, oder die IDs des Tokenisierers stimmen nicht mit dem Wortschatz überein, für den das Modell entwickelt wurde.
  • Zahlentypfehler: Die Mathematik wird instabil. Der Verlust wird zu NaN, die Gradienten explodieren, die Logits überschreiten den Unendlichkeitswert, oder der Softmax erhält eine Zeile ohne gültige Einträge.
  • Optimierungsfehler: Die Implementierung ist korrekt, aber das Lernen ist unwirksam, weil die Lernrate zu hoch oder zu niedrig ist, das Modell zu klein ist oder die Laufzeit zu kurz ist.
  • Fehler bei Generalisierung und Generierung: Das Training funktioniert, doch das Modell nicht. Der Validierungsverlust steigt, die Beispiele wiederholen sich, die Ausgabe ignoriert den Prompt oder das Modell reproduziert Trainingsabschnitte.
  • Fangen Sie mit einer kleinen Debug-Konfiguration an

    Das Debuggen bei einem vollständigen Lauf verwandelt jede Hypothese in ein langes Warten. Definieren Sie ein kleines Modell, das innerhalb von Sekunden einige Beispiele merken kann:

    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,
    )
    

    Kombinieren Sie es mit einer kleinen Batch-Größe:

    debug_batch_size = 8
    

    und einem kurzen Laufzeitintervall:

    debug_steps = 200
    

    Dropout ist absichtlich deaktiviert:

    dropout = 0.0
    

    Dropout setzt zufällige Aktivierungen auf Null, wodurch identische Laufe abweichen. Seine Deaktivierung (zusammen mit einer festen Seed-Wert) sorgt dafür, dass jeder Test reproduzierbar ist. Setzen Sie die Produktionseinstellungen wieder ein, sobald der Pipeline erfolgreich ist.

    Überprüfen Sie die Laufzeitumgebung

    Vor dem Berühren des Modells sollten Sie die Versionen von Python und PyTorch sowie anzeigen, ob CUDA oder Apples Metal-Backend (MPS) nutzbar ist:

    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(),
        )
    

    Ein Hilfsprogramm wählt das beste Gerät aus, wobei CUDA bevorzugt wird, anschließend MPS und dann der CPU. Der hasattr-Schutz sorgt dafür, dass es auch auf älteren Versionen ohne MPS-Backend funktioniert:

    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")
    

    Rufen Sie es einmal auf und protokollieren Sie das Ergebnis:

    device = get_device()
    print("Selected device:", device)
    

    Wenn das Training seltsamerweise langsam ist, liegt die Ursache oft an dieser Zeile: Die Aufgabe erwartete eine GPU, musste aber aufgrund eines Treiber- oder Installationsproblems auf den CPU ausweichen.

    Stellen Sie sicher, dass alle Eingabedateien vorhanden sind

    Überprüfen Sie, ob die Definition des Tokenisierers sowie die kodierten Trainings-, Validierungs- und Testdatensätze vorhanden sind. Führen Sie bei Fehlen frühzeitig einen klaren FileNotFoundError aus, anstatt dass ein verwirrender Fehler tief im Trainingsschleifen auftaucht:

    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)
    

    Drucken Sie auch die Größen aus:

    for path in required_paths:
        print(
            path,
            path.stat().st_size,
            "bytes",
        )
    

    Eine leere oder ungewöhnlich kleine Datei bedeutet in der Regel, dass eine Vorkonvertierungsaktion unterbrochen wurde und ein unvollständiges Ergebnis zurückließ.

    Testen Sie den Tokenizer isoliert

    Laden Sie den Zeichentokenizer:

    tokenizer = CharTokenizer.from_file(
        "tokenizer/char_tokenizer.json"
    )
    

    Überprüfen Sie die Größe des Wortschatzes sowie beide Enden der Zeichelliste auf fehlende oder beschädigte Elemente:

    print("Vocabulary size:", tokenizer.vocab_size)
    print("First tokens:", tokenizer.chars[:20])
    print("Last tokens:", tokenizer.chars[-20:])
    

    Die Round-Trip-Eigenschaft

    Ein verlustfreier Tokenizer gibt nach der Kodierung und Dekodierung genau die ursprüngliche Eingabe zurück. Das Ausgeben mit repr macht unsichtbare Zeichen wie Leerzeichen am Ende sichtbar:

    sample = "The history of"
    
    encoded = tokenizer.encode(sample)
    decoded = tokenizer.decode(encoded)
    
    print("Encoded:", encoded)
    print("Decoded:", repr(decoded))
    
    assert decoded == sample
    

    Die überprüfte Invarianz:

    decode(encode(text)) = text
    

    Ein Fehler bedeutet, dass mindestens ein Zeichen nicht zuverlässig dargestellt werden kann – in der Regel ein Symbol, das im Wörterbuch fehlt. Die encode-Methode im vollständigen Skript löst in diesem Fall einen KeyError aus, anstatt es stillschweigend zu ignorieren, was eigentlich gewünscht ist.

    Fehler bei Inkonsistenzen zwischen Tokenisierer und Checkpoint erfassen

    Gleiche Größen des Wörterbuchs sind notwendig, aber nicht ausreichend. Zwei Wörterbücher können jeweils 100 Zeichen enthalten und dennoch unterschiedliche IDs zuweisen:

    Tokenizer A: "a" → 10
    Tokenizer B: "a" → 24
    

    Ein mit einer Kartei trainiertes Modell, das mit einer anderen ausgegeben wird, erzeugt Unsinn, obwohl die Formen aller Tensor korrekt erscheinen. Speichern Sie das Wörterbuch zusammen mit dem Checkpoint – oder zumindest einen Fingerabdruck davon. Ein SHA-256-Hash der serialisierten Zeichelliste funktioniert hier; die Korrektur von separators und ensure_ascii stellt sicher, dass dieselbe Liste immer zu denselben Bytes serialisiert wird:

    import hashlib
    import json
    
    
    def tokenizer_fingerprint(chars):
        payload = json.dumps(
            chars,
            ensure_ascii=False,
            separators=(",", ":"),
        ).encode("utf-8")
    
        return hashlib.sha256(
            payload
        ).hexdigest()
    

    Berechnen Sie es für den geladenen Tokenisator:

    fingerprint = tokenizer_fingerprint(
        tokenizer.chars
    )
    
    print("Tokenizer fingerprint:", fingerprint)
    

    Speichern Sie es im Checkpoint-Dictionary beim Speichern:

    checkpoint[
        "tokenizer_fingerprint"
    ] = fingerprint
    

    Beim Laden vergleichen Sie die Werte und lehnen Sie den Fortgang bei einer Abweichung ab. Die Bedingung is not None erlaubt es weiterhin älteren Checkpoints ohne Fingerabdruck, geladen zu werden:

    saved_fingerprint = checkpoint.get(
        "tokenizer_fingerprint"
    )
    
    if (
        saved_fingerprint is not None
        and saved_fingerprint != fingerprint
    ):
        raise ValueError(
            "Checkpoint and tokenizer do not match"
        )
    

    Überprüfen Sie die kodierten Token-IDs

    Laden Sie den Trainingsdatensatz auf die CPU als 64-Bit-Integer, wie es die Type-Embeddings und Cross-Entropy erwarten:

    train_ids = torch.load(
        "data/encoded/train_ids.pt",
        map_location="cpu",
    ).long()
    

    Drucken Sie Form, Datentyp und Wertebereich aus:

    print("Shape:", train_ids.shape)
    print("Dtype:", train_ids.dtype)
    print("Minimum ID:", train_ids.min().item())
    print("Maximum ID:", train_ids.max().item())
    

    Jede ID muss innerhalb des Wortschatzes liegen:

    0 ≤ token ID < vocabulary size
    

    Als Assertions:

    assert train_ids.min().item() >= 0
    
    assert (
        train_ids.max().item()
        < tokenizer.vocab_size
    )
    

    Ein außerhalb des Bereichs liegender ID stört die Suche im Embedding-Modell, und bei einer GPU kann der Fehler als unaufklärbare Behauptung auf der Geräteebene auftreten, weit entfernt von seiner Ursache. Typische Gründe sind ein fehlerhafter Tokenisierungsdatei, beschädigte kodierte Dateien, ein nach der Kodierung neu aufgebauter Wortschatz oder inkonsistente Handhabung von Sonderzeichen.

    Lesen Sie die gespeicherten Daten als Text zurück

    Auch Zahlen innerhalb des Bereichs können weiterhin falschen Text kodieren, daher decodieren Sie einige hundert IDs und lesen Sie sie aus:

    sample_ids = train_ids[:500]
    
    sample_text = tokenizer.decode(
        sample_ids.tolist()
    )
    
    print(sample_text)
    

    Sie sollten lesbaren WikiText mit seiner üblichen Formatierung, Zeilenumbrüchen, Überschriften und Satzzeichen sehen, ohne wiederholte oder beschädigte Zeichenfolgen. Wenn das Beispiel falsch aussieht, stoppen Sie: Keine Modifikation des Modells kann einen fehlerhaften Tokenisierer oder Datensatz ausgleichen.

    Überprüfen Sie den Shift von einem Token zwischen Eingaben und Zielen

    Bauen Sie ein Beispiel manuell auf, wobei das Zielfenster eine Position später beginnt:

    block_size = 32
    start = 100
    
    inputs = train_ids[
        start:
        start + block_size
    ]
    
    targets = train_ids[
        start + 1:
        start + block_size + 1
    ]
    

    Dekodieren Sie beide, um sie miteinander zu vergleichen:

    input_text = tokenizer.decode(
        inputs.tolist()
    )
    
    target_text = tokenizer.decode(
        targets.tolist()
    )
    
    print("Input: ", repr(input_text))
    print("Target:", repr(target_text))
    

    Der Zielwert sollte wie der Eingabewert aussehen, wobei das erste Zeichen weggelassen und ein neues Zeichen hinzugefügt wird. Prüfen Sie anschließend die Beziehung der Tensorwerte:

    assert torch.equal(
        inputs[1:],
        targets[:-1],
    )
    

    Nur wenige Überprüfungen im Projekt erkennen ernsthaftere Fehler. Die Invarianz lautet:

    inputs[1:] == targets[:-1]
    

    Jeder Position im Zielwert entspricht dem Token, das auf der entsprechenden Eingabeposition folgt – genau das benötigt die Vorhersage des nächsten Tokens.

    Der Fehler bei identischen Slices

    Der klassische Fehler besteht darin, für beide Slices dieselben Grenzen zu verwenden:

    inputs = data[
        start:
        start + block_size
    ]
    
    targets = data[
        start:
        start + block_size
    ]
    

    Dann lernt das Modell eine Identitätsabbildung:

    Current token → current token
    

    Durch Starten des Ziel-Slices einen Token später wird dieser Fehler behoben:

    targets = data[
        start + 1:
        start + block_size + 1
    ]
    

    und die ursprüngliche Aufgabe wird wiederhergestellt:

    Current context → next token
    

    Ein deutliches Anzeichen für diesen Fehler ist ein Verlustwert, der bereits frühzeitig verdächtig schnell abfällt.

    Überprüfen Sie die Formen, Datentypen und Geräte der Batchs

    Nehmen Sie einen echten Batch als Beispiel:

    inputs, targets = get_batch(
        data=train_ids,
        batch_size=8,
        block_size=32,
        device=device,
    )
    

    Drucken Sie alles aus, was falsch sein könnte:

    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)
    

    Für eine Batch-Größe von 8 und eine Blockgröße von 32 ist Folgendes zu erwarten:

    Input shape:  [8, 32]
    Target shape: [8, 32]
    Dtype:        torch.int64
    Device:       same as model
    

    Machen Sie die Erwartungen dauerhaft:

    assert inputs.shape == targets.shape
    assert inputs.dtype == torch.long
    assert targets.dtype == torch.long
    assert inputs.device == device
    assert targets.device == device
    

    Beseitigen Sie Geräteunterschiede

    Diese Fehler treten ständig bei der Arbeit mit PyTorch auf:

    Expected all tensors to be on the same device
    

    Eine Operation erhielt Tensor auf unterschiedlichen Geräten, wie CPU und GPU. Drucken Sie an, wo die Parameter und der Batch liegen:

    model_device = next(
        model.parameters()
    ).device
    
    print("Model device:", model_device)
    print("Input device:", inputs.device)
    

    Verlegen Sie das Modell sowie jeden Batch explizit:

    model = model.to(device)
    inputs = inputs.to(device)
    targets = targets.to(device)
    

    Ein subtilerer Fehler versteckt sich im Modell: torch.arange verwendet standardmäßig die CPU, daher holen Sie das Gerät aus den eingehenden Tokenen:

    positions = torch.arange(
        sequence_length,
        device=token_ids.device,
    )
    

    Andernfalls scheitert das Hinzufügen von PositionsEmbeddings zu TokenEmbeddings unter CUDA oder MPS. Die Verknüpfung des Geräts mit der Eingabe sorgt außerdem dafür, dass das Modell portabel bleibt.

    Die Forward-Pass-Logik überprüfen

    Führen Sie einen Batch mit Zielen aus, damit das Modell Logits und Verlustwert zurückgibt:

    logits, loss = model(
        inputs,
        targets,
    )
    

    Prüfen Sie diese Werte:

    print("Logits shape:", logits.shape)
    print("Loss shape:", loss.shape)
    print("Loss value:", loss.item())
    

    Logits benötigen einen Score pro Wortschatz-Eintrag für jede Position, und der Verlustwert muss skalar sein:

    assert logits.shape == (
        inputs.size(0),
        inputs.size(1),
        tokenizer.vocab_size,
    )
    
    assert loss.ndim == 0
    

    Logits mit der Form [B, V, T] deuten darauf hin, dass durch Transponieren oder Umformen die Dimensionen in der falschen Reihenfolge angeordnet wurden. Da F.cross_entropy die Klassenscores in der zweiten Dimension erwartet, kann ein falsch geordneter Tensor manchmal ohne Fehler zum Verlustwert gelangen und einen sinnlosen Wert berechnen.

    Den anfänglichen Verlustwert mit dem zufälligen Baseline-Verlust vergleichen

    Ein frisch initialisierter Modell mit kleinen Gewichten prognostiziert eine fast gleichmäßige Verteilung, und die Kreuzentropie gegenüber einer gleichmäßigen Verteilung über V Klassen beträgt log(V):

    import math
    
    expected_loss = math.log(
        tokenizer.vocab_size
    )
    
    print("Expected loss:", expected_loss)
    print("Actual loss:", loss.item())
    

    Eine kleine Abweichung ist normal; eine große ist ein Hinweis. Ein anfänglicher Verlust, der weit über dem Baseline liegt, deutet auf extreme Logits, instabile Initialisierung, ungültige Token-IDs, Ziele, die nicht zum Wortschatz passen, oder eine falsche Ausgabestruktur hin. Ein anfänglicher Verlust, der weit darunter liegt – etwas, was ein Modell ohne Kenntnisse nicht ehrlich erreichen kann – deutet auf Datenlecks, versehentlich geladene trainierte Gewichte, Ziele, die den Eingaben entsprechen, sichtbare zukünftige Token oder eine unbeabsichtigte Wiederaufnahme eines Checkpoints hin.

    Beweisen Sie, dass die kausale Maske funktioniert

    Ein GPT muss jede Position ausschließlich anhand früherer Token vorhersagen. Erstellen Sie zwei Sequenzen mit einem gemeinsamen Präfix und unterschiedlichen Suffixen; wenn das Modell kausal ist, müssen die Präfix-Logits übereinstimmen. Im Evaluierungsmodus wird Dropout deaktiviert, damit Zufälligkeiten nicht stören:

    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,
    )
    

    Führen Sie beide ohne Gradienten aus:

    with torch.no_grad():
        logits_a, _ = model(sequence_a)
        logits_b, _ = model(sequence_b)
    

    Messen Sie den größten Präfixunterschied:

    prefix_difference = (
        logits_a[:, :prefix_length, :]
        - logits_b[:, :prefix_length, :]
    ).abs().max().item()
    
    print(
        "Maximum prefix difference:",
        prefix_difference,
    )
    

    Überprüfen Sie die Gleichheit innerhalb einer kleinen Toleranz, die Schwingungen im Fließkommazahlenbereich berücksichtigt:

    assert torch.allclose(
        logits_a[:, :prefix_length, :],
        logits_b[:, :prefix_length, :],
        atol=1e-5,
    )
    

    Ein Versagen bedeutet, dass späteren Positionen Informationen zu früheren gelangen – meist weil das Maskentensor fehlt, auf die falsche Dimension angewendet wird oder aus dem falschen Dreieck erstellt wurde. Das End-zu-Ende-Testen ist effektiver als die Überprüfung des Maskentensors.

    Überanpassung an eine einzige Batch-Gruppe

    Falls Sie eine Technik aus diesem Leitfaden übernehmen, wählen Sie diese:

    Ein Modell mit ausreichender Kapazität sollte in der Lage sein, eine kleine Datensammlung zu merken.

    Es trainiert gleichzeitig Daten, Modell, Verlustfunktion, Backpropagation und Optimierer. Korrigieren Sie eine Datensammlung, die bei jedem Schritt wiederverwendet wird:

    fixed_inputs, fixed_targets = get_batch(
        data=train_ids,
        batch_size=8,
        block_size=32,
        device=device,
    )
    

    Bauen Sie das kleine Modell ohne Dropout auf:

    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)
    

    Trainieren Sie wiederholt mit dieser Datensammlung und protokollieren Sie alle 50 Schritte:

    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(),
            )
    

    Der Verlust sollte deutlich unter dem Baseline-Wert liegen. Falls nicht, vermuten Sie einen fehlerhaften Verlust, Gradienten, die bestimmte Parameter nie erreichen, nicht verschobene Zielwerte, ein zu kleines Modell selbst für diesen Zweck, eine falsch gewählte Lernrate, ein fehlerhafter kausaler Filter oder einen Optimierer, der nichts aktualisiert. Betrachten Sie den Test als Kontrollmechanismus für jedes vollständige Trainingsschritt.

    Stellen Sie sicher, dass Gradienten alle Parameter erreichen

    Führen Sie einen Vorwärts- und Rückwärtspass durch:

    optimizer.zero_grad(
        set_to_none=True
    )
    
    _, loss = model(
        inputs,
        targets,
    )
    
    loss.backward()
    

    Melden Sie jeden trainierbaren Parameter, dessen .grad immer noch None ist, sowie die Norm für den Rest:

    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(),
            )
    

    Ein fehlender Gradient bedeutet in der Regel eine in __init__ definierte Schicht, die in forward nicht verwendet wird, einen versehentlichen Aufruf von .detach(), eine Vorwärtsverarbeitungsstrecke, die einen Komponenten überspringt, eine aus einem getrennten Tensor berechnete Verlustfunktion oder requires_grad=False.

    Überwachen Sie die globale Gradienten-Norm

    Parameterbezogene Normen helfen dabei, inaktive Schichten zu erkennen; eine zusammengefasste Zahl zeigt die Stabilität im Laufe der Zeit an. Diese Funktion kombiniert alle L2-Normen der Gradienten, dieselben Werte, die auch für das Clipping verwendet werden:

    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
    

    Protokollieren Sie sie nach jedem Rückwärtslauf:

    gradient_norm = (
        calculate_gradient_norm(model)
    )
    
    print("Gradient norm:", gradient_norm)
    

    Achten Sie auf Normen, die genau null sind, extrem groß oder sprunghaft sind, NaN-Werte aufweisen oder inf-Werte haben.

    Erkennen Sie NaN- und Inf-Werte frühzeitig

    Ein Hilfsprogramm wird ausgelöst, sobald ein Tensor einen nicht-endlichen Wert enthält, und gibt den Namen des Tensors an:

    def assert_finite_tensor(
        tensor,
        name,
    ):
        if not torch.isfinite(
            tensor
        ).all():
            raise FloatingPointError(
                f"{name} contains NaN or infinity"
            )
    

    Wenden Sie es auf Logits und Verlustwerte an:

    assert_finite_tensor(
        logits,
        "logits",
    )
    
    assert_finite_tensor(
        loss,
        "loss",
    )
    

    und auf jeden Gradienten nach backward():

    for name, parameter in (
        model.named_parameters()
    ):
        if parameter.grad is not None:
            assert_finite_tensor(
                parameter.grad,
                f"gradient for {name}",
            )
    

    Durch Überprüfung mehrerer Punkte wird der erste Ort aufgedeckt, an dem ungültige Zahlen auftauchen, was weitaus nützlicher ist als das Erkennen eines NaN-Verlusts Hunderte von Schritten später.

    Warum der Verlust zu NaN wird

    Häufige Ursachen: eine zu hohe Lernrate, explodierende Gradienten, eine Aufmerksamkeitszeile mit maskierten Positionen, ungültige Eingaben für Softmax, Überlauf bei gemischter Präzision, bereits beschädigte Parameter, Division durch Null, ein Logarithmus von Null oder einem negativen Wert sowie unendliche Logits. Wenn dies eintritt:

    1. Stoppen Sie den Lauf.
    2. Finden Sie den letzten Schritt mit einem endlichen Verlust.
    3. Verringern Sie die Lernrate.
    4. Aktivieren Sie das Gradienten-Klippverhalten.
  • Überprüfen Sie erneut die Kausalitätsmaske.
  • Schalten Sie die gemischte Präzision aus.
  • Prüfen Sie Parameter und Gradienten auf nicht-endliche Werte.
  • Setzen Sie niemals mit der Berechnung fort, sobald die Parameter NaN enthalten; jede Aktualisierung verbreitet den Fehler, daher sollten Sie stattdessen von dem letzten gültigen Checkpoint aus fortfahren.

    Verwenden Sie Gradienten-Klippung als Schutzmaßnahme

    Durch Klippung werden Gradienten neu skaliert, deren kombinierte Norm einen Schwellenwert überschreitet; daher sollte sie zwischen backward() und optimizer.step() eingesetzt werden:

    loss.backward()
    
    gradient_norm = (
        torch.nn.utils.clip_grad_norm_(
            model.parameters(),
            max_norm=1.0,
        )
    )
    
    optimizer.step()
    

    clip_grad_norm_ gibt die vor der Klippung gemessene Norm zurück, was gleichzeitig zur Überwachung dient:

    print(
        "Gradient norm before clipping:",
        float(gradient_norm),
    )
    

    Falls der Schwellenwert fast bei jedem Schritt überschritten wird, verdeckt die Klippung ein Problem wie eine zu hohe Lernrate oder numerische Instabilität. Sie schützt vor gelegentlichen fehlerhaften Datensätzen, ersetzt aber keine sinnvolle Lernrate.

    Überprüfen Sie, ob der Optimierer die Gewichte ändert

    Kopieren Sie einen Parameter vor einem Update. Die Methode .clone() ist wichtig: Ohne sie teilt sich der Wert von before den Speicher mit dem Parameter und ändert sich ebenfalls:

    parameter_name, parameter = next(
        model.named_parameters()
    )
    
    before = parameter.detach().clone()
    

    Führen Sie einen Trainingsschritt aus:

    optimizer.zero_grad(
        set_to_none=True
    )
    
    _, loss = model(
        inputs,
        targets,
    )
    
    loss.backward()
    optimizer.step()
    

    Messen Sie die Veränderung:

    after = parameter.detach()
    
    maximum_change = (
        after - before
    ).abs().max().item()
    
    print(
        "Maximum parameter change:",
        maximum_change,
    )
    

    und stellen Sie sicher, dass eine Veränderung stattfindet:

    assert maximum_change > 0
    

    Unveränderte Gewichte deuten auf eine Null-Lernrate, einen Optimierer hin, der ohne die Parameter des Modells erstellt wurde (zum Beispiel bevor das Modell ersetzt wurde), fehlende Gradienten, ein fehlendes optimizer.step() oder eingefrorene Parameter.

    Stellen Sie die Lernrate in beide Richtungen ein

    Eine zu hohe Lernrate zeigt sich durch schnell ansteigende Verlustwerte oder starke Schwankungen, sehr große Gradientennormen, NaN-Verlustwerte sowie Beispiele, die sich nie verbessern. Eine erste Lösung besteht darin, den Höchstwert zu senken, zum Beispiel auf:

    max_learning_rate = 1e-4
    

    Anstatt:

    max_learning_rate = 1e-3
    

    Protokollieren Sie die Lernrate neben dem Verlustwert. Bei Warmup beginnt die Instabilität oft genau am Peak, was eine alleinige Verlustdarstellung verdeckt.

    Eine zu niedrige Lernrate zeigt sich anders: Der Verlust sinkt sehr langsam, obwohl Gradienten vorhanden sind, die Parameter bewegen sich kaum, und selbst der Test mit einer einzigen Batch-Größe erfordert viele Schritte. Erhöhen Sie sie beispielsweise auf:

    max_learning_rate = 3e-4
    

    Anstatt:

    max_learning_rate = 1e-5
    

    Es gibt keinen universell richtigen Wert; dieser hängt von der Größe des Modells und des Batches, dem Optimierer sowie dem Datensatz ab. Führen Sie kurze Experimente durch, bei denen nur die Lernrate geändert wird.

    Checkliste für einen Verlustwert, der nicht sinkt

    Gehen Sie diese Fragen in dieser Reihenfolge durch. Daten:

    Are targets shifted by one token?
    Are token IDs within range?
    Does decoded input look correct?
    

    Modell:

    Are logits shaped [B, T, V]?
    Is the causal mask valid?
    Are positions on the correct device?
    

    Verlustwert:

    Does cross-entropy receive raw logits?
    Are logits and targets flattened correctly?
    

    Das Eingeben von Softmax-Wahrscheinlichkeiten in cross_entropy ist ein klassischer Fehler, da die Funktion selbst eine log-Softmax-Anwendung vornimmt. Gradienten:

    Do all important parameters receive gradients?
    Are gradient norms finite and nonzero?
    

    Optimierer:

    Is the learning rate positive?
    Does optimizer.step() run?
    Do parameters change?
    

    Kapazität:

    Can the model overfit one batch?
    This order avoids random trial and error.
    

    Diese Reihenfolge beseitigt nacheinander eine Klassenart als Ursache, anstatt auf Versuch und Irrtum zu setzen.

    Überanpassung und Unteranpassung erkennen

    Überanpassung zeigt sich in auseinanderdriftenden Kurven:

    Training loss: continues decreasing
    Validation loss: stops decreasing or increases
    

    Den Unterschied quantifizieren:

    generalization_gap = (
        validation_loss
        - training_loss
    )
    

    Zu den Lösungen gehören das Beibehalten des besten Validierungs-Checkpoints, die Erhöhung von Dropout oder Gewichtsabnahme, das Verkleinern des Modells, das Hinzufügen mehrerer oder vielfältigerer Daten sowie ein früheres Stoppen. Wählen Sie den Stopppunkt mithilfe der Validierungsdaten; die Verwendung der Testdaten führt zu Informationslecks und erhöht die Endwertung.

    Unteranpassung zeigt sich in zwei Kurven, die zusammen auf hohem Niveau bleiben:

    Training loss:   remains high
    Validation loss: remains similarly high
    

    Mögliche Gründe sind zu geringe Kapazität, eine zu kurze Ausführungsdauer, ein zu niedriger Lernrate, ein kurzes Kontextfenster, Daten, die für die Architektur zu schwierig sind, oder eine Tokenisierung, die Kontextinformationen verschwendet. Zu den Lösungsmöglichkeiten gehören mehr Schritte, eine größere Embedding-Dimension, mehr Transformer-Schichten, ein längeres Kontextfenster, Byte-Pair-Encoding (BPE) anstelle von Zeichen sowie eine Anpassung der Lernrate. Führen Sie zunächst den Test mit einer einzigen Batch-Größe erneut durch: Wenn das Modell einen Batch nicht merken kann, liegt das Problem in der Korrektheit oder Optimierung und nicht in der Kapazität.

    Diagnose wiederholter Generierung

    Wiederholungen sehen so aus:

    the the the the
    

    oder mit WikiText-Überschriftsmarkierungen:

    = = = = = = =
    

    Ursachen sind unter anderem gieriges Dekodieren, eine sehr niedrige Temperatur, ein zu kleiner Top-k-Wert, ein untertrainiertes oder überangepasstes Modell, wiederholte Strukturen in den Daten sowie ein kurzes Kontextfenster. Versuchen Sie ein ausgewogeneres Sampling:

    temperature = 0.8
    top_k = 20
    top_p = 0.9
    

    Eine Wiederholungsstrafe kann hilfreich sein, wenn sie mild bleibt:

    repetition_penalty = 1.05
    

    In Zeichnungsmodellen schreckt eine starke Strafe davon ab, Buchstaben zu wiederverwenden, und zerstört so schnell die Rechtschreibung. Wenn jede Dekodierungsanpassung weiterhin Schleifen auslöst, liegt das Problem beim Modell und nicht beim Sampler. Zu den Wechselwirkungen dieser Einstellungen siehe unsere Anleitung zu Temperaturen, Top-K und Top-P.

    Diagnose chaotischer Generierung

    Das gegenteilige Problem führt zu unerwünschten Symbolen, fehlerhaften Wörtern, übermäßigem Satzzeichengebrauch, plötzlichen Themensprüngen und unlesbaren Zeichenfolgen. Mögliche Ursachen sind eine hohe Temperatur, kein Top-K- oder Top-P-Filtern, ein nicht passender Tokenisierer, der falsche Checkpoint, ein unzureichend trainiertes Modell mit hohem Validierungsverlust oder Gewichte, die nie geladen wurden. Versuchen Sie ein strengeres Sampling:

    temperature = 0.6
    top_k = 10
    top_p = 0.9
    

    Überprüfen Sie, ob die Gewichte tatsächlich aus dem Checkpoint stammen:

    model.load_state_dict(
        checkpoint["model_state_dict"]
    )
    

    und ob während des Samplings der Dropout deaktiviert ist:

    model.eval()
    

    Wenn die Ausgabe den Prompt ignoriert

    Der Prompt kann sehr kurz sein oder sich von den Trainingsdaten unterscheiden; das Modell könnte klein, unzureichend trainiert sein, schwach bei langfristigen Abhängigkeiten oder durch einen kurzen Kontext eingeschränkt sein; außerdem erschweren Zeichentoken das Lernen semantischer Muster. Testen Sie längere, wikiartige Prompts. Vergleichen Sie einen minimalen Prompt mit einem umfangreicheren:

    "The "
    

    mit einem reichhaltigeren Prompt:

    "The history of the city began"
    

    Der umfangreichere Prompt bietet dem Modell viel mehr Anhaltspunkte für die Konditionierung. Falls die Fortsetzungen weiterhin abweichen, prüfen Sie den Validierungsverlust sowie die Implementierung der Aufmerksamkeit.

    Checkpoints, die sich weigern zu laden

    Die Fehler sind bekannt:

    Missing key(s) in state_dict
    Unexpected key(s) in state_dict
    Size mismatch
    

    Das bedeutet, das von Ihnen erstellte Modell ist nicht dasselbe wie das gespeicherte: Seine Konfiguration, die Anzahl der Schichten, die Dimension der Embeddings, die Größe des Wortschatzes oder die Gewichtsverbindungen haben sich geändert, Klassen oder Attribute wurden umbenannt, oder es wird ein Optimiererzustand aus einer anderen Architektur geladen. Überprüfen Sie die gespeicherte Konfiguration:

    print(
        checkpoint["config"]
    )
    

    Erstellen Sie stattdessen das Modell auf der Grundlage dieser Konfiguration und nicht auf Basis der aktuellen Standardwerte:

    config = MiniGPTConfig(
        **checkpoint["config"]
    )
    
    model = MiniGPT(config)
    

    Laden Sie anschließend das Zustandsdictionary. Wenn ein Modell auf der Grundlage einer neuen Konfiguration erstellt wird und man erwartet, dass die alten Gewichte passen, entstehen die meisten dieser Fehler.

    Aufzählung fehlender und unerwarteter Schlüssel

    Nur zu Diagnosezwecken: Laden Sie die Daten unpräzise und geben Sie die Abweichungen aus:

    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,
    )
    

    Die Listen zeigen in der Regel die Ursache an, wie zum Beispiel ein umbenannter Untermodul. Für Inferenz oder fortgesetztes Training sollte eine strenge Ladenlogik verwendet werden, damit inkompatible Checkpoints deutlich fehlschlagen, anstatt die Schichten mit zufälligen Anfangswerten zu belassen.

    Optimiererzustand auf das richtige Gerät verschieben

    Nach dem Wiederherstellen eines Optimierers können seine internen Tensor-Strukturen (wie die Momentenschätzungen von AdamW) auf einem anderen Gerät als das Modell liegen. Dieser Hilfsfunktion verschiebt alle Tensor im Zustand dorthin:

    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
                    )
    

    Rufen Sie sie unmittelbar nach dem Laden auf:

    optimizer.load_state_dict(
        checkpoint[
            "optimizer_state_dict"
        ]
    )
    
    move_optimizer_to_device(
        optimizer,
        device,
    )
    

    Das ist besonders wichtig, wenn auf einem Rechner gespeichert und auf einem anderen fortgesetzt wird – beispielsweise von CUDA zu MPS oder CPU.

    Die wichtigsten Überprüfungen in einer einzigen Gesundheitsfunktion bündeln

    Sammeln Sie die wichtigsten Prüfungen in einer Funktion zusammen: Datentyp und Wertebereich der Token, Zielverschiebung, Form des Logits, endliche Verlustfunktion sowie einen Vergleich mit der zufälligen Baseline:

    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.")
    

    Bei einem trainierten Checkpoint sollte der Verlust eindeutig unter dem Baseline-Wert liegen; andernfalls wurden die Gewichte nicht geladen oder der Tokenizer stimmt nicht überein.

    Numerische Probleme mit Forward-Hooks finden

    Wenn ein NaN an einer unbekannten Stelle auftaucht, überprüfen Forward-Hooks während des Durchlaufs die Ausgabe jedes Moduls. Dieser Hook handhabt einzelne Tensor- und Tupelwerte und wirft bei dem ersten nicht-endlichen Wert einen Fehler mit dem Namen der Modulklasse aus:

    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__}"
                )
    

    Fügen Sie ihn jedem linearen, layer-norm- und Embedding-Modul hinzu und speichern Sie die Handles auf:

    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
                )
            )
    

    Führen Sie einen Forward-Pass durch; da die Schichten nacheinander ausgeführt werden, gibt der erste Fehler an, um welchen fehlerhaften Layer-Typ es sich handelt. Entfernen Sie anschließend die Hooks:

    for hook in hooks:
        hook.remove()
    

    Hooks werden bei jedem Vorwärtsaufruf ausgeführt und verlangsamen das Modell, daher sollten sie nur beim Fehlersuchen verwendet werden. Für den genauen Modulpfad sollten bei der Registrierung die Namen aus named_modules() aufgezeichnet werden.

    Ein vollständiges Diagnosescript

    Sämtliche Überprüfungen sind in einem Befehlszeilenwerkzeug zusammengefasst. Speichern Sie es als:

    debug_mini_gpt.py
    

    Das Skript definiert einen minimalen CharTokenizer, die Geräteauswahl, den Fingerabdruck, einen Hilfsfunktion für das Batching sowie numerische Werkzeuge. Es erstellt das Modell anhand der eigenen Konfiguration des Checkpoints, lehnt Tokenizer ab, deren Wortschatzgröße unterschiedlich ist, und führt anschließend acht nummerierte Tests durch: Tokenizer-Rundreise, Token-Bereich, Batch-Shift, Vorwärtsverarbeitung, kausale Unabhängigkeit, Gradienten, Optimierer-Update sowie optional das Überanpassungsproblem bei einem neuen Debug-Modell im Ein-Batch-Modus – alles unter einer festen Seed-Wert. Zwei Details sind erwähnenswert: Der Tokenizer-Test decodiert gespeicherte IDs und kodiert sie erneut, um das tatsächliche Datensatz zu überprüfen, und der Optimierer-Test verwendet eine neue AdamW-Instanz, damit veralteter Zustand nicht stören kann.

    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()
    

    Neue Versionen von PyTorch haben das Standardverhalten von torch.load so geändert, dass nur Gewichte geladen werden. Je nach Version und Inhalt des Checkpoints müssen Sie möglicherweise weights_only explizit setzen; lesen Sie dazu die aktuelle Dokumentation.

    Ausführung der Diagnosen

    Führen Sie den vollständigen Test mit den Standardpfaden aus:

    python debug_mini_gpt.py
    

    Überspringen Sie die Überanpassungsprüfung für eine schnelle Überprüfung. Der Flag ist --skip-overfit – mit zwei vorangestellten Bindestrichen.

    python debug_mini_gpt.py - skip-overfit
    

    Verwenden Sie einen anderen Checkpoint:

    python debug_mini_gpt.py \
      --checkpoint checkpoints/mini_gpt_latest.pt
    

    Geben Sie dem Überanpassungstest mehr Schritte:

    python debug_mini_gpt.py \
      --overfit-steps 500
    

    Der nachgestellte Backslash setzt einen Befehl in Unix-ähnlichen Shells fort; falls Ihre Shell dies nicht unterstützt, geben Sie den Befehl in einer Zeile ein.

    Die Debugging-Reihenfolge in der Praxis

    Falls sich das Modell falsch verhält, gehen Sie diese Schritte nacheinander durch, ohne voranzuspringen.

    Schritte 1 bis 5: Daten und Formen

    Lesen Sie die decodierten Daten:

    Does the tokenized dataset decode correctly?
    

    Überprüfen Sie die Zielverschiebung:

    Does inputs[:, 1:] equal targets[:, :-1]?
    

    Überprüfen Sie den Tokenbereich:

    Are all IDs between 0 and vocab_size - 1?
    

    Überprüfen Sie die Tensorformen:

    Inputs: [B, T]
    Targets: [B, T]
    Logits: [B, T, V]
    

    Überprüfen Sie den anfänglichen Verlust:

    Is it near log(vocab_size) for a new model?
    

    Schritte 6 bis 10: Modellverhalten und Training

    Überprüfen Sie die kausale Unabhängigkeit:

    Can changing the future affect prefix logits?
    

    Die einzige zulässige Antwort ist „nein“. Überprüfen Sie die Gradienten:

    Are gradients present, finite, and nonzero?
    

    Überprüfen Sie die Parameteraktualisierungen:

    Does optimizer.step() change weights?
    

    Memorieren Sie eine Batch-Gruppe:

    Can the model memorize a tiny fixed batch?
    

    Nur dann starten Sie das vollständige Training:

    Only after all earlier tests pass should you invest in a long training run.
    

    Checklisten für jede Phase

    Vor dem Training:

    □ 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
    

    Während des Trainings:

    □ 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
    

    Während der Generierung:

    □ 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
    

    Gewohnheiten, die das Debuggen erschweren

    Auswechseln vieler Einstellungen auf einmal

    Falls ein Experiment all diese Faktoren gleichzeitig verändert, kann man das Ergebnis nicht einem einzelnen davon zuordnen:

    Learning rate
    Batch size
    Dropout
    Model size
    Context length
    

    Ändern Sie pro Experiment nur eine wichtige Variable.

    Bewertung allein anhand von Beispielen

    Schwache Ergebnisse können auf unzureichende Trainingsdauer, schlechte Dekodierung, den falschen Checkpoint oder Tokenisierer, Overfitting oder Underfitting zurückzuführen sein – Beispiele können diese Unterschiede nicht erkennen. Schauen Sie sich zunächst Metriken und Pipeline-Tests an.

    Ausblenden von Warnungen

    Warnungen, die auf umgeformte Tensorstrukturen, den Wechsel auf ein anderes Gerät, NaN- oder unendliche Werte sowie nicht übereinstimmende Checkpoint-Schlüssel hinweisen, deuten oft auf echte Fehler hin; verstehen Sie diese, bevor Sie sie unterdrücken.

    Auslassen von Smoke-Tests

    Vor einem langen Lauf sollten Sie mit kleinen Schritten beginnen:

    Tiny model
    Tiny batch
    Short context
    Few training steps
    

    Dann skalieren Sie schrittweise auf.

    Clipping als Lösung betrachten

    Das Clipping kann einen überdimensionierten Update aufnehmen, doch ständiges Clipping deutet darauf hin, dass etwas Gravierenderes Aufmerksamkeit benötigt: die Lernrate, die Initialisierung, das Skalieren des Verlusts, die numerische Präzision oder Anomalien in den Daten.

    Übungen: Den Pipeline absichtlich stören

    Nachdem man gesehen hat, wie ein Test fehlschlägt, vertraut man ihm mehr, weshalb jede Übung einen bekannten Fehler beinhaltet.

    Den Zielversatz entfernen

    Machen Sie die Eingaben und Ziele identisch, überprüfen Sie, ob der Batch-Test fehlschlägt, und fügen Sie anschließend den Versatz wieder hinzu.

    Einen außerhalb des Bereichs liegenden Token einfügen

    Setzen Sie eine Token-ID auf:

    tokenizer.vocab_size
    

    Die Bereichsprüfung muss fehlschlagen, da die höchste gültige ID ist:

    vocab_size - 1
    

    Die kausale Maske deaktivieren

    Nehmen Sie die Maske vorübergehend entfernt und führen Sie den Test zur Zukunftsunabhängigkeit durch; allein die Änderung des Suffixes sollte nun die Präfix-Logits verändern.

    Eine absurde Lernrate verwenden

    Setzen Sie:

    learning_rate = 0.1
    

    Verfolgen Sie den Verlust, die Gradientennorm, die Parameterwerte sowie die Überprüfungen auf Nichtendlichkeiten und halten Sie die Ausführung kurz.

    Das Modell einfrieren

    wenden Sie Folgendes an und beobachten Sie, wie die Gradienten- und Optimierer-Tests dies berichten:

    for parameter in model.parameters():
        parameter.requires_grad = False
    

    In die falsche Architektur laden

    Laden Sie einen Checkpoint in ein Modell, das in einem dieser Aspekte abweicht, und lesen Sie anschließend die Fehler wegen fehlender Schlüssel, unerwarteter Schlüssel sowie Größenunterschiede:

    Vocabulary size
    Embedding dimension
    Number of layers
    

    Dropout-Einstellungen vergleichen

    Führen Sie den Test mit einer einzigen Batch-Größe mit beiden Werten durch und vergleichen Sie, wie schnell jede Variante Informationen speichert:

    dropout = 0.0
    dropout = 0.2
    

    Einen Debug-Bericht erstellen

    Speichern Sie diese Ergebnisse als JSON, damit Ausführungen verglichen und Fehler nachgeahmt werden können:

    Tokenizer fingerprint
    Vocabulary size
    Token range
    Batch shape
    Initial loss
    Expected baseline
    Gradient norm
    Missing gradients
    Parameter update size
    Single-batch final loss
    

    Wichtige Erkenntnisse

    • Eine Aufgabe, die ohne Fehler abgeschlossen wird, kann dennoch die falsche Aufgabe erlernen.
  • Debuggen in Reihenfolge des Datenflusses; der Round-Trip-Test, die Bereichskontrolle sowie die Prüfung auf einen Token-Shift fangen die meisten Datenfehler ab.
  • Logits müssen im Format [B, T, V] vorliegen, und ein neues Modell sollte bei etwa log(vocab_size) beginnen.
  • Der Test mit gemeinsamem Präfix beweist die Kausalität durch das Verhalten.
  • Gradientenprüfungen sowie ein Vergleich der Parameter vor und nach dem Training bestätigen, dass Lernen möglich ist; Overfitting bei einer Batch-Gruppe bestätigt das gesamte Verhalten des Loops.
  • Nichtendliche Werte sowie spezielle Hooks ermitteln numerische Fehler; das Clipping beinhaltet lediglich deren Behandlung.
  • Trainings- und Validierungskurven unterscheiden Overfitting von Underfitting, wobei sowohl die Modellqualität als auch die Dekodierung den generierten Text prägen.
  • Rekonstruieren Sie die Modelle aus der Konfiguration des Checkpoints und überprüfen Sie den Tokenizer mithilfe seiner „Fingerabdruck“-Daten.
  • Der gesamte Ablauf:

    Environment
    → files
    → tokenizer
    → token IDs
    → batch shifting
    → shapes
    → initial loss
    → causal independence
    → gradients
    → optimizer updates
    → one-batch overfitting
    → full training
    → evaluation
    → generation
    

    Hinter all dem steht eine Regel: Debuggen Sie nicht auf Intuition hin; schreiben Sie einen Test, der eine Annahme isoliert, diese überprüft und anschließend fortfährt.

    Ein logischer nächster Schritt ist eine bessere Darstellung der Daten. Zeichentoken erzeugen lange Sequenzen, während Wortschatze enorm anwachsen; die Byte-Paar-Kodierung lernt Kombinationen für häufige Zeichensequenzen, wodurch die Sequenzen verkürzt werden und somit im selben Kontextfenster mehr Text untergebracht werden kann. Die Einführung dieser Methode bedeutet das Trainieren der Kombinationen, die Neukodierung von WikiText-2 sowie die Anpassung des Wortschatzes des Modells – dabei gelten alle hier genannten Überprüfungen unverändert:

    Character tokens
    → learned subword merges
    → shorter sequences
    → better use of the context window
    

    Verwandte Literatur

  • Der Aufbau eines Byte Pair Encoding Tokenizers von Null an für einen kleinen GPT — Implementieren Sie einen BPE-Tokenizer auf Zeichenebene in Python, trainieren Sie ihn mit WikiText-2, speichern und kennzeichnen Sie ihn, und trainieren Sie anschließend einen kleinen GPT mit kürzeren, dichteren Tokenfolgen.
  • Von GPT-1 zu Verstandesmodellen: Was jede Generation für Entwickler verändert hat — Verfolgen Sie die Entwicklung der GPT-Familie vom Vortraining im Jahr 2018 bis zu den Verstandesmodellen, erkennen Sie, welche Ideen jede Generation hinzugefügt hat, und rufen Sie GPT-4o Vision sowie Strukturierte Ausgaben von Python aus auf.