Переобучение и борьба с ним

⏱ 45 мин · 🧩 заданий: 6

Запомнить или понять

Студент, выучивший ответы к билетам наизусть, сдаст экзамен по этим билетам на отлично — и провалится на любом новом вопросе. Модель может сделать то же самое: запомнить обучающие примеры вместе с их случайным шумом вместо того, чтобы уловить закономерность. Это переобучение (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. Регуляризация весов

Добавим к потерям штраф за большие веса:

L_{\text{рег}} = L + \frac{\lambda}{2}\sum w^2 \quad \text{(L2)}, \qquad L_{\text{рег}} = L + \lambda \sum |w| \quad \text{(L1)}
  • 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

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

Что происходит с моделью?

недообучениепереобучениевсё хорошо
Ошибка на обучении 30%, на валидации 31%
Ошибка на обучении 0.5%, на валидации 25%
Ошибка на обучении 2%, на валидации 3%
Потери на валидации сначала падали, а теперь растут, на обучении — продолжают падать
Отметьте ячейку — «+», пустая — «−».
Задание 2 Несколько вариантов

Что помогает против переобучения?

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

Как регуляризация L2 меняет шаг градиентного спуска?

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

Ранняя остановка. Напишите 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)
Задание 5 Программа на Python

Шаг с 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
Задание 6 Программа на Python

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]

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