深度学习是一类机器学习方法,它彻底改变了计算机/机器在现实生活中构建自动化解决方案的方式,以前的技术难以实现这些效果。深度学习利用大量数据来学习输入与输出之间的复杂非线性关系,如图1.1所示,这些输入和输出的示例可能包括以下几种:
- 输入:一张带有文字的图像;输出:文字
- 输入:文字;输出:自然语音朗读该文字
- 输入:自然语音朗读文字;输出:转录的文字
等等。(上述示例有意排除了表格形式的输入数据,因为在这种数据上,梯度提升树(如XGBoost、LightGBM、CatBoost)仍然优于深度学习。)
深度神经网络涉及大量的数学计算、线性代数方程、非线性函数以及各种优化算法。如果我们要使用像 Python 这样的编程语言从头开始构建和训练一个深度神经网络,就需要编写所有必要的方程、函数和优化计划。此外,代码还必须编写得足够高效,以便能够加载大量数据,并在合理的时间内完成训练。这意味着每次我们构建一个深度学习应用程序时,都需要实现许多底层的细节。
为了抽象出这些细节,近年来开发了许多深度学习库,如 Theano 和 TensorFlow 等。PyTorch 也是其中一个基于 Python 的深度学习库,可以用来构建深度学习模型。
TensorFlow 由谷歌于 2015 年末推出,作为开源的深度学习 Python(和 C++)库,彻底改变了应用深度学习的领域。2016 年,Facebook 推出了自己的开源深度学习库,命名为 Torch。Torch 最初与一种称为 Lua 的脚本语言一起使用,不久后,出现了相应的 Python 版本,称为 PyTorch。大约在同一时间,微软也发布了自己的库——CNTK。在激烈的竞争中,PyTorch 迅速成长,成为使用最广泛的深度学习库之一。
本书旨在成为解决一些最先进的深度学习问题的实践资源,介绍如何使用复杂的深度学习架构解决这些问题,以及如何有效地使用 PyTorch 来构建、训练和评估这些复杂模型。
虽然本书以 PyTorch 为核心,但也涵盖了一些最新和最先进的深度学习模型。本书面向具备 Python 知识的数据科学家、机器学习工程师或研究人员,最好是那些之前使用过 PyTorch 的人。对于那些不熟悉 PyTorch 或熟悉 TensorFlow 但不熟悉 PyTorch 的读者,我建议花更多的时间在本章以及其他资源上,如 Torch 网站上的基础教程,以先掌握 PyTorch 的基础知识。
由于本书的实践性质,强烈建议你在电脑上自己尝试每章中的例子,以熟练掌握编写 PyTorch 代码的技能。我们将从本章的介绍开始,随后探索各种深度学习问题和模型架构,以展示 PyTorch 提供的各种功能。
本章将回顾一些深度学习的概念,并简要概述 PyTorch 库。对于那些熟悉 TensorFlow 并希望过渡到 PyTorch 的读者,我们还将在本章中看到 PyTorch 的 API 在多个方面与 TensorFlow 的不同之处。本章的最后,我们将通过一个实践练习来训练一个使用 PyTorch 的深度学习模型。
本章将涵盖以下主题:
- 深度学习的基础回顾
- 探索与 TensorFlow 对比下的 PyTorch 库
- 使用 PyTorch 训练神经网络
深度学习的基础回顾
神经网络是机器学习方法的一种子类型,灵感来源于生物大脑的结构和功能,如图 1.2 所示的生物神经元。在神经网络中,每个计算单元(类比称为神经元)以层级的方式与其他神经元相连接。当这样的层数超过两层时,所形成的神经网络就被称为深度神经网络(DNN)。这种模型通常被称为深度学习模型。
深度学习模型已经被证明优于其他经典的机器学习模型,因为它们能够学习输入数据与输出(即真实值)之间的高度复杂关系。近年来,深度学习受到了广泛关注,而且这确实是有原因的,主要有以下两个原因:
- 强大计算机的可用性,包括GPU
- 大量数据的可用性
由于摩尔定律的影响,即计算机的处理能力每两年翻一番,我们现在正处于一个能够在现实且相对较短的时间内训练数千层深度学习模型的时代。同时,随着数字设备的使用呈指数增长,我们的数字足迹迅速扩展,导致全球每时每刻都在产生海量数据。
因此,能够训练深度学习模型来处理一些最困难的认知任务,这些任务要么在以前是不可处理的,要么通过其他机器学习技术得到的解决方案是次优的。
深度学习,或者更广泛地说,神经网络,相较于经典的机器学习模型还有另一个优势。通常,在基于经典机器学习的方法中,特征工程在训练模型的整体性能中起着至关重要的作用。然而,深度学习模型消除了手动构建特征的需求。在大量数据的支持下,深度学习模型无需手工设计特征即可表现出色,甚至可以超越传统的机器学习模型。
下图显示了深度学习模型如何比经典机器学习模型更好地利用大量数据:
如图所示,深度学习的性能在数据集规模达到一定程度之前未必明显优于其他模型。然而,随着数据规模的进一步增加,深度神经网络开始超越非深度学习模型的表现。
可以基于多种不同的神经网络架构来构建深度学习模型,这些架构在多年间得到了发展。不同架构之间的主要区别因素在于神经网络中使用的层的类型和组合。
一些知名的层包括以下几种:
- 全连接层或线性层:在全连接层中,如下图所示,所有先前层的神经元都与后续层的所有神经元相连接:
这个例子展示了两个连续的全连接层,分别包含 N1 和 N2 个神经元。全连接层是许多深度学习分类器的基本单元,事实上,大多数分类器都依赖于全连接层。
- 卷积层:下图展示了一个卷积层,其中卷积核(或滤波器)在输入数据上进行卷积操作:
卷积层是卷积神经网络(CNNs)的基本单元,CNN 是解决计算机视觉问题最有效的模型。
- 循环层:下图展示了一个循环层。尽管它看起来与全连接层相似,但关键的区别在于循环连接(用粗体弯曲箭头标示):
循环层相较于全连接层具有一个优势,那就是它们具备记忆能力,这在处理需要记住过去输入和当前输入的序列数据时非常有用。
- 反卷积层(卷积层的逆操作):与卷积层相反,反卷积层的工作方式如下图所示:
该层在空间上扩展输入数据,因此在旨在生成或重建图像的模型中至关重要。
- 池化层:下图展示了最大池化层,它可能是最广泛使用的池化层类型:
这是一个最大池化层,它从输入的 2x2 大小的子区域中提取每个子区域内的最大值。其他形式的池化包括最小池化和平均池化。基于前述层的一些著名架构如下图所示:
更全面的神经网络架构集可以在 [1] 中找到。
除了层的类型及其在网络中的连接方式外,激活函数和优化计划等其他因素也定义了模型。
激活函数
激活函数对于神经网络至关重要,因为它们增加了非线性特性。如果没有这些特性,无论我们添加多少层,整个神经网络都会简化为一个简单的线性模型。这里列出不同类型的激活函数,它们基本上是不同的非线性数学函数。
一些流行的激活函数如下:
- Sigmoid:Sigmoid(或逻辑)函数的表达式如下:
该函数的图形形式如下所示:
如图所示,Sigmoid 函数接受一个数值 xxx 作为输入,并输出一个位于 (0, 1) 范围内的值 yyy。
- TanH:TanH 函数的表达式如下:
该函数的图形形式如下所示:
与 Sigmoid 函数相反,TanH 激活函数的输出 yyy 在 -1 到 1 之间变化。因此,这种激活函数在需要同时输出正值和负值的情况下非常有用。
- 修正线性单元 (ReLU):ReLU 是比前两者更新的激活函数,表达式如下:
该函数的图形形式如下所示:
与 Sigmoid 和 TanH 激活函数相比,ReLU 的一个显著特点是,当输入值大于 0 时,输出值会随输入值不断增长。这防止了该函数的梯度像前两个激活函数那样逐渐趋近于 0。不过,当输入为负值时,输出和梯度都会变为 0。
- Leaky ReLU:ReLU 会完全抑制任何负输入,通过输出 0 来处理。然而,在某些情况下,我们可能希望也处理负输入。Leaky ReLU 提供了处理负输入的选项,通过输出输入负值的一部分 kkk 来实现。这个部分 kkk 是该激活函数的一个参数,数学表达式如下:
下图展示了 Leaky ReLU 的输入-输出关系:
激活函数是深度学习中一个正在积极发展的研究领域。这里无法列出所有的激活函数,但我鼓励你去了解该领域的最新进展。许多激活函数只是对本节中提到的函数的细微修改。
优化计划
到目前为止,我们讨论了如何构建神经网络结构。为了训练神经网络,我们需要采用一个优化计划。与任何其他基于参数的机器学习模型一样,深度学习模型通过调整其参数来进行训练。参数的调整通过反向传播过程进行,其中神经网络的最终或输出层产生一个损失值。这个损失是通过损失函数计算的,损失函数以神经网络最终层的输出和相应的真实目标值为输入。然后,这个损失通过使用梯度下降法和微分的链式法则反向传播到前一层。
为了最小化损失,每一层的参数或权重都会相应地被修改。修改的幅度由一个系数决定,该系数在 0 到 1 之间变化,也称为学习率。我们称之为优化计划的这一整套更新神经网络权重的过程,对模型的训练效果有显著影响。因此,在这一领域进行了大量研究,而且研究仍在继续。以下是一些流行的优化计划:
- 随机梯度下降法(SGD) :它以如下方式更新模型参数:
θ 是模型的参数,XXX 和 yyy 分别是输入训练数据和对应的标签。LLL 是损失函数,η\etaη 是学习率。SGD 对每一对训练样本(XXX, yyy)执行此更新。它的一个变种——小批量梯度下降法(mini-batch gradient descent) ——对每 kkk 个样本(其中 kkk 是批量大小)进行更新。梯度是为整个小批量一起计算的。另一个变种是批量梯度下降法(batch gradient descent) ,它通过计算整个数据集的梯度来执行参数更新。
- Adagrad:在前面的优化计划中,我们对模型的所有参数使用了相同的学习率。然而,不同的参数可能需要以不同的速度进行更新,特别是在稀疏数据的情况下,一些参数在特征提取中的作用比其他参数更加积极。Adagrad 引入了按参数更新的概念,如下所示:
在这里,我们使用下标 iii 来表示第 iii 个参数,使用上标 ttt 来表示梯度下降迭代的时间步长 ttt。∑t=0tgi2\sum_{t=0}^{t} g_i^2∑t=0tgi2 表示从时间步长 0 到 ttt 的第 iii 个参数的平方梯度和。 ϵ\epsilonϵ 表示一个小的数值,用于避免除以零的情况。通过将全局学习率 η\etaη 除以 ∑t=0tgi2\sum_{t=0}^{t} g_i^2∑t=0tgi2 的平方根,可以确保对频繁变化的参数进行较小的更新,反之亦然。
- Adadelta:在 Adagrad 中,学习率的分母是一个逐渐增加的值,因为每个时间步长都增加了平方项。这导致学习率衰减到极小的值。为了解决这个问题,Adadelta 引入了只计算前几个时间步长的平方梯度和的概念。实际上,我们可以将其表示为过去梯度的运行衰减平均值:
这里的 ρ\rhoρ 是我们希望为之前的平方梯度和选择的衰减因子。通过这种公式,我们可以确保由于衰减平均值的作用,平方梯度和不会积累到一个很大的值。一旦定义了 ρ\rhoρ,我们可以使用公式 1.6 来定义 Adadelta 的更新步骤。
然而,如果我们仔细观察公式 1.6,会发现均方根梯度并不是一个无量纲量,因此理想情况下不应将其用作学习率的系数。为了解决这个问题,我们定义了另一个运行平均值,这次是用于平方参数更新的平均值。首先,让我们定义参数更新:
然后,类似于公式 1.7,我们可以将参数更新的平方和定义如下:
在这里,SSPU 是参数更新的平方和。一旦我们有了这个,我们就可以使用最终的 Adadelta 方程来调整公式 1.6 中的维度问题:
明显的是,最终的 Adadelta 方程不需要任何学习率。然而,你仍然可以提供一个学习率作为乘数。因此,对于这个优化策略,唯一的强制性超参数是衰减因子:
-
RMSprop:在讨论 Adadelta 时,我们隐含地讨论了 RMSprop 的内部工作原理,因为这两者非常相似。唯一的不同是 RMSprop 不会调整维度问题,因此更新方程保持与方程 1.6 相同,其中 是从方程 1.7 获得的。这实际上意味着,在 RMSprop 的情况下,我们确实需要指定一个基础学习率以及一个衰减因子。
-
自适应矩估计(Adam):这是一种计算每个参数自定义学习率的优化策略。就像 Adadelta 和 RMSprop 一样,Adam 也使用之前平方梯度的衰减平均值,如方程 1.7 所示。然而,它还使用之前梯度值的衰减平均值:
SG 和 SSG 在数学上分别等同于估计梯度的一阶矩和二阶矩,因此这个方法被称为自适应矩估计。通常, 和 的值接近 1,在这种情况下,SG 和 SSG 的初始值可能会被推向零。为了对抗这种情况,这两个量通过偏差修正进行重新公式化:
并且
一旦它们被定义,参数更新可以表示如下:
基本上,方程右侧最极端的梯度被替换为梯度的衰减平均值。值得注意的是,Adam 优化涉及三个超参数——基础学习率,以及梯度和平方梯度的两个衰减率。Adam 是近年来在训练复杂深度学习模型方面最成功的优化策略之一。
那么,我们该使用哪种优化器呢?这要根据具体情况来决定。如果我们处理的是稀疏数据,那么自适应优化器(第 2 到第 5 种)将具有优势,因为它们提供了逐参数的学习率更新。如前所述,在处理稀疏数据时,不同的参数可能以不同的速度更新,因此逐参数的自定义学习率机制可以极大地帮助模型达到最优解。SGD 也可能找到一个不错的解决方案,但训练时间会长得多。在自适应优化器中,Adagrad 由于学习率分母单调递增的特点,会面临学习率消失的问题。
RMSprop、Adadelta 和 Adam 在各种深度学习任务中的性能都非常接近。RMSprop 和 Adadelta 大致相似,除了 RMSprop 使用基础学习率,而 Adadelta 使用之前参数更新的衰减平均值。Adam 略有不同,因为它还包括梯度的一阶矩计算,并考虑了偏差修正。总体而言,Adam 在其他条件相同的情况下可能是更好的选择。在本书的练习中,我们将使用一些这些优化策略。可以随意切换到其他优化器,以观察以下方面的变化:
- 模型训练时间和轨迹(收敛)
- 最终模型性能
在接下来的章节中,我们将使用这些架构、层、激活函数和优化策略来解决不同类型的机器学习问题,并借助 PyTorch 完成。在本章的示例中,我们将创建一个包含卷积层、线性层、最大池化层和 Dropout 层的卷积神经网络。最终层使用 Log-Softmax,所有其他层使用 ReLU 作为激活函数。模型使用固定学习率为 0.5 的 Adadelta 优化器进行训练。
对比 PyTorch 库与 TensorFlow
PyTorch 是一个基于 Torch 库的 Python 机器学习库,广泛用于深度学习,不仅在研究中,还用于构建工业应用。它主要由 Meta 开发。PyTorch 是与另一种知名深度学习库 TensorFlow 竞争的库,后者由 Google 开发。最初,两者的区别在于 PyTorch 基于即时执行(eager execution),而 TensorFlow 基于图形化的延迟执行(deferred execution)。不过,现在 TensorFlow 也提供了即时执行模式。
即时执行基本上是一种命令式编程模式,其中数学操作会立即计算。延迟执行模式则会将所有操作存储在计算图中,操作不会立即计算,然后在稍后的时间里评估整个图。即时执行被认为具有一些优势,如直观的流程、易于调试以及减少了代码的搭建工作。
PyTorch 不仅仅是一个深度学习库。凭借其类似 NumPy 的语法/接口,它提供了强大的张量计算能力,并利用 GPU 进行加速。那么,什么是张量呢?张量是计算单元,非常类似于 NumPy 数组,只不过它们也可以在 GPU 上使用,以加速计算。
凭借加速计算和创建动态计算图的能力,PyTorch 提供了一个完整的深度学习框架。除此之外,它在本质上非常 Pythonic,这使得 PyTorch 用户能够利用 Python 提供的所有特性,包括广泛的数据科学生态系统。
在这一部分,我们将扩展张量的概念以及它在 PyTorch 中如何实现及其所有属性。我们还将查看一些有用的 PyTorch 模块,这些模块扩展了各种功能,帮助加载数据、构建模型以及在模型训练过程中指定优化策略。我们将这些 PyTorch API 与 TensorFlow 等效的 API 进行比较,以理解这两个库在根本层面上的实现差异。
张量模块
如前所述,张量在概念上类似于 NumPy 数组。张量是一个 n 维数组,我们可以对其进行数学运算,通过 GPU 加速计算,并且可以跟踪计算图和梯度,这对深度学习至关重要。要在 GPU 上运行张量,我们只需将张量转换为某种数据类型即可。
以下是如何在 PyTorch 中实例化一个张量的示例:
points = torch.tensor([1.0, 4.0, 2.0, 1.0, 3.0, 5.0])
要获取第一个条目,只需编写:
points[0]
我们还可以使用以下代码检查张量的形状:
points.shape
在 TensorFlow 中,我们通常这样声明一个张量:
points = tf.constant([1.0, 4.0, 2.0, 1.0, 3.0, 5.0])
获取第一个元素或获取张量形状的命令与 PyTorch 相同。
在 PyTorch 中,张量是对存储在连续内存块中的一维数值数组的视图。这些数组称为存储实例。每个 PyTorch 张量都有一个存储属性,可以调用它来输出张量的底层存储实例,如下例所示:
points = torch.tensor([[1.0, 4.0], [2.0, 1.0], [3.0, 5.0]])
points.storage()
这将输出以下内容:
1.0
4.0
2.0
1.0
3.0
5.0
[torch.storage._TypedStorage(dtype=torch.float32, device=cpu) of size 6]
TensorFlow 张量没有存储属性。当我们说 PyTorch 张量是对存储实例的视图时,张量使用以下信息来实现视图:
- 大小(Size)
- 存储(Storage)
- 偏移量(Offset)
- 步幅(Stride)
让我们用之前的示例来探讨这些不同的信息:
points = torch.tensor([[1.0, 4.0], [2.0, 1.0], [3.0, 5.0]])
让我们调查这些信息的含义:
points.size()
这将输出以下内容:
torch.Size([3, 2])
如我们所见,大小类似于 NumPy 中的形状属性,它告诉我们每个维度上的元素数量。这些数字的乘积等于底层存储实例的长度(在此例中为 6)。在 TensorFlow 中,可以通过形状属性来推导张量的形状:
points = tf.constant([[1.0, 4.0], [2.0, 1.0], [3.0, 5.0]])
points.shape
这将输出以下内容:
TensorShape([3, 2])
既然我们已经检查了 PyTorch 张量的存储属性,接下来我们看偏移量(offset):
points.storage_offset()
这将输出以下内容:
0
这里的偏移量表示张量在存储数组中的第一个元素的索引。由于输出为 0,这意味着张量的第一个元素是存储数组中的第一个元素。 让我们检查一下:
points[1].storage_offset()
这将输出以下内容:
2
因为 points[1]
是 [2.0, 1.0]
,而存储数组是 [1.0, 4.0, 2.0, 1.0, 3.0, 5.0]
,我们可以看到张量 [2.0, 1.0]
的第一个元素,即 2.0 在存储数组中的索引为 2。存储偏移量属性(storage_offset),与存储属性(storage)一样,在 TensorFlow 张量中不存在。
最后,我们来看看步幅属性(stride):
points.stride()
这将输出以下内容:
(2, 1)
如我们所见,步幅包含每个维度中为访问张量的下一个元素而需要跳过的元素数量。因此,在这种情况下,在第一个维度上,为了访问第一个元素之后的元素,即 1.0,我们需要跳过 2 个元素(即 1.0 和 4.0),以访问下一个元素,即 2.0。同样,在第二个维度上,我们需要跳过 1 个元素以访问 1.0 之后的元素,即 4.0。因此,利用所有这些属性,张量可以从一个连续的一维存储数组中派生。TensorFlow 张量没有步幅或存储偏移量属性。
张量中包含的数据是数值类型。具体而言,PyTorch 提供了以下数据类型以便存储在张量中:
torch.float32
或torch.float
—32 位浮点数torch.float64
或torch.double
—64 位双精度浮点数torch.float16
或torch.half
—16 位半精度浮点数torch.int8
—有符号 8 位整数torch.uint8
—无符号 8 位整数torch.int16
或torch.short
—有符号 16 位整数torch.int32
或torch.int
—有符号 32 位整数torch.int64
或torch.long
—有符号 64 位整数
TensorFlow 提供了类似的数据类型[2]。
在 PyTorch 中,指定某种数据类型的示例如下:
points = torch.tensor([[1.0, 2.0], [3.0, 4.0]], dtype=torch.float32)
在 TensorFlow 中,可以用以下等效代码完成:
points = tf.constant([[1.0, 2.0], [3.0, 4.0]], dtype=tf.float32)
除了数据类型,PyTorch 中的张量还需要指定存储它们的设备。可以在实例化时指定设备:
points = torch.tensor([[1.0, 2.0], [3.0, 4.0]], dtype=torch.float32, device='cpu')
或者,我们也可以在所需的设备上创建张量的副本:
points_2 = points.to(device='cuda')
如这两个示例所示,我们可以将张量分配给 CPU(使用 device='cpu'
),如果不指定设备,则默认会发生这种情况;或者我们可以将张量分配给 GPU(使用 device='cuda'
)。在 TensorFlow 中,设备分配的方式略有不同:
with tf.device('/CPU:0'):
points = tf.constant([[1.0, 2.0], [3.0, 4.0]], dtype=tf.float32)
PyTorch 目前支持 NVIDIA (CUDA) 和 AMD GPUs。
当张量放置在 GPU 上时,计算速度会加快,并且由于 PyTorch 中的张量 API 在 CPU 和 GPU 张量之间大致统一,因此在设备之间移动相同的张量、执行计算并将其移回是非常方便的。
如果有多个相同类型的设备,比如多个 GPU,我们可以使用设备索引精确定位要放置张量的设备,例如:
points_3 = points.to(device='cuda:0')
你可以在这里阅读有关 PyTorch-CUDA 的更多信息[3]。你还可以在这里阅读有关 CUDA 的更多信息[4]。
接下来,我们将探讨一些重要的 PyTorch 模块,这些模块旨在构建深度学习模型。
PyTorch 模块
PyTorch 库除了提供类似于 NumPy 的计算功能外,还提供了一系列模块,帮助开发者快速设计、训练和测试深度学习模型。以下是一些最有用的模块。
torch.nn
在构建神经网络架构时,网络的基本方面包括层数、每层的神经元数量以及哪些是可学习的等。PyTorch 的 nn
模块允许用户通过定义这些高级方面来快速实例化神经网络架构,而不必手动指定所有细节。以下是一个没有使用 nn
模块的单层神经网络初始化示例:
import math
# 假设输入是 256 维,输出是 4 维
# 因此,我们初始化一个 256x4 维的矩阵,填充随机值
weights = torch.randn(256, 4) / math.sqrt(256)
# 然后确保这个神经网络的参数是可训练的,即 256x4 矩阵中的数字可以通过梯度反向传播进行调整
weights.requires_grad_()
# 最后,我们还添加了 4 维输出的偏置权重,并使这些权重也可训练
bias = torch.zeros(4, requires_grad=True)
我们可以使用 nn.Linear(256, 4)
来表示相同的内容。在 TensorFlow 中,这可以写成 tf.keras.layers.Dense(4, input_shape=(256,), activation=None)
。
在 torch.nn
模块中,有一个子模块叫做 torch.nn.functional
。这个子模块包含了所有 torch.nn
模块中的函数,而其他所有子模块都是类。这些函数包括损失函数、激活函数,以及其他可以用于以函数方式创建神经网络的神经网络函数(即每个后续层被表示为前一层的函数),如池化、卷积和线性函数。
使用 torch.nn.functional
模块的损失函数示例如下:
import torch.nn.functional as F
loss_func = F.cross_entropy
loss = loss_func(model(X), y)
在这里,X
是输入,y
是目标输出,model
是神经网络模型。在 TensorFlow 中,以上代码可以写成:
import tensorflow as tf
loss_func = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)
loss = loss_func(y, model(X))
torch.optim
在训练神经网络时,我们通过反向传播误差来调整网络的权重或参数——这个过程称为优化。optim
模块包括所有与运行各种类型的优化调度相关的工具和功能。
假设我们在训练过程中使用 torch.optim
模块定义了一个优化器,如下所示:
opt = optim.SGD(model.parameters(), lr=lr)
那么,我们不需要像这样手动编写优化步骤:
with torch.no_grad():
# 使用随机梯度下降应用参数更新
for param in model.parameters():
param -= param.grad * lr
model.zero_grad()
我们可以简单地写成:
opt.step()
opt.zero_grad()
TensorFlow 不需要这样的显式代码来更新和清除梯度,优化器的代码看起来如下:
opt = tf.keras.optimizers.SGD(learning_rate=lr)
model.compile(optimizer=opt, loss=...)
接下来,我们将介绍 torch.utils.data
模块。
torch.utils.data
在 utils.data
模块下,Torch 提供了自己的 Dataset
和 DataLoader
类,这些类由于其抽象和灵活的实现非常有用。这些类提供了直观且有用的方法来迭代和执行其他类似操作于张量。
使用这些类,我们可以确保由于优化的张量计算而获得高性能,并且实现数据 I/O 的安全保障。例如,假设我们使用 torch.utils.data.DataLoader
如下:
from torch.utils.data import (TensorDataset, DataLoader)
train_dataset = TensorDataset(x_train, y_train)
train_dataloader = DataLoader(train_dataset, batch_size=bs)
那么,我们不需要像这样手动迭代数据批次:
for i in range((n-1)//bs + 1):
x_batch = x_train[start_i:end_i]
y_batch = y_train[start_i:end_i]
pred = model(x_batch)
我们可以简单地写成:
for x_batch, y_batch in train_dataloader:
pred = model(x_batch)
torch.utils.data
类似于 TensorFlow 中的 tf.data.Dataset
。上述迭代数据批次的代码在 TensorFlow 中可以写成:
import tensorflow as tf
train_dataset = tf.data.Dataset.from_tensor_slices((x_train, y_train))
train_dataloader = train_dataset.batch(bs)
for x_batch, y_batch in train_dataloader:
pred = model(x_batch)
现在,我们已经探索了 PyTorch 库(与 TensorFlow 相比),并理解了 PyTorch 和 Tensor 模块,接下来我们将学习如何使用 PyTorch 训练神经网络。
使用 PyTorch 训练神经网络
在这个练习中,我们将使用著名的 MNIST 数据集,这是一系列手写邮政编码数字(从零到九)的图像,并附有相应的标签。MNIST 数据集包含 60,000 个训练样本和 10,000 个测试样本,每个样本是一个 28 x 28 像素的灰度图像。PyTorch 也在其 Dataset 模块下提供了 MNIST 数据集。
在这个练习中,我们将使用 PyTorch 训练一个深度学习多类分类器,并测试训练好的模型在测试样本上的表现。这个练习的完整 PyTorch 代码以及等效的 TensorFlow 代码可以在本书的 GitHub 仓库中找到。
对于这个练习,我们需要导入一些依赖库。请执行以下导入语句:
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
import matplotlib.pyplot as plt
接下来,我们定义模型架构,如下图所示:
模型由卷积层、Dropout 层以及线性/全连接层组成,这些都可以通过 torch.nn
模块来实现:
class ConvNet(nn.Module):
def __init__(self):
super(ConvNet, self).__init__()
self.cn1 = nn.Conv2d(1, 16, 3, 1)
self.cn2 = nn.Conv2d(16, 32, 3, 1)
self.dp1 = nn.Dropout2d(0.10)
self.dp2 = nn.Dropout2d(0.25)
self.fc1 = nn.Linear(4608, 64)
# 4608 基本上是 12 X 12 X 32
self.fc2 = nn.Linear(64, 10)
def forward(self, x):
x = self.cn1(x)
x = F.relu(x)
...
x = self.fc2(x)
op = F.log_softmax(x, dim=1)
return op
__init__
函数定义了模型的核心架构,即每一层的神经元数量等。而 forward
函数则执行前向传播,因此包含了每层的激活函数以及任何在层之后使用的池化或 Dropout 操作。此函数将返回最终的层输出,即模型的预测结果,其维度与目标输出(真实值)相同。
注意,第一个卷积层的输入通道为 1,输出通道为 16,卷积核大小为 3,步幅为 1。输入通道 1 主要用于灰度图像。我们选择 3x3 的卷积核有多种原因。首先,卷积核的尺寸通常为奇数,以便图像像素在中心像素周围对称分布。1x1 的卷积核太小,因为它无法获取周围像素的信息。3x3 是下一步,但为什么不使用更大的 5x5、7x7,甚至 27x27 呢?
在极端情况下,27x27 的卷积核在 28x28 的图像上卷积会得到非常粗糙的特征。然而,图像中最重要的视觉特征通常是局部的(在小的空间邻域内),因此使用较小的卷积核来查看少量相邻像素的视觉模式是有意义的。3x3 是 CNN 中用于计算机视觉问题的最常见卷积核尺寸之一。
请注意,我们有两个连续的卷积层,两个卷积核都是 3x3 的。从空间覆盖的角度来看,这等同于使用一个 5x5 的卷积层。然而,使用多个小卷积核的层通常更为理想,因为它会导致更深的网络,从而学习到更复杂的特征,同时由于卷积核较小,参数数量也较少。使用许多小卷积核可能会导致专门化的卷积核——一个用于检测边缘,一个用于圆形,一个用于红色等。
卷积层输出的通道数通常大于或等于输入的通道数。我们的第一个卷积层接受一个通道的数据,输出 16 个通道。这基本上意味着该层尝试从输入图像中检测 16 种不同的信息。每个通道称为特征图,每个特征图都有一个专门的卷积核来提取其特征。
在第二个卷积层中,我们将通道数从 16 增加到 32,以尝试从图像中提取更多种类的特征。这种增加通道数(或图像深度)的做法在 CNN 中很常见。我们将在第 2 章“深度 CNN 架构”中详细阅读有关基于宽度的 CNN 的更多内容。
最后,步幅为 1 是合理的,因为我们的卷积核大小仅为 3。使用更大的步幅值,比如 10,将导致卷积核跳过图像中的许多像素,而我们不希望这样。如果我们的卷积核大小是 100,我们可能会考虑 10 作为合理的步幅值。步幅越大,卷积操作的数量越少,但卷积核的整体视野也越小。
上述代码也可以使用 torch.nn.Sequential
API 编写:
model = nn.Sequential(
nn.Conv2d(1, 16, 3, 1),
nn.ReLU(),
nn.Conv2d(16, 32, 3, 1),
nn.ReLU(),
nn.MaxPool2d(2),
nn.Dropout2d(0.10),
nn.Flatten(),
nn.Linear(4608, 64),
nn.ReLU(),
nn.Dropout2d(0.25),
nn.Linear(64, 10),
nn.LogSoftmax(dim=1)
)
通常,初始化模型时使用独立的 __init__
和 forward
方法可以提供更多灵活性,以便在不是所有层都依次执行的情况下(例如并行或跳跃连接)定义模型功能。上述的顺序代码在 TensorFlow 中看起来非常类似:
import tensorflow as tf
model = tf.keras.Sequential([
tf.keras.layers.Conv2D(16, 3, activation='relu', input_shape=(28, 28, 1)),
tf.keras.layers.Conv2D(32, 3, activation='relu'),
tf.keras.layers.MaxPooling2D(pool_size=(2, 2)),
tf.keras.layers.Dropout(0.10),
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(64, activation='relu'),
tf.keras.layers.Dropout(0.25),
tf.keras.layers.Dense(10, activation='softmax')
])
使用 __init__
和 forward
方法的代码在 TensorFlow 中如下:
import tensorflow as tf
class ConvNet(tf.keras.Model):
def __init__(self):
super(ConvNet, self).__init__()
self.cn1 = tf.keras.layers.Conv2D(16, 3, activation='relu', input_shape=(28, 28, 1))
self.fc2 = tf.keras.layers.Dense(10, activation='softmax')
def call(self, x):
x = self.cn1(x)
x = self.fc2(x)
return x
在 TensorFlow 中,我们使用 call
方法代替 forward
,其他部分与 PyTorch 代码类似。
接下来,我们定义训练例程,即实际的反向传播步骤。如你所见,torch.optim
模块大大帮助我们保持代码的简洁:
def train(model, device, train_dataloader, optim, epoch):
model.train()
for b_i, (X, y) in enumerate(train_dataloader):
X, y = X.to(device), y.to(device)
optim.zero_grad()
pred_prob = model(X)
loss = F.nll_loss(pred_prob, y)
# nll 是负对数似然损失
loss.backward()
optim.step()
if b_i % 10 == 0:
print('epoch: {} [{}/{} ({:.0f}%)]\t \
training loss:\ {:.6f}'.format(
epoch, b_i * len(X),
len(train_dataloader.dataset),
100. * b_i / len(train_dataloader),
loss.item()))
这段代码对数据集进行批次迭代,将数据集复制到指定设备上,通过神经网络模型进行前向传播,计算模型预测和真实值之间的损失,使用给定的优化器调整模型权重,并每 10 个批次打印一次训练日志。整个过程完成一次即为一个 epoch,即整个数据集读取一次。对于 TensorFlow,我们将在第 7 步直接在高层次运行训练。PyTorch 中的详细训练例程定义提供了更紧密控制训练过程的灵活性,相较于 TensorFlow 中的单行代码训练。
类似于上述训练例程,我们编写一个测试例程,用于评估模型在测试集上的表现:
def test(model, device, test_dataloader):
model.eval()
loss = 0
success = 0
with torch.no_grad():
for X, y in test_dataloader:
X, y = X.to(device), y.to(device)
pred_prob = model(X)
# 损失在批次中累加
loss += F.nll_loss(pred_prob, y, reduction='sum').item()
# 使用 argmax 获取最可能的预测
pred = pred_prob.argmax(dim=1, keepdim=True)
success += pred.eq(y.view_as(pred)).sum().item()
loss /= len(test_dataloader.dataset)
print('\nTest dataset: Overall Loss: {:.4f}, \
Overall Accuracy: {}/{} ({:.0f}%)\n'.format(loss,
success, len(test_dataloader.dataset),
100. * success / len(test_dataloader.dataset)))
这个函数大部分与前面的训练函数类似。唯一的区别是,模型预测和真实值之间计算的损失不会用于调整模型权重,而是用于计算整个测试批次的总体测试误差。
接下来,我们进入本练习的另一个关键部分,即加载数据集。得益于 PyTorch 的 DataLoader
模块,我们可以用几行代码设置数据集加载机制:
'''均值和标准差值是通过计算训练数据集中所有图像的像素值的均值和标准差得出的'''
train_dataloader = torch.utils.data.DataLoader(
datasets.MNIST('../data', train=True, download=True,
transform=transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1302,), (0.3069,))])),
# train_X.mean()/256. 和 train_X.std()/256.
batch_size=32, shuffle=True)
test_dataloader = torch.utils.data.DataLoader(
datasets.MNIST('../data', train=False,
transform=transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1302,), (0.3069,))
])),
batch_size=500, shuffle=False)
如你所见,我们将 batch_size
设置为 32,这是一个相当常见的选择。通常,在决定批量大小时存在权衡。非常小的批量大小可能会导致由于频繁计算梯度而训练缓慢,并且可能会导致梯度噪声非常大。另一方面,批量大小非常大也可能会由于计算梯度的等待时间过长而减慢训练速度。在进行单次梯度更新之前等待很长时间通常不值得。建议做频繁但不精确的梯度更新,这样最终会使模型得到更好的学习参数。
对于训练和测试数据集,我们指定了本地存储位置以及批量大小,批量大小决定了构成一次训练和测试运行的数据实例数量。我们还指定了要随机打乱训练数据实例,以确保数据样本在批次中的均匀分布。
最后,我们还将数据集归一化为具有指定均值和标准差的正态分布。这个均值和标准差来自于训练数据集(如果我们从头开始训练模型)。然而,如果我们从预训练模型进行迁移学习,那么均值和标准差值来自于预训练模型的原始训练数据集。我们将在第 2 章“深度 CNN 架构”中学习更多关于迁移学习的内容。
在 TensorFlow 中,我们会使用 tf.keras.datasets
来加载 MNIST 数据,并使用 tf.data.Dataset
模块从数据集中创建训练数据批次,如下所示:
# 加载 MNIST 数据集。
(x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data()
# 将像素值归一化到 0 和 1 之间
x_train = x_train.astype("float32") / 255.0
x_test = x_test.astype("float32") / 255.0
# 添加通道维度(CNN 需要的)
x_train = x_train[..., tf.newaxis]
x_test = x_test[..., tf.newaxis]
# 创建训练数据的 dataloader。
train_dataloader = tf.data.Dataset.from_tensor_slices((x_train, y_train))
train_dataloader = train_dataloader.shuffle(10000)
train_dataloader = train_dataloader.batch(32)
# 创建测试数据的 dataloader。
test_dataloader = tf.data.Dataset.from_tensor_slices((x_test, y_test))
test_dataloader = test_dataloader.batch(500)
我们已经定义了训练例程。现在是时候定义我们将用于运行模型训练的优化器和设备:
torch.manual_seed(0)
device = torch.device("cpu")
model = ConvNet()
optimizer = optim.Adadelta(model.parameters(), lr=0.5)
我们将设备定义为 cpu
。我们还设置了种子以避免未知的随机性并确保可重复性。我们将使用 Adadelta 作为本练习的优化器,学习率为 0.5。此前在讨论优化计划时提到,如果我们处理的是稀疏数据,Adadelta 可能是一个不错的选择。
这是一个稀疏数据的例子,因为图像中并非所有像素都是信息丰富的。尽管如此,我鼓励你尝试其他优化器,如 Adam,以了解它们对训练过程和模型性能的影响。以下是 TensorFlow 中用于实例化和编译模型的等效代码:
tf.random.set_seed(0)
model = ConvNet()
optimizer = tf.keras.optimizers.experimental.Adadelta(learning_rate=0.5)
model.compile(optimizer=optimizer, loss='sparse_categorical_crossentropy', metrics=['accuracy'])
然后,我们开始实际的模型训练过程,并在每个训练 epoch 结束时进行模型测试:
for epoch in range(1, 3):
train(model, device, train_dataloader, optimizer, epoch)
test(model, device, test_dataloader)
为了演示,我们将训练运行两个 epoch。输出如下:
epoch: 1 [0/60000 (0%)] training loss: 2.31060
epoch: 1 [320/60000 (1%)] training loss: 1.924133
epoch: 1 [640/60000 (1%)] training loss: 1.313336
epoch: 1 [960/60000 (2%)] training loss: 0.796470
epoch: 1 [1280/60000 (2%)] training loss: 0.819801
...
epoch: 2 [58560/60000 (98%)] training loss: 0.007698
epoch: 2 [58880/60000 (98%)] training loss: 0.002685
epoch: 2 [59200/60000 (99%)] training loss: 0.016287
epoch: 2 [59520/60000 (99%)] training loss: 0.012645
epoch: 2 [59840/60000 (100%)] training loss: 0.007993
Test dataset: Overall Loss: 0.0416, Overall Accuracy: 9864/10000 (99%)
TensorFlow 中的训练循环代码等效如下:
model.fit(train_dataloader, epochs=2, validation_data=test_dataloader)
现在我们已经训练了一个模型,并且得到了合理的测试集性能,我们还可以手动检查模型在样本图像上的推断是否正确:
test_samples = enumerate(test_dataloader)
b_i, (sample_data, sample_targets) = next(test_samples)
plt.imshow(sample_data[0][0], cmap='gray', interpolation='none')
输出将如下所示:
等效的 TensorFlow 代码除了使用 sample_data[0]
代替 sample_data[0][0]
外,其余部分相同:
test_samples = enumerate(test_dataloader)
b_i, (sample_data, sample_targets) = next(test_samples)
plt.imshow(sample_data[0], cmap='gray', interpolation='none')
plt.show()
接下来,我们对这张图片运行模型推断,并与真实标签进行比较:
print(f"模型预测结果是:{model(sample_data).data.max(1)[1][0]}")
print(f"真实标签是:{sample_targets[0]}")
注意,对于预测,我们首先使用 max()
函数在轴 1 上计算具有最大概率的类别。max()
函数输出两个列表——一个是每个样本的类别概率列表,另一个是每个样本的类别标签列表。因此,我们使用索引 [1]
选择第二个列表。
进一步地,我们通过使用索引 [0]
选择第一个类别标签,以查看 sample_data
下的第一个样本。输出将如下所示:
模型预测结果是:7
真实标签是:7
这似乎是正确的预测。使用 model()
进行的神经网络前向传播生成的是概率。因此,我们使用 max()
函数来输出具有最大概率的类别。在 TensorFlow 中,可以用以下代码实现相同的输出:
print(f"模型预测结果是:{tf.math.argmax(model(sample_data)[0])}")
print(f"真实标签是:{sample_targets[0]}")
本练习的代码模式源自官方 PyTorch 示例库 [8]。
至此,我们在完整的练习中探索了 PyTorch 库,并在模型训练的不同阶段——模型初始化、数据加载、训练循环和模型评估——比较了 PyTorch 和 TensorFlow 的 API。这一分析应有助于你开始使用 PyTorch,以及如果你已经熟悉 TensorFlow,能够顺利过渡到 PyTorch。
总结
在本章中,我们回顾了深度学习的概念,探讨了 PyTorch 深度学习库与 TensorFlow 的比较,并进行了一个关于从零开始训练深度学习模型(卷积神经网络)的实操练习。
在下一章中,我们将深入了解多年来开发的各种卷积神经网络(CNN)架构,探讨每种架构的独特用处,以及如何使用 PyTorch 轻松实现这些架构。