19.4. 多保真度超参数优化¶ 在 SageMaker Studio Lab 中打开 Notebook
即使在大小适中的数据集上,训练神经网络的成本也很高。根据配置空间(第 19.1.1.2 节),超参数优化需要数十到数百次函数评估才能找到一个性能良好的超参数配置。正如我们在 第 19.3 节 中所看到的,我们可以通过利用并行资源来显著加快 HPO 的总墙上时钟时间,但这并不能减少所需的计算总量。
在本节中,我们将展示如何加速超参数配置的评估。随机搜索等方法为每次超参数评估分配相同数量的资源(例如,迭代轮数、训练数据点)。图 19.4.1 描绘了一组使用不同超参数配置训练的神经网络的学习曲线。经过几个迭代轮后,我们已经能够从视觉上区分性能良好和次优的配置。然而,学习曲线是有噪声的,我们可能仍然需要完整的 100 个迭代轮来确定性能最好的那一个。
图 19.4.1 随机超参数配置的学习曲线¶
多保真度超参数优化为有前景的配置分配更多资源,并及早停止对表现不佳的配置的评估。这加快了优化过程,因为我们可以用相同的总资源量尝试更多的配置。
更正式地,我们扩展在 第 19.1.1 节 中的定义,使我们的目标函数 \(f(\mathbf{x}, r)\) 获得一个额外的输入 \(r \in [r_{\mathrm{min}}, r_{max}]\),它指定了我们愿意为评估配置 \(\mathbf{x}\) 付出的资源量。我们假设误差 \(f(\mathbf{x}, r)\) 随 \(r\) 减小,而计算成本 \(c(\mathbf{x}, r)\) 随 \(r\) 增加。通常,\(r\) 表示训练神经网络的迭代轮数,但它也可以是训练子集的大小或交叉验证的折数。
from collections import defaultdict
import numpy as np
from scipy import stats
from d2l import torch as d2l
d2l.set_figsize()
19.4.1. 逐次减半¶
将随机搜索适应多保真度设置的最简单方法之一是逐次减半 (Jamieson and Talwalkar, 2016, Karnin et al., 2013)。其基本思想是从 \(N\) 个配置开始(例如从配置空间中随机采样),并只对每个配置训练 \(r_{\mathrm{min}}\) 个迭代轮。然后,我们丢弃一部分表现最差的试验,并对剩下的试验进行更长时间的训练。重复这个过程,运行时间更长的试验越来越少,直到至少有一个试验达到 \(r_{max}\) 个迭代轮。
更正式地,考虑一个最小预算 \(r_{\mathrm{min}}\)(例如 1 个迭代轮)、一个最大预算 \(r_{max}\)(例如我们之前示例中的 max_epochs
)和一个减半常数 \(\eta\in\{2, 3, \dots\}\)。为简单起见,假设 \(r_{max} = r_{\mathrm{min}} \eta^K\),其中 \(K \in \mathbb{I}\)。那么初始配置的数量是 \(N = \eta^K\)。让我们定义一组层级 \(\mathcal{R} = \{ r_{\mathrm{min}}, r_{\mathrm{min}}\eta, r_{\mathrm{min}}\eta^2, \dots, r_{max} \}\)。
一轮逐次减半的过程如下。我们从运行 \(N\) 个试验开始,直到第一个层级 \(r_{\mathrm{min}}\)。对验证误差进行排序,我们保留前 \(1 / \eta\) 的部分(即 \(\eta^{K-1}\) 个配置),并丢弃其余所有配置。幸存的试验将为下一个层级(\(r_{\mathrm{min}}\eta\) 个迭代轮)进行训练,然后重复此过程。在每个层级,有 \(1 / \eta\) 的试验幸存下来,它们的训练将以 \(\eta\) 倍的预算继续。通过这种对 \(N\) 的特殊选择,只有一个试验将被训练到完整的预算 \(r_{max}\)。一旦这样一轮逐次减半完成,我们用一组新的初始配置开始下一轮,如此迭代,直到总预算用完。
图 19.4.2 随机超参数配置的学习曲线。¶
我们子类化 第 19.2 节 中的 HPOScheduler
基类来实现逐次减半,允许一个通用的 HPOSearcher
对象来采样配置(在我们下面的例子中,它将是一个 RandomSearcher
)。此外,用户必须传入最小资源 \(r_{\mathrm{min}}\)、最大资源 \(r_{max}\) 和 \(\eta\) 作为输入。在我们的调度器内部,我们维护一个队列,其中包含当前层级 \(r_i\) 仍需评估的配置。每当我们跳到下一个层级时,我们都会更新这个队列。
class SuccessiveHalvingScheduler(d2l.HPOScheduler): #@save
def __init__(self, searcher, eta, r_min, r_max, prefact=1):
self.save_hyperparameters()
# Compute K, which is later used to determine the number of configurations
self.K = int(np.log(r_max / r_min) / np.log(eta))
# Define the rungs
self.rung_levels = [r_min * eta ** k for k in range(self.K + 1)]
if r_max not in self.rung_levels:
# The final rung should be r_max
self.rung_levels.append(r_max)
self.K += 1
# Bookkeeping
self.observed_error_at_rungs = defaultdict(list)
self.all_observed_error_at_rungs = defaultdict(list)
# Our processing queue
self.queue = []
开始时我们的队列是空的,我们用 \(n = \textrm{prefact} \cdot \eta^{K}\) 个配置填充它,这些配置首先在最小层级 \(r_{\mathrm{min}}\) 上进行评估。在这里,\(\textrm{prefact}\) 允许我们在不同上下文中重用我们的代码。在本节中,我们固定 \(\textrm{prefact} = 1\)。每当有资源可用并且 HPOTuner
对象查询 suggest
函数时,我们从队列中返回一个元素。一旦我们完成一轮逐次减半,即我们在最高资源级别 \(r_{max}\) 上评估了所有幸存的配置并且我们的队列为空时,我们用一组新的、随机采样的配置重新开始整个过程。
@d2l.add_to_class(SuccessiveHalvingScheduler) #@save
def suggest(self):
if len(self.queue) == 0:
# Start a new round of successive halving
# Number of configurations for the first rung:
n0 = int(self.prefact * self.eta ** self.K)
for _ in range(n0):
config = self.searcher.sample_configuration()
config["max_epochs"] = self.r_min # Set r = r_min
self.queue.append(config)
# Return an element from the queue
return self.queue.pop()
当我们收集到一个新的数据点时,我们首先更新搜索器模块。之后,我们检查是否已经收集了当前层级上的所有数据点。如果是,我们对所有配置进行排序,并将前 \(\frac{1}{\eta}\) 的配置推入队列。
@d2l.add_to_class(SuccessiveHalvingScheduler) #@save
def update(self, config: dict, error: float, info=None):
ri = int(config["max_epochs"]) # Rung r_i
# Update our searcher, e.g if we use Bayesian optimization later
self.searcher.update(config, error, additional_info=info)
self.all_observed_error_at_rungs[ri].append((config, error))
if ri < self.r_max:
# Bookkeeping
self.observed_error_at_rungs[ri].append((config, error))
# Determine how many configurations should be evaluated on this rung
ki = self.K - self.rung_levels.index(ri)
ni = int(self.prefact * self.eta ** ki)
# If we observed all configuration on this rung r_i, we estimate the
# top 1 / eta configuration, add them to queue and promote them for
# the next rung r_{i+1}
if len(self.observed_error_at_rungs[ri]) >= ni:
kiplus1 = ki - 1
niplus1 = int(self.prefact * self.eta ** kiplus1)
best_performing_configurations = self.get_top_n_configurations(
rung_level=ri, n=niplus1
)
riplus1 = self.rung_levels[self.K - kiplus1] # r_{i+1}
# Queue may not be empty: insert new entries at the beginning
self.queue = [
dict(config, max_epochs=riplus1)
for config in best_performing_configurations
] + self.queue
self.observed_error_at_rungs[ri] = [] # Reset
配置是根据它们在当前层级上观察到的性能进行排序的。
@d2l.add_to_class(SuccessiveHalvingScheduler) #@save
def get_top_n_configurations(self, rung_level, n):
rung = self.observed_error_at_rungs[rung_level]
if not rung:
return []
sorted_rung = sorted(rung, key=lambda x: x[1])
return [x[0] for x in sorted_rung[:n]]
让我们看看逐次减半在我们的神经网络示例上的表现如何。我们将使用 \(r_{\mathrm{min}} = 2\)、\(\eta = 2\)、\(r_{max} = 10\),所以层级是 \(2, 4, 8, 10\)。
min_number_of_epochs = 2
max_number_of_epochs = 10
eta = 2
num_gpus=1
config_space = {
"learning_rate": stats.loguniform(1e-2, 1),
"batch_size": stats.randint(32, 256),
}
initial_config = {
"learning_rate": 0.1,
"batch_size": 128,
}
我们只需将调度器替换为我们新的 SuccessiveHalvingScheduler
。
searcher = d2l.RandomSearcher(config_space, initial_config=initial_config)
scheduler = SuccessiveHalvingScheduler(
searcher=searcher,
eta=eta,
r_min=min_number_of_epochs,
r_max=max_number_of_epochs,
)
tuner = d2l.HPOTuner(
scheduler=scheduler,
objective=d2l.hpo_objective_lenet,
)
tuner.run(number_of_trials=30)
error = 0.17762434482574463, runtime = 53.576584339141846
我们可以可视化我们评估过的所有配置的学习曲线。大多数配置都被提前停止,只有性能较好的配置才能存活到 \(r_{max}\)。与普通的随机搜索相比,后者会为每个配置分配 \(r_{max}\) 的资源。
for rung_index, rung in scheduler.all_observed_error_at_rungs.items():
errors = [xi[1] for xi in rung]
d2l.plt.scatter([rung_index] * len(errors), errors)
d2l.plt.xlim(min_number_of_epochs - 0.5, max_number_of_epochs + 0.5)
d2l.plt.xticks(
np.arange(min_number_of_epochs, max_number_of_epochs + 1),
np.arange(min_number_of_epochs, max_number_of_epochs + 1)
)
d2l.plt.ylabel("validation error")
d2l.plt.xlabel("epochs")
Text(0.5, 0, 'epochs')
最后,请注意我们实现 SuccessiveHalvingScheduler
时的一些微小复杂性。假设一个工作程序空闲下来可以运行一个任务,并且当当前层级几乎完全填满时调用了 suggest
,但另一个工作程序仍在忙于评估。由于我们缺少这个工作程序的指标值,我们无法确定前 \(1 / \eta\) 的部分来开启下一个层级。另一方面,我们想给空闲的工作程序分配一个任务,这样它就不会闲置。我们的解决方案是开始新一轮的逐次减半,并把我们的工作程序分配给那里的第一个试验。然而,一旦在 update
中完成一个层级,我们会确保将新的配置插入到队列的开头,这样它们就会优先于下一轮的配置。