Машинное обучение Оценка моделей: метрики, кросс-валидация, калибровка
0%

Оценка моделей: метрики, кросс-валидация, калибровка

Оценка моделей: метрики, кросс-валидация, калибровка

Предыдущие девять статей трека учили строить модели. Эта — единственная, без которой все они бесполезны. Причина простая: модель, качество которой вы не умеете измерять, не отличима от случайного генератора, а «улучшение», которое вы не умеете подтверждать, статистически не отличимо от шума.

Хуже того: измерение — это и есть определение задачи. Скажите «мы делаем антифрод», и ничего не понятно. Скажите «мы максимизируем recall при precision ≥ 0.9 на транзакциях дороже 10 000 ₽, где метка приходит через 45 дней» — и половина архитектурных решений уже принята: понятно, как резать выборку, какой порог настраивать, зачем нужна калибровка и почему обычный train_test_split здесь ведёт к катастрофе.

Эта статья — про три уровня вопроса «насколько модель хороша»:

  1. Какой метрикой мерить — чтобы число отражало реальную пользу, а не удобство библиотеки.
  2. На каких данных мерить — чтобы число не оказалось завышенным из-за утечки или подглядывания.
  3. Насколько числу верить — чтобы разница 0.812 против 0.809 не превратилась в квартальную дорожную карту.

Что мы вообще оцениваем: риск и его оценки

Формально мы хотим знать истинный риск модели $f$ на генеральной совокупности $\mathcal{D}$:

$$ R(f) = \mathbb{E}_ {(x, y) \sim \mathcal{D}} \left[ L(y, f(x)) \right] $$

Но $\mathcal{D}$ недоступно — есть только конечная выборка. Считаем эмпирический риск:

$$ \hat{R}_ S(f) = \frac{1}{|S|} \sum_{(x_i, y_i) \in S} L(y_i, f(x_i)) $$

Между $\hat{R}_ S$ и $R$ три источника расхождения, и каждая практика этой статьи борется с одним из них:

Источник расхождения Что это Лекарство
Оптимистическое смещение $f$ подбирали по тем же данным, на которых меряют отложенный тест, вложенная CV
Дисперсия оценки выборка конечна, $\hat{R}_ S$ — случайная величина кросс-валидация, бутстрап-CI
Сдвиг распределения прод $\neq$ обучающая выборка временное разбиение, мониторинг, A/B

Ключевая мысль, которую стоит держать в голове весь текст: оценка качества — сама по себе статистическая оценка, у неё есть смещение и дисперсия. Число roc_auc = 0.8412 — это не факт, это точечная оценка со своим доверительным интервалом, часто шириной ±0.03.

Протокол разбиения: train / validation / test

Минимальная честная схема выглядит так:

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

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

Последний пункт нарушают почти все. Классический сценарий: команда полгода гоняет модели, каждый раз сверяясь с тестом; на 200-й итерации выбранная модель имеет тестовый ROC-AUC 0.87, а в проде — 0.79. Ничего мистического: перебирая 200 конфигураций по одному и тому же 20-тысячному тесту, вы гарантированно найдёте ту, которой повезло. Это adaptive overfitting — переобучение под процедуру оценки. Cawley и Talbot разобрали это явление в «On Over-fitting in Model Selection and Subsequent Selection Bias in Performance Evaluation», JMLR 2010, а Dwork и соавторы предложили способ переиспользовать holdout безопасно — «The reusable holdout», Science 2015.

Практические правила:

  • фиксируйте тест до начала экспериментов и держите его в отдельном файле/таблице;
  • логируйте, сколько раз к нему обращались, — это реальная метрика гигиены проекта;
  • в соревновательных и командных сценариях используйте два теста: рабочий и «запечатанный», который вскрывается один раз перед релизом;
  • разбиение делайте по той же оси, по которой данные будут расходиться в проде: по времени для прогнозов, по пользователю для персонализации, по клинике для медданных. Об утечках через некорректное разбиение подробно в статье про данные и признаки.

Метрики классификации

Матрица ошибок — источник всего остального

Любая метрика бинарной классификации при фиксированном пороге — функция четырёх чисел:

Предсказано 1 Предсказано 0
Факт 1 TP (истинно-положительные) FN (пропуски)
Факт 0 FP (ложные тревоги) TN (истинно-отрицательные)

Дальше — арифметика, но за каждым выражением стоит вопрос бизнеса:

$$ \text{precision} = \frac{TP}{TP + FP}, \qquad \text{recall} = \frac{TP}{TP + FN}, \qquad \text{FPR} = \frac{FP}{FP + TN} $$

  • Precision отвечает на вопрос «из тех, кого мы дёрнули, сколько дёрнули по делу?» — это цена ложной тревоги: время оператора, раздражённый клиент, зря отменённая транзакция.
  • Recall (он же TPR, чувствительность) — «из тех, кого надо было поймать, скольких поймали?» — цена пропуска: несработавший антифрод, невыявленная опухоль.
  • FPR — «какую долю невиновных мы задели?». Отличается от precision тем, что в знаменателе не наши предсказания, а весь негативный класс, поэтому FPR не зависит от баланса классов, а precision зависит очень сильно.

Ключевая картина: и матрица ошибок, и ROC-кривая — это два взгляда на одно и то же распределение скоров.

