Фундамент
~15 мин

Несбалансированные данные

SMOTE, class weights, подбор порога, PR-AUC — как работать с дисбалансом классов.

Несбалансированные данные — когда accuracy врёт, а модель бесполезна

Представь: ты строишь антифрод-систему. В данных 0.1% мошеннических транзакций и 99.9% легитимных. Ты обучаешь модель, получаешь accuracy = 99.9% — и радуешься. Но модель просто всем предсказывает «не фрод». Она не поймала ни одного мошенника. Accuracy = 99.9%, полезность = 0%.

Это не баг в sklearn — это фундаментальная проблема несбалансированных данных. Когда один класс доминирует, стандартные алгоритмы минимизируют общую ошибку и просто «забивают» на минорный класс. А ведь именно минорный класс — болезнь, фрод, дефект — обычно самый важный.

Стратегии работы с несбалансированными данными
Три основных подхода к борьбе с дисбалансом: resampling, class weights, threshold tuning

Масштаб проблемы: где дисбаланс в реальном мире

Дисбаланс — не исключение, а норма в индустриальном ML. Конкретные цифры по доменам: кредитный фрод — 0.1-0.3% положительного класса, медицинская диагностика — 1-5% (рак, редкие заболевания), клики по рекламе (CTR) — 1-3%, отток клиентов (churn) — 5-15%, дефекты на производстве — 0.5-2%. Почти в каждой реальной задаче классификации классы распределены неравномерно.

Правило

Если minority class < 10% — стандартные алгоритмы с дефолтными настройками будут его игнорировать. Чем сильнее дисбаланс, тем больше специальных техник нужно.

Метрики для несбалансированных данных

Accuracy бесполезна при дисбалансе. Главные метрики: Precision, Recall, F1 и, что критически важно, PR-AUC (Precision-Recall Area Under Curve). Почему не ROC-AUC? При сильном дисбалансе ROC-AUC обманчиво высока — модель с PR-AUC = 0.15 может иметь ROC-AUC = 0.95, потому что огромное число True Negatives «разбавляет» FPR.

PR-AUC — площадь под Precision-Recall кривой. Не зависит от TN, честно отражает качество на минорном классе

from sklearn.metrics import (
    precision_recall_curve, average_precision_score,
    roc_auc_score
)
import numpy as np

# Имитация: 1% positive, модель с умеренным качеством
np.random.seed(42)
n = 10000
y_true = np.zeros(n, dtype=int)
y_true[:100] = 1  # 1% positive
np.random.shuffle(y_true)

# Скоры модели (чуть лучше рандома для positive)
y_score = np.random.beta(2, 5, n)
y_score[y_true == 1] += 0.3
y_score = np.clip(y_score, 0, 1)

roc_auc = roc_auc_score(y_true, y_score)
pr_auc = average_precision_score(y_true, y_score)

print(f"ROC-AUC: {roc_auc:.3f}")   # ~0.92 — выглядит отлично!
print(f"PR-AUC:  {pr_auc:.3f}")     # ~0.15 — реальное качество
# ROC-AUC обманывает — модель на деле слабая

Конкретный пример: при 1% positive rate модель с ROC-AUC = 0.95 может иметь PR-AUC всего 0.15. Это значит, что при разумном пороге precision будет ~20%, а recall ~30%. ROC-AUC «видит» 9900 правильных TN и считает, что всё хорошо. PR-AUC честно показывает, что модель плохо находит positive.

Resampling: SMOTE, undersampling, oversampling

Три подхода к выравниванию распределения: oversampling (дублируем/генерируем minority), undersampling (удаляем majority), гибрид (комбинация). Oversampling без генерации — просто дублирование, ведёт к переобучению. Undersampling теряет информацию из majority. SMOTE — золотая середина.

SMOTE — Synthetic Minority Over-sampling Technique

SMOTE работает так: для каждого minority-объекта берёт k ближайших соседей (обычно k=5), выбирает случайного соседа, создаёт новый объект на отрезке между ними (линейная интерполяция). Генерирует синтетические, а не дублированные примеры. Когда SMOTE не работает: minority < 1% (слишком мало соседей), высокоразмерные разреженные данные (TF-IDF, one-hot на 1000+ фич), сильно перекрывающиеся классы (SMOTE генерирует шум в зоне пересечения).

from imblearn.pipeline import Pipeline
from imblearn.over_sampling import SMOTE
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score
from sklearn.datasets import make_classification

