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

Особенности данных с дисбалансом

Проблема дисбаланса классов (class imbalance) является одной из ключевых сложностей в задачах классификации, особенно в областях, где целевые события по своей природе редки. Это касается, в частности, медицины (диагностика редких заболеваний), финансов (обнаружение мошенничества), технической диагностики (предсказание поломок) и других критически чувствительных сфер, где цена ошибки модели чрезвычайно высока: будь то пропуск тяжелого заболевания на ранней стадии, финансовые и репутационные потери или возникновение техногенной аварии.

В условиях сильного перекоса распределения классов стандартные алгоритмы обучения склонны к смещению в сторону преобладающего класса, поскольку минимизация общей ошибки не отражает реальных затрат на неверную классификацию редких примеров. Как следствие, такие модели могут демонстрировать высокую статистическую точность, одновременно обладая крайне низкой чувствительностью к событиям, представляющим наибольший интерес. К примеру, если мы создаем модель для диагностирования редкой болезни с частотой 1 случай на 100 000 населения, модель может ставить всем пациентам диагноз «здоров», и будет права в 99.999% случаев.

Оценка качества модели при дисбалансе классов

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

Accuracy (доля правильных ответов). Интуитивно понятная и широко распространенная метрика, определяемая как:

Accuracy = \frac{TP + TN}{TP + TN + FP + FN}

где:

  • TP — истинно положительные;

  • TN — истинно отрицательные;

  • FP — ложно положительные;

  • FN — ложно отрицательные.

В условиях сильного дисбаланса accuracy становится бессмысленной: модель, предсказывающая только мажоритарный (частый) класс, может достигать высокой точности, полностью игнорируя редкий класс.

Группа метрик Precision, Recall и F1-score. Для оценки качества по меньшему классу обычно используют:

  • Precision (точность) — доля корректных предсказаний среди всех, отнесенных к положительному классу:

Precision = \frac{TP}{TP + FP}
  • Recall (полнота) — доля корректно предсказанных положительных примеров среди всех истинно положительных:

Recall = \frac{TP}{TP + FN}

Упростив, можно сказать, что Precision определяет вероятность ошибки первого рода (ложно-положительное срабатывание), Recall — вероятность ошибки второго рода (пропуск целевого события). При достижении определенного уровня качества модели повышая Precision мы снижаем Recall и наоборот. Возникает дилемма, что для нас менее критично: пропустить поломку оборудования (ошибка второго рода) или остановить производство для исправления поломки, которой нет (ошибка первого рода). Компромиссом стало введение новой метрики F1-score (подробней этой дилеммы мы коснемся в последней статье нашего цикла).

F1-score — гармоническое среднее между precision и recall:

F1 = \frac{2 \cdot Precision \cdot Recall}{Precision + Recall}

Эти метрики являются более устойчивыми к дисбалансу, особенно при явном выделении положительного класса как целевого. Однако и они имеют ограничения:

  • все эти метрики чувствительны к изменению порога классификации;

  • F1-score учитывает только один класс (обычно положительный), игнорируя поведение модели на других;

  • в задачах с более чем двумя классами возникает необходимость агрегирования результата по каждому отдельному классу в один общий результат: macro, micro, weighted F1. Каждый из этих подходов имеет свои преимущества и искажения при дисбалансе.

Группа метрик ROC AUC и PR AUC. Особенностью данной категории является то, что она оценивает модель для всех возможных порогов определения класса. Модели машинного обучения при прогнозировании результата возвращают вероятность каждого из классов. На самом деле технически все немного сложней, но чтобы не сильно углубляться, мы будем считать, что это просто вероятность возникновения прогнозируемого события. В бинарной классификации по умолчанию используется порог в 0.5 для вероятности класса 1. ROC AUC и PR AUC оценивают результативность модели (значения по оси y) для всех возможных порогов (значения по оси X), строя кривую для его отображения. Площадь под этой кривой оценивают как результативность модели.

ROC AUC (Area Under ROC Curve). ROC AUC измеряет способность модели различать классы, отображая зависимость между долей правильно предсказанных положительных среди всех реально положительных классов (TPR) и доля неправильно предсказанных положительных среди всех реально отрицательных классов (FPR) при различном пороге определения критической вероятности. Если проще, то данная метрика показывает, вероятность того, что модель сможет правильно отличить класс 1 от класса 0.

ROC\ AUC = \int_0^1 TPR(FPR)\, dFPR, \quad

где:

