Машинное обучение Переобучение, bias-variance и регуляризация
0%

Переобучение, bias-variance и регуляризация

Переобучение, bias-variance и регуляризация

Есть ровно одна ошибка, которая убивает больше ML-проектов, чем все остальные вместе: модель показала прекрасное качество на данных, которые видела, и посредственное — на тех, которые увидит завтра. Мы уже сталкивались с этим в каждой статье трека — у деревьев без ограничения глубины, у kNN при k = 1, у SVM с огромным C, у полиномиальной регрессии высокой степени. Настало время разобрать явление системно.

Эта статья отвечает на три вопроса:

  1. Что именно происходит при переобучении — с точки зрения статистики, а не интуиции.
  2. Как это увидеть и измерить — диагностика по кривым обучения и валидации.
  3. Что с этим делать — полная линейка регуляризаторов, от штрафа на веса до ранней остановки, аугментации и ансамблирования, с пониманием, какой рычаг когда работает.

Ключевая мысль, ради которой всё написано: регуляризация — это не «магический параметр, который нужно потюнить», а осознанное внесение смещения в модель ради снижения дисперсии. Вы намеренно делаете модель хуже на обучающей выборке, чтобы она стала лучше на генеральной совокупности. Понимание этого обмена отделяет инженера от человека, который перебирает alpha по сетке и надеется на лучшее.

Постановка: риск, эмпирический риск и разрыв обобщения

Формализуем. Есть неизвестное распределение $P(x, y)$, из которого приходят объекты. Есть функция потерь $L(y, \hat{y})$. Качество модели $f$ — это истинный риск (ожидаемая ошибка на новом объекте):

$$ R(f) = \mathbb{E}_ {(x,y) \sim P}\big[L(y, f(x))\big] $$

Мы не можем его вычислить: $P$ неизвестно. Вместо него есть выборка $D = {(x_i, y_i)}_ {i=1}^{n}$ и эмпирический риск:

$$ \hat{R}_ D(f) = \frac{1}{n}\sum_{i=1}^{n} L(y_i, f(x_i)) $$

Обучение — это минимизация $\hat{R}_ D$ по классу моделей $\mathcal{F}$ (принцип ERM, Empirical Risk Minimization). Проблема в том, что нас интересует $R$, а минимизируем мы $\hat{R}_ D$. Разница

$$ \underbrace{R(\hat{f})}_ {\text{что нас волнует}} = \underbrace{\hat{R}_ D(\hat{f})}_ {\text{что мы видим}} + \underbrace{\big[R(\hat{f}) - \hat{R}_ D(\hat{f})\big]}_ {\text{разрыв обобщения}} $$

называется разрывом обобщения (generalization gap). Переобучение — это ситуация, когда разрыв велик. И вот критически важный момент: разрыв растёт не от количества данных, которые модель увидела, а от количества «выборов», которые она сделала, глядя на эти данные.

Классическая оценка теории Вапника–Червоненкиса: с вероятностью $1 - \delta$

$$ R(f) \le \hat{R}_ D(f) + O!\left(\sqrt{\frac{h\left(\log \frac{2n}{h} + 1\right) + \log\frac{4}{\delta}}{n}}\right) $$

где $h$ — VC-размерность класса $\mathcal{F}$ (грубо — сколько точек класс может «раскрасить» произвольным образом). Отсюда практический вывод, который переживёт любую моду: разрыв растёт с ёмкостью модели и падает как $\sqrt{1/n}$ с ростом выборки. Хотите позволить себе более сложную модель — принесите больше данных.

Оговорка, без которой будет неправда: VC-оценки для современных нейросетей и бустингов вакуумны — там $h \gg n$, а модели тем не менее обобщаются. Почему — разбираем в разделе про двойной спуск. Но для табличных задач с сотнями и тысячами объектов эта интуиция работает буквально.

Разложение на смещение и дисперсию

Для квадратичной функции потерь ошибку можно разложить точно. Пусть данные порождены как $y = f(x) + \varepsilon$, где $\mathbb{E}[\varepsilon] = 0$, $\operatorname{Var}(\varepsilon) = \sigma^2$, а $\hat{f}_ D$ — модель, обученная на случайной выборке $D$. Зафиксируем точку $x_0$ и усредним по всем возможным выборкам $D$ и по шуму:

$$ \mathbb{E}_ {D,\varepsilon}\Big[\big(y_0 - \hat{f}_ D(x_0)\big)^2\Big] = \underbrace{\sigma^2}_ {\text{неустранимый шум}}

  • \underbrace{\Big(\mathbb{E}_ D[\hat{f}_ D(x_0)] - f(x_0)\Big)^2}_ {\text{смещение}^2}
  • \underbrace{\mathbb{E}_ D\Big[\big(\hat{f}_ D(x_0) - \mathbb{E}_ D[\hat{f}_ D(x_0)]\big)^2\Big]}_ {\text{дисперсия}} $$

Вывод занимает три строки: добавляем и вычитаем $\bar{f}(x_0) = \mathbb{E}_ D[\hat{f}_ D(x_0)]$, раскрываем квадрат, перекрёстные члены обнуляются, потому что $\varepsilon$ независим от $D$, а $\mathbb{E}_ D[\hat{f}_ D - \bar{f}] = 0$ по определению.

