Инициализация весов нейронной сети

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

Почему начальные значения важны

Градиентный спуск начинает с какой-то точки и идёт вниз. От выбора этой точки зависит, начнётся ли обучение вообще, как быстро оно пойдёт и куда придёт. Для глубоких сетей неудачная инициализация — частая причина того, что «ничего не учится».

Нули: проблема симметрии

Если все веса скрытого слоя одинаковы (например, нули), то все нейроны слоя вычисляют одно и то же, получают одинаковые градиенты и обновляются одинаково. Они так и останутся копиями друг друга — слой из 100 нейронов будет работать как один.

import numpy as np
import nanotorch as nt

X, y = nt.make_moons(200, noise=0.1, seed=0)
model = nt.Sequential(nt.Linear(2, 5), nt.Tanh(), nt.Linear(5, 2))
for p in model.parameters():
    p.data[...] = 0.1                       # все веса одинаковые
opt = nt.SGD(model.parameters(), lr=0.5)
for _ in range(200):
    loss = nt.cross_entropy(model(nt.Tensor(X)), y)
    opt.zero_grad(); loss.backward(); opt.step()
print("веса скрытого слоя после обучения (столбцы — нейроны):")
print(model[0].W.data.round(3))
print("точность:", nt.accuracy(model(nt.Tensor(X)), y))

Все пять столбцов одинаковы. Поэтому веса инициализируют случайно — это «разбивает симметрию». Сдвиги обычно можно начинать с нуля: симметрию уже разбили веса.

Масштаб: затухание и взрыв

Случайно — но с каким разбросом? Посмотрим, что происходит с сигналом, проходящим через 10 слоёв tanh, при разных стандартных отклонениях весов:

import numpy as np
import vis

rng = np.random.default_rng(0)
X = rng.normal(size=(500, 128))
curves = {}
for std in (0.01, 0.05, 0.088, 0.3):
    h, stds = X, []
    for _ in range(10):
        h = np.tanh(h @ rng.normal(0, std, (128, 128)))
        stds.append(h.std())
    curves[f"std весов = {std}"] = stds
    print(f"std = {std:<5}: разброс активаций по слоям", np.round(stds[::3], 3))
vis.line(np.arange(1, 11), curves, title="Разброс активаций в сети из 10 слоёв tanh", xlabel="слой", ylabel="std активаций")
  • Маленькие веса (0.01): каждый слой умножает сигнал на число меньше 1 — к последнему слою он почти ноль. Градиенты на обратном пути затухают точно так же — первые слои не учатся.
  • Большие веса (0.3): входы tanh огромные, нейроны насыщаются — выдают ±1, где производная почти ноль. Для ReLU большие веса приводят к взрыву — активации растут экспоненциально с глубиной.
  • Подходящий масштаб (\approx 1/\sqrt{128} \approx 0.088) держит разброс в разумных пределах: он уменьшается медленно, потому что tanh немного сжимает значения.

Правило сохранения дисперсии

Найдём «подходящий масштаб». Выход нейрона z = \sum_{i=1}^{n} w_i x_i. Пусть веса и входы независимы, со средним 0, \text{Var}(w_i) = v, \text{Var}(x_i) = 1. Дисперсия суммы независимых величин — сумма дисперсий:

\text{Var}(z) = \sum_{i=1}^{n} \text{Var}(w_i)\text{Var}(x_i) = n \cdot v

Чтобы сигнал не рос и не затухал, нужно \text{Var}(z) = \text{Var}(x), то есть

v = \frac{1}{n_{in}} \quad \text{— инициализация LeCun}

Уточнения для разных активаций:

Название Дисперсия весов Для чего
LeCun 1 / n_{in} линейные слои, SELU
Xavier (Glorot), 2010 2 / (n_{in} + n_{out}) tanh, сигмоида: компромисс между сохранением дисперсии на прямом (n_{in}) и обратном (n_{out}) проходе
He (Kaiming), 2015 2 / n_{in} ReLU и родственники: ReLU обнуляет половину значений, и дисперсия падает вдвое — компенсируем множителем 2

В nanotorch это параметр init у Linear: "kaiming" (по умолчанию), "xavier", "lecun" или число — стандартное отклонение. В PyTorch похожие функции лежат в torch.nn.init.

import numpy as np
import vis

rng = np.random.default_rng(1)
X = rng.normal(size=(200, 128))
relu = lambda z: np.maximum(0, z)
curves = {}
for name, std in [("LeCun 1/n", np.sqrt(1 / 128)), ("He 2/n", np.sqrt(2 / 128)), ("std = 0.2", 0.2)]:
    h, stds = X, []
    for _ in range(30):
        h = relu(h @ rng.normal(0, std, (128, 128)))
        stds.append(h.std())
    curves[name] = stds
vis.line(np.arange(1, 31), curves, title="Сеть из 30 слоёв ReLU", xlabel="слой", ylabel="std активаций", logy=True)

С He-инициализацией разброс держится на одном уровне все 30 слоёв, с LeCun — уменьшается примерно в \sqrt 2 раз на каждом слое, с 0.2 — взрывается.