Порог, матрица ошибок и ROC-кривая

Слева видно главное: модель выдаёт не класс, а скор; класс появляется только когда вы провели вертикальную черту. Сдвиг черты меняет TP/FP/FN/TN, значит меняет precision, recall, F1 и accuracy — но не меняет саму модель. Справа — та же картина, развёрнутая по всем порогам сразу: каждая точка ROC есть одна вертикальная черта слева.

Accuracy и почему ей нельзя пользоваться по умолчанию

$$ \text{accuracy} = \frac{TP + TN}{TP + TN + FP + FN} $$

Метрика честная ровно в одном случае: классы сбалансированы и ошибки стоят одинаково. В реальных задачах это почти никогда не так. При доле мошенничества 0.2 % константный классификатор «всё честно» даёт accuracy 99.8 % — и нулевую пользу.

Замены при дисбалансе:

  • F1 — гармоническое среднее precision и recall: $F_1 = 2 \cdot \frac{P \cdot R}{P + R}$. Гармоническое, а не арифметическое, потому что оно наказывает перекос: при $P = 1.0, R = 0.01$ арифметическое даст 0.505, гармоническое — 0.0198.
  • F-beta — когда цены ошибок различаются: $F_\beta = (1 + \beta^2) \frac{P \cdot R}{\beta^2 P + R}$. При $\beta = 2$ recall важнее precision вчетверо (медицина, безопасность), при $\beta = 0.5$ — наоборот.
  • Balanced accuracy — среднее recall по классам, устойчиво к дисбалансу.
  • MCC (коэффициент корреляции Мэтьюса) — единственная из ходовых метрик, которая использует все четыре клетки матрицы симметрично:

$$ \text{MCC} = \frac{TP \cdot TN - FP \cdot FN}{\sqrt{(TP+FP)(TP+FN)(TN+FP)(TN+FN)}} $$

Диапазон $[-1, 1]$, ноль = случайное угадывание. Chicco и Jurman в BMC Genomics, 2020 показывают, что F1 может выглядеть прилично там, где MCC честно показывает провал: F1 игнорирует TN целиком.

import numpy as np
from sklearn.metrics import (classification_report, confusion_matrix,
                             matthews_corrcoef, balanced_accuracy_score)

rng = np.random.default_rng(0)
n = 20_000
y_true = (rng.random(n) < 0.02).astype(int)          # 2 % позитивов — типичный антифрод
# слабая, но не бесполезная модель: скор позитивов сдвинут вправо
score = rng.normal(loc=y_true * 1.1, scale=1.0)
y_pred = (score > 0.5).astype(int)

tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel()
print(f"TN={tn} FP={fp} FN={fn} TP={tp}")
print(f"accuracy       = {(tp + tn) / n:.4f}")        # выглядит внушительно
print(f"balanced acc   = {balanced_accuracy_score(y_true, y_pred):.4f}")
print(f"MCC            = {matthews_corrcoef(y_true, y_pred):.4f}")   # отрезвляет
print(classification_report(y_true, y_pred, digits=3, zero_division=0))

Метрики без порога: ROC-AUC и PR-AUC

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

ROC-AUC — площадь под кривой TPR(FPR). У неё есть красивая вероятностная интерпретация:

$$ \text{AUC} = P\big(s(x^+) > s(x^-)\big) + \tfrac{1}{2} P\big(s(x^+) = s(x^-)\big) $$

то есть вероятность, что случайно взятый позитив получит скор выше случайно взятого негатива. Это ровно нормированная U-статистика Манна–Уитни, отсюда и её устойчивость: AUC инвариантна к любому монотонному преобразованию скоров и к доле позитивов в выборке.

Инвариантность к дисбалансу — одновременно достоинство и ловушка. Если позитивов 0.1 %, то при FPR = 0.01 вы дёргаете 1 % огромного негативного класса — это в 10 раз больше объектов, чем все позитивы вместе. ROC-кривая выглядит прекрасно, а precision будет ~0.09. Именно поэтому при сильном дисбалансе показывают PR-AUC (average precision) — площадь под кривой precision(recall):

$$ \text{AP} = \sum_k (R_k - R_{k-1}) \cdot P_k $$

Базовый уровень PR-AUC равен доле позитивов (для антифрода с 0.2 % позитивов AP = 0.15 — это в 75 раз лучше случайного, хотя число выглядит «плохо»). Классические работы на эту тему: Davis и Goadrich, «The Relationship Between Precision-Recall and ROC Curves», ICML 2006, и Saito с Rehmsmeier, PLOS ONE 2015.

Когда что использовать:

Ситуация Метрика Почему
Классы примерно сбалансированы ROC-AUC простая интерпретация, устойчива
Позитивов < 5 %, важен именно позитивный класс PR-AUC (average precision) отражает реальную precision при работе
Ресурс ограничен: оператор проверит 500 кейсов в день precision@k, recall@k, lift@k прямо совпадает с операционным сценарием
Нужна работа только в зоне низких FPR partial AUC остальная часть кривой недостижима на практике
Вероятности используются в расчётах log loss, Brier AUC вообще ничего не говорит о калибровке
from sklearn.metrics import roc_auc_score, average_precision_score

