Головна / Статті / Відладка моделі Small GPT у PyTorch: тести, які ізолюють кожну причину збою.

Відладка моделі Small GPT у PyTorch: тести, які ізолюють кожну причину збою.

Етапний алгоритм роботи для відладки моделі GPT на рівні символів у PyTorch, від ідентифікації токенів та зміни цілей до обчислення градієнтів, виявлення значень NaN та використання контрольних точок.

6828 слів

Показники оцінки, такі як втрата, перплекситет та частота повторень, свідчать про те, що невелика модель GPT поводиться некоректно, але рідко пояснюють чому. Запропонованим рішенням є корекція швидкості навчання, додавання ще одного шару та сподівання на краще. Цей посібник замінює ці припущення на повторюваний процес роботи для компактної моделі GPT на рівні символів (Mini-GPT), навченої на WikiText-2: прив’язування кожного симптому до ймовірних причин, проведення цілеспрямованих перевірок на кожному етапі обробки та усунення проблем у порядку їх виникнення. У результаті ви отримуєте набір тверджень та один скрипт діагностики, який потрібно запускати перед кожним дорогим процесом навчання.

Чому підхід GPT може бути неправильним без збоїв

Навчання мовної моделі передбачає послідовність багатьох перетворень, кожне з яких використовує результат попереднього:

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

Дефект у будь-якій частині впливає на все, що знаходиться далі. Припустимо, цілі не відрізняються від вхідних даних на одну позицію:

Input: The cat
Target: The cat

Тепер мережа отримує винагороду за відтворення токена, який вона вже бачить, замість прогнозування наступного. Нічого не падає, і збитки все ще можуть зменшуватися, оскільки копіювання є простим. Модель просто оптимізує неправильну мету. Це ключова відмінність від виправлення помилок у веб-сервісі, де неправильне значення повернення зазвичай порушує тест чи сторінку:

Код, який виконується до кінця, все одно може навчати пошкоджену модель.

Працюйте від початку конвеєра даних до кінця

Перевіряйте етапи в тому порядку, в якому через них проходять дані:

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

Оцінювання якості генерації до підтвердження конвеєра даних — це марна трата часу, оскільки поганий зразок може походити з будь-якого з одинадцяти попередніх етапів. На кожному етапі ставте одне конкретне запитання та відповідайте на нього за допомогою перевірки, яка пройде або провалиться.

Чотири групи помилок

