Перейти к содержимому

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 в одну модель и не повторяет их выводы. Понадобятся:

К 2018 году в обработке естественного языка было два подхода. Первый — обучать отдельную модель под каждую задачу (классификация, логический вывод, ответы на вопросы) на размеченных данных; таких данных мало, и они дорогие. Второй — брать из неразмеченного текста только представления слов или контекстные представления и подавать их в модель, архитектура которой всё равно подбирается под задачу (обзор этих работ — в разд. 2 статьи GPT-1).

Radford, Narasimhan, Salimans, Sutskever в статье Improving Language Understanding by Generative Pre-Training (OpenAI, 2018) предложили переносить всю модель, а не только представления. Схема из двух этапов:

  1. Генеративное предобучение (generative pre-training) — трансформер-декодер обучается как языковая модель, предсказывать следующий токен на большом неразмеченном корпусе.
  2. Дискриминативное дообучение (discriminative fine-tuning) — к той же модели добавляется один линейный слой, и она дообучается на размеченных данных конкретной задачи. Структурированные входы (пара предложений, вопрос с вариантами ответа) преобразуются в одну последовательность токенов, поэтому архитектура под задачу почти не меняется (см. Дообучение на задачах).

Главный научный вклад — показать, что такое предобучение трансформера на длинных связных текстах даёт универсальную модель: по аннотации статьи, общая задаче-независимая модель улучшила лучший известный результат в 9 из 12 исследованных задач. В разделе 5 статьи авторы также показали, что качество на задачах без дообучения (zero-shot, по эвристикам вроде сравнения вероятностей) растёт в ходе предобучения — эта линия станет центральной в GPT-2.

Факты о модели и обучении из статьи (разд. 4.1, «Model specifications»):

ЧтоЗначение в статье
Корпус предобученияBooksCorpus — более 7000 неопубликованных книг разных жанров; выбран за длинные фрагменты связного текста
Архитектура12-слойный декодер с masked self-attention, 768-мерные состояния, 12 голов
Скрытый размер FFN3072
Контекстпоследовательности из 512 токенов
СловарьBPE с 40 000 слияний; в чекпоинте openai-community/openai-gpt — 40 478 токенов (vocab_size)
Позицииобучаемые позиционные эмбеддинги вместо синусоид оригинального трансформера
АктивацияGELU
ИнициализацияN(0, 0,022)\mathcal{N}(0,\ 0{,}02^2)
Регуляризацияdropout 0,1 на residual, эмбеддингах и attention; модифицированная L2-регуляризация с w=0,01w = 0{,}01
ОптимизацияAdam, максимальный learning rate 2,5⋅10−42{,}5 \cdot 10^{-4}, линейный 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 целиком, блок за блоком. Вход — батч индексов токенов x∈{0,…,V−1}B×Tx \in \{0, \dots, V-1\}^{B \times T}, T≤Tmax⁡T \le T_{\max}. Для краткости формулы записаны для одной последовательности (матрицы T×dT \times d); в коде ко всем тензорам спереди добавляется ось батча BB.

H(0)=Dropout(E[x0,…,xT−1]+P[0,…,T−1])H^{(0)} = \mathrm{Dropout}\big(E[x_0, \dots, x_{T-1}] + P[0, \dots, T-1]\big)

где:

  • E∈RV×dE \in \mathbb{R}^{V \times d} — таблица токенных эмбеддингов; E[xt]E[x_t] — её строка с номером xtx_t, вектор ∈Rd\in \mathbb{R}^{d};
  • E[x0,…,xT−1]∈RT×dE[x_0, \dots, x_{T-1}] \in \mathbb{R}^{T \times d} — строки, выбранные для всех токенов последовательности;
  • P∈RTmax⁡×dP \in \mathbb{R}^{T_{\max} \times d} — таблица обучаемых позиционных эмбеддингов; берутся её первые TT строк (при генерации с кэшем — строки s,…,s+T−1s, \dots, s+T-1, где ss — длина кэша);
  • H(0)∈RT×dH^{(0)} \in \mathbb{R}^{T \times d} — вход первого блока.

Каждая строка H(0)H^{(0)} — «что это за токен» плюс «где он стоит». Позиция нужна, потому что attention сам по себе не различает порядок (см. positional-encoding.md). Пример при d=2d = 2: токен с эмбеддингом (0,1; 0,2)(0{,}1;\ 0{,}2) на позиции с эмбеддингом (0,3; −0,1)(0{,}3;\ -0{,}1) даёт вектор (0,4; 0,1)(0{,}4;\ 0{,}1); тот же токен на другой позиции даст другой вектор.

Для l=1,…,Ll = 1, \dots, L:

U(l)=LN1(H(l−1)+MHA(H(l−1))),H(l)=LN2(U(l)+FFN(U(l))),\begin{aligned} U^{(l)} &= \mathrm{LN}_1\big(H^{(l-1)} + \mathrm{MHA}(H^{(l-1)})\big), \\ H^{(l)} &= \mathrm{LN}_2\big(U^{(l)} + \mathrm{FFN}(U^{(l)})\big), \end{aligned}

где:

  • H(l−1)∈RT×dH^{(l-1)} \in \mathbb{R}^{T \times d} — вход блока ll, H(l)∈RT×dH^{(l)} \in \mathbb{R}^{T \times d} — его выход;
  • MHA:RT×d→RT×d\mathrm{MHA} : \mathbb{R}^{T \times d} \to \mathbb{R}^{T \times d} — masked multi-head attention (формулы — в Устройство компонентов);
  • FFN:RT×d→RT×d\mathrm{FFN} : \mathbb{R}^{T \times d} \to \mathbb{R}^{T \times d} — позиционно-независимая двухслойная сеть, применяется к каждой строке отдельно;
  • U(l)∈RT×dU^{(l)} \in \mathbb{R}^{T \times d} — промежуточное состояние после attention-подблока;
  • LN1,LN2\mathrm{LN}_1, \mathrm{LN}_2 — два LayerNorm со своими параметрами γ,β∈Rd\gamma, \beta \in \mathbb{R}^{d}, применяются к каждой строке.

Форма сохраняется: каждый блок принимает и отдаёт T×dT \times d, поэтому блоки можно ставить друг за другом в любом количестве. Смешивание информации между позициями происходит только в MHA; FFN и LayerNorm работают с каждой позицией независимо.

Z=H(L)Wout+bout,p(xt+1∣x≤t)=softmax(Zt)Z = H^{(L)} W_{\text{out}} + b_{\text{out}}, \qquad p(x_{t+1} \mid x_{\le t}) = \mathrm{softmax}(Z_t)

где:

  • H(L)∈RT×dH^{(L)} \in \mathbb{R}^{T \times d} — выход последнего блока;
  • Wout∈Rd×VW_{\text{out}} \in \mathbb{R}^{d \times V}, bout∈RVb_{\text{out}} \in \mathbb{R}^{V} — веса выходной проекции; при weight tying Wout=E⊤W_{\text{out}} = E^{\top} и boutb_{\text{out}} нет (см. Weight tying и веса OpenAI);
  • Z∈RT×VZ \in \mathbb{R}^{T \times V} — логиты; строка ZtZ_t — ненормированные оценки всех токенов словаря как продолжения префикса x0,…,xtx_0, \dots, x_t;
  • p(xt+1∣x≤t)∈RVp(x_{t+1} \mid x_{\le t}) \in \mathbb{R}^{V} — распределение следующего токена.

forward возвращает только логиты ZZ; softmax считается в функции потерь (cross-entropy, см. language-modeling.md) или в generate. Благодаря causal-маске строка ZtZ_t зависит только от x0,…,xtx_0, \dots, x_t, поэтому один проход даёт TT предсказаний сразу — это 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.

Обзор всех видов attention в репозитории (MHA, GQA, MQA, скользящее окно) — в attention.md; GPT-1 использует обычный MHA — у каждой из HH голов свои Q, K и V (см. виды по числу голов K/V).

HH = num_heads голов считаются параллельно; в коде это не отдельные модули, а одна проекция Linear(emb_size, H · head_size) для каждого из Q, K, V с последующим reshape на головы.

Q=XWQ+bQ,K=XWK+bK,V=XWV+bVQ = X W_Q + b_Q, \quad K = X W_K + b_K, \quad V = X W_V + b_V