Что означает каждый член:

  • Шум $\sigma^2$ — то, что не объясняется признаками в принципе. Нижняя граница ошибки любой модели. Если два одинаковых клиента ведут себя по-разному, никакая модель это не разведёт. Оценивать $\sigma^2$ полезно: если ваш RMSE уже близок к нему, дальнейший тюнинг бессмыслен.
  • Смещение — систематическая ошибка класса моделей. Линейная модель на синусоиде промахнётся одинаково при любой выборке. Это недообучение (underfitting).
  • Дисперсия — чувствительность к конкретной выборке. Дерево глубины 30 на других 1000 объектах построит совсем другие правила. Это переобучение.

Смещение и дисперсия на мишенях

Что этот компромисс НЕ означает

Три распространённых заблуждения:

  1. «Разложение работает для любой метрики». Нет. Аддитивное разложение точно только для MSE. Для 0-1 loss аналога нет; существуют приближения (Domingos, 2000; Kohavi & Wolpert, 1996), но они не аддитивны, и там возможен эффект, когда рост дисперсии уменьшает ошибку классификации (если модель систематически ошибается, шум может «перебросить» ответ на правильную сторону границы). Для логистической потери — тоже не работает напрямую.
  2. «Смещение и дисперсию всегда нужно балансировать 50/50». Нет. Оптимум там, где сумма минимальна, и часто это точка с очень маленьким смещением и умеренной дисперсией (см. бустинг и глубокие сети).
  3. «Больше данных = меньше смещения». Нет. Данные снижают дисперсию, но не смещение: линейная модель на миллионе точек синусоиды останется прямой. Смещение снижается только сменой класса моделей или обогащением признаков.

Численный эксперимент: измеряем смещение и дисперсию руками

Разложение — не абстракция, его можно посчитать симуляцией. Генерируем много выборок из известного процесса, обучаем модель на каждой и смотрим на разброс предсказаний в фиксированных точках.

import numpy as np
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression

rng = np.random.default_rng(42)

TRUE_SIGMA = 0.35          # известный уровень шума
N_TRAIN = 30               # маленькая выборка — дисперсия будет заметна
N_DATASETS = 400           # число независимых обучающих выборок


def true_f(x):
    """Истинная зависимость, которую модель не знает."""
    return np.sin(2 * np.pi * x) + 0.4 * x


def sample_dataset(n):
    x = rng.uniform(0, 1, size=n)
    y = true_f(x) + rng.normal(0, TRUE_SIGMA, size=n)
    return x.reshape(-1, 1), y


# точки, в которых замеряем поведение модели
x_grid = np.linspace(0.02, 0.98, 120).reshape(-1, 1)
f_true = true_f(x_grid.ravel())

print(f"{'степень':>8} {'смещение²':>11} {'дисперсия':>11} {'шум':>8} {'сумма':>9}")
for degree in (1, 3, 5, 9, 15):
    # предсказания всех моделей во всех точках сетки
    preds = np.empty((N_DATASETS, len(x_grid)))
    for k in range(N_DATASETS):
        X, y = sample_dataset(N_TRAIN)
        model = make_pipeline(PolynomialFeatures(degree), LinearRegression())
        model.fit(X, y)
        preds[k] = model.predict(x_grid)

    mean_pred = preds.mean(axis=0)                     # E_D[f̂(x)]
    bias2 = np.mean((mean_pred - f_true) ** 2)         # смещение²
    variance = np.mean(preds.var(axis=0))              # дисперсия
    noise = TRUE_SIGMA ** 2                            # неустранимая часть
    print(f"{degree:>8} {bias2:>11.4f} {variance:>11.4f} {noise:>8.4f} "
          f"{bias2 + variance + noise:>9.4f}")

Типичный вывод (числа зависят от seed, но картина устойчива):

 степень   смещение²   дисперсия      шум     сумма
       1      0.2841      0.0102   0.1225    0.4168
       3      0.0169      0.0223   0.1225    0.1617
       5      0.0021      0.0398   0.1225    0.1644
       9      0.0009      0.1734   0.1225    0.2968
      15      0.0007      2.9106   0.1225    3.0338

Читайте таблицу как учебник: смещение падает монотонно (сложная модель может выразить больше), дисперсия растёт монотонно и взрывается после степени 9, а сумма имеет минимум около степени 3–5. Обратите внимание: степень 15 почти не имеет смещения — «в среднем» она права. Просто каждая конкретная обученная модель дико отклоняется. Это и есть переобучение в чистом виде.

Практический трюк из этого кода: если у вас есть возможность бутстрапировать обучающую выборку и посмотреть на разброс предсказаний ансамбля — вы получите прямую оценку дисперсии модели, не прибегая ни к какой теории. Разброс большой → нужна регуляризация или больше данных.

Диагностика: кривые обучения и кривые валидации

Прежде чем что-то лечить, нужно поставить диагноз. Есть два разных инструмента, которые часто путают.

