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

Обучение

Реализация: llm/src/llm/training/trainer.py (класс Trainer), training/optimizer.py (get_optimizer), training/scheduler.py (get_linear_schedule_with_warmup), core/weight_init.py, datasets/

  • Какую функцию минимизирует обучение языковой модели и как тексты превращаются в батчи input_ids/labels.
  • Почему градиент cross-entropy по логитам равен p−yp - y и как autograd доводит его до каждого веса.
  • Как устроены SGD, момент, Adam и AdamW, чем weight decay в AdamW отличается от L2-регуляризации и что из этого реализовано в get_optimizer.
  • Зачем нужны warmup, линейный спад learning rate, gradient clipping и правильная инициализация; откуда берётся начальный loss ≈ln⁡V\approx \ln V.
  • Что такое dropout, вспомогательный loss MoE, форматы float32/float16/bfloat16 и сколько памяти занимает обучение.
  • Как по шагам работает Trainer, как запустить обучение и что делать, если loss не падает или стал NaN.

Модель с параметрами θ\theta для каждой позиции tt выдаёт распределение pθ(⋅∣x0,…,xt)p_\theta(\cdot \mid x_0, \dots, x_t) над словарём. Обучение — это подбор θ\theta, при котором реальный следующий токен получает высокую вероятность. Формально минимизируется средняя cross-entropy (средний отрицательный логарифм правдоподобия) по всем предсказаниям в батче:

L(θ)=−1∣M∣∑(b, t)∈Mlog⁡pθ ⁣(xt+1(b) | x0(b),…,xt(b))\mathcal{L}(\theta) = -\frac{1}{|\mathcal{M}|} \sum_{(b,\,t) \in \mathcal{M}} \log p_\theta\!\left(x^{(b)}_{t+1} \,\middle|\, x^{(b)}_0, \dots, x^{(b)}_t\right)

где:

  • bb — номер последовательности в батче, 0≤b<B0 \le b < B;
  • tt — позиция, на которой делается предсказание, 0≤t≤T−20 \le t \le T-2 (для последней позиции T−1T-1 нет «следующего» токена);
  • xt+1(b)x^{(b)}_{t+1} — правильный следующий токен (индекс в словаре);
  • M\mathcal{M} — множество пар (b,t)(b, t), участвующих в loss: все позиции, кроме тех, чья метка равна -100 (см. ниже); ∣M∣≤B⋅(T−1)|\mathcal{M}| \le B \cdot (T-1);
  • pθ(⋅)p_\theta(\cdot) — softmax от логитов модели zt(b)∈RVz^{(b)}_t \in \mathbb{R}^{V}.

Это оценка математического ожидания cross-entropy по корпусу: батч — случайная выборка, и среднее по ней — несмещённая оценка среднего по всему корпусу. Минимум по всему корпусу — та же задача максимального правдоподобия, что и в главе «Языковое моделирование»; exp⁡(L)\exp(\mathcal{L}) — перплексия.

Все T−1T-1 предсказаний одной последовательности считаются за один проход: causal-маска не даёт позиции tt видеть токены правее, поэтому модель честно предсказывает xt+1x_{t+1} по префиксу, а правильные токены префикса берутся из данных, а не из собственных предсказаний модели (teacher forcing).

Интуиция. Если модель даёт правильному токену вероятность 1, слагаемое равно 0; вероятность 0.5 — штраф ln⁡2≈0.69\ln 2 \approx 0.69; вероятность 1/V1/V (случайное угадывание) — штраф ln⁡V\ln V. Loss измеряется в натах на токен.

В коде это Trainer.compute_lm_loss:

shift_logits = logits[..., :-1, :].contiguous() # предсказания позиций 0 … T-2
shift_labels = labels[..., 1:].contiguous() # правильные токены 1 … T-1
loss = F.cross_entropy(shift_logits.view(-1, shift_logits.size(-1)),
shift_labels.view(-1), ignore_index=-100)

F.cross_entropy с редукцией по умолчанию (mean) делит сумму на число позиций с меткой, отличной от -100, — это и есть ∣M∣|\mathcal{M}|. Если таких позиций в батче нет (M\mathcal{M} пусто: батч из пустых строк или строк из одного токена), среднее не определено и F.cross_entropy вернула бы NaN, который через backward() испортил бы веса; поэтому compute_lm_loss в этом случае возвращает 0 — шаг не меняет веса.

В библиотеке три датасета (llm/src/llm/datasets/). Все принимают список строк, токенизатор и block_size (TT) и возвращают словари {"input_ids": [T], "attention_mask": [T], "labels": [T]}:

КлассКогда токенизируетЧто делает со строкой
TextDatasetодин раз, в __init__encode(text, add_special_tokens=False); длиннее TT — обрезает конец, короче — дополняет pad_token_id
StreamingTextDatasetпри каждом __getitem__то же, но токенизирует на лету; это обычный Dataset (не IterableDataset), строки всё равно лежат в памяти списком
TextWithSpecialTokensDatasetв __init__как TextDataset, плюс add_bos/add_eos добавляют по одному BOS/EOS; при обрезке для них оставляется место

Важно понимать, что датасеты не нарезают длинный текст на последовательные блоки: каждая строка — ровно один пример, всё после TT-го токена отбрасывается. Метки — копия входа, но на pad-позициях стоит -100, а attention_mask отмечает настоящие токены единицами; сдвиг на одну позицию делает не датасет, а compute_lm_loss. Всё это собирает одна функция lm_example (datasets/lm_example.py). Схема для строки из 5 токенов при T=8T = 8:

позиция t 0 1 2 3 4 5 6 7
input_ids 17 42 8 99 5 PAD PAD PAD
attention_mask 1 1 1 1 1 0 0 0
labels 17 42 8 99 5 -100 -100 -100
после сдвига в compute_lm_loss:
предсказание z_0 z_1 z_2 z_3 z_4 z_5 z_6 (logits[:, :-1])
цель 42 8 99 5 -100 -100 -100 (labels[:, 1:])

В loss входят 4 цели из 7: предсказания паддинга (-100) отбрасываются ignore_index. Паддинг определяется по месту, а не по значению токена: pad_token_id может совпадать с настоящим токеном (0 по умолчанию, pad = EOS у GPT-2), и сравнение input_ids == pad_token_id выбросило бы из loss и настоящие токены.

Trainer передаёт attention_mask из батча в модель: model(input_ids, attention_mask=...). При правом паддинге на выход настоящих токенов она не влияет — causal-маска и так не даёт им смотреть на паддинг, — но Mixtral по ней исключает паддинг из статистики роутера (см. ниже). Если в батче нет attention_mask (свой датасет), модель вызывается как model(input_ids).

Почему это важно: если дополнить и labels значением pad_token_id, ignore_index=-100 не сработает, и предсказания pad-токенов войдут в loss. В примере выше это 3 из 7 целей, а на учебном корпусе экспериментов (experiments/shared/configs.py, в среднем 9 токенов на строку при T=128T = 128) — около 94%: loss в основном измерял бы, насколько хорошо модель научилась предсказывать «после PAD снова PAD». Чем это заметно — в разделе «Диагностика».

Классический способ подготовки данных для предобучения (так готовят данные GPT-2 и nanoGPT) другой: все документы склеиваются в один поток токенов через разделитель EOS, и поток режется на куски длины TT без паддинга. В библиотеке такого датасета нет; для коротких строк учебного корпуса хватает TextDataset.

Батч собирает torch.utils.data.DataLoader: Trainer создаёт его с shuffle=True, так что порядок примеров в каждой эпохе новый — это и делает градиентный спуск стохастическим. Число шагов оптимизатора за всё обучение:

Nsteps=⌈∣D∣B⌉⋅nepochsN_{\text{steps}} = \left\lceil \frac{|D|}{B} \right\rceil \cdot n_{\text{epochs}}

где ∣D∣|D| — число примеров, BB — batch_size, nepochsn_{\text{epochs}} — num_epochs; последний неполный батч не отбрасывается. Для учебного эксперимента: 12 строк, B=2B = 2, 3 эпохи — всего 18 шагов.

Возьмём одну позицию. Модель выдала логиты z∈RVz \in \mathbb{R}^{V}, правильный токен — yy (индекс). Обозначим y∈{0,1}V\mathbf{y} \in \{0,1\}^V его one-hot вектор. Тогда

pi=ezi∑j=1Vezj,ℓ(z)=−log⁡pyp_i = \frac{e^{z_i}}{\sum_{j=1}^{V} e^{z_j}}, \qquad \ell(z) = -\log p_y

где p∈RVp \in \mathbb{R}^V — вероятности после softmax, ℓ\ell — вклад одной позиции в loss. Главный результат:

∂ℓ∂z=p−y\frac{\partial \ell}{\partial z} = p - \mathbf{y}
Вывод

Шаг 1. Подставим softmax в логарифм и разложим логарифм частного:

ℓ=−log⁡ezy∑jezj=−zy+log⁡∑j=1Vezj\ell = -\log \frac{e^{z_y}}{\sum_j e^{z_j}} = -z_y + \log \sum_{j=1}^{V} e^{z_j}

Шаг 2. Продифференцируем первое слагаемое по ziz_i: zyz_y зависит только от самой себя, поэтому

∂(−zy)∂zi=−1[i=y]=−yi\frac{\partial (-z_y)}{\partial z_i} = -\mathbb{1}[i = y] = -\mathbf{y}_i

Шаг 3. Второе слагаемое — логарифм суммы. По цепному правилу (log⁡u)′=u′/u(\log u)' = u'/u, а в сумме от ziz_i зависит только слагаемое ezie^{z_i}:

∂∂zilog⁡∑jezj=1∑jezj⋅ezi=pi\frac{\partial}{\partial z_i} \log \sum_j e^{z_j} = \frac{1}{\sum_j e^{z_j}} \cdot e^{z_i} = p_i

