Files
PracticeClassif/_research/SECTION_optuna.md
2026-06-30 15:17:40 +03:00

40 KiB
Raw Permalink Blame History

title, section, tool, supervisor, status, tags
title section tool supervisor status tags
Optuna — подбор гиперпараметров: полный разбор HPO Optuna мнс Павленко Б.В. draft
hpo
optuna
hyperparameter-tuning
bayesian-optimization
pruning

Optuna — библиотека для подбора гиперпараметров

Источники: официальный сайт optuna.org, документация optuna.readthedocs.io, а также блог команды Optuna на Medium (релизы v4.5, v4.6 и AutoSampler).

Optuna — это open-source фреймворк для автоматической оптимизации гиперпараметров, не привязанный к конкретной ML-библиотеке (framework-agnostic): одинаково применим к PyTorch, TensorFlow/Keras, scikit-learn, XGBoost, LightGBM и др. Ключевые архитектурные принципы по optuna.org:

  • Eager / define-by-run search spaces — пространство поиска описывается обычным Python-кодом (условия, циклы) прямо внутри objective-функции, а не статической конфигурацией заранее.
  • State-of-the-art алгоритмы — эффективные сэмплеры (TPE, GP, CMA-ES) + прунинг бесперспективных trial-ов.
  • Простая параллелизация — распараллеливание по потокам/процессам/узлам без изменения кода objective.
  • Визуализация и Dashboard — встроенные графики истории, важности параметров, Pareto-фронта.

Установка (Python 3.9+):

pip install optuna           # ядро
pip install optuna-dashboard # веб-дашборд (опционально)
pip install optunahub        # доступ к OptunaHub (AutoSampler и др.)

1. Базовая модель: Study, Trial, define-by-run API

Три фундаментальных объекта:

Понятие Что это Роль
Study Сессия оптимизации Хранит все trial-ы, направление(я) оптимизации, sampler, pruner, storage
Trial Одна попытка (один набор гиперпараметров) Через него suggest_* запрашивает значения и report отдаёт промежуточные метрики
objective(trial) Пользовательская функция Возвращает метрику (или кортеж метрик), которую Study минимизирует/максимизирует

1.1 define-by-run: методы trial.suggest_*

Пространство поиска формируется в момент исполнения — каждый вызов suggest_* регистрирует гиперпараметр:

import optuna

def objective(trial: optuna.Trial) -> float:
    # вещественный, равномерно
    lr = trial.suggest_float("lr", 1e-5, 1e-1, log=True)   # log-uniform
    # вещественный с шагом
    dropout = trial.suggest_float("dropout", 0.0, 0.5, step=0.05)
    # целочисленный (можно log=True и step)
    n_layers = trial.suggest_int("n_layers", 1, 8)
    # категориальный
    optimizer = trial.suggest_categorical("optimizer", ["adam", "sgd", "adamw"])

    # define-by-run: пространство зависит от уже выбранных значений
    units = [trial.suggest_int(f"units_l{i}", 16, 256, log=True)
             for i in range(n_layers)]

    score = train_and_eval(lr, dropout, optimizer, units)  # ваша логика
    return score

Сводка методов suggest:

Метод Назначение Полезные аргументы
suggest_float(name, low, high, *, step, log) Вещественный параметр log=True (лог-равномерно, для lr/wd); step (дискретизация)
suggest_int(name, low, high, *, step, log) Целочисленный log=True, step
suggest_categorical(name, choices) Категориальный (строки/числа/bool) список вариантов

log=True критичен для масштабно-чувствительных параметров (learning rate, weight decay): диапазон [10^{-5}, 10^{-1}] при логарифмическом сэмплировании покрывается равномерно по порядкам величины. Нельзя одновременно задавать step и log=True.

1.2 Создание Study и запуск

study = optuna.create_study(
    direction="maximize",          # или "minimize"
    sampler=optuna.samplers.TPESampler(seed=42),
    pruner=optuna.pruners.MedianPruner(),
    storage="sqlite:///hpo.db",    # опц. — персистентность/параллелизм
    study_name="edgenext_hpo",
    load_if_exists=True,
)

study.optimize(
    objective,
    n_trials=100,        # сколько trial-ов выполнить
    timeout=3600,        # ИЛИ ограничение по времени (сек)
    n_jobs=1,            # параллелизм внутри процесса (потоки)
    gc_after_trial=True, # сборка мусора между trial (важно для GPU)
)

