Dropout

⏱ 30 мин · 🧩 заданий: 7

Идея

Dropout (Шривастава, Хинтон и др., 2014): на каждом шаге обучения каждый нейрон слоя независимо выключается с вероятностью p — его выход заменяется нулём. На следующем шаге выключаются другие, случайно выбранные нейроны.

шаг 1: ● ○ ● ● ○ ● ● — работает шаг 2: ○ ● ● ○ ● ● ○ — выключен (выход 0) шаг 3: ● ● ○ ● ● ○ применение: ● ● ● ● ● ● (работают все)

Звучит как порча собственной сети — зачем? Две интуиции:

  • Никаких «сговоров». Без dropout нейроны могут подстраиваться друг под друга: один исправляет ошибки другого, и вместе они запоминают конкретные обучающие примеры. Когда любой сосед в любой момент может пропасть, каждый нейрон вынужден учить признак, полезный сам по себе. Такие признаки лучше переносятся на новые данные.
  • Ансамбль. Каждая маска выключенных нейронов — это своя подсеть. Слой из 100 нейронов даёт 2^{100} подсетей, и все они обучаются одновременно, разделяя веса. При применении мы используем всю сеть — это приближённо усредняет предсказания всех подсетей. А ансамбли, как мы видели в прошлом уроке, переобучаются меньше.

Масштабирование: inverted dropout

Если при обучении в среднем работает доля 1 - p нейронов, то следующий слой привыкает к входу, который в среднем в 1 - p раз меньше, чем при применении (когда работают все). Чтобы масштаб совпадал, оставшиеся активации при обучении делят на 1 - p:

m_i = \frac{\mathbb{1}[u_i \ge p]}{1 - p}, \quad u_i \sim U(0, 1), \qquad y = x \odot m, \qquad \mathbb{E}[y_i] = (1 - p)\cdot\frac{x_i}{1 - p} = x_i

Это и есть 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.

Обратный проход

Слой — поэлементное умножение на константную маску, поэтому градиент течёт только через включённые нейроны и умножается на тот же множитель:

\frac{\partial L}{\partial x} = \frac{\partial L}{\partial y} \odot m

Выключенный нейрон на этом шаге не получает градиента — его входные веса не обновляются. Вот как это устроено в 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

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

В слое 1000 нейронов, dropout с p = 0.3. Сколько нейронов в среднем выключается на одном шаге обучения?

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

Dropout с p = 0.2 (inverted dropout). Что происходит во время обучения с активациями, которые не обнулились?

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

Что делает слой Dropout в каждом режиме?

traineval
Случайно обнуляет часть активаций
Возвращает вход без изменений
Масштабирует оставшиеся активации на 1/(1-p)
Даёт одинаковый ответ при повторном вызове на том же входе
Отметьте ячейку — «+», пустая — «−».
Задание 4 Несколько вариантов

Почему dropout уменьшает переобучение? Отметьте верные объяснения.

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

Вы обучили сеть с dropout и измеряете качество на тесте, забыв вызвать model.eval(). Что произойдёт?

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

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"

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

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), "Без маски градиент проходит как есть"

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