Перейти к содержимому
Ступень 33.4 Как обучать глубокие сети

Инициализация и нормализация

Почему сигнал и градиенты в глубокой сети затухают или взрываются, как правильный выбор начальных весов (Xavier, Kaiming) удерживает их на старте, а нормализация — во время всего обучения, и почему почти все современные LLM используют RMSNorm в схеме Pre-Norm.

УРОКstage-3/3-4-deep-training/01-init-normalization.mdx1 ч 40 минУрок 1 из 3
После урока вы сможете
  • Объяснить затухание и взрыв градиентов через произведение множителей по слоям и посчитать его на числах
  • Вывести инициализации Xavier и Kaiming из условия «дисперсия не меняется от слоя к слою»
  • Сравнить BatchNorm, LayerNorm и RMSNorm (по какой оси, сколько параметров, что на инференсе) и объяснить, почему в LLM стоит RMSNorm
  • Записать Pre-Norm и Post-Norm формулами и объяснить, почему современные LLM выбирают Pre-Norm

В модуле 3.2 вы обучили MLP с одним скрытым слоем из шести нейронов, и всё получилось с первой попытки. Теперь добавьте слоёв: 10, 30, 50. Скорее всего, loss застынет на месте с первого шага. До конца 2000-х глубокие сети считались почти необучаемыми: сигнал, проходя через десятки слоёв, либо гаснет до нуля, либо разрастается до бесконечности.

У Qwen3-0.6B 28 блоков трансформера, у DeepSeek-V3 — 61. Эти модели обучаются стабильно благодаря трём приёмам: правильной инициализации весов, нормализации и остаточным связям. Первые два — тема этого урока, третий — следующего. Без них не работает ни одна LLM.

Вспомните обратный проход: по цепному правилуцепное правилоEN chain ruleПроизводная композиции — произведение производных звеньев — (f(g(x)))′ = f′(g(x))·g′(x). Математическая основа backprop. градиент для первого слоя — это произведение множителей от всех слоёв выше. Произведение многих чисел ведёт себя капризно:

Множитель на слойЧерез 10 слоёвЧерез 50 слоёв
0.25 (максимум производной сигмоиды)9.5⋅10−79.5 \cdot 10^{-7}≈10−30\approx 10^{-30}
0.90.350.005
1.011
1.12.6117

Если множитель меньше единицы, градиент первых слоёв затухает: они перестают учиться, хотя их веса формально обновляются. Это затухание градиентовзатухание градиентовEN vanishing gradientsГрадиент первых слоёв — произведение множителей всех слоёв выше; если они меньше 1, он экспоненциально уменьшается с глубиной (0.9⁵⁰ ≈ 0.005), и нижние слои перестают учиться.. Если больше единицы — градиенты взрываются, шаги оптимизатора становятся гигантскими, а loss превращается в nan. Это взрыв градиентоввзрыв градиентовEN exploding gradientsОбратная ситуация: множители больше 1, градиенты растут экспоненциально (1.1⁵⁰ ≈ 117), loss становится nan. Лечат инициализацией, нормализацией и gradient clipping.. Устойчиво только окно около единицы, и сеть надо в нём удержать.

С прямым проходом то же самое. Каждый слой умножает масштаб активаций на некоторое число. Если оно не равно единице, то к 50-му слою активации либо крошечные, либо огромные. Поэтому нам нужен способ выбрать веса так, чтобы масштаб сигнала не менялся от слоя к слою.

Почему «просто случайные числа» не работают

Заголовок раздела «Почему «просто случайные числа» не работают»

Масштаб сигнала будем мерить дисперсией Var\mathrm{Var} — средним квадратом отклонения от среднего — и стандартным отклонением std =Var= \sqrt{\mathrm{Var}} (урок 1.3.1). Запись N(0,σ2)N(0, \sigma^2) означает нормальное распределение («колокол») со средним 0 и дисперсией σ2\sigma^2, то есть со std =σ= \sigma. Например, N(0,0.012)N(0, 0.01^2) — колокол со std 0.01.

Возьмём слой с n=512n = 512 входами: z=∑i=1nwixiz = \sum_{i=1}^{n} w_i x_i. Пусть входы xix_i и веса wiw_i независимы, со средним 0. Понадобятся два факта:

  1. Для независимых ww и xx со средним 0 дисперсия произведения равна произведению дисперсий: Var(wx)=Var(w)⋅Var(x)\mathrm{Var}(w x) = \mathrm{Var}(w) \cdot \mathrm{Var}(x). (Среднее wxwx равно нулю, а средний квадрат (wx)2=w2x2(wx)^2 = w^2 x^2 для независимых величин распадается в произведение средних квадратов.)
  2. Дисперсия суммы независимых слагаемых равна сумме дисперсий (урок 1.3.1). У нас nn одинаковых слагаемых, поэтому
