PyTorch 深度学习(GPT 重译)(三)(1)https://developer.aliyun.com/article/1485213
6.3.3 与线性模型比较
我们还可以评估模型在所有数据上的表现,并查看它与一条直线的差异:
# In[23]: from matplotlib import pyplot as plt t_range = torch.arange(20., 90.).unsqueeze(1) fig = plt.figure(dpi=600) plt.xlabel("Fahrenheit") plt.ylabel("Celsius") plt.plot(t_u.numpy(), t_c.numpy(), 'o') plt.plot(t_range.numpy(), seq_model(0.1 * t_range).detach().numpy(), 'c-') plt.plot(t_u.numpy(), seq_model(0.1 * t_u).detach().numpy(), 'kx')
结果显示在图 6.9 中。我们可以看到神经网络有过拟合的倾向,正如我们在第五章讨论的那样,因为它试图追踪测量值,包括嘈杂的值。即使我们微小的神经网络有太多参数来拟合我们所拥有的少量测量值。总的来说,它做得还不错。
图 6.9 我们的神经网络模型的绘图,包括输入数据(圆圈)和模型输出(X)。连续线显示样本之间的行为。
6.4 结论
尽管我们一直在处理一个非常简单的问题,但在第五章和第六章中我们已经涵盖了很多内容。我们分析了构建可微分模型并使用梯度下降进行训练,首先使用原始自动求导,然后依赖于nn
。到目前为止,您应该对幕后发生的事情有信心。希望这一次 PyTorch 的体验让您对更多内容感到兴奋!
6.5 练习
- 在我们简单的神经网络模型中尝试隐藏神经元的数量以及学习率。
- 什么改变会导致模型输出更线性?
- 你能明显地使模型过拟合数据吗?
- 物理学中第三难的问题是找到一种合适的葡萄酒来庆祝发现。从第四章加载葡萄酒数据,并创建一个具有适当数量输入参数的新模型。
- 训练所需时间与我们一直在使用的温度数据相比需要多长时间?
- 你能解释哪些因素导致训练时间?
- 你能在这个数据集上训练时使损失减少吗?
- 你会如何绘制多个数据集的图表?
6.6 总结
- 神经网络可以自动适应专门解决手头问题。
- 神经网络允许轻松访问模型中任何参数相对于损失的解析导数,这使得演化参数非常高效。由于其自动微分引擎,PyTorch 轻松提供这些导数。
- 环绕线性变换的激活函数使神经网络能够逼近高度非线性函数,同时保持足够简单以进行优化。
nn
模块与张量标准库一起提供了创建神经网络的所有构建模块。- 要识别过拟合,保持训练数据点与验证集分开是至关重要的。没有一种对抗过拟合的固定方法,但增加数据量,或增加数据的变化性,并转向更简单的模型是一个良好的开始。
- 做数据科学的人应该一直在绘制数据。
¹ 参见 F. Rosenblatt,“感知器:大脑中信息存储和组织的概率模型”,心理评论 65(6),386-408(1958 年),pubmed.ncbi.nlm.nih.gov/13602029/
。
² 为了直观地理解这种通用逼近性质,你可以从图 6.5 中选择一个函数,然后构建一个几乎在大部分区域为零且在x = 0 周围为正的基本函数,通过缩放(包括乘以负数)、平移激活函数的副本。通过这个基本函数的缩放、平移和扩展(沿X轴挤压)的副本,你可以逼近任何(连续)函数。在图 6.6 中,右侧中间行的函数可能是这样一个基本构件。Michael Nielsen 在他的在线书籍神经网络与深度学习中有一个交互式演示,网址为mng.bz/Mdon
。
³ 当然,即使这些说法并不总是正确;参见 Jakob Foerster 的文章,“深度线性网络中的非线性计算”,OpenAI,2019,mng.bz/gygE
。
⁴ 并非所有版本的 Python 都指定了dict
的迭代顺序,因此我们在这里使用OrderedDict
来确保层的顺序,并强调层的顺序很重要。
七、从图像中识别鸟类和飞机:从图像中学习
本章内容包括
- 构建前馈神经网络
- 使用
Dataset
和DataLoader
加载数据 - 理解分类损失
上一章让我们有机会深入了解通过梯度下降学习的内部机制,以及 PyTorch 提供的构建模型和优化模型的工具。我们使用了一个简单的具有一个输入和一个输出的回归模型,这使我们可以一目了然,但诚实地说只是勉强令人兴奋。
在本章中,我们将继续构建我们的神经网络基础。这一次,我们将把注意力转向图像。图像识别可以说是让世界意识到深度学习潜力的任务。
我们将逐步解决一个简单的图像识别问题,从上一章中定义的简单神经网络开始构建。这一次,我们将使用一个更广泛的小图像数据集,而不是一组数字。让我们首先下载数据集,然后开始准备使用它。
7.1 一个小图像数据集
没有什么比对一个主题的直观理解更好,也没有什么比处理简单数据更能实现这一点。图像识别中最基本的数据集之一是被称为 MNIST 的手写数字识别数据集。在这里,我们将使用另一个类似简单且更有趣的数据集。它被称为 CIFAR-10,就像它的姐妹 CIFAR-100 一样,它已经成为计算机视觉领域的经典数据集十年。
CIFAR-10 由 60,000 个 32×32 彩色(RGB)图像组成,标记为 10 个类别中的一个整数:飞机(0)、汽车(1)、鸟(2)、猫(3)、鹿(4)、狗(5)、青蛙(6)、马(7)、船(8)和卡车(9)。如今,CIFAR-10 被认为对于开发或验证新研究来说过于简单,但对于我们的学习目的来说完全够用。我们将使用torchvision
模块自动下载数据集,并将其加载为一组 PyTorch 张量。图 7.1 让我们一睹 CIFAR-10 的风采。
图 7.1 显示所有 CIFAR-10 类别的图像样本
7.1.1 下载 CIFAR-10
正如我们预期的那样,让我们导入torchvision
并使用datasets
模块下载 CIFAR-10 数据:
# In[2]: from torchvision import datasets data_path = '../data-unversioned/p1ch7/' cifar10 = datasets.CIFAR10(data_path, train=True, download=True) # ❶ cifar10_val = datasets.CIFAR10(data_path, train=False, download=True) # ❷
❶ 为训练数据实例化一个数据集;如果数据不存在,TorchVision 会下载数据
❷ 使用 train=False,这样我们就得到了一个用于验证数据的数据集,如果需要的话会进行下载。
我们提供给CIFAR10
函数的第一个参数是数据将被下载的位置;第二个参数指定我们是对训练集感兴趣还是对验证集感兴趣;第三个参数表示我们是否允许 PyTorch 在指定的位置找不到数据时下载数据。
就像CIFAR10
一样,datasets
子模块为我们提供了对最流行的计算机视觉数据集的预先访问,如 MNIST、Fashion-MNIST、CIFAR-100、SVHN、Coco 和 Omniglot。在每种情况下,数据集都作为torch.utils.data.Dataset
的子类返回。我们可以看到我们的cifar10
实例的方法解析顺序将其作为一个基类:
# In[4]: type(cifar10).__mro__ # Out[4]: (torchvision.datasets.cifar.CIFAR10, torchvision.datasets.vision.VisionDataset, torch.utils.data.dataset.Dataset, object)
7.1.2 Dataset 类
现在是一个好时机去了解在实践中成为torch.utils.data.Dataset
子类意味着什么。看一下图 7.2,我们就能明白 PyTorch 的Dataset
是什么。它是一个需要实现两个方法的对象:__len__
和__getitem__
。前者应该返回数据集中的项目数;后者应该返回项目,包括一个样本及其对应的标签(一个整数索引)。
在实践中,当一个 Python 对象配备了__len__
方法时,我们可以将其作为参数传递给len
Python 内置函数:
# In[5]: len(cifar10) # Out[5]: 50000
图 7.2 PyTorch Dataset
对象的概念:它不一定保存数据,但通过 __len__
和 __getitem__
提供统一访问。
同样,由于数据集配备了 __getitem__
方法,我们可以使用标准的下标索引元组和列表来访问单个项目。在这里,我们得到了一个 PIL
(Python Imaging Library,PIL
包)图像,输出我们期望的整数值 1
,对应于“汽车”:
# In[6]: img, label = cifar10[99] img, label, class_names[label] # Out[6]: (<PIL.Image.Image image mode=RGB size=32x32 at 0x7FB383657390>, 1, 'automobile')
因此,data.CIFAR10
数据集中的样本是 RGB PIL 图像的一个实例。我们可以立即绘制它:
# In[7]: plt.imshow(img) plt.show()
这产生了图 7.3 中显示的输出。这是一辆红色的汽车!³
图 7.3 CIFAR-10 数据集中的第 99 张图像:一辆汽车
7.1.3 数据集转换
这一切都很好,但我们可能需要一种方法在对其进行任何操作之前将 PIL 图像转换为 PyTorch 张量。这就是 torchvision.transforms
的作用。该模块定义了一组可组合的、类似函数的对象,可以作为参数传递给 torchvision
数据集,如 datasets.CIFAR10(...)
,并在加载数据后但在 __getitem__
返回数据之前对数据执行转换。我们可以查看可用对象的列表如下:
# In[8]: from torchvision import transforms dir(transforms) # Out[8]: ['CenterCrop', 'ColorJitter', ... 'Normalize', 'Pad', 'RandomAffine', ... 'RandomResizedCrop', 'RandomRotation', 'RandomSizedCrop', ... 'TenCrop', 'ToPILImage', 'ToTensor', ... ]
在这些转换中,我们可以看到 ToTensor
,它将 NumPy 数组和 PIL 图像转换为张量。它还会确保输出张量的维度布局为 C × H × W(通道、高度、宽度;就像我们在第四章中介绍的那样)。
让我们尝试一下 ToTensor
转换。一旦实例化,它可以像一个函数一样调用,参数是 PIL 图像,返回一个张量作为输出:
# In[9]: to_tensor = transforms.ToTensor() img_t = to_tensor(img) img_t.shape # Out[9]: torch.Size([3, 32, 32])
图像已经转换为 3 × 32 × 32 张量,因此是一个 3 通道(RGB)32 × 32 图像。请注意 label
没有发生任何变化;它仍然是一个整数。
正如我们预期的那样,我们可以直接将转换作为参数传递给 dataset .CIFAR10
:
# In[10]: tensor_cifar10 = datasets.CIFAR10(data_path, train=True, download=False, transform=transforms.ToTensor())
此时,访问数据集的元素将返回一个张量,而不是一个 PIL 图像:
# In[11]: img_t, _ = tensor_cifar10[99] type(img_t) # Out[11]: torch.Tensor
如预期的那样,形状的第一个维度是通道,标量类型是 float32
:
# In[12]: img_t.shape, img_t.dtype # Out[12]: (torch.Size([3, 32, 32]), torch.float32)
原始 PIL 图像中的值范围从 0 到 255(每个通道 8 位),ToTensor
转换将数据转换为每个通道的 32 位浮点数,将值从 0.0 缩放到 1.0。让我们验证一下:
# In[13]: img_t.min(), img_t.max() # Out[13]: (tensor(0.), tensor(1.))
现在让我们验证一下我们得到了相同的图像:
# In[14]: plt.imshow(img_t.permute(1, 2, 0)) # ❶ plt.show() # Out[14]: <Figure size 432x288 with 1 Axes>
❶ 改变轴的顺序从 C × H × W 到 H × W × C
正如我们在图 7.4 中看到的,我们得到了与之前相同的输出。
图 7.4 我们已经见过这个。
检查通过。请注意,我们必须使用 permute
来改变轴的顺序,从 C × H × W 变为 H × W × C,以匹配 Matplotlib 的期望。
7.1.4 数据标准化
转换非常方便,因为我们可以使用 transforms.Compose
链接它们,它们可以透明地处理标准化和数据增强,直接在数据加载器中进行。例如,标准化数据集是一个好习惯,使得每个通道具有零均值和单位标准差。我们在第四章中提到过这一点,但现在,在经历了第五章之后,我们也对此有了直观的理解:通过选择在 0 加减 1(或 2)附近线性的激活函数,保持数据在相同范围内意味着神经元更有可能具有非零梯度,因此会更快地学习。此外,将每个通道标准化,使其具有相同的分布,将确保通道信息可以通过梯度下降混合和更新,使用相同的学习率。这就像在第 5.4.4 节中,当我们将权重重新缩放为与温度转换模型中的偏差相同数量级时的情况。
为了使每个通道的均值为零,标准差为单位,我们可以计算数据集中每个通道的均值和标准差,并应用以下转换:v_n[c] = (v[c] - mean[c]) / stdev[c]
。这就是transforms.Normalize
所做的。mean
和stdev
的值必须离线计算(它们不是由转换计算的)。让我们为 CIFAR-10 训练集计算它们。
由于 CIFAR-10 数据集很小,我们将能够完全在内存中操作它。让我们沿着额外的维度堆叠数据集返回的所有张量:
# In[15]: imgs = torch.stack([img_t for img_t, _ in tensor_cifar10], dim=3) imgs.shape # Out[15]: torch.Size([3, 32, 32, 50000])
现在我们可以轻松地计算每个通道的均值:
# In[16]: imgs.view(3, -1).mean(dim=1) # ❶ # Out[16]: tensor([0.4915, 0.4823, 0.4468])
❶ 请记住,view(3, -1)保留了三个通道,并将所有剩余的维度合并成一个,找出适当的大小。这里我们的 3 × 32 × 32 图像被转换成一个 3 × 1,024 向量,然后对每个通道的 1,024 个元素取平均值。
计算标准差类似:
# In[17]: imgs.view(3, -1).std(dim=1) # Out[17]: tensor([0.2470, 0.2435, 0.2616])
有了这些数据,我们可以初始化Normalize
转换
# In[18]: transforms.Normalize((0.4915, 0.4823, 0.4468), (0.2470, 0.2435, 0.2616)) # Out[18]: Normalize(mean=(0.4915, 0.4823, 0.4468), std=(0.247, 0.2435, 0.2616))
并在ToTensor
转换后连接它:
# In[19]: transformed_cifar10 = datasets.CIFAR10( data_path, train=True, download=False, transform=transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4915, 0.4823, 0.4468), (0.2470, 0.2435, 0.2616)) ]))
请注意,在这一点上,绘制从数据集中绘制的图像不会为我们提供实际图像的忠实表示:
# In[21]: img_t, _ = transformed_cifar10[99] plt.imshow(img_t.permute(1, 2, 0)) plt.show()
我们得到的重新归一化的红色汽车如图 7.5 所示。这是因为归一化已经将 RGB 级别移出了 0.0 到 1.0 的范围,并改变了通道的整体幅度。所有的数据仍然存在;只是 Matplotlib 将其渲染为黑色。我们将记住这一点以备将来参考。
图 7.5 归一化后的随机 CIFAR-10 图像
图 7.6 手头的问题:我们将帮助我们的朋友为她的博客区分鸟和飞机,通过训练一个神经网络来完成这项任务。
尽管如此,我们加载了一个包含成千上万张图片的花哨数据集!这非常方便,因为我们正需要这样的东西。
7.2 区分鸟和飞机
珍妮,我们在观鸟俱乐部的朋友,在机场南部的树林里设置了一组摄像头。当有东西进入画面时,摄像头应该保存一张照片并上传到俱乐部的实时观鸟博客。问题是,许多从机场进出的飞机最终触发了摄像头,所以珍妮花了很多时间从博客中删除飞机的照片。她需要的是一个像图 7.6 中所示的自动化系统。她需要一个神经网络–如果我们喜欢花哨的营销说辞,那就是人工智能–来立即丢弃飞机。
别担心!我们会处理好的,没问题–我们刚好有了完美的数据集(多么巧合啊,对吧?)。我们将从我们的 CIFAR-10 数据集中挑选出所有的鸟和飞机,并构建一个可以区分鸟和飞机的神经网络。
7.2.1 构建数据集
第一步是将数据整理成正确的形状。我们可以创建一个仅包含鸟和飞机的Dataset
子类。然而,数据集很小,我们只需要在数据集上进行索引和len
操作。它实际上不必是torch.utils.data.dataset.Dataset
的子类!那么,为什么不简单地过滤cifar10
中的数据并重新映射标签,使它们连续呢?下面是具体操作:
# In[5]: label_map = {0: 0, 2: 1} class_names = ['airplane', 'bird'] cifar2 = [(img, label_map[label]) for img, label in cifar10 if label in [0, 2]] cifar2_val = [(img, label_map[label]) for img, label in cifar10_val if label in [0, 2]]
cifar2
对象满足Dataset
的基本要求–也就是说,__len__
和__getitem__
已经定义–所以我们将使用它。然而,我们应该意识到,这是一个聪明的捷径,如果我们在使用中遇到限制,我们可能希望实现一个合适的Dataset
。⁴
我们有了数据集!接下来,我们需要一个模型来处理我们的数据。
7.2.2 一个全连接的模型
我们在第五章学习了如何构建一个神经网络。我们知道它是一个特征的张量输入,一个特征的张量输出。毕竟,一幅图像只是以空间配置排列的一组数字。好吧,我们还不知道如何处理空间配置部分,但理论上,如果我们只是取图像像素并将它们展平成一个长的 1D 向量,我们可以将这些数字视为输入特征,对吧?这就是图 7.7 所说明的。
图 7.7 将我们的图像视为一维值向量并在其上训练一个全连接分类器
让我们试试看。每个样本有多少特征?嗯,32 × 32 × 3:也就是说,每个样本有 3072 个输入特征。从我们在第五章构建的模型开始,我们的新模型将是一个具有 3072 个输入特征和一些隐藏特征数量的nn.Linear
,然后是一个激活函数,然后是另一个将网络缩减到适当的输出特征数量(对于这种用例为 2)的nn.Linear
:
# In[6]: import torch.nn as nn n_out = 2 model = nn.Sequential( nn.Linear( 3072, # ❶ 512, # ❷ ), nn.Tanh(), nn.Linear( 512, # ❷ n_out, # ❸ ) )
❶ 输入特征
❷ 隐藏层大小
❸ 输出类别
我们有点随意地选择了 512 个隐藏特征。神经网络至少需要一个隐藏层(激活层,所以两个模块),中间需要一个非线性激活函数,以便能够学习我们在第 6.3 节中讨论的任意函数–否则,它将只是一个线性模型。隐藏特征表示(学习的)输入之间通过权重矩阵编码的关系。因此,模型可能会学习“比较”向量元素 176 和 208,但它并不会事先关注它们,因为它在结构上不知道这些实际上是(第 5 行,第 16 像素)和
(第 6 行,第 16 像素),因此是相邻的。
所以我们有了一个模型。接下来我们将讨论我们模型的输出应该是什么。
7.2.3 分类器的输出
在第六章中,网络产生了预测的温度(具有定量意义的数字)作为输出。我们可以在这里做类似的事情:使我们的网络输出一个单一的标量值(所以n_out = 1
),将标签转换为浮点数(飞机为 0.0,鸟为 1.0),并将其用作MSELoss
的目标(批次中平方差的平均值)。这样做,我们将问题转化为一个回归问题。然而,更仔细地观察,我们现在处理的是一种性质有点不同的东西。
我们需要认识到输出是分类的:它要么是飞机,要么是鸟(或者如果我们有所有 10 个原始类别的话,还可能是其他东西)。正如我们在第四章中学到的,当我们必须表示一个分类变量时,我们应该切换到该变量的一种独热编码表示,比如对于飞机是[1,
0]
,对于鸟是[0,
1]
(顺序是任意的)。如果我们有 10 个类别,如完整的 CIFAR-10 数据集,这仍然有效;我们将只有一个长度为 10 的向量。
在理想情况下,网络将为飞机输出torch.tensor([1.0, 0.0])
,为鸟输出torch.tensor([0.0, 1.0])
。实际上,由于我们的分类器不会是完美的,我们可以期望网络输出介于两者之间的值。在这种情况下的关键认识是,我们可以将输出解释为概率:第一个条目是“飞机”的概率,第二个是“鸟”的概率。
将问题转化为概率的形式对我们网络的输出施加了一些额外的约束:
- 输出的每个元素必须在
[0.0, 1.0]
范围内(一个结果的概率不能小于 0 或大于 1)。 - 输出的元素必须加起来等于 1.0(我们确定两个结果中的一个将会发生)。
这听起来像是在一个数字向量上以可微分的方式强制执行一个严格的约束。然而,有一个非常聪明的技巧正是做到了这一点,并且是可微分的:它被称为softmax。
7.2.4 将输出表示为概率
Softmax 是一个函数,它接受一个值向量并产生另一个相同维度的向量,其中值满足我们刚刚列出的表示概率的约束条件。Softmax 的表达式如图 7.8 所示。
图 7.8 手写 softmax
也就是说,我们取向量的元素,计算元素的指数,然后将每个元素除以指数的总和。在代码中,就像这样:
# In[7]: def softmax(x): return torch.exp(x) / torch.exp(x).sum()
让我们在一个输入向量上测试一下:
# In[8]: x = torch.tensor([1.0, 2.0, 3.0]) softmax(x) # Out[8]: tensor([0.0900, 0.2447, 0.6652])
如预期的那样,它满足概率的约束条件:
# In[9]: softmax(x).sum() # Out[9]: tensor(1.)
Softmax 是一个单调函数,即输入中的较低值将对应于输出中的较低值。然而,它不是尺度不变的,即值之间的比率不被保留。事实上,输入的第一个和第二个元素之间的比率为 0.5,而输出中相同元素之间的比率为 0.3678。这并不是一个真正的问题,因为学习过程将以适当的比率调整模型的参数。
nn
模块将 softmax 作为一个模块提供。由于通常输入张量可能具有额外的批次第 0 维,或者具有编码概率的维度和其他维度,nn.Softmax
要求我们指定应用 softmax 函数的维度:
# In[10]: softmax = nn.Softmax(dim=1) x = torch.tensor([[1.0, 2.0, 3.0], [1.0, 2.0, 3.0]]) softmax(x) # Out[10]: tensor([[0.0900, 0.2447, 0.6652], [0.0900, 0.2447, 0.6652]])
在这种情况下,我们有两个输入向量在两行中(就像我们处理批次时一样),因此我们初始化nn.Softmax
以沿着第 1 维操作。
太棒了!我们现在可以在模型末尾添加一个 softmax,这样我们的网络就能够生成概率:
# In[11]: model = nn.Sequential( nn.Linear(3072, 512), nn.Tanh(), nn.Linear(512, 2), nn.Softmax(dim=1))
实际上,我们可以在甚至训练模型之前尝试运行模型。让我们试试,看看会得到什么。我们首先构建一个包含一张图片的批次,我们的鸟(图 7.9):
# In[12]: img, _ = cifar2[0] plt.imshow(img.permute(1, 2, 0)) plt.show()
图 7.9 CIFAR-10 数据集中的一只随机鸟(归一化后)
哦,你好。为了调用模型,我们需要使输入具有正确的维度。我们记得我们的模型期望输入中有 3,072 个特征,并且nn
将数据组织成沿着第零维的批次。因此,我们需要将我们的 3 × 32 × 32 图像转换为 1D 张量,然后在第零位置添加一个额外的维度。我们在第三章学习了如何做到这一点:
# In[13]: img_batch = img.view(-1).unsqueeze(0)
现在我们准备调用我们的模型:
# In[14]: out = model(img_batch) out # Out[14]: tensor([[0.4784, 0.5216]], grad_fn=<SoftmaxBackward>)
所以,我们得到了概率!好吧,我们知道我们不应该太兴奋:我们的线性层的权重和偏置根本没有经过训练。它们的元素由 PyTorch 在-1.0 和 1.0 之间随机初始化。有趣的是,我们还看到输出的grad_fn
,这是反向计算图的顶点(一旦我们需要反向传播时将被使用)。
另外,虽然我们知道哪个输出概率应该是哪个(回想一下我们的class_names
),但我们的网络并没有这方面的指示。第一个条目是“飞机”,第二个是“鸟”,还是反过来?在这一点上,网络甚至无法判断。正是损失函数在反向传播后将这两个数字关联起来。如果标签提供为“飞机”索引 0 和“鸟”索引 1,那么输出将被诱导采取这个顺序。因此,在训练后,我们将能够通过计算输出概率的argmax来获得标签:也就是说,我们获得最大概率的索引。方便的是,当提供一个维度时,torch.max
会返回沿着该维度的最大元素以及该值出现的索引。在我们的情况下,我们需要沿着概率向量(而不是跨批次)取最大值,因此是第 1 维:
# In[15]: _, index = torch.max(out, dim=1) index # Out[15]: tensor([1])
它说这张图片是一只鸟。纯属运气。但我们通过让模型输出概率来适应手头的分类任务,现在我们已经运行了我们的模型对输入图像进行验证,确保我们的管道正常工作。是时候开始训练了。与前两章一样,我们在训练过程中需要最小化的损失。
7.2.5 用于分类的损失
我们刚提到损失是给概率赋予意义的。在第 5 和第六章中,我们使用均方误差(MSE)作为我们的损失。我们仍然可以使用 MSE,并使我们的输出概率收敛到[0.0, 1.0]
和[1.0, 0.0]
。然而,仔细想想,我们并不真正关心精确复制这些值。回顾我们用于提取预测类别索引的 argmax 操作,我们真正感兴趣的是第一个概率对于飞机而言比第二个更高,对于鸟而言则相反。换句话说,我们希望惩罚错误分类,而不是费力地惩罚一切看起来不完全像 0.0 或 1.0 的东西。
在这种情况下,我们需要最大化的是与正确类别相关联的概率,out[class_index]
,其中out
是 softmax 的输出,class_index
是一个包含 0 表示“飞机”和 1 表示“鸟”的向量,对于每个样本。这个数量–即与正确类别相关联的概率–被称为似然度(给定数据的模型参数的)。换句话说,我们希望一个损失函数在似然度低时非常高:低到其他选择具有更高的概率。相反,当似然度高于其他选择时,损失应该很低,我们并不真正固执于将概率提高到 1。
有一个表现出这种行为的损失函数,称为负对数似然(NLL)。它的表达式为NLL = - sum(log(out_i[c_i]))
,其中求和是针对N个样本,c_i
是样本i的正确类别。让我们看一下图 7.10,它显示了 NLL 作为预测概率的函数。
图 7.10 预测概率的 NLL 损失函数
图表显示,当数据被分配低概率时,NLL 增长到无穷大,而当概率大于 0.5 时,它以相对缓慢的速度下降。记住,NLL 以概率作为输入;因此,随着可能性增加,其他概率必然会减少。
总结一下,我们的分类损失可以计算如下。对于批次中的每个样本:
- 运行正向传播,并从最后(线性)层获取输出值。
- 计算它们的 softmax,并获得概率。
- 获取与正确类别对应的预测概率(参数的似然度)。请注意,我们知道正确类别是什么,因为这是一个监督问题–这是我们的真实值。
- 计算其对数,加上一个负号,并将其添加到损失中。
那么,在 PyTorch 中我们如何做到这一点呢?PyTorch 有一个nn.NLLLoss
类。然而(注意),与您可能期望的相反,它不接受概率,而是接受对数概率的张量作为输入。然后,它计算给定数据批次的我们模型的 NLL。这种输入约定背后有一个很好的原因:当概率接近零时,取对数是棘手的。解决方法是使用nn.LogSoftmax
而不是nn.Softmax
,后者会确保计算在数值上是稳定的。
现在我们可以修改我们的模型,使用nn.LogSoftmax
作为输出模块:
model = nn.Sequential( nn.Linear(3072, 512), nn.Tanh(), nn.Linear(512, 2), nn.LogSoftmax(dim=1))
然后我们实例化我们的 NLL 损失:
loss = nn.NLLLoss()
损失将nn.LogSoftmax
的输出作为批次的第一个参数,并将类别索引的张量(在我们的情况下是零和一)作为第二个参数。现在我们可以用我们的小鸟来测试它:
img, label = cifar2[0] out = model(img.view(-1).unsqueeze(0)) loss(out, torch.tensor([label])) tensor(0.6509, grad_fn=<NllLossBackward>)
结束我们对损失的研究,我们可以看看使用交叉熵损失如何改善均方误差。在图 7.11 中,我们看到当预测偏离目标时,交叉熵损失有一些斜率(在低损失角落,正确类别被分配了预测概率为 99.97%),而我们在开始时忽略的均方误差更早饱和,关键是对于非常错误的预测也是如此。其根本原因是均方误差的斜率太低,无法弥补错误预测的 softmax 函数的平坦性。这就是为什么概率的均方误差不适用于分类工作。
图 7.11 预测概率与目标概率向量之间的交叉熵(左)和均方误差(右)作为预测分数的函数–也就是在(对数)softmax 之前
7.2.6 训练分类器
好了!我们准备好重新引入我们在第五章写的训练循环,并看看它是如何训练的(过程如图 7.12 所示):
import torch import torch.nn as nn model = nn.Sequential( nn.Linear(3072, 512), nn.Tanh(), nn.Linear(512, 2), nn.LogSoftmax(dim=1)) learning_rate = 1e-2 optimizer = optim.SGD(model.parameters(), lr=learning_rate) loss_fn = nn.NLLLoss() n_epochs = 100 for epoch in range(n_epochs): for img, label in cifar2: out = model(img.view(-1).unsqueeze(0)) loss = loss_fn(out, torch.tensor([label])) optimizer.zero_grad() loss.backward() optimizer.step() print("Epoch: %d, Loss: %f" % (epoch, float(loss))) # ❶
❶ 打印最后一张图像的损失。在下一章中,我们将改进我们的输出,以便给出整个时代的平均值。
图 7.12 训练循环:(A)对整个数据集进行平均更新;(B)在每个样本上更新模型;(C)对小批量进行平均更新
更仔细地看,我们对训练循环进行了一点改变。在第五章,我们只有一个循环:在时代上(回想一下,一个时代在所有训练集中的样本都被评估完时结束)。我们认为在一个批次中评估所有 10,000 张图像会太多,所以我们决定有一个内部循环,在那里我们一次评估一个样本并在该单个样本上进行反向传播。
在第一种情况下,梯度在应用之前被累积在所有样本上,而在这种情况下,我们基于单个样本上梯度的非常部分估计来应用参数的变化。然而,基于一个样本减少损失的好方向可能不适用于其他样本。通过在每个时代对样本进行洗牌并在一次或(最好是为了稳定性)几个样本上估计梯度,我们有效地在梯度下降中引入了随机性。记得随机梯度下降(SGD)吗?这代表随机梯度下降,这就是S的含义:在洗牌数据的小批量(又称小批量)上工作。事实证明,遵循在小批量上估计的梯度,这些梯度是对整个数据集估计的梯度的较差近似,有助于收敛并防止优化过程在途中遇到的局部最小值中卡住。正如图 7.13 所示,来自小批量的梯度随机偏离理想轨迹,这也是为什么我们希望使用相当小的学习率的部分原因。在每个时代对数据集进行洗牌有助于确保在小批量上估计的梯度序列代表整个数据集上计算的梯度。
通常,小批量是一个在训练之前需要设置的固定大小,就像学习率一样。这些被称为超参数,以区别于模型的参数。
图 7.13 梯度下降在整个数据集上的平均值(浅色路径)与随机梯度下降,其中梯度是在随机选择的小批量上估计的。
在我们的训练代码中,我们选择了大小为 1 的小批量,一次从数据集中选择一个项目。torch.utils.data
模块有一个帮助对数据进行洗牌和组织成小批量的类:DataLoader
。数据加载器的工作是从数据集中抽样小批量,使我们能够选择不同的抽样策略。一个非常常见的策略是在每个时代洗牌数据后进行均匀抽样。图 7.14 显示了数据加载器对从Dataset
获取的索引进行洗牌的过程。
图 7.14 通过使用数据集来采样单个数据项来分发小批量数据的数据加载器
让我们看看这是如何完成的。至少,DataLoader
构造函数需要一个Dataset
对象作为输入,以及batch_size
和一个布尔值shuffle
,指示数据是否需要在每个 epoch 开始时进行洗牌:
train_loader = torch.utils.data.DataLoader(cifar2, batch_size=64, shuffle=True)
DataLoader
可以被迭代,因此我们可以直接在新训练代码的内部循环中使用它:
import torch import torch.nn as nn train_loader = torch.utils.data.DataLoader(cifar2, batch_size=64, shuffle=True) model = nn.Sequential( nn.Linear(3072, 512), nn.Tanh(), nn.Linear(512, 2), nn.LogSoftmax(dim=1)) learning_rate = 1e-2 optimizer = optim.SGD(model.parameters(), lr=learning_rate) loss_fn = nn.NLLLoss() n_epochs = 100 for epoch in range(n_epochs): for imgs, labels in train_loader: batch_size = imgs.shape[0] outputs = model(imgs.view(batch_size, -1)) loss = loss_fn(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() print("Epoch: %d, Loss: %f" % (epoch, float(loss))) # ❶
❶ 由于洗牌,现在这会打印一个随机批次的损失–显然这是我们在第八章想要改进的地方
PyTorch 深度学习(GPT 重译)(三)(3)https://developer.aliyun.com/article/1485215