Переобучение и борьба с ним
Запомнить или понять
Студент, выучивший ответы к билетам наизусть, сдаст экзамен по этим билетам на отлично — и провалится на любом новом вопросе. Модель может сделать то же самое: запомнить обучающие примеры вместе с их случайным шумом вместо того, чтобы уловить закономерность. Это переобучение (overfitting). Его противоположность — недообучение (underfitting): модель слишком проста, чтобы уловить даже основную закономерность.
| Ошибка на обучении | Ошибка на валидации | Что делать | |
|---|---|---|---|
| Недообучение | высокая | высокая (близка к обучающей) | модель побольше, обучать дольше, лучшие признаки |
| Переобучение | низкая | высокая (большой разрыв) | больше данных, регуляризация, меньше модель |
| Хорошо | низкая | низкая, близка к обучающей | — |
Смещение и разброс
Ошибку модели на новых данных можно разложить на три части:
- смещение (bias) — систематическая ошибка из-за слишком простой модели (прямая для параболы);
- разброс (variance) — чувствительность к конкретной обучающей выборке: возьмём другие данные — получим заметно другую модель;
- неустранимый шум в самих данных.
Простые модели — большое смещение и малый разброс, сложные — наоборот. Классическая картина: ошибка на валидации как функция сложности модели имеет форму буквы U. (У очень больших нейросетей картина интереснее — есть явление «двойного спуска», когда после пика ошибка снова падает; но на практике меры против переобучения всё равно нужны.)
Кривые обучения
Главный инструмент диагностики — графики потерь на обучении и валидации по эпохам. Возьмём всего 300 обучающих картинок цифр и сеть с двумя скрытыми слоями по 128 нейронов:
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)
nt.manual_seed(0)
model = nt.Sequential(nt.Linear(64, 128), nt.ReLU(), nt.Linear(128, 128), nt.ReLU(), 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)
vis.line({"обучение": h["loss"], "валидация": h["val_loss"]}, title="Потери: переобучение", xlabel="эпоха", logy=True)
best = int(np.argmin(h["val_loss"]))
print(f"точность на обучении {h['metric'][-1]:.1%}")
print(f"на валидации: на эпохе {best} (минимум потерь) {h['val_metric'][best]:.1%}, в конце {h['val_metric'][-1]:.1%}")
print(f"валидационная потеря: минимум {h['val_loss'][best]:.3f}, в конце {h['val_loss'][-1]:.3f}")
Чтобы эффект был нагляднее, мы взяли всего 300 примеров и у четверти из них заменили метку на случайную — в реальных данных ошибки разметки тоже встречаются. Сеть всё равно добивается 100% точности на обучении: она заучила даже неправильные метки. Потери на обучении падают почти до нуля, а на валидации после нескольких эпох начинают расти, и точность на новых данных падает: подгонка под шум испортила то общее, что сеть успела выучить вначале.
Как бороться
1. Больше данных
Самое надёжное средство. Чем больше примеров, тем труднее их «запомнить» и тем больше модель вынуждена искать общие закономерности.
import numpy as np
import nanotorch as nt
import ml_data
import vis
X, y = ml_data.load_digits()
X = X / 16.0
X_pool, X_val, y_pool, y_val = nt.train_test_split(X, y, test_size=0.4, seed=0)
sizes, train_acc, val_acc = [50, 100, 200, 400, 800, len(X_pool)], [], []
for n in sizes:
nt.manual_seed(0)
model = nt.Sequential(nt.Linear(64, 128), nt.ReLU(), nt.Linear(128, 10))
h = nt.fit(model, X_pool[:n], y_pool[:n], nt.cross_entropy, nt.Adam(model.parameters(), lr=0.003),
epochs=max(10, 6000 // n), batch_size=32, X_val=X_val, y_val=y_val, metric=nt.accuracy, verbose=False)
train_acc.append(h["metric"][-1]); val_acc.append(h["val_metric"][-1])
vis.line(sizes, {"обучение": train_acc, "валидация": val_acc}, title="Точность в зависимости от размера обучающей выборки", xlabel="число примеров")
Когда данных не хватает, их «размножают» аугментацией: сдвиги, повороты, отражения картинок, замены слов синонимами в текстах. Модель видит новые варианты и учится инвариантности к ним.
2. Ранняя остановка
Раз валидационные потери в какой-то момент начинают расти — остановимся там, где они были минимальны, и вернём веса с той эпохи. Чтобы не реагировать на случайные колебания, ждут несколько эпох без улучшения (patience). Просто и очень эффективно.
3. Регуляризация весов
Добавим к потерям штраф за большие веса:
- L2 (weight decay): градиент получает добавку \lambda w, и шаг становится w \leftarrow (1 - \eta\lambda)w - \eta g — веса затухают, если данные их не поддерживают. Модель с маленькими весами — более гладкая функция, она не может «изгибаться» под каждую точку.
- L1: добавка \lambda\,\text{sign}(w) тянет веса к нулю с постоянной силой и делает многие из них ровно нулевыми — отбор признаков.
В nanotorch L2 включается параметром оптимизатора: nt.SGD(..., weight_decay=1e-3), nt.AdamW(..., weight_decay=0.01).
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 wd in (0.0, 1.0, 3.0):
nt.manual_seed(0)
model = nt.Sequential(nt.Linear(64, 128), nt.ReLU(), nt.Linear(128, 128), nt.ReLU(), nt.Linear(128, 10))
h = nt.fit(model, X_train, y_train, nt.cross_entropy, nt.AdamW(model.parameters(), lr=0.003, weight_decay=wd),
epochs=80, batch_size=32, X_val=X_val, y_val=y_val, metric=nt.accuracy, verbose=False)
curves[f"weight_decay = {wd}"] = h["val_loss"]
norm = np.sqrt(sum((p.data ** 2).sum() for p in model.parameters()))
print(f"weight_decay = {wd}: обучение {h['metric'][-1]:.1%}, валидация {h['val_metric'][-1]:.1%}, норма весов {norm:.1f}")
vis.line(curves, title="Потери на валидации", xlabel="эпоха", logy=True)
Чем сильнее затухание, тем меньше норма весов и тем слабее рост потерь на валидации. При weight_decay = 3 сеть уже не может заучить испорченные метки: точность на обучении около 75% — почти ровно доля правильных меток (75% неиспорченных плюс случайные совпадения), зато на валидации точность заметно выше, чем без регуляризации. Но меру знать нужно: при слишком сильном затухании модель недообучится. (В AdamW затухание за шаг равно lr · weight_decay, поэтому значения здесь больше, чем привычные 0.01 для обычных задач.)
4. Уменьшить модель
Меньше параметров — меньше возможностей запомнить шум. Но слишком маленькая модель недообучится. На практике чаще берут модель «с запасом» и регуляризуют её.
5. Dropout
Случайно выключать нейроны во время обучения — одно из самых популярных средств для нейросетей. Ему посвящён следующий урок.
6. Ансамбли
Обучить несколько моделей (с разной инициализацией или на разных подвыборках) и усреднить их ответы. Ошибки разных моделей частично независимы и усреднением гасятся — разброс уменьшается.
Кросс-валидация
Когда данных мало, одна валидационная выборка даёт шумную оценку: повезло или нет с разбиением. K-fold кросс-валидация: делим данные на k частей, k раз обучаем модель на k - 1 частях и проверяем на оставшейся, затем усредняем. Получаем более надёжную оценку (и её разброс), ценой k обучений. Для больших нейросетей так делают редко — слишком дорого, но для небольших моделей и данных это стандарт.
Итог
- Переобучение — модель запоминает обучающие данные: низкая ошибка на обучении, высокая на валидации. Недообучение — высокие обе.
- Диагностика — кривые обучения: растущая валидационная ошибка при падающей обучающей — сигнал переобучения.
- Средства: больше данных и аугментация, ранняя остановка, L2/L1-регуляризация, меньшая модель, Dropout, ансамбли.
- L2: w \leftarrow (1 - \eta\lambda)w - \eta g; L1 зануляет веса.
- Кросс-валидация — честная оценка на маленьких данных.
Практика решено 0 из 6
Что происходит с моделью?
| недообучение | переобучение | всё хорошо | |
|---|---|---|---|
| Ошибка на обучении 30%, на валидации 31% | |||
| Ошибка на обучении 0.5%, на валидации 25% | |||
| Ошибка на обучении 2%, на валидации 3% | |||
| Потери на валидации сначала падали, а теперь растут, на обучении — продолжают падать |
Что помогает против переобучения?
Как регуляризация L2 меняет шаг градиентного спуска?
Ранняя остановка. Напишите early_stopping(val_losses, patience): по списку потерь на валидации
(по эпохам) верните пару (stop_epoch, best_epoch) — номер эпохи (с нуля), на которой обучение
остановится, и эпоху с лучшей потерей до остановки. Обучение останавливается, когда потеря
patience эпох подряд не улучшала лучший результат. Если этого не случилось, stop_epoch —
последняя эпоха.
Какие тесты проверяют код (3)
assert early_stopping([1.0, 0.8, 0.7, 0.72, 0.75, 0.71, 0.9], patience=3) == (5, 2) assert early_stopping([1.0, 0.9, 0.8, 0.7], patience=2) == (3, 3), "Если улучшение всё время есть — до конца" assert early_stopping([0.5, 0.6, 0.4, 0.45, 0.46], patience=2) == (4, 2)
def early_stopping(val_losses, patience):
best, best_epoch, waited = float("inf"), 0, 0
for epoch, loss in enumerate(val_losses):
if loss < best:
best, best_epoch, waited = loss, epoch, 0
else:
waited += 1
if waited >= patience:
return epoch, best_epoch
return len(val_losses) - 1, best_epoch
Шаг с L2-регуляризацией. Напишите l2_step(w, grad, lr, lam) — шаг градиентного спуска для
потерь L(w) + \frac{\lambda}{2}\|w\|^2 (верните новый массив) и l2_penalty(weights, lam) —
значение штрафа \frac{\lambda}{2}\sum \|W\|^2 по списку матриц весов (float).
Какие тесты проверяют код (3)
assert np.allclose(l2_step(np.array([1.0, -2.0]), np.array([0.0, 0.0]), 0.1, 0.5), [0.95, -1.9]), "Без градиента веса просто затухают в (1 − lr·λ) раз" assert np.allclose(l2_step(np.array([1.0]), np.array([2.0]), 0.1, 1.0), [0.7]) assert abs(l2_penalty([np.array([[1.0, 2]]), np.array([3.0])], 0.1) - 0.7) < 1e-12
import numpy as np
def l2_step(w, grad, lr, lam):
return w - lr * (grad + lam * w)
def l2_penalty(weights, lam):
return float(lam / 2 * sum((W ** 2).sum() for W in weights))
K-fold кросс-валидация. Напишите kfold_indices(n, k, seed=0): перемешайте индексы 0..n−1
(np.random.default_rng(seed).permutation(n)), разбейте на k почти равных частей
(np.array_split) и верните список из k пар (train_idx, val_idx): в i-й паре валидация — i-я
часть, обучение — все остальные.
Какие тесты проверяют код (1)
folds = kfold_indices(10, 3)
assert len(folds) == 3
assert sorted(np.concatenate([v for _, v in folds]).tolist()) == list(range(10)), "Каждый объект ровно один раз в валидации"
for tr, va in folds:
assert len(set(tr.tolist()) & set(va.tolist())) == 0 and len(tr) + len(va) == 10
assert sorted(len(v) for _, v in folds) == [3, 3, 4]
import numpy as np
def kfold_indices(n, k, seed=0):
parts = np.array_split(np.random.default_rng(seed).permutation(n), k)
return [(np.concatenate(parts[:i] + parts[i + 1:]), parts[i]) for i in range(k)]
Войдите, чтобы отмечать пройденные уроки.