print(study.best_value, study.best_params, study.best_trial.number)
  • direction / directions"minimize" (loss, latency, params) или "maximize" (accuracy, R@1).
  • Остановка по n_trials, по timeout или по любому из них (что наступит раньше).
  • study.best_params, study.best_value, study.best_trial, study.trials (полный список), study.trials_dataframe() (экспорт в pandas).

2. Samplers (алгоритмы выбора следующего набора)

Sampler решает, какие значения гиперпараметров пробовать дальше. По optuna.readthedocs.io — samplers:

Sampler Алгоритм / назначение Сильные стороны Слабые стороны Когда применять
TPESampler (default) Tree-structured Parzen Estimator (Bayesian) Работает с категориями, условными пространствами; multivariate=True ловит зависимости между параметрами; group=True — групповая факторизация Слабее GP на гладких непрерывных задачах; жадноват Универсальный выбор, 1001000 trial; смешанные/условные пространства
GPSampler Gaussian Process Bayesian optimization Высокая sample-efficiency на непрерывных/целочисленных; поддержка constrained + multi-objective (с v4.4/v4.5); в v4.6 ускорен ~8× O(n^3) — дорог при больших n; хуже с категориями Дорогие objective (обучение сети), малый бюджет (≲500 trial)
BoTorchSampler (optuna-integration) BO поверх BoTorch/GPyTorch Гибкий BO, кастомные acquisition O(n^3), тяжёлые зависимости (torch) Исследовательский BO, малый бюджет 10100 trial
CmaEsSampler CMA-ES (эволюционная стратегия) Очень эффективен на непрерывных задачах большой размерности Не поддерживает multi-objective; плохо с категориями; не любит условные пространства Непрерывные пространства, бюджет 100010000 trial
NSGAIISampler NSGA-II (генетический, multi-objective) Pareto-фронт для 23 целей; масштабируется по числу trial; поддержка constraints_func Не sample-efficient (нужно много trial); хуже many-objective Multi-objective (23 цели), большой бюджет
NSGAIIISampler NSGA-III (reference-points) Для many-objective (≥4 целей) Нужно много trial; настройка reference points Multi-objective при ≥4 целях
QMCSampler Quasi-Monte Carlo (low-discrepancy: Sobol/Halton) Лучшее покрытие пространства, чем random; масштабируется неограниченно Не использует историю (не адаптивный) Стартовая разведка, baseline, генерация init-точек
RandomSampler Равномерный random search Простой, без допущений, эталон сравнения Неэффективен в больших пространствах Baseline, sanity-check, очень малая размерность
GridSampler Полный перебор заданной сетки Детерминированный, исчерпывающий Комбинаторный взрыв Малое дискретное пространство, нужна полнота
BruteForceSampler Define-by-run полный перебор Перебирает всё дерево вариантов автоматически Только для конечных малых пространств Перебор небольшого условного дискретного пространства
PartialFixedSampler Часть параметров зафиксирована, остальное — другим sampler Дообследование подпространства Вспомогательный, не самостоятельный Зафиксировать найденное и доискать остальное
AutoSampler (OptunaHub) Автоматический выбор из TPE/GP/NSGA-II/NSGA-III Не нужно выбирать sampler вручную; ≥ default по качеству Зависимость от optunahub; внутренний overhead анализа Когда не хочется выбирать вручную (см. §2.2)

2.1 Таблица поддержки возможностей (из официальной документации)

Легенда: ✓ — поддерживается, △ — работает, но неэффективно, ✗ — ошибка/нет интерфейса.

Возможность Auto Random TPE GP CMA-ES NSGA-II NSGA-III Grid QMC BoTorch BruteForce
Float
Integer
Categorical
Pruning ✗/△ ✗/△
Multivariate
Conditional space
Multi-objective
Batch optimization
Distributed
Constrained

Ориентировочная вычислительная сложность и бюджет (d — размерность, n — число trial, m — число целей, p — размер популяции):

Sampler Сложность Рекоменд. число trial
RandomSampler / QMCSampler O(d) / O(dn) не ограничено
TPESampler O(dn\log n) 1001000
GPSampler / BoTorchSampler O(n^3) ≲500 / 10100
CmaEsSampler O(d^3) 100010000
NSGA-II / NSGA-III O(mp^2) 10010000

