Подбор гиперпараметров
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. Разберём каждый подход.

Что такое гиперпараметры и почему их нельзя выучить
Параметры — то, что модель выучивает из данных: веса нейросети, коэффициенты регрессии, разбиения деревьев. Гиперпараметры — то, что задаётся до обучения: 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
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 — честная оценка с подбором
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
Middle
Senior
Материалы
Официальный tutorial Optuna: от базового использования до distributed optimization.
Классическая статья, доказывающая превосходство Random Search над Grid Search.
GridSearchCV, RandomizedSearchCV, HalvingGridSearchCV — всё в одном месте.
Практическое руководство по Optuna с примерами для XGBoost и LightGBM.