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

Языковое моделирование

  • Что такое языковая модель и почему «предсказать следующий токен» — это то же самое, что задать вероятность любого текста.
  • Как логиты превращаются в вероятности через softmax и почему softmax считают с вычитанием максимума.
  • Что такое cross-entropy (отрицательное логарифмическое правдоподобие), как она связана с методом максимального правдоподобия и KL-дивергенцией, и что такое перплексия.
  • Как устроено обучение методом teacher forcing: сдвиг меток на один токен, метка -100, causal-маска — и где это сделано в коде репозитория.
  • Общую схему decoder-only трансформера, на которой построены все шесть моделей библиотеки, и карту остальных глав пособия.

Эта глава — первая, других глав она не требует. Нужны школьная вероятность (условная вероятность, произведение вероятностей), логарифм и экспонента, умение читать код на Python. Обозначения — в notation.md, термины — в glossary.md.

Представим, что текст уже разбит на токены (tokens) — кусочки из конечного словаря (vocabulary) размера VV. Как именно режут текст, разбирается в следующей главе, tokenization.md; пока достаточно знать, что каждый токен — это целое число от 00 до V−1V-1, а текст — последовательность таких чисел x0,x1,…,xT−1x_0, x_1, \dots, x_{T-1} длины TT.

Языковая модель (language model) — это модель, которая каждой последовательности токенов сопоставляет вероятность P(x0,x1,…,xT−1)P(x_0, x_1, \dots, x_{T-1}). Хорошая языковая модель даёт осмысленному тексту («кошка сидит на ковре») большую вероятность, а бессмыслице («ковре на кошка сидит») — маленькую.

Зачем такая модель нужна:

  • оценивать тексты: выбрать из нескольких вариантов перевода или распознанной речи самый правдоподобный;
  • порождать тексты: выбирать токен за токеном, следуя вероятностям модели (подробно — в generation.md);
  • учить представления: чтобы хорошо предсказывать текст, модель вынуждена выучить грамматику, факты и логику; GPT-1 показал, что такую модель затем легко дообучить на другие задачи (Radford et al., 2018).

Задать вероятность «в лоб» — таблицей для всех возможных текстов — нельзя: при V=32 000V = 32\,000 и T=20T = 20 различных последовательностей VT≈1090V^T \approx 10^{90}, больше, чем атомов в наблюдаемой Вселенной. Нужен способ разложить эту огромную вероятность на небольшие части.

Из определения условной вероятности P(A,B)=P(A) P(B∣A)P(A, B) = P(A)\,P(B \mid A) следует цепное правило (chain rule) — разложение совместной вероятности в произведение условных:

P(x0,x1,…,xT−1)=∏t=0T−1P(xt∣x0,…,xt−1)=∏t=0T−1P(xt∣x<t)P(x_0, x_1, \dots, x_{T-1}) = \prod_{t=0}^{T-1} P(x_t \mid x_0, \dots, x_{t-1}) = \prod_{t=0}^{T-1} P(x_t \mid x_{<t})

где:

  • xt∈{0,…,V−1}x_t \in \{0, \dots, V-1\} — токен на позиции tt (позиции нумеруются с 0);
  • x<t=(x0,…,xt−1)x_{<t} = (x_0, \dots, x_{t-1}) — префикс (контекст), все токены до позиции tt; при t=0t = 0 префикс пуст, и множитель равен P(x0)P(x_0);
  • P(xt∣x<t)P(x_t \mid x_{<t}) — вероятность, что следующим после префикса будет именно токен xtx_t;
  • TT — длина последовательности.
Вывод для трёх токенов

Применим определение условной вероятности дважды. Сначала отделим последний токен, считая A=(x0,x1)A = (x_0, x_1), B=x2B = x_2:

P(x0,x1,x2)=P(x0,x1) P(x2∣x0,x1)P(x_0, x_1, x_2) = P(x_0, x_1)\, P(x_2 \mid x_0, x_1)

Затем разложим P(x0,x1)P(x_0, x_1) так же, с A=x0A = x_0, B=x1B = x_1:

P(x0,x1)=P(x0) P(x1∣x0)P(x_0, x_1) = P(x_0)\, P(x_1 \mid x_0)

Подставляя, получаем

P(x0,x1,x2)=P(x0) P(x1∣x0) P(x2∣x0,x1)P(x_0, x_1, x_2) = P(x_0)\, P(x_1 \mid x_0)\, P(x_2 \mid x_0, x_1)

Для произвольного TT то же рассуждение повторяется по индукции: P(x≤t)=P(x<t) P(xt∣x<t)P(x_{\le t}) = P(x_{<t})\,P(x_t \mid x_{<t}).

Интуиция. Цепное правило — не приближение, а точное тождество: оно верно для любого распределения. Мы ничего не потеряли, но задача поменялась. Вместо одного распределения над VTV^T текстами нужно уметь для любого префикса выдавать распределение над одним следующим токеном — это всего VV чисел.

Пример. Пусть токены — слова, и модель считает, что P(кошка)=0.01P(\text{кошка}) = 0.01, P(сидит∣кошка)=0.2P(\text{сидит} \mid \text{кошка}) = 0.2, P(на∣кошка сидит)=0.5P(\text{на} \mid \text{кошка сидит}) = 0.5. Тогда

P(кошка сидит на)=0.01⋅0.2⋅0.5=0.001P(\text{кошка сидит на}) = 0.01 \cdot 0.2 \cdot 0.5 = 0.001

Вероятности длинных текстов быстро становятся крошечными: уже при сотне множителей порядка 0.10.1 получается 10−10010^{-100}. Поэтому на практике работают с логарифмом, и произведение превращается в сумму:

log⁡P(x0,…,xT−1)=∑t=0T−1log⁡P(xt∣x<t)\log P(x_0, \dots, x_{T-1}) = \sum_{t=0}^{T-1} \log P(x_t \mid x_{<t})

В примере: ln⁡0.001=ln⁡0.01+ln⁡0.2+ln⁡0.5≈−4.605−1.609−0.693=−6.908\ln 0.001 = \ln 0.01 + \ln 0.2 + \ln 0.5 \approx -4.605 - 1.609 - 0.693 = -6.908.

Модель, которая порождает последовательность слева направо, каждый раз предсказывая следующий элемент по предыдущим, называется авторегрессивной (autoregressive). Языковая модель с параметрами θ\theta приближает каждый множитель цепного правила:

