Keras-深度学习-全-

Keras 深度学习(全)

原文:annas-archive.org/md5/31e7ae0c4516a0dda7076f8a39dcfc99

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

深入学习与 Keras 实践是一本简明而全面的现代神经网络、人工智能和深度学习技术的入门书,专为软件工程师和数据科学家设计。

使命

本书介绍了 20 多个用 Python 编写的深度神经网络,这些网络使用 Keras——一个在 Google 的 TensorFlow 或 Lisa Lab 的 Theano 后端之上运行的模块化神经网络库。

读者将逐步接触到监督学习算法,如简单的线性回归、经典的多层感知机、更加复杂的深度卷积网络和生成对抗网络。此外,本书还涵盖了无监督学习算法,如自编码器和生成网络。书中还详细解释了递归网络和长短时记忆LSTM)网络。接下来,本书将介绍 Keras 的功能 API,并讲解如何在读者的使用场景未被 Keras 的广泛功能覆盖时进行定制。它还将探讨由先前提到的构建模块组成的更大、更复杂的系统。书的最后介绍了深度强化学习及其在构建游戏 AI 中的应用。

实际应用包括用于将新闻文章分类为预定义类别的代码、文本的句法分析、情感分析、文本的合成生成以及词性标注。还探讨了图像处理,包括手写数字图像的识别、图像分类及其相关的图像注释、以及高级物体识别。此外,还将提供面部检测的显著点识别示例。声音分析包括对多位演讲者的离散语音识别。强化学习用于构建一个深度 Q 学习网络,能够自主地玩游戏。

实验是本书的精髓。每个网络都通过多个变体进行增强,这些变体通过改变输入参数、网络形状、损失函数和用于优化的算法,逐步提高学习性能。本书还提供了在 CPU 和 GPU 上训练的多种比较。

深度学习与机器学习和人工智能有何不同

人工智能 (AI) 是一个非常广泛的研究领域,在这个领域中,机器展示了认知能力,如学习行为、与环境的主动互动、推理与演绎、计算机视觉、语音识别、问题解决、知识表示、感知等许多能力(更多信息,请参考本文:人工智能:一种现代方法,S. Russell 和 P. Norvig 著,Prentice Hall,2003 年)。更通俗地说,AI 指代任何机器模仿人类通常表现出的智能行为的活动。人工智能从计算机科学、数学和统计学等领域中汲取灵感。

机器学习 (ML) 是人工智能的一个子领域,专注于教计算机如何在不需要为特定任务编程的情况下学习(更多信息,请参见 模式识别与机器学习,C. M. Bishop 著,Springer,2006 年)。实际上,机器学习的关键思想是,能够创建从数据中学习并做出预测的算法。机器学习有三种不同的广泛分类。在监督学习中,机器被提供输入数据和期望的输出,目标是通过这些训练示例学习,使得能够对未见过的新数据做出有意义的预测。在无监督学习中,机器仅被提供输入数据,机器需要自行发现一些有意义的结构,且没有外部监督。在强化学习中,机器充当一个与环境互动的智能体,学习哪些行为会产生奖励。

深度学习 (DL) 是机器学习(ML)方法的一种特定子集,使用人工神经网络 (ANN),这些网络在某种程度上受到人脑神经元结构的启发(更多信息,请参考文章 为人工智能学习深度架构,Y. Bengio 著,Found. Trends,第 2 卷,2009 年)。非正式地说,深度一词指的是人工神经网络中存在许多层,但这一含义随着时间的推移有所变化。四年前,10 层已经足够让一个网络被视为深度网络,而如今,通常认为拥有数百层的网络才算是深度网络。

深度学习(DL)对机器学习来说简直是一场真正的海啸(更多信息请参见C.D. Manning 的《计算语言学与深度学习》,“计算语言学”,第 41 卷,2015 年),因为相对较少的聪明方法已经非常成功地应用到众多不同领域(图像、文本、视频、语音和视觉),显著提升了过去几十年间所取得的最先进成果。深度学习的成功还得益于更多训练数据的可用性(如图像领域的 ImageNet)和 GPU 低成本的可用性,使得数值计算更加高效。谷歌、微软、亚马逊、苹果、脸书等许多公司每天都在使用这些深度学习技术来分析海量数据。然而,这种专业技术已不再仅限于纯粹的学术研究领域和大型工业公司,它已成为现代软件生产的重要组成部分,因此读者应当掌握这些技术。本书不要求任何特定的数学背景,但假设读者已经是一个 Python 程序员。

本书涵盖的内容

第一章,神经网络基础,讲解了神经网络的基本知识。

第二章,Keras 安装与 API,展示了如何在 AWS、微软 Azure、谷歌云和你自己的机器上安装 Keras。此外,我们还提供了 Keras API 的概述。

第三章,使用卷积网络的深度学习,介绍了卷积网络的概念。卷积网络是深度学习中的一项基础创新,已成功应用于多个领域,从文本到视频再到语音,远远超出了最初用于图像处理的领域。

第四章,生成对抗网络与 WaveNet,介绍了生成对抗网络,用于生成类似人类生成的数据的合成数据。我们还将介绍 WaveNet,这是一种深度神经网络,用于高质量地再现人声和音乐。

第五章,词嵌入,讨论了词嵌入,这是一种深度学习方法,用于检测词与词之间的关系并将相似的词归为一类。

第六章,递归神经网络 – RNN,介绍了递归神经网络,这是一类专门优化用于处理序列数据(如文本)的网络。

第七章,其他深度学习模型,简要介绍了 Keras 函数式 API、回归网络、自编码器等内容。

第八章,AI 游戏对战,教你深度强化学习,并展示如何利用 Keras 构建可以通过奖励反馈学习玩街机游戏的深度学习网络。

附录,结论,是本书内容的简要回顾,并带领读者了解 Keras 2.0 的新功能。

本书所需的内容

为了能够顺利跟随章节,你需要以下软件:

  • TensorFlow 1.0.0 或更高版本

  • Keras 2.0.2 或更高版本

  • Matplotlib 1.5.3 或更高版本

  • Scikit-learn 0.18.1 或更高版本

  • NumPy 1.12.1 或更高版本

硬件规格如下:

  • 32 位或 64 位架构

  • 2+ GHz CPU

  • 4 GB RAM

  • 至少需要 10 GB 的可用硬盘空间

本书的目标读者

如果你是一个有机器学习经验的数据科学家,或者是一个接触过神经网络的 AI 程序员,你会发现这本书是深入了解 Keras 深度学习的有用入门书籍。此书要求具备 Python 知识。

约定

本书中有多种文本样式,用以区分不同类型的信息。以下是一些样式的示例及其含义解释。

文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账户名会以如下格式显示:“此外,我们将真实标签分别加载到 Y_trainY_test 中,并对其进行 one-hot 编码。”

代码块的格式如下:

from keras.models import Sequential
model = Sequential()
model.add(Dense(12, input_dim=8, kernel_initializer='random_uniform'))

当我们希望引起你对代码块中特定部分的注意时,相关的行或项会以粗体显示:

# 10 outputs
# final stage is softmax
model = Sequential()
model.add(Dense(NB_CLASSES, input_shape=(RESHAPED,)))
model.add(Activation('softmax'))
model.summary()

所有的命令行输入或输出都按如下方式书写:

pip install quiver_engine

新术语重要词汇以粗体显示。你在屏幕上看到的单词,例如菜单或对话框中的内容,会以这样的形式出现在文本中:“我们的简单网络的初始准确率为 92.22%,这意味着每 100 个手写字符中,大约有 8 个未能被正确识别。”

警告或重要提示会以这样的框框显示。

提示和技巧会以这样的形式出现。

读者反馈

我们欢迎读者的反馈。让我们知道你对本书的看法——你喜欢或不喜欢的部分。读者反馈对我们很重要,它帮助我们开发出更符合你需求的书籍。

若要向我们提供一般反馈,只需发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书籍标题。

如果你在某个领域有专长,并且有兴趣撰写或参与书籍的编写,请查看我们的作者指南:www.packtpub.com/authors

客户支持

现在你已经是 Packt 书籍的骄傲拥有者,我们为你提供了一些帮助,以确保你能够充分利用你的购买。

下载示例代码

您可以从www.packtpub.com上的账户下载本书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,将文件直接发送到您的电子邮件。

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的官网。

  2. 将鼠标指针悬停在顶部的“SUPPORT”标签上。

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入书名。

  5. 选择您要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买此书的地方。

  7. 点击“代码下载”。

一旦文件下载完成,请确保使用最新版本的工具解压或提取文件夹:

  • Windows 的 WinRAR / 7-Zip

  • Mac 的 Zipeg / iZip / UnRarX

  • Linux 的 7-Zip / PeaZip

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Deep-Learning-with-Keras。我们还在github.com/PacktPublishing/提供了其他书籍和视频的代码包,欢迎查看!

下载本书的彩色图像

我们还为您提供了一份 PDF 文件,包含本书中使用的截图/图表的彩色图像。这些彩色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/DeepLearningwithKeras_ColorImages.pdf下载此文件。

勘误

尽管我们已尽全力确保内容的准确性,但错误仍然可能发生。如果您在我们的书籍中发现错误——可能是文本或代码中的错误——我们将非常感激您向我们报告。这样,您可以帮助其他读者避免困扰,并且帮助我们改进后续版本的书籍。如果您发现任何勘误,请访问www.packtpub.com/submit-errata报告,选择您的书籍,点击“勘误提交表单”链接,并输入勘误的详细信息。一旦您的勘误被验证,您的提交将被接受,勘误将上传到我们的网站,或添加到该书名的现有勘误列表中。

要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索框中输入书名。所需的信息将在勘误部分下显示。

盗版

互联网上的版权侵权问题在所有媒体中都持续存在。在 Packt,我们非常重视版权和许可证的保护。如果你在互联网上发现任何形式的非法复制品,请立即提供该位置地址或网站名称,以便我们采取措施。

如发现涉嫌盗版的材料,请通过copyright@packtpub.com与我们联系,并提供相关链接。

我们感谢你在保护我们的作者及其作品的同时,帮助我们继续为你带来有价值的内容。

问题

如果你对本书的任何内容有问题,可以通过questions@packtpub.com与我们联系,我们将尽力解决问题。

第一章:神经网络基础

人工神经网络(简称神经网络)代表了一类机器学习模型,灵感来源于对哺乳动物中枢神经系统的研究。每个神经网络由多个互联的神经元组成,这些神经元按组织,当某些条件发生时,它们会交换信息(在术语中称为激活)。最初的研究始于 20 世纪 50 年代末,伴随感知机的引入(更多信息请参见文章:感知机:一种用于大脑信息存储和组织的概率模型,作者:F. 罗斯布拉特,心理学评论,卷 65,第 386 - 408 页,1958 年),这是一个用于简单操作的两层网络,随后在 20 世纪 60 年代末,通过引入反向传播算法,对多层网络的高效训练进行了扩展(参考文章:反向传播:它的作用与如何实现,作者:P. J. 韦尔博斯,IEEE 会议录,卷 78,第 1550 - 1560 页,1990 年,及深度信念网络的快速学习算法,作者:G. E. 亨顿、S. 奥辛德罗和 Y. W. 特,神经计算,卷 18,第 1527 - 1554 页,2006 年)。有些研究认为这些技术的起源比通常引用的时间更久远(更多信息请参见文章:神经网络中的深度学习:概述,作者:J. 施米德胡伯,卷 61,第 85 - 117 页,2015 年)。神经网络曾是 20 世纪 80 年代以前的学术研究重点,直到其他更简单的方法变得更为相关。然而,随着 2000 年代中期的到来,神经网络重新受到关注,这要归功于 G. 亨顿提出的突破性快速学习算法(更多信息请参见文章:反向传播的根源:从有序导数到神经网络和政治预测神经网络,作者:S. 莱文,卷 9,1996 年,及通过反向传播误差学习表示,作者:D. E. 鲁梅哈特、G. E. 亨顿和 R. J. 威廉姆斯,卷 323,1986 年),以及 2011 年左右引入的用于大规模数值计算的 GPU。

这些改进为现代深度学习开辟了道路,深度学习是一类神经网络,其特点是包含大量神经元层,能够基于逐级抽象的方式学习复杂的模型。几年前,人们称其为深度,通常只有 3 到 5 层,而现在这一层数已上升到 100 到 200 层。

这种通过渐进抽象进行的学习类似于人类大脑中经过数百万年进化的视觉模型。人类视觉系统的确被组织成不同的层级。我们的眼睛连接到大脑的一个区域,称为视觉皮层 V1,它位于大脑的后下部。这个区域对许多哺乳动物都是共有的,负责区分基本特性以及视觉方向、空间频率和颜色的微小变化。据估计,V1 包含约 1.4 亿个神经元,它们之间有 100 亿个连接。V1 随后与其他区域 V2、V3、V4、V5 和 V6 连接,进行更复杂的图像处理和对更复杂概念的识别,比如形状、面孔、动物等。这种分层组织是通过几亿年的多次尝试调优的结果。据估计,人类大脑皮层约有 160 亿个神经元,约 10%-25%的皮层区域专门负责视觉处理(更多信息请参见文章:The Human Brain in Numbers: A Linearly Scaled-up Primate Brain,S. Herculano-Houzel,第三卷,2009 年)。深度学习从人类视觉系统的这种分层组织中获得了一些灵感:早期的人工神经元层学习图像的基本属性,而较深层的神经元则学习更复杂的概念。

本书通过提供在 Keras 中编写的工作网络,涵盖了神经网络的几个主要方面。Keras 是一个简约高效的 Python 库,用于深度学习计算,支持运行在 Google 的 TensorFlow(更多信息请参见www.tensorflow.org/)或蒙特利尔大学的 Theano(更多信息请参见deeplearning.net/software/theano/)后台。因此,让我们开始吧。

在本章中,我们将讨论以下主题:

  • 感知器

  • 多层感知器

  • 激活函数

  • 梯度下降

  • 随机梯度下降

  • 反向传播

感知器

感知器是一种简单的算法,它给定一个输入向量 x,包含 m 个值(x[1]x[2],...,x[n]),通常称为输入特征或简写为特征,输出为 1(是)或 0(否)。从数学上讲,我们定义一个函数:

这里,w 是一个权重向量,wx 是点积 b 是偏置。如果你记得初等几何学,wx + b 定义了一个边界超平面,该超平面的位置会根据赋给 wb 的值变化。如果 x 位于直线以上,则答案为正,否则为负。这个算法非常简单!感知器无法表达 也许 的答案。它只能回答 1)或 0),前提是我们理解如何定义 wb,这就是训练过程,接下来我们将讨论。

Keras 代码的第一个示例

Keras 的初始构建模块是模型,而最简单的模型称为顺序模型。一个顺序的 Keras 模型是一个线性管道(堆叠)的神经网络层。以下代码片段定义了一个包含 12 个人工神经元的单层,并且它期望 8 个输入变量(也称为特征):

from keras.models import Sequential
model = Sequential()
model.add(Dense(12, input_dim=8, kernel_initializer='random_uniform'))

每个神经元可以使用特定的权重进行初始化。Keras 提供了几种选择,最常见的几种列举如下:

  • random_uniform:权重初始化为均匀随机的小值,范围在 (-0.05, 0.05) 之间。换句话说,给定区间内的任何值都有相同的概率被选中。

  • random_normal:权重根据高斯分布进行初始化,均值为零,标准差为 0.05。对于不熟悉高斯分布的朋友,可以将其想象为一个对称的钟形曲线

  • zero:所有权重初始化为零。

完整列表可以在 keras.io/initializations/ 找到。

多层感知器 —— 网络的第一个示例

在本章中,我们定义了一个具有多个线性层的网络的第一个示例。从历史上看,感知器是指具有单一线性层的模型,因此如果它具有多个层,你可以称之为多层感知器MLP)。下图表示一个典型的神经网络,包含一个输入层、一个中间层和一个输出层。

在前面的示意图中,第一层中的每个节点接收输入,并根据预定义的局部决策边界进行激活。然后,第一层的输出传递到第二层,第二层的结果传递到最终的输出层,输出层由一个单一的神经元组成。有趣的是,这种分层组织与我们早前讨论的人类视觉模式有些相似。

net 是稠密的,意味着每个层中的神经元都与前一层中的所有神经元及后一层中的所有神经元相连接。

训练感知器中的问题及其解决方案

让我们考虑一个单一的神经元;对于权重 w 和偏置 b,最佳选择是什么?理想情况下,我们希望提供一组训练示例,让计算机调整权重和偏置,使输出中产生的误差最小化。为了让这个问题更加具体,假设我们有一组猫的图片和另一组不包含猫的图片。为了简化,假设每个神经元只查看一个输入像素值。在计算机处理这些图片时,我们希望神经元调整它的权重和偏置,使得错误识别为非猫的图片越来越少。这种方法看起来非常直观,但它要求权重(和/或偏置)的小变化只会导致输出的小变化。

如果我们的输出跳跃过大,就无法逐步学习(而不是像进行穷尽搜索那样尝试所有可能的方向——这是一个在不确定是否改进的情况下进行的过程)。毕竟,孩子们是逐步学习的。不幸的是,感知器没有表现出这种逐步行为。感知器的输出要么是0,要么是1,这是一种很大的跳跃,这对学习没有帮助,正如下面的图所示:

我们需要一些不同的、更平滑的东西。我们需要一个从 0 逐渐变化到 1 的函数,且没有间断。数学上,这意味着我们需要一个连续的函数,使我们能够计算其导数。

激活函数 — sigmoid

Sigmoid 函数定义如下:

如下图所示,当输入在  中变化时,输出在 (0, 1) 范围内变化较小。数学上,这个函数是连续的。一个典型的 sigmoid 函数在下图中表示:

神经元可以使用 sigmoid 来计算非线性函数 。注意,如果  非常大且为正,那么 ,因此 ,而如果  非常大且为负,,则 。换句话说,具有 sigmoid 激活的神经元表现得类似于感知器,但其变化是渐进的,输出值,如 0.55390.123191,都是完全合法的。从这个意义上说,sigmoid 神经元可以回答也许

激活函数 — ReLU

Sigmoid 并不是唯一用于神经网络的平滑激活函数。最近,一种叫做修正线性单元ReLU)的简单函数变得非常流行,因为它能够产生非常好的实验结果。ReLU 函数简单地定义为 ,其非线性函数在下图中表示。正如你在下图中看到的,负值时该函数为零,正值时则呈线性增长:

激活函数

Sigmoid 和 ReLU 通常被称为神经网络术语中的激活函数。在Keras 中测试不同优化器一节中,我们将看到这些逐步变化,典型的 Sigmoid 和 ReLU 函数,是发展学习算法的基本构建块,这些算法逐渐减少网络所犯的错误,一点点地适应。以下是使用激活函数 σ 与输入向量 (x[1], x[2], ..., x[m])、权重向量 (w[1], w[2], ..., w[m])、偏置 b 和求和 Σ 的示例:

Keras 支持多种激活函数,完整列表请参见 keras.io/activations/

实际示例 — 识别手写数字

在这一部分,我们将构建一个可以识别手写数字的网络。为了实现这个目标,我们使用 MNIST(有关更多信息,请参见yann.lecun.com/exdb/mnist/),这是一个由 60,000 个训练样本和 10,000 个测试样本组成的手写数字数据库。训练样本由人类标注,包含正确答案。例如,如果手写数字是数字 3,那么 3 就是与该样本相关联的标签。

在机器学习中,当有正确答案的数据集时,我们称之为可以进行一种有监督学习。在这种情况下,我们可以使用训练样本来调整我们的网络。测试样本也会与每个数字相关联正确的答案。然而,在这种情况下,想法是装作标签是未知的,让网络进行预测,然后稍后重新考虑标签,以评估我们的神经网络在识别数字方面的学习效果。因此,毫不奇怪,测试样本仅用于测试我们的网络。

每个 MNIST 图像是灰度图,并由 28 x 28 个像素组成。这些数字的一部分在下面的图示中表示:

一热编码 — OHE

在许多应用中,将分类(非数值)特征转换为数值变量是很方便的。例如,具有值d的分类特征数字* [0-9] 可以编码为一个具有10个位置的二进制向量,该向量始终在除d位置外的所有位置上为0,在d位置上为1*。这种表示方式称为一热编码OHE),在数据挖掘中非常常见,尤其是当学习算法专门处理数值函数时。

在 Keras 中定义一个简单的神经网络

在这里,我们使用 Keras 定义一个识别 MNIST 手写数字的网络。我们从一个非常简单的神经网络开始,然后逐步改进它。

Keras 提供了适合的库来加载数据集,并将其划分为训练集X_train,用于对网络进行微调,以及测试集* X_test,* 用于评估性能。数据被转换为float32以支持 GPU 计算,并归一化为* [0, 1] *。此外,我们将真实标签分别加载到Y_trainY_test中,并对它们进行一热编码。我们来看一下代码:

from __future__ import print_function
import numpy as np
from keras.datasets import mnist
from keras.models import Sequential
from keras.layers.core import Dense, Activation
from keras.optimizers import SGD
from keras.utils import np_utils
np.random.seed(1671) # for reproducibility

# network and training
NB_EPOCH = 200
BATCH_SIZE = 128
VERBOSE = 1
NB_CLASSES = 10 # number of outputs = number of digits
OPTIMIZER = SGD() # SGD optimizer, explained later in this chapter
N_HIDDEN = 128
VALIDATION_SPLIT=0.2 # how much TRAIN is reserved for VALIDATION

# data: shuffled and split between train and test sets
#
(X_train, y_train), (X_test, y_test) = mnist.load_data()
#X_train is 60000 rows of 28x28 values --> reshaped in 60000 x 784
RESHAPED = 784
#
X_train = X_train.reshape(60000, RESHAPED)
X_test = X_test.reshape(10000, RESHAPED)
X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
# normalize
#
X_train /= 255
X_test /= 255
print(X_train.shape[0], 'train samples')
print(X_test.shape[0], 'test samples')
# convert class vectors to binary class matrices
Y_train = np_utils.to_categorical(y_train, NB_CLASSES)
Y_test = np_utils.to_categorical(y_test, NB_CLASSES)

输入层有一个与图像中每个像素关联的神经元,总共有28 x 28 = 784个神经元,每个像素对应 MNIST 图像中的一个像素。

通常,每个像素的值会在范围* [0, 1] *内进行归一化(这意味着每个像素的强度会除以 255,255 是最大强度值)。输出是 10 个类别,每个类别对应一个数字。

最后一层是一个具有 softmax 激活函数的单神经元,softmax 是 sigmoid 函数的推广。Softmax 压缩 一个具有 k 个维度的任意实数向量,将其映射到范围 (0, 1) 的 k 维实数向量。在我们的例子中,它将前一层提供的 10 个答案与 10 个神经元的结果进行聚合:

# 10 outputs
# final stage is softmax
model = Sequential()
model.add(Dense(NB_CLASSES, input_shape=(RESHAPED,)))
model.add(Activation('softmax'))
model.summary()

一旦我们定义了模型,就必须对其进行编译,以便它可以被 Keras 后端(无论是 Theano 还是 TensorFlow)执行。在编译过程中需要做出一些选择:

  • 我们需要选择用于更新权重的 优化器,它是训练模型时使用的特定算法。

  • 我们需要选择用于优化器的 目标函数,该目标函数用于引导权重空间(通常,目标函数也被称为 损失函数,优化过程被定义为损失的 最小化 过程)。

  • 我们需要评估训练好的模型

一些常见的目标函数选择(Keras 目标函数的完整列表可以参考 keras.io/objectives/)如下:

  • 均方误差(MSE):这是预测值和真实值之间的均方误差。从数学上讲,如果 是包含 n 个预测值的向量,而 Y 是包含 n 个观察值的向量,那么它们满足以下方程:

这些目标函数对每个预测所犯的错误进行平均,如果预测值与真实值之间的距离较远,那么通过平方操作,这个距离会更加显著。

  • 二分类交叉熵:这是二元对数损失。假设我们的模型预测值为 p,而目标为 t,那么二分类交叉熵定义如下:

这个目标函数适用于二分类标签预测。

  • 类别交叉熵:这是多类的对数损失。如果目标是 t[i,j],预测是 p[i,j],那么类别交叉熵为:

这个目标函数适用于多类标签预测。在与 softmax 激活函数一起使用时,它也是默认选择。

一些常见的度量标准选择(Keras 度量标准的完整列表可以参考 keras.io/metrics/)如下:

  • 准确率:这是预测正确的比例,与目标相比。

  • 精确度:表示在多标签分类中,有多少选定项是相关的。

  • 召回率:表示在多标签分类中,有多少选定项是相关的。

度量标准与目标函数类似,唯一的区别是它们不是用于训练模型,而仅用于评估模型。在 Keras 中编译模型是非常简单的:

model.compile(loss='categorical_crossentropy', optimizer=OPTIMIZER, metrics=['accuracy'])

一旦模型被编译,就可以使用 fit() 函数进行训练,该函数指定了一些参数:

  • epochs:这是模型暴露于训练集的次数。在每次迭代中,优化器会尝试调整权重,以最小化目标函数。

  • batch_size:这是优化器执行权重更新之前,观察到的训练实例的数量。

在 Keras 中训练模型非常简单。假设我们想要迭代 NB_EPOCH 步:

history = model.fit(X_train, Y_train,
batch_size=BATCH_SIZE, epochs=NB_EPOCH,
verbose=VERBOSE, validation_split=VALIDATION_SPLIT)

我们将部分训练集保留用于验证。关键思想是,我们保留部分训练数据用于在训练过程中评估验证集的表现。这是进行任何机器学习任务时的良好实践,我们将在所有示例中采纳这一做法。

一旦模型训练完成,我们可以在包含新未见示例的测试集上进行评估。通过这种方式,我们可以获得目标函数的最小值和评估指标的最佳值。

请注意,训练集和测试集当然是严格分开的。在已经用于训练的示例上评估模型是没有意义的。学习本质上是一个旨在概括未见观察结果的过程,而不是去记忆已知的内容:

score = model.evaluate(X_test, Y_test, verbose=VERBOSE)
print("Test score:", score[0])
print('Test accuracy:', score[1])

恭喜你,你刚刚在 Keras 中定义了你的第一个神经网络。只需几行代码,你的计算机就能够识别手写数字。让我们运行代码并看看性能如何。

运行一个简单的 Keras 网络并建立基准

那么,让我们来看一下当我们运行下面截图中的代码时会发生什么:

首先,网络架构被转储,我们可以看到使用的不同类型的层、它们的输出形状、需要优化的参数数量以及它们是如何连接的。然后,网络在 48,000 个样本上进行训练,12,000 个样本保留用于验证。一旦神经网络模型构建完成,它就会在 10,000 个样本上进行测试。正如你所看到的,Keras 内部使用 TensorFlow 作为计算的后端系统。现在,我们不会深入探讨训练是如何进行的,但我们可以注意到,程序运行了 200 次迭代,每次迭代时,准确率都有所提高。当训练结束后,我们在测试集上测试模型,得到了约 92.36% 的训练准确率、92.27% 的验证准确率和 92.22% 的测试准确率。

这意味着大约每十个手写字符中就有一个没有被正确识别。我们当然可以做得更好。在下方的截图中,我们可以看到测试集上的准确率:

使用隐藏层改进 Keras 中的简单网络

我们在训练集上的基准准确率为 92.36%,在验证集上的准确率为 92.27%,在测试集上的准确率为 92.22%。这是一个很好的起点,但我们肯定可以做得更好。让我们看看如何改进。

第一个改进是向网络中添加额外的层。因此,在输入层之后,我们有一个第一个密集层,包含N_HIDDEN个神经元,并使用激活函数relu。这个附加层被视为隐藏层,因为它与输入和输出都没有直接连接。在第一个隐藏层之后,我们有第二个隐藏层,仍然包含N_HIDDEN个神经元,之后是一个输出层,包含 10 个神经元,每个神经元将在相应的数字被识别时激活。以下代码定义了这个新网络:

from __future__ import print_function
import numpy as np
from keras.datasets import mnist
from keras.models import Sequential
from keras.layers.core import Dense, Activation
from keras.optimizers import SGD
from keras.utils import np_utils
np.random.seed(1671) # for reproducibility
# network and training
NB_EPOCH = 20
BATCH_SIZE = 128
VERBOSE = 1
NB_CLASSES = 10 # number of outputs = number of digits
OPTIMIZER = SGD() # optimizer, explained later in this chapter
N_HIDDEN = 128
VALIDATION_SPLIT=0.2 # how much TRAIN is reserved for VALIDATION
# data: shuffled and split between train and test sets
(X_train, y_train), (X_test, y_test) = mnist.load_data()
#X_train is 60000 rows of 28x28 values --> reshaped in 60000 x 784
RESHAPED = 784
#
X_train = X_train.reshape(60000, RESHAPED)
X_test = X_test.reshape(10000, RESHAPED)
X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
# normalize
X_train /= 255
X_test /= 255
print(X_train.shape[0], 'train samples')
print(X_test.shape[0], 'test samples')
# convert class vectors to binary class matrices
Y_train = np_utils.to_categorical(y_train, NB_CLASSES)
Y_test = np_utils.to_categorical(y_test, NB_CLASSES)
# M_HIDDEN hidden layers
# 10 outputs
# final stage is softmax
model = Sequential()
model.add(Dense(N_HIDDEN, input_shape=(RESHAPED,)))
model.add(Activation('relu'))
model.add(Dense(N_HIDDEN))
model.add(Activation('relu'))
model.add(Dense(NB_CLASSES))
model.add(Activation('softmax'))
model.summary()
model.compile(loss='categorical_crossentropy',
optimizer=OPTIMIZER,
metrics=['accuracy'])
history = model.fit(X_train, Y_train,
batch_size=BATCH_SIZE, epochs=NB_EPOCH,
verbose=VERBOSE, validation_split=VALIDATION_SPLIT)
score = model.evaluate(X_test, Y_test, verbose=VERBOSE)
print("Test score:", score[0])
print('Test accuracy:', score[1])

让我们运行代码,看看这个多层网络的结果如何。还不错。通过添加两个隐藏层,我们在训练集上达到了 94.50%,在验证集上 94.63%,在测试集上 94.41%。这意味着,相比于之前的网络,我们在测试集上的准确率提高了 2.2%。然而,我们大幅减少了从 200 次迭代到 20 次迭代的训练周期。这是好事,但我们还想要更多。

如果你愿意,你可以自己试试,看看如果只添加一个隐藏层而不是两个,或者如果添加两个以上的层会发生什么。我将这个实验留给你做。以下截图显示了前面示例的输出:

在 Keras 中通过 dropout 进一步改进简单的网络

现在我们的基准准确率为:训练集 94.50%,验证集 94.63%,测试集 94.41%。第二个改进非常简单。我们决定通过 dropout 概率随机丢弃一些在内部密集隐藏层网络中传播的值。在机器学习中,这是一种众所周知的正则化方法。令人惊讶的是,随机丢弃一些值的想法竟然能提升我们的表现:

from __future__ import print_function
import numpy as np
from keras.datasets import mnist
from keras.models import Sequential
from keras.layers.core import Dense, Dropout, Activation
from keras.optimizers import SGD
from keras.utils import np_utils
np.random.seed(1671) # for reproducibility
# network and training
NB_EPOCH = 250
BATCH_SIZE = 128
VERBOSE = 1
NB_CLASSES = 10 # number of outputs = number of digits
OPTIMIZER = SGD() # optimizer, explained later in this chapter
N_HIDDEN = 128
VALIDATION_SPLIT=0.2 # how much TRAIN is reserved for VALIDATION
DROPOUT = 0.3
# data: shuffled and split between train and test sets
(X_train, y_train), (X_test, y_test) = mnist.load_data()
#X_train is 60000 rows of 28x28 values --> reshaped in 60000 x 784
RESHAPED = 784
#
X_train = X_train.reshape(60000, RESHAPED)
X_test = X_test.reshape(10000, RESHAPED)
X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
# normalize
X_train /= 255
X_test /= 255
# convert class vectors to binary class matrices
Y_train = np_utils.to_categorical(y_train, NB_CLASSES)
Y_test = np_utils.to_categorical(y_test, NB_CLASSES)
# M_HIDDEN hidden layers 10 outputs
model = Sequential()
model.add(Dense(N_HIDDEN, input_shape=(RESHAPED,)))
model.add(Activation('relu'))
model.add(Dropout(DROPOUT))
model.add(Dense(N_HIDDEN))
model.add(Activation('relu'))
model.add(Dropout(DROPOUT))
model.add(Dense(NB_CLASSES))
model.add(Activation('softmax'))
model.summary()
model.compile(loss='categorical_crossentropy',
optimizer=OPTIMIZER,
metrics=['accuracy'])
history = model.fit(X_train, Y_train,
batch_size=BATCH_SIZE, epochs=NB_EPOCH,
verbose=VERBOSE, validation_split=VALIDATION_SPLIT)
score = model.evaluate(X_test, Y_test, verbose=VERBOSE)
print("Test score:", score[0])
print('Test accuracy:', score[1])

让我们像之前那样运行 20 次迭代,看看这个网络在训练集上达到 91.54%,在验证集上 94.48%,在测试集上 94.25%的准确率:

请注意,训练集的准确率仍然应该高于测试集准确率,否则我们就没有训练足够长的时间。因此,让我们尝试显著增加 epoch 数量,直到 250 次,我们将获得 98.1%的训练准确率,97.73%的验证准确率,和 97.7%的测试准确率:

观察随着 epoch 数量增加,训练集和测试集上的准确率是非常有用的。正如你在下面的图表中看到的,这两条曲线大约在 250 个 epoch 时交汇,因此,在这一点之后就无需继续训练:

注意,常常观察到,在内部隐藏层进行随机丢弃(dropout)的网络在测试集中的未知样本上表现更好。直观地看,可以将其理解为每个神经元变得更强大,因为它知道不能依赖于邻近的神经元。在测试时,不会有丢弃,所以此时我们使用的是所有已调优的神经元。简而言之,采用某种丢弃函数进行测试通常是检验网络性能的一个好方法。

在 Keras 中测试不同的优化器

我们已经定义并使用了一个网络;接下来可以开始介绍网络训练的直观理解。我们先聚焦于一种流行的训练技术——梯度下降GD)。假设一个通用的代价函数 C(w),它是一个关于单一变量 w 的函数,如下图所示:

梯度下降可以看作是一个登山者,目标是从山顶走到山谷。山代表代价函数 C,而山谷代表最小值 C[min]。登山者从起点 w[0] 开始,逐步向前移动。在每一步 r 中,梯度指向最大增加的方向。从数学上讲,这个方向是偏导数的值 ,其在步数 r 达到的点 w[r] 处被评估。因此,通过朝着相反方向移动 ,登山者可以朝着山谷前进。在每一步中,登山者可以决定步长。这就是梯度下降中的 学习率 。注意,如果 太小,登山者会移动得很慢。然而,如果 太大,登山者则可能会错过山谷。

现在你应该记得,sigmoid 是一个连续函数,并且可以计算其导数。可以证明,sigmoid 如下所示:

它的导数为:

ReLU 在 0 处不可导。然而,我们可以通过选择将 01 作为 0 处的导数,从而将其扩展为一个定义在整个领域上的函数。ReLU 的逐点导数 如下所示:

一旦我们得到了导数,就可以使用梯度下降技术来优化网络。Keras 使用其后台(无论是 TensorFlow 还是 Theano)来为我们计算导数,所以我们无需担心实现或计算它。我们只需选择激活函数,Keras 就会为我们计算其导数。

神经网络本质上是多个函数的组合,包含成千上万,有时甚至数百万个参数。每一层网络都会计算一个函数,其误差应当被最小化,以提高在学习阶段观察到的准确率。当我们讨论反向传播时,我们会发现最小化的过程比我们的小示例要复杂一些。然而,它仍然基于通过下降谷底的直观理解。

Keras 实现了一个快速的梯度下降变种,称为随机梯度下降SGD),以及另外两种更先进的优化技术,分别是RMSpropAdam。RMSprop 和 Adam 除了包含 SGD 的加速成分外,还引入了动量(一个速度分量)。这使得它们在加速收敛的同时,也需要更多的计算。Keras 支持的优化器完整列表可以查看keras.io/optimizers/。到目前为止,SGD 是我们的默认选择。那么现在我们来试试另外两种优化器。非常简单,我们只需要修改几行代码:

from keras.optimizers import RMSprop, Adam
...
OPTIMIZER = RMSprop() # optimizer,

就这样。让我们按以下截图所示进行测试:

正如你在之前的截图中看到的,RMSprop 比 SDG 更快,因为我们能够在训练集上获得 97.97%的准确率,在验证集上为 97.59%,在测试集上为 97.84%,仅用 20 次迭代就超越了 SDG。为了完整起见,让我们看看随着周期数的增加,准确率和损失的变化,如以下图表所示:

好的,让我们试试另一个优化器,Adam()。它非常简单,如下所示:

OPTIMIZER = Adam() # optimizer

正如我们所看到的,Adam 稍微好一点。使用 Adam 时,在 20 次迭代后,我们在训练集上的准确率为 98.28%,验证集为 98.03%,测试集为 97.93%,如以下图表所示:

这是我们的第五个变种,请记住,我们的初始基准是 92.36%。

到目前为止,我们取得了逐步的进展;然而,现在的提升变得越来越困难。请注意,我们正在以 30%的丢弃率进行优化。为了完整起见,报告不同丢弃率下的测试集准确率可能会很有帮助,且选择Adam()作为优化器,如以下图所示:

增加训练的周期数

让我们再试一次,将训练周期数从 20 增加到 200。不幸的是,这样的选择让我们的计算时间增加了 10 倍,但并没有带来任何提升。实验失败了,但我们学到了一个重要的经验:如果我们花更多时间进行学习,并不一定会有所改善。学习更多的是采用聪明的技巧,而不一定是花费的计算时间。让我们在以下图表中跟踪我们第六个变种的表现:

控制优化器学习率

还有一个尝试是改变优化器的学习参数。正如下图所示,最佳值接近0.001,这是优化器的默认学习率。很好!Adam 优化器开箱即用效果不错:

增加内部隐藏神经元的数量

我们还可以尝试另一种方法,即改变内部隐藏神经元的数量。我们报告了随着隐藏神经元数量增加而进行的实验结果。从下图中可以看到,随着模型复杂度的增加,运行时间显著增加,因为需要优化的参数越来越多。然而,随着网络的扩展,通过增加网络规模获得的收益越来越小:

在下图中,我们展示了随着隐藏神经元数量的增加,每次迭代所需的时间:

以下图表展示了随着隐藏神经元数量的增加,准确率的变化:

增加批处理计算的大小

梯度下降法试图最小化训练集提供的所有示例上的成本函数,同时也针对输入中提供的所有特征。随机梯度下降是一个更便宜的变种,它只考虑BATCH_SIZE个示例。因此,让我们通过改变这个参数来观察它的行为。正如你所看到的,最佳准确率出现在BATCH_SIZE=128时:

总结识别手写图表的实验

所以,总结一下:通过五种不同的变体,我们将性能从 92.36%提高到了 97.93%。首先,我们在 Keras 中定义了一个简单的网络层。然后,我们通过添加一些隐藏层来提高性能。接着,我们通过为网络添加随机丢弃层并实验不同类型的优化器来提高测试集上的性能。当前的结果总结在以下表格中:

模型/准确率 训练 验证 测试
简单模型 92.36% 92.37% 92.22%
两个隐藏层(128) 94.50% 94.63% 94.41%
Dropout(30%) 98.10% 97.73% 97.7%(200 次迭代)
RMSprop 97.97% 97.59% 97.84%(20 次迭代)
Adam 98.28% 98.03% 97.93%(20 次迭代)

然而,接下来的两个实验并没有提供显著的改进。增加内部神经元的数量会创建更复杂的模型,并需要更昂贵的计算,但它仅提供了边际性的提升。如果我们增加训练轮次,也会得到相同的结果。最后一个实验是改变优化器的BATCH_SIZE

采用正则化来避免过拟合

直观上,一个好的机器学习模型应该在训练数据上达到较低的误差。从数学上讲,这等同于最小化给定训练数据上的损失函数,这是由以下公式表示的:

然而,这可能还不足以解决问题。模型可能会变得过于复杂,以捕捉训练数据中固有的所有关系。复杂度的增加可能会带来两个负面后果。首先,复杂模型可能需要大量的时间来执行。其次,复杂模型可能在训练数据上表现非常好——因为它记住了训练数据中固有的所有关系,但在验证数据上表现不佳——因为模型无法在新的未见过的数据上进行泛化。再强调一次,学习更多的是关于泛化,而不是记忆。下图表示了一个典型的损失函数,在验证集和训练集上都逐渐下降。然而,在某一点,验证集上的损失开始增加,这是由于过拟合造成的:

一般来说,如果在训练过程中,我们发现损失在初期下降后,在验证集上反而开始增加,那么我们就遇到了过拟合问题,即模型复杂度过高导致过拟合训练数据。事实上,过拟合是机器学习中用来简洁描述这一现象的术语。

为了解决过拟合问题,我们需要一种方法来捕捉模型的复杂性,也就是说,模型的复杂度有多大。解决方案是什么呢?其实,模型不过是一个权重向量。因此,模型的复杂度可以方便地通过非零权重的数量来表示。换句话说,如果我们有两个模型,M1M2,它们在损失函数上的表现几乎相同,那么我们应该选择权重非零数量最少的最简单模型。我们可以使用一个超参数 ⅄>=0 来控制拥有简单模型的重要性,如下公式所示:

机器学习中使用的正则化方法有三种不同类型:

  • L1 正则化(也称为套索回归):模型的复杂度表现为权重绝对值的总和

  • L2 正则化(也称为岭回归):模型的复杂度表现为权重平方和的总和

  • 弹性网正则化:模型的复杂度通过前两种方法的组合来捕捉

请注意,正则化的相同理念可以独立应用于权重、模型和激活函数。

因此,调整正则化可以是提升网络性能的好方法,特别是在出现明显过拟合的情况下。这一组实验留给有兴趣的读者自行完成。

注意,Keras 支持 l1、l2 和弹性网络正则化。添加正则化非常简单;例如,在这里,我们为核(权重W)添加了一个l2正则化器:

from keras import regularizers model.add(Dense(64, input_dim=64, kernel_regularizer=regularizers.l2(0.01)))

可用参数的完整描述可见于:keras.io/regularizers/

超参数调优

前述实验让我们了解了调整网络的机会。然而,适用于此示例的内容不一定适用于其他示例。对于给定的网络,确实存在多个可以优化的参数(例如hidden neurons的数量、BATCH_SIZEepochs的数量,以及根据网络复杂性调整的更多参数)。

超参数调优是寻找那些最小化成本函数的最佳参数组合的过程。关键思想是,如果我们有n个参数,那么我们可以想象这些参数定义了一个n维的空间,目标是找到该空间中对应于成本函数最优值的点。一种实现此目标的方法是,在该空间中创建一个网格,并系统地检查每个网格顶点对应的成本函数值。换句话说,参数被分成不同的桶,然后通过暴力搜索方法检查不同的值组合。

预测输出

当网络被训练完成后,它自然可以用于预测。在 Keras 中,这非常简单。我们可以使用以下方法:

# calculate predictions
predictions = model.predict(X)

对于给定的输入,可以计算出多种类型的输出,包括一种方法:

  • model.evaluate():用于计算损失值

  • model.predict_classes():用于计算类别输出

  • model.predict_proba():用于计算类别概率

反向传播的实用概述

多层感知机通过一种叫做反向传播的过程从训练数据中学习。这个过程可以描述为一种在错误被检测到时逐步进行修正的方式。让我们来看看这是如何运作的。

记住,每个神经网络层都有一组关联的权重,用于确定给定输入集的输出值。此外,记住一个神经网络可以有多个隐藏层。

一开始,所有的权重都被随机分配。然后,网络会对训练集中的每个输入进行激活:值从输入阶段通过隐藏层传播向前,最终到达输出阶段进行预测(注意,我们通过用绿色虚线表示一些值来简化了下图,但实际上所有的值都会通过网络向前传播):

由于我们知道训练集中真实的观测值,因此可以计算预测中所犯的错误。反向传播的关键直觉是将误差反向传播,并使用适当的优化算法,如梯度下降,来调整神经网络权重,以减少误差(为了简单起见,这里仅表示一些误差值):

从输入到输出的前向传播过程以及误差的反向传播会重复多次,直到误差降到预定的阈值以下。整个过程在以下图示中表示:

特征表示输入,标签在这里用于驱动学习过程。模型以这样一种方式更新,使得损失函数逐步最小化。在神经网络中,真正重要的不是单个神经元的输出,而是每一层中调整的集体权重。因此,网络逐渐调整其内部权重,以便增加正确预测的标签数量。当然,使用正确的特征集和拥有高质量的标签数据对于最小化学习过程中的偏差至关重要。

朝着深度学习方法迈进

在进行手写数字识别时,我们得出结论:当我们接近 99%的准确率时,进一步提升变得越来越困难。如果我们想要更多的改进,显然需要一个新思路。我们缺少了什么呢?思考一下。

基本的直觉是,到目前为止,我们丢失了与图像的局部空间性相关的所有信息。特别是,这段代码将位图(表示每个手写数字)转换为一个平坦的向量,其中空间局部性已经丢失:

#X_train is 60000 rows of 28x28 values --> reshaped in 60000 x 784
X_train = X_train.reshape(60000, 784)
X_test = X_test.reshape(10000, 784)

然而,这并不是我们大脑的工作方式。记住,我们的视觉是基于多个皮层层次的,每个层次识别越来越多的结构化信息,同时仍然保留局部性。首先,我们看到单个像素,然后从中识别简单的几何形状,再然后识别更多复杂的元素,如物体、面孔、人类身体、动物等等。

在第三章,使用卷积神经网络进行深度学习,我们将看到一种特定类型的深度学习网络,称为卷积神经网络CNN),它通过考虑保留图像中的空间局部性(更一般地说,任何类型的信息)以及通过逐级抽象学习的思想发展而来:通过一层,你只能学习简单的模式;而通过多层,你可以学习多种模式。在讨论 CNN 之前,我们需要讨论 Keras 架构的一些方面,并对一些额外的机器学习概念做一个实际的介绍。接下来的章节将讨论这些内容。

总结

在本章中,您学习了神经网络的基础知识,更具体地说,了解了感知机是什么、多层感知机是什么、如何在 Keras 中定义神经网络、如何在建立良好的基线后逐步改进指标,以及如何微调超参数空间。此外,您现在还对一些有用的激活函数(sigmoid 和 ReLU)有了直观的理解,并了解了如何通过基于梯度下降、随机梯度下降或更复杂方法(如 Adam 和 RMSprop)的反向传播算法训练网络。

在下一章中,我们将学习如何在 AWS、微软 Azure、谷歌云以及您自己的机器上安装 Keras。此外,我们还将概述 Keras 的 API。

第二章:Keras 安装与 API

在上一章中,我们讨论了神经网络的基本原理,并提供了一些能够识别 MNIST 手写数字的网络示例。

本章将讲解如何安装 Keras、Theano 和 TensorFlow。我们将逐步介绍如何使环境正常运行,并在短时间内从直觉走向实际的神经网络。接下来,我们将讨论如何在基于容器的 Docker 化基础设施上安装这些工具,并在 Google GCP、Amazon AWS 和 Microsoft Azure 云平台上进行安装。此外,我们还将介绍 Keras API 的概述,以及一些常用操作,例如加载和保存神经网络的架构与权重、早期停止、历史保存、检查点以及与 TensorBoard 和 Quiver 的交互。让我们开始吧。

到本章结束时,我们将涵盖以下主题:

  • 安装和配置 Keras

  • Keras 架构

安装 Keras

在接下来的章节中,我们将展示如何在多个平台上安装 Keras。

第 1 步 — 安装一些有用的依赖项

首先,我们安装 numpy 包,它提供对大型多维数组和矩阵的支持,以及高级数学函数。然后安装 scipy,这是一个用于科学计算的库。之后,可以安装 scikit-learn,这是一个被认为是机器学习的 Python 瑞士军刀的包。在本例中,我们将用它来进行数据探索。可选地,可以安装 pillow,一个用于图像处理的库,以及 h5py,这是一个用于数据序列化的库,Keras 用它来保存模型。只需要一条命令行即可安装所需的所有内容。或者,您可以安装 Anaconda Python,它会自动安装 numpyscipyscikit-learnh5pypillow 和其他很多科学计算所需的库(更多信息请参考:批量归一化:通过减少内部协变量偏移加速深度网络训练,作者 S. Ioffe 和 C. Szegedy,arXiv.org/abs/1502.03167,2015)。您可以在 docs.continuum.io/anaconda/pkg-docs 查找 Anaconda Python 中可用的包。以下截图展示了如何为我们的工作安装这些包:

第 2 步 — 安装 Theano

我们可以使用 pip 来安装 Theano,如下图所示:

第 3 步 — 安装 TensorFlow

现在我们可以使用 TensorFlow 官方网站上的说明来安装 TensorFlow,www.tensorflow.org/versions/r0.11/get_started/os_setup.html#pip-installation。同样,我们只是使用 pip 安装正确的包,如下图所示。例如,如果我们需要使用 GPU,那么选择适当的包非常重要:

第 4 步 — 安装 Keras

现在我们可以简单地安装 Keras 并开始测试已安装的环境。非常简单;我们再次使用 pip,如下面的截图所示:

第 5 步 — 测试 Theano、TensorFlow 和 Keras

现在让我们测试一下环境。首先来看一下如何在 Theano 中定义 sigmoid 函数。如你所见,这非常简单;我们只需写出数学公式并在矩阵上按元素计算该函数。只需运行 Python Shell 并按如下截图所示写下代码,即可得到结果:

所以,Theano 可以正常工作。接下来,我们通过简单地导入 MNIST 数据集来测试 TensorFlow,如下截图所示。在第一章《神经网络基础》中,我们已经看到了几个 Keras 网络的实际示例:

配置 Keras

Keras 具有一个非常简洁的配置文件。我们通过 vi 会话加载它。参数非常简单:

参数
image_dim_ordering 可以是 tf 表示 TensorFlow 的图像顺序,或者 th 表示 Theano 的图像顺序
epsilon 计算过程中使用的 epsilon
floatx 可以是 float32float64
backend 可以是 tensorflowtheano

image_dim_orderingth 值会为你提供一个相对不直观的图像维度顺序(深度、宽度和高度),而不是 tf 的(宽度、高度和深度)。以下是我机器上的默认参数:

如果你安装了支持 GPU 的 TensorFlow 版本,那么当 TensorFlow 被选作后端时,Keras 会自动使用你配置的 GPU。

在 Docker 上安装 Keras

启动 TensorFlow 和 Keras 的最简单方法之一是运行在 Docker 容器中。一个方便的解决方案是使用社区创建的深度学习预定义 Docker 镜像,它包含所有流行的深度学习框架(TensorFlow、Theano、Torch、Caffe 等)。请参考 GitHub 仓库 github.com/saiprashanths/dl-docker 获取代码文件。假设你已经启动并运行了 Docker(有关更多信息,请参考 www.docker.com/products/overview),安装过程非常简单,如下所示:

下一个截图中显示的内容大致是:从 Git 获取图像后,我们构建 Docker 镜像:

在这个截图中,我们可以看到如何运行它:

从容器内,可以启用对 Jupyter Notebooks 的支持(有关更多信息,请参见 jupyter.org/):

从主机机器直接通过端口访问:

还可以通过下面截图中的命令访问 TensorBoard(有关更多信息,请参见 www.tensorflow.org/how_tos/summaries_and_tensorboard/),该命令将在下一部分讨论:

运行前述命令后,您将被重定向到以下页面:

在 Google Cloud ML 上安装 Keras

在 Google Cloud 上安装 Keras 非常简单。首先,我们可以安装 Google Cloud (有关可下载文件,请参见 cloud.google.com/sdk/),它是 Google Cloud Platform 的命令行界面;然后我们可以使用 CloudML,这是一个托管服务,允许我们轻松构建使用 TensorFlow 的机器学习模型。在使用 Keras 之前,让我们使用 Google Cloud 和 TensorFlow 来训练 GitHub 上提供的 MNIST 示例。代码是本地的,训练发生在云端:

在下一个截图中,您可以看到如何运行训练会话:

我们可以使用 TensorBoard 显示交叉熵如何随着迭代减少:

在下一个截图中,我们看到交叉熵的图形:

现在,如果我们想在 TensorFlow 上使用 Keras,只需从 PyPI 下载 Keras 源代码(有关可下载文件,请参见 pypi.Python.org/pypi/Keras/1.2.0 或更高版本),然后像使用 CloudML 包解决方案一样直接使用 Keras,如以下示例所示:

这里,trainer.task2.py 是一个示例脚本:

from keras.applications.vgg16 import VGG16
from keras.models import Model
from keras.preprocessing import image
from keras.applications.vgg16 import preprocess_input
import numpy as np

# pre-built and pre-trained deep learning VGG16 model
base_model = VGG16(weights='imagenet', include_top=True)
for i, layer in enumerate(base_model.layers):
  print (i, layer.name, layer.output_shape)

在 Amazon AWS 上安装 Keras

在 Amazon 上安装 TensorFlow 和 Keras 非常简单。事实上,可以使用一个名为 TFAMI.v3 的预构建 AMI,该 AMI 是开放且免费的(有关更多信息,请参见 github.com/ritchieng/tensorflow-aws-ami),如下所示:

此 AMI 在不到五分钟内运行 TensorFlow,并支持 TensorFlow、Keras、OpenAI Gym 及所有依赖项。截至 2017 年 1 月,它支持以下内容:

  • TensorFlow 0.12

  • Keras 1.1.0

  • TensorLayer 1.2.7

  • CUDA 8.0

  • CuDNN 5.1

  • Python 2.7

  • Ubuntu 16.04

此外,TFAMI.v3 可在 P2 计算实例上运行(有关更多信息,请参见 aws.amazon.com/ec2/instance-types/#p2),如以下截图所示:

P2 实例的一些特点如下:

TFAMI.v3 也适用于 G2 计算实例(更多信息,请参阅 aws.amazon.com/ec2/instance-types/#g2)。G2 实例的一些特点如下:

  • Intel Xeon E5-2670(Sandy Bridge)处理器

  • NVIDIA GPU,每个具有 1,536 个 CUDA 核心和 4 GB 显存

在 Microsoft Azure 上安装 Keras

在 Azure 上安装 Keras 的一种方法是安装 Docker 支持,然后获取包含 TensorFlow 和 Keras 的容器化版本。在线上,您也可以找到关于如何通过 Docker 安装 Keras 和 TensorFlow 的详细说明,但这本质上是我们在前面章节中已经看到的内容(更多信息,请参阅 blogs.msdn.microsoft.com/uk_faculty_connection/2016/09/26/tensorflow-on-docker-with-microsoft-azure/)。

如果您仅使用 Theano 作为后端,那么通过加载在 Cortana Intelligence Gallery 上提供的预构建包,Keras 只需点击即可运行(更多信息,请参阅 gallery.cortanaintelligence.com/Experiment/Theano-Keras-1)。

以下示例展示了如何将 Theano 和 Keras 作为 ZIP 文件直接导入 Azure ML,并在执行 Python 脚本模块中使用它们。此示例由 Hai Ning 提供(更多信息,请参阅 goo.gl/VLR25o),本质上是在 azureml_main() 方法内运行 Keras 代码:

# The script MUST contain a function named azureml_main
# which is the entry point for this module.

# imports up here can be used to
import pandas as pd
import theano
import theano.tensor as T
from theano import function
from keras.models import Sequential
from keras.layers import Dense, Activation
import numpy as np
# The entry point function can contain up to two input arguments:
#   Param<dataframe1>: a pandas.DataFrame
#   Param<dataframe2>: a pandas.DataFrame
def azureml_main(dataframe1 = None, dataframe2 = None):
    # Execution logic goes here
    # print('Input pandas.DataFrame #1:rnrn{0}'.format(dataframe1))

    # If a zip file is connected to the third input port is connected,
    # it is unzipped under ".Script Bundle". This directory is added
    # to sys.path. Therefore, if your zip file contains a Python file
    # mymodule.py you can import it using:
    # import mymodule
    model = Sequential()
    model.add(Dense(1, input_dim=784, activation="relu"))
    model.compile(optimizer='rmsprop', loss='binary_crossentropy', metrics=['accuracy'])
    data = np.random.random((1000,784))
    labels = np.random.randint(2, size=(1000,1))
    model.fit(data, labels, nb_epoch=10, batch_size=32)
    model.evaluate(data, labels)

    return dataframe1,

在此截图中,您看到一个使用 Microsoft Azure ML 运行 Theano 和 Keras 的示例:

Keras API

Keras 拥有模块化、简洁且易于扩展的架构。Keras 的作者 Francois Chollet 说:

该库的开发重点是实现快速实验。能够以尽可能短的延迟从想法到结果是进行良好研究的关键。

Keras 定义了在 TensorFlow(更多信息,请参阅 github.com/tensorflow/tensorflow)或 Theano(更多信息,请参阅 github.com/Theano/Theano)之上运行的高层神经网络。具体来说:

  • 模块化:模型要么是一个序列,要么是一个独立模块的图,这些模块可以像 LEGO 积木一样组合在一起,构建神经网络。即,该库预定义了大量的模块,包含不同类型的神经网络层、损失函数、优化器、初始化方案、激活函数和正则化方案。

  • 极简主义:该库是用 Python 实现的,每个模块都简洁且自描述。

  • 易于扩展:该库可以通过新功能进行扩展,正如我们将在第七章中描述的那样,附加深度学习模型

开始使用 Keras 架构

在这一部分,我们回顾了用于定义神经网络的最重要的 Keras 组件。首先,我们定义张量的概念,然后讨论组合预定义模块的不同方式,最后概述最常用的模块。

什么是张量?

Keras 使用 Theano 或 TensorFlow 执行对张量的高效计算。但张量到底是什么?张量其实就是一个多维数组或矩阵。这两个后端都能够高效地进行符号计算,张量是创建神经网络的基本构建块。

在 Keras 中组合模型

在 Keras 中有两种组合模型的方式。它们如下:

  • 顺序组合

  • 功能组合

让我们详细看看每一个。

顺序组合

第一种是顺序组合,将不同的预定义模型按顺序堆叠在一起,形成类似于堆栈或队列的线性层次结构。在第一章,神经网络基础中,我们看到了几个顺序流水线的示例。例如:

model = Sequential()
model.add(Dense(N_HIDDEN, input_shape=(784,)))
model.add(Activation('relu'))
model.add(Dropout(DROPOUT))
model.add(Dense(N_HIDDEN))
model.add(Activation('relu'))
model.add(Dropout(DROPOUT))
model.add(Dense(nb_classes))
model.add(Activation('softmax'))
model.summary()

功能组合

组合模块的第二种方式是通过功能性 API,这种方式可以定义复杂的模型,如有向无环图、具有共享层的模型或多输出模型。我们将在第七章中看到这样的示例,附加深度学习模型

预定义神经网络层概览

Keras 有许多预构建的层。让我们回顾一下最常用的层,并指出这些层在何种章节中最常使用。

常规密集

一个密集模型是一个全连接的神经网络层。我们已经在第一章,神经网络基础中看到了使用示例。这里是一个带有参数定义的原型:

keras.layers.core.Dense(units, activation=None, use_bias=True, kernel_initializer='glorot_uniform', bias_initializer='zeros', kernel_regularizer=None, bias_regularizer=None, activity_regularizer=None, kernel_constraint=None, bias_constraint=None)

循环神经网络——简单的、LSTM 和 GRU

循环神经网络是一类利用输入序列特性的神经网络。这类输入可以是文本、语音、时间序列或任何其他序列中元素的出现依赖于前面元素的情况。我们将在第六章,循环神经网络——RNN中讨论简单的 LSTM 和 GRU 循环神经网络。这里展示了一些带有参数定义的原型:

keras.layers.recurrent.Recurrent(return_sequences=False, go_backwards=False, stateful=False, unroll=False, implementation=0)

keras.layers.recurrent.SimpleRNN(units, activation='tanh', use_bias=True, kernel_initializer='glorot_uniform', recurrent_initializer='orthogonal', bias_initializer='zeros', kernel_regularizer=None, recurrent_regularizer=None, bias_regularizer=None, activity_regularizer=None, kernel_constraint=None, recurrent_constraint=None, bias_constraint=None, dropout=0.0, recurrent_dropout=0.0)

keras.layers.recurrent.GRU(units, activation='tanh', recurrent_activation='hard_sigmoid', use_bias=True, kernel_initializer='glorot_uniform', recurrent_initializer='orthogonal', bias_initializer='zeros', kernel_regularizer=None, recurrent_regularizer=None, bias_regularizer=None, activity_regularizer=None, kernel_constraint=None, recurrent_constraint=None, bias_constraint=None, dropout=0.0, recurrent_dropout=0.0)

keras.layers.recurrent.LSTM(units, activation='tanh', recurrent_activation='hard_sigmoid', use_bias=True, kernel_initializer='glorot_uniform', recurrent_initializer='orthogonal', bias_initializer='zeros', unit_forget_bias=True, kernel_regularizer=None, recurrent_regularizer=None, bias_regularizer=None, activity_regularizer=None, kernel_constraint=None, recurrent_constraint=None, bias_constraint=None, dropout=0.0, recurrent_dropout=0.0)

卷积层和池化层

卷积网络(ConvNets)是一类使用卷积和池化操作,通过逐步学习基于不同抽象层次的复杂模型的神经网络。这种通过逐步抽象的学习方式,类似于人类大脑中经过数百万年演化的视觉模型。几年前,人们称其为深度,通常指的是 3 到 5 层,而现在它已经发展到 100 到 200 层。我们将在第三章,深度学习与卷积网络中讨论卷积神经网络。以下是带有参数定义的原型:

keras.layers.convolutional.Conv1D(filters, kernel_size, strides=1, padding='valid', dilation_rate=1, activation=None, use_bias=True, kernel_initializer='glorot_uniform', bias_initializer='zeros', kernel_regularizer=None, bias_regularizer=None, activity_regularizer=None, kernel_constraint=None, bias_constraint=None)

keras.layers.convolutional.Conv2D(filters, kernel_size, strides=(1, 1), padding='valid', data_format=None, dilation_rate=(1, 1), activation=None, use_bias=True, kernel_initializer='glorot_uniform', bias_initializer='zeros', kernel_regularizer=None, bias_regularizer=None, activity_regularizer=None, kernel_constraint=None, bias_constraint=None)

keras.layers.pooling.MaxPooling1D(pool_size=2, strides=None, padding='valid')

keras.layers.pooling.MaxPooling2D(pool_size=(2, 2), strides=None, padding='valid', data_format=None)

正则化

正则化是一种防止过拟合的方法。我们已经在第一章,神经网络基础中看到过使用示例。多个层有用于正则化的参数。以下是常用于全连接和卷积模块的正则化参数列表:

  • kernel_regularizer:应用于权重矩阵的正则化函数

  • bias_regularizer:应用于偏置向量的正则化函数

  • activity_regularizer:应用于层输出(激活)的正则化函数

此外,还可以使用 Dropout 进行正则化,这通常是一个非常有效的选择

keras.layers.core.Dropout(rate, noise_shape=None, seed=None)

其中:

  • rate:它是一个介于 0 和 1 之间的浮动数,表示丢弃的输入单元的比例

  • noise_shape:它是一个一维整数张量,表示将与输入相乘的二进制丢弃掩码的形状

  • seed:它是一个整数,用作随机种子

批量归一化

批量归一化(欲了解更多信息,请参考www.colwiz.com/cite-in-google-docs/cid=f20f9683aaf69ce)是一种加速学习并通常实现更高准确率的方法。我们将在第四章,生成对抗网络与 WaveNet中讨论 GAN 时展示使用示例。以下是带有参数定义的原型:

keras.layers.normalization.BatchNormalization(axis=-1, momentum=0.99, epsilon=0.001, center=True, scale=True, beta_initializer='zeros', gamma_initializer='ones', moving_mean_initializer='zeros', moving_variance_initializer='ones', beta_regularizer=None, gamma_regularizer=None, beta_constraint=None, gamma_constraint=None)

预定义激活函数概述

激活函数包括常用的函数,如 Sigmoid、线性、双曲正切和 ReLU。我们在第一章《神经网络基础》中看到了一些激活函数的示例,更多示例将在后续章节中呈现。以下图示为 Sigmoid、线性、双曲正切和 ReLU 激活函数的示例:

Sigmoid 线性
双曲正切 ReLU

损失函数概述

损失函数(或目标函数,或优化得分函数;更多信息,请参见keras.io/losses/)可以分为四类:

  • 准确率用于分类问题。可选择多种方法:binary_accuracy(二分类问题中所有预测的平均准确率),categorical_accuracy(多分类问题中所有预测的平均准确率),sparse_categorical_accuracy(适用于稀疏目标),以及top_k_categorical_accuracy(当目标类别位于提供的 top_k 预测之内时成功)。

  • 错误损失,用于衡量预测值与实际观察值之间的差异。可选择多种方法:mse(预测值与目标值之间的均方误差),rmse(预测值与目标值之间的均方根误差),mae(预测值与目标值之间的均绝对误差),mape(预测值与目标值之间的均百分比误差),以及msle(预测值与目标值之间的均平方对数误差)。

  • 铰链损失,通常用于训练分类器。有两个版本:hinge 定义为 平方铰链 定义为铰链损失的平方值。

  • 类别损失用于计算分类问题的交叉熵。有多个版本,包括二元交叉熵(更多信息,请参见en.wikipedia.org/wiki/Cross_entropy),以及类别交叉熵。

我们在第一章《神经网络基础》中看到了一些目标函数的示例,更多示例将在后续章节中呈现。

度量函数概述

度量函数(更多信息,请参见keras.io/metrics/)类似于目标函数,唯一的区别是评估度量函数时得到的结果不会用于训练模型。我们在第一章《神经网络基础》中看到了一些度量函数的示例,更多示例将在后续章节中呈现。

优化器概述

优化器包括 SGD、RMSprop 和 Adam。我们在第一章《神经网络基础》中看到了几个优化器的示例,更多的示例(如 Adagrad 和 Adadelta;更多信息,请参考keras.io/optimizers/)将在后续章节中展示。

一些有用的操作

在这里,我们报告了一些可以通过 Keras API 执行的实用操作。目标是简化网络的创建、训练过程和中间结果的保存。

保存和加载模型的权重和架构

模型架构可以轻松保存和加载,如下所示:

# save as JSON json_string = model.to_json()
# save as YAML yaml_string = model.to_yaml() 
# model reconstruction from JSON: from keras.models import model_from_json model = model_from_json(json_string) # model reconstruction from YAML model = model_from_yaml(yaml_string)

模型参数(权重)可以轻松保存和加载,如下所示:

from keras.models import load_model model.save('my_model.h5')
# creates a HDF5 file 'my_model.h5' del model
# deletes the existing model
# returns a compiled model
# identical to the previous one model = load_model('my_model.h5')

用于自定义训练过程的回调函数

当某个指标停止改善时,可以使用适当的callback停止训练过程:

keras.callbacks.EarlyStopping(monitor='val_loss', min_delta=0,  
patience=0, verbose=0, mode='auto')

通过定义如下的callback,可以保存损失历史:

class LossHistory(keras.callbacks.Callback):     def on_train_begin(self, logs={}):         self.losses = []     def on_batch_end(self, batch, logs={}):         self.losses.append(logs.get('loss')) model = Sequential() model.add(Dense(10, input_dim=784, init='uniform')) model.add(Activation('softmax')) model.compile(loss='categorical_crossentropy', optimizer='rmsprop') history = LossHistory() model.fit(X_train,Y_train, batch_size=128, nb_epoch=20,  
verbose=0, callbacks=[history]) print history.losses

检查点

检查点是一个过程,它定期保存应用程序的状态快照,以便在失败时从最后保存的状态重新启动应用程序。这在训练深度学习模型时非常有用,因为这通常是一个耗时的任务。深度学习模型在任何时刻的状态就是该时刻的模型权重。Keras 将这些权重以 HDF5 格式保存(更多信息,请参考www.hdfgroup.org/),并通过其回调 API 提供检查点功能。

一些可能需要使用检查点的场景包括以下几点:

第一和第二种场景可以通过在每个 epoch 后保存检查点来处理,这可以通过默认使用ModelCheckpoint回调来实现。以下代码演示了如何在 Keras 中训练深度学习模型时添加检查点:

from __future__ import division, print_function 
from keras.callbacks import ModelCheckpoint 
from keras.datasets import mnist 
from keras.models import Sequential 
from keras.layers.core import Dense, Dropout 
from keras.utils import np_utils 
import numpy as np 
import os 

BATCH_SIZE = 128 
NUM_EPOCHS = 20 
MODEL_DIR = "/tmp" 

(Xtrain, ytrain), (Xtest, ytest) = mnist.load_data() 
Xtrain = Xtrain.reshape(60000, 784).astype("float32") / 255 
Xtest = Xtest.reshape(10000, 784).astype("float32") / 255 
Ytrain = np_utils.to_categorical(ytrain, 10) 
Ytest = np_utils.to_categorical(ytest, 10) 
print(Xtrain.shape, Xtest.shape, Ytrain.shape, Ytest.shape) 

model = Sequential() 
model.add(Dense(512, input_shape=(784,), activation="relu")) 
model.add(Dropout(0.2)) 
model.add(Dense(512, activation="relu")) 
model.add(Dropout(0.2)) 
model.add(Dense(10, activation="softmax")) 

model.compile(optimizer="rmsprop", loss="categorical_crossentropy", 
              metrics=["accuracy"]) 

# save best model 
checkpoint = ModelCheckpoint( 
    filepath=os.path.join(MODEL_DIR, "model-{epoch:02d}.h5")) 
model.fit(Xtrain, Ytrain, batch_size=BATCH_SIZE, nb_epoch=NUM_EPOCHS, 
          validation_split=0.1, callbacks=[checkpoint])

第三种场景涉及监控某个指标,如验证准确率或损失,并且仅在当前指标优于之前保存的检查点时才保存检查点。Keras 提供了一个额外的参数save_best_only,在实例化检查点对象时需要将其设置为true以支持此功能。

使用 TensorBoard 和 Keras

Keras 提供了一个回调函数,用于保存训练和测试指标,以及模型中不同层的激活直方图:

keras.callbacks.TensorBoard(log_dir='./logs', histogram_freq=0,  
write_graph=True, write_images=False)

保存的数据可以通过在命令行启动的 TensorBoard 进行可视化:

tensorboard --logdir=/full_path_to_your_logs

使用 Quiver 和 Keras

在第三章,卷积神经网络与深度学习中,我们将讨论卷积神经网络(ConvNets),这是一种用于处理图像的高级深度学习技术。这里我们预览一下 Quiver(更多信息请参见github.com/jakebian/quiver),它是一个用于以交互方式可视化卷积神经网络特征的工具。安装过程相当简单,安装后,Quiver 可以通过一行命令来使用:

pip install quiver_engine 

from quiver_engine import server     server.launch(model)

这将在 localhost:5000 启动可视化。Quiver 允许你像下面的示例一样直观地检查神经网络:

总结

本章我们讨论了如何在以下环境中安装 Theano、TensorFlow 和 Keras:

  • 在本地机器上

  • 基于容器的 Docker 化基础设施

  • 在云端使用 Google GCP、Amazon AWS 和 Microsoft Azure

除此之外,我们还查看了几个模块,这些模块定义了 Keras API 以及一些常用操作,如加载和保存神经网络的架构和权重、早期停止、历史保存、检查点、与 TensorBoard 的交互以及与 Quiver 的交互。

在下一章中,我们将介绍卷积网络的概念,它是深度学习中的一项基础创新,已经在多个领域取得了成功应用,从文本、视频到语音,远远超出了最初在图像处理领域的应用。

第三章:使用卷积神经网络的深度学习

在前面的章节中,我们讨论了密集网络,其中每一层都与相邻的层完全连接。我们将这些密集网络应用于分类 MNIST 手写字符数据集。在那种情况下,输入图像中的每个像素都被分配给一个神经元,总共有 784 个(28 x 28 像素)输入神经元。然而,这种策略并没有利用每个图像的空间结构和关系。特别地,这段代码将每个书写数字的位图转换为一个平坦的向量,在这个过程中,空间局部性丧失:

#X_train is 60000 rows of 28x28 values --> reshaped in 60000 x 784
X_train = X_train.reshape(60000, 784)
X_test = X_test.reshape(10000, 784)
o

卷积神经网络(也称为 ConvNet)利用空间信息,因此非常适合分类图像。这些网络使用一种受生物学数据启发的特殊架构,这些数据来自对视觉皮层进行的生理实验。正如我们所讨论的,我们的视觉是基于多个皮层层级的,每一层识别越来越结构化的信息。首先,我们看到单个像素;然后从这些像素中,我们识别出简单的几何形状。接着...越来越复杂的元素,如物体、面孔、人类身体、动物等等。

卷积神经网络确实令人着迷。在短短的时间内,它们成为了一种颠覆性技术,突破了多个领域的所有最先进成果,从文本到视频,再到语音,远远超出了最初的图像处理领域。

本章将涵盖以下主题:

  • 深度卷积神经网络

  • 图像分类

深度卷积神经网络 — DCNN

深度卷积神经网络DCNN)由许多神经网络层组成。通常交替使用两种不同类型的层,卷积层和池化层。每个滤波器的深度从网络的左到右逐渐增加。最后阶段通常由一个或多个全连接层组成:

卷积神经网络之外有三个关键直觉:

  • 局部感受野

  • 共享权重

  • 池化

让我们回顾一下它们。

局部感受野

如果我们希望保持空间信息,那么用像素矩阵表示每张图像是很方便的。然后,一种简单的编码局部结构的方法是将相邻输入神经元的子矩阵连接到下一个层中的一个隐藏神经元。这一个隐藏神经元代表一个局部感受野。需要注意的是,这个操作被称为卷积,它也给这种类型的网络命名。

当然,我们可以通过使用重叠的子矩阵来编码更多的信息。例如,假设每个子矩阵的大小为 5 x 5,并且这些子矩阵与 28 x 28 像素的 MNIST 图像一起使用。这样,我们将能够在下一个隐藏层中生成 23 x 23 的局部感受野神经元。实际上,只有在滑动子矩阵 23 个位置后,才会触及图像的边界。在 Keras 中,每个子矩阵的大小称为步幅长度,这是一个可以在构建网络时进行微调的超参数。

假设我们定义从一层到另一层的特征图。当然,我们可以有多个特征图,它们各自独立地从每个隐藏层中学习。例如,我们可以从 28 x 28 的输入神经元开始处理 MNIST 图像,然后在下一个隐藏层中回调 k 个特征图,每个特征图的大小为 23 x 23 个神经元(同样步幅为 5 x 5)。

共享权重和偏置

假设我们想摆脱行像素表示,转而通过获得在输入图像中放置位置无关的相同特征的能力。一个简单的直觉是对隐藏层中的所有神经元使用相同的一组权重和偏置。这样,每一层都会学习从图像中提取的、位置无关的潜在特征。

假设输入图像的形状是 (256, 256),并且具有三个通道,在 tf(TensorFlow)顺序下表示为 (256, 256, 3)。请注意,在 th(Theano)模式下,通道维度(深度)位于索引 1 位置;而在 tf(TensorFlow)模式下,位于索引 3 位置。

在 Keras 中,如果我们想添加一个卷积层,输出维度为 32,每个滤波器的扩展为 3 x 3,我们将写道:

model = Sequential()
model.add(Conv2D(32, (3, 3), input_shape=(256, 256, 3))

或者,我们将写道:

model = Sequential()
model.add(Conv2D(32, kernel_size=3, input_shape=(256, 256, 3))

这意味着我们在一个 256 x 256 的图像上应用 3 x 3 的卷积,图像有三个输入通道(或输入滤波器),并且结果是 32 个输出通道(或输出滤波器)。

以下是卷积示例:

池化层

假设我们想总结一个特征图的输出。同样,我们可以利用来自单个特征图的输出的空间连续性,将子矩阵的值聚合为一个单一的输出值,这个值合成地描述了与该物理区域相关的意义

最大池化

一种简单而常见的选择是最大池化,它仅输出在该区域内观察到的最大激活值。在 Keras 中,如果我们想定义一个 2 x 2 大小的最大池化层,我们将写道:

model.add(MaxPooling2D(pool_size = (2, 2)))

以下是最大池化示例:

平均池化

另一个选择是平均池化,它仅将一个区域的激活值聚合为该区域观察到的激活值的平均值。

请注意,Keras 实现了大量的池化层,完整的池化层列表可以在以下链接找到:keras.io/layers/pooling/。简而言之,所有的池化操作无非是在给定区域上的汇总操作。

ConvNets 总结

到目前为止,我们已经描述了 ConvNets 的基本概念。卷积神经网络(CNNs)在一维(时间维度)上对音频和文本数据应用卷积和池化操作,在二维(高 × 宽)上对图像进行卷积和池化操作,在三维(高 × 宽 × 时间)上对视频进行卷积和池化操作。对于图像,将滤波器滑动在输入体积上,产生一个映射,显示每个空间位置的滤波器响应。换句话说,ConvNet 有多个滤波器堆叠在一起,它们学习识别特定的视觉特征,而不受图像中位置的影响。网络初期的视觉特征较为简单,随着网络层数加深,特征变得越来越复杂。

DCNN 示例 — LeNet

Yann le Cun 提出了一个名为 LeNet 的卷积神经网络(更多信息参见:Convolutional Networks for Images, Speech, and Time-Series,作者 Y. LeCun 和 Y. Bengio,脑理论神经网络,卷 3361,1995 年),该网络用于识别 MNIST 手写字符,具有对简单几何变换和失真的鲁棒性。这里的关键直觉是,低层交替使用卷积操作和最大池化操作。卷积操作基于精心选择的局部感受野,并为多个特征图共享权重。然后,更高层是基于传统多层感知器(MLP)结构的全连接层,其中包含隐藏层,输出层使用 softmax 激活函数。

Keras 中的 LeNet 代码

要定义 LeNet 代码,我们使用一个卷积 2D 模块,具体如下:

keras.layers.convolutional.Conv2D(filters, kernel_size, padding='valid')

在这里,filters 表示使用的卷积核数量(例如,输出的维度),kernel_size 是一个整数或一个包含两个整数的元组/列表,指定 2D 卷积窗口的宽度和高度(可以是一个单独的整数,以指定所有空间维度的相同值),而 padding='same' 表示使用了填充。这里有两个选项:padding='valid' 表示卷积仅在输入和滤波器完全重叠的地方计算,因此输出小于输入;而 padding='same' 表示输出的尺寸与输入相同,为此,输入周围的区域会用零进行填充。

此外,我们使用 MaxPooling2D 模块:

keras.layers.pooling.MaxPooling2D(pool_size=(2, 2), strides=(2, 2))

在这里,pool_size=(2, 2) 是一个包含两个整数的元组,表示图像在垂直和水平方向上缩小的比例。因此,(2, 2) 将在每个维度上将图像减半,而 strides=(2, 2) 是处理时使用的步长。

现在,让我们回顾一下代码。首先我们导入一些模块:

from keras import backend as K
from keras.models import Sequential
from keras.layers.convolutional import Conv2D
from keras.layers.convolutional import MaxPooling2D
from keras.layers.core import Activation
from keras.layers.core import Flatten
from keras.layers.core import Dense
from keras.datasets import mnist
from keras.utils import np_utils
from keras.optimizers import SGD, RMSprop, Adam
import numpy as np
import matplotlib.pyplot as plt

然后我们定义 LeNet 网络:

#define the ConvNet
class LeNet:
    @staticmethod
    def build(input_shape, classes):
         model = Sequential()
         # CONV => RELU => POOL

我们有一个包含 ReLU 激活的第一卷积阶段,然后是最大池化。我们的网络将学习 20 个卷积滤波器,每个大小为 5 x 5。输出维度与输入形状相同,因此为 28 x 28。请注意,由于Convolution2D是我们管道的第一个阶段,我们还需要定义它的input_shape。最大池化操作实现一个滑动窗口,在层上滑动,并在每个区域垂直和水平方向上以两个像素的步长取最大值:

model.add(Convolution2D(20, kernel_size=5, padding="same",
input_shape=input_shape))
model.add(Activation("relu"))
model.add(MaxPooling2D(pool_size=(2, 2), strides=(2, 2)))
# CONV => RELU => POOL

然后是第二个带 ReLU 激活的卷积阶段,再次进行最大池化。在这种情况下,我们将从之前的 20 增加到 50 个学习的卷积滤波器。在深层增加滤波器的数量是深度学习中常用的技术:

model.add(Conv2D(50, kernel_size=5, border_mode="same"))
model.add(Activation("relu"))
model.add(MaxPooling2D(pool_size=(2, 2), strides=(2, 2)))

然后我们有一个相当标准的扁平化和 500 个神经元的密集网络,后跟一个有 10 类的 softmax 分类器:

# Flatten => RELU layers
model.add(Flatten())
model.add(Dense(500))
model.add(Activation("relu"))
# a softmax classifier
model.add(Dense(classes))
model.add(Activation("softmax"))
return model

恭喜!您刚刚定义了第一个深度学习网络!让我们看看它的视觉效果:

现在我们需要一些额外的代码来训练网络,但这与我们已在第一章中描述的内容非常相似,神经网络基础。这次,我们还展示了打印损失的代码:

# network and training
NB_EPOCH = 20
BATCH_SIZE = 128
VERBOSE = 1
OPTIMIZER = Adam()
VALIDATION_SPLIT=0.2
IMG_ROWS, IMG_COLS = 28, 28 # input image dimensions
NB_CLASSES = 10 # number of outputs = number of digits
INPUT_SHAPE = (1, IMG_ROWS, IMG_COLS)
# data: shuffled and split between train and test sets
(X_train, y_train), (X_test, y_test) = mnist.load_data()
k.set_image_dim_ordering("th")
# consider them as float and normalize
X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
X_train /= 255
X_test /= 255
# we need a 60K x [1 x 28 x 28] shape as input to the CONVNET
X_train = X_train[:, np.newaxis, :, :]
X_test = X_test[:, np.newaxis, :, :]
print(X_train.shape[0], 'train samples')
print(X_test.shape[0], 'test samples')
# convert class vectors to binary class matrices
y_train = np_utils.to_categorical(y_train, NB_CLASSES)
y_test = np_utils.to_categorical(y_test, NB_CLASSES)
# initialize the optimizer and model
model = LeNet.build(input_shape=INPUT_SHAPE, classes=NB_CLASSES)
model.compile(loss="categorical_crossentropy", optimizer=OPTIMIZER,
metrics=["accuracy"])
history = model.fit(X_train, y_train,
batch_size=BATCH_SIZE, epochs=NB_EPOCH,
verbose=VERBOSE, validation_split=VALIDATION_SPLIT)
score = model.evaluate(X_test, y_test, verbose=VERBOSE)
print("Test score:", score[0])
print('Test accuracy:', score[1])
# list all data in history
print(history.history.keys())
# summarize history for accuracy
plt.plot(history.history['acc'])
plt.plot(history.history['val_acc'])
plt.title('model accuracy')
plt.ylabel('accuracy')
plt.xlabel('epoch')
plt.legend(['train', 'test'], loc='upper left')
plt.show()
# summarize history for loss
plt.plot(history.history['loss'])
plt.plot(history.history['val_loss'])
plt.title('model loss')
plt.ylabel('loss')
plt.xlabel('epoch')
plt.legend(['train', 'test'], loc='upper left')
plt.show()

现在让我们运行代码。正如您所看到的,时间显著增加,我们深度网络中的每次迭代现在需要约 134 秒,而在第一章中定义的网络,神经网络基础,每次迭代只需约 1-2 秒。然而,准确率已经达到了新的峰值 99.06%:

让我们绘制模型的准确率和损失,我们可以理解,仅需 4 - 5 次迭代即可达到类似 99.2%的准确率:

在下面的截图中,我们展示了我们模型达到的最终准确率:

让我们看看一些 MNIST 图像,以了解 99.2%的准确率有多好!例如,人们写数字 9 的方式有很多种,在以下图表中显示了其中一种。对于数字 3、7、4 和 5 也是如此。在这张图中,数字1是如此难以识别,甚至可能连人类也会有问题:

到目前为止,我们通过不同的模型总结了所有的进展,如下图所示。我们的简单网络从 92.22%的准确率开始,这意味着大约 100 个手写字符中有 8 个识别错误。然后,通过深度学习架构获得了 7%,达到了 99.20%的准确率,这意味着大约 100 个手写字符中有 1 个识别错误:

理解深度学习的力量

我们可以进行另一个测试,以更好地理解深度学习和卷积神经网络(ConvNet)的强大能力,那就是减少训练集的大小,并观察性能随之下降的情况。一种方法是将 50,000 个样本的训练集拆分成两个不同的子集:

  • 用于训练我们模型的合适训练集将逐步减少其大小(5,900、3,000、1,800、600 和 300 个样本)

  • 用于估算模型训练效果的验证集将由剩余的样本组成

我们的测试集始终固定,包含 10,000 个样本。

在这个设置下,我们将刚定义的深度学习卷积神经网络与第一章中定义的第一个神经网络示例进行比较,神经网络基础。正如我们在下面的图表中看到的那样,我们的深度网络始终优于简单网络,并且当提供的训练样本数量逐步减少时,差距越来越明显。使用 5,900 个训练样本时,深度学习网络的准确率为 96.68%,而简单网络的准确率为 85.56%。更重要的是,即使只有 300 个训练样本,我们的深度学习网络仍然能够达到 72.44%的准确率,而简单网络的准确率则显著下降至 48.26%。所有实验只进行了四次训练迭代。这证明了深度学习所带来的突破性进展。乍一看,这从数学角度来看可能会令人惊讶,因为深度网络有更多的未知数(权重),所以人们可能会认为需要更多的数据点。然而,保持空间信息,加入卷积、池化和特征图是卷积神经网络的创新,这一结构已经在数百万年的时间里得到了优化(因为它的灵感来源于视觉皮层):

关于 MNIST 的最新成果列表可访问:rodrigob.github.io/are_we_there_yet/build/classification_datasets_results.html。截至 2017 年 1 月,最佳结果的错误率为 0.21%。

使用深度学习识别 CIFAR-10 图像

CIFAR-10 数据集包含 60,000 张 32 x 32 像素、3 通道的彩色图像,分为 10 个类别。每个类别包含 6,000 张图像。训练集包含 50,000 张图像,而测试集提供 10,000 张图像。这张来自 CIFAR 数据集的图像(www.cs.toronto.edu/~kriz/cifar.html)展示了 10 个类别中的一些随机示例:

目标是识别先前未见过的图像,并将它们分配到 10 个类别中的一个。让我们定义一个合适的深度网络。

首先,我们导入一些有用的模块,定义几个常量,并加载数据集:

from keras.datasets import cifar10
from keras.utils import np_utils
from keras.models import Sequential
from keras.layers.core import Dense, Dropout, Activation, Flatten
from keras.layers.convolutional import Conv2D, MaxPooling2D
from keras.optimizers import SGD, Adam, RMSprop
import matplotlib.pyplot as plt

# CIFAR_10 is a set of 60K images 32x32 pixels on 3 channels
IMG_CHANNELS = 3
IMG_ROWS = 32
IMG_COLS = 32

#constant
BATCH_SIZE = 128
NB_EPOCH = 20
NB_CLASSES = 10
VERBOSE = 1
VALIDATION_SPLIT = 0.2
OPTIM = RMSprop()

#load dataset
(X_train, y_train), (X_test, y_test) = cifar10.load_data()
print('X_train shape:', X_train.shape)
print(X_train.shape[0], 'train samples')
print(X_test.shape[0], 'test samples')

现在我们进行独热编码并对图像进行归一化:

# convert to categorical
Y_train = np_utils.to_categorical(y_train, NB_CLASSES)
Y_test = np_utils.to_categorical(y_test, NB_CLASSES)

# float and normalization
X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
X_train /= 255
X_test /= 255

我们的网络将学习 32 个卷积滤波器,每个滤波器的大小为 3 x 3。输出维度与输入形状相同,因此为 32 x 32,激活函数为 ReLU,这是一种引入非线性的简单方式。之后我们会进行一个 2 x 2 大小的最大池化操作,并使用 25%的 dropout:

# network
model = Sequential()
model.add(Conv2D(32, (3, 3), padding='same',
input_shape=(IMG_ROWS, IMG_COLS, IMG_CHANNELS)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Dropout(0.25))

深度管道的下一阶段是一个包含 512 个单元的密集网络,激活函数为 ReLU,随后是 50%的 dropout,最后是一个包含 10 个类别输出的 softmax 层,每个类别对应一个输出:

model.add(Flatten())
model.add(Dense(512))
model.add(Activation('relu'))
model.add(Dropout(0.5))
model.add(Dense(NB_CLASSES))
model.add(Activation('softmax'))
model.summary()

在定义了网络后,我们可以训练模型。在这种情况下,我们将数据拆分,并计算出一个验证集,除了训练集和测试集。训练集用于构建我们的模型,验证集用于选择表现最好的方法,而测试集用于检验我们最好的模型在全新数据上的表现:

# train
model.compile(loss='categorical_crossentropy', optimizer=OPTIM,
metrics=['accuracy'])
model.fit(X_train, Y_train, batch_size=BATCH_SIZE,
epochs=NB_EPOCH, validation_split=VALIDATION_SPLIT,
verbose=VERBOSE)
score = model.evaluate(X_test, Y_test,
batch_size=BATCH_SIZE, verbose=VERBOSE)
print("Test score:", score[0])
print('Test accuracy:', score[1])

在这种情况下,我们保存了我们深度网络的架构:

#save model
model_json = model.to_json()
open('cifar10_architecture.json', 'w').write(model_json)
And the weights learned by our deep network on the training set
model.save_weights('cifar10_weights.h5', overwrite=True)

让我们运行代码。我们的网络经过 20 次迭代,测试准确率达到了 66.4%。我们还打印了准确率和损失图,并使用model.summary()输出了网络结构:

在下图中,我们展示了网络在训练集和测试集上所达到的准确率和损失:

通过更深的网络提高 CIFAR-10 的性能

提高性能的一种方法是定义一个更深的网络,包含多个卷积操作。在这个示例中,我们有一个模块序列:

conv+conv+maxpool+dropout+conv+conv+maxpool

接着是一个标准的dense+dropout+dense结构。所有的激活函数均为 ReLU。

让我们看看新网络的代码:

model = Sequential()
model.add(Conv2D(32, (3, 3), padding='same',
input_shape=(IMG_ROWS, IMG_COLS, IMG_CHANNELS)))
model.add(Activation('relu'))
model.add(Conv2D(32, (3, 3), padding='same'))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Dropout(0.25))
model.add(Conv2D(64, (3, 3), padding='same'))
model.add(Activation('relu'))
model.add(Conv2D(64, 3, 3))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Dropout(0.25))
model.add(Flatten())
model.add(Dense(512))
model.add(Activation('relu'))
model.add(Dropout(0.5))
model.add(Dense(NB_CLASSES))
model.add(Activation('softmax'))

恭喜!你已经定义了一个更深的网络。让我们运行代码!首先我们输出网络结构,然后运行 40 次迭代,准确率达到了 76.9%:

在下图中,我们可以看到经过 40 次迭代后所达到的准确率:

所以相较于之前的简单深层网络,我们提高了 10.5%的性能。为了完整起见,我们还报告了训练过程中的准确率和损失,如下所示:

通过数据增强提高 CIFAR-10 的性能

提高性能的另一种方法是为我们的训练生成更多的图像。关键的直觉是,我们可以使用标准的 CIFAR 训练集,并通过多种类型的变换对其进行增强,包括旋转、重缩放、水平/垂直翻转、缩放、通道偏移等。让我们看看代码:

from keras.preprocessing.image import ImageDataGenerator
from keras.datasets import cifar10
import numpy as np
NUM_TO_AUGMENT=5

#load dataset
(X_train, y_train), (X_test, y_test) = cifar10.load_data()

# augumenting
print("Augmenting training set images...")
datagen = ImageDataGenerator(
rotation_range=40,
width_shift_range=0.2,
height_shift_range=0.2,
zoom_range=0.2,
horizontal_flip=True,
fill_mode='nearest')

rotation_range 是一个表示随机旋转图片的度数范围(0 - 180)。width_shiftheight_shift 是表示随机平移图片的垂直或水平方向的范围。zoom_range 是用于随机缩放图片的范围。horizontal_flip 是用于随机水平翻转一半图片的选项。fill_mode 是旋转或平移后,用于填充可能出现的新像素的策略:

xtas, ytas = [], []
for i in range(X_train.shape[0]):
num_aug = 0
x = X_train[i] # (3, 32, 32)
x = x.reshape((1,) + x.shape) # (1, 3, 32, 32)
for x_aug in datagen.flow(x, batch_size=1,
save_to_dir='preview', save_prefix='cifar', save_format='jpeg'):
if num_aug >= NUM_TO_AUGMENT:
break
xtas.append(x_aug[0])
num_aug += 1

在数据增强后,我们将从标准的 CIFAR-10 数据集生成更多的训练图片:

现在,我们可以直接将这一思路应用于训练。使用之前定义的同一个卷积神经网络(ConvNet),我们只需要生成更多的增强图像,然后进行训练。为了提高效率,生成器与模型并行运行。这使得图像增强在 CPU 上进行,并且与 GPU 上的训练并行执行。以下是代码:

#fit the dataget
datagen.fit(X_train)

# train
history = model.fit_generator(datagen.flow(X_train, Y_train,
batch_size=BATCH_SIZE), samples_per_epoch=X_train.shape[0],
epochs=NB_EPOCH, verbose=VERBOSE)
score = model.evaluate(X_test, Y_test,
batch_size=BATCH_SIZE, verbose=VERBOSE)
print("Test score:", score[0])
print('Test accuracy:', score[1])

由于我们现在有更多的训练数据,每次迭代的开销更大。因此,我们只运行 50 次迭代,看看能否达到 78.3%的准确率:

我们实验中获得的结果总结在下面的图表中:

关于 CIFAR-10 的最先进结果列表可以在以下网址找到:rodrigob.github.io/are_we_there_yet/build/classification_datasets_results.html。截至 2017 年 1 月,最佳结果的准确率为 96.53%。

使用 CIFAR-10 进行预测

现在假设我们想使用刚刚为 CIFAR-10 训练的深度学习模型进行大批量图像评估。由于我们已经保存了模型和权重,我们无需每次都重新训练:

import numpy as np
import scipy.misc
from keras.models import model_from_json
from keras.optimizers import SGD

#load model
model_architecture = 'cifar10_architecture.json'
model_weights = 'cifar10_weights.h5'
model = model_from_json(open(model_architecture).read())
model.load_weights(model_weights)

#load images
img_names = ['cat-standing.jpg', 'dog.jpg']
imgs = [np.transpose(scipy.misc.imresize(scipy.misc.imread(img_name), (32, 32)),
(1, 0, 2)).astype('float32')
for img_name in img_names]
imgs = np.array(imgs) / 255

# train
optim = SGD()
model.compile(loss='categorical_crossentropy', optimizer=optim,
metrics=['accuracy'])

# predict
predictions = model.predict_classes(imgs)
print(predictions)

现在,让我们为一张 和一张 获取预测结果。

我们得到了类别 3(猫)和 5(狗)作为输出,正如预期的那样:

用于大规模图像识别的非常深的卷积神经网络

2014 年,关于图像识别的一个有趣贡献被提出(更多信息请参考:用于大规模图像识别的非常深的卷积神经网络,作者:K. Simonyan 和 A. Zisserman,2014 年)。该论文表明,通过将深度推向 16-19 层,可以显著改善先前的网络配置。论文中有一个模型被称为 D 或 VGG-16,它有 16 层深度。该模型在 Java Caffe 中实现(caffe.berkeleyvision.org/),用于在 ImageNet ILSVRC-2012 数据集上训练模型,该数据集包含 1,000 个类别的图像,并分为三个部分:训练集(130 万张图像)、验证集(5 万张图像)和测试集(10 万张图像)。每张图像的尺寸为(224 x 224),且有三个通道。该模型在 ILSVRC-2012-验证集上达到了 7.5%的 Top 5 错误率,在 ILSVRC-2012-测试集上达到了 7.4%的 Top 5 错误率。

根据 ImageNet 网站的描述:

本次竞赛的目标是估计照片的内容,用于检索和自动注释,训练数据集使用的是一个大规模手工标注的 ImageNet 子集(包含 1000 万个标注的图像,涵盖 10,000 多个物体类别)。测试图像将以没有初始注释的形式呈现——没有分割或标签——算法需要生成标签,指明图像中有哪些物体。

在 Caffe 中实现的模型学习到的权重已直接转换为 Keras(更多信息请参阅:gist.github.com/baraldilorenzo/07d7802847aaad0a35d3),并可用于预加载到 Keras 模型中,接下来按论文描述实现:

from keras.models import Sequential
from keras.layers.core import Flatten, Dense, Dropout
from keras.layers.convolutional import Conv2D, MaxPooling2D, ZeroPadding2D
from keras.optimizers import SGD
import cv2, numpy as np

# define a VGG16 network
def VGG_16(weights_path=None):
model = Sequential()
model.add(ZeroPadding2D((1,1),input_shape=(3,224,224)))
model.add(Conv2D(64, (3, 3), activation='relu'))
model.add(ZeroPadding2D((1,1)))
model.add(Conv2D(64, (3, 3), activation='relu'))
model.add(MaxPooling2D((2,2), strides=(2,2)))
model.add(ZeroPadding2D((1,1)))
model.add(Conv2D(128, (3, 3), activation='relu'))
model.add(ZeroPadding2D((1,1)))
model.add(Conv2D(128, (3, 3), activation='relu'))
model.add(MaxPooling2D((2,2), strides=(2,2)))
model.add(ZeroPadding2D((1,1)))
model.add(Conv2D(256, (3, 3), activation='relu'))
model.add(ZeroPadding2D((1,1)))
model.add(Conv2D(256, (3, 3), activation='relu'))
model.add(ZeroPadding2D((1,1)))
model.add(Conv2D(256, (3, 3), activation='relu'))
model.add(MaxPooling2D((2,2), strides=(2,2)))
model.add(ZeroPadding2D((1,1)))
model.add(Conv2D(512, (3, 3), activation='relu'))
model.add(ZeroPadding2D((1,1)))
model.add(Conv2D(512, (3, 3), activation='relu'))
model.add(ZeroPadding2D((1,1)))
model.add(Conv2D(512, (3, 3), activation='relu'))
model.add(MaxPooling2D((2,2), strides=(2,2)))
model.add(ZeroPadding2D((1,1)))
model.add(Conv2D(512, (3, 3), activation='relu'))
model.add(ZeroPadding2D((1,1)))
model.add(Conv2D(512, (3, 3), activation='relu'))
model.add(ZeroPadding2D((1,1)))
model.add(Conv2D(512, (3, 3), activation='relu'))
model.add(MaxPooling2D((2,2), strides=(2,2)))
model.add(Flatten())
#top layer of the VGG net
model.add(Dense(4096, activation='relu'))
model.add(Dropout(0.5))
model.add(Dense(4096, activation='relu'))
model.add(Dropout(0.5))
model.add(Dense(1000, activation='softmax'))
if weights_path:
model.load_weights(weights_path)
return model

使用 VGG-16 网络识别猫

现在让我们测试一张图片!

im = cv2.resize(cv2.imread('cat.jpg'), (224, 224)).astype(np.float32)
im = im.transpose((2,0,1))
im = np.expand_dims(im, axis=0)

# Test pretrained model
model = VGG_16('/Users/gulli/Keras/codeBook/code/data/vgg16_weights.h5')
optimizer = SGD()
model.compile(optimizer=optimizer, loss='categorical_crossentropy')
out = model.predict(im)
print np.argmax(out)

当代码执行时,返回类别285,对应(更多信息请参阅:gist.github.com/yrevar/942d3a0ac09ec9e5eb3a)埃及猫:

使用 Keras 内置的 VGG-16 网络模块

Keras 应用程序是预构建和预训练的深度学习模型。权重在实例化模型时自动下载并存储在~/.keras/models/中。使用内置代码非常简单:

from keras.models import Model
from keras.preprocessing import image
from keras.optimizers import SGD
from keras.applications.vgg16 import VGG16
import matplotlib.pyplot as plt
import numpy as np
import cv2

# prebuild model with pre-trained weights on imagenet
model = VGG16(weights='imagenet', include_top=True)
sgd = SGD(lr=0.1, decay=1e-6, momentum=0.9, nesterov=True)
model.compile(optimizer=sgd, loss='categorical_crossentropy')

# resize into VGG16 trained images' format
im = cv2.resize(cv2.imread('steam-locomotive.jpg'), (224, 224))
im = np.expand_dims(im, axis=0)

# predict
out = model.predict(im)
plt.plot(out.ravel())
plt.show()
print np.argmax(out)
#this should print 820 for steaming train

现在,让我们考虑一列火车:

它就像我祖父驾驶过的那种。如果我们运行代码,我们得到结果820,这是蒸汽火车的 ImageNet 代码。同样重要的是,所有其他类别的支持非常弱,如下图所示:

总结这一部分时,请注意,VGG-16 只是 Keras 中预构建的模块之一。Keras 模型的完整预训练模型列表可以在此处找到:keras.io/applications/

循环使用预构建的深度学习模型进行特征提取

一个非常简单的想法是使用 VGG-16,更一般地说,使用 DCNN 进行特征提取。此代码通过从特定层提取特征来实现该想法:

from keras.applications.vgg16 import VGG16
from keras.models import Model
from keras.preprocessing import image
from keras.applications.vgg16 import preprocess_input
import numpy as np

# pre-built and pre-trained deep learning VGG16 model
base_model = VGG16(weights='imagenet', include_top=True)
for i, layer in enumerate(base_model.layers):
     print (i, layer.name, layer.output_shape)

# extract features from block4_pool block
model =
Model(input=base_model.input, output=base_model.get_layer('block4_pool').output)
img_path = 'cat.jpg'
img = image.load_img(img_path, target_size=(224, 224))
x = image.img_to_array(img)
x = np.expand_dims(x, axis=0)
x = preprocess_input(x)

# get the features from this block
features = model.predict(x)

现在你可能会想,为什么我们要从 DCNN 的中间层提取特征。关键的直觉是,当网络学习将图像分类到不同类别时,每一层都会学习识别做最终分类所需的特征。较低层识别较低阶的特征,如颜色和边缘,而较高层则将这些较低阶的特征组合成较高阶的特征,如形状或物体。因此,中间层具有从图像中提取重要特征的能力,这些特征更有可能帮助进行不同种类的分类。这有多个优势。首先,我们可以依赖公开可用的大规模训练,并将这种学习迁移到新的领域。其次,我们可以节省时间,避免进行昂贵的大规模训练。第三,即使我们没有大量的训练样本,也能提供合理的解决方案。我们还可以为当前任务提供一个良好的起始网络形状,而不必盲目猜测。

用于迁移学习的非常深的 Inception-v3 网络

迁移学习是一种非常强大的深度学习技术,在不同领域有着更多的应用。其直觉非常简单,可以通过一个类比来解释。假设你想学习一门新语言,比如西班牙语,那么从你已经掌握的另一种语言(如英语)开始可能会很有帮助。

按照这个思路,计算机视觉研究人员现在通常使用预训练的 CNN 来为新任务生成表示,其中数据集可能不足以从头训练整个 CNN。另一个常用的策略是采用预训练的 ImageNet 网络,然后对整个网络进行微调,以适应新的任务。

Inception-v3 网络是 Google 开发的一个非常深的卷积神经网络。Keras 实现了下面图示的完整网络,并且它已经在 ImageNet 上进行了预训练。该模型的默认输入大小是 299 x 299,且有三个通道:

这个框架示例的灵感来自于以下方案:keras.io/applications/。我们假设在一个与 ImageNet 不同的领域中有一个训练数据集DD的输入有 1,024 个特征,输出有 200 个类别。让我们看看一个代码片段:

from keras.applications.inception_v3 import InceptionV3
from keras.preprocessing import image
from keras.models import Model
from keras.layers import Dense, GlobalAveragePooling2D
from keras import backend as K

# create the base pre-trained model
base_model = InceptionV3(weights='imagenet', include_top=False)

我们使用经过训练的 Inception-v3 模型;我们不包括顶层模型,因为我们想在D上进行微调。顶层是一个具有 1,024 个输入的全连接层,最后的输出层是一个 softmax 全连接层,输出 200 个类别。x = GlobalAveragePooling2D()(x)用于将输入转换为适合全连接层处理的正确形状。实际上,base_model.output张量的形状为dim_ordering="th"时是(samples, channels, rows, cols),或者dim_ordering="tf"时是(samples, rows, cols, channels),但全连接层需要的是(samples, channels),而GlobalAveragePooling2D会对(rows, cols)进行平均化。因此,如果查看最后四层(include_top=True时),你会看到这些形状:

# layer.name, layer.input_shape, layer.output_shape
('mixed10', [(None, 8, 8, 320), (None, 8, 8, 768), (None, 8, 8, 768), (None, 8, 8, 192)], (None, 8, 8, 2048))
('avg_pool', (None, 8, 8, 2048), (None, 1, 1, 2048))
('flatten', (None, 1, 1, 2048), (None, 2048))
('predictions', (None, 2048), (None, 1000))

当你设置include_top=False时,你正在移除最后三层并暴露出mixed10层,因此GlobalAveragePooling2D层将(None, 8, 8, 2048)转换为(None, 2048),其中(None, 2048)张量中的每个元素都是(None, 8, 8, 2048)张量中对应(8, 8)子张量的平均值:

*# add a global spatial average pooling layer* x = base_model.output
x = GlobalAveragePooling2D()(x)*# let's add a fully-connected layer as first layer* x = Dense(1024, activation='relu')(x)*# and a logistic layer with 200 classes as last layer* predictions = Dense(200, activation='softmax')(x)*# model to train* model = Model(input=base_model.input, output=predictions)

所有的卷积层都已预训练,因此在训练完整模型时我们会冻结它们:

*# that is, freeze all convolutional InceptionV3 layers* for layer in base_model.layers: layer.trainable = False

然后我们对模型进行编译并训练几个 epoch,以便训练顶层:

*# compile the model (should be done *after* setting layers to non-trainable)* model.compile(optimizer='rmsprop', loss='categorical_crossentropy')

*# train the model on the new data for a few epochs* model.fit_generator(...)

然后我们冻结 Inception 模型的顶层,并对某些 Inception 层进行微调。在这个例子中,我们决定冻结前 172 层(这是一个需要调整的超参数):

*# we chose to train the top 2 inception blocks, that is, we will freeze* *# the first 172 layers and unfreeze the rest:* for layer in 
model.layers[:172]: layer.trainable = False for layer in 
model.layers[172:]: layer.trainable = True

然后我们重新编译模型以进行微调优化。我们需要重新编译模型,以使这些修改生效:

*# we use SGD with a low learning rate* from keras.optimizers
import SGD
model.compile(optimizer=SGD(lr=0.0001, momentum=0.9), loss='categorical_crossentropy')

*# we train our model again (this time fine-tuning the top 2 inception blocks)* *# alongside the top Dense layers* model.fit_generator(...)

现在我们有了一个新的深度网络,它重用了标准的 Inception-v3 网络,但通过迁移学习在新的领域D上进行了训练。当然,为了获得良好的准确性,有很多参数需要微调。然而,我们现在通过迁移学习重用了一个非常大的预训练网络作为起点。通过这样做,我们可以节省不需要在自己的机器上进行训练,而是重用 Keras 中已经可用的资源。

总结

在这一章中,我们学习了如何使用深度学习卷积网络(ConvNets)高精度识别 MNIST 手写字符。接着,我们使用 CIFAR 10 数据集构建了一个 10 个类别的深度学习分类器,并使用 ImageNet 数据集构建了一个 1,000 个类别的准确分类器。此外,我们还研究了如何使用像 VGG16 这样的深度学习大网络以及像 InceptionV3 这样非常深的网络。最后,本章讨论了迁移学习,旨在适应在大型数据集上训练的预构建模型,使其能够在新领域上有效工作。

在下一章中,我们将介绍生成对抗网络,它用于生成看起来像是人类生成的数据的合成数据;我们还将介绍 WaveNet,一种用于高质量重现人类声音和乐器声音的深度神经网络。

第四章:生成对抗网络与 WaveNet

在本章中,我们将讨论生成对抗网络GANs)和 WaveNet。GAN 被 Yann LeCun(深度学习的奠基人之一)称为过去 10 年中机器学习领域最有趣的想法www.quora.com/What-are-some-recent-and-potentially-upcoming-breakthroughs-in-deep-learning)。GAN 能够学习如何生成看起来真实的合成数据。例如,计算机可以学习如何绘画并创造逼真的图像。这个概念最初由 Ian Goodfellow 提出(更多信息请参见:NIPS 2016 教程:生成对抗网络,I. Goodfellow,2016);他曾与蒙特利尔大学、Google Brain 合作,最近也参与了 OpenAI 的工作(openai.com/)。WaveNet 是 Google DeepMind 提出的一种深度生成网络,用于教计算机如何再现人类语音和乐器声音,且质量令人印象深刻。

在本章中,我们将涵盖以下主题:

  • 什么是 GAN?

  • 深度卷积 GAN

  • GAN 的应用

什么是 GAN?

GAN 的关键直觉可以很容易地类比为艺术伪造,即创造那些被错误归功于其他、更著名艺术家的艺术作品的过程 (en.wikipedia.org/wiki/Art)。GAN 同时训练两个神经网络,如下图所示。生成器G(Z)进行伪造,而判别器D(Y)则根据对真实艺术品和复制品的观察判断复制品的真实性。D(Y)接受一个输入,Y,(例如一张图片),并做出判断,判定输入的真实性——一般来说,接近零的值表示真实,接近一的值表示伪造G(Z)从随机噪声Z中接收输入,并通过训练让自己欺骗D,让D误认为G(Z)生成的内容是真的。因此,训练判别器D(Y)的目标是最大化每个真实数据分布中的图像的D(Y),并最小化每个不属于真实数据分布的图像的D(Y)。因此,GD进行的是一场对立的博弈;这就是所谓的对抗训练。请注意,我们以交替的方式训练GD,每个目标都作为损失函数通过梯度下降优化。生成模型学习如何更成功地伪造,而判别模型学习如何更成功地识别伪造。判别器网络(通常是标准的卷积神经网络)试图对输入图像进行分类,判断它是现实的还是生成的。这个新颖的核心思想是通过生成器和判别器进行反向传播,以调整生成器的参数,让生成器学会如何在更多的情境下欺骗判别器。最终,生成器将学会如何生成与真实图像无法区分的伪造图像。

当然,GAN 需要在两方博弈中找到平衡。为了有效学习,需要确保如果一方成功地在一次更新中向下调整,那么另一方也必须在同样的更新中向下调整。想一想!如果造假者每次都能成功地欺骗裁判,那么造假者自己就没有更多的学习空间了。有时,两个玩家最终会达到平衡,但这并不总是能保证,两个玩家可能会继续对抗很长时间。以下图展示了来自双方学习的一个例子:

一些 GAN 应用

我们已经看到生成器学会了如何伪造数据。这意味着它学会了如何创造新的合成数据,这些数据由网络生成,看起来真实且像是由人类创造的。在深入探讨一些 GAN 代码的细节之前,我想分享一篇近期论文的结果:StackGAN: 文本到照片级图像合成的堆叠生成对抗网络,作者:Han Zhang、Tao Xu、Hongsheng Li、Shaoting Zhang、Xiaolei Huang、Xiaogang Wang 和 Dimitris Metaxas(代码可在线获取:github.com/hanzhanggit/StackGAN)。

在这里,使用 GAN 从文本描述合成伪造图像。结果令人印象深刻。第一列是测试集中的真实图像,剩下的列包含由 StackGAN 的 Stage-I 和 Stage-II 根据相同的文本描述生成的图像。更多例子可在 YouTube 查看(www.youtube.com/watch?v=SuRyL5vhCIM&feature=youtu.be):

现在让我们看看 GAN 是如何学会伪造MNIST 数据集的。在这种情况下,生成器和判别器网络结合了 GAN 和 ConvNets(更多信息请参考:无监督表示学习与深度卷积生成对抗网络,A. Radford、L. Metz 和 S. Chintala,arXiv: 1511.06434,2015 年)用于生成器和判别器网络。一开始,生成器什么都没有生成,但经过几次迭代后,合成的伪造数字逐渐变得越来越清晰。在下图中,面板按训练轮次递增排序,你可以看到面板之间质量的提升:

以下图像展示了随着迭代次数的增加,伪造的手写数字的变化:

以下图像展示了伪造的手写数字,计算结果与原始几乎无法区分:

GAN 最酷的用途之一就是对生成器的向量Z中的人脸进行算术操作。换句话说,如果我们停留在合成伪造图像的空间中,就能看到像这样的效果:

[微笑的女人] - [中立的女人] + [中立的男人] = [微笑的男人]

或者像这样:

[戴眼镜的男人] - [没有眼镜的男人] + [没有眼镜的女人] = [戴眼镜的女人]

下一张图来自文章 无监督表示学习与深度卷积生成对抗网络,作者:A. Radford、L. Metz 和 S. Chintala,arXiv: 1511.06434,2015 年 11 月:

深度卷积生成对抗网络

深度卷积生成对抗网络DCGAN)在论文中介绍:《使用深度卷积生成对抗网络进行无监督表示学习》,作者 A. Radford, L. Metz 和 S. Chintala,arXiv: 1511.06434, 2015。生成器使用一个 100 维的均匀分布空间Z,然后通过一系列卷积操作将其投影到一个更小的空间中。下图展示了一个示例:

一个 DCGAN 生成器可以通过以下 Keras 代码进行描述;它也被一个实现所描述,具体请参见:github.com/jacobgil/keras-dcgan

def generator_model():
    model = Sequential()
    model.add(Dense(input_dim=100, output_dim=1024))
    model.add(Activation('tanh'))
    model.add(Dense(128*7*7))
    model.add(BatchNormalization())
    model.add(Activation('tanh'))
    model.add(Reshape((128, 7, 7), input_shape=(128*7*7,)))
    model.add(UpSampling2D(size=(2, 2)))
    model.add(Convolution2D(64, 5, 5, border_mode='same'))
    model.add(Activation('tanh'))
    model.add(UpSampling2D(size=(2, 2)))
    model.add(Convolution2D(1, 5, 5, border_mode='same'))
    model.add(Activation('tanh'))
    return model

请注意,代码使用的是 Keras 1.x 的语法。然而,由于 Keras 的遗留接口,它也可以在 Keras 2.0 中运行。在这种情况下,系统会报告一些警告,如下图所示:

现在让我们看一下代码。第一层全连接层接收一个 100 维的向量作为输入,并使用激活函数tanh输出 1,024 维。我们假设输入是从均匀分布中采样,范围为[-1, 1]。接下来的全连接层通过批量归一化(更多信息请参见S. Ioffe 和 C. Szegedy 的《批量归一化:通过减少内部协变量偏移加速深度网络训练》,arXiv: 1502.03167, 2014)生成 128 x 7 x 7 的输出数据,批量归一化是一种通过将每个单元的输入归一化为零均值和单位方差来帮助稳定学习的技术。批量归一化已被实验证明在许多情况下可以加速训练,减少初始化不良的问题,并且通常能产生更精确的结果。此外,还有一个Reshape()模块,它将数据转换为 127 x 7 x 7(127 个通道,7 的宽度,7 的高度),dim_ordering设置为tf,以及一个UpSampling()模块,它将每个数据重复为 2 x 2 的正方形。然后,我们有一个卷积层,在 5 x 5 卷积核上生成 64 个滤波器,激活函数为tanh,接着是一个新的UpSampling()模块和一个最终的卷积层,具有一个滤波器,使用 5 x 5 卷积核,激活函数为tanh。请注意,该卷积神经网络没有池化操作。判别器可以通过以下代码进行描述:

def discriminator_model():
    model = Sequential()
    model.add(Convolution2D(64, 5, 5, border_mode='same',
    input_shape=(1, 28, 28)))
    model.add(Activation('tanh'))
    model.add(MaxPooling2D(pool_size=(2, 2)))
    model.add(Convolution2D(128, 5, 5))
    model.add(Activation('tanh'))
    model.add(MaxPooling2D(pool_size=(2, 2)))
    model.add(Flatten())
    model.add(Dense(1024))
    model.add(Activation('tanh'))
    model.add(Dense(1))
    model.add(Activation('sigmoid'))
    return model

这段代码接收一个标准的 MNIST 图像,形状为 (1, 28, 28),并应用一个 64 个 5 x 5 滤波器的卷积,激活函数为 tanh。随后进行一个 2 x 2 的最大池化操作,接着再进行一次卷积和最大池化操作。最后两步是全连接层,其中最后一步是伪造的预测,只有一个神经元,并使用 sigmoid 激活函数。对于选定的训练轮数,生成器和判别器依次使用 binary_crossentropy 作为损失函数进行训练。在每个训练轮次中,生成器会做出若干预测(例如,它生成伪造的 MNIST 图像),而判别器则尝试在将预测与真实的 MNIST 图像混合后进行学习。经过 32 个轮次,生成器学会了伪造这一组手写数字。没有人编程让机器写字,但它已经学会了如何写出与人类手写的数字无法区分的数字。请注意,训练 GAN 可能非常困难,因为需要在两个参与者之间找到平衡。如果你对这个话题感兴趣,我建议你查看一些从业者收集的技巧系列(github.com/soumith/ganhacks):

Keras 对抗性 GAN 用于伪造 MNIST

Keras 对抗性(github.com/bstriner/keras-adversarial)是一个用于构建 GAN 的开源 Python 包,由 Ben Striner 开发(github.com/bstrinergithub.com/bstriner/keras-adversarial/blob/master/LICENSE.txt)。由于 Keras 最近刚刚升级到 2.0,我建议你下载最新的 Keras 对抗性包:

git clone --depth=50 --branch=master https://github.com/bstriner/keras-adversarial.git

并安装 setup.py

python setup.py install

请注意,Keras 2.0 的兼容性已在此问题中跟踪:github.com/bstriner/keras-adversarial/issues/11

如果生成器G和判别器D基于相同的模型,M,那么它们可以合并为一个对抗模型;它使用相同的输入,M,但为GD分开目标和度量。该库有以下 API 调用:

adversarial_model = AdversarialModel(base_model=M,
    player_params=[generator.trainable_weights, discriminator.trainable_weights],
    player_names=["generator", "discriminator"])

如果生成器G和判别器D基于两个不同的模型,则可以使用此 API 调用:

adversarial_model = AdversarialModel(player_models=[gan_g, gan_d],
    player_params=[generator.trainable_weights, discriminator.trainable_weights],
    player_names=["generator", "discriminator"])

让我们看一个关于 MNIST 的计算示例:

import matplotlib as mpl
# This line allows mpl to run with no DISPLAY defined
mpl.use('Agg')

让我们看看开源代码(github.com/bstriner/keras-adversarial/blob/master/examples/example_gan_convolutional.py)。注意,这段代码使用的是 Keras 1.x 的语法,但得益于legacy.py中包含的便捷实用函数集,它也能在 Keras 2.x 之上运行。legacy.py的代码在附录、结论中有报告,且可以在github.com/bstriner/keras-adversarial/blob/master/keras_adversarial/legacy.py找到。

首先,开源示例导入了一些模块。除了 LeakyReLU(ReLU 的一个特殊版本,当单元不活跃时允许一个小的梯度)之外,我们之前都见过这些模块。实验表明,LeakyReLU 可以在多种情况下提高 GAN 的性能(有关更多信息,请参阅:Empirical Evaluation of Rectified Activations in Convolutional Network,B. Xu, N. Wang, T. Chen 和 M. Li,arXiv:1505.00853,2014):

from keras.layers import Dense, Reshape, Flatten, Dropout, LeakyReLU,
    Input, Activation, BatchNormalization
from keras.models import Sequential, Model
from keras.layers.convolutional import Convolution2D, UpSampling2D
from keras.optimizers import Adam
from keras.regularizers import l1, l1l2
from keras.datasets import mnist

import pandas as pd
import numpy as np

然后,导入 GAN 的特定模块:

from keras_adversarial import AdversarialModel, ImageGridCallback,
    simple_gan, gan_targets
from keras_adversarial import AdversarialOptimizerSimultaneous,
    normal_latent_sampling, AdversarialOptimizerAlternating
from image_utils import dim_ordering_fix, dim_ordering_input,
    dim_ordering_reshape, dim_ordering_unfix

对抗性模型用于多玩家游戏。给定一个包含n个目标和k个玩家的基础模型,创建一个包含nk*个目标的模型,其中每个玩家在该玩家的目标上优化损失。此外,simple_gan生成一个具有给定gan_targets的 GAN。注意,在库中,生成器和判别器的标签是相反的;直观地说,这对 GAN 来说是一种标准做法:

def gan_targets(n):
    """
    Standard training targets [generator_fake, generator_real, discriminator_fake,     
    discriminator_real] = [1, 0, 0, 1]
    :param n: number of samples
    :return: array of targets
    """
    generator_fake = np.ones((n, 1))
    generator_real = np.zeros((n, 1))
    discriminator_fake = np.zeros((n, 1))
    discriminator_real = np.ones((n, 1))
    return [generator_fake, generator_real, discriminator_fake, discriminator_real]

这个示例以类似于我们之前看到的方式定义了生成器。然而,在这种情况下,我们使用了函数式语法——我们管道中的每个模块只是作为输入传递给下一个模块。因此,第一个模块是密集层,通过使用glorot_normal进行初始化。此初始化使用了通过节点的输入和输出总和缩放的高斯噪声。所有其他模块都使用相同类型的初始化。BatchNormlization函数中的mode=2参数根据每批次的统计数据进行特征标准化。从实验结果来看,这能产生更好的效果:

def model_generator():
    nch = 256
    g_input = Input(shape=[100])
    H = Dense(nch * 14 * 14, init='glorot_normal')(g_input)
    H = BatchNormalization(mode=2)(H)
    H = Activation('relu')(H)
    H = dim_ordering_reshape(nch, 14)(H)
    H = UpSampling2D(size=(2, 2))(H)
    H = Convolution2D(int(nch / 2), 3, 3, border_mode='same', 
        init='glorot_uniform')(H)
    H = BatchNormalization(mode=2, axis=1)(H)
    H = Activation('relu')(H)
    H = Convolution2D(int(nch / 4), 3, 3, border_mode='same', 
        init='glorot_uniform')(H)
    H = BatchNormalization(mode=2, axis=1)(H)
    H = Activation('relu')(H)
    H = Convolution2D(1, 1, 1, border_mode='same', init='glorot_uniform')(H)
    g_V = Activation('sigmoid')(H)
    return Model(g_input, g_V)

判别器与本章之前定义的非常相似。唯一的主要区别是采用了LeakyReLU

def model_discriminator(input_shape=(1, 28, 28), dropout_rate=0.5):
    d_input = dim_ordering_input(input_shape, name="input_x")
    nch = 512
    H = Convolution2D(int(nch / 2), 5, 5, subsample=(2, 2),
        border_mode='same', activation='relu')(d_input)
    H = LeakyReLU(0.2)(H)
    H = Dropout(dropout_rate)(H)
    H = Convolution2D(nch, 5, 5, subsample=(2, 2),
        border_mode='same', activation='relu')(H)
    H = LeakyReLU(0.2)(H)
    H = Dropout(dropout_rate)(H)
    H = Flatten()(H)
    H = Dense(int(nch / 2))(H)
    H = LeakyReLU(0.2)(H)
    H = Dropout(dropout_rate)(H)
    d_V = Dense(1, activation='sigmoid')(H)
    return Model(d_input, d_V)

然后,定义了两个简单的函数来加载和标准化 MNIST 数据:

def mnist_process(x):
    x = x.astype(np.float32) / 255.0
    return x

def mnist_data():
    (xtrain, ytrain), (xtest, ytest) = mnist.load_data()
    return mnist_process(xtrain), mnist_process(xtest)

下一步,GAN 被定义为生成器和判别器的组合,形成一个联合 GAN 模型。注意,权重通过normal_latent_sampling进行初始化,它从正态高斯分布中采样:

if __name__ == "__main__":
    # z in R¹⁰⁰
    latent_dim = 100
    # x in R^{28x28}
    input_shape = (1, 28, 28)
    # generator (z -> x)
    generator = model_generator()
    # discriminator (x -> y)
    discriminator = model_discriminator(input_shape=input_shape)
    # gan (x - > yfake, yreal), z generated on GPU
    gan = simple_gan(generator, discriminator, normal_latent_sampling((latent_dim,)))
    # print summary of models
    generator.summary()
    discriminator.summary()
    gan.summary()

之后,示例创建了我们的 GAN,并使用Adam优化器编译训练好的模型,binary_crossentropy作为损失函数:

# build adversarial model
model = AdversarialModel(base_model=gan,
    player_params=[generator.trainable_weights, discriminator.trainable_weights],
    player_names=["generator", "discriminator"])
model.adversarial_compile(adversarial_optimizer=AdversarialOptimizerSimultaneous(),
    player_optimizers=[Adam(1e-4, decay=1e-4), Adam(1e-3, decay=1e-4)],
    loss='binary_crossentropy')

用于创建看起来像真实图像的新图像的生成器被定义。每个 epoch 将在训练期间生成一张看起来像原始图像的伪造图像:

def generator_sampler():
    zsamples = np.random.normal(size=(10 * 10, latent_dim))
    gen = dim_ordering_unfix(generator.predict(zsamples))
    return gen.reshape((10, 10, 28, 28))

generator_cb = ImageGridCallback(
    "output/gan_convolutional/epoch-{:03d}.png",generator_sampler)
xtrain, xtest = mnist_data()
xtrain = dim_ordering_fix(xtrain.reshape((-1, 1, 28, 28)))
xtest = dim_ordering_fix(xtest.reshape((-1, 1, 28, 28)))
y = gan_targets(xtrain.shape[0])
ytest = gan_targets(xtest.shape[0])
history = model.fit(x=xtrain, y=y,
validation_data=(xtest, ytest), callbacks=[generator_cb], nb_epoch=100,
    batch_size=32)
df = pd.DataFrame(history.history)
df.to_csv("output/gan_convolutional/history.csv")
generator.save("output/gan_convolutional/generator.h5")
discriminator.save("output/gan_convolutional/discriminator.h5")

请注意,dim_ordering_unfix是一个实用函数,用于支持在image_utils.py中定义的不同图像排序,具体如下:

def dim_ordering_fix(x):
    if K.image_dim_ordering() == 'th':
        return x
    else:
        return np.transpose(x, (0, 2, 3, 1))

现在,让我们运行代码,查看生成器和判别器的损失情况。在下面的截图中,我们看到了判别器和生成器的网络输出:

以下截图显示了用于训练和验证的样本数量:

在经过 5-6 次迭代后,我们已经生成了可以接受的人工图像,计算机已经学会如何重现手写字符,如下图所示:

用于伪造 CIFAR 的 Keras 对抗 GAN

现在我们可以使用 GAN 方法学习如何伪造 CIFAR-10,并创建看起来真实的合成图像。让我们看看开源代码(github.com/bstriner/keras-adversarial/blob/master/examples/example_gan_cifar10.py)。再次注意,它使用的是 Keras 1.x 的语法,但得益于legacy.py中包含的一套方便的实用函数,它也能在 Keras 2.x 上运行(github.com/bstriner/keras-adversarial/blob/master/keras_adversarial/legacy.py)。首先,开源示例导入了一些包:

import matplotlib as mpl
# This line allows mpl to run with no DISPLAY defined
mpl.use('Agg')
import pandas as pd
import numpy as np
import os
from keras.layers import Dense, Reshape, Flatten, Dropout, LeakyReLU, 
    Activation, BatchNormalization, SpatialDropout2D
from keras.layers.convolutional import Convolution2D, UpSampling2D, 
    MaxPooling2D, AveragePooling2D
from keras.models import Sequential, Model
from keras.optimizers import Adam
from keras.callbacks import TensorBoard
from keras.regularizers import l1l2
from keras_adversarial import AdversarialModel, ImageGridCallback, 
    simple_gan, gan_targets
from keras_adversarial import AdversarialOptimizerSimultaneous, 
    normal_latent_sampling, fix_names
import keras.backend as K
from cifar10_utils import cifar10_data
from image_utils import dim_ordering_fix, dim_ordering_unfix, 
    dim_ordering_shape

接下来,定义了一个生成器,它使用卷积与l1l2正则化、批归一化和上采样的组合。请注意,axis=1表示首先对张量的维度进行归一化,mode=0表示采用特征归一化。这个特定的网络是许多精细调整实验的结果,但它本质上仍然是一个卷积 2D 和上采样操作的序列,在开始时使用Dense模块,结束时使用sigmoid。此外,每个卷积使用LeakyReLU激活函数和BatchNormalization

def model_generator():
    model = Sequential()
    nch = 256
    reg = lambda: l1l2(l1=1e-7, l2=1e-7)
    h = 5
    model.add(Dense(input_dim=100, output_dim=nch * 4 * 4, W_regularizer=reg()))
    model.add(BatchNormalization(mode=0))
    model.add(Reshape(dim_ordering_shape((nch, 4, 4))))
    model.add(Convolution2D(nch/2, h, h, border_mode='same', W_regularizer=reg()))
    model.add(BatchNormalization(mode=0, axis=1))
    model.add(LeakyReLU(0.2))
    model.add(UpSampling2D(size=(2, 2)))
    model.add(Convolution2D(nch / 2, h, h, border_mode='same', W_regularizer=reg()))
    model.add(BatchNormalization(mode=0, axis=1))
    model.add(LeakyReLU(0.2))
    model.add(UpSampling2D(size=(2, 2)))
    model.add(Convolution2D(nch / 4, h, h, border_mode='same', W_regularizer=reg()))
    model.add(BatchNormalization(mode=0, axis=1))
    model.add(LeakyReLU(0.2))
    model.add(UpSampling2D(size=(2, 2)))
    model.add(Convolution2D(3, h, h, border_mode='same', W_regularizer=reg()))
    model.add(Activation('sigmoid'))
    return model

然后,定义了一个判别器。再次,我们有一系列的二维卷积操作,在这种情况下我们采用SpatialDropout2D,它会丢弃整个 2D 特征图而不是单个元素。我们还使用MaxPooling2DAveragePooling2D,原因类似:

def model_discriminator():
    nch = 256
    h = 5
    reg = lambda: l1l2(l1=1e-7, l2=1e-7)
    c1 = Convolution2D(nch / 4, h, h, border_mode='same', W_regularizer=reg(),
    input_shape=dim_ordering_shape((3, 32, 32)))
    c2 = Convolution2D(nch / 2, h, h, border_mode='same', W_regularizer=reg())
    c3 = Convolution2D(nch, h, h, border_mode='same', W_regularizer=reg())
    c4 = Convolution2D(1, h, h, border_mode='same', W_regularizer=reg())
    def m(dropout):
        model = Sequential()
        model.add(c1)
        model.add(SpatialDropout2D(dropout))
        model.add(MaxPooling2D(pool_size=(2, 2)))
        model.add(LeakyReLU(0.2))
        model.add(c2)
        model.add(SpatialDropout2D(dropout))
        model.add(MaxPooling2D(pool_size=(2, 2)))
        model.add(LeakyReLU(0.2))
        model.add(c3)
        model.add(SpatialDropout2D(dropout))
        model.add(MaxPooling2D(pool_size=(2, 2)))
        model.add(LeakyReLU(0.2))
        model.add(c4)
        model.add(AveragePooling2D(pool_size=(4, 4), border_mode='valid'))
        model.add(Flatten())
        model.add(Activation('sigmoid'))
        return model
    return m

现在可以生成适当的 GAN。以下函数接受多个输入,包括生成器、判别器、潜在维度的数量和 GAN 目标:

def example_gan(adversarial_optimizer, path, opt_g, opt_d, nb_epoch, generator,
        discriminator, latent_dim, targets=gan_targets, loss='binary_crossentropy'):
    csvpath = os.path.join(path, "history.csv")
    if os.path.exists(csvpath):
        print("Already exists: {}".format(csvpath))
    return

然后创建了两个 GAN,一个带有 dropout,另一个没有 dropout 用于判别器:

print("Training: {}".format(csvpath))
# gan (x - > yfake, yreal), z is gaussian generated on GPU
# can also experiment with uniform_latent_sampling
d_g = discriminator(0)
d_d = discriminator(0.5)
generator.summary()
d_d.summary()
gan_g = simple_gan(generator, d_g, None)
gan_d = simple_gan(generator, d_d, None)
x = gan_g.inputs[1]
z = normal_latent_sampling((latent_dim,))(x)
# eliminate z from inputs
gan_g = Model([x], fix_names(gan_g([z, x]), gan_g.output_names))
gan_d = Model([x], fix_names(gan_d([z, x]), gan_d.output_names))

这两个 GAN 现在被合并为一个具有独立权重的对抗性模型,模型随后被编译:

# build adversarial model
model = AdversarialModel(player_models=[gan_g, gan_d],
    player_params=[generator.trainable_weights, d_d.trainable_weights],
    player_names=["generator", "discriminator"])
model.adversarial_compile(adversarial_optimizer=adversarial_optimizer,
    player_optimizers=[opt_g, opt_d], loss=loss)

接下来,有一个简单的回调,用于采样图像,并打印定义了ImageGridCallback方法的文件:

# create callback to generate images
zsamples = np.random.normal(size=(10 * 10, latent_dim))
def generator_sampler():
    xpred = dim_ordering_unfix(generator.predict(zsamples)).transpose((0, 2, 3, 1))
    return xpred.reshape((10, 10) + xpred.shape[1:])
generator_cb =
    ImageGridCallback(os.path.join(path, "epoch-{:03d}.png"),
    generator_sampler, cmap=None)

现在,CIFAR-10 数据已经加载并且模型已拟合。如果后端是 TensorFlow,则损失信息将保存在 TensorBoard 中,以检查损失如何随着时间的推移减少。历史数据也方便地保存在 CVS 格式中,模型的权重也存储在h5格式中:

# train model
xtrain, xtest = cifar10_data()
y = targets(xtrain.shape[0])
ytest = targets(xtest.shape[0])
callbacks = [generator_cb]
if K.backend() == "tensorflow":
    callbacks.append(TensorBoard(log_dir=os.path.join(path, 'logs'),
        histogram_freq=0, write_graph=True, write_images=True))
history = model.fit(x=dim_ordering_fix(xtrain),y=y,
    validation_data=(dim_ordering_fix(xtest), ytest),
    callbacks=callbacks, nb_epoch=nb_epoch,
    batch_size=32)
# save history to CSV
df = pd.DataFrame(history.history)
df.to_csv(csvpath)
# save models
generator.save(os.path.join(path, "generator.h5"))
d_d.save(os.path.join(path, "discriminator.h5"))

最后,整个 GAN 可以运行。生成器从一个具有 100 个潜在维度的空间中采样,我们为这两个 GAN 使用了Adam优化器:

def main():
    # z in R¹⁰⁰
    latent_dim = 100
    # x in R^{28x28}
    # generator (z -> x)
    generator = model_generator()
    # discriminator (x -> y)
    discriminator = model_discriminator()
    example_gan(AdversarialOptimizerSimultaneous(), "output/gan-cifar10",
        opt_g=Adam(1e-4, decay=1e-5),
        opt_d=Adam(1e-3, decay=1e-5),
        nb_epoch=100, generator=generator, discriminator=discriminator,
        latent_dim=latent_dim)
if __name__ == "__main__":
main()

为了完整查看开源代码,我们需要包含一些简单的工具函数来存储图像的网格:

from matplotlib import pyplot as plt, gridspec
import os

def write_image_grid(filepath, imgs, figsize=None, cmap='gray'):
    directory = os.path.dirname(filepath)
    if not os.path.exists(directory):
        os.makedirs(directory)
    fig = create_image_grid(imgs, figsize, cmap=cmap)
    fig.savefig(filepath)
    plt.close(fig)

def create_image_grid(imgs, figsize=None, cmap='gray'):
    n = imgs.shape[0]
    m = imgs.shape[1]
    if figsize is None:
        figsize=(n,m)
    fig = plt.figure(figsize=figsize)
    gs1 = gridspec.GridSpec(n, m)
    gs1.update(wspace=0.025, hspace=0.025) # set the spacing between axes.
    for i in range(n):
        for j in range(m):
            ax = plt.subplot(gs1[i, j])
            img = imgs[i, j, :]
    ax.imshow(img, cmap=cmap)
    ax.axis('off')
    return fig

此外,我们需要一些工具方法来处理不同的图像排序(例如,Theano 或 TensorFlow):

import keras.backend as K
import numpy as np
from keras.layers import Input, Reshape

def dim_ordering_fix(x):
    if K.image_dim_ordering() == 'th':
        return x
    else:
        return np.transpose(x, (0, 2, 3, 1))

def dim_ordering_unfix(x):
    if K.image_dim_ordering() == 'th':
        return x
    else:
        return np.transpose(x, (0, 3, 1, 2))

def dim_ordering_shape(input_shape):
    if K.image_dim_ordering() == 'th':
        return input_shape
    else:
        return (input_shape[1], input_shape[2], input_shape[0])

def dim_ordering_input(input_shape, name):
    if K.image_dim_ordering() == 'th':
        return Input(input_shape, name=name)
    else:
        return Input((input_shape[1], input_shape[2], input_shape[0]), name=name)

def dim_ordering_reshape(k, w, **kwargs):
    if K.image_dim_ordering() == 'th':
        return Reshape((k, w, w), **kwargs)
    else:
        return Reshape((w, w, k), **kwargs)

# One more utility function is used to fix names
def fix_names(outputs, names):
    if not isinstance(outputs, list):
        outputs = [outputs]
    if not isinstance(names, list):
        names = [names]
    return [Activation('linear', name=name)(output) 
        for output, name in zip(outputs, names)]

以下截图显示了定义的网络的转储:

如果我们运行开源代码,第一次迭代将生成不真实的图像。然而,在经过 99 次迭代后,网络将学会伪造看起来像真实 CIFAR-10 图像的图像,如下所示:

在接下来的图像中,我们看到右边是真实的 CIFAR-10 图像,左边是伪造的图像:

伪造的图像 真实的 CIFAR-10 图像

WaveNet——一种用于学习如何生成音频的生成模型

WaveNet 是一种深度生成模型,用于生成原始音频波形。这项突破性的技术是由 Google DeepMind(deepmind.com/)提出的,用于教用户如何与计算机对话。结果令人印象深刻,您可以在网上找到合成语音的例子,其中计算机学会了如何用名人如马特·达蒙的声音进行对话。那么,您可能会想,为什么学习合成音频如此困难呢?好吧,我们听到的每一个数字声音都是基于每秒 16,000 个样本(有时为 48,000 或更多),而构建一个预测模型,基于所有先前的样本学习如何重现一个样本,是一个非常困难的挑战。尽管如此,实验表明,WaveNet 已经改进了当前最先进的文本到语音TTS)系统,在美式英语和普通话中,将与人声的差距缩小了 50%。更酷的是,DeepMind 证明 WaveNet 还可以用来教计算机生成钢琴音乐等乐器的声音。现在是时候介绍一些定义了。TTS 系统通常分为两类:

  • 连续语音合成(Concatenative TTS):这是首先记忆单个语音片段,然后在需要重现语音时重新组合这些片段的方法。然而,这种方法无法扩展,因为只能重现已记忆的语音片段,而不能在不重新记忆的情况下重现新的发声者或不同类型的音频。

  • 参数化语音合成(Parametric TTS):这是创建模型来存储待合成音频的所有特征的过程。在 WaveNet 之前,使用参数化语音合成生成的音频比连续语音合成的音频自然度低。WaveNet 通过直接建模音频声音的生成改进了技术水平,而不是使用过去使用的中间信号处理算法。

原则上,WaveNet 可以看作是一堆 1 维卷积层(我们已经在第三章,深度学习与 ConvNets中看到了 2 维卷积用于图像),具有恒定的步幅为 1 且没有池化层。注意,输入和输出在构造上具有相同的维度,因此 ConvNet 非常适合模拟音频等序列数据。然而,已经表明,为了达到输出神经元的大接受域大小(记住神经元层的接收域是前一层提供输入的横截面),需要使用大量大滤波器或者昂贵地增加网络的深度。因此,纯粹的 ConvNets 在学习如何合成音频方面并不那么有效。WaveNet 背后的关键直觉是扩张因果卷积(更多信息请参考文章:Multi-Scale Context Aggregation by Dilated Convolutions,作者 Fisher Yu, Vladlen Koltun, 2016,可在www.semanticscholar.org/paper/Multi-Scale-Context-Aggregation-by-Dilated-Yu-Koltun/420c46d7cafcb841309f02ad04cf51cb1f190a48获取)或者有时称为空洞卷积(atrous是法语表达à trous的词语,意思是带孔的,因此空洞卷积是一种在应用卷积层的滤波器时跳过某些输入值的方式)。例如,在一维情况下,大小为 3 的滤波器w,带有膨胀率1将计算以下和:

由于引入空洞这一简单思想,能够堆叠多个膨胀卷积层,并使滤波器指数增长,从而在不需要过深网络的情况下学习长距离输入依赖关系。因此,WaveNet 是一种卷积神经网络(ConvNet),其中卷积层具有不同的膨胀因子,允许感受野随着深度的增加而指数增长,从而高效地覆盖成千上万个音频时间步长。在训练时,输入是来自人类发音者的声音。波形被量化为固定的整数范围。WaveNet 定义了一个初始卷积层,只访问当前和先前的输入。然后是一个堆叠的膨胀卷积网络层,依然只访问当前和先前的输入。最后,有一系列密集层将先前的结果结合起来,后跟一个用于分类输出的 softmax 激活函数。在每一步,网络会预测一个值并将其反馈到输入中,同时计算下一步的预测。损失函数是当前步骤输出和下一步输入之间的交叉熵。由 Bas Veeling 开发的一个 Keras 实现可以在此获取:github.com/basveeling/wavenet,并可以通过 git 轻松安装:

pip install virtualenv
mkdir ~/virtualenvs && cd ~/virtualenvs
virtualenv wavenet
source wavenet/bin/activate
cd ~
git clone https://github.com/basveeling/wavenet.git
cd wavenet
pip install -r requirements.txt

请注意,此代码与 Keras 1.x 兼容,详情请查看 github.com/basveeling/wavenet/issues/29,了解将其迁移到 Keras 2.x 上的进展情况。训练非常简单,但需要大量的计算能力(因此请确保你有良好的 GPU 支持):

$ python wavenet.py with 'data_dir=your_data_dir_name'

在训练后采样网络同样非常简单:

python wavenet.py predict with 'models/[run_folder]/config.json predict_seconds=1'

你可以在网上找到大量的超参数,用于微调我们的训练过程。正如这个内部层的转储所解释的,网络确实非常深。请注意,输入波形被划分为(fragment_length = 1152nb_output_bins = 256),这就是传递到 WaveNet 的张量。WaveNet 以重复的块组织,称为残差块,每个残差块由两个膨胀卷积模块的乘积合并组成(一个使用 sigmoid 激活,另一个使用 tanh 激活),然后是一个合并的卷积求和。请注意,每个膨胀卷积都有逐渐增大的空洞,空洞大小按指数增长(2 ** i),从 1 到 512,如下文所定义:

def residual_block(x):
    original_x = x
    tanh_out = CausalAtrousConvolution1D(nb_filters, 2, atrous_rate=2 ** i,
        border_mode='valid', causal=True, bias=use_bias,
        name='dilated_conv_%d_tanh_s%d' % (2 ** i, s), activation='tanh',
        W_regularizer=l2(res_l2))(x)
    sigm_out = CausalAtrousConvolution1D(nb_filters, 2, atrous_rate=2 ** i,
        border_mode='valid', causal=True, bias=use_bias,
        name='dilated_conv_%d_sigm_s%d' % (2 ** i, s), activation='sigmoid',
        W_regularizer=l2(res_l2))(x)
    x = layers.Merge(mode='mul',
        name='gated_activation_%d_s%d' % (i, s))([tanh_out, sigm_out])
        res_x = layers.Convolution1D(nb_filters, 1, border_mode='same', bias=use_bias,
        W_regularizer=l2(res_l2))(x)
    skip_x = layers.Convolution1D(nb_filters, 1, border_mode='same', bias=use_bias,
        W_regularizer=l2(res_l2))(x)
    res_x = layers.Merge(mode='sum')([original_x, res_x])
    return res_x, skip_x

在残差膨胀块之后,有一系列合并的卷积模块,接着是两个卷积模块,最后是 nb_output_bins 类别中的 softmax 激活函数。完整的网络结构如下:

Layer (type) Output Shape Param # Connected to
====================================================================================================
input_part (InputLayer) (None, 1152, 256) 0
____________________________________________________________________________________________________
initial_causal_conv (CausalAtrou (None, 1152, 256) 131328 input_part[0][0]
____________________________________________________________________________________________________
dilated_conv_1_tanh_s0 (CausalAt (None, 1152, 256) 131072 initial_causal_conv[0][0]
____________________________________________________________________________________________________
dilated_conv_1_sigm_s0 (CausalAt (None, 1152, 256) 131072 initial_causal_conv[0][0]
____________________________________________________________________________________________________
gated_activation_0_s0 (Merge) (None, 1152, 256) 0 dilated_conv_1_tanh_s0[0][0]
dilated_conv_1_sigm_s0[0][0]
______________________________________________________________________
_____________________________
convolution1d_1 (Convolution1D) (None, 1152, 256) 65536 gated_activation_0_s0[0][0]
____________________________________________________________________________________________________
merge_1 (Merge) (None, 1152, 256) 0 initial_causal_conv[0][0]
convolution1d_1[0][0]
____________________________________________________________________________________________________
dilated_conv_2_tanh_s0 (CausalAt (None, 1152, 256) 131072 merge_1[0][0]
____________________________________________________________________________________________________
dilated_conv_2_sigm_s0 (CausalAt (None, 1152, 256) 131072 merge_1[0][0]
____________________________________________________________________________________________________
gated_activation_1_s0 (Merge) (None, 1152, 256) 0 dilated_conv_2_tanh_s0[0][0]
dilated_conv_2_sigm_s0[0][0]
____________________________________________________________________________________________________
convolution1d_3 (Convolution1D) (None, 1152, 256) 65536 gated_activation_1_s0[0][0]
____________________________________________________________________________________________________
merge_2 (Merge) (None, 1152, 256) 0 merge_1[0][0]
convolution1d_3[0][0]
____________________________________________________________________________________________________
dilated_conv_4_tanh_s0 (CausalAt (None, 1152, 256) 131072 merge_2[0][0]
____________________________________________________________________________________________________
dilated_conv_4_sigm_s0 (CausalAt (None, 1152, 256) 131072 merge_2[0][0]
____________________________________________________________________________________________________
gated_activation_2_s0 (Merge) (None, 1152, 256) 0 dilated_conv_4_tanh_s0[0][0]
dilated_conv_4_sigm_s0[0][0]
____________________________________________________________________________________________________
convolution1d_5 (Convolution1D) (None, 1152, 256) 65536 gated_activation_2_s0[0][0]
____________________________________________________________________________________________________
merge_3 (Merge) (None, 1152, 256) 0 merge_2[0][0]
convolution1d_5[0][0]
____________________________________________________________________________________________________
dilated_conv_8_tanh_s0 (CausalAt (None, 1152, 256) 131072 merge_3[0][0]
____________________________________________________________________________________________________
dilated_conv_8_sigm_s0 (CausalAt (None, 1152, 256) 131072 merge_3[0][0]
____________________________________________________________________________________________________
gated_activation_3_s0 (Merge) (None, 1152, 256) 0 dilated_conv_8_tanh_s0[0][0]
dilated_conv_8_sigm_s0[0][0]
____________________________________________________________________________________________________
convolution1d_7 (Convolution1D) (None, 1152, 256) 65536 gated_activation_3_s0[0][0]
____________________________________________________________________________________________________
merge_4 (Merge) (None, 1152, 256) 0 merge_3[0][0]
convolution1d_7[0][0]
____________________________________________________________________________________________________
dilated_conv_16_tanh_s0 (CausalA (None, 1152, 256) 131072 merge_4[0][0]
____________________________________________________________________________________________________
dilated_conv_16_sigm_s0 (CausalA (None, 1152, 256) 131072 merge_4[0][0]
____________________________________________________________________________________________________
gated_activation_4_s0 (Merge) (None, 1152, 256) 0 dilated_conv_16_tanh_s0[0][0]
dilated_conv_16_sigm_s0[0][0]
____________________________________________________________________________________________________
convolution1d_9 (Convolution1D) (None, 1152, 256) 65536 gated_activation_4_s0[0][0]
____________________________________________________________________________________________________
merge_5 (Merge) (None, 1152, 256) 0 merge_4[0][0]
convolution1d_9[0][0]
____________________________________________________________________________________________________
dilated_conv_32_tanh_s0 (CausalA (None, 1152, 256) 131072 merge_5[0][0]
____________________________________________________________________________________________________
dilated_conv_32_sigm_s0 (CausalA (None, 1152, 256) 131072 merge_5[0][0]
____________________________________________________________________________________________________
gated_activation_5_s0 (Merge) (None, 1152, 256) 0 dilated_conv_32_tanh_s0[0][0]
dilated_conv_32_sigm_s0[0][0]
____________________________________________________________________________________________________
convolution1d_11 (Convolution1D) (None, 1152, 256) 65536 gated_activation_5_s0[0][0]
____________________________________________________________________________________________________
merge_6 (Merge) (None, 1152, 256) 0 merge_5[0][0]
convolution1d_11[0][0]
____________________________________________________________________________________________________
dilated_conv_64_tanh_s0 (CausalA (None, 1152, 256) 131072 merge_6[0][0]
____________________________________________________________________________________________________
dilated_conv_64_sigm_s0 (CausalA (None, 1152, 256) 131072 merge_6[0][0]
____________________________________________________________________________________________________
gated_activation_6_s0 (Merge) (None, 1152, 256) 0 dilated_conv_64_tanh_s0[0][0]
dilated_conv_64_sigm_s0[0][0]
____________________________________________________________________________________________________
convolution1d_13 (Convolution1D) (None, 1152, 256) 65536 gated_activation_6_s0[0][0]
____________________________________________________________________________________________________
merge_7 (Merge) (None, 1152, 256) 0 merge_6[0][0]
convolution1d_13[0][0]
____________________________________________________________________________________________________
dilated_conv_128_tanh_s0 (Causal (None, 1152, 256) 131072 merge_7[0][0]
____________________________________________________________________________________________________
dilated_conv_128_sigm_s0 (Causal (None, 1152, 256) 131072 merge_7[0][0]
____________________________________________________________________________________________________
gated_activation_7_s0 (Merge) (None, 1152, 256) 0 dilated_conv_128_tanh_s0[0][0]
dilated_conv_128_sigm_s0[0][0]
____________________________________________________________________________________________________
convolution1d_15 (Convolution1D) (None, 1152, 256) 65536 gated_activation_7_s0[0][0]
____________________________________________________________________________________________________
merge_8 (Merge) (None, 1152, 256) 0 merge_7[0][0]
convolution1d_15[0][0]
____________________________________________________________________________________________________
dilated_conv_256_tanh_s0 (Causal (None, 1152, 256) 131072 merge_8[0][0]
____________________________________________________________________________________________________
dilated_conv_256_sigm_s0 (Causal (None, 1152, 256) 131072 merge_8[0][0]
____________________________________________________________________________________________________
gated_activation_8_s0 (Merge) (None, 1152, 256) 0 dilated_conv_256_tanh_s0[0][0]
dilated_conv_256_sigm_s0[0][0]
____________________________________________________________________________________________________
convolution1d_17 (Convolution1D) (None, 1152, 256) 65536 gated_activation_8_s0[0][0]
____________________________________________________________________________________________________
merge_9 (Merge) (None, 1152, 256) 0 merge_8[0][0]
convolution1d_17[0][0]
____________________________________________________________________________________________________
dilated_conv_512_tanh_s0 (Causal (None, 1152, 256) 131072 merge_9[0][0]
____________________________________________________________________________________________________
dilated_conv_512_sigm_s0 (Causal (None, 1152, 256) 131072 merge_9[0][0]
____________________________________________________________________________________________________
gated_activation_9_s0 (Merge) (None, 1152, 256) 0 dilated_conv_512_tanh_s0[0][0]
dilated_conv_512_sigm_s0[0][0]
____________________________________________________________________________________________________
convolution1d_2 (Convolution1D) (None, 1152, 256) 65536 gated_activation_0_s0[0][0]
____________________________________________________________________________________________________
convolution1d_4 (Convolution1D) (None, 1152, 256) 65536 gated_activation_1_s0[0][0]
____________________________________________________________________________________________________
convolution1d_6 (Convolution1D) (None, 1152, 256) 65536 gated_activation_2_s0[0][0]
____________________________________________________________________________________________________
convolution1d_8 (Convolution1D) (None, 1152, 256) 65536 gated_activation_3_s0[0][0]
____________________________________________________________________________________________________
convolution1d_10 (Convolution1D) (None, 1152, 256) 65536 gated_activation_4_s0[0][0]
____________________________________________________________________________________________________
convolution1d_12 (Convolution1D) (None, 1152, 256) 65536 gated_activation_5_s0[0][0]
____________________________________________________________________________________________________
convolution1d_14 (Convolution1D) (None, 1152, 256) 65536 gated_activation_6_s0[0][0]
____________________________________________________________________________________________________
convolution1d_16 (Convolution1D) (None, 1152, 256) 65536 gated_activation_7_s0[0][0]
____________________________________________________________________________________________________
convolution1d_18 (Convolution1D) (None, 1152, 256) 65536 gated_activation_8_s0[0][0]
____________________________________________________________________________________________________
convolution1d_20 (Convolution1D) (None, 1152, 256) 65536 gated_activation_9_s0[0][0]
____________________________________________________________________________________________________
merge_11 (Merge) (None, 1152, 256) 0 convolution1d_2[0][0]
convolution1d_4[0][0]
convolution1d_6[0][0]
convolution1d_8[0][0]
convolution1d_10[0][0]
convolution1d_12[0][0]
convolution1d_14[0][0]
convolution1d_16[0][0]
convolution1d_18[0][0]
convolution1d_20[0][0]
____________________________________________________________________________________________________
activation_1 (Activation) (None, 1152, 256) 0 merge_11[0][0]
____________________________________________________________________________________________________
convolution1d_21 (Convolution1D) (None, 1152, 256) 65792 activation_1[0][0]
____________________________________________________________________________________________________
activation_2 (Activation) (None, 1152, 256) 0 convolution1d_21[0][0]
____________________________________________________________________________________________________
convolution1d_22 (Convolution1D) (None, 1152, 256) 65792 activation_2[0][0]
____________________________________________________________________________________________________
output_softmax (Activation) (None, 1152, 256) 0 convolution1d_22[0][0]
====================================================================================================
Total params: 4,129,536
Trainable params: 4,129,536
Non-trainable params: 0

DeepMind 尝试使用包含多个发言者的数据集进行训练,这显著提高了学习共享语言和语调的能力,从而使得生成的结果接近自然语音。你可以在线找到一系列合成语音的精彩例子(deepmind.com/blog/wavenet-generative-model-raw-audio/),有趣的是,音频质量在 WaveNet 使用额外文本条件时得到了提升,这些文本会被转化为语言学和语音学特征序列,并与音频波形一起使用。我的最爱例子是同一句话由网络以不同的语调发音。当然,听到 WaveNet 自己创作钢琴音乐也非常令人着迷。去网上看看吧!

总结

在这一章中,我们讨论了生成对抗网络(GAN)。一个 GAN 通常由两个网络组成;一个被训练用来伪造看起来真实的合成数据,另一个则被训练用来区分真实数据与伪造数据。这两个网络持续竞争,通过这种方式,它们相互促进和改进。我们回顾了一个开源代码,学习如何伪造看起来真实的 MNIST 和 CIFAR-10 图像。此外,我们还讨论了 WaveNet,这是一种由 Google DeepMind 提出的深度生成网络,用于教计算机如何以惊人的质量再现人类语音和乐器声音。WaveNet 通过基于扩张卷积网络的参数化语音合成方法直接生成原始音频。扩张卷积网络是一种特殊的卷积神经网络(ConvNets),其中卷积滤波器具有孔洞,这使得感受野在深度上呈指数增长,因此能够高效地覆盖成千上万的音频时间步长。DeepMind 展示了如何使用 WaveNet 合成人的声音和乐器,并在此基础上改进了之前的最先进技术。在下一章中,我们将讨论词嵌入——一组用于检测词语之间关系并将相似词语归类在一起的深度学习方法。

第五章:词嵌入

Wikipedia 将词嵌入定义为一组语言建模和特征学习技术的统称,属于自然语言处理NLP)领域,其中词汇表中的单词或短语被映射为实数向量。

词嵌入是一种将文本中的单词转换为数值向量的方式,以便它们能够被要求输入向量作为数值的标准机器学习算法分析。

你已经在第一章《神经网络基础》中学习过一种词嵌入方法——one-hot 编码。One-hot 编码是最基本的嵌入方法。回顾一下,one-hot 编码通过一个与词汇表大小相同的向量表示文本中的单词,其中仅对应单词的条目为 1,所有其他条目为 0。

使用 one-hot 编码的一个主要问题是无法表示单词之间的相似性。在任何给定的语料库中,你会希望像(catdog)、(knifespoon)等单词对具有一定的相似性。向量之间的相似性是通过点积计算的,点积是向量元素逐元素相乘后求和的结果。在 one-hot 编码的向量中,语料库中任意两个单词之间的点积始终为零。

为了克服 one-hot 编码的局限性,NLP 社区借鉴了信息检索IR)中的技术,使用文档作为上下文来向量化文本。值得注意的技术包括 TF-IDF(en.wikipedia.org/wiki/Tf%E2%80%93idf)、潜在语义分析LSA)(en.wikipedia.org/wiki/Latent_semantic_analysis)和主题建模(en.wikipedia.org/wiki/Topic_model)。然而,这些表示捕捉的是稍有不同的以文档为中心的语义相似性观念。

词嵌入技术的开发始于 2000 年。词嵌入与之前基于信息检索(IR)的技术不同,它们使用单词作为上下文,从而得到了更加自然、符合人类理解的语义相似度形式。今天,词嵌入已成为将文本向量化用于各种 NLP 任务(如文本分类、文档聚类、词性标注、命名实体识别、情感分析等)的首选技术。

在本章中,我们将学习两种特定形式的词嵌入,分别是 GloVe 和 word2vec,统称为词的分布式表示。这些嵌入已被证明更有效,并在深度学习和 NLP 社区得到了广泛应用。

我们还将学习如何在 Keras 代码中生成自己的词嵌入,以及如何使用和微调预训练的 word2vec 和 GloVe 模型。

本章将涵盖以下主题:

  • 在上下文中构建各种分布式词表示

  • 构建用于利用嵌入执行 NLP 任务的模型,例如句子解析和情感分析。

分布式表示

分布式表示试图通过考虑一个词与其上下文中其他词的关系来捕捉词的含义。这个观点可以通过语言学家 J. R. Firth 的一句话来体现(更多信息请参考文章:基于段落向量的文档嵌入,作者:Andrew M. Dai、Christopher Olah 和 Quoc V. Le,arXiv:1507.07998,2015),他是最早提出这一观点的学者:

你可以通过它所处的语境来理解一个词。

考虑以下一对句子:

巴黎是法国的首都。 柏林是德国的首都。

即便你对世界地理(或者英语)毫无了解,你仍然能够不费力地推断出词对(巴黎柏林)和(法国德国)在某种程度上是相关的,并且每对词中的相应词之间是以同样的方式相互关联的,也就是说:

巴黎 : 法国 :: 柏林 : 德国

因此,分布式表示的目标是找到一个通用的变换函数φ,将每个词转换为其关联的向量,使得以下形式的关系成立:

换句话说,分布式表示旨在将词转化为向量,使得向量之间的相似度与词语的语义相似度相关。

最著名的词嵌入方法是 word2vec 和 GloVe,我们将在后续章节中更详细地介绍。

word2vec

word2vec 模型组是由谷歌的研究团队于 2013 年创建的,团队由 Tomas Mikolov 领导。该模型是无监督的,输入为大量文本语料库,输出为词向量空间。word2vec 嵌入空间的维度通常低于 one-hot 嵌入空间的维度,后者的维度等于词汇表的大小。与稀疏的 one-hot 嵌入空间相比,嵌入空间的密度更大。

word2vec 的两种架构如下:

  • 连续词袋模型CBOW

  • Skip-gram

在 CBOW 架构中,模型根据周围词的窗口来预测当前词。此外,上下文词的顺序对预测没有影响(也就是说,词袋假设)。在 skip-gram 架构中,模型根据中心词来预测周围的词。根据作者的说法,CBOW 更快,但 skip-gram 在预测不频繁出现的词时效果更好。

一个有趣的观察是,尽管 word2vec 生成的嵌入用于深度学习 NLP 模型,但我们将讨论的两种 word2vec 模型,恰好也是近年来最成功和公认的模型,实际上是浅层神经网络。

skip-gram word2vec 模型

Skip-gram 模型被训练来预测给定当前词的周围词。为了理解 skip-gram word2vec 模型如何工作,考虑以下示例句子:

I love green eggs and ham.

假设窗口大小为三,这个句子可以分解成以下 (上下文, 词) 对:

([I, green], love)

([love, eggs], green)

([green, and], eggs)

...

由于 skip-gram 模型在给定中心词的情况下预测上下文词,我们可以将前面的数据集转换为 (输入, 输出) 对。也就是说,给定一个输入词,我们期望 skip-gram 模型预测出输出词:

(love, I), (love, green), (green, love), (green, eggs), (eggs, green), (eggs, and), ...

我们还可以通过将每个输入词与词汇表中的某个随机词配对来生成额外的负样本。例如:

(love, Sam), (love, zebra), (green, thing), ...

最后,我们为分类器生成正负样本:

((love, I), 1), ((love, green), 1), ..., ((love, Sam), 0), ((love, zebra), 0), ...

我们现在可以训练一个分类器,该分类器输入一个词向量和一个上下文向量,并学习根据是否看到正样本或负样本来预测 1 或 0。这个训练好的网络的输出是词嵌入层的权重(下图中的灰色框):

Skip-gram 模型可以在 Keras 中构建如下。假设词汇表的大小设置为 5000,输出嵌入大小为 300,窗口大小为 1。窗口大小为 1 意味着一个词的上下文是其左右两侧紧挨着的词。我们首先处理导入并将变量设置为其初始值:

from keras.layers import Merge
from keras.layers.core import Dense, Reshape
from keras.layers.embeddings import Embedding
from keras.models import Sequential

vocab_size = 5000
embed_size = 300

我们然后为词创建一个顺序模型。这个模型的输入是词汇表中的词 ID。嵌入权重最初设置为小的随机值。在训练过程中,模型将使用反向传播更新这些权重。接下来的层将输入重塑为嵌入大小:

word_model = Sequential()
word_model.add(Embedding(vocab_size, embed_size,
                         embeddings_initializer="glorot_uniform",
                         input_length=1))
word_model.add(Reshape((embed_size, )))

我们需要的另一个模型是一个用于上下文词的顺序模型。对于每一对 skip-gram,我们都有一个与目标词对应的上下文词,因此这个模型与词模型相同:

context_model = Sequential()
context_model.add(Embedding(vocab_size, embed_size,
                  embeddings_initializer="glorot_uniform",
                  input_length=1))
context_model.add(Reshape((embed_size,)))

这两个模型的输出都是大小为 (embed_size) 的向量。这些输出通过点积合并成一个,并传入一个全连接层,该层有一个单一的输出,并通过 sigmoid 激活层进行包装。你在第一章 神经网络基础 中已经见过 sigmoid 激活函数。正如你记得的,它调节输出,使得大于 0.5 的数值迅速接近 1 并趋于平稳,而小于 0.5 的数值迅速接近 0 同样也趋于平稳:

model = Sequential()
model.add(Merge([word_model, context_model], mode="dot"))
model.add(Dense(1, init="glorot_uniform", activation="sigmoid"))
model.compile(loss="mean_squared_error", optimizer="adam")

使用的损失函数是mean_squared_error;其思路是最小化正例的点积并最大化负例的点积。如果你还记得,点积是将两个向量对应的元素相乘并求和——这使得相似的向量相比于不相似的向量具有更高的点积,因为前者有更多重叠的元素。

Keras 提供了一个便捷函数,用于提取已转换为单词索引列表的文本中的跳字模型(skip-grams)。以下是使用该函数提取从 56 个跳字模型中前 10 个的示例(包括正例和负例)。

我们首先声明必要的导入并分析文本:

from keras.preprocessing.text import *
from keras.preprocessing.sequence import skipgrams

text = "I love green eggs and ham ."

下一步是声明tokenizer并运行文本进行处理。这将生成一个单词令牌的列表:

tokenizer = Tokenizer()
tokenizer.fit_on_texts([text])

tokenizer创建一个字典,将每个唯一单词映射到一个整数 ID,并通过word_index属性提供该映射。我们提取该映射并创建一个双向查找表:

word2id = tokenizer.word_index
id2word = {v:k for k, v in word2id.items()}

最后,我们将输入的单词列表转换为 ID 列表,并将其传递给skipgrams函数。然后,我们打印生成的 56 个(对,标签)跳字元组中的前 10 个:

wids = [word2id[w] for w in text_to_word_sequence(text)]
pairs, labels = skipgrams(wids, len(word2id))
print(len(pairs), len(labels))
for i in range(10):
    print("({:s} ({:d}), {:s} ({:d})) -> {:d}".format(
          id2word[pairs[i][0]], pairs[i][0], 
          id2word[pairs[i][1]], pairs[i][1], 
          labels[i]))

代码的结果如下所示。请注意,由于跳字方法是从正例的可能性池中随机采样结果,因此你的结果可能会有所不同。此外,生成负例的负采样过程是随机配对文本中的任意令牌。随着输入文本大小的增加,这种方法更可能选择无关的单词对。在我们的示例中,由于文本非常短,因此也有可能生成正例。

(and (1), ham (3)) -> 0
(green (6), i (4)) -> 0
(love (2), i (4)) -> 1
(and (1), love (2)) -> 0
(love (2), eggs (5)) -> 0
(ham (3), ham (3)) -> 0
(green (6), and (1)) -> 1
(eggs (5), love (2)) -> 1
(i (4), ham (3)) -> 0
(and (1), green (6)) -> 1 

该示例的代码可以在章节的源代码下载中的skipgram_example.py文件中找到。

CBOW word2vec 模型

现在让我们来看一下 CBOW word2vec 模型。回顾一下,CBOW 模型根据上下文单词预测中心单词。因此,在以下示例的第一个元组中,CBOW 模型需要预测输出单词love,给定上下文单词Igreen

([I, green], love) ([love, eggs], green) ([green, and], eggs) ...

像 skip-gram 模型一样,CBOW 模型也是一个分类器,它以上下文词作为输入,预测目标词。与 skip-gram 模型相比,CBOW 模型的架构相对简单。模型的输入是上下文词的词 ID。这些词 ID 被输入到一个通用的嵌入层,该层的权重被初始化为小的随机值。每个词 ID 都会通过嵌入层转换成大小为(embed_size)的向量。因此,输入上下文的每一行都通过该层转化为大小为(2*window_size, embed_size)的矩阵。接着,这个矩阵被输入到一个 lambda 层,lambda 层计算所有嵌入的平均值。这个平均值再输入到一个全连接层,生成大小为(vocab_size)的密集向量。全连接层的激活函数是 softmax,它会报告输出向量中的最大值作为概率。具有最大概率的 ID 对应于目标词。

CBOW 模型的交付物是来自嵌入层的权重,嵌入层在下图中显示为灰色:

该模型的 Keras 代码如下所示。再假设词汇表大小为5000,嵌入大小为300,上下文窗口大小为1。我们的第一步是设置所有的导入以及这些值:

from keras.models import Sequential
from keras.layers.core import Dense, Lambda
from keras.layers.embeddings import Embedding
import keras.backend as K

vocab_size = 5000
embed_size = 300
window_size = 1

然后,我们构建一个顺序模型,并向其中添加一个嵌入层,该层的权重初始化为小的随机值。请注意,该嵌入层的input_length等于上下文词的数量。因此,每个上下文词都会被输入到这个层,并在反向传播过程中共同更新权重。该层的输出是上下文词的嵌入矩阵,这些嵌入通过 lambda 层平均成一个单一的向量(每一行输入)。最后,全连接层会将每一行转换为大小为(vocab_size)的密集向量。目标词是密集输出向量中 ID 值最大的词。

model = Sequential()
model.add(Embedding(input_dim=vocab_size, output_dim=embed_size, 
                    embeddings_initializer='glorot_uniform',
                    input_length=window_size*2))
model.add(Lambda(lambda x: K.mean(x, axis=1), output_shape=  (embed_size,)))
model.add(Dense(vocab_size, kernel_initializer='glorot_uniform', activation='softmax'))

model.compile(loss='categorical_crossentropy', optimizer="adam")

这里使用的损失函数是categorical_crossentropy,它是一个常见的选择,适用于有两个或更多类别的情况(在我们的例子中是vocab_size)。

示例的源代码可以在章节的源代码下载中找到keras_cbow.py文件。

从模型中提取 word2vec 嵌入

如前所述,尽管两个 word2vec 模型都可以简化为一个分类问题,但我们并不真正关注分类问题本身。相反,我们更关心这个分类过程的副作用,也就是将词从词汇表转换为其密集、低维分布式表示的权重矩阵。

有许多例子表明这些分布式表示展示了常常令人惊讶的句法和语义信息。例如,在 Tomas Mikolov 在 2013 年 NIPS 大会上的演示中(更多信息请参阅文章:使用神经网络学习文本表示,T. Mikolov, I. Sutskever, K. Chen, G. S. Corrado, J. Dean, Q. Le 和 T. Strohmann,NIPS 2013),连接具有相似意义但性别相反的单词的向量在降维后的二维空间中大致平行,我们通过对单词向量进行算术运算,通常可以得到非常直观的结果。该演示提供了许多其他的例子。

直观地说,训练过程将足够的信息传递给内部编码,以预测在输入单词的上下文中出现的输出单词。因此,表示单词的点在这个空间中移动,靠近与之共同出现的单词。这导致相似的单词聚集在一起。与这些相似单词共同出现的单词也会以类似的方式聚集在一起。结果,连接表示语义相关点的向量往往会在分布式表示中展示这些规律性。

Keras 提供了一种从训练模型中提取权重的方法。对于 skip-gram 示例,可以通过以下方式提取嵌入权重:

merge_layer = model.layers[0]
word_model = merge_layer.layers[0]
word_embed_layer = word_model.layers[0]
weights = word_embed_layer.get_weights()[0]

同样,CBOW 示例的嵌入权重可以使用以下单行代码提取:

weights = model.layers[0].get_weights()[0]

在这两种情况下,权重矩阵的形状都是vocab_sizeembed_size。为了计算词汇表中单词的分布式表示,您需要通过将单词索引的位置设置为 1,在一个大小为(vocab_size)的零向量中构造一个 one-hot 向量,并将其与矩阵相乘,得到大小为(embed_size)的嵌入向量。

以下是由 Christopher Olah 的工作中得出的词嵌入可视化(更多信息请参阅文章:文档嵌入与段落向量,Andrew M. Dai, Christopher Olah 和 Quoc V. Le,arXiv:1507.07998,2015)。这是通过 T-SNE 将词嵌入降到二维并可视化的结果。形成实体类型的单词是通过使用 WordNet 同义词集簇选择的。正如您所见,表示相似实体类型的点往往会聚集在一起:

示例的源代码可以在源代码下载中的keras_skipgram.py找到。

使用 word2vec 的第三方实现

在过去的几节中,我们已经详细讨论了 word2vec。此时,您已经了解了 skip-gram 和 CBOW 模型的工作原理,并且知道如何使用 Keras 构建这些模型的实现。然而,word2vec 的第三方实现已经广泛可用,除非您的使用案例非常复杂或不同,否则直接使用现有的实现而不是自己动手实现更为合理。

gensim 库提供了 word2vec 的实现。虽然这是一本关于 Keras 的书,而不是 gensim,但我们在这里讨论这个问题,因为 Keras 不支持 word2vec,并且将 gensim 的实现集成到 Keras 代码中是非常常见的做法。

gensim 的安装相当简单,并在 gensim 安装页面上详细描述(radimrehurek.com/gensim/install.html)。

以下代码展示了如何使用 gensim 构建 word2vec 模型,并使用来自 text8 语料库的文本进行训练,text8 语料库可以从mattmahoney.net/dc/text8.zip下载。text8 语料库是一个包含约 1700 万个单词的文件,来源于维基百科的文本。维基百科的文本被清洗过,去除了标记、标点和非 ASCII 文本,清洗后的前 1 亿个字符组成了 text8 语料库。这个语料库常被用作 word2vec 的示例,因为它训练速度快且能产生良好的结果。首先,我们像往常一样设置导入:

from gensim.models import KeyedVectors
import logging
import os

然后,我们读取 text8 语料库中的单词,并将单词分割成每句 50 个单词。gensim 库提供了一个内置的 text8 处理器,它做的事情类似。由于我们想展示如何使用任何(最好是大型)语料库生成模型,而这些语料库可能无法完全加载到内存中,因此我们将展示如何使用 Python 生成器生成这些句子。

Text8Sentences类将从 text8 文件中生成每个最大长度为maxlen的句子。在这种情况下,我们确实将整个文件加载到内存中,但在遍历文件夹中的文件时,生成器允许我们一次将数据的部分加载到内存中,处理它们,然后将它们传递给调用者:

class Text8Sentences(object):
  def __init__(self, fname, maxlen):
    self.fname = fname
    self.maxlen = maxlen

  def __iter__(self):
    with open(os.path.join(DATA_DIR, "text8"), "rb") as ftext:
      text = ftext.read().split(" ")
      sentences, words = [], []
      for word in text:
        if len(words) >= self.maxlen:
          yield words
          words = []
          words.append(word)
          yield words

然后我们设置调用代码。gensim 的 word2vec 使用 Python 的 logging 来报告进度,所以我们首先启用它。下一行声明了一个Text8Sentences类的实例,接下来的一行则用数据集中的句子训练模型。我们选择将嵌入向量的大小设置为300,并且只考虑在语料库中至少出现 30 次的单词。默认的窗口大小是5,因此我们将把单词 w[i-5]w[i-4]w[i-3]w[i-2]w[i-1]w[i+1]w[i+2]w[i+3]w[i+4]w[i+5] 作为单词 w[i] 的上下文。默认情况下,创建的 word2vec 模型是 CBOW,但你可以通过在参数中设置sg=1来更改这一点:

logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)

DATA_DIR = "../data/"
sentences = Text8Sentences(os.path.join(DATA_DIR, "text8"), 50)
model = word2vec.Word2Vec(sentences, size=300, min_count=30)

word2vec 的实现将对数据进行两次遍历,第一次是生成词汇表,第二次是构建实际的模型。在运行过程中,你可以在控制台看到它的进度:

模型创建后,我们应当对结果向量进行归一化。根据文档,这样可以节省大量内存。一旦模型训练完成,我们可以选择将其保存到磁盘:

model.init_sims(replace=True)
model.save("word2vec_gensim.bin")

保存的模型可以通过以下调用重新加载到内存:

model = Word2Vec.load("word2vec_gensim.bin")

我们现在可以查询模型,找出它所知道的所有词语:

>>> model.vocab.keys()[0:10]
['homomorphism',
'woods',
'spiders',
'hanging',
'woody',
'localized',
'sprague',
'originality',
'alphabetic',
'hermann']

我们可以找到给定词语的实际向量嵌入:

>>> model["woman"]
 array([ -3.13099056e-01, -1.85702944e+00, 1.18816841e+00,
 -1.86561719e-01, -2.23673001e-01, 1.06527400e+00,
 &mldr;
 4.31755871e-01, -2.90115297e-01, 1.00955181e-01,
 -5.17173052e-01, 7.22485244e-01, -1.30940580e+00], dtype=”float32”)

我们还可以找到与某个特定词语最相似的词语:

>>> model.most_similar("woman")
 [('child', 0.7057571411132812),
 ('girl', 0.702182412147522),
 ('man', 0.6846336126327515),
 ('herself', 0.6292711496353149),
 ('lady', 0.6229539513587952),
 ('person', 0.6190367937088013),
 ('lover', 0.6062309741973877),
 ('baby', 0.5993420481681824),
 ('mother', 0.5954475402832031),
 ('daughter', 0.5871444940567017)]

我们可以提供一些提示来帮助寻找词语的相似性。例如,以下命令返回与womanking相似但与man不同的前 10 个词:

>>> model.most_similar(positive=['woman', 'king'], negative=['man'], topn=10)
 [('queen', 0.6237582564353943),
 ('prince', 0.5638638734817505),
 ('elizabeth', 0.5557916164398193),
 ('princess', 0.5456407070159912),
 ('throne', 0.5439794063568115),
 ('daughter', 0.5364126563072205),
 ('empress', 0.5354889631271362),
 ('isabella', 0.5233952403068542),
 ('regent', 0.520746111869812),
 ('matilda', 0.5167444944381714)]

我们还可以找到单个词之间的相似度。为了让大家了解词语在嵌入空间中的位置如何与其语义含义相关联,让我们看一下以下的词对:

>>> model.similarity("girl", "woman")
 0.702182479574
 >>> model.similarity("girl", "man")
 0.574259909834
 >>> model.similarity("girl", "car")
 0.289332921793
 >>> model.similarity("bus", "car")
 0.483853497748

如你所见,girlwoman的相似度高于girlmancarbus的相似度高于girlcar。这与我们对这些词的直观理解非常一致。

示例的源代码可以在源代码下载中的word2vec_gensim.py中找到。

探索 GloVe

词表示的全局向量,或称为 GloVe 嵌入,由 Jeffrey Pennington、Richard Socher 和 Christopher Manning 创建(更多信息请参考文章:GloVe: Global Vectors for Word Representation,作者:J. Pennington、R. Socher 和 C. Manning,发表于 2014 年自然语言处理实证方法会议(EMNLP)论文集,第 1532-1543 页,2013 年)。作者将 GloVe 描述为一种无监督学习算法,用于获取词语的向量表示。训练基于从语料库中聚合的全局词语共现统计数据,结果表示展示了词向量空间中的有趣线性子结构。

GloVe 与 word2vec 的不同之处在于,word2vec 是一个预测模型,而 GloVe 是一个基于计数的模型。第一步是构建一个大的(词语,语境)对矩阵,这些词语在训练语料库中共同出现。该矩阵中的每个元素表示行所表示的词语在列所表示的语境(通常是一个词语序列)中共同出现的频率,如下图所示:

GloVe 过程将共现矩阵转换为(词语,特征)和(特征,语境)矩阵。这个过程称为矩阵分解,并使用随机梯度下降SGD)这一迭代数值方法完成。用公式表示如下:

这里,R是原始的共现矩阵。我们首先将PQ用随机值填充,并尝试通过相乘来重建矩阵R'。重建矩阵R'和原始矩阵R之间的差异告诉我们,需要调整PQ的值多少,以便将R'拉近R,从而最小化重建误差。这个过程会重复多次,直到 SGD 收敛且重建误差低于指定阈值。此时,(词语,特征)矩阵即为 GloVe 嵌入。为了加速这一过程,SGD 通常会以并行模式进行,如HOGWILD!论文中所述。

需要注意的一点是,基于神经网络的预测模型(如 word2vec)和基于计数的模型(如 GloVe)在目的上非常相似。它们都构建一个向量空间,其中单词的位置受到邻近单词的影响。神经网络模型从单个词共现实例开始,而基于计数的模型从语料库中所有单词之间的共现统计数据开始。最近的几篇论文展示了这两种模型之间的相关性。

本书不会更详细地讲解 GloVe 向量的生成。尽管 GloVe 通常比 word2vec 显示出更高的准确性,并且如果使用并行化训练,速度更快,但 Python 工具在成熟度上不如 word2vec。截至本书撰写时,唯一可用的工具是 GloVe-Python 项目(github.com/maciejkula/glove-python),它提供了一个在 Python 上实现 GloVe 的玩具实现。

使用预训练的词向量

通常,只有在你拥有大量非常专业的文本时,才会从头开始训练自己的 word2vec 或 GloVe 模型。迄今为止,词向量最常见的使用方式是以某种方式在你的网络中使用预训练的词向量。你在网络中使用词向量的三种主要方式如下:

  • 从头开始学习词向量

  • 微调从预训练的 GloVe/word2vec 模型学习到的词向量

  • 查找预训练的 GloVe/word2vec 模型中的词向量

在第一种选择中,词向量权重初始化为小的随机值,并通过反向传播进行训练。你在 Keras 的 skip-gram 和 CBOW 模型示例中看到过这个。这是当你在网络中使用 Keras 的嵌入层时的默认模式。

在第二种选择中,你从预训练模型构建一个权重矩阵,并使用这个权重矩阵初始化嵌入层的权重。网络将通过反向传播更新这些权重,但由于良好的初始权重,模型会更快地收敛。

第三种选择是查找预训练模型中的词向量,并将输入转换为嵌入向量。然后,你可以在转换后的数据上训练任何机器学习模型(即,不一定是深度学习网络)。如果预训练模型是在与目标领域相似的领域上训练的,通常效果很好,而且是最不昂贵的选择。

对于一般的英语文本使用,可以使用 Google 的 word2vec 模型,该模型在 100 亿个单词的 Google 新闻数据集上进行训练。词汇表大小约为 300 万个单词,嵌入的维度为 300。Google 新闻模型(约 1.5 GB)可以从这里下载:drive.google.com/file/d/0B7XkCwpI5KDYNlNUTTlSS21pQmM/edit?usp=sharing

同样地,可以从 GloVe 网站下载一个预先训练的模型,该模型在来自英语维基百科和 gigaword 语料库的 60 亿个标记上进行了训练。词汇量约为 400,000 个单词,下载提供了维度为 50、100、200 和 300 的向量。模型大小约为 822 MB。这是该模型的直接下载 URL(nlp.stanford.edu/data/glove.6B.zip)。基于 Common Crawl 和 Twitter 的更大型号模型也可以从同一位置获取。

在接下来的几节中,我们将看看如何以列出的三种方式使用这些预训练模型。

从头开始学习嵌入

在本示例中,我们将训练一个一维卷积神经网络CNN),将句子分类为正面或负面。您已经看到如何使用二维 CNN 分类图像在第三章,使用 ConvNets 进行深度学习。回想一下,CNN 通过强制相邻层神经元之间的局部连接来利用图像中的空间结构。

句子中的单词表现出与图像展现空间结构相同的线性结构。传统(非深度学习)自然语言处理方法涉及创建单词n-grams(en.wikipedia.org/wiki/N-gram)。一维 CNN 做类似的事情,学习卷积滤波器,这些滤波器一次处理几个单词,并对结果进行最大池化,以创建代表句子中最重要思想的向量。

还有另一类神经网络,称为循环神经网络RNN),专门设计用于处理序列数据,包括文本,即一系列单词。RNN 中的处理方式与 CNN 中的处理方式不同。我们将在未来的章节中学习有关 RNN 的内容。

在我们的示例网络中,输入文本被转换为一系列单词索引。请注意,我们使用自然语言工具包NLTK)将文本解析为句子和单词。我们也可以使用正则表达式来做这件事,但是 NLTK 提供的统计模型在解析上比正则表达式更强大。如果您正在处理词嵌入,很可能已经安装了 NLTK。

此链接(www.nltk.org/install.html)包含帮助您在计算机上安装 NLTK 的信息。您还需要安装 NLTK 数据,这是 NLTK 标准提供的一些训练语料库。NLTK 数据的安装说明在这里:www.nltk.org/data.html

词汇索引的序列被输入到一组嵌入层的数组中,这些嵌入层的大小是固定的(在我们的例子中是最长句子的单词数)。嵌入层默认通过随机值进行初始化。嵌入层的输出被连接到一个 1D 卷积层,该卷积层以 256 种不同的方式对词三元组进行卷积(本质上,它对词嵌入应用不同的学习到的线性权重组合)。然后,这些特征通过一个全局最大池化层被池化成一个单一的池化词向量。这个向量(256)被输入到一个全连接层,输出一个向量(2)。Softmax 激活函数会返回一对概率,一个对应正向情感,另一个对应负向情感。网络结构如下图所示:

让我们来看一下如何使用 Keras 编写代码。首先我们声明导入的库。在常量之后,您会注意到我将 random.seed 的值设置为 42。这是因为我们希望运行结果保持一致。由于权重矩阵的初始化是随机的,初始化的差异可能会导致输出的差异,因此我们通过设置种子来控制这一点:

from keras.layers.core import Dense, Dropout, SpatialDropout1D
from keras.layers.convolutional import Conv1D
from keras.layers.embeddings import Embedding
from keras.layers.pooling import GlobalMaxPooling1D
from kera
s.models import Sequential
from keras.preprocessing.sequence import pad_sequences
from keras.utils import np_utils
from sklearn.model_selection import train_test_split
import collections
import matplotlib.pyplot as plt
import nltk
import numpy as np

np.random.seed(42)

我们声明常量。在本章的所有后续示例中,我们将对来自 Kaggle 上 UMICH SI650 情感分类竞赛的句子进行分类。数据集大约包含 7000 个句子,并标注为1表示正向情感,0表示负向情感。INPUT_FILE 定义了该文件的路径,该文件包含句子和标签。文件的格式为情感标签(01)后跟一个制表符,然后是一个句子。

VOCAB_SIZE 设置表示我们将只考虑文本中前 5000 个标记。EMBED_SIZE 设置是由嵌入层生成的嵌入大小。NUM_FILTERS 是我们为卷积层训练的卷积滤波器数量,NUM_WORDS 是每个滤波器的大小,也就是一次卷积时处理的单词数。BATCH_SIZENUM_EPOCHS 分别是每次馈送给网络的记录数量和在训练期间遍历整个数据集的次数:

INPUT_FILE = "../data/umich-sentiment-train.txt"
VOCAB_SIZE = 5000
EMBED_SIZE = 100
NUM_FILTERS = 256
NUM_WORDS = 3
BATCH_SIZE = 64
NUM_EPOCHS = 20

在接下来的代码块中,我们首先读取输入的句子,并通过从语料库中最频繁的单词构建词汇表。然后,我们使用该词汇表将输入句子转换为一个词索引列表:

counter = collections.Counter()
fin = open(INPUT_FILE, "rb")
maxlen = 0
for line in fin:
    _, sent = line.strip().split("t")
    words = [x.lower() for x in   nltk.word_tokenize(sent)]
    if len(words) > maxlen:
        maxlen = len(words)
    for word in words:
        counter[word] += 1
fin.close()

word2index = collections.defaultdict(int)
for wid, word in enumerate(counter.most_common(VOCAB_SIZE)):
    word2index[word[0]] = wid + 1
vocab_size = len(word2index) + 1
index2word = {v:k for k, v in word2index.items()}

我们将每个句子填充到预定的长度maxlen(在这种情况下是训练集中最长句子的单词数)。我们还使用 Keras 工具函数将标签转换为类别格式。最后两步是处理文本输入的标准工作流程,我们将在后续的步骤中一再使用:

xs, ys = [], []
fin = open(INPUT_FILE, "rb")
for line in fin:
    label, sent = line.strip().split("t")
    ys.append(int(label))
    words = [x.lower() for x in nltk.word_tokenize(sent)]
    wids = [word2index[word] for word in words]
    xs.append(wids)
fin.close()
X = pad_sequences(xs, maxlen=maxlen)
Y = np_utils.to_categorical(ys)

最后,我们将数据分割为70/30的训练集和测试集。现在,数据已经准备好输入到网络中:

Xtrain, Xtest, Ytrain, Ytest = train_test_split(X, Y, test_size=0.3, random_state=42)

我们定义了前面在本节中描述的网络:

model = Sequential()
model.add(Embedding(vocab_size, EMBED_SIZE, input_length=maxlen)
model.add(SpatialDropout1D(Dropout(0.2)))
model.add(Conv1D(filters=NUM_FILTERS, kernel_size=NUM_WORDS,
activation="relu"))
model.add(GlobalMaxPooling1D())
model.add(Dense(2, activation="softmax"))

然后我们编译模型。由于我们的目标是二分类(正类或负类),我们选择categorical_crossentropy作为损失函数。优化器我们选择adam。接着,我们使用训练集对模型进行训练,批量大小为 64,训练 20 个周期:

model.compile(loss="categorical_crossentropy", optimizer="adam",
              metrics=["accuracy"])
history = model.fit(Xtrain, Ytrain, batch_size=BATCH_SIZE,
                    epochs=NUM_EPOCHS,
                    validation_data=(Xtest, Ytest))

代码输出结果如下:

如你所见,网络在测试集上的准确率达到了 98.6%。

本示例的源代码可以在章节的源代码下载中找到,文件名为learn_embedding_from_scratch.py

从 word2vec 微调学习到的嵌入

在这个示例中,我们将使用与之前从头学习嵌入时相同的网络。在代码方面,唯一的主要区别是增加了一段代码来加载 word2vec 模型,并构建嵌入层的权重矩阵。

和往常一样,我们从导入模块开始,并设置一个随机种子以保证可重复性。除了之前看到的导入外,还有一个额外的导入,用于从 gensim 导入 word2vec 模型:

from gensim.models import KeyedVectors
from keras.layers.core import Dense, Dropout, SpatialDropout1D
from keras.layers.convolutional import Conv1D
from keras.layers.embeddings import Embedding
from keras.layers.pooling import GlobalMaxPooling1D
from keras.models import Sequential
from keras.preprocessing.sequence import pad_sequences
from keras.utils import np_utils
from sklearn.model_selection import train_test_split
import collections
import matplotlib.pyplot as plt
import nltk
import numpy as np

np.random.seed(42)

接下来是设置常量。这里唯一的不同是,我们将NUM_EPOCHS的设置从20减少到了10。回想一下,用预训练模型的值初始化矩阵通常能使权重值较好,并加快收敛速度:

INPUT_FILE = "../data/umich-sentiment-train.txt"
WORD2VEC_MODEL = "../data/GoogleNews-vectors-negative300.bin.gz"
VOCAB_SIZE = 5000
EMBED_SIZE = 300
NUM_FILTERS = 256
NUM_WORDS = 3
BATCH_SIZE = 64
NUM_EPOCHS = 10

下一个代码块从数据集中提取单词,并创建一个包含最常见词汇的词汇表,然后再次解析数据集,创建一个填充的单词列表。它还将标签转换为类别格式。最后,它将数据拆分为训练集和测试集。这个代码块与前面的示例相同,已在前面进行了详细解释:

counter = collections.Counter()
fin = open(INPUT_FILE, "rb")
maxlen = 0
for line in fin:
   _, sent = line.strip().split("t")
   words = [x.lower() for x in nltk.word_tokenize(sent)]
   if len(words) > maxlen:
       maxlen = len(words)
   for word in words:
       counter[word] += 1
fin.close()

word2index = collections.defaultdict(int)
for wid, word in enumerate(counter.most_common(VOCAB_SIZE)):
    word2index[word[0]] = wid + 1
vocab_sz = len(word2index) + 1
index2word = {v:k for k, v in word2index.items()}

xs, ys = [], []
fin = open(INPUT_FILE, "rb")
for line in fin:
    label, sent = line.strip().split("t")
    ys.append(int(label))
    words = [x.lower() for x in nltk.word_tokenize(sent)]
    wids = [word2index[word] for word in words]
    xs.append(wids)
fin.close()
X = pad_sequences(xs, maxlen=maxlen)
Y = np_utils.to_categorical(ys)

Xtrain, Xtest, Ytrain, Ytest = train_test_split(X, Y, test_size=0.3,
     random_state=42)

下一个代码块加载了一个预训练的 word2vec 模型。这个模型是用大约 100 亿个 Google 新闻文章中的单词训练的,词汇表大小为 300 万。我们加载它并从中查找词汇表中单词的嵌入向量,然后将嵌入向量写入权重矩阵embedding_weights。该权重矩阵的行对应词汇表中的单词,每行的列构成该单词的嵌入向量。

embedding_weights矩阵的维度为vocab_szEMBED_SIZEvocab_sz比词汇表中唯一词汇的最大数量多 1,额外的伪标记_UNK_代表词汇表中没有出现的词汇。

请注意,我们的词汇表中可能会有些词汇在 Google News 的 word2vec 模型中不存在,因此当遇到这些词汇时,它们的嵌入向量将保持为默认值,全为零:

# load word2vec model
word2vec = Word2Vec.load_word2vec_format(WORD2VEC_MODEL, binary=True)
embedding_weights = np.zeros((vocab_sz, EMBED_SIZE))
for word, index in word2index.items():
    try:
        embedding_weights[index, :] = word2vec[word]
    except KeyError:
        pass

我们定义了网络。这个代码块与前面的示例的不同之处在于,我们用在前一个代码块中构建的embedding_weights矩阵初始化了嵌入层的权重:

model = Sequential()
model.add(Embedding(vocab_sz, EMBED_SIZE, input_length=maxlen,
          weights=[embedding_weights]))
model.add(SpatialDropout1D(Dropout(0.2)))
model.add(Conv1D(filters=NUM_FILTERS, kernel_size=NUM_WORDS,
                        activation="relu"))
model.add(GlobalMaxPooling1D())
model.add(Dense(2, activation="softmax"))

然后,我们使用类别交叉熵损失函数和 Adam 优化器编译模型,使用批量大小 64 进行 10 个 epoch 的训练,并评估训练后的模型:

model.compile(optimizer="adam", loss="categorical_crossentropy",
              metrics=["accuracy"])
history = model.fit(Xtrain, Ytrain, batch_size=BATCH_SIZE,
                    epochs=NUM_EPOCHS,
                    validation_data=(Xtest, Ytest))

score = model.evaluate(Xtest, Ytest, verbose=1)
print("Test score: {:.3f}, accuracy: {:.3f}".format(score[0], score[1]))

运行代码的输出如下所示:

((4960, 42), (2126, 42), (4960, 2), (2126, 2))
 Train on 4960 samples, validate on 2126 samples
 Epoch 1/10
 4960/4960 [==============================] - 7s - loss: 0.1766 - acc: 0.9369 - val_loss: 0.0397 - val_acc: 0.9854
 Epoch 2/10
 4960/4960 [==============================] - 7s - loss: 0.0725 - acc: 0.9706 - val_loss: 0.0346 - val_acc: 0.9887
 Epoch 3/10
 4960/4960 [==============================] - 7s - loss: 0.0553 - acc: 0.9784 - val_loss: 0.0210 - val_acc: 0.9915
 Epoch 4/10
 4960/4960 [==============================] - 7s - loss: 0.0519 - acc: 0.9790 - val_loss: 0.0241 - val_acc: 0.9934
 Epoch 5/10
 4960/4960 [==============================] - 7s - loss: 0.0576 - acc: 0.9746 - val_loss: 0.0219 - val_acc: 0.9929
 Epoch 6/10
 4960/4960 [==============================] - 7s - loss: 0.0515 - acc: 0.9764 - val_loss: 0.0185 - val_acc: 0.9929
 Epoch 7/10
 4960/4960 [==============================] - 7s - loss: 0.0528 - acc: 0.9790 - val_loss: 0.0204 - val_acc: 0.9920
 Epoch 8/10
 4960/4960 [==============================] - 7s - loss: 0.0373 - acc: 0.9849 - val_loss: 0.0221 - val_acc: 0.9934
 Epoch 9/10
 4960/4960 [==============================] - 7s - loss: 0.0360 - acc: 0.9845 - val_loss: 0.0194 - val_acc: 0.9929
 Epoch 10/10
 4960/4960 [==============================] - 7s - loss: 0.0389 - acc: 0.9853 - val_loss: 0.0254 - val_acc: 0.9915
 2126/2126 [==============================] - 1s
 Test score: 0.025, accuracy: 0.993

经过 10 个 epoch 的训练后,该模型在测试集上的准确率达到了 99.3%。这比之前的示例有所提升,后者在 20 个 epoch 后准确率为 98.6%。

本示例的源代码可以在本章的源代码下载中的finetune_word2vec_embeddings.py文件中找到。

微调来自 GloVe 的学习嵌入

使用预训练的 GloVe 嵌入进行微调与使用预训练的 word2vec 嵌入进行微调非常相似。事实上,除了构建嵌入层权重矩阵的代码块外,其余所有代码都是相同的。由于我们已经看过这段代码两次,所以我将只关注构建 GloVe 嵌入权重矩阵的代码块。

GloVe 嵌入有多种版本。我们使用的是在来自英文维基百科和 Gigaword 语料库的 60 亿个标记上预训练的模型。该模型的词汇表大小约为 40 万,下载包提供了维度为 50、100、200 和 300 的向量。我们将使用 300 维模型中的嵌入。

我们在前一个示例中唯一需要更改的代码是替换实例化 word2vec 模型并加载嵌入矩阵的代码块,替换为以下代码块。如果我们使用的模型的向量大小不是 300,那么我们还需要更新EMBED_SIZE

向量以空格分隔的文本格式提供,因此第一步是将代码读取到字典word2emb中。这类似于我们之前示例中实例化 Word2Vec 模型的那一行代码:

GLOVE_MODEL = "../data/glove.6B.300d.txt"
word2emb = {}
fglove = open(GLOVE_MODEL, "rb")
for line in fglove:
    cols = line.strip().split()
    word = cols[0]
    embedding = np.array(cols[1:], dtype="float32")
    word2emb[word] = embedding
fglove.close()

然后,我们实例化一个大小为(vocab_szEMBED_SIZE)的嵌入权重矩阵,并从word2emb字典中填充向量。对于那些在词汇表中存在但不在 GloVe 模型中的单词,向量将保持为全零:

embedding_weights = np.zeros((vocab_sz, EMBED_SIZE))
for word, index in word2index.items():
    try:
        embedding_weights[index, :] = word2emb[word]
    except KeyError:
        pass

本程序的完整代码可以在 GitHub 上的本书代码库中的finetune_glove_embeddings.py找到。运行结果如下所示:

这让我们在 10 个 epoch 后达到了 99.1%的准确率,几乎与我们通过微调网络使用 word2vec embedding_weights得到的结果一样好。

本示例的源代码可以在本章的源代码下载中的finetune_glove_embeddings.py文件中找到。

查找嵌入

我们最终的策略是从预训练的网络中查找嵌入。使用当前示例的最简单方法是将嵌入层的trainable参数设置为False。这样可以确保反向传播不会更新嵌入层的权重:

model.add(Embedding(vocab_sz, EMBED_SIZE, input_length=maxlen,
                     weights=[embedding_weights],
                     trainable=False))
model.add(SpatialDropout1D(Dropout(0.2)))

使用 word2vec 和 GloVe 示例设置此值后,经过 10 个 epoch 的训练,我们分别得到了 98.7%和 98.9%的准确率。

然而,通常情况下,这并不是你在代码中使用预训练嵌入的方式。通常,这涉及到对数据集进行预处理,通过查找预训练模型中的单词来创建单词向量,然后使用这些数据训练其他模型。第二个模型通常不包含嵌入层,甚至可能不是深度学习网络。

以下示例描述了一个密集网络,它将大小为100的向量作为输入,表示一个句子,并输出10,表示积极或消极的情感。我们的数据集仍然是来自 UMICH S1650 情感分类比赛的那个,约有 7,000 个句子。

如前所述,代码的许多部分是重复的,因此我们只解释那些新的或需要说明的部分。

我们从导入开始,设置随机种子以确保结果可重复,并设置一些常量值。为了创建每个句子的 100 维向量,我们将句子中单词的 GloVe 100 维向量相加,因此我们选择了glove.6B.100d.txt文件:

from keras.layers.core import Dense, Dropout, SpatialDropout1D
from keras.models import Sequential
from keras.preprocessing.sequence import pad_sequences
from keras.utils import np_utils
from sklearn.model_selection import train_test_split
import collections
import matplotlib.pyplot as plt
import nltk
import numpy as np

np.random.seed(42)

INPUT_FILE = "../data/umich-sentiment-train.txt"
GLOVE_MODEL = "../data/glove.6B.100d.txt"
VOCAB_SIZE = 5000
EMBED_SIZE = 100
BATCH_SIZE = 64
NUM_EPOCHS = 10

下一个代码块读取句子并创建一个单词频率表。通过这个表,选择最常见的 5,000 个标记,并创建查找表(从单词到单词索引以及反向查找)。此外,我们为词汇表中不存在的标记创建一个伪标记_UNK_。使用这些查找表,我们将每个句子转换为一个单词 ID 序列,并对这些序列进行填充,使得所有序列的长度相同(即训练集中句子的最大单词数)。我们还将标签转换为类别格式:

counter = collections.Counter()
fin = open(INPUT_FILE, "rb")
maxlen = 0
for line in fin:
    _, sent = line.strip().split("t")
    words = [x.lower() for x in nltk.word_tokenize(sent)]
    if len(words) > maxlen:
        maxlen = len(words)
    for word in words:
        counter[word] += 1
fin.close()

word2index = collections.defaultdict(int)
for wid, word in enumerate(counter.most_common(VOCAB_SIZE)):
     word2index[word[0]] = wid + 1
vocab_sz = len(word2index) + 1
index2word = {v:k for k, v in word2index.items()}
index2word[0] = "_UNK_"

ws, ys = [], []
fin = open(INPUT_FILE, "rb")
for line in fin:
    label, sent = line.strip().split("t")
    ys.append(int(label))
    words = [x.lower() for x in nltk.word_tokenize(sent)]
    wids = [word2index[word] for word in words]
    ws.append(wids)
fin.close()
W = pad_sequences(ws, maxlen=maxlen)
Y = np_utils.to_categorical(ys)

我们将 GloVe 向量加载到字典中。如果我们想在这里使用 word2vec,我们只需将这一块代码替换为 gensim 的Word2Vec.load_word2vec_format()调用,并将以下代码块替换为查找 word2vec 模型,而不是word2emb字典:

word2emb = collections.defaultdict(int)
fglove = open(GLOVE_MODEL, "rb")
for line in fglove:
    cols = line.strip().split()
    word = cols[0]
    embedding = np.array(cols[1:], dtype="float32")
    word2emb[word] = embedding
fglove.close()

下一个代码块从单词 ID 矩阵W中查找每个句子的单词,并用相应的嵌入向量填充矩阵E。然后,将这些嵌入向量相加以创建一个句子向量,并将其写回到X矩阵中。此代码块的输出是矩阵X,其大小为(num_recordsEMBED_SIZE):

X = np.zeros((W.shape[0], EMBED_SIZE))
for i in range(W.shape[0]):
    E = np.zeros((EMBED_SIZE, maxlen))
    words = [index2word[wid] for wid in W[i].tolist()]
    for j in range(maxlen):
         E[:, j] = word2emb[words[j]]
    X[i, :] = np.sum(E, axis=1)

我们现在已经使用预训练模型预处理了数据,并准备好使用它来训练和评估我们的最终模型。让我们像往常一样将数据分为70/30的训练集/测试集:

Xtrain, Xtest, Ytrain, Ytest = train_test_split(X, Y, test_size=0.3, random_state=42)

我们将要训练的用于情感分析任务的网络是一个简单的密集网络。我们用类别交叉熵损失函数和 Adam 优化器来编译它,并使用从预训练嵌入中构建的句子向量来训练它。最后,我们在 30%的测试集上评估该模型:

model = Sequential()
model.add(Dense(32, input_dim=100, activation="relu"))
model.add(Dropout(0.2))
model.add(Dense(2, activation="softmax"))

model.compile(optimizer="adam", loss="categorical_crossentropy", metrics=["accuracy"])
history = model.fit(Xtrain, Ytrain, batch_size=BATCH_SIZE,
                    epochs=NUM_EPOCHS,
                    validation_data=(Xtest, Ytest))

score = model.evaluate(Xtest, Ytest, verbose=1)
print("Test score: {:.3f}, accuracy: {:.3f}".format(score[0], score[1]))

使用 GloVe 嵌入的代码输出如下所示:

在经过 10 轮训练后,使用 100 维 GloVe 嵌入进行预处理的密集网络在测试集上取得了 96.5%的准确率。而使用 300 维固定的 word2vec 嵌入进行预处理时,网络在测试集上的准确率达到了 98.5%。

本示例的源代码可以在章节的源代码下载中找到,文件分别为transfer_glove_embeddings.py(GloVe 示例)和transfer_word2vec_embeddings.py(word2vec 示例)。

概述

在本章中,我们学习了如何将文本中的单词转换为向量嵌入,这些嵌入保留了单词的分布语义。我们现在也能直观地理解为什么单词嵌入会展现出这种行为,以及为什么单词嵌入在处理文本数据的深度学习模型中如此有用。

接着,我们研究了两种流行的单词嵌入方法——word2vec 和 GloVe,并理解了这些模型是如何工作的。我们还学习了如何使用 gensim 从数据中训练我们自己的 word2vec 模型。

最后,我们了解了在网络中使用嵌入的不同方式。第一种是从头开始学习嵌入,作为训练网络的一部分。第二种是将预训练的 word2vec 和 GloVe 模型的嵌入权重导入到我们的网络中,并在训练过程中进行微调。第三种是直接在下游应用中使用这些预训练的权重。

在下一章,我们将学习循环神经网络(RNN),一种优化处理序列数据(如文本)的网络类型。

第六章:递归神经网络 — RNN

在第三章《卷积神经网络深度学习》中,我们学习了卷积神经网络(CNN),并了解了它们如何利用输入的空间几何信息。例如,CNN 在音频和文本数据的时间维度上应用一维卷积和池化操作,在图像的(高 x 宽)维度上应用二维卷积操作,在视频的(高 x 宽 x 时间)维度上应用三维卷积操作。

在本章中,我们将学习递归神经网络RNN),这是一类利用输入序列特性的神经网络。这些输入可以是文本、语音、时间序列或其他任何元素的顺序依赖于前面元素出现的场景。例如,句子 the dog... 中下一个词更可能是 barks 而不是 car,因此,在这样的序列中,RNN 更有可能预测 barks 而不是 car

RNN 可以被看作是一个 RNN 单元的图,其中每个单元对序列中的每个元素执行相同的操作。RNN 非常灵活,已经被用于解决语音识别、语言建模、机器翻译、情感分析、图像描述等问题。通过重新排列图中单元的排列方式,RNN 可以适应不同类型的问题。我们将看到这些配置的一些示例,以及它们如何用于解决特定问题。

我们还将学习简单 RNN 单元的一个主要局限性,以及两种简单 RNN 单元的变体——长短期记忆LSTM)和门控递归单元GRU)——如何克服这个局限性。LSTM 和 GRU 都是 SimpleRNN 单元的替代品,因此,只需将 RNN 单元替换为这两种变体之一,通常就能显著提高网络的性能。虽然 LSTM 和 GRU 不是唯一的变体,但通过实验证明(更多信息请参见文章:递归网络架构的实证探索,R. Jozefowicz、W. Zaremba 和 I. Sutskever,JMLR,2015 年,以及 LSTM: A Search Space Odyssey,K. Greff,arXiv:1503.04069,2015 年),它们是大多数序列问题的最佳选择。

最后,我们还将学习一些提升 RNN 性能的技巧,以及何时和如何应用这些技巧。

在本章中,我们将涵盖以下主题:

  • SimpleRNN 单元

  • 在 Keras 中生成文本的基本 RNN 实现

  • RNN 拓扑结构

  • LSTM、GRU 和其他 RNN 变体

SimpleRNN 单元

传统的多层感知机神经网络假设所有输入相互独立。但在序列数据的情况下,这一假设是无效的。你已经在上一节看到过例子,其中句子的前两个单词会影响第三个单词。这个想法在语音中也适用——如果我们在一个嘈杂的房间里交谈,我可以根据到目前为止听到的单词,对我可能没有听懂的单词做出合理的猜测。时间序列数据,例如股价或天气,也表现出对过去数据的依赖,这种依赖称为长期趋势。

RNN 单元通过拥有一个隐藏状态或记忆,来结合这种依赖关系,隐藏状态保存了到目前为止所看到的内容的精髓。任意时刻的隐藏状态值是前一个时间步隐藏状态值和当前时间步输入值的函数,即:

h[t]h[t-1]分别是时间步tt-1的隐藏状态值,x[t]是时间t时的输入值。请注意,这个方程是递归的,也就是说,h[t-1]可以通过h[t-2]x[t-1]来表示,依此类推,直到序列的开始。这就是 RNN 如何编码并整合来自任意长序列的信息。

我们还可以将 RNN 单元通过图示表示,如左侧的图所示。在时间t时,单元有一个输入x[t]和一个输出y[t]。部分输出y[t](即隐藏状态h[t])被反馈回单元,在稍后的时间步t+1时使用。就像传统神经网络的参数包含在其权重矩阵中一样,RNN 的参数由三个权重矩阵UVW定义,分别对应输入、输出和隐藏状态:

另一种看待 RNN 的方法是将其展开,如右侧前面图示所示。展开意味着我们为完整的序列绘制网络。这里显示的网络是一个三层 RNN,适合处理三元素序列。请注意,权重矩阵UVW在各个步骤之间是共享的。这是因为我们在每个时间步上对不同的输入应用相同的操作。能够在所有时间步共享这些权重向量,极大地减少了 RNN 需要学习的参数数量。

我们还可以用方程来描述 RNN 中的计算。在时间t时,RNN 的内部状态由隐藏向量h[t]的值给出,它是权重矩阵W与时间t-1时的隐藏状态h[t-1]的乘积,以及权重矩阵U与时间t的输入x[t]的乘积之和,经过tanh非线性激活函数处理。选择tanh而非其他非线性函数的原因在于它的二阶导数非常缓慢地趋近于零。这使得梯度保持在激活函数的线性区域,有助于解决梯度消失问题。我们将在本章稍后了解梯度消失问题。

在时间t时,输出向量y[t]是权重矩阵V与隐藏状态h[t]的乘积,并对乘积应用softmax,使得结果向量成为一组输出概率:

Keras 提供了 SimpleRNN(更多信息请参考:keras.io/layers/recurrent/)递归层,它包含了我们到目前为止所见的所有逻辑,以及更高级的变种,如 LSTM 和 GRU,后者将在本章稍后介绍,因此不严格要求理解它们的工作原理就能开始使用它们。然而,理解其结构和方程在你需要自己构建 RNN 来解决特定问题时是非常有帮助的。

使用 Keras 的 SimpleRNN — 生成文本

RNN 已被自然语言处理NLP)领域广泛应用于各种任务。其中一个应用是构建语言模型。语言模型使我们能够预测给定前一个单词的情况下,文本中某个单词的概率。语言模型在机器翻译、拼写纠正等各类高级任务中至关重要。

能够根据前一个单词预测下一个单词的能力,带来了一个生成模型,让我们通过从输出概率中采样来生成文本。在语言建模中,我们的输入通常是一个单词序列,输出是一个预测的单词序列。使用的训练数据是现有的未标注文本,我们将时间t时的标签y[t]设置为时间t+1时的输入x[t+1]

在我们使用 Keras 构建 RNN 的第一个例子中,我们将训练一个基于字符的语言模型,使用《爱丽丝梦游仙境》这本书的文本,根据前 10 个字符预测下一个字符。我们选择构建一个基于字符的模型,因为它的词汇量较小,训练速度较快。其思想与使用基于单词的语言模型相同,只不过我们使用的是字符而非单词。然后,我们将使用训练好的模型生成一些相同风格的文本。

首先,我们导入必要的模块:

from __future__ import print_function
from keras.layers import Dense, Activation
from keras.layers.recurrent import SimpleRNN
from keras.models import Sequential
from keras.utils.visualize_util import plot
import numpy as np

我们从《爱丽丝梦游仙境》中的文本读取输入文本,该文本位于 Project Gutenberg 网站上(www.gutenberg.org/files/11/11-0.txt)。该文件包含换行符和非 ASCII 字符,因此我们做了一些初步清理,并将内容写入一个名为text的变量中:

fin = open("../data/alice_in_wonderland.txt", 'rb')
lines = []
for line in fin:
    line = line.strip().lower()
    line = line.decode("ascii", "ignore")
    if len(line) == 0:
        continue
    lines.append(line)
fin.close()
text = " ".join(lines)

由于我们构建的是字符级 RNN,因此我们的词汇表是文本中出现的字符集合。在我们的例子中有 42 个字符。由于我们将处理这些字符的索引而不是字符本身,以下代码片段创建了必要的查找表:

chars = set([c for c in text])
nb_chars = len(chars)
char2index = dict((c, i) for i, c in enumerate(chars))
index2char = dict((i, c) for i, c in enumerate(chars))

下一步是创建输入文本和标签文本。我们通过由STEP变量(在我们的例子中是1)给定的字符数遍历文本,然后提取一个由SEQLEN变量(在我们的例子中是10)决定大小的文本片段。片段之后的下一个字符就是我们的标签字符:

SEQLEN = 10
STEP = 1

input_chars = []
label_chars = []
for i in range(0, len(text) - SEQLEN, STEP):
    input_chars.append(text[i:i + SEQLEN])
    label_chars.append(text[i + SEQLEN])

使用前面的代码,输入文本和标签文本对于文本it turned into a pig将如下所示:

it turned -> i
 t turned i -> n
 turned in -> t
turned int -> o
urned into ->
rned into -> a
ned into a ->
ed into a -> p
d into a p -> i
 into a pi -> g

下一步是将这些输入文本和标签文本向量化。RNN 的每一行输入都对应前面展示的其中一行输入文本。输入中有SEQLEN个字符,并且由于我们的词汇表大小由nb_chars给定,我们将每个输入字符表示为大小为(nb_chars)的独热编码向量。因此,每一行输入是一个大小为(SEQLENnb_chars)的张量。我们的输出标签是一个单一字符,因此与我们表示输入中每个字符的方式类似,它表示为大小为(nb_chars)的独热向量。因此,每个标签的形状为nb_chars

X = np.zeros((len(input_chars), SEQLEN, nb_chars), dtype=np.bool)
y = np.zeros((len(input_chars), nb_chars), dtype=np.bool)
for i, input_char in enumerate(input_chars):
    for j, ch in enumerate(input_char):
        X[i, j, char2index[ch]] = 1
    y[i, char2index[label_chars[i]]] = 1

最后,我们准备好构建模型。我们将 RNN 的输出维度定义为 128。这是一个需要通过实验确定的超参数。一般来说,如果选择的大小过小,模型将没有足够的能力生成良好的文本,并且你将看到长时间重复的字符或重复的词组。另一方面,如果选择的值过大,模型将有过多的参数,并且需要更多的数据来有效地训练。我们希望返回一个单一的字符作为输出,而不是一串字符,因此设置return_sequences=False。我们已经看到 RNN 的输入形状是(SEQLENnb_chars)。此外,我们设置unroll=True,因为它可以提高 TensorFlow 后端的性能。

RNN 连接到一个密集(全连接)层。密集层有(nb_char)个单元,输出词汇表中每个字符的分数。密集层的激活函数是 softmax,它将分数归一化为概率。具有最高概率的字符被选为预测结果。我们使用类别交叉熵损失函数来编译模型,这是一种适用于分类输出的良好损失函数,并使用 RMSprop 优化器:

HIDDEN_SIZE = 128
BATCH_SIZE = 128
NUM_ITERATIONS = 25
NUM_EPOCHS_PER_ITERATION = 1
NUM_PREDS_PER_EPOCH = 100

model = Sequential()
model.add(SimpleRNN(HIDDEN_SIZE, return_sequences=False,
    input_shape=(SEQLEN, nb_chars),
    unroll=True))
model.add(Dense(nb_chars))
model.add(Activation("softmax"))

model.compile(loss="categorical_crossentropy", optimizer="rmsprop")

我们的训练方法与之前所见的有所不同。到目前为止,我们的方法是训练模型固定次数的迭代次数,然后在一部分保留的测试数据上进行评估。由于我们这里没有任何标注数据,我们训练模型进行一次迭代(NUM_EPOCHS_PER_ITERATION=1),然后进行测试。我们以这种方式继续训练 25 次(NUM_ITERATIONS=25),直到看到可理解的输出为止。所以,实际上,我们是训练 NUM_ITERATIONS 次迭代,每次迭代后测试模型。

我们的测试包括从模型中生成一个字符,给定一个随机输入,然后去掉输入中的第一个字符,并附加我们上一轮预测的字符,再从模型中生成另一个字符。我们重复这个过程 100 次(NUM_PREDS_PER_EPOCH=100),并生成和打印出结果字符串。这个字符串能为我们提供模型质量的指示:

for iteration in range(NUM_ITERATIONS):
    print("=" * 50)
    print("Iteration #: %d" % (iteration))
    model.fit(X, y, batch_size=BATCH_SIZE, epochs=NUM_EPOCHS_PER_ITERATION)

    test_idx = np.random.randint(len(input_chars))
    test_chars = input_chars[test_idx]
    print("Generating from seed: %s" % (test_chars))
    print(test_chars, end="")
    for i in range(NUM_PREDS_PER_EPOCH):
        Xtest = np.zeros((1, SEQLEN, nb_chars))
        for i, ch in enumerate(test_chars):
            Xtest[0, i, char2index[ch]] = 1
        pred = model.predict(Xtest, verbose=0)[0]
        ypred = index2char[np.argmax(pred)]
        print(ypred, end="")
        # move forward with test_chars + ypred
        test_chars = test_chars[1:] + ypred
print()

运行的输出如下所示。如你所见,模型一开始预测的是胡言乱语,但在第 25 次迭代结束时,它已经学会了相对准确地拼写单词,尽管它在表达连贯的思想方面仍然存在问题。这个模型的惊人之处在于它是基于字符的,并且不理解单词的含义,然而它还是学会了拼写出看起来像是原始文本的单词:

生成下一个字符或单词并不是你可以用这种模型做的唯一事情。这种模型已经成功地用于股市预测(更多信息请参见文章:Financial Market Time Series Prediction with Recurrent Neural Networks,A. Bernal、S. Fok 和 R. Pidaparthi,2012),以及生成古典音乐(更多信息请参见文章:DeepBach: A Steerable Model for Bach Chorales Generation,G. Hadjeres 和 F. Pachet,arXiv:1612.01010,2016),这些只是一些有趣的应用实例。Andrej Karpathy 在他的博客文章中涵盖了一些其他有趣的示例,比如生成假维基百科页面、代数几何证明和 Linux 源代码,文章标题为:The Unreasonable Effectiveness of Recurrent Neural Networks,网址为:karpathy.github.io/2015/05/21/rnn-effectiveness/

这个示例的源代码可以在章节的代码下载中找到,文件名为alice_chargen_rnn.py。数据可以从古腾堡计划获取。

RNN 拓扑结构

MLP 和 CNN 架构的 API 是有限的。两种架构都接受一个固定大小的张量作为输入,并输出一个固定大小的张量;它们将在模型的层数所决定的固定步骤数中执行从输入到输出的转换。RNN 没有这个限制——你可以在输入、输出或两者中都有序列。这意味着 RNN 可以以多种方式排列,以解决特定问题。

正如我们所学,RNN 将输入向量与前一状态向量结合,生成一个新的状态向量。这可以类比为运行一个带有输入和一些内部变量的程序。因此,RNN 可以被看作是本质上描述计算机程序。事实上,已有研究表明,RNN 是图灵完备的(更多信息请参考文章:On the Computational Power of Neural Nets,H. T. Siegelmann 和 E. D. Sontag,计算学习理论第五届年度研讨会论文集,ACM,1992),即在适当的权重下,它们能够模拟任意程序。

这种能够处理序列的特性产生了许多常见的拓扑结构,接下来我们将讨论其中的一些:

所有这些不同的拓扑结构都来源于前面图示的相同基本结构。在这个基本拓扑结构中,所有的输入序列长度相同,并且每个时间步都会生成一个输出。我们已经通过字符级别的 RNN 生成《爱丽丝梦游仙境》中的单词,看到过这样的例子。

另一个多对多的 RNN 例子是如(b)所示的机器翻译网络,它是序列到序列(sequence-to-sequence)网络家族的一部分(更多信息请参考:Grammar as a Foreign Language,O. Vinyals,神经信息处理系统进展,2015)。这些网络接受一个序列并生成另一个序列。在机器翻译的情况下,输入可以是一个句子中的英文单词序列,输出则是翻译后的西班牙语句子中的单词。在使用序列到序列模型进行词性标注POS)的情况下,输入可以是一个句子中的单词,输出则是相应的词性标签。与之前的拓扑结构不同的是,在某些时间步没有输入,在其他时间步没有输出。我们将在本章后面看到这种网络的例子。

其他变体是如(c)所示的单对多网络,一个例子是图像描述生成网络(更多信息请参考文章:Deep Visual-Semantic Alignments for Generating Image Descriptions,A. Karpathy 和 F. Li,计算机视觉与模式识别 IEEE 会议论文集,2015),其中输入是图像,输出是一个单词序列。

类似地,图(d)所示的一个多对一网络示例可能是一个句子情感分析网络,其中输入是一个单词序列,输出是一个正面或负面的情感(欲了解更多信息,请参考文章:递归深度模型用于情感树库上的语义组合性,作者:R. Socher,《自然语言处理经验方法会议论文集(EMNLP)》第 1631 卷,2013 年)。我们将在本章后面看到该拓扑结构的一个(相比于引用的模型,简化了许多)示例。

梯度消失和爆炸

就像传统神经网络一样,训练 RNN 也涉及反向传播。不同之处在于,由于所有时间步共享参数,因此每个输出的梯度不仅依赖于当前时间步,还依赖于之前的时间步。这个过程叫做时间反向传播BPTT)(欲了解更多信息,请参考文章:通过反向传播错误学习内部表示,作者:G. E. Hinton、D. E. Rumelhart 和 R. J. Williams,《平行分布处理:认知微观结构的探索 1》,1985 年):

考虑前面图示的小型三层 RNN。在前向传播过程中(通过实线表示),网络产生的预测结果与标签进行比较,以计算每个时间步的损失L[t]。在反向传播过程中(通过虚线表示),计算损失相对于参数UVW的梯度,并通过梯度之和更新参数。

以下方程显示了损失相对于W的梯度,这个矩阵编码了长期依赖的权重。我们专注于这一部分的更新,因为它是梯度消失和爆炸问题的根源。损失相对于矩阵UV的另外两个梯度也以类似的方式在所有时间步中求和:

现在让我们看看在最后一个时间步(t=3)时损失的梯度会发生什么。正如你所看到的,这个梯度可以通过链式法则分解为三个子梯度的乘积。隐藏状态h2相对于W的梯度可以进一步分解为每个隐藏状态相对于前一个隐藏状态的梯度之和。最后,每个隐藏状态相对于前一个隐藏状态的梯度可以进一步分解为当前隐藏状态与前一个隐藏状态的梯度的乘积:

类似的计算也用于计算损失L[1]L[2](在时间步 1 和 2 时)相对于W的梯度,并将它们汇总为W的梯度更新。我们在本书中不会进一步探讨数学内容。如果你想自己研究,WILDML 博客文章(goo.gl/l06lbX)对 BPTT 有很好的解释,包括更详细的数学推导过程。

对于我们的目的,上面方程中梯度的最终形式告诉我们为什么 RNN 会有消失梯度和爆炸梯度的问题。考虑一下隐藏状态相对于前一个隐藏状态的单独梯度小于 1 的情况。当我们在多个时间步进行反向传播时,梯度的乘积变得越来越小,导致消失梯度问题。类似地,如果梯度大于 1,乘积会变得越来越大,导致爆炸梯度问题。

消失梯度的影响是,远离的步骤产生的梯度对学习过程没有任何贡献,因此 RNN 最终无法学习长距离的依赖关系。消失梯度问题也会发生在传统神经网络中,只是由于 RNN 通常有更多的层(时间步),反向传播必须在这些层之间发生,所以这个问题在 RNN 中更加明显。

爆炸梯度更容易被检测到,梯度会变得非常大,然后变成非数值NaN),训练过程会崩溃。爆炸梯度问题可以通过将梯度裁剪到预定义的阈值来控制,正如论文《On the Difficulty of Training Recurrent Neural Networks》中所讨论的,作者为 R. Pascanu、T. Mikolov 和 Y. Bengio,ICML,Pp 1310-1318,2013 年。

尽管有几种方法可以最小化消失梯度问题,比如适当初始化W矩阵,使用 ReLU 代替tanh层,以及使用无监督方法预训练层,最流行的解决方案是使用 LSTM 或 GRU 架构。这些架构被设计用来处理消失梯度问题,并更有效地学习长期依赖关系。我们将在本章后面深入了解 LSTM 和 GRU 架构。

长短期记忆(Long short term memory — LSTM)

LSTM 是 RNN 的一种变种,能够学习长期依赖关系。LSTM 最早由 Hochreiter 和 Schmidhuber 提出,并经过许多其他研究者的改进。它们在许多问题中表现良好,是最广泛使用的 RNN 类型。

我们已经看到 SimpleRNN 如何使用前一时间步的隐藏状态和当前输入通过tanh层来实现递归。LSTM 也以类似的方式实现递归,但它们并非通过单一的tanh层,而是通过四个层在非常特定的方式下相互作用。以下图示说明了在时间步t对隐藏状态所应用的变换:

图表看起来很复杂,但我们可以逐个组件地查看它。图表上方的线代表细胞状态 c,表示单元的内部记忆。底部的线是隐藏状态,ifog 门是 LSTM 绕过梯度消失问题的机制。在训练过程中,LSTM 会学习这些门的参数。

为了更深入理解这些门如何调节 LSTM 的隐藏状态,我们可以考虑以下方程式,展示如何根据上一时间步的隐藏状态 h[t-1] 计算当前时间步 t 的隐藏状态 h[t]

这里 ifo 分别是输入门、遗忘门和输出门。它们使用相同的方程计算,但有不同的参数矩阵。sigmoid 函数调节这些门的输出,使其在 0 到 1 之间,因此输出向量可以与另一个向量逐元素相乘,从而定义第二个向量可以通过第一个向量的程度。

遗忘门定义了你希望通过多少前一状态 h[t-1]。输入门定义了你希望让多少当前输入 x[t] 的新计算状态通过,输出门定义了你希望暴露多少内部状态到下一层。内部隐藏状态 g 是基于当前输入 x[t] 和前一隐藏状态 h[t-1] 计算的。注意,g 的方程与 SimpleRNN 单元的方程是相同的,但在这种情况下,我们将通过输入门 i 的输出来调节输出。

给定 ifog,我们现在可以计算当前时间步 t 的细胞状态 c[t],其计算方式是 c[t-1](上一时间步的状态)与遗忘门的乘积,再加上 g(当前状态)与输入门 i 的乘积。因此,这基本上是将以前的记忆与新的输入结合的一种方式——将遗忘门设为 0 会忽略旧的记忆,而将输入门设为 0 会忽略新计算的状态。

最后,当前时间步 t 的隐藏状态 h[t] 通过将记忆 c[t] 与输出门相乘来计算。

需要注意的一点是,LSTM 是 SimpleRNN 单元的直接替代,唯一的区别是 LSTM 对梯度消失问题具有抵抗力。你可以将网络中的 RNN 单元替换为 LSTM,而无需担心任何副作用。通常情况下,你会看到更好的结果,但训练时间更长。

如果你想了解更多内容,WILDML 博客有一篇关于这些 LSTM 门及其工作原理的详细解释。想要更直观的讲解,可以看看 Christopher Olah 的博客文章:理解 LSTM (colah.github.io/posts/2015-08-Understanding-LSTMs/),他一步一步地带你走过这些计算,并在每个步骤中附有插图。

LSTM 与 Keras—情感分析

Keras 提供了一个 LSTM 层,我们将在这里使用它来构建和训练一个多对一的 RNN。我们的网络输入一个句子(一系列单词),输出一个情感值(正面或负面)。我们的训练集来自 Kaggle 上 UMICH SI650 情感分类比赛的数据集,包含约 7,000 个短句子 (inclass.kaggle.com/c/si650winter11)。每个句子会被标记为 10,分别表示正面或负面情感,网络将学习预测这些情感标签。

我们像往常一样从导入库开始:

from keras.layers.core import Activation, Dense, Dropout, SpatialDropout1D
from keras.layers.embeddings import Embedding
from keras.layers.recurrent import LSTM
from keras.models import Sequential
from keras.preprocessing import sequence
from sklearn.model_selection import train_test_split
import collections
import matplotlib.pyplot as plt
import nltk
import numpy as np
import os

在开始之前,我们希望对数据进行一些探索性分析。具体来说,我们需要了解语料库中有多少个唯一单词,以及每个句子中有多少个单词:

maxlen = 0
word_freqs = collections.Counter()
num_recs = 0
ftrain = open(os.path.join(DATA_DIR, "umich-sentiment-train.txt"), 'rb')
for line in ftrain:
    label, sentence = line.strip().split("t")
    words = nltk.word_tokenize(sentence.decode("ascii", "ignore").lower())
    if len(words) > maxlen:
        maxlen = len(words)
    for word in words:
        word_freqs[word] += 1
    num_recs += 1
ftrain.close()

使用这些信息,我们得到如下关于语料库的估算:

maxlen : 42
len(word_freqs) : 2313

使用唯一单词的数量 len(word_freqs),我们将词汇表大小设置为一个固定的数字,并将所有其他单词视为 词汇表外 (OOV) 单词,并用伪词 UNK(表示未知)替换它们。在预测时,这将允许我们将以前未见过的单词处理为 OOV 单词。

句子中的单词数量 (maxlen) 允许我们设置固定的序列长度,并将较短的句子进行零填充,将较长的句子截断为该长度。尽管 RNN 能处理可变长度的序列,通常的做法是通过像上述方法一样填充和截断,或者根据序列长度将输入分成不同的批次。我们将在这里使用前者。对于后者,Keras 建议使用大小为 1 的批次(更多信息请参考:github.com/fchollet/keras/issues/40)。

根据前面的估算,我们将 VOCABULARY_SIZE 设置为 2002。这包括我们词汇表中的 2,000 个单词,加上 UNK 伪单词和 PAD 伪单词(用于将句子填充到固定的单词数量),在我们的情况下是由 MAX_SENTENCE_LENGTH 给定的 40。

DATA_DIR = "../data"

MAX_FEATURES = 2000
MAX_SENTENCE_LENGTH = 40

接下来,我们需要一对查找表。每一行输入到 RNN 的都是一个单词索引序列,索引按训练集中单词的出现频率从高到低排序。两个查找表使我们能够根据单词查找索引,或根据索引查找单词。这也包括 PADUNK 伪单词:

vocab_size = min(MAX_FEATURES, len(word_freqs)) + 2
word2index = {x[0]: i+2 for i, x in
enumerate(word_freqs.most_common(MAX_FEATURES))}
word2index["PAD"] = 0
word2index["UNK"] = 1
index2word = {v:k for k, v in word2index.items()}

接下来,我们将输入的句子转换为单词索引序列,并将它们填充到 MAX_SENTENCE_LENGTH 个单词。由于我们的输出标签是二元的(正面或负面情感),我们不需要处理标签:

X = np.empty((num_recs, ), dtype=list)
y = np.zeros((num_recs, ))
i = 0
ftrain = open(os.path.join(DATA_DIR, "umich-sentiment-train.txt"), 'rb')
for line in ftrain:
    label, sentence = line.strip().split("t")
    words = nltk.word_tokenize(sentence.decode("ascii", "ignore").lower())
    seqs = []
    for word in words:
        if word2index.has_key(word):
            seqs.append(word2index[word])
        else:
            seqs.append(word2index["UNK"])
    X[i] = seqs
    y[i] = int(label)
    i += 1
ftrain.close()
X = sequence.pad_sequences(X, maxlen=MAX_SENTENCE_LENGTH)

最后,我们将训练集划分为 80-20 的训练集和测试集:

Xtrain, Xtest, ytrain, ytest = train_test_split(X, y, test_size=0.2, random_state=42)

下图展示了我们 RNN 的结构:

每一行的输入是一个单词索引序列。序列的长度由MAX_SENTENCE_LENGTH给定。张量的第一维被设置为None,表示批次大小(每次输入到网络中的记录数)在定义时尚不可知;它将在运行时通过batch_size参数指定。因此,假设批次大小尚未确定,输入张量的形状是(None, MAX_SENTENCE_LENGTH, 1)。这些张量被输入到一个嵌入层,大小为EMBEDDING_SIZE,其权重以小的随机值初始化,并在训练期间学习。这个层会将张量转换为形状(None, MAX_SENTENCE_LENGTH, EMBEDDING_SIZE)。嵌入层的输出被输入到一个 LSTM 中,序列长度为MAX_SENTENCE_LENGTH,输出层大小为HIDDEN_LAYER_SIZE,因此 LSTM 的输出是一个形状为(None, HIDDEN_LAYER_SIZE, MAX_SENTENCE_LENGTH)的张量。默认情况下,LSTM 将在最后一个序列中输出一个形状为(None, HIDDEN_LAYER_SIZE)的张量(return_sequences=False)。这个输出被输入到一个输出大小为1的全连接层,并使用 Sigmoid 激活函数,因此它将输出0(负面评价)或1(正面评价)。

我们使用二进制交叉熵损失函数来编译模型,因为它预测的是二进制值,并且使用 Adam 优化器,这是一种良好的通用优化器。请注意,超参数EMBEDDING_SIZEHIDDEN_LAYER_SIZEBATCH_SIZENUM_EPOCHS(如下所示设置为常量)是在多次运行中通过实验调优的:

EMBEDDING_SIZE = 128
HIDDEN_LAYER_SIZE = 64
BATCH_SIZE = 32
NUM_EPOCHS = 10

model = Sequential()
model.add(Embedding(vocab_size, EMBEDDING_SIZE,
input_length=MAX_SENTENCE_LENGTH))
model.add(SpatialDropout1D(Dropout(0.2)))
model.add(LSTM(HIDDEN_LAYER_SIZE, dropout=0.2, recurrent_dropout=0.2))
model.add(Dense(1))
model.add(Activation("sigmoid"))

model.compile(loss="binary_crossentropy", optimizer="adam",
    metrics=["accuracy"])

然后,我们将网络训练10个周期(NUM_EPOCHS),每个批次大小为32BATCH_SIZE)。在每个周期,我们使用测试数据验证模型:

history = model.fit(Xtrain, ytrain, batch_size=BATCH_SIZE, epochs=NUM_EPOCHS,
    validation_data=(Xtest, ytest))

这一步的输出显示了在多个周期中损失值如何下降,准确率如何上升:

我们还可以使用以下代码绘制损失值和准确率值随时间的变化:

plt.subplot(211)
plt.title("Accuracy")
plt.plot(history.history["acc"], color="g", label="Train")
plt.plot(history.history["val_acc"], color="b", label="Validation")
plt.legend(loc="best")

plt.subplot(212)
plt.title("Loss")
plt.plot(history.history["loss"], color="g", label="Train")
plt.plot(history.history["val_loss"], color="b", label="Validation")
plt.legend(loc="best")

plt.tight_layout()
plt.show()

上述示例的输出如下:

最后,我们对模型进行全测试集评估,并打印分数和准确率。我们还从测试集中随机挑选了几个句子,并打印 RNN 的预测结果、标签和实际句子:

score, acc = model.evaluate(Xtest, ytest, batch_size=BATCH_SIZE)
print("Test score: %.3f, accuracy: %.3f" % (score, acc))

for i in range(5):
    idx = np.random.randint(len(Xtest))
    xtest = Xtest[idx].reshape(1,40)
    ylabel = ytest[idx]
    ypred = model.predict(xtest)[0][0]
    sent = " ".join([index2word[x] for x in xtest[0].tolist() if x != 0])
    print("%.0ft%dt%s" % (ypred, ylabel, sent))

从结果中可以看出,我们得到了接近 99%的准确率。模型对这一特定数据集的预测完全与标签匹配,尽管并非所有预测都如此:

如果你想在本地运行此代码,你需要从 Kaggle 网站获取数据。

本示例的源代码可以在本章的代码下载中找到文件umich_sentiment_lstm.py

门控递归单元 — GRU

GRU 是 LSTM 的一种变体,由 K. Cho 提出(更多信息请参考:使用 RNN 编码器-解码器进行统计机器翻译的短语表示学习,K. Cho,arXiv:1406.1078,2014)。它保留了 LSTM 对梯度消失问题的抗性,但其内部结构更为简单,因此训练速度更快,因为更新其隐藏状态所需的计算较少。GRU 单元的门控机制如下面的图所示:

与 LSTM 单元的输入、遗忘和输出门不同,GRU 单元只有两个门,

一个更新门 z 和一个重置门 r。更新门定义了保留多少前一段记忆,而重置门定义了如何将新输入与前一段记忆结合。与 LSTM 不同,这里没有与隐藏状态区分开的持久性单元状态。以下方程定义了 GRU 中的门控机制:

根据几项实证评估(更多信息请参考文章:递归网络架构的实证探索,R. Jozefowicz,W. Zaremba,I. Sutskever,JMLR,2015 和 门控递归神经网络在序列建模中的实证评估,J. Chung,arXiv:1412.3555,2014),GRU 和 LSTM 的性能相当,并且没有简单的方法来为特定任务推荐其中之一。尽管 GRU 的训练速度更快且需要更少的数据来进行泛化,但在数据充足的情况下,LSTM 更强的表达能力可能会导致更好的结果。像 LSTM 一样,GRU 可以作为 SimpleRNN 单元的替代品。

Keras 提供了 LSTMGRU 的内置实现,以及我们之前看到的 SimpleRNN 类。

Keras 中的 GRU — 词性标注

Keras 提供了 GRU 的实现,我们将在这里使用它来构建一个进行词性标注(POS tagging)的网络。POS 是在多个句子中以相同方式使用的单词的语法类别。POS 的例子有名词、动词、形容词等等。例如,名词通常用于标识事物,动词通常用于标识它们的动作,形容词则用于描述这些事物的某些属性。词性标注曾经是手动完成的,但现在通常使用统计模型自动完成。近年来,深度学习也被应用于这一问题(更多信息请参考文章:几乎从零开始的自然语言处理,R. Collobert,机器学习研究期刊,第 2493-2537 页,2011)。

对于我们的训练数据,我们需要带有词性标签的句子。Penn Treebank(catalog.ldc.upenn.edu/ldc99t42)就是一个这样的数据集,它是一个人工注释的约 450 万词的美式英语语料库。然而,它是一个非免费的资源。Penn Treebank 的 10%样本可以作为 NLTK 的一部分免费获取(www.nltk.org/),我们将使用它来训练我们的网络。

我们的模型将接收一个句子的单词序列,并输出每个单词的相应 POS 标签。因此,对于输入序列[The, cat, sat, on, the, mat, .],输出的序列将是 POS 符号[DT, NN, VB, IN, DT, NN]。

我们从导入开始:

from keras.layers.core import Activation, Dense, Dropout, RepeatVector, SpatialDropout1D
from keras.layers.embeddings import Embedding
from keras.layers.recurrent import GRU
from keras.layers.wrappers import TimeDistributed
from keras.models import Sequential
from keras.preprocessing import sequence
from keras.utils import np_utils
from sklearn.model_selection import train_test_split
import collections
import nltk
import numpy as np
import os

然后我们从 NLTK 下载适合我们下游代码的数据格式。具体来说,这些数据作为 NLTK Treebank 语料库的一部分以解析的形式提供。我们使用以下 Python 代码将这些数据下载到两个并行文件中,一个存储句子中的单词,另一个存储 POS 标签:

DATA_DIR = "../data"

fedata = open(os.path.join(DATA_DIR, "treebank_sents.txt"), "wb")
ffdata = open(os.path.join(DATA_DIR, "treebank_poss.txt"), "wb")

sents = nltk.corpus.treebank.tagged_sents()
for sent in sents:
    words, poss = [], []
    for word, pos in sent:
        if pos == "-NONE-":
            continue
        words.append(word)
        poss.append(pos)
    fedata.write("{:s}n".format(" ".join(words)))
    ffdata.write("{:s}n".format(" ".join(poss)))

fedata.close()
ffdata.close()

再次,我们想稍微探索一下数据,找出应该设置的词汇表大小。这次,我们需要考虑两个不同的词汇表,一个是单词的源词汇表,另一个是 POS 标签的目标词汇表。我们需要找出每个词汇表中唯一单词的数量。我们还需要找出训练语料库中每个句子的最大单词数和记录的数量。由于 POS 标注的独特一对一性质,最后两个值对于两个词汇表是相同的:

def parse_sentences(filename):
    word_freqs = collections.Counter()
    num_recs, maxlen = 0, 0
    fin = open(filename, "rb")
    for line in fin:
        words = line.strip().lower().split()
        for word in words:
            word_freqs[word] += 1
        if len(words) > maxlen:
            maxlen = len(words)
        num_recs += 1
    fin.close()
    return word_freqs, maxlen, num_recs

    s_wordfreqs, s_maxlen, s_numrecs = parse_sentences(
    os.path.join(DATA_DIR, "treebank_sents.txt"))
    t_wordfreqs, t_maxlen, t_numrecs = parse_sentences(
    os.path.join(DATA_DIR, "treebank_poss.txt"))
print(len(s_wordfreqs), s_maxlen, s_numrecs, len(t_wordfreqs), t_maxlen, t_numrecs)

运行这段代码告诉我们,有 10,947 个独特的单词和 45 个独特的 POS 标签。最大句子长度是 249,而 10%数据集中的句子数量为 3,914。根据这些信息,我们决定仅考虑源词汇表中的前 5,000 个单词。我们的目标词汇表有 45 个独特的 POS 标签,我们希望能够预测所有这些标签,因此我们会将所有这些标签考虑进词汇表。最后,我们将 250 设置为最大序列长度:

MAX_SEQLEN = 250
S_MAX_FEATURES = 5000
T_MAX_FEATURES = 45

就像我们的情感分析示例一样,输入的每一行将表示为单词索引的序列。对应的输出将是 POS 标签索引的序列。因此,我们需要构建查找表,以便在单词/POS 标签与其相应的索引之间进行转换。以下是实现该功能的代码。在源端,我们构建了一个词汇索引,并增加了两个额外的槽位来存储PADUNK伪单词。在目标端,我们不会丢弃任何单词,因此不需要UNK伪单词:

s_vocabsize = min(len(s_wordfreqs), S_MAX_FEATURES) + 2
s_word2index = {x[0]:i+2 for i, x in
enumerate(s_wordfreqs.most_common(S_MAX_FEATURES))}
s_word2index["PAD"] = 0
s_word2index["UNK"] = 1
s_index2word = {v:k for k, v in s_word2index.items()}

t_vocabsize = len(t_wordfreqs) + 1
t_word2index = {x[0]:i for i, x in
enumerate(t_wordfreqs.most_common(T_MAX_FEATURES))}
t_word2index["PAD"] = 0
t_index2word = {v:k for k, v in t_word2index.items()}

下一步是构建我们的数据集,将其输入到我们的网络中。我们将使用这些查找表将输入的句子转换为长度为MAX_SEQLEN250)的词 ID 序列。标签需要被构建为一个大小为T_MAX_FEATURES + 1(46)的独热向量序列,长度也为MAX_SEQLEN250)。build_tensor函数从两个文件读取数据并将其转换为输入和输出张量。额外的默认参数被传递进来构建输出张量。这会触发对np_utils.to_categorical()的调用,将输出序列的 POS 标签 ID 转换为独热向量表示:

def build_tensor(filename, numrecs, word2index, maxlen,
        make_categorical=False, num_classes=0):
    data = np.empty((numrecs, ), dtype=list)
    fin = open(filename, "rb")
    i = 0
    for line in fin:
        wids = []
        for word in line.strip().lower().split():
            if word2index.has_key(word):
                wids.append(word2index[word])
            else:
                wids.append(word2index["UNK"])
        if make_categorical:
            data[i] = np_utils.to_categorical(wids, 
                num_classes=num_classes)
        else:
            data[i] = wids
        i += 1
    fin.close()
    pdata = sequence.pad_sequences(data, maxlen=maxlen)
    return pdata

X = build_tensor(os.path.join(DATA_DIR, "treebank_sents.txt"),
    s_numrecs, s_word2index, MAX_SEQLEN)
Y = build_tensor(os.path.join(DATA_DIR, "treebank_poss.txt"),
    t_numrecs, t_word2index, MAX_SEQLEN, True, t_vocabsize)

然后我们可以将数据集分割为 80-20 的训练集和测试集:

Xtrain, Xtest, Ytrain, Ytest = train_test_split(X, Y, test_size=0.2, random_state=42)

下图展示了我们网络的示意图。它看起来很复杂,让我们来逐步解析:

如前所述,假设批处理大小尚未确定,网络的输入是一个形状为(None, MAX_SEQLEN, 1)的词 ID 张量。该张量通过一个嵌入层,该层将每个词转换为形状为(EMBED_SIZE)的稠密向量,因此该层输出的张量形状为(None, MAX_SEQLEN, EMBED_SIZE)。该张量被送入编码器 GRU,其输出大小为HIDDEN_SIZE。GRU 被设置为在看到大小为MAX_SEQLEN的序列后返回一个单一的上下文向量(return_sequences=False),因此 GRU 层输出的张量形状为(None, HIDDEN_SIZE)

然后,这个上下文向量通过 RepeatVector 层复制成形状为(None, MAX_SEQLEN, HIDDEN_SIZE)的张量,并送入解码器 GRU 层。接着该张量进入一个稠密层,输出形状为(None, MAX_SEQLEN, t_vocab_size)。稠密层的激活函数是 softmax。该张量每一列的 argmax 值就是该位置单词预测的 POS 标签的索引。

模型定义如下所示:EMBED_SIZEHIDDEN_SIZEBATCH_SIZENUM_EPOCHS是超参数,这些值是在尝试多个不同值后确定的。由于我们有多个类别的标签,模型使用categorical_crossentropy作为损失函数,并且使用流行的adam优化器:

EMBED_SIZE = 128
HIDDEN_SIZE = 64
BATCH_SIZE = 32
NUM_EPOCHS = 1

model = Sequential()
model.add(Embedding(s_vocabsize, EMBED_SIZE,
input_length=MAX_SEQLEN))
model.add(SpatialDropout1D(Dropout(0.2)))
model.add(GRU(HIDDEN_SIZE, dropout=0.2, recurrent_dropout=0.2))
model.add(RepeatVector(MAX_SEQLEN))
model.add(GRU(HIDDEN_SIZE, return_sequences=True))
model.add(TimeDistributed(Dense(t_vocabsize)))
model.add(Activation("softmax"))

model.compile(loss="categorical_crossentropy", optimizer="adam",
    metrics=["accuracy"])

我们训练这个模型进行一个周期。该模型非常复杂,包含许多参数,并且在第一次训练后开始出现过拟合。在接下来的训练周期中,如果将相同的数据输入模型多次,模型开始对训练数据过拟合,并且在验证数据上的表现变差:

model.fit(Xtrain, Ytrain, batch_size=BATCH_SIZE, epochs=NUM_EPOCHS,
    validation_data=[Xtest, Ytest])

score, acc = model.evaluate(Xtest, Ytest, batch_size=BATCH_SIZE)
print("Test score: %.3f, accuracy: %.3f" % (score, acc))

训练和评估的输出如下所示。如你所见,模型在第一次训练后表现得相当不错:

与实际的 RNN 类似,Keras 中的三种递归类(SimpleRNNLSTMGRU)是可以互换的。为了演示,我们只需将之前程序中的所有 GRU 替换为 LSTM,然后重新运行程序。模型定义和导入语句是唯一改变的部分:

from keras.layers.recurrent import GRU

model = Sequential()
model.add(Embedding(s_vocabsize, EMBED_SIZE,
input_length=MAX_SEQLEN))
model.add(SpatialDropout1D(Dropout(0.2)))
model.add(GRU(HIDDEN_SIZE, dropout=0.2, recurrent_dropout=0.2))
model.add(RepeatVector(MAX_SEQLEN))
model.add(GRU(HIDDEN_SIZE, return_sequences=True))
model.add(TimeDistributed(Dense(t_vocabsize)))
model.add(Activation("softmax"))

从输出结果可以看出,基于 GRU 的网络结果与我们之前的基于 LSTM 的网络相当接近。

序列到序列模型是一类非常强大的模型。其最典型的应用是机器翻译,但还有许多其他应用,比如之前的例子。实际上,许多 NLP 任务在更高的层次上,诸如命名实体识别(更多信息请参考文章:命名实体识别与长短期记忆,J. Hammerton 著,发表于《第七届自然语言学习会议》,HLT-NAACL,计算语言学会,2003 年)和句子解析(更多信息请参考文章:语法作为外语,O. Vinyals 著,发表于《神经信息处理系统进展》,2015 年),以及更复杂的网络,如图像描述生成(更多信息请参考文章:生成图像描述的深度视觉-语义对齐,A. Karpathy 和 F. Li 著,发表于《IEEE 计算机视觉与模式识别会议论文集》,2015 年),都可以视为序列到序列组合模型的例子。

本示例的完整代码可以在本章节的代码下载文件 pos_tagging_gru.py 中找到。

双向 RNN

在给定的时间步 t,RNN 的输出依赖于所有之前时间步的输出。然而,输出也完全可能依赖于未来的输出。这对于 NLP 等应用尤其重要,在这些应用中,我们尝试预测的单词或短语的属性可能依赖于整个封闭句子的上下文,而不仅仅是其前面的单词。双向 RNN 还帮助网络架构对序列的开头和结尾给予同等的重视,并增加了可用于训练的数据量。

双向 RNN 是两个 RNN 堆叠在一起,分别以相反的方向读取输入。因此,在我们的例子中,一个 RNN 将从左到右读取单词,另一个 RNN 将从右到左读取单词。每个时间步的输出将基于两个 RNN 的隐藏状态。

Keras 通过双向包装层提供对双向 RNN 的支持。例如,在我们的词性标注示例中,我们只需使用这个双向包装层将 LSTM 转换为双向 RNN,就像以下的模型定义代码所示:

from keras.layers.wrappers import Bidirectional

model = Sequential()
model.add(Embedding(s_vocabsize, EMBED_SIZE,
input_length=MAX_SEQLEN))
model.add(SpatialDropout1D(Dropout(0.2)))
model.add(Bidirectional(LSTM(HIDDEN_SIZE, dropout=0.2, recurrent_dropout=0.2)))
model.add(RepeatVector(MAX_SEQLEN))
model.add(Bidirectional(LSTM(HIDDEN_SIZE, return_sequences=True)))
model.add(TimeDistributed(Dense(t_vocabsize)))
model.add(Activation("softmax"))

这样,我们得到了与下面展示的单向 LSTM 示例相媲美的性能:

有状态的 RNN

RNN(循环神经网络)可以是有状态的,这意味着它们可以在训练过程中跨批次保持状态。也就是说,对于一批训练数据计算出的隐藏状态将作为下一批训练数据的初始隐藏状态。然而,这需要显式设置,因为 Keras 的 RNN 默认是无状态的,并且会在每个批次之后重置状态。将 RNN 设置为有状态意味着它可以在其训练序列中建立状态,甚至在进行预测时保持该状态。

使用有状态 RNN 的好处是网络大小较小和/或训练时间较短。缺点是我们现在需要负责使用反映数据周期性的批次大小来训练网络,并在每个周期后重置状态。此外,在训练网络时数据不应被打乱,因为数据呈现的顺序对于有状态网络是相关的。

使用 Keras 的有状态 LSTM — 预测电力消耗

在这个示例中,我们使用有状态和无状态 LSTM 网络预测某个消费者的电力消耗并比较它们的表现。如你所记得,Keras 中的 RNN 默认是无状态的。在有状态模型中,处理一批输入后的内部状态将作为下一批的初始状态重新使用。换句话说,从一批中的第i个元素计算出的状态将作为下一批中第i个元素的初始状态。

我们将使用的数据集是来自 UCI 机器学习库的电力负荷图数据集(archive.ics.uci.edu/ml/datasets/ElectricityLoadDiagrams20112014),其中包含 370 个客户的消费信息,数据以 15 分钟为间隔,时间跨度为 2011 年至 2014 年的四年周期。我们随机选择了第 250 号客户作为示例。

需要记住的一点是,大多数问题都可以使用无状态 RNN 来解决,因此如果你使用有状态 RNN,请确保你确实需要它。通常,当数据具有周期性成分时,你需要使用它。如果你稍微思考一下,你会意识到电力消耗是有周期性的。白天的消耗往往高于夜晚。让我们提取第 250 号客户的消费数据,并绘制前 10 天的数据。最后,我们还将其保存为二进制的 NumPy 文件,供下一步使用:

import numpy as np
import matplotlib.pyplot as plt
import os
import re

DATA_DIR = "../data"

fld = open(os.path.join(DATA_DIR, "LD2011_2014.txt"), "rb")
data = []
cid = 250
for line in fld:
    if line.startswith(""";"):
        continue
    cols = [float(re.sub(",", ".", x)) for x in 
                line.strip().split(";")[1:]]
    data.append(cols[cid])
fld.close()

NUM_ENTRIES = 1000
plt.plot(range(NUM_ENTRIES), data[0:NUM_ENTRIES])
plt.ylabel("electricity consumption")
plt.xlabel("time (1pt = 15 mins)")
plt.show()

np.save(os.path.join(DATA_DIR, "LD_250.npy"), np.array(data))

前面的示例输出如下:

如你所见,数据明显存在日周期性趋势。因此,这个问题非常适合使用有状态模型。此外,根据我们的观察,BATCH_SIZE设置为96(24 小时内每 15 分钟一次的读取数)似乎合适。

我们将同时展示无状态版本和有状态版本的模型代码。两者的大部分代码是相同的,因此我们将同时查看这两个版本。我将在代码中出现差异时指出。

首先,像往常一样,我们导入必要的库和类:

from keras.layers.core import Dense
from keras.layers.recurrent import LSTM
from keras.models import Sequential
from sklearn.preprocessing import MinMaxScaler
import numpy as np
import math
import os

接下来,我们将客户 250 的数据从保存的 NumPy 二进制文件中加载到一个大小为140256的长数组中,并将其重新缩放到范围(0, 1)。最后,我们将输入调整为网络所需的三维形状:

DATA_DIR = "../data"

data = np.load(os.path.join(DATA_DIR, "LD_250.npy"))
data = data.reshape(-1, 1)
scaler = MinMaxScaler(feature_range=(0, 1), copy=False)
data = scaler.fit_transform(data)

在每个批次中,模型将处理一组 15 分钟的读数,并预测下一个读数。输入序列的长度由代码中的NUM_TIMESTEPS变量给出。根据一些实验,我们得到了NUM_TIMESTEPS的值为20,也就是说,每个输入行将是一个长度为20的序列,而输出的长度为1。下一步将输入数组重排列成XY张量,其形状分别为(None, 4)(None, 1)。最后,我们将输入张量X调整为网络所需的三维形状:

X = np.zeros((data.shape[0], NUM_TIMESTEPS))
Y = np.zeros((data.shape[0], 1))
for i in range(len(data) - NUM_TIMESTEPS - 1):
    X[i] = data[i:i + NUM_TIMESTEPS].T
    Y[i] = data[i + NUM_TIMESTEPS + 1]

# reshape X to three dimensions (samples, timesteps, features)
X = np.expand_dims(X, axis=2)

然后,我们将XY张量分割成 70-30 的训练测试集。由于我们处理的是时间序列数据,我们只需选择一个分割点并将数据切成两部分,而不是使用train_test_split函数,后者会打乱数据:

sp = int(0.7 * len(data))
Xtrain, Xtest, Ytrain, Ytest = X[0:sp], X[sp:], Y[0:sp], Y[sp:]
print(Xtrain.shape, Xtest.shape, Ytrain.shape, Ytest.shape)

首先我们定义我们的无状态模型。我们还设置了BATCH_SIZENUM_TIMESTEPS的值,正如我们之前讨论的那样。我们的 LSTM 输出大小由HIDDEN_SIZE给出,这是另一个通常通过实验得出的超参数。在这里,我们将其设置为10,因为我们的目标是比较两个网络:

NUM_TIMESTEPS = 20
HIDDEN_SIZE = 10
BATCH_SIZE = 96 # 24 hours (15 min intervals)

# stateless
model = Sequential()
model.add(LSTM(HIDDEN_SIZE, input_shape=(NUM_TIMESTEPS, 1), 
    return_sequences=False))
model.add(Dense(1))

对应的有状态模型定义非常相似,如下所示。在 LSTM 构造函数中,你需要设置stateful=True,并且不再使用input_shape来确定批次大小,而是需要显式地使用batch_input_shape来设置批次大小。你还需要确保训练和测试数据的大小是批次大小的整数倍。我们稍后会在查看训练代码时看到如何做到这一点:

# stateful
model = Sequential()
model.add(LSTM(HIDDEN_SIZE, stateful=True,
    batch_input_shape=(BATCH_SIZE, NUM_TIMESTEPS, 1), 
    return_sequences=False))
model.add(Dense(1))

接下来我们编译模型,对于无状态和有状态的 RNN 模型是相同的。请注意,这里的评估指标是均方误差,而不是我们通常使用的准确率。因为这是一个回归问题;我们关注的是预测与标签之间的偏差,而不是预测是否与标签匹配。你可以在 Keras 的评估指标页面找到 Keras 内置指标的完整列表:

model.compile(loss="mean_squared_error", optimizer="adam",
    metrics=["mean_squared_error"])

要训练无状态模型,我们可以使用我们现在可能已经非常熟悉的一行代码:

BATCH_SIZE = 96 # 24 hours (15 min intervals)

# stateless
model.fit(Xtrain, Ytrain, epochs=NUM_EPOCHS, batch_size=BATCH_SIZE,
    validation_data=(Xtest, Ytest),
    shuffle=False)

对应的有状态模型的代码如下所示。这里有三点需要注意:

首先,你应该选择一个反映数据周期性的批次大小。这是因为有状态的 RNN 会将每个批次的状态与下一个批次对齐,所以选择正确的批次大小可以让网络学习得更快。

一旦设置了批次大小,训练和测试集的大小需要是批次大小的精确倍数。我们通过截断训练和测试集中的最后几条记录来确保这一点:

第二点是你需要手动拟合模型,即在需要的轮数内对模型进行训练。每次迭代训练模型一个轮次,并且状态在多个批次间保持。每个轮次结束后,模型的状态需要手动重置。

第三点是数据应该按顺序输入。默认情况下,Keras 会在每个批次内打乱行,这会破坏我们对状态 RNN 有效学习所需的对齐。通过在调用 model.fit() 时设置 shuffle=False 可以避免这种情况:

BATCH_SIZE = 96 # 24 hours (15 min intervals)

# stateful
# need to make training and test data to multiple of BATCH_SIZE
train_size = (Xtrain.shape[0] // BATCH_SIZE) * BATCH_SIZE
test_size = (Xtest.shape[0] // BATCH_SIZE) * BATCH_SIZE
Xtrain, Ytrain = Xtrain[0:train_size], Ytrain[0:train_size]
Xtest, Ytest = Xtest[0:test_size], Ytest[0:test_size]
print(Xtrain.shape, Xtest.shape, Ytrain.shape, Ytest.shape)
for i in range(NUM_EPOCHS):
    print("Epoch {:d}/{:d}".format(i+1, NUM_EPOCHS))
    model.fit(Xtrain, Ytrain, batch_size=BATCH_SIZE, epochs=1,
        validation_data=(Xtest, Ytest),
        shuffle=False)
    model.reset_states()

最后,我们对模型进行评估并打印出得分:

score, _ = model.evaluate(Xtest, Ytest, batch_size=BATCH_SIZE)
rmse = math.sqrt(score)
print("MSE: {:.3f}, RMSE: {:.3f}".format(score, rmse))

对于无状态模型,运行五轮后的输出如下所示:

对于带状态模型,运行五次,每次一个轮次的相应输出如下所示。注意第二行中截断操作的结果:

正如你所看到的,带状态模型产生的结果略好于无状态模型。从绝对值来看,由于我们将数据缩放到 (0, 1) 范围内,这意味着无状态模型的错误率约为 6.2%,而带状态模型的错误率为 5.9%;换句话说,它们的准确率分别约为 93.8% 和 94.1%。因此,从相对角度来看,我们的带状态模型比无状态模型略微更优。

本示例的源代码包括 econs_data.py 文件(解析数据集)和 econs_stateful.py 文件(定义和训练无状态及带状态模型),可以从本章的代码下载中获取。

其他 RNN 变体

我们将通过查看一些 RNN 单元的变体来结束本章。RNN 是一个活跃的研究领域,许多研究者为特定目的提出了不同的变体。

一种流行的 LSTM 变体是添加 窥视连接,意味着门层可以查看单元状态。这是 Gers 和 Schmidhuber 在 2002 年提出的(有关更多信息,请参阅文章:Learning Precise Timing with LSTM Recurrent Networks,作者 F. A. Gers、N. N. Schraudolph 和 J. Schmidhuber,《机器学习研究杂志》,第 115-143 页)。

另一种 LSTM 变体,最终导致了 GRU 的出现,是使用耦合的遗忘门和输出门。关于遗忘哪些信息以及获取哪些信息的决策是一起做出的,而新信息则替代了被遗忘的信息。

Keras 仅提供了三种基本变体,即 SimpleRNN、LSTM 和 GRU 层。然而,这不一定是问题。Gref 进行了一项实验性调查(有关更多信息,请参阅文章:LSTM: A Search Space Odyssey,作者 K. Greff,arXiv:1503.04069,2015),并得出结论,任何变体都没有显著改善标准 LSTM 架构。因此,Keras 提供的组件通常足以解决大多数问题。

如果你确实需要构建自己的层,Keras 允许你构建自定义层。我们将在下一章中学习如何构建自定义层。还有一个开源框架叫做 recurrent shop (github.com/datalogai/recurrentshop),它允许你使用 Keras 构建复杂的循环神经网络。

总结

在本章中,我们学习了循环神经网络的基本架构,以及它们如何在序列数据上比传统神经网络表现得更好。我们看到了如何使用 RNN 学习作者的写作风格,并生成基于所学模型的文本。我们还看到了如何将这个示例扩展到预测股价或其他时间序列、从嘈杂的音频中识别语音等任务,还可以生成由学习模型作曲的音乐。

我们研究了不同的 RNN 单元组合方式,这些拓扑结构可以用于建模和解决特定问题,如情感分析、机器翻译、图像标题生成、分类等。

接着,我们研究了 SimpleRNN 架构的最大缺点之一——梯度消失和梯度爆炸问题。我们看到了如何通过 LSTM(和 GRU)架构来处理梯度消失问题。我们还详细了解了 LSTM 和 GRU 架构。我们还看到了两个示例,分别使用基于 LSTM 的模型预测情感,以及使用基于 GRU 的序列到序列架构预测词性标记(POS)。

接着,我们学习了状态感知型 RNN(stateful RNN),以及如何在 Keras 中使用它们。我们还看到了一个使用状态感知型 RNN 来预测大气中二氧化碳水平的示例。

最后,我们学习了一些在 Keras 中不可用的 RNN 变体,并简要探讨了如何构建它们。

在下一章,我们将探讨一些不完全符合我们目前所学基本模型的模型。我们还将学习如何使用 Keras 的函数式 API 将这些基本模型组合成更大更复杂的模型,并查看一些定制 Keras 以满足我们需求的示例。

第七章:其他深度学习模型

到目前为止,大部分讨论都集中在不同的分类模型上。这些模型使用对象特征和标签进行训练,以预测以前未见过的对象的标签。这些模型的架构也相对简单,迄今为止我们看到的所有模型都是通过 Keras 的顺序 API 构建的线性管道。

在本章中,我们将重点讨论更复杂的架构,其中管道不一定是线性的。Keras 提供了功能性 API 来处理这些类型的架构。在本章中,我们将学习如何使用功能性 API 定义我们的网络。需要注意的是,功能性 API 也可以用于构建线性架构。

分类网络的最简单扩展是回归网络。在监督学习的两大子类别中,分别是分类和回归。与预测一个类别不同,回归网络现在预测一个连续值。我们在讨论无状态与有状态 RNN 时,看到过回归网络的一个例子。许多回归问题可以通过分类模型轻松解决。我们将在本章中看到一个回归网络的例子,用于预测大气中的苯。

还有一类模型处理从无标签数据中学习数据结构,这些被称为无监督(或更准确地说是自监督)模型。它们类似于分类模型,但标签是隐含在数据中的。我们已经看过这类模型的例子,例如,CBOW 和 skip-gram 的 word2vec 模型就是自监督模型。自编码器是另一种这类模型的例子。在本章中,我们将学习自编码器,并描述一个构建句子紧凑向量表示的例子。

接下来,我们将看看如何将迄今为止看到的网络组合成更大的计算图。这些图通常是为了实现一些顺序模型无法单独完成的自定义目标而构建的,可能有多个输入和输出以及与外部组件的连接。我们将看到一个将网络组合起来用于问答任务的例子。

然后,我们将绕道一看 Keras 后端 API,并学习如何使用这个 API 构建自定义组件来扩展 Keras 的功能。

回到无标签数据的模型,另一类不需要标签的模型是生成模型。这些模型使用一组现有对象进行训练,尝试学习这些对象的分布。一旦学到了分布,我们就可以从这个分布中抽取样本,这些样本看起来像原始的训练数据。我们已经看过一个例子,在上一章中,我们训练了一个字符 RNN 模型来生成类似《爱丽丝梦游仙境》文本的内容。这个想法已经涵盖,所以我们这里不再讨论生成模型的这个部分。不过,我们将探讨如何利用已经训练好的网络学习数据分布的思路,使用在 ImageNet 数据上预训练的 VGG-16 网络来创建有趣的视觉效果。

总结一下,我们将在本章学习以下主题:

  • Keras 功能性 API

  • 回归网络

  • 用于无监督学习的自编码器

  • 使用功能性 API 组合复杂的网络

  • 定制 Keras

  • 生成网络

我们开始吧。

Keras 功能性 API

Keras 的功能性 API 将每一层定义为一个函数,并提供操作符将这些函数组合成一个更大的计算图。一个函数是一种具有单一输入和单一输出的变换。例如,函数 y = f(x) 定义了一个输入为 x,输出为 y 的函数 f。让我们考虑 Keras 中的简单顺序模型(更多信息请参见:keras.io/getting-started/sequential-model-guide/):

from keras.models import Sequential
from keras.layers.core import dense, Activation

model = Sequential([
   dense(32, input_dim=784),
   Activation("sigmoid"),
   dense(10),
   Activation("softmax"),
])

model.compile(loss="categorical_crossentropy", optimizer="adam")

如你所见,顺序模型将网络表示为一个线性管道,或者说是一个层的列表。我们也可以将网络表示为以下嵌套函数的组合。这里 x 是形状为 (None, 784) 的输入张量,y 是形状为 (None, 10) 的输出张量。这里的 None 表示尚未确定的批量大小:

其中:

网络可以通过以下方式使用 Keras 功能性 API 重新定义。注意,predictions 变量是我们之前以方程式形式定义的相同函数的组合:

from keras.layers import Input
from keras.layers.core import dense
from keras.models import Model
from keras.layers.core import Activation

inputs = Input(shape=(784,))

x = dense(32)(inputs)
x = Activation("sigmoid")(x)
x = dense(10)(x)
predictions = Activation("softmax")(x)

model = Model(inputs=inputs, outputs=predictions)

model.compile(loss="categorical_crossentropy", optimizer="adam")

由于模型是层的组合,而层本身也是函数,所以模型本身也是一个函数。因此,你可以通过对适当形状的输入张量调用模型,将训练好的模型视为另一个层。因此,如果你构建了一个用于图像分类的有用模型,你可以通过 Keras 的 TimeDistributed 包装器轻松地将其扩展为处理一系列图像:

sequence_predictions = TimeDistributed(model)(input_sequences)

功能性 API 可以用来定义任何可以通过顺序 API 定义的网络。此外,以下类型的网络只能使用功能性 API 定义:

  • 拥有多个输入和输出的模型

  • 由多个子模型组成的模型

  • 使用共享层的模型

拥有多个输入和输出的模型是通过分别组合输入和输出来定义的,如前面的示例所示,然后将输入函数数组和输出函数数组传递到Model构造函数的输入和输出参数中:

model = Model(inputs=[input1, input2], outputs=[output1, output2])

拥有多个输入和输出的模型通常也由多个子网络组成,这些子网络的计算结果最终会合并成最终结果。合并函数提供了多种合并中间结果的方法,如向量加法、点积和拼接。稍后我们将在本章的问答示例中看到合并的示例。

功能式 API 的另一个好用场景是使用共享层的模型。共享层只需定义一次,并在每个需要共享其权重的管道中引用。

本章我们将几乎专门使用功能式 API,因此你将看到许多使用示例。Keras 官网上有更多关于功能式 API 的使用示例。

回归网络

监督学习的两大主要技术是分类和回归。在这两种情况下,模型都会用数据进行训练,以预测已知标签。在分类的情况下,这些标签是离散值,如文本的类别或图像的种类;而在回归的情况下,这些标签是连续值,如股票价格或人的智商(IQ)。

我们看到的大多数示例展示了深度学习模型用于执行分类任务。在本节中,我们将探讨如何使用此类模型进行回归。

请记住,分类模型在最后有一个带有非线性激活的密集层,其输出维度对应于模型可以预测的类别数。因此,ImageNet 图像分类模型在最后有一个密集层(1,000),对应于它可以预测的 1,000 个 ImageNet 类别。类似地,情感分析模型在最后有一个密集层,对应于正面或负面的情感。

回归模型在最后也有一个密集层,但只有一个输出,即输出维度为一,并且没有非线性激活。因此,密集层仅返回来自前一层的激活值之和。此外,通常使用的损失函数是均方误差MSE),但也可以使用其他目标函数(详见 Keras 目标页面:keras.io/losses/)。

Keras 回归示例——预测空气中的苯浓度

在这个例子中,我们将预测大气中苯的浓度,给定一些其他变量,例如一氧化碳、氮氧化物等的浓度,以及温度和相对湿度。我们将使用的数据集来自 UCI 机器学习库的空气质量数据集(archive.ics.uci.edu/ml/datasets/Air+Quality)。该数据集包含 9,358 条来自五个金属氧化物化学传感器的小时平均读数。传感器阵列位于意大利的一个城市,记录时间为 2004 年 3 月至 2005 年 2 月。

和往常一样,首先我们导入所有必要的库:

from keras.layers import Input
from keras.layers.core import dense
from keras.models import Model
from sklearn.preprocessing import StandardScaler
import matplotlib.pyplot as plt
import numpy as np
import os
import pandas as pd

数据集以 CSV 文件形式提供。我们将输入数据加载到 Pandas(更多信息参见:pandas.pydata.org/)数据框中。Pandas 是一个流行的数据分析库,围绕数据框构建,这是一个借鉴自 R 语言的概念。我们在这里使用 Pandas 加载数据集有两个原因。首先,数据集中包含一些由于某种原因无法记录的空字段。其次,数据集使用逗号作为小数点分隔符,这在一些欧洲国家中是常见的习惯。Pandas 内建支持处理这两种情况,并且提供了一些其他便利功能,正如我们接下来会看到的:

DATA_DIR = "../data"
AIRQUALITY_FILE = os.path.join(DATA_DIR, "AirQualityUCI.csv")

aqdf = pd.read_csv(AIRQUALITY_FILE, sep=";", decimal=",", header=0)

# remove first and last 2 cols 
del aqdf["Date"]
del aqdf["Time"]
del aqdf["Unnamed: 15"]
del aqdf["Unnamed: 16"]

# fill NaNs in each column with the mean value
aqdf = aqdf.fillna(aqdf.mean())

Xorig = aqdf.as_matrix()

上述示例删除了前两列,其中包含观察日期和时间,及后两列,这些列似乎是无关的。接下来,我们用该列的平均值替换空字段。最后,我们将数据框导出为矩阵,以便后续使用。

需要注意的是,数据的每一列有不同的尺度,因为它们测量的是不同的量。例如,氧化锡的浓度在 1,000 范围内,而非甲烷烃的浓度在 100 范围内。在许多情况下,我们的特征是同质的,因此不需要缩放,但在像这样的情况下,通常建议对数据进行缩放。这里的缩放过程包括从每列中减去该列的均值,并除以其标准差:

为此,我们使用scikit-learn库提供的StandardScaler类,如下所示。我们存储均值和标准差,因为我们稍后在报告结果或预测新数据时会用到它们。我们的目标变量是输入数据集中的第四列,因此我们将缩放后的数据分为输入变量X和目标变量y

scaler = StandardScaler()
Xscaled = scaler.fit_transform(Xorig)
# store these off for predictions with unseen data
Xmeans = scaler.mean_
Xstds = scaler.scale_

y = Xscaled[:, 3]
X = np.delete(Xscaled, 3, axis=1)

然后,我们将数据分为前 70%用于训练,后 30%用于测试。这为我们提供了 6,549 条训练记录和 2,808 条测试记录:

train_size = int(0.7 * X.shape[0])
Xtrain, Xtest, ytrain, ytest = X[0:train_size], X[train_size:], 
    y[0:train_size], y[train_size:]

接下来,我们定义我们的网络。这是一个简单的两层密集网络,输入为 12 个特征的向量,输出为缩放后的预测值。隐藏层密集层有八个神经元。我们使用一种特定的初始化方案叫做glorot uniform来初始化两个密集层的权重矩阵。有关初始化方案的完整列表,请参见 Keras 初始化文档:keras.io/initializers/。所使用的损失函数是均方误差(mse),优化器是adam

readings = Input(shape=(12,))
x = dense(8, activation="relu", kernel_initializer="glorot_uniform")(readings)
benzene = dense(1, kernel_initializer="glorot_uniform")(x)

model = Model(inputs=[readings], outputs=[benzene])
model.compile(loss="mse", optimizer="adam")

我们为此模型训练了 20 个周期,批次大小为 10:

NUM_EPOCHS = 20
BATCH_SIZE = 10

history = model.fit(Xtrain, ytrain, batch_size=BATCH_SIZE, epochs=NUM_EPOCHS,
    validation_split=0.2)

这导致一个模型,其训练集的均方误差为 0.0003(大约 2%的 RMSE),验证集为 0.0016(大约 4%的 RMSE),如下训练步骤日志所示:

我们还查看了一些原本记录的苯浓度值,并将它们与我们模型预测的值进行比较。实际值和预测值都从它们的缩放z-值重缩放为实际值:

ytest_ = model.predict(Xtest).flatten()
for i in range(10):
    label = (ytest[i] * Xstds[3]) + Xmeans[3]
    prediction = (ytest_[i] * Xstds[3]) + Xmeans[3]
    print("Benzene Conc. expected: {:.3f}, predicted: {:.3f}".format(label, prediction))

并排比较显示,预测值与实际值非常接近:

Benzene Conc. expected: 4.600, predicted: 5.254
Benzene Conc. expected: 5.500, predicted: 4.932
Benzene Conc. expected: 6.500, predicted: 5.664
Benzene Conc. expected: 10.300, predicted: 8.482
Benzene Conc. expected: 8.900, predicted: 6.705
Benzene Conc. expected: 14.000, predicted: 12.928
Benzene Conc. expected: 9.200, predicted: 7.128
Benzene Conc. expected: 8.200, predicted: 5.983
Benzene Conc. expected: 7.200, predicted: 6.256
Benzene Conc. expected: 5.500, predicted: 5.184

最后,我们将实际值与我们整个测试集的预测值进行比较。再次看到,网络预测的值非常接近预期值:

plt.plot(np.arange(ytest.shape[0]), (ytest * Xstds[3]) / Xmeans[3], 
    color="b", label="actual")
plt.plot(np.arange(ytest_.shape[0]), (ytest_ * Xstds[3]) / Xmeans[3], 
    color="r", alpha=0.5, label="predicted")
plt.xlabel("time")
plt.ylabel("C6H6 concentrations")
plt.legend(loc="best")
plt.show()

前面示例的输出如下:

无监督学习 — 自编码器

自编码器是一类神经网络,尝试通过反向传播将输入重建为目标。自编码器由两部分组成:编码器和解码器。编码器读取输入并将其压缩为紧凑的表示,解码器则读取紧凑的表示并从中重建输入。换句话说,自编码器通过最小化重建误差来尝试学习恒等函数。

尽管恒等函数看起来不像是一个很有趣的函数来学习,但实现的方式使其变得有趣。自编码器中的隐藏单元数量通常小于输入(和输出)单元的数量。这迫使编码器学习输入的压缩表示,而解码器则从中重建。如果输入数据中存在特征之间的相关性,那么自编码器将发现这些相关性,并最终学习一个类似于使用主成分分析PCA)得到的低维表示。

一旦自编码器训练完成,我们通常会丢弃解码器组件,只使用编码器组件生成输入的紧凑表示。或者,我们可以将编码器作为特征检测器,生成输入的紧凑且语义丰富的表示,并通过将软最大分类器附加到隐藏层来构建分类器。

自编码器的编码器和解码器组件可以使用密集、卷积或递归网络来实现,具体取决于所建模的数据类型。例如,密集网络可能是构建协同过滤CF)模型的自编码器的好选择(有关更多信息,请参阅文章:AutoRec: Autoencoders Meet Collaborative Filtering,S. Sedhain,2015 年国际万维网大会论文集,ACM 和 Wide & Deep Learning for Recommender Systems,H. Cheng,2016 年第一届推荐系统深度学习研讨会论文集,ACM),在该模型中,我们根据实际稀疏的用户评分学习压缩的用户偏好模型。类似地,卷积神经网络可能适用于文章中涵盖的用例:See: Using Deep Learning to Remove Eyeglasses from Faces,M. Runfeldt,递归网络是构建基于文本数据的自编码器的好选择,如深度患者(有关更多信息,请参阅文章:Deep Patient: An Unsupervised Representation to Predict the Future of Patients from the Electronic Health Records,R. Miotto,Scientific Reports 6,2016)和跳跃思想向量(有关更多信息,请参阅文章:Skip-Thought Vectors,R. Kiros,神经信息处理系统进展,2015 年)。

自编码器也可以通过逐次堆叠编码器来实现,将输入压缩为越来越小的表示,并按相反的顺序堆叠解码器。堆叠自编码器具有更强的表达能力,连续的表示层捕捉了输入的层次分组,类似于卷积神经网络中的卷积和池化操作。

堆叠自编码器曾经是逐层训练的。例如,在下图所示的网络中,我们首先会训练层X,通过隐藏层H1来重构层X'(忽略H2)。然后,我们会训练层H1,通过隐藏层H2来重构层H1'。最后,我们会将所有层堆叠在一起,如所示配置,并微调网络,以便通过X重构X'。然而,随着现代激活和正则化函数的改进,训练这些网络的整体方法已变得非常普遍:

Keras 博客文章,在 Keras 中构建自编码器blog.keras.io/building-autoencoders-in-keras.html)提供了很好的自编码器构建示例,这些自编码器使用全连接和卷积神经网络重构 MNIST 数字图像。文章中还讨论了去噪和变分自编码器的内容,虽然我们在这里不做讲解。

Keras 自编码器示例 — 句子向量

在这个例子中,我们将构建并训练一个基于 LSTM 的自编码器,用于为 Reuters-21578 语料库中的文档生成句子向量(archive.ics.uci.edu/ml/datasets/Reuters-21578+Text+Categorization+Collection)。我们已经在第五章,词嵌入中,展示了如何使用词嵌入表示一个单词,并创建表示其在上下文中意义的向量。在这里,我们将看到如何为句子构建类似的向量。句子是词语的序列,因此,句子向量表示的是句子的意义。

构建句子向量的最简单方法是将词向量相加并除以词数。然而,这种方法将句子视为词袋,并没有考虑词语的顺序。因此,在这种情况下,句子The dog bit the manThe man bit the dog将被视为相同。LSTM 被设计用于处理序列输入,并且会考虑词语的顺序,从而为句子提供更好、更自然的表示。

首先,我们导入必要的库:

from sklearn.model_selection import train_test_split
from keras.callbacks import ModelCheckpoint
from keras.layers import Input
from keras.layers.core import RepeatVector
from keras.layers.recurrent import LSTM
from keras.layers.wrappers import Bidirectional
from keras.models import Model
from keras.preprocessing import sequence
from scipy.stats import describe
import collections
import matplotlib.pyplot as plt
import nltk
import numpy as np
import os

数据以一组 SGML 文件提供。我们已经在第六章,递归神经网络—RNN中解析并整合了这些数据到一个单一的文本文件中,用于我们的基于 GRU 的词性标注示例。我们将重用这些数据,首先将每个文本块转换为句子列表,每行一个句子:

sents = []
fsent = open(sent_filename, "rb")
for line in fsent:
    docid, sent_id, sent = line.strip().split("t")
    sents.append(sent)
fsent.close()

为了扩展我们的词汇量,我们逐字逐句地再次阅读这份句子列表。每个单词在加入时都会进行标准化处理。标准化的方式是将任何看起来像数字的标记替换为数字9并将其转换为小写。结果是生成词频表word_freqs。我们还计算每个句子的长度,并通过用空格重新连接词语来创建解析后的句子列表,这样可以在后续步骤中更方便地进行解析:

def is_number(n):
    temp = re.sub("[.,-/]", "", n)
    return temp.isdigit()

word_freqs = collections.Counter()
sent_lens = []
parsed_sentences = []
for sent in sentences:
    words = nltk.word_tokenize(sent)
    parsed_words = []
    for word in words:
        if is_number(word):
            word = "9"
        word_freqs[word.lower()] += 1
        parsed_words.append(word)
    sent_lens.append(len(words))
    parsed_sentences.append(" ".join(parsed_words))

这为我们提供了一些关于语料库的信息,帮助我们确定 LSTM 网络常数的合适值:

sent_lens = np.array(sent_lens)
print("number of sentences: {:d}".format(len(sent_lens)))
print("distribution of sentence lengths (number of words)")
print("min:{:d}, max:{:d}, mean:{:.3f}, med:{:.3f}".format(
np.min(sent_lens), np.max(sent_lens), np.mean(sent_lens),
np.median(sent_lens)))
print("vocab size (full): {:d}".format(len(word_freqs)))

这为我们提供了以下关于语料库的信息:

number of sentences: 131545
 distribution of sentence lengths (number of words)
 min: 1, max: 429, mean: 22.315, median: 21.000
 vocab size (full): 50751

根据这些信息,我们为 LSTM 模型设置了以下常量。我们选择将VOCAB_SIZE设置为5000,即我们的词汇表覆盖了最常用的 5,000 个单词,这些单词覆盖了语料库中超过 93%的词汇。剩余的单词被视为词汇表外OOV),并用UNK标记替换。在预测时,任何模型未见过的单词也会被分配为UNK标记。SEQUENCE_LEN设置为训练集中文本长度的中位数的大约两倍,实际上,我们的 131 百万句子中大约有 1.1 亿句子比这个设置要短。比SEQUENCE_LENGTH短的句子会被PAD字符填充,超过长度的句子会被截断以适应限制:

VOCAB_SIZE = 5000
SEQUENCE_LEN = 50

由于我们 LSTM 的输入是数值型的,我们需要构建查找表,用于在单词和单词 ID 之间相互转换。由于我们将词汇表大小限制为 5,000,并且需要添加两个伪单词PADUNK,因此我们的查找表包含最常出现的 4,998 个单词条目以及PADUNK

word2id = {}
word2id["PAD"] = 0
word2id["UNK"] = 1
for v, (k, _) in enumerate(word_freqs.most_common(VOCAB_SIZE - 2)):
    word2id[k] = v + 2
id2word = {v:k for k, v in word2id.items()}

我们网络的输入是一系列单词,每个单词由一个向量表示。简单来说,我们可以为每个单词使用一个独热编码,但那样会使输入数据变得非常庞大。因此,我们使用 50 维的 GloVe 词向量来编码每个单词。这个嵌入被生成到一个形状为(VOCAB_SIZE, EMBED_SIZE)的矩阵中,其中每一行代表我们词汇表中某个单词的 GloVe 嵌入。PADUNK行(分别为01)分别用零和随机均匀值填充:

EMBED_SIZE = 50

def lookup_word2id(word):
    try:
        return word2id[word]
    except KeyError:
        return word2id["UNK"]

def load_glove_vectors(glove_file, word2id, embed_size):
    embedding = np.zeros((len(word2id), embed_size))
    fglove = open(glove_file, "rb")
    for line in fglove:
        cols = line.strip().split()
        word = cols[0]
        if embed_size == 0:
            embed_size = len(cols) - 1
        if word2id.has_key(word):
            vec = np.array([float(v) for v in cols[1:]])
        embedding[lookup_word2id(word)] = vec
    embedding[word2id["PAD"]] = np.zeros((embed_size))
    embedding[word2id["UNK"]] = np.random.uniform(-1, 1, embed_size)
    return embedding

embeddings = load_glove_vectors(os.path.join(
    DATA_DIR, "glove.6B.{:d}d.txt".format(EMBED_SIZE)), word2id, EMBED_SIZE)

我们的自动编码器模型接受一系列 GloVe 词向量,并学习生成一个与输入序列相似的输出序列。编码器 LSTM 将序列压缩成一个固定大小的上下文向量,解码器 LSTM 则使用这个向量来重构原始序列。网络的示意图如下所示:

由于输入非常庞大,我们将使用生成器来产生每一批输入。我们的生成器会生成形状为(BATCH_SIZE, SEQUENCE_LEN, EMBED_SIZE)的张量批次。这里BATCH_SIZE64,由于我们使用 50 维的 GloVe 向量,因此EMBED_SIZE50。我们在每个 epoch 开始时对句子进行打乱,并返回 64 个句子的批次。每个句子表示为一个 GloVe 词向量。如果词汇表中的某个单词没有对应的 GloVe 嵌入,它会被表示为一个零向量。我们构建了生成器的两个实例,一个用于训练数据,另一个用于测试数据,分别包含原始数据集的 70%和 30%:

BATCH_SIZE = 64

def sentence_generator(X, embeddings, batch_size):
    while True:
        # loop once per epoch
        num_recs = X.shape[0]
        indices = np.random.permutation(np.arange(num_recs))
        num_batches = num_recs // batch_size
        for bid in range(num_batches):
            sids = indices[bid * batch_size : (bid + 1) * batch_size]
            Xbatch = embeddings[X[sids, :]]
            yield Xbatch, Xbatch

train_size = 0.7
Xtrain, Xtest = train_test_split(sent_wids, train_size=train_size)
train_gen = sentence_generator(Xtrain, embeddings, BATCH_SIZE)
test_gen = sentence_generator(Xtest, embeddings, BATCH_SIZE)

现在我们准备好定义自动编码器了。如图所示,它由一个编码器 LSTM 和一个解码器 LSTM 组成。编码器 LSTM 读取一个形状为(BATCH_SIZE, SEQUENCE_LEN, EMBED_SIZE)的张量,表示一个句子批次。每个句子表示为一个填充的固定长度的单词序列,长度为SEQUENCE_LEN。每个单词由一个 300 维的 GloVe 向量表示。编码器 LSTM 的输出维度是一个超参数LATENT_SIZE,这是后续经过训练的自动编码器编码器部分输出的句子向量的大小。LATENT_SIZE维度的向量空间表示了编码句子意义的潜在空间。LSTM 的输出是每个句子的一个LATENT_SIZE大小的向量,因此,对于整个批次,输出张量的形状是(BATCH_SIZE, LATENT_SIZE)。这个张量现在输入到 RepeatVector 层,该层将其在整个序列中复制,即该层的输出张量的形状为(BATCH_SIZE, SEQUENCE_LEN, LATENT_SIZE)。这个张量接着输入到解码器 LSTM,其输出维度是EMBED_SIZE,因此输出张量的形状为(BATCH_SIZE, SEQUENCE_LEN, EMBED_SIZE),即与输入张量的形状相同。

我们使用SGD优化器和mse损失函数来编译这个模型。我们使用 MSE 的原因是我们希望重建一个具有相似意义的句子,也就是说,重建一个在LATENT_SIZE维度的嵌入空间中接近原始句子的句子:

inputs = Input(shape=(SEQUENCE_LEN, EMBED_SIZE), name="input")
encoded = Bidirectional(LSTM(LATENT_SIZE), merge_mode="sum",
    name="encoder_lstm")(inputs)
decoded = RepeatVector(SEQUENCE_LEN, name="repeater")(encoded)
decoded = Bidirectional(LSTM(EMBED_SIZE, return_sequences=True),
    merge_mode="sum",
    name="decoder_lstm")(decoded)

autoencoder = Model(inputs, decoded)

autoencoder.compile(optimizer="sgd", loss="mse")

我们使用以下代码训练自动编码器 10 个周期。选择 10 个周期是因为在这个时间范围内,MSE 损失已经收敛。我们还会根据 MSE 损失保存到目前为止最好的模型:

num_train_steps = len(Xtrain) // BATCH_SIZE
num_test_steps = len(Xtest) // BATCH_SIZE
checkpoint = ModelCheckpoint(filepath=os.path.join(DATA_DIR,
    "sent-thoughts-autoencoder.h5"), save_best_only=True)
history = autoencoder.fit_generator(train_gen,
    steps_per_epoch=num_train_steps,
    epochs=NUM_EPOCHS,
    validation_data=test_gen,
    validation_steps=num_test_steps,
    callbacks=[checkpoint])

训练结果如下所示。正如你所看到的,训练 MSE 从 0.14 降到 0.1,验证 MSE 从 0.12 降到 0.1:

或者,图形化显示如下:

由于我们输入的是一个嵌入矩阵,输出也将是一个单词嵌入矩阵。由于嵌入空间是连续的,而我们的词汇表是离散的,并不是每个输出的嵌入都对应一个单词。我们能做的最好的方法是找到一个与输出嵌入最接近的单词,从而重建原始文本。这有点麻烦,所以我们将以不同的方式评估我们的自动编码器。

由于自动编码器的目标是生成一个良好的潜在表示,我们比较编码器生成的潜在向量,使用的是原始输入和自动编码器输出。首先,我们将编码器部分提取出来作为单独的网络:

encoder = Model(autoencoder.input, autoencoder.get_layer("encoder_lstm").output)

然后,我们在测试集上运行自动编码器以返回预测的嵌入。接着,我们将输入嵌入和预测的嵌入通过编码器传输,从每个嵌入生成句子向量,并使用余弦相似度比较这两个向量。接近 1 的余弦相似度表示高相似性,接近 0 则表示低相似性。以下代码对 500 个测试句子的随机子集进行处理,生成源嵌入和由自动编码器生成的相应目标嵌入之间的句子向量余弦相似度样本值:

def compute_cosine_similarity(x, y):
    return np.dot(x, y) / (np.linalg.norm(x, 2) * np.linalg.norm(y, 2))

k = 500
cosims = np.zeros((k))
i = 0
for bid in range(num_test_steps):
    xtest, ytest = test_gen.next()
    ytest_ = autoencoder.predict(xtest)
    Xvec = encoder.predict(xtest)
    Yvec = encoder.predict(ytest_)
    for rid in range(Xvec.shape[0]):
        if i >= k:
            break
        cosims[i] = compute_cosine_similarity(Xvec[rid], Yvec[rid])
        if i <= 10:
            print(cosims[i])
            i += 1
if i >= k:
    break

以下是前 10 个余弦相似度值。如我们所见,这些向量似乎非常相似:

0.982818722725
0.970908224583
0.98131018877
0.974798440933
0.968060493469
0.976065933704
0.96712064743
0.949920475483
0.973583400249
0.980291545391
0.817819952965

以下展示了测试集前 500 个句子的句子向量余弦相似度分布的直方图。如前所述,这确认了自动编码器的输入和输出生成的句子向量非常相似,表明生成的句子向量是句子的良好表示:

构建深度网络

我们已经深入研究了这三种基本的深度学习网络——全连接网络FCN)、卷积神经网络(CNN)和循环神经网络(RNN)模型。虽然每种网络都有其最适用的特定用例,但你也可以通过将这些模型作为类似乐高的构建块,结合使用 Keras 功能性 API 以新的、有趣的方式将它们组合在一起,构建更大、更有用的模型。

这样的模型往往对其构建的任务有所专门化,因此很难进行一般化。不过,通常它们涉及从多个输入中学习或生成多个输出。例如,一个问答网络,可以在给定故事和问题的情况下预测答案。另一个例子是孪生网络,它计算一对图像之间的相似度,网络通过一对图像作为输入来预测二元(相似/不相似)或类别(相似度的不同级别)标签。再一个例子是物体分类和定位网络,它从图像中共同学习预测图像的类别以及图像在图片中的位置。前两个例子是具有多个输入的复合网络,最后一个是具有多个输出的复合网络。

Keras 示例 —— 用于问答的记忆网络

在这个例子中,我们将构建一个用于问答的记忆网络。记忆网络是一种专门的架构,除了其他可学习的单元(通常是 RNN)外,还包含一个记忆单元。每个输入都会更新记忆状态,最终输出通过结合记忆和可学习单元的输出进行计算。该架构在 2014 年通过论文提出(更多信息请参考:Memory Networks,J. Weston, S. Chopra 和 A. Bordes,arXiv:1410.3916,2014 年)。一年后,另一篇论文(更多信息请参考:Towards AI-Complete Question Answering: A Set of Prerequisite Toy Tasks,J. Weston,arXiv:1502.05698,2015 年)提出了一个合成数据集的想法,并给出了 20 个问答任务的标准集,每个任务的难度比前一个更高,并应用各种深度学习网络来解决这些任务。在所有任务中,记忆网络取得了最佳结果。这个数据集后来通过 Facebook 的 bAbI 项目向公众发布(research.fb.com/projects/babi/)。我们记忆网络的实现最接近于这篇论文中描述的实现(更多信息请参考:End-To-End Memory Networks,S. Sukhbaatar, J. Weston 和 R. Fergus,2015 年《神经信息处理系统进展》),其特点是所有的训练都在单一网络中联合进行。它使用 bAbI 数据集来解决第一个问答任务。

首先,我们将导入必要的库:

from keras.layers import Input
from keras.layers.core import Activation, dense, Dropout, Permute
from keras.layers.embeddings import Embedding
from keras.layers.merge import add, concatenate, dot
from keras.layers.recurrent import LSTM
from keras.models import Model
from keras.preprocessing.sequence import pad_sequences
from keras.utils import np_utils
import collections
import itertools
import nltk
import numpy as np
import matplotlib.pyplot as plt
import os

第一个问答任务的 bAbI 数据集由 10,000 个短句组成,每个句子用于训练集和测试集。一个故事由两到三句组成,后面跟着一个问题。每个故事的最后一句话会在结尾附加问题和答案。以下代码块解析每个训练和测试文件,将其转换为包含故事、问题和答案三元组的列表:

DATA_DIR = "../data"
TRAIN_FILE = os.path.join(DATA_DIR, "qa1_single-supporting-fact_train.txt")
TEST_FILE = os.path.join(DATA_DIR, "qa1_single-supporting-fact_test.txt")

def get_data(infile):
    stories, questions, answers = [], [], []
    story_text = []
    fin = open(TRAIN_FILE, "rb")
    for line in fin:
        line = line.decode("utf-8").strip()
        lno, text = line.split(" ", 1)
        if "t" in text:
            question, answer, _ = text.split("t")
            stories.append(story_text)
            questions.append(question)
            answers.append(answer)
            story_text = []
        else:
            story_text.append(text)
    fin.close()
    return stories, questions, answers

data_train = get_data(TRAIN_FILE)
data_test = get_data(TEST_FILE)

下一步是遍历生成的列表中的文本并构建我们的词汇表。现在这应该对我们非常熟悉,因为我们已经多次使用过类似的表达。与上次不同,我们的词汇表非常小,只有 22 个独特的词,因此我们不会遇到任何超出词汇表范围的单词:

def build_vocab(train_data, test_data):
    counter = collections.Counter()
    for stories, questions, answers in [train_data, test_data]:
        for story in stories:
            for sent in story:
                for word in nltk.word_tokenize(sent):
                    counter[word.lower()] += 1
                for question in questions:
                    for word in nltk.word_tokenize(question):
                         counter[word.lower()] += 1
                for answer in answers:
                    for word in nltk.word_tokenize(answer):
                         counter[word.lower()] += 1
    word2idx = {w:(i+1) for i, (w, _) in enumerate(counter.most_common())}
    word2idx["PAD"] = 0
idx2word = {v:k for k, v in word2idx.items()}
    return word2idx, idx2word

word2idx, idx2word = build_vocab(data_train, data_test)

vocab_size = len(word2idx)

记忆网络基于 RNN,其中故事和问题中的每个句子都被视为一个单词序列,因此我们需要找出故事和问题序列的最大长度。以下代码块执行此操作。我们发现,故事的最大长度是 14 个单词,问题的最大长度仅为 4 个单词:

def get_maxlens(train_data, test_data):
    story_maxlen, question_maxlen = 0, 0
    for stories, questions, _ in [train_data, test_data]:
        for story in stories:
            story_len = 0
            for sent in story:
                swords = nltk.word_tokenize(sent)
                story_len += len(swords)
            if story_len > story_maxlen:
                story_maxlen = story_len
        for question in questions:
            question_len = len(nltk.word_tokenize(question))
            if question_len > question_maxlen:
                question_maxlen = question_len
    return story_maxlen, question_maxlen

story_maxlen, question_maxlen = get_maxlens(data_train, data_test)

如前所述,我们的 RNN 输入是一个单词 ID 的序列。因此,我们需要使用我们的词汇字典,将(故事、问题和答案)三元组转换为整数单词 ID 的序列。下方的代码块完成了这一转换,并将结果中的故事和答案序列零填充至我们之前计算的最大序列长度。此时,我们为训练集和测试集中的每个三元组获得了填充后的单词 ID 序列列表:

def vectorize(data, word2idx, story_maxlen, question_maxlen):
    Xs, Xq, Y = [], [], []
    stories, questions, answers = data
    for story, question, answer in zip(stories, questions, answers):
        xs = [[word2idx[w.lower()] for w in nltk.word_tokenize(s)] 
                   for s in story]
        xs = list(itertools.chain.from_iterable(xs))
        xq = [word2idx[w.lower()] for w in nltk.word_tokenize(question)]
        Xs.append(xs)
        Xq.append(xq)
        Y.append(word2idx[answer.lower()])
    return pad_sequences(Xs, maxlen=story_maxlen),
        pad_sequences(Xq, maxlen=question_maxlen),
        np_utils.to_categorical(Y, num_classes=len(word2idx))

Xstrain, Xqtrain, Ytrain = vectorize(data_train, word2idx, story_maxlen, question_maxlen)
Xstest, Xqtest, Ytest = vectorize(data_test, word2idx, story_maxlen, question_maxlen)

我们想要定义模型。这个定义比我们之前看到的要长一些,所以在查看定义时,可能方便参考图示:

我们模型的输入有两个:问题的单词 ID 序列和句子的单词 ID 序列。每个序列都传入一个嵌入层,将单词 ID 转换为 64 维嵌入空间中的向量。此外,故事序列还会通过一个额外的嵌入层,将其投影到 max_question_length 大小的嵌入空间中。所有这些嵌入层都以随机权重开始,并与网络的其余部分一起训练。

前两个嵌入(故事和问题)通过点积合并,形成网络的记忆。这些嵌入表示故事和问题中在嵌入空间中相同或相近的单词。记忆的输出与第二个故事嵌入合并,并求和形成网络的响应,之后再次与问题的嵌入合并,形成响应序列。这个响应序列被送入一个 LSTM,其上下文向量被传递到一个全连接层来预测答案,答案是词汇表中的一个单词。

该模型使用 RMSprop 优化器和分类交叉熵作为损失函数进行训练:

EMBEDDING_SIZE = 64
LATENT_SIZE = 32

# inputs
story_input = Input(shape=(story_maxlen,))
question_input = Input(shape=(question_maxlen,))

# story encoder memory
story_encoder = Embedding(input_dim=vocab_size,
output_dim=EMBEDDING_SIZE,
    input_length=story_maxlen)(story_input)
story_encoder = Dropout(0.3)(story_encoder)

# question encoder
question_encoder = Embedding(input_dim=vocab_size,
output_dim=EMBEDDING_SIZE,
    input_length=question_maxlen)(question_input)
question_encoder = Dropout(0.3)(question_encoder)

# match between story and question
match = dot([story_encoder, question_encoder], axes=[2, 2])

# encode story into vector space of question
story_encoder_c = Embedding(input_dim=vocab_size,
output_dim=question_maxlen,
    input_length=story_maxlen)(story_input)
story_encoder_c = Dropout(0.3)(story_encoder_c)

# combine match and story vectors
response = add([match, story_encoder_c])
response = Permute((2, 1))(response)

# combine response and question vectors
answer = concatenate([response, question_encoder], axis=-1)
answer = LSTM(LATENT_SIZE)(answer)
answer = Dropout(0.3)(answer)
answer = dense(vocab_size)(answer)
output = Activation("softmax")(answer)

model = Model(inputs=[story_input, question_input], outputs=output)
model.compile(optimizer="rmsprop", loss="categorical_crossentropy",
    metrics=["accuracy"])

我们使用批量大小为 32,训练 50 轮网络,并在验证集上取得了超过 81% 的准确率:

BATCH_SIZE = 32
NUM_EPOCHS = 50
history = model.fit([Xstrain, Xqtrain], [Ytrain], batch_size=BATCH_SIZE, 
    epochs=NUM_EPOCHS,
    validation_data=([Xstest, Xqtest], [Ytest]))

这是训练日志的跟踪记录:

训练和验证损失以及准确度的变化在下面的图表中显示:

我们将模型运行于测试集中的前 10 个故事上,以验证预测的准确性:

ytest = np.argmax(Ytest, axis=1)
Ytest_ = model.predict([Xstest, Xqtest])
ytest_ = np.argmax(Ytest_, axis=1)

for i in range(NUM_DISPLAY):
    story = " ".join([idx2word[x] for x in Xstest[i].tolist() if x != 0])
    question = " ".join([idx2word[x] for x in Xqtest[i].tolist()])
    label = idx2word[ytest[i]]
    prediction = idx2word[ytest_[i]]
    print(story, question, label, prediction)

如你所见,预测结果大多数是正确的:

定制 Keras

就像将我们的基本构建块组合成更大的架构使我们能够构建有趣的深度学习模型一样,有时我们需要查看谱系的另一端。Keras 已经内置了许多功能,因此你很可能可以使用提供的组件构建所有模型,而完全不需要自定义。如果你确实需要自定义,Keras 也能满足你的需求。

正如你所记得的,Keras 是一个高层次的 API,它将计算任务委托给 TensorFlow 或 Theano 后端。你为自定义功能编写的任何代码都会调用其中一个后端。为了确保你的代码在两个后端之间具有可移植性,你的自定义代码应该使用 Keras 后端 API(keras.io/backend/),它提供了一组函数,这些函数像一个 facade 一样封装了你选择的后端。根据所选的后端,调用后端 facade 会被转换为适当的 TensorFlow 或 Theano 调用。可以在 Keras 后端页面上找到可用函数的完整列表及其详细描述。

除了可移植性之外,使用后端 API 还可以使代码更加易于维护,因为与等效的 TensorFlow 或 Theano 代码相比,Keras 代码通常更高层次、更简洁。即使在极少数情况下你确实需要直接使用后端,你的 Keras 组件也可以直接在 TensorFlow(但不是 Theano)代码中使用,正如这个 Keras 博客中所描述的那样(blog.keras.io/keras-as-a-simplified-interface-to-tensorflow-tutorial.html)。

自定义 Keras 通常意味着编写你自己的自定义层或自定义距离函数。在本节中,我们将演示如何构建一些简单的 Keras 层。你将在后续的章节中看到更多使用后端函数构建其他自定义 Keras 组件(如目标函数(损失函数))的例子。

Keras 示例 —— 使用 lambda 层

Keras 提供了一个 lambda 层;它可以封装你选择的函数。例如,如果你想构建一个逐元素平方输入张量的层,你可以简单地这样写:

model.add(lambda(lambda x: x ** 2))

你也可以将函数封装在 lambda 层中。例如,如果你想构建一个自定义层来计算两个输入张量之间的逐元素欧几里得距离,你需要定义一个函数来计算该值,并且定义另一个函数返回该函数的输出形状,如下所示:

def euclidean_distance(vecs):
    x, y = vecs
    return K.sqrt(K.sum(K.square(x - y), axis=1, keepdims=True))

def euclidean_distance_output_shape(shapes):
    shape1, shape2 = shapes
    return (shape1[0], 1)

你可以像下面这样使用 lambda 层来调用这些函数:

lhs_input = Input(shape=(VECTOR_SIZE,))
lhs = dense(1024, kernel_initializer="glorot_uniform", activation="relu")(lhs_input)

rhs_input = Input(shape=(VECTOR_SIZE,))
rhs = dense(1024, kernel_initializer="glorot_uniform", activation="relu")(rhs_input)

sim = lambda(euclidean_distance, output_shape=euclidean_distance_output_shape)([lhs, rhs])

Keras 示例 —— 构建自定义归一化层

虽然 lambda 层非常有用,但有时你可能需要更多的控制。例如,我们将查看一个实现了称为局部响应归一化技术的归一化层代码。这种技术对局部输入区域进行归一化,但由于它没有其他正则化方法(如 dropout 和批量归一化)以及更好的初始化方法效果好,因此已经不再流行。

构建自定义层通常涉及与后端函数的交互,因此需要将代码视为张量的形式来思考。正如你所记得的,处理张量是一个两步过程。首先,定义张量并将其排列成计算图,然后用实际数据运行该图。因此,在这个层面上工作比在 Keras 的其他部分更具挑战性。Keras 文档中有一些关于构建自定义层的指南(keras.io/layers/writing-your-own-keras-layers/),你一定要阅读。

使后端 API 开发更容易的一种方法是拥有一个小的测试框架,您可以运行它来验证代码是否按预期工作。这是我从 Keras 源代码中改编的一个小测试框架,用来运行您的层并返回结果:

from keras.models import Sequential
from keras.layers.core import Dropout, Reshape

def test_layer(layer, x):
    layer_config = layer.get_config()
    layer_config["input_shape"] = x.shape
    layer = layer.__class__.from_config(layer_config)
    model = Sequential()
    model.add(layer)
    model.compile("rmsprop", "mse")
    x_ = np.expand_dims(x, axis=0)
    return model.predict(x_)[0]

这里是使用 Keras 提供的 layer 对象进行的一些测试,以确保测试框架能够正常运行:

from keras.layers.core import Dropout, Reshape
from keras.layers.convolutional import ZeroPadding2D
import numpy as np

x = np.random.randn(10, 10)
layer = Dropout(0.5)
y = test_layer(layer, x)
assert(x.shape == y.shape)

x = np.random.randn(10, 10, 3)
layer = ZeroPadding2D(padding=(1,1))
y = test_layer(layer, x)
assert(x.shape[0] + 2 == y.shape[0])
assert(x.shape[1] + 2 == y.shape[1])

x = np.random.randn(10, 10)
layer = Reshape((5, 20))
y = test_layer(layer, x)
assert(y.shape == (5, 20))

在我们开始构建本地响应归一化层之前,需要花点时间了解它到底是做什么的。这项技术最初是与 Caffe 一起使用的,Caffe 的文档(caffe.berkeleyvision.org/tutorial/layers/lrn.html)将其描述为一种侧向抑制,通过对局部输入区域进行归一化来工作。在 ACROSS_CHANNEL 模式下,局部区域跨越邻近的通道,但没有空间扩展。在 WITHIN_CHANNEL 模式下,局部区域在空间上扩展,但位于不同的通道中。我们将实现 WITHIN_CHANNEL 模型,如下所示。WITHIN_CHANNEL 模型中的本地响应归一化公式如下:

自定义层的代码遵循标准结构。__init__ 方法用于设置应用程序特定的参数,也就是与层相关的超参数。由于我们的层只执行前向计算,且没有任何可学习的权重,因此我们在构建方法中做的唯一事情就是设置输入形状,并委托给父类的构建方法,后者负责处理任何必要的管理工作。在涉及可学习权重的层中,这个方法就是你设置初始值的地方。

call 方法执行实际的计算。请注意,我们需要考虑维度顺序。另一个需要注意的事情是,批次大小通常在设计时是未知的,因此您需要编写操作,使得批次大小不会被显式调用。计算本身非常直接,并且紧跟公式。分母中的求和部分也可以看作是对行和列维度进行平均池化,填充大小为 (n, n),步幅为 (1, 1)。由于池化数据已经是平均值,因此我们不再需要将求和结果除以 n

课堂的最后部分是get_output_shape_for方法。由于该层对输入张量的每个元素进行了归一化,输出大小与输入大小相同:

from keras import backend as K
from keras.engine.topology import Layer, InputSpec

class LocalResponseNormalization(Layer):

    def __init__(self, n=5, alpha=0.0005, beta=0.75, k=2, **kwargs):
        self.n = n
        self.alpha = alpha
        self.beta = beta
        self.k = k
        super(LocalResponseNormalization, self).__init__(**kwargs)

    def build(self, input_shape):
        self.shape = input_shape
        super(LocalResponseNormalization, self).build(input_shape)

    def call(self, x, mask=None):
        if K.image_dim_ordering == "th":
            _, f, r, c = self.shape
        else:
            _, r, c, f = self.shape
        squared = K.square(x)
        pooled = K.pool2d(squared, (n, n), strides=(1, 1),
            padding="same", pool_mode="avg")
        if K.image_dim_ordering == "th":
            summed = K.sum(pooled, axis=1, keepdims=True)
            averaged = self.alpha * K.repeat_elements(summed, f, axis=1)
        else:
            summed = K.sum(pooled, axis=3, keepdims=True)
            averaged = self.alpha * K.repeat_elements(summed, f, axis=3)
        denom = K.pow(self.k + averaged, self.beta)
        return x / denom

    def get_output_shape_for(self, input_shape):
        return input_shape

你可以在开发过程中使用我们这里描述的测试工具测试这个层。运行这个工具比构建一个完整的网络并将其放入其中要容易得多,或者更糟糕的是,在完全指定该层之前等待运行:

x = np.random.randn(225, 225, 3)
layer = LocalResponseNormalization()
y = test_layer(layer, x)
assert(x.shape == y.shape)

尽管构建自定义 Keras 层在经验丰富的 Keras 开发者中似乎非常普遍,但互联网上并没有太多可用的示例。这可能是因为自定义层通常是为了特定的狭窄目的而构建的,因此不一定具有广泛的用途。可变性也意味着一个单一的示例无法展示你可以通过 API 做的所有可能性。现在你已经对如何构建自定义 Keras 层有了很好的了解,你可能会发现查看 Keunwoo Choi 的melspectogramkeunwoochoi.wordpress.com/2016/11/18/for-beginners-writing-a-custom-keras-layer/)和 Shashank Gupta 的NodeEmbeddingLayershashankg7.github.io/2016/10/12/Custom-Layer-In-Keras-Graph-Embedding-Case-Study.html)是很有启发性的。

生成模型

生成模型是学习创建与训练数据相似的数据的模型。我们在第六章中看到过一个生成模型的例子,该模型学习写出类似于《爱丽丝梦游仙境》的散文。在这个例子中,我们训练了一个模型,给定前 10 个字符来预测文本中的第 11 个字符。另一种生成模型是生成对抗模型GAN),这种模型最近成为了一类非常强大的模型——你在第四章中看到了 GAN 的例子,生成对抗网络和 WaveNet。生成模型的直觉是,它学习到一个好的内部表示,可以在预测阶段生成类似的数据。

从概率的角度看生成模型,典型的分类或回归网络,也称为判别模型,学习一个函数,将输入数据X映射到某个标签或输出y,也就是说,这些模型学习条件概率P(y|X)。另一方面,生成模型学习联合概率,同时学习输入和标签,即P(x, y)。然后,这些知识可以用来创建可能的新(X, y)样本。这赋予了生成模型在没有标签的情况下也能解释输入数据的潜在结构的能力。这在现实世界中是一个非常重要的优势,因为无标签数据比有标签数据更为丰富。

像上述示例这样的简单生成模型也可以扩展到音频领域,例如,学习生成和播放音乐的模型。一个有趣的例子在 WaveNet 论文中描述(更多信息请参考:WaveNet: A Generative Model for Raw Audio,A. van den Oord,2016),该论文描述了一个使用空洞卷积层构建的网络,并提供了 Keras 的实现,代码托管在 GitHub 上(github.com/basveeling/wavenet)。

Keras 示例 — 深度梦境

在这个示例中,我们将查看一个稍微不同的生成网络。我们将看到如何使用预训练的卷积网络生成图像中的新对象。训练用于区分图像的网络对图像有足够的了解,因此也能够生成图像。这一点最早由 Google 的 Alexander Mordvintsev 展示,并在 Google Research 博客中描述(research.googleblog.com/2015/06/inceptionism-going-deeper-into-neural.html)。它最初被称为inceptionalism,但深度梦境这一术语变得更加流行,用来描述这种技术。

深度梦境将反向传播的梯度激活值加回图像,并不断地重复同一过程。网络在这个过程中优化损失函数,但我们能够看到它如何在输入图像(三个通道)上进行优化,而不是在无法轻易可视化的高维隐藏层上。

这种基本策略有许多变种,每一种都能带来新的有趣效果。一些变种包括模糊、对总激活值加约束、衰减梯度、通过裁剪和缩放来无限放大图像、通过随机移动图像来增加抖动等等。在我们的示例中,我们将展示最简单的方法——我们将优化选定层的激活值均值的梯度,针对预训练 VGG-16 的每个池化层,并观察其对输入图像的影响。

首先,像往常一样,我们将声明我们的导入:

from keras import backend as K
from keras.applications import vgg16
from keras.layers import Input
import matplotlib.pyplot as plt
import numpy as np
import os

接下来,我们将加载我们的输入图像。这张图像可能你在深度学习相关的博客文章中见过。原始图像来自这里(www.flickr.com/photos/billgarrett-newagecrap/14984990912):

DATA_DIR = "../data"
IMAGE_FILE = os.path.join(DATA_DIR, "cat.jpg")
img = plt.imread(IMAGE_FILE)
plt.imshow(img)

上述示例的输出如下:

接下来,我们定义一对函数,用于将图像预处理和后处理成适合输入预训练 VGG-16 网络的四维表示:

def preprocess(img):
    img4d = img.copy()
    img4d = img4d.astype("float64")
    if K.image_dim_ordering() == "th":
        # (H, W, C) -> (C, H, W)
        img4d = img4d.transpose((2, 0, 1))
        img4d = np.expand_dims(img4d, axis=0)
        img4d = vgg16.preprocess_input(img4d)
    return img4d

def deprocess(img4d):
    img = img4d.copy()
    if K.image_dim_ordering() == "th":
        # (B, C, H, W)
        img = img.reshape((img4d.shape[1], img4d.shape[2],         img4d.shape[3]))
        # (C, H, W) -> (H, W, C)
        img = img.transpose((1, 2, 0))
    else:
        # (B, H, W, C)
        img = img.reshape((img4d.shape[1], img4d.shape[2], img4d.shape[3]))
    img[:, :, 0] += 103.939
    img[:, :, 1] += 116.779
    img[:, :, 2] += 123.68
    # BGR -> RGB
    img = img[:, :, ::-1]
    img = np.clip(img, 0, 255).astype("uint8")
return img

这两个函数是互为反函数的,也就是说,将图像通过 preprocess 处理后再通过 deprocess 处理,将返回原始图像。

接下来,我们加载预训练的 VGG-16 网络。这个网络已经在 ImageNet 数据上进行了预训练,并且可以从 Keras 发行版中获取。你已经在第三章《深度学习与卷积神经网络》中学习了如何使用预训练模型。我们选择了那个已经去掉全连接层的版本。除了节省我们自己去移除它们的麻烦外,这还允许我们传入任何形状的图像,因为我们需要指定图像宽度和高度的原因是,这决定了全连接层中权重矩阵的大小。由于 CNN 变换是局部的,图像的大小不会影响卷积层和池化层权重矩阵的大小。因此,图像大小的唯一限制是它必须在一个 batch 内保持一致:

img_copy = img.copy()
print("Original image shape:", img.shape)
p_img = preprocess(img_copy)
batch_shape = p_img.shape
dream = Input(batch_shape=batch_shape)
model = vgg16.VGG16(input_tensor=dream, weights="imagenet", include_top=False)

我们需要在接下来的计算中按名称引用 CNN 的层对象,所以我们来构建一个字典。我们还需要理解层的命名规则,因此我们将其输出:

layer_dict = {layer.name : layer for layer in model.layers}
print(layer_dict)

上述示例的输出如下:

{'block1_conv1': <keras.layers.convolutional.Convolution2D at 0x11b847690>,
 'block1_conv2': <keras.layers.convolutional.Convolution2D at 0x11b847f90>,
 'block1_pool': <keras.layers.pooling.MaxPooling2D at 0x11c45db90>,
 'block2_conv1': <keras.layers.convolutional.Convolution2D at 0x11c45ddd0>,
 'block2_conv2': <keras.layers.convolutional.Convolution2D at 0x11b88f810>,
 'block2_pool': <keras.layers.pooling.MaxPooling2D at 0x11c2d2690>,
 'block3_conv1': <keras.layers.convolutional.Convolution2D at 0x11c47b890>,
 'block3_conv2': <keras.layers.convolutional.Convolution2D at 0x11c510290>,
 'block3_conv3': <keras.layers.convolutional.Convolution2D at 0x11c4afa10>,
 'block3_pool': <keras.layers.pooling.MaxPooling2D at 0x11c334a10>,
 'block4_conv1': <keras.layers.convolutional.Convolution2D at 0x11c345b10>,
 'block4_conv2': <keras.layers.convolutional.Convolution2D at 0x11c345950>,
 'block4_conv3': <keras.layers.convolutional.Convolution2D at 0x11d52c910>,
 'block4_pool': <keras.layers.pooling.MaxPooling2D at 0x11d550c90>,
 'block5_conv1': <keras.layers.convolutional.Convolution2D at 0x11d566c50>,
 'block5_conv2': <keras.layers.convolutional.Convolution2D at 0x11d5b1910>,
 'block5_conv3': <keras.layers.convolutional.Convolution2D at 0x11d5b1710>,
 'block5_pool': <keras.layers.pooling.MaxPooling2D at 0x11fd68e10>,
 'input_1': <keras.engine.topology.InputLayer at 0x11b847410>}

我们接着计算每一层五个池化层的损失,并计算每个步骤的平均激活的梯度。梯度会被加回到图像中,并且在每一步显示在每个池化层的图像上:

num_pool_layers = 5
num_iters_per_layer = 3
step = 100

for i in range(num_pool_layers):
    # identify each pooling layer
    layer_name = "block{:d}_pool".format(i+1)
    # build loss function that maximizes the mean activation in layer
    layer_output = layer_dict[layer_name].output
    loss = K.mean(layer_output)
    # compute gradient of image wrt loss and normalize
    grads = K.gradients(loss, dream)[0]
    grads /= (K.sqrt(K.mean(K.square(grads))) + 1e-5)
    # define function to return loss and grad given input image
    f = K.function([dream], [loss, grads])
    img_value = p_img.copy()
    fig, axes = plt.subplots(1, num_iters_per_layer, figsize=(20, 10))
    for it in range(num_iters_per_layer):
        loss_value, grads_value = f([img_value])
        img_value += grads_value * step 
        axes[it].imshow(deprocess(img_value))
    plt.show()

结果图像如下所示:

如你所见,深度梦境的过程放大了梯度在所选层的效果,结果是产生了非常超现实的图像。后续层反向传播的梯度造成了更多的失真,反映出它们更大的感受野和识别更复杂特征的能力。

为了让我们自己确信,训练好的网络确实学到了它所训练的图像类别的表示,我们来看一个完全随机的图像,如下所示,并将其通过预训练网络:

img_noise = np.random.randint(100, 150, size=(227, 227, 3), dtype=np.uint8)
plt.imshow(img_noise)

上述示例的输出如下:

将该图像通过前面的代码处理后,在每一层会生成非常特定的模式,如下所示,表明网络试图在随机数据中寻找结构:

我们可以用噪声图像作为输入重复我们的实验,并从单一的滤波器中计算损失,而不是对所有滤波器的损失求平均。我们选择的滤波器对应 ImageNet 标签中的非洲象(24)。因此,我们将上一段代码中的损失值替换为以下内容。这样,我们就不再对所有滤波器的损失求平均,而是计算代表非洲象类别的滤波器的输出损失:

loss = layer_output[:, :, :, 24]

我们得到的结果看起来像是在block4_pool输出中重复出现的象鼻图像,如下所示:

Keras 示例 — 风格迁移

这篇论文描述了深度梦境的扩展(有关更多信息,请参考:使用卷积神经网络的图像风格迁移,L. A. Gatys, A. S. Ecker, M. Bethge,IEEE 计算机视觉与模式识别会议论文集,2016 年),该论文表明,训练过的神经网络(例如 VGG-16)可以同时学习内容和风格,并且这两者可以独立操作。因此,物体的图像(内容)可以通过将其与画作的图像(风格)结合,风格化成类似画作的效果。

让我们像往常一样,首先导入我们的库:

from keras.applications import vgg16
from keras import backend as K
from scipy.misc import imresize
import matplotlib.pyplot as plt
import numpy as np
import os

我们的示例将展示如何将我们的猫图像风格化为罗斯林·惠勒(Rosalind Wheeler)创作的克劳德·莫奈(Claude Monet)的《日本桥》(The Japanese Bridge)的复制品图像 (goo.gl/0VXC39):

DATA_DIR = "../data"
CONTENT_IMAGE_FILE = os.path.join(DATA_DIR, "cat.jpg")
STYLE_IMAGE_FILE = os.path.join(DATA_DIR, "JapaneseBridgeMonetCopy.jpg")
RESIZED_WH = 400

content_img_value = imresize(plt.imread(CONTENT_IMAGE_FILE), (RESIZED_WH, RESIZED_WH))
style_img_value = imresize(plt.imread(STYLE_IMAGE_FILE), (RESIZED_WH, RESIZED_WH))

plt.subplot(121)
plt.title("content")
plt.imshow(content_img_value)

plt.subplot(122)
plt.title("style")
plt.imshow(style_img_value)

plt.show()

上一个示例的输出如下:

如前所述,我们声明了两个函数,用于在图像和卷积神经网络所期望的四维张量之间进行转换:

def preprocess(img):
    img4d = img.copy()
    img4d = img4d.astype("float64")
    if K.image_dim_ordering() == "th":
        # (H, W, C) -> (C, H, W)
        img4d = img4d.transpose((2, 0, 1))
    img4d = np.expand_dims(img4d, axis=0)
    img4d = vgg16.preprocess_input(img4d)
    return img4d

def deprocess(img4d):
    img = img4d.copy()
    if K.image_dim_ordering() == "th":
        # (B, C, H, W)
        img = img.reshape((img4d.shape[1], img4d.shape[2], img4d.shape[3]))
        # (C, H, W) -> (H, W, C)
        img = img.transpose((1, 2, 0))
    else:
        # (B, H, W, C)
        img = img.reshape((img4d.shape[1], img4d.shape[2], img4d.shape[3]))
    img[:, :, 0] += 103.939
    img[:, :, 1] += 116.779
    img[:, :, 2] += 123.68
    # BGR -> RGB
    img = img[:, :, ::-1]
    img = np.clip(img, 0, 255).astype("uint8")
    return img

我们声明张量来存储内容图像和风格图像,并声明另一个张量来存储合成图像。然后,内容图像和风格图像被连接成一个单一的输入张量。这个输入张量将被输入到预训练的 VGG-16 网络中:

content_img = K.variable(preprocess(content_img_value))
style_img = K.variable(preprocess(style_img_value))
if K.image_dim_ordering() == "th":
    comb_img = K.placeholder((1, 3, RESIZED_WH, RESIZED_WH))
else:
    comb_img = K.placeholder((1, RESIZED_WH, RESIZED_WH, 3))

# concatenate images into single input
input_tensor = K.concatenate([content_img, style_img, comb_img], axis=0)

我们实例化一个预训练的 VGG-16 网络实例,该网络使用 ImageNet 数据进行预训练,并且排除了全连接层:

model = vgg16.VGG16(input_tensor=input_tensor, weights="imagenet", include_top=False)

如前所述,我们构建一个层字典,将层名称映射到训练好的 VGG-16 网络的输出层:

layer_dict = {layer.name : layer.output for layer in model.layers}

下一个模块定义了计算content_lossstyle_lossvariational_loss的代码。最后,我们将损失定义为这三种损失的线性组合:

def content_loss(content, comb):
    return K.sum(K.square(comb - content))

def gram_matrix(x):
    if K.image_dim_ordering() == "th":
        features = K.batch_flatten(x)
    else:
        features = K.batch_flatten(K.permute_dimensions(x, (2, 0, 1)))
    gram = K.dot(features, K.transpose(features))
    return gram

def style_loss_per_layer(style, comb):
    S = gram_matrix(style)
    C = gram_matrix(comb)
    channels = 3
    size = RESIZED_WH * RESIZED_WH
    return K.sum(K.square(S - C)) / (4 * (channels ** 2) * (size ** 2))

def style_loss():
    stl_loss = 0.0
    for i in range(NUM_LAYERS):
        layer_name = "block{:d}_conv1".format(i+1)
        layer_features = layer_dict[layer_name]
        style_features = layer_features[1, :, :, :]
        comb_features = layer_features[2, :, :, :]
        stl_loss += style_loss_per_layer(style_features, comb_features)
    return stl_loss / NUM_LAYERS

def variation_loss(comb):
    if K.image_dim_ordering() == "th":
        dx = K.square(comb[:, :, :RESIZED_WH-1, :RESIZED_WH-1] - 
                      comb[:, :, 1:, :RESIZED_WH-1])
        dy = K.square(comb[:, :, :RESIZED_WH-1, :RESIZED_WH-1] - 
                      comb[:, :, :RESIZED_WH-1, 1:])
    else:
        dx = K.square(comb[:, :RESIZED_WH-1, :RESIZED_WH-1, :] - 
                      comb[:, 1:, :RESIZED_WH-1, :])
        dy = K.square(comb[:, :RESIZED_WH-1, :RESIZED_WH-1, :] - 
                      comb[:, :RESIZED_WH-1, 1:, :])
     return K.sum(K.pow(dx + dy, 1.25))

CONTENT_WEIGHT = 0.1
STYLE_WEIGHT = 5.0
VAR_WEIGHT = 0.01
NUM_LAYERS = 5

c_loss = content_loss(content_img, comb_img)
s_loss = style_loss()
v_loss = variation_loss(comb_img)
loss = (CONTENT_WEIGHT * c_loss) + (STYLE_WEIGHT * s_loss) + (VAR_WEIGHT * v_loss)

这里,内容损失是目标层提取的内容图像特征与组合图像之间的均方根距离(也称为L2 距离)。最小化此损失有助于使风格化图像保持接近原始图像。

风格损失是基本图像表示和风格图像的 Gram 矩阵之间的 L2 距离。矩阵M的 Gram 矩阵是M的转置与M的乘积,即MT * M。这个损失度量了在内容图像表示和风格图像中,特征如何一起出现。一个实际的含义是,内容和风格矩阵必须是方阵。

总变差损失度量了相邻像素之间的差异。最小化此损失有助于使相邻像素相似,从而使最终图像平滑而不是跳跃

我们计算梯度和损失函数,并反向运行网络五次:

grads = K.gradients(loss, comb_img)[0]
f = K.function([comb_img], [loss, grads])

NUM_ITERATIONS = 5
LEARNING_RATE = 0.001

content_img4d = preprocess(content_img_value)
for i in range(NUM_ITERATIONS):
    print("Epoch {:d}/{:d}".format(i+1, NUM_ITERATIONS))
    loss_value, grads_value = f([content_img4d])
    content_img4d += grads_value * LEARNING_RATE 
    plt.imshow(deprocess(content_img4d))
    plt.show()

最后两次迭代的输出如下所示。正如你所看到的,它已经捕捉到了印象派模糊感,甚至是画布的纹理,出现在最终的图像中:

总结

在这一章中,我们介绍了一些之前章节没有涉及的深度学习网络。我们首先简要了解了 Keras 函数式 API,它允许我们构建比迄今为止看到的顺序网络更复杂的网络。接着,我们研究了回归网络,它允许我们在连续空间中进行预测,并开启了我们可以解决的全新问题。然而,回归网络实际上只是标准分类网络的一种非常简单的修改。接下来我们讨论了自编码器,它是一种网络风格,允许我们进行无监督学习,并利用如今所有人都可以访问的大量未标记数据。我们还学习了如何将已经学过的网络像巨大的乐高积木一样组合成更大、更有趣的网络。然后,我们从使用较小的网络构建大型网络转向学习如何使用 Keras 后端层定制网络中的单个层。最后,我们研究了生成模型,这是另一类模型,能够学习模仿其训练的输入,并探讨了这种模型的一些新颖应用。

在下一章,我们将把注意力转向另一种学习方式——强化学习,并通过在 Keras 中构建和训练网络来玩一个简单的计算机游戏,从而探索其概念。

第八章:AI 游戏玩法

在之前的章节中,我们研究了监督学习技术,如回归和分类,以及无监督学习技术,如生成对抗网络(GANs)、自编码器和生成模型。在监督学习的情况下,我们使用预期的输入和输出训练网络,并希望它在面对新的输入时能够预测正确的输出。在无监督学习的情况下,我们向网络展示一些输入,期望它学习数据的结构,以便能够将这一知识应用于新的输入。

在本章中,我们将学习强化学习,或者更具体地说是深度强化学习,即将深度神经网络应用于强化学习。强化学习源于行为心理学。通过奖励正确的行为和惩罚错误的行为来训练代理。在深度强化学习的背景下,网络展示某些输入,并根据其从该输入中产生的输出是否正确给予正面或负面的奖励。因此,在强化学习中,我们有稀疏和时延的标签。经过多次迭代,网络学习产生正确的输出。

深度强化学习领域的先驱是一家名为 DeepMind 的小型英国公司,该公司于 2013 年发表了一篇论文(更多信息请参见:通过深度强化学习玩 Atari 游戏,V. Mnih,arXiv:1312.5602,2013 年),描述了如何通过显示屏幕像素并在分数增加时给予奖励,训练一个卷积神经网络CNN)玩 Atari 2600 电子游戏。相同的架构被用于学习七种不同的 Atari 2600 游戏,其中六个游戏的模型超越了所有之前的方法,并且在三个游戏中超越了人类专家。

与之前学习的策略不同,在这些策略中每个网络都学习单一的学科,强化学习似乎是一种可以应用于各种环境的通用学习算法;它甚至可能是通用人工智能的第一步。DeepMind 后来被谷歌收购,并且该团队一直处于 AI 研究的前沿。随后的一篇论文(更多信息请参见:通过深度强化学习实现人类级控制,V. Mnih,Nature 518.7540,2015 年:529-533。)于 2015 年在著名的《自然》期刊上发表,他们将相同的模型应用于 49 种不同的游戏。

在本章中,我们将探索支撑深度强化学习的理论框架。然后,我们将应用这一框架使用 Keras 构建一个网络,学习玩接球游戏。我们还将简要看看可以使这个网络更好的几个想法以及这个领域中的一些有前景的新研究方向。

总结一下,在本章中,我们将学习关于强化学习的以下核心概念:

  • Q-learning

  • 探索与利用

  • 经验回放

强化学习

我们的目标是构建一个神经网络来玩接球游戏。每场游戏开始时,球会从屏幕顶部的一个随机位置掉落。目标是在球到达底部之前,使用左右箭头键移动底部的球拍接住球。就游戏而言,这相当简单。在任何时刻,这场游戏的状态由球和球拍的(x, y)坐标给出。大多数街机游戏通常有更多的运动部件,因此一个通用的解决方案是将整个当前的游戏屏幕图像作为状态。下图展示了我们接球游戏的四个连续屏幕截图:

精明的读者可能注意到,我们的问题可以被建模为一个分类问题,其中网络的输入是游戏屏幕图像,输出是三种动作之一——向左移动、保持不动或向右移动。然而,这需要我们提供训练示例,可能来自专家进行的游戏录制。另一种更简单的做法可能是构建一个网络,让它反复玩游戏,并根据它是否成功接住球来给它反馈。这种方法也更直观,且更接近人类和动物学习的方式。

表示此类问题最常见的方法是通过马尔可夫决策过程MDP)。我们的游戏是智能体试图学习的环境。在时间步* t 时,环境的状态由s[t]给出(并包含球和球拍的位置)。智能体可以执行某些动作(例如,向左或向右移动球拍)。这些动作有时会导致奖励r[t],奖励可以是正的或负的(例如,得分增加或减少)。动作改变环境,可能导致新的状态s[t+1],然后智能体可以执行另一个动作a[t+1]*,依此类推。状态、动作和奖励的集合,以及从一个状态过渡到另一个状态的规则,构成了马尔可夫决策过程。单场游戏是这个过程的一个回合,表示为状态、动作和奖励的有限序列:

由于这是一个马尔可夫决策过程,状态s[t+1]的概率仅依赖于当前状态s[t]和动作a[t]

最大化未来奖励

作为一个智能体,我们的目标是最大化每场游戏的总奖励。总奖励可以表示如下:

为了最大化总奖励,智能体应该尽力从游戏中的任何时刻t开始,最大化总奖励。时间步t的总奖励由R[t]给出,并表示为:

然而,预测未来的奖励值越远就越困难。为了考虑这一点,我们的智能体应该尝试最大化时间t时的总折扣未来奖励。这是通过在每个未来时间步长中使用折扣因子γ来折扣奖励,从而实现的。如果γ为0,则我们的网络根本不考虑未来的奖励;如果γ为1,则我们的网络完全是确定性的。一个好的γ值大约是0.9。通过因式分解方程,我们可以递归地表达在给定时间步长的总折扣未来奖励,作为当前奖励与下一个时间步长的总折扣未来奖励之和:

Q 学习

深度强化学习利用一种称为Q 学习的无模型强化学习技术。Q 学习可以用来在有限马尔可夫决策过程中为任何给定状态找到最优动作。Q 学习尝试最大化 Q 函数的值,Q 函数表示在状态s下执行动作a时,获得的最大折扣未来奖励:

一旦我们知道了 Q 函数,状态s下的最优动作a就是具有最高 Q 值的动作。然后我们可以定义一个策略Ïπ(s),它可以为我们提供任何状态下的最优动作:

我们可以通过贝尔曼方程的方式定义一个过渡点(s[t], a[t], r[t], s[t+1])的 Q 函数,类似于我们对总折扣未来奖励的处理。这个方程被称为贝尔曼方程

Q 函数可以通过贝尔曼方程来近似。你可以将 Q 函数看作一个查找表(称为Q 表),其中状态(由s表示)是行,动作(由a表示)是列,元素(由Q(s, a)表示)是你在给定行的状态下采取给定列的动作所获得的奖励。在任何状态下,最佳的动作是具有最高奖励的那个。我们从随机初始化 Q 表开始,然后执行随机动作并观察奖励,以便根据以下算法迭代更新 Q 表:

initialize Q-table Q
observe initial state s
repeat
   select and carry out action a
   observe reward r and move to new state s'
   Q(s, a) = Q(s, a) + α(r + γ max_a' Q(s', a') - Q(s, a))
   s = s'
until game over

你会发现这个算法本质上是在对贝尔曼方程做随机梯度下降,通过状态空间(或回合)反向传播奖励,并在多次试验(或周期)中取平均。在这里,α是学习率,决定了前一个 Q 值和折扣后的新最大 Q 值之间的差异应被纳入多少。

深度 Q 网络作为 Q 函数

我们知道我们的 Q 函数将是一个神经网络,接下来自然会问:什么类型的神经网络呢?对于我们的简单示例游戏,每个状态由四张连续的黑白屏幕图像(大小为(80, 80))表示,因此可能的状态总数(也是 Q 表的行数)是2^(80x80x4)。幸运的是,这些状态中的许多代表了不可能或极不可能的像素组合。由于卷积神经网络具有局部连接性(即每个神经元仅与输入的局部区域相连),它避免了这些不可能或极不可能的像素组合。此外,神经网络通常非常擅长为结构化数据(如图像)提取有效特征。因此,CNN 可以非常有效地用于建模 Q 函数。

DeepMind 论文(更多信息请参考:Playing Atari with Deep Reinforcement Learning,V. Mnih,arXiv:1312.5602,2013)也使用了三层卷积层,后面跟着两层全连接层。与传统用于图像分类或识别的 CNN 不同,该网络没有池化层。这是因为池化层使网络对图像中特定物体位置的敏感性降低,而在游戏中,这些信息可能是计算奖励时所需要的,因此不能被丢弃。

下图展示了用于我们示例的深度 Q 网络结构。它遵循原始 DeepMind 论文中的相同结构,唯一不同的是输入层和输出层的形状。我们每个输入的形状为(80, 80, 4):四张连续的黑白游戏控制台截图,每张图像大小为80 x 80 像素。我们的输出形状为(3),对应三个可能动作的 Q 值(向左移动、停留、向右移动):

由于我们的输出是三个 Q 值,这是一个回归任务,我们可以通过最小化Q(s, a)当前值与其根据奖励和未来折扣 Q 值Q(s', a')计算值之间的平方误差之差来进行优化。当前值在迭代开始时已知,未来值是基于环境返回的奖励计算出来的:

平衡探索与利用

深度强化学习是在线学习的一个例子,在这种方法中,训练和预测步骤是交替进行的。与批量学习技术不同,批量学习技术通过在整个训练数据上进行学习生成最佳预测器,而在线学习训练的预测器则随着在新数据上的训练不断改进。

因此,在训练的初期阶段,深度 Q 网络给出随机预测,这可能导致 Q 学习性能不佳。为了缓解这一问题,我们可以使用简单的探索方法,如ε-贪婪法。在ε-贪婪探索中,智能体以概率1-ε选择网络建议的动作,否则选择一个随机动作。 这就是为什么这个策略被称为探索/利用。

随着训练轮数的增加,Q 函数逐渐收敛,开始返回更一致的 Q 值。ε的值可以逐渐减小以适应这一变化,因此,随着网络开始返回更一致的预测,智能体选择利用网络返回的值,而不是选择随机动作。以 DeepMind 为例,ε的值随时间从1减小到0.1,而在我们的示例中,它从0.1减小到0.001

因此,ε-贪婪探索确保在开始时系统平衡 Q 网络做出的不可靠预测和完全随机的动作来探索状态空间,随后随着 Q 网络的预测改善,系统逐渐转向较少的激进探索(更多的激进利用)。

经验回放,或经验的价值

基于表示状态动作对(s[t], a[t])的 Q 值方程,该 Q 值由当前奖励r[t]和下一个时间步的折扣最大 Q 值(s[t+1], a[t+1])表示,我们的策略在逻辑上是训练网络预测给定当前状态(s, a, r)下的最佳下一个状态s'。事实证明,这倾向于将网络推向局部最小值。原因是连续的训练样本往往非常相似。

为了应对这一点,在游戏过程中,我们将所有先前的动作(s, a, r, s')收集到一个固定大小的队列中,称为回放记忆。回放记忆代表网络的经验。在训练网络时,我们从回放记忆中生成随机批次,而不是从最近的(批量)事务中生成。由于这些批次由乱序的经验元组(s, a, r, s')组成,网络的训练效果更好,并且避免陷入局部最小值。

经验可以通过人类的游戏玩法来收集,而不仅仅是(或额外地)通过网络在游戏中的先前动作来收集。另一种方法是在游戏开始时,以观察模式运行网络一段时间,在此期间它会生成完全随机的动作(* = 1*),并从游戏中提取奖励和下一个状态,并将其收集到经验回放队列中。

示例 - Keras 深度 Q 网络用于接球

我们游戏的目标是通过左右箭头键水平移动屏幕底部的挡板,接住从屏幕顶部随机位置释放的球。当挡板成功接住球时,玩家获胜;如果球在挡板接到之前掉出屏幕,玩家失败。这个游戏的优点是非常简单易懂并且易于构建,它的模型源自 Eder Santana 在他的博客文章中描述的接球游戏(更多信息请参考:Keras 玩接球游戏,一个单文件强化学习示例,作者 Eder Santana,2017。)关于深度强化学习。我们使用 Pygame(www.pygame.org/news)这个免费且开源的游戏构建库来构建最初的游戏。该游戏允许玩家使用左右箭头键来移动挡板。你可以在本章的代码包中找到 game.py 文件,以便亲自体验一下这个游戏。

安装 Pygame

Pygame 运行在 Python 之上,并且可以在 Linux(各种版本)、macOS、Windows 以及一些手机操作系统(如 Android 和 Nokia)上使用。完整的发行版列表可以在以下网址找到:www.pygame.org/download.shtml。预构建版本适用于 32 位和 64 位的 Linux 和 Windows,以及 64 位的 macOS。在这些平台上,你可以通过 pip install pygame 命令来安装 Pygame。

如果没有适用于你的平台的预构建版本,你也可以按照以下网址提供的说明从源代码构建: www.pygame.org/wiki/GettingStarted

Anaconda 用户可以在 conda-forge 上找到预构建的 Pygame 版本:

conda install binstar

conda install anaconda-client

conda install -c https://conda.binstar.org/tlatorre pygame # Linux

conda install -c https://conda.binstar.org/quasiben pygame # Mac

为了训练我们的神经网络,我们需要对原始游戏进行一些修改,使得网络能够代替人类玩家进行游戏。我们希望将游戏封装起来,允许网络通过 API 与游戏进行交互,而不是通过键盘的左右箭头键。让我们来看看这个封装游戏的代码。

一如既往,我们从导入开始:

from __future__ import division, print_function
import collections
import numpy as np
import pygame
import random
import os

我们定义了我们的类。构造函数可以选择性地将封装版本的游戏设置为 无头 模式,即不需要显示 Pygame 屏幕。当你需要在云中的 GPU 主机上运行并且只能访问基于文本的终端时,这非常有用。如果你在本地运行封装的游戏并且能够访问图形终端,可以注释掉这一行。接下来,我们调用 pygame.init() 方法来初始化所有 Pygame 组件。最后,我们设置了一些类级常量:

class MyWrappedGame(object):

    def __init__(self):
        # run pygame in headless mode
        os.environ["SDL_VIDEODRIVER"] = "dummy"

        pygame.init()

        # set constants
        self.COLOR_WHITE = (255, 255, 255)
        self.COLOR_BLACK = (0, 0, 0)
        self.GAME_WIDTH = 400
        self.GAME_HEIGHT = 400
        self.BALL_WIDTH = 20
        self.BALL_HEIGHT = 20
        self.PADDLE_WIDTH = 50
        self.PADDLE_HEIGHT = 10
        self.GAME_FLOOR = 350
        self.GAME_CEILING = 10
        self.BALL_VELOCITY = 10
        self.PADDLE_VELOCITY = 20
        self.FONT_SIZE = 30
        self.MAX_TRIES_PER_GAME = 1
        self.CUSTOM_EVENT = pygame.USEREVENT + 1
        self.font = pygame.font.SysFont("Comic Sans MS", self.FONT_SIZE)

reset()方法定义了在每局游戏开始时需要调用的操作,如清空状态队列、设置球和挡板的位置、初始化分数等:

    def reset(self):
        self.frames = collections.deque(maxlen=4)
        self.game_over = False
        # initialize positions
        self.paddle_x = self.GAME_WIDTH // 2
        self.game_score = 0
        self.reward = 0
        self.ball_x = random.randint(0, self.GAME_WIDTH)
        self.ball_y = self.GAME_CEILING
        self.num_tries = 0

        # set up display, clock, etc
        self.screen = pygame.display.set_mode((self.GAME_WIDTH, self.GAME_HEIGHT))
        self.clock = pygame.time.Clock()

在原始游戏中,有一个 Pygame 事件队列,玩家通过按左右箭头键移动挡板时产生的事件,以及 Pygame 组件产生的内部事件,都会写入该队列。游戏代码的核心部分基本上是一个循环(称为事件循环),它读取事件队列并对此做出反应。

在包装版本中,我们将事件循环移到了调用者处。step()方法描述了循环中单次执行时发生的事情。该方法接受一个整数012表示一个动作(分别是向左移动、保持不动和向右移动),然后它设置控制此时球和挡板位置的变量。PADDLE_VELOCITY变量表示一个速度,当发送向左或向右移动的动作时,挡板会向左或向右移动相应的像素。如果球已经越过挡板,它会检查是否发生碰撞。如果发生碰撞,挡板会接住球,玩家(即神经网络)获胜,否则玩家失败。然后,该方法会重新绘制屏幕并将其附加到固定长度的deque中,该队列包含游戏画面的最后四帧。最后,它返回状态(由最后四帧组成)、当前动作的奖励以及一个标志,告诉调用者游戏是否结束:

    def step(self, action):
        pygame.event.pump()

        if action == 0: # move paddle left
            self.paddle_x -= self.PADDLE_VELOCITY
            if self.paddle_x < 0:
                # bounce off the wall, go right
                self.paddle_x = self.PADDLE_VELOCITY
        elif action == 2: # move paddle right
            self.paddle_x += self.PADDLE_VELOCITY
            if self.paddle_x > self.GAME_WIDTH - self.PADDLE_WIDTH:
                # bounce off the wall, go left
                self.paddle_x = self.GAME_WIDTH - self.PADDLE_WIDTH - self.PADDLE_VELOCITY
        else: # don't move paddle
            pass

        self.screen.fill(self.COLOR_BLACK)
        score_text = self.font.render("Score: {:d}/{:d}, Ball: {:d}"
            .format(self.game_score, self.MAX_TRIES_PER_GAME,
                    self.num_tries), True, self.COLOR_WHITE)
        self.screen.blit(score_text, 
            ((self.GAME_WIDTH - score_text.get_width()) // 2,
            (self.GAME_FLOOR + self.FONT_SIZE // 2)))

        # update ball position
        self.ball_y += self.BALL_VELOCITY
        ball = pygame.draw.rect(self.screen, self.COLOR_WHITE,
            pygame.Rect(self.ball_x, self.ball_y, self.BALL_WIDTH, 
            self.BALL_HEIGHT))
        # update paddle position
        paddle = pygame.draw.rect(self.screen, self.COLOR_WHITE,
            pygame.Rect(self.paddle_x, self.GAME_FLOOR, 
                        self.PADDLE_WIDTH, self.PADDLE_HEIGHT))

        # check for collision and update reward
        self.reward = 0
        if self.ball_y >= self.GAME_FLOOR - self.BALL_WIDTH // 2:
            if ball.colliderect(paddle):
                self.reward = 1
            else:
                self.reward = -1

        self.game_score += self.reward
        self.ball_x = random.randint(0, self.GAME_WIDTH)
        self.ball_y = self.GAME_CEILING
        self.num_tries += 1

        pygame.display.flip()

        # save last 4 frames
        self.frames.append(pygame.surfarray.array2d(self.screen))

        if self.num_tries >= self.MAX_TRIES_PER_GAME:
            self.game_over = True

        self.clock.tick(30)
        return np.array(list(self.frames)), self.reward, self.game_over

我们将查看训练我们的网络以玩游戏的代码。

如往常一样,首先我们导入所需的库和对象。除了来自 Keras 和 SciPy 的第三方组件外,我们还导入了之前描述的wrapped_game类:

from __future__ import division, print_function
from keras.models import Sequential
from keras.layers.core import Activation, Dense, Flatten
from keras.layers.convolutional import Conv2D
from keras.optimizers import Adam
from scipy.misc import imresize
import collections
import numpy as np
import os

import wrapped_game

我们定义了两个便捷函数。第一个函数将四个输入图像转换为适合网络使用的形式。输入是一组四个 800 x 800 的图像,因此输入的形状是(4, 800, 800)。然而,网络期望其输入是一个形状为(batch size, 80, 80, 4)的四维张量。在游戏的最初,我们没有四帧画面,所以通过将第一帧堆叠四次来模拟。这个函数返回的输出张量的形状是(80, 80, 4)

get_next_batch()函数从经验回放队列中采样batch_size个状态元组,并从神经网络获取奖励和预测的下一个状态。然后,它计算下一时间步的 Q 函数值并返回:

def preprocess_images(images):
    if images.shape[0] < 4:
        # single image
        x_t = images[0]
        x_t = imresize(x_t, (80, 80))
        x_t = x_t.astype("float")
        x_t /= 255.0
        s_t = np.stack((x_t, x_t, x_t, x_t), axis=2)
    else:
        # 4 images
        xt_list = []
        for i in range(images.shape[0]):
            x_t = imresize(images[i], (80, 80))
            x_t = x_t.astype("float")
            x_t /= 255.0
            xt_list.append(x_t)
        s_t = np.stack((xt_list[0], xt_list[1], xt_list[2], xt_list[3]), 
                       axis=2)
    s_t = np.expand_dims(s_t, axis=0)
    return s_t

def get_next_batch(experience, model, num_actions, gamma, batch_size):
    batch_indices = np.random.randint(low=0, high=len(experience), 
        size=batch_size)
    batch = [experience[i] for i in batch_indices]
    X = np.zeros((batch_size, 80, 80, 4))
    Y = np.zeros((batch_size, num_actions))
    for i in range(len(batch)):
        s_t, a_t, r_t, s_tp1, game_over = batch[i]
        X[i] = s_t
        Y[i] = model.predict(s_t)[0]
        Q_sa = np.max(model.predict(s_tp1)[0])
        if game_over:
            Y[i, a_t] = r_t
        else:
            Y[i, a_t] = r_t + gamma * Q_sa
    return X, Y

我们定义了我们的网络。这是一个用于建模游戏 Q 函数的网络。我们的网络与 DeepMind 论文中提出的网络非常相似。唯一的区别是输入和输出的大小。我们的输入形状是 (80, 80, 4),而他们的是 (84, 84, 4),我们的输出是 (3),对应需要计算 Q 函数值的三个动作,而他们的是 (18),对应 Atari 游戏中可能的动作:

网络有三层卷积层和两层全连接(密集)层。除最后一层外,所有层都使用 ReLU 激活单元。由于我们是在预测 Q 函数的值,因此这是一个回归网络,最后一层没有激活单元:

# build the model
model = Sequential()
model.add(Conv2D(32, kernel_size=8, strides=4, 
                 kernel_initializer="normal", 
                 padding="same",
                 input_shape=(80, 80, 4)))
model.add(Activation("relu"))
model.add(Conv2D(64, kernel_size=4, strides=2, 
                 kernel_initializer="normal", 
                 padding="same"))
model.add(Activation("relu"))
model.add(Conv2D(64, kernel_size=3, strides=1,
                 kernel_initializer="normal",
                 padding="same"))
model.add(Activation("relu"))
model.add(Flatten())
model.add(Dense(512, kernel_initializer="normal"))
model.add(Activation("relu"))
model.add(Dense(3, kernel_initializer="normal"))

如前所述,我们的损失函数是当前 Q(s, a) 值与根据奖励和折扣后的 Q 值 Q(s', a') 计算得到的值之间的平方差,因此均方误差(MSE)损失函数非常有效。对于优化器,我们选择了 Adam,这是一个通用的优化器,并且以较低的学习率进行了初始化:

model.compile(optimizer=Adam(lr=1e-6), loss="mse")

我们为训练定义了一些常量。NUM_ACTIONS 常量定义了网络可以向游戏发送的输出动作的数量。在我们的例子中,这些动作是 012,分别对应向左移动、停留和向右移动。GAMMA 值是未来奖励的折扣因子 INITIAL_EPSILONFINAL_EPSILON 分别指代 参数在 -贪婪探索中的起始值和结束值。MEMORY_SIZE 是经验回放队列的大小。NUM_EPOCHS_OBSERVE 指网络在完全随机发送动作并观察奖励的过程中允许探索游戏的轮次。NUM_EPOCHS_TRAIN 变量指网络进行在线训练的轮次。每一轮对应一个单独的游戏或回合。一个训练运行的总游戏数是 NUM_EPOCHS_OBSERVENUM_EPOCHS_TRAIN 的总和。BATCH_SIZE 是我们在训练中使用的迷你批次的大小:

# initialize parameters
DATA_DIR = "../data"
NUM_ACTIONS = 3 # number of valid actions (left, stay, right)
GAMMA = 0.99 # decay rate of past observations
INITIAL_EPSILON = 0.1 # starting value of epsilon
FINAL_EPSILON = 0.0001 # final value of epsilon
MEMORY_SIZE = 50000 # number of previous transitions to remember
NUM_EPOCHS_OBSERVE = 100
NUM_EPOCHS_TRAIN = 2000

BATCH_SIZE = 32
NUM_EPOCHS = NUM_EPOCHS_OBSERVE + NUM_EPOCHS_TRAIN

我们实例化了游戏和经验回放队列。我们还打开了一个日志文件并初始化了一些变量,为训练做准备:

game = wrapped_game.MyWrappedGame()
experience = collections.deque(maxlen=MEMORY_SIZE)

fout = open(os.path.join(DATA_DIR, "rl-network-results.tsv"), "wb")
num_games, num_wins = 0, 0
epsilon = INITIAL_EPSILON

接下来,我们设置控制训练轮次的循环。正如之前提到的,每一轮对应一个单独的游戏,因此我们此时会重置游戏状态。一个游戏对应一个小球从天花板掉下,可能被挡板接住或错过的回合。损失是预测值和实际 Q 值之间的平方差:

我们通过发送一个虚拟动作(在我们的例子中是 停留)来开始游戏,并获得游戏的初始状态元组:

for e in range(NUM_EPOCHS):
    game.reset() 
    loss = 0.0

    # get first state
    a_0 = 1 # (0 = left, 1 = stay, 2 = right)
    x_t, r_0, game_over = game.step(a_0) 
    s_t = preprocess_images(x_t)

下一个块是游戏的主循环。这是原始游戏中的事件循环,我们将其移到了调用代码中。我们保存当前状态,因为我们将需要它来进行经验回放队列,然后决定向封装的游戏发送什么动作信号。如果我们处于观察模式,我们只会生成一个对应于我们动作的随机数,否则我们将使用-贪心探索方法,随机选择一个动作或使用我们的神经网络(我们也在训练它)来预测我们应该发送的动作:

    while not game_over:
        s_tm1 = s_t

        # next action
        if e <= NUM_EPOCHS_OBSERVE:
            a_t = np.random.randint(low=0, high=NUM_ACTIONS, size=1)[0]
        else:
            if np.random.rand() <= epsilon:
                a_t = np.random.randint(low=0, high=NUM_ACTIONS, size=1)[0]
            else:
                q = model.predict(s_t)[0]
                a_t = np.argmax(q)

一旦知道我们的动作,我们通过调用game.step()发送它到游戏中,game.step()返回新的状态、奖励和一个布尔标志,表示游戏是否结束。如果奖励是正数(表示球被接住),我们会增加胜利次数,并将这个(状态,动作,奖励,新状态,游戏结束)元组存储在我们的经验回放队列中:

        # apply action, get reward
        x_t, r_t, game_over = game.step(a_t)
        s_t = preprocess_images(x_t)
        # if reward, increment num_wins
        if r_t == 1:
            num_wins += 1
        # store experience
        experience.append((s_tm1, a_t, r_t, s_t, game_over))

然后,我们从经验回放队列中随机抽取一个小批量进行训练。对于每次训练,我们计算损失。在每个训练周期中,所有训练的损失总和即为该周期的损失:

        if e > NUM_EPOCHS_OBSERVE:
            # finished observing, now start training
            # get next batch
            X, Y = get_next_batch(experience, model, NUM_ACTIONS, GAMMA, BATCH_SIZE)
            loss += model.train_on_batch(X, Y)

当网络相对未经训练时,它的预测不太准确,因此有意义的是更多地探索状态空间,尽量减少卡在局部最小值的机会。然而,随着网络越来越多地训练,我们逐渐减少的值,以便模型能够预测网络向游戏发送的更多动作:

    # reduce epsilon gradually
    if epsilon > FINAL_EPSILON:
        epsilon -= (INITIAL_EPSILON - FINAL_EPSILON) / NUM_EPOCHS

我们在控制台和日志文件中写出每个训练周期的日志,供后续分析。在训练 100 个周期后,我们保存当前模型的状态,以便在我们决定停止训练时能够恢复。我们还保存了最终模型,以便以后用它来玩游戏:

    print("Epoch {:04d}/{:d} | Loss {:.5f} | Win Count {:d}"
        .format(e + 1, NUM_EPOCHS, loss, num_wins))
    fout.write("{:04d}t{:.5f}t{:d}n".format(e + 1, loss, num_wins))

    if e % 100 == 0:
        model.save(os.path.join(DATA_DIR, "rl-network.h5"), overwrite=True)

fout.close()
model.save(os.path.join(DATA_DIR, "rl-network.h5"), overwrite=True)

我们通过让游戏观察 100 场比赛,然后分别玩 1,000 场、2,000 场和 5,000 场比赛来进行训练。以下是 5,000 场比赛训练的日志文件中的最后几行。如您所见,在训练接近尾声时,网络变得相当擅长玩这个游戏:

损失和胜利计数随训练轮数变化的图表,显示了类似的趋势。虽然看起来随着更多的训练,损失可能会进一步收敛,但它已经从0.6下降到大约0.1,并且训练了5000个周期。同样,胜利次数的图表呈上升趋势,表明随着训练轮数的增加,网络学习得更快:

最后,我们通过让训练好的模型玩固定数量的游戏(在我们的案例中是 100 场)来评估它的技能,并查看它能赢得多少场。以下是执行此操作的代码。如之前一样,我们从导入开始:

from __future__ import division, print_function
from keras.models import load_model
from keras.optimizers import Adam
from scipy.misc import imresize
import numpy as np
import os
import wrapped_game

我们加载在训练结束时保存的模型并进行编译。我们还实例化了我们的wrapped_game

DATA_DIR = "../data"
model = load_model(os.path.join(DATA_DIR, "rl-network.h5"))
model.compile(optimizer=Adam(lr=1e-6), loss="mse")

game = wrapped_game.MyWrappedGame()

然后我们进行 100 场游戏的循环。通过调用每个游戏的 reset() 方法来实例化每场游戏,并开始它。然后,对于每场游戏,直到结束,我们调用模型预测具有最佳 Q 函数的动作。我们报告它赢得的游戏总数。

我们用每个模型进行了测试。第一个训练了 1,000 场游戏,赢得了 42 场中的 100 场;第二个训练了 2,000 场游戏,赢得了 74 场中的 100 场;第三个训练了 5,000 场游戏,赢得了 87 场中的 100 场。这清楚地表明,网络随着训练的进行在不断改进:

num_games, num_wins = 0, 0
for e in range(100):
    game.reset()

    # get first state
    a_0 = 1 # (0 = left, 1 = stay, 2 = right)
    x_t, r_0, game_over = game.step(a_0) 
    s_t = preprocess_images(x_t)

    while not game_over:
        s_tm1 = s_t
        # next action
        q = model.predict(s_t)[0]
        a_t = np.argmax(q)
        # apply action, get reward
        x_t, r_t, game_over = game.step(a_t)
        s_t = preprocess_images(x_t)
        # if reward, increment num_wins
        if r_t == 1:
            num_wins += 1

    num_games += 1
    print("Game: {:03d}, Wins: {:03d}".format(num_games, num_wins), end="r")
print("")

如果你运行评估代码,并取消注释以启用无头模式运行的调用,你可以观看网络进行游戏,观看的过程相当令人惊叹。考虑到 Q 值预测一开始是随机的,并且训练过程中主要是稀疏奖励机制为网络提供指导,网络能够如此高效地学会玩游戏几乎是不可思议的。但和深度学习的其他领域一样,网络确实学会了相当好地玩游戏。

前面展示的例子相对简单,但它阐明了深度强化学习模型的工作过程,并且希望能帮助你建立一个思维模型,通过这个模型你可以接近更复杂的实现。你可能会对 Ben Lau 使用 Keras 实现的 FlappyBird 感兴趣(更多信息请参考:使用 Keras 和深度 Q 网络来玩 FlappyBird,Ben Lau,2016 年,以及 GitHub 页面:github.com/yanpanlau/Keras-FlappyBird)。Keras-RL 项目(github.com/matthiasplappert/keras-rl),这是一个用于深度强化学习的 Keras 库,也有一些非常好的例子。

自从 DeepMind 提出的原始方案以来,已经有其他改进方案被提出,例如双 Q 学习(更多信息请参考:使用双 Q 学习的深度强化学习,H. Van Hasselt,A. Guez 和 D. Silver,AAAI,2016),优先经验回放(更多信息请参考:优先经验回放,T. Schaul,arXiv:1511.05952,2015),以及对抗网络架构(更多信息请参考:用于深度强化学习的对抗网络架构,Z. Wang,arXiv:1511.06581,2015)。双 Q 学习使用两个网络——主网络选择动作,目标网络选择该动作的目标 Q 值。这可以减少单个网络对 Q 值的过高估计,从而让网络训练得更快、更好。优先经验回放增加了采样具有更高预期学习进展的经验元组的概率。对抗网络架构将 Q 函数分解为状态和动作组件,并将它们单独合并回来。

本节讨论的所有代码,包括可以由人类玩家玩的基础游戏,都可以在本章附带的代码包中找到。

前路漫漫

2016 年 1 月,DeepMind 宣布发布 AlphaGo(更多信息请参见:利用深度神经网络和树搜索掌握围棋游戏,D. Silver,Nature 529.7587,页 484-489,2016),这是一个用于下围棋的神经网络。围棋被认为是 AI 难以掌握的一项非常具有挑战性的游戏,主要因为在游戏的任何时刻,可能的着法平均大约有10¹⁷⁰种(更多信息请参见:ai-depot.com/LogicGames/Go-Complexity.html)(而国际象棋则大约是10⁵⁰种)。因此,使用暴力破解的方法来确定最佳着法在计算上是不可行的。在发布时,AlphaGo 已经以 5-0 的成绩战胜了现任欧洲围棋冠军范辉。这是计算机程序首次在围棋对抗中击败人类玩家。随后,2016 年 3 月,AlphaGo 以 4-1 战胜了世界第二号职业围棋选手李世石。

AlphaGo 的研发过程中有几个值得注意的新想法。首先,它是通过结合人类专家对局的监督学习和通过让一台 AlphaGo 与另一台 AlphaGo 对弈的强化学习来进行训练的。你已经在前几章中见过这些思想的应用。

其次,AlphaGo 由价值网络和策略网络组成。在每一步棋时,AlphaGo 会使用蒙特卡罗模拟,这是一种在存在随机变量的情况下预测未来不同结果概率的过程,用来从当前局面想象出许多替代棋局。价值网络用于减少树搜索的深度,从而估算胜负概率,而无需计算游戏到结束的所有步骤,类似于对棋步优劣的直觉判断。策略网络则通过引导搜索朝向能够获得最大即时奖励(或 Q 值)的动作,来减少搜索的广度。有关更详细的描述,请参考博客文章:AlphaGo:通过机器学习掌握古老的围棋游戏,Google Research Blog,2016 年。

虽然 AlphaGo 相较于原始的 DeepMind 网络有了很大的进步,但它仍然是在一个所有玩家都可以看到所有棋子的游戏中进行的,也就是说,它仍然是一个完全信息博弈。2017 年 1 月,卡内基梅隆大学的研究人员宣布了 Libratus(一篇相关论文:AI Takes on Top Poker Players,作者 T. Revel,New Scientist 223.3109,页码 8,2017),这是一款可以玩扑克的 AI。与此同时,由阿尔伯塔大学、捷克布拉格的查尔斯大学和捷克技术大学的研究人员组成的另一个团队,提出了 DeepStack 架构(一篇相关论文:DeepStack: Expert-Level Artificial Intelligence in No-Limit Poker,作者 M. Moravaák,arXiv:1701.01724,2017),用于实现同样的目标。扑克是一种不完全信息博弈,因为玩家无法看到对手的牌。因此,除了学习如何玩游戏之外,扑克 AI 还需要对对手的游戏玩法发展出直觉。

与其使用内置策略来获得直觉,Libratus 使用一种算法,通过尝试在风险和回报之间取得平衡来计算这一策略,这也被称为纳什均衡。从 2017 年 1 月 11 日到 1 月 31 日,Libratus 与四名顶级扑克玩家进行了对战(一篇相关论文:Upping the Ante: Top Poker Pros Face Off vs. Artificial Intelligence,卡内基梅隆大学,2017 年 1 月),并以压倒性优势战胜了他们。

DeepStack 的直觉通过强化学习进行训练,使用从随机扑克情境中生成的示例。它已与来自 17 个国家的 33 位职业扑克玩家进行过对战,且其获胜评分使其比优秀玩家的评分高出一个数量级(一篇相关论文:The Uncanny Intuition of Deep Learning to Predict Human Behavior,作者 C. E. Perez,Medium corporation,Intuition Machine,2017 年 2 月 13 日)。

如你所见,这的确是一个令人激动的时代。最初从能够玩街机游戏的深度学习网络开始,进展到现在能够有效读取你的思维,或至少能够预测(有时是非理性的)人类行为并在虚张声势的博弈中获胜的网络。深度学习的可能性似乎几乎是无限的。

总结

在本章中,我们学习了强化学习背后的概念,以及如何使用 Keras 构建深度学习网络,通过奖励反馈学习如何玩街机游戏。接下来,我们简要讨论了该领域的进展,例如已经能够在超人水平上玩围棋和扑克等更复杂游戏的网络。虽然游戏玩耍看起来像是一个轻松的应用,但这些理念是通向通用人工智能的第一步,在这种人工智能中,网络通过经验学习,而不是依赖大量的训练数据。

第九章:结论

恭喜你读完了这本书!让我们花点时间回顾一下自从开始以来我们已经走了多远。

如果你像大多数读者一样,你开始时已经具备了一些 Python 知识和机器学习的背景,但你有兴趣深入了解深度学习,并希望能够使用 Python 应用这些深度学习技能。

你学会了如何在你的机器上安装 Keras,并开始使用它构建简单的深度学习模型。接着,你了解了原始的深度学习模型——多层感知机,也叫全连接网络FCN)。你学会了如何使用 Keras 构建这个网络。

你还学习了许多可调参数,你需要调整这些参数才能从你的网络中获得良好的结果。使用 Keras,很多繁琐的工作已经为你做了,因为它提供了合理的默认设置,但有时候这些知识对你还是很有帮助的。

在此基础上,你被介绍了卷积神经网络CNN),它最初是为利用图像的特征局部性而构建的,虽然你也可以将其用于其他类型的数据,如文本、音频或视频。你再次看到如何使用 Keras 构建 CNN。你还了解了 Keras 提供的功能,可以轻松直观地构建 CNN。你还看到了如何使用预训练的图像网络通过迁移学习和微调来对自己的图像进行预测。

从那里,你学习了生成对抗网络GAN),它是由一对网络(通常是 CNN)组成,尝试相互对抗,在这个过程中使彼此变得更强。GAN 是深度学习领域的前沿技术,最近围绕 GAN 有很多研究工作。

从那里,我们将注意力转向了文本,并学习了词嵌入,这是近年来用于文本向量表示的最常见技术。我们研究了各种流行的词嵌入算法,了解了如何使用预训练的词嵌入来表示词汇集合,以及 Keras 和 gensim 对词嵌入的支持。

接着我们学习了递归神经网络RNN),这是一类针对处理序列数据(如文本或时间序列)进行优化的神经网络。我们了解了基本 RNN 模型的不足之处,以及如何通过更强大的变体(如长短期记忆网络LSTM)和门控递归单元GRU))来缓解这些问题。我们看了一些使用这些组件的例子。我们还简要了解了有状态 RNN 模型及其可能的应用场景。

接下来,我们介绍了一些不完全符合我们目前所讨论模型类型的其他模型。其中包括自编码器,一种无监督学习模型——回归网络,它预测一个连续的值,而不是离散的标签。我们介绍了Keras 功能 API,它允许我们构建具有多个输入和输出的复杂网络,并在多个管道之间共享组件。我们还研究了如何自定义 Keras,添加当前不存在的功能。

最后,我们研究了在玩街机游戏的背景下使用强化学习训练深度学习网络,许多人认为这是通向通用人工智能的第一步。我们提供了一个使用 Keras 训练简单游戏的示例。接着,我们简要描述了在这一领域的进展,特别是在网络以超人水平玩更难的游戏(如围棋和扑克)方面的进展。

我们相信您现在已具备使用深度学习和 Keras 解决新机器学习问题的技能。这是您成为深度学习专家之路上一个重要且宝贵的技能。

我们感谢您让我们帮助您踏上深度学习精通之路。

Keras 2.0 — 新特性

根据 Francois Chollet 的说法,Keras 于两年前,即 2015 年 3 月发布。随后,它从一个用户增长到了十万个用户。以下图片来自 Keras 博客,展示了 Keras 用户数量随时间增长的情况。

Keras 2.0 的一个重要更新是,API 现在将成为 TensorFlow 的一部分,从 TensorFlow 1.2 开始。事实上,Keras 正变得越来越成为深度学习的通用语言,一种在越来越多的深度学习应用场景中使用的规范。例如,Skymind 正在为 ScalNet 在 Scala 中实现 Keras 规范,而 Keras.js 则为 JavaScript 实现相同的功能,以便直接在浏览器中运行深度学习。同时,也在努力为 MXNET 和 CNTK 深度学习工具包提供 Keras API。

安装 Keras 2.0

安装 Keras 2.0 非常简单,只需运行pip install keras --upgrade,然后运行pip install tensorflow --upgrade

API 更改

Keras 2.0 的变化意味着需要重新考虑一些 API。欲了解详细信息,请参考发布说明(github.com/fchollet/keras/wiki/Keras-2.0-release-notes)。此模块legacy.py总结了最具影响力的变化,并在使用 Keras 1.x 调用时避免警告:

""
Utility functions to avoid warnings while testing both Keras 1 and 2.
"""
import keras
keras_2 = int(keras.__version__.split(".")[0]) > 1 # Keras > 1

def fit_generator(model, generator, epochs, steps_per_epoch):
    if keras_2:
        model.fit_generator(generator, epochs=epochs, steps_per_epoch=steps_per_epoch)
    else:
        model.fit_generator(generator, nb_epoch=epochs, samples_per_epoch=steps_per_epoch)

def fit(model, x, y, nb_epoch=10, *args, **kwargs):
    if keras_2:
        return model.fit(x, y, *args, epochs=nb_epoch, **kwargs)
    else:
        return model.fit(x, y, *args, nb_epoch=nb_epoch, **kwargs)

def l1l2(l1=0, l2=0):
    if keras_2:
        return keras.regularizers.L1L2(l1, l2)
    else:
        return keras.regularizers.l1l2(l1, l2)

def Dense(units, W_regularizer=None, W_initializer='glorot_uniform', **kwargs):
    if keras_2:
        return keras.layers.Dense(units, kernel_regularizer=W_regularizer, kernel_initializer=W_initializer, **kwargs)
    else:
        return keras.layers.Dense(units, W_regularizer=W_regularizer, 
                                  init=W_initializer, **kwargs)

def BatchNormalization(mode=0, **kwargs):
    if keras_2:
        return keras.layers.BatchNormalization(**kwargs)
    else:
        return keras.layers.BatchNormalization(mode=mode, **kwargs)

def Convolution2D(units, w, h, W_regularizer=None, W_initializer='glorot_uniform', border_mode='same', **kwargs):
    if keras_2:
        return keras.layers.Conv2D(units, (w, h), padding=border_mode,
                                   kernel_regularizer=W_regularizer,
                                   kernel_initializer=W_initializer,
                                   **kwargs)
    else:
        return keras.layers.Conv2D(units, w, h, border_mode=border_mode, W_regularizer=W_regularizer, init=W_initializer, **kwargs)

def AveragePooling2D(pool_size, border_mode='valid', **kwargs):
    if keras_2:
        return keras.layers.AveragePooling2D(pool_size=pool_size, 
                                             padding=border_mode, **kwargs)
    else:
        return keras.layers.AveragePooling2D(pool_size=pool_size, 
                                             border_mode=border_mode, **kwargs)

还存在一些重大变化。具体来说:

  • 已移除 maxout 密集层、时间分布密集层和高速公路遗留层

  • 批归一化层不再支持 mode 参数,因为 Keras 的内部结构已发生变化

  • 自定义层需要更新

  • 任何未记录的 Keras 功能可能已被破坏

此外,Keras 代码库已被配置为检测使用 Keras 1.x API 调用,并显示弃用警告,指示如何修改调用以符合 Keras 2 API。如果你已经有一些 Keras 1.x 代码,并且因为担心非破坏性更改而犹豫是否尝试 Keras 2,那么 Keras 2 代码库中的这些弃用警告将对你过渡到 Keras 2 非常有帮助。

posted @ 2025-07-10 11:38  绝不原创的飞龙  阅读(58)  评论(0)    收藏  举报