Глубина
~15 мин

Подбор гиперпараметров

Grid Search, Random Search, Bayesian Optimization (Optuna), early stopping, learning rate schedules.

Подбор гиперпараметров — от Grid Search до Optuna

Ты обучил XGBoost с дефолтными параметрами — AUC = 0.82. После подбора гиперпараметров: AUC = 0.89. Семь процентов, которые отделяют «модель» от «той самой модели». Но как искать эффективно, если пространство параметров содержит 10^6 комбинаций? Grid Search перебирает всё — и убивает неделю на GPU. Random Search находит хорошее решение за 30 итераций. Optuna с байесовской оптимизацией — за 15. Разберём каждый подход.

Сравнение стратегий подбора гиперпараметров: Grid, Random, Bayesian
Grid Search, Random Search и Bayesian Optimization: разные стратегии поиска в пространстве параметров

Что такое гиперпараметры и почему их нельзя выучить

Параметры — то, что модель выучивает из данных: веса нейросети, коэффициенты регрессии, разбиения деревьев. Гиперпараметры — то, что задаётся до обучения: learning rate, max_depth, количество деревьев, dropout rate. Они контролируют сложность модели и процесс обучения, но не оптимизируются через градиентный спуск.

  • Деревья/бустинг: max_depth (3-10), min_samples_leaf (1-100), n_estimators (100-3000), learning_rate (0.001-0.3), subsample (0.5-1.0), colsample_bytree (0.5-1.0)
  • Нейросети: learning_rate (1e-5 - 1e-2), batch_size (16-512), hidden_dim (64-1024), dropout (0.1-0.5), weight_decay (1e-6 - 1e-3), num_layers (2-8)
  • SVM: C (0.01-100), gamma (1e-4 - 1), kernel (rbf/linear/poly)
  • Общие: regularization strength, number of features, preprocessing steps (scaler type, PCA components)

Grid Search — полный перебор

Самый простой подход: задай сетку значений для каждого параметра, перебери все комбинации. Для каждой комбинации обучи модель с кросс-валидацией, выбери лучшую. Проблема — экспоненциальный рост: 5 параметров по 5 значений = 3125 комбинаций. С 5-fold CV = 15 625 обучений модели.

from sklearn.model_selection import GridSearchCV
from sklearn.ensemble import GradientBoostingClassifier

param_grid = {
    'n_estimators': [100, 200, 500],
    'max_depth': [3, 5, 7],
    'learning_rate': [0.01, 0.1, 0.3],
    'subsample': [0.8, 1.0],
}
# 3 * 3 * 3 * 2 = 54 комбинации * 5 folds = 270 обучений

grid_search = GridSearchCV(
    GradientBoostingClassifier(random_state=42),
    param_grid,
    cv=5,
    scoring='roc_auc',
    n_jobs=-1,       # параллелизация
    verbose=1
)
grid_search.fit(X_train, y_train)

print(f"Лучшие параметры: {grid_search.best_params_}")
print(f"Лучший ROC-AUC (CV): {grid_search.best_score_:.4f}")

Когда Grid Search оправдан: маленькое пространство (<100 комбинаций), быстрая модель (логистическая регрессия, SVM с малым датасетом), нужна полная воспроизводимость. Во всех остальных случаях — Random Search или Optuna.

Random Search — умнее, чем кажется

Bergstra & Bengio (2012) доказали: Random Search не хуже Grid Search в подавляющем большинстве случаев. Почему? Большинство гиперпараметров имеют разную важность. Если из 5 параметров реально важны 1-2, Grid Search тратит бюджет на перебор неважных комбинаций. Random Search сэмплирует каждое измерение независимо — при тех же 60 итерациях покрывает больше уникальных значений важных параметров.

from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import loguniform, randint, uniform

param_distributions = {
    'n_estimators': randint(100, 1000),
    'max_depth': randint(3, 10),
    'learning_rate': loguniform(0.001, 0.3),  # log-uniform для lr!
    'subsample': uniform(0.6, 0.4),           # uniform [0.6, 1.0]
    'min_samples_leaf': randint(1, 50),
}

random_search = RandomizedSearchCV(
    GradientBoostingClassifier(random_state=42),
    param_distributions,
    n_iter=60,        # 60 случайных комбинаций
    cv=5,
    scoring='roc_auc',
    random_state=42,
    n_jobs=-1,
    verbose=1
)
random_search.fit(X_train, y_train)

print(f"Лучшие параметры: {random_search.best_params_}")
print(f"Лучший ROC-AUC (CV): {random_search.best_score_:.4f}")

Конкретные числа из статьи Bergstra: 60 случайных итераций находят точку в топ-5% лучших с вероятностью 95%, если реально важных параметров 1-2. Это значит: 60 рандомных попыток ≈ Grid Search 9x9 (81 точка) по двум осям, но за меньший бюджет.

Bayesian Optimization — Optuna