Кривая валидации (validation curve) — ошибка на train и valid как функция гиперпараметра сложности при фиксированном размере выборки. Отвечает на вопрос «какую сложность выбрать».

Кривая обучения (learning curve) — ошибка на train и valid как функция размера обучающей выборки при фиксированной модели. Отвечает на вопрос «поможет ли собрать больше данных».

Второй инструмент недооценён, а он самый ценный для планирования: он экономит месяцы работы дата-инженеров, показывая, что новые данные ничего не дадут.

Код, который строит обе кривые правильно (с пайплайном, чтобы препроцессинг не подтекал через фолды — см. данные и признаки):

import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import learning_curve, validation_curve, KFold
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler, PolynomialFeatures
from sklearn.linear_model import Ridge

cv = KFold(n_splits=5, shuffle=True, random_state=0)

# ВАЖНО: препроцессинг внутри пайплайна, иначе статистики масштабирования
# посчитаются по валидационным фолдам — классическая утечка
pipe = make_pipeline(PolynomialFeatures(degree=9),
                     StandardScaler(),
                     Ridge(alpha=1.0))

# --- Кривая валидации: как качество зависит от силы регуляризации ---
alphas = np.logspace(-4, 4, 25)
train_scores, valid_scores = validation_curve(
    pipe, X, y,
    param_name="ridge__alpha", param_range=alphas,
    cv=cv, scoring="neg_root_mean_squared_error", n_jobs=-1,
)
train_rmse = -train_scores.mean(axis=1)
valid_rmse = -valid_scores.mean(axis=1)
best_alpha = alphas[valid_rmse.argmin()]
print(f"Оптимальная alpha: {best_alpha:.4g}, valid RMSE = {valid_rmse.min():.4f}")

# --- Кривая обучения: поможет ли собрать больше данных ---
sizes, train_scores, valid_scores = learning_curve(
    pipe, X, y,
    train_sizes=np.linspace(0.1, 1.0, 10),
    cv=cv, scoring="neg_root_mean_squared_error", n_jobs=-1,
)
valid_mean = -valid_scores.mean(axis=1)
valid_std = valid_scores.std(axis=1)

# грубая эвристика: если последние две точки отличаются меньше чем на 1%,
# кривая вышла на плато и новые данные почти не помогут
delta = (valid_mean[-2] - valid_mean[-1]) / valid_mean[-1]
print(f"Прирост от последних {sizes[-1] - sizes[-2]:.0f} объектов: {delta:.2%}")
if delta < 0.01:
    print("→ Кривая на плато: инвестировать в данные невыгодно, меняйте модель/признаки")

plt.fill_between(sizes, valid_mean - valid_std, valid_mean + valid_std, alpha=0.15)
plt.plot(sizes, valid_mean, marker="o", label="валидация")
plt.plot(sizes, -train_scores.mean(axis=1), marker="s", label="обучение")
plt.xlabel("Размер обучающей выборки"); plt.ylabel("RMSE"); plt.legend()

Как читать кривую обучения. Три сценария:

Картина Диагноз Действие
Train и valid сошлись высоко Смещение Усложнять модель, не собирать данные
Train низко, valid высоко, зазор не схлопывается Дисперсия Регуляризация, ансамбли, данные
Train низко, valid падает и ещё не выровнялась Дисперсия, но данные помогут Инвестировать в сбор данных
Valid ниже train Почти всегда баг Ищите утечку, дубликаты, неверный сплит

Последняя строка — не шутка. Валидационная ошибка ниже обучающей встречается легитимно только при сильной регуляризации во время обучения (dropout активен на train и выключен на valid) или при крошечных валидационных фолдах. В остальных случаях это утечка.

Карта моделей в координатах смещение–дисперсия

Из карты читается стратегия всего трека: путь к «идеальной зоне» идёт либо слева (усложняем недообученную модель), либо справа (регуляризуем и усредняем переобученную). Второй путь оказался исторически успешнее — именно поэтому ансамбли доминируют на табличных данных: они берут модель с почти нулевым смещением и сбивают дисперсию усреднением.

Регуляризация через штраф: ridge, lasso, elastic net

Общая форма. Вместо минимизации только ошибки минимизируем ошибку плюс штраф за сложность:

$$ \hat{\beta} = \arg\min_{\beta} \Big{ \underbrace{\mathcal{L}(\beta; X, y)}_ {\text{подгонка}} + \underbrace{\lambda , \Omega(\beta)}_ {\text{штраф}} \Big} $$

Это в точности задача с ограничением $\Omega(\beta) \le t$ (двойственность Лагранжа): каждому $\lambda$ соответствует свой бюджет $t$. Именно вид $\Omega$ определяет геометрию решения.

Геометрия ограничений L1 и L2

Ridge (L2, тихоновская регуляризация)

$$ \hat{\beta}^{\text{ridge}} = \arg\min_\beta |y - X\beta|_ 2^2 + \lambda|\beta|_ 2^2 \quad\Longrightarrow\quad \hat{\beta}^{\text{ridge}} = (X^\top X + \lambda I)^{-1} X^\top y $$

Три следствия, которые надо помнить наизусть:

1. Задача всегда разрешима. $X^\top X$ может быть вырожденной (коллинеарные признаки, $p > n$), но $X^\top X + \lambda I$ при $\lambda > 0$ строго положительно определена. Ridge изобрели именно для этого — Хоэрл и Кеннард, 1970, а до них Тихонов в 1943 для некорректных обратных задач.

2. Ridge — это покомпонентное сжатие в базисе SVD. Пусть $X = UDV^\top$. Тогда

$$ X\hat{\beta}^{\text{ridge}} = \sum_{j=1}^{p} u_j , \frac{d_j^2}{d_j^2 + \lambda} , u_j^\top y $$

Направления с большой сингулярной величиной $d_j$ (много дисперсии в данных) почти не сжимаются; направления с маленькой $d_j$ (шумные, плохо определённые) душатся почти в ноль. Это прямая связь с PCA: ridge — мягкий вариант отбрасывания младших главных компонент. Отсюда же эффективное число степеней свободы:

$$ \mathrm{df}(\lambda) = \sum_{j=1}^{p} \frac{d_j^2}{d_j^2 + \lambda} $$

При $\lambda = 0$ это $p$, при $\lambda \to \infty$ это 0. Очень полезная величина: она говорит, сколько параметров модель «реально тратит».

3. Intercept не штрафуют, признаки стандартизуют. Штраф на свободный член сделал бы модель зависящей от сдвига таргета (прибавили 1000 к зарплатам — предсказания поехали). Нестандартизованные признаки получают несопоставимые штрафы: признак в рублях и признак в миллионах рублей при одинаковом alpha регуляризуются с разной силой. sklearn.linear_model.Ridge не штрафует intercept автоматически, но масштабирование — на вас.

import numpy as np

def ridge_svd(X, y, alphas):
    """
    Ridge для всего пути alpha за одно SVD.
    X должен быть уже отцентрирован и отмасштабирован, y отцентрирован.
    Сложность: O(n·p·min(n,p)) на SVD + O(p·len(alphas)) на все решения —
    несравнимо дешевле, чем len(alphas) отдельных обращений матрицы.
    """
    U, d, Vt = np.linalg.svd(X, full_matrices=False)
    Uty = U.T @ y                                   # проекции таргета на левые сингулярные векторы
    coefs, dofs = [], []
    for a in alphas:
        shrink = d / (d ** 2 + a)                   # покомпонентный коэффициент сжатия
        coefs.append(Vt.T @ (shrink * Uty))
        dofs.append(np.sum(d ** 2 / (d ** 2 + a)))  # эффективные степени свободы
    return np.array(coefs), np.array(dofs)


rng = np.random.default_rng(0)
n, p = 60, 40
X = rng.normal(size=(n, p))
X[:, 1] = X[:, 0] + 0.01 * rng.normal(size=n)       # почти коллинеарный признак
beta_true = np.zeros(p); beta_true[:5] = [3, -2, 1.5, 0, 0]
y = X @ beta_true + rng.normal(0, 1.0, size=n)

alphas = np.logspace(-2, 3, 6)
coefs, dofs = ridge_svd(X, y, alphas)
for a, c, df in zip(alphas, coefs, dofs):
    print(f"alpha={a:>8.3g}  df={df:>5.1f}  ||beta||={np.linalg.norm(c):>6.2f}  "
          f"beta_0={c[0]:>6.2f}  beta_1={c[1]:>6.2f}")

Обратите внимание в выводе: при малых alpha коэффициенты у коллинеарной пары beta_0 и beta_1 огромные и противоположные по знаку (классический симптом мультиколлинеарности), с ростом alpha они сближаются и делят вклад поровну. Это второе полезное свойство L2 — группировка коррелированных признаков.

Lasso (L1)

$$ \hat{\beta}^{\text{lasso}} = \arg\min_\beta \tfrac{1}{2n}|y - X\beta|_ 2^2 + \lambda|\beta|_ 1 $$

Закрытой формы нет: $|\beta|_ 1$ не дифференцируема в нуле. Зато есть красивое решение через координатный спуск — для одной координаты при фиксированных остальных задача решается точно оператором мягкого порога:

$$ S(z, \gamma) = \operatorname{sign}(z),\max(|z| - \gamma,\ 0) $$

Именно этот max(..., 0) и создаёт разреженность: если частная корреляция признака с остатком меньше порога $\lambda$, коэффициент становится ровно нулём, а не «очень маленьким». Геометрически (см. SVG выше) — ромб $|\beta_1| + |\beta_2| \le t$ имеет углы на осях, и эллипс уровня ошибки с высокой вероятностью касается именно угла.