Pθ(x0,…,xT−1)=∏t=0T−1pθ(xt∣x<t)P_\theta(x_0, \dots, x_{T-1}) = \prod_{t=0}^{T-1} p_\theta(x_t \mid x_{<t})

где:

  • θ\theta — все обучаемые параметры модели (матрицы эмбеддингов, attention, FFN и т. д.);
  • pθ(⋅∣x<t)p_\theta(\cdot \mid x_{<t}) — распределение над словарём, которое модель выдаёт, прочитав префикс x<tx_{<t}: вектор из VV неотрицательных чисел с суммой 1.

Все модели этой библиотеки — GPT-1, GPT-2, LLaMA, Mistral, Mixtral, Gemma — именно такие: causal language models, «причинные» языковые модели, которые видят только прошлое.

Первый токен в библиотеке отдельно не моделируется: модель получает на вход готовую последовательность и предсказывает токены с позиции 1. Если нужно моделировать и x0x_0, в начало добавляют специальный токен <bos> (begin of sequence), и тогда P(x0)=pθ(x0∣⟨bos⟩)P(x_0) = p_\theta(x_0 \mid \langle \text{bos} \rangle). Специальные токены описаны в tokenization.md.

Альтернативы. Авторегрессия — не единственный способ. BERT (Devlin et al., 2018) обучается как маскированная языковая модель: восстанавливает закрытые токены по контексту с обеих сторон. Такая модель хорошо понимает текст, но не задаёт вероятность последовательности через цепное правило и не умеет естественно порождать текст слева направо. В этом пособии речь только об авторегрессивных моделях.

Следующий токен как классификация на V классов

Заголовок раздела «Следующий токен как классификация на V классов»

Предсказание следующего токена — это задача классификации: по контексту выбрать один из VV классов. Нейросеть не выдаёт вероятности напрямую. Она выдаёт VV вещественных чисел — логитов (logits), по одному на токен словаря:

zt=htWout+b\mathbf{z}_t = \mathbf{h}_t W_{\text{out}} + \mathbf{b}

где:

  • ht∈R1×d\mathbf{h}_t \in \mathbb{R}^{1 \times d} — скрытое состояние на позиции tt после всех блоков трансформера (строка, dd — размерность модели, embed_dim);
  • Wout∈Rd×VW_{\text{out}} \in \mathbb{R}^{d \times V} — матрица выходной проекции («голова» языковой модели, LM head);
  • b∈R1×V\mathbf{b} \in \mathbb{R}^{1 \times V} — смещение (bias); в части моделей его нет;
  • zt∈R1×V\mathbf{z}_t \in \mathbb{R}^{1 \times V} — логиты: чем больше zt,iz_{t,i}, тем «увереннее» модель, что следующий токен — ii.

В коде это последний nn.Linear(embed_dim, vocab_size) модели, например self._linear в Llama (models/llama/llama.py): logits = self._linear(out). Модель возвращает логиты для всех позиций сразу — тензор формы [batch, seq_len, vocab_size], то есть B×T×VB \times T \times V. Как устроена эта проекция и когда она делит веса с эмбеддингами (weight tying), разбирается в embeddings.md.

Логиты могут быть любыми числами, в том числе отрицательными, и в сумме не дают 1. Превращает их в распределение функция softmax:

softmax⁡(z)i=ezi∑j=0V−1ezj,i=0,…,V−1\operatorname{softmax}(\mathbf{z})_i = \frac{e^{z_i}}{\sum_{j=0}^{V-1} e^{z_j}}, \qquad i = 0, \dots, V-1

где:

  • z∈RV\mathbf{z} \in \mathbb{R}^{V} — вектор логитов для одной позиции;
  • ziz_i — логит токена ii;
  • ezie^{z_i} — экспонента: делает каждое слагаемое положительным;
  • знаменатель — нормирующая сумма (статистическая сумма, partition function), одна на весь вектор.

Итого pθ(xt=i∣x<t)=softmax⁡(zt)ip_\theta(x_t = i \mid x_{<t}) = \operatorname{softmax}(\mathbf{z}_t)_i.

Свойства softmax.

  1. Все выходы положительны и в сумме дают 1 — это корректное распределение. Ни один токен не получает вероятность ровно 0.
  2. Монотонность: больший логит даёт большую вероятность; порядок токенов сохраняется.
  3. Инвариантность к сдвигу: softmax⁡(z+c)=softmax⁡(z)\operatorname{softmax}(\mathbf{z} + c) = \operatorname{softmax}(\mathbf{z}) для любого числа cc. Важны только разности логитов: pi/pj=ezi−zjp_i / p_j = e^{z_i - z_j}.
  4. Чувствительность к масштабу: если умножить логиты на число больше 1, распределение становится «острее» (ближе к выбору одного максимума), если на число меньше 1 — «площе». На этом основана температура при генерации (generation.md).
  5. Название: softmax — «мягкий» (дифференцируемый) вариант argmax. При z⋅λ\mathbf{z} \cdot \lambda, λ→∞\lambda \to \infty вся вероятность уходит на максимальный логит (если максимум единственный; при нескольких равных максимумах она делится между ними поровну).
Доказательство инвариантности к сдвигу

Вынесем общий множитель ece^{c} из числителя и знаменателя:

ezi+c∑jezj+c=ec eziec∑jezj=ezi∑jezj\frac{e^{z_i + c}}{\sum_j e^{z_j + c}} = \frac{e^{c}\, e^{z_i}}{e^{c} \sum_j e^{z_j}} = \frac{e^{z_i}}{\sum_j e^{z_j}}

Пример. Словарь из трёх токенов, логиты z=(2,1,0)\mathbf{z} = (2, 1, 0):

e^z = (7.389, 2.718, 1.000), сумма = 11.107
softmax = (0.665, 0.245, 0.090)

Разница логитов на 1 означает отношение вероятностей e≈2.718e \approx 2.718: 0.665/0.245≈2.7180.665 / 0.245 \approx 2.718.

Численная устойчивость: вычитание максимума

Заголовок раздела «Численная устойчивость: вычитание максимума»

