Dropout
Идея
Dropout (Шривастава, Хинтон и др., 2014): на каждом шаге обучения каждый нейрон слоя независимо выключается с вероятностью p — его выход заменяется нулём. На следующем шаге выключаются другие, случайно выбранные нейроны.
Звучит как порча собственной сети — зачем? Две интуиции:
- Никаких «сговоров». Без dropout нейроны могут подстраиваться друг под друга: один исправляет ошибки другого, и вместе они запоминают конкретные обучающие примеры. Когда любой сосед в любой момент может пропасть, каждый нейрон вынужден учить признак, полезный сам по себе. Такие признаки лучше переносятся на новые данные.
- Ансамбль. Каждая маска выключенных нейронов — это своя подсеть. Слой из 100 нейронов даёт 2^{100} подсетей, и все они обучаются одновременно, разделяя веса. При применении мы используем всю сеть — это приближённо усредняет предсказания всех подсетей. А ансамбли, как мы видели в прошлом уроке, переобучаются меньше.
Масштабирование: inverted dropout
Если при обучении в среднем работает доля 1 - p нейронов, то следующий слой привыкает к входу, который в среднем в 1 - p раз меньше, чем при применении (когда работают все). Чтобы масштаб совпадал, оставшиеся активации при обучении делят на 1 - p:
Это и есть inverted dropout — его используют все современные библиотеки. При применении слой просто ничего не делает.
import numpy as np
import nanotorch as nt
nt.manual_seed(0)
drop = nt.Dropout(p=0.5)
x = nt.tensor(np.ones((2, 8)))
drop.train()
print("train:\n", drop(x).data)
print("ещё раз:\n", drop(x).data)
drop.eval()
print("eval:\n", drop(x).data)
big = nt.tensor(np.ones((1000, 1000)))
drop.train()
print("среднее в режиме train:", drop(big).data.mean().round(4))
При каждом вызове в режиме train выключаются разные нейроны, но среднее остаётся равным 1.
Обратный проход
Слой — поэлементное умножение на константную маску, поэтому градиент течёт только через включённые нейроны и умножается на тот же множитель:
Выключенный нейрон на этом шаге не получает градиента — его входные веса не обновляются. Вот как это устроено в nanotorch: умножение тензора на массив-маску, а градиент считает автодифференцирование.
import inspect
import nanotorch as nt
print(inspect.getsource(nt.Dropout))
Эксперимент: зашумлённые цифры
Возьмём ту же задачу, что и в прошлом уроке: 300 картинок цифр, у четверти которых испорчены метки. Без регуляризации сеть заучивает и шум. Добавим dropout после каждого скрытого слоя:
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_val, y_train, y_val = nt.train_test_split(X, y, test_size=0.5, seed=0)
X_train, y_train, X_val, y_val = X_train[:300], y_train[:300].copy(), X_val[:500], y_val[:500]
rng = np.random.default_rng(0)
noisy = rng.choice(300, 75, replace=False)
y_train[noisy] = rng.integers(0, 10, 75)
curves = {}
for p in (0.0, 0.3, 0.5):
nt.manual_seed(0)
model = nt.Sequential(nt.Linear(64, 128), nt.ReLU(), nt.Dropout(p),
nt.Linear(128, 128), nt.ReLU(), nt.Dropout(p),
nt.Linear(128, 10))
h = nt.fit(model, X_train, y_train, nt.cross_entropy, nt.Adam(model.parameters(), lr=0.003),
epochs=80, batch_size=32, X_val=X_val, y_val=y_val, metric=nt.accuracy, verbose=False)
curves[f"p = {p}"] = h["val_loss"]
print(f"dropout p = {p}: обучение {h['metric'][-1]:.1%}, валидация {h['val_metric'][-1]:.1%}")
vis.line(curves, title="Потери на валидации", xlabel="эпоха", logy=True)
С p = 0.5 сеть уже не успевает заучить испорченные метки: точность на обучении заметно ниже 100%, а на валидации — почти на 10 процентных пунктов выше, чем без dropout, и потери на валидации почти не растут к концу обучения. Цена — обучение идёт медленнее: каждому шагу достаётся лишь половина сети.
Не забудьте model.eval()
Самая частая ошибка с dropout — проверять модель в режиме обучения. Слой не знает, что вы делаете, он смотрит только на флаг training:
import numpy as np
import nanotorch as nt
import ml_data
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.3, seed=0)
nt.manual_seed(0)
model = nt.Sequential(nt.Linear(64, 128), nt.ReLU(), nt.Dropout(0.5), nt.Linear(128, 10))
nt.fit(model, X_train, y_train, nt.cross_entropy, nt.Adam(model.parameters(), lr=0.003),
epochs=15, batch_size=32, verbose=False)
Xt = nt.tensor(X_test)
model.eval()
print(f"eval: точность {nt.accuracy(model(Xt), y_test):.1%}")
model.train() # забыли переключить режим
for i in range(3):
print(f"train: точность {nt.accuracy(model(Xt), y_test):.1%} (запуск {i + 1})")
В режиме train ответы случайны: при каждом запуске разная точность, и она ниже. Функция fit из nanotorch сама переключает модель в eval() для валидации и обратно в train() для обучения; в собственном цикле обучения это ваша забота. То же правило касается BatchNorm.
Monte Carlo Dropout
У «ошибки» из предыдущего раздела есть полезное применение. Если оставить dropout включённым и прогнать один пример через сеть много раз, получится много разных предсказаний — как от ансамбля. Их среднее обычно близко к ответу в режиме eval, а разброс показывает, насколько сеть не уверена. Этот приём называется MC Dropout (Гал и Гахрамани, 2016).
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.3, seed=0)
nt.manual_seed(0)
model = nt.Sequential(nt.Linear(64, 128), nt.ReLU(), nt.Dropout(0.5), nt.Linear(128, 10))
nt.fit(model, X_train, y_train, nt.cross_entropy, nt.Adam(model.parameters(), lr=0.003),
epochs=15, batch_size=32, verbose=False)
model.train()
Xt = nt.tensor(X_test)
probs = np.stack([model(Xt).softmax(axis=1).data for _ in range(50)]) # 50 прогонов
mean = probs.mean(axis=0)
pred = mean.argmax(axis=1)
spread = probs.std(axis=0)[np.arange(len(pred)), pred] # разброс вероятности выбранного класса
model.eval()
order = np.argsort(spread)
print(f"точность усреднённого ансамбля: {(pred == y_test).mean():.1%}")
print(f"самые уверенные 100 примеров: точность {(pred[order[:100]] == y_test[order[:100]]).mean():.0%}")
print(f"самые неуверенные 100 примеров: точность {(pred[order[-100:]] == y_test[order[-100:]]).mean():.0%}")
vis.images(X_test[order[-8:]].reshape(-1, 8, 8), titles=[f"{p}?" for p in pred[order[-8:]]], cols=8)
Там, где разброс большой, сеть ошибается гораздо чаще — внизу картинки, на которых она сомневается больше всего. Такая оценка неуверенности полезна, например, чтобы отправлять сомнительные случаи на проверку человеку.
Практические советы
- Типичные значения: p = 0.5 для широких полносвязных слоёв, 0.1–0.3 — для остальных. Dropout на входном слое, если и ставят, то маленький.
- В свёрточных сетях dropout применяют реже (там хорошо работают BatchNorm и аугментация), обычно — перед последними полносвязными слоями.
- В трансформерах dropout стоит после внимания и после полносвязной части каждого блока, обычно p = 0.1. Самые большие языковые модели, обученные на огромных данных, часто обходятся без него вовсе: при таком объёме данных переобучение почти не грозит.
- С dropout нужно больше эпох обучения; иногда стоит немного увеличить ширину слоя.
Итог
- Dropout при обучении выключает каждый нейрон с вероятностью p; при применении работают все.
- Inverted dropout делит оставшиеся активации на 1 - p, чтобы среднее не менялось, — тогда в режиме eval слой тождественный.
- Градиент проходит только через включённые нейроны: \partial L/\partial x = \partial L/\partial y \odot m.
- Работает как обучение огромного ансамбля подсетей и не даёт нейронам «сговариваться».
- Не забывайте
model.eval()перед проверкой; MC Dropout превращает dropout в оценку неуверенности.
Практика решено 0 из 7
В слое 1000 нейронов, dropout с p = 0.3. Сколько нейронов в среднем выключается на одном шаге обучения?
Dropout с p = 0.2 (inverted dropout). Что происходит во время обучения с активациями, которые не обнулились?
Что делает слой Dropout в каждом режиме?
| train | eval | |
|---|---|---|
| Случайно обнуляет часть активаций | ||
| Возвращает вход без изменений | ||
| Масштабирует оставшиеся активации на 1/(1-p) | ||
| Даёт одинаковый ответ при повторном вызове на том же входе |
Почему dropout уменьшает переобучение? Отметьте верные объяснения.
Вы обучили сеть с dropout и измеряете качество на тесте, забыв вызвать model.eval(). Что произойдёт?
Dropout: прямой проход. Напишите dropout_forward(x, p, training, rng), которая возвращает пару (out, mask):
- если
trainingложно илиp == 0— вернуть(x, None); - иначе построить маску
mask = (rng.random(x.shape) >= p) / (1 - p)и вернуть(x * mask, mask).
rng — это np.random.default_rng(...).
Какие тесты проверяют код (3)
rng = np.random.default_rng(0) x = np.ones((200, 500)) out, mask = dropout_forward(x, 0.3, True, rng) assert out.shape == x.shape assert abs((out == 0).mean() - 0.3) < 0.01, "Должно обнуляться около 30% значений" assert np.allclose(np.unique(out), [0, 1 / 0.7]), "Оставшиеся значения масштабируются на 1/(1-p)" assert abs(out.mean() - 1) < 0.01, "Среднее выхода должно совпадать со средним входа" rng = np.random.default_rng(1) x = np.random.default_rng(2).normal(size=(4, 6)) out, mask = dropout_forward(x, 0.5, False, rng) assert out is x and mask is None, "В режиме eval вход возвращается без изменений, маска — None" out, mask = dropout_forward(x, 0.0, True, rng) assert out is x and mask is None, "При p = 0 тоже ничего не делаем" rng = np.random.default_rng(3) x = np.random.default_rng(4).normal(size=(5, 7)) out, mask = dropout_forward(x, 0.5, True, rng) assert np.allclose(out, x * mask), "out должен быть равен x * mask"
import numpy as np
def dropout_forward(x, p, training, rng):
if not training or p == 0:
return x, None
mask = (rng.random(x.shape) >= p) / (1 - p)
return x * mask, mask
Dropout: обратный проход. Выход y = x \odot m, где m — маска из прямого прохода (уже с множителем 1/(1-p)).
Напишите dropout_backward(dout, mask), которая возвращает градиент по входу \partial L / \partial x.
Если mask is None (режим eval или p == 0), слой был тождественным.
Какие тесты проверяют код (1)
mask = np.array([[0.0, 2.0], [2.0, 0.0]]) dout = np.array([[1.0, 3.0], [5.0, 7.0]]) assert np.allclose(dropout_backward(dout, mask), [[0, 6], [10, 0]]), "Градиент проходит только через включённые нейроны и умножается на тот же множитель" assert np.allclose(dropout_backward(dout, None), dout), "Без маски градиент проходит как есть"
import numpy as np
def dropout_backward(dout, mask):
if mask is None:
return dout
return dout * mask
Войдите, чтобы отмечать пройденные уроки.