Strona główna / Artykuły / Naprawianie błędów w małym modelu GPT w PyTorch: testy izolujące poszczególne usterki.

Naprawianie błędów w małym modelu GPT w PyTorch: testy izolujące poszczególne usterki.

Krok po kroku procedura pracy przy naprawianiu błędów w modelu GPT na poziomie znaków w PyTorch, od identyfikacji tokenów i ich przesunięcia do obliczania gradientów, wartości NaN oraz tworzenia punktów kontrolnych.

6828 słów

Metryki oceny, takie jak strata, perplexity i wskaźnik powtórzeń, wskazują, że mały model GPT zachowuje się niewłaściwie, ale rzadko mówią, dlaczego. Kuszącą odpowiedzią jest dostosowanie szybkości uczenia się, dodanie warstwy i mieć nadzieję. Ten przewodnik zastępuje takie domysły powtarzalnym procesem pracy dla kompaktowego modelu GPT na poziomie znaków (Mini-GPT) trenowanego na WikiText-2: przyporządkowuje każdy objaw prawdopodobnym przyczynom, przeprowadza celowane sprawdzenia na każdym etapie procesu i naprawia problemy w kolejności ich występowania. W rezultacie otrzymujesz zestaw stwierdzeń oraz jeden skrypt diagnostyczny do uruchomienia przed każdym kosztownym procesem treningowym.

Dlaczego pipeline GPT może być błędny bez awarii

Trening modelu językowego obejmuje wiele transformacji, z których każda wykorzystuje wynik poprzedniej:

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

Błąd w dowolnym miejscu wpływa na wszystko poniżej. Załóżmy, że cele nie są odstępione od danych wejściowych o jedną pozycję:

Input: The cat
Target: The cat

Sieć jest teraz nagradzana za odtwarzanie tokena, który już widzi, zamiast przewidywania następnego. Nic się nie psuje, a strata może nadal spadać, ponieważ kopiowanie jest proste. Model po prostu optymalizuje niewłaściwy cel. To kluczowa różnica w porównaniu z debugowaniem usługi internetowej, gdzie błędna wartość zwrotna zazwyczaj powoduje awarię testu lub strony:

Kod, który działa do końca, nadal może trenować uszkodzony model.

Pracuj od początku łańcucha przetwarzania do jego końca

Weryfikuj etapy w kolejności, w jakiej przepływają przez nie dane:

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

Ocena jakości generowania przed potwierdzeniem poprawności łańcucha przetwarzania to marnowanie czasu, ponieważ zły przykład może pochodzić z dowolnego z jedenastu wcześniejszych etapów. Na każdym etapie zadaj jedno konkretne pytanie i odpowiedz na nie poprzez sprawdzenie, które może się powieść lub nie.

Cztery rodzaje awarii