где:

  • X∈RT×dX \in \mathbb{R}^{T \times d} — вход подблока (в GPT-1 это H(l−1)H^{(l-1)} без нормализации);
  • WQ,WK,WV∈Rd×HdhW_Q, W_K, W_V \in \mathbb{R}^{d \times H d_h}, bQ,bK,bV∈RHdhb_Q, b_K, b_V \in \mathbb{R}^{H d_h} — параметры проекций;
  • Q,K,V∈RT×HdhQ, K, V \in \mathbb{R}^{T \times H d_h}; столбцы idh,…,(i+1)dh−1i d_h, \dots, (i+1) d_h - 1 образуют Qi,Ki,Vi∈RT×dhQ_i, K_i, V_i \in \mathbb{R}^{T \times d_h} — запросы, ключи и значения головы ii.

По умолчанию dh=d/Hd_h = d / H, так что Hdh=dH d_h = d (768 / 12 = 64 в GPT-1). Выход:

MHA(X)=Dropout(Concat(head1,…,headH) WO+bO)\mathrm{MHA}(X) = \mathrm{Dropout}\big(\mathrm{Concat}(\mathrm{head}_1, \dots, \mathrm{head}_H)\, W_O + b_O\big)

где headi∈RT×dh\mathrm{head}_i \in \mathbb{R}^{T \times d_h} — выход головы ii (ниже), Concat(⋅)∈RT×Hdh\mathrm{Concat}(\cdot) \in \mathbb{R}^{T \times H d_h} — склейка голов по последней оси, WO∈RHdh×dW_O \in \mathbb{R}^{H d_h \times d}, bO∈Rdb_O \in \mathbb{R}^{d} — выходная проекция, которая возвращает результат в пространство модели и смешивает головы.

