Если положительный класс редкий — фрод 0.1%, отток 3%, дефолт по кредиту 2% — модель с точностью 99% почти наверняка бесполезна. Она просто выучила «отвечай всегда ноль» и на этом набрала свои проценты. Бороться нужно в таком порядке: сменить метрику (PR-AUC и F1, а не accuracy), добавить веса классов (class_weight='balanced'), подобрать порог решения. Oversampling, undersampling и SMOTE — тяжёлая артиллерия, к которой я подключаюсь последней и далеко не всегда. Дальше разберу, почему именно так, и покажу код на sklearn.
Тема живучая: дисбаланс спрашивают почти на каждом собеседовании в банки и маркетплейсы, где фрод и отток — ежедневная работа. Если готовитесь к таким интервью, загляните в вопросы с реальных собеседований и подборку компаний — Сбер, T-Bank, Ozon, Avito, там это классика.
Почему на дисбалансе модель учится предсказывать «всё отрицательное»?
Потому что функция потерь оптимизирует общую ошибку, а редкому классу в ней почти нет места. Представьте payments: 200 000 транзакций, из них 800 фродовых. Логистическая регрессия минимизирует log-loss по всей выборке. Если модель для всех предскажет «не фрод», она ошибётся всего на 800 объектах из 200 000 — это 0.4% ошибки, и по метрике потерь такой прогноз уже почти оптимален. Градиенту просто невыгодно тянуться к редким примерам: их вклад в суммарный лосс тонет среди 199 200 отрицательных.
В итоге модель выдаёт всем низкие вероятности, честный ноль на пороге 0.5, и формально она «отличная». Это не баг обучения — это ровно то, что вы попросили оптимизировать. Проблема в постановке: общая ошибка не равна бизнес-цели «поймать мошенника».
Почему accuracy — плохая метрика для редкого класса?
Потому что accuracy при дисбалансе меряет, как хорошо модель угадывает большинство, а большинство и так очевидно. При доле фрода 0.4% модель «всё отрицательное» даёт 99.6% accuracy и ловит ноль мошенников. Цифра красивая, пользы ноль. Нужны метрики, сфокусированные на положительном классе:
- Precision — из тех, кого пометили фродом, сколько реально фрод. Отвечает за ложные срабатывания.
- Recall (полнота, TPR) — из всех настоящих фродов сколько поймали. Отвечает за пропуски.
- F1 — гармоническое среднее precision и recall, один компромиссный балл.
- PR-AUC (average precision) — площадь под precision-recall кривой. Вот это правильная сводная метрика для дисбаланса.
Отдельно про ROC-AUC: на сильном дисбалансе он врёт в плюс. FPR считается относительно гигантского числа отрицательных, поэтому даже плохая модель держит FPR крошечным, и ROC-кривая выглядит шикарно при мусорной precision. PR-AUC такого не прощает — он смотрит именно на цену ошибок по редкому классу. Всегда держите под рукой ещё и confusion matrix: она сразу показывает, ловите вы фрод ценой блокировки половины честных клиентов или нет.
Ещё одна метрика из реального прода — precision@k. Антифрод-команда разбирает не «все сработки подряд», а фиксированный бюджет алертов в день, скажем 500 транзакций на ручную проверку. Поэтому меня в первую очередь интересует, сколько настоящих фродов в топ-500 самых подозрительных по вероятности. Если из 500 реальными оказались 300 — вот это польза, которую увидит бизнес. PR-AUC хорош для сравнения моделей между собой, а precision@k — для разговора с продуктом на языке денег и человеко-часов.
Метрику выбирают под деньги. Пропущенный фрод (FN) и заблокированный хороший клиент (FP) стоят по-разному. Прежде чем крутить модель, спросите продакта, что дороже — и оптимизируйте под это, а не под абстрактный F1.
Что такое веса классов и когда их хватает?
Веса классов говорят модели штрафовать ошибку на редком классе сильнее, чем на частом. class_weight='balanced' в sklearn ставит вес обратно пропорционально частоте класса: формула n_samples / (n_classes * n_class_samples). При соотношении 1:250 один фродовый пример начинает «весить» примерно как 250 обычных, и градиент наконец его замечает.
Это мой дефолтный первый ход. Он ничего не стоит, не требует новых данных, не создаёт риска утечки и меньше ломает калибровку вероятностей, чем ресемплинг. Часто этого одного достаточно.
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=42
)
model = LogisticRegression(class_weight='balanced', max_iter=1000)
model.fit(X_train, y_train)
class_weight есть у LogisticRegression, SVC, RandomForestClassifier, DecisionTreeClassifier. В градиентном бустинге (XGBoost, LightGBM) аналог называется scale_pos_weight и задаётся как count(neg) / count(pos). Обратите внимание на stratify=y в сплите — без него в тесте может оказаться совсем мало (или ноль) положительных, и метрики поедут.
И balanced — не догма. Иногда полный вес по частоте слишком агрессивен: модель ловит почти весь фрод, но заваливает precision и блокирует толпу честных клиентов. Тогда я задаю веса вручную, например class_weight={0: 1, 1: 50} вместо неявных 1:250, и подбираю множитель по валидации как обычный гиперпараметр — под ту точку precision/recall, которая устраивает бизнес.
Oversampling или undersampling — что выбрать?
Оба способа насильно выравнивают тренировочную выборку, и выбор между ними — это про размер данных. Undersampling выкидывает отрицательные примеры, пока классы не сравняются: быстро, дёшево на памяти, отлично когда отрицательных и так миллионы. Минус — вы выбрасываете информацию. Oversampling дублирует положительные: ничего не теряете, но простое копирование строк даёт модели шанс запомнить конкретные точки и переобучиться.
from imblearn.under_sampling import RandomUnderSampler
from imblearn.over_sampling import RandomOverSampler
# на больших данных — недосэмплинг большинства
X_us, y_us = RandomUnderSampler(random_state=42).fit_resample(X_train, y_train)
# на маленьких — передискретизация меньшинства
X_os, y_os = RandomOverSampler(random_state=42).fit_resample(X_train, y_train)
Правило простое: много данных — undersample большинство, мало данных — oversample меньшинство или просто веса. Ресемплить можно только train, тест не трогаем никогда. Хороший компромисс — обучить ансамбль на нескольких разных недосэмплированных подвыборках (EasyEnsemble/BalancedBagging): не теряете информацию совсем и не раздуваете обучение.
Как работает SMOTE и в чём его риски?
SMOTE (Synthetic Minority Over-sampling Technique) не копирует редкие примеры, а генерирует новые синтетические. Для каждой точки меньшинства он находит k ближайших соседей того же класса и создаёт новую точку на отрезке между ними со случайным сдвигом. Получаются «правдоподобные» новые случаи фрода вместо тупых дублей. Звучит красиво, но рисков хватает:
- Синтез в чужой зоне. SMOTE интерполирует в пространстве признаков. Если фродовые точки лежат вперемешку с честными (а во фроде так почти всегда), новые точки падают прямо в область большинства — вы своими руками добавляете шум и размываете границу.
- Усиление шума в разметке. Один неправильно размеченный «фрод» породит вокруг себя целое семейство фейковых фродов.
- Плохо с категориями и высокой размерностью. Расстояние между one-hot признаками теряет смысл. Есть SMOTE-NC для смешанных типов, но и он капризен.
- Ломает калибровку. После ресемплинга базовая доля класса искусственная, и
predict_probaбольше не отражает реальную априорную вероятность. Если вам нужны честные вероятности, а не только ранжирование, придётся перекалибровать. - Ловушка утечки. SMOTE до сплита — классическая ошибка: синтетические соседи протекают в тест, и метрики взлетают на пустом месте. SMOTE применяют только внутри пайплайна на тренировочных фолдах.
from imblearn.pipeline import Pipeline
from imblearn.over_sampling import SMOTE
pipe = Pipeline([
('smote', SMOTE(random_state=42, k_neighbors=5)),
('clf', LogisticRegression(max_iter=1000)),
])
pipe.fit(X_train, y_train) # SMOTE отработает ТОЛЬКО на train
У SMOTE есть вариации — BorderlineSMOTE синтезирует точки только у границы классов, ADASYN даёт больше синтетики там, где меньшинство хуже всего отделяется. Иногда они помогают, но лечат они следствие, а не причину, и все перечисленные риски остаются в силе.
Честно: на табличном фроде SMOTE редко обыгрывает нормально настроенные веса плюс порог. Я достаю его, когда положительных мало в абсолюте (сотни, а не просто малая доля) и классы при этом более-менее разделимы. Во всех остальных случаях он чаще добавляет проблем, чем ловит фрод.
Зачем сдвигать порог и как подобрать его правильно?
Затем, что по умолчанию классификатор режет на вероятности 0.5, а на дисбалансе модель почти никогда не выдаёт больше 0.5 для редкого класса — и на этом пороге вы не ловите вообще ничего. Но predict_proba при этом ранжирует объекты правильно: настоящие фроды получают вероятность выше, чем честные транзакции, просто вся шкала сдвинута вниз. Значит, нужен порог пониже, и подбирают его по метрике, которая вам важна.
Это самый дешёвый и самый недооценённый приём: ни переобучения, ни синтетики, работает на уже готовой модели.
import numpy as np
from sklearn.metrics import precision_recall_curve
proba = model.predict_proba(X_test)[:, 1]
prec, rec, thr = precision_recall_curve(y_test, proba)
f1 = 2 * prec * rec / (prec + rec + 1e-9)
best_idx = f1[:-1].argmax() # thr короче prec/rec на 1 элемент
best_threshold = thr[best_idx]
y_pred = (proba >= best_threshold).astype(int)
print('Порог:', round(float(best_threshold), 3))
Важно: порог подбирают на валидации, а финальные числа считают на отложенном тесте — иначе вы подгоняетесь под тест и получаете завышенную оценку. И помните, что порог — это и есть закодированный компромисс precision/recall: сколько честных клиентов вы готовы заблокировать, чтобы поймать одного мошенника. Это бизнес-решение, а не гиперпараметр из воздуха.
Полный пример на sklearn: сравниваем подходы честно
Соберём три модели на одних данных и сравним по PR-AUC — так видно, что реально помогает, а что косметика. Логику проверки удобно повторить в Python-тренажёре на своих данных.
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score, classification_report
from sklearn.model_selection import train_test_split
from imblearn.pipeline import Pipeline
from imblearn.over_sampling import SMOTE
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=42
)
def pr_auc(model, name):
proba = model.predict_proba(X_test)[:, 1]
print(name, 'PR-AUC:', round(average_precision_score(y_test, proba), 4))
return proba
# 1. Базовая модель — как есть
base = LogisticRegression(max_iter=1000).fit(X_train, y_train)
pr_auc(base, 'baseline')
# 2. Веса классов — бесплатный первый ход
weighted = LogisticRegression(class_weight='balanced', max_iter=1000).fit(X_train, y_train)
proba_w = pr_auc(weighted, 'class_weight')
# 3. SMOTE внутри пайплайна — без утечки
smote = Pipeline([
('smote', SMOTE(random_state=42)),
('clf', LogisticRegression(max_iter=1000)),
]).fit(X_train, y_train)
pr_auc(smote, 'smote')
# отчёт для лучшей модели после сдвига порога
print(classification_report(y_test, (proba_w >= 0.3).astype(int), digits=3))
Смотрим не на accuracy, а на PR-AUC и на precision/recall в отчёте. У меня в проде на подобных задачах class_weight плюс подобранный порог почти всегда идут вровень со SMOTE или обгоняют его, при этом код проще и вероятности честнее. Больше готовых сниппетов по pandas и sklearn — в Python-справочнике, а отработать на задачах уровня собеса можно в разделе с заданиями и бизнес-кейсах.
Что выбрать на практике?
Начинайте с дешёвого и добавляйте сложность, только если предыдущий шаг не вытянул. Мой рабочий чеклист:
- Всегда: стратифицированный сплит, метрики PR-AUC + F1 + confusion matrix. Accuracy забудьте.
- Первый ход:
class_weight='balanced'(илиscale_pos_weightв бустинге). Бесплатно и часто достаточно. - Второй ход: подбор порога на валидации под нужный компромисс precision/recall. Тоже бесплатно, часто даёт самый большой прирост.
- Много данных и медленное обучение: undersample большинство или BalancedBagging.
- Положительных реально мало и классы разделимы: пробуйте SMOTE строго внутри пайплайна и оставляйте, только если он честно выиграл по PR-AUC на тесте.
- Если можете — добавьте данных или признаков. Один сильный признак (например, отклонение суммы транзакции от привычного профиля клиента) обыгрывает любой ресемплинг.
Главная ошибка новичка — сразу тянуться к SMOTE, потому что он звучит умно. В реальности порядок обратный: сначала правильная метрика и веса, потом порог, и только в узких случаях синтетика. Дисбаланс — это в первую очередь про постановку задачи и про деньги за ошибку, а не про хитрый семплер.
Такие задачи спрашивают на собеседованиях в data-команды, и вилки там соответствующие — актуальные цифры смотрите в разборе зарплат аналитиков и DS. А если хотите отработать весь цикл от метрик до кода на живых датасетах с автопроверкой — на сайте открыты первые 5 задач в каждом разделе бесплатно, а Pro снимает лимиты на все тренажёры, кейсы и AI мок-собеседования.
Связанная тема — ROC-AUC vs PR-AUC: какую метрику выбрать.