Байесовская оптимизация строит вероятностную модель (surrogate) целевой функции и на каждом шаге выбирает точку, которая максимизирует expected improvement — баланс exploration (неизученные регионы) и exploitation (регионы с хорошими результатами). Optuna использует TPE (Tree-structured Parzen Estimator) — строит два распределения: p(x|y<y*) и p(x|y>=y*), выбирает x с максимальным отношением. С каждой итерацией surrogate улучшается → поиск концентрируется вокруг оптимума.

import optuna
from xgboost import XGBClassifier
from sklearn.model_selection import cross_val_score

def objective(trial):
    params = {
        'n_estimators': trial.suggest_int('n_estimators', 100, 1000),
        'max_depth': trial.suggest_int('max_depth', 3, 10),
        'learning_rate': trial.suggest_float('learning_rate', 1e-3, 0.3, log=True),
        'subsample': trial.suggest_float('subsample', 0.6, 1.0),
        'colsample_bytree': trial.suggest_float('colsample_bytree', 0.5, 1.0),
        'min_child_weight': trial.suggest_int('min_child_weight', 1, 20),
        'reg_alpha': trial.suggest_float('reg_alpha', 1e-8, 10.0, log=True),
        'reg_lambda': trial.suggest_float('reg_lambda', 1e-8, 10.0, log=True),
    }

    model = XGBClassifier(**params, random_state=42, n_jobs=-1)
    score = cross_val_score(model, X_train, y_train, cv=5, scoring='roc_auc').mean()
    return score

study = optuna.create_study(direction='maximize')
study.optimize(objective, n_trials=50, show_progress_bar=True)

print(f"Лучший ROC-AUC: {study.best_value:.4f}")
print(f"Лучшие параметры: {study.best_params}")

Pruning — не доучивай плохие модели

Optuna может останавливать плохие trials досрочно (pruning). Если на 10-м шаге бустинга метрика хуже медианы завершённых trials — зачем обучать до 1000? MedianPruner и PercentilePruner экономят 50-80% compute. Идеально сочетается с LightGBM early stopping.

import optuna
from optuna.integration import LightGBMPruningCallback
import lightgbm as lgb
from sklearn.model_selection import StratifiedKFold

def objective_lgbm(trial):
    params = {
        'objective': 'binary',
        'metric': 'auc',
        'verbosity': -1,
        'n_estimators': 1000,  # большое число — early stopping остановит раньше
        'learning_rate': trial.suggest_float('learning_rate', 1e-3, 0.3, log=True),
        'max_depth': trial.suggest_int('max_depth', 3, 10),
        'num_leaves': trial.suggest_int('num_leaves', 8, 256),
        'min_child_samples': trial.suggest_int('min_child_samples', 5, 100),
        'subsample': trial.suggest_float('subsample', 0.5, 1.0),
        'colsample_bytree': trial.suggest_float('colsample_bytree', 0.5, 1.0),
    }

    cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
    scores = []

    for train_idx, val_idx in cv.split(X_train, y_train):
        X_tr, X_val = X_train[train_idx], X_train[val_idx]
        y_tr, y_val = y_train[train_idx], y_train[val_idx]

        model = lgb.LGBMClassifier(**params)
        model.fit(
            X_tr, y_tr,
            eval_set=[(X_val, y_val)],
            callbacks=[
                lgb.early_stopping(50),          # early stopping
                LightGBMPruningCallback(trial, 'auc'),  # Optuna pruning
            ],
        )
        scores.append(model.score(X_val, y_val))

    return sum(scores) / len(scores)

study = optuna.create_study(
    direction='maximize',
    pruner=optuna.pruners.MedianPruner(n_startup_trials=5)
)
study.optimize(objective_lgbm, n_trials=50)

Optuna > Hyperopt > Grid/Random

В 2024+ нет причин не использовать Optuna. Бесплатный, open-source, встроенный pruning, визуализация (optuna.visualization), поддержка distributed trials. Hyperopt — предшественник, менее удобный API. Grid/Random — только для учебных задач или когда нужна полная воспроизводимость.

Cross-validation стратегии для подбора

Подбор гиперпараметров всегда делается через кросс-валидацию, но тип CV зависит от данных: StratifiedKFold для классификации (сохраняет пропорцию классов), GroupKFold когда объекты группируются (один пользователь не должен быть в train и val одновременно — иначе data leakage), TimeSeriesSplit для временных рядов (train — прошлое, val — будущее).

from sklearn.model_selection import GroupKFold
import numpy as np

# GroupKFold — критичен, когда данные группируются
# Пример: несколько транзакций от одного пользователя
user_ids = np.array([1, 1, 1, 2, 2, 3, 3, 3, 3, 4, 4, 5])

group_kfold = GroupKFold(n_splits=3)

for fold, (train_idx, val_idx) in enumerate(group_kfold.split(X, y, groups=user_ids)):
    train_users = set(user_ids[train_idx])
    val_users = set(user_ids[val_idx])
    # Гарантия: один пользователь ТОЛЬКО в train ИЛИ val
    assert train_users.isdisjoint(val_users)
    print(f"Fold {fold}: train users {train_users}, val users {val_users}")