%%{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;
headi=Dropoutattn ⁣(softmax ⁣(QiKi⊤dh+M))Vi,Mts={0,s≤t−∞,s>t\mathrm{head}_i = \mathrm{Dropout}_{\text{attn}}\!\left(\mathrm{softmax}\!\left(\frac{Q_i K_i^{\top}}{\sqrt{d_h}} + M\right)\right) V_i, \qquad M_{ts} = \begin{cases} 0, & s \le t \\ -\infty, & s > t \end{cases}

где:

  • QiKi⊤∈RT×TQ_i K_i^{\top} \in \mathbb{R}^{T \times T} — матрица оценок: элемент (t,s)(t, s) — скалярное произведение запроса позиции tt и ключа позиции ss;
  • dh\sqrt{d_h} — масштаб, удерживающий дисперсию оценок порядка 1 (вывод — в attention.md);
  • M∈RT×TM \in \mathbb{R}^{T \times T} — causal-маска: запрещает позиции tt смотреть на будущие позиции s>ts > t (см. masks.md);
  • softmax берётся по каждой строке (по ss), так что веса в строке неотрицательны и дают в сумме 1;
  • Dropoutattn\mathrm{Dropout}_{\text{attn}} — dropout на весах внимания (attention_dropout, по умолчанию 0);
  • headi∈RT×dh\mathrm{head}_i \in \mathbb{R}^{T \times d_h} — для каждой позиции взвешенное среднее значений ViV_i видимых позиций.

После softmax веса замаскированных позиций становятся нулевыми: e−∞=0e^{-\infty} = 0.

%%{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')) — прибавление MM (маска — нижнетреугольный буфер _tril_mask); weights @ v — умножение на ViV_i для всех голов сразу, потому что Q, K, V имеют форму [batch, num_heads, seq_len, head_size].

FFN(x)=Dropout(GELU(xW1+b1) W2+b2)\mathrm{FFN}(x) = \mathrm{Dropout}\big(\mathrm{GELU}(x W_1 + b_1)\, W_2 + b_2\big)

где:

  • x∈Rdx \in \mathbb{R}^{d} — одна строка состояния (FFN применяется к каждой позиции отдельно);
  • W1∈Rd×dffW_1 \in \mathbb{R}^{d \times d_{ff}}, b1∈Rdffb_1 \in \mathbb{R}^{d_{ff}} — расширяющий слой; W2∈Rdff×dW_2 \in \mathbb{R}^{d_{ff} \times d}, b2∈Rdb_2 \in \mathbb{R}^{d} — сжимающий;
  • dff=4dd_{ff} = 4d — в коде зашито (emb_size * 4); для GPT-1 это 3072, как в статье.

GELU по умолчанию — tanh-аппроксимация (activation="gelu_tanh", класс GELU в core/gelu.py), как в оригинальном коде OpenAI:

GELU(z)≈12z(1+tanh⁡ ⁣(2/π (z+0,044715 z3)))\mathrm{GELU}(z) \approx \tfrac{1}{2} z \left(1 + \tanh\!\left(\sqrt{2/\pi}\,\big(z + 0{,}044715\, z^3\big)\right)\right)

где zz — скаляр (активация применяется поэлементно). Точная форма GELU(z)=z Φ(z)\mathrm{GELU}(z) = z\,\Phi(z) с функцией распределения стандартного нормального закона Φ\Phi и сравнение с 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;
LN(x)=γ⊙x−μσ2+ε+β,μ=1d∑j=1dxj,σ2=1d∑j=1d(xj−μ)2\mathrm{LN}(x) = \gamma \odot \frac{x - \mu}{\sqrt{\sigma^2 + \varepsilon}} + \beta, \qquad \mu = \frac{1}{d}\sum_{j=1}^{d} x_j,\quad \sigma^2 = \frac{1}{d}\sum_{j=1}^{d} (x_j - \mu)^2

где x∈Rdx \in \mathbb{R}^{d} — одна строка состояния; μ,σ2\mu, \sigma^2 — скаляры, среднее и дисперсия её координат; γ,β∈Rd\gamma, \beta \in \mathbb{R}^{d} — обучаемые масштаб и сдвиг; ε=10−5\varepsilon = 10^{-5} (значение по умолчанию nn.LayerNorm) защищает от деления на ноль; ⊙\odot — поэлементное умножение. Подробно — в normalization.md.

В GPT-1, как и в оригинальном трансформере, нормализация стоит после residual-сложения (post-LN): LN(x+f(x))\mathrm{LN}(x + f(x)). Начиная с GPT-2 её ставят перед подблоком (pre-LN): x+f(LN(x))x + f(\mathrm{LN}(x)).

Разница существенна для обучения. В post-LN каждый LayerNorm стоит на основном (residual) пути, и сигнал, и градиент проходят через 2L2L нормализаций подряд. Xiong et al. (2020) показали, что у post-LN градиенты параметров у выхода велики в начале обучения, поэтому ему нужен warmup learning rate; в pre-LN residual-путь от входа до выхода — чистая сумма без нормализаций, и глубокие стеки обучаются стабильнее. Подробный разбор — в normalization.md.

Маленький пример при d=2d = 2, γ=(1,1)\gamma = (1, 1), β=0\beta = 0, ε→0\varepsilon \to 0. Пусть x=(1,3)x = (1, 3) и подблок выдал f=(1,1)f = (1, 1).

  • Post-LN: x+f=(2,4)x + f = (2, 4), μ=3\mu = 3, σ2=1\sigma^2 = 1, выход LN(2,4)=(−1,1)\mathrm{LN}(2, 4) = (-1, 1). Норма исходного вектора потеряна — на выходе всегда вектор с нулевым средним и единичной дисперсией.
  • Pre-LN: LN(x)=(−1,1)\mathrm{LN}(x) = (-1, 1) идёт в подблок, а выход x+f(LN(x))x + f(\mathrm{LN}(x)) сохраняет x=(1,3)x = (1, 3) целиком как слагаемое.

У post-LN есть плюс: выход каждого блока нормализован, поэтому GPT-1 не нужна финальная нормализация перед выходной проекцией. В pre-LN residual-поток ничем не нормализован, и GPT-2 добавляет финальный LayerNorm.

КомпонентКлассФайл
Токен-эмбеддингиTokenEmbeddingscore/token_embeddings.py
Позиционные эмбеддингиPositionalEmbeddings (обучаемые, абсолютные)core/positional_embeddings.py
AttentionMultiHeadAttention (стандартный causal MHA, без RoPE/GQA)core/multi_head_attention.py
FFNFeedForward (2-слойный MLP, tanh-аппроксимация GELU — activation="gelu_tanh", как в оригинальном коде OpenAI; меняется ключом activation в конфиге)core/feed_forward.py
Блок декодераGptDecoder (post-LN)core/gpt_decoder.py
Модель целикомGPTmodels/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.)

Посчитаем параметры по компонентам. Предполагаем Hdh=dH d_h = d (так по умолчанию) и dff=4dd_{ff} = 4d (так в коде всегда).

КомпонентПараметрыОткуда
Токенные эмбеддинги EEVdV dтаблица V×dV \times d
Позиционные эмбеддинги PPTmax⁡dT_{\max} dтаблица Tmax⁡×dT_{\max} \times d
Attention одного блока4d2+4d4d^2 + 4dWQ,WK,WV,WOW_Q, W_K, W_V, W_O по d×dd \times d и четыре bias по dd
FFN одного блока8d2+5d8d^2 + 5dW1W_1: d⋅4dd \cdot 4d, b1b_1: 4d4d; W2W_2: 4d⋅d4d \cdot d, b2b_2: dd
Два LayerNorm одного блока4d4dпо γ\gamma и β\beta размера dd у каждого
Выходная проекцияVd+VV d + V без tying; 00 с tyingWoutW_{\text{out}} и boutb_{\text{out}}; при tying — общая с EE матрица

Один блок: (4d2+4d)+(8d2+5d)+4d=12d2+13d(4d^2 + 4d) + (8d^2 + 5d) + 4d = 12d^2 + 13d. Вся модель:

NGPT=Vd+Tmax⁡d+L (12d2+13d)+{0,с weight tyingVd+V,без негоN_{\text{GPT}} = V d + T_{\max} d + L\,(12 d^2 + 13 d) + \begin{cases} 0, & \text{с weight tying} \\ V d + V, & \text{без него} \end{cases}

где NGPTN_{\text{GPT}} — число обучаемых параметров, остальные символы — из таблицы обозначений (VV — словарь, dd — размерность модели, Tmax⁡T_{\max} — максимальный контекст, LL — число блоков).

Главный член — 12Ld212 L d^2: при большом dd линейные слои блоков доминируют, а эмбеддинги растут лишь линейно по dd.

GPT-1 (V=40 478V = 40\,478, Tmax⁡=512T_{\max} = 512, d=768d = 768, L=12L = 12, с tying):

V·d = 40 478 · 768 = 31 087 104
T_max·d = 512 · 768 = 393 216
блок: 12·768² + 13·768 = 7 077 888 + 9 984 = 7 087 872
L · блок = 12 · 7 087 872 = 85 054 464
итого 116 534 784

Это совпадает с числом параметров HF-модели openai-community/openai-gpt, записанным в backlog.md. Блоки — 73 % параметров, эмбеддинги — 27 %. Без tying добавляется Vd+V=31 127 582V d + V = 31\,127\,582, итого 147 662 366. В самой статье GPT-1 число параметров не приводится; в статье GPT-2 (табл. 2) самая маленькая модель, 117M, названа эквивалентной исходному GPT.

Учебный конфиг gpt_train.json (d=256d = 256, L=4L = 4, Tmax⁡=128T_{\max} = 128; vocab_size берётся из токенизатора, при bpe_vocab_size = 1000 возьмём V=1000V = 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размер словаря VV
embed_dim256768размерность эмбеддингов и скрытого состояния dd
num_heads412число attention-голов HH (head_size = embed_dim / num_heads; embed_dim должен делиться на num_heads, иначе ValueError)
head_size(нет в примере)64необязательный размер головы dhd_h; по умолчанию embed_dim // num_heads (тогда embed_dim обязан делиться на num_heads). Если задан, num_heads · head_size может не совпадать с embed_dim
num_layers412число блоков GptDecoder в стеке LL
max_position_embeddings128512максимальная длина последовательности Tmax⁡T_{\max} (размер таблицы позиционных эмбеддингов и causal-маски)
dropout0.10.1dropout на эмбеддингах и на выходах 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 инициализируются N(0, 0,022)\mathcal{N}(0,\ 0{,}02^2), bias — нулями, LayerNorm — весом 1 и нулевым сдвигом (функция init_normal_ в core/weight_init.py). Инициализация PyTorch по умолчанию даёт эмбеддинги N(0,1)\mathcal{N}(0, 1) и веса Linear с std ≈ 1/3⋅fan_in1/\sqrt{3 \cdot \text{fan\_in}}; с N(0, 0,022)\mathcal{N}(0,\ 0{,}02^2) логиты свежей модели близки к нулю, и начальный loss — около ln⁡V\ln V, как у равномерного распределения. Например, у свежей модели учебного конфига с V=1000V = 1000 loss на случайных токенах ≈ 6,9, а ln⁡1000≈6,91\ln 1000 \approx 6{,}91. Инициализация важна только при обучении с нуля: загрузка чекпоинта её перезаписывает.

Из-за post-LN и малых весов у свежей модели скалярные произведения Q·K почти нулевые и внимание почти равномерное — модель начинает учитывать порядок токенов по мере обучения. Общая теория инициализации — в training.md.

В GPT-1 логиты считаются умножением скрытого состояния на ту же матрицу, что хранит токенные эмбеддинги: Z=H(L)E⊤Z = H^{(L)} E^{\top}, без bias. Это weight tying (связывание весов). Оно видно в формуле (2) статьи — P(u)=softmax(hnWe⊤)P(u) = \mathrm{softmax}(h_n W_e^{\top}), где WeW_e — матрица эмбеддингов токенов, — и в оригинальном коде 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] и считает xW⊤+bx W^{\top} + b. У выходной проекции weight имеет форму [V, d] — ровно как таблица эмбеддингов, поэтому связывание — это просто один и тот же тензор в двух модулях, без транспонирования.

С tie_word_embeddings загружаются веса openai-community/openai-gpt — через convert_hf_state_dict из models/gpt/hf_weights.py:

from transformers import OpenAIGPTLMHeadModel
from 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):

  1. Снимает префикс transformer. и переименовывает верхнеуровневые ключи: tokens_embed (GPT-1) и wte (GPT-2) → _token_embeddings._embedding, positions_embed / wpe → _position_embeddings.embedding, ln_f → _norm (есть только у GPT-2).
  2. lm_head.weight пропускает — он совпадает с эмбеддингами; пропускает и буферы causal-маски старых чекпоинтов (attn.bias, attn.masked_bias).
  3. В блоках HF использует слой Conv1D, который хранит вес в форме [in, out] (и считает xW+bxW + b), а nn.Linear — [out, in], поэтому веса c_attn, c_proj, c_fc транспонируются.
  4. 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.
  5. В конце добавляет _linear.weight — ссылку на ту же матрицу эмбеддингов: load_state_dict ждёт оба ключа.

Неизвестный ключ даёт KeyError — так не получится молча загрузить чекпоинт другой архитектуры.

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)dhd_h
self._max_seq_len = config["max_position_embeddings"]предел длины для проверок и generateTmax⁡T_{\max}
self._token_embeddings = TokenEmbeddings(...)nn.Embedding(V, d)EE
self._position_embeddings = PositionalEmbeddings(...)nn.Embedding(T_max, d)PP
self._dropout = nn.Dropout(config["dropout"])dropout на сумме эмбеддинговDropout шага 1
self._decoders = nn.ModuleList([GptDecoder(...) ...])LL блоковшаг 2
self._linear = output_projection(...)Linear(d, V); при tying — без bias и с общим весомWout,boutW_{\text{out}}, b_{\text{out}}
self.apply(partial(init_normal_, std=...))инициализация N(0, 0,022)\mathcal{N}(0,\ 0{,}02^2)

GptDecoder.__init__ (core/gpt_decoder.py) создаёт _heads = MultiHeadAttention(...), _ff = FeedForward(..., activation=activation), _norm1, _norm2 = nn.LayerNorm(emb_size). Отдельный класс GptDecoder нужен именно из-за post-LN: остальные блоки декодера в репозитории — pre-LN.

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 запрещает выйти за Tmax⁡T_{\max}: у обучаемых позиций нет строки для позиции Tmax⁡T_{\max} и дальше.
  • 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) — это U(l)U^{(l)}, result = self._norm2(ffn_out + out) — это H(l)H^{(l)}.