# Генерируем несбалансированные данные: 5% minority
X, y = make_classification(
    n_samples=5000, n_features=20,
    weights=[0.95, 0.05], random_state=42
)

# КРИТИЧЕСКИ ВАЖНО: SMOTE только внутри Pipeline!
# Pipeline гарантирует: SMOTE на train, NOT на val/test
pipe = Pipeline([
    ('smote', SMOTE(random_state=42, k_neighbors=5)),
    ('clf', RandomForestClassifier(n_estimators=100, random_state=42))
])

# cross_val_score сначала split, потом fit (с SMOTE) только на train fold
scores = cross_val_score(pipe, X, y, cv=5, scoring='average_precision')
print(f"PR-AUC (SMOTE + RF): {scores.mean():.3f} +/- {scores.std():.3f}")
# ~0.72 +/- 0.05 — значительно лучше, чем без SMOTE

Random undersampling + ensemble

Альтернатива SMOTE — BalancedRandomForest и EasyEnsemble. Идея: вместо удаления majority один раз, создаём несколько подвыборок, обучаем на каждой отдельный классификатор, агрегируем предсказания. BalancedRandomForest: каждое дерево обучается на случайной подвыборке majority (размером = minority) + все minority. EasyEnsemble: то же, но с AdaBoost. Оба подхода сохраняют информацию из majority через ансамбль.

from imblearn.ensemble import BalancedRandomForestClassifier
from sklearn.model_selection import cross_val_score

# BalancedRandomForest — undersampling внутри каждого дерева
brf = BalancedRandomForestClassifier(
    n_estimators=200,
    sampling_strategy='auto',  # auto = балансирует до 1:1
    random_state=42,
    n_jobs=-1
)

scores = cross_val_score(brf, X, y, cv=5, scoring='average_precision')
print(f"PR-AUC (BalancedRF): {scores.mean():.3f} +/- {scores.std():.3f}")
# ~0.70 +/- 0.04 — конкурирует с SMOTE, работает быстрее

SMOTE на всех данных до split — утечка данных!

Самая частая ошибка: сначала применить SMOTE ко всему датасету, потом разбить на train/test. Синтетические объекты создаются из соседей — если сосед попал в test, а объект в train, модель «знает» про тестовые данные. SMOTE только внутри CV-фолда через Pipeline.

Class weights — самый простой и часто лучший способ

Вместо изменения данных — меняем функцию потерь. class_weight='balanced' в sklearn автоматически присваивает весá обратно пропорциональные частоте класса: w_i = n_samples / (n_classes * n_samples_i). Для дисбаланса 99:1 minority получает вес ~50, majority — ~0.5. Ошибка на minority штрафуется в 100 раз сильнее.

from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score

# Вариант 1: автоматический class_weight
lr_balanced = LogisticRegression(
    class_weight='balanced',  # автоматические веса
    max_iter=1000,
    random_state=42
)

# Вариант 2: ручные веса (когда знаешь стоимость ошибок)
lr_custom = LogisticRegression(
    class_weight={0: 1, 1: 20},  # FN стоит в 20 раз дороже FP
    max_iter=1000,
    random_state=42
)

scores_bal = cross_val_score(lr_balanced, X, y, cv=5, scoring='average_precision')
scores_def = cross_val_score(LogisticRegression(max_iter=1000), X, y, cv=5, scoring='average_precision')

print(f"PR-AUC (balanced):  {scores_bal.mean():.3f} +/- {scores_bal.std():.3f}")
print(f"PR-AUC (default):   {scores_def.mean():.3f} +/- {scores_def.std():.3f}")
# balanced обычно на 0.05-0.15 лучше при дисбалансе 95/5

Когда class_weight достаточно, а когда нужен SMOTE? Для умеренного дисбаланса (5-20% minority) — class_weight='balanced' обычно достаточно, проще и быстрее. Для сильного дисбаланса (1-5%) — комбинация class_weight + SMOTE даёт лучший результат. Для экстремального (<1%) — рассмотри anomaly detection (Isolation Forest, One-Class SVM) или custom loss function.

Threshold tuning — подбор порога вместо 0.5

Дефолтный порог 0.5 предполагает, что классы сбалансированы и ошибки равноценны. При дисбалансе оптимальный порог почти всегда ниже 0.5 — модель должна быть менее уверена, чтобы предсказать minority class. Правильный порог находится на валидации, не на тесте.