Var(z)=n⋅Var(w)⋅Var(x).\mathrm{Var}(z) = n \cdot \mathrm{Var}(w) \cdot \mathrm{Var}(x).

Здесь написано: каждый слой умножает дисперсию сигнала на n⋅Var(w)n \cdot \mathrm{Var}(w). Проверим формулу числами, прежде чем ей пользоваться:

import numpy as np
rng = np.random.default_rng(0)
n = 512
x = rng.standard_normal((10_000, n)) # 10 000 примеров, у каждого входа Var = 1
W = rng.standard_normal((n, 1)) * 0.1 # веса со std 0.1, то есть Var(w) = 0.01
z = x @ W # выход одного нейрона на каждом примере
print(f"по формуле: {n * 0.01 * 1:.2f}, на деле: {z.var():.2f}")
по формуле: 5.12, на деле: 4.95

Совпало с точностью до случайного разброса. Подставим теперь разные std весов:

  • Веса из N(0,1)N(0, 1), то есть std = 1. Множитель 512⋅1=512512 \cdot 1 = 512, а std выхода — 512≈22.6\sqrt{512} \approx 22.6. После tanh⁡\tanh почти все значения окажутся у ±1\pm 1, в зоне насыщения. Производная tanh⁡\tanh там почти нулевая, и градиент дальше не проходит. С ReLU активации просто растут: к 50-му слою их std порядка 106010^{60}.
  • Веса из N(0,0.012)N(0, 0.01^2). Множитель для std: 0.01⋅512≈0.230.01 \cdot \sqrt{512} \approx 0.23. Каждый слой уменьшает сигнал в 4 раза, и к 10-му слою от него остаётся ∼10−7\sim 10^{-7}.

Нужен std ровно такой, чтобы множитель n⋅Var(w)n \cdot \mathrm{Var}(w) был равен единице: тогда сигнал проходит через любое число слоёв, не раздуваясь и не съёживаясь.

Инициализация весовинициализация весовEN weight initializationВыбор начальных значений весов; главное — дисперсия, при которой сигнал не затухает и не взрывается от слоя к слою. — это выбор их начальных значений. Одинаковыми (например, нулями) веса делать нельзя: в уроке 3.2.3 одинаковые нейроны получали одинаковые градиенты и так и оставались копиями друг друга. Поэтому веса берут случайными — из нормального или равномерного распределения со средним 0. Вопрос только в дисперсии.

Условие «дисперсия не меняется» даёт nin⋅Var(w)=1n_{in} \cdot \mathrm{Var}(w) = 1, то есть Var(w)=1/nin\mathrm{Var}(w) = 1/n_{in}. Но обратный проход умножает градиент на W⊤W^\top, и для него то же рассуждение даёт Var(w)=1/nout\mathrm{Var}(w) = 1/n_{out}. Ксавье Глоро и Йошуа Бенджио предложили компромисс: подставить вместо nn среднее арифметическое (nin+nout)/2(n_{in} + n_{out})/2.

Var(w)=2nin+nout.\mathrm{Var}(w) = \frac{2}{n_{in} + n_{out}}.

Это инициализация Xavierинициализация Xavier (Glorot)EN Xavier / Glorot initializationVar(w) = 2 / (n_in + n_out) (Glorot, Bengio, 2010): компромисс между условиями прямого и обратного прохода. Рассчитана на активации, которые около нуля ведут себя как тождество (tanh, линейные слои); с ReLU сигнал затухает — там нужна Kaiming.. Для слоя 784 → 256 получаем std =2/1040≈0.044= \sqrt{2/1040} \approx 0.044. Есть и равномерный вариант: U(−a,a)U(-a, a) — равномерное распределение, где любое число от −a-a до aa одинаково вероятно, — с a=6/(nin+nout)a = \sqrt{6/(n_{in} + n_{out})} (у равномерного распределения дисперсия a2/3a^2/3, поэтому дисперсия выходит та же). Вывод предполагает, что активация около нуля ведёт себя как тождество — функция, которая возвращает вход без изменений, f(z)=zf(z) = z. Для tanh⁡\tanh это так (tanh⁡0.1≈0.0997\tanh 0.1 \approx 0.0997), для ReLU — нет: она обнуляет всё отрицательное.

Сначала идея словами: ReLU выбрасывает половину сигнала, поэтому веса должны быть «вдвое сильнее» (по дисперсии), чтобы это компенсировать. Теперь аккуратно.