В репозитории дообучения GPT-1 на задачах нет: GPT — только языковая модель, у неё нет классификационной головы, специальных токенов и функции потерь для задач. Раздел описывает, как это сделано в статье (разд. 3.2–3.3, рис. 1).

После предобучения модель дообучается на размеченном наборе C\mathcal{C}, где каждый пример — последовательность токенов x1,…,xmx^1, \dots, x^m и метка yy. Вход прогоняется через предобученную модель, берётся состояние последнего блока на последнем токене hlm∈Rdh_l^m \in \mathbb{R}^{d} и подаётся в новый линейный слой:

P(y∣x1,…,xm)=softmax(hlmWy)P(y \mid x^1, \dots, x^m) = \mathrm{softmax}\big(h_l^m W_y\big)

где Wy∈Rd×CW_y \in \mathbb{R}^{d \times C} — единственные новые параметры (CC — число классов), hlmh_l^m — выход последнего блока на позиции последнего токена (из-за causal-маски только он «видел» весь вход).

Функция потерь дообучения — сумма потерь задачи и вспомогательной потери языкового моделирования:

L3(C)=L2(C)+λ⋅L1(C)L_3(\mathcal{C}) = L_2(\mathcal{C}) + \lambda \cdot L_1(\mathcal{C})

где L2L_2 — log-правдоподобие меток ∑(x,y)log⁡P(y∣x1,…,xm)\sum_{(x, y)} \log P(y \mid x^1, \dots, x^m), L1L_1 — log-правдоподобие языковой модели на тех же текстах, λ=0,5\lambda = 0{,}5 в экспериментах статьи (статья записывает правдоподобия, которые максимизируются; в коде это были бы cross-entropy со знаком минус). По разд. 5 статьи вспомогательная потеря помогает на больших наборах и почти не помогает на маленьких.