print(f"ROC-AUC = {roc_auc_score(y_true, score):.4f}")
print(f"PR-AUC  = {average_precision_score(y_true, score):.4f}"
      f"   (базовый уровень = {y_true.mean():.4f})")

def precision_at_k(y_true, score, k):
    """Precision среди top-k по скору — операционная метрика 'что увидит оператор'."""
    idx = np.argsort(-score)[:k]
    return y_true[idx].mean()

for k in (100, 500, 2000):
    print(f"precision@{k:<5} = {precision_at_k(y_true, score, k):.3f}"
          f"   lift = {precision_at_k(y_true, score, k) / y_true.mean():.1f}x")

lift@k — отношение precision в топе к базовой доле позитивов — часто самая понятная бизнесу метрика: «наша модель находит мошенников в 12 раз плотнее, чем случайная проверка».

Метрики качества вероятностей

AUC меряет порядок, но не значения. Модель, выдающая $p/100$ вместо $p$, имеет ту же AUC и совершенно непригодна для расчёта ожидаемых потерь. Для вероятностей нужны строго собственные функции потерь (strictly proper scoring rules) — такие, что минимум достигается ровно на истинной вероятности:

$$ \text{LogLoss} = -\frac{1}{n} \sum_i \big[ y_i \log \hat{p}_ i + (1 - y_i) \log(1 - \hat{p}_ i) \big], \qquad \text{Brier} = \frac{1}{n} \sum_i (\hat{p}_ i - y_i)^2 $$

Разница между ними практическая: log loss наказывает уверенные ошибки бесконечно жёстко (предсказали 0.001, случилась единица — вклад $\approx 6.9$), Brier — квадратично и мягко. Если у вас в данных есть шумные метки, log loss будет истерично реагировать на них; Brier устойчивее. Теория собственных скоринговых правил — у Gneiting и Raftery, «Strictly Proper Scoring Rules, Prediction, and Estimation», JASA 2007.

Полезнейший факт — разложение Мёрфи для Brier:

$$ \text{Brier} = \underbrace{\text{Reliability}}_ {\text{ошибка калибровки}} - \underbrace{\text{Resolution}}_ {\text{различающая способность}} + \underbrace{\text{Uncertainty}}_ {\text{неустранимая, } \bar{y}(1-\bar{y})} $$

Оно прямо говорит: качество вероятностной модели = насколько честны её числа (reliability) плюс насколько они разнообразны и информативны (resolution). Калибровка чинит первое слагаемое и не трогает второе — к этому вернёмся ниже.

Метрики регрессии

Для регрессии выбор метрики — это выбор того, какую статистику распределения вы хотите предсказывать. Это не вкусовщина, а математика:

  • MSE / RMSE: $\frac{1}{n}\sum (y_i - \hat{y}_ i)^2$. Оптимум — условное среднее $\mathbb{E}[y|x]$. Квадрат делает метрику чувствительной к выбросам: один прогноз, ошибшийся в 10 раз, весит как сто, ошибшихся вдвое. RMSE в единицах таргета, поэтому её и показывают.
  • MAE: $\frac{1}{n}\sum |y_i - \hat{y}_ i|$. Оптимум — условная медиана. Устойчива к выбросам. Если бизнес говорит «нам важен типичный заказ, а не редкие оптовые» — это MAE.
  • Huber / log-cosh: квадратичная у нуля, линейная на хвостах — компромисс с параметром $\delta$.
  • MAPE: $\frac{100}{n}\sum |y_i - \hat{y}_ i| / |y_i|$. Соблазнительна («ошибка 8 %»), но взрывается при $y_i \to 0$, не определена при $y_i = 0$ и асимметрична: занижение прогноза штрафуется максимум на 100 %, завышение — неограниченно. Из-за этого модель, обученная под MAPE, систематически занижает прогноз. Хорошая замена — WAPE $\sum|y_i - \hat{y}_ i| / \sum |y_i|$: те же проценты, но без деления на отдельные малые значения.
  • : $1 - \text{SS}_ {res}/\text{SS}_ {tot}$. Смысл — «насколько лучше, чем предсказывать среднее». Осторожно: на тестовой выборке R² может быть отрицательным, а на выборках с малой дисперсией таргета — почти нулевым при отличной абсолютной точности. Никогда не сравнивайте R² между разными выборками.
  • Pinball (квантильная) loss — когда вам нужен не центр, а квантиль:

$$ L_\tau(y, \hat{y}) = \begin{cases} \tau (y - \hat{y}), & y \ge \hat{y} \ (1 - \tau)(\hat{y} - y), & y < \hat{y}\end{cases} $$

Это ровно та ситуация, когда ошибки стоят по-разному. Прогноз спроса на складе: недозаказ = упущенная выручка, перезаказ = стоимость хранения. Если недозаказ втрое дороже, ставьте $\tau = 0.75$ и модель сама научится систематически завышать прогноз в нужной пропорции.

from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score

def wape(y, yhat):
    return np.abs(y - yhat).sum() / np.abs(y).sum()

def pinball(y, yhat, tau):
    d = y - yhat
    return np.mean(np.maximum(tau * d, (tau - 1) * d))

y = rng.gamma(shape=2.0, scale=50.0, size=5000)            # правый хвост, как у чеков
yhat_mean = np.full_like(y, y.mean())                      # предсказываем среднее
yhat_med = np.full_like(y, np.median(y))                   # предсказываем медиану

