TensorFlow-深度学习入门指南-四-

92 阅读1小时+

TensorFlow 深度学习入门指南(四)

原文:Beginning Deep Learning with TensorFlow

协议:CC BY-NC-SA 4.0

十、卷积神经网络

目前人工智能还没有达到 5 岁人类的水平,但是在感知方面进步很快。在机器语音和视觉识别领域,五到十年超越人类已经没有悬念。

—沈向阳

我们介绍了神经网络的基本理论,TensorFlow 的使用,以及基本的全连通网络模型,对神经网络有了更全面和深入的了解。但是对于深度学习,我们还是有点怀疑。深度学习的深度是指网络的更深层次,一般在五层以上,目前介绍的神经网络层大多在五层以内实现。那么深度学习和神经网络有什么区别和联系呢?

本质上,深度学习和神经网络指的是同一类型的算法。在 20 世纪 80 年代,基于生物神经元的多层感知器(MLP)数学模型的网络模型被称为神经网络。由于当时计算能力有限、数据量小等因素,神经网络一般只能训练到很少的层数。我们把这种类型的神经网络称为浅层神经网络(shallow neural network)。浅层神经网络不容易从数据中提取高层特征,一般表达能力也不好。虽然在数字图片识别等简单任务中取得了不错的效果,但很快被 90 年代提出的新的支持向量机超越。

加拿大多伦多大学教授杰弗里·辛顿(Geoffrey Hinton)长期坚持神经网络的研究。然而,由于当时支持向量机的流行,神经网络相关的研究遇到了许多障碍。2006 年,Geoffrey Hinton 在[1]中提出了一种逐层预训练算法,可以有效地初始化深度信念网络(DBN)网络,从而使训练大规模、深层次(数百万个参数)的网络成为可能。在论文中,Geoffrey Hinton 将神经网络称为深度神经网络,相关研究也称为深度学习(deep learning)。从这个角度来看,深度学习和神经网络在指定上本质上是一致的,深度学习更侧重于深度神经网络。深度学习的“深度”将在本章的相关网络结构中得到最淋漓尽致的体现。

在学习更深层次的网络模型之前,我们先来考虑这样一个问题:神经网络的理论研究在 80 年代已经基本到位,但为什么未能充分挖掘深度网络的巨大潜力?通过对这个问题的讨论,我们引出本章的核心内容:卷积神经网络。这也是一种可以轻松达到几百层的神经网络。

10.1 全连接 N 的问题

首先,我们来分析一下全连通网络的问题。考虑一个简单的四层全连接层网络。输入是调平后的 784 个节点的手写数字图片矢量。中间三个隐层节点数为 256,输出层节点数为十,如图 10-1 所示。

img/515226_1_En_10_Fig1_HTML.png

图 10-1

四层全连接网络结构简图

我们可以通过 TensorFlow 快速构建这个网络模型:添加 4 个密集层,并使用顺序容器将其封装为一个网络对象:

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers,Sequential,losses,optimizers,datasets
# Create 4-layer fully connected network
model = keras.Sequential([
    layers.Dense(256, activation='relu'),
    layers.Dense(256, activation='relu'),
    layers.Dense(256, activation='relu'),
    layers.Dense(10),
])
# build model and print the model info
model.build(input_shape=(4, 784))
model.summary()

使用 summary()函数打印出模型中各层参数的统计结果,如表 10-1 所示。网络的参数是如何计算的?每条连接线的权标量被认为是一个参数,所以对于一个有 n 个输入节点和 m 个输出节点的全连接层,张量 W 中包含的参数总共有 nm 个, m 个参数包含在向量 b 中。因此,全连接层的参数总数为nm+m。以第一层为例,输入特征长度为 784,输出特征长度为 256,当前层的参数量为 784 ⋅ 256 + 256 = 200960。同样的方法可以计算第二层、第三层、第四层的参数量,分别是 65792、65792、 2570。总参数量约 34 万。在计算机中,如果将单个权重保存为 float 类型的变量,至少需要占用 4 个字节的内存(float 在 Python 中占用的内存更多),那么 34 万个参数至少需要 1.34MB 左右的内存。换句话说,仅存储网络参数就需要 1.34MB 的内存。实际上,网络训练过程还需要缓存计算图、梯度信息、输入和中间计算结果等。,其中与梯度相关的操作会占用大量资源。

表 10-1

网络参数统计

|

|

隐藏层 1

|

隐藏层 2

|

隐藏层 3

|

输出层

| | --- | --- | --- | --- | --- | | 参数数量 | Two hundred thousand nine hundred and sixty | Sixty-five thousand seven hundred and ninety-two | Sixty-five thousand seven hundred and ninety-two | Two thousand five hundred and seventy |

那么训练这样一个网络需要多大的内存呢?我们可以简单地模拟现代 GPU 设备上的资源消耗。在 TensorFlow 中,如果不设置 GPU 内存占用方式,默认会占用所有 GPU 内存。这里 TensorFlow 内存使用量设置为按需分配,其占用的 GPU 内存资源观察如下:

# List all GPU devices
gpus = tf.config.experimental.list_physical_devices('GPU')
if gpus:
  try:
    # Set GPU occupation as on demand
    for gpu in gpus:
      tf.config.experimental.set_memory_growth(gpu, True)
  except RuntimeError as e:
    # excepting handling

    print(e)

前面的代码在导入 TensorFlow 库之后、创建模型之前插入。TensorFlow 配置为通过 TF . config . experimental . set _ memory _ growth(GPU,True)按需申请 GPU 内存资源。这样 TensorFlow 占用的 GPU 内存量就是运算所需的量。当批量大小设置为 32 时,我们观察到训练过程中 GPU 内存占用约 708MB,CPU 内存占用约 870MB。因为深度学习框架有不同的设计考虑,所以这个数字仅供参考。即便如此,我们也能感觉到四层全连通层的计算量并不小。

回到 80 年代,1.3MB 网络参数是什么概念?1989 年,Yann LeCun 在关于手写邮政编码识别的论文中使用 256KB 内存的计算机实现了他的算法[2]。这台计算机还配有美国电话电报公司 DSP-32C DSP 计算卡(浮点计算能力约为 25 兆浮点运算)。对于 1.3MB 的网络参数,256KB 内存的电脑连网络参数都加载不了,更别说网络训练了。可以看出,全连接层的较高存储器使用率严重限制了神经网络向更大规模和更深层的发展。

局部相关性

接下来,我们探讨如何避免全连接网络参数过大的缺陷。为了讨论方便,我们以图片类型数据的场景为例。对于 2D 图像数据,在进入全连通层之前,需要将矩阵数据展平成一个 1D 向量,然后将每个像素成对连接到每个输出节点,如图 10-2 所示。

img/515226_1_En_10_Fig2_HTML.png

图 10-2

2D 特征全连通图

可以看出,网络层的每个输出节点都连接到所有输入节点,用于提取所有输入节点的特征信息。这种密集的连接方式是全连接层参数数量大、计算成本高的根本原因。全连接层也叫密集连接层(dense layer),输出和输入的关系为:

{o}_j=\sigma \left({\sum}_{i\in nodes(I)}{w}_{ij}{x}_i+{b}_j\right)

其中节点 ( I )表示第一层的节点集合

那么,有必要将输出节点与所有输入节点连接起来吗?有没有近似的简化模型?我们可以分析输入节点对输出节点的重要性分布,只考虑输入节点中比较重要的部分,舍弃节点中不太重要的部分,这样输出节点只需要连接一些输入节点,表示为:

{o}_j=\sigma \left({\sum}_{i\in top\left(I,j,k\right)}{w}_{ij}{x}_i+{b}_j\right)

其中 top ( Ijk )表示第 I 层中的 top k 节点集合,该集合对于第 j 层中的编号节点具有最高的重要性。这样,全连通层的加权连接可以从第 I 层中的‖ I ‖ ⋅ ‖ J 减少到第 14 层中的 k ⋅ 其中‖ IJ 分别表示 I 层和 J 层的节点数。

那么问题就转变为探究第 I 层输入节点对数字输出节点 j 的重要性分布。然而,很难找出每个中间节点的重要性分布。我们可以利用先验知识进一步简化这个问题。

在现实生活中,有很多数据使用位置或距离作为重要性分布的度量。比如,住的离自己比较近的人,更容易对自己产生较大的影响(位置相关性),股票走势预测要更关注近期的走势(时间相关性);图片的每个像素与周围像素的关联度更大(位置关联)。以 2D 图像数据为例,如果我们简单地认为与当前像素的欧氏距离小于等于\frac{k}{\sqrt{2}}的像素更重要,欧氏距离大于\frac{k}{\sqrt{2}}的像素更不重要,那么我们就很容易把求每个像素重要性分布的问题简单化。如图 10-3 所示,实心网格所在的像素作为参考点,欧氏距离小于等于\frac{k}{\sqrt{2}}的像素用矩形网格表示。网格内的像素比较重要,网格外的像素不太重要。这个窗口被称为感受野,它表征了每个像素对中心像素的重要性分布。对于中心像素,将考虑网格内的像素,而忽略网格外的像素。

img/515226_1_En_10_Fig3_HTML.png

图 10-3

像素的重要性分布

这种基于距离的重要性分布的假设特征被称为局部相关性。它只关注一些离自己近的节点,而忽略了离自己远的节点。在这种重要性分布的假设下,全连接层的连接方式变成如图 10-4 所示。输出节点 j 只连接到以 j 为中心的局部区域(感受野),不连接其他像素。

img/515226_1_En_10_Fig4_HTML.jpg

图 10-4

本地连接网络

利用局部相关的思想,我们将感受野窗口的高度和宽度记为 k (感受野的高度和宽度不一定相等;为方便起见,我们只考虑高度和宽度相等的情况)。当前节点与感受野中的所有像素相连,不考虑外部的其他像素。网络层的输入和输出关系表示如下:

{o}_j=\sigma \left({\sum}_{dist\left(i,j\right)\le \frac{k}{\sqrt{2}}}{w}_{ij}{x}_i+{b}_j\right)

其中 dist ( ij )表示 ij 节点之间的欧氏距离。

重量分担

每个输出节点只连接感受野中的 k × k 个输入节点,输出层节点数为‖ J 。所以当前层的参数个数为k×k×J。与全连接层相比,由于 k 通常较小,如 1、3 和 5,因此k×k≪‖I成功减少了参数数量。

参数的数量是否可以进一步减少,比如我们是否只需要 k × k 个参数就可以完成当前层的计算?答案是肯定的。通过权重分担的思想,对于每个输出节点 o j ,使用相同的权重矩阵 W ,那么无论输出节点‖ J 会有多少,网络层参数的个数总是 k × k 。如图 10-5 所示,计算左上角的输出像素时,使用权重矩阵:

W=\left[{w}_{11}\ {w}_{12}\ {w}_{13}\ {w}_{21}\ {w}_{22}\ {w}_{23}\ {w}_{31}\ {w}_{32}\ {w}_{33}\ \right]

与相应感受野内的像素相乘累加,作为左上像素的输出值。计算右下感受野时,共享权重参数 W ,即使用相同的权重参数 W 相乘累加得到右下像素值的输出。此时网络层只有 3 × 3 = 9 个参数,与输入输出节点数无关。

img/515226_1_En_10_Fig5_HTML.png

图 10-5

重量分配矩阵图

通过应用局部相关和权重共享的思想,我们成功地将网络参数的数量从‖I‖×J‖减少到 k × k (准确地说,是在单输入通道和单卷积核的条件下)。这种加权的“局部连接层”网络实际上是一种卷积神经网络。接下来,我们将从数学的角度介绍卷积运算,然后正式学习卷积神经网络的原理和实现。

卷积运算

在局部相关性的先验下,我们提出了一个简化的“局部连接层”对于窗口 k × k 中的所有像素,通过相乘和累加权重提取特征信息,每个输出节点提取感受野区域对应的特征。信息。这个运算其实是信号处理领域的一个标准运算:离散卷积运算。离散卷积运算在计算机视觉中有着广泛的应用。下面是卷积神经网络层的数学解释。

