深度学习系统设计(二)(1)https://developer.aliyun.com/article/1517011
4.4 不能在单个 GPU 上加载的大型模型训练
在研究领域,神经网络大小(由参数数量定义)正在迅速增长,我们不能忽视这一趋势。以 ImageNet 挑战为例,2014 年的获胜者(GoogleNet)有 400 万个参数;2017 年的获胜者(Squeeze-and-Excitation Networks)有 1.458 亿个参数;当前领先的方法有超过 10 亿个参数。
尽管我们的神经网络大小增长了近 300 倍,但 GPU 内存仅增加了 4 倍。将来我们会更频繁地遇到无法训练模型的情况,因为它无法加载到单个 GPU 上。
在本节中,我们将讨论训练大型模型的常见策略。与第 4.2 节中描述的数据并行策略不同,这里介绍的方法需要在训练代码上付出努力。
注意:虽然本节介绍的方法通常由数据科学家实现,但我们希望您仍然能够理解它们。了解这些训练技术背后的策略对于设计训练服务和训练代码之间的通信协议非常有帮助。它还为在训练服务中进行故障排除或微调训练性能提供了洞察力。为了简单起见,我们只会在概念级别上描述算法,并侧重于工程角度上的必要工作。
4.4.1 传统方法:节省内存
假设您的数据科学团队想要训练一个可以加载到您训练集群中最大 GPU 的模型;例如,他们想要在一个 10 GB 内存的 GPU 上训练一个 24 GB 的 BERT 模型。团队可以使用几种节省内存的技术来在这种情况下训练模型,包括梯度累积和内存交换。这项工作通常由数据科学家实现。作为平台开发人员,您只需了解这些选项。我们将简要描述它们,这样您就会知道何时建议使用它们中的每一种。
注意还有其他几种节省内存的方法,例如 OpenAI 的梯度检查点(github.com/cybertronai/gradient-checkpointing
)和 NVIDIA 的 vDNN(arxiv.org/abs/1602.08124
),但由于本书不涉及深度学习算法,我们将它们留给独立研究。
梯度累积
在深度学习训练中,数据集被分成批次。在每个训练步骤中,用于损失计算、梯度计算和模型参数更新,我们将整个批次的示例(训练数据)加载到内存中,并一次处理所有计算。
我们可以通过减小批量大小来减轻内存压力,例如将批次中的示例数量从 32 减少到 16。但是减小批量大小可能会导致模型收敛速度大大降低。这就是梯度累积可以帮助的地方。
梯度累积将批量示例分成可配置数量的小批量,然后在每个小批量之后计算损失和梯度。但是,它不会更新模型参数,而是等待并累积所有小批量的梯度。然后,最终,根据累积梯度更新模型参数。
让我们看一个示例,了解这如何加快流程。想象一下,由于 GPU 内存限制,我们无法使用批量大小为 32 进行训练。使用梯度累积,我们可以将每个批次分成四个小批次,每个小批次大小为 8。因为我们累积所有四个小批次的梯度,并且仅在所有四个小批次完成后更新模型,所以该过程几乎等同于使用批量大小为 32 进行训练。不同之处在于,我们一次只在 GPU 中计算 8 个示例,而不是 32 个,因此成本比使用批量为 32 的情况慢 4 倍。
内存交换(GPU 和 CPU)
内存交换方法非常简单:它在 CPU 和 GPU 之间来回复制激活。如果您不习惯深度学习术语,请将激活想象为神经网络每个节点的计算输出。其思想是仅在 GPU 中保留当前计算步骤所需的数据,并将计算结果交换到 CPU 内存以供将来使用。
在此基础上,一种名为 L2L(层到层)的新的中继式执行技术将仅在 GPU 上保留正在执行的层和中转缓冲区。整个模型和保存状态的优化器都存储在 CPU 空间中。L2L 可以大大提高 GPU 吞吐量,并允许我们在价格合理的设备上开发大型模型。如果您对此方法感兴趣,可以查阅普迪佩迪等人撰写的论文“使用新的执行算法在恒定内存中训练大型神经网络”(arxiv.org/abs/2002.05645
),该论文在 GitHub 上还有一个 PyTorch 实现。
梯度累积和内存交换都是在较小的 GPU 上训练大模型的有效方法。但是,像大多数事情一样,它们会降低训练速度。由于这个缺点,我们通常只在原型设计时使用它们。
为了获得可行的训练速度,我们真的需要在多个 GPU 上分布式地训练模型。因此,在下一节中,我们将介绍一种更接近生产的方法:管道并行。它可以以令人印象深刻的训练速度分布式训练大型模型。
4.4.2 管道模型并行
在第 4.2 节中,我们讨论了最常用的分布式训练方法:数据并行。这种方法在每个设备上保留整个模型的副本,并将数据划分为多个设备。然后它聚合梯度并在每个训练步骤中更新模型。整个数据并行的方法效果很好,只要整个模型可以加载到一个 GPU 上。然而,正如我们在本节中看到的那样,我们并不总是能够做到这一点。这就是管道并行的用处所在。在本节中,我们将学习管道并行,这是一种在多个 GPU 上分布式训练大型模型的训练方法。
要理解管道并行,让我们先简要了解模型并行。这个小插曲将使我们更容易转向管道并行。
模型并行
模型并行的思想是将神经网络分割成较小的子网并在不同的 GPU 上运行每个子网。图 4.6 说明了模型并行的方法。
图 4.6 将四层全连接深度学习网络分为四个子群组;每个群组有一层,每个子群组在一个 GPU 上运行。
图 4.6 展示了模型并行的过程。首先,它将神经网络(四层)转换为四个子神经网络(单层),然后为每个单层网络分配一个专用的 GPU。通过这样做,我们在四个 GPU 上分布式地运行模型。
模型并行的概念很简单,但实际实现可能会有些棘手;它取决于网络的架构。为了让您有一个概念,下面的代码片段是一个在两个 GPU 上运行网络的虚构的 PyTorch 代码片段。
列表 4.2 是在 PyTorch 中实现模型并行的示例代码
gpu1 = 1 gpu2 = 2 class a_large_model(nn.Module): def __init__(self): super().__init__() # initialize the network as two subnetworks. self.subnet1 = ... self.subnet2 = ... # put subnetwork 1 and 2 to two different GPUs self.subnet1.cuda(gpu1) self.subnet2.cuda(gpu2) def forward(x): # load data to GPU 1 and calculate output for # subnet 1, GPU 2 is idle at the moment. x = x.cuda(gpu1) x = self.subnet1(x) # move the output of subnet 1 to GPU 2 and calculate # output for subnet 2\. GPU 1 is idle x = x.cuda(gpu2) x = self.sub_network2(x) return x
如列表 4.2 所示,在__init__
函数中初始化了两个子网络并将其分配到两个 GPU 上,然后在forward
函数中将它们连接起来。由于深度学习网络结构的多样性,不存在一种通用方法(范式)来拆分网络。我们必须逐个实现模型并行。
模型并行的另一个问题是严重浪费 GPU 资源。由于训练组中的所有设备都有顺序依赖性,一次只能有一个设备工作,这会浪费大量的 GPU 时钟周期。图 4.7 显示了使用三个 GPU 进行模型并行训练时的 GPU 利用情况。
图 4.7 模型并行训练可能导致 GPU 利用率严重下降。在这种方法中,网络被分为三个子网并在三个 GPU 上运行。由于三个 GPU 之间的顺序依赖关系,每个 GPU 在训练时间的 66%空闲。
让我们通过这张图来看看为什么 GPU 使用率如此之低。在左边,图 4.7(a)中,我们看到了模型并行设计。我们将模型网络分成三个子网络,并让每个子网络在不同的 GPU 上运行。在每个训练迭代中,当运行正向传播时,我们首先计算子网 1,然后计算子网 2 和子网 3;当运行反向传播时,梯度更新则发生在相反的顺序中。
在图 4.7(b)中,右边,你可以看到在训练过程中三个 GPU 的资源利用情况。时间轴分为两部分:正向传播和反向传播。正向传播意味着模型推断的计算,从 GPU 1 到 GPU 2 和 GPU3,而反向传播意味着模型权重更新的反向传播,从 GPU 3 到 GPU 2 和 GPU 1。
如果你在时间条上垂直观察,无论是正向传播还是反向传播,你都只能看到一个 GPU 在工作。这是因为每个子网之间存在顺序依赖关系。例如,在正向传播中,子网 2 需要等待子网 1 的输出来完成自己的正向计算,因此在 GPU 1 完成计算之前,GPU 2 将在正向传播中空闲。
无论你添加多少个 GPU,一次只能有一个 GPU 工作,这是一种巨大的浪费。这时就轮到管道并行 ism 派上用场了。管道并行 ism 通过消除这种浪费并充分利用 GPU 来使模型训练更加高效。让我们看看它是如何工作的。
管道并行 ism
管道并行 ism 本质上是模型并行 ism 的改进版本。除了将网络划分到不同的 GPU 中,它还将每个训练示例批次分成小的微批次,并在层之间重叠这些微批次的计算。通过这样做,它使所有 GPU 大部分时间都保持忙碌,从而提高了 GPU 的利用率。
这种方法有两个主要的实现:PipeDream(微软)和 GPipe(谷歌)。我们在这里使用 GPipe 作为演示示例,因为它优化了每个训练步骤中的梯度更新,并且具有更好的训练吞吐量。你可以从“GPipe:使用微批量管道并行 ism 轻松扩展”的 Huang 等人的论文中找到有关 GPipe 的更多细节(arxiv.org/abs/1811.06965
)。让我们在图 4.8 中,以高层次来看一下 GPipe 是如何工作的。
图 4.8(a)显示了一个具有顺序层的示例神经网络被分区到四个加速器上。 F[k] 是第 k 个单元的组合前向计算函数。 Bk 是反向传播函数,它依赖于来自上一层的 B[k+1] 和 F[k]。 (b)naive 模型并行 ism 策略由于网络的顺序依赖关系而导致严重的利用不足。(c)流水线并行 ism 将输入 minibatch 划分为更小的微批次,使不同的加速器可以同时处理不同的微批次。 梯度在最后同步应用。 (来源:图 2,“GPipe:使用微批次管道并行 ism 轻松扩展”,Huang 等人,2019 年,arXiv:1811.06965)
图 4.8(a)描述了一个由四个子网络组成的神经网络; 每个子网络都加载在一个 GPU 上。 F 表示前向传递,B 表示后向传递,而 F[k] 和 B[k] 则在 GPUk 上运行。 训练顺序首先是前向传递,F[0] -> F[1] -> F[2] -> F[3],然后是后向传递,F[3] -> (B[3], F[2]) -> (B[2], F[2]) -> (B[1], F[1]) -> B[0]。
图 4.8(b)显示了 naive 模型并行 ism 的训练流程。 我们可以看到 GPU 严重未被利用; 在前向传递和后向传递中只有一个 GPU 被激活; 因此,每个 GPU 有 75% 的空闲时间。
图 4.8(c)显示了 GPipe 在训练操作序列中的改进。 GPipe 首先将每个训练示例批次划分为四个相等的微批次,并通过四个 GPU 进行管道处理。 图中的 F[(0,2)] 表示在 GPU 0 上使用 minibatch 2 进行前向传递计算。 在后向传递期间,基于用于前向传递的相同模型参数计算每个微批次的梯度。 关键在于它不会立即更新模型参数; 相反,它会累积每个微批次的所有梯度。 在每个训练批次结束时,我们使用来自所有四个微批次的累积梯度来更新所有四个 GPU 上的模型参数。
通过比较图 4.8(b)和(c),我们可以看到 GPU 利用率大大提高; 现在每个 GPU 有 47% 的空闲时间。 让我们看一个使用 PyTorch GPipe 实现来在两个 GPU 上训练一个 transformer 模型的代码示例(请参见下面的清单)。 为了清楚地演示这个想法,我们只保留与管道相关的代码,并将它们分成四部分。 您可以查看由 Pritam Damania 撰写的教程“使用流水线并行 ism 训练 transformer 模型的 PyTorch”来获取完整的代码(mng.bz/5mD8
)。
清单 4.3 使用流水线并行 ism 训练 transformer 模型
## Part One: initialize remote communication # for multiple machines rpc.init_rpc( name="worker", # set rank number to this node, rank is the global # unique id of a node, 0 is the master, # other ranks are observers rank=0, # set the number of workers in the group world_size=1, .. .. .. ) .. .. .. ## Part Two: split model to 2 subnetworks, load # to different GPUs and initialize the pipeline. num_gpus = 2 partition_len = ((nlayers - 1) // num_gpus) + 1 # Add all the necessary transformer blocks. for i in range(nlayers): transformer_block = TransformerEncoderLayer(emsize, nhead, nhid, dropout) .. .. .. # Load first half encoder layers to GPU 0 and second hard encoder layers to GPU 1. device = i // (partition_len) tmp_list.append(transformer_block.to(device)) # Load decoder to GPU 1. tmp_list.append(Decoder(ntokens, emsize).cuda(num_gpus - 1)) module_list.append(nn.Sequential(*tmp_list)) ## Part Three: Build up the pipeline. chunks = 8 # Set micro-batches number to 8. model = Pipe(torch.nn.Sequential(*module_list), chunks = chunks) .. .. .. ## Part 4: Train with pipeline def train(): model.train() # Turn on the train mode .. .. .. for batch, i in enumerate(range(0, nbatches, bptt)): data, targets = get_batch(train_data, i) optimizer.zero_grad() # Compute pipeline output,by following the pipeline setup, # the Pytorch framework will coordinate the network computation # between GPU 0 and GPU 1. # Since the Pipe is only within a single host and process the "RRef" # returned by forward method is local to this node and can simply # retrieved via "RRef.local_value()". output = model(data).local_value() # Compute the loss on GPU 1. # Need to move targets to the device where the output of the # pipeline resides. loss = criterion(output.view(-1, ntokens), targets.cuda(1)) # Backprop and model parameters update are the same as single GPU training. # The Pytorch framework hides all the details of micro-batches # computation and model parameters update. loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5) optimizer.step() .. .. ..
如我们从清单 4.3 中看到的,流水线并行代码比分布式数据并行 ism 复杂得多。 除了设置通信组之外,我们还需要考虑如何划分我们的模型网络并在工作进程间传输梯度和激活(对子网络的前向输出建模)。
4.4.3 软件工程师如何支持流水线并行 ism
你可能注意到我们在本节中讨论的所有方法都是用于编写训练代码的技术。因为数据科学家通常编写训练代码,所以你可能想知道作为软件开发人员,我们能做些什么来支持流水线并行训练。
首先,我们可以着手构建训练服务来自动化流水线训练的执行,并提高资源利用率(例如,始终保持 GPU 忙碌)。这种自动化包括分配工作资源,启用工作节点间的通信,以及将流水线训练代码和相应的初始化参数分发给每个工作节点(例如,工作节点 IP 地址,进程 ID,GPU ID 和工作节点组大小)。
第二,我们可以向数据科学家团队介绍新的分布式训练选项。有时候数据科学家团队对能够改善模型训练体验的新工程方法并不了解,所以在这里沟通非常重要。我们可以与团队成员合作,引导对流水线并行方法进行实验。
第三,我们可以着手改善模型训练的可用性。在 4.2.4 节中,我们讨论了分布式训练的脆弱性;它要求每个工作节点表现一致。如果一个工作节点失败,整个训练集也会失败,这是对时间和预算的巨大浪费。数据科学家会非常感激我们在训练过程监控、故障转移和故障恢复上所付出的努力。
数据并行还是流水线并行?
现在我们知道有两种主要的分布式训练策略:数据并行和流水线并行。你可能对这些概念有所了解,但可能仍然不确定何时使用它们。
我们建议始终从单个机器上进行模型训练。如果你有一个大型数据集并且训练时间很长,那么考虑分布式训练。我们总是更喜欢使用数据并行而不是流水线并行,仅仅因为数据并行的实现更简单,我们可以更快地得到结果。如果模型太大无法加载到一个 GPU 中,那么流水线并行是正确的选择。
摘要
- 分布式训练有两种思路:数据并行和模型并行。流水线并行是模型并行的改进版。
- 如果一个模型可以加载到一个 GPU 中,数据并行是实现分布式训练的主要方法;它易于使用并提供了很大的速度改进。
- 使用 Kubernetes 来管理计算集群可以大大减少计算资源管理的复杂性。
- 尽管每个训练框架(TensorFlow、PyTorch)提供了不同的配置和 API 来编写分布式训练代码,但它们的代码模式和执行流程非常相似。因此,训练服务可以采用统一的方法支持各种分布式训练代码。
- 在封装各种训练框架的设置配置后,即使在分布式训练环境中,训练服务仍然可以将训练代码视为黑盒处理。
- 要获取数据并行训练的进展/状态,您只需检查主工作器,因为所有工作器始终彼此同步。此外,为避免在训练工作完成后从所有工作器保存重复的模型,您可以设置训练代码,以确保仅在主工作器执行时保存模型和检查点文件。
- Horovod 是一个优秀的分布式训练框架。它提供了一种统一的方法来运行在各种框架(如 PyTorch、TensorFlow、MXNet 和 PySpark)中编写的分布式训练代码。如果训练代码使用 Horovod 实现分布式训练,训练服务可以使用一种方法(Horovod 方法)来执行它,而不管它是用哪个训练框架编写的。
- 可用性、弹性和故障恢复是分布式训练的重要工程问题。
- 针对无法放入一个 GPU 的模型,有两种训练策略:节省内存的方法和模型并行的方法。
- 节省内存的方法每次只将模型的一部分或小批量数据加载到 GPU 上——例如,梯度累积和内存交换。这些方法易于实施,但会减慢模型的训练过程。
- 模型并行的方法将一个大型模型划分为一组子神经网络,并将它们分布到多个 GPU 上。这种方法的缺点是 GPU 利用率较低。为了克服这个问题,发明了流水线模型并行。
第五章:超参数优化服务
本章内容包括
- 超参数及其重要性
- 超参数优化(HPO)的两种常见方法
- 设计一个 HPO 服务
- 三个流行的 HPO 库:Hyperopt、Optuna 和 Ray Tune
在前两章中,我们看到了模型是如何训练的:一个训练服务管理着远程计算集群中的训练过程,并提供给定的模型算法。但模型算法和训练服务并不是模型训练的全部。还有一个组成部分我们尚未讨论过——超参数优化(HPO)。数据科学家经常忽视这样一个事实,即超参数选择可以显着影响模型训练结果,特别是当这些决策可以使用工程方法自动化时。
超参数是必须在模型训练过程开始之前设置其值的参数。学习率、批量大小和隐藏层数量都是超参数的示例。与模型参数的值(例如权重和偏置)不同,超参数在训练过程中不能被学习。
研究表明,超参数的选择值可以影响模型训练的质量以及训练算法的时间和内存要求。因此,必须调整超参数以使其对模型训练最优。如今,HPO 已经成为深度学习模型开发过程中的标准步骤。
作为深度学习组件之一,HPO 对软件工程师非常重要。这是因为 HPO 不需要对深度学习算法有深入的理解,所以工程师经常被分配到这项任务。大多数情况下,HPO 可以像黑盒子一样运行,训练代码不需要修改。此外,工程师有能力构建一个自动 HPO 机制,使 HPO 成为可能。由于要调整的超参数(学习率、epoch 数量、数据批量大小等)以及要尝试的值太多,手动调整每个超参数值是不现实的。软件工程师非常适合创建一个自动化系统,因为他们在微服务、分布式计算和资源管理方面有着丰富的经验。
在本章中,我们将重点介绍自动 HPO 的工程。我们首先介绍了与使用 HPO 工作所需的背景信息。我们深入了解超参数及其调整或优化过程。我们还将遇到一些流行的 HPO 算法,并比较了两种自动化 HPO 的常见方法:使用库和构建服务。
然后我们将开始设计。我们将看看如何设计一个 HPO 服务,包括创建 HPO 服务的五个设计原则,以及在此阶段特别重要的一个通用设计提案。最后,我们向您展示三个流行的开源 HPO 框架,如果您想在本地优化训练代码,这些框架将是完美的选择。
不同于之前的章节,本章我们不会构建一个全新的示例服务。相反,我们建议您使用开源的 Kubeflow Katib(见附录 C 中讨论)。Katib 是一个设计良好、可扩展且高度可移植的 HPO 服务,几乎可以用于任何 HPO 项目。因此,如果对您来说这是一个低成本的解决方案,我们就不需要再构建一个了。
本章应该为您提供 HPO 领域的全面视角,同时还为您提供了如何针对您的具体需求运行 HPO 的实用理解。无论您决定使用远程服务还是在本地机器上使用 Hyperopt、Optuna 或 Ray Tune 等库/框架来运行 HPO,我们都可以为您提供支持。
5.1 理解超参数
在我们学习如何调整超参数之前,让我们更清晰地了解一下超参数是什么以及它们为什么重要。
5.1.1 什么是超参数?
训练深度学习模型的过程使用两种类型的参数或数值:模型参数和超参数。模型参数是可训练的,也就是说,在模型训练过程中它们的值是学习到的,并且随着模型的迭代而改变。相比之下,超参数是静态的;这些配置在训练开始之前就已经被定义和设置好了。例如,我们可以在输入参数中将训练时期设置为 30,并将神经网络的激活函数设置为 ReLU(修正线性单元)来启动模型训练过程。
换句话说,任何影响模型训练性能但无法从数据中估计的模型训练配置都是超参数。一个模型训练算法中可能有数百个超参数,包括例如模型优化器的选择—ADAM(见“Adam: A Method for Stochastic Optimization,” by Diederik P. Kingma and Jimmy Ba; arxiv.org/abs/1412.6980
)或 RMSprop(见“A Look at Gradient Descent and RMSprop Optimizers,” by Rohith Gandhi; mng.bz/xdZX
)—神经网络中的层数、嵌入维度、小批量大小和学习率。
5.1.2 超参数为什么重要?
超参数的值选择对模型训练结果有巨大影响。通常手动设置,这些值控制着训练算法执行的行为,并确定模型训练的速度和模型的准确度。
要亲自看到这种效果,您可以通过在 TensorFlow playground(playground.tensorflow.org
)中运行模型训练来尝试不同的超参数值。在这个在线游乐场中,您可以设计自己的神经网络,并训练它以识别四种类型的图案。通过设置不同的超参数,比如学习率、正则化方法、激活函数、神经网络层数和神经元数量,您不仅会看到模型性能的变化,还会看到学习行为的变化,比如训练时间和学习曲线。要在这个游乐场中训练一个能够识别复杂数据模式(如螺旋形)的模型,我们需要非常小心地选择超参数。例如,尝试将隐藏层数量设置为 6,每层神经元数量设置为 5,激活函数设置为ReLU
,数据批量大小设置为 10,正则化方法设置为L1
。经过近 500 个 epochs 的训练,您会发现该模型可以对螺旋形图表进行准确的分类预测。
在研究领域,超参数选择对模型性能的影响早已有据可查。以自然语言处理嵌入训练为例。一篇论文,“Improving Distributional Similarity with Lessons Learned from Word Embeddings,”由 Levy 等人(aclanthology.org/Q15-1016.pdf
)撰写,揭示了词嵌入的许多性能增益都归因于某些系统设计选择以及 HPO(Hyperparameter Optimization,超参数优化)而不是嵌入算法本身。在 NLP 嵌入训练中,这些作者发现超参数的选择比训练算法的选择更具影响力!因为超参数选择对模型训练性能非常关键,所以超参数调整现在已经成为模型训练过程中的标准步骤。
5.2 理解超参数优化
现在您已经对超参数是什么以及它们为何对模型训练如此重要有了坚实的理解,让我们转向优化这些超参数的过程。在本节中,我们将为您介绍 HPO 的步骤。我们还将看看用于优化超参数的 HPO 算法,以及执行 HPO 的常见方法。
5.2.1 什么是 HPO?
HPO,或调整,是发现一组产生最佳模型的超参数的过程。这里的最佳意味着在给定数据集上最小化预定义损失函数的模型。在图 5.1 中,您可以看到 HPO 在模型训练过程中的通用工作流程的高级视图。
图 5.1 这个 HPO 工作流程的高级视图显示,该过程本质上是一个实验,旨在找到最佳的超参数值。
从图 5.1. 可以看出,HPO 工作流程可以被可视化为一个由四个步骤构成的循环。它向我们展示了 HPO 过程是一个重复的模型训练过程,只是每次神经网络都会使用不同的超参数集进行训练。在这个过程中将发现最优的超参数集。我们通常将每次模型训练的运行称为“试验”。整个 HPO 实验是一个试验循环,在此循环中我们运行一个试验接着运行另一个试验,直到满足结束条件。
注意为了公正评估,每个 HPO 试验都使用相同的数据集。
每次试验分为四个步骤,如图 5.1. 所示。第一步是使用一组超参数值训练神经网络。第二步是评价训练输出(模型)。
在第 3 步中,HPO 过程检查是否已满足结束条件,例如是否已用完试验预算,或者是否在此试验中产生的模型已达到我们的性能评估目标。如果试验结果满足结束条件,则试验循环中断,实验结束。产生最佳模型评估结果的超参数值被视为最优超参数。
如果未满足结束条件,则过程进入步骤 4:HPO 过程将产生一组新的超参数值,并通过触发模型训练运行来开始一个新的试验。每个试验中使用的超参数值可以通过手动或自动由 HPO 算法生成。让我们在接下来的两个部分中更详细地看看这两种方法和 HPO 算法。
手动 HPO
作为数据科学家,我们经常手动选择超参数值来运行图 5.1. 中的 HPO 过程。尽管,可以承认的是,手动选择最佳超参数值更像是即兴表演而不是科学。但是我们也在借鉴我们的经验以及从其中获得的直觉。我们通常会使用经验性的超参数值开始模型训练,例如在相关的已发表论文中使用的值,然后进行一些微小的调整并测试模型。经过几次试验后,我们手动比较模型性能并从这些试验中选择表现最佳的模型。图 5.2. 演示了这个工作流程。
图 5.2. 手动选择超参数值可能会很繁琐且耗时。
手动 HPO 的最大问题在于我们不知道我们的超参数值是否最佳,因为我们只是选择一些经验值并对其进行微调。为了获得最优值,我们需要尝试所有可能的超参数值,也就是搜索空间。在图 5.2 的示例中,我们想要优化两个超参数:学习速率和数据集批处理大小。在 HPO 过程中,目标是找到产生最佳模型的 batch_size
和 learning_rate
对。假设我们将 batch_size
的搜索空间定义为 {8, 16, 32, 64, 128, 256},并将 learning_rate
的另一个搜索空间定义为 {0.1, 0.01, 0.001, 0.5, 0.05, 0.005}。那么我们需要验证的超参数值的总数是 36(62)。
因为我们要手动运行 HPO,我们必须运行模型训练过程(HPO 试验)36 次,并记录每个试验中使用的模型评估结果和超参数值。完成所有 36 次试验并比较结果后,通常是模型准确率,我们找到了最佳的 batch_size
和 learning_rate
。
手动运行整个超参数搜索空间的 HPO 可能会耗时、容易出错且繁琐,正如你所见。此外,深度学习超参数通常具有复杂的配置空间,通常由连续、分类和条件超参数的组合以及高维度组成。目前,深度学习行业正在向自动 HPO 迈进,因为手动 HPO 简单不可行。
自动 HPO
自动 HPO 是利用计算能力和算法自动找到训练代码的最佳超参数的过程。这一想法是使用高效的搜索算法在没有人类干预的情况下发现最佳超参数。
我们还希望自动 HPO 以黑盒方式运行,因此它对其正在优化的训练代码无知,因此我们可以轻松地将现有的模型训练代码引入到 HPO 系统中。图 5.3 显示了自动化的 HPO 工作流程。
图 5.3 自动化的 HPO 工作流程
在第 1 步,数据科学家向自动 HPO 系统提交 HPO 请求,该系统以黑盒方式运行 HPO 过程(图 5.3)。他们将要优化的超参数及其值搜索空间输入黑盒(图 5.3 中的“自动 HPO”框)–例如,学习速率的搜索空间可能是 [0.005, 0.1],数据集批处理大小的搜索空间可能是 {8, 16, 32, 64, 128, 256}。数据科学家还需要配置训练执行,例如训练代码;评估方法;退出目标;以及试验预算,比如这次实验总共 24 次试验。
一旦用户提交了 HPO 请求,HPO 实验(步骤 2)就会开始。HPO 系统安排所有试验并管理其训练执行;它还运行 HPO 算法为每个试验生成超参数值(从搜索空间中挑选值)。当试验预算用完或达到训练目标时,系统会返回一组最优的超参数值(步骤 3)。
自动 HPO 依赖于两个关键组件:HPO 算法和试验训练执行管理。使用高效的 HPO 算法,我们可以使用更少的计算资源找到最优的超参数值。通过使用复杂的训练管理系统,数据科学家可以在整个 HPO 过程中无需手动操作。
注意:由于手动 HPO 的低效性,自动 HPO 是主流方法。为了简洁起见,在本章的其余部分中,我们将使用术语 HPO 来指代“自动超参数优化”。
5.2.2 流行的 HPO 算法
大多数 HPO 算法可以归类为三个桶:无模型优化、贝叶斯优化和多途径优化。
注意:因为本章的主要目标是教授 HPO 工程,所以这里讨论的 HPO 算法将保持在高级别。本节的目标是为您提供足够的 HPO 算法背景知识,以便能够构建或设置 HPO 系统。如果您想了解算法背后的数学推理,请查看 AutoML: Methods, Systems, Challenges 一书的第一章“Hyperparameter Optimization”,作者是 Matthias Feurer 和 Frank Hutter (mng.bz/AlGx
),以及 Bergstra 等人的论文“Algorithms for Hyper-Parameter Optimization” (mng.bz/Zo9A
)。
无模型优化方法
在无模型方法中,数据科学家不对训练代码做任何假设,并忽略 HPO 试验之间的相关性。网格搜索和随机搜索是最常用的方法。
在网格搜索中,用户为每个超参数指定了一组有限的值,然后从这些值的笛卡尔积中选择试验超参数。例如,我们可以首先指定学习率的值集(搜索空间)为{0.1, 0.005, 0.001},数据批量大小为{10, 40, 100},然后用这些集合的笛卡尔积(作为网格值)构建网格,例如 (0.1, 10),(0.1, 40),和 (0.1, 100)。构建网格后,我们可以使用网格值开始 HPO 试验。
当超参数数量变大或参数的搜索空间变大时,网格搜索会遇到困难,因为在这种情况下所需的评估数量会呈指数增长。网格搜索的另一个问题是其效率低下。因为网格搜索将每组超参数候选视为相等,所以它会在非最优配置空间浪费大量计算资源,而在最优空间上则没有足够的计算资源。
随机搜索通过在超参数配置空间中随机采样,直到搜索的某个预算用尽为止来工作。例如,我们可以将学习速率的搜索空间设置为[0.001, 0.1],数据批量大小设置为[10, 100],然后将搜索预算设置为 100,这意味着它将运行总共 100 次 HPO 试验。在每次试验中,会在 0.001 和 0.1 之间随机选择一个值作为学习速率,并在 10 和 100 之间随机选择一个值作为数据批量大小。
此方法比网格搜索有两个优点。首先,随机搜索可以评估每个超参数的更多值,这增加了找到最优超参数集的机会。其次,随机搜索具有更简单的并行化要求;因为所有评估工作者可以完全并行运行,它们无需彼此通信,并且失败的工作者不会在搜索空间中留下空缺。但是在网格搜索中,失败的工作者可以跳过分配给 HPO 工作者的试验超参数。
随机搜索的缺点是不确定性;不能保证在有限的计算预算内找到最优的超参数集。理论上,如果我们允许足够的资源,随机搜索可以在搜索中添加足够的随机点,因此它将如预期地找到最优超参数集。在实践中,随机搜索被用作基线。
图 5.4 网格搜索和随机搜索的比较,以最小化具有一个重要参数和一个不重要参数的函数。(来源:Matthias Feurer 和 Frank Hutter 的“超参数优化”的图 1.1。在AutoML: Methods, Systems, Challenges中,由 Frank Hutter,Lars Kotthoff 和 Joaquin Vanschoren 编辑; Springer, 2019. www.automl.org/wp-content/uploads/2019/05/AutoML_Book_Chapter1.pdf)
图 5.4 展示了网格搜索和随机搜索之间的比较。网格搜索中的试验超参数候选项(黑色点)是重要参数值(行中)和不重要值点(列中)的笛卡尔积。它们的分布可以看作是搜索空间中的一个网格(白色方形画布)。随机搜索算法从搜索空间中随机获取超参数候选项。当给定足够的搜索预算时,其搜索点更有可能接近最优位置。
基于模型的贝叶斯优化
贝叶斯优化是一种用于全局优化昂贵黑箱函数的最先进的优化框架。它广泛应用于各种问题设置,例如图像分类,语音识别和神经语言建模。
贝叶斯优化方法可以使用不同的采样器,例如高斯过程回归 (见“高斯过程回归的直观教程”,Jie Wang; arxiv.org/abs/2009.10862
) 和基于树结构的 Parzen 估计方法 (TPE),来计算搜索空间中的超参数候选。简单来说,贝叶斯优化方法使用统计方法根据过去试验中使用的值及其评估结果计算新的超参数值建议。
注意 为什么叫贝叶斯优化?贝叶斯分析 (www.britannica.com/science/Bayesian-analysis
) 是一种广泛使用的统计推断方法,以英国数学家托马斯·贝叶斯 (www.britannica.com/biography/Thomas-Bayes
) 命名,它允许您将有关总体参数的先验信息与样本中包含的信息的证据相结合,以指导统计推断过程。基于这种方法,乔纳斯·莫库斯 (Jonas Mockus) 在他在 1970 年代和 1980 年代的全局优化工作中引入了贝叶斯优化 (见“贝叶斯线性回归”,布鲁娜·温德瓦尔德; www.researchgate.net/publication/333917874_Bayesian_Linear_Regression
) 这个术语。
贝叶斯优化方法背后的概念是,如果算法能够从过去的试验中学习,那么寻找最佳超参数的过程将更加高效。在实践中,贝叶斯优化方法可以通过较少的评估运行(试验)找到最佳超参数集,并且比其他搜索方法更稳定。图 5.5 显示了随机搜索和贝叶斯方法之间的数据采样差异。
图 5.5 随机搜索 (a) 和贝叶斯方法 (b) 的数据采样器比较,使用 10 次试验
假设最佳超参数值在 (x,y) = (0.5, 1),我们试图使用随机搜索和贝叶斯搜索找到它。在图 5.5 (a) 中,我们看到数据在搜索空间中随机抽样,其中 x := [–1.0, 1.0],y := [1, 5]。在图 5.5 (b) 中,我们看到数据在区域 (x := [0.3, 0.7],y := [1,1.5]) 中密集抽样,最佳值位于该区域。这种比较表明,在给定的搜索空间中,贝叶斯搜索更有可能找到最佳超参数,并且在有限的执行预算下,选择的(抽样的)超参数值在搜索过程中的每次实验后越来越接近最佳值。
还有其他先进的超参数优化算法,例如 Hyperband(mng.bz/Rlwv
)、TPE(mng.bz/2a6a
)和协方差矩阵适应进化策略(CMA-ES;mng.bz/1M5q
)。尽管它们不完全遵循与贝叶斯-高斯过程方法相同的数学理论,但它们共享相同的超参数选择策略:通过考虑历史评估结果来计算下一个建议的值。
多信度优化
多信度方法提高了无模型和贝叶斯优化方法的效率。如今,在大型数据集上调整超参数可能需要几个小时甚至几天。为了加速超参数优化,开发了多信度方法。采用这种方法,我们使用实际损失函数的所谓低信度近似来最小化损失函数。因此,在超参数优化过程中我们可以跳过很多计算。
在机器学习的背景下,损失函数(www.datarobot.com/blog/introduction-to-loss-functions/
)是评估训练算法对数据集建模效果的一种方法。如果模型输出(预测)与期望结果相差很远,损失函数应该输出较高的数字;否则,应该输出较低的数字。损失函数是机器学习算法开发的关键组成部分;损失函数的设计直接影响模型准确性。
尽管这种近似方法在优化性能和运行时间之间引入了一种权衡,但在实践中,加速往往超过了近似误差。有关更多详细信息,请参阅 Matthias Feurer 和 Frank Hutter 的“超参数优化”(www.automl.org/wp-content/uploads/2019/05/AutoML_Book_Chapter1.pdf)。
贝叶斯式超参数优化算法为什么有效?
Michael McCourt 的博客文章“高斯过程背后的直觉”(sigopt.com/blog/intuition-behind-gaussian-processes/
)对为什么贝叶斯优化算法可以在不检查搜索空间中的每个可能值的情况下找到最佳超参数集提供了很好的解释。在某些情况下,我们观察到的实验是独立的,例如抛硬币 50 次;一个的知识并不意味着对其他的了解。但是,幸运的是,许多情况具有更有用的结构,从中以往的观察结果能够提供对未观察到的结果的见解。
在机器学习的背景下,我们假设历史实验(训练试验)结果与未来实验结果之间存在某种关系。更具体地说,我们相信存在一个数学模型来描述这种关系。虽然使用贝叶斯方法——例如,高斯过程——来建模这种关系是一个非常强的假设,但我们得到了强大的力量来做出可证明的最优预测。一个额外的好处是,我们现在有一种处理模型预测结果不确定性的方法。
注意 如果您有兴趣将贝叶斯优化应用于深度学习项目,Quan Nguyen 的书籍 贝叶斯优化实战(Manning, 2022; www.manning.com/books/bayesian-optimization-in-action
)是一个很好的资源。
哪种 HPO 算法效果最好?
没有单一的 HPO 算法最好。不同的优化算法可能适用于不同的调优任务,在不同的约束条件下。其中一些变量可能包括搜索空间的外观(例如,超参数类型、值范围)、试验预算的外观以及目标是什么(最终最优性或随时最优性能)。图 5.6 显示了来自 Optuna (optuna.org/
) HPO 框架的 HPO 算法选择指南。
图 5.6 来自 Optuna HPO 框架的 HPO 算法选择备忘单
在图 5.6 中,我们看到一个关于何时使用以下三种 HPO 算法的决策图:高斯过程、TPE 和 CMA-ES。由于 HPO 是一个快速发展的领域,新的高效算法随时可能被发布,因此像这样的算法选择备忘单将很快过时。例如,FLAML (github.com/microsoft/FLAML
) 是一个新开发的 Python HPO 库,它在 HPO 过程中检查超参数之间的相关性;它绝对值得一试。因此,请咨询您的数据科学团队以获取最新的 HPO 算法选择指南。
注意 HPO 算法不是 HPO 工程的主要关注点。HPO 算法背后的数学可能会让人望而生畏,但幸运的是,这不是工程师的重点。通常,确定要为某个特定训练任务使用哪种 HPO 算法是数据科学家的工作。作为工程师,我们的角色是构建一个灵活、可扩展的黑盒式 HPO 系统,以便数据科学家可以轻松地使用任意 HPO 算法运行其模型训练代码。
5.2.3 常见的自动 HPO 方法
幸运的是,今天已经存在许多成熟的框架和系统用于进行 HPO。根据使用情况,它们分为两种不同的类别:HPO 库方法和 HPO 服务方法。图 5.7 说明了这两种方法。现在让我们逐一讨论它们。
HPO 库方法
在图 5.7(a)中,库方法,我们看到数据科学家自己管理 HPO 过程,从编码到执行。他们使用 HPO 库(例如 Hyperopt——一个开源的 Python HPO 库)编写整个 HPO 流程,并将其与训练代码一起集成到一个训练应用程序中。接下来,数据科学家在本地计算机或直接访问的服务器上运行此应用程序。应用程序内的 HPO 库将执行我们在图 5.3 中看到的 HPO 工作流。
图 5.7 两种不同的 HPO 方法:库 vs 服务。(a)HPO 库可以在本地计算机或经过预配置的服务器组上运行 HPO 实验(训练);(b)HPO 服务可以以完全远程和自动化的方式运行 HPO 实验。
灵活性和敏捷性是库方法的最大优势;你可以选择任何你喜欢的 HPO 算法/库,将它们集成到你的训练代码中,立即开始 HPO 过程,因为一切(训练加上超参数计算)都发生在你的本地计算机上。一些 HPO 库——例如 Ray Tune(5.4.3 节)——也支持并行分布式执行,但不是完全自动化的。这需要设置一个具有特定软件的分布式计算组,允许跨计算机通信,并且需要在每台服务器上手动启动并行过程。
库方法面临的最大挑战是可扩展性、可重用性和稳定性。HPO 需要大量的计算资源来执行其试验,因此单个服务器通常无法执行 HPO。即使具有分布功能,它仍然无法扩展。想象一下,我们想要使用 20 台服务器进行需要 10,000 次试验的 HPO 任务;我们需要在 20 台服务器上手动设置 HPO 过程,并在每次训练或 HPO 代码更改时重新设置。此外,如果 20 个并行工作中的 1 个失败,整个 HPO 工作组都会停止。为了解决这些问题,引入了 HPO 服务方法。
HPO 服务方法
现在让我们更仔细地看看 HPO 服务方法;我们为清晰起见重复图 5.7,这里呈现为图 5.8。在图 5.8(b)中,服务方法,我们看到 HPO 发生在一个远程计算集群中,由一个服务——HPO 服务管理。数据科学家只向服务提供训练代码和选定的 HPO 算法配置,并启动 HPO 作业。该服务管理计算资源分配和 HPO 工作流程(图 5.3)的执行;它跟踪每个试验的结果(模型性能指标,例如准确性),并在所有试验完成时向数据科学家返回最终的最佳超参数。
图 5.8 两种不同的 HPO 方法:库 vs 服务
该服务方法提供了真正的黑盒体验。数据科学家无需担心管理自己的服务器、设置试验工作者,以及学习如何修改训练代码以适应不同的 HPO 算法。HPO 服务会处理所有这些任务。作为 HPO 服务的用户,我们只需将参数传递给服务,然后服务会自动运行 HPO 并在最后返回最优超参数。该服务还负责自动缩放和失败试验作业的故障恢复。由于这些优点,服务方法现在是深度学习生产环境中的主导 HPO 方法。由于您现在熟悉了 HPO 的概念和方法,让我们在接下来的两节中看看如何设计 HPO 服务以及如何使用 HPO 库。
注意 HPO 不是一次性工作。如果使用不同的数据集进行训练,即使模型架构没有改变,您也需要重新进行 HPO。如果数据集发生变化,最优模型权重集也会发生变化,因此您需要进行新的 HPO 搜索工作。
5.3 设计一个 HPO 服务
现在你已经对 HPO 库方法有了很好的理解,让我们来回顾一下 HPO 服务方法。在这一节中,我们将看看如何设计一个 HPO 服务,以支持对任意模型训练进行自动和黑盒方式的 HPO。
5.3.1 HPO 设计原则
在我们查看具体的设计方案之前,让我们先来了解一下构建 HPO 服务的五个设计原则。
原则 1:训练代码不可知
HPO 服务需要对训练代码和模型训练框架保持不可知。除了支持像 TensorFlow、PyTorch 和 MPI 这样的任意机器学习框架之外,我们希望该服务能够调整任何编程语言编写的训练代码的超参数。
原则 2:在支持不同 HPO 算法方面具有可扩展性和一致性
从第 5.2.2 节的 HPO 算法讨论中,我们知道超参数搜索算法是 HPO 过程的核心。超参数搜索的效率决定了 HPO 的性能。一个好的 HPO 算法可以在少量试验中找到大量超参数和任意搜索空间的最优超参数。
由于 HPO 算法研究是一个活跃的领域,每隔几个月就会发表一个新的有效算法。我们的 HPO 服务需要轻松集成这些新算法,并将它们作为算法选项暴露给客户(数据科学家)。此外,新添加的算法在用户体验方面应该与现有算法保持一致。
原则 3:可扩展性和容错性
除了 HPO 算法之外,HPO 服务的另一个重要责任是管理用于 HPO 的计算资源——具有各种超参数值的模型训练。从 HPO 实验的角度来看,我们希望在实验级别和试验级别进行分布式执行。更具体地说,我们不仅希望以分布式和并行的方式运行试验,还希望能够以分布式方式运行单个训练试验——例如,在一个试验中进行模型训练的分布式训练。从资源利用的角度来看,系统需要支持自动缩放,以使计算集群大小能够根据当前工作负载自动调整,从而不会出现资源的过度或不足利用。
容错性也是 HPO 试验执行管理的另一个重要方面。容错性很重要,因为一些 HPO 算法需要按顺序执行试验。例如,试验 2 必须在试验 1 之后进行,因为算法需要过去的超参数值和结果来推断下一个试验开始前的超参数。在这种情况下,当一个试验意外失败——例如,由于节点重新启动或网络问题——整个 HPO 过程都会失败。系统应自动从之前的故障中恢复。常见的方法是记录每个试验的最新状态,这样我们就可以从上次记录的检查点继续恢复。
原则 4:多租户性
HPO 过程本质上是一组模型训练执行。与模型训练类似,HPO 服务必须为各种用户或组提供资源隔离。这将确保不同的用户活动保持在其边界内。
原则 5:可移植性
如今,“云中立”概念变得非常流行。人们希望在不同的环境中运行他们的模型训练工作——亚马逊网络服务、谷歌云平台和 Azure——因此我们构建的 HPO 服务需要与基础架构解耦。在这里,使用 Kubernetes 运行 HPO 服务是一个不错的选择。
5.3.2 一般 HPO 服务设计
因为 HPO 工作流程(图 5.3)非常标准且变化不大,所以 HPO 服务系统设计(图 5.9)可以应用于大多数 HPO 场景。它由三个主要组件组成:API 接口、HPO 作业管理器和超参数(HP)建议生成器。(它们在图 5.9 中分别标记为 A、B 和 C。)
图 5.9 HPO 服务的一般系统设计
API 接口(组件 A)是用户提交 HPO 作业的入口点。要启动 HPO 实验,用户向接口提交 API 请求(步骤 1);请求提供模型训练代码,如 Docker 镜像;超参数及其搜索空间;以及 HPO 算法。
HP 建议制定者(组件 C)是不同 HPO 算法的包装器/适配器。它为用户运行每个不同的 HPO 算法提供了一个统一的接口,因此用户可以选择算法而不必担心执行细节。要添加新的 HPO 算法,必须在此建议制定者组件中注册它,以成为用户的算法选项。
HPO 作业管理器(组件 B)是 HPO 服务的核心组件;它管理客户请求的 HPO 实验。对于每个 HPO 请求,作业管理器启动一个 HPO 试验循环(步骤 2)。在循环中,它首先调用 HP 建议制定者来获得建议的超参数值集合(步骤 2.a),然后创建一个试验以使用这些超参数值运行模型训练(步骤 2.b 和 2.c)。
对于每个训练试验,HPO 作业管理器都会创建一个试验对象。该对象有两个职责:首先,它收集试验执行的输出,例如训练进度、模型指标、模型准确性和尝试的超参数;其次,它管理训练过程。它处理训练过程的启动、分布式训练设置和失败恢复。
HPO 服务的端到端执行流程
让我们按照图 5.9 显示的端到端用户工作流程来走一遍。为了方便起见,我们重复了图 5.9 并将其显示为图 5.10。
图 5.10 HPO 服务的一般系统设计
首先,用户向 API 接口提交 HPO 请求(步骤 1)。该请求定义了训练代码、超参数及其值搜索空间的列表、训练目标和一个 HPO 算法。然后,HPO 作业管理器为该请求启动 HPO 试验循环(步骤 2)。该循环启动一组试验来确定哪组超参数值最好。最后,当试算预算用尽或一次试验达到训练目标时,试验循环会中断,最优超参数会被返回(步骤 3)。
在试验循环中,作业管理器首先查询 HP 建议制定者以推荐超参数候选项(步骤 2.a)。制定者将运行所选的 HPO 算法来计算一组超参数值,并将其返回给作业管理器(步骤 2.b)。然后,作业管理器创建一个试验对象,以使用建议的超参数值启动模型训练过程(步骤 2.c)。试验对象还将监视训练过程,并继续向试验历史数据库报告训练指标,直到训练完成(步骤 2.d)。当作业管理器注意到当前试验已完成时,它将拉取试验历史记录(试验指标和用于过去试验的超参数值)并将其传递给 HP 建议制定者以获得新的 HP 候选项(步骤 2.e)。
因为 HPO 的使用案例非常标准和通用,并且已经有多个开源的 HPO 项目可以直接使用,我们认为学习如何使用它们比重新构建一个没有附加值的新系统更好。因此,在附录 C 中,我们将介绍一个功能强大且高度可移植的基于 Kubernetes 的 HPO 服务——Kubeflow Katib。
深度学习系统设计(二)(3)https://developer.aliyun.com/article/1517013