for name, p in (("среднее", yhat_mean), ("медиана", yhat_med)):
    print(f"{name:8} RMSE={mean_squared_error(y, p) ** 0.5:7.2f}  "
          f"MAE={mean_absolute_error(y, p):7.2f}  WAPE={wape(y, p):.3f}")
# среднее выигрывает по RMSE, медиана — по MAE. Метрика выбирает модель, а не наоборот.

Для ранжирования (MAP@k, NDCG@k, MRR) метрики устроены иначе — они учитывают позицию объекта в выдаче; разбор в статье про рекомендательные системы.

От метрики к решению: порог и матрица стоимостей

Отдельный шаг, который в учебниках часто теряется: даже идеально откалиброванная модель не принимает решений. Решение принимает порог, и выбирать его надо не по F1 (у F1 нет экономического смысла), а по ожидаемой полезности.

Пусть $C_{FP}$ — стоимость ложной тревоги, $C_{FN}$ — стоимость пропуска, $B_{TP}$ — выгода от пойманного позитива. Тогда для калиброванной вероятности $p$ выгодно дёрнуть объект, если

$$ p \cdot B_{TP} > (1 - p) \cdot C_{FP} \quad \Longleftrightarrow \quad p > \frac{C_{FP}}{B_{TP} + C_{FP}} $$

Оптимальный порог — не 0.5, а отношение стоимостей. При $C_{FP} = 200$ ₽ (звонок оператора) и $B_{TP} = 8000$ ₽ (спасённая транзакция) порог равен $200 / 8200 \approx 0.024$.

def expected_profit(y_true, proba, thr, b_tp=8000, c_fp=200, c_fn=0):
    """Ожидаемая прибыль политики 'дёргаем, если proba >= thr'."""
    pred = proba >= thr
    tp = np.sum(pred & (y_true == 1))
    fp = np.sum(pred & (y_true == 0))
    fn = np.sum(~pred & (y_true == 1))
    return tp * b_tp - fp * c_fp - fn * c_fn

# порог подбираем ТОЛЬКО на валидации, никогда на тесте
thresholds = np.linspace(0.001, 0.5, 500)
profits = [expected_profit(y_val, proba_val, t) for t in thresholds]
best_thr = thresholds[int(np.argmax(profits))]
print(f"оптимальный порог = {best_thr:.4f}, прибыль на валидации = {max(profits):,.0f} ₽")

Два важных нюанса. Первый: этот расчёт корректен только если вероятности откалиброваны — иначе $p$ в неравенстве не имеет смысла. Второй: порог — часть модели, и его надо подбирать на валидации, а замерять эффект на тесте, иначе получите тот самый оптимистический сдвиг. И третий, чисто операционный: если пропускная способность ограничена (оператор проверяет 500 кейсов в день), порог вообще не нужен — нужен top-k по скору.

Кросс-валидация

Один holdout даёт одно число с большой дисперсией. На выборке в 2000 объектов 20-процентный holdout — это 400 объектов; стандартная ошибка accuracy около 0.02, то есть разница между моделями в 3 п.п. неотличима от везения. Кросс-валидация решает это, переиспользуя данные: каждый объект ровно один раз побывал в тесте.

Какой вариант выбирать

Вариант Когда Замечание
KFold(k=5..10) базовый случай, i.i.d. данные k=5 — разумный дефолт по стоимости
StratifiedKFold классификация, особенно при дисбалансе сохраняет долю классов в каждом фолде; всегда для классификации
RepeatedStratifiedKFold мало данных, нужна устойчивая оценка 5×5 вместо 25 фолдов: меньше дисперсия оценки
GroupKFold / StratifiedGroupKFold есть повторные измерения одной сущности иначе один пациент/пользователь окажется и в train, и в test
TimeSeriesSplit временные данные никогда не обучаться на будущем
LeaveOneOut очень мало данных (< 100) почти несмещённая, но очень шумная и дорогая: $n$ обучений

О смещении и дисперсии самой CV: при малых $k$ каждая модель обучается на заметно меньшей выборке, поэтому оценка риска пессимистична (смещение вверх); при $k \to n$ (LOO) смещение почти исчезает, но обучающие выборки становятся почти идентичными, их ошибки — сильно коррелированными, и дисперсия усреднённой оценки растёт. Классический эмпирический разбор — Kohavi, «A Study of Cross-Validation and Bootstrap for Accuracy Estimation and Model Selection», IJCAI 1995. Отдельно стоит знать результат Bengio и Grandvalet: несмещённого оценщика дисперсии k-fold CV не существует (JMLR 2004) — поэтому «стандартная ошибка по фолдам» это эвристика, а не строгая величина.

Стоимость. k-fold стоит $k$ обучений: если модель учится $T$, полный перебор из $G$ конфигураций — $O(k \cdot G \cdot T)$. Для градиентного бустинга на миллионе строк это часы; отсюда практика: сначала грубый поиск на одном holdout, потом CV только для финалистов.

Временные ряды: расширяющееся окно, purging и embargo

Для временных данных случайный k-fold — прямая утечка будущего в прошлое. Правильная схема — walk-forward:

Три детали, которые отличают работающую схему от игрушечной:

  1. Embargo (зазор) между train и test. Если таргет считается по окну в 7 дней вперёд, последние 7 дней обучающего периода «знают» о тесте. Их надо выбросить.
  2. Purging — удаление обучающих примеров, чьи периоды наблюдения пересекаются с тестовыми. Подробно у Marcos López de Prado, Advances in Financial Machine Learning, глава 7.
  3. Тест должен повторять горизонт прода. Если в проде модель переобучается раз в месяц и предсказывает на месяц вперёд — фолды должны быть месячными, иначе метрика измеряет не тот режим.

Больше про специфику — в статье о временных рядах; про интуицию утечек — в данных и признаках.

Вложенная кросс-валидация

Если гиперпараметры подбираются по CV, то итоговая CV-оценка уже оптимистична: вы выбрали максимум по шумным числам. Честная схема — два вложенных цикла:

Важный концептуальный момент: вложенная CV оценивает не конкретную модель, а процедуру целиком — «обучение плюс подбор гиперпараметров». Именно это число надо ожидать в проде, если вы будете переобучать пайплайн автоматически. Про смещение при одноуровневом подборе: Varma и Simon, BMC Bioinformatics 2006 — на малых выборках завышение доходило до 20 п.п.

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV, StratifiedKFold, cross_val_score

# ВСЯ предобработка — внутри Pipeline, иначе скейлер увидит валидационный фолд (утечка)
pipe = Pipeline([
    ("scaler", StandardScaler()),
    ("clf", LogisticRegression(max_iter=1000, solver="liblinear")),
])
grid = {"clf__C": [0.01, 0.1, 1, 10, 100], "clf__penalty": ["l1", "l2"]}

inner = StratifiedKFold(n_splits=3, shuffle=True, random_state=42)
outer = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

search = GridSearchCV(pipe, grid, scoring="roc_auc", cv=inner, n_jobs=-1)
scores = cross_val_score(search, X, y, scoring="roc_auc", cv=outer, n_jobs=-1)

print(f"вложенная CV ROC-AUC: {scores.mean():.4f} ± {scores.std():.4f}")
# Сравните с search.fit(X, y).best_score_ — оно почти всегда выше. Это и есть смещение отбора.

Стоимость вложенной CV — $O(k_{out} \cdot k_{in} \cdot G \cdot T)$: при 5×3 и 10 конфигурациях это 150 обучений. Поэтому её применяют либо на небольших данных, либо один раз — чтобы понять величину смещения, а дальше жить с обычной схемой, зная поправку.

Насколько числу верить: интервалы и сравнение моделей

Бутстрап-интервал на тесте

Самый практичный способ приложить к метрике честный интервал — ресемплинг тестовой выборки. Работает для любой метрики, включая недифференцируемые вроде precision@k.

def bootstrap_ci(y_true, score, metric, n_boot=2000, alpha=0.05, seed=0):
    """Перцентильный доверительный интервал метрики. O(n_boot * n) по времени."""
    rng = np.random.default_rng(seed)
    n = len(y_true)
    stats = np.empty(n_boot)
    for b in range(n_boot):
        idx = rng.integers(0, n, n)                  # выборка с возвращением
        if len(np.unique(y_true[idx])) < 2:          # вырожденный ресемпл — пропускаем
            stats[b] = np.nan
            continue
        stats[b] = metric(y_true[idx], score[idx])
    lo, hi = np.nanpercentile(stats, [100 * alpha / 2, 100 * (1 - alpha / 2)])
    return metric(y_true, score), lo, hi

point, lo, hi = bootstrap_ci(y_true, score, roc_auc_score)
print(f"ROC-AUC = {point:.4f}, 95% CI = [{lo:.4f}, {hi:.4f}]")

При дисбалансе используйте стратифицированный бутстрап (ресемплить классы по отдельности) — иначе интервал будет шире реального из-за колебаний числа позитивов. И запомните ориентир: чтобы отличить AUC 0.80 от 0.82 на уровне значимости 5 %, нужны тысячи объектов позитивного класса, а не всего теста.

Сравнение двух моделей

Нельзя просто посмотреть «у модели A CV-скор 0.812, у B — 0.809, значит A лучше». Нужен парный тест.

  • По фолдам — парный t-тест на разностях, но с поправкой: обучающие выборки фолдов пересекаются, поэтому обычный t-тест сильно завышает значимость. Корректная версия — corrected resampled t-test (Nadeau и Bengio, Machine Learning, 2003):

$$ t = \frac{\bar{d}}{\sqrt{\left(\frac{1}{k} + \frac{n_{test}}{n_{train}}\right) \hat{\sigma}^2_d}} $$

Второе слагаемое в скобках — плата за перекрытие выборок; без него вы «докажете» значимость чего угодно.

  • По объектам на одном тесте — тест МакНемара (Dietterich, Neural Computation, 1998). Он смотрит только на объекты, где модели разошлись:
from scipy.stats import chi2

def mcnemar(y_true, pred_a, pred_b):
    """b = A права, B нет; c = наоборот. Учитываем только расхождения."""
    b = np.sum((pred_a == y_true) & (pred_b != y_true))
    c = np.sum((pred_a != y_true) & (pred_b == y_true))
    if b + c == 0:
        return 1.0, b, c
    stat = (abs(b - c) - 1) ** 2 / (b + c)         # с поправкой на непрерывность
    return 1 - chi2.cdf(stat, df=1), b, c