def lasso_coordinate_descent(X, y, lam, n_iter=200, tol=1e-7):
    """
    Координатный спуск для lasso. X стандартизован, y отцентрирован.
    Сложность: O(n_iter · n · p) по времени, O(n·p) по памяти.
    На практике сходится за десятки проходов; sklearn использует
    ту же схему плюс active-set эвристику (работает только по ненулевым координатам).
    """
    n, p = X.shape
    beta = np.zeros(p)
    r = y.copy()                                  # текущий остаток y - X @ beta
    z = (X ** 2).sum(axis=0) / n                  # нормировка колонок (для стандартизованных = 1)

    for _ in range(n_iter):
        beta_old = beta.copy()
        for j in range(p):
            if beta[j] != 0.0:
                r += X[:, j] * beta[j]            # «выключаем» j-й признак из остатка
            rho = X[:, j] @ r / n                 # корреляция признака с остатком
            # мягкий порог: всё, что слабее lam, обнуляется полностью
            beta[j] = np.sign(rho) * max(abs(rho) - lam, 0.0) / z[j]
            if beta[j] != 0.0:
                r -= X[:, j] * beta[j]            # «включаем» обратно
        if np.max(np.abs(beta - beta_old)) < tol:
            break
    return beta


for lam in (0.01, 0.05, 0.2, 0.5):
    b = lasso_coordinate_descent(X / X.std(0), y - y.mean(), lam)
    print(f"lambda={lam:<5} ненулевых коэффициентов: {np.sum(np.abs(b) > 1e-8):>3} из {p}")

Когда lasso — правильный выбор:

  • Признаков много, но вы верите, что значимых мало (гипотеза разреженности). Классика: геномика, тексты в bag-of-words, тысячи агрегатов в скоринге.
  • Нужна интерпретируемая модель с коротким списком факторов — регуляторы и бизнес это любят.
  • Нужен быстрый отбор признаков как препроцессинг перед более тяжёлой моделью.

Когда lasso подводит:

  • Коррелированные группы. Из группы из 10 почти одинаковых признаков lasso выберет один случайный и обнулит девять. При пересборке модели на новых данных выберется другой — модель выглядит нестабильной для бизнеса.
  • При $p > n$ lasso не может выбрать больше $n$ признаков — структурное ограничение задачи.
  • Смещение больших коэффициентов. Мягкий порог сдвигает и те коэффициенты, что явно ненулевые. Лечится либо relaxed lasso (обычный МНК на отобранном подмножестве), либо адаптивным lasso.

Elastic Net

Комбинация, снимающая обе слабости lasso:

$$ \hat{\beta}^{\text{EN}} = \arg\min_\beta \tfrac{1}{2n}|y - X\beta|_ 2^2 + \lambda\Big(\alpha|\beta|_ 1 + \tfrac{1-\alpha}{2}|\beta|_ 2^2\Big) $$

L1-часть даёт разреженность, L2-часть даёт группировку: коррелированные признаки входят или выходят вместе. Это дефолтный выбор для широких табличных данных с коррелированными агрегатами. В sklearn параметр называется l1_ratio ($\alpha$ в формуле).

Сводная таблица штрафов

Штраф Формула $\Omega(\beta)$ Разреженность Замкнутая форма Основной эффект Типичное применение
L2 / ridge $\sum \beta_j^2$ нет да сжатие, группировка мультиколлинеарность, $p > n$, дефолт
L1 / lasso $\sum \lvert\beta_j\rvert$ да нет отбор признаков разреженные задачи, интерпретация
Elastic Net $\alpha\sum\lvert\beta_j\rvert + \frac{1-\alpha}{2}\sum\beta_j^2$ да нет отбор + группировка широкие коррелированные данные
Group Lasso $\sum_g \sqrt{p_g},|\beta_g|_ 2$ по группам нет целые группы вкл/выкл one-hot категории целиком
Fused Lasso $\sum \lvert\beta_{j+1} - \beta_j\rvert$ по разностям нет кусочная гладкость сигналы, временные ряды

Group Lasso заслуживает отдельного упоминания: если категориальный признак раскодирован в 30 one-hot-колонок, обычный lasso может оставить 7 из них — это семантическая каша. Group Lasso включает или выключает признак целиком.

Байесовский взгляд: штраф — это априорное распределение

Самая элегантная интерпретация. Оценка максимума апостериорной вероятности (MAP):

$$ \hat{\beta}_ {\text{MAP}} = \arg\max_\beta ; \log p(y \mid X, \beta) + \log p(\beta) $$

Подставим гауссовское правдоподобие $y_i \sim \mathcal{N}(x_i^\top\beta, \sigma^2)$ и гауссовский априор $\beta_j \sim \mathcal{N}(0, \tau^2)$:

$$ -\log p(y\mid X,\beta) - \log p(\beta) = \frac{1}{2\sigma^2}|y - X\beta|^2 + \frac{1}{2\tau^2}|\beta|^2 + \text{const} $$

Это в точности ridge с $\lambda = \sigma^2/\tau^2$. Аналогично лапласовский априор $p(\beta_j) \propto e^{-|\beta_j|/b}$ даёт lasso.

Что это даёт практически:

  • Регуляризация — формализация априорного знания. Вы говорите: «до всяких данных я считаю, что коэффициенты малы и сосредоточены вокруг нуля». Лапласовский априор острее в нуле — отсюда разреженность.
  • $\lambda$ имеет смысл отношения шума к сигналу. Данных много и они чистые ($\sigma^2$ мал) — $\lambda$ должна быть маленькой. Логично и проверяемо.
  • MAP — это не полный байес. Он даёт точку, а не распределение. Полный байесовский подход (интегрирование по апостериорному) даёт ещё и калибрфайонную неопределённость — но это уже другая история, за которой идите к Gelman, Bayesian Data Analysis.