2.2 AutoSampler (OptunaHub) — автоматический выбор алгоритма

Согласно статье AutoSampler: full support for multi-objective & constrained optimization, AutoSampler анализирует характеристики задачи (бюджет вычислений, тип/структуру пространства, число целей, наличие ограничений) и динамически переключается между четырьмя алгоритмами:

Внутренний sampler Когда AutoSampler его выбирает
TPESampler сложные пространства с категориями и условными ветвлениями
GPSampler непрерывные/целочисленные пространства; теперь с полной поддержкой multi-objective и constraints
NSGAIISampler multi-objective при большом числе trial
NSGAIIISampler many-objective (много целей)

Гарантия: AutoSampler выбирает алгоритм, который «эмпирически даёт результат не хуже default». Поддержка multi-objective + constrained стала возможна благодаря наращиванию возможностей GPSampler в версиях 4.24.5 и финализирована в v4.6. Бенчмарки: превосходство на WFG1 (multi-objective) и на rotated Rastrigin (constrained).

import optuna, optunahub

def objective(trial):
    x = trial.suggest_float("x", 0, 5)
    y = trial.suggest_float("y", 0, 3)
    return 4 * x**2 + 4 * y**2, (x - 5)**2 + (y - 5)**2  # 2 цели

module = optunahub.load_module("samplers/auto_sampler")  # подгрузка с OptunaHub
study = optuna.create_study(
    directions=["minimize", "minimize"],
    sampler=module.AutoSampler(),
)
study.optimize(objective, n_trials=300)

Установка: pip install -U optuna optunahub + pip install -r https://hub.optuna.org/samplers/auto_sampler/requirements.txt. Принудительное обновление кэша: optunahub.load_module("samplers/auto_sampler", force_reload=True).


3. Pruners (ранняя остановка бесперспективных trial-ов)

Pruner отслеживает промежуточные метрики (например, val-accuracy по эпохам) и досрочно прерывает trial, который заведомо хуже остальных, экономя GPU-время. По optuna.readthedocs.io — pruners.

Важное ограничение: модуль pruners рассчитан только на single-objective оптимизацию.

Pruner Принцип Ключевые параметры Когда использовать
MedianPruner (default) Median stopping rule: режет trial, если его промежуточное значение хуже медианы предыдущих на этом шаге n_startup_trials, n_warmup_steps, interval_steps Универсальный, простой и надёжный baseline
PercentilePruner Обобщение медианного: режет хуже заданного перцентиля percentile, n_startup_trials, n_warmup_steps Когда нужен более/менее агрессивный порог, чем медиана
SuccessiveHalvingPruner ASHA (Asynchronous Successive Halving): ресурсы удваиваются у «выживших» min_resource, reduction_factor, min_early_stopping_rate Большой бюджет, параллельный/распределённый поиск
HyperbandPruner Несколько «скобок» (brackets) SuccessiveHalving с разным балансом explore/exploit min_resource, max_resource, reduction_factor Когда неизвестен оптимальный бюджет на trial — часто лучший выбор
PatientPruner Обёртка над другим pruner: добавляет «терпение» (patience), не режет при временной просадке wrapped_pruner, patience, min_delta Шумные кривые обучения, чтобы не зарезать поздно-сходящиеся
ThresholdPruner Режет, если метрика вышла за абсолютный порог (детекция выбросов/расходимости) lower, upper, n_warmup_steps Отсев расходящихся trial (NaN, взрыв loss)
WilcoxonPruner Wilcoxon signed-rank test: статистически сравнивает trial с лучшим по набору инстансов p_threshold, n_startup_steps Когда objective усредняется по многим примерам/фолдам
NopPruner Никогда не режет (no-op) Отключить прунинг, эталон сравнения

3.1 Встраивание в цикл обучения: report() + should_prune()

Внутри objective на каждой эпохе сообщаем промежуточную метрику через trial.report(value, step) и проверяем trial.should_prune():

import optuna

def objective(trial):
    model, loader_tr, loader_val = build(trial)
    for epoch in range(N_EPOCHS):
        train_one_epoch(model, loader_tr)
        acc = evaluate(model, loader_val)

        trial.report(acc, step=epoch)        # отдать промежуточную метрику
        if trial.should_prune():             # спросить pruner
            raise optuna.TrialPruned()       # прервать trial
    return acc

Логика: report() сохраняет (step, value) в storage; should_prune() вызывает pruner.prune(study, trial), который сравнивает кривую текущего trial с историей. Прерывание выполняется через исключение optuna.TrialPruned — Optuna помечает trial как PRUNED, а не FAIL.

Для согласованности шаги step должны совпадать между trial-ами (например, номер эпохи), иначе медиана/перцентиль считаются по разным точкам.


4. Multi-objective и constrained optimization

4.1 Несколько целей

Задаётся через directions (список), objective возвращает кортеж:

def objective(trial):
    model = build(trial)
    acc = evaluate(model)                 # хотим максимизировать
    n_params = count_params(model) / 1e6  # хотим минимизировать (млн)
    return acc, n_params

study = optuna.create_study(
    directions=["maximize", "minimize"],  # accuracy ↑, params ↓
    sampler=optuna.samplers.NSGAIISampler(),
)
study.optimize(objective, n_trials=200)

best_trials = study.best_trials   # МНОЖЕСТВО Парето-оптимальных, не один best

При нескольких целях единственного «лучшего» нет — есть Pareto-фронт: множество недоминируемых решений. Точка a доминирует b, если не хуже по всем целям и строго лучше хотя бы по одной. study.best_trials возвращает весь фронт; выбор финальной точки — компромисс инженера (например, max accuracy при params ≤ заданного бюджета).

4.2 Ограничения (constraints)

Поддерживается в TPESampler, NSGAII/NSGAIIISampler, GPSampler, BoTorchSampler (см. таблицу §2.1). Паттерн по Optuna FAQ: ограничения формулируются так, что допустимое решение ⇔ значение ≤ 0, сохраняются в user_attrs, а constraints_func их извлекает.

def objective(trial):
    x = trial.suggest_float("x", 0, 10)
    y = trial.suggest_float("y", 0, 10)
    # ограничения: c_i <= 0 означает "выполнено"
    c0 = (x - 5)**2 + y**2 - 25
    c1 = -(x - 8)**2 - (y + 3)**2 + 7.7
    trial.set_user_attr("constraint", (c0, c1))   # сохранить
    return 4*x**2 + y**2, (x - 5)**2 + (y - 5)**2  # 2 цели

def constraints(trial):
    return trial.user_attrs["constraint"]          # извлечь для sampler

study = optuna.create_study(
    directions=["minimize", "minimize"],
    sampler=optuna.samplers.NSGAIISampler(constraints_func=constraints),
)
study.optimize(objective, n_trials=300)

Sampler штрафует/отодвигает недопустимые области (где c_i > 0), концентрируя бюджет в feasible-зоне. В v4.5 GPSampler получил constrained multi-objective на базе log-EHVI (Expected Hypervolume Improvement), с моделированием и целей, и ограничений отдельными GP (см. §5).


5. Что нового в Optuna v4.5 и v4.6

v4.5 (по блогу Optuna)

Фича Суть
Constrained multi-objective в GPSampler GPSampler теперь умеет одновременно несколько целей + inequality-ограничения
Acquisition = log-EHVI Логарифм Expected Hypervolume Improvement как функция приобретения
GP для целей и для ограничений Отдельные гауссовские процессы; feasibility оценивается через Probability of Improvement (PI)
Box decomposition Ускорение вычислений до 6800×; практично до 4 целей
Эффективность Меньше «впустую» оценок в infeasible-областях; быстрее сходимости, чем TPE/NSGA-II на бенчмарках (целевой уровень за <50 trial)

v4.6 (по блогу Optuna)

Фича Суть
LLM-интеграция в Dashboard Фильтрация trial на естественном языке + автогенерация графиков (Plotly.js)
GPSampler ускорен ~8× Кэширование, параллелизация, оптимизация внутренних операций при сохранении качества
AutoSampler: full multi-objective + constrained Автоматический выбор среди GPSampler / NSGAIIISampler и др. по характеру задачи (см. §2.2)
Robust BO: CARBO и Value-at-Risk (VaR) Устойчивая байесовская оптимизация при шуме параметров (реальные приложения)
OpenTelemetry-мониторинг Туториал по экспорту метрик Dashboard в Prometheus/Grafana
LaTeX в OptunaHub Рендер математики (GitHub-совместимый синтаксис)
MCP Server: Structured Output Валидация и schema-based обработка результатов

Breaking-изменений API, формата storage и pruner-ов в v4.6 не заявлено.


6. OptunaHub, storage, распределённый поиск, warm-start

6.1 OptunaHub

Платформа обмена компонентами (samplers, pruners, visualization) — например, AutoSampler ставится не из ядра, а через optunahub.load_module("samplers/auto_sampler"). Позволяет публиковать и подключать сторонние алгоритмы без раздувания ядра Optuna.

6.2 Storage (персистентность и параллелизм)

Backend Описание Когда
InMemoryStorage По умолчанию, в ОЗУ Один процесс, не нужна персистентность
RDBStorage SQL-БД (SQLite / PostgreSQL / MySQL) Персистентность, дашборд, многопроцессный/многоузловой поиск
JournalStorage Журнальный файл (file / Redis backend) Распределённость без полноценной СУБД; устойчивость к гонкам записи
# RDB (sqlite — для одной машины; postgres/mysql — для кластера)
study = optuna.create_study(
    storage="sqlite:///hpo.db",
    study_name="edgenext_hpo",
    load_if_exists=True,   # докинуть trial к существующему study
)

# JournalStorage (рекомендован для распределённого/NFS)
from optuna.storages import JournalStorage
from optuna.storages.journal import JournalFileBackend
storage = JournalStorage(JournalFileBackend("./hpo_journal.log"))

6.3 Распределённый / параллельный поиск

  • Внутри процесса: study.optimize(objective, n_jobs=-1) — потоки (полезно, если objective отпускает GIL / I/O-bound).
  • Между процессами/узлами: запустить тот же скрипт N раз с общим storage (RDB/Journal) и одинаковым study_name — воркеры сами координируются через БД. CLI-вариант: optuna study optimize ... --n-jobs ....

6.4 Warm-start: enqueue_trial / add_trial

Метод Назначение
study.enqueue_trial(params) Поставить конкретный набор параметров в очередь на следующий запуск (например, известный хороший конфиг, baseline)
study.add_trial(trial) Добавить уже завершённый trial с известным результатом (перенос истории, тёплый старт sampler)
# заранее проверить «ручной» хороший конфиг
study.enqueue_trial({"lr": 3e-4, "dropout": 0.1, "optimizer": "adamw"})
study.optimize(objective, n_trials=100)

# перенести результат внешнего эксперимента в историю
from optuna.trial import create_trial
from optuna.distributions import FloatDistribution
study.add_trial(create_trial(
    params={"lr": 1e-3},
    distributions={"lr": FloatDistribution(1e-5, 1e-1, log=True)},
    value=0.81,
))

7. Визуализация

По optuna.readthedocs.io — visualization. Два backend: optuna.visualization (Plotly, интерактивно, default) и optuna.visualization.matplotlib (статично, для статей/отчётов).

Функция Что показывает Применение
plot_optimization_history Лучшее значение по ходу trial Скорость сходимости, плато
plot_param_importances Важность гиперпараметров Какие параметры реально влияют (см. ниже)
plot_slice Срез: параметр vs objective Влияние одного параметра, перспективные диапазоны
plot_contour 2D-контур пары параметров Взаимодействие двух параметров
plot_parallel_coordinate Параллельные координаты Многомерные паттерны хороших trial
plot_pareto_front Pareto-фронт Компромиссы в multi-objective
plot_intermediate_values Промежуточные кривые (по эпохам) Диагностика прунинга
plot_edf Эмпирическая функция распределения Сравнение study между собой
plot_rank Ранги trial по параметрам Локализация хороших зон
plot_timeline Временная диаграмма исполнения Параллелизм, длительность trial
plot_hypervolume_history Рост hypervolume Прогресс multi-objective
plot_terminator_improvement Оценка критерия остановки Когда прекращать study

Важность параметров (plot_param_importances, требует scikit-learn) считается оценщиками:

  • fANOVA (FanovaImportanceEvaluator, default) — функциональный дисперсионный анализ через random forest: доля дисперсии objective, объясняемая параметром/взаимодействием.
  • PED-ANOVA (PedAnovaImportanceEvaluator) — быстрый оценщик, фокусируется на важности в хорошей области пространства (по перцентилю); полезен для больших историй.
