
Привет, Хабр! На связи KozhinDev и ml-разработчик Приходько Александр. Данная статья является четвертой частью цикла статей о борьбе с дисбалансом классов. Предыдущие статьи:
В первой статье мы рассказали про суть проблемы дисбаланса классов и стандартные методы борьбы с ним;
Во второй статье обсуждались методы undersampling - удаление данных из распространенного класса;
В третьей статье рассматривались методы oversampling - генерация примеров редкого класса.
В данной статье мы рассмотрим комбинированные и ансамблевые методы библиотеки Imbalanced Learn.
Комбинированные/гибридные методы — сочетают oversampling и undersampling в одной последовательности, часто с процедурой «очистки» границ классов (например, SMOTE + удаление Tomek links или SMOTE + ENN).
Ансамблевые методы – создают несколько моделей, каждая из которых обучается на разных сбалансированных подвыборках (обычно путём недовыбора большинства) или встроенно учитывают несбалансированность при генерации базовых моделей. Затем их прогнозы объединяются (среднее, голосование и т.д.).
Схема эксперимента
Для объективной оценки эффективности различных методов борьбы с дисбалансом классов мы проведем контролируемый эксперимент с синтетической генерацией данных и многоразовой валидацией. Эксперимент состоит из следующих этапов:
Генерация синтетических данных. Используется функция sklearn.datasets.make_classification, позволяющая создать выборку с заданными характеристиками: число признаков, информативность, шум, а также начальный баланс классов. Это обеспечивает контролируемую среду для тестирования и исключает влияние внешних факторов реальных данных.
Искусственное создание дисбаланса. Мы вручную создаем дисбаланс в тренировочной выборке, удаляя случайное подмножество примеров положительного класса. Такой подход позволяет сохранить контроль над уровнем дисбаланса и гарантирует наличие достаточного количества примеров для последующей реконструкции.
Применение методов балансировки. На полученной несбалансированной выборке применяются различные методы балансировки. Каждая трансформированная выборка используется для обучения модели.
Обучение и тестирование моделей. Для сравнения мы используем 4 модели-классификатора (LogisticRegression, XGBClassifier, LGBMClassifier и CatBoostClassifier), каждая из которых будет обучаться на искусственно сбалансированных данных, а затем тестироваться на изначально сгенерированной, неизмененной тестовой выборке. Это позволит оценить влияние методов балансировки на обобщающую способность моделей.
Повторение эксперимента. Чтобы минимизировать влияние случайности при создании дисбаланса и борьбы с ним, пункты 2–4 повторяется 100 раз. Такой подход позволяет оценить вариативность и устойчивость каждого метода. Результаты агрегируются в виде boxplot-графиков по f1-score.
Метрики качества. Метрикой в эксперименте выбран f1-score, как метрика работающая при сильном дисбалансе и отображающая качество классификации редкого класса.
С кодом можно ознакомиться в конце статьи
Тестовый запуск
Для начала проведем оценку деградации f1-score без исправления дисбаланса

Результат такой же как был и в прошлых статьях – падение метрики при соотношении 5:1 для моделей градиентного бустинга над решающими деревьями и при 2:1 для логистической регрессии.
SMOTE ENN
Метод SMOTE ENN комбинирует метод генерации данных SMOTE и метод удаления ENN. SMOTE создает новые объекты между k ближайших соседей одного и того же класса. ENN для каждого представителя частого класса определяет k ближайших соседей и если все они (или большинство из них) принадлежат такому же классу, то он удаляется.

Комбинированное применение SMOTE и ENN дало небольшой прирост f1-score по сравнению с использованием только SMOTE. Интересно, что аналогичный прирост наблюдается и при отдельном применении ENN. Это позволяет предположить, что методы не конкурируют друг с другом, а работают с разными аспектами данных: SMOTE усиливает представление редкого класса, тогда как ENN корректирует качество данных, удаляя шумовые элементы.


SMOTE Tomek
Метод SMOTE Tomek комбинирует метод генерации данных SMOTE и метод удаления Tomek links. Tomek links находит пары близко расположенных данных частого и редкого классов и в каждой из таких пар удаляет элемент, принадлежащий к частному классу.

SMOTE Tomek показал результат немного хуже, чем SMOTE ENN
Easy Ensemble Classifier
Easy Ensemble Classifier - ансамбль моделей, каждая из которых обучена на сбалансированном поднаборе: все примеры редкого класса + случайная подвыборка частого класса (разная для каждой модели). По умолчанию используется эстиматор AdaBoostClassifier. Мы же будем менять базовый эстиматор на наши классификаторы (логистическая регрессия, XGBoost, LightGBM и CatBoost)

