BatchNorm

⏱ 40 мин · 🧩 заданий: 5

Проблема

Каждый слой сети учится на выходах предыдущего. Но предыдущий слой тоже учится — и распределение его выходов постоянно меняется: сдвигается среднее, растёт или падает разброс. Следующему слою приходится подстраиваться под «движущуюся цель». Кроме того, если активации какого-то слоя оказались слишком большими или маленькими, мы получаем знакомые проблемы — насыщение, затухание, взрыв.

Нормализация входов модели (стандартизация признаков) помогает первому слою. Идея BatchNorm (Иоффе и Сегеди, 2015): давайте нормализовать входы каждого слоя — прямо внутри сети, на каждом шаге обучения.

Алгоритм

Для батча из N объектов и каждого признака (нейрона) отдельно:

\mu = \frac{1}{N}\sum_{i=1}^N x_i, \qquad \sigma^2 = \frac{1}{N}\sum_{i=1}^N (x_i - \mu)^2
\hat x_i = \frac{x_i - \mu}{\sqrt{\sigma^2 + \varepsilon}}, \qquad y_i = \gamma\,\hat x_i + \beta
  1. Считаем среднее и дисперсию признака по объектам батча.
  2. Нормализуем: теперь у признака среднее 0 и дисперсия 1 (\varepsilon \approx 10^{-5} защищает от деления на ноль).
  3. Умножаем на обучаемый масштаб \gamma и прибавляем обучаемый сдвиг \beta.

Третий шаг важен: жёстко зафиксировать среднее 0 и дисперсию 1 — лишнее ограничение (например, для сигмоиды это означало бы работу только в её почти линейной середине). С \gamma и \beta сеть сама выбирает удобный масштаб — но теперь он задаётся двумя явными параметрами, а не случайно складывается из всех весов предыдущих слоёв.

признак 1 признак 2 … признак D объект 1 · · · объект 2 · · · BatchNorm: среднее и дисперсия … ↓ ↓ ↓ по столбцу (по батчу) объект N · · · μ₁,σ₁ μ₂,σ₂ μ_D,σ_D
import numpy as np
import nanotorch as nt

rng = np.random.default_rng(0)
x = nt.tensor(rng.normal([10, -3, 0.5], [5, 0.1, 2], size=(256, 3)))
print("до:    среднее", x.data.mean(0).round(2), " std", x.data.std(0).round(2))

bn = nt.BatchNorm1d(3)
y = bn(x)
print("после: среднее", y.data.mean(0).round(2), " std", y.data.std(0).round(2))
print("обучаемые параметры:", [name for name, _ in bn.named_parameters()], " всего:", bn.num_params())

Обучение и применение

Во время обучения статистики считаются по текущему батчу. Но при применении модели:

  • объект может быть один — статистику по батчу не посчитать;
  • ответ модели для объекта не должен зависеть от того, с какими соседями он попал в батч.

Поэтому во время обучения BatchNorm копит скользящие средние \mu и \sigma^2:

\mu_{run} \leftarrow (1 - m)\,\mu_{run} + m\,\mu_{batch}, \qquad m \approx 0.1

а в режиме применения использует их. Переключение — model.train() и model.eval().

import numpy as np
import nanotorch as nt

rng = np.random.default_rng(0)
bn = nt.BatchNorm1d(1)
for step in range(100):
    bn(nt.tensor(rng.normal(7, 2, size=(32, 1))))        # обучение: копим статистики
print("накопленные: mean =", bn.running_mean.round(2), " var =", bn.running_var.round(2))

bn.eval()
print("применение к одному объекту 9.0 →", bn(nt.tensor([[9.0]])).data.round(3), "(≈ (9 − 7) / 2)")
bn.train()

Классическая ошибка

Забыть вызвать model.eval() перед проверкой или применением модели. Тогда BatchNorm будет нормализовать по тестовому батчу, а Dropout — выключать нейроны, и результаты окажутся неверными и случайными.

Где ставить

Обычно BatchNorm ставят после линейного (или свёрточного) слоя и перед активацией:

\text{Linear} \to \text{BatchNorm} \to \text{ReLU} \to \text{Linear} \to \text{BatchNorm} \to \text{ReLU} \to \dots

Сдвиг b у линейного слоя перед BatchNorm бесполезен (его всё равно вычтет нормализация, а роль сдвига играет \beta), поэтому его часто отключают: Linear(..., bias=False).

Что даёт BatchNorm

  • Можно брать больший шаг обучения: масштаб активаций больше не зависит от масштаба весов, и большие шаги не приводят к взрыву.
  • Меньше зависимость от инициализации.
  • Быстрее сходимость — часто в разы меньше эпох.
  • Небольшая регуляризация: статистики батча немного шумят, и это мешает переобучению.

Почему именно BatchNorm так хорошо работает — до сих пор предмет исследований. Исходное объяснение («уменьшение внутреннего сдвига распределений») оказалось неполным; сейчас считается, что главное — он делает поверхность потерь более гладкой, и градиентный спуск ведёт себя предсказуемее.