from optuna.visualization import plot_optimization_history, plot_param_importances
plot_optimization_history(study).show()
plot_param_importances(study).show()
# с PED-ANOVA:
from optuna.importance import PedAnovaImportanceEvaluator
plot_param_importances(study, evaluator=PedAnovaImportanceEvaluator()).show()

8. Интеграция с PyTorch

Базовый objective с прунингом по эпохам:

import optuna, torch, torch.nn as nn

def objective(trial):
    # define-by-run пространство
    lr      = trial.suggest_float("lr", 1e-5, 1e-1, log=True)
    wd      = trial.suggest_float("wd", 1e-6, 1e-2, log=True)
    dropout = trial.suggest_float("dropout", 0.0, 0.5, step=0.1)
    n_units = trial.suggest_int("n_units", 64, 512, log=True)

    model = build_model(n_units, dropout).cuda()
    opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=wd)

    for epoch in range(EPOCHS):
        train_one_epoch(model, train_loader, opt)
        val_acc = evaluate(model, val_loader)

        trial.report(val_acc, step=epoch)   # промежуточная метрика
        if trial.should_prune():            # запрос pruner
            raise optuna.TrialPruned()
    return val_acc

study = optuna.create_study(
    direction="maximize",
    sampler=optuna.samplers.TPESampler(multivariate=True),
    pruner=optuna.pruners.HyperbandPruner(min_resource=1, max_resource=EPOCHS),
)
study.optimize(objective, n_trials=100, gc_after_trial=True)

8.1 Callbacks и интеграции

  • Callbacks: study.optimize(objective, callbacks=[cb]), где cb(study, trial) вызывается после каждого trial (логирование, ранний стоп всего study, чекпойнты). Готовый MaxTrialsCallback ограничивает число завершённых trial.
  • Готовые интеграции (пакет optuna-integration): PyTorchLightningPruningCallback, TorchDistributedTrial, callbacks для Keras/XGBoost/LightGBM/MLflow/W&B.

8.2 TorchDistributedTrial (DDP)

При обучении в torch.distributed (DDP) гиперпараметры и решение о прунинге должны быть согласованы между всеми ранками. TorchDistributedTrial оборачивает trial: реальные suggest_*/report/should_prune исполняет rank 0 и broadcast-ит результат остальным процессам.

import optuna
from optuna.integration import TorchDistributedTrial
import torch.distributed as dist

def objective(single_trial):
    # rank0 владеет trial; остальные получают None
    trial = TorchDistributedTrial(single_trial if dist.get_rank() == 0 else None)
    lr = trial.suggest_float("lr", 1e-5, 1e-1, log=True)  # одинаково на всех ранках
    for epoch in range(EPOCHS):
        train_ddp_epoch(lr, epoch)
        acc = evaluate_ddp()
        trial.report(acc, epoch)
        if trial.should_prune():           # согласованное решение
            raise optuna.TrialPruned()
    return acc

Запускают одно study только на rank 0 (if dist.get_rank() == 0: study.optimize(...)), либо общий storage + барьеры — детали в optuna-integration.


9. Практический полный пример: image-classification + pruning (skeleton)

import optuna
from optuna.trial import TrialState
import torch, torch.nn as nn, torch.nn.functional as F
from torchvision import datasets, transforms

DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
EPOCHS, N_TRIALS = 15, 100

def get_loaders(batch_size):
    tf = transforms.Compose([transforms.ToTensor(),
                             transforms.Normalize((0.1307,), (0.3081,))])
    tr = datasets.MNIST("./data", train=True,  download=True, transform=tf)
    va = datasets.MNIST("./data", train=False, download=True, transform=tf)
    return (torch.utils.data.DataLoader(tr, batch_size=batch_size, shuffle=True),
            torch.utils.data.DataLoader(va, batch_size=256))

def build_model(trial):
    n_layers = trial.suggest_int("n_layers", 1, 3)
    layers, in_f = [], 28 * 28
    for i in range(n_layers):
        out_f = trial.suggest_int(f"units_l{i}", 64, 512, log=True)
        p     = trial.suggest_float(f"dropout_l{i}", 0.0, 0.5, step=0.1)
        layers += [nn.Linear(in_f, out_f), nn.ReLU(), nn.Dropout(p)]
        in_f = out_f
    layers.append(nn.Linear(in_f, 10))
    return nn.Sequential(nn.Flatten(), *layers)