EEC показал очень хороший результат. Даже при соотношении 1000:1 модель модели показывают распределение f1-score на уровне 0.8
RUS Boost Classifier
Главная идея RUS Boost Classifier заключается в соединении случайного уменьшения большинства с последуюшим градиентным бустингом. На каждой итерации бустинга случайно уменьшается количество записей большего класса до сбалансированного набора, после чего обучается слабый классификатор на этом выборочном наборе, затем оценивается ошибка на полном наборе и обновляются веса образцов как в AdaBoostClassifier. То есть сочетание случайной выборки большинства (Random UnderSampling, RUS) с преимуществами бустинга.

Метод RUS Boost показал сильно различающийся между моделями результат. Значение f1-score для логистической регрессии и модели XGBoost резко упали при соотношении 5 к 1 и 2 к 1 соответственно. Модель LightGBM потеряла точность при соотношении 100 к 1. Модель CatBoost показала более стабильный результат.
Balanced Bagging Classifier
Balanced Bagging Classifier - это ансамблевый метод, который объединяет идею бэггинга (bagging) и сбалансированной выборки (обычно undersampling). Термин “Bagging” происходит от сочетания двух слов Bootstrap и AGGregating. Данный метод заключается в создании подвыборки с возвращением. То есть одна и та же запись наименьшего класса может попасть сразу в несколько подвыборок. Для каждой базовой модели в ансамбле создается подобная подвыборка так, чтобы классы внутри нее были сбалансированы (обычно за счёт сокращения представителей большинства). В реализации метода Balancing Bagging Classifier из пакета imblearn базовым классификатором по умолчанию является дерево решений.

Balanced Bagging Classifier показал результат незначительно хуже, чем Easy Ensemble Classifier
Balanced Random Forest Classifier
Как следует из названия, данный метод в своей основе использует случайный лес. Для каждой модели ансамбля формируется бутстрап-выборка, но не стандартная: большинство классов недовыбирают так, чтобы для каждого отдельного дерева решений количество образцов каждого класса были примерно равны. После этого на полученной сбалансированной подвыборке строится обычное решающее дерево. Повторяя это многократно, ансамбль сохраняет разнообразие деревьев (и поэтому не теряет слишком много информации при удалении представителей большинства), при этом повышается чувствительность для редкого класса.
Предыдущие ансамблевые методы строились на своих, выбранных создателями, моделях-классификаторах. Для сравнения наших первоначальных моделей мы заменили эти классификаторы на логистическую регрессию, XGBoost, LightGBM и CatBoost. Но метод Balanced Random Forest Classifier поддерживает только базовый эстиматор - случайный лес. Для чистоты эксперимента мы сравним этот метод с другими ансамблевыми методами на базовых эстиматорами.

Ансамблевые модели с базовыми эстиматорами показали себя лучше чем при использовании наших моделей. При этом, как будет видно по графику затрат времени на одну итерацию обучения. Balanced Random Forest Classifier, Easy Ensemble Classifier, RUS Boost Classifier и Balanced Bagging Classifier с базовыми эстиматорами работают значительно быстрее. Значительного отличия в f1-score между этими моделями нет
Итоги
Группа комбинированных и ансамблевых методов показали более высокие метрики, чем методы under- и oversampling'а, при этом оказались более медленными. Лучший результат - у ансамблевых методов на базовых эстиматорах, без значительного разброса эффективности.
Применение ансамблевых и комбинированных методов требует значительных вычислительных ресурсов. Так, 4 из 6 методов при максимальном количестве записей на одну итерацию тратили от одной минуты.

