19.2. 超参数优化 API¶ 在 SageMaker Studio Lab 中打开 Notebook
在深入研究方法之前,我们将首先讨论一个基本的代码结构,它能让我们高效地实现各种HPO算法。通常,这里考虑的所有HPO算法都需要实现两个决策原语:*搜索*和*调度*。首先,它们需要对新的超参数配置进行采样,这通常涉及在配置空间上进行某种搜索。其次,对于每个配置,HPO算法需要调度其评估,并决定为其分配多少资源。一旦我们开始评估一个配置,我们称之为一个*试验*(trial)。我们将这些决策映射到两个类:HPOSearcher
和HPOScheduler
。在此之上,我们还提供了一个HPOTuner
类来执行优化过程。
调度器和搜索器的概念也已在流行的HPO库中实现,例如Syne Tune (Salinas et al., 2022)、Ray Tune (Liaw et al., 2018)或Optuna (Akiba et al., 2019)。
import time
from scipy import stats
from d2l import torch as d2l
19.2.1. 搜索器¶
下面我们为搜索器定义一个基类,它通过sample_configuration
函数提供一个新的候选配置。实现此函数的一种简单方法是随机均匀地抽样配置,就像我们在 第 19.1 节中对随机搜索所做的那样。更复杂的算法,如贝叶斯优化,将根据先前试验的性能来做出这些决策。因此,这些算法能够随着时间的推移采样到更有希望的候选配置。我们添加了update
函数来更新先前试验的历史记录,然后可以利用这些历史记录来改进我们的采样分布。
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
指定要评估的第一个配置,而后续的配置则是随机抽取的。
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
19.2.2. 调度器¶
除了为新试验采样配置之外,我们还需要决定何时以及运行试验多长时间。实际上,所有这些决策都由HPOScheduler
完成,它将新配置的选择委托给HPOSearcher
。suggest
方法在有训练资源可用时被调用。除了调用搜索器的sample_configuration
之外,它还可以决定诸如max_epochs
(即模型训练多长时间)之类的参数。update
方法在试验返回新观察值时被调用。
class HPOScheduler(d2l.HyperParameters): #@save
def suggest(self) -> dict:
raise NotImplementedError
def update(self, config: dict, error: float, info=None):
raise NotImplementedError
为了实现随机搜索,以及其他HPO算法,我们只需要一个基本的调度器,它在每次有新资源可用时调度一个新的配置。
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)
19.2.3. 调优器¶
最后,我们需要一个组件来运行调度器/搜索器并对结果进行一些簿记。以下代码实现了HPO试验的顺序执行,它一个接一个地评估训练任务,并将作为基础示例。我们稍后将使用*Syne Tune*来处理更具可扩展性的分布式HPO情况。
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}")
19.2.4. 记录HPO算法的性能¶
对于任何HPO算法,我们最感兴趣的是性能最好的配置(称为*在任者* incumbent)及其在给定墙钟时间后的验证误差。这就是为什么我们跟踪每次迭代的runtime
,它包括运行评估的时间(调用objective
)和做出决策的时间(调用scheduler.suggest
)。接下来,我们将cumulative_runtime
与incumbent_trajectory
进行绘图,以可视化由scheduler
(和searcher
)定义的HPO算法的*随时性能*(any-time performance)。这使我们不仅可以量化优化器找到的配置的效果如何,还可以量化优化器找到它的速度有多快。
@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)
19.2.5. 示例:优化卷积神经网络的超参数¶
我们现在使用我们新实现的随机搜索来优化 第 7.6 节 中 LeNet
卷积神经网络的*批量大小*和*学习率*。我们首先定义目标函数,它将再次是验证误差。
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
我们还需要定义配置空间。此外,要评估的第一个配置是 第 7.6 节 中使用的默认设置。
config_space = {
"learning_rate": stats.loguniform(1e-2, 1),
"batch_size": stats.randint(32, 256),
}
initial_config = {
"learning_rate": 0.1,
"batch_size": 128,
}
现在我们可以开始随机搜索了
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)
error = 0.9000097513198853, runtime = 62.85189199447632
下面我们绘制在任者的优化轨迹,以了解随机搜索的随时性能
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)
19.2.6. 比较HPO算法¶
就像对待训练算法或模型架构一样,理解如何最好地比较不同的HPO算法非常重要。每次HPO运行都依赖于两个主要的随机性来源:训练过程的随机效应,例如随机权重初始化或小批量顺序,以及HPO算法本身的内在随机性,例如随机搜索的随机抽样。因此,在比较不同算法时,至关重要的是多次运行每个实验并报告统计数据,例如基于不同随机数生成器种子的算法的多次重复群体的均值或中位数。
为了说明这一点,我们比较了随机搜索(见 第 19.1.2 节)和贝叶斯优化 (Snoek et al., 2012) 在调整前馈神经网络超参数上的表现。每种算法都使用不同的随机种子评估了 \(50\) 次。实线表示这 \(50\) 次重复中在任者的平均性能,虚线表示标准差。我们可以看到,随机搜索和贝叶斯优化在约1000秒内的表现大致相同,但贝叶斯优化可以利用过去的观察来识别更好的配置,因此在此之后迅速超越了随机搜索。
图 19.2.1 用于比较两种算法A和B的随时性能图示例。¶
19.2.7. 小结¶
本节提出了一个简单而灵活的接口,用于实现在本章中将要介绍的各种HPO算法。类似的接口可以在流行的开源HPO框架中找到。我们还研究了如何比较HPO算法,以及需要注意的潜在陷阱。
19.2.8. 练习¶
本练习的目标是为一个稍微更具挑战性的HPO问题实现目标函数,并运行更现实的实验。我们将使用 第 5.6 节 中实现的双隐藏层 MLP
DropoutMLP
。编写目标函数代码,该函数应依赖于模型的所有超参数和
batch_size
。使用max_epochs=50
。GPU在这里没有帮助,所以num_gpus=0
。提示:修改hpo_objective_lenet
。选择一个合理的搜索空间,其中
num_hiddens_1
、num_hiddens_2
是 \([8, 1024]\)范围内的整数,dropout值在\([0, 0.95]\)之间,而batch_size
在\([16, 384]\)之间。为config_space
提供代码,使用scipy.stats
中合理的分布。在此示例上运行
number_of_trials=20
的随机搜索,并绘制结果。确保首先评估 第 5.6 节 的默认配置,即initial_config = {'num_hiddens_1': 256, 'num_hiddens_2': 256, 'dropout_1': 0.5, 'dropout_2': 0.5, 'lr': 0.1, 'batch_size': 256}
。
在本练习中,你将实现一个新的搜索器(
HPOSearcher
的子类),它根据过去的数据做出决策。它依赖于参数probab_local
和num_init_random
。其sample_configuration
方法工作如下。对于前num_init_random
次调用,执行与RandomSearcher.sample_configuration
相同的操作。否则,以1 - probab_local
的概率,执行与RandomSearcher.sample_configuration
相同的操作。否则,选择迄今为止获得最小验证误差的配置,随机选择其一个超参数,并像在RandomSearcher.sample_configuration
中那样随机采样其值,但保持所有其他值不变。返回这个配置,它与迄今为止最好的配置相同,除了这一个超参数。编写这个新的
LocalSearcher
。提示:你的搜索器在构造时需要config_space
作为参数。可以随意使用一个RandomSearcher
类型的成员。你还需要实现update
方法。重新运行上一个练习的实验,但使用你的新搜索器而不是
RandomSearcher
。尝试不同的probab_local
和num_init_random
值。但是请注意,不同HPO方法之间的适当比较需要多次重复实验,并且理想情况下应考虑多个基准任务。