PyTorch 深度学习(GPT 重译)(三)(2)https://developer.aliyun.com/article/1485214
在每个内部迭代中,imgs
是一个大小为 64 × 3 × 32 × 32 的张量–也就是说,64 个(32 × 32)RGB 图像的小批量–而labels
是一个包含标签索引的大小为 64 的张量。
让我们运行我们的训练:
Epoch: 0, Loss: 0.523478 Epoch: 1, Loss: 0.391083 Epoch: 2, Loss: 0.407412 Epoch: 3, Loss: 0.364203 ... Epoch: 96, Loss: 0.019537 Epoch: 97, Loss: 0.008973 Epoch: 98, Loss: 0.002607 Epoch: 99, Loss: 0.026200
我们看到损失有所下降,但我们不知道是否足够低。由于我们的目标是正确地为图像分配类别,并最好在一个独立的数据集上完成,我们可以计算我们模型在验证集上的准确率,即正确分类的数量占总数的比例:
val_loader = torch.utils.data.DataLoader(cifar2_val, batch_size=64, shuffle=False) correct = 0 total = 0 with torch.no_grad(): for imgs, labels in val_loader: batch_size = imgs.shape[0] outputs = model(imgs.view(batch_size, -1)) _, predicted = torch.max(outputs, dim=1) total += labels.shape[0] correct += int((predicted == labels).sum()) print("Accuracy: %f", correct / total) Accuracy: 0.794000
不是很好的性能,但比随机好得多。为我们辩护,我们的模型是一个相当浅的分类器;奇迹的是它居然工作了。这是因为我们的数据集非常简单–两类样本中很多样本可能有系统性差异(比如背景颜色),这有助于模型根据少量像素区分鸟类和飞机。
我们可以通过添加更多的层来为我们的模型增加一些亮点,这将增加模型的深度和容量。一个相当任意的可能性是
model = nn.Sequential( nn.Linear(3072, 1024), nn.Tanh(), nn.Linear(1024, 512), nn.Tanh(), nn.Linear(512, 128), nn.Tanh(), nn.Linear(128, 2), nn.LogSoftmax(dim=1))
在这里,我们试图将特征数量逐渐缓和到输出,希望中间层能更好地将信息压缩到越来越短的中间输出中。
nn.LogSoftmax
和nn.NLLLoss
的组合等效于使用nn.CrossEntropyLoss
。这个术语是 PyTorch 的一个特殊之处,因为nn.NLLoss
实际上计算交叉熵,但输入是对数概率预测,而nn.CrossEntropyLoss
采用分数(有时称为对数几率)。从技术上讲,nn.NLLLoss
是 Dirac 分布之间的交叉熵,将所有质量放在目标上,并且由对数概率输入给出的预测分布。
为了增加混乱,在信息理论中,这个交叉熵可以被解释为预测分布在目标分布下的负对数似然,经过样本大小归一化。因此,这两种损失都是模型参数的负对数似然,给定数据时,我们的模型预测(应用 softmax 后的)概率。在本书中,我们不会依赖这些细节,但当你在文献中看到这些术语时,不要让 PyTorch 的命名混淆你。
通常会从网络中删除最后一个nn.LogSoftmax
层,并使用nn.CrossEntropyLoss
作为损失函数。让我们试试:
model = nn.Sequential( nn.Linear(3072, 1024), nn.Tanh(), nn.Linear(1024, 512), nn.Tanh(), nn.Linear(512, 128), nn.Tanh(), nn.Linear(128, 2)) loss_fn = nn.CrossEntropyLoss()
请注意,数字将与nn.LogSoftmax
和nn.NLLLoss
完全相同。只是一次性完成所有操作更方便,唯一需要注意的是,我们模型的输出将无法解释为概率(或对数概率)。我们需要明确通过 softmax 传递输出以获得这些概率。
训练这个模型并在验证集上评估准确率(0.802000)让我们意识到,一个更大的模型带来了准确率的提高,但并不多。训练集上的准确率几乎完美(0.998100)。这告诉我们什么?我们在两种情况下都过度拟合了我们的模型。我们的全连接模型通过记忆训练集来找到区分鸟类和飞机的方法,但在验证集上的表现并不是很好,即使我们选择了一个更大的模型。
PyTorch 通过nn.Model
的parameters()
方法(我们用来向优化器提供参数的相同方法)提供了一种快速确定模型有多少参数的方法。要找出每个张量实例中有多少元素,我们可以调用numel
方法。将它们相加就得到了我们的总数。根据我们的用例,计算参数可能需要我们检查参数是否将requires_grad
设置为True
。我们可能希望区分可训练参数的数量与整个模型大小。让我们看看我们现在有什么:
# In[7]: numel_list = [p.numel() for p in connected_model.parameters() if p.requires_grad == True] sum(numel_list), numel_list # Out[7]: (3737474, [3145728, 1024, 524288, 512, 65536, 128, 256, 2])
哇,370 万个参数!对于这么小的输入图像来说,这不是一个小网络,是吗?即使我们的第一个网络也相当庞大:
# In[9]: numel_list = [p.numel() for p in first_model.parameters()] sum(numel_list), numel_list # Out[9]: (1574402, [1572864, 512, 1024, 2])
我们第一个模型中的参数数量大约是最新模型的一半。嗯,从单个参数大小的列表中,我们开始有了一个想法:第一个模块有 150 万个参数。在我们的完整网络中,我们有 1,024 个输出特征,这导致第一个线性模块有 3 百万个参数。这不应该出乎意料:我们知道线性层计算y = weight * x + bias
,如果x
的长度为 3,072(为简单起见忽略批处理维度),而y
必须具有长度 1,024,则weight
张量的大小需要为 1,024 × 3,072,bias
大小必须为 1,024。而 1,024 * 3,072 + 1,024 = 3,146,752,正如我们之前发现的那样。我们可以直接验证这些数量:
# In[10]: linear = nn.Linear(3072, 1024) linear.weight.shape, linear.bias.shape # Out[10]: (torch.Size([1024, 3072]), torch.Size([1024]))
这告诉我们什么?我们的神经网络随着像素数量的增加不会很好地扩展。如果我们有一个 1,024 × 1,024 的 RGB 图像呢?那就是 3.1 百万个输入值。即使突然转向 1,024 个隐藏特征(这对我们的分类器不起作用),我们将有超过 30 亿个参数。使用 32 位浮点数,我们已经占用了 12 GB 的内存,甚至还没有到达第二层,更不用说计算和存储梯度了。这在大多数现代 GPU 上根本无法容纳。
7.2.7 完全连接的极限
让我们推理一下在图像的 1D 视图上使用线性模块意味着什么–图 7.15 展示了正在发生的事情。这就像是将每个输入值–也就是我们 RGB 图像中的每个分量–与每个输出特征的所有其他值进行线性组合。一方面,我们允许任何像素与图像中的每个其他像素进行组合,这可能与我们的任务相关。另一方面,我们没有利用相邻或远离像素的相对位置,因为我们将图像视为一个由数字组成的大向量。
图 7.15 使用带有输入图像的全连接模块:每个输入像素与其他每个像素组合以生成输出中的每个元素。
在一个 32 × 32 图像中捕捉到的飞机在蓝色背景上将非常粗略地类似于一个黑色的十字形状。如图 7.15 中的全连接网络需要学习,当像素 0,1 是黑色时,像素 1,1 也是黑色,依此类推,这是飞机的一个很好的指示。这在图 7.16 的上半部分有所说明。然而,将相同的飞机向下移动一个像素或更多像图的下半部分一样,像素之间的关系将不得不从头开始重新学习:这次,当像素 0,2 是黑色时,像素 1,2 是黑色,依此类推时,飞机很可能存在。更具体地说,全连接网络不是平移不变的。这意味着一个经过训练以识别从位置 4,4 开始的斯皮特火机的网络将无法识别完全相同的从位置 8,8 开始的斯皮特火机。然后,我们必须增广数据集–也就是在训练过程中对图像应用随机平移–以便网络有机会在整个图像中看到斯皮特火机,我们需要对数据集中的每个图像都这样做(值得一提的是,我们可以连接一个来自torchvision.transforms
的转换来透明地执行此操作)。然而,这种数据增广策略是有代价的:隐藏特征的数量–也就是参数的数量–必须足够大,以存储关于所有这些平移副本的信息。
图 7.16 全连接层中的平移不变性或缺乏平移不变性
因此,在本章结束时,我们有了一个数据集,一个模型和一个训练循环,我们的模型学习了。然而,由于我们的问题与网络结构之间存在不匹配,我们最终过拟合了训练数据,而不是学习我们希望模型检测到的泛化特征。
我们已经创建了一个模型,允许将图像中的每个像素与其他像素相关联,而不考虑它们的空间排列。我们有一个合理的假设,即更接近的像素在理论上更相关。这意味着我们正在训练一个不具有平移不变性的分类器,因此如果我们希望在验证集上表现良好,我们被迫使用大量容量来学习平移副本。肯定有更好的方法,对吧?
当然,像这样的问题在这本书中大多是修辞性的。解决我们当前一系列问题的方法是改变我们的模型,使用卷积层。我们将在下一章中介绍这意味着什么。
7.3 结论
在本章中,我们解决了一个简单的分类问题,从数据集到模型,再到在训练循环中最小化适当的损失。所有这些都将成为你的 PyTorch 工具箱中的标准工具,并且使用它们所需的技能将在你使用 PyTorch 的整个期间都很有用。
我们还发现了我们模型的一个严重缺陷:我们一直将 2D 图像视为 1D 数据。此外,我们没有一种自然的方法来融入我们问题的平移不变性。在下一章中,您将学习如何利用图像数据的 2D 特性以获得更好的结果。⁹
我们可以立即利用所学知识处理没有这种平移不变性的数据。例如,在表格数据或我们在第四章中遇到的时间序列数据上使用它,我们可能已经可以做出很棒的事情。在一定程度上,也可以将其应用于适当表示的文本数据。¹⁰
7.4 练习
- 使用
torchvision
实现数据的随机裁剪。
- 结果图像与未裁剪的原始图像有何不同?
- 当第二次请求相同图像时会发生什么?
- 使用随机裁剪图像进行训练的结果是什么?
- 切换损失函数(也许是均方误差)。
- 训练行为是否会改变?
- 是否可能减少网络的容量,使其停止过拟合?
- 这样做时模型在验证集上的表现如何?
7.5 总结
- 计算机视觉是深度学习的最广泛应用之一。
- 有许多带有注释的图像数据集可以公开获取;其中许多可以通过
torchvision
访问。 Dataset
和DataLoader
为加载和采样数据集提供了简单而有效的抽象。- 对于分类任务,在网络输出上使用 softmax 函数会产生满足概率解释要求的值。在这种情况下,用 softmax 的输出作为非负对数似然函数的输入得到的损失函数是理想的分类损失函数。在 PyTorch 中,softmax 和这种损失的组合称为交叉熵。
- 没有什么能阻止我们将图像视为像素值向量,使用全连接网络处理它们,就像处理任何其他数值数据一样。然而,这样做会使利用数据中的空间关系变得更加困难。
- 可以使用
nn.Sequential
创建简单模型。
¹ 这些图像是由加拿大高级研究所(CIFAR)的 Krizhevsky、Nair 和 Hinton 收集和标记的,并且来自麻省理工学院计算机科学与人工智能实验室(CSAIL)的更大的未标记 32×32 彩色图像集合:“8000 万小图像数据集”。
² 对于一些高级用途,PyTorch 还提供了IterableDataset
。这可以用于数据集中随机访问数据代价过高或没有意义的情况:例如,因为数据是即时生成的。
³ 这在打印时无法很好地翻译;你必须相信我们的话,或者在电子书或 Jupyter Notebook 中查看。
⁴ 在这里,我们手动构建了新数据集,并且还想重新映射类别。在某些情况下,仅需要获取给定数据集的索引子集即可。这可以通过torch.utils.data.Subset
类来实现。类似地,ConcatDataset
用于将(兼容项的)数据集合并为一个更大的数据集。对于可迭代数据集,ChainDataset
提供了一个更大的可迭代数据集。
⁵ 在“概率”向量上使用距离已经比使用MSELoss
与类别编号要好得多——回想我们在第四章“连续、有序和分类值”侧边栏中讨论的值类型,对于类别来说,使用MSELoss
没有意义,在实践中根本不起作用。然而,MSELoss
并不适用于分类问题。
⁶ 对于特殊的二元分类情况,在这里使用两个值是多余的,因为一个总是另一个的 1 减。事实上,PyTorch 允许我们仅在模型末尾使用nn.Sigmoid
激活输出单个概率,并使用二元交叉熵损失函数nn.BCELoss
。还有一个将这两个步骤合并的nn.BCELossWithLogits
。
⁷ 虽然原则上可以说这里的模型不确定(因为它将 48%和 52%的概率分配给两个类别),但典型的训练结果是高度自信的模型。贝叶斯神经网络可以提供一些补救措施,但这超出了本书的范围。
⁸ 要了解术语的简明定义,请参考 David MacKay 的《信息理论、推断和学习算法》(剑桥大学出版社,2003 年),第 2.3 节。
⁹ 关于平移不变性的同样警告也适用于纯粹的 1D 数据:音频分类器应该在要分类的声音开始时间提前或延后十分之一秒时产生相同的输出。
¹⁰词袋模型,只是对单词嵌入进行平均处理,可以使用本章的网络设计进行处理。更现代的模型考虑了单词的位置,并需要更高级的模型。
八、使用卷积进行泛化
本章涵盖
- 理解卷积
- 构建卷积神经网络
- 创建自定义
nn.Module
子类 - 模块和功能 API 之间的区别
- 神经网络的设计选择
在上一章中,我们构建了一个简单的神经网络,可以拟合(或过拟合)数据,这要归功于线性层中可用于优化的许多参数。然而,我们的模型存在问题,它更擅长记忆训练集,而不是泛化鸟类和飞机的属性。根据我们的模型架构,我们猜测这是为什么。由于需要完全连接的设置来检测图像中鸟或飞机的各种可能的平移,我们有太多的参数(使模型更容易记忆训练集)和没有位置独立性(使泛化更困难)。正如我们在上一章中讨论的,我们可以通过使用各种重新裁剪的图像来增加我们的训练数据,以尝试强制泛化,但这不会解决参数过多的问题。
有一种更好的方法!它包括用不同的线性操作替换我们神经网络单元中的密集、全连接的仿射变换:卷积。
8.1 卷积的理由
让我们深入了解卷积是什么以及我们如何在神经网络中使用它们。是的,是的,我们正在努力区分鸟和飞机,我们的朋友仍在等待我们的解决方案,但这个偏离值得额外花费的时间。我们将对计算机视觉中这个基础概念发展直觉,然后带着超能力回到我们的问题。
在本节中,我们将看到卷积如何提供局部性和平移不变性。我们将通过仔细查看定义卷积的公式并使用纸和笔应用它来做到这一点——但不用担心,要点将在图片中,而不是公式中。
我们之前说过,将我们的输入图像以 1D 视图呈现,并将其乘以一个n_output_features
× n_input_features
的权重矩阵,就像在nn.Linear
中所做的那样,意味着对于图像中的每个通道,计算所有像素的加权和,乘以一组权重,每个输出特征一个。
我们还说过,如果我们想要识别与对象对应的模式,比如天空中的飞机,我们可能需要查看附近像素的排列方式,而不太关心远离彼此的像素如何组合。基本上,我们的斯皮特火箭的图像是否在角落里有树、云或风筝并不重要。
为了将这种直觉转化为数学形式,我们可以计算像素与其相邻像素的加权和,而不是与图像中的所有其他像素。这相当于构建权重矩阵,每个输出特征和输出像素位置一个,其中距离中心像素一定距离的所有权重都为零。这仍然是一个加权和:即,一个线性操作。
8.1.1 卷积的作用
我们之前确定了另一个期望的属性:我们希望这些局部模式对输出产生影响,而不管它们在图像中的位置如何:也就是说,要平移不变。为了在应用于我们在第七章中使用的图像-作为-向量的矩阵中实现这一目标,需要实现一种相当复杂的权重模式(如果它太复杂,不用担心;很快就会好转):大多数权重矩阵将为零(对应于距离输出像素太远而不会产生影响的输入像素的条目)。对于其他权重,我们必须找到一种方法来保持与输入和输出像素相同相对位置对应的条目同步。这意味着我们需要将它们初始化为相同的值,并确保所有这些绑定权重在训练期间网络更新时保持不变。这样,我们可以确保权重在邻域内运作以响应局部模式,并且无论这些局部模式在图像中的位置如何,都能识别出来。
当然,这种方法远非实用。幸运的是,图像上有一个现成的、局部的、平移不变的线性操作:卷积。我们可以对卷积提出更简洁的描述,但我们将要描述的正是我们刚刚勾勒的内容——只是从不同角度来看。
卷积,或更准确地说,离散卷积¹(这里有一个我们不会深入讨论的连续版本),被定义为 2D 图像的权重矩阵,卷积核,与输入中的每个邻域的点积。考虑一个 3 × 3 的卷积核(在深度学习中,我们通常使用小卷积核;稍后我们会看到原因)作为一个 2D 张量
weight = torch.tensor([[w00, w01, w02], [w10, w11, w12], [w20, w21, w22]])
以及一个 1 通道的 MxN 图像:
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]])
我们可以计算输出图像的一个元素(不包括偏置)如下:
o11 = i11 * w00 + i12 * w01 + i22 * w02 + i21 * w10 + i22 * w11 + i23 * w12 + i31 * w20 + i32 * w21 + i33 * w22
图 8.1 展示了这个计算的过程。
也就是说,我们在输入图像的i11
位置上“平移”卷积核,并将每个权重乘以相应位置的输入图像的值。因此,输出图像是通过在所有输入位置上平移卷积核并执行加权求和来创建的。对于多通道图像,如我们的 RGB 图像,权重矩阵将是一个 3 × 3 × 3 矩阵:每个通道的一组权重共同贡献到输出值。
请注意,就像nn.Linear
的weight
矩阵中的元素一样,卷积核中的权重事先是未知的,但它们是随机初始化并通过反向传播进行更新的。还要注意,相同的卷积核,因此卷积核中的每个权重,在整个图像中都会被重复使用。回想自动求导,这意味着每个权重的使用都有一个跨越整个图像的历史。因此,损失相对于卷积权重的导数包含整个图像的贡献。
图 8.1 卷积:局部性和平移不变性
现在可以看到与之前所述的连接:卷积等同于具有多个线性操作,其权重几乎在每个像素周围为零,并且在训练期间接收相等的更新。
总结一下,通过转换为卷积,我们得到
- 对邻域进行局部操作
- 平移不变性
- 具有更少参数的模型
第三点的关键见解是,使用卷积层,参数的数量不取决于图像中的像素数量,就像在我们的全连接模型中一样,而是取决于卷积核的大小(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 的内核大小。
在所有方向上具有相同大小的内核尺寸是非常常见的,因此 PyTorch 为此提供了一个快捷方式:每当为 2D 卷积指定kernel_size=3
时,它表示 3 × 3(在 Python 中提供为元组(3, 3)
)。对于 3D 卷积,它表示 3 × 3 × 3。我们将在本书第 2 部分中看到的 CT 扫描在三个轴中的一个轴上具有不同的体素(体积像素)分辨率。在这种情况下,考虑在特殊维度上具有不同大小的内核是有意义的。但现在,我们将坚持在所有维度上使用相同大小的卷积:
# In[11]: conv = nn.Conv2d(3, 16, kernel_size=3) # ❶ conv # Out[11]: Conv2d(3, 16, kernel_size=(3, 3), stride=(1, 1))
❶ 与快捷方式kernel_size=3
相比,我们可以等效地传递我们在输出中看到的元组:kernel_size=(3, 3)。
我们期望weight
张量的形状是什么?卷积核的大小为 3 × 3,因此我们希望权重由 3 × 3 部分组成。对于单个输出像素值,我们的卷积核会考虑,比如,in_ch
= 3 个输入通道,因此单个输出像素值的权重分量(以及整个输出通道的不变性)的形状为in_ch
× 3 × 3。最后,我们有与输出通道一样多的权重组件,这里out_ch
= 16,因此完整的权重张量是out_ch
× in_ch
× 3 × 3,在我们的情况下是 16 × 3 × 3 × 3。偏置的大小将为 16(为了简单起见,我们已经有一段时间没有讨论偏置了,但就像在线性模块的情况下一样,它是一个我们添加到输出图像的每个通道的常数值)。让我们验证我们的假设:
# In[12]: conv.weight.shape, conv.bias.shape # Out[12]: (torch.Size([16, 3, 3, 3]), torch.Size([16]))
我们可以看到卷积是从图像中学习的方便选择。我们有更小的模型寻找局部模式,其权重在整个图像上进行优化。
2D 卷积通过产生一个 2D 图像作为输出,其像素是输入图像邻域的加权和。在我们的情况下,卷积核权重和偏置conv.weight
都是随机初始化的,因此输出图像不会特别有意义。通常情况下,如果我们想要使用一个输入图像调用conv
模块,我们需要使用unsqueeze
添加零批次维度,因为nn.Conv2d
期望输入为B × C × H × W形状的张量:
# In[13]: img, _ = cifar2[0] output = conv(img.unsqueeze(0)) img.unsqueeze(0).shape, output.shape # Out[13]: (torch.Size([1, 3, 32, 32]), torch.Size([1, 16, 30, 30]))
我们很好奇,所以我们可以显示输出,如图 8.2 所示:
# In[15]: plt.imshow(output[0, 0].detach(), cmap='gray') plt.show()
图 8.2 我们的鸟经过随机卷积处理后的样子。(我们在代码中作弊一点,以展示给您输入。)
等一下。让我们看看output
的大小:它是torch.Size([1, 16, 30, 30])
。嗯;我们在过程中丢失了一些像素。这是怎么发生的?
8.2.1 填充边界
我们的输出图像比输入图像小的事实是决定在图像边界做什么的副作用。将卷积核应用为 3×3 邻域像素的加权和要求在所有方向上都有邻居。如果我们在 i00 处,我们只有右侧和下方的像素。默认情况下,PyTorch 将在输入图片内滑动卷积核,获得width
- kernel_width
+ 1 个水平和垂直位置。对于奇数大小的卷积核,这导致图像在每一侧缩小卷积核宽度的一半(在我们的情况下,3//2 = 1)。这解释了为什么每个维度都缺少两个像素。
图 8.3 零填充以保持输出中的图像大小
然而,PyTorch 给了我们填充图像的可能性,通过在边界周围创建幽灵像素,这些像素在卷积方面的值为零。图 8.3 展示了填充的效果。
在我们的情况下,当kernel_size=3
时指定padding=1
意味着 i00 上方和左侧有额外的邻居,这样原始图像的角落处甚至可以计算卷积的输出。³最终结果是输出现在与输入具有完全相同的大小:
# In[16]: conv = nn.Conv2d(3, 1, kernel_size=3, padding=1) # ❶ output = conv(img.unsqueeze(0)) img.unsqueeze(0).shape, output.shape # Out[16]: (torch.Size([1, 3, 32, 32]), torch.Size([1, 1, 32, 32]))
❶ 现在有填充了
请注意,无论是否使用填充,weight
和bias
的大小都不会改变。
填充卷积有两个主要原因。首先,这样做有助于我们分离卷积和改变图像大小的问题,这样我们就少了一件事要记住。其次,当我们有更复杂的结构,比如跳跃连接(在第 8.5.3 节讨论)或我们将在第 2 部分介绍的 U-Net 时,我们希望几个卷积之前和之后的张量具有兼容的大小,以便我们可以将它们相加或取差异。
8.2.2 用卷积检测特征
我们之前说过,weight
和bias
是通过反向传播学习的参数,就像nn.Linear
中的weight
和bias
一样。然而,我们可以通过手动设置权重来玩转卷积,看看会发生什么。
首先让我们将bias
归零,以消除任何混淆因素,然后将weights
设置为一个恒定值,以便输出中的每个像素得到其邻居的平均值。对于每个 3×3 邻域:
# In[17]: with torch.no_grad(): conv.bias.zero_() with torch.no_grad(): conv.weight.fill_(1.0 / 9.0)
我们本可以选择conv.weight.one_()
–这将导致输出中的每个像素是邻域像素的总和。除了输出图像中的值会大九倍之外,没有太大的区别。
无论如何,让我们看看对我们的 CIFAR 图像的影响:
# In[18]: output = conv(img.unsqueeze(0)) plt.imshow(output[0, 0].detach(), cmap='gray') plt.show()
正如我们之前所预测的,滤波器产生了图像的模糊版本,如图 8.4 所示。毕竟,输出的每个像素都是输入邻域的平均值,因此输出中的像素是相关的,并且变化更加平滑。
图 8.4 我们的鸟,这次因为一个恒定的卷积核而变模糊
接下来,让我们尝试一些不同的东西。下面的卷积核一开始可能看起来有点神秘:
# In[19]: 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 将为零。这是一个边缘检测卷积核:卷积核突出显示了水平相邻区域之间的垂直边缘。
图 8.5 我们鸟身上的垂直边缘,感谢手工制作的卷积核
将卷积核应用于我们的图像,我们看到了图 8.5 中显示的结果。如预期,卷积核增强了垂直边缘。我们可以构建更多复杂的滤波器,例如用于检测水平或对角边缘,或十字形或棋盘格模式,其中“检测”意味着输出具有很高的幅度。事实上,计算机视觉专家的工作历来是提出最有效的滤波器组合,以便在图像中突出显示某些特征并识别对象。
在深度学习中,我们让核根据数据以最有效的方式进行估计:例如,以最小化我们在第 7.2.5 节中介绍的输出和地面真相之间的负交叉熵损失为目标。从这个角度来看,卷积神经网络的工作是估计一组滤波器组的核,这些核将在连续层中将多通道图像转换为另一个多通道图像,其中不同通道对应不同特征(例如一个通道用于平均值,另一个通道用于垂直边缘等)。图 8.6 显示了训练如何自动学习核。
图 8.6 通过估计核权重的梯度并逐个更新它们以优化损失的卷积学习过程
8.2.3 深入探讨深度和池化
这一切都很好,但在概念上存在一个问题。我们之所以如此兴奋,是因为从全连接层转向卷积,我们实现了局部性和平移不变性。然后我们建议使用小卷积核,如 3 x 3 或 5 x 5:这确实是局部性的极致。那么大局观呢?我们怎么知道我们图像中的所有结构都是 3 像素或 5 像素宽的?好吧,我们不知道,因为它们不是。如果它们不是,我们的网络如何能够看到具有更大范围的这些模式?如果我们想有效解决鸟类与飞机的问题,我们真的需要这个,因为尽管 CIFAR-10 图像很小,但对象仍然具有跨越几个像素的(翼)跨度。
一种可能性是使用大型卷积核。当然,在极限情况下,我们可以为 32 x 32 图像使用 32 x 32 卷积核,但我们将收敛到旧的全连接、仿射变换,并丢失卷积的所有优点。另一种选项是在卷积神经网络中使用一层接一层的卷积,并在连续卷积之间同时对图像进行下采样。
从大到小:下采样
下采样原则上可以以不同方式发生。将图像缩小一半相当于将四个相邻像素作为输入,并产生一个像素作为输出。如何根据输入值计算输出值取决于我们。我们可以
- 对四个像素求平均值。这种平均池化曾经是一种常见方法,但现在已经不太受青睐。
- 取四个像素中的最大值。这种方法称为最大池化,目前是最常用的方法,但它的缺点是丢弃了其他四分之三的数据。
- 执行步幅卷积,只计算每第N个像素。具有步幅 2 的 3 x 4 卷积仍然包含来自前一层的所有像素的输入。文献显示了这种方法的前景,但它尚未取代最大池化。
我们将继续关注最大池化,在图 8.7 中有所说明。该图显示了最常见的设置,即取非重叠的 2 x 2 瓦片,并将每个瓦片中的最大值作为缩小比例后的新像素。
图 8.7 详细介绍了最大池化
直觉上,卷积层的输出图像,特别是因为它们后面跟着一个激活函数,往往在检测到对应于估计内核的某些特征(如垂直线)时具有较高的幅度。通过将 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.8 中,我们首先在我们的 8×8 图像上应用一组 3×3 内核,获得相同大小的多通道输出图像。然后我们将输出图像缩小一半,得到一个 4×4 图像,并对其应用另一组 3×3 内核。这第二组内核在已经缩小一半的东西的 3×3 邻域上有效地映射回输入的 8×8 邻域。此外,第二组内核获取第一组内核的输出(如平均值、边缘等特征)并在其上提取额外的特征。
图 8.8 通过手动进行更多卷积,展示叠加卷积和最大池化的效果:使用两个小的十字形内核和最大池化突出显示一个大的十字形。
因此,一方面,第一组内核在第一阶低级特征的小邻域上操作,而第二组内核有效地在更宽的邻域上操作,产生由前一特征组成的特征。这是一个非常强大的机制,使卷积神经网络能够看到非常复杂的场景–比我们的 CIFAR-10 数据集中的 32×32 图像复杂得多。
输出像素的感受野
当第二个 3×3 卷积内核在图 8.8 中的卷积输出中产生 21 时,这是基于第一个最大池输出的左上角 3×3 像素。它们又对应于第一个卷积输出左上角的 6×6 像素,而这又是由第一个卷积从左上角的 7×7 像素计算得出的。因此,第二个卷积输出中的像素受到 7×7 输入方块的影响。第一个卷积还使用隐式“填充”列和行来在角落产生输出;否则,我们将有一个 8×8 的输入像素方块通知第二个卷积输出中的给定像素(远离边界)。在花哨的语言中,我们说,3×3 卷积,2×2 最大池,3×3 卷积结构的给定输出神经元具有 8×8 的感受野。
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
激活函数。得到的 16 通道 32 × 32 图像通过第一个MaxPool3d
池化为一个 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), # ... # ❶ nn.Linear(8 * 8 * 8, 32), nn.Tanh(), nn.Linear(32, 2))
❶ 警告:这里缺少重要内容!
这段代码给出了图 8.9 中显示的神经网络。
图 8.9 典型卷积网络的形状,包括我们正在构建的网络。图像被馈送到一系列卷积和最大池化模块,然后被拉直成一个一维向量,然后被馈送到全连接模块。
先忽略“缺少内容”的评论一分钟。让我们首先注意到线性层的大小取决于MaxPool2d
的预期输出大小:8 × 8 × 8 = 512。让我们计算一下这个小模型的参数数量:
# In[24]: numel_list = [p.numel() for p in model.parameters()] sum(numel_list), numel_list # Out[24]: (18090, [432, 16, 1152, 8, 16384, 32, 64, 2])
对于这样小图像的有限数据集来说,这是非常合理的。为了增加模型的容量,我们可以增加卷积层的输出通道数(即每个卷积层生成的特征数),这将导致线性层的大小也增加。
我们在代码中放置“警告”注释是有原因的。模型没有运行的可能性:
# In[25]: model(img.unsqueeze(0)) # Out[25]: ... RuntimeError: size mismatch, m1: [64 x 8], m2: [512 x 32] at c:\...\THTensorMath.cpp:940
诚然,错误消息有点晦涩,但并不是太过复杂。我们在回溯中找到了linear
的引用:回顾模型,我们发现只有一个模块必须有一个 512 × 32 的张量,即nn.Linear(512, 32)
,也就是最后一个卷积块后的第一个线性模块。
缺失的是将一个 8 通道 8 × 8 图像重塑为一个 512 元素的一维向量(如果忽略批处理维度,则为一维)。这可以通过在最后一个nn.MaxPool2d
的输出上调用view
来实现,但不幸的是,当我们使用nn.Sequential
时,我们没有任何明确的方式查看每个模块的输出。
8.3 继承 nn.Module
在开发神经网络的某个阶段,我们会发现自己想要计算一些预制模块不涵盖的内容。在这里,这是一些非常简单的操作,比如重塑;但在第 8.5.3 节中,我们使用相同的构造来实现残差连接。因此,在本节中,我们学习如何制作自己的nn.Module
子类,然后我们可以像预构建的模块或nn.Sequential
一样使用它们。
当我们想要构建比仅仅一层接一层应用更复杂功能的模型时,我们需要离开nn.Sequential
,转而使用能够为我们提供更大灵活性的东西。PyTorch 允许我们通过继承nn.Module
来在模型中使用任何计算。
要对 nn.Module
进行子类化,至少需要定义一个接受模块输入并返回输出的 forward
函数。这是我们定义模块计算的地方。这里的 forward
名称让人想起了很久以前的一个时期,当模块需要定义我们在第 5.5.1 节中遇到的前向和后向传递时。使用标准的 torch
操作,PyTorch 将自动处理后向传递;实际上,nn.Module
从不带有 backward
。
通常,我们的计算将使用其他模块–预制的如卷积或自定义的。要包含这些子模块,我们通常在构造函数 __init__
中定义它们,并将它们分配给 self
以在 forward
函数中使用。它们将同时在我们模块的整个生命周期中保持其参数。请注意,您需要在执行这些操作之前调用 super().__init__()
(否则 PyTorch 会提醒您)。
8.3.1 我们的网络作为 nn.Module
让我们将我们的网络编写为一个子模块。为此,我们在构造函数中实例化了所有之前传递给 nn.Sequential
的 nn.Conv2d
、nn.Linear
等,然后在 forward
中依次使用它们的实例:
# In[26]: 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) # ❶ out = self.act3(self.fc1(out)) out = self.fc2(out) return out
❶ 这种重塑是我们之前缺少的
Net
类在子模块方面等效于我们之前构建的 nn.Sequential
模型;但通过显式编写 forward
函数,我们可以直接操作 self.pool3
的输出并在其上调用 view
将其转换为 B × N 向量。请注意,在调用 view
时,我们将批处理维度保留为 -1,因为原则上我们不知道批处理中会有多少样本。
图 8.10 我们基准的卷积网络架构
在这里,我们使用 nn.Module
的子类来包含我们的整个模型。我们还可以使用子类来定义更复杂网络的新构建块。继续第六章中的图表风格,我们的网络看起来像图 8.10 所示的那样。我们正在对要在哪里呈现的信息做一些临时选择。
请记住,分类网络的目标通常是在某种意义上压缩信息,即我们从具有大量像素的图像开始,将其压缩为(概率向量的)类。关于我们的架构有两件事情值得与这个目标有关的评论。
首先,我们的目标反映在中间值的大小通常会缩小–这是通过在卷积中减少通道数、通过池化减少像素数以及在线性层中使输出维度低于输入维度来实现的。这是分类网络的一个共同特征。然而,在许多流行的架构中,如我们在第二章中看到的 ResNets 并在第 8.5.3 节中更多讨论的,通过在空间分辨率中进行池化来实现减少,但通道数增加(仍导致尺寸减小)。似乎我们的快速信息减少模式在深度有限且图像较小的网络中效果良好;但对于更深的网络,减少通常较慢。
其次,在一个层中,输出大小与输入大小没有减少:初始卷积。如果我们将单个输出像素视为一个具有 32 个元素的向量(通道),那么它是 27 个元素的线性变换(作为 3 个通道 × 3 × 3 核大小的卷积)–仅有轻微增加。在 ResNet 中,初始卷积从 147 个元素(3 个通道 × 7 × 7 核大小)生成 64 个通道。⁶ 因此,第一层在整体维度(如通道乘以像素)方面大幅增加数据流经过它,但对于独立考虑的每个输出像素,输出仍大致与输入相同。⁷
PyTorch 深度学习(GPT 重译)(三)(4)https://developer.aliyun.com/article/1485216