Майже кожна проблема з такою моделлю належить до однієї з чотирьох груп, і знання цієї групи допомагає звузити пошук.

  • Помилки коректності: код має логічні помилки. Цілі не зміщуються, маска причинності дозволяє позиціям бачити наступні токени, функція втрат використовує тензори з неправильною структурою, а ідентифікатори токенайзера не узгоджуються з словниковим запасом, для якого була побудована модель.
  • Числові проблеми: математичні обчислення стають нестабільними. Функція втрат приймає значення NaN, градієнти розростаються, логітни переповнюються до нескінченності, а функція softmax отримує рядок без дійсних значень.
  • Помилки оптимізації: реалізація є правильною, але процес навчання є неефективним через занадто високу або низьку швидкість навчання, занадто малу модель або занадто короткий час її роботи.
  • Проблеми з узагальненням та генерацією: навчання працює, але модель — ні. Зростає втрата під час перевірки, зразки повторюються, результат ігнорує запит або модель відтворює фрагменти з часу навчання.
  • Почніть із мінімальної конфігурації для відлагодження

    Відлагодження під час повноцінного запуску перетворює кожну гіпотезу на довге очікування. Визначте невелику модель, яка зможе запам’ятати кілька прикладів протягом кількох секунд:

    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_batch_size = 8
    

    та коротким запуском:

    debug_steps = 200
    

    Параметр Dropout навмисно вимкнений:

    dropout = 0.0
    

    Dropout зводить до нуля випадкові активації, тому ідентичні запуски дають різні результати. Його видалення (разом із фіксованим насінням) робить кожен тест відтворюваним. Поверніть параметри для продакшену, як тільки процес пройде успішно.

    Перевірте середовище виконання

    Перш ніж торкатися моделі, виведіть версії Python та PyTorch, а також інформацію про те, чи можна використовувати CUDA чи фоновий механізм 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(),
        )
    

    Допоміжна функція обирає найкращий пристрій, віддаючи перевагу CUDA, потім MPS, а потім CPU. Захист за допомогою hasattr забезпечує її роботу у старіших версіях без підтримки 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")
    

    Викличте її один раз та задокументуйте результат:

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

    Якщо процес навчання раптово сповільнюється, причиною часто є цей рядок: програма очікувала GPU, але змушена була перейти на CPU через проблеми з драйвером чи встановленням.

    Переконайтеся, що всі вхідні файли наявні

    Перевірте, чи існують визначення токенайзера та закодовані версії даних для навчання, перевірки та тестування; у разі проблеми негайно видастьте чіткий повідомлення про помилку FileNotFoundError, а не заплутану помилку всередині циклу навчання:

    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)
    

    Також виведіть розміри цих файлів:

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

    Порожній або надзвичайно малий файл зазвичай означає, що процес попередньої обробки було перервано, і залишився неповний результат.

    Перевірте токенайзер окремо

    Завантажте токенайзер символів:

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

    Властивість без втрат

    Токенайзер без втрат повертає точно такий самий вхідний даний після кодування та декодування. Виведення за допомогою repr відображає невидимі символи, такі як пробіли в кінці:

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

    Перевірювана інваріантна умова:

    decode(encode(text)) = text
    

    Помилка означає, що принаймні один символ не може бути точно представлений, зазвичай це символ, відсутній у словнику. У повному скрипті метод encode у такому випадку викликає помилку KeyError, замість того щоб мовчки проігнорувати її, що саме і потрібно.

    Ловіть неузгодженості між токенайзером та чекпоїнтом

    Рівна кількість елементів у словниках є необхідною, але недостатньою. Два словники можуть містити по 100 символів, проте призначати їм різні ідентифікатори:

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

    Модель, навчена за однією схемою мапування, але використовувана з іншою, генерує нісенітницю, хоча форми всіх тензорів виглядають правильними. Зберігайте словник разом із чекпоїнтом або принаймні його „відбиток“. Підійде хеш SHA-256 серіалізованого списку символів; налаштування параметрів separators та ensure_ascii гарантує, що один і той самий список завжди буде серіалізуватися у однакові байти:

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

    Обчисліть його для завантаженого токенайзера:

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

    Збережіть його у словнику контрольних точок під час збереження:

    checkpoint[
        "tokenizer_fingerprint"
    ] = fingerprint
    

    Під час завантаження порівняйте та відмовтеся від подальшої обробки у разі незбігу. Умова is not None все ще дозволяє завантажувати старіші контрольні точки без відбитка:

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

    Перевірте закодовані ідентифікатори токенів

    Завантажте даний набір для навчання на CPU у вигляді 64-бітних цілих чисел, як очікують типові ембеддинги та функція крос-ентропії:

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

    Виведіть форму, тип даних та діапазон:

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

    Кожен ідентифікатор має знаходитися в межах словника:

    0 ≤ token ID < vocabulary size
    

    Як асертації:

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

    ID, що виходить за межі діапазону, порушує пошук вбудованих даних, а на GPU ця помилка може проявитися у незрозумілому твердженні з боку пристрою, далекому від її справжньої причини. Типовими причинами є неправильний файл токенізатора, пошкоджені кодовані файли, словниковий запас, перебудований після кодування, або недосконале оброблення спеціальних токенів.

    Зчитайте збережені дані у вигляді тексту

    Числа, що знаходяться в межах діапазону, все одно можуть кодувати неправильний текст, тому розкодуйте кілька сотень ID та прочитайте їх:

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

    Ви повинні побачити читабельний WikiText із звичайним форматуванням, переносами рядків, заголовками та пунктуацією, без послідовностей повторюваних або пошкоджених символів. Якщо зразок виглядає неправильно, зупиніться: жодна зміна моделі не компенсує пошкоджений токенізатор чи набір даних.

    Перевірте зсув на один токен між вхідними та цільовими даними

    Створіть один приклад вручну, так щоб цільове вікно починалося на одну позицію пізніше:

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

    Розкодуйте обидва, щоб порівняти їх:

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

    Цільовий даний має виглядати як вхідні дані, але без першого символу та з доданим одним новим символом. Потім перевірте взаємозв’язок між тензорами:

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

    Небагато перевірок у проекті допомагають виявити більш серйозні помилки. Інваріант:

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

    Кожна позиція цільових даних містить токен, який йде після відповідної позиції вхідних даних, що саме потрібно для прогнозування наступного токена.

    Помилка ідентичних сегментів

    Класична помилка полягає у використанні одних і тих самих меж для обох сегментів:

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

    Потім модель навчається функції-ідентичності:

    Current token → current token
    

    Початок сегмента цільових даних на один токен пізніше виправляє цю проблему:

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

    і відновлює початкову мету:

    Current context → next token
    

    Ознакою цієї помилки є стрімке зниження показника втрат на початковому етапі.

    Перевірка форм, типів даних та пристроїв пакетів

    Візьміть зразок реального пакету:

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

    Виведіть усе, що може бути не так:

    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)
    

    Для розміру пакету 8 та розміру блоку 32 очікуйте:

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

    Зробіть ці очікування постійними:

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

    Виправлення неузгодженостей пристроїв

    Ця помилка постійно з’являється під час роботи з PyTorch:

    Expected all tensors to be on the same device
    

    Одна операція отримала тензори на різних пристроях, наприклад CPU та GPU. Виведіть, де знаходяться параметри та пакет:

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

    Явно перемістіть модель та кожен пакет:

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

    Більш прихований джерело проблеми знаходиться всередині моделі: torch.arange за замовчуванням використовує CPU, тож беріть пристрій з надходячих токенів:

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

    Інакше додавання позиційних ембеддингів до токенних ембеддингів на CUDA чи MPS зазнає невдачі. Пов’язування пристрою з вхідними даними також забезпечує портативність моделі.

    Перевірка процесу обчислень

    Запустіть один пакет даних із метками, щоб модель повертала логіти та значення втрати:

    logits, loss = model(
        inputs,
        targets,
    )
    

    Перевірте їх:

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

    Для логітів потрібен один показник для кожної статті словника та кожної позиції, а значення втрати має бути скаляром:

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

    Логіти у форматі [B, V, T] означають, що операції транспозиції чи перетворення форми призводять до неправильного порядку вимірів. Оскільки функція F.cross_entropy приймає показники класів у другому вимірі, тензор із неправильним порядком іноді може бути оброблений без помилки та дати беззмістовний результат.

    Порівняння початкової втрати з випадковим базовим значенням

    Модель, яка щойно була ініціалізована з малими вагами, прогнозує майже однорідний розподіл, а крос-ентропія щодо однорідного розподілу над V класами дорівнює log(V):

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

    Невелика відхилення є нормальною ситуацією; велика відхилення є ознакою проблеми. Великі початкові значення втрати порівняно з базовим рівнем свідчать про екстремальні значення логітів, нестабільну ініціалізацію, некоректні ID токенів, цілі, які не відповідають словнику, або неправильну форму вихідних даних. Початкові значення втрати, значно нижчі за базовий рівень — чого не може досягти модель, яка нічого не знає, — свідчать про витік даних, випадкове завантаження навчених ваг, цілі, які дорівнюють вхідним даним, видимі майбутні токени або ненавмисне відновлення роботи з перервою.

    Доведіть, що маска причинності функціонує

    GPT має прогнозувати кожну позицію лише на основі попередніх токенів. Створіть дві послідовності з спільним префіксом та різними суфіксами; якщо модель є причинно-наслідковою, логітти префіксу мають збігатися. Режим оцінки вимикає механізм dropout, щоб випадковість не заважала:

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

    Запустіть обидві послідовності без використання градієнтів:

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

    Виміряйте максимальну різницю у префіксах:

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

    Перевірте рівність у межах невеликої толерантності, яка компенсує шум у числах з плаваючою комою:

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

    Невдача означає, що інформація з пізніших позицій потрапляє у ранні, зазвичай через відсутність маски, її неправильне застосування до відповідного виміру чи створення з неправильної структури. Тестування повного циклу є ефективнішим, ніж перевірка тензора маски.

    Надмірне навчання для однієї партії даних

    Якщо ви оберете одну з технік з цього посібника, виберіть саме її:

    Модель з достатньою ємністю повинна мати змогу запам’ятати одну невелику партію даних.

    Вона одночасно використовує дані, модель, показник втрат, алгоритм зворотного поширення помилки та оптимізатор. Виправте одну партію даних, яка використовується на кожному кроці:

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

    Створіть невелику модель без функції 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)
    

    Тренуйте її на цій партії даних багаторазово, фіксуючи результат кожні 50 кроків:

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

    Показник втрат має значно знизитися порівняно з базовим рівнем. Якщо цього не відбувається, перевірте наявність проблем із показником втрат, градієнтами, які ніколи не досягають певних параметрів, незсунутих цілей, занадто малої моделі навіть для цього завдання, неправильно обраної швидкості навчання, пошкодженої маски причинності чи оптимізатора, який нічого не оновлює. Використовуйте тестування як критерій для будь-якого повного запуску.

    Переконайтеся, що градієнти доходять до кожного параметра

    Виконайте один прохід вперед та назад:

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

    Звітуйте про кожен параметр, який можна навчати та чий значення .grad досі дорівнює None, а також про норму для решти параметрів:

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

    Відсутній градієнт зазвичай означає шар, визначений у функції __init__, але не використаний у функції forward, випадкове використання функції .detach(), гілку обробки даних, яка ігнорує певний компонент, втрати, обчислені з від’єднаного тензора, або налаштування requires_grad=False.

    Відстежування глобальної норми градієнта

    Норми окремих параметрів допомагають виявити „мертві“ шари; єдине агреговане значення дозволяє відстежувати стабільність з часом. Ця функція об’єднує всі норми градієнтів типу L2, які використовуються для обрізки значень:

    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
    

    Записуйте це значення після кожного кроку у зворотному напрямку:

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

    Стежте за нормами, які дорівнюють рівно нулю, є надзвичайно великими або різко змінюються, мають значення NaN чи inf.

    Раннє виявлення значень NaN та inf

    Допоміжна функція спрацьовує як тільки тензор містить нескінченне значення, вказуючи назву цього тензора:

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

    Застосуйте її до логітів та значення втрати:

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

    та до кожного градієнта після виконання backward():

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

    Перевірка кількох точок дозволяє виявити місце, де з’являються недійсні числа, що є значно кориснішим, ніж виявлення NaN у значенні втрати через сотні кроків.

    Чому значення втрати стає NaN

    Часті причини: занадто висока швидкість навчання, експлозія градієнтів, рядок уваги, де всі позиції замасковані, недійсний вхід для функції softmax, переповнення при роботі з різною точністю, вже пошкоджені параметри, ділення на нуль, логарифм від нуля або від’ємного числа, а також нескінченні логіти. Коли це трапляється:

    1. Зупиніть виконання.
    2. Знайдіть останній крок із скінченною втратою.
    3. Знизьте швидкість навчання.
    4. Увімкніть обрізку градієнтів.

  • Перевірте маску причинності ще раз.
  • Вимкніть режим змішаної точності.
  • Перевірте параметри та градієнти на наявність нескінченних значень.
  • Ніколи не продовжуйте процес, якщо параметри містять NaN; кожне оновлення поширює ці пошкодження, тому краще продовжити з останнього коректного чекпоїнта.

    Використовуйте обрізку градієнтів як захист

    Обрізка масштабує градієнти, норма яких разом перевищує певний поріг, тому її слід виконувати між backward() та optimizer.step():

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

    clip_grad_norm_ повертає норму, виміряну перед обрізкою, що також слугує моніторингом:

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

    Якщо поріг перевищується майже на кожному кроці, обрізка приховує проблеми, такі як занадто висока швидкість навчання чи числова нестабільність. Вона захищає від епізодичних проблем з даними, але не замінює розумно обрану швидкість навчання.

    Перевірте, чи оптимізатор змінює ваги

    Скопіюйте один параметр перед оновленням. Функція .clone() є важливою: без неї значення before ділить пам’ять із параметром та також змінюється:

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

    Виконайте один крок навчання:

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

    Виміряйте зміну:

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

    та переконайтеся, що вона відбулася:

    assert maximum_change > 0
    

    Незмінені ваги свідчать про нульову швидкість навчання, оптимізатор, створений без параметрів моделі (наприклад, до заміни моделі), відсутність градієнтів, відсутність функції optimizer.step() або заморожені параметри.

    Налаштовуйте швидкість навчання в обох напрямках

    Занадто висока швидкість проявляється у стрімкому зростанні значень втрат або їхніх різких коливаннях, дуже великих нормах градієнтів, значеннях втрат типу NaN та зразках даних, які так і не покращуються. Першим кроком є зниження цієї швидкості, наприклад до:

    max_learning_rate = 1e-4
    

    Замість цього:

    max_learning_rate = 1e-3
    

    Записуйте швидкість навчання поруч із показником втрат. При процедурі розігріву нестабільність часто починається саме на піку, що приховується лише графіком втрат.

    Занадто низька швидкість навчання проявляється інакше: втрати знижуються дуже повільно, хоча градієнти існують, параметри ледве змінюються, а навіть тест на одну партію даних вимагає багатьох кроків. Тоді потрібно її підвищити, наприклад до:

    max_learning_rate = 3e-4
    

    Замість цього:

    max_learning_rate = 1e-5
    

    Не існує універсально правильного значення; воно залежить від розміру моделі та партії даних, оптимізатора та набору даних. Проводьте короткі експерименти, у яких змінюється лише швидкість навчання.

    Чек-лист для ситуації, коли показник втрат не знижується

    Розгляньте ці питання у порядку. Дані:

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

    Модель:

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

    Показник втрат:

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

    Передача ймовірностей softmax у функцію cross_entropy — це класична помилка, оскільки сама функція використовує лог-softmax. Градієнти:

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

    Оптимізатор:

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

    Ємність:

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

    Цей порядок усуває одну категорію причин за раз, замість того щоб покладатися на проби й помилки.

    Визначення переобучення та недообучення

    Переобучення проявляється у розходженні кривих:

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

    Квантувати розбіжність:

    generalization_gap = (
        validation_loss
        - training_loss
    )
    

    Способи усунення включають зберігання найкращого контрольного пункту валідації, збільшення параметрів dropout чи втрат ваг, скорочення розміру моделі, додавання більшої кількості чи різноманітніших даних та припинення роботи раніше. Виберіть точку зупинки за допомогою валідаційного набору; використання тестового набору призводить до витоку інформації та завищення кінцевого балу.

    Недообучення проявляється у двох кривих, які залишаються на високому рівні разом:

    Training loss:   remains high
    Validation loss: remains similarly high
    

    Ймовірні причини – недостатня ємність, занадто короткий запуск, низька швидкість навчання, коротке вікно контексту, дані, занадто складні для архітектури, чи токенізація, яка марнує контекст. Варіанти вирішення – більше кроків, більша розмірність ембеддингу, більше шарів Transformer, довший контекст, кодування пар байтів (BPE) замість символів та коригування швидкості навчання. Спочатку перезапустіть тест з одним набором даних: якщо модель не може запам’ятати один набір, проблема полягає у правильності чи оптимізації, а не у ємності.

    Діагностика повторюваної генерації

    Повторюваність виглядає так:

    the the the the
    

    або, з маркерами заголовків WikiText:

    = = = = = = =
    

    Причини включають жадібне декодування, дуже низьку температуру, дуже малий параметр top-k, недостатньо навчений чи перенавчений модель, повторювані структури в даних та коротке вікно контексту. Спробуйте більш збалансоване вибірку даних:

    temperature = 0.8
    top_k = 20
    top_p = 0.9
    

    Штраф за повторення може допомогти, якщо він буде помірним:

    repetition_penalty = 1.05
    

    У моделях символів сильний штраф стримує повторне використання літер, що швидко псує орфографію. Якщо кожна налаштована процедура декодування все одно виконує цикли, проблема полягає у самій моделі, а не у механізмі вибору елементів. Щоб дізнатися, як ці налаштування взаємодіють, перегляньте наш посібник з температури, top-k та top-p.

    Діагностика хаотичного генерування

    Інший тип збою призводить до появи випадкових символів, розірваних слів, надмірної кількості розділових знаків, раптових змін теми та нерозбірливих рядків. Ймовірними причинами є висока температура, відсутність фільтрації top-k чи top-p, несумісний токенайзер, неправильний чекпоїнт, недостатньо навчена модель із високими показниками втрат під час перевірки або ваги, які так і не були завантажені. Спробуйте використовувати більш строгий механізм вибору елементів:

    temperature = 0.6
    top_k = 10
    top_p = 0.9
    

    Підтвердіть, що ваги справді походять з чекпоїнта:

    model.load_state_dict(
        checkpoint["model_state_dict"]
    )
    

    і що функція dropout вимкнена під час збору даних:

    model.eval()
    

    Коли результат ігнорує запит

    Запит може бути дуже коротким або не схожим на дані навчання; модель може бути маленькою, недостатньо навченою, слабкою у роботі з довгостроковими залежностями або обмеженою коротким контекстом; крім того, символьні токени ускладнюють засвоєння семантичних закономірностей. Протестуйте довші запити у стилі WikiText. Порівняйте мінімальний запит з більш деталізованим:

    "The "
    

    з більш насиченим за змістом запитом:

    "The history of the city began"
    

    Другий варіант надає моделі значно більше інформації для формування рішень. Якщо продовження все одно відрізняються, перевірте показники втрат під час верифікації та реалізацію механізму уваги.

    Чекпоїнти, які відмовляються завантажуватися

    Помилки є знайомими:

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

    Вони мають на увазі, що створений вами модель не є тією, яку ви зберегли: змінилася її конфігурація, кількість шарів, розмір ембеддингів, розмір словникового запасу чи параметри ваг, класи або атрибути були перейменовані, або завантажується стан оптимізатора з іншої архітектури. Огляньте збережену конфігурацію:

    print(
        checkpoint["config"]
    )
    

    Створіть модель на основі цієї конфігурації, а не за поточними значеннями за замовчуванням:

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

    Потім завантажте словник стану. Створення моделі за новою конфігурацією з очікуванням, що старі ваги підійдуть, спричиняє більшість цих помилок.

    Список відсутніх та неочікуваних ключів

    Лише для діагностики: завантажте дані нестрого та виведіть невідповідності:

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

    Списки зазвичай вказують на причину, наприклад, на перейменований підмодуль. Для інференсу чи продовження навчання необхідно дотримуватися суворих правил завантаження, щоб несумісний чекпоїнт гучно видалив помилку, а не залишив шари з випадковими початковими значеннями.

    Перемістити стан оптимізатора на правильний пристрій

    Після відновлення оптимізатора його внутрішні тензори (наприклад, оцінки моментів у AdamW) можуть знаходитися на іншому пристрої, ніж сама модель. Цей помічник переміщує кожен тензор у стані:

    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
                    )
    

    Викликайте його відразу після завантаження:

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

    Це має найбільше значення під час збереження даних на одній машині та їх відновлення на іншій, наприклад, з CUDA на MPS чи CPU.

    Об’єднати ключові перевірки в одну функцію стану

    Зберіть найважливіші перевірки в одну функцію: тип та діапазон токенів, зсув цілей, форма логітів, скінченність втрати та порівняння з випадковою базовою значенням:

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

    Для навченого чекпоїнта значення втрат має явно бути нижчим за базове значення; інакше ваги не завантажилися або токенайзер не відповідає.

    Виявлення числових проблем за допомогою форвард-хуків

    Коли десь з’являється значення NaN, форвард-хуки перевіряють вихід кожного модуля під час обробки даних. Цей хук працює з одиночними тензорами та кортежами та генерує помилку з назвою класу модуля при першому нескінченному значенні:

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

    Приєднайте його до кожного лінійного, модуля нормалізації шару та модуля ембеддингів, зберігаючи відповідні ідентифікатори:

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

    Виконайте один прохід даними вперед; оскільки шари виконуються по черзі, перша помилка вказує на тип першого несправного шару. Потім видаліть ці хуки:

    for hook in hooks:
        hook.remove()
    

    Хуки виконуються при кожному прямому виклику та уповільнюють роботу моделі, тому використовуйте їх лише під час пошуку багів. Щоб дізнатися точний шлях до модуля, записуйте назви з named_modules() під час реєстрації.

    Повний діагностичний скрипт

    Усі перевірки об’єднані в один інструмент командного рядка. Збережіть його як:

    debug_mini_gpt.py
    

    Скрипт визначає мінімальний CharTokenizer, механізм вибору пристрою, фінгерпринт, допоміжний інструмент для обробки пакетів даних та числові утиліти. Він будує модель на основі власної конфігурації чекпоїнта, відхиляє токенайзер із різною кількістю словників, а потім виконує вісім тестів із номерами: тест на обробку токенів в обидва боки, перевірка діапазону токенів, зсув пакетів даних, прямий проходження даних через модель, перевірка причинно-наслідкової незалежності, обчислення градієнтів, оновлення оптимізатора та, за бажанням, перевірка проблеми надприлаштування на новій моделі для дебагування, усе це під фіксованим значенням насіння. Варто звернути увагу на два моменти: під час тесту токенайзера збережені ідентифікатори декодуються та знову кодуються для перевірки справжнього набору даних, а під час тесту оптимізатора використовується новий екземпляр AdamW, щоб старі дані не могли вплинути на результати.

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

    Нещодавні оновлення PyTorch змінили стандартну поведінку torch.load на завантаження лише ваг, тому залежно від вашої версії та вмісту чекпоїнтів може знадобитися явно встановити параметр weights_only; перевірте актуальну документацію.

    Запуск діагностики

    Запустіть повний набір тестів за стандартними шляхами:

    python debug_mini_gpt.py
    

    Щоб швидко перевірити ситуацію, пропустіть етап переобучення. Прапорець має назву --skip-overfit з двома передніми хрест-знаками:

    python debug_mini_gpt.py - skip-overfit
    

    Використайте інший чекпоїнт:

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

    Надайте тесту на переобучення більше кроків:

    python debug_mini_gpt.py \
      --overfit-steps 500
    

    Кінцевий слеш продовжує команду в оболонках у стилі Unix; якщо ваша оболонка його не підтримує, розмістіть команду в одному рядку.

    Порядок дебагування на практиці

    Коли модель поводиться некоректно, виконуйте ці кроки по черзі, не пропускаючи жодного з них.

    Кроки від 1 до 5: дані та форми

    Прочитайте розшифровані дані:

    Does the tokenized dataset decode correctly?
    

    Перевірте зсув цілі:

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

    Перевірте діапазон токенів:

    Are all IDs between 0 and vocab_size - 1?
    

    Перевірте форми тензора:

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

    Перевірте початкову втрату:

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

    Кроки від 6 до 10: поведінка моделі та навчання

    Перевірте причинно-наслідкову незалежність:

    Can changing the future affect prefix logits?
    

    Єдиною прийнятною відповіддю є «ні». Перевірте градієнти:

    Are gradients present, finite, and nonzero?
    

    Перевірте оновлення параметрів:

    Does optimizer.step() change weights?
    

    Запам’ятайте один пакет даних:

    Can the model memorize a tiny fixed batch?
    

    Лише тоді розпочніть повне навчання:

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

    Чек-листи для кожної фази

    Перед навчанням:

    □ 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
    

    Під час навчання:

    □ Loss remains finite
    □ Gradient norms remain finite
    □ Learning rate follows the intended schedule
    □ Training loss decreases
    □ Validation loss is evaluated in eval mode
    □ Best checkpoint updates when validation improves
    □ Samples become more structured
    

    Під час генерації:

    □ Best checkpoint is loaded
    □ Matching tokenizer is loaded
    □ Model is in evaluation mode
    □ Context is cropped to block size
    □ Only final-position logits are sampled
    □ Temperature is positive
    □ Top-k does not exceed vocabulary size
    □ Repetition is measured, not only observed
    

    Звички, які ускладнюють виправлення помилок

    Зміна багатьох налаштувань одночасно

    Якщо один експеримент змінює всі ці фактори одночасно, ви не можете приписати результат жодному з них:

    Learning rate
    Batch size
    Dropout
    Model size
    Context length
    

    Змінюйте по одній основній змінні на кожен експеримент.

    Оцінка лише за зразками

    Погана якість тексту може бути наслідком недостатньої підготовки, неефективного декодування, використання неправильних точок контролю чи токенізаторів, пере- чи недооптимізації, і зразки не дозволяють розрізнити ці причини. Спочатку перевіряйте метрики та тести конвеєра обробки даних.

    Ігнорування попереджень

    Попередження про зміну форми тензорів, перехід на інше пристрій, значення NaN чи нескінченності, або невідповідні ключі точок контролю часто вказують на справжні баги; зрозумійте їх перед тим, як приглушити.

    Пропуск тестів на працездатність

    Перед тривалою роботою почніть з чогось простого:

    Tiny model
    Tiny batch
    Short context
    Few training steps
    

    Потім поступово збільшуйте масштаби.

    Використання механізму обрізки як рішення

    Кліпування може поглинути одне надмірне оновлення, але постійне кліпування означає, що потрібна увага до чогось більш глибокого: швидкості навчання, ініціалізації, масштабування втрат, числової точності чи аномалій у даних.

    Вправи: навмисно порушити ланцюжок обробки

    Ви більше довіряєте тесту після того, як бачите його невдачу, тому кожна вправа містить відомий баг.

    Видалити зсув цілей

    Зробіть вхідні дані та цілі ідентичними, переконайтеся, що тест пакету провалився, а потім відновте зсув.

    Вставити токен поза діапазоном

    Встановіть ID одного токена на:

    tokenizer.vocab_size
    

    Перевірка діапазону має провалитися, оскільки найвищий допустимий ID — це:

    vocab_size - 1
    

    Вимкнути маску причинності

    Тимчасово видаліть маску та запустіть тест на незалежність від майбутнього; зміна лише суфікса тепер має змінити логітти префікса.

    Використати абсурдну швидкість навчання

    Встановіть:

    learning_rate = 0.1
    

    Відстежуйте втрати, норму градієнта, значення параметрів та перевірки на нескінченність, а також намагайтеся скоротити час виконання.

    Заморозьте модель

    Застосуйте наступні кроки та подивіться, як тести градієнта та оптимізатора це оцінюють:

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

    Завантаження у неправильну архітектуру

    Завантажте чекпоїнт у модель, яка відрізняється за одним із цих параметрів, а потім перегляньте помилки відсутніх ключів, неочікуваних ключів та розбіжностей у розмірах:

    Vocabulary size
    Embedding dimension
    Number of layers
    

    Порівняння налаштувань dropout

    Запустіть тест з одним набором даних з обома значеннями та порівняйте, наскільки швидко кожне з них запам’ятовує інформацію:

    dropout = 0.0
    dropout = 0.2
    

    Створення звіту для відлагодження

    Збережіть ці результати у форматі JSON, щоб можна було порівнювати виконання та відтворювати проблеми:

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

    Основні висновки

    • Завдання, яке завершується без помилок, все одно може навчитися виконувати неправильну задачу.
  • Дебагування слід проводити у порядку потоку даних; тест на подорож туди-назад, перевірка діапазону та переконання щодо зсуву одного токена виявляють більшість помилок у даних.
  • Логіти мають бути у форматі [B, T, V], а нова модель має починатися близько значення log(vocab_size).
  • Тест на спільний префікс доводить причинно-наслідковий зв’язок через поведінку.
  • Перевірка градієнтів та порівняння параметрів до та після процесу навчання підтверджують можливість навчання; переобучення на одній партії даних підтверджує проблеми в усьому циклі.
  • Перевірки на нескінченні значення та спеціальні механізми допомагають виявити числові помилки; обрізка даних лише їх усуває.
  • Криві навчання та перевірки розділяють переобучення від недообучення, а якість моделі та процес декодування впливають на форму створеного тексту.
  • Необхідно відновити моделі з конфігурації чекпоїнта та перевірити токенайзер за допомогою його ідентифікатора.
  • Повний процес:

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

    За всім цим стоїть одне правило: не дебагувати за інтуїцією; написати тест, який ізолює одне припущення, підтвердити його та рухатися далі.

    Природним наступним кроком є краща представлення даних. Токени символів створюють довгі послідовності, тоді як словники ростуть до величезних розмірів; кодування пар байтів дозволяє об’єднувати часто зустрічаючіся послідовності символів, скорочуючи їх, щоб у одному вікні контексту могло поміщатися більше тексту. Його впровадження означає навчання процесів об’єднання, перекодування WikiText-2 та зміну розміру словника моделі, причому всі перевірки залишаються незмінними:

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

    Пов’язана література

    • The Next-Token Loop: A Mental Model of LLMs Before You Touch Agents — Дізнайтеся, як працюють токени, вікна контексту, процеси вибору даних та цикл генерації, за допомогою невеликих прикладів на Python без підключення до Інтернету, які пояснюють причини існування технологій RAG, ReAct та LangGraph.
    • From a One-Node Chatbot to an MCP-Backed Agent in LangGraph — Створіть додаток на LangGraph шар за шаром: стани та функції їх зміни, краї мережі, цикли використання інструментів, потоки з можливістю збереження стану, три режими потокової обробки даних та інструменти, які надаються через MCP.
  • Створення токенайзера з кодуванням пар байтів від нуля для малого GPT — Реалізуйте токенайзер BPE на рівні символів у Python, навчіть його на даних WikiText-2, збережіть та отримайте його «відбиток», а потім перенавчіть малий GPT на коротші, більш щільні послідовності токенів.
  • Від GPT-1 до моделей міркувань: що змінилося з кожною генерацією для розробників — Простежте еволюцію родини GPT від попереднього навчання у 2018 році до моделей міркувань, дізнайтеся, які ідеї були додані кожною генерацією, та скористайтеся функціями бачення GPT-4o та структурованих вихідних даних у Python.