p, b, c = mcnemar(y_test, pred_a, pred_b)
print(f"A права/B нет: {b}, B права/A нет: {c}, p-value = {p:.4f}")
  • Множественные сравнения. Сравниваете 20 моделей — при $\alpha = 0.05$ примерно одна «победа» будет случайной. Применяйте поправку Холма или контроль FDR (Benjamini–Hochberg). Для сравнения многих моделей на многих датасетах стандарт — процедура Демшара, «Statistical Comparisons of Classifiers over Multiple Data Sets», JMLR 2006.

Практическое правило, которое экономит месяцы: если разница меньше разброса по фолдам, выбирайте более простую и дешёвую модель. Она почти наверняка окажется лучше в проде за счёт стабильности и стоимости поддержки.

Калибровка вероятностей

Зачем

Модель называется калиброванной, если среди объектов, которым она дала $\hat{p} = 0.8$, доля позитивов действительно 0.8:

$$ P\big(y = 1 \mid \hat{p}(x) = p\big) = p \quad \text{для всех } p \in [0, 1] $$

Пока вы просто сортируете объекты по риску, калибровка не нужна — AUC от неё не зависит. Она становится критичной, как только число $\hat{p}$ участвует в арифметике:

  • расчёт ожидаемой прибыли и выбор порога по стоимостям (см. выше);
  • ожидаемая выручка = $\sum_i \hat{p}_ i \cdot \text{LTV}_ i$ — некалиброванные вероятности дают систематически неверный прогноз бюджета;
  • каскады моделей: если выход одной модели — вход другой, смещение накапливается;
  • человеческие решения: врач или андеррайтер интерпретирует «85 %» буквально;
  • регуляторные и рисковые модели (PD в кредитном скоринге), где калибровка проверяется аудитом.

Диаграмма надёжности до и после калибровки

Почему модели откалиброваны плохо

  • Логистическая регрессия обучается на log loss (собственная функция потерь) и на своих же признаках обычно калибрована из коробки — редкое приятное исключение.
  • Градиентный бустинг и SVM оптимизируют другие вещи (экспоненциальную/hinge-потерю), их скоры сдвинуты к краям или сжаты к центру. Смотри ансамбли и бустинг.
  • Случайный лес усредняет голоса деревьев, поэтому вероятности «стянуты» к середине: чтобы получить 0.0, все деревья должны проголосовать одинаково.
  • Наивный Байес из-за нарушенного предположения о независимости даёт вероятности, прижатые к 0 и 1, — см. kNN и наивный Байес.
  • Глубокие сети переуверены тем сильнее, чем больше ёмкость: Guo и соавторы, «On Calibration of Modern Neural Networks», ICML 2017.
  • Любая модель после undersampling негативов калибрована по определению неверно — вы изменили априорную долю классов.

Классическое эмпирическое сравнение того, какие модели как искажают вероятности, — Niculescu-Mizil и Caruana, «Predicting Good Probabilities With Supervised Learning», ICML 2005.

Как измерять: ECE и её ловушки

Ожидаемая ошибка калибровки — взвешенное среднее по бинам:

$$ \text{ECE} = \sum_{m=1}^{M} \frac{|B_m|}{n} \left| \text{acc}(B_m) - \text{conf}(B_m) \right| $$

def expected_calibration_error(y_true, proba, n_bins=10, strategy="quantile"):
    """ECE. strategy='quantile' устойчивее при перекошенном распределении p̂."""
    if strategy == "quantile":
        edges = np.quantile(proba, np.linspace(0, 1, n_bins + 1))
        edges[0], edges[-1] = 0.0, 1.0 + 1e-9
    else:
        edges = np.linspace(0, 1, n_bins + 1)
    ece, n = 0.0, len(y_true)
    for lo, hi in zip(edges[:-1], edges[1:]):
        mask = (proba >= lo) & (proba < hi)
        if mask.sum() == 0:
            continue
        ece += mask.sum() / n * abs(y_true[mask].mean() - proba[mask].mean())
    return ece

Три подводных камня ECE, о которых редко пишут:

  1. Зависит от числа бинов. Мало бинов — недооценка, много — переоценка из-за шума. Всегда указывайте $M$ и стратегию; используйте квантильные бины.
  2. Смещена вверх на конечной выборке: даже идеально калиброванная модель даст ECE > 0. Считайте «нулевой уровень», прогнав ECE на синтетических метках, сгенерированных из $\hat{p}$.
  3. Не является собственным скоринговым правилом: модель, выдающая всем константу $\bar{y}$, имеет ECE = 0 и нулевую полезность. Поэтому ECE всегда смотрят вместе с Brier/log loss и с диаграммой надёжности, а не вместо них.

Как чинить

Метод Модель поправки Когда
Platt scaling сигмоида $\sigma(a s + b)$, 2 параметра мало данных (< 1000 в калибровочной выборке), искажение S-образное (SVM, бустинг)
Isotonic regression любая неубывающая ступенчатая функция много данных (> ~1000), произвольная форма искажения; склонна к переобучению на малых выборках
Temperature scaling softmax с делением логитов на $T$ нейросети, многоклассовость; один параметр, не меняет argmax и accuracy
Beta calibration трёхпараметрическое семейство компромисс между Platt и isotonic

Критически важно: калибровочная выборка должна быть отдельной. Калибровать на train — значит поймать переобучение модели вместо реального искажения. CalibratedClassifierCV делает это правильно через внутреннюю CV.

from sklearn.calibration import CalibratedClassifierCV
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import brier_score_loss, log_loss, roc_auc_score

base = GradientBoostingClassifier(random_state=0).fit(X_train, y_train)

# cv=5: модель обучается на 4/5, калибратор — на оставшейся 1/5, и так 5 раз
calibrated = CalibratedClassifierCV(
    GradientBoostingClassifier(random_state=0), method="isotonic", cv=5
).fit(X_train, y_train)

for name, model in (("сырой", base), ("isotonic", calibrated)):
    p = model.predict_proba(X_test)[:, 1]
    print(f"{name:9} Brier={brier_score_loss(y_test, p):.4f}  "
          f"LogLoss={log_loss(y_test, p):.4f}  "
          f"ECE={expected_calibration_error(y_test, p):.4f}  "
          f"ROC-AUC={roc_auc_score(y_test, p):.4f}")
# Brier, LogLoss и ECE улучшатся; ROC-AUC при Platt не изменится вовсе
# (сигмоида монотонна), при isotonic — почти не изменится (возможны склейки рангов).

Последняя строка — важный диагностический факт: монотонная калибровка не меняет ранжирование, значит не может улучшить AUC. Если после «калибровки» AUC заметно вырос — у вас утечка.

Поправка на undersampling

Если вы обучались на выборке, где негативы прорежены с коэффициентом $\beta$ (оставили долю $\beta$ негативов), вероятности надо вернуть к исходному априору:

$$ p = \frac{\beta , p_s}{\beta , p_s - p_s + 1} $$

где $p_s$ — вероятность на прореженных данных. Разбор — у Dal Pozzolo и соавторов, «Calibrating Probability with Undersampling for Unbalanced Classification», IEEE SSCI 2015. То же самое верно для class_weight="balanced" и SMOTE: любое изменение баланса классов ломает калибровку, и её надо восстанавливать явно.

Оценка в проде: офлайн-метрика ≠ бизнес-эффект

Самое частое разочарование в ML-проекте: офлайн ROC-AUC вырос с 0.81 до 0.85, а в A/B-тесте эффекта нет. Причины системные, и знать их надо заранее.

Что ломается между офлайн и онлайн:

  1. Train/serving skew. В офлайне признак «средний чек за 30 дней» посчитан по полной истории, в проде — по данным, доступным на момент запроса, с лагом репликации. Разные числа — разная модель. Ловится теневым режимом: гоняем прод-модель на живом трафике и сравниваем с офлайн-скорами по тем же объектам.
  2. Метрика-прокси не совпадает с целью. Вы оптимизируете CTR, бизнес хочет удержание. Модель научится кликбейту. Про это — Google, «Rules of Machine Learning» (правила 2, 12, 39 — обязательное чтение).
  3. Обратная связь. Модель влияет на данные, на которых будет обучаться следующая её версия: мы не видим меток по заявкам, которые сами же отклонили (selection bias, в кредитном скоринге — «reject inference»). Офлайн-метрика на таких данных систематически завышена.
  4. Отложенные метки. Метка «дефолт» приходит через год. Оценивать модель по свежему периоду нельзя вообще — там ещё нет правды; нужен либо старый период, либо ранние прокси-метки.
  5. Дрейф. Распределение признаков и связь $P(y|x)$ меняются. Мониторятся PSI/KL по признакам, доля пропусков, разброс скоров и — если метки есть — сама метрика на скользящем окне.

Что делать практически:

  • считать метрики по срезам, а не только целиком: по регионам, платформам, новым/старым пользователям, времени суток. Средняя метрика прячет провал на сегменте, который может оказаться самым дорогим;
  • держать набор guardrail-метрик в A/B: даже если целевая метрика выросла, рост жалоб или latency отменяет релиз;
  • фиксировать эталонный набор «золотых» примеров, на которых модель обязана работать всегда;
  • считать мощность эксперимента заранее — иначе A/B на 3 днях просто не способен увидеть эффект в 1 %. Стандартный источник — Kohavi, Tang, Xu, Trustworthy Online Controlled Experiments (Cambridge, 2020), и экспериментальная платформа Netflix.