Код для реализации эксперимента
Скрытый текст
import numpy as np
import pandas as pd
from dataclasses import dataclass
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.tree import DecisionTreeClassifier
from sklearn.linear_model import LogisticRegression
from xgboost import XGBClassifier
from lightgbm import LGBMClassifier
from catboost import CatBoostClassifier
import matplotlib.pyplot as plt
import seaborn as sns
from tqdm import tqdm
from imblearn.combine import SMOTEENN, SMOTETomek
from imblearn.ensemble import (EasyEnsembleClassifier,
RUSBoostClassifier,
BalancedBaggingClassifier,
BalancedRandomForestClassifier)
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 train_estimator(model, train_type, method_mapping, balance_method, X_train, X_test, y_train):
# клонируем модель
cloned = clone(model)
# для стандартного варианта мы используем стандартный клон модели
if train_type == 'standard':
result_model = cloned
# для ансамблей мы передаем клон модели в качестве естиматора
else:
result_model = method_mapping[balance_method](estimator=cloned)
# обучение и получение прогноза
result_model.fit(X_train, y_train)
y_pred = result_model.predict(X_test)
return y_pred
def evaluate_models(balance_method: str,
method_mapping: dict,
models: dict[str, object],
cfg: EvalConfig,) -> None:
"""
Оценка набора моделей при разных уровнях искусственно созданного дисбаланса. В качестве результата работы строит график распределения f1-score обученных моделей в форме боксплотов.
Параметры
----------
balance_method : str
Метод балансировки/подачи выходов:
- "none" или любая другая строка: стандартное обучение и предсказание с порогом 0.5;
- "threshold_opt": разделяем тренировочную выборку на train/val и подбираем оптимальный порог на валидации;
- "auto_balance": автоматически выставляем параметр `scale_pos_weight` для XGBoost (в пропорции n_neg/n_pos).
models : Dict[str, object]. Словарь имен моделей -> экземпляров sklearn-подобных классификаторов.
cfg : EvalConfig Конфигурация эксперимента (X_train, X_test, y_train, y_test, ratios, n_runs, metric_func и т.д.)
base_models : Dict[str, object]. Словарь имен моделей -> экземпляров sklearn-подобных классификаторов.
"""
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_res, y_res = (pd.DataFrame(X_train) if isinstance(X_train, pd.DataFrame) else X_train), y_train
train_type = 'standard'
else:
# при необходимости создаем дисбаланс в данных
X_tr, y_tr = create_imbalanced_data(X_train, y_train, ratio, random_state=run)
# задаем параметры по умолчанию
train_type = 'standard'
X_res, y_res = X_tr, y_tr
# для метода naive семплирование не проводится
if balance_method == 'naive':
pass
elif balance_method != 'base_models':
# для комбинированных методов мы проводим модификацию данных
if balance_method in ['smote_enn', 'smote_tomek']:
sampler_cls = method_mapping[balance_method]
if sampler_cls is None:
X_res, y_res = X_tr, y_tr
else:
try:
sampler = sampler_cls(random_state=run)
except TypeError:
sampler = sampler_cls()
X_res, y_res = sampler.fit_resample(X_tr, y_tr)
# для ансамблей мы проводим обучение особым образом
else:
train_type = 'ensembl'
else:
# инициализация ансамблевых моделей как фабрик — каждая лямбда возвращает экземпляр классификатора
ensembles = [
("BalancedRandomForest", lambda run: BalancedRandomForestClassifier(random_state=run)),
("EasyEnsemble", lambda run: EasyEnsembleClassifier(estimator=DecisionTreeClassifier(random_state=run))),
("RUSBoost", lambda run: RUSBoostClassifier(estimator=DecisionTreeClassifier(random_state=run))),
("BalancedBagging", lambda run: BalancedBaggingClassifier(estimator=DecisionTreeClassifier(random_state=run)))
]
# обучение и получение прогноза; фабрики вызываются с текущим seed (run)
for ens_name, ens_factory in ensembles:
try:
clf = ens_factory(run) # инстанцируем классификатор
clf.fit(X_res, y_res) # обучаем на подготовленных данных
y_pred = clf.predict(X_test) # предсказываем на тесте
# записываем результат
results.append({
"Category": f"{ratio}:1",
"Values": f1_score(y_test, y_pred),
"Model": ens_name,
"Method": balance_method,
"Run": run,
"Ratio": ratio
})
except Exception as e:
# если стабилизация не помогла — записываем 0 и текст ошибки для диагностики
print(f"Error in {balance_method} / {ens_name} / {ratio}:1 - {str(e)}\n")
results.append({
"Category": f"{ratio}:1",
"Values": 0,
"Model": ens_name,
"Method": balance_method,
"Run": run,
"Ratio": ratio,
"Error": str(e)
})
continue
# перебираем все модели из нашего списка
for model_name, model in models.items():
y_pred = train_estimator(model, train_type,
method_mapping, balance_method,
X_res, X_test, y_res)
# собираем результаты в словарь
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)
# чтобы определить эстиматор для метода мы будем создавать экземпляр класса только при его передачи
method_mapping = {
"naive": None,
"smote_enn": SMOTEENN,
"smote_tomek": SMOTETomek,
"eec": EasyEnsembleClassifier,
"rbc": RUSBoostClassifier,
"bbc": BalancedBaggingClassifier,
"base_models": None
}
# создаем данные
n_samples = 100_000
n_features = 10
X_train, X_test, y_train, y_test = generate_data(n_samples, n_features)
cfg = EvalConfig(
X_train = X_train,
X_test = X_test,
y_train = y_train,
y_test = y_test,
ratios=[1, 2, 5, 10, 50, 100, 500, 1000],
n_runs=100,
)
models = {"LogisticRegression": LogisticRegression(),
"XGBoost": XGBClassifier(),
"LightGBM": LGBMClassifier(verbose=-1),
"CatBoost": CatBoostClassifier(verbose=0)}
# запуск эксперимента
for balance_method in method_mapping.keys():
evaluate_models(balance_method, method_mapping, models, cfg)