Регуляризация без штрафа на веса

Штраф в лоссе — самый известный, но далеко не единственный способ ограничить дисперсию.

Разберём наиболее важные.

Ранняя остановка

Обучаем итеративно, следим за валидационной метрикой, останавливаемся, когда она перестала улучшаться. Это единственный регуляризатор, который экономит вычисления вместо того, чтобы их тратить.

Не очевидный факт: для линейной модели, обучаемой градиентным спуском из нуля, ранняя остановка математически эквивалентна ridge. После $t$ шагов с темпом $\eta$ компонента вдоль $j$-го сингулярного направления сжимается в $1 - (1 - \eta d_j^2)^t$ раз — гладкая функция, монотонно растущая от 0 до 1, качественно неотличимая от ridge-множителя $d_j^2/(d_j^2 + \lambda)$. Направления с большой $d_j$ выучиваются быстро, шумные — медленно; остановившись рано, вы просто не успеваете выучить шум. Строгий разбор — в The Elements of Statistical Learning, раздел 7.10, и в Bishop, PRML, раздел 5.5.2.

from sklearn.ensemble import HistGradientBoostingRegressor

# встроенная ранняя остановка: модель сама режет валидационный кусок
model = HistGradientBoostingRegressor(
    max_iter=2000,                 # ставим заведомо много
    learning_rate=0.05,
    early_stopping=True,
    validation_fraction=0.15,      # доля на внутреннюю валидацию
    n_iter_no_change=50,           # терпение
    tol=1e-5,
    random_state=0,
)
model.fit(X_train, y_train)
print(f"Реально построено деревьев: {model.n_iter_}")   # << 2000

Правило для бустинга: не тюньте n_estimators по сетке. Ставьте большое число и отдавайте управление ранней остановке — это и быстрее, и точнее. Тюньте learning_rate, глубину и min_child_weight.

Dropout

Во время обучения каждый нейрон с вероятностью $p$ обнуляется. Модель не может опереться на конкретный нейрон — приходится выучивать избыточные представления. Интерпретаций две:

  1. Ансамбль. Сеть с $m$ нейронами и dropout — это неявное усреднение $2^m$ прореженных подсетей с общими весами (Srivastava et al., 2014).
  2. Адаптивный L2. Для линейной модели dropout на входах в точности эквивалентен L2-штрафу, отмасштабированному дисперсией каждого признака (Wager, Wang & Liang, 2013).

Два практических нюанса, на которых спотыкаются: inverted dropout (масштабируем на 1 $/(1-p)$ во время обучения, чтобы на инференсе ничего не менять) и обязательный model.eval() перед предсказанием. Забытый eval() — источник загадочно шумных продовых метрик.

Аугментация данных

Единственный регуляризатор, который добавляет информацию, а не убирает степени свободы. Вы кодируете известные инварианты задачи: повернутая кошка — всё ещё кошка, перефразированный отзыв — всё тот же тональный класс, сдвинутый на час временной ряд — тот же паттерн.

Ключевое требование: аугментации должны сохранять таргет. Отражение цифры «6» по горизонтали даёт не «6». Аугментация, ломающая семантику, добавляет смещение вместо снижения дисперсии.

Ансамблирование

Если $B$ моделей имеют дисперсию $\sigma^2$ и попарную корреляцию $\rho$, дисперсия среднего:

$$ \operatorname{Var}\left(\frac{1}{B}\sum_b \hat{f}_ b\right) = \rho\sigma^2 + \frac{1-\rho}{B}\sigma^2 $$

Второе слагаемое уходит с ростом $B$, первое — нет. Отсюда весь дизайн случайного леса: бутстрап и случайный подбор признаков в узле нужны не сами по себе, а чтобы уменьшить $\rho$. Подробно — в статье про ансамбли.

Weight decay ≠ L2 в адаптивных оптимизаторах

Тонкость, которая стоила сообществу нескольких лет непонятных результатов. В классическом SGD добавление $\lambda|w|^2$ к лоссу и вычитание $\eta\lambda w$ из весов — одно и то же. В Adam — нет: адаптивная нормировка делит градиент (включая L2-часть) на бегущую оценку его величины, из-за чего параметры с крупными градиентами регуляризуются слабее. Loshchilov & Hutter (2017) предложили AdamW, где decay применяется к весам напрямую, минуя адаптивную нормировку. Сегодня AdamW — стандарт де-факто.

import torch

# НЕПРАВИЛЬНО для Adam: weight_decay здесь подмешивается в градиент
opt_bad = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-2)

# ПРАВИЛЬНО: развязанный weight decay
opt_good = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-2)

# И почти всегда: не регуляризовать bias и параметры нормализаций —
# они не отвечают за ёмкость модели, а их сжатие ломает обучение
decay, no_decay = [], []
for name, param in model.named_parameters():
    if not param.requires_grad:
        continue
    if param.ndim <= 1 or name.endswith(".bias"):   # bias, LayerNorm, BatchNorm
        no_decay.append(param)
    else:
        decay.append(param)

