Инициализация весов нейронной сети
Почему начальные значения важны
Градиентный спуск начинает с какой-то точки и идёт вниз. От выбора этой точки зависит, начнётся ли обучение вообще, как быстро оно пойдёт и куда придёт. Для глубоких сетей неудачная инициализация — частая причина того, что «ничего не учится».
Нули: проблема симметрии
Если все веса скрытого слоя одинаковы (например, нули), то все нейроны слоя вычисляют одно и то же, получают одинаковые градиенты и обновляются одинаково. Они так и останутся копиями друг друга — слой из 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) = \text{Var}(x), то есть
Уточнения для разных активаций:
| Название | Дисперсия весов | Для чего |
|---|---|---|
| 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
Все веса и сдвиги MLP инициализировали нулями. Что будет при обучении?
Линейный слой с n входами, входы с дисперсией 1, веса независимы с дисперсией v. Какова дисперсия выхода (без сдвига)?
Какая инициализация подходит?
| He (Kaiming): 2/n_in | Xavier (Glorot): 2/(n_in + n_out) | |
|---|---|---|
| Слои с ReLU | ||
| Слои с tanh | ||
| Слои с Leaky ReLU, GELU | ||
| Слои с сигмоидой |
Напишите функции инициализации, возвращающие матрицу (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
import numpy as np
def he_init(n_in, n_out, rng):
return rng.normal(0, np.sqrt(2 / n_in), (n_in, n_out))
def xavier_init(n_in, n_out, rng):
return rng.normal(0, np.sqrt(2 / (n_in + n_out)), (n_in, n_out))
Сигнал сквозь глубокую сеть. Напишите 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}"
import numpy as np
def activation_stds(X, weights, act):
stds, h = [], X
for W in weights:
h = act(h @ W)
stds.append(float(h.std()))
return stds
Войдите, чтобы отмечать пройденные уроки.