ReLU обнуляет отрицательную половину входов. Её выход неотрицателен, и среднее у него уже не 0. Поэтому в формулу для следующего слоя честнее подставлять не дисперсию входа, а его средний квадрат E[x2]\mathbb{E}[x^2] (среднее значение x2x^2; E\mathbb{E} — матожидание, то есть среднее, урок 1.3.1): Var(z)=n⋅Var(w)⋅E[x2]\mathrm{Var}(z) = n \cdot \mathrm{Var}(w) \cdot \mathbb{E}[x^2]. Это верно при любых входах, если среднее весов равно 0. Если zz симметрично относительно нуля, ReLU обнуляет ровно половину его значений, и E[ReLU(z)2]=12Var(z)\mathbb{E}[\mathrm{ReLU}(z)^2] = \tfrac{1}{2}\mathrm{Var}(z): ReLU «съедает» половину среднего квадрата сигнала. Пример: zz принимает значения −2,−1,1,2-2, -1, 1, 2 поровну. Средний квадрат zz равен (4+1+1+4)/4=2.5(4 + 1 + 1 + 4)/4 = 2.5. После ReLU остаются 0,0,1,20, 0, 1, 2, и средний квадрат равен (0+0+1+4)/4=1.25(0 + 0 + 1 + 4)/4 = 1.25 — ровно половина. От слоя к слою получаем Var(zl+1)=12 n Var(w) Var(zl)\mathrm{Var}(z_{l+1}) = \tfrac{1}{2}\, n\, \mathrm{Var}(w)\, \mathrm{Var}(z_l). Чтобы множитель был равен единице, дисперсию весов удваивают:

Var(w)=2nin.\mathrm{Var}(w) = \frac{2}{n_{in}}.

Это инициализация Kaimingинициализация Kaiming (He)EN Kaiming / He initializationVar(w) = 2 / n_in (He и соавт., MSRA, 2015). Двойка компенсирует то, что ReLU обнуляет половину входов. В PyTorch — nn.init.kaiming_normal_., её вывели Каймин Хэ и соавторы из Microsoft Research Asia (подробности — в китайском углу ниже). Для nin=512n_{in} = 512: std =2/512=0.0625= \sqrt{2/512} = 0.0625.

Проверим на сети из 30 слоёв ширины 256 с ReLU (это код задания ниже):

small слой 1: 0.0937 слой 10: 2.82e-10 слой 30: 2.85e-29
xavier слой 1: 0.583 слой 10: 0.0229 слой 30: 1.73e-05
kaiming слой 1: 0.825 слой 10: 0.75 слой 30: 0.663

Каждая строка — std активаций после 1-го, 10-го и 30-го слоя. Запись 2.82e-10 означает 2.82⋅10−102.82 \cdot 10^{-10} (научная запись из урока 1.0.2). С «маленькими» весами сигнал умирает уже к 10-му слою. Xavier теряет 2\sqrt{2} на каждом слое: с ReLU он недодаёт ровно ту двойку. Kaiming держит масштаб на всей глубине. Std здесь около 0.8, а не 1, потому что выход ReLU неотрицателен: единице равен его средний квадрат, а std меньше.

СхемаVar(w)\mathrm{Var}(w)Для чегоВ PyTorch
Xavier (2010)2/(nin+nout)2/(n_{in} + n_{out})tanh, сигмоида, линейные слоиnn.init.xavier_normal_
Kaiming (2015)2/nin2/n_{in}ReLU и родственныеnn.init.kaiming_normal_(w, nonlinearity="relu")
nn.Linear по умолчанию1/(3nin)1/(3 n_{in})«разумное» значение для неглубоких сетейU(−1/n,1/n)U(-1/\sqrt{n}, 1/\sqrt{n})
GPT-2, nanoGPTstd =0.02= 0.02трансформерыnn.init.normal_(w, std=0.02)

У трансформера d=768d = 768 (GPT-2 small), и формула Ксавье дала бы std =1/768≈0.036= 1/\sqrt{768} \approx 0.036. В GPT-2 и nanoGPT все веса берут из N(0,0.022)N(0, 0.02^2) — чуть меньше. Это эмпирическое число: в трансформере устойчивость обеспечивают нормализация и остаточные связи, поэтому инициализация может быть «с запасом» маленькой. В следующем уроке мы увидим ещё один приём GPT-2: выходные проекции блоков дополнительно уменьшают в 2N\sqrt{2N} раз, где NN — число блоков.

Но инициализация отвечает только за старт. Через тысячу шагов веса изменятся, и распределения активаций поплывут. Поэтому масштаб нужно поддерживать постоянно — это работа нормализации.

Нормализация: держать масштаб на каждом шаге

Заголовок раздела «Нормализация: держать масштаб на каждом шаге»