opt = torch.optim.AdamW([
    {"params": decay,    "weight_decay": 1e-2},
    {"params": no_decay, "weight_decay": 0.0},
], lr=1e-3)

Двойной спуск: почему классическая картина неполна

Классическая U-образная кривая «ошибка против сложности» описывает мир, где число параметров меньше числа объектов. Современные модели живут в другом режиме, и там происходит странное.

Belkin и соавторы (PNAS, 2019) показали: если продолжать наращивать ёмкость за точку интерполяции (где модель уже идеально запоминает обучающую выборку), тестовая ошибка сначала взлетает до пика, а затем снова начинает падать — иногда ниже классического минимума. Nakkiran et al. (2019) воспроизвели эффект для ResNet и трансформеров и показали, что двойной спуск возникает не только по ёмкости, но и по числу эпох, и по объёму данных.

Что происходит на самом деле. В точке интерполяции ($p \approx n$) существует ровно одно решение, идеально подгоняющее данные, и оно ужасно — вынужденно огромной нормы. Когда параметров становится сильно больше, решений, интерполирующих данные, бесконечно много, и оптимизатор (SGD, псевдообратная матрица) выбирает среди них решение минимальной нормы. То есть неявная регуляризация возникает из самого алгоритма оптимизации — implicit regularization. Bartlett et al. (2020) показали условия, при которых такое «доброкачественное переобучение» (benign overfitting) безвредно: спектр ковариации данных должен убывать достаточно медленно.

Практические выводы, а не философия:

  • Не бойтесь моделей, которые идеально запоминают train. Нулевая обучающая ошибка сама по себе не диагноз. Смотрите на валидацию, а не на разрыв.
  • Если валидационная метрика ухудшилась после увеличения модели — попробуйте увеличить ещё. Возможно, вы сидите на пике двойного спуска.
  • Для табличных данных это почти неактуально. Двойной спуск наблюдается в переопараметризованном режиме с сильной неявной регуляризацией. На 20 000 строк с 50 признаками классическая U-образная картина работает буквально, и явная регуляризация обязательна.
  • Неявная регуляризация — не заменитель явной. AdamW с weight decay, аугментации и dropout дают выигрыш даже в переопараметризованном режиме.

Типичные ошибки

Подбор $\lambda$ на тестовой выборке. Перебрали 50 значений alpha, выбрали лучшее по тесту, отчитались этим числом. Отчёт завышен: тест перестал быть тестом в момент первого взгляда на него. Правильно — вложенная кросс-валидация (см. оценку моделей).

Препроцессинг вне пайплайна. StandardScaler().fit(X) на всём датасете до сплита — утечка: среднее и дисперсия валидационных объектов участвовали в обучении. На больших выборках эффект мал, на маленьких — критичен. Всегда Pipeline.

Регуляризация нестандартизованных признаков. L1/L2 штрафуют веса, а вес обратно пропорционален масштабу признака. Признак «возраст» (0–100) и признак «доход» (0–10 000 000) при одинаковом alpha регуляризуются с разницей в пять порядков. Масштабирование перед L1/L2 — не рекомендация, а обязательное условие корректности.

Штраф на intercept. Делает модель зависимой от сдвига таргета. sklearn защищён, самописный градиентный спуск — нет.

«Переобучение» на самом деле — дубликаты. Один и тот же объект попал и в train, и в test. Метрики фантастические, прод разочаровывает. Проверяйте: точные дубликаты, near-duplicates по хешу, и особенно — несколько записей одного пользователя, разбросанных по фолдам. Лекарство — GroupKFold по идентификатору сущности.

Случайный сплит на данных со временем. Обучились на будущем, предсказали прошлое. Формально не переобучение, но симптом тот же: валидация врёт. Только TimeSeriesSplit или сплит по дате — подробнее в статье про временные ряды.

Переобучение на валидации. Вы честно не трогали тест, но провели 800 экспериментов на одной валидационной выборке и выбрали лучший. Валидация выработала свой ресурс: вы отобрали конфигурацию, которой повезло именно на этих объектах. Симптом — на новых данных лидерборд перетасовывается. Лекарство: разные seed’ы CV, повторная CV, ограничение числа экспериментов, финальный holdout, вскрываемый один раз.

Регуляризация как средство от всего. Если разрыв train/valid огромен из-за утечки, сдвига распределения или битого таргета — увеличение alpha просто равномерно ухудшит модель. Сначала диагноз, потом лечение.

Ранняя остановка по тестовой выборке. Тот же класс ошибки, но особенно коварный, потому что выглядит невинно: eval_set=[(X_test, y_test)] в бустинге. Тест мгновенно превращается в валидацию. Заведите три выборки: train, valid (для остановки и тюнинга), test (для одного финального замера).

Как это выглядит в проде

Регуляризация — часть контракта, а не эксперимента. Гиперпараметры фиксируются вместе с версией модели и перепроверяются при каждом переобучении. Данные меняются — оптимальное $\lambda$ дрейфует. Пайплайн переобучения должен переподбирать его, а не хардкодить прошлогоднее значение.

