本章涵盖以下内容
- 构建前馈神经网络
- 使用
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)的 Krizhevsky、Nair 和 Hinton 收集并标注,来源于一个更大的未标注 32 × 32 彩色图像集合:麻省理工学院计算机科学与人工智能实验室(CSAIL)的 “80 million tiny images dataset(八千万微小图像数据集)”。
如今,CIFAR-10 已经被认为过于简单,不再适合作为开发或验证新研究成果的数据集,但对于我们的学习目的来说,它依然非常合适。我们将使用 torchvision 模块来自动下载这个数据集,并把它加载为一个由 PyTorch 张量构成的集合。图 7.1 展示了 CIFAR-10 的一些样本图像。
图 7.1 CIFAR-10 所有类别的图像样本
7.1.1 下载 CIFAR-10
我们先导入 torchvision,然后使用其中的 datasets 模块来下载 CIFAR-10 数据(code/p1ch7/1_datasets.ipynb)。
代码清单 7.1 加载 CIFAR-10 数据
# In[2]:
from torchvision import datasets
import os
data_path = '../data-unversioned/p1ch7/'
os.makedirs(data_path, exist_ok=True)
cifar10 = datasets.CIFAR10(data_path, train=True, download=True) #1
cifar10_val = datasets.CIFAR10(data_path, train=False, download=True) #2
#1 为训练数据实例化一个数据集;如果本地不存在数据,TorchVision 会自动下载。
#2 当 train=False 时,我们得到的是验证数据集;同样地,如有需要也会自动下载
我们传给 CIFAR10 函数的第一个参数,是数据要下载到的本地目录。第二个参数指定我们想要的是训练集还是验证集。最后,第三个参数决定了:如果在第一个参数指定的位置找不到数据,是否允许 PyTorch 自动下载。
和 CIFAR10 一样,datasets 子模块还为一些最常用的计算机视觉数据集提供了现成接口,例如 MNIST、Fashion-MNIST、CIFAR-100、SVHN、COCO 和 Omniglot。在每一种情况下,返回的数据集都是 torch.utils.data.Dataset 的一个子类。我们可以看到,我们这个 cifar10 实例的方法解析顺序(method-resolution order)中就包含了它这个基类:
# In[4]:
type(cifar10).__mro__
# Out[4]:
(torchvision.datasets.cifar.CIFAR10,
torchvision.datasets.vision.VisionDataset,
torch.utils.data.dataset.Dataset,
typing.Generic,
object)
7.1.2 Dataset 类
现在正是一个好时机,来弄清楚:一个 torch.utils.data.Dataset 的子类在实际中意味着什么。看图 7.2,我们会发现,PyTorch 中的 Dataset 核心就在于:它是一个需要实现两个方法的对象——__len__ 和 __getitem__。前者应当返回数据集中条目的数量,后者则应当返回某个条目本身,该条目由一个样本和与之对应的标签(一个整数索引)组成。在我们的图像分类例子中,我们返回的是“样本 + 标签”;但一般来说,getitem 也可以返回任何数据点(例如,有标签或无标签的数据)。
图 7.2 PyTorch Dataset 对象的概念。它未必真正持有数据本身,但它通过 __len__ 和 __getitem__ 提供了统一的数据访问方式。
在实际使用中,如果一个 Python 对象实现了 __len__ 方法,那么我们就可以把它传给 Python 内置的 len 函数:
# In[5]:
len(cifar10)
# Out[5]:
50000
同样地,由于这个数据集也实现了 __getitem__ 方法,因此我们可以像索引元组和列表那样,使用标准的下标语法访问其中的单个元素。这里我们取到的是一张 PIL(Python Imaging Library,即 PIL 包)图像,以及我们所需的输出——一个值为 1 的整数,对应类别“automobile(汽车)”:
# In[6]:
img, label = cifar10[99]
img, label, class_names[label]
# Out[6]:
(<PIL.Image.Image image mode=RGB size=32x32>, 1, 'automobile')
因此,CIFAR10 数据集中的样本是一个 RGB 的 PIL 图像实例。我们可以立刻把它画出来,得到图 7.3 所示的输出:
# In[7]:
plt.imshow(img)
plt.show()
图 7.3 CIFAR-10 数据集中的第 99 张图像:一辆汽车
这是一辆红色的车!(在纸质版里效果不太明显;你只能相信我们,或者到电子书/Jupyter Notebook 里亲自看看。)
7.1.3 Dataset 的变换(transforms)
这一切都很好,不过在真正做任何事情之前,我们多半需要先把 PIL 图像转换成 PyTorch 张量。这正是 torchvision.transforms 的用途所在。我们在第 2 章中已经短暂接触过它,当时我们用 transforms 对图像做预处理。这个模块定义了一组可以组合的、类似函数的对象,它们可以作为参数传给某个 torchvision 数据集,例如 datasets.CIFAR10(...),然后在数据被加载出来之后、但在 __getitem__ 返回之前,对数据执行变换。可用对象的列表如下:
# In[8]:
from torchvision import transforms
dir(transforms)
# Out[8]:
['AugMix',
'AutoAugment',
'AutoAugmentPolicy',
'CenterCrop',
'ColorJitter',
...
'Normalize',
'PILToTensor',
'Pad',
...
'RandomChoice',
'RandomCrop',
'RandomEqualize',
...
]
在这些变换中,有一个叫 ToTensor,它可以把 NumPy 数组和 PIL 图像转换为张量。它还会把输出张量的维度排列成 C × H × W(通道、高度、宽度)。(而原始图像的格式其实是 H × W × C。)
我们来试一下 ToTensor 变换。实例化之后,它就可以像函数一样,用 PIL 图像作为参数进行调用,并返回一个张量:
# In[9]:
from torchvision import transforms
to_tensor = transforms.ToTensor()
img_t = to_tensor(img)
img_t.shape
# Out[9]:
torch.Size([3, 32, 32])
这张图像已经被转换成了一个 3 × 32 × 32 的张量,因此它就是一张三通道(RGB)的 32 × 32 图像。label 则没有任何变化,仍然是一个整数。
为了方便起见,我们也可以直接把这个变换作为参数传给 datasets.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)) #1
plt.show()
# Out[14]:
<Figure size 432x288 with 1 Axes>
#1 将轴的顺序从 C × H × W 改为 H × W × C
正如图 7.4 所示,我们得到了和之前相同的输出。注意,这里我们必须用 permute 把轴顺序从 C × H × W 改成 H × W × C,以匹配 Matplotlib 所期望的格式。
图 7.4 这张图我们已经见过了。
7.1.4 数据归一化
transforms 非常方便,因为我们可以通过 transforms.Compose 将多个变换串联起来,而且它们还能在数据加载器内部透明地处理归一化和数据增强。举例来说,一个良好的实践是:对数据集进行归一化,使得每个通道都具有零均值和单位标准差。我们在第 4 章提到过这一点;而现在,在学习完第 5 章之后,我们也对它背后的原因有了直觉理解:由于我们往往会选择那些在 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]:
mean = imgs.view(3, -1).mean(dim=1)
mean
# Out[16]:
tensor([0.4914, 0.4822, 0.4465])
回想一下,view(3, -1) 会保留这 3 个通道,并把其余所有维度合并成一个维度,由系统自动推断适当的大小。在这里,我们的 3 × 32 × 32 图像被转换成一个 3 × 1,024 的向量,然后再对每个通道的 1,024 个元素求均值。
计算标准差的过程类似:
# In[17]:
std = imgs.view(3, -1).std(dim=1)
std
# Out[17]:
tensor([0.2470, 0.2435, 0.2616])
有了这些数值之后,我们就可以初始化 Normalize 变换了:
# In[18]:
transforms.Normalize(mean=mean, std=std)
# Out[18]:
Normalize(mean=tensor([0.4914, 0.4822, 0.4465]),↪
↪ std=tensor([0.2470, 0.2435, 0.2616]))
然后把它接在 ToTensor 变换之后:
# In[19]:
transformed_cifar10 = datasets.CIFAR10(
data_path, train=True, download=False,
transform=transforms.Compose([
transforms.ToTensor(),
transforms.Normalize(mean=mean, std=std)
]))
到了这一步,如果我们把数据集中的某张图像画出来,得到的将不再是对原始图像的真实可视化表示:
# 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.2 区分鸟和飞机
我们在观鸟俱乐部的朋友 Jane,在机场南边的树林里架设了一整套摄像头。这些摄像头的设计目的是:一旦有什么东西进入画面,就自动拍下一张照片,并上传到俱乐部的实时观鸟博客。问题在于,机场起降的大量飞机也经常触发这些摄像头,因此 Jane 得花很多时间把博客上的飞机照片一张张删掉。她真正需要的是一个像图 7.6 所示的自动化系统。她不想再手动删除了;她需要一个神经网络——如果你喜欢更花哨的营销说法,也可以叫它 AI——在第一时间就把飞机照片丢掉。
图 7.6 当前要解决的问题:我们要通过训练一个神经网络,帮助我们的朋友为她的博客区分鸟和飞机。
别担心!这事交给我们,完全没问题——我们手头正好就有一个再合适不过的数据集(多巧啊,是吧?)。我们将从 CIFAR-10 数据集中挑出所有“鸟”和“飞机”的图片,然后构建一个神经网络,让它学会区分这两类图像。
7.2.1 构建数据集
第一步是把数据整理成合适的形式。我们当然可以创建一个 Dataset 子类,只保留鸟和飞机这两类数据。不过,这个数据集很小,而我们对数据集的要求也只是:支持索引和 len 即可。它其实并不一定非要是 torch.utils.data.dataset.Dataset 的子类!既然如此,何不抄个近路:直接在 cifar10 里过滤数据,并且把标签重新映射成连续的值(code/p1ch7/2_birds_airplanes.ipynb)?
代码清单 7.2 将 CIFAR-10 过滤成两个类别(鸟和飞机)
# 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。
注意 在这里,我们是手动构造了一个新数据集,并且还想对类别进行重新映射。在某些情况下,只取某个已有数据集的一部分索引就已经够用了。这个时候,我们可以使用 torch.utils.data.Subset 类来完成。同样地,我们也可以使用 ConcatDataset 把多个数据集(前提是其条目格式兼容)拼接成一个更大的数据集。对于可迭代数据集,ChainDataset 则可以生成一个更大的、可迭代的数据集。
现在我们已经有了一个数据集!接下来,我们需要一个模型来喂这些数据。
7.2.2 一个全连接模型
我们在第 5 章已经学会了如何构建一个神经网络。我们知道,神经网络本质上就是“输入特征张量进,输出特征张量出”。归根结底,一张图像也不过是一组以空间方式排列起来的数字。好吧,我们目前还不知道该如何处理“空间排列”这部分,但理论上,如果我们只是把图像像素拉直成一个长长的一维向量,那这些数字不就可以当作输入特征了吗?图 7.7 展示了这一思路。
图 7.7 把图像看作一个一维数值向量,并在其上训练一个全连接分类器
我们就来试试看。每个样本有多少个特征?32 × 32 × 3 = 3072,所以每个样本共有 3072 个输入特征。从第 5 章搭建的模型出发,我们的新模型可以写成:一个 nn.Linear,输入特征数为 3072、输出为某个隐藏特征数;后面接一个激活函数;再接一个 nn.Linear,把网络收缩到合适的输出特征数(在这个例子里是 2,因为我们只有两个类别):
# In[6]:
import torch.nn as nn
n_out = 2
model = nn.Sequential(
nn.Linear(
3072, #1
512, #2
),
nn.Tanh(),
nn.Linear(
512,
n_out, #3
)
)
#1 输入特征数
#2 隐藏层大小
#3 输出类别数
我们在某种程度上是随意选了 512 个隐藏特征。一个神经网络若想按我们在 6.3 节中讨论的那种方式学习任意函数,就至少需要一个隐藏层(包含激活,因此实际上是两个模块),并且在中间包含非线性。否则,它仍然只是一个线性模型。隐藏特征表示的是输入之间通过权重矩阵编码出来的(学习到的)关系。因此,这个模型也许会学会去“比较”向量中的第 176 个元素和第 208 个元素,但它事先并不知道这两个元素其实分别对应于(第 5 行,第 16 个像素)和(第 6 行,第 16 个像素),也就是说,它并没有结构性地意识到它们彼此相邻。
所以,现在我们有了一个模型。接下来,我们来讨论模型的输出应该是什么样子。
7.2.3 分类器的输出
在第 6 章中,网络输出的是预测温度——这是一个具有定量意义的数值。我们在这里也可以做类似的事:让网络输出一个标量值(即 n_out = 1),把标签转成浮点数(飞机记作 0.0,鸟记作 1.0),然后使用这些值作为 MSELoss 的目标(即对一个 batch 中平方误差求平均)。这么做的话,我们就把这个问题当成了一个回归问题。然而,更仔细地看,我们现在面对的是一种本质上稍有不同的问题。
注意 如果回忆第 4 章关于数值类型的讨论,就会知道:对于类别数据,直接拿类别编号去做 MSELoss 根本没有意义,实际中也完全行不通。即便是对“概率”向量之间的距离做度量,也会比拿类别编号直接做 MSE 好得多。不过,即便如此,MSELoss 仍然并不适合分类问题。
我们需要认识到:这里的输出是类别型的。它要么是鸟,要么是飞机(如果使用 CIFAR-10 全部 10 个原始类别,那当然还可能是其他类别)。正如我们在第 4 章中学到的那样,当需要表示一个类别变量时,我们应当切换为该变量的 one-hot 编码 表示,例如用 [1, 0] 表示飞机,用 [0, 1] 表示鸟(顺序可以任意约定)。如果我们有 10 个类别,像完整的 CIFAR-10 数据集那样,这种方法依然适用——只是向量长度会变成 10。
注意 对于特殊的二分类问题,这里使用两个值其实是冗余的,因为其中一个值总等于 1 - 另一个。实际上,PyTorch 允许我们只输出一个概率:在模型末尾加上 nn.Sigmoid 激活得到概率,再配合二元交叉熵损失函数 nn.BCELoss。另外,nn.BCEWithLogitsLoss 还会把这两个步骤合并起来。
在理想情况下,网络对于飞机应输出 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 是一个单调函数,这意味着较小的输入值总会产生较小的输出值,因此它会保持排序次序不变。不过,它并不是尺度不变的——数值之间的比例关系会在变换过程中发生变化。举例来说,对于输入向量 [1.0, 2.0, 3.0],第一个元素和第二个元素之间的比例是 1/2 = 0.5;而经过 softmax 之后,这个比例变成了大约 0.090/0.245 = 0.368。这种比例的变化并不会妨碍学习,因为神经网络会自动调整自身参数,以生成合适的 logit 值,从而在应用 softmax 后得到所需的概率分布。
nn 模块把 softmax 也封装成了一个模块。由于和往常一样,输入张量可能会额外包含 batch 的第 0 维,也可能有一些维度用于编码概率、另一些维度则不是,因此 nn.Softmax 需要我们显式指定:softmax 函数沿着哪个维度应用:
# In[10]:
softmax = nn.Softmax(dim=1)
x = torch.tensor([[1.0, 2.0, 3.0],
[3.0, 2.0, 1.0]])
softmax(x)
# Out[10]:
tensor([[0.0900, 0.2447, 0.6652],
[0.6652, 0.2447, 0.0900]])
在这里,我们有两个输入向量,分别位于两行中(这和处理 batch 时很像),所以我们把 nn.Softmax 设置为沿第 1 维工作(即对每一行应用 softmax)。
太好了!现在我们可以在模型末尾加上一个 softmax,这样网络就具备输出概率的能力了:
# In[11]:
model = nn.Sequential(
nn.Linear(3072, 512),
nn.Tanh(),
nn.Linear(512, 2),
nn.Softmax(dim=1))
我们甚至可以在训练之前,先跑一遍模型看看会输出什么。就这么做吧,纯粹先感受一下。我们先构造一个只包含一张图像的 batch,这张图像是一只鸟(见图 7.9):
# In[12]:
img, _ = cifar2[0]
plt.imshow(img.permute(1, 2, 0))
plt.show()
图 7.9 CIFAR-10 数据集中的一只随机鸟(归一化之后)
我们能看出来,这张图多少还是有点像一只鸟。要调用模型,我们得先把输入整理成正确的维度。回想一下,我们的模型期望输入有 3072 个特征,而 nn 模块又要求数据沿第 0 维按 batch 组织。因此,我们需要先把这个 3 × 32 × 32 的图像展平成一个一维张量,然后再在第 0 个位置额外加一个维度。这个操作我们在第 3 章里已经学过:
# In[13]:
img_batch = img.view(-1).unsqueeze(0)
img_batch.shape
# Out[13]:
torch.Size([1, 3072])
现在我们就可以调用模型了:
# In[14]:
out = model(img_batch)
out
# Out[14]:
tensor([[0.4784, 0.5216]], grad_fn=<SoftmaxBackward>)
于是,我们得到了概率!当然,我们也知道,现在还不能高兴得太早:线性层里的权重和偏置根本还没有训练过。它们的元素只是由 PyTorch 在 -1.0 到 1.0 之间随机初始化的。有意思的是,我们还能在输出里看到 grad_fn,它就是反向计算图最末端的那个节点(等到我们需要反向传播时,它就会派上用场)。
注意 从原则上说,我们当然可以认为此时模型是“不确定”的(因为它把两类分别赋予了 48% 和 52% 的概率),但典型的训练结果往往会产生过度自信的模型。贝叶斯神经网络在一定程度上可以缓解这一问题,不过这超出了本书的范围。
另外,虽然我们知道这两个输出概率分别对应哪一类(回想一下我们的 class_names),但网络自己其实并不知道。第一个值是“飞机”,第二个值是“鸟”,还是反过来?在这一刻,网络根本无从区分。正是损失函数,在经过反向传播之后,才会把这两个数字和具体语义对应起来。如果标签里规定“飞机”对应索引 0,“鸟”对应索引 1,那么训练之后,模型输出也就会被引导成遵循这一顺序。
因此,在训练完成后,我们就可以通过计算输出概率的 argmax 来得到类别标签,也就是概率最大值所在的位置索引。很方便的是,当给定一个维度时,torch.max 不仅会返回该维度上的最大值,还会返回这个最大值所在的索引。在我们的例子中,需要沿着概率向量来取最大值(而不是跨 batch 取),因此是第 1 维:
# In[15]:
_, index = torch.max(out, dim=1)
index
# Out[15]:
tensor([1])
它说这张图是只鸟。纯属运气。不过,至少我们已经根据当前这个分类任务,把模型输出适配成了概率形式。我们也已经让模型真正跑过了一张输入图像,并验证了整个管线是通的。现在,终于到了训练的时候。和前两章一样,我们需要一个在训练过程中要最小化的损失函数。
用于分类的损失
我们刚才提到过,正是损失函数赋予了模型输出概率真正的含义。在第 5 章和第 6 章中,我们使用的是均方误差(MSE)作为损失函数。这里我们当然也可以继续用 MSE,让输出概率分别收敛到 [0.0, 1.0] 和 [1.0, 0.0]。不过仔细想一想,我们真正关心的其实并不是把这两个值精确复现出来。回看我们用来提取预测类别索引的 argmax 操作,我们真正关心的是:对于飞机,第一项概率要高于第二项;对于鸟,则相反。换句话说,我们想惩罚的是误分类,而不是执着地惩罚一切“不完全等于 0.0 或 1.0”的输出。
在这种情况下,我们真正需要最大化的是:与正确类别对应的那个概率,记作 out[class_index],其中 out 是 softmax 的输出,class_index 则是一个向量,里面对每个样本分别存放“飞机”的 0 或“鸟”的 1。这个量——也就是与正确类别对应的概率——被称为似然(likelihood) (更准确地说,是在给定数据下,模型参数的似然)。
提示 想要一个简洁的术语定义,可以参考 David MacKay 的 Information Theory, Inference, and Learning Algorithms(Cambridge University Press, 2003)第 2.3 节。
换句话说,我们需要一个这样的损失函数:当似然很低时,它的值要非常高——低到其他类别的概率反而更高;反过来,当似然高于其他类别时,损失应该较低,而且我们并不执着于一定要把这个概率推到 1。
确实存在这样一个损失函数,它叫 负对数似然(negative log likelihood, NLL) 。它的表达式为:
NLL = - sum(log(out_i[c_i]))
其中求和是对 N 个样本进行的,c_i 是第 i 个样本的正确类别。我们来看图 7.10,它展示了 NLL 随预测概率变化的情况。
图 7.10 NLL 损失作为预测概率函数的曲线
图 7.10 表明:当给数据分配了很低的概率时,NLL 会增长到无穷大;而当概率高于 0.5 之后,它下降得则相对平缓。记住,NLL 是以概率作为输入的,因此随着正确类别的似然变大,其他类别的概率必然会相应减小。
总结一下,我们的分类损失可以按如下方式计算。对于 batch 中的每一个样本:
- 做一次前向传播,获得最后一层(线性层)的输出值。
- 对这些输出做 softmax,得到概率。
- 取出与正确类别对应的那个预测概率(也就是参数的似然)。由于这是一个监督学习问题,我们知道正确类别,也就是 ground truth。
- 对这个概率取对数,加上负号,然后把它累加到损失里。
那么,在 PyTorch 中要怎么实现呢?PyTorch 提供了一个 nn.NLLLoss 类。不过(接下来有个坑),和你可能预期的不一样,它接收的并不是概率,而是一个对数概率张量作为输入。然后它会计算:在给定这一批数据的情况下,我们模型的 NLL。之所以采用这种输入约定,是有充分理由的:当概率非常接近 0 时,直接取对数会很棘手。解决办法是使用 nn.LogSoftmax 而不是 nn.Softmax,它会以数值稳定的方式完成这一计算。
现在我们可以把模型修改成使用 nn.LogSoftmax 作为输出模块:
# In[16]:
model = nn.Sequential(
nn.Linear(3072, 512),
nn.Tanh(),
nn.Linear(512, 2),
nn.LogSoftmax(dim=1))
然后实例化我们的 NLL 损失:
# In[17]:
loss = nn.NLLLoss()
这个损失函数以某个 batch 上 nn.LogSoftmax 的输出作为第一个参数,以类别索引张量(在这里就是由 0 和 1 组成的张量)作为第二个参数。现在我们就可以拿那只小鸟来试一下:
# In[18]:
img, label = cifar2[0]
out = model(img.view(-1).unsqueeze(0))
loss(out, torch.tensor([label]))
# Out[18]:
tensor(0.5077, grad_fn=<NllLossBackward0>)
继续比较不同的损失函数,会发现交叉熵损失(cross-entropy loss) 与负对数似然关系非常密切。当你把交叉熵损失直接应用到 logits(即还没有经过 LogSoftmax 的层输出)上时,得到的结果与“先对输出做 LogSoftmax,再对结果应用 NLLLoss”是一样的。交叉熵损失会在内部先对 logits 计算 softmax,然后再计算负对数似然,也就是说,它本质上是把这两个步骤合并成了一次操作。
我们可以观察到:在分类任务中,和 MSE 相比,使用交叉熵损失有明显优势。正如图 7.11 所示,即便预测已经几乎正确——例如给正确类别分配了 99.97% 的预测概率——交叉熵损失仍然保有梯度或斜率。相比之下,MSE 会更早进入饱和状态。而且这种饱和甚至会发生在预测依然明显错误的时候,这正是它的一个关键缺陷。其根本原因在于:在预测错误的情形下,MSE 的斜率太平缓,无法有效抵消 softmax 函数所带来的平坦效应。MSE 的这一特性,使它并不适合处理分类任务中的概率输出;而在这类任务中,保持一个有反应能力的梯度,对于有效学习和模型调整是至关重要的。
图 7.11 交叉熵(左)与“预测概率和目标概率向量之间的 MSE”(右),作为预测分数(也就是应用(log-)softmax 之前的值)的函数关系图
7.2.5 训练分类器
好了!我们终于可以把第 5 章写过的训练循环拿出来,看看它是如何训练的了(这个过程如图 7.12 所示)。现在我们可以写出训练循环:
# In[19]:
import torch
import torch.nn as nn
import torch.optim as optim
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 = 5
for epoch in range(n_epochs):
for img, label in cifar2:
img_tensor = img.view(-1).unsqueeze(0)
label_tensor = torch.tensor([label])
out = model(img_tensor)
loss = loss_fn(out, label_tensor)
optimizer.zero_grad()
loss.backward()
optimizer.step()
print("Epoch: %d, Loss: %f" % (epoch, float(loss))) #1
#1 打印最后一张图像对应的损失
图 7.12 训练循环:(A) 在整个数据集上平均后再更新;(B) 每个样本更新一次模型;(C) 在小批量上平均后更新。
你可能已经注意到,我们对之前的训练循环做了修改。在第 5 章中,我们使用的是一个单层循环:一次处理整个数据集,执行前向传播、反向传播和优化器更新,并把这个过程重复 N 个 epoch(其中一个 epoch 表示完整遍历一次整个训练集)。一个 batch 指的是:模型参数更新之前所处理的样本数量。因此,在那种情况下,每个 epoch 实际上只处理了一个 batch。
但是现在,我们的数据集里有 10,000 张图像,如果一次性把所有图像都放进同一个 batch 里处理,batch 会太大,不现实。因此,我们实现了一个内层循环,每次只处理一个样本,并针对这个单独样本做反向传播。
在原来的训练循环中,梯度是在所有样本上累积之后再统一应用的;而在这个新实现中,我们根据单个样本来更新参数,因此这只是对真实梯度的一种局部估计。问题在于:对于某一个样本来说,能降低损失的更新方向,未必对其他样本也同样有利。通过在每个 epoch 中打乱样本顺序,并且每次只用一个或少数几个样本来估计梯度,我们实际上是在给梯度下降引入随机性。还记得 SGD 吗?它的全称是 stochastic gradient descent(随机梯度下降) ,其中这个 S 指的正是:在打乱后的数据上,用小 batch(也就是 minibatch)来训练。沿着 minibatch 上估计出来的梯度前进——虽然这种梯度相对于全数据集上的真实梯度来说更粗糙——实际上反而有助于收敛,并能防止优化过程陷入沿途遇到的局部极小值中。正如图 7.13 所示,minibatch 的梯度会随机偏离理想轨迹,这也是为什么我们通常要把学习率设得相对较小。每个 epoch 对数据集重新打乱,有助于保证 minibatch 上估计出的梯度序列,能够代表整个数据集上的梯度情况。
图 7.13 在整个数据集上平均后的梯度下降(浅色路径)与随机梯度下降的对比;后者的梯度是在随机抽取的 minibatch 上估计的
通常,minibatch 的大小是一个在训练前就要设定的固定值,就像学习率一样。这些量被称为 超参数(hyperparameters) ,以区别于模型本身的参数。
在我们的训练代码中,我们通过每次只从数据集中取一个样本,实际上使用了大小为 1 的 minibatch。torch.utils.data 模块提供了一个有助于数据打乱和组织 minibatch 的类,叫作 DataLoader,它可以帮助我们使用更大的 batch 大小。数据加载器的职责,是从数据集中抽取 minibatch,同时允许我们灵活选择不同的采样策略。一种非常常见的策略是:在每个 epoch 开始时先打乱数据,然后进行均匀采样。图 7.14 展示了数据加载器如何通过 Dataset 提供的索引进行打乱。
图 7.14 数据加载器通过数据集来抽取单个数据项,并将其打包成 minibatch 输出
我们来看具体怎么做。最基本地,DataLoader 构造函数需要一个 Dataset 对象作为输入,外加 batch_size 和一个 shuffle 布尔值,用来指定是否要在每个 epoch 开始时打乱数据:
# In[20]:
train_loader = torch.utils.data.DataLoader(cifar2, batch_size=64,
shuffle=True)
DataLoader 是可迭代的,因此我们可以直接在新的训练代码内层循环中使用它:
# In[21]:
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]
img_tensor = imgs.view(batch_size, -1)
label_tensor = torch.tensor(labels)
out = model(img_tensor)
loss = loss_fn(out, label_tensor)
optimizer.zero_grad()
loss.backward()
optimizer.step()
print("Epoch: %d, Loss: %f" % (epoch, float(loss))) #1
#1 由于数据被打乱,这里打印的是一个随机 batch 的损失——显然这是我们想在第 8 章改进的地方。
在每次内层迭代中,imgs 是一个大小为 64 × 3 × 32 × 32 的张量,也就是说,是一个由 64 张 32 × 32 RGB 图像组成的 minibatch;而 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
我们看到损失确实有所下降,但我们并不知道它是否已经足够低。既然我们的目标是对图像进行正确分类,而且最好是在一个独立的数据集上做到这一点,那么我们就可以在验证集上计算模型的准确率,也就是:正确分类的数量除以总样本数:
# In[22]:
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.811000
这算不上特别出色,但比随机猜已经强了不少。平心而论,我们的模型只是一个相当浅的分类器,它居然能起作用,已经有点像奇迹了。它之所以能起作用,是因为我们的数据集确实非常简单——这两个类别中的很多样本,大概率存在某些系统性的差异(比如背景颜色),使得模型只凭少数像素就能把鸟和飞机区分开来。
我们当然可以给模型加点“花活”,比如加入更多层,这会提高模型的深度和容量。一个颇为随意的选择如下:
# In[23]:
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.NLLLoss计算的是交叉熵,但它要求输入是对数概率。nn.CrossEntropyLoss则直接作用于原始分数(通常称为 logits)。
从技术上说,nn.NLLLoss 衡量的是:一个将全部概率质量集中在目标类别上的分布,与由这些对数概率预测出来的分布之间的交叉熵。
因此,一个很常见的做法是:直接把网络最后一层的 nn.LogSoftmax 去掉,只把 nn.CrossEntropyLoss 当作损失函数来使用。我们来试一下:
# In[24]:
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.813000,只比原来略微提升了一点,增幅并不大。然而,在训练集上的准确率却是完美的(1.000000)。这说明了什么?说明无论在哪种情况下,我们都在过拟合。我们的全连接模型在训练集上找到了区分鸟和飞机的方法,但它的做法更像是在“记住”训练集,而不是学到可以泛化的特征;因此,即便模型做得更大,它在验证集上的表现仍然谈不上多么出色。
PyTorch 提供了一种快速查看模型参数数量的方法:通过 nn.Module 的 parameters() 方法(这也是我们把参数传给优化器时使用的同一个方法)。为了知道每个参数张量里有多少个元素,我们可以调用其 numel 方法;把这些值加总起来,就得到了总参数量。根据具体使用场景,我们还可能需要检查参数的 requires_grad 是否为 True,因为我们有时想区分“可训练参数的数量”和“模型整体大小”。现在我们来看一看当前这个模型的情况:
# In[28]:
numel_list = [p.numel()
for p in model.parameters()
if p.requires_grad == True]
sum(numel_list), numel_list
# Out[28]:
(3737474, [3145728, 1024, 524288, 512, 65536, 128, 256, 2])
哇,370 多万个参数!对于这样一个小尺寸输入来说,这个规模已经相当可观了,不是吗?其实,就连我们最初那个网络也已经不算小了:
# In[29]:
numel_list = [p.numel() for p in first_model.parameters()]
sum(numel_list), numel_list
# Out[29]:
(1574402, [1572864, 512, 1024, 2])
我们第一个模型的参数总量,大约是最新模型的一半。看看每层参数规模的列表,我们已经开始能看出主要元凶是谁了:第一层模块,它自己就有 150 万个参数。而在最新这个网络里,我们设定了 1024 个输出特征,于是第一个线性模块一下子就有了 300 多万个参数。这其实并不奇怪:我们知道线性层计算的是 y = weight * x + bias。如果 x 的长度是 3072(先忽略 batch 维),而 y 必须有 1024 个元素,那么权重张量的大小就必须是 1024 × 3072,偏置的大小则必须是 1024。于是:
1024 * 3072 + 1024 = 3,146,752
这正对应于 numel_list 第 28 个单元输出中的前两个参数值。我们可以直接验证这些量:
# In[30]:
linear = nn.Linear(3072, 1024)
linear.weight.shape, linear.bias.shape
# Out[30]:
(torch.Size([1024, 3072]), torch.Size([1024]))
这说明了什么?说明我们的神经网络在像素数增长时,扩展性会非常差。如果我们面对的是一张 1024 × 1024 的 RGB 图像呢?那就是 310 多万个输入值。即便你仍然非常粗暴地直接压到 1024 个隐藏特征(而这对分类器来说其实根本行不通),参数量也会超过 30 亿。假设使用 32 位浮点数存储,光这一层参数就已经要占到 12 GB 内存了,而这时我们甚至还没进入第二层,更别提计算和存储梯度了。这在如今大多数 GPU 上都是根本装不下的。
7.2.6 全连接方式的局限
让我们推理一下:把一个线性模块应用到图像的一维展开表示上,究竟意味着什么。图 7.15 直观地展示了其中发生的事情。它相当于:对输入中的每一个值——也就是 RGB 图像中的每一个分量——都和所有其他输入值一起进行线性组合,以生成每一个输出特征。一方面,这意味着:图像中任意一个像素都可能与任意另一个像素的组合,对当前任务有意义。另一方面,这样做也意味着:我们完全没有利用相邻像素或远距离像素之间的相对位置关系,因为我们只是把图像当成了一长串数字向量。
图 7.15 将全连接模块用于输入图像。每个输入像素都会与其他所有像素组合,以生成输出中的每个元素。
一架飞行在天空中的飞机,如果被拍成一张 32 × 32 的图像,粗略看起来大概像是蓝色背景上的一个深色十字形。像图 7.15 那样的全连接网络,必须自己学会:当像素 [x=0, y=1] 很暗,同时像素 [x=1, y=1] 也很暗,等等,这就是飞机的一个良好指示。这一点在图 7.16 的上半部分中有所体现。然而,如果把同一架飞机在图像中平移一个或更多像素,就像图的下半部分那样,那么这些像素之间的关系就必须从头再学一遍。这一次,飞机很可能意味着:像素 [x=0, y=2] 很暗,像素 [x=1, y=2] 也很暗,等等。更技术化地说,全连接网络不具备平移不变性(translation invariance) 。一个训练时学会识别起始于位置 4,4 的飞机的网络,并不能自动识别同一架飞机出现在位置 8,8 时的情况。
图 7.16 全连接层在平移不变性上的缺失
这样一来,我们就不得不对数据集做增强——也就是在训练过程中,对图像施加随机平移——这样网络才有机会在图像的不同区域看到飞机,而我们必须对数据集中的每一张图像都这么做(顺便说一句,我们完全可以把一个来自 torchvision.transforms 的变换拼接进去,让这个过程自动完成)。不过,这种数据增强策略是有代价的:隐藏特征数,也就是参数数量,必须足够大,才能存储所有这些平移副本所对应的信息。
因此,在本章结尾时,我们已经拥有了一个数据集、一个模型和一个训练循环,而且模型确实在学习。可是,由于我们的网络结构与问题本身之间存在错配,结果并不是学到了我们希望模型真正检测到的泛化特征,而是对训练数据发生了过拟合。
我们构建的这个模型,允许图像中任意一个像素与任意另一个像素建立关系,而不考虑它们在空间上的排列方式。然而,我们其实有一个相当合理的假设:彼此距离更近的像素,从理论上讲应该更加相关。我们当前训练的是一个不具有平移不变性的分类器,因此如果我们希望它在验证集上表现良好,就不得不耗费大量容量去学习各种平移后的副本。总该有更好的办法吧?
当然,这类书里的大多数此类问题,本来就是反问句。解决当前这组问题的办法,就是把模型改成使用卷积层(convolutional layers) 。下一章我们就会讲它到底意味着什么。
7.3 小结
在本章中,我们完整走通了一个简单分类问题的整个流程:从数据集,到模型,再到在训练循环中最小化一个合适的损失函数。所有这些内容,都会成为你 PyTorch 工具箱中的标准工具,而掌握它们所需的技能,也会在你今后的 PyTorch 使用过程中持续派上用场。
我们也发现了当前模型的一个严重缺陷:我们把二维图像当成了一维数据来处理。同时,我们也没有一种自然的方式,把问题本身所具备的平移不变性编码进模型中。下一章,你将学会如何利用图像数据的二维特性,从而获得好得多的结果。
注意 关于平移不变性的这个问题,同样也适用于纯一维数据:例如,一个音频分类器,理论上应当在声音提前或延后 0.1 秒开始的情况下,仍然输出相同的分类结果。
当然,我们马上就可以把本章学到的东西,用在那些不要求平移不变性的数据上。例如,用它处理表格数据,或者第 4 章中遇到的时间序列数据,很可能已经能做出相当不错的东西了。在某种程度上,它也可以被用于那些经过适当表示后的文本数据。
注意 词袋模型(bag-of-words models)只是对词向量求平均,因此可以直接使用本章中的网络设计来处理。而更现代的模型会考虑词语的位置,因此需要更高级的模型结构。
7.4 练习
使用 torchvision 为数据实现随机裁剪:
- 得到的图像与未裁剪原图相比,有什么不同?
- 当你第二次请求同一张图像时,会发生什么?
- 使用随机裁剪后的图像进行训练,结果会怎样?
切换损失函数(比如改用 MSE):
- 训练行为会发生变化吗?
- 是否可以把网络容量缩小到足以停止过拟合?
- 在这样做的情况下,模型在验证集上的表现如何?
总结
- 计算机视觉是深度学习最广泛的应用领域之一。
- 公开可用的带标注图像数据集有很多;其中许多,包括
cifar10,都可以通过torchvision访问。 torchvision中包含transforms,也就是一些常见的图像变换,可用于在数据送入深度学习模型之前进行预处理。我们在本章中使用transforms将图像转换为张量,并对数据做了归一化。Dataset和DataLoader为加载和采样数据集提供了简单而有效的抽象。它们允许你调整 batch 大小、在每个 batch 之前打乱数据,以及执行其他实用操作。- 对于分类任务,输出是类别型的,我们可以使用 one-hot 编码来表示正确输出。对网络输出应用 softmax 函数,会得到一个各分量之和为 1 的向量,从而满足将其解释为概率的要求。
- 在分类问题中,我们可以把 softmax 的输出送入负对数似然损失,得到损失函数。在 PyTorch 中,这种 softmax 与负对数似然的组合被称为交叉熵损失。
- 没有什么能阻止我们把图像当成像素值向量来处理,并像处理其他数值数据一样,用全连接网络来建模它们。不过,这样做会让我们更难利用数据中的空间关系。
- 你可以用 PyTorch 的
nn.Sequential和nn.Linear来构建模型。在这种设置下,序列中的第一个线性层往往拥有大量参数,因为它直接面对输入尺寸。常见做法是:随着层逐渐接近输出端,逐步减少后续层中的参数数量。这种渐进式收缩有助于优化模型架构。