Идея любой нормализации одна: взять группу чисел, вычесть среднее, поделить на стандартное отклонение (как стандартизация признаковстандартизация признаковEN feature standardization (z-score)(x − μ)/σ. Не путайте с нормализацией (normalization) — обычно это min-max масштабирование в [0, 1]; в китайских текстах эти термины иногда смешивают. из ступени 2), а потом дать сети обучаемый масштаб γ\gamma и сдвиг β\beta, чтобы она могла вернуть нужный ей масштаб. Способы отличаются прежде всего тем, какую группу чисел нормировать. (RMSNorm к тому же пропускает вычитание среднего, о ней ниже.)

Представьте активации слоя как таблицу «примеры батча × признаки». Буквой B отмечены числа, которые BatchNorm нормирует вместе, буквой L — числа, которые вместе нормируют LayerNorm и RMSNorm:

признак 1признак 2признак 3признак 4
пример 1·B··
пример 2LB, LLL
пример 3·B··

BatchNorm берёт столбец: один признак у всех примеров батча. LayerNorm и RMSNorm берут строку: все признаки одного примера. На деле нормируются все столбцы (BatchNorm) или все строки (LayerNorm и RMSNorm), каждый отдельно; в таблице для наглядности выделено по одному.

пакетная нормализацияпакетная нормализация (BatchNorm)EN batch normalizationНормирует каждый признак по примерам батча, хранит скользящие статистики для инференса (Ioffe, Szegedy, 2015). Стандарт CNN; в LLM не используется. предложили Иоффе и Сегеди из Google. Для каждого признака jj считаются среднее и дисперсия по примерам батча:

x^ij=xij−μjσj2+ε,yij=γjx^ij+βj.\hat{x}_{ij} = \frac{x_{ij} - \mu_j}{\sqrt{\sigma_j^2 + \varepsilon}}, \qquad y_{ij} = \gamma_j \hat{x}_{ij} + \beta_j.

Число ε\varepsilon (например, 10−510^{-5}) защищает от деления на ноль. BatchNorm сделал возможным обучение глубоких CNN с большим learning rate и стал стандартом компьютерного зрения. Но у него есть неприятные свойства:

  • Выход примера зависит от соседей по батчу. Поменяли состав батча — поменялся ответ.
  • Обучение и инференс различаются. На инференсеинференс, вывод моделиEN inferenceСм. «reasoning»: в китайском одно и то же слово. (когда готовую модель применяют к новым данным) батча может не быть, поэтому BatchNorm во время обучения копит скользящие средние среднего и дисперсии каждого признака и в режиме model.eval() использует их. Забытый model.eval() — классический баг.
  • Маленькие батчи дают шумную статистику. При обучении LLM на одну GPU часто приходится 1–4 последовательности.

нормализация слоянормализация слояEN layer normalization (Ба, Кирос, Хинтон) нормирует каждый пример по его признакам. В трансформере пример — это вектор одного токена длины dd:

μ=1d∑k=1dxk,σ2=1d∑k=1d(xk−μ)2,LN(x)=γ⊙x−μσ2+ε+β.\mu = \frac{1}{d}\sum_{k=1}^{d} x_k, \quad \sigma^2 = \frac{1}{d}\sum_{k=1}^{d} (x_k - \mu)^2, \quad \mathrm{LN}(x) = \gamma \odot \frac{x - \mu}{\sqrt{\sigma^2 + \varepsilon}} + \beta.

Пример: x=(2,4,6,8)x = (2, 4, 6, 8). Среднее μ=5\mu = 5, дисперсия σ2=(9+1+1+9)/4=5\sigma^2 = (9 + 1 + 1 + 9)/4 = 5, σ≈2.24\sigma \approx 2.24. Результат (при γ=1\gamma = 1, β=0\beta = 0): (−1.34,−0.45,0.45,1.34)(-1.34, -0.45, 0.45, 1.34). Соседи по батчу ни на что не влияют, обучение и инференс считаются одинаково.

RMSNormRMSNorm (нормализация по среднеквадратичному)EN root mean square layer normalization (RMSNorm)x / sqrt(mean(x²) + ε) · g — LayerNorm без вычитания среднего и без сдвига β (Zhang, Sennrich, 2019). Стандарт Llama, Qwen, DeepSeek, MiniMind. (Бяо Чжан и Рико Сеннрих) — LayerNorm без центрирования и без сдвига β\beta. Вектор делится на свой среднеквадратичный размер (root mean square):

RMS(x)=1d∑k=1dxk2+ε,RMSNorm(x)=g⊙xRMS(x).\mathrm{RMS}(x) = \sqrt{\frac{1}{d}\sum_{k=1}^{d} x_k^2 + \varepsilon}, \qquad \mathrm{RMSNorm}(x) = g \odot \frac{x}{\mathrm{RMS}(x)}.

Словами: возводим числа в квадрат, усредняем, извлекаем корень — это «типичный размер» числа в векторе; делим вектор на этот размер и умножаем на обучаемый вектор gg (у LayerNorm его зовут γ\gamma).

