) sin ( α ) cos ( α ) · 1 0 = cos ( α ) sin ( α )
这种转换也可以以图形方式可视化。图 2-3 展示了最终向量如何对应于原始单位向量的旋转。
![unit_circle.png]()
图 2-3。单位圆上的位置由余弦和正弦参数化。
机器学习程序经常使用矩阵的一些标准数学运算。我们将简要回顾其中一些最基本的运算。
在平面上旋转向量的操作可以通过这个矩阵来执行。
矩阵的转置是一个方便的操作,它将矩阵沿对角线翻转。数学上,假设A是一个矩阵;那么转置矩阵由方程定义。例如,旋转矩阵的转置是
矩阵的加法仅对形状相同的矩阵定义,并且仅是逐元素执行。例如:
同样,矩阵可以乘以标量。在这种情况下,矩阵的每个元素都简单地逐元素乘以相关的标量:
此外,有时可以直接相乘两个矩阵。矩阵乘法的概念可能是与矩阵相关的最重要的数学概念。特别注意,矩阵乘法不同于矩阵的逐元素乘法!假设我们有一个形状为(m,n)的矩阵A,其中m行n列。那么,A可以右乘任何形状为(n,k)的矩阵B(其中k是任意正整数),形成形状为(m,k)的矩阵AB。对于实际的数学描述,假设A是形状为(m,n)的矩阵,B是形状为(n,k)的矩阵。那么AB由以下定义:
我们之前简要展示了一个矩阵乘法方程。现在让我们扩展这个例子,因为我们有了正式的定义:
基本要点是一个矩阵的行与另一个矩阵的列相乘。
这个定义隐藏了许多微妙之处。首先注意矩阵乘法不是交换的。也就是说, 通常不成立。实际上,当 BA 没有意义时,AB 可能存在。例如,假设 A 是一个形状为 (2, 3) 的矩阵,B 是一个形状为 (3, 4) 的矩阵。那么 AB 是一个形状为 (2, 4) 的矩阵。然而,BA 没有定义,因为各自的维度(4 和 2)不匹配。另一个微妙之处是,正如旋转示例中所示,一个形状为 (m, n) 的矩阵可以右乘一个形状为 (n, 1) 的矩阵。然而,一个形状为 (n, 1) 的矩阵简单地是一个列向量。因此,将矩阵乘以向量是有意义的。矩阵-向量乘法是常见机器学习系统的基本构建块之一。
标准乘法的一个最好的特性是它是一个线性操作。更准确地说,如果一个函数 f 被称为线性,那么 和 其中 c 是一个标量。为了证明标量乘法是线性的,假设 a, b, c, d 都是实数。那么我们有
我们在这里利用了标量乘法的交换和分配性质。现在假设相反,A, C, D 现在是矩阵,其中 C, D 的大小相同,并且将 A 右乘以 C 或 D 是有意义的(b 仍然是一个实数)。那么矩阵乘法是一个线性操作符:
换句话说,矩阵乘法是可分配的,并且与标量乘法交换。事实上,可以证明向量上的任何线性变换对应于矩阵乘法。对于计算机科学的类比,将线性性视为超类中要求的属性。然后标准乘法和矩阵乘法是该抽象方法的具体实现,分别适用于不同子类(实数和矩阵)。
张量
在前面的部分中,我们介绍了标量作为秩为 0 的张量,向量作为秩为 1 的张量,矩阵作为秩为 2 的张量。那么什么是秩为 3 的张量呢?在转向一般定义之前,思考一下标量、向量和矩阵之间的共同点可能有所帮助。标量是单个数字。向量是数字列表。要选择向量的任何特定元素,需要知道它的索引。因此,我们需要一个索引元素进入向量(因此是一个秩为 1 的张量)。矩阵是数字表。要选择矩阵的任何特定元素,需要知道它的行和列。因此,我们需要两个索引元素(因此是一个秩为 2 的张量)。很自然地,秩为 3 的张量是一组数字,其中有三个必需的索引。可以将秩为 3 的张量想象为数字的长方体,如图 2-4 所示。
![images/3tensor.gif]()
图 2-4。秩为 3 的张量可以被视为数字的长方体。
图中显示的秩为 3 的张量T的形状为(N, N, N)。那么张量的任意元素将通过指定(i, j, k)作为索引来选择。
张量和形状之间存在联系。秩为 1 的张量具有 1 维形状,秩为 2 的张量具有 2 维形状,秩为 3 的张量具有 3 维形状。你可能会质疑这与我们之前讨论的行向量和列向量相矛盾。根据我们的定义,列向量的形状是(n, 1)。这难道不会使列向量成为一个秩为 2 的张量(或矩阵)吗?这确实发生了。回想一下,未指定为行向量或列向量的向量的形状是(n)。当我们指定一个向量是行向量还是列向量时,实际上我们指定了一种将基础向量转换为矩阵的方法。这种维度扩展是张量操作中的常见技巧。
注意,另一种思考秩为 3 的张量的方式是将其视为具有相同形状的矩阵列表。假设W是一个形状为(n, n)的矩阵。那么张量 包含了W的n个副本。
请注意,黑白图像可以表示为二阶张量。假设我们有一个 224×224 像素的黑白图像。那么,像素(i,j)是 1/0 来编码黑/白像素。因此,黑白图像可以表示为形状为(224, 224)的矩阵。现在,考虑一个 224×224 的彩色图像。一个特定像素的颜色通常由三个单独的 RGB 通道表示。也就是说,像素(i,j)被表示为一个包含三个数字(r,g,b)的元组,分别编码像素中的红色、绿色和蓝色的量。r,g,b通常是从 0 到 255 的整数。因此,彩色图像可以被编码为形状为(224, 224, 3)的三阶张量。继续类比,考虑一个彩色视频。假设视频的每一帧是一个 224×224 的彩色图像。那么一分钟的视频(以 60 帧每秒的速度)将是一个形状为(224, 224, 3, 3600)的四阶张量。进一步地,10 个这样的视频集合将形成一个形状为(10, 224, 224, 3, 3600)的五阶张量。总的来说,张量提供了对数值数据的便捷表示。在实践中,看到高于五阶张量的张量并不常见,但设计任何张量软件以允许任意张量是明智的,因为聪明的用户总会提出设计者没有考虑到的用例。
物理学中的张量
张量在物理学中被广泛用于编码基本物理量。例如,应力张量通常用于材料科学中定义材料内某点的应力。从数学上讲,应力张量是一个形状为(3, 3)的二阶张量:
然后,假设n是一个形状为(3)的向量,编码一个方向。在方向n上的应力由向量(注意矩阵-向量乘法)指定。这种关系在图 2-5 中以图形方式描述。
![images/stress_energy_small.png]()
图 2-5。应力分量的三维图示。
作为另一个物理例子,爱因斯坦的广义相对论场方程通常以张量格式表达:
这里是里奇曲率张量,是度规张量,是应力能量张量,其余量是标量。然而,需要注意的是,这些张量和我们之前讨论过的其他张量之间有一个重要的微妙区别。像度规张量这样的量为时空中的每个点提供一个单独的张量(在数字数组的意义上,数学上,度规张量是一个张量场)。之前讨论过的应力张量也是如此,这些方程中的其他张量也是如此。在时空中的特定点,这些量中的每一个都变成了一个对称的秩 2 张量,使用我们的符号形状为(4,4)。
现代张量微积分系统(如 TensorFlow)的一部分力量在于,一些长期用于经典物理学的数学机器现在可以被改编用于解决图像处理和语言理解等应用问题。与此同时,今天的张量微积分系统与物理学家的数学机器相比仍然有限。例如,目前还没有简单的方法来使用 TensorFlow 谈论度规张量这样的量。我们希望随着张量微积分对计算机科学变得更加基础,情况会发生变化,像 TensorFlow 这样的系统将成为物理世界和计算世界之间的桥梁。
数学细节
到目前为止,在本章中的讨论通过示例和插图非正式地介绍了张量。在我们的定义中,张量简单地是一组数字的数组。通常方便将张量视为一个函数。最常见的定义将张量引入为从向量空间的乘积到实数的多线性函数:
这个定义使用了一些你没有见过的术语。一个向量空间简单地是向量的集合。你已经见过一些向量空间的例子,比如或者一般的。我们可以假设而不会失去一般性。正如我们之前定义的,一个函数f是线性的,如果和。一个多线性函数简单地是一个在每个参数上都是线性的函数。当提供数组索引作为参数时,这个函数可以被看作是给定多维数组的单个条目。
在本书中我们不会经常使用这个更数学化的定义,但它作为一个有用的桥梁,将你将要学习的深度学习概念与物理学和数学界对张量进行的几个世纪的研究联系起来。
协变和逆变
我们在这里的定义中忽略了许多需要仔细处理的细节,以进行正式处理。例如,我们在这里没有涉及共变和逆变指数的概念。我们所谓的秩-n张量最好描述为一个(p,q)-张量,其中n = p + q,p是逆变指数的数量,q是共变指数的数量。例如,矩阵是(1,1)-张量。作为一个微妙之处,有些秩为 2 的张量不是矩阵!我们不会在这里仔细探讨这些主题,因为它们在机器学习中并不经常出现,但我们鼓励您了解协变性和逆变性如何影响您构建的机器学习系统。
TensorFlow 中的基本计算
在过去的几节中,我们已经涵盖了各种张量的数学定义。现在是时候使用 TensorFlow 创建和操作张量了。对于本节,我们建议您使用交互式 Python 会话(使用 IPython)跟随进行。许多基本的 TensorFlow 概念在直接实验后最容易理解。
安装 TensorFlow 并入门
在继续本节之前,您需要在您的计算机上安装 TensorFlow。安装的详细信息将取决于您的特定硬件,因此我们建议您查阅官方 TensorFlow 文档以获取更多详细信息。
尽管 TensorFlow 有多种编程语言的前端,但我们将在本书的其余部分中专门使用 TensorFlow Python API。我们建议您安装Anaconda Python,它打包了许多有用的数值库以及基本的 Python 可执行文件。
一旦您安装了 TensorFlow,我们建议您在学习基本 API 时交互地调用它(请参见示例 2-1)。在与 TensorFlow 交互时进行实验时,使用tf.InteractiveSession()
会很方便。在 IPython(一个交互式 Python shell)中调用此语句将使 TensorFlow 几乎以命令方式运行,使初学者更容易地玩弄张量。稍后在本章中,您将更深入地了解命令式与声明式风格的区别。
示例 2-1。初始化一个交互式 TensorFlow 会话
>>> import tensorflow as tf
>>> tf.InteractiveSession()
<tensorflow.python.client.session.InteractiveSession>
本节中的其余代码将假定已加载了一个交互式会话。
初始化常量张量
到目前为止,我们已经将张量讨论为抽象的数学实体。然而,像 TensorFlow 这样的系统必须在真实计算机上运行,因此任何张量必须存在于计算机内存中,以便对计算机程序员有用。TensorFlow 提供了许多在内存中实例化基本张量的函数。其中最简单的是tf.zeros()
和tf.ones()
。tf.zeros()
接受一个张量形状(表示为 Python 元组)并返回一个填充有零的该形状的张量。让我们尝试在 shell 中调用此命令(请参见示例 2-2)。
示例 2-2。创建一个零张量
>>> tf.zeros(2)
<tf.Tensor 'zeros:0' shape=(2,) dtype=float32>
TensorFlow 返回所需张量的引用,而不是张量本身的值。为了强制返回张量的值,我们将使用张量对象的tf.Tensor.eval()
方法(请参见示例 2-3)。由于我们已经初始化了tf.InteractiveSession()
,这个方法将向我们返回零张量的值。
示例 2-3。评估张量的值
>>> a = tf.zeros(2)
>>> a.eval()
array([ 0., 0.], dtype=float32)
请注意,TensorFlow 张量的计算值本身是一个 Python 对象。特别地,a.eval()
是一个numpy.ndarray
对象。NumPy 是 Python 的一个复杂数值系统。我们不会在这里尝试对 NumPy 进行深入讨论,只是注意到 TensorFlow 被设计为在很大程度上与 NumPy 约定兼容。
我们可以调用tf.zeros()
和tf.ones()
来创建和显示各种大小的张量(请参见示例 2-4)。
示例 2-4。评估和显示张量
>>> a = tf.zeros((2, 3))
>>> a.eval()
array([[ 0., 0., 0.],
[ 0., 0., 0.]], dtype=float32)
>>> b = tf.ones((2,2,2))
>>> b.eval()
array([[[ 1., 1.],
[ 1., 1.]],
[[ 1., 1.],
[ 1., 1.]]], dtype=float32)
如果我们想要一个填充有除 0/1 之外的某个数量的张量呢?tf.fill()
方法提供了一个很好的快捷方式来做到这一点(示例 2-5)。
示例 2-5。用任意值填充张量
>>> b = tf.fill((2, 2), value=5.)
>>> b.eval()
array([[ 5., 5.],
[ 5., 5.]], dtype=float32)
tf.constant
是另一个函数,类似于tf.fill
,允许在程序执行期间不应更改的张量的构建(示例 2-6)。
示例 2-6。创建常量张量
>>> a = tf.constant(3)
>>> a.eval()
3
抽样随机张量
尽管使用常量张量方便测试想法,但更常见的是使用随机值初始化张量。这样做的最常见方式是从随机分布中抽样张量中的每个条目。tf.random_normal
允许从指定均值和标准差的正态分布中抽样指定形状的张量中的每个条目(示例 2-7)。
对称性破缺
许多机器学习算法通过对保存权重的一组张量执行更新来学习。这些更新方程通常满足初始化为相同值的权重将继续一起演变的属性。因此,如果初始张量集初始化为一个常量值,模型将无法学习太多。解决这种情况需要破坏对称性。打破对称性的最简单方法是随机抽样张量中的每个条目。
示例 2-7。抽样具有随机正态条目的张量
>>> a = tf.random_normal((2, 2), mean=0, stddev=1)
>>> a.eval()
array([[-0.73437649, -0.77678096],
[ 0.51697761, 1.15063596]], dtype=float32)
需要注意的一点是,机器学习系统通常使用具有数千万参数的非常大的张量。当我们从正态分布中抽样数千万个随机值时,几乎可以肯定会有一些抽样值远离均值。这样大的样本可能导致数值不稳定,因此通常使用tf.truncated_normal()
而不是tf.random_normal()
进行抽样。这个函数在 API 方面与tf.random_normal()
相同,但会删除并重新抽样所有距离均值超过两个标准差的值。
tf.random_uniform()
的行为类似于tf.random_normal()
,唯一的区别是随机值是从指定范围的均匀分布中抽样的(示例 2-8)。
示例 2-8。抽样具有均匀随机条目的张量
>>> a = tf.random_uniform((2, 2), minval=-2, maxval=2)
>>> a.eval()
array([[-1.90391684, 1.4179163 ],
[ 0.67762709, 1.07282352]], dtype=float32)
张量加法和缩放
TensorFlow 利用 Python 的运算符重载,使用标准 Python 运算符使基本张量算术变得简单直观(示例 2-9)。
示例 2-9。将张量相加
>>> c = tf.ones((2, 2))
>>> d = tf.ones((2, 2))
>>> e = c + d
>>> e.eval()
array([[ 2., 2.],
[ 2., 2.]], dtype=float32)
>>> f = 2 * e
>>> f.eval()
array([[ 4., 4.],
[ 4., 4.]], dtype=float32)
张量也可以这样相乘。但是请注意,当两个张量相乘时,我们得到的是逐元素乘法而不是矩阵乘法,可以在示例 2-10 中看到。
示例 2-10。逐元素张量乘法
>>> c = tf.fill((2,2), 2.)
>>> d = tf.fill((2,2), 7.)
>>> e = c * d
>>> e.eval()
array([[ 14., 14.],
[ 14., 14.]], dtype=float32)
矩阵运算
TensorFlow 提供了各种便利设施来处理矩阵。(在实践中,矩阵是最常用的张量类型。)特别是,TensorFlow 提供了快捷方式来创建某些常用矩阵类型。其中最常用的可能是单位矩阵。单位矩阵是指在对角线上除了 1 之外其他地方都是 0 的方阵。tf.eye()
允许快速构建所需大小的单位矩阵(示例 2-11)。
示例 2-11。创建一个单位矩阵
>>> a = tf.eye(4)
>>> a.eval()
array([[ 1., 0., 0., 0.],
[ 0., 1., 0., 0.],
[ 0., 0., 1., 0.],
[ 0., 0., 0., 1.]], dtype=float32)
对角矩阵是另一种常见类型的矩阵。与单位矩阵不同,对角矩阵只在对角线上非零。与单位矩阵不同,它们可以在对角线上取任意值。让我们构造一个沿对角线升序值的对角矩阵(示例 2-12)。首先,我们需要一种方法在 TensorFlow 中构造升序值的向量。这样做的最简单方法是调用tf.range(start, limit, delta)
。请注意,范围中排除了limit
,delta
是遍历的步长。然后,生成的向量可以传递给tf.diag(diagonal)
,它将构造具有指定对角线的矩阵。
示例 2-12。创建对角矩阵
>>> r = tf.range(1, 5, 1)
>>> r.eval()
array([1, 2, 3, 4], dtype=int32)
>>> d = tf.diag(r)
>>> d.eval()
array([[1, 0, 0, 0],
[0, 2, 0, 0],
[0, 0, 3, 0],
[0, 0, 0, 4]], dtype=int32)
现在假设我们在 TensorFlow 中有一个指定的矩阵。如何计算矩阵的转置?tf.matrix_transpose()
会很好地完成这个任务(示例 2-13)。
示例 2-13。取矩阵转置
>>> a = tf.ones((2, 3))
>>> a.eval()
array([[ 1., 1., 1.],
[ 1., 1., 1.]], dtype=float32)
>>> at = tf.matrix_transpose(a)
>>> at.eval()
array([[ 1., 1.],
[ 1., 1.],
[ 1., 1.]], dtype=float32)
现在,假设我们有一对矩阵,我们想要使用矩阵乘法相乘。最简单的方法是调用tf.matmul()
(示例 2-14)。
示例 2-14。执行矩阵乘法
>>> a = tf.ones((2, 3))
>>> a.eval()
array([[ 1., 1., 1.],
[ 1., 1., 1.]], dtype=float32)
>>> b = tf.ones((3, 4))
>>> b.eval()
array([[ 1., 1., 1., 1.],
[ 1., 1., 1., 1.],
[ 1., 1., 1., 1.]], dtype=float32)
>>> c = tf.matmul(a, b)
>>> c.eval()
array([[ 3., 3., 3., 3.],
[ 3., 3., 3., 3.]], dtype=float32)
您可以检查这个答案是否与我们之前提供的矩阵乘法的数学定义相匹配。
张量类型
您可能已经注意到了前面示例中的dtype
表示。TensorFlow 中的张量有各种类型,如tf.float32
、tf.float64
、tf.int32
、tf.int64
。可以通过在张量构造函数中设置dtype
来创建指定类型的张量。此外,给定一个张量,可以使用转换函数如tf.to_double()
、tf.to_float()
、tf.to_int32()
、tf.to_int64()
等来更改其类型(示例 2-15)。
示例 2-15。创建不同类型的张量
>>> a = tf.ones((2,2), dtype=tf.int32)
>>> a.eval()
array([[0, 0],
[0, 0]], dtype=int32)
>>> b = tf.to_float(a)
>>> b.eval()
array([[ 0., 0.],
[ 0., 0.]], dtype=float32)
张量形状操作
在 TensorFlow 中,张量只是内存中写入的数字集合。不同的形状是对底层数字集合的视图,提供了与数字集合交互的不同方式。在不同的时间,将相同的数字集合视为具有不同形状的张量可能是有用的。tf.reshape()
允许将张量转换为具有不同形状的张量(示例 2-16)。
示例 2-16。操作张量形状
>>> a = tf.ones(8)
>>> a.eval()
array([ 1., 1., 1., 1., 1., 1., 1., 1.], dtype=float32)
>>> b = tf.reshape(a, (4, 2))
>>> b.eval()
array([[ 1., 1.],
[ 1., 1.],
[ 1., 1.],
[ 1., 1.]], dtype=float32)
>>> c = tf.reshape(a, (2, 2, 2))
>>> c.eval()
array([[[ 1., 1.],
[ 1., 1.]],
[[ 1., 1.],
[ 1., 1.]]], dtype=float32)
注意如何使用tf.reshape
将原始秩为 1 的张量转换为秩为 2 的张量,然后再转换为秩为 3 的张量。虽然所有必要的形状操作都可以使用tf.reshape()
执行,但有时使用诸如tf.expand_dims
或tf.squeeze
等函数执行更简单的形状操作可能更方便。tf.expand_dims
向大小为 1 的张量添加额外的维度。它用于通过增加一个维度来增加张量的秩(例如,将秩为 1 的向量转换为秩为 2 的行向量或列向量)。另一方面,tf.squeeze
从张量中删除所有大小为 1 的维度。这是将行向量或列向量转换为平坦向量的有用方法。
这也是一个方便的机会来介绍tf.Tensor.get_shape()
方法(示例 2-17)。这个方法允许用户查询张量的形状。
示例 2-17。获取张量的形状
>>> a = tf.ones(2)
>>> a.get_shape()
TensorShape([Dimension(2)])
>>> a.eval()
array([ 1., 1.], dtype=float32)
>>> b = tf.expand_dims(a, 0)
>>> b.get_shape()
TensorShape([Dimension(1), Dimension(2)])
>>> b.eval()
array([[ 1., 1.]], dtype=float32)
>>> c = tf.expand_dims(a, 1)
>>> c.get_shape()
TensorShape([Dimension(2), Dimension(1)])
>>> c.eval()
array([[ 1.],
[ 1.]], dtype=float32)
>>> d = tf.squeeze(b)
>>> d.get_shape()
TensorShape([Dimension(2)])
>>> d.eval()
array([ 1., 1.], dtype=float32)
广播简介
广播是一个术语(由 NumPy 引入),用于当张量系统的矩阵和不同大小的向量可以相加时。这些规则允许像将向量添加到矩阵的每一行这样的便利。广播规则可能相当复杂,因此我们不会深入讨论规则。尝试并查看广播的工作方式通常更容易(示例 2-18)。
示例 2-18。广播的示例
>>> a = tf.ones((2, 2))
>>> a.eval()
array([[ 1., 1.],
[ 1., 1.]], dtype=float32)
>>> b = tf.range(0, 2, 1, dtype=tf.float32)
>>> b.eval()
array([ 0., 1.], dtype=float32)
>>> c = a + b
>>> c.eval()
array([[ 1., 2.],
[ 1., 2.]], dtype=float32)
注意向量b
被添加到矩阵a
的每一行。注意另一个微妙之处;我们明确为b
设置了dtype
。如果没有设置dtype
,TensorFlow 将报告类型错误。让我们看看如果我们没有设置dtype
会发生什么(示例 2-19)。
示例 2-19。TensorFlow 不执行隐式类型转换
>>> b = tf.range(0, 2, 1)
>>> b.eval()
array([0, 1], dtype=int32)
>>> c = a + b
ValueError: Tensor conversion requested dtype float32 for Tensor with dtype int32:
'Tensor("range_2:0", shape=(2,), dtype=int32)
与 C 语言不同,TensorFlow 在底层不执行隐式类型转换。在进行算术运算时通常需要执行显式类型转换。
命令式和声明式编程
计算机科学中的大多数情况涉及命令式编程。考虑一个简单的 Python 程序(示例 2-20)。
示例 2-20。以命令式方式执行加法的 Python 程序
>>> a = 3
>>> b = 4
>>> c = a + b
>>> c
7
这个程序,当被翻译成机器码时,指示机器对两个寄存器执行一个原始的加法操作,一个包含 3,另一个包含 4。结果是 7。这种编程风格被称为命令式,因为程序明确告诉计算机执行哪些操作。
另一种编程风格是声明式。在声明式系统中,计算机程序是要执行的计算的高级描述。它不会明确告诉计算机如何执行计算。示例 2-21 是示例 2-20 的 TensorFlow 等价物。
示例 2-21。以声明式方式执行加法的 TensorFlow 程序
>>> a = tf.constant(3)
>>> b = tf.constant(4)
>>> c = a + b
>>> c
<tf.Tensor 'add_1:0' shape=() dtype=int32>
>>> c.eval()
7
注意c
的值不是7
!相反,它是一个符号张量。这段代码指定了将两个值相加以创建一个新张量的计算。实际计算直到我们调用c.eval()
才执行。在之前的部分,我们一直在使用eval()
方法来模拟 TensorFlow 中的命令式风格,因为一开始理解声明式编程可能会有挑战。
然而,声明式编程对软件工程并不是一个未知的概念。关系数据库和 SQL 提供了一个广泛使用的声明式编程系统的例子。像 SELECT 和 JOIN 这样的命令可以在底层以任意方式实现,只要它们的基本语义得以保留。TensorFlow 代码最好被视为类似于 SQL 程序;TensorFlow 代码指定要执行的计算,细节留给 TensorFlow 处理。TensorFlow 开发人员利用底层缺乏细节来调整执行风格以适应底层硬件,无论是 CPU、GPU 还是移动设备。
值得注意的是,声明式编程的主要弱点是抽象性很差。例如,没有对关系数据库的底层实现有详细了解,长的 SQL 程序可能会变得难以忍受地低效。同样,没有对底层学习算法的理解实现的大型 TensorFlow 程序可能不会运行良好。在本节的其余部分,我们将开始减少抽象,这个过程将贯穿整本书的其余部分。
TensorFlow Eager
TensorFlow 团队最近添加了一个新的实验模块,TensorFlow Eager,使用户能够以命令式方式运行 TensorFlow 计算。随着时间的推移,这个模块很可能会成为新程序员学习 TensorFlow 的首选入口模式。然而,在撰写时,这个模块仍然非常新,并且存在许多问题。因此,我们不会教授您关于 Eager 模式,但鼓励您自行了解。
重要的是要强调,即使 Eager 成熟后,TensorFlow 的很多部分仍然会保持声明式,所以学习声明式的 TensorFlow 是值得的。
TensorFlow 图
在 TensorFlow 中,任何计算都表示为tf.Graph
对象的实例。这样的图由一组tf.Tensor
对象实例和tf.Operation
对象实例组成。我们已经详细介绍了tf.Tensor
,但tf.Operation
对象是什么?在本章的过程中,您已经看到了它们。对tf.matmul
等操作的调用会创建一个tf.Operation
实例,以标记执行矩阵乘法操作的需求。
当未明确指定tf.Graph
时,TensorFlow 会将张量和操作添加到隐藏的全局tf.Graph
实例中。可以通过tf.get_default_graph()
获取此实例(示例 2-22)。
示例 2-22。获取默认的 TensorFlow 图
>>> tf.get_default_graph()
<tensorflow.python.framework.ops.Graph>
可以指定 TensorFlow 操作应在除默认之外的图中执行。我们将在未来章节中演示这方面的示例。
TensorFlow 会话
在 TensorFlow 中,tf.Session()
对象存储计算执行的上下文。在本章的开头,我们使用tf.InteractiveSession()
为所有 TensorFlow 计算设置环境。此调用创建了一个隐藏的全局上下文,用于执行所有计算。然后我们使用tf.Tensor.eval()
来执行我们声明指定的计算。在幕后,此调用在这个隐藏的全局tf.Session
上下文中进行评估。使用显式上下文进行计算而不是隐藏上下文可能会更方便(通常也更必要)(示例 2-23)。
示例 2-23。显式操作 TensorFlow 会话
>>> sess = tf.Session()
>>> a = tf.ones((2, 2))
>>> b = tf.matmul(a, a)
>>> b.eval(session=sess)
array([[ 2., 2.],
[ 2., 2.]], dtype=float32)
此代码在sess
的上下文中评估b
,而不是隐藏的全局会话。实际上,我们可以使用另一种符号更明确地表示这一点(示例 2-24)。
示例 2-24。在会话中运行计算
>>> sess.run(b)
array([[ 2., 2.],
[ 2., 2.]], dtype=float32)
事实上,调用b.eval(session=sess)
只是调用sess.run(b)
的语法糖。
整个讨论可能有点诡辩。鉴于所有不同的方法似乎返回相同的答案,哪个会话正在进行并不重要?直到您开始执行具有状态的计算时,显式会话才能展现其价值,这是您将在下一节中了解的主题。
TensorFlow 变量
本节中的所有示例代码都使用了常量张量。虽然我们可以以任何方式组合和重组这些张量,但我们永远无法更改张量本身的值(只能创建具有新值的新张量)。到目前为止,编程风格一直是函数式而不是有状态的。虽然函数式计算非常有用,但机器学习往往严重依赖有状态的计算。学习算法本质上是更新存储的张量以解释提供的数据的规则。如果无法更新这些存储的张量,学习将变得困难。
tf.Variable()
类提供了一个围绕张量的包装器,允许进行有状态的计算。变量对象充当张量的持有者。创建变量非常容易(示例 2-25)。
示例 2-25。创建 TensorFlow 变量
>>> a = tf.Variable(tf.ones((2, 2)))
>>> a
<tf.Variable 'Variable:0' shape=(2, 2) dtype=float32_ref>
当我们尝试评估变量a
,就像在示例 2-26 中一样,作为张量时会发生什么?
示例 2-26。评估未初始化的变量失败
>>> a.eval()
FailedPreconditionError: Attempting to use uninitialized value Variable
评估失败,因为必须显式初始化变量。初始化所有变量的最简单方法是调用tf.global_variables_initializer
。在会话中运行此操作将初始化程序中的所有变量(示例 2-27)。
示例 2-27。评估初始化的变量
>>> sess = tf.Session()
>>> sess.run(tf.global_variables_initializer())
>>> a.eval(session=sess)
array([[ 1., 1.],
[ 1., 1.]], dtype=float32)
初始化后,我们可以获取存储在变量中的值,就像它是一个普通的张量一样。到目前为止,变量没有比普通张量更有趣的地方。只有当我们可以对变量进行赋值时,变量才变得有趣。tf.assign()
让我们可以做到这一点。使用tf.assign()
,我们可以更新现有变量的值(示例 2-28)。
示例 2-28。为变量赋值
>>> sess.run(a.assign(tf.zeros((2,2))))
array([[ 0., 0.],
[ 0., 0.]], dtype=float32)
>>> sess.run(a)
array([[ 0., 0.],
[ 0., 0.]], dtype=float32)
如果我们尝试为变量a
分配一个不是形状(2,2)
的值会发生什么?让我们在示例 2-29 中找出答案。
示例 2-29。当形状不相等时,赋值失败
>>> sess.run(a.assign(tf.zeros((3,3))))
ValueError: Dimension 0 in both shapes must be equal, but are 2 and 3 for 'Assign_3'
(op: 'Assign') with input shapes: [2,2], [3,3].
你可以看到 TensorFlow 会抱怨。变量的形状在初始化时是固定的,必须在更新时保持不变。另一个有趣的地方是,tf.assign
本身是底层全局tf.Graph
实例的一部分。这使得 TensorFlow 程序可以在每次运行时更新其内部状态。在接下来的章节中,我们将大量使用这个特性。
回顾
在这一章中,我们介绍了张量的数学概念,并简要回顾了与张量相关的一些数学概念。然后我们演示了如何在 TensorFlow 中创建张量并在 TensorFlow 中执行相同的数学运算。我们还简要介绍了一些底层的 TensorFlow 结构,比如计算图、会话和变量。如果你还没有完全掌握本章讨论的概念,不要太担心。在本书的剩余部分中,我们将反复使用这些概念,所以会有很多机会让这些想法深入人心。
在下一章中,我们将教你如何使用 TensorFlow 为线性回归和逻辑回归构建简单的学习模型。随后的章节将在这些基础上构建,教你如何训练更复杂的模型。
第三章:使用 TensorFlow 进行线性和逻辑回归
本章将向您展示如何在 TensorFlow 中构建简单但非平凡的学习系统示例。本章的第一部分回顾了构建学习系统的数学基础,特别涵盖了函数、连续性和可微性。我们介绍了损失函数的概念,然后讨论了机器学习如何归结为找到复杂损失函数的最小点的能力。然后我们介绍了梯度下降的概念,并解释了如何使用它来最小化损失函数。我们最后简要讨论了自动微分的算法思想。第二部分重点介绍了这些数学思想支撑的 TensorFlow 概念。这些概念包括占位符、作用域、优化器和 TensorBoard,可以实现学习系统的实际构建和分析。最后一部分提供了如何在 TensorFlow 中训练线性和逻辑回归模型的案例研究。
本章很长,介绍了许多新的概念。如果您在第一次阅读时没有完全理解这些概念的微妙之处,那没关系。我们建议继续前进,以后有需要时再回来参考这里的概念。我们将在本书的其余部分中反复使用这些基础知识,以便让这些思想逐渐沉淀。
数学复习
本节回顾了概念上理解机器学习所需的数学工具。我们试图尽量减少所需的希腊符号数量,而是专注于建立概念理解而不是技术操作。
函数和可微性
本节将为您提供函数和可微性概念的简要概述。函数f是将输入映射到输出的规则。所有计算机编程语言中都有函数,数学上对函数的定义实际上并没有太大不同。然而,在物理学和工程学中常用的数学函数具有其他重要属性,如连续性和可微性。连续函数,粗略地说,是可以在不从纸上抬起铅笔的情况下绘制的函数,如图 3-1 所示。(这当然不是技术定义,但它捕捉了连续性条件的精神。)
![continuous_1.gif]()
图 3-1。一些连续函数。
可微性是函数上的一种平滑条件。它表示函数中不允许有尖锐的角或转折(图 3-2)。
![Math_images_4.jpg]()
图 3-2。一个可微函数。
可微函数的关键优势在于我们可以利用函数在特定点的斜率作为指导,找到函数高于或低于当前位置的地方。这使我们能够找到函数的最小值。可微函数f的导数,表示为,是另一个函数,提供原始函数在所有点的斜率。概念上,函数在给定点的导数指示了函数高于或低于当前值的方向。优化算法可以遵循这个指示牌,向* f *的最小值靠近。在最小值处,函数的导数为零。
最初,导数驱动的优化的力量并不明显。几代微积分学生都在纸上进行枯燥的最小化函数练习中受苦。这些练习并不有用,因为找到具有少量输入参数的函数的最小值是一个最好通过图形方式完成的微不足道的练习。导数驱动的优化的力量只有在有数百、数千、数百万或数十亿个变量时才会显现出来。在这些规模上,通过解析理解函数几乎是不可能的,所有的可视化都是充满风险的练习,很可能会忽略函数的关键属性。在这些规模上,函数的梯度,一个多变量函数的的推广,很可能是理解函数及其行为的最强大的数学工具。我们将在本章后面更深入地探讨梯度。(概念上是这样;我们不会在这项工作中涵盖梯度的技术细节。)
在非常高的层面上,机器学习只是函数最小化的行为:学习算法只不过是适当定义的函数的最小值查找器。这个定义具有数学上的简单性优势。但是,这些特殊的可微函数是什么,它们如何在它们的最小值中编码有用的解决方案,我们如何找到它们呢?
损失函数
为了解决给定的机器学习问题,数据科学家必须找到一种构建函数的方法,其最小值编码了手头的现实世界问题的解决方案。幸运的是,对于我们这位不幸的数据科学家来说,机器学习文献已经建立了一个丰富的损失函数历史,执行这种编码。实际机器学习归结为理解不同类型的可用损失函数,并知道应该将哪种损失函数应用于哪些问题。换句话说,损失函数是将数据科学项目转化为数学的机制。所有的机器学习,以及大部分人工智能,都归结为创建正确的损失函数来解决手头的问题。我们将为您介绍一些常见的损失函数家族。
我们首先注意到,损失函数必须满足一些数学属性才能有意义。首先,必须使用数据点x和标签y。我们通过将损失函数写成来表示这一点。使用我们在上一章中的术语,x和y都是张量,是从张量对到标量的函数。损失函数的函数形式应该是什么?人们常用的一个假设是使损失函数可加性。假设是示例i的可用数据,并且总共有N个示例。那么损失函数可以分解为
(在实践中, 对于每个数据点都是相同的。)这种加法分解带来了许多有用的优势。首先是导数通过加法因子化,因此计算总损失的梯度简化如下:
这种数学技巧意味着只要较小的函数是可微的,总损失函数也将是可微的。由此可见,设计损失函数的问题归结为设计较小函数。在我们深入设计之前,我们将方便地进行一个小的旁观,解释分类和回归问题之间的区别。
分类和回归
机器学习算法可以广泛地分为监督或无监督问题。监督问题是指数据点x和标签y都是可用的问题,而无监督问题只有数据点x没有标签y。一般来说,无监督机器学习更加困难且定义不明确(“理解”数据点x是什么意思?)。我们暂时不会深入讨论无监督损失函数,因为在实践中,大多数无监督损失都是巧妙地重新利用监督损失。
监督机器学习可以分为分类和回归两个子问题。分类问题是指您试图设计一个机器学习系统,为给定的数据点分配一个离散标签,比如 0/1(或更一般地)。回归是指设计一个机器学习系统,为给定的数据点附加一个实值标签(在)。
从高层来看,这些问题可能看起来相当不同。离散对象和连续对象通常在数学和常识上被不同对待。然而,机器学习中使用的一种技巧是使用连续、可微的损失函数来编码分类和回归问题。正如我们之前提到的,机器学习的很大一部分就是将复杂的现实系统转化为适当简单的可微函数的艺术。
在接下来的章节中,我们将向您介绍一对数学函数,这对函数将非常有用,可以将分类和回归任务转换为适当的损失函数。
L²损失
L²损失(读作ell-two损失)通常用于回归问题。L²损失(或者在其他地方通常称为L²范数)提供了一个向量大小的度量:
在这里,a被假定为长度为N的向量。L²范数通常用来定义两个向量之间的距离:
L²作为距离测量的概念在解决监督机器学习中的回归问题时非常有用。假设x是一组数据,y是相关标签。让f是一些可微函数,编码我们的机器学习模型。然后为了鼓励f预测y,我们创建L²损失函数。
作为一个快速说明,在实践中通常不直接使用L²损失,而是使用它的平方。
为了避免在梯度中处理形式为的术语。我们将在本章和本书的其余部分中反复使用平方L²损失。
概率分布
在介绍分类问题的损失函数之前,介绍概率分布将会很有用。首先,什么是概率分布,为什么我们应该关心它对机器学习有什么作用?概率是一个深奥的主题,因此我们只会深入到您获得所需的最低理解为止。在高层次上,概率分布提供了一个数学技巧,允许您将一组离散选择放松为一个连续的选择。例如,假设您需要设计一个机器学习系统,预测硬币是正面朝上还是反面朝上。看起来正面朝上/朝下似乎无法编码为连续函数,更不用说可微函数了。那么您如何使用微积分或 TensorFlow 的机制来解决涉及离散选择的问题呢?
进入概率分布。与硬选择不同,让分类器预测正面朝上或反面朝上的机会。例如,分类器可能学习预测正面的概率为 0.75,反面的概率为 0.25。请注意,概率是连续变化的!因此,通过使用离散事件的概率而不是事件本身,您可以巧妙地避开微积分无法真正处理离散事件的问题。
概率分布p简单地是所涉及的可能离散事件的概率列表。在这种情况下,p = (0.75, 0.25)。另外,您可以将视为从两个元素集合到实数的函数。这种观点在符号上有时会很有用。
我们简要指出,概率分布的技术定义更加复杂。将概率分布分配给实值事件是可行的。我们将在本章后面讨论这样的分布。
交叉熵损失
交叉熵是衡量两个概率分布之间距离的数学方法:
这里p和q是两个概率分布。符号p(x)表示p赋予事件x的概率。这个定义值得仔细讨论。与L²范数一样,H提供了距离的概念。请注意,在p = q的情况下,
这个数量是p的熵,通常简单地写作H(p)。这是分布无序程度的度量;当所有事件等可能时,熵最大。H(p)总是小于或等于H(p, q)。事实上,分布q距离p越远,交叉熵就越大。我们不会深入探讨这些陈述的确切含义,但将交叉熵视为距离机制的直觉值得记住。
另外,请注意,与L²范数不同,H是不对称的!也就是说,。因此,使用交叉熵进行推理可能有点棘手,最好谨慎处理。
回到具体问题,现在假设是具有两个结果的离散系统的真实数据分布,是机器学习系统预测的。那么交叉熵损失是
这种损失形式在机器学习系统中被广泛使用来训练分类器。经验上,最小化H(p, q)似乎能够构建出很好地复制提供的训练标签的分类器。
梯度下降
到目前为止,在这一章中,您已经了解了将函数最小化作为机器学习的代理的概念。简而言之,最小化适当的函数通常足以学会解决所需的任务。为了使用这个框架,您需要使用适当的损失函数,比如L²或H(p, q) 交叉熵,以将分类和回归问题转化为适当的损失函数。
可学习权重
到目前为止,在本章中,我们已经解释了机器学习是通过最小化适当定义的损失函数来实现的。也就是说,我们试图找到最小化它的损失函数的参数。然而,细心的读者会记得(x,y)是固定的量,不能改变。那么在学习过程中我们改变的是什么参数呢?
输入可学习权重W。假设f(x)是我们希望用机器学习模型拟合的可微函数。我们将规定f由选择W的方式进行参数化。也就是说,我们的函数实际上有两个参数f(W, x)。固定W的值会导致一个仅依赖于数据点x的函数。这些可学习权重实际上是通过最小化损失函数选择的量。我们将在本章后面看到如何使用tf.Variable
来编码可学习权重。
但是,现在假设我们已经用适当的损失函数编码了我们的学习问题?在实践中,我们如何找到这个损失函数的最小值?我们将使用的关键技巧是梯度下降最小化。假设f是一个依赖于一些权重W的函数。那么表示的是在W中会最大程度增加f的方向变化。由此可知,朝着相反方向迈出一步会让我们更接近f的最小值。
梯度的符号
我们已经将可学习权重W的梯度写成了。有时,使用以下替代符号表示梯度会更方便:
将这个方程理解为梯度编码了最大程度改变损失的方向。
梯度下降的思想是通过反复遵循负梯度来找到函数的最小值。从算法上讲,这个更新规则可以表示为
其中是步长,决定了新梯度被赋予多少权重。这个想法是每次都朝着的方向迈出许多小步。注意本身是W的一个函数,所以实际步骤在每次迭代中都会改变。每一步都对权重矩阵W进行一点更新。执行更新的迭代过程通常称为学习权重矩阵W。
使用小批量高效计算梯度
一个问题是计算可能非常慢。隐含地,取决于损失函数。由于取决于整个数据集,对于大型数据集来说,计算可能会变得非常缓慢。在实践中,人们通常在称为minibatch的数据集的一部分上估计。每个 minibatch 通常包含 50-100 个样本。Minibatch 的大小是深度学习算法中的一个超参数。每个步骤的步长是另一个超参数。深度学习算法通常具有超参数的集群,这些超参数本身不是通过随机梯度下降学习的。
可学习参数和超参数之间的这种张力是深度结构的弱点和优势之一。超参数的存在为利用专家的强烈直觉提供了很大的空间,而可学习参数则允许数据自己说话。然而,这种灵活性本身很快变成了一个弱点,对于超参数行为的理解有点像黑魔法,阻碍了初学者广泛部署深度学习。我们将在本书的后面花费大量精力讨论超参数优化。
我们通过介绍时代的概念来结束本节。一个时代是梯度下降算法在数据x上的完整遍历。更具体地说,一个时代包括需要查看给定 minibatch 大小的所有数据所需的梯度下降步骤。例如,假设一个数据集有 1,000 个数据点,训练使用大小为 50 的 minibatch。那么一个时代将包括 20 个梯度下降更新。每个训练时代增加了模型获得的有用知识量。从数学上讲,这将对应于训练集上损失函数值的减少。
早期的时代将导致损失函数的急剧下降。这个过程通常被称为在该数据集上学习先验。虽然看起来模型正在快速学习,但实际上它只是在调整自己以适应与手头问题相关的参数空间的部分。后续时代将对应于损失函数的较小下降,但通常在这些后续时代中才会发生有意义的学习。几个时代通常对于一个非平凡的模型来说时间太短,模型通常从 10-1,000 个时代或直到收敛进行训练。虽然这看起来很大,但重要的是要注意,所需的时代数量通常不随手头数据集的大小而增加。因此,梯度下降与数据大小成线性关系,而不是二次关系!这是随机梯度下降方法相对于其他学习算法的最大优势之一。更复杂的学习算法可能只需要对数据集进行一次遍历,但可能使用的总计算量与数据点数量成二次关系。在大数据集的时代,二次运行时间是一个致命的弱点。
跟踪损失函数随着周期数的减少可以是理解学习过程的极其有用的视觉简写。这些图通常被称为损失曲线(见图 3-4)。随着时间的推移,一个经验丰富的从业者可以通过快速查看损失曲线来诊断学习中的常见失败。我们将在本书的过程中对各种深度学习模型的损失曲线给予重要关注。特别是在本章后面,我们将介绍 TensorBoard,这是 TensorFlow 提供的用于跟踪诸如损失函数之类的量的强大可视化套件。
这些规则可以通过链式法则结合起来:
机器学习是定义适合数据集的损失函数,然后将其最小化的艺术。为了最小化损失函数,我们需要计算它们的梯度,并使用梯度下降算法迭代地减少损失。然而,我们仍然需要讨论梯度是如何实际计算的。直到最近,答案是“手动”。机器学习专家会拿出笔和纸,手动计算矩阵导数,以计算学习系统中所有梯度的解析公式。然后这些公式将被手动编码以实现学习算法。这个过程以前是臭名昭著的,不止一位机器学习专家在发表的论文和生产系统中意外梯度错误的故事被发现了多年。
图 3-4。一个模型的损失曲线示例。请注意,这个损失曲线来自使用真实梯度(即非小批量估计)训练的模型,因此比您在本书后面遇到的其他损失曲线更平滑。
这种情况已经发生了显著变化,随着自动微分引擎的广泛可用。像 TensorFlow 这样的系统能够自动计算几乎所有损失函数的梯度。这种自动微分是 TensorFlow 和类似系统的最大优势之一,因为机器学习从业者不再需要成为矩阵微积分的专家。然而,了解 TensorFlow 如何自动计算复杂函数的导数仍然很重要。对于那些在微积分入门课程中受苦的读者,你可能记得计算函数的导数是令人惊讶地机械化的。有一系列简单的规则可以应用于计算大多数函数的导数。例如:
数学显示="block"的
数学显示="block"的
数学显示="block"的
自动微分系统
其中 用于表示 f 的导数, 用于表示 g 的导数。有了这些规则,很容易想象如何为一维微积分编写自动微分引擎。事实上,在基于 Lisp 的课程中,创建这样一个微分引擎通常是一年级的编程练习。(事实证明,正确解析函数比求导数更加困难。Lisp 使用其语法轻松解析公式,而在其他语言中,等到上编译器课程再做这个练习通常更容易)。
如何将这些规则扩展到更高维度的微积分?搞定数学更加棘手,因为需要考虑更多的数字。例如,给定 X = AB,其中 X、A、B 都是矩阵,公式变成了
这样的公式可以组合起来提供一个矢量和张量微积分的符号微分系统。
使用 TensorFlow 进行学习
在本章的其余部分,我们将介绍您学习使用 TensorFlow 创建基本机器学习模型所需的概念。我们将从介绍玩具数据集的概念开始,然后解释如何使用常见的 Python 库创建有意义的玩具数据集。接下来,我们将讨论新的 TensorFlow 想法,如占位符、喂养字典、名称范围、优化器和梯度。下一节将向您展示如何使用这些概念训练简单的回归和分类模型。
创建玩具数据集
在本节中,我们将讨论如何创建简单但有意义的合成数据集,或称为玩具数据集,用于训练简单的监督分类和回归模型。
对 NumPy 的(极其)简要介绍
我们将大量使用 NumPy 来定义有用的玩具数据集。NumPy 是一个允许操作张量(在 NumPy 中称为 ndarray
)的 Python 包。示例 3-1 展示了一些基础知识。
示例 3-1。一些基本 NumPy 用法示例
>>> import numpy as np
>>> np.zeros((2,2))
array([[ 0., 0.],
[ 0., 0.]])
>>> np.eye(3)
array([[ 1., 0., 0.],
[ 0., 1., 0.],
[ 0., 0., 1.]])
您可能会注意到 NumPy ndarray
操作看起来与 TensorFlow 张量操作非常相似。这种相似性是 TensorFlow 架构师特意设计的。许多关键的 TensorFlow 实用函数具有与 NumPy 中类似函数的参数和形式。出于这个目的,我们不会试图深入介绍 NumPy,并相信读者通过实验来掌握 NumPy 的用法。有许多在线资源提供了 NumPy 的教程介绍。
为什么玩具数据集很重要?
在机器学习中,学会正确使用玩具数据集通常至关重要。学习是具有挑战性的,初学者经常犯的一个最常见的错误是尝试在太早的时候在复杂数据上学习非平凡的模型。这些尝试往往以惨败告终,想要成为机器学习者的人会灰心丧气,认为机器学习不适合他们。
当然,真正的罪魁祸首不是学生,而是真实世界数据集具有许多特殊性。经验丰富的数据科学家已经了解到,真实世界数据集通常需要许多清理和预处理转换才能适合学习。深度学习加剧了这个问题,因为大多数深度学习模型对数据中的不完美非常敏感。诸如广泛范围的回归标签或潜在的强噪声模式等问题可能会使梯度下降方法出现问题,即使其他机器学习算法(如随机森林)也不会有问题。
幸运的是,几乎总是可以解决这些问题,但这可能需要数据科学家具有相当的复杂技能。这些敏感性问题可能是机器学习作为一种技术商品化的最大障碍。我们将深入探讨数据清理策略,但目前,我们建议一个更简单的替代方案:使用玩具数据集!
玩具数据集对于理解学习算法至关重要。给定非常简单的合成数据集,可以轻松判断算法是否学习了正确的规则。在更复杂的数据集上,这种判断可能非常具有挑战性。因此,在本章的其余部分,我们将只使用玩具数据集,同时涵盖基于 TensorFlow 的梯度下降学习的基础知识。在接下来的章节中,我们将深入研究具有真实数据的案例研究。
使用高斯分布添加噪声
早些时候,我们讨论了离散概率分布作为将离散选择转换为连续值的工具。我们也提到了连续概率分布的概念,但没有深入探讨。
连续概率分布(更准确地称为概率密度函数)是用于建模可能具有一系列结果的随机事件的有用数学工具。对于我们的目的,将概率密度函数视为用于模拟数据收集中的某些测量误差的有用工具就足够了。高斯分布被广泛用于噪声建模。
如图 3-5 所示,注意高斯分布可以具有不同的均值和标准差。高斯分布的均值是它取的平均值,而标准差是围绕这个平均值的扩散的度量。一般来说,将高斯随机变量添加到某个数量上提供了一种结构化的方式,通过使其稍微变化来模糊这个数量。这是一个非常有用的技巧,用于生成非平凡的合成数据集。
![gaussian.png]()
图 3-5。不同均值和标准差的各种高斯概率分布的插图。
我们迅速指出高斯分布也被称为正态分布。均值为,标准差为的高斯分布写为。这种简写符号很方便,我们将在接下来的章节中多次使用它。
玩具回归数据集
最简单的线性回归形式是学习一维线的参数。假设我们的数据点x是一维的。然后假设实值标签y由线性规则生成
在这里,w,b是必须通过梯度下降从数据中估计出来的可学习参数。为了测试我们是否可以使用 TensorFlow 学习这些参数,我们将生成一个由直线上的点组成的人工数据集。为了使学习挑战稍微困难一些,我们将在数据集中添加少量高斯噪声。
让我们写下我们的直线方程,受到少量高斯噪声的干扰:
这里是噪声项的标准差。然后我们可以使用 NumPy 从这个分布中生成一个人工数据集,如示例 3-2 所示。
示例 3-2. 使用 NumPy 对人工数据集进行抽样
# Generate synthetic data
N = 100
w_true = 5
b_true = 2
noise_scale = .1
x_np = np.random.rand(N, 1)
noise = np.random.normal(scale=noise_scale, size=(N, 1))
# Convert shape of y_np to (N,)
y_np = np.reshape(w_true * x_np + b_true + noise, (-1))
我们使用 Matplotlib 在图 3-6 中绘制这个数据集(您可以在与本书相关的GitHub 存储库中找到确切的绘图代码)以验证合成数据看起来是否合理。如预期的那样,数据分布是一条直线,带有少量测量误差。
![lr_data.png]()
图 3-6. 玩具回归数据分布的绘图。
玩具分类数据集
创建合成分类数据集有点棘手。从逻辑上讲,我们希望有两个不同的、容易分离的点类。假设数据集只包含两种类型的点,(-1,-1)和(1,1)。然后学习算法将不得不学习一个将这两个数据值分开的规则。
-
y[0] = (-1, -1)
-
y[1] = (1, 1)
与以前一样,让我们通过向两种类型的点添加一些高斯噪声来增加一些挑战:
然而,这里有一点小技巧。我们的点是二维的,而我们之前引入的高斯噪声是一维的。幸运的是,存在高斯的多变量扩展。我们不会在这里讨论多变量高斯的复杂性,但您不需要理解这些复杂性来跟随我们的讨论。
在示例 3-3 中生成合成数据集的 NumPy 代码比线性回归问题稍微棘手,因为我们必须使用堆叠函数np.vstack
将两种不同类型的数据点组合在一起,并将它们与不同的标签关联起来。(我们使用相关函数np.concatenate
将一维标签组合在一起。)
示例 3-3. 使用 NumPy 对玩具分类数据集进行抽样
# Generate synthetic data
N = 100
# Zeros form a Gaussian centered at (-1, -1)
# epsilon is .1
x_zeros = np.random.multivariate_normal(
mean=np.array((-1, -1)), cov=.1*np.eye(2), size=(N/2,))
y_zeros = np.zeros((N/2,))
# Ones form a Gaussian centered at (1, 1)
# epsilon is .1
x_ones = np.random.multivariate_normal(
mean=np.array((1, 1)), cov=.1*np.eye(2), size=(N/2,))
y_ones = np.ones((N/2,))
x_np = np.vstack([x_zeros, x_ones])
y_np = np.concatenate([y_zeros, y_ones])
图 3-7 使用 Matplotlib 绘制了这段代码生成的数据,以验证分布是否符合预期。我们看到数据分布在两个清晰分开的类中。
![logistic_data.png]()
图 3-7. 玩具分类数据分布的绘图。
新的 TensorFlow 概念
在 TensorFlow 中创建简单的机器学习系统将需要您学习一些新的 TensorFlow 概念。
占位符
占位符是将信息输入到 TensorFlow 计算图中的一种方式。将占位符视为信息进入 TensorFlow 的输入节点。用于创建占位符的关键函数是tf.placeholder
(示例 3-4)。
示例 3-4. 创建一个 TensorFlow 占位符
>>> tf.placeholder(tf.float32, shape=(2,2))
<tf.Tensor 'Placeholder:0' shape=(2, 2) dtype=float32>
我们将使用占位符将数据点x和标签y馈送到我们的回归和分类算法中。
馈送字典和获取
回想一下,我们可以通过sess.run(var)
在 TensorFlow 中评估张量。那么我们如何为占位符提供值呢?答案是构建feed 字典。Feed 字典是 Python 字典,将 TensorFlow 张量映射到包含这些占位符具体值的np.ndarray
对象。Feed 字典最好被视为 TensorFlow 计算图的输入。那么输出是什么?TensorFlow 称这些输出为fetches。您已经见过 fetches 了。我们在上一章中广泛使用了它们,但没有这样称呼;fetch 是一个张量(或张量),其值是在计算图中的计算(使用 feed 字典中的占位符值)完成后检索的(示例 3-5)。
示例 3-5。使用 fetches
>>> a = tf.placeholder(tf.float32, shape=(1,))
>>> b = tf.placeholder(tf.float32, shape=(1,))
>>> c = a + b
>>> with tf.Session() as sess:
c_eval = sess.run(c, {a: [1.], b: [2.]})
print(c_eval)
[ 3.]
命名空间
在复杂的 TensorFlow 程序中,将在整个程序中定义许多张量、变量和占位符。tf.name_scope(name)
为管理这些变量集合提供了一个简单的作用域机制(示例 3-6)。在tf.name_scope(name)
调用的作用域内创建的所有计算图元素将在其名称前加上name
。
这种组织工具在与 TensorBoard 结合使用时最有用,因为它有助于可视化系统自动将图元素分组到相同的命名空间中。您将在下一节中进一步了解 TensorBoard。
示例 3-6。使用命名空间来组织占位符
>>> N = 5
>>> with tf.name_scope("placeholders"):
x = tf.placeholder(tf.float32, (N, 1))
y = tf.placeholder(tf.float32, (N,))
>>> x
<tf.Tensor 'placeholders/Placeholder:0' shape=(5, 1) dtype=float32>
优化器
在前两节介绍的基本概念已经暗示了在 TensorFlow 中如何进行机器学习。您已经学会了如何为数据点和标签添加占位符,以及如何使用张量操作定义损失函数。缺失的部分是您仍然不知道如何使用 TensorFlow 执行梯度下降。
实际上,可以直接在 Python 中使用 TensorFlow 原语定义优化算法,TensorFlow 在tf.train
模块中提供了一系列优化算法。这些算法可以作为节点添加到 TensorFlow 计算图中。
我应该使用哪个优化器?
在tf.train
中有许多可能的优化器可用。简短预览中包括tf.train.GradientDescentOptimizer
、tf.train.MomentumOptimizer
、tf.train.AdagradOptimizer
、tf.train.AdamOptimizer
等。这些不同优化器之间有什么区别呢?
几乎所有这些优化器都是基于梯度下降的思想。回想一下我们之前介绍的简单梯度下降规则:
从数学上讲,这个更新规则是原始的。研究人员发现了许多数学技巧,可以在不使用太多额外计算的情况下实现更快的优化。一般来说,tf.train.AdamOptimizer
是一个相对稳健的好默认值。(许多优化方法对超参数的选择非常敏感。对于初学者来说,最好避开更复杂的方法,直到他们对不同优化算法的行为有很好的理解。)
示例 3-7 是一小段代码,它向计算图中添加了一个优化器,用于最小化预定义的损失l
。
示例 3-7。向 TensorFlow 计算图添加 Adam 优化器
learning_rate = .001
with tf.name_scope("optim"):
train_op = tf.train.AdamOptimizer(learning_rate).minimize(l)
使用 TensorFlow 计算梯度
我们之前提到,在 TensorFlow 中直接实现梯度下降算法是可能的。虽然大多数用例不需要重新实现tf.train
的内容,但直接查看梯度值以进行调试可能很有用。tf.gradients
提供了一个有用的工具来实现这一点(示例 3-8)。
示例 3-8。直接计算梯度
>>> W = tf.Variable((3,))
>>> l = tf.reduce_sum(W)
>>> gradW = tf.gradients(l, W)
>>> gradW
[<tf.Tensor 'gradients/Sum_grad/Tile:0' shape=(1,) dtype=int32>]
这段代码符号地拉下了损失l
相对于可学习参数(tf.Variable
)W
的梯度。tf.gradients
返回所需梯度的列表。请注意,梯度本身也是张量!TensorFlow 执行符号微分,这意味着梯度本身是计算图的一部分。TensorFlow 符号梯度的一个很好的副作用是,可以在 TensorFlow 中堆叠导数。这对于更高级的算法有时可能是有用的。
TensorBoard 的摘要和文件写入器
对张量程序结构有一个视觉理解是非常有用的。TensorFlow 团队提供了 TensorBoard 包来实现这个目的。TensorBoard 启动一个 Web 服务器(默认情况下在 localhost 上),显示 TensorFlow 程序的各种有用的可视化。然而,为了能够使用 TensorBoard 检查 TensorFlow 程序,程序员必须手动编写日志记录语句。tf.train.FileWriter()
指定了 TensorBoard 程序的日志目录,tf.summary
将各种 TensorFlow 变量的摘要写入指定的日志目录。在本章中,我们只会使用tf.summary.scalar
,它总结了一个标量量,以跟踪损失函数的值。tf.summary.merge_all()
是一个有用的日志辅助工具,它将多个摘要合并为一个摘要以方便使用。
示例 3-9 中的代码片段为损失添加了一个摘要,并指定了一个日志目录。
示例 3-9。为损失添加一个摘要
with tf.name_scope("summaries"):
tf.summary.scalar("loss", l)
merged = tf.summary.merge_all()
train_writer = tf.summary.FileWriter('/tmp/lr-train', tf.get_default_graph())
使用 TensorFlow 训练模型
假设现在我们已经为数据点和标签指定了占位符,并且已经用张量操作定义了一个损失。我们已经在计算图中添加了一个优化器节点train_op
,我们可以使用它来执行梯度下降步骤(虽然我们实际上可能使用不同的优化器,但为了方便起见,我们将更新称为梯度下降)。我们如何迭代地执行梯度下降来在这个数据集上学习?
简单的答案是我们使用 Python 的for
循环。在每次迭代中,我们使用sess.run()
来获取图中的train_op
以及合并的摘要操作merged
和损失l
。我们使用一个 feed 字典将所有数据点和标签输入sess.run()
。
示例 3-10 中的代码片段演示了这种简单的学习方法。请注意,出于教学简单性的考虑,我们不使用小批量。在接下来的章节中,代码将在训练更大的数据集时使用小批量。
示例 3-10。训练模型的简单示例
n_steps = 1000
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
# Train model
for i in range(n_steps):
feed_dict = {x: x_np, y: y_np}
_, summary, loss = sess.run([train_op, merged, l], feed_dict=feed_dict)
print("step %d, loss: %f" % (i, loss))
train_writer.add_summary(summary, i)
在 TensorFlow 中训练线性和逻辑模型
本节将在前一节介绍的所有 TensorFlow 概念上进行总结,以在我们在本章中之前介绍的玩具数据集上训练线性和逻辑回归模型。
在 TensorFlow 中的线性回归
在本节中,我们将提供代码来定义一个在 TensorFlow 中学习其权重的线性回归模型。这个任务很简单,你可以很容易地在没有 TensorFlow 的情况下完成。然而,在 TensorFlow 中做这个练习是很好的,因为它将整合我们在本章中介绍的新概念。
在 TensorFlow 中定义和训练线性回归
线性回归模型很简单:
这里w和b是我们希望学习的权重。我们将这些权重转换为tf.Variable
对象。然后我们使用张量操作构建L²损失:
示例 3-11 中的代码在 TensorFlow 中实现了这些数学操作。它还使用tf.name_scope
来分组各种操作,并添加了tf.train.AdamOptimizer
用于学习和tf.summary
操作用于 TensorBoard 的使用。
示例 3-11. 定义线性回归模型
# Generate tensorflow graph
with tf.name_scope("placeholders"):
x = tf.placeholder(tf.float32, (N, 1))
y = tf.placeholder(tf.float32, (N,))
with tf.name_scope("weights"):
# Note that x is a scalar, so W is a single learnable weight.
W = tf.Variable(tf.random_normal((1, 1)))
b = tf.Variable(tf.random_normal((1,)))
with tf.name_scope("prediction"):
y_pred = tf.matmul(x, W) + b
with tf.name_scope("loss"):
l = tf.reduce_sum((y - y_pred)**2)
# Add training op
with tf.name_scope("optim"):
# Set learning rate to .001 as recommended above.
train_op = tf.train.AdamOptimizer(.001).minimize(l)
with tf.name_scope("summaries"):
tf.summary.scalar("loss", l)
merged = tf.summary.merge_all()
train_writer = tf.summary.FileWriter('/tmp/lr-train', tf.get_default_graph())
示例 3-12 然后训练这个模型,如之前讨论的(不使用小批量)。
示例 3-12. 训练线性回归模型
n_steps = 1000
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
# Train model
for i in range(n_steps):
feed_dict = {x: x_np, y: y_np}
_, summary, loss = sess.run([train_op, merged, l], feed_dict=feed_dict)
print("step %d, loss: %f" % (i, loss))
train_writer.add_summary(summary, i)
此示例的所有代码都在与本书相关的GitHub 存储库中提供。我们鼓励所有读者运行线性回归示例的完整脚本,以获得对学习算法如何运行的第一手感觉。这个示例足够小,读者不需要访问任何专用计算硬件来运行。
线性回归的梯度
我们建模的线性系统的方程是y = wx + b,其中w,b是可学习的权重。正如我们之前提到的,这个系统的损失是。一些矩阵微积分可以用来直接计算w的可学习参数的梯度:
对于b
我们将这些方程放在这里,仅供好奇的读者参考。我们不会试图系统地教授如何计算我们在本书中遇到的损失函数的导数。然而,我们将指出,对于复杂系统,通过手工计算损失函数的导数有助于建立对深度网络学习方式的直觉。这种直觉可以作为设计者的强大指导,因此我们鼓励高级读者自行探索这个主题。
使用 TensorBoard 可视化线性回归模型
前一节中定义的模型使用tf.summary.FileWriter
将日志写入日志目录/tmp/lr-train。我们可以使用示例 3-13 中的命令在此日志目录上调用 TensorBoard(TensorBoard 默认与 TensorFlow 一起安装)。
示例 3-13. 调用 TensorBoard
tensorboard --logdir=/tmp/lr-train
此命令将在连接到 localhost 的端口上启动 TensorBoard。使用浏览器打开此端口。TensorBoard 屏幕将类似于图 3-8。 (具体外观可能会因您使用的 TensorBoard 版本而有所不同。)
![tensorboard_lr_raw.png]()
图 3-8. TensorBoard 面板截图。
转到 Graphs 选项卡,您将看到我们定义的 TensorFlow 架构的可视化,如图 3-9 所示。
![lr_graph.png]()
图 3-9. 在 TensorBoard 中可视化线性回归架构。
请注意,此可视化已将属于各种tf.name_scopes
的所有计算图元素分组。不同的组根据计算图中的依赖关系连接。您可以展开所有分组的元素以查看其内容。图 3-10 展示了扩展的架构。
正如您所看到的,有许多隐藏的节点突然变得可见!TensorFlow 的函数,如tf.train.AdamOptimizer
,通常会在它们自己的tf.name_scope
下隐藏许多内部变量。在 TensorBoard 中展开提供了一种简单的方法,可以查看系统实际创建了什么。虽然可视化看起来相当复杂,但大多数细节都是在幕后,您暂时不需要担心。
![lr_expanded.png]()
图 3-10。架构的扩展可视化。
返回主页选项卡并打开摘要部分。现在您应该看到一个类似于图 3-11 的损失曲线。请注意平滑下降的形状。损失在开始时迅速下降,然后逐渐减少并稳定下来。
![lr_loss_tensorboard.png]()
图 3-11。在 TensorBoard 中查看损失曲线。
视觉和非视觉调试风格
像 TensorBoard 这样的工具是否必要才能充分利用像 TensorFlow 这样的系统?这取决于情况。使用 GUI 或交互式调试器是否是成为专业程序员的必要条件?
不同的程序员有不同的风格。有些人会发现 TensorBoard 的可视化能力成为张量编程工作流程中至关重要的一部分。其他人可能会发现 TensorBoard 并不是特别有用,会更多地使用打印语句进行调试。张量编程和调试的这两种风格都是有效的,就像有些优秀的程序员信誓旦旦地使用调试器,而有些则憎恶它们一样。
总的来说,TensorBoard 对于调试和建立对手头数据集的基本直觉非常有用。我们建议您遵循最适合您的风格。
用于评估回归模型的指标
到目前为止,我们还没有讨论如何评估训练模型是否真正学到了东西。评估模型是否训练的第一个工具是查看损失曲线,以确保其具有合理的形状。您在上一节中学习了如何做到这一点。接下来要尝试什么?
现在我们希望您查看与模型相关的指标。指标是用于比较预测标签和真实标签的工具。对于回归问题,有两个常见的指标:R²和 RMSE(均方根误差)。R²是两个变量之间相关性的度量,取值介于+1 和 0 之间。+1 表示完美相关,而 0 表示没有相关性。在数学上,两个数据集X和Y的R²定义如下:
其中 cov(X, Y)是X和Y的协方差,衡量两个数据集共同变化的程度,而和是标准差,衡量每个集合的变化程度。直观地说,R²衡量了每个集合中独立变化的多少可以通过它们的共同变化来解释。
多种类型的 R²!
请注意,实践中有两种常见的R²定义。一个常见的初学者(和专家)错误是混淆这两个定义。在本书中,我们将始终使用平方的皮尔逊相关系数(图 3-12)。另一种定义称为确定系数。这种另一种R²通常更加令人困惑,因为它不像平方的皮尔逊相关系数那样有 0 的下限。
在图 3-12 中,预测值和真实值高度相关,R²接近 1。看起来学习在这个系统上做得很好,并成功学习到了真实规则。不要那么快。您会注意到图中两个轴的刻度不同!原来R²不会因为刻度的差异而受到惩罚。为了理解这个系统发生了什么,我们需要考虑图 3-13 中的另一个度量。
![lr_pred.png]()
图 3-12。绘制皮尔逊相关系数。
![lr_learned.png]()
图 3-13。绘制均方根误差(RMSE)。
RMSE 是预测值和真实值之间平均差异的度量。在图 3-13 中,我们将预测值和真实标签作为两个单独的函数绘制,使用数据点x作为我们的 x 轴。请注意,学习到的线并不是真实函数!RMSE 相对较高,诊断了错误,而R²没有发现这个错误。
这个系统发生了什么?为什么尽管经过训练收敛,TensorFlow 仍然没有学习到正确的函数?这个例子很好地说明了梯度下降算法的一个弱点。不能保证找到真正的解决方案!梯度下降算法可能会陷入局部最小值。也就是说,它可能找到看起来不错的解决方案,但实际上并不是损失函数的最低最小值。
那么为什么要使用梯度下降呢?对于简单的系统,确实往往最好避免梯度下降,而使用其他性能更好的算法。然而,在复杂的系统中,比如我们将在后面的章节中展示的系统,还没有比梯度下降表现更好的替代算法。我们鼓励您记住这一点,因为我们将继续深入学习。
TensorFlow 中的逻辑回归
在本节中,我们将使用 TensorFlow 定义一个简单的分类器。首先考虑分类器的方程是什么。通常使用的数学技巧是利用 S 形函数。S 形函数,在图 3-14 中绘制,通常用表示,是从实数到(0, 1)的函数。这个特性很方便,因为我们可以将 S 形函数的输出解释为事件发生的概率。(将离散事件转换为连续值的技巧是机器学习中的一个常见主题。)
![logistic.gif]()
图 3-14。绘制 S 形函数。
用于预测离散 0/1 变量概率的方程如下。这些方程定义了一个简单的逻辑回归模型:
TensorFlow 提供了用于计算 S 形值的交叉熵损失的实用函数。其中最简单的函数是tf.nn.sigmoid_cross_entropy_with_logits
。(对数几率是 S 形函数的反函数。实际上,这意味着直接将参数传递给 TensorFlow,而不是 S 形值本身)。我们建议使用 TensorFlow 的实现,而不是手动定义交叉熵,因为在计算交叉熵损失时会出现棘手的数值问题。
示例 3-14 在 TensorFlow 中定义了一个简单的逻辑回归模型。
示例 3-14。定义一个简单的逻辑回归模型
# Generate tensorflow graph
with tf.name_scope("placeholders"):
# Note that our datapoints x are 2-dimensional.
x = tf.placeholder(tf.float32, (N, 2))
y = tf.placeholder(tf.float32, (N,))
with tf.name_scope("weights"):
W = tf.Variable(tf.random_normal((2, 1)))
b = tf.Variable(tf.random_normal((1,)))
with tf.name_scope("prediction"):
y_logit = tf.squeeze(tf.matmul(x, W) + b)
# the sigmoid gives the class probability of 1
y_one_prob = tf.sigmoid(y_logit)
# Rounding P(y=1) will give the correct prediction.
y_pred = tf.round(y_one_prob)
with tf.name_scope("loss"):
# Compute the cross-entropy term for each datapoint
entropy = tf.nn.sigmoid_cross_entropy_with_logits(logits=y_logit, labels=y)
# Sum all contributions
l = tf.reduce_sum(entropy)
with tf.name_scope("optim"):
train_op = tf.train.AdamOptimizer(.01).minimize(l)
train_writer = tf.summary.FileWriter('/tmp/logistic-train', tf.get_default_graph())
示例 3-15 中的此模型的训练代码与线性回归模型的代码相同。
示例 3-15。训练逻辑回归模型
n_steps = 1000
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
# Train model
for i in range(n_steps):
feed_dict = {x: x_np, y: y_np}
_, summary, loss = sess.run([train_op, merged, l], feed_dict=feed_dict)
print("loss: %f" % loss)
train_writer.add_summary(summary, i)
使用 TensorBoard 可视化逻辑回归模型
与之前一样,您可以使用 TensorBoard 来可视化模型。首先,像图 3-15 中所示,可视化损失函数。请注意,与以前一样,损失函数遵循一种整齐的模式。损失函数有一个陡峭的下降,然后是逐渐平滑。
![logistic_loss_tensorboard.png]()
图 3-15。可视化逻辑回归损失函数。
您还可以在 TensorBoard 中查看 TensorFlow 图。由于作用域结构类似于线性回归所使用的结构,简化的图显示方式并没有太大不同,如图 3-16 所示。
![logistic_graph.png]()
图 3-16。可视化逻辑回归的计算图。
然而,如果您扩展这个分组图中的节点,就像图 3-17 中所示,您会发现底层的计算图是不同的。特别是,损失函数与线性回归所使用的损失函数有很大不同(这是应该的)。
![logistic_expanded.png]()
图 3-17。逻辑回归的扩展计算图。
评估分类模型的指标
现在您已经为逻辑回归训练了一个分类模型,需要了解适用于评估分类模型的指标。虽然逻辑回归的方程比线性回归的方程复杂,但基本的评估指标更简单。分类准确率只是检查学习模型正确分类的数据点的比例。实际上,稍加努力,就可以推导出逻辑回归模型学习的分隔线。这条线显示了模型学习到的分隔正负示例的边界。(我们将推导这条线从逻辑回归方程中的练习留给感兴趣的读者。解决方案在本节的代码中。)
我们在图 3-18 中显示了学习到的类别和分隔线。请注意,这条线清晰地分隔了正负示例,并且具有完美的准确率(1.0)。这个结果提出了一个有趣的观点。回归通常比分类更难解决。在图 3-18 中,有许多可能的线可以很好地分隔数据点,但只有一条线可以完美地匹配线性回归的数据。
![logistic_pred.png]()
图 3-18。查看逻辑回归的学习类别和分隔线。
回顾
在本章中,我们向您展示了如何在 TensorFlow 中构建和训练一些简单的学习系统。我们首先回顾了一些基础数学概念,包括损失函数和梯度下降。然后,我们向您介绍了一些新的 TensorFlow 概念,如占位符、作用域和 TensorBoard。我们以在玩具数据集上训练线性和逻辑回归系统的案例研究结束了本章。本章涵盖了很多内容,如果您还没有完全掌握,也没关系。本章介绍的基础知识将贯穿本书的其余部分。
在第四章中,我们将向您介绍您的第一个深度学习模型和全连接网络,并向您展示如何在 TensorFlow 中定义和训练全连接网络。在接下来的章节中,我们将探索更复杂的深度网络,但所有这些架构都将使用本章介绍的相同基本学习原则。
第四章:全连接的深度网络
本章将向您介绍全连接的深度网络。全连接网络是深度学习的主力军,用于成千上万的应用。全连接网络的主要优势在于它们是“结构不可知的”。也就是说,不需要对输入做出特殊的假设(例如,输入由图像或视频组成)。我们将利用这种通用性,使用全连接的深度网络来解决本章后面的化学建模问题。
我们简要探讨支撑全连接网络的数学理论。特别是,我们探讨全连接架构是“通用逼近器”,能够学习任何函数的概念。这个概念解释了全连接架构的通用性,但也伴随着我们深入讨论的许多注意事项。
虽然结构不可知使全连接网络非常广泛适用,但这种网络的性能往往比针对问题空间结构调整的专用网络要弱。我们将在本章后面讨论全连接架构的一些限制。
什么是全连接的深度网络?
全连接神经网络由一系列全连接层组成。全连接层是从到的函数。每个输出维度都依赖于每个输入维度。在图 4-1 中,全连接层的图示如下。
![FCLayer.png]()
图 4-1. 深度网络中的全连接层。
让我们更深入地了解全连接网络的数学形式。让表示全连接层的输入。让是全连接层的第 i 个输出。那么的计算如下:
在这里, 是一个非线性函数(暂时将视为前一章介绍的 Sigmoid 函数), 是网络中可学习的参数。完整的输出y如下:
请注意,可以直接堆叠全连接网络。具有多个全连接网络的网络通常被称为“深度”网络,如图 4-2 所示。
![multilayer_fcnet.png]()
图 4-2。一个多层深度全连接网络。
作为一个快速实现的注意事项,注意单个神经元的方程看起来非常类似于两个向量的点积(回想一下张量基础的讨论)。对于一层神经元,通常为了效率目的,将y计算为矩阵乘积是很方便的:
其中 sigma 是一个矩阵在,非线性是逐分量应用的。
全连接网络中的“神经元”
全连接网络中的节点通常被称为“神经元”。因此,在文献中,全连接网络通常被称为“神经网络”。这种命名方式在很大程度上是历史的偶然。
在 1940 年代,沃伦·S·麦卡洛克和沃尔特·皮茨发表了一篇关于大脑的第一个数学模型,认为神经元能够计算布尔量上的任意函数。这项工作的后继者稍微完善了这个逻辑模型,通过使数学“神经元”成为在零和一之间变化的连续函数。如果这些函数的输入足够大,神经元就会“发射”(取值为一),否则就是静止的。通过可调权重的添加,这个描述与之前的方程匹配。
这才是真正的神经元行为吗?当然不是!一个真实的神经元(图 4-3)是一个极其复杂的引擎,拥有超过 100 万亿个原子,以及数以万计的不同信号蛋白质,能够对不同信号做出反应。微处理器比一个一行方程更好地类比于神经元。
![neuron.png]()
图 4-3。神经元的更生物学准确的表示。
在许多方面,生物神经元和人工神经元之间的这种脱节是非常不幸的。未经培训的专家读到令人激动的新闻稿,声称已经创建了拥有数十亿“神经元”的人工神经网络(而大脑只有 1000 亿个生物神经元),并且合理地认为科学家们已经接近创造人类水平的智能。不用说,深度学习的最新技术距离这样的成就还有几十年(甚至几个世纪)的距离。
当您进一步了解深度学习时,您可能会遇到关于人工智能的夸大宣传。不要害怕指出这些声明。目前的深度学习是一套在快速硬件上解决微积分问题的技术。它不是终结者的前身(图 4-4)。
![terminator.png]()
图 4-4。不幸的是(或者也许是幸运的),这本书不会教你如何构建一个终结者!
AI 寒冬
人工智能经历了多轮繁荣和衰退的发展。这种循环性的发展是该领域的特点。每一次学习的新进展都会引发一波乐观情绪,其中预言家声称人类水平(或超人类)的智能即将出现。几年后,没有这样的智能体现出来,失望的资助者退出。由此产生的时期被称为 AI 寒冬。
迄今为止已经有多次 AI 寒冬。作为一种思考练习,我们鼓励您考虑下一次 AI 寒冬将在何时发生。当前的深度学习进展解决了比以往任何一波进步更多的实际问题。AI 是否可能最终脱颖而出,摆脱繁荣和衰退的周期,或者您认为我们很快就会迎来 AI 的“大萧条”?
使用反向传播学习全连接网络
完全连接的神经网络的第一个版本是感知器(图 4-5),由 Frank Rosenblatt 在 1950 年代创建。这些感知器与我们在前面的方程中介绍的“神经元”是相同的。
![perceptron.png]()
图 4-5。感知器的示意图。
感知器是通过自定义的“感知器”规则进行训练的。虽然它们在解决简单问题时有一定用处,但感知器在根本上受到限制。1960 年代末 Marvin Minsky 和 Seymour Papert 的书《感知器》证明了简单感知器无法学习 XOR 函数。图 4-6 说明了这个说法的证明。
![xor2.gif]()
图 4-6。感知器的线性规则无法学习感知器。
这个问题通过多层感知器(另一个称为深度全连接网络的名称)的发明得以解决。这一发明是一个巨大的成就,因为早期的简单学习算法无法有效地学习深度网络。 “信用分配”问题困扰着它们;算法如何决定哪个神经元学习什么?
解决这个问题的完整方法需要反向传播。反向传播是学习神经网络权重的通用规则。不幸的是,关于反向传播的复杂解释在文献中泛滥。这种情况很不幸,因为反向传播只是自动微分的另一个说法。
假设是代表深度全连接网络的函数。这里是完全连接网络的输入,是可学习的权重。然后,反向传播算法简单地计算。在实践中,实现反向传播以处理所有可能出现的f函数的复杂性。幸运的是,TensorFlow 已经为我们处理了这一点!
通用收敛定理
前面的讨论涉及到了深度全连接网络是强大逼近的想法。McCulloch 和 Pitts 表明逻辑网络可以编码(几乎)任何布尔函数。Rosenblatt 的感知器是 McCulloch 和 Pitt 的逻辑函数的连续模拟,但被 Minsky 和 Papert 证明在根本上受到限制。多层感知器试图解决简单感知器的限制,并且在经验上似乎能够学习复杂函数。然而,从理论上讲,尚不清楚这种经验能力是否存在未被发现的限制。 1989 年,George Cybenko 证明了多层感知器能够表示任意函数。这一演示为全连接网络作为学习架构的普遍性主张提供了相当大的支持,部分解释了它们持续受欢迎的原因。
然而,如果在上世纪 80 年代后期人们已经理解了反向传播和全连接网络理论,为什么“深度”学习没有更早变得更受欢迎呢?这种失败的很大一部分是由于计算能力的限制;学习全连接网络需要大量的计算能力。此外,由于对好的超参数缺乏理解,深度网络非常难以训练。因此,计算要求较低的替代学习算法,如 SVM,变得更受欢迎。深度学习近年来的流行部分原因是更好的计算硬件的增加可用性,使计算速度更快,另一部分原因是对能够实现稳定学习的良好训练方案的增加理解。
通用逼近是否令人惊讶?
通用逼近性质在数学中比人们可能期望的更常见。例如,Stone-Weierstrass 定理证明了在闭区间上的任何连续函数都可以是一个合适的多项式函数。进一步放宽我们的标准,泰勒级数和傅里叶级数本身提供了一些通用逼近能力(在它们的收敛域内)。通用收敛在数学中相当常见的事实部分地为经验观察提供了部分理由,即许多略有不同的全连接网络变体似乎具有通用逼近性质。
通用逼近并不意味着通用学习!
通用逼近定理中存在一个关键的微妙之处。全连接网络可以表示任何函数并不意味着反向传播可以学习任何函数!反向传播的一个主要限制是没有保证全连接网络“收敛”;也就是说,找到学习问题的最佳可用解决方案。这个关键的理论差距让几代计算机科学家对神经网络感到不安。即使在今天,许多学者仍然更愿意使用具有更强理论保证的替代算法。
经验研究已经产生了许多实用技巧,使反向传播能够为问题找到好的解决方案。在本章的其余部分中,我们将深入探讨许多这些技巧。对于实践数据科学家来说,通用逼近定理并不是什么需要太认真对待的东西。这是令人放心的,但深度学习的艺术在于掌握使学习有效的实用技巧。
为什么要使用深度网络?
通用逼近定理中的一个微妙之处是,事实上它对只有一个全连接层的全连接网络也成立。那么,具有多个全连接层的“深度”学习有什么用呢?事实证明,这个问题在学术和实践领域仍然颇具争议。
在实践中,似乎更深层的网络有时可以在大型数据集上学习更丰富的模型。(然而,这只是一个经验法则;每个实践者都有许多例子,深度全连接网络表现不佳。)这一观察结果导致研究人员假设更深层的网络可以更有效地表示复杂函数。也就是说,相比具有相同数量的神经元的较浅网络,更深的网络可能能够学习更复杂的函数。例如,在第一章中简要提到的 ResNet 架构,具有 130 层,似乎胜过其较浅的竞争对手,如 AlexNet。一般来说,对于固定的神经元预算,堆叠更深层次会产生更好的结果。
文献中提出了一些关于深度网络优势的错误“证明”,但它们都有漏洞。深度与宽度的问题似乎涉及到复杂性理论中的深刻概念(研究解决给定计算问题所需的最小资源量)。目前看来,理论上证明(或否定)深度网络的优越性远远超出了我们数学家的能力范围。
训练全连接神经网络
正如我们之前提到的,全连接网络的理论与实践有所不同。在本节中,我们将向您介绍一些关于全连接网络的经验观察,这些观察有助于从业者。我们强烈建议您使用我们的代码(在本章后面介绍)来验证我们的说法。
可学习表示
一种思考全连接网络的方式是,每个全连接层都会对问题所在的特征空间进行转换。在工程和物理学中,将问题的表示转换为更易处理的形式的想法是非常古老的。因此,深度学习方法有时被称为“表示学习”。(有趣的事实是,深度学习的一个主要会议被称为“国际学习表示会议”。)
几代分析师已经使用傅立叶变换、勒让德变换、拉普拉斯变换等方法,将复杂的方程和函数简化为更适合手工分析的形式。一种思考深度学习网络的方式是,它们实现了一个适合手头问题的数据驱动转换。
执行特定于问题的转换能力可能非常强大。标准的转换技术无法解决图像或语音分析的问题,而深度网络能够相对轻松地解决这些问题,这是由于学习表示的固有灵活性。这种灵活性是有代价的:深度架构学习到的转换通常比傅立叶变换等数学变换要不那么通用。尽管如此,将深度变换纳入分析工具包中可以成为一个强大的问题解决工具。
有一个合理的观点认为,深度学习只是第一个有效的表示学习方法。将来,可能会有替代的表示学习方法取代深度学习方法。
激活函数
我们之前介绍了非线性函数作为 S 形函数。虽然 S 形函数是全连接网络中的经典非线性,但近年来研究人员发现其他激活函数,特别是修正线性激活(通常缩写为 ReLU 或 relu)比 S 形单元效果更好。这种经验观察可能是由于深度网络中的梯度消失问题。对于 S 形函数,几乎所有输入值的斜率都为零。因此,对于更深的网络,梯度会趋近于零。对于 ReLU 函数,输入空间的大部分部分斜率都不为零,允许非零梯度传播。图 4-7 展示了 S 形和 ReLU 激活函数并排的情况。
![activation-functions.png]()
图 4-7。S 形和 ReLU 激活函数。
全连接网络记忆
全连接网络的一个显著特点是,给定足够的时间,它们倾向于完全记住训练数据。因此,将全连接网络训练到“收敛”实际上并不是一个有意义的度量。只要用户愿意等待,网络将继续训练和学习。
对于足够大的网络,训练损失趋向于零是非常常见的。这一经验观察是全连接网络的通用逼近能力最实用的证明之一。然而,请注意,训练损失趋向于零并不意味着网络已经学会了一个更强大的模型。相反,模型很可能已经开始记忆训练集的怪癖,这些怪癖并不适用于任何其他数据点。
值得深入探讨这里我们所说的奇特之处。高维统计学的一个有趣特性是,给定足够大的数据集,将有大量的虚假相关性和模式可供选择。在实践中,全连接网络完全有能力找到并利用这些虚假相关性。控制网络并防止它们以这种方式行为不端对于建模成功至关重要。
正则化
正则化是一个通用的统计术语,用于限制记忆化,同时促进可泛化的学习。有许多不同类型的正则化可用,我们将在接下来的几节中介绍。
不是您的统计学家的正则化
正则化在统计文献中有着悠久的历史,有许多关于这个主题的论文。不幸的是,只有一部分经典分析适用于深度网络。在统计学中广泛使用的线性模型可能与深度网络表现出截然不同,而在那种情况下建立的许多直觉对于深度网络来说可能是错误的。
与深度网络一起工作的第一条规则,特别是对于具有先前统计建模经验的读者,是相信经验结果胜过过去的直觉。不要假设对于建模深度架构等技术的过去知识有太多意义。相反,建立一个实验来系统地测试您提出的想法。我们将在下一章更深入地讨论这种系统化实验过程。
Dropout
Dropout 是一种正则化形式,它随机地删除一些输入到全连接层的节点的比例(图 4-8)。在这里,删除一个节点意味着其对应激活函数的贡献被设置为 0。由于没有激活贡献,被删除节点的梯度也降为零。
![dropout.png]()
图 4-8。在训练时,Dropout 随机删除网络中的神经元。从经验上看,这种技术通常为网络训练提供强大的正则化。
要删除的节点是在梯度下降的每一步中随机选择的。底层的设计原则是网络将被迫避免“共适应”。简而言之,我们将解释什么是共适应以及它如何在非正则化的深度架构中出现。假设深度网络中的一个神经元学习了一个有用的表示。那么网络中更深层的其他神经元将迅速学会依赖于该特定神经元获取信息。这个过程将使网络变得脆弱,因为网络将过度依赖于该神经元学到的特征,而这些特征可能代表数据集的一个怪癖,而不是学习一个普遍规则。
Dropout 可以防止这种协同适应,因为不再可能依赖于单个强大的神经元的存在(因为该神经元在训练期间可能会随机消失)。因此,其他神经元将被迫“弥补空缺”并学习到有用的表示。理论上的论点是,这个过程应该会产生更强大的学习模型。
在实践中,dropout 有一对经验效果。首先,它防止网络记忆训练数据;使用 dropout 后,即使对于非常大的深度网络,训练损失也不会迅速趋向于 0。其次,dropout 倾向于略微提升模型对新数据的预测能力。这种效果通常适用于各种数据集,这也是 dropout 被认为是一种强大的发明而不仅仅是一个简单的统计技巧的部分原因。
你应该注意,在进行预测时应关闭 dropout。忘记关闭 dropout 可能导致预测比原本更加嘈杂和无用。我们将在本章后面正确讨论如何处理训练和预测中的 dropout。
大型网络如何避免过拟合?
对于传统训练有素的统计学家来说,最令人震惊的一点是,深度网络可能经常具有比训练数据中存在的内部自由度更多的内部自由度。在传统统计学中,这些额外的自由度的存在会使模型变得无用,因为不再存在一个保证模型学到的是“真实”的经典意义上的保证。
那么,一个拥有数百万参数的深度网络如何能够在只有数千个示例的数据集上学习到有意义的结果呢?Dropout 可以在这里起到很大的作用,防止蛮力记忆。但是,即使没有使用 dropout,深度网络也会倾向于学习到有用的事实,这种倾向可能是由于反向传播或全连接网络结构的某种特殊性质,我们尚不理解。
早停止
正如前面提到的,全连接网络往往会记住放在它们面前的任何东西。因此,在实践中,跟踪网络在一个保留的“验证”集上的表现,并在该验证集上的表现开始下降时停止网络,通常是很有用的。这种简单的技术被称为早停止。
在实践中,早停止可能会很棘手。正如你将看到的,深度网络的损失曲线在正常训练过程中可能会有很大的变化。制定一个能够区分健康变化和明显下降趋势的规则可能需要很大的努力。在实践中,许多从业者只是训练具有不同(固定)时代数量的模型,并选择在验证集上表现最好的模型。图 4-9 展示了训练和测试集准确率随着训练进行而通常变化的情况。
![earlystopping.png]()
图 4-9. 训练和测试集的模型准确率随着训练进行而变化。
我们将在接下来的章节中更深入地探讨与验证集一起工作的正确方法。
权重正则化
从统计学文献中借鉴的一种经典正则化技术惩罚那些权重增长较大的学习权重。根据前一章的符号表示,让表示特定模型的损失函数,让表示该模型的可学习参数。那么正则化的损失函数定义如下
其中是权重惩罚,是一个可调参数。惩罚的两种常见选择是L¹和L²惩罚
其中和分别表示L¹和L²的惩罚。从个人经验来看,这些惩罚对于深度模型来说往往不如 dropout 和早停止有用。一些从业者仍然使用权重正则化,因此值得了解如何在调整深度网络时应用这些惩罚。
训练全连接网络
训练全连接网络需要一些技巧,超出了您在本书中迄今为止看到的内容。首先,与之前的章节不同,我们将在更大的数据集上训练模型。对于这些数据集,我们将向您展示如何使用 minibatches 来加速梯度下降。其次,我们将回到调整学习率的话题。
Minibatching
对于大型数据集(甚至可能无法完全装入内存),在每一步计算梯度时无法在整个数据集上进行。相反,从业者通常选择一小部分数据(通常是 50-500 个数据点)并在这些数据点上计算梯度。这小部分数据传统上被称为一个 minibatch。
在实践中,minibatching 似乎有助于收敛,因为可以在相同的计算量下进行更多的梯度下降步骤。minibatch 的正确大小是一个经验性问题,通常通过超参数调整来设置。
学习率
学习率决定了每个梯度下降步骤的重要性。设置正确的学习率可能会有些棘手。许多初学者设置学习率不正确,然后惊讶地发现他们的模型无法学习或开始返回 NaN。随着 ADAM 等方法的发展,这种情况已经得到了显著改善,但如果模型没有学到任何东西,调整学习率仍然是值得的。
在 TensorFlow 中的实现
在这一部分中,我们将向您展示如何在 TensorFlow 中实现一个全连接网络。在这一部分中,我们不需要引入太多新的 TensorFlow 原语,因为我们已经涵盖了大部分所需的基础知识。
安装 DeepChem
在这一部分中,您将使用 DeepChem 机器学习工具链进行实验(完整披露:其中一位作者是 DeepChem 的创始人)。有关 DeepChem 的详细安装说明可以在线找到,但简要地说,通过conda
工具进行的 Anaconda 安装可能是最方便的。
Tox21 数据集
在我们的建模案例研究中,我们将使用一个化学数据集。毒理学家对使用机器学习来预测给定化合物是否有毒非常感兴趣。这个任务非常复杂,因为当今的科学只对人体内发生的代谢过程有有限的了解。然而,生物学家和化学家已经研究出一套有限的实验,可以提供毒性的指示。如果一个化合物在这些实验中是“命中”的,那么人类摄入后可能会有毒。然而,这些实验通常成本很高,因此数据科学家旨在构建能够预测这些实验结果的机器学习模型,用于新分子。
最重要的毒理学数据集之一称为 Tox21。它由 NIH 和 EPA 发布,作为数据科学倡议的一部分,并被用作模型构建挑战中的数据集。这个挑战的获胜者使用了多任务全连接网络(全连接网络的一种变体,其中每个网络为每个数据点预测多个数量)。我们将分析来自 Tox21 集合中的一个数据集。该数据集包含一组经过测试与雄激素受体相互作用的 10,000 种分子。数据科学挑战是预测新分子是否会与雄激素受体相互作用。
处理这个数据集可能有些棘手,因此我们将利用 DeepChem 部分作为 MoleculeNet 数据集收集。DeepChem 将 Tox21 中的每个分子处理为长度为 1024 的比特向量。然后加载数据集只需几个简单的调用到 DeepChem 中(示例 4-1)。
示例 4-1. 加载 Tox21 数据集
import deepchem as dc
_, (train, valid, test), _ = dc.molnet.load_tox21()
train_X, train_y, train_w = train.X, train.y, train.w
valid_X, valid_y, valid_w = valid.X, valid.y, valid.w
test_X, test_y, test_w = test.X, test.y, test.w
这里的 X
变量保存处理过的特征向量,y
保存标签,w
保存示例权重。标签是与雄激素受体相互作用或不相互作用的化合物的二进制 1/0。Tox21 拥有不平衡数据集,其中正例远远少于负例。w
保存建议的每个示例权重,给予正例更多的重视(增加罕见示例的重要性是处理不平衡数据集的常见技术)。为简单起见,我们在训练过程中不使用这些权重。所有这些变量都是 NumPy 数组。
Tox21 拥有比我们这里将要分析的更多数据集,因此我们需要删除与这些额外数据集相关联的标签(示例 4-2)。
示例 4-2. 从 Tox21 中删除额外的数据集
# Remove extra tasks
train_y = train_y[:, 0]
valid_y = valid_y[:, 0]
test_y = test_y[:, 0]
train_w = train_w[:, 0]
valid_w = valid_w[:, 0]
test_w = test_w[:, 0]
接受占位符的小批量
在之前的章节中,我们创建了接受固定大小参数的占位符。在处理小批量数据时,能够输入不同大小的批次通常很方便。假设一个数据集有 947 个元素。那么以小批量大小为 50,最后一个批次将有 47 个元素。这将导致 第三章 中的代码崩溃。幸运的是,TensorFlow 对这种情况有一个简单的解决方法:使用 None
作为占位符的维度参数允许占位符在该维度上接受任意大小的张量(示例 4-3)。
示例 4-3. 定义接受不同大小小批量的占位符
d = 1024
with tf.name_scope("placeholders"):
x = tf.placeholder(tf.float32, (None, d))
y = tf.placeholder(tf.float32, (None,))
注意 d
是 1024,即我们特征向量的维度。
实现隐藏层
实现隐藏层的代码与我们在上一章中看到的用于实现逻辑回归的代码非常相似,如 示例 4-4 所示。
示例 4-4. 定义一个隐藏层
with tf.name_scope("hidden-layer"):
W = tf.Variable(tf.random_normal((d, n_hidden)))
b = tf.Variable(tf.random_normal((n_hidden,)))
x_hidden = tf.nn.relu(tf.matmul(x, W) + b)
我们使用 tf.name_scope
将引入的变量分组在一起。请注意,我们使用全连接层的矩阵形式。我们使用形式 xW 而不是 Wx,以便更方便地处理一次输入的小批量。(作为练习,尝试计算涉及的维度,看看为什么会这样。)最后,我们使用内置的 tf.nn.relu
激活函数应用 ReLU 非线性。
完全连接层的其余代码与上一章中用于逻辑回归的代码非常相似。为了完整起见,我们展示了用于指定网络的完整代码在例 4-5 中使用。作为一个快速提醒,所有模型的完整代码都可以在与本书相关的 GitHub 存储库中找到。我们强烈建议您尝试运行代码。
例 4-5。定义完全连接的架构
with tf.name_scope("placeholders"):
x = tf.placeholder(tf.float32, (None, d))
y = tf.placeholder(tf.float32, (None,))
with tf.name_scope("hidden-layer"):
W = tf.Variable(tf.random_normal((d, n_hidden)))
b = tf.Variable(tf.random_normal((n_hidden,)))
x_hidden = tf.nn.relu(tf.matmul(x, W) + b)
with tf.name_scope("output"):
W = tf.Variable(tf.random_normal((n_hidden, 1)))
b = tf.Variable(tf.random_normal((1,)))
y_logit = tf.matmul(x_hidden, W) + b
# the sigmoid gives the class probability of 1
y_one_prob = tf.sigmoid(y_logit)
# Rounding P(y=1) will give the correct prediction.
y_pred = tf.round(y_one_prob)
with tf.name_scope("loss"):
# Compute the cross-entropy term for each datapoint
y_expand = tf.expand_dims(y, 1)
entropy = tf.nn.sigmoid_cross_entropy_with_logits(logits=y_logit, labels=y_expand)
# Sum all contributions
l = tf.reduce_sum(entropy)
with tf.name_scope("optim"):
train_op = tf.train.AdamOptimizer(learning_rate).minimize(l)
with tf.name_scope("summaries"):
tf.summary.scalar("loss", l)
merged = tf.summary.merge_all()
向隐藏层添加 dropout
TensorFlow 负责为我们实现 dropout,内置原语tf.nn.dropout(x, keep_prob)
,其中keep_prob
是保留任何给定节点的概率。回想一下我们之前的讨论,我们希望在训练时打开 dropout,在进行预测时关闭 dropout。为了正确处理这一点,我们将引入一个新的占位符keep_prob
,如例 4-6 所示。
例 4-6。为丢失概率添加一个占位符
keep_prob = tf.placeholder(tf.float32)
在训练期间,我们传入所需的值,通常为 0.5,但在测试时,我们将keep_prob
设置为 1.0,因为我们希望使用所有学习节点进行预测。通过这种设置,在前一节中指定的完全连接网络中添加 dropout 只是一行额外的代码(例 4-7)。
例 4-7。定义一个带有 dropout 的隐藏层
with tf.name_scope("hidden-layer"):
W = tf.Variable(tf.random_normal((d, n_hidden)))
b = tf.Variable(tf.random_normal((n_hidden,)))
x_hidden = tf.nn.relu(tf.matmul(x, W) + b)
# Apply dropout
x_hidden = tf.nn.dropout(x_hidden, keep_prob)
实现小批量处理
为了实现小批量处理,我们需要在每次调用sess.run
时提取一个小批量的数据。幸运的是,我们的特征和标签已经是 NumPy 数组,我们可以利用 NumPy 对数组的方便语法来切片数组的部分(例 4-8)。
例 4-8。在小批量上进行训练
step = 0
for epoch in range(n_epochs):
pos = 0
while pos < N:
batch_X = train_X[pos:pos+batch_size]
batch_y = train_y[pos:pos+batch_size]
feed_dict = {x: batch_X, y: batch_y, keep_prob: dropout_prob}
_, summary, loss = sess.run([train_op, merged, l], feed_dict=feed_dict)
print("epoch %d, step %d, loss: %f" % (epoch, step, loss))
train_writer.add_summary(summary, step)
step += 1
pos += batch_size
评估模型准确性
为了评估模型的准确性,标准做法要求在未用于训练的数据上测量模型的准确性(即验证集)。然而,数据不平衡使这一点变得棘手。我们在上一章中使用的分类准确度指标简单地衡量了被正确标记的数据点的比例。然而,我们数据集中 95%的数据被标记为 0,只有 5%被标记为 1。因此,全 0 模型(将所有内容标记为负面的模型)将实现 95%的准确性!这不是我们想要的。
更好的选择是增加正例的权重,使其更重要。为此,我们使用 MoleculeNet 推荐的每个示例权重来计算加权分类准确性,其中正样本的权重是负样本的 19 倍。在这种加权准确性下,全 0 模型的准确率将达到 50%,这似乎更为合理。
对于计算加权准确性,我们使用sklearn.metrics
中的函数accuracy_score(true, pred, sample_weight=given_sample_weight
。这个函数有一个关键字参数sample_weight
,让我们可以为每个数据点指定所需的权重。我们使用这个函数在训练集和验证集上计算加权指标(例 4-9)。
例 4-9。计算加权准确性
train_weighted_score = accuracy_score(train_y, train_y_pred, sample_weight=train_w)
print("Train Weighted Classification Accuracy: %f" % train_weighted_score)
valid_weighted_score = accuracy_score(valid_y, valid_y_pred, sample_weight=valid_w)
print("Valid Weighted Classification Accuracy: %f" % valid_weighted_score)
虽然我们可以自己重新实现这个函数,但有时使用 Python 数据科学基础设施中的标准函数会更容易(并且更少出错)。了解这种基础设施和可用函数是作为一名实践数据科学家的一部分。现在,我们可以训练模型(在默认设置下进行 10 个时期)并评估其准确性:
Train Weighted Classification Accuracy: 0.742045
Valid Weighted Classification Accuracy: 0.648828
在第五章中,我们将向您展示系统地提高这种准确性的方法,并更仔细地调整我们的完全连接模型。
使用 TensorBoard 跟踪模型收敛
现在我们已经指定了我们的模型,让我们使用 TensorBoard 来检查模型。让我们首先在 TensorBoard 中检查图结构(图 4-10)。
该图与逻辑回归的图类似,只是增加了一个新的隐藏层。让我们扩展隐藏层,看看里面有什么(图 4-11)。
![fcgraph.png]()
图 4-10。可视化全连接网络的计算图。
![hidden_expand.png]()
图 4-11。可视化全连接网络的扩展计算图。
您可以看到这里如何表示新的可训练变量和 dropout 操作。一切看起来都在正确的位置。让我们通过查看随时间变化的损失曲线来结束(图 4-12)。
![fcnet_loss_curve.png]()
图 4-12。可视化全连接网络的损失曲线。
正如我们在前一节中看到的那样,损失曲线呈下降趋势。但是,让我们放大一下,看看这个损失在近距离下是什么样子的(图 4-13)。
![fcnet_zoomed_loss.png]()
图 4-13。放大损失曲线的一部分。
请注意,损失看起来更加崎岖!这是使用小批量训练的代价之一。我们不再拥有在前几节中看到的漂亮、平滑的损失曲线。
回顾
在本章中,我们向您介绍了全连接深度网络。我们深入研究了这些网络的数学理论,并探讨了“通用逼近”的概念,这在一定程度上解释了全连接网络的学习能力。我们以一个案例研究结束,您在该案例中训练了一个深度全连接架构的 Tox21 数据集。
在本章中,我们还没有向您展示如何调整全连接网络以实现良好的预测性能。在第五章中,我们将讨论“超参数优化”,即调整网络参数的过程,并让您调整本章介绍的 Tox21 网络的参数。
第五章:超参数优化
训练一个深度模型和训练一个好的深度模型是非常不同的事情。虽然从互联网上复制粘贴一些 TensorFlow 代码以运行第一个原型是很容易的,但要将该原型转变为高质量模型则更加困难。将原型转变为高质量模型的过程涉及许多步骤。我们将在本章的其余部分探讨其中一个步骤,即超参数优化。
首次近似地说,超参数优化是调整模型中所有不是通过梯度下降学习的参数的过程。这些量被称为“超参数”。考虑一下前一章中的全连接网络。虽然全连接网络的权重可以从数据中学习,但网络的其他设置不能。这些超参数包括隐藏层的数量、每个隐藏层的神经元数量、学习率等。如何系统地找到这些量的良好值?超参数优化方法为我们提供了这个问题的答案。
回想一下我们之前提到过,模型性能是在一个保留的“验证”集上进行跟踪的。超参数优化方法系统地在验证集上尝试多种超参数选择。表现最佳的超参数值集合然后在第二个保留的“测试”集上进行评估,以衡量真实的模型性能。不同的超参数优化方法在它们用来提出新的超参数设置的算法上有所不同。这些算法从显而易见的到相当复杂的不等。在这些章节中,我们只会涵盖一些较简单的方法,因为更复杂的超参数优化技术往往需要大量的计算能力。
作为一个案例研究,我们将调整第四章中介绍的 Tox21 毒性全连接网络,以获得良好的性能。我们强烈鼓励您(一如既往)使用与本书相关的GitHub 存储库中的代码自行运行超参数优化方法。
超参数优化不仅适用于深度网络!
值得强调的是,超参数优化不仅适用于深度网络。大多数形式的机器学习算法都有无法通过默认学习方法学习的参数。这些参数也被称为超参数。在本章的后面部分,您将看到随机森林(另一种常见的机器学习方法)的一些超参数示例。
然而值得注意的是,深度网络往往对超参数选择更为敏感,而不同于其他算法。虽然随机森林可能会因为超参数的默认选择而表现稍差,但深度网络可能完全无法学习。因此,掌握超参数优化是一名潜在的深度学习者的关键技能。
模型评估和超参数优化
在之前的章节中,我们只是简要地讨论了如何判断一个机器学习模型是否好。任何模型性能的测量都必须评估模型的泛化能力。也就是说,模型能否对它从未见过的数据点进行预测?模型性能的最佳测试是创建一个模型,然后在模型构建之后可用的数据上进行前瞻性评估。然而,这种测试方式通常难以定期进行。在设计阶段,一名实践数据科学家可能希望评估许多不同类型的模型或学习算法,以找到最佳的那个。
解决这一困境的方法是将可用数据集的一部分作为验证集“保留”。这个验证集将用于衡量不同模型的性能(具有不同的超参数选择)。最好还要有第二个保留集,即测试集,用于评估超参数选择方法选择的最终模型的性能。
假设您有一百个数据点。一个简单的程序是使用其中 80 个数据点来训练潜在模型,使用 20 个保留的数据点来验证模型选择。然后可以通过模型在保留的 20 个数据点上的“分数”来跟踪所提出模型的“好坏”。通过提出新设计并仅接受那些在保留集上表现更好的模型,可以逐步改进模型。
然而,在实践中,这个过程会导致过拟合。从业者很快会了解保留集的特殊性,并调整模型结构以在保留集上人为提高分数。为了应对这一问题,从业者通常将保留集分为两部分:一部分用于超参数验证,另一部分用于最终模型验证。在这种情况下,假设您保留了 10 个数据点用于验证,另外 10 个用于最终测试。这将被称为 80/10/10 数据分割。
为什么测试集是必要的?
值得注意的一个重要观点是,超参数优化方法本身就是一种学习算法形式。特别是,它们是一种用于设置不易通过基于微积分的分析处理的不可微量的学习算法。超参数学习算法的“训练集”就是保留的验证集。
总的来说,在训练集上衡量模型性能并没有太多意义。与往常一样,学到的量必须具有泛化性,因此有必要在不同的集合上测试性能。由于训练集用于基于梯度的学习,验证集用于超参数学习,因此测试集是必要的,以评估学到的超参数在新数据上的泛化能力。
黑盒学习算法
黑盒学习算法假设它们试图优化的系统没有结构信息。大多数超参数方法都是黑盒的;它们适用于任何类型的深度学习或机器学习算法。
总的来说,黑盒方法不像白盒方法(如梯度下降)那样具有良好的可扩展性,因为它们往往会在高维空间中迷失。由于黑盒方法缺乏来自梯度的方向信息,它们甚至可能在 50 维空间中迷失(在实践中优化 50 个超参数是相当具有挑战性的)。
要理解为什么,假设有 50 个超参数,每个超参数有 3 个潜在值。那么黑盒算法必须盲目搜索一个大小为的空间。这是可以做到的,但通常需要大量的计算能力。
度量,度量,度量。
在选择超参数时,您希望选择那些使您设计的模型更准确的超参数。在机器学习中,度量是一个函数,用于衡量经过训练模型的预测准确性。超参数优化是为了优化使度量在验证集上最大化(或最小化)的超参数。虽然这一听起来很简单,但准确性的概念实际上可能相当微妙。假设您有一个二元分类器。是更重要的是永远不要将假样本误标为真样本,还是永远不要将真样本误标为假样本?如何选择满足应用需求的模型超参数?
答案是选择正确的指标。在本节中,我们将讨论许多不同的分类和回归问题的指标。我们将评论每个指标强调的特点。没有最佳指标,但对于不同的应用程序,有更合适和不太合适的指标。
指标不能替代常识!
指标是非常盲目的。它们只优化一个数量。因此,盲目优化指标可能导致完全不合适的结果。在网络上,媒体网站经常选择优化“用户点击”这一指标。然后,一些有抱负的年轻记者或广告商意识到像“当 X 发生时,您绝对不会相信发生了什么”这样的标题会导致用户点击的比例更高。于是,点击诱饵诞生了。虽然点击诱饵标题确实会诱使读者点击,但它们也会让读者失去兴趣,并导致他们避免在充斥着点击诱饵的网站上花费时间。优化用户点击导致用户参与度和信任度下降。
这里的教训是普遍的。优化一个指标往往会以另一个数量为代价。确保您希望优化的数量确实是“正确”的数量。机器学习似乎仍然需要人类判断,这是不是很有趣呢?
二元分类指标
在介绍二元分类模型的指标之前,我们认为您会发现学习一些辅助量是有用的。当二元分类器对一组数据点进行预测时,您可以将所有这些预测分为四类之一(表 5-1)。
表 5-1. 预测类别
类别 |
含义 |
真阳性(TP) |
预测为真,标签为真 |
假阳性(FP) |
预测为真,标签为假 |
真阴性(TN) |
预测为假,标签为假 |
假阴性(FN) |
预测为假,标签为真 |
我们还将介绍表 5-2 中显示的符号。
表 5-2. 正负
一般来说,最小化假阳性和假阴性的数量是非常可取的。然而,对于任何给定的数据集,通常由于信号的限制,往往不可能同时最小化假阳性和假阴性。因此,有各种指标提供假阳性和假阴性之间的各种权衡。这些权衡对于应用程序可能非常重要。假设您正在设计乳腺癌的医学诊断。那么,将一个健康患者标记为患有乳腺癌将是一个假阳性。将一个乳腺癌患者标记为没有这种疾病将是一个假阴性。这两种结果都是不可取的,设计正确的平衡是生物伦理学中一个棘手的问题。
我们将展示一些不同的指标,平衡不同比例的假阳性和假阴性(表 5-3)。每个比例都优化了不同的平衡,我们将更详细地探讨其中一些。
表 5-3. 二元指标表
指标 |
定义 |
准确率 |
(TP + TN)/(P + N) |
精确率 |
TP/(TP + FP) |
召回率 |
TP/(TP + FN) = TP/P |
特异性 |
TN/(FP + TN) = TN/N |
假阳性率(FPR) |
FP/(FP + TN) = FP/N |
假阴性率(FNR) |
FN/(TP + FN) = FN/P |
准确率是最简单的指标。它简单地计算分类器正确预测的比例。在简单的应用中,准确率应该是从业者首选的指标。在准确率之后,精确度和召回率是最常测量的指标。精确度简单地衡量了被预测为正类的数据点实际上是正类的比例。召回率则衡量了分类器标记为正类的正类标记数据点的比例。特异度衡量了被正确分类的负类标记数据点的比例。假阳率衡量了被错误分类为正类的负类标记数据点的比例。假阴率是被错误标记为负类的正类标记数据点的比例。
这些指标强调分类器性能的不同方面。它们还可以用于构建一些更复杂的二元分类器性能测量。例如,假设您的二元分类器输出类别概率,而不仅仅是原始预测。那么,就会出现选择截断的问题。也就是说,在什么正类概率下您将输出标记为实际正类?最常见的答案是 0.5,但通过选择更高或更低的截断,通常可以手动调整精确度、召回率、FPR 和 TPR 之间的平衡。这些权衡通常以图形方式表示。
接收器操作特征曲线(ROC)绘制了真正率和假正率之间的权衡,随着截断概率的变化(参见图 5-1)。
![roc_intro3.png]()
图 5-1。接收器操作特征曲线(ROC)。
接收器操作特征曲线(ROC-AUC)下的曲线下面积(AUC)是一个常用的指标。ROC-AUC 指标很有用,因为它提供了二元分类器在所有截断选择下的全局图像。一个完美的指标将具有 ROC-AUC 1.0,因为真正率将始终被最大化。作为比较,一个随机分类器将具有 ROC-AUC 0.5。ROC-AUC 在不平衡数据集中通常很有用,因为全局视图部分考虑了数据集中的不平衡。
多类别分类指标
许多常见的机器学习任务需要模型输出不仅仅是二元分类标签。例如,ImageNet 挑战(ILSVRC)要求参赛者构建能够识别提供图像中的一千个潜在对象类别中的哪一个的模型。或者在一个更简单的例子中,也许您想要预测明天的天气,提供的类别是“晴天”、“下雨”和“多云”。如何衡量这种模型的性能?
最简单的方法是使用准确率的直接泛化,它衡量了被分类器正确标记的数据点的比例(表 5-4)。
表 5-4。多类别分类指标
我们注意到确实存在诸如精确度、召回率和 ROC-AUC 等数量的多类别泛化,并鼓励您在感兴趣的情况下查阅这些定义。在实践中,有一个更简单的可视化方法,即混淆矩阵,它效果很好。对于一个具有k个类别的多类别问题,混淆矩阵是一个k×k的矩阵。(i, j)-th 单元格表示被标记为类别i且真实标签为类别j的数据点的数量。图 5-2 展示了一个混淆矩阵。
![confusion_matrix.png]()
图 5-2。一个 10 类分类器的混淆矩阵。
不要低估人眼从简单可视化中捕捉到系统性失败模式的能力!查看混淆矩阵可以快速理解许多更复杂的多类别指标可能忽略的内容。
回归指标
您在几章前学习了回归指标。简要回顾一下,皮尔逊R²和 RMSE(均方根误差)是很好的默认值。
我们之前只简要介绍了* R ²的数学定义,但现在将更深入地探讨它。让x i代表预测值,y i代表标签。让x ¯和y ¯分别代表预测值和标签的平均值。那么皮尔逊R*(注意没有平方)是
R = ∑((xi - x ¯)(yi - y ¯))/(√(∑(xi - x ¯)²)√(∑(yi - y ¯)²))
这个方程可以重写为
R = cov(x,y)/(σ(x)σ(y))
其中 cov 代表协方差,σ代表标准差。直观地说,皮尔逊R度量了预测值和标签从它们的平均值归一化的联合波动。如果预测值和标签不同,这些波动将发生在不同点,并且倾向于抵消,使R²变小。如果预测值和标签趋于一致,波动将一起发生,并使R²变大。我们注意到R²限制在 0 到 1 之间的范围。
RMSE 度量了预测值和真实值之间的误差的绝对量。它代表均方根误差,大致类似于真实数量和预测数量之间的误差的绝对值。从数学上讲,RMSE 定义如下(使用与之前相同的符号):
均方根误差(RMSE)= √(∑(xi - yi)² / N)
超参数优化算法
正如我们在本章前面提到的,超参数优化方法是用于在验证集上找到优化所选指标的超参数值的学习算法。一般来说,这个目标函数是不可微分的,因此任何优化方法必须是一个黑盒。在本节中,我们将向您展示一些简单的黑盒学习算法,用于选择超参数值。我们将使用来自第四章的 Tox21 数据集作为案例研究,以演示这些黑盒优化方法。Tox21 数据集足够小,使实验变得容易,但足够复杂,使超参数优化并不是微不足道的。
在启动之前,我们注意到这些黑盒算法都不是完美的。很快你会看到,在实践中,需要大量人为输入来优化超参数。
超参数优化不能自动化吗?
机器学习的一个长期梦想是自动选择模型的超参数。诸如“自动统计学家”等项目一直致力于消除超参数选择过程中的一些繁琐工作,并使模型构建更容易为非专家所掌握。然而,在实践中,通常为了增加便利性而付出了性能的巨大代价。
近年来,有大量的工作集中在改进模型调整的算法基础上。高斯过程、进化算法和强化学习都被用来学习模型的超参数和架构,几乎没有人为输入。最近的研究表明,借助大量的计算能力,这些算法可以超越专家在模型调整方面的表现!但是开销很大,需要数十到数百倍的计算能力。
目前,自动模型调整仍然不太实用。本节中涵盖的所有算法都需要大量手动调整。然而,随着硬件质量的提高,我们预计超参数学习将变得越来越自动化。在短期内,我们强烈建议所有从业者掌握超参数调整的复杂性。精通超参数调整是区分专家和新手的技能。
建立一个基准线
超参数调整的第一步是找到一个基准线。基准线是由一个强大的(通常非深度学习)算法可以实现的性能。一般来说,随机森林是设置基准线的绝佳选择。如图 5-3 所示,随机森林是一种集成方法,它在输入数据和输入特征的子集上训练许多决策树模型。这些个体树然后对结果进行投票。
![random_forest_new2.png]()
图 5-3。随机森林的示意图。这里 v 是输入特征向量。
随机森林往往是相当强大的模型。它们对噪声具有容忍性,不担心其输入特征的规模。(虽然对于 Tox21 我们不必担心这一点,因为我们所有的特征都是二进制的,但一般来说,深度网络对其输入范围非常敏感。为了获得良好的性能,最好对输入范围进行归一化或缩放。我们将在后面的章节中回到这一点。)它们还倾向于具有强大的泛化能力,不需要太多的超参数调整。对于某些数据集,要想用深度网络超越随机森林的性能可能需要相当大的复杂性。
我们如何创建和训练一个随机森林?幸运的是,在 Python 中,scikit-learn 库提供了一个高质量的随机森林实现。有许多关于 scikit-learn 的教程和介绍,所以我们只会展示构建 Tox21 随机森林模型所需的训练和预测代码(示例 5-1)。
示例 5-1。在 Tox21 数据集上定义和训练一个随机森林
from sklearn.ensemble import RandomForestClassifier
# Generate tensorflow graph
sklearn_model = RandomForestClassifier(
class_weight="balanced", n_estimators=50)
print("About to fit model on training set.")
sklearn_model.fit(train_X, train_y)
train_y_pred = sklearn_model.predict(train_X)
valid_y_pred = sklearn_model.predict(valid_X)
test_y_pred = sklearn_model.predict(test_X)
weighted_score = accuracy_score(train_y, train_y_pred, sample_weight=train_w)
print("Weighted train Classification Accuracy: %f" % weighted_score)
weighted_score = accuracy_score(valid_y, valid_y_pred, sample_weight=valid_w)
print("Weighted valid Classification Accuracy: %f" % weighted_score)
weighted_score = accuracy_score(test_y, test_y_pred, sample_weight=test_w)
print("Weighted test Classification Accuracy: %f" % weighted_score)
这里的train_X
,train_y
等是在上一章中定义的 Tox21 数据集。回想一下,所有这些量都是 NumPy 数组。n_estimators
指的是我们森林中的决策树数量。设置 50 或 100 棵树通常会提供良好的性能。Scikit-learn 提供了一个简单的面向对象的 API,具有fit(X, y)
和predict(X)
方法。该模型根据我们的加权准确性指标实现了以下准确性:
Weighted train Classification Accuracy: 0.989845
Weighted valid Classification Accuracy: 0.681413
回想一下,来自第四章的全连接网络取得了良好的性能:
Train Weighted Classification Accuracy: 0.742045
Valid Weighted Classification Accuracy: 0.648828
看起来我们的基线比我们的深度学习模型获得了更高的准确性!是时候卷起袖子开始工作了。
研究生下降
尝试好的超参数的最简单方法是手动尝试多种不同的超参数变体,看看哪种有效。这种策略可能会出奇地有效和有教育意义。深度学习从业者需要建立对深度网络结构的直觉。鉴于理论的非常薄弱,经验性工作是学习如何构建深度学习模型的最佳方法。我们强烈建议尝试许多不同的全连接模型变体。要有系统性;在电子表格中记录您的选择和结果,并系统地探索空间。尝试理解各种超参数的影响。哪些使网络训练进行得更快,哪些使其变慢?哪些设置范围完全破坏了学习?(这些很容易找到,不幸的是。)
有一些软件工程技巧可以使这种搜索更容易。创建一个函数,其参数是您希望探索的超参数,并让其打印出准确性。然后尝试新的超参数组合只需要一个函数调用。示例 5-2 展示了这个函数签名在 Tox21 案例研究中的全连接网络中会是什么样子。
示例 5-2。将超参数映射到不同的 Tox21 全连接网络的函数
def eval_tox21_hyperparams(n_hidden=50, n_layers=1, learning_rate=.001,
dropout_prob=0.5, n_epochs=45, batch_size=100,
weight_positives=True):
让我们逐个讨论这些超参数。n_hidden
控制网络中每个隐藏层中的神经元数量。n_layers
控制隐藏层的数量。learning_rate
控制梯度下降中使用的学习率,dropout_prob
是训练步骤中不丢弃神经元的概率。n_epochs
控制通过总数据的次数,batch_size
控制每个批次中的数据点数量。
weight_positives
是这里唯一的新超参数。对于不平衡的数据集,通常有助于对两类示例进行加权,使它们具有相等的权重。对于 Tox21 数据集,DeepChem 为我们提供了要使用的权重。我们只需将每个示例的交叉熵项乘以权重以执行此加权(示例 5-3)。
示例 5-3。对 Tox21 加权正样本
entropy = tf.nn.sigmoid_cross_entropy_with_logits(logits=y_logit, labels=y_expand)
# Multiply by weights
if weight_positives:
w_expand = tf.expand_dims(w, 1)
entropy = w_expand * entropy
为什么选择超参数值的方法被称为研究生下降?直到最近,机器学习一直是一个主要的学术领域。设计新的机器学习算法的经过考验的方法是描述所需的方法给一个新的研究生,并要求他们解决细节。这个过程有点像一种仪式,通常需要学生痛苦地尝试许多设计替代方案。总的来说,这是一个非常有教育意义的经历,因为获得设计美学的唯一方法是建立起一个记忆工作和不工作的设置。
网格搜索
在尝试了一些超参数的手动设置之后,这个过程将开始变得非常乏味。有经验的程序员往往会诱惑简单地编写一个for
循环,迭代所需的超参数选择。这个过程更多或少是网格搜索方法。对于每个超参数,选择一个可能是好的超参数的值列表。编写一个嵌套的for
循环,尝试所有这些值的组合以找到它们的验证准确性,并跟踪最佳表现者。
然而,这个过程中有一个微妙之处。深度网络对用于初始化网络的随机种子的选择非常敏感。因此,值得重复每个超参数设置的选择多次,并对结果进行平均以减少方差。
如示例 5-4 所示,执行此操作的代码很简单。
示例 5-4。对 Tox21 完全连接网络超参数进行网格搜索
scores = {}
n_reps = 3
hidden_sizes = [50]
epochs = [10]
dropouts = [.5, 1.0]
num_layers = [1, 2]
for rep in range(n_reps):
for n_epochs in epochs:
for hidden_size in hidden_sizes:
for dropout in dropouts:
for n_layers in num_layers:
score = eval_tox21_hyperparams(n_hidden=hidden_size, n_epochs=n_epochs,
dropout_prob=dropout, n_layers=n_layers)
if (hidden_size, n_epochs, dropout, n_layers) not in scores:
scores[(hidden_size, n_epochs, dropout, n_layers)] = []
scores[(hidden_size, n_epochs, dropout, n_layers)].append(score)
print("All Scores")
print(scores)
avg_scores = {}
for params, param_scores in scores.iteritems():
avg_scores[params] = np.mean(np.array(param_scores))
print("Scores Averaged over %d repetitions" % n_reps)
随机超参数搜索
对于有经验的从业者来说,往往会很诱人地重复使用在以前的应用中有效的神奇超参数设置或搜索网格。这些设置可能很有价值,但也可能导致我们走入歧途。每个机器学习问题都略有不同,最佳设置可能位于我们以前未考虑的参数空间的某个区域。因此,尝试超参数的随机设置(其中随机值是从一个合理范围内选择的)通常是值得的。
尝试随机搜索还有一个更深层次的原因。在高维空间中,常规网格可能会错过很多信息,特别是如果网格点之间的间距不大的话。选择网格点的随机选择可以帮助我们避免陷入松散网格的陷阱。图 5-4 说明了这一事实。
![random_grid.png]()
图 5-4。说明为什么随机超参数搜索可能优于网格搜索。
我们如何在软件中实现随机超参数搜索?一个巧妙的软件技巧是预先抽样所需的随机值并将其存储在列表中。然后,随机超参数搜索简单地变成了对这些随机抽样列表进行网格搜索。这里有一个例子。对于学习率,通常很有用的是尝试从.1 到.000001 等范围内的广泛范围。示例 5-5 使用 NumPy 来抽样一些随机学习率。
示例 5-5。对学习率进行随机抽样
n_rates = 5
learning_rates = 10**(-np.random.uniform(low=1, high=6, size=n_rates))
我们在这里使用了一个数学技巧。请注意,.1 = 10^(-1),.000001 = 10^(-6)。使用np.random.uniform
在 1 和 6 之间对实值进行抽样很容易。我们可以将这些抽样值提升到一个幂以恢复我们的学习率。然后learning_rates
保存了一个值列表,我们可以将其输入到前一节的网格搜索代码中。
读者的挑战
在本章中,我们只涵盖了超参数调整的基础知识,但所涵盖的工具非常强大。作为挑战,尝试调整完全连接的深度网络,以实现高于随机森林的验证性能。这可能需要一些工作,但这个经验是非常值得的。
回顾
在本章中,我们介绍了超参数优化的基础知识,即选择模型参数的值,这些值无法在训练数据上自动学习。特别是,我们介绍了随机和网格超参数搜索,并演示了在上一章中介绍的 Tox21 数据集上优化模型的代码的使用。
在第六章中,我们将回顾深度架构,并向您介绍卷积神经网络,这是现代深度架构的基本构建块之一。
第六章:卷积神经网络
卷积神经网络允许深度网络学习结构化空间数据(如图像、视频和文本)上的函数。从数学上讲,卷积网络提供了有效利用数据局部结构的工具。图像满足某些自然的统计特性。让我们假设将图像表示为像素的二维网格。在像素网格中彼此接近的图像部分很可能一起变化(例如,图像中对应桌子的所有像素可能都是棕色的)。卷积网络学会利用这种自然的协方差结构以有效地学习。
卷积网络是一个相对古老的发明。卷积网络的版本早在上世纪 80 年代就在文献中提出过。虽然这些旧卷积网络的设计通常相当合理,但它们需要超过当时可用硬件的资源。因此,卷积网络在研究文献中相对默默无闻。
这一趋势在 2012 年 ILSVRC 挑战赛中戏剧性地逆转,该挑战赛是关于图像中物体检测的,卷积网络 AlexNet 实现的错误率是其最近竞争对手的一半。AlexNet 能够利用 GPU 在大规模数据集上训练旧的卷积架构。这种旧架构与新硬件的结合使得 AlexNet 能够在图像物体检测领域显著超越现有技术。这一趋势仅在继续,卷积神经网络在处理图像方面取得了巨大的提升。几乎可以说,现代几乎所有的图像处理流程现在都由卷积神经网络驱动。
卷积网络设计也经历了复兴,将卷积网络推进到远远超过上世纪 80 年代基本模型的水平。首先,网络变得更加深层,强大的最新网络达到了数百层深度。另一个广泛的趋势是将卷积架构泛化到适用于新数据类型。例如,图卷积架构允许卷积网络应用于分子数据,如我们在前几章中遇到的 Tox21 数据集!卷积架构还在基因组学、文本处理甚至语言翻译中留下了痕迹。
在本章中,我们将介绍卷积网络的基本概念。这些将包括构成卷积架构的基本网络组件,以及指导这些组件如何连接的设计原则的介绍。我们还将提供一个深入的示例,演示如何使用 TensorFlow 训练卷积网络。本章的示例代码改编自 TensorFlow 文档中有关卷积神经网络的教程。如果您对我们所做的更改感兴趣,请访问 TensorFlow 网站上的原始教程。与往常一样,我们鼓励您在本书的相关GitHub 存储库中逐步完成本章的脚本。
卷积架构简介
大多数卷积架构由许多基本原语组成。这些原语包括卷积层和池化层等层。还有一组相关的词汇,包括局部感受野大小、步幅大小和滤波器数量。在本节中,我们将简要介绍卷积网络基本词汇和概念的基础。
局部感受野
局部感受野的概念源自神经科学,神经元的感受野是影响神经元放电的身体感知部分。神经元在处理大脑看到的感官输入时有一定的“视野”。这个视野传统上被称为局部感受野。这个“视野”可以对应于皮肤的一小块或者一个人的视野的一部分。图 6-1 展示了一个神经元的局部感受野。
![neuron_receptive_field.png]()
图 6-1. 一个神经元的局部感受野的插图。
卷积架构借用了这个概念,计算概念上的“局部感受野”。图 6-2 提供了应用于图像数据的局部感受野概念的图示表示。每个局部感受野对应于图像中的一组像素,并由一个单独的“神经元”处理。这些“神经元”与全连接层中的神经元直接类似。与全连接层一样,对传入数据(源自局部感受图像补丁)应用非线性变换。
![local_receiptive_input.png]()
图 6-2. 卷积网络中“神经元”的局部感受野(RF)。
这样的“卷积神经元”层可以组合成一个卷积层。这一层可以被看作是一个空间区域到另一个空间区域的转换。在图像的情况下,一个批次的图像通过卷积层被转换成另一个。图 6-3 展示了这样的转换。在接下来的部分,我们将向您展示卷积层是如何构建的更多细节。
![conv_receptive.png]()
图 6-3. 一个卷积层执行图像转换。
值得强调的是,局部感受野不一定局限于图像数据。例如,在堆叠的卷积架构中,其中一个卷积层的输出馈送到下一个卷积层的输入,局部感受野将对应于处理过的特征数据的“补丁”。
卷积核
在上一节中,我们提到卷积层对其输入中的局部感受野应用非线性函数。这种局部应用的非线性是卷积架构的核心,但不是唯一的部分。卷积的第二部分是所谓的“卷积核”。卷积核只是一个权重矩阵,类似于与全连接层相关联的权重。图 6-4 以图解的方式展示了卷积核如何应用到输入上。
![sliding_kernal.png]()
图 6-4. 一个卷积核被应用到输入上。卷积核的权重与局部感受野中对应的数字逐元素相乘,相乘的数字相加。请注意,这对应于一个没有非线性的卷积层。
卷积网络背后的关键思想是相同的(非线性)转换应用于图像中的每个局部感受野。在视觉上,将局部感受野想象成在图像上拖动的滑动窗口。在每个局部感受野的位置,非线性函数被应用以返回与该图像补丁对应的单个数字。正如图 6-4 所示,这种转换将一个数字网格转换为另一个数字网格。对于图像数据,通常以每个感受野大小的像素数来标记局部感受野的大小。例如,在卷积网络中经常看到 5×5 和 7×7 的局部感受野大小。
如果我们想要指定局部感受野不重叠怎么办?这样做的方法是改变卷积核的步幅大小。步幅大小控制感受野在输入上的移动方式。图 6-4 展示了一个一维卷积核,分别具有步幅大小 1 和 2。图 6-5 说明了改变步幅大小如何改变感受野在输入上的移动方式。
![stride_size.png]()
图 6-5。步幅大小控制局部感受野在输入上的“滑动”。这在一维输入上最容易可视化。左侧的网络步幅为 1,而右侧的网络步幅为 2。请注意,每个局部感受野计算其输入的最大值。
现在,请注意我们定义的卷积核将一个数字网格转换为另一个数字网格。如果我们想要输出多个数字网格怎么办?这很容易;我们只需要添加更多的卷积核来处理图像。卷积核也称为滤波器,因此卷积层中的滤波器数量控制我们获得的转换网格数量。一组卷积核形成一个卷积层。
多维输入上的卷积核
在本节中,我们主要将卷积核描述为将数字网格转换为其他数字网格。回想一下我们在前几章中使用的张量语言,卷积将矩阵转换为矩阵。
如果您的输入具有更多维度怎么办?例如,RGB 图像通常具有三个颜色通道,因此 RGB 图像在正确的情况下是一个秩为 3 的张量。处理 RGB 数据的最简单方法是规定每个局部感受野包括与该补丁中的像素相关联的所有颜色通道。然后,您可以说局部感受野的大小为 5×5×3,对于一个大小为 5×5 像素且具有三个颜色通道的局部感受野。
一般来说,您可以通过相应地扩展局部感受野的维度来将高维张量推广到更高维度的张量。这可能还需要具有多维步幅,特别是如果要分别处理不同维度。细节很容易解决,我们将探索多维卷积核作为您要进行的练习。
池化层
在前一节中,我们介绍了卷积核的概念。这些核将可学习的非线性变换应用于输入的局部补丁。这些变换是可学习的,并且根据通用逼近定理,能够学习局部补丁上任意复杂的输入变换。这种灵活性赋予了卷积核很大的能力。但同时,在深度卷积网络中具有许多可学习权重可能会减慢训练速度。
与使用可学习变换不同,可以使用固定的非线性变换来减少训练卷积网络的计算成本。一种流行的固定非线性是“最大池化”。这样的层选择并输出每个局部感受补丁中激活最大的输入。图 6-6 展示了这个过程。池化层有助于以结构化方式减少输入数据的维度。更具体地说,它们采用局部感受野,并用最大(或最小或平均)函数替换字段的每个部分的非线性激活函数。
![maxpool.jpeg]()
图 6-6。最大池化层的示例。请注意,每个彩色区域(每个局部感受野)中的最大值报告在输出中。
随着硬件的改进,池化层变得不那么有用。虽然池化仍然作为一种降维技术很有用,但最近的研究倾向于避免使用池化层,因为它们固有的丢失性(无法从池化数据中推断出输入中的哪个像素产生了报告的激活)。尽管如此,池化出现在许多标准卷积架构中,因此值得理解。
构建卷积网络
一个简单的卷积架构将一系列卷积层和池化层应用于其输入,以学习输入图像数据上的复杂函数。在构建这些网络时有很多细节,但在其核心,架构设计只是一种复杂的乐高堆叠形式。图 6-7 展示了一个卷积架构可能是如何由组成块构建起来的。
![cnnimage.png]()
图 6-7。一个简单的卷积架构的示例,由堆叠的卷积和池化层构成。
膨胀卷积
膨胀卷积或空洞卷积是一种新近流行的卷积层形式。这里的见解是为每个神经元在局部感受野中留下间隙(atrous 意味着a trous,即法语中的“带孔”)。这个基本概念在信号处理中是一个古老的概念,最近在卷积文献中找到了一些好的应用。
空洞卷积的核心优势是每个神经元的可见区域增加。让我们考虑一个卷积架构,其第一层是具有 3×3 局部感受野的普通卷积。然后,在架构中更深一层的第二个普通卷积层中的神经元具有 5×5 的感受野深度(第二层中局部感受野中的每个神经元本身在第一层中具有局部感受野)。然后,更深的两层的神经元具有 7×7 的感受视图。一般来说,卷积架构中第N层的神经元具有大小为(2N + 1) × (2N + 1)的感受视图。这种感受视图的线性增长对于较小的图像是可以接受的,但对于大型图像很快就会成为一个负担。
空洞卷积通过在其局部感受野中留下间隙实现了可见感受野的指数增长。一个“1-膨胀”卷积不留下间隙,而一个“2-膨胀”卷积在每个局部感受野元素之间留下一个间隙。堆叠膨胀层会导致局部感受野大小呈指数增长。图 6-8 说明了这种指数增长。
膨胀卷积对于大型图像非常有用。例如,医学图像在每个维度上可以延伸到数千个像素。创建具有全局理解的普通卷积网络可能需要不合理深的网络。使用膨胀卷积可以使网络更好地理解这些图像的全局结构。
![dilated_convolution.png]()
图 6-8。一个膨胀(或空洞)卷积。为每个神经元在局部感受野中留下间隙。图(a)描述了一个 1-膨胀的 3×3 卷积。图(b)描述了将一个 2-膨胀的 3×3 卷积应用于(a)。图(c)描述了将一个 4-膨胀的 3×3 卷积应用于(b)。注意,(a)层的感受野宽度为 3,(b)层的感受野宽度为 7,(c)层的感受野宽度为 15。
卷积网络的应用
在前一节中,我们介绍了卷积网络的机制,并向您介绍了构成这些网络的许多组件。在本节中,我们描述了一些卷积架构可以实现的应用。
目标检测和定位
目标检测是检测照片中存在的对象(或实体)的任务。目标定位是识别图像中对象存在的位置,并在每个出现的位置周围绘制“边界框”的任务。图 6-9 展示了标准图像上检测和定位的样子。
![detection_and_localization.jpg]()
图 6-9。在一些示例图像中检测和定位的对象,并用边界框标出。
为什么检测和定位很重要?一个非常有用的定位任务是从自动驾驶汽车拍摄的图像中检测行人。不用说,自动驾驶汽车能够识别所有附近的行人是非常重要的。目标检测的其他应用可能用于在上传到社交网络的照片中找到所有朋友的实例。另一个应用可能是从无人机中识别潜在的碰撞危险。
这些丰富的应用使得检测和定位成为大量研究活动的焦点。本书中多次提到的 ILSVRC 挑战专注于检测和定位在 ImagetNet 集合中找到的对象。
图像分割
图像分割是将图像中的每个像素标记为其所属对象的任务。分割与目标定位相关,但要困难得多,因为它需要准确理解图像中对象之间的边界。直到最近,图像分割通常是通过图形模型完成的,这是一种与深度网络不同的机器学习形式,但最近卷积分割已经崭露头角,并使图像分割算法取得了新的准确性和速度记录。图 6-10 显示了应用于自动驾驶汽车图像数据的图像分割的示例。
![nvidia_digits.png]()
图 6-10。图像中的对象被“分割”为各种类别。图像分割预计将对自动驾驶汽车和机器人等应用非常有用,因为它将实现对场景的细粒度理解。
图卷积
到目前为止,我们向您展示的卷积算法期望其输入为矩形张量。这样的输入可以是图像、视频,甚至句子。是否可能将卷积推广到不规则输入?
卷积层背后的基本思想是局部感受野的概念。每个神经元计算其局部感受野中的输入,这些输入通常构成图像输入中相邻的像素。对于不规则输入,例如图 6-11 中的无向图,这种简单的局部感受野的概念是没有意义的;没有相邻的像素。如果我们可以为无向图定义一个更一般的局部感受野,那么我们应该能够定义接受无向图的卷积层。
![graph_example.png]()
图 6-11。由边连接的节点组成的无向图的示例。
如图 6-11 所示,图由一组由边连接的节点组成。一个潜在的局部感受野的定义可能是将其定义为一个节点及其邻居的集合(如果两个节点通过边连接,则被认为是邻居)。使用这种局部感受野的定义,可以定义卷积和池化层的广义概念。这些层可以组装成图卷积架构。
这种图卷积架构可能在哪些地方证明有用?在化学中,分子可以被建模为原子形成节点,化学键形成边缘的无向图。因此,图卷积架构在化学机器学习中特别有用。例如,图 6-12 展示了图卷积架构如何应用于处理分子输入。
![graphconv_graphic_v2.png]()
图 6-12。展示了一个图卷积架构处理分子输入的示意图。分子被建模为一个无向图,其中原子形成节点,化学键形成边缘。"图拓扑"是对应于分子的无向图。"原子特征"是向量,每个原子一个,总结了局部化学信息。改编自“一次性学习的低数据药物发现”。
使用变分自动编码器生成图像
到目前为止,我们描述的应用都是监督学习问题。有明确定义的输入和输出,任务仍然是(使用卷积网络)学习一个将输入映射到输出的复杂函数。有没有无监督学习问题可以用卷积网络解决?回想一下,无监督学习需要“理解”输入数据点的结构。对于图像建模,理解输入图像结构的一个好的衡量标准是能够“采样”来自输入分布的新图像。
什么是“采样”图像的意思?为了解释,假设我们有一组狗的图像数据集。采样一个新的狗图像需要生成一张不在训练数据中的新狗图像!这个想法是,我们希望得到一张狗的图片,这张图片可能已经被包含在训练数据中,但实际上并没有。我们如何用卷积网络解决这个任务?
也许我们可以训练一个模型,输入词标签如“狗”,并预测狗的图像。我们可能能够训练一个监督模型来解决这个预测问题,但问题在于我们的模型只能在输入标签“狗”时生成一张狗的图片。现在假设我们可以给每只狗附加一个随机标签,比如“dog3422”或“dog9879”。那么我们只需要给一只新的狗附加一个新的随机标签,比如“dog2221”,就可以得到一张新的狗的图片。
变分自动编码器形式化了这些直觉。变分自动编码器由两个卷积网络组成:编码器网络和解码器网络。编码器网络用于将图像转换为一个平坦的“嵌入”向量。解码器网络负责将嵌入向量转换为图像。为了确保解码器可以生成不同的图像,会添加噪音。图 6-13 展示了一个变分自动编码器。
![variational_autoencoder.png]()
图 6-13。变分自动编码器的示意图。变分自动编码器由两个卷积网络组成,编码器和解码器。
在实际实现中涉及更多细节,但变分自动编码器能够对图像进行采样。然而,朴素的变分编码器似乎生成模糊的图像样本,正如图 6-14 所示。这种模糊可能是因为L²损失不会严厉惩罚图像的模糊(回想我们关于L²不惩罚小偏差的讨论)。为了生成清晰的图像样本,我们将需要其他架构。
![variational-autoencoder-faces.jpg]()
图 6-14。从一个训练有素的人脸数据集上训练的变分自动编码器中采样的图像。请注意,采样的图像非常模糊。
对抗模型
L2 损失会严厉惩罚大的局部偏差,但不会严重惩罚许多小的局部偏差,导致模糊。我们如何设计一个替代的损失函数,更严厉地惩罚图像中的模糊?事实证明,编写一个能够解决问题的损失函数是相当具有挑战性的。虽然我们的眼睛可以很快发现模糊,但我们的分析工具并不那么快捕捉到这个问题。
如果我们能够“学习”一个损失函数会怎样?这个想法起初听起来有点荒谬;我们从哪里获取训练数据呢?但事实证明,有一个聪明的想法使这变得可行。
假设我们可以训练一个单独的网络来学习损失。让我们称这个网络为鉴别器。让我们称制作图像的网络为生成器。生成器可以与鉴别器对抗,直到生成器能够产生逼真的图像。这种架构通常被称为生成对抗网络,或 GAN。
由 GAN 生成的面部图像(图 6-15)比朴素变分自动编码器生成的图像要清晰得多(图 6-14)!GAN 已经取得了许多其他有希望的成果。例如,CycleGAN 似乎能够学习复杂的图像转换,例如将马转变为斑马,反之亦然。图 6-16 展示了一些 CycleGAN 图像转换。
![GAN_faces.png]()
图 6-15。从一个在面部数据集上训练的生成对抗网络(GAN)中采样的图像。请注意,采样的图像比变分自动编码器生成的图像更清晰。
![CycleGAN.jpg]()
图 6-16。CycleGAN 能够执行复杂的图像转换,例如将马的图像转换为斑马的图像(反之亦然)。
不幸的是,生成对抗网络在实践中仍然具有挑战性。使生成器和鉴别器学习合理的函数需要许多技巧。因此,虽然有许多令人兴奋的 GAN 演示,但 GAN 尚未发展到可以广泛部署在工业应用中的阶段。
在 TensorFlow 中训练卷积网络
在这一部分,我们考虑了一个用于训练简单卷积神经网络的代码示例。具体来说,我们的代码示例将演示如何使用 TensorFlow 在 MNIST 数据集上训练 LeNet-5 卷积架构。和往常一样,我们建议您通过运行与本书相关的GitHub 存储库中的完整代码示例来跟随。
MNIST 数据集
MNIST 数据集包含手写数字的图像。与 MNIST 相关的机器学习挑战包括创建一个在数字训练集上训练并推广到验证集的模型。图 6-17 展示了从 MNIST 数据集中绘制的一些图像。
![minst_images.png]()
图 6-17。来自 MNIST 数据集的一些手写数字图像。学习挑战是从图像中预测数字。
对于计算机视觉的机器学习方法的发展,MNIST 是一个非常重要的数据集。该数据集足够具有挑战性,以至于明显的非学习方法往往表现不佳。与此同时,MNIST 数据集足够小,以至于尝试新架构不需要非常大量的计算资源。
然而,MNIST 数据集大多已经过时。最佳模型实现了接近百分之百的测试准确率。请注意,这并不意味着手写数字识别问题已经解决!相反,很可能是人类科学家已经过度拟合了 MNIST 数据集的架构,并利用其特点实现了非常高的预测准确性。因此,不再建议使用 MNIST 来设计新的深度架构。尽管如此,MNIST 仍然是一个非常好的用于教学目的的数据集。
加载 MNIST
MNIST 代码库位于Yann LeCun 的网站上。下载脚本从网站下载原始文件。请注意脚本如何缓存下载,因此重复调用download()
不会浪费精力。
作为一个更一般的说明,将机器学习数据集存储在云中,并让用户代码在处理之前检索数据,然后输入到学习算法中是非常常见的。我们在第四章中通过 DeepChem 库访问的 Tox21 数据集遵循了相同的设计模式。一般来说,如果您想要托管一个大型数据集进行分析,将其托管在云端并根据需要下载到本地机器进行处理似乎是一个不错的做法。(然而,对于非常大的数据集,网络传输时间变得非常昂贵。)请参见示例 6-1。
示例 6-1。这个函数下载 MNIST 数据集
def download(filename):
"""Download the data from Yann's website, unless it's already here."""
if not os.path.exists(WORK_DIRECTORY):
os.makedirs(WORK_DIRECTORY)
filepath = os.path.join(WORK_DIRECTORY, filename)
if not os.path.exists(filepath):
filepath, _ = urllib.request.urlretrieve(SOURCE_URL + filename, filepath)
size = os.stat(filepath).st_size
print('Successfully downloaded', filename, size, 'bytes.')
return filepath
此下载检查WORK_DIRECTORY
的存在。如果该目录存在,则假定 MNIST 数据集已经被下载。否则,脚本使用urllib
Python 库执行下载并打印下载的字节数。
MNIST 数据集以字节编码的原始字符串形式存储像素值。为了方便处理这些数据,我们需要将其转换为 NumPy 数组。函数np.frombuffer
提供了一个方便的方法,允许将原始字节缓冲区转换为数值数组(示例 6-2)。正如我们在本书的其他地方所指出的,深度网络可能会被占据广泛范围的输入数据破坏。为了稳定的梯度下降,通常需要将输入限制在一个有界范围内。原始的 MNIST 数据集包含从 0 到 255 的像素值。为了稳定性,这个范围需要被移动,使其均值为零,范围为单位(从-0.5 到+0.5)。
示例 6-2。从下载的数据集中提取图像到 NumPy 数组
def extract_data(filename, num_images):
"""Extract the images into a 4D tensor [image index, y, x, channels].
Values are rescaled from [0, 255] down to [-0.5, 0.5].
"""
print('Extracting', filename)
with gzip.open(filename) as bytestream:
bytestream.read(16)
buf = bytestream.read(IMAGE_SIZE * IMAGE_SIZE * num_images * NUM_CHANNELS)
data = numpy.frombuffer(buf, dtype=numpy.uint8).astype(numpy.float32)
data = (data - (PIXEL_DEPTH / 2.0)) / PIXEL_DEPTH
data = data.reshape(num_images, IMAGE_SIZE, IMAGE_SIZE, NUM_CHANNELS)
return data
标签以简单的文件形式存储为字节字符串。有一个包含 8 个字节的标头,其余的数据包含标签(示例 6-3)。
示例 6-3。这个函数将从下载的数据集中提取标签到一个标签数组中
def extract_labels(filename, num_images):
"""Extract the labels into a vector of int64 label IDs."""
print('Extracting', filename)
with gzip.open(filename) as bytestream:
bytestream.read(8)
buf = bytestream.read(1 * num_images)
labels = numpy.frombuffer(buf, dtype=numpy.uint8).astype(numpy.int64)
return labels
在前面示例中定义的函数的基础上,现在可以下载并处理 MNIST 训练和测试数据集(示例 6-4)。
示例 6-4。使用前面示例中定义的函数,此代码片段下载并处理 MNIST 训练和测试数据集
# Get the data.
train_data_filename = download('train-images-idx3-ubyte.gz')
train_labels_filename = download('train-labels-idx1-ubyte.gz')
test_data_filename = download('t10k-images-idx3-ubyte.gz')
test_labels_filename = download('t10k-labels-idx1-ubyte.gz')
# Extract it into NumPy arrays.
train_data = extract_data(train_data_filename, 60000)
train_labels = extract_labels(train_labels_filename, 60000)
test_data = extract_data(test_data_filename, 10000)
test_labels = extract_labels(test_labels_filename, 10000)
MNIST 数据集并没有明确定义用于超参数调整的验证数据集。因此,我们手动将训练数据集的最后 5,000 个数据点指定为验证数据(示例 6-5)。
示例 6-5。提取训练数据的最后 5,000 个数据集用于超参数验证
VALIDATION_SIZE = 5000 # Size of the validation set.
# Generate a validation set.
validation_data = train_data[:VALIDATION_SIZE, ...]
validation_labels = train_labels[:VALIDATION_SIZE]
train_data = train_data[VALIDATION_SIZE:, ...]
train_labels = train_labels[VALIDATION_SIZE:]
选择正确的验证集
在示例 6-5 中,我们使用训练数据的最后一部分作为验证集来评估我们学习方法的进展。在这种情况下,这种方法相对无害。测试集中的数据分布在验证集中得到了很好的代表。
然而,在其他情况下,这种简单的验证集选择可能是灾难性的。在分子机器学习中(使用机器学习来预测分子的性质),测试分布几乎总是与训练分布截然不同。科学家最感兴趣的是前瞻性预测。也就是说,科学家希望预测从未针对该属性进行测试的分子的性质。在这种情况下,使用最后一部分训练数据进行验证,甚至使用训练数据的随机子样本,都会导致误导性地高准确率。分子机器学习模型在验证时具有 90%的准确率,而在测试时可能只有 60%是非常常见的。
为了纠正这个错误,有必要设计验证集选择方法,这些方法要尽力使验证集与训练集不同。对于分子机器学习,存在各种算法,大多数使用各种数学估计图的不相似性(将分子视为具有原子节点和化学键边的数学图)。
这个问题在许多其他机器学习领域也会出现。在医学机器学习或金融机器学习中,依靠历史数据进行预测可能是灾难性的。对于每个应用程序,重要的是要批判性地思考所选验证集上的性能是否实际上是真实性能的良好代理。
TensorFlow 卷积原语
我们首先介绍用于构建我们的卷积网络的 TensorFlow 原语(示例 6-6)。
示例 6-6。在 TensorFlow 中定义 2D 卷积
tf.nn.conv2d(
input,
filter,
strides,
padding,
use_cudnn_on_gpu=None,
data_format=None,
name=None
)
函数tf.nn.conv2d
是内置的 TensorFlow 函数,用于定义卷积层。这里,input
被假定为形状为(batch, height, width, channels)
的张量,其中batch
是一个小批量中的图像数量。
请注意,先前定义的转换函数将 MNIST 数据读入此格式。参数filter
是形状为(filter_height, filter_width, channels, out_channels)
的张量,指定了在卷积核中学习的非线性变换的可学习权重。strides
包含滤波器步幅,是长度为 4 的列表(每个输入维度一个)。
padding
控制输入张量是否被填充(如图 6-18 中的额外零)以确保卷积层的输出与输入具有相同的形状。如果padding="SAME"
,则填充input
以确保卷积层输出与原始输入图像张量具有相同形状的图像张量。如果padding="VALID"
,则不添加额外填充。
![conv_padding.png]()
图 6-18。卷积层的填充确保输出图像具有与输入图像相同的形状。
示例 6-7 中的代码定义了 TensorFlow 中的最大池化。
示例 6-7。在 TensorFlow 中定义最大池化
tf.nn.max_pool(
value,
ksize,
strides,
padding,
data_format='NHWC',
name=None
)
tf.nn.max_pool
函数执行最大池化。这里value
与tf.nn.conv2d
的input
具有相同的形状,即(batch, height, width, channels)
。ksize
是池化窗口的大小,是长度为 4 的列表。strides
和padding
的行为与tf.nn.conv2d
相同。
卷积架构
本节中定义的架构将与 LeNet-5 非常相似,LeNet-5 是最初用于在 MNIST 数据集上训练卷积神经网络的原始架构。在 LeNet-5 架构被发明时,计算成本非常昂贵,需要多周的计算才能完成训练。如今的笔记本电脑幸运地足以训练 LeNet-5 模型。图 6-19 展示了 LeNet-5 架构的结构。
![lenet5.png]()
图 6-19。LeNet-5 卷积架构的示意图。
更多计算会有什么不同?
LeNet-5 架构已有几十年历史,但实质上是解决数字识别问题的正确架构。然而,它的计算需求使得这种架构在几十年来相对默默无闻。因此,有趣的是,今天有哪些研究问题同样被解决,但仅仅受限于缺乏足够的计算能力?
一个很好的应用是视频处理。卷积模型在处理视频方面非常出色。然而,在大型视频数据集上存储和训练模型是不方便的,因此大多数学术论文不会报告视频数据集的结果。因此,要拼凑出一个良好的视频处理系统并不容易。
随着计算能力的增强,这种情况可能会发生变化,视频处理系统可能会变得更加普遍。然而,今天的硬件改进与过去几十年的硬件改进之间存在一个关键区别。与过去几年不同,摩尔定律的放缓明显。因此,硬件的改进需要更多的比自然晶体管缩小更多的东西,通常需要在架构设计上付出相当大的智慧。我们将在后面的章节中回到这个话题,并讨论深度网络的架构需求。
让我们定义训练 LeNet-5 网络所需的权重。我们首先定义一些用于定义权重张量的基本常量(示例 6-8)。
示例 6-8。为 LeNet-5 模型定义基本常量
NUM_CHANNELS = 1
IMAGE_SIZE = 28
NUM_LABELS = 10
我们定义的架构将使用两个卷积层交替使用两个池化层,最后是两个完全连接的层。请记住,池化不需要可学习的权重,因此我们只需要为卷积和完全连接的层创建权重。对于每个tf.nn.conv2d
,我们需要创建一个与tf.nn.conv2d
的filter
参数对应的可学习权重张量。在这种特定的架构中,我们还将添加一个卷积偏置,每个输出通道一个(示例 6-9)。
示例 6-9。为卷积层定义可学习的权重
conv1_weights = tf.Variable(
tf.truncated_normal([5, 5, NUM_CHANNELS, 32], # 5x5 filter, depth 32.
stddev=0.1,
seed=SEED, dtype=tf.float32))
conv1_biases = tf.Variable(tf.zeros([32], dtype=tf.float32))
conv2_weights = tf.Variable(tf.truncated_normal(
[5, 5, 32, 64], stddev=0.1,
seed=SEED, dtype=tf.float32))
conv2_biases = tf.Variable(tf.constant(0.1, shape=[64], dtype=tf.float32))
请注意,卷积权重是 4 维张量,而偏置是 1 维张量。第一个完全连接的层将卷积层的输出转换为大小为 512 的向量。输入图像从大小IMAGE_SIZE=28
开始。经过两个池化层(每个将输入减少 2 倍),我们最终得到大小为IMAGE_SIZE//4
的图像。我们相应地创建完全连接权重的形状。
第二个完全连接的层用于提供 10 路分类输出,因此其权重形状为(512,10)
,偏置形状为(10)
,如示例 6-10 所示。
示例 6-10。为完全连接的层定义可学习的权重
fc1_weights = tf.Variable( # fully connected, depth 512.
tf.truncated_normal([IMAGE_SIZE // 4 * IMAGE_SIZE // 4 * 64, 512],
stddev=0.1,
seed=SEED,
dtype=tf.float32))
fc1_biases = tf.Variable(tf.constant(0.1, shape=[512], dtype=tf.float32))
fc2_weights = tf.Variable(tf.truncated_normal([512, NUM_LABELS],
stddev=0.1,
seed=SEED,
dtype=tf.float32))
fc2_biases = tf.Variable(tf.constant(
0.1, shape=[NUM_LABELS], dtype=tf.float32))
所有权重定义完成后,我们现在可以自由定义网络的架构。该架构有六层,模式为 conv-pool-conv-pool-full-full(示例 6-11)。
示例 6-11。定义 LeNet-5 架构。调用此示例中定义的函数将实例化架构。
def model(data, train=False):
"""The Model definition."""
# 2D convolution, with 'SAME' padding (i.e. the output feature map has
# the same size as the input). Note that {strides} is a 4D array whose
# shape matches the data layout: [image index, y, x, depth].
conv = tf.nn.conv2d(data,
conv1_weights,
strides=[1, 1, 1, 1],
padding='SAME')
# Bias and rectified linear non-linearity.
relu = tf.nn.relu(tf.nn.bias_add(conv, conv1_biases))
# Max pooling. The kernel size spec {ksize} also follows the layout of
# the data. Here we have a pooling window of 2, and a stride of 2.
pool = tf.nn.max_pool(relu,
ksize=[1, 2, 2, 1],
strides=[1, 2, 2, 1],
padding='SAME')
conv = tf.nn.conv2d(pool,
conv2_weights,
strides=[1, 1, 1, 1],
padding='SAME')
relu = tf.nn.relu(tf.nn.bias_add(conv, conv2_biases))
pool = tf.nn.max_pool(relu,
ksize=[1, 2, 2, 1],
strides=[1, 2, 2, 1],
padding='SAME')
# Reshape the feature map cuboid into a 2D matrix to feed it to the
# fully connected layers.
pool_shape = pool.get_shape().as_list()
reshape = tf.reshape(
pool,
[pool_shape[0], pool_shape[1] * pool_shape[2] * pool_shape[3]])
# Fully connected layer. Note that the '+' operation automatically
# broadcasts the biases.
hidden = tf.nn.relu(tf.matmul(reshape, fc1_weights) + fc1_biases)
# Add a 50% dropout during training only. Dropout also scales
# activations such that no rescaling is needed at evaluation time.
if train:
hidden = tf.nn.dropout(hidden, 0.5, seed=SEED)
return tf.matmul(hidden, fc2_weights) + fc2_biases
如前所述,网络的基本架构交替使用tf.nn.conv2d
、tf.nn.max_pool
和非线性,以及最后一个完全连接的层。为了正则化,在最后一个完全连接的层之后应用一个 dropout 层,但只在训练期间。请注意,我们将输入作为参数data
传递给函数model()
。
网络中仍需定义的唯一部分是占位符(示例 6-12)。我们需要定义两个占位符,用于输入训练图像和训练标签。在这个特定的网络中,我们还定义了一个用于评估的单独占位符,允许我们在评估时输入更大的批次。
示例 6-12。为架构定义占位符
BATCH_SIZE = 64
EVAL_BATCH_SIZE = 64
train_data_node = tf.placeholder(
tf.float32,
shape=(BATCH_SIZE, IMAGE_SIZE, IMAGE_SIZE, NUM_CHANNELS))
train_labels_node = tf.placeholder(tf.int64, shape=(BATCH_SIZE,))
eval_data = tf.placeholder(
tf.float32,
shape=(EVAL_BATCH_SIZE, IMAGE_SIZE, IMAGE_SIZE, NUM_CHANNELS))
有了这些定义,我们现在已经处理了数据,指定了输入和权重,并构建了模型。我们现在准备训练网络(示例 6-13)。
示例 6-13。训练 LeNet-5 架构
# Create a local session to run the training.
start_time = time.time()
with tf.Session() as sess:
# Run all the initializers to prepare the trainable parameters.
tf.global_variables_initializer().run()
# Loop through training steps.
for step in xrange(int(num_epochs * train_size) // BATCH_SIZE):
# Compute the offset of the current minibatch in the data.
# Note that we could use better randomization across epochs.
offset = (step * BATCH_SIZE) % (train_size - BATCH_SIZE)
batch_data = train_data[offset:(offset + BATCH_SIZE), ...]
batch_labels = train_labels[offset:(offset + BATCH_SIZE)]
# This dictionary maps the batch data (as a NumPy array) to the
# node in the graph it should be fed to.
feed_dict = {train_data_node: batch_data,
train_labels_node: batch_labels}
# Run the optimizer to update weights.
sess.run(optimizer, feed_dict=feed_dict)
这个拟合代码的结构看起来与本书迄今为止看到的其他拟合代码非常相似。在每一步中,我们构建一个 feed 字典,然后运行优化器的一步。请注意,我们仍然使用小批量训练。
评估经过训练的模型
我们现在有一个正在训练的模型。我们如何评估训练模型的准确性?一个简单的方法是定义一个错误度量。与前几章一样,我们将使用一个简单的分类度量来衡量准确性(示例 6-14)。
示例 6-14。评估经过训练的架构的错误
def error_rate(predictions, labels):
"""Return the error rate based on dense predictions and sparse labels."""
return 100.0 - (
100.0 *
numpy.sum(numpy.argmax(predictions, 1) == labels) /
predictions.shape[0])
我们可以使用这个函数来评估网络在训练过程中的错误。让我们引入一个额外的方便函数,以批处理的方式评估任何给定数据集上的预测(示例 6-15)。这种便利是必要的,因为我们的网络只能处理固定批量大小的输入。
示例 6-15。一次评估一批数据
def eval_in_batches(data, sess):
"""Get predictions for a dataset by running it in small batches."""
size = data.shape[0]
if size < EVAL_BATCH_SIZE:
raise ValueError("batch size for evals larger than dataset: %d"
% size)
predictions = numpy.ndarray(shape=(size, NUM_LABELS),
dtype=numpy.float32)
for begin in xrange(0, size, EVAL_BATCH_SIZE):
end = begin + EVAL_BATCH_SIZE
if end <= size:
predictions[begin:end, :] = sess.run(
eval_prediction,
feed_dict={eval_data: data[begin:end, ...]})
else:
batch_predictions = sess.run(
eval_prediction,
feed_dict={eval_data: data[-EVAL_BATCH_SIZE:, ...]})
predictions[begin:, :] = batch_predictions[begin - size:, :]
return predictions
现在我们可以在训练过程中的内部for
循环中添加一些仪器(instrumentation),定期评估模型在验证集上的准确性。我们可以通过评分测试准确性来结束训练。示例 6-16 展示了添加了仪器的完整拟合代码。
示例 6-16。训练网络的完整代码,添加了仪器
# Create a local session to run the training.
start_time = time.time()
with tf.Session() as sess:
# Run all the initializers to prepare the trainable parameters.
tf.global_variables_initializer().run()
# Loop through training steps.
for step in xrange(int(num_epochs * train_size) // BATCH_SIZE):
# Compute the offset of the current minibatch in the data.
# Note that we could use better randomization across epochs.
offset = (step * BATCH_SIZE) % (train_size - BATCH_SIZE)
batch_data = train_data[offset:(offset + BATCH_SIZE), ...]
batch_labels = train_labels[offset:(offset + BATCH_SIZE)]
# This dictionary maps the batch data (as a NumPy array) to the
# node in the graph it should be fed to.
feed_dict = {train_data_node: batch_data,
train_labels_node: batch_labels}
# Run the optimizer to update weights.
sess.run(optimizer, feed_dict=feed_dict)
# print some extra information once reach the evaluation frequency
if step % EVAL_FREQUENCY == 0:
# fetch some extra nodes' data
l, lr, predictions = sess.run([loss, learning_rate,
train_prediction],
feed_dict=feed_dict)
elapsed_time = time.time() - start_time
start_time = time.time()
print('Step %d (epoch %.2f), %.1f ms' %
(step, float(step) * BATCH_SIZE / train_size,
1000 * elapsed_time / EVAL_FREQUENCY))
print('Minibatch loss: %.3f, learning rate: %.6f' % (l, lr))
print('Minibatch error: %.1f%%'
% error_rate(predictions, batch_labels))
print('Validation error: %.1f%%' % error_rate(
eval_in_batches(validation_data, sess), validation_labels))
sys.stdout.flush()
# Finally print the result!
test_error = error_rate(eval_in_batches(test_data, sess),
test_labels)
print('Test error: %.1f%%' % test_error)
读者的挑战
尝试自己训练网络。您应该能够达到<1%的测试错误!
回顾
在这一章中,我们向您展示了卷积网络设计的基本概念。这些概念包括构成卷积网络核心构建模块的卷积和池化层。然后我们讨论了卷积架构的应用,如目标检测、图像分割和图像生成。我们以一个深入的案例研究结束了这一章,向您展示了如何在 MNIST 手写数字数据集上训练卷积架构。
在第七章中,我们将介绍循环神经网络,另一个核心深度学习架构。与为图像处理而设计的卷积网络不同,循环架构非常适合处理顺序数据,如自然语言数据集。
第七章:循环神经网络
到目前为止,在这本书中,我们向您介绍了使用深度学习处理各种类型输入的方法。我们从简单的线性和逻辑回归开始,这些回归是在固定维度的特征向量上进行的,然后讨论了全连接的深度网络。这些模型接受任意大小的特征向量,这些向量的大小是固定的,预先确定的。这些模型不对编码到这些向量中的数据类型做任何假设。另一方面,卷积网络对其数据的结构做出了强烈的假设。卷积网络的输入必须满足一个允许定义局部感受野的局部性假设。
到目前为止,我们如何使用我们描述的网络来处理句子等数据?句子确实具有一些局部性质(附近的单词通常相关),因此确实可以使用一维卷积网络来处理句子数据。尽管如此,大多数从业者倾向于使用不同类型的架构,即循环神经网络,以处理数据序列。
循环神经网络(RNNs)被设计成允许深度网络处理数据序列。RNNs 假设传入的数据采用向量或张量序列的形式。如果我们将句子中的每个单词转换为向量(稍后会详细介绍如何做到这一点),那么句子可以被馈送到 RNN 中。同样,视频(被视为图像序列)也可以通过 RNN 进行处理。在每个序列位置,RNN 对该序列位置的输入应用任意非线性转换。这种非线性转换对所有序列步骤都是共享的。
前面段落中的描述有点抽象,但事实证明它非常强大。在本章中,您将了解有关 RNN 结构的更多细节,以及如何在 TensorFlow 中实现 RNN。我们还将讨论 RNN 如何在实践中用于执行诸如抽样新句子或为诸如聊天机器人之类的应用生成文本的任务。
本章的案例研究在 Penn Treebank 语料库上训练了一个循环神经网络语言模型,这是从《华尔街日报》文章中提取的句子集合。本教程改编自 TensorFlow 官方文档关于循环网络的教程。(如果您对我们所做的更改感兴趣,我们鼓励您访问 TensorFlow 网站上的原始教程。)与往常一样,我们建议您跟着本书相关的GitHub 存储库中的代码进行学习。
循环架构概述
循环架构对建模非常复杂的时变数据集非常有用。时变数据集传统上称为时间序列。图 7-1 显示了一些时间序列数据集。
![time_series.png]()
图 7-1。我们可能感兴趣建模的一些时间序列数据集。
在时间序列建模中,我们设计学习系统,该系统能够学习演化规则,即根据过去学习系统的未来如何演变。从数学上讲,假设在每个时间步骤,我们接收到一个数据点,其中t是当前时间。然后,时间序列方法试图学习某个函数f,使得
这个想法是f很好地编码了系统的基本动态,从数据中学习它将使学习系统能够预测手头系统的未来。在实践中,学习一个依赖于所有过去输入的函数太过繁琐,因此学习系统通常假设所有关于上一个数据点 的信息可以被编码为某个固定向量 。然后,更新方程简化为以下格式
请注意,我们假设这里的相同函数f适用于所有时间步t。也就是说,我们假设时间序列是稳态的(参见图 7-2)。这种假设对许多系统来说是不成立的,特别是包括股票市场,今天的规则不一定适用于明天。
![RNN-unrolled.png]()
图 7-2。具有稳态演化规则的时间序列的数学模型。请记住,稳态系统是指其基本动态不随时间变化的系统。
这个方程与循环神经网络有什么关系呢?基本答案源自我们在第四章中介绍的通用逼近定理。函数f可以是任意复杂的,因此使用全连接的深度网络来学习f似乎是一个合理的想法。这种直觉基本上定义了 RNN。一个简单的循环网络可以被视为一个被重复应用到数据的每个时间步长的全连接网络。
事实上,循环神经网络只有在复杂的高维时间序列中才真正变得有趣。对于更简单的系统,通常有经典的信号处理时间序列方法可以很好地建模时间动态。然而,对于复杂系统,如语音(请参见图 7-3 中的语音谱图),RNN 将展现出自己的能力,并提供其他方法无法提供的功能。
![sepctrogram.GIF]()
图 7-3。代表语音样本中发现的频率的语音谱图。
循环细胞
梯度不稳定
随着时间的推移,循环网络往往会降低信号。可以将其视为在每个时间步长上通过乘法因子衰减信号。因此,经过 50 个时间步长后,信号会相当衰减。
由于这种不稳定性,训练长时间序列上的循环神经网络一直是具有挑战性的。已经出现了许多方法来对抗这种不稳定性,我们将在本节的其余部分讨论这些方法。
有许多关于简单循环神经网络概念的阐述,在实际应用中已被证明更成功。在本节中,我们将简要回顾其中一些变体。
长短期记忆(LSTM)
标准循环细胞的挑战之一是来自遥远过去的信号会迅速衰减。因此,RNN 可能无法学习复杂依赖关系的模型。这种失败在语言建模等应用中尤为显著,其中单词可能对先前短语有复杂的依赖关系。
解决这个问题的一个潜在方案是允许过去的状态无修改地传递。长短期记忆(LSTM)架构提出了一种机制,允许过去的状态以最小的修改传递到现在。经验表明,使用 LSTM“单元”(如图 7-4 所示)在学习性能方面似乎比使用完全连接层的简单递归神经网络表现更好。
![colah_lstm.png]()
图 7-4. 长短期记忆(LSTM)单元。LSTM 在保留输入的长距离依赖性方面比标准递归神经网络表现更好。因此,LSTM 通常被用于复杂的序列数据,如自然语言。
这么多方程!
LSTM 方程涉及许多复杂的术语。如果您有兴趣准确理解 LSTM 背后的数学直觉,我们鼓励您用铅笔和纸玩弄这些方程,并尝试对 LSTM 单元进行求导。
然而,对于其他主要关注使用递归架构解决实际问题的读者,我们认为深入研究 LSTM 工作的细节并非绝对必要。相反,保持高层次的直觉,即允许过去状态传递,并深入研究本章的示例代码。
优化递归网络
与完全连接网络或卷积网络不同,LSTM 涉及一些复杂的数学运算和控制流操作。因此,即使使用现代 GPU 硬件,训练大型递归网络在规模上仍然具有挑战性。
已经付出了大量努力来优化 RNN 实现,以便在 GPU 硬件上快速运行。特别是,Nvidia 已将 RNN 集成到其 CuDNN 库中,该库提供了专门针对 GPU 上训练深度网络的优化代码。对于 TensorFlow 用户来说,与 CuDNN 等库的集成是在 TensorFlow 内部完成的,因此您不需要过多担心代码优化(除非您正在处理非常大规模的数据集)。我们将在第九章中更深入地讨论深度神经网络的硬件需求。
门控循环单元(GRU)
LSTM 单元的复杂性,无论是概念上还是计算上,都激发了许多研究人员试图简化 LSTM 方程,同时保留原始方程的性能增益和建模能力。
有许多可替代 LSTM 的竞争者,但其中一种领先者是门控循环单元(GRU),如图 7-5 所示。GRU 去除了 LSTM 的一个子组件,但经验表明其性能与 LSTM 相似。GRU 可能是序列建模项目中 LSTM 单元的合适替代品。
![lstm_gru.png]()
图 7-5. 门控循环单元(GRU)单元。GRU 以较低的计算成本保留了许多 LSTM 的优点。
递归模型的应用
虽然递归神经网络是对建模时间序列数据集有用的工具,但递归网络还有一系列其他应用。这些应用包括自然语言建模、机器翻译、化学逆合成以及使用神经图灵机进行任意计算。在本节中,我们简要介绍了一些这些令人兴奋的应用。
从递归网络中采样
到目前为止,我们已经教会了您循环网络如何学习对数据序列的时间演变进行建模。如果您理解了一组序列的演变规则,那么您应该能够从训练序列的分布中采样新的序列。事实证明,训练模型可以生成良好的序列。迄今为止最有用的应用是语言建模。能够生成逼真的句子是一个非常有用的工具,支撑着自动完成和聊天机器人等系统。
为什么我们不使用 GAN 来处理序列?
在第六章中,我们讨论了生成新图像的问题。我们讨论了诸如变分自动编码器之类的模型,这些模型只生成模糊的图像,并引入了能够生成清晰图像的生成对抗网络技术。然而,问题仍然存在:如果我们需要 GAN 来获得良好的图像样本,为什么我们不用它们来获得良好的句子?
事实证明,今天的生成对抗模型在采样序列方面表现平平。目前尚不清楚原因。对 GAN 的理论理解仍然非常薄弱(即使按照深度学习理论的标准),但是关于博弈论均衡发现的某些东西似乎在序列方面表现不如图像。
Seq2seq 模型
序列到序列(seq2seq)模型是强大的工具,使模型能够将一个序列转换为另一个序列。序列到序列模型的核心思想是使用一个编码循环网络,将输入序列嵌入到向量空间中,同时使用一个解码网络,使得可以对输出序列进行采样,如前面的句子所述。图 7-6 说明了一个 seq2seq 模型。
![seq2seq_colah.png]()
图 7-6。序列到序列模型是强大的工具,可以学习序列转换。它们已经应用于机器翻译(例如,将一系列英语单词转换为中文)和化学逆合成(将一系列化学产品转换为一系列反应物)。
事情变得有趣起来,因为编码器和解码器层本身可以很深。 (RNN 层可以以自然的方式堆叠。)Google 神经机器翻译(GNMT)系统有许多堆叠的编码和解码层。由于这种强大的表征能力,它能够执行远远超出其最近的非深度竞争对手能力的最先进的翻译。图 7-7 说明了 GNMT 架构。
![google_nmt.png]()
图 7-7。Google 神经机器翻译(GNMT)架构是一个深度的 seq2seq 模型,学习执行机器翻译。
到目前为止,我们主要讨论了自然语言处理的应用,seq2seq 架构在其他领域有着无数的应用。其中一位作者已经使用 seq2seq 架构来执行化学逆合成,即将分子分解为更简单的组分。图 7-8 说明。
![seq2seq_retrosynthesis.png]()
图 7-8。化学逆合成的 seq2seq 模型将一系列化学产品转化为一系列化学反应物。
神经图灵机
机器学习的梦想是向抽象堆栈的更高层次发展:从学习短模式匹配引擎到学习执行任意计算。神经图灵机是这种演变中的一个强大步骤。
图灵机是计算理论中的一个重要贡献。它是第一个能够执行任何计算的机器的数学模型。图灵机维护一个提供已执行计算的内存的“带子”。机器的第二部分是一个在单个带子单元上执行转换的“头”。图灵机的见解是,“头”并不需要非常复杂就能执行任意复杂的计算。
神经图灵机(NTM)是将图灵机本身转变为神经网络的一种非常巧妙的尝试。在这种转变中的技巧是将离散动作转变为软连续函数(这是深度学习中反复出现的技巧,所以请注意!)
图灵机头部与 RNN 单元非常相似!因此,NTM 可以被端到端地训练,以学习执行任意计算,至少在原则上是这样的(图 7-9)。实际上,NTM 能够执行的计算集合存在严重的限制。梯度流不稳定性(一如既往)限制了可以学习的内容。需要更多的研究和实验来设计 NTM 的后继者,使其能够学习更有用的函数。
![turing_machine.png]()
图 7-9。神经图灵机(NTM)是图灵机的可学习版本。它维护一个带子,可以在其中存储中间计算的输出。虽然 NTM 有许多实际限制,但可能它们的智能后代将能够学习强大的算法。
图灵完备性
图灵完备性的概念在计算机科学中是一个重要的概念。如果一种编程语言能够执行图灵机能够执行的任何计算,那么它被称为图灵完备。图灵机本身是为了提供一个数学模型,说明一个函数“可计算”的含义而发明的。该机器提供了读取、写入和存储各种指令的能力,这些抽象原语是所有计算机的基础。
随着时间的推移,大量的工作表明图灵机紧密地模拟了物理世界中可执行的计算集合。在第一次近似中,如果可以证明图灵机无法执行某个计算,那么任何计算设备也无法执行。另一方面,如果可以证明计算系统可以执行图灵机的基本操作,那么它就是“图灵完备”的,可以原则上执行任何计算。一些令人惊讶的系统是图灵完备的。如果感兴趣,我们鼓励您阅读更多关于这个主题的内容。
循环网络是图灵完备的
也许并不令人惊讶的是,NTM 能够执行图灵机能够执行的任何计算,因此是图灵完备的。然而,一个较少人知道的事实是,普通的循环神经网络本身也是图灵完备的!换句话说,原则上,循环神经网络能够学习执行任意计算。
基本思想是转换操作符可以学习执行基本的读取、写入和存储操作。随时间展开的循环网络允许执行复杂的计算。在某种意义上,这个事实不应该太令人惊讶。通用逼近定理已经证明全连接网络能够学习任意函数。随时间将任意函数链接在一起导致任意计算。(尽管正式证明这一点所需的技术细节是艰巨的。)
在实践中使用循环神经网络
在本节中,您将了解如何在 Penn Treebank 数据集上使用递归神经网络进行语言建模,这是一个由华尔街日报文章构建的自然语言数据集。 我们将介绍执行此建模所需的 TensorFlow 基元,并将指导您完成准备数据进行训练所需的数据处理和预处理步骤。 我们鼓励您跟着尝试在与本书相关的GitHub 存储库中运行代码。
处理 Penn Treebank 语料库
Penn Treebank 包含一个由华尔街日报文章组成的百万字语料库。 此语料库可用于字符级或单词级建模(预测给定前面的句子中的下一个字符或单词的任务)。 使用训练模型的困惑度来衡量模型的有效性(稍后将详细介绍此指标)。
Penn Treebank 语料库由句子组成。 我们如何将句子转换为可以馈送到机器学习系统(例如递归语言模型)的形式? 请记住,机器学习模型接受张量(递归模型接受张量序列)作为输入。 因此,我们需要将单词转换为机器学习的张量。
将单词转换为向量的最简单方法是使用“一热”编码。 在此编码中,假设我们的语言数据集使用具有个单词的词汇表。 然后,每个单词转换为形状为的向量。 此向量的所有条目都为零,除了一个条目,在索引处,该索引对应于当前单词。 有关此嵌入的示例,请参见图 7-10。
![one-hot.jpg]()
图 7-10。 一热编码将单词转换为只有一个非零条目的向量(通常设置为一)。 向量中的不同索引唯一表示语言语料库中的单词。
也可以使用更复杂的嵌入。 基本思想类似于一热编码。 每个单词与唯一向量相关联。 但是,关键区别在于可以直接从数据中学习此编码向量,以获得对于当前数据集有意义的单词的“单词嵌入”。 我们将在本章后面向您展示如何学习单词嵌入。
为了处理 Penn Treebank 数据,我们需要找到语料库中使用的单词的词汇表,然后将每个单词转换为其关联的单词向量。 然后,我们将展示如何将处理后的数据馈送到 TensorFlow 模型中。
Penn Treebank 的限制
Penn Treebank 是语言建模的一个非常有用的数据集,但对于最先进的语言模型来说已经不再构成挑战; 研究人员已经在这个集合的特殊性上过拟合了模型。 最先进的研究将使用更大的数据集,例如十亿字语料库语言基准。 但是,对于我们的探索目的,Penn Treebank 已经足够。
预处理代码
示例 7-1 中的代码片段读取了与 Penn Treebank 语料库相关的原始文件。 语料库存储在每行一个句子的形式中。 通过一些 Python 字符串处理,将"\n"
换行标记替换为固定标记"<eos>"
,然后将文件拆分为标记列表。
示例 7-1。 此函数读取原始 Penn Treebank 文件
def _read_words(filename):
with tf.gfile.GFile(filename, "r") as f:
if sys.version_info[0] >= 3:
return f.read().replace("\n", "<eos>").split()
else:
return f.read().decode("utf-8").replace("\n", "<eos>").split()
定义了_read_words
后,我们可以使用示例 7-2 中定义的_build_vocab
函数构建与给定文件相关联的词汇表。我们简单地读取文件中的单词,并使用 Python 的collections
库计算文件中唯一单词的数量。为方便起见,我们构建一个字典对象,将单词映射到它们的唯一整数标识符(在词汇表中的位置)。将所有这些联系在一起,_file_to_word_ids
将文件转换为单词标识符列表(示例 7-3)。
示例 7-2。此函数构建一个由指定文件中所有单词组成的词汇表
def _build_vocab(filename):
data = _read_words(filename)
counter = collections.Counter(data)
count_pairs = sorted(counter.items(), key=lambda x: (-x[1], x[0]))
words, _ = list(zip(*count_pairs))
word_to_id = dict(zip(words, range(len(words))))
return word_to_id
示例 7-3。此函数将文件中的单词转换为 id 号
def _file_to_word_ids(filename, word_to_id):
data = _read_words(filename)
return [word_to_id[word] for word in data if word in word_to_id]
有了这些实用工具,我们可以使用函数ptb_raw_data
(示例 7-4)处理 Penn Treebank 语料库。请注意,训练、验证和测试数据集是预先指定的,因此我们只需要将每个文件读入一个唯一索引列表中。
示例 7-4。此函数从指定位置加载 Penn Treebank 数据
def ptb_raw_data(data_path=None):
"""Load PTB raw data from data directory "data_path".
Reads PTB text files, converts strings to integer ids,
and performs mini-batching of the inputs.
The PTB dataset comes from Tomas Mikolov's webpage:
http://www.fit.vutbr.cz/~imikolov/rnnlm/simple-examples.tgz
Args:
data_path: string path to the directory where simple-examples.tgz
has been extracted.
Returns:
tuple (train_data, valid_data, test_data, vocabulary)
where each of the data objects can be passed to PTBIterator.
"""
train_path = os.path.join(data_path, "ptb.train.txt")
valid_path = os.path.join(data_path, "ptb.valid.txt")
test_path = os.path.join(data_path, "ptb.test.txt")
word_to_id = _build_vocab(train_path)
train_data = _file_to_word_ids(train_path, word_to_id)
valid_data = _file_to_word_ids(valid_path, word_to_id)
test_data = _file_to_word_ids(test_path, word_to_id)
vocabulary = len(word_to_id)
return train_data, valid_data, test_data, vocabulary
tf.GFile 和 tf.Flags
TensorFlow 是一个庞大的项目,包含许多部分。虽然大部分库专注于机器学习,但也有相当大比例的库专门用于加载和处理数据。其中一些功能提供了在其他地方找不到的有用功能。然而,加载功能的其他部分则不太有用。
tf.GFile
和tf.FLags
提供的功能与标准 Python 文件处理和argparse
几乎相同。这些工具的来源是历史性的。对于 Google 来说,内部代码标准要求使用自定义文件处理程序和标志处理程序。然而,对于我们其他人来说,尽可能使用标准 Python 工具是更好的风格。这样做对于可读性和稳定性更有利。
将数据加载到 TensorFlow 中
在本节中,我们将介绍加载我们处理过的索引到 TensorFlow 所需的代码。为此,我们将向您介绍一些新的 TensorFlow 机制。到目前为止,我们已经使用 feed 字典将数据传递到 TensorFlow 中。虽然 feed 字典对于小型玩具数据集来说是可以接受的,但对于较大的数据集来说,它们通常不是一个好选择,因为引入了大量 Python 开销,涉及打包和解包字典。为了更高性能的代码,最好使用 TensorFlow 队列。
tf.Queue
提供了一种异步加载数据的方式。这允许将 GPU 计算线程与 CPU 绑定的数据预处理线程解耦。这种解耦对于希望保持 GPU 最大活跃性的大型数据集特别有用。
可以将tf.Queue
对象提供给 TensorFlow 占位符以训练模型并实现更高的性能。我们将在本章后面演示如何做到这一点。
在示例 7-5 中介绍的ptb_producer
函数将原始索引列表转换为可以将数据传递到 TensorFlow 计算图中的tf.Queues
。让我们首先介绍一些我们使用的计算原语。tf.train.range_input_producer
是一个方便的操作,从输入张量生成一个tf.Queue
。方法tf.Queue.dequeue()
从队列中提取一个张量进行训练。tf.strided_slice
提取与当前小批量数据对应的部分张量。
示例 7-5。此函数从指定位置加载 Penn Treebank 数据
def ptb_producer(raw_data, batch_size, num_steps, name=None):
"""Iterate on the raw PTB data.
This chunks up raw_data into batches of examples and returns
Tensors that are drawn from these batches.
Args:
raw_data: one of the raw data outputs from ptb_raw_data.
batch_size: int, the batch size.
num_steps: int, the number of unrolls.
name: the name of this operation (optional).
Returns:
A pair of Tensors, each shaped [batch_size, num_steps]. The
second element of the tuple is the same data time-shifted to the
right by one.
Raises:
tf.errors.InvalidArgumentError: if batch_size or num_steps are
too high.
"""
with tf.name_scope(name, "PTBProducer",
[raw_data, batch_size, num_steps]):
raw_data = tf.convert_to_tensor(raw_data, name="raw_data",
dtype=tf.int32)
data_len = tf.size(raw_data)
batch_len = data_len // batch_size
data = tf.reshape(raw_data[0 : batch_size * batch_len],
[batch_size, batch_len])
epoch_size = (batch_len - 1) // num_steps
assertion = tf.assert_positive(
epoch_size,
message="epoch_size == 0, decrease batch_size or num_steps")
with tf.control_dependencies([assertion]):
epoch_size = tf.identity(epoch_size, name="epoch_size")
i = tf.train.range_input_producer(epoch_size,
shuffle=False).dequeue()
x = tf.strided_slice(data, [0, i * num_steps],
[batch_size, (i + 1) * num_steps])
x.set_shape([batch_size, num_steps])
y = tf.strided_slice(data, [0, i * num_steps + 1],
[batch_size, (i + 1) * num_steps + 1])
y.set_shape([batch_size, num_steps])
return x, y
tf.data
TensorFlow(从版本 1.4 开始)支持一个新模块tf.data
,其中包含一个新类tf.data.Dataset
,提供了一个明确的 API 来表示数据流。很可能tf.data
最终会取代队列成为首选的输入模式,特别是因为它具有经过深思熟虑的功能 API。
在撰写本文时,tf.data
模块刚刚发布,与 API 的其他部分相比仍然相对不成熟,因此我们决定在示例中继续使用队列。但是,我们鼓励您自己了解tf.data
。
基本循环架构
我们将使用 LSTM 单元来对 Penn Treebank 进行建模,因为 LSTMs 通常在语言建模挑战中表现出优越性能。函数tf.contrib.rnn.BasicLSTMCell
已经为我们实现了基本的 LSTM 单元,因此无需自行实现(示例 7-6)。
示例 7-6。这个函数从 tf.contrib 中包装了一个 LSTM 单元
def lstm_cell():
return tf.contrib.rnn.BasicLSTMCell(
size, forget_bias=0.0, state_is_tuple=True,
reuse=tf.get_variable_scope().reuse)
使用 TensorFlow Contrib 代码是否可以接受?
请注意,我们使用的 LSTM 实现来自tf.contrib
。在工业强度项目中使用tf.contrib
中的代码是否可以接受?对此仍有争议。根据我们的个人经验,tf.contrib
中的代码往往比核心 TensorFlow 库中的代码稍微不稳定,但通常仍然相当可靠。tf.contrib
中通常有许多有用的库和实用程序,而核心 TensorFlow 库中并没有。我们建议根据需要使用tf.contrib
中的部分代码,但请注意您使用的部分并在核心 TensorFlow 库中有等价物时进行替换。
示例 7-7 中的代码片段指示 TensorFlow 为我们的词汇表中的每个单词学习一个词嵌入。对我们来说关键的函数是tf.nn.embedding_lookup
,它允许我们执行正确的张量查找操作。请注意,我们需要手动将嵌入矩阵定义为 TensorFlow 变量。
示例 7-7。为词汇表中的每个单词学习一个词嵌入
with tf.device("/cpu:0"):
embedding = tf.get_variable(
"embedding", [vocab_size, size], dtype=tf.float32)
inputs = tf.nn.embedding_lookup(embedding, input_.input_data)
有了我们手头的词向量,我们只需要将 LSTM 单元(使用函数lstm_cell
)应用于序列中的每个词向量。为此,我们只需使用 Python 的for
循环来构建所需的一系列对cell()
的调用。这里只有一个技巧:我们需要确保在每个时间步重复使用相同的变量,因为 LSTM 单元应在每个时间步执行相同的操作。幸运的是,变量作用域的reuse_variables()
方法使我们能够轻松做到这一点。参见示例 7-8。
示例 7-8。将 LSTM 单元应用于输入序列中的每个词向量
outputs = []
state = self._initial_state
with tf.variable_scope("RNN"):
for time_step in range(num_steps):
if time_step > 0: tf.get_variable_scope().reuse_variables()
(cell_output, state) = cell(inputs[:, time_step, :], state)
outputs.append(cell_output)
现在剩下的就是定义与图相关的损失,以便对其进行训练。幸运的是,TensorFlow 在tf.contrib
中提供了用于训练语言模型的损失。我们只需要调用tf.contrib.seq2seq.sequence_loss
(示例 7-9)。在底层,这个损失实际上是一种困惑度。
示例 7-9。添加序列损失
# use the contrib sequence loss and average over the batches
loss = tf.contrib.seq2seq.sequence_loss(
logits,
input_.targets,
tf.ones([batch_size, num_steps], dtype=tf.float32),
average_across_timesteps=False,
average_across_batch=True
)
# update the cost variables
self._cost = cost = tf.reduce_sum(loss)
困惑
困惑度经常用于语言建模挑战。它是二元交叉熵的一种变体,用于衡量学习分布与数据真实分布之间的接近程度。经验上,困惑度在许多语言建模挑战中都被证明是有用的,我们在这里也利用了它(因为sequence_loss
只是实现了针对序列的困惑度)。
然后我们可以使用标准的梯度下降方法训练这个图。我们略去了底层代码的一些混乱细节,但建议您如果感兴趣可以查看 GitHub。评估训练模型的质量也变得简单,因为困惑度既用作训练损失又用作评估指标。因此,我们可以简单地显示self._cost
来评估模型的训练情况。我们鼓励您自己训练模型!
读者的挑战
尝试通过尝试不同的模型架构降低 Penn Treebank 上的困惑度。请注意,这些实验可能会在没有 GPU 的情况下耗费时间。
回顾
本章向您介绍了循环神经网络(RNNs),这是一种用于学习序列数据的强大架构。RNNs 能够学习控制数据序列的基本演变规则。虽然 RNNs 可以用于建模简单的时间序列,但在建模复杂的序列数据(如语音和自然语言)时最为强大。
我们向您介绍了许多 RNN 变体,如 LSTMs 和 GRUs,它们在具有复杂长程交互的数据上表现更好,并且还简要讨论了神经图灵机的令人兴奋的前景。我们以一个深入的案例研究结束了本章,该案例将 LSTMs 应用于对 Penn Treebank 进行建模。
在第八章中,我们将向您介绍强化学习,这是一种学习玩游戏的强大技术。
第八章:强化学习
到目前为止,我们在本书中介绍的学习技术属于监督学习或无监督学习的范畴。在这两种情况下,解决给定问题需要数据科学家设计一个处理和处理输入数据的深度架构,并将架构的输出连接到适合当前问题的损失函数。这个框架是广泛适用的,但并非所有应用都能很好地适应这种思维方式。让我们考虑训练一个机器学习模型赢得国际象棋比赛的挑战。将棋盘作为空间输入使用卷积网络处理似乎是合理的,但损失会是什么?我们的标准损失函数如交叉熵或 L² 损失似乎都不太适用。
强化学习提供了一个数学框架,非常适合解决游戏问题。中心数学概念是 马尔可夫决策过程,这是一个用于建模与提供在完成某些 动作 后提供 奖励 的 环境 互动的 AI 代理的工具。这个框架被证明是灵活和通用的,并在近年来找到了许多应用。值得注意的是,强化学习作为一个领域已经相当成熟,自上世纪 70 年代以来就以可识别的形式存在。然而,直到最近,大多数强化学习系统只能解决玩具问题。最近的工作揭示了这些限制可能是由于缺乏复杂的数据输入机制;许多游戏或机器人环境的手工设计特征通常不足。在现代硬件上进行端到端训练的深度表示提取似乎突破了早期强化学习系统的障碍,并在近年取得了显著的成果。
可以说,深度强化学习的第一次突破是在 ATARI 街机游戏上取得的。ATARI 街机游戏传统上是在游戏机厅玩的,提供给用户简单的游戏,通常不需要复杂的策略,但可能需要良好的反应能力。图 8-1 展示了流行的 ATARI 游戏 Breakout 的截图。近年来,由于良好的 ATARI 模拟软件的开发,ATARI 游戏已成为游戏算法的测试平台。最初,应用于 ATARI 的强化学习算法并没有取得出色的结果;算法理解视觉游戏状态的要求使大多数尝试受挫。然而,随着卷积网络的成熟,DeepMind 的研究人员意识到卷积网络可以与现有的强化学习技术结合,并进行端到端训练。
![打砖块.jpg]()
图 8-1. ATARI 街机游戏 Breakout 的截图。玩家必须使用屏幕底部的挡板弹球,打破屏幕顶部的砖块。
结果系统取得了出色的成绩,并学会了以超人标准玩许多 ATARI 游戏(尤其是那些依赖快速反应的游戏)。图 8-2 列出了 DeepMind 的 DQN 算法在 ATARI 游戏中取得的得分。这一突破性成果推动了深度强化学习领域的巨大增长,并激发了无数研究人员探索深度强化学习技术的潜力。与此同时,DeepMind 的 ATARI 结果显示,强化学习技术能够解决依赖短期动作的系统。这些结果并没有表明深度强化学习系统能够解决需要更大战略规划的游戏。
![atari_scores.jpg]()
图 8-2. DeepMind 的 DQN 强化学习算法在各种 ATARI 游戏中的结果。100% 是一个强大的人类玩家的得分。请注意,DQN 在许多游戏中取得了超人类表现,但在其他游戏中表现相当糟糕。
计算机围棋
1994 年,IBM 推出了 Deep Blue 系统,后来成功击败了加里·卡斯帕罗夫在备受瞩目的国际象棋比赛中。该系统依靠蛮力计算来扩展可能的国际象棋走法树(在一定程度上借助手工制作的国际象棋启发式方法)来进行大师级别的国际象棋对局。
计算机科学家尝试将类似的技术应用于其他游戏,如围棋。不幸的是,对于早期的实验者来说,围棋的 19×19 棋盘比国际象棋的 8×8 棋盘大得多。因此,可能移动的树比国际象棋快速扩展得多,简单的粗略计算表明,根据摩尔定律,要以 Deep Blue 的风格实现围棋的蛮力解决方案需要很长时间。使事情复杂化的是,在半局围棋游戏中不存在简单的评估谁领先的启发式方法(确定黑色还是白色领先对于最好的人类分析师来说是一个臭名昭著的嘈杂艺术)。因此,直到最近,许多著名的计算机科学家认为,强大的计算机围棋游戏至少还需要十年的时间。
为了展示其强化学习算法的威力,DeepMind 接受了学习玩围棋的挑战,这是一个需要复杂战略规划的游戏。在一篇引人注目的论文中,DeepMind 展示了其深度强化学习引擎 AlphaGo,它将卷积网络与基于树的搜索相结合,击败了人类围棋大师李世石(图 8-3)。
![lee_sedol.jpg]()
图 8-3. 人类围棋冠军李世石与 AlphaGo 对战。李世石最终以 1-4 输掉比赛,但成功赢得了一局。这种胜利不太可能在 AlphaGo 的改进后继者(如 AlphaZero)面前复制。
AlphaGo 令人信服地证明了深度强化学习技术能够学习解决复杂的战略游戏。突破的核心是意识到卷积网络可以学习估计在半局游戏中黑色或白色领先,这使得游戏树可以在合理的深度上被截断。(AlphaGo 还估计哪些移动最有成效,从而使游戏树空间得以第二次修剪。)AlphaGo 的胜利真正将深度强化学习推向了前台,许多研究人员正在努力将 AlphaGo 风格的系统转变为实际应用。
在本章中,我们讨论强化学习算法,特别是深度强化学习架构。然后,我们向读者展示如何成功地将强化学习应用于井字棋游戏。尽管游戏很简单,但训练一个成功的强化学习者来玩井字棋需要相当大的复杂性,您很快就会看到。
本章的代码改编自 DeepChem 强化学习库,特别是由 Peter Eastman 和 Karl Leswing 创建的示例代码。感谢 Peter 在本章示例代码的调试和调优方面提供的帮助。
马尔可夫决策过程
在讨论强化学习算法之前,确定强化学习方法试图解决的问题族将非常有用。马尔可夫决策过程(MDPs)的数学框架非常适用于制定强化学习方法。传统上,MDPs 是用一系列希腊符号介绍的,但我们将尝试通过提供一些基本直觉来进行讨论。
MDPs 的核心是一个环境和一个代理的组合。环境编码了代理寻求行动的“世界”。示例环境可以包括游戏世界。例如,围棋棋盘上,对面坐着李世石大师,这是一个有效的环境。另一个潜在的环境可能是围绕着一架小型机器人直升机的环境。在斯坦福大学的一个著名的早期强化学习成功中,由安德鲁·吴领导的团队训练了一架直升机使用强化学习倒飞,如图 8-4 所示。
![upside_down_helicopter.jpg]()
图 8-4. 安德鲁·吴在斯坦福大学的团队,从 2004 年到 2010 年,训练了一架直升机学会使用强化学习倒飞。这项工作需要建立一个复杂而准确的物理模拟器。
代理是在环境中行动的学习实体。在我们的第一个例子中,AlphaGo 本身就是代理。在第二个例子中,机器人直升机(或更准确地说,机器人直升机中的控制算法)是代理。每个代理都有一组可以在环境中采取的行动。对于 AlphaGo,这些构成有效的围棋着法。对于机器人直升机,这些包括控制主旋翼和副旋翼。
代理所采取的行动被认为会对环境产生影响。在 AlphaGo 的情况下,这种影响是确定性的(AlphaGo 决定放置一个围棋子导致该子被放置)。在直升机的情况下,影响可能是概率性的(直升机位置的变化可能取决于风况,这不能有效地建模)。
模型的最后一部分是奖励的概念。与监督学习不同,监督学习中存在明确的标签供学习,或者无监督学习中的挑战是学习数据的潜在结构,强化学习在部分、稀疏奖励的环境中运作。在围棋中,奖励是在游戏结束时获得的,无论是胜利还是失败,而在直升机飞行中,奖励可能是为成功飞行或完成特技动作而给出的。
奖励函数的设计很困难
强化学习中最大的挑战之一是设计奖励,以促使代理人学习所需的行为。即使是简单的赢/输游戏,如围棋或井字棋,这也可能非常困难。输掉应该受到多少惩罚,赢得应该受到多少奖励?目前还没有好的答案。
对于更复杂的行为,这可能非常具有挑战性。许多研究表明,简单的奖励可能导致代理学习出人意料的甚至可能有害的行为。这些系统引发了对未来代理人具有更大自主权的担忧,当它们在真实世界中被释放后,经过训练以优化不良奖励函数时,可能会造成混乱。
总的来说,强化学习比监督学习技术不够成熟,我们警告应该非常谨慎地决定在生产系统中部署强化学习。考虑到对学习行为的不确定性,请确保彻底测试任何部署的强化学习系统。
强化学习算法
现在我们已经向您介绍了强化学习的核心数学结构,让我们考虑如何设计算法来学习强化学习代理的智能行为。在高层次上,强化学习算法可以分为基于模型和无模型算法。中心区别在于算法是否试图学习其环境如何行动的内部模型。对于简单的环境,如井字棋,模型动态是微不足道的。对于更复杂的环境,如直升机飞行甚至 ATARI 游戏,底层环境可能非常复杂。避免构建环境的显式模型,而是采用隐式模型来指导代理如何行动可能更为实际。
模拟和强化学习
任何强化学习算法都需要通过评估代理当前行为并改变以改善获得的奖励来迭代地提高当前代理的性能。这些对代理结构的更新通常包括一些梯度下降更新,我们将在接下来的章节中看到。然而,正如您从之前的章节中熟知的那样,梯度下降是一种缓慢的训练算法!可能需要数百万甚至数十亿次梯度下降步骤才能学习到有效的模型。
这在学习环境是真实世界时会带来问题;一个代理如何能与真实世界互动数百万次?在大多数情况下是不可能的。因此,大多数复杂的强化学习系统在很大程度上依赖于模拟器,这些模拟器允许与环境的计算版本进行交互。对于直升机飞行环境,研究人员面临的最大挑战之一是构建一个准确的直升机物理模拟器,以便计算学习有效的飞行策略。
Q-Learning
在马尔可夫决策过程的框架中,代理在环境中采取行动并获得与代理行动相关的奖励。Q函数预测在特定环境状态下采取特定行动的预期奖励。这个概念似乎非常简单,但当这个预期奖励包括来自未来行动的折扣奖励时,就会变得棘手。
折扣奖励
折扣奖励的概念很普遍,并且通常在财务领域引入。假设一个朋友说他下周会给你 10 美元。那未来的 10 美元对你来说不如手头上的 10 美元值钱(如果支付没有发生呢?)。因此,在数学上,引入一个降低未来支付“现值”的折扣因子 γ(通常在 0 和 1 之间)是常见做法。例如,假设你的朋友有点不太可靠。你可能决定设置 γ = 0.5,并将你朋友的承诺价值设为 10γ = 5 美元今天,以考虑奖励的不确定性。
然而,这些未来的奖励取决于代理未来采取的行动。因此,Q函数必须以递归的方式根据自身进行公式化,因为一个状态的预期奖励取决于另一个状态的奖励。这种递归定义使得学习Q函数变得棘手。这种递归关系可以在具有离散状态空间的简单环境中明确表达,并且可以用动态规划方法解决。对于更一般的环境,Q-learning 方法直到最近才变得有用。
最近,Deep Q-networks(DQN)由 DeepMind 引入,并被用于解决 ATARI 游戏,如前面提到的。DQN 背后的关键见解再次是通用逼近定理;由于Q可能是任意复杂的,我们应该用一个通用逼近器,如深度网络来建模它。虽然以前已经使用神经网络来建模Q,但 DeepMind 还为这些网络引入了经验重播的概念,这让它们能够有效地大规模训练 DQN 模型。经验重播存储观察到的游戏结果和过去游戏的转换,并在训练时重新采样它们(除了在新游戏上训练)以确保网络不会忘记过去的教训。
灾难性遗忘
神经网络很快忘记过去。事实上,这种现象被称为灾难性遗忘,可以非常迅速地发生;几个小批量更新就足以使网络忘记它先前知道的复杂行为。因此,如果没有像经验重播这样的技术,确保网络始终在过去比赛的情节上训练,就不可能学习复杂的行为。
设计一个不会遭受灾难性遗忘的深度网络训练算法仍然是一个当今的主要开放问题。人类明显不会遭受灾难性遗忘;即使多年没有骑自行车,您仍然可能记得如何骑。创建一个具有类似韧性的神经网络可能涉及添加长期外部记忆,类似于神经图灵机。不幸的是,迄今为止设计具有韧性架构的尝试都没有真正取得良好的效果。
策略学习
在前一节中,您了解了Q-learning,它旨在了解在给定环境状态下采取特定动作的预期奖励。策略学习是一种学习代理行为的替代数学框架。它引入了策略函数π,为代理在给定状态下可以采取的每个动作分配概率。
请注意,策略足以完全定义代理行为。给定一个策略,代理可以通过为当前环境状态抽样一个合适的动作来行动。策略学习很方便,因为策略可以通过称为策略梯度的算法直接学习。这个算法使用一些数学技巧,通过反向传播来计算深度网络的策略梯度。关键概念是展开。让一个代理根据其当前策略在环境中行动,并观察所有获得的奖励。然后反向传播,增加那些导致更有益奖励的动作的可能性。这个描述在高层次上是准确的,但我们将在本章后面看到更多的实现细节。
策略通常与值函数 V相关联。这个函数返回从环境的当前状态开始遵循策略π的预期折扣奖励。V和Q是密切相关的函数,因为两者都提供了从当前状态开始估计未来奖励的估计,但V不指定要采取的动作,而是假设动作是从π中抽样的。
另一个常见定义的函数是优势 A。这个函数定义了由于在给定环境状态s中采取特定动作a而预期奖励的差异,与遵循基本策略π相比。在数学上,A是根据Q和V定义的:
优势在策略学习算法中很有用,因为它让算法能够量化一个特定动作可能比策略当前推荐更合适的程度。
策略梯度在强化学习之外
尽管我们已经将策略梯度介绍为一种强化学习算法,但它同样可以被视为一种学习具有不可微子模块的深度网络的工具。当我们解开数学术语时,这意味着什么?
假设我们有一个深度网络,在网络内部调用一个外部程序。这个外部程序是一个黑匣子;它可能是一个网络调用,也可能是对 1970 年代 COBOL 例程的调用。当这个模块没有梯度时,我们如何学习深度网络的其余部分?
事实证明,策略梯度可以被重新用于估计系统的“有效”梯度。简单的直觉是可以运行多个“rollouts”,用于估计梯度。预计在未来几年会看到研究将这个想法扩展到创建具有不可微分模块的大型网络。
异步训练
在前一节介绍的策略梯度方法的一个缺点是,执行 rollout 操作需要评估代理在某个(可能是模拟的)环境中的行为。大多数模拟器都是复杂的软件,无法在 GPU 上运行。因此,进行单个学习步骤将需要运行长时间的 CPU 绑定计算。这可能导致训练速度过慢。
异步强化学习方法通过使用多个异步 CPU 线程独立执行 rollouts 来加速这个过程。这些工作线程将执行 rollouts,本地估计策略的梯度更新,然后定期与全局参数集进行同步。从经验上看,异步训练似乎显著加速了强化学习,并允许在笔记本电脑上学习相当复杂的策略。(没有 GPU!大部分计算能力用于 rollouts,因此梯度更新步骤通常不是强化学习训练的速度限制因素。)目前最流行的异步强化学习算法是异步演员优势评论家(A3C)算法。
CPU 还是 GPU?
大多数大型深度学习应用都需要 GPU,但目前强化学习似乎是一个例外。强化学习算法依赖于执行许多 rollouts,目前似乎偏向于多核 CPU 系统。在特定应用中,个别模拟器可能会被移植到 GPU 上更快地运行,但基于 CPU 的模拟器可能会在不久的将来继续占主导地位。
强化学习的局限性
马尔可夫决策过程的框架是非常普遍的。例如,行为科学家通常使用马尔可夫决策过程来理解和建模人类决策过程。这个框架的数学普遍性促使科学家们提出,解决强化学习可能会促使人工通用智能(AGI)的创造。AlphaGo 对李世石的惊人成功加强了这种信念,事实上,像 OpenAI 和 DeepMind 这样的研究团队致力于构建 AGI,他们大部分的努力都集中在开发新的强化学习技术上。
尽管如此,强化学习目前存在重大弱点。仔细的基准测试工作表明,强化学习技术对超参数的选择非常敏感(即使按照深度学习的标准,它已经比其他技术如随机森林更加挑剔)。正如我们所提到的,奖励函数工程非常不成熟。人类能够内部设计自己的奖励函数,或者有效地学习从观察中复制奖励函数。虽然已经提出了直接学习奖励函数的“逆强化学习”算法,但这些算法在实践中存在许多限制。
除了这些基本限制外,仍然存在许多实际的扩展问题。人类能够玩那些将高级战略与成千上万的“微”动作结合在一起的游戏。例如,玩策略游戏星际争霸的大师级水平(参见图 8-5)需要复杂的战略策略,结合对数百个单位的精心控制。游戏可能需要数千次局部移动才能完成。此外,与围棋或国际象棋不同,星际争霸有一个“战争迷雾”,玩家无法看到整个游戏状态。这种大规模游戏状态和不确定性的结合使得强化学习在星际争霸上的尝试失败。因此,DeepMind 和其他团队的 AI 研究人员正在集中精力用深度强化学习方法解决星际争霸。尽管付出了一些努力,但最好的星际争霸机器人仍停留在业余水平。
![deep_starcraft.png]()
图 8-5. 为玩实时战略游戏星际争霸所需的一系列子任务。在这个游戏中,玩家必须建立一个可以用来击败对方的军队。成功的星际争霸游戏需要掌握资源规划、探索和复杂策略。最好的计算机星际争霸代理仍停留在业余水平。
总的来说,人们普遍认为强化学习是一种有用的技术,可能在未来几十年内产生深远影响,但也清楚强化学习方法的许多实际限制意味着大部分工作在短期内仍将继续在研究实验室中进行。
玩井字棋
井字棋是一种简单的双人游戏。玩家在一个 3×3 的游戏棋盘上放置 X 和 O,直到一名玩家成功地将她的三个棋子排成一行。首先完成这个目标的玩家获胜。如果在棋盘填满之前没有玩家成功排成三个一行,游戏以平局结束。图 8-6 展示了一个井字棋游戏棋盘。
![Tic_tac_toe.png]()
图 8-6. 一个井字棋游戏棋盘。
井字棋是强化学习技术的一个很好的测试平台。这个游戏足够简单,不需要大量的计算能力来训练有效的代理程序。与此同时,尽管井字棋很简单,学习一个有效的代理程序需要相当的复杂性。本节中的 TensorFlow 代码可以说是本书中最复杂的例子。在本节的其余部分,我们将带领你设计一个 TensorFlow 井字棋异步强化学习代理程序。
面向对象
到目前为止,在本书中介绍的代码主要由脚本和较小的辅助函数组成。然而,在本章中,我们将转向面向对象的编程风格。如果你来自科学界而不是技术界,这种编程风格可能对你来说是新的。简而言之,面向对象的程序定义了对象,这些对象模拟了世界的各个方面。例如,你可能想要定义Environment
、Agent
或Reward
对象,这些对象直接对应这些数学概念。类是用于实例化(或创建)许多新对象的对象模板。例如,你很快就会看到一个Environment
类定义,我们将用它来定义许多特定的Environment
对象。
面向对象是构建复杂系统的特别强大的工具,因此我们将使用它来简化我们的强化学习系统的设计。实际上,你的现实世界深度学习(或强化学习)系统很可能也需要是面向对象的,因此我们鼓励你花一些时间来掌握面向对象设计。有许多优秀的书籍涵盖了面向对象设计的基础知识,我们建议你在需要时查阅。
抽象环境
让我们从定义一个抽象的Environment
对象开始,它将系统的状态编码为 NumPy 对象的列表(示例 8-1)。这个Environment
对象非常通用(改编自 DeepChem 的强化学习引擎),因此它可以很容易地作为您可能想要实现的其他强化学习项目的模板。
示例 8-1。这个类定义了构建新环境的模板
class Environment(object):
"""An environment in which an actor performs actions to accomplish a task.
An environment has a current state, which is represented as either a single NumPy
array, or optionally a list of NumPy arrays. When an action is taken, that causes
the state to be updated. Exactly what is meant by an "action" is defined by each
subclass. As far as this interface is concerned, it is simply an arbitrary object.
The environment also computes a reward for each action, and reports when the task
has been terminated (meaning that no more actions may be taken).
"""
def __init__(self, state_shape, n_actions, state_dtype=None):
"""Subclasses should call the superclass constructor in addition to doing their
own initialization."""
self.state_shape = state_shape
self.n_actions = n_actions
if state_dtype is None:
# Assume all arrays are float32.
if isinstance(state_shape[0], collections.Sequence):
self.state_dtype = [np.float32] * len(state_shape)
else:
self.state_dtype = np.float32
else:
self.state_dtype = state_dtype
井字棋环境
我们需要专门化Environment
类以创建一个适合我们需求的TicTacToeEnvironment
。为此,我们构建一个Environment
的子类,添加更多功能,同时保留原始超类的核心功能。在示例 8-2 中,我们将TicTacToeEnvironment
定义为Environment
的子类,添加了特定于井字棋的细节。
示例 8-2。TicTacToeEnvironment 类定义了构建新井字棋环境的模板
class TicTacToeEnvironment(dc.rl.Environment):
"""
Play tictactoe against a randomly acting opponent
"""
X = np.array([1.0, 0.0])
O = np.array([0.0, 1.0])
EMPTY = np.array([0.0, 0.0])
ILLEGAL_MOVE_PENALTY = -3.0
LOSS_PENALTY = -3.0
NOT_LOSS = 0.1
DRAW_REWARD = 5.0
WIN_REWARD = 10.0
def __init__(self):
super(TicTacToeEnvironment, self).__init__([(3, 3, 2)], 9)
self.terminated = None
self.reset()
这里要注意的第一个有趣的细节是,我们将棋盘状态定义为形状为(3, 3, 2)
的 NumPy 数组。我们使用了X
和O
的独热编码(独热编码不仅在自然语言处理中有用!)。
第二个需要注意的重要事情是,环境通过设置违规移动和失败的惩罚以及平局和胜利的奖励来明确定义奖励函数。这段代码强有力地说明了奖励函数工程的任意性。为什么是这些特定的数字?
从经验上看,这些选择似乎导致了稳定的行为,但我们鼓励您尝试使用替代奖励设置来观察结果。在这个实现中,我们指定代理始终扮演X
,但随机确定是X
还是O
先走。函数get_O_move()
简单地在游戏棋盘上的随机空格上放置一个O
。TicTacToeEnvironment
编码了一个扮演O
的对手,总是选择一个随机移动。reset()
函数简单地清空棋盘,并在这个游戏中O
先走时随机放置一个O
。参见示例 8-3。
示例 8-3。TicTacToeEnvironment 类的更多方法
def reset(self):
self.terminated = False
self.state = [np.zeros(shape=(3, 3, 2), dtype=np.float32)]
# Randomize who goes first
if random.randint(0, 1) == 1:
move = self.get_O_move()
self.state[0][move[0]][move[1]] = TicTacToeEnvironment.O
def get_O_move(self):
empty_squares = []
for row in range(3):
for col in range(3):
if np.all(self.state[0][row][col] == TicTacToeEnvironment.EMPTY):
empty_squares.append((row, col))
return random.choice(empty_squares)
实用函数game_over()
报告游戏是否结束,如果所有方块都填满。check_winner()
检查指定的玩家是否取得了三连胜并赢得了比赛(示例 8-4)。
示例 8-4。TicTacToeEnvironment 类的用于检测游戏是否结束以及谁赢得比赛的实用方法
def check_winner(self, player):
for i in range(3):
row = np.sum(self.state[0][i][:], axis=0)
if np.all(row == player * 3):
return True
col = np.sum(self.state[0][:][i], axis=0)
if np.all(col == player * 3):
return True
diag1 = self.state[0][0][0] + self.state[0][1][1] + self.state[0][2][2]
if np.all(diag1 == player * 3):
return True
diag2 = self.state[0][0][2] + self.state[0][1][1] + self.state[0][2][0]
if np.all(diag2 == player * 3):
return True
return False
def game_over(self):
for i in range(3):
for j in range(3):
if np.all(self.state[0][i][j] == TicTacToeEnvironment.EMPTY):
return False
return True
在我们的实现中,一个动作只是一个介于 0 和 8 之间的数字,指定X
方块放置的位置。step()
方法检查这个位置是否被占用(如果是,则返回惩罚),然后放置方块。如果X
赢了,就会返回奖励。否则,允许随机的O
对手进行移动。如果O
赢了,就会返回惩罚。如果游戏以平局结束,就会返回惩罚。否则,游戏将继续进行,获得一个NOT_LOSS
奖励。参见示例 8-5。
示例 8-5。这个方法执行模拟的一步
def step(self, action):
self.state = copy.deepcopy(self.state)
row = action // 3
col = action % 3
# Illegal move -- the square is not empty
if not np.all(self.state[0][row][col] == TicTacToeEnvironment.EMPTY):
self.terminated = True
return TicTacToeEnvironment.ILLEGAL_MOVE_PENALTY
# Move X
self.state[0][row][col] = TicTacToeEnvironment.X
# Did X Win
if self.check_winner(TicTacToeEnvironment.X):
self.terminated = True
return TicTacToeEnvironment.WIN_REWARD
if self.game_over():
self.terminated = True
return TicTacToeEnvironment.DRAW_REWARD
move = self.get_O_move()
self.state[0][move[0]][move[1]] = TicTacToeEnvironment.O
# Did O Win
if self.check_winner(TicTacToeEnvironment.O):
self.terminated = True
return TicTacToeEnvironment.LOSS_PENALTY
if self.game_over():
self.terminated = True
return TicTacToeEnvironment.DRAW_REWARD
return TicTacToeEnvironment.NOT_LOSS
层次抽象
运行异步强化学习算法,如 A3C,需要每个线程都能访问一个策略模型的单独副本。这些模型的副本必须定期与彼此重新同步,以便进行训练。我们可以构建多个 TensorFlow 图的最简单方法是什么?
一个简单的可能性是创建一个函数,在一个单独的 TensorFlow 图中创建模型的副本。这种方法效果很好,但对于复杂的网络来说会变得有点混乱。使用一点面向对象的方法可以显著简化这个过程。由于我们的强化学习代码是从 DeepChem 库中改编而来的,我们使用了 DeepChem 中的 TensorGraph 框架的简化版本(请参阅 https://deepchem.io 获取信息和文档)。这个框架类似于其他高级 TensorFlow 框架,比如 Keras。所有这些模型中的核心抽象是引入一个 Layer
对象,它封装了深度网络的一部分。
Layer
是 TensorFlow 图中的一个部分,接受输入层列表 in_layers
。在这个抽象中,深度架构由层的 有向图 组成。有向图类似于您在第六章中看到的无向图,但是它们的边上有方向。在这种情况下,in_layers
有边指向新的 Layer
,方向指向新层。您将在下一节中了解更多关于这个概念的内容。
我们使用 tf.register_tensor_conversion_function
,这是一个允许任意类注册为可转换为张量的实用程序。这个注册意味着一个 Layer
可以通过调用 tf.convert_to_tensor
被转换为 TensorFlow 张量。_get_input_tensors()
私有方法是一个实用程序,它使用 tf.convert_to_tensor
将输入层转换为输入张量。每个 Layer
负责实现一个 create_tensor()
方法,该方法指定要添加到 TensorFlow 计算图中的操作。请参见 示例 8-6。
示例 8-6. Layer 对象是面向对象深度架构中的基本抽象。它封装了网络的一部分,比如全连接层或卷积层。这个示例定义了所有这些层的通用超类。
class Layer(object):
def __init__(self, in_layers=None, **kwargs):
if "name" in kwargs:
self.name = kwargs["name"]
else:
self.name = None
if in_layers is None:
in_layers = list()
if not isinstance(in_layers, Sequence):
in_layers = [in_layers]
self.in_layers = in_layers
self.variable_scope = ""
self.tb_input = None
def create_tensor(self, in_layers=None, **kwargs):
raise NotImplementedError("Subclasses must implement for themselves")
def _get_input_tensors(self, in_layers):
"""Get the input tensors to his layer.
Parameters
----------
in_layers: list of Layers or tensors
the inputs passed to create_tensor(). If None, this layer's inputs will
be used instead.
"""
if in_layers is None:
in_layers = self.in_layers
if not isinstance(in_layers, Sequence):
in_layers = [in_layers]
tensors = []
for input in in_layers:
tensors.append(tf.convert_to_tensor(input))
return tensors
def _convert_layer_to_tensor(value, dtype=None, name=None, as_ref=False):
return tf.convert_to_tensor(value.out_tensor, dtype=dtype, name=name)
tf.register_tensor_conversion_function(Layer, _convert_layer_to_tensor)
前面的描述是抽象的,但在实践中很容易使用。示例 8-7 展示了一个 Squeeze
层,它使用 Layer
包装了 tf.squeeze
(您稍后会发现这个类很方便)。请注意,Squeeze
是 Layer
的子类。
示例 8-7. Squeeze 层压缩其输入
class Squeeze(Layer):
def __init__(self, in_layers=None, squeeze_dims=None, **kwargs):
self.squeeze_dims = squeeze_dims
super(Squeeze, self).__init__(in_layers, **kwargs)
def create_tensor(self, in_layers=None, **kwargs):
inputs = self._get_input_tensors(in_layers)
parent_tensor = inputs[0]
out_tensor = tf.squeeze(parent_tensor, squeeze_dims=self.squeeze_dims)
self.out_tensor = out_tensor
return out_tensor
Input
层为方便起见包装了占位符(示例 8-8)。请注意,必须为我们使用的每个层调用 Layer.create_tensor
方法,以构建 TensorFlow 计算图。
class Input(Layer):
def __init__(self, shape, dtype=tf.float32, **kwargs):
self._shape = tuple(shape)
self.dtype = dtype
super(Input, self).__init__(**kwargs)
def create_tensor(self, in_layers=None, **kwargs):
if in_layers is None:
in_layers = self.in_layers
out_tensor = tf.placeholder(dtype=self.dtype, shape=self._shape)
self.out_tensor = out_tensor
return out_tensor
tf.keras 和 tf.estimator
TensorFlow 现在将流行的 Keras 面向对象前端集成到核心 TensorFlow 库中。Keras 包括一个 Layer
类定义,与本节中刚学到的 Layer
对象非常相似。事实上,这里的 Layer
对象是从 DeepChem 库中改编的,而 DeepChem 又是从较早版本的 Keras 改编而来的。
值得注意的是,tf.keras
还没有成为 TensorFlow 的标准高级接口。tf.estimator
模块提供了一个替代(尽管不那么丰富)的高级接口到原始 TensorFlow。
无论最终哪个前端成为标准,我们认为理解构建自己前端的基本设计原则是有益且值得的。您可能需要为您的组织构建一个需要另一种设计的新系统,因此对设计原则的扎实掌握将对您有所帮助。
定义层的图
我们在前一节中简要提到,一个深度架构可以被视为 Layer
对象的有向图。在本节中,我们将这种直觉转化为 TensorGraph
对象。这些对象负责构建底层的 TensorFlow 计算图。
TensorGraph
对象负责在内部维护一个tf.Graph
、一个tf.Session
和一个层列表(self.layers
)(示例 8-9)。有向图通过每个Layer
对象的in_layers
隐式表示。TensorGraph
还包含用于将这个tf.Graph
实例保存到磁盘的实用程序,并因此为自己分配一个目录(如果没有指定,则使用tempfile.mkdtemp()
)来存储与其底层 TensorFlow 图相关的权重的检查点。
示例 8-9。TensorGraph 包含一组层的图;TensorGraph 对象可以被视为持有您想要训练的深度架构的“模型”对象
class TensorGraph(object):
def __init__(self,
batch_size=100,
random_seed=None,
graph=None,
learning_rate=0.001,
model_dir=None,
**kwargs):
"""
Parameters
----------
batch_size: int
default batch size for training and evaluating
graph: tensorflow.Graph
the Graph in which to create Tensorflow objects. If None, a new Graph
is created.
learning_rate: float or LearningRateSchedule
the learning rate to use for optimization
kwargs
"""
# Layer Management
self.layers = dict()
self.features = list()
self.labels = list()
self.outputs = list()
self.task_weights = list()
self.loss = None
self.built = False
self.optimizer = None
self.learning_rate = learning_rate
# Singular place to hold Tensor objects which don't serialize
# See TensorGraph._get_tf() for more details on lazy construction
self.tensor_objects = {
"Graph": graph,
#"train_op": None,
}
self.global_step = 0
self.batch_size = batch_size
self.random_seed = random_seed
if model_dir is not None:
if not os.path.exists(model_dir):
os.makedirs(model_dir)
else:
model_dir = tempfile.mkdtemp()
self.model_dir_is_temp = True
self.model_dir = model_dir
self.save_file = "%s/%s" % (self.model_dir, "model")
self.model_class = None
私有方法_add_layer
执行一些工作,将一个新的Layer
对象添加到TensorGraph
中(示例 8-10)。
示例 8-10。_add_layer 方法添加一个新的 Layer 对象
def _add_layer(self, layer):
if layer.name is None:
layer.name = "%s_%s" % (layer.__class__.__name__, len(self.layers) + 1)
if layer.name in self.layers:
return
if isinstance(layer, Input):
self.features.append(layer)
self.layers[layer.name] = layer
for in_layer in layer.in_layers:
self._add_layer(in_layer)
TensorGraph
中的层必须形成一个有向无环图(图中不能有循环)。因此,我们可以对这些层进行拓扑排序。直观地说,拓扑排序“排序”图中的层,以便每个Layer
对象的in_layers
在有序列表中位于它之前。这种拓扑排序是必要的,以确保给定层的所有输入层在该层本身之前添加到图中(示例 8-11)。
示例 8-11。topsort 方法对 TensorGraph 中的层进行排序
def topsort(self):
def add_layers_to_list(layer, sorted_layers):
if layer in sorted_layers:
return
for in_layer in layer.in_layers:
add_layers_to_list(in_layer, sorted_layers)
sorted_layers.append(layer)
sorted_layers = []
for l in self.features + self.labels + self.task_weights + self.outputs:
add_layers_to_list(l, sorted_layers)
add_layers_to_list(self.loss, sorted_layers)
return sorted_layers
build()
方法负责通过按照拓扑顺序调用layer.create_tensor
来填充tf.Graph
实例(示例 8-12)。
示例 8-12。build 方法填充底层的 TensorFlow 图
def build(self):
if self.built:
return
with self._get_tf("Graph").as_default():
self._training_placeholder = tf.placeholder(dtype=tf.float32, shape=())
if self.random_seed is not None:
tf.set_random_seed(self.random_seed)
for layer in self.topsort():
with tf.name_scope(layer.name):
layer.create_tensor(training=self._training_placeholder)
self.session = tf.Session()
self.built = True
set_loss()
方法为训练向图中添加损失。add_output()
指定所讨论的层可能从图中获取。set_optimizer()
指定用于训练的优化器(示例 8-13)。
示例 8-13。这些方法向计算图中添加必要的损失、输出和优化器
def set_loss(self, layer):
self._add_layer(layer)
self.loss = layer
def add_output(self, layer):
self._add_layer(layer)
self.outputs.append(layer)
def set_optimizer(self, optimizer):
"""Set the optimizer to use for fitting."""
self.optimizer = optimizer
get_layer_variables()
方法用于获取层创建的可学习tf.Variable
对象。私有方法_get_tf
用于获取支持TensorGraph
的tf.Graph
和优化器实例。get_global_step
是一个方便的方法,用于获取训练过程中的当前步骤(从构造时的 0 开始)。参见示例 8-14。
示例 8-14。获取与每个层相关的可学习变量
def get_layer_variables(self, layer):
"""Get the list of trainable variables in a layer of the graph."""
if not self.built:
self.build()
with self._get_tf("Graph").as_default():
if layer.variable_scope == "":
return []
return tf.get_collection(
tf.GraphKeys.TRAINABLE_VARIABLES, scope=layer.variable_scope)
def get_global_step(self):
return self._get_tf("GlobalStep")
def _get_tf(self, obj):
"""Fetches underlying TensorFlow primitives.
Parameters
----------
obj: str
If "Graph", returns tf.Graph instance. If "Optimizer", returns the
optimizer. If "train_op", returns the train operation. If "GlobalStep" returns
the global step.
Returns
-------
TensorFlow Object
"""
if obj in self.tensor_objects and self.tensor_objects[obj] is not None:
return self.tensor_objects[obj]
if obj == "Graph":
self.tensor_objects["Graph"] = tf.Graph()
elif obj == "Optimizer":
self.tensor_objects["Optimizer"] = tf.train.AdamOptimizer(
learning_rate=self.learning_rate,
beta1=0.9,
beta2=0.999,
epsilon=1e-7)
elif obj == "GlobalStep":
with self._get_tf("Graph").as_default():
self.tensor_objects["GlobalStep"] = tf.Variable(0, trainable=False)
return self._get_tf(obj)
最后,restore()
方法从磁盘恢复保存的TensorGraph
(示例 8-15)。(正如您将在后面看到的,TensorGraph
在训练期间会自动保存。)
示例 8-15。从磁盘恢复训练模型
def restore(self):
"""Reload the values of all variables from the most recent checkpoint file."""
if not self.built:
self.build()
last_checkpoint = tf.train.latest_checkpoint(self.model_dir)
if last_checkpoint is None:
raise ValueError("No checkpoint found")
with self._get_tf("Graph").as_default():
saver = tf.train.Saver()
saver.restore(self.session, last_checkpoint)
A3C 算法
在本节中,您将学习如何实现 A3C,即您在本章前面看到的异步强化学习算法。A3C 是一个比您之前看到的训练算法复杂得多的算法。该算法需要在多个线程中运行梯度下降,与游戏回合代码交替进行,并异步更新学习权重。由于这种额外的复杂性,我们将以面向对象的方式定义 A3C 算法。让我们从定义一个A3C
对象开始。
A3C
类实现了 A3C 算法(示例 8-16)。在基本算法上添加了一些额外的功能,以鼓励学习,特别是熵项和对广义优势估计的支持。我们不会涵盖所有这些细节,但鼓励您查阅研究文献(在文档中列出)以了解更多。
示例 8-16。定义封装异步 A3C 训练算法的 A3C 类
class A3C(object):
"""
Implements the Asynchronous Advantage Actor-Critic (A3C) algorithm.
The algorithm is described in Mnih et al, "Asynchronous Methods for Deep
Reinforcement Learning" (https://arxiv.org/abs/1602.01783). This class
requires the policy to output two quantities: a vector giving the probability
of taking each action, and an estimate of the value function for the current
state. It optimizes both outputs at once using a loss that is the sum of three
terms:
1\. The policy loss, which seeks to maximize the discounted reward for each action.
2\. The value loss, which tries to make the value estimate match the actual
discounted reward that was attained at each step.
3\. An entropy term to encourage exploration.
This class only supports environments with discrete action spaces, not
continuous ones. The "action" argument passed to the environment is an
integer, giving the index of the action to perform.
This class supports Generalized Advantage Estimation as described in Schulman
et al., "High-Dimensional Continuous Control Using Generalized Advantage
Estimation" (https://arxiv.org/abs/1506.02438). This is a method of trading
off bias and variance in the advantage estimate, which can sometimes improve
the rate of convergence. Use the advantage_lambda parameter to adjust the
tradeoff.
"""
self._env = env
self.max_rollout_length = max_rollout_length
self.discount_factor = discount_factor
self.advantage_lambda = advantage_lambda
self.value_weight = value_weight
self.entropy_weight = entropy_weight
self._optimizer = None
(self._graph, self._features, self._rewards, self._actions,
self._action_prob, self._value, self._advantages) = self.build_graph(
None, "global", model_dir)
with self._graph._get_tf("Graph").as_default():
self._session = tf.Session()
A3C 类的核心在于build_graph()
方法(示例 8-17),它构建了一个TensorGraph
实例(在其下面是一个 TensorFlow 计算图),编码了模型学习的策略。请注意,与之前看到的其他定义相比,这个定义是多么简洁!使用面向对象有很多优势。
示例 8-17。这个方法构建了 A3C 算法的计算图。请注意,策略网络是在这里使用您之前看到的 Layer 抽象定义的。
def build_graph(self, tf_graph, scope, model_dir):
"""Construct a TensorGraph containing the policy and loss calculations."""
state_shape = self._env.state_shape
features = []
for s in state_shape:
features.append(Input(shape=[None] + list(s), dtype=tf.float32))
d1 = Flatten(in_layers=features)
d2 = Dense(
in_layers=[d1],
activation_fn=tf.nn.relu,
normalizer_fn=tf.nn.l2_normalize,
normalizer_params={"dim": 1},
out_channels=64)
d3 = Dense(
in_layers=[d2],
activation_fn=tf.nn.relu,
normalizer_fn=tf.nn.l2_normalize,
normalizer_params={"dim": 1},
out_channels=32)
d4 = Dense(
in_layers=[d3],
activation_fn=tf.nn.relu,
normalizer_fn=tf.nn.l2_normalize,
normalizer_params={"dim": 1},
out_channels=16)
d4 = BatchNorm(in_layers=[d4])
d5 = Dense(in_layers=[d4], activation_fn=None, out_channels=9)
value = Dense(in_layers=[d4], activation_fn=None, out_channels=1)
value = Squeeze(squeeze_dims=1, in_layers=[value])
action_prob = SoftMax(in_layers=[d5])
rewards = Input(shape=(None,))
advantages = Input(shape=(None,))
actions = Input(shape=(None, self._env.n_actions))
loss = A3CLoss(
self.value_weight,
self.entropy_weight,
in_layers=[rewards, actions, action_prob, value, advantages])
graph = TensorGraph(
batch_size=self.max_rollout_length,
graph=tf_graph,
model_dir=model_dir)
for f in features:
graph._add_layer(f)
graph.add_output(action_prob)
graph.add_output(value)
graph.set_loss(loss)
graph.set_optimizer(self._optimizer)
with graph._get_tf("Graph").as_default():
with tf.variable_scope(scope):
graph.build()
return graph, features, rewards, actions, action_prob, value, advantages
这个示例中有很多代码。让我们将其拆分成多个示例,并更仔细地讨论。示例 8-18 将TicTacToeEnvironment
的数组编码输入到图的Input
实例中。
示例 8-18。这段代码从 build_graph()方法中输入了 TicTacToeEnvironment 的数组编码。
state_shape = self._env.state_shape
features = []
for s in state_shape:
features.append(Input(shape=[None] + list(s), dtype=tf.float32))
示例 8-19 显示了用于从环境中构建奖励、观察到的优势和采取的行动的输入的代码。
示例 8-19。这段代码从 build_graph()方法中定义了奖励、优势和行动的输入对象
rewards = Input(shape=(None,))
advantages = Input(shape=(None,))
actions = Input(shape=(None, self._env.n_actions))
策略网络负责学习策略。在示例 8-20 中,输入的棋盘状态首先被展平为输入特征向量。一系列全连接(或Dense
)变换被应用于展平的棋盘。最后,使用Softmax
层来从d5
预测动作概率(请注意,out_channels
设置为 9,对应井字棋棋盘上的每个可能移动)。
示例 8-20。这段代码从 build_graph()方法中定义了策略网络
d1 = Flatten(in_layers=features)
d2 = Dense(
in_layers=[d1],
activation_fn=tf.nn.relu,
normalizer_fn=tf.nn.l2_normalize,
normalizer_params={"dim": 1},
out_channels=64)
d3 = Dense(
in_layers=[d2],
activation_fn=tf.nn.relu,
normalizer_fn=tf.nn.l2_normalize,
normalizer_params={"dim": 1},
out_channels=32)
d4 = Dense(
in_layers=[d3],
activation_fn=tf.nn.relu,
normalizer_fn=tf.nn.l2_normalize,
normalizer_params={"dim": 1},
out_channels=16)
d4 = BatchNorm(in_layers=[d4])
d5 = Dense(in_layers=[d4], activation_fn=None, out_channels=9)
value = Dense(in_layers=[d4], activation_fn=None, out_channels=1)
value = Squeeze(squeeze_dims=1, in_layers=[value])
action_prob = SoftMax(in_layers=[d5])
特征工程是否已经过时?
在这一部分中,我们将原始的井字棋游戏棋盘输入到 TensorFlow 中,用于训练策略。然而,值得注意的是,对于比井字棋更复杂的游戏,这种方法可能不会产生令人满意的结果。关于 AlphaGo 的一个较少为人知的事实是,DeepMind 进行了复杂的特征工程,以提取棋盘上的“有趣”棋子模式,以便让 AlphaGo 的学习更容易。(这个事实被藏在 DeepMind 论文的补充信息中。)
事实仍然是,强化学习(以及深度学习方法广泛地)通常仍然需要人为引导的特征工程来提取有意义的信息,以便学习算法能够学习有效的策略和模型。随着硬件进步提供更多的计算能力,这种对特征工程的需求可能会减少,但在短期内,计划手动提取关于系统的信息以提高性能。
A3C 损失函数
我们现在已经设置好了面向对象的机制,用于为 A3C 策略网络定义损失。这个损失函数本身将被实现为一个Layer
对象(这是一个方便的抽象,深度架构的所有部分都只是层)。A3CLoss
对象实现了一个数学损失,包括三个项的总和:policy_loss
、value_loss
和用于探索的entropy
项。参见示例 8-21。
示例 8-21。这个层实现了 A3C 的损失函数
class A3CLoss(Layer):
"""This layer computes the loss function for A3C."""
def __init__(self, value_weight, entropy_weight, **kwargs):
super(A3CLoss, self).__init__(**kwargs)
self.value_weight = value_weight
self.entropy_weight = entropy_weight
def create_tensor(self, **kwargs):
reward, action, prob, value, advantage = [
layer.out_tensor for layer in self.in_layers
]
prob = prob + np.finfo(np.float32).eps
log_prob = tf.log(prob)
policy_loss = -tf.reduce_mean(
advantage * tf.reduce_sum(action * log_prob, axis=1))
value_loss = tf.reduce_mean(tf.square(reward - value))
entropy = -tf.reduce_mean(tf.reduce_sum(prob * log_prob, axis=1))
self.out_tensor = policy_loss + self.value_weight * value_loss
- self.entropy_weight * entropy
return self.out_tensor
这个定义有很多部分,让我们提取代码片段并检查。A3CLoss
层将reward, action, prob, value, advantage
层作为输入。为了数学稳定性,我们将概率转换为对数概率(这在数值上更稳定)。参见示例 8-22。
示例 8-22。这段代码从 A3CLoss 中获取奖励、动作、概率、价值、优势作为输入层,并计算对数概率
reward, action, prob, value, advantage = [
layer.out_tensor for layer in self.in_layers
]
prob = prob + np.finfo(np.float32).eps
log_prob = tf.log(prob)
策略损失计算所有观察到的优势的总和,加权为所采取动作的对数概率。(请记住,优势是采取给定动作与从原始策略获得该状态的预期奖励之间的差异)。这里的直觉是,policy_loss
提供了哪些动作是有益的信号,哪些是无益的(示例 8-23)。
示例 8-23。A3CLoss 中的此代码段定义了策略损失
policy_loss = -tf.reduce_mean(
advantage * tf.reduce_sum(action * log_prob, axis=1))
值损失计算我们对V(reward
)的估计值与观察到的V的实际值(value
)之间的差异。请注意这里使用的L²损失(示例 8-24)。
示例 8-24。A3CLoss 中的此代码段定义了值损失
value_loss = tf.reduce_mean(tf.square(reward - value))
熵项是一个附加项,通过添加一些噪音来鼓励策略进一步探索。这个项实际上是 A3C 网络的一种正则化形式。由A3CLoss
计算的最终损失是这些组件损失的线性组合。请参阅示例 8-25。
示例 8-25。A3CLoss 中的此代码段定义了添加到损失中的熵项
entropy = -tf.reduce_mean(tf.reduce_sum(prob * log_prob, axis=1))
定义工作者
到目前为止,您已经看到了如何构建策略网络,但尚未看到异步训练过程是如何实现的。从概念上讲,异步训练包括各个工作者在本地模拟游戏展开上运行梯度下降,并定期将学到的知识贡献给全局一组权重。继续我们的面向对象设计,让我们介绍Worker
类。
每个Worker
实例都持有一个模型的副本,该模型在单独的线程上异步训练(示例 8-26)。请注意,“a3c.build_graph()”用于为相关线程构建 TensorFlow 计算图的本地副本。在这里特别注意local_vars
和global_vars
。我们需要确保仅训练与此 Worker 策略副本相关的变量,而不是全局变量的副本(用于在线程之间共享信息)。因此,gradients
使用tf.gradients
仅对local_vars
的损失梯度进行计算。
示例 8-26。Worker 类实现每个线程执行的计算
class Worker(object):
"""A Worker object is created for each training thread."""
def __init__(self, a3c, index):
self.a3c = a3c
self.index = index
self.scope = "worker%d" % index
self.env = copy.deepcopy(a3c._env)
self.env.reset()
(self.graph, self.features, self.rewards, self.actions, self.action_prob,
self.value, self.advantages) = a3c.build_graph(
a3c._graph._get_tf("Graph"), self.scope, None)
with a3c._graph._get_tf("Graph").as_default():
local_vars = tf.get_collection(tf.GraphKeys.TRAINABLE_VARIABLES,
self.scope)
global_vars = tf.get_collection(tf.GraphKeys.TRAINABLE_VARIABLES,
"global")
gradients = tf.gradients(self.graph.loss.out_tensor, local_vars)
grads_and_vars = list(zip(gradients, global_vars))
self.train_op = a3c._graph._get_tf("Optimizer").apply_gradients(
grads_and_vars)
self.update_local_variables = tf.group(
* [tf.assign(v1, v2) for v1, v2 in zip(local_vars, global_vars)])
self.global_step = self.graph.get_global_step()
工作者展开
每个Worker
负责在本地模拟游戏展开。“create_rollout()”方法使用session.run
从 TensorFlow 图中获取动作概率(示例 8-27)。然后使用np.random.choice
从该策略中加权采样一个动作,权重为每个类别的概率。所采取动作的奖励是通过调用self.env.step(action)
从TicTacToeEnvironment
计算的。
示例 8-27。create_rollout 方法在本地模拟游戏展开
def create_rollout(self):
"""Generate a rollout."""
n_actions = self.env.n_actions
session = self.a3c._session
states = []
actions = []
rewards = []
values = []
# Generate the rollout.
for i in range(self.a3c.max_rollout_length):
if self.env.terminated:
break
state = self.env.state
states.append(state)
feed_dict = self.create_feed_dict(state)
results = session.run(
[self.action_prob.out_tensor, self.value.out_tensor],
feed_dict=feed_dict)
probabilities, value = results[:2]
action = np.random.choice(np.arange(n_actions), p=probabilities[0])
actions.append(action)
values.append(float(value))
rewards.append(self.env.step(action))
# Compute an estimate of the reward for the rest of the episode.
if not self.env.terminated:
feed_dict = self.create_feed_dict(self.env.state)
final_value = self.a3c.discount_factor * float(
session.run(self.value.out_tensor, feed_dict))
else:
final_value = 0.0
values.append(final_value)
if self.env.terminated:
self.env.reset()
return states, actions, np.array(rewards), np.array(values)
“process_rollouts()”方法执行计算折扣奖励、值、动作和优势所需的预处理(示例 8-28)。
示例 8-28。process_rollout 方法计算奖励、值、动作和优势,然后针对损失采取梯度下降步骤
def process_rollout(self, states, actions, rewards, values, step_count):
"""Train the network based on a rollout."""
# Compute the discounted rewards and advantages.
if len(states) == 0:
# Rollout creation sometimes fails in multithreaded environment.
# Don't process if malformed
print("Rollout creation failed. Skipping")
return
discounted_rewards = rewards.copy()
discounted_rewards[-1] += values[-1]
advantages = rewards - values[:-1] + self.a3c.discount_factor * np.array(
values[1:])
for j in range(len(rewards) - 1, 0, -1):
discounted_rewards[j-1] += self.a3c.discount_factor * discounted_rewards[j]
advantages[j-1] += (
self.a3c.discount_factor * self.a3c.advantage_lambda * advantages[j])
# Convert the actions to one-hot.
n_actions = self.env.n_actions
actions_matrix = []
for action in actions:
a = np.zeros(n_actions)
a[action] = 1.0
actions_matrix.append(a)
# Rearrange the states into the proper set of arrays.
state_arrays = [[] for i in range(len(self.features))]
for state in states:
for j in range(len(state)):
state_arrays[j].append(state[j])
# Build the feed dict and apply gradients.
feed_dict = {}
for f, s in zip(self.features, state_arrays):
feed_dict[f.out_tensor] = s
feed_dict[self.rewards.out_tensor] = discounted_rewards
feed_dict[self.actions.out_tensor] = actions_matrix
feed_dict[self.advantages.out_tensor] = advantages
feed_dict[self.global_step] = step_count
self.a3c._session.run(self.train_op, feed_dict=feed_dict)
“Worker.run()”方法执行Worker
的训练步骤,依赖于“process_rollouts()”在底层发出实际调用“self.a3c._session.run()”(示例 8-29)。
示例 8-29。run()方法是 Worker 的顶层调用
def run(self, step_count, total_steps):
with self.graph._get_tf("Graph").as_default():
while step_count[0] < total_steps:
self.a3c._session.run(self.update_local_variables)
states, actions, rewards, values = self.create_rollout()
self.process_rollout(states, actions, rewards, values, step_count[0])
step_count[0] += len(actions)
训练策略
“A3C.fit()”方法将引入的所有不同部分汇集在一起,以训练模型。该“fit()”方法负责使用 Pythonthreading
库定期生成Worker
线程。由于每个Worker
负责自己的训练,因此“fit()”方法只负责定期将训练好的模型保存到磁盘。请参阅示例 8-30。
示例 8-30。fit()方法将所有内容汇集在一起,并运行 A3C 训练算法
def fit(self,
total_steps,
max_checkpoints_to_keep=5,
checkpoint_interval=600,
restore=False):
"""Train the policy.
Parameters
----------
total_steps: int
the total number of time steps to perform on the environment, across all
rollouts on all threads
max_checkpoints_to_keep: int
the maximum number of checkpoint files to keep. When this number is
reached, older files are deleted.
checkpoint_interval: float
the time interval at which to save checkpoints, measured in seconds
restore: bool
if True, restore the model from the most recent checkpoint and continue
training from there. If False, retrain the model from scratch.
"""
with self._graph._get_tf("Graph").as_default():
step_count = [0]
workers = []
threads = []
for i in range(multiprocessing.cpu_count()):
workers.append(Worker(self, i))
self._session.run(tf.global_variables_initializer())
if restore:
self.restore()
for worker in workers:
thread = threading.Thread(
name=worker.scope,
target=lambda: worker.run(step_count, total_steps))
threads.append(thread)
thread.start()
variables = tf.get_collection(
tf.GraphKeys.GLOBAL_VARIABLES, scope="global")
saver = tf.train.Saver(variables, max_to_keep=max_checkpoints_to_keep)
checkpoint_index = 0
while True:
threads = [t for t in threads if t.isAlive()]
if len(threads) > 0:
threads[0].join(checkpoint_interval)
checkpoint_index += 1
saver.save(
self._session, self._graph.save_file, global_step=checkpoint_index)
if len(threads) == 0:
break
读者的挑战
我们强烈建议您尝试为自己训练井字游戏模型!请注意,这个例子比书中的其他例子更复杂,需要更大的计算能力。我们建议使用至少几个 CPU 核心的机器。这个要求并不过分;一台好的笔记本电脑应该足够。尝试使用类似htop
这样的工具来检查代码是否确实是多线程的。看看您能训练出多好的模型!您应该能大部分时间击败随机基线,但这个基本实现不会给您一个总是赢的模型。我们建议探索强化学习文献,并在基础实现的基础上进行扩展,看看您能做得多好。
回顾
在本章中,我们向您介绍了强化学习(RL)的核心概念。我们带您了解了 RL 方法在 ATARI、倒立直升机飞行和计算机围棋上取得的一些最近的成功。然后,我们教授了您马尔可夫决策过程的数学框架。我们通过一个详细的案例研究,带您了解了如何构建一个井字游戏代理。这个算法使用了一种复杂的训练方法 A3C,利用多个 CPU 核心加快训练速度。在第九章中,您将学习更多关于使用多个 GPU 训练模型的知识。
第九章:训练大型深度网络
到目前为止,您已经看到了如何训练可以完全在一台好的笔记本电脑上训练的小模型。所有这些模型都可以在配备 GPU 的硬件上运行,并获得显著的速度提升(除了在前一章讨论的强化学习模型的显著例外)。然而,训练更大的模型仍然需要相当高的技术含量。在本章中,我们将讨论可以用于训练深度网络的各种硬件类型,包括图形处理单元(GPU)、张量处理单元(TPU)和神经形态芯片。我们还将简要介绍用于更大深度学习模型的分布式训练原则。我们将以一个深入的案例研究结束本章,该案例改编自 TensorFlow 教程之一,演示如何在具有多个 GPU 的服务器上训练 CIFAR-10 卷积神经网络。我们建议您尝试自己运行此代码,但也承认获得多 GPU 服务器的访问权限比找到一台好的笔记本电脑更加困难。幸运的是,云上的多 GPU 服务器访问正在变得可能,并且很可能是 TensorFlow 工业用户寻求训练大型模型的最佳解决方案。
深度网络的定制硬件
正如您在整本书中所看到的,深度网络训练需要在数据的小批量上重复执行张量操作链。张量操作通常通过软件转换为矩阵乘法操作,因此深度网络的快速训练基本上取决于能够快速执行矩阵乘法操作的能力。虽然 CPU 完全可以实现矩阵乘法,但 CPU 硬件的通用性意味着很多精力将被浪费在数学运算不需要的开销上。
硬件工程师多年来已经注意到这一事实,存在多种用于处理深度网络的替代硬件。这种硬件可以广泛分为仅推断或训练和推断。仅推断硬件不能用于训练新的深度网络,但可以用于在生产中部署经过训练的模型,从而可能实现性能的数量级增加。训练和推断硬件允许原生地训练模型。目前,由于 Nvidia 团队在软件和推广方面的重大投资,Nvidia 的 GPU 硬件在训练和推断市场上占据主导地位,但许多其他竞争对手也在紧追 GPU 的脚步。在本节中,我们将简要介绍一些这些新型硬件的替代方案。除了 GPU 和 CPU 外,这些替代形式的硬件大多尚未广泛可用,因此本节的大部分内容是前瞻性的。
CPU 训练
尽管 CPU 训练绝不是训练深度网络的最先进技术,但对于较小的模型通常表现良好(正如您在本书中亲身见证的)。对于强化学习问题,多核 CPU 机器甚至可以胜过 GPU 训练。
CPU 也广泛用于深度网络的推断应用。大多数公司都在大力投资开发主要基于英特尔服务器盒的云服务器。很可能,广泛部署的第一代深度网络(在科技公司之外)将主要部署在这样的英特尔服务器上。虽然基于 CPU 的部署对于学习模型的重度部署来说并不足够,但对于第一批客户需求通常是足够的。图 9-1 展示了一个标准的英特尔 CPU。
![cpu_pic.jpg]()
图 9-1. 英特尔的 CPU。 CPU 仍然是计算机硬件的主导形式,并且存在于所有现代笔记本电脑、台式机、服务器和手机中。大多数软件都是编写为在 CPU 上执行的。数值计算(如神经网络训练)可以在 CPU 上执行,但可能比针对数值方法进行优化的定制硬件慢。
GPU 训练
GPU 首先是为了执行图形社区所需的计算而开发的。在一个幸运的巧合中,事实证明用于定义图形着色器的基本功能可以重新用于执行深度学习。在它们的数学核心中,图形和机器学习都严重依赖于矩阵乘法。从经验上看,GPU 矩阵乘法比 CPU 实现快一个数量级或两个数量级。 GPU 是如何成功地做到这一点的呢?诀窍在于 GPU 利用了成千上万个相同的线程。聪明的黑客已经成功地将矩阵乘法分解为大规模并行操作,可以提供显著的加速。图 9-2 说明了 GPU 的架构。
尽管有许多 GPU 供应商,但 Nvidia 目前主导着 GPU 市场。 Nvidia 的 GPU 的许多强大功能源自其自定义库 CUDA(计算统一设备架构),该库提供了使编写 GPU 程序更容易的基本功能。 Nvidia 提供了一个 CUDA 扩展,CUDNN,用于加速深度网络(图 9-2)。 TensorFlow 内置了 CUDNN 支持,因此您可以通过 TensorFlow 也利用 CUDNN 来加速您的网络。
![GPU_architecture.jpg]()
图 9-2. Nvidia 的 GPU 架构。 GPU 比 CPU 拥有更多的核心,非常适合执行数值线性代数,这对图形和机器学习计算都很有用。 GPU 已经成为训练深度网络的主要硬件平台。
晶体管尺寸有多重要?
多年来,半导体行业一直通过观察晶体管尺寸的进展来跟踪芯片速度的发展。随着晶体管变小,更多的晶体管可以被打包到标准芯片上,算法可以运行得更快。在撰写本书时,英特尔目前正在使用 10 纳米晶体管,并正在努力过渡到 7 纳米。近年来,晶体管尺寸的缩小速度已经显著放缓,因为在这些尺度上会出现严重的散热问题。
Nvidia 的 GPU 在某种程度上打破了这一趋势。它们倾向于使用比英特尔最好的晶体管尺寸落后一两代的尺寸,并专注于解决架构和软件瓶颈,而不是晶体管工程。到目前为止,Nvidia 的策略已经取得了成功,并且该公司在机器学习芯片领域实现了市场主导地位。
目前尚不清楚架构和软件优化能走多远。 GPU 优化是否很快会遇到与 CPU 相同的摩尔定律障碍?还是聪明的架构创新将使 GPU 更快数年?只有时间能告诉我们。
张量处理单元
张量处理单元(TPU)是由谷歌设计的定制 ASIC(特定应用集成电路),旨在加速在 TensorFlow 中设计的深度学习工作负载。与 GPU 不同,TPU 被简化并且仅实现了在芯片上执行必要矩阵乘法所需的最低限度。与 GPU 不同,TPU 依赖于相邻的 CPU 来完成大部分预处理工作。这种精简的方法使 TPU 能够以更低的能源成本实现比 GPU 更高的速度。
第一个版本的 TPU 只允许对经过训练的模型进行推断,但最新版本(TPU2)也允许对(某些)深度网络进行训练。然而,谷歌并没有发布关于 TPU 的许多细节,访问权限仅限于谷歌的合作伙伴,计划通过谷歌云实现 TPU 访问。英伟达正在从 TPU 中汲取经验,未来的英伟达 GPU 很可能会变得类似于 TPU,因此终端用户可能会从谷歌的创新中受益,无论是谷歌还是英伟达赢得了消费者深度学习市场。图 9-3 展示了 TPU 架构设计。
![TPU_architecture.jpg]()
图 9-3. 谷歌的张量处理单元(TPU)架构。TPU 是由谷歌设计的专用芯片,旨在加速深度学习工作负载。TPU 是一个协处理器,而不是一个独立的硬件部件。
什么是 ASICs?
CPU 和 GPU 都是通用芯片。CPU 通常支持汇编指令集,并设计为通用。为了实现广泛的应用,需要小心设计。GPU 不太通用,但仍允许通过诸如 CUDA 等语言实现广泛的算法。
应用特定集成电路(ASICs)试图摆脱通用性,而是专注于特定应用的需求。从历史上看,ASICs 只在市场上取得了有限的渗透率。摩尔定律的持续发展意味着通用 CPU 仅仅落后于定制 ASICs 一两步,因此硬件设计开销通常不值得付出努力。
在过去几年中,这种状况已经开始发生变化。晶体管尺寸的减小放缓了 ASIC 的使用。例如,比特币挖掘完全依赖于实现专门密码操作的定制 ASICs。
现场可编程门阵列
现场可编程门阵列(FPGAs)是一种“现场可编程”的 ASIC 类型。标准 FPGAs 通常可以通过硬件描述语言(如 Verilog)重新配置,以动态实现新的 ASIC 设计。虽然 FPGAs 通常不如定制 ASICs 高效,但它们可以比 CPU 实现提供显著的速度提升。特别是微软已经使用 FPGAs 执行深度学习推断,并声称在部署中取得了显著的加速。然而,这种方法在微软之外尚未广泛流行。
神经形态芯片
深度网络中的“神经元”在数学上模拟了 1940 年代对神经生物学的理解。不用说,自那时以来,对神经元行为的生物学理解已经取得了巨大进展。首先,现在已知深度网络中使用的非线性激活并不是神经元非线性的准确模型。“脉冲列”是一个更好的模型(见图 9-4),在这个模型中,神经元以短暂的脉冲(脉冲)激活,但大部分时间处于背景状态。
![spike_trains.jpg]()
图 9-4. 神经元经常以短暂的脉冲列(A)激活。神经形态芯片试图在计算硬件中模拟脉冲行为。生物神经元是复杂的实体(B),因此这些模型仍然只是近似。
硬件工程师花费了大量精力探索是否可以基于脉冲列而不是现有电路技术(CPU、GPU、ASIC)创建芯片设计。这些设计师认为,今天的芯片设计受到基本功耗限制的影响;大脑消耗的能量比计算机芯片少几个数量级,智能设计应该从大脑的结构中学习。
许多项目已经构建了大型脉冲列车芯片,试图扩展这一核心论点。IBM 的 TrueNorth 项目成功地构建了具有数百万“神经元”的脉冲列车处理器,并证明了这种硬件可以以比现有芯片设计低得多的功耗要求执行基本图像识别。然而,尽管取得了这些成功,但如何将现代深度架构转换为脉冲列车芯片尚不清楚。如果无法将 TensorFlow 模型“编译”到脉冲列车硬件上,这些项目在不久的将来可能不会被广泛采用。
分布式深度网络训练
在前一节中,我们调查了训练深度网络的各种硬件选项。然而,大多数组织可能只能访问 CPU 和可能是 GPU。幸运的是,可以对深度网络进行分布式训练,其中多个 CPU 或 GPU 用于更快更有效地训练模型。图 9-5 说明了使用多个 CPU/GPU 训练深度网络的两种主要范式,即数据并行和模型并行训练。您将在接下来的两节中更详细地了解这些方法。
![parallelism_modes.jpg]()
图 9-5。数据并行和模型并行是深度架构的分布式训练的两种主要模式。数据并行训练将大型数据集分割到多个计算节点上,而模型并行训练将大型模型分割到多个节点上。接下来的两节将更深入地介绍这两种方法。
数据并行 ism
数据并行 ism 是最常见的多节点深度网络训练类型。数据并行模型将大型数据集分割到不同的机器上。大多数节点是工作节点,并且可以访问用于训练网络的总数据的一部分。每个工作节点都有一个正在训练的模型的完整副本。一个节点被指定为监督员,定期从工作节点收集更新的权重,并将平均版本的权重推送到工作节点。请注意,您在本书中已经看到了一个数据并行的示例;第八章中介绍的 A3C 实现是数据并行深度网络训练的一个简单示例。
作为历史注记,Google 的 TensorFlow 前身 DistBelief 是基于 CPU 服务器上的数据并行训练。该系统能够实现与 GPU 训练速度相匹配或超过的分布式 CPU 速度(使用 32-128 个节点)。图 9-6 说明了 DistBelief 实现的数据并行训练方法。然而,像 DistBelief 这样的系统的成功往往取决于具有高吞吐量网络互连的存在,这可以实现快速的模型参数共享。许多组织缺乏能够实现有效的多节点数据并行 CPU 训练的网络基础设施。然而,正如 A3C 示例所示,可以在单个节点上使用不同的 CPU 核心执行数据并行训练。对于现代服务器,还可以在单个服务器内使用多个 GPU 执行数据并行训练,我们稍后将向您展示。
![downpour_sgd.png]()
图 9-6。Downpour 随机梯度下降(SGD)方法维护模型的多个副本,并在数据集的不同子集上对其进行训练。这些碎片的学习权重定期同步到存储在参数服务器上的全局权重。
模型并行 ism
人类大脑是唯一已知的智能硬件的例子,因此自然会对深度网络的复杂性和大脑的复杂性进行比较。简单的论点指出,大脑大约有 1000 亿个神经元;构建具有这么多“神经元”的深度网络是否足以实现普遍智能?不幸的是,这种论点忽略了生物神经元比“数学神经元”复杂得多的事实。因此,简单的比较价值有限。尽管如此,近几年来,构建更大的深度网络一直是主要的研究重点。
训练非常大的深度网络的主要困难在于 GPU 的内存通常有限(通常为几十吉字节)。即使进行仔细编码,具有数亿个参数的神经网络也无法在单个 GPU 上训练,因为内存要求太高。模型并行训练算法试图通过将大型深度网络存储在多个 GPU 的内存中来规避这一限制。一些团队已成功在 GPU 阵列上实现了这些想法,以训练具有数十亿参数的深度网络。不幸的是,迄今为止,这些模型尚未显示出通过额外困难来证明性能改进的效果。目前看来,使用较小模型增加实验便利性的好处超过了模型并行 ism 的收益。
硬件内存互连
启用模型并行 ism 需要在计算节点之间具有非常高的带宽连接,因为每次梯度更新都需要节点间通信。请注意,虽然数据并行 ism 需要强大的互连,但同步操作只需要在多次本地梯度更新后偶尔执行。
一些团队使用 InfiniBand 互连(InfiniBand 是一种高吞吐量、低延迟的网络标准)或 Nvidia 的专有 NVLink 互连来尝试构建这样的大型模型。然而,迄今为止,这些实验的结果并不一致,而且这些系统的硬件要求往往昂贵。
在 Cifar10 上使用多个 GPU 进行数据并行训练
在本节中,我们将深入介绍如何在 Cifar10 基准集上训练数据并行卷积网络。Cifar10 由尺寸为 32×32 的 60,000 张图像组成。Cifar10 数据集经常用于评估卷积架构。图 9-7 显示了 Cifar10 数据集中的样本图像。
![cifar10.png]()
图 9-7。Cifar10 数据集包含来自 10 个类别的 60,000 张图像。这里显示了各种类别的一些样本图像。
本节中将使用的架构在不同的 GPU 上加载模型架构的单独副本,并定期同步跨核心学习的权重,如图 9-8 所示。
![cifar_parallelism.png]()
图 9-8。本章将训练的数据并行架构。
下载和加载数据
read_cifar10()
方法读取和解析 Cifar10 原始数据文件。示例 9-1 使用tf.FixedLengthRecordReader
从 Cifar10 文件中读取原始数据。
示例 9-1。此函数从 Cifar10 原始数据文件中读取和解析数据
def read_cifar10(filename_queue):
"""Reads and parses examples from CIFAR10 data files.
Recommendation: if you want N-way read parallelism, call this function
N times. This will give you N independent Readers reading different
files & positions within those files, which will give better mixing of
examples.
Args:
filename_queue: A queue of strings with the filenames to read from.
Returns:
An object representing a single example, with the following fields:
height: number of rows in the result (32)
width: number of columns in the result (32)
depth: number of color channels in the result (3)
key: a scalar string Tensor describing the filename & record number
for this example.
label: an int32 Tensor with the label in the range 0..9.
uint8image:: a [height, width, depth] uint8 Tensor with the image data
"""
class CIFAR10Record(object):
pass
result = CIFAR10Record()
# Dimensions of the images in the CIFAR-10 dataset.
# See http://www.cs.toronto.edu/~kriz/cifar.html for a description of the
# input format.
label_bytes = 1 # 2 for CIFAR-100
result.height = 32
result.width = 32
result.depth = 3
image_bytes = result.height * result.width * result.depth
# Every record consists of a label followed by the image, with a
# fixed number of bytes for each.
record_bytes = label_bytes + image_bytes
# Read a record, getting filenames from the filename_queue. No
# header or footer in the CIFAR-10 format, so we leave header_bytes
# and footer_bytes at their default of 0.
reader = tf.FixedLengthRecordReader(record_bytes=record_bytes)
result.key, value = reader.read(filename_queue)
# Convert from a string to a vector of uint8 that is record_bytes long.
record_bytes = tf.decode_raw(value, tf.uint8)
# Read a record, getting filenames from the filename_queue. No
# header or footer in the CIFAR-10 format, so we leave header_bytes
# and footer_bytes at their default of 0.
reader = tf.FixedLengthRecordReader(record_bytes=record_bytes)
result.key, value = reader.read(filename_queue)
# Convert from a string to a vector of uint8 that is record_bytes long.
record_bytes = tf.decode_raw(value, tf.uint8)
# The first bytes represent the label, which we convert from uint8->int32.
result.label = tf.cast(
tf.strided_slice(record_bytes, [0], [label_bytes]), tf.int32)
# The remaining bytes after the label represent the image, which we reshape
# from [depth * height * width] to [depth, height, width].
depth_major = tf.reshape(
tf.strided_slice(record_bytes, [label_bytes],
[label_bytes + image_bytes]),
[result.depth, result.height, result.width])
# Convert from [depth, height, width] to [height, width, depth].
result.uint8image = tf.transpose(depth_major, [1, 2, 0])
return result
深入探讨架构
网络的架构是一个标准的多层卷积网络,类似于您在第六章中看到的 LeNet5 架构的更复杂版本。inference()
方法构建了架构(示例 9-2)。这个卷积架构遵循一个相对标准的架构,其中卷积层与本地归一化层交替出现。
示例 9-2。此函数构建 Cifar10 架构
def inference(images):
"""Build the CIFAR10 model.
Args:
images: Images returned from distorted_inputs() or inputs().
Returns:
Logits.
"""
# We instantiate all variables using tf.get_variable() instead of
# tf.Variable() in order to share variables across multiple GPU training runs.
# If we only ran this model on a single GPU, we could simplify this function
# by replacing all instances of tf.get_variable() with tf.Variable().
#
# conv1
with tf.variable_scope('conv1') as scope:
kernel = _variable_with_weight_decay('weights',
shape=[5, 5, 3, 64],
stddev=5e-2,
wd=0.0)
conv = tf.nn.conv2d(images, kernel, [1, 1, 1, 1], padding='SAME')
biases = _variable_on_cpu('biases', [64], tf.constant_initializer(0.0))
pre_activation = tf.nn.bias_add(conv, biases)
conv1 = tf.nn.relu(pre_activation, name=scope.name)
_activation_summary(conv1)
# pool1
pool1 = tf.nn.max_pool(conv1, ksize=[1, 3, 3, 1], strides=[1, 2, 2, 1],
padding='SAME', name='pool1')
# norm1
norm1 = tf.nn.lrn(pool1, 4, bias=1.0, alpha=0.001 / 9.0, beta=0.75,
name='norm1')
# conv2
with tf.variable_scope('conv2') as scope:
kernel = _variable_with_weight_decay('weights',
shape=[5, 5, 64, 64],
stddev=5e-2,
wd=0.0)
conv = tf.nn.conv2d(norm1, kernel, [1, 1, 1, 1], padding='SAME')
biases = _variable_on_cpu('biases', [64], tf.constant_initializer(0.1))
pre_activation = tf.nn.bias_add(conv, biases)
conv2 = tf.nn.relu(pre_activation, name=scope.name)
_activation_summary(conv2)
# norm2
norm2 = tf.nn.lrn(conv2, 4, bias=1.0, alpha=0.001 / 9.0, beta=0.75,
name='norm2')
# pool2
pool2 = tf.nn.max_pool(norm2, ksize=[1, 3, 3, 1],
strides=[1, 2, 2, 1], padding='SAME', name='pool2')
# local3
with tf.variable_scope('local3') as scope:
# Move everything into depth so we can perform a single matrix multiply.
reshape = tf.reshape(pool2, [FLAGS.batch_size, -1])
dim = reshape.get_shape()[1].value
weights = _variable_with_weight_decay('weights', shape=[dim, 384],
stddev=0.04, wd=0.004)
biases = _variable_on_cpu('biases', [384], tf.constant_initializer(0.1))
local3 = tf.nn.relu(tf.matmul(reshape, weights) + biases, name=scope.name)
_activation_summary(local3)
# local4
with tf.variable_scope('local4') as scope:
weights = _variable_with_weight_decay('weights', shape=[384, 192],
stddev=0.04, wd=0.004)
biases = _variable_on_cpu('biases', [192], tf.constant_initializer(0.1))
local4 = tf.nn.relu(tf.matmul(local3, weights) + biases, name=scope.name)
_activation_summary(local4)
# linear layer(WX + b),
# We don't apply softmax here because
# tf.nn.sparse_softmax_cross_entropy_with_logits accepts the unscaled logits
# and performs the softmax internally for efficiency.
with tf.variable_scope('softmax_linear') as scope:
weights = _variable_with_weight_decay('weights', [192, cifar10.NUM_CLASSES],
stddev=1/192.0, wd=0.0)
biases = _variable_on_cpu('biases', [cifar10.NUM_CLASSES],
tf.constant_initializer(0.0))
softmax_linear = tf.add(tf.matmul(local4, weights), biases, name=scope.name)
_activation_summary(softmax_linear)
return softmax_linear
缺少对象定位?
将此架构中呈现的模型代码与先前架构中的策略代码进行对比。注意介绍Layer
对象如何使代码大大简化,同时提高可读性。这种明显的可读性改进是大多数开发人员在实践中更喜欢在 TensorFlow 之上使用面向对象的覆盖的原因之一。
也就是说,在本章中,我们使用原始的 TensorFlow,因为使类似TensorGraph
这样的类与多个 GPU 一起工作将需要额外的开销。一般来说,原始的 TensorFlow 代码提供了最大的灵活性,但面向对象提供了便利。选择适合手头问题的抽象。
在多个 GPU 上训练
我们在每个 GPU 上实例化模型和架构的单独版本。然后,我们使用 CPU 来平均各个 GPU 节点的权重(示例 9-3)。
示例 9-3。此函数训练 Cifar10 模型
def train():
"""Train CIFAR10 for a number of steps."""
with tf.Graph().as_default(), tf.device('/cpu:0'):
# Create a variable to count the number of train() calls. This equals the
# number of batches processed * FLAGS.num_gpus.
global_step = tf.get_variable(
'global_step', [],
initializer=tf.constant_initializer(0), trainable=False)
# Calculate the learning rate schedule.
num_batches_per_epoch = (cifar10.NUM_EXAMPLES_PER_EPOCH_FOR_TRAIN /
FLAGS.batch_size)
decay_steps = int(num_batches_per_epoch * cifar10.NUM_EPOCHS_PER_DECAY)
# Decay the learning rate exponentially based on the number of steps.
lr = tf.train.exponential_decay(cifar10.INITIAL_LEARNING_RATE,
global_step,
decay_steps,
cifar10.LEARNING_RATE_DECAY_FACTOR,
staircase=True)
# Create an optimizer that performs gradient descent.
opt = tf.train.GradientDescentOptimizer(lr)
# Get images and labels for CIFAR-10.
images, labels = cifar10.distorted_inputs()
batch_queue = tf.contrib.slim.prefetch_queue.prefetch_queue(
[images, labels], capacity=2 * FLAGS.num_gpus)
示例 9-4 中的代码执行了基本的多 GPU 训练。注意每个 GPU 为不同批次出队,但通过tf.get_variable_score().reuse_variables()
实现的权重共享使训练能够正确进行。
示例 9-4。此代码片段实现了多 GPU 训练
# Calculate the gradients for each model tower.
tower_grads = []
with tf.variable_scope(tf.get_variable_scope()):
for i in xrange(FLAGS.num_gpus):
with tf.device('/gpu:%d' % i):
with tf.name_scope('%s_%d' % (cifar10.TOWER_NAME, i)) as scope:
# Dequeues one batch for the GPU
image_batch, label_batch = batch_queue.dequeue()
# Calculate the loss for one tower of the CIFAR model. This function
# constructs the entire CIFAR model but shares the variables across
# all towers.
loss = tower_loss(scope, image_batch, label_batch)
# Reuse variables for the next tower.
tf.get_variable_scope().reuse_variables()
# Retain the summaries from the final tower.
summaries = tf.get_collection(tf.GraphKeys.SUMMARIES, scope)
# Calculate the gradients for the batch of data on this CIFAR tower.
grads = opt.compute_gradients(loss)
# Keep track of the gradients across all towers.
tower_grads.append(grads)
# We must calculate the mean of each gradient. Note that this is the
# synchronization point across all towers.
grads = average_gradients(tower_grads)
最后,我们通过在示例 9-5 中需要时应用联合训练操作并编写摘要检查点来结束。
示例 9-5。此代码片段将来自各个 GPU 的更新分组并根据需要编写摘要检查点。
# Add a summary to track the learning rate.
summaries.append(tf.summary.scalar('learning_rate', lr))
# Add histograms for gradients.
for grad, var in grads:
if grad is not None:
summaries.append(tf.summary.histogram(var.op.name + '/gradients', grad))
# Apply the gradients to adjust the shared variables.
apply_gradient_op = opt.apply_gradients(grads, global_step=global_step)
# Add histograms for trainable variables.
for var in tf.trainable_variables():
summaries.append(tf.summary.histogram(var.op.name, var))
# Track the moving averages of all trainable variables.
variable_averages = tf.train.ExponentialMovingAverage(
cifar10.MOVING_AVERAGE_DECAY, global_step)
variables_averages_op = variable_averages.apply(tf.trainable_variables())
# Group all updates into a single train op.
train_op = tf.group(apply_gradient_op, variables_averages_op)
# Create a saver.
saver = tf.train.Saver(tf.global_variables())
# Build the summary operation from the last tower summaries.
summary_op = tf.summary.merge(summaries)
# Build an initialization operation to run below.
init = tf.global_variables_initializer()
# Start running operations on the Graph. allow_soft_placement must be set to
# True to build towers on GPU, as some of the ops do not have GPU
# implementations.
sess = tf.Session(config=tf.ConfigProto(
allow_soft_placement=True,
log_device_placement=FLAGS.log_device_placement))
sess.run(init)
# Start the queue runners.
tf.train.start_queue_runners(sess=sess)
summary_writer = tf.summary.FileWriter(FLAGS.train_dir, sess.graph)
for step in xrange(FLAGS.max_steps):
start_time = time.time()
_, loss_value = sess.run([train_op, loss])
duration = time.time() - start_time
assert not np.isnan(loss_value), 'Model diverged with loss = NaN'
if step % 10 == 0:
num_examples_per_step = FLAGS.batch_size * FLAGS.num_gpus
examples_per_sec = num_examples_per_step / duration
sec_per_batch = duration / FLAGS.num_gpus
format_str = ('%s: step %d, loss = %.2f (%.1f examples/sec; %.3f '
'sec/batch)')
print (format_str % (datetime.now(), step, loss_value,
examples_per_sec, sec_per_batch))
if step % 100 == 0:
summary_str = sess.run(summary_op)
summary_writer.add_summary(summary_str, step)
# Save the model checkpoint periodically.
if step % 1000 == 0 or (step + 1) == FLAGS.max_steps:
checkpoint_path = os.path.join(FLAGS.train_dir, 'model.ckpt')
saver.save(sess, checkpoint_path, global_step=step)
读者的挑战
您现在拥有实践中训练此模型所需的所有要素。尝试在适合的 GPU 服务器上运行它!您可能需要使用nvidia-smi
等工具来确保所有 GPU 实际上都在使用。
回顾
在本章中,您了解了常用于训练深度架构的各种类型硬件。您还了解了在多个 CPU 或 GPU 上训练深度架构的数据并行和模型并行设计。我们通过一个案例研究来结束本章,介绍如何在 TensorFlow 中实现卷积网络的数据并行训练。
在第十章中,我们将讨论深度学习的未来以及如何有效和道德地运用您在本书中学到的技能。
第十章:深度学习的未来
在这本书中,我们已经介绍了现代深度学习的基础知识。我们讨论了各种算法,并深入研究了许多复杂的案例研究。通过学习本书中涵盖的示例,读者现在已经准备好在工作中使用深度学习,并开始阅读关于深度学习方法的大量研究文献。
强调这种技能集是多么独特是值得的。深度学习已经在科技行业产生了巨大影响,但深度学习开始大幅改变几乎所有非技术行业的状态,甚至改变全球地缘政治平衡。你对这一划时代的技术的理解将打开许多你可能没有想到的大门。在这最后一章中,我们将简要概述一些深度学习在软件行业之外的重要应用。
我们还将利用这一章来帮助你回答如何有效和道德地使用你的新知识的问题。深度学习是一种如此强大的技术,从业者需要考虑如何正确使用他们的技能。深度学习已经被滥用了许多次,因此新从业者在构建复杂的深度学习系统之前应该暂停一下,问问他们正在构建的系统是否在道德上是正确的。我们将尝试提供一些道德最佳实践的简要讨论,但需要注意的是,软件伦理领域足够复杂,简短的讨论可能无法完全涵盖。
最后,我们将探讨深度学习的发展方向。深度学习是否是构建人工智能的第一步,即具有人类全部能力范围的计算实体?我们调查了各种专家意见。
科技行业之外的深度学习
像谷歌、Facebook、微软等科技公司已经在深度学习基础设施上进行了大量投资。这些公司大多已经熟悉机器学习系统,可能是因为过去与广告预测系统或搜索引擎等机器学习系统的经验。因此,从旧的机器学习系统转向深度学习只需要进行一次小的概念转变。此外,过去机器学习应用的成功使得科技管理人员更加愿意接受深度学习可以在公司内更广泛应用的论点。因此,软件公司很可能在不久的将来仍然是深度学习的主要用户。如果你打算在未来几年内找到一个使用深度学习的工作,那么你很可能会在一家科技公司工作。
然而,与此同时,深度学习开始渗透到历史上没有使用过太多机器学习的行业中。与简单的机器学习方法不同,深度学习减少了对复杂特征预处理的需求,并允许直接输入感知、文本和分子数据。因此,许多行业开始注意到这一点,并已经在许多创新初创公司中开始大规模改革这些行业。我们现在将简要讨论一些正在发生的邻近行业的变化,并指出许多深度学习专家可能在不久的将来会有许多新的工作机会。
应用是协同的
您很快将了解不同行业中的许多深度学习应用。这些应用的显著特点是它们都使用相同的基本深度学习算法。您已经看到的技术,如全连接网络、卷积网络、循环网络和强化学习,广泛适用于这些领域中的任何一个。特别是,这意味着卷积网络设计的核心改进将在制药、农业和机器人应用中产生成果。相反,机器人学家发现的深度学习创新将反馈并加强深度学习的基础。这种基础驱动应用驱动基础的良性循环意味着深度学习是一个不可或缺的力量。
制药行业中的深度学习
深度学习在药物发现领域正显示出大规模发展的迹象。药物发现分为多个阶段。有预临床发现阶段,其中潜在药物的效果在试管和动物中间接测试,然后是临床阶段,治疗药物在人类志愿者中直接测试。通过非人类和人类测试的药物被批准销售给消费者。
研究人员已经开始构建优化药物发现过程的模型。例如,分子深度学习已经应用于诸如预测潜在药物毒性和涉及药物样分子合成和设计的化学问题等问题。其他研究人员和公司正在使用深度卷积网络设计新的实验,以密切跟踪大规模细胞行为,以获得对新生物学的更深入理解。这些应用对制药界产生了一定影响,但由于不可能构建一个“设计”新药的药物发现模型,目前还没有出现什么显著的变化。然而,随着更多的数据收集工作继续进行,以及更多的生物和化学深度学习模型被设计出来,未来几年这种情况可能会发生巨大变化。
法律中的深度学习
法律行业在法律文献中严重依赖先例,以就新案件的合法性或非法性进行论证。传统上,大型律师事务所雇用大量法律助理研究员进行必要的法律文献查找。近年来,法律搜索引擎已经成为大多数复杂公司的标准配置。
这样的搜索算法仍然相对不成熟,很可能神经语言处理(NLP)的深度学习系统可以带来显著的改进。例如,许多初创公司正在致力于构建深度 NLP 系统,以提供更好的法律先例查询。其他初创公司正在研究使用机器学习来预测诉讼结果的预测方法,而一些公司甚至正在尝试自动生成法律论点的方法。
总的来说,这些深度模型的复杂应用将需要时间来成熟,但法律人工智能创新的浪潮很可能预示着法律行业的巨大转变。
机器人学中的深度学习
机器人行业传统上避免使用机器学习,因为很难证明机器学习系统的安全性。当构建需要对人类操作员周围的系统进行安全部署时,这种缺乏安全保证可能成为一个重大责任。
然而,近年来,人们已经清楚地意识到,深度强化学习系统结合低数据学习技术,可以在机器人操作任务中实现显著的改进。谷歌已经证明了强化学习可以用于学习机器人物体控制,利用一系列机器人手臂在真实机器人上进行大规模训练(见图 10-1)。这种增强学习技术很可能在未来几年开始渗透到更大的机器人行业中。
![google_robotic_arms.jpg]()
图 10-1。谷歌拥有一些机器人手臂,用于测试用于机器人控制的深度强化学习方法。这种基础研究很可能会在未来几年传播到工厂生产线上。
农业中的深度学习
工业农业已经被大量机械化,使用先进的拖拉机来种植甚至采摘作物。机器人技术和计算机视觉的进步正在加速这种自动化趋势。卷积网络已经被用来识别杂草,减少农药的使用。其他公司已经尝试过自动驾驶拖拉机、自动水果采摘和算法优化作物产量。目前这些主要是研究项目,但这些努力很可能在未来十年内发展成重大的部署项目。
在道德上使用深度学习
本书大部分内容都集中在有效使用深度学习上。我们已经介绍了许多构建在不同数据类型上具有良好泛化能力的深度模型的技术。然而,值得花一些时间思考我们作为工程师所构建系统的社会影响。深度学习系统带来了一系列潜在令人不安的应用。
首先,卷积网络将使面部检测技术的广泛部署成为可能。中国在实际部署此类系统方面处于领先地位(见图 10-2)。
![face_detection.png]()
图 10-2。中国政府广泛部署了基于卷积网络的面部检测算法。这些系统追踪个人的能力很可能意味着在中国公共场所的匿名性将成为过去。
请注意,无处不在的面部检测意味着公共匿名将成为过去。在公共领域采取的任何行动都将被公司和政府记录和追踪。对于任何关心深度学习伦理影响的人来说,这种未来愿景应该听起来令人不安。
这里更广泛的教训是,当算法能够理解视觉和感知信息时,几乎所有人类生活的方面都将受到算法的影响。这是一个宏观趋势,目前尚不清楚任何一个工程师是否有能力阻止这种未来的出现。尽管如此,工程师仍然保留着用脚投票的能力。你的技能是宝贵的,也是受欢迎的;不要为遵循不道德实践和构建潜在危险系统的公司工作。
人工智能中的偏见
机器学习和深度学习提供了从数据中学习有趣模型的能力,而不需要太多的努力。这种坚实的数学过程可以提供客观性的幻象。然而,值得强调的是,各种偏见可能会渗入这种分析中。从历史上、有偏见的记录中提取的基础数据中的偏见可能会导致模型学习基本不公平的模型。谷歌曾经因为一个有缺陷的视觉预测模型将黑人消费者标记为大猩猩而臭名昭著,这很可能是由于训练数据存在偏见,没有充分代表有色人种。一旦谷歌注意到这个问题,这个系统就迅速得到了纠正,但这种失败令人深感不安,也象征着技术行业中更基本的排斥问题。
随着人工智能在诸如囚犯假释授予和贷款批准流程等应用中的越来越多的使用,我们越来越重要的是确保我们的模型不会做出种族主义假设或学习历史数据中已经存在的偏见。如果您正在处理敏感数据,做出可能改变人类生活轨迹的预测,请检查两次甚至三次,确保您的系统不会受到偏见的影响。
人工通用智能即将到来吗?
关于人工通用智能(AGI)是否会很快出现,有广泛的讨论。专家们对于是否值得认真规划 AGI 存在着强烈分歧。我们认为,虽然进行“AI 价值对齐”和“安全奖励函数”设计的研究没有坏处,但今天和可预见的未来的人工智能系统不太可能迅速实现自我意识。正如您亲身体会到的,大多数深度学习系统只是复杂的数值引擎,容易出现许多繁琐的数值稳定性问题。通用智能成为问题可能需要数十年的基础性进步。与此同时,正如我们在前一节中讨论的那样,人工智能已经对人类社会和产业产生了巨大影响。绝对值得担心人工智能的影响,而无需召唤超智能的妖怪。
超级智能谬论
尼克·博斯特罗姆(牛津大学出版社)的书《超级智能》对围绕人工智能的讨论产生了深远影响。该书的基本前提是,当模型能够递归地改进自己时,智能爆炸可能会发生。从本质上讲,这本书的前提并不那么激进。如果通用人工智能(AGI)出现,就没有理由认为它不能成功地快速改进自己。
与此同时,深度学习专家吴恩达曾明确表示,担心超智能就像担心火星上的人口过剩一样。有一天,人类很可能会到达火星。当足够多的人登陆火星时,过度拥挤很可能存在,甚至可能是一个非常严重的问题。但这并不改变火星今天是一片空旷荒地的事实。同样,创造普遍智能 AI 的文献状况也是如此。
现在,这最后的陈述是夸张的。在强化学习和生成建模方面取得的实质性进展为创造更智能的代理提供了很多希望。但是,过分强调超智能实体的可能性会削弱我们面临的自动化真正挑战。当然,这甚至没有提到我们面临的其他严重挑战,比如全球变暖。
从这里去哪里?
如果您仔细阅读了本书,并努力使用我们在相关 GitHub 存储库中的代码示例,恭喜!您现在已经掌握了实用机器学习的基础知识。您将能够在实践中训练有效的机器学习系统。
然而,机器学习是一个发展非常迅速的领域。该领域的爆炸性增长意味着每年都会发现数十个有价值的新模型。实践机器学习者应该不断保持警惕,寻找新模型。在查看新模型时,一个有用的评估其有用性的技巧是尝试思考如何将模型应用于您或您的组织关心的问题。这个测试提供了一个很好的方法来组织研究界的大量模型,并将为您提供一个工具,以优先考虑对您真正重要的技术。
作为一个负责任的机器学习者,请确保考虑你的数据科学模型被用于什么目的。问问自己,你的机器学习工作是否被用来改善人类福祉。如果答案是否定的,那么请意识到,凭借你的技能,你有能力找到一份工作,可以利用你的机器学习超能力为善而不是为恶。
最后,我们希望你能玩得开心。深度学习是一个充满活力的人类探索领域,充满了令人兴奋的新发现、杰出的人才,以及深远影响的可能性。我们很高兴与你分享我们对这一领域的激情和热情,希望你能通过与周围世界分享你对深度学习的知识,将我们的努力传递下去。