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.
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.
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:
- Zatrzymaj proces.
- Znajdź ostatni krok z skończoną stratą.
- Zmniejsz szybkość uczenia się.
- Włącz obcinanie gradientów.
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.
[B, T, V], a nowy model powinien zaczynać się w okolicach wartości log(vocab_size).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
- The Next-Token Loop: Model mentalny LLM przed zajrzeniem do agentów — Dowiedz się, jak działają tokeny, okna kontekstowe, próbkowanie i pętla generowania, za pomocą prostych przykładów w Pythonie działających offline, które wyjaśniają, dlaczego istnieją RAG, ReAct i LangGraph.
- Od chatbota z jednym węzłem do agenta wspieranego przez MCP w LangGraph — Buduj aplikację w LangGraph warstwa po warstwie: stan i reduktory, krawędzie, pętle narzędziowe, wątki z zapisem stanu, trzy tryby strumieniowania oraz narzędzia dostarczane przez MCP.