GPT-1
Реализация:
llm/src/llm/models/gpt/gpt.py· классGPTНоутбук:notebooks/gpt.ipynb
Место в линейке: GPT-1 → GPT-2 → LLaMA → Mistral → Mixtral · Gemma
Что вы узнаете
Заголовок раздела «Что вы узнаете»- какую задачу решала статья GPT-1 и в чём её научный вклад: генеративное предобучение плюс дискриминативное дообучение;
- как устроен прямой проход GPT-1 от индексов токенов до логитов — формулами, с формами тензоров;
- что такое post-LN блок декодера и чем он отличается от pre-LN;
- как посчитать число параметров модели по конфигу и сверить его с кодом;
- как GPT-1 дообучали на классификации, entailment, сходстве и тестах с выбором ответа;
- как всё это реализовано в классах
GPTиGptDecoderи как загрузить в них веса OpenAI.
Предварительные знания
Заголовок раздела «Предварительные знания»Эта глава собирает механизмы части I в одну модель и не повторяет их выводы. Понадобятся:
- языковое моделирование — цепное правило, cross-entropy, общая схема decoder-only трансформера;
- токенизация — BPE;
- эмбеддинги — таблица эмбеддингов, выходная проекция, weight tying;
- позиционное кодирование — обучаемые абсолютные позиции;
- attention и маски — scaled dot-product, multi-head, causal-маска, KV-кэш;
- нормализация — LayerNorm, post-LN и pre-LN;
- feed-forward — FFN и GELU;
- обучение и генерация.
Исторический контекст и вклад статьи
Заголовок раздела «Исторический контекст и вклад статьи»К 2018 году в обработке естественного языка было два подхода. Первый — обучать отдельную модель под каждую задачу (классификация, логический вывод, ответы на вопросы) на размеченных данных; таких данных мало, и они дорогие. Второй — брать из неразмеченного текста только представления слов или контекстные представления и подавать их в модель, архитектура которой всё равно подбирается под задачу (обзор этих работ — в разд. 2 статьи GPT-1).
Radford, Narasimhan, Salimans, Sutskever в статье Improving Language Understanding by Generative Pre-Training (OpenAI, 2018) предложили переносить всю модель, а не только представления. Схема из двух этапов:
- Генеративное предобучение (generative pre-training) — трансформер-декодер обучается как языковая модель, предсказывать следующий токен на большом неразмеченном корпусе.
- Дискриминативное дообучение (discriminative fine-tuning) — к той же модели добавляется один линейный слой, и она дообучается на размеченных данных конкретной задачи. Структурированные входы (пара предложений, вопрос с вариантами ответа) преобразуются в одну последовательность токенов, поэтому архитектура под задачу почти не меняется (см. Дообучение на задачах).
Главный научный вклад — показать, что такое предобучение трансформера на длинных связных текстах даёт универсальную модель: по аннотации статьи, общая задаче-независимая модель улучшила лучший известный результат в 9 из 12 исследованных задач. В разделе 5 статьи авторы также показали, что качество на задачах без дообучения (zero-shot, по эвристикам вроде сравнения вероятностей) растёт в ходе предобучения — эта линия станет центральной в GPT-2.
Факты о модели и обучении из статьи (разд. 4.1, «Model specifications»):
| Что | Значение в статье |
|---|---|
| Корпус предобучения | BooksCorpus — более 7000 неопубликованных книг разных жанров; выбран за длинные фрагменты связного текста |
| Архитектура | 12-слойный декодер с masked self-attention, 768-мерные состояния, 12 голов |
| Скрытый размер FFN | 3072 |
| Контекст | последовательности из 512 токенов |
| Словарь | BPE с 40 000 слияний; в чекпоинте openai-community/openai-gpt — 40 478 токенов (vocab_size) |
| Позиции | обучаемые позиционные эмбеддинги вместо синусоид оригинального трансформера |
| Активация | GELU |
| Инициализация | |
| Регуляризация | dropout 0,1 на residual, эмбеддингах и attention; модифицированная L2-регуляризация с |
| Оптимизация | Adam, максимальный learning rate , линейный warmup 2000 шагов, затем косинусное затухание до 0; 100 эпох, батчи из 64 последовательностей по 512 токенов |
Архитектурно GPT-1 опирается на decoder-only трансформер из работы Liu et al. (2018) — оригинальный трансформер Vaswani et al. (2017) без энкодера и cross-attention.
Архитектура блока декодера
Заголовок раздела «Архитектура блока декодера»%%{init: {"flowchart": {"rankSpacing": 28, "nodeSpacing": 28}}}%%
flowchart TB
Ids(["token ids"]):::io --> TokEmb["Token Embedding"]:::blue
Ids --> PosEmb["Position Embedding<br/>(обучаемые)"]:::purple
TokEmb --> Sum(("+")):::add
PosEmb --> Sum
Sum --> Drop["Dropout"]:::gray
subgraph Dec["GptDecoder × num_layers · post-LN"]
direction TB
X(["x"]):::io --> Attn["Masked Multi-Head Attention"]:::blue
Attn --> A1(("+")):::add
X -. residual .-> A1
A1 --> N1["LayerNorm"]:::gray
N1 --> FFN["Feed Forward<br/>Linear → GELU → Linear"]:::purple
FFN --> A2(("+")):::add
N1 -. residual .-> A2
A2 --> N2["LayerNorm"]:::gray
end
Drop --> Dec
Dec --> Lin
Lin["Linear → vocab_size"]:::gray --> Out(["logits"]):::io
Out -. "generate(): softmax → выбор токена" .-> Next(["следующий токен"]):::io
style Dec fill:transparent,stroke:#82b366,stroke-width:2px,color:#5b9a3c
classDef io fill:#ffffff,stroke:#999999,color:#1a1a1a;
classDef add fill:#ffffff,stroke:#666666,color:#1a1a1a;
classDef blue fill:#dae8fc,stroke:#6c8ebf,color:#1a1a1a;
classDef blueHl fill:#dae8fc,stroke:#2f5f9e,stroke-width:3px,color:#1a1a1a;
classDef purple fill:#e1d5e7,stroke:#9673a6,color:#1a1a1a;
classDef purpleHl fill:#e1d5e7,stroke:#6a3d85,stroke-width:3px,color:#1a1a1a;
classDef gray fill:#f5f5f5,stroke:#666666,color:#1a1a1a;
classDef grayHl fill:#f5f5f5,stroke:#333333,stroke-width:3px,color:#1a1a1a;
classDef gold fill:#fff2cc,stroke:#d6b656,color:#1a1a1a;
classDef rope fill:#d5f0ec,stroke:#3a9e8f,color:#1a1a1a;
classDef ropeHl fill:#d5f0ec,stroke:#1f6f63,stroke-width:3px,color:#1a1a1a;
classDef dim fill:#f5f5f5,stroke:#bbbbbb,color:#999999,stroke-dasharray:4 3;
Обратите внимание: LayerNorm стоит после сложения с residual-связью (x + Attention(x), затем норма) — это ключевое отличие от GPT-2 и всех более поздних архитектур в этом репозитории, которые используют pre-LN. Финальной нормализации после стека блоков нет: выход последнего блока уже нормализован его LayerNorm.
Прямой проход в формулах
Заголовок раздела «Прямой проход в формулах»Разберём forward целиком, блок за блоком. Вход — батч индексов токенов , . Для краткости формулы записаны для одной последовательности (матрицы ); в коде ко всем тензорам спереди добавляется ось батча .
Шаг 1. Эмбеддинги токенов и позиций
Заголовок раздела «Шаг 1. Эмбеддинги токенов и позиций»где:
- — таблица токенных эмбеддингов; — её строка с номером , вектор ;
- — строки, выбранные для всех токенов последовательности;
- — таблица обучаемых позиционных эмбеддингов; берутся её первые строк (при генерации с кэшем — строки , где — длина кэша);
- — вход первого блока.
Каждая строка — «что это за токен» плюс «где он стоит». Позиция нужна, потому что attention сам по себе не различает порядок (см. positional-encoding.md). Пример при : токен с эмбеддингом на позиции с эмбеддингом даёт вектор ; тот же токен на другой позиции даст другой вектор.
Шаг 2. L блоков декодера (post-LN)
Заголовок раздела «Шаг 2. L блоков декодера (post-LN)»Для :
где:
- — вход блока , — его выход;
- — masked multi-head attention (формулы — в Устройство компонентов);
- — позиционно-независимая двухслойная сеть, применяется к каждой строке отдельно;
- — промежуточное состояние после attention-подблока;
- — два LayerNorm со своими параметрами , применяются к каждой строке.
Форма сохраняется: каждый блок принимает и отдаёт , поэтому блоки можно ставить друг за другом в любом количестве. Смешивание информации между позициями происходит только в MHA; FFN и LayerNorm работают с каждой позицией независимо.
Шаг 3. Выходная проекция и распределение
Заголовок раздела «Шаг 3. Выходная проекция и распределение»где:
- — выход последнего блока;
- , — веса выходной проекции; при weight tying и нет (см. Weight tying и веса OpenAI);
- — логиты; строка — ненормированные оценки всех токенов словаря как продолжения префикса ;
- — распределение следующего токена.
forward возвращает только логиты ; softmax считается в функции потерь (cross-entropy, см. language-modeling.md) или в generate. Благодаря causal-маске строка зависит только от , поэтому один проход даёт предсказаний сразу — это teacher forcing при обучении.
Сводка форм для учебного конфига (B = 2, T = 16, d = 256, H = 4, V = 1000):
x [2, 16] индексы токеновE[x], P[0:16] [2, 16, 256], [16, 256]H^(0) [2, 16, 256]Q, K, V одного блока [2, 4, 16, 64] после разбиения на головывеса внимания [2, 4, 16, 16]H^(l), l = 1..4 [2, 16, 256]Z (logits) [2, 16, 1000]Устройство компонентов
Заголовок раздела «Устройство компонентов»Этот раздел напоминает формулы компонентов с подробными схемами; выводы и обоснования — в главах части I: attention.md, masks.md, feed-forward.md, normalization.md.
Multi-Head Attention
Заголовок раздела «Multi-Head Attention»Обзор всех видов attention в репозитории (MHA, GQA, MQA, скользящее окно) — в attention.md; GPT-1 использует обычный MHA — у каждой из голов свои Q, K и V (см. виды по числу голов K/V).
= num_heads голов считаются параллельно; в коде это не отдельные модули, а одна проекция Linear(emb_size, H · head_size) для каждого из Q, K, V с последующим reshape на головы.
где:
- — вход подблока (в GPT-1 это без нормализации);
- , — параметры проекций;
- ; столбцы образуют — запросы, ключи и значения головы .
По умолчанию , так что (768 / 12 = 64 в GPT-1). Выход:
где — выход головы (ниже), — склейка голов по последней оси, , — выходная проекция, которая возвращает результат в пространство модели и смешивает головы.
%%{init: {"flowchart": {"rankSpacing": 28, "nodeSpacing": 28}}}%%
flowchart TB
X(["x · [batch, seq_len, emb_size]"]):::io
X --> H1["Head 1"]:::blue
X --> H2["Head 2"]:::blue
X --> Hd["⋯"]:::io
X --> Hh["Head h"]:::blue
H1 --> Cat["Concat<br/>[batch, seq_len, h · head_size]"]:::gray
H2 --> Cat
Hh --> Cat
Cat --> WO["Linear W_O → emb_size"]:::gray --> Drop["Dropout"]:::gray --> Out(["out"]):::io
classDef io fill:#ffffff,stroke:#999999,color:#1a1a1a;
classDef add fill:#ffffff,stroke:#666666,color:#1a1a1a;
classDef blue fill:#dae8fc,stroke:#6c8ebf,color:#1a1a1a;
classDef blueHl fill:#dae8fc,stroke:#2f5f9e,stroke-width:3px,color:#1a1a1a;
classDef purple fill:#e1d5e7,stroke:#9673a6,color:#1a1a1a;
classDef purpleHl fill:#e1d5e7,stroke:#6a3d85,stroke-width:3px,color:#1a1a1a;
classDef gray fill:#f5f5f5,stroke:#666666,color:#1a1a1a;
classDef grayHl fill:#f5f5f5,stroke:#333333,stroke-width:3px,color:#1a1a1a;
classDef gold fill:#fff2cc,stroke:#d6b656,color:#1a1a1a;
classDef rope fill:#d5f0ec,stroke:#3a9e8f,color:#1a1a1a;
classDef ropeHl fill:#d5f0ec,stroke:#1f6f63,stroke-width:3px,color:#1a1a1a;
classDef dim fill:#f5f5f5,stroke:#bbbbbb,color:#999999,stroke-dasharray:4 3;
Одна голова: scaled dot-product attention с causal-маской
Заголовок раздела «Одна голова: scaled dot-product attention с causal-маской»где:
- — матрица оценок: элемент — скалярное произведение запроса позиции и ключа позиции ;
- — масштаб, удерживающий дисперсию оценок порядка 1 (вывод — в attention.md);
- — causal-маска: запрещает позиции смотреть на будущие позиции (см. masks.md);
- softmax берётся по каждой строке (по ), так что веса в строке неотрицательны и дают в сумме 1;
- — dropout на весах внимания (
attention_dropout, по умолчанию 0); - — для каждой позиции взвешенное среднее значений видимых позиций.
После softmax веса замаскированных позиций становятся нулевыми: .
%%{init: {"flowchart": {"rankSpacing": 28, "nodeSpacing": 28}}}%%
flowchart TB
X(["x"]):::io --> Wq["W_q"]:::gray --> Q["Q"]:::blue
X --> Wk["W_k"]:::gray --> K["K"]:::blue
X --> Wv["W_v"]:::gray --> V["V"]:::blue
Q --> QK["Q · Kᵀ"]:::gray
K --> QK
QK --> Scale["÷ √head_size"]:::gray
Scale --> Mask["causal mask<br/>позиции j > i → −∞"]:::gold
Mask --> SM["softmax по строкам"]:::purple
SM --> AV["weights · V"]:::gray
V --> AV
AV --> O(["выход головы · [batch, seq_len, head_size]"]):::io
classDef io fill:#ffffff,stroke:#999999,color:#1a1a1a;
classDef add fill:#ffffff,stroke:#666666,color:#1a1a1a;
classDef blue fill:#dae8fc,stroke:#6c8ebf,color:#1a1a1a;
classDef blueHl fill:#dae8fc,stroke:#2f5f9e,stroke-width:3px,color:#1a1a1a;
classDef purple fill:#e1d5e7,stroke:#9673a6,color:#1a1a1a;
classDef purpleHl fill:#e1d5e7,stroke:#6a3d85,stroke-width:3px,color:#1a1a1a;
classDef gray fill:#f5f5f5,stroke:#666666,color:#1a1a1a;
classDef grayHl fill:#f5f5f5,stroke:#333333,stroke-width:3px,color:#1a1a1a;
classDef gold fill:#fff2cc,stroke:#d6b656,color:#1a1a1a;
classDef rope fill:#d5f0ec,stroke:#3a9e8f,color:#1a1a1a;
classDef ropeHl fill:#d5f0ec,stroke:#1f6f63,stroke-width:3px,color:#1a1a1a;
classDef dim fill:#f5f5f5,stroke:#bbbbbb,color:#999999,stroke-dasharray:4 3;
В коде (core/multi_head_attention.py, MultiHeadAttention.forward): scores = q @ k.transpose(-2, -1) / self._head_size ** 0.5 — дробь под softmax; scores.masked_fill(~causal_mask, float('-inf')) — прибавление (маска — нижнетреугольный буфер _tril_mask); weights @ v — умножение на для всех голов сразу, потому что Q, K, V имеют форму [batch, num_heads, seq_len, head_size].
Feed Forward
Заголовок раздела «Feed Forward»где:
- — одна строка состояния (FFN применяется к каждой позиции отдельно);
- , — расширяющий слой; , — сжимающий;
- — в коде зашито (
emb_size * 4); для GPT-1 это 3072, как в статье.
GELU по умолчанию — tanh-аппроксимация (activation="gelu_tanh", класс GELU в core/gelu.py), как в оригинальном коде OpenAI:
где — скаляр (активация применяется поэлементно). Точная форма с функцией распределения стандартного нормального закона и сравнение с ReLU — в feed-forward.md.
flowchart LR
X(["x"]):::io --> L1["Linear<br/>emb_size → 4·emb_size"]:::gray --> Act["GELU"]:::purple --> L2["Linear<br/>4·emb_size → emb_size"]:::gray --> Drop["Dropout"]:::gray --> Out(["out"]):::io
classDef io fill:#ffffff,stroke:#999999,color:#1a1a1a;
classDef add fill:#ffffff,stroke:#666666,color:#1a1a1a;
classDef blue fill:#dae8fc,stroke:#6c8ebf,color:#1a1a1a;
classDef blueHl fill:#dae8fc,stroke:#2f5f9e,stroke-width:3px,color:#1a1a1a;
classDef purple fill:#e1d5e7,stroke:#9673a6,color:#1a1a1a;
classDef purpleHl fill:#e1d5e7,stroke:#6a3d85,stroke-width:3px,color:#1a1a1a;
classDef gray fill:#f5f5f5,stroke:#666666,color:#1a1a1a;
classDef grayHl fill:#f5f5f5,stroke:#333333,stroke-width:3px,color:#1a1a1a;
classDef gold fill:#fff2cc,stroke:#d6b656,color:#1a1a1a;
classDef rope fill:#d5f0ec,stroke:#3a9e8f,color:#1a1a1a;
classDef ropeHl fill:#d5f0ec,stroke:#1f6f63,stroke-width:3px,color:#1a1a1a;
classDef dim fill:#f5f5f5,stroke:#bbbbbb,color:#999999,stroke-dasharray:4 3;
LayerNorm
Заголовок раздела «LayerNorm»где — одна строка состояния; — скаляры, среднее и дисперсия её координат; — обучаемые масштаб и сдвиг; (значение по умолчанию nn.LayerNorm) защищает от деления на ноль; — поэлементное умножение. Подробно — в normalization.md.
Post-LN: где стоит нормализация
Заголовок раздела «Post-LN: где стоит нормализация»В GPT-1, как и в оригинальном трансформере, нормализация стоит после residual-сложения (post-LN): . Начиная с GPT-2 её ставят перед подблоком (pre-LN): .
Разница существенна для обучения. В post-LN каждый LayerNorm стоит на основном (residual) пути, и сигнал, и градиент проходят через нормализаций подряд. Xiong et al. (2020) показали, что у post-LN градиенты параметров у выхода велики в начале обучения, поэтому ему нужен warmup learning rate; в pre-LN residual-путь от входа до выхода — чистая сумма без нормализаций, и глубокие стеки обучаются стабильнее. Подробный разбор — в normalization.md.
Маленький пример при , , , . Пусть и подблок выдал .
- Post-LN: , , , выход . Норма исходного вектора потеряна — на выходе всегда вектор с нулевым средним и единичной дисперсией.
- Pre-LN: идёт в подблок, а выход сохраняет целиком как слагаемое.
У post-LN есть плюс: выход каждого блока нормализован, поэтому GPT-1 не нужна финальная нормализация перед выходной проекцией. В pre-LN residual-поток ничем не нормализован, и GPT-2 добавляет финальный LayerNorm.
Компоненты
Заголовок раздела «Компоненты»| Компонент | Класс | Файл |
|---|---|---|
| Токен-эмбеддинги | TokenEmbeddings | core/token_embeddings.py |
| Позиционные эмбеддинги | PositionalEmbeddings (обучаемые, абсолютные) | core/positional_embeddings.py |
| Attention | MultiHeadAttention (стандартный causal MHA, без RoPE/GQA) | core/multi_head_attention.py |
| FFN | FeedForward (2-слойный MLP, tanh-аппроксимация GELU — activation="gelu_tanh", как в оригинальном коде OpenAI; меняется ключом activation в конфиге) | core/feed_forward.py |
| Блок декодера | GptDecoder (post-LN) | core/gpt_decoder.py |
| Модель целиком | GPT | models/gpt/gpt.py |
GptDecoder.forward:
attn_out = Attention(x)out = Norm1(attn_out + x)ffn_out = FFN(out)result = Norm2(ffn_out + out)Важная деталь: после последнего блока декодера нет финальной нормализации — GPT.forward идёт напрямую из стека декодеров в Linear-проекцию на словарь. (GPT-2 в этом смысле отличается — см. gpt2.md.)
Подсчёт параметров
Заголовок раздела «Подсчёт параметров»Посчитаем параметры по компонентам. Предполагаем (так по умолчанию) и (так в коде всегда).
| Компонент | Параметры | Откуда |
|---|---|---|
| Токенные эмбеддинги | таблица | |
| Позиционные эмбеддинги | таблица | |
| Attention одного блока | по и четыре bias по | |
| FFN одного блока | : , : ; : , : | |
| Два LayerNorm одного блока | по и размера у каждого | |
| Выходная проекция | без tying; с tying | и ; при tying — общая с матрица |
Один блок: . Вся модель:
где — число обучаемых параметров, остальные символы — из таблицы обозначений ( — словарь, — размерность модели, — максимальный контекст, — число блоков).
Главный член — : при большом линейные слои блоков доминируют, а эмбеддинги растут лишь линейно по .
GPT-1 (, , , , с tying):
V·d = 40 478 · 768 = 31 087 104T_max·d = 512 · 768 = 393 216блок: 12·768² + 13·768 = 7 077 888 + 9 984 = 7 087 872L · блок = 12 · 7 087 872 = 85 054 464итого 116 534 784Это совпадает с числом параметров HF-модели openai-community/openai-gpt, записанным в backlog.md. Блоки — 73 % параметров, эмбеддинги — 27 %. Без tying добавляется , итого 147 662 366. В самой статье GPT-1 число параметров не приводится; в статье GPT-2 (табл. 2) самая маленькая модель, 117M, названа эквивалентной исходному GPT.
Учебный конфиг gpt_train.json (, , ; vocab_size берётся из токенизатора, при bpe_vocab_size = 1000 возьмём ), по умолчанию без tying:
V·d = 256 000; T_max·d = 32 768; блок = 12·256² + 13·256 = 789 760; 4 блока = 3 159 040с tying: 256 000 + 32 768 + 3 159 040 = 3 447 808без tying: 3 447 808 + 1000·256 + 1000 = 3 704 808Проверка на модели из репозитория (parameters() возвращает общий тензор при tying один раз, поэтому двойного счёта нет):
from llm.models.gpt import GPT
cfg = {"vocab_size": 40478, "embed_dim": 768, "num_heads": 12, "num_layers": 12, "max_position_embeddings": 512, "dropout": 0.0, "tie_word_embeddings": True}print(sum(p.numel() for p in GPT(cfg).parameters())) # 116534784Для учебного конфига с vocab_size=1000 тот же код печатает 3 704 808 (без tying) и 3 447 808 (с tying).
Конфигурация
Заголовок раздела «Конфигурация»Пример из experiments/llm_only/configs/gpt_train.json:
| Параметр | Значение в примере | Значение в GPT-1 | Смысл |
|---|---|---|---|
vocab_size | (из токенизатора) | 40478 | размер словаря |
embed_dim | 256 | 768 | размерность эмбеддингов и скрытого состояния |
num_heads | 4 | 12 | число attention-голов (head_size = embed_dim / num_heads; embed_dim должен делиться на num_heads, иначе ValueError) |
head_size | (нет в примере) | 64 | необязательный размер головы ; по умолчанию embed_dim // num_heads (тогда embed_dim обязан делиться на num_heads). Если задан, num_heads · head_size может не совпадать с embed_dim |
num_layers | 4 | 12 | число блоков GptDecoder в стеке |
max_position_embeddings | 128 | 512 | максимальная длина последовательности (размер таблицы позиционных эмбеддингов и causal-маски) |
dropout | 0.1 | 0.1 | dropout на эмбеддингах и на выходах attention и FFN перед residual (embd_pdrop и resid_pdrop в оригинале) |
attention_dropout | (нет в примере) | 0.1 | необязательный dropout на весах внимания после softmax (attn_pdrop), по умолчанию 0.0 |
activation | (нет в примере) | GELU | необязательный: активация FFN — "gelu_tanh" (по умолчанию, tanh-аппроксимация GELU, как в оригинальном коде OpenAI), "gelu" (точный GELU через erf) или "relu" |
initializer_range | (нет в примере) | 0.02 | необязательное стандартное отклонение начальных весов, по умолчанию 0.02 (см. ниже) |
tie_word_embeddings | (нет в примере) | да | необязательный: true — выходная проекция без bias делит веса с токенными эмбеддингами, как в оригинале (см. ниже); по умолчанию false — отдельный Linear с bias |
Инициализация весов
Заголовок раздела «Инициализация весов»Как в статье (разд. 4.1) и коде OpenAI, веса Linear и Embedding инициализируются , bias — нулями, LayerNorm — весом 1 и нулевым сдвигом (функция init_normal_ в core/weight_init.py). Инициализация PyTorch по умолчанию даёт эмбеддинги и веса Linear с std ≈ ; с логиты свежей модели близки к нулю, и начальный loss — около , как у равномерного распределения. Например, у свежей модели учебного конфига с loss на случайных токенах ≈ 6,9, а . Инициализация важна только при обучении с нуля: загрузка чекпоинта её перезаписывает.
Из-за post-LN и малых весов у свежей модели скалярные произведения Q·K почти нулевые и внимание почти равномерное — модель начинает учитывать порядок токенов по мере обучения. Общая теория инициализации — в training.md.
Weight tying и веса OpenAI
Заголовок раздела «Weight tying и веса OpenAI»В GPT-1 логиты считаются умножением скрытого состояния на ту же матрицу, что хранит токенные эмбеддинги: , без bias. Это weight tying (связывание весов). Оно видно в формуле (2) статьи — , где — матрица эмбеддингов токенов, — и в оригинальном коде OpenAI (finetune-transformer-lm/train.py: tf.matmul(h, we, transpose_b=True)), и в HuggingFace (OpenAIGPTLMHeadModel). Зачем это нужно и почему это разумно, разобрано в embeddings.md.
Здесь tying включается ключом "tie_word_embeddings": true (функция output_projection в core/token_embeddings.py): _linear.weight — тот же параметр, что _token_embeddings._embedding.weight, и градиенты от входа и от выхода складываются в нём. Модель становится меньше на vocab_size · embed_dim + vocab_size параметров. По умолчанию ключ выключен, чтобы загружались чекпоинты, сохранённые раньше: в них есть отдельные _linear.weight и _linear.bias. Чекпоинт одного вида в модель другого не загружается.
nn.Linear хранит вес в форме [out, in] и считает . У выходной проекции weight имеет форму [V, d] — ровно как таблица эмбеддингов, поэтому связывание — это просто один и тот же тензор в двух модулях, без транспонирования.
С tie_word_embeddings загружаются веса openai-community/openai-gpt — через convert_hf_state_dict из models/gpt/hf_weights.py:
from transformers import OpenAIGPTLMHeadModelfrom llm.models.gpt import GPT, convert_hf_state_dict
hf = OpenAIGPTLMHeadModel.from_pretrained("openai-community/openai-gpt")model = GPT({"vocab_size": 40478, "embed_dim": 768, "num_heads": 12, "num_layers": 12, "max_position_embeddings": 512, "dropout": 0.0, "tie_word_embeddings": True})model.load_state_dict(convert_hf_state_dict(hf.state_dict()))Логиты совпадают с HF с точностью до ~2e-5, greedy-генерация — токен в токен. Активация по умолчанию подходит: afn="gelu" в конфиге HF для этой модели означает tanh-аппроксимацию (своя таблица активаций в modeling_openai.py), то есть наш "gelu_tanh". Для генерации текста нужен и токенизатор этой модели (OpenAIGPTTokenizer из transformers): собственный BPE репозитория (tokenization.md) даёт другие индексы.
Что делает convert_hf_state_dict (одна функция для GPT-1 и GPT-2):
- Снимает префикс
transformer.и переименовывает верхнеуровневые ключи:tokens_embed(GPT-1) иwte(GPT-2) →_token_embeddings._embedding,positions_embed/wpe→_position_embeddings.embedding,ln_f→_norm(есть только у GPT-2). lm_head.weightпропускает — он совпадает с эмбеддингами; пропускает и буферы causal-маски старых чекпоинтов (attn.bias,attn.masked_bias).- В блоках HF использует слой
Conv1D, который хранит вес в форме[in, out](и считает ), аnn.Linear—[out, in], поэтому весаc_attn,c_proj,c_fcтранспонируются. - HF хранит Q, K, V одной матрицей
c_attnформы[d, 3d]; после транспонирования[3d, d]она режетсяtorch.chunk(value, 3, dim=0)на_heads._q,_heads._k,_heads._v. Остальные имена:attn.c_proj→_heads._layer,mlp.c_fc→_ff._layer1,mlp.c_proj→_ff._layer2,ln_1→_norm1,ln_2→_norm2. - В конце добавляет
_linear.weight— ссылку на ту же матрицу эмбеддингов:load_state_dictждёт оба ключа.
Неизвестный ключ даёт KeyError — так не получится молча загрузить чекпоинт другой архитектуры.
Как это устроено в коде
Заголовок раздела «Как это устроено в коде»GPT.__init__
Заголовок раздела «GPT.__init__»models/gpt/gpt.py, класс GPT (наследник BaseModel из core/base_model.py, который даёт generate, save, load):
| Строка кода | Что создаёт | В формулах |
|---|---|---|
head_size = resolve_head_size(config, "num_heads") | размер головы с проверкой делимости (core/config_checks.py) | |
self._max_seq_len = config["max_position_embeddings"] | предел длины для проверок и generate | |
self._token_embeddings = TokenEmbeddings(...) | nn.Embedding(V, d) | |
self._position_embeddings = PositionalEmbeddings(...) | nn.Embedding(T_max, d) | |
self._dropout = nn.Dropout(config["dropout"]) | dropout на сумме эмбеддингов | Dropout шага 1 |
self._decoders = nn.ModuleList([GptDecoder(...) ...]) | блоков | шаг 2 |
self._linear = output_projection(...) | Linear(d, V); при tying — без bias и с общим весом | |
self.apply(partial(init_normal_, std=...)) | инициализация |
GptDecoder.__init__ (core/gpt_decoder.py) создаёт _heads = MultiHeadAttention(...), _ff = FeedForward(..., activation=activation), _norm1, _norm2 = nn.LayerNorm(emb_size). Отдельный класс GptDecoder нужен именно из-за post-LN: остальные блоки декодера в репозитории — pre-LN.
GPT.forward
Заголовок раздела «GPT.forward»def forward(self, x, attention_mask=None, use_cache=False, cache=None): start_pos = cache_start_pos(cache) # s — длина кэша check_sequence_length(x.size(1), start_pos, self._max_seq_len) # s + T ≤ T_max padding = padding_from_attention_mask(attention_mask, x, start_pos) # None без нулей в маске tok_out = self._token_embeddings(x) # E[x] [B, T, d] pos_out = self._position_embeddings(seq_len, start_pos=start_pos).unsqueeze(0) # P[s:s+T] [1, T, d] # при паддинге: self._position_embeddings(seq_len, positions=padding.positions) [B, T, d] out = self._dropout(tok_out + pos_out) # H^(0) [B, T, d] for i, decoder in enumerate(self._decoders): # H^(l), padding передаётся в каждый блок ... logits = self._linear(out) # Z [B, T, V](фрагмент сокращён: цикл передаёт в каждый блок padding и собирает новый KV-кэш по слоям, если use_cache=True). Возвращается кортеж (logits, new_cache); при use_cache=False второй элемент — None.
Детали:
- Без паддинга
.unsqueeze(0)превращает[T, d]в[1, T, d], и сложение с[B, T, d]проходит по правилам broadcasting: одни и те же позиционные векторы прибавляются к каждой последовательности батча. check_sequence_lengthзапрещает выйти за : у обучаемых позиций нет строки для позиции и дальше.padding_from_attention_maskпо маске с нулями строит маску ключей и позицииcumsum(mask) − 1для каждой строки батча; паддинг допускается в любом месте строки (см. masks.md).- Порядок позиционных аргументов у
GPT.forward—(x, attention_mask, use_cache, cache), а уGPT2.forward—(x, use_cache, cache, attention_mask).generateпередаёт их по имени, так что это безопасно; в своём коде тоже передавайте по имени.
GptDecoder.forward реализует шаг 2 буквально: out = self._norm1(attention + x) — это , result = self._norm2(ffn_out + out) — это .
Дообучение на задачах
Заголовок раздела «Дообучение на задачах»В репозитории дообучения GPT-1 на задачах нет:
GPT— только языковая модель, у неё нет классификационной головы, специальных токенов и функции потерь для задач. Раздел описывает, как это сделано в статье (разд. 3.2–3.3, рис. 1).
После предобучения модель дообучается на размеченном наборе , где каждый пример — последовательность токенов и метка . Вход прогоняется через предобученную модель, берётся состояние последнего блока на последнем токене и подаётся в новый линейный слой:
где — единственные новые параметры ( — число классов), — выход последнего блока на позиции последнего токена (из-за causal-маски только он «видел» весь вход).
Функция потерь дообучения — сумма потерь задачи и вспомогательной потери языкового моделирования:
где — log-правдоподобие меток , — log-правдоподобие языковой модели на тех же текстах, в экспериментах статьи (статья записывает правдоподобия, которые максимизируются; в коде это были бы cross-entropy со знаком минус). По разд. 5 статьи вспомогательная потеря помогает на больших наборах и почти не помогает на маленьких.
Ключевая идея — входные преобразования (input transformations): задачи со структурированным входом сводятся к одной или нескольким последовательностям токенов, которые понимает языковая модель. Добавляются случайно инициализированные специальные токены: начало <s>, разделитель $ и конец (extract) <e>.
| Задача | Как строится вход | Как получается ответ |
|---|---|---|
| Классификация (тональность, грамматичность) | <s> текст <e> | → линейный слой |
| Логический вывод (entailment): посылка и гипотеза | <s> посылка $ гипотеза <e> | → линейный слой (3 класса: следует, противоречит, нейтрально) |
| Сходство двух предложений | две последовательности: <s> A $ B <e> и <s> B $ A <e> — у пары нет естественного порядка | обе прогоняются независимо, их складываются поэлементно → линейный слой |
| Вопросы с выбором ответа (multiple choice), здравый смысл | для каждого варианта : <s> контекст вопрос $ a_k <e> | каждая прогоняется независимо, линейный слой даёт скаляр, softmax по вариантам |
%%{init: {"flowchart": {"rankSpacing": 28, "nodeSpacing": 28}}}%%
flowchart LR
subgraph Ent["Entailment"]
direction LR
E1(["⟨s⟩ посылка $ гипотеза ⟨e⟩"]):::io --> ET["GPT"]:::blue --> EL["Linear"]:::gray
end
subgraph Sim["Similarity"]
direction LR
S1(["⟨s⟩ A $ B ⟨e⟩"]):::io --> ST1["GPT"]:::blue --> SP(("+")):::add
S2(["⟨s⟩ B $ A ⟨e⟩"]):::io --> ST2["GPT"]:::blue --> SP
SP --> SL["Linear"]:::gray
end
subgraph MC["Multiple choice"]
direction LR
M1(["⟨s⟩ контекст $ ответ 1 ⟨e⟩"]):::io --> MT1["GPT"]:::blue --> ML1["Linear"]:::gray --> MS["softmax"]:::purple
M2(["⟨s⟩ контекст $ ответ 2 ⟨e⟩"]):::io --> MT2["GPT"]:::blue --> ML2["Linear"]:::gray --> MS
end
classDef io fill:#ffffff,stroke:#999999,color:#1a1a1a;
classDef add fill:#ffffff,stroke:#666666,color:#1a1a1a;
classDef blue fill:#dae8fc,stroke:#6c8ebf,color:#1a1a1a;
classDef purple fill:#e1d5e7,stroke:#9673a6,color:#1a1a1a;
classDef gray fill:#f5f5f5,stroke:#666666,color:#1a1a1a;
Все «GPT» на схеме — одна и та же модель с общими весами. Гиперпараметры дообучения в статье: dropout классификатора 0,1, learning rate , батч 32, как правило 3 эпохи, линейный warmup на 0,2 % обучения.
Чтобы воспроизвести это на GPT из репозитория, пришлось бы добавить специальные токены в словарь (строки в ), получить скрытые состояния последнего блока (сейчас forward возвращает только логиты) и добавить с функцией потерь .
Генерация
Заголовок раздела «Генерация»Генерация одинакова для всех моделей репозитория и подробно разобрана в главе generation.md. Кратко:
generate(x, max_new_tokens, do_sample, temperature=1.0, top_k=None, top_p=None, use_cache=True, attention_mask=None, eos_token_id=None, pad_token_id=None)— один методBaseModel.generate(core/base_model.py); выбор токена —sample_next_tokenвcore/generation.py: greedy (do_sample=False), sampling с температурой, top-k, top-p.- С
eos_token_idзаконченные строки дополняютсяpad_token_id(по умолчанию тем жеeos_token_id); генерация останавливается, когда закончены все строки. Градиенты не считаются; неизвестный именованный аргумент —TypeError. - С KV-кэшем в
forwardподаётся только новый токен, а его позиция берётся из длины кэша (cache_start_pos); при паддинге — изattention_mask: номер токена среди настоящих токенов строки. - Когда последовательность становится длиннее
max_position_embeddings,generateберёт последниеmax_position_embeddingsтокенов и пересчитывает их без кэша: при сдвиге окна абсолютные позиции всех токенов меняются, и закэшированные K/V больше не годятся. - Промпты разной длины генерируются одним батчем с левым паддингом и
attention_mask; каждая строка даёт то же, что её промпт отдельно — см. masks.md.
import torchfrom llm.models.gpt import GPT
model = GPT({"vocab_size": 1000, "embed_dim": 256, "num_heads": 4, "num_layers": 4, "max_position_embeddings": 128, "dropout": 0.1})model.eval()prompt = torch.randint(0, 1000, (2, 4))out = model.generate(prompt, max_new_tokens=5, do_sample=False) # [2, 9]Отличия от оригинала
Заголовок раздела «Отличия от оригинала»| Что | Статья / код OpenAI | Этот репозиторий |
|---|---|---|
| Weight tying | есть (формула (2) статьи) | ключ tie_word_embeddings, по умолчанию выключен (отдельный Linear с bias) |
| Dropout внимания | 0,1 | attention_dropout, по умолчанию 0 |
| Токенизатор | BPE с 40 000 слияний, предобработка ftfy и spaCy | собственный BPE (tokenization.md); для весов OpenAI нужен токенизатор HF |
| Регуляризация | модифицированная L2 с | не реализована в модели (относится к оптимизатору, см. training.md) |
| Дообучение | классификационная голова, специальные токены, потеря | нет |
| Размер FFN | 3072 = | всегда , не настраивается |
| Активация | GELU (в коде OpenAI — tanh-аппроксимация) | "gelu_tanh" по умолчанию, можно "gelu" или "relu" |
Что изменилось в GPT-2
Заголовок раздела «Что изменилось в GPT-2»- нормализация: post-LN → pre-LN;
- появляется финальная нормализация перед выходной проекцией;
- выходные проекции residual-подблоков инициализируются с уменьшенным std;
- словарь — byte-level BPE на 50 257 токенов, контекст — 1024;
- FFN и attention переиспользуют ту же математику (GELU, стандартный MHA), но собраны в отдельный класс
Gpt2Decoderвместо параметризуемогоGptDecoder.
Подробности — в gpt2.md.
- GPT-1 ввёл схему «генеративное предобучение языковой модели + дискриминативное дообучение всей модели» и улучшил лучший результат в 9 из 12 задач.
- Архитектура: обучаемые эмбеддинги токенов и позиций → post-LN блоков (MHA + GELU-FFN) → линейная проекция на словарь, связанная с эмбеддингами.
- Post-LN нормализует выход каждого блока, поэтому финальной нормализации нет; ценой этого — менее стабильное обучение глубоких стеков.
- Параметры: с tying; для GPT-1 — 116 534 784.
- В репозитории — классы
GPTиGptDecoder; веса OpenAI загружаются черезconvert_hf_state_dictприtie_word_embeddings=True.
Вопросы и упражнения
Заголовок раздела «Вопросы и упражнения»- Почему в GPT-1 нет LayerNorm между последним блоком и выходной проекцией, а в GPT-2 он есть?
Ответ
В post-LN блок заканчивается нормализацией , поэтому уже нормализован. В pre-LN блок заканчивается residual-сложением, выход не нормализован, и перед проекцией нужен отдельный LayerNorm.
- Посчитайте число параметров GPT-1 без weight tying.
Ответ
.
- Сколько параметров в одном блоке
GptDecoderпри ? Какая доля приходится на FFN?
Ответ
. FFN: , то есть около 66,5 % блока. Attention: (33,3 %), LayerNorm — 1024.
- Модель с
max_position_embeddings = 128получила вход длины 130. Что произойдёт вforward? А вgenerate, если промпт короче, но генерация выходит за 128 токенов?
Ответ
forward выбросит ValueError из check_sequence_length: для позиций 128 и 129 нет строк в таблице . generate не падает: когда длина превысит 128, он подаёт последние 128 токенов без кэша (next_generation_input).
- Как бы вы оформили для GPT-1 задачу «перефразирование ли это» (paraphrase detection) и почему вход строится дважды?
Ответ
Это задача сходства: <s> A $ B <e> и <s> B $ A <e>, оба прохода, сумма , линейный слой на 2 класса. Из-за causal-маски модель читает текст слева направо, и порядок предложений влияет на представление; сумма по двум порядкам делает ответ симметричным.
- Используя пример из раздела про post-LN, проверьте: если подблок выдал при , каким будет выход post-LN блока? Что это говорит о постоянной составляющей выхода подблока?
Ответ
, , , — то же, что при . Добавка одинакового числа ко всем координатам полностью убирается вычитанием среднего.
- (Код.) Проверьте, что при
tie_word_embeddings=Trueвыходная проекция и эмбеддинги — один объект и чтоparameters()не считает его дважды.
Ответ
m = GPT({..., "tie_word_embeddings": True})assert m._linear.weight is m._token_embeddings._embedding.weightassert m._linear.bias is Nonenn.Module.parameters() по умолчанию пропускает повторяющиеся тензоры, поэтому сумма numel() совпадает с формулой с tying.
Литература
Заголовок раздела «Литература»Основная статья:
- Radford, Narasimhan, Salimans, Sutskever. Improving Language Understanding by Generative Pre-Training. OpenAI, 2018. PDF (на arXiv не публиковалась)
Компоненты и связанные работы:
- Vaswani et al. Attention Is All You Need. 2017. arXiv:1706.03762
- Liu et al. Generating Wikipedia by Summarizing Long Sequences. 2018. arXiv:1801.10198 — decoder-only трансформер, на который опирается GPT-1
- Hendrycks, Gimpel. Gaussian Error Linear Units (GELUs). 2016. arXiv:1606.08415
- Ba, Kiros, Hinton. Layer Normalization. 2016. arXiv:1607.06450
- Xiong et al. On Layer Normalization in the Transformer Architecture. 2020. arXiv:2002.04745 — почему pre-LN обучается стабильнее post-LN
- Radford, Wu, Child, Luan, Amodei, Sutskever. Language Models are Unsupervised Multitask Learners. OpenAI, 2019. PDF — GPT-2; упоминание о размере исходного GPT (табл. 2)