Введение

Привет, Хабр! На связи KozhinDev и ml-разработчик Приходько Александр. Это третья статья в цикле публикаций по теме борьбы с проблемой дисбаланса классов в машинном обучении. В первой статье мы обсудили актуальность данной проблемы в машинном обучении, а также сравнили методы борьбы с ним, без внесения изменений в сами данные: изменение весов классов и порога принятия решения моделью. Во второй статье мы сравнивали undersampling-методы, которые удаляли представителей частого класса.

В данной части мы протестируем методы балансировки данных методом oversampling из библиотеки imblearn. Суть данного метода заключается в том, что мы пытаемся бороться с дисбалансом классов генерируя данные для редкого класса. Мы рассмотрим разные способы генерации таких данных и протестируем их на синтетических данных.

Схема эксперимента

Для объективной оценки эффективности различных методов борьбы с дисбалансом классов мы проведем контролируемый эксперимент с синтетической генерацией данных и многоразовой валидацией. Эксперимент состоит из следующих этапов:

  1. Генерация синтетических данных. Используется функция sklearn.datasets.make_classification, позволяющая создать выборку с заданными характеристиками: число признаков, информативность, шум, а также начальный баланс классов. Это обеспечивает контролируемую среду для тестирования и исключает влияние внешних факторов реальных данных.

  2. Искусственное создание дисбаланса. Мы вручную создаем дисбаланс в тренировочной выборке, удаляя случайное подмножество примеров положительного класса. Такой подход позволяет сохранить контроль над уровнем дисбаланса и гарантирует наличие достаточного количества примеров для последующей реконструкции.

  3. Применение методов балансировки. На полученной несбалансированной выборке применяются различные методы балансировки. Каждая трансформированная выборка используется для обучения модели.

  4. Обучение и тестирование моделей. Для сравнения мы используем 4 модели-классификатора (LogisticRegression, XGBClassifier, LGBMClassifier и CatBoostClassifier), каждая из которых будет обучаться на искусственно сбалансированных данных, а затем тестироваться на изначально сгенерированной, неизмененной тестовой выборке. Это позволит оценить влияние методов балансировки на обобщающую способность моделей.

  5. Повторение эксперимента. Чтобы минимизировать влияние случайности при создании дисбаланса и борьбы с ним, пункты 2–4 повторяется 100 раз. Такой подход позволяет оценить вариативность и устойчивость каждого метода. Результаты агрегируются в виде boxplot-графиков по f1-score.

  6. Метрики качества. Метрикой в эксперименте выбран f1-score, как метрика работающая при сильном дисбалансе и отображающая качество классификации редкого класса.

С кодом можно ознакомиться в конце статьи

Тестовый запуск

По традиции мы начинаем с тестового запуска, чтобы было с чем сравнить эффективность методов.

Результат стабильный: падение точности логистической регрессии при соотношении 2:1 и при соотношении 5:1 у моделей градиентного бустинга на решающих деревьях.

Random Oversampling

Это простой метод, который дублирует с возвращением случайные данные редкого класса.

Как можно заметить, модели становятся стабильней и проблемы возникают только при соотношении классов 50:1. Random oversampling дает значительный эффект при использовании логистической регрессии - при усилении дисбаланса усиливается только разброс f1-score.

SMOTE

Synthetic Minority Over-sampling Technique (SMOTE) принципиально отличается от Random Oversampling, тем что он не просто дублирует существующие примеры, а генерирует новые, синтетические примеры на основе существующих. Новые объекты создаются между k ближайших соседей одного и того же класса.

В сравнении с дублированием случайных записей метод показал более высокий результат при сильном дисбалансе. Также, в отличии от Random Oversampling, при использовании SMOTE лучше себя показала модель LGBMClassifier.

SMOTEN и SMOTENC

Synthetic Minority Over-sampling Technique for Nominal (SMOTEN) и Synthetic Minority Over-sampling Technique for Nominal and Continuous (SMOTENC) предназначены для работы с категориальными данными. При этом SMOTEN предназначен исключительно для категориальных данных, а SMOTENC применяется в том числе для численных наборов. В наших данных присутствуют только численные данные, поэтому мы не будем тестировать эти методы.

ADASYN

Adaptive Synthetic Sampling (ADASYN) адаптивно распределяет количество синтетических образцов по всем примерам меньшинства: чем «труднее» классификация (т.е. чем больше соседей из большинства у конкретной точки), тем больше для нее будет создано синтетики. Генерация образцов такая же как в SMOTE (линейная интерполяция с соседями меньшинства), но количество на точку пропорционально ее «сложности».

Результат практически идентичен результату работы метода SMOTE.

Borderline SMOTE

Borderline SMOTE сначала отбирает только те примеры меньшинства, которые находятся у границы (т.н. danger/borderline: имеют большое количество соседей из частого класса). Дальше метод генерирует синтетику только для этих выбранных пограничных точек.

Borderline SMOTE показал результат похожий на SMOTE, но с гораздо более высоким разбросом.

KMeansSMOTE