Prawie każdy problem z takim modelem należy do jednej z czterech grup, a znajomość tej grupy pomaga w zawężeniu poszukiwań.

  • Błędy poprawności: kod jest logicznie błędny. Cele nie są przesuwane, maska przyczynowa pozwala pozycjom widzieć późniejsze tokeny, funkcja straty używa tensorów o niewłaściwej strukturze lub ID tokenizatora nie pasują do słownika, dla którego zbudowano model.
  • Błędy numeryczne: obliczenia stają się niestabilne. Funkcja straty przyjmuje wartość NaN, gradienty rosną wykładniczo, wartości logitów przekraczają granicę nieskończoności lub funkcja softmax otrzymuje wiersz bez żadnych ważnych wartości.
  • Błędy optymalizacji: implementacja jest poprawna, ale proces uczenia się jest nieskuteczny – z powodu zbyt wysokiej lub zbyt niskiej szybkości uczenia się, zbyt małego rozmiaru modelu lub zbyt krótkiego czasu jego działania.
  • Błędy generalizacji i generowania: szkolenie przebiega pomyślnie, ale model nie funkcjonuje prawidłowo. Spada straty w fazie walidacji, przykłady są powtarzane, wynik ignoruje polecenie lub model odtwarza fragmenty z okresu szkolenia.
  • Zacznij od małej konfiguracji do debugowania

    Debugowanie podczas pełnego uruchomienia zamienia każdą hipotezę w długie oczekiwanie. Zdefiniuj mały model, który może zapamiętać kilka przykładów w ciągu zaledwie kilku sekund:

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

    Połącz go z małą grupą danych:

    debug_batch_size = 8
    

    i krótkim czasem uruchomienia:

    debug_steps = 200
    

    Wartość Dropout jest celowo ustawiona na niską wartość:

    dropout = 0.0
    

    Dropout zerauje losowe aktywacje, przez co identyczne uruchomienia dają różne wyniki. Usunięcie go (wraz z ustalonym seedem) sprawia, że każdy test jest powtarzalny. Przywróć ustawienia produkcyjne, gdy proces przejdzie pomyślnie.

    Potwierdź środowisko uruchomienia

    Zanim dotkniesz modelu, wydrukuj wersje Pythona i PyTorch oraz to, czy dostępna jest obsługa CUDA lub backendu Apple’s Metal (MPS):

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

    Pomocnik wybiera najlepsze urządzenie, preferując CUDA, następnie MPS, a potem CPU. Zabezpieczenie za pomocą hasattr zapewnia działanie na starszych wersjach bez backendu 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")
    

    Zawołaj to raz i zapisz wynik:

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

    Gdy trening jest tajemniczo wolny, często winna jest ta linia: proces oczekiwał GPU, ale musiał przenieść się na CPU z powodu problemu z driverym lub instalacją.

    Upewnij się, że wszystkie pliki wejściowe są obecne

    Potwierdź, że istnieją definicja tokenizatora oraz zakodowane dane treningowe, walidacyjne i testowe; w przeciwnym razie natychmiast wyświetl jasny błąd FileNotFoundError, zamiast mylącego błędu w głębi pętli treningowej:

    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)
    

    Wydrukuj również rozmiary:

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

    Pusty lub niezwykle mały plik zazwyczaj oznacza, że zadanie przetwarzania wstępnego zostało przerwane i pozostawiło niekompletne wyniki.

    Przetestuj tokenizer w izolacji

    Ładuj tokenizer znaków:

    tokenizer = CharTokenizer.from_file(
        "tokenizer/char_tokenizer.json"
    )
    
    print("Vocabulary size:", tokenizer.vocab_size)
    print("First tokens:", tokenizer.chars[:20])
    print("Last tokens:", tokenizer.chars[-20:])
    

    Cecha bezstratności

    Tokenizer bezstratny zwraca dokładnie ten sam tekst wejściowy po kodowaniu i dekodowaniu. Wydrukowanie za pomocą repr ujawnia niewidoczne znaki, takie jak spacje na końcu tekstu:

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

    Sprawdzana inwariancka:

    decode(encode(text)) = text
    

    Błąd oznacza, że przynajmniej jeden znak nie może zostać wiernie przedstawiony, zwykle chodzi o symbol brakujący w słowniku. Metoda encode w pełnym skrypcie w takim przypadku wywołuje błąd KeyError, zamiast go po prostu pominąć, co jest właśnie tym, czego chcemy.

    Złap niezgodności między tokenizatorem a punktem kontrolnym

    Równe rozmiary słowników są konieczne, ale niewystarczające. Dwa słowniki mogą zawierać po 100 znaków i mimo to przypisywać im różne ID:

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

    Model wytrenowany przy użyciu jednego mapowania i używany z innym generuje bezsensowne wyniki, mimo że kształty tensorów wyglądają poprawnie. Zachowaj słownik wraz z punktem kontrolnym lub przynajmniej jego „odcisk palca”. Wystarczy hasz SHA-256 zserializowanej listy znaków; poprawne ustawienie parametrów separators i ensure_ascii gwarantuje, że ta sama lista zawsze zostanie zserializowana do tych samych bajtów:

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

    Oblicz to dla załadowanego tokenizera:

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

    Zapisz to w słowniku punktów kontrolnych podczas zapisu:

    checkpoint[
        "tokenizer_fingerprint"
    ] = fingerprint
    

    Podczas ładowania porównaj i odmów dalszej pracy w przypadku niezgodności. Warunek is not None nadal pozwala na załadowanie starszych punktów kontrolnych bez oznaczenia identyfikacyjnego:

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

    Przegląd zaimplementowanych identyfikatorów tokenów

    Ładuj część treningową na CPU jako 64-bitowe liczby całkowite, tak jak oczekują embeddingi typów i funkcja entropii krzyżowej:

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

    Wyświetl kształt, typ danych i zakres:

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

    Każdy identyfikator musi znajdować się w słowniku:

    0 ≤ token ID < vocabulary size
    

    Jako stwierdzenia typu assertion:

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

    ID poza zakresem uniemożliwia wyszukiwanie embedów, a na GPU błąd może pojawić się jako niejasna informacja pochodząca z urządzenia, daleka od swojej przyczyny. Typowymi przyczynami są błędny plik tokenizatora, uszkodzone pliki zakodowane, słownictwo odbudowane po kodowaniu lub niespójne traktowanie specjalnych tokenów.

    Odczytaj przechowywane dane jako tekst

    Liczby w prawidłowym zakresie mogą nadal kodować niewłaściwy tekst, więc zdekoduj kilkaset ID i je odczytaj:

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

    Powinieneś zobaczyć czytelny tekst WikiText w jego standardowym formacie, z przecinkami, nagłówkami i interpunkcją, bez serii powtarzających się lub uszkodzonych znaków. Jeśli przykład wygląda niewłaściwie, przestań – żadna zmiana modelu nie naprawi uszkodzonego tokenizatora lub zestawu danych.

    Sprawdź przesunięcie o jeden token między danymi wejściowymi a docelowymi

    Zbuduj jeden przykład ręcznie, tak aby okno docelowe zaczynało się o jedną pozycję później:

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

    Dekoduj oba, aby je porównać:

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

    Cel powinien wyglądać jak dane wejściowe, z pominięciem pierwszej litery i dodaniem jednej nowej litery. Następnie sprawdź związek pomiędzy tensorami:

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

    Niewiele sprawdzeń w projekcie pozwala wykryć poważniejsze błędy. Inwariancka zasada:

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

    Każda pozycja w celu zawiera token następujący po odpowiadającej jej pozycji wejściowej, co jest dokładnie tym, czego wymaga prognoza następnego tokena.

    Błąd identycznych kawałków

    Klasyczny błąd polega na użyciu tych samych granic dla obu kawałków:

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

    Wtedy model uczy się mapowania tożsamościowego:

    Current token → current token
    

    Rozpoczęcie kawałka celu o jeden token później naprawia ten problem:

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

    i przywraca zamierzony cel:

    Current context → next token
    

    Charakterystycznym objawem tego błędu jest szybkie spadki strat na wczesnym etapie.

    Pobierz próbkę rzeczywistej partii:

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

    Wyświetl wszystko, co może być nieprawidłowe:

    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)
    

    Dla rozmiaru partii 8 i rozmiaru bloku 32 oczekuj:

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

    Uczyń te oczekiwania trwałymi:

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

    Błąd ten pojawia się ciągle podczas pracy z PyTorch:

    Expected all tensors to be on the same device
    

    Jedna operacja otrzymała tensory na różnych urządzeniach, takich jak CPU i GPU. Wyświetl, gdzie znajdują się parametry oraz partia:

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

    Przenieś model i każdą partię wyraźnie:

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

    W modelu kryje się subtelniejsze źródło problemu: torch.arange domyślnie używa CPU, więc pobierz urządzenie z przychodzących tokenów:

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

    w przeciwnym razie dodawanie embeddingów pozycji do embeddingów tokenów na CUDA lub MPS nie udaje się. Powiązanie urządzenia z danymi wejściowymi zapewnia również przenośność modelu.

    Walidacja przepływu danych

    Zrób jedną partię z celami, aby model zwrócił logity i stratę:

    logits, loss = model(
        inputs,
        targets,
    )
    

    Zbadaj je:

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

    Logity wymagają jednej oceny na każdą pozycję dla każdego elementu słownika, a strata musi być skalarem:

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

    Logity o kształcie [B, V, T] oznaczają, że transpozycja lub przekształcenie wymiarów spowodowało błędny porządek. Ponieważ F.cross_entropy przyjmuje oceny klas w drugim wymiarze, błędnie ułożony tensor może czasami dotrzeć do obliczenia straty bez błędu i dać wynik bezsensowny.

    Porównanie początkowej straty z losową bazą

    Świeżo zainicjowany model o małych wagach przewiduje niemal jednolity rozkład, a entropia krzyżowa względem rozkładu jednolitego na V klas wynosi log(V):

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

    Niewielka odchylenie jest normalne; duże odchylenie jest sygnałem problemu. Wysoka wartość straty na początkowym etapie w porównaniu z bazą sugeruje ekstremalne wartości logits, niestabilną inicjalizację, nieważne identyfikatory tokenów, cele niepasujące do słownika lub niewłaściwą strukturę wyjścia. Bardzo niska wartość straty na początkowym etapie, której model nieznający żadnych danych nie może uczciwie osiągnąć, wskazuje na wyciek danych, przypadkowe załadowanie wag treningowych, cele równe wejściom, widoczne przyszłe tokeny lub niezamierzone wznowienie pracy z punktu kontrolnego.

    Dowód skuteczności maski przyczynowej

    GPT musi przewidywać każdą pozycję wyłącznie na podstawie wcześniejszych tokenów. Stwórz dwie sekwencje z wspólnym prefiksem i różnymi sufiksami; jeśli model jest przyczynowo-skutkowy, logity prefiksu muszą się zgadzać. Tryb oceny wyłącza dropout, aby przypadkowość nie wpływała na wyniki:

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

    Zrób testy obu wariantów bez użycia gradientów:

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

    Pomierz największą różnicę w prefiksie:

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

    Sprawdź równość w ramach małej tolerancji, która pozwala zignorować szumy typu floating-point:

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

    Niepowodzenie oznacza, że informacje z późniejszych pozycji przenikają do wcześniejszych, zwykle z powodu braku maski, jej niewłaściwego zastosowania do odpowiedniej wymiarowości lub utworzenia jej na podstawie niewłaściwego trójkąta. Testowanie zachowania od początku do końca jest skuteczniejsze niż tylko sprawdzanie tensora maski.

    Nadfitowanie pojedynczej partii danych

    Jeśli przyjmiesz jedną z technik przedstawionych w tym przewodniku, wybierz właśnie tę:

    Model o wystarczającej pojemności powinien być w stanie zapamiętać jeden mały zbiór danych.

    Wspólnie testuje dane, model, stratę, backpropagację oraz optymalizator. Ustal jeden zbiór danych, który jest używany na każdym kroku:

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

    Zbuduj mały model bez mechanizmu dropout:

    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)
    

    Szkol model na tym zbiorze danych wielokrotnie, zapisując wyniki co 50 kroków:

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

    Strata powinna spaść znacznie poniżej wartości bazowej. Jeśli tak się nie dzieje, sprawdź, czy nie ma problemu z obliczaniem straty, czy gradienty w ogóle docierają do niektórych parametrów, czy cele nie zostały przesunięte, czy model jest zbyt mały nawet na to zadanie, czy szybkość uczenia się nie została źle dobranej, czy maska przyczynowa jest błędna, lub czy optymalizator w ogóle nic nie aktualizuje. Traktuj test jako warunek konieczny przed każdym pełnym przeprowadzeniem symulacji.

    Potwierdź, że gradienty docierają do wszystkich parametrów

    Zrób jedną rundę obliczeń w kierunku przodu i wstecz:

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

    Zgłoś każdy parametr treningowy, którego .grad nadal ma wartość None, oraz normę dla pozostałych:

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

    Brak gradientu zazwyczaj oznacza warstwę zdefiniowaną w __init__, ale niewykorzystaną w funkcji forward, przypadkowe wywołanie .detach(), gałąź przetwarzania, która pomija jakąś komponentę, stratę obliczoną na podstawie odłączonego tensora lub ustawienie requires_grad=False.

    Śledź globalną normę gradientu

    Normy dla poszczególnych parametrów pomagają wykryć niewykorzystywane warstwy; jedna zsumowana wartość pokazuje stabilność w czasie. Ta funkcja łączy wszystkie normy L2 gradientu, które są używane do krawędziowania wartości:

    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
    

    Zapisz tę wartość po każdym kroku wstecznym:

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

    Uważaj na normy, które wynoszą dokładnie zero, są zbyt duże lub gwałtownie zmieniają się, zawierają wartości NaN lub inf.

    Rozpoznawaj NaN i nieskończoność wcześnie

    Pomocnik jest wyzwalany natychmiast, gdy tensor zawiera wartość nieskończoną, podając nazwę tego tensora:

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

    Zastosuj to do logits i straty:

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

    oraz do każdego gradientu po wywołaniu backward():

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

    Sprawdzenie kilku punktów pozwala zlokalizować pierwsze miejsce, w którym pojawiają się nieważne liczby, co jest znacznie przydatniejsze niż wykrycie straty typu NaN setki kroków później.

    Dlaczego strata staje się NaN

    Częste przyczyny: zbyt wysoka szybkość uczenia się, eksplozje gradientów, wiersz uwagi z zamaskowanymi wszystkimi pozycjami, nieważne dane wejściowe do funkcji softmax, przepełnienie przy mieszanej precyzji, już uszkodzone parametry, dzielenie przez zero, logarytm zera lub wartości ujemnej oraz nieskończone logits. Gdy to się zdarza:

    1. Zatrzymaj proces.
    2. Znajdź ostatni krok z skończoną stratą.
    3. Zmniejsz szybkość uczenia się.
    4. Włącz obcinanie gradientów.
  • Ponownie sprawdź maskę przyczynowo-skutkową.
  • Wyłącz mieszankę precyzji.
  • Zbadaj parametry i gradienty pod kątem wartości nieskończonych.
  • Nigdy nie kontynuuj obliczeń, gdy parametry zawierają NaN; każda aktualizacja rozprzestrzenia tę awarię, dlatego lepiej wznowić pracę od ostatniego poprawnego punktu kontrolnego.

    Używaj klipsy gradientów jako zabezpieczenia

    Klipsowanie skaluje gradienty, których łączna norma przekracza określony próg, więc powinno być wykonywane pomiędzy backward() a optimizer.step():

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

    clip_grad_norm_ zwraca normę zmierzoną przed klipsowaniem, co służy również do monitorowania:

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

    Jeśli próg jest przekraczany niemal przy każdym kroku, klipsowanie maskuje problem taki jak zbyt wysoka szybkość uczenia się lub niestabilność numeryczna. Chroni przed sporadycznymi błędnymi partiami danych, ale nie zastępuje rozsądnej szybkości uczenia się.

    Sprawdź, czy optymalizator zmienia wagi

    Zrób kopię jednego parametru przed aktualizacją. Metoda .clone() jest kluczowa: bez niej wartość before dzieli pamięć z parametrem i również ulega zmianie:

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

    Zrób jeden krok treningu:

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

    Pomierz zmianę:

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

    i upewnij się, że nastąpiła zmiana:

    assert maximum_change > 0
    

    Niezmienione wagi wskazują na zerową szybkość uczenia się, optymalizator stworzony bez parametrów modelu (na przykład przed zastąpieniem modelu), brak gradientów, brak wywołania optimizer.step() lub zamrożone parametry.

    Dostosuj szybkość uczenia się w obu kierunkach

    Zbyt wysoka szybkość uczenia się objawia się gwałtownym wzrostem straty lub jej dużymi wahaniami, bardzo dużymi wartościami norm gradientów, stratą typu NaN oraz próbkami, które się nie poprawiają. Pierwszym rozwiązaniem jest obniżenie tej wartości, na przykład do:

    max_learning_rate = 1e-4
    

    Zamiast:

    max_learning_rate = 1e-3
    

    Zapisuj szybkość uczenia się obok wartości straty. Przy procedurze warmup niestabilność często zaczyna się dokładnie na szczycie, co sam wykres straty ukrywa.

    Zbyt niska szybkość uczenia się daje inne efekty: strata spada bardzo powoli, mimo że gradienty istnieją, parametry ledwo się poruszają, a nawet test z jedną partią danych wymaga wielu kroków. W takim przypadku należy ją zwiększyć, na przykład do:

    max_learning_rate = 3e-4
    

    Zamiast:

    max_learning_rate = 1e-5
    

    Nie ma jednej uniwersalnie poprawnej wartości; zależy ona od rozmiaru modelu i partii danych, optymalizatora oraz zbioru danych. Przeprowadź krótkie eksperymenty, w których zmienia się tylko szybkość uczenia się.

    Lista kontrolna dla straty, która nie spada

    Rozważ te pytania w kolejności. Dane:

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

    Model:

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

    Strata:

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

    Podawanie prawdopodobieństw softmax do funkcji cross_entropy to klasyczny błąd, ponieważ funkcja ta sama przeprowadza obliczenia log-softmax. Gradienty:

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

    Optymalizator:

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

    Pojemność:

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

    Taki porządek eliminuje po jednej klasie przyczyny na raz, zamiast polegać na próbach i błędach.

    Rozpoznawanie nadfitowania i niedofitowania

    Nadfitowanie objawia się rozchodzącymi się krzywymi:

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

    Ilościowe określenie różnicy:

    generalization_gap = (
        validation_loss
        - training_loss
    )
    

    Środki zaradcze obejmują zachowanie najlepszego punktu weryfikacji, zwiększenie stopnia dropout lub degradacji wag, zmniejszenie rozmiaru modelu, dodanie większej ilości lub bardziej zróżnicowanych danych oraz wcześniejsze zatrzymanie procesu. Punkt zatrzymania należy wybrać na podstawie danych weryfikacyjnych; użycie danych testowych prowadzi do wycieku informacji i zawyżenia ostatecznej oceny.

    Niedofitowanie objawia się dwiema krzywymi, które pozostają na wysokim poziomie razem:

    Training loss:   remains high
    Validation loss: remains similarly high
    

    Prawdopodobne przyczyny to zbyt mała pojemność, zbyt krótki proces wykonywania, niska szybkość uczenia się, krótkie okno kontekstu, dane zbyt trudne dla architektury lub tokenizacja powodująca marnotrawstwo kontekstu. Opcjami są dodatkowe kroki, większa wymiarowość embeddingów, więcej warstw Transformera, dłuższy kontekst, kodowanie par bajtowych (BPE) zamiast znaków oraz ponowna kalibracja szybkości uczenia się. Najpierw uruchom ponownie test z jedną partią danych: jeśli model nie może zapamiętać jednej partii, problem leży w poprawności lub optymalizacji, a nie w pojemności.

    Diagnoza powtarzalnej generacji

    Powtórzenia wyglądają w ten sposób:

    the the the the
    

    lub, z markerami nagłówków WikiText:

    = = = = = = =
    

    Przyczyny obejmują dekodowanie chciwe, bardzo niską temperaturę, małe wartości top-k, model niedośćwiadczony lub przefitygowany, powtarzające się struktury w danych oraz krótkie okno kontekstu. Spróbuj bardziej zrównoważonego próbkowania:

    temperature = 0.8
    top_k = 20
    top_p = 0.9
    

    Kara za powtórzenia może być pomocna, jeśli jest umiarkowana:

    repetition_penalty = 1.05
    

    W modelach znakowych silna kara odstrasza od ponownego używania liter i szybko psuje pisownię. Jeśli każda konfiguracja dekodowania nadal wprowadza pętle, słabym punktem jest model, a nie sampler. Aby dowiedzieć się więcej o wzajemnym oddziaływaniu tych ustawień, zapoznaj się z naszym przewodnikiem po temperaturze, top-k i top-p.

    Diagnoza chaotycznej generacji

    Przeciwny problem powoduje pojedyncze symbole, niepełne słowa, nadmiar interpunkcji, nagłe zmiany tematu oraz nieczytelne ciągi znaków. Prawdopodobnymi przyczynami są wysoka temperatura, brak filtrowania top-k lub top-p, nieodpowiedni tokenizer, niewłaściwy checkpoint, słabo wyszkolony model z wysoką stratą walidacyjną lub wagi, które nigdy nie zostały załadowane. Spróbuj bardziej restrykcyjnego samplingu:

    temperature = 0.6
    top_k = 10
    top_p = 0.9
    

    Potwierdź, że wagi rzeczywiście pochodzą z punktu kontrolnego:

    model.load_state_dict(
        checkpoint["model_state_dict"]
    )
    

    i że funkcja dropout jest wyłączona podczas pobierania próbek:

    model.eval()
    

    Gdy wynik ignoruje polecenie

    Polecenie może być bardzo krótkie lub różnić się od danych treningowych; model może być mały, niedostatecznie wyszkolony, słaby w radzeniu sobie z zależnościami długodystansowymi lub ograniczony krótkim kontekstem; ponadto tokeny znakowe utrudniają naukę wzorców semantycznych. Przetestuj dłuższe polecenia w stylu WikiText. Porównaj jedno minimalne:

    "The "
    

    z bardziej złożonym:

    "The history of the city began"
    

    To drugie daje modelowi znacznie więcej informacji do wykorzystania przy podejmowaniu decyzji. Jeśli kontynuacje nadal się odchylają, sprawdź stratę walidacyjną oraz implementację mechanizmu uwagi.

    Punkty kontrolne, które odmawiają ładowania

    Błędy są znajome:

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

    Oznacza to, że model, który zbudowałeś, nie jest tym samym, który zapisałeś: zmieniła się jego konfiguracja, liczba warstw, wymiar embeddingów, rozmiar słownika lub parametry wag, klasy lub atrybuty zostały przemianowane, albo ładowany jest stan optymalizatora z innej architektury. Sprawdź zapewnioną konfigurację:

    print(
        checkpoint["config"]
    )
    

    Zbuduj model na jej podstawie zamiast używać obecnych ustawień domyślnych:

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

    Następnie załaduj słownik stanu. Budowanie modelu na podstawie nowej konfiguracji przy oczekiwaniu, że stare wagi będą pasować, powoduje większość z tych błędów.

    Wykaz brakujących i nieoczekiwanych kluczy

    Tylko w celach diagnostycznych – załaduj dane nieściśle i wydrukuj niezgodności:

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

    Listy zazwyczaj ujawniają przyczynę problemu, np. przemianowane podmoduły. Aby umożliwić wnioskowanie lub kontynuację treningu, należy zachować ścisłe zarządzanie ładowaniem, dzięki czemu niekompatybilny punkt kontrolny spowoduje wyraźny błąd, zamiast pozostawić warstwy przy losowych wartościach początkowych.

    Przenieś stan optymalizatora na odpowiedni urządzenie

    Po przywróceniu optymalizatora jego wewnętrzne tensory (takie jak estymaty momentów w AdamW) mogą znajdować się na innym urządzeniu niż model. Ten narzędzie przenosi każdy tensor z tego stanu:

    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
                    )
    

    Zawołaj je zaraz po ładowaniu:

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

    Ma to największe znaczenie przy zapisywaniu danych na jednym komputerze i ich kontynuowaniu na innym, np. z CUDA na MPS lub CPU.

    Zgrupuj kluczowe sprawdzenia w jednej funkcji stanu zdrowia

    Zebierz najważniejsze informacje w jednej funkcji: typ i zakres tokenów, przesunięcia docelowe, kształt logitów, skończoną wartość straty oraz porównanie z losowym bazowym poziomem:

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

    Dla przeszkolonego punktu kontrolnego strata powinna wyraźnie być poniżej wartości bazowej; w przeciwnym razie wagi nie zostały załadowane lub tokenizer nie jest zgodny.

    Znajdź problemy numeryczne za pomocą forward hooks

    Gdy gdzieś pojawia się wartość NaN, forward hooks sprawdzają wyjście każdego modułu podczas przetwarzania. Ten hook obsługuje pojedyncze tablice tensorowe i tuple oraz wywołuje błąd z nazwą klasy modułu przy pierwszej wartości nieskończonej:

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

    Przyłącz go do każdego modułu liniowego, layer-norm oraz embedding, zachowując odpowiednie identyfikatory:

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

    Wykonaj jedno przetwarzanie forward; ponieważ warstwy są wykonywane w kolejności, pierwszy błąd wskazuje typ pierwszej warstwy, która zawiodła. Następnie usuń te hooki:

    for hook in hooks:
        hook.remove()
    

    Haki są wykonywane przy każdym wezwaniu i spowalniają model, dlatego należy ich używać tylko podczas szukania błędu. Aby uzyskać dokładną ścieżkę modułu, zapisz nazwy z funkcji named_modules() podczas rejestracji.

    Kompletny skrypt diagnostyczny

    Wszystkie sprawdzenia są połączone w jedno narzędzie z linii poleceń. Zapisz je jako:

    debug_mini_gpt.py
    

    Skrypt definiuje minimalny CharTokenizer, mechanizm wyboru urządzenia, funkcję do generowania odcisków palców, narzędzie pomocnicze do przetwarzania danych w grupach oraz funkcje numeryczne. Buduje model na podstawie własnej konfiguracji pliku checkpoint, odrzuca tokenizery o różnej wielkości słownika, a następnie przeprowadza osiem testów numerowanych: test obiegu tokena, zakres tokenów, przesuwanie grup danych, przepływ danych do przodu, niezależność przyczynowa, gradienty, aktualizację optymalizatora oraz, opcjonalnie, przeciążenie modelu przy użyciu pojedynczej grupy danych na nowym modelu do debugowania, wszystko to przy stałym seedzie. Warto zwrócić uwagę na dwa szczegóły: test tokenizera dekoduje przechowywane ID i ponownie je koduje, weryfikując tym samym rzeczywisty zestaw danych, natomiast test optymalizatora wykorzystuje nową instancję AdamW, aby stary stan nie mógł wpływać na wyniki.

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

    Nowsze wersje PyTorch zmieniły domyślne zachowanie funkcji torch.load tak, że ładowa ona tylko wagi, więc w zależności od Twojej wersji oraz zawartości pliku checkpoint może być konieczne wyraźne ustawienie parametru weights_only; sprawdź aktualną dokumentację.

    Rozpoczęcie diagnostyki

    Zrób pełny test przy użyciu domyślnych ścieżek:

    python debug_mini_gpt.py
    

    Pomij kolejność sprawdzania problemu przeoverfittingu dla szybszej weryfikacji. Flagą jest --skip-overfit, zawierająca dwa spacje na początku:

    python debug_mini_gpt.py - skip-overfit
    

    Użyj innego pliku checkpoint:

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

    Daj testowi przeoverfittingu więcej kroków:

    python debug_mini_gpt.py \
      --overfit-steps 500
    

    Odostatnia spacja służy do kontynuowania polecenia w shellach w stylu Unix; jeśli Twój shell jej nie obsługuje, umieść polecenie na jednej linii.

    Kolejność debugowania w praktyce

    Gdy model zachowuje się niewłaściwie, postępuj zgodnie z tymi krokami, nie przeskakując do kolejnych.

    Kroki od 1 do 5: dane i kształty

    Przeczytaj odszyfrowane dane:

    Does the tokenized dataset decode correctly?
    

    Sprawdź przesunięcie docelowego obiektu:

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

    Sprawdź zakres tokenów:

    Are all IDs between 0 and vocab_size - 1?
    

    Sprawdź kształty tensora:

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

    Sprawdź początkową stratę:

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

    Kroki od 6 do 10: zachowanie modelu i trening

    Sprawdź niezależność przyczynowo-skutkową:

    Can changing the future affect prefix logits?
    

    Jedyną dopuszczalną odpowiedzią jest „nie”. Sprawdź gradienty:

    Are gradients present, finite, and nonzero?
    

    Sprawdź aktualizacje parametrów:

    Does optimizer.step() change weights?
    

    Zapamiętaj jeden batch:

    Can the model memorize a tiny fixed batch?
    

    Dopiero wtedy rozpocznij pełny trening:

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

    Listy kontrolne dla każdej fazy

    Przed treningiem:

    □ 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
    

    Podczas treningu:

    □ 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
    

    Podczas generowania:

    □ 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
    

    Nawyki utrudniające debugowanie

    Zmiana wielu ustawień jednocześnie

    Jeśli jedno doświadczenie zmienia je wszystkie naraz, nie można przypisać wyniku żadnemu z nich:

    Learning rate
    Batch size
    Dropout
    Model size
    Context length
    

    W każdym doświadczeniu zmieniaj tylko jedną główną zmienną.

    Ocena wyłącznie na podstawie próbek

    Słabe wyniki mogą wynikać z niewystarczającego treningu, złego dekodowania, błędnego punktu kontrolnego lub tokenizera, nadprzystosowania lub niewystarczającego przystosowania, a próbki nie pozwalają odróżnić tych przypadków. Najpierw sprawdź metryki i testy w całym procesie.

    Tłumienie ostrzeżeń

    Ostrzeżenia dotyczące przekształconych tensorów, przechodzenia na inny urządzenie, wartości NaN lub nieskończonych, czy niepasujących kluczy punktów kontrolnych często wskazują na rzeczywiste błędy; zrozum je przed ich tłumieniem.

    Pomijanie testów wstępnych

    Zanim rozpoczniesz długie testy, zacznij od małych skali:

    Tiny model
    Tiny batch
    Short context
    Few training steps
    

    Następnie stopniowo zwiększ skalę.

    Traktowanie klipiングu jako rozwiązania

    Clipping może pochłonąć jedną nadmiernie dużą aktualizację, ale ciągłe clipping oznacza, że coś głębszego wymaga uwagi: szybkość uczenia się, inicjalizacja, skalowanie straty, precyzja numeryczna lub anomalie w danych.

    Ćwiczenia: celowo zepsuć proces

    Po obserwacji niepowodzenia testu bardziej mu ufasz, dlatego każde ćwiczenie zawiera znany błąd.

    Usuń przesunięcie celu

    Zrób tak, by dane wejściowe i cele były identyczne, upewnij się, że test partii nie udaje się, a następnie przywróć przesunięcie.

    Dodaj token poza zakresem

    Ustaw ID jednego tokena na:

    tokenizer.vocab_size
    

    Sprawdzenie zakresu musi zawieść się, ponieważ najwyższy ważny ID to:

    vocab_size - 1
    

    Wyłącz maskę przyczynowo-skutkową

    Tymczasowo usuń maskę i przeprowadź test niezależności od przyszłości; zmiana tylko sufiksu powinna teraz wpłynąć na logity prefiksu.

    Użyj absurdalnej szybkości uczenia się

    Ustaw:

    learning_rate = 0.1
    

    Nadzoruj stratę, normę gradientu, wartości parametrów oraz sprawdzania nieskończoności i staraj się utrzymać krótki czas wykonywania.

    Zamroź model

    Zastosuj poniższe kroki i zobacz, jak testy gradientu oraz optymalizatora to przedstawiają:

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

    Ładowanie do niewłaściwej architektury

    Ładuj plik checkpoint do modelu, który różni się pod względem jednej z tych cech, a następnie sprawdź błędy braku klucza, nieoczekiwanego klucza oraz niezgodności rozmiaru:

    Vocabulary size
    Embedding dimension
    Number of layers
    

    Porównanie ustawień dropout

    Zrób test z jedną partią danych przy obu wartościach i porównaj, jak szybko każda z nich zapamiętuje dane:

    dropout = 0.0
    dropout = 0.2
    

    Napisz raport debugowy

    Zapisz te wyniki w formacie JSON, aby można było porównywać wykonywania oraz odtwarzać błędy:

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

    Główne wnioski

    • Zadanie, które kończy się bez błędów, może nadal uczyć się niewłaściwego zadania.
  • Debugowanie w kolejności przepływu danych; test powrotny, sprawdzenie zakresu oraz założenie o przesunięciu jednego tokena wykrywają większość błędów w danych.
  • Logity muszą mieć postać [B, T, V], a nowy model powinien zaczynać się w okolicach wartości log(vocab_size).
  • Test wspólnego prefiksu potwierdza kauzalność poprzez zachowanie modelu.
  • Sprawdzenia gradientów oraz porównanie parametrów przed i po treningu potwierdzają możliwość uczenia się; nadmiarowe dopasowanie do jednej partii danych potwierdza poprawność całego cyklu.
  • Sprawdzenia nieskończoności oraz mechanizmy hooków lokalizują błędy numeryczne; funkcja krawędziowania jedynie je ogranicza.
  • Krzywe treningowe i walidacyjne odróżniają nadmiarowe od niewystarczającego dopasowania, a zarówno jakość modelu, jak i proces dekodowania wpływają na kształt generowanego tekstu.
  • Odbuduj modele na podstawie konfiguracji z pliku checkpoint i sprawdź tokenizer za pomocą jego „odcisku palca”.
  • Pełny proces:

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

    Za tym wszystkim kryje się jedna zasada: nie debuguj na podstawie intuicji; napisz test, który izoluje jedno założenie, je potwierdź i przejdź dalej.

    Naturalnym następnym krokiem jest lepsza reprezentacja danych. Tokeny znakowe tworzą długie sekwencje, podczas gdy słowniki wyrazów stają się ogromne; kodowanie par bajtowych uczy się łączenia często występujących sekwencji znaków, skracając je w taki sposób, aby ta sama okno kontekstowe mogło pomieścić więcej tekstu. Przyjęcie tego rozwiązania oznacza szkolenie mechanizmów łączenia, ponowne kodowanie danych z WikiText-2 oraz zmianę rozmiaru słownika modelu, przy czym wszystkie sprawdzania pozostają bez zmian:

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

    Literatura pokrewna

  • Budowanie tokenizera BPE od zera dla małego GPT — Zaimplementuj tokenizer BPE na poziomie znaków w Pythonie, przeszkol go na WikiText-2, zapisz go i stwórz jego „odcisk palca”, a następnie przeszkol mały GPT na krótszych, bardziej gęstych sekwencjach tokenów.
  • Od GPT-1 do modeli rozumujących: co zmieniła się z każdą generacją dla programistów — Prześledź rozwój rodziny GPT od wstępnego szkolenia z 2018 roku aż po modele rozumujące, zobacz, jakie pomysły dodawała każda generacja, oraz użyj GPT-4o Vision i Structured Outputs z Pythona.