面向程序员的 FastAI 和 PyTorch 深度学习(九)
原文:Deep Learning for Coders With Fastai and Pytorch
译者:飞龙
第十四章:ResNets
在本章中,我们将在上一章介绍的 CNN 基础上构建,并向您解释 ResNet(残差网络)架构。它是由 Kaiming He 等人于 2015 年在文章“Deep Residual Learning for Image Recognition”中引入的,到目前为止是最常用的模型架构。最近在图像模型中的发展几乎总是使用残差连接的相同技巧,大多数时候,它们只是原始 ResNet 的调整。
我们将首先展示最初设计的基本 ResNet,然后解释使其性能更好的现代调整。但首先,我们需要一个比 MNIST 数据集更难一点的问题,因为我们已经在常规 CNN 上接近 100%的准确率了。
回到 Imagenette
当我们已经在上一章的 MNIST 中看到的准确率已经很高时,要评估我们对模型的任何改进将会很困难,因此我们将通过回到 Imagenette 来解决一个更困难的图像分类问题。我们将继续使用小图像以保持事情相对快速。
让我们获取数据——我们将使用已经调整大小为 160 像素的版本以使事情更快,然后将随机裁剪到 128 像素:
def get_data(url, presize, resize):
path = untar_data(url)
return DataBlock(
blocks=(ImageBlock, CategoryBlock), get_items=get_image_files,
splitter=GrandparentSplitter(valid_name='val'),
get_y=parent_label, item_tfms=Resize(presize),
batch_tfms=[*aug_transforms(min_scale=0.5, size=resize),
Normalize.from_stats(*imagenet_stats)],
).dataloaders(path, bs=128)
dls = get_data(URLs.IMAGENETTE_160, 160, 128)
dls.show_batch(max_n=4)
当我们查看 MNIST 时,我们处理的是 28×28 像素的图像。对于 Imagenette,我们将使用 128×128 像素的图像进行训练。稍后,我们希望能够使用更大的图像,至少与 224×224 像素的 ImageNet 标准一样大。您还记得我们如何从 MNIST 卷积神经网络中获得每个图像的单个激活向量吗?
我们采用的方法是确保有足够的步幅为 2 的卷积,以使最终层具有 1 的网格大小。然后我们展平我们最终得到的单位轴,为每个图像获得一个向量(因此,对于一个小批量的激活矩阵)。我们可以对 Imagenette 做同样的事情,但这会导致两个问题:
-
我们需要很多步幅为 2 的层,才能使我们的网格在最后变成 1×1 的大小——可能比我们本来会选择的要多。
-
该模型将无法处理除最初训练的大小之外的任何大小的图像。
处理第一个问题的一种方法是以一种处理 1×1 以外的网格大小的方式展平最终的卷积层。我们可以简单地将矩阵展平为向量,就像我们以前做过的那样,通过将每一行放在前一行之后。事实上,这是卷积神经网络直到 2013 年几乎总是采用的方法。最著名的例子是 2013 年 ImageNet 的获奖者 VGG,有时今天仍在使用。但这种架构还有另一个问题:它不仅不能处理与训练集中使用的相同大小的图像之外的图像,而且需要大量内存,因为展平卷积层导致许多激活被馈送到最终层。因此,最终层的权重矩阵是巨大的。
这个问题通过创建完全卷积网络来解决。完全卷积网络的技巧是对卷积网格中的激活进行平均。换句话说,我们可以简单地使用这个函数:
def avg_pool(x): return x.mean((2,3))
正如您所看到的,它正在计算 x 轴和 y 轴上的平均值。这个函数将始终将一组激活转换为每个图像的单个激活。PyTorch 提供了一个稍微更灵活的模块,称为nn.AdaptiveAvgPool2d
,它将一组激活平均到您需要的任何大小的目标(尽管我们几乎总是使用大小为 1)。
因此,一个完全卷积网络具有多个卷积层,其中一些将是步幅为 2 的,在最后是一个自适应平均池化层,一个展平层来移除单位轴,最后是一个线性层。这是我们的第一个完全卷积网络:
def block(ni, nf): return ConvLayer(ni, nf, stride=2)
def get_model():
return nn.Sequential(
block(3, 16),
block(16, 32),
block(32, 64),
block(64, 128),
block(128, 256),
nn.AdaptiveAvgPool2d(1),
Flatten(),
nn.Linear(256, dls.c))
我们将在网络中用其他变体替换block
的实现,这就是为什么我们不再称其为conv
。我们还通过利用 fastai 的ConvLayer
节省了一些时间,它已经提供了前一章中conv
的功能(还有更多!)。
停下来思考
考虑这个问题:这种方法对于像 MNIST 这样的光学字符识别(OCR)问题是否有意义?绝大多数从事 OCR 和类似问题的从业者倾向于使用全卷积网络,因为这是现在几乎每个人都学习的。但这真的毫无意义!例如,你不能通过将数字切成小块、混在一起,然后决定每个块平均看起来像 3 还是 8 来判断一个数字是 3 还是 8。但这正是自适应平均池化有效地做的事情!全卷积网络只对没有单一正确方向或大小的对象(例如大多数自然照片)是一个很好的选择。
一旦我们完成卷积层,我们将得到大小为bs x ch x h x w
的激活(批量大小、一定数量的通道、高度和宽度)。我们想将其转换为大小为bs x ch
的张量,因此我们取最后两个维度的平均值,并像在我们之前的模型中那样展平尾随的 1×1 维度。
这与常规池化不同,因为这些层通常会取给定大小窗口的平均值(对于平均池化)或最大值(对于最大池化)。例如,大小为 2 的最大池化层在旧的 CNN 中非常流行,通过在每个维度上取每个 2×2 窗口的最大值(步幅为 2),将图像的尺寸减半。
与以前一样,我们可以使用我们自定义的模型定义一个Learner
,然后在之前获取的数据上对其进行训练:
def get_learner(m):
return Learner(dls, m, loss_func=nn.CrossEntropyLoss(), metrics=accuracy
).to_fp16()
learn = get_learner(get_model())
learn.lr_find()
(0.47863011360168456, 3.981071710586548)
对于 CNN 来说,3e-3 通常是一个很好的学习率,这在这里也是如此,所以让我们试一试:
learn.fit_one_cycle(5, 3e-3)
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 1.901582 | 2.155090 | 0.325350 | 00:07 |
1 | 1.559855 | 1.586795 | 0.507771 | 00:07 |
2 | 1.296350 | 1.295499 | 0.571720 | 00:07 |
3 | 1.144139 | 1.139257 | 0.639236 | 00:07 |
4 | 1.049770 | 1.092619 | 0.659108 | 00:07 |
考虑到我们必须从头开始选择 10 个类别中的正确一个,而且我们只训练了 5 个时期,这是一个相当不错的开始!我们可以通过使用更深的模型做得更好,但只是堆叠新层并不会真正改善我们的结果(你可以尝试自己看看!)。为了解决这个问题,ResNets 引入了跳跃连接的概念。我们将在下一节中探讨 ResNets 的这些方面。
构建现代 CNN:ResNet
我们现在已经拥有构建我们自从本书开始就一直在计算机视觉任务中使用的模型所需的所有要素:ResNets。我们将介绍它们背后的主要思想,并展示它如何在 Imagenette 上提高了准确性,然后构建一个带有所有最新调整的版本。
跳跃连接
2015 年,ResNet 论文的作者们注意到了一件他们觉得奇怪的事情。即使使用了批量归一化,他们发现使用更多层的网络表现不如使用更少层的网络,并且模型之间没有其他差异。最有趣的是,这种差异不仅在验证集中观察到,而且在训练集中也观察到;因此这不仅仅是一个泛化问题,而是一个训练问题。正如论文所解释的:
出乎意料的是,这种退化并不是由过拟合引起的,向适当深度的模型添加更多层会导致更高的训练错误,正如我们的实验[先前报告]和彻底验证的那样。
这种现象在图 14-1 中的图表中有所说明,左侧是训练错误,右侧是测试错误。
图 14-1。不同深度网络的训练(由 Kaiming He 等人提供)。
正如作者在这里提到的,他们并不是第一个注意到这个奇怪事实的人。但他们是第一个迈出非常重要的一步:
让我们考虑一个更浅的架构及其更深的对应物,后者在其上添加更多层。存在一种通过构建解决更深模型的方法:添加的层是恒等映射,其他层是从学习的更浅模型中复制的。
由于这是一篇学术论文,这个过程以一种不太易懂的方式描述,但概念实际上非常简单:从一个训练良好的 20 层神经网络开始,然后添加另外 36 层什么都不做的层(例如,它们可以是具有单个权重等于 1 和偏置等于 0 的线性层)。结果将是一个 56 层的网络,它与 20 层网络完全相同,证明总是存在深度网络应该至少和任何浅层网络一样好。但由于某种原因,随机梯度下降似乎无法找到它们。
行话:恒等映射
将输入返回而不做任何改变。这个过程由一个恒等函数执行。
实际上,还有另一种更有趣的方法来创建这些额外的 36 层。如果我们用x + conv(x)
替换每次出现的conv(x)
,其中conv
是上一章中添加第二个卷积,然后是 ReLU,然后是批量归一化层的函数。此外,回想一下批量归一化是gamma*y + beta
。如果我们为这些最终批量归一化层中的每一个初始化gamma
为零会怎样?那么我们这些额外的 36 层的conv(x)
将始终等于零,这意味着x+conv(x)
将始终等于x
。
这给我们带来了什么好处?关键是,这 36 个额外的层,就目前而言,是一个恒等映射,但它们有参数,这意味着它们是可训练的。因此,我们可以从最好的 20 层模型开始,添加这 36 个最初什么都不做的额外层,然后微调整个 56 层模型。这些额外的 36 层可以学习使它们最有用的参数!
ResNet 论文提出了这样的一个变体,即“跳过”每第二个卷积,因此我们实际上得到了x+conv2(conv1(x))
。这在图 14-2(来自论文)中的图表中显示。
图 14-2。一个简单的 ResNet 块(由 Kaiming He 等人提供)。
右侧的箭头只是x+conv2(conv1(x))
中的x
部分,被称为恒等分支或跳跃连接。左侧路径是conv2(conv1(x))
部分。您可以将恒等路径视为提供从输入到输出的直接路径。
在 ResNet 中,我们不是先训练少量层,然后在末尾添加新层并进行微调。相反,我们在整个 CNN 中使用像图 14-2 中的 ResNet 块这样的块,以通常的方式从头开始初始化并以通常的方式使用 SGD 进行训练。我们依靠跳跃连接使网络更容易使用 SGD 进行训练。
还有另一种(在很大程度上等效的)思考这些 ResNet 块的方式。这就是论文描述的方式:
我们不是希望每几个堆叠的层直接适应所需的底层映射,而是明确让这些层适应一个残差映射。形式上,将所需的底层映射表示为H(x),我们让堆叠的非线性层适应另一个映射F(x) := H(x)−x。原始映射被重新构造为F(x)+x。我们假设优化残差映射比优化原始未引用的映射更容易。在极端情况下,如果恒等映射是最佳的,将残差推向零将比通过一堆非线性层适应恒等映射更容易。
再次,这是相当晦涩的文字,让我们尝试用简单的英语重新表述一下!如果给定层的结果是x
,我们使用一个返回y = x + block(x)
的 ResNet 块,我们不是要求该块预测y
;我们要求它预测y
和x
之间的差异。因此,这些块的任务不是预测特定的特征,而是最小化x
和期望的y
之间的误差。因此,ResNet 擅长学习不做任何事情和通过两个卷积层块(具有可训练权重)之间的区别。这就是这些模型得名的原因:它们在预测残差(提醒:“残差”是预测减去目标)。
这两种关于 ResNet 的思考方式共享的一个关键概念是学习的便利性。这是一个重要的主题。回想一下普遍逼近定理,它指出一个足够大的网络可以学习任何东西。这仍然是真的,但事实证明,在原始数据和训练方案下,网络在原则上可以学习的东西与它实际上容易学习的东西之间存在非常重要的区别。过去十年中神经网络的许多进步都像 ResNet 块一样:意识到如何使一些一直可能的东西变得可行。
真实身份路径
原始论文实际上并没有在每个块的最后一个 batchnorm 层中使用零作为gamma
的初始值的技巧;这是几年后才出现的。因此,ResNet 的原始版本并没有真正以真实的身份路径开始训练 ResNet 块,但是尽管如此,具有“穿越”跳过连接的能力确实使其训练效果更好。添加 batchnorm gamma
初始化技巧使模型能够以更高的学习速率训练。
这是一个简单 ResNet 块的定义(fastai 将最后一个 batchnorm 层的gamma
权重初始化为零,因为norm_type=NormType.BatchZero
):
class ResBlock(Module):
def __init__(self, ni, nf):
self.convs = nn.Sequential(
ConvLayer(ni,nf),
ConvLayer(nf,nf, norm_type=NormType.BatchZero))
def forward(self, x): return x + self.convs(x)
然而,这有两个问题:它无法处理除 1 以外的步幅,并且要求ni==nf
。停下来仔细思考为什么会这样。
问题在于,如果在其中一个卷积层上使用步幅为 2,输出激活的网格大小将是输入的每个轴的一半。因此,我们无法将其添加回forward
中的x
,因为x
和输出激活具有不同的维度。如果ni!=nf
,则会出现相同的基本问题:输入和输出连接的形状不允许我们将它们相加。
为了解决这个问题,我们需要一种方法来改变x
的形状,使其与self.convs
的结果匹配。可以通过使用步幅为 2 的平均池化层来减半网格大小:也就是说,该层从输入中获取 2×2 的块,并用它们的平均值替换它们。
可以通过使用卷积来改变通道数。然而,我们希望这个跳过连接尽可能接近一个恒等映射,这意味着使这个卷积尽可能简单。最简单的卷积是一个卷积核大小为 1 的卷积。这意味着卷积核大小为ni
× nf
× 1
× 1
,因此它只是对每个输入像素的通道进行点积运算,根本不跨像素进行组合。这种1x1 卷积在现代 CNN 中被广泛使用,因此花一点时间思考它是如何工作的。
术语:1x1 卷积
卷积核大小为 1 的卷积。
以下是使用这些技巧处理跳过连接中形状变化的 ResBlock:
def _conv_block(ni,nf,stride):
return nn.Sequential(
ConvLayer(ni, nf, stride=stride),
ConvLayer(nf, nf, act_cls=None, norm_type=NormType.BatchZero))
class ResBlock(Module):
def __init__(self, ni, nf, stride=1):
self.convs = _conv_block(ni,nf,stride)
self.idconv = noop if ni==nf else ConvLayer(ni, nf, 1, act_cls=None)
self.pool = noop if stride==1 else nn.AvgPool2d(2, ceil_mode=True)
def forward(self, x):
return F.relu(self.convs(x) + self.idconv(self.pool(x)))
请注意,我们在这里使用noop
函数,它只是返回其未更改的输入(noop是一个计算机科学术语,代表“无操作”)。在这种情况下,如果nf==nf
,idconv
什么也不做,如果stride==1
,pool
也不做任何操作,这正是我们在跳过连接中想要的。
此外,您会看到我们已经从convs
的最后一个卷积层和idconv
中删除了 ReLU(act_cls=None
),并将其移到在我们添加跳跃连接之后。这样做的想法是整个 ResNet 块就像一个层,您希望激活在层之后。
让我们用ResBlock
替换我们的block
并尝试一下:
def block(ni,nf): return ResBlock(ni, nf, stride=2)
learn = get_learner(get_model())
learn.fit_one_cycle(5, 3e-3)
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 1.973174 | 1.845491 | 0.373248 | 00:08 |
1 | 1.678627 | 1.778713 | 0.439236 | 00:08 |
2 | 1.386163 | 1.596503 | 0.507261 | 00:08 |
3 | 1.177839 | 1.102993 | 0.644841 | 00:09 |
4 | 1.052435 | 1.038013 | 0.667771 | 00:09 |
这并没有好多少。但这一切的目的是让我们能够训练更深的模型,而我们实际上还没有充分利用这一点。要创建一个比如说深两倍的模型,我们只需要用两个ResBlock
替换我们的block
:
def block(ni, nf):
return nn.Sequential(ResBlock(ni, nf, stride=2), ResBlock(nf, nf))
learn = get_learner(get_model())
learn.fit_one_cycle(5, 3e-3)
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 1.964076 | 1.864578 | 0.355159 | 00:12 |
1 | 1.636880 | 1.596789 | 0.502675 | 00:12 |
2 | 1.335378 | 1.304472 | 0.588535 | 00:12 |
3 | 1.089160 | 1.065063 | 0.663185 | 00:12 |
4 | 0.942904 | 0.963589 | 0.692739 | 00:12 |
现在我们取得了良好的进展!
ResNet 论文的作者后来赢得了 2015 年 ImageNet 挑战赛。当时,这是计算机视觉领域迄今为止最重要的年度事件。我们已经看到另一个 ImageNet 的获奖者:2013 年的获奖者 Zeiler 和 Fergus。值得注意的是,在这两种情况下,突破的起点都是实验观察:Zeiler 和 Fergus 案例中关于层实际学习内容的观察,以及 ResNet 作者案例中关于可以训练哪种网络的观察。设计和分析周到的实验,甚至只是看到一个意想不到的结果,然后,最重要的是,开始弄清楚到底发生了什么,具有极大的坚韧性,这是许多科学发现的核心。深度学习不像纯数学。这是一个非常实验性的领域,因此成为一个强大的实践者,而不仅仅是一个理论家,是非常重要的。
自 ResNet 推出以来,它已经被广泛研究和应用于许多领域。其中最有趣的论文之一,发表于 2018 年,是由 Hao Li 等人撰写的“可视化神经网络损失景观”。它表明使用跳跃连接有助于平滑损失函数,这使得训练更容易,因为它避免了陷入非常陡峭的区域。图 14-3 展示了该论文中的一幅惊人图片,说明了 SGD 需要导航以优化普通 CNN(左侧)与 ResNet(右侧)之间的不同之处。
图 14-3. ResNet 对损失景观的影响(由 Hao Li 等人提供)
我们的第一个模型已经很好了,但进一步的研究发现了更多可以应用的技巧,使其变得更好。我们接下来将看看这些技巧。
一个最先进的 ResNet
在“用卷积神经网络进行图像分类的技巧”中,Tong He 等人研究了 ResNet 架构的变体,这几乎没有额外的参数或计算成本。通过使用调整后的 ResNet-50 架构和 Mixup,他们在 ImageNet 上实现了 94.6%的 Top-5 准确率,而普通的 ResNet-50 没有 Mixup 只有 92.2%。这个结果比普通 ResNet 模型取得的结果更好,后者深度是它的两倍(速度也是两倍,更容易过拟合)。
术语:Top-5 准确率
一个度量,测试我们模型的前 5 个预测中我们想要的标签有多少次。在 ImageNet 竞赛中使用它,因为许多图像包含多个对象,或者包含可以轻松混淆甚至可能被错误标记为相似标签的对象。在这些情况下,查看前 1 的准确率可能不合适。然而,最近 CNN 的表现越来越好,以至于前 5 的准确率几乎达到 100%,因此一些研究人员现在也在 ImageNet 中使用前 1 的准确率。
当我们扩展到完整的 ResNet 时,我们将使用这个调整过的版本,因为它要好得多。它与我们之前的实现略有不同,它不是直接从 ResNet 块开始,而是从几个卷积层开始,然后是一个最大池化层。这就是网络的第一层,称为干的样子:
def _resnet_stem(*sizes):
return [
ConvLayer(sizes[i], sizes[i+1], 3, stride = 2 if i==0 else 1)
for i in range(len(sizes)-1)
] + [nn.MaxPool2d(kernel_size=3, stride=2, padding=1)]
_resnet_stem(3,32,32,64)
[ConvLayer(
(0): Conv2d(3, 32, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1))
(1): BatchNorm2d(32, eps=1e-05, momentum=0.1)
(2): ReLU()
), ConvLayer(
(0): Conv2d(32, 32, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(1): BatchNorm2d(32, eps=1e-05, momentum=0.1)
(2): ReLU()
), ConvLayer(
(0): Conv2d(32, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(1): BatchNorm2d(64, eps=1e-05, momentum=0.1)
(2): ReLU()
), MaxPool2d(kernel_size=3, stride=2, padding=1, ceil_mode=False)]
术语:干
CNN 的前几层。通常,干的结构与 CNN 的主体不同。
我们之所以有一系列普通卷积层的起始,而不是 ResNet 块,是基于对所有深度卷积神经网络的一个重要洞察:绝大部分的计算发生在早期层。因此,我们应该尽可能保持早期层的速度和简单。
要了解为什么绝大部分的计算发生在早期层,考虑一下在 128 像素输入图像上的第一个卷积。如果是步幅为 1 的卷积,它将应用核到 128×128 个像素中的每一个。这是很多工作!然而,在后续层中,网格大小可能只有 4×4 甚至 2×2,因此要做的核应用要少得多。
另一方面,第一层卷积只有 3 个输入特征和 32 个输出特征。由于它是一个 3×3 的核,这是权重中的 864 个参数。但最后一个卷积将有 256 个输入特征和 512 个输出特征,导致 1,179,648 个权重!因此,第一层包含了绝大部分的计算量,而最后几层包含了绝大部分的参数。
一个 ResNet 块比一个普通卷积块需要更多的计算,因为(在步幅为 2 的情况下)一个 ResNet 块有三个卷积和一个池化层。这就是为什么我们希望从普通卷积开始我们的 ResNet。
现在我们准备展示一个现代 ResNet 的实现,带有“技巧袋”。它使用了四组 ResNet 块,分别为 64、128、256 和 512 个滤波器。每组都以步幅为 2 的块开始,除了第一组,因为它紧接着一个MaxPooling
层:
class ResNet(nn.Sequential):
def __init__(self, n_out, layers, expansion=1):
stem = _resnet_stem(3,32,32,64)
self.block_szs = [64, 64, 128, 256, 512]
for i in range(1,5): self.block_szs[i] *= expansion
blocks = [self._make_layer(*o) for o in enumerate(layers)]
super().__init__(*stem, *blocks,
nn.AdaptiveAvgPool2d(1), Flatten(),
nn.Linear(self.block_szs[-1], n_out))
def _make_layer(self, idx, n_layers):
stride = 1 if idx==0 else 2
ch_in,ch_out = self.block_szs[idx:idx+2]
return nn.Sequential(*[
ResBlock(ch_in if i==0 else ch_out, ch_out, stride if i==0 else 1)
for i in range(n_layers)
])
_make_layer
函数只是用来创建一系列n_layers
块。第一个是从ch_in
到ch_out
,步幅为指定的stride
,其余所有块都是步幅为 1 的块,从ch_out
到ch_out
张量。一旦块被定义,我们的模型就是纯顺序的,这就是为什么我们将其定义为nn.Sequential
的子类。(暂时忽略expansion
参数;我们将在下一节讨论它。暂时设为1
,所以它不起作用。)
模型的各个版本(ResNet-18、-34、-50 等)只是改变了每个组中块的数量。这是 ResNet-18 的定义:
rn = ResNet(dls.c, [2,2,2,2])
让我们训练一下,看看它与之前的模型相比如何:
learn = get_learner(rn)
learn.fit_one_cycle(5, 3e-3)
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 1.673882 | 1.828394 | 0.413758 | 00:13 |
1 | 1.331675 | 1.572685 | 0.518217 | 00:13 |
2 | 1.087224 | 1.086102 | 0.650701 | 00:13 |
3 | 0.900428 | 0.968219 | 0.684331 | 00:12 |
4 | 0.760280 | 0.782558 | 0.757197 | 00:12 |
尽管我们有更多的通道(因此我们的模型更准确),但由于我们优化了干,我们的训练速度与以前一样快。
为了使我们的模型更深,而不占用太多计算或内存,我们可以使用 ResNet 论文引入的另一种层:瓶颈层。
瓶颈层
瓶颈层不是使用 3 个内核大小为 3 的卷积堆叠,而是使用三个卷积:两个 1×1(在开头和结尾)和一个 3×3,如右侧在图 14-4 中所示。
图 14-4. 常规和瓶颈 ResNet 块的比较(由 Kaiming He 等人提供)
为什么这很有用?1×1 卷积速度更快,因此即使这似乎是一个更复杂的设计,这个块的执行速度比我们看到的第一个 ResNet 块更快。这样一来,我们可以使用更多的滤波器:正如我们在插图中看到的,输入和输出的滤波器数量是四倍更高的(256 而不是 64)。1×1 卷积减少然后恢复通道数(因此称为瓶颈)。总体影响是我们可以在相同的时间内使用更多的滤波器。
让我们尝试用这种瓶颈设计替换我们的ResBlock
:
def _conv_block(ni,nf,stride):
return nn.Sequential(
ConvLayer(ni, nf//4, 1),
ConvLayer(nf//4, nf//4, stride=stride),
ConvLayer(nf//4, nf, 1, act_cls=None, norm_type=NormType.BatchZero))
我们将使用这个来创建一个具有组大小(3,4,6,3)
的 ResNet-50。现在我们需要将4
传递给ResNet
的expansion
参数,因为我们需要从四倍少的通道开始,最终将以四倍多的通道结束。
像这样更深的网络通常在仅训练 5 个时期时不会显示出改进,所以这次我们将将其增加到 20 个时期,以充分利用我们更大的模型。为了获得更好的结果,让我们也使用更大的图像:
dls = get_data(URLs.IMAGENETTE_320, presize=320, resize=224)
我们不必为更大的 224 像素图像做任何调整;由于我们的全卷积网络,它可以正常工作。这也是为什么我们能够在本书的早期进行渐进调整的原因——我们使用的模型是全卷积的,所以我们甚至能够微调使用不同尺寸训练的模型。现在我们可以训练我们的模型并查看效果:
rn = ResNet(dls.c, [3,4,6,3], 4)
learn = get_learner(rn)
learn.fit_one_cycle(20, 3e-3)
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 1.613448 | 1.473355 | 0.514140 | 00:31 |
1 | 1.359604 | 2.050794 | 0.397452 | 00:31 |
2 | 1.253112 | 4.511735 | 0.387006 | 00:31 |
3 | 1.133450 | 2.575221 | 0.396178 | 00:31 |
4 | 1.054752 | 1.264525 | 0.613758 | 00:32 |
5 | 0.927930 | 2.670484 | 0.422675 | 00:32 |
6 | 0.838268 | 1.724588 | 0.528662 | 00:32 |
7 | 0.748289 | 1.180668 | 0.666497 | 00:31 |
8 | 0.688637 | 1.245039 | 0.650446 | 00:32 |
9 | 0.645530 | 1.053691 | 0.674904 | 00:31 |
10 | 0.593401 | 1.180786 | 0.676433 | 00:32 |
11 | 0.536634 | 0.879937 | 0.713885 | 00:32 |
12 | 0.479208 | 0.798356 | 0.741656 | 00:32 |
13 | 0.440071 | 0.600644 | 0.806879 | 00:32 |
14 | 0.402952 | 0.450296 | 0.858599 | 00:32 |
15 | 0.359117 | 0.486126 | 0.846369 | 00:32 |
16 | 0.313642 | 0.442215 | 0.861911 | 00:32 |
17 | 0.294050 | 0.485967 | 0.853503 | 00:32 |
18 | 0.270583 | 0.408566 | 0.875924 | 00:32 |
19 | 0.266003 | 0.411752 | 0.872611 | 00:33 |
现在我们得到了一个很好的结果!尝试添加 Mixup,然后在吃午餐时将其训练一百个时期。你将拥有一个从头开始训练的非常准确的图像分类器。
这里展示的瓶颈设计通常仅用于 ResNet-50、-101 和-152 模型。ResNet-18 和-34 模型通常使用前一节中看到的非瓶颈设计。然而,我们注意到瓶颈层通常即使对于较浅的网络也效果更好。这只是表明,论文中的细节往往会持续多年,即使它们并不是最佳设计!质疑假设和“每个人都知道的东西”总是一个好主意,因为这仍然是一个新领域,很多细节并不总是做得很好。
结论
自第一章以来,我们一直在使用的计算机视觉模型是如何构建的,使用跳跃连接来训练更深的模型。尽管已经进行了大量研究以寻找更好的架构,但它们都使用这个技巧的某个版本来建立从输入到网络末端的直接路径。在使用迁移学习时,ResNet 是预训练模型。在下一章中,我们将看一下我们使用的模型是如何从中构建的最终细节。
问卷调查
-
在以前的章节中,我们如何将用于 MNIST 的 CNN 转换为单个激活向量?为什么这对 Imagenette 不适用?
-
我们在 Imagenette 上做了什么?
-
什么是自适应池化?
-
什么是平均池化?
-
为什么在自适应平均池化层之后需要
Flatten
? -
什么是跳跃连接?
-
为什么跳跃连接使我们能够训练更深的模型?
-
图 14-1 展示了什么?这是如何导致跳跃连接的想法的?
-
什么是恒等映射?
-
ResNet 块的基本方程是什么(忽略批量归一化和 ReLU 层)?
-
ResNet 与残差有什么关系?
-
当存在步幅为 2 的卷积时,我们如何处理跳跃连接?当滤波器数量发生变化时呢?
-
我们如何用向量点积表示 1×1 卷积?
-
使用
F.conv2d
或nn.Conv2d
创建一个 1×1 卷积并将其应用于图像。图像的形状会发生什么变化? -
noop
函数返回什么? -
解释图 14-3 中显示的内容。
-
何时使用前 5 准确度比前 1 准确度更好?
-
CNN 的“起始”是什么?
-
为什么在 CNN 的起始部分使用普通卷积而不是 ResNet 块?
-
瓶颈块与普通 ResNet 块有何不同?
-
为什么瓶颈块更快?
-
完全卷积网络(以及具有自适应池化的网络)如何实现渐进式调整大小?
进一步研究
-
尝试为 MNIST 创建一个带有自适应平均池化的完全卷积网络(请注意,您将需要更少的步幅为 2 的层)。与没有这种池化层的网络相比如何?
-
在第十七章中,我们介绍了爱因斯坦求和符号。快进去看看它是如何工作的,然后使用
torch.einsum
编写一个 1×1 卷积操作的实现。将其与使用torch.conv2d
进行相同操作进行比较。 -
使用纯 PyTorch 或纯 Python 编写一个前 5 准确度函数。
-
在 Imagenette 上训练一个模型更多的 epochs,使用和不使用标签平滑。查看 Imagenette 排行榜,看看你能达到最佳结果有多接近。阅读描述领先方法的链接页面。
第十五章:应用架构深入探讨
我们现在处于一个令人兴奋的位置,我们可以完全理解我们为计算机视觉、自然语言处理和表格分析使用的最先进模型的架构。在本章中,我们将填补有关 fastai 应用模型如何工作的所有缺失细节,并向您展示如何构建它们。
我们还将回到我们在第十一章中看到的用于 Siamese 网络的自定义数据预处理流程,并向您展示如何使用 fastai 库中的组件为新任务构建自定义预训练模型。
我们将从计算机视觉开始。
计算机视觉
对于计算机视觉应用,我们使用cnn_learner
和unet_learner
函数来构建我们的模型,具体取决于任务。在本节中,我们将探讨如何构建我们在本书的第 I 部分和 II 部分中使用的Learner
对象。
cnn_learner
让我们看看当我们使用cnn_learner
函数时会发生什么。我们首先向这个函数传递一个用于网络主体的架构。大多数情况下,我们使用 ResNet,您已经知道如何创建,所以我们不需要深入研究。预训练权重将根据需要下载并加载到 ResNet 中。
然后,对于迁移学习,网络需要被切割。这指的是切掉最后一层,该层仅负责 ImageNet 特定的分类。实际上,我们不仅切掉这一层,还切掉自自适应平均池化层以及之后的所有内容。这样做的原因很快就会变得清楚。由于不同的架构可能使用不同类型的池化层,甚至完全不同类型的头部,我们不仅仅搜索自适应池化层来决定在哪里切割预训练模型。相反,我们有一个信息字典,用于确定每个模型的主体在哪里结束,头部从哪里开始。我们称之为model_meta
—这是resnet50
的信息:
model_meta[resnet50]
{'cut': -2,
'split': <function fastai.vision.learner._resnet_split(m)>,
'stats': ([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])}
行话:主体和头部
神经网络的头部是专门针对特定任务的部分。对于 CNN,通常是自适应平均池化层之后的部分。主体是其他所有部分,包括干部(我们在第十四章中学到的)。
如果我们取出在-2
之前的所有层,我们就得到了 fastai 将保留用于迁移学习的模型部分。现在,我们放上我们的新头部。这是使用create_head
函数创建的:
create_head(20,2)
Sequential(
(0): AdaptiveConcatPool2d(
(ap): AdaptiveAvgPool2d(output_size=1)
(mp): AdaptiveMaxPool2d(output_size=1)
)
(1): Flatten()
(2): BatchNorm1d(20, eps=1e-05, momentum=0.1, affine=True)
(3): Dropout(p=0.25, inplace=False)
(4): Linear(in_features=20, out_features=512, bias=False)
(5): ReLU(inplace=True)
(6): BatchNorm1d(512, eps=1e-05, momentum=0.1, affine=True)
(7): Dropout(p=0.5, inplace=False)
(8): Linear(in_features=512, out_features=2, bias=False)
)
使用这个函数,您可以选择在末尾添加多少个额外的线性层,每个线性层之后使用多少 dropout,以及使用什么类型的池化。默认情况下,fastai 将同时应用平均池化和最大池化,并将两者连接在一起(这是AdaptiveConcatPool2d
层)。这不是一个特别常见的方法,但它在 fastai 和其他研究实验室近年来独立开发,并倾向于比仅使用平均池化提供小幅改进。
fastai 与大多数库有所不同,因为默认情况下它在 CNN 头部中添加两个线性层,而不是一个。原因是,正如我们所看到的,即使将预训练模型转移到非常不同的领域,迁移学习仍然可能是有用的。然而,在这些情况下,仅使用单个线性层可能不足够;我们发现使用两个线性层可以使迁移学习更快速、更容易地应用在更多情况下。
最后一个 Batchnorm
create_head
的一个值得关注的参数是bn_final
。将其设置为True
将导致一个 batchnorm 层被添加为您的最终层。这有助于帮助您的模型适当地缩放输出激活。迄今为止,我们还没有看到这种方法在任何地方发表,但我们发现在实践中无论我们在哪里使用它,它都效果很好。
现在让我们看看unet_learner
在我们在第一章展示的分割问题中做了什么。
unet_learner
深度学习中最有趣的架构之一是我们在第一章中用于分割的架构。分割是一项具有挑战性的任务,因为所需的输出实际上是一幅图像,或者一个像素网格,包含了每个像素的预测标签。其他任务也有类似的基本设计,比如增加图像的分辨率(超分辨率)、给黑白图像添加颜色(着色)、或将照片转换为合成画作(风格转移)——这些任务在本书的在线章节中有介绍,所以在阅读完本章后一定要查看。在每种情况下,我们都是从一幅图像开始,将其转换为另一幅具有相同尺寸或纵横比的图像,但像素以某种方式被改变。我们将这些称为生成式视觉模型。
我们的做法是从与前一节中看到的开发 CNN 头部的确切方法开始。例如,我们从一个 ResNet 开始,然后截断自适应池化层和之后的所有层。然后我们用我们的自定义头部替换这些层,执行生成任务。
在上一句中有很多含糊之处!我们到底如何创建一个生成图像的 CNN 头部?如果我们从一个 224 像素的输入图像开始,那么在 ResNet 主体的末尾,我们将得到一个 7×7 的卷积激活网格。我们如何将其转换为一个 224 像素的分割掩模?
当然,我们使用神经网络来做这个!所以我们需要一种能够在 CNN 中增加网格大小的层。一个简单的方法是用一个 2×2 的方块替换 7×7 网格中的每个像素。这四个像素中的每一个将具有相同的值——这被称为最近邻插值。PyTorch 为我们提供了一个可以做到这一点的层,因此一个选项是创建一个包含步长为 1 的卷积层(以及通常的批归一化和 ReLU 层)和 2×2 最近邻插值层的头部。实际上,你现在可以尝试一下!看看你是否可以创建一个设计如此的自定义头部,并在 CamVid 分割任务上尝试一下。你应该会发现你得到了一些合理的结果,尽管它们不会像我们在第一章中的结果那样好。
另一种方法是用转置卷积替换最近邻和卷积的组合,也被称为步长一半卷积。这与常规卷积相同,但首先在输入的所有像素之间插入零填充。这在图片上最容易看到——图 15-1 显示了一张来自我们在第十三章讨论过的优秀的卷积算术论文中的图表,展示了一个应用于 3×3 图像的 3×3 转置卷积。
图 15-1. 一个转置卷积(由 Vincent Dumoulin 和 Francesco Visin 提供)
正如你所看到的,结果是增加输入的大小。你现在可以通过使用 fastai 的ConvLayer
类来尝试一下;在你的自定义头部中传递参数transpose=True
来创建一个转置卷积,而不是一个常规卷积。
然而,这两种方法都不是很好。问题在于我们的 7×7 网格根本没有足够的信息来创建一个 224×224 像素的输出。要求每个网格单元的激活具有足够的信息来完全重建输出中的每个像素是非常困难的。
解决方案是使用跳跃连接,就像 ResNet 中那样,但是从 ResNet 主体中的激活一直跳到架构对面的转置卷积的激活。这种方法在 2015 年 Olaf Ronneberger 等人的论文“U-Net:用于生物医学图像分割的卷积网络”中有所阐述。尽管该论文侧重于医学应用,但 U-Net 已经彻底改变了各种生成视觉模型。
图 15-2。U-Net 架构(由 Olaf Ronneberger、Philipp Fischer 和 Thomas Brox 提供)
这幅图片展示了左侧的 CNN 主体(在这种情况下,它是一个常规的 CNN,而不是 ResNet,它们使用 2×2 最大池化而不是步幅为 2 的卷积,因为这篇论文是在 ResNets 出现之前写的),右侧是转置卷积(“上采样”)层。额外的跳跃连接显示为从左到右的灰色箭头(有时被称为交叉连接)。你可以看到为什么它被称为U-Net!
有了这种架构,传递给转置卷积的输入不仅是前一层中较低分辨率的网格,还有 ResNet 头部中较高分辨率的网格。这使得 U-Net 可以根据需要使用原始图像的所有信息。U-Net 的一个挑战是确切的架构取决于图像大小。fastai 有一个独特的DynamicUnet
类,根据提供的数据自动生成合适大小的架构。
现在让我们专注于一个示例,其中我们利用 fastai 库编写一个自定义模型。
孪生网络
让我们回到我们在第十一章中为孪生网络设置的输入管道。你可能还记得,它由一对图像组成,标签为True
或False
,取决于它们是否属于同一类。
利用我们刚刚看到的内容,让我们为这个任务构建一个自定义模型并对其进行训练。如何做?我们将使用一个预训练的架构并将我们的两个图像传递给它。然后我们可以连接结果并将它们发送到一个自定义头部,该头部将返回两个预测。在模块方面,看起来像这样:
class SiameseModel(Module):
def __init__(self, encoder, head):
self.encoder,self.head = encoder,head
def forward(self, x1, x2):
ftrs = torch.cat([self.encoder(x1), self.encoder(x2)], dim=1)
return self.head(ftrs)
要创建我们的编码器,我们只需要取一个预训练模型并切割它,就像我们之前解释的那样。函数create_body
为我们执行此操作;我们只需传递我们想要切割的位置。正如我们之前看到的,根据预训练模型的元数据字典,ResNet 的切割值为-2
:
encoder = create_body(resnet34, cut=-2)
然后我们可以创建我们的头部。查看编码器告诉我们最后一层有 512 个特征,所以这个头部将需要接收512*4
。为什么是 4?首先我们必须乘以 2,因为我们有两个图像。然后我们需要第二次乘以 2,因为我们的连接池技巧。因此我们创建头部如下:
head = create_head(512*4, 2, ps=0.5)
有了我们的编码器和头部,我们现在可以构建我们的模型:
model = SiameseModel(encoder, head)
在使用Learner
之前,我们还需要定义两件事。首先,我们必须定义要使用的损失函数。它是常规的交叉熵,但由于我们的目标是布尔值,我们需要将它们转换为整数,否则 PyTorch 会抛出错误:
def loss_func(out, targ):
return nn.CrossEntropyLoss()(out, targ.long())
更重要的是,为了充分利用迁移学习,我们必须定义一个自定义的splitter。splitter是一个告诉 fastai 库如何将模型分成参数组的函数。这些在幕后用于在进行迁移学习时仅训练模型的头部。
这里我们想要两个参数组:一个用于编码器,一个用于头部。因此我们可以定义以下splitter(params
只是一个返回给定模块的所有参数的函数):
def siamese_splitter(model):
return [params(model.encoder), params(model.head)]
然后,我们可以通过传递数据、模型、损失函数、分割器和任何我们想要的指标来定义我们的Learner
。由于我们没有使用 fastai 的传输学习便利函数(如cnn_learner
),我们必须手动调用learn.freeze
。这将确保只有最后一个参数组(在本例中是头部)被训练:
learn = Learner(dls, model, loss_func=loss_func,
splitter=siamese_splitter, metrics=accuracy)
learn.freeze()
然后我们可以直接使用通常的方法训练我们的模型:
learn.fit_one_cycle(4, 3e-3)
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 0.367015 | 0.281242 | 0.885656 | 00:26 |
1 | 0.307688 | 0.214721 | 0.915426 | 00:26 |
2 | 0.275221 | 0.170615 | 0.936401 | 00:26 |
3 | 0.223771 | 0.159633 | 0.943843 | 00:26 |
现在我们解冻并使用有区别的学习率微调整个模型一点(即,对于主体使用较低的学习率,对于头部使用较高的学习率):
learn.unfreeze()
learn.fit_one_cycle(4, slice(1e-6,1e-4))
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 0.212744 | 0.159033 | 0.944520 | 00:35 |
1 | 0.201893 | 0.159615 | 0.942490 | 00:35 |
2 | 0.204606 | 0.152338 | 0.945196 | 00:36 |
3 | 0.213203 | 0.148346 | 0.947903 | 00:36 |
94.8%是非常好的,当我们记得以相同方式训练的分类器(没有数据增强)的错误率为 7%时。
现在我们已经看到如何创建完整的最先进的计算机视觉模型,让我们继续进行自然语言处理。
自然语言处理
将 AWD-LSTM 语言模型转换为迁移学习分类器,就像我们在第十章中所做的那样,遵循与本章第一节中的cnn_learner
相似的过程。在这种情况下,我们不需要一个“元”字典,因为我们没有这么多种类的体系结构需要在主体中支持。我们只需要选择语言模型中的堆叠 RNN 作为编码器,这是一个单独的 PyTorch 模块。这个编码器将为输入的每个单词提供一个激活,因为语言模型需要为每个下一个单词输出一个预测。
要从中创建一个分类器,我们使用了ULMFiT 论文中描述的一种方法,称为“用于文本分类的 BPTT(BPT3C)”:
我们将文档分成固定长度为b的批次。在每个批次的开始,模型使用前一个批次的最终状态进行初始化;我们跟踪用于平均值和最大池化的隐藏状态;梯度被反向传播到隐藏状态对最终预测有贡献的批次。在实践中,我们使用可变长度的反向传播序列。
换句话说,分类器包含一个for
循环,循环遍历每个序列的批次。状态在批次之间保持不变,并且存储每个批次的激活。最后,我们使用相同的平均值和最大连接池技巧,这与我们用于计算机视觉模型的方法相同,但这一次,我们不是在 CNN 网格单元上进行池化,而是在 RNN 序列上进行池化。
对于这个for
循环,我们需要将我们的数据分批处理,但每个文本需要单独处理,因为它们各自有自己的标签。然而,这些文本很可能不会都是相同的长度,这意味着我们无法将它们都放在同一个数组中,就像我们在语言模型中所做的那样。
这就是填充将会有所帮助的地方:当获取一堆文本时,我们确定最长的文本,然后用一个特殊的标记xxpad
填充较短的文本。为了避免在同一批次中有一个包含 2,000 个标记的文本和一个包含 10 个标记的文本的极端情况(因此有很多填充和浪费的计算),我们通过确保相似大小的文本被放在一起来改变随机性。文本在训练集中仍然会以某种随机顺序排列(对于验证集,我们可以简单地按长度顺序排序),但不完全是这样。
这是由 fastai 库在创建我们的DataLoaders
时在幕后自动完成的。
表格
最后,让我们看看fastai.tabular
模型。(我们不需要单独查看协同过滤,因为我们已经看到这些模型只是表格模型或使用点积方法,我们之前从头开始实现。)
这是TabularModel
的forward
方法:
if self.n_emb != 0:
x = [e(x_cat[:,i]) for i,e in enumerate(self.embeds)]
x = torch.cat(x, 1)
x = self.emb_drop(x)
if self.n_cont != 0:
x_cont = self.bn_cont(x_cont)
x = torch.cat([x, x_cont], 1) if self.n_emb != 0 else x_cont
return self.layers(x)
我们不会在这里显示__init__
,因为这并不那么有趣,但会依次查看forward
中的每行代码。第一行只是测试是否有任何嵌入需要处理-如果只有连续变量,我们可以跳过这一部分:
if self.n_emb != 0:
self.embeds
包含嵌入矩阵,因此这会获取每个激活
x = [e(x_cat[:,i]) for i,e in enumerate(self.embeds)]
并将它们连接成一个单一张量:
x = torch.cat(x, 1)
然后应用了辍学。您可以将emb_drop
传递给__init__
以更改此值:
x = self.emb_drop(x)
现在我们测试是否有任何连续变量需要处理:
if self.n_cont != 0:
它们通过一个批量归一化层
x_cont = self.bn_cont(x_cont)
并与嵌入激活连接在一起,如果有的话:
x = torch.cat([x, x_cont], 1) if self.n_emb != 0 else x_cont
最后,这些通过线性层传递(每个线性层包括批量归一化,如果use_bn
为True
,并且辍学,如果ps
设置为某个值或值列表):
return self.layers(x)
恭喜!现在您已经了解了 fastai 库中使用的每个架构的所有细节!
结论
正如您所看到的,深度学习架构的细节现在不应该让您感到恐惧。您可以查看 fastai 和 PyTorch 的代码,看看发生了什么。更重要的是,尝试理解为什么会发生这种情况。查看代码中引用的论文,并尝试看看代码如何与描述的算法相匹配。
现在我们已经调查了模型的所有部分以及传递给它的数据,我们可以考虑这对于实际深度学习意味着什么。如果您拥有无限的数据,无限的内存和无限的时间,那么建议很简单:在所有数据上训练一个巨大的模型很长时间。但深度学习不简单的原因是您的数据、内存和时间通常是有限的。如果内存或时间不足,解决方案是训练一个较小的模型。如果您无法训练足够长时间以过拟合,那么您没有充分利用模型的容量。
因此,第一步是达到过拟合的点。然后问题是如何减少过拟合。图 15-3 显示了我们建议从那里优先考虑的步骤。
图 15-3。减少过拟合的步骤
许多从业者在面对过拟合模型时,从这个图表的完全错误的一端开始。他们的起点是使用更小的模型或更多的正则化。除非训练模型占用太多时间或内存,否则使用更小的模型应该是您采取的最后一步。减小模型的大小会降低模型学习数据中微妙关系的能力。
相反,您的第一步应该是寻求创建更多数据。这可能涉及向您已经拥有的数据添加更多标签,找到模型可以被要求解决的其他任务(或者,换个角度思考,识别您可以建模的不同类型的标签),或者通过使用更多或不同的数据增强技术创建额外的合成数据。由于 Mixup 和类似方法的发展,现在几乎所有类型的数据都可以获得有效的数据增强。
一旦您获得了您认为可以合理获得的尽可能多的数据,并且通过利用您可以找到的所有标签并进行所有有意义的增强来尽可能有效地使用它,如果您仍然过拟合,您应该考虑使用更具一般化能力的架构。例如,添加批量归一化可能会提高泛化能力。
如果在尽力使用数据和调整架构后仍然过拟合,您可以考虑正则化。一般来说,在最后一层或两层添加 dropout 可以很好地正则化您的模型。然而,正如我们从 AWD-LSTM 开发故事中学到的那样,在整个模型中添加不同类型的 dropout 通常会更有帮助。一般来说,具有更多正则化的较大模型更灵活,因此比具有较少正则化的较小模型更准确。
只有在考虑了所有这些选项之后,我们才建议您尝试使用较小版本的架构。
问卷
-
神经网络的头是什么?
-
神经网络的主体是什么?
-
什么是“剪切”神经网络?为什么我们需要在迁移学习中这样做?
-
model_meta
是什么?尝试打印它以查看里面的内容。 -
阅读
create_head
的源代码,并确保你理解每一行的作用。 -
查看
create_head
的输出,并确保你理解每一层的存在原因,以及create_head
源代码是如何创建它的。 -
找出如何改变
create_cnn
创建的 dropout、层大小和层数,并查看是否可以找到能够提高宠物识别准确性的值。 -
AdaptiveConcatPool2d
是什么作用? -
什么是最近邻插值?如何用它来上采样卷积激活?
-
什么是转置卷积?还有另一个名称是什么?
-
创建一个带有
transpose=True
的卷积层,并将其应用于图像。检查输出形状。 -
绘制 U-Net 架构。
-
什么是用于文本分类的 BPTT(BPT3C)?
-
在 BPT3C 中如何处理不同长度的序列?
-
尝试在笔记本中逐行运行
TabularModel.forward
的每一行,每个单元格一行,并查看每个步骤的输入和输出形状。 -
TabularModel
中的self.layers
是如何定义的? -
预防过拟合的五个步骤是什么?
-
为什么在尝试其他方法预防过拟合之前不减少架构复杂性?
进一步研究
-
编写自己的自定义头,并尝试使用它训练宠物识别器。看看是否可以获得比 fastai 默认更好的结果。
-
尝试在 CNN 头部之间切换
AdaptiveConcatPool2d
和AdaptiveAvgPool2d
,看看会有什么不同。 -
编写自己的自定义分割器,为每个 ResNet 块创建一个单独的参数组,以及一个单独的参数组用于干扰。尝试使用它进行训练,看看是否可以改善宠物识别器。
-
阅读关于生成图像模型的在线章节,并创建自己的着色器、超分辨率模型或风格转移模型。
-
使用最近邻插值创建一个自定义头,并用它在 CamVid 上进行分割。
第十六章:训练过程
现在你知道如何为计算机视觉、自然图像处理、表格分析和协同过滤创建最先进的架构,也知道如何快速训练它们。所以我们完成了,对吧?还没有。我们仍然需要探索一下训练过程的更多内容。
我们在第四章中解释了随机梯度下降的基础:将一个小批量数据传递给模型,用损失函数将其与目标进行比较,然后计算这个损失函数对每个权重的梯度,然后使用公式更新权重:
new_weight = weight - lr * weight.grad
我们在训练循环中从头开始实现了这个,看到 PyTorch 提供了一个简单的nn.SGD
类,可以为我们的每个参数进行这个计算。在本章中,我们将构建一些更快的优化器,使用一个灵活的基础。但在训练过程中,我们可能还想要改变一些东西。对于训练循环的任何调整,我们都需要一种方法来向 SGD 的基础添加一些代码。fastai 库有一个回调系统来做到这一点,我们将教你所有相关知识。
让我们从标准的 SGD 开始建立一个基线;然后我们将介绍最常用的优化器。
建立基线
首先,我们将使用普通的 SGD 创建一个基线,并将其与 fastai 的默认优化器进行比较。我们将通过使用与第十四章中相同的get_data
来获取 Imagenette:
dls = get_data(URLs.IMAGENETTE_160, 160, 128)
我们将创建一个没有预训练的 ResNet-34,并传递任何接收到的参数:
def get_learner(**kwargs):
return cnn_learner(dls, resnet34, pretrained=False,
metrics=accuracy, **kwargs).to_fp16()
这是默认的 fastai 优化器,具有通常的 3e-3 学习率:
learn = get_learner()
learn.fit_one_cycle(3, 0.003)
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 2.571932 | 2.685040 | 0.322548 | 00:11 |
1 | 1.904674 | 1.852589 | 0.437452 | 00:11 |
2 | 1.586909 | 1.374908 | 0.594904 | 00:11 |
现在让我们尝试普通的 SGD。我们可以将opt_func
(优化函数)传递给cnn_learner
,以便让 fastai 使用任何优化器:
learn = get_learner(opt_func=SGD)
首先要看的是lr_find
:
learn.lr_find()
(0.017378008365631102, 3.019951861915615e-07)
看起来我们需要使用比我们通常使用的更高的学习率:
learn.fit_one_cycle(3, 0.03, moms=(0,0,0))
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 2.969412 | 2.214596 | 0.242038 | 00:09 |
1 | 2.442730 | 1.845950 | 0.362548 | 00:09 |
2 | 2.157159 | 1.741143 | 0.408917 | 00:09 |
因为用动量加速 SGD 是一个很好的主意,fastai 在fit_one_cycle
中默认执行这个操作,所以我们用moms=(0,0,0)
关闭它。我们很快会讨论动量。
显然,普通的 SGD 训练速度不如我们所希望的快。所以让我们学习一些技巧来加速训练!
通用优化器
为了构建我们加速的 SGD 技巧,我们需要从一个灵活的优化器基础开始。在 fastai 之前没有任何库提供这样的基础,但在 fastai 的开发过程中,我们意识到学术文献中看到的所有优化器改进都可以使用优化器回调来处理。这些是我们可以组合、混合和匹配在优化器中构建优化器步骤的小代码片段。它们由 fastai 的轻量级Optimizer
类调用。这些是我们在本书中使用的两个关键方法在Optimizer
中的定义:
def zero_grad(self):
for p,*_ in self.all_params():
p.grad.detach_()
p.grad.zero_()
def step(self):
for p,pg,state,hyper in self.all_params():
for cb in self.cbs:
state = _update(state, cb(p, **{**state, **hyper}))
self.state[p] = state
正如我们在从头开始训练 MNIST 模型时看到的,zero_grad
只是循环遍历模型的参数并将梯度设置为零。它还调用detach_
,这会删除任何梯度计算的历史,因为在zero_grad
之后不再需要它。
更有趣的方法是step
,它循环遍历回调(cbs
)并调用它们来更新参数(如果cb
返回任何内容,_update
函数只是调用state.update
)。正如你所看到的,Optimizer
本身不执行任何 SGD 步骤。让我们看看如何将 SGD 添加到Optimizer
中。
这是一个优化器回调,通过将-lr
乘以梯度并将其添加到参数(当在 PyTorch 中传递Tensor.add_
两个参数时,它们在相加之前相乘)来执行单个 SGD 步骤:
def sgd_cb(p, lr, **kwargs): p.data.add_(-lr, p.grad.data)
我们可以使用cbs
参数将这个传递给Optimizer
;我们需要使用partial
,因为Learner
将调用这个函数来创建我们的优化器:
opt_func = partial(Optimizer, cbs=[sgd_cb])
让我们看看这是否有效:
learn = get_learner(opt_func=opt_func)
learn.fit(3, 0.03)
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 2.730918 | 2.009971 | 0.332739 | 00:09 |
1 | 2.204893 | 1.747202 | 0.441529 | 00:09 |
2 | 1.875621 | 1.684515 | 0.445350 | 00:09 |
它正在工作!这就是我们如何在 fastai 中从头开始创建 SGD。现在让我们看看这个“动量”是什么。
动量
正如在第四章中所描述的,SGD 可以被看作站在山顶上,通过在每个时间点沿着最陡峭的斜坡方向迈出一步来往下走。但如果我们有一个球在山上滚动呢?在每个给定点,它不会完全按照梯度的方向前进,因为它会有动量。具有更多动量的球(例如,一个更重的球)会跳过小凸起和洞,更有可能到达崎岖山脉的底部。另一方面,乒乓球会卡在每一个小缝隙中。
那么我们如何将这个想法带到 SGD 中呢?我们可以使用移动平均值,而不仅仅是当前梯度,来进行我们的步骤:
weight.avg = beta * weight.avg + (1-beta) * weight.grad
new_weight = weight - lr * weight.avg
这里beta
是我们选择的一个数字,定义了要使用多少动量。如果beta
为 0,第一个方程变为weight.avg = weight.grad
,因此我们最终得到普通的 SGD。但如果它接近 1,所选择的主要方向是以前步骤的平均值。(如果你对统计学有一点了解,你可能会在第一个方程中认出指数加权移动平均,它经常用于去噪数据并获得潜在趋势。)
请注意,我们写weight.avg
以突出显示我们需要为模型的每个参数存储移动平均值(它们都有自己独立的移动平均值)。
图 16-1 显示了一个单参数的噪声数据示例,其中动量曲线以红色绘制,参数的梯度以蓝色绘制。梯度增加,然后减少,动量很好地跟随总体趋势,而不会受到噪声的太大影响。
图 16-1。动量的一个例子
如果损失函数有窄谷,我们需要导航:普通的 SGD 会使我们从一边反弹到另一边,而带有动量的 SGD 会将这些平均值平滑地滚动到一侧。参数beta
确定我们使用的动量的强度:使用较小的beta
,我们会保持接近实际梯度值,而使用较高的beta
,我们将主要朝着梯度的平均值前进,直到梯度的任何变化使得该趋势移动。
使用较大的beta
,我们可能会错过梯度改变方向并滚动到一个小的局部最小值。这是一个期望的副作用:直观地,当我们向模型展示一个新的输入时,它会看起来像训练集中的某个东西,但不会完全像它。它将对应于损失函数中接近我们在训练结束时得到的最小值的点,但不会在那个最小值。因此,我们宁愿在一个宽阔的最小值中进行训练,附近的点具有近似相同的损失(或者如果你喜欢的话,损失尽可能平坦的点)。图 16-2 显示了当我们改变beta
时,图 16-1 中的图表如何变化。
图 16-2。不同 beta 值的动量
我们可以看到在这些示例中,beta
太高会导致梯度的整体变化被忽略。在带动量的 SGD 中,通常使用的beta
值为 0.9。
fit_one_cycle
默认从 0.95 开始,逐渐调整到 0.85,然后在训练结束时逐渐移回到 0.95。让我们看看在普通 SGD 中添加动量后我们的训练情况如何。
要向我们的优化器添加动量,我们首先需要跟踪移动平均梯度,我们可以使用另一个回调来实现。当优化器回调返回一个dict
时,它用于更新优化器的状态,并在下一步传回优化器。因此,这个回调将跟踪梯度平均值,存储在名为grad_avg
的参数中:
def average_grad(p, mom, grad_avg=None, **kwargs):
if grad_avg is None: grad_avg = torch.zeros_like(p.grad.data)
return {'grad_avg': grad_avg*mom + p.grad.data}
要使用它,我们只需在我们的步骤函数中用grad_avg
替换p.grad.data
:
def momentum_step(p, lr, grad_avg, **kwargs): p.data.add_(-lr, grad_avg)
opt_func = partial(Optimizer, cbs=[average_grad,momentum_step], mom=0.9)
Learner
将自动调度mom
和lr
,因此fit_one_cycle
甚至可以与我们自定义的Optimizer
一起使用:
learn = get_learner(opt_func=opt_func)
learn.fit_one_cycle(3, 0.03)
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 2.856000 | 2.493429 | 0.246115 | 00:10 |
1 | 2.504205 | 2.463813 | 0.348280 | 00:10 |
2 | 2.187387 | 1.755670 | 0.418853 | 00:10 |
learn.recorder.plot_sched()
我们仍然没有得到很好的结果,所以让我们看看我们还能做什么。
RMSProp
RMSProp 是由 Geoffrey Hinton 在他的 Coursera 课程“神经网络机器学习”第 6 讲 e中介绍的 SGD 的另一种变体。与 SGD 的主要区别在于它使用自适应学习率:每个参数都有自己特定的学习率,由全局学习率控制。这样,我们可以通过为需要大幅度改变的权重提供更高的学习率来加速训练,而对于已经足够好的权重,则提供较低的学习率。
我们如何决定哪些参数应该具有较高的学习率,哪些不应该?我们可以查看梯度来获取一个想法。如果一个参数的梯度一直接近于零,那么该参数将需要更高的学习率,因为损失是平的。另一方面,如果梯度到处都是,我们可能应该小心并选择一个较低的学习率以避免发散。我们不能简单地平均梯度来查看它们是否变化很多,因为大正数和大负数的平均值接近于零。相反,我们可以使用通常的技巧,即取绝对值或平方值(然后在平均后取平方根)。
再次,为了确定噪声背后的一般趋势,我们将使用移动平均值,具体来说是梯度的平方的移动平均值。然后,我们将通过使用当前梯度(用于方向)除以这个移动平均值的平方根来更新相应的权重(这样,如果它很低,有效的学习率将更高,如果它很高,有效的学习率将更低):
w.square_avg = alpha * w.square_avg + (1-alpha) * (w.grad ** 2)
new_w = w - lr * w.grad / math.sqrt(w.square_avg + eps)
eps
(epsilon)是为了数值稳定性而添加的(通常设置为 1e-8),alpha
的默认值通常为 0.99。
我们可以通过做与avg_grad
类似的事情将其添加到Optimizer
中,但是多了一个**2
:
def average_sqr_grad(p, sqr_mom, sqr_avg=None, **kwargs):
if sqr_avg is None: sqr_avg = torch.zeros_like(p.grad.data)
return {'sqr_avg': sqr_avg*sqr_mom + p.grad.data**2}
我们可以像以前一样定义我们的步骤函数和优化器:
def rms_prop_step(p, lr, sqr_avg, eps, grad_avg=None, **kwargs):
denom = sqr_avg.sqrt().add_(eps)
p.data.addcdiv_(-lr, p.grad, denom)
opt_func = partial(Optimizer, cbs=[average_sqr_grad,rms_prop_step],
sqr_mom=0.99, eps=1e-7)
让我们试一试:
learn = get_learner(opt_func=opt_func)
learn.fit_one_cycle(3, 0.003)
epoch | train_loss | valid_loss | accuracy | time |
---|---|---|---|---|
0 | 2.766912 | 1.845900 | 0.402548 | 00:11 |
1 | 2.194586 | 1.510269 | 0.504459 | 00:11 |
2 | 1.869099 | 1.447939 | 0.544968 | 00:11 |
好多了!现在我们只需将这些想法结合起来,我们就有了 Adam,fastai 的默认优化器。
Adam
Adam 将 SGD 与动量和 RMSProp 的思想结合在一起:它使用梯度的移动平均作为方向,并除以梯度平方的移动平均的平方根,为每个参数提供自适应学习率。
Adam 计算移动平均值的方式还有一个不同之处。它采用无偏移动平均值,即
w.avg = beta * w.avg + (1-beta) * w.grad
unbias_avg = w.avg / (1 - (beta**(i+1)))
如果我们是第i
次迭代(从 0 开始,就像 Python 一样)。这个除数1 - (beta**(i+1))
确保无偏平均值看起来更像开始时的梯度(因为beta < 1
,分母很快接近 1)。
将所有内容放在一起,我们的更新步骤看起来像这样:
w.avg = beta1 * w.avg + (1-beta1) * w.grad
unbias_avg = w.avg / (1 - (beta1**(i+1)))
w.sqr_avg = beta2 * w.sqr_avg + (1-beta2) * (w.grad ** 2)
new_w = w - lr * unbias_avg / sqrt(w.sqr_avg + eps)
至于 RMSProp,eps
通常设置为 1e-8,文献建议的(beta1,beta2)
的默认值为(0.9,0.999)
。
在 fastai 中,Adam 是我们使用的默认优化器,因为它可以加快训练速度,但我们发现beta2=0.99
更适合我们使用的调度类型。beta1
是动量参数,我们在调用fit_one_cycle
时用参数moms
指定。至于eps
,fastai 使用默认值 1e-5。eps
不仅仅对数值稳定性有用。更高的eps
限制了调整学习率的最大值。举个极端的例子,如果eps
为 1,那么调整后的学习率永远不会高于基本学习率。
与其在书中展示所有这些代码,我们让你去看看 fastai 的优化器笔记本https://oreil.ly/24_O[GitHub 存储库](浏览 _nbs文件夹并搜索名为optimizer的笔记本)。你会看到到目前为止我们展示的所有代码,以及 Adam 和其他优化器,以及许多示例和测试。
当我们从 SGD 转换为 Adam 时,有一件事情会改变,那就是我们如何应用权重衰减,这可能会产生重要的后果。
解耦权重衰减
权重衰减,我们在第八章中讨论过,相当于(在普通 SGD 的情况下)用以下方式更新参数:
new_weight = weight - lr*weight.grad - lr*wd*weight
这个公式的最后一部分解释了这种技术的名称:每个权重都会被lr * wd
的因子衰减。
权重衰减的另一个名称是L2 正则化,它包括将所有平方权重的总和添加到损失中(乘以权重衰减)。正如我们在第八章中看到的,这可以直接表达在梯度上:
weight.grad += wd*weight
对于 SGD,这两个公式是等价的。然而,这种等价性仅适用于标准 SGD,因为正如我们在动量、RMSProp 或 Adam 中看到的,更新周围有一些额外的梯度公式。
大多数库使用第二种公式,但 Ilya Loshchilov 和 Frank Hutter 在“解耦权重衰减正则化”中指出,第一种方法是 Adam 优化器或动量的唯一正确方法,这就是为什么 fastai 将其设为默认值。
现在你知道了learn.fit_one_cycle
这行代码背后隐藏的一切!
然而,优化器只是训练过程的一部分。当你需要改变 fastai 的训练循环时,你不能直接改变库内的代码。相反,我们设计了一套回调系统,让你可以在独立的块中编写任何你喜欢的调整,然后进行混合和匹配。
回调
有时候你需要稍微改变事物的工作方式。事实上,我们已经看到了这种情况的例子:Mixup,fp16 训练,每个时期重置模型以训练 RNN 等。我们如何进行这种类型的调整训练过程?
我们已经看到了基本训练循环,借助Optimizer
类的帮助,对于单个时期,它看起来像这样:
for xb,yb in dl:
loss = loss_func(model(xb), yb)
loss.backward()
opt.step()
opt.zero_grad()
图 16-3 展示了如何形象地描绘这一点。
图 16-3. 基本训练循环
深度学习从业者通常自定义训练循环的常规方式是复制现有训练循环,然后将特定更改所需的代码插入其中。这几乎是你在网上找到的所有代码的样子。但是它存在严重问题。
某个特定调整过的训练循环不太可能满足您的特定需求。可以对训练循环进行数百次更改,这意味着可能有数十亿种可能的排列组合。您不能只是从这里的一个训练循环中复制一个调整,从那里的另一个训练循环中复制另一个调整,然后期望它们都能一起工作。每个都将基于对其所在环境的不同假设,使用不同的命名约定,并期望数据以不同的格式存在。
我们需要一种方法,允许用户在训练循环的任何部分插入自己的代码,但以一种一致和明确定义的方式。计算机科学家已经提出了一个优雅的解决方案:回调。回调是您编写并注入到另一段代码中的代码片段,在预定义的点执行。事实上,回调已经多年用于深度学习训练循环。问题在于,在以前的库中,只能在可能需要的一小部分地方注入代码——更重要的是,回调无法执行它们需要执行的所有操作。
为了能够像手动复制和粘贴训练循环并直接插入代码一样灵活,回调必须能够读取训练循环中的所有可能信息,根据需要修改所有信息,并完全控制批次、周期甚至整个训练循环何时应该终止。fastai 是第一个提供所有这些功能的库。它修改了训练循环,使其看起来像图 16-4。
图 16-4. 带有回调的训练循环
这种方法的有效性在过去几年中得到了验证——通过使用 fastai 回调系统,我们能够实现我们尝试的每一篇新论文,并满足每一个修改训练循环的用户请求。训练循环本身并不需要修改。图 16-5 展示了添加的一些回调。
图 16-5. 一些 fastai 回调
这很重要,因为这意味着我们头脑中的任何想法,我们都可以实现。我们永远不需要深入 PyTorch 或 fastai 的源代码,并临时拼凑一个系统来尝试我们的想法。当我们实现自己的回调来开发自己的想法时,我们知道它们将与 fastai 提供的所有其他功能一起工作——因此我们将获得进度条、混合精度训练、超参数退火等等。
另一个优点是,它使逐渐删除或添加功能以及执行消融研究变得容易。您只需要调整传递给 fit 函数的回调列表。
例如,这是每个训练循环批次运行的 fastai 源代码:
try:
self._split(b); self('begin_batch')
self.pred = self.model(*self.xb); self('after_pred')
self.loss = self.loss_func(self.pred, *self.yb); self('after_loss')
if not self.training: return
self.loss.backward(); self('after_backward')
self.opt.step(); self('after_step')
self.opt.zero_grad()
except CancelBatchException: self('after_cancel_batch')
finally: self('after_batch')
形式为self('...')
的调用是回调被调用的地方。正如您所看到的,这发生在每一步之后。回调将接收整个训练状态,并且还可以修改它。例如,输入数据和目标标签分别在self.xb
和self.yb
中;回调可以修改这些以修改训练循环看到的数据。它还可以修改self.loss
甚至梯度。
让我们通过编写一个回调来看看这在实践中是如何工作的。
创建回调
当您想要编写自己的回调时,可用事件的完整列表如下:
begin_fit
在做任何事情之前调用;适用于初始设置。
begin_epoch
在每个周期开始时调用;对于需要在每个周期重置的任何行为都很有用。
begin_train
在周期的训练部分开始时调用。
begin_batch
在每个批次开始时调用,就在绘制该批次之后。可以用于对批次进行任何必要的设置(如超参数调度)或在输入/目标进入模型之前对其进行更改(例如,通过应用 Mixup)。
after_pred
在计算模型对批次的输出后调用。可以用于在将其馈送到损失函数之前更改该输出。
after_loss
在计算损失之后但在反向传播之前调用。可以用于向损失添加惩罚(例如在 RNN 训练中的 AR 或 TAR)。
after_backward
在反向传播之后调用,但在参数更新之前调用。可以在更新之前对梯度进行更改(例如通过梯度裁剪)。
after_step
在步骤之后和梯度归零之前调用。
after_batch
在批次结束时调用,以在下一个批次之前执行任何必要的清理。
after_train
在时代的训练阶段结束时调用。
begin_validate
在时代的验证阶段开始时调用;用于特定于验证所需的任何设置。
after_validate
在时代的验证部分结束时调用。
after_epoch
在时代结束时调用,进行下一个时代之前的任何清理。
after_fit
在训练结束时调用,进行最终清理。
此列表的元素可作为特殊变量event
的属性使用,因此您只需在笔记本中键入event.
并按 Tab 键即可查看所有选项的列表
让我们看一个例子。您是否还记得在第十二章中我们需要确保在每个时代的训练和验证开始时调用我们的特殊reset
方法?我们使用 fastai 提供的ModelResetter
回调来为我们执行此操作。但它究竟是如何工作的呢?这是该类的完整源代码:
class ModelResetter(Callback):
def begin_train(self): self.model.reset()
def begin_validate(self): self.model.reset()
是的,实际上就是这样!它只是在完成时代的训练或验证后,调用一个名为reset
的方法。
回调通常像这样“简短而甜美”。实际上,让我们再看一个。这是添加 RNN 正则化(AR 和 TAR)的 fastai 回调的源代码:
class RNNRegularizer(Callback):
def __init__(self, alpha=0., beta=0.): self.alpha,self.beta = alpha,beta
def after_pred(self):
self.raw_out,self.out = self.pred[1],self.pred[2]
self.learn.pred = self.pred[0]
def after_loss(self):
if not self.training: return
if self.alpha != 0.:
self.learn.loss += self.alpha * self.out[-1].float().pow(2).mean()
if self.beta != 0.:
h = self.raw_out[-1]
if len(h)>1:
self.learn.loss += self.beta * (h[:,1:] - h[:,:-1]
).float().pow(2).mean()
自己编写代码。
回去重新阅读“激活正则化和时间激活正则化”,然后再看看这里的代码。确保您理解它在做什么以及为什么。
在这两个示例中,请注意我们如何可以通过直接检查self.model
或self.pred
来访问训练循环的属性。这是因为Callback
将始终尝试获取其内部Learner
中没有的属性。这些是self.learn.model
或self.learn.pred
的快捷方式。请注意,它们适用于读取属性,但不适用于编写属性,这就是为什么当RNNRegularizer
更改损失或预测时,您会看到self.learn.loss =
或self.learn.pred =
。
在编写回调时,可以直接检查Learner
的以下属性:
model
用于训练/验证的模型。
data
底层的DataLoaders
。
loss_func
使用的损失函数。
opt
用于更新模型参数的优化器。
opt_func
用于创建优化器的函数。
cbs
包含所有Callback
的列表。
dl
用于迭代的当前DataLoader
。
x
/xb
从self.dl
中绘制的最后一个输入(可能由回调修改)。xb
始终是一个元组(可能有一个元素),x
是去元组化的。您只能分配给xb
。
y
/yb
从self.dl
中绘制的最后一个目标(可能由回调修改)。yb
始终是一个元组(可能有一个元素),y
是去元组化的。您只能分配给yb
。
pred
从self.model
中绘制的最后预测(可能由回调修改)。
loss
最后计算的损失(可能由回调修改)。
n_epoch
此次训练的时代数。
n_iter
当前self.dl
中的迭代次数。
纪元
当前纪元索引(从 0 到n_epoch-1
)。
iter
self.dl
中的当前迭代索引(从 0 到n_iter-1
)。
以下属性由TrainEvalCallback
添加,除非您刻意删除该回调,否则应该可用:
train_iter
自此次训练开始以来已完成的训练迭代次数
pct_train
已完成的训练迭代的百分比(从 0 到 1)
training
一个指示我们是否处于训练模式的标志
以下属性由Recorder
添加,除非您刻意删除该回调,否则应该可用:
smooth_loss
训练损失的指数平均版本
回调也可以通过使用异常系统中断训练循环的任何部分。
回调排序和异常
有时回调需要能够告诉 fastai 跳过一个批次或一个纪元,或者完全停止训练。例如,考虑TerminateOnNaNCallback
。这个方便的回调将在损失变为无穷大或NaN
(不是一个数字)时自动停止训练。以下是此回调的 fastai 源代码:
class TerminateOnNaNCallback(Callback):
run_before=Recorder
def after_batch(self):
if torch.isinf(self.loss) or torch.isnan(self.loss):
raise CancelFitException
raise CancelFitException
这一行告诉训练循环在这一点中断训练。训练循环捕获此异常并不再运行任何进一步的训练或验证。可用的回调控制流异常如下:
CancelFitException
跳过本批次的其余部分并转到after_batch
。
CancelEpochException
跳过本纪元的训练部分的其余部分并转到after_train
。
CancelTrainException
跳过本纪元的验证部分的其余部分并转到after_validate
。
CancelValidException
跳过本纪元的其余部分并转到after_epoch
。
CancelBatchException
训练中断并转到after_fit
。
您可以检测是否发生了其中一个异常,并添加代码,以在以下事件之后立即执行:
after_cancel_batch
在继续到after_batch
之前立即到达CancelBatchException
后
after_cancel_train
在继续到after_epoch
之前立即到达CancelTrainException
后
after_cancel_valid
在继续到after_epoch
之前立即到达CancelValidException
后
after_cancel_epoch
在继续到after_epoch
之前立即到达CancelEpochException
后
after_cancel_fit
在继续到after_fit
之前立即到达CancelFitException
后
有时需要按特定顺序调用回调。例如,在TerminateOnNaNCallback
的情况下,很重要的是Recorder
在此回调之后运行其after_batch
,以避免注册NaN
损失。您可以在回调中指定run_before
(此回调必须在之前运行...)或run_after
(此回调必须在之后运行...)以确保您需要的顺序。
结论
在本章中,我们仔细研究了训练循环,探讨了 SGD 的变体以及为什么它们可能更强大。在撰写本文时,开发新的优化器是一个活跃的研究领域,因此在阅读本章时,可能会在书籍网站上发布新变体的附录。请务必查看我们的通用优化器框架如何帮助您快速实现新的优化器。
我们还研究了强大的回调系统,该系统允许您通过允许您在每个步骤之间检查和修改任何参数来自定义训练循环的每一部分。
问卷调查
-
SGD 一步的方程是什么,以数学或代码形式(您喜欢的方式)?
-
我们传递什么给
cnn_learner
以使用非默认优化器? -
什么是优化器回调?
-
优化器中的
zero_grad
是做什么的? -
优化器中的
step
是做什么的?通常优化器中如何实现它? -
重写
sgd_cb
以使用+=
运算符,而不是add_
。 -
什么是动量?写出方程式。
-
动量的物理类比是什么?它如何应用在我们的模型训练设置中?
-
动量值越大对梯度有什么影响?
-
1cycle 训练的动量的默认值是多少?
-
RMSProp 是什么?写出方程。
-
梯度的平方值表示什么?
-
Adam 与动量和 RMSProp 有何不同?
-
写出 Adam 的方程。
-
计算几批虚拟值的
unbias_avg
和w.avg
的值。 -
在 Adam 中,
eps
值较高会产生什么影响? -
阅读 fastai 存储库中的优化器笔记本并执行它。
-
在哪些情况下,像 Adam 这样的动态学习率方法会改变权重衰减的行为?
-
训练循环的四个步骤是什么?
-
为什么使用回调比为每个想要添加的调整编写新的训练循环更好?
-
fastai 回调系统设计的哪些方面使其像复制和粘贴代码片段一样灵活?
-
在编写回调时,如何获取可用事件的列表?
-
编写
ModelResetter
回调(请不要偷看)。 -
如何在回调内部访问训练循环的必要属性?何时可以使用或不使用与它们配套的快捷方式?
-
回调如何影响训练循环的控制流?
-
编写
TerminateOnNaN
回调(如果可能的话,请不要偷看)。 -
如何确保你的回调在另一个回调之后或之前运行?
进一步研究
-
查阅“修正的 Adam”论文,使用通用优化器框架实现它,并尝试一下。搜索其他最近在实践中表现良好的优化器,并选择一个实现。
-
查看文档中的混合精度回调。尝试理解每个事件和代码行的作用。
-
从头开始实现自己版本的学习率查找器。与 fastai 的版本进行比较。
-
查看 fastai 附带的回调的源代码。看看能否找到一个与你要做的类似的回调,以获得一些灵感。
深度学习基础:总结
恭喜你——你已经完成了书中“深度学习基础”部分!现在你理解了 fastai 的所有应用程序和最重要的架构是如何构建的,以及训练它们的推荐方法——你拥有构建这些内容所需的所有信息。虽然你可能不需要创建自己的训练循环或批归一化层,但了解幕后发生的事情对于调试、性能分析和部署解决方案非常有帮助。
既然你现在理解了 fastai 应用的基础,一定要花时间深入研究源代码笔记本,并运行和实验它们的部分。这将让你更清楚地了解 fastai 中的所有内容是如何开发的。
在下一节中,我们将更深入地探讨:我们将探索神经网络的实际前向和后向传递是如何进行的,以及我们可以利用哪些工具来获得更好的性能。然后,我们将继续进行一个项目,将书中的所有材料汇集在一起,用它来构建一个用于解释卷积神经网络的工具。最后但并非最不重要的是,我们将从头开始构建 fastai 的Learner
类。