Пример: x=(3,4)x = (3, 4). RMS=(9+16)/2=12.5≈3.54\mathrm{RMS} = \sqrt{(9 + 16)/2} = \sqrt{12.5} \approx 3.54, результат — (0.85,1.13)(0.85, 1.13). Если умножить xx на 10, ответ не изменится: (30,40)(30, 40) даёт RMS≈35.4\mathrm{RMS} \approx 35.4 и те же (0.85,1.13)(0.85, 1.13). Нормализация инвариантна к масштабу — ей всё равно, во сколько раз растянут вход.

Посмотрим на все три в деле. Вторая строка — та же, что первая, только в 10 раз больше:

import torch
import torch.nn as nn
torch.set_printoptions(precision=2, sci_mode=False)
x = torch.tensor([[1.0, 2.0, 3.0, 6.0], # «токен» 1
[10.0, 20.0, 30.0, 60.0], # тот же вектор, умноженный на 10
[0.0, -1.0, 1.0, 0.0]]) # совсем другой токен
bn = nn.BatchNorm1d(4) # нормирует КАЖДЫЙ ПРИЗНАК по батчу (по столбцам)
ln = nn.LayerNorm(4) # нормирует КАЖДЫЙ ПРИМЕР по признакам (по строкам)
rms = nn.RMSNorm(4) # то же, но без вычитания среднего (PyTorch ≥ 2.4)
print("BatchNorm:\n", bn(x))
print("LayerNorm:\n", ln(x))
print("RMSNorm:\n", rms(x))
# BatchNorm зависит от соседей по батчу: уберём третью строку — первая изменится
print("BatchNorm без 3-й строки, строка 1:", bn(x[:2])[0])
print("LayerNorm без 3-й строки, строка 1:", ln(x[:2])[0])
# Параметры: у LayerNorm γ и β, у RMSNorm только γ
print(sum(p.numel() for p in ln.parameters()), sum(p.numel() for p in rms.parameters()))
BatchNorm:
tensor([[-0.59, -0.54, -0.63, -0.59],
[ 1.41, 1.40, 1.41, 1.41],
[-0.82, -0.86, -0.78, -0.82]], grad_fn=<NativeBatchNormBackward0>)
LayerNorm:
tensor([[-1.07, -0.53, 0.00, 1.60],
[-1.07, -0.53, 0.00, 1.60],
[ 0.00, -1.41, 1.41, 0.00]], grad_fn=<NativeLayerNormBackward0>)
RMSNorm:
tensor([[ 0.28, 0.57, 0.85, 1.70],
[ 0.28, 0.57, 0.85, 1.70],
[ 0.00, -1.41, 1.41, 0.00]], grad_fn=<MulBackward0>)
BatchNorm без 3-й строки, строка 1: tensor([-1.00, -1.00, -1.00, -1.00], grad_fn=<SelectBackward0>)
LayerNorm без 3-й строки, строка 1: tensor([-1.07, -0.53, 0.00, 1.60], grad_fn=<SelectBackward0>)
8 4

Что видно:

  • BatchNorm превратил первый токен (1,2,3,6)(1, 2, 3, 6) почти в константу ≈−0.6\approx -0.6: внутренняя «форма» вектора потерялась, осталось только «меньше или больше среднего по батчу». А без третьей строки ответ для первой стал другим, (−1,−1,−1,−1)(-1, -1, -1, -1).
  • LayerNorm и RMSNorm дали первым двум строкам одинаковый ответ: масштаб убран, форма вектора сохранена. От состава батча ничего не зависит.
  • У RMSNorm параметров вдвое меньше: только γ\gamma.

BatchNorm отпадает сразу. Кроме перечисленных проблем, есть ещё одна: если нормировать по позициям последовательности, статистика будет включать будущие токены. Тогда модель будет подглядывать в ответ, и обучение на предсказание следующего токена потеряет смысл. А при генерации по одному токену «статистики батча» просто нет.

Между LayerNorm и RMSNorm выбор решили практические соображения:

  1. Дешевле. Не нужно считать среднее и вычитать его, нет вектора β\beta. Нормализация в LLM ограничена не арифметикой, а чтением памяти, но любая сэкономленная операция над тензором размера «батч × длина × dd» повторяется в каждом слое на каждом шаге. Авторы RMSNorm сообщили об ускорении на 7–64% в зависимости от модели.
  2. Качество то же. Чжан и Сеннрих предположили, что LayerNorm помогает обучению благодаря инвариантности к масштабу, а центрирование почти ничего не добавляет. В их экспериментах на нескольких задачах RMSNorm действительно работал наравне с LayerNorm.
  3. Так сложилось. Ту же нормировку «только масштаб, без центрирования и сдвига» независимо применил T5 (2019), затем Llama (2023). После Llama её взяли Qwen, DeepSeek, Mistral, MiniMind. GPT-2 и GPT-3 ещё использовали LayerNorm.