Ключевая идея — входные преобразования (input transformations): задачи со структурированным входом сводятся к одной или нескольким последовательностям токенов, которые понимает языковая модель. Добавляются случайно инициализированные специальные токены: начало <s>, разделитель $ и конец (extract) <e>.

ЗадачаКак строится входКак получается ответ
Классификация (тональность, грамматичность)<s> текст <e>hlmh_l^m → линейный слой
Логический вывод (entailment): посылка и гипотеза<s> посылка $ гипотеза <e>hlmh_l^m → линейный слой (3 класса: следует, противоречит, нейтрально)
Сходство двух предложенийдве последовательности: <s> A $ B <e> и <s> B $ A <e> — у пары нет естественного порядкаобе прогоняются независимо, их hlmh_l^m складываются поэлементно → линейный слой
Вопросы с выбором ответа (multiple choice), здравый смыслдля каждого варианта aka_k: <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 6,25⋅10−56{,}25 \cdot 10^{-5}, батч 32, как правило 3 эпохи, линейный warmup на 0,2 % обучения.

Чтобы воспроизвести это на GPT из репозитория, пришлось бы добавить специальные токены в словарь (строки в EE), получить скрытые состояния последнего блока (сейчас forward возвращает только логиты) и добавить WyW_y с функцией потерь L3L_3.