KMeansSMOTE добавляет этап кластеризации перед применением SMOTE:

  1. Сначала метод группирует объекты меньшинства (чаще - именно только их) в k кластеров с помощью метода кластеризации KMeans.

  2. Затем внутри каждого кластера создают синтетические образцы, применяя метод SMOTE только между соседями внутри кластера.

Часто распределение количества генерируемых точек между кластерами делается неравномерным (например, пропорционально размеру кластера или наоборот - больший вес дается более разреженным кластерам), чтобы компенсировать локальные дисбалансы внутри миноритарного класса.

При малом количестве представителей редкого класса их может быть недостаточно для генерации синтетических данных внутри кластера. В результате возникнет ошибка “RuntimeError: No clusters found with sufficient samples of class 1. Try lowering the cluster_balance_threshold or increasing the number of clusters”. Есть несколько методов борьбы с данной проблемой:

  1. Уменьшить cluster_balance_threshold, что снизит требования к количеству данных в кластере;

  2. Динамически определять количество кластеров, на которые будут разбиваться данные. Уменьшая количество кластеров мы тем самым увеличим количество данных внутри одного кластера, но снижаем количество данных которые можно синтезировать;

  3. Вместо стандартного KMeans можно использовать метод MiniBatchKMeans. При очень малом числе примеров редкого класса стандартный KMeans чаще создаёт пустые или почти пустые кластеры, либо сильно различающиеся результаты кластеризации. MiniBatchKMeans ведёт себя более предсказуемо за счёт обработки данных небольшими батчами.

В нашем коде мы применим все эти методы, но когда и они перестанут помогать, мы для KMeansSMOTE будем записывать нуль в результаты f1-score.

Мы видим значительное увеличение разброса F1-score. При соотношении 1000:1, все значения F1-score равны нулю из-за недостаточности данных.

SVM SMOTE

Данный метод отличается от KMeansSMOTE тем что, для определения тех экземпляров миноритарного класса, которые следует дополнять, используется метод опорных векторов (SVM) вместо KMeans.

SVM SMOTE показывает результат очень похожий на Borderline SMOTE.

Итоги

Теперь можно сравнить затраты времени на эти методы. Здесь не такой большой разброс по сравнению с группой методов undersampling из предыдущей статьи цикла.

На наших данных методы генерации показали себя хуже, чем методы удаления данных. Лучшие результаты у методов SMOTE и ADASYN, при этом с довольно высокой скоростью работы. Более сложные модификации метода SMOTE показали результат хуже. Скорее всего, это связано со структурой наших синтетических данных.

На высоких уровнях дисбаланса модель LogisticRegression показала себя стабильно лучше других. Среди моделей XGBClassifier, LGBMClassifier и CatBoostClassifier значительных различий нет с небольшим преобладанием CatBoostClassifier.

Код для реализации эксперимента

Скрытый текст
import numpy as np
import pandas as pd
from dataclasses import dataclass

from sklearn.cluster import MiniBatchKMeans
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 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.over_sampling import (RandomOverSampler, 
                                    SMOTE, 
                                    ADASYN, 
                                    BorderlineSMOTE, 
                                    KMeansSMOTE, 
                                    SVMSMOTE)

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 : 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_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:
                # модифицированное семплирование для KMS
                if balance_method == 'kms':
                    n_minority = int((y_tr == 1).sum())
                    n_majority = int((y_tr == 0).sum())
                    
                    # Подбираем число кластеров, если минорных много — больше кластеров, если мало — меньше
                    n_clusters = max(2, min(1000, max(2, n_minority // 5)))

                    # Динамический порог баланса в кластере — если доля минорного класса в train мала,
                    # понизим threshold чтобы не отбросить все кластеры
                    global_minority_ratio = n_minority / max(1, (n_majority + n_minority))
                    cbt = float(np.clip(global_minority_ratio * 2.0, 0.001, 1))

                    # определяем семплер
                    kmeans_est = MiniBatchKMeans(n_clusters=n_clusters, random_state=run, n_init=10)
                    sampler = KMeansSMOTE(kmeans_estimator=kmeans_est,
                                          cluster_balance_threshold=cbt,
                                          random_state=run)
                    
                else:
                    sampler = method_mapping[balance_method]

                # проводим обучение семплера, при ошибке записываем в результат нулевой f1-score
                try:
                    X_res, y_res = sampler.fit_resample(X_tr, y_tr)
                    
                except Exception as e:
                    print(f"Ошибка семплирования '{balance_method}' при соотношении {ratio}:1")

                    for model_name in models.keys():
                        results.append({
                            "Category": f"1:{ratio}",
                            "Values": 0.0,
                            "Model": model_name,
                            "Method": balance_method,
                            "Run": run,
                            "Ratio": ratio
                        })
                    continue

            # перебираем все модели из нашего списка
            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)

method_mapping = {
    "naive": None,
    "random_oversampling": RandomOverSampler(),
    "smote": SMOTE(),
    "adasyn": ADASYN(),
    "borderline_smote": BorderlineSMOTE(),
    "kms": None,
    "svm_smote": SVMSMOTE(),
}

# синтезируем данные
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)

Комментарии (0)