神经网络的一份训练配方
本文转载于 Karpathy 写的 A Recipe for Training Neural Networks,被誉为“炼丹师圣经”。
几周前,我在 Twitter 上发布了一条关于“最常见的神经网络错误”的推文,列出了一些与神经网络训练相关的常见陷阱。这条推文获得的互动比我预想的要多得多(甚至还包括一场网络研讨会 🙂)。
显然,很多人都亲身经历过这样一种巨大差距:“这里是卷积层如何工作的解释” 和 “我们的卷积网络达到了当前最先进(state of the art)的结果” 之间的差距。
因此,我觉得也许可以把我那尘封已久的博客翻出来,把那条推文扩展成它应得的长文形式。然而,我并不打算继续枚举更多常见错误,或者只是把它们详细展开,而是想稍微深入一点,讨论如何从根本上避免犯这些错误(或者至少能非常快地修复它们)。
做到这一点的关键,是遵循某种特定的过程。据我所知,这个过程并不常被系统性地记录下来。我们先从两个重要的观察开始,它们正是这个过程的动机来源。
1)神经网络训练是一种“有漏洞的抽象”
据说,开始训练神经网络是很容易的。大量库和框架都以展示 30 行的“奇迹代码片段”为荣,这些代码声称可以解决你的数据问题,从而给人一种(错误的)印象:这项技术是即插即用的。
你经常会看到类似这样的东西:
>>> your_data = # 把你超棒的数据集塞到这里
>>> model = SuperCrossValidator(SuperDuper.fit, your_data, ResNet50, SGDOptimizer)
# 在这里征服世界这些库和示例会激活我们大脑中那一部分熟悉于标准软件工程的区域——在那个世界里,干净的 API 和良好的抽象通常是可以实现的。
以 requests 库为例:
>>> r = requests.get('https://api.github.com/user', auth=('user', 'pass'))
>>> r.status_code
200这很酷!一位勇敢的开发者替你承担了理解查询字符串、URL、GET/POST 请求、HTTP 连接等一切复杂性的负担,并将它们大体上隐藏在了几行代码之后。这正是我们所熟悉、也所期望的事情。
不幸的是,神经网络完全不是这样。
只要你稍微偏离“训练一个 ImageNet 分类器”这个场景,神经网络就不再是“现成可用”的技术了。
我曾在我的文章《Yes you should understand backprop》中试图阐明这一点,我当时以反向传播为例,称它是一种“有漏洞的抽象(leaky abstraction)”,但现实情况要糟糕得多。
反向传播 + SGD 并不会神奇地让你的网络正常工作。Batch Normalization 并不会神奇地让它更快收敛。RNN 并不会神奇地让你“随便把文本塞进去”。而且,仅仅因为你可以把问题表述成强化学习,并不意味着你就应该这么做。
如果你坚持在不了解其工作原理的情况下使用这些技术,你很可能会失败。
这就引出了第二个观察。
2)神经网络训练会“悄无声息地失败”
当你破坏或错误配置传统代码时,通常会得到某种异常提示:
- 你把一个整数传给了一个期望字符串的地方;
- 函数只接受 3 个参数;
- 导入失败;
- 某个 key 不存在;
- 两个列表中的元素数量不相等。
此外,通常还可以为某些功能编写单元测试。而在神经网络训练中,这些还只是开始。
一切在语法上都可能是正确的,但整体的组织方式却是错误的,而且这一点非常难以察觉。
“可能出错的空间”非常巨大,这些错误往往是逻辑层面的(而非语法层面的),而且极难通过单元测试发现。
- 例如,也许你在进行左右翻转的数据增强时,忘记同步翻转标签。你的网络仍然(令人震惊地)可以工作得相当不错,因为它可以在内部学会检测图像是否被翻转,然后再对预测结果进行一次左右翻转。
- 或者,你的自回归模型由于一个 off-by-one 的错误,不小心把它试图预测的量当成了输入。
- 或者,你本想裁剪梯度,却误裁剪了 loss,从而导致异常样本在训练过程中被忽略。
- 或者,你从一个预训练的 checkpoint 初始化了权重,但却没有使用原始的均值进行归一化。
- 或者,你只是把正则化强度、学习率、学习率衰减率、模型规模等参数配置错了。
因此,你配置错误的神经网络只有在你运气好的时候才会抛出异常;大多数时候,它会正常训练,但悄无声息地表现得差一点。
正因为如此(而这一点真的怎么强调都不为过),“快而猛烈”的神经网络训练方式是行不通的,只会带来痛苦。
当然,痛苦是让神经网络工作良好过程中完全正常的一部分,但它是可以被缓解的——方法是:彻底、谨慎、偏执,并且痴迷于几乎所有可能的可视化。
在我的经验中,与深度学习成功最强相关的品质是:耐心 和 对细节的关注。
配方
基于上述两个事实,我为自己制定了一套特定的流程,在将神经网络应用于一个新问题时,我都会严格遵循它。下面我将尝试对这一流程进行描述。
你会看到,这个流程非常严肃地对待前面提到的两个原则。特别是,它从简单开始,逐步走向复杂,并且在每一步中,我们都会对将要发生的事情提出具体假设,然后通过实验来验证这些假设,或者持续调查,直到找到问题所在。
我们竭力避免一次性引入大量“未经验证”的复杂性,因为那几乎注定会引入一些 bug 或错误配置,而它们要么会花费你极长时间才能找到,要么你永远也找不到。
如果编写神经网络代码像训练神经网络一样,你一定会使用一个非常小的学习率,并在每一次迭代之后对整个测试集进行评估。
1. 与数据融为一体
训练神经网络的第一步是:完全不要触碰任何神经网络代码,而是开始彻底检查你的数据。
这一步至关重要。
我喜欢花费大量时间(以小时计)浏览成千上万个样本,理解它们的分布,并寻找其中的模式。幸运的是,人脑在这方面非常擅长。
有一次,我发现数据中包含重复样本。另一次,我发现了损坏的图像或标签。我会寻找数据不平衡和偏差。
我通常也会关注自己在对数据进行分类时的思考过程,因为这会暗示我们最终应当探索何种类型的网络结构。例如:
- 局部特征是否已经足够,还是需要全局上下文?
- 变化有多大?以什么形式存在?
- 哪些变化是无关的,可以通过预处理消除?
- 空间位置是否重要,还是应该通过平均池化消除?
- 细节有多重要?图像可以被下采样到什么程度?
- 标签有多嘈杂?
此外,由于神经网络本质上是你的数据集的一种压缩或编译形式,你将能够通过查看网络的(错误)预测结果,理解这些预测可能来源于数据中的哪些部分。
如果你的网络给出了与你在数据中观察到的现象明显不一致的预测,那说明某些地方一定出了问题。
在获得定性认知之后,再写一些简单代码来搜索、筛选或排序你能想到的任何维度(例如:标签类型、标注大小、标注数量等),并对它们的分布以及各个维度上的离群点进行可视化,通常也是一个好主意。
尤其是离群点,几乎总是会揭示数据质量或预处理中的 bug。
2. 搭建端到端训练/评估骨架 + 做一些“愚蠢基线”
现在我们已经理解了数据,我们是不是就可以拿起我们超级华丽的 Multi-scale ASPP FPN ResNet,然后开始训练很强的模型了?当然不是。那就是通往痛苦的道路。
我们的下一步是搭建一个完整的训练 + 评估骨架,并通过一系列实验来建立对其正确性的信任。在这个阶段,最好选择某个你不可能在实现上搞错的简单模型——例如,一个线性分类器,或者一个非常小的卷积网络。我们会想要训练它、可视化 loss、可视化其他指标(例如 accuracy)、可视化模型预测,并沿途进行一系列带有明确假设的消融实验。
这个阶段的一些 tips & tricks:
- 固定随机种子:始终使用固定的随机种子,以保证当你运行两次代码时会得到相同的结果,这会移除一个变化因素,并帮助你保持理智。
- 简化:确保关闭所有不必要的花哨东西。例如,在这个阶段一定要关闭所有数据增强。数据增强是一种我们之后可能会加入的正则化策略,但现在它只是另一个引入愚蠢 bug 的机会。
- 在评估中增加有效数字(精度):当你绘制测试 loss 时,用整个(较大的)测试集来做评估。不要只在 batch 上绘制测试 loss,然后依赖 Tensorboard 的平滑功能。我们是在追求正确性,我们非常愿意为保持理智而牺牲时间。
- 验证初始化时的 loss:验证你的 loss 从正确的 loss 值开始。例如,如果你正确初始化了最后一层,那么在初始化时,你应该在 softmax 上测得 (-\log(1/n_classes))。对于 L2 回归、Huber loss 等也可以推导出相应的默认初始值。
- 良好初始化:正确初始化最后一层的权重。例如,如果你在回归某个均值为 50 的数,那么就把最终 bias 初始化为 50。如果你的数据集是 1:10 的正负样本不平衡,那么就设置 logit 的 bias,让网络在初始化时预测概率为 0.1。把这些设置对,会加快收敛,并消除那种“冰球杆(hockey stick)”式的 loss 曲线:在前几个迭代里,你的网络基本只是在学习 bias。
- 人类基线:除了 loss 之外,监控一些人类可解释、可核验的指标(例如 accuracy)。只要可能,就评估你自己(人类)的准确率,并与模型进行比较。或者,把测试数据标注两遍,并对每个样本把一遍标注当作预测,把另一遍标注当作真值。
- 输入无关基线:训练一个与输入无关的基线(最简单的方法是把所有输入设为 0)。这应该比你真正输入数据(不置零)时的表现更差。它确实更差吗?也就是说,你的模型到底有没有从输入中提取到任何信息?
- 过拟合一个 batch:过拟合单个 batch(只包含少量样本,例如最少两个)。为此,我们提高模型容量(例如增加层数或滤波器数量),并验证我们能达到最低可能的 loss(例如 0)。我也喜欢在同一张图里可视化标签与预测,确保当我们达到最小 loss 时它们能完全对齐。如果不能对齐,那么某处有 bug,我们不能进入下一阶段。
- 验证训练 loss 的下降:在这个阶段你很可能是在欠拟合,因为你用的是玩具模型。尝试稍微提高一点容量。你的训练 loss 是否如预期那样下降了?
- 在进入网络之前做可视化:唯一毫无歧义的可视化位置,是在
y_hat = model(x)(或 TensorFlow 里的sess.run)之前的那一刻。也就是说,你要可视化实际喂进网络的东西:把那一坨原始张量形式的数据与标签解码成图像/文本/曲线等可视化。这是唯一的“真相来源”。我数不清这一步救过我多少次,它能暴露数据预处理与数据增强中的问题。 - 可视化预测的动态过程:我喜欢在训练过程中,对一个固定的测试 batch 可视化模型预测。这些预测随时间变化的“动态”会给你极强的直觉,帮助你理解训练是如何推进的。很多时候,如果网络在某种意义上抖动得太厉害,你甚至能“感觉到”它在挣扎拟合数据,这往往揭示不稳定性。学习率太低或太高,也很容易从抖动程度上看出来。
- 用反向传播绘制依赖关系:你的深度学习代码通常包含复杂的、向量化的、广播的操作。我碰到过几次相对常见的 bug:人们把这些操作搞错了(例如某处用了
view而不是transpose/permute),结果无意中在 batch 维度上混合了信息。令人沮丧的是,你的网络通常仍然能训练得还行,因为它会学着忽略来自其他样本的数据。一种调试方法(以及对其他相关问题也适用的方法)是:把 loss 设成一个非常简单的东西,例如“第 i 个样本所有输出的总和”,然后一路反向传播到输入,并确保你只在第 i 个输入上得到非零梯度。同样的策略也可以用于,例如确保你的自回归模型在时间 t 时只依赖 1..t-1。更一般地说,梯度能告诉你网络里“什么依赖什么”,这对调试非常有用。 - 泛化一个特例:这更像是通用编码建议,但我经常看到人们在写一个相对通用的功能时“贪多嚼不烂”,从而引入 bug。我喜欢先写一个完全针对我当前任务的非常具体的函数,把它跑通,然后再逐步把它泛化,同时确保泛化后结果不变。这通常也适用于向量化代码:我几乎总是先写出完全的循环版本,然后才把它逐个循环地转成向量化代码。
3. 过拟合(Overfit)
到这里,我们应该已经对数据集有了较好的理解,并且训练 + 评估管线已经跑通。对于任意给定的模型,我们可以(可复现地)计算一个我们信任的指标。我们也已经掌握了输入无关基线的性能、一些“愚蠢基线”的性能(我们最好要超过它们),并且对人类大概能做到什么水平也有一个粗略感觉(我们希望能达到这一水平)。现在,迭代一个好模型的舞台已经搭好了。
我喜欢用两阶段的方法来寻找好模型:首先找到一个足够大的模型,使它能够过拟合(也就是说,专注于训练 loss);
然后对它进行适当正则化(牺牲一些训练 loss,提升验证 loss)。我之所以喜欢这两步,是因为如果我们无论如何都无法用任何模型达到低错误率,那可能再次表明存在问题、bug 或错误配置。
这个阶段的一些 tips & tricks:
- 选择模型:为了达到较低的训练 loss,你需要为数据选择合适的架构。在选择架构方面,我的第一条建议是:别当英雄(Don’t be a hero)。我见过很多人急于发挥创造力,把神经网络工具箱里的积木以他们认为“合理”的方式堆成各种奇特架构。在项目早期要强烈抵抗这种诱惑。我总是建议:找到最相关的论文,直接 copy paste 他们能取得良好性能的最简单架构。例如,如果你在做图像分类,别当英雄,你的第一次实验就直接 copy paste 一个 ResNet-50。之后你当然可以做更定制的东西,然后超越它。
- Adam 很安全:在建立基线的早期阶段,我喜欢用 Adam,并把学习率设为 3e-4。在我的经验里,Adam 对超参数更宽容,包括不太合适的学习率。对于 ConvNet,调得很好的 SGD 几乎总能略微优于 Adam,但其最优学习率区间更窄,并且更依赖具体问题。(注:如果你使用 RNN 或相关序列模型,更常用 Adam。项目初期同样别当英雄,照最相关论文的做法来。)
- 一次只复杂化一个因素:如果你有多个信号要输入到分类器,我建议你一个一个地加进去,并且每一次都确保你获得了你预期的性能提升。不要一开始就把“厨房水槽(kitchen sink)”全扔进模型里。还有其他增加复杂度的方式,例如先用更小的图像,之后再把图像变大,等等。
- 不要相信学习率衰减的默认设置:如果你在复用其他领域的代码,一定要非常小心学习率衰减。不仅不同问题需要不同衰减策略,更糟糕的是:典型实现里衰减往往基于当前 epoch 编号,而 epoch 编号会随着数据集大小变化很大。例如,ImageNet 会在第 30 个 epoch 把学习率乘以 0.1。如果你不是在训练 ImageNet,那么你几乎肯定不想这么做。如果你不小心,你的代码可能会在过早的时候把学习率偷偷衰减到接近 0,导致模型无法收敛。在我自己的工作中,我总是先完全禁用学习率衰减(使用恒定学习率),并把它放到最后再调。
4. 正则化(Regularize)
理想情况下,我们现在应该处于这样一个阶段:我们有一个足够大的模型,它至少能拟合训练集。现在该对它进行正则化,通过牺牲一些训练准确率来获得更好的验证准确率了。
一些 tips & tricks:
- 获取更多数据:首先,在任何实际场景中,对模型进行正则化最好的、也是最优先的方式,远远是:收集更多真实训练数据。一个非常常见的错误是:在一个小数据集上花大量工程时间去“挤牙膏”,而其实你完全可以去收集更多数据。据我所知,增加更多数据几乎是唯一一种能够在模型配置正确的前提下,几乎无限期地单调提升性能的方式。另一个方式是模型集成(如果你承担得起),但通常在大约 5 个模型之后就会达到收益上限。
- 数据增强:仅次于真实数据的,是“半假数据”——尝试更激进的数据增强。
- 创造性的增强:如果半假数据还不够,假数据也可能有用。人们正在发现扩展数据集的创造性方式;例如,域随机化、仿真、把(可能是仿真的)数据巧妙插入场景,甚至使用 GAN。
- 预训练:如果可以,使用预训练网络几乎从不吃亏,即使你有足够的数据也是如此。
- 坚持监督学习:不要对无监督预训练过度兴奋。与 2008 年那篇博客告诉你的不同,据我所知,它的任何版本都没有在现代计算机视觉中报告过非常强的结果(尽管 NLP 似乎在 BERT 等模型上做得很好,这很可能归功于文本更“刻意”的结构,以及更高的信噪比)。
- 更小的输入维度:移除可能包含虚假信号的特征。如果你的数据集很小,任何额外的虚假输入都只是另一个过拟合的机会。类似地,如果低层细节并不重要,就尝试输入更小的图像。
- 更小的模型规模:在许多情况下,你可以利用领域知识对网络施加约束,从而减小网络规模。例如,过去在 ImageNet 上使用 backbone 顶部的全连接层曾经很流行,但这些后来被简单的平均池化替代,从而消除了大量参数。
- 减小 batch size:由于 batch norm 内部的归一化,小 batch size 在某种程度上对应更强的正则化。这是因为 batch 的经验均值/方差是整体均值/方差的更粗糙近似,所以尺度与偏移会让你的 batch “抖动”得更厉害。
- drop(丢弃):加入 dropout。对 ConvNet 使用 dropout2d(空间 dropout)。谨慎/少量使用,因为 dropout 似乎与 batch normalization 不太兼容。
- weight decay:增大 weight decay 惩罚。
- early stopping:根据验证 loss 来停止训练,以捕捉模型刚要开始过拟合的时刻。
- 尝试更大的模型:我把这个放在最后,并且只在尝试 early stopping 之后才建议它,但我过去几次发现:更大的模型当然最终会更严重地过拟合,但它们的“early-stopped”性能往往会比更小的模型好得多。
最后,为了进一步增强对网络是一个合理分类器的信心,我喜欢可视化网络第一层的权重,并确保你能看到一些合理的边缘结构。如果你的第一层滤波器看起来像噪声,那么可能有问题。类似地,网络内部的激活有时也会显示奇怪的伪影,并提示问题。
5. 调参(Tune)
你现在应该已经处于这样一个状态:围绕你的数据集形成了一个闭环,在一个宽广的模型空间里探索能够达到低验证 loss 的架构。
这一步的一些 tips and tricks:
- 随机搜索优于网格搜索:当你要同时调多个超参数时,用网格搜索确保覆盖所有设置似乎很诱人,但要记住,最好用随机搜索。直观原因是:神经网络通常对某些参数极其敏感,而对另一些参数几乎不敏感。在极端情况下,如果参数 a 很重要而参数 b 的变化没有影响,那么你宁愿更充分地采样 a,而不是在少数固定点上重复多次。
- 超参数优化:有很多很花哨的贝叶斯超参数优化工具箱,我的一些朋友也报告过使用它们的成功。但我个人的经验是:探索一个足够宽的模型与超参数空间的最先进方法,是使用一个实习生 🙂。开玩笑的。
6. 榨干最后一点性能(Squeeze out the juice)
一旦你找到了最好的架构类型和超参数,你还可以用一些技巧把系统里最后的性能“挤出来”:
- 集成(ensembles):模型集成几乎是获得额外 2% 准确率的一个“必然”方式。如果你负担不起测试时的计算,研究一下如何把集成通过“暗知识(dark knowledge)”蒸馏到一个网络里。
- 让它继续训练:我经常看到人们在验证 loss 看起来趋于平稳时就想停掉训练。在我的经验中,网络会以出乎直觉的长时间继续变好。有一次,我不小心让一个模型在寒假期间一直训练,等我一月份回来时,它达到了 SOTA。
结论(Conclusion)
当你走到这里时,你就拥有了成功所需的全部要素:
- 你对技术、数据集和问题都有深入理解;
- 你搭建了完整的训练/评估基础设施,并对其准确性建立了高度信心;
- 你逐步探索越来越复杂的模型,并以你在每一步都能预测到的方式获得性能提升。
- 现在,你已经准备好去读大量论文、尝试大量实验,并拿到你的 SOTA 结果了。
祝你好运!