Мониторинг разрыва в проде. Логируйте распределение предсказаний и (когда таргет доезжает) метрику качества. Расхождение между офлайн-валидацией и онлайн-метрикой — самый ранний сигнал того, что модель переобучилась под исторические данные.

Регуляризация как контроль стоимости. Lasso, обнуляющий 80% признаков, — это не только про качество: это про 80% фичей, которые не нужно считать в реальном времени, не нужно мониторить и не нужно поддерживать. В high-load инференсе экономия на извлечении признаков часто важнее третьего знака в AUC.

Простота как страховка. В финансах и медицине регуляризованная линейная модель на 15 признаках часто побеждает бустинг на 300 не потому, что точнее, а потому что предсказуема при сдвиге распределения, объяснима регулятору и не ломается тихо. Дисперсия — это ещё и операционный риск.

Канонический шаблон, к которому стоит приводить любой табличный проект:

from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.linear_model import ElasticNet
from sklearn.model_selection import GridSearchCV, cross_val_score, KFold

numeric = Pipeline([
    ("impute", SimpleImputer(strategy="median")),
    ("scale", StandardScaler()),          # обязательно ДО регуляризованной модели
])
categorical = Pipeline([
    ("impute", SimpleImputer(strategy="most_frequent")),
    ("ohe", OneHotEncoder(handle_unknown="ignore", min_frequency=20)),
])

prep = ColumnTransformer([
    ("num", numeric, numeric_cols),
    ("cat", categorical, categorical_cols),
])

pipe = Pipeline([("prep", prep), ("model", ElasticNet(max_iter=20000))])

grid = {
    "model__alpha":    np.logspace(-4, 1, 25),
    "model__l1_ratio": [0.1, 0.3, 0.5, 0.7, 0.9, 1.0],
}

inner = KFold(n_splits=5, shuffle=True, random_state=0)
outer = KFold(n_splits=5, shuffle=True, random_state=1)

search = GridSearchCV(pipe, grid, cv=inner,
                      scoring="neg_root_mean_squared_error", n_jobs=-1)

# ВЛОЖЕННАЯ CV: честная оценка качества ВСЕЙ процедуры, включая подбор гиперпараметров.
# Внешние фолды ни разу не участвовали в выборе alpha.
scores = cross_val_score(search, X, y, cv=outer,
                         scoring="neg_root_mean_squared_error", n_jobs=-1)
print(f"Честная оценка RMSE: {-scores.mean():.4f} ± {scores.std():.4f}")

# И только после этого — финальная модель на всех данных
search.fit(X, y)
print("Итоговые гиперпараметры:", search.best_params_)

Замечание о стоимости: вложенная CV с сеткой из $|G|$ точек и $k_{\text{in}} \times k_{\text{out}}$ фолдами обучает модель $|G| \cdot k_{\text{in}} \cdot k_{\text{out}}$ раз — в примере выше это $150 \times 5 \times 5 = 3750$ обучений. Для линейных моделей это секунды; для бустинга — часы. Практичные компромиссы: RandomizedSearchCV вместо полной сетки, байесовская оптимизация (optuna), либо специализированные эстиматоры RidgeCV (использует эффективную формулу LOOCV через диагональ hat-матрицы — почти бесплатно) и LassoCV/ElasticNetCV (считают весь путь регуляризации за одно прохождение по warm-start).

Мини-итог

Ошибка модели на новом объекте раскладывается на неустранимый шум, квадрат смещения и дисперсию. Смещение — это негибкость класса моделей, дисперсия — чувствительность к конкретной выборке. Данные лечат дисперсию, но не смещение; усложнение модели лечит смещение ценой дисперсии.

Регуляризация — управляемое внесение смещения ради снижения дисперсии. Она бывает трёх сортов: штраф на параметры (ridge сжимает, lasso зануляет, elastic net делает и то и другое), ограничение структуры (глубина дерева, число компонент, размер сети) и вмешательство в процесс обучения (ранняя остановка, dropout, аугментация, ансамблирование). Все они эквивалентны априорному знанию о том, что «простые решения вероятнее» — байесовский взгляд делает это буквальным.

Практическая дисциплина сводится к четырём пунктам: препроцессинг всегда внутри пайплайна, гиперпараметры подбираются кросс-валидацией и никогда на тесте, финальная оценка снимается один раз на замороженной выборке, а диагноз (смещение или дисперсия?) ставится по кривым обучения ДО того, как крутить alpha.

И последнее: современный режим переопараметризации показал, что нулевая ошибка на обучении — не приговор, а неявная регуляризация оптимизатора реальна. Но на табличных данных, где живёт большинство продовых задач, классический компромисс работает ровно так, как его описали в 1970-х.

Источники

Что дальше

Рекомендательные системы — область, где переобучение принимает особенно коварные формы: матрица взаимодействий почти пуста, параметров в разы больше, чем наблюдений, а обратная связь смещена самой моделью. Разберём коллаборативную фильтрацию, матричную факторизацию с регуляризацией, неявный фидбэк и то, почему офлайн-метрики рекомендаций регулярно расходятся с результатами A/B-теста.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов