Оптимизация с Optuna: Pruning, Pareto многокритериальный поиск, ранняя остановка и визуальный анализ
'Практическое руководство по построению продвинутого пайплайна Optuna: прунинг, Pareto-оптимизация, кастомная ранняя остановка и визуальный анализ результатов.'
Кратко о подходе
Этот материал показывает полный рабочий процесс Optuna с применением прунинга, многокритериальной оптимизации, пользовательских колбэков для ранней остановки и визуализации для глубокого анализа испытаний. Каждый блок содержит исполняемые сниппеты, чтобы продемонстрировать, как Optuna ускоряет эксперименты и помогает принимать обоснованные решения.
Прунинг на Gradient Boosting
Начнем с поиска гиперпараметров для GradientBoostingClassifier, используя MedianPruner, чтобы отсекать неудачные испытания на ранних стадиях. В objective мы репортим промежуточные значения по фолдам, чтобы прунер мог принять решение.
import optuna
from optuna.pruners import MedianPruner
from optuna.samplers import TPESampler
import numpy as np
from sklearn.datasets import load_breast_cancer, load_diabetes
from sklearn.model_selection import cross_val_score, KFold
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
import matplotlib.pyplot as plt
def objective_with_pruning(trial):
X, y = load_breast_cancer(return_X_y=True)
params = {
'n_estimators': trial.suggest_int('n_estimators', 50, 200),
'min_samples_split': trial.suggest_int('min_samples_split', 2, 20),
'min_samples_leaf': trial.suggest_int('min_samples_leaf', 1, 10),
'subsample': trial.suggest_float('subsample', 0.6, 1.0),
'max_features': trial.suggest_categorical('max_features', ['sqrt', 'log2', None]),
}
model = GradientBoostingClassifier(**params, random_state=42)
kf = KFold(n_splits=3, shuffle=True, random_state=42)
scores = []
for fold, (train_idx, val_idx) in enumerate(kf.split(X)):
X_train, X_val = X[train_idx], X[val_idx]
y_train, y_val = y[train_idx], y[val_idx]
model.fit(X_train, y_train)
score = model.score(X_val, y_val)
scores.append(score)
trial.report(np.mean(scores), fold)
if trial.should_prune():
raise optuna.TrialPruned()
return np.mean(scores)
study1 = optuna.create_study(
direction='maximize',
sampler=TPESampler(seed=42),
pruner=MedianPruner(n_startup_trials=5, n_warmup_steps=1)
)
study1.optimize(objective_with_pruning, n_trials=30, show_progress_bar=True)
print(study1.best_value, study1.best_params)Такая конфигурация помогает быстро отсеивать слабые конфигурации и концентрировать вычисления на перспективных областях пространства гиперпараметров.
Многокритериальная оптимизация и фронт Парето
Далее мы оптимизируем одновременно точность классификации и метрику сложности модели. Optuna формирует фронт Парето, позволяющий сравнивать компромиссы между метриками.
def multi_objective(trial):
X, y = load_breast_cancer(return_X_y=True)
n_estimators = trial.suggest_int('n_estimators', 10, 200)
max_depth = trial.suggest_int('max_depth', 2, 20)
min_samples_split = trial.suggest_int('min_samples_split', 2, 20)
model = RandomForestClassifier(
n_estimators=n_estimators,
max_depth=max_depth,
min_samples_split=min_samples_split,
random_state=42,
n_jobs=-1
)
accuracy = cross_val_score(model, X, y, cv=3, scoring='accuracy', n_jobs=-1).mean()
complexity = n_estimators * max_depth
return accuracy, complexity
study2 = optuna.create_study(
directions=['maximize', 'minimize'],
sampler=TPESampler(seed=42)
)
study2.optimize(multi_objective, n_trials=50, show_progress_bar=True)
for t in study2.best_trials[:3]:
print(t.number, t.values)Многокритериальный подход дает возможность выбирать модели с приемлемым соотношением точности и простоты.
Пользовательский колбэк для ранней остановки (регрессия)
Можно реализовать колбэки для управления поведением study. Пример ниже демонстрирует EarlyStoppingCallback, который останавливает исследование, если мониторимая метрика не улучшается в течение заданного числа завершенных испытаний.
class EarlyStoppingCallback:
def __init__(self, early_stopping_rounds=10, direction='maximize'):
self.early_stopping_rounds = early_stopping_rounds
self.direction = direction
self.best_value = float('-inf') if direction == 'maximize' else float('inf')
self.counter = 0
def __call__(self, study, trial):
if trial.state != optuna.trial.TrialState.COMPLETE:
return
v = trial.value
if self.direction == 'maximize':
if v > self.best_value:
self.best_value, self.counter = v, 0
else:
self.counter += 1
else:
if v < self.best_value:
self.best_value, self.counter = v, 0
else:
self.counter += 1
if self.counter >= self.early_stopping_rounds:
study.stop()
def objective_regression(trial):
X, y = load_diabetes(return_X_y=True)
alpha = trial.suggest_float('alpha', 1e-3, 10.0, log=True)
max_iter = trial.suggest_int('max_iter', 100, 2000)
from sklearn.linear_model import Ridge
model = Ridge(alpha=alpha, max_iter=max_iter, random_state=42)
score = cross_val_score(model, X, y, cv=5, scoring='neg_mean_squared_error', n_jobs=-1).mean()
return -score
early_stopping = EarlyStoppingCallback(early_stopping_rounds=15, direction='minimize')
study3 = optuna.create_study(direction='minimize', sampler=TPESampler(seed=42))
study3.optimize(objective_regression, n_trials=100, callbacks=[early_stopping], show_progress_bar=True)
print(study3.best_value, study3.best_params)Такой колбэк помогает экономить ресурсы, автоматически завершая поиск при стагнации результатов.
Визуализация и анализ
Создавайте графики истории исследований, важности параметров, фронта Парето и зависимостей параметр-метрика, чтобы понять, откуда приходят улучшения.
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
ax = axes[0, 0]
values = [t.value for t in study1.trials if t.value is not None]
ax.plot(values, marker='o', markersize=3)
ax.axhline(y=study1.best_value, color='r', linestyle='--')
ax.set_title('Study 1 History')
ax = axes[0, 1]
importance = optuna.importance.get_param_importances(study1)
params = list(importance.keys())[:5]
vals = [importance[p] for p in params]
ax.barh(params, vals)
ax.set_title('Param Importance')
ax = axes[1, 0]
for t in study2.trials:
if t.values:
ax.scatter(t.values[0], t.values[1], alpha=0.3)
for t in study2.best_trials:
ax.scatter(t.values[0], t.values[1], c='red', s=90)
ax.set_title('Pareto Front')
ax = axes[1, 1]
pairs = [(t.params.get('max_depth', 0), t.value) for t in study1.trials if t.value]
Xv, Yv = zip(*pairs) if pairs else ([], [])
ax.scatter(Xv, Yv, alpha=0.6)
ax.set_title('max_depth vs Accuracy')
plt.tight_layout()
plt.savefig('optuna_analysis.png', dpi=150)
plt.show()Сводка результатов и дальнейшие шаги
Подсчитайте ключевые метрики: частоту прунинга, число решений на фронте Парето и лучшее значение MSE в регрессии. Эти данные помогут решить, расширять ли поиск, менять сэмплер или уточнять пространство гиперпараметров для продакшен-режима.
p1 = len([t for t in study1.trials if t.state == optuna.trial.TrialState.PRUNED])
print("Study 1 Best Accuracy:", study1.best_value)
print("Study 1 Pruned %:", p1 / len(study1.trials) * 100)
print("Study 2 Pareto Solutions:", len(study2.best_trials))
print("Study 3 Best MSE:", study3.best_value)
print("Study 3 Trials:", len(study3.trials))Рассмотрите интеграцию с глубокими моделями, расширение датасетов и адаптацию логики колбэков для кастомных циклов обучения.
Switch Language
Read this article in English