Генерация одинакова для всех моделей репозитория и подробно разобрана в главе 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 torch
from 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,1attention_dropout, по умолчанию 0
ТокенизаторBPE с 40 000 слияний, предобработка ftfy и spaCyсобственный BPE (tokenization.md); для весов OpenAI нужен токенизатор HF
Регуляризациямодифицированная L2 с w=0,01w = 0{,}01не реализована в модели (относится к оптимизатору, см. training.md)
Дообучениеклассификационная голова, специальные токены, потеря L3L_3нет
Размер FFN3072 = 4d4dвсегда 4d4d, не настраивается
АктивацияGELU (в коде OpenAI — tanh-аппроксимация)"gelu_tanh" по умолчанию, можно "gelu" или "relu"
  • нормализация: post-LN → pre-LN;
  • появляется финальная нормализация перед выходной проекцией;
  • выходные проекции residual-подблоков инициализируются с уменьшенным std;
  • словарь — byte-level BPE на 50 257 токенов, контекст — 1024;
  • FFN и attention переиспользуют ту же математику (GELU, стандартный MHA), но собраны в отдельный класс Gpt2Decoder вместо параметризуемого GptDecoder.

Подробности — в gpt2.md.

  • GPT-1 ввёл схему «генеративное предобучение языковой модели + дискриминативное дообучение всей модели» и улучшил лучший результат в 9 из 12 задач.
  • Архитектура: обучаемые эмбеддинги токенов и позиций → LL post-LN блоков (MHA + GELU-FFN) → линейная проекция на словарь, связанная с эмбеддингами.
  • Post-LN нормализует выход каждого блока, поэтому финальной нормализации нет; ценой этого — менее стабильное обучение глубоких стеков.
  • Параметры: Vd+Tmax⁡d+L(12d2+13d)Vd + T_{\max} d + L(12d^2 + 13d) с tying; для GPT-1 — 116 534 784.
  • В репозитории — классы GPT и GptDecoder; веса OpenAI загружаются через convert_hf_state_dict при tie_word_embeddings=True.
  1. Почему в GPT-1 нет LayerNorm между последним блоком и выходной проекцией, а в GPT-2 он есть?
