
Привет, Хабр! На связи KozhinDev и ml-разработчик Приходько Александр. Это вторая статья в цикле публикаций по теме борьбы с дисбалансом классов в машинном обучении. В предыдущей статье мы рассмотрели актуальность данной проблемы и сравнили методы борьбы без внесения изменений в данные: балансировка весов классов и изменение порога принятия решения моделью. В данной части будем тестировать балансировку данных методом undersampling из библиотеки imblearn.
Суть данного метода, как нетрудно догадаться, заключается в том, что мы удаляем часть данных в наибольшем классе до тех пор пока количество данных в классах не сравняется. Конечно, данный метод приводит к потери части информации, но при сильном дисбалансе выгода от этого метода может превысить вред. Существует несколько методов определения какие данные будут удалены и именно сравнением подобных методов мы займемся в данной статье.
Ниже код для нашего эксперимента:
Скрытый текст
import numpy as np
import pandas as pd
from dataclasses import dataclass
from sklearn.cluster import MiniBatchKMeans
from sklearn.ensemble import RandomForestClassifier
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.base import clone
from sklearn.metrics import f1_score
from sklearn.linear_model import LogisticRegression
from catboost import CatBoostClassifier
from lightgbm import LGBMClassifier
from xgboost import XGBClassifier
import matplotlib.pyplot as plt
import seaborn as sns
from tqdm import tqdm
from imblearn.under_sampling import (RandomUnderSampler,
ClusterCentroids,
EditedNearestNeighbours,
RepeatedEditedNearestNeighbours,
AllKNN,
InstanceHardnessThreshold,
NearMiss,
NeighbourhoodCleaningRule,
OneSidedSelection,
TomekLinks,
CondensedNearestNeighbour)
import warnings
warnings.filterwarnings("ignore")
@dataclass
class EvalConfig:
X_train: np.ndarray
X_test: np.ndarray
y_train: np.ndarray
y_test: np.ndarray
ratios: list[int]
n_runs: int
def generate_data(n_samples: int, n_features: int,
class_sep: float = 0.75,
random_state: int = 42
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""
Генерирует синтетический набор данных для бинарной классификации
и возвращает предварительно разделенные train/test части.
Параметры:
- n_samples: общее число образцов
- n_features: число признаков
- class_sep: параметр, отвечающий за разделимость классов
- random_state: воспроизводимость
Возвращает:
- X_train, X_test, y_train, y_test (numpy arrays)
"""
# синтезируем данные
X, y = make_classification(
n_samples=n_samples,
n_features=n_features,
n_informative=int(n_features / 2),
n_redundant=0,
flip_y = 0,
n_clusters_per_class=2,
class_sep=class_sep,
random_state=random_state,
)
# делим на train/test
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=random_state
)
return X_train, X_test, y_train, y_test
def create_imbalanced_data(X, y, ratio: float, random_state: int = 42):
"""
Создает дисбаланс в данных, удаляя случайную часть записей
Параметры:
- X: тренировочные данные
- y: тестовые данные
- ratio: степень дисбаланса
- random_state: воспроизводимость
Возвращает:
- X, y с соотношением majority:minority = ratio:1.
"""
rng = np.random.default_rng(random_state)
y_arr = np.asarray(y)
unique, counts = np.unique(y_arr, return_counts=True)
# выбираем majority и minority — сортируем индексы по убыванию counts
order = np.argsort(-counts) # индексы отсортированы по убыванию (max -> min)
majority_class = unique[order[0]]
minority_class = unique[order[1]]
majority_idx = np.where(y_arr == majority_class)[0]
minority_idx = np.where(y_arr == minority_class)[0]
# целевое число элементов минорного класса (не больше существующих)
n_major = len(majority_idx)
n_min_target = max(1, int(n_major / ratio))
n_min_target = min(n_min_target, len(minority_idx))
chosen_min = rng.choice(minority_idx, size=n_min_target, replace=False)
chosen_all = np.concatenate([majority_idx, chosen_min])
rng.shuffle(chosen_all)
# формируем результат
if isinstance(X, pd.DataFrame):
X_sel = X.iloc[chosen_all].reset_index(drop=True)
y_sel = pd.Series(y_arr[chosen_all]).reset_index(drop=True).astype(int)
else:
X_sel = X[chosen_all]
y_sel = y_arr[chosen_all].astype(int)
return X_sel, y_sel
def plot_graph(data: pd.DataFrame, balance_method: str) -> None:
"""
Построение боксплота распределения метрик (например, F1-Score) по уровням дисбаланса.
Параметры:
- data: DataFrame с колонками ['Category', 'Values', 'Model', 'Method', 'Metric']
- balance_method: название метода балансировки (для подписи графика)
"""
sns.set_theme(style="darkgrid")
plt.figure(figsize=(14, 7))
ax = sns.boxplot(
x='Category',
y='Values',
hue='Model',
data=data,
palette='coolwarm'
)
ax.set_ylim(0, 1)
plt.xlabel('Уровень дисбаланса')
plt.ylabel('Распределение F1-Score')
plt.title(f'Распределение результатов для {balance_method}')
sns.despine(left=True, top=True)
plt.legend(title="Model")
plt.show()
def evaluate_models(balance_method: str,
method_mapping: dict,
models: dict[str, object],
cfg: EvalConfig,) -> None:
"""
Обучает клонированную модель на тренировочных данных и возвращает F1-score
на тестовой выборке вместе с используемым порогом классификации.
Параметры:
- balance_method: строка, указывающая метод балансировки.
- method_mapping: словарь с методами балансировки.
- models: список моделей.
- cfg: экземпляр EvalConfig (в текущей реализации параметр здесь не используется
напрямую, но оставлен для совместимости и возможных расширений).
"""
results = []
# извлекаем данные
X_train, X_test, y_train, y_test = cfg.X_train, cfg.X_test, cfg.y_train, cfg.y_test
# итерируемся по списку соотношений классов
for ratio in cfg.ratios:
desc=f"Соотношение {ratio}:1"
# для каждого обучения модели создаем свой уникальный дисбаланс, одинаковый для разных экспериментов
for run in tqdm(range(cfg.n_runs), desc=desc):
# подготовка обучающей выборки — либо исходная, либо с искусственным дисбалансом
if ratio == 1:
X_tr, y_tr = (pd.DataFrame(X_train) if isinstance(X_train, pd.DataFrame) else X_train), y_train
else:
X_tr, y_tr = create_imbalanced_data(X_train, y_train, ratio, random_state=run)
# применение метода балансировки (если не 'naive')
if (balance_method == 'naive') or (ratio == 1):
X_res, y_res = X_tr, y_tr
else:
# применяем модификацию данных
sampler = method_mapping[balance_method]
X_res, y_res = sampler.fit_resample(X_tr, y_tr)
# перебираем все модели из нашего списка
for model_name, model in models.items():
cloned = clone(model)
cloned.fit(X_res, y_res)
y_pred = cloned.predict(X_test)
# собираем результаты
results.append({"Category": f"{ratio}:1",
"Values": f1_score(y_test, y_pred),
"Model": model_name,
"Method": balance_method,
"Run": run,
"Ratio": ratio})
# строим график результатов
plot_graph(pd.DataFrame(results), balance_method)
Запускаем цикл перебирать наши методы:
for balance_method in method_mapping:
evaluate_models(balance_method, method_mapping, models, cfg)
Как мы упоминали в первой статье, из-за особенностей эксперимента вычисления на GPU только замедлят работу. Мы будем использовать процессор
Тестовый запуск
Для начала проведем оценку деградации F1-score без исправления дисбаланса

Результат такой же как был и в прошлой статье - падение метрики при дисбалансе 5 к 1 для моделей градиентного бустинга над решающими деревьями и при 2 к 1 для логистической регрессии.
Random Undersampler
Самый простой метод андерсемплинга - RandomUnderSampler. RandomUnderSampler берёт меньшинство за основу и случайным образом удаляет часть примеров из большинства, чтобы сделать классы сбалансированными.

Данный метод показывает очень хороший результат, значимое снижение точности происходит только при соотношении 500 к 1. Скорее всего, это связано с однородностью данных.
Cluster Centroids
При данном методе представители класса большинства объединяются в кластеры, после чего они заменяются на одно значение, которое лежит в центре этого кластера. В результате происходит неполное удаление части данных, сохраняется некое представления об удаленных данных.

Результат практически такой же как и при RandomUnderSampler, единственное при соотношении 1000 к 1 разброс незначительно меньше.
NearMiss
Данный метод основан на определении расстояния между частыми и редкими данными. Этот метод поддерживает 3 подхода:
NearMiss-1
Для каждого примера частого класса вычисляют среднее расстояние до k ближайших примеров меньшинства.
Оставляют те примеры частого класса, у которых это среднее расстояние наименьшее (то есть они ближе к меньшинству).
Полезно, если хотите сохранить примеры, которые находятся рядом с границей.
NearMiss-2
Для каждого примера частого класса вычисляют среднее расстояние до k самых удалённых примеров меньшинства.
Выбирают образцы класса большинства с наименьшим значением такого среднего расстояния (то есть те, которые находятся близко к примерам меньшинства).
Часто дает более «компактный» выбор примеров частого класса.
NearMiss-3
Для каждого примера меньшинства выбирают k ближайших примеров большинства; объединяют эти ближайшие и берут их как подмножество частого класса. Гарантирует покрытие меньшинства — для каждого примера меньшинства берутся ближайшие соседи из большинства.
Мы будем использовать второй вариант как более строгий.

Здесь мы видим хороший результат с более плавным снижением качества моделей, несмотря на значительное усиления дисбаланса.
Группа методов очистки
В эту группу методов входят Edited Nearest Neighbours, Repeated Edited Nearest Neighbours, All KNN и Tomek Links. Их суть заключается в попытке удалить шумные и пересекающиеся данные в категориях, и как следствие этого снизить количество записей.
Edited Nearest Neighbours (ENN). Для каждого представителя наибольшего класса определяются N ближайших соседей (n_neighbors, по умолчанию 3). Если все ближайшие соседи относятся к наибольшему классу, то он удаляется. Данный метод поддерживает и другой алгоритм – пример будет удаляться если большинство его соседей относятся к частому классу.
Представим точку класса 0 (частого) и её 3 ближайших соседа, которые имеют следующие классов 0, 0 , 1 и сравним действия алгоритмов:
all: не все соседи класса 0 -> запись не удаляется;
mode: наиболее частый класс из соседей 0 -> запись удаляется.
По умолчанию используется первый вариант, мы будем использовать второй вариант как более радикальный метод.

Repeated Edited Nearest Neighbours (RENN). Отличается от метода ENN многократным повторением, пока в очередном проходе ничего не удаляется, либо до заранее заданного числа итераций.

All KNN. Если в ENN используется фиксированное число соседей (например, k=3), то в AllKNN выполняется серия шагов, где k последовательно увеличивается от 1 до заданного максимального значения.

Tomek Links. Суть данного метода заключается в следующем: если два объекта из разных классов находятся близко, то они находятся в «зоне неопределенности». Данный метод находит такие пары и удаляет представителя наибольшего класса. Удаляться могут в том числе и представители редкого класса, в таком случае данный метод используется для очистки от похожих записей.
Такой подход позволяет избавиться от наиболее похожих представителей частого класса и облегчить обучение. Но в этом же заключается и минус такого подхода - сознательно избавляясь от наиболее сложноклассифицируемых данных, мы обучаем модель на более простых примерах, что искажает реальную картину.
Tomek Links поддерживает несколько паттернов:
auto - удаляет объекты всех классов кроме наименьшего (по умолчанию);
majority - удаляет объекты наибольшего класса (для многоклассовой классификации)
all - удаляет все найденные пары (для грубой очистки)

Ни один из методов этой группы не дал значительного эффекта. Скорее всего это связано с тем, что данные методы, в большей степени, направлены на очистку от шумных данных. У нас же уровень шума не настолько высокий, чтобы эти методы дали значительный эффект.
Группа методов, определяющих сложность классификации данных
К этой группе относятся Condensed Nearest Neighbour, Neighbourhood Cleaning Rule, One Sided Selection и Instance Hardness Threshold. Работа данных методов основывается на различиях в сложности классификации данных простым классификатором. Данные делятся на легко классифицируемые и сложно классифицируемые.
Condensed Nearest Neighbour. Этот метод заменяет данные на сжатое (condensed) подмножество.
Сжатое подмножество — это небольшая часть выборки, состоящая из наиболее информативных примеров. Алгоритм CNN поддерживает несколько стратегий определения такого подмножества (sampling_strategy). Так при стратегии “majority” все представители редкого класса будут перенесены в сжатое подмножество. Остальные примеры идут в несжатое подмножество. Затем поочерёдно берут каждый пример из несжатого и подают вместе со сжатым подмножеством на классификатор 1-NN (классификатор ближайшего соседа). Если пример правильно классифицируется — его пропускают, как не несущий никакой дополнительной информации, если классификатор ошибается — переносят в сжатое. Процесс продолжается до тех пор, пока данные не перестанут добавляться в сжатое множество. В результате остаются только информативные примеры, устранение дисбаланса это только побочный и не обязательный эффект.
Данный метод поддерживает несколько механизмов разделения на подмножества:
'majority' – уменьшает только мажоритарный класс (самый многочисленный).
'not minority' – уменьшает все классы, кроме самого маленького (при наличии более 2-х классов данных).
'all' – уменьшает все классы (в сжатое подмножество добавляются по одному представителю каждого класса, выбранные случайным образом).
'auto' – эквивалент 'not minority'.
конкретное количество (или долю) экземпляров для каждого класса в виде словаря.
список конкретных классов, которые нужно уменьшить.
В нашем эксперименте расчеты при таком подходе займут очень много времени, поэтому проводить мы его не будем
Neighbourhood Cleaning Rule. Представляет собой попытку объединить метод ENN и CNN: сначала проводится удаление части данных с помощью ENN, оставшиеся данные очищаются с помощью CNN.

Почему NCR работает быстрее CNN? NCR выполняет простые локальные очистки и сильно сокращает набор перед тем, как запускать более тяжелые операции, тогда как классический CNN — это итеративный, часто квадратичный по числу расстояний процесс, который многократно сравнивает пары объектов.
One Sided Selection. OSS комбинирует две идеи:
Удаление легко классифицируемых примеров класса большинства. Если объект частого класса находится в своей группе (т.е. его ближайшие соседи также принадлежат частому классу), то он не несет новой информации и может быть удален.
Сохранение граничных и сложных объектов. Объекты класса большинства, которые находятся рядом с классом меньшинства, сохраняются — они помогают алгоритму лучше провести границу между классами.
Алгоритм использует Condensed Nearest Neighbor (CNN) для удаления ненужных образцов, а затем применяет Tomek links для очистки границ (удаляет пары «перепутанных» соседей разных классов, когда они мешают корректной классификации).

Instance Hardness Threshold. Он оценивает уверенность классификатора в каждом объекте через предсказанную вероятность его истинного класса, полученную с помощью кросс-валидации. Затем он удаляет из целевых классов те объекты, у которых самая низкая вероятность принадлежности к своему классу (т.е. самые «трудные» для классификатора), чтобы получить нужный баланс.

Очень интересный результат показала модель IHT: при небольшом дисбалансе она продемонстрировала увеличение точности. Общие результаты также оказались достаточно высокими при соотношении меньше 100 к 1. Другие методы, определяющие сложность классификации данных не дали значительного эффекта.
Итоги
Методы, рассмотренные в данной статье, в отличии от предыдущей, показывают больший разброс скорости работы. Построим график, чтобы сравнить их. Абсолютные значения будут зависеть от вычислительной мощности, поэтому более важно соотношение временных затрат чем конкретные числа.

Наш эксперимент показал, что для наших данных наилучшим методом оказалось простое удаление случайных записей в классе большинства (метод RandomUnderSampler) и Cluster Centroids. На третьем месте у нас метод IHT и NearMiss. Первая модель показала хорошие результаты при низком уровне дисбаланса, вторая наоборот - при высоком. В случае более зашумленных данных методы очистки продемонстрировали бы больший результат.
Модели XGBClassifier, LGBMClassifier и CatBoostClassifier показали практически одинаковые результаты с незначительным преимуществом у CatBoostClassifier. LogisticRegression стабильно хуже остальных моделей