Фаза 02 · урок 17
Работа с несбалансированными данными
Цель урока: Вы строите модель обнаружения мошенничества. Она получает точность 99,9 %. Вы празднуете. Затем замечаете, что она предсказывает «не мошенничество» для каждой без исключения транзакции.
Текущий релиз AlexBred.com: первые 100 уроков русскоязычной программы.
Содержание урока
- Цели обучения
- Проблема
- Концепция
- Почему accuracy не работает
- Более подходящие метрики
- Пайплайн для несбалансированных данных
- SMOTE: метод синтетического передискретизирования миноритарного класса
- Сравнение стратегий сэмплирования
- Веса классов
- Настройка порога
- Обучение с учётом стоимости ошибок
- Блок-схема принятия решения
- Соберите это
- Шаг 1: Создайте несбалансированный набор данных
- Шаг 2: SMOTE с нуля
- Шаг 3: Случайное передискретизирование и недодискретизирование
- Шаг 4: Логистическая регрессия с весами классов
- Шаг 5: Настройка порога
- Шаг 6: Функции оценки
- Шаг 7: Сравните все подходы
- Используйте это
- Подготовьте к поставке
- Упражнения
- Ключевые термины
- Дополнительное чтение
Когда 99 % ваших данных «нормальны», точность — это ложь.
Тип: Сборка Язык: Python Предварительные требования: Фаза 2, уроки 01–09 (особенно метрики оценки) Время: ~90 минут
Цели обучения
- Реализовать SMOTE с нуля и объяснить, чем синтетическое передискретизирование отличается от случайного дублирования
- Оценивать несбалансированные классификаторы с помощью F1, AUPRC и коэффициента корреляции Мэттьюса вместо accuracy
- Сравнивать взвешивание классов, настройку порога и стратегии ресемплинга и выбирать подходящий подход для заданного коэффициента дисбаланса
- Построить полный пайплайн для несбалансированных данных, объединяющий SMOTE, веса классов и оптимизацию порога
Проблема
Вы строите модель обнаружения мошенничества. Она получает точность 99,9 %. Вы празднуете. Затем замечаете, что она предсказывает «не мошенничество» для каждой без исключения транзакции.
Это не ошибка. Так рационально поступать, когда мошенническими являются лишь 0,1 % транзакций. Модель усваивает, что постоянное предсказание мажоритарного класса минимизирует общую ошибку. Технически она права и совершенно бесполезна.
Это происходит всюду, где важна классификация в реальном мире. Диагностика болезней: доля положительных случаев 1 %. Сетевые вторжения: 0,01 % атак. Производственные дефекты: 0,5 % бракованных изделий. Фильтрация спама: 20 % спама. Прогноз оттока: 5 % уходящих клиентов. Чем существеннее миноритарный класс, тем реже он обычно встречается.
Accuracy не работает, потому что считает все верные предсказания одинаковыми. Правильно помеченная легитимная транзакция и правильно обнаруженное мошенничество дают по одному баллу точности. Но обнаружение мошенничества — вся причина существования модели. Нужны метрики, методы и стратегии обучения, заставляющие модель уделять внимание редкому, но важному классу.
Концепция
Почему accuracy не работает
Рассмотрим набор данных из 1000 примеров: 990 отрицательных и 10 положительных. Модель всегда предсказывает отрицательный класс:
| Предсказано положительное | Предсказано отрицательное | |
|---|---|---|
| Фактически положительное | 0 (TP) | 10 (FN) |
| Фактически отрицательное | 0 (FP) | 990 (TN) |
Accuracy = (0 + 990) / 1000 = 99.0%
Модель не обнаруживает ни одного мошенничества, ни одной болезни, ни одного дефекта. Но accuracy говорит: 99 %. Вот почему accuracy опасна для несбалансированных задач.
Более подходящие метрики
Precision = TP / (TP + FP). Из всего, что было помечено как положительное, сколько на самом деле положительно? Высокий precision означает мало ложных тревог.
Recall = TP / (TP + FN). Из всего, что на самом деле положительно, сколько мы обнаружили? Высокий recall означает мало пропущенных положительных случаев.
F1 Score = 2 * precision * recall / (precision + recall). Гармоническое среднее. Штрафует за сильный дисбаланс между precision и recall больше, чем это делало бы арифметическое среднее.
F-beta Score = (1 + beta^2) * precision * recall / (beta^2 * precision + recall). Когда beta > 1, важнее recall. Когда beta < 1, важнее precision. F2 часто применяют при обнаружении мошенничества (пропустить мошенничество хуже, чем поднять ложную тревогу).
AUPRC (площадь под кривой precision-recall). Похожа на AUC-ROC, но более информативна для несбалансированных данных. У случайного классификатора AUPRC равна доле положительного класса (а не 0,5, как ROC). Поэтому улучшения легче увидеть.
Коэффициент корреляции Мэттьюса (Matthews Correlation Coefficient) = (TP * TN - FP * FN) / sqrt((TP+FP)(TP+FN)(TN+FP)(TN+FN)). Принимает значения от -1 до +1. Даёт высокий балл, только если модель хорошо работает на обоих классах. Остаётся сбалансированным, даже когда размеры классов сильно различаются.
Для модели «всегда предсказывать отрицательное» выше: precision = 0/0 (не определён, часто принимают за 0), recall = 0/10 = 0, F1 = 0, MCC = 0. Эти метрики правильно определяют модель как бесполезную.
Пайплайн для несбалансированных данных
SMOTE: метод синтетического передискретизирования миноритарного класса
Случайное передискретизирование дублирует существующие примеры миноритарного класса. Это работает, но создаёт риск переобучения, потому что модель многократно видит идентичные точки.
SMOTE создаёт новые синтетические примеры миноритарного класса, правдоподобные, но не являющиеся копиями. Алгоритм:
- Для каждого примера миноритарного класса x находит его k ближайших соседей среди других примеров миноритарного класса
- Случайно выбирает одного соседа
- Создаёт новый пример на отрезке между x и этим соседом
Формула: new_sample = x + random(0, 1) * (neighbor - x)
Она интерполирует между реальными точками миноритарного класса, создавая примеры в той же области пространства признаков без простого копирования существующих данных.
Сравнение стратегий сэмплирования
Случайное передискретизирование: дублирует примеры миноритарного класса до достижения размера мажоритарного класса.
- Плюсы: просто, не теряет информацию
- Минусы: точные дубликаты вызывают переобучение, увеличивает время обучения
Случайное недодискретизирование: удаляет примеры мажоритарного класса до достижения размера миноритарного класса.
- Плюсы: быстрое обучение, просто
- Минусы: выбрасывает потенциально полезные данные мажоритарного класса, повышает дисперсию
SMOTE: создаёт синтетические примеры миноритарного класса интерполяцией.
- Плюсы: генерирует новые точки данных, уменьшает переобучение по сравнению со случайным передискретизированием
- Минусы: может создавать шумные примеры около границы решений, не учитывает распределение мажоритарного класса
| Стратегия | Изменяемые данные | Риск | Когда использовать |
|---|---|---|---|
| Передискретизирование | Миноритарный класс дублируется | Переобучение | Малые наборы данных, умеренный дисбаланс |
| Недодискретизирование | Мажоритарный класс удаляется | Потеря информации | Большие наборы данных, требуется быстрое обучение |
| SMOTE | Добавляется синтетический миноритарный класс | Шум на границе | Умеренный дисбаланс, достаточно примеров миноритарного класса для k-NN |
Веса классов
Вместо изменения данных измените то, как модель учитывает ошибки. Назначьте более высокий вес ошибочной классификации миноритарного класса.
Для бинарной задачи с 950 отрицательными и 50 положительными примерами:
- Вес отрицательного класса = n_samples / (2 * n_negative) = 1000 / (2 * 950) = 0.526
- Вес положительного класса = n_samples / (2 * n_positive) = 1000 / (2 * 50) = 10.0
Положительный класс получает вес в 19 раз больше. Неправильно классифицировать один положительный пример стоит столько же, сколько неправильно классифицировать 19 отрицательных. Модель вынуждена уделять внимание миноритарному классу.
В логистической регрессии это изменяет функцию потерь:
weighted_loss = -sum(w_i * [y_i * log(p_i) + (1-y_i) * log(1-p_i)])
где w_i зависит от класса примера i.
Веса классов математически эквивалентны передискретизированию в ожидании, но не создают новых точек данных. Это делает их быстрее и устраняет риск переобучения на дублированных примерах.
Настройка порога
Большинство классификаторов выводят вероятность. Порог по умолчанию — 0,5: если P(positive) >= 0.5, предсказывается положительный класс. Но 0,5 выбран произвольно. При несбалансированных классах оптимальный порог обычно гораздо ниже.
Процесс:
- Обучите модель
- Получите предсказанные вероятности на валидационном наборе
- Переберите пороги от 0,0 до 1,0
- Вычислите F1 (или выбранную вами метрику) для каждого порога
- Выберите порог, максимизирующий метрику
Модель может выдать P(fraud) = 0.15 для мошеннической транзакции. При пороге 0,5 она классифицируется как не мошенничество. При пороге 0,10 мошенничество правильно обнаруживается. Калибровка вероятностей менее важна, чем ранжирование: пока мошенничество получает более высокие вероятности, чем не-мошенничество, существует порог, который их разделяет.
Обучение с учётом стоимости ошибок
Обобщение весов классов. Вместо единообразных стоимостей назначаются конкретные стоимости ошибочной классификации:
| Предсказать положительное | Предсказать отрицательное | |
|---|---|---|
| Фактически положительное | 0 (верно) | C_FN = 100 |
| Фактически отрицательное | C_FP = 1 | 0 (верно) |
Пропуск мошеннической транзакции (FN) стоит в 100 раз больше, чем ложная тревога (FP). Модель оптимизирует общую стоимость, а не общее число ошибок.
Это наиболее принципиальный подход, когда вы можете оценить реальные затраты. Пропущенная диагностика рака имеет совсем другую стоимость, чем ложная тревога, приводящая к дополнительной биопсии. Явное задание этих стоимостей заставляет делать правильные компромиссы.
Блок-схема принятия решения
class-imbalance
Соберите это
Шаг 1: Создайте несбалансированный набор данных
import numpy as np
def make_imbalanced_data(n_majority=950, n_minority=50, seed=42):
rng = np.random.RandomState(seed)
X_maj = rng.randn(n_majority, 2) * 1.0 + np.array([0.0, 0.0])
X_min = rng.randn(n_minority, 2) * 0.8 + np.array([2.5, 2.5])
X = np.vstack([X_maj, X_min])
y = np.concatenate([np.zeros(n_majority), np.ones(n_minority)])
shuffle_idx = rng.permutation(len(y))
return X[shuffle_idx], y[shuffle_idx]
Шаг 2: SMOTE с нуля
def euclidean_distance(a, b):
return np.sqrt(np.sum((a - b) ** 2))
def find_k_neighbors(X, idx, k):
distances = []
for i in range(len(X)):
if i == idx:
continue
d = euclidean_distance(X[idx], X[i])
distances.append((i, d))
distances.sort(key=lambda x: x[1])
return [d[0] for d in distances[:k]]
def smote(X_minority, k=5, n_synthetic=100, seed=42):
rng = np.random.RandomState(seed)
n_samples = len(X_minority)
k = min(k, n_samples - 1)
synthetic = []
for _ in range(n_synthetic):
idx = rng.randint(0, n_samples)
neighbors = find_k_neighbors(X_minority, idx, k)
neighbor_idx = neighbors[rng.randint(0, len(neighbors))]
t = rng.random()
new_point = X_minority[idx] + t * (X_minority[neighbor_idx] - X_minority[idx])
synthetic.append(new_point)
return np.array(synthetic)
Шаг 3: Случайное передискретизирование и недодискретизирование
def random_oversample(X, y, seed=42):
rng = np.random.RandomState(seed)
classes, counts = np.unique(y, return_counts=True)
max_count = counts.max()
X_resampled = list(X)
y_resampled = list(y)
for cls, count in zip(classes, counts):
if count < max_count:
cls_indices = np.where(y == cls)[0]
n_needed = max_count - count
chosen = rng.choice(cls_indices, size=n_needed, replace=True)
X_resampled.extend(X[chosen])
y_resampled.extend(y[chosen])
X_out = np.array(X_resampled)
y_out = np.array(y_resampled)
shuffle = rng.permutation(len(y_out))
return X_out[shuffle], y_out[shuffle]
def random_undersample(X, y, seed=42):
rng = np.random.RandomState(seed)
classes, counts = np.unique(y, return_counts=True)
min_count = counts.min()
X_resampled = []
y_resampled = []
for cls in classes:
cls_indices = np.where(y == cls)[0]
chosen = rng.choice(cls_indices, size=min_count, replace=False)
X_resampled.extend(X[chosen])
y_resampled.extend(y[chosen])
X_out = np.array(X_resampled)
y_out = np.array(y_resampled)
shuffle = rng.permutation(len(y_out))
return X_out[shuffle], y_out[shuffle]
Шаг 4: Логистическая регрессия с весами классов
def sigmoid(z):
return 1.0 / (1.0 + np.exp(-np.clip(z, -500, 500)))
def logistic_regression_weighted(X, y, weights, lr=0.01, epochs=200):
n_samples, n_features = X.shape
w = np.zeros(n_features)
b = 0.0
for _ in range(epochs):
z = X @ w + b
pred = sigmoid(z)
error = pred - y
weighted_error = error * weights
gradient_w = (X.T @ weighted_error) / n_samples
gradient_b = np.mean(weighted_error)
w -= lr * gradient_w
b -= lr * gradient_b
return w, b
def compute_class_weights(y):
classes, counts = np.unique(y, return_counts=True)
n_samples = len(y)
n_classes = len(classes)
weight_map = {}
for cls, count in zip(classes, counts):
weight_map[cls] = n_samples / (n_classes * count)
return np.array([weight_map[yi] for yi in y])
Шаг 5: Настройка порога
def find_optimal_threshold(y_true, y_probs, metric="f1"):
best_threshold = 0.5
best_score = -1.0
for threshold in np.arange(0.05, 0.96, 0.01):
y_pred = (y_probs >= threshold).astype(int)
tp = np.sum((y_pred == 1) & (y_true == 1))
fp = np.sum((y_pred == 1) & (y_true == 0))
fn = np.sum((y_pred == 0) & (y_true == 1))
if metric == "f1":
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
score = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
elif metric == "recall":
score = tp / (tp + fn) if (tp + fn) > 0 else 0.0
elif metric == "precision":
score = tp / (tp + fp) if (tp + fp) > 0 else 0.0
if score > best_score:
best_score = score
best_threshold = threshold
return best_threshold, best_score
Шаг 6: Функции оценки
def confusion_matrix_values(y_true, y_pred):
tp = np.sum((y_pred == 1) & (y_true == 1))
tn = np.sum((y_pred == 0) & (y_true == 0))
fp = np.sum((y_pred == 1) & (y_true == 0))
fn = np.sum((y_pred == 0) & (y_true == 1))
return tp, tn, fp, fn
def compute_metrics(y_true, y_pred):
tp, tn, fp, fn = confusion_matrix_values(y_true, y_pred)
accuracy = (tp + tn) / (tp + tn + fp + fn)
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
denom = np.sqrt(float((tp + fp) * (tp + fn) * (tn + fp) * (tn + fn)))
mcc = (tp * tn - fp * fn) / denom if denom > 0 else 0.0
return {
"accuracy": accuracy,
"precision": precision,
"recall": recall,
"f1": f1,
"mcc": mcc,
}
Шаг 7: Сравните все подходы
X, y = make_imbalanced_data(950, 50, seed=42)
split = int(0.8 * len(y))
X_train, X_test = X[:split], X[split:]
y_train, y_test = y[:split], y[split:]
# Baseline: no treatment
w_base, b_base = logistic_regression_weighted(
X_train, y_train, np.ones(len(y_train)), lr=0.1, epochs=300
)
probs_base = sigmoid(X_test @ w_base + b_base)
preds_base = (probs_base >= 0.5).astype(int)
# Oversampled
X_over, y_over = random_oversample(X_train, y_train)
w_over, b_over = logistic_regression_weighted(
X_over, y_over, np.ones(len(y_over)), lr=0.1, epochs=300
)
preds_over = (sigmoid(X_test @ w_over + b_over) >= 0.5).astype(int)
# SMOTE
minority_mask = y_train == 1
X_minority = X_train[minority_mask]
synthetic = smote(X_minority, k=5, n_synthetic=len(y_train) - 2 * int(minority_mask.sum()))
X_smote = np.vstack([X_train, synthetic])
y_smote = np.concatenate([y_train, np.ones(len(synthetic))])
w_sm, b_sm = logistic_regression_weighted(
X_smote, y_smote, np.ones(len(y_smote)), lr=0.1, epochs=300
)
preds_smote = (sigmoid(X_test @ w_sm + b_sm) >= 0.5).astype(int)
# Class weights
sample_weights = compute_class_weights(y_train)
w_cw, b_cw = logistic_regression_weighted(
X_train, y_train, sample_weights, lr=0.1, epochs=300
)
probs_cw = sigmoid(X_test @ w_cw + b_cw)
preds_cw = (probs_cw >= 0.5).astype(int)
# Threshold tuning (tune on held-out validation set, not test set)
probs_val = sigmoid(X_val @ w_cw + b_cw)
best_thresh, best_f1 = find_optimal_threshold(y_val, probs_val, metric="f1")
preds_thresh = (probs_cw >= best_thresh).astype(int)
Файл с кодом запускает всё это единым скриптом и выводит результаты.
Используйте это
Со scikit-learn и imbalanced-learn эти методы сводятся к однострочным вызовам:
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, f1_score
from sklearn.model_selection import train_test_split
from imblearn.over_sampling import SMOTE
from imblearn.under_sampling import RandomUnderSampler
from imblearn.pipeline import Pipeline
X_train, X_test, y_train, y_test = train_test_split(X, y, stratify=y)
model_weighted = LogisticRegression(class_weight="balanced")
model_weighted.fit(X_train, y_train)
print(classification_report(y_test, model_weighted.predict(X_test)))
smote = SMOTE(random_state=42)
X_resampled, y_resampled = smote.fit_resample(X_train, y_train)
model_smote = LogisticRegression()
model_smote.fit(X_resampled, y_resampled)
print(classification_report(y_test, model_smote.predict(X_test)))
pipeline = Pipeline([
("smote", SMOTE()),
("model", LogisticRegression(class_weight="balanced")),
])
pipeline.fit(X_train, y_train)
print(classification_report(y_test, pipeline.predict(X_test)))
Реализации с нуля показывают, что именно делает каждый метод. SMOTE — всего лишь k-NN-интерполяция по миноритарному классу. Веса классов умножают функцию потерь. Настройка порога — это цикл по пороговым значениям. Никакой магии.
Подготовьте к поставке
Этот урок создаёт:
outputs/skill-imbalanced-data.md– контрольный список решений для работы с несбалансированными задачами классификации
Упражнения
-
Borderline-SMOTE: измените реализацию SMOTE так, чтобы синтетические примеры генерировались только для точек миноритарного класса около границы решений (тех, среди k ближайших соседей которых есть примеры мажоритарного класса). Сравните результаты со стандартным SMOTE на наборе данных с перекрывающимися классами.
-
Оптимизация матрицы стоимостей: реализуйте обучение с учётом стоимости ошибок, где матрица стоимостей является параметром. Создайте функцию, принимающую матрицу стоимостей и возвращающую оптимальные предсказания, минимизирующие ожидаемую стоимость. Протестируйте разные соотношения стоимостей (1:10, 1:100, 1:1000) и постройте график изменения компромисса precision-recall.
-
Калибровка порога: реализуйте масштабирование Платта (обучите логистическую регрессию на сырых выходах модели, чтобы получить калиброванные вероятности). Сравните кривую precision-recall до и после калибровки. Покажите, что калибровка не меняет ранжирование (AUC остаётся той же), но делает вероятности более осмысленными.
-
Ансамбль со сбалансированным бэггингом: обучите несколько моделей, каждую на сбалансированной бутстреп-выборке (весь миноритарный класс + случайное подмножество мажоритарного). Усредните их предсказания. Сравните этот подход с одной моделью со SMOTE. Измерьте и качество, и дисперсию между запусками.
-
Эксперимент с коэффициентом дисбаланса: возьмите сбалансированный набор данных и постепенно увеличивайте коэффициент дисбаланса (50/50, 70/30, 90/10, 95/5, 99/1). Для каждого соотношения обучите модель со SMOTE и без него. Постройте F1 в зависимости от коэффициента дисбаланса для обоих подходов. При каком соотношении SMOTE начинает давать заметную разницу?
Ключевые термины
| Термин | Как обычно говорят | Что это действительно означает |
|---|---|---|
| Дисбаланс классов | «В одном классе намного больше примеров» | Распределение классов в наборе данных сильно скошено, из-за чего модели отдают предпочтение мажоритарному классу |
| SMOTE | «Синтетическое передискретизирование» | Создаёт новые примеры миноритарного класса, интерполируя между существующими примерами миноритарного класса и их k ближайшими соседями того же класса |
| Веса классов | «Ошибки на редких классах дороже» | Умножение функции потерь на зависящие от класса веса, чтобы модель сильнее штрафовалась за ошибочную классификацию миноритарного класса |
| Настройка порога | «Сдвиг границы решения» | Изменение порогового значения вероятности классификации с 0,5 по умолчанию на значение, оптимизирующее нужную метрику |
| Компромисс precision-recall | «Нельзя получить оба сразу» | Понижение порога обнаруживает больше положительных случаев (выше recall), но также помечает больше ложноположительных случаев (ниже precision), и наоборот |
| AUPRC | «Площадь под PR-кривой» | Обобщает кривую precision-recall одним числом; более информативна, чем AUC-ROC, при сильном дисбалансе классов |
| Коэффициент корреляции Мэттьюса | «Сбалансированная метрика» | Корреляция между предсказанными и фактическими метками, дающая высокий балл только когда модель хорошо работает на обоих классах |
| Обучение с учётом стоимости ошибок | «Разные ошибки стоят по-разному» | Включает реальные стоимости ошибочной классификации в целевую функцию обучения, чтобы модель оптимизировала общую стоимость, а не число ошибок |
| Случайное передискретизирование | «Дублируйте миноритарный класс» | Повторение примеров миноритарного класса для выравнивания численности классов; просто, но создаёт риск переобучения на дубликатах |
Дополнительное чтение
- SMOTE: Synthetic Minority Over-sampling Technique (Chawla и др., 2002) – исходная статья о SMOTE, по-прежнему самая цитируемая работа по обучению на несбалансированных данных
- Learning from Imbalanced Data (He и Garcia, 2009) – полный обзор, охватывающий сэмплирование, методы с учётом стоимости ошибок и алгоритмические подходы
- Документация imbalanced-learn – Python-библиотека с вариантами SMOTE, стратегиями недодискретизирования и интеграцией в пайплайны
- The Precision-Recall Plot Is More Informative than the ROC Plot (Saito и Rehmsmeier, 2015) – когда и почему для несбалансированных задач следует предпочесть PR-кривые ROC-кривым
Источник: Handling Imbalanced Data 02.16 — Обнаружение аномалий · Фаза 2 — Основы машинного обучения · Полный каталог · 02.18 — Отбор признаков