TPR = \frac{TP}{TP + FN}, \quad  FPR = \frac{FP}{FP + TN}
https://neptune.ai/blog/f1-score-accuracy-roc-auc-pr-auc

В условиях сильного дисбаланса FPR может оставаться низким даже при большом количестве ложноположительных прогнозов, из-за большого числа TN, что делает ROC AUC излишне оптимистичной.

PR AUC (Area Under Precision-Recall Curve). Эта метрика во многом похожа ROC-AUC, но в отличии от нее она показывает не зависимость TPR от FPR, а зависимость Precision от Recall. PR AUC считается более информативной в задачах с сильным дисбалансом, поскольку фокусируется только на положительном классе. Она особенно полезна, когда важно оценить качество именно по меньшему классу, который встречается очень редко.

PR\ AUC = \int_0^1 Precision(Recall)\, dRecall

Тем не менее, интерпретация PR AUC менее интуитивна, а чувствительность к малым изменениям в предсказаниях требует осторожности в использовании:

  • Ошибки на первых позициях (где precision наиболее важна) сильнее уменьшают площадь, чем те же ошибки в хвосте.

  • При очень редком положительном классе базовая (случайная) precision очень мала; поэтому относительное изменение precision при небольшой модификации позиций может быть большим.

https://neptune.ai/blog/f1-score-accuracy-roc-auc-pr-auc

Нельзя однозначно назвать универсальную метрику качества, которая будет обладать всеми преимуществами: устойчивостью к дисбалансу, интерпретируемость, многоклассовостью и подобными. Чаще всего на практике используется ROC AUC и F1-score при сильном дисбалансе. Мы в своей практике, по возможности, используем кастомная метрика точности.

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

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

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

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

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

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

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

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

Пара важных моментов:

  1. Почему мы используем именно F1-score, а не ROC-AUC или PR-AUC? AUC-метрики дают более теоретическое понимание эффективности модели сразу на всех возможных порогах принятия решений. На практике же необходимо определить конкретный порог для прогноза. Возможности смещения такого порога мы рассмотрим чуть позже.

  2. Почему тестирование проводится именно на первоначальной выборке, если модель обучалась на дисбалансных данных? Мы оцениваем деградацию обобщающей способности модели при нарастании дисбаланса в обучающих данных. Для этого используем фиксированную тестовую выборку с натуральным (исходным) распределением классов, чтобы изолировать эффект дисбаланса в обучении и обеспечить сопоставимость результатов между экспериментами. Тестирование на первоначальной (сбалансированной) выборке позволяет ответить на вопрос: «Насколько хорошо модель научилась распознавать оба класса, несмотря на дисбаланс в обучении?»

Подготовка

В данной статье я применю только встроенные в модели методы борьбы с дисбалансом: подбор оптимального порога принятия решения моделью и взвешивание весов классов.

Ниже код для нашего эксперимента:

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

from tqdm import tqdm

from dataclasses import dataclass
from typing import Tuple, Dict, List, Optional, Callable
from collections import defaultdict
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 scipy.special import expit

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

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)
    if len(unique) != 2:
        raise ValueError("Функция поддерживает только бинарную классификацию")

    # выбираем 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 get_proba(model, X) -> np.ndarray:
    """
    Универсально возвращает вероятности для положительного класса в бинарной задаче.

    Параметры:
    - model: клон модели
    - X: тренировочные данные

    Возвращает:
    - Возвращаем одномерный numpy array с вероятностями для класса '1'.
    """
    # используем встроенный метод предсказания вероятностей, если модель его поддерживает
    if hasattr(model, "predict_proba"):
        proba = model.predict_proba(X)
        if proba.ndim == 2 and proba.shape[1] == 2:
            return proba[:, 1]
        elif proba.ndim == 2:
            raise ValueError("Model is multiclass - get_proba expects binary classifier.")
        return proba.ravel()

    # если не поддерживает predict_proba, но имеет decision_function, преобразовать ее выход в вероятность
    if hasattr(model, "decision_function"):
        return expit(model.decision_function(X))

    # если модель не поддерживает ни predict_proba, ни decision_function, возвращаем 0/1.
    return model.predict(X).astype(float)