В float32 экспонента переполняется уже при z>88.7z > 88.7 (e88.7≈3.4⋅1038e^{88.7} \approx 3.4 \cdot 10^{38} — максимум float32). Логиты такого размера встречаются, и наивная формула даёт inf / inf = nan. Решение следует из свойства 3: вычтем из всех логитов максимум m=max⁡jzjm = \max_j z_j:

softmax⁡(z)i=ezi−m∑jezj−m\operatorname{softmax}(\mathbf{z})_i = \frac{e^{z_i - m}}{\sum_j e^{z_j - m}}

Теперь наибольший показатель равен 00, все экспоненты лежат в (0,1](0, 1], а знаменатель — не меньше 1 (слагаемое для максимума равно e0=1e^0 = 1). Переполнения нет, деления на ноль тоже.

import torch
def stable_softmax(z):
z = z - z.max(dim=-1, keepdim=True).values # сдвиг не меняет результат
e = z.exp()
return e / e.sum(dim=-1, keepdim=True)
z = torch.tensor([1000.0, 999.0, 998.0])
print(z.exp() / z.exp().sum()) # tensor([nan, nan, nan])
print(stable_softmax(z)) # tensor([0.6652, 0.2447, 0.0900]) — как для (2, 1, 0)
print(torch.softmax(z, dim=-1)) # то же: torch.softmax вычитает максимум сам

Для логарифма вероятности используют тот же приём — функцию log-sum-exp:

log⁡softmax⁡(z)i=zi−m−log⁡∑jezj−m\log \operatorname{softmax}(\mathbf{z})_i = z_i - m - \log \sum_j e^{z_j - m}

Так torch.log_softmax и F.cross_entropy считают логарифм вероятности, не вычисляя саму вероятность: даже если pi≈10−50p_i \approx 10^{-50} (в float32 это 0, и log⁡0=−∞\log 0 = -\infty), логарифм получается конечным.

Пусть есть обучающий корпус — последовательности токенов. Естественное требование к параметрам: модель должна давать наблюдаемым текстам как можно большую вероятность. Это метод максимального правдоподобия (maximum likelihood estimation, MLE):

θ∗=arg⁡max⁡θ∏тексты∏tpθ(xt∣x<t)\theta^{*} = \arg\max_{\theta} \prod_{\text{тексты}} \prod_{t} p_\theta(x_t \mid x_{<t})

где θ∗\theta^{*} — искомые параметры, внешнее произведение берётся по всем текстам корпуса (они считаются независимыми), внутреннее — по позициям tt внутри текста; xtx_t и x<tx_{<t} — токены этого текста.

Логарифм — монотонная функция, поэтому максимум произведения достигается там же, где максимум суммы логарифмов. Поменяем знак, чтобы получить задачу минимизации, и усредним по числу предсказаний NN. Получаем отрицательное логарифмическое правдоподобие (negative log-likelihood, NLL):

L(θ)=−1N∑n=1Nlog⁡pθ(yn∣контекстn)\mathcal{L}(\theta) = -\frac{1}{N} \sum_{n=1}^{N} \log p_\theta\big(y_n \mid \text{контекст}_n\big)

где:

  • NN — число позиций, для которых считается loss (в батче B×(T−1)B \times (T-1) минус игнорируемые, см. ниже);
  • yny_n — правильный следующий токен в nn-м предсказании (метка, label);
  • pθ(yn∣⋅)=softmax⁡(zn)ynp_\theta(y_n \mid \cdot) = \operatorname{softmax}(\mathbf{z}_n)_{y_n} — вероятность, которую модель дала правильному токену;
  • логарифм натуральный, поэтому loss измеряется в натах (nats); чтобы перевести в биты, делят на ln⁡2≈0.693\ln 2 \approx 0.693.

Каждое слагаемое −log⁡p-\log p — «штраф за удивление»: если модель дала правильному токену вероятность 1, штраф 0; если 0.5 — штраф ln⁡2≈0.693\ln 2 \approx 0.693; если 0.001 — штраф 6.96.9. Уверенная ошибка стоит очень дорого: при p→0p \to 0 штраф уходит в бесконечность.

Для одной позиции запишем правильный ответ как one-hot вектор y∈{0,1}V\mathbf{y} \in \{0, 1\}^V: единица на месте правильного токена, остальные нули. Тогда

−log⁡py=−∑i=0V−1yilog⁡pi=H(y,p)-\log p_{y} = -\sum_{i=0}^{V-1} y_i \log p_i = H(\mathbf{y}, \mathbf{p})

где:

  • p=softmax⁡(z)∈RV\mathbf{p} = \operatorname{softmax}(\mathbf{z}) \in \mathbb{R}^V — распределение модели;
  • H(q,p)=−∑iqilog⁡piH(\mathbf{q}, \mathbf{p}) = -\sum_i q_i \log p_i — перекрёстная энтропия (cross-entropy) распределения p\mathbf{p} относительно q\mathbf{q} (буква HH здесь и в следующем разделе — энтропия, а не число голов attention из notation.md).

В сумме выживает только слагаемое с yi=1y_i = 1, поэтому cross-entropy с one-hot целью и NLL — одно и то же. В PyTorch это функция F.cross_entropy(logits, targets): она принимает логиты (не вероятности), сама применяет log-softmax устойчивым способом и берёт элемент правильного класса.

Пример. Логиты (2,1,0)(2, 1, 0), правильный токен — 0. Вероятность 0.6650.665, loss −ln⁡0.665=0.408-\ln 0.665 = 0.408. Если бы правильным был токен 2: −ln⁡0.090=2.408-\ln 0.090 = 2.408. Разница ровно 2 — это разница логитов, как и должно быть по формуле log-softmax.

Пусть у языка есть «истинное» распределение следующего токена qq (для данного контекста), а модель выдаёт pθp_\theta. Ожидаемый loss по данным — это cross-entropy H(q,pθ)H(q, p_\theta), и она раскладывается так:

H(q,pθ)=H(q)+DKL(q ∥ pθ)H(q, p_\theta) = H(q) + D_{\mathrm{KL}}(q \,\|\, p_\theta)

где:

  • H(q)=−∑iqilog⁡qiH(q) = -\sum_i q_i \log q_i — энтропия истинного распределения: неустранимая неопределённость самого языка (после «Я пошёл в» возможны десятки продолжений);
  • DKL(q ∥ pθ)=∑iqilog⁡qipθ,iD_{\mathrm{KL}}(q \,\|\, p_\theta) = \sum_i q_i \log \dfrac{q_i}{p_{\theta,i}} — дивергенция Кульбака — Лейблера: насколько модель отличается от истины; она неотрицательна и равна нулю только при pθ=qp_\theta = q.
Вывод

Прибавим и вычтем ∑iqilog⁡qi\sum_i q_i \log q_i:

H(q,p)=−∑iqilog⁡pi=−∑iqilog⁡qi+∑iqilog⁡qi−∑iqilog⁡pi=H(q)+∑iqilog⁡qipi=H(q)+DKL(q ∥ p)\begin{aligned} H(q, p) &= -\sum_i q_i \log p_i \\ &= -\sum_i q_i \log q_i + \sum_i q_i \log q_i - \sum_i q_i \log p_i \\ &= H(q) + \sum_i q_i \log \frac{q_i}{p_i} \\ &= H(q) + D_{\mathrm{KL}}(q \,\|\, p) \end{aligned}

Неотрицательность KL (неравенство Гиббса) следует из неравенства log⁡u≤u−1\log u \le u - 1 при u=pi/qiu = p_i / q_i:

−DKL(q ∥ p)=∑iqilog⁡piqi≤∑iqi(piqi−1)=∑ipi−∑iqi≤1−1=0-D_{\mathrm{KL}}(q \,\|\, p) = \sum_i q_i \log \frac{p_i}{q_i} \le \sum_i q_i \left(\frac{p_i}{q_i} - 1\right) = \sum_i p_i - \sum_i q_i \le 1 - 1 = 0

(суммы по тем ii, где qi>0q_i > 0; равенство — только при pi=qip_i = q_i для всех ii).

Интуиция. H(q)H(q) от модели не зависит, поэтому минимизировать cross-entropy — то же самое, что минимизировать KL-дивергенцию до истинного распределения. Loss никогда не опустится ниже энтропии языка. Если на валидации loss перестал падать, это может значить, что модель упёрлась либо в свою ёмкость, либо в H(q)H(q).

Для одной позиции производная loss по логитам получается очень простой:

∂L∂z=p−y\frac{\partial \mathcal{L}}{\partial \mathbf{z}} = \mathbf{p} - \mathbf{y}

где p=softmax⁡(z)\mathbf{p} = \operatorname{softmax}(\mathbf{z}), y\mathbf{y} — one-hot правильного токена. Логит правильного токена тянется вверх с силой 1−py1 - p_y, логиты остальных — вниз пропорционально их вероятностям. В примере с логитами (2,1,0)(2, 1, 0) и правильным токеном 0 градиент равен (0.665−1, 0.245, 0.090)=(−0.335, 0.245, 0.090)(0.665 - 1,\ 0.245,\ 0.090) = (-0.335,\ 0.245,\ 0.090). Вывод этой формулы и то, как градиент идёт дальше по сети, — в training.md.

Loss в натах трудно интерпретировать. Удобнее перплексия (perplexity, PPL) — экспонента от среднего NLL:

PPL=exp⁡(L)=exp⁡ ⁣(−1N∑n=1Nlog⁡pθ(yn∣⋅))=(∏n=1Npθ(yn∣⋅))−1/N\mathrm{PPL} = \exp(\mathcal{L}) = \exp\!\left(-\frac{1}{N}\sum_{n=1}^{N} \log p_\theta(y_n \mid \cdot)\right) = \left(\prod_{n=1}^{N} p_\theta(y_n \mid \cdot)\right)^{-1/N}

где L\mathcal{L} — средний loss в натах на токен, NN — число предсказаний. Последнее выражение — величина, обратная среднему геометрическому вероятностей правильных токенов.

Интерпретация. Перплексия — «эффективное число вариантов», между которыми модель в среднем колеблется на каждом шаге. PPL = 10 означает, что модель удивлена так же, как если бы на каждом шаге равновероятно выбирала из 10 токенов. Меньше — лучше; минимум 1 (модель всегда уверена и права).

Пример 1: равномерное распределение. Необученная модель, которая всем VV токенам даёт вероятность 1/V1/V:

L=−log⁡1V=ln⁡V,PPL=eln⁡V=V\mathcal{L} = -\log \frac{1}{V} = \ln V, \qquad \mathrm{PPL} = e^{\ln V} = V

Отсюда полезная проверка: начальный loss свежей модели должен быть около ln⁡V\ln V. Логиты свежей модели с малыми весами близки к нулю, а по свойству 3 softmax одинаковых логитов — равномерное распределение. Для словаря GPT-2 (V=50 257V = 50\,257) это ln⁡50 257≈10.82\ln 50\,257 \approx 10.82; для учебного токенизатора из experiments/llm_only на встроенном корпусе (V=426V = 426) — ln⁡426≈6.05\ln 426 \approx 6.05. Свежие GPT, GPT2 и Llama с таким словарём дают на старте 6.04–6.29. Если начальный loss намного больше ln⁡V\ln V, модель на старте уверенно ошибается — обычно из-за слишком крупной инициализации (см. gpt.md).

Пример 2. Модель дала правильным токенам вероятности 0.5, 0.25, 0.125, 0.50.5,\ 0.25,\ 0.125,\ 0.5:

-ln p = 0.693, 1.386, 2.079, 0.693 среднее L = 1.213 ната
PPL = e^1.213 = 3.36 (= 2^1.75: вероятности — степени двойки)

Тонкости. Перплексия зависит от токенизатора: у модели с более крупными токенами меньше предсказаний на тот же текст, и сравнивать PPL моделей с разными словарями напрямую нельзя. Для такого сравнения loss нормируют на символ или байт текста (bits per character, bits per byte). Кроме того, PPL — среднее по позициям, и оно зависит от того, какие позиции попали в усреднение (см. про паддинг ниже).

При генерации модель на каждом шаге получает свой же предыдущий выход. При обучении так не делают: модели всегда подают истинный префикс из корпуса и спрашивают следующий токен. Этот приём называется teacher forcing («форсирование учителем»; термин восходит к Williams & Zipser, 1989).

Плюсы: предсказания всех позиций независимы при известном тексте, поэтому их можно считать одновременно, за один прямой проход, — трансформер как раз так и устроен. Минус — exposure bias: при обучении модель не видит собственных ошибок, а при генерации ошибки накапливаются (Bengio et al., 2015). Для больших моделей этот эффект на практике обычно терпим, и teacher forcing остаётся стандартом.

Модель читает последовательность x0,…,xT−1x_0, \dots, x_{T-1} и на каждой позиции tt выдаёт логиты zt\mathbf{z}_t — прогноз токена xt+1x_{t+1}. Значит, вход и цель — одна и та же последовательность со сдвигом на один:

позиция t 0 1 2 3 4 5
вход x_t Мир ␣программирования ␣прекрасен ␣и ␣удивителен .
логиты z_t → цель ␣програм… ␣прекрасен ␣и ␣удив… . (нет цели)

(токены — реальный результат BPETokenizer из experiments/llm_only для первой строки учебного корпуса; ␣ — пробел, который токенизатор прикрепляет к началу слова.) Из TT позиций получается T−1T - 1 обучающих примеров: логиты последней позиции не с чем сравнить.

В репозитории датасеты возвращают labels, совпадающие с input_ids (labels = input_ids.clone() в TextDataset, StreamingTextDataset, TextWithSpecialTokensDataset — datasets/), а сдвиг делает Trainer.compute_lm_loss (training/trainer.py):

shift_logits = logits[..., :-1, :].contiguous() # [B, T-1, V]: прогнозы позиций 0 … T-2
shift_labels = labels[..., 1:].contiguous() # [B, T-1]: токены позиций 1 … T-1
loss = F.cross_entropy(
shift_logits.view(-1, shift_logits.size(-1)), # [B·(T-1), V]
shift_labels.view(-1), # [B·(T-1)]
ignore_index=-100,
)

Это та же договорённость, что в HuggingFace (labels равны входу, сдвиг внутри модели или loss). Формула, которую реализует этот код:

L=−1∣M∣∑(b,t)∈Mlog⁡softmax⁡(zb,t)xb,t+1,M={(b,t):0≤t≤T−2, labelb,t+1≠−100}\mathcal{L} = -\frac{1}{|\mathcal{M}|} \sum_{(b, t) \in \mathcal{M}} \log \operatorname{softmax}(\mathbf{z}_{b,t})_{x_{b,t+1}}, \qquad \mathcal{M} = \{(b, t) : 0 \le t \le T-2,\ \text{label}_{b,t+1} \ne -100\}

где:

  • bb — номер последовательности в батче (0≤b<B0 \le b < B), tt — позиция;
  • zb,t∈RV\mathbf{z}_{b,t} \in \mathbb{R}^V — логиты позиции tt последовательности bb (элемент тензора logits формы [B, T, V]);
  • xb,t+1x_{b,t+1} — правильный следующий токен (элемент labels[b, t+1]);
  • M\mathcal{M} — множество учитываемых позиций, ∣M∣|\mathcal{M}| — их число: усреднение идёт только по ним (reduction="mean" по умолчанию).

Проверить, что это работает, можно на свежей модели: loss должен быть около ln⁡V\ln V.

import torch
import torch.nn.functional as F
from llm.models.gpt import GPT
V = 426
model = GPT({"vocab_size": V, "embed_dim": 256, "num_heads": 4, "num_layers": 4,
"max_position_embeddings": 128, "dropout": 0.0})
input_ids = torch.randint(0, V, (2, 16)) # [B, T]
labels = input_ids.clone() # как в TextDataset
logits, _ = model(input_ids) # [B, T, V]; все модели возвращают (logits, cache)
loss = F.cross_entropy(logits[:, :-1].reshape(-1, V), labels[:, 1:].reshape(-1), ignore_index=-100)
print(loss.item(), torch.log(torch.tensor(float(V))).item()) # около 6.1–6.3 и 6.05
print(loss.exp().item()) # перплексия порядка V (сотни)

Не все позиции нужно учить. Если короткую последовательность дополнили pad-токенами до длины батча, предсказывать «после паддинга снова паддинг» бессмысленно — это только размывает loss. Для таких позиций в labels ставят -100: это ignore_index в F.cross_entropy (и его значение по умолчанию). Позиции с меткой -100 не входят ни в сумму, ни в знаменатель среднего и не дают градиента. Так же -100 используют, чтобы не учить модель на промпте при дообучении на инструкциях, — loss считают только по ответу.

В репозитории метки -100 на паддинге ставят датасеты llm/datasets (функция lm_example: input_ids дополняются pad_token_id, labels — значением -100) и коллатор hf-proxy (pad в HFTokenizerAdapter). Подробнее — в разделе «Типичные ошибки и тонкости» ниже и в training.md.

При teacher forcing все TT позиций обрабатываются одним проходом, и модель видит весь вход целиком — в том числе xt+1x_{t+1}, который она должна предсказать на позиции tt. Без ограничений задача вырождается: достаточно скопировать следующий входной токен, loss быстро падает почти до нуля, но при генерации, где будущего нет, модель бесполезна.

Поэтому внимание на позиции tt разрешено только к позициям j≤tj \le t. Это causal-маска (причинная маска): перед softmax в attention к оценкам прибавляется матрица

Mij={0,j≤i−∞,j>iM_{ij} = \begin{cases} 0, & j \le i \\ -\infty, & j > i \end{cases}

где ii — позиция запроса (кто смотрит), jj — позиция ключа (на кого смотрят). После softmax e−∞=0e^{-\infty} = 0, и веса «будущих» позиций обнуляются. Для T=4T = 4 (1 — можно смотреть, 0 — нельзя):

j=0 j=1 j=2 j=3
i=0 1 0 0 0
i=1 1 1 0 0
i=2 1 1 1 0
i=3 1 1 1 1

С маской выход на позиции tt зависит только от x0,…,xtx_0, \dots, x_t — ровно то, что требует pθ(xt+1∣x≤t)p_\theta(x_{t+1} \mid x_{\le t}). Один проход по последовательности длины TT эквивалентен TT отдельным проходам по префиксам, но гораздо дешевле. В коде маска — буфер _tril_mask = torch.tril(...) в модулях attention и masked_fill(~mask, float("-inf")) перед softmax (MultiHeadAttention в core/multi_head_attention.py). Остальные виды масок — в masks.md, сам attention — в attention.md.

Все шесть моделей библиотеки — decoder-only трансформеры: стопка одинаковых блоков с causal-вниманием, без энкодера. Схема общая, модели различаются начинкой блоков.

%%{init: {"flowchart": {"rankSpacing": 28, "nodeSpacing": 28}}}%%
flowchart TB
    Ids(["token ids · [B, T]"]):::io --> Emb["Token Embedding<br/>[B, T, d]"]:::blue
    Emb --> Pos["+ позиционная информация<br/>(GPT: обучаемые; LLaMA и др.: RoPE внутри attention)"]:::purple
    subgraph Dec["Блок декодера × L"]
        direction TB
        X(["h"]):::io --> N1["Norm"]:::gray
        N1 --> Attn["Causal Self-Attention"]:::blue
        Attn --> A1(("+")):::add
        X -. residual .-> A1
        A1 --> N2["Norm"]:::gray
        N2 --> FFN["Feed-Forward (FFN / MoE)"]:::purple
        FFN --> A2(("+")):::add
        A1 -. residual .-> A2
    end
    Pos --> Dec
    Dec --> FN["Финальная Norm"]:::gray
    FN --> Lin["Linear d → V"]:::gray
    Lin --> Out(["logits · [B, T, V]"]):::io
    Out -. "обучение: cross-entropy со сдвигом" .-> Loss(["loss"]):::io
    Out -. "генерация: 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 purple fill:#e1d5e7,stroke:#9673a6,color:#1a1a1a;
    classDef gray fill:#f5f5f5,stroke:#666666,color:#1a1a1a;

Здесь показан pre-LN блок (нормализация перед подслоем), как в GPT-2, LLaMA, Mistral, Mixtral и Gemma. GPT-1 использует post-LN (нормализация после сложения) и не имеет финальной нормализации — см. gpt.md и normalization.md.

Пошагово, с формами тензоров:

  1. Токены input_ids формы B×TB \times T — целые числа из словаря (tokenization.md).
  2. Эмбеддинги: каждый индекс заменяется строкой обучаемой матрицы E∈RV×dE \in \mathbb{R}^{V \times d} (здесь EE — матрица эмбеддингов, а не число экспертов MoE); получаем B×T×dB \times T \times d (embeddings.md).
  3. Позиции: attention сам по себе не знает порядка токенов, его нужно сообщить — сложением с позиционными эмбеддингами (GPT, GPT-2) или поворотом Q и K (RoPE: LLaMA, Mistral, Mixtral, Gemma) (positional-encoding.md).
  4. LL блоков декодера. В каждом два подслоя с residual-связями:
    • causal self-attention смешивает информацию между позициями (только из прошлого) (attention.md, masks.md);
    • FFN обрабатывает каждую позицию отдельно — нелинейное преобразование «внутри токена» (feed-forward.md); в Mixtral вместо него — смесь экспертов (mixture-of-experts.md).
  5. Финальная нормализация (normalization.md).
  6. Проекция на словарь d→Vd \to V — логиты для каждой позиции.
  7. Дальше — либо loss (эта глава и training.md), либо выбор следующего токена (generation.md).

Pre-LN блок записывается так:

X′=X+Attn⁡(Norm⁡(X))X′′=X′+FFN⁡(Norm⁡(X′))\begin{aligned} X' &= X + \operatorname{Attn}\big(\operatorname{Norm}(X)\big) \\ X'' &= X' + \operatorname{FFN}\big(\operatorname{Norm}(X')\big) \end{aligned}

где:

  • X∈RT×dX \in \mathbb{R}^{T \times d} — вход блока (матрица состояний всех позиций одной последовательности, строка tt — вектор ht\mathbf{h}_t; для батча добавляется измерение BB);
  • Norm⁡\operatorname{Norm} — LayerNorm или RMSNorm, применяется к каждой строке отдельно;
  • Attn⁡,FFN⁡\operatorname{Attn}, \operatorname{FFN} — подслои; их выходы той же формы T×dT \times d;
  • X′X' — состояние после подслоя attention, X′′X'' — выход блока, вход следующего.

Обозначим через X(l)X^{(l)} выход блока ll. Развернув рекурсию по всем LL блокам, получаем, что финальное состояние — это сумма эмбеддинга и вкладов всех подслоёв:

X(L)=X(0)+∑l=1L(Attn⁡l(Norm⁡(X(l−1)))+FFN⁡l(Norm⁡(X′(l))))X^{(L)} = X^{(0)} + \sum_{l=1}^{L} \Big(\operatorname{Attn}_l\big(\operatorname{Norm}(X^{(l-1)})\big) + \operatorname{FFN}_l\big(\operatorname{Norm}(X'^{(l)})\big)\Big)

где X(0)X^{(0)} — эмбеддинги (с позициями), X(L)X^{(L)} — состояние после последнего блока, X′(l)X'^{(l)} — состояние внутри блока ll после подслоя attention, Attn⁡l\operatorname{Attn}_l и FFN⁡l\operatorname{FFN}_l — подслои блока ll.

Интуиция. Удобно представлять строку ht\mathbf{h}_t матрицы XX как общую шину (residual stream) шириной dd на позиции tt: каждый подслой читает с шины (через нормализацию), что-то вычисляет и дописывает результат обратно сложением, ничего не стирая (Elhage et al., 2021). Attention переносит информацию по шине между позициями, FFN перерабатывает её внутри позиции. Голова LM в конце читает с шины прогноз следующего токена.

Residual-связи (He et al., 2015) важны и для обучения: производная X+f(X)X + f(X) по XX содержит единичное слагаемое (якобиан равен I+∂f/∂XI + \partial f / \partial X), и градиент доходит до нижних слоёв напрямую, не затухая при перемножении десятков матриц. Если убрать residual-связи, глубокий трансформер почти не обучается.

ГодПодходИдеяОграничение
1948n-граммы (Shannon, 1948)P(xt∣x<t)≈P(xt∣xt−n+1,…,xt−1)P(x_t \mid x_{<t}) \approx P(x_t \mid x_{t-n+1}, \dots, x_{t-1}): учитываются только n−1n-1 предыдущих токенов, вероятности — частоты в корпусеконтекст в несколько слов; число n-грамм растёт как VnV^n, большинство не встречается в корпусе — нужно сглаживание (Kneser & Ney, 1995)
2003нейросетевая LM (Bengio et al., 2003)контекст фиксированной длины → эмбеддинги слов → MLP → softmax по словарюконтекст по-прежнему фиксированный
2010RNN LM (Mikolov et al., 2010); LSTM (Hochreiter & Schmidhuber, 1997)скрытое состояние переносит информацию через всю последовательность; LSTM с гейтами борется с затуханием градиентаобработка строго последовательная — плохо параллелится; дальние зависимости всё равно даются тяжело
2014attention в переводе (Bahdanau et al., 2014)декодер на каждом шаге взвешенно смотрит на все состояния энкодеравсё ещё поверх RNN
2017трансформер (Vaswani et al., 2017)только attention и FFN, без рекуррентности; обучение параллельно по всем позициямквадратичная по TT стоимость attention
2018decoder-only LM: Liu et al., 2018, GPT-1 (Radford et al., 2018)от трансформера остаётся только декодер с causal-маской; предобучение на большом корпусе + дообучение на задачах—

Трансформер Vaswani et al. был encoder-decoder моделью для перевода. Liu et al. (2018) показали, что для порождения текста достаточно одного декодера, а GPT-1 применил такую модель как универсальную предобученную основу. Дальнейшая линия — GPT-2 (масштаб и pre-LN), LLaMA (RoPE, RMSNorm, SwiGLU), Mistral (GQA, скользящее окно), Mixtral (MoE), Gemma (MQA, GeGLU) — это изменения внутри той же схемы decoder-only.

МодельГодКлассAttentionПозицииНормаFFNСловарь оригинала
GPT-12018llm.models.gpt.GPTMHAобучаемыеLayerNorm, post-LNGELUBPE, 40 000 слияний, V=40 478V = 40\,478
GPT-22019llm.models.gpt.GPT2MHAобучаемыеLayerNorm, pre-LN + финальнаяGELUbyte-level BPE, V=50 257V = 50\,257
LLaMA2023llm.models.llama.LlamaMHARoPERMSNormSwiGLUSentencePiece BPE, V=32 000V = 32\,000
Mistral2023llm.models.mistral.MistralGQA + скользящее окноRoPERMSNormSwiGLUSentencePiece, V=32 000V = 32\,000
Mixtral2023llm.models.mixtral.MixtralGQA (окно — опционально)RoPERMSNormMoE из SwiGLUSentencePiece, V=32 000V = 32\,000
Gemma2024llm.models.gemma.GemmaMQA (или GQA/MHA)RoPERMSNormGeGLUSentencePiece, V=256 000V = 256\,000

Все модели наследуют BaseModel (core/base_model.py): forward принимает x и необязательные use_cache=False, cache=None, attention_mask=None и возвращает кортеж (logits, cache), а метод generate общий. (У GPT порядок позиционных аргументов другой — forward(x, attention_mask, use_cache, cache), поэтому их надёжнее передавать по имени.) В экспериментах experiments/llm_only все модели обучаются с одним и тем же учебным BPE-токенизатором (BPETokenizer), а vocab_size модели берётся из токенизатора — словари оригинальных моделей используются только при загрузке весов HuggingFace.

Часть I — механизмы, из которых собраны модели:

ГлаваО чём
Языковое моделирование (эта глава)задача, цепное правило, cross-entropy, перплексия, общая схема
Токенизациякак текст превращается в индексы; BPE; llm/tokenizers
Эмбеддингииндексы → векторы; weight tying; выходная проекция и логиты
Позиционное кодированиеобучаемые, синусоидальные, RoPE
Attentionscaled dot-product, multi-head, GQA/MQA, скользящее окно, KV-кэш
Маскиcausal, скользящее окно, паддинг
НормализацияLayerNorm, RMSNorm, pre-LN и post-LN
Feed-forwardFFN, GELU, SiLU, SwiGLU, GeGLU
Mixture-of-Expertsроутинг top-k, load-balancing loss
Обучениеградиент loss, AdamW, расписание lr, clipping, инициализация, Trainer
Генерацияgreedy, температура, top-k, top-p, KV-кэш

Часть II — сами архитектуры по порядку появления: GPT-1 → GPT-2 → LLaMA → Mistral → Mixtral, и параллельная ветка Gemma. Если нужно быстро понять одну модель, можно начать с её главы: она ссылается на нужные разделы части I.

  • Softmax перед F.cross_entropy. Функция ждёт логиты и сама применяет log-softmax. Если подать вероятности, softmax применится дважды: loss будет считаться неверно, а обучение резко замедлится.
  • Забытый сдвиг. Если сравнивать logits[:, t] с labels[:, t], модель учится копировать текущий вход, а не предсказывать следующий. Loss быстро падает, генерация бессмысленна. В репозитории сдвиг делает Trainer.compute_lm_loss; при собственном цикле обучения его нужно сделать самому.
  • Двойной сдвиг. Обратная ошибка: сдвинуть метки в датасете и использовать Trainer, который сдвигает ещё раз. Тогда модель учится предсказывать токен через один.
  • Паддинг в loss. Если дополнить labels значением pad_token_id, а не -100, pad-позиции входят в loss. В experiments/llm_only каждая строка корпуса — отдельный пример, дополненный до max_position_embeddings = 128 токенов, а строки короткие (первая — 6 токенов), и большая часть позиций в loss оказалась бы предсказанием паддинга после паддинга. Модель быстро учит это тривиальное правило, и средний loss получается заниженным относительно качества на настоящем тексте. Корректный вариант — -100 на pad-позициях (так делают датасеты llm/datasets и коллатор hf-proxy) или склейка текстов в непрерывный поток, нарезанный на куски длины TT без паддинга (так готовят данные для предобучения).
  • Первый токен не моделируется. Loss считается по T−1T - 1 позициям; x0x_0 не предсказывается, если в начало не добавлен <bos>.
  • Перплексия с разными токенизаторами несравнима — см. раздел «Перплексия».
  • Натуральный логарифм. F.cross_entropy возвращает наты; перплексия — exp(loss), а не 2 ** loss.
  • Языковая модель задаёт вероятность последовательности; по цепному правилу она раскладывается в произведение вероятностей следующего токена.
  • Предсказание следующего токена — классификация на VV классов: логиты →\to softmax. Softmax инвариантен к сдвигу, поэтому его считают с вычитанием максимума.
  • Функция потерь — средний −log⁡p-\log p правильного токена (cross-entropy = NLL); её минимизация — это максимальное правдоподобие и минимизация KL до истинного распределения.
  • Перплексия =eL= e^{\mathcal{L}} — эффективное число вариантов на шаг; у равномерной модели PPL=V\mathrm{PPL} = V, начальный loss ≈ln⁡V\approx \ln V.
  • Teacher forcing + causal-маска позволяют обучать все позиции за один проход; метки сдвигаются на один токен, позиции с меткой -100 игнорируются.
  • Все модели репозитория — decoder-only: эмбеддинги → LL блоков (attention + FFN с residual и нормализацией) → финальная норма → проекция на словарь.
  1. Запишите цепное правило для последовательности из четырёх токенов. Какие множители меняются, если заменить последний токен?
Ответ

P(x0,x1,x2,x3)=P(x0) P(x1∣x0) P(x2∣x0,x1) P(x3∣x0,x1,x2)P(x_0, x_1, x_2, x_3) = P(x_0)\,P(x_1 \mid x_0)\,P(x_2 \mid x_0, x_1)\,P(x_3 \mid x_0, x_1, x_2). Замена x3x_3 меняет только последний множитель: он единственный, где x3x_3 встречается.

  1. Вычислите softmax логитов (0,ln⁡3)(0, \ln 3). Что изменится, если к обоим логитам прибавить 100?
Ответ

e0=1e^0 = 1, eln⁡3=3e^{\ln 3} = 3, сумма 4, softmax =(0.25,0.75)= (0.25, 0.75). Прибавление 100 ничего не меняет (инвариантность к сдвигу), но наивный расчёт в float32 даст переполнение: e100+ln⁡3>3.4⋅1038e^{100 + \ln 3} > 3.4 \cdot 10^{38}. Устойчивая версия вычтет максимум 100+ln⁡3100 + \ln 3 и получит те же (0.25,0.75)(0.25, 0.75).

  1. Модель дала правильным токенам вероятности 0.2, 0.5, 0.10.2,\ 0.5,\ 0.1. Найдите средний loss и перплексию.
Ответ

−ln⁡0.2=1.609-\ln 0.2 = 1.609, −ln⁡0.5=0.693-\ln 0.5 = 0.693, −ln⁡0.1=2.303-\ln 0.1 = 2.303; среднее L=4.605/3=1.535\mathcal{L} = 4.605 / 3 = 1.535. PPL=e1.535=4.64\mathrm{PPL} = e^{1.535} = 4.64 — то же, что (0.2⋅0.5⋅0.1)−1/3=0.01−1/3(0.2 \cdot 0.5 \cdot 0.1)^{-1/3} = 0.01^{-1/3}.

  1. Какой начальный loss и какую перплексию ожидать у свежей модели со словарём GPT-2 (V=50 257V = 50\,257)? Сколько это в битах на токен?
Ответ

ln⁡50 257≈10.82\ln 50\,257 \approx 10.82 ната, PPL≈50 257\mathrm{PPL} \approx 50\,257; в битах log⁡250 257≈15.6\log_2 50\,257 \approx 15.6.

  1. Батч из B=8B = 8 последовательностей длины T=128T = 128. Сколько слагаемых в loss Trainer.compute_lm_loss, если меток -100 нет? А если в каждой последовательности последние 28 позиций помечены -100?
Ответ

Без -100: 8⋅127=10168 \cdot 127 = 1016. С -100 на позициях 100…127: метки сдвинутой последовательности — позиции 1…127, из них игнорируются 28, остаётся 99 на последовательность, всего 8⋅99=7928 \cdot 99 = 792. Среднее берётся по 792 позициям.

  1. Объясните, почему без causal-маски loss при обучении быстро падает почти до нуля, а генерация не работает.
Ответ

Без маски на позиции tt attention видит вход xt+1x_{t+1} — ровно то, что нужно предсказать. Модель учится копировать его с соседней позиции, это легко, и loss близок к нулю. При генерации будущего токена во входе нет, и выученное правило ничего не даёт.

  1. Докажите, что H(q,p)≥H(q)H(q, p) \ge H(q), и объясните, что это значит для loss на реальных данных.
Ответ

H(q,p)=H(q)+DKL(q ∥ p)H(q, p) = H(q) + D_{\mathrm{KL}}(q \,\|\, p), а DKL≥0D_{\mathrm{KL}} \ge 0 (вывод — в разделе «Связь с KL-дивергенцией»). Значит, loss на данных не может быть ниже энтропии самого языка: даже идеальная модель не угадывает следующий токен наверняка, если продолжений несколько.

  1. (Код.) Возьмите TextDataset из llm.datasets.text_dataset и токенизатор, обученный на паре предложений, с block_size=32. Посчитайте, какая доля позиций в labels равна -100. Замените labels на input_ids (паддинг войдёт в loss) и сравните loss необученной модели в обоих случаях.
  • Radford, Narasimhan, Salimans, Sutskever. Improving Language Understanding by Generative Pre-Training. OpenAI, 2018. PDF
  • 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
  • Devlin, Chang, Lee, Toutanova. BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding. 2018. arXiv:1810.04805
  • Shannon. A Mathematical Theory of Communication. Bell System Technical Journal, 1948. PDF
  • Kneser, Ney. Improved Backing-off for M-gram Language Modeling. ICASSP, 1995. doi:10.1109/ICASSP.1995.479394
  • Bengio, Ducharme, Vincent, Jauvin. A Neural Probabilistic Language Model. JMLR 3, 2003. JMLR
  • Hochreiter, Schmidhuber. Long Short-Term Memory. Neural Computation 9(8), 1997. doi:10.1162/neco.1997.9.8.1735
  • Mikolov, Karafiát, Burget, Černocký, Khudanpur. Recurrent Neural Network Based Language Model. Interspeech, 2010. ISCA
  • Bahdanau, Cho, Bengio. Neural Machine Translation by Jointly Learning to Align and Translate. 2014. arXiv:1409.0473
  • Williams, Zipser. A Learning Algorithm for Continually Running Fully Recurrent Neural Networks. Neural Computation 1(2), 1989. doi:10.1162/neco.1989.1.2.270
  • Bengio, Vinyals, Jaitly, Shazeer. Scheduled Sampling for Sequence Prediction with Recurrent Neural Networks. 2015. arXiv:1506.03099
  • He, Zhang, Ren, Sun. Deep Residual Learning for Image Recognition. 2015. arXiv:1512.03385
  • Elhage et al. A Mathematical Framework for Transformer Circuits. Anthropic, 2021. transformer-circuits.pub