BatchNormLayerNormRMSNorm
Нормируетпризнак по батчупример по признакампример по признакам
Вычитает среднеедаданет
Параметрыγ\gamma, β\beta + скользящие статистикиγ\gamma, β\betagg
Обучение и инференсразличаютсяодинаковыодинаковы
Где живётCNN (ResNet)BERT, GPT-2, GPT-3T5, Llama, Qwen, DeepSeek

Трансформер (его мы соберём на ступени 4) состоит из одинаковых блоков, а в каждом блоке два подслоя: внимание (attention), через которое токены — кусочки текста — обмениваются информацией, и MLP. Что такое внимание, сейчас знать не нужно: для этого раздела подслой — просто «какая-то функция FF». Каждый подслой обёрнут остаточной связьюостаточная связьEN residual connection: выход равен x+F(x)x + F(x), то есть подслой не заменяет вход, а прибавляет поправку. Подробно о ней — в следующем уроке. Сейчас важно другое: куда поставить нормализацию относительно этого сложения? Вариантов два.

flowchart LR
subgraph P1["Post-Norm (2017, BERT)"]
  a1["x"] --> f1["F"] --> s1(("+"))
  a1 --> s1
  s1 --> n1["Norm"] --> o1["выход"]
end
subgraph P2["Pre-Norm (GPT-2, Llama, Qwen, DeepSeek)"]
  a2["x"] --> n2["Norm"] --> f2["F"] --> s2(("+"))
  a2 --> s2
  s2 --> o2["выход"]
end
Post-Norm нормирует сумму, Pre-Norm — только вход ветки

Post-NormPost-Norm (нормализация после сложения)EN post-normalization (Post-LN)Norm(x + F(x)), как в исходном трансформере 2017 года и BERT. Без разогрева learning rate нестабилен на глубине., как в «Attention Is All You Need» и BERT:

xl+1=Norm(xl+F(xl)).x_{l+1} = \mathrm{Norm}\big(x_l + F(x_l)\big).

Pre-NormPre-Norm (нормализация на входе подслоя)EN pre-normalization (Pre-LN)x + F(Norm(x)): нормализуется только вход ветки, остаточный путь чистый. Стандарт с GPT-2 (2019); требует финальной нормализации перед LM-головой., как в GPT-2, Llama, Qwen и DeepSeek:

xl+1=xl+F(Norm(xl)).x_{l+1} = x_l + F\big(\mathrm{Norm}(x_l)\big).

В Pre-Norm остаточный путь «чистый»: развернув все слои, получаем xL=x0+∑lFl(… )x_L = x_0 + \sum_l F_l(\dots). Производная xLx_L по xlx_l содержит слагаемое-единицу (тождество), и градиент доходит до нижних слоёв по прямой «трассе». В Post-Norm сигнал снизу проходит через нормализацию на каждом слое, и каждая его перемасштабирует.

Сюн и соавторы (arXiv:2002.04745, 2020) доказали поучительную вещь. При инициализации градиент весов последних слоёв Post-Norm не зависит от глубины, а у Pre-Norm он сам уменьшается как 1/L1/\sqrt{L}, где LL — число слоёв: в 4 раза больше слоёв — градиент вдвое меньше. Проверим. Код ниже собирает сети из 6, 24 и 96 одинаковых блоков, делает один обратный проход и печатает размер градиента последнего блока; подробности кода не важны, важны три строки вывода:

import torch
import torch.nn as nn
d = 64
class Block(nn.Module):
"""MLP-блок с остаточной связью: mode='post' — LN(x + F(x)), mode='pre' — x + F(LN(x))."""
def __init__(self, mode):
super().__init__()
self.mode = mode
self.norm = nn.LayerNorm(d)
self.f = nn.Sequential(nn.Linear(d, 4 * d), nn.ReLU(), nn.Linear(4 * d, d))
nn.init.kaiming_normal_(self.f[0].weight, nonlinearity="relu")
nn.init.kaiming_normal_(self.f[2].weight, nonlinearity="linear")
def forward(self, x):
if self.mode == "post":
return self.norm(x + self.f(x)) # Post-Norm: нормируем сумму
return x + self.f(self.norm(x)) # Pre-Norm: нормируем только вход ветки
def last_block_grad(mode, depth):
"""Норма градиента по первой матрице ПОСЛЕДНЕГО блока при инициализации."""
torch.manual_seed(0)
blocks = nn.Sequential(*[Block(mode) for _ in range(depth)]) # depth блоков подряд
final = nn.LayerNorm(d) if mode == "pre" else nn.Identity() # Pre-Norm требует финальной нормы; Identity — «ничего не делать»
x, r = torch.randn(256, d), torch.randn(256, d)
(final(blocks(x)) * r).mean().backward() # простой «loss»: случайная взвешенная сумма выходов
return blocks[-1].f[0].weight.grad.norm().item()
for depth in (6, 24, 96):
post, pre = last_block_grad("post", depth), last_block_grad("pre", depth)
print(f"блоков {depth:2d}: Post-Norm {post:.4f} Pre-Norm {pre:.4f}")
блоков 6: Post-Norm 0.0305 Pre-Norm 0.0178
блоков 24: Post-Norm 0.0320 Pre-Norm 0.0096
блоков 96: Post-Norm 0.0288 Pre-Norm 0.0040