def optimize_threshold(proba_val: np.ndarray, 
                       y_val: np.ndarray, 
                       steps=50):
    """
    Подбирает лучший порог (threshold) на валидационной выборке по заданной метрике.

    Подход:
    - формируем сет квантилей вероятностей (линейно по квантилям от 0.01 до 0.99)
    - вычисляем уникальные пороги по квантилям (уменьшаем количество проверок)
    - выбираем порог, дающий максимум f1_score(y_val, preds)

    Параметры:
    - proba_val: вероятности на валидации (1d numpy array)
    - y_val: истинные метки валидации
    - steps: число шагов для формирования набора квантилей (по умолчанию 50)

    Возвращает кортеж (best_threshold, best_score).
    """
    # создаем квантили и создаем уникальные трешхолды
    qs = np.linspace(0.01, 0.99, steps)
    thresholds = np.unique(np.quantile(proba_val, qs))

    # перебираем пороги и вычисляем метрики
    best_t, best_score = 0.5, 0
    for t in thresholds:
        preds = (proba_val >= t).astype(int)
        score = f1_score(y_val, preds)
        if score > best_score:
            best_score, best_t = score, t
    return best_t, best_score


def train_with_threshold(cloned_model, 
                         X_tr: np.ndarray, y_tr: np.ndarray, 
                         X_test: np.ndarray, y_test: np.ndarray,
                         cfg: EvalConfig, 
                         balance_method: str, 
                         X_val=None, y_val=None):
    """
    Обучает клонированную модель на тренировочных данных и возвращает F1-score
    на тестовой выборке вместе с используемым порогом классификации.

    Параметры:
    - cloned_model: sklearn-подобная модель (клонированный объект), готовая к обучению.
    - X_tr, y_tr: тренировочные признаки и метки (numpy array или DataFrame/Series).
    - X_test, y_test: тестовые признаки и метки.
    - cfg: экземпляр EvalConfig (в текущей реализации параметр здесь не используется
      напрямую, но оставлен для совместимости и возможных расширений).
    - balance_method: строка, указывающая режим балансировки ('threshold_opt' или другие).
    - X_val, y_val: опциональная валидационная выборка, необходимая при 'threshold_opt'.

    Возвращает:
    - Кортеж (score, used_threshold):
        - score: значение f1_score на тестовой выборке при применённом пороге.
        - used_threshold: порог, применённый для формирования предсказаний
          (0.5 — если подбор не выполнялся, либо найденный оптимальный порог).
    """
    # обучаем модель и делаем прогноз
    cloned_model.fit(X_tr, y_tr)
    proba_test = get_proba(cloned_model, X_test)

    # оптимизация порога, если нужно
    if balance_method in ["threshold_opt", "balance+threshold"]:
        proba_val = get_proba(cloned_model, X_val)
        best_t, _ = optimize_threshold(proba_val, y_val)
        preds = (proba_test >= best_t).astype(int)
        return f1_score(y_test, preds), best_t

    # стандартное прогнозирование
    preds = (proba_test >= 0.5).astype(int)
    return f1_score(y_test, preds), 0.5


