从零开始的深度学习-全-
从零开始的深度学习(全)
译者:飞龙
前言
如果你尝试学习神经网络和深度学习,你可能会遇到大量资源,从博客文章到 MOOCs(大规模在线开放课程,比如 Coursera 和 Udacity 提供的课程)的质量各异,甚至一些书籍——我知道当我几年前开始探索这个主题时也是如此。然而,如果你正在阅读这篇前言,很可能你遇到的每一个神经网络解释都在某种程度上有所欠缺。当我开始学习时,我也发现了同样的问题:各种解释就像盲人描述大象的不同部分一样,但没有一个描述整体。这就是我写这本书的原因。
关于神经网络的现有资源大多可以分为两类。一些是概念性和数学性的,包含了通常在神经网络解释中找到的圆圈通过箭头线连接的图示,以及详尽的数学解释,让你“理解理论”。这方面的典型例子是 Ian Goodfellow 等人的非常好的书《深度学习》(麻省理工学院出版社)。
其他资源包含了密集的代码块,如果运行,似乎显示了随着时间减少的损失值,从而神经网络“学习”。例如,PyTorch 文档中的以下示例确实定义并训练了一个简单的神经网络,使用随机生成的数据:
# N is batch size; D_in is input dimension;
# H is hidden dimension; D_out is output dimension.
N, D_in, H, D_out = 64, 1000, 100, 10
# Create random input and output data
x = torch.randn(N, D_in, device=device, dtype=dtype)
y = torch.randn(N, D_out, device=device, dtype=dtype)
# Randomly initialize weights
w1 = torch.randn(D_in, H, device=device, dtype=dtype)
w2 = torch.randn(H, D_out, device=device, dtype=dtype)
learning_rate = 1e-6
for t in range(500):
# Forward pass: compute predicted y
h = x.mm(w1)
h_relu = h.clamp(min=0)
y_pred = h_relu.mm(w2)
# Compute and print loss
loss = (y_pred - y).pow(2).sum().item()
print(t, loss)
# Backprop to compute gradients of w1 and w2 with respect to loss
grad_y_pred = 2.0 * (y_pred - y)
grad_w2 = h_relu.t().mm(grad_y_pred)
grad_h_relu = grad_y_pred.mm(w2.t())
grad_h = grad_h_relu.clone()
grad_h[h < 0] = 0
grad_w1 = x.t().mm(grad_h)
# Update weights using gradient descent
w1 -= learning_rate * grad_w1
w2 -= learning_rate * grad_w2
当然,这样的解释并没有提供关于“真正发生了什么”的深入见解:这里包含的基本数学原理,个别神经网络组件以及它们如何一起工作等等。
一个好的神经网络解释会包含什么?为了得到答案,可以看看其他计算机科学概念是如何解释的:例如,如果你想学习排序算法,有一些教科书会包含:
-
算法的解释,用简单的英语
-
算法如何工作的视觉解释,就像你在编程面试中画在白板上的那种
-
一些关于“算法为什么有效”的数学解释
-
实现算法的伪代码
很少或者从来没有在神经网络的解释中找到这些元素并排,尽管我认为一个正确的神经网络解释应该这样做;这本书是填补这一空白的尝试。
理解神经网络需要多个心智模型
我不是一名研究员,也没有博士学位。然而,我曾经专业地教授数据科学:我曾经与一家名为 Metis 的公司一起教授了几个数据科学训练营,然后与 Metis 一起环游世界一年,在许多不同行业的公司进行为期一到五天的研讨会,向他们的员工解释机器学习和基本软件工程概念。我一直热爱教学,并且一直被如何最好地解释技术概念的问题所吸引,最近专注于机器学习和统计学概念。对于神经网络,我发现最具挑战性的部分是传达正确的“心智模型”,即神经网络是什么,特别是因为完全理解神经网络不仅需要一个而是几个心智模型,所有这些模型都阐明神经网络工作的不同(但仍然是必要的)方面。为了说明这一点:以下四个句子都是对问题“神经网络是什么?”的正确回答:
-
神经网络是一个数学函数,接受输入并产生输出。
-
神经网络是一个计算图,通过它,多维数组流动。
-
神经网络由层组成,每一层可以被认为有一定数量的“神经元”。
-
神经网络是一个通用的函数逼近器,理论上可以表示任何监督学习问题的解决方案。
实际上,你们中的许多人可能以前听过其中一个或多个,并且可能对它们的含义以及对神经网络工作方式的影响有一个合理的理解。然而,要完全理解它们,我们必须理解所有它们,并展示它们如何相互联系——例如,神经网络可以被表示为计算图的事实如何与“层”这个概念相联系?此外,为了使所有这些精确,我们将从头开始在 Python 中实现所有这些概念,并将它们组合在一起,以制作可以在您的笔记本电脑上训练的工作神经网络。尽管我们将花费大量时间在实现细节上,在 Python 中实现这些模型的目的是巩固和精确化我们对概念的理解;而不是尽可能写出简洁或高性能的神经网络库。
我的目标是在你阅读完本书后,你将对所有这些心智模型(以及它们对神经网络应该如何实现的影响)有坚实的理解,从而学习相关概念或在该领域进行更多项目将会更容易。
章节概要
前三章是最重要的,可以自成一本独立的书。
-
在第一章中,我将展示数学函数如何被表示为一系列操作链接在一起形成一个计算图,并展示这种表示方式如何让我们使用微积分中的链式法则计算这些函数输出相对于它们的输入的导数。在本章末尾,我将介绍一个非常重要的操作,矩阵乘法,并展示它如何适应这种表示方式中的数学函数,同时仍然允许我们计算深度学习所需的导数。
-
在第二章中,我们将直接使用我们在第一章中创建的基本组件来构建和训练模型,以解决一个真实世界的问题:具体来说,我们将使用它们来构建线性回归和神经网络模型,以预测一个真实世界数据集上的房价。我将展示神经网络比线性回归表现更好,并尝试解释一些原因。本章中构建模型的“第一原理”方法应该让你对神经网络的工作原理有很好的理解,但也会展示逐步、纯粹基于第一原理的方法定义深度学习模型的有限能力;这将激励我们继续学习第三章。
-
在第三章中,我们将采用前两章基于第一原理的方法构建的基本组件,并用它们来构建组成所有深度学习模型的“高级”组件:
Layer
、Model
、Optimizer
等。我们将通过在第二章中相同数据集上训练一个从头定义的深度学习模型来结束本章,并展示它比我们简单的神经网络表现更好。 -
事实证明,使用标准训练技术训练时,给定架构的神经网络实际上会在给定数据集上找到一个好的解决方案的理论保证很少。在第四章中,我们将介绍最重要的“训练技巧”,通常会增加神经网络找到好解决方案的概率,并在可能的情况下,给出一些数学直觉为什么它们有效。
-
在第五章中,我涵盖了卷积神经网络(CNNs)背后的基本思想,这是一种专门用于理解图像的神经网络架构。关于 CNNs 有很多解释,所以我将专注于解释 CNNs 的绝对基础知识以及它们与常规神经网络的区别:特别是 CNNs 导致每一层神经元组织成“特征图”,以及两个这些层(每个由多个特征图组成)如何通过卷积滤波器连接在一起。此外,就像我们从头开始编写了神经网络中的常规层一样,我们将从头开始编写卷积层,以加强我们对它们工作原理的理解。
-
在前五章中,我们将构建一个小型神经网络库,将神经网络定义为一系列由
Layer
组成的层,这些层本身由一系列将输入向前传递和梯度向后传递的Operation
组成。这不是实践中大多数神经网络的实现方式;相反,它们使用一种称为自动微分的技术。我将在第六章的开头快速说明自动微分,并用它来激发本章的主题:循环神经网络(RNNs),这是通常用于理解数据的神经网络架构,其中数据点按顺序出现,如时间序列数据或自然语言数据。我将解释“普通 RNNs”的工作原理以及两个变体:GRUs和LSTMs(当然,我们会从头开始实现这三个);在整个过程中,我将小心区分这些 RNN 变体之间共享的元素和这些变体之间的特定差异。 -
最后,在第七章中,我将展示如何使用高性能、开源的神经网络库 PyTorch 来实现我们在第一章至第六章中从头开始做的一切。学习这样的框架对于推进你对神经网络的学习至关重要;但是在深入学习一个框架之前,没有对神经网络的工作原理和原因有扎实的理解,长期来看会严重限制你的学习。本书中章节的进展目标是让你有能力编写极高性能的神经网络(通过教授 PyTorch),同时为你长期的学习和成功打下基础(在学习 PyTorch 之前教授你基础知识)。最后,我们将简要说明神经网络如何用于无监督学习。
我在这里的目标是写一本我在几年前开始学习这个主题时希望存在的书。希望你会发现这本书有帮助。继续前进!
本书中使用的约定
本书中使用以下排版约定:
斜体
表示新术语、URL、电子邮件地址、文件名和文件扩展名。
固定宽度
用于程序清单,以及在段落中引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。
固定宽度加粗
显示用户应该按照字面输入的命令或其他文本。
固定宽度斜体
用于应该由用户提供的值或由上下文确定的值替换的文本,以及代码示例中的注释。
勾股定理是。
注意
此元素表示一般说明。
使用代码示例
补充材料(代码示例、练习等)可在本书的 GitHub 存储库下载。
这本书旨在帮助您完成工作。一般来说,如果本书提供示例代码,您可以在程序和文档中使用它。除非您复制了代码的大部分内容,否则无需联系我们以获得许可。例如,编写一个程序使用本书中的几个代码块不需要许可。出售或分发包含 O'Reilly 图书示例的 CD-ROM 需要许可。引用本书并引用示例代码回答问题不需要许可。将本书中大量示例代码整合到产品文档中需要许可。
我们感谢,但不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Deep Learning from Scratch by Seth Weidman (O'Reilly). Copyright 2019 Seth Weidman, 978-1-492-04141-2.”
如果您认为您对代码示例的使用超出了合理使用范围或上述许可,请随时通过permissions@oreilly.com与我们联系。
致谢
我要感谢我的编辑 Melissa Potter,以及 O'Reilly 团队,在整个过程中他们对我的反馈非常细致,对我的问题也很及时回应。
我要特别感谢几位致力于使机器学习中的技术概念更易于为更广泛的受众所理解的人,其中有几位我有幸亲自认识:按随机生成的顺序,这些人是 Brandon Rohrer、Joel Grus、Jeremy Watt 和 Andrew Trask。
我要感谢 Metis 的老板和 Facebook 的主管,他们非常支持我抽出时间来开展这个项目。
我要特别感谢和致谢 Mat Leonard,他曾是我在这个项目中的合著者,虽然我们后来决定各自走开。Mat 帮助组织了与本书相关的迷你库lincoln
的代码,并对前两章极不完善的版本给予了非常有用的反馈,在此过程中还写了自己版本的大部分章节。
最后,我想感谢我的朋友 Eva 和 John,他们直接鼓励和启发我开始写作。我还想感谢旧金山的许多朋友,他们容忍了我对这本书的普遍关注和担忧,以及我数月来无法和他们一起出去玩的情况,并且在我需要他们支持时始终如一。
公平地说,这个例子旨在为那些已经了解神经网络的人展示 PyTorch 库,而不是作为一个教程。尽管如此,许多教程都遵循这种风格,只展示代码以及一些简要的解释。
具体来说,在排序算法的情况下,算法为什么会以一个正确排序的列表终止。
第一章:基础
不要死记这些公式。如果你理解了概念,你可以发明自己的符号。
约翰·科克兰,《投资笔记》2006
本章的目的是解释一些对理解神经网络工作至关重要的基础心智模型。具体来说,我们将涵盖嵌套数学函数及其导数。我们将从最简单的基本构建块开始,逐步展示我们可以构建由“链”组成的复杂函数,即使其中一个函数是接受多个输入的矩阵乘法,也可以计算函数输出相对于输入的导数。理解这个过程如何运作将是理解神经网络的关键,而我们实际上直到第二章才会开始涵盖神经网络。
当我们围绕神经网络的这些基础构建块来找到方向时,我们将系统地从三个视角描述我们引入的每个概念:
-
数学,以方程或方程组的形式
-
代码,尽可能少的额外语法(使 Python 成为理想选择)
-
一个解释正在发生的事情的图表,就像你在编程面试中在白板上画的那种
正如前言中提到的,理解神经网络的一个挑战是需要多种心智模型。在本章中,我们将感受到这一点:这三种视角中的每一种都排除了我们将要涵盖的概念的某些基本特征,只有当它们一起被考虑时,才能提供关于嵌套数学函数工作方式的完整图景。事实上,我坚定地认为,任何试图解释神经网络构建块的尝试,如果排除了这三种视角中的任何一种,都是不完整的。
现在,我们要迈出第一步了。我们将从一些极其简单的基本构建块开始,以说明我们如何可以从这三个视角理解不同的概念。我们的第一个基本构建块将是一个简单但至关重要的概念:函数。
函数
什么是函数,我们如何描述它?与神经网络一样,有几种方法来描述函数,其中没有一种能够完整地描绘出整个图景。与其试图给出一个简洁的一句话描述,不如我们简单地逐个走过这三种心智模型,扮演感受大象不同部分的盲人的角色。
数学
这里有两个函数的例子,用数学符号描述:
-
f1 = x²
-
f2 = max(x, 0)
这个符号表示函数,我们任意地称为f[1]和f[2],将一个数字x作为输入,并将其转换为x²(在第一种情况下)或max(x, 0)(在第二种情况下)。
图表
描述函数的一种方式是:
-
画一个x-y平面(其中x指水平轴,y指垂直轴)。
-
绘制一堆点,其中点的 x 坐标是函数在某个范围内的(通常是均匀间隔的)输入,y 坐标是该范围内函数的输出。
-
连接这些绘制的点。
这最初是由法国哲学家勒内·笛卡尔完成的,它在许多数学领域中非常有用,特别是微积分。图 1-1 显示了这两个函数的图表。
图 1-1。两个连续的、大部分可微的函数
然而,还有另一种描述函数的方式,在学习微积分时并不那么有用,但在思考深度学习模型时将非常有用。我们可以将函数看作是接受数字输入并产生数字输出的盒子,就像具有其自身内部规则的小工厂,用于处理输入。图 1-2 展示了这两个函数被描述为通用规则以及它们如何在特定输入上运行。
图 1-2. 另一种看待这些函数的方式
代码
最后,我们可以使用代码描述这些函数。在此之前,我们应该简要介绍一下我们将在其上编写函数的 Python 库:NumPy。
代码注意事项#1:NumPy
NumPy 是一个广泛使用的 Python 库,用于快速数值计算,其内部大部分是用 C 编写的。简而言之:我们在神经网络中处理的数据将始终保存在一个几乎总是一维、二维、三维或四维的多维数组中,尤其是二维或三维。来自 NumPy 库的ndarray
类允许我们以既直观又快速的方式操作这些数组。举个最简单的例子:如果我们将数据存储在 Python 列表(或列表的列表)中,使用正常语法逐元素添加或乘以列表是行不通的,而对于ndarray
却是行得通的:
print("Python list operations:")
a = [1,2,3]
b = [4,5,6]
print("a+b:", a+b)
try:
print(a*b)
except TypeError:
print("a*b has no meaning for Python lists")
print()
print("numpy array operations:")
a = np.array([1,2,3])
b = np.array([4,5,6])
print("a+b:", a+b)
print("a*b:", a*b)
Python list operations:
a+b: [1, 2, 3, 4, 5, 6]
a*b has no meaning for Python lists
numpy array operations:
a+b: [5 7 9]
a*b: [ 4 10 18]
ndarray
还具有您从n
维数组中期望的几个特性;每个ndarray
都有n
个轴,从 0 开始索引,因此第一个轴是0
,第二个是1
,依此类推。特别是,由于我们经常处理 2D ndarray
,我们可以将axis = 0
看作行,axis = 1
看作列——参见图 1-3。
图 1-3. 一个 2D NumPy 数组,其中 axis = 0 表示行,axis = 1 表示列
NumPy 的ndarray
还支持沿着这些轴以直观方式应用函数。例如,沿着轴 0(2D 数组的行)求和基本上会沿着该轴“折叠数组”,返回一个比原始数组少一个维度的数组;对于 2D 数组,这相当于对每列求和:
print('a:')
print(a)
print('a.sum(axis=0):', a.sum(axis=0))
print('a.sum(axis=1):', a.sum(axis=1))
a:
[[1 2]
[3 4]]
a.sum(axis=0): [4 6]
a.sum(axis=1): [3 7]
最后,NumPy 的ndarray
支持将 1D 数组添加到最后一个轴;对于具有R
行和C
列的 2D 数组a
,这意味着我们可以添加长度为C
的 1D 数组b
,NumPy 将以直观的方式进行加法运算,将元素添加到a
的每一行:¹
a = np.array([[1,2,3],
[4,5,6]])
b = np.array([10,20,30])
print("a+b:\n", a+b)
a+b:
[[11 22 33]
[14 25 36]]
代码注意事项#2:类型检查的函数
正如我提到的,我们在本书中编写的代码的主要目标是使我解释的概念变得精确和清晰。随着书的进行,这将变得更具挑战性,因为我们将编写具有许多参数的函数作为复杂类的一部分。为了应对这一挑战,我们将在整个过程中使用带有类型签名的函数;例如,在第三章中,我们将初始化我们的神经网络如下:
def __init__(self,
layers: List[Layer],
loss: Loss,
learning_rate: float = 0.01) -> None:
仅凭这个类型签名,您就可以对该类的用途有一些了解。相比之下,考虑以下类型签名,我们可以用来定义一个操作:
def operation(x1, x2):
仅凭这个类型签名,您无法了解正在发生什么;只有通过打印出每个对象的类型,查看在每个对象上执行的操作,或根据名称x1
和x2
猜测,我们才能理解这个函数中正在发生的事情。相反,我可以定义一个带有以下类型签名的函数:
def operation(x1: ndarray, x2: ndarray) -> ndarray:
您立即知道这是一个接受两个ndarray
的函数,可能以某种方式将它们组合在一起,并输出该组合的结果。由于它们提供的更清晰性,我们将在本书中始终使用带有类型检查的函数。
NumPy 中的基本函数
在了解了这些基础知识之后,让我们在 NumPy 中编写我们之前定义的函数:
def square(x: ndarray) -> ndarray:
'''
Square each element in the input ndarray.
'''
return np.power(x, 2)
def leaky_relu(x: ndarray) -> ndarray:
'''
Apply "Leaky ReLU" function to each element in ndarray.
'''
return np.maximum(0.2 * x, x)
注意
NumPy 的一个特点是许多函数可以通过写np.*function_name*(ndarray)
或写ndarray.*function_name*
来应用于ndarray
。例如,前面的relu
函数可以写成:x.clip(min=0)
。我们将尽量保持一致,使用np.*function_name*(ndarray)
的约定——特别是,我们将避免诸如*ndarray*.T
用于转置二维ndarray
的技巧,而是写成np.transpose(*ndarray*, (1, 0))
。
如果你能理解数学、图表和代码是表示同一基本概念的三种不同方式,那么你就已经在正确理解深度学习所需的灵活思维方面迈出了重要一步。
导数
导数,就像函数一样,是理解深度学习的一个非常重要的概念,你们中的许多人可能已经熟悉了。和函数一样,它们可以用多种方式表示。我们首先简单地说一下,函数在某一点的导数是函数输出相对于该点的输入的“变化率”。现在让我们通过相同的三个导数视角来更好地理解导数的工作原理。
数学
首先,我们将数学上精确描述:我们可以将这个数字描述为一个极限,即在特定值a的输入处改变其输入时,f的输出会发生多少变化:
这个极限可以通过设置一个非常小的Δ值(例如 0.001)来进行数值近似,因此我们可以计算导数为:
虽然准确,但这只是导数完整心智模型的一部分。让我们从另一个角度来看待它们:一个图表。
图表
首先,熟悉的方式:如果我们简单地在函数f的笛卡尔表示上画一条切线,f在点a的导数就是这条线在a点的斜率。与前一小节中的数学描述一样,我们实际上可以计算这条线的斜率的两种方法。第一种是使用微积分来计算极限。第二种是只需取连接f在a - 0.001 和a + 0.001 的线的斜率。后一种方法在图 1-4 中有所描述,对于学过微积分的人应该很熟悉。
图 1-4. 导数作为斜率
正如我们在前一节中看到的,另一种思考函数的方式是将其视为小工厂。现在想象一下,这些工厂的输入通过一根绳子连接到输出。导数等于这个问题的答案:如果我们向上拉动函数的输入a一点点,或者为了考虑到函数在a可能是不对称的情况,向下拉动a一点点,根据工厂的内部运作,输出将以这个小量的多少倍改变?这在图 1-5 中有所描述。
图 1-5. 另一种可视化导数的方式
这第二种表示将比第一种更重要,以理解深度学习。
代码
最后,我们可以编写我们之前看到的导数近似的代码:
from typing import Callable
def deriv(func: Callable[[ndarray], ndarray],
input_: ndarray,
delta: float = 0.001) -> ndarray:
'''
Evaluates the derivative of a function "func" at every element in the
"input_" array.
'''
return (func(input_ + delta) - func(input_ - delta)) / (2 * delta)
注意
当我们说“某物是另一物的函数”时——例如,P是E的函数(故意随机选择的字母),我们的意思是存在某个函数f,使得f(E) = P——或者等价地,存在一个函数f,接受E对象并产生P对象。我们也可以将其理解为P“被定义为”将函数f应用于E时产生的结果:
我们可以将其编码为:
def f(input_: ndarray) -> ndarray:
# Some transformation(s)
return output
P = f(E)
嵌套函数
现在我们将介绍一个对理解神经网络至关重要的概念:函数可以“嵌套”形成“复合”函数。我所说的“嵌套”到底是什么意思呢?我指的是如果我们有两个函数,按照数学约定我们称为 f[1] 和 f[2],其中一个函数的输出成为下一个函数的输入,这样我们就可以“串联”它们。
图表
表示嵌套函数最自然的方式是使用“迷你工厂”或“盒子”表示法(来自 “函数” 的第二种表示法)。
如 图 1-6 所示,一个输入进入第一个函数,被转换,然后出来;然后它进入第二个函数,再次被转换,我们得到最终输出。
图 1-6. 嵌套函数,自然地
数学
我们还应该包括不太直观的数学表示:
这是不太直观的,因为嵌套函数的怪癖是从“外到内”阅读,但实际上操作是“从内到外”执行的。例如,尽管 读作“f 2 of f 1 of x”,但它实际上意味着“首先将 f[1] 应用于 x,然后将 f[2] 应用于将 f[1] 应用于 x 的结果”。
代码
最后,为了遵守我承诺的从三个角度解释每个概念,我们将对此进行编码。首先,我们将为嵌套函数定义一个数据类型:
from typing import List
# A Function takes in an ndarray as an argument and produces an ndarray
Array_Function = Callable[[ndarray], ndarray]
# A Chain is a list of functions
Chain = List[Array_Function]
然后我们将定义数据如何通过长度为 2 的链传递:
def chain_length_2(chain: Chain,
a: ndarray) -> ndarray:
'''
Evaluates two functions in a row, in a "Chain".
'''
assert len(chain) == 2, \
"Length of input 'chain' should be 2"
f1 = chain[0]
f2 = chain[1]
return f2(f1(x))
另一个图表
使用盒子表示法描绘嵌套函数,我们可以看到这个复合函数实际上只是一个单一函数。因此,我们可以简单地表示这个函数为 f[1] f[2],如 图 1-7 所示。
图 1-7. 另一种思考嵌套函数的方式
此外,微积分中的一个定理告诉我们,由“大部分可微”的函数组成的复合函数本身也是大部分可微的!因此,我们可以将 f[1]f[2] 视为另一个我们可以计算导数的函数,计算复合函数的导数将对训练深度学习模型至关重要。
然而,我们需要一个公式来计算这个复合函数的导数,以其组成函数的导数表示。这将是我们接下来要讨论的内容。
链式法则
链式法则是一个数学定理,让我们能够计算复合函数的导数。深度学习模型在数学上是复合函数,推理它们的导数对于训练它们是至关重要的,我们将在接下来的几章中看到。
数学
从数学上讲,定理陈述了一个相当不直观的形式,即对于给定的值 x
,
其中 u 只是一个代表函数输入的虚拟变量。
注意
当描述具有一个输入和输出的函数 f 的导数时,我们可以将表示该函数导数的 函数 表示为 。我们可以用另一个虚拟变量代替 u —— 这无关紧要,就像 f(x) = x² 和 f(y) = y² 意思相同。
另一方面,稍后我们将处理接受多个输入的函数,比如,x和y。一旦到达那里,编写并且让它意味着与不同的东西就会有意义。
这就是为什么在前面的公式中,我们用底部的u表示所有的导数:f[1]和f[2]都是接受一个输入并产生一个输出的函数,在这种情况下(具有一个输入和一个输出的函数),我们将在导数符号中使用u。
图表
前面的公式并没有给出链式法则的太多直觉。对于这一点,框表示法更有帮助。让我们推理一下在简单情况下f[1] f[2]的导数“应该”是什么。
图 1-8。链式法则的示例
直觉上,使用图 1-8 中的图表,复合函数的导数应该是其组成函数的导数的一种乘积。假设我们将值 5 输入到第一个函数中,再假设在u=5 处计算第一个函数的导数得到一个值为 3,即,。
假设我们然后取出第一个框中的函数的值,假设它是 1,所以f1 = 1,并计算在这个值处第二个函数f[2]的导数:即,。我们发现这个值是-2。
如果我们将这些函数想象成字面上串在一起,那么如果将第二个框的输入改变 1 个单位会导致第二个框的输出变化-2 个单位,那么将第二个框的输入改变 3 个单位应该会导致第二个框的输出变化-2×3 = -6 个单位。这就是为什么在链式法则的公式中,最终结果最终是一个乘积: 乘以 。
因此,通过考虑图表和数学,我们可以通过链式法则推理出嵌套函数输出的导数与其输入应该是什么,代码指令可能是什么样的?
代码
让我们编写代码并展示以这种方式计算导数实际上会产生“看起来正确”的结果。我们将使用来自“NumPy 中的基本函数”的square
函数,以及sigmoid
,另一个在深度学习中变得重要的函数:
def sigmoid(x: ndarray) -> ndarray:
'''
Apply the sigmoid function to each element in the input ndarray.
'''
return 1 / (1 + np.exp(-x))
现在我们编写链式法则的代码:
def chain_deriv_2(chain: Chain,
input_range: ndarray) -> ndarray:
'''
Uses the chain rule to compute the derivative of two nested functions:
(f2(f1(x))' = f2'(f1(x)) * f1'(x)
'''
assert len(chain) == 2, \
"This function requires 'Chain' objects of length 2"
assert input_range.ndim == 1, \
"Function requires a 1 dimensional ndarray as input_range"
f1 = chain[0]
f2 = chain[1]
# df1/dx
f1_of_x = f1(input_range)
# df1/du
df1dx = deriv(f1, input_range)
# df2/du(f1(x))
df2du = deriv(f2, f1(input_range))
# Multiplying these quantities together at each point
return df1dx * df2du
图 1-9 绘制了结果,并显示链式法则有效:
PLOT_RANGE = np.arange(-3, 3, 0.01)
chain_1 = [square, sigmoid]
chain_2 = [sigmoid, square]
plot_chain(chain_1, PLOT_RANGE)
plot_chain_deriv(chain_1, PLOT_RANGE)
plot_chain(chain_2, PLOT_RANGE)
plot_chain_deriv(chain_2, PLOT_RANGE)
图 1-9。链式法则有效,第 1 部分
链式法则似乎有效。当函数是向上倾斜时,导数是正的;当函数是平的时,导数是零;当函数是向下倾斜时,导数是负的。
因此,我们实际上可以计算嵌套或“复合”函数的导数,如f[1] f[2],只要这些单独的函数本身大部分是可微的。
事实证明,深度学习模型在数学上是这些大部分可微函数的长链;花时间详细地手动通过一个稍微更长的例子将有助于建立您对正在发生的事情以及如何将其推广到更复杂模型的直觉。
稍微长一点的例子
让我们仔细研究一个稍微更长的链条:如果我们有三个大部分可微的函数—f[1]、f[2]和f[3]—我们将如何计算f[1] f[2] f[3]的导数?我们“应该”能够做到,因为根据之前提到的微积分定理,我们知道“大部分可微”函数的复合是可微的。
数学
数学上,结果是以下表达式:
为什么这个公式适用于长度为 2 的链条的基本逻辑,,在这里也适用——看公式本身缺乏直觉!
图表
要(字面上)看到这个公式为什么是有意义的,最好的方法是通过另一个盒子图表,如图 1-10 所示。
图 1-10。计算三个嵌套函数导数的“盒子模型”
使用类似的推理来自前一节:如果我们想象f[1] f[2] f[3]的输入(称为a)通过一根绳子连接到输出(称为b),那么将a改变一个小量Δ将导致f1 的变化为乘以Δ,这将导致(链中的下一步)的变化为乘以Δ,以此类推到第三步,当我们到达最终变化时,等于前述链式法则的完整公式乘以Δ。花一点时间阅读这个解释和之前的图表,但不要花太多时间,因为当我们编写代码时,我们将对此有更多的直觉。
代码
我们如何将这样的公式转化为代码指令,以计算导数,考虑到组成函数?有趣的是,在这个简单的例子中,我们已经看到了神经网络前向和后向传递的开端:
def chain_deriv_3(chain: Chain,
input_range: ndarray) -> ndarray:
'''
Uses the chain rule to compute the derivative of three nested functions:
(f3(f2(f1)))' = f3'(f2(f1(x))) * f2'(f1(x)) * f1'(x)
'''
assert len(chain) == 3, \
"This function requires 'Chain' objects to have length 3"
f1 = chain[0]
f2 = chain[1]
f3 = chain[2]
# f1(x)
f1_of_x = f1(input_range)
# f2(f1(x))
f2_of_x = f2(f1_of_x)
# df3du
df3du = deriv(f3, f2_of_x)
# df2du
df2du = deriv(f2, f1_of_x)
# df1dx
df1dx = deriv(f1, input_range)
# Multiplying these quantities together at each point
return df1dx * df2du * df3du
这里发生了一些有趣的事情——为了计算这个嵌套函数的链式法则,我们进行了两次“遍历”:
-
首先,我们“向前走”通过它,沿途计算量
f1_of_x
和f2_of_x
。我们可以称之为(并将其视为)“前向传递”。 -
然后,我们“向后走”,使用我们在前向传递中计算的量来计算组成导数的量。
最后,我们将这三个量相乘以得到我们的导数。
现在,让我们展示这是如何工作的,使用我们迄今为止定义的三个简单函数:sigmoid
、square
和 leaky_relu
。
PLOT_RANGE = np.range(-3, 3, 0.01)
plot_chain([leaky_relu, sigmoid, square], PLOT_RANGE)
plot_chain_deriv([leaky_relu, sigmoid, square], PLOT_RANGE)
图 1-11 显示了结果。
图 1-11. 链式法则有效,即使是三重嵌套函数
再次比较导数的图与原始函数的斜率,我们看到链式法则确实正确计算了导数。
现在让我们将我们的理解应用于具有多个输入的复合函数,这是一类遵循我们已经建立的相同原则并且最终更适用于深度学习的函数。
具有多个输入的函数
到目前为止,我们对如何将函数串联起来形成复合函数有了概念上的理解。我们也知道如何将这些函数表示为一系列输入和输出的方框。最后,我们已经了解了如何计算这些函数的导数,以便我们既从数学上又从“前向”和“后向”组件计算的过程中理解这些导数的数量。
在深度学习中,我们处理的函数通常不只有一个输入。相反,它们有几个输入,在某些步骤中被相加、相乘或以其他方式组合。正如我们将看到的,计算这些函数的输出对其输入的导数仍然不是问题:让我们考虑一个非常简单的具有多个输入的场景,其中两个输入被相加,然后通过另一个函数进行馈送。
数学
对于这个例子,实际上从数学上看是有用的。如果我们的输入是 x 和 y,那么我们可以将函数看作是分两步进行的。在第一步中,x 和 y 被馈送到一个将它们相加的函数中。我们将这个函数表示为 α(我们将使用希腊字母来引用函数名称),函数的输出为 a。形式上,这简单地表示为:
第二步是将 a 馈送到某个函数 σ 中(σ 可以是任何连续函数,如 sigmoid
,或 square
函数,甚至一个名称不以 s 开头的函数)。我们将这个函数的输出表示为 s:
我们可以等价地将整个函数表示为 f 并写成:
这更加数学上简洁,但它掩盖了这实际上是两个操作按顺序发生的事实。为了说明这一点,我们需要下一节中的图表。
图表
现在我们正在检查具有多个输入的函数,让我们暂停一下来定义一个我们一直在围绕的概念:用圆圈和连接它们的箭头表示数学“运算顺序”的图表可以被视为 计算图。例如,图 1-12 显示了我们刚刚描述的函数 f 的计算图。
图 1-12. 具有多个输入的函数
这里我们看到两个输入进入 α,作为 a 出来,然后通过 σ 进行馈送。
代码
编写这个代码非常简单;但是请注意,我们必须添加一个额外的断言:
def multiple_inputs_add(x: ndarray,
y: ndarray,
sigma: Array_Function) -> float:
'''
Function with multiple inputs and addition, forward pass.
'''
assert x.shape == y.shape
a = x + y
return sigma(a)
与本章前面看到的函数不同,这个函数不仅仅是在其输入 ndarray
的每个元素上“逐元素”操作。每当我们处理一个需要多个 ndarray
作为输入的操作时,我们必须检查它们的形状,以确保它们满足该操作所需的任何条件。在这里,对于一个简单的加法操作,我们只需要检查形状是否相同,以便可以逐元素进行加法。
具有多个输入的函数的导数
我们不应感到惊讶,我们可以计算这样一个函数的输出对其两个输入的导数。
图表
从概念上讲,我们只需做与具有一个输入的函数相同的事情:通过计算图“向后”计算每个组成函数的导数,然后将结果相乘以获得总导数。如图 1-13 所示。
图 1-13。通过具有多个输入的函数的计算图向后传递
数学
链式法则适用于这些函数,就像适用于前几节中的函数一样。由于这是一个嵌套函数,,我们有:
当然,也将是相同的。
现在注意:
因为对于x的每个单位增加,a都会增加一个单位,无论x的值如何(y的情况也是如此)。
有了这个,我们可以编写如何计算这种函数的导数。
代码
def multiple_inputs_add_backward(x: ndarray,
y: ndarray,
sigma: Array_Function) -> float:
'''
Computes the derivative of this simple function with respect to
both inputs.
'''
# Compute "forward pass"
a = x + y
# Compute derivatives
dsda = deriv(sigma, a)
dadx, dady = 1, 1
return dsda * dadx, dsda * dady
读者的一个简单练习是修改这个例子,使得x
和y
相乘而不是相加。
接下来,我们将研究一个更复杂的例子,更接近深度学习中发生的情况:与前一个例子类似的函数,但有两个向量输入。
具有多个向量输入的函数
在深度学习中,我们处理的函数的输入是向量或矩阵。这些对象不仅可以相加、相乘等,还可以通过点积或矩阵乘法进行组合。在本章的其余部分,我将展示如何使用正向和反向传递计算这些函数的导数的数学和逻辑仍然适用。
这些技术最终将成为理解为什么深度学习有效的核心。在深度学习中,我们的目标是将模型拟合到一些数据中。更准确地说,这意味着我们希望找到一个数学函数,将数据中的观察(将是函数的输入)映射到数据中的一些期望的预测(将是函数的输出),并以尽可能优化的方式。原来这些观察结果将被编码在矩阵中,通常每行作为一个观察,每列作为该观察的一个数值特征。我们将在下一章中更详细地介绍这一点;目前,能够推理复杂函数的导数,包括点积和矩阵乘法,将是至关重要的。
让我们首先准确地定义我所说的意思。
数学
在神经网络中表示单个数据点或“观察”的典型方式是将其表示为具有n个特征的行,其中每个特征只是一个数字x[1],x[2],等等,直到x[n]:
在这里要牢记的一个典型例子是预测房价,我们将在下一章中从头开始构建一个神经网络来实现这一点;在这个例子中,x[1],x[2]等等是房屋的数值特征,比如房屋的面积或其与学校的距离。
从现有特征创建新特征
神经网络中可能最常见的操作之一是形成这些特征的“加权和”,其中加权和可以强调某些特征并减弱其他特征,因此可以被视为一个新特征,它本身只是旧特征的组合。数学上简洁表达这一点的方法是将这个观察结果与与特征相同长度的一组“权重”进行点积,w[1],w[2],等等,直到w[n]。让我们从我们在本章迄今使用的三个角度来探讨这个概念。
数学
要在数学上准确,如果:
然后我们可以定义这个操作的输出为:
请注意,这个操作是矩阵乘法的特例,只是碰巧是点积,因为X有一行,W只有一列。
接下来,让我们看一下我们可以用图示来描述这个操作的几种方式。
图
一种简单的描述这个操作的方式如图 1-14 所示。
![dlfs 0114###### 图 1-14。矢量点积的图示这个图示描述了一个接受两个输入的操作,这两个输入都可以是ndarray
,并产生一个输出ndarray
。但这实际上是对许多操作进行了大量简写,这些操作发生在许多输入上。我们可以选择突出显示各个操作和输入,如图 1-15 和 1-16 所示。
图 1-15。矩阵乘法的另一个图示
图 1-16。矩阵乘法的第三个图示
关键点是点积(或矩阵乘法)是表示许多个体操作的简洁方式;此外,正如我们将在下一节中开始看到的,使用这个操作也使我们在反向传播中的导数计算变得极其简洁。
代码
最后,在代码中,这个操作只是:
def matmul_forward(X: ndarray,
W: ndarray) -> ndarray:
'''
Computes the forward pass of a matrix multiplication.
'''
assert X.shape[1] == W.shape[0], \
'''
For matrix multiplication, the number of columns in the first array should
match the number of rows in the second; instead the number of columns in the
first array is {0} and the number of rows in the second array is {1}.
'''.format(X.shape[1], W.shape[0])
# matrix multiplication
N = np.dot(X, W)
return N
我们有一个新的断言,确保矩阵乘法能够进行。(这是必要的,因为这是我们的第一个不仅仅处理大小相同的ndarray
并对元素进行操作的操作——我们的输出现在实际上与我们的输入大小不同。)
具有多个矢量输入的函数的导数
对于简单将一个数字作为输入并产生一个输出的函数,如f(x) = x²或f(x) = sigmoid(x),计算导数是直接的:我们只需应用微积分规则。对于矢量函数,导数并不是立即明显的:如果我们将点积写成,如前一节所示,自然会产生一个问题——和会是什么?
图
从概念上讲,我们只是想做类似于图 1-17 的事情。
图 1-17。矩阵乘法的反向传播,概念上
当我们只处理加法和乘法时,计算这些导数是很容易的,就像前面的例子一样。但是如何用矩阵乘法做类似的事情呢?要准确定义这一点,我们将不得不求助于数学。
数学
首先,我们如何定义“关于矩阵的导数”?回想一下,矩阵语法只是一堆数字以特定形式排列的简写,“关于矩阵的导数”实际上意味着“关于矩阵的每个元素的导数”。由于X是一行,自然的定义方式是:
然而,ν的输出只是一个数字:。观察这一点,我们可以看到,例如,如果变化了ϵ单位,那么N将变化单位——同样的逻辑也适用于其他x[i]元素。因此:
因此:
这是一个令人惊讶且优雅的结果,事实证明这是理解为什么深度学习有效以及如何实现得如此干净的关键部分。
通过类似的推理,我们可以看到:
代码
在这里,对答案“应该”是什么进行数学推理是困难的部分。简单的部分是编写结果的代码:
def matmul_backward_first(X: ndarray,
W: ndarray) -> ndarray:
'''
Computes the backward pass of a matrix multiplication with respect to the
first argument.
'''
# backward pass
dNdX = np.transpose(W, (1, 0))
return dNdX
这里计算的dNdX
数量代表了X的每个元素相对于输出N的和的偏导数。我们在整本书中将使用一个特殊的名称来称呼这个数量:我们将其称为X相对于X的梯度。这个想法是对于X的每个单独元素——比如,x[3]——向量点积N的输出相对于x[3]的偏导数对应的元素在dNdx
中(具体来说是dNdX[2]
)。在本书中,我们将使用术语“梯度”来指代偏导数的多维模拟;具体来说,它是一个函数的输出相对于该函数输入的每个元素的偏导数数组。
向量函数及其导数:再进一步
当然,深度学习模型涉及多个操作:它们包括一系列操作,其中一些是像上一节中介绍的向量函数,一些只是将函数逐个元素应用于它们作为输入接收的ndarray
。因此,我们现在将看看如何计算包含两种函数的复合函数的导数。假设我们的函数接受向量X和W,执行在前一节中描述的点积——我们将其表示为,然后将向量通过一个函数σ。我们将用新的语言表达与之前相同的目标:我们想计算这个新函数的输出相对于X和W的梯度。再次强调,从下一章开始,我们将详细了解这与神经网络的关系,但现在我们只是想建立这样一个概念:我们可以计算任意复杂计算图的梯度。
图表
这个函数的图表,显示在图 1-18 中,与图 1-17 中的相同,只是在末尾简单地添加了σ函数。
图 1-18. 与之前相同的图表,但在末尾添加了另一个函数
数学
从数学上讲,这同样很简单:
代码
最后,我们可以将这个函数编写成:
def matrix_forward_extra(X: ndarray,
W: ndarray,
sigma: Array_Function) -> ndarray:
'''
Computes the forward pass of a function involving matrix multiplication,
one extra function.
'''
assert X.shape[1] == W.shape[0]
# matrix multiplication
N = np.dot(X, W)
# feeding the output of the matrix multiplication through sigma
S = sigma(N)
return S
向量函数及其导数:反向传播
反向传播同样只是先前示例的直接扩展。
数学
由于f(X, W)是一个嵌套函数——具体来说,f(X, W) = σ(ν(X, W))——它对于例如X的导数在概念上应该是:
但这部分简单地是:
这是很明确的,因为σ只是一个连续函数,我们可以在任何点评估它的导数,这里我们只是在处评估它。
此外,我们在先前的示例中推理,。因此:
与先前示例中一样,由于最终答案是一个数字,,乘以W^(T)中与X相同形状的向量。
图表
这个函数的反向传播图,如图 1-19 所示,与先前示例的类似,甚至比数学更高级;我们只需要根据在矩阵乘法结果处评估的σ函数的导数再添加一个乘法。
图 1-19. 具有矩阵乘法的图:反向传播
代码
最后,编写反向传播也同样简单:
def matrix_function_backward_1(X: ndarray,
W: ndarray,
sigma: Array_Function) -> ndarray:
'''
Computes the derivative of our matrix function with respect to
the first element.
'''
assert X.shape[1] == W.shape[0]
# matrix multiplication
N = np.dot(X, W)
# feeding the output of the matrix multiplication through sigma
S = sigma(N)
# backward calculation
dSdN = deriv(sigma, N)
# dNdX
dNdX = np.transpose(W, (1, 0))
# multiply them together; since dNdX is 1x1 here, order doesn't matter
return np.dot(dSdN, dNdX)
请注意,我们在这里看到了与之前的三个嵌套函数示例中相同的动态:我们在正向传播中计算数量(这里只是N
),然后在反向传播中使用它们。
这样对吗?
我们如何知道我们计算的这些导数是否正确?一个简单的测试是稍微扰动输入,观察输出的变化。例如,在这种情况下,X是:
print(X)
[[ 0.4723 0.6151 -1.7262]]
如果我们将x[3]从-1.726增加到-1.716,我们应该看到正向函数产生的值增加了关于 x[3]的梯度 × 0.01。图 1-20 展示了这一点。
图 1-20. 梯度检查:一个示例
使用matrix_function_backward_1
函数,我们可以看到梯度是-0.1121
:
print(matrix_function_backward_1(X, W, sigmoid))
[[ 0.0852 -0.0557 -0.1121]]
为了测试这个梯度是否正确,我们应该看到,在将x[3]增加 0.01 后,函数的output大约减少0.01 × -0.1121 = -0.001121
;如果我们看到减少的数量多或少于这个量,或者出现增加,那么我们就知道我们对链式法则的推理是错误的。然而,当我们进行这个计算时,²,我们看到增加x[3]一点点确实会减少函数输出的值0.01 × -0.1121
——这意味着我们计算的导数是正确的!
在本章结束时,我们将介绍一个建立在我们迄今为止所做的一切基础上,并直接应用于我们将在下一章中构建的模型的示例:一个计算图,从将一对二维矩阵相乘开始。
带有两个 2D 矩阵输入的计算图
在深度学习中,以及更普遍地在机器学习中,我们处理的操作以两个二维数组作为输入,其中一个代表数据批次X,另一个代表权重W。在下一章中,我们将深入探讨为什么在建模上这是有意义的,但在本章中我们将专注于这个操作背后的机制和数学。具体来说,我们将详细介绍一个简单的例子,并展示即使涉及 2D 矩阵的乘法,而不仅仅是 1D 向量的点积,我们在本章中一直使用的推理仍然在数学上是有意义的,并且实际上非常容易编码。
和以前一样,推导这些结果所需的数学并不困难,但有些混乱。尽管如此,结果是相当干净的。当然,我们将一步一步地分解它,并始终将其与代码和图表联系起来。
数学
假设:
和:
这可能对应于一个数据集,其中每个观测具有三个特征,三行可能对应于我们想要进行预测的三个不同观测。
现在我们将对这些矩阵定义以下简单的操作:
-
将这些矩阵相乘。和以前一样,我们将把执行这个操作的函数表示为ν(X, W),输出为N,所以N = ν(X, W)。
-
通过一些可微函数σ将结果传递,定义(S = σ(N)。
和以前一样,现在的问题是:输出S对X和W的梯度是多少?我们能否再次简单地使用链式法则?为什么或为什么不?
如果你稍微思考一下,你可能会意识到与我们之前看过的例子有所不同:S 现在是一个矩阵,不再是一个简单的数字。毕竟,一个矩阵对另一个矩阵的梯度意味着什么呢?
这引出了一个微妙但重要的想法:我们可以对多维数组执行任何系列的操作,但为了定义对某个输出的“梯度”是有意义的,我们需要求和(或以其他方式聚合成一个数字)序列中的最终数组,以便“改变X的每个元素将如何影响输出”的概念甚至有意义。
因此,我们将在最后添加一个第三个函数Lambda,它只是取S的元素并将它们求和。
让我们在数学上具体化。首先,让我们将X和W相乘:
其中我们为方便起见将结果矩阵中的第i行第j列表示为。
接下来,我们将通过σ将这个结果馈送,这只是意味着将σ应用于矩阵的每个元素:
最后,我们可以简单地总结这些元素:
现在我们回到了一个纯粹的微积分环境:我们有一个数字L,我们想要计算L对X和W的梯度;也就是说,我们想知道改变这些输入矩阵的每个元素(x[11],w[21]等)会如何改变L。我们可以写成:
现在我们从数学上理解了我们面临的问题。让我们暂停一下数学,跟上我们的图表和代码。
图表
从概念上讲,我们在这里所做的与我们在以前的例子中使用多个输入的计算图所做的类似;因此,图 1-21 应该看起来很熟悉。
![dlfs 0121
图 1-21. 具有复杂前向传递的函数的图表
我们只是像以前一样将输入向前发送。我们声称即使在这种更复杂的情况下,我们也应该能够使用链式法则计算我们需要的梯度。
代码
我们可以编写如下代码:
def matrix_function_forward_sum(X: ndarray,
W: ndarray,
sigma: Array_Function) -> float:
'''
Computing the result of the forward pass of this function with
input ndarrays X and W and function sigma.
'''
assert X.shape[1] == W.shape[0]
# matrix multiplication
N = np.dot(X, W)
# feeding the output of the matrix multiplication through sigma
S = sigma(N)
# sum all the elements
L = np.sum(S)
return L
有趣的部分:反向传播
现在我们想要为这个函数“执行反向传播”,展示即使涉及矩阵乘法,我们也可以计算出对输入ndarray
的每个元素的N
梯度。有了这一最后一步,开始在第二章中训练真实的机器学习模型将变得简单。首先,让我们在概念上提醒自己我们正在做什么。
图表
再次,我们所做的与本章中之前的例子类似;图 1-22 应该和图 1-21 一样熟悉。
图 1-22. 通过我们复杂的函数的反向传播
我们只需要计算每个组成函数的偏导数,并在其输入处评估它,将结果相乘以得到最终的导数。让我们依次考虑这些偏导数;唯一的方法就是通过数学。
数学
首先要注意的是我们可以直接计算这个。值L确实是x[11]、x[12]等等的函数,一直到x[33]。
然而,这似乎很复杂。链式法则的整个意义不就是我们可以将复杂函数的导数分解为简单的部分,计算每个部分,然后将结果相乘吗?确实,这个事实使得编写这些代码变得如此容易:我们只需逐步进行前向传播,保存结果,然后使用这些结果来评估反向传播所需的所有必要导数。
我将展示当涉及矩阵时,这种方法只“部分”有效。让我们深入探讨。
我们可以将L写成。如果这是一个常规函数,我们只需写出链式法则:
然后我们依次计算这三个偏导数。这正是我们之前在三个嵌套函数的函数中所做的,我们使用链式法则计算导数,图 1-22 表明这种方法对这个函数也应该适用。
第一个导数是最直接的,因此是最好的热身。我们想知道L(Λ的输出)每个元素增加时L会增加多少。由于L是S的所有元素的和,这个导数就是:
因为增加S的任何元素,比如说,0.46 个单位,会使Λ增加 0.46 个单位。
接下来,我们有。这只是σ是什么函数的导数,在N中的元素处进行评估。在我们之前使用的“XW”语法中,这也很容易计算:
请注意,此时我们可以确定我们可以将这两个导数逐元素相乘并计算:
然而,现在我们卡住了。根据图表和应用链式法则,我们想要的下一步是。然而,请记住,N,ν的输出,只是X与W的矩阵乘法的结果。因此,我们想知道增加X的每个元素(一个 3×3 矩阵)将如何增加N的每个元素(一个 3×2 矩阵)。如果你对这种概念感到困惑,那就是关键所在——我们并不清楚如何定义这个概念,或者如果我们这样做是否有用。
为什么现在成为问题了?之前,我们很幸运地X和W在形状上是彼此的转置。在这种情况下,我们可以证明和。这里是否有类似的说法?
“?”
更具体地说,这就是我们卡住的地方。我们需要弄清楚“?”中应该填什么:
答案
事实证明,由于乘法的工作方式,填充“?”的内容只是W^(T),就像我们刚刚看到的向量点积的简单示例一样!验证这一点的方法是直接计算L对X的每个元素的偏导数;当我们这样做时,得到的矩阵确实(令人惊讶地)分解为:
第一个乘法是逐元素的,第二个是矩阵乘法。
这意味着*即使我们计算图中的操作涉及乘以具有多行和列的矩阵,即使这些操作的输出形状与输入的形状不同,我们仍然可以将这些操作包括在我们的计算图中,并使用“链式法则”逻辑进行反向传播。这是一个关键的结果,没有它,训练深度学习模型将会更加繁琐,你将在下一章中进一步体会到。
代码
让我们用代码封装我们刚刚推导出的内容,并希望在这个过程中巩固我们的理解:
def matrix_function_backward_sum_1(X: ndarray,
W: ndarray,
sigma: Array_Function) -> ndarray:
'''
Compute derivative of matrix function with a sum with respect to the
first matrix input.
'''
assert X.shape[1] == W.shape[0]
# matrix multiplication
N = np.dot(X, W)
# feeding the output of the matrix multiplication through sigma
S = sigma(N)
# sum all the elements
L = np.sum(S)
# note: I'll refer to the derivatives by their quantities here,
# unlike the math, where we referred to their function names
# dLdS - just 1s
dLdS = np.ones_like(S)
# dSdN
dSdN = deriv(sigma, N)
# dLdN
dLdN = dLdS * dSdN
# dNdX
dNdX = np.transpose(W, (1, 0))
# dLdX
dLdX = np.dot(dSdN, dNdX)
return dLdX
现在让我们验证一切是否正常:
np.random.seed(190204)
X = np.random.randn(3, 3)
W = np.random.randn(3, 2)
print("X:")
print(X)
print("L:")
print(round(matrix_function_forward_sum(X, W, sigmoid), 4))
print()
print("dLdX:")
print(matrix_function_backward_sum_1(X, W , sigmoid))
X:
[[-1.5775 -0.6664 0.6391]
[-0.5615 0.7373 -1.4231]
[-1.4435 -0.3913 0.1539]]
L:
2.3755
dLdX:
[[ 0.2489 -0.3748 0.0112]
[ 0.126 -0.2781 -0.1395]
[ 0.2299 -0.3662 -0.0225]]
与前面的例子一样,由于dLdX
表示X相对于L的梯度,这意味着,例如,左上角的元素表示。因此,如果这个例子的矩阵运算是正确的,那么将x[11]增加 0.001 应该使L增加0.01 × 0.2489
。实际上,我们看到这就是发生的:
X1 = X.copy()
X1[0, 0] += 0.001
print(round(
(matrix_function_forward_sum(X1, W, sigmoid) - \
matrix_function_forward_sum(X, W, sigmoid)) / 0.001, 4))
0.2489
看起来梯度计算正确!
描述这些梯度的可视化
回到本章开头我们注意到的内容,我们将问题中的元素x[11]通过一个包含许多操作的函数:矩阵乘法——实际上是将矩阵X中的九个输入与矩阵W中的六个输入组合在一起,得到六个输出——sigmoid
函数,然后是求和。然而,我们也可以将这看作是一个名为“”的单一函数,如图 1-23 所示。
图 1-23. 描述嵌套函数的另一种方式:作为一个函数,“WNSL”
由于每个函数都是可微的,整个过程只是一个可微函数,以x[11]为输入;因此,梯度就是回答问题“”的答案。为了可视化这一点,我们可以简单地绘制L随着x[11]的变化而变化的情况。看一下x[11]的初始值,我们看到它是-1.5775
:
print("X:")
print(X)
X:
[[-1.5775 -0.6664 0.6391]
[-0.5615 0.7373 -1.4231]
[-1.4435 -0.3913 0.1539]]
如果我们绘制从先前定义的计算图中将X和W输入的结果得到的L的值,或者换一种表示方法,从将X
和W
输入到前面代码中调用的函数中得到的结果,除了x[11](或X[0, 0]
)的值之外不改变,得到的图像看起来像图 1-24 所示。
图 1-24. L 与x[11]的关系,保持 X 和 W 的其他值不变
实际上,在x[11]的情况下,通过直观观察,这个函数沿着L轴增加的距离大约是 0.5(从略高于 2.1 到略高于 2.6),我们知道我们正在展示x[11]-轴上的变化为 2,这将使斜率大约为 —这正是我们刚刚计算的!
因此,我们复杂的矩阵数学实际上似乎已经使我们正确计算了相对于X的每个元素的L的偏导数。此外,相对于W的L的梯度也可以类似地计算。
注意
相对于W的L的梯度表达式将是X(*T*)。然而,由于*X*(T)表达式从L的导数中因子出现的顺序,X^(T)将位于相对于W的L的梯度表达式的左侧:
因此,在代码中,虽然我们会有dNdW = np.transpose(X, (1, 0))
,但下一步将是:
dLdW = np.dot(dNdW, dSdN)
而不是之前的dLdX = np.dot(dSdN, dNdX
。
结论
在本章之后,您应该有信心能够理解复杂的嵌套数学函数,并通过将它们概念化为一系列箱子,每个代表一个单一的组成函数,通过连接的字符串来推理出它们的工作原理。具体来说,您可以编写代码来计算这些函数的输出相对于任何输入的导数,即使涉及到包含二维ndarray
的矩阵乘法,也能理解这些导数计算背后的数学原理。这些基础概念正是我们在下一章开始构建和训练神经网络所需要的,以及在之后的章节中从头开始构建和训练深度学习模型所需要的。继续前进!
¹ 这将使我们能够轻松地在矩阵乘法中添加偏差。
² 在整个过程中,我将提供指向 GitHub 存储库的相关补充材料的链接,该存储库包含本书的代码,包括本章的代码。
³ 在接下来的部分中,我们将专注于计算N
相对于X
的梯度,但相对于W
的梯度也可以通过类似的方式推理。
⁴ 我们在“矩阵链规则”中进行了这样的操作。
⁵ 完整的函数可以在书的网站找到;它只是前一页显示的matrix function backward sum
函数的一个子集。
第二章:基础知识
在第一章中,我描述了理解深度学习的主要概念构建块:嵌套、连续、可微函数。我展示了如何将这些函数表示为计算图,图中的每个节点代表一个简单的函数。特别是,我演示了这种表示如何轻松地计算嵌套函数的输出相对于其输入的导数:我们只需对所有组成函数取导数,将这些导数在这些函数接收到的输入处进行评估,然后将所有结果相乘;这将导致嵌套函数的正确导数,因为链式法则。我用一些简单的例子说明了这实际上是有效的,这些函数以 NumPy 的ndarray
作为输入,并产生ndarray
作为输出。
我展示了即使在函数接受多个ndarray
作为输入并通过矩阵乘法操作将它们组合在一起时,计算导数的方法仍然有效,这与我们看到的其他操作不同,矩阵乘法操作会改变其输入的形状。具体来说,如果这个操作的一个输入——称为输入X——是一个 B × N 的ndarray
,另一个输入到这个操作的W是一个 N × M 的ndarray
,那么它的输出P是一个 B × M 的ndarray
。虽然这种操作的导数不太清楚,但我展示了当矩阵乘法ν(X, W)被包含为嵌套函数中的一个“组成操作”时,我们仍然可以使用一个简单的表达式代替它的导数来计算其输入的导数:具体来说,的作用可以由X^(T)来填充,的作用可以由W^(T)来扮演。
在本章中,我们将开始将这些概念转化为现实世界的应用,具体来说,我们将:
-
用这些基本组件来表达线性回归
-
展示我们在第一章中所做的关于导数的推理使我们能够训练这个线性回归模型
-
将这个模型(仍然使用我们的基本组件)扩展到一个单层神经网络
然后,在第三章中,使用这些相同的基本组件构建深度学习模型将变得简单。
在我们深入研究所有这些之前,让我们先概述一下监督学习,这是我们将专注于的机器学习的子集,我们将看到如何使用神经网络来解决问题。
监督学习概述
在高层次上,机器学习可以被描述为构建能够揭示或“学习”数据中的关系的算法;监督学习可以被描述为机器学习的子集,专注于找到已经被测量的数据特征之间的关系。¹
在本章中,我们将处理一个在现实世界中可能遇到的典型监督学习问题:找到房屋特征与房屋价值之间的关系。显然,诸如房间数量、平方英尺、或者与学校的距离等特征与一所房屋的居住或拥有价值之间存在某种关系。在高层次上,监督学习的目的是揭示这些关系,鉴于我们已经测量了这些特征。
所谓“度量”,我指的是每个特征都已经被精确定义并表示为一个数字。房屋的许多特征,比如卧室数量、平方英尺等,自然适合被表示为数字,但如果我们有其他不同类型的信息,比如来自 TripAdvisor 的房屋社区的自然语言描述,这部分问题将会变得不那么直接,将这种不太结构化的数据合理地转换为数字可能会影响我们揭示关系的能力。此外,对于任何模糊定义的概念,比如房屋的价值,我们只需选择一个单一的数字来描述它;在这里,一个明显的选择是使用房屋的价格。
一旦我们将我们的“特征”转换为数字,我们必须决定使用什么结构来表示这些数字。在机器学习中几乎是普遍的一种结构,也很容易进行计算,即将单个观察值的每组数字表示为数据的行,然后将这些行堆叠在一起形成数据的“批次”,这些数据将作为二维ndarray
输入到我们的模型中。我们的模型将返回预测作为输出ndarray
,每个预测在一行中,类似地堆叠在一起,每个批次中的每个观察值都有一个预测。
现在来看一些定义:我们说这个ndarray
中每行的长度是我们数据的特征数量。一般来说,单个特征可以映射到多个特征,一个经典的例子是描述我们的数据属于几个类别之一的特征,比如红砖房屋、黄褐砖房屋或板岩房屋;在这种特定情况下,我们可能用三个特征描述这个单个特征。将我们非正式认为的观察特征映射为特征的过程称为特征工程。我不会在本书中花太多时间讨论这个过程;事实上,在本章中,我们将处理一个每个观察值有 13 个特征的问题,我们只是用一个单一的数值特征表示每个特征。
我说过,监督学习的目标最终是揭示数据特征之间的关系。在实践中,我们通过选择一个我们想要从其他特征中预测的特征来实现这一点;我们称这个特征为我们的目标。选择哪个特征作为目标是完全任意的,取决于您要解决的问题。例如,如果您的目标只是描述房屋价格和房间数量之间的关系,您可以通过训练一个模型,将房屋价格作为目标,房间数量作为特征,或者反之;无论哪种方式,最终的模型都将包含这两个特征之间关系的描述,使您能够说,例如,房屋中房间数量较多与价格较高相关。另一方面,如果您的目标是预测房屋价格没有价格信息可用,您必须选择价格作为目标,这样您最终可以在模型训练后将其他信息输入模型中。
图 2-1 展示了监督学习的描述层次结构,从在数据中找到关系的最高级描述,到通过训练模型揭示特征和目标之间的数值表示的最低级别。
图 2-1. 监督学习概述
正如提到的,我们将几乎所有的时间花在图 2-1 底部突出显示的层次上;然而,在许多问题中,获得顶部部分的正确性——收集正确的数据,定义您要解决的问题,并进行特征工程——比实际建模要困难得多。然而,由于本书侧重于建模——具体来说,是理解深度学习模型的工作原理——让我们回到这个主题。
监督学习模型
现在我们在高层次上知道监督学习模型试图做什么了——正如我在本章前面所暗示的,这些模型只是嵌套的数学函数。我们在上一章中看到了如何用图表、数学和代码表示这样的函数,所以现在我可以更准确地用数学和代码来陈述监督学习的目标(稍后我会展示很多图表):目标是找到(一个数学函数)/(一个以ndarray
为输入并产生ndarray
为输出的函数),它可以(将观察特征映射到目标)/(给定一个包含我们创建的特征的输入ndarray
,产生一个输出ndarray
,其值“接近”包含目标的ndarray
)。
具体来说,我们的数据将用矩阵X表示,其中有n行,每一行代表一个具有k个特征的观察,所有这些特征都是数字。每行观察将是一个向量,如,这些观察将堆叠在一起形成一个批次。例如,大小为 3 的批次将如下所示:
对于每个观察批次,我们将有一个相应的目标批次,其中每个元素是相应观察的目标数值。我们可以用一维向量表示这些:
在这些数组方面,我们在监督学习中的目标是使用我在上一章中描述的工具来构建一个函数,该函数可以接受具有X[batch]结构的观察批次作为输入,并产生值向量p[i]——我们将其解释为“预测”——这些预测(至少对于我们特定数据集X中的数据)与某种合理的接近度量的目标值y[i]“接近”。
最后,我们准备具体化所有这些,并开始为真实数据集构建我们的第一个模型。我们将从一个简单的模型——线性回归开始,并展示如何用前一章的基本组件来表达它。
线性回归
线性回归通常显示为:
这种表示在数学上描述了我们的信念,即每个目标的数值是X的k个特征的线性组合,再加上β[0]项来调整预测的“基准”值(具体来说,当所有特征的值为 0 时将进行的预测)。
当然,这并没有让我们深入了解如何编写代码以便“训练”这样一个模型。为了做到这一点,我们必须将这个模型转化为我们在第一章中看到的函数语言;最好的起点是一个图表。
线性回归:一个图表
我们如何将线性回归表示为计算图?我们可以将其分解到最细的元素,每个x[i]都乘以另一个元素w[i],然后将结果相加,如图 2-2 所示。
图 2-2。线性回归的操作显示在个别乘法和加法的水平上
但是,正如我们在第一章中看到的,如果我们可以将这些操作表示为仅仅是矩阵乘法,我们将能够更简洁地编写函数,同时仍能正确计算输出相对于输入的导数,这将使我们能够训练模型。
我们如何做到这一点?首先,让我们处理一个更简单的情况,即我们没有截距项(β[0]之前显示)。请注意,我们可以将线性回归模型的输出表示为每个观察向量的点积与我们将称之为W的另一个参数向量进行点积:
我们的预测将简单地是:
因此,我们可以用一个操作来表示线性回归的“生成预测”:点积。
此外,当我们想要使用一批次观察进行线性回归预测时,我们可以使用另一个单一操作:矩阵乘法。例如,如果我们有一个大小为 3 的批次:
然后执行这批次X[batch]与W的矩阵乘法,得到一批次的预测向量,如所需:
因此,用矩阵乘法生成一批次观察的线性回归预测是可以的。接下来,我将展示如何利用这一事实,以及从前一章推导出的关于导数的推理,来训练这个模型。
“训练”这个模型
“训练”一个模型是什么意思?在高层次上,模型^([4)接收数据,以某种方式与参数结合,并产生预测。例如,之前显示的线性回归模型接收数据X和参数W,并使用矩阵乘法产生预测p[batch]:
然而,要训练我们的模型,我们需要另一个关键信息:这些预测是否准确。为了了解这一点,我们引入与输入到函数中的一批次观察X[batch]相关联的目标向量y[batch],并计算一个关于y[batch]和p[batch]的函数的单个数字,表示模型对所做预测的“惩罚”。一个合理的选择是均方误差,简单地是我们模型的预测“偏离”的平均平方值:
得到这个我们可以称之为L的数字是关键的:一旦我们有了它,我们可以使用我们在第一章中看到的所有技术来计算L对W的每个元素的梯度。然后我们可以使用这些导数来更新 W 的每个元素,使 L 减少。重复这个过程多次,我们希望能够“训练”我们的模型;在本章中,我们将看到这在实践中确实可以起作用。为了清楚地看到如何计算这些梯度,我们将完成将线性回归表示为计算图的过程。
线性回归:更有帮助的图表(和数学)
图 2-3 展示了如何用上一章的图表来表示线性回归。
![线性回归简单###### 图 2-3。线性回归方程表达为计算图—深蓝色字母是函数的数据输入,浅蓝色 W 表示权重最后,为了强调我们仍然用这个图表示一个嵌套的数学函数,我们可以表示最终计算的损失值L为:## 加入截距将模型表示为图表在概念上向我们展示了如何向模型添加截距。我们只需在最后添加一个额外步骤,涉及添加一个“偏差”,如图 2-4 所示。
图 2-4。线性回归的计算图,最后添加了一个偏置项
然而,在继续编码之前,我们应该对正在发生的事情进行数学推理;添加了偏差后,我们模型预测p[i]的每个元素将是之前描述的点积,加上数量b:
请注意,由于线性回归中的截距应该只是一个单独的数字,而不是对每个观察值都不同,应该将相同的数字添加到传递给偏置操作的每个输入的每个观察值中;我们将在本章的后面部分讨论这对于计算导数意味着什么。
线性回归:代码
现在我们将把这些东西联系起来,并编写一个函数,该函数根据观察批次X[batch]及其相应目标y[batch]进行预测并计算损失。请记住,使用链式法则计算嵌套函数的导数涉及两组步骤:首先,我们执行“前向传递”,将输入依次通过一系列操作向前传递,并在进行操作时保存计算的量;然后我们使用这些量在反向传递期间计算适当的导数。
以下代码执行此操作,将在字典中保存在前向传递中计算的量;此外,为了区分在前向传递中计算的量和参数本身(我们也需要用于反向传递),我们的函数将期望接收一个包含参数的字典:
def forward_linear_regression(X_batch: ndarray,
y_batch: ndarray,
weights: Dict[str, ndarray])
-> Tuple[float, Dict[str, ndarray]]:
'''
Forward pass for the step-by-step linear regression.
'''
# assert batch sizes of X and y are equal
assert X_batch.shape[0] == y_batch.shape[0]
# assert that matrix multiplication can work
assert X_batch.shape[1] == weights['W'].shape[0]
# assert that B is simply a 1x1 ndarray
assert weights['B'].shape[0] == weights['B'].shape[1] == 1
# compute the operations on the forward pass
N = np.dot(X_batch, weights['W'])
P = N + weights['B']
loss = np.mean(np.power(y_batch - P, 2))
# save the information computed on the forward pass
forward_info: Dict[str, ndarray] = {}
forward_info['X'] = X_batch
forward_info['N'] = N
forward_info['P'] = P
forward_info['y'] = y_batch
return loss, forward_info
现在我们已经准备好开始“训练”这个模型了。接下来,我们将详细介绍这意味着什么以及我们将如何做到这一点。
训练模型
我们现在将使用上一章学到的所有工具来计算每个W中的w[i]的,以及。如何做到?嗯,由于这个函数的“前向传递”是通过一系列嵌套函数传递输入,因此反向传递将简单涉及计算每个函数的偏导数,在函数的输入处评估这些导数,然后将它们相乘在一起——尽管涉及矩阵乘法,但我们将能够使用上一章中涵盖的推理来处理这个问题。
计算梯度:图表
从概念上讲,我们希望得到类似于图 2-5 中所示的内容。
图 2-5。通过线性回归计算图的反向传递
我们简单地向后移动,计算每个组成函数的导数,并在前向传递时评估这些函数接收到的输入的导数,然后在最后将这些导数相乘在一起。这是足够简单的,所以让我们深入了解细节。
计算梯度:数学(和一些代码)
从图 2-5 中,我们可以看到我们最终想要计算的导数乘积是:
这里有三个组件;让我们依次计算每个组件。
首先:。由于对于Y和P中的每个元素,:
我们有点超前了,但请注意编写这个代码只是简单的:
dLdP = -2 * (Y - P)
接下来,我们有涉及矩阵的表达式: 。但由于 α 只是加法,我们在前一章节中对数字推理的逻辑同样适用于这里:将 N 的任何元素增加一个单位将使 增加一个单位。因此, 只是一个由 +1 组成的矩阵,形状与 N 相同。
因此,编码这个表达式只是:
dPdN = np.ones_like(N)
最后,我们有 。正如我们在上一章节中详细讨论的,当计算嵌套函数的导数时,其中一个组成函数是矩阵乘法时,我们可以假设:
在代码中,这只是简单的:
dNdW = np.transpose(X, (1, 0))
我们将对截距项执行相同的操作;因为我们只是将其添加,截距项对输出的偏导数就是 1:
dPdB = np.ones_like(weights['B'])
最后一步就是简单地将它们相乘在一起,确保我们根据我们在上一章节末尾推理出的正确顺序进行涉及 dNdW
和 dNdX
的矩阵乘法。
计算梯度:(完整)代码
请记住我们的目标是取出在前向传播中计算的或输入的所有内容——从 Figure 2-5 中的图表中,这将包括 X、W、N、B、P 和 y——并计算 和 。以下代码实现了这一点,接收 W 和 B 作为名为 weights
的字典中的输入,其余的量作为名为 forward_info
的字典中的输入:
def loss_gradients(forward_info: Dict[str, ndarray],
weights: Dict[str, ndarray]) -> Dict[str, ndarray]:
'''
Compute dLdW and dLdB for the step-by-step linear regression model.
'''
batch_size = forward_info['X'].shape[0]
dLdP = -2 * (forward_info['y'] - forward_info['P'])
dPdN = np.ones_like(forward_info['N'])
dPdB = np.ones_like(weights['B'])
dLdN = dLdP * dPdN
dNdW = np.transpose(forward_info['X'], (1, 0))
# need to use matrix multiplication here,
# with dNdW on the left (see note at the end of last chapter)
dLdW = np.dot(dNdW, dLdN)
# need to sum along dimension representing the batch size
# (see note near the end of this chapter)
dLdB = (dLdP * dPdB).sum(axis=0)
loss_gradients: Dict[str, ndarray] = {}
loss_gradients['W'] = dLdW
loss_gradients['B'] = dLdB
return loss_gradients
正如你所看到的,我们只需计算每个操作的导数,然后逐步将它们相乘在一起,确保我们按正确顺序进行矩阵乘法。正如我们很快将看到的,这实际上是有效的——在我们在上一章节围绕链式法则建立的直觉之后,这应该不会太令人惊讶。
注意
关于这些损失梯度的实现细节:我们将它们存储为一个字典,其中权重的名称作为键,增加权重影响损失的数量作为值。weights
字典的结构也是一样的。因此,我们将按照以下方式迭代我们模型中的权重:
for key in weights.keys():
weights[key] -= learning_rate * loss_grads[key]
以这种方式存储它们并没有什么特别之处;如果我们以不同的方式存储它们,我们只需迭代它们并以不同的方式引用它们。
使用这些梯度来训练模型。
现在我们只需一遍又一遍地运行以下过程:
-
选择一批数据。
-
运行模型的前向传播。
-
使用在前向传播中计算的信息运行模型的反向传播。
-
使用在反向传播中计算的梯度来更新权重。
这本书的这一章节的 Jupyter Notebook 包含一个名为 train
的函数,用于编写这个。这并不太有趣;它只是实现了前面的步骤,并添加了一些明智的事情,比如对数据进行洗牌以确保以随机顺序传递。关键的代码行在一个 for
循环内重复,如下所示:
forward_info, loss = forward_loss(X_batch, y_batch, weights)
loss_grads = loss_gradients(forward_info, weights)
for key in weights.keys(): # 'weights' and 'loss_grads' have the same keys
weights[key] -= learning_rate * loss_grads[key]
然后我们运行train
函数一定数量的周期,或者遍历整个训练数据集,如下所示:
train_info = train(X_train, y_train,
learning_rate = 0.001,
batch_size=23,
return_weights=True,
seed=80718)
train
函数返回train_info
,一个Tuple
,其中一个元素是代表模型学习内容的参数或权重。
注意
“参数”和“权重”这两个术语在深度学习中通常是可以互换使用的,因此在本书中我们将它们互换使用。
评估我们的模型:训练集与测试集
为了了解我们的模型是否揭示了数据中的关系,我们必须从统计学中引入一些术语和思考方式。我们认为收到的任何数据集都是从一个总体中抽取的样本。我们的目标始终是找到一个能够揭示总体关系的模型,尽管我们只看到了一个样本。
我们建立的模型可能会捕捉到样本中存在但在总体中不存在的关系。例如,在我们的样本中,黄色板岩房屋带有三个浴室可能相对便宜,我们构建的复杂神经网络模型可能会捕捉到这种关系,尽管在总体中可能不存在。这是一个被称为过拟合的问题。我们如何检测我们使用的模型结构是否可能存在这个问题?
解决方案是将我们的样本分成一个训练集和一个测试集。我们使用训练数据来训练模型(即迭代更新权重),然后我们在测试集上评估模型以估计其性能。
完整的逻辑是,如果我们的模型能够成功地发现从训练集到样本其余部分(我们的整个数据集)泛化的关系,那么同样的“模型结构”很可能会从我们的样本——再次强调,是我们的整个数据集——泛化到总体,这正是我们想要的。
评估我们的模型:代码
有了这个理解,让我们在测试集上评估我们的模型。首先,我们将编写一个函数,通过截断我们之前看到的forward_pass
函数来生成预测:
def predict(X: ndarray,
weights: Dict[str, ndarray]):
'''
Generate predictions from the step-by-step linear regression model.
'''
N = np.dot(X, weights['W'])
return N + weights['B']
然后我们只需使用train
函数之前返回的权重,并写:
preds = predict(X_test, weights) # weights = train_info[0]
这些预测有多好?请记住,目前我们还没有验证我们看似奇怪的定义模型的方法,即将模型定义为一系列操作,并通过使用损失的偏导数来调整涉及的参数,使用链式法则迭代地训练模型;因此,如果这种方法有效,我们应该感到高兴。
我们可以做的第一件事是查看我们的模型是否有效,即制作一个图,其中模型的预测值在 x 轴上,实际值在 y 轴上。如果每个点都恰好落在 45 度线上,那么模型就是完美的。图 2-6 显示了我们模型的预测值和实际值的图。
图 2-6。我们自定义线性回归模型的预测与实际值
我们的图看起来很不错,但让我们量化一下模型的好坏。有几种常见的方法可以做到这一点:
-
计算我们模型预测值与实际值之间的平均距离,即平均绝对误差:
def mae(preds: ndarray, actuals: ndarray): ''' Compute mean absolute error. ''' return np.mean(np.abs(preds - actuals))
-
计算我们模型预测值与实际值之间的平均平方距离,这个指标称为均方根误差:
def rmse(preds: ndarray, actuals: ndarray): ''' Compute root mean squared error. ''' return np.sqrt(np.mean(np.power(preds - actuals, 2)))
这个特定模型的值为:
Mean absolute error: 3.5643
Root mean squared error: 5.0508
均方根误差是一个特别常见的指标,因为它与目标在同一尺度上。如果我们将这个数字除以目标的平均值,我们可以得到一个预测值与实际值之间平均偏差的度量。由于y_test
的平均值为22.0776
,我们看到这个模型对房价的预测平均偏差为 5.0508 / 22.0776 ≅ 22.9%。
这些数字好吗?在包含本章代码的Jupyter Notebook中,我展示了使用最流行的 Python 机器学习库 Sci-Kit Learn 对这个数据集进行线性回归的结果,平均绝对误差和均方根误差分别为3.5666
和5.0482
,与我们之前计算的“基于第一原理”的线性回归几乎相同。这应该让你相信,我们在本书中迄今为止采取的方法实际上是一种用于推理和训练模型的有效方法!在本章后面以及下一章中,我们将把这种方法扩展到神经网络和深度学习模型。
分析最重要的特征
在开始建模之前,我们将数据的每个特征缩放为均值为 0,标准差为 1;这具有计算优势,我们将在第四章中更详细地讨论。这样做的好处是,对于线性回归来说,我们可以解释系数的绝对值与模型中不同特征的重要性相对应;较大的系数意味着该特征更重要。以下是系数:
np.round(weights['W'].reshape(-1), 4)
array([-1.0084, 0.7097, 0.2731, 0.7161, -2.2163, 2.3737, 0.7156,
-2.6609, 2.629 , -1.8113, -2.3347, 0.8541, -4.2003])
最后一个系数最大的事实意味着数据集中的最后一个特征是最重要的。
在图 2-7 中,我们将这个特征与我们的目标绘制在一起。
图 2-7。自定义线性回归中最重要的特征与目标
我们看到这个特征与目标确实强相关:随着这个特征的增加,目标值减少,反之亦然。然而,这种关系不是线性的。当特征从-2 变为-1 时,目标变化的预期量不等于特征从 1 变为 2 时的变化量。我们稍后会回到这个问题。
在图 2-8 中,我们将这个特征与模型预测之间的关系叠加到这个图中。我们将通过将以下数据馈送到我们训练过的模型中来生成这个图:
-
所有特征的值设置为它们的均值
-
最重要特征的值在-1.5 到 3.5 之间线性插值,这大致是我们数据中这个缩放特征的范围
图 2-8。自定义线性回归中最重要的特征与目标和预测
这幅图(字面上)展示了线性回归的一个局限性:尽管这个特征与目标之间存在一个视觉上明显且“可建模”的非线性关系,但由于其固有结构,我们的模型只能“学习”线性关系。
为了让我们的模型学习特征和目标之间更复杂、非线性的关系,我们将不得不构建一个比线性回归更复杂的模型。但是如何做呢?答案将以基于原则的方式引导我们构建一个神经网络。
从零开始的神经网络
我们刚刚看到如何从第一原理构建和训练线性回归模型。我们如何将这种推理链扩展到设计一个可以学习非线性关系的更复杂模型?中心思想是,我们首先进行许多线性回归,然后将结果馈送到一个非线性函数中,最后进行最后一次线性回归,最终进行预测。事实证明,我们可以通过与线性回归模型相同的方式推理出如何计算这个更复杂模型的梯度。
步骤 1:一堆线性回归
做“一堆线性回归”是什么意思?做一个线性回归涉及使用一组参数进行矩阵乘法:如果我们的数据X的维度是[batch_size, num_features]
,那么我们将它乘以一个维度为[num_features, 1]
的权重矩阵W,得到一个维度为[batch_size, 1]
的输出;对于批次中的每个观察值,这个输出只是原始特征的一个加权和。要做多个线性回归,我们只需将我们的输入乘以一个维度为[num_features, num_outputs]
的权重矩阵,得到一个维度为[batch_size, num_outputs]
的输出;现在,对于每个观察值,我们有num_outputs
个不同的原始特征的加权和。
这些加权和是什么?我们应该将它们中的每一个看作是一个“学习到的特征”——原始特征的组合,一旦网络训练完成,将代表其尝试学习的特征组合,以帮助准确预测房价。我们应该创建多少个学习到的特征?让我们创建 13 个,因为我们创建了 13 个原始特征。
步骤 2:一个非线性函数
接下来,我们将通过一个非线性函数来处理这些加权和;我们将尝试的第一个函数是在第一章中提到的sigmoid
函数。作为提醒,图 2-9 展示了sigmoid
函数。
图 2-9。从 x = -5 到 x = 5 绘制的 Sigmoid 函数
为什么使用这个非线性函数是个好主意?为什么不使用square
函数f(x) = x²,例如?有几个原因。首先,我们希望在这里使用的函数是单调的,以便“保留”输入的数字的信息。假设,给定输入的日期,我们的两个线性回归分别产生值-3 和 3。然后通过square
函数传递这些值将为每个产生一个值 9,因此任何接收这些数字作为输入的函数在它们通过square
函数传递后将“丢失”一个原始为-3,另一个为 3 的信息。
当然,第二个原因是这个函数是非线性的;这种非线性将使我们的神经网络能够建模特征和目标之间固有的非线性关系。
最后,sigmoid
函数有一个很好的性质,即它的导数可以用函数本身来表示:
我们将很快在神经网络的反向传播中使用sigmoid
函数时使用它。
步骤 3:另一个线性回归
最后,我们将得到的 13 个元素——每个元素都是原始特征的组合,通过sigmoid
函数传递,使它们的值都在 0 到 1 之间——并将它们输入到一个常规线性回归中,使用它们的方式与我们之前使用原始特征的方式相同。
然后,我们将尝试训练整个得到的函数,方式与本章前面训练标准线性回归的方式相同:我们将数据通过模型,使用链式法则来计算增加权重会增加(或减少)损失多少,然后在每次迭代中更新权重,以减少损失。随着时间的推移(我们希望),我们将得到比以前更准确的模型,一个已经“学会”了特征和目标之间固有非线性关系的模型。
根据这个描述,可能很难理解正在发生的事情,所以让我们看一个插图。
图表
图 2-10 是我们更复杂模型的图表。
图 2-10。将步骤 1-3 翻译成我们在第一章中看到的计算图的一种类型
你会看到我们从矩阵乘法和矩阵加法开始,就像以前一样。现在让我们正式定义一些之前提到的术语:当我们在嵌套函数中应用这些操作时,我们将称第一个矩阵用于转换输入特征的矩阵为权重矩阵,我们将称第二个矩阵,即添加到每个结果特征集的矩阵为偏置。这就是为什么我们将它们表示为W[1]和B[1]。
应用这些操作后,我们将通过一个 sigmoid 函数将结果传递,并再次用另一组权重和偏置(现在称为W[2]和B[2])重复这个过程,以获得我们的最终预测P。
另一个图表?
用这些单独的步骤来表示事物是否让你对正在发生的事情有直观的理解?这个问题涉及到本书的一个关键主题:要完全理解神经网络,我们必须看到多种表示,每一种都突出神经网络工作的不同方面。图 2-10 中的表示并没有给出关于网络“结构”的直觉,但它清楚地指示了如何训练这样一个模型:在反向传播过程中,我们将计算每个组成函数的偏导数,在该函数的输入处评估,然后通过简单地将所有这些导数相乘来计算损失相对于每个权重的梯度——就像我们在第一章中看到的简单链式法则示例中一样。
然而,还有另一种更标准的表示神经网络的方式:我们可以将我们原始特征中的每一个表示为圆圈。由于我们有 13 个特征,我们需要 13 个圆圈。然后我们需要 13 个圆圈来表示我们正在进行的“线性回归- Sigmoid”操作的 13 个输出。此外,每个这些圆圈都是我们原始 13 个特征的函数,所以我们需要将第一组 13 个圆圈中的所有圆圈连接到第二组中的所有圆圈。⁶
最后,所有这些 13 个输出都被用来做一个最终的预测,所以我们会再画一个圆圈来代表最终的预测,以及 13 条线显示这些“中间输出”与最终预测的“连接”。
图 2-11 显示了最终的图表。⁷
图 2-11. 神经网络的更常见(但在许多方面不太有用)的视觉表示
如果你以前读过关于神经网络的任何东西,你可能已经看到它们被表示为图 2-11 中的图表:作为连接它们的线的圆圈。虽然这种表示方法确实有一些优点——它让你一眼就能看到这是什么样的神经网络,有多少层等等——但它并没有给出实际计算的任何指示,或者这样一个网络可能如何训练。因此,虽然这个图表对你来说非常重要,因为你会在其他地方看到它,但它主要包含在这里,让你看到它与我们主要表示神经网络的方式之间的连接:作为连接它们的线的方框,其中每个方框代表一个函数,定义了模型在前向传递中应该发生什么以进行预测,以及模型在反向传递中应该学习什么。我们将在下一章中看到如何通过将每个函数编码为继承自基础Operation
类的 Python 类来更直接地在这些图表和代码之间进行转换——说到代码,让我们接着讨论。
代码
在编码时,我们遵循与本章早期更简单的线性回归函数相同的函数结构——将 weights
作为字典传入,并返回损失值和 forward_info
字典,同时用 图 2-10 中指定的操作替换内部操作:
def forward_loss(X: ndarray,
y: ndarray,
weights: Dict[str, ndarray]
) -> Tuple[Dict[str, ndarray], float]:
'''
Compute the forward pass and the loss for the step-by-step
neural network model.
'''
M1 = np.dot(X, weights['W1'])
N1 = M1 + weights['B1']
O1 = sigmoid(N1)
M2 = np.dot(O1, weights['W2'])
P = M2 + weights['B2']
loss = np.mean(np.power(y - P, 2))
forward_info: Dict[str, ndarray] = {}
forward_info['X'] = X
forward_info['M1'] = M1
forward_info['N1'] = N1
forward_info['O1'] = O1
forward_info['M2'] = M2
forward_info['P'] = P
forward_info['y'] = y
return forward_info, loss
即使我们现在处理的是一个更复杂的图表,我们仍然只是一步一步地通过每个操作,进行适当的计算,并在进行时将结果保存在 forward_info
中。
神经网络:向后传递
向后传递的工作方式与本章早期更简单的线性回归模型相同,只是步骤更多。
图表
作为提醒,这些步骤是:
-
计算每个操作的导数并在其输入处评估。
-
将结果相乘。
正如我们将再次看到的那样,这将因为链式法则而起作用。图 2-12 展示了我们需要计算的所有偏导数。
图 2-12. 与神经网络中的每个操作相关的偏导数将在向后传递中相乘
从概念上讲,我们希望计算所有这些偏导数,通过我们的函数向后追踪,然后将它们相乘以获得损失相对于每个权重的梯度,就像我们为线性回归模型所做的那样。
数学(和代码)
表 2-1 列出了这些偏导数以及与每个偏导数对应的代码行。
表 2-1. 神经网络的导数表
导数 | 代码 |
---|---|
dLdP = -(forward_info[*y*] - forward_info[*P*]) |
|
np.ones_like(forward_info[*M2*]) |
|
np.ones_like(weights[*B2*]) |
|
dM2dW2 = np.transpose(forward_info[*O1*], (1, 0)) |
|
dM2dO1 = np.transpose(weights[*W2*], (1, 0)) |
|
dO1dN1 = sigmoid(forward_info[*N1*] × (1 - sigmoid(forward_info[*N1*]) |
|
dN1dM1 = np.ones_like(forward_info[*M1*]) |
|
dN1dB1 = np.ones_like(weights[*B1*]) |
|
dM1dW1 = np.transpose(forward_info[*X*], (1, 0)) |
注意
我们计算相对于偏差项的损失梯度dLdB1
和dLdB2
的表达式时,必须沿行相加,以考虑通过的数据批次中每行添加相同偏差元素的事实。有关详细信息,请参阅“相对于偏差项的损失梯度”。
总体损失梯度
您可以在本章的书的 GitHub 页面上查看完整的loss_gradients
函数。此函数计算表 2-1 中的每个偏导数,并将它们相乘以获得相对于包含权重的ndarray
中的每个权重的损失梯度:
-
dLdW2
-
dLdB2
-
dLdW1
-
dLdB1
唯一的注意事项是,我们将计算dLdB1
和dLdB2
的表达式沿axis = 0
相加,如“相对于偏差项的损失梯度”中所述。
我们终于从头开始构建了我们的第一个神经网络!让我们看看它是否比我们的线性回归模型更好。
训练和评估我们的第一个神经网络
正如前向和后向传递对我们的神经网络与本章早期的线性回归模型一样有效,训练和评估也是相同的:对于每次数据迭代,我们通过前向传递函数将输入传递,通过后向传递计算损失相对于权重的梯度,然后使用这些梯度来更新权重。实际上,我们可以在训练循环内使用以下相同的代码:
forward_info, loss = forward_loss(X_batch, y_batch, weights)
loss_grads = loss_gradients(forward_info, weights)
for key in weights.keys():
weights[key] -= learning_rate * loss_grads[key]
区别仅仅在于forward_loss
和loss_gradients
函数的内部,以及weights
字典中,现在有四个键(W1
,B1
,W2
和B2
),而不是两个。事实上,这是本书的一个重要观点:即使对于非常复杂的架构,数学原理和高级训练程序与简单模型相同。
我们也可以以相同的方式从该模型中获得预测:
preds = predict(X_test, weights)
区别再次仅仅在于predict
函数的内部:
def predict(X: ndarray,
weights: Dict[str, ndarray]) -> ndarray:
'''
Generate predictions from the step-by-step neural network model.
'''
M1 = np.dot(X, weights['W1'])
N1 = M1 + weights['B1']
O1 = sigmoid(N1)
M2 = np.dot(O1, weights['W2'])
P = M2 + weights['B2']
return P
使用这些预测,我们可以在验证集上计算平均绝对误差和均方根误差,就像以前一样:
Mean absolute error: 2.5289
Root mean squared error: 3.6775
这两个值都明显低于之前的模型!查看图 2-13 中预测与实际值的图表显示了类似的改进。
图 2-13。神经网络回归中的预测值与目标值
从视觉上看,这些点比图 2-6 中更接近 45 度线。我鼓励您逐步查看本书的 GitHub 页面上的Jupyter Notebook并自行运行代码!
为什么会发生这种情况的两个原因
为什么这个模型看起来比之前的模型表现更好?回想一下,我们早期模型的最重要特征与目标之间存在非线性关系;尽管如此,我们的模型被限制为仅学习个体特征与目标之间的线性关系。我声称,通过将非线性函数加入到混合中,我们使我们的模型能够学习特征和目标之间的正确非线性关系。
让我们来可视化一下。图 2-14 展示了我们在线性回归部分展示过的相同图,绘制了模型中最重要特征的归一化数值以及目标值和在变化最重要特征值的同时通过输入其他特征的均值得到的预测值,最重要特征的值从-3.5 变化到 1.5,与之前一样。
图 2-14。最重要特征与目标值和预测值,神经网络回归
我们可以看到所示的关系(a)现在是非线性的,(b)更接近于这个特征和目标之间的关系(由点表示),这是期望的。因此,通过向我们的模型添加非线性函数,使其能够通过迭代更新权重来学习存在于输入和输出之间的非线性关系。
这就是为什么我们的神经网络表现比直接线性回归好的第一个原因。第二个原因是,我们的神经网络可以学习原始特征和目标之间的组合关系,而不仅仅是单个特征。这是因为神经网络使用矩阵乘法创建了 13 个“学习到的特征”,每个特征都是所有原始特征的组合,然后在这些学习到的特征之上实质上应用另一个线性回归。例如,通过在书籍网站上分享的一些探索性分析,我们可以看到模型学习到的 13 个原始特征的最重要组合是:
以及:
这些将与其他 11 个学习到的特征一起包含在神经网络的最后两层的线性回归中。
这两个因素——学习个体特征和目标之间的非线性关系,以及学习特征和目标之间的组合关系——是神经网络通常比实际问题上的直接回归表现更好的原因。
结论
在本章中,您学习了如何使用第一章中的基本构建块和心智模型来理解、构建和训练两个标准的机器学习模型来解决实际问题。我首先展示了如何使用计算图表示经典统计学中的简单机器学习模型——线性回归。这种表示允许我们计算出该模型的损失相对于模型参数的梯度,从而通过不断地从训练集中输入数据并更新模型参数来训练模型,使损失减少。
然后我们看到了这个模型的一个限制:它只能学习特征和目标之间的线性关系;这促使我们尝试构建一个能够学习特征和目标之间的非线性关系的模型,这导致我们构建了我们的第一个神经网络。您通过从头开始构建一个神经网络来学习神经网络的工作原理,并学习如何使用与我们训练线性回归模型相同的高级过程来训练它们。然后您从经验上看到神经网络的表现比简单线性回归模型更好,并学到了两个关键原因:神经网络能够学习特征和目标之间的非线性关系,还能够学习特征和目标之间的组合关系。
当然,我们之所以在本章结束时仍然涵盖一个相对简单的模型,是有原因的:以这种方式定义神经网络是一个非常手动的过程。定义前向传播涉及 6 个单独编码的操作,而反向传播涉及 17 个。然而,敏锐的读者会注意到这些步骤中存在很多重复,并通过适当定义抽象,我们可以从以个别操作定义模型(如本章中)转变为以这些抽象定义模型。这将使我们能够构建更复杂的模型,包括深度学习模型,同时加深我们对这些模型如何工作的理解。这就是我们将在下一章开始做的事情。继续前进!
另一种机器学习,无监督学习,可以被认为是在你已经测量过的事物和尚未被测量的事物之间找到关系。
尽管在现实世界的问题中,甚至如何选择价格都不明显:是房子上次卖出的价格吗?那些长时间没有上市的房子呢?在这本书中,我们将专注于数据的数值表示是明显的或已经为您决定的示例,但在许多现实世界的问题中,正确处理这一点至关重要。
你们大多数人可能知道这些被称为“分类”特征。
至少是我们在这本书中看到的那些。
此外,我们必须沿着轴 0 对dLdB
进行求和;我们将在本章后面更详细地解释这一步骤。
这突出了一个有趣的想法:我们可以有输出只连接到我们原始特征的一部分;这实际上就是卷积神经网络所做的。
嗯,不完全是:我们没有画出我们需要展示所有连接的 169 条线,以显示第一层和第二层“特征”之间的所有连接,但我们画出了足够多的线,以便您了解。
第三章:从头开始的深度学习
您可能没有意识到,但现在您已经具备回答本书开头提出的关于深度学习模型的关键问题的所有数学和概念基础:您了解神经网络是如何工作的——涉及矩阵乘法、损失和相对于该损失的偏导数的计算,以及这些计算为什么有效(即微积分中的链式法则)。通过从第一原理构建神经网络,将它们表示为一系列“构建块”,我们实现了这种理解。在本章中,您将学习将这些构建块本身表示为抽象的 Python 类,然后使用这些类构建深度学习模型;到本章结束时,您确实将完成“从头开始的深度学习”!
我们还将描述神经网络的描述与您之前可能听过的深度学习模型的更传统描述相匹配。例如,在本章结束时,您将了解深度学习模型具有“多个隐藏层”是什么意思。这实际上是理解一个概念的本质:能够在高级描述和实际发生的低级细节之间进行翻译。让我们开始朝着这个翻译的方向构建。到目前为止,我们只是根据低级别发生的操作来描述模型。在本章的第一部分中,我们将将模型的这种描述映射到常见的更高级概念,例如“层”,最终使我们能够更轻松地描述更复杂的模型。
深度学习定义:第一次尝试
“深度学习”模型是什么?在前一章中,我们将模型定义为由计算图表示的数学函数。这样的模型的目的是尝试将输入映射到输出,每个输入都来自具有共同特征的数据集(例如,表示房屋不同特征的单独输入),输出来自相关分布(例如,这些房屋的价格)。我们发现,如果我们将模型定义为一个包括参数作为某些操作的输入的函数,我们可以通过以下过程“拟合”它以最佳地描述数据:
-
反复通过模型传递观察数据,跟踪在这个“前向传递”过程中计算的量。
-
计算代表我们模型预测与期望输出或目标之间差距有多大的损失。
-
使用在“前向传递”中计算的量和在第一章中推导出的链式法则,计算每个输入参数最终对这个损失产生了多大影响。
-
更新参数的值,以便在下一组观察通过模型时,损失有望减少。
我们最初使用的模型只包含一系列线性操作,将我们的特征转换为目标(结果等同于传统的线性回归模型)。这带来了一个预期的限制,即即使在“最佳拟合”时,模型仍然只能表示特征和目标之间的线性关系。
然后我们定义了一个函数结构,首先应用这些线性操作,然后是一个非线性操作(sigmoid
函数),最后是一组线性操作。我们展示了通过这种修改,我们的模型可以学习更接近输入和输出之间真实的非线性关系,同时还具有额外的好处,即它可以学习输入特征和目标之间的组合关系。
这些模型与深度学习模型之间的联系是什么?我们将从一个有些笨拙的定义开始:深度学习模型由一系列操作表示,其中至少涉及两个非连续的非线性函数。
我将很快展示这个定义的来源,但首先要注意的是,由于深度学习模型只是一系列操作,训练它们的过程实际上与我们已经看到的简单模型的训练过程是相同的。毕竟,使得这个训练过程能够工作的是模型相对于其输入的可微性;正如在第一章中提到的,可微函数的组合是可微的,因此只要组成函数的各个操作是可微的,整个函数就是可微的,我们就能够使用刚刚描述的相同的四步训练过程来训练它。
然而,到目前为止,我们实际上训练这些模型的方法是通过手动编写前向和后向传递来计算这些导数,然后将适当的量相乘以获得导数。对于第二章中的简单神经网络模型,这需要 17 个步骤。由于我们在如此低的层次上描述模型,不清楚如何向这个模型添加更多复杂性(或者这到底意味着什么),甚至进行简单的更改,比如将另一个非线性函数替换为 sigmoid 函数。为了能够构建任意“深度”和其他“复杂”的深度学习模型,我们必须考虑在这 17 个步骤中哪里可以创建可重用的组件,比单个操作的层次更高,可以替换并构建不同的模型。为了指导我们创建哪些抽象,我们将尝试将我们一直在使用的操作映射到传统的神经网络描述,即由“层”、“神经元”等组成。
作为第一步,我们将不再重复编写相同的矩阵乘法和偏置添加,而是创建一个抽象来表示我们目前所使用的各个操作。
神经网络的构建模块:操作
Operation
类将代表我们神经网络中的一个组成函数。根据我们在模型中使用这些函数的方式,从高层次来看,它应该有forward
和backward
方法,每个方法接收一个ndarray
作为输入并输出一个ndarray
。一些操作,比如矩阵乘法,似乎有另一种特殊类型的输入,也是一个ndarray
:参数。在我们的Operation
类中——或者可能是在另一个继承自它的类中——我们应该允许params
作为另一个实例变量。
另一个观点是,似乎有两种类型的Operation
:一些,比如矩阵乘法,返回一个与其输入不同形状的ndarray
作为输出;相比之下,一些Operation
,比如sigmoid
函数,只是将某个函数应用于输入ndarray
的每个元素。那么,关于在我们的操作之间传递的ndarray
的形状,有什么“一般规则”呢?让我们考虑通过我们的Operation
传递的ndarray
:每个Operation
将在前向传递中向前发送输出,并在后向传递中接收一个“输出梯度”,这将代表Operation
输出的每个元素相对于损失的偏导数(由组成网络的其他Operation
计算)。此外,在后向传递中,每个Operation
将向后发送一个“输入梯度”,表示相对于输入的每个元素的损失的偏导数。
这些事实对我们的Operation
的工作方式施加了一些重要的限制,这将帮助我们确保我们正确计算梯度:
-
输出梯度
ndarray
的形状必须与输出的形状匹配。 -
Operation
在反向传递期间发送的输入梯度的形状必须与Operation
的输入的形状匹配。
一旦您在图表中看到这一切,一切都会更清晰;让我们接着看。
图表
这一切都总结在图 3-1 中,对于一个操作O
,它从操作N
接收输入,然后将输出传递给另一个操作P
。
图 3-1. 一个带有输入和输出的 Operation
图 3-2 涵盖了带有参数的Operation
的情况。
图 3-2. 一个带有输入、输出和参数的 ParamOperation
代码
有了这一切,我们可以将我们的神经网络的基本构建模块,即Operation
,写成:
class Operation(object):
'''
Base class for an "operation" in a neural network.
'''
def __init__(self):
pass
def forward(self, input_: ndarray):
'''
Stores input in the self._input instance variable
Calls the self._output() function.
'''
self.input_ = input_
self.output = self._output()
return self.output
def backward(self, output_grad: ndarray) -> ndarray:
'''
Calls the self._input_grad() function.
Checks that the appropriate shapes match.
'''
assert_same_shape(self.output, output_grad)
self.input_grad = self._input_grad(output_grad)
assert_same_shape(self.input_, self.input_grad)
return self.input_grad
def _output(self) -> ndarray:
'''
The _output method must be defined for each Operation.
'''
raise NotImplementedError()
def _input_grad(self, output_grad: ndarray) -> ndarray:
'''
The _input_grad method must be defined for each Operation.
'''
raise NotImplementedError()
对于我们定义的任何单个Operation
,我们将不得不实现_output
和_input_grad
函数,这两个函数的名称是因为它们计算的数量。
注意
我们主要为了教学目的而定义这样的基类:重要的是要有这样的心智模型,即所有您在深度学习中遇到的Operation
都符合这种向前发送输入和向后发送梯度的蓝图,前向传递时接收的形状与反向传递时发送的形状匹配,反之亦然。
我们将在本章后面定义迄今为止使用的特定Operation
,如矩阵乘法等。首先,我们将定义另一个从Operation
继承的类,专门用于涉及参数的Operation
:
class ParamOperation(Operation):
'''
An Operation with parameters.
'''
def __init__(self, param: ndarray) -> ndarray:
'''
The ParamOperation method
'''
super().__init__()
self.param = param
def backward(self, output_grad: ndarray) -> ndarray:
'''
Calls self._input_grad and self._param_grad.
Checks appropriate shapes.
'''
assert_same_shape(self.output, output_grad)
self.input_grad = self._input_grad(output_grad)
self.param_grad = self._param_grad(output_grad)
assert_same_shape(self.input_, self.input_grad)
assert_same_shape(self.param, self.param_grad)
return self.input_grad
def _param_grad(self, output_grad: ndarray) -> ndarray:
'''
Every subclass of ParamOperation must implement _param_grad.
'''
raise NotImplementedError()
与基本Operation
类似,一个单独的ParamOperation
还必须定义_param_grad
函数,除了_output
和_input_grad
函数。
我们现在已经正式化了迄今为止我们在模型中使用的神经网络构建模块。我们可以直接根据这些Operation
定义神经网络,但是有一个我们已经绕了一个半章的中间类,我们将首先定义它:Layer
。
神经网络的构建模块:层
就Operation
而言,层是一系列线性操作后跟一个非线性操作。例如,我们上一章的神经网络可以说有五个总操作:两个线性操作——权重乘法和添加偏置项——跟随sigmoid
函数,然后又两个线性操作。在这种情况下,我们会说前三个操作,包括非线性操作在内,构成第一层,而最后两个操作构成第二层。此外,我们说输入本身代表一种特殊类型的层,称为输入层(在编号层次上,这一层不计算,因此我们可以将其视为“零”层)。同样地,最后一层称为输出层。中间层——根据我们的编号,“第一个”——也有一个重要的名称:它被称为隐藏层,因为它是唯一一个在训练过程中我们通常不明确看到其值的层。
输出层是对层的这一定义的一个重要例外,因为它不一定必须对其应用非线性操作;这仅仅是因为我们通常希望这一层的输出值在负无穷到正无穷之间(或至少在 0 到正无穷之间),而非线性函数通常会将其输入“压缩”到与我们尝试解决的特定问题相关的该范围的某个子集(例如,sigmoid
函数将其输入压缩到 0 到 1 之间)。
图表
为了明确连接,图 3-3 显示了前一章中的神经网络的图表,其中将单独的操作分组到层中。
图 3-3。前一章中的神经网络,操作分组成层
您可以看到输入表示“输入”层,接下来的三个操作(以sigmoid
函数结束)表示下一层,最后两个操作表示最后一层。
当然,这相当繁琐。这就是问题所在:将神经网络表示为一系列单独的操作,同时清楚地显示神经网络的工作原理以及如何训练它们,对于比两层神经网络更复杂的任何东西来说都太“低级”。这就是为什么更常见的表示神经网络的方式是以层为单位,如图 3-4 所示。
图 3-4。以层为单位的前一章中的神经网络
与大脑的连接
最后,让我们将我们迄今所见的内容与您可能之前听过的概念之间建立最后一个连接:每个层可以说具有等于表示该层输出中每个观察的向量的维度的神经元数量。因此,前一个示例中的神经网络可以被认为在输入层有 13 个神经元,然后在隐藏层中有 13 个神经元(再次),在输出层中有一个神经元。
大脑中的神经元具有这样的特性,它们可以从许多其他神经元接收输入,只有当它们累积接收到的信号达到一定的“激活能量”时,它们才会“发射”并向前发送信号。神经网络的神经元具有类似的属性:它们确实根据其输入向前发送信号,但是输入仅通过非线性函数转换为输出。因此,这个非线性函数被称为激活函数,从中出来的值被称为该层的激活。¹
现在我们已经定义了层,我们可以陈述更传统的深度学习定义:深度学习模型是具有多个隐藏层的神经网络。
我们可以看到,这等同于早期纯粹基于“操作”定义的定义,因为层只是一系列具有非线性操作的“操作”,最后是一个非线性操作。
现在我们已经为我们的“操作”定义了一个基类,让我们展示它如何可以作为我们在前一章中看到的模型的基本构建模块。
构建模块上的构建模块
我们需要为前一章中的模型实现哪些特定的“操作”?根据我们逐步实现神经网络的经验,我们知道有三种:
-
输入与参数矩阵的矩阵乘法
-
添加偏置项
-
sigmoid
激活函数
让我们从WeightMultiply
“操作”开始:
class WeightMultiply(ParamOperation):
'''
Weight multiplication operation for a neural network.
'''
def __init__(self, W: ndarray):
'''
Initialize Operation with self.param = W.
'''
super().__init__(W)
def _output(self) -> ndarray:
'''
Compute output.
'''
return np.dot(self.input_, self.param)
def _input_grad(self, output_grad: ndarray) -> ndarray:
'''
Compute input gradient.
'''
return np.dot(output_grad, np.transpose(self.param, (1, 0)))
def _param_grad(self, output_grad: ndarray) -> ndarray:
'''
Compute parameter gradient.
'''
return np.dot(np.transpose(self.input_, (1, 0)), output_grad)
在前向传递中,我们简单地编码矩阵乘法,以及在反向传递中“向输入和参数发送梯度”的规则(使用我们在第一章末尾推理出的规则)。很快您将看到,我们现在可以将其用作我们可以简单插入到我们的“层”中的构建模块。
接下来是加法操作,我们将其称为BiasAdd
:
class BiasAdd(ParamOperation):
'''
Compute bias addition.
'''
def __init__(self,
B: ndarray):
'''
Initialize Operation with self.param = B.
Check appropriate shape.
'''
assert B.shape[0] == 1
super().__init__(B)
def _output(self) -> ndarray:
'''
Compute output.
'''
return self.input_ + self.param
def _input_grad(self, output_grad: ndarray) -> ndarray:
'''
Compute input gradient.
'''
return np.ones_like(self.input_) * output_grad
def _param_grad(self, output_grad: ndarray) -> ndarray:
'''
Compute parameter gradient.
'''
param_grad = np.ones_like(self.param) * output_grad
return np.sum(param_grad, axis=0).reshape(1, param_grad.shape[1])
最后,让我们做sigmoid
:
class Sigmoid(Operation):
'''
Sigmoid activation function.
'''
def __init__(self) -> None:
'''Pass'''
super().__init__()
def _output(self) -> ndarray:
'''
Compute output.
'''
return 1.0/(1.0+np.exp(-1.0 * self.input_))
def _input_grad(self, output_grad: ndarray) -> ndarray:
'''
Compute input gradient.
'''
sigmoid_backward = self.output * (1.0 - self.output)
input_grad = sigmoid_backward * output_grad
return input_grad
这只是实现了前一章描述的数学。
注意
对于sigmoid
和ParamOperation
,在反向传播期间计算的步骤是:
input_grad = <something> * output_grad
是我们应用链规则的步骤,以及WeightMultiply
的相应规则:
np.dot(output_grad, np.transpose(self.param, (1, 0)))
正如我在第一章中所说的,当涉及的函数是矩阵乘法时,这相当于链规则的类比。
现在我们已经准确定义了这些Operation
,我们可以将它们用作定义Layer
的构建块。
层蓝图
由于我们编写了Operation
的方式,编写Layer
类很容易:
-
forward
和backward
方法只涉及将输入依次通过一系列Operation
向前传递 - 就像我们一直在图表中所做的那样!这是关于Layer
工作最重要的事实;代码的其余部分是围绕这一点的包装,并且主要涉及簿记:-
在
_setup_layer
函数中定义正确的Operation
系列,并在这些Operation
中初始化和存储参数(这也将在_setup_layer
函数中进行) -
在
forward
方法中存储正确的值在self.input_
和self.output
中 -
在
backward
方法中执行正确的断言检查
-
-
最后,
_params
和_param_grads
函数只是从层内的ParamOperation
中提取参数及其梯度(相对于损失)。
所有这些看起来是这样的:
class Layer(object):
'''
A "layer" of neurons in a neural network.
'''
def __init__(self,
neurons: int):
'''
The number of "neurons" roughly corresponds to the "breadth" of the
layer
'''
self.neurons = neurons
self.first = True
self.params: List[ndarray] = []
self.param_grads: List[ndarray] = []
self.operations: List[Operation] = []
def _setup_layer(self, num_in: int) -> None:
'''
The _setup_layer function must be implemented for each layer.
'''
raise NotImplementedError()
def forward(self, input_: ndarray) -> ndarray:
'''
Passes input forward through a series of operations.
'''
if self.first:
self._setup_layer(input_)
self.first = False
self.input_ = input_
for operation in self.operations:
input_ = operation.forward(input_)
self.output = input_
return self.output
def backward(self, output_grad: ndarray) -> ndarray:
'''
Passes output_grad backward through a series of operations.
Checks appropriate shapes.
'''
assert_same_shape(self.output, output_grad)
for operation in reversed(self.operations):
output_grad = operation.backward(output_grad)
input_grad = output_grad
self._param_grads()
return input_grad
def _param_grads(self) -> ndarray:
'''
Extracts the _param_grads from a layer's operations.
'''
self.param_grads = []
for operation in self.operations:
if issubclass(operation.__class__, ParamOperation):
self.param_grads.append(operation.param_grad)
def _params(self) -> ndarray:
'''
Extracts the _params from a layer's operations.
'''
self.params = []
for operation in self.operations:
if issubclass(operation.__class__, ParamOperation):
self.params.append(operation.param)
就像我们从抽象定义的Operation
转向实现神经网络所需的特定Operation
一样,让我们现在也实现该网络中的Layer
。
密集层
我们称我们一直在处理的Operation
为WeightMultiply
,BiasAdd
等等。到目前为止我们一直在使用的层应该叫什么?LinearNonLinear
层?
这一层的一个定义特征是每个输出神经元都是所有输入神经元的函数。这就是矩阵乘法真正做的事情:如果矩阵是行乘以列,那么乘法本身计算的是个新特征,每个特征都是所有个输入特征的加权线性组合。² 因此,这些层通常被称为全连接层;最近,在流行的Keras
库中,它们也经常被称为Dense
层,这是一个更简洁的术语,传达了相同的概念。
既然我们知道该如何称呼它以及为什么,让我们根据我们已经定义的操作来定义Dense
层 - 正如您将看到的,由于我们如何定义了我们的Layer
基类,我们所需要做的就是在_setup_layer
函数中将前一节中定义的Operation
作为列表放入其中。
class Dense(Layer):
'''
A fully connected layer that inherits from "Layer."
'''
def __init__(self,
neurons: int,
activation: Operation = Sigmoid()) -> None:
'''
Requires an activation function upon initialization.
'''
super().__init__(neurons)
self.activation = activation
def _setup_layer(self, input_: ndarray) -> None:
'''
Defines the operations of a fully connected layer.
'''
if self.seed:
np.random.seed(self.seed)
self.params = []
# weights
self.params.append(np.random.randn(input_.shape[1], self.neurons))
# bias
self.params.append(np.random.randn(1, self.neurons))
self.operations = [WeightMultiply(self.params[0]),
BiasAdd(self.params[1]),
self.activation]
return None
请注意,我们将默认激活函数设置为Linear
激活函数,这实际上意味着我们不应用激活函数,只是将恒等函数应用于层的输出。
在Operation
和Layer
之上,我们现在应该添加哪些构建块?为了训练我们的模型,我们知道我们将需要一个NeuralNetwork
类来包装Layer
,就像Layer
包装Operation
一样。不明显需要哪些其他类,所以我们将直接着手构建NeuralNetwork
,并在进行过程中找出我们需要的其他类。
神经网络类,也许还有其他类
我们的NeuralNetwork
类应该能做什么?在高层次上,它应该能够从数据中学习:更准确地说,它应该能够接收代表“观察”(X
)和“正确答案”(y
)的数据批次,并学习X
和y
之间的关系,这意味着学习一个能够将X
转换为非常接近y
的预测p
的函数。
鉴于刚刚定义的Layer
和Operation
类,这种学习将如何进行?回顾上一章的模型是如何工作的,我们将实现以下内容:
-
神经网络应该接受
X
并将其逐步通过每个Layer
(实际上是一个方便的包装器,用于通过许多Operation
进行馈送),此时结果将代表prediction
。 -
接下来,应该将
prediction
与值y
进行比较,计算损失并生成“损失梯度”,这是与网络中最后一个层(即生成prediction
的层)中的每个元素相关的损失的偏导数。 -
最后,我们将通过每个层将这个损失梯度逐步向后发送,同时计算“参数梯度”——损失对每个参数的偏导数,并将它们存储在相应的
Operation
中。
图
图 3-5 以Layer
的术语捕捉了神经网络的描述。
图 3-5。反向传播,现在以 Layer 而不是 Operation 的术语
代码
我们应该如何实现这一点?首先,我们希望我们的神经网络最终处理Layer
的方式与我们的Layer
处理Operation
的方式相同。例如,我们希望forward
方法接收X
作为输入,然后简单地执行类似以下的操作:
for layer in self.layers:
X = layer.forward(X)
return X
同样,我们希望我们的backward
方法接收一个参数——我们最初称之为grad
——然后执行类似以下的操作:
for layer in reversed(self.layers):
grad = layer.backward(grad)
grad
将从哪里来?它必须来自损失,一个特殊的函数,它接收prediction
以及y
,然后:
-
计算代表网络进行该
prediction
的“惩罚”的单个数字。 -
针对每个
prediction
中的元素,发送一个梯度与损失相关的反向梯度。这个梯度是网络中最后一个Layer
将作为其backward
函数输入接收的内容。
在前一章的示例中,损失函数是prediction
和目标之间的平方差,相应地计算了prediction
相对于损失的梯度。
我们应该如何实现这一点?这个概念似乎很重要,值得拥有自己的类。此外,这个类可以类似于Layer
类实现,只是forward
方法将产生一个实际数字(一个float
)作为损失,而不是一个ndarray
被发送到下一个Layer
。让我们正式化这一点。
损失类
Loss
基类将类似于Layer
——forward
和backward
方法将检查适当的ndarray
的形状是否相同,并定义两个方法,_output
和_input_grad
,任何Loss
子类都必须定义:
class Loss(object):
'''
The "loss" of a neural network.
'''
def __init__(self):
'''Pass'''
pass
def forward(self, prediction: ndarray, target: ndarray) -> float:
'''
Computes the actual loss value.
'''
assert_same_shape(prediction, target)
self.prediction = prediction
self.target = target
loss_value = self._output()
return loss_value
def backward(self) -> ndarray:
'''
Computes gradient of the loss value with respect to the input to the
loss function.
'''
self.input_grad = self._input_grad()
assert_same_shape(self.prediction, self.input_grad)
return self.input_grad
def _output(self) -> float:
'''
Every subclass of "Loss" must implement the _output function.
'''
raise NotImplementedError()
def _input_grad(self) -> ndarray:
'''
Every subclass of "Loss" must implement the _input_grad function.
'''
raise NotImplementedError()
与Operation
类一样,我们检查损失向后发送的梯度与从网络的最后一层接收的prediction
的形状是否相同:
class MeanSquaredError(Loss):
def __init__(self)
'''Pass'''
super().__init__()
def _output(self) -> float:
'''
Computes the per-observation squared error loss.
'''
loss =
np.sum(np.power(self.prediction - self.target, 2)) /
self.prediction.shape[0]
return loss
def _input_grad(self) -> ndarray:
'''
Computes the loss gradient with respect to the input for MSE loss.
'''
return 2.0 * (self.prediction - self.target) / self.prediction.shape[0]
在这里,我们简单地编写均方误差损失公式的前向和反向规则。
这是我们需要从头开始构建深度学习的最后一个关键构建块。让我们回顾一下这些部分如何组合在一起,然后继续构建模型!
从零开始的深度学习
我们最终希望构建一个NeuralNetwork
类,使用图 3-5 作为指南,我们可以用来定义和训练深度学习模型。在我们深入编码之前,让我们准确描述一下这样一个类会是什么样的,以及它将如何与我们刚刚定义的Operation
、Layer
和Loss
类进行交互:
-
NeuralNetwork
将具有Layer
列表作为属性。Layer
将如先前定义的那样,具有forward
和backward
方法。这些方法接受ndarray
对象并返回ndarray
对象。 -
每个
Layer
在_setup_layer
函数期间的operations
属性中保存了一个Operation
列表。 -
这些
Operation
,就像Layer
本身一样,有forward
和backward
方法,接受ndarray
对象作为参数并返回ndarray
对象作为输出。 -
在每个操作中,
backward
方法中接收的output_grad
的形状必须与Layer
的output
属性的形状相同。在backward
方法期间向后传递的input_grad
的形状和input_
属性的形状也是如此。 -
一些操作具有参数(存储在
param
属性中);这些操作继承自ParamOperation
类。Layer
及其forward
和backward
方法的输入和输出形状的约束也适用于它们——它们接收ndarray
对象并输出ndarray
对象,input
和output
属性及其相应梯度的形状必须匹配。 -
NeuralNetwork
还将有一个Loss
。这个类将获取NeuralNetwork
最后一个操作的输出和目标,检查它们的形状是否相同,并计算损失值(一个数字)和一个ndarray
loss_grad
,该loss_grad
将被馈送到输出层,开始反向传播。
实现批量训练
我们已经多次介绍了逐批次训练模型的高级步骤。这些步骤很重要,值得重复:
-
通过模型函数(“前向传递”)将输入馈送。
-
计算代表损失的数字。
-
使用链式法则和在前向传递期间计算的量,计算损失相对于参数的梯度。
-
使用这些梯度更新参数。
然后我们将通过一批新数据并重复这些步骤。
将这些步骤转换为刚刚描述的NeuralNetwork
框架是直接的:
-
接收
X
和y
作为输入,都是ndarray
。 -
逐个将
X
通过每个Layer
向前传递。 -
使用
Loss
生成损失值和损失梯度以进行反向传播。 -
使用损失梯度作为网络
backward
方法的输入,该方法将计算网络中每一层的param_grads
。 -
在每一层上调用
update_params
函数,该函数将使用NeuralNetwork
的整体学习率以及新计算的param_grads
。
我们最终有了一个完整的神经网络定义,可以进行批量训练。现在让我们编写代码。
神经网络:代码
编写所有这些代码非常简单:
class NeuralNetwork(object):
'''
The class for a neural network.
'''
def __init__(self, layers: List[Layer],
loss: Loss,
seed: float = 1)
'''
Neural networks need layers, and a loss.
'''
self.layers = layers
self.loss = loss
self.seed = seed
if seed:
for layer in self.layers:
setattr(layer, "seed", self.seed)
def forward(self, x_batch: ndarray) -> ndarray:
'''
Passes data forward through a series of layers.
'''
x_out = x_batch
for layer in self.layers:
x_out = layer.forward(x_out)
return x_out
def backward(self, loss_grad: ndarray) -> None:
'''
Passes data backward through a series of layers.
'''
grad = loss_grad
for layer in reversed(self.layers):
grad = layer.backward(grad)
return None
def train_batch(self,
x_batch: ndarray,
y_batch: ndarray) -> float:
'''
Passes data forward through the layers.
Computes the loss.
Passes data backward through the layers.
'''
predictions = self.forward(x_batch)
loss = self.loss.forward(predictions, y_batch)
self.backward(self.loss.backward())
return loss
def params(self):
'''
Gets the parameters for the network.
'''
for layer in self.layers:
yield from layer.params
def param_grads(self):
'''
Gets the gradient of the loss with respect to the parameters for the
network.
'''
for layer in self.layers:
yield from layer.param_grads
有了这个NeuralNetwork
类,我们可以以更模块化、灵活的方式实现上一章中的模型,并定义其他模型来表示输入和输出之间的复杂非线性关系。例如,这里是如何轻松实例化我们在上一章中介绍的两个模型——线性回归和神经网络:³
linear_regression = NeuralNetwork(
layers=[Dense(neurons = 1)],
loss = MeanSquaredError(),
learning_rate = 0.01
)
neural_network = NeuralNetwork(
layers=[Dense(neurons=13,
activation=Sigmoid()),
Dense(neurons=1,
activation=Linear())],
loss = MeanSquaredError(),
learning_rate = 0.01
)
基本上我们已经完成了;现在我们只需反复将数据通过网络以便学习。然而,为了使这个过程更清晰、更容易扩展到下一章中将看到的更复杂的深度学习场景,定义另一个类来执行训练以及另一个类来执行“学习”,即根据反向传播计算的梯度实际更新NeuralNetwork
参数。让我们快速定义这两个类。
训练器和优化器
首先,让我们注意这些类与我们在第二章中用于训练网络的代码之间的相似之处。在那里,我们使用以下代码来实现用于训练模型的前述四个步骤:
# pass X_batch forward and compute the loss
forward_info, loss = forward_loss(X_batch, y_batch, weights)
# compute the gradient of the loss with respect to each of the weights
loss_grads = loss_gradients(forward_info, weights)
# update the weights
for key in weights.keys():
weights[key] -= learning_rate * loss_grads[key]
这段代码位于一个for
循环中,该循环反复将数据通过定义和更新我们的网络的函数。
有了我们现在的类,我们最终将在Trainer
类中的fit
函数内部执行这些操作,该函数将主要是对前一章中使用的train
函数的包装。 (完整的代码在本章的Jupyter Notebook中的书的 GitHub 页面上。)主要区别是,在这个新函数内部,前面代码块中的前两行将被替换为这一行:
neural_network.train_batch(X_batch, y_batch)
更新参数将在以下两行中进行,这将在一个单独的Optimizer
类中进行。最后,之前包围所有这些内容的for
循环将在包围NeuralNetwork
和Optimizer
的Trainer
类中进行。
接下来,让我们讨论为什么需要一个Optimizer
类以及它应该是什么样子。
优化器
在上一章描述的模型中,每个Layer
包含一个简单的规则,根据参数和它们的梯度来更新权重。正如我们将在下一章中提到的,我们可以使用许多其他更新规则,例如涉及梯度更新历史而不仅仅是在该迭代中传入的特定批次的梯度更新。创建一个单独的Optimizer
类将使我们能够灵活地将一个更新规则替换为另一个,这是我们将在下一章中更详细地探讨的内容。
描述和代码
基本的Optimizer
类将接受一个NeuralNetwork
,每次调用step
函数时,将根据它们当前的值、梯度和Optimizer
中存储的任何其他信息来更新网络的参数:
class Optimizer(object):
'''
Base class for a neural network optimizer.
'''
def __init__(self,
lr: float = 0.01):
'''
Every optimizer must have an initial learning rate.
'''
self.lr = lr
def step(self) -> None:
'''
Every optimizer must implement the "step" function.
'''
pass
以下是我们迄今为止看到的简单更新规则的实际情况,即随机梯度下降:
class SGD(Optimizer):
'''
Stochastic gradient descent optimizer.
'''
def __init__(self,
lr: float = 0.01) -> None:
'''Pass'''
super().__init__(lr)
def step(self):
'''
For each parameter, adjust in the appropriate direction, with the
magnitude of the adjustment based on the learning rate.
'''
for (param, param_grad) in zip(self.net.params(),
self.net.param_grads()):
param -= self.lr * param_grad
注意
请注意,虽然我们的NeuralNetwork
类没有_update_params
方法,但我们依赖于params()
和param_grads()
方法来提取正确的ndarray
以进行优化。
这就是基本的Optimizer
类;接下来让我们来看一下Trainer
类。
训练器
除了按照前面描述的方式训练模型外,Trainer
类还将NeuralNetwork
与Optimizer
连接在一起,确保后者正确训练前者。您可能已经注意到,在前一节中,我们在初始化Optimizer
时没有传入NeuralNetwork
;相反,我们将在不久后初始化Trainer
类时将NeuralNetwork
分配为Optimizer
的属性,使用以下代码行:
setattr(self.optim, 'net', self.net)
在下一小节中,我展示了一个简化但有效的Trainer
类的工作版本,目前只包含fit
方法。该方法为我们的模型训练了一定数量的epochs,并在每个一定数量的 epochs 后打印出损失值。在每个 epoch 中,我们:
-
在 epoch 开始时对数据进行洗牌
-
通过网络以批次方式传递数据,每传递完一个批次后更新参数
当我们通过Trainer
将整个训练集传递完时,该 epoch 结束。
训练器代码
下面是一个简单版本的Trainer
类的代码,我们隐藏了在fit
函数中使用的两个不言自明的辅助方法:generate_batches
,它从X_train
和y_train
生成用于训练的数据批次,以及permute_data
,它在每个 epoch 开始时对X_train
和y_train
进行洗牌。在train
函数中还包括一个restart
参数:如果为True
(默认值),则在调用train
函数时会重新初始化模型的参数为随机值:
class Trainer(object):
'''
Trains a neural network.
'''
def __init__(self,
net: NeuralNetwork,
optim: Optimizer)
'''
Requires a neural network and an optimizer in order for training to
occur. Assign the neural network as an instance variable to the optimizer.
'''
self.net = net
setattr(self.optim, 'net', self.net)
def fit(self, X_train: ndarray, y_train: ndarray,
X_test: ndarray, y_test: ndarray,
epochs: int=100,
eval_every: int=10,
batch_size: int=32,
seed: int = 1,
restart: bool = True) -> None:
'''
Fits the neural network on the training data for a certain number of
epochs. Every "eval_every" epochs, it evaluates the neural network on
the testing data.
'''
np.random.seed(seed)
if restart:
for layer in self.net.layers:
layer.first = True
for e in range(epochs):
X_train, y_train = permute_data(X_train, y_train)
batch_generator = self.generate_batches(X_train, y_train,
batch_size)
for ii, (X_batch, y_batch) in enumerate(batch_generator):
self.net.train_batch(X_batch, y_batch)
self.optim.step()
if (e+1) % eval_every == 0:
test_preds = self.net.forward(X_test)
loss = self.net.loss.forward(test_preds, y_test)
print(f"Validation loss after {e+1} epochs is {loss:.3f}")
在书的GitHub 存储库中的这个函数的完整版本中,我们还实现了提前停止,它执行以下操作:
-
它每
eval_every
个 epoch 保存一次损失值。 -
它检查验证损失是否低于上次计算时的值。
-
如果验证损失不更低,则使用
eval_every
个 epoch 之前的模型。
最后,我们已经准备好训练这些模型了!
将所有内容整合在一起
这是使用所有Trainer
和Optimizer
类以及之前定义的两个模型——linear_regression
和neural_network
来训练我们的网络的完整代码。我们将学习率设置为0.01
,最大迭代次数设置为50
,并且每10
次迭代评估我们的模型:
optimizer = SGD(lr=0.01)
trainer = Trainer(linear_regression, optimizer)
trainer.fit(X_train, y_train, X_test, y_test,
epochs = 50,
eval_every = 10,
seed=20190501);
Validation loss after 10 epochs is 30.295
Validation loss after 20 epochs is 28.462
Validation loss after 30 epochs is 26.299
Validation loss after 40 epochs is 25.548
Validation loss after 50 epochs is 25.092
使用来自第二章的相同模型评分函数,并将它们包装在一个eval_regression_model
函数中,我们得到以下结果:
eval_regression_model(linear_regression, X_test, y_test)
Mean absolute error: 3.52
Root mean squared error 5.01
这些结果与我们在上一章中运行的线性回归的结果类似,证实了我们的框架正在工作。
使用具有 13 个神经元的隐藏层的neural_network
模型运行相同的代码,我们得到以下结果:
Validation loss after 10 epochs is 27.434
Validation loss after 20 epochs is 21.834
Validation loss after 30 epochs is 18.915
Validation loss after 40 epochs is 17.193
Validation loss after 50 epochs is 16.214
eval_regression_model(neural_network, X_test, y_test)
Mean absolute error: 2.60
Root mean squared error 4.03
同样,这些结果与我们在上一章中看到的结果类似,它们比我们直接的线性回归要好得多。
我们的第一个深度学习模型(从头开始)
既然所有的设置都已经完成,定义我们的第一个深度学习模型就变得微不足道了:
deep_neural_network = NeuralNetwork(
layers=[Dense(neurons=13,
activation=Sigmoid()),
Dense(neurons=13,
activation=Sigmoid()),
Dense(neurons=1,
activation=LinearAct())],
loss=MeanSquaredError(),
learning_rate=0.01
)
我们甚至不会试图在这方面变得聪明(尚未)。我们只会添加一个与第一层具有相同维度的隐藏层,这样我们的网络现在有两个隐藏层,每个隐藏层有 13 个神经元。
使用与之前模型相同的学习率和评估计划进行训练会产生以下结果:
Validation loss after 10 epochs is 44.134
Validation loss after 20 epochs is 25.271
Validation loss after 30 epochs is 22.341
Validation loss after 40 epochs is 16.464
Validation loss after 50 epochs is 14.604
eval_regression_model(deep_neural_network, X_test, y_test)
Mean absolute error: 2.45
Root mean squared error 3.82
我们最终从头开始进行了深度学习,事实上,在这个真实世界的问题上,没有使用任何技巧(只是稍微调整学习率),我们的深度学习模型的表现略好于只有一个隐藏层的神经网络。
更重要的是,我们通过构建一个易于扩展的框架来实现这一点。我们可以很容易地实现其他类型的Operation
,将它们包装在新的Layer
中,并将它们直接放入其中,假设它们已经定义了_output
和_input_grad
方法,并且它们的输入、输出和参数的维度与它们各自的梯度相匹配。同样地,我们可以很容易地将不同的激活函数放入我们现有的层中,看看是否会降低我们的错误指标;我鼓励你克隆本书的GitHub 存储库并尝试一下!
结论和下一步
在下一章中,我将介绍几种技巧,这些技巧对于让我们的模型在面对比这个简单问题更具挑战性的问题时能够正确训练是至关重要的⁴——特别是定义其他Loss
和Optimizer
。我还将介绍调整学习率和在整个训练过程中修改学习率的其他技巧,并展示如何将这些技巧融入Optimizer
和Trainer
类中。最后,我们将看到 Dropout,这是一种新型的Operation
,已被证明对增加深度学习模型的训练稳定性至关重要。继续前进!
¹ 在所有激活函数中,sigmoid
函数最接近大脑中神经元的实际激活,它将输入映射到 0 到 1 之间,但一般来说,激活函数可以是任何单调的非线性函数。
² 正如我们将在第五章中看到的,这并不适用于所有层:例如,在卷积层中,每个输出特征是输入特征的一个小子集的组合。
³ 学习率 0.01 并不特殊;我们只是在写前一章时在实验过程中发现它是最佳的。
⁴ 即使在这个简单的问题上,稍微改变超参数可能会导致深度学习模型无法击败两层神经网络。克隆GitHub 存储库并尝试一下吧!
第四章:扩展
在上一章中,经过两章的推理,我们从第一原则出发,探讨了深度学习模型是什么以及它们应该如何工作,最终构建了我们的第一个深度学习模型,并训练它解决了相对简单的问题,即根据房屋的数值特征预测房价。然而,在大多数实际问题中,成功训练深度学习模型并不那么容易:虽然这些模型可以理论上找到任何可以被定义为监督学习问题的问题的最优解,但在实践中它们经常失败,而且确实很少有理论保证表明给定的模型架构实际上会找到一个好的解决方案。尽管如此,还是有一些被充分理解的技术可以使神经网络训练更有可能成功;这将是本章的重点。
我们将从数学上回顾神经网络“试图做什么”,即找到一个函数的最小值。然后我将展示一系列可以帮助网络实现这一目标的技术,展示它们在手写数字的经典 MNIST 数据集上的有效性。我们将从一个在深度学习分类问题中经常使用的损失函数开始,展示它显著加速学习(到目前为止,我们在本书中只涵盖了回归问题,因为我们还没有介绍这个损失函数,因此还没有能够公正地处理分类问题)。同样,我们将涵盖除 sigmoid 之外的激活函数,并展示为什么它们也可能加速学习,同时讨论激活函数的一般权衡。接下来,我们将涵盖动量,这是迄今为止我们一直使用的随机梯度下降优化技术中最重要(也是最直接)的扩展,同时简要讨论更高级的优化器可以做什么。最后,我们将涵盖三种互不相关但都至关重要的技术:学习率衰减、权重初始化和 dropout。正如我们将看到的,这些技术中的每一种都将帮助我们的神经网络找到逐渐更优的解决方案。
在第一章中,我们遵循了“图表-数学-代码”模型来介绍每个概念。在这里,每个技术并没有明显的图表,因此我们将从每个技术的“直觉”开始,然后跟随数学(通常比第一章简单得多),最后以代码结束,这实际上将包括将技术整合到我们构建的框架中,并精确描述它如何与我们在上一章中形式化的构建块互动。在这种精神下,我们将从神经网络试图做什么的“整体”直觉开始:找到一个函数的最小值。
关于神经网络的一些直觉
神经网络包含一堆权重;给定这些权重,以及一些输入数据 X
和 y
,我们可以计算出一个结果的“损失”。图 4-1 展示了神经网络的这种极高级别(但仍然正确)的视图。
图 4-1. 用权重来思考神经网络的简单方式
实际上,每个单独的权重与特征 X
、目标 y
、其他权重以及最终的损失 L
之间都有一些复杂的非线性关系。如果我们将这些绘制出来,改变权重的值,同时保持其他权重、X
和 y
的值恒定,并绘制出损失 L
的结果值,我们可能会看到类似于图 4-2 所示的情况。
图 4-2. 神经网络的权重与损失
当我们开始训练神经网络时,我们将每个权重初始化为图 4-2 中某个位置的值。然后,使用我们在反向传播过程中计算的梯度,我们迭代地更新权重,我们的第一个更新基于我们在初始值处选择的曲线的斜率。图 4-3 展示了这种几何解释,即根据梯度和学习率更新神经网络权重的含义。左侧的蓝色箭头代表重复应用此更新规则,学习率比右侧的红色箭头小;请注意,在这两种情况下,水平方向上的更新与权重值处曲线的斜率成比例(更陡的斜率意味着更大的更新)。
图 4-3。根据梯度和学习率更新神经网络权重的几何表示
训练深度学习模型的目标是将每个权重移动到使损失最小化的“全局”值。正如我们从图 4-3 中看到的,如果我们采取的步骤太小,我们可能会陷入“局部”最小值,这比全局最小值不太理想(遵循这种情况的权重路径由蓝色箭头表示)。如果步骤太大,我们可能会“反复跳过”全局最小值,即使我们接近它(这种情况由红色箭头表示)。这是调整学习率的基本权衡:如果学习率太小,我们可能会陷入局部最小值;如果学习率太大,它们可能会跳过全局最小值。
实际上,情况比这复杂得多。一个原因是神经网络中有成千上万,甚至数百万个权重,因此我们在一个具有成千上万维或数百万维的空间中寻找全局最小值。此外,由于我们在每次迭代中更新权重,并传入不同的X
和y
,我们试图找到最小值的曲线不断变化!后者是神经网络多年来受到怀疑的主要原因之一;看起来迭代地以这种方式更新权重实际上无法找到全局理想的解决方案。Yann LeCun 等人在 2015 年的一篇自然文章中最好地表达了这一点:
特别是,人们普遍认为简单的梯度下降会陷入糟糕的局部最小值,即权重配置,对于这些配置,任何微小的变化都不会减少平均误差。实际上,对于大型网络,糟糕的局部最小值很少是一个问题。无论初始条件如何,系统几乎总是达到非常相似质量的解决方案。最近的理论和实证结果强烈表明,局部最小值通常不是一个严重的问题。
因此,在实践中,图 4-3 不仅提供了一个很好的心智模型,解释了为什么学习率不应该太大或太小,还提供了为什么我们将在本章学习的许多技巧实际上有效的直觉。具备了对神经网络试图做什么的直觉,让我们开始研究这些技巧。我们将从一个损失函数开始,即softmax 交叉熵损失函数,这个损失函数在很大程度上有效,因为它能够为权重提供比我们在上一章看到的均方误差损失函数更陡的梯度。
Softmax 交叉熵损失函数
在第三章中,我们使用均方误差(MSE)作为我们的损失函数。这个函数有一个很好的性质,即它是凸的,这意味着预测与目标之间的距离越远,Loss
向后发送到网络Layer
的初始梯度就越陡峭,因此参数接收到的所有梯度也会更大。然而,在分类问题中,我们可以做得更好,因为在这种问题中我们知道网络输出的值应该被解释为概率;因此,每个值不仅应该在 0 到 1 之间,而且对于我们通过网络传递的每个观察值,概率向量应该总和为 1。Softmax 交叉熵损失函数利用这一点,为相同的输入产生比均方误差损失更陡的梯度。这个函数有两个组件:第一个是softmax函数,第二个组件是“交叉熵”损失;我们将依次介绍每个组件。
组件#1:Softmax 函数
对于一个具有N
个可能类别的分类问题,我们将使我们的神经网络为每个观察输出一个包含N
个值的向量。对于一个有三个类别的问题,这些值可以是:
[5, 3, 2]
数学
再次,由于这是一个分类问题,我们知道这应该被解释为概率向量(这个观察属于类别 1、2 或 3 的概率)。将这些值转换为概率向量的一种方法是简单地对它们进行标准化,求和并除以总和:
然而,事实证明有一种方法既产生更陡的梯度,又具有一些优雅的数学特性:softmax 函数。对于长度为 3 的向量,这个函数将被定义为:
直觉
Softmax 函数背后的直觉是,它相对于其他值更强烈地放大最大值,迫使神经网络在分类问题的背景下对其认为是正确的预测更“不中立”。让我们比较这两个函数,标准化和 softmax,对我们前面的概率向量会产生什么影响:
normalize(np.array([5,3,2]))
array([0.5, 0.3, 0.2])
softmax(np.array([5,3,2]))
array([0.84, 0.11, 0.04])
我们可以看到原始的最大值—5—比简单标准化数据时具有显着更高的值,而另外两个值比从normalize
函数输出时更低。因此,softmax
函数在标准化值和实际应用max
函数之间(在这种情况下将导致输出为array([1.0, 0.0, 0.0])
)的部分路径上,因此得名softmax
。
组件#2:交叉熵损失
请记住,任何损失函数都将接受一个概率向量和一个实际值向量。
数学
对于这些向量中的每个索引i
,交叉熵损失函数为:
直觉
要看到这作为损失函数是有道理的原因,考虑到由于y的每个元素都是 0 或 1,前述方程简化为:
现在我们可以更容易地将其分解。如果y=0,那么在区间 0 到 1 上的这个损失值与均方误差损失值的图像如图 4-4 所示。
![dlfs 0404
图 4-4。当时的交叉熵损失与 MSE
不仅在这个区间内交叉熵损失的惩罚要高得多,²而且它们的增长速度更快;事实上,当我们的预测与目标之间的差距接近 1 时,交叉熵损失的值会趋近于无穷!当y = 1 时的图形类似,只是“翻转”了(即,它围绕x = 0.5 的线旋转了 180 度)。
因此,对于我们知道输出将在 0 和 1 之间的问题,交叉熵损失产生的梯度比均方误差更陡。真正的魔力发生在我们将这种损失与 softmax 函数结合时——首先将神经网络输出通过 softmax 函数进行归一化,使值相加为 1,然后将得到的概率输入到交叉熵损失函数中。
让我们看看这在我们迄今为止一直使用的三类情况下是什么样子;从i = 1 开始的损失向量的分量的表达式,即给定观察值的损失的第一个分量,我们将表示为SCE[1]:
根据这个表达式,这种损失的梯度似乎对这种损失有点棘手。然而,有一个优雅的表达式,既易于数学书写,又易于实现:
这意味着 softmax 交叉熵的总梯度是:
就是这样!正如承诺的那样,最终的实现也很简单:
softmax_x = softmax(x, axis = 1)
loss_grad = softmax_x - y
让我们编写代码。
代码
回顾第三章,任何Loss
类都应该接收两个 2D 数组,一个是网络的预测,另一个是目标。每个数组中的行数是批量大小,列数是分类问题中的类别数n
;每个数组中的一行代表数据集中的一个观察值,行中的n
个值代表神经网络对该观察值属于每个n
类的概率的最佳猜测。因此,我们将需要对prediction
数组中的每一行应用softmax
。这会导致一个潜在的问题:接下来我们将把结果数值输入log
函数来计算损失。这应该让你担心,因为log(x)当x趋近于 0 时会趋向于负无穷,同样地,1 - x当x趋近于 1 时会趋向于无穷。为了防止可能导致数值不稳定的极大损失值,我们将裁剪 softmax 函数的输出,使其不小于 10^(–7)且不大于 10⁷。
最后,我们可以把所有东西放在一起!
class SoftmaxCrossEntropyLoss(Loss):
def __init__(self, eps: float=1e-9)
super().__init__()
self.eps = eps
self.single_output = False
def _output(self) -> float:
# applying the softmax function to each row (observation)
softmax_preds = softmax(self.prediction, axis=1)
# clipping the softmax output to prevent numeric instability
self.softmax_preds = np.clip(softmax_preds, self.eps, 1 - self.eps)
# actual loss computation
softmax_cross_entropy_loss = (
-1.0 * self.target * np.log(self.softmax_preds) - \
(1.0 - self.target) * np.log(1 - self.softmax_preds)
)
return np.sum(softmax_cross_entropy_loss)
def _input_grad(self) -> ndarray:
return self.softmax_preds - self.target
很快我将通过一些在 MNIST 数据集上的实验来展示这种损失是如何改进均方误差损失的。但首先让我们讨论选择激活函数涉及的权衡,并看看是否有比 sigmoid 更好的选择。
关于激活函数的说明
我们在第二章中争论说 sigmoid 是一个很好的激活函数,因为它:
-
是一个非线性和单调函数
-
对模型产生了“正则化”效果,将中间特征强制限制在一个有限范围内,具体在 0 和 1 之间
然而,Sigmoid 也有一个缺点,类似于均方误差损失的缺点:在反向传播过程中产生相对平坦的梯度。在反向传播过程中传递给 Sigmoid 函数(或任何函数)的梯度表示函数的输出最终对损失的影响有多大;因为 Sigmoid 函数的最大斜率为 0.25,这些梯度在向后传递到模型中的前一个操作时将最多被除以 4。更糟糕的是,当 Sigmoid 函数的输入小于-2 或大于 2 时,这些输入接收到的梯度几乎为 0,因为在x = -2 或x = 2 时,Sigmoid(x)几乎是平的。这意味着影响这些输入的任何参数将接收到较小的梯度,结果我们的网络可能学习速度较慢。³此外,如果在神经网络的连续层中使用多个 Sigmoid 激活函数,这个问题将会加剧,进一步减少神经网络中较早的权重可能接收到的梯度。
“另一个极端”的激活函数会是什么样子?
另一个极端:修正线性单元
修正线性单元(ReLU)激活是一种常用的激活函数,具有与 Sigmoid 相反的优缺点。如果x小于 0,则简单地定义 ReLU 为 0,否则为x。这在图 4-5 中有所展示。
![dlfs 0405###### 图 4-5。ReLU 激活这是一个“有效”的激活函数,因为它是单调的和非线性的。它产生比 Sigmoid 大得多的梯度——如果函数的输入大于 0,则为 1,否则为 0,平均为 0.5——而 Sigmoid 能产生的最大梯度为 0.25。ReLU 激活在深度神经网络架构中非常受欢迎,因为它的缺点(在小于或大于 0 的值之间绘制了一个尖锐的、有些任意的区别)可以通过其他技术来解决,包括本章将介绍的一些技术,而它的好处(产生大梯度)对于训练深度神经网络架构中的权重至关重要。然而,有一种激活函数介于这两者之间,我们将在本章的演示中使用:Tanh。### 一个中庸之道:TanhTanh 函数的形状与 Sigmoid 函数类似,但将输入映射到-1 到 1 之间的值。图 4-6 展示了这个函数。
图 4-6。Tanh 激活
这个函数产生的梯度比 Sigmoid 要陡峭得多;具体来说,Tanh 的最大梯度是 1,而 Sigmoid 的是 0.25。图 4-7 展示了这两个函数的梯度。
图 4-7。Sigmoid 导数与 Tanh 导数
此外,就像有易于表达的导数,也有易于表达的导数。
这里的重点是,无论架构如何,选择激活函数都涉及权衡:我们希望一个激活函数能够让我们的网络学习输入和输出之间的非线性关系,同时不会增加不必要的复杂性,使网络更难找到一个好的解决方案。例如,“Leaky ReLU”激活函数在输入到 ReLU 函数小于 0 时允许一个轻微的负斜率,增强了 ReLU 发送梯度向后的能力,“ReLU6”激活函数将 ReLU 的正端限制在 6,进一步引入了更多的非线性到网络中。然而,这两种激活函数都比 ReLU 更复杂;如果我们处理的问题相对简单,那么这些更复杂的激活函数可能会使网络学习变得更加困难。因此,在本书的其余部分中我们演示的模型中,我们将简单地使用 Tanh 激活函数,它很好地平衡了这些考虑。
既然我们已经选择了一个激活函数,让我们用它来进行一些实验。
实验
我们已经证明在我们的实验中使用Tanh
,所以让我们回到本节的原始目的:展示为什么 softmax 交叉熵损失在深度学习中如此普遍。我们将使用 MNIST 数据集,该数据集包含黑白手写数字的图像,每个图像为 28×28 像素,每个像素的值范围从 0(白色)到 255(黑色)。此外,该数据集已经预先划分为一个包含 60,000 个图像的训练集和一个包含额外 10,000 个图像的测试集。在本书的GitHub 存储库中,我们展示了一个帮助函数,使用以下代码行将图像及其对应的标签读入训练和测试集中:
X_train, y_train, X_test, y_test = mnist.load()
我们的目标是训练一个神经网络,让它学会识别图像中包含的从 0 到 9 的 10 个数字。
数据预处理
对于分类,我们必须执行one-hot 编码,将表示标签的向量转换为与预测相同形状的ndarray
:具体来说,我们将标签“0”映射到一个向量,第一个位置(索引 0)为 1,其他位置为 0,“1”映射到第二个位置(索引 1),以此类推:
最后,将数据缩放到均值为 0,方差为 1 总是有帮助的,就像我们在之前章节中对“真实世界”数据集所做的那样。然而,在这里,由于每个数据点都是一幅图像,我们不会将每个特征缩放为均值为 0,方差为 1,因为这样会导致相邻像素的值被不同的量改变,从而可能扭曲图像!相反,我们将为我们的数据集提供一个全局缩放,减去整体均值并除以整体方差(请注意,我们使用训练集的统计数据来缩放测试集):
X_train, X_test = X_train - np.mean(X_train), X_test - np.mean(X_train)
X_train, X_test = X_train / np.std(X_train), X_test / np.std(X_train)
模型
我们将定义我们的模型对每个输入有 10 个输出:一个输出对应于模型属于 10 个类别中的每一个的概率。由于我们知道每个输出都将是一个概率,我们将在最后一层给我们的模型一个sigmoid
激活。在本章中,为了说明我们描述的“训练技巧”是否真的增强了我们模型的学习能力,我们将使用一个一致的模型架构,即一个具有隐藏层神经元数量接近我们输入数量(784)和输出数量(10)几何平均值的两层神经网络:。
现在让我们转向我们的第一个实验,比较一个使用简单均方误差损失训练的神经网络和一个使用 softmax 交叉熵损失训练的神经网络。您看到的损失值是每个观察值的(请记住,平均交叉熵损失的绝对损失值将是均方误差损失的三倍)。如果我们运行:
model = NeuralNetwork(
layers=[Dense(neurons=89,
activation=Tanh()),
Dense(neurons=10,
activation=Sigmoid())],
loss = MeanSquaredError(),
seed=20190119)
optimizer = SGD(0.1)
trainer = Trainer(model, optimizer)
trainer.fit(X_train, train_labels, X_test, test_labels,
epochs = 50,
eval_every = 10,
seed=20190119,
batch_size=60);
calc_accuracy_model(model, X_test)
它给了我们:
Validation loss after 10 epochs is 0.611
Validation loss after 20 epochs is 0.428
Validation loss after 30 epochs is 0.389
Validation loss after 40 epochs is 0.374
Validation loss after 50 epochs is 0.366
The model validation accuracy is: 72.58%
现在让我们测试之前在本章中提到的一个观点:softmax 交叉熵损失函数将帮助我们的模型更快地学习。
实验:Softmax 交叉熵损失
首先让我们将前述模型更改为:
model = NeuralNetwork(
layers=[Dense(neurons=89,
activation=Tanh()),
Dense(neurons=10,
activation=Linear())],
loss = SoftmaxCrossEntropy(),
seed=20190119)
注意
由于我们现在将模型输出通过 softmax 函数作为损失的一部分,我们不再需要通过 sigmoid 激活函数。
然后我们对模型运行 50 个 epochs,得到以下结果:
Validation loss after 10 epochs is 0.630
Validation loss after 20 epochs is 0.574
Validation loss after 30 epochs is 0.549
Validation loss after 40 epochs is 0.546
Loss increased after epoch 50, final loss was 0.546, using the model from
epoch 40
The model validation accuracy is: 91.01%
确实,将我们的损失函数更改为一个给出更陡的梯度的函数,单独就能极大地提高我们模型的准确性!
当然,即使不改变我们的架构,我们也可以做得更好。在下一节中,我们将介绍动量,这是迄今为止我们一直使用的随机梯度下降优化技术最重要和最直接的扩展。
动量
到目前为止,我们在每个时间步只使用了一个“更新规则”来更新我们的权重。简单地对损失函数关于权重的导数进行计算,然后将权重朝着正确的方向移动。这意味着我们在Optimizer
中的_update_rule
函数看起来像这样:
update = self.lr*kwargs['grad']
kwargs['param'] -= update
首先让我们来看一下为什么我们可能希望将这个更新规则扩展到包含动量的直觉。
动量的直觉
回想一下图 4-3,它绘制了一个参数值与网络损失值之间的关系。想象一种情况,即参数值不断朝着同一个方向更新,因为损失值在每次迭代中都在减小。这就好比参数“滚动下山”,而每个时间步的更新值就好比参数的“速度”。然而,在现实世界中,物体不会瞬间停下并改变方向;这是因为它们具有动量,这只是说它们在某一时刻的速度不仅仅是受到那一时刻作用在它们身上的力的影响,还受到它们过去速度的累积影响,最近的速度权重更大。这种物理解释是应用动量到我们的权重更新的动机。在下一节中,我们将详细介绍这一点。
在优化器类中实现动量
基于动量的参数更新意味着每个时间步的参数更新将是过去时间步的参数更新的加权平均,权重呈指数衰减。因此,我们还需要选择第二个参数,动量参数,它将决定这种衰减的程度;它越高,每个时间步的权重更新将更多地基于参数的累积动量而不是当前速度。
数学
从数学上讲,如果我们的动量参数是μ,每个时间步的梯度是,我们的权重更新是:
例如,如果我们的动量参数是 0.9,我们将把一个时间步之前的梯度乘以 0.9,两个时间步之前的梯度乘以 0.9² = 0.81,三个时间步之前的梯度乘以 0.9³ = 0.729,依此类推,然后最后将所有这些加到当前时间步的梯度中,以获得当前时间步的整体权重更新。
代码
我们如何实现这个?我们是否每次想要更新权重时都必须计算无限和?
原来有一个更聪明的方法。我们的Optimizer
将跟踪一个表示参数更新历史的单独数量,除了每个时间步接收一个梯度。然后,在每个时间步,我们将使用当前梯度来更新这个历史,并根据这个历史计算实际的参数更新。由于动量在某种程度上基于与物理学的类比,我们将称这个数量为“速度”。
我们应该如何更新速度?原来我们可以使用以下步骤:
-
将其乘以动量参数。
-
添加梯度。
这导致速度在每个时间步取以下值,从t = 1 开始:
有了这个,我们可以使用速度作为参数更新!然后我们可以将其合并到一个名为SGDMomentum
的新的Optimizer
子类中;这个类将有step
和_update_rule
函数,如下所示:
def step(self) -> None:
'''
If first iteration: intialize "velocities" for each param.
Otherwise, simply apply _update_rule.
'''
if self.first:
# now we will set up velocities on the first iteration
self.velocities = [np.zeros_like(param)
for param in self.net.params()]
self.first = False
for (param, param_grad, velocity) in zip(self.net.params(),
self.net.param_grads(),
self.velocities):
# pass in velocity into the "_update_rule" function
self._update_rule(param=param,
grad=param_grad,
velocity=velocity)
def _update_rule(self, **kwargs) -> None:
'''
Update rule for SGD with momentum.
'''
# Update velocity
kwargs['velocity'] *= self.momentum
kwargs['velocity'] += self.lr * kwargs['grad']
# Use this to update parameters
kwargs['param'] -= kwargs['velocity']
让我们看看这个新的优化器是否可以改善我们网络的训练。
实验:带有动量的随机梯度下降
让我们用 MNIST 数据集上的一个隐藏层训练相同的神经网络,除了使用optimizer = SGDMomentum(lr=0.1, momentum=0.9)
作为优化器,而不是optimizer = SGD(lr=0.1)
:
Validation loss after 10 epochs is 0.441
Validation loss after 20 epochs is 0.351
Validation loss after 30 epochs is 0.345
Validation loss after 40 epochs is 0.338
Loss increased after epoch 50, final loss was 0.338, using the model from epoch 40
The model validation accuracy is: 95.51%
您可以看到损失明显降低,准确率明显提高,这只是将动量添加到我们的参数更新规则中的结果!⁶
当然,修改每次迭代中的参数更新的另一种方法是修改学习率;虽然我们可以手动更改初始学习率,但我们也可以使用某些规则在训练过程中自动衰减学习率。接下来将介绍最常见的这种规则。
学习率衰减
[学习率]通常是最重要的超参数,我们应该始终确保它已经调整。
Yoshua Bengio,深度架构基于梯度训练的实用建议,2012
随着训练的进行,学习率衰减的动机再次来自前一节中的图 4-3:虽然我们希望在训练开始时“迈大步”,但很可能随着我们继续迭代更新权重,我们会达到一个开始“跳过”最小值的点。请注意,这不一定会是一个问题,因为如果我们的权重与损失之间的关系“平滑下降”接近最小值,就像图 4-3 中一样,梯度的幅度会随着斜率的减小而自动减小。尽管如此,这可能不会发生,即使发生了,学习率衰减也可以让我们更精细地控制这个过程。
学习率衰减的类型
有不同的学习率衰减方式。最简单的是线性衰减,其中学习率从初始值线性下降到某个终端值,实际下降是在每个时期结束时实施的。更准确地说,在时间步t,如果我们想要开始的学习率是 ,我们的最终学习率是 ,那么我们每个时间步的学习率是:
其中N是总时期数。
另一种效果差不多的简单方法是指数衰减,其中学习率每个时期按照一个恒定的比例下降。这里的公式简单地是:
其中:
实现这些很简单:我们将初始化我们的Optimizer
,使其具有“最终学习率”final_lr
,初始学习率将在训练时期内向其衰减:
def __init__(self,
lr: float = 0.01,
final_lr: float = 0,
decay_type: str = 'exponential')
self.lr = lr
self.final_lr = final_lr
self.decay_type = decay_type
然后,在训练开始时,我们可以调用一个 _setup_decay
函数来计算每个时期学习率将衰减多少:
self.optim._setup_decay()
这些计算将实现我们刚刚看到的线性和指数学习率衰减公式:
def _setup_decay(self) -> None:
if not self.decay_type:
return
elif self.decay_type == 'exponential':
self.decay_per_epoch = np.power(self.final_lr / self.lr,
1.0 / (self.max_epochs-1))
elif self.decay_type == 'linear':
self.decay_per_epoch = (self.lr - self.final_lr) / (self.max_epochs-1)
然后,在每个时期结束时,我们将实际衰减学习率:
def _decay_lr(self) -> None:
if not self.decay_type:
return
if self.decay_type == 'exponential':
self.lr *= self.decay_per_epoch
elif self.decay_type == 'linear':
self.lr -= self.decay_per_epoch
最后,在fit
函数中,我们将在每个时期结束时从Trainer
中调用_decay_lr
函数:
if self.optim.final_lr:
self.optim._decay_lr()
让我们进行一些实验,看看这是否有助于改善训练。
实验:学习率衰减
接下来,我们尝试使用学习率衰减训练相同的模型架构。我们初始化学习率,使得整个运行过程中的“平均学习率”等于之前的学习率 0.1:对于线性学习率衰减的运行,我们将学习率初始化为 0.15,衰减到 0.05;对于指数衰减的运行,我们将学习率初始化为 0.2,衰减到 0.05。对于线性衰减运行:
optimizer = SGDMomentum(0.15, momentum=0.9, final_lr=0.05, decay_type='linear')
我们有:
Validation loss after 10 epochs is 0.403
Validation loss after 20 epochs is 0.343
Validation loss after 30 epochs is 0.282
Loss increased after epoch 40, final loss was 0.282, using the model from epoch 30
The model validation accuracy is: 95.91%
对于指数衰减的运行,有:
optimizer = SGDMomentum(0.2, momentum=0.9, final_lr=0.05, decay_type='exponential')
我们有:
Validation loss after 10 epochs is 0.461
Validation loss after 20 epochs is 0.323
Validation loss after 30 epochs is 0.284
Loss increased after epoch 40, final loss was 0.284, using the model from epoch 30
The model validation accuracy is: 96.06%
这些运行中“最佳模型”的损失分别为 0.282 和 0.284,明显低于之前的 0.338!
接下来:如何以及为什么更智能地初始化模型的权重。
权重初始化
正如我们在激活函数部分提到的,一些激活函数,如 sigmoid 和 Tanh,在其输入为 0 时具有最陡的梯度,随着输入远离 0,函数迅速变平。这可能会限制这些函数的有效性,因为如果许多输入的值远离 0,那么附加到这些输入的权重将在反向传播中接收到非常小的梯度。
这事实证明是我们现在处理的神经网络中的一个主要问题。考虑我们一直在研究的 MNIST 网络中的隐藏层。这一层将接收 784 个输入,然后将它们乘以一个权重矩阵,最终得到一些数量为n
的神经元(然后可选择性地为每个神经元添加偏置)。图 4-8 显示了我们神经网络的隐藏层(具有 784 个输入)中这些n
个值在通过Tanh
激活函数之前和之后的分布。
图 4-8. 激活函数和激活的输入分布
通过激活函数后,大多数激活要么是-1,要么是 1!这是因为每个特征在数学上被定义为:
由于我们将每个权重的方差初始化为 1( —和 )以及 对于独立的随机变量X[1]和X[2],我们有:
这使其标准差()略高于 28,这反映了我们在图 4-8 顶部看到的值的分布。
这告诉我们我们有一个问题。但问题是否仅仅是我们输入激活函数的特征不能“太分散”?如果是这个问题,我们可以简单地将特征除以某个值来减少它们的方差。然而,这引发了一个明显的问题:我们如何知道要将值除以多少?答案是,值应该根据输入到层中的神经元数量进行缩放。如果我们有一个多层神经网络,其中一层有 200 个神经元,下一层有 100 个,那么 200 个神经元层将传递前向值,其分布比 100 个神经元层更广。这是不希望的——我们不希望我们神经网络在训练期间学习的特征的尺度取决于向前传递的特征数量,原因与我们不希望我们网络的预测取决于输入特征的尺度相同。例如,如果我们将特征中的所有值乘以 2 或除以 2,我们的模型的预测不应受影响。
有几种方法可以纠正这个问题;这里我们将介绍最常见的一种,由前一段建议:我们可以根据它们连接的层中的神经元数量调整权重的初始方差,以便在前向传递期间传递给下一层的值和在反向传递期间传递给前一层的值大致具有相同的尺度。是的,我们也必须考虑反向传递,因为同样的问题也存在:层在反向传播期间接收的梯度的方差将直接取决于下一个层中的特征数量,因为这是将梯度向后发送到相关层的层。
数学和代码
那么,我们如何平衡这些问题?如果每一层有个神经元输入和个神经元输出,为了保持结果特征的方差在前向传递中恒定,每个权重的方差应该是:
同样,保持特征方差在向后传递过程中恒定的权重方差将是:
作为这些之间的折衷,最常被称为Glorot 初始化⁷涉及将每一层中权重的方差初始化为:
编码这很简单 - 我们为每一层添加一个weight_init
参数,并将以下内容添加到我们的_setup_layer
函数中:
if self.weight_init == "glorot":
scale = 2/(num_in + self.neurons)
else:
scale = 1.0
现在我们的模型将如下所示:
model = NeuralNetwork(
layers=[Dense(neurons=89,
activation=Tanh(),
weight_init="glorot"),
Dense(neurons=10,
activation=Linear(),
weight_init="glorot")],
loss = SoftmaxCrossEntropy(),
seed=20190119)
对于每一层指定weight_init="glorot"
。
实验:权重初始化
使用 Glorot 初始化运行与上一节相同的模型给出:
Validation loss after 10 epochs is 0.352
Validation loss after 20 epochs is 0.280
Validation loss after 30 epochs is 0.244
Loss increased after epoch 40, final loss was 0.244, using the model from epoch 30
The model validation accuracy is: 96.71%
对于具有线性学习率衰减的模型,以及:
Validation loss after 10 epochs is 0.305
Validation loss after 20 epochs is 0.264
Validation loss after 30 epochs is 0.245
Loss increased after epoch 40, final loss was 0.245, using the model from epoch 30
The model validation accuracy is: 96.71%
对于具有指数学习率衰减的模型。我们在这里看到损失再次显著下降,从我们之前实现的 0.282 和 0.284 下降到 0.244 和 0.245!请注意,通过所有这些更改,我们并没有增加模型的大小或训练时间;我们只是根据我们对神经网络尝试做的事情的直觉调整了训练过程,这是我在本章开头展示的。
在本章中,我们将介绍最后一种技术。作为动机,您可能已经注意到本章中使用的所有模型都不是深度学习模型;相反,它们只是具有一个隐藏层的神经网络。这是因为没有dropout,我们现在将学习的技术,深度学习模型很难有效训练而不会过拟合。
Dropout
在本章中,我展示了几种修改我们神经网络训练过程的方法,使其越来越接近全局最小值。您可能已经注意到,我们还没有尝试看似最明显的事情:向我们的网络添加更多层或每层更多的神经元。原因是简单地向大多数神经网络架构添加更多“火力”可能会使网络更难以找到很好泛化的解决方案。这里的直觉是,虽然向神经网络添加更多容量使其能够模拟输入和输出之间更复杂的关系,但也有可能导致网络过度拟合训练数据的解决方案。dropout 允许我们向神经网络添加容量,同时在大多数情况下减少网络过拟合的可能性。
具体来说dropout是什么?
定义
Dropout 只是在训练的每个前向传递中随机选择一层中的一定比例p的神经元,并将它们设置为 0。这种奇怪的技巧降低了网络的容量,但在许多情况下,经验上确实可以防止网络过拟合。这在更深层次的网络中尤其如此,因为所学习的特征构造上是多层抽象远离原始特征。
尽管 dropout 可以帮助我们的网络在训练过程中避免过拟合,但当预测时,我们仍然希望为我们的网络提供“最佳机会”来进行正确的预测。因此,Dropout
操作将有两种“模式”:一个“训练”模式,在该模式下应用 dropout,以及一个“推断”模式,在该模式下不应用 dropout。然而,这会导致另一个问题:将 dropout 应用于一层会平均减少传递的值的总体幅度,即如果以下层中的权重通常期望具有幅度M的值,则实际上得到的幅度为。当在推断模式下运行网络时,我们希望模拟这种幅度变化,因此,除了删除 dropout,我们将乘以所有值乘以 1 - p。
为了更清楚,让我们编写代码。
实施
我们可以将 dropout 实现为一个Operation
,我们将其附加到每一层的末尾。它将如下所示:
class Dropout(Operation):
def __init__(self,
keep_prob: float = 0.8):
super().__init__()
self.keep_prob = keep_prob
def _output(self, inference: bool) -> ndarray:
if inference:
return self.inputs * self.keep_prob
else:
self.mask = np.random.binomial(1, self.keep_prob,
size=self.inputs.shape)
return self.inputs * self.mask
def _input_grad(self, output_grad: ndarray) -> ndarray:
return output_grad * self.mask
在前向传播时,应用 dropout 时,我们保存一个代表被设置为 0 的单个神经元的“掩码”。然后,在反向传播时,我们将操作接收到的梯度乘以这个掩码。这是因为 dropout 使得输入值为 0 的梯度为 0(因为改变它们的值现在对损失没有影响),而其他梯度保持不变。
调整我们的框架的其余部分以适应 dropout
您可能已经注意到,在_output
方法中我们包含了一个影响是否应用 dropout 的inference
标志。为了正确调用此标志,我们实际上必须在整个训练过程中的几个其他地方添加它:
-
Layer
和NeuralNetwork
的forward
方法将接受inference
作为参数(默认为False
),并将该标志传递到每个Operation
中,以便每个Operation
在训练模式和推理模式下表现不同。 -
回想一下,在
Trainer
中,我们每eval_every
个周期在测试集上评估训练好的模型。现在,每次我们这样做时,我们将使用inference
标志等于True
进行评估:test_preds = self.net.forward(X_test, inference=True)
-
最后,我们在
Layer
类中添加了一个dropout
关键字;Layer
类的__init__
函数的完整签名现在如下所示:def __init__(self, neurons: int, activation: Operation = Linear(), dropout: float = 1.0, weight_init: str = "standard")
我们通过将以下内容添加到类的
_setup_layer
函数中来附加dropout
操作:if self.dropout < 1.0: self.operations.append(Dropout(self.dropout))
就是这样!让我们看看 dropout 是否有效。
实验:Dropout
首先,我们看到向现有模型添加 dropout 确实会减少损失。将 0.8 的 dropout(使 20%的神经元被设置为 0)添加到第一层,使我们的模型如下所示:
mnist_soft = NeuralNetwork(
layers=[Dense(neurons=89,
activation=Tanh(),
weight_init="glorot",
dropout=0.8),
Dense(neurons=10,
activation=Linear(),
weight_init="glorot")],
loss = SoftmaxCrossEntropy(),
seed=20190119)
使用与之前相同的超参数(指数权重衰减,从初始学习率 0.2 到最终学习率 0.05)对模型进行训练会得到以下结果:
Validation loss after 10 epochs is 0.285
Validation loss after 20 epochs is 0.232
Validation loss after 30 epochs is 0.199
Validation loss after 40 epochs is 0.196
Loss increased after epoch 50, final loss was 0.196, using the model from epoch 40
The model validation accuracy is: 96.95%
这是相比之前看到的又一个显著的损失减少:模型实现了最小损失 0.196,而之前是 0.244。
当我们添加更多层时,dropout 的真正力量就会显现出来。让我们将本章中一直在使用的模型改为深度学习模型,定义第一个隐藏层的神经元数量是之前的两倍(178),第二个隐藏层的神经元数量是之前的一半(46)。我们的模型如下所示:
model = NeuralNetwork(
layers=[Dense(neurons=178,
activation=Tanh(),
weight_init="glorot",
dropout=0.8),
Dense(neurons=46,
activation=Tanh(),
weight_init="glorot",
dropout=0.8),
Dense(neurons=10,
activation=Linear(),
weight_init="glorot")],
loss = SoftmaxCrossEntropy(),
seed=20190119)
注意在前两层中包含 dropout。
使用与之前相同的优化器训练这个模型会导致最小损失再次显著减少,并且准确性提高!
Validation loss after 10 epochs is 0.321
Validation loss after 20 epochs is 0.268
Validation loss after 30 epochs is 0.248
Validation loss after 40 epochs is 0.222
Validation loss after 50 epochs is 0.217
Validation loss after 60 epochs is 0.194
Validation loss after 70 epochs is 0.191
Validation loss after 80 epochs is 0.190
Validation loss after 90 epochs is 0.182
Loss increased after epoch 100, final loss was 0.182, using the model from epoch 90
The model validation accuracy is: 97.15%
然而,更重要的是,没有 dropout,这种改进是不可能的。以下是使用没有 dropout 的相同模型进行训练的结果:
Validation loss after 10 epochs is 0.375
Validation loss after 20 epochs is 0.305
Validation loss after 30 epochs is 0.262
Validation loss after 40 epochs is 0.246
Loss increased after epoch 50, final loss was 0.246, using the model from epoch 40
The model validation accuracy is: 96.52%
没有 dropout,深度学习模型的表现比只有一个隐藏层的模型更差——尽管参数超过两倍,训练时间也超过两倍!这说明了 dropout 对有效训练深度学习模型的重要性;事实上,dropout 是 2012 年开启现代深度学习时代的 ImageNet 获奖模型的重要组成部分。没有 dropout,您可能不会读到这本书!
结论
在本章中,您已经学习了一些改进神经网络训练的常见技术,既了解了它们为什么有效的直觉,也了解了它们如何工作的细节。为了总结这些内容,我们将为您提供一个清单,您可以尝试其中的一些方法来从您的神经网络中挤出一些额外的性能,无论领域如何:
-
将动量或其他类似有效的高级优化技术之一添加到您的权重更新规则中。
-
随着时间的推移,使用线性或指数衰减来衰减您的学习率,如本章所示,或者使用更现代的技术,如余弦衰减。事实上,更有效的学习率调度根据不仅是每个时代的损失,而且是测试集上的损失,仅在该损失未减少时才降低学习率。您应该尝试实现这个作为练习!
-
确保您的权重初始化的尺度是您层中神经元数量的函数(这在大多数神经网络库中默认完成)。
-
在网络中包含多个连续的全连接层时,添加 dropout,尤其是。
接下来,我们将讨论针对特定领域专门设计的高级架构,从卷积神经网络开始,这些网络专门用于理解图像数据。继续!
¹ 此外,正如我们在第三章中看到的,我们将这些梯度乘以学习率,以便更精细地控制权重的变化。
² 我们可以更具体一些:在区间 0 到 1 上, 的平均值是 1,而在相同区间上,x²的平均值只是。
³ 要直观地理解为什么会发生这种情况:想象一下一个权重w正在为一个特征f做出贡献(使得),在我们的神经网络的前向传播过程中,对于某个观察值,f = -10。因为在x = -10 时,sigmoid(x)非常平坦,改变w的值几乎不会对模型预测产生影响,因此也不会对损失产生影响。
⁴ 例如,TensorFlow 的 MNIST 分类教程使用softmax_cross_entropy_with_logits
函数,而 PyTorch 的nn.CrossEntropyLoss
实际上在其中计算 softmax 函数。
⁵ 您可能会认为 softmax 交叉熵损失在这里获得了“不公平的优势”,因为 softmax 函数对其接收到的值进行了归一化,使它们相加为 1,而均方误差损失只是得到了通过sigmoid
函数传递的 10 个输入,并没有被归一化为 1。然而,在书的网站上,我展示了即使将输入归一化为均方误差损失,使其对每个观察值求和为 1,均方误差仍然比 softmax 交叉熵损失表现更差。
⁶ 此外,动量只是我们可以利用当前数据批次之外的梯度信息来更新参数的一种方式;我们在附录 A 中简要介绍了其他更新规则,您可以在书的 GitHub 存储库中看到这些更新规则的实现。
⁷ 这样被称为是因为它是由 Glorot 和 Bengio 在 2010 年的一篇论文中提出的:“理解训练深度前馈神经网络的困难”。
⁸ 欲了解更多信息,请参阅 G. E. Hinton 等人的“通过防止特征检测器的共适应来改进神经网络”。
第五章:卷积神经网络
在本章中,我们将介绍卷积神经网络(CNNs)。CNNs 是用于预测的标准神经网络架构,当输入观察数据是图像时,这在各种神经网络应用中都是适用的情况。到目前为止,在本书中,我们专注于全连接神经网络,我们将其实现为一系列Dense
层。因此,我们将通过回顾这些网络的一些关键元素来开始本章,并用此来激发我们为什么可能想要为图像使用不同的架构。然后,我们将以与本书中介绍其他概念类似的方式介绍 CNNs:我们将首先讨论它们在高层次上的工作原理,然后转向在低层次上讨论它们,最后通过编写从头开始的卷积操作的代码详细展示它们的工作方式。到本章结束时,您将对 CNNs 的工作原理有足够深入的理解,能够使用它们来解决问题,并自行学习高级 CNN 变体,如 ResNets、DenseNets 和 Octave Convolutions。
神经网络和表示学习
神经网络最初接收关于观察数据的信息,每个观察数据由一些特征的数量n表示。到目前为止,我们在两个非常不同的领域中看到了两个例子:第一个是房价数据集,每个观察数据由 13 个特征组成,每个特征代表房屋的某个数值特征。第二个是手写数字的 MNIST 数据集;由于图像用 784 个像素表示(28 像素宽,28 像素高),每个观察数据由 784 个数值表示,指示每个像素的明暗程度。
在每种情况下,经过适当缩放数据后,我们能够构建一个模型,以高准确度预测该数据集的适当结果。在每种情况下,一个具有一个隐藏层的简单神经网络模型表现比没有隐藏层的模型更好。为什么呢?一个原因,正如我在房价数据的案例中所展示的,是神经网络可以学习输入和输出之间的非线性关系。然而,一个更一般的原因是,在机器学习中,我们通常需要我们原始特征的线性组合来有效地预测我们的目标。假设 MNIST 数字的像素值为x[1]到x[784]。例如,可能情况是,x[1]高于平均值,x[139]低于平均值,并且 x[237]也低于平均值的组合强烈预测图像将是数字 9。可能还有许多其他这样的组合,所有这些组合都对图像是特定数字的概率产生积极或消极的影响。神经网络可以通过训练过程自动发现原始特征的重要组合。这个学习过程从通过乘以一个随机权重矩阵创建最初的随机原始特征组合开始;通过训练,神经网络学会了改进有用的组合并丢弃无用的组合。学习哪些特征组合是重要的这个过程被称为表示学习,这也是神经网络在不同领域成功的主要原因。这在图 5-1 中总结。
图 5-1。到目前为止我们看到的神经网络从个特征开始,然后学习介于和之间的“组合”来进行预测
是否有理由修改这个过程以适应图像数据?提示答案是“是”的基本见解是,在图像中,有趣的“特征组合”(像素)往往来自图像中彼此靠近的像素。在图像中,有趣的特征不太可能来自整个图像中随机选择的 9 个像素的组合,而更可能来自相邻像素的 3×3 补丁。我们想要利用关于图像数据的这一基本事实:特征的顺序很重要,因为它告诉我们哪些像素在空间上彼此靠近,而在房价数据中,特征的顺序并不重要。但是我们该如何做呢?
图像数据的不同架构
在高层次上,解决方案将是创建特征的组合,就像以前一样,但是数量级更多,并且每个特征只是输入图像中一个小矩形补丁的像素的组合。图 5-2 描述了这一点。
图 5-2。对于图像数据,我们可以将每个学习到的特征定义为数据的一个小补丁的函数,因此可以定义介于 n 和 n²之间的输出神经元
让我们的神经网络学习所有输入特征的组合——也就是说,输入图像中所有像素的组合——事实证明是非常低效的,因为它忽略了前一节描述的见解:图像中有趣的特征组合大多出现在这些小补丁中。尽管如此,以前至少非常容易计算新特征,这些特征是所有输入特征的组合:如果我们有f个输入特征,并且想要计算n个新特征,我们只需将包含我们输入特征的ndarray
乘以一个f
×n
矩阵。我们可以使用什么操作来计算输入图像的局部补丁中像素的许多组合?答案是卷积操作。
卷积操作
在描述卷积操作之前,让我们明确一下我们所说的“来自图像局部补丁的像素组合的特征”是什么意思。假设我们有一个 5×5 的输入图像I:
假设我们想计算一个新特征,它是中间 3×3 像素补丁的函数。就像我们迄今所见的神经网络中将新特征定义为旧特征的线性组合一样,我们将定义一个新特征,它是这个 3×3 补丁的函数,我们将通过定义一个 3×3 的权重W来实现:
然后,我们将简单地将W与I中相关补丁的点积,以获取输出中特征的值,由于涉及的输入图像部分位于(3,3)处,我们将表示为o[33](o代表“输出”):
然后,这个值将被视为我们在神经网络中看到的其他计算特征:可能会添加一个偏差,然后可能会通过激活函数,然后它将代表一个“神经元”或“学习到的特征”,将被传递到网络的后续层。因此,我们可以定义特征,这些特征是输入图像的小补丁的函数。
我们应该如何解释这些特征?事实证明,以这种方式计算的特征有一个特殊的解释:它们表示权重定义的视觉模式是否存在于图像的该位置。当将 3×3 或 5×5 的数字数组与图像的每个位置的像素值进行点积时,它们可以表示“模式检测器”,这在计算机视觉领域已经很久了。例如,将以下 3×3 数字数组进行点积:
对于输入图像的给定部分,检测该图像位置是否存在边缘。已知有类似的矩阵可以检测角是否存在,垂直或水平线是否存在,等等。
现在假设我们使用相同的权重集 W 来检测输入图像的每个位置是否存在由W定义的视觉模式。我们可以想象“在输入图像上滑动W”,将W与图像每个位置的像素进行点积,最终得到一个几乎与原始图像大小相同的新图像O(可能略有不同,取决于我们如何处理边缘)。这个图像O将是一种“特征图”,显示了输入图像中W定义的模式存在的位置。这实际上就是卷积神经网络中发生的操作;它被称为卷积,其输出确实被称为特征图。
这个操作是 CNN 如何工作的核心。在我们可以将其整合到我们在前几章中看到的那种完整的Operation
之前,我们必须为其添加另一个维度-字面上。
多通道卷积操作
回顾一下:卷积神经网络与常规神经网络的不同之处在于它们创建了数量级更多的特征,并且每个特征仅是来自输入图像的一个小块的函数。现在我们可以更具体:从n个输入像素开始,刚刚描述的卷积操作将为输入图像中的每个位置创建n个输出特征。在神经网络的卷积Layer
中实际发生的事情更进一步:在那里,我们将创建f组n个特征,每个都有一个对应的(最初是随机的)权重集,定义了在输入图像的每个位置检测到的视觉模式,这将在特征图中捕获。这f个特征图将通过f个卷积操作创建。这在图 5-3 中有所体现。
![神经网络图示
图 5-3。比以前更具体,对于具有 n 个像素的输入图像,我们定义一个输出,其中包含 f 个特征图,每个特征图的大小与原始图像大致相同,总共有 n×f 个输出神经元用于图像,每个神经元仅是原始图像的一个小块的函数
现在我们已经介绍了一堆概念,让我们为了清晰起见对它们进行定义。在卷积Layer
的上下文中,每个由特定权重集检测到的“特征集”称为特征图,特征图的数量在卷积Layer
中被称为Layer
的通道数,这就是为什么与Layer
相关的操作被称为多通道卷积。此外,f组权重W[i]被称为卷积滤波器。
卷积层
现在我们了解了多通道卷积操作,我们可以考虑如何将这个操作整合到神经网络层中。以前,我们的神经网络层相对简单:它们接收二维的ndarray
作为输入,并产生二维的ndarray
作为输出。然而,根据前一节的描述,卷积层将会为单个图像产生一个三维的ndarray
作为输出,维度为通道数(与“特征图”相同)×图像高度×图像宽度。
这引发了一个问题:我们如何将这个ndarray
前向传递到另一个卷积层中,以创建一个“深度卷积”神经网络?我们已经看到如何在具有单个通道和我们的滤波器的图像上执行卷积操作;当两个卷积层串联时,我们如何在具有多个通道的输入上执行多通道卷积?理解这一点是理解深度卷积神经网络的关键。
考虑在具有全连接层的神经网络中会发生什么:在第一个隐藏层中,我们有,假设,h[1]个特征,这些特征是来自输入层的所有原始特征的组合。在接下来的层中,特征是来自前一层的所有特征的组合,因此我们可能有h[2]个原始特征的“特征的特征”。为了创建这一层的h[2]个特征,我们使用个权重来表示h[2]个特征中的每一个都是前一层中的h[1]个特征的函数。
如前一节所述,在卷积神经网络的第一层中会发生类似的过程:我们首先使用m[1]个卷积滤波器将输入图像转换为m[1]个特征图。我们应该将这一层的输出看作是表示权重的m[1]个滤波器在输入图像的每个位置是否存在的不同视觉模式。就像全连接神经网络的不同层可以包含不同数量的神经元一样,卷积神经网络的下一层可能包含m[2]个滤波器。为了使网络学习复杂的模式,每个滤波器的解释应该是在图像的每个位置是否存在前一层中m[1]个视觉模式的组合或更高阶视觉特征。这意味着如果卷积层的输出是一个形状为m[2]个通道×图像高度×图像宽度的 3D ndarray
,那么图像中一个给定位置上的m[2]个特征图中的一个是在前一层对应的m[1]个特征图的每个相同位置上卷积m[1]个不同的滤波器的线性组合。这将使得m[2]个滤波器图中的每个位置都表示前一卷积层中已学习的m[1]个视觉特征的组合。
实现影响
了解两个多通道卷积层如何连接告诉我们如何实现这个操作:正如我们需要个权重来连接一个具有h[1]个神经元的全连接层和一个具有个神经元的全连接层,我们需要个卷积滤波器来连接一个具有m[1]个通道的卷积层和一个具有m[2]个通道的卷积层。有了这个最后的细节,我们现在可以指定构成完整的多通道卷积操作的ndarray
的维度,包括输入、输出和参数:
-
输入的形状将是:
-
批量大小
-
输入通道
-
图像高度
-
图像宽度
-
-
输出的形状将是:
-
批量大小
-
输出通道
-
图像高度
-
图像宽度
-
-
卷积滤波器本身的形状将是:
-
输入通道
-
输出通道
-
滤波器高度
-
滤波器宽度
-
注意
维度的顺序可能因库而异,但这四个维度始终存在。
我们将在本章后面实现这个卷积操作时牢记所有这些。
卷积层和全连接层之间的差异
在本章的开头,我们讨论了卷积层和全连接层在高层次上的区别;图 5-4 重新审视了这种比较,现在我们已经更详细地描述了卷积层。
图 5-4。卷积层和全连接层之间的比较
此外,这两种层之间的最后一个区别是个体神经元本身的解释方式:
-
全连接层中每个神经元的解释是,它检测先前层学习的特定特征组合是否存在于当前观察中。
-
卷积层中的神经元的解释是,它检测先前层学习的特定视觉模式组合是否存在于输入图像的给定位置。
在我们将这样的层合并到神经网络之前,我们需要解决另一个问题:如何使用输出的多维数组来进行预测。
使用卷积层进行预测:Flatten 层
我们已经讨论了卷积层如何学习代表图像中是否存在视觉模式的特征,并将这些特征存储在特征图层中;我们如何使用这些特征图层来进行预测呢?在上一章中使用全连接神经网络预测图像属于 10 个类别中的哪一个时,我们只需要确保最后一层的维度为 10;然后我们可以将这 10 个数字输入到 softmax 交叉熵损失函数中,以确保它们被解释为概率。现在我们需要弄清楚在卷积层的情况下我们可以做什么,其中每个观察都有一个三维的形状为m通道数 × 图像高度 × 图像宽度的ndarray
。
要找到答案,回想一下,每个神经元只是表示图像中是否存在特定视觉特征组合(如果这是一个深度卷积神经网络,则可能是特征的特征或特征的特征的特征)在图像的给定位置。这与如果我们将全连接神经网络应用于此图像时学习的特征没有区别:第一个全连接层将表示单个像素的特征,第二个将表示这些特征的特征,依此类推。在全连接架构中,我们只需将网络学习的每个“特征的特征”视为单个神经元,用作预测图像属于哪个类别的输入。
事实证明,我们可以用卷积神经网络做同样的事情——我们将m个特征图视为个神经元,并使用Flatten
操作将这三个维度(通道数、图像高度和图像宽度)压缩成一个一维向量,然后我们可以使用简单的矩阵乘法进行最终预测。这样做的直觉是,每个单独的神经元基本上代表与全连接层中的神经元相同的“类型”——具体来说,表示在图像的给定位置是否存在给定的视觉特征(或特征组合)——因此我们可以在神经网络的最后一层中以相同的方式处理它们。⁴
我们将在本章后面看到如何实现Flatten
层。但在我们深入实现之前,让我们讨论另一种在许多 CNN 架构中很重要的层,尽管本书不会详细介绍它。
池化层
池化层是卷积神经网络中常用的另一种类型的层。它们简单地对由卷积操作创建的每个特征图进行下采样;对于最常用的池化大小为 2,这涉及将每个特征图的每个 2×2 部分映射到该部分的最大值(最大池化)或该部分的平均值(平均池化)。因此,对于一个 n×n 的图像,整个图像将被映射到一个 × 的大小。图 5-5 说明了这一点。
图 5-5。一个 4×4 输入的最大池化和平均池化示例;每个 2×2 的块被映射到该块的平均值或最大值
池化的主要优势在于计算:通过将图像下采样为前一层的四分之一像素数,池化将网络训练所需的权重数量和计算数量减少了四分之一;如果网络中使用了多个池化层,这种减少可以进一步叠加,就像在 CNN 的早期架构中使用的许多架构中一样。当然,池化的缺点是,从下采样的图像中只能提取四分之一的信息。然而,尽管池化通过降低图像的分辨率导致网络“丢失”了关于图像的信息,但尽管如此,这种权衡在增加计算速度方面是值得的,因为架构在图像识别基准测试中表现非常出色。然而,许多人认为池化只是一个偶然起作用的技巧,应该被淘汰;正如 Geoffrey Hinton 在 2014 年的 Reddit AMA 中写道:“卷积神经网络中使用的池化操作是一个大错误,它能够如此成功地运行是一场灾难。”事实上,大多数最近的 CNN 架构(如残差网络或“ResNets”)最小化或根本不使用池化。因此,在本书中,我们不会实现池化层,但考虑到它们在著名架构(如 AlexNet)中的使用对“推动 CNN 发展”至关重要,我们在这里提及它们以保持完整性。
将 CNN 应用于图像之外
到目前为止,我们所描述的一切在使用神经网络处理图像方面都是非常标准的:图像通常被表示为一组m[1]通道的像素,其中m[1]=1 表示黑白图像,m[1]=3 表示彩色图像—然后对每个通道应用一定数量的卷积操作(使用之前解释过的滤波器映射),这种模式会持续几层。这些内容在其他卷积神经网络的处理中已经涵盖过;不太常见的是,将数据组织成“通道”,然后使用 CNN 处理数据的想法不仅仅适用于图像。例如,这种数据表示是 DeepMind 的 AlphaGo 系列程序的关键,展示了神经网络可以学会下围棋。引用论文中的话:
神经网络的输入是一个 19 × 19 × 17 的图像堆栈,包括 17 个二进制特征平面。8 个特征平面 X[t] 包含二进制值,指示当前玩家的棋子的存在(如果时间步 t 的交叉点 i 包含玩家颜色的棋子,则为 ;如果交叉点为空,包含对手的棋子,或者 t < 0,则为 0)。另外 8 个特征平面 Y[t] 表示对手棋子的相应特征。最后一个特征平面 C 表示要下的颜色,其常量值为 1(黑色下棋)或 0(白色下棋)。这些平面被连接在一起以给出输入特征 s[t] = X[t], Y[t], X[t – 1], Y[t – 1], …, X[t – 7], Y[t – 7], C。历史特征 X[t], Y[t] 是必要的,因为围棋不仅仅通过当前的棋子就能完全观察到,重复是被禁止的;同样,颜色特征 C 是必要的,因为贴目是不可观察的。
换句话说,他们基本上将棋盘表示为一个 19 × 19 像素的“图像”,有 17 个通道!他们使用其中的 16 个通道来编码每个玩家之前 8 步所发生的情况;这是必要的,以便他们可以编码防止重复之前步骤的规则。第 17 个通道实际上是一个 19 × 19 的网格,要么全是 1,要么全是 0,取决于轮到谁走。⁷ CNN 和它们的多通道卷积操作主要应用于图像,但更一般的是,用多个“通道”表示沿某些空间维度排列的数据的想法即使超出图像也是适用的。
然而,为了真正理解多通道卷积操作,您必须从头开始实现它,接下来的几节将详细描述这个过程。
实现多通道卷积操作
事实证明,如果我们首先检查一维情况,即涉及四维输入 ndarray
和四维参数 ndarray
的实现将更清晰。从那个起点逐步构建到完整操作将主要是添加一堆 for
循环的问题。在整个过程中,我们将采取与第一章相同的方法,交替使用图表、数学和工作的 Python 代码。
前向传播
一维卷积在概念上与二维卷积相同:我们将一维输入和一维卷积滤波器作为输入,然后通过沿着输入滑动滤波器来创建输出。
假设我们的输入长度为 5:
假设我们要检测的“模式”的大小为 3:
图表和数学
输出的第一个元素将通过将输入的第一个元素与滤波器进行卷积来创建:
输出的第二个元素将通过将滤波器向右滑动一个单位并将其与系列的下一组值进行卷积来创建:
好吧。然而,当我们计算下一个输出值时,我们意识到我们已经没有空间了:
我们已经到达了输入的末尾,结果输出只有三个元素,而我们开始时有五个!我们该如何解决这个问题?
填充
为了避免由于卷积操作导致输出缩小,我们将引入一种在卷积神经网络中广泛使用的技巧:我们在边缘周围“填充”输入与零,以使输出保持与输入大小相同。否则,每次我们在输入上卷积一个滤波器时,我们最终得到的输出会略小于输入,就像之前看到的那样。
正如您可以从前面的卷积示例推理出的:对于大小为 3 的滤波器,应该在边缘周围添加一个单位的填充,以保持输出与输入大小相同。更一般地,由于我们几乎总是使用奇数大小的滤波器,我们添加填充等于滤波器大小除以 2 并向下舍入到最接近的整数。
假设我们添加了这种填充,这样,输入不再是从i[1]到i[5],而是从i[0]到i[6],其中i[0]和i[6]都是 0。然后我们可以计算卷积的输出为:
依此类推,直到:
现在输出与输入大小相同了。我们如何编写代码呢?
代码
编写这部分代码实际上非常简单。在我们开始之前,让我们总结一下我们刚刚讨论的步骤:
-
我们最终希望生成一个与输入大小相同的输出。
-
为了在不“缩小”输出的情况下执行此操作,我们首先需要填充输入。
-
然后我们将不得不编写一些循环,通过输入并将其每个位置与滤波器进行卷积。
我们将从我们的输入和滤波器开始:
input_1d = np.array([1,2,3,4,5])
param_1d = np.array([1,1,1])
这里有一个辅助函数,可以在一维输入的两端填充:
def _pad_1d(inp: ndarray,
num: int) -> ndarray:
z = np.array([0])
z = np.repeat(z, num)
return np.concatenate([z, inp, z])
_pad_1d(input_1d, 1)
array([0., 1., 2., 3., 4., 5., 0.])
卷积本身呢?观察到,对于我们想要生成的每个输出元素,我们在“填充”输入中有一个对应的元素,我们在那里“开始”卷积操作;一旦我们弄清楚从哪里开始,我们只需循环遍历滤波器中的所有元素,在每个元素上进行乘法并将结果添加到总和中。
我们如何找到这个“对应的元素”?注意,简单地说,输出中第一个元素的值从填充输入的第一个元素开始!这使得for
循环非常容易编写:
def conv_1d(inp: ndarray,
param: ndarray) -> ndarray:
# assert correct dimensions
assert_dim(inp, 1)
assert_dim(param, 1)
# pad the input
param_len = param.shape[0]
param_mid = param_len // 2
input_pad = _pad_1d(inp, param_mid)
# initialize the output
out = np.zeros(inp.shape)
# perform the 1d convolution
for o in range(out.shape[0]):
for p in range(param_len):
out[o] += param[p] * input_pad[o+p]
# ensure shapes didn't change
assert_same_shape(inp, out)
return out
conv_1d_sum(input_1d, param_1d)
array([ 3., 6., 9., 12., 9.])
这已经足够简单了。在我们继续进行此操作的反向传递之前——棘手的部分——让我们简要讨论一下我们正在忽略的卷积的一个超参数:步幅。
关于步幅的说明
我们之前注意到,池化操作是从特征图中对图像进行下采样的一种方法。在许多早期的卷积架构中,这确实显著减少了所需的计算量,而且没有对准确性造成重大影响;然而,它们已经不再受欢迎,因为它们的缺点:它们有效地对图像进行下采样,使得分辨率减半的图像传递到下一层。
一个更广泛接受的方法是修改卷积操作的步幅。步幅是滤波器在图像上逐步滑动的量——在先前的情况下,我们使用步幅为 1,因此每个滤波器与输入的每个元素进行卷积,这就是为什么输出的大小与输入的大小相同。使用步幅为 2,滤波器将与输入图像的每隔一个元素进行卷积,因此输出的大小将是输入的一半;使用步幅为 3,滤波器将与输入图像的每隔两个元素进行卷积,依此类推。这意味着,例如,使用步幅为 2 将导致相同的输出大小,因此与使用大小为 2 的池化相比,计算减少了很多,但没有太多的信息损失:使用大小为 2 的池化,只有输入中四分之一的元素对输出产生任何影响,而使用步幅为 2,每个输入元素对输出都有一些影响。因此,即使在今天最先进的 CNN 架构中,使用大于 1 的步幅进行下采样的情况比池化更为普遍。
然而,在这本书中,我只会展示步幅为 1 的示例,将这些操作修改为允许大于 1 的步幅是留给读者的练习。使用步幅等于 1 也使得编写反向传播更容易。
卷积:反向传播
反向传播是卷积变得有点棘手的地方。让我们回顾一下我们要做的事情:之前,我们使用输入和参数生成了卷积操作的输出。现在我们想要计算:
-
损失相对于卷积操作的输入的每个元素的偏导数——之前是
inp
-
损失相对于卷积操作的滤波器的每个元素的偏导数——之前是
param_1d
想想我们在第四章中看到的ParamOperation
是如何工作的:在backward
方法中,它们接收一个表示每个输出元素最终影响损失程度的输出梯度,然后使用这个输出梯度来计算输入和参数的梯度。因此,我们需要编写一个函数,该函数接受与输入形状相同的output_grad
,并产生一个input_grad
和一个param_grad
。
我们如何测试计算出的梯度是否正确?我们将从第一章中带回一个想法:我们知道对于任何一个输入,对于和的偏导数是 1(如果和s = a + b + c,那么)。因此,我们可以使用我们的_input_grad
和_param_grad
函数(我们将很快推理和编写)以及一个全部为 1 的output_grad
来计算input_grad
和param_grad
量。然后,我们将通过改变输入的元素一些数量α,并查看结果的总和是否通过梯度乘以α而改变来检查这些梯度是否正确。
梯度“应该”是多少?
使用刚才描述的逻辑,让我们计算输入向量的一个元素的梯度应该是多少:
def conv_1d_sum(inp: ndarray,
param: ndarray) -> ndarray:
out = conv_1d(inp, param)
return np.sum(out)
# randomly choose to increase 5th element by 1
input_1d_2 = np.array([1,2,3,4,6])
param_1d = np.array([1,1,1])
print(conv_1d_sum(input_1d, param_1d))
print(conv_1d_sum(input_1d_2, param_1d))
39.0
41.0
因此,输入的第五个元素的梯度应该是 41 - 39 = 2。
现在让我们尝试推理如何计算这样的梯度,而不仅仅是计算这两个总和之间的差异。这就是事情变得有趣的地方。
计算 1D 卷积的梯度
我们看到增加输入的这个元素使输出增加了 2。仔细观察输出,可以清楚地看到它是如何做到这一点的:
输入的特定元素被表示为t[5]。它在输出中出现在两个地方:
-
作为o[4]的一部分,它与w[3]相乘。
-
作为o[5]的一部分,它与w[2]相乘。
为了帮助看到输入如何映射到输出总和的一般模式,请注意,如果存在o[6],t[5]也将通过与w[1]相乘而对输出产生影响。
因此,最终影响损失的数量,我们可以表示为,将是:
当然,在这个简单的例子中,当损失只是总和时,对于所有输出元素,(对于“填充”元素除外,该数量为 0)。这个总和非常容易计算:它只是w[2] + w[3],确实是 2,因为w[2] = w[3] = 1。
一般模式是什么?
现在让我们寻找通用输入元素的一般模式。这实际上是一个跟踪索引的练习。由于我们在这里将数学转换为代码,让我们使用来表示输出梯度的第i个元素(因为我们最终将通过output_grad[i]
访问它)。然后:
仔细观察这个输出,我们可以类似地推理:
和:
这里显然有一个模式,将其转换为代码有点棘手,特别是因为输出上的索引增加的同时权重上的索引减少。然而,表达这一点的方式是通过以下双重for
循环:
# param: in our case an ndarray of shape (1,3)
# param_len: the integer 3
# inp: in our case an ndarray of shape (1,5)
# input_grad: always an ndarray the same shape as "inp"
# output_pad: in our case an ndarray of shape (1,7)
for o in range(inp.shape[0]):
for p in range(param.shape[0]):
input_grad[o] += output_pad[o+param_len-p-1] * param[p]
这样做适当地增加了权重的索引,同时减少了输出上的权重。
尽管现在可能不明显,但通过推理并得到它是计算卷积操作的梯度中最棘手的部分。增加更多复杂性,例如批量大小、具有二维输入的卷积或具有多个通道的输入,只是在前面的几行中添加更多的for
循环,我们将在接下来的几节中看到。
计算参数梯度
我们可以类似地推理,关于如何增加滤波器的一个元素应该增加输出。首先,让我们增加(任意地)滤波器的第一个元素一个单位,并观察对总和的影响:
input_1d = np.array([1,2,3,4,5])
# randomly choose to increase first element by 1
param_1d_2 = np.array([2,1,1])
print(conv_1d_sum(input_1d, param_1d))
print(conv_1d_sum(input_1d, param_1d_2))
39.0
49.0
所以我们应该发现。
就像我们为输入所做的那样,通过仔细检查输出并看到哪些滤波器元素影响它,以及填充输入以更清楚地看到模式,我们看到:
由于对于总和,所有的元素都是 1,而t[0]是 0,我们有:
这证实了之前的计算。
编码这个
编码这个比编写输入梯度的代码更容易,因为这次“索引是朝着同一个方向移动的。”在同一个嵌套的for
循环中,代码是:
# param: in our case an ndarray of shape (1,3)
# param_grad: an ndarray the same shape as param
# inp: in our case an ndarray of shape (1,5)
# input_pad: an ndarray the same shape as (1,7)
# output_grad: in our case an ndarray of shape (1,5)
for o in range(inp.shape[0]):
for p in range(param.shape[0]):
param_grad[p] += input_pad[o+p] * output_grad[o]
最后,我们可以结合这两个计算,并编写一个函数来计算输入梯度和滤波器梯度,具体步骤如下:
-
将输入和滤波器作为参数。
-
计算输出。
-
填充输入和输出梯度(例如,得到
input_pad
和output_pad
)。 -
如前所示,使用填充的输出梯度和滤波器来计算梯度。
-
类似地,使用输出梯度(未填充)和填充输入来计算滤波器梯度。
我展示了包装在书的GitHub 存储库中的前述代码块的完整函数。
这就结束了我们关于如何在 1D 中实现卷积的解释!正如我们将在接下来的几节中看到的,将这种推理扩展到在二维输入、二维输入批次,甚至多通道的二维输入批次上工作是(也许令人惊讶地)直接的。
批处理、2D 卷积和多通道
首先,让我们为这些卷积函数添加能够处理批量输入的功能——2D 输入的第一个维度表示输入的批量大小,第二个维度表示 1D 序列的长度:
input_1d_batch = np.array([[0,1,2,3,4,5,6],
[1,2,3,4,5,6,7]])
我们可以遵循之前定义的相同一般步骤:首先填充输入,使用此输入计算输出,然后填充输出梯度以计算输入和滤波器梯度。
带批处理的 1D 卷积:正向传播
在输入具有表示批处理大小的第二维度时实现正向传播的唯一区别是,我们必须为每个观察值单独填充和计算输出(就像之前一样),然后stack
结果以获得一批输出。例如,conv_1d
变为:
def conv_1d_batch(inp: ndarray,
param: ndarray) -> ndarray:
outs = [conv_1d(obs, param) for obs in inp]
return np.stack(outs)
带批处理的 1D 卷积:反向传播
反向传播类似:现在计算输入梯度只需从前一节的计算输入梯度的for
循环中获取,为每个观察值计算它,并stack
结果:
# "_input_grad" is the function containing the for loop from earlier:
# it takes in a 1d input, a 1d filter, and a 1d output_gradient and computes
# the input grad
grads = [_input_grad(inp[i], param, out_grad[i])[1] for i in range(batch_size)]
np.stack(grads)
处理一批观察时,滤波器的梯度有点不同。这是因为滤波器与输入中的每个观察值进行卷积,因此与输出中的每个观察值相连。因此,为了计算参数梯度,我们必须遍历所有观察值,并在这样做时递增参数梯度的适当值。不过,这只涉及在计算我们之前看到的参数梯度的代码中添加一个外部for
循环:
# param: in our case an ndarray of shape (1,3)
# param_grad: an ndarray the same shape as param
# inp: in our case an ndarray of shape (1,5)
# input_pad: an ndarray the same shape as (1,7)
# output_grad: in our case an ndarray of shape (1,5)
for i in range(inp.shape[0]): # inp.shape[0] = 2
for o in range(inp.shape[1]): # inp.shape[0] = 5
for p in range(param.shape[0]): # param.shape[0] = 3
param_grad[p] += input_pad[i][o+p] * output_grad[i][o]
将这个维度添加到原始 1D 卷积之上确实很简单;从一维到二维输入的扩展同样是直接的。
2D 卷积
2D 卷积是 1D 情况的直接扩展,因为从根本上讲,通过每个维度的滤波器将输入连接到输出的方式在 2D 情况下与 1D 情况相同。因此,正向传播和反向传播的高级步骤保持不变:
-
在正向传播中,我们:
-
适当地填充输入。
-
使用填充的输入和参数来计算输出。
-
-
在反向传播中,为了计算输入梯度,我们:
-
适当地填充输出梯度。
-
使用这个填充的输出梯度,以及输入和参数,来计算输入梯度和参数梯度。
-
-
同样在反向传播中,为了计算参数梯度,我们:
-
适当地填充输入。
-
遍历填充输入的元素,并在进行时适当递增参数梯度。
-
2D 卷积:编写正向传播
具体来说,回想一下,对于 1D 卷积,计算输出的代码在正向传播中如下所示:
# input_pad: a version of the input that has been padded appropriately based on
# the size of param
out = np.zeros_like(inp)
for o in range(out.shape[0]):
for p in range(param_len):
out[o] += param[p] * input_pad[o+p]
对于 2D 卷积,我们只需将其修改为:
# input_pad: a version of the input that has been padded appropriately based on
# the size of param
out = np.zeros_like(inp)
for o_w in range(img_size): # loop through the image height
for o_h in range(img_size): # loop through the image width
for p_w in range(param_size): # loop through the parameter width
for p_h in range(param_size): # loop through the parameter height
out[o_w][o_h] += param[p_w][p_h] * input_pad[o_w+p_w][o_h+p_h]
您可以看到我们只是将每个for
循环“展开”为两个for
循环。
当我们有一批图像时,扩展到两个维度也类似于 1D 情况:就像我们在那里所做的那样,我们只需在这里显示的循环外部添加一个for
循环。
2D 卷积:编写反向传播
果然,就像在正向传播中一样,我们可以在反向传播中使用与 1D 情况相同的索引。回想一下,在 1D 情况下,代码是:
input_grad = np.zeros_like(inp)
for o in range(inp.shape[0]):
for p in range(param_len):
input_grad[o] += output_pad[o+param_len-p-1] * param[p]
在 2D 情况下,代码简单地是:
# output_pad: a version of the output that has been padded appropriately based
# on the size of param
input_grad = np.zeros_like(inp)
for i_w in range(img_width):
for i_h in range(img_height):
for p_w in range(param_size):
for p_h in range(param_size):
input_grad[i_w][i_h] +=
output_pad[i_w+param_size-p_w-1][i_h+param_size-p_h-1] \
* param[p_w][p_h]
请注意,输出的索引与 1D 情况相同,但是在两个维度上进行;在 1D 情况下,我们有:
output_pad[i+param_size-p-1] * param[p]
在 2D 情况下,我们有:
output_pad[i_w+param_size-p_w-1][i_h+param_size-p_h-1] * param[p_w][p_h]
1D 情况下的其他事实也适用:
-
对于一批输入图像,我们只需为每个观察执行前面的操作,然后
stack
结果。 -
对于参数梯度,我们必须循环遍历批次中的所有图像,并将每个图像的组件添加到参数梯度的适当位置中。
# input_pad: a version of the input that has been padded appropriately based on
# the size of param
param_grad = np.zeros_like(param)
for i in range(batch_size): # equal to inp.shape[0]
for o_w in range(img_size):
for o_h in range(img_size):
for p_w in range(param_size):
for p_h in range(param_size):
param_grad[p_w][p_h] += input_pad[i][o_w+p_w][o_h+p_h] \
* output_grad[i][o_w][o_h]
到目前为止,我们几乎已经编写了完整的多通道卷积操作的代码;目前,我们的代码在二维输入上卷积滤波器并产生二维输出。当然,正如我们之前描述的那样,每个卷积层不仅沿着这两个维度排列神经元,还有一定数量的“通道”,等于该层创建的特征图的数量。解决这个最后的挑战是我们接下来要讨论的内容。
最后一个元素:添加“通道”
我们如何修改我们迄今为止所写的内容,以考虑输入和输出都是多通道的情况?答案与之前添加批次时一样简单:我们在已经看到的代码中添加两个外部for
循环——一个循环用于输入通道,另一个循环用于输出通道。通过循环遍历输入通道和输出通道的所有组合,我们使每个输出特征图成为所有输入特征图的组合,如所需。
为了使其工作,我们将始终将我们的图像表示为三维ndarray
,而不是我们一直使用的二维数组;我们将用一个通道表示黑白图像,用三个通道表示彩色图像(一个通道用于图像中每个位置的红色值,一个用于蓝色值,一个用于绿色值)。然后,无论通道数量如何,操作都会按照前面描述的方式进行,从图像中创建多个特征图,每个特征图都是来自图像中所有通道(或者来自网络中更深层次的层的通道)卷积的组合。
前向传播
考虑到这一切,为了计算卷积层的输出,给定输入和参数的四维ndarray
,完整的代码如下:
def _compute_output_obs(obs: ndarray,
param: ndarray) -> ndarray:
'''
obs: [channels, img_width, img_height]
param: [in_channels, out_channels, param_width, param_height]
'''
assert_dim(obs, 3)
assert_dim(param, 4)
param_size = param.shape[2]
param_mid = param_size // 2
obs_pad = _pad_2d_channel(obs, param_mid)
in_channels = fil.shape[0]
out_channels = fil.shape[1]
img_size = obs.shape[1]
out = np.zeros((out_channels,) + obs.shape[1:])
for c_in in range(in_channels):
for c_out in range(out_channels):
for o_w in range(img_size):
for o_h in range(img_size):
for p_w in range(param_size):
for p_h in range(param_size):
out[c_out][o_w][o_h] += \
param[c_in][c_out][p_w][p_h]
* obs_pad[c_in][o_w+p_w][o_h+p_h]
return out
def _output(inp: ndarray,
param: ndarray) -> ndarray:
'''
obs: [batch_size, channels, img_width, img_height]
param: [in_channels, out_channels, param_width, param_height]
'''
outs = [_compute_output_obs(obs, param) for obs in inp]
return np.stack(outs)
请注意,_pad_2d_channel
是一个沿着通道维度对输入进行填充的函数。
再次强调,进行计算的实际代码与之前显示的简单 2D 情况(无通道)中的代码类似,只是现在我们有了例如fil[c_out][c_in][p_w][p_h]
而不仅仅是fil[p_w][p_h]
,因为有两个更多的维度和c_out × c_in
更多的元素在滤波器数组中。
反向传播
反向传播与简单的 2D 情况下的反向传播遵循相同的概念原则:
-
对于输入梯度,我们分别计算每个观察的梯度——填充输出梯度以进行计算——然后
stack
梯度。 -
我们还使用填充的输出梯度来计算参数梯度,但我们也循环遍历观察,并使用每个观察中的适当值来更新参数梯度。
这是计算输出梯度的代码:
def _compute_grads_obs(input_obs: ndarray,
output_grad_obs: ndarray,
param: ndarray) -> ndarray:
'''
input_obs: [in_channels, img_width, img_height]
output_grad_obs: [out_channels, img_width, img_height]
param: [in_channels, out_channels, img_width, img_height]
'''
input_grad = np.zeros_like(input_obs)
param_size = param.shape[2]
param_mid = param_size // 2
img_size = input_obs.shape[1]
in_channels = input_obs.shape[0]
out_channels = param.shape[1]
output_obs_pad = _pad_2d_channel(output_grad_obs, param_mid)
for c_in in range(in_channels):
for c_out in range(out_channels):
for i_w in range(input_obs.shape[1]):
for i_h in range(input_obs.shape[2]):
for p_w in range(param_size):
for p_h in range(param_size):
input_grad[c_in][i_w][i_h] += \
output_obs_pad[c_out][i_w+param_size-p_w-1][i_h+param_size-p_h-1] \
* param[c_in][c_out][p_w][p_h]
return input_grad
def _input_grad(inp: ndarray,
output_grad: ndarray,
param: ndarray) -> ndarray:
grads = [_compute_grads_obs(inp[i], output_grad[i], param) for i in range(output_grad.shape[0])]
return np.stack(grads)
这是参数梯度:
def _param_grad(inp: ndarray,
output_grad: ndarray,
param: ndarray) -> ndarray:
'''
inp: [in_channels, img_width, img_height]
output_grad_obs: [out_channels, img_width, img_height]
param: [in_channels, out_channels, img_width, img_height]
'''
param_grad = np.zeros_like(param)
param_size = param.shape[2]
param_mid = param_size // 2
img_size = inp.shape[2]
in_channels = inp.shape[1]
out_channels = output_grad.shape[1]
inp_pad = _pad_conv_input(inp, param_mid)
img_shape = output_grad.shape[2:]
for i in range(inp.shape[0]):
for c_in in range(in_channels):
for c_out in range(out_channels):
for o_w in range(img_shape[0]):
for o_h in range(img_shape[1]):
for p_w in range(param_size):
for p_h in range(param_size):
param_grad[c_in][c_out][p_w][p_h] += \
inp_pad[i][c_in][o_w+p_w][o_h+p_h] \
* output_grad[i][c_out][o_w][o_h]
return param_grad
这三个函数——_output
、_input_grad
和_param_grad
——正是我们需要创建一个Conv2DOperation
所需的,这最终将构成我们在 CNN 中使用的Conv2DLayer
的核心!在我们能够在工作的卷积神经网络中使用这个Operation
之前,还有一些细节需要解决。
使用这个操作来训练 CNN
在我们能够拥有一个可工作的 CNN 模型之前,我们需要实现一些额外的部分:
-
我们必须实现本章前面讨论的
Flatten
操作;这是为了使模型能够进行预测。 -
我们必须将这个
Operation
以及Conv2DOpOperation
整合到一个Conv2D
Layer
中。 -
最后,为了使这些可用,我们必须编写一个更快的 Conv2D 操作的版本。我们将在这里概述这一点,并在“矩阵链规则”中分享详细信息。
Flatten 操作
我们需要完成卷积层的另一个“操作”:Flatten 操作。卷积操作的输出是每个观察结果的 3D ndarray,维度为(通道数,图像高度,图像宽度)。然而,除非我们将这些数据传递到另一个卷积层,否则我们首先需要将其转换为每个观察结果的向量。幸运的是,正如之前描述的那样,由于涉及的每个单独神经元都编码了图像中特定视觉特征是否存在的信息,我们可以简单地将这个 3D ndarray“展平”为一个 1D 向量,并且在没有任何问题的情况下将其传递到前面。这里显示的 Flatten 操作就是这样做的,考虑到在卷积层中,与任何其他层一样,我们的 ndarray 的第一个维度始终是批量大小:
class Flatten(Operation):
def __init__(self):
super().__init__()
def _output(self) -> ndarray:
return self.input.reshape(self.input.shape[0], -1)
def _input_grad(self, output_grad: ndarray) -> ndarray:
return output_grad.reshape(self.input.shape)
这是我们需要的最后一个“操作”;让我们将这些“操作”封装在一个“层”中。
完整的 Conv2D 层
因此,完整的卷积层看起来会像这样:
class Conv2D(Layer):
def __init__(self,
out_channels: int,
param_size: int,
activation: Operation = Sigmoid(),
flatten: bool = False) -> None:
super().__init__()
self.out_channels = out_channels
self.param_size = param_size
self.activation = activation
self.flatten = flatten
def _setup_layer(self, input_: ndarray) -> ndarray:
self.params = []
conv_param = np.random.randn(self.out_channels,
input_.shape[1], # input channels
self.param_size,
self.param_size)
self.params.append(conv_param)
self.operations = []
self.operations.append(Conv2D(conv_param))
self.operations.append(self.activation)
if self.flatten:
self.operations.append(Flatten())
return None
Flatten 操作是可选的,取决于我们是否希望将此层的输出传递到另一个卷积层或传递到另一个全连接层进行预测。
关于速度和另一种实现的说明
熟悉计算复杂性的人会意识到,这段代码运行速度非常慢:为了计算参数梯度,我们需要编写七个嵌套的 for 循环!这样做没有问题,因为从头开始编写卷积操作的目的是巩固我们对 CNN 工作原理的理解。然而,完全可以以完全不同的方式编写卷积;与我们在本章中所做的方式不同,我们可以将其分解为以下步骤:
-
从输入中提取大小为 filter_height×filter_width 的测试集的 image_height×image_width×num_channels 个补丁。
-
对于这些补丁中的每一个,执行与将输入通道连接到输出通道的适当滤波器的点积。
-
堆叠和重塑所有这些点积的结果以形成输出。
通过一点巧妙,我们几乎可以用一个批量矩阵乘法来表达之前描述的所有操作,使用 NumPy 的 np.matmul 函数实现。如何做到这一点的细节在附录 A 中有描述,并且在本书的网站上实现,但可以说这样可以让我们编写相对较小的卷积神经网络,可以在合理的时间内进行训练。这让我们实际上可以运行实验,看看卷积神经网络的工作效果如何!
实验
即使使用通过重塑和 matmul 函数定义的卷积操作,训练这个只有一个卷积层的模型一个时期大约需要 10 分钟,因此我们限制自己演示一个只有一个卷积层的模型,具有 32 个通道(一个相当随意选择的数字):
model = NeuralNetwork(
layers=[Conv2D(out_channels=32,
param_size=5,
dropout=0.8,
weight_init="glorot",
flatten=True,
activation=Tanh()),
Dense(neurons=10,
activation=Linear())],
loss = SoftmaxCrossEntropy(),
seed=20190402)
请注意,这个模型在第一层只有 32×5×5=800 个参数,但这些参数用于创建 32×28×28=25,088 个神经元或“学习特征”。相比之下,具有 32 个隐藏单元的全连接层将有 784×32=25,088 个参数,只有 32 个神经元。
一些简单的试错——用不同的学习率训练这个模型几百批次,并观察结果验证损失——显示,现在我们的第一层是卷积层,而不是全连接层,学习率为 0.01 比学习率为 0.1 效果更好。使用优化器SGDMomentum(lr = 0.01, momentum=0.9)
训练这个网络一个时期,得到:
Validation accuracy after 100 batches is 79.65%
Validation accuracy after 200 batches is 86.25%
Validation accuracy after 300 batches is 85.47%
Validation accuracy after 400 batches is 87.27%
Validation accuracy after 500 batches is 88.93%
Validation accuracy after 600 batches is 88.25%
Validation accuracy after 700 batches is 89.91%
Validation accuracy after 800 batches is 89.59%
Validation accuracy after 900 batches is 89.96%
Validation loss after 1 epochs is 3.453
Model validation accuracy after 1 epoch is 90.50%
这表明我们确实可以从头开始训练一个卷积神经网络,在通过训练集进行一次遍历后,准确率达到 90%以上!
结论
在本章中,您已经了解了卷积神经网络。您从高层次开始学习它们是什么,以及它们与全连接神经网络的相似之处和不同之处,然后一直到最低层次,看到它们如何在 Python 中从头开始实现核心多通道卷积操作。
从高层开始,卷积层比我们迄今为止看到的全连接层创建大约一个数量级更多的神经元,每个神经元是前一层中仅有几个特征的组合,而不是每个神经元都是前一层所有特征的组合,就像全连接层中那样。在更低的层次上,我们看到这些神经元实际上被分组成“特征图”,每个特征图表示在图像的特定位置是否存在特定的视觉特征,或者在深度卷积神经网络的情况下,特定的视觉特征组合是否存在。总体上,我们将这些特征图称为卷积Layer
的“通道”。
尽管与我们在Dense
层中看到的Operation
有很多不同,卷积操作与我们看到的其他ParamOperation
一样适合相同的模板:
-
它有一个
_output
方法,根据其输入和参数计算输出。 -
它有
_input_grad
和_param_grad
方法,给定与Operation
的output
相同形状的output_grad
,计算与输入和参数相同形状的梯度。
唯一的区别在于现在_input
、output
和param
是四维的ndarray
,而在全连接层的情况下它们是二维的。
这些知识应该为您未来学习或应用卷积神经网络奠定非常坚实的基础。接下来,我们将介绍另一种常见的高级神经网络架构:递归神经网络,设计用于处理以序列形式出现的数据,而不仅仅是我们在房屋和图像的情况下处理的非顺序批次。继续前进!
我们将编写的代码,虽然清楚地表达了卷积的工作原理,但效率非常低下。在“关于偏差项的损失梯度”中,我提供了一个更有效的实现,使用 NumPy 描述了本章中我们将描述的批量、多通道卷积操作。
查看维基百科页面“Kernel (image processing)”获取更多示例。
这些也被称为内核。
⁴ 这就是为什么重要理解卷积操作的输出,既可以看作是创建一定数量的滤波器映射(比如说,),也可以看作是创建 个独立的神经元。正如在神经网络中一样,同时在脑海中保持多个层次的解释,并看到它们之间的联系是关键。
⁵ 请参阅原始 ResNet 论文,作者是 Kaiming He 等人,题目是“用于图像识别的深度残差学习”。
⁶ DeepMind(David Silver 等人),无需人类知识掌握围棋,2017 年。
⁷ 一年后,DeepMind 发布了使用类似表示法的结果,只是这一次,为了编码更复杂的国际象棋规则,输入有 119 个通道!参见 DeepMind(David Silver 等人),“一个通用的强化学习算法,通过自我对弈掌握国际象棋、将棋和围棋”。
⁸ 请在书籍的网站上查看这些内容的完整实现。
⁹ 完整的代码可以在书籍的 GitHub 仓库的本章节中找到。
第六章:循环神经网络
在这一章中,我们将介绍循环神经网络(RNNs),这是一类用于处理数据序列的神经网络架构。到目前为止,我们看到的神经网络将它们接收到的每一批数据视为一组独立的观察结果;在我们在第四章中看到的全连接神经网络或第五章中看到的卷积神经网络中,没有某些 MNIST 数字在其他数字之前或之后到达的概念。然而,许多种类的数据在本质上是有序的,无论是时间序列数据,在工业或金融背景下可能会处理的数据,还是语言数据,其中字符、单词、句子等是有序的。循环神经网络旨在学习如何接收这些数据序列并返回一个正确的预测作为输出,无论这个正确的预测是关于第二天金融资产价格的还是关于句子中下一个单词的。
处理有序数据将需要对我们在前几章中看到的全连接神经网络进行三种改变。首先,它将涉及“向我们馈送神经网络的ndarray
添加一个新维度”。以前,我们馈送给神经网络的数据在本质上是二维的——每个ndarray
有一个维度表示观察数量,另一个维度表示特征数量;另一种思考方式是每个观察是一个一维向量。使用循环神经网络,每个输入仍然会有一个维度表示观察数量,但每个观察将被表示为一个二维ndarray
:一个维度将表示数据序列的长度,第二个维度将表示每个序列元素上存在的特征数量。因此,RNN 的整体输入将是一个形状为[batch_size, sequence_length, num_features]
的三维ndarray
——一批序列。
第二,当然,为了处理这种新的三维输入,我们将不得不使用一种新的神经网络架构,这将是本章的主要焦点。然而,第三个改变是我们将在本章开始讨论的地方:我们将不得不使用完全不同的框架和不同的抽象来处理这种新形式的数据。为什么?在全连接和卷积神经网络的情况下,即使每个“操作”实际上代表了许多个单独的加法和乘法(如矩阵乘法或卷积的情况),它都可以被描述为一个单一的“小工厂”,在前向和后向传递中都将一个ndarray
作为输入并产生一个ndarray
作为输出(可能在这些计算中使用另一个表示操作参数的ndarray
)。事实证明,循环神经网络无法以这种方式实现。在继续阅读以了解原因之前,花些时间思考一下:神经网络架构的哪些特征会导致我们迄今为止构建的框架崩溃?虽然答案很有启发性,但完整的解决方案涉及到深入实现细节的概念,超出了本书的范围。为了开始解开这个问题,让我们揭示我们迄今为止使用的框架的一个关键限制。
关键限制:处理分支
事实证明,我们的框架无法训练具有像图 6-1 所示的计算图的模型。
图 6-1。导致我们的操作框架失败的计算图:在前向传递过程中同一数量被多次重复,这意味着我们不能像以前那样在后向传递过程中按顺序发送梯度
这有什么问题吗?将前向传递转换为代码似乎没问题(请注意,我们这里仅出于说明目的编写了Add
和Multiply
操作):
a1 = torch.randn(3,3)
w1 = torch.randn(3,3)
a2 = torch.randn(3,3)
w2 = torch.randn(3,3)
w3 = torch.randn(3,3)
# operations
wm1 = WeightMultiply(w1)
wm2 = WeightMultiply(w2)
add2 = Add(2, 1)
mult3 = Multiply(2, 1)
b1 = wm1.forward(a1)
b2 = wm2.forward(a2)
c1 = add2.forward((b1, b2))
L = mult3.forward((c1, b2))
问题开始于我们开始后向传递时。假设我们想要使用我们通常的链式法则逻辑来计算L
相对于w1
的导数。以前,我们只需按相反顺序在每个操作上调用backward
。在这里,由于在前向传递中重复使用b2
,这种方法不起作用。例如,如果我们从mult3
开始调用backward
,我们将得到每个输入c1
和b2
的梯度。然而,如果我们接着在add2
上调用backward
,我们不能只传入c1
的梯度:我们还必须以某种方式传入b2
的梯度,因为这也会影响损失L
。因此,为了正确执行此图的后向传递,我们不能只按照完全相反的顺序移动操作;我们必须手动编写类似以下内容的内容:
c1_grad, b2_grad_1 = mult3.backward(L_grad)
b1_grad, b2_grad_2 = add2.backward(c1_grad)
# combine these gradients to reflect the fact that b2 is used twice on the
# forward pass
b2_grad = b2_grad_1 + b2_grad_2
a2_grad = wm2.backward(b2_grad)
a1_grad = wm1.backward(b1_grad)
在这一点上,我们可能完全可以跳过使用Operation
;我们可以简单地保存在前向传递中计算的所有量,并在后向传递中重复使用它们,就像我们在第二章中所做的那样!我们可以通过手动定义网络前向和后向传递中要执行的各个计算来始终编写任意复杂的神经网络,就像我们在第二章中写出了两层神经网络后向传递中涉及的 17 个单独操作一样(事实上,我们稍后在本章中的“RNN 单元”中会做类似的事情)。我们尝试使用Operation
构建一个灵活的框架,让我们以高层次的术语描述神经网络,并让所有低级别的计算“自动工作”。虽然这个框架展示了许多关于神经网络的关键概念,但现在我们看到了它的局限性。
有一个优雅的解决方案:自动微分,这是一种完全不同的实现神经网络的方式。我们将在这里涵盖这个概念的足够部分,以便让您了解它的工作原理,但不会进一步构建一个完整功能的自动微分框架将需要几章的篇幅。此外,当我们涵盖 PyTorch 时,我们将看到如何使用一个高性能的自动微分框架。尽管如此,自动微分是一个重要的概念,需要从第一原则理解,在我们深入研究 RNN 之前,我们将为其设计一个基本框架,并展示它如何解决在前面示例中描述的前向传递中重复使用对象的问题。
自动微分
正如我们所看到的,有一些神经网络架构,对于我们迄今使用的Operation
框架来说,很难轻松地计算输出相对于输入的梯度,而我们必须这样做才能训练我们的模型。自动微分允许我们通过完全不同的路径计算这些梯度:而不是Operation
是构成网络的原子单位,我们定义一个包装在数据周围的类,允许数据跟踪在其上执行的操作,以便数据可以在参与不同操作时不断累积梯度。为了更好地理解这种“梯度累积”是如何工作的,让我们开始编码吧。
编写梯度累积
为了自动跟踪梯度,我们必须重写执行数据基本操作的 Python 方法。在 Python 中,使用诸如+
或-
之类的运算符实际上调用诸如__add__
和__sub__
之类的底层隐藏方法。例如,这是+
的工作原理:
a = array([3,3])
print("Addition using '__add__':", a.__add__(4))
print("Addition using '+':", a + 4)
Addition using '__add__': [7 7]
Addition using '+': [7 7]
我们可以利用这一点编写一个类,该类包装了典型的 Python“数字”(float
或int
)并覆盖了add
和mul
方法:
Numberable = Union[float, int]
def ensure_number(num: Numberable) -> NumberWithGrad:
if isinstance(num, NumberWithGrad):
return num
else:
return NumberWithGrad(num)
class NumberWithGrad(object):
def __init__(self,
num: Numberable,
depends_on: List[Numberable] = None,
creation_op: str = ''):
self.num = num
self.grad = None
self.depends_on = depends_on or []
self.creation_op = creation_op
def __add__(self,
other: Numberable) -> NumberWithGrad:
return NumberWithGrad(self.num + ensure_number(other).num,
depends_on = [self, ensure_number(other)],
creation_op = 'add')
def __mul__(self,
other: Numberable = None) -> NumberWithGrad:
return NumberWithGrad(self.num * ensure_number(other).num,
depends_on = [self, ensure_number(other)],
creation_op = 'mul')
def backward(self, backward_grad: Numberable = None) -> None:
if backward_grad is None: # first time calling backward
self.grad = 1
else:
# These lines allow gradients to accumulate.
# If the gradient doesn't exist yet, simply set it equal
# to backward_grad
if self.grad is None:
self.grad = backward_grad
# Otherwise, simply add backward_grad to the existing gradient
else:
self.grad += backward_grad
if self.creation_op == "add":
# Simply send backward self.grad, since increasing either of these
# elements will increase the output by that same amount
self.depends_on[0].backward(self.grad)
self.depends_on[1].backward(self.grad)
if self.creation_op == "mul":
# Calculate the derivative with respect to the first element
new = self.depends_on[1] * self.grad
# Send backward the derivative with respect to that element
self.depends_on[0].backward(new.num)
# Calculate the derivative with respect to the second element
new = self.depends_on[0] * self.grad
# Send backward the derivative with respect to that element
self.depends_on[1].backward(new.num)
这里有很多事情要做,让我们解开这个NumberWithGrad
类并看看它是如何工作的。请记住,这样一个类的目标是能够编写简单的操作并自动计算梯度;例如,假设我们写:
a = NumberWithGrad(3)
b = a * 4
c = b + 5
在这一点上,通过增加ϵ,a
将增加多少会增加c
的值?很明显,它将增加c
。确实,使用前面的类,如果我们首先写:
c.backward()
然后,不需要编写for
循环来迭代Operation
,我们可以写:
print(a.grad)
4
这是如何工作的?前一个类中融入的基本见解是,每当在NumberWithGrad
上执行+
或*
操作时,都会创建一个新的NumberWithGrad
,第一个NumberWithGrad
作为依赖项。然后,当在NumberWithGrad
上调用backward
时,就像之前在c
上调用的那样,用于创建c
的所有NumberWithGrad
的所有梯度都会自动计算。因此,确实,不仅计算了a
的梯度,还计算了b
的梯度:
print(b.grad)
1
然而,这个框架的真正美妙之处在于它允许NumberWithGrad
累积梯度,从而在一系列计算中多次重复使用,并且我们最终得到正确的梯度。我们将用相同的一系列操作来说明这一点,这些操作在之前让我们困惑,使用NumberWithGrad
多次进行一系列计算,然后详细解释它是如何工作的。
自动微分示例
这是一系列计算,其中a
被多次重复使用:
a = NumberWithGrad(3)
b = a * 4
c = b + 3
d = c * (a + 2)
我们可以计算出,如果我们进行这些操作,d = 75,但正如我们所知道的,真正的问题是:增加a
的值将如何增加d
的值?我们可以首先通过数学方法解决这个问题。我们有:
因此,使用微积分中的幂规则:
对于a = 3,因此,这个导数的值应该是。通过数值确认:
def forward(num: int):
b = num * 4
c = b + 3
return c * (num + 2)
print(round(forward(3.01) - forward(2.99)) / 0.02), 3)
35.0
现在,观察到当我们使用自动微分框架计算梯度时,我们得到相同的结果:
a = NumberWithGrad(3)
b = a * 4
c = b + 3
d = (a + 2)
e = c * d
e.backward()
print(a.grad)
35
解释发生了什么
正如我们所看到的,自动微分的目标是使数据对象本身——数字、ndarray
、张量
等——成为分析的基本单位,而不是以前的Operation
。
所有自动微分技术都有以下共同点:
-
每种技术都包括一个包装实际计算数据的类。在这里,我们将
NumberWithGrad
包装在float
和int
周围;例如,在 PyTorch 中,类似的类称为Tensor
。 -
重新定义常见操作,如加法、乘法和矩阵乘法,以便它们始终返回该类的成员;在前面的情况下,我们确保要么是
NumberWithGrad
和NumberWithGrad
的加法要么是NumberWithGrad
和float
或int
的加法。 -
NumberWithGrad
类必须包含有关如何计算梯度的信息,考虑到前向传播时发生了什么。以前,我们通过在类中包含一个creation_op
参数来实现这一点,该参数简单地记录了NumberWithGrad
是如何创建的。 -
在反向传播过程中,梯度是使用底层数据类型而不是包装器向后传递的。这意味着梯度的类型是
float
和int
,而不是NumberWithGrad
。 -
正如在本节开头提到的,自动微分允许我们在前向传播期间重复使用计算的量——在前面的示例中,我们两次使用
a
而没有问题。允许这样做的关键是这些行:if self.grad is None: self.grad = backward_grad else: self.grad += backward_grad
这些行表明,在接收到新的梯度
backward_grad
时,NumberWithGrad
应该将NumberWithGrad
的梯度初始化为这个值,或者简单地将该值添加到NumberWithGrad
的现有梯度中。这就是允许NumberWithGrad
在模型中重复使用相关对象时累积梯度的原因。
这就是我们将涵盖的自动微分的全部内容。现在让我们转向激发这一偏离的模型结构,因为在前向传播过程中需要重复使用某些量来进行预测。
循环神经网络的动机
正如我们在本章开头讨论的那样,循环神经网络旨在处理以序列形式出现的数据:每个观察结果不再是具有n
个特征的向量,而是一个二维数组,维度为n
个特征乘以t
个时间步。这在图 6-2 中有所描述。
图 6-2. 顺序数据:在每个时间步中我们有 n 个特征
在接下来的几节中,我们将解释 RNN 如何适应这种形式的数据,但首先让我们尝试理解为什么我们需要它们。仅仅使用普通的前馈神经网络来处理这种类型的数据会有什么限制?一种方法是将每个时间步表示为一个独立的特征集。例如,一个观察结果可以具有来自时间t = 1
的特征和来自时间t = 2
的目标值,下一个观察结果可以具有来自时间t = 2
的特征和来自时间t = 3
的目标值,依此类推。如果我们想要使用多个时间步的数据来进行每次预测,而不仅仅是来自一个时间步的数据,我们可以使用t = 1
和t = 2
的特征来预测t = 3
的目标,使用t = 2
和t = 3
的特征来预测t = 4
的目标,依此类推。
然而,将每个时间步视为独立的方式忽略了数据是按顺序排列的事实。我们如何理想地利用数据的顺序性来做出更好的预测?解决方案看起来会像这样:
-
使用时间步
t = 1
的特征来预测对应时间t = 1
的目标。 -
使用时间步
t = 2
的特征以及从t = 1
包括t = 1
的目标值的信息来预测t = 2
。 -
使用时间步
t = 3
的特征以及从t = 1
和t = 2
累积的信息来预测t = 3
时的结果。 -
然后,每一步都使用所有先前时间步的信息来进行预测。
为了做到这一点,似乎我们希望逐个序列元素地通过神经网络传递我们的数据,首先传递第一个时间步的数据,然后传递下一个时间步的数据,依此类推。此外,我们希望我们的神经网络在通过新的序列元素时“累积信息”关于它之前所看到的内容。我们将在本章的其余部分详细讨论循环神经网络如何做到这一点。正如我们将看到的,虽然有几种循环神经网络的变体,但它们在处理数据时都共享一个共同的基本结构;我们将大部分时间讨论这种结构,并在最后讨论这些变体的不同之处。
循环神经网络简介
让我们通过高层次的方式开始讨论 RNN,看看数据是如何通过“前馈”神经网络传递的。在这种类型的网络中,数据通过一系列层向前传递。对于单个观察结果,每一层的输出是该观察结果在该层的神经网络“表示”。在第一层之后,该表示由原始特征的组合组成;在下一层之后,它由这些表示的组合或原始特征的“特征的特征”组成,依此类推,直到网络中的后续层。因此,在每次向前传递之后,网络在每个层的输出中包含许多原始观察结果的表示。这在图 6-3 中有所体现。
图 6-3。一个常规的神经网络将观察结果向前传递,并在每一层之后将其转换为不同的表示
然而,当下一组观察结果通过网络传递时,这些表示将被丢弃;循环神经网络及其所有变体的关键创新是将这些表示传递回网络,以及下一组观察结果。这个过程看起来是这样的:
-
在第一个时间步,
t = 1
,我们将通过第一个时间步的观察结果(可能还有随机初始化的表示)进行传递。我们将输出t = 1
的预测,以及每一层的表示。 -
在下一个时间步中,我们将通过第二个时间步的观察结果
t = 2
,以及在第一个时间步计算的表示(再次,这些只是神经网络层的输出),并以某种方式将它们结合起来(正是在这个结合步骤中,我们将学习到的 RNN 的变体有所不同)。我们将使用这两个信息来输出t = 2
的预测,以及每一层的更新表示,这些表示现在是输入在t = 1
和t = 2
时传递的函数。 -
在第三个时间步中,我们将通过来自
t = 3
的观察结果,以及现在包含来自t = 1
和t = 2
信息的表示,利用这些信息对t = 3
进行预测,以及每一层的额外更新的表示,现在包含时间步 1-3 的信息。
这个过程在图 6-4 中描述。
图 6-4。循环神经网络将每一层的表示向前传递到下一个时间步
我们看到每一层都有一个“持久”的表示,随着时间的推移而更新,因为新的观察结果被传递。事实上,这就是为什么 RNN 不适用于我们为之前章节编写的Operation
框架的原因:每一层的表示的ndarray
会不断更新和重复使用,以便使用 RNN 对一系列数据进行一次预测。因为我们无法使用之前章节的框架,我们将不得不从头开始考虑如何构建处理 RNN 的类。
RNN 的第一个类:RNNLayer
根据我们希望 RNN 工作的描述,我们至少知道我们需要一个RNNLayer
类,该类将逐个序列元素向前传递数据序列。现在让我们深入了解这样一个类应该如何工作的细节。正如我们在本章中提到的,RNN 将处理每个观察都是二维的数据,维度为(sequence_length, num_features)
;由于在计算上总是更有效率地批量传递数据,RNNLayer
将需要接收三维的ndarray
,大小为(batch_size, sequence_length, num_features)
。然而,我在前一节中解释过,我们希望逐个序列元素通过我们的RNNLayer
传递数据;如果我们的输入data
是(batch_size, sequence_length, num_features)
,我们如何做到这一点呢?这样做:
-
从第二轴选择一个二维数组,从
data[:, 0, :]
开始。这个ndarray
的形状将是(batch_size, num_features)
。 -
为
RNNLayer
初始化一个“隐藏状态”,该状态将随着传入的每个序列元素而不断更新,这次的形状为(batch_size, hidden_size)
。这个ndarray
将代表层对已经在先前时间步传入的数据的“累积信息”。 -
将这两个
ndarray
通过该层的第一个时间步向前传递。我们将设计RNNLayer
以输出与输入不同维度的ndarray
,就像常规的Dense
层一样,因此输出将是形状为(batch_size, num_outputs)
。此外,更新神经网络对每个观察的表示:在每个时间步,我们的RNNLayer
还应该输出一个形状为(batch_size, hidden_size)
的ndarray
。 -
从
data
中选择下一个二维数组:data[:, 1, :]
。 -
将这些数据以及 RNN 在第一个时间步输出的表示值传递到该层的第二个时间步,以获得另一个形状为
(batch_size, num_outputs)
的输出,以及形状为(batch_size, hidden_size)
的更新表示。 -
一直持续到所有
sequence_length
时间步都通过该层。然后将所有结果连接在一起,以获得该层的输出形状为(batch_size, sequence_length, num_outputs)
。
这给了我们一个关于我们的RNNLayer
应该如何工作的想法——当我们编写代码时,我们将巩固这种理解——但它也暗示我们需要另一个类来处理接收数据并更新每个时间步的层隐藏状态。为此,我们将使用RNNNode
,这是我们将要介绍的下一个类。
RNN 的第二个类:RNNNode
根据前一节的描述,RNNNode
应该有一个forward
方法,具有以下输入和输出:
-
两个
ndarray
作为输入:-
一个用于网络的数据输入,形状为
[batch_size, num_features]
-
一个用于该时间步观察的表示的形状为
[batch_size, hidden_size]
-
-
两个
ndarray
作为输出:-
一个用于网络在该时间步的输出的数组,形状为
[batch_size, num_outputs]
-
一个用于该时间步观察的更新表示,形状为:
[batch_size, hidden_size]
-
接下来,我们将展示RNNNode
和RNNLayer
这两个类如何配合。
将这两个类结合起来
RNNLayer
类将包装在一个RNNNode
列表周围,并且(至少)包含一个具有以下输入和输出的forward
方法:
-
输入:形状为
[batch_size, sequence_length, num_features]
的一批观察序列 -
输出:这些序列的神经网络输出的形状为
[batch_size, sequence_length, num_outputs]
图 6-5 展示了数据如何通过具有每个五个RNNNode
的两个RNNLayer
的 RNN 向前传播的顺序。在每个时间步,初始维度为feature_size
的输入依次通过每个RNNLayer
中的第一个RNNNode
向前传递,网络最终在该时间步输出维度为output_size
的预测。此外,每个RNNNode
将“隐藏状态”向前传递到每层内的下一个RNNNode
。一旦每个五个时间步的数据都通过所有层向前传递,我们将得到一个形状为(5, output_size)
的最终预测集,其中output_size
应该与目标的维度相同。然后,这些预测将与目标进行比较,并计算损失梯度,启动反向传播。图 6-5 总结了这一点,展示了数据如何从第一个(1)到最后一个(10)依次通过 5×2 个RNNNode
的顺序流动。
图 6-5。设计用于处理长度为 5 的序列的具有两层的 RNN 中数据将如何流动的顺序
或者,数据可以按照图 6-6 中显示的顺序在 RNN 中流动。无论顺序如何,以下步骤必须发生:
-
每个层都需要在给定时间步处理其数据,然后才能处理下一层 - 例如,在图 6-5 中,2 不能在 1 之前发生,4 不能在 3 之前发生。
-
同样,每个层都必须按顺序处理其所有时间步 - 例如,在图 6-5 中,4 不能在 2 之前发生,3 不能在 1 之前发生。
-
最后一层必须为每个观测输出维度
feature_size
。
图 6-6。数据在同一 RNN 的前向传播过程中可能流动的另一种顺序
这涵盖了 RNN 前向传播的工作原理。那么反向传播呢?
反向传播
通过递归神经网络的反向传播通常被描述为一个称为“递归神经网络的反向传播”算法。虽然这确实描述了反向传播过程中发生的事情,但这让事情听起来比实际复杂得多。牢记数据如何通过 RNN 向前流动的解释,我们可以这样描述反向传播过程:我们通过将梯度反向通过网络传递,以与在前向传播过程中向前传递输入的顺序相反的顺序将数据向后传递,这与我们在常规前馈网络中所做的事情是一样的。
观察图 6-5 和 6-6 中的图表,在前向传播过程中:
-
我们从形状为
(feature_size, sequence_length)
的一批观测开始。 -
这些输入被分解为单个
sequence_length
元素,并逐个传入网络。 -
每个元素都通过所有层,最终被转换为大小为
output_size
的输出。 -
同时,层将隐藏状态向前传递到下一个时间步的层的计算中。
-
这将持续进行所有
sequence_length
时间步,最终产生大小为(output_size, sequence_length)
的总输出。
反向传播只是以相反的方式工作:
-
我们从形状为
[output_size, sequence_length]
的梯度开始,表示输出的每个元素(也是形状为[output_size, sequence_length]
的)最终对该批观测的损失产生了多大影响。 -
这些梯度被分解为单个
sequence_length
元素,并以相反顺序通过层向后传递。 -
单个元素的梯度通过所有层向后传递。
-
同时,各层将“与该时间步的隐藏状态相关的损失的梯度”向后传递到先前时间步的层的计算中。
-
这将持续进行所有
sequence_length
时间步,直到梯度已经向网络中的每一层传递,从而使我们能够计算出损失相对于每个权重的梯度,就像在常规前馈网络的情况下一样。
这种前向传递和后向传递之间的并行性在图 6-7 中得到了突出,该图显示了数据在 RNN 在后向传递过程中的流动方式。当然,您会注意到,它与图 6-5 相同,但箭头方向相反,数字也有所改变。
图 6-7。在后向传递中,RNNs 将数据传递的方向与前向传递相反
这突显了,在高层次上,RNNLayer
的前向和后向传递与普通神经网络中的层非常相似:它们都接收特定形状的ndarray
作为输入,输出另一种形状的ndarray
,在后向传递中接收与其输出相同形状的输出梯度,并产生与其输入相同形状的输入梯度。然而,在RNNLayer
中处理权重梯度的方式与其他层有关键差异,因此在我们开始编码之前,我们将简要介绍一下这一点。
在 RNN 中累积权重的梯度
在循环神经网络中,就像在常规神经网络中一样,每一层都会有一组权重。这意味着相同的权重集将影响所有sequence_length
时间步的层输出;因此,在反向传播过程中,相同的权重集将接收sequence_length
不同的梯度。例如,在图 6-7 中显示的反向传播中标记为“1”的圆圈中,第二层将接收最后一个时间步的梯度,而在标记为“3”的圆圈中,该层将接收倒数第二个时间步的梯度;这两者都将由相同的权重驱动。因此,在反向传播过程中,我们将不得不累积权重的梯度,这意味着无论我们选择如何存储权重,我们都将不得不使用类似以下的方法更新它们的梯度:
weight_grad += grad_from_time_step
这与Dense
和Conv2D
层不同,在这些层中,我们只是将参数存储在param_grad
参数中。
我们已经阐明了 RNN 的工作原理以及我们想要构建的类来实现它们;现在让我们开始研究细节。
RNN:代码
让我们从几种实现 RNN 与我们在本书中介绍的其他神经网络类似的方式开始:
-
RNN 仍然通过一系列层向前传递数据,这些层在前向传递时将输出向前传递,在后向传递时将梯度向后传递。因此,例如,无论我们的
NeuralNetwork
类的等价物最终是什么,它仍将具有layers
属性作为RNNLayer
的列表,并且前向传递将包含如下代码:def forward(self, x_batch: ndarray) -> ndarray: assert_dim(ndarray, 3) x_out = x_batch for layer in self.layers: x_out = layer.forward(x_out) return x_out
-
RNN 的
Loss
与以前相同:最后一个Layer
生成一个ndarray
output
,与y_batch
进行比较,计算出一个单一值,并返回相对于Loss
输入的该值的梯度,形状与output
相同。我们将不得不修改 softmax 函数,以便与形状为[batch_size, sequence_length, feature_size]
的ndarray
适当地配合,但我们可以处理这个问题。 -
Trainer
大部分都是相同的:我们循环遍历我们的训练数据,选择输入数据和输出数据的批次,并不断将它们通过我们的模型,产生损失值,告诉我们我们的模型是否在学习,并在每个批次通过后更新权重。说到这里... -
我们的
Optimizer
保持不变。正如我们将看到的,我们将不得不更新如何在每个时间步提取params
和param_grads
,但“更新规则”(我们在类中的_update_rule
函数中捕获)保持不变。
Layer
本身是有趣的地方。
RNNLayer 类
之前,我们给Layer
提供了一组Operation
,用于向前传递数据并向后发送梯度。RNNLayer
将完全不同;它们现在必须保持一个“隐藏状态”,随着新数据被馈送并在每个时间步骤以某种方式与数据“组合”,该状态将不断更新。这应该如何工作?我们可以使用图 6-5 和 6-6 作为指导:它们建议每个RNNLayer
应该有一个RNNNode
列表作为属性,然后该层的input
中的每个序列元素应该逐个通过每个RNNNode
传递。每个RNNNode
将接收此序列元素,以及该层的“隐藏状态”,并在该时间步骤为该层产生一个输出,同时更新该层的隐藏状态。
为了澄清所有这些,让我们深入研究并开始编码:我们将按顺序介绍RNNLayer
的初始化方式,它在前向传递期间如何发送数据,以及在反向传递期间如何发送数据。
初始化
每个RNNLayer
将以以下方式开始:
-
一个
int
hidden_size
-
一个
int
output_size
-
一个形状为
(1, hidden_size)
的ndarray
start_H
,表示该层的隐藏状态
此外,就像在常规神经网络中一样,当我们初始化层时,我们将设置self.first = True
;第一次将数据传递到forward
方法时,我们将将接收到的ndarray
传递到一个_init_params
方法中,初始化参数,并设置self.first = False
。
有了初始化的层,我们准备描述如何将数据发送到前面。
前向方法
forward
方法的大部分将包括接收形状为(batch_size, sequence_length, feature_size)
的ndarray
x_seq_in
,并按顺序通过该层的RNNNode
。在以下代码中,self.nodes
是该层的RNNNode
,H_in
是该层的隐藏状态:
sequence_length = x_seq_in.shape[1]
x_seq_out = np.zeros((batch_size, sequence_length, self.output_size))
for t in range(sequence_length):
x_in = x_seq_in[:, t, :]
y_out, H_in = self.nodes[t].forward(x_in, H_in, self.params)
x_seq_out[:, t, :] = y_out
关于隐藏状态H_in
的一点说明:RNNLayer
的隐藏状态通常表示为一个向量,但每个RNNNode
中的操作要求隐藏状态为大小为(batch_size, hidden_size)
的ndarray
。因此,在每次前向传递的开始时,我们简单地“重复”隐藏状态:
batch_size = x_seq_in.shape[0]
H_in = np.copy(self.start_H)
H_in = np.repeat(H_in, batch_size, axis=0)
在前向传递之后,我们取得构成批次的观测值的平均值,以获得该层的更新隐藏状态:
self.start_H = H_in.mean(axis=0, keepdims=True)
此外,我们可以从这段代码中看到,RNNNode
将必须具有一个接收两个形状数组的forward
方法:
-
(batch_size, feature_size)
-
(batch_size, hidden_size)
并返回两个形状的数组:
-
(batch_size, output_size)
-
(batch_size, hidden_size)
我们将在下一节中介绍RNNNode
(及其变体)。但首先让我们介绍RNNLayer
类的backward
方法。
反向方法
由于forward
方法输出了x_seq_out
,backward
方法将接收与x_seq_out
形状相同的梯度,称为x_seq_out_grad
。与forward
方法相反,我们通过RNNNode
将此梯度向后传递,最终返回整个层的形状为(batch_size, sequence_length, self.feature_size)
的x_seq_in_grad
作为梯度:
h_in_grad = np.zeros((batch_size, self.hidden_size))
sequence_length = x_seq_out_grad.shape[1]
x_seq_in_grad = np.zeros((batch_size, sequence_length, self.feature_size))
for t in reversed(range(sequence_length)):
x_out_grad = x_seq_out_grad[:, t, :]
grad_out, h_in_grad = \
self.nodes[t].backward(x_out_grad, h_in_grad, self.params)
x_seq_in_grad[:, t, :] = grad_out
从中我们看到,RNNNode
应该有一个backward
方法,遵循模式,与forward
方法相反,接收两个形状的数组:
-
(batch_size, output_size)
-
(batch_size, hidden_size)
并返回两个形状的数组:
-
(batch_size, feature_size)
-
(batch_size, hidden_size)
这就是RNNLayer
的工作原理。现在似乎唯一剩下的就是描述递归神经网络的核心:RNNNode
,在这里实际计算发生。在我们继续之前,让我们澄清RNNNode
及其在整个 RNN 中的变体的作用。
RNNNodes 的基本要素
在大多数关于 RNN 的讨论中,首先讨论的是我们在这里称为RNNNode
的工作原理。然而,我们最后讨论这些,因为关于 RNN 最重要的概念是我们在本章中迄今为止描述的那些:数据的结构方式以及数据和隐藏状态在层之间和通过时间如何路由。事实证明,我们可以实现RNNNode
的多种方式,即给定时间步骤的数据的实际处理和层的隐藏状态的更新。一种方式产生了通常被认为是“常规”递归神经网络的东西,我们在这里将其称为另一个常见术语:香草 RNN。然而,还有其他更复杂的方式可以产生不同的 RNN 变体;例如,一种带有称为 GRUs 的RNNNode
的变体,GRUs 代表“门控循环单元”。通常,GRUs 和其他 RNN 变体被描述为与香草 RNN 有显著不同;然而,重要的是要理解所有 RNN 变体都共享我们迄今为止看到的层的结构——例如,它们都以相同的方式向前传递数据,更新它们的隐藏状态(s)在每个时间步。它们之间唯一的区别在于这些“节点”的内部工作方式。
为了强调这一点:如果我们实现了一个GRULayer
而不是一个RNNLayer
,代码将完全相同!以下代码仍然构成前向传递的核心:
sequence_length = x_seq_in.shape[1]
x_seq_out = np.zeros((batch_size, sequence_length, self.output_size))
for t in range(sequence_length):
x_in = x_seq_in[:, t, :]
y_out, H_in = self.nodes[t].forward(x_in, H_in, self.params)
x_seq_out[:, t, :] = y_out
唯一的区别是self.nodes
中的每个“节点”将是一个GRUNode
而不是RNNNode
。类似地,backward
方法也将是相同的。
这对于香草 RNN 的最知名变体——LSTMs 或“长短期记忆”单元——也几乎完全正确。唯一的区别在于,这些LSTMLayer
需要在通过时间向前传递序列元素时“记住”两个量,并更新:除了“隐藏状态”外,层中还存储着“细胞状态”,使其能够更好地建模长期依赖关系。这导致了我们在实现LSTMLayer
与RNNLayer
时会有一些细微差异;例如,LSTMLayer
将有两个ndarray
来存储层在整个时间步中的状态:
-
一个形状为
(1, hidden_size)
的ndarray
start_H
,表示层的隐藏状态 -
一个形状为
(1, cell_size)
的ndarray
start_C
,表示层的细胞状态
因此,每个LSTMNode
都应该接收输入,以及隐藏状态和细胞状态。在前向传递中,这将如下所示:
y_out, H_in, C_in = self.nodes[t].forward(x_in, H_in, C_in self.params)
以及:
grad_out, h_in_grad, c_in_grad = \
self.nodes[t].backward(x_out_grad, h_in_grad, c_in_grad, self.params)
在backward
方法中。
这里提到的三种变体远不止这些,其中一些,比如带有“窥视孔连接”的 LSTMs,除了隐藏状态外还有一个细胞状态,而另一些只保留隐藏状态。尽管如此,由LSTMPeepholeConnectionNode
组成的层将与我们迄今为止看到的变体一样适用于RNNLayer
,因此具有相同的forward
和backward
方法。RNN 的基本结构——数据如何通过层向前路由,以及如何通过时间步向前路由,然后在反向传递期间沿相反方向路由——这就是使递归神经网络独特的地方。例如,香草 RNN 和基于 LSTM 的 RNN 之间的实际结构差异相对较小,尽管它们的性能可能有显著不同。
有了这些,让我们看看RNNNode
的实现。
“香草”RNNNodes
RNN 每次接收一个序列元素的数据;例如,如果我们正在预测石油价格,在每个时间步,RNN 将接收关于我们用于预测该时间步价格的特征的信息。此外,RNN 将在其“隐藏状态”中具有一个编码,表示有关先前时间步发生的事情的累积信息。我们希望将这两个数据片段——时间步的特征和所有先前时间步的累积信息——组合成该时间步的预测以及更新的隐藏状态。
要理解 RNN 应该如何实现这一点,回想一下在常规神经网络中发生的情况。在前馈神经网络中,每一层接收来自前一层的一组“学习到的特征”,每个特征都是网络“学习到”有用的原始特征的组合。然后该层将这些特征乘以一个权重矩阵,使该层能够学习到作为输入接收的特征的组合特征。为了水平设置和规范化输出,我们向这些新特征添加一个“偏置”,并通过激活函数传递它们。
在递归神经网络中,我们希望我们更新的隐藏状态是输入和旧隐藏状态的组合。因此,类似于常规神经网络中发生的情况:
-
我们首先将输入和隐藏状态连接起来。然后我们将这个值乘以一个权重矩阵,加上一个偏置,并通过
Tanh
激活函数传递结果。这就是我们更新的隐藏状态。 -
接下来,我们将这个新的隐藏状态乘以一个权重矩阵,将隐藏状态转换为我们想要的维度的输出。例如,如果我们使用这个 RNN 来预测每个时间步的单个连续值,我们将把隐藏状态乘以一个大小为
(hidden_size, 1)
的权重矩阵。
因此,我们更新的隐藏状态将是在该时间步接收到的输入以及先前隐藏状态的函数,输出将是通过全连接层的操作将此更新的隐藏状态馈送的结果。
让我们编写代码。
RNNNode:代码
以下代码实现了刚才描述的步骤。请注意,正如我们稍后将对 GRUs 和 LSTMs 做的一样(以及我们在第一章中展示的简单数学函数),我们将在Node
中存储所有在前向传播中计算的量作为属性,以便我们可以使用它们来计算反向传播:
def forward(self,
x_in: ndarray,
H_in: ndarray,
params_dict: Dict[str, Dict[str, ndarray]]
) -> Tuple[ndarray]:
'''
param x: numpy array of shape (batch_size, vocab_size)
param H_prev: numpy array of shape (batch_size, hidden_size)
return self.x_out: numpy array of shape (batch_size, vocab_size)
return self.H: numpy array of shape (batch_size, hidden_size)
'''
self.X_in = x_in
self.H_in = H_in
self.Z = np.column_stack((x_in, H_in))
self.H_int = np.dot(self.Z, params_dict['W_f']['value']) \
+ params_dict['B_f']['value']
self.H_out = tanh(self.H_int)
self.X_out = np.dot(self.H_out, params_dict['W_v']['value']) \
+ params_dict['B_v']['value']
return self.X_out, self.H_out
另一个注意事项:由于我们这里没有使用ParamOperation
,我们需要以不同的方式存储参数。我们将把它们存储在一个名为params_dict
的字典中,通过名称引用参数。此外,每个参数将有两个键:value
和deriv
,分别存储实际参数值和它们关联的梯度。在前向传播中,我们只使用value
键。
RNNNodes:反向传播
通过RNNNode
的反向传播简单地计算损失相对于RNNNode
输入的梯度值,给定损失相对于RNNNode
输出的梯度。我们可以使用类似于我们在第一章和第二章中解决的逻辑来做到这一点:由于我们可以将RNNNode
表示为一系列操作,我们可以简单地计算每个操作在其输入处的导数,并将这些导数与之前的导数逐个相乘(注意正确处理矩阵乘法),最终得到表示损失相对于每个输入的梯度的ndarray
。以下代码实现了这一点:
def forward(self,
x_in: ndarray,
H_in: ndarray,
params_dict: Dict[str, Dict[str, ndarray]]
) -> Tuple[ndarray]:
'''
param x: numpy array of shape (batch_size, vocab_size)
param H_prev: numpy array of shape (batch_size, hidden_size)
return self.x_out: numpy array of shape (batch_size, vocab_size)
return self.H: numpy array of shape (batch_size, hidden_size)
'''
self.X_in = x_in
self.H_in = H_in
self.Z = np.column_stack((x_in, H_in))
self.H_int = np.dot(self.Z, params_dict['W_f']['value']) \
+ params_dict['B_f']['value']
self.H_out = tanh(self.H_int)
self.X_out = np.dot(self.H_out, params_dict['W_v']['value']) \
+ params_dict['B_v']['value']
return self.X_out, self.H_out
请注意,就像我们之前的Operation
s 一样,backward
函数的输入形状必须与forward
函数的输出形状匹配,backward
函数的输出形状必须与forward
函数的输入形状匹配。
“Vanilla” RNNNodes 的局限性
记住:RNNs 的目的是对数据序列中的依赖关系进行建模。以模拟石油价格为例,这意味着我们应该能够揭示我们在过去几个时间步中看到的特征序列与明天石油价格会发生什么之间的关系。但“几个”应该是多长时间呢?对于石油价格,我们可能会想象,昨天发生的事情——前一时间步——对于预测明天的石油价格最为重要,前一天的重要性较小,而随着时间的倒退,重要性通常会逐渐减弱。
虽然这对许多现实世界的问题是正确的,但有些领域我们希望应用 RNNs,我们希望学习极端长期的依赖关系。语言建模是一个典型的例子,即构建一个模型,可以预测下一个字符、单词或单词部分,给定一个理论上极长的过去单词或字符序列(因为这是一个特别普遍的应用,我们将在本章后面讨论一些与语言建模相关的细节)。对于这一点,vanilla RNNs 通常是不够的。现在我们已经看到了它们的细节,我们可以理解为什么:在每个时间步,隐藏状态都会被同一组权重矩阵乘以所有层中的所有时间步。考虑当我们一遍又一遍地将一个数字乘以一个值x
时会发生什么:如果x < 1
,数字会指数级地减少到 0,如果x > 1
,数字会指数级地增加到无穷大。循环神经网络也有同样的问题:在长时间跨度上,因为在每个时间步中隐藏状态都会被相同的权重集乘以,这些权重的梯度往往会变得极小或极大。前者被称为消失梯度问题,后者被称为爆炸梯度问题*。这两个问题使得训练 RNNs 来模拟非常长期的依赖关系(50-100 个时间步)变得困难。我们接下来将介绍的两种常用的修改 vanilla RNN 架构的方法都显著缓解了这个问题。
一种解决方案:GRUNodes
Vanilla RNNs 可以被描述为将输入和隐藏状态结合在一起,并使用矩阵乘法来确定如何“加权”隐藏状态中包含的信息与新输入中的信息,以预测输出。激发更高级 RNN 变体的洞察力是,为了模拟长期依赖关系,比如语言中存在的依赖关系,有时我们会收到告诉我们需要“忘记”或“重置”隐藏状态的信息。一个简单的例子是句号“。”或冒号“:”——如果语言模型收到其中一个,它就知道应该忘记之前的字符,并开始对字符序列中的新模式进行建模。
第一个简单的变体是 GRUs 或门控循环单元,利用了这一洞察力,因为输入和先前的隐藏状态通过一系列“门”传递。
-
第一个门类似于在 vanilla RNNs 中发生的操作:输入和隐藏状态被连接在一起,乘以一个权重矩阵,然后通过
sigmoid
操作传递。我们可以将其输出视为“更新”门。 -
第二个门被解释为“重置”门:输入和隐藏状态被连接在一起,乘以一个权重矩阵,通过
sigmoid
操作,然后乘以先前的隐藏状态。这使得网络能够“学会忘记”隐藏状态中的内容,给定传入的特定输入。 -
然后,第二个门的输出乘以另一个矩阵,并通过
Tanh
函数传递,输出为新隐藏状态的“候选”。 -
最后,隐藏状态更新为更新门乘以新隐藏状态的“候选”,再加上旧隐藏状态乘以 1 减去更新门。
注意
在本章中,我们将介绍普通 RNN 的两个高级变体:GRUs 和 LSTMs。LSTMs 更受欢迎,比 GRUs 早发明很久。尽管如此,GRUs 是 LSTMs 的一个更简单的版本,并更直接地说明了“门”的概念如何使 RNN 能够“学会重置”其隐藏状态,这就是为什么我们首先介绍它们。
GRUNodes:一个图表
图 6-8 将GRUNode
描述为一系列门。每个门包含一个Dense
层的操作:乘以一个权重矩阵,加上一个偏置,并通过激活函数传递结果。使用的激活函数要么是sigmoid
,在这种情况下,结果的范围在 0 到 1 之间,要么是Tanh
,在这种情况下,范围在-1 到 1 之间;下一个产生的每个中间ndarray
的范围在数组的名称下显示。
图 6-8。数据通过 GRUNode 向前流动,通过门并产生 X_out 和 H_out
在图 6-8 中,以及图 6-9 和 6-10 中,节点的输入为绿色,计算的中间量为蓝色,输出为红色。所有权重(未直接显示)都包含在门中。
请注意,要通过这个过程反向传播,我们必须将其表示为一系列Operation
,计算每个Operation
相对于其输入的导数,并将结果相乘。我们在这里没有明确展示这一点,而是将门(实际上是三个操作的组合)显示为一个单独的块。但是,到目前为止,我们知道如何通过组成每个门的Operation
进行反向传播,而“门”的概念在循环神经网络及其变体的描述中被广泛使用,因此我们将在这里坚持使用这种表示方式。
实际上,图 6-9 显示了一个使用门的普通RNNNode
的表示。
图 6-9。数据通过 RNNNode 向前流动,通过两个门并产生 X_out 和 H_out
因此,另一种思考我们之前描述的作为一个普通RNNNode
组成部分的Operation
的方式是将输入和隐藏状态通过两个门传递。
GRUNodes:代码
以下代码实现了先前描述的GRUNode
的前向传递:
def forward(self,
X_in: ndarray,
H_in: ndarray,
params_dict: Dict[str, Dict[str, ndarray]]) -> Tuple[ndarray]:
'''
param X_in: numpy array of shape (batch_size, vocab_size)
param H_in: numpy array of shape (batch_size, hidden_size)
return self.X_out: numpy array of shape (batch_size, vocab_size)
return self.H_out: numpy array of shape (batch_size, hidden_size)
'''
self.X_in = X_in
self.H_in = H_in
# reset gate
self.X_r = np.dot(X_in, params_dict['W_xr']['value'])
self.H_r = np.dot(H_in, params_dict['W_hr']['value'])
# update gate
self.X_u = np.dot(X_in, params_dict['W_xu']['value'])
self.H_u = np.dot(H_in, params_dict['W_hu']['value'])
# gates
self.r_int = self.X_r + self.H_r + params_dict['B_r']['value']
self.r = sigmoid(self.r_int)
self.u_int = self.X_r + self.H_r + params_dict['B_u']['value']
self.u = sigmoid(self.u_int)
# new state
self.h_reset = self.r * H_in
self.X_h = np.dot(X_in, params_dict['W_xh']['value'])
self.H_h = np.dot(self.h_reset, params_dict['W_hh']['value'])
self.h_bar_int = self.X_h + self.H_h + params_dict['B_h']['value']
self.h_bar = np.tanh(self.h_bar_int)
self.H_out = self.u * self.H_in + (1 - self.u) * self.h_bar
self.X_out = (
np.dot(self.H_out, params_dict['W_v']['value']) \
+ params_dict['B_v']['value']
)
return self.X_out, self.H_out
请注意,我们没有明确连接X_in
和H_in
,因为—与RNNNode
不同,在GRUNode
中我们独立使用它们;具体来说,在self.h_reset = self.r * H_in
这一行中,我们独立使用H_in
而不是X_in
。
backward
方法可以在书的网站上找到;它只是通过组成GRUNode
的操作向后步进,计算每个操作相对于其输入的导数,并将结果相乘。
LSTMNodes
长短期记忆单元,或 LSTMs,是香草 RNN 单元最受欢迎的变体。部分原因是它们是在深度学习的早期阶段,即 1997 年发明的⁶,而对于 LSTM 替代方案如 GRUs 的调查在过去几年中才加速进行(例如,GRUs 是在 2014 年提出的)。
与 GRUs 一样,LSTMs 的动机是为了让 RNN 能够在接收新输入时“重置”或“忘记”其隐藏状态。在 GRUs 中,通过将输入和隐藏状态通过一系列门传递,以及使用这些门计算“建议”的新隐藏状态—self.h_bar
,使用门self.r
计算—然后使用建议的新隐藏状态和旧隐藏状态的加权平均值计算最终隐藏状态,由更新门控制:
self.H_out = self.u * self.H_in + (1 - self.u) * self.h_bar
相比之下,LSTMs使用一个单独的“状态”向量,“单元状态”,来确定是否“忘记”隐藏状态中的内容。然后,它们使用另外两个门来控制它们应该重置或更新单元状态中的内容的程度,以及第四个门来确定基于最终单元状态的情况下隐藏状态的更新程度。⁷
LSTMNodes: Diagram
图 6-10 显示了一个LSTMNode
的图示,其中操作表示为门。
图 6-10. 数据通过 LSTMNode 向前流动的流程,通过一系列门传递,并输出更新的单元状态和隐藏状态 C_out 和 H_out,以及实际输出 X_out
LSTMs: The code
与GRUNode
一样,LSTMNode
的完整代码,包括backward
方法和一个示例,展示了这些节点如何适应LSTMLayer
,都包含在书的网站上。在这里,我们只展示forward
方法:
def forward(self,
X_in: ndarray,
H_in: ndarray,
C_in: ndarray,
params_dict: Dict[str, Dict[str, ndarray]]):
'''
param X_in: numpy array of shape (batch_size, vocab_size)
param H_in: numpy array of shape (batch_size, hidden_size)
param C_in: numpy array of shape (batch_size, hidden_size)
return self.X_out: numpy array of shape (batch_size, output_size)
return self.H: numpy array of shape (batch_size, hidden_size)
return self.C: numpy array of shape (batch_size, hidden_size)
'''
self.X_in = X_in
self.C_in = C_in
self.Z = np.column_stack((X_in, H_in))
self.f_int = (
np.dot(self.Z, params_dict['W_f']['value']) \
+ params_dict['B_f']['value']
)
self.f = sigmoid(self.f_int)
self.i_int = (
np.dot(self.Z, params_dict['W_i']['value']) \
+ params_dict['B_i']['value']
)
self.i = sigmoid(self.i_int)
self.C_bar_int = (
np.dot(self.Z, params_dict['W_c']['value']) \
+ params_dict['B_c']['value']
)
self.C_bar = tanh(self.C_bar_int)
self.C_out = self.f * C_in + self.i * self.C_bar
self.o_int = (
np.dot(self.Z, params_dict['W_o']['value']) \
+ params_dict['B_o']['value']
)
self.o = sigmoid(self.o_int)
self.H_out = self.o * tanh(self.C_out)
self.X_out = (
np.dot(self.H_out, params_dict['W_v']['value']) \
+ params_dict['B_v']['value']
)
return self.X_out, self.H_out, self.C_out
这是我们需要开始训练模型的 RNN 框架的最后一个元素!我们应该涵盖的另一个主题是:如何以一种形式表示文本数据,以便我们可以将其馈送到我们的 RNN 中。
用于基于字符级 RNN 的语言模型的数据表示
语言建模是 RNN 最常用的任务之一。我们如何将字符序列重塑为训练数据集,以便 RNN 可以训练以预测下一个字符?最简单的方法是使用one-hot 编码。具体操作如下:首先,每个字母都表示为一个维度等于词汇表的大小或文本总体语料库中字母数量的向量(这是在网络中预先计算并硬编码为一个超参数)。然后,每个字母都表示为一个向量,其中该字母所在位置为 1,其他位置为 0。最后,每个字母的向量简单地连接在一起,以获得字母序列的整体表示。
这是一个简单的示例,展示了具有四个字母a
、b
、c
和d
的词汇表,我们任意地将a
称为第一个字母,b
称为第二个字母,依此类推:
这个二维数组将代替一个形状为(sequence_length, num_features) = (5, 4)
的观察值在一个序列批次中。因此,如果我们的文本是“abcdba”—长度为 6—并且我们想要将长度为 5 的序列馈送到我们的数组中,第一个序列将被转换为前述矩阵,第二个序列将是:
然后将它们连接在一起,以创建一个形状为(batch_size, sequence_length, vocab_size) = (2, 5, 4)
的 RNN 输入。继续这样,我们可以将原始文本转换为一批序列,以馈送到 RNN 中。
在书的 GitHub 存储库中的第六章笔记本中,我们将其编码为RNNTrainer
类的一部分,该类可以接受原始文本,使用这里描述的技术对其进行预处理,并将其批量馈送到 RNN 中。
其他语言建模任务
我们在本章中没有强调这一点,但正如您从前面的代码中看到的,所有RNNNode
变体都允许RNNLayer
输出与其接收的特征数量不同的特征。所有三个节点的最后一步是将网络的最终隐藏状态乘以我们通过params_dict[*W_v*]
访问的权重矩阵;这个权重矩阵的第二维将决定Layer
的输出维度。这使我们可以通过在每个Layer
中更改一个output_size
参数来简单地为不同的语言建模任务使用相同的架构。
例如,到目前为止,我们只考虑通过“下一个字符预测”构建语言模型;在这种情况下,我们的输出大小将等于词汇表的大小:output_size = vocab_size
。然而,对于情感分析之类的任务,我们传入的序列可能只有一个标签“0”或“1”——积极或消极。在这种情况下,我们不仅将有output_size = 1
,而且只有在传入整个序列后才将输出与目标进行比较。这将看起来像图 6-11 中所示。
图 6-11。对于情感分析,RNN 将将其预测与实际值进行比较,并仅为最后一个序列元素的输出产生梯度;然后反向传播将继续进行,每个不是最后一个节点的节点将简单地接收一个全零的“X_grad_out”数组
因此,这个框架可以轻松适应不同的语言建模任务;实际上,它可以适应任何数据是顺序的并且可以逐个序列元素馈送到网络中的建模任务。
在结束之前,我们将讨论 RNN 中很少讨论的一个方面:这些不同类型的层——GRULayer
、LSTMLayer
和其他变体——可以混合和匹配。
组合 RNNLayer 变体
堆叠不同类型的RNNLayer
非常简单:每个 RNN 输出一个形状为(batch_size, sequence_length, output_size)
的ndarray
,可以被馈送到下一层。就像在Dense
层中一样,我们不需要指定input_shape
;我们只需根据层接收的第一个ndarray
设置权重,使其具有适当的形状。一个RNNModel
可以具有一个self.layers
属性:
[RNNLayer(hidden_size=256, output_size=128),
RNNLayer(hidden_size=256, output_size=62)]
与我们的全连接神经网络一样,我们只需要确保最后一层产生所需维度的输出;在这里,如果我们处理的词汇量为 62 并进行下一个字符预测,我们的最后一层必须具有 62 的output_size
,就像我们处理 MNIST 问题的全连接神经网络中的最后一层必须具有维度 10 一样。
在阅读本章后应该清楚的一点,但在处理 RNN 时通常不经常涉及的是,因为我们看到的每种类型的层都具有相同的基础结构,接收维度为feature_size
的序列并输出维度为output_size
的序列,我们可以轻松地堆叠不同类型的层。例如,在书籍网站上,我们训练一个具有self.layers
属性的RNNModel
:
[GRULayer(hidden_size=256, output_size=128),
LSTMLayer(hidden_size=256, output_size=62)]
换句话说,第一层通过使用GRUNode
将其输入向前传递一段时间,然后将形状为(batch_size, sequence_length, 128)
的ndarray
传递到下一层,随后通过其LSTMNode
将它们传递。
将所有内容整合在一起
一个经典的练习是训练 RNN 以特定风格写文本;在书的网站上,我们有一个端到端的代码示例,使用本章描述的抽象定义模型,学习以莎士比亚风格写文本。我们还没有展示的唯一组件是一个RNNTrainer
类,它通过训练数据进行迭代,对其进行预处理,并将其馈送到模型中。这与我们之前看到的Trainer
的主要区别是,对于 RNN,一旦我们选择要馈送的数据批次——每个批次元素仅为一个字符串——我们必须首先对其进行预处理,对每个字母进行独热编码,并将生成的向量连接成一个序列,将长度为sequence_length
的每个字符串转换为形状为(sequence_length, vocab_size)
的ndarray
。为了形成将馈送到我们的 RNN 中的批次,这些 ndarray
将被连接在一起,形成大小为(sequence_length, vocab_size, batch_size)
的批次。
但是一旦数据经过预处理并且模型定义好了,RNN 的训练方式与我们之前看到的其他神经网络相同:批次被迭代地馈送,模型的预测与目标进行比较以生成损失,损失通过构成模型的操作进行反向传播以更新权重。
结论
在本章中,您了解了递归神经网络,这是一种专门设计用于处理数据序列而不是单个操作的神经网络架构。您了解了 RNN 由在时间上向前传递数据的层组成,随着时间的推移更新它们的隐藏状态(以及在 LSTMs 的情况下更新它们的单元状态)。您看到了高级 RNN 变体 GRUs 和 LSTMs 的细节,以及它们如何通过每个时间步的一系列“门”向前传递数据;然而,您了解到这些高级变体基本上以相同的方式处理数据序列,因此它们的层结构相同,在每个时间步应用的特定操作不同。
希望这个多方面的主题现在不再是一个黑匣子。在第七章中,我将通过转向深度学习的实践方面来结束本书,展示如何使用 PyTorch 框架实现我们迄今所讨论的一切,PyTorch 是一个高性能、基于自动微分的框架,用于构建和训练深度学习模型。继续前进!
¹ 我们碰巧发现将观察结果排列在行上,将特征排列在列上很方便,但我们不一定要以这种方式排列数据。然而,数据必须是二维的。
² 或者至少是这本书的这个版本。
³ 我想提到作者 Daniel Sabinasz 在他的博客deep ideas上分享的另一种解决这个问题的方法:他将操作表示为一个图,然后使用广度优先搜索来计算反向传播中的梯度,以正确的顺序构建一个模仿 TensorFlow 的框架。他关于如何做到这一点的博客文章非常清晰和结构良好。
⁴ 深入了解如何实现自动微分,请参阅 Andrew Trask 的Grokking Deep Learning(Manning)。
⁵ 请查看 LSTMs 的维基百科页面,了解更多LSTM 变体的例子。
⁶ 参见 Hochreiter 等人的原始 LSTM 论文“长短期记忆” (1997)。
至少是标准变体的 LSTMs;正如提到的,还有其他变体,比如“带有窥视孔连接的 LSTMs”,其门的排列方式不同。
第七章:PyTorch
在第六章和第五章中,您学习了如何通过从头开始实现卷积和循环神经网络来使它们工作。然而,了解它们如何工作是必要的,但仅凭这些知识无法使它们在真实世界的问题上工作;为此,您需要能够在高性能库中实现它们。我们可以致力于构建一个高性能神经网络库的整本书,但那将是一本非常不同(或者只是更长)的书,面向一个非常不同的受众。相反,我们将把这最后一章献给介绍 PyTorch,这是一个越来越受欢迎的基于自动微分的神经网络框架,我们在第六章的开头介绍过。
与本书的其余部分一样,我们将以与神经网络工作方式相匹配的方式编写代码,编写Layer
、Trainer
等类。这样做的同时,我们不会按照常见的 PyTorch 实践编写代码,但我们将在本书的 GitHub 存储库中包含链接,让您了解更多关于如何表达神经网络的信息,以 PyTorch 设计的方式来表达。在我们开始之前,让我们从学习 PyTorch 的核心数据类型开始,这种数据类型使其具有自动微分的能力,从而使其能够清晰地表达神经网络训练:Tensor
。
PyTorch 张量
在上一章中,我们展示了一个简单的NumberWithGrad
通过跟踪对其执行的操作来累积梯度。这意味着如果我们写:
a = NumberWithGrad(3)
b = a * 4
c = b + 3
d = (a + 2)
e = c * d
e.backward()
然后a.grad
将等于35
,这实际上是e
相对于a
的偏导数。
PyTorch 的Tensor
类就像一个"ndarrayWithGrad
":它类似于NumberWithGrad
,只是使用数组(如numpy
)而不仅仅是float
和int
。让我们使用 PyTorch 的Tensor
重新编写前面的示例。首先,我们将手动初始化一个Tensor
:
a = torch.Tensor([[3., 3.,],
[3., 3.]], requires_grad=True)
这里注意几点:
-
我们可以通过简单地将其中包含的数据包装在
torch.Tensor
中来初始化一个Tensor
,就像我们用ndarray
做的那样。 -
当以这种方式初始化
Tensor
时,我们必须传入参数requires_grad=True
,以告诉Tensor
累积梯度。
一旦我们这样做了,我们就可以像以前一样执行计算:
b = a * 4
c = b + 3
d = (a + 2)
e = c * d
e_sum = e.sum()
e_sum.backward()
您可以看到与NumberWithGrad
示例相比,这里有一个额外的步骤:在调用其总和之前,我们必须对e
进行求和,然后调用backward
。这是因为,正如我们在第一章中所讨论的,想象“一个数字相对于一个数组的导数”是没有意义的:但是,我们可以推断出e_sum
相对于a
的每个元素的偏导数是什么,并且,我们看到答案与我们在之前的章节中发现的是一致的:
print(a.grad)
tensor([[35., 35.],
[35., 35.]], dtype=torch.float64)
PyTorch 的这个特性使我们能够通过定义前向传播、计算损失并在损失上调用.backward
来简单地定义模型,并自动计算每个参数
相对于该损失的导数。特别是,我们不必担心在前向传递中多次重复使用相同的数量(这是我们在前几章中使用的Operation
框架的限制);正如这个简单的例子所示,一旦我们在我们的计算输出上调用backward
,梯度将自动正确计算。
在接下来的几节中,我们将展示如何使用 PyTorch 的数据类型实现我们在本书中提出的训练框架。
使用 PyTorch 进行深度学习
正如我们所看到的,深度学习模型有几个元素共同工作以产生一个经过训练的模型:
-
一个包含
Layer
的Model
-
一个
Optimizer
-
一个
Loss
-
一个
Trainer
事实证明,使用 PyTorch,Optimizer
和Loss
都是一行代码,Model
和Layer
也很简单。让我们依次介绍这些元素。
PyTorch 元素:模型、层、优化器和损失
PyTorch 的一个关键特性是能够将模型和层定义为易于使用的对象,通过继承torch.nn.Module
类处理梯度向后传播和自动存储参数。稍后在本章中您将看到这些部分如何结合在一起;现在只需知道PyTorchLayer
可以这样编写:
from torch import nn, Tensor
class PyTorchLayer(nn.Module):
def __init__(self) -> None:
super().__init__()
def forward(self, x: Tensor,
inference: bool = False) -> Tensor:
raise NotImplementedError()
PyTorchModel
也可以这样编写:
class PyTorchModel(nn.Module):
def __init__(self) -> None:
super().__init__()
def forward(self, x: Tensor,
inference: bool = False) -> Tensor:
raise NotImplementedError()
换句话说,PyTorchLayer
或PyTorchModel
的每个子类只需要实现__init__
和forward
方法,这将使我们能够以直观的方式使用它们。
推断标志
正如我们在第四章中看到的,由于 dropout,我们需要根据我们是在训练模式还是推断模式下运行模型的能力来改变模型的行为。在 PyTorch 中,我们可以通过在模型或层(从nn.Module
继承的任何对象)上运行m.eval
将模型或层从训练模式(其默认行为)切换到推断模式。此外,PyTorch 有一种优雅的方式可以快速更改层的所有子类的行为,即使用apply
函数。如果我们定义:
def inference_mode(m: nn.Module):
m.eval()
然后我们可以包括:
if inference:
self.apply(inference_mode)
在我们定义的每个PyTorchModel
或PyTorchLayer
子类的forward
方法中,从而获得我们想要的标志。
让我们看看这是如何结合在一起的。
使用 PyTorch 实现神经网络构建块:DenseLayer
现在我们已经具备开始使用 PyTorch 操作实现之前看到的Layer
的所有先决条件,一个DenseLayer
层将被写成如下:
class DenseLayer(PyTorchLayer):
def __init__(self,
input_size: int,
neurons: int,
dropout: float = 1.0,
activation: nn.Module = None) -> None:
super().__init__()
self.linear = nn.Linear(input_size, neurons)
self.activation = activation
if dropout < 1.0:
self.dropout = nn.Dropout(1 - dropout)
def forward(self, x: Tensor,
inference: bool = False) -> Tensor:
if inference:
self.apply(inference_mode)
x = self.linear(x) # does weight multiplication + bias
if self.activation:
x = self.activation(x)
if hasattr(self, "dropout"):
x = self.dropout(x)
return x
在这里,通过nn.Linear
,我们看到了 PyTorch 操作的第一个示例,它会自动处理反向传播。这个对象不仅在前向传播时处理权重乘法和偏置项的加法,还会导致x
的梯度累积,以便在反向传播时计算出参数相对于损失的正确导数。还要注意,由于所有 PyTorch 操作都继承自nn.Module
,我们可以像数学函数一样调用它们:在前面的情况下,例如,我们写self.linear(x)
而不是self.linear.forward(x)
。当我们在即将到来的模型中使用DenseLayer
时,这也适用于DenseLayer
本身。
示例:PyTorch 中的波士顿房价模型
使用这个Layer
作为构建块,我们可以实现在第二章和第三章中看到的熟悉的房价模型。回想一下,这个模型只有一个带有sigmoid
激活函数的隐藏层;在第三章中,我们在面向对象的框架中实现了这一点,该框架具有Layer
的类和一个模型,其layers
属性是长度为 2 的列表。类似地,我们可以定义一个HousePricesModel
类,它继承自PyTorchModel
,如下所示:
class HousePricesModel(PyTorchModel):
def __init__(self,
hidden_size: int = 13,
hidden_dropout: float = 1.0):
super().__init__()
self.dense1 = DenseLayer(13, hidden_size,
activation=nn.Sigmoid(),
dropout = hidden_dropout)
self.dense2 = DenseLayer(hidden_size, 1)
def forward(self, x: Tensor) -> Tensor:
assert_dim(x, 2)
assert x.shape[1] == 13
x = self.dense1(x)
return self.dense2(x)
然后我们可以通过以下方式实例化:
pytorch_boston_model = HousePricesModel(hidden_size=13)
请注意,为 PyTorch 模型编写单独的Layer
类并不是传统的做法;更常见的做法是简单地根据正在发生的各个操作定义模型,使用类似以下的方式:
class HousePricesModel(PyTorchModel):
def __init__(self,
hidden_size: int = 13):
super().__init__()
self.fc1 = nn.Linear(13, hidden_size)
self.fc2 = nn.Linear(hidden_size, 1)
def forward(self, x: Tensor) -> Tensor:
assert_dim(x, 2)
assert x.shape[1] == 13
x = self.fc1(x)
x = torch.sigmoid(x)
return self.fc2(x)
在未来构建自己的 PyTorch 模型时,您可能希望以这种方式编写代码,而不是创建一个单独的Layer
类——当阅读他人的代码时,您几乎总是会看到类似于前面代码的东西。
Layer
和Model
比Optimizer
和Loss
更复杂,我们将在下一节中介绍。
PyTorch 元素:优化器和损失
Optimizer
和Loss
在 PyTorch 中实现为一行代码。例如,我们在第四章中介绍的SGDMomentum
损失可以写成:
import torch.optim as optim
optimizer = optim.SGD(pytorch_boston_model.parameters(), lr=0.001)
注意
在 PyTorch 中,模型作为参数传递给Optimizer
;这确保了优化器“指向”正确的模型参数,以便在每次迭代中知道要更新什么(我们之前使用Trainer
类做过这个操作)。
此外,我们在[第二章]中看到的均方误差损失和我们在[第四章]中讨论的SoftmaxCrossEntropyLoss
可以简单地写为:
mean_squared_error_loss = nn.MSELoss()
softmax_cross_entropy_loss = nn.CrossEntropyLoss()
与之前的Layer
一样,这些都继承自nn.Module
,因此可以像调用Layer
一样调用它们。
注意
请注意,即使nn.CrossEntropyLoss
类的名称中没有softmax一词,也确实对输入执行了 softmax 操作,因此我们可以传入神经网络的“原始输出”,而不是已经通过 softmax 函数的输出,就像我们以前做的那样。
这些Loss
也继承自nn.Module
,就像之前的Layer
一样,因此可以使用相同的方式调用,例如使用loss(x)
而不是loss.forward(x)
。
PyTorch 元素:Trainer
Trainer
将所有这些元素汇集在一起。让我们考虑Trainer
的要求。我们知道它必须实现我们在本书中多次看到的训练神经网络的一般模式:
-
通过模型传递一批输入。
-
将输出和目标输入到损失函数中以计算损失值。
-
计算损失相对于所有参数的梯度。
-
使用
Optimizer
根据某种规则更新参数。
使用 PyTorch,这一切都是一样的,只是有两个小的实现注意事项:
-
默认情况下,
Optimizer
将在每次参数更新迭代后保留参数的梯度(在本书中我们称之为param_grads
)。在下一次参数更新之前清除这些梯度,我们将调用self.optim.zero_grad
。 -
正如在简单的自动微分示例中所示,为了启动反向传播,我们在计算损失值后必须调用
loss.backward
。
这导致了在 PyTorch 训练循环中看到的以下代码序列,实际上将在PyTorchTrainer
类中使用。与之前章节中的Trainer
类一样,PyTorchTrainer
将接收一个Optimizer
,一个PyTorchModel
和一个Loss
(可以是nn.MSELoss
或nn.CrossEntropyLoss
)用于数据批次(X_batch, y_batch)
;将这些对象放置为self.optim
,self.model
和self.loss
,以下五行代码训练模型:
# First, zero the gradients
self.optim.zero_grad()
# feed X_batch through the model
output = self.model(X_batch)
# Compute the loss
loss = self.loss(output, y_batch)
# Call backward on the loss to kick off backpropagation
loss.backward()
# Call self.optim.step() (as before) to update the parameters
self.optim.step()
这些是最重要的行;但是,这是PyTorchTrainer
的其余代码,其中许多与我们在之前章节中看到的Trainer
的代码相似:
class PyTorchTrainer(object):
def __init__(self,
model: PyTorchModel,
optim: Optimizer,
criterion: _Loss):
self.model = model
self.optim = optim
self.loss = criterion
self._check_optim_net_aligned()
def _check_optim_net_aligned(self):
assert self.optim.param_groups[0]['params']\
== list(self.model.parameters())
def _generate_batches(self,
X: Tensor,
y: Tensor,
size: int = 32) -> Tuple[Tensor]:
N = X.shape[0]
for ii in range(0, N, size):
X_batch, y_batch = X[ii:ii+size], y[ii:ii+size]
yield X_batch, y_batch
def fit(self, X_train: Tensor, y_train: Tensor,
X_test: Tensor, y_test: Tensor,
epochs: int=100,
eval_every: int=10,
batch_size: int=32):
for e in range(epochs):
X_train, y_train = permute_data(X_train, y_train)
batch_generator = self._generate_batches(X_train, y_train,
batch_size)
for ii, (X_batch, y_batch) in enumerate(batch_generator):
self.optim.zero_grad()
output = self.model(X_batch)
loss = self.loss(output, y_batch)
loss.backward()
self.optim.step()
output = self.model(X_test)
loss = self.loss(output, y_test)
print(e, loss)
注意
由于我们将Model
,Optimizer
和Loss
传入Trainer
,我们需要检查Optimizer
引用的参数实际上是否与模型的参数相同;_check_optim_net_aligned
会执行此操作。
现在训练模型就像这样简单:
net = HousePricesModel()
optimizer = optim.SGD(net.parameters(), lr=0.001)
criterion = nn.MSELoss()
trainer = PyTorchTrainer(net, optimizer, criterion)
trainer.fit(X_train, y_train, X_test, y_test,
epochs=10,
eval_every=1)
这段代码几乎与我们在前三章中使用的训练模型的代码完全相同。无论您使用 PyTorch、TensorFlow 还是 Theano 作为底层,训练深度学习模型的要素都是相同的!
接下来,我们将通过展示如何实现我们在[第四章]中看到的改进训练的技巧来探索 PyTorch 的更多特性。
在 PyTorch 中优化学习的技巧
我们学到了四个加速学习的技巧[第四章]:
-
动量
-
Dropout
-
权重初始化
-
学习率衰减
这些在 PyTorch 中都很容易实现。例如,要在我们的优化器中包含动量,我们可以简单地在SGD
中包含一个momentum
关键字,使得优化器变为:
optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
Dropout 同样很容易。就像 PyTorch 有一个内置的Module
nn.Linear(n_in, n_out)
来计算之前的Dense
层的操作一样,Module
nn.Dropout(dropout_prob)
实现了Dropout
操作,但传入的概率默认情况下是丢弃给定神经元的概率,而不是像之前我们的实现中那样保留它。
我们根本不需要担心权重初始化:PyTorch 中大多数涉及参数的操作,包括nn.Linear
,其权重会根据层的大小自动缩放。
最后,PyTorch 有一个lr_scheduler
类,可以用来在各个 epoch 中衰减学习率。你需要开始的关键导入是from torch.optim import lr_scheduler
。现在你可以轻松地在任何未来的深度学习项目中使用我们从头开始介绍的这些技术!
PyTorch 中的卷积神经网络
在第五章中,我们系统地介绍了卷积神经网络的工作原理,特别关注多通道卷积操作。我们看到该操作将输入图像的像素转换为组织成特征图的神经元层,其中每个神经元表示图像中该位置是否存在给定的视觉特征(由卷积滤波器定义)。多通道卷积操作对其两个输入和输出具有以下形状:
-
数据输入形状
[batch_size, in_channels, image_height, image_width]
-
参数输入形状
[in_channels, out_channels, filter_size, filter_size]
-
输出形状
[batch_size, out_channels, image_height, image_width]
根据这种表示法,PyTorch 中的多通道卷积操作是:
nn.Conv2d(in_channels, out_channels, filter_size)
有了这个定义,将ConvLayer
包装在这个操作周围就很简单了:
class ConvLayer(PyTorchLayer):
def __init__(self,
in_channels: int,
out_channels: int,
filter_size: int,
activation: nn.Module = None,
flatten: bool = False,
dropout: float = 1.0) -> None:
super().__init__()
# the main operation of the layer
self.conv = nn.Conv2d(in_channels, out_channels, filter_size,
padding=filter_size // 2)
# the same "activation" and "flatten" operations from before
self.activation = activation
self.flatten = flatten
if dropout < 1.0:
self.dropout = nn.Dropout(1 - dropout)
def forward(self, x: Tensor) -> Tensor:
# always apply the convolution operation
x = self.conv(x)
# optionally apply the convolution operation
if self.activation:
x = self.activation(x)
if self.flatten:
x = x.view(x.shape[0], x.shape[1] * x.shape[2] * x.shape[3])
if hasattr(self, "dropout"):
x = self.dropout(x)
return x
注意
在第五章中,我们根据滤波器大小自动填充输出,以保持输出图像与输入图像相同大小。PyTorch 不会这样做;为了实现之前的相同行为,我们在nn.Conv2d
操作中添加一个参数padding = filter_size // 2
。
然后,我们只需在__init__
函数中定义一个PyTorchModel
及其操作,并在forward
函数中定义操作序列,即可开始训练。下面是一个简单的架构,我们可以在第四章和第五章中看到的 MNIST 数据集上使用:
-
一个将输入从 1 个“通道”转换为 16 个通道的卷积层
-
另一个层,将这 16 个通道转换为 8 个(每个通道仍然包含 28×28 个神经元)
-
两个全连接层
几个卷积层后跟少量全连接层的模式对于卷积架构是常见的;在这里,我们只使用了两个:
class MNIST_ConvNet(PyTorchModel):
def __init__(self):
super().__init__()
self.conv1 = ConvLayer(1, 16, 5, activation=nn.Tanh(),
dropout=0.8)
self.conv2 = ConvLayer(16, 8, 5, activation=nn.Tanh(), flatten=True,
dropout=0.8)
self.dense1 = DenseLayer(28 * 28 * 8, 32, activation=nn.Tanh(),
dropout=0.8)
self.dense2 = DenseLayer(32, 10)
def forward(self, x: Tensor) -> Tensor:
assert_dim(x, 4)
x = self.conv1(x)
x = self.conv2(x)
x = self.dense1(x)
x = self.dense2(x)
return x
然后我们可以像训练HousePricesModel
一样训练这个模型:
model = MNIST_ConvNet()
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
trainer = PyTorchTrainer(model, optimizer, criterion)
trainer.fit(X_train, y_train,
X_test, y_test,
epochs=5,
eval_every=1)
与nn.CrossEntropyLoss
类相关的一个重要注意事项。回想一下,在前几章的自定义框架中,我们的Loss
类期望输入与目标的形状相同。为了实现这一点,我们对 MNIST 数据中目标的 10 个不同值进行了独热编码,以便对于每批数据,目标的形状为[batch_size, 10]
。
使用 PyTorch 的nn.CrossEntropyLoss
类——其与我们之前的SoftmaxCrossEntropyLoss
完全相同——我们不需要这样做。这个损失函数期望两个Tensor
:
-
一个大小为
[batch_size, num_classes]
的预测Tensor
,就像我们的SoftmaxCrossEntropyLoss
类之前所做的那样 -
一个大小为
[batch_size]
的目标Tensor
,具有num_classes
个不同的值
因此,在前面的示例中,y_train
只是一个大小为[60000]
的数组(MNIST 训练集中的观测数量),而y_test
只是大小为[10000]
的数组(测试集中的观测数量)。
现在我们正在处理更大的数据集,我们应该涵盖另一个最佳实践。将整个训练和测试集加载到内存中训练模型显然非常低效,就像我们现在使用的X_train
、y_train
、X_test
和y_test
一样。PyTorch 有一种解决方法:DataLoader
类。
DataLoader 和 Transforms
回想一下,在第二章中的 MNIST 建模中,我们对 MNIST 数据应用了一个简单的预处理步骤,减去全局均值并除以全局标准差,以粗略“规范化”数据:
X_train, X_test = X_train - X_train.mean(), X_test - X_train.mean()
X_train, X_test = X_train / X_train.std(), X_test / X_train.std()
然而,这要求我们首先完全将这两个数组读入内存;在将批次馈送到神经网络时,执行此预处理将更加高效。PyTorch 具有内置函数来执行此操作,特别是在处理图像数据时经常使用——通过transforms
模块进行转换,以及通过torch.utils.data
进行DataLoader
:
from torchvision.datasets import MNIST
import torchvision.transforms as transforms
from torch.utils.data import DataLoader
之前,我们通过以下方式将整个训练集读入X_train
中:
mnist_trainset = MNIST(root="../data/", train=True)
X_train = mnist_trainset.train_data
然后,我们对X_train
执行转换,使其准备好进行建模。
PyTorch 有一些方便的函数,允许我们将许多转换组合到每个读入的数据批次中;这使我们既可以避免将整个数据集读入内存,又可以使用 PyTorch 的转换。
我们首先定义要对读入的每批数据执行的转换列表。例如,以下转换将每个 MNIST 图像转换为Tensor
(大多数 PyTorch 数据集默认为“PIL 图像”,因此transforms.ToTensor()
通常是列表中的第一个转换),然后使用整体 MNIST 均值和标准差0.1305
和0.3081
对数据集进行“规范化”——先减去均值,然后除以标准差:
img_transforms = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1305,), (0.3081,))
])
注意
Normalize
实际上是从输入图像的每个通道中减去均值和标准差。因此,当处理具有三个输入通道的彩色图像时,通常会有一个Normalize
转换,其中包含两个三个数字的元组,例如transforms.Normalize((0.1, 0.3, 0.6), (0.4, 0.2, 0.5))
,这将告诉DataLoader
:
-
使用均值为 0.1 和标准差为 0.4 来规范化第一个通道
-
使用均值为 0.3 和标准差为 0.2 来规范化第二个通道
-
使用均值为 0.6 和标准差为 0.5 来规范化第三个通道
其次,一旦应用了这些转换,我们将其应用于读入的批次的dataset
:
dataset = MNIST("../mnist_data/", transform=img_transforms)
最后,我们可以定义一个DataLoader
,它接收这个数据集并定义了连续生成数据批次的规则:
dataloader = DataLoader(dataset, batch_size=60, shuffle=True)
然后,我们可以修改Trainer
以使用dataloader
生成用于训练网络的批次,而不是将整个数据集加载到内存中,然后手动使用batch_generator
函数生成它们,就像我们之前做的那样。在书的网站上,我展示了使用这些DataLoader
训练卷积神经网络的示例。Trainer
中的主要变化只是改变了这一行:
for X_batch, y_batch in enumerate(batch_generator):
到:
for X_batch, y_batch in enumerate(train_dataloader):
此外,我们现在不再将整个训练集馈送到fit
函数中,而是馈送DataLoader
:
trainer.fit(train_dataloader = train_loader,
test_dataloader = test_loader,
epochs=1,
eval_every=1)
使用这种架构并调用fit
方法,就像我们刚刚做的那样,在一个 epoch 后我们可以达到大约 97%的 MNIST 准确率。然而,比准确率更重要的是,您已经看到了如何将我们从第一原则推理出的概念实现到高性能框架中。现在您既了解了基本概念又了解了框架,我鼓励您修改书的 GitHub 存储库中的代码,并尝试其他卷积架构、其他数据集等。
CNN 是我们在本书中之前介绍的两种高级架构之一;现在让我们转向另一种,并展示如何在 PyTorch 中实现我们介绍过的最先进的 RNN 变体之一,即 LSTMs。
PyTorch 中的 LSTMs
在上一章中,我们看到了如何从头开始编写 LSTMs。我们编写了一个LSTMLayer
,接受大小为[batch_size
, sequence_length
, feature_size
]的输入ndarray
,并输出大小为[batch_size
, sequence_length
, feature_size
]的ndarray
。此外,每个层接受一个隐藏状态和一个单元状态,每个状态初始化为形状[1, hidden_size]
,当传入一个批次时,扩展为形状[batch_size, hidden_size]
,然后在迭代完成后缩小回[1, hidden_size]
。
基于这一点,我们为我们的LSTMLayer
定义__init__
方法如下:
class LSTMLayer(PyTorchLayer):
def __init__(self,
sequence_length: int,
input_size: int,
hidden_size: int,
output_size: int) -> None:
super().__init__()
self.hidden_size = hidden_size
self.h_init = torch.zeros((1, hidden_size))
self.c_init = torch.zeros((1, hidden_size))
self.lstm = nn.LSTM(input_size, hidden_size, batch_first=True)
self.fc = DenseLayer(hidden_size, output_size)
与卷积层一样,PyTorch 有一个nn.lstm
操作用于实现 LSTMs。请注意,在我们自定义的LSTMLayer
中,我们将一个DenseLayer
存储在self.fc
属性中。您可能还记得上一章中,LSTM 单元的最后一步是将最终隐藏状态通过Dense
层的操作(权重乘法和偏置加法)转换为每个操作的维度output_size
。PyTorch 的做法有点不同:nn.lstm
操作只是简单地输出每个时间步的隐藏状态。因此,为了使我们的LSTMLayer
能够输出与其输入不同的维度 - 正如我们希望所有的神经网络层都能够做到的那样 - 我们在最后添加一个DenseLayer
来将隐藏状态转换为维度output_size
。
通过这种修改,forward
函数现在变得简单明了,看起来与第六章中的LSTMLayer
的forward
函数相似:
def forward(self, x: Tensor) -> Tensor:
batch_size = x.shape[0]
h_layer = self._transform_hidden_batch(self.h_init,
batch_size,
before_layer=True)
c_layer = self._transform_hidden_batch(self.c_init,
batch_size,
before_layer=True)
x, (h_out, c_out) = self.lstm(x, (h_layer, c_layer))
self.h_init, self.c_init = (
self._transform_hidden_batch(h_out,
batch_size,
before_layer=False).detach(),
self._transform_hidden_batch(c_out,
batch_size,
before_layer=False).detach()
)
x = self.fc(x)
return x
这里的关键一行,应该看起来很熟悉,因为我们在第六章中实现了 LSTMs:
x, (h_out, c_out) = self.lstm(x, (h_layer, c_layer))
除此之外,在self.lstm
函数之前和之后对隐藏状态和单元状态进行一些重塑,通过一个辅助函数self._transform_hidden_batch
。您可以在书的 GitHub 存储库中看到完整的函数。
最后,将模型包装起来很容易:
class NextCharacterModel(PyTorchModel):
def __init__(self,
vocab_size: int,
hidden_size: int = 256,
sequence_length: int = 25):
super().__init__()
self.vocab_size = vocab_size
self.sequence_length = sequence_length
# In this model, we have only one layer,
# with the same output size as input_size
self.lstm = LSTMLayer(self.sequence_length,
self.vocab_size,
hidden_size,
self.vocab_size)
def forward(self,
inputs: Tensor):
assert_dim(inputs, 3) # batch_size, sequence_length, vocab_size
out = self.lstm(inputs)
return out.permute(0, 2, 1)
注意
nn.CrossEntropyLoss
函数期望前两个维度是batch_size
和类别分布;然而,我们一直在实现 LSTMs 时,类别分布作为最后一个维度(vocab_size
)从LSTMLayer
中出来。因此,为了准备最终模型输出以输入损失,我们使用out.permute(0, 2, 1)
将包含字母分布的维度移动到第二维度。
最后,在书的 GitHub 存储库中,我展示了如何编写一个从PyTorchTrainer
继承的LSTMTrainer
类,并使用它来训练NextCharacterModel
生成文本。我们使用了与第六章中相同的文本预处理:选择文本序列,对字母进行独热编码,将独热编码的文本序列分组成批次。
这就是如何将本书中看到的三种用于监督学习的神经网络架构 - 全连接神经网络、卷积神经网络和循环神经网络 - 转换为 PyTorch。最后,我们将简要介绍神经网络如何用于机器学习的另一半:非监督学习。
附言:通过自动编码器进行无监督学习
在本书中,我们一直专注于深度学习模型如何用于解决监督学习问题。当然,机器学习还有另一面:无监督学习;这通常被描述为“在没有标签的数据中找到结构”; 我更喜欢将其看作是在数据中找到尚未被测量的特征之间的关系,而监督学习涉及在数据中找到已经被测量的特征之间的关系。
假设你有一个没有标签的图像数据集。你对这些图像了解不多——例如,你不确定是否有 10 个不同的数字,或者 5 个,或者 20 个(这些图像可能来自一个奇怪的字母表)——你想知道这样的问题的答案:
-
有多少个不同的数字?
-
哪些数字在视觉上相似?
-
是否有与其他图像明显不相似的“异常”图像?
要理解深度学习如何帮助解决这个问题,我们需要快速退后一步,从概念上思考深度学习模型试图做什么。
表示学习
我们已经看到深度学习模型可以学习进行准确的预测。它们通过将接收到的输入转换为逐渐更抽象且更直接用于解决相关问题的表示来实现这一点。特别是,在网络的最终层,直接在具有预测本身的层之前(对于回归问题只有一个神经元,对于分类问题有num_classes
个神经元),网络试图创建一个对于预测任务尽可能有用的输入数据表示。这在图 7-1 中显示。
图 7-1. 神经网络的最终层,在预测之前,表示网络对于预测任务发现的输入的表示
一旦训练完成,模型不仅可以为新数据点做出预测,还可以生成这些数据点的表示。这些表示可以用于聚类、相似性分析或异常检测,除了预测。
一种没有任何标签的情况下的方法
这种整体方法的一个限制是需要标签来训练模型首先生成表示。问题是:如何训练模型生成“有用”的表示而没有任何标签?如果没有标签,我们需要使用唯一拥有的东西——训练数据本身——来生成数据的表示。这就是一类被称为自动编码器的神经网络架构的理念,它涉及训练神经网络重建训练数据,迫使网络学习对于这种重建最有帮助的每个数据点的表示。
图表
图 7-2 展示了自动编码器的高级概述:
-
一组层将数据转换为数据的压缩表示。
-
另一组层将这个表示转换为与原始数据相同大小和形状的输出。
图 7-2. 自动编码器有一组层(可以被视为“编码器”网络),将输入映射到一个低维表示,另一组层(可以被视为“解码器”网络)将低维表示映射回输入;这种结构迫使网络学习一个对于重建输入最有用的低维表示
实现这样的架构展示了一些我们还没有机会介绍的 PyTorch 特性。
在 PyTorch 中实现自动编码器
我们现在将展示一个简单的自动编码器,它接收输入图像,通过两个卷积层然后一个Dense
层生成一个表示,然后将这个表示再通过一个Dense
层和两个卷积层传递回来,生成与输入相同大小的输出。我们将使用这个示例来说明在 PyTorch 中实现更高级架构时的两种常见做法。首先,我们可以将PyTorchModel
作为另一个PyTorchModel
的属性包含,就像我们之前将PyTorchLayer
定义为这些模型的属性一样。在以下示例中,我们将实现我们的自动编码器,将两个PyTorchModel
作为属性:一个Encoder
和一个Decoder
。一旦我们训练模型,我们将能够使用训练好的Encoder
作为自己的模型来生成表示。
我们将Encoder
定义为:
class Encoder(PyTorchModel):
def __init__(self,
hidden_dim: int = 28):
super(Encoder, self).__init__()
self.conv1 = ConvLayer(1, 14, activation=nn.Tanh())
self.conv2 = ConvLayer(14, 7, activation=nn.Tanh(), flatten=True)
self.dense1 = DenseLayer(7 * 28 * 28, hidden_dim, activation=nn.Tanh())
def forward(self, x: Tensor) -> Tensor:
assert_dim(x, 4)
x = self.conv1(x)
x = self.conv2(x)
x = self.dense1(x)
return x
我们将Decoder
定义为:
class Decoder(PyTorchModel):
def __init__(self,
hidden_dim: int = 28):
super(Decoder, self).__init__()
self.dense1 = DenseLayer(hidden_dim, 7 * 28 * 28, activation=nn.Tanh())
self.conv1 = ConvLayer(7, 14, activation=nn.Tanh())
self.conv2 = ConvLayer(14, 1, activation=nn.Tanh())
def forward(self, x: Tensor) -> Tensor:
assert_dim(x, 2)
x = self.dense1(x)
x = x.view(-1, 7, 28, 28)
x = self.conv1(x)
x = self.conv2(x)
return x
注意
如果我们使用的步幅大于 1,我们将无法简单地使用常规卷积将编码转换为输出,而是必须使用转置卷积,其中操作的输出图像大小将大于输入图像的大小。有关更多信息,请参阅PyTorch 文档中的nn.ConvTranspose2d
操作。
然后Autoencoder
本身可以包裹这些并变成:
class Autoencoder(PyTorchModel):
def __init__(self,
hidden_dim: int = 28):
super(Autoencoder, self).__init__()
self.encoder = Encoder(hidden_dim)
self.decoder = Decoder(hidden_dim)
def forward(self, x: Tensor) -> Tensor:
assert_dim(x, 4)
encoding = self.encoder(x)
x = self.decoder(encoding)
return x, encoding
Autoencoder
的forward
方法展示了 PyTorch 中的第二种常见做法:由于我们最终想要看到模型生成的隐藏表示,forward
方法返回两个元素:这个“编码”,encoding
,以及将用于训练网络的输出,x
。
当然,我们需要修改我们的Trainer
类以适应这一点;具体来说,PyTorchModel
目前只从其forward
方法中输出单个Tensor
。事实证明,将其修改为默认返回Tensor
的Tuple
,即使该Tuple
只有长度为 1,将会非常有用——使我们能够轻松编写像Autoencoder
这样的模型,并且不难。我们只需要做三件小事:首先,将我们的基本PyTorchModel
类的forward
方法的函数签名修改为:
def forward(self, x: Tensor) -> Tuple[Tensor]:
然后,在任何继承自PyTorchModel
基类的模型的forward
方法末尾,我们将写return x,
而不是之前的return x
。
其次,我们将修改我们的Trainer
,始终将模型返回的第一个元素作为输出:
output = self.model(X_batch)[0]
...
output = self.model(X_test)[0]
Autoencoder
模型的另一个显著特点是:我们对最后一层应用了Tanh
激活函数,这意味着模型输出将在-1 和 1 之间。对于任何模型,模型输出应该与其进行比较的目标在相同的尺度上,这里,目标是我们的输入本身。因此,我们应该将我们的输入缩放到-1 的最小值和 1 的最大值,如下面的代码所示:
X_train_auto = (X_train - X_train.min())
/ (X_train.max() - X_train.min()) * 2 - 1
X_test_auto = (X_test - X_train.min())
/ (X_train.max() - X_train.min()) * 2 - 1
最后,我们可以使用训练代码训练我们的模型,到目前为止应该看起来很熟悉(我们将 28 任意地用作编码输出的维度):
model = Autoencoder(hidden_dim=28)
criterion = nn.MSELoss()
optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
trainer = PyTorchTrainer(model, optimizer, criterion)
trainer.fit(X_train_auto, X_train_auto,
X_test_auto, X_test_auto,
epochs=1,
batch_size=60)
一旦我们运行这段代码并训练模型,我们可以通过将X_test_auto
通过模型(因为forward
方法被定义为返回两个量)来查看重建图像和图像表示:
reconstructed_images, image_representations = model(X_test_auto)
reconstructed_images
的每个元素是一个[1, 28, 28]
的Tensor
,表示神经网络尝试重建对应原始图像后的最佳结果,通过将其通过一个具有较低维度的层的自动编码器架构。图 7-3 显示了随机选择的重建图像与原始图像并排。
图 7-3。来自 MNIST 测试集的图像以及通过自动编码器传递后的图像的重建
从视觉上看,这些图像看起来相似,告诉我们神经网络确实似乎已经将原始图像(784 像素)映射到了一个较低维度的空间——具体来说是 28——这样大部分关于 784 像素图像的信息都被编码在这个长度为 28 的向量中。我们如何检查整个数据集,以查看神经网络是否确实学习了图像数据的结构而没有看到标签呢?嗯,“数据的结构”在这里意味着底层数据实际上是 10 个不同手写数字的图像。因此,在新的 28 维空间中,接近给定图像的图像理想情况下应该是相同的数字,或者至少在视觉上非常相似,因为视觉相似性是我们作为人类区分不同图像的方式。我们可以通过应用 Laurens van der Maaten 在 Geoffrey Hinton 的指导下作为研究生时发明的降维技术t-分布随机邻居嵌入(t-SNE)来测试是否符合这种情况。t-SNE 以类似于神经网络训练的方式进行降维:它从一个初始的低维表示开始,然后更新它,以便随着时间的推移,它接近具有这样的属性的解决方案,即在高维空间中“靠在一起”的点在低维空间中也是“靠在一起”,反之亦然。
我们将尝试以下操作:
-
将这 10,000 个图像通过 t-SNE 并将维度降低到 2。
-
将生成的两维空间可视化,通过它们的实际标签对不同的点进行着色(自动编码器没有看到)。
图 7-4 显示了结果。
图 7-4。在自动编码器的 28 维学习空间上运行 t-SNE 的结果
似乎每个数字的图像主要被分组在自己的独立簇中;这表明训练我们的自动编码器架构学习仅从较低维度表示中重建原始图像确实使其能够发现这些图像的底层结构的大部分,而不需要看到任何标签。不仅 10 个数字被表示为不同的簇,而且视觉上相似的数字也更接近:在顶部稍微向右,我们有数字 3、5 和 8 的簇,底部我们看到 4 和 9 紧密聚集在一起,7 也不远。最后,最不同的数字—0、1 和 6—形成最不同的簇。
无监督学习的更强测试,以及解决方案
我们刚刚看到的是一个相当弱的测试,用于检查我们的模型是否已经学习了输入图像空间的底层结构——到这一点,一个卷积神经网络可以学习图像的表示,使得视觉上相似的图像具有相似的表示,这应该不会太令人惊讶。一个更强的测试是检查神经网络是否发现了一个“平滑”的底层空间:一个空间,其中任何长度为 28 的向量,而不仅仅是通过编码器网络传递真实数字得到的向量,都可以映射到一个看起来像真实数字的图像。事实证明,我们的自动编码器无法做到这一点;图 7-5 显示了生成五个长度为 28 的随机向量并通过解码器网络传递它们的结果,利用了Autoencoder
包含Decoder
作为属性的事实:
test_encodings = np.random.uniform(low=-1.0, high=1.0, size=(5, 28))
test_imgs = model.decoder(Tensor(test_encodings))
图 7-5。通过解码器传递五个随机生成的向量的结果
您可以看到生成的图像看起来不像数字;因此,虽然我们的自动编码器可以以合理的方式将数据映射到较低维度空间,但似乎无法学习一个像前面描述的“平滑”空间。
解决问题,即训练神经网络学习在训练集中表示图像的“平滑”基础空间,是生成对抗网络(GANs)的主要成就之一。GANs 于 2014 年发明,最广为人知的是通过同时训练两个神经网络的训练过程,使神经网络能够生成看起来逼真的图像。然而,真正推动 GANs 的发展是在 2015 年,当研究人员将它们与深度卷积架构一起使用时,不仅生成了看起来逼真的 64×64 彩色卧室图像,还从随机生成的 100 维向量中生成了大量这样的图像样本。这表明神经网络确实已经学会了这些未标记图像的“空间”的基本表示。GANs 值得有一本专门的书来介绍,所以我们不会详细介绍它们。
结论
现在,您对一些最流行的先进深度学习架构的机制有了深入的了解,以及如何在最流行的高性能深度学习框架之一中实现这些架构。阻止您使用深度学习模型解决实际问题的唯一障碍是实践。幸运的是,阅读他人的代码并迅速掌握使某些模型架构在某些问题上起作用的细节和实现技巧从未如此简单。推荐的下一步列表在书的 GitHub 存储库中列出(https://oreil.ly/2N4H8jz)。
继续前进!
以这种方式编写Layer
和Model
在 PyTorch 中并不是最常见或推荐的用法;我们在这里展示它,因为它最接近我们迄今为止涵盖的概念。要查看使用 PyTorch 构建神经网络构建块的更常见方法,请参阅官方文档中的这个入门教程(https://oreil.ly/SKB_V)。
在书的 GitHub 存储库中,您可以找到一个实现指数学习率衰减的代码示例,作为PyTorchTrainer
的一部分。在那里使用的ExponentialLR
类的文档可以在 PyTorch 网站上找到(https://oreil.ly/2Mj9IhH)。
查看“使用 PyTorch 的 CNNs”部分。
2008 年的原始论文是“使用 t-SNE 可视化数据”(https://oreil.ly/2KIAaOt),由 Laurens van der Maaten 和 Geoffrey Hinton 撰写。
此外,我们做到这一点并没有费多少力气:这里的架构非常简单,我们没有使用我们讨论过的训练神经网络的任何技巧,比如学习率衰减,因为我们只训练了一个周期。这说明使用类似自动编码器的架构来学习数据集的结构而不使用标签的基本想法是一个好主意,而不仅仅是在这里“碰巧起作用”。
查看 DCGAN 论文,“使用深度卷积生成对抗网络进行无监督表示学习”(https://arxiv.org/abs/1511.06434)由 Alec Radford 等人撰写,以及这个 PyTorch 文档(https://oreil.ly/2TEspgG)。
附录 A. 深入探讨
在这一部分,我们深入探讨了一些重要但不是必要理解的技术领域。
矩阵链式法则
首先解释一下为什么我们可以在第一章的链式法则表达式中用 W^T 替换 。
记住 L 实际上是:
这是一个简写,意味着:
等等。让我们仅关注其中一个表达式。如果我们对 求关于 的每个元素的偏导数(这最终是我们将要对所有六个组件的 做的事情)会是什么样子?
嗯,因为:
不难看出,通过链式法则的简单应用,这个表达式对于 的偏导数是:
由于 XW[11] 表达式中 x[11] 乘以的唯一因素是 w[11],因此对其他所有元素的偏导数为 0。
因此,计算 σ(XW[11]) 对于 X 的所有元素的偏导数给出了以下关于 的整体表达式:
同样,我们可以计算 XW*[32] 的偏导数对于 X 的每个元素:
现在我们有了所有组件,可以直接计算 。我们只需计算与前述矩阵相同形式的六个矩阵并将结果相加。
请注意,数学再次变得混乱,尽管不是高级的。您可以跳过以下计算,直接转到结论,这实际上是一个简单的表达式。但是通过计算将使您更加欣赏结论是多么令人惊讶简单。生活还有什么不是用来欣赏的呢?
这里只有两个步骤。首先,我们将明确写出 是刚刚描述的六个矩阵的总和:
现在让我们将这个总和合并成一个大矩阵。这个矩阵不会立即呈现出任何直观的形式,但实际上是计算前述总和的结果:
现在来了有趣的部分。回想一下:
嗯,W 隐藏在前述矩阵中——它只是被转置了。回想一下:
结果表明,前述矩阵等同于:
此外,请记住,我们正在寻找填写以下方程中问号的内容:
嗯,事实证明那个东西就是W。这个结果就像肉从骨头上掉下来一样。
还要注意,这与我们之前在一维中看到的结果相同;同样,这将成为一个解释为什么深度学习有效并且允许我们干净地实现它的结果。这是否意味着我们可以实际替换前面方程中的问号并说?不,不完全是。但是如果我们将两个输入(X和W)相乘以得到结果N,并将这些输入通过一些非线性函数σ以得到输出S,那么我们可以说以下内容:
这个数学事实使我们能够使用矩阵乘法的符号高效计算和表达梯度更新。此外,我们可以类似地推理出以下内容:
相对于偏差项的损失梯度
接下来,我们将详细介绍在完全连接的神经网络中计算损失相对于偏差项的导数时,为什么要沿着axis=0
求和。
在神经网络中添加偏差项发生在以下情境中:我们有一个由n行(批量大小)乘以f列(特征数量)的矩阵表示的数据批次,并且我们向每个f特征中添加一个单个数字。例如,在[第二章中的神经网络示例中,我们有 13 个特征,偏差项B有 13 个数字;第一个数字将添加到M1 = np.dot(X, weights[*W1*])
的第一列中的每一行,第二个数字将添加到第二列中的每一行,依此类推。在网络的后续部分,将包含一个数字,它将简单地添加到M2
的单列中的每一行。因此,由于相同的数字将被添加到矩阵的每一行,所以在反向传播时,我们需要沿着表示每个偏差元素添加到的行的维度对梯度求和。这就是为什么我们沿着axis=0
对dLdB1
和dLdB2
的表达式求和;例如,dLdB1 = (dLdN1 × dN1dB1).sum(axis=0)
。图 A-1 提供了所有这些的视觉解释,并附有一些评论。
图 A-1。总结了为什么计算完全连接层的输出相对于偏差的导数涉及沿着轴=0 求和
通过矩阵乘法进行卷积
最后,我们将展示如何通过批量矩阵乘法来表达批量、多通道卷积操作,以便在 NumPy 中高效实现它。
要理解卷积是如何工作的,请考虑在完全连接神经网络的前向传播中发生的情况:
-
我们收到一个大小为
[batch_size, in_features]
的输入。 -
我们将其乘以一个大小为
[in_features, out_features]
的参数。 -
我们得到一个大小为
[batch_size, out_features]
的结果输出。
在卷积层中,相比之下:
-
我们收到一个大小为
[batch_size, in_channels, img_height, img_width]
的输入。 -
我们将其与一个大小为
[in_channels, out_channels, param_height, param_width]
的参数进行卷积。 -
我们得到一个大小为
[batch_size, in_channels, img_height, img_width]
的结果输出。
使卷积操作看起来更像常规前馈操作的关键是首先从输入图像的每个通道中提取img_height × img_width
“图像补丁”。一旦提取了这些补丁,输入就可以被重新整形,以便卷积操作可以通过 NumPy 的np.matmul
函数表达为批量矩阵乘法。首先:
def _get_image_patches(imgs_batch: ndarray,
fil_size: int):
'''
imgs_batch: [batch_size, channels, img_width, img_height]
fil_size: int
'''
# pad the images
imgs_batch_pad = np.stack([_pad_2d_channel(obs, fil_size // 2)
for obs in imgs_batch])
patches = []
img_height = imgs_batch_pad.shape[2]
# For each location in the image...
for h in range(img_height-fil_size+1):
for w in range(img_height-fil_size+1):
# ...get an image patch of size [fil_size, fil_size]
patch = imgs_batch_pad[:, :, h:h+fil_size, w:w+fil_size]
patches.append(patch)
# Stack, getting an output of size
# [img_height * img_width, batch_size, n_channels, fil_size, fil_size]
return np.stack(patches)
然后我们可以按照以下方式计算卷积操作的输出:
-
获取大小为
[batch_size, in_channels, img_height x img_width, filter_size, filter_size]
的图像块。 -
将其重塑为
[batch_size, img_height × img_width, in_channels × filter_size× filter_size]
。 -
将参数重塑为
[in_channels × filter_size × filter_size, out_channels]
。 -
进行批量矩阵乘法后,结果将是
[batch_size, img_height × img_width, out_channels]
。 -
将其重塑为
[batch_size, out_channels, img_height, img_width]
。
def _output_matmul(input_: ndarray,
param: ndarray) -> ndarray:
'''
conv_in: [batch_size, in_channels, img_width, img_height]
param: [in_channels, out_channels, fil_width, fil_height]
'''
param_size = param.shape[2]
batch_size = input_.shape[0]
img_height = input_.shape[2]
patch_size = param.shape[0] * param.shape[2] * param.shape[3]
patches = _get_image_patches(input_, param_size)
patches_reshaped = (
patches
.transpose(1, 0, 2, 3, 4)
.reshape(batch_size, img_height * img_height, -1)
)
param_reshaped = param.transpose(0, 2, 3, 1).reshape(patch_size, -1)
output = np.matmul(patches_reshaped, param_reshaped)
output_reshaped = (
output
.reshape(batch_size, img_height, img_height, -1)
.transpose(0, 3, 1, 2)
)
return output_reshaped
这就是前向传播!对于反向传播,我们需要计算参数梯度和输入梯度。同样,我们可以借鉴全连接神经网络中的做法。首先是参数梯度,全连接神经网络的梯度是:
np.matmul(self.inputs.transpose(1, 0), output_grad)
这应该激励我们如何通过卷积操作实现反向传播:这里,输入形状是[batch_size, in_channels, img_height, img_width]
,接收到的输出梯度将是[batch_size, out_channels, img_height, img_width]
。考虑到参数的形状是[in_channels, out_channels, param_height, param_width]
,我们可以通过以下步骤实现这种转换:
-
首先,我们需要从输入图像中提取图像块,得到与上次相同的输出,形状为
[batch_size, in_channels, img_height x img_width, filter_size, filter_size]
。 -
然后,借鉴全连接情况下的乘法,将其重塑为形状为
[in_channels × param_height × param_width, batch_size × img_height × img_width]
。 -
然后,将原始形状为
[batch_size, out_channels, img_height, img_width]
的输出重塑为形状为[batch_size × img_height × img_width, out_channels]
。 -
将它们相乘,得到形状为
[in_channels × param_height × param_width, out_channels]
的输出。 -
将其重塑为最终的参数梯度,形状为
[in_channels, out_channels, param_height, param_width]
。
这个过程的实现如下:
def _param_grad_matmul(input_: ndarray,
param: ndarray,
output_grad: ndarray):
'''
input_: [batch_size, in_channels, img_width, img_height]
param: [in_channels, out_channels, fil_width, fil_height]
output_grad: [batch_size, out_channels, img_width, img_height]
'''
param_size = param.shape[2]
batch_size = input_.shape[0]
img_size = input_.shape[2] ** 2
in_channels = input_.shape[1]
out_channels = output_grad.shape[1]
patch_size = param.shape[0] * param.shape[2] * param.shape[3]
patches = _get_image_patches(input_, param_sizes)
patches_reshaped = (
patches
.reshape(batch_size * img_size, -1)
)
output_grad_reshaped = (
output_grad
.transpose(0, 2, 3, 1)
.reshape(batch_size * img_size, -1)
)
param_reshaped = param.transpose(0, 2, 3, 1).reshape(patch_size, -1)
param_grad = np.matmul(patches_reshaped.transpose(1, 0),
output_grad_reshaped)
param_grad_reshaped = (
param_grad
.reshape(in_channels, param_size, param_size, out_channels)
.transpose(0, 3, 1, 2)
)
return param_grad_reshaped
此外,我们遵循一个非常类似的步骤来获取输入梯度,受到全连接层中操作的启发,即:
np.matmul(output_grad, self.param.transpose(1, 0))
以下代码计算输入梯度:
def _input_grad_matmul(input_: ndarray,
param: ndarray,
output_grad: ndarray):
param_size = param.shape[2]
batch_size = input_.shape[0]
img_height = input_.shape[2]
in_channels = input_.shape[1]
output_grad_patches = _get_image_patches(output_grad, param_size)
output_grad_patches_reshaped = (
output_grad_patches
.transpose(1, 0, 2, 3, 4)
.reshape(batch_size * img_height * img_height, -1)
)
param_reshaped = (
param
.reshape(in_channels, -1)
)
input_grad = np.matmul(output_grad_patches_reshaped,
param_reshaped.transpose(1, 0))
input_grad_reshaped = (
input_grad
.reshape(batch_size, img_height, img_height, 3)
.transpose(0, 3, 1, 2)
)
return input_grad_reshaped
这三个函数构成了Conv2DOperation
的核心,具体是它的_output
、_param_grad
和_input_grad
方法,你可以在书的 GitHub 仓库中的lincoln
库中看到。