Шаг 4. Складываем: ∂ℓ/∂zi=pi−yi\partial \ell / \partial z_i = p_i - \mathbf{y}_i для каждого ii, то есть ∂ℓ/∂z=p−y\partial \ell / \partial z = p - \mathbf{y}.

Интуиция. Градиент — это «сколько лишней вероятности модель дала каждому токену». У правильного токена компонента py−1≤0p_y - 1 \le 0: шаг против градиента увеличивает его логит. У остальных pi≥0p_i \ge 0: их логиты уменьшаются тем сильнее, чем больше вероятности они забрали. Сумма компонент равна ∑ipi−1=0\sum_i p_i - 1 = 0: softmax не меняется от прибавления константы ко всем логитам, и градиент эту «бесполезную» сторону не трогает. Когда модель уверена и права (py→1p_y \to 1), градиент стремится к нулю — обучение на этом примере затухает само.

При усреднении по ∣M∣|\mathcal{M}| позициям градиент каждой позиции делится на ∣M∣|\mathcal{M}|: ∂L/∂zt=(pt−yt)/∣M∣\partial \mathcal{L} / \partial z_t = (p_t - \mathbf{y}_t)/|\mathcal{M}|.

Пример. V=3V = 3, z=(2,1,0)z = (2, 1, 0), правильный токен y=0y = 0:

e^z = (7.389, 2.718, 1.000), сумма = 11.107
p = (0.6652, 0.2447, 0.0900)
loss = -ln 0.6652 = 0.4076
p - y = (-0.3348, 0.2447, 0.0900) сумма компонент = 0

То же даёт autograd PyTorch:

import torch
import torch.nn.functional as F
z = torch.tensor([[2.0, 1.0, 0.0]], requires_grad=True)
loss = F.cross_entropy(z, torch.tensor([0]))
loss.backward()
print(loss.item()) # 0.4076
print(z.grad) # tensor([[-0.3348, 0.2447, 0.0900]])

Модель — это композиция функций: эмбеддинги → LL блоков декодера → нормализация → выходная проекция → softmax → loss. Граф вычислений (computational graph) — ориентированный граф, где вершины — промежуточные тензоры, а рёбра — операции. Обратное распространение ошибки (backpropagation, Rumelhart, Hinton, Williams, 1986) — это цепное правило, применённое к этому графу от loss к параметрам.

flowchart LR
    X["input_ids"] --> E["эмбеддинги"]
    E --> D["блоки декодера"]
    D --> H["скрытое состояние h"]
    H --> Z["выходная проекция: logits"]
    Z --> LS["loss"]
    LS -. "градиент по logits" .-> Z
    Z -. "градиент по h" .-> H
    H -. "градиент" .-> D
    D -. "градиент" .-> E

    classDef io fill:#ffffff,stroke:#999999,color:#1a1a1a;
    classDef blue fill:#dae8fc,stroke:#6c8ebf,color:#1a1a1a;
    classDef gold fill:#fff2cc,stroke:#d6b656,color:#1a1a1a;
    class X,LS io;
    class E,D,H blue;
    class Z gold;

Сплошные стрелки — прямой проход (forward), пунктирные — обратный (backward): каждая операция получает градиент по своему выходу и превращает его в градиенты по своим входам и параметрам.

Разберём последнюю операцию — выходную проекцию z=hW+bz = hW + b для одной позиции:

∂ℓ∂W=h⊤(p−y),∂ℓ∂b=p−y,∂ℓ∂h=(p−y) W⊤\frac{\partial \ell}{\partial W} = h^{\top} (p - \mathbf{y}), \qquad \frac{\partial \ell}{\partial b} = p - \mathbf{y}, \qquad \frac{\partial \ell}{\partial h} = (p - \mathbf{y})\, W^{\top}

где:

  • h∈R1×dh \in \mathbb{R}^{1 \times d} — скрытое состояние позиции (строка);
  • W∈Rd×VW \in \mathbb{R}^{d \times V}, b∈R1×Vb \in \mathbb{R}^{1 \times V} — веса и смещение выходной проекции;
  • p−y∈R1×Vp - \mathbf{y} \in \mathbb{R}^{1 \times V} — градиент по логитам из предыдущего раздела;
  • h⊤(p−y)∈Rd×Vh^{\top}(p - \mathbf{y}) \in \mathbb{R}^{d \times V} — внешнее произведение, той же формы, что WW;
  • ∂ℓ/∂h∈R1×d\partial \ell / \partial h \in \mathbb{R}^{1 \times d} — «сигнал ошибки», который уходит в последний блок декодера.

Каждый элемент WijW_{ij} влияет на loss только через логит zj=∑ihiWij+bjz_j = \sum_i h_i W_{ij} + b_j, поэтому ∂ℓ/∂Wij=hi⋅(pj−yj)\partial \ell / \partial W_{ij} = h_i \cdot (p_j - \mathbf{y}_j) — это и записано в матричной форме. Градиент по hh собирается со всех VV логитов: ∂ℓ/∂hi=∑j(pj−yj)Wij\partial \ell / \partial h_i = \sum_j (p_j - \mathbf{y}_j) W_{ij}. Дальше блок декодера делает то же самое со своими операциями, и так до эмбеддингов. Если тензор используется в нескольких местах (например, residual-соединение x+f(x)x + f(x)), градиенты от всех использований складываются.

В PyTorch это делает autograd: во время forward каждая операция над тензорами с requires_grad=True записывается в граф, а loss.backward() проходит граф в обратном порядке и прибавляет градиенты к полю .grad каждого параметра. Именно потому, что градиенты накапливаются, перед каждым шагом нужен optimizer.zero_grad(). Стоимость backward — примерно вдвое больше forward (для каждой матрицы нужны два произведения: по входу и по весам), отсюда оценка «6 FLOP на параметр на токен» в разделе о законах масштабирования.

Градиентный спуск (gradient descent) делает шаг против градиента:

θt=θt−1−η gt,gt=∇θL(θt−1)\theta_{t} = \theta_{t-1} - \eta\, g_t, \qquad g_t = \nabla_\theta \mathcal{L}(\theta_{t-1})

где θt\theta_t — все параметры после шага tt (шаги нумеруются с 1; в разделах об оптимизаторах tt — номер шага, а не позиция в тексте), η>0\eta > 0 — learning rate (шаг обучения), gtg_t — градиент той же формы, что θ\theta. Градиент показывает направление быстрейшего роста loss, поэтому малый шаг против него уменьшает loss.

Точный градиент по всему корпусу слишком дорог. Стохастический градиентный спуск (SGD) считает gtg_t по случайному мини-батчу: это несмещённая, но шумная оценка. Шум при малом шаге усредняется по многим шагам, а каждый шаг в тысячи раз дешевле.

У SGD две беды: шум мини-батчей и «овраги» — направления, где loss круто меняется поперёк оврага и полого вдоль. Шаг, подходящий для крутого направления, слишком мал для пологого. Момент (momentum, Polyak, 1964) накапливает экспоненциально затухающую сумму градиентов. В форме PyTorch (torch.optim.SGD):

ut=μ ut−1+gt,θt=θt−1−η utu_t = \mu\, u_{t-1} + g_t, \qquad \theta_t = \theta_{t-1} - \eta\, u_t

где utu_t — «скорость» той же формы, что θ\theta, u0=0u_0 = 0, μ∈[0,1)\mu \in [0, 1) — коэффициент момента. При постоянном градиенте ut→g/(1−μ)u_t \to g/(1-\mu): для μ=0.9\mu = 0.9 шаг вдоль устойчивого направления в 10 раз больше, а колебания поперёк оврага (градиент меняет знак) взаимно гасятся.

В репозитории: get_optimizer(model, optimizer_type="sgd") создаёт optim.SGD(..., momentum=0.9) с теми же группами weight decay, что и AdamW (см. ниже); в SGD это L2 через градиент.

Adam (Kingma, Ba, 2015, arXiv:1412.6980, алгоритм 1) даёт каждому параметру свой масштаб шага. Все операции ниже — поэлементные:

mt=β1mt−1+(1−β1) gtvt=β2vt−1+(1−β2) gt2m^t=mt1−β1t,v^t=vt1−β2tθt=θt−1−η m^tv^t+ε\begin{aligned} m_t &= \beta_1 m_{t-1} + (1 - \beta_1)\, g_t \\ v_t &= \beta_2 v_{t-1} + (1 - \beta_2)\, g_t^2 \\ \hat m_t &= \frac{m_t}{1 - \beta_1^t}, \qquad \hat v_t = \frac{v_t}{1 - \beta_2^t} \\ \theta_t &= \theta_{t-1} - \eta\, \frac{\hat m_t}{\sqrt{\hat v_t} + \varepsilon} \end{aligned}

где:

  • gtg_t — градиент на шаге tt; gt2g_t^2 — его поэлементный квадрат;
  • mtm_t — первый момент: экспоненциальное скользящее среднее градиента (как момент в SGD, но нормированный множителем 1−β11-\beta_1); m0=0m_0 = 0;
  • vtv_t — второй момент: скользящее среднее квадрата градиента, оценка «типичного размера» градиента параметра; v0=0v_0 = 0;
  • β1,β2∈[0,1)\beta_1, \beta_2 \in [0,1) — коэффициенты сглаживания; по умолчанию в PyTorch 0.90.9 и 0.9990.999 (среднее примерно по последним 1/(1−β)1/(1-\beta) шагам: 10 и 1000);
  • m^t,v^t\hat m_t, \hat v_t — моменты с поправкой смещения (bias correction); βt\beta^t — степень;
  • ε\varepsilon — малое число против деления на ноль, в PyTorch 10−810^{-8};
  • η\eta — learning rate; m,v,m^,v^m, v, \hat m, \hat v имеют ту же форму, что θ\theta.