def plot_graph(data: pd.DataFrame, balance_method: str) -> None:
    """
    Построение боксплота распределения метрик по уровням дисбаланса.

    Параметры:
    - data: DataFrame с колонками ['Category', 'Values', 'Model', 'Method', 'Run', 'Ratio']
    - 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,
                    models: Dict[str, object],
                    cfg: EvalConfig,) -> None:
    """
    Обучает клонированную модель на тренировочных данных и возвращает F1-score
    на тестовой выборке вместе с используемым порогом классификации.
    
    Параметры:
    - balance_method: строка, указывающая режим балансировки ('threshold_opt' или другие).
    - models: список моделей.
    - cfg: экземпляр EvalConfig (в текущей реализации параметр здесь не используется
      напрямую, но оставлен для совместимости и возможных расширений).
    """
    results = []
    thresholds_store = defaultdict(lambda: defaultdict(list))

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

            # если выбран режим threshold_opt — выделяем валидацию из тренировочного набора
            if balance_method in ["threshold_opt", "balance+threshold"]:
                X_tr, X_val, y_tr, y_val = train_test_split(
                    X_tr, y_tr, test_size=0.2, stratify=y_tr, random_state=run
                )
            else:
                X_val = y_val = None

            # распаковываем словарь с моделями
            for model_name, model in models.items():
                # клонируем модель
                cloned = clone(model)

                # расчет веса для балансировки
                y_arr = np.asarray(y_tr)
                n_pos = int((y_arr == 1).sum())
                n_neg = int((y_arr == 0).sum())
                weight = n_neg / max(1, n_pos)

                # рассчитываем веса классов для ряда случаев
                if (model_name == 'XGBoost' and balance_method in ["auto_balance", "balance+threshold"])\
                      or model_name == 'LightGBM_scale':
                    cloned.set_params(scale_pos_weight = weight)
                elif model_name == 'CatBoost_scale':
                    cloned.set_params(class_weights = [1.0, float(weight)])

                # обучение и подбор порога
                score, used_t = train_with_threshold(cloned, X_tr, y_tr, X_test, y_test,
                                                     cfg, balance_method, X_val, y_val)
                # записываем результат
                results.append({
                    "Category": f"{ratio}:1",
                    "Values": score,
                    "Model": model_name,
                    "Method": balance_method,
                    "Run": run,
                    "Ratio": ratio
                })

                if used_t is not None:
                    thresholds_store[model_name][ratio].append(used_t)
    
    # строим резублирующий график
    plot_graph(pd.DataFrame(results), balance_method)

Запуск эксперимента

Эксперимент включает большое количество итераций с повторной подготовкой и преобразованием датасетов, в тоже время модели легкие и быстро обучаются. В таких условиях основное время уходит на CPU-операции по трансформации данных и передачу их на устройство, а не на матричные умножения на GPU. Ускорение обучения моделей на GPU не может компенсировать затрат на перенос данных между CPU и GPU и в результате длительность эксперимента увеличивается.

Для начала запустим обучение на базовых моделях без каких-либо надстроек, чтобы посмотреть как дисбаланс повлияет на результат.

Генерируем данные:

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,
)

Запускаем обучение:

balance_method = 'naive'
models = {"LogisticRegression": LogisticRegression(),
         "XGBoost": XGBClassifier(),
         "LightGBM": LGBMClassifier(verbose=-1),
         "CatBoost": CatBoostClassifier(verbose=0)}

evaluate_models(balance_method, models, cfg)

Как видно из графика, для моделей градиентного бустинга над решающими деревьями (XGBClassifier, LGBMClassifier и CatBoostClassifier) значительное снижение качества обучения происходит уже при соотношении классов 5 к 1. Качество LogisticRegression падает уже при соотношении 2 к 1. Также логистическая регрессия вполне ожидаемо показала качество значительно хуже. Расхождения между XGBClassifier, LGBMClassifier и CatBoostClassifier практически нет.

Порог принятия решений

Ранее я упоминал, что при прогнозе модели возвращают вероятность каждого отдельного класса.  Подбирая наиболее оптимальный порог для прогноза, можно улучшить качество классификации. В таком случае нам понадобится выделить из данных для тренировки валидационный датасет, а следовательно количество данных для тренировки снизится на 20% (обычно для тестирования обучения выделяют от 20 до 30 процентов выборки)

balance_method = "threshold_opt"
evaluate_models(balance_method, models, cfg)

Результат показывает прирост по точности, особенно при соотношении больше 50 к 1. Также есть значительный разброс F1-score, и чем сильнее дисбаланс, тем сильнее разброс.

Веса классов

Теперь мы используем автоматическую балансировку классов. Данный подход несколько различается у разных моделей, но суть одна - модель штрафуется сильнее при ошибках в определении конкретного класса. Как правило, это применяется для борьбы с дисбалансом классов, но может использоваться для оптимизации модели для поддержки бизнес-логики. Об этом подробнее будет также в последней статье цикла.

  • Логистическая регрессия использует параметр class_weight='balanced', который автоматически присваивает веса классам, обратно пропорциональные их частоте. Вес класса вычисляется как:

w_i = \frac{n_{\text{samples}}}{n_{\text{classes}} \cdot np.bincoun(y)}
  • XGBClassifier. Параметр scale_pos_weight умножает вес положительного класса (миноритарного) на заданное значение. Этот параметр определяется не автоматически: нужно вручную вычислить соотношение классов. В нашем эксперименте данные определяются внутри цикла, поэтому приходится переопределять модель классификатора заново.

  • LGBMClassifier поддерживает три подхода:

    • is_unbalance=True увеличивает вес миноритарного класса через коэффициенты в функции потерь

    • class_weight='balanced' рассчитываются как в логистической регрессии. 

    • scale_pos_weight рассчитываются как в XGBoost.

  • CatBoostClassifier поддерживает два варианта: 

    • class_weights рассчитываются как в XGBoost

    • auto_class_weights = 'Balanced' веса классов вычисляются как

CW_{k} = \frac{\max_{c=1}^{K} \left( \sum_{t_i = c} w_i \right)}{\sum_{t_i = k} w_i}

Таким образом можно как отдать эту задачу балансировки классов на откуп самим моделям, так и подогнать веса классов вручную для оптимального результата.

balance_method = "auto_balance"
models = {"LogisticRegression": LogisticRegression(class_weight='balanced'),
         "XGBoost": XGBClassifier(),
         "LightGBM": LGBMClassifier(class_weight='balanced', verbose=-1),
         "CatBoost": CatBoostClassifier(auto_class_weights='Balanced', verbose=0)}

evaluate_models(balance_method, models, cfg)

Стабильность моделей значительно повысилась и спад начался только при соотношении больше 50 к 1. LogisticRegression продемонстрировала наибольшую стабильность, и при соотношении 1000 к 1 она показала наиболее высокое значение F1-score. Скорее всего это связано с самим механизмом работы логистической регрессии. Худший результат при высоком дисбалансе у XGBClassifier.

Порог принятия решений + веса классов

Вполне логично попробовать объединить два предыдущих метода и посмотреть смогут ли они улучшить друг друга

balance_method = "balance+threshold"
models = {"LogisticRegression": LogisticRegression(class_weight = 'balanced'),
          "XGBoost": XGBClassifier(),
          "LightGBM": LGBMClassifier(is_unbalance = True, verbose = -1),
          "CatBoost": CatBoostClassifier(auto_class_weights = 'Balanced', verbose = 0)}

evaluate_models(balance_method, models, cfg)

Комбинация метода балансировки классов и определение оптимального порога принятия решения дает комбинированный результаты. Наибольший эффект был для модели LGBMClassifier при соотношении 1000 к 1. Относительно обычной балансировки весов классов есть значительный прирост F1-score.

Различные методы балансировки классов

Как оговаривалось ранее модели поддерживают различные методы балансировки классов. Сравним различные методы взвешивания классов для моделей LightGBM и CatBoost.

balance_method = "difference_methods"
models = {"LightGBM_unbalance": LGBMClassifier(is_unbalance=True, verbose=-1),
         "LightGBM_balanced": LGBMClassifier(class_weight='balanced', verbose=-1),
         "LightGBM_scale": LGBMClassifier(verbose=-1),
         "CatBoost_balanced": CatBoostClassifier(auto_class_weights='Balanced', verbose=0),
         "CatBoost_scale": CatBoostClassifier(verbose=0)}

evaluate_models(balance_method, models, cfg)

Как мы видим, различные методы балансировки показывают практически одинаковый результат. Единственное отличие у LightGBM с методом class_weight = 'balanced', при соотношении 100 к 1 результат лучше, а при более сильном результат значительно хуже.

Итоги

  1. Даже небольшой дисбаланс данных может привести к значительному ухудшению качества классификации

  2. Определение оптимального порога принятия решения может компенсировать вред от дисбаланса классов, но в незначительной степени и с большой нестабильностью результатов

  3. Автоматическая балансировка веса классов может дать значительный прирост к точности моделей, но при дисбалансе выше соотношения 50 к 1 этот метод начинает терять эффективность.

  4. Модель LogisticRegression вполне ожидаемо показала наиболее низкое значение F1-score и большую чувствительность к дисбалансу. При этом автоматическая балансировка классов придает этой модели наибольшую устойчивость. И то и другое явно является результатом принципиального отличия модели логистической регрессии и моделей градиентного бустинга. При действительно высоком дисбалансе имеет смысл попробовать модель LogisticRegression.

  5. Модель XGBoost в нашем эксперименте на синтетических данных с балансировкой классов показала наихудший результат из всех моделей при высоком дисбалансе. Скорее всего это связано с механизмом балансировки весов.

  6. Модель LightGBM показала более высоких значениях f1-score при балансировки классов при соотношении 500 к 1 и 1000 к 1. Но при этом результаты имеют значительный разброс.

  7. CatBoost имеет более высокую точность на сырых моделях.

  8. Различные методы балансировки веса классов не дают значительного различия в результатах

Можно расширить эксперимент: подобрать параметры модели (такие как регуляризация), изменить функцию потерь (к примеру на FocalLoss), использовать ансамбль моделей и пр., но перед нами стояла зада ознакомиться с самими принципами. Даже не углубляясь настолько сильно в оптимизацию моделей мы видим, что даже при сильном дисбалансе возможно увеличить метрики без модификации данных.

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