Глубина выросла в 16 раз (с 6 до 96), и градиент последнего блока Pre-Norm уменьшился в 0.0178/0.0040≈4.50.0178 / 0.0040 \approx 4.5 раза — почти ровно 16=4\sqrt{16} = 4. У Post-Norm он стоит на месте. Почему это плохо для Post-Norm? Интуиция такая: изменения всех слоёв за один шаг складываются, и чем глубже сеть, тем сильнее общий шаг меняет её выход. Pre-Norm сам «притормаживает» верхние слои пропорционально глубине, а Post-Norm — нет. На практике это значит: в Post-Norm верхние слои глубокой сети с первого шага получают слишком крупные обновления, и с обычным learning rate обучение разваливается. Спасает разогрев (warmup) — тысячи шагов с маленьким learning rate в начале — или особые приёмы вроде DeepNorm (Microsoft Research Asia, 2022), который усиливает остаточный путь и уменьшает начальные веса веток; его вы попробуете в домашней работе модуля. Pre-Norm сам гасит обновления верхних слоёв и обучается стабильно без всяких ухищрений. Это главная причина, почему с 2019 года почти все LLM — Pre-Norm.

У Pre-Norm две особенности:

  • Финальная нормализация. Поток xx внутри модели никто не нормирует, поэтому после последнего блока ставят ещё одну норму, и только потом LM-голову (последний линейный слой, который превращает вектор в логиты токенов). В коде Qwen3 и DeepSeek-V3 это поле norm модели.
  • Растущий поток. Норма xLx_L растёт с глубиной, и вклад каждого следующего слоя в ней «размывается». Эксперимент с косинусом вход–выход — в уроке 4.5, а как с этим борются в 2026 году (mHC у DeepSeek, Attention Residuals у Kimi) — на ступени 9.

Реализуйте три схемы инициализации, «прогон» сигнала через глубокую сеть и две нормализации на NumPy. Тесты проверят std весов, сохранение сигнала с Kaiming и его гибель с Xavier и «маленькими» весами при ReLU, а также значения LayerNorm и RMSNorm на примерах из урока. Когда init_weights и activation_stds готовы, кнопка «Запустить» печатает ту самую таблицу из раздела про Kaiming — сверьте с ней свои числа.

Активации по слоям при разной инициализацииnumpy · нужен интернет7 тестов
import numpy as np


def init_weights(n_in, n_out, scheme, rng):
    """Возвращает матрицу весов формы (n_in, n_out) из нормального распределения N(0, std²).

    scheme — схема инициализации:
      'small'   — std = 0.01 (наивные «маленькие случайные числа»);
      'xavier'  — std = sqrt(2 / (n_in + n_out))   (Glorot, 2010);
      'kaiming' — std = sqrt(2 / n_in)             (He, 2015, под ReLU).
    Для любой другой строки — raise ValueError.
    rng — генератор np.random.default_rng(...): берите rng.standard_normal((n_in, n_out)).
    """
    # TODO: выберите std по схеме и верните rng.standard_normal(...) * std
    ...


def activation_stds(x, weights, act):
    """Прогоняет x через слои: h = act(h @ W) для каждой W из списка weights.

    Возвращает список из len(weights) чисел — std активаций после каждого слоя.
    """
    # TODO: цикл по слоям, после каждого — stds.append(float(h.std()))
    ...


def layer_norm(x, eps=1e-5):
    """LayerNorm без обучаемых γ и β: по последней оси вычесть среднее
    и поделить на sqrt(дисперсия + eps). Подсказка: axis=-1, keepdims=True."""
    # TODO
    ...


def rms_norm(x, eps=1e-6):
    """RMSNorm без обучаемого веса: x / sqrt(mean(x²) + eps) по последней оси.
    Среднее НЕ вычитается."""
    # TODO
    ...


def relu(z):
    return np.maximum(z, 0.0)