from sklearn.metrics import f1_score, precision_recall_curve
import numpy as np

# Предположим, модель обучена, есть скоры на валидации
# y_val, y_scores — истинные метки и предсказанные вероятности

def find_best_threshold(y_true, y_scores, metric='f1'):
    """Подбор оптимального порога по F1 на валидации."""
    precisions, recalls, thresholds = precision_recall_curve(y_true, y_scores)

    # F1 для каждого порога
    f1_scores = 2 * precisions * recalls / (precisions + recalls + 1e-8)
    best_idx = np.argmax(f1_scores)

    best_threshold = thresholds[best_idx] if best_idx < len(thresholds) else 0.5
    best_f1 = f1_scores[best_idx]

    print(f"Лучший порог: {best_threshold:.3f}")
    print(f"F1 при этом пороге: {best_f1:.3f}")
    print(f"Precision: {precisions[best_idx]:.3f}, Recall: {recalls[best_idx]:.3f}")
    return best_threshold

# Пример: для 5% positive оптимальный порог обычно 0.15-0.35
# threshold = find_best_threshold(y_val, model.predict_proba(X_val)[:, 1])

В продакшне порог — это бизнес-решение, а не математическое. В антифроде: низкий порог = больше пойманных мошенников, но больше заблокированных легитимных транзакций (злые клиенты). Высокий порог = меньше ложных блокировок, но часть фрода проходит. Оптимум зависит от стоимости каждой ошибки.

Комбинированная стратегия

  • Умеренный дисбаланс (5-20% minority): class_weight='balanced' + подбор порога на валидации. Этого обычно достаточно.
  • Сильный дисбаланс (1-5% minority): SMOTE внутри Pipeline + class_weight + threshold tuning. Мониторинг PR-AUC, не ROC-AUC.
  • Экстремальный дисбаланс (<1% minority): Anomaly detection (Isolation Forest, One-Class SVM), cost-sensitive learning с кастомной loss, или двухэтапный пайплайн: сначала фильтр (высокий recall), потом точная модель (высокий precision).

Рецепт уровня Яндекс/Сбер для продакшн-антифрода: LightGBM с is_unbalance=true (аналог class_weight) + threshold tuning по бизнес-метрике (деньги, а не F1) + мониторинг PR-AUC на проде + стратифицированная кросс-валидация (StratifiedKFold) при обучении. SMOTE используется редко — class_weight проще и стабильнее в продакшне.

На собесе

Junior

Что такое accuracy paradox? При дисбалансе 99:1 модель, предсказывающая всем majority, получает accuracy=99%, но полностью бесполезна. Что такое SMOTE? Генерация синтетических примеров minority класса через интерполяцию между k ближайших соседей. Почему нельзя использовать accuracy для несбалансированных данных? Accuracy не различает типы ошибок (FP vs FN). При дисбалансе модель оптимизирует majority за счёт minority.

Middle

PR-AUC vs ROC-AUC: когда ROC-AUC обманывает? При сильном дисбалансе (>95/5). ROC-AUC использует FPR = FP/(FP+TN), огромное TN «разбавляет» FP. Модель с PR-AUC=0.15 может иметь ROC-AUC=0.95. Главная ошибка при SMOTE? SMOTE до split = data leakage. Синтетические объекты создаются из соседей — если сосед в test, утечка. SMOTE только через Pipeline внутри CV. class_weight vs resampling — когда что? class_weight проще, не генерирует данных, работает для умеренного дисбаланса (5-20%). SMOTE лучше при 1-5%, но медленнее и может генерировать шум при перекрытии классов.

Senior

Cost-sensitive learning: не все ошибки равны. В антифроде FN (пропущенный фрод) может стоить $10K, FP (заблокированная транзакция) — $10. Оптимальный порог: t* = C_FP / (C_FP + C_FN). Custom loss function с асимметричными штрафами. Threshold optimization как бизнес-решение: на practice threshold зависит от бизнес-KPI (precision@k, recall@budget), а не от максимума F1. Порог может меняться в зависимости от времени суток, региона, типа транзакции. Anomaly detection как альтернатива для extreme imbalance: Isolation Forest, One-Class SVM, Autoencoder. Когда positive < 0.1%, supervised classification вырождается — лучше моделировать «нормальное поведение» и ловить отклонения. Stratified cross-validation: при дисбалансе обычный KFold может создать фолды без minority. StratifiedKFold обязателен.