Главная / Статьи / Отладка небольшой версии GPT в PyTorch: тесты, позволяющие выявить каждую причину сбоя.

Отладка небольшой версии 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 ставит на ноль случайные активации, из-за чего одинаковые запуски дают разные результаты. Его удаление (вместе с фиксированным значением seed) делает каждый тест воспроизводимым. Восстановите производственные настройки, как только пайплайн пройдет проверку.

    Проверьте среду выполнения

    Прежде чем взаимодействовать с моделью, выведите версии 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
    )
    

    Идентификатор, выходящий за пределы диапазона, нарушает поиск встроенных данных, и на GPU ошибка может проявиться в виде неясного утверждения с обеих сторон устройства, далекого от истинной причины. Типичные причины — неправильный файл токенизатора, поврежденные закодированные файлы, пересоздание словаря после кодирования или неоднородное обработка специальных токенов.

    Читайте сохраненные данные обратно в виде текста

    Числа, находящиеся в диапазоне, все равно могут кодировать неверный текст, поэтому декодируйте несколько сотен идентификаторов и прочитайте полученный текст:

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

    Небольшое отклонение — это нормально; большое отклонение является признаком проблемы. Очень высокая начальная потеря по сравнению с базовым уровнем указывает на крайние значения логитов, нестабильную инициализацию, некорректные идентификаторы токенов, цели, не соответствующие словарю, или неправильную форму выходных данных. Очень низкая начальная потеря, которую модель, не знающая ничего, не может добиться честно, указывает на утечку данных, случайную загрузку обученных весов, цели, равные входным данным, видимые будущие токены или непреднамеренное возобновление работы чекпоинта.

    Докажите, что маска причинности работает

    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 и infinity

    Помощник срабатывает сразу, как только тензор содержит неограниченное значение, указывая название тензора:

    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
    

    В моделях символов сильный штраф препятствует повторному использованию букв и быстро портит орфографию. Если каждая настройка декодирования всё равно приводит к циклам, проблема заключается в самой модели, а не в сэмплере. Чтобы узнать, как взаимодействуют эти настройки, ознакомьтесь с нашим руководством по параметрам temperature, top-k и top-p.

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

    Противоположная проблема проявляется в появлении случайных символов, неполных слов, избытке знаков препинания, резких сменах темы и неразборчивых строк. Вероятные причины — высокое значение параметра temperature, отсутствие фильтрации 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.")
    

    Для обученного чекпоинта показатель потерь должен явно быть ниже базового уровня; в противном случае веса не были загружены или токенизатор не совпадает.

    Выявление числовых проблем с помощью хуков forward

    Когда где-то появляется значение NaN, хуки forward проверяют выход каждого модуля во время прохождения данных. Эти хуки обрабатывают отдельные тензоры и кортежи и вызывают исключение с указанием имени класса модуля при первом встреченном неограниченном значении:

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

    Прикрепите их к каждому модулю типа linear, layer-norm и embedding, сохраняя соответствующие обработчики:

    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
    

    Связанная литература

  • Создание токенизатора с кодированием парами байт с нуля для небольшого GPT — Реализация токенизатора BPE на уровне символов на Python, его обучение на данных WikiText-2, сохранение и анализ характеристик, а также переобучение небольшого GPT с использованием более коротких и плотных последовательностей токенов.
  • От GPT-1 к моделям рассуждений: что изменилось с каждым поколением для разработчиков — История семейства GPT от предварительного обучения в 2018 году до моделей рассуждений, анализ новых идей, добавленных с каждым поколением, а также возможность вызова функций визуального анализа GPT-4o и генерации структурированных результатов из Python.