# Эксперимент для кнопки «Запустить»: 30 слоёв ширины 256 с ReLU
rng = np.random.default_rng(0)
x = rng.standard_normal((200, 256))
for scheme in ("small", "xavier", "kaiming"):
    Ws = [init_weights(256, 256, scheme, rng) for _ in range(30)]
    s = activation_stds(x, Ws, relu)
    if s:
        print(f"{scheme:8s} слой 1: {s[0]:.3g}   слой 10: {s[9]:.3g}   слой 30: {s[29]:.3g}")
    else:
        print(f"{scheme:8s} — пока пусто: реализуйте init_weights и activation_stds")

Повторим эксперимент с обратным проходом: 30 слоёв Linear + ReLU, инициализация PyTorch по умолчанию против Kaiming.

import torch
import torch.nn as nn
torch.manual_seed(0)
# 1) Какое std даёт nn.Linear «из коробки»?
lin = nn.Linear(512, 512)
print(f"std весов nn.Linear(512, 512): {lin.weight.std():.4f}") # ≈ 1/sqrt(3·512)
print(f"Kaiming для ReLU хочет: {(2 / 512) ** 0.5:.4f}")
# 2) Глубокая сеть: 30 слоёв Linear + ReLU. Смотрим градиенты по слоям.
def deep_mlp(init, depth=30, d=256):
layers = []
for _ in range(depth):
lin = nn.Linear(d, d)
if init == "kaiming":
nn.init.kaiming_normal_(lin.weight, nonlinearity="relu") # std = sqrt(2 / d)
nn.init.zeros_(lin.bias)
layers += [lin, nn.ReLU()]
return nn.Sequential(*layers)
x = torch.randn(512, 256)
r = torch.randn(512, 256) # фиксированное «направление» loss, одинаковое для всех сетей
for init in ("default", "kaiming"):
torch.manual_seed(1)
net = deep_mlp(init)
loss = (net(x) * r).mean()
loss.backward()
g = [m.weight.grad.norm().item() for m in net if isinstance(m, nn.Linear)]
print(f"{init:8s} ‖grad‖: слой 1 = {g[0]:.1e}, слой 15 = {g[14]:.1e}, слой 30 = {g[29]:.1e}")
std весов nn.Linear(512, 512): 0.0255
Kaiming для ReLU хочет: 0.0625
default ‖grad‖: слой 1 = 1.7e-13, слой 15 = 1.1e-09, слой 30 = 9.0e-04
kaiming ‖grad‖: слой 1 = 3.1e-02, слой 15 = 2.7e-02, слой 30 = 2.5e-02

С инициализацией по умолчанию градиент первого слоя примерно в 5⋅1095 \cdot 10^{9} раз меньше, чем у последнего: первые слои фактически заморожены. С Kaiming градиенты всех слоёв одного порядка. Такие графики «норма градиента по слоям» — первое, что стоит построить, если глубокая сеть не учится (см. урок об отладке).

Проверь себя6 вопросов
1 В каждом из 30 слоёв производная сигмоиды не больше 0.25. Во сколько раз (как минимум) уменьшится градиент от верха до первого слоя только из-за активаций?
2 Слой с ReLU имеет nin=1024n_{in} = 1024 входа. Какое std весов даёт Kaiming-инициализация? (до трёх знаков)
3 Какие утверждения верны?Можно выбрать несколько ответов
4 Чему равна первая компонента RMSNorm(6,8)\mathrm{RMSNorm}(6, 8) при g=1g = 1 и ε≈0\varepsilon \approx 0? (до двух знаков)
5 Какая формула описывает блок Pre-Norm?
6 Как называется нормализация, которая стоит в Llama, Qwen, DeepSeek и MiniMind?
  • Градиент первых слоёв — произведение множителей всех слоёв выше. Если множитель отличается от единицы, градиенты затухают или взрываются экспоненциально по глубине.
  • Инициализация выбирает дисперсию весов так, чтобы сигнал сохранял масштаб: Xavier 2/(nin+nout)2/(n_{in}+n_{out}) для tanh, Kaiming 2/nin2/n_{in} для ReLU. nn.Linear по умолчанию не Kaiming.
  • Нормализация держит масштаб на протяжении всего обучения. BatchNorm нормирует признак по батчу (CNN), LayerNorm и RMSNorm — токен по признакам. В LLM стоит RMSNorm: дешевле и не хуже.
  • Pre-Norm x+F(Norm(x))x + F(\mathrm{Norm}(x)) оставляет остаточный путь чистым и обучается без капризов, поэтому его выбирают почти все современные LLM. Цена — финальная норма и растущий поток.

Дальше: остаточные связи. Почему 56-слойная сеть может учиться хуже 20-слойной, как ResNet из пекинского MSRA это исправил, почему остаточный поток — «шина» трансформера и как бороться с переобучением с помощью dropout и weight decay.