.. _sec_api_hpo: API оптимизации гиперпараметров =============================== Прежде чем перейти к методологии, обсудим базовую структуру кода, позволяющую эффективно реализовывать различные алгоритмы оптимизации гиперпараметров (HPO). В общем случае все рассматриваемые здесь алгоритмы HPO должны реализовывать две примитивные операции принятия решений: *поиск* и *планирование*. Во-первых, им необходимо выбирать новые конфигурации гиперпараметров, что часто включает определённый вид поиска по пространству конфигураций. Во-вторых, для каждой конфигурации алгоритм HPO должен планировать её оценку и решать, сколько ресурсов выделить. Как только мы начинаем оценивать конфигурацию, мы будем называть её *испытанием*. Мы отображаем эти решения на два класса, ``HPOSearcher`` и ``HPOScheduler``. Кроме того, мы предоставляем класс ``HPOTuner``, который выполняет процесс оптимизации. Эта концепция планировщика и поисковика также реализована в популярных библиотеках оптимизации гиперпараметров, таких как Syne Tune :cite:`salinas-automl22`, Ray Tune :cite:`liaw-arxiv18` или Optuna :cite:`akiba-sigkdd19`. .. raw:: latex \diilbookstyleinputcell .. code:: python import time from scipy import stats from d2l import torch as d2l Поисковик --------- Ниже мы определяем базовый класс для поисковых алгоритмов, который предоставляет новую конфигурацию через функцию ``sample_configuration``. Простой способ реализации этой функции заключается в равномерной случайной выборке конфигураций, как мы делали это для случайного поиска в :numref:`sec_what_is_hpo`. Более сложные алгоритмы, такие как байесовская оптимизация, принимают эти решения на основе результатов предыдущих испытаний. В результате такие алгоритмы способны со временем выбирать более перспективные кандидаты. Мы добавляем функцию ``update`` для обновления истории предыдущих испытаний, которую затем можно использовать для улучшения нашего распределения выборки. .. raw:: latex \diilbookstyleinputcell .. code:: python class HPOSearcher(d2l.HyperParameters): #@save def sample_configuration() -> dict: raise NotImplementedError def update(self, config: dict, error: float, additional_info=None): pass В приведённом ниже коде показано, как реализовать наш оптимизатор случайного поиска из предыдущего раздела с использованием данного API. В качестве небольшого расширения мы позволяем пользователю задать первую конфигурацию для оценки через ``initial_config``, тогда как последующие выбираются случайным образом. .. raw:: latex \diilbookstyleinputcell .. code:: python class RandomSearcher(HPOSearcher): #@save def __init__(self, config_space: dict, initial_config=None): self.save_hyperparameters() def sample_configuration(self) -> dict: if self.initial_config is not None: result = self.initial_config self.initial_config = None else: result = { name: domain.rvs() for name, domain in self.config_space.items() } return result Планировщик ----------- Помимо выбора конфигураций для новых экспериментов, нам также необходимо решать, когда и как долго запускать каждый эксперимент. На практике все эти решения принимает ``HPOScheduler``, который делегирует выбор новых конфигураций ``HPOSearcher``. Метод ``suggest`` вызывается всякий раз, когда становится доступным ресурс для обучения. Помимо вызова метода ``sample_configuration`` поискового алгоритма, он также может принимать решения относительно таких параметров, как ``max_epochs`` (то есть, как долго обучать модель). Метод ``update`` вызывается всякий раз, когда эксперимент возвращает новое наблюдение. .. raw:: latex \diilbookstyleinputcell .. code:: python class HPOScheduler(d2l.HyperParameters): #@save def suggest(self) -> dict: raise NotImplementedError def update(self, config: dict, error: float, info=None): raise NotImplementedError Для реализации случайного поиска, а также других алгоритмов оптимизации гиперпараметров (HPO), достаточно базового планировщика, который назначает новую конфигурацию каждый раз, когда становятся доступны новые ресурсы. .. raw:: latex \diilbookstyleinputcell .. code:: python class BasicScheduler(HPOScheduler): #@save def __init__(self, searcher: HPOSearcher): self.save_hyperparameters() def suggest(self) -> dict: return self.searcher.sample_configuration() def update(self, config: dict, error: float, info=None): self.searcher.update(config, error, additional_info=info) Тюнер ----- Наконец, нам нужен компонент, который запускает планировщик/поисковик и ведёт учёт результатов. Приведённый ниже код реализует последовательное выполнение испытаний HPO, оценивая одну задачу обучения за другой, и будет служить базовым примером. Позже мы будем использовать *Syne Tune* для более масштабируемых распределённых случаев HPO. .. raw:: latex \diilbookstyleinputcell .. code:: python class HPOTuner(d2l.HyperParameters): #@save def __init__(self, scheduler: HPOScheduler, objective: callable): self.save_hyperparameters() # Bookeeping results for plotting self.incumbent = None self.incumbent_error = None self.incumbent_trajectory = [] self.cumulative_runtime = [] self.current_runtime = 0 self.records = [] def run(self, number_of_trials): for i in range(number_of_trials): start_time = time.time() config = self.scheduler.suggest() print(f"Trial {i}: config = {config}") error = self.objective(**config) error = float(error.cpu().detach().numpy()) self.scheduler.update(config, error) runtime = time.time() - start_time self.bookkeeping(config, error, runtime) print(f" error = {error}, runtime = {runtime}") Учёт производительности алгоритмов HPO -------------------------------------- При использовании любого алгоритма HPO нас в первую очередь интересует конфигурация с наилучшей производительностью (называемая *инкумбентом*) и её ошибка валидации по прошествии заданного времени. Именно поэтому мы отслеживаем ``runtime`` на каждой итерации, что включает как время выполнения оценки (вызов ``objective``), так и время принятия решения (вызов ``scheduler.suggest``). Далее мы будем строить график зависимости ``cumulative_runtime`` от ``incumbent_trajectory``, чтобы визуализировать *производительность в любой момент времени* алгоритма HPO, определяемую через ``scheduler`` (и ``searcher``). Это позволяет нам количественно оценить не только то, насколько хорошо работает конфигурация, найденная оптимизатором, но и то, как быстро оптимизатор способен её найти. .. raw:: latex \diilbookstyleinputcell .. code:: python @d2l.add_to_class(HPOTuner) #@save def bookkeeping(self, config: dict, error: float, runtime: float): self.records.append({"config": config, "error": error, "runtime": runtime}) # Check if the last hyperparameter configuration performs better # than the incumbent if self.incumbent is None or self.incumbent_error > error: self.incumbent = config self.incumbent_error = error # Add current best observed performance to the optimization trajectory self.incumbent_trajectory.append(self.incumbent_error) # Update runtime self.current_runtime += runtime self.cumulative_runtime.append(self.current_runtime) Пример: Оптимизация гиперпараметров свёрточной нейронной сети ------------------------------------------------------------- Теперь мы используем нашу новую реализацию случайного поиска для оптимизации *размера батча* и *скорости обучения* ``LeNet`` свёрточной нейронной сети из :numref:`sec_lenet`. Мы начинаем с определения целевой функции, которой вновь будет ошибка на валидационной выборке. .. raw:: latex \diilbookstyleinputcell .. code:: python def hpo_objective_lenet(learning_rate, batch_size, max_epochs=10): #@save model = d2l.LeNet(lr=learning_rate, num_classes=10) trainer = d2l.HPOTrainer(max_epochs=max_epochs, num_gpus=1) data = d2l.FashionMNIST(batch_size=batch_size) model.apply_init([next(iter(data.get_dataloader(True)))[0]], d2l.init_cnn) trainer.fit(model=model, data=data) validation_error = trainer.validation_error() return validation_error Нам также необходимо определить пространство конфигураций. При этом первой конфигурацией, подлежащей оценке, будет настройка по умолчанию, использованная в :numref:`sec_lenet`. .. raw:: latex \diilbookstyleinputcell .. code:: python config_space = { "learning_rate": stats.loguniform(1e-2, 1), "batch_size": stats.randint(32, 256), } initial_config = { "learning_rate": 0.1, "batch_size": 128, } Теперь мы можем приступить к случайному поиску: .. raw:: latex \diilbookstyleinputcell .. code:: python searcher = RandomSearcher(config_space, initial_config=initial_config) scheduler = BasicScheduler(searcher=searcher) tuner = HPOTuner(scheduler=scheduler, objective=hpo_objective_lenet) tuner.run(number_of_trials=5) Ниже мы построим график траектории оптимизации текущего лучшего решения, чтобы получить производительность случайного поиска в любой момент времени: .. raw:: latex \diilbookstyleinputcell .. code:: python board = d2l.ProgressBoard(xlabel="time", ylabel="error") for time_stamp, error in zip( tuner.cumulative_runtime, tuner.incumbent_trajectory ): board.draw(time_stamp, error, "random search", every_n=1) Сравнение алгоритмов HPO ------------------------ Так же как и при сравнении алгоритмов обучения или архитектур моделей, важно понимать, как наилучшим образом сравнивать различные алгоритмы настройки гиперпараметров (HPO). Каждый запуск HPO зависит от двух основных источников случайности: случайных факторов процесса обучения, таких как случайная инициализация весов или порядок формирования мини-выборок, и внутренней случайности самого алгоритма HPO, например, случайной выборке при случайном поиске. Следовательно, при сравнении различных алгоритмов критически важно запускать каждый эксперимент несколько раз и сообщать статистические показатели, такие как среднее значение или медиана, по совокупности нескольких повторений алгоритма с использованием различных начальных значений генератора случайных чисел. Чтобы проиллюстрировать это, мы сравним случайный поиск (см. :numref:`sec_rs`) и байесовскую оптимизацию :cite:`snoek-nips12` при настройке гиперпараметров нейронной сети прямого распространения. Каждый алгоритм оценивался :math:`50` раз с разными случайными начальными значениями. Сплошная линия показывает среднюю производительность лучшей найденной конфигурации по этим :math:`50` повторениям, а пунктирная — стандартное отклонение. Мы видим, что случайный поиск и байесовская оптимизация демонстрируют примерно одинаковые результаты вплоть до ~1000 секунд, однако байесовская оптимизация способна использовать предыдущие наблюдения для выявления более эффективных конфигураций, благодаря чему после этого момента она начинает значительно превосходить случайный поиск. .. _example_anytime_performance: .. figure:: ../img/example_anytime_performance.svg Пример графика производительности в любой момент времени для сравнения двух алгоритмов A и B. Итоги ----- В этом разделе мы описали простой, но гибкий интерфейс для реализации различных алгоритмов HPO, которые будут рассмотрены в этой главе. Аналогичные интерфейсы можно найти в популярных открытых фреймворках для HPO. Мы также обсудили методы сравнения алгоритмов HPO и потенциальные ошибки, на которые следует обратить внимание. Упражнения ---------- 1. Цель этого упражнения — реализовать целевую функцию для чуть более сложной задачи HPO и провести более реалистичные эксперименты. Мы будем использовать многослойный перцептрон с двумя скрытыми слоями ``DropoutMLP``, реализованный в :numref:`sec_dropout`. 1. Реализуйте целевую функцию, которая должна зависеть от всех гиперпараметров модели и ``batch_size``. Используйте ``max_epochs=50``. Графические процессоры здесь не помогут, поэтому ``num_gpus=0``. Подсказка: измените ``hpo_objective_lenet``. 2. Выберите разумное пространство поиска, где ``num_hiddens_1`` и ``num_hiddens_2`` — целые числа из :math:`[8, 1024]`, значения дропаута находятся в диапазоне :math:`[0, 0.95]`, а ``batch_size`` — в диапазоне :math:`[16, 384]`. Предоставьте код для ``config_space``, используя разумные распределения из ``scipy.stats``. 3. Выполните случайный поиск на этом примере с ``number_of_trials=20`` и постройте графики результатов. Убедитесь, что сначала вы оценили конфигурацию по умолчанию :numref:`sec_dropout`, которая равна ``initial_config = {'num_hiddens_1': 256, 'num_hiddens_2': 256, 'dropout_1': 0.5, 'dropout_2': 0.5, 'lr': 0.1, 'batch_size': 256}``. 2. В этом упражнении вы реализуете новый алгоритм поиска (подкласс ``HPOSearcher``), который принимает решения на основе предыдущих данных. Он зависит от параметров ``probab_local``, ``num_init_random``. Его метод ``sample_configuration`` работает следующим образом. Для первых ``num_init_random`` вызовов поступайте так же, как в ``RandomSearcher.sample_configuration``. В противном случае с вероятностью ``1 - probab_local`` поступайте так же, как в ``RandomSearcher.sample_configuration``. В остальных случаях выберите конфигурацию, достигшую наименьшей ошибки на валидационной выборке на данный момент, случайным образом выберите один из её гиперпараметров и случайным образом выберите его значение, как в ``RandomSearcher.sample_configuration``, но оставьте все остальные значения неизменными. Верните эту конфигурацию, которая идентична лучшей конфигурации на данный момент, за исключением одного гиперпараметра. 1. Реализуйте новый ``LocalSearcher``. Подсказка: при создании вашего поисковика требуется передать ``config_space`` в качестве аргумента. Вы можете использовать поле типа ``RandomSearcher``. Вам также потребуется реализовать метод ``update``. 2. Повторите эксперимент из предыдущего упражнения, но используйте вместо ``RandomSearcher`` ваш новый алгоритм поиска. Экспериментируйте с различными значениями ``probab_local`` и ``num_init_random``. Обратите внимание, что для корректного сравнения различных методов HPO необходимо повторять эксперименты несколько раз и, в идеале, использовать ряд бенчмарк-задач. `Обсуждения `__