Ответ

В post-LN блок заканчивается нормализацией LN2\mathrm{LN}_2, поэтому H(L)H^{(L)} уже нормализован. В pre-LN блок заканчивается residual-сложением, выход не нормализован, и перед проекцией нужен отдельный LayerNorm.

  1. Посчитайте число параметров GPT-1 без weight tying.
Ответ

116 534 784+Vd+V=116 534 784+31 087 104+40 478=147 662 366116\,534\,784 + V d + V = 116\,534\,784 + 31\,087\,104 + 40\,478 = 147\,662\,366.

  1. Сколько параметров в одном блоке GptDecoder при d=256d = 256? Какая доля приходится на FFN?
Ответ

12⋅2562+13⋅256=786 432+3 328=789 76012 \cdot 256^2 + 13 \cdot 256 = 786\,432 + 3\,328 = 789\,760. FFN: 8⋅2562+5⋅256=525 5688 \cdot 256^2 + 5 \cdot 256 = 525\,568, то есть около 66,5 % блока. Attention: 4⋅2562+4⋅256=263 1684 \cdot 256^2 + 4 \cdot 256 = 263\,168 (33,3 %), LayerNorm — 1024.

  1. Модель с max_position_embeddings = 128 получила вход длины 130. Что произойдёт в forward? А в generate, если промпт короче, но генерация выходит за 128 токенов?
Ответ

forward выбросит ValueError из check_sequence_length: для позиций 128 и 129 нет строк в таблице PP. generate не падает: когда длина превысит 128, он подаёт последние 128 токенов без кэша (next_generation_input).

  1. Как бы вы оформили для GPT-1 задачу «перефразирование ли это» (paraphrase detection) и почему вход строится дважды?
Ответ

Это задача сходства: <s> A $ B <e> и <s> B $ A <e>, оба прохода, сумма hlmh_l^m, линейный слой на 2 класса. Из-за causal-маски модель читает текст слева направо, и порядок предложений влияет на представление; сумма по двум порядкам делает ответ симметричным.

  1. Используя пример из раздела про post-LN, проверьте: если подблок выдал f=(5,5)f = (5, 5) при x=(1,3)x = (1, 3), каким будет выход post-LN блока? Что это говорит о постоянной составляющей выхода подблока?
Ответ

x+f=(6,8)x + f = (6, 8), μ=7\mu = 7, σ2=1\sigma^2 = 1, LN=(−1,1)\mathrm{LN} = (-1, 1) — то же, что при f=(1,1)f = (1, 1). Добавка одинакового числа ко всем координатам полностью убирается вычитанием среднего.

  1. (Код.) Проверьте, что при tie_word_embeddings=True выходная проекция и эмбеддинги — один объект и что parameters() не считает его дважды.
Ответ
m = GPT({..., "tie_word_embeddings": True})
assert m._linear.weight is m._token_embeddings._embedding.weight
assert m._linear.bias is None

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