Оценка моделей: метрики, кросс-валидация, калибровка
Предыдущие девять статей трека учили строить модели. Эта — единственная, без которой все они бесполезны. Причина простая: модель, качество которой вы не умеете измерять, не отличима от случайного генератора, а «улучшение», которое вы не умеете подтверждать, статистически не отличимо от шума.
Хуже того: измерение — это и есть определение задачи. Скажите «мы делаем антифрод»,
и ничего не понятно. Скажите «мы максимизируем recall при precision ≥ 0.9 на транзакциях
дороже 10 000 ₽, где метка приходит через 45 дней» — и половина архитектурных решений
уже принята: понятно, как резать выборку, какой порог настраивать, зачем нужна калибровка
и почему обычный train_test_split здесь ведёт к катастрофе.
Эта статья — про три уровня вопроса «насколько модель хороша»:
- Какой метрикой мерить — чтобы число отражало реальную пользу, а не удобство библиотеки.
- На каких данных мерить — чтобы число не оказалось завышенным из-за утечки или подглядывания.
- Насколько числу верить — чтобы разница 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
Минимальная честная схема выглядит так:
структуры данных} B -->|~60%| C[Train
подгонка параметров] B -->|~20%| D[Validation
выбор гиперпараметров,
порога, признаков] B -->|~20%| E[Test
финальная оценка,
трогать один раз] C --> F[Обучение модели] D --> G[Перебор конфигураций] F --> G G --> H[Лучшая конфигурация] H --> I[Переобучение на train + validation] I --> J[Единственный замер на Test] E --> J J --> K{Оценка приемлема?} K -->|да| L[Кандидат в прод:
shadow, затем A/B] K -->|нет| M[Назад к данным и признакам,
НЕ к перебору на тесте] M --> B
Три роли, которые нельзя смешивать:
- 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-кривая — это два взгляда на одно и то же распределение скоров.
Слева видно главное: модель выдаёт не класс, а скор; класс появляется только когда вы провели вертикальную черту. Сдвиг черты меняет 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|$: те же проценты, но без деления на отдельные малые значения.
- R²: $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 п.п. неотличима от везения. Кросс-валидация решает это, переиспользуя данные: каждый объект ровно один раз побывал в тесте.
больше разброса?"} S --> D D -->|да| Y["есть основания выбрать"] D -->|нет| N["различие в пределах шума"]
Какой вариант выбирать
| Вариант | Когда | Замечание |
|---|---|---|
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:
Три детали, которые отличают работающую схему от игрушечной:
- Embargo (зазор) между train и test. Если таргет считается по окну в 7 дней вперёд, последние 7 дней обучающего периода «знают» о тесте. Их надо выбросить.
- Purging — удаление обучающих примеров, чьи периоды наблюдения пересекаются с тестовыми. Подробно у Marcos López de Prado, Advances in Financial Machine Learning, глава 7.
- Тест должен повторять горизонт прода. Если в проде модель переобучается раз в месяц и предсказывает на месяц вперёд — фолды должны быть месячными, иначе метрика измеряет не тот режим.
Больше про специфику — в статье о временных рядах; про интуицию утечек — в данных и признаках.
Вложенная кросс-валидация
Если гиперпараметры подбираются по CV, то итоговая CV-оценка уже оптимистична: вы выбрали максимум по шумным числам. Честная схема — два вложенных цикла:
на внешнем train} I --> G[Перебор гиперпараметров] G --> BP[Лучшие гиперпараметры
для ЭТОГО внешнего фолда] BP --> R[Переобучение на всём внешнем train] R --> E[Замер на внешнем test] OV --> E E --> A[5 честных оценок] A --> F["среднее ± разброс = несмещённая оценка
ВСЕЙ процедуры, включая подбор"]
Важный концептуальный момент: вложенная 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, о которых редко пишут:
- Зависит от числа бинов. Мало бинов — недооценка, много — переоценка из-за шума. Всегда указывайте $M$ и стратегию; используйте квантильные бины.
- Смещена вверх на конечной выборке: даже идеально калиброванная модель даст ECE > 0. Считайте «нулевой уровень», прогнав ECE на синтетических метках, сгенерированных из $\hat{p}$.
- Не является собственным скоринговым правилом: модель, выдающая всем константу $\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-тесте эффекта нет. Причины системные, и знать их надо заранее.
чаще всего беда именно тут DS->>AB: 5% трафика, заранее посчитанная мощность AB-->>DS: бизнес-метрика +1.2% ± 0.9%, p = 0.03 Note over AB: guardrail-метрики: жалобы,
latency, конверсия смежных сценариев DS->>PR: раскатка 100% при положительном решении PR-->>DS: дрейф признаков, PSI, деградация метрик PR->>DS: триггер переобучения
Что ломается между офлайн и онлайн:
- Train/serving skew. В офлайне признак «средний чек за 30 дней» посчитан по полной истории, в проде — по данным, доступным на момент запроса, с лагом репликации. Разные числа — разная модель. Ловится теневым режимом: гоняем прод-модель на живом трафике и сравниваем с офлайн-скорами по тем же объектам.
- Метрика-прокси не совпадает с целью. Вы оптимизируете CTR, бизнес хочет удержание. Модель научится кликбейту. Про это — Google, «Rules of Machine Learning» (правила 2, 12, 39 — обязательное чтение).
- Обратная связь. Модель влияет на данные, на которых будет обучаться следующая её версия: мы не видим меток по заявкам, которые сами же отклонили (selection bias, в кредитном скоринге — «reject inference»). Офлайн-метрика на таких данных систематически завышена.
- Отложенные метки. Метка «дефолт» приходит через год. Оценивать модель по свежему периоду нельзя вообще — там ещё нет правды; нужен либо старый период, либо ранние прокси-метки.
- Дрейф. Распределение признаков и связь $P(y|x)$ меняются. Мониторятся PSI/KL по признакам, доля пропусков, разброс скоров и — если метки есть — сама метрика на скользящем окне.
Что делать практически:
- считать метрики по срезам, а не только целиком: по регионам, платформам, новым/старым пользователям, времени суток. Средняя метрика прячет провал на сегменте, который может оказаться самым дорогим;
- держать набор guardrail-метрик в A/B: даже если целевая метрика выросла, рост жалоб или latency отменяет релиз;
- фиксировать эталонный набор «золотых» примеров, на которых модель обязана работать всегда;
- считать мощность эксперимента заранее — иначе A/B на 3 днях просто не способен увидеть эффект в 1 %. Стандартный источник — Kohavi, Tang, Xu, Trustworthy Online Controlled Experiments (Cambridge, 2020), и экспериментальная платформа Netflix.
Инфраструктурная сторона (реестр моделей, версионирование метрик, автоматический откат) разобрана в статье про MLOps.
Типичные ошибки
- Accuracy на несбалансированных данных. Классика; лечится MCC/PR-AUC/balanced accuracy.
- Предобработка вне пайплайна.
StandardScaler().fit(X)доcross_val_score— утечка: среднее и дисперсия посчитаны с участием валидационных фолдов. Всё, что «учится» на данных (скейлеры, импьютеры, отбор признаков, таргет-энкодинг, SMOTE), должно быть внутриPipeline. - Отбор признаков до CV. Выбрали 100 признаков с наибольшей корреляцией с таргетом на всех данных, потом честно кросс-валидируете — оценка завышена драматически. На случайном шуме такая процедура даёт «AUC 0.9» (ESL, раздел 7.10.2).
- Ресемплинг перед разбиением. SMOTE до
train_test_splitкопирует синтетические клоны позитивов в обе части. Метрика становится фантастической, прод — нет. - Порог по F1 вместо экономики. F1 — метрика без денежного смысла; порог должен выводиться из стоимостей ошибок.
- Случайный k-fold на временных данных или на данных с группами. Пользователь/пациент/ сессия попадает в train и test одновременно.
- Тест как валидация. «Мы просто ещё разок проверим на тесте» — самый дорогой разок в проекте.
- Сравнение метрик, посчитанных на разных выборках. R² и accuracy зависят от дисперсии и баланса конкретного набора; «0.85 у нас против 0.83 в статье» ничего не значит.
- Игнорирование доверительных интервалов. Три знака после запятой без интервала — ложная точность.
- Метрики только «в среднем». Модель, которая на 5 % трафика работает как случайная, может иметь отличный общий AUC.
- Калибровка на train. Даёт красивую диаграмму надёжности и неверные вероятности в проде.
- Забытая коррекция после балансировки классов. Вероятности сдвинуты в разы.
Мини-итог
- Метрика — это формализация цели. Начинайте с вопроса «какое решение принимается
по предсказанию и сколько стоят ошибки», а не с списка функций в
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 и регуляризация
Если нужно вернуться к основаниям — постановка задач и типы обучения собраны в карте трека, а корректная подготовка данных и профилактика утечек — в данных и признаках.