Влияние на обучение

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

X, y = ml_data.load_digits()
X = (X - X.mean(0)) / (X.std(0) + 1e-8)

curves = {}
for init in ("kaiming", 0.01, 0.5):
    nt.manual_seed(0)
    layers, d = [], 64
    for _ in range(6):
        layers += [nt.Linear(d, 64, init=init), nt.ReLU()]
        d = 64
    model = nt.Sequential(*layers, nt.Linear(64, 10, init=init))
    h = nt.fit(model, X, y, nt.cross_entropy, nt.SGD(model.parameters(), lr=0.01), epochs=15, batch_size=32, verbose=False)
    loss = [min(v, 10.0) if np.isfinite(v) else 10.0 for v in h["loss"]]   # nan и огромные значения рисуем как 10
    curves[f"init = {init}"] = loss
    last = h["loss"][-1]
    print(f"init = {init!s:8}: loss после 15 эпох = " + (f"{last:.3f}" if np.isfinite(last) else "nan — обучение разошлось"))
vis.line(curves, title="Сеть из 6 слоёв ReLU с разной инициализацией", xlabel="эпоха", ylabel="кросс-энтропия")

С маленькими весами сеть стоит на месте (потери ≈ ln 10: сигнал и градиент не проходят через слои), с большими — активации взрываются и обучение расходится, с He — быстро учится.

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

До 2015 года глубокие сети было очень трудно обучать именно из-за затухания и взрыва сигнала. Помимо правильной инициализации, проблему решили нормализация (BatchNorm, LayerNorm — следующие уроки) и остаточные связи (residual connections), которые мы увидим в трансформере.

Итог

  • Одинаковые веса — проблема симметрии: нейроны остаются копиями. Веса инициализируют случайно, сдвиги — нулями.
  • Слишком маленький масштаб — сигнал и градиенты затухают; слишком большой — насыщение или взрыв.
  • Дисперсия выхода слоя: n_{in} \cdot \text{Var}(w). Чтобы она сохранялась: LeCun 1/n_{in}, Xavier 2/(n_{in} + n_{out}) для tanh, He 2/n_{in} для ReLU.
  • Правильная инициализация — первое, что проверять, если глубокая сеть не учится.

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

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

Все веса и сдвиги MLP инициализировали нулями. Что будет при обучении?

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

Линейный слой с n входами, входы с дисперсией 1, веса независимы с дисперсией v. Какова дисперсия выхода (без сдвига)?

Задание 3 Заполнить таблицу

Какая инициализация подходит?

He (Kaiming): 2/n_inXavier (Glorot): 2/(n_in + n_out)
Слои с ReLU
Слои с tanh
Слои с Leaky ReLU, GELU
Слои с сигмоидой
Отметьте ячейку — «+», пустая — «−».
Задание 4 Программа на Python

Напишите функции инициализации, возвращающие матрицу (n_in, n_out) из нормального распределения со средним 0 (используйте переданный генератор rng, метод rng.normal(0, std, size)):

  • he_init(n_in, n_out, rng) — дисперсия 2 / n_{in};
  • xavier_init(n_in, n_out, rng) — дисперсия 2 / (n_{in} + n_{out}).

Помните: normal принимает стандартное отклонение — корень из дисперсии.

Какие тесты проверяют код (1)
rng = np.random.default_rng(0)
W = he_init(400, 300, rng)
assert W.shape == (400, 300)
assert abs(W.var() - 2 / 400) / (2 / 400) < 0.05, f"Дисперсия {W.var():.5f}, ожидалась {2 / 400:.5f} — не забыли корень?"
W = xavier_init(400, 600, rng)
assert abs(W.var() - 2 / 1000) / (2 / 1000) < 0.05, f"Дисперсия {W.var():.5f}, ожидалась 0.002"
assert abs(W.mean()) < 0.001

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

Сигнал сквозь глубокую сеть. Напишите activation_stds(X, weights, act): пропустите X через слои h = act(h @ W) для каждой матрицы из списка weights (сдвигов нет) и верните список стандартных отклонений h.std() после каждого слоя (обычные float).

Тест сравнит инициализации на сети из 20 слоёв ReLU: при He-инициализации разброс должен остаться в разумных пределах, при std = 0.01 — исчезнуть.

Какие тесты проверяют код (1)
rng = np.random.default_rng(0)
X = rng.normal(size=(500, 256))
relu = lambda z: np.maximum(0, z)
he = [rng.normal(0, np.sqrt(2 / 256), (256, 256)) for _ in range(20)]
tiny = [rng.normal(0, 0.01, (256, 256)) for _ in range(20)]
s_he, s_tiny = activation_stds(X, he, relu), activation_stds(X, tiny, relu)
assert len(s_he) == 20 and all(isinstance(v, float) for v in s_he)
assert 0.3 < s_he[-1] < 3, f"He: разброс после 20 слоёв {s_he[-1]:.3g} — должен сохраниться"
assert s_tiny[-1] < 1e-10, f"std = 0.01: разброс должен исчезнуть, получено {s_tiny[-1]:.3g}"

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