Несбалансированные данные
SMOTE, class weights, подбор порога, PR-AUC — как работать с дисбалансом классов.
Несбалансированные данные — когда accuracy врёт, а модель бесполезна
Представь: ты строишь антифрод-систему. В данных 0.1% мошеннических транзакций и 99.9% легитимных. Ты обучаешь модель, получаешь accuracy = 99.9% — и радуешься. Но модель просто всем предсказывает «не фрод». Она не поймала ни одного мошенника. Accuracy = 99.9%, полезность = 0%.
Это не баг в sklearn — это фундаментальная проблема несбалансированных данных. Когда один класс доминирует, стандартные алгоритмы минимизируют общую ошибку и просто «забивают» на минорный класс. А ведь именно минорный класс — болезнь, фрод, дефект — обычно самый важный.

Масштаб проблемы: где дисбаланс в реальном мире
Дисбаланс — не исключение, а норма в индустриальном ML. Конкретные цифры по доменам: кредитный фрод — 0.1-0.3% положительного класса, медицинская диагностика — 1-5% (рак, редкие заболевания), клики по рекламе (CTR) — 1-3%, отток клиентов (churn) — 5-15%, дефекты на производстве — 0.5-2%. Почти в каждой реальной задаче классификации классы распределены неравномерно.
Правило
Метрики для несбалансированных данных
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 — значительно лучше, чем без SMOTERandom 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 — утечка данных!
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
Middle
Senior
Материалы
Подробный обзор техник работы с несбалансированными данными от Open Data Science.
Документация imblearn: SMOTE, ADASYN, undersampling, ensemble методы.
Визуальное объяснение проблемы дисбаланса и основных решений.
Практические рекомендации по работе с дисбалансом от Google.