Инфраструктурная сторона (реестр моделей, версионирование метрик, автоматический откат) разобрана в статье про MLOps.

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

  1. Accuracy на несбалансированных данных. Классика; лечится MCC/PR-AUC/balanced accuracy.
  2. Предобработка вне пайплайна. StandardScaler().fit(X) до cross_val_score — утечка: среднее и дисперсия посчитаны с участием валидационных фолдов. Всё, что «учится» на данных (скейлеры, импьютеры, отбор признаков, таргет-энкодинг, SMOTE), должно быть внутри Pipeline.
  3. Отбор признаков до CV. Выбрали 100 признаков с наибольшей корреляцией с таргетом на всех данных, потом честно кросс-валидируете — оценка завышена драматически. На случайном шуме такая процедура даёт «AUC 0.9» (ESL, раздел 7.10.2).
  4. Ресемплинг перед разбиением. SMOTE до train_test_split копирует синтетические клоны позитивов в обе части. Метрика становится фантастической, прод — нет.
  5. Порог по F1 вместо экономики. F1 — метрика без денежного смысла; порог должен выводиться из стоимостей ошибок.
  6. Случайный k-fold на временных данных или на данных с группами. Пользователь/пациент/ сессия попадает в train и test одновременно.
  7. Тест как валидация. «Мы просто ещё разок проверим на тесте» — самый дорогой разок в проекте.
  8. Сравнение метрик, посчитанных на разных выборках. R² и accuracy зависят от дисперсии и баланса конкретного набора; «0.85 у нас против 0.83 в статье» ничего не значит.
  9. Игнорирование доверительных интервалов. Три знака после запятой без интервала — ложная точность.
  10. Метрики только «в среднем». Модель, которая на 5 % трафика работает как случайная, может иметь отличный общий AUC.
  11. Калибровка на train. Даёт красивую диаграмму надёжности и неверные вероятности в проде.
  12. Забытая коррекция после балансировки классов. Вероятности сдвинуты в разы.

Мини-итог

  • Метрика — это формализация цели. Начинайте с вопроса «какое решение принимается по предсказанию и сколько стоят ошибки», а не с списка функций в sklearn.metrics.
  • Классификация: матрица ошибок → precision/recall/F-beta/MCC при пороге; ROC-AUC при балансе, PR-AUC и precision@k при дисбалансе; log loss и Brier — когда важны сами вероятности.
  • Регрессия: RMSE предсказывает среднее, MAE — медиану, pinball — квантиль. Выбор метрики меняет то, какую модель вы получите; MAPE применяйте только при заведомо больших положительных $y$.
  • Протокол важнее метрики. Разбиение по времени/группам, вся предобработка в пайплайне, тест — один раз, гиперпараметры — только на валидации, вложенная CV если хотите честно оценить процедуру целиком.
  • Число без интервала — не результат. Бутстрап на тесте, парные тесты для сравнения моделей, поправка на множественные сравнения.
  • Калибровка — отдельное измерение качества, ортогональное ранжированию. Нужна везде, где вероятность попадает в формулу. Меряется reliability-диаграммой плюс Brier/log loss, чинится Platt/isotonic/temperature на отдельной выборке.
  • Офлайн-метрика — гипотеза, а не результат. Подтверждают её shadow-режим и A/B, а поддерживают — мониторинг дрейфа и метрики по срезам.

Источники

  • Hastie, Tibshirani, Friedman. The Elements of Statistical Learning, глава 7 «Model Assessment and Selection» — бесплатный PDF
  • Kohavi. A Study of Cross-Validation and Bootstrap for Accuracy Estimation and Model Selection, IJCAI 1995 — PDF
  • Cawley, Talbot. On Over-fitting in Model Selection and Subsequent Selection Bias in Performance Evaluation, JMLR 2010 — статья
  • Varma, Simon. Bias in error estimation when using cross-validation for model selection, BMC Bioinformatics 2006 — статья
  • Bengio, Grandvalet. No Unbiased Estimator of the Variance of K-Fold Cross-Validation, JMLR 2004 — статья
  • Davis, Goadrich. The Relationship Between Precision-Recall and ROC Curves, ICML 2006 — PDF
  • Saito, Rehmsmeier. The Precision-Recall Plot Is More Informative than the ROC Plot When Evaluating Binary Classifiers on Imbalanced Datasets, PLOS ONE 2015 — статья
  • Niculescu-Mizil, Caruana. Predicting Good Probabilities With Supervised Learning, ICML 2005 — PDF
  • Guo et al. On Calibration of Modern Neural Networks, ICML 2017 — arXiv:1706.04599
  • Gneiting, Raftery. Strictly Proper Scoring Rules, Prediction, and Estimation, JASA 2007 — PDF
  • Dietterich. Approximate Statistical Tests for Comparing Supervised Classification Learning Algorithms, Neural Computation 1998 — статья
  • Nadeau, Bengio. Inference for the Generalization Error, Machine Learning 2003 — Springer
  • Demšar. Statistical Comparisons of Classifiers over Multiple Data Sets, JMLR 2006 — статья
  • Kaufman, Rosset, Perlich. Leakage in Data Mining: Formulation, Detection, and Avoidance, ACM TKDD 2012 — статья
  • Kohavi, Tang, Xu. Trustworthy Online Controlled Experiments, Cambridge University Press, 2020
  • Hyndman, Athanasopoulos. Forecasting: Principles and Practice, раздел про time series cross-validation — otexts.com/fpp3/tscv.html
  • Документация: Model evaluation, Cross-validation, Probability calibration

Что дальше

Мы научились честно измерять качество — и главный симптом, который эти измерения выявляют, всегда один: разрыв между ошибкой на обучении и ошибкой на новых данных. Следующая статья разбирает его природу: откуда берётся переобучение, как раскладывается ошибка на смещение и разброс, почему увеличение данных помогает не всегда, и какие механизмы — от L1/L2 и dropout до early stopping и ограничения ёмкости — позволяют этим разрывом управлять.

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

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

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

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

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

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