Интуиция. Отношение m^/v^\hat m / \sqrt{\hat v} безразмерно: если все градиенты параметра умножить на 100, отношение не изменится. Поэтому величина шага каждого параметра порядка η\eta независимо от масштаба его градиента. Параметры с редкими или маленькими градиентами (например, эмбеддинги редких токенов) получают такой же по порядку шаг, как и параметры с большими. Когда градиент устойчиво одного знака, ∣m^∣≈v^|\hat m| \approx \sqrt{\hat v} и шаг близок к η\eta; когда знак скачет (шум), m^\hat m мал по сравнению с v^\sqrt{\hat v}, и шаг автоматически уменьшается.

Зачем поправка смещения. Моменты стартуют с нуля и в первые шаги занижены. Без поправки на первом шаге m1=0.1g1m_1 = 0.1 g_1, v1=0.001g12v_1 = 0.001 g_1^2, и

m1v1=0.1 g10.001 ∣g1∣≈3.16⋅sign⁡(g1)\frac{m_1}{\sqrt{v_1}} = \frac{0.1\, g_1}{\sqrt{0.001}\, |g_1|} \approx 3.16 \cdot \operatorname{sign}(g_1)

— первый шаг был бы в 3 раза больше номинального. С поправкой m^1=g1\hat m_1 = g_1, v^1=g12\hat v_1 = g_1^2 и шаг равен ровно η⋅sign⁡(g1)\eta \cdot \operatorname{sign}(g_1).

Вывод поправки смещения

Раскроем рекуррентность для mtm_t при m0=0m_0 = 0:

mt=(1−β1)∑i=1tβ1 t−i gim_t = (1-\beta_1) \sum_{i=1}^{t} \beta_1^{\,t-i}\, g_i

Пусть градиенты — случайные величины с одинаковым средним E[gi]=E[g]\mathbb{E}[g_i] = \mathbb{E}[g]. Тогда

E[mt]=E[g] (1−β1)∑i=1tβ1 t−i=E[g] (1−β1)⋅1−β1t1−β1=E[g] (1−β1t)\mathbb{E}[m_t] = \mathbb{E}[g]\,(1-\beta_1) \sum_{i=1}^{t} \beta_1^{\,t-i} = \mathbb{E}[g]\,(1-\beta_1) \cdot \frac{1-\beta_1^{t}}{1-\beta_1} = \mathbb{E}[g]\,(1-\beta_1^{t})

(сумма геометрической прогрессии 1+β1+⋯+β1t−11 + \beta_1 + \dots + \beta_1^{t-1}). Значит, mtm_t занижает среднее в 1−β1t1-\beta_1^t раз, и деление на этот множитель убирает смещение. Для vtv_t всё то же с β2\beta_2 и g2g^2. При больших tt множители стремятся к 1 и поправка перестаёт действовать; для β2=0.999\beta_2 = 0.999 это происходит лишь через несколько тысяч шагов.

Adam хранит на каждый параметр два дополнительных числа (mm и vv) — это важно для подсчёта памяти.

Weight decay — регуляризация, которая на каждом шаге немного тянет веса к нулю: θ←(1−ηλ) θ\theta \leftarrow (1 - \eta\lambda)\,\theta. Для обычного SGD это то же самое, что L2-регуляризация — прибавить к loss λ2∥θ∥2\tfrac{\lambda}{2}\|\theta\|^2: её градиент λθ\lambda\theta добавляется к gtg_t, и шаг SGD даёт θ−η(gt+λθ)\theta - \eta(g_t + \lambda\theta).

Loshchilov и Hutter (2019, arXiv:1711.05101) показали, что для Adam это не одно и то же. При L2 член λθ\lambda\theta попадает в gtg_t и затем, как и весь градиент, делится на v^t\sqrt{\hat v_t}:

Adam + L2:θt=θt−1−η m^tv^t+ε,где mt,vt считаются по gt+λθt−1\text{Adam + L2:}\quad \theta_t = \theta_{t-1} - \eta\, \frac{\hat m_t}{\sqrt{\hat v_t} + \varepsilon}, \quad \text{где } m_t, v_t \text{ считаются по } g_t + \lambda \theta_{t-1}

Веса с большими градиентами (большое v^\hat v) в итоге почти не регуляризуются, а веса с маленькими — регуляризуются очень сильно. AdamW (decoupled weight decay, «отделённое» затухание весов) применяет затухание напрямую, мимо адаптивной нормировки. В форме PyTorch (torch.optim.AdamW):

θ′=θt−1−ηtλ θt−1θt=θ′−ηt m^tv^t+ε\begin{aligned} \theta' &= \theta_{t-1} - \eta_t \lambda\, \theta_{t-1} \\ \theta_t &= \theta' - \eta_t\, \frac{\hat m_t}{\sqrt{\hat v_t} + \varepsilon} \end{aligned}

где λ\lambda — коэффициент weight decay (weight_decay), ηt\eta_t — текущий learning rate с учётом расписания, m^t,v^t\hat m_t, \hat v_t считаются по чистому градиенту gtg_t без λθ\lambda\theta.

Пример. Два веса, оба θ=1\theta = 1, λ=0.01\lambda = 0.01. У первого v^=10\sqrt{\hat v} = 10, у второго 0.010.01. В Adam + L2 вклад затухания в шаг равен ηλθ/v^\eta\lambda\theta/\sqrt{\hat v} (грубо, считая, что λθ\lambda\theta мало меняет v^\hat v): 0.001η0.001\eta для первого и 1⋅η1 \cdot \eta для второго — разница в тысячу раз. В AdamW оба получают одинаковое ηλθ=0.01η\eta\lambda\theta = 0.01\eta.

Тонкость реализации. В статье (алгоритм 2) затухание умножается на множитель расписания ηt\eta_t, но не на базовый learning rate α\alpha: θt=θt−1−ηt(α m^t/(v^t+ε)+λθt−1)\theta_t = \theta_{t-1} - \eta_t(\alpha\, \hat m_t/(\sqrt{\hat v_t}+\varepsilon) + \lambda\theta_{t-1}). PyTorch умножает λ\lambda на полный learning rate (param.mul_(1 - lr * weight_decay)), поэтому при η=3⋅10−4\eta = 3 \cdot 10^{-4} и λ=0.01\lambda = 0.01 за шаг вес уменьшается лишь в 1−3⋅10−61 - 3 \cdot 10^{-6} раз. Значения λ\lambda из разных кодовых баз напрямую не сравнимы.

Пример шага AdamW вручную. θ0=1\theta_0 = 1, g1=0.5g_1 = 0.5, η=10−3\eta = 10^{-3}, λ=0.01\lambda = 0.01, β1=0.9\beta_1 = 0.9, β2=0.999\beta_2 = 0.999:

затухание: θ' = 1 − 0.001·0.01·1 = 0.99999
m_1 = 0.1·0.5 = 0.05 m̂_1 = 0.05 / (1 − 0.9) = 0.5
v_1 = 0.001·0.25 = 0.00025 v̂_1 = 0.00025 / (1 − 0.999) = 0.25
шаг: 0.001 · 0.5 / (√0.25 + 1e-8) = 0.001
θ_1 = 0.99999 − 0.001 = 0.99899

torch.optim.AdamW([p], lr=1e-3, weight_decay=0.01) даёт то же: 0.99899. Для сравнения, torch.optim.Adam с тем же weight_decay=0.01 (это L2) на первом шаге даёт 0.999: градиент 0.5+0.010.5 + 0.01 после нормировки превращается в тот же единичный шаг, и регуляризация на первом шаге пропадает вовсе.

Обычная практика — применять weight decay только к матрицам (веса Linear, эмбеддинги), а смещения (bias) и коэффициенты нормализации (веса LayerNorm и RMSNorm) не затухать. GPT-1 (разд. 4.1 статьи) применяет свою регуляризацию с w=0.01w = 0.01 именно так — «on all non bias or gain weights». Смысл: смещений и коэффициентов нормализации мало, на переобучение они почти не влияют, а затухание коэффициента RMSNorm к нулю просто уменьшает масштаб сигнала и спорит с нормализацией.

Что делает репозиторий. get_optimizer (training/optimizer.py) делит параметры на две группы функцией weight_decay_param_groups:

decay = [p for p in model.parameters() if p.dim() >= 2] # матрицы Linear и Embedding
no_decay = [p for p in model.parameters() if p.dim() < 2] # bias и веса нормализаций
groups = [{"params": decay, "weight_decay": weight_decay},
{"params": no_decay, "weight_decay": 0.0}]
  • Критерий — размерность: матрицы Linear (включая роутер и экспертов MoE) и эмбеддинги двумерны, bias и веса LayerNorm/RMSNorm — одномерны. Общая матрица при weight tying входит в группы один раз (model.parameters() не повторяет параметр) и затухает, как и в GPT-1. Для учебного GPT (V=1000V = 1000) без decay остаются 14 312 одномерных параметров из 3 704 808.
  • Эмбеддинги затухают, как в GPT-1, nanoGPT и HF Trainer (он исключает только bias и веса нормализаций).
  • Группы одинаковы для всех трёх вариантов: "adamw" — decoupled weight decay, "adam" — Adam с L2, а не AdamW, "sgd" — SGD с моментом 0.9 и L2.
  • Trainer вызывает get_optimizer(model, lr=lr): всегда AdamW, λ=0.01\lambda = 0.01 на матрицах, β1,β2=0.9,0.999\beta_1, \beta_2 = 0.9, 0.999 (значения PyTorch по умолчанию). Для сравнения, LLaMA (Touvron et al., 2023, разд. 2.3) обучалась с β2=0.95\beta_2 = 0.95 и λ=0.1\lambda = 0.1.

Другие λ\lambda или β\beta можно задать, подменив оптимизатор Trainer до вызова train() — планировщик создаётся внутри train() по self.optimizer:

from llm.training.optimizer import weight_decay_param_groups
trainer = Trainer(model, dataset, lr=3e-4, batch_size=8, num_epochs=3, warmup_steps=100)
trainer.optimizer = torch.optim.AdamW(weight_decay_param_groups(model, 0.1), lr=3e-4, betas=(0.9, 0.95))
trainer.train()