import numpy as np
import nanotorch as nt
import ml_data
import vis

X, y = ml_data.load_digits()
X = X / 16.0
X_train, X_test, y_train, y_test = nt.train_test_split(X, y, test_size=0.25, seed=0)

def make(bn):
    layers, d = [], 64
    for _ in range(5):
        layers.append(nt.Linear(d, 64))
        if bn:
            layers.append(nt.BatchNorm1d(64))
        layers.append(nt.ReLU())
        d = 64
    return nt.Sequential(*layers, nt.Linear(64, 10))

curves = {}
for bn in (False, True):
    for lr in (0.05, 0.1):
        nt.manual_seed(0)
        model = make(bn)
        h = nt.fit(model, X_train, y_train, nt.cross_entropy, nt.SGD(model.parameters(), lr=lr),
                   epochs=12, batch_size=64, X_val=X_test, y_val=y_test, metric=nt.accuracy, verbose=False)
        name = f"{'с BatchNorm' if bn else 'без BN'}, lr = {lr}"
        curves[name] = [v if np.isfinite(v) else np.nan for v in h["val_metric"]]
        print(f"{name:22}: точность на тесте {h['val_metric'][-1]:.1%}")
vis.line(curves, title="Точность на тесте по эпохам (5 скрытых слоёв)", xlabel="эпоха")

С BatchNorm та же сеть с тем же шагом обучается быстрее и ровнее. Но чудес не бывает: при слишком большом шаге (попробуйте 0.3) разваливается и она — веса меняются так быстро, что накопленные скользящие средние не успевают за ними.

Недостатки

  • Зависимость от батча. При маленьком батче (2–8 объектов) статистики очень шумные, и качество падает. Это проблема для больших моделей, где в память помещается лишь несколько объектов.
  • Разное поведение при обучении и применении — источник ошибок.
  • Неудобен для последовательностей (тексты разной длины) и рекуррентных сетей.

Поэтому в трансформерах используют другую нормализацию — LayerNorm, которая считает статистики по признакам одного объекта. О ней — следующий урок.

Итог

  • BatchNorm нормализует каждый признак по объектам батча: \hat x = (x - \mu)/\sqrt{\sigma^2 + \varepsilon}, затем \gamma\hat x + \beta.
  • При обучении — статистики батча и накопление скользящих средних; при применении (eval()) — накопленные средние.
  • Ставят между линейным слоем и активацией; позволяет брать больший шаг, ускоряет и стабилизирует обучение.
  • Недостатки: зависимость от размера батча, разные режимы работы, неудобство для последовательностей.

Практика решено 0 из 5

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Один вариант ответа

По каким осям BatchNorm считает среднее и дисперсию для входа формы (N, D) (N объектов, D признаков)?

Задание 2 Один вариант ответа

Зачем после нормализации умножать на обучаемый γ и прибавлять β?

Задание 3 Один вариант ответа

Модель с BatchNorm в режиме применения (model.eval()) получила один объект. Какие статистики она использует?

Задание 4 Ввод ответа

Сколько обучаемых параметров у слоя BatchNorm1d(128)?

Задание 5 Программа на Python

BatchNorm своими руками. Напишите класс MyBatchNorm для входов (N, D) (на NumPy, без градиентов):

  • __init__(self, D, momentum=0.1, eps=1e-5): gamma — единицы, beta — нули, running_mean — нули, running_var — единицы, training = True;
  • forward(self, x): в режиме обучения нормализуйте по батчу (var — смещённая, x.var(axis=0)) и обновите running_mean = (1 - momentum) * running_mean + momentum * mean (так же running_var); в режиме применения (training = False) используйте накопленные значения. Результат: \gamma \cdot \frac{x - \mu}{\sqrt{\sigma^2 + \varepsilon}} + \beta.
Какие тесты проверяют код (3)
rng = np.random.default_rng(0)
bn = MyBatchNorm(3)
x = rng.normal(5, 3, size=(64, 3))
out = bn.forward(x)
assert np.allclose(out.mean(axis=0), 0, atol=1e-9), "В режиме обучения среднее каждого признака должно стать 0"
assert np.allclose(out.std(axis=0), 1, atol=1e-3), "…а стандартное отклонение — 1"
assert np.allclose(bn.running_mean, 0.1 * x.mean(0)), "running_mean после первого шага = 0.9·0 + 0.1·mean"

bn = MyBatchNorm(2)
bn.gamma = np.array([2.0, 1.0]); bn.beta = np.array([0.0, 10.0])
out = bn.forward(np.array([[1.0, 1], [3, 5]]))
assert np.allclose(out, [[-2, 9], [2, 11]], atol=1e-4), "Не забудьте γ и β"

bn = MyBatchNorm(1)
for _ in range(200):
    bn.forward(np.random.default_rng(1).normal(4, 2, size=(32, 1)))
bn.training = False
y = bn.forward(np.array([[4.0]]))
assert abs(y[0, 0]) < 0.3, "В режиме применения используются накопленные статистики (среднее ≈ 4)"

Войдите, чтобы отмечать пройденные уроки.