Nested CV — честная оценка с подбором

Если подбираешь гиперпараметры через CV и оцениваешь качество через тот же CV — оценка оптимистична (information leakage). Nested CV: inner loop — подбор параметров (GridSearchCV внутри), outer loop — оценка качества. Обязателен для научных публикаций, рекомендуется для production.

Early Stopping и Learning Rate Schedule

Early stopping — лучший регуляризатор для бустинга и нейросетей. Идея: обучай модель с большим n_estimators (или epochs), но останови, когда метрика на валидации перестала улучшаться. Learning Rate Schedule: начинай с высокого lr (быстрое обучение), снижай по ходу (точная подстройка). Варианты: reduce on plateau, cosine annealing, warmup + linear decay.

import lightgbm as lgb

# Практический рецепт: LightGBM с early stopping
model = lgb.LGBMClassifier(
    n_estimators=2000,           # большое число — early stopping остановит раньше
    learning_rate=0.05,          # начальный lr
    max_depth=6,
    num_leaves=63,
    subsample=0.8,
    colsample_bytree=0.8,
    random_state=42,
    n_jobs=-1,
)

model.fit(
    X_train, y_train,
    eval_set=[(X_val, y_val)],
    callbacks=[
        lgb.early_stopping(stopping_rounds=50),   # стоп, если 50 итераций без улучшения
        lgb.log_evaluation(period=100),             # логировать каждые 100 итераций
    ],
)

print(f"Лучшая итерация: {model.best_iteration_}")
# Часто: n_estimators=2000, а остановка на ~350-600 — экономия 60-80% времени
  • Практический рецепт для бустинга: lr=0.05-0.1, n_estimators=1000-3000, early_stopping_rounds=50-100. Пусть early stopping сам найдёт оптимальное число деревьев.
  • Для нейросетей: lr=1e-3 (Adam), epochs=100+, patience=10-20, ReduceLROnPlateau(factor=0.5). Cosine annealing для Transformers.
  • lr и n_estimators связаны: маленький lr → нужно больше деревьев. lr=0.01 + 3000 деревьев ≈ lr=0.1 + 300 деревьев, но маленький lr обычно даёт чуть лучшее качество.

На собесе

Junior

Чем гиперпараметр отличается от параметра? Параметры модель выучивает из данных (веса, коэффициенты). Гиперпараметры задаются до обучения (learning_rate, max_depth) и контролируют процесс обучения. Что такое Grid Search? Полный перебор всех комбинаций значений из заданной сетки. Прост, но экспоненциально растёт. Зачем кросс-валидация при подборе гиперпараметров? Одна train/val выборка — ненадёжная оценка (зависит от конкретного разбиения). CV усредняет по K фолдам → стабильная оценка ± std.

Middle

Random vs Grid (аргумент Bergstra)? Grid тратит бюджет на перебор неважных параметров. Random сэмплирует каждое измерение независимо → 60 итераций покрывают 95% оптимума. Random ≥ Grid при тех же ресурсах. Optuna TPE: как выбирает следующую точку? TPE строит два распределения: l(x) = p(x|y<y*) и g(x) = p(x|y>=y*). Максимизирует l(x)/g(x) — выбирает точки, похожие на лучшие результаты, но не одинаковые. y* — квантиль лучших trials. Nested CV: зачем inner+outer? Inner loop подбирает параметры. Если оценивать качество в том же inner loop — оптимизм (параметры подогнаны под этот split). Outer loop даёт честную оценку: «если я подберу параметры на новых данных, какое качество ожидать?» Как выбрать диапазон поиска? Когда log-scale? Learning rate — всегда log-scale (1e-5 до 1e-1). Regularization (alpha, lambda) — log-scale. max_depth, n_estimators — uniform. Если не знаешь диапазон — начни широко, потом сузь.

Senior

Multi-fidelity optimization (Successive Halving, ASHA)? Идея: обучай все кандидаты на малом бюджете (10% данных, 10% epochs), оставляй лучшую половину, увеличь бюджет, повторяй. ASHA — асинхронная версия для distributed. Optuna поддерживает через HyperbandPruner. Hyperparameter transfer between datasets? Meta-learning: подбери параметры на похожих задачах, используй как warm start. Optuna warm start trials. Auto-sklearn использует мета-признаки датасета для инициализации. AutoML (auto-sklearn, FLAML)? Автоматический подбор: preprocessing + model selection + hyperparameter tuning в одном пайплайне. FLAML (Microsoft) — быстрый, cost-effective, хорош для baseline. Hyperparameter importance analysis (fANOVA)? Какие параметры реально влияют на качество? Optuna: optuna.importance.get_param_importances(). fANOVA разбивает variance на вклады отдельных параметров. Когда НЕ тюнить? Если можно добавить данных — лучше инвестировать в data, а не в тюнинг. Если разница между дефолтами и лучшими параметрами <0.5% — не стоит тратить compute. Если задача проста — LogisticRegression с C=1 может быть достаточно.