Постоянный learning rate почти никогда не используют. В репозитории — линейный разогрев (warmup) от 0 до η\eta за WwW_{\text{w}} шагов и затем линейный спад до 0 к концу обучения (get_linear_schedule_with_warmup в training/scheduler.py):

η(k)=η⋅λ(k),λ(k)={kmax⁡(1, Ww),k<Wwmax⁡ ⁣(0,  Nsteps−kmax⁡(1, Nsteps−Ww)),k≥Ww\eta(k) = \eta \cdot \lambda(k), \qquad \lambda(k) = \begin{cases} \dfrac{k}{\max(1,\, W_{\text{w}})}, & k < W_{\text{w}} \\ \max\!\left(0,\; \dfrac{N_{\text{steps}} - k}{\max(1,\, N_{\text{steps}} - W_{\text{w}})}\right), & k \ge W_{\text{w}} \end{cases}

где:

  • kk — сколько раз уже был вызван scheduler.step(), k=0,1,…k = 0, 1, \dots (номер шага; не путать с числом экспертов kk из главы о MoE);
  • η\eta — базовый learning rate (lr оптимизатора);
  • WwW_{\text{w}} — num_warmup_steps (в Trainer — warmup_steps или, при warmup_ratio, ⌈Nsteps⋅warmup_ratio⌉\lceil N_{\text{steps}} \cdot \texttt{warmup\_ratio} \rceil); индекс w\text{w} — чтобы не путать с шириной окна WW;
  • NstepsN_{\text{steps}} — num_training_steps (в Trainer — len(train_loader) * num_epochs, то же число, что в формуле выше);
  • λ(k)\lambda(k) — множитель от 0 до 1 (не путать с коэффициентом weight decay).

Код — буквальная запись формулы:

def lr_lambda(current_step):
if current_step < num_warmup_steps:
return float(current_step) / float(max(1, num_warmup_steps))
return max(0.0, float(num_training_steps - current_step)
/ float(max(1, num_training_steps - num_warmup_steps)))
return LambdaLR(optimizer, lr_lambda)

LambdaLR при создании сразу выставляет learning rate ηλ(0)\eta\lambda(0). Поэтому шаг оптимизатора номер kk (с нуля) использует ηλ(k)\eta\lambda(k), и при Ww>0W_{\text{w}} > 0 первый шаг идёт с learning rate 0: параметры не меняются (затухание AdamW тоже умножается на 0), но моменты Adam уже обновляются. Последний, (Nsteps−1)(N_{\text{steps}}-1)-й шаг идёт с η/(Nsteps−Ww)\eta/(N_{\text{steps}} - W_{\text{w}}), а не с нулём.

Пример. η=3⋅10−4\eta = 3 \cdot 10^{-4}, Ww=100W_{\text{w}} = 100, Nsteps=1000N_{\text{steps}} = 1000 (значения получены прогоном LambdaLR):

шаг kk015099100101500999
λ(k)\lambda(k)00.010.50.9910.99890.55560.0011
lr03.0e-61.5e-42.97e-43.0e-42.997e-41.667e-43.3e-7
lr
3e-4 | ***
| ******
| * ******
| * *****
1.5e-4 | ******
| * *****
| * ******
| ******
0 |* **
+--------------------------------------------------> шаг k
0 100 1000
warmup линейный спад
  1. Шумные оценки Adam в начале. В первые шаги v^t\hat v_t оценён по нескольким градиентам, и отношение m^/v^\hat m / \sqrt{\hat v} ведёт себя как у метода со случайным масштабом шага. Liu et al. (2020, RAdam, arXiv:1908.03265) показали, что дисперсия адаптивного множителя 1/v^t1/\sqrt{\hat v_t} в начале обучения велика, и warmup работает как способ её уменьшить: пока оценки шумные, шаги маленькие.
  2. Post-LN. Xiong et al. (2020, arXiv:2002.04745) показали, что в post-LN трансформере (как GPT-1 в этом репозитории, см. «Нормализация») градиенты параметров у выходных слоёв в начале обучения велики, и без warmup большой learning rate сразу выводит модель в плохую область. В pre-LN (GPT-2 и все последующие модели) градиенты при инициализации ведут себя ровнее, и авторы обучали pre-LN трансформер без warmup.
  3. Случайные начальные веса. Пока модель не сдвинулась с начальной точки, направление градиента плохо предсказывает loss даже на небольшом расстоянии; малые шаги безопаснее.

Спад к концу обучения нужен по другой причине: шум стохастических градиентов не даёт сойтись точнее, чем позволяет η\eta; уменьшая η\eta, мы усредняем шум и «оседаем» в минимуме.

Распространённая альтернатива спаду — косинусное (cosine annealing, Loshchilov, Hutter, 2017, SGDR, arXiv:1608.03983). После warmup:

η(k)=ηmin⁡+12(η−ηmin⁡)(1+cos⁡π(k−Ww)Nsteps−Ww)\eta(k) = \eta_{\min} + \frac{1}{2}\left(\eta - \eta_{\min}\right)\left(1 + \cos\frac{\pi (k - W_{\text{w}})}{N_{\text{steps}} - W_{\text{w}}}\right)

где ηmin⁡\eta_{\min} — конечный learning rate, остальные символы — как выше. В начале спада learning rate убывает медленнее линейного, в конце — тоже медленно выходит на ηmin⁡\eta_{\min}. В статье SGDR расписание ещё и периодически «перезапускается»; в LLM обычно берут один период. LLaMA (разд. 2.3 статьи) использует косинусный спад до 10% от максимального learning rate и 2000 шагов warmup. В репозитории косинусного расписания нет; его легко задать своим LambdaLR по формуле выше.

Иногда на отдельном батче градиент оказывается в десятки раз больше обычного — всплеск (spike): редкие токены, неудачное сочетание примеров, приближение к неустойчивой области. Один такой шаг может отбросить модель далеко назад. Обрезка градиента по норме (gradient clipping, Pascanu, Mikolov, Bengio, 2013, arXiv:1211.5063) ограничивает длину общего вектора градиента:

∥g∥=∑j∥gj∥22,g←g⋅min⁡ ⁣(1,  c∥g∥+10−6)\|g\| = \sqrt{\sum_{j} \|g_j\|_2^2}, \qquad g \leftarrow g \cdot \min\!\left(1,\; \frac{c}{\|g\| + 10^{-6}}\right)

где:

  • gjg_j — градиент jj-го тензора параметров, ∥gj∥2\|g_j\|_2 — его евклидова норма;
  • ∥g∥\|g\| — общая норма по всем параметрам модели (как если бы все градиенты склеили в один вектор);
  • cc — порог (max_norm); 10−610^{-6} — защита от деления на 0 (так в torch.nn.utils.clip_grad_norm_).

Если норма не превышает cc, градиент не меняется. Если превышает — весь градиент умножается на одно и то же число, так что направление сохраняется, а длина становится cc.

Пример. Градиент одного тензора (3,4)(3, 4), c=1c = 1: норма 55, множитель 1/51/5, результат (0.6,0.8)(0.6, 0.8).

В Trainer порог зашит: torch.nn.utils.clip_grad_norm_(self.model.parameters(), 1.0) между loss.backward() и optimizer.step(). c=1.0c = 1.0 — распространённое значение (так обучалась, например, LLaMA, разд. 2.3). Функция возвращает норму до обрезки, но Trainer её не сохраняет; это одна из самых полезных величин для диагностики (см. ниже).

Зачем обрезка, если Adam и так нормирует шаг? Шаг Adam ограничен (при β1=0.9\beta_1 = 0.9, β2=0.999\beta_2 = 0.999 отношение m^/v^\hat m/\sqrt{\hat v} после одного огромного градиента — не больше ≈3\approx 3), но огромный градиент надолго раздувает vv: он «забывается» примерно за 1/(1−β2)=10001/(1-\beta_2) = 1000 шагов, и всё это время шаги по этим параметрам занижены. Обрезка не даёт одному батчу испортить статистику оптимизатора.

Рассмотрим линейный слой y=xWy = xW с x∈R1×nx \in \mathbb{R}^{1 \times n}. Пусть компоненты xix_i и веса WijW_{ij} независимы, со средним 0 и дисперсиями Var⁡(x)\operatorname{Var}(x) и σW2\sigma_W^2. Тогда

Var⁡(yj)=Var⁡ ⁣(∑i=1nxiWij)=∑i=1nVar⁡(xi)Var⁡(Wij)=n σW2 Var⁡(x)\operatorname{Var}(y_j) = \operatorname{Var}\!\left(\sum_{i=1}^{n} x_i W_{ij}\right) = \sum_{i=1}^{n} \operatorname{Var}(x_i)\operatorname{Var}(W_{ij}) = n\, \sigma_W^2\, \operatorname{Var}(x)

где nn — число входов (fan-in); второе равенство — дисперсия суммы независимых слагаемых равна сумме дисперсий, а дисперсия произведения независимых величин с нулевым средним равна произведению дисперсий.

Если nσW2>1n\sigma_W^2 > 1, сигнал растёт от слоя к слою экспоненциально, если <1< 1 — затухает; то же происходит с градиентами при обратном проходе. Отсюда классические схемы:

  • Glorot, Bengio (2010, AISTATS): σW2=2/(nin+nout)\sigma_W^2 = 2/(n_{\text{in}} + n_{\text{out}}) — компромисс между сохранением дисперсии на прямом (ninσW2=1n_{\text{in}}\sigma_W^2 = 1) и обратном (noutσW2=1n_{\text{out}}\sigma_W^2 = 1) проходе.
  • He et al. (2015, arXiv:1502.01852): для ReLU, которая обнуляет половину входов, σW2=2/nin\sigma_W^2 = 2/n_{\text{in}}.
  • PyTorch по умолчанию для nn.Linear: равномерное распределение на [−1/n,1/n][-1/\sqrt{n}, 1/\sqrt{n}], то есть σW=1/3n\sigma_W = 1/\sqrt{3n} (для n=256n = 256 — 0.036); для nn.Embedding — N(0,1)\mathcal{N}(0, 1).

В трансформере эту задачу во многом решают нормализации (LayerNorm/RMSNorm возвращают масштаб к единице перед каждым подблоком), поэтому инициализация здесь определяет прежде всего масштаб логитов и residual-потока.

GPT-1 (разд. 4.1 статьи) инициализирует веса нормальным распределением N(0,0.022)\mathcal{N}(0, 0.02^2); так же — код GPT-2 и HuggingFace (ключ initializer_range). Это не схема «сохранения дисперсии»: при d=256d = 256 получается nσW2=256⋅0.0004≈0.1n\sigma_W^2 = 256 \cdot 0.0004 \approx 0.1 — сигнал в каждой проекции уменьшается, а масштаб восстанавливают нормализации. Эффект — маленькие логиты у свежей модели и почти равномерное внимание.

В репозитории — init_normal_ (core/weight_init.py): nn.Linear и nn.Embedding — N(0,std2)\mathcal{N}(0, \text{std}^2), bias — нули, nn.LayerNorm — вес 1, сдвиг 0. GPT.__init__ вызывает её через self.apply(partial(init_normal_, std=config.get("initializer_range", 0.02))). Подробнее — gpt.md.

В pre-LN архитектуре скрытое состояние после LL блоков — это сумма входа и выходов всех подблоков:

hL=h0+∑i=12Lrih_L = h_0 + \sum_{i=1}^{2L} r_i

где h0∈Rdh_0 \in \mathbb{R}^{d} — эмбеддинг, rir_i — выход ii-го подблока (в каждом блоке два: attention и FFN), который residual-соединение прибавляет к потоку. Выход подблока заканчивается проекцией (MultiHeadAttention._layer, FeedForward._layer2), чей вход нормализован, поэтому все rir_i имеют примерно одинаковую дисперсию s2∝σW2s^2 \propto \sigma_W^2. Если слагаемые некоррелированы,

Var⁡(hL)=Var⁡(h0)+∑i=12LVar⁡(ri)≈Var⁡(h0)+2L s2\operatorname{Var}(h_L) = \operatorname{Var}(h_0) + \sum_{i=1}^{2L} \operatorname{Var}(r_i) \approx \operatorname{Var}(h_0) + 2L\, s^2

— дисперсия residual-потока растёт линейно с числом слагаемых, то есть с глубиной. Чем глубже модель, тем меньше относительный вклад каждого нового блока, и тем сильнее финальная нормализация сжимает сигнал. Статья GPT-2 (разд. 2.3) масштабирует веса residual-слоёв на 1/Nres1/\sqrt{N_{\text{res}}}, где NresN_{\text{res}} — число residual-слоёв (подблоков, пишущих в поток); здесь Nres=2LN_{\text{res}} = 2L, как в HuggingFace. Если σW→σW/2L\sigma_W \to \sigma_W/\sqrt{2L}, то s2→s2/(2L)s^2 \to s^2/(2L) и сумма 2L⋅s2/(2L)=s22L \cdot s^2/(2L) = s^2 перестаёт зависеть от глубины.

Грубая оценка. Для GPT-2 124M (d=768d = 768, L=12L = 12): s2≈d σW2=768⋅0.022≈0.31s^2 \approx d\,\sigma_W^2 = 768 \cdot 0.02^2 \approx 0.31. Без масштабирования 24 слагаемых дают ≈7.4\approx 7.4, с масштабированием — ≈0.31\approx 0.31.

В репозитории — scale_residual_projections_ (там же): для GPT2 проекции decoder._heads._layer и decoder._ff._layer2 каждого блока переинициализируются N(0,(0.02/2L)2)\mathcal{N}(0, (0.02/\sqrt{2L})^2), как в GPT2PreTrainedModel._init_weights в HuggingFace. Подробнее — gpt2.md.

Хорошая проверка инициализации — loss свежей модели. Если логиты zjz_j малы и независимы от правильного токена, модель предсказывает почти равномерное распределение, и loss близок к ln⁡V\ln V. Точнее, пусть zj∼N(0,σ2)z_j \sim \mathcal{N}(0, \sigma^2) независимо:

E[ℓ]=E ⁣[log⁡∑j=1Vezj]−E[zy]≈log⁡ ⁣(V E[ez])−0=ln⁡V+σ22\mathbb{E}[\ell] = \mathbb{E}\!\left[\log \sum_{j=1}^{V} e^{z_j}\right] - \mathbb{E}[z_y] \approx \log\!\left(V\, \mathbb{E}[e^{z}]\right) - 0 = \ln V + \frac{\sigma^2}{2}

где использовано E[ez]=eσ2/2\mathbb{E}[e^{z}] = e^{\sigma^2/2} для нормальной zz и то, что при большом VV сумма ∑jezj\sum_j e^{z_j} близка к V⋅E[ez]V \cdot \mathbb{E}[e^z] (закон больших чисел).

Откуда σ\sigma? Перед выходной проекцией стоит нормализация, так что компоненты hh имеют дисперсию около 1, и σ≈d σW\sigma \approx \sqrt{d}\,\sigma_W. При d=256d = 256:

Инициализация выходной проекцииσW\sigma_Wσ=d σW\sigma = \sqrt{d}\,\sigma_Wln⁡V+σ2/2\ln V + \sigma^2/2 при V=1000V = 1000измерено
N(0,0.022)\mathcal{N}(0, 0.02^2) (все шесть моделей)0.020.326.966.95–6.96 (std логитов 0.32)
PyTorch по умолчанию (модель без init_normal_)1/3⋅256=0.0361/\sqrt{3 \cdot 256} = 0.0360.587.077.05–7.10 (std логитов 0.58)

Измерения — свежие модели с конфигами из experiments/llm_only/configs/*_train.json, случайные токены, среднее по трём сидам. Они совпадают с бэклогом (пункт 7: «6.96 при ln V = 6.91 (было 7.07)»). Разница в 0.1 нат невелика, но если начальный loss сильно больше ln⁡V\ln V, логиты слишком велики, и модель уверенно ошибается с первого шага.

МодельИнициализацияГде в коде
GPTinit_normal_: N(0,0.022)\mathcal{N}(0, 0.02^2), bias 0, LayerNorm 1/0GPT.__init__
GPT-2то же + scale_residual_projections_ (0.02/2L0.02/\sqrt{2L})GPT2.__init__
LLaMA, Mistral, Mixtral, Gemmainit_normal_: N(0,0.022)\mathcal{N}(0, 0.02^2), bias 0; веса RMSNorm — 1 (так их создаёт конструктор)__init__ каждой модели

Так же инициализируют эти модели HuggingFace-реализации (_init_weights, initializer_range = 0.02 в LlamaConfig, MistralConfig, MixtralConfig, GemmaConfig); масштабирования residual-проекций, как у GPT-2, у них нет. Стандартное отклонение у всех шести моделей задаёт необязательный ключ конфига initializer_range. Без неё эти модели стартуют с более крупными логитами (вторая строка таблицы выше), а Gemma с tie_word_embeddings и scale_embeddings — с loss ≈258 вместо ln⁡V\ln V: эмбеддинги N(0,1)\mathcal{N}(0, 1), умноженные на d\sqrt{d}, и та же матрица на выходе. Для загрузки готовых весов это неважно: load_state_dict перезаписывает любую инициализацию.

Dropout (Srivastava et al., 2014, JMLR 15) при обучении случайно обнуляет элементы тензора с вероятностью pp. PyTorch использует «инвертированный» вариант — оставшиеся элементы масштабируются, чтобы среднее не менялось:

h~i=mi hi1−p,mi∼Bernoulli⁡(1−p)\tilde h_i = \frac{m_i\, h_i}{1 - p}, \qquad m_i \sim \operatorname{Bernoulli}(1 - p)

где hh — вход (любой формы), mm — случайная маска той же формы (1 — элемент остаётся, 0 — обнуляется), pp — вероятность обнуления (dropout в конфиге). Математическое ожидание сохраняется: E[h~i]=(1−p) hi/(1−p)=hi\mathbb{E}[\tilde h_i] = (1-p)\, h_i/(1-p) = h_i. В статье Srivastava et al. масштаб применялся к весам на этапе тестирования; инвертированный вариант переносит его в обучение, чтобы при инференсе слой был тождественным.

Train и eval. В режиме model.train() dropout активен, в model.eval() — тождественен. Пример (p=0.5p = 0.5, масштаб 1/(1−p)=21/(1-p) = 2): вход (1,2,3,4)(1, 2, 3, 4) в режиме train может дать (0,0,6,0)(0, 0, 6, 0), в eval — (1,2,3,4)(1, 2, 3, 4). Trainer.train() вызывает model.train() в начале каждой эпохи, evaluate() — model.eval(); BaseModel.load возвращает модель уже в режиме eval.

Интуиция. Сеть не может полагаться на отдельный нейрон — он в любой момент может выпасть, — и вынуждена распределять информацию избыточно. Srivastava et al. трактуют это как приближённое усреднение экспоненциально большого числа «прореженных» сетей с общими весами.

Где dropout в моделях репозитория (все — с одной вероятностью config["dropout"]):

МодельПосле эмбеддинговВыход attention (после проекции)Выход FFNДругое
GPT, GPT-2да (токены + позиции)дада (после второго Linear)attention_dropout на весах после softmax, по умолчанию 0
LLaMAдадада (SwiGLU)
Mistralдада (GroupedQueryAttention)да (SwiGLU)
Mixtralдадаодин на выходе MoE (эксперты с dropout=0)
Gemmaдада (MultiQueryAttention)да (GeGLU)

GPT-1 обучался с dropout 0.1 (разд. 4.1 статьи). Современные LLM при предобучении dropout почти не используют: данных так много, что модель за одну-две эпохи не успевает переобучиться, а dropout замедляет обучение. В LLaMA, Mistral и Gemma его нет (бэклог, пункты 51 и 55; llama.md, mistral.md, gemma.md). В репозитории dropout — обязательный ключ конфига; dropout: 0 отключает его полностью. На крошечном учебном корпусе dropout, наоборот, полезен.

В Mixtral роутер MoE обучается вместе с экспертами и без ограничений «схлопывается» на нескольких экспертах. Против этого к loss языковой модели прибавляется load-balancing loss (Fedus et al., Switch Transformers, arXiv:2101.03961, разд. 2.2):

Ltotal=LLM+α⋅Laux\mathcal{L}_{\text{total}} = \mathcal{L}_{\text{LM}} + \alpha \cdot \mathcal{L}_{\text{aux}}

где LLM\mathcal{L}_{\text{LM}} — cross-entropy из начала главы, Laux\mathcal{L}_{\text{aux}} — load-balancing loss по всем слоям MoE (формула и вывод — в главе «Mixture-of-Experts» и в mixtral.md), α\alpha — коэффициент router_aux_loss_coef из конфига.

Как это устроено в коде:

  • BaseModel.auxiliary_loss() (core/base_model.py) по умолчанию возвращает None.
  • Mixtral.auxiliary_loss() возвращает router_aux_loss_coef * load_balancing_loss(...) по логитам роутеров, запомненным при последнем прямом проходе, или None, если коэффициент равен 0 (по умолчанию 0 — выключено; в HF MixtralConfig коэффициент 0.001).
  • Trainer.train() после compute_lm_loss вызывает self.model.auxiliary_loss() и, если результат не None, прибавляет его к loss до backward(). В evaluate() вспомогательный loss не прибавляется: валидационный loss — чистая cross-entropy, его можно сравнивать между моделями.
  • Trainer передаёт в модель attention_mask из батча, и Mixtral.forward запоминает по ней маску настоящих токенов: pad-токены в статистику загрузки экспертов не входят.

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

ФорматБит: знак / экспонента / мантиссаМаксимумМин. нормальноеМашинный эпсилонВерных десятичных знаков
float321 / 8 / 233.4⋅10383.4 \cdot 10^{38}1.2⋅10−381.2 \cdot 10^{-38}2−23≈1.2⋅10−72^{-23} \approx 1.2 \cdot 10^{-7}~7
float161 / 5 / 1065 50465\,5046.1⋅10−56.1 \cdot 10^{-5}2−10≈9.8⋅10−42^{-10} \approx 9.8 \cdot 10^{-4}~3
bfloat161 / 8 / 73.4⋅10383.4 \cdot 10^{38}1.2⋅10−381.2 \cdot 10^{-38}2−7=7.8⋅10−32^{-7} = 7.8 \cdot 10^{-3}~2

Значения проверены через torch.finfo. Машинный эпсилон — расстояние от 1 до следующего представимого числа.

  • float16 точнее bfloat16, но с узким диапазоном: torch.tensor(70000., dtype=torch.float16) — inf, а градиент 10−810^{-8} превращается в 0 (underflow).
  • bfloat16 (Kalamkar et al., 2019, arXiv:1905.12322) — это float32 с обрезанной мантиссой: тот же диапазон, переполнения практически не бывает, но точность низкая. В bf16 1+0.001=11 + 0.001 = 1: маленькое обновление веса просто теряется. Поэтому веса и состояние оптимизатора держат во float32, даже когда считают в bf16.

Обучение со смешанной точностью (mixed precision, Micikevicius et al., 2018, arXiv:1710.03740) сочетает скорость половинной точности с устойчивостью float32:

  1. Мастер-копия весов во float32; для forward и backward веса приводятся к float16. Обновление θ←θ−η⋅шаг\theta \leftarrow \theta - \eta \cdot \text{шаг} делается во float32, иначе малые шаги теряются (см. пример 1+0.0011 + 0.001).
  2. Масштабирование loss (loss scaling): loss умножают на большое SS перед backward, чтобы малые градиенты не обнулились в float16, а перед шагом оптимизатора градиенты делят на SS. При переполнении (inf⁡\inf) шаг пропускается и SS уменьшается.
  3. Накопление во float32: суммы в матричных произведениях, softmax, нормализации и loss считаются с повышенной точностью.

С bfloat16 шаг 2 обычно не нужен — диапазон как у float32. В PyTorch это делают torch.autocast (и torch.amp.GradScaler для float16).

В Trainer mixed precision не реализована: всё обучение идёт в dtype модели (по умолчанию float32). Модели к половинной точности готовы: RMSNorm для float16/bfloat16 считает нормализацию во float32, MoE работает в bf16, load-balancing loss считается во float32. Свой цикл с autocast может выглядеть так (проверено на CPU с bfloat16):

model.train()
for batch in loader:
optimizer.zero_grad()
with torch.autocast(device_type="cuda", dtype=torch.bfloat16): # веса остаются float32; на CPU — device_type="cpu"
logits, _ = model(batch["input_ids"])
loss = trainer.compute_lm_loss(logits.float(), batch["labels"]) # loss во float32
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()

При обучении AdamW во float32 на каждый параметр хранится четыре числа по 4 байта:

Mсостояние=N⋅(4⏟θ+4⏟∇θ+4⏟m+4⏟v)=16N байтM_{\text{состояние}} = N \cdot (\underbrace{4}_{\theta} + \underbrace{4}_{\nabla\theta} + \underbrace{4}_{m} + \underbrace{4}_{v}) = 16N \text{ байт}

где NN — число параметров, θ\theta — веса, ∇θ\nabla\theta — градиенты (поле .grad), mm, vv — моменты Adam. При mixed precision по схеме Micikevicius получается столько же: 2 (веса fp16) + 2 (градиенты fp16) + 4 (мастер-копия fp32) + 4 + 4 (моменты) = 16 байт (Rajbhandari et al., ZeRO, 2020, arXiv:1910.02054, разд. 3.1). Выигрыш mixed precision — в скорости и в памяти на активации, а не на состояние.

Сверх этого нужна память на активации — промежуточные тензоры forward, сохранённые для backward. Она растёт с BB, TT, LL и dd (а у attention — с T2T^2) и часто превышает память на состояние.

Примеры.

  • Учебный GPT из gpt_train.json при V=1000V = 1000: N=3 704 808N = 3\,704\,808, 16N≈5916N \approx 59 МБ — помещается где угодно.
  • Модель на 7 млрд параметров: 16⋅7⋅109=11216 \cdot 7 \cdot 10^9 = 112 ГБ только на состояние — больше памяти одного GPU на 80 ГБ, даже без активаций. Для инференса в bf16 той же модели хватает 2N=142N = 14 ГБ. Поэтому большие модели обучают на многих GPU с разбиением состояния (ZeRO и аналоги).

Сколько параметров и данных нужно? Kaplan et al. (2020, arXiv:2001.08361) обнаружили, что loss предобучения убывает по степенному закону от числа параметров NN, объёма данных DD (в токенах) и вычислений CC на много порядков. Там же — оценка стоимости обучения:

C≈6NDC \approx 6 N D

где CC — число операций с плавающей точкой (FLOP), 2ND2ND — forward (умножение и сложение на каждый параметр на каждый токен), 4ND4ND — backward (вдвое дороже forward).

Hoffmann et al. (2022, Chinchilla, arXiv:2203.15556) уточнили: при фиксированном бюджете CC оптимально увеличивать NN и DD примерно поровну, что соответствует ~20 токенам на параметр. Модель Chinchilla (70B параметров, 1.4T токенов) превзошла более крупные модели, обученные на меньшем числе токенов. Для модели на 7B это ~140B токенов и C≈6⋅7⋅109⋅1.4⋅1011≈5.9⋅1021C \approx 6 \cdot 7 \cdot 10^9 \cdot 1.4 \cdot 10^{11} \approx 5.9 \cdot 10^{21} FLOP.

Это оптимум по стоимости обучения. Модели, которые потом много раз используются, выгодно обучать дольше: LLaMA 7B обучалась на 1T токенов (Touvron et al., 2023), в разы больше «оптимума Chinchilla», — модель меньше и дешевле в инференсе. Для учебных экспериментов этого репозитория (тысячи токенов) законы масштабирования неприменимы — это лишь контекст.

Trainer (training/trainer.py) — минимальный цикл обучения, общий для всех шести моделей:

Trainer(model, train_dataset, val_dataset=None, lr=3e-4, batch_size=8, num_epochs=3,
warmup_steps=None, warmup_ratio=None)

Длину warmup задают либо числом шагов warmup_steps, либо долей warmup_ratio от числа шагов обучения: Ww=⌈Nsteps⋅warmup_ratio⌉W_{\text{w}} = \lceil N_{\text{steps}} \cdot \texttt{warmup\_ratio} \rceil, как warmup_ratio в HuggingFace TrainingArguments. Доля удобнее, когда NstepsN_{\text{steps}} зависит от размера датасета: warmup не окажется длиннее всего обучения. Оба параметра сразу — ValueError; ни одного — 100 шагов. Если Ww≥NstepsW_{\text{w}} \ge N_{\text{steps}}, train() выдаёт предупреждение: learning rate не дойдёт до заданного.

Конструктор создаёт DataLoader (shuffle=True для обучающего, без перемешивания для валидационного), оптимизатор get_optimizer(model, lr=lr) (AdamW, λ=0.01\lambda = 0.01), выбирает устройство (cuda, если доступна, иначе cpu) и переносит на него модель.

flowchart TD
    A["zero_grad"] --> B["forward: logits = model(input_ids)"]
    B --> C["compute_lm_loss: сдвиг и cross-entropy"]
    C --> D["+ auxiliary_loss (если есть)"]
    D --> E["loss.backward()"]
    E --> F["clip_grad_norm_ до 1.0"]
    F --> G["optimizer.step(): AdamW"]
    G --> H["scheduler.step(): warmup и спад"]
    H --> A

    classDef blue fill:#dae8fc,stroke:#6c8ebf,color:#1a1a1a;
    classDef purple fill:#e1d5e7,stroke:#9673a6,color:#1a1a1a;
    classDef gold fill:#fff2cc,stroke:#d6b656,color:#1a1a1a;
    class A,B,C,D blue;
    class E,F purple;
    class G,H gold;

Метод train() по шагам:

ШагКодЧто происходитРаздел главы
0total_steps = len(self.train_loader) * self.num_epochs; self.num_warmup_steps(total_steps); get_linear_schedule_with_warmup(...)число шагов NstepsN_{\text{steps}}, длина warmup (предупреждение, если она не меньше NstepsN_{\text{steps}}) и планировщикрасписание
1self.model.train()включить dropout (в начале каждой эпохи)dropout
2self.optimizer.zero_grad()обнулить накопленные .gradbackprop
3outputs = self.model(input_ids), logits = outputs[0]forward, логиты [B,T,V][B, T, V]
4self.compute_lm_loss(logits, labels)сдвиг и средняя cross-entropy L\mathcal{L}функция потерь
5loss + self.model.auxiliary_loss()load-balancing loss MoE, если естьaux loss
6loss.backward()autograd: ∇θL\nabla_\theta \mathcal{L} в .gradградиент
7clip_grad_norm_(..., 1.0)общая норма градиента ≤1\le 1clipping
8self.optimizer.step()шаг AdamW с текущим ηλ(k)\eta\lambda(k)AdamW
9self.scheduler.step()k←k+1k \leftarrow k + 1, новый learning rateрасписание
10total_loss += loss.item()после эпохи печатается средний loss и добавляется в self.loss_history; если задан val_dataset — evaluate()

evaluate() переводит модель в eval(), отключает градиенты (torch.no_grad()) и возвращает средний по батчам LM loss без вспомогательного. Чего в Trainer нет: mixed precision, накопления градиентов (gradient accumulation), групп параметров для weight decay, настройки β\beta, порога clipping и λ\lambda, логирования нормы градиента и learning rate, сохранения чекпоинтов по ходу обучения, выбора устройства mps.

Скрипт experiments/llm_only/run_llm_experiment.py обучает любую из шести моделей по JSON-конфигу (запускать из корня репозитория, подробности — experiments/README.md):

Окно терминала
uv run python experiments/llm_only/run_llm_experiment.py --model gpt2 --action train \
--config experiments/llm_only/configs/gpt2_train.json

Раздел training конфига передаётся в Trainer:

"training": { "learning_rate": 0.0003, "batch_size": 2, "num_epochs": 3, "warmup_ratio": 0.1 }

Вместо warmup_ratio можно задать warmup_steps; без обоих ключей скрипт обучает без warmup. На учебном корпусе 18 шагов, и warmup_ratio = 0.1 даёт ⌈1,8⌉=2\lceil 1{,}8 \rceil = 2 шага warmup.

Скрипт берёт 80% учебного корпуса (load_training_data), обучает или загружает BPE-токенизатор, подставляет его vocab_size в model_config, строит TextDataset с block_size = max_position_embeddings и вызывает Trainer(...).train() без валидационного набора. Веса сохраняются как голый state_dict (torch.save(model.state_dict(), ...)), а конфиг — отдельным JSON; режим generate загружает их через load_state_dict.

Минимальный пример обучения прямо из Python:

from llm.models.gpt import GPT
from llm.tokenizers import BPETokenizer
from llm.datasets.text_dataset import TextDataset
from llm.training.trainer import Trainer
texts = ["Языковая модель предсказывает следующий токен.",
"Обучение минимизирует среднюю cross-entropy.",
"AdamW отделяет weight decay от градиентного шага."] * 4
tokenizer = BPETokenizer()
tokenizer.train(texts=texts, vocab_size=200, special_tokens=["<pad>", "<unk>", "<bos>", "<eos>"])
config = {"vocab_size": tokenizer.get_vocab_size(), "embed_dim": 64, "num_heads": 4,
"num_layers": 2, "max_position_embeddings": 32, "dropout": 0.1}
model = GPT(config)
dataset = TextDataset(texts, tokenizer, block_size=32)
trainer = Trainer(model, dataset, lr=3e-4, batch_size=4, num_epochs=3, warmup_steps=2)
trainer.train()
print(trainer.loss_history) # средний loss каждой эпохи

Для обученной модели удобнее методы BaseModel (core/base_model.py), чем голый state_dict:

model.save("checkpoints/gpt_tiny.pt") # класс, конфиг и веса в одном файле
restored = GPT.load("checkpoints/gpt_tiny.pt", device="cpu")
  • save пишет словарь {"model_class", "config", "state_dict"}; вычисляемые буферы (маски, таблицы RoPE) не сохраняются.
  • load — метод класса: создаёт модель по сохранённому конфигу, загружает веса и возвращает её в режиме eval. Файл читается с weights_only=True; файл другой модели или голый state_dict дают ValueError.
  • Состояние оптимизатора и планировщика (моменты Adam, номер шага) не сохраняется, поэтому продолжить обучение «с того же места» нельзя: новый Trainer начнёт с нулевых моментов и снова с warmup.

Loss не падает или падает очень медленно.

  • Проверьте, какой learning rate реально был: trainer.optimizer.param_groups[0]["lr"], а множитель расписания на шаге kk — trainer.scheduler.lr_lambdas[0](k). Warmup должен быть малой долей от NstepsN_{\text{steps}} (обычно единицы процентов). Пример: учебный конфиг даёт 18 шагов (12 примеров, B=2B = 2, 3 эпохи). С warmup_steps = 50 обучение целиком проходит внутри warmup, и максимальный множитель — λ(17)=17/50=0.34\lambda(17) = 17/50 = 0.34; средний loss эпох GPT — 6.08 → 5.98 → 5.79. С warmup_ratio = 0.1 (2 шага warmup), как в конфигах репозитория, — 6.04 → 5.27 → 4.84. Если warmup не короче всего обучения, Trainer предупреждает.
  • Слишком маленький или слишком большой η\eta: loss стоит на месте или скачет. Для маленьких трансформеров с AdamW типичны 10−410^{-4}–10−310^{-3}.
  • Двойной сдвиг меток. compute_lm_loss сдвигает сам; если сдвинуть labels ещё и в датасете, модель будет предсказывать токен через один.
  • Начальный loss сильно больше ln⁡V\ln V — проблема инициализации или масштаба логитов (см. выше).

Loss падает подозрительно быстро.

  • Pad-токены в loss (см. «Данные»). Датасеты llm/datasets ставят на паддинге -100 сами, но свой датасет или коллатор может этого не делать. Если паддинг входит в loss, на учебном корпусе (прогон с warmup_steps = 5) валидационный loss опускается до 1.23 (перплексия 3.4) — почти целиком за счёт предсказаний «PAD после PAD». С метками -100 на паддинге тот же прогон даёт валидационный loss 5.92 при ln⁡V=6.18\ln V = 6.18 (V=484V = 484 у токенизатора, обученного на всех 15 строках корпуса; скрипт эксперимента обучает токенизатор только на 12 обучающих строках и получает V=426V = 426, см. tokenization.md) — модель на 12 строках почти ничему не научилась. Проверить свой датасет — посчитать долю целей, которые входят в loss:
batch = next(iter(trainer.train_loader))
targets = batch["labels"][:, 1:]
print((targets != -100).float().mean()) # доля целей в loss: при коротких строках и большом T
# она мала; около 1.0 — паддинг входит в loss
  • Утечка данных: одни и те же строки в обучении и валидации.

Loss стал NaN или inf.

  • Слишком большой learning rate или отсутствие warmup (особенно в post-LN GPT-1).
  • Переполнение float16 (максимум 65 504) — используйте bfloat16 или loss scaling.
  • Логируйте норму градиента: norm = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) возвращает её до обрезки. Рост нормы на порядки перед NaN — признак неустойчивости; постоянная норма ≫1\gg 1 значит, что clipping срабатывает на каждом шаге и фактически уменьшает learning rate.
  • torch.autograd.set_detect_anomaly(True) укажет операцию, где впервые появился NaN (медленно, только для отладки).

Переобучение (train loss падает, валидационный растёт).

  • Передавайте val_dataset в Trainer и сравнивайте с train loss после каждой эпохи; run_llm_experiment.py валидацию не запускает.
  • Больше данных; dropout; weight decay; меньше эпох (ранняя остановка). Trainer не сохраняет лучшую модель — сохраняйте её сами через model.save после эпохи с лучшим валидационным loss.

MoE: эксперты не используются. Включите load-balancing loss (router_aux_loss_coef > 0, например 0.001–0.02).

  • Забыть zero_grad() — градиенты копятся между шагами (autograd прибавляет к .grad). Намеренно так делают при накоплении градиентов, но тогда loss делят на число накапливаемых батчей.
  • Забыть model.eval() при генерации и оценке — dropout продолжает работать, выход случаен и хуже. После Model.load модель уже в режиме eval; после Trainer.train() — в режиме train, если не было валидации (иначе последним вызывается evaluate(), и модель остаётся в eval).
  • Adam вместо AdamW. get_optimizer(..., optimizer_type="adam") — это L2-регуляризация, нормируемая Adam, а не decoupled weight decay.
  • Первый шаг с learning rate 0 — особенность LambdaLR с warmup: не ошибка, но при очень коротком обучении заметна.
  • Датасет обрезает длинные строки: всё после block_size токенов теряется. Для длинных документов нужна склейка и нарезка на блоки.
  • pad_token_id = None: если в словаре токенизатора нет <pad>, атрибут pad_token_id равен None, и дополнение последовательностей падает с RuntimeError: Could not infer dtype of NoneType. Обучайте токенизатор со специальными токенами.
  • Инициализация перезаписывается при загрузке: всё сказанное об инициализации важно только для обучения с нуля.
  • Обучение минимизирует среднюю cross-entropy следующего токена; датасеты возвращают labels — копию input_ids с -100 на паддинге — и attention_mask, сдвиг делает compute_lm_loss, pad-токены в loss не входят.
  • Градиент cross-entropy по логитам — p−yp - \mathbf{y}; autograd по цепному правилу доводит его до всех весов.
  • Adam нормирует шаг каждого параметра оценкой второго момента, с поправкой смещения в начале; AdamW применяет weight decay мимо этой нормировки. В репозитории — AdamW с λ=0.01\lambda = 0.01 на матрицах (веса Linear и эмбеддинги); bias и веса нормализаций не затухают.
  • Learning rate: линейный warmup от 0 и линейный спад до 0; warmup гасит шум ранних оценок Adam и нестабильность post-LN. Длину warmup удобно задавать долей от числа шагов (warmup_ratio). Косинусного расписания в репозитории нет.
  • Gradient clipping ограничивает общую норму градиента единицей и защищает от всплесков.
  • Все шесть моделей инициализируются N(0,0.022)\mathcal{N}(0, 0.02^2), как в статьях и HuggingFace (GPT-2 — ещё и с масштабом 1/2L1/\sqrt{2L} для residual-проекций); начальный loss ≈ln⁡V+σ2/2\approx \ln V + \sigma^2/2.
  • Dropout — m⊙h/(1−p)m \odot h/(1-p) только в режиме train; в современных LLM его обычно нет.
  • Состояние AdamW — 16 байт на параметр; mixed precision в Trainer не реализована.
  1. Логиты z=(0,0,0,0)z = (0, 0, 0, 0), правильный токен y=2y = 2. Найдите loss и градиент по логитам.
Ответ

p=(0.25,0.25,0.25,0.25)p = (0.25, 0.25, 0.25, 0.25), loss =−ln⁡0.25=ln⁡4≈1.386= -\ln 0.25 = \ln 4 \approx 1.386 (это ln⁡V\ln V для V=4V = 4). Градиент p−y=(0.25,0.25,−0.75,0.25)p - \mathbf{y} = (0.25, 0.25, -0.75, 0.25), сумма компонент 0.

  1. Продолжите пример AdamW из главы: после первого шага θ1=0.99899\theta_1 = 0.99899, m1=0.05m_1 = 0.05, v1=0.00025v_1 = 0.00025. Второй градиент g2=−0.5g_2 = -0.5, те же η=10−3\eta = 10^{-3}, λ=0.01\lambda = 0.01. Найдите θ2\theta_2.
Ответ
затухание: θ' = 0.99899 · (1 − 1e-5) = 0.9989800
m_2 = 0.9·0.05 + 0.1·(−0.5) = −0.005 m̂_2 = −0.005 / (1 − 0.81) = −0.026316
v_2 = 0.999·0.00025 + 0.001·0.25 = 0.00049975 v̂_2 = 0.00049975 / (1 − 0.998001) = 0.25
шаг: 0.001 · (−0.026316) / 0.5 = −0.0000526
θ_2 = 0.9989800 + 0.0000526 = 0.9990326

Совпадает с torch.optim.AdamW. Знак градиента сменился, но m^2\hat m_2 ещё помнит первый градиент, поэтому шаг в 19 раз меньше номинального — момент сглаживает шум.

  1. η=6⋅10−4\eta = 6 \cdot 10^{-4}, Ww=200W_{\text{w}} = 200, Nsteps=2000N_{\text{steps}} = 2000. Каким будет learning rate на шагах k=50k = 50 и k=1100k = 1100 по get_linear_schedule_with_warmup?
Ответ

k=50<200k = 50 < 200: λ=50/200=0.25\lambda = 50/200 = 0.25, lr =1.5⋅10−4= 1.5 \cdot 10^{-4}. k=1100k = 1100: λ=(2000−1100)/(2000−200)=0.5\lambda = (2000 - 1100)/(2000 - 200) = 0.5, lr =3⋅10−4= 3 \cdot 10^{-4}.

  1. Сколько памяти займёт состояние AdamW во float32 для модели на 1.3 млрд параметров? Сколько токенов ей нужно по оценке Chinchilla и сколько FLOP займёт обучение?
Ответ

16⋅1.3⋅109=20.816 \cdot 1.3 \cdot 10^9 = 20.8 ГБ (без активаций). Токенов ≈20⋅1.3⋅109=26⋅109\approx 20 \cdot 1.3 \cdot 10^9 = 26 \cdot 10^9. C≈6⋅1.3⋅109⋅2.6⋅1010≈2.0⋅1020C \approx 6 \cdot 1.3 \cdot 10^9 \cdot 2.6 \cdot 10^{10} \approx 2.0 \cdot 10^{20} FLOP.

  1. У модели два тензора параметров с градиентами (1,2)(1, 2) и (2,4)(2, 4). Что сделает clip_grad_norm_ с порогом 1.0?
Ответ

Общая норма 1+4+4+16=5>1\sqrt{1 + 4 + 4 + 16} = 5 > 1, все градиенты умножаются на 1/51/5: (0.2,0.4)(0.2, 0.4) и (0.4,0.8)(0.4, 0.8). Обрезка общая: отдельные тензоры по отдельности не нормируются, соотношение между ними сохраняется.

  1. Модель с V=32 000V = 32\,000, d=4096d = 4096, выходная проекция инициализирована N(0,0.022)\mathcal{N}(0, 0.02^2), перед ней RMSNorm. Оцените начальный loss.
Ответ

σ≈4096⋅0.02=1.28\sigma \approx \sqrt{4096} \cdot 0.02 = 1.28, ln⁡32 000≈10.37\ln 32\,000 \approx 10.37, σ2/2≈0.82\sigma^2/2 \approx 0.82 — loss ≈11.19\approx 11.19. Прямая проверка на случайных логитах даёт 11.19. При большом dd фиксированное 0.02 уже заметно поднимает начальный loss над ln⁡V\ln V.

  1. Почему dropout делит на 1−p1 - p? Что случится, если оценивать модель, забыв model.eval()?
Ответ

Деление сохраняет математическое ожидание каждого элемента, поэтому при инференсе слой можно просто отключить, и масштаб активаций будет тем же, что в среднем при обучении. Без eval() выход останется случайным: часть активаций обнуляется, остальные удваиваются (при p=0.5p = 0.5), loss и генерация становятся шумными и хуже.

  1. Объясните, почему torch.optim.Adam(..., weight_decay=0.01) и torch.optim.AdamW(..., weight_decay=0.01) на первом шаге ведут себя по-разному, и какой из них ближе к «затуханию весов».
Ответ

В Adam член λθ\lambda\theta прибавляется к градиенту и вместе с ним нормируется на v^\sqrt{\hat v}; на первом шаге m^1/v^1=sign⁡(g1+λθ)\hat m_1/\sqrt{\hat v_1} = \operatorname{sign}(g_1 + \lambda\theta), и регуляризация полностью растворяется в единичном шаге (0.999 в примере главы). AdamW умножает веса на 1−ηλ1 - \eta\lambda отдельно от адаптивного шага, одинаково для всех параметров (0.99899). Затуханию весов в смысле Loshchilov и Hutter соответствует AdamW.

  • Rumelhart, Hinton, Williams. Learning representations by back-propagating errors. Nature 323, 1986. doi:10.1038/323533a0
  • Polyak. Some methods of speeding up the convergence of iteration methods. USSR Computational Mathematics and Mathematical Physics, 1964 — метод тяжёлого шарика (момент).
  • Kingma, Ba. Adam: A Method for Stochastic Optimization. ICLR 2015. arXiv:1412.6980
  • Loshchilov, Hutter. Decoupled Weight Decay Regularization. ICLR 2019. arXiv:1711.05101
  • Loshchilov, Hutter. SGDR: Stochastic Gradient Descent with Warm Restarts. ICLR 2017. arXiv:1608.03983
  • Liu et al. On the Variance of the Adaptive Learning Rate and Beyond (RAdam). ICLR 2020. arXiv:1908.03265
  • Xiong et al. On Layer Normalization in the Transformer Architecture. 2020. arXiv:2002.04745
  • Pascanu, Mikolov, Bengio. On the difficulty of training recurrent neural networks. ICML 2013. arXiv:1211.5063
  • Glorot, Bengio. Understanding the difficulty of training deep feedforward neural networks. AISTATS 2010. PMLR 9
  • He, Zhang, Ren, Sun. Delving Deep into Rectifiers: Surpassing Human-Level Performance on ImageNet Classification. 2015. arXiv:1502.01852
  • Radford, Narasimhan, Salimans, Sutskever. Improving Language Understanding by Generative Pre-Training. OpenAI, 2018. PDF — инициализация, dropout, регуляризация (разд. 4.1)
  • Radford, Wu, Child, Luan, Amodei, Sutskever. Language Models are Unsupervised Multitask Learners. OpenAI, 2019. PDF — масштабирование residual-весов (разд. 2.3)
  • Touvron et al. LLaMA: Open and Efficient Foundation Language Models. 2023. arXiv:2302.13971 — гиперпараметры обучения (разд. 2.3)
  • Srivastava, Hinton, Krizhevsky, Sutskever, Salakhutdinov. Dropout: A Simple Way to Prevent Neural Networks from Overfitting. JMLR 15, 2014. jmlr.org
  • Fedus, Zoph, Shazeer. Switch Transformers: Scaling to Trillion Parameter Models with Simple and Efficient Sparsity. 2021. arXiv:2101.03961
  • Micikevicius et al. Mixed Precision Training. ICLR 2018. arXiv:1710.03740
  • Kalamkar et al. A Study of BFLOAT16 for Deep Learning Training. 2019. arXiv:1905.12322
  • Rajbhandari, Rasley, Ruwase, He. ZeRO: Memory Optimizations Toward Training Trillion Parameter Models. 2020. arXiv:1910.02054
  • Kaplan et al. Scaling Laws for Neural Language Models. 2020. arXiv:2001.08361
  • Hoffmann et al. Training Compute-Optimal Large Language Models (Chinchilla). 2022. arXiv:2203.15556