2025, Sep 29 23:16

Кросс-валидация без повторного андерсэмплинга: предвычисляем фолды

Как ускорить кросс-валидацию на несбалансированных данных: предвычислить андерсэмплированные фолды и передать их в HalvingRandomSearchCV. Без повторов.

Кросс‑валидация на несбалансированных данных нередко подталкивает к использованию стратегий повторной выборки вроде RandomUnderSampler из imblearn. Но есть нюанс: если поместить ресемплер внутрь pipeline и затем запустить поиск гиперпараметров, пере‑выборка будет выполняться снова и снова для каждого сплита и каждого кандидата. Когда андерсэмплинг для конкретного фолда детерминирован, эта повторная работа — пустая трата ресурсов.

Постановка задачи

Рассмотрим подход, где совместимая с imblearn Pipeline включает масштабирование, андерсэмплинг и градиентный бустинг. Гиперпараметры настраиваются с помощью HalvingRandomSearchCV. Схема выглядит аккуратно, но при этом андерсэмплинг выполняется при каждом fit в ходе поиска.

def fit_with_resampling(sampler_obj, estimator_obj, do_scale, search_space, X_tr, y_tr):
    # ресемплинг включён в pipeline, чтобы тестовый фолд оставался вне обучения
    if do_scale is True:
        pipe = Pipeline([
            ("scale", MinMaxScaler()),
            ("balance", sampler_obj),
            ("algo", estimator_obj),
        ])
    else:
        pipe = Pipeline([
            ("balance", sampler_obj),
            ("algo", estimator_obj),
        ])

    searcher = HalvingRandomSearchCV(
        estimator=pipe,
        param_distributions=search_space,
        n_candidates="exhaust",
        factor=3,
        resource="algo__n_estimators",
        max_resources=500,
        min_resources=10,
        scoring="roc_auc",
        cv=3,
        random_state=10,
        refit=True,
        n_jobs=-1,
    )

    searcher.fit(X_tr, y_tr)
    return searcher

В такой конфигурации андерсэмплинг заново запускается на каждой итерации кросс‑валидации для каждого кандидата в поиске. Если для данного фолда результат андерсэмплинга не меняется между прогонами, эта избыточность ни к чему.

Что именно вызывает неэффективность

Ресемплинг внутри pipeline повторно выполняется при каждом вызове fit во время поиска. HalvingRandomSearchCV перебирает несколько кандидатов и делает множество fit на каждом сплите в рамках схемы последовательного отсева. Поскольку в pipeline включён семплер, каждый такой fit пересчитывает один и тот же андерсэмплированный поднабор для данного фолда, даже когда семплер и данные неизменны.

Решение: заранее подготовить андерсэмплированные фолды и передать их в поиск

Практичный выход — построить фолды один раз, выполнить андерсэмплинг каждого обучающего фолда ровно один раз, сохранить только индексы и передать эти предвычисленные разбиения в HalvingRandomSearchCV через параметр cv. Такой подход сохраняет протокол оценки, гарантирует «чистый» отложенный тестовый фолд и избавляет от повторяющейся работы по ресемплингу.

def build_sampled_folds(X_arr, y_arr, splitter, sampler):
    cached = []
    for tr_idx, te_idx in splitter.split(X_arr, y_arr):
        sampler.fit_resample(X_arr[tr_idx], y_arr[tr_idx])
        tr_idx_sampled = tr_idx[sampler.sample_indices_]
        cached.append((tr_idx_sampled, te_idx))
    return cached

fold_splits = build_sampled_folds(
    X_arr=X_train, y_arr=y_train,
    splitter=KFold(3),
    sampler=RandomUnderSampler()
)

Имея такие фолды, подключите их к поиску и оставьте pipeline минимальным. Имя шага модели в pipeline должно совпадать с префиксами параметров, которые вы передаёте в поиск.

learners = [
    (GradientBoostingClassifier(), {"algo__max_depth": [1, 3]}),
    (RandomForestClassifier(), {"algo__max_depth": [1, 3]})
]

for clf, grid in learners:
    print(clf)
    pipe = Pipeline([
        ("algo", clf)
    ])
    tuner = HalvingRandomSearchCV(
        estimator=pipe,
        param_distributions=grid,
        n_candidates="exhaust",
        factor=3,
        resource="algo__n_estimators",
        max_resources=500,
        min_resources=10,
        scoring="roc_auc",
        cv=fold_splits,  # заранее подготовленные андерсэмплированные фолды
        random_state=10,
        refit=True,
        n_jobs=-1,
        verbose=True
    )
    tuner.fit(X_train, y_train)
Обучение на 3 фолдах для каждого из 2 кандидатов, всего 6 обучений

Важные детали при повторном обучении

Когда refit=True, HalvingRandomSearchCV переобучает лучший оценщик на всём датасете. Если цель — продолжать обучение на андерсэмплированной версии обучающей выборки, установите refit=False, а затем обучите лучшую конфигурацию отдельно на том андерсэмплированном обучающем наборе, который вы хотите использовать. Так вы избегаете повторного обучения на полном входе после поиска.

Почему этот подход оправдан

Предвычисление андерсэмплированных фолдов устраняет лишний ресемплинг во время подбора гиперпараметров, не меняя сам протокол оценки. Вы сможете повторно использовать одни и те же правила разбиения на train/test для разных моделей — XGBoost, CatBoost, GradientBoostingClassifier или RandomForestClassifier — при этом стоимость андерсэмплинга будет уплачиваться всего один раз на фолд. Кроме того, вы избегаете хрупких обходных путей с конкатенацией исходных и андерсэмплированных данных и попытками сопоставить, какие части относятся к какому фолду — такой путь легко реализовать неверно и получить утечки.

Итоги

Не включайте шаг ресемплинга в pipeline, используемый в поиске, если он детерминирован и зависит от фолда. Постройте фолды один раз, извлеките индексы андерсэмплированного обучающего поднабора через sample_indices_ семплера и напрямую передайте эти предвычисленные разбиения в HalvingRandomSearchCV. Если финальную модель нужно обучать на андерсэмплированном наборе, отключите автоматический refit и выполните заключительное обучение самостоятельно на тех данных, на которых модель действительно должна учиться. Так кросс‑валидация остаётся чистой, экономной и согласованной для всех моделей, которые вы настраиваете.

Статья основана на вопросе со StackOverflow от Sole Galli и ответе от SLebedev777.