MLдисбаланс классовSMOTEклассификация

Дисбаланс классов в машинном обучении: как с ним бороться

2026-07-12 12 мин

Если положительный класс редкий — фрод 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 и ловит ноль мошенников. Цифра красивая, пользы ноль. Нужны метрики, сфокусированные на положительном классе:

Отдельно про 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 ближайших соседей того же класса и создаёт новую точку на отрезке между ними со случайным сдвигом. Получаются «правдоподобные» новые случаи фрода вместо тупых дублей. Звучит красиво, но рисков хватает:

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-справочнике, а отработать на задачах уровня собеса можно в разделе с заданиями и бизнес-кейсах.

Что выбрать на практике?

Начинайте с дешёвого и добавляйте сложность, только если предыдущий шаг не вытянул. Мой рабочий чеклист:

Главная ошибка новичка — сразу тянуться к SMOTE, потому что он звучит умно. В реальности порядок обратный: сначала правильная метрика и веса, потом порог, и только в узких случаях синтетика. Дисбаланс — это в первую очередь про постановку задачи и про деньги за ошибку, а не про хитрый семплер.

Такие задачи спрашивают на собеседованиях в data-команды, и вилки там соответствующие — актуальные цифры смотрите в разборе зарплат аналитиков и DS. А если хотите отработать весь цикл от метрик до кода на живых датасетах с автопроверкой — на сайте открыты первые 5 задач в каждом разделе бесплатно, а Pro снимает лимиты на все тренажёры, кейсы и AI мок-собеседования.

Связанная тема — ROC-AUC vs PR-AUC: какую метрику выбрать.

Закрепи на практике
Python-задачи через pandas/numpy/scipy — попробуй бесплатно.
Python-тренажёр →