Классификатор мошеннических транзакций показывает accuracy 99.2%. Звучит отлично, пока не вспоминаешь, что мошеннических транзакций в датасете 0.8%. Модель, которая на каждый вход отвечает «не мошенничество», получит accuracy 99.2%. И будет абсолютно бесполезна, потому что не поймает ни одного мошенника.
Это не гипотетический пример, несбалансированные классы давно являются нормой в реальных задачах.
Precision и Recall: что модель ловит и чем платит
Вместо одной цифры accuracy нужны две: precision и recall.
Recall (полнота) отвечает на вопрос: из всех реальных мошеннических транзакций, какой процент модель поймала? Если из 100 мошеннических модель нашла 80 и пропустила 20 — recall = 0.80. Остальные 20 прошли мимо, и банк потерял деньги.
Precision (точность) отвечает на другой вопрос: из всех транзакций, которые модель назвала мошенническими, какой процент действительно мошеннические? Если модель пометила 200 транзакций как мошеннические, из них 80 реально мошеннические, а 120 — нормальные, которые модель заблокировала по ошибке — precision = 0.40. 120 клиентов получили звонок от банка или заблокированную карту без причины.
Recall и precision живут в конфликте. Хотите поймать больше мошенников (высокий recall)? Модель будет агрессивнее и начнёт ложно блокировать больше нормальных транзакций (precision упадёт). Хотите меньше ложных блокировок (высокий precision)? Модель станет осторожнее и начнёт пропускать мошенников (recall упадёт).
Этот компромисс определяется бизнесом. Для того же спам‑фильтра ложное срабатывание (важное письмо в спаме) хуже, чем пропущенный спам, precision важнее. Для медицинского скрининга пропущенный диагноз может стоить жизни, recall критичен.
Как это выглядит в коде
from sklearn.metrics import precision_score, recall_score, f1_score, classification_report from sklearn.datasets import make_classification from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split # Создаём несбалансированный датасет: 95% класс 0, 5% класс 1 X, y = make_classification( n_samples=10000, n_features=20, weights=[0.95, 0.05], # 95/5 баланс random_state=42, ) X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42) model = LogisticRegression() model.fit(X_train, y_train) y_pred = model.predict(X_test) print(classification_report(y_test, y_pred))
Вывод:
precision recall f1-score support 0 0.97 0.99 0.98 2850 1 0.73 0.52 0.61 150 accuracy 0.97 3000
Accuracy 0.97, выглядит здорово. Но recall для класса 1 (тот самый редкий, ради которого модель создавалась) — 0.52. Модель пропускает почти половину.
classification_report показывает precision и recall для каждого класса отдельно. Для класса 0 (большинство) всё прекрасно. Для класса 1 (меньшинство) precision 0.73 и recall 0.52. На accuracy это почти не влияет (класс 1 составляет 5% выборки), но для бизнеса это провал.
F1-score: компромисс между precision и recall
F1 — гармоническое среднее precision и recall. Если одно из них низкое, F1 тоже низкий:
print(f"Precision: {precision_score(y_test, y_pred):.3f}") print(f"Recall: {recall_score(y_test, y_pred):.3f}") print(f"F1: {f1_score(y_test, y_pred):.3f}")
Precision: 0.730 Recall: 0.520 F1: 0.607
F1 = 0.607 при accuracy 0.97. Разница между тем, что показывает accuracy, и тем, что показывает. |
F1 подходит, когда precision и recall одинаково важны. Когда не одинаково, используют F‑beta:
from sklearn.metrics import fbeta_score # beta=2: recall в 2 раза важнее precision (fraud, медицина) f2 = fbeta_score(y_test, y_pred, beta=2) print(f"F2 (recall-weighted): {f2:.3f}") # beta=0.5: precision в 2 раза важнее recall (спам-фильтр) f05 = fbeta_score(y_test, y_pred, beta=0.5) print(f"F0.5 (precision-weighted): {f05:.3f}")
F2 штрафует за низкий recall сильнее, чем за низкий precision. F0.5 наоборот. Выбор beta определяется бизнесом: сколько стоит пропущенный положительный (false negative) vs сколько стоит ложное срабатывание (false positive).
Порог классификации: не всегда 0.5
Большинство моделей не предсказывают класс напрямую. Они предсказывают вероятность, а потом по порогу (обычно 0.5) решают: если вероятность выше 0.5 — класс 1, ниже — класс 0.
Порог 0.5 — это дефолт, а не оптимум. Для несбалансированных классов 0.5 обычно слишком высокий: модель осторожничает и пропускает редкий класс.
import numpy as np y_proba = model.predict_proba(X_test)[:, 1] # Пробуем разные пороги for threshold in [0.3, 0.4, 0.5, 0.6]: y_pred_t = (y_proba >= threshold).astype(int) p = precision_score(y_test, y_pred_t) r = recall_score(y_test, y_pred_t) f1 = f1_score(y_test, y_pred_t) print(f"Threshold {threshold}: precision={p:.3f}, recall={r:.3f}, F1={f1:.3f}")
Threshold 0.3: precision=0.561, recall=0.740, F1=0.638 Threshold 0.4: precision=0.667, recall=0.640, F1=0.653 Threshold 0.5: precision=0.730, recall=0.520, F1=0.607 Threshold 0.6: precision=0.813, recall=0.347, F1=0.486
При пороге 0.3 recall вырос с 0.52 до 0.74 (ловим на 42% больше мошенников), precision упал с 0.73 до 0.56 (больше ложных блокировок). F1 вырос с 0.607 до 0.638. Какой порог лучше — зависит от стоимости ошибок каждого типа.
Precision‑Recall кривая: визуализация компромисса
from sklearn.metrics import precision_recall_curve, average_precision_score import matplotlib.pyplot as plt precision_curve, recall_curve, thresholds = precision_recall_curve(y_test, y_proba) ap = average_precision_score(y_test, y_proba) plt.figure(figsize=(8, 6)) plt.plot(recall_curve, precision_curve, label=f"AP = {ap:.3f}") plt.xlabel("Recall") plt.ylabel("Precision") plt.title("Precision-Recall Curve") plt.legend() plt.grid(True) plt.savefig("pr_curve.png")
PR‑кривая показывает все возможные комбинации precision и recall при разных порогах. Average Precision (AP) — площадь под этой кривой, одна цифра, которая характеризует качество модели по всем порогам. Чем ближе AP к 1.0, тем лучше. Случайная модель на датасете с 5% положительных покажет AP ≈ 0.05.
PR‑кривая информативнее ROC‑AUC для несбалансированных данных. ROC‑AUC может показывать 0.95 при 0.1% положительных (потому что True Negative Rate для класса большинства почти всегда высокий), а PR‑кривая честно покажет, что модель плохо работает с редким классом.
Confusion matrix: где конкретно ошибается модель
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay cm = confusion_matrix(y_test, y_pred) disp = ConfusionMatrixDisplay(cm, display_labels=["Normal", "Fraud"]) disp.plot(cmap="Blues") plt.savefig("confusion_matrix.png")
Confusion matrix показывает четыре числа: True Positive (правильно пойманные мошенники), False Positive (нормальные, ошибочно помеченные как мошенники), True Negative (правильно пропущенные нормальные), False Negative (пропущенные мошенники).
Для бизнеса False Negative (пропущенные мошенники) и False Positive (ложные блокировки) имеют разную стоимость. Средний ущерб от мошеннической транзакции — допустим, 15 000 рублей. Стоимость ложной блокировки — звонок менеджера (200 рублей рабочего времени) + раздражение клиента (сложно оценить, но допустим 500 рублей как риск оттока). Тогда одна False Negative стоит 15 000, одна False Positive — 700. Оптимальный порог — тот, при котором суммарная стоимость ошибок минимальна.
# Стоимость ошибок cost_fn = 15000 # пропущенный мошенник cost_fp = 700 # ложная блокировка best_threshold = 0.5 min_cost = float("inf") for threshold in np.arange(0.1, 0.9, 0.01): y_pred_t = (y_proba >= threshold).astype(int) cm = confusion_matrix(y_test, y_pred_t) tn, fp, fn, tp = cm.ravel() total_cost = fn * cost_fn + fp * cost_fp if total_cost < min_cost: min_cost = total_cost best_threshold = threshold print(f"Optimal threshold: {best_threshold:.2f}") print(f"Minimum total cost: {min_cost:,.0f} руб")
Это переводит метрику из «точности модели» в «деньги, которые компания теряет на ошибках». Для руководителя, который утверждает бюджет на ML, фраза «модель экономит 2 млн рублей в месяц на пропущенном мошенничестве» понятнее, чем «F1 вырос с 0.61 до 0.72».
Стратификация при валидации
При разбиении на train/test для несбалансированных данных нужна стратификация, иначе в тестовой выборке может оказаться 0 примеров редкого класса:
from sklearn.model_selection import StratifiedKFold, cross_val_score cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) scores = cross_val_score(model, X, y, cv=cv, scoring="f1") print(f"F1 across folds: {scores}") print(f"Mean F1: {scores.mean():.3f} ± {scores.std():.3f}")
StratifiedKFold гарантирует, что в каждом фолде соотношение классов такое же, как во всём датасете. Без стратификации фолд может случайно получить 2% или 8% положительных вместо 5%, и оценка будет шумной.
Обратите внимание на scoring="f1". По умолчанию cross_val_score использует accuracy, и вы вернётесь к проблеме, с которой начали. Для несбалансированных классов ставьте scoring="f1", scoring="recall" или scoring="average_precision" в зависимости от того, что важнее для задачи.
Что делать дальше: resampling и class_weight
Если после подбора порога recall всё равно низкий, есть два подхода к самому обучению.
class_weight в модели:
model = LogisticRegression(class_weight="balanced") model.fit(X_train, y_train)
class_weight="balanced" автоматически увеличивает вес редкого класса обратно пропорционально его частоте. Если класс 1 составляет 5%, его вес будет в 20 раз больше, чем у класса 0. Модель сильнее наказывается за ошибки на редком классе и учится обращать на него больше внимания.
SMOTE (Synthetic Minority Over‑sampling):
from imblearn.over_sampling import SMOTE smote = SMOTE(random_state=42) X_resampled, y_resampled = smote.fit_resample(X_train, y_train) print(f"Before: {np.bincount(y_train)}") # [4750, 250] print(f"After: {np.bincount(y_resampled)}") # [4750, 4750]
SMOTE генерирует синтетические примеры редкого класса путём интерполяции между существующими. Датасет становится сбалансированным, модель обучается на равном количестве примеров каждого класса.
SMOTE применяется только к тренировочной выборке. Тестовую трогать нельзя: она должна отражать реальное распределение, иначе оценка будет нечестной.
Оба подхода помогают, но не заменяют правильный выбор метрики. Если вы оптимизируете accuracy, ни class_weight, ни SMOTE не спасут, потому что accuracy не видит проблему. Сначала метрика (F1, precision, recall, AP), потом порог, потом resampling.
Перед тем как отчитываться об accuracy 95%, проверьте: какой recall у редкого класса? Если ниже 0.6 — модель пропускает слишком много. Стоит ли пропущенный случай дороже ложного срабатывания? Если да — оптимизируйте recall (F2, снижение порога). Используется ли стратификация при валидации? Если нет — метрики могут быть шумными. Используется ли class_weight или resampling? Если нет — модель может игнорировать редкий класс.
Accuracy для несбалансированных данных — бесполезная метрика, которая создаёт ложное чувство безопасности. Precision, recall, F1, PR‑кривая и стоимость ошибок — вот что показывает, работает модель или нет.

