Бывали у вас датасеты, где класс «1» встречается в 100 раз реже класса «0»? У меня — постоянно. Модель радуется высокой точности, а на деле совершенно промахивается по редкому классу. Давайте обсудим, почему старый добрый SMOTE уже не торт, и что помогает в таких случаях.
Дисбаланс данных как проблема
Если у вас 99 кошек и 1 собака, алгоритм, который всегда угадывает «кошка», получит 99% точности — и это ловушка. Классическая метрика accuracy тут бессмысленна, модель может совсем не узнавать собак, но всё равно быть якобы точной.
Дисбаланс классов приводит к тому, что алгоритм игнорирует редкий класс, просто потому, что ошибаться на нём дешевле в плане функции потерь. В итоге модель учится отлично предсказывать большинство (кошек), и проваливается на меньшинстве (собаках). Мы можем это исправить, но сначала коротко о старом подходе, который раньше предлагали буквально в каждом туториале.
SMOTE не оправдал надежд
Когда я только начинал, все дороги вели к SMOTE. Казалось бы, гениальная идея, зачем выкидывать лишние данные, когда можно добавить недостающие? SMOTE берёт точки меньшинства и генерирует между ними синтетические образцы, заполняя пробелы.
Почему редко используют SMOTE:
-
Переобучение на шуме. SMOTE без разбора лепит новые примеры, которые могут выглядеть правдоподобно математически, но не имеют смысла для задачи.
-
Перекрытие классов. SMOTE не глядит на класс‑большинство. Он может понагенерировать редких примеров прям в области, густо населённой другим классом. В результате границы между классами размываются, растёт количество ложных срабатываний.
-
Слабая применимость в сложных задачах.
Focal Loss: сфокусируемся на сложных примерах
focal loss заставляет модель фокусироваться на трудных примерах. Простыми словами, мы уменьшаем вклад очевидных лёгких случаев и усиливаем вклад редких/трудных. Впервые об этом узнали из компьютерного зрения, где фоновых объектов море, а целевых мало. Но при чём тут мы? А при том, что focal loss заходит во всех случаях, когда классы несбалансированы.
Берём стандартную бинарную кросс‑энтропию и домножаем её на коэффициент, зависящий от pt — предсказанной вероятности правильного класса. Для каждой выборки вычисляется pt = exp(-loss), что по сути высокая вероятность правильного класса даст pt близкий к 1, низкая — к 0. Затем потери умножаются на , где
— параметр фокусировки. Если пример легко классифицируется правильно (pt близок к 1), то
почти 0 и вклад этого примера в финальный лосс мизерный. А если модель ошибается (pt маленький), коэффициент близок к 1 и пример получает полный вес. Кроме того, вводится параметр
для баланса классов (можно чуть занизить потери для одного класса, поднять для другого вручную).
focal loss здорово подавляет градиенты от миллионов однотипных легкоклассифицируемых объектов большинства, и концентрирует обучение на редких случаях, которые она пока путает.
Реализовать focal loss можно самостоятельно через несколько строк, используя функционал PyTorch:
import torch
import torch.nn.functional as F
def focal_loss(inputs, targets, alpha=1.0, gamma=2.0):
bce_loss = F.binary_cross_entropy_with_logits(inputs, targets, reduction='none')
pt = torch.exp(-bce_loss) # вероятность, если угадали класс
focal = alpha * (1 - pt) ** gamma * bce_loss
return focal.mean()
Здесь считаем покомпонентно бинарную кроссэнтропию (reduction='none' даёт вектор потерь по батчу), далее вычисляем pt = exp(-bce_loss) — это примерно вероятность угаданного класса для каждого примера. Домножаем каждую потерю на и на коэффициент
и усредняем и получаем один скаляр лосса.
В результате, при правильно предсказанные примеры почти не влияют на обучение, а вот трудные случаи болевым образом сказываются на градиенте, заставляя модель их разбирать.
Фокус‑функция просто динамически меняет веса примеров в процессе обучения. Если используете focal loss, обычно параметр class_weight уже не нуже, иначе можно перестараться.
Веса классов
Теперь перейдём к более простому, к взвешиванию классов в функции потерь. Казалось бы, это самое первое, что приходит в голову: а давайте скажем модели, что ошибки на редком классе для нас в N раз хуже, чем ошибки на частом? Именно это и делается через задание весов.
Большинство библиотек машинного обучения умеют использовать веса классов:
-
В scikit‑learn почти во всех классификаторах есть параметр
class_weight. -
В XGBoost/LightGBM и подобных бустингах есть параметр вроде
scale_pos_weight, который делает то же самое для бинарных задач (в многоклассовых обычно нужно вручную). -
В PyTorch при определении функции потерь, например
nn.CrossEntropyLoss(weight=...), можно передать тензор весов для каждого класса.
Как выбрать эти веса? Проще всего, обратно пропорционально частоте: если класс А встречается 1000 раз, а класс B 100 раз, то вес ошибки на B ставим в 10 раз больше, чем на A. В sklearn class_weight='balanced' делает именно это. Можно вручную:
import numpy as np
# Пример для PyTorch:
class_counts = np.bincount(y_train) # количество образцов каждого класса
class_weights = 1.0 / class_counts
class_weights = class_weights / class_weights.sum() # нормализация по желанию
criterion = torch.nn.CrossEntropyLoss(weight=torch.tensor(class_weights, dtype=torch.float))
Если класс 0 встречается 90%, а класс 1 – 10%, то веса примерно [0.1, 0.9] (после нормировки). Ошибка на редком классе 1 будет штрафоваться в 9 раз сильнее, чем на классе 0. Модель учтя это, не станет жертвовать редким классом ради общей точности, ведь промах по классу 1 теперь «дороже».
Конечно,иногда нужно методом проб и ошибок масштабировать веса не строго по обратной частоте, а скажем, в чуть меньшей пропорции, чтобы не перегнуть. Например, при 100:1 соотношении дать не 100-кратный вес, а 10-кратный, иначе модель может переобучиться уже на меньшинство и ухудшить точность на большинстве. И тут, кстати, focal loss более лучше подоходит.
Андерсэмплинг: меньше данных, да лучше
А что если пойти с другой стороны и уменьшить количество примеров класса‑мажоритета? Обычно же данные на вес золота, как это мы добровольно от них отказываемся! Но когда данных слишком много и они однородные, можно немного проредить, особенно если это ускорит обучение и сбалансирует влияние классов.
Undersampling (андерсэмплинг) — это стратегия, при которой мы отбрасываем часть объектов наиболее частого класса, чтобы уровнять пропорции. Самый простой вариант: случайно выбросить лишние примеры большинства до уровня меньшинства. Например, было 1000 кошек и 100 собак, берем всех собак и случайных 100 кошек — получаем баланс 1:1. Минус очевиден: мы потеряли кучу информации (900 кошек ушли в небытие). Плюс тоже очевиден: обучаться быстро, классы равновесны, ничего не перевешивает.
На практике же почти никогда не делают жёсткий андерсэмплинг до 1:1, если данных много.Обычно применяют компромиссные стратегии:
-
Частичный андерсэмплинг. Например, уменьшить majority не до уровня minority, а до какого‑то приемлемого соотношения, скажем 2:1 или 3:1. В примере с 1000 vs 100 можно оставить, скажем, 300 кошек и 100 собак (вместо всех 1000). Мы всё ещё теряем данные, но не так радикально, и модель получит более сбалансированный сигнал.
-
Андерсэмплинг с кластерацией. Если подозреваем, что в тех 1000 кошках много дублирующегося, можно кластеризовать их и взять по нескольку представителей из каждого кластера, тем самым выбросив только самые избыточные точки.
Можно пользоваться RandomUnderSampler из пакета imbalanced-learn, пару строк, и у вас уменьшенная выборка:
from imblearn.under_sampling import RandomUnderSampler
rus = RandomUnderSampler(sampling_strategy=0.5) # оставим majority примерно вдвое больше minority
X_res, y_res = rus.fit_resample(X, y)
print(Counter(y_res)) # например, Counter({0: 200, 1: 100})
Здесь sampling_strategy=0.5 значит: хотим, чтобы после выборки размер класса меньшинства относился к большинству как 1:2 (то есть majority будет в 2 раза больше minority). Можно указать 'auto' или конкретное число объектов. В результате, если изначально было 100 редких и 1000 частых, останется 100 редких и 200 частых, уже куда лучше баланс.
Андерсэмплинг хорош тем, что не портит данные, в отличие от оверсэмплинга. Мы убираем, но не придумываем. Главное не удалить что‑то важное.
Что выбрать?
Можно использовать одновременно и веса классов, и лёгкий undersampling. Например, у меня была выборка 1:50 (редкий:частый). Я отбросил половину лишних частых (стало 1:25), и добавил вес 5:1 на редкий класс. Модель получилась чуть стабильнее, чем если бы я только 50:1 вес воткнул или выкинул 49/50 данных.
Когда данные дорогие. Если вы не можете позволить себе потерять данные (маленький датасет сам по себе), тогда лучше не undersample, а играть функцией потерь. Focal loss или веса спасут, и все исходные данные останутся при деле.
Когда классы уж очень разные. Например, случаев класса B считанные единицы на миллионы записей. Тут часто вообще имеет смысл рассмотреть методы обнаружения аномалий вместо классической классификации — обучение без учителя, one‑class SVM, Isolation Forest и тому подобное Но если всё же хотим классификатор, focal loss с высоким gamma и хороший перебор порога по PR‑кривой могут дать результат.
Тем, кто хочет профессионального развития, рекомендую курс «Machine Learning. Advanced» для Data Scientists уровня Middle+: в программу входят end-to-end пайплайны, production-код, AutoML и ограничения, байес, временные ряды, рекомендации, RL. Готовы к серьезному обучению? Пройдите вступительный тест.
Для знакомства с форматом обучения и экспертами приходите на бесплатные демо-уроки:
-
9 февраля в 18:00. «Сингулярное разложение матрицы и ALS в теории рекомендательных систем». Записаться
-
18 февраля в 20:00. «Поиск аномалий во временных рядах: за рамками трех сигм». Записаться
Автор: badcasedaily1