def objective(trial):
    lr  = trial.suggest_float("lr", 1e-5, 1e-1, log=True)
    bs  = trial.suggest_categorical("batch_size", [32, 64, 128])
    opt_name = trial.suggest_categorical("optimizer", ["Adam", "SGD", "AdamW"])

    model = build_model(trial).to(DEVICE)
    optimizer = getattr(torch.optim, opt_name)(model.parameters(), lr=lr)
    train_loader, val_loader = get_loaders(bs)

    for epoch in range(EPOCHS):
        model.train()
        for x, y in train_loader:
            x, y = x.to(DEVICE), y.to(DEVICE)
            optimizer.zero_grad()
            F.cross_entropy(model(x), y).backward()
            optimizer.step()

        model.eval(); correct = 0
        with torch.no_grad():
            for x, y in val_loader:
                x, y = x.to(DEVICE), y.to(DEVICE)
                correct += (model(x).argmax(1) == y).sum().item()
        acc = correct / len(val_loader.dataset)

        trial.report(acc, epoch)            # отдать метрику pruner-у
        if trial.should_prune():            # ранняя остановка
            raise optuna.TrialPruned()
    return acc

if __name__ == "__main__":
    study = optuna.create_study(
        direction="maximize",
        sampler=optuna.samplers.TPESampler(multivariate=True, seed=42),
        pruner=optuna.pruners.MedianPruner(n_startup_trials=5, n_warmup_steps=3),
        storage="sqlite:///mnist_hpo.db",
        study_name="mnist", load_if_exists=True,
    )
    study.optimize(objective, n_trials=N_TRIALS, timeout=3600, gc_after_trial=True)

    pruned   = study.get_trials(deepcopy=False, states=[TrialState.PRUNED])
    complete = study.get_trials(deepcopy=False, states=[TrialState.COMPLETE])
    print(f"pruned={len(pruned)}  complete={len(complete)}")
    print("best acc:", study.best_value, "| params:", study.best_params)

10. Шпаргалка: «когда какой sampler + pruner»

Сценарий Sampler Pruner Обоснование
Универсальный старт (DL, смешанное пространство) TPESampler (multivariate=True) MedianPruner Надёжный default, ловит зависимости параметров
Дорогой objective, малый бюджет (≲500), непрерывные параметры GPSampler PatientPruner / без прунинга Максимальная sample-efficiency BO
Большой бюджет (>1000), непрерывное пространство высокой размерности CmaEsSampler SuccessiveHalvingPruner (ASHA) Эффективная эволюционная стратегия + дешёвый прунинг
Не знаю бюджет на trial, длинное обучение TPESampler HyperbandPruner Несколько brackets закрывают разный explore/exploit
Multi-objective: accuracy ↑ vs params/latency ↓ (23 цели) NSGAIISampler (+constraints_func) — (multi-obj не прунится) Строит Pareto-фронт; поддержка ограничений
Many-objective (≥4 целей) NSGAIIISampler Reference-points масштабируют many-objective
Шумные кривые обучения (поздняя сходимость) TPESampler / GPSampler PatientPruner (обёртка) Не режет временные просадки
Отсев расходящихся trial (NaN, взрыв loss) любой ThresholdPruner Жёсткий абсолютный порог
Objective усредняется по многим примерам/фолдам TPESampler WilcoxonPruner Статистически корректное сравнение с лучшим
Малое дискретное пространство, нужна полнота GridSampler / BruteForceSampler MedianPruner Исчерпывающий перебор
Стартовая разведка / baseline QMC / RandomSampler NopPruner Равномерное покрытие, эталон
Не хочу выбирать вручную, есть optunahub AutoSampler — (multi-obj) / MedianPruner (single) Автовыбор TPE/GP/NSGA-II/III под задачу
Распределённый кластер, много воркеров TPESampler / RandomSampler + RDB/Journal storage SuccessiveHalvingPruner (ASHA) Асинхронность дружит с общим storage

Практический дефолт для одной цели в edge-CV задачах (как EdgeNeXt-style сети): TPESampler(multivariate=True) + HyperbandPruner, storage = SQLite, gc_after_trial=True. Для совместной оптимизации accuracy и числа параметров/latency — NSGAIISampler + constraints_func и анализ plot_pareto_front.