在信号处理领域,1D 连续信号的卷积运算定义为两个函数的积分:函数 f ( τ ,函数 g ( τ ,其中中 g ( τ )翻转平移后变成g(n-τ)。1D 连续卷积被定义为:

\left(f\bigotimes g\right)(n)={\int}_{-\infty}^{\infty }f\left(\tau \right)g\left(n-\tau \right) d\tau

离散卷积用累加运算代替了积分运算:

\left(f\bigotimes g\right)(n)={\sum}_{\tau =-\infty}^{\infty }f\left(\tau \right)g\left(n-\tau \right)

至于卷积为什么这样定义,限于篇幅我就不细说了。我们集中讨论 2D 离散卷积运算。在计算机视觉中,卷积运算是基于 2D 图像函数 f ( mn )和 2D 卷积核 g ( mn ),其中 f ( ij )和 g ( ij2D 离散卷积定义为:

\left[f\bigotimes g\right]\left(m,n\right)={\sum}_{i=-\infty}^{\infty }{\sum}_{j=-\infty}^{\infty }f\left(i,j\right)g\left(m-i,n-j\right)

img/515226_1_En_10_Fig6_HTML.png

图 10-6

2D 图像函数 f ( ij )和卷积核函数 g ( ij )

让我们详细介绍一下 2D 离散卷积运算。先将卷积核函数 g ( ij )(每次沿 xy 方向反转)变成g(-) I,-j)。当( mn)=(1,1);这意味着卷积核函数g(1I,1j)翻转,然后向左上方移动一个单位。此时:

\left[f\bigotimes g\right]\left(-1,-1\right)={\sum}_{i=-\infty}^{\infty }{\sum}_{j=-\infty}^{\infty }f\left(i,j\right)g\left(-1-i,-1-j\right)={\sum}_{i\in \left[-1,1\right]}{\sum}_{j\in \left[-1,1\right]}f\left(i,j\right)g\left(-1-i,-1-j\right)

2D 函数只有在I∈[1,1],j∈[1,1]时才有有效值。在其他位置,则为 0。根据计算公式,我们可以得到 fg = 7,如图 10-7 。

img/515226_1_En_10_Fig7_HTML.jpg

图 10-7

离散卷积运算-1

同样,当( mn ) = (0,1):fg](0,1)=∑I∈【1,1】∈j∈【1,1】f(I

*即卷积核翻转后,单位上移,对应位置相乘累加, fg = 7,如图 10-8 。

img/515226_1_En_10_Fig8_HTML.jpg

图 10-8

离散卷积运算-2

当( mn ) = (1,1):

\left[f\bigotimes g\right]\left(1,-1\right)={\sum}_{i\in \left[-1,1\right]}{\sum}_{j\in \left[-1,1\right]}f\left(i,j\right)g\left(1-i,-1-j\right)

即卷积核翻转后向右上方平移一个单位,对应位置相乘累加, fg = 1,如图 10-9 。

img/515226_1_En_10_Fig9_HTML.jpg

图 10-9

离散卷积运算-3

当( mn)=(1,0):

\left[f\bigotimes g\right]\left(-1,0\right)={\sum}_{i\in \left[-1,1\right]}{\sum}_{j\in \left[-1,1\right]}f\left(i,j\right)g\left(-1-i,-j\right)

即卷积核翻转后向左平移一个单位,对应位置相乘累加,fg = 1,如图 10-10 所示。

img/515226_1_En_10_Fig10_HTML.jpg

图 10-10

离散卷积运算-4

这样循环计算,我们就可以得到函数 fgm∈[1,1],n∈[1,1]]的所有值,如图 10-11 所示。

img/515226_1_En_10_Fig11_HTML.png

图 10-11

2D 离散卷积运算

到目前为止,我们已经成功地完成了图像函数和卷积核函数的卷积运算,以获得新的特征图。

回想一下“权重乘累加”的运算,我们记为 fg :fg=∑I∑-w/2, h/2】f(Ij)g(Imjm)

仔细对比标准的 2D 卷积运算,不难发现“权乘累加”中的卷积核函数 g ( mn )并没有翻转。对于神经网络,目标是学习一个函数 g ( mn ),使 L 尽可能小。至于是不是正好是卷积运算中定义的“卷积核”函数,并不是很重要,因为我们不会直接用到。在深度学习中,函数 g ( mn )统称为卷积核(kernel),有时也称为滤波器、权重等。由于总是使用函数 g ( mn )来完成卷积运算,所以卷积运算实际上已经实现了重量共享的思想。

我们来总结一下 2D 离散卷积的运算过程:每次通过移动卷积核,与画面对应位置的感受野像素相乘累加,得到该位置的输出值。卷积核是一个行和列的大小为 k 的权重矩阵 W 。特征图上与尺寸 k 相对应的窗口为感受野。感受野和权重矩阵相乘并累加,得到该位置的输出值。通过权重共享,我们逐渐将卷积核从左上向右下移动,提取每个位置的像素特征,直到右下,完成卷积运算。可见两种理解方式是一致的。从数学的角度来看,卷积神经网络是完成 2D 函数的离散卷积运算;从局部相关性和权重分担的角度,也可以得到同样的效果。通过这两个视角,我们不仅可以直观地理解卷积神经网络的计算过程,而且可以从数学的角度进行严密的推导。正是基于卷积运算,卷积神经网络才能如此命名。

在计算机视觉领域,2D 卷积运算可以提取数据的有用特征,用特定的卷积核对输入图像进行卷积运算,得到具有不同特征的输出图像。如表 [10-2 所示,列出了一些常见的卷积核以及相应的效果。

表 10-2

常见卷积核及其作用

| ![img/515226_1_En_10_Figa_HTML.gif](https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/4f3dc02690dc46809e99fac9a48ec7ce~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5biD5a6i6aOe6b6Z:q75.awebp?rk3s=f64ab15b&x-expires=1771858494&x-signature=1W40laboZssaYXVHFJtatQDEwts%3D) |

10.2 卷积神经网络

卷积神经网络充分利用了局部相关和权重共享的思想,大大减少了网络参数的数量,从而提高了训练效率,更容易实现超大规模的深度网络。2012 年,加拿大多伦多大学的 Alex Krizhevsky 将深度卷积神经网络应用于大规模图像识别挑战 ILSVRC-2012,在 ImageNet 数据集上取得了 15.3%的 Top-5 错误率,排名第一。与第二名相比,Alex 将前 5 名的错误率降低了 10.9% [3]。这一巨大突破引起了业界的强烈关注。卷积神经网络迅速成为计算机视觉领域的新宠。随后,在一系列的工作中,基于卷积的神经网络模型相继被提出,并在原有的性能上取得了巨大的改善。

现在我们来介绍一下卷积神经网络层的具体计算过程。以 2D 影像数据为例,卷积层接受输入特征图 X ,高度 h ,宽度 w ,通道数 c 。在couthw 和 中通道数 c 的作用下,特征映射为高h*w′和 *c 应当注意,卷积核的高度和宽度可以不相等。为了简化讨论,我们只考虑等高和等宽的情况,然后可以很容易地推广到等高和不等宽的情况。**

我们首先讨论单通道输入和单卷积核,然后推广到多通道输入和单卷积核,最后讨论多通道输入和多卷积核的最常用和最复杂的卷积层实现。

10.2.1 单通道输入和单卷积内核

先讨论单通道输入 c = 1,比如一幅灰度图像只有一个通道的灰度值,单卷积核cout= 1。以大小为 5 × 5 的输入矩阵 X 和大小为 3 × 3 的卷积核矩阵为例,如图 10-12 所示。与卷积核大小相同的感受野(输入 X 上方的绿框)首先移动到输入 X 的左上方。选择输入上的感受野元素,乘以卷积核的对应元素(图中中间的方框):

\left[1-1\ 0-1-2\ 2\ 1\ 2-2\ \right]\bigodot \left[-1\ 1\ 2\ 1-1\ 3\ 0-1-2\ \right]=\left[-1-1\ 0-1\ 2\ 6\ 0-2\ 4\ \right]

⨀符号表示哈达玛乘积,即矩阵的相应元素相乘。符号@(矩阵乘法)是矩阵运算的另一种常见形式。矩阵运算后,所有 9 个值相加:

-1-1+0-1+2+6+0-2+4=7

我们得到标量 7,写入输出矩阵第一行第一列的位置,如图 10-12 所示。

img/515226_1_En_10_Fig12_HTML.png

图 10-12

3 × 3 卷积运算-1

第一个感受野区域的特征提取完成后,感受野窗口向右移动一个步长单位(步长,记为 s ,默认为 1),选择图 10-13 中绿色框内的 9 个感受野元素。同样,将卷积核的相应元素相乘并累加,可以得到输出 10,写入第一行第二列位置。

img/515226_1_En_10_Fig13_HTML.png

图 10-13

3 × 3 卷积运算-2

将感受野窗口再次向右移动一个步长单位,选择图 10-14 中绿色方框内的元素,与卷积核相乘累加,得到输出 3,写入输出的第一行第三列,如图 10-14 所示。

img/515226_1_En_10_Fig14_HTML.png

图 10-14

3 × 3 卷积运算-3

此时感受野已经移动到有效像素输入的最右侧,不能继续向右移动(不填充无效元素),所以感受野窗口下移一个步长单位( s = 1),回到当前行的开头,继续选择新的感受野元素区域,如图 10-15 所示,卷积核运算得到 output -1。因为感受野下移一步,所以输出值-1 被写入第二行第一列位置。

img/515226_1_En_10_Fig15_HTML.png

图 10-15

3 × 3 卷积运算-4

按照前面的方法,感受野每右移一步( s = 1),如果超出输入边界,则下移一步( s = 1),返回到行首,直到感受野移动到最右最底的位置,如图 10-16 所示。每个选择的感受野元素乘以卷积核的相应元素,并写入输出的相应位置。最后,我们得到一个 3 × 3 的矩阵,比输入的 5 × 5 略小,这是因为感受野不能超出单元边界。可以看出,卷积运算的输出矩阵的大小是由卷积核的大小 k 、输入 X 的高度 h 和宽度 w 、移动步长 s 以及是否填充边界决定的。

img/515226_1_En_10_Fig16_HTML.png

图 10-16

3 × 3 卷积运算-5

现在我们介绍了单通道输入和单卷积核的计算过程。神经网络输入通道的实际数量通常很大。接下来,我们将学习多通道输入和单个卷积核的卷积运算方法。

10.2.2 多通道输入和单卷积内核

多通道输入卷积层更常见。例如,彩色图像包含三个通道(R/G/B)。每个通道上的像素值表示 R/G/B 颜色的强度。下面我们以三通道输入和单卷积核为例,将单通道输入的卷积运算扩展到多通道。如图 10-17 所示,每行最左边的 5 × 5 矩阵代表输入通道 13,第二列的 3 × 3 矩阵代表卷积核的通道 13,第三列的矩阵代表当前通道上计算的中间矩阵;最右边的矩阵表示卷积层运算的最终输出。

在多通道输入的情况下,卷积核的通道数需要与输入通道数相匹配。计算卷积核的第到第个通道和输入 X 的第到第个通道,得到第一个中间矩阵,然后可以看作单输入单卷积核的情况。所有通道的中间矩阵的相应元素被再次相加,作为最终输出。

具体计算过程如下:初始状态下,如图 10-17 所示,每个通道上的感受野窗口同步落在相应通道上最左边和最上面的位置。感受野区域元素和每个通道上的卷积核相乘并累加相应通道上的矩阵,得到三个通道上输出 7,-11,-1 的中间变量,然后我们可以将这些中间变量相加得到输出-5,并写入相应的位置。

img/515226_1_En_10_Fig17_HTML.png

图 10-17

多通道输入和单卷积核-1

然后,感受野窗口在每个通道上同步向右移动一步( s = 1)。此时感受野区域元素如图 10-18 所示。每个通道上的感受野乘以卷积核的相应通道上的矩阵,然后累加得到中间变量 10、20 和 20。然后,我们将它们相加得到输出 50,并写入第一行和第二列的元素位置。

img/515226_1_En_10_Fig18_HTML.png

图 10-18

多通道输入和单卷积核-2

这样,感受野窗口同步移动到最右边和最底部的位置。完成输入和卷积核的所有卷积运算,得到的 3 × 3 输出矩阵如图 10-19 所示。

img/515226_1_En_10_Fig19_HTML.png

图 10-19

多通道输入和单卷积核-3

整个计算框图如图 10-20 所示。每个输入通道的感受野乘以卷积核的相应通道,以获得与通道数量相等的中间变量。将所有这些中间变量相加以获得当前位置的输出值。输入通道的数量决定了卷积核通道的数量。一个卷积核只能得到一个输出矩阵,与输入通道的数量无关。

img/515226_1_En_10_Fig20_HTML.png

图 10-20

多通道输入和单卷积核图

一般来说,一个卷积核只能完成某个逻辑特征的提取。当需要同时提取多个逻辑特征时,可以通过增加多个卷积核来提高神经网络的表达能力。多声道输入和多卷积核就是这种情况。

10.2.3 多通道输入和多重卷积内核

多通道输入和多卷积核是卷积神经网络最常见的形式。我们已经介绍了单卷积核的运算过程。每个卷积核和输入被卷积以获得输出矩阵。当有多个卷积核时,将第 i th ( i ∈ 1, nn 为卷积核的个数)卷积核和输入 X 得到第 i 个输出矩阵(也称为输出张量 O 的通道 i ,最后将通道维中的所有输出矩阵缝合在一起(堆栈操作创建一个新的

以一个具有三个输入通道和两个卷积核的卷积层为例。第一个卷积核与输入 X 得到第一个输出通道,第二个卷积核与输入 X 得到第二个输出通道,如图 [10-21 。两个输出通道缝合在一起,形成最终输出 O 。统一设置每个卷积核的大小 k 、步长 s 和填充设置,以保证每个输出通道具有相同的大小,满足拼接的条件。

img/515226_1_En_10_Fig21_HTML.png

图 10-21

多重卷积核图

步幅大小

在卷积运算中,如何控制感受野布局的密度?对于具有高信息密度的输入,例如具有大量对象的图片,为了最大化有用的信息,在网络设计期间,期望更密集地布置感受野窗口。对于信息密度较低的输入,比如海洋的图片,我们可以适当减少感受野的数量。感受野密度的控制方法一般通过移动步幅来实现。

步幅大小是指感受野窗口每次移动的长度单位。对于 2D 输入,分为 x (向右)方向和 y (向下)方向的移动长度。为了简化讨论,我们只考虑两个方向的步长相同的情况,这也是神经网络中最常见的设置。如图 10-22 所示,绿色实线代表感受野窗口的位置,绿色虚线代表最后一个感受野的位置。从最后位置到当前位置的移动长度是步幅大小的定义。在图 10-22 中,感受野在 x 方向的步长为 2,表示为 s = 2。

img/515226_1_En_10_Fig22_HTML.png

图 10-22

步长图(即步幅)

当感受野到达输入 X 的右边界时,它向下移动一步( s = 2)并返回到行首,如图 10-23 所示。

img/515226_1_En_10_Fig23_HTML.png

图 10-23

卷积运算步长解算-1

如图 10-24 所示,来回循环直至到达底部和右侧边缘。卷积层的最终输出高度和宽度只有 2 × 2。与以前的情况( s = 1)相比,输出高度和宽度从 3 × 3 减少到 2 × 2,感受野的数量减少到只有 4 个。

img/515226_1_En_10_Fig24_HTML.png

图 10-24

卷积运算步长解算-2

可以看出,通过设置步幅大小,可以有效地控制信息密度的提取。步长较小时,感受野移动窗口较小,有助于提取更多的特征信息,输出张量的大小较大;当步长较大时,感受野移动窗口较大,有助于降低计算成本和过滤冗余信息,当然输出张量的大小也较小。

填料

卷积运算后,输出的高度和宽度通常会小于输入的高度和宽度。即使步幅大小为 1,输出的高度和宽度也将略小于输入的高度和宽度。当设计网络模型时,有时希望输出的高度和宽度可以与输入的高度和宽度相同,从而便于网络参数和剩余连接的设计。为了使输出的高度和宽度等于输入的高度和宽度,通常通过在原始输入的高度和宽度上填充几个无效元素来增加输入。通过仔细设计填充单元的数量,卷积运算后输出的高度和宽度可以等于原始输入,甚至更大。

如图 10-25 所示,我们可以在顶部、底部、左侧或右侧边界填充一个不确定的数字。默认填充数为 0,也可以用自定义数据填充。在图 10-25 中,上下方向填充一行,左右方向填充两列。

img/515226_1_En_10_Fig25_HTML.png

图 10-25

矩阵填充图

那么如何计算填充后的卷积层数呢?我们可以简单地用填充后得到的新张量X代替输入 X 。如图 10-26 所示,感受野的初始位置在X′的左上方。与前面类似,获得输出 1 并写入输出张量的相应位置。

img/515226_1_En_10_Fig26_HTML.png

图 10-26

填充-1 后的卷积运算

将步幅移动一个单位,重复操作得到输出 0,如图 10-27 所示。

img/515226_1_En_10_Fig27_HTML.png

图 10-27

填充-2 后的卷积运算

来回循环,得到的输出张量如图 10-28 所示。

img/515226_1_En_10_Fig28_HTML.png

图 10-28

填充-3 后的卷积运算

通过精心设计的填充方案,即向上、向下、向左、向右填充一个单元( p = 1),可以得到与输入高度和宽度相同的结果 O 。没有填充,如图 10-29 所示,我们只能得到略小于输入的输出。

img/515226_1_En_10_Fig29_HTML.png

图 10-29

无填充的卷积输出

卷积神经层的输出大小 bhwcout由卷积核的个数cout、卷积核的大小 k 、步长 s 决定 填充数 p (仅考虑上下填充数 p h ,左右填充数 p w ),以及输入 X 的高度 h 和宽度 w 。 之间的数学关系可以表示为:

![img/515226_1_En_10_Figb_HTML.png 其中 p hp w 分别表示高度和宽度方向的填充量,⌊⋅⌋表示向下舍入。以前面的例子为例, h = w = 5, k = 3,ph=pw= 1, s = 1,则输出为:{h}^{\prime }=\left\lfloor \frac{5+2\ast 1-3}{1}\right\rfloor +1=\left\lfloor 4\right\rfloor +1=5

{w}^{\prime }=\left\lfloor \frac{5+2\ast 1-3}{1}\right\rfloor +1=\left\lfloor 4\right\rfloor +1=5

在 TensorFlow 中,当在 s = 1,如果想让输出 O 和输入 X 的高度和宽度相等,只需要简单设置参数 padding="SAME "就可以让 TensorFlow 自动计算填充数,非常方便。

10.3 卷积层实现

在 TensorFlow 中,你既可以通过自定义权重的底层实现来构建神经网络,也可以直接调用卷积层的高层 API 来快速构建复杂的网络。我们主要以 2D 卷积为例介绍如何实现一个卷积神经网络层。

定制重量

在 TensorFlow 中,2D 卷积运算可以通过 tf.nn.conv2d 函数轻松实现。tf.nn.conv2d 根据输入X:中的 bhwc 中的卷积核W:kkc 进行卷积运算 h??’, w ,,cout其中 中的 c 表示输入通道的数量,cout表示卷积核的数量

In [1]:
x = tf.random.normal([2,5,5,3]) # input with 3 channels with height and width 5
# Create w using [k,k,cin,cout] format, 4 3x3 kernels
w = tf.random.normal([3,3,3,4])
# Stride is 1, padding is 0,
out = tf.nn.conv2d(x,w,strides=1,padding=[[0,0],[0,0],[0,0],[0,0]])
Out[1]: #  shape of output tensor
TensorShape([2, 3, 3, 4])

填充参数的格式为:

padding=[[0,0],[top,bottom],[left,right],[0,0]]

例如,如果一个单元在所有方向(上、下、左、右)都被填满,则填充参数如下:

In [2]:
x = tf.random.normal([2,5,5,3]) # input with 3 channels with height and width 5
# Create w using [k,k,cin,cout] format, 4 3x3 kernels
w = tf.random.normal([3,3,3,4])
# Stride is 1, padding is 0,
out = tf.nn.conv2d(x,w,strides=1,padding=[[0,0],[1,1],[1,1],[0,0]])
Out[2]: # shape of output tensor
TensorShape([2, 5, 5, 4])

具体来说,通过设置参数 padding='SAME '和 strides=1,我们可以得到卷积层输入和输出的相同大小,其中填充的具体数目由 TensorFlow 自动计算。例如:

In [3]:
x = tf.random.normal([2,5,5,3]) # input
w = tf.random.normal([3,3,3,4]) # 4 3x3 kernels
# Stride is 1,padding is "SAME"
# padding="SAME" gives use same size only when stride=1
out = tf.nn.conv2d(x,w,strides=1,padding='SAME')
Out[3]: TensorShape([2, 5, 5, 4])

s 例如:

In [4]:
x = tf.random.normal([2,5,5,3])
w = tf.random.normal([3,3,3,4])
out = tf.nn.conv2d(x,w,strides=3,padding='SAME')
Out [4]:TensorShape([2, 2, 2, 4])

卷积神经网络层和全连接层一样,网络可以设置一个偏置向量。tf.nn.conv2d 函数不实现偏置向量的计算。我们可以手动添加偏差。例如:

# Create bias tensor
b = tf.zeros([4])
# Add bias to convolution output. It’ll broadcast to size of [b,h',w',cout]
out = out + b

卷积层类别

通过卷积层类层。Conv2D,可以直接定义卷积核 W 和偏置张量 b 并直接调用类实例完成卷积层的正演计算。在 TensorFlow 中,API 的命名有一定的规则。大写字母的对象一般代表类,所有小写一般代表功能,比如层。Conv2D 表示卷积层类,nn.conv2d 表示卷积函数。使用类方法将自动创建所需的权重张量和偏差向量。用户不需要记忆卷积核张量的定义格式,因此使用起来更加简单方便,但我们也失去了一些灵活性。函数接口需要自己定义权重和偏置,更加灵活。

当创建一个新的卷积层类时,只需要指定卷积核参数过滤器的数量、卷积核的大小 kernel_size、步距、填充等。具有 4 个 3 × 3 卷积核的卷积层创建如下(步长为 1,填充方案为“相同”):

layer = layers.Conv2D(4,kernel_size=3,strides=1,padding='SAME')

如果卷积核的高度和宽度不相等,沿不同方向的步距也不相等,则需要设计元组格式的 kernel_size 参数( k hk w )和步距参数( s hs w 创建 4 个 3 × 4 卷积核如下(sh= 2 在垂直方向,sw= 1 在水平方向):

layer = layers.Conv2D(4,kernel_size=(3,4),strides=(2,1),padding='SAME')

创建完成后,可以通过调用实例(call method)来完成正向计算,例如:

In [5]:
layer = layers.Conv2D(4,kernel_size=3,strides=1,padding='SAME')
out = layer(x) # forward calculation
out.shape # shape of output
Out[5]:TensorShape([2, 5, 5, 4])

在 Conv2D 类中保存了卷积核张量 W 和偏差 b ,通过类成员 trainable _ variables 可以直接返回 Wb 的列表。例如:

In [6]:
# Return all trainable variables
layer.trainable_variables
Out[6]:
[<tf.Variable 'conv2d/kernel:0' shape=(3, 3, 3, 4) dtype=float32, numpy=
 array([[[[ 0.13485974, -0.22861657,  0.01000655,  0.11988598],
          [ 0.12811887,  0.20501086, -0.29820845, -0.19579397],
          [ 0.00858489, -0.24469738, -0.08591779, -0.27885547]], ...
 <tf.Variable 'conv2d/bias:0' shape=(4,) dtype=float32, numpy=array([0., 0., 0., 0.], dtype=float32)>]

这个 layer.trainable _ variables 类成员对于获取网络层中要优化的变量非常有用。也可以直接调用类实例 layer.kernel、layer.bias 来访问 Wb

10.4 动手操作 LeNet-5

20 世纪 90 年代,Yann LeCun 等人提出了一种用于识别手写数字和机印字符图片的神经网络,命名为 LeNet-5 [4]。LeNet-5 的提出使卷积神经网络在当时成功商业化,并广泛应用于邮政编码和支票号码识别等任务中。图 10-30 是 LeNet-5 的网络结构图。它接受大小为 32 × 32 的数字和字符图片作为输入,然后通过第一个卷积层获得形状为[ b ,28,28,6]的张量。在下采样层之后,张量大小被减小到[b,14,14,6]。在第二个卷积层之后,张量形状变成[ b ,10,10,16]。经过类似的下采样层,张量大小减少到[ b ,5,5,16]。在进入全连接层之前,张量被转换成形状[ b ,400]并馈入两个全连接层,输入节点数分别为 120 和 84。获得形状为[ b ,84]的张量,并最终通过高斯连接层。

img/515226_1_En_10_Fig30_HTML.png

图 10-30

LeNet-5 结构[4]

现在看来,LeNet-5 网络的层数更少(两个卷积层和两个全连接层),参数更少,计算成本更低,特别是在现代 GPU 的支持下,可以在几分钟内训练完成。

我们基于 LeNet-5 做了一些调整,使其更容易使用现代深度学习框架实现。首先,我们将输入形状从 32 × 32 调整为 28 × 28,然后将两个下采样层实现为最大池层(降低特征图的高度和宽度,这将在后面介绍),最后将高斯连接层替换为全连接层。修改后的网络在下文中也被称为 LeNet-5 网络。网络结构图如图 10-31 所示。

img/515226_1_En_10_Fig31_HTML.png

图 10-31

改进的 LeNet-5 结构

我们基于 MNIST 手写数字图片数据集训练 LeNet-5 网络,并测试其最终精度。我们已经介绍了如何在 TensorFlow 中加载 MNIST 数据集,所以在此不再赘述。

首先通过顺序容器创建 LeNet-5,如下所示:

from tensorflow.keras import Sequential

network = Sequential([
    layers.Conv2D(6,kernel_size=3,strides=1), # Convolutional layer with 6 3x3 kernels
    layers.MaxPooling2D(pool_size=2,strides=2), # Pooling layer with size 2
    layers.ReLU(), # Activation function
    layers.Conv2D(16,kernel_size=3,strides=1), # Convolutional layer with 16 3x3 kernels

    layers.MaxPooling2D(pool_size=2,strides=2), # Pooling layer with size 2
    layers.ReLU(), # Activation function
    layers.Flatten(), # Flatten layer

    layers.Dense(120, activation='relu'), # Fully-connected layer
    layers.Dense(84, activation='relu'), # Fully-connected layer
    layers.Dense(10) # Fully-connected layer
                    ])
# build the network
network.build(input_shape=(4, 28, 28, 1))
# network summary

network.summary()

summary()函数统计各层的参数并打印出网络结构信息和各层参数的详细情况,如表 10-3 所示,我们可以和全连通网络 10.1 的参数标度进行比较。

表 10-3

网络参数统计

|

|

卷积层 1

|

卷积层 2

|

完全连接的第 1 层

|

完全连接的第 2 层

|

完全连接的第 3 层

| | --- | --- | --- | --- | --- | --- | | 参数数量 | Sixty | Eight hundred and eighty | Forty-eight thousand one hundred and twenty | Ten thousand one hundred and sixty-four | Eight hundred and fifty |

可以看出,卷积层的参数量很小,主要参数量集中在全连接层。因为卷积层降低了输入特征维数很多,所以全连接层的参数量不会太大。整个模型的参数数量约为 60K,表 10.1 中的全连通网络参数数量达到 340000 个,因此卷积神经网络可以在增加网络深度的同时显著减少网络参数数量。

在训练阶段,首先在数据集中 shape[ b ,28,28,1]的原始输入上增加一个维度(b,28,28,1】,并发送给模型进行正演计算,得到 shape [ b ,10]的输出张量。我们创建了一个新的交叉熵损失函数类来处理分类任务。通过设置 from_logits=True 标志,在损失函数中实现 softmax 激活函数,无需手动添加损失函数,提高了数值稳定性。代码如下:

from tensorflow.keras import losses, optimizers
# Create loss function
criteon = losses.CategoricalCrossentropy(from_logits=True)

培训实施如下:

    # Create Gradient tape environment
    with tf.GradientTape() as tape:
        # Expand input dimension =>[b,28,28,1]
        x = tf.expand_dims(x,axis=3)
        # Forward calculation, [b, 784] => [b, 10]
        out = network(x)
        # One-hot encoding, [b] => [b, 10]
        y_onehot = tf.one_hot(y, depth=10)
        # Calculate cross-entropy
        loss = criteon(y_onehot, out)

获得损耗值后,损耗和网络参数 network.trainable _ variables 之间的梯度由 TensorFlow 的梯度记录器 tf 计算。GradientTape(),网络权重参数由优化器对象自动更新,如下所示:

    # Calcualte gradient
    grads = tape.gradient(loss, network.trainable_variables)
    # Update paramaters
    optimizer.apply_gradients(zip(grads, network.trainable_variables))

重复上述步骤几次后,即可完成训练。

在测试阶段,由于不需要记录梯度信息,代码一般不需要在“有 tf 的环境”中编写。GradientTape()作为磁带”。正向计算得到的输出通过 Softmax 函数后,我们得到网络预测当前图片 x 属于类别I(I∈【0,9】)的概率 P 。使用 argmax 函数选择概率最高的元素的索引作为当前预测类别,与真实标签进行比较,计算比较结果中真实样本的个数。具有正确预测的样本数除以总样本数,得到网络的测试精度。

        # Use correct to record the number of correct predictions
        # Use total to record the total number
        correct, total = 0,0
        for x,y in db_test: # Loop through all samples
            # Expand dimension =>[b,28,28,1]
            x = tf.expand_dims(x,axis=3)
            # Forward calculation to get probability, [b, 784] => [b, 10]
            out = network(x)
            # Technically, we should pass out to softmax() function firs.
 # But because softmax() doesn’t change the order the numbers, we omit the softmax() part.
            pred = tf.argmax(out, axis=-1)
            y = tf.cast(y, tf.int64)
            # Calculate the correct prediction number
            correct += float(tf.reduce_sum(tf.cast(tf.equal(pred, y),tf.float32)))
            # Total sample number
            total += x.shape[0]
        # Calculate accuracy
        print('test acc:', correct/total)

在数据集上循环训练 30 个历元后,网络的训练准确率达到 98.1%,测试准确率也达到 97.7%。对于简单的手写数字图片识别任务,老的 LeNet-5 网络已经可以取得不错的效果,但是对于稍微复杂一点的任务,比如彩色动物图片识别,LeNet-5 的性能会急剧下降。

10.5 表征学习

我们介绍了卷积神经网络层的工作原理和实现方法。复杂的卷积神经网络模型也是基于卷积层的堆叠。在过去,研究人员已经发现,网络层越深,模型的表达能力越强,越有可能实现更好的性能。那么堆叠卷积网络的特点是什么,使得层越深,网络的表达能力越强呢?

2014 年,马修·d·泽勒等人[5]试图用可视化的方法来准确理解卷积神经网络学习了什么。通过使用“反进化网络”将每一层的特征映射回输入图片,我们可以查看学习到的特征分布,如图 10-32 所示。可以观察到,第二层的特征对应于底层图像的提取,例如边缘、角和颜色;第三层开始捕捉纹理的中间特征;第四层和第五层呈现对象的一些特征,例如小狗的脸、鸟的脚和其他高级特征。通过这些可视化,我们可以在一定程度上体验卷积神经网络的特征学习过程。

img/515226_1_En_10_Fig32_HTML.jpg

图 10-32

卷积神经网络特征的可视化[5]

图像识别过程通常被认为是表征学习过程。它从接收到的原始像素特征开始,逐步提取边缘、角点等低层特征,然后是纹理等中层特征,最后是物体部分等高层特征。最后的网络层基于这些学习到的抽象特征表示来学习分类逻辑。层越高,学习的特征越准确,分类器的分类就越有利,从而获得更好的性能。从表征学习的角度来看,卷积神经网络是逐层提取特征的,网络训练的过程可以认为是一个特征学习的过程。基于学习到的高级抽象特征,可以方便地执行分类任务。

应用表示学习的思想,一个训练有素的卷积神经网络往往可以学习到更好的特征。这种特征提取方法一般是通用的。例如,在猫和狗的任务中学习头、脚、身体和其他特征的表征在某种程度上也可以用于其他动物。基于这种思想,在任务 A 上训练的深度神经网络的前几个特征提取层可以迁移到任务 B 上,只需要训练任务 B 的分类逻辑(表示为网络的最后一层)。这种方法是一种迁移学习,也称为微调。

10.6 梯度传播

完成手写数字图像识别练习后,我们对卷积神经网络的使用有了初步的了解。现在我们来解决一个关键问题。卷积层通过移动感受野来实现离散卷积运算。那么它的梯度传播是如何工作的呢?

考虑一个简单的例子,输入是一个 3 × 3 单通道矩阵,使用一个 2 × 2 卷积内核来执行卷积运算。然后,我们计算展平输出和相应标签之间的误差,如图 10-33 所示。让我们讨论一下这种情况下的梯度更新方法。

img/515226_1_En_10_Fig33_HTML.png

图 10-33

卷积层的梯度传播示例

首先导出输出张量 O 的表达式:

o00=x00w00+x01w01+x10w10+x

o01=x01w00+x02w01+x11w10+x

o10=x10w00+x11w01+x20w10+x

o11=x11w00+x12w01+x21w10+x

w 00 梯度计算为例,按链式法则分解:

\frac{\partial L}{\partial {w}_{00}}={\sum}_{i\in \left\{00,01,10,11\right\}}\frac{\partial L}{\partial {o}_i}\frac{\partial {o}_i}{\partial {w}_{00}}

其中\frac{\partial L}{\partial {O}_i}可以直接从误差函数中导出。我们来考虑一下\frac{\partial {O}_i}{\partial {w}_i}:

\frac{\partial {o}_{00}}{\partial {w}_{00}}=\frac{\partial \left({x}_{00}{w}_{00}+{x}_{01}{w}_{01}+{x}_{10}{w}_{10}+{x}_{11}{w}_{11}+b\right)}{w_{00}}={x}_{00}

类似地,可以推导出:

\frac{\partial {o}_{01}}{\partial {w}_{00}}=\frac{\partial \left({x}_{01}{w}_{00}+{x}_{02}{w}_{01}+{x}_{11}{w}_{10}+{x}_{12}{w}_{11}+b\right)}{w_{00}}={x}_{01}

\frac{\partial {o}_{10}}{\partial {w}_{00}}=\frac{\partial \left({x}_{10}{w}_{00}+{x}_{11}{w}_{01}+{x}_{20}{w}_{10}+{x}_{21}{w}_{11}+b\right)}{w_{00}}={x}_{10}

\frac{\partial {o}_{11}}{\partial {w}_{00}}=\frac{\partial \left({x}_{11}{w}_{00}+{x}_{12}{w}_{01}+{x}_{21}{w}_{10}+{x}_{22}{w}_{11}+b\right)}{w_{00}}={x}_{11}

可以观察到,循环移动感受野的方法并没有改变网络层的衍生性,梯度的推导也并不复杂。但是当网络层数增加时,人工的梯度推导会变得非常繁琐。不过不用担心,深度学习框架可以帮助我们自动完成所有参数的梯度计算和更新,我们只需要设计好网络结构。

10.7 汇集层

在卷积层,可以通过调整步长参数 s 来降低特征图的高度和宽度,从而减少网络参数的数量。事实上,除了设置步幅大小,还有一个特殊的网络层也可以减少参数数量,这就是所谓的池层。

池层也是基于本地相关性的思想。通过从一组局部相关的元素中取样或聚集信息,我们可以获得新的元素值。特别是,最大池层从本地相关元素集中选择最大的元素值,平均池层从本地相关元素集中计算平均值。以一个 5 × 5 max 池层为例,假设感受野窗口大小 k = 2,步幅 s = 1,如图 10-34 所示。绿色虚线框代表第一个感受野的位置,感受野元素组为:

\left\{1,-1,-1,-2\right\}

根据最大池,我们有:

{x}^{\prime }=\mathit{\max}\left(\left\{1,-1,-1,-2\right\}\right)=1

如果使用平均池操作,输出值将为:

{x}^{\prime }= avg\left(\left\{1,-1,-1,-2\right\}\right)=-0.75

在计算当前位置的感受野之后,类似于卷积层的计算步骤,感受野根据步幅大小向右移动几个单位。输出变成:

{x}^{\prime }=\mathit{\max}\left(-1,0,-2,2\right)=2

img/515226_1_En_10_Fig34_HTML.png

图 10-34

最大池示例-1

同理,逐渐将感受野窗口移至最右侧,计算输出x=max(2,0,3,1) = 1。此时,窗口已经到达输入边缘。感受野窗口向下移动一步,回到行首,如图 10-35 所示。

img/515226_1_En_10_Fig35_HTML.png

图 10-35

最大池示例-2

来回循环,直到我们到达底部和右侧,我们得到最大池层的输出,如图 10-36 所示。长度和宽度略小于输入的高度和宽度。

img/515226_1_En_10_Fig36_HTML.png

图 10-36

最大池示例-3

由于 pooling 层没有需要学习的参数,计算简单,可以有效减小特征图的大小;它广泛应用于计算机视觉相关的任务。

通过精心设计池层感受野的高度、宽度 *k、*和步幅参数 s ,可以实现各种降维操作。比如一个常见的池层设置是 k = 2, s = 2,可以达到只输出输入高度和宽度一半的目的。如图 10-37 和图 10-38 所示,感受野 k = 3,步长 s = 2,输入 X 的高度和宽度为 5 × 5,但输出只有高度和宽度 2 × 2。

img/515226_1_En_10_Fig38_HTML.png

图 10-38

池层示例(一半大小输出)-2

img/515226_1_En_10_Fig37_HTML.png

图 10-37

池层示例(一半大小输出)-1

10.8 批处理正则层

随着卷积神经网络的出现,网络参数的数量大大减少,使得几十层的深度网络成为可能。但是在残差网络出现之前,不断增加的神经网络层数使得训练非常不稳定,有时网络长时间不更新甚至不收敛。同时,网络对超参数更加敏感,超参数的微小变化将完全改变网络的训练轨迹。

2015 年,Google 研究人员 Sergey Ioffe 等人提出了一种参数归一化的方法,并设计了批处理归一化(BatchNorm,或 BN)层[6]。BN 层的提出使得网络超参数的设置更加自由,比如更大的学习速率,更随机的网络初始化。同时,网络具有更快的收敛速度和更好的性能。BN 层提出后,被广泛应用于各种深度网络模型中。卷积层、BN 层、ReLU 层、pooling 层一度成为网络模型的标准单元块。堆叠 Conv-BN-ReLU-Pooling 方法通常会产生良好的模型性能。

为什么我们需要对网络中的数据进行规范化?很难从理论层面彻底解释这个问题,即使是 BN 层作者给出的解释也未必能说服所有人。与其纠结原因,不如通过具体问题来体验数据规范化的好处。

考虑 Sigmoid 激活函数及其梯度分布。如图 10-39 所示,Sigmoid 函数在区间x∈[2,2]的导数值分布在区间【0.1,0.25】。当 x > 2 或 x < -2 时,Sigmoid 函数的导数变得很小,趋近于 0,容易出现梯度弥散。为了避免 Sigmoid 函数因输入过大或过小而出现梯度分散现象,将函数输入归一化到 0 附近的小区间是非常重要的。从图 10-39 可以看出,归一化后数值映射到 0 附近,这里的导数值不会太小,不易出现梯度分散。这是规范化好处的一个例子。

img/515226_1_En_10_Fig39_HTML.png

图 10-39

Sigmoid 函数及其导数

让我们看另一个例子。考虑一个有两个输入节点的线性模型,如图 10-40(a) 所示:

L=a={x}_1{w}_1+{x}_2{w}_2+b

讨论以下两种输入分布下的优化问题:

  • x1∈【1,10】,x2∈【1,10】

  • x1∈【1,10】,x2∈【100,1000】

因为模型相对简单,所以可以绘制两种类型的损失函数等值线图。图 10-40(b) 为x1∈【1,10】和 x2∈【100,1000】时的优化轨迹示意图,图 10-40(c) 为x1∈【1,11】时的优化轨迹示意图图中圆环的中心是全局极值点。

img/515226_1_En_10_Fig40_HTML.png

图 10-40

数据规范化的一个例子

考虑:

\frac{\partial L}{\partial {w}_1}={x}_1

\frac{\partial L}{\partial {w}_2}={x}_2

当输入分布相似,偏导数值相同时,函数的优化轨迹如图 10-40(c) 所示;当输入分布相差很大时,例如x1≪x2,

\frac{\partial L}{\partial {w}_1}\ll \frac{\partial L}{\partial {w}_2}

损失函数的等势线在轴上更陡,一个可能的优化轨迹如图 10-40(b) 所示。对比两种优化轨迹可以看出,当 x 1 和 x 2 的分布相似时,图 10-40(c) 中的收敛更快,优化轨迹更理想。

通过前面两个例子,我们可以从经验上得出结论:当网络层输入分布相似,且分布在小范围内(如接近 0)时,更有利于函数优化。那么如何保证投入分布是相似的呢?数据规范化可以达到这个目的,数据可以映射到:

\hat{x}=\frac{x-{\mu}_r}{\sqrt{{\sigma_r}²+\epsilon }}

其中 μ r 为均值,σr2ϵ为小数值,如 1e—8。

在基于批次的训练阶段,如何获取各网络层的所有输入统计量 μ r 和σr2?考虑批内均值 μ B 和方差σB2:

{\mu}_B=\frac{1}{m}{\sum}_{i=1}^m{x}_i

{\sigma_B}²=\frac{1}{m}{\sum}_{i=1}^m{\left({x}_i-{\mu}_B\right)}²

可以看作是μr和σr2的近似值,其中 m 为批样本数。因此,在培训阶段,通过规范化:

{\hat{x}}_{train}=\frac{x_{train}-{\mu}_B}{\sqrt{{\sigma_B}²+\epsilon }}

以及近似的总体均值μr和方差σr2利用每批的均值 μ B 和方差σB2

在测试阶段,我们可以使用以下方法标准化测试数据:

{\hat{x}}_{test}=\frac{x_{test}-{\mu}_r}{\sqrt{{\sigma_r}²+\epsilon }}

前面的运算没有引入额外的变量进行优化,均值和方差都是通过已有数据得到的,不需要参与梯度更新。事实上,为了提高 BN 层的表达能力,BN 层的作者引入了“缩放和移位”技术来再次映射和转换变量:

\overset{\sim }{x}=\hat{x}\bullet \gamma +\beta

其中参数 γ 再次缩放归一化变量,参数 β 实现平移操作。不同的是,参数 γ和β 由反向传播算法自动优化,以达到在网络层“按需”缩放和平移数据分发的目的。

我们来学习一下如何在 TensorFlow 中实现 BN 层。

向前传播

我们将 BN 层的输入表示为 x ,输出表示为\overset{\sim }{x}。前向传播过程在训练阶段和测试阶段讨论。

训练阶段:首先计算当前批次的均值μB和方差σB2,然后根据以下公式将数据归一化:

img/515226_1_En_10_Figaj_HTML.png

然后,我们使用:

{\mu}_r\leftarrow momentum\bullet {\mu}_r+\left(1- momentum\right)\bullet {\mu}_B

{\sigma_r}²\leftarrow momentum\bullet {\sigma_r}²+\left(1- momentum\right)\bullet {\sigma_B}²

迭代更新全局训练数据的统计值 μ rσr2,其中动量是一个超参数,需要设置它来平衡更新幅度:当动量 = 0, μ rσ 动量 = 1 时, μ rσr2保持不变。在 TensorFlow 中,动量默认设置为 0.99。

测试阶段:BN 层使用

img/515226_1_En_10_Figam_HTML.png

计算img/515226_1_En_10_Figc_HTML.gif,其中 μ * r *σr2γβ 来自训练阶段的统计或优化结果,直接用于测试阶段,这些参数不更新。

反向传播

在反向更新阶段,反向传播算法求解损失函数的梯度\frac{\partial L}{\partial \gamma }\frac{\partial L}{\partial \beta },并根据梯度更新规则自动优化参数 γ和β

需要注意的是,对于 2D 特征图输入 X : [ bhwc ],BN 层不计算μB和σB2的每一个点;而是在通道轴 c 上的每个通道上计算μB和σB2,所以μB和σB以形状[100,32,32,3]的输入为例,通道轴上的平均值 c 计算如下:

In [7]:
x=tf.random.normal([100,32,32,3])
# Combine other dimensions except the channel dimension
x=tf.reshape(x,[-1,3])
# Calculate mean
ub=tf.reduce_mean(x,axis=0)
ub
Out[7]:
<tf.Tensor: id=62, shape=(3,), dtype=float32, numpy=array([-0.00222636, -0.00049868, -0.00180082], dtype=float32)>

有 c 个通道,因此产生 c 个平均值。

除了在 c 轴上统计数据的方法,我们还可以很容易地将该方法扩展到其他维度,如图 10-41 所示:

  • 层范数:计算每个样本所有特征的均值和方差。

  • 实例范数:计算每个样本每个通道上特征的均值和方差。

  • 分组范数:将 c 通道分成若干组,统计每个样本在通道组中的特征均值和方差。

前面提到的归一化方法是由几篇独立的论文提出的,并且已经被证实在某些应用中它等同于或者优于 BatchNorm 算法。可见深度学习算法的研究并不难。只要多思考,多实践自己的工程能力,每个人都有机会发表创新成果。

img/515226_1_En_10_Fig41_HTML.jpg

图 10-41

不同的规范化插图[7]

10.8.3 批量标准化层的实现

在 TensorFlow 中,BN 层可以通过各层轻松实现。BatchNormalization()类:

# Create BN layer
layer=layers.BatchNormalization()

与全连接层和卷积层不同,BN 层在训练阶段和测试阶段的行为是不同的。有必要通过设置训练标志来区分训练模式和测试模式。

以 LeNet-5 的网络模型为例,在卷积层之后增加 BN 层;代码如下:

network = Sequential([
    layers.Conv2D(6,kernel_size=3,strides=1),
    # Insert BN layer
    layers.BatchNormalization(),
    layers.MaxPooling2D(pool_size=2,strides=2),
    layers.ReLU(),
    layers.Conv2D(16,kernel_size=3,strides=1),
    # Insert BN layer
    layers.BatchNormalization(),
    layers.MaxPooling2D(pool_size=2,strides=2),
    layers.ReLU(),
    layers.Flatten(),
    layers.Dense(120, activation='relu'),
    layers.Dense(84, activation='relu'),
    layers.Dense(10)
                    ])

在训练阶段,你需要设置网络参数training=True来区分 BN 层是训练还是测试模型。代码如下:

    with tf.GradientTape() as tape:
        # Insert channel dimension
        x = tf.expand_dims(x,axis=3)
        # Forward calculation, [b, 784] => [b, 10]
        out = network(x, training=True)

在测试阶段,你需要设置training=False来避免 BN 层的错误行为。代码如下:

        for x,y in db_test:
            # Insert channel dimension
            x = tf.expand_dims(x,axis=3)
            # Forward calculation
            out = network(x, training=False)

10.9 经典卷积网络

自 2012 年 AlexNet [3]问世以来,人们提出了多种深度卷积神经网络模型,其中比较有代表性的有 VGG 系列[8]、GoogLeNet 系列[9]、ResNet 系列[10]、DenseNet 系列[11]。他们网络层的整体趋势是逐渐增加的。以网络模型在 ILSVRC 挑战赛 ImageNet 数据集上的分类性能为例。如图 10-42 所示,AlexNet 出现之前的网络模型都是浅层神经网络,Top-5 错误率在 25%以上。AlexNet 8 层深度神经网络将 Top-5 错误率降至 16.4%,性能大幅提升。随后的 VGG 和谷歌网络模型继续将错误率降至 6.7%;ResNet 的出现,第一次把网络层的数量增加到了 152 层。错误率也降低到 3.57%。

img/515226_1_En_10_Fig42_HTML.png

图 10-42

imagenes 数据集分类任务的模型性能

本节将重点介绍这些网络模型的特征。

10.9.1 AlexNet

2012 年,ILSVRC12 挑战赛 ImageNet 数据集分类任务的冠军 Alex Krizhevsky 提出了一个八层深度神经网络模型 AlexNet,它接收 224 × 224 的彩色图像数据的输入规模,经过五个卷积层和三个全连接层后得到 1000 个类别的概率分布。为了降低特征图的维数,AlexNet 在第一、第二、第五卷积层之后增加了 Max Pooling 层。如图 10-43 所示,网络的参数数量达到 6000 万。为了在当时的 NVIDIA GTX 580 GPU (3GB GPU 内存)上训练模型,Alex Krizhevsky 将卷积层和前两个全连接层分别在两个 GPU 上拆解进行训练,最后一层合并到一个 GPU 上做反向更新。AlexNet 在 ImageNet 中取得了 15.3%的 Top-5 错误率,比第二名低了 10.9%。

AlexNet 的创新之处在于:

img/515226_1_En_10_Fig43_HTML.jpg

图 10-43

AlexNet 架构[3]

  • 层数达到了八层。

  • 使用 ReLU 激活功能。以前的神经网络大多使用 Sigmoid 激活函数,计算相对复杂,容易出现梯度分散。

  • 引入漏失层。剔除提高了模型的泛化能力,防止了过拟合。

10.9.2 VGG 系列

AlexNet 模型的卓越性能激发了行业向更深层次的网络模型方向发展。2014 年,ILSVRC14 挑战赛的 ImageNet 分类任务亚军——牛津大学 VGG 实验室提出了 VGG11、VGG13、VGG16、VGG19 等一系列网络模型(图 10-45 ),并将网络深度提高到了 19 层[8]。以 VGG16 为例,它接受大小为 224 × 224 的彩色图片数据,然后经过 2 个 Conv-Conv 池单元和 3 个 Conv-Conv-Conv 池单元,最后通过 3 个全连通层输出当前图片属于 1000 个类别的概率,如图 10-44 所示。VGG16 在 ImageNet 上取得了 7.4%的 Top-5 错误率,比 AlexNet 的错误率低 7.9%。

VGG 系列网络的创新之处在于:

img/515226_1_En_10_Fig45_HTML.jpg

图 10-45

VGG 系列网络架构[8]

img/515226_1_En_10_Fig44_HTML.jpg

图 10-44

VGG16 体系结构

  • 层数增加到 19 层。

  • 使用更小的 3×3 卷积核,与 AlexNet 中的 7×7 卷积核相比,参数更少,计算成本更低。

  • 使用较小的池层窗口 2 × 2,步长大小 s = 2,而在 AlexNet 中s= 2,池窗口为 3×3。

10.9.3 GoogLeNet

3×3 卷积核的个数参数更少,计算成本更低,性能更好。因此,业界开始探索最小的卷积核:1x1 卷积核。如图 10-46 所示,输入为三通道 5x5 画面,用单个 1x1 卷积核进行卷积运算。用对应通道的卷积核计算每个通道的数据,得到三个通道的中间矩阵,将对应的位置相加,得到最终的输出张量。对于 中 bhwc 的输入形状,1x1 卷积层的输出为[ bhwcout,其中c**1x1 卷积核的一个特殊特性是,它只能变换通道数,而不改变特征图的宽度和高度。

![img/515226_1_En_10_Fig46_HTML.png

图 10-46

1 × 1 卷积内核示例

2014 年,ILSVRC14 挑战赛冠军 Google 提出了大量使用 3×3 和 1×1 卷积核的网络模型:GoogLeNet,网络层数为 22 [9]。GoogLeNet 的层数虽然比 AlexNet 多很多,但参数量只有 AlexNet 的一半,性能也比 AlexNet 好很多。在 ImageNet 数据集分类任务上,GoogLeNet 取得了 6.7%的 Top-5 错误率,在错误率上比 VGG16 低 0.7%。

GoogLeNet 网络采用模块化设计的思想,通过堆叠大量的初始模块形成复杂的网络结构。如图 10-47 所示,初始模块的输入为 X ,然后经过四个子网络,最后在通道轴上拼接合并,形成初始模块的输出。这四个子网络是:

img/515226_1_En_10_Fig47_HTML.png

图 10-47

初始模块

  • 1 × 1 卷积层。

  • 1 × 1 卷积层,然后通过一个 3×3 的卷积层。

  • 1 × 1 卷积层,然后通过一个 5×5 的卷积层。

  • 3 × 3 最大池层,然后通过 1x1 卷积层。

GoogLeNet 的网络结构如图 10-48 所示。红框中的网络结构是图 10-47 中的网络结构。

img/515226_1_En_10_Fig48_HTML.png

图 10-48

GoogLeNet 架构[9]

10.10 实际操作 CIFAR10 和 VGG13

MNIST 是机器学习最常用的数据集之一,但由于手写数字图片非常简单,而 MNIST 数据集只保存图像灰度信息,因此不适合输入设计为 RGB 三通道的网络模型。本节将介绍另一个经典的影像分类数据集:CIFAR10。

CIFAR10 数据集由加拿大高级研究所发布。它包含十类物体的彩色图片,如飞机、汽车、鸟和猫。每个类别收集了大小图片 6000 张,共计 60000 张。其中 5 万张作为训练数据集,1 万张作为测试数据集。每种类型的样品如图 10-49 所示。

img/515226_1_En_10_Fig49_HTML.jpg

图 10-49

CIFAR10 数据集 1

同样,在 TensorFlow 中,不需要手动下载、解析和加载 CIFAR10 数据集。训练集和测试集可以通过 datasets.cifar10.load_data()函数直接加载。举个例子,

# Load CIFAR10 data set
(x,y), (x_test, y_test) = datasets.cifar10.load_data()
# Delete one dimension of y, [b,1] => [b]
y = tf.squeeze(y, axis=1)
y_test = tf.squeeze(y_test, axis=1)
# Print the shape of training and testing sets
print(x.shape, y.shape, x_test.shape, y_test.shape)
# Create training set and preprocess
train_db = tf.data.Dataset.from_tensor_slices((x,y))
train_db = train_db.shuffle(1000).map(preprocess).batch(128)
# Create testing set and preprocess
test_db = tf.data.Dataset.from_tensor_slices((x_test,y_test))
test_db = test_db.map(preprocess).batch(128)
# Select a Batch
sample = next(iter(train_db))
print('sample:', sample[0].shape, sample[1].shape,
      tf.reduce_min(sample[0]), tf.reduce_max(sample[0]))

TensorFlow 会自动将数据集下载到路径 C:\Users\username\。keras\datasets,用户可以查看它,或者手动删除不必要的数据集缓存。前面的代码运行后,训练集中的 Xy 的形状为(50000,32,32,3)和(50000),测试集中的 Xy 的形状为(10000,32,32,3)和(10000),表示图片的大小为 32 × 32,这些是彩色图片,训练集中的样本数

CIFAR10 图像识别任务并不简单。这主要是由于 CIFAR10 的图像内容需要大量的细节才能呈现,保存的图像分辨率只有 32 × 32,使得主体信息模糊,甚至人眼难以分辨。浅层神经网络的表达能力有限,难以达到较好的性能。在本节中,我们将根据数据集的特征修改 VGG13 网络结构,以完成 CIFAR10 图像识别,如下所示:

  • 将网络输入调整为 32 × 32。原网络输入为 224 × 224,导致输入特征维数过大,网络参数过大。

  • 对于十个分类任务的设置,三个全连接层的维数是[256,64,10]。

图 10-50 是调整后的 VGG13 网络结构,我们统称为 VGG13 网络模型。

img/515226_1_En_10_Fig50_HTML.png

图 10-50

调整后的 VGG13 模型结构

我们将网络实现为两个子网络:卷积子网络和全连接子网络。卷积子网络由五个子模块组成,每个子模块包含 conv-conv-最大池单元结构。代码如下:

conv_layers = [
    # Conv-Conv-Pooling unit 1
    # 64 3x3 convolutional kernels with same input and output size
    layers.Conv2D(64, kernel_size=[3, 3], padding="same", activation=tf.nn.relu),
    layers.Conv2D(64, kernel_size=[3, 3], padding="same", activation=tf.nn.relu),
    # Reduce the width and height size to half of its original
    layers.MaxPool2D(pool_size=[2, 2], strides=2, padding='same'),

    # Conv-Conv-Pooling unit 2, output channel increases to 128, half width and height
    layers.Conv2D(128, kernel_size=[3, 3], padding="same", activation=tf.nn.relu),
    layers.Conv2D(128, kernel_size=[3, 3], padding="same", activation=tf.nn.relu),
    layers.MaxPool2D(pool_size=[2, 2], strides=2, padding='same'),

    # Conv-Conv-Pooling unit 3, output channel increases to 256, half width and height

    layers.Conv2D(256, kernel_size=[3, 3], padding="same", activation=tf.nn.relu),
    layers.Conv2D(256, kernel_size=[3, 3], padding="same", activation=tf.nn.relu),
    layers.MaxPool2D(pool_size=[2, 2], strides=2, padding='same'),

    # Conv-Conv-Pooling unit 4, output channel increases to 512, half width and height
    layers.Conv2D(512, kernel_size=[3, 3], padding="same", activation=tf.nn.relu),

    layers.Conv2D(512, kernel_size=[3, 3], padding="same", activation=tf.nn.relu),
    layers.MaxPool2D(pool_size=[2, 2], strides=2, padding='same'),

    # Conv-Conv-Pooling unit 5, output channel increases to 512, half width and height
    layers.Conv2D(512, kernel_size=[3, 3], padding="same", activation=tf.nn.relu),
    layers.Conv2D(512, kernel_size=[3, 3], padding="same", activation=tf.nn.relu),
    layers.MaxPool2D(pool_size=[2, 2], strides=2, padding='same')
]
conv_net = Sequential(conv_layers)

全连通子网络包含三个全连通层,除最后一层外,每层都增加了一个 ReLU 非线性激活函数。代码如下所示:

# Create 3 fully connected layer sub-network
fc_net = Sequential([
    layers.Dense(256, activation=tf.nn.relu),
    layers.Dense(128, activation=tf.nn.relu),
    layers.Dense(10, activation=None),
])

创建子网后,使用以下代码查看网络的参数:

# build network and print parameter info
conv_net.build(input_shape=[4, 32, 32, 3])
fc_net.build(input_shape=[4, 512])
conv_net.summary()
fc_net.summary()

卷积网络的参数总数约为 940000,全连接网络的参数总数约为 177000,网络的参数总数约为 950000,比最初版本的 VGG13 少了很多。

由于我们将网络实现为两个子网络,因此在执行梯度更新时,有必要合并两个子网络的参数,如下所示:

# merge parameters of two sub-networks
variables = conv_net.trainable_variables + fc_net.trainable_variables
# calculate gradient for all parameters
grads = tape.gradient(loss, variables)
# update gradients
optimizer.apply_gradients(zip(grads, variables))

运行 CIFS ar 10 _ train . py 文件开始训练模型。经过 50 个历元的训练,网络的测试准确率达到了 77.5%。

10.11 卷积层变体

卷积神经网络的研究已经产生了各种优秀的网络模型,并且已经提出了卷积层的各种变体。本节将重点介绍几种典型的卷积层变体。

扩张/阿特鲁卷积

为了减少网络的参数数目,卷积核的设计通常选择较小的 1 × 1 和 3 × 3 感受野大小。卷积核小使得提取特征时网络的感受野面积有限,但增大感受野面积会增加网络参数的数量和计算成本,因此需要权衡设计。

扩张/阿特鲁卷积是解决这个问题的较好方法。扩张/阿特鲁卷积是在普通卷积的感受野上增加一个扩张率参数来控制感受野区域的采样步长,如图 10-51 所示。感受野采样步长扩张率为 1 时,每个感受野采样点之间的距离为 1,此时的扩张卷积退化为普通卷积;当扩张率为 2 时,在感受野中每两个单位采样一个点。如图 10-51 中间绿色方框中的绿色网格所示,每个采样网格的间距为 2。同样,图 10-51 右侧的膨胀率为 3,采样步长为 3。尽管扩张率的增加会增加感受野的面积,但计算中涉及的实际点数保持不变。

img/515226_1_En_10_Fig51_HTML.png

图 10-51

不同扩张率的感受野步长

以单通道 7 × 7 张量和单个 3 × 3 卷积核为例,如图 10-52 。在初始位置,感受野从顶部和右侧位置取样,每隔一点取样。共采集了 9 个数据点,如图 10-52 中绿色方框所示。这 9 个数据点乘以卷积核,写入输出张量的相应位置。

img/515226_1_En_10_Fig52_HTML.png

图 10-52

扩张卷积样本-1

卷积核窗口按照步长 s = 1 向右移动一个单位,如图 10-53 所示。执行相同的间隔采样。总共采样了 9 个数据点。用卷积核完成乘法和累加运算,输出张量写到相应的位置,直到卷积核移动到最下面最右边的位置。需要注意的是,卷积核窗口的移动步长 s 和感受野区域的采样步长扩张率是不同的概念。

img/515226_1_En_10_Fig53_HTML.png

图 10-53

扩张卷积样本-2

扩展卷积提供了更大的感受野窗口,而不增加网络参数。然而,当使用中空卷积建立网络模型时,需要仔细设计膨胀率参数以避免网格效应。同时,较大的膨胀率参数不利于诸如小对象检测和语义分割的任务。

在 TensorFlow 中,可以通过设置图层的 dilation_rate 参数来选择使用正常卷积或膨胀卷积。Conv2D()类。例如

In [8]:
x = tf.random.normal([1,7,7,1]) # Input
# Dilated convolution, 1 3x3 kernel
layer = layers.Conv2D(1,kernel_size=3,strides=1,dilation_rate=2)
out = layer(x) # forward calculation
out.shape
Out[8]: TensorShape([1, 3, 3, 1])

当 dilation_rate 参数设置为默认值 1 时,使用正常的卷积方法进行计算;当 dilation_rate 参数大于 1 时,对膨胀卷积方法进行采样计算。

转置卷积

转置卷积(或分数步长卷积,有时也称为反卷积)。实际上,反卷积在数学上定义为卷积的逆过程,但转置卷积无法恢复原卷积的输入,所以称之为反卷积并不恰当)通过在输入之间填充大量的填充来达到输出高度和宽度大于输入高度和宽度的效果,从而达到上采样的目的,如图 10-54 所示。我们先介绍转置卷积的计算过程,然后介绍转置卷积和普通卷积的关系。

为了简化讨论,我们只讨论带有 h = w 的输入,即输入高度和宽度相等的情况。

img/515226_1_En_10_Fig54_HTML.png

图 10-54

用于上采样的转置卷积

o + 2p − k = n * s

考虑下面这个例子:单通道特征图有 2 × 2 个输入,转置卷积核为 3 × 3, s = 2,填充 p = 0。首先,在输入数据点之间均匀插入s1 个空白数据点,得到的矩阵为 3 × 3,如图 10-55 第二个矩阵所示。根据填充量k**p1 = 301 = 2 填充 3 × 3 矩阵周围相应的行/列。此时输入张量的高度和宽度为 7 × 7,如图 10-55 第三个矩阵所示。

img/515226_1_En_10_Fig55_HTML.png

图 10-55

输入和填充示例

在 7 × 7 的输入张量上,应用步长s= 1,填充 p = 0 的 3 × 3 卷积核运算(注意这个阶段普通卷积的步长s始终为 1,与转置卷积的步长 s 不同)。根据普通卷积计算公式,输出大小为:

o=\left\lfloor \frac{i+2\ast p-k}{s^{\prime }}\right\rfloor +1=\left\lfloor \frac{7+2\ast 0-3}{1}\right\rfloor +1=5

表示 5 × 5 输出大小。我们直接按照这个计算过程给出最终的转置卷积输出和输入关系。当o+2p-k为 s 的倍数时,满足关系o=(I-1)s+k-2p

转置卷积不是普通卷积的逆过程,但两者有一定的联系,转置卷积也是基于普通卷积实现的。同样设置下,普通卷积运算 o = Conv ( x )后得到输入 x ,将 o 送入转置卷积运算得到x=conv transpose(o,其中x′𕟆我们可以用输入为 5 × 5,步长 s = 2,填充 p = 0,3 × 3 卷积核的普通卷积运算来验证演示,如图 10-56 所示。

img/515226_1_En_10_Fig56_HTML.png

图 10-56

使用普通卷积生成相同大小的输入

可以看出,将转置卷积大小为 5 × 5 的输出发送到相同设定条件下的普通卷积,可以得到大小为 2 × 2 的输出。这个大小正好是转置卷积的输入大小。同时,我们也观察到输出矩阵并不完全是输入到转置卷积中的输入矩阵。转置卷积和普通卷积不是互逆过程,不能恢复对方的输入内容,只能恢复大小相等的张量。所以称之为反卷积是不合适的。

基于 TensorFlow 实现上例的转置卷积运算,代码如下:

In [8]:
# Create matrix X with size 5x5
x = tf.range(25)+1
# Reshape X to certain shape
x = tf.reshape(x,[1,5,5,1])
x = tf.cast(x, tf.float32)
# Create constant matrix
w = tf.constant([[-1,2,-3.],[4,-5,6],[-7,8,-9]])
# Reshape dimension
w = tf.expand_dims(w,axis=2)
w = tf.expand_dims(w,axis=3)
# Regular convolution calculation
out = tf.nn.conv2d(x,w,strides=2,padding='VALID')
out
Out[9]: # Output size is 2x2
<tf.Tensor: id=14, shape=(1, 2, 2, 1), dtype=float32, numpy=
array([[[[ -67.],
         [ -77.]],

        [[-117.],
         [-127.]]]], dtype=float32)>

现在我们用普通卷积的输出作为转置卷积的输入,验证转置卷积的输出是否为 5×5;代码如下:

In [10]:
# Transposed convolution calculation
xx = tf.nn.conv2d_transpose(out, w, strides=2,
    padding='VALID',
    output_shape=[1,5,5,1])
Out[10]: # Output size is 5x5
<tf.Tensor: id=117, shape=(5, 5), dtype=float32, numpy=
array([[   67.,  -134.,   278.,  -154.,   231.],
       [ -268.,   335.,  -710.,   385.,  -462.],
       [  586.,  -770.,  1620.,  -870.,  1074.],
       [ -468.,   585., -1210.,   635.,  -762.],
       [  819.,  -936.,  1942., -1016.,  1143.]], dtype=float32)>

可以看出,转置卷积可以恢复相同大小的普通卷积的输入,但转置卷积的输出并不等同于普通卷积的输入。

o + 2p − k ≠n * s

让我们更深入地分析卷积运算中输入和输出之间关系的细节。考虑卷积运算的输出表达式:

o=\left\lfloor \frac{i+2\ast p-k}{s}\right\rfloor +1

当步长 s > 1 时,\left\lfloor \frac{i+2\ast p-k}{s}\right\rfloor的下舍入运算使多个输入大小 i 对应同一个输出大小 o 。例如,考虑输入大小为 6 × 6、卷积核大小为 3 × 3、步长为 1 的卷积运算。代码如下:

In [11]:
x = tf.random.normal([1,6,6,1])
# 6x6 input
out = tf.nn.conv2d(x,w,strides=2,padding='VALID')
out.shape
x = tf.random.normal([1,6,6,1])...
Out[12]: # Output size 2x2, same as when the input size is 5x5
<tf.Tensor: id=21, shape=(1, 2, 2, 1), dtype=float32, numpy=
array([[[[ 20.438847 ],
         [ 19.160788 ]],

        [[  0.8098897],
         [-28.30303  ]]]], dtype=float32)>

在这种情况下,可以得到同样大小 2 × 2 的卷积输出,如图 10-56 所示。因此,不同输入大小的卷积运算可能获得相同的输出。考虑到卷积和转置卷积的输入输出关系是可以互换的,从转置卷积的角度来看,输入大小 i 经过转置卷积运算后,可能会得到不同的输出大小 o 。因此,通过填充图 10-55 中的 a 行和 a 列来实现不同大小的输出 o ,从而恢复不同大小输入的正常卷积,则 a 的关系为:

a=\left(o+2p-k\right)\%s

转置卷积的输出变为:

o=\left(i-1\right)s+k-2p+a

在 TensorFlow 中,不需要手动指定一个。我们只是指定输出大小。TensorFlow 会自动导出需要填充的行数和列数,前提是输出大小合法。例如:

In [13]:
# Get output of size 6x6
xx = tf.nn.conv2d_transpose(out, w, strides=2,
    padding='VALID',
    output_shape=[1,6,6,1])
xx
Out[13]:
<tf.Tensor: id=23, shape=(1, 6, 6, 1), dtype=float32, numpy=
array([[[[ -20.438847 ],
         [  40.877693 ],
         [ -80.477325 ],
         [  38.321575 ],
         [ -57.48236  ],
         [   0\.       ]],...

改变参数 output_shape=[1,5,5,1]也可以得到高、宽为 5 × 5 的张量。

矩阵转置

转置卷积的转置WT是指卷积核矩阵 W 生成的稀疏矩阵W需要先进行转置,然后进行矩阵乘法运算,而普通卷积没有转置的步骤。这就是为什么它被称为转置卷积。

考虑普通的 Conv2d 运算: XW ,卷积核需要按照步长在行列方向上循环移动,以获得运算所涉及的感受野的数据,并串行计算每个窗口的“乘累加”值,效率极低。为了加快运算速度,数学上可以将卷积核 W 按照步距重排为稀疏矩阵W′,然后运算W@X一次完成(其实矩阵W太稀疏,导致很多无用的 0-乘法运算,很多深

以下面的卷积核为例:4 行 4 列的输入 X ,高度和宽度为 3 × 3,步幅为 1,无填充。首先将 X 展平为X??’,如图 10-57 所示。

img/515226_1_En_10_Fig57_HTML.png

图 10-57

转置卷积X??

然后将卷积核 W 转换成稀疏矩阵W,如图 10-58 所示。

img/515226_1_En_10_Fig58_HTML.png

图 10-58

转置卷积W??

这时,普通的卷积运算可以通过一次矩阵乘法来实现:

{O}^{\prime }={W}^{\prime }@{X}^{\prime }

如果给定 O ,如何生成与 X it 形状大小相同的张量?将转置后的矩阵W与重排后的矩阵O相乘如图 10-57 :

{X}^{\prime }={W}^{\prime T}@{O}^{\prime }

X?? 整形为与原始输入尺寸 X 相同。比如O的形状为[4,1】,WT的形状为[16,4],矩阵乘法得到的X的形状为[16,1],形状为[4,4]的张量经过整形就可以生成。由于转置卷积在矩阵运算时需要先进行转置,然后才能与转置卷积的输入矩阵相乘,所以称为转置卷积。

转置卷积具有“放大特征图”的功能,被广泛应用于对抗网络的生成和语义分割。例如,DCGAN [12]中的生成器通过堆叠转置卷积层来实现逐层“放大”,最终得到非常逼真的生成画面。

img/515226_1_En_10_Fig59_HTML.jpg

图 10-59

DCGAN 架构[12]

转置卷积实现

在 TensorFlow 中,转置卷积运算可以通过 nn.conv2d_transpose()函数实现。我们先通过 nn.conv2d 完成普通的卷积运算,注意转置卷积的卷积核的定义格式是[ kkc outcin]。例如

In [14]:
# Input 4x4
x = tf.range(16)+1
x = tf.reshape(x,[1,4,4,1])
x = tf.cast(x, tf.float32)
# 3x3 kernel
w = tf.constant([[-1,2,-3.],[4,-5,6],[-7,8,-9]])
w = tf.expand_dims(w,axis=2)
w = tf.expand_dims(w,axis=3)
# Regular convolutional operation
out = tf.nn.conv2d(x,w,strides=1,padding='VALID')
Out[14]:
<tf.Tensor: id=42, shape=(2, 2), dtype=float32, numpy=
array([[-56., -61.],
       [-76., -81.]], dtype=float32)>

在步幅=1,填充= '有效',卷积核不变的情况下,我们通过卷积核 w 与输出的转置卷积运算,尝试恢复与输入 x 大小相同的高度和宽度张量。代码如下:

In [15]: # Restore 4x4 input
xx = tf.nn.conv2d_transpose(out, w, strides=1, padding='VALID', output_shape=[1,4,4,1])
tf.squeeze(xx)
Out[15]:
<tf.Tensor: id=44, shape=(4, 4), dtype=float32, numpy=
array([[  56.,  -51.,   46.,  183.],
       [-148.,  -35.,   35., -123.],
       [  88.,   35.,  -35.,   63.],
       [ 532.,  -41.,   36.,  729.]], dtype=float32)>

可以看出,4 × 4 的特征图是由转置卷积生成的,但特征图的数据与输入 x 并不相同。

使用 tf.nn.conv2d_transpose 进行转置卷积运算时,需要手动设置输出高度和宽度。tf.nn.conv2d_transpose 不支持自定义填充设置,它只能设置为 VALID 或 SAME。

当设置了 padding='VALID '时,输出大小为:

o=\left(i-1\right)s+k

当设置 padding='SAME '时,输出大小为:

o=i\bullet s

如果读者暂时不能理解转置卷积的原理细节,他/她可以记住前面的两个表达式。例如,当计算 2 × 2 转置卷积输入和 3 × 3 卷积内核时,步长=1,填充= '有效',输出大小为:

{h}^{\prime }={w}^{\prime }=\left(2-1\right)\bullet 1+3=4

计算 2 × 2 转置卷积输入和 3 × 3 卷积内核时,步长=3,填充=“相同”,输出大小为:

{h}^{\prime }={w}^{\prime }=2\bullet 3=6

转置卷积也可以和其他层一样。通过图层创建转置卷积图层。Conv2DTranspose 类,然后调用实例完成正向计算:

In [16]:
layer = layers.Conv2DTranspose(1,kernel_size=3,strides=1,padding='VALID')
xx2 = layer(out)
xx2
Out[16]:
<tf.Tensor: id=130, shape=(1, 4, 4, 1), dtype=float32, numpy=
array([[[[  9.7032385 ],
         [  5.485071  ],
         [ -1.6490463 ],
         [  1.6279562 ]],...

分离卷积

这里我们以深度方向可分离卷积为例。当普通卷积对多通道输入进行运算时,卷积核的每个通道与输入的每个通道分别进行卷积,得到一个多通道特征图,然后将相应的元素相加,产生单个卷积核输出的最终结果,如图 10-60 所示。

img/515226_1_En_10_Fig60_HTML.png

图 10-60

普通卷积计算示意图

单独卷积的计算过程是不同的。卷积核的每个通道与每个输入通道进行卷积,得到多个通道的中间特征,如图 10-61 所示。然后,对该多通道中间特征张量进行多个 1 × 1 卷积核的普通卷积运算,以获得具有恒定高度和宽度的多个输出。这些输出在信道轴上拼接,以产生最终分离的卷积层输出。可以看出,分离的卷积层包括两步卷积运算。第一卷积运算是单个卷积核,第二卷积运算包括多个卷积核。

img/515226_1_En_10_Fig61_HTML.png

图 10-61

深度可分卷积计算示意图

那么使用单独卷积有什么好处呢?一个明显的优点是,对于相同的输入输出,可分离卷积的参数约为普通卷积的 1/3。考虑上图中普通卷积和单独卷积的例子。普通卷积的参数数量为:

3\bullet 3\bullet 3\bullet 4=108

分离卷积的参数的第一部分是:

3\bullet 3\bullet 3\bullet 1=27

参数的第二部分是:

1\bullet 1\bullet 3\bullet 4=14

分离卷积的总参数量只有 39,但它可以实现与普通卷积相同的输入输出大小变换。分离卷积已广泛应用于对计算成本敏感的领域,如异常和移动网络。

10.12 深层剩余网络

AlexNet、VGG、GoogLeNet 等网络模型的出现,将神经网络的发展带到了几十层的阶段。研究人员发现,网络越深,越有可能获得更好的泛化能力。但是随着模型的深入,网络越来越难训练,主要是梯度分散和梯度爆炸造成的。在层数较深的神经网络中,当梯度信息从网络的最后一层逐层传递到网络的第一层时,在传递过程中会出现梯度接近 0 或者梯度值很大的现象。网络层越深,这种现象可能越严重。

那么如何解决深度神经网络的梯度分散和梯度爆炸现象呢?一个非常自然的想法是,由于浅层神经网络不容易出现这些梯度,所以可以尝试为深层神经网络添加一个回退机制。当深度神经网络可以容易地回退到浅层神经网络时,深度神经网络可以获得与浅层神经网络相当的模型性能,但不会更差。

通过在输入和输出之间增加一个直接连接——跳过连接——神经网络就有了后退的能力。以 VGG13 深度神经网络为例,假设在 VGG13 模型中观察到了梯度弥散现象,而十层网络模型没有观察到梯度弥散现象,那么可以考虑在最后两个卷积层增加 Skip 连接,如图 10-62 所示。这样网络模型就可以自动选择是通过这两个卷积层完成特征变换,还是跳过这两个卷积层选择跳过连接,或者将两个卷积层的输出合并起来跳过连接。

img/515226_1_En_10_Fig62_HTML.png

图 10-62

跳过连接的 VGG13 架构

2015 年,微软亚洲研究院的何等人发表了基于跳过连接的深度残差网络(residual neural network,简称 ResNet)算法[10],提出了 18 层、34 层、50 层、101 层、152 层网络,即 ResNet-18、ResNet-34、ResNet-50、ResNet-101 和 ResNet-152 模型,甚至成功训练了一个 1202 层的极深度神经网络。ResNet 在 ILSVRC 2015 挑战赛的 ImageNet 数据集上实现了分类和检测等任务的最佳性能。ResNet 的论文至今已被引用超过 25000 次,可见 ResNet 在人工智能界的影响力。

ResNet 原则

ResNet 通过在卷积层的输入和输出之间增加 Skip 连接来实现回退机制,如图 10-63 所示。输入 x 经过两个卷积层得到特征变换后的输出 F ( x ),将 F ( x )的对应元素加到 x 得到最终输出:

H(x)=x+F(x)

H ( x )称为残差块(简称 ResBlock)。由于跳过连接包围的卷积神经网络需要学习映射F(x)=H(x)x,所以称为残差网络。

为了满足卷积层的输入 x 和输出 F ( x )的相加,输入的形状需要和输出 F ( x )的形状完全相同。当形状不一致时,输入的 x 一般通过在 Skip 连接上增加额外的卷积运算转换成与 F ( x 相同的形状,如图 10-63 中的函数 identity ( x )所示,其中 identity ( x )主要采取 1 × 1 卷积运算来调整

img/515226_1_En_10_Fig63_HTML.png

图 10-63

剩余模块

图 10-64 对比了 34 层深度残差网络、34 层普通深度网络、19 层 VGG 网络结构。可以看出,深度残差网络通过堆叠残差模块达到更深的网络层,从而获得训练稳定、性能优越的深度网络模型。

img/515226_1_En_10_Fig64_HTML.jpg

图 10-64

网络架构比较[10]

ResBlock 实现

深度残差网络没有添加新的网络层类型,而只是在输入和输出之间添加了一个跳过连接,因此没有 ResNet 的底层实现。残差模块可以通过调用普通卷积层在 TensorFlow 中实现。

首先,创建一个新类。初始化残差块中需要的卷积层和激活功能层,然后创建新的卷积层;代码如下:

class BasicBlock(layers.Layer):
    # Residual block
    def __init__(self, filter_num, stride=1):
        super(BasicBlock, self).__init__()
        # Create Convolutional Layer 1
        self.conv1 = layers.Conv2D(filter_num, (3, 3), strides=stride, padding='same')
        self.bn1 = layers.BatchNormalization()
        self.relu = layers.Activation('relu')
        # Create Convolutional Layer 2
        self.conv2 = layers.Conv2D(filter_num, (3, 3), strides=1, padding='same')
        self.bn2 = layers.BatchNormalization()

F ( x )和 x 形状不同时,不能直接相加。我们需要创建一个新的卷积层身份 ( x )来完成 x 的形状转换。按照前面的代码,实现如下:

        if stride != 1: # Insert identity layer
            self.downsample = Sequential()
            self.downsample.add(layers.Conv2D(filter_num, (1, 1), strides=stride))
        else: # connect directly
            self.downsample = lambda x:x

正向传播时,只需要添加 F ( x )和身份 ( x )并添加 ReLU 激活函数。正向计算功能代码如下:

    def call(self, inputs, training=None):
        # Forward calculation
        out = self.conv1(inputs) # 1st Conv layer
        out = self.bn1(out)
        out = self.relu(out)
        out = self.conv2(out) # 2nd Conv layer
        out = self.bn2(out)
        #  identity() conversion
        identity = self.downsample(inputs)
        # f(x)+x
        output = layers.add([out, identity])
        # activation function
        output = tf.nn.relu(output)
        return output

10.13 密集网络

跳过连接的想法在 ResNet 上取得了巨大的成功。研究人员已经开始尝试不同的跳过连接方案,其中 DenseNet [11]更受欢迎。DenseNet 通过跳过连接将所有先前图层的要素地图信息与当前图层的输出进行聚合。与 ResNet 的相应位置添加方法不同,DenseNet 使用通道轴维度中的拼接操作来聚合特征信息。

如图 10-65 所示,输入 X 0 经过卷积层 H 1 ,输出 X 1 与信道轴拼接得到聚合特征张量,发送到卷积层 H 2 得到输出 X 2 同样, X 2X 1X 0 拼接发送到下一层。如此重复,直到最后一层X4的输出和前面所有层的特征信息:{XI}I= 0,1,2,3 聚合到模块的最终输出。这种基于跳跃连接的密集连接模块称为密集块。

img/515226_1_En_10_Fig65_HTML.jpg

图 10-65

密集块状建筑 2

DenseNet 通过堆叠多个密集块来构建复杂的深度神经网络,如图 10-66 所示。

img/515226_1_En_10_Fig66_HTML.jpg

图 10-66

典型的 DenseNet 架构 3

图 10-67DenseNet 不同版本的性能对比,DenseNet 和 ResNet 的性能对比,dense net 和 ResNet 的训练曲线。

img/515226_1_En_10_Fig67_HTML.jpg

图 10-67

DenseNet 和 ResNet 的性能比较[11]

10.14 实际操作 CIFAR10 和 ResNet18

在本节中,我们将实现 18 层深度残差网络 ResNet18,并在 CIFAR10 图像数据集上对其进行训练和测试。我们将它的性能与 13 层普通神经网络 VGG13 进行比较。

标准 ResNet18 接受大小为 224 × 224 的图像数据。我们适当调整 ResNet18,使其输入尺寸为 32 × 32,输出尺寸为 10。调整后的 ResNet18 网络结构如图 10-68 所示。

img/515226_1_En_10_Fig68_HTML.png

图 10-68

调整后的 ResNet18 架构

首先实现中间两个卷积层的残差模块,以及如下所示的跳过连接 1x1 卷积层的残差块:

class BasicBlock(layers.Layer):
    # Residual block
    def __init__(self, filter_num, stride=1):
        super(BasicBlock, self).__init__()
        # 1st conv layer
        self.conv1 = layers.Conv2D(filter_num, (3, 3), strides=stride, padding='same')
        self.bn1 = layers.BatchNormalization()
        self.relu = layers.Activation('relu')
        # 2nd conv layer
        self.conv2 = layers.Conv2D(filter_num, (3, 3), strides=1, padding='same')
        self.bn2 = layers.BatchNormalization()

        if stride != 1:
            self.downsample = Sequential()
            self.downsample.add(layers.Conv2D(filter_num, (1, 1), strides=stride))
        else:
            self.downsample = lambda x:x

    def call(self, inputs, training=None):
        # Forward calculation
        # [b, h, w, c], 1st conv layer
        out = self.conv1(inputs)
        out = self.bn1(out)
        out = self.relu(out)
        # 2nd conv layer
        out = self.conv2(out)
        out = self.bn2(out)
        # identity()
        identity = self.downsample(inputs)
        # Add two layers
        output = layers.add([out, identity])

        output = tf.nn.relu(output) # activation function

        return output

设计深度卷积神经网络时,一般遵循特征图高度和宽度逐渐减小,通道数逐渐增加的经验法则。高层特征的提取可以通过堆叠通道号逐渐增加的 res 块来实现,通过 build_resblock 可以一次构建多个残差模块,如下所示:

    def build_resblock(self, filter_num, blocks, stride=1):
        # stack filter_num BasicBlocks
        res_blocks = Sequential()
        # Only 1st BasicBlock’s stride may not be 1
        res_blocks.add(BasicBlock(filter_num, stride))

        for _ in range(1, blocks):# Stride of Other BasicBlocks are all 1
            res_blocks.add(BasicBlock(filter_num, stride=1))

        return res_blocks

让我们实现一个通用的 ResNet 网络模型,如下所示:

class ResNet(keras.Model):
    # General ResNet class
    def __init__(self, layer_dims, num_classes=10): # [2, 2, 2, 2]
        super(ResNet, self).__init__()
        self.stem = Sequential([layers.Conv2D(64, (3, 3), strides=(1, 1)),
                                layers.BatchNormalization(),
                                layers.Activation('relu'),
                                layers.MaxPool2D(pool_size=(2, 2), strides=(1, 1), padding='same')
                                ])
        # Stack 4 Blocks
        self.layer1 = self.build_resblock(64,  layer_dims[0])
        self.layer2 = self.build_resblock(128, layer_dims[1], stride=2)
        self.layer3 = self.build_resblock(256, layer_dims[2], stride=2)
        self.layer4 = self.build_resblock(512, layer_dims[3], stride=2)

        # Pooling layer => 1x1
        self.avgpool = layers.GlobalAveragePooling2D()
        # Fully connected layer
        self.fc = layers.Dense(num_classes)

    def call(self, inputs, training=None):
        # Forward calculation
        x = self.stem(inputs)
        # 4 blocks
        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = self.layer4(x)

        # Pooling layer
        x = self.avgpool(x)
        # Fully connected layer

        x = self.fc(x)

        return x

通过调整每个 Res 块的栈数和通道数可以生成不同的 ResNet,比如用 64-64-128-128-256-256-512-512 通道配置,一共 8 个 Res 块,就可以得到 ResNet18 网络模型。每个 ResBlock 包含两个主卷积层,因此卷积层数为 8 ⋅ 2 = 16,加上网络末端的全连通层,共 18 层。创建 ResNet18 和 ResNet34 可以简单地实现如下:

def resnet18():
    return ResNet([2, 2, 2, 2])

def resnet34():
    return ResNet([3, 4, 6, 3])

接下来,按如下方式完成 CIFAR10 数据集的加载:

(x,y), (x_test, y_test) = datasets.cifar10.load_data() # load data
y = tf.squeeze(y, axis=1) # sequeeze data
y_test = tf.squeeze(y_test, axis=1)
print(x.shape, y.shape, x_test.shape, y_test.shape)

train_db = tf.data.Dataset.from_tensor_slices((x,y)) # create training set
train_db = train_db.shuffle(1000).map(preprocess).batch(512)

test_db = tf.data.Dataset.from_tensor_slices((x_test,y_test)) #creat testing set
test_db = test_db.map(preprocess).batch(512)
# sample an example
sample = next(iter(train_db))
print('sample:', sample[0].shape, sample[1].shape,
      tf.reduce_min(sample[0]), tf.reduce_max(sample[0]))

数据预处理逻辑相对简单。我们只需要将数据范围直接映射到区间[1,1]。在这里,您还可以根据 ImageNet 数据图片的平均值和标准偏差执行标准化,如下所示:

def preprocess(x, y):
    x = 2*tf.cast(x, dtype=tf.float32) / 255\. - 1
    y = tf.cast(y, dtype=tf.int32)
    return x,y

网络训练逻辑与正常分类网络训练部分相同,训练 50 个时期如下:

    for epoch in range(50): # Train epoch
        for step, (x,y) in enumerate(train_db):
            with tf.GradientTape() as tape:
                # [b, 32, 32, 3] => [b, 10], forward calculation
                logits = model(x)
                # [b] => [b, 10],one-hot encoding
                y_onehot = tf.one_hot(y, depth=10)
                # Calculate loss
                loss = tf.losses.categorical_crossentropy(y_onehot, logits, from_logits=True)
                loss = tf.reduce_mean(loss)
            # Calculate gradient

            grads = tape.gradient(loss, model.trainable_variables)
            # Update parameters
            optimizer.apply_gradients(zip(grads, model.trainable_variables))

ResNet18 共有 1100 万个网络参数。经过 50 个历元后,网络的准确率达到了 79.3%。我们这里的代码相对来说比较精简。在仔细的超参数和数据增强的支持下,准确率可以更高。

10.15 参考

  1. G.E. Hinton,S. Osindero 和 Y.-W. Teh,“深度信念网络的快速学习算法”,*神经计算。,*第 18 期,第 1527—1554 页,2006 年第 7 期。

  2. Y.LeCun,B. Boser,J. S. Denker,D. Henderson,R. E. Howard,W. Hubbard 和 L. D. Jackel,“应用于手写邮政编码识别的反向传播”,*神经计算。,*第 1 卷,第 551—541 页,1989 年第 12 期。

  3. A.Krizhevsky、I. Sutskever 和 G. E. Hinton,“使用深度卷积神经网络的 ImageNet 分类”,神经信息处理系统的进展 25 ,F. Pereira、C. J. C. Burges、L. Bottou 和 K. Q. Weinberger,Curran Associates,Inc .,2012 年,第 1097-1105 页。

  4. Y.Lecun,L. Bottou,Y. Bengio 和 P. Haffner,“基于梯度的学习在文档识别中的应用”,《美国电气和电子工程师协会会议录,1998 年。

  5. 米(meter 的缩写))d .泽勒和 r .弗格斯,“可视化和理解卷积网络”,计算机视觉- ECCV 2014 年,Cham,2014 年。

  6. 南 Ioffe 和 C. Szegedy,“批量标准化:通过减少内部协变量移位来加速深度网络训练”, CoRR, abs/1502.03167,2015。

  7. Y.吴和 K. He,“组规范化”, CoRR, abs/1803.08494,2018。

  8. K.Simonyan 和 A. Zisserman,“用于大规模图像识别的极深度卷积网络”, CoRR, abs/1409.1556,2014 年。

  9. C.Szegedy,W. Liu,Y. Jia,P. Sermanet,S. Reed,D. Anguelov,D. Erhan,V. Vanhoucke 和 A. Rabinovich,“用卷积走得更深”,《计算机视觉与模式识别(CVPR)】,2015。

  10. K.何,x .张,s .任,j .孙,“深度残差学习用于图像识别”, CoRR, abs/1512.03385,2015。

  11. G.黄,刘,温伯格,“密集连接卷积网络”, CoRR, abs/1608.06993,2016。

  12. A.拉德福德,l .梅斯和 s .钦塔拉,深度卷积生成对抗网络的无监督表示学习,2015 年。

Footnotes 1

图片来源:www.cs.toronto.edu/~kriz/cifar.html

  2

图片来源: https://github.com/liuzhuang13/DenseNet

  3

图片来源: https://github.com/liuzhuang13/DenseNet

 

*

十一、循环神经网络

人工智能的强大崛起可能是人类历史上最好的事情,也可能是最坏的事情。

—史蒂芬·霍金

卷积神经网络利用数据的局部相关性和权重共享的思想,大大减少了网络参数的数量。非常适合空间和局部相关的图片。它已经成功地应用于计算机视觉领域的一系列任务中。除了空间维度,自然信号还具有时间维度。具有时间维度的信号非常常见,比如我们正在阅读的文本,我们说话时发出的语音信号,以及随时间变化的股票市场。这类数据不一定具有局部相关性,数据在时间维度上的长度也是可变的。卷积神经网络不擅长处理这类数据。

因此,分析和识别这种类型的信号是将人工智能推向通用人工智能必须解决的任务。本章将要介绍的循环神经网络可以较好地解决这类问题。在介绍循环神经网络之前,我们先介绍一下按时间顺序表示数据的方法。

11.1 序列表示方法

有顺序的数据一般称为序列,比如随时间变化的商品价格数据就是非常典型的序列。考虑到某商品 A 在 1 月至 6 月间的价格变化趋势,我们可以将其记录为一维向量:[ x 1x 2x 3x 4x 5x 6 、6 如果想表示 b 商品 1-6 月的价格变化趋势,可以记录为 2 维张量:

\left[\left[{x}_1^{(1)},{x}_2^{(1)},\cdots, {x}_6^{(1)}\right],\left[{x}_1^{(2)},{x}_2^{(2)},\cdots, {x}_6^{(2)}\right],\cdots, \left[{x}_1^{(b)},{x}_2^{(b)},\cdots, {x}_6^{(b)}\right]\right]

其中 b 代表商品的数量,张量形状为[ b ,6]。

这样,序列信号就不难表示了,只需要一个形状为[b,s]的张量,其中 b 是序列的个数,s 是序列的长度。然而,许多信号不能直接用标量值来表示。例如,为了表示由每个时间戳生成的长度为 n 的特征向量,需要形状为[b,s,n]的张量。考虑更复杂的文本数据:句子。每个时间戳上生成的字是一个字符,而不是一个数值,因此不能用标量直接表示。我们已经知道,神经网络本质上是一系列数学运算,如矩阵乘法和加法。它们不能直接处理字符串数据。如果希望神经网络用于自然语言处理任务,那么如何将单词或字符转换成数值就变得尤为关键。接下来,我们主要讨论文本序列的表示方法。其他非数字信号请参考文本序列的表示方法。

对于包含 n 个单词的句子,表示单词的一种简单方法是我们前面介绍的一键编码方法。以英语句子为例;假设只考虑最常用的 10000 个单词,那么每个单词都可以表示为一个位置为 1,其他位置为 0,长度为 10000 的稀疏一热向量。如图 11-1 所示,如果只考虑 n 个位置名称,那么每个位置名称可以编码为一个长度为 n 的独热向量。

img/515226_1_En_11_Fig1_HTML.png

图 11-1

位置名称的一键编码

我们把把文本编码成数字的过程称为单词嵌入。一键编码实现单词嵌入简单直观,编码过程不需要学习和训练。而一热编码向量是高维的,极其稀疏,大量位置为 0。因此,它在计算上是昂贵的,并且也不利于神经网络训练。从语义的角度来看,一键编码有一个严重的问题。它忽略了单词固有的语义相关性。例如,对于单词“喜欢”、“不喜欢”、“罗马”、“巴黎”、“喜欢”和“不喜欢”,从语义的角度来看是强烈相关的。两者都表示喜欢的程度。“罗马”和“巴黎”也密切相关。它们都显示了欧洲的两个地点。对于一组这样的词,如果采用一热编码,得到的向量之间没有相关性,不能很好的体现原文的语义相关性。因此,一键编码有明显的缺点。

在自然语言处理领域,有一个关于词向量的专门研究领域,通过词向量可以很好地反映语义的相关程度。衡量词向量之间相关性的一种方法是余弦相似度:

similarity\left(a,b\right)\triangleq coscos\ \left(\theta \right)=\frac{a\cdotp b}{\mid a\mid \bullet \mid b\mid }

其中 ab 代表两个字向量。图 11-2 显示了单词“法兰西”和“意大利”之间的相似性,以及单词“球”和“鳄鱼”之间的相似性,并且 θ 是两个单词向量之间的角度。可见 coscos ( θ )更好的体现了语义相关性。

img/515226_1_En_11_Fig2_HTML.jpg

图 11-2

余弦相似图

嵌入层

在神经网络中,可以通过训练直接获得单词的表示向量。我们把单词的表示层叫做嵌入层。嵌入层负责将单词编码成单词向量 v 。它接受使用数字编码的单词数 i ,比如 2 代表“我”,3 代表“我”。系统的总字数记录为 N vocab ,输出为长度为 n :

v={f}_{\theta}\left(i|{N}_{vocab},n\right)

的向量 v

嵌入层实现起来非常简单。用 shape[Nvocabn ]构建查找表。对于任意字数 i ,只需要查询相应位置的向量并返回:

v= table\left[i\right]

嵌入层是可训练的。可以放在神经网络的前面,完成单词到向量的转换。得到的表征向量可以继续通过神经网络完成后续任务,计算误差 L 。采用梯度下降算法实现端到端的训练。

在 TensorFlow 中,一个单词嵌入层可以由层来定义。嵌入( N vocabn ),其中Nvocab参数指定单词的个数, n 指定单词向量的长度。例如:

x = tf.range(10) # Generate a digital code of 10 words
x = tf.random.shuffle(x) # Shuffle
# Create a layer with a total of 10 words, each word is represented by a vector of length 4
net = layers.Embedding(10, 4)
out = net(x) # Get word vector

前面的代码创建了一个包含十个单词的嵌入层。每个单词由长度为 4 的向量表示。您可以传入一个数字代码为 0–9 的输入,以获得这四个单词的单词向量。这些字向量是随机初始化的,没有经过训练,例如:

<tf.Tensor: id=96, shape=(10, 4), dtype=float32, numpy=
array([[-0.00998075, -0.04006485,  0.03493755,  0.03328368],
       [-0.04139598, -0.02630153, -0.01353856,  0.02804044],…

我们可以直接查看嵌入层内部的查询表:

In [1]: net.embeddings
Out[1]:
<tf.Variable 'embedding_4/embeddings:0' shape=(10, 4) dtype=float32, numpy=
array([[ 0.04112223,  0.01824595, -0.01841902,  0.00482471],
       [-0.00428962, -0.03172196, -0.04929272,  0.04603403],…

net.embeddings 张量的可优化属性是真实的,这意味着它可以通过梯度下降算法来优化。

In [2]: net.embeddings.trainable
Out[2]:True

预先训练的单词向量

嵌入层的查找表是随机初始化的,需要从头开始训练。事实上,我们可以使用预先训练的单词嵌入模型来获得单词表示。基于预训练模型的词向量相当于传递了整个语义空间的知识,往往可以获得更好的性能。

目前广泛使用的预训练模型有 Word2Vec 和 GloVe。他们已经在大规模语料库上接受了训练,以获得更好的词向量表示,并可以直接导出学习到的词向量表,以便于迁移到其他任务。比如手套型号 GloVe.6B.50d 的词汇量为 40 万,每个单词用一个长度为 50 的向量表示。用户只需下载相应的模型文件即可使用。“glove6b50dtxt.zip”型号文件约 69MB。

那么如何使用这些预先训练好的词向量模型来帮助提高 NLP 任务的性能呢?很简单。对于嵌入层,不再使用随机初始化。相反,我们使用预先训练的模型参数来初始化嵌入层的查询表。例如:

# Load the word vector table from the pre-trained model
embed_glove = load_embed('glove.6B.50d.txt')
# Initialize the Embedding layer directly using the pre-trained word vector table
net.set_weights([embed_glove])

预训练的词向量模型初始化的嵌入层可以设置为不参与训练:net.trainable = False,那么预训练的词向量直接应用于这个特定的任务。如果您还想从预训练的单词向量模型中学习不同的表示,则可以通过设置 net.trainable = True 将嵌入层包括在反向传播算法中,然后可以使用梯度下降来微调单词表示。

11.2 循环神经网络

现在让我们考虑如何处理序列信号。以一段文字序列为例,考虑一句话:

“我讨厌这部无聊的电影”

通过嵌入层,可以转换成一个具有形状的张量[ bsn ],其中 b 是句子的数量,s 是句子的长度,n 是词向量的长度。前面的句子可以表示为形状为[1,5,10]的张量,其中 5 表示句子单词的长度,10 表示单词向量的长度。

接下来,我们将逐步探索一种可以处理序列信号的网络模型。我们以情感分类任务为例,如图 11-3 所示。情感分类任务提取由文本数据表达的整体语义特征,并由此预测输入文本的情感类型:积极或消极。从分类的角度来看,情感分类是一个简单的二分类问题。与图像分类不同,由于输入是文本序列,传统的卷积神经网络无法达到很好的效果。那么什么类型的网络擅长处理序列数据呢?

img/515226_1_En_11_Fig3_HTML.png

图 11-3

情感分类任务

11.2.1 全连接层是否可行?

我们首先想到的是,对于每一个词向量,都可以用一个全连通的层网络。

o=\sigma \left({W}_t{x}_t+{b}_t\right)

提取语义特征,如图 11-4 所示。通过 s 个全连接层分类网络 1 提取每个单词的单词向量。最后融合所有单词的特征,通过分类网络 2 输出序列的类别概率分布。对于长度为 s 的句子,至少需要 s 个全连接的网络层。

img/515226_1_En_11_Fig4_HTML.png

图 11-4

网络架构 1

这种方案的缺点是:

  • 网络参数数量可观,内存占用和计算成本较高。同时,由于每个序列的长度 s 不相同,网络结构是动态变化的。

  • 各全连通层子网WI和 b i 只能感知当前词向量的输入,无法感知前后的上下文信息,导致句子整体语义缺失。每个子网络只能根据自己的输入提取高级特征。

我们将逐一解决这两个缺点。

共享重量

在介绍卷积神经网络时,我们已经了解到,卷积神经网络之所以在处理局部相关数据方面优于全连接网络,是因为它充分利用了权重分担的思想,大大减少了网络参数的数量,使得网络训练更加高效。那么,我们在处理序列信号时,是否可以借鉴权重分担的思想呢?

在图 11-4 的方案中,s 个全连通层的网络并没有实现权重分担。我们尝试共享这 s 个网络层参数,实际上相当于用一个全连通的网络来提取所有单词的特征信息,如图 11-5 。

img/515226_1_En_11_Fig5_HTML.png

图 11-5

网络架构 2

权重共享后,参数数量大大减少,网络训练变得更加稳定高效。但是,这种网络结构不考虑序列的顺序,通过打乱单词向量的顺序仍然可以获得相同的输出。因此,它不能获得有效的全局语义信息。

全局语义

如何赋予网络提取整体语义特征的能力?换句话说,网络如何将词向量的语义信息按顺序提取出来,并累积成整个句子的全局语义信息?我们想到了记忆机制。如果网络能够提供单独的记忆变量,每次提取词向量的特征并刷新记忆变量,直到最后一次输入完成,此时的记忆变量存储所有序列的语义特征,由于输入序列的顺序,记忆变量的内容与序列顺序密切相关。

img/515226_1_En_11_Fig6_HTML.png

图 11-6

循环神经网络(没有添加偏差)

我们将前面的记忆机制实现为一个状态张量 h ,如图 11-6 所示。除了原有的 W xh 参数共享之外,这里增加了一个额外的Whh参数。每个时间戳 t 的状态张量 h 刷新机制为:

{h}_t=\sigma \left({W}_{xh}{x}_t+{W}_{hh}{h}_{t-1}+b\right)

其中状态张量 h 0 为初始内存状态,可以初始化为全 0。输入 s 个字向量后,得到网络的最终状态张量 h sh s 更好的代表了句子的全局语义信息。将 h s 通过一个全连通的层分类器就可以完成情感分类任务。

4 循环神经网络

通过一步步的探索,我们最终提出了一个“新”的网络结构,如图 11-7 所示。在每个时间戳 t,网络层接受当前时间戳的输入xt和前一个时间戳的网络状态向量ht—1,之后:

{h}_t={f}_{\theta}\left({h}_{t-1},{x}_t\right)

变换后得到当前时间戳的新状态向量ht并写入内存状态,其中 f θ 代表网络的运行逻辑, θ 为网络参数集。在每一个时间戳,网络层都有一个输出产生 o tot=gϕ(ht),就是输出网络变换后的状态向量。

img/515226_1_En_11_Fig7_HTML.png

图 11-7

扩展的 RNN 模型

前面的网络结构折叠在时间戳上,如图 11-8 所示。网络循环接受序列的每个特征向量xt,刷新内部状态向量 h t ,同时形成输出 o t 。对于这种网络结构,我们称之为循环神经网络(RNN)。

img/515226_1_En_11_Fig8_HTML.png

图 11-8

折叠 RNN 模型

更具体地说,如果我们用张量 W xhW hh 和 bias b 来参数化 f θ 网络,并使用以下方式更新记忆状态,我们称这类网络为基本循环神经网络,除非另有说明;一般来说,循环神经网络指的就是这种实现。

{h}_t=\sigma \left({W}_{xh}{x}_t+{W}_{hh}{h}_{t-1}+b\right)

在循环神经网络中,激活函数更多使用的是 Tanh 函数,我们可以选择不使用 bias b 来进一步减少参数的数量。状态向量ht可以直接作为输出,即ot=ht,或者对 h t 做一个简单的线性变换就可以做到 o t

11.3 梯度传播

通过循环神经网络的更新表达式可以看出,输出可导至张量 W xhW hh 和 bias b ,可以用自动梯度下降算法求解网络的梯度。这里我们简单推导 RNN 的梯度传播公式,并探讨其特性。

考虑梯度\frac{\partial L}{\partial {W}_{hh}},其中 L 为网络的误差,只考虑 t 处最后输出 o * t * 与真值之差。由于 W

其中\frac{\partial L}{\partial {o}_t}可以根据损失函数直接得到,在ot=ht:

\frac{\partial {o}_t}{\partial {h}_t}=I

的情况下

\frac{\partial^{+}{h}_i}{\partial {W}_{hh}}的梯度也可以在展开后得到hI:

\frac{\partial^{+}{h}_i}{\partial {W}_{hh}}=\frac{\partial \sigma \left({W}_{xh}{x}_t+{W}_{hh}{h}_{t-1}+b\right)}{\partial {W}_{hh}}

其中\frac{\partial^{+}{h}_i}{\partial {W}_{hh}}只考虑一个时间戳的梯度传播,即“直接”偏导数,与\frac{\partial L}{\partial {W}_{hh}}考虑所有时间戳的梯度传播不同 i = 1, t

所以我们只需要推导出\frac{\partial {h}_t}{\partial {h}_i}的表达式,就可以完成循环神经网络的梯度推导。利用链式法则,我们把\frac{\partial {h}_t}{\partial {h}_i}分成连续时间戳的梯度表达式:

\frac{\partial {h}_t}{\partial {h}_i}=\frac{\partial {h}_t}{\partial {h}_{t-1}}\frac{\partial {h}_{t-1}}{\partial {h}_{t-2}}\cdots \frac{\partial {h}_{i+1}}{\partial {h}_i}={\prod}_{k=i}^{t-1}\frac{\partial {h}_{k+1}}{\partial {h}_k}

考虑:

{h}_{k+1}=\sigma \left({W}_{xh}{x}_{k+1}+{W}_{hh}{h}_k+b\right)

然后:

\frac{\partial {h}_{k+1}}{\partial {h}_k}={W}_{hh}^T\mathit{\operatorname{diag}}\left({\sigma}^{\prime}\left({W}_{xh}{x}_{k+1}+{W}_{hh}{h}_k+b\right)\right)

={W}_{hh}^T\mathit{\operatorname{diag}}\left({\sigma}^{\prime}\left({h}_{k+1}\right)\right)

其中 diag ( x )将向量x的每个元素作为矩阵的对角元素,得到一个其他元素都为 0 的对角矩阵,例如:

\mathit{\operatorname{diag}}\left(\left[3,2,1\right]\right)=\left[3\ 0\ 0\ 0\ 2\ 0\ 0\ 0\ 1\ \right]

因此,

\frac{\partial {h}_t}{\partial {h}_i}={\prod}_{j=i}^{t-1}\mathit{\operatorname{diag}}\left({\sigma}^{\prime}\left({W}_{xh}{x}_{j+1}+{W}_{hh}{h}_j+b\right)\right){W}_{hh}

至此,\frac{\partial L}{\partial {W}_{hh}}的梯度推导完成。

由于深度学习框架可以帮助我们自动导出梯度,所以我们只需要了解循环神经网络的梯度传播机制。在推导\frac{\partial L}{\partial {W}_{hh}}的过程中,我们发现\frac{\partial {h}_t}{\partial {h}_i}的梯度包含了Whh的连续乘法运算,这是造成循环神经网络训练困难的根本原因。我们以后再讨论。

11.4 如何使用 RNN 图层

在介绍了循环神经网络的原理之后,让我们学习如何在 TensorFlow 中实现 RNN 层。在 TensorFlow 中,σ(Wxhxt+Whhht—1+b)的计算可以分层完成。SimpleRNNCell()函数。需要注意的是,在 TensorFlow 中,RNN 代表一般意义上的循环神经网络。对于我们目前介绍的基本循环神经网络,一般称为 SimpleRNN。SimpleRNN 和 SimpleRNNCell 的区别在于,有 Cell 的层只完成一个时间戳的转发操作,而没有 cell 的层一般是基于 cell 层实现的,cell 层内部已经完成了多个时间戳循环。所以使用起来更加方便快捷。

我们先介绍 SimpleRNNCell 的使用,再介绍 SimpleRNN 层的使用。

简单电池

以某个输入特征长度 n=4,细胞状态向量特征长度 h=3 为例。首先,我们创建一个 SimpleRNNCell,不指定序列长度 s。代码如下:

In [3]:
cell = layers.SimpleRNNCell(3) # Create RNN Cell, memory vector length is 3
cell.build(input_shape=(None,4)) # Output feature length n=4
cell.trainable_variables # Print wxh, whh, b tensor
Out[3]:
[<tf.Variable 'kernel:0' shape=(4, 3) dtype=float32, numpy=...>,
 <tf.Variable 'recurrent_kernel:0' shape=(3, 3) dtype=float32, numpy=...>,
 <tf.Variable 'bias:0' shape=(3,) dtype=float32, numpy=array([0., 0., 0.], dtype=float32)>]

可以看出 SimpleRNNCell 内部维护了三个张量,核变量是张量 W xh ,recurrent_kernel 变量是张量 W hh ,偏置变量是偏置向量 b 。但是 RNN 的内存向量 h 不是由 SimpleRNNCell 维护的,用户需要初始化向量 h 0 并在每个时间戳上记录 h t

通过调用单元格实例:

{o}_t,\left[{h}_t\right]= Cell\left({x}_t,\left[{h}_{t-1}\right]\right)

可以完成正向操作

对于 SimpleRNNCell,ot=ht,是同一个对象。没有额外的线性层转换。[ht]被包裹在一个列表中。此设置是为了与 RNN 变量(如 LSTM 和格鲁)保持一致。在循环神经网络的初始化阶段,状态向量 h 0 通常被初始化为全零向量,例如:

In [4]:
# Initialize state vector. Wrap with list, unified format
h0 = [tf.zeros([4, 64])]
x = tf.random.normal([4, 80, 100]) # Generate input tensor, 4 sentences of 80 words
xt = x[:,0,:] # The first word of all sentences
# Construct a Cell with input feature n=100, sequence length s=80, state length=64
cell = layers.SimpleRNNCell(64)
out, h1 = cell(xt, h0) # Forward calculation
print(out.shape, h1[0].shape)
Out[4]: (4, 64) (4, 64)

可以看出,经过一次时间戳计算,输出的形状和状态张量都是[b,h],两者的 id 打印如下:

In [5]:print(id(out), id(h1[0]))
Out[5]:2154936585256 2154936585256

两个 id 是一样的,就是直接用状态向量作为输出向量。对于长度为 s 的训练,需要遍历信元类 s 次,才能完成网络层的一次正向操作。例如:

h = h0 # Save a list of state vectors on each time stamp
# Unpack the input in the dimension of the sequence length to get xt:[b,n]
for xt in tf.unstack(x, axis=1):
    out, h = cell(xt, h) # Forward calculation, both out and h are covered
# The final output can aggregate the output on each time stamp, or just take the output of the last time stamp
out = out

最后时间戳之外的输出变量将是网络的最终输出。实际上,你也可以在每个时间戳上保存输出,然后求和或平均,作为网络的最终输出。

11.4.2 多层简单网络

和卷积神经网络一样,循环神经网络虽然在时间轴上扩展了很多倍,但也只能算作一个网络层。通过在深度方向堆叠多个细胞类,网络可以达到与深度卷积神经网络相同的效果,大大提高了网络的表达能力。但是,相比于数十或数百个卷积神经网络的深层层数,循环神经网络容易出现梯度扩散和梯度爆炸。深度循环神经网络非常难以训练。目前常见的循环神经网络模型的层数一般小于 10 层。

这里我们以一个两层循环神经网络为例来介绍使用细胞类来构建一个多层 RNN 网络。首先创建两个 SimpleRNNCell 单元格,如下所示:

x = tf.random.normal([4,80,100])
xt = x[:,0,:] # Take first timestamp of the input x0
# Construct 2 Cells, first cell0, then cell1, the memory state vector length is 64
cell0 = layers.SimpleRNNCell(64)
cell1 = layers.SimpleRNNCell(64)
h0 = [tf.zeros([4,64])] # initial state vector of cell0
h1 = [tf.zeros([4,64])] # initial state vector of cell1

在时间轴上多次计算,实现整个网络的正向运行。每个时间戳上的输入 xt 先经过第一层得到输出 out0,再经过第二层得到输出 out1。代码如下:

for xt in tf.unstack(x, axis=1):
    # xt is input and output is out0
    out0, h0 = cell0(xt, h0)
    # The output out0 of the previous cell is used as the input of this cell
    out1, h1 = cell1(out0, h1)

上述方法首先在所有图层上完成一个时间戳的输入传播,然后在一个循环中计算所有时间戳的输入。

其实也可以先完成第一层输入的所有时间戳的计算,并保存第一层在所有时间戳上的输出列表,再计算第二层、第三层等的传播。如下所示:

# Save the output above all timestamps of the previous layer
middle_sequences = []
# Calculate the output on all timestamps of the first layer and save
for xt in tf.unstack(x, axis=1):
    out0, h0 = cell0(xt, h0)
    middle_sequences.append(out0)
# Calculate the output on all timestamps of the second layer
# If it is not the last layer, you need to save the output above all timestamps
for xt in middle_sequences:
    out1, h1 = cell1(xt, h1)

这样我们就需要一个额外的列表来保存上一层所有时间戳的信息:middle_sequences.append(out0)。这两种方法效果相同,可以选择自己喜欢的编码风格。

应该注意的是,在每个时间戳,循环神经网络的每一层都有一个状态输出。对于后续任务,我们应该收集哪种状态输出最有效?一般来说,末级单元的状态可能保留了高层的全局语义特征,所以一般将末级的输出作为后续任务网络的输入。更具体地说,每一层的最后时间戳上的状态输出包含整个序列的全局信息。如果只想用一个状态变量来完成后续任务,比如情感分类问题,一般最后一层在最后一个时间戳的输出是最合适的。

11.4.3 SimpleRNN 图层

通过使用 SimpleRNNCell 层,我们可以了解循环神经网络正向操作的每个细节。在实际使用中,为了简单起见,我们不希望手动实现循环神经网络的内部计算过程,比如各层状态向量的初始化以及时间轴上各层的运算。使用 SimpleRNN 高级接口可以帮助我们非常方便地实现这个目标。

例如,如果我们想完成一个单层循环神经网络的正向运算,可以很容易地实现如下:

In [6]:
layer = layers.SimpleRNN(64) # Create a SimpleRNN layer with a state vector length of 64
x = tf.random.normal([4, 80, 100])
out = layer(x) # Like regular convolutional networks, one line of code can get the output
out.shape
Out[6]: TensorShape([4, 64])

可以看到,SimpleRNN 只用一行代码就可以完成整个正向操作过程,默认情况下返回最后一个时间戳的输出。如果您想要返回所有时间戳的输出列表,您可以如下设置 return_sequences=True:

In [7]:
# When creating the RNN layer, set the output to return all timestamps
layer = layers.SimpleRNN(64,return_sequences=True)
out = layer(x) # Forward calculation
out # Output, automatic concat operation
Out[7]:
<tf.Tensor: id=12654, shape=(4, 80, 64), dtype=float32, numpy=
array([[[ 0.31804922,  0.7904409 ,  0.13204293, ...,  0.02601025,
         -0.7833339 ,  0.65577114],...>

可以看到,返回的输出张量形状是[4,80,64],中间的维度 80 是时间戳维度。同样,我们可以通过堆叠多个 SimpleRNNs 来实现多层循环神经网络,例如两层网络,其用法类似于普通网络。例如:

net = keras.Sequential([ # Build a 2-layer RNN network
# Except for the last layer, the output of all timestamps needs to be returned to be used as the input of the next layer
layers.SimpleRNN(64, return_sequences=True),
layers.SimpleRNN(64),
])
out = net(x) # Forward calculation

每一层都需要前一层在每个时间戳的状态输出,所以除了最后一层,所有 RNN 层都需要返回每个时间戳的状态输出,这是通过设置 return_sequences=True 来实现的。如您所见,使用 SimpleRNN 层类似于卷积神经网络的用法,非常简洁高效。

11.5 RNN 情感分类实践

现在让我们使用基本的 RNN 网络来解决情感分类问题。网络结构如图 11-9 所示。RNN 网络有两层。循环提取序列信号的语义特征。第二 RNN 层的最后时间戳的状态向量{h}_s^{(2)}被用作句子的全局语义特征表示。送到全连通层构成的分类网络 3,得到样本 x 是正面情绪 P 的概率(x 是正面情绪│x) ∈[0,1]。

img/515226_1_En_11_Fig9_HTML.png

图 11-9

情感分类任务的网络结构

数据集

这里使用经典的 IMDB 电影评论数据集来完成情感分类任务。IMDB 电影评论数据集包含 50,000 条用户评论。评估标签分为负面和正面。IMDB 评分< 5 的用户评论标记为 0,表示负面;IMDB 评分≥7 的用户评论标为 1,表示正面。25,000 条电影评论用于训练集,25,000 条用于测试集。

可以通过 Keras 提供的数据集工具加载 IMDB 数据集,如下所示:

In [8]:
batchsz = 128 # Batch size
total_words = 10000 # Vocabulary size N_vocab
max_review_len = 80 # The maximum length of the sentence s, the sentence part greater than will be truncated, and the sentence less than will be filled
embedding_len = 100 # Word vector feature length n
# Load the IMDB data set, the data here is coded with numbers, and a number represents a word
(x_train, y_train), (x_test, y_test) = keras.datasets.imdb.load_data(num_words=total_words)
# Print the input shape, the shape of the label
print(x_train.shape, len(x_train[0]), y_train.shape)
print(x_test.shape, len(x_test[0]), y_test.shape)
Out[8]:
(25000,) 218 (25000,)
(25000,) 68 (25000,)

可以看到,x_train 和 x_test 是一维数组,长度为 25000。数组中的每个元素都是一个长度不定的列表,其中存储了用数字编码的每个句子。例如,训练集的第一句共有 218 个单词,测试集的第一句有 68 个单词,每个句子都包含句子开始标记 ID。

那么每个单词是如何编码成数字的呢?我们可以通过查看其编码表来获得编码方案,例如:

In [9]:
# Digital code table
word_index = keras.datasets.imdb.get_word_index()
# Print out the words and corresponding numbers in the coding table
for k,v in word_index.items():
   print(k,v)
Out[10]:
   ...diamiter 88301
   moveis 88302
   mardi 14352
   wells' 11583
   850pm 88303...

由于编码表的关键字是一个字,值是一个 ID,所以编码表被翻转,并加上标志位的编码 ID。代码如下:

# The first 4 IDs are special bits
word_index = {k:(v+3) for k,v in word_index.items()}
word_index["<PAD>"] = 0  # Fill flag
word_index["<START>"] = 1 # Start flag
word_index["<UNK>"] = 2  # Unknown word sign
word_index["<UNUSED>"] = 3
# Flip code table
reverse_word_index = dict([(value, key) for (key, value) in word_index.items()])

对于数字编码的句子,通过以下函数将其转换为字符串数据:

def decode_review(text):
    return ' '.join([reverse_word_index.get(i, '?') for i in text])

例如,要转换一个句子,代码如下:

In [11]:decode_review(x_train[0])
Out[11]:
"<START> this film was just brilliant casting location scenery story direction everyone's...<UNK> father came from...

对于长短不齐的句子,人为设置一个阈值。对于大于这个长度的句子,选择一些要截断的单词,可以选择截掉句首或者句尾。对于小于此长度的句子,可以选择在句首或句尾填充。句子截断功能可以通过 keras . preprocessing . sequence . pad _ sequences()函数方便地实现,例如:

# Truncate and fill sentences so that they are of equal length, here long sentences retain the part behind the sentence, and short sentences are filled in front
x_train = keras.preprocessing.sequence.pad_sequences(x_train, maxlen=max_review_len)
x_test = keras.preprocessing.sequence.pad_sequences(x_test, maxlen=max_review_len)

截断或填充到相同长度后,通过 dataset 类将其包装成 Dataset 对象,并添加常用的数据集处理流程,代码如下:

In [12]:
# Build a data set, break up, batch, and discard the last batch that is not enough batchsz
db_train = tf.data.Dataset.from_tensor_slices((x_train, y_train))
db_train = db_train.shuffle(1000).batch(batchsz, drop_remainder=True)
db_test = tf.data.Dataset.from_tensor_slices((x_test, y_test))
db_test = db_test.batch(batchsz, drop_remainder=True)
# Statistical data set attributes
print('x_train shape:', x_train.shape, tf.reduce_max(y_train), tf.reduce_min(y_train))
print('x_test shape:', x_test.shape)
Out[12]:
x_train shape: (25000, 80) tf.Tensor(1, shape=(), dtype=int64) tf.Tensor(0, shape=(), dtype=int64)
x_test shape: (25000, 80)

可以看出,截断填充后的句子长度统一为 80,这是设定的句子长度阈值。drop_remainder=True 参数丢弃最后一个批次,因为它的实际批次大小可能小于预设的批次大小。

网络模型

我们创建一个自定义模型类 MyRNN,继承自模型基类,我们需要创建一个新的嵌入层、两个 RNN 层和一个分类层,如下所示:

class MyRNN(keras.Model):
    # Use Cell method to build a multi-layer network
    def __init__(self, units):
        super(MyRNN, self).__init__()
        # [b, 64], construct Cell initialization state vector, reuse
        self.state0 = [tf.zeros([batchsz, units])]
        self.state1 = [tf.zeros([batchsz, units])]
        # Word vector encoding [b, 80] => [b, 80, 100]
        self.embedding = layers.Embedding(total_words, embedding_len,
                                          input_length=max_review_len)
        # Construct 2 Cells and use dropout technology to prevent overfitting
        self.rnn_cell0 = layers.SimpleRNNCell(units, dropout=0.5)
        self.rnn_cell1 = layers.SimpleRNNCell(units, dropout=0.5)
        # Construct a classification network to classify the output features of CELL, 2 classification
        # [b, 80, 100] => [b, 64] => [b, 1]
        self.outlayer = layers.Dense(1)

单词向量被编码为长度 n=100,RNN 的状态向量长度是 h =个单位。分类网络完成一个二元分类任务,所以输出节点设置为 1。

正向传播逻辑如下:输入序列通过嵌入层完成词向量编码,循环通过两个 RNN 层提取语义特征,取最后一层最后一个时间戳的状态向量输出,送入分类网络。输出概率在 Sigmoid 激活函数之后获得,如下所示:

    def call(self, inputs, training=None):
        x = inputs # [b, 80]
        # Word vector embedding: [b, 80] => [b, 80, 100]
        x = self.embedding(x)
        # Pass 2 RNN CELLs,[b, 80, 100] => [b, 64]
        state0 = self.state0
        state1 = self.state1
        for word in tf.unstack(x, axis=1): # word: [b, 100]
            out0, state0 = self.rnn_cell0(word, state0, training)
            out1, state1 = self.rnn_cell1(out0, state1, training)
        # Last layer's last time stamp as the network output: [b, 64] => [b, 1]
        x = self.outlayer(out1, training)
        # Pass through activation function, p(y is pos|x)
        prob = tf.sigmoid(x)

        return prob

培训和测试

为简单起见,这里我们使用 Keras 的 Compile&Fit 方法来训练网络。设置优化器为 Adam optimizer,学习率为 0.001,误差函数使用二类交叉熵损失函数 BinaryCrossentropy,测试度量使用准确率。代码如下:

def main():
    units = 64 # RNN state vector length n
    epochs = 20 # Training epochs

    model = MyRNN(units) # Create the model
    # Compile
    model.compile(optimizer = optimizers.Adam(0.001),
                  loss = losses.BinaryCrossentropy(),
                  metrics=['accuracy'])
    # Fit and validate
    model.fit(db_train, epochs=epochs, validation_data=db_test)
    # Test
    model.evaluate(db_test)

经过 20 次历元训练,网络在测试数据集上达到了 80.1%的准确率。

11.6 渐变消失和渐变爆炸

循环神经网络的训练并不稳定,网络的深度不能任意加深。为什么循环神经网络训练困难?我们来简单回顾一下梯度求导中的关键表达式:

\frac{\partial {h}_t}{\partial {h}_i}={\prod}_{j=i}^{t-1}\mathit{\operatorname{diag}}\left({\sigma}^{\prime}\left({W}_{xh}{x}_{j+1}+{W}_{hh}{h}_j+b\right)\right){W}_{hh}

换句话说,从时间戳 i 到时间戳 t 的渐变\frac{\partial {h}_t}{\partial {h}_i}包含了 W * hh 的连续乘法运算。当W*hh的最大特征值小于 1 时,多次连续的乘法运算会使\frac{\partial {h}_t}{\partial {h}_i}的元素值接近于零;当\frac{\partial {h}_t}{\partial {h}_i}的值大于 1 时,多次连续的乘法运算会使\frac{\partial {h}_t}{\partial {h}_i}的值爆炸式增加。

我们可以从下面两个例子直观感受到渐变消失和渐变爆炸的产生:

In [13]:
W = tf.ones([2,2]) # Create a matrix
eigenvalues = tf.linalg.eigh(W)[0] # Calculate eigenvalue
eigenvalues
Out[13]:
<tf.Tensor: id=923, shape=(2,), dtype=float32, numpy=array([0., 2.], dtype=float32)>

可以看出全 1 矩阵的最大特征值是 2。计算 W 矩阵的W1~W10并绘制成矩阵的幂和 L2 范数的图形,如图 11-10 所示。可以看出,当 W 矩阵的最大特征值大于 1 时,矩阵相乘会使结果越来越大。

img/515226_1_En_11_Fig10_HTML.jpg

图 11-10

最大特征值大于 1 时的矩阵乘法

val = [W]
for i in range(10): # Matrix multiplication n times
    val.append([val[-1]@W])
# Calculate L2 norm
norm = list(map(lambda x:tf.norm(x).numpy(),val))

考虑最大特征值小于 1 的情况。

In [14]:
W = tf.ones([2,2])*0.4 # Create a matrix
eigenvalues = tf.linalg.eigh(W)[0] # Calculate eigenvalues
print(eigenvalues)
Out[14]:
tf.Tensor([0\.  0.8], shape=(2,), dtype=float32)

可以看出此时 W 矩阵的最大特征值为 0.8。同样,考虑矩阵 W 的多次乘法的结果如下:

val = [W]
for i in range(10):
    val.append([val[-1]@W])
# Calculate the L2 norm
norm = list(map(lambda x:tf.norm(x).numpy(),val))
plt.plot(range(1,12),norm)

其 L2-诺姆曲线如图 11-11 所示。可以看出,当 W 矩阵的最大特征值小于 1 时,矩阵相乘会使结果越来越小,接近于 0。

img/515226_1_En_11_Fig11_HTML.jpg

图 11-11

最大特征值小于 1 时的矩阵乘法

我们把梯度值接近 0 的现象叫做梯度消失,把梯度值远大于 1 的现象叫做梯度爆炸。有关梯度传播机制的详细信息可在第七章中找到。梯度消失和梯度爆炸是神经网络优化过程中出现的两种情况,也不利于网络训练。

考虑梯度下降算法:

{\theta}^{\prime }=\theta -\eta {\nabla}_{\theta }L

当出现梯度消失时,∇ θ L ≈ 0,此时θθ表示每次梯度更新后参数保持不变,神经网络的参数长时间不能更新。具体表现为 L 几乎没有变化,其他评价指标如准确度也保持不变。当梯度发生爆炸时,∇θl≫1、梯度ηθl的更新步长很大,以至于更新后的θθ 相差很大,网络 L

通过推导循环神经网络的梯度传播公式,我们发现循环神经网络容易出现梯度消失和梯度爆炸。那么如何解决这两个问题呢?

渐变剪辑

梯度爆炸可以通过梯度裁剪得到一定程度的解决。梯度裁剪非常类似于张量限制。它还将梯度张量的值或范数限制在一个很小的区间内,从而减少远大于 1 的梯度值,避免梯度爆炸。

在深度学习中,常用的梯度裁剪方法有三种。

  • 直接限制张量的值,使张量的所有元素 W 都是Wijminmax 。在 TensorFlow 中,可以通过 tf.clip_by_value()函数来实现。例如:

  • Limit the norm of the gradient tensor W. For example, the L2 norm of W – ‖W2 is constrained between [0,max]. If ‖W2 is greater than the max value, use:

    {W}^{\prime }=\frac{W}{{\left\Vert W\right\Vert}_2}\bullet \mathit{\max}

In [15]:
a=tf.random.uniform([2,2])
tf.clip_by_value(a,0.4,0.6) # Gradient value clipping
Out[15]:
<tf.Tensor: id=1262, shape=(2, 2), dtype=float32, numpy=
array([[0.5410726, 0.6      ],
       [0.4      , 0.6      ]], dtype=float32)>

W2限制到最大。这可以通过 tf.clip_by_norm 函数来实现。例如:

In [16]:
a=tf.random.uniform([2,2]) * 5
# Clip by norm
b = tf.clip_by_norm(a, 5)
# Norm before and after clipping

tf.norm(a),tf.norm(b)
Out[16]:
(<tf.Tensor: id=1338, shape=(), dtype=float32, numpy=5.380655>,
 <tf.Tensor: id=1343, shape=(), dtype=float32, numpy=5.0>)

可以看出,对于 L2 范数大于 max 的张量,限幅后范数值减少到 5。

  • 神经网络的更新方向由所有参数的梯度张量 W 表示。前两种方法仅考虑单一梯度张量,因此网络的更新方向可能改变。如果能够考虑到所有参数的梯度 W 的范数,并且能够做到等尺度,那么就可以很好地限制网络的梯度值,而不改变网络的更新方向。这是渐变裁剪的第三种方法:全局范数裁剪。在 TensorFlow 中,整体网络梯度的范数 W 可以通过 tf.clip_by_global_norm 函数快速缩放。

W ( i ) 表示网络参数的第 i 个梯度张量。使用以下公式计算网络的全局范数。

global\_\mathit{\operatorname{norm}}=\sqrt{\sum_i\left\Vert {W}^{(i)}\right\Vert {{}_2}²}

对于第 i -th 参数 W ( i ) ,用下面的公式进行裁剪。

{W}^{(i)}=\frac{W^{(i)}\bullet \mathit{\max}\_\mathit{\operatorname{norm}}}{\mathit{\max}\left( global\_\mathit{\operatorname{norm}},\mathit{\max}\_\mathit{\operatorname{norm}}\right)}

其中 max_norm 是用户指定的全局最大范数值。例如:

In [17]:
w1=tf.random.normal([3,3]) # Create gradient tensor 1
w2=tf.random.normal([3,3]) # Create gradient tensor 2
# Calculate global norm
global_norm=tf.math.sqrt(tf.norm(w1)**2+tf.norm(w2)**2)
# Clip by global norm and max norm=2
(ww1,ww2),global_norm=tf.clip_by_global_norm([w1,w2],2)
# Calcualte global norm after clipping
global_norm2 = tf.math.sqrt(tf.norm(ww1)**2+tf.norm(ww2)**2)
# Print the global norm before cropping and the global norm after cropping
print(global_norm, global_norm2)
Out[17]:
tf.Tensor(4.1547523, shape=(), dtype=float32)
tf.Tensor(2.0, shape=(), dtype=float32)

可以看出,经过裁剪后,网络参数的梯度组的全局范数减少到 max_norm=2。需要注意的是,tf.clip_by_global_norm 返回裁剪后张量的两个对象——list 和 global_norm,其中 global_norm 表示裁剪前梯度的全局范数和。

通过梯度裁剪,可以抑制梯度爆炸现象。如图 11-12 所示,图中曲面表示的 J ( wb )函数在不同网络参数 wb 下的误差值 J 。存在一个 J ( wb )函数梯度变化较大的区域。当参数进入这个区域时,容易出现梯度爆炸,使网络状态迅速恶化。右图 11-12 显示了添加渐变裁剪后的优化轨迹。由于梯度被有效地限制,每次更新的步长被有效地控制,从而防止网络突然恶化。

img/515226_1_En_11_Fig12_HTML.jpg

图 11-12

渐变裁剪的优化轨迹图[1]

在网络训练期间,通常在计算梯度之后和更新梯度之前执行梯度裁剪。例如:

with tf.GradientTape() as tape:
  logits = model(x) # Forward calculation
  loss = criteon(y, logits) # Calculate error
# Calcualte gradients
grads = tape.gradient(loss, model.trainable_variables)
grads, _ = tf.clip_by_global_norm(grads, 25) # Global norm clipping
# Update parameters using clipped gradient
optimizer.apply_gradients(zip(grads, model.trainable_variables))

渐变消失

梯度消失现象可以通过提高学习速率、减小网络深度、增加跳连接等一系列措施来抑制。

增加学习速率 η 可以在一定程度上防止梯度消失。当梯度消失时,网络∇ θ L 的梯度接近于 0。此时,如果学习率 η 也很小,比如η= 1e5,则梯度更新步长更小。通过提高学习速率,比如让η= 1e2,可以快速更新网络状态,逃离梯度消失区。

对于深度神经网络,梯度从最后一层逐渐传播到第一层,梯度消失一般更容易出现在网络的前几层。在深度残差网络出现之前,训练几十层或者几百层的深度网络是非常困难的。网络前几层的梯度非常容易出现梯度消失,使得网络参数长时间不更新。深度残差网络较好地克服了梯度消失现象,使神经网络层数可达数百或数千。一般来说,降低网络深度可以减少梯度消失现象,但网络层数减少后,网络表达能力会更弱。

11.7 RNN 短期记忆

除了循环神经网络的训练难度,还有一个更严重的问题,就是短时记忆。考虑一个长句子:

今天的天气真好,尽管路上发生了一件不愉快的事情...,我马上调整好状态,开心地准备迎接美好的一天。

按照我们的理解,我们之所以“高高兴兴地准备迎接美好的一天”,是因为句首提到的“今天的天气真美”。可见,人类可以很好地理解长句,但循环神经网络不是必须的。研究人员发现,循环神经网络在处理长句时,只能理解有限长度内的信息,而更大范围内的有用信息却不能很好地利用。我们称这种现象为短期记忆。

那么,这种短时记忆是否可以延长,以便循环神经网络可以在更长的范围内有效地使用训练数据,从而提高模型性能?1997 年,瑞士人工智能科学家 Jürgen Schmidhuber 提出了长短期记忆(LSTM)模型。与基本的 RNN 网络相比,LSTM 拥有更长的内存,更擅长处理更长的序列数据。LSTM 被提出后,已经广泛应用于序列预测、自然语言处理等任务,几乎取代了基本的 RNN 模型。

接下来,我们将介绍更受欢迎和强大的 LSTM 网络。

11.8 LSTM 原则

RNN 的基本网络结构如图 11-13 所示。前一个时间戳的状态向量ht-1与当前时间戳的输入 x t 进行线性变换后,通过激活函数 tanh 得到新的状态向量 h t 。与只有一个状态向量 h t 的基本 RNN 网络相比,LSTM 增加了一个新的状态向量 C t ,同时引入了门控机制,通过门控单元控制信息的遗忘和更新,如图 11-14 所示。

img/515226_1_En_11_Fig14_HTML.png

图 11-14

LSM 结构

img/515226_1_En_11_Fig13_HTML.png

图 11-13

基本 RNN 结构

在 LSTM 中,有两个状态向量 ch ,其中 c 是 LSTM 的内部状态向量,可以理解为 LSTM 的内存状态向量, h 代表 LSTM 的输出向量。与基本的 RNN 相比,LSTM 将内部存储器和输出分成两个变量,并使用三个门,输入门、遗忘门和输出门,来控制内部信息流。

闸门机制可以理解为控制数据流的一种方式,类似于水阀:当水阀全开时,水流畅通无阻;当水阀完全关闭时,水流被完全阻断。在 LSTM,阀门开度由闸门控制值向量 g 表示,如图 11-15 所示,通过 σ ( g 激活函数,闸门控制被压缩到[0,1]之间的区间。当 σ ( g ) = 0 时,所有门关闭,输出为 o = 0。当 σ ( g ) = 1 时,所有门打开,输出为 o = x 。通过 gate 机制,可以更好地控制数据流。

img/515226_1_En_11_Fig15_HTML.png

图 11-15

闸门机制

下面,我们分别介绍这三种门的原理和功能。

忘记入口

遗忘门作用于 LSTM 状态向量 c 来控制前一时间戳的存储器ct—1对当前时间戳的影响。如图 11-16 所示,遗忘门的控制变量 g f

{g}_f=\sigma \left({W}_f\left[{h}_{t-1},{x}_t\right]+{b}_f\right)

决定

其中 W fb f 为遗忘门的参数张量,可以通过反向传播算法自动优化。 σ 为激活函数,一般使用 Sigmoid 函数。当gf= 1 时,遗忘门全部打开,LSTM 接受前一状态ct—1的所有信息。当门控gf= 0 时,忘记门关闭,LSTM 直接忽略ct—1,输出为 0 的向量。这就是它被称为遗忘之门的原因。

通过遗忘门后,LSTM 的状态向量变成了gfct—1

img/515226_1_En_11_Fig16_HTML.png

图 11-16

忘记大门

输入门

输入门用于控制 LSTM 接收输入的程度。首先,通过对当前时间戳的输入 x t 和前一时间戳的输出ht—1

\tilde{c}_{t}= tanhtanh\ \left({W}_c\left[{h}_{t-1},{x}_t\right]+{b}_c\right)

进行非线性变换,得到新的输入向量\tilde{c}_{t}

其中 W cb c 为输入门的参数,需要反向传播算法自动优化,Tanh 为激活函数,用于将输入归一化为[-1,1]。\tilde{c}_{t}不完全刷新进入 LSTM 的存储器,但控制通过输入门接收的输入量。输入门的控制变量也来自输入 x * t 和输出h*t—1:

{g}_i=\sigma \left({W}_i\left[{h}_{t-1},{x}_t\right]+{b}_i\right)

其中 W ib i 为输入门的参数,需要反向传播算法自动优化, σ 为激活函数,一般使用 Sigmoid 函数。输入门控制变量 g i 决定 LSTM 如何接受当前时间戳的新输入\tilde{c}_{t}:当gI= 0 时,LSTM 不接受任何新输入\tilde{c}_{t};当gI= 1 时,LSTM 接受所有新输入\tilde{c}_{t},如图 11-17 所示。

通过输入门后,要写入内存的向量是{g}_i\tilde{c}_{t}

img/515226_1_En_11_Fig17_HTML.png

图 11-17

输入门

更新存储器

在遗忘门和输入门的控制下,LSTM 选择性地读取前一个时间戳的存储器c??和当前时间戳的新输入\tilde{c}_{t}。状态向量 c * t * 的刷新方式为:

{c}_t={g}_i\tilde{c}_{t}+{g}_f{c}_{t-1}

得到的新的状态向量ct就是当前时间戳的状态向量,如图 11-17 所示。

输出门

LSTM 的内部状态向量 c t 不直接用于输出,与基本的 RNN 不同。基本 RNN 网络的状态向量 h 同时用于存储和输出,所以基本 RNN 可以理解为状态向量 c 和输出向量 h 是同一个对象。在 LSTM 中,状态向量不是全部输出,而是在输出门的作用下有选择地输出。输出门的门变量 g o 是:

{g}_o=\sigma \left({W}_o\left[{h}_{t-1},{x}_t\right]+{b}_o\right)

其中 W ob o 为输出门的参数,也需要反向传播算法自动优化。 σ 为激活函数,一般使用 Sigmoid 函数。当输出门go= 0 时,输出关闭,LSTM 内部存储器被完全封锁,不能作为输出使用。此时输出的是 0 的向量;当输出门go= 1 时,输出全开,LSTM 状态向量 c t 全部用于输出。LSTM 的输出由:

{h}_t={g}_o\bullet tanhtanh\ \left({c}_t\right)

组成

即内存向量ct通过 Tanh 激活函数后与输入门交互,得到 LSTM 的输出。由于go∈【0,1】和tanh tanh(ct)∈【1,1】,LSTM 的输出为ht∈【1,1】。

img/515226_1_En_11_Fig18_HTML.png

图 11-18

输出门

总结

虽然 LSTM 有大量的状态向量和门,但计算过程相对复杂。但是由于每个门的控制功能都很清楚,所以每个状态的作用也更容易理解。这里列出了典型的门控行为,并解释了代码的 LSTM 行为,如表 11-1 所示。

表 11-1

输入门和遗忘门的典型行为

|

输入门控

|

忘记门控

|

LSTM 行为

| | --- | --- | --- | | Zero | one | 仅使用内存 | | one | one | 集成输入和存储器 | | Zero | Zero | 清除存储器 | | one | Zero | 输入覆盖内存 |

11.9 如何使用 LSTM 层

在 TensorFlow 中,也有两种实现 LSTM 网络的方法。可以使用 LSTMCell 手动完成时间戳的循环操作,也可以通过 LSTM 层一步完成正向操作。

LSTMCell

LSTMCell 的用法和 SimpleRNNCell 基本相同。不同的是 LSTM 有两个状态变量——list,即[ h tc t ],需要分别初始化。列表的第一个元素是 h t ,第二个元素是 c t 。当调用单元格来完成正向操作时,将返回两个元素。第一个元素是单元格的输出,是 h t ,第二个元素是单元格更新后的状态列表:[htct]。首先创建一个状态向量长度为 h = 64 的新 LSTMCell,其中状态向量 c t 和输出向量 h t 的长度都是 h 。代码如下:

In [18]:
x = tf.random.normal([2,80,100])
xt = x[:,0,:] # Get a timestamp input
cell = layers.LSTMCell(64) # Create LSTM Cell
# Initialization state and output List,[h,c]
state = [tf.zeros([2,64]),tf.zeros([2,64])]
out, state = cell(xt, state) # Forward calculation
# View the id of the returned element
id(out),id(state[0]),id(state[1])
Out[18]: (1537587122408, 1537587122408, 1537587122728)

可以看出,返回的 output out 与 list 的第一个元素 h t 的 id 相同,这与基本 RNN 的初衷是一致的,是为了格式的统一。

通过在时间戳上展开循环操作,可以完成一层的前向传播,写入方法与基本 RNN 相同。例如:

# Untie it in the sequence length dimension, and send it to the LSTM Cell unit in a loop
for xt in tf.unstack(x, axis=1):
    # Forward calculation
    out, state = cell(xt, state)

输出可以只使用最后一个时间戳的输出,也可以聚合所有时间戳的输出向量。

11.9.2 LSTM 层

穿过层层。LSTM 层,整个序列的操作可以方便地一次性完成。首先创建一个新的 LSTM 网络层,例如:

# Create an LSTM layer with a memory vector length of 64
layer = layers.LSTM(64)
# The sequence passes through the LSTM layer and returns the output h of the last time stamp by default

out = layer(x)

通过 LSTM 层向前传播后,默认情况下将只返回最后一个时间戳的输出。如果需要返回每个时间戳以上的输出,需要设置 return_sequences=True。例如:

# When creating the LSTM layer, set to return the output on each timestamp
layer = layers.LSTM(64, return_sequences=True)
# Forward calculation, the output on each timestamp is automatically concated to form a tensor
out = layer(x)

此时返回的 out 包含所有时间戳之上的状态输出,其形状为[2,80,64],其中 80 代表 80 个时间戳。

对于多层神经网络,可以用顺序容器包装多个 LSTM 层,设置所有非最终层网络 return_sequences=True,因为非最终 LSTM 层需要上一层所有时间戳的输出作为输入。例如:

# Like the CNN network, LSTM can also be simply stacked layer by layer
net = keras.Sequential([
    layers.LSTM(64, return_sequences=True), # The non-final layer needs to return all timestamp output
    layers.LSTM(64)
])
# Once through the network model, you can get the output of the last layer and the last time stamp
out = net(x)

11.10 GRU 简介

LSTM 有更长的记忆容量,并且在大多数序列任务上比基本的 RNN 模型有更好的表现。更重要的是,LSTM 不容易出现梯度消失。但是,LSTM 结构相对复杂,计算成本高,模型参数大。因此,科学家们试图简化 LSTM 内部的计算过程,特别是减少门的数量。研究发现遗忘门是 LSTM 中最重要的门控制[2],甚至发现仅具有遗忘门的网络的简化版本在多个基准数据集上优于标准的 LSTM 网络。在 LSTM 的许多简化版本中,门控循环单位(GRU)是使用最广泛的 RNN 变体之一。GRU 将内部状态向量和输出向量合并成一个状态向量 h ,门的数量也减少为两个,复位门和更新门,如图 11-19 所示。

img/515226_1_En_11_Fig19_HTML.png

图 11-19

GRU 网络结构

下面我们分别介绍一下复位门和更新门的原理和作用。

11.10.1 重置时间为

复位门用于控制上一个时间戳的状态h??进入 GRU 的数量,门控向量 g r 是通过变换当前时间戳输入 x t 和上一个时间戳状态ht1得到的

其中Wr和 b r 为复位门的参数,由反向传播算法自动优化, σ 为激活函数,一般使用 Sigmoid 函数。门控向量 g r 只控制状态ht-1,不控制输入xt:

\tilde{h}_{t}= tanhtanh\ \left({W}_h\left[{g}_r{h}_{t-1},{x}_t\right]+{b}_h\right)

g r = 0 时,新的输入\tilde{h}_{t}全部来自输入 x * t ht—1不被接受,相当于复位h*t—1。当gr= 1,ht—1与输入*t共同生成一个新的输入\tilde{h}_{t},如图 11-20 所示。*

*img/515226_1_En_11_Fig20_HTML.png

图 11-20

复位门

更新门

更新门控制最后时间戳状态h??和新输入\tilde{h}_{t}对新状态向量 h * t 的影响程度。更新门控向量 g z * 由:

{g}_z=\sigma \left({W}_z\left[{h}_{t-1},{x}_t\right]+{b}_z\right)

其中 W zb z 为更新门的参数,由反向传播算法自动优化, σ 为激活函数,一般使用 Sigmoid 函数。 g z 用于控制新输入的\tilde{h}_{t}信号,1gz用于控制状态ht—1信号:

{h}_t=\left(1-{g}_z\right){h}_{t-1}+{g}_z\tilde{h}_{t}

可以看出\tilde{h}_{t}ht—1h * t 的更新处于相互竞争的状态。当更新门g*z= 0 时,所有 h * t 来自上一次时间戳状态h*t—1;当更新门gz= 1 时,所有的 h * t * 都来自新的输入\tilde{h}_{t}

img/515226_1_En_11_Fig21_HTML.png

图 11-21

更新门

如何使用 GRU

同样,在 TensorFlow 中,也有单元和层方法来实现 GRU 网络。格鲁塞尔和 GRU 层的用法与前面的 SimpleRNNCell、LSTMCell、SimpleRNN 和 LSTM 非常相似。首先,使用 GRUCell 创建一个 GRU 单元格对象,并在时间轴上循环展开操作。例如:

In [19]:
# Initialize the state vector, there is only one GRU
h = [tf.zeros([2,64])]
cell = layers.GRUCell(64) # New GRU Cell, vector length is 64
# Untie in the timestamp dimension, loop through the cell
for xt in tf.unstack(x, axis=1):
    out, h = cell(xt, h)
# Out shape
out.shape
Out[19]:TensorShape([2, 64])

您可以通过图层轻松创建 GRU 网络图层。GRU 类,并通过顺序容器堆叠多个 GRU 层的网络。例如:

net = keras.Sequential([
    layers.GRU(64, return_sequences=True),
    layers.GRU(64)
])
out = net(x)

11.11 LSTM/GRU 情感分类实践

前面我们介绍了情感分类问题,并使用 SimpleRNN 模型来解决这个问题。在引入更强大的 LSTM 和 GRU 网络后,我们升级了网络模型。得益于 TensorFlow 循环神经网络相关接口的统一格式,只需对原代码进行少量修改,就可以完美升级到 LSTM 或 GRU 模型。

11 . 11 . 1 lstm 模型

首先,让我们使用细胞方法。LSTM 网络有两个状态表,每层的 hc 向量需要分别初始化。例如:

        self.state0 = [tf.zeros([batchsz, units]),tf.zeros([batchsz, units])]
        self.state1 = [tf.zeros([batchsz, units]),tf.zeros([batchsz, units])]

将模型修改为 LSTMCell 模型,如下所示:

        self.rnn_cell0 = layers.LSTMCell(units, dropout=0.5)
        self.rnn_cell1 = layers.LSTMCell(units, dropout=0.5)

其他代码无需修改即可运行。对于层方法,仅需要修改网络模型的一部分,如下所示:

        # Build RNN, replace with LSTM class
        self.rnn = keras.Sequential([
            layers.LSTM(units, dropout=0.5, return_sequences=True),
            layers.LSTM(units, dropout=0.5)
        ])

GRU 模型

对于单元格方法,只有一个 GRU 状态列表。与基本 RNN 一样,您只需修改创建的单元类型。代码如下:

        # Create 2 Cells
        self.rnn_cell0 = layers.GRUCell(units, dropout=0.5)
        self.rnn_cell1 = layers.GRUCell(units, dropout=0.5)

对于图层方法,只需修改网络层类型,如下所示:

        # Create RNN
        self.rnn = keras.Sequential([
            layers.GRU(units, dropout=0.5, return_sequences=True),
            layers.GRU(units, dropout=0.5)
        ])

11.12 预先训练的单词向量

在情感分类任务中,嵌入层是从头开始训练的。事实上,对于文本处理任务,大部分领域知识是共享的,因此我们可以利用在其他任务上训练的词向量来初始化嵌入层,以完成领域知识的传递。基于预先训练好的嵌入层开始训练,用少量的样本就可以达到很好的效果。

我们以预训练的手套词向量为例,演示如何使用预训练的词向量模型来提高任务绩效。首先从官网下载预先训练好的手套词向量表。我们选择特征长度为 100 的文件 glove.6B.100d.txt,每个单词用长度为 100 的向量表示,下载后可以解压。

img/515226_1_En_11_Fig22_HTML.jpg

图 11-22

手套字向量模型文件

使用 Python 文件 IO 代码读取单词编码向量表,存储在 Numpy 数组中。代码如下所示:

print('Indexing word vectors.')
embeddings_index = {} # Extract words and their vectors and save them in a dictionary
# Word vector model file storage path
GLOVE_DIR = r'C:\Users\z390\Downloads\glove6b50dtxt'
with open(os.path.join(GLOVE_DIR, 'glove.6B.100d.txt'),encoding='utf-8') as f:
    for line in f:
        values = line.split()
        word = values[0]
        coefs = np.asarray(values[1:], dtype='float32')
        embeddings_index[word] = coefs
print('Found %s word vectors.' % len(embeddings_index))

GloVe.6B 版本存储了总共 40 万字的向量表。我们只考虑了 10,000 个常用词。我们根据单词的数字代码表从手套模型中获得单词向量,并将其写入相应的位置,如下所示:

num_words = min(total_words, len(word_index))
embedding_matrix = np.zeros((num_words, embedding_len)) # Word vector table
for word, i in word_index.items():
    if i >= MAX_NUM_WORDS:
        continue # Filter out other words
    embedding_vector = embeddings_index.get(word) # Query word vector from GloVe
    if embedding_vector is not None:
        # words not found in embedding index will be all-zeros.
        embedding_matrix[i] = embedding_vector # Write the corresponding location
print(applied_vec_count, embedding_matrix.shape)

获得词汇数据后,使用词汇初始化嵌入层,设置嵌入层不参与梯度优化,如下:

        # Create Embedding layer
        self.embedding = layers.Embedding(total_words, embedding_len, input_length=max_review_len,
        trainable=False)# Does not participate in gradient updates
        self.embedding.build(input_shape=(None, max_review_len))
        # Initialize the Embedding layer using the GloVe model
        self.embedding.set_weights([embedding_matrix])# initialization

其他部分是一致的。我们可以简单地将预训练手套模型初始化的嵌入层的训练结果与随机初始化的嵌入层的训练结果进行比较。训练 50 个历元后,预训练模型的准确率达到 84.7%,提高了约 2%。

11.13 预先训练的单词向量

在这一章中,我们介绍了循环神经网络(RNN ),它适用于处理序列相关的问题,如语音和股票市场信号。讨论了几种序列表示方法,包括一键编码和单词嵌入。然后我们介绍了开发 RNN 结构的动机以及 SimpleRNNCell 网络的例子。使用 RNN 实现了动手情感分类,以帮助我们熟悉使用 RNN 解决现实世界的问题。梯度消失和爆炸是 RNN 训练过程中的常见问题。幸运的是,梯度裁剪方法可以用来克服梯度爆炸问题。RNN 的不同变体,例如 LSTM 和 GRU,可以用来避免梯度消失的问题。情感分类实例表明,使用 LSTM 和 GRU 模型具有更好的性能,因为它们能够避免梯度爆炸问题。

11.14 参考

  1. I. Goodfellow,Y. Bengio 和 a .库维尔,《深度学习》,麻省理工学院出版社,2016 年。

  2. J.Westhuizen 和 J. Lasenby,《遗忘门的不合理效力》, CoRR, abs/1804.04849,2018。*