原文:
zh.annas-archive.org/md5/2a872f7dd98f6fbe3043a236f689e451
译者:飞龙
第十三章:深入探讨 – PyTorch 的机制
在第十二章中,使用 PyTorch 并行化神经网络训练,我们讨论了如何定义和操作张量,并使用torch.utils.data
模块构建输入管道。我们进一步构建并训练了一个多层感知器,使用 PyTorch 神经网络模块(torch.nn
)对鸢尾花数据集进行分类。
现在我们已经有了一些关于 PyTorch 神经网络训练和机器学习的实践经验,是时候深入探索 PyTorch 库,并探索其丰富的功能集,这将使我们能够在即将到来的章节中实现更高级的深度学习模型。
在本章中,我们将使用 PyTorch API 的不同方面来实现神经网络。特别是,我们将再次使用torch.nn
模块,它提供了多层抽象,使得实现标准架构非常方便。它还允许我们实现自定义神经网络层,这在需要更多定制的研究项目中非常有用。稍后在本章中,我们将实现这样一个自定义层。
为了说明使用torch.nn
模块构建模型的不同方法,我们还将考虑经典的异或(XOR)问题。首先,我们将使用Sequential
类构建多层感知器。然后,我们将考虑其他方法,例如使用nn.Module
子类化来定义自定义层。最后,我们将处理两个涵盖从原始输入到预测的机器学习步骤的真实项目。
我们将涵盖的主题如下:
-
理解并操作 PyTorch 计算图
-
使用 PyTorch 张量对象进行操作
-
解决经典的 XOR 问题并理解模型容量
-
使用 PyTorch 的
Sequential
类和nn.Module
类构建复杂的神经网络模型 -
使用自动微分和
torch.autograd
计算梯度
PyTorch 的关键特性
在前一章中,我们看到 PyTorch 为我们提供了一个可扩展的、跨平台的编程接口,用于实现和运行机器学习算法。在 2016 年的初始发布以及 2018 年的 1.0 版本发布之后,PyTorch 已经发展成为两个最受欢迎的深度学习框架之一。它使用动态计算图,相比静态计算图具有更大的灵活性优势。动态计算图易于调试:PyTorch 允许在图声明和图评估步骤之间交错执行代码。您可以逐行执行代码,同时完全访问所有变量。这是一个非常重要的功能,使得开发和训练神经网络非常方便。
虽然 PyTorch 是一个开源库,可以免费使用,但其开发是由 Facebook 提供资金和支持的。这涉及到一个大型的软件工程团队,他们不断扩展和改进这个库。由于 PyTorch 是一个开源库,它也得到了来自 Facebook 以外其他开发者的强大支持,他们积极贡献并提供用户反馈。这使得 PyTorch 库对学术研究人员和开发者都更加有用。由于这些因素的影响,PyTorch 拥有广泛的文档和教程,帮助新用户上手。
PyTorch 的另一个关键特性,也在前一章节中提到过的,是其能够与单个或多个图形处理单元(GPU)一起工作。这使得用户能够在大型数据集和大规模系统上高效训练深度学习模型。
最后但同样重要的是,PyTorch 支持移动部署,这也使它成为生产环境中非常合适的工具。
在下一节中,我们将看到在 PyTorch 中张量和函数如何通过计算图相互连接。
PyTorch 的计算图
PyTorch 根据有向无环图(DAG)执行其计算。在本节中,我们将看到如何为简单的算术计算定义这些图。然后,我们将看到动态图的范例,以及如何在 PyTorch 中动态创建图。
理解计算图
PyTorch 的核心是构建计算图,它依赖于这个计算图来推导从输入到输出的张量之间的关系。假设我们有秩为 0(标量)的张量 a、b 和 c,我们想要评估 z = 2 × (a – b) + c。
此评估可以表示为一个计算图,如图 13.1所示:
图 13.1:计算图的工作原理
正如您所见,计算图只是一个节点网络。每个节点类似于一个操作,它对其输入张量或张量应用函数,并根据需要返回零个或多个张量作为输出。PyTorch 构建这个计算图并使用它来相应地计算梯度。在下一小节中,我们将看到如何使用 PyTorch 为这种计算创建图的一些示例。
在 PyTorch 中创建图
让我们看一个简单的例子,说明如何在 PyTorch 中创建一个用于评估 z = 2 × (a – b) + c 的图,如前图所示。变量 a、b 和 c 是标量(单个数字),我们将它们定义为 PyTorch 张量。为了创建图,我们可以简单地定义一个常规的 Python 函数,其输入参数为 a
、b
和 c
,例如:
>>> import torch
>>> def compute_z(a, b, c):
... r1 = torch.sub(a, b)
... r2 = torch.mul(r1, 2)
... z = torch.add(r2, c)
... return z
现在,为了执行计算,我们可以简单地将此函数与张量对象作为函数参数调用。请注意,PyTorch 函数如add
、sub
(或subtract
)、mul
(或multiply
)也允许我们以 PyTorch 张量对象的形式提供更高秩的输入。在以下代码示例中,我们提供了标量输入(秩 0),以及秩 1 和秩 2 的输入,作为列表:
>>> print('Scalar Inputs:', compute_z(torch.tensor(1),
... torch.tensor(2), torch.tensor(3)))
Scalar Inputs: tensor(1)
>>> print('Rank 1 Inputs:', compute_z(torch.tensor([1]),
... torch.tensor([2]), torch.tensor([3])))
Rank 1 Inputs: tensor([1])
>>> print('Rank 2 Inputs:', compute_z(torch.tensor([[1]]),
... torch.tensor([[2]]), torch.tensor([[3]])))
Rank 2 Inputs: tensor([[1]])
在这一节中,你看到了在 PyTorch 中创建计算图是多么简单。接下来,我们将看看可以用来存储和更新模型参数的 PyTorch 张量。
PyTorch 张量对象用于存储和更新模型参数
我们在第十二章《使用 PyTorch 并行化神经网络训练》中介绍了张量对象。在 PyTorch 中,需要计算梯度的特殊张量对象允许我们在训练期间存储和更新模型的参数。这样的张量可以通过在用户指定的初始值上简单地将requires_grad
赋值为True
来创建。请注意,截至目前(2021 年中),只有浮点和复杂dtype
的张量可以需要梯度。在以下代码中,我们将生成float32
类型的张量对象:
>>> a = torch.tensor(3.14, requires_grad=True)
>>> print(a)
tensor(3.1400, requires_grad=True)
>>> b = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
>>> print(b)
tensor([1., 2., 3.], requires_grad=True)
注意,默认情况下requires_grad
被设置为False
。可以通过运行requires_grad_()
有效地将其设置为True
。
method_()
是 PyTorch 中的一个原地方法,用于执行操作而不复制输入。
让我们看一下以下例子:
>>> w = torch.tensor([1.0, 2.0, 3.0])
>>> print(w.requires_grad)
False
>>> w.requires_grad_()
>>> print(w.requires_grad)
True
你会记得,对于 NN 模型,使用随机权重初始化模型参数是必要的,以打破反向传播过程中的对称性——否则,一个多层 NN 将不会比单层 NN(如逻辑回归)更有用。在创建 PyTorch 张量时,我们也可以使用随机初始化方案。PyTorch 可以基于各种概率分布生成随机数(参见pytorch.org/docs/stable/torch.html#random-sampling
)。在以下例子中,我们将看一些标准的初始化方法,这些方法也可以在torch.nn.init
模块中找到(参见pytorch.org/docs/stable/nn.init.html
)。
因此,让我们看看如何使用 Glorot 初始化创建一个张量,这是一种经典的随机初始化方案,由 Xavier Glorot 和 Yoshua Bengio 提出。为此,我们首先创建一个空张量和一个名为init
的操作符,作为GlorotNormal
类的对象。然后,通过调用xavier_normal_()
方法按照 Glorot 初始化填充这个张量的值。在下面的例子中,我们初始化一个形状为 2×3 的张量:
>>> import torch.nn as nn
>>> torch.manual_seed(1)
>>> w = torch.empty(2, 3)
>>> nn.init.xavier_normal_(w)
>>> print(w)
tensor([[ 0.4183, 0.1688, 0.0390],
[ 0.3930, -0.2858, -0.1051]])
Xavier(或 Glorot)初始化
在深度学习的早期开发中观察到,随机均匀或随机正态的权重初始化通常会导致训练过程中模型表现不佳。
2010 年,Glorot 和 Bengio 调查了初始化的效果,并提出了一种新颖、更健壮的初始化方案,以促进深层网络的训练。Xavier 初始化背后的主要思想是大致平衡不同层次梯度的方差。否则,一些层可能在训练过程中受到过多关注,而其他层则滞后。
根据 Glorot 和 Bengio 的研究论文,如果我们想要在均匀分布中初始化权重,我们应该选择此均匀分布的区间如下:
在这里,n[in] 是与权重相乘的输入神经元的数量,n[out] 是输入到下一层的输出神经元的数量。对于从高斯(正态)分布初始化权重,我们建议您选择这个高斯分布的标准差为:
PyTorch 支持在权重的均匀分布和正态分布中进行Xavier 初始化。
有关 Glorot 和 Bengio 初始化方案的更多信息,包括其背景和数学动机,我们建议查阅原始论文(理解深层前馈神经网络的难度,Xavier Glorot 和 Yoshua Bengio,2010),可以在 proceedings.mlr.press/v9/glorot10a/glorot10a.pdf
免费获取。
现在,为了将其放入更实际的用例背景中,让我们看看如何在基础 nn.Module
类内定义两个 Tensor
对象:
>>> class MyModule(nn.Module):
... def __init__(self):
... super().__init__()
... self.w1 = torch.empty(2, 3, requires_grad=True)
... nn.init.xavier_normal_(self.w1)
... self.w2 = torch.empty(1, 2, requires_grad=True)
... nn.init.xavier_normal_(self.w2)
然后可以将这两个张量用作权重,其梯度将通过自动微分计算。
通过自动微分计算梯度
正如您已经知道的那样,优化神经网络需要计算损失相对于神经网络权重的梯度。这对于优化算法如随机梯度下降(SGD)是必需的。此外,梯度还有其他应用,比如诊断网络以找出为什么神经网络模型对测试示例做出特定预测。因此,在本节中,我们将涵盖如何计算计算的梯度对其输入变量的梯度。
计算损失相对于可训练变量的梯度
PyTorch 支持自动微分,可以将其视为计算嵌套函数梯度的链式规则的实现。请注意,出于简化的目的,我们将使用术语梯度来指代偏导数和梯度。
偏导数和梯度
部分导数 可以理解为多变量函数(具有多个输入 f(x[1], x[2], …)相对于其输入之一(此处为 x[1])的变化率。函数的梯度,
,是由所有输入的偏导数
组成的向量。
当我们定义一系列操作以产生某些输出甚至是中间张量时,PyTorch 提供了一个计算梯度的上下文,用于计算这些计算张量相对于其在计算图中依赖节点的梯度。要计算这些梯度,我们可以从torch.autograd
模块调用backward
方法。它计算给定张量相对于图中叶节点(终端节点)的梯度之和。
让我们来看一个简单的例子,我们将计算 z = wx + b 并定义损失为目标 y 和预测 z 之间的平方损失,Loss = (y - z)²。在更一般的情况下,我们可能有多个预测和目标,我们将损失定义为平方误差的总和,。为了在 PyTorch 中实现这个计算,我们将定义模型参数 w 和 b 为变量(具有
requires_gradient
属性设置为True
的张量),输入 x 和 y 为默认张量。我们将计算损失张量并用它来计算模型参数 w 和 b 的梯度,如下所示:
>>> w = torch.tensor(1.0, requires_grad=True)
>>> b = torch.tensor(0.5, requires_grad=True)
>>> x = torch.tensor([1.4])
>>> y = torch.tensor([2.1])
>>> z = torch.add(torch.mul(w, x), b)
>>> loss = (y-z).pow(2).sum()
>>> loss.backward()
>>> print('dL/dw : ', w.grad)
>>> print('dL/db : ', b.grad)
dL/dw : tensor(-0.5600)
dL/db : tensor(-0.4000)
计算值z是 NN 中的前向传递。我们在loss
张量上使用backward
方法来计算 和
。由于这是一个非常简单的例子,我们可以通过符号方式获得
来验证计算得到的梯度与我们在先前的代码示例中得到的结果是否匹配:
>>> # verifying the computed gradient
>>> print(2 * x * ((w * x + b) - y))
tensor([-0.5600], grad_fn=<MulBackward0>)
我们留下对b的验证作为读者的练习。
理解自动微分
自动微分表示一组用于计算任意算术操作梯度的计算技术。在这个过程中,通过重复应用链式法则来积累计算(表示为一系列操作)的梯度。为了更好地理解自动微分背后的概念,让我们考虑一系列嵌套计算,y = f(g(h(x))),其中 x 是输入,y 是输出。这可以分解为一系列步骤:
-
u[0] = x
-
u[1] = h(x)
-
u[2] = g(u[1])
-
u[3] = f(u[2]) = y
导数 可以通过两种不同的方式计算:前向累积,从
开始,以及反向累积,从
开始。请注意,PyTorch 使用后者,即反向累积,这对于实现反向传播更有效率。
对抗样本
计算损失相对于输入示例的梯度用于生成对抗样本(或对抗攻击)。在计算机视觉中,对抗样本是通过向输入示例添加一些微小且难以察觉的噪声(或扰动)生成的示例,导致深度神经网络误分类它们。涵盖对抗样本超出了本书的范围,但如果您感兴趣,可以在 arxiv.org/pdf/1312.6199.pdf
找到Christian Szegedy et al.的原始论文神经网络的有趣属性。
通过 torch.nn 模块简化常见架构的实现
您已经看到了构建前馈 NN 模型(例如,多层感知器)和使用 nn.Module
类定义层序列的一些示例。在我们深入研究 nn.Module
之前,让我们简要了解另一种通过 nn.Sequential
配置这些层的方法。
基于 nn.Sequential
实现模型
使用 nn.Sequential
(pytorch.org/docs/master/generated/torch.nn.Sequential.html#sequential
),模型内部存储的层以级联方式连接。在下面的示例中,我们将构建一个具有两个全连接层的模型:
>>> model = nn.Sequential(
... nn.Linear(4, 16),
... nn.ReLU(),
... nn.Linear(16, 32),
... nn.ReLU()
... )
>>> model
Sequential(
(0): Linear(in_features=4, out_features=16, bias=True)
(1): ReLU()
(2): Linear(in_features=16, out_features=32, bias=True)
(3): ReLU()
)
我们指定了层并在将这些层传递给 nn.Sequential
类后实例化了 model
。第一个全连接层的输出作为第一个 ReLU 层的输入。第一个 ReLU 层的输出成为第二个全连接层的输入。最后,第二个全连接层的输出作为第二个 ReLU 层的输入。
我们可以通过应用不同的激活函数、初始化器或正则化方法来进一步配置这些层的参数。大多数这些类别的所有可用选项的详细和完整列表可以在官方文档中找到:
-
选择激活函数:
pytorch.org/docs/stable/nn.html#non-linear-activations-weighted-sum-nonlinearity
-
通过
nn.init
初始化层参数:pytorch.org/docs/stable/nn.init.html
-
通过在
torch.optim
中某些优化器的weight_decay
参数应用 L2 正则化到层参数(以防止过拟合):pytorch.org/docs/stable/optim.html
-
通过将 L1 正则化应用于层参数(以防止过拟合),通过将 L1 惩罚项添加到损失张量中实现下一步
在以下代码示例中,我们将通过指定权重的初始值分布来配置第一个全连接层。然后,我们将通过计算权重矩阵的 L1 惩罚项来配置第二个全连接层:
>>> nn.init.xavier_uniform_(model[0].weight)
>>> l1_weight = 0.01
>>> l1_penalty = l1_weight * model[2].weight.abs().sum()
在这里,我们使用 Xavier 初始化来初始化第一个线性层的权重。然后,我们计算了第二个线性层权重的 L1 范数。
此外,我们还可以指定训练的优化器类型和损失函数。再次,您可以在官方文档中找到所有可用选项的全面列表。
-
通过
torch.optim
优化器:pytorch.org/docs/stable/optim.html#algorithms
选择损失函数
关于优化算法的选择,SGD 和 Adam 是最常用的方法。损失函数的选择取决于任务;例如,对于回归问题,您可能会使用均方误差损失。
交叉熵损失函数系列提供了分类任务的可能选择,在第十四章,使用深度卷积神经网络分类图像中广泛讨论。
此外,您可以结合适用于问题的适当指标,利用您从先前章节学到的技术(如第六章中用于模型评估和超参数调优的技术)。例如,精度和召回率、准确率、曲线下面积(AUC)、假阴性和假阳性分数是评估分类模型的适当指标。
在本例中,我们将使用 SGD 优化器和交叉熵损失进行二元分类:
>>> loss_fn = nn.BCELoss()
>>> optimizer = torch.optim.SGD(model.parameters(), lr=0.001)
接下来,我们将看一个更实际的例子:解决经典的 XOR 分类问题。首先,我们将使用nn.Sequential()
类来构建模型。在此过程中,您还将了解模型处理非线性决策边界的能力。然后,我们将讨论通过nn.Module
构建模型,这将为我们提供更多灵活性和对网络层的控制。
解决 XOR 分类问题
XOR 分类问题是分析模型捕捉两类之间非线性决策边界能力的经典问题。我们生成了一个包含 200 个训练样本的玩具数据集,具有两个特征(x[0],x[1]),这些特征从均匀分布-1, 1)中抽取。然后,根据以下规则为训练样本i分配了地面真实标签:
![
我们将使用一半的数据(100 个训练样本)进行训练,剩余一半用于验证。生成数据并将其拆分为训练和验证数据集的代码如下:
>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> torch.manual_seed(1)
>>> np.random.seed(1)
>>> x = np.random.uniform(low=-1, high=1, size=(200, 2))
>>> y = np.ones(len(x))
>>> y[x[:, 0] * x[:, 1]<0] = 0
>>> n_train = 100
>>> x_train = torch.tensor(x[:n_train, :], dtype=torch.float32)
>>> y_train = torch.tensor(y[:n_train], dtype=torch.float32)
>>> x_valid = torch.tensor(x[n_train:, :], dtype=torch.float32)
>>> y_valid = torch.tensor(y[n_train:], dtype=torch.float32)
>>> fig = plt.figure(figsize=(6, 6))
>>> plt.plot(x[y==0, 0], x[y==0, 1], 'o', alpha=0.75, markersize=10)
>>> plt.plot(x[y==1, 0], x[y==1, 1], '<', alpha=0.75, markersize=10)
>>> plt.xlabel(r'$x_1$', size=15)
>>> plt.ylabel(r'$x_2$', size=15)
>>> plt.show()
该代码生成了训练和验证样本的散点图,根据它们的类别标签使用不同的标记:
图 13.2:训练和验证样本的散点图
在前面的小节中,我们介绍了在 PyTorch 中实现分类器所需的基本工具。现在我们需要决定为这个任务和数据集选择什么样的架构。作为一个经验法则,我们拥有的层次越多,每层的神经元越多,模型的容量就越大。在这里,模型容量可以被看作是模型能够逼近复杂函数的能力的一个度量。虽然更多参数意味着网络可以拟合更复杂的函数,但更大的模型通常更难训练(且容易过拟合)。实际操作中,从一个简单模型开始作为基准总是一个好主意,例如单层神经网络如逻辑回归:
>>> model = nn.Sequential(
... nn.Linear(2, 1),
... nn.Sigmoid()
... )
>>> model
Sequential(
(0): Linear(in_features=2, out_features=1, bias=True)
(1): Sigmoid()
)
定义模型后,我们将初始化用于二元分类的交叉熵损失函数和 SGD 优化器:
>>> loss_fn = nn.BCELoss()
>>> optimizer = torch.optim.SGD(model.parameters(), lr=0.001)
接下来,我们将创建一个数据加载器,使用批大小为 2 来处理训练数据:
>>> from torch.utils.data import DataLoader, TensorDataset
>>> train_ds = TensorDataset(x_train, y_train)
>>> batch_size = 2
>>> torch.manual_seed(1)
>>> train_dl = DataLoader(train_ds, batch_size, shuffle=True)
现在我们将训练模型 200 个 epoch,并记录训练过程的历史:
>>> torch.manual_seed(1)
>>> num_epochs = 200
>>> def train(model, num_epochs, train_dl, x_valid, y_valid):
... loss_hist_train = [0] * num_epochs
... accuracy_hist_train = [0] * num_epochs
... loss_hist_valid = [0] * num_epochs
... accuracy_hist_valid = [0] * num_epochs
... for epoch in range(num_epochs):
... for x_batch, y_batch in train_dl:
... pred = model(x_batch)[:, 0]
... loss = loss_fn(pred, y_batch)
... loss.backward()
... optimizer.step()
... optimizer.zero_grad()
... loss_hist_train[epoch] += loss.item()
... is_correct = ((pred>=0.5).float() == y_batch).float()
... accuracy_hist_train[epoch] += is_correct.mean()
... loss_hist_train[epoch] /= n_train
... accuracy_hist_train[epoch] /= n_train/batch_size
... pred = model(x_valid)[:, 0]
... loss = loss_fn(pred, y_valid)
... loss_hist_valid[epoch] = loss.item()
... is_correct = ((pred>=0.5).float() == y_valid).float()
... accuracy_hist_valid[epoch] += is_correct.mean()
... return loss_hist_train, loss_hist_valid, \
... accuracy_hist_train, accuracy_hist_valid
>>> history = train(model, num_epochs, train_dl, x_valid, y_valid)
注意,训练 epoch 的历史包括训练损失和验证损失,以及训练准确率和验证准确率,这对于训练后的可视化检查非常有用。在下面的代码中,我们将绘制学习曲线,包括训练和验证损失,以及它们的准确率。
以下代码将绘制训练性能:
>>> fig = plt.figure(figsize=(16, 4))
>>> ax = fig.add_subplot(1, 2, 1)
>>> plt.plot(history[0], lw=4)
>>> plt.plot(history[1], lw=4)
>>> plt.legend(['Train loss', 'Validation loss'], fontsize=15)
>>> ax.set_xlabel('Epochs', size=15)
>>> ax = fig.add_subplot(1, 2, 2)
>>> plt.plot(history[2], lw=4)
>>> plt.plot(history[3], lw=4)
>>> plt.legend(['Train acc.', 'Validation acc.'], fontsize=15)
>>> ax.set_xlabel('Epochs', size=15)
这导致了下图,分别显示了损失和准确率的两个单独面板:
图 13.3:损失和准确率结果
正如您所见,一个没有隐藏层的简单模型只能得出线性决策边界,无法解决 XOR 问题。因此,我们可以观察到,训练集和验证集的损失项都非常高,分类准确率非常低。
为了得到非线性决策边界,我们可以添加一个或多个通过非线性激活函数连接的隐藏层。普遍逼近定理表明,具有单个隐藏层和相对较大隐藏单元数的前馈神经网络可以相对良好地逼近任意连续函数。因此,更有效地解决 XOR 问题的一种方法是添加一个隐藏层,并比较不同数量的隐藏单元,直到在验证数据集上观察到满意的结果。增加更多的隐藏单元相当于增加层的宽度。
或者,我们也可以添加更多隐藏层,这会使模型变得更深。将网络变得更深而不是更宽的优势在于,需要的参数更少,即使实现相似的模型容量。
然而,与宽模型相比,深模型的一个缺点是,深模型容易出现梯度消失和梯度爆炸,这使得它们更难训练。
作为练习,请尝试添加一层、两层、三层和四层,每层都有四个隐藏单元。在下面的例子中,我们将查看具有两个隐藏层的前馈神经网络的结果:
>>> model = nn.Sequential(
... nn.Linear(2, 4),
... nn.ReLU(),
... nn.Linear(4, 4),
... nn.ReLU(),
... nn.Linear(4, 1),
... nn.Sigmoid()
... )
>>> loss_fn = nn.BCELoss()
>>> optimizer = torch.optim.SGD(model.parameters(), lr=0.015)
>>> model
Sequential(
(0): Linear(in_features=2, out_features=4, bias=True)
(1): ReLU()
(2): Linear(in_features=4, out_features=4, bias=True)
(3): ReLU()
(4): Linear(in_features=4, out_features=1, bias=True)
(5): Sigmoid()
)
>>> history = train(model, num_epochs, train_dl, x_valid, y_valid)
我们可以重复之前的代码进行可视化,产生以下结果:
图 13.4:添加两个隐藏层后的损失和准确率结果
现在,我们可以看到该模型能够为这些数据推导出非线性决策边界,并且该模型在训练数据集上达到 100%的准确率。验证数据集的准确率为 95%,这表明模型略微过拟合。
使用 nn.Module 使模型构建更加灵活
在前面的例子中,我们使用了 PyTorch 的Sequential
类来创建一个具有多层的全连接神经网络。这是一种非常常见和方便的建模方式。然而,不幸的是,它不允许我们创建具有多个输入、输出或中间分支的复杂模型。这就是nn.Module
派上用场的地方。
构建复杂模型的另一种方法是通过子类化nn.Module
来实现。在这种方法中,我们创建一个派生自nn.Module
的新类,并将__init__()
方法定义为构造函数。forward()
方法用于指定前向传播。在构造函数__init__()
中,我们将层定义为类的属性,以便可以通过self
引用属性访问这些层。然后,在forward()
方法中,我们指定这些层在神经网络的前向传播中如何使用。定义实现前述模型的新类的代码如下:
>>> class MyModule(nn.Module):
... def __init__(self):
... super().__init__()
... l1 = nn.Linear(2, 4)
... a1 = nn.ReLU()
... l2 = nn.Linear(4, 4)
... a2 = nn.ReLU()
... l3 = nn.Linear(4, 1)
... a3 = nn.Sigmoid()
... l = [l1, a1, l2, a2, l3, a3]
... self.module_list = nn.ModuleList(l)
...
... def forward(self, x):
... for f in self.module_list:
... x = f(x)
... return x
注意,我们将所有层放在nn.ModuleList
对象中,这只是由nn.Module
项组成的list
对象。这样做可以使代码更易读,更易于理解。
一旦我们定义了这个新类的实例,我们就可以像之前一样对其进行训练:
>>> model = MyModule()
>>> model
MyModule(
(module_list): ModuleList(
(0): Linear(in_features=2, out_features=4, bias=True)
(1): ReLU()
(2): Linear(in_features=4, out_features=4, bias=True)
(3): ReLU()
(4): Linear(in_features=4, out_features=1, bias=True)
(5): Sigmoid()
)
)
>>> loss_fn = nn.BCELoss()
>>> optimizer = torch.optim.SGD(model.parameters(), lr=0.015)
>>> history = train(model, num_epochs, train_dl, x_valid, y_valid)
接下来,除了训练历史记录外,我们将使用 mlxtend 库来可视化验证数据和决策边界。
可以通过以下方式通过conda
或pip
安装 mlxtend:
conda install mlxtend -c conda-forge
pip install mlxtend
要计算我们模型的决策边界,我们需要在MyModule
类中添加一个predict()
方法:
>>> def predict(self, x):
... x = torch.tensor(x, dtype=torch.float32)
... pred = self.forward(x)[:, 0]
... return (pred>=0.5).float()
它将为样本返回预测类(0 或 1)。
以下代码将绘制训练性能以及决策区域偏差:
>>> from mlxtend.plotting import plot_decision_regions
>>> fig = plt.figure(figsize=(16, 4))
>>> ax = fig.add_subplot(1, 3, 1)
>>> plt.plot(history[0], lw=4)
>>> plt.plot(history[1], lw=4)
>>> plt.legend(['Train loss', 'Validation loss'], fontsize=15)
>>> ax.set_xlabel('Epochs', size=15)
>>> ax = fig.add_subplot(1, 3, 2)
>>> plt.plot(history[2], lw=4)
>>> plt.plot(history[3], lw=4)
>>> plt.legend(['Train acc.', 'Validation acc.'], fontsize=15)
>>> ax.set_xlabel('Epochs', size=15)
>>> ax = fig.add_subplot(1, 3, 3)
>>> plot_decision_regions(X=x_valid.numpy(),
... y=y_valid.numpy().astype(np.integer),
... clf=model)
>>> ax.set_xlabel(r'$x_1$', size=15)
>>> ax.xaxis.set_label_coords(1, -0.025)
>>> ax.set_ylabel(r'$x_2$', size=15)
>>> ax.yaxis.set_label_coords(-0.025, 1)
>>> plt.show()
这导致图 13.5,其中包括损失、准确率的三个独立面板以及验证示例的散点图,以及决策边界:
图 13.5:包括散点图在内的结果
在 PyTorch 中编写自定义层
在我们想定义一个 PyTorch 尚未支持的新层的情况下,我们可以定义一个新类,该类派生自nn.Module
类。当设计新层或自定义现有层时,这尤其有用。
为了说明实现自定义层的概念,让我们考虑一个简单的例子。假设我们想定义一个新的线性层,计算,其中
表示作为噪声变量的随机变量。为了实现这个计算,我们定义一个新的类作为
nn.Module
的子类。对于这个新类,我们必须定义构造函数__init__()
方法和forward()
方法。在构造函数中,我们为自定义层定义变量和其他所需的张量。如果构造函数提供了input_size
,我们可以在构造函数中创建变量并初始化它们。或者,我们可以延迟变量初始化(例如,如果我们事先不知道确切的输入形状),并将其委托给另一个方法进行延迟变量创建。
为了看一个具体的例子,我们将定义一个名为NoisyLinear
的新层,实现了在前述段落中提到的计算:
>>> class NoisyLinear(nn.Module):
... def __init__(self, input_size, output_size,
... noise_stddev=0.1):
... super().__init__()
... w = torch.Tensor(input_size, output_size)
... self.w = nn.Parameter(w) # nn.Parameter is a Tensor
... # that's a module parameter.
... nn.init.xavier_uniform_(self.w)
... b = torch.Tensor(output_size).fill_(0)
... self.b = nn.Parameter(b)
... self.noise_stddev = noise_stddev
...
... def forward(self, x, training=False):
... if training:
... noise = torch.normal(0.0, self.noise_stddev, x.shape)
... x_new = torch.add(x, noise)
... else:
... x_new = x
... return torch.add(torch.mm(x_new, self.w), self.b)
, was to be generated and added to the input during training only and not used for inference or evaluation.
在我们进一步使用我们自定义的NoisyLinear
层将其应用于模型之前,让我们在一个简单示例的背景下测试它。
-
在下面的代码中,我们将定义此层的新实例,并在输入张量上执行它。然后,我们将在相同的输入张量上三次调用该层:
>>> torch.manual_seed(1) >>> noisy_layer = NoisyLinear(4, 2) >>> x = torch.zeros((1, 4)) >>> print(noisy_layer(x, training=True)) tensor([[ 0.1154, -0.0598]], grad_fn=<AddBackward0>) >>> print(noisy_layer(x, training=True)) tensor([[ 0.0432, -0.0375]], grad_fn=<AddBackward0>) >>> print(noisy_layer(x, training=False)) tensor([[0., 0.]], grad_fn=<AddBackward0>)
请注意,前两次调用的输出不同,因为
NoisyLinear
层向输入张量添加了随机噪声。第三次调用输出[0, 0],因为我们通过指定training=False
未添加噪声。 -
现在,让我们创建一个类似于以前用于解决 XOR 分类任务的新模型。与之前一样,我们将使用
nn.Module
类构建模型,但这次我们将把我们的NoisyLinear
层作为多层感知机的第一个隐藏层。代码如下:>>> class MyNoisyModule(nn.Module): ... def __init__(self): ... super().__init__() ... self.l1 = NoisyLinear(2, 4, 0.07) ... self.a1 = nn.ReLU() ... self.l2 = nn.Linear(4, 4) ... self.a2 = nn.ReLU() ... self.l3 = nn.Linear(4, 1) ... self.a3 = nn.Sigmoid() ... ... def forward(self, x, training=False): ... x = self.l1(x, training) ... x = self.a1(x) ... x = self.l2(x) ... x = self.a2(x) ... x = self.l3(x) ... x = self.a3(x) ... return x ... ... def predict(self, x): ... x = torch.tensor(x, dtype=torch.float32) ... pred = self.forward(x)[:, 0] ... return (pred>=0.5).float() ... >>> torch.manual_seed(1) >>> model = MyNoisyModule() >>> model MyNoisyModule( (l1): NoisyLinear() (a1): ReLU() (l2): Linear(in_features=4, out_features=4, bias=True) (a2): ReLU() (l3): Linear(in_features=4, out_features=1, bias=True) (a3): Sigmoid() )
-
类似地,我们将像以前一样训练模型。此时,为了在训练批次上计算预测值,我们使用
pred = model(x_batch, True)[:, 0]
而不是pred = model(x_batch)[:, 0]
:>>> loss_fn = nn.BCELoss() >>> optimizer = torch.optim.SGD(model.parameters(), lr=0.015) >>> torch.manual_seed(1) >>> loss_hist_train = [0] * num_epochs >>> accuracy_hist_train = [0] * num_epochs >>> loss_hist_valid = [0] * num_epochs >>> accuracy_hist_valid = [0] * num_epochs >>> for epoch in range(num_epochs): ... for x_batch, y_batch in train_dl: ... pred = model(x_batch, True)[:, 0] ... loss = loss_fn(pred, y_batch) ... loss.backward() ... optimizer.step() ... optimizer.zero_grad() ... loss_hist_train[epoch] += loss.item() ... is_correct = ( ... (pred>=0.5).float() == y_batch ... ).float() ... accuracy_hist_train[epoch] += is_correct.mean() ... loss_hist_train[epoch] /= 100/batch_size ... accuracy_hist_train[epoch] /= 100/batch_size ... pred = model(x_valid)[:, 0] ... loss = loss_fn(pred, y_valid) ... loss_hist_valid[epoch] = loss.item() ... is_correct = ((pred>=0.5).float() == y_valid).float() ... accuracy_hist_valid[epoch] += is_correct.mean()
-
训练模型后,我们可以绘制损失、准确率和决策边界:
>>> fig = plt.figure(figsize=(16, 4)) >>> ax = fig.add_subplot(1, 3, 1) >>> plt.plot(loss_hist_train, lw=4) >>> plt.plot(loss_hist_valid, lw=4) >>> plt.legend(['Train loss', 'Validation loss'], fontsize=15) >>> ax.set_xlabel('Epochs', size=15) >>> ax = fig.add_subplot(1, 3, 2) >>> plt.plot(accuracy_hist_train, lw=4) >>> plt.plot(accuracy_hist_valid, lw=4) >>> plt.legend(['Train acc.', 'Validation acc.'], fontsize=15) >>> ax.set_xlabel('Epochs', size=15) >>> ax = fig.add_subplot(1, 3, 3) >>> plot_decision_regions( ... X=x_valid.numpy(), ... y=y_valid.numpy().astype(np.integer), ... clf=model ... ) >>> ax.set_xlabel(r'$x_1$', size=15) >>> ax.xaxis.set_label_coords(1, -0.025) >>> ax.set_ylabel(r'$x_2$', size=15) >>> ax.yaxis.set_label_coords(-0.025, 1) >>> plt.show()
-
结果图如下:
图 13.6:使用 NoisyLinear 作为第一个隐藏层的结果
在这里,我们的目标是学习如何定义一个新的自定义层,这个层是从nn.Module
子类化而来,并且如同使用任何其他标准torch.nn
层一样使用它。虽然在这个特定的例子中,NoisyLinear
并没有帮助提高性能,请记住我们的主要目标是学习如何从头开始编写一个定制层。一般来说,编写一个新的自定义层在其他应用中可能会很有用,例如,如果您开发了一个依赖于现有层之外的新层的新算法。
项目一 – 预测汽车的燃油效率
到目前为止,在本章中,我们主要集中在torch.nn
模块上。我们使用nn.Sequential
来简化模型的构建。然后,我们使用nn.Module
使模型构建更加灵活,并实现了前馈神经网络,其中我们添加了定制层。在本节中,我们将致力于一个真实世界的项目,即预测汽车的每加仑英里数(MPG)燃油效率。我们将涵盖机器学习任务的基本步骤,如数据预处理、特征工程、训练、预测(推理)和评估。
处理特征列
在机器学习和深度学习应用中,我们可能会遇到各种不同类型的特征:连续、无序分类(名义)和有序分类(序数)。您会回忆起,在第四章,构建良好的训练数据集 – 数据预处理中,我们涵盖了不同类型的特征,并学习了如何处理每种类型。请注意,虽然数值数据可以是连续的或离散的,但在使用 PyTorch 进行机器学习的上下文中,“数值”数据特指浮点类型的连续数据。
有时,特征集合由不同类型的特征混合组成。例如,考虑一个具有七种不同特征的情景,如图 13.7所示:
图 13.7:汽车 MPG 数据结构
图中显示的特征(车型年份、汽缸数、排量、马力、重量、加速度和起源)来自汽车 MPG 数据集,这是一个常见的用于预测汽车燃油效率的机器学习基准数据集。完整数据集及其描述可从 UCI 的机器学习存储库获取:archive.ics.uci.edu/ml/datasets/auto+mpg
。
我们将从汽车 MPG 数据集中处理五个特征(汽缸数、排量、马力、重量和加速度),将它们视为“数值”(这里是连续)特征。车型年份可以被视为有序分类(序数)特征。最后,制造地可以被视为无序分类(名义)特征,具有三个可能的离散值,1、2 和 3,分别对应于美国、欧洲和日本。
让我们首先加载数据并应用必要的预处理步骤,包括删除不完整的行,将数据集分为训练和测试数据集,以及标准化连续特征:
>>> import pandas as pd
>>> url = 'http://archive.ics.uci.edu/ml/' \
... 'machine-learning-databases/auto-mpg/auto-mpg.data'
>>> column_names = ['MPG', 'Cylinders', 'Displacement', 'Horsepower',
... 'Weight', 'Acceleration', 'Model Year', 'Origin']
>>> df = pd.read_csv(url, names=column_names,
... na_values = "?", comment='\t',
... sep=" ", skipinitialspace=True)
>>>
>>> ## drop the NA rows
>>> df = df.dropna()
>>> df = df.reset_index(drop=True)
>>>
>>> ## train/test splits:
>>> import sklearn
>>> import sklearn.model_selection
>>> df_train, df_test = sklearn.model_selection.train_test_split(
... df, train_size=0.8, random_state=1
... )
>>> train_stats = df_train.describe().transpose()
>>>
>>> numeric_column_names = [
... 'Cylinders', 'Displacement',
... 'Horsepower', 'Weight',
... 'Acceleration'
... ]
>>> df_train_norm, df_test_norm = df_train.copy(), df_test.copy()
>>> for col_name in numeric_column_names:
... mean = train_stats.loc[col_name, 'mean']
... std = train_stats.loc[col_name, 'std']
... df_train_norm.loc[:, col_name] = \
... (df_train_norm.loc[:, col_name] - mean)/std
... df_test_norm.loc[:, col_name] = \
... (df_test_norm.loc[:, col_name] - mean)/std
>>> df_train_norm.tail()
这导致了以下结果:
图 13.8:经过预处理的 Auto MG 数据
float. These columns will constitute the continuous features.
接下来,让我们将相当精细的模型年份(ModelYear
)信息分组到桶中,以简化我们稍后将要训练的模型的学习任务。具体来说,我们将每辆车分配到四个年份桶中,如下所示:
注意,所选的间隔是任意选择的,用于说明“分桶”的概念。为了将车辆分组到这些桶中,我们首先定义三个截断值:[73, 76, 79],用于模型年份特征。这些截断值用于指定半开区间,例如,(-∞, 73),[73, 76),[76, 79),和[76, ∞)。然后,原始数值特征将传递给torch.bucketize
函数(pytorch.org/docs/stable/generated/torch.bucketize.html
)来生成桶的索引。代码如下:
>>> boundaries = torch.tensor([73, 76, 79])
>>> v = torch.tensor(df_train_norm['Model Year'].values)
>>> df_train_norm['Model Year Bucketed'] = torch.bucketize(
... v, boundaries, right=True
... )
>>> v = torch.tensor(df_test_norm['Model Year'].values)
>>> df_test_norm['Model Year Bucketed'] = torch.bucketize(
... v, boundaries, right=True
... )
>>> numeric_column_names.append('Model Year Bucketed')
我们将此分桶特征列添加到 Python 列表numeric_column_names
中。
接下来,我们将继续定义一个无序分类特征Origin
的列表。在 PyTorch 中,处理分类特征有两种方法:使用通过nn.Embedding
(pytorch.org/docs/stable/generated/torch.nn.Embedding.html
)实现的嵌入层,或者使用独热编码向量(也称为指示器)。在编码方法中,例如,索引 0 将被编码为[1, 0, 0],索引 1 将被编码为[0, 1, 0],依此类推。另一方面,嵌入层将每个索引映射到一组随机数的向量,类型为float
,可以进行训练。(您可以将嵌入层视为与可训练权重矩阵相乘的一种更有效的独热编码实现。)
当类别数量较多时,使用比类别数量少的嵌入层维度可以提高性能。
在下面的代码片段中,我们将使用独热编码方法处理分类特征,以便将其转换为密集格式:
>>> from torch.nn.functional import one_hot
>>> total_origin = len(set(df_train_norm['Origin']))
>>> origin_encoded = one_hot(torch.from_numpy(
... df_train_norm['Origin'].values) % total_origin)
>>> x_train_numeric = torch.tensor(
... df_train_norm[numeric_column_names].values)
>>> x_train = torch.cat([x_train_numeric, origin_encoded], 1).float()
>>> origin_encoded = one_hot(torch.from_numpy(
... df_test_norm['Origin'].values) % total_origin)
>>> x_test_numeric = torch.tensor(
... df_test_norm[numeric_column_names].values)
>>> x_test = torch.cat([x_test_numeric, origin_encoded], 1).float()
将分类特征编码为三维密集特征后,我们将其与前一步骤中处理的数值特征串联起来。最后,我们将从地面实际 MPG 值创建标签张量如下:
>>> y_train = torch.tensor(df_train_norm['MPG'].values).float()
>>> y_test = torch.tensor(df_test_norm['MPG'].values).float()
在本节中,我们介绍了 PyTorch 中预处理和创建特征的最常见方法。
训练 DNN 回归模型
现在,在构建必需的特征和标签之后,我们将创建一个数据加载器,用于训练数据的批量大小为 8:
>>> train_ds = TensorDataset(x_train, y_train)
>>> batch_size = 8
>>> torch.manual_seed(1)
>>> train_dl = DataLoader(train_ds, batch_size, shuffle=True)
接下来,我们将建立一个具有两个全连接层的模型,其中一个具有 8 个隐藏单元,另一个具有 4 个:
>>> hidden_units = [8, 4]
>>> input_size = x_train.shape[1]
>>> all_layers = []
>>> for hidden_unit in hidden_units:
... layer = nn.Linear(input_size, hidden_unit)
... all_layers.append(layer)
... all_layers.append(nn.ReLU())
... input_size = hidden_unit
>>> all_layers.append(nn.Linear(hidden_units[-1], 1))
>>> model = nn.Sequential(*all_layers)
>>> model
Sequential(
(0): Linear(in_features=9, out_features=8, bias=True)
(1): ReLU()
(2): Linear(in_features=8, out_features=4, bias=True)
(3): ReLU()
(4): Linear(in_features=4, out_features=1, bias=True)
)
在定义模型后,我们将为回归定义 MSE 损失函数,并使用随机梯度下降进行优化:
>>> loss_fn = nn.MSELoss()
>>> optimizer = torch.optim.SGD(model.parameters(), lr=0.001)
现在我们将训练模型 200 个 epoch,并在每 20 个 epoch 显示训练损失:
>>> torch.manual_seed(1)
>>> num_epochs = 200
>>> log_epochs = 20
>>> for epoch in range(num_epochs):
... loss_hist_train = 0
... for x_batch, y_batch in train_dl:
... pred = model(x_batch)[:, 0]
... loss = loss_fn(pred, y_batch)
... loss.backward()
... optimizer.step()
... optimizer.zero_grad()
... loss_hist_train += loss.item()
... if epoch % log_epochs==0:
... print(f'Epoch {epoch} Loss '
... f'{loss_hist_train/len(train_dl):.4f}')
Epoch 0 Loss 536.1047
Epoch 20 Loss 8.4361
Epoch 40 Loss 7.8695
Epoch 60 Loss 7.1891
Epoch 80 Loss 6.7062
Epoch 100 Loss 6.7599
Epoch 120 Loss 6.3124
Epoch 140 Loss 6.6864
Epoch 160 Loss 6.7648
Epoch 180 Loss 6.2156
经过 200 个 epoch 后,训练损失约为 5。现在我们可以在测试数据集上评估训练模型的回归性能。为了预测新数据点上的目标值,我们可以将它们的特征提供给模型:
>>> with torch.no_grad():
... pred = model(x_test.float())[:, 0]
... loss = loss_fn(pred, y_test)
... print(f'Test MSE: {loss.item():.4f}')
... print(f'Test MAE: {nn.L1Loss()(pred, y_test).item():.4f}')
Test MSE: 9.6130
Test MAE: 2.1211
测试集上的 MSE 为 9.6,平均绝对误差(MAE)为 2.1。完成此回归项目后,我们将在下一部分进行分类项目。
项目二 – 对 MNIST 手写数字进行分类
对于这个分类项目,我们将对 MNIST 手写数字进行分类。在前一部分中,我们详细介绍了 PyTorch 中机器学习的四个基本步骤,我们将在本节中重复这些步骤。
您会记得,在第十二章中,您学习了如何从torchvision
模块中加载可用的数据集的方法。首先,我们将使用torchvision
模块加载 MNIST 数据集。
-
设置步骤包括加载数据集并指定超参数(训练集和测试集的大小,以及小批量的大小):
>>> import torchvision >>> from torchvision import transforms >>> image_path = './' >>> transform = transforms.Compose([ ... transforms.ToTensor() ... ]) >>> mnist_train_dataset = torchvision.datasets.MNIST( ... root=image_path, train=True, ... transform=transform, download=False ... ) >>> mnist_test_dataset = torchvision.datasets.MNIST( ... root=image_path, train=False, ... transform=transform, download=False ... ) >>> batch_size = 64 >>> torch.manual_seed(1) >>> train_dl = DataLoader(mnist_train_dataset, ... batch_size, shuffle=True)
在这里,我们构建了一个每批 64 个样本的数据加载器。接下来,我们将预处理加载的数据集。
-
我们预处理输入特征和标签。在这个项目中,特征是我们从第 1 步读取的图像的像素。我们使用
torchvision.transforms.Compose
定义了一个自定义转换。在这种简单情况下,我们的转换仅包括一个方法ToTensor()
。ToTensor()
方法将像素特征转换为浮点型张量,并将像素从[0, 255]范围归一化到[0, 1]范围。在第十四章,使用深度卷积神经网络对图像进行分类中,当我们处理更复杂的图像数据集时,我们将看到一些额外的数据转换方法。标签是从 0 到 9 的整数,表示十个数字。因此,我们不需要进行任何缩放或进一步的转换。请注意,我们可以使用data
属性访问原始像素,并不要忘记将它们缩放到[0, 1]范围内。在数据预处理完成后,我们将在下一步构建模型。
-
构建神经网络模型:
>>> hidden_units = [32, 16] >>> image_size = mnist_train_dataset[0][0].shape >>> input_size = image_size[0] * image_size[1] * image_size[2] >>> all_layers = [nn.Flatten()] >>> for hidden_unit in hidden_units: ... layer = nn.Linear(input_size, hidden_unit) ... all_layers.append(layer) ... all_layers.append(nn.ReLU()) ... input_size = hidden_unit >>> all_layers.append(nn.Linear(hidden_units[-1], 10)) >>> model = nn.Sequential(*all_layers) >>> model Sequential( (0): Flatten(start_dim=1, end_dim=-1) (1): Linear(in_features=784, out_features=32, bias=True) (2): ReLU() (3): Linear(in_features=32, out_features=16, bias=True) (4): ReLU() (5): Linear(in_features=16, out_features=10, bias=True) )
请注意,模型以一个展平层开始,将输入图像展平为一维张量。这是因为输入图像的形状是[1, 28, 28]。模型有两个隐藏层,分别为 32 和 16 个单元。最后是一个由十个单元组成的输出层,通过 softmax 函数激活,代表十个类别。在下一步中,我们将在训练集上训练模型,并在测试集上评估模型。
-
使用模型进行训练、评估和预测:
>>> loss_fn = nn.CrossEntropyLoss() >>> optimizer = torch.optim.Adam(model.parameters(), lr=0.001) >>> torch.manual_seed(1) >>> num_epochs = 20 >>> for epoch in range(num_epochs): ... accuracy_hist_train = 0 ... for x_batch, y_batch in train_dl: ... pred = model(x_batch) ... loss = loss_fn(pred, y_batch) ... loss.backward() ... optimizer.step() ... optimizer.zero_grad() ... is_correct = ( ... torch.argmax(pred, dim=1) == y_batch ... ).float() ... accuracy_hist_train += is_correct.sum() ... accuracy_hist_train /= len(train_dl.dataset) ... print(f'Epoch {epoch} Accuracy ' ... f'{accuracy_hist_train:.4f}') Epoch 0 Accuracy 0.8531 ... Epoch 9 Accuracy 0.9691 ... Epoch 19 Accuracy 0.9813
我们使用了交叉熵损失函数进行多类别分类,使用 Adam 优化器进行梯度下降。我们将在第十四章讨论 Adam 优化器。我们对模型进行了 20 个 epochs 的训练,并且在每个 epoch 显示了训练准确率。训练后的模型在训练集上达到了 96.3%的准确率,并且我们将在测试集上进行评估:
>>> pred = model(mnist_test_dataset.data / 255.) >>> is_correct = ( ... torch.argmax(pred, dim=1) == ... mnist_test_dataset.targets ... ).float() >>> print(f'Test accuracy: {is_correct.mean():.4f}') Test accuracy: 0.9645
测试准确率为 95.6%。您已经学会了如何使用 PyTorch 解决分类问题。
更高级别的 PyTorch API:简介 PyTorch-Lightning
近年来,PyTorch 社区开发了几个不同的库和 API,这些库和 API 都是基于 PyTorch 构建的。值得注意的例子包括 fastai (docs.fast.ai/
)、Catalyst (github.com/catalyst-team/catalyst
)、PyTorch Lightning (www.pytorchlightning.ai
)、(lightning-flash.readthedocs.io/en/latest/quickstart.html
)以及 PyTorch-Ignite (github.com/pytorch/ignite
)。
在本节中,我们将探讨 PyTorch Lightning(简称 Lightning),这是一个广泛使用的 PyTorch 库,通过消除大量样板代码,使训练深度神经网络变得更加简单。然而,尽管 Lightning 专注于简单性和灵活性,它也允许我们使用许多高级功能,例如多 GPU 支持和快速低精度训练,您可以在官方文档中了解更多信息:pytorch-lightning.rtfd.io/en/latest/
。
还有一个关于 PyTorch-Ignite 的额外介绍在github.com/rasbt/machine-learning-book/blob/main/ch13/ch13_part4_ignite.ipynb
。
在之前的一个章节中,项目二 - 分类 MNIST 手写数字,我们实现了一个多层感知器,用于在 MNIST 数据集中分类手写数字。在接下来的小节中,我们将使用 Lightning 重新实现这个分类器。
安装 PyTorch Lightning
您可以根据喜好通过 pip 或 conda 安装 Lightning。例如,通过 pip 安装 Lightning 的命令如下:
pip install pytorch-lightning
以下是通过 conda 安装 Lightning 的命令:
conda install pytorch-lightning -c conda-forge
下一小节的代码基于 PyTorch Lightning 1.5 版本,您可以通过在命令中将pytorch-lightning
替换为pytorch-lightning==1.5
来安装它。
设置 PyTorch Lightning 模型
我们首先实现模型,在接下来的子节中将对其进行训练。在 Lightning 中定义模型相对简单,因为它基于常规的 Python 和 PyTorch 代码。要实现 Lightning 模型,只需使用 LightningModule
替代常规的 PyTorch 模块即可。为了利用 PyTorch 的便利函数,如训练器 API 和自动日志记录,我们只需定义几个特定命名的方法,我们将在接下来的代码中看到:
import pytorch_lightning as pl
import torch
import torch.nn as nn
from torchmetrics import Accuracy
class MultiLayerPerceptron(pl.LightningModule):
def __init__(self, image_shape=(1, 28, 28), hidden_units=(32, 16)):
super().__init__()
# new PL attributes:
self.train_acc = Accuracy()
self.valid_acc = Accuracy()
self.test_acc = Accuracy()
# Model similar to previous section:
input_size = image_shape[0] * image_shape[1] * image_shape[2]
all_layers = [nn.Flatten()]
for hidden_unit in hidden_units:
layer = nn.Linear(input_size, hidden_unit)
all_layers.append(layer)
all_layers.append(nn.ReLU())
input_size = hidden_unit
all_layers.append(nn.Linear(hidden_units[-1], 10))
self.model = nn.Sequential(*all_layers)
def forward(self, x):
x = self.model(x)
return x
def training_step(self, batch, batch_idx):
x, y = batch
logits = self(x)
loss = nn.functional.cross_entropy(self(x), y)
preds = torch.argmax(logits, dim=1)
self.train_acc.update(preds, y)
self.log("train_loss", loss, prog_bar=True)
return loss
def training_epoch_end(self, outs):
self.log("train_acc", self.train_acc.compute())
def validation_step(self, batch, batch_idx):
x, y = batch
logits = self(x)
loss = nn.functional.cross_entropy(self(x), y)
preds = torch.argmax(logits, dim=1)
self.valid_acc.update(preds, y)
self.log("valid_loss", loss, prog_bar=True)
self.log("valid_acc", self.valid_acc.compute(), prog_bar=True)
return loss
def test_step(self, batch, batch_idx):
x, y = batch
logits = self(x)
loss = nn.functional.cross_entropy(self(x), y)
preds = torch.argmax(logits, dim=1)
self.test_acc.update(preds, y)
self.log("test_loss", loss, prog_bar=True)
self.log("test_acc", self.test_acc.compute(), prog_bar=True)
return loss
def configure_optimizers(self):
optimizer = torch.optim.Adam(self.parameters(), lr=0.001)
return optimizer
现在让我们逐一讨论不同的方法。正如你所见,__init__
构造函数包含了我们在之前子节中使用的相同模型代码。新的内容是我们添加了诸如 self.train_acc = Accuracy()
等准确性属性。这些属性将允许我们在训练过程中跟踪准确性。Accuracy
是从 torchmetrics
模块导入的,它应该会随着 Lightning 的自动安装而被安装。如果无法导入 torchmetrics
,你可以尝试通过 pip install torchmetrics
进行安装。更多信息可以在 torchmetrics.readthedocs.io/en/latest/pages/quickstart.html
找到。
forward
方法实现了一个简单的前向传递,当我们在输入数据上调用模型时,返回 logit(我们网络中 softmax 层之前的最后一个全连接层的输出)。通过调用 self(x)
的 forward
方法计算的 logit 用于训练、验证和测试步骤,我们将在接下来描述这些步骤。
training_step
、training_epoch_end
、validation_step
、test_step
和 configure_optimizers
方法是 Lightning 特别识别的方法。例如,training_step
定义了训练期间的单次前向传递,我们在此期间跟踪准确性和损失,以便稍后进行分析。请注意,我们通过 self.train_acc.update(preds, y)
计算准确性,但尚未记录。training_step
方法在训练过程中每个单独的批次上执行,而 training_epoch_end
方法在每个训练周期结束时执行,我们通过累积的准确性值计算训练集准确性。
validation_step
和 test_step
方法类似于 training_step
方法,定义了验证和测试评估过程的计算方式。与 training_step
类似,每个 validation_step
和 test_step
都接收一个批次数据,这就是为什么我们通过 torchmetric
的相应精度属性来记录准确性。但是,请注意,validation_step
仅在特定间隔调用,例如每次训练周期后。这就是为什么我们在验证步骤内记录验证准确性,而在训练准确性方面,我们会在每次训练周期后记录,否则,稍后检查的准确性图表将显得太过嘈杂。
最后,通过 configure_optimizers
方法,我们指定用于训练的优化器。接下来的两个小节将讨论如何设置数据集以及如何训练模型。
为 Lightning 设置数据加载器
有三种主要方法可以为 Lightning 准备数据集。我们可以:
-
将数据集作为模型的一部分
-
像往常一样设置数据加载器并将它们提供给 Lightning Trainer 的
fit
方法——Trainer 将在下一小节中介绍 -
创建一个
LightningDataModule
在这里,我们将使用 LightningDataModule
,这是最有组织的方法。LightningDataModule
包含五个主要方法,正如我们在接下来会看到的:
from torch.utils.data import DataLoader
from torch.utils.data import random_split
from torchvision.datasets import MNIST
from torchvision import transforms
class MnistDataModule(pl.LightningDataModule):
def __init__(self, data_path='./'):
super().__init__()
self.data_path = data_path
self.transform = transforms.Compose([transforms.ToTensor()])
def prepare_data(self):
MNIST(root=self.data_path, download=True)
def setup(self, stage=None):
# stage is either 'fit', 'validate', 'test', or 'predict'
# here note relevant
mnist_all = MNIST(
root=self.data_path,
train=True,
transform=self.transform,
download=False
)
self.train, self.val = random_split(
mnist_all, [55000, 5000], generator=torch.Generator().manual_seed(1)
)
self.test = MNIST(
root=self.data_path,
train=False,
transform=self.transform,
download=False
)
def train_dataloader(self):
return DataLoader(self.train, batch_size=64, num_workers=4)
def val_dataloader(self):
return DataLoader(self.val, batch_size=64, num_workers=4)
def test_dataloader(self):
return DataLoader(self.test, batch_size=64, num_workers=4)
在 prepare_data
方法中,我们定义了通用步骤,如下载数据集。在 setup
方法中,我们定义了用于训练、验证和测试的数据集。请注意,MNIST 没有专门的验证集拆分,这就是为什么我们使用 random_split
函数将包含 60,000 个示例的训练集分为 55,000 个示例用于训练和 5,000 个示例用于验证。
数据加载器的方法是不言自明的,并定义了如何加载各自的数据集。现在,我们可以初始化数据模块并在接下来的小节中用它来进行训练、验证和测试:
torch.manual_seed(1)
mnist_dm = MnistDataModule()
使用 PyTorch Lightning Trainer 类来训练模型
现在,我们可以从设置模型以及 Lightning 数据模块中具体命名的方法中受益。Lightning 实现了一个 Trainer
类,通过为我们处理所有中间步骤(如调用 zero_grad()
、backward()
和 optimizer.step()
)使得训练模型非常方便。此外,作为一个额外的好处,它让我们可以轻松地指定一个或多个 GPU(如果可用)来使用:
mnistclassifier = MultiLayerPerceptron()
if torch.cuda.is_available(): # if you have GPUs
trainer = pl.Trainer(max_epochs=10, gpus=1)
else:
trainer = pl.Trainer(max_epochs=10)
trainer.fit(model=mnistclassifier, datamodule=mnist_dm)
通过上述代码,我们为我们的多层感知器训练了 10 个 epochs。在训练过程中,我们可以看到一个便利的进度条,用于跟踪 epoch 和核心指标,如训练损失和验证损失:
Epoch 9: 100% 939/939 [00:07<00:00, 130.42it/s, loss=0.1, v_num=0, train_loss=0.260, valid_loss=0.166, valid_acc=0.949]
在训练结束后,我们还可以更详细地检查我们记录的指标,正如我们将在下一小节中看到的那样。
使用 TensorBoard 评估模型
在前一节中,我们体验了 Trainer
类的便利性。Lightning 的另一个好处是其日志记录功能。回想一下,我们之前在 Lightning 模型中指定了几个 self.log
步骤。在训练期间,我们可以可视化它们在 TensorBoard 中的展示。 (注意,Lightning 还支持其他日志记录器;更多信息请参阅官方文档:pytorch-lightning.readthedocs.io/en/latest/common/loggers.html
。)
安装 TensorBoard
可以通过 pip 或 conda 安装 TensorBoard,具体取决于您的偏好。例如,通过 pip 安装 TensorBoard 的命令如下:
pip install tensorboard
以下是通过 conda 安装 Lightning 的命令:
conda install tensorboard -c conda-forge
下一小节的代码基于 TensorBoard 版本 2.4,您可以通过在这些命令中替换 tensorboard
为 tensorboard==2.4
来安装它。
默认情况下,Lightning 将训练结果保存在名为 lightning_logs
的子文件夹中。要可视化训练运行结果,您可以在命令行终端中执行以下代码,它将在您的浏览器中打开 TensorBoard:
tensorboard --logdir lightning_logs/
或者,如果您在 Jupyter 笔记本中运行代码,您可以将以下代码添加到 Jupyter 笔记本单元格中,以直接显示笔记本中的 TensorBoard 仪表板:
%load_ext tensorboard
%tensorboard --logdir lightning_logs/
图 13.9 展示了 TensorBoard 仪表板上记录的训练和验证准确率。请注意左下角显示的 version_0
切换。如果多次运行训练代码,Lightning 会将它们作为单独的子文件夹进行跟踪:version_0
、version_1
、version_2
等等:
图 13.9: TensorBoard 仪表板
通过观察 图 13.9 中的训练和验证准确率,我们可以假设再训练几个周期可以提高性能。
Lightning 允许我们加载已训练的模型,并方便地再训练多个周期。如前所述,Lightning 通过子文件夹追踪各个训练运行。在 图 13.10 中,我们看到了 version_0
子文件夹的内容,其中包括日志文件和重新加载模型的模型检查点:
图 13.10: PyTorch Lightning 日志文件
例如,我们可以使用以下代码从此文件夹加载最新的模型检查点,并通过 fit
方法训练模型:
if torch.cuda.is_available(): # if you have GPUs
trainer = pl.Trainer(max_epochs=15, resume_from_checkpoint='./lightning_logs/version_0/checkpoints/epoch=8-step=7739.ckpt', gpus=1)
else:
trainer = pl.Trainer(max_epochs=15, resume_from_checkpoint='./lightning_logs/version_0/checkpoints/epoch=8-step=7739.ckpt')
trainer.fit(model=mnistclassifier, datamodule=mnist_dm)
在这里,我们将 max_epochs
设置为 15
,这使得模型总共训练了 5 个额外的周期(之前训练了 10 个周期)。
现在,让我们看一下 图 13.11 中的 TensorBoard 仪表板,看看再训练几个周期是否值得:
图 13.11: 训练了五个额外周期后的 TensorBoard 仪表板
正如我们在 图 13.11 中所看到的,TensorBoard 允许我们展示额外训练周期(version_1
)的结果与之前的(version_0
)对比,这非常方便。确实,我们可以看到再训练五个周期提高了验证准确率。在这一点上,我们可以决定是否继续训练更多周期,这留给您作为练习。
一旦完成训练,我们可以使用以下代码在测试集上评估模型:
trainer.test(model=mnistclassifier, datamodule=mnist_dm)
在总共训练 15 个周期后,得到的测试集性能约为 95%:
[{'test_loss': 0.14912301301956177, 'test_acc': 0.9499600529670715}]
请注意,PyTorch Lightning 还会自动保存模型。如果您想稍后重用模型,您可以通过以下代码方便地加载它:
model = MultiLayerPerceptron.load_from_checkpoint("path/to/checkpoint.ckpt")
了解更多关于 PyTorch Lightning 的信息
欲了解更多有关 Lightning 的信息,请访问官方网站,其中包含教程和示例,网址为 pytorch-lightning.readthedocs.io
。
Lightning 在 Slack 上也有一个活跃的社区,欢迎新用户和贡献者加入。要了解更多信息,请访问官方 Lightning 网站 www.pytorchlightning.ai
。
概要
在本章中,我们涵盖了 PyTorch 最重要和最有用的特性。我们首先讨论了 PyTorch 的动态计算图,这使得实现计算非常方便。我们还介绍了定义 PyTorch 张量对象作为模型参数的语义。
在我们考虑了计算任意函数的偏导数和梯度的概念后,我们更详细地讨论了 torch.nn
模块。它为我们提供了一个用户友好的接口,用于构建更复杂的深度神经网络模型。最后,我们通过使用迄今为止讨论的内容解决了回归和分类问题,从而结束了本章。
现在我们已经涵盖了 PyTorch 的核心机制,下一章将介绍深度学习中 卷积神经网络 (CNN) 架构的概念。CNN 是强大的模型,在计算机视觉领域表现出色。
加入我们书籍的 Discord 空间
加入本书的 Discord 工作区,与作者进行每月的 问答 会话:
第十四章:使用深度卷积神经网络进行图像分类
在上一章中,我们深入研究了 PyTorch 神经网络和自动微分模块的不同方面,您熟悉了张量和装饰函数,并学习了如何使用torch.nn
。在本章中,您将学习有关用于图像分类的卷积神经网络(CNNs)。我们将从底层开始讨论 CNN 的基本构建模块。接下来,我们将深入探讨 CNN 架构,并探讨如何在 PyTorch 中实现 CNN。本章将涵盖以下主题:
-
一维和二维卷积操作
-
CNN 架构的构建模块
-
在 PyTorch 中实现深度 CNN
-
数据增强技术以提高泛化性能
-
实现用于识别笑容的面部 CNN 分类器
CNN 的构建模块
CNN 是一个模型家族,最初受人类大脑视觉皮层在识别物体时的工作启发。CNN 的发展可以追溯到 1990 年代,当时 Yann LeCun 及其同事提出了一种新颖的神经网络架构,用于从图像中识别手写数字(Y. LeCun和同事在 1989 年发表在*神经信息处理系统(NeurIPS)*会议上的文章 Handwritten Digit Recognition with a Back-Propagation Network)。
人类视觉皮层
我们对大脑视觉皮层如何运作的最初发现是由 David H. Hubel 和 Torsten Wiesel 在 1959 年完成的,当时他们在麻醉猫的主视觉皮层插入了微电极。他们观察到,神经元在向猫前方不同模式的光投影后会产生不同的反应。这最终导致了对视觉皮层不同层次的发现。尽管主要层主要检测边缘和直线,但高阶层更专注于提取复杂的形状和图案。
由于 CNN 在图像分类任务中表现出色,这种特定类型的前馈神经网络引起了广泛关注,并在计算机视觉的机器学习领域取得了巨大进展。几年后的 2019 年,Yann LeCun 与其他两位研究者 Yoshua Bengio 和 Geoffrey Hinton 因其在人工智能(AI)领域的贡献而共同获得了图灵奖(计算机科学中最负盛名的奖项)。
在接下来的几节中,我们将讨论 CNN 的更广泛概念以及为什么卷积架构通常被描述为“特征提取层”。然后,我们将深入探讨在 CNN 中常用的卷积操作类型的理论定义,并通过计算一维和二维卷积的示例来详细讨论。
理解 CNN 和特征层级
成功提取显著(相关)特征对于任何机器学习算法的性能至关重要,传统的机器学习模型依赖于可能来自领域专家或基于计算特征提取技术的输入特征。
某些类型的 NN,如 CNNs,可以自动从原始数据中学习对特定任务最有用的特征。因此,将 CNN 层视为特征提取器是很常见的:早期层(紧接着输入层的那些层)从原始数据中提取低级特征,后续层(通常是全连接层,如多层感知器(MLP))利用这些特征来预测连续目标值或类别标签。
某些类型的多层 NNs,特别是深度 CNNs,通过逐层组合低级特征以形成高级特征的方式构建所谓的特征层次结构。例如,如果我们处理图像,则从较早的层中提取低级特征(如边缘和斑点),然后将它们组合形成高级特征。这些高级特征可以形成更复杂的形状,例如建筑物、猫或狗等对象的一般轮廓。
正如您在图 14.1中看到的那样,CNN 从输入图像计算特征图,其中每个元素来自输入图像中的一个局部像素块:
图 14.1:从图像创建特征图(由 Alexander Dummer 在 Unsplash 上拍摄的照片)
这个局部像素块被称为局部感受野。CNN 在与图像相关的任务上通常表现非常出色,这在很大程度上归功于两个重要的思想:
-
稀疏连接性:特征图中的单个元素仅连接到一个小的像素块。(这与 MLP 连接到整个输入图像非常不同。你可能会发现回顾并比较我们如何在第十一章中从头实现全连接网络的方式很有用。)
-
参数共享:相同的权重用于输入图像的不同补丁。
由于这两个思想的直接结果,用卷积层替换传统的全连接 MLP 会大大减少网络中的权重(参数),我们将看到在捕获显著特征方面的改进。在图像数据的背景下,假设附近的像素通常比远离的像素更相关是有意义的。
典型地,CNN 由若干卷积和子采样层组成,最后跟随一个或多个完全连接层。全连接层本质上就是一个 MLP,其中每个输入单元i与每个输出单元j通过权重w[ij]连接(我们在第十一章中更详细地介绍过)。
请注意,常称为 池化层 的子采样层没有任何可学习的参数;例如,池化层中没有权重或偏置单元。然而,卷积层和全连接层都有在训练期间优化的权重和偏置。
在接下来的章节中,我们将更详细地研究卷积层和池化层,并了解它们的工作原理。为了理解卷积操作的工作原理,让我们从一维卷积开始,这在处理某些类型的序列数据(如文本)时有时会用到。在讨论一维卷积之后,我们将探讨通常应用于二维图像的二维卷积。
执行离散卷积
离散卷积(或简称为 卷积)是 CNN 中的一个基本操作。因此,理解这个操作如何工作非常重要。在本节中,我们将涵盖数学定义并讨论一些计算一维张量(向量)和二维张量(矩阵)卷积的朴素算法。
请注意,本节中的公式和描述仅用于理解 CNN 中的卷积操作。实际上,像 PyTorch 这样的软件包中已经存在更高效的卷积操作实现,你将在本章后面看到。
数学符号
在本章中,我们将使用下标表示多维数组(张量)的大小;例如, 是一个大小为 n[1]×n[2] 的二维数组。我们使用方括号 [ ] 表示多维数组的索引。例如,A[i, j] 指的是矩阵 A 中索引为 i, j 的元素。此外,请注意我们使用特殊符号
表示两个向量或矩阵之间的卷积运算,这与 Python 中的乘法运算符
*
不要混淆。
一维离散卷积
让我们从一些基本定义和符号开始。两个向量 x 和 w 的离散卷积表示为 ,其中向量 x 是我们的输入(有时称为 信号),w 被称为 滤波器 或 核。离散卷积的数学定义如下:
正如前面提到的,方括号 [ ] 用于表示向量元素的索引。索引 i 遍历输出向量 y 的每个元素。在前述公式中有两件需要澄清的奇怪之处:负无穷到正无穷的指数和 x 的负索引。
看起来从–∞到+∞的索引和,主要因为在机器学习应用中,我们总是处理有限的特征向量。例如,如果x具有 10 个特征,索引为 0、1、2、…、8、9,那么索引–∞: –1 和 10: +∞对于x来说是超出范围的。因此,为了正确计算上述公式中的求和,假设x和w填充了零。这将导致输出向量y也具有无限大小,并且也有很多零。由于在实际情况下这并不有用,因此x仅填充有限数量的零。
这个过程称为零填充或简称填充。这里,每一侧填充的零的数量由p表示。图 14.2 展示了一维向量x的一个填充示例:
图 14.2:填充的示例
假设原始输入x和滤波器w分别具有n和m个元素,其中。因此,填充向量x^p 的大小为n + 2p。用于计算离散卷积的实际公式将变为:
现在我们已经解决了无限索引问题,第二个问题是用i + m – k索引x。这里需要注意的重要一点是,在这个求和中x和w的索引方向不同。用一个索引反向的方法计算总和等同于在填充后翻转其中一个向量x或w后,同时用正向的方法计算总和。然后,我们可以简单地计算它们的点积。假设我们翻转(旋转)滤波器w,得到旋转后的滤波器w^r。那么,点积x[i: i + m].w^r 得到一个元素y[i],其中x[i: i + m]是大小为m的x的一个片段。这个操作像滑动窗口方法一样重复,以获取所有的输出元素。
以下图示给出了一个例子,其中x = [3 2 1 7 1 2 5 4]和,以便计算前三个输出元素:
图 14.3:计算离散卷积的步骤
在上述例子中,您可以看到填充大小为零(p = 0)。注意,旋转后的滤波器w^r 每次移动两个单元。这种移动是卷积的另一个超参数,步长,s。在这个例子中,步长是二,s = 2。注意,步长必须是一个小于输入向量大小的正数。我们将在下一节详细讨论填充和步长。
交叉相关
输入向量和滤波器之间的交叉相关(或简称相关)用 表示,与卷积非常相似,唯一的区别在于:在交叉相关中,乘法是在相同方向上进行的。因此,在每个维度上不需要旋转滤波器矩阵 w。数学上,交叉相关定义如下:
对于交叉相关,填充和步长的规则也可以应用。请注意,大多数深度学习框架(包括 PyTorch)实现的是交叉相关,但在深度学习领域中通常称之为卷积,这是一种常见的约定。
填充输入以控制输出特征图的大小
到目前为止,我们只在卷积中使用了零填充来计算有限大小的输出向量。从技术上讲,填充可以应用于任何 。根据 p 的选择,边界单元格的处理方式可能与位于 x 中间的单元格有所不同。
现在,考虑一个例子,其中 n = 5,m = 3. 那么,当 p = 0 时,x[0] 仅用于计算一个输出元素(例如 y[0]),而 x[1] 用于计算两个输出元素(例如 y[0] 和 y[1])。因此,你可以看到,对 x 元素的这种不同处理可以在较多的计算中人为地突出中间元素 x[2]。如果我们选择 p = 2,我们可以避免这个问题,这样 x 的每个元素将参与计算 y 的三个元素。
此外,输出 y 的大小也取决于我们使用的填充策略。
在实际应用中,有三种常用的填充模式:full、same 和 valid。
在 full 模式下,填充参数 p 被设置为 p = m – 1. 全填充会增加输出的维度,因此在 CNN 架构中很少使用。
通常使用 same padding 模式来确保输出向量与输入向量 x 的大小相同。在这种情况下,填充参数 p 根据滤波器大小计算,同时要求输入大小和输出大小相同。
最后,在 valid 模式下进行卷积计算时,表示 p = 0(无填充)。
图 14.4 展示了一个简单的 5×5 像素输入与 3×3 的核大小和步长为 1 的三种不同填充模式:
图 14.4:三种填充模式
CNN 中最常用的填充模式是 same padding。它与其他填充模式相比的优势之一是保持了向量的大小,或者在计算机视觉中处理图像相关任务时保持了输入图像的高度和宽度,这使得设计网络架构更加方便。
有效填充与全填充以及相同填充相比的一个巨大缺点是,在具有许多层的神经网络中,张量的体积会大幅减少,这可能对网络的性能有害。在实践中,应该保留卷积层的空间尺寸,并通过池化层或步长为 2 的卷积层减少空间尺寸,正如追求简单性:全卷积网络 ICLR(研讨会轨道),由约斯特·托比亚斯·施普林根伯格,亚历克西·多索维茨基和其他人,2015 年所述 (arxiv.org/abs/1412.6806
)。
至于全填充,其尺寸会导致输出大于输入尺寸。全填充通常用于信号处理应用中,重要的是最小化边界效应。然而,在深度学习环境中,边界效应通常不是问题,因此我们很少在实践中看到使用全填充。
确定卷积输出的尺寸
卷积输出的大小由我们沿着输入向量移动滤波器w的总次数决定。假设输入向量的大小为n,滤波器的大小为m,并且填充为p,步长为s,则从结果的输出大小将如下确定:
这里, 表示地板运算。
地板运算
地板运算返回小于或等于输入的最大整数,例如:
考虑以下两种情况:
-
计算输入向量大小为 10,卷积核大小为 5,填充为 2,步长为 1 时的输出大小:
(请注意,在这种情况下,输出尺寸与输入相同;因此,我们可以得出这是相同填充模式。)
-
当我们有大小为 3 且步长为 2 的卷积核时,同一输入向量的输出大小会如何变化?
如果你对学习卷积输出的尺寸更感兴趣,我们推荐阅读深度学习卷积算术指南,作者是文森特·杜穆林和弗朗切斯科·维辛,可以在 arxiv.org/abs/1603.07285
自由获取。
最后,为了学习如何在一维中计算卷积,我们展示了一个简单的实现在以下代码块中,并且结果与numpy.convolve
函数进行了比较。代码如下:
>>> import numpy as np
>>> def conv1d(x, w, p=0, s=1):
... w_rot = np.array(w[::-1])
... x_padded = np.array(x)
... if p > 0:
... zero_pad = np.zeros(shape=p)
... x_padded = np.concatenate([
... zero_pad, x_padded, zero_pad
... ])
... res = []
... for i in range(0, int((len(x_padded) - len(w_rot))) + 1, s):
... res.append(np.sum(x_padded[i:i+w_rot.shape[0]] * w_rot))
... return np.array(res)
>>> ## Testing:
>>> x = [1, 3, 2, 4, 5, 6, 1, 3]
>>> w = [1, 0, 3, 1, 2]
>>> print('Conv1d Implementation:',
... conv1d(x, w, p=2, s=1))
Conv1d Implementation: [ 5\. 14\. 16\. 26\. 24\. 34\. 19\. 22.]
>>> print('NumPy Results:',
... np.convolve(x, w, mode='same'))
NumPy Results: [ 5 14 16 26 24 34 19 22]
到目前为止,我们主要关注向量的卷积(1D 卷积)。我们从 1D 情况开始,以便更容易理解概念。在接下来的部分,我们将更详细地讨论 2D 卷积,这是用于图像相关任务的 CNN 的构建模块。
进行 2D 离散卷积
你在前几节学到的概念很容易扩展到 2D。当我们处理 2D 输入,比如一个矩阵,,和滤波器矩阵,
,其中
和
,那么矩阵
就是X和W之间的 2D 卷积的结果。数学上定义如下:
注意,如果省略其中一个维度,剩余的公式与我们之前用于计算 1D 卷积的公式完全相同。事实上,所有先前提到的技术,如零填充、旋转滤波器矩阵和使用步长,也适用于 2D 卷积,只要它们分别扩展到两个维度。图 14.5展示了使用 3×3 大小的核对大小为 8×8 的输入矩阵进行的 2D 卷积。输入矩阵通过p = 1 进行了零填充。因此,2D 卷积的输出大小为 8×8:
图 14.5:2D 卷积的输出
下面的例子演示了如何计算输入矩阵X[3×3]和核矩阵W[3×3]之间的 2D 卷积,使用填充p = (1, 1)和步长s = (2, 2)。根据指定的填充,每侧都添加了一层零,得到填充后的矩阵,如下所示:
图 14.6:计算输入和核矩阵之间的 2D 卷积
使用上述滤波器,旋转后的滤波器将是:
请注意,此旋转与转置矩阵不同。在 NumPy 中获取旋转滤波器,我们可以写成W_rot=W[::-1,::-1]
。接下来,我们可以将旋转的滤波器矩阵沿着填充的输入矩阵X^(padded)移动,如滑动窗口一样,并计算元素乘积的和,这在图 14.7中由运算符表示:
图 14.7:计算元素乘积的和
结果将是 2×2 矩阵Y。
让我们根据描述的朴素算法也实现 2D 卷积。scipy.signal
包提供了通过scipy.signal.convolve2d
函数计算 2D 卷积的方法:
>>> import numpy as np
>>> import scipy.signal
>>> def conv2d(X, W, p=(0, 0), s=(1, 1)):
... W_rot = np.array(W)[::-1,::-1]
... X_orig = np.array(X)
... n1 = X_orig.shape[0] + 2*p[0]
... n2 = X_orig.shape[1] + 2*p[1]
... X_padded = np.zeros(shape=(n1, n2))
... X_padded[p[0]:p[0]+X_orig.shape[0],
... p[1]:p[1]+X_orig.shape[1]] = X_orig
...
... res = []
... for i in range(0,
... int((X_padded.shape[0] - \
... W_rot.shape[0])/s[0])+1, s[0]):
... res.append([])
... for j in range(0,
... int((X_padded.shape[1] - \
... W_rot.shape[1])/s[1])+1, s[1]):
... X_sub = X_padded[i:i+W_rot.shape[0],
... j:j+W_rot.shape[1]]
... res[-1].append(np.sum(X_sub * W_rot))
... return(np.array(res))
>>> X = [[1, 3, 2, 4], [5, 6, 1, 3], [1, 2, 0, 2], [3, 4, 3, 2]]
>>> W = [[1, 0, 3], [1, 2, 1], [0, 1, 1]]
>>> print('Conv2d Implementation:\n',
... conv2d(X, W, p=(1, 1), s=(1, 1)))
Conv2d Implementation:
[[ 11\. 25\. 32\. 13.]
[ 19\. 25\. 24\. 13.]
[ 13\. 28\. 25\. 17.]
[ 11\. 17\. 14\. 9.]]
>>> print('SciPy Results:\n',
... scipy.signal.convolve2d(X, W, mode='same'))
SciPy Results:
[[11 25 32 13]
[19 25 24 13]
[13 28 25 17]
[11 17 14 9]]
高效计算卷积的算法
我们提供了一个朴素的实现来计算 2D 卷积,以便理解这些概念。然而,这种实现在内存需求和计算复杂度方面非常低效。因此,在现实世界的神经网络应用中不应该使用它。
一个方面是,在大多数工具(如 PyTorch)中,滤波器矩阵实际上并没有旋转。此外,近年来已开发出更高效的算法,使用傅里叶变换来计算卷积。还要注意,在神经网络的背景下,卷积核的大小通常远小于输入图像的大小。
例如,现代 CNN 通常使用 1×1、3×3 或 5×5 的核大小,为此已设计出更高效的算法,可以更有效地执行卷积操作,如 Winograd 的最小过滤算法。这些算法超出了本书的范围,但如果您有兴趣了解更多,可以阅读 Andrew Lavin 和 Scott Gray 在 2015 年撰写的手稿Fast Algorithms for Convolutional Neural Networks,可在arxiv.org/abs/1509.09308
免费获取。
在下一节中,我们将讨论 CNN 中经常使用的另一重要操作,即下采样或池化。
下采样层
在 CNN 中,最大池化和平均池化(又称均值池化)通常以两种形式进行下采样操作。池化层通常用表示。此处的下标决定了邻域的大小(每个维度中相邻像素的数量),在这个邻域内进行最大或均值操作。我们将这样的邻域称为池化尺寸。
操作在图 14.8中描述。在此,最大池化从像素邻域中取最大值,而平均池化计算它们的平均值:
图 14.8:最大池化和平均池化的示例
池化的优点是双重的:
-
池化(最大池化)引入了局部不变性。这意味着局部邻域的小变化不会改变最大池化的结果。因此,它有助于生成对输入数据中噪声更加稳健的特征。请参考下面的示例,显示两个不同输入矩阵X[1]和X[2]的最大池化结果相同:
-
池化减小了特征的大小,提高了计算效率。此外,减少特征的数量也可能降低过拟合的程度。
重叠与非重叠池化
传统上,池化被假定为非重叠的。池化通常在非重叠的邻域上执行,可以通过设置步幅参数等于池化大小来完成。例如,非重叠池化层,,需要一个步幅参数s = (n[1], n[2])。另一方面,如果步幅小于池化大小,则会发生重叠池化。描述重叠池化在卷积网络中使用的一个例子可见于A. Krizhevsky、I. Sutskever和G. Hinton于 2012 年的ImageNet Classification with Deep Convolutional Neural Networks,该文稿可以免费获取,网址为
papers.nips.cc/paper/4824-imagenet-classification-with-deep-convolutional-neural-networks
。
尽管池化仍然是许多 CNN 架构的重要部分,但也已经开发出了几种不使用池化层的 CNN 架构。研究人员使用步幅为 2 的卷积层代替使用池化层来减少特征尺寸。
从某种意义上说,你可以将带有步幅 2 的卷积层视为带有可学习权重的池化层。如果你对使用和不使用池化层开发的不同 CNN 架构进行经验比较感兴趣,我们建议阅读Jost Tobias Springenberg、Alexey Dosovitskiy、Thomas Brox和Martin Riedmiller撰写的研究文章Striving for Simplicity: The All Convolutional Net。该文章可以免费获取,网址为arxiv.org/abs/1412.6806
。
将所有内容放在一起 - 实现一个 CNN
到目前为止,你已经学习了 CNN 的基本构建块。本章节中介绍的概念实际上并不比传统的多层神经网络更难。我们可以说,在传统 NN 中最重要的操作是矩阵乘法。例如,我们使用矩阵乘法来计算预激活(或净输入),如z = Wx + b。这里,x是一个列向量(矩阵),表示像素,而W是连接像素输入到每个隐藏单元的权重矩阵。
在 CNN 中,这个操作被卷积操作所取代,如,其中X是一个表示像素的矩阵,排列为高度×宽度。在这两种情况下,预激活被传递到激活函数以获取隐藏单元的激活,
,其中
是激活函数。此外,你会记得子采样是 CNN 的另一个构建块,可能以池化的形式出现,正如在前一节中所描述的。
使用多个输入或颜色通道
卷积层的输入可以包含一个或多个 2D 数组或矩阵,维度为N[1]×N[2](例如,像素的图像高度和宽度)。这些N[1]×N[2]矩阵称为通道。传统的卷积层实现期望输入为秩-3 张量表示,例如,三维数组,,其中C[in]是输入通道数。例如,让我们考虑图像作为 CNN 的第一层的输入。如果图像是彩色并使用 RGB 色彩模式,则C[in]=3(对应于 RGB 中的红色、绿色和蓝色通道)。然而,如果图像是灰度的,则C[in]=1,因为只有一个灰度像素强度通道。
读取图像文件
当我们处理图像时,可以使用uint8
(无符号 8 位整数)数据类型将图像读入 NumPy 数组中,以减少内存使用,与 16 位、32 位或 64 位整数类型相比。
无符号 8 位整数的取值范围为[0, 255],足以存储 RGB 图像中的像素信息,其值也在同一范围内。
在第十二章中,使用 PyTorch 并行化神经网络训练,你看到 PyTorch 提供了一个模块,用于通过torchvision
加载/存储和操作图像。让我们回顾一下如何读取图像(本示例中的 RGB 图像位于本章提供的代码包文件夹中):
>>> import torch
>>> from torchvision.io import read_image
>>> img = read_image('example-image.png')
>>> print('Image shape:', img.shape)
Image shape: torch.Size([3, 252, 221])
>>> print('Number of channels:', img.shape[0])
Number of channels: 3
>>> print('Image data type:', img.dtype)
Image data type: torch.uint8
>>> print(img[:, 100:102, 100:102])
tensor([[[179, 182],
[180, 182]],
[[134, 136],
[135, 137]],
[[110, 112],
[111, 113]]], dtype=torch.uint8)
请注意,使用torchvision
时,输入和输出的图像张量格式为Tensor[通道数, 图像高度, 图像宽度]
。
现在您熟悉输入数据的结构,接下来的问题是,我们如何在我们讨论的卷积操作中结合多个输入通道?答案非常简单:我们分别为每个通道执行卷积操作,然后使用矩阵求和将结果相加。与每个通道相关联的卷积(c)有其自己的核矩阵,例如W[:, :, c]。
总的预激活结果由以下公式计算:
最终结果A是一个特征映射。通常,CNN 的卷积层具有多个特征映射。如果使用多个特征映射,则核张量变为四维:宽度×高度×C[in]×C[out]。因此,现在让我们在前述公式中包含输出特征映射的数量,并进行更新,如下所示:
结束我们对在 NN 上下文中计算卷积的讨论,让我们看一下图 14.9 中的例子,展示了一个卷积层,后跟一个池化层。在这个例子中,有三个输入通道。卷积核张量是四维的。每个卷积核矩阵表示为m[1]×m[2],共有三个,每个输入通道一个。此外,有五个这样的卷积核,对应五个输出特征图。最后,有一个池化层用于对特征图进行子采样:
图 14.9:实现 CNN
在前面的例子中有多少个可训练参数?
为了说明卷积、参数共享和稀疏连接的优点,让我们通过一个例子来详细说明。网络中的卷积层是一个四维张量。因此,与卷积核相关联的参数数量为m[1]×m[2]×3×5。此外,每个卷积层输出特征图都有一个偏置向量。因此,偏置向量的尺寸是 5。池化层没有任何(可训练的)参数;因此,我们可以写成如下形式:
m[1] × m[2] × 3 × 5 + 5
如果输入张量的尺寸为n[1]×n[2]×3,假设使用同填充模式进行卷积,那么输出特征图的尺寸将是n[1] × n[2] × 5。
请注意,如果我们使用全连接层而不是卷积层,这个数字会大得多。在全连接层的情况下,达到相同输出单元数量的权重矩阵参数数量如下:
(n[1] × n[2] × 3) × (n[1] × n[2] × 5) = (n[1] × n[2])² × 3 × 5
此外,偏置向量的尺寸是n[1] × n[2] × 5(每个输出单元一个偏置元素)。鉴于m[1] < n[1]和m[2] < n[2],我们可以看到可训练参数数量的差异是显著的。
最后,正如之前提到的,卷积操作通常是通过将具有多个颜色通道的输入图像视为一堆矩阵来执行的;也就是说,我们分别对每个矩阵执行卷积,然后将结果相加,正如前面的图所示。但是,如果您处理的是 3D 数据集,例如,可以将卷积扩展到 3D 体积,如 Daniel Maturana 和 Sebastian Scherer 在 2015 年的论文《VoxNet:用于实时物体识别的 3D 卷积神经网络》中所示,可访问www.ri.cmu.edu/pub_files/2015/9/voxnet_maturana_scherer_iros15.pdf
。
在接下来的部分中,我们将讨论如何正则化一个神经网络。
使用 L2 正则化和 dropout 对 NN 进行正则化
选择网络的大小,无论是传统(全连接)NN 还是 CNN,一直都是一个具有挑战性的问题。例如,需要调整权重矩阵的大小和层数,以达到合理的性能。
您会从 第十三章,深入了解 - PyTorch 的机制 中记得,一个没有隐藏层的简单网络只能捕捉线性决策边界,这对处理异或(或 XOR)或类似问题是不够的。网络的 容量 是指它可以学习逼近的函数复杂性级别。小网络或具有相对少参数的网络具有低容量,因此可能会 欠拟合,导致性能不佳,因为它们无法学习复杂数据集的底层结构。但是,非常大的网络可能会导致 过拟合,即网络会记住训练数据,并在训练数据集上表现极好,但在留置测试数据集上表现不佳。当我们处理现实中的机器学习问题时,我们不知道网络应该有多大 先验。
解决这个问题的一种方法是构建一个具有相对较大容量的网络(在实践中,我们希望选择一个略大于必要的容量),以在训练数据集上表现良好。然后,为了防止过拟合,我们可以应用一个或多个正则化方案,以在新数据(如留置测试数据集)上实现良好的泛化性能。
在 第三章 和 第四章 中,我们介绍了 L1 和 L2 正则化。这两种技术都可以通过在训练过程中对损失函数增加惩罚来缩小权重参数,从而防止或减少过拟合的影响。虽然 L1 和 L2 正则化都可以用于神经网络,L2 是其中更常见的选择,但对于神经网络的正则化还有其他方法,如我们在本节讨论的 dropout。但在讨论 dropout 之前,在卷积或全连接网络中使用 L2 正则化(回想一下,全连接层是通过 torch.nn.Linear
在 PyTorch 中实现的),你可以简单地将特定层的 L2 惩罚添加到损失函数中,如下所示:
>>> import torch.nn as nn
>>> loss_func = nn.BCELoss()
>>> loss = loss_func(torch.tensor([0.9]), torch.tensor([1.0]))
>>> l2_lambda = 0.001
>>> conv_layer = nn.Conv2d(in_channels=3,
... out_channels=5,
... kernel_size=5)
>>> l2_penalty = l2_lambda * sum(
... [(p**2).sum() for p in conv_layer.parameters()]
... )
>>> loss_with_penalty = loss + l2_penalty
>>> linear_layer = nn.Linear(10, 16)
>>> l2_penalty = l2_lambda * sum(
... [(p**2).sum() for p in linear_layer.parameters()]
... )
>>> loss_with_penalty = loss + l2_penalty
权重衰减与 L2 正则化
通过在 PyTorch 优化器中将 weight_decay
参数设置为正值,可以替代使用 L2 正则化的另一种方法,例如:
optimizer = torch.optim.SGD(
model.parameters(),
weight_decay=l2_lambda,
...
)
虽然 L2 正则化和 weight_decay
不严格相同,但可以证明它们在使用 随机梯度下降(SGD)优化器时是等效的。感兴趣的读者可以在 Ilya Loshchilov 和 Frank Hutter,2019 年的文章 Decoupled Weight Decay Regularization 中找到更多信息,该文章可以免费在 arxiv.org/abs/1711.05101
上获取。
近年来,dropout 已经成为一种流行的技术,用于正则化(深度)神经网络以避免过拟合,从而提高泛化性能(Dropout: A Simple Way to Prevent Neural Networks from Overfitting,作者为 N. Srivastava、G. Hinton、A. Krizhevsky、I. Sutskever 和 R. Salakhutdinov,Journal of Machine Learning Research 15.1,页面 1929-1958,2014 年,www.jmlr.org/papers/volume15/srivastava14a/srivastava14a.pdf
)。dropout 通常应用于更高层的隐藏单元,并按以下方式工作:在 NN 的训练阶段,每次迭代时随机丢弃一部分隐藏单元,丢弃概率为 p[drop](或保留概率 p[keep] = 1 - p[drop])。此丢弃概率由用户确定,常见选择为 p = 0.5,如前述的 Nitish Srivastava 等人在 2014 年的文章中讨论的那样。当丢弃一定比例的输入神经元时,与剩余神经元相关联的权重会重新缩放,以考虑缺失的(丢弃的)神经元。
这种随机 dropout 的效果是,网络被迫学习数据的冗余表示。因此,网络不能依赖于任何一组隐藏单元的激活,因为它们在训练过程中可能随时关闭,并被迫从数据中学习更一般和更稳健的模式。
这种随机 dropout 可以有效地防止过拟合。图 14.10 显示了在训练阶段应用 dropout 的示例,其中丢失概率为 p = 0.5,因此在每次训练的前向传递中,一半的神经元将随机失活。然而,在预测阶段,所有神经元将有助于计算下一层的预激活:
图 14.10:在训练阶段应用 dropout
正如所示,一个重要的记住的要点是,单位在训练过程中可能会随机丢失,而在评估(推断)阶段,所有隐藏单元必须处于活跃状态(例如,p[drop] = 0 或 p[keep] = 1)。为了确保训练和预测期间的总激活在同一尺度上,必须适当缩放活跃神经元的激活(例如,如果丢失概率设置为 p = 0.5,则通过减半激活来进行缩放)。
然而,由于在进行预测时始终缩放激活比较麻烦,因此 PyTorch 和其他工具在训练期间缩放激活(例如,如果丢失概率设置为 p = 0.5,则通过加倍激活)。这种方法通常被称为 逆 dropout。
虽然关系并不立即明显,dropout 可以被解释为一组模型的共识(平均)。正如在第七章,集成学习中的不同模型组合中讨论的那样,在集成学习中,我们独立训练几个模型。在预测期间,我们然后使用所有训练模型的共识。我们已经知道,模型集成比单个模型表现更好。然而,在深度学习中,训练多个模型、收集和平均多个模型的输出都是计算昂贵的。在这里,dropout 提供了一种解决方案,以一种有效的方式同时训练多个模型,并在测试或预测时计算它们的平均预测。
正如之前提到的,模型集成与 dropout 之间的关系并不立即明显。但是请考虑,在 dropout 中,由于在每次前向传递期间随机将权重设置为零,我们对每个小批量都有一个不同的模型。
然后,通过迭代小批量,我们本质上对M = 2^h 个模型进行采样,其中h是隐藏单元的数量。
与常规集成不同,dropout 的限制和方面在于我们在这些“不同模型”上共享权重,这可以看作是一种正则化形式。然后,在“推断”期间(例如,在测试数据集中预测标签),我们可以对训练过程中采样的所有这些不同模型进行平均。尽管如此,这是非常昂贵的。
然后,对模型进行平均化,即计算模型i返回的类成员概率的几何平均,可以如下计算:
现在,dropout 背后的技巧在于,这个模型集合(这里是M个模型)的几何平均可以通过在训练过程中采样的最后一个(或最终)模型的预测,乘以一个因子 1/(1 – p)来近似计算,这比显式计算使用上述方程求解几何平均要便宜得多。(事实上,如果我们考虑线性模型,这个近似等同于真正的几何平均。)
用于分类的损失函数
在第十二章,使用 PyTorch 并行化神经网络训练中,我们看到了不同的激活函数,如 ReLU、sigmoid 和 tanh。这些激活函数中的一些,如 ReLU,主要用于神经网络的中间(隐藏)层,以为我们的模型添加非线性。但其他的,如 sigmoid(用于二元)和 softmax(用于多类别),则添加在最后(输出)层,将结果作为模型的类成员概率输出。如果在输出层没有包含 sigmoid 或 softmax 激活函数,则模型将计算 logits 而不是类成员概率。
在这里关注分类问题,根据问题的类型(二元分类还是多类分类)和输出的类型(logits 还是概率),我们应选择适当的损失函数来训练我们的模型。二元交叉熵是二元分类(具有单个输出单元)的损失函数,分类交叉熵是多类分类的损失函数。在torch.nn
模块中,分类交叉熵损失接受整数形式的真实标签(例如,y=2,对于三个类别 0、1 和 2)。
图 14.11描述了在torch.nn
中可用的两个损失函数,用于处理二元分类和整数标签的多类分类。这两个损失函数中的每一个还可以选择以 logits 或类成员概率的形式接收预测值:
图 14.11: PyTorch 中两个损失函数的示例
请注意,由于数值稳定性原因,通常通过提供 logits 而不是类成员概率来计算交叉熵损失更为可取。对于二元分类,我们可以将 logits 作为输入提供给损失函数nn.BCEWithLogitsLoss()
,或者基于 logits 计算概率并将其馈送给损失函数nn.BCELoss()
。对于多类分类,我们可以将 logits 作为输入提供给损失函数nn.CrossEntropyLoss()
,或者基于 logits 计算对数概率并将其馈送给负对数似然损失函数nn.NLLLoss()
。
下面的代码将向您展示如何使用这些损失函数来处理两种不同的格式,其中损失函数的输入可以是 logits 或类成员概率:
>>> ####### Binary Cross-entropy
>>> logits = torch.tensor([0.8])
>>> probas = torch.sigmoid(logits)
>>> target = torch.tensor([1.0])
>>> bce_loss_fn = nn.BCELoss()
>>> bce_logits_loss_fn = nn.BCEWithLogitsLoss()
>>> print(f'BCE (w Probas): {bce_loss_fn(probas, target):.4f}')
BCE (w Probas): 0.3711
>>> print(f'BCE (w Logits): '
... f'{bce_logits_loss_fn(logits, target):.4f}')
BCE (w Logits): 0.3711
>>> ####### Categorical Cross-entropy
>>> logits = torch.tensor([[1.5, 0.8, 2.1]])
>>> probas = torch.softmax(logits, dim=1)
>>> target = torch.tensor([2])
>>> cce_loss_fn = nn.NLLLoss()
>>> cce_logits_loss_fn = nn.CrossEntropyLoss()
>>> print(f'CCE (w Probas): '
... f'{cce_logits_loss_fn(logits, target):.4f}')
CCE (w Probas): 0.5996
>>> print(f'CCE (w Logits): '
... f'{cce_loss_fn(torch.log(probas), target):.4f}')
CCE (w Logits): 0.5996
请注意,有时您可能会遇到使用分类交叉熵损失进行二元分类的实现。通常情况下,当我们有一个二元分类任务时,模型为每个示例返回单个输出值。我们将这个单一模型输出解释为正类(例如类 1)的概率 P(class = 1|x)。在二元分类问题中,我们隐含地有 P(class = 0|x)= 1 – P(class = 1|x),因此我们不需要第二个输出单元来获取负类的概率。然而,有时实践者选择为每个训练示例返回两个输出,并将其解释为每个类的概率:P(class = 0|x)与P(class = 1|x)。在这种情况下,建议使用 softmax 函数(而不是逻辑 sigmoid)来归一化输出(使它们总和为 1),并且分类交叉熵是适当的损失函数。
使用 PyTorch 实现深度 CNN
正如您可能还记得的第十三章中,我们使用torch.nn
模块解决了手写数字识别问题。您可能还记得,我们使用具有两个线性隐藏层的 NN 达到了约 95.6%的准确率。
现在,让我们实现一个 CNN,看看它是否能够比之前的模型在分类手写数字方面实现更好的预测性能。请注意,在第十三章中看到的全连接层在此问题上表现良好。然而,在某些应用中,比如从手写数字中读取银行账号号码,即使是微小的错误也可能非常昂贵。因此,尽可能减少这种错误至关重要。
多层 CNN 架构
我们将要实现的网络架构如图 14.12所示。输入为 28×28 的灰度图像。考虑到通道数(对于灰度图像为 1)和输入图像的批量,输入张量的维度将是batchsize×28×28×1。
输入数据通过两个卷积层,卷积核大小为 5×5。第一个卷积层有 32 个输出特征图,第二个卷积层有 64 个输出特征图。每个卷积层后面跟着一个池化层,采用最大池化操作,P[2×2]。然后一个全连接层将输出传递给第二个全连接层,它作为最终的softmax输出层。我们将要实现的网络架构如图 14.12所示:
图 14.12:一个深度 CNN
每层张量的尺寸如下:
-
输入:[batchsize×28×28×1]
-
卷积 _1:[batchsize×28×28×32]
-
池化 _1:[batchsize×14×14×32]
-
卷积 _2:[batchsize×14×14×64]
-
池化 _2:[batchsize×7×7×64]
-
FC_1:[batchsize×1024]
-
FC_2 和 softmax 层:[batchsize×10]
对于卷积核,我们使用stride=1
以保持输入维度在生成的特征图中的尺寸不变。对于池化层,我们使用kernel_size=2
来对图像进行子采样并缩小输出特征图的尺寸。我们将使用 PyTorch NN 模块来实现这个网络。
加载和预处理数据
首先,我们将使用torchvision
模块加载 MNIST 数据集,并构建训练集和测试集,就像在第十三章中一样:
>>> import torchvision
>>> from torchvision import transforms
>>> image_path = './'
>>> transform = transforms.Compose([
... transforms.ToTensor()
... ])
>>> mnist_dataset = torchvision.datasets.MNIST(
... root=image_path, train=True,
... transform=transform, download=True
... )
>>> from torch.utils.data import Subset
>>> mnist_valid_dataset = Subset(mnist_dataset,
... torch.arange(10000))
>>> mnist_train_dataset = Subset(mnist_dataset,
... torch.arange(
... 10000, len(mnist_dataset)
... ))
>>> mnist_test_dataset = torchvision.datasets.MNIST(
... root=image_path, train=False,
... transform=transform, download=False
... )
MNIST 数据集附带了一个预先指定的训练和测试数据集分割方案,但我们还想从训练分区创建一个验证集分割。因此,我们使用了前 10,000 个训练示例用于验证。注意,图像并未按类标签排序,因此我们不必担心这些验证集图像是否来自相同的类别。
接下来,我们将使用 64 个图像的批量构建数据加载器,分别用于训练集和验证集:
>>> from torch.utils.data import DataLoader
>>> batch_size = 64
>>> torch.manual_seed(1)
>>> train_dl = DataLoader(mnist_train_dataset,
... batch_size,
... shuffle=True)
>>> valid_dl = DataLoader(mnist_valid_dataset,
... batch_size,
... shuffle=False)
我们读取的特征值范围是[0, 1]。此外,我们已经将图像转换为张量。标签是 0 到 9 的整数,表示十个数字。因此,我们不需要进行任何缩放或进一步的转换。
现在,在准备好数据集后,我们可以开始实现刚刚描述的 CNN 了。
使用torch.nn
模块实现 CNN
在 PyTorch 中实现 CNN 时,我们使用torch.nn
的Sequential
类来堆叠不同的层,如卷积层、池化层、dropout 以及全连接层。torch.nn
模块为每个类提供了具体的实现:nn.Conv2d
用于二维卷积层;nn.MaxPool2d
和nn.AvgPool2d
用于子采样(最大池化和平均池化);nn.Dropout
用于使用 dropout 进行正则化。我们将详细介绍每个类。
配置 PyTorch 中的 CNN 层
使用Conv2d
类构建层需要指定输出通道数(等同于输出特征图的数量或输出滤波器的数量)和内核大小。
此外,还有一些可选参数,我们可以用来配置卷积层。最常用的是步长(在x和y维度上都默认为 1)和填充参数,它控制了两个维度上的隐式填充量。更多配置参数详见官方文档:pytorch.org/docs/stable/generated/torch.nn.Conv2d.html
。
值得一提的是,通常当我们读取一幅图像时,默认的通道维度是张量数组的第一维度(或考虑批处理维度时的第二维度)。这称为 NCHW 格式,其中N代表批处理中的图像数量,C代表通道数,H和W分别代表高度和宽度。
请注意,默认情况下,Conv2D
类假定输入数据采用 NCHW 格式。(其他工具如 TensorFlow 采用 NHWC 格式。)然而,如果你遇到一些数据,其通道放置在最后一个维度,你需要交换数据的轴,将通道移到第一维度(或考虑批处理维度时的第二维度)。构建完层之后,可以通过提供四维张量进行调用,其中第一维度保留给示例的批处理,第二维度对应通道,其余两个维度是空间维度。
如我们要构建的 CNN 模型的结构所示,每个卷积层后面都跟着一个池化层进行子采样(减小特征映射的大小)。MaxPool2d
和 AvgPool2d
类分别构建最大池化层和平均池化层。kernel_size
参数确定将用于计算最大或均值操作的窗口(或邻域)的大小。此外,如前所述,stride
参数可用于配置池化层。
最后,Dropout
类将构建用于正则化的 dropout 层,其中参数 p
表示 p [dropout] 的丢弃概率,该概率用于在训练期间确定是否丢弃输入单元,正如我们之前讨论的那样。在调用该层时,可以通过 model.train()
和 model.eval()
控制其行为,以指定该调用是在训练期间还是推断期间进行的。在使用 dropout 时,交替使用这两种模式至关重要,以确保其行为正确;例如,在训练期间仅随机丢弃节点,而在评估或推断期间则不会。
在 PyTorch 中构建 CNN
现在您已经了解了这些类,我们可以构建之前图示的 CNN 模型。在以下代码中,我们将使用 Sequential
类并添加卷积和池化层:
>>> model = nn.Sequential()
>>> model.add_module(
... 'conv1',
... nn.Conv2d(
... in_channels=1, out_channels=32,
... kernel_size=5, padding=2
... )
... )
>>> model.add_module('relu1', nn.ReLU())
>>> model.add_module('pool1', nn.MaxPool2d(kernel_size=2))
>>> model.add_module(
... 'conv2',
... nn.Conv2d(
... in_channels=32, out_channels=64,
... kernel_size=5, padding=2
... )
... )
>>> model.add_module('relu2', nn.ReLU())
>>> model.add_module('pool2', nn.MaxPool2d(kernel_size=2))
到目前为止,我们已向模型添加了两个卷积层。对于每个卷积层,我们使用了大小为 5×5 的核和 padding=2
。正如前面讨论的,使用相同的填充模式保留了特征映射的空间尺寸(垂直和水平维度),使得输入和输出具有相同的高度和宽度(通道数量可能仅在使用的滤波器数量方面有所不同)。如前所述,输出特征映射的空间尺寸由以下计算得出:
其中 n 是输入特征映射的空间维度,p、m 和 s 分别表示填充、核大小和步长。我们设置 p = 2 以实现 o = i。
池化大小为 2×2,步长为 2 的最大池化层将空间尺寸减半。(请注意,如果在 MaxPool2D
中未指定 stride
参数,默认情况下设置为与池化核大小相同。)
虽然我们可以手动计算此阶段的特征映射大小,但 PyTorch 提供了一个方便的方法来为我们计算:
>>> x = torch.ones((4, 1, 28, 28))
>>> model(x).shape
torch.Size([4, 64, 7, 7])
通过将输入形状作为元组 (4, 1, 28, 28)
提供(在本示例中指定),我们计算输出形状为 (4, 64, 7, 7)
,表示具有 64 个通道和空间尺寸为 7×7 的特征映射。第一个维度对应于批处理维度,我们任意地使用了 4。
我们接下来要添加的下一层是一个完全连接的层,用于在我们的卷积和池化层之上实现分类器。这一层的输入必须具有秩为 2 的形状,即形状 [batchsize × input_units]。因此,我们需要展平先前层的输出,以满足完全连接层的需求:
>>> model.add_module('flatten', nn.Flatten())
>>> x = torch.ones((4, 1, 28, 28))
>>> model(x).shape
torch.Size([4, 3136])
正如输出形状所示,完全连接层的输入维度已经正确设置。接下来,我们将在中间添加两个完全连接层和一个 dropout 层:
>>> model.add_module('fc1', nn.Linear(3136, 1024))
>>> model.add_module('relu3', nn.ReLU())
>>> model.add_module('dropout', nn.Dropout(p=0.5))
>>> model.add_module('fc2', nn.Linear(1024, 10))
最后一个全连接层名为 'fc2'
,为 MNIST 数据集中的 10 个类标签具有 10 个输出单元。在实践中,我们通常使用 softmax 激活函数来获得每个输入示例的类成员概率,假设类别是互斥的,因此每个示例的概率总和为 1。然而,softmax 函数已经在 PyTorch 的 CrossEntropyLoss
实现内部使用,因此我们不必在上述输出层之后显式添加它。以下代码将为模型创建损失函数和优化器:
>>> loss_fn = nn.CrossEntropyLoss()
>>> optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
Adam 优化器
在这个实现中,我们使用了用于训练 CNN 模型的 torch.optim.Adam
类。Adam 优化器是一种强大的基于梯度的优化方法,适用于非凸优化和机器学习问题。两种受到 Adam 启发的流行优化方法是 RMSProp
和 AdaGrad
。
Adam 的关键优势在于从梯度矩时的运行平均值中派生更新步长的选择。请随意阅读更多关于 Adam 优化器的内容,可参考 Diederik P. Kingma 和 Jimmy Lei Ba 在 2014 年的论文 Adam: A Method for Stochastic Optimization。该文章可以在 arxiv.org/abs/1412.6980
自由获取。
现在我们可以通过定义以下函数来训练模型:
>>> def train(model, num_epochs, train_dl, valid_dl):
... loss_hist_train = [0] * num_epochs
... accuracy_hist_train = [0] * num_epochs
... loss_hist_valid = [0] * num_epochs
... accuracy_hist_valid = [0] * num_epochs
... for epoch in range(num_epochs):
... model.train()
... for x_batch, y_batch in train_dl:
... pred = model(x_batch)
... loss = loss_fn(pred, y_batch)
... loss.backward()
... optimizer.step()
... optimizer.zero_grad()
... loss_hist_train[epoch] += loss.item()*y_batch.size(0)
... is_correct = (
... torch.argmax(pred, dim=1) == y_batch
... ).float()
... accuracy_hist_train[epoch] += is_correct.sum()
... loss_hist_train[epoch] /= len(train_dl.dataset)
... accuracy_hist_train[epoch] /= len(train_dl.dataset)
...
... model.eval()
... with torch.no_grad():
... for x_batch, y_batch in valid_dl:
... pred = model(x_batch)
... loss = loss_fn(pred, y_batch)
... loss_hist_valid[epoch] += \
... loss.item()*y_batch.size(0)
... is_correct = (
... torch.argmax(pred, dim=1) == y_batch
... ).float()
... accuracy_hist_valid[epoch] += is_correct.sum()
... loss_hist_valid[epoch] /= len(valid_dl.dataset)
... accuracy_hist_valid[epoch] /= len(valid_dl.dataset)
...
... print(f'Epoch {epoch+1} accuracy: '
... f'{accuracy_hist_train[epoch]:.4f} val_accuracy: '
... f'{accuracy_hist_valid[epoch]:.4f}')
... return loss_hist_train, loss_hist_valid, \
... accuracy_hist_train, accuracy_hist_valid
注意,使用指定的训练设置 model.train()
和评估 model.eval()
将自动设置 dropout 层的模式并适当调整隐藏单元的比例,因此我们无需担心这些问题。接下来,我们将训练这个 CNN 模型,并使用我们为监控学习进度创建的验证数据集:
>>> torch.manual_seed(1)
>>> num_epochs = 20
>>> hist = train(model, num_epochs, train_dl, valid_dl)
Epoch 1 accuracy: 0.9503 val_accuracy: 0.9802
...
Epoch 9 accuracy: 0.9968 val_accuracy: 0.9892
...
Epoch 20 accuracy: 0.9979 val_accuracy: 0.9907
当完成了 20 个周期的训练后,我们可以可视化学习曲线:
>>> import matplotlib.pyplot as plt
>>> x_arr = np.arange(len(hist[0])) + 1
>>> fig = plt.figure(figsize=(12, 4))
>>> ax = fig.add_subplot(1, 2, 1)
>>> ax.plot(x_arr, hist[0], '-o', label='Train loss')
>>> ax.plot(x_arr, hist[1], '--<', label='Validation loss')
>>> ax.legend(fontsize=15)
>>> ax = fig.add_subplot(1, 2, 2)
>>> ax.plot(x_arr, hist[2], '-o', label='Train acc.')
>>> ax.plot(x_arr, hist[3], '--<',
... label='Validation acc.')
>>> ax.legend(fontsize=15)
>>> ax.set_xlabel('Epoch', size=15)
>>> ax.set_ylabel('Accuracy', size=15)
>>> plt.show()
图 14.13:训练和验证数据的损失和准确率图表
现在,我们在测试数据集上评估训练好的模型:
>>> pred = model(mnist_test_dataset.data.unsqueeze(1) / 255.)
>>> is_correct = (
... torch.argmax(pred, dim=1) == mnist_test_dataset.targets
... ).float()
>>> print(f'Test accuracy: {is_correct.mean():.4f}')
Test accuracy: 0.9914
CNN 模型达到了 99.07% 的准确率。请记住,在 第十三章 中,我们仅使用全连接(而不是卷积)层时,准确率约为 95%。
最后,我们可以通过使用torch.argmax
函数将类成员概率形式的预测结果转换为预测标签。我们将对一批 12 个示例执行此操作,并可视化输入和预测标签:
>>> fig = plt.figure(figsize=(12, 4))
>>> for i in range(12):
... ax = fig.add_subplot(2, 6, i+1)
... ax.set_xticks([]); ax.set_yticks([])
... img = mnist_test_dataset[i][0][0, :, :]
... pred = model(img.unsqueeze(0).unsqueeze(1))
... y_pred = torch.argmax(pred)
... ax.imshow(img, cmap='gray_r')
... ax.text(0.9, 0.1, y_pred.item(),
... size=15, color='blue',
... horizontalalignment='center',
... verticalalignment='center',
... transform=ax.transAxes)
>>> plt.show()
图 14.14显示了手写输入及其预测标签:
图 14.14:手写数字的预测标签
在这组绘图示例中,所有预测标签都是正确的。
我们留给读者作为练习的任务是展示一些被错误分类的数字,就像我们在第十一章,从头开始实现多层人工神经网络中所做的那样。
使用 CNN 从面部图像进行微笑分类
在本节中,我们将使用 CelebA 数据集实现一个 CNN,用于从面部图像进行微笑分类。正如您在第十二章中看到的那样,CelebA 数据集包含 202,599 张名人面部的图像。此外,每个图像还有 40 个二进制面部属性,包括名人是否微笑以及他们的年龄(年轻或老年)。
基于你目前所学的内容,本节的目标是构建并训练一个 CNN 模型,用于从这些面部图像中预测微笑属性。在这里,为了简化起见,我们将仅使用训练数据的一小部分(16,000 个训练示例)来加快训练过程。然而,为了提高泛化性能并减少在这样一个小数据集上的过拟合,我们将使用一种称为数据增强的技术。
加载 CelebA 数据集
首先,让我们加载数据,类似于我们在前一节中为 MNIST 数据集所做的方式。CelebA 数据集分为三个部分:训练数据集、验证数据集和测试数据集。接下来,我们将计算每个分区中的示例数量:
>>> image_path = './'
>>> celeba_train_dataset = torchvision.datasets.CelebA(
... image_path, split='train',
... target_type='attr', download=True
... )
>>> celeba_valid_dataset = torchvision.datasets.CelebA(
... image_path, split='valid',
... target_type='attr', download=True
... )
>>> celeba_test_dataset = torchvision.datasets.CelebA(
... image_path, split='test',
... target_type='attr', download=True
... )
>>>
>>> print('Train set:', len(celeba_train_dataset))
Train set: 162770
>>> print('Validation set:', len(celeba_valid_dataset))
Validation: 19867
>>> print('Test set:', len(celeba_test_dataset))
Test set: 19962
下载 CelebA 数据集的替代方法
CelebA 数据集相对较大(约 1.5 GB),而 torchvision
的下载链接声名狼藉。如果您在执行前述代码时遇到问题,可以手动从官方 CelebA 网站下载文件(mmlab.ie.cuhk.edu.hk/projects/CelebA.html
),或使用我们的下载链接:drive.google.com/file/d/1m8-EBPgi5MRubrm6iQjafK2QMHDBMSfJ/view?usp=sharing
。如果您使用我们的下载链接,它将下载一个 celeba.zip
文件,您需要在运行代码的当前目录中解压此文件夹。此外,在下载并解压 celeba
文件夹后,您需要使用设置 download=False
而不是 download=True
重新运行上面的代码。如果您在使用此方法时遇到问题,请不要犹豫,打开一个新问题或在 github.com/rasbt/machine-learning-book
上开始讨论,以便我们为您提供额外的信息。
接下来,我们将讨论数据增强作为提高深度神经网络性能的一种技术。
图像转换和数据增强
数据增强总结了一系列技术,用于处理训练数据有限的情况。例如,某些数据增强技术允许我们修改或甚至人为合成更多数据,从而通过减少过拟合来提升机器或深度学习模型的性能。虽然数据增强不仅适用于图像数据,但有一组独特适用于图像数据的转换技术,例如裁剪图像的部分、翻转、以及调整对比度、亮度和饱和度。让我们看看一些这些转换,这些转换可以通过 torchvision.transforms
模块获得。在下面的代码块中,我们将首先从 celeba_train_dataset
数据集获取五个示例,并应用五种不同类型的转换:1)将图像裁剪到边界框,2)水平翻转图像,3)调整对比度,4)调整亮度,以及 5)中心裁剪图像并将结果图像调整回其原始大小(218, 178)。在下面的代码中,我们将可视化这些转换的结果,将每个结果显示在单独的列中进行比较:
>>> fig = plt.figure(figsize=(16, 8.5))
>>> ## Column 1: cropping to a bounding-box
>>> ax = fig.add_subplot(2, 5, 1)
>>> img, attr = celeba_train_dataset[0]
>>> ax.set_title('Crop to a \nbounding-box', size=15)
>>> ax.imshow(img)
>>> ax = fig.add_subplot(2, 5, 6)
>>> img_cropped = transforms.functional.crop(img, 50, 20, 128, 128)
>>> ax.imshow(img_cropped)
>>>
>>> ## Column 2: flipping (horizontally)
>>> ax = fig.add_subplot(2, 5, 2)
>>> img, attr = celeba_train_dataset[1]
>>> ax.set_title('Flip (horizontal)', size=15)
>>> ax.imshow(img)
>>> ax = fig.add_subplot(2, 5, 7)
>>> img_flipped = transforms.functional.hflip(img)
>>> ax.imshow(img_flipped)
>>>
>>> ## Column 3: adjust contrast
>>> ax = fig.add_subplot(2, 5, 3)
>>> img, attr = celeba_train_dataset[2]
>>> ax.set_title('Adjust constrast', size=15)
>>> ax.imshow(img)
>>> ax = fig.add_subplot(2, 5, 8)
>>> img_adj_contrast = transforms.functional.adjust_contrast(
... img, contrast_factor=2
... )
>>> ax.imshow(img_adj_contrast)
>>>
>>> ## Column 4: adjust brightness
>>> ax = fig.add_subplot(2, 5, 4)
>>> img, attr = celeba_train_dataset[3]
>>> ax.set_title('Adjust brightness', size=15)
>>> ax.imshow(img)
>>> ax = fig.add_subplot(2, 5, 9)
>>> img_adj_brightness = transforms.functional.adjust_brightness(
... img, brightness_factor=1.3
... )
>>> ax.imshow(img_adj_brightness)
>>>
>>> ## Column 5: cropping from image center
>>> ax = fig.add_subplot(2, 5, 5)
>>> img, attr = celeba_train_dataset[4]
>>> ax.set_title('Center crop\nand resize', size=15)
>>> ax.imshow(img)
>>> ax = fig.add_subplot(2, 5, 10)
>>> img_center_crop = transforms.functional.center_crop(
... img, [0.7*218, 0.7*178]
... )
>>> img_resized = transforms.functional.resize(
... img_center_crop, size=(218, 178)
... )
>>> ax.imshow(img_resized)
>>> plt.show()
图 14.15 展示了结果:
图 14.15:不同的图像转换
在图 14.15中,第一行显示了原始图像,第二行显示了它们的变换版本。请注意,对于第一个变换(最左侧列),边界框由四个数字指定:边界框的左上角坐标(这里 x=20,y=50)以及框的宽度和高度(宽度=128,高度=128)。还请注意,PyTorch(以及其他软件包如 imageio
)加载的图像的原点(位于位置 (0, 0) 处的坐标)是图像的左上角。
前面代码块中的变换是确定性的。然而,建议在模型训练期间对所有这些变换进行随机化。例如,可以从图像中随机裁剪一个随机边界框(其中上左角的坐标被随机选择),可以以概率 0.5 随机沿水平或垂直轴翻转图像,或者可以随机更改图像的对比度,其中 contrast_factor
是从值范围内随机选择的,但服从均匀分布。此外,我们还可以创建这些变换的流水线。
例如,我们可以首先随机裁剪图像,然后随机翻转它,最后将其调整为所需大小。代码如下(由于涉及随机元素,我们设置了随机种子以确保可重现性):
>>> torch.manual_seed(1)
>>> fig = plt.figure(figsize=(14, 12))
>>> for i, (img, attr) in enumerate(celeba_train_dataset):
... ax = fig.add_subplot(3, 4, i*4+1)
... ax.imshow(img)
... if i == 0:
... ax.set_title('Orig.', size=15)
...
... ax = fig.add_subplot(3, 4, i*4+2)
... img_transform = transforms.Compose([
... transforms.RandomCrop([178, 178])
... ])
... img_cropped = img_transform(img)
... ax.imshow(img_cropped)
... if i == 0:
... ax.set_title('Step 1: Random crop', size=15)
...
... ax = fig.add_subplot(3, 4, i*4+3)
... img_transform = transforms.Compose([
... transforms.RandomHorizontalFlip()
... ])
... img_flip = img_transform(img_cropped)
... ax.imshow(img_flip)
... if i == 0:
... ax.set_title('Step 2: Random flip', size=15)
...
... ax = fig.add_subplot(3, 4, i*4+4)
... img_resized = transforms.functional.resize(
... img_flip, size=(128, 128)
... )
... ax.imshow(img_resized)
... if i == 0:
... ax.set_title('Step 3: Resize', size=15)
... if i == 2:
... break
>>> plt.show()
图 14.16 展示了三个示例图像的随机变换:
图 14.16: 随机图像变换
请注意,每次迭代这三个示例时,由于随机变换,我们会得到略有不同的图像。
为方便起见,我们可以定义变换函数以在数据集加载期间使用此流程进行数据增强。在下面的代码中,我们将定义函数get_smile
,它将从 'attributes'
列表中提取笑脸标签:
>>> get_smile = lambda attr: attr[18]
我们将定义transform_train
函数,它将生成变换后的图像(我们将首先随机裁剪图像,然后随机翻转它,最后将其调整为所需大小 64×64):
>>> transform_train = transforms.Compose([
... transforms.RandomCrop([178, 178]),
... transforms.RandomHorizontalFlip(),
... transforms.Resize([64, 64]),
... transforms.ToTensor(),
... ])
我们仅对训练样本应用数据增强,而不应用于验证或测试图像。验证或测试集的代码如下(我们首先简单地裁剪图像,然后将其调整为所需大小 64×64):
>>> transform = transforms.Compose([
... transforms.CenterCrop([178, 178]),
... transforms.Resize([64, 64]),
... transforms.ToTensor(),
... ])
现在,为了看到数据增强的效果,让我们将transform_train
函数应用于我们的训练数据集,并迭代数据集五次:
>>> from torch.utils.data import DataLoader
>>> celeba_train_dataset = torchvision.datasets.CelebA(
... image_path, split='train',
... target_type='attr', download=False,
... transform=transform_train, target_transform=get_smile
... )
>>> torch.manual_seed(1)
>>> data_loader = DataLoader(celeba_train_dataset, batch_size=2)
>>> fig = plt.figure(figsize=(15, 6))
>>> num_epochs = 5
>>> for j in range(num_epochs):
... img_batch, label_batch = next(iter(data_loader))
... img = img_batch[0]
... ax = fig.add_subplot(2, 5, j + 1)
... ax.set_xticks([])
... ax.set_yticks([])
... ax.set_title(f'Epoch {j}:', size=15)
... ax.imshow(img.permute(1, 2, 0))
...
... img = img_batch[1]
... ax = fig.add_subplot(2, 5, j + 6)
... ax.set_xticks([])
... ax.set_yticks([])
... ax.imshow(img.permute(1, 2, 0))
>>> plt.show()
图 14.17 展示了两个示例图像的五种数据增强结果:
图 14.17: 五种图像变换的结果
接下来,我们将对验证和测试数据集应用transform
函数:
>>> celeba_valid_dataset = torchvision.datasets.CelebA(
... image_path, split='valid',
... target_type='attr', download=False,
... transform=transform, target_transform=get_smile
... )
>>> celeba_test_dataset = torchvision.datasets.CelebA(
... image_path, split='test',
... target_type='attr', download=False,
... transform=transform, target_transform=get_smile
... )
此外,我们将不再使用所有可用的训练和验证数据,而是从中选择 16000 个训练示例和 1000 个验证示例,因为我们的目标是有意地使用小数据集来训练我们的模型:
>>> from torch.utils.data import Subset
>>> celeba_train_dataset = Subset(celeba_train_dataset,
... torch.arange(16000))
>>> celeba_valid_dataset = Subset(celeba_valid_dataset,
... torch.arange(1000))
>>> print('Train set:', len(celeba_train_dataset))
Train set: 16000
>>> print('Validation set:', len(celeba_valid_dataset))
Validation set: 1000
现在,我们可以为三个数据集创建数据加载器:
>>> batch_size = 32
>>> torch.manual_seed(1)
>>> train_dl = DataLoader(celeba_train_dataset,
... batch_size, shuffle=True)
>>> valid_dl = DataLoader(celeba_valid_dataset,
... batch_size, shuffle=False)
>>> test_dl = DataLoader(celeba_test_dataset,
... batch_size, shuffle=False)
现在数据加载器已经准备好,我们将在下一节中开发一个 CNN 模型,并进行训练和评估。
训练 CNN 笑容分类器
到目前为止,使用torch.nn
模块构建模型并训练应该是很简单的。我们的 CNN 的设计如下:CNN 模型接收大小为 3×64×64 的输入图像(图像具有三个色彩通道)。
输入数据通过四个卷积层进行处理,使用 3×3 的核大小和 1 的填充以生成 32、64、128 和 256 个特征图,用于进行相同填充。前三个卷积层后面跟着最大池化,P[2×2]。还包括两个 dropout 层用于正则化:
>>> model = nn.Sequential()
>>> model.add_module(
... 'conv1',
... nn.Conv2d(
... in_channels=3, out_channels=32,
... kernel_size=3, padding=1
... )
... )
>>> model.add_module('relu1', nn.ReLU())
>>> model.add_module('pool1', nn.MaxPool2d(kernel_size=2))
>>> model.add_module('dropout1', nn.Dropout(p=0.5))
>>>
>>> model.add_module(
... 'conv2',
... nn.Conv2d(
... in_channels=32, out_channels=64,
... kernel_size=3, padding=1
... )
... )
>>> model.add_module('relu2', nn.ReLU())
>>> model.add_module('pool2', nn.MaxPool2d(kernel_size=2))
>>> model.add_module('dropout2', nn.Dropout(p=0.5))
>>>
>>> model.add_module(
... 'conv3',
... nn.Conv2d(
... in_channels=64, out_channels=128,
... kernel_size=3, padding=1
... )
... )
>>> model.add_module('relu3', nn.ReLU())
>>> model.add_module('pool3', nn.MaxPool2d(kernel_size=2))
>>>
>>> model.add_module(
... 'conv4',
... nn.Conv2d(
... in_channels=128, out_channels=256,
... kernel_size=3, padding=1
... )
... )
>>> model.add_module('relu4', nn.ReLU())
让我们看看在使用一个玩具批次输入(任意四张图像)后,应用这些层后输出特征图的形状:
>>> x = torch.ones((4, 3, 64, 64))
>>> model(x).shape
torch.Size([4, 256, 8, 8])
有 256 个大小为 8×8 的特征图(或通道)。现在,我们可以添加一个全连接层以得到具有单个单元的输出层。如果我们将特征图进行 reshape(展平),这个全连接层的输入单元数将为 8 × 8 × 256 = 16,384。或者,让我们考虑一个新层,称为全局 平均池化,它分别计算每个特征图的平均值,从而将隐藏单元减少到 256。然后我们可以添加一个全连接层。虽然我们没有明确讨论全局平均池化,但它在概念上与其他池化层非常相似。实际上,全局平均池化可以被视为当池化大小等于输入特征图大小时平均池化的特殊情况。
要理解这一点,考虑图 14.18,显示了一个输入特征图的示例,形状为batchsize×8×64×64。通道编号为k =0, 1, …, 7。全局平均池化操作计算每个通道的平均值,因此输出将具有形状[batchsize×8]。在这之后,我们将挤压全局平均池化层的输出。
如果不压缩输出,形状将会是[batchsize×8×1×1],因为全局平均池化将 64×64 的空间维度减少到 1×1:
图 14.18: 输入特征图
因此,在我们的情况下,该层之前的特征图形状为[batchsize×256×8×8],我们预计输出将会有 256 个单元,也就是输出的形状将为[batchsize×256]。让我们添加这一层并重新计算输出形状,以验证这一点是否正确:
>>> model.add_module('pool4', nn.AvgPool2d(kernel_size=8))
>>> model.add_module('flatten', nn.Flatten())
>>> x = torch.ones((4, 3, 64, 64))
>>> model(x).shape
torch.Size([4, 256])
最后,我们可以添加一个全连接层以获得单个输出单元。在这种情况下,我们可以指定激活函数为'sigmoid'
:
>>> model.add_module('fc', nn.Linear(256, 1))
>>> model.add_module('sigmoid', nn.Sigmoid())
>>> x = torch.ones((4, 3, 64, 64))
>>> model(x).shape
torch.Size([4, 1])
>>> model
Sequential(
(conv1): Conv2d(3, 32, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(relu1): ReLU()
(pool1): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
(dropout1): Dropout(p=0.5, inplace=False)
(conv2): Conv2d(32, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(relu2): ReLU()
(pool2): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
(dropout2): Dropout(p=0.5, inplace=False)
(conv3): Conv2d(64, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(relu3): ReLU()
(pool3): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
(conv4): Conv2d(128, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(relu4): ReLU()
(pool4): AvgPool2d(kernel_size=8, stride=8, padding=0)
(flatten): Flatten(start_dim=1, end_dim=-1)
(fc): Linear(in_features=256, out_features=1, bias=True)
(sigmoid): Sigmoid()
)
下一步是创建损失函数和优化器(再次使用 Adam 优化器)。对于具有单个概率输出的二元分类,我们使用BCELoss
作为损失函数:
>>> loss_fn = nn.BCELoss()
>>> optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
现在我们可以通过定义以下函数来训练模型:
>>> def train(model, num_epochs, train_dl, valid_dl):
... loss_hist_train = [0] * num_epochs
... accuracy_hist_train = [0] * num_epochs
... loss_hist_valid = [0] * num_epochs
... accuracy_hist_valid = [0] * num_epochs
... for epoch in range(num_epochs):
... model.train()
... for x_batch, y_batch in train_dl:
... pred = model(x_batch)[:, 0]
... loss = loss_fn(pred, y_batch.float())
... loss.backward()
... optimizer.step()
... optimizer.zero_grad()
... loss_hist_train[epoch] += loss.item()*y_batch.size(0)
... is_correct = ((pred>=0.5).float() == y_batch).float()
... accuracy_hist_train[epoch] += is_correct.sum()
... loss_hist_train[epoch] /= len(train_dl.dataset)
... accuracy_hist_train[epoch] /= len(train_dl.dataset)
...
... model.eval()
... with torch.no_grad():
... for x_batch, y_batch in valid_dl:
... pred = model(x_batch)[:, 0]
... loss = loss_fn(pred, y_batch.float())
... loss_hist_valid[epoch] += \
... loss.item() * y_batch.size(0)
... is_correct = \
... ((pred>=0.5).float() == y_batch).float()
... accuracy_hist_valid[epoch] += is_correct.sum()
... loss_hist_valid[epoch] /= len(valid_dl.dataset)
... accuracy_hist_valid[epoch] /= len(valid_dl.dataset)
...
... print(f'Epoch {epoch+1} accuracy: '
... f'{accuracy_hist_train[epoch]:.4f} val_accuracy: '
... f'{accuracy_hist_valid[epoch]:.4f}')
... return loss_hist_train, loss_hist_valid, \
... accuracy_hist_train, accuracy_hist_valid
接下来,我们将对这个 CNN 模型进行 30 个 epochs 的训练,并使用我们创建的验证数据集来监控学习进度:
>>> torch.manual_seed(1)
>>> num_epochs = 30
>>> hist = train(model, num_epochs, train_dl, valid_dl)
Epoch 1 accuracy: 0.6286 val_accuracy: 0.6540
...
Epoch 15 accuracy: 0.8544 val_accuracy: 0.8700
...
Epoch 30 accuracy: 0.8739 val_accuracy: 0.8710
现在让我们可视化学习曲线,并比较每个 epoch 后的训练和验证损失和准确率:
>>> x_arr = np.arange(len(hist[0])) + 1
>>> fig = plt.figure(figsize=(12, 4))
>>> ax = fig.add_subplot(1, 2, 1)
>>> ax.plot(x_arr, hist[0], '-o', label='Train loss')
>>> ax.plot(x_arr, hist[1], '--<', label='Validation loss')
>>> ax.legend(fontsize=15)
>>> ax = fig.add_subplot(1, 2, 2)
>>> ax.plot(x_arr, hist[2], '-o', label='Train acc.')
>>> ax.plot(x_arr, hist[3], '--<',
... label='Validation acc.')
>>> ax.legend(fontsize=15)
>>> ax.set_xlabel('Epoch', size=15)
>>> ax.set_ylabel('Accuracy', size=15)
>>> plt.show()
图 14.19:训练和验证结果的比较
一旦我们对学习曲线满意,我们可以在保留测试数据集上评估模型:
>>> accuracy_test = 0
>>> model.eval()
>>> with torch.no_grad():
... for x_batch, y_batch in test_dl:
... pred = model(x_batch)[:, 0]
... is_correct = ((pred>=0.5).float() == y_batch).float()
... accuracy_test += is_correct.sum()
>>> accuracy_test /= len(test_dl.dataset)
>>> print(f'Test accuracy: {accuracy_test:.4f}')
Test accuracy: 0.8446
最后,我们已经知道如何在一些测试示例上获得预测结果。在接下来的代码中,我们将从预处理的测试数据集(test_dl
)的最后一个批次中取出 10 个示例的小子集。然后,我们将计算每个示例属于类别 1 的概率(基于 CelebA 中提供的标签为smile),并将示例与它们的真实标签和预测概率可视化:
>>> pred = model(x_batch)[:, 0] * 100
>>> fig = plt.figure(figsize=(15, 7))
>>> for j in range(10, 20):
... ax = fig.add_subplot(2, 5, j-10+1)
... ax.set_xticks([]); ax.set_yticks([])
... ax.imshow(x_batch[j].permute(1, 2, 0))
... if y_batch[j] == 1:
... label='Smile'
... else:
... label = 'Not Smile'
... ax.text(
... 0.5, -0.15,
... f'GT: {label:s}\nPr(Smile)={pred[j]:.0f}%',
... size=16,
... horizontalalignment='center',
... verticalalignment='center',
... transform=ax.transAxes
... )
>>> plt.show()
在图 14.20中,你可以看到 10 个示例图像,以及它们的真实标签以及它们属于类别 1(笑脸)的概率:
图 14.20:图像标签及其属于类别 1 的概率
在每张图像下方提供了类别 1(即smile,根据 CelebA)的概率。如你所见,我们训练的模型在这组 10 个测试示例上完全准确。
作为一个可选练习,鼓励你尝试使用整个训练数据集,而不是我们创建的小子集。此外,你可以改变或修改 CNN 的架构。例如,你可以改变不同卷积层中的 dropout 概率和滤波器数量。此外,你可以用全连接层替换全局平均池化。如果你在本章使用我们训练的 CNN 架构和整个训练数据集,应该能够达到 90%以上的准确率。
概要
在本章中,我们学习了 CNN 及其主要组件。我们从卷积操作开始,看了 1D 和 2D 的实现。然后,我们涵盖了另一种常见 CNN 架构中的层类型:子采样或所谓的池化层。我们主要关注了两种最常见的池化形式:最大池化和平均池化。
接下来,将所有这些个别概念结合起来,我们使用torch.nn
模块实现了深度 CNN。我们实现的第一个网络适用于已经熟悉的 MNIST 手写数字识别问题。
接着,我们在一个更复杂的数据集上实现了第二个卷积神经网络(CNN),其中包含面部图像,并训练了 CNN 进行微笑分类。在此过程中,您还了解了数据增强以及我们可以使用torchvision.transforms
模块对面部图像应用的不同转换。
在接下来的章节中,我们将转向循环神经网络(RNNs)。RNNs 用于学习序列数据的结构,并且它们具有一些迷人的应用,包括语言翻译和图像字幕。
加入我们书籍的 Discord 空间
加入本书的 Discord 工作空间,与作者进行每月的问答会话: