本章涵盖以下内容
- 理解卷积
- 构建卷积神经网络
- 创建自定义
nn.Module子类 - 模块式 API 与函数式 API 的区别
- 神经网络中的设计选择
在上一章中,我们构建了一个简单的神经网络。借助线性层中大量可供优化的参数,它能够对数据进行拟合(或者过拟合)。我们的模型使用的是全连接层:每个神经元都与前一层中的每个神经元相连,把图像当成一个被拉平的一维向量来处理,而完全不保留其空间结构。不过,这个模型存在问题:它更擅长“记住”训练集,而不是真正泛化出鸟和飞机的共同属性。基于模型的架构,我们大致能猜到原因。由于全连接结构必须去适应图像中鸟或飞机可能出现的各种平移位置,我们既拥有了过多的参数(这让模型更容易记住训练集),又缺乏位置无关性(这让它更难泛化)。正如上一章讨论的那样,我们当然可以通过大量重新裁剪图像的方式来增强训练数据,试图强迫模型学会泛化,但这并不能解决“参数太多”这个根本问题。
有一种更好的办法!它的核心是:把神经网络单元中那个稠密的、全连接的仿射变换,替换成另一种线性操作——卷积。
8.1 为什么要用卷积
现在让我们彻底弄明白什么是卷积,以及怎样在神经网络中使用它。是的,是的,我们本来正在忙着完成“区分鸟和飞机”的任务,而我们的朋友还在等着我们的解决方案,但这段绕路值得花这点时间。我们会为这个计算机视觉中的基础概念建立直觉,然后带着“超能力”回到原来的问题。
在这一节中,我们将看到卷积是如何带来局部性(locality) 和 平移不变性(translation invariance) 的。为此,我们会仔细看看定义卷积的公式,并用纸笔演算它——不过别担心,核心理解会来自图示,而不是公式本身。
我们之前说过:把输入图像拉平成一维向量,再用一个 n_output_features × n_input_features 的权重矩阵与之相乘(也就是 nn.Linear 所做的事),这意味着:对于图像中的每个通道,都要把所有像素与一组权重做加权求和,每个输出特征对应一组权重。我们也说过:如果我们想识别与物体对应的模式,比如天空中的一架飞机,那么我们很可能更关心相邻像素是如何排列的,而不会太关心图像中彼此相距很远的像素是如何组合出现的。说到底,一张喷火式战斗机的图像,角落里有没有树、云或者风筝,其实并不重要。
要把这种直觉转化为数学形式,我们可以只计算某个像素与其邻近像素的加权和,而不是与整幅图像中所有其他像素的加权和。这就等价于:为每个输出特征、每个输出像素位置,构造一个权重矩阵,并使得距离某个中心像素超过一定范围的权重全部为零。它仍然是一个加权求和,也就是说,它仍然是一种线性操作。
8.1.1 卷积到底做了什么
前面我们还确定了另一个想要的性质:我们希望这些局部模式无论出现在图像的什么位置,都能对输出产生影响——也就是说,我们希望模型具有平移不变性。如果要在第 7 章那种“图像作为向量”的矩阵表示中实现这一点,就需要设计一种相当复杂的权重模式(如果现在觉得它太复杂,也不用担心,马上就会变清楚):权重矩阵的大部分元素都会是零(对应那些离输出像素太远、不会产生影响的输入像素)。而对于其他权重,我们还必须设法让那些对应于“相同相对位置关系”的输入像素和输出像素的权重保持同步。因此,我们不仅要把它们初始化为相同的值,还要确保在训练过程中网络更新参数时,这些被“绑在一起”的权重始终保持相同。这样我们才能保证:权重只在局部邻域内起作用,以便响应局部模式;而且无论这些局部模式在图像哪里出现,都能被识别出来。
显然,这种做法不仅麻烦,而且几乎不切实际。幸运的是,图像上现成就存在一种同时具备局部性和平移不变性的线性运算:卷积。我们当然可以给卷积下一个更紧凑的定义,但接下来我们要描述的,和刚才那种复杂方案本质上是同一件事——只是换了一个角度来看。
卷积,更准确地说是离散卷积(discrete convolution) (与之对应还有连续版本,这里不展开),对于二维图像的定义是:把一个权重矩阵——也就是卷积核(kernel) ——与输入图像中的每一个局部邻域做标量积。考虑一个 3 × 3 的卷积核(在深度学习中,我们通常使用小卷积核,后面会解释原因),表示为一个二维张量:
weight = torch.tensor([[w00, w01, w02],
[w10, w11, w12],
[w20, w21, w22]])
再考虑一张单通道的 M×N 图像:
image = torch.tensor([[i00, i01, i02, i03, ..., i0N],
[i10, i11, i12, i13, ..., i1N],
[i20, i21, i22, i23, ..., i2N],
[i30, i31, i32, i33, ..., i3N],
...
[iM0, iM1m iM2, iM3, ..., iMN]])
注意 PyTorch 中的卷积和数学上严格定义的卷积之间有一个细微差别:其中一个操作数没有做符号翻转。如果我们较真一点,其实可以把 PyTorch 的“卷积”称为离散互相关(discrete cross-correlation) 。
现在,我们可以按如下方式计算输出图像中的一个元素(这里暂时不考虑 bias):
o11 = i11 * w00 + i12 * w01 + i13 * w02 +
i21 * w10 + i22 * w11 + i23 * w12 +
i31 * w20 + i32 * w21 + i33 * w22
图 8.1 展示了这个计算过程。
图 8.1 卷积:局部性与平移不变性
也就是说,我们把卷积核“平移”到输入图像中 i11 所在的位置上,再让每个权重与输入图像中对应位置的像素值相乘。于是,输出图像就是通过把卷积核平移到所有输入位置,并对每个位置执行加权求和而生成的。对于多通道图像,比如我们的 RGB 图像,权重矩阵就会变成一个 3 × 3 × 3 的张量:每个通道各有一组权重,它们共同对输出值作出贡献。
注意,就像 nn.Linear 的权重矩阵中的元素一样,卷积核中的权重也不是预先已知的;它们同样会被随机初始化,并通过反向传播不断更新。不同之处在于:同一个卷积核会在整张图像上反复复用,因此卷积核中的每一个权重都在整张图像范围内参与了计算。回忆一下 autograd 的工作方式,一个权重一旦在整张图像上都参与了运算,那么损失函数对这个卷积权重的导数,自然也会包含来自整张图像各处的贡献。
到这里,我们已经可以看出它和前面讨论的联系了:一个卷积,本质上等价于许多个线性操作,这些线性操作的权重几乎处处都为零,只有在某个像素附近的一小块区域内非零;而且这些操作中的相应权重在训练时接收的是相同的更新。这一过程如图 8.2 所示。
图 8.2 卷积及其等价的线性操作表示。线性权重会是稀疏的(只有少数非零值)并且是绑定的(相同的数值会在多个位置重复出现)。
总结一下,切换到卷积之后,我们得到了:
- 对局部邻域执行操作
- 平移不变性
- 参数数量大幅减少的模型
第三点背后的关键洞见在于:对于卷积层来说,参数数量不再依赖于图像中的像素数——而这正是我们全连接模型的问题所在。相反,它只依赖于卷积核的大小(例如 3 × 3、5 × 5 等),以及我们决定在模型中使用多少个卷积滤波器(也就是输出通道数)。
8.2 卷积实战
好了,看起来我们已经在理论兔子洞里待得够久了!现在让我们重新回到“鸟和飞机”这个挑战上,看看 PyTorch 里卷积是如何真正派上用场的。torch.nn 模块为 1 维、2 维和 3 维数据都提供了卷积:时间序列用 nn.Conv1d,图像用 nn.Conv2d,体数据或视频用 nn.Conv3d。
对于我们的 CIFAR-10 数据,我们会使用 nn.Conv2d。最基本地,传给 nn.Conv2d 的参数包括:输入特征数(或者说输入通道数,因为我们面对的是多通道图像;也就是每个像素不止一个值)、输出特征数,以及卷积核的大小。比如,对于第一个卷积模块,我们每个像素有 3 个输入特征(RGB 三个通道),而输出通道数可以是某个任意值——比如 16。输出图像中的通道越多,网络容量就越大。我们需要这些通道,是为了让网络能够检测很多不同类型的特征。而且由于这些卷积核是随机初始化的,因此即使训练完成后,其中一些特征也可能最终根本没有用。我们先固定使用 3 × 3 的卷积核。
注意 这和所谓的 lottery ticket hypothesis(彩票假设) 有点关系:很多卷积核最终可能和没中奖的彩票一样没用。
通常情况下,卷积核在各个方向上的大小都相同,因此 PyTorch 为此提供了一个简写:对于二维卷积,只要写 kernel_size=3,就表示 3 × 3(在 Python 中等价于传入元组 (3, 3));对于三维卷积,则表示 3 × 3 × 3。本书第二部分中我们会见到 CT 扫描,它在三个轴中的某一个方向上体素(体积像素)分辨率不同。在那种情况下,使用在不同维度上尺寸不同的卷积核就是有意义的。但现在,我们先统一用各个维度相同大小的卷积:
# In[7]:
conv = nn.Conv2d(3, 16, kernel_size=3) #1
conv
# Out[7]:
Conv2d(3, 16, kernel_size=(3, 3), stride=(1, 1))
#1 其中 3 表示输入通道数(通常对应彩色图像的红、绿、蓝三个通道),16 表示输出通道数。最后一个参数 kernel_size=3,也可以等价地写成输出里显示的元组形式:kernel_size=(3, 3)。
那么,我们预期 conv.weight 张量会是什么形状呢?
这个层被设计成:接收一个有 3 个通道的输入,对它应用 16 个不同的 3 × 3 卷积滤波器,并生成 16 个输出通道。对于单个输出像素来说,卷积核会同时查看 3 个输入通道,因此,一个输出像素(也就对应整个某个输出通道)所使用的权重部分,其形状应为 in_ch (3) × 3 × 3。而因为有 16 个输出通道,所以这一层完整的权重张量形状就是 out_ch (16) × in_ch (3) × 3 × 3,也就是 16 × 3 × 3 × 3。而 bias 的大小则应为 16(我们有一阵子没提 bias 了,只是为了简化叙述;它和线性层中的 bias 一样,都是一个加到每个输出通道上的常数项)。我们来验证一下这个推断:
# In[8]:
conv.weight.shape, conv.bias.shape
# Out[8]:
(torch.Size([16, 3, 3, 3]), torch.Size([16]))
现在可以看出,卷积对于图像学习来说是多么合适:模型更小,专注于寻找局部模式,而且这些权重会在整幅图像上共同被优化。
一次二维卷积前向传播会产生一张二维输出图像,其中每个像素都是对输入图像某个局部邻域加权求和得到的。在我们的例子中,卷积核权重和 bias 都是随机初始化的,因此输出图像暂时不会有什么特别有意义的结构。和之前一样,如果我们只想把一张图像送入 conv 模块,就需要通过 unsqueeze 在最前面加上 batch 维度,因为 nn.Conv2d 期望输入张量的形状是 B × C × H × W:
# In[9]:
img, _ = cifar2[0]
output = conv(img.unsqueeze(0))
img.unsqueeze(0).shape, output.shape
# Out[9]:
(torch.Size([1, 3, 32, 32]), torch.Size([1, 16, 30, 30]))
我们很好奇,所以可以把输出画出来,如图 8.3 所示:
# In[12]:
# 这里略去辅助函数 plot_images 的定义
plot_images(img, output)
图 8.3 左侧是原始的鸟图像,右侧是经过一次随机卷积处理后的图像。
等一下。我们来看看 output 的尺寸:它是 torch.Size([1, 16, 30, 30])。嗯?怎么丢掉了几个像素?这是怎么回事?
8.2.1 在边界处进行填充
输出图像比输入图像小,这是我们在处理图像边界时所作选择带来的副作用。对一个 3 × 3 邻域执行卷积加权求和,要求中心像素在上下左右各个方向都必须有邻居。如果我们位于索引 (0, 0) 的位置,那么它右边和下边有像素,但左边和上边是没有的。PyTorch 默认会让卷积核在输入图像内部滑动,因此在水平和垂直方向上,能够放置卷积核的位置数都是 width - kernel_width + 1。对于奇数尺寸的卷积核,这就意味着:输出图像在每一边都会比输入图像少掉“卷积核宽度的一半”(这里 3 // 2 = 1)那么多像素。因此,我们每个维度都少了两个像素,这就解释了为什么尺寸从 32 × 32 变成了 30 × 30。
不过,PyTorch 允许我们通过填充(padding) 来解决这个问题:在图像边界周围补上一圈“幽灵像素”,在卷积看来这些像素的值都是 0。图 8.4 展示了 padding 的作用。
图 8.4 padding=1 会在输入四周补上一圈 0,从而使输出图像保持与输入相同的尺寸。
在我们的例子里,当 kernel_size=3 时指定 padding=1,意味着原图中索引 (0, 0) 的位置,在上方和左侧都会额外得到一圈“邻居”,这样即使在原始图像的角落,也能计算出卷积输出。
注意 对于偶数大小的卷积核,左右(以及上下)通常需要填充的像素数并不相同。PyTorch 不支持在卷积模块本身中直接这样做,不过 torch.nn.functional.pad 可以处理这种情况。当然,最好还是坚持使用奇数大小的卷积核;偶数大小的卷积核说到底只是“不那么好用的奇数核”。
使用 padding 的直接结果就是:输出图像现在和输入图像的尺寸完全一样了:
# In[13]:
conv = nn.Conv2d(3, 1, kernel_size=3, padding=1) #1
output = conv(img.unsqueeze(0))
img.unsqueeze(0).shape, output.shape
# Out[13]:
(torch.Size([1, 3, 32, 32]), torch.Size([1, 1, 32, 32]))
#1 现在加入了 padding
注意,无论是否使用 padding,weight 和 bias 的尺寸都不会发生变化。
对卷积进行 padding 主要有两个原因。第一,它能帮助我们把“做卷积”和“改变图像尺寸”这两件事拆开,从而少记一件复杂的事。第二,当我们以后构建更复杂的结构,例如跳跃连接(skip connections) (8.5.3 节会讲)或 U-Net(第 10 章会讲)时,我们希望若干次卷积前后的张量尺寸是兼容的,这样才能方便地做加法或求差。
8.2.2 用卷积检测特征
前面说过,weight 和 bias 都是通过反向传播学习得到的参数,就和 nn.Linear 中的权重与偏置完全一样。不过,我们现在也可以先手动设置卷积权重,看看会发生什么。
先把 bias 清零,避免它干扰观察;然后把卷积核中的权重都设成一个常数值,这样输出中的每个像素就会变成其邻域像素的平均值。对于每个 3 × 3 邻域:
# In[14]:
with torch.no_grad():
conv.bias.zero_()
with torch.no_grad():
conv.weight.fill_(1.0 / 9.0)
我们本来也可以用 conv.weight.one_(),那样的话输出中的每个像素就会变成邻域像素值的总和,而不是平均值。差别其实不大,只是输出图像中的数值会大 9 倍而已。
总之,我们来看看这个卷积核对 CIFAR 图像有什么影响:
# In[15]:
output = conv(img.unsqueeze(0))
plot_images(img, output)
正如我们所预料的那样,这个滤波器会产生一个模糊版的图像,如图 8.5 所示。毕竟,输出中的每个像素都是输入中某个邻域的平均值,因此输出图像中的相邻像素彼此更相关,变化也更平滑。
图 8.5 这一次,我们的鸟被一个常值卷积核模糊化了
接下来,我们试点不一样的。下面这个卷积核一开始看上去可能有点神秘:
# In[16]:
conv = nn.Conv2d(3, 1, kernel_size=3, padding=1)
with torch.no_grad():
conv.weight[:] = torch.tensor([[-1.0, 0.0, 1.0],
[-1.0, 0.0, 1.0],
[-1.0, 0.0, 1.0]])
conv.bias.zero_()
就像我们前面针对一般卷积核所做的那样,来手工算一下位置 2,2 处某个像素的加权求和,我们得到:
o22 = i13 - i11 +
i23 - i21 +
i33 - i31
这个式子表示:把 i22 右侧所有像素的值加起来,再减去 i22 左侧所有像素的值。假如这个卷积核正好作用在两个相邻区域之间的一条垂直边界上,而这两个区域亮度差异较大,那么 o22 的值就会很高;反过来,如果卷积核作用在一个亮度基本一致的区域上,o22 就会接近 0。也就是说,这其实是一个边缘检测卷积核:它会突出两个水平相邻区域之间的垂直边缘。
把这个卷积核应用到我们的图像上,就会得到图 8.6 所示的结果。正如预期的那样,这个卷积核在整只鸟身上的垂直边缘处都会产生较强响应,因此强化了垂直边缘。我们当然还可以手工设计许多更复杂的滤波器,比如检测水平边缘、对角边缘,或者十字形、棋盘格等模式;所谓“检测”,就是让输出在这些模式出现的位置上有较大的响应。事实上,计算机视觉专家过去长期以来的工作之一,就是寻找一组最有效的滤波器组合,以便突出图像中的某些特征,从而实现物体识别(见图 8.7)。
图 8.6 一个人工设计的卷积核为我们的鸟突出显示了整张图里的垂直边缘
图 8.7 用于检测不同特征的不同卷积核示例。这里这些例子是我们为了说明原理而手工写死的;而在实际中,模型会自己学出适合当前任务的卷积核。
而在深度学习中,我们不再手工设计卷积核,而是让它们直接从数据中估计出来,只要这种方式能够在判别任务上达到最佳效果即可——例如,最小化模型输出与 ground truth 之间的负交叉熵损失,正如我们在 7.2.5 节中介绍的那样。从这个角度看,卷积神经网络的任务,就是在一层又一层的滤波器组中,估计出相应的卷积核,使得一个多通道图像被变换成另一个多通道图像,而不同通道对应着不同的特征(例如,一个通道表示均值响应,另一个通道表示垂直边缘,等等)。图 8.8 展示了训练是如何自动学习这些卷积核的。
图 8.8 卷积学习过程:通过计算卷积核权重上的梯度,并逐个更新它们来优化损失
8.2.3 通过深度与池化看得更远
到目前为止,一切都挺好。不过从概念上讲,这里有一个明显的问题。我们之所以对卷积感到兴奋,是因为从全连接层切换到卷积之后,模型获得了局部性和平移不变性。然后我们又推荐使用小卷积核,比如 3 × 3 或 5 × 5:这当然非常“局部”。但更大的整体结构怎么办?我们怎么知道图像中的所有结构都只有 3 像素宽或者 5 像素宽?答案显然是不知道,因为它们并不是。那么,如果图像中的模式范围更大,我们的网络又该如何看见这些更大尺度的结构?如果我们想真正解决“鸟和飞机”的问题,这一点就必须认真考虑——虽然 CIFAR-10 的图像很小,但图中的物体仍然会跨越好几个像素的“翼展”。
一种办法当然是直接使用大卷积核。是的,理论上极端情况下,我们完全可以对一张 32 × 32 的图像使用一个 32 × 32 的卷积核,但那样一来,我们就会重新退化回原来的全连接仿射变换,也就失去了卷积的所有优点。另一种方法,也是卷积神经网络普遍采用的方法,是:一层卷积接一层卷积地堆叠起来,同时在层与层之间对图像进行下采样。下采样(也叫 pooling 或 subsampling)指的是缩小特征图的空间尺寸,通常是通过在特征图的某些区域上取最大值或平均值来实现。
从大到小:下采样
从原理上讲,下采样可以有多种做法。把图像缩小一半,相当于每 4 个相邻像素生成 1 个输出像素。这个输出像素具体怎么由输入像素计算出来,则取决于我们的选择。我们可以:
- 对这 4 个像素求平均——这叫平均池化(average pooling) 。早期这是常用方法,但如今热度有所下降。
- 对这 4 个像素取最大值——这叫最大池化(max pooling) 。这是目前最常见的做法,不过它的缺点是会丢弃剩余四分之三的数据。
- 使用带步长的卷积(strided convolution),只计算每隔
N个像素的位置——只要卷积核尺寸不小于2 × 2且stride=2,那么上一层中的所有像素仍然都会对输出有贡献。文献显示这种方法很有潜力,但目前还没有真正取代 max pooling。
接下来我们会重点使用 max pooling。图 8.9 展示了最常见的设置:把图像划分成互不重叠的 2 × 2 小块,并从每一块中取最大值,作为缩小后图像中的一个新像素。
图 8.9 最大池化的细节示意
从直觉上说,卷积层输出的图像——尤其是当后面还接着激活函数时,和其他线性层一样——往往会在某些与卷积核对应的特征被检测到的位置上拥有较大的响应值(例如垂直线条)。因此,在 2 × 2 邻域中保留最大的那个值作为下采样输出,就能确保那些已经被检测到的显著特征在下采样后仍然得以保留,而较弱的响应则会被牺牲掉。
最大池化由 nn.MaxPool2d 模块提供(和卷积类似,它也有适用于 1D 和 3D 数据的版本)。它的输入参数是要进行池化操作的邻域大小。如果我们想把图像缩小一半,那么就应当使用大小为 2 的池化窗口。我们先直接在输入图像上验证一下它是否如预期那样工作:
# In[21]:
pool = nn.MaxPool2d(2)
output = pool(img.unsqueeze(0))
img.unsqueeze(0).shape, output.shape
# Out[21]:
(torch.Size([1, 3, 32, 32]), torch.Size([1, 3, 16, 16]))
将卷积与下采样结合起来,以达成更大的目标
现在让我们看看,把卷积和下采样组合起来,为什么能帮助我们识别更大的结构。在图 8.10 中,我们首先对一张 8 × 8 图像应用一组 3 × 3 卷积核,得到一张同样大小的多通道输出图像。接着,我们把输出图像缩小一半,变成一张 4 × 4 图像,然后再对它应用另一组 3 × 3 卷积核。由于第二组卷积核作用在一个已经缩小了一半的图像上,因此它在这个缩小图像上的 3 × 3 邻域,实际上对应回原始输入图像中更大的区域,等效上接近 8 × 8 的范围。除此之外,第二组卷积核操作的输入不再是原始像素,而是第一组卷积核提取出来的特征(例如均值、边缘等),因此它是在已有特征的基础上继续提取更高层特征。
图 8.10 手工演示更多卷积:通过堆叠卷积和下采样的效果,展示两个小的十字形卷积核加 max pooling 如何突出一个大的十字结构
输出像素的感受野(receptive field)
在图 8.10 中,当第二个 3 × 3 卷积核在其卷积输出中生成数值 21 时,这个结果是基于第一次 max pooling 输出中的左上角 3 × 3 像素计算出来的。而这 3 × 3 区域,反过来对应于第一次卷积输出中左上角的 6 × 6 像素区域;而那个 6 × 6 区域,又是由第一次卷积从原始输入中的左上角 7 × 7 像素区域计算出来的。因此,第二次卷积输出中的这个单个像素,其实受到了输入图像中一个 7 × 7 方块区域的影响。再考虑到第一次卷积为了在角落生成输出,还隐式使用了 padding 的一行一列,因此对于边界之外的位置,某些输出像素甚至会对应于 8 × 8 的输入区域。用更正式的话说,在这样一个由 3 × 3 conv → 2 × 2 max pool → 3 × 3 conv 组成的结构中,某个输出神经元的感受野是 8 × 8。
所以,一方面,第一组卷积核在小的局部邻域上提取一阶、低层次特征;另一方面,第二组卷积核则相当于在更大的邻域范围内操作,从而生成由前面那些特征组合而成的更高层特征。这个机制非常强大,它使卷积神经网络能够理解极其复杂的场景——远远超过 CIFAR-10 那种 32 × 32 小图像的复杂度。
8.2.4 把这些部件组装成我们的网络
现在,我们已经掌握了这些构件,可以着手构建一个用于识别鸟和飞机的卷积神经网络了。先以前一章的全连接模型为起点,按照刚才介绍的方式引入 nn.Conv2d 和 nn.MaxPool2d:
# In[22]:
model = nn.Sequential(
nn.Conv2d(3, 16, kernel_size=3, padding=1),
nn.Tanh(),
nn.MaxPool2d(2),
nn.Conv2d(16, 8, kernel_size=3, padding=1),
nn.Tanh(),
nn.MaxPool2d(2),
# ...
)
第一层卷积会把 3 个 RGB 通道变成 16 个通道,从而给网络机会生成 16 种彼此独立的特征,希望它们能帮助区分鸟和飞机的低层特征。然后我们应用 Tanh 激活函数。
注意 我们当然也可以用上一章见过的 ReLU,不过这里使用 Tanh,是为了展示:我们完全可以很方便地使用其他类型的激活函数。
这样得到的 16 通道 × 32 × 32 图像,经过第一个 MaxPool2d 之后会被池化成一个 16 通道 × 16 × 16 图像。接着,这个下采样后的图像再经过第二次卷积,生成一个 8 通道 × 16 × 16 的输出。运气好的话,这些通道中就会包含更高层次的特征。然后我们再次应用 Tanh 激活,并再次池化,得到一个 8 通道 × 8 × 8 的输出。
那么,这个过程该如何结束呢?当输入图像已经被压缩成一组 8 × 8 的特征后,我们希望网络最终能输出一些概率值,供负对数似然损失使用。然而,概率只是一个一维向量中的两个数(一个表示飞机,一个表示鸟),而此时我们手里还是一个多通道二维特征图。
回想一下本章开头,我们其实已经知道该怎么做了:把这个 8 通道 × 8 × 8 的图像拉平成一个一维向量,然后再接上一组全连接层,完成整个网络:
# In[23]:
model = nn.Sequential(
nn.Conv2d(3, 16, kernel_size=3, padding=1),
nn.Tanh(),
nn.MaxPool2d(2),
nn.Conv2d(16, 8, kernel_size=3, padding=1),
nn.Tanh(),
nn.MaxPool2d(2),
# ... #1
nn.Linear(8 * 8 * 8, 32),
nn.Tanh(),
nn.Linear(32, 2))
#1 警告:这里缺了一个很重要的步骤!
这段代码对应的神经网络如图 8.11 所示。
图 8.11 典型卷积网络的形状,也包括我们正在构建的这个。输入图像先经过一系列卷积和最大池化模块,然后被拉直成一维向量,再送入全连接模块。
先暂时忽略那个“缺了点什么”的注释。我们先注意到:线性层的输入尺寸取决于最后一个 MaxPool2d 输出的大小,也就是 8 × 8 × 8 = 512。现在来数一下这个小模型的参数量:
# In[25]:
numel_list = [p.numel() for p in model.parameters()]
sum(numel_list), numel_list
# Out[25]:
(18090, [432, 16, 1152, 8, 16384, 32, 64, 2])
对于这样一个有限大小、而且图像又这么小的数据集来说,这个参数规模是相当合理的。如果我们想提升模型容量,可以增加卷积层的输出通道数(也就是每层卷积生成的特征数),而这也会连带使后面的线性层尺寸增大。
我们之所以在代码里加上“警告”注释,是有原因的。这个模型根本不可能顺利跑起来:
# In[26]:
model(img.unsqueeze(0))
# Out[26]:
...
RuntimeError: mat1 and mat2 shapes cannot be multiplied (64x8 and 512x32)
平心而论,这个报错信息有点晦涩,但也不至于完全看不懂。在 traceback 中我们看到了和 linear 有关的提示;回头看模型,我们会发现,唯一需要一个 512 × 32 张量的模块就是 nn.Linear(512, 32),也就是最后一个卷积块后面接的第一个线性层。
那里缺失的,其实正是:把一个 8 通道 × 8 × 8 的图像重塑成一个长度为 512 的一维向量的步骤(严格说,如果不算 batch 维的话,是一维向量)。这本来可以通过对最后一个 nn.MaxPool2d 的输出调用 view 来完成,但不幸的是,当我们使用 nn.Sequential 时,并不能直接看到每个模块的中间输出。下一节我们就会解决这个问题。
8.3 继承 nn.Module
在神经网络开发过程中,我们迟早会遇到这样的情况:想要实现某种现成模块不直接支持的计算。眼下这个例子非常简单,只是一个 reshape。(从 PyTorch 1.3 开始,我们其实可以直接用 nn.Flatten,但这里我们故意通过继承 nn.Module 来做,是为了教学目的。)在本章后面,我们还会用同样的技巧去实现更复杂的神经网络结构。本节中,我们将学习如何定义自己的 nn.Module 子类,使它们像内置模块或者 nn.Sequential 一样使用。
当我们想构建的模型不再只是“一层接一层顺序堆叠”这么简单时,就需要离开 nn.Sequential,转而使用更灵活的方式。PyTorch 允许我们通过继承 nn.Module,把任意计算逻辑写进模型中。
在 PyTorch 里,nn.Module 是一个非常基础的构件,它既可以表示整个神经网络,也可以表示网络中的一个局部组件。这种递归性质正是 PyTorch 灵活性的关键——任何组件(比如一个卷积层)本身都是一个 nn.Module,而你的整个网络同样也是一个 nn.Module。
要继承 nn.Module,最基本的要求是定义一个 forward 函数,它接收模块输入并返回模块输出。这里就是我们定义模块计算逻辑的地方。此外,在 PyTorch 中,只要我们使用标准的 torch 运算,autograd 就会自动处理反向传播;也正因为如此,nn.Module 从来不需要显式定义 backward,因为它是隐式得到的。
通常来说,我们的计算过程会用到其他模块——可以是现成的卷积层,也可以是我们自己写的模块。为了把这些子模块纳入当前模块中,我们一般会在构造函数 __init__ 中定义它们,并赋值给 self,这样在 forward 中就能使用它们了。同时,它们也会在整个模块生命周期中持有自己的参数。注意,在做这些之前,你必须先调用 super().__init__()(否则 PyTorch 会立刻提醒你)。
8.3.1 把我们的网络写成一个 nn.Module
现在,我们把刚才的网络写成一个 nn.Module 子类。为此,我们会在构造函数中实例化所有单独的层模块(nn.Conv2d、nn.Linear 等),然后在 forward 方法中明确指定数据如何流经这些模块:
# In[27]:
class Net(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(3, 16, kernel_size=3, padding=1)
self.act1 = nn.Tanh()
self.pool1 = nn.MaxPool2d(2)
self.conv2 = nn.Conv2d(16, 8, kernel_size=3, padding=1)
self.act2 = nn.Tanh()
self.pool2 = nn.MaxPool2d(2)
self.fc1 = nn.Linear(8 * 8 * 8, 32)
self.act3 = nn.Tanh()
self.fc2 = nn.Linear(32, 2)
def forward(self, x):
out = self.pool1(self.act1(self.conv1(x)))
out = self.pool2(self.act2(self.conv2(out)))
out = out.view(-1, 8 * 8 * 8) #1
out = self.act3(self.fc1(out))
out = self.fc2(out)
return out
#1 这个 reshape 正是我们前面缺失的那一步。
从所包含的子模块来看,Net 类和我们前面构建的 nn.Sequential 模型是等价的。但由于这里我们显式写出了 forward,所以可以直接操作 self.pool2 的输出,并对它调用 view,把它变成一个 B × N 的向量。注意,我们在 view 中把 batch 维度写成 -1,让 PyTorch 自动推断它的大小,因为原则上我们并不知道一个 batch 里会有多少个样本。
这里我们用 nn.Module 的子类来包裹整个模型。实际上,我们也完全可以用子类来定义新的构件,再把它们组合成更复杂的网络。沿用第 6 章中的图示风格,我们的网络大致如图 8.12 所示。至于图中哪些信息放在哪里,其实我们做的是一些比较临时性的展示选择。
图 8.12 我们的基线卷积网络架构
回忆一下,分类网络的目标通常是要对信息进行压缩:我们从一张拥有大量像素的图像出发,最终把它压缩成(一个类别概率向量的)输出。针对这个目标,我们当前这个架构里有两点值得评论。
第一,目标本身已经体现在中间表示的尺寸变化上:总体来说,这些中间张量是不断缩小的。我们既通过池化减少像素数量,也通过在线性层中让输出维度小于输入维度来减少通道维度。这是分类网络的一种常见特征。不过,在很多流行架构里,比如 ResNet,压缩信息的方式更多是通过降低空间分辨率(减小宽和高),同时增加通道数(整体尺寸仍然是在减少)。看起来,我们这种“快速压缩信息”的模式在浅层网络和小图像上效果不错,但对于更深的网络,信息压缩通常会更缓慢地进行。
第二,有一层的输出尺寸并没有相对于输入缩小——那就是最初的第一层卷积。如果我们把单个输出像素看作一个 32 维向量(也就是通道数),那么它实际上是对 27 个元素的线性变换(即 3 个通道 × 3 × 3 卷积核大小),这其实只是一个适度的扩张。在 ResNet 中,初始卷积则会从 147 个元素(3 个通道 × 7 × 7 卷积核大小)生成 64 个通道。
注意 “第一层卷积在逐像素线性映射上的维度关系”这一点,是 Jeremy Howard 在 fast.ai 课程中强调过的。
所以,第一层是一个特殊的例外:它会显著增加数据流经该层时的整体维度(可理解为“通道数 × 像素数”),但如果把每个输出像素单独拿出来看,它的映射依然大致是“输出数和输入数同一量级”的。
注意 在深度学习之外、也早于深度学习的一些机器学习方法里,先把数据投影到高维空间,再在上面使用概念上更简单(至少比线性模型更简单)的学习方法,这通常被称为 kernel trick(核技巧) 。从某种意义上说,第一层通道数的增加和这种现象有点类似,只不过它是在“嵌入本身的聪明程度”和“后续模型的简单程度”之间做了不同的权衡。
8.3.2 PyTorch 如何跟踪参数和子模块
有意思的是,就像我们刚才在构造函数里做的那样:只要把一个 nn.Module 实例赋值给另一个 nn.Module 的某个属性,PyTorch 就会自动把它注册为当前模块的一个子模块。
注意 这些子模块必须是顶层属性,不能藏在 list 或 dict 对象里面!否则,优化器将无法发现这些子模块,也就无法找到其中的参数。对于确实需要用列表或字典保存子模块的场景,PyTorch 提供了 nn.ModuleList 和 nn.ModuleDict。
我们当然也可以在 nn.Module 子类里定义任意其他方法。例如,对于那些“训练时的行为”和“预测时的行为”差异很大的模型,定义一个 predict 方法可能就很有意义。不过要注意:直接调用这些自定义方法,和直接调用 forward 很像;某些功能和机制(例如前向和反向中的 hooks,也就是注册进去的函数)在你绕开标准的 __call__、转而使用自定义方法时,是不会被执行的。
下面这段代码展示了:Net 无需用户额外做任何事,就能访问其所有子模块的参数:
# In[28]:
model = Net()
numel_list = [p.numel() for p in model.parameters()]
sum(numel_list), numel_list
# Out[28]:
(18090, [432, 16, 1152, 8, 16384, 32, 64, 2])
这里,parameters() 调用会深入遍历构造函数中以属性方式挂在模块上的所有子模块,并递归地对它们调用 parameters()。无论这些子模块嵌套得多深,任何一个 nn.Module 都可以访问其所有子模块参数的完整列表。由于这些参数的 grad 属性已经由 autograd 填充好了,优化器只需读取这些梯度,就知道该如何更新参数以最小化损失。这套机制我们在第 5 章里已经很熟了。
到这里,我们已经知道如何实现自己的模块了——而这一能力在本书第二部分会被频繁用到。
8.3.3 函数式 API
回头看看 Net 的实现,再想想我们为什么要在构造函数里注册这些子模块,好让模型能访问它们的参数,会发现一个问题:像 nn.Tanh 和 nn.MaxPool2d 这种根本没有参数的模块,也被我们注册成了子模块,似乎有点浪费。既然它们没有状态,那为什么不直接在 forward 里调用它们,就像我们直接调用 view 一样呢?
当然可以!这也正是 PyTorch 为几乎每个 nn 模块都提供函数式对应物的原因。这里所谓的“函数式”,指的是“没有内部状态”——换句话说,就是“其输出值完全且唯一由输入参数决定”。的确,torch.nn.functional 提供了许多函数,它们和 nn 里的模块做的是同样的事情。只不过,与模块版不同,这些函数不是依赖“输入 + 模块内部保存的参数”来工作,而是把输入和参数都当作普通函数参数显式传入。比如,nn.Linear 的函数式对应物是 nn.functional.linear,它的函数签名是 linear(input, weight, bias=None)。其中 weight 和 bias 都作为函数参数显式传入;而在我们前面使用的模块式版本中,它们是保存在模块内部的。表 8.1 总结了模块式和函数式两种方式之间的一些关键差异。
表 8.1 PyTorch 中模块式 API 与函数式 API 的比较
模块式(nn.Module) | 函数式(torch.nn.functional) |
|---|---|
例:nn.Linear(in_features, out_features) | 例:F.linear(input, weight, bias=None) |
| 具有内部状态(参数保存在模块中) | 没有内部状态(无状态函数) |
| 参数作为属性存储 | 参数作为函数参数传入 |
| 参数会被自动注册和跟踪 | 参数必须手动管理 |
常用于 nn.Conv2d、nn.Linear 这类层 | 常用于 F.relu、F.tanh 这类激活函数 |
通过 layer(input) 调用 | 通过 F.function(input, other_args) 调用 |
回到我们的模型,对 nn.Linear 和 nn.Conv2d 继续使用模块式 nn 是合理的,这样 Net 就能在训练时自动管理它们的 Parameter。但对于池化和激活函数,由于它们没有参数,我们完全可以安全地改用函数式对应物:
# In[29]:
import torch.nn.functional as F
class Net(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(3, 16, kernel_size=3, padding=1)
self.conv2 = nn.Conv2d(16, 8, kernel_size=3, padding=1)
self.fc1 = nn.Linear(8 * 8 * 8, 32)
self.fc2 = nn.Linear(32, 2)
def forward(self, x):
out = F.max_pool2d(torch.tanh(self.conv1(x)), 2)
out = F.max_pool2d(torch.tanh(self.conv2(out)), 2)
out = out.view(-1, 8 * 8 * 8)
out = torch.tanh(self.fc1(out))
out = self.fc2(out)
return out
这一定义比我们在 8.3.1 节中的 Net 要简洁得多,而且功能上完全等价。当然,对于那些初始化时需要多个参数的模块,依然很适合在构造函数里实例化出来。
从这个角度看,函数式写法也有助于我们更好地理解 nn.Module API 的本质:一个 Module,其实就是一个状态容器,其中保存着 Parameter 和子模块;同时,它还封装了执行前向传播的指令。
至于是用函数式 API 还是模块式 API,更多是风格和习惯问题。当网络中的某一段简单到可以直接用 nn.Sequential 拼起来时,我们自然是在模块式世界里;而当我们开始自己写 forward 时,对于那些根本不需要持有参数状态的部分,函数式接口往往会显得更自然。
所以现在,我们既知道了:需要时可以自己写 nn.Module;也知道了:在“实例化一个模块再调用它”显得过于笨重的场景下,还可以改用函数式 API。到这里,关于 PyTorch 中神经网络代码的组织方式,我们已经补齐了最后一块关键拼图;理解了这一点之后,你基本上就能看懂几乎任何用 PyTorch 实现的神经网络代码了。
现在,我们最后再确认一下模型确实能跑起来,然后就可以进入训练循环了:
# In[30]:
model = Net()
model(img.unsqueeze(0))
# Out[30]:
tensor([[ 0.0190, -0.0683]], grad_fn=<AddmmBackward0>)
我们得到了两个数!说明信息流动是正确的。你可能现在还没太大感觉,但在更复杂的模型里,线性层的输入尺寸算对这件事,经常会成为一个让人抓狂的点。甚至还流传着一些“大佬”的故事:他们会先随手写一个线性层尺寸,然后故意跑出 PyTorch 的报错信息,再根据报错反推正确的尺寸。听上去是不是有点糙?其实一点也不丢人——完全合理!
8.4 训练我们的卷积神经网络
现在我们已经来到了可以把完整训练循环组装起来的阶段。整体结构我们其实早在第 5 章就已经搭建过了,而这里的训练循环看起来也和第 6 章中的那一版很像。不过,这一次我们会重新审视它,并补充一些细节,比如记录准确率之类的信息。等模型真正跑起来之后,我们大概也会开始渴望更快一点的速度,因此接下来还会学习如何把模型放到 GPU 上高速运行。但在那之前,先来看训练循环本身。
回忆一下,训练的核心由两层嵌套循环组成:外层遍历各个 epoch,内层遍历 DataLoader,由它从 Dataset 中不断产生 batch。对于每一次循环,我们都需要做以下几件事:
- 把输入送入模型(前向传播)。
- 计算损失(这也属于前向传播的一部分)。
- 清空旧梯度。
- 调用
loss.backward(),计算损失相对于所有参数的梯度(反向传播)。 - 让优化器朝着更低损失的方向迈出一步。
同时,我们还会收集并打印一些信息。下面就是我们的训练循环,它和上一章中的版本几乎一样,不过还是值得再回顾一下每一行都在做什么:
# In[31]:
import datetime #1
def training_loop(n_epochs, optimizer, model, loss_fn, train_loader):
for epoch in range(1, n_epochs + 1): #2
start_time = datetime.datetime.now()
loss_train = 0.0
for imgs, labels in train_loader: #3
outputs = model(imgs) #4
loss = loss_fn(outputs, labels) #5
optimizer.zero_grad() #6
loss.backward() #7
optimizer.step() #8
loss_train += loss.item() #9
end_time = datetime.datetime.now()
epoch_duration = (end_time - start_time).total_seconds()
if epoch == 1 or epoch % 10 == 0:
print('{} Epoch {}, Training loss {:.6f}, Time {:.2f}s'.format(
datetime.datetime.now(), epoch,
loss_train / len(train_loader), epoch_duration)) #10
#1 使用 Python 标准库中的 datetime 模块
#2 按 epoch 进行循环,编号从 1 到 n_epochs,而不是从 0 开始
#3 在数据加载器为我们生成的各个 batch 上循环
#4 把一个 batch 送进模型……
#5 ……并计算我们希望最小化的损失
#6 清掉上一轮遗留下来的梯度之后……
#7 ……执行反向传播。也就是说,计算所有希望网络学习到的参数的梯度。
#8 使用刚刚计算出来的梯度来更新模型
#9 累加整个 epoch 中看到的损失。注意,必须用 .item() 把损失变成 Python 数值,以脱离计算图。
#10 再除以训练数据加载器的长度,得到每个 batch 的平均损失。这比直接看损失总和更直观。
这里我们继续使用第 7 章中的 Dataset,再用 DataLoader 把它包装起来;像之前一样实例化网络、优化器和损失函数;然后调用这个训练循环。
和上一章相比,模型方面最实质性的变化是:现在我们的模型是 nn.Module 的一个自定义子类,而且我们已经改用卷积了。接下来我们让它训练 100 个 epoch,并打印损失。根据你的硬件配置不同,这个过程可能需要 20 分钟甚至更久:
# In[32]:
train_loader = torch.utils.data.DataLoader(cifar2, batch_size=64,
shuffle=True) #1
model = Net() # #2
optimizer = optim.SGD(model.parameters(), lr=1e-2) # #3
loss_fn = nn.CrossEntropyLoss() # #4
training_loop( #5
n_epochs = 100,
optimizer = optimizer,
model = model,
loss_fn = loss_fn,
train_loader = train_loader,
)
# Out[32]:
2025-06-08 14:34:27.755713 Epoch 1, Training loss 0.553273, Time 1.72s
2025-06-08 14:34:39.389411 Epoch 10, Training loss 0.332629, Time 1.30s
2025-06-08 14:34:52.348689 Epoch 20, Training loss 0.283454, Time 1.34s
2025-06-08 14:35:05.399002 Epoch 30, Training loss 0.259716, Time 1.28s
2025-06-08 14:35:18.432120 Epoch 40, Training loss 0.239583, Time 1.26s
2025-06-08 14:35:31.409170 Epoch 50, Training loss 0.220342, Time 1.46s
2025-06-08 14:35:44.441435 Epoch 60, Training loss 0.204420, Time 1.24s
2025-06-08 14:35:57.261905 Epoch 70, Training loss 0.190523, Time 1.29s
2025-06-08 14:36:10.689505 Epoch 80, Training loss 0.175177, Time 1.32s
2025-06-08 14:36:23.668655 Epoch 90, Training loss 0.160240, Time 1.40s
2025-06-08 14:36:36.904013 Epoch 100, Training loss 0.147124, Time 1.20s
#1 DataLoader 会把 cifar2 数据集中的样本打包成 batch。shuffle=True 会在每个 epoch 对样本顺序随机打乱。
#2 实例化我们的网络……
#3 ……以及我们一直在使用的随机梯度下降优化器……
#4 ……还有第 7 章中引入的交叉熵损失
#5 调用前面定义好的训练循环
现在我们确实可以训练这个网络了。不过,话说回来,如果我们只是告诉那位观鸟的朋友“我们的训练损失已经很低了”,她大概率不会因此留下什么深刻印象。
8.4.1 衡量准确率
为了得到一个比损失更容易理解的指标,我们可以看看模型在训练集和验证集上的准确率。这里使用的代码和第 7 章里是一样的:
# In[33]:
train_loader = torch.utils.data.DataLoader(cifar2, batch_size=64,
shuffle=False)
val_loader = torch.utils.data.DataLoader(cifar2_val, batch_size=64,
shuffle=False)
def validate(model, train_loader, val_loader):
for name, loader in [("train", train_loader), ("val", val_loader)]:
correct = 0
total = 0
with torch.no_grad(): #1
for imgs, labels in loader:
outputs = model(imgs)
_, predicted = torch.max(outputs, dim=1) #2
total += labels.shape[0] #3
correct += int((predicted == labels).sum()) #4
print("Accuracy {}: {:.2f}".format(name , correct / total))
validate(model, train_loader, val_loader)
# Out[33]:
Accuracy train: 0.95
Accuracy val: 0.90
#1 这里我们不需要梯度,因为不会更新参数。
#2 取每个输出向量中最大值所在的索引
#3 统计样本数,因此 total 会按 batch 大小增加
#4 将预测类别(最大概率对应的类)与真实标签比较,会得到一个布尔数组。对它求和,就得到该 batch 中预测正确的样本数。
这里我们把结果转成 Python 的 int;对于整数张量来说,这和我们在训练循环里使用 .item() 的作用是等价的。
这个模型比上一章的全连接模型强了不少——上一章的验证准确率只有 80%。也就是说,我们大约把验证集上的错误数减半了。而且,我们使用的参数数量还少得多:从上一章全连接模型的 370 多万个参数,一下子降到了卷积模型中的 18,090 个参数,减少幅度超过了 99%。这一点突出了深度学习中的一个基本观念:模型架构比参数数量更重要! 我们的卷积模型之所以能更好地泛化到新的图像样本,是因为它具备了局部性和平移不变性。到这里,我们其实已经可以让它继续训练更多 epoch,看看还能榨出多少性能。
8.4.2 保存和加载模型
既然目前看来我们对这个模型已经比较满意,那把它保存下来当然会很方便,对吧?这件事做起来很简单。我们先把模型保存到文件里:
# In[34]:
torch.save(model.state_dict(), data_path + 'birds_vs_airplanes.pt')
现在,birds_vs_airplanes.pt 这个文件中已经包含了 model 的全部参数——也就是两个卷积模块和两个线性模块中的权重和偏置。注意,这里面存的只有参数,没有结构。这意味着:等以后我们要把模型部署给那位朋友正式使用时,仍然需要保留模型类的定义,重新创建一个实例,然后再把这些参数加载进去:
# In[35]:
loaded_model = Net() #1
loaded_model.load_state_dict(torch.load(data_path
+ 'birds_vs_airplanes.pt'))
# Out[35]:
<All keys matched successfully>
#1 这意味着,在保存和以后重新加载模型状态之间,我们必须确保 Net 的定义没有发生改变。
我们在代码仓库中也附带提供了一个预训练好的模型,保存在 ../data/p1ch8/birds_vs_airplanes.pt。
8.4.3 在 GPU 上训练
现在我们有了一个网络,而且它确实能训练起来!不过,要是再快一点就更好了。毫不意外,提速的方法就是:把训练搬到 GPU 上去。利用我们在第 3 章中见过的 .to 方法,可以把数据加载器产出的张量移动到 GPU 上,之后相关计算就会自动在那里进行。但与此同时,我们也必须把模型参数本身移到 GPU 上。好在 nn.Module 已经实现了 .to 方法,它会把模块里的所有参数都移动到 GPU(或者在传入 dtype 参数时执行类型转换)。
这里有一个比较微妙的区别:Module.to 和 Tensor.to 的行为并不一样。Module.to 是原地修改(in place) 的;而 Tensor.to 则是非原地(out of place) 操作,和 Tensor.tanh 这样的计算类似,它会返回一个新的张量。来看下面这个具体例子:
# In[]:
model_a = Net()
model_b = model_a.to("cuda")
print(model_a is model_b)
tensor_a = torch.rand(1)
tensor_b = tensor_a.to("cuda")
print(tensor_a is tensor_b)
# Out[]:
True
False
在 Python 里,is 运算符用来检查两个变量是否指向同一个对象。从输出中可以看出,model_a 和 model_b 是同一个对象,而 tensor_a 和 tensor_b 则不是。Module.to 的一个实际含义是:通常最好在把模型参数迁移到合适设备之后,再去创建 Optimizer。
一种推荐写法是:如果 GPU 可用,就把东西迁移到 GPU。一个常见模式是根据 torch.cuda.is_available() 来设置一个 device 变量:
# In[36]:
device = (torch.device('cuda') if torch.cuda.is_available()
else torch.device('cpu'))
print(f"Training on device {device}.")
然后,我们只需要对训练循环稍作修改:把从数据加载器拿到的张量通过 Tensor.to 挪到 GPU 上。注意,下面这段代码和本节开头给出的第一版训练循环完全一样,唯一的区别就是多了把输入移到 GPU 的那两行:
# In[37]:
import datetime
def training_loop(n_epochs, optimizer, model, loss_fn, train_loader):
for epoch in range(1, n_epochs + 1):
start_time = datetime.datetime.now()
loss_train = 0.0
for imgs, labels in train_loader:
imgs = imgs.to(device=device) #1
labels = labels.to(device=device)
outputs = model(imgs)
loss = loss_fn(outputs, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
loss_train += loss.item()
end_time = datetime.datetime.now()
epoch_duration = (end_time - start_time).total_seconds()
if epoch == 1 or epoch % 10 == 0:
print('{} Epoch {}, Training loss {:.6f}, Time {:.2f}s'.format(
datetime.datetime.now(), epoch,
loss_train / len(train_loader), epoch_duration))
#1 这两行把 imgs 和 labels 移到训练设备上,是我们当前版本 training_loop 和前一版本唯一的区别。
validate 函数也必须做同样的修改。之后,我们就可以像之前一样实例化模型,把它移动到 device 上,然后开跑:
注意 DataLoader 还有一个 pin_memory 选项,它会让数据加载器使用固定在 GPU 传输路径上的内存,以期提升传输速度。不过它是否真的带来收益要看具体情况,因此这里我们不再展开。
# In[38]:
train_loader = torch.utils.data.DataLoader(cifar2, batch_size=64,
shuffle=True)
model = Net().to(device=device) #1
optimizer = optim.SGD(model.parameters(), lr=1e-2)
loss_fn = nn.CrossEntropyLoss()
training_loop(
n_epochs = 100,
optimizer = optimizer,
model = model,
loss_fn = loss_fn,
train_loader = train_loader,
)
# Out[38]:
2025-06-08 14:36:43.831980 Epoch 1, Training loss 0.591598, Time 6.02s
2025-06-08 14:36:47.707613 Epoch 10, Training loss 0.328330, Time 0.40s
2025-06-08 14:36:52.163547 Epoch 20, Training loss 0.289371, Time 0.36s
2025-06-08 14:36:56.326006 Epoch 30, Training loss 0.262738, Time 0.44s
2025-06-08 14:37:00.233507 Epoch 40, Training loss 0.241435, Time 0.50s
2025-06-08 14:37:05.260639 Epoch 50, Training loss 0.227982, Time 0.53s
2025-06-08 14:37:09.706318 Epoch 60, Training loss 0.211528, Time 0.44s
2025-06-08 14:37:14.019777 Epoch 70, Training loss 0.198672, Time 0.51s
2025-06-08 14:37:17.456679 Epoch 80, Training loss 0.186976, Time 0.32s
2025-06-08 14:37:20.734218 Epoch 90, Training loss 0.175323, Time 0.32s
2025-06-08 14:37:24.933440 Epoch 100, Training loss 0.162720, Time 0.42s
#1 把我们的模型(所有参数)都移到 GPU 上。如果你忘了把模型或者输入其中之一移到 GPU,PyTorch 运算就会报错,说张量不在同一个设备上,因为它不支持混用 CPU 和 GPU 输入。
即使对于我们这里这个很小的网络,也已经能看到相当明显的提速。对于更大的模型,GPU 计算的优势会更显著。
不过,在加载网络权重时会有一点小麻烦:PyTorch 默认会尝试把权重加载回它保存时所在的那个设备。也就是说,如果保存时权重在 GPU 上,那么恢复时也会默认回到 GPU 上。而我们并不一定希望它回到同样的设备,因此有两个选择:要么在保存前先把网络移回 CPU,要么在恢复之后再挪到想要的设备上。更简洁的一种方式,是在加载权重时直接告诉 PyTorch 覆盖掉原来的设备信息。这可以通过向 torch.load 传入 map_location 关键字参数来完成:
# In[40]:
loaded_model = Net().to(device=device)
loaded_model.load_state_dict(torch.load(data_path
+ 'birds_vs_airplanes.pt',
map_location=device))
# Out[40]:
<All keys matched successfully>
8.5 模型设计
我们已经把模型写成了 nn.Module 的子类——这几乎是除最简单模型外的事实标准。接着,我们成功训练了它,也看到如何用 GPU 来加速训练。到这里,我们已经具备了构建并成功训练一个前馈卷积神经网络来做图像分类的能力。自然而然,接下来就会有一个问题:然后呢? 如果我们面对的是一个更复杂的问题,该怎么办?说到底,我们这个“鸟和飞机”的数据集其实并不复杂:图像很小,目标位于中心,而且占据了视野的大部分。
如果我们把场景换成 ImageNet 之类的数据集,就会遇到更大、更复杂的图像。在那种情况下,正确答案通常依赖多个视觉线索,而且这些线索往往是分层次组织的。比如,当网络要判断一个深色的砖块状物体到底是遥控器还是手机时,它也许会去寻找某种像“屏幕”这样的局部结构。
更何况,现实世界中的任务也不只涉及图像。我们还有表格数据、序列数据和文本。神经网络真正的承诺在于:只要我们能给出合适的架构(也就是各层/模块之间的连接方式)和合适的损失函数,它就有足够的灵活性去处理这些不同种类的数据。
PyTorch 自带了一套非常全面的模块和损失函数,足以实现从前馈网络到长短期记忆网络(LSTM)再到 Transformer 这样的最先进架构(后两者都是处理序列数据时非常流行的模型)。此外,还有不少模型可以通过 PyTorch Hub,或者通过 torchvision 以及其他垂直社区项目直接获取。
在第二部分中,我们还会见到一些更高级的架构;届时我们会围绕端到端问题展开,同时探索神经网络架构的不同变体。不过,基于目前为止积累的知识,我们已经能理解:借助 PyTorch 的表达能力,我们几乎可以实现任何架构。本节的目的,正是提供一些概念性工具,让我们以后看到最新研究论文时,能直接开始在 PyTorch 里实现它;或者由于作者常常会直接放出 PyTorch 实现,至少也能把这些实现看懂,而不会一边看一边被咖啡呛到。
8.5.1 增加记忆容量:宽度(Width)
在当前这个前馈架构基础上,如果先不引入更复杂的东西,我们最可能想先探索的有两个方向。第一个方向是网络的宽度(width) :也就是每层的神经元数量,或者对于卷积层来说,就是通道数。在 PyTorch 中,把模型变宽非常容易。只要在第一层卷积里指定更多的输出通道,并在后续层中相应地扩大规模,同时记得在 forward 函数里更新那次从卷积表示切换到全连接表示时向量的长度即可:
# In[41]:
class NetWidth(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(3, 32, kernel_size=3, padding=1)
self.conv2 = nn.Conv2d(32, 16, kernel_size=3, padding=1)
self.fc1 = nn.Linear(16 * 8 * 8, 32)
self.fc2 = nn.Linear(32, 2)
def forward(self, x):
out = F.max_pool2d(torch.tanh(self.conv1(x)), 2)
out = F.max_pool2d(torch.tanh(self.conv2(out)), 2)
out = out.view(-1, 16 * 8 * 8)
out = torch.tanh(self.fc1(out))
out = self.fc2(out)
return out
如果我们不想在模型定义里把这些数字写死,也完全可以给 __init__ 传入一个参数,把宽度参数化;同时记得,在 forward 中调用 view 的地方也要同步参数化:
# In[42]:
class NetWidth(nn.Module):
def __init__(self, n_chans1=32):
super().__init__()
self.n_chans1 = n_chans1
self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1)
self.conv2 = nn.Conv2d(n_chans1, n_chans1 // 2, kernel_size=3,
padding=1)
self.fc1 = nn.Linear(8 * 8 * n_chans1 // 2, 32)
self.fc2 = nn.Linear(32, 2)
def forward(self, x):
out = F.max_pool2d(torch.tanh(self.conv1(x)), 2)
out = F.max_pool2d(torch.tanh(self.conv2(out)), 2)
out = out.view(-1, 8 * 8 * self.n_chans1 // 2)
out = torch.tanh(self.fc1(out))
out = self.fc2(out)
return out
这些指定每一层通道数和特征数的数字,与模型的参数总量是直接相关的;在其他条件不变时,它们会增加模型容量。和之前一样,我们也可以看看现在这个模型有多少参数:
# In[45]:
sum(p.numel() for p in model.parameters())
# Out[45]:
38386
模型容量越大,它能够处理的输入变化范围通常也越大;但与此同时,也越容易发生过拟合,因为模型有更多参数可以拿去“记住”那些并不重要的输入细节。我们之前已经讨论过对抗过拟合的方法,其中最有效的通常仍然是:增加样本数量;如果拿不到新数据,那就对现有数据做人为修改,也就是数据增强。
除此之外,在模型层面(而不是数据层面)我们也还有一些常见技巧可以用来控制过拟合。下面就来回顾其中最典型的几种。
8.5.2 帮助模型收敛与泛化:正则化(Regularization)
训练一个模型包含两个关键步骤:首先是优化(optimization) ,也就是让训练集上的损失下降;其次是泛化(generalization) ,也就是让模型不仅能在训练集上工作,还能在它没见过的数据上工作,比如验证集。那些旨在帮助这两个步骤的数学工具,通常都会被归到“正则化(regularization) ”这个大类下面。
正则化包含了一整套用于防止过拟合的技术。本节我们会看几种典型策略。
把参数控制在合理范围内:权重惩罚(weight penalties)
让泛化更稳定的第一种方式,是在损失函数中加入一个正则项。这个项会促使模型权重倾向于保持较小,从而限制训练过程把它们推得太大。换句话说,它就是对较大权重值施加惩罚。这样一来,损失函数的几何形状会更平滑,而模型通过强行拟合单个样本能够得到的收益也会相对减少。
这类正则项中最流行的是:
- L2 正则化:对模型中所有权重的平方求和
- L1 正则化:对模型中所有权重的绝对值求和
注意 这里我们主要关注 L2 正则化。L1 正则化在更广泛的统计学领域里因为 Lasso 而非常著名,它一个很有吸引力的性质是:往往会得到比较稀疏的训练后权重。这两类正则项通常都会乘上一个较小的系数,这个系数是我们在训练前设定的一个超参数。
L2 正则化也常被称为 weight decay(权重衰减) 。这个名字的来源在于:如果你从 SGD 和反向传播的角度去想,L2 正则项对某个参数 w_i 的负梯度是 - 2 * lambda * w_i,其中 lambda 就是刚才提到的那个超参数。因此,把 L2 正则化加到损失里,等价于在优化器更新时,让每一个权重都按与它当前值成正比的量往小了缩一点(所以叫“衰减”)。注意,weight decay 不只作用在普通权重上,也会作用于网络中的其他参数,比如 bias。
打个直观的比方:你可以把神经网络的参数想象成花园里的植物。花园里如果有一株植物长得过大,遮住了其他植物,就不太理想;类似地,在神经网络里,我们也不希望某一个权重变得太过强势,以至于主导整个模型输出。而 weight decay 就像你工具箱里的一把修枝剪,专门用来给这些“植物”修剪。lambda 越大,这把剪刀就越狠!
在 PyTorch 里,我们完全可以手工把正则项加到损失上。也就是说,在计算出原始损失之后,不管你原来用的是什么损失函数,都可以遍历模型参数,把它们的平方和(L2)或绝对值和(L1)加到损失里,再一起反向传播:
# In[46]:
def training_loop_l2reg(n_epochs, optimizer, model, loss_fn,
train_loader):
for epoch in range(1, n_epochs + 1):
loss_train = 0.0
for imgs, labels in train_loader:
imgs = imgs.to(device=device)
labels = labels.to(device=device)
outputs = model(imgs)
loss = loss_fn(outputs, labels)
l2_lambda = 0.001
l2_norm = sum(p.pow(2.0).sum()
for p in model.parameters()) #1
loss = loss + l2_lambda * l2_norm
optimizer.zero_grad()
loss.backward()
optimizer.step()
loss_train += loss.item()
if epoch == 1 or epoch % 10 == 0:
print('{} Epoch {}, Training loss {}'.format(
datetime.datetime.now(), epoch,
loss_train / len(train_loader)))
#1 如果要做 L1 正则化,就把 pow(2.0) 换成 abs()
不过,PyTorch 里的 SGD 优化器本身就已经有一个 weight_decay 参数,它对应的就是前面说的 2 * lambda,并且会在更新步骤中直接执行权重衰减。它和在损失里手工加上权重的 L2 范数是完全等价的,但省去了我们手动累加损失项、并让 autograd 处理它们的麻烦。因此,通常更推荐直接使用 PyTorch 自带的 weight_decay 参数,这样也能减少自己实现时出错的风险。
不让模型过度依赖单个输入:Dropout
2014 年,来自 Geoff Hinton 团队的 Nitish Srivastava 和合作者提出了一种非常有效的防过拟合策略,并把论文题目直接取名为 “Dropout: A Simple Way to Prevent Neural Networks from Overfitting” 。听起来几乎正是我们想要的,对吧?Dropout 的思想确实很简单:在每次训练迭代中,随机把网络中一部分神经元的输出置零。
这样一来,每次训练时,网络实际看到的都是一个稍有不同的子模型,神经元之间就更难在过拟合过程中“串通”起来共同记住训练集。换个角度看,dropout 其实是在扰动模型内部生成的特征,它的作用有点像数据增强(即对训练数据做各种变换和扰动),只不过这一次,扰动发生在网络内部的各层表示上。
在 PyTorch 中,我们可以通过在非线性激活函数和下一层线性/卷积模块之间插入一个 nn.Dropout 模块来实现 dropout。我们需要为它指定一个参数,即输入被置零的概率。对于卷积网络,我们通常会使用专门的 nn.Dropout2d 或 nn.Dropout3d,它们会按整通道来置零输入:
# In[48]:
class NetDropout(nn.Module):
def __init__(self, n_chans1=32):
super().__init__()
self.n_chans1 = n_chans1
self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1)
self.conv1_dropout = nn.Dropout2d(p=0.4)
self.conv2 = nn.Conv2d(n_chans1, n_chans1 // 2, kernel_size=3,
padding=1)
self.conv2_dropout = nn.Dropout2d(p=0.4)
self.fc1 = nn.Linear(8 * 8 * n_chans1 // 2, 32)
self.fc2 = nn.Linear(32, 2)
def forward(self, x):
out = F.max_pool2d(torch.tanh(self.conv1(x)), 2)
out = self.conv1_dropout(out)
out = F.max_pool2d(torch.tanh(self.conv2(out)), 2)
out = self.conv2_dropout(out)
out = out.view(-1, 8 * 8 * self.n_chans1 // 2)
out = torch.tanh(self.fc1(out))
out = self.fc2(out)
return out
注意,dropout 通常只在训练阶段启用;而在推理或部署时,它会被跳过,或者等价地说,它的置零概率会被视为 0。这是由 Dropout 模块的训练状态控制的。回忆一下,PyTorch 可以通过下面两种调用,在任意 nn.Module 子类上切换模式:
model.train()
或
model.eval()
这个调用会自动递归地作用到子模块上,因此如果其中包含 Dropout,它在后续前向和反向传播中的行为也会自动跟着切换。如果在推理时忘记把模型切换到 eval 模式,这种错误往往不会直接报错,但会默默损害模型性能,是一种很典型的“静默失败”。
把激活控制在合理范围内:批归一化(Batch Normalization)
就在 dropout 风头正盛时,2015 年 Sergey Ioffe 和 Christian Szegedy 又发表了一篇同样极具影响力的论文,题目是 “Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift” 。它提出了一种技术,能在训练中带来多个积极效果:让我们可以使用更大的学习率、减少训练对初始化的敏感性,并且本身也具有一定的正则化作用,在某种意义上成了 dropout 的一种替代选择。
批归一化的核心思想,是对网络中激活函数的输入进行重新缩放,使得每个 minibatch 在这些位置上都具有某种理想的分布。结合我们对学习机制和非线性激活函数作用的理解,这种分布有助于避免激活函数输入过度进入其饱和区,从而导致梯度消失、训练变慢(见图 8.13)。
图 8.13 对于 3 个样本、每个样本有 4 个输入特征的情况,BatchNorm 会先沿着样本维度,对每个特征分别计算均值和标准差,然后用这些统计量把输入特征归一化,使每个特征具有零均值和单位方差。
在实际中,批归一化会利用某一中间层位置上、当前 minibatch 中各个样本的均值和标准差,对这个位置的中间输入进行平移和缩放。它之所以具有正则化效果,是因为:对于某一个具体样本而言,它以及它后续产生的激活,在模型看来总是会随着随机抽取出来的 minibatch 不同,而被以略微不同的方式平移和缩放。从某种意义上说,这本身也是一种有原则的数据增强。原论文作者甚至认为,使用 batch normalization 可以消除,或至少减轻,对 dropout 的需求。
在 PyTorch 中,批归一化由 nn.BatchNorm1d、nn.BatchNorm2d 和 nn.BatchNorm3d 提供,分别对应不同维度的输入。由于 batch normalization 的目标是重新缩放激活函数的输入,因此它自然应该放在线性变换(这里是卷积)之后、激活函数之前,如下所示:
# In[50]:
class NetBatchNorm(nn.Module):
def __init__(self, n_chans1=32):
super().__init__()
self.n_chans1 = n_chans1
self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1)
self.conv1_batchnorm = nn.BatchNorm2d(num_features=n_chans1)
self.conv2 = nn.Conv2d(n_chans1, n_chans1 // 2, kernel_size=3,
padding=1)
self.conv2_batchnorm = nn.BatchNorm2d(num_features=n_chans1 // 2)
self.fc1 = nn.Linear(8 * 8 * n_chans1 // 2, 32)
self.fc2 = nn.Linear(32, 2)
def forward(self, x):
out = self.conv1_batchnorm(self.conv1(x))
out = F.max_pool2d(torch.tanh(out), 2)
out = self.conv2_batchnorm(self.conv2(out))
out = F.max_pool2d(torch.tanh(out), 2)
out = out.view(-1, 8 * 8 * self.n_chans1 // 2)
out = torch.tanh(self.fc1(out))
out = self.fc2(out)
return out
和 dropout 一样,batch normalization 在训练和推理阶段的行为也必须不同。事实上,在推理时,我们不希望模型针对某个特定输入的输出,还会受到“当前同时送入模型的其他样本统计量”的影响。因此,我们需要一种机制,让归一化仍然存在,但它所使用的归一化参数要固定下来,不再随着当前 batch 波动。
PyTorch 的做法是:在训练过程中,除了使用当前 minibatch 来估计这一批的均值和标准差之外,它还会持续更新一组运行中的均值和标准差估计值,用来近似表示整个数据集的统计特征。于是,当用户调用:
model.eval()
并且模型中含有 batch normalization 模块时,这些运行统计量就会被冻结,并用于归一化。相反,如果想恢复使用当前 minibatch 的统计量,就调用 model.train(),和 dropout 时完全一样。
8.5.3 变得更深,以学习更复杂的结构:深度(Depth)
前面我们说过,宽度是让模型变大、也在某种意义上更有能力的第一个方向。第二个最根本的方向显然就是深度(depth) 。毕竟这是一本深度学习的书,我们理应对“深”这件事抱有某种天然好感。毕竟,更深的模型总是比更浅的模型更好,对吧?嗯,也得看情况。
随着深度增加,网络能够逼近的函数复杂度通常也会提高。以计算机视觉为例,一个较浅的网络也许只能在照片中识别出“这是一个人的轮廓”;而一个更深的网络,则可能不仅识别出这个人,还能识别出轮廓上半部分的脸,以及脸里的嘴。也就是说,深度让模型有能力处理分层次组织的信息:为了判断输入中的某个局部,你可能需要先理解更大范围的上下文。
还有一种理解深度的方式:增加深度,相当于增加了网络在处理输入时可以执行的操作序列长度。这种把深层网络看成“一连串操作”的视角,对软件开发者而言特别有吸引力,因为他们本来就习惯把算法理解为一系列步骤,例如“找到人的边界,在边界上方寻找头部,再在头部内部寻找嘴巴”。
跳跃连接(Skip Connections)
不过,深度也伴随着额外挑战。正是这些挑战,使得深度学习模型直到 2015 年末之前,都很难真正训练到 20 层甚至更深。一般来说,模型越深,训练就越难收敛。回想一下反向传播,如果把它放到一个非常深的网络中来看,就会发现:损失函数相对于参数的导数,尤其是早期层参数的导数,必须乘上很多其他数值,而这些数值来自从损失到该参数之间那一长串导数链条。如果链条中乘上的数普遍比较小,那么结果就会越来越小;反之,如果其中有些数过大,也可能因为浮点数近似而吞没较小的数。归根结底,长链条乘法往往会让某个参数对梯度的贡献逐渐消失,导致该层训练效果很差,因为这些参数得不到有效更新。
2015 年 12 月,Kaiming He 及其合作者提出了 残差网络(ResNet) 。这一架构使用了一个极其简单的技巧,使得非常深的网络也能够成功训练。这项工作打开了从几十层到上百层深度网络的大门,并刷新了当时计算机视觉基准任务上的最佳结果。这个技巧就是:通过跳跃连接来“短接”若干层,如图 8.14 所示。
图 8.14 我们这个三层卷积网络的结构。真正让 NetRes 与 NetDepth 区分开的,就是那条跳跃连接。
所谓跳跃连接,本质上不过是:把某个层块的输入直接加到这个层块的输出上。在 PyTorch 中,这就是字面意义上的加法。我们先在简单卷积模型基础上再加一层,为了多样性,这里也把激活函数从 Tanh 切换成 ReLU。增加一层后的“普通版”模块如下:
# In[52]:
class NetDepth(nn.Module):
def __init__(self, n_chans1=32):
super().__init__()
self.n_chans1 = n_chans1
self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1)
self.conv2 = nn.Conv2d(n_chans1, n_chans1 // 2, kernel_size=3,
padding=1)
self.conv3 = nn.Conv2d(n_chans1 // 2, n_chans1 // 2,
kernel_size=3, padding=1)
self.fc1 = nn.Linear(4 * 4 * n_chans1 // 2, 32)
self.fc2 = nn.Linear(32, 2)
def forward(self, x):
out = F.max_pool2d(torch.relu(self.conv1(x)), 2)
out = F.max_pool2d(torch.relu(self.conv2(out)), 2)
out = F.max_pool2d(torch.relu(self.conv3(out)), 2)
out = out.view(-1, 4 * 4 * self.n_chans1 // 2)
out = torch.relu(self.fc1(out))
out = self.fc2(out)
return out
如果要把一个 ResNet 风格的跳跃连接加到这个模型中,本质上就是:在 forward 函数里,把第二层的输出加到第三层的输入路径中去:
# In[54]:
class NetRes(nn.Module):
def __init__(self, n_chans1=32):
super().__init__()
self.n_chans1 = n_chans1
self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1)
self.conv2 = nn.Conv2d(n_chans1, n_chans1 // 2, kernel_size=3,
padding=1)
self.conv3 = nn.Conv2d(n_chans1 // 2, n_chans1 // 2,
kernel_size=3, padding=1)
self.fc1 = nn.Linear(4 * 4 * n_chans1 // 2, 32)
self.fc2 = nn.Linear(32, 2)
def forward(self, x):
out = F.max_pool2d(torch.relu(self.conv1(x)), 2)
out = F.max_pool2d(torch.relu(self.conv2(out)), 2)
out1 = out
out = F.max_pool2d(torch.relu(self.conv3(out)) + out1, 2)
out = out.view(-1, 4 * 4 * self.n_chans1 // 2)
out = torch.relu(self.fc1(out))
out = self.fc2(out)
return out
换句话说,除了常规的前馈路径外,我们还把前一层激活的输出直接作为后一层的输入之一。这种做法也常被称为 identity mapping(恒等映射) 。那么,它为什么能缓解前面提到的梯度消失问题呢?
从反向传播的角度看,可以理解为:跳跃连接,或者在一个深网络中一连串的跳跃连接,为深层参数到损失函数之间提供了一条更直接的路径(也就是“捷径”)。这样一来,这些参数对损失梯度的贡献就更直接,因为损失相对于这些参数的偏导数,不再一定要经过一长串其他操作的连乘才传回来。
经验上看,跳跃连接对收敛通常有明显的帮助,尤其是在训练初期更是如此。并且,带有残差连接的深层网络,其损失函数地形通常也比同样深度和宽度的纯前馈网络更加平滑。
顺便说一句,ResNet 出现时,跳跃连接其实并不是什么全新的想法。早在那之前,Highway Networks 和 U-Net 就已经以某种形式使用过跳跃连接了。不过,ResNet 对跳跃连接的使用方式,第一次真正让深度超过 100 层的模型变得可以训练。
在 ResNet 之后,其他一些架构又把跳跃连接推进得更远。其中一个代表是 DenseNet,它提议让每一层都通过跳跃连接与后续多层相连,从而用更少的参数实现当时的最先进效果。到了现在,我们其实已经知道怎么去实现类似 DenseNet 的东西:本质上无非就是把更早的中间输出,通过算术加法,送到更靠后的中间输出中去。
在 PyTorch 中构建非常深的模型
前面提到过,我们完全可以把卷积神经网络堆到 100 层以上。那么,在 PyTorch 中,怎样才能把这样的网络写出来,又不至于把自己写崩溃呢?标准做法是:先定义一个基本构件,比如一个 (Conv2d, ReLU, Conv2d) + skip connection 的块,然后在一个 for 循环里动态构建整个网络。下面我们就看一个实际示例。目标是创建图 8.15 所示的网络。
图 8.15 我们带残差连接的深层架构。左边定义了一个简化的残差块,右边展示了如何把它作为构件拼装进整个网络。
我们先定义一个模块子类,它的唯一职责就是完成一个块的计算——也就是一组卷积、激活和跳跃连接:
# In[56]:
class ResBlock(nn.Module):
def __init__(self, n_chans):
super(ResBlock, self).__init__()
self.conv = nn.Conv2d(n_chans, n_chans, kernel_size=3,
padding=1, bias=False) #1
self.batch_norm = nn.BatchNorm2d(num_features=n_chans)
torch.nn.init.kaiming_normal_(self.conv.weight,
nonlinearity='relu') #2
torch.nn.init.constant_(self.batch_norm.weight, 0.5)
torch.nn.init.zeros_(self.batch_norm.bias)
def forward(self, x):
out = self.conv(x)
out = self.batch_norm(out)
out = torch.relu(out)
return out + x
#1 因为 BatchNorm 会抵消 bias 的作用,所以这里通常就把 bias 去掉了。
#2 这里使用了自定义初始化。kaiming_normal_ 会根据 ResNet 论文中的方法,用合适标准差的正态分布随机初始化卷积权重。BatchNorm 则被初始化成:初始时输出分布大致为 0 均值、0.5 方差。
由于我们打算生成一个很深的模型,因此在这个块中加入了 batch normalization,因为它有助于防止训练中梯度消失。现在我们想要得到一个包含 100 个 block 的网络。难道这意味着我们得准备开始疯狂复制粘贴了吗?当然不用。到现在为止,我们其实已经拥有足够多的拼装积木,可以很自然地想象出它的写法了。
首先,在 __init__ 中,我们创建一个 nn.Sequential,其内容是一长串 ResBlock 实例。nn.Sequential 会保证前一个 block 的输出作为下一个 block 的输入;同时,它也会保证这些 block 中的所有参数都能被 Net 看到。然后,在 forward 中,我们只需要调用这个 sequential,让数据顺着这 100 个 block 流过去并产生输出:
# In[57]:
class NetResDeep(nn.Module):
def __init__(self, n_chans1=32, n_blocks=100):
super().__init__()
self.n_chans1 = n_chans1
self.conv1 = nn.Conv2d(3, n_chans1, kernel_size=3, padding=1)
self.resblocks = nn.Sequential(
*(n_blocks * [ResBlock(n_chans=n_chans1)]))
self.fc1 = nn.Linear(8 * 8 * n_chans1, 32)
self.fc2 = nn.Linear(32, 2)
def forward(self, x):
out = F.max_pool2d(torch.relu(self.conv1(x)), 2)
out = self.resblocks(out)
out = F.max_pool2d(out, 2)
out = out.view(-1, 8 * 8 * self.n_chans1)
out = torch.relu(self.fc1(out))
out = self.fc2(out)
return out
在这个实现中,我们把实际层数参数化了,这一点对于实验和复用来说都非常重要。并且不用说,反向传播仍然会像预期那样正常工作。毫不意外,这样的网络收敛速度会慢不少;同时,收敛过程本身也会更脆弱。这也正是为什么我们在这里使用了更讲究的初始化方式,并且给 NetRes 采用了比其他网络更小的学习率。严格来说,我们其实并没有把任何一个网络真正训练到收敛,但如果没有这些额外技巧,我们几乎根本走不到现在这一步。
当然,这一讨论并不是鼓励你在 32 × 32 小图像数据集上疯狂追求深度。但它确实清楚展示了:在更具挑战性的数据集(比如 ImageNet)上,这是完全可行的。同时,它也为我们理解现有实现——例如 torchvision 中的 ResNet——提供了关键的认知基础。
初始化(Initialization)
我们顺带简要评论一下前面用到的初始化。初始化是神经网络训练中的关键技巧之一。不幸的是,出于历史原因,PyTorch 默认的权重初始化并不总是理想的。目前社区里一直有人在推动这方面的改进。如果这件事后来真的有了进展,可以在 GitHub 上追踪相关讨论。在此之前,我们往往仍然得自己去调整初始化。
我们当时发现模型难以收敛,于是先去看了大家在类似情形下通常如何初始化(例如:卷积权重方差更小,batch norm 初始时产生 0 均值、单位方差的输出),然后在网络依然不收敛的情况下,又把 batch norm 的输出方差再减半。
权重初始化这个话题,其实完全值得单独写上一整章;不过那样对本书来说就有点太过头了。在第二部分中,我们还会再次遇到初始化问题;届时我们会采用一些从实践角度看更接近“理应就是 PyTorch 默认值”的初始化方式,而不会在这里过多展开。
注意 等你真正走到那一步——也就是对权重初始化的细节本身产生强烈兴趣的时候(大概率不会早于读完整本书)——可以再回过头来看这个话题。关于这一主题的经典论文是 Glorot 与 Bengio 2010 年的那篇,它提出了 PyTorch 中著名的 Xavier 初始化。前面提到的 ResNet 论文也对此做了进一步讨论,给出了我们这里用到的 Kaiming 初始化。更近一些的工作里,H. Zhang 等人甚至通过进一步改进初始化,使得他们在超深残差网络实验中都不再需要 batch norm 了。
8.5.4 比较本节中的几种设计
图 8.16 总结了我们在本节中逐一尝试过的这些设计修改所带来的效果。我们不应该过度解读其中任何具体数值:整个问题设置和实验本身都很简化,而且如果换不同的随机种子重复实验,验证准确率的波动很可能就已经和这些差异差不多大了。在这组演示中,我们故意把其他条件保持不变,例如学习率和训练 epoch 数;而在真实实践中,我们当然会同时调整这些因素去寻找最佳结果。同时,我们也很可能会把其中的多个设计元素组合起来使用。
图 8.16 这些修改过的网络整体表现都差不多。
不过,我们还是可以做一个定性的观察:就像我们在 5.5.3 节讨论验证与过拟合时看到的那样,weight decay 和 dropout 这两种方法——从统计估计角度看,它们更符合“正则化”的严格含义——往往会表现出训练准确率与验证准确率之间更小的差距。而 batch norm 更像是一个帮助收敛的工具,它会让网络在训练集上几乎达到 100% 准确率。因此,我们更倾向于把前两者看作正则化手段。
8.5.5 它已经开始过时了
作为深度学习实践者,既幸福又痛苦的一点就在于:神经网络架构演化得非常快。这并不是说本章中介绍的内容已经老掉牙了;只是如果想系统地介绍“当下最新最强”的架构,那几乎得另外写一本书(而且就算写出来了,它们很快也不再是“最新最强”了)。真正重要的 takeaway 是:我们应该尽最大努力,把论文中的数学想法熟练地翻译成实际的 PyTorch 代码;至少,也应该有能力看懂别人抱着同样目的所写出来的实现。在最近这几章里,你应该已经积累了不少把想法转化为 PyTorch 模型实现所需的基本功。
8.6 结论
经过相当多的努力之后,我们现在终于拥有了一个模型,可以让那位虚构的朋友 Jane 拿去给她的博客过滤图像。我们要做的,只是把一张新来的图像裁剪并缩放到 32 × 32,然后看看模型会给出什么判断。当然,严格来说,我们其实只解决了问题的一部分,但光是这一部分,本身也已经是一段完整旅程了。
之所以说我们只解决了一部分,是因为还有一些有趣的未知问题依然摆在前面。其中一个就是:如何从更大的一张图里先找出鸟或飞机的位置。我们的这个模型是做不到给图像中物体画 bounding box 的。
另一个困难则是:如果 Fred 那只猫突然从镜头前走过去,会发生什么?我们的模型并不会“克制住不发表意见”。它会很开心地输出“飞机”或者“鸟”,而且可能还会带着 0.99 的高置信度!这种现象——也就是模型对那些明显偏离训练分布的样本依然表现得极其自信——被称为 过度泛化(overgeneralization) 。这也是当我们把一个(看上去相当不错的)模型真正投入生产环境时最棘手的问题之一,尤其是在那些我们其实并不能完全信任输入的情形下——而遗憾的是,这才是现实世界的大多数情况。
在本章中,我们已经用 PyTorch 构建出了一些切实可用、能够从图像中学习的模型。更重要的是,我们是以一种有助于建立卷积网络直觉的方式完成这一切的。我们还进一步探索了怎样让模型更宽、更深,同时控制诸如过拟合之类的副作用。虽然我们仍然只是触及了表面,但相较于上一章,我们已经又向前迈出了一大步。现在,我们已经拥有了一个扎实的基础,可以去面对深度学习项目中真正会遇到的挑战。
既然现在我们已经熟悉了 PyTorch 的约定和常见功能,那就准备好去做一些更大的事情吧。接下来,我们会把已经学到的工具用起来,构建真实世界应用中的模型。第二部分会让你接触到生成式 AI 模型,学习多种不同架构,并最终走向一个自动检测肺癌的项目;也就是说,我们会从“熟悉 PyTorch API”前进到“能够用 PyTorch 完整实现一个项目”。
8.7 练习
把我们的模型改成使用 5 × 5 卷积核,也就是在 nn.Conv2d 构造函数中传入 kernel_size=5:
- 这个变化会对模型中的参数数量产生什么影响?
- 它会改善还是加剧过拟合?
阅读 https://pytorch.org/docs/stable/nn.html#conv2d。
- 你能描述
kernel_size=(1,3)会做什么吗? - 使用这种卷积核时,模型表现会怎样?
你能否找到一张既不包含鸟、也不包含飞机的图像,但模型却以超过 95% 的置信度断言其中有其一?
- 你能否手工编辑一张中性的图像,让它看起来更像飞机?
- 你能否手工编辑一张飞机图像,欺骗模型让它把它判断成鸟?
- 如果网络容量更小,这些任务会变得更容易还是更难?如果网络容量更大呢?
总结
- 卷积可以作为处理图像的前馈网络中的线性操作。使用卷积能得到参数更少的网络,同时利用局部性并具有平移不变性。
- 把多层卷积及其激活一层层堆叠起来,并在层间使用最大池化,其效果是:卷积作用在越来越小的特征图上,从而随着深度增加,能够有效建模输入图像更大范围内的空间关系。
- 任意
nn.Module子类都可以递归地收集并返回自己及其子模块的参数。这个机制可以用来统计参数量、把参数交给优化器,或者检查它们的数值。 - 函数式 API
torch.nn.functional提供的是那些不依赖内部状态的操作。它适用于不持有参数、因此也不会被训练的那些运算。 - 模型一旦训练完成,就可以很方便地把参数保存到磁盘(
torch.save),再通过一行代码加载回来(torch.load)。 - 你可以通过把训练迁移到 GPU 上来加速训练。使用我们已经见过的
.to方法,既可以把张量挪到 GPU,也可以把模型参数整体挪到 GPU。 - 模型的宽度和深度,是两条可以用来增加模型容量的基本维度。提升容量能帮助模型学习更复杂的函数,但也可能带来更严重的过拟合。
- 正则化是一组帮助防止过拟合的技术。在卷积神经网络这个例子里,我们用到了
weight decay、dropout 和 batch normalization。 - 深度学习中的跳跃连接(skip connection) ,是 ResNet 这类架构常用的一种技术。它通过提供一条绕过一层或多层的捷径,让梯度能在极深网络中更顺畅地流动,从而缓解梯度消失问题,并使更深网络的成功训练成为可能。