Модель, которая дает 97% accuracy, еще не обязательно решает задачу. Иногда первый полезный навык в ML — не обучить алгоритм, а понять, где он врет и какой ценой бизнес платит за эти ошибки.
Разобрать базовый путь от данных к первой модели можно на бесплатном открытом уроке в рамках онлайн‑курса «Machine Learning. Basic»:
26 мая, 16:00 — «Ваша первая модель машинного обучения за час». Записаться
Разберем, как выглядит первый практический шаг в машинном обучении.
Будет возможность протестировать формат обучения, познакомиться с преподавателем и задать вопросы по старту в машинном обучении.
DenisDenisMIS
Спасибо за Вашу просветительскую деятельность и хорошо структурированную статью с примерами кода. Из своей практики отмечу, что для моей области (цифровая медицина) очень важно не потерять за высоким accuracy точность модели в терминах Recall и Precision.
И вот я попробовал применить Ваш Туториал для своей задачи. Я пытался найти методы машинного обучения, которые справятся не только с несбалансированностью классов, но и с проблемой пересечения (overlay).
Я взял вот такой датасет вот отсюда Мне было интересно, как разные модели справятся с задачей классификации такого рода.
Вот что получается, если использовать подстройку threshold по Вашей статье!
Да, точность не великая, но она лучше просто обычного zero-shot подхода к классификации, который бы у меня ранее.