JavaScript-深度学习-全-
JavaScript 深度学习(全)
原文:
zh.annas-archive.org/md5/ea99677736c22d68b5818a18b5a9213a译者:飞龙
第一部分:动机与基本概念
第一部分 包括一个单独的章节,为您介绍将构成本书其余部分背景的基本概念。这些包括人工智能、机器学习和深度学习以及它们之间的关系。第一章 还探讨了在 JavaScript 中实践深度学习的价值和潜力。
第一章:深度学习和 JavaScript
本章内容
-
深度学习是什么以及它与人工智能(AI)和机器学习的关系
-
使深度学习在各种机器学习技术中脱颖而出的因素,以及导致当前“深度学习革命”的因素
-
使用 TensorFlow.js 进行 JavaScript 中深度学习的原因
-
本书的总体组织
人工智能(AI)周围的热议完全有其原因:即所谓的深度学习革命确实已经发生。 深度学习革命 指的是从 2012 年开始并持续至今的深度神经网络速度和技术上的迅速进步。自那时以来,深度神经网络已被应用到越来越广泛的问题中,在某些情况下使得机器能够解决以前无法解决的问题,并在其他情况下显著提高了解决方案的准确性(有关示例,请参见 表 1.1)。 对于 AI 专家来说,神经网络在许多方面的突破是令人震惊的。对于使用神经网络的工程师来说,这一进步所带来的机遇是令人振奋的。
表 1.1。自 2012 年深度学习革命开始以来,由于深度学习技术的显著改进而导致准确性显著提高的任务示例。这个列表并不全面。在未来的几个月和年份中,进展的速度无疑将继续。
| 机器学习任务 | 代表性深度学习技术 | 我们在本书中使用 TensorFlow.js 执行类似任务的地方 |
|---|---|---|
| 图像内容分类 | 深度卷积神经网络(卷积网络)如 ResNet^([a]) 和 Inception^([b]) 将 ImageNet 分类任务的错误率从 2011 年的约 25%降至 2017 年的不到 5%。^([c]) | 为 MNIST 训练卷积网络(第四章);MobileNet 推断和迁移学习(第五章) |
| 本地化对象和图像 | 深度卷积网络的变体^([d])将 2012 年的定位误差从 0.33 减少到 2017 年的 0.06。 | 在 TensorFlow.js 中使用 YOLO (section 5.2) |
| 将一种自然语言翻译成另一种自然语言 | Google 的神经机器翻译(GNMT)相比于最佳传统机器翻译技术减少了约 60%的翻译错误。^([e]) | 基于长短期记忆(LSTM)的序列到序列模型与注意力机制(第九章) |
| 大词汇量连续语音识别 | 基于 LSTM 的编码器-注意力-解码器架构比最佳非深度学习语音识别系统具有更低的词错误率。^([f]) | 基于注意力的 LSTM 小词汇量连续语音识别(第九章) |
| 生成逼真图像 | 生成对抗网络(GANs)现在能够根据训练数据生成逼真的图像(参见github.com/junyanz/CycleGAN)。 |
使用变分自编码器(VAEs)和 GANs 生成图像(第九章) |
| 生成音乐 | 循环神经网络(RNNs)和变分自编码器(VAEs)正在帮助创作音乐乐谱和新颖的乐器声音(参见magenta.tensorflow.org/demos)。 |
训练 LSTMs 生成文本(第九章) |
| 学习玩游戏 | 深度学习结合强化学习(RL)使机器能够学习使用原始像素作为唯一输入来玩简单的雅达利游戏。^([g]) 结合深度学习和蒙特卡洛树搜索,Alpha-Zero 纯粹通过自我对弈达到了超人类水平的围棋水平。^([h]) | 使用 RL 解决杆-极控制问题和一个贪吃蛇视频游戏(第十一章) |
| 使用医学图像诊断疾病 | 深度卷积网络能够根据患者视网膜图像诊断糖尿病视网膜病变,其特异性和敏感性与训练有素的人类眼科医生相当。^([i]) | 使用预训练的 MobileNet 图像模型进行迁移学习(第五章)。 |
^a
Kaiming He 等人,“深度残差学习用于图像识别”,IEEE 计算机视觉与模式识别会议 (CVPR)论文集,2016 年,第 770–778 页,
mng.bz/PO5P。^b
Christian Szegedy 等人,“使用卷积进一步深入”,IEEE 计算机视觉与模式识别会议 (CVPR)论文集,2015 年,第 1–9 页,
mng.bz/JzGv。^c
2017 年大规模视觉识别挑战(ILSVRC2017)结果,
image-net.org/challenges/LSVRC/2017/results。^d
Yunpeng Chen 等人,“双路径网络”,
arxiv.org/pdf/1707.01629.pdf。^e
Yonghui Wu 等人,“谷歌的神经机器翻译系统:弥合人机翻译差距”,提交于 2016 年 9 月 26 日,
arxiv.org/abs/1609.08144。^f
Chung-Cheng Chiu 等人,“基于序列到序列模型的最新语音识别技术”,提交于 2017 年 12 月 5 日,
arxiv.org/abs/1712.01769。^g
Volodymyr Mnih 等人,“使用深度强化学习玩雅达利游戏”,2013 年 NIPS 深度学习研讨会,
arxiv.org/abs/1312.5602。^h
David Silver 等人,“通过自我对弈用通用强化学习算法掌握国际象棋和将棋”,提交于 2017 年 12 月 5 日,
arxiv.org/abs/1712.01815。^i
Varun Gulshan 等人,“开发和验证用于检测视网膜底片中糖尿病视网膜病变的深度学习算法”,《JAMA》,第 316 卷,第 22 期,2016 年,第 2402–2410 页,
mng.bz/wlDQ。
JavaScript 是一种传统上用于创建 Web 浏览器 UI 和后端业务逻辑(使用 Node.js)的语言。作为一个在 JavaScript 中表达想法和创造力的人,您可能会对深度学习革命感到有些被排斥,因为它似乎是 Python、R 和 C++等语言的专属领域。本书旨在通过名为 TensorFlow.js 的 JavaScript 深度学习库将深度学习和 JavaScript 结合起来。我们这样做是为了让像您这样的 JavaScript 开发人员学会如何编写深度神经网络而不需要学习一门新的语言;更重要的是,我们相信深度学习和 JavaScript 是一对天生的组合。
交叉汇合将创造独特的机会,这是任何其他编程语言都无法提供的。对 JavaScript 和深度学习都是如此。通过 JavaScript,深度学习应用可以在更多平台上运行,触及更广泛的受众,并变得更具视觉和交互性。通过深度学习,JavaScript 开发人员可以使他们的 Web 应用程序更加智能。我们将在本章后面描述如何做到这一点。
表 1.1 列出了迄今为止在这场深度学习革命中我们所见过的一些最令人兴奋的成就。在本书中,我们选择了其中一些应用,并创建了如何在 TensorFlow.js 中实现它们的示例,无论是完整形式还是简化形式。这些示例将在接下来的章节中深入介绍。因此,你不仅仅会对这些突破感到惊叹:你还可以学习它们、理解它们,并在 JavaScript 中实现它们。
但在您深入研究这些令人兴奋的、实用的深度学习示例之前,我们需要介绍有关人工智能、深度学习和神经网络的基本背景。
1.1. 人工智能、机器学习、神经网络和深度学习
AI、机器学习、神经网络和深度学习等短语意味着相关但不同的事物。为了在令人眼花缭乱的人工智能世界中找到方向,您需要了解它们指代的内容。让我们定义这些术语及其之间的关系。
1.1.1. 人工智能
如图 1.1 中的维恩图所示,人工智能是一个广泛的领域。该领域的简明定义如下:自动执行通常由人类执行的智力任务的努力。因此,人工智能涵盖了机器学习、神经网络和深度学习,但它还包括许多与机器学习不同的方法。例如,早期的国际象棋程序涉及由程序员精心制定的硬编码规则。这些不被视为机器学习,因为机器是明确地编程来解决问题,而不是允许它们通过从数据中学习来发现解决问题的策略。很长一段时间以来,许多专家相信通过手工制作一套足够庞大的明确规则来操纵知识并做出决策,可以实现人类级别的人工智能。这种方法被称为符号人工智能,并且它是从 1950 年代到 1980 年代末人工智能的主导范式。^([1])
¹
一个重要的符号人工智能类型是专家系统。请参阅这篇 Britannica 文章了解它们。
图 1.1. 人工智能、机器学习、神经网络和深度学习之间的关系。正如这个维恩图所示,机器学习是人工智能的一个子领域。人工智能的一些领域使用与机器学习不同的方法,如符号人工智能。神经网络是机器学习的一个子领域。存在非神经网络的机器学习技术,如决策树。深度学习是创建和应用“深度”神经网络的科学与艺术——多“层”的神经网络——与“浅层”神经网络——层次较少的神经网络相对。

1.1.2. 机器学习:它与传统编程的不同之处
作为与符号人工智能不同的人工智能子领域,机器学习是从一个问题中产生的:计算机是否能超越程序员所知道的如何编程来执行,并且自行学习如何执行特定任务?正如你所看到的,机器学习的方法与符号人工智能的方法根本不同。而符号人工智能依赖于硬编码的知识和规则,机器学习则试图避免这种硬编码。那么,如果一台计算机没有明确指示如何执行任务,它将如何学习如何执行任务呢?答案是通过从数据中学习示例。
这打开了一个新的编程范式(图 1.2)。举个机器学习范例,假设你正在开发一个处理用户上传照片的 Web 应用程序。你希望应用程序的一个功能是自动将照片分类为包含人脸和不包含人脸的照片。应用程序将针对人脸图像和非人脸图像采取不同的操作。为此,你想创建一个程序,在给定任何输入图像(由像素数组组成)时输出二进制的人脸/非人脸答案。
图 1.2. 比较传统编程范式和机器学习范式

我们人类可以在一瞬间完成这个任务:我们大脑的基因硬编码和生活经验赋予了我们这样做的能力。然而,对于任何程序员来说,无论多么聪明和经验丰富,都很难用编程语言(人类与计算机交流的唯一实用方式)编写出如何准确判断图像是否包含人脸的一套明确规则。你可以花费几天的时间查看对 RGB(红绿蓝)像素值进行算术运算的代码,以便检测看起来像脸、眼睛和嘴巴的椭圆轮廓,以及设计关于轮廓之间几何关系的启发式规则。但你很快会意识到,这样的努力充满了难以证明的逻辑和参数的任意选择。更重要的是,很难让它工作得好!^([2]) 你想出的任何启发式方法在面对现实生活图像中人脸可能呈现的各种变化时都很可能不够用,比如脸部的大小、形状和细节的差异;面部表情;发型;肤色;方向;部分遮挡的存在或不存在;眼镜;光照条件;背景中的物体;等等。
²
实际上,以前确实尝试过这样的方法,但效果并不好。这份调查报告提供了深度学习出现之前人脸检测的手工制定规则的很好的例子:Erik Hjelmås 和 Boon Kee Low,“Face Detection: A Survey”,计算机视觉与图像理解,2001 年 9 月,第 236–274 页,
mng.bz/m4d2。
在机器学习范式中,你意识到为这样的任务手工制定一套规则是徒劳的。相反,你找到一组图像,其中一些有脸,一些没有。然后,你为每个图像输入期望的(即正确的)脸部或非脸部答案。这些答案被称为标签。这是一个更容易处理的(事实上,微不足道的)任务。如果图像很多,可能需要一些时间来为它们标记标签,但是标记任务可以分配给几个人,并且可以并行进行。一旦你标记了图像,你就应用机器学习,让机器自己发现一套规则。如果你使用正确的机器学习技术,你将得到一套训练有素的规则,能够以超过 99% 的准确率执行脸部/非脸部任务——远远优于任何你希望通过手工制定规则实现的东西。
从前面的例子中,我们可以看到机器学习是自动发现解决复杂问题规则的过程。这种自动化对于像面部检测这样的问题非常有益,人类直觉地知道规则并且可以轻松标记数据。对于其他问题,规则并不是直观的。例如,考虑预测用户是否会点击网页上显示的广告的问题,给定页面和广告的内容以及时间和位置等其他信息。一般来说,没有人能准确预测这种问题。即使有人能够,模式也可能随着时间和新内容、新广告的出现而变化。但是标记的训练数据来自广告服务的历史:它来自广告服务器的日志。仅凭数据和标签的可用性就使机器学习成为解决这类问题的良好选择。
在图 1.3 中,我们更详细地探讨了机器学习涉及的步骤。有两个重要阶段。第一个是训练阶段。这个阶段使用数据和答案,称为训练数据。每对输入数据和期望的答案被称为例子。借助这些例子,训练过程产生了自动发现的规则。尽管规则是自动发现的,但它们并不是完全从零开始发现的。换句话说,机器学习算法并不创造性地提出规则。特别是,人类工程师在训练开始时提供规则的蓝图。这个蓝图被封装在一个模型中,形成了机器可能学习的规则的假设空间。如果没有这个假设空间,就会有一个完全不受限制的、无限的可能规则搜索空间,这不利于在有限的时间内找到好的规则。我们将详细描述可用的模型种类以及根据手头的问题选择最佳模型的方法。目前,可以说,在深度学习的背景下,模型在神经网络由多少层、它们是什么类型的层以及它们如何连接方面有所不同。
图 1.3. 比图 1.2 中更详细的机器学习范式视角。机器学习的工作流程包括两个阶段:训练和推断。训练是机器自动发现将数据转换为答案的规则的过程。学习到的规则被封装在一个经过训练的“模型”中,是训练阶段的成果,并构成推断阶段的基础。推断意味着使用模型为新数据获取答案。

使用训练数据和模型架构,训练过程会产生学习到的规则,封装在一个训练模型中。 这个过程采用蓝图,并以各种方式改变(或调整)它,使模型的输出逐渐接近期望的输出。 训练阶段的时间可以从毫秒到数天不等,这取决于训练数据的数量,模型架构的复杂性以及硬件的速度。 这种机器学习风格——即使用标记的示例逐渐减少模型输出中的错误——被称为监督学习。[3] 本书中涵盖的大部分深度学习算法都是监督学习。 一旦我们有了训练好的模型,就可以将学到的规则应用到新数据上——即训练过程从未见过的数据。 这是第二阶段,或推断阶段。 推断阶段的计算负荷比训练阶段小,因为 1)推断通常一次只处理一个输入(例如,一个图像),而训练涉及遍历所有训练数据; 2)在推断期间,模型不需要被改变。
³
另一种机器学习的风格是无监督学习,其中使用未标记的数据。 无监督学习的例子包括聚类(发现数据集中的不同子集)和异常检测(确定给定示例与训练集中的示例是否足够不同)。
学习数据的表示
机器学习是关于从数据中学习的。 但究竟学到了什么? 答案:一种有效地转换数据的方式,或者换句话说,将数据的旧表示改变为一个新表示,使我们更接近解决手头的问题。
在我们进一步讨论之前,什么是表示?其核心是一种看待数据的方式。 相同的数据可以以不同的方式来看待,从而导致不同的表示。 例如,彩色图像可以有 RGB 或 HSV(色相-饱和度-值)编码。 这里,编码 和 表示 这两个词基本上是指相同的事物,可以互换使用。 当以这两种不同格式进行编码时,代表像素的数值完全不同,即使它们是同一图像的。 不同的表示对于解决不同的问题非常有用。 例如,要找出图像中所有红色部分,RGB 表示更有用; 但是要找出相同图像的色饱和部分,HSV 表示更有用。 这基本上就是机器学习的全部内容:找到一种适当的转换,将输入数据的旧表示转换为一个新表示——这个新表示适合解决特定的任务,比如在图像中检测汽车的位置或决定图像中是否包含猫和狗。
为了给出一个视觉示例,我们在一个平面上有一组白点和几个黑点(图 1.4)。假设我们想要开发一个算法,可以接受点的二维(x,y)坐标并预测该点是黑色还是白色。在这种情况下,
-
输入数据是点的二维笛卡尔坐标(x 和 y)。
-
输出是点的预测颜色(是黑色还是白色)。
图 1.4. 机器学习的表示转换的玩具示例。面板 A:平面中由黑点和白点组成的数据集的原始表示。面板 B 和 C:两个连续的转换步骤将原始表示转换为更适合颜色分类任务的表示。

数据显示了图 1.4 的面板 A 中的模式。机器如何根据 x 和 y 坐标决定点的颜色呢?它不能简单地将 x 与一个数字进行比较,因为白点的 x 坐标范围与黑点的 x 坐标范围重叠!同样,算法不能依赖于 y 坐标。因此,我们可以看到点的原始表示不适合黑白分类任务。
我们需要的是一种将两种颜色分开的新表示方式。在这里,我们将原始的笛卡尔 x-y 表示转换为极坐标系统表示。换句话说,我们通过以下方式表示一个点:1)它的角度——x 轴和连接原点与点的线之间形成的角度(参见图 1.4 的面板 A 中的示例)和 2)它的半径——它到原点的距离。经过这个转换,我们得到了相同数据集的新表示,如图 1.4 的面板 B 所示。这个表示更适合我们的任务,因为黑点和白点的角度值现在完全不重叠。然而,这种新的表示仍然不是理想的,因为黑白颜色分类不能简单地与阈值值(如零)进行比较。
幸运的是,我们可以应用第二个转换来实现这一点。这个转换基于简单的公式
(absolute value of angle) - 135 degrees
结果表示,如面板 C 所示,是一维的。与面板 B 中的表示相比,它舍弃了关于点到原点的距离的无关信息。但它是一个完美的表示,因为它允许完全直接的决策过程:
if the value < 0, the point is classified as white;
else, the point is classified as black
在这个示例中,我们手动定义了数据表示的两步转换。但是,如果我们尝试使用关于正确分类百分比的反馈来自动搜索不同可能的坐标转换,那么我们就会进行机器学习。在解决实际机器学习问题时涉及的转换步骤数量通常远远大于两步,特别是在深度学习中,可以达到数百步。此外,实际机器学习中所见到的表示转换类型可能比这个简单示例中所见到的要复杂得多。深度学习中的持续研究不断发现更复杂、更强大的转换方式。但是,图 1.4 中的示例捕捉到了寻找更好表示的本质。这适用于所有的机器学习算法,包括神经网络、决策树、核方法等。
1.1.3. 神经网络与深度学习
神经网络是机器学习的一个子领域,其中数据表示的转换是由一个系统完成的,其架构 loosely 受到人类和动物大脑中神经元连接方式的启发。神经元在大脑中如何连接到彼此?这在物种和脑区之间有所不同。但是神经元连接的一个经常遇到的主题是层组织。许多哺乳动物的大脑部分都是以分层方式组织的。例如视网膜、大脑皮层和小脑皮层。
至少在表面上,这种模式在某种程度上与人工神经网络的一般组织方式相似(在计算机领域简称为神经网络,这里几乎没有混淆的风险),其中数据在多个可分隔阶段中进行处理,适当地称为层。这些层通常被堆叠在一起,仅在相邻层之间存在连接。图 1.5 显示了一个具有四层的简单(人工)神经网络。输入数据(在本例中为图像)流入第一层(图中的左侧),然后依次从一层流向下一层。每个层对数据的表示应用新的转换。随着数据通过层的流动,表示与原始数据越来越不同,并且越来越接近神经网络的目标,即为输入图像应用正确的标签。最后一层(图中的右侧)发出神经网络的最终输出,即图像分类任务的结果。
图 1.5. 神经网络的示意图,按层组织。这个神经网络对手写数字的图像进行分类。在层之间,你可以看到原始数据的中间表示。经授权转载自 François Chollet 的《用 Python 进行深度学习》,Manning 出版社,2017 年。

神经网络的一层类似于一个数学函数,因为它是从输入值到输出值的映射。然而,神经网络的层与纯粹的数学函数不同,因为它们通常是有状态的。换句话说,它们持有内部记忆。一个层的记忆体现在它的权重中。什么是权重?它们只是一组属于该层的数值,决定了每个输入表示在该层如何被转换为输出表示。例如,常用的密集层通过将其输入数据与矩阵相乘,并将结果加上一个向量来进行转换。矩阵和向量就是密集层的权重。当一个神经网络通过接触训练数据进行训练时,权重会以一种系统化的方式发生变化,目的是最小化一个称为损失函数的特定值,我们将在第二章和第三章中通过具体例子详细介绍这一点。
尽管神经网络的灵感来源于大脑,但我们应该小心不要过度赋予它们人性化。神经网络的目的是不是研究或模拟大脑的工作方式。这是神经科学的领域,是一个独立的学术学科。神经网络的目的是通过从数据中学习,让机器执行有趣的实际任务。虽然一些神经网络在结构和功能上与生物大脑的某些部分相似,确实值得注意,但这是否只是巧合超出了本书的范围。无论如何,我们不应过度解读这些相似性。重要的是,没有证据表明大脑是通过任何形式的梯度下降学习的,而梯度下降是训练神经网络的主要方式(在下一章中介绍)。许多神经网络中的重要技术帮助推动了深度学习革命,它们的发明和采用并不是因为得到了神经科学的支持,而是因为它们帮助神经网络更好、更快地解决实际学习任务。
⁴
关于功能相似性的一个引人注目的例子,请看那些最大化激活卷积神经网络不同层的输入(参见第四章),这些输入与人类视觉系统不同部分的神经元感受野有着密切的相似之处。
现在你知道什么是神经网络了,我们可以告诉你什么是深度学习。深度学习是研究和应用深度神经网络的学科,简单地说,就是具有许多层次(通常是从几十到数百层)的神经网络。在这里,深度一词指的是大量连续层次的表示法的概念。构成数据模型的层数称为模型的深度。该领域的其他合适名称可能是“层次表示学习”或“分层表示学习”。现代深度学习通常涉及数十到数百个连续的表示层次,它们都是自动从训练数据中学习的。与此同时,其他机器学习方法往往集中于仅学习一到两层数据表示;因此,它们有时被称为浅层学习。
在深度学习中,“深度”一词被误解为对数据的任何深层次理解,即,“深度”意味着理解像“自由不是免费的”这样的句子背后的含义,或者品味 M.C. Escher 的作品中的矛盾和自指。这种“深度”对于人工智能研究者来说仍然是一个难以捉摸的目标。[5]未来,深度学习可能会让我们更接近这种深度,但这肯定比给神经网络添加层次更难量化和实现。
⁵
Douglas Hofstadter,《Google 翻译的浅薄》,《大西洋月刊》,2018 年 1 月 30 日,
mng.bz/5AE1。
不仅仅是神经网络:其他流行的机器学习技术
我们直接从 图 1.1 的 Venn 图中的“机器学习”圈子转到内部的“神经网络”圈子。然而,值得我们简要讨论一下不是神经网络的机器学习技术,不仅因为这样做会给我们更好的历史背景,而且因为您可能会在现有代码中遇到一些这样的技术。
朴素贝叶斯分类器是最早的机器学习形式之一。简而言之,贝叶斯定理是关于如何估计事件概率的定理,给定 1) 事件发生的先验信念有多大可能性和 2) 与事件相关的观察事实(称为特征)。这个定理可以用来通过选择给定观察事实的最大概率(似然)的类别,将观察到的数据点分类到许多已知类别之一。朴素贝叶斯基于观察到的事实相互独立的假设(一个强假设和天真的假设,因此得名)。
逻辑回归(或 logreg)也是一种分类技术。由于它的简单和多才多艺的性质,它仍然很受欢迎,通常是数据科学家在尝试了解手头分类任务的感觉后尝试的第一件事情。
核方法,其中支持向量机(SVM)是最著名的例子,通过将原始数据映射到更高维度的空间,并找到一个最大化两类示例之间距离(称为边距)的转换来解决二元(即两类)分类问题。
决策树是类似流程图的结构,允许您对输入数据点进行分类或根据输入预测输出值。在流程图的每个步骤中,您需要回答一个简单的是/否问题,例如,“特征 X 是否大于某个阈值?”根据答案是是还是否,您将前进到两个可能的下一个问题之一,这只是另一个是/否问题,依此类推。一旦到达流程图的末尾,您将得到最终答案。因此,决策树易于人类可视化和解释。
随机森林和梯度提升机通过形成大量专门的个体决策树的集成来提高决策树的准确性。集成,也称为集成学习,是训练一组(即集成)个体机器学习模型并在推理过程中使用它们的输出的技术。如今,梯度提升可能是处理非感知数据(例如,信用卡欺诈检测)的最佳算法之一,如果不是最佳算法。与深度学习并列,它是数据科学竞赛(例如 Kaggle 上的竞赛)中最常用的技术之一。
神经网络的兴起、衰退和再兴起,以及背后的原因
神经网络的核心思想早在 1950 年代就形成了。训练神经网络的关键技术,包括反向传播,是在 1980 年代发明的。然而,在 1980 年代到 2010 年代的很长一段时间里,神经网络几乎完全被研究界忽视,部分原因是由于竞争方法(如 SVM)的流行,部分原因是由于缺乏训练深度(多层)神经网络的能力。但是大约在 2010 年左右,一些仍然在研究神经网络的人开始取得重要的突破:加拿大多伦多大学的 Geoffrey Hinton 小组,蒙特利尔大学的 Yoshua Bengio 小组,纽约大学的 Yann LeCun 小组,以及瑞士达勒莫勒人工智能研究所(IDSIA)的研究人员。这些团队取得了重要的里程碑,包括在图形处理单元(GPU)上实现深度神经网络的第一个实际应用,并将 ImageNet 计算机视觉挑战的错误率从约 25%降低到不到 5%。
自 2012 年以来,深度卷积神经网络(卷积网络)已成为所有计算机视觉任务的首选算法;更一般地,它们适用于所有感知任务。非计算机视觉感知任务的例子包括语音识别。在 2015 年和 2016 年的主要计算机视觉会议上,几乎不可能找到不涉及卷积网络的演示。同时,深度学习还在许多其他类型的问题中找到了应用,如自然语言处理。它已经完全取代了支持向量机(SVM)和决策树在广泛应用的范围内。例如,多年来,欧洲核子研究组织(CERN)使用基于决策树的方法来分析大型强子对撞机中的 ATLAS 探测器的粒子数据;但由于其在大型数据集上的性能更高且更容易训练,CERN 最终转向了深度神经网络。
那么,是什么让深度学习在众多可用的机器学习算法中脱颖而出呢?(请参阅信息框 1.1 查看一些不是深度神经网络的流行机器学习技术列表。)深度学习迅速崛起的主要原因之一是它在许多问题上提供了更好的性能。但这并不是唯一的原因。深度学习还使问题解决变得更容易,因为它自动化了曾经是机器学习工作流程中最关键和最困难的步骤:特征工程。
以前的机器学习技术——浅层学习——只涉及将输入数据转换为一个或两个连续的表示空间,通常通过简单的转换,如高维非线性投影(核方法)或决策树。但是,复杂问题所需的精细表示通常无法通过这些技术获得。因此,人工工程师不得不费尽心思地使初始输入数据更容易被这些方法处理:他们不得不手动为其数据设计出良好的表示层。这就是特征工程。另一方面,深度学习自动化了这一步骤:通过深度学习,您可以一次学习所有特征,而不是自己设计它们。这极大地简化了机器学习工作流程,通常用单个、简单的端到端深度学习模型替代了复杂的多阶段管道。通过自动化特征工程,深度学习使机器学习变得更少劳动密集和更加健壮——一举两得。
这是深度学习从数据中学习的两个基本特征:逐层增量地开发越来越复杂的表示方式;以及这些中间增量表示是共同学习的,每层都更新以满足其上层和下层的表示需求。这两个属性共同使深度学习比以前的机器学习方法更加成功。
1.1.4. 为什么深度学习?为什么是现在?
如果神经网络的基本思想和核心技术早在 1980 年代就已存在,为什么深度学习革命直到 2012 年才开始发生?两者之间发生了什么变化?总的来说,推动机器学习进步的有三个技术力量:
-
硬件
-
数据集和基准测试
-
算法进展
让我们逐一探讨这些因素。
硬件
深度学习是一门由实验结果指导而非理论指导的工程科学。只有当适当的硬件可用于尝试新的想法(或扩大旧想法的规模,通常情况下)时,算法进展才能成为可能。用于计算机视觉或语音识别的典型深度学习模型所需的计算能力比您的笔记本电脑提供的数量级更高。
在整个 21 世纪的头十年中,NVIDIA 和 AMD 等公司投入了数十亿美元开发快速、大规模并行芯片——用于提供越来越逼真的视频游戏图形的单一用途超级计算机,旨在实时在您的屏幕上渲染复杂的 3D 场景。当 NVIDIA 于 2007 年推出 CUDA(短语 Compute Unified Device Architecture)时,这些投资开始让科学界受益。CUDA 是其 GPU 产品线的通用编程接口。在各种高度可并行化的应用程序中,一小部分 GPU 开始取代大型 CPU 集群,从物理建模开始。由许多矩阵乘法和加法组成的深度神经网络也是高度可并行化的。
在 2011 年左右,一些研究人员开始编写神经网络的 CUDA 实现,丹·希雷赞和亚历克斯·克里兹弗斯基是其中的先驱之一。如今,高端 GPU 在训练深度神经网络时可以提供比 typical CPU 高出数百倍的并行计算能力。如果没有现代 GPU 的强大计算能力,许多最先进的深度神经网络的训练是不可能的。
数据和基准测试
如果硬件和算法是深度学习革命的蒸汽机,那么数据就是它的煤炭:是驱动我们智能机器的原材料,没有它什么也不可能。在数据方面,除了过去 20 年存储硬件的指数级进步(遵循摩尔定律)之外,游戏变革者是互联网的崛起,使得收集和分发用于机器学习的大型数据集成为可能。今天,大型公司处理图像数据集、视频数据集和自然语言数据集,而这些数据集没有互联网是无法收集的。Flickr 上用户生成的图像标签,例如,已成为计算机视觉的数据宝库。YouTube 视频也是如此。而维基百科是自然语言处理的关键数据集。
如果有一个数据集促进了深度学习的崛起,那就是 ImageNet,它由 140 万张手动注释的图片组成,涵盖 1000 个分类。使 ImageNet 特殊的不仅仅是其规模庞大;还有与之相关的年度比赛。自 2010 年以来,像 ImageNet 和 Kaggle 这样的公开竞赛是激励研究人员和工程师去突破极限的极佳方式。拥有共同的基准让研究人员竞争超越已经极大地推动了深度学习的最近崛起。
算法进步
除了硬件和数据之外,在 2000 年代后期之前,我们还缺乏一种可靠的方法来训练非常深的神经网络。因此,神经网络仍然是相当浅的,只使用一两层表示;因此,它们无法与更精细的浅层方法(如 SVM 和随机森林)相媲美。关键问题在于通过深层堆栈的层传播梯度。用于训练神经网络的反馈信号会随着层数的增加而减弱。
这种情况在 2009 年至 2010 年发生了变化,随着几个简单但重要的算法改进的出现,使梯度传播变得更好:
-
更好的激活函数用于神经网络层(如线性整流单元,或 relu)
-
更好的权重初始化方案(例如,Glorot 初始化)
-
更好的优化方案(例如,RMSProp 和 ADAM 优化器)
只有当这些改进开始允许训练具有 10 层或更多层的模型时,深度学习才开始发光发热。最终,在 2014 年、2015 年和 2016 年,还发现了更先进的帮助梯度传播的方法,如批归一化、残差连接和深度可分离卷积。今天,我们可以从头开始训练数千层深的模型。
1.2. 为什么要结合 JavaScript 和机器学习?
机器学习,像人工智能和数据科学的其他分支一样,通常使用传统的后端语言进行,比如 Python 和 R,运行在服务器或工作站上而不是在 web 浏览器中。^([6]) 这种现状并不令人意外。深度神经网络的训练通常需要多核和 GPU 加速的计算,这在浏览器选项卡中直接不可用;有时需要大量数据来训练这样的模型,最方便的方式是在后端进行摄取:例如,从几乎无限大小的本地文件系统中。直到最近,许多人认为“JavaScript 中的深度学习”是一种新奇事物。在本节中,我们将阐述为什么对于许多种类的应用来说,在浏览器环境中使用 JavaScript 进行深度学习是一个明智的选择,并解释如何结合深度学习和 web 浏览器的力量创造独特的机会,特别是在 TensorFlow.js 的帮助下。
⁶
Srishti Deoras,“数据科学家学习的前 10 种编程语言”,《Analytics India Magazine》,2018 年 1 月 25 日,
mng.bz/6wrD。
首先,一旦训练了机器学习模型,就必须将其部署到某个地方,以便对真实数据进行预测(例如对图像和文本进行分类,检测音频或视频流中的事件等)。没有部署,对模型进行训练只是浪费计算资源。通常情况下,希望或必须将“某个地方”设置为 web 前端。本书的读者可能会意识到 web 浏览器的整体重要性。在台式机和笔记本电脑上,通过 web 浏览器是用户访问互联网上的内容和服务的主要方式。这是用户在使用这些设备时花费大部分时间的方式,远远超过第二名。这是用户完成大量日常工作、保持联系和娱乐自己的方式。运行在 web 浏览器中的各种应用程序为应用客户端机器学习提供了丰富的机会。对于移动前端来说,Web 浏览器在用户参与度和时间上落后于原生移动应用程序。但是,移动浏览器仍然是一股不可忽视的力量,因为它们具有更广泛的覆盖范围、即时访问和更快的开发周期。^([7]) 实际上,由于它们的灵活性和易用性,许多移动应用程序,例如 Twitter 和 Facebook,对于某些类型的内容会在启用 JavaScript 的 web 视图中运行。
⁷
Rishabh Borde,“移动应用中花费在互联网上的时间,2017–19:比移动网络多了 8 倍”,DazeInfo,2017 年 4 月 12 日,
mng.bz/omDr。
由于其广泛的覆盖范围,Web 浏览器是部署深度学习模型的一个合理选择,只要模型所需的数据类型在浏览器中可用。但是,浏览器中有哪些类型的数据可用呢?答案是,很多!例如,深度学习的最流行应用:图像和视频中的对象分类和检测、语音转录、语言翻译和文本内容分析。Web 浏览器配备了可能是最全面的技术和 API,用于呈现(以及在用户许可的情况下捕获)文本、图像、音频和视频数据。因此,强大的机器学习模型可以直接在浏览器中使用,例如,使用 TensorFlow.js 和简单的转换过程。在本书的后几章中,我们将涵盖许多在浏览器中部署深度学习模型的具体示例。例如,一旦您从网络摄像头捕获了图像,您可以使用 TensorFlow.js 运行 MobileNet 对对象进行标记,运行 YOLO2 对检测到的对象放置边界框,运行 Lipnet 进行唇读,或者运行 CNN-LSTM 网络为图像应用标题。
一旦您使用浏览器的 WebAudio API 从麦克风捕获了音频,TensorFlow.js 就可以运行模型执行实时口语识别。文本数据也有令人兴奋的应用,例如为用户文本(如电影评论)分配情感分数(第九章)。除了这些数据模态,现代 Web 浏览器还可以访问移动设备上的一系列传感器。例如,HTML5 提供了对地理位置(纬度和经度)、运动(设备方向和加速度)和环境光(参见 mobilehtml5.org)的 API 访问。结合深度学习和其他数据模态,来自这些传感器的数据为许多令人兴奋的新应用打开了大门。
基于浏览器的深度学习应用具有五个额外的好处:降低服务器成本、减少推理延迟、数据隐私、即时 GPU 加速和即时访问:
-
服务器成本在设计和扩展 Web 服务时通常是一个重要考虑因素。及时运行深度学习模型所需的计算通常是相当大的,这就需要使用 GPU 加速。如果模型没有部署到客户端,它们就需要部署在支持 GPU 的机器上,比如来自 Google Cloud 或 Amazon Web Services 的具有 CUDA GPU 的虚拟机。这样的云 GPU 机器通常价格昂贵。即使是最基本的 GPU 机器目前也在每小时约$0.5–1 左右(见
www.ec2instances.info和cloud.google.com/gpu)。随着流量的增加,运行一系列云 GPU 机器的成本变得更高,更不用说可伸缩性的挑战以及服务器堆栈的复杂性。所有这些问题都可以通过将模型部署到客户端来消除。客户端下载模型的开销(通常是数兆字节或更多)可以通过浏览器的缓存和本地存储功能来减轻(第二章)。 -
降低推理延迟—对于某些类型的应用程序,延迟的要求非常严格,以至于深度学习模型必须在客户端上运行。任何涉及实时音频、图像和视频数据的应用程序都属于此类。考虑一下如果图像帧需要传输到服务器进行推理会发生什么。假设图像以每秒 10 帧的速率从摄像头捕获,大小适中为 400 × 400 像素,具有三个颜色通道(RGB)和每个颜色通道 8 位深度。即使使用 JPEG 压缩,每个图像的大小也约为 150 Kb。在具有约 300 Kbps 上传带宽的典型移动网络上,每个图像的上传可能需要超过 500 毫秒,导致一个可察觉且可能不可接受的延迟,对于某些应用程序(例如游戏)来说。这个计算没有考虑到网络连接的波动(和可能的丢失)、下载推理结果所需的额外时间以及大量的移动数据使用量,每一项都可能是一个停滞点。客户端推理通过在设备上保留数据和计算来解决这些潜在的延迟和连接性问题。在没有模型纯粹在客户端上运行的情况下,无法运行实时的机器学习应用程序,比如在网络摄像头图像中标记对象和检测姿势。即使对于没有延迟要求的应用程序,减少模型推理延迟也可以提高响应性,从而改善用户体验。
-
数据隐私——将训练和推断数据留在客户端的另一个好处是保护用户的隐私。数据隐私的话题在今天变得越来越重要。对于某些类型的应用程序,数据隐私是绝对必要的。与健康和医疗数据相关的应用程序是一个突出的例子。考虑一个“皮肤病诊断辅助”应用,它从用户的网络摄像头收集患者皮肤的图像,并使用深度学习生成皮肤状况的可能诊断。许多国家的健康信息隐私法规将不允许将图像传输到集中式服务器进行推断。通过在浏览器中运行模型推断,用户的数据永远不需要离开用户的手机或存储在任何地方,确保用户健康数据的隐私。再考虑另一个基于浏览器的应用程序,它使用深度学习为用户提供建议,以改善他们在应用程序中编写的文本。一些用户可能会使用此应用程序编写诸如法律文件之类的敏感内容,并且不希望数据通过公共互联网传输到远程服务器。在客户端浏览器 JavaScript 中纯粹运行模型是解决此问题的有效方法。
-
即时的 WebGL 加速 — 除了数据可用性外,将机器学习模型在 Web 浏览器中运行的另一个先决条件是通过 GPU 加速获得足够的计算能力。 正如前面提到的,许多最先进的深度学习模型在计算上是如此密集,以至于通过 GPU 上的并行计算加速是必不可少的(除非您愿意让用户等待几分钟才能获得单个推断结果,在真实应用中很少发生)。 幸运的是,现代 Web 浏览器配备了 WebGL API,尽管它最初是为了加速 2D 和 3D 图形的渲染而设计的,但可以巧妙地利用它来进行加速神经网络所需的并行计算。 TensorFlow.js 的作者们费尽心思地将基于 WebGL 的深度学习组件加速包装在库中,因此通过一行 JavaScript 导入即可为您提供加速功能。 基于 WebGL 的神经网络加速可能与本机的、量身定制的 GPU 加速(如 NVIDIA 的 CUDA 和 CuDNN,用于 Python 深度学习库,如 TensorFlow 和 PyTorch)不完全相匹配,但它仍然会大大加快神经网络的速度,并实现实时推断,例如 PoseNet 对人体姿势的提取。 如果对预训练模型进行推断是昂贵的,那么对这些模型进行训练或迁移学习的成本就更高了。 训练和迁移学习使得诸如个性化深度学习模型、前端可视化深度学习和联邦学习(在许多设备上训练相同的模型,然后聚合训练结果以获得良好的模型)等令人兴奋的应用成为可能。 TensorFlow.js 的 WebGL 加速使得在 Web 浏览器中纯粹进行训练或微调神经网络成为可能。
-
即时访问 — 一般来说,运行在浏览器中的应用程序具有“零安装”的天然优势:访问应用程序所需的全部步骤就是输入 URL 或单击链接。 这省去了任何可能繁琐和容易出错的安装步骤,以及在安装新软件时可能存在的风险访问控制。 在浏览器中进行深度学习的背景下,TensorFlow.js 提供的基于 WebGL 的神经网络加速不需要特殊类型的图形卡或为此类卡安装驱动程序,这通常是一个不平凡的过程。 大多数合理更新的台式机、笔记本电脑和移动设备都配备了供浏览器和 WebGL 使用的图形卡。 只要安装了与 TensorFlow.js 兼容的 Web 浏览器(门槛很低),这些设备就可以自动准备好运行 WebGL 加速的神经网络。 这在访问便利至关重要的地方尤为吸引人,例如深度学习的教育。
使用 GPU 和 WebGL 加速计算
训练机器学习模型并将其用于推断需要大量的数学运算。例如,广泛使用的“密集”神经网络层涉及将大矩阵与向量相乘,并将结果添加到另一个向量中。这种类型的典型操作涉及数千或数百万次浮点运算。关于这类操作的一个重要事实是它们通常是可并行化的。例如,将两个向量相加可以分解为许多较小的操作,比如将两个单独的数字相加。这些较小的操作不相互依赖。例如,您不需要知道两个向量在索引 0 处的两个元素的和来计算索引 1 处的两个元素的和。因此,这些较小的操作可以同时进行,而不是一个接一个地进行,无论向量有多大。串行计算,例如向量加法的简单 CPU 实现,被称为单指令单数据(SISD)。GPU 上的并行计算称为单指令多数据(SIMD)。通常,CPU 计算每个单独的加法所需的时间比 GPU 更少。但是,在这么大量的数据上的总成本导致 GPU 的 SIMD 胜过 CPU 的 SISD。深度神经网络可以包含数百万个参数。对于给定的输入,可能需要进行数十亿次逐元素数学运算(如果不是更多)。GPU 能够执行的大规模并行计算在这个规模下表现出色。
任务:逐个元素添加两个向量:

CPU 上的计算

GPU 上的计算

WebGL 加速如何利用 GPU 的并行计算能力来实现比 CPU 更快的向量操作
精确来说,现代 CPU 也能够执行一定级别的 SIMD 指令。但是,GPU 配备了更多的处理单元(数量在数百到数千之间),可以同时对输入数据的多个片段执行指令。向量加法是一个相对简单的 SIMD 任务,因为每个计算步骤只查看一个索引,并且不同索引处的结果彼此独立。在机器学习中看到的其他 SIMD 任务更复杂。例如,在矩阵乘法中,每个计算步骤使用多个索引处的数据,并且索引之间存在依赖关系。但是通过并行化加速的基本思想是相同的。
有趣的是,GPU 最初并不是为加速神经网络而设计的。这可以从名称中看出:图形处理单元。GPU 的主要用途是处理 2D 和 3D 图形。在许多图形应用中,例如 3D 游戏,至关重要的是要尽可能地快速处理,以便屏幕上的图像可以以足够高的帧率更新,以获得流畅的游戏体验。这是 GPU 的创建者利用 SIMD 并行化时的最初动机。但令人惊喜的是,GPU 能够进行的并行计算也正适合机器学习的需求。
用于 GPU 加速的 WebGL 库 TensorFlow.js 最初是为在网络浏览器中渲染 3D 对象上的纹理(表面图案)等任务而设计的。但是,纹理只是一组数字!因此,我们可以假装这些数字是神经网络的权重或激活,并重新利用 WebGL 的 SIMD 纹理操作来运行神经网络。这正是 TensorFlow.js 在浏览器中加速神经网络的方式。
除了我们描述的优势之外,基于网络的机器学习应用享受与不涉及机器学习的通用网络应用相同的好处:
-
与原生应用开发不同,使用 TensorFlow.js 编写的 JavaScript 应用程序将在许多设备系列上运行,从 Mac、Windows 和 Linux 桌面到 Android 和 iOS 设备。
-
凭借其优化的二维和三维图形能力,网络浏览器是数据可视化和交互性最丰富、最成熟的环境。在人们希望向人类展示神经网络的行为和内部机制时,很难想象有哪种环境能比得上浏览器。以 TensorFlow Playground 为例(
playground.tensorflow.org)。这是一个非常受欢迎的 Web 应用程序,您可以使用其中与神经网络交互式解决分类问题。您可以调整神经网络的结构和超参数,并观察其隐藏层和输出的变化(见图 1.6)。如果您之前还没有尝试过,我们强烈建议您试试。许多人表示,这是他们在神经网络主题上看到的最具有教育性和愉悦性的教育材料之一。事实上,TensorFlow Playground 实际上是 TensorFlow.js 的重要前身。作为 Playground 的衍生品,TensorFlow.js 具有更广泛的深度学习功能和更优化的性能。此外,它还配备了一个专门用于深度学习模型可视化的组件(在第七章中有详细介绍)。无论您是想构建基本的教育应用程序,还是以视觉上吸引人和直观的方式展示您的前沿深度学习研究,TensorFlow.js 都将帮助您在实现目标的道路上走得更远(参见诸如实时 tSNE 嵌入可视化的示例^([8]))。¹⁰
参见 Nicola Pezzotti,“使用 TensorFlow.js 进行实时 tSNE 可视化”,googblogs,
mng.bz/nvDg。
图 1.6. TensorFlow Playground 的屏幕截图(playground.tensorflow.org),这是一个由谷歌的 Daniel Smilkov 及其同事开发的受欢迎的基于浏览器的用户界面,用于教授神经网络的工作原理。TensorFlow Playground 也是后来 TensorFlow.js 项目的重要前身。

1.2.1. 使用 Node.js 进行深度学习
为了安全和性能考虑,Web 浏览器被设计为资源受限的环境,具有有限的内存和存储配额。这意味着,尽管浏览器对许多类型的推断、小规模训练和迁移学习任务(需要较少的资源)非常理想,但对于使用大量数据训练大型机器学习模型来说,并不是理想的环境。然而,Node.js 完全改变了这一方程。Node.js 使 JavaScript 能够在 Web 浏览器之外运行,从而赋予它对所有本机资源(如 RAM 和文件系统)的访问权限。TensorFlow.js 带有一个 Node.js 版本,称为tfjs-node。它直接绑定到从 C++和 CUDA 代码编译的本机 TensorFlow 库,并且使用户能够使用与 TensorFlow(在 Python 中)底层使用的并行化 CPU 和 GPU 操作内核相同的内核。正如可以通过实证显示的那样,在 tfjs-node 中模型训练的速度与 Python 中 Keras 的速度相当。因此,tfjs-node 是一个适合用于训练大型机器学习模型的环境。在本书中,您将看到一些示例,我们在其中使用 tfjs-node 来训练超出浏览器能力范围的大型模型(例如,第五章中的单词识别器和第九章中的文本情感分析器)。
但是,相对于更成熟的 Python 环境来训练机器学习模型,选择 Node.js 的可能原因是什么呢?答案是 1)性能和 2)与现有堆栈和开发人员技能集的兼容性。首先,在性能方面,如 Node.js 所使用的 V8 引擎等最新 JavaScript 解释器对 JavaScript 代码进行即时(JIT)编译,导致性能优于 Python。因此,只要模型足够小以至于语言解释器的性能成为决定因素,通常在 tfjs-node 中训练模型比在 Keras(Python)中快。
其次,Node.js 是构建服务器端应用程序的非常流行的环境。如果您的后端已经是用 Node.js 编写的,并且您想要向堆栈中添加机器学习,那么使用 tfjs-node 通常比使用 Python 更好。通过保持代码在一个语言中,您可以直接重用代码库的大部分代码,包括加载和格式化数据的那些部分。这将帮助您更快地设置模型训练管道。通过不向堆栈添加新语言,您还可以降低其复杂性和维护成本,可能节省雇佣 Python 程序员的时间和成本。
最后,用 TensorFlow.js 编写的机器学习代码将在浏览器环境和 Node.js 中都能工作,只有在依赖于仅限于浏览器或仅限于 Node 的 API 的数据相关代码可能会有例外。您在本书中遇到的大多数代码示例都将在这两种环境中工作。我们努力将代码中与环境无关且以机器学习为中心的部分与与环境相关的数据摄取和 UI 代码分开。额外的好处是您只需学习一个库,就能在服务器和客户端都进行深度学习。
1.2.2. JavaScript 生态系统
当评估 JavaScript 在某种类型的应用程序(如深度学习)中的适用性时,我们不应忽视 JavaScript 是一种具有异常强大生态系统的语言的因素。多年来,JavaScript 在 GitHub 上的存储库数量和拉取活动方面一直稳居数十种编程语言中的第一位(参见 githut.info)。在 npm 上,JavaScript 包的事实上公共存储库,截至 2018 年 7 月,已经有超过 600,000 个包。这个数字是 PyPI(Python 包的事实上公共存储库)的包数量的四倍以上(参见 www.modulecounts.com)。尽管 Python 和 R 在机器学习和数据科学领域拥有更成熟的社区,但 JavaScript 社区也在建立机器学习相关的数据流水线支持。
想要从云存储和数据库获取数据吗?谷歌云和亚马逊 Web 服务都提供 Node.js API。如今最流行的数据库系统,如 MongoDB 和 RethinkDB,都对 Node.js 驱动程序提供了一流的支持。想要在 JavaScript 中整理数据吗?我们推荐 Ashley Davis 的《使用 JavaScript 进行数据整理》一书(Manning Publications,2018 年,www.manning.com/books/data-wrangling-with-javascript)。想要可视化您的数据吗?有成熟和强大的库,如 d3.js、vega.js 和 plotly.js,在许多方面超越了 Python 可视化库。一旦您准备好输入数据,本书的主要内容 TensorFlow.js 将接手处理,并帮助您创建、训练和执行深度学习模型,以及保存、加载和可视化它们。
最后,JavaScript 生态系统仍在以令人振奋的方式不断发展。它的影响力正在从其传统的强项——即 Web 浏览器和 Node.js 后端环境——扩展到新的领域,例如桌面应用程序(例如 Electron)和本地移动应用程序(例如 React Native 和 Ionic)。对于这样的框架编写 UI 和应用程序通常比使用各种平台特定的应用程序创建工具更容易。JavaScript 是一种具有将深度学习的力量带到所有主要平台的潜力的语言。我们在 table 1.2 中总结了将 JavaScript 和深度学习结合使用的主要优点。
表 1.2. 在 JavaScript 中进行深度学习的利益的简要总结
| 考虑因素 | 示例 |
|---|---|
| 与客户端相关的原因 |
-
由于数据局部性而降低推理和训练延迟
-
在客户端脱机时运行模型的能力
-
隐私保护(数据永远不会离开浏览器)
-
降低服务器成本
-
简化的部署栈
|
| 与 Web 浏览器相关的原因 |
|---|
-
可用于推理和训练的多种数据形式(HTML5 视频、音频和传感器 API)
-
零安装用户体验
-
在广泛范围的 GPU 上通过 WebGL API 进行并行计算的零安装访问
-
跨平台支持
-
理想的可视化和交互环境
-
固有互联环境开启对各种机器学习数据和资源的直接访问
|
| 与 JavaScript 相关的原因 |
|---|
-
JavaScript 是许多衡量标准中最受欢迎的开源编程语言,因此有大量的 JavaScript 人才和热情。
-
JavaScript 在客户端和服务器端都有着丰富的生态系统和广泛的应用。
-
Node.js 允许应用程序在服务器端运行,而不受浏览器资源约束。
-
V8 引擎使 JavaScript 代码运行速度快。
|
1.3. 为什么选择 TensorFlow.js?
要在 JavaScript 中进行深度学习,您需要选择一个库。TensorFlow.js 是我们这本书的选择。在本节中,我们将描述 TensorFlow.js 是什么以及我们选择它的原因。
1.3.1. TensorFlow、Keras 和 TensorFlow.js 的简要历史
TensorFlow.js 是一个使您能够在 JavaScript 中进行深度学习的库。顾名思义,TensorFlow.js 旨在与 Python 深度学习框架 TensorFlow 保持一致和兼容。要了解 TensorFlow.js,我们需要简要介绍 TensorFlow 的历史。
TensorFlow 是由谷歌的深度学习团队于 2015 年 11 月开源的。本书的作者是该团队的成员。自从它开源以来,TensorFlow 受到了极大的欢迎。它现在被广泛应用于谷歌和更大的技术社区中的各种工业应用和研究项目中。名称“TensorFlow”是为了反映使用该框架编写的典型程序内部发生的情况:数据表示称为tensors(张量)在层和其他数据处理节点之间流动,允许对机器学习模型进行推理和训练。
首先,什么是张量?这只是计算机科学家简明扼要地说“多维数组”的方式。在神经网络和深度学习中,每个数据和每个计算结果都表示为一个张量。例如,灰度图像可以表示为一个数字的 2D 数组——一个 2D 张量;彩色图像通常表示为一个 3D 张量,其中额外的维度是颜色通道。声音、视频、文本和任何其他类型的数据都可以表示为张量。每个张量具有两个基本属性:数据类型(例如 float32 或 int32)和形状。形状描述了张量沿着所有维度的大小。例如,一个 2D 张量可能具有形状[128, 256],而一个 3D 张量可能具有形状[10, 20, 128]。一旦数据被转换为给定数据类型和形状的张量,它就可以被馈送到接受该数据类型和形状的任何类型的层中,而不管数据的原始含义是什么。因此,张量是深度学习模型的通用语言。
但是为什么使用张量?在前一节中,我们了解到在运行深度神经网络中涉及的大部分计算是作为大规模并行化操作执行的,通常在 GPU 上进行,这需要对多个数据块执行相同的计算。张量是将我们的数据组织成可以在并行中高效处理的结构的容器。当我们将形状为[128, 128]的张量 A 加到形状为[128, 128]的张量 B 时,非常清楚需要进行128 * 128次独立的加法运算。
那么“流”部分呢?想象一下张量就像一种携带数据的流体。在 TensorFlow 中,它通过一个图流动——一个由相互连接的数学操作(称为节点)组成的数据结构。如图 1.7 所示,节点可以是神经网络中的连续层。每个节点将张量作为输入并产生张量作为输出。随着“张量流体”通过 TensorFlow 图“流动”,它会被转换成不同的形状和不同的值。这对应于表示的转换:也就是我们在前面的章节中描述的神经网络的要点。使用 TensorFlow,机器学习工程师可以编写各种各样的神经网络,从浅层到非常深层的网络,从用于计算机视觉的卷积网络到用于序列任务的循环神经网络(RNN)。图数据结构可以被序列化并部署到运行许多类型设备上,从大型机到手机。
图 1.7. 张量“流动”通过多个层,这在 TensorFlow 和 TensorFlow.js 中是一个常见情景。

TensorFlow 的核心设计是非常通用和灵活的:操作可以是任何明确定义的数学函数,不仅仅是神经网络层。例如,它们可以是低级数学操作,比如将两个张量相加和相乘——这种操作发生在神经网络层的内部。这使得深度学习工程师和研究人员能够为深度学习定义任意和新颖的操作。然而,对于大部分深度学习从业者来说,操作这样的低级机制比它们值得的麻烦更多。它会导致冗长和更容易出错的代码以及更长的开发周期。大多数深度学习工程师使用少数固定的层类型(例如,卷积、池化或密集层,你将在后面的章节中详细学习)。他们很少需要创建新的层类型。这就是乐高积木的类比适用的地方。使用乐高,只有少数几种积木类型。乐高建筑师不需要考虑制作一块乐高积木需要什么。这与像 Play-Doh 这样的玩具不同,它类似于 TensorFlow 的低级 API。然而,连接乐高积木的能力导致了组合成千上万种可能性和几乎无限的力量。可以用乐高或 Play-Doh 建造一个玩具房子,但除非你对房子的大小、形状、质地或材料有非常特殊的要求,否则用乐高建造房子会更容易更快。对于大多数人来说,我们建造的乐高房子将更加稳固,看起来更漂亮,而不是用 Play-Doh 建造的房子。
在 TensorFlow 的世界中,高级 API 被称为 Keras 是对应的乐高积木^([9])。Keras 提供了一组最常用的神经网络层类型,每个层都有可配置的参数。它还允许用户将层连接在一起形成神经网络。此外,Keras 还提供了以下 API:
⁹
实际上,自从引入 TensorFlow 以来,出现了许多高级 API,一些是由谷歌工程师创建的,一些是由开源社区创建的。其中最受欢迎的是 Keras、tf.Estimator、tf.contrib.slim 和 TensorLayers 等。对于本书的读者来说,与 TensorFlow.js 最相关的高级 API 无疑是 Keras,因为 TensorFlow.js 的高级 API 是基于 Keras 建模的,并且 TensorFlow.js 在模型保存和加载方面提供了双向兼容性。
-
指定神经网络的训练方式(损失函数、度量指标和优化器)
-
提供数据用于训练或评估神经网络,或使用模型进行推断
-
监控正在进行的训练过程(回调函数)
-
保存和加载模型
-
打印或绘制模型的架构
在 Keras 中,用户只需使用很少的代码即可执行完整的深度学习工作流程。低级 API 的灵活性和高级 API 的易用性使得 TensorFlow 和 Keras 在工业和学术领域的应用方面领先于其他深度学习框架(请参阅mng.bz/vlDJ上的推文)。作为不断推进的深度学习革命的一部分,不应低估它们让更广泛的人群获得深度学习的作用。在 TensorFlow 和 Keras 等框架出现之前,只有那些具有 CUDA 编程技能并且在 C++中有编写神经网络的丰富经验的人能够进行实际的深度学习。通过 TensorFlow 和 Keras,创建基于 GPU 加速的深度神经网络所需的技能和工作量大大减少。但是有一个问题:无法在 JavaScript 或直接在 Web 浏览器中运行 TensorFlow 或 Keras 模型。为了在浏览器中提供经过训练的深度学习模型,我们必须通过 HTTP 请求到后端服务器进行操作。这就是 TensorFlow.js 的用武之地。TensorFlow.js 是由 Google 的深度学习相关数据可视化和人机交互专家 Nikhil Thorat 和 Daniel Smilkov 发起的努力^([10])。正如我们所提到的,深度神经网络的高度流行的 TensorFlow Playground 演示植入了 TensorFlow.js 项目的最初种子。2017 年 9 月,发布了一个名为 deeplearn.js 的库,它具有类似于 TensorFlow 低级 API 的低级 API。 Deeplearn.js 支持 WebGL 加速的神经网络操作,使得在 Web 浏览器中以低延迟运行真实的神经网络成为可能。
¹⁰
作为一个有趣的历史注释,这些作者还在创建 TensorBoard(TensorFlow 模型的流行可视化工具)方面发挥了关键作用。
在 deeplearn.js 初始成功后,谷歌 Brain 团队的更多成员加入了该项目,并将其更名为 TensorFlow.js。JavaScript API 经过了重大改进,提高了与 TensorFlow 的 API 兼容性。此外,在底层核心之上构建了一个类似 Keras 的高级 API,使用户更容易在 JavaScript 库中定义、训练和运行深度学习模型。今天,我们对于 Keras 的力量和易用性所说的一切对于 TensorFlow.js 也完全适用。为了进一步提高互操作性,构建了转换器,使 TensorFlow.js 可以导入从 TensorFlow 和 Keras 中保存的模型,并将模型导出为它们所用的格式。自从在 2018 年春季全球 TensorFlow 开发者峰会和 Google I/O 上首次亮相以来(参见www.youtube.com/watch?v=YB-kfeNIPCE 和 www.youtube.com/watch?v=OmofOvMApTU),TensorFlow.js 快速成为了一个非常受欢迎的 JavaScript 深度学习库,在 GitHub 上类似的库中当前拥有最高的赞数和派生数。
图 1.8 展示了 TensorFlow.js 的架构概述。最底层负责并行计算,用于快速数学运算。尽管大多数用户看不到此层,但它的高性能非常重要,以便在 API 的更高层级中进行模型训练和推断尽可能地快速。在浏览器中,它利用 WebGL 实现 GPU 加速(参见信息框 1.2)。在 Node.js 上,可以直接绑定到多核 CPU 并行化和 CUDA GPU 加速。这些是 TensorFlow 和 Keras 在 Python 中使用的相同的数学后端。在最低的数学层级之上建立了 Ops API,它与 TensorFlow 的低级 API 具有良好的对应性,并支持从 TensorFlow 加载 SavedModels。在最高的层级上是类似 Keras 的 Layers API。对于使用 TensorFlow.js 的大多数程序员来说,Layers API 是正确的 API 选择,也是本书的主要关注点。Layers API 还支持与 Keras 的双向模型导入/导出。
图 1.8. TensorFlow.js 的架构一览。它与 Python TensorFlow 和 Keras 的关系也显示出来。

1.3.2. 为什么选择 TensorFlow.js: 与类似库的简要比较
TensorFlow.js 并不是唯一一个用于深度学习的 JavaScript 库;也不是第一个出现的(例如,brain.js 和 ConvNetJS 的历史要长得多)。那么,为什么 TensorFlow.js 在类似的库中脱颖而出呢?第一个原因是它的全面性——TensorFlow.js 是目前唯一一个支持生产深度学习工作流中所有关键部分的库:
-
支持推断和训练
-
支持 Web 浏览器和 Node.js。
-
利用 GPU 加速(浏览器中的 WebGL 和 Node.js 中的 CUDA 核心)。
-
支持在 JavaScript 中定义神经网络模型架构。
-
支持模型的序列化和反序列化。
-
支持与 Python 深度学习框架之间的转换。
-
与 Python 深度学习框架的 API 兼容。
-
配备了内置的数据摄取支持,并提供了可视化 API。
第二个原因是生态系统。大多数 JavaScript 深度学习库定义了自己独特的 API,而 TensorFlow.js 与 TensorFlow 和 Keras 紧密集成。你有一个来自 Python TensorFlow 或 Keras 的训练模型,想在浏览器中使用它?没问题。你在浏览器中创建了一个 TensorFlow.js 模型,想将其带入 Keras 以获得更快的加速器,如 Google TPU?也可以!与非 JavaScript 框架的紧密集成不仅提升了互操作性,还使开发人员更容易在编程语言和基础设施堆栈之间迁移。例如,一旦你通过阅读本书掌握了 TensorFlow.js,如果想开始使用 Python 中的 Keras,将会非常顺利。反向旅程同样轻松:掌握 Keras 的人应该能够快速学会 TensorFlow.js(假设具备足够的 JavaScript 技能)。最后但同样重要的是,不应忽视 TensorFlow.js 的流行度和其社区的实力。TensorFlow.js 的开发人员致力于长期维护和支持该库。从 GitHub 的星星和分叉数量到外部贡献者的数量,从讨论的活跃程度到在 Stack Overflow 上的问题和答案的数量,TensorFlow.js 无愧于任何竞争库的阴影。
1.3.3. TensorFlow.js 在世界上是如何被使用的?
对于一个库的力量和流行程度,最有说服力的证明莫过于它在真实应用中的使用方式。TensorFlow.js 的几个值得注意的应用包括以下内容:
-
Google 的 Project Magenta 使用 TensorFlow.js 运行 RNN 和其他类型的深度神经网络,在浏览器中生成音乐乐谱和新颖的乐器声音(
magenta.tensorflow.org/demos/)。 -
丹·希夫曼(Dan Shiffman)和他在纽约大学的同事们构建了 ML5.js,这是一个易于使用的、针对浏览器的各种开箱即用的深度学习模型的高级 API,例如目标检测和图像风格转换(
ml5js.org)。 -
开源开发者 Abhishek Singh 创建了一个基于浏览器的界面,将美国手语翻译成语音,以帮助不能说话或听力受损的人使用智能扬声器,如亚马逊 Echo。^([11])
¹¹
Abhishek Singh,“使用你的网络摄像头和 TensorFlow.js 让 Alexa 响应手语”,Medium,2018 年 8 月 8 日,
mng.bz/4eEa。 -
Canvas Friends 是基于 TensorFlow.js 的类似游戏的网络应用程序,帮助用户提高其绘画和艺术技巧 (www.y8.com/games/canvas_friends)。
-
MetaCar,一个在浏览器中运行的自动驾驶汽车模拟器,使用 TensorFlow.js 实现了对其模拟至关重要的强化学习算法 (www.metacar-project.com)。
-
诊所医生,一个基于 Node.js 的应用程序,用于监视服务器端程序的性能,使用 TensorFlow.js 实现了隐马尔可夫模型,并使用它来检测 CPU 使用率的峰值。^([12])
¹²
Andreas Madsen,“Clinic.js Doctor Just Got More Advanced with TensorFlow.js,” Clinic.js 博客,2018 年 8 月 22 日,
mng.bz/Q06w。 -
查看 TensorFlow.js 的优秀应用程序库,由开源社区构建,地址为
github.com/tensorflow/tfjs/blob/master/GALLERY.md。
1.3.4. 本书将教授和不会教授你关于 TensorFlow.js 的内容
通过学习本书中的材料,您应该能够使用 TensorFlow.js 构建如下应用程序:
-
一个能够对用户上传的图像进行分类的网站
-
深度神经网络,从浏览器连接的传感器接收图像和音频数据,并在其上执行实时机器学习任务,例如识别和迁移学习
-
客户端自然语言人工智能,如评论情感分类器,可辅助评论审核
-
一个使用千兆字节级别数据和 GPU 加速的 Node.js(后端)机器学习模型训练器
-
一个由 TensorFlow.js 提供支持的强化学习器,可以解决小规模控制和游戏问题
-
一个仪表板,用于说明经过训练的模型的内部情况和机器学习实验的结果
更重要的是,您不仅会知道如何构建和运行这些应用程序,还将了解它们的工作原理。例如,您将具有创建各种类型问题的深度学习模型所涉及的策略和约束的实际知识,以及训练和部署这些模型的步骤和技巧。
机器学习是一个广泛的领域;TensorFlow.js 是一个多才多艺的库。因此,一些应用程序完全可以使用现有的 TensorFlow.js 技术来完成,但超出了本书的范围。例如:
-
在 Node.js 环境中高性能、分布式训练深度神经网络,涉及大量数据(数量级为千兆字节)
-
非神经网络技术,例如 SVM、决策树和随机森林
-
高级深度学习应用程序,如将大型文档缩减为几个代表性句子的文本摘要引擎,从输入图像生成文本摘要的图像到文本引擎,以及增强输入图像分辨率的生成图像模型
然而,这本书将为您提供深度学习的基础知识,使您能够学习与这些高级应用相关的代码和文章。
就像任何其他技术一样,TensorFlow.js 也有其局限性。有些任务超出了它的能力范围。尽管这些限制可能在将来被推动,但了解编写时的边界是很好的:
-
在浏览器标签页中运行内存需求超出 RAM 和 WebGL 限制的深度学习模型。对于浏览器内推断,通常意味着模型总重量超过 ~100 MB。对于训练,需要更多的内存和计算资源,因此即使是较小的模型在浏览器标签页中进行训练也可能太慢了。模型训练通常还涉及比推断更大量的数据,这是评估浏览器内训练可行性时应考虑的另一个限制因素。
-
创建一个高端的强化学习器,例如能够击败人类玩家的围棋游戏。
-
使用 Node.js 进行分布式(多机器)设置来训练深度学习模型。
练习
-
无论您是前端 JavaScript 开发人员还是 Node.js 开发人员,根据本章学到的知识,思考一下在您正在开发的系统中应用机器学习以使其更加智能的几种可能情况。可参考 表 1.1 和 1.2,以及 第 1.3.3 节。一些进一步的示例包括:
-
一个出售眼镜等配件的时尚网站使用网络摄像头捕获用户面部图像,并使用运行在 TensorFlow.js 上的深度神经网络检测面部标记点。然后利用检测到的标记点,在用户面部叠加太阳镜的图像,以在网页中模拟试戴体验。由于客户端推断可实现低延迟和高帧率运行,因此体验效果非常逼真。用户的数据隐私得到尊重,因为捕获的面部图像永远不会离开浏览器。
-
一个用 React Native(一个用于创建原生移动应用的跨平台 JavaScript 库)编写的移动体育应用程序跟踪用户的运动。使用 HTML5 API,该应用程序从手机的陀螺仪和加速度计获取实时数据。然后将数据传递给由 TensorFlow.js 驱动的模型,该模型自动检测用户当前的活动类型(例如,休息、步行、慢跑或疾跑)。
-
一个浏览器扩展程序会自动检测设备使用者是儿童还是成年人(通过每 5 秒一次的摄像头捕获的图像和由 TensorFlow.js 驱动的计算机视觉模型),并根据这些信息来阻止或允许访问特定网站。
-
基于浏览器的编程环境使用 TensorFlow.js 实现的循环神经网络来检测代码注释中的拼写错误。
-
基于 Node.js 的服务器端应用,使用实时信号如航空公司状态、货物类型和数量、日期/时间和交通信息等来预测每个交易的预计到达时间(ETA)。所有的训练和推理流水线均使用 TensorFlow.js 在 Node.js 中编写,从而简化了服务器堆栈。
-
总结
-
AI 是自动化认知任务的研究。机器学习是 AI 的一个子领域,其中通过学习训练数据中的示例来自动发现执行任务(如图像分类)的规则。
-
机器学习中的一个核心问题是如何将原始数据的表示转换为更适合解决任务的表示。
-
神经网络是机器学习中的一种方法,通过数学运算的不断迭代(或层级)来转换数据表示。深度学习领域则是关注于深度神经网络,即具有多层的神经网络。
-
由于硬件的增强、标记数据的可用性和算法的进步,自 2010 年代初以来,深度学习领域取得了惊人的进展,解决了以前无法解决的问题,创造了令人兴奋的新机会。
-
JavaScript 和 Web 浏览器是部署和训练深度神经网络的适宜环境。
-
本书的重点 TensorFlow.js 是一款全面、多功能和强大的 JavaScript 深度学习开源库。
第二部分:TensorFlow.js 的简介。
在覆盖了基础知识之后,在本书的这一部分,我们将以实践的方式深入探讨机器学习,使用 TensorFlow.js 作为工具。我们从第二章开始,进行一个简单的机器学习任务——回归(预测单个数字)——然后逐步向更复杂的任务发展,如第三章和第四章中的二元分类和多类分类。随着任务类型的不同,你还将看到从简单数据(一维数字数组)到更复杂数据(图像和声音)的渐进过程。我们将介绍一些方法的数学基础,比如反向传播,以及解决这些问题的代码。我们将摒弃正式的数学表达,而采用更直观的解释、图表和伪代码。
第二章:入门:TensorFlow.js 中的简单线性回归
本章内容
-
一个简单的神经网络的最小示例,用于线性回归这一简单的机器学习任务
-
张量和张量操作
-
基本神经网络优化
没有人喜欢等待,特别是当我们不知道要等多久时,等待就会变得非常烦人。任何用户体验设计师都会告诉你,如果无法隐藏延迟,那么下一个最好的办法就是给用户一个可靠的等待时间估计。估计预期延迟是一个预测问题,而 TensorFlow.js 库可以用于构建一个敏感于上下文和用户的准确下载时间预测,从而使我们能够构建清晰、可靠的体验,尊重用户的时间和注意力。
在本章中,以一个简单的下载时间预测问题作为我们的示例,我们将介绍完整机器学习模型的主要组成部分。我们将从实际角度介绍张量、建模和优化,以便你能够建立对它们是什么、如何工作以及如何适当使用它们的直觉。
深度学习内部的完全理解——这是一个专注研究者通过多年学习构建的类型——需要熟悉许多数学学科。然而,对于深度学习从业者来说,熟练掌握线性代数、微分计算和高维空间的统计学是有帮助的,但并非必需,即使要构建复杂、高性能的系统也是如此。我们在本章和整本书中的目标是根据需要介绍技术主题——尽可能使用代码,而不是数学符号。我们的目标是传达对机器的直觉理解及其目的,而不需要领域专业知识。
2.1. 示例 1:使用 TensorFlow.js 预测下载持续时间
让我们开始吧!我们将构建一个最小的神经网络,使用 TensorFlow.js 库(有时缩写为 tfjs)来预测给定下载大小的下载时间。除非你已经有 TensorFlow.js 或类似库的经验,否则你不会立即理解这个第一个示例的所有内容,但没关系。这里介绍的每个主题都将在接下来的章节中详细介绍,所以如果有些部分对你来说看起来是随意的或神奇的,不要担心!我们必须要从某个地方开始。我们将从编写一个接受文件大小作为输入并输出预测的文件下载时间的简短程序开始。
2.1.1. 项目概述:持续时间预测
当你第一次学习机器学习系统时,可能会因为各种新概念和术语而感到害怕。因此,先看一下整个工作流程是很有帮助的。这个示例的总体概述如 图 2.1 所示,并且这是我们在本书中将会反复看到的一种模式。
图 2.1. 下载时间预测系统的主要步骤概述,我们的第一个例子

首先,我们将访问我们的训练数据。在机器学习中,数据可以从磁盘中读取,通过网络下载,生成,或者简单地硬编码。在本例中,我们采用了最后一种方法,因为它很方便,并且我们只处理了少量数据。其次,我们将把数据转换成张量,以便将其馈送到我们的模型中。下一步是创建一个模型,就像我们在第一章中看到的那样,这类似于设计一个适当的可训练函数:一个将输入数据映射到我们试图预测的事物的函数。在这种情况下,输入数据和预测目标都是数字。一旦我们的模型和数据可用,我们将训练模型,监视其报告的指标。最后,我们将使用训练好的模型对我们尚未见过的数据进行预测,并测量模型的准确性。
我们将通过每个阶段的可复制粘贴的可运行代码片段以及对理论和工具的解释来进行。
2.1.2. 有关代码清单和控制台交互的注意事项
本书中的代码将以两种格式呈现。第一种格式是代码清单,展示了您将在引用的代码仓库中找到的结构化代码。每个清单都有一个标题和一个编号。例如,清单 2.1 包含了一个非常简短的 HTML 片段,您可以将其逐字复制到一个文件中,例如 /tmp/tmp.html,在您的计算机上然后在您的 Web 浏览器中打开文件:///tmp/tmp.html,尽管它本身不会做太多事情。
第二种代码格式是控制台交互。这些更为非正式的代码块旨在传达在 JavaScript REPL(交互式解释器或 shell)^([1]) 中的示例交互,例如浏览器的 JavaScript 控制台(在 Chrome 中是 Cmd-Opt-J、Ctrl+Shift+J 或 F12,但您的浏览器/操作系统可能会有所不同)。控制台交互以前导的大于号开头,就像我们在 Chrome 或 Firefox 中看到的那样,并且它们的输出与控制台中的一样,呈现在下一行。例如,以下交互创建一个数组并打印其值。您在 JavaScript 控制台中看到的输出可能略有不同,但要点应该是相同的:
¹
Read-eval-print-loop,也称为交互式解释器或 shell。REPL 允许我们与我们的代码进行积极的交互,以查询变量和测试函数。
> let a = ['hello', 'world', 2 * 1009]
> a;
(3) ["hello", "world", 2018]
在本书中测试、运行和学习代码列表的最佳方式是克隆引用的存储库,然后与其一起玩耍。在本书的开发过程中,我们经常使用 CodePen 作为一个简单、交互式、可共享的存储库(codepen.io)。例如,列表 2.1 可供你在 codepen.io/tfjs-book/pen/VEVMbx 上玩耍。当你导航到 CodePen 时,它应该会自动运行。你应该能够看到输出打印到控制台。点击左下角的 Console 打开控制台。如果 CodePen 没有自动运行,请尝试进行一个小的、无关紧要的更改,例如在末尾添加一个空格,以启动它。
本节的列表可在此 CodePen 集合中找到:codepen.io/collection/Xzwavm/。在只有单个 JavaScript 文件的情况下,CodePen 的效果很好,但我们更大更结构化的示例保存在 GitHub 存储库中,你将在后面的示例中看到。对于这个示例,我们建议你先阅读本节,然后再玩一玩相关的 CodePen。
2.1.3. 创建和格式化数据
让我们估计一下在一台机器上下载一个文件需要多长时间,只给出其大小(以 MB 为单位)。我们首先使用一个预先创建的数据集,但如果你有动力的话,你可以创建一个类似的数据集,模拟你自己系统的网络统计信息。
列表 2.1. 硬编码训练和测试数据(来自 CodePen 2-a)
<script src='https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest'></script>
<script>
const trainData = {
sizeMB: [0.080, 9.000, 0.001, 0.100, 8.000,
5.000, 0.100, 6.000, 0.050, 0.500,
0.002, 2.000, 0.005, 10.00, 0.010,
7.000, 6.000, 5.000, 1.000, 1.000],
timeSec: [0.135, 0.739, 0.067, 0.126, 0.646,
0.435, 0.069, 0.497, 0.068, 0.116,
0.070, 0.289, 0.076, 0.744, 0.083,
0.560, 0.480, 0.399, 0.153, 0.149]
};
const testData = {
sizeMB: [5.000, 0.200, 0.001, 9.000, 0.002,
0.020, 0.008, 4.000, 0.001, 1.000,
0.005, 0.080, 0.800, 0.200, 0.050,
7.000, 0.005, 0.002, 8.000, 0.008],
timeSec: [0.425, 0.098, 0.052, 0.686, 0.066,
0.078, 0.070, 0.375, 0.058, 0.136,
0.052, 0.063, 0.183, 0.087, 0.066,
0.558, 0.066, 0.068, 0.610, 0.057]
};
</script>
在上述 HTML 代码列表中,我们选择了显式包含 <script> 标签,演示了如何使用 @latest 后缀加载最新版本的 TensorFlow.js 库(在撰写本文时,此代码与 tfjs 0.13.5 兼容)。我们将在后面详细介绍不同的方式将 TensorFlow.js 导入到你的应用程序中,但在以后的过程中,我们将假定 <script> 标签已经包含在内。第一个脚本加载 TensorFlow 包并定义了符号 tf,它提供了一种引用 TensorFlow 中名称的方式。例如,tf.add() 指的是 TensorFlow 加法操作,用于将两个张量相加。在以后的过程中,我们将假设 tf 符号已经加载并在全局命名空间中可用,例如,通过之前的方式引用 TensorFlow.js 脚本。
列表 2.1 创建了两个常量,trainData和testData,分别表示下载文件所需的时间(timeSec)和文件大小(sizeMB)的 20 个样本。sizeMB中的元素与timeSec中的元素一一对应。例如,在trainData中,sizeMB的第一个元素为 0.080 MB,并且下载该文件所需时间为 0.135 秒,即 timeSec的第一个元素,依此类推。在这个示例中,我们通过在代码中直接编写数据来创建数据。这种方法在这个简单的示例中是可行的,但是当数据集的大小增长时,它很快就会变得难以管理。未来的示例将演示如何从外部存储或网络上的流数据。
回到数据上。根据图 2.2 中的绘图,我们可以看到文件大小和下载时间之间存在着可预测但并不完美的关系。现实生活中的数据是嘈杂的,但看起来我们应该能够对文件大小给出一个相当好的线性估计值。根据视觉判断,当文件大小为零时,持续时间应该约为 0.1 秒,然后每增加 1MB,持续时间大约增加 0.07 秒。请回忆起第一章中提到的,每个输入-输出对有时被称为样本。输出通常被称为目标,而输入的元素通常被称为特征。在我们的例子中,我们的 40 个样本中每个样本恰好有一个特征sizeMB和一个数值目标timeSec。
图 2.2. 下载持续时间与文件大小的测量数据。如果您对如何创建类似的绘图感兴趣,可以参考 CodePen 上的代码codepen.io/tfjs-book/pen/dgQVze。

在列表 2.1 中,您可能已经注意到数据被分为两个子集,即trainData和testData。trainData是训练集,它包含了模型将会在上面进行训练的样本。testData是测试集,我们将使用它来判断模型在训练完成后的效果如何。如果我们使用完全相同的数据进行训练和评估,那就像是在已经看到答案之后进行考试。在最极端的情况下,模型可以从训练数据中理论上记住每个sizeMB对应的timeSec 值,这不是一个很好的学习算法。结果可能不是对未来性能的很好评估,因为未来输入特征的值很可能与模型进行训练时的值完全相同。
因此,工作流程如下。首先,我们将在训练数据上拟合神经网络,以便准确预测timeSec给定sizeMB。然后,我们将要求网络使用测试数据为sizeMB生成预测,并测量这些预测与timeSec的接近程度。但首先,我们必须将此数据转换为 TensorFlow.js 能够理解的格式,这将是我们对张量的第一个示例用法。代码清单 2.2 中的代码展示了在本书中你将看到的tf.*命名空间下的函数的第一个用法。在这里,我们看到了将存储在原始 JavaScript 数据结构中的数据转换为张量的方法。
尽管使用方法非常简单明了,但那些希望在这些 API 中获得更牢固基础的读者应该阅读附录 B,其中不仅涵盖了诸如tf.tensor2d()之类的张量创建函数,还涉及执行操作转换和合并张量的函数,以及常见的真实世界数据类型(如图像和视频)如何被惯例地打包成张量的模式。我们在主要文本中没有深入研究底层 API,因为这些材料有些枯燥,并且与具体的示例问题无关。
代码清单 2.2. 将数据转换为张量(来自 CodePen 2-b)
const trainTensors = {
sizeMB: tf.tensor2d(trainData.sizeMB, [20, 1]), ***1***
timeSec: tf.tensor2d(trainData.timeSec, [20, 1])
};
const testTensors = {
sizeMB: tf.tensor2d(testData.sizeMB, [20, 1]),
timeSec: tf.tensor2d(testData.timeSec, [20, 1])
};
- 1 这里的[20, 1]是张量的“形状”。稍后会有更多解释,但在这里这个形状意味着我们希望将数字列表解释为 20 个样本,每个样本是 1 个数字。如果形状从数据数组的结构中明显,则可以省略此参数。
一般来说,所有当前的机器学习系统都使用张量作为它们的基本数据结构。张量对于该领域是至关重要的——以至于 TensorFlow 和 TensorFlow.js 都以它们命名。从第一章快速提醒:在其核心,张量是数据的容器——几乎总是数字数据。因此,它可以被认为是数字的容器。你可能已经熟悉向量和矩阵,它们分别是 1D 和 2D 张量。张量是矩阵向任意维度的泛化。张量的维数和每个维度的大小称为张量的形状。例如,一个 3 × 4 矩阵是一个形状为[3, 4]的张量。长度为 10 的向量是一个形状为[10]的 1D 张量。
在张量的上下文中,维度通常被称为轴。在 TensorFlow.js 中,张量是让组件之间通信和协同工作的常见表示,无论是在 CPU、GPU 还是其他硬件上。随着需求的出现,我们将对张量及其常见用例有更多介绍,但现在,让我们继续进行我们的预测项目。
2.1.4. 定义一个简单的模型
在深度学习的上下文中,从输入特征到目标的函数称为模型。模型函数接受特征,运行计算,并产生预测。我们正在构建的模型是一个接受文件大小作为输入并输出持续时间的函数(参见图 2.2)。在深度学习术语中,有时我们将网络用作模型的同义词。我们的第一个模型将是线性回归的实现。
回归,在机器学习的上下文中,意味着模型将输出实值,并尝试匹配训练目标;这与分类相反,后者输出来自一组选项的选择。在回归任务中,模型输出的数字越接近目标越好。如果我们的模型预测一个 1 MB 文件大约需要 0.15 秒,那就比预测一个 1 MB 文件需要约 600 秒要好(正如我们从图 2.2 中看到的)。
线性回归是一种特定类型的回归,其中输出作为输入的函数可以被表示为一条直线(或者类比为在存在多个输入特征时的高维空间中的一个平面)。模型的一个重要特性是它们是可调的。这意味着输入-输出计算可以被调整。我们利用这个特性来调整模型以更好地“拟合”数据。在线性情况下,模型的输入-输出关系总是一条直线,但我们可以调整斜率和 y 截距。
让我们构建我们的第一个网络来感受一下。
代码清单 2.3 构建线性回归模型(来自 CodePen 2-c)
const model = tf.sequential();
model.add(tf.layers.dense({inputShape: [1], units: 1}));
神经网络的核心构建模块是层,一个你可以将其视为从张量到张量的可调函数的数据处理模块。在这里,我们的网络由一个单一的密集层组成。该层对输入张量的形状有约束,由参数inputShape: [1]定义。在这里,它意味着该层期望以一维张量形式接收输入,其中恰好有一个值。来自密集层的输出始终是每个示例的一维张量,但该维度的大小由units配置参数控制。在这种情况下,我们只需要一个输出数字,因为我们试图预测的恰好是一个数字,即timeSec。
核心部分,密集层是每个输入与每个输出之间的可调整乘加。由于只有一个输入和一个输出,这个模型就是你可能从高中数学中记得的简单的y = m * x + b线性方程。密集层内部将m称为核,将b称为偏置,如图 2.3 所示。在这种情况下,我们构建了一个关于输入(sizeMB)和输出(timeSec)之间关系的线性模型:
timeSec = kernel * sizeMB + bias
图 2.3. 我们简单线性回归模型的示意图。该模型只有一个层。模型的可调参数(或权重),即核函数和偏差,显示在密集层内部。

在这个方程中有四个项。就模型训练而言,其中两个是固定的:sizeMB 和 timeSec 的值由训练数据确定(见 listing 2.1)。另外两个项,即核函数和偏差,是模型的参数。它们的值在模型创建时是随机选择的。这些随机值不能很好地预测下载持续时间。为了进行良好的预测,我们必须通过允许模型从数据中学习来搜索核函数和偏差的良好值。这个搜索过程就是训练过程。
要找到核函数和偏差(统称为权重)的良好设置,我们需要两样东西:
-
一个告诉我们在给定权重设置下我们做得有多好的度量
-
一种方法来更新权重的值,以便下次我们的表现比当前更好,根据先前提到的度量
这将引导我们解决线性回归问题的下一步。为了使网络准备好进行训练,我们需要选择度量和更新方法,这对应于前面列出的两个必需项。这是 TensorFlow.js 称为模型编译步骤的一部分,它采取
-
一个损失函数—一个错误度量。这是网络在训练数据上衡量自己性能并使自己朝着正确方向前进的方式。更低的损失更好。当我们训练时,我们应该能够绘制随时间变化的损失并看到它下降。如果我们的模型训练了很长时间,而损失并没有减少,这可能意味着我们的模型没有学会拟合数据。在本书的过程中,您将学会解决此类问题。
-
一个优化器—根据数据和损失函数,网络将如何更新其权重(在本例中为核函数和偏差)的算法。
损失函数和优化器的确切目的,以及如何为它们做出良好选择,将在接下来的几章中进行彻底探讨。但现在,以下选择就足够了。
代码清单 2.4. 配置训练选项:模型编译(来自 CodePen 2-c)
model.compile({optimizer: 'sgd', loss: 'meanAbsoluteError'});
我们在模型上调用compile方法,指定'sgd'作为我们的优化器,'meanAbsoluteError'作为我们的损失。'meanAbsoluteError'表示我们的损失函数将计算我们的预测与目标的距离,取其绝对值(使它们全部为正数),然后返回这些值的平均值:
meanAbsoluteError = average( absolute(modelOutput - targets) )
例如,给定
modelOutput = [1.1, 2.2, 3.3, 3.6]
targets = [1.0, 2.0, 3.0, 4.0]
那么,
meanAbsoluteError = average([|1.1 - 1.0|, |2.2 - 2.0|,
|3.3 - 3.0|, |3.6 - 4.0|])
= average([0.1, 0.2, 0.3, 0.4])
= 0.25
如果我们的模型做出非常糟糕的预测,与目标差距很大,那么meanAbsoluteError将非常大。相反,我们可能做的最好的事情是准确预测每一个,这样我们的模型输出和目标之间的差异将为零,因此损失(meanAbsoluteError)将为零。
在 list 2.4 中的sgd代表随机梯度下降,我们将在 section 2.2 中稍作描述。简而言之,这意味着我们将使用微积分来确定应该对权重进行哪些调整以减少损失;然后我们将进行这些调整并重复该过程。
我们的模型现在已经准备好适应我们的训练数据了。
2.1.5. 将模型拟合到训练数据
在 TensorFlow.js 中训练模型是通过调用模型的fit()方法来完成的。我们将模型与训练数据拟合。在这里,我们将sizeMB张量作为我们的输入,将timeSec张量作为我们期望的输出。我们还传入一个配置对象,其中包含一个epochs字段,该字段指定我们想要完全遍历我们的训练数据 10 次。在深度学习中,通过完整训练集的每次迭代称为epoch。
list 2.5. 拟合线性回归模型(来自 CodePen 2-c)
(async function() {
await model.fit(trainTensors.sizeMB,
trainTensors.timeSec,
{epochs: 10});
})();
fit()方法通常运行时间较长,持续几秒钟或几分钟。因此,我们利用 ES2017/ES8 的async/await特性,以便在浏览器中运行时该函数不会阻塞主 UI 线程。这与 JavaScript 中其他可能运行时间较长的函数类似,例如async fetch。在这里,我们等待fit()调用完成后再继续进行,使用立即调用的异步函数表达式^([2])模式,但未来的示例将在前台线程中进行其他工作的同时在后台线程中进行训练。
²
有关立即调用的函数表达式的更多信息,请参见
mng.bz/RPOZ。
一旦我们的模型完成拟合,我们就会想要看看它是否起作用。至关重要的是,我们将在训练期间未使用的数据上评估模型。在本书中,将反复出现将测试数据与训练数据分离(因此避免在测试数据上训练)的主题。这是机器学习工作流程的重要部分,你应该内化。
模型的evaluate()方法计算应用于提供的示例特征和目标的损失函数。它与fit()方法类似,因为它计算相同的损失,但evaluate()不会更新模型的权重。我们使用evaluate()来估计模型在测试数据上的质量,以便了解模型在将来应用中的表现:
> model.evaluate(testTensors.sizeMB, testTensors.timeSec).print();
Tensor
0.31778740882873535
在这里,我们看到损失在测试数据上平均约为 0.318。考虑到,默认情况下,模型是从随机初始状态训练的,你会得到不同的值。另一种说法是,该模型的平均绝对误差(MAE)略高于 0.3 秒。这个好吗?比只估算一个常量好吗?我们可以选择一个好的常量是平均延迟。让我们看看使用这个常量会得到什么样的误差,使用 TensorFlow.js 对张量进行数学运算的支持。首先,我们将计算在训练集上计算的平均下载时间:
> const avgDelaySec = tf.mean(trainData.timeSec);
> avgDelaySec.print();
Tensor
0.2950500249862671
接下来,让我们手动计算 meanAbsoluteError。MAE 简单地是我们的预测值与实际值之间的平均差值。我们将使用 tf.sub() 计算测试目标与我们(常量)预测之间的差值,并使用 tf.abs() 取绝对值(因为有时我们会偏低,有时偏高),然后使用 tf.mean 求平均值:
> tf.mean(tf.abs(tf.sub(testData.timeSec, 0.295))).print();
Tensor
0.22020000219345093
请参见信息框 2.1 了解如何使用简洁的链式 API 执行相同的计算。
张量链式 API
除了标准 API 外,在 tf 命名空间下可用的张量函数之外,大多数张量函数也可以直接从张量对象本身获得,如果你喜欢,可以采用链式编程风格进行编写。下面的代码在功能上与主文中的 meanAbsoluteError 计算完全相同:
// chaining API pattern
> testData.timeSec.sub(0.295).abs().mean().print();
Tensor
0.22020000219345093
看起来平均延迟约为 0.295 秒,总是猜测平均值比我们的网络更好地估计。这意味着我们的模型准确性甚至比一个常识性的、平凡的方法还要差!我们能做得更好吗?可能是我们训练的周期不够。请记住,在训练期间,核心和偏置的值是逐步更新的。在这种情况下,每个周期都是一步。如果模型只训练了少数周期(步骤),参数值可能没有机会接近最优值。让我们再训练几个周期,然后重新评估:
> model.fit(trainTensors.sizeMB,
trainTensors.timeSec,
{epochs: 200}); ***1***
> model.evaluate(testTensors.sizeMB, testTensors.timeSec).print();
Tensor
0.04879039153456688
- 1 确保在执行 model.evaluate 之前等待 model.fit 返回的 promise 解析。
好多了!看起来我们之前是欠拟合,意味着我们的模型还没有足够地适应训练数据。现在我们的估计平均在 0.05 秒之内。我们比简单地猜测均值要准确四倍。在本书中,我们将提供关于如何避免欠拟合的指导,以及更隐蔽的过拟合问题的解决方法,过拟合是指模型对训练数据调整过多,导致在未见过的数据上泛化能力较差!
2.1.6 使用我们训练的模型进行预测
好的,太棒了!现在我们有了一个能够根据输入大小准确预测下载时间的模型,但我们如何使用它呢?答案是模型的 predict() 方法:
> const smallFileMB = 1;
> const bigFileMB = 100;
> const hugeFileMB = 10000;
> model.predict(tf.tensor2d([[smallFileMB], [bigFileMB],
[hugeFileMB]])).print();
Tensor
[[0.1373825 ],
[7.2438402 ],
[717.8896484]]
在这里,我们可以看到我们的模型预测一个 10,000 MB 的文件下载大约需要 718 秒。请注意,我们的训练数据中没有任何接近这个大小的例子。通常来说,对训练数据范围之外的值进行外推是非常危险的,但对于一个如此简单的问题,它可能是准确的......只要我们不遇到内存缓冲区、输入输出连接等新问题。如果我们能够收集更多在这个范围内的训练数据将会更好。
我们还看到我们需要将输入变量包装到一个适当形状的张量中。在 listing 2.3 中,我们定义inputShape为[1],所以模型期望每个例子具有这个形状。fit()和predict()都可以一次处理多个例子。为了提供n个样本,我们将它们堆叠成一个单个输入张量,因此必须具有形状[n, 1]。如果我们忘记了,并且向模型提供了形状错误的张量,我们将得到一个形状错误的错误,如下所示:
> model.predict(tf.tensor1d([smallFileMB, bigFileMB, hugeFileMB])).print();
Uncaught Error: Error when checking : expected dense_Dense1_input to have 2
dimension(s), but got array with shape [3]
注意此类形状不匹配的问题,因为这是一种非常常见的错误!
2.1.7. 我们第一个示例的总结
对于这个小例子来说,可以说明模型的结果。图 2.4 展示了模型在该过程中的四个点(从 10 个周期的欠拟合到收敛)。我们可以看到收敛的模型与数据非常匹配。如果你对如何绘制这种类似于图 2.4 的数据感兴趣,请访问codepen.io/tfjs-book/pen/VEVMMd上的 CodePen。
图 2.4. 训练 10、20、100 和 200 个周期后的线性模型拟合情况

这是我们的第一个示例的结束。你刚刚看到了如何在很少的 JavaScript 代码行中构建、训练和评估一个 TensorFlow.js 模型(参见 listing 2.6)。在下一节中,我们将更深入地了解model.fit内部发生的情况。
2.6. 模型定义、训练、评估和预测
const model = tf.sequential([tf.layers.dense({inputShape: [1], units: 1})]);
model.compile({optimizer: 'sgd', loss: 'meanAbsoluteError'});
(async () => await model.fit(trainTensors.sizeMB,
trainTensors.timeSec,
{epochs: 10}))();
model.evaluate(testTensors.sizeMB, testTensors.timeSec);
model.predict(tf.tensor2d([[7.8]])).print();
2.2. Model.fit()内部: 对示例 1 中的梯度下降进行解剖
在前一节中,我们构建了一个简单的模型并拟合了一些训练数据,展示了在给定文件大小的情况下,我们可以进行相当准确的下载时间预测。它可能不是最令人印象深刻的神经网络,但它的工作方式与我们将要构建的更大、更复杂的系统完全相同。我们看到将其拟合 10 个周期并不好,但将其拟合 200 个周期产生了一个质量较高的模型^([3])。让我们更详细地了解一下模型训练时发生的确切情况。
³
注意,对于像这个简单的线性模型,存在着简单、高效、封闭形式的解。然而,这种优化方法在我们后面介绍的更复杂的模型中仍然适用。
2.2.1. 梯度下降优化背后的直觉
回想一下,我们的简单单层模型是在拟合一个线性函数f(input),定义为
output = kernel * input + bias
这里的 kernel 和 bias 是稠密层(dense layer)中的可调参数(权重)。这些权重包含了网络从训练数据中学到的信息。
最初,这些权重被随机初始化为小的随机值(一个称为随机初始化的步骤)。当 kernel 和 bias 都是随机值时,我们当然不会指望kernel * input + bias会产生有用的结果。通过想象力,我们可以想象在不同的参数选择下,MAE 的值会如何变化。我们预期当参数近似于我们在图 2.4 中观察到的直线的斜率和截距时,损失会很低,并且当参数描述非常不同的直线时,损失会变得更糟。这个概念——损失作为所有可调参数的函数——被称为损失面。
由于这只是个小例子,我们只有两个可调参数和一个目标,所以可以将损失面绘制为 2D 等高线图,就像图 2.5 展示的那样。这个损失面呈现出一个漂亮的碗状,碗底的全局最小值代表了最佳的参数设置。然而,一个深度学习模型的损失面比这个要复杂得多。它会有多于两个维度,并且可能有很多局部最小值——也就是比附近任何点都更低但不是全局最低点的点。
图 2.5. 损失面展示了损失以及模型可调参数的等高线图。通过这个俯视图,我们可以看到选择{bias: 0.08, kernel: 0.07}(用白色 X 标记)作为低损失程度的合理选择。我们很少能有能力测试所有的参数设置来构建这样的图,但如果我们能,优化将会非常容易;只需选择对应最低损失的参数!

我们可以看到这个损失面的形状像个碗,最好(最低)的值在{bias: 0.08, kernel: 0.07}附近。这符合我们的数据所暗示的直线的几何形状,其中下载时间约为 0.10 秒,即使文件大小接近零。我们模型的随机初始化让我们从随机的参数设置开始,类似于地图上的随机位置,然后我们计算我们的初始损失。接下来,我们根据一个反馈信号逐渐调整参数。这个逐渐调整,也称为训练,是“机器学习”中的“学习”。这发生在一个训练循环中,如图 2.6 所示。
图 2.6. 描述训练循环,通过梯度下降更新模型

图 2.6 展示了训练循环在需要的情况下如何迭代执行这些步骤:
-
绘制一批训练样本
x和相应的目标y_true。 一批简单地将若干输入示例组合成张量。 一批中的示例数量称为批量大小。 在实际的深度学习中,通常设置为 2 的幂,例如 128 或 256。 示例被批量处理以利用 GPU 的并行处理能力,并使梯度的计算值更稳定(详情请参见第 2.2.2 节)。 -
在
x上运行网络(称为前向传递)以获得预测y_pred。 -
计算网络在批量上的损失,这是
y_true和y_pred之间不匹配的度量。 请回忆,当调用model.compile()时指定了损失函数。 -
以稍微减少此批次上的损失的方式更新网络中的所有权重(参数)。 单个权重的详细更新由优化器管理,这是我们在
model.compile()调用中指定的另一个选项。
如果您可以在每一步中降低损失,最终您将获得一个在训练数据上损失较低的网络。 网络已经“学会”将其输入映射到正确的目标。 从远处看,它可能看起来像魔术,但当简化为这些基本步骤时,事实证明它是简单的。
唯一困难的部分是步骤 4:如何确定应该增加哪些权重,应该减少哪些权重,以及数量是多少? 我们可以简单地猜测和检查,只接受实际减少损失的更新。 对于像这样的简单问题,这样的算法可能有效,但速度会很慢。 对于更大的问题,当我们正在优化数百万个权重时,随机选择良好方向的可能性变得微乎其微。 更好的方法是利用网络中使用的所有操作都是可微分的事实,并计算损失相对于网络参数的梯度。
什么是梯度? 不是精确定义它(需要一些微积分),我们可以直观地描述它如下:
一个方向,如果你将权重沿着那个方向微小移动,你将在所有可能的方向中最快地增加损失函数
即使这个定义并不过于技术性,仍然有很多要解释的,所以让我们试着把它分解一下:
-
首先,梯度是一个向量。 它的元素数量与权重相同。 它代表了在所有权重值选择空间中的方向。 如果您的模型的权重由两个数字组成,就像在我们的简单线性回归网络中一样,那么梯度就是一个 2D 向量。 深度学习模型通常具有数千或数百万个维度,这些模型的梯度是具有数千或数百万个元素的向量(方向)。
-
其次,梯度取决于当前的权重值。换句话说,不同的权重值会产生不同的梯度。从图 2.5 可以清楚地看出,最快下降的方向取决于您在损失曲面上的位置。在左边缘,我们必须向右走。接近底部,我们必须向上走,依此类推。
-
最后,梯度的数学定义指定了一个使损失函数增加的方向。当然,训练神经网络时,我们希望损失减少。这就是为什么我们必须沿着梯度的相反方向移动权重的原因。
比喻一下,想象一下在山脉中徒步旅行。假设我们希望前往海拔最低的地方。在这个比喻中,我们可以通过沿着东西和南北轴定义的任意方向改变我们的海拔。我们应该将第一个要点解释为,我们的海拔梯度是指在我们脚下的坡度下最陡的方向。第二个要点有点显而易见,说明最陡的方向取决于我们当前的位置。最后,如果我们希望海拔低,我们应该朝着梯度的相反方向迈步。
这个训练过程恰如其分地被命名为梯度下降。还记得在清单 2.4 中,当我们用配置optimizer: 'sgd'指定我们的模型优化器时吗?随机梯度下降中的梯度下降部分现在应该清楚了。 "随机"部分只是意味着我们在每个梯度下降步骤中从训练数据中抽取随机样本以提高效率,而不是在每个步骤中使用每个训练数据样本。随机梯度下降只是梯度下降的一个针对计算效率的修改。
现在我们有了更完整的工具来解释优化是如何工作的,以及为什么我们的下载时间估算模型的 200 个周期比 10 个周期更好。图 2.7 说明了梯度下降算法如何沿着我们的损失曲面找到一个很好地适应我们的训练数据的权重设置的路径。图 2.7 面板 A 中的等高线图显示了与之前相同的损失曲面,略微放大,并现在叠加了梯度下降算法所遵循的路径。该路径始于随机初始化——图像上的一个随机位置。由于我们事先不知道最优值,所以我们必须选择一个随机的起点!路径沿途还标出了其他几个感兴趣的点,说明了对应于欠拟合和良好拟合模型的位置。图 2.7 面板 B 显示了模型损失作为步骤的函数的图,突出显示了类似的感兴趣点。面板 C 说明了使用权重作为在 B 中突出显示的步骤的快照的模型。
图 2.7. 面板 A:使用梯度下降进行 200 次中等步长引导参数设置到局部最优解。注释突出显示了起始权重以及 20、100 和 200 个周期后的值。面板 B:损失作为周期函数的绘图,突出显示了相同点的损失。面板 C:从 sizeMB 到 timeSec 的函数,经过 10、20、100 和 200 个周期的训练得到的拟合模型所体现的,这里重复给出以便您轻松比较损失表面位置和模型输出。请访问 codepen.io/tfjs-book/pen/JmerMM 以玩耍这段代码。

我们简单的线性回归模型是本书唯一一个我们能够如此生动地可视化梯度下降过程的模型。但是当我们后面遇到更复杂的模型时,请记住梯度下降的本质仍然相同:它只是在一个复杂的、高维度表面上迭代地向下走,希望最终能够在一个损失非常低的地方停下来。
在我们的初始尝试中,我们使用了默认步长(由默认学习率确定),但是在有限数据上仅循环了 10 次时,步数不足以达到最优值;200 步足够了。一般来说,您如何知道如何设置学习率,或者如何知道训练何时完成?有一些有用的经验法则,我们将在本书的过程中介绍,但没有一条硬性规定能够永远避免麻烦。如果我们使用的学习率太小,导致步长太小,我们将无法在合理的时间内达到最优参数。相反,如果我们使用的学习率太大,因此步长太大,我们将完全跳过最小值,甚至可能比我们离开的地方的损失更高。这将导致我们模型的参数在逼近最优值时出现剧烈振荡,而不是以直接的方式快速逼近。图 2.8 示例如何当我们的梯度步长过大时会发生什么。在更极端的情况下,大的学习率会导致参数值发散并趋向无穷大,这将进一步在权重中生成 NaN(非数字)值,彻底破坏您的模型。
图 2.8. 当学习率过高时,梯度步长会过大,新参数可能比旧参数更差。这可能导致振荡行为或其他稳定性问题,导致出现无穷大或 NaN。您可以尝试将 CodePen 代码中的学习率增加到 0.5 或更高以查看此行为。

2.2.2. 反向传播:梯度下降的内部
在上一节中,我们解释了权重更新的步长如何影响梯度下降过程。但是,我们还没有讨论如何计算更新的方向。这些方向对于神经网络的学习过程是至关重要的。它们由相对于权重的梯度决定,计算这些梯度的算法称为反向传播。反向传播在 20 世纪 60 年代被发明,它是神经网络和深度学习的基础之一。在本节中,我们将使用一个简单的例子来展示反向传播的工作原理。请注意,本节是面向希望理解反向传播的读者。如果您只希望使用 TensorFlow.js 应用算法,这部分内容不是必需的,因为这些机制都被很好地隐藏在tf.Model.fit() API 下面;您可以跳过本节,继续阅读第 2.3 节。
考虑一个简单的线性模型
y’ = v * x,
其中 x 是输入特征,y’ 是预测输出,v 是在反向传播期间要更新的模型唯一的权重参数。假设我们使用平方误差作为损失函数;则我们有以下关系式,描述loss、v、x和y(实际目标值)之间的关系:
loss = square(y’ - y) = square(v * x - y)
让我们假设以下具体值:两个输入变量的值为 x = 2 和 y = 5,权重值为 v = 0。损失可以计算为 25。这在图 2.9 中逐步显示。图中 A 面板中的每个灰色正方形代表一个输入变量(即x和y),每个白色方框表示一个操作。总共有三个操作。连接操作的边(以及将可调权重v与第一个操作连接的边)标记为e[1]、e[2]和e[3]。
图 2.9。通过一个只有一个可更新权重(v)的简单线性模型说明反向传播算法。A 面板:对模型的前向传递(从权重(v)和输入(x和y)计算出损失值)。B 面板:反向传递——从损失到v逐步计算损失相对于v的梯度。

反向传播的一个重要步骤是确定以下量:
假设其他所有内容(在这种情况下是
x和y)保持不变,如果*v增加一个单位,我们将获得的损失值的变化有多大?
这个量被称作相对于 v 的损失梯度。为什么我们需要这个梯度呢?因为一旦我们拥有了它,我们就可以朝着相反的方向改变 v,这样就可以得到损失值的减少。请注意,我们不需要相对于 x 或 y 的损失梯度,因为 x 和 y 不需要被更新:它们是输入数据,并且是固定的。
这个梯度是逐步计算的,从损失值开始向后退到变量v,如图 2.9 B 面所示。计算的方向是这个算法被称为“反向传播”的原因。让我们来看看具体步骤。以下每个步骤都对应着图中的一个箭头:
-
在标记为
loss的边缘,我们从梯度值为 1 开始。这是一个微不足道的观点,“loss的单位增加对应着loss本身的单位增加”。 -
在标记为
e[3]的边缘,我们计算损失相对于e[3]当前值的单位变化的梯度。因为中间操作是一个平方,并且从基本微积分我们知道(e[3])²相对于e[3]的导数(在一维情况下的梯度)是2 * e[3],我们得到一个梯度值为2 * -5 = -10。值-10与之前的梯度(即 1)相乘,得到边缘e[3]上的梯度:-10。这是如果e[3]增加 1 损失将增加的量。正如你可能已经观察到的,我们用来从损失相对于一个边缘的梯度转移到相对于下一个边缘的梯度的规则是将先前的梯度与当前节点局部计算的梯度相乘。这个规则有时被称为链式法则。 -
在边缘
e[2],我们计算e[3]相对于e[2]的梯度。因为这是一个简单的add操作,梯度是 1,不管其他输入值是什么(-y)。将这个 1 与边缘e[3]上的梯度相乘,我们得到边缘e[2]上的梯度,即-10。 -
在边缘
e[1],我们计算e[2]相对于e[1]的梯度。这里的操作是x和v之间的乘法,即x * v。所以,e[2]相对于e[1](即相对于v)的梯度是x,即 2。值 2 与边缘e[2]上的梯度相乘,得到最终的梯度:2 * -10 = -20。
到目前为止,我们已经得到了v相对于损失的梯度:它是-20。为了应用梯度下降,我们需要将这个梯度的负数与学习率相乘。假设学习率是 0.01。然后我们得到一个梯度更新为
-(-20) * 0.01 = 0.2
这是我们在训练的这一步将应用于v的更新:
v = 0 + 0.2 = 0.2
正如你所见,因为我们有x = 2和y = 5,并且要拟合的函数是y’ = v * x,v的最佳值是5/2 = 2.5。经过一步训练后,v的值从 0 变为 0.2。换句话说,权重v更接近期望值。在后续的训练步骤中,它将变得越来越接近(忽略训练数据中的任何噪声),这将基于先前描述的相同的反向传播算法。
先前的示例被故意简化,以便易于跟踪。尽管该示例捕获了反向传播的本质,但实际神经网络训练中发生的反向传播与之不同,具有以下方面:
-
通常,不是提供一个简单的训练示例(在我们的例子中是
x = 2和y = 5),而是同时提供许多输入示例的批处理。用于导出梯度的损失值是所有单个示例的损失值的算术平均值。 -
被更新的变量通常有更多的元素。因此,通常涉及矩阵微积分,而不是我们刚刚做的简单的单变量导数。
-
与仅计算一个变量的梯度不同,通常涉及多个变量。图 2.10 显示了一个示例,这是一个略微更复杂的具有两个要优化变量的线性模型。除了
k之外,模型还有一个偏置项:y’ = k * x + b。在这里,有两个梯度要计算,一个是为了k,另一个是为了b。反向传播的两条路径都从损失开始。它们共享一些共同的边,并形成类似树的结构。
图 2.10. 示意图显示从损失到两个可更新权重(k和b)的反向传播。

在本节中,我们对反向传播的处理是轻松和高层次的。如果您希望深入了解反向传播的数学和算法,请参考信息框 2.2 中的链接。
在这一点上,您应该对将简单模型拟合到训练数据时发生的情况有很好的理解,因此让我们将我们的小型下载时间预测问题放在一边,并使用 TensorFlow.js 来解决一些更具挑战性的问题。在下一节中,我们将构建一个模型,以同时准确预测多个输入特征的房地产价格。
有关梯度下降和反向传播的进一步阅读
优化神经网络背后的微积分绝对是有趣的,并且能够洞察到这些算法的行为;但是在基础知识之上,它绝对不是机器学习从业者的必需品,就像理解 TCP/IP 协议的复杂性对于理解如何构建现代 Web 应用程序有用但并不重要一样。我们邀请好奇的读者探索这里的优秀资源,以建立对网络中基于梯度的优化数学的更深入的理解:
-
反向传播演示滚动说明:
mng.bz/2J4g -
斯坦福 CS231 讲座 4 课程关于反向传播的课程笔记:
cs231n.github.io/optimization-2/ -
Andrej Karpathy 的“神经网络黑客指南:”
karpathy.github.io/neuralnets/
2.3. 具有多个输入特征的线性回归
在我们的第一个示例中,我们只有一个输入特征sizeMB,用它来预测我们的目标timeSec。更常见的情况是具有多个输入特征,不确定哪些特征最具预测性,哪些只与目标松散相关,并同时使用它们,并让学习算法来处理。在本节中,我们将解决这个更复杂的问题。
到本节结束时,您将
-
了解如何构建一个模型,该模型接收并从多个输入特征中学习。
-
使用 Yarn、Git 和标准 JavaScript 项目打包结构构建和运行带有机器学习的 Web 应用程序。
-
知道如何对数据进行归一化以稳定学习过程。
-
体会如何在训练过程中使用
tf.Model.fit()回调来更新 Web UI。
2.3.1. 波士顿房价数据集
波士顿房价数据集^([4])是 1970 年代末在马萨诸塞州波士顿及周边地区收集的 500 条简单的房地产记录的集合。几十年来,它一直被用作介绍性统计和机器学习问题的标准数据集。数据集中的每个独立记录都包括波士顿社区的数值测量,例如房屋的典型大小、该地区距离最近的高速公路有多远、该地区是否拥有水边物业等。表 2.1 提供了特征的精确排序列表,以及每个特征的平均值。
⁴
大卫·哈里森(David Harrison)和丹尼尔·鲁宾菲尔德(Daniel Rubinfeld),“享乐主义住房价格与对清洁空气的需求”,《环境经济与管理杂志》,第 5 卷,1978 年,第 81–102 页,
mng.bz/1wvX。
表 2.1. 波士顿房屋数据集的特征
| 索引 | 特征简称 | 特征描述 | 平均值 | 范围(最大值-最小值) |
|---|---|---|---|---|
| 0 | CRIM | 犯罪率 | 3.62 | 88.9 |
| 1 | ZN | 用于超过 25,000 平方英尺的住宅用地比例 | 11.4 | 100 |
| 2 | INDUS | 城镇中非零售业务用地(工业)比例 | 11.2 | 27.3 |
| 3 | CHAS | 区域是否靠近查尔斯河 | 0.0694 | 1 |
| 4 | NOX | 一氧化氮浓度(百万分之一) | 0.555 | 0.49 |
| 5 | RM | 每个住宅的平均房间数 | 6.28 | 5.2 |
| 6 | AGE | 1940 年前建造的自有住房比例 | 68.6 | 97.1 |
| 7 | DIS | 到五个波士顿就业中心的加权距离 | 3.80 | 11.0 |
| 8 | RAD | 径向公路可达性指数 | 9.55 | 23.0 |
| 9 | TAX | 每 1 万美元的税率 | 408.0 | 524.0 |
| 10 | PTRATIO | 学生-教师比例 | 18.5 | 9.40 |
| 11 | LSTAT | 无高中学历的工作男性比例 | 12.7 | 36.2 |
| 12 | MEDV | 单位为 $1,000 的自有住房的中位数价值 | 22.5 | 45 |
在本节中,我们将构建、训练和评估一个学习系统,以估计邻域房屋价格的中位数值(MEDV),并给出邻域的所有输入特征。你可以把它想象成一个从可测量的邻域属性估计房地产价格的系统。
2.3.2. 从 GitHub 获取并运行波士顿房屋项目
由于这个问题比下载时间预测示例要复杂一些,并且有更多的组成部分,我们将首先以一个工作代码仓库的形式提供解决方案,然后引导你完成。如果你已经是 Git 源代码控制工作流和 npm/Yarn 包管理的专家,你可能只需快速浏览一下这一小节。有关基本的 JavaScript 项目结构的更多信息,请参阅 信息框 2.3。
我们将从 GitHub 上的源获取项目仓库的副本来开始。获取项目所需的 HTML、JavaScript 和配置文件。除了最简单的那些(这些都托管在 CodePen 上),本书中的所有示例都在两个 Git 仓库之一中收集,然后在仓库中分目录存放。这两个仓库分别是 tensorflow/tfjs-examples 和 tensorflow/tfjs-models,都托管在 GitHub 上。以下命令将克隆我们需要的仓库到本地,并将工作目录切换到波士顿房屋预测项目:
⁵
本书示例是开源的,托管在 github.com 和 codepen.io 上。如果你想要关于如何使用 Git 源代码控制工具的温习,GitHub 有一个很好的教程,从
help.github.com/articles/set-up-git开始。如果你发现错误或想通过 GitHub 提交更正,请随时发送修复请求。
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/boston-housing
本书中使用的基本 JavaScript 项目结构
本书示例中使用的标准项目结构包括三种重要类型的文件。第一种是 HTML。我们将使用的 HTML 文件将是基本的骨架,主要用于承载几个组件。通常只会有一个名为 index.html 的 HTML 文件,其中包含几个 div 标签,可能还有几个 UI 元素,以及一个 source 标签来引入 JavaScript 代码,如 index.js。
JavaScript 代码通常会模块化成多个文件,以促进良好的可读性和风格。在波士顿房屋项目中,负责更新可视元素的代码存放在 ui.js 中,而处理数据下载的代码则在 data.js 中。两者均通过 import 语句从 index.js 中引用。
我们将使用的第三种重要文件类型是元数据包 .json 文件,这是 npm 包管理器(www.npmjs.com)的要求。如果您之前没有使用过 npm 或者 Yarn,请我们建议您浏览一下 npm 的“入门”文档(docs.npmjs.com/about-npm),并且熟悉到足以构建和运行示例代码的程度。我们将使用 Yarn 作为我们的包管理器(yarnpkg.com/en/),但是如果您更喜欢使用 npm,可以将 npm 替换为 Yarn。
在存储库内,注意以下重要文件:
-
index.html—根 HTML 文件,它提供 DOM 根,并调用 JavaScript 脚本
-
index.js—根 JavaScript 文件,该文件加载数据,定义模型和训练循环,并指定 UI 元素
-
data.js—下载和访问波士顿房价数据集所需的结构的实现
-
ui.js—实现将 UI 元素与操作连接的 UI 钩子的文件;绘图配置的规范
-
normalization.js—数值例程,例如从数据中减去均值
-
package.json—标准的 npm 包定义,描述了构建和运行此演示所需的依赖项(例如 TensorFlow.js!)
请注意,我们不遵循将 HTML 文件和 JavaScript 文件放在特定类型的子目录中的标准做法。这种模式在更大的存储库中是最佳做法,但对于我们将在本书中使用的较小示例或您可以在 github.com/tensorflow/tfjs-examples 找到的示例,它更多地是混淆而不是澄清。
要运行此演示,请使用 Yarn:
yarn && yarn watch
这将在您的浏览器中打开一个指向 localhost 上的端口的新标签,该端口将运行示例。如果您的浏览器没有自动反应,可以在命令行中导航到输出的 URL。点击标记为“Train Linear Regressor”的按钮将触发构建线性模型并将其拟合到波士顿房价数据的过程,然后在每个周期后输出训练和测试数据集的损失的动态图表,如图 2.11 所示。
图 2.11。tfjs-examples 中的波士顿房价线性回归示例

本节的其余部分将介绍构建这个波士顿房价线性回归 Web 应用演示的重要要点。我们首先将回顾数据是如何收集和处理的,以便与 TensorFlow.js 一起使用。然后我们将重点关注模型的构建、训练和评估;最后,我们将展示如何在网页上使用模型进行实时预测。
2.3.3. 访问波士顿房价数据
在我们的第一个项目中,在清单 2.1 中,我们将数据硬编码为 JavaScript 数组,并使用tf.tensor2d函数将其转换为张量。硬编码对于小型演示来说没问题,但显然不适用于更大的应用程序。一般来说,JavaScript 开发人员会发现他们的数据位于某个 URL(可能是本地)的某种序列化格式中。例如,波士顿房屋数据以 CSV 格式公开且免费提供,可以从 Google Cloud 的以下 URL 中获取:
-
storage.googleapis.com/tfjs-examples/multivariate-linear-regression/data/train-data.csv -
storage.googleapis.com/tfjs-examples/multivariate-linear-regression/data/train-target.csv -
storage.googleapis.com/tfjs-examples/multivariate-linear-regression/data/test-data.csv -
storage.googleapis.com/tfjs-examples/multivariate-linear-regression/data/test-target.csv
数据已经通过将样本随机分配到训练和测试部分而进行了预拆分。大约有三分之二的样本在训练拆分中,剩下的三分之一用于独立评估经过训练的模型。此外,对于每个拆分,目标特征已经与其他特征分开成为 CSV 文件,导致了表 2.2 中列出的四个文件名。
表 2.2. 波士顿房屋数据集的拆分和内容的文件名
| 特征(12 个数字) | 目标(1 个数字) | ||
|---|---|---|---|
| 训练-测试拆分 | 训练 | train-data.csv | train-target.csv |
| 测试 | test-data.csv | test-target.csv |
为了将这些数据引入我们的应用程序,我们需要能够下载这些数据并将其转换为适当类型和形状的张量。波士顿房屋项目在 data.js 中定义了一个名为BostonHousingDataset的类,用于此目的。该类抽象了数据集流操作,提供了一个 API 来检索原始数据作为数字矩阵。在内部,该类使用了公共开源 Papa Parse 库(www.papaparse.com)来流式传输和解析远程 CSV 文件。一旦文件已加载和解析,库就会返回一个数字数组的数组。然后,使用与第一个示例中相同的 API 将其转换为张量,如下清单所示,这是index.js中的一个略微简化的示例,重点放在相关部分上。
清单 2.7. 在 index.js 中将波士顿房屋数据转换为张量
// Initialize a BostonHousingDataset object defined in data.js.
const bostonData = new BostonHousingDataset();
const tensors = {};
// Convert the loaded csv data, of type number[][] into 2d tensors.
export const arraysToTensors = () => {
tensors.rawTrainFeatures = tf.tensor2d(bostonData.trainFeatures);
tensors.trainTarget = tf.tensor2d(bostonData.trainTarget);
tensors.rawTestFeatures = tf.tensor2d(bostonData.testFeatures);
tensors.testTarget = tf.tensor2d(bostonData.testTarget);
}
// Trigger the data to load asynchronously once the page has loaded.
let tensors;
document.addEventListener('DOMContentLoaded', async () => {
await bostonData.loadData();
arraysToTensors();
}, false);
2.3.4. 精确定义波士顿房屋问题
现在我们可以以我们想要的形式访问我们的数据,现在是时候更准确地澄清我们的任务了。我们说我们想要从其他字段预测 MEDV,但是我们将如何确定我们的工作是否做得好呢?我们如何区分一个好模型和一个更好的模型呢?
我们在第一个例子中使用的度量标准meanAbsoluteError将所有错误都视为平等。如果只有 10 个样本,并且我们对所有 10 个样本进行预测,并且我们在其中的第 10 个样本上完全正确,但在其他 9 个样本上偏差为 30,则meanAbsoluteError将为 3(因为 30/10 为 3)。如果我们的预测对每个样本都偏差为 3,那么meanAbsoluteError仍然为 3。这个“错误的平等性”原则可能似乎是唯一显然正确的选择,但是选择除meanAbsoluteError之外的损失度量有很好的理由。
另一种选择是将大错误的权重赋予小错误。我们可以不是取绝对误差的平均值,而是取平方误差的平均值。
在进行有关这 10 个样本的案例研究时,这种均方误差(MSE)方法看到了在每个示例上偏差为 3 时(10 × 3² = 90)比在一个示例上偏差为 30 时(1 × 30² = 900)较低的损失。由于对大错误的敏感性,平方误差比绝对误差更敏感于样本异常值。将模型拟合以最小化 MSE 的优化器将更喜欢系统地犯小错误的模型,而不是偶尔给出非常糟糕估计的模型。显然,这两种错误度量都会更喜欢根本没有错误的模型!但是,如果您的应用可能对非常不正确的异常值敏感,那么 MSE 可能比 MAE 更好。选择 MSE 或 MAE 的其他技术原因,但它们在此时并不重要。在本例中,我们将使用 MSE 来增加变化,但 MAE 也足够。
在我们继续之前,我们应该找到损失的基准估计。如果我们不知道从一个非常简单的估计中得出的误差,那么我们就没有能力从一个更复杂的模型中评估它。我们将使用平均房地产价格作为我们的“最佳天真猜测”,并计算总是猜测该值时的误差。
列表 2.8. 计算猜测平均价格的基线损失
export const computeBaseline = () => {
const avgPrice = tf.mean(tensors.trainTarget); ***1***
console.log(`Average price: ${avgPrice.dataSync()[0]}`);
const baseline =
tf.mean(tf.pow(tf.sub(
tensors.testTarget, avgPrice), 2)); ***2***
console.log(
`Baseline loss: ${baseline.dataSync()[0]}`); ***3***
};
-
1 计算平均价格
-
2 计算测试数据上的平均平方误差。sub()、pow 和 mean() 调用是计算平均平方误差的步骤。
-
3 打印出损失值
因为 TensorFlow.js 通过在 GPU 上进行调度来优化其计算,所以张量可能并不总是可供 CPU 访问。在列表 2.8 中对dataSync的调用告诉 TensorFlow.js 完成张量的计算,并将值从 GPU 拉到 CPU 中,以便可以打印出来或以其他方式与非 TensorFlow 操作共享。
当执行时,列表 2.8 中的代码将在控制台中产生以下输出:
Average price: 22.768770217895508
Baseline loss: 85.58282470703125
这告诉我们,天真的误差率大约为 85.58。如果我们构建一个总是输出 22.77 的模型,该模型在测试数据上将达到 85.58 的 MSE。再次注意,我们在训练数据上计算指标,并在测试数据上评估它,以避免不公平的偏见。
平均平方误差为 85.58,所以我们应该取平方根得到平均误差。85.58 的平方根大约是 9.25。因此,我们可以说我们期望我们的(常量)估计平均偏离(上下)约 9.25。根据表 2.1 的数值,以千美元为单位,估计一个常量意味着我们会偏离约 9,250 美元。如果这对我们的应用程序足够好,我们可以停止!明智的机器学习从业者知道何时避免不必要的复杂性。让我们假设我们的价格估计应用程序需要比这更接近。我们将通过拟合我们的数据来查看是否可以获得比 85.58 更好的 MSE 的线性模型。
2.3.5。稍微偏离数据标准化
查看波士顿房屋的特征,我们会看到各种值。NOX 的范围在 0.4 到 0.9 之间,而 TAX 则从 180 到 711。为了拟合线性回归,优化器将尝试找到每个特征的权重,使特征的累加乘以权重大约等于房屋价格。请记住,为了找到这些权值,优化器正在寻找,遵循权重空间中的梯度。如果某些特征与其他特征的比例相差很大,那么某些权重将比其他权重敏感得多。向一个方向的一个非常小的移动将比另一个方向的一个非常大的移动更改输出。这可能导致不稳定,并使得难以拟合模型。
为了对抗这一点,我们将首先标准化我们的数据。这意味着我们将缩放我们的特征,使它们的平均值为零,标准差为单位。这种标准化方法很常见,也可以被称为标准转换或z-score 标准化。做这种操作的算法很简单——我们首先计算每个特征的平均值,并从原始值中减去,使得该特征的平均值为零。然后我们计算特征的标准差与减去的平均值,并进行除法。在伪代码中,
normalizedFeature = (feature - mean(feature)) / std(feature)
例如,当特征是[10, 20, 30, 40]时,标准化后的版本大约是[-1.3, -0.4, 0.4, 1.3],很明显的平均值为零;肉眼看,标准差大约为一。在波士顿房屋的例子中,标准化代码被分解到一个单独的文件中,normalization.js,其内容在列表 2.9 中。在这里,我们看到两个函数,一个用于计算所提供的二维张量的平均值和标准差,另一个用于在提供预先计算的平均值和标准差的情况下标准化张量。
列表 2.9。数据规范化:零均值,单位标准差
/**
* Calculates the mean and standard deviation of each column of an array.
*
* @param {Tensor2d} data Dataset from which to calculate the mean and
* std of each column independently.
*
* @returns {Object} Contains the mean and std of each vector
* column as 1d tensors.
*/
export function determineMeanAndStddev(data) {
const dataMean = data.mean(0);
const diffFromMean = data.sub(dataMean);
const squaredDiffFromMean = diffFromMean.square();
const variance = squaredDiffFromMean.mean(0);
const std = variance.sqrt();
return {mean, std};
}
/**
* Given expected mean and standard deviation, normalizes a dataset by
* subtracting the mean and dividing by the standard deviation.
*
* @param {Tensor2d} data: Data to normalize.
* Shape: [numSamples, numFeatures].
* @param {Tensor1d} mean: Expected mean of the data. Shape [numFeatures].
* @param {Tensor1d} std: Expected std of the data. Shape [numFeatures]
*
* @returns {Tensor2d}: Tensor the same shape as data, but each column
* normalized to have zero mean and unit standard deviation.
*/
export function normalizeTensor(data, dataMean, dataStd) {
return data.sub(dataMean).div(dataStd);
}
让我们稍微深入一下这些函数。函数determineMeanAndStddev将data作为输入,这是一个秩 2 张量。按照惯例,第一个维度是样本维度:每个索引对应一个独立,唯一的样本。第二个维度是特征维度:其 12 个元素对应于 12 个输入特征(如 CRIM,ZN,INDUS 等)。由于我们要独立计算每个特征的平均值,因此调用
const dataMean = data.mean(0);
这个调用中的0表示平均值是在第 0 维度(第一维度)上计算的。记得data是一个二维张量,因此有两个维度(或轴)。第一个轴,即“批处理”轴,是样本维度。当我们沿着该轴从第一个到第二个到第三个元素移动时,我们引用不同的样本,或者在我们的情况下,不同的房地产部分。第二个维度是特征维度。当我们在该维度的第一个元素移动到第二个元素时,我们引用不同的特征,例如 CRIM,ZN 和 INDUS,来自表 2.1。当我们沿轴 0 取平均值时,我们正在沿样本方向取平均值。结果是具有仅保留特征轴的秩 1 张量。我们拥有每个特征的平均值。如果我们改为沿轴 1 取平均值,我们仍会得到一个秩 1 张量,但剩余轴将是样本维度。这些值将对应于每个房地产部分的平均值,这在我们的应用程序中没有意义。在使用轴进行计算时,请注意在正确方向上进行计算,因为这是常见的错误来源。
果然,如果我们在这里设置一个断点^([6]),我们可以使用 JavaScript 控制台来探索计算出的平均值,我们看到的平均值非常接近我们为整个数据集计算的值。这意味着我们的训练样本是代表性的:
⁶
在 Chrome 中设置断点的说明在这里:
mng.bz/rPQJ。如果您需要 Firefox,Edge 或其他浏览器中断点设置说明,您可以使用您喜欢的搜索引擎搜索“如何设置断点”。
> dataMean.shape
[12]
> dataMean.print();
[3.3603415, 10.6891899, 11.2934837, 0.0600601, 0.5571442, 6.2656188,
68.2264328, 3.7099338, 9.6336336, 409.2792969, 18.4480476, 12.5154343]
在下一行中,我们通过使用tf.sub从我们的数据中减去平均值,从而获得数据的中心版本:
const diffFromMean = data.sub(dataMean);
如果您没有 100%的注意力,这一行可能会隐藏一个迷人的小魔术。您看,data是一个形状为[333,12]的秩 2 张量,而dataMean是一个形状为[12]的秩 1 张量。通常情况下,不可能减去具有不同形状的两个张量。但是,在这种情况下,TensorFlow 使用广播将第二个张量的形状扩展为在效果上重复它 333 次,而不使其清楚地拼写出来。这种易用性使操作变得更加简单,但是有时广播兼容的形状规则可能有点令人困惑。如果您对广播的细节感兴趣,请直接阅读信息框 2.4。
determineMeanAndStddev函数的下几行没有新的惊喜:tf.square()将每个元素乘以自身,而tf.sqrt()获取元素的平方根。每种方法的详细 API 在 TensorFlow.js API 参考文档中都有记录,js.tensorflow.org/api/latest/。该文档页面还具有实时的可编辑小部件,可以让您探索如何将函数与自己的参数值一起使用,如图 2.12 所示。
图 2.12。js.tensorflow.org的 TensorFlow.js API 文档允许您在文档内直接探索和交互使用 TensorFlow API。这使得理解函数用途和棘手的边界案例变得简单而快速。

在这个例子中,我们编写了代码以优先考虑阐述的清晰度,但是determineMeanAndStddev函数可以更简洁地表达:
const std = data.sub(data.mean(0)).square().mean().sqrt();
你应该能够看到,TensorFlow 允许我们在不使用很多样板代码的情况下表达相当多的数字计算。
广播
考虑一个张量运算,如C = tf.someOperation(A,B),其中A和B是张量。如果可能且没有歧义,较小的张量将被扩展到与较大的张量匹配的形状。广播包括两个步骤:
-
小张量添加轴(称为广播轴)以匹配大张量的秩。
-
较小的张量将沿着这些新轴重复以匹配大张量的完整形状。
在实现方面,实际上没有创建新的张量,因为那将非常低效。重复操作完全是虚拟的,在算法级别而不是在内存级别上发生。但是思考较小张量沿着新轴重复是有帮助的。
通过广播,如果一个张量的形状为(a, b, ..., n, n + 1, ... m),另一个张量的形状为(n, n + 1, ... , m),通常可以对两个张量进行逐元素操作。广播将自动发生在轴a到n - 1。例如,以下示例通过广播在不同形状的两个随机张量上应用逐元素maximum操作:
x = tf.randomUniform([64, 3, 11, 9]); ***1***
y = tf.randomUniform([11, 9]); ***2***
z = tf.maximum(x, y); ***3***
-
1 x 是一个形状为 [64, 3, 11, 9] 的随机张量。
-
2 y 是一个形状为 [11, 9] 的随机张量。
-
3 输出 z 的形状与 x 相同,为 [64, 3, 11, 9]。
2.3.6. 波士顿房屋数据的线性回归
我们的数据已经归一化,并且我们已经完成了对数据的尽职调查工作,计算出了一个合理的基线——下一步是构建和拟合一个模型,看看我们是否能超越基线。在 listing 2.10 中,我们定义了一个线性回归模型,就像我们在 section 2.1 中所做的那样(来自 index.js)。代码非常相似;我们从下载时间预测模型看到的唯一区别在于 inputShape 配置,它现在接受长度为 12 的向量,而不是 1。单个密集层仍然具有 units: 1,表示输出为一个数字。
Listing 2.10. 为波士顿房屋定义线性回归模型
export const linearRegressionModel = () => {
const model = tf.sequential();
model.add(tf.layers.dense(
{inputShape: [bostonData.numFeatures], units: 1}));
return model;
};
在我们的模型被定义之后,但在我们开始训练之前,我们必须通过调用model.compile来指定损失和优化器。在 listing 2.11 中,我们看到指定了'meanSquaredError'损失,并且优化器使用了自定义的学习率。在我们之前的示例中,优化器参数被设置为字符串'sgd',但现在是tf.train.sgd(LEARNING_RATE)。这个工厂函数将返回一个代表随机梯度下降优化算法的对象,但是参数化了我们自定义的学习率。这是 TensorFlow.js 中的一个常见模式,借鉴自 Keras,并且你将看到它被用于许多可配置选项。对于标准、已知的默认参数,字符串标记值可以替代所需的对象类型,TensorFlow.js 将使用良好的默认参数替换所需对象的字符串。在这种情况下,'sgd'将被替换为tf.train.sgd(0.01)。当需要额外的定制时,用户可以通过工厂函数构建对象并提供所需的定制值。这允许代码在大多数情况下简洁,但允许高级用户在需要时覆盖默认行为。
Listing 2.11. 为波士顿房屋模型编译(来自 index.js)
const LEARNING_RATE = 0.01;
model.compile({
optimizer: tf.train.sgd(LEARNING_RATE),
loss: 'meanSquaredError'});
现在我们可以使用训练数据集训练我们的模型。在列表 2.12 到 2.14 中,我们将使用model.fit()调用的一些附加功能,但本质上它与图 2.6 中的情况相同。在每一步中,它从特征(tensors.trainFeatures)和目标(tensors.trainTarget)中选择一定数量的新样本,计算损失,然后更新内部权重以减少该损失。该过程将在训练数据上进行NUM_EPOCHS次完整的遍历,并且在每一步中将选择BATCH_SIZE个样本。
图 2.12. 在波士顿房屋数据上训练我们的模型
await model.fit(tensors.trainFeatures, tensors.trainTarget, {
batchSize: BATCH_SIZE
epochs: NUM_EPOCHS,
});
在波士顿房价 Web 应用程序中,我们展示了一个图表,显示模型训练时的训练损失。这需要使用model.fit()回调功能来更新用户界面。model.fit()回调 API 允许用户提供回调函数,在特定事件发生时执行。截至版本 0.12.0,回调触发器的完整列表包括onTrainBegin、onTrainEnd、onEpochBegin、onEpochEnd、onBatchBegin和onBatchEnd。
图 2.13. model.fit()中的回调函数
let trainLoss;
await model.fit(tensors.trainFeatures, tensors.trainTarget, {
batchSize: BATCH_SIZE,
epochs: NUM_EPOCHS,
callbacks: {
onEpochEnd: async (epoch, logs) => {
await ui.updateStatus(
`Epoch ${epoch + 1} of ${NUM_EPOCHS} completed.`);
trainLoss = logs.loss;
await ui.plotData(epoch, trainLoss);
}
}
});
这里介绍的最后一个新的自定义是利用验证数据。验证是一个值得解释的机器学习概念。在早期的下载时间示例中,我们将训练数据与测试数据分开,因为我们想要一个对模型在新的、未见过的数据上的性能进行无偏估计。通常情况下,还有一个称为验证数据的拆分。验证数据与训练数据和测试数据都是分开的。验证数据用于什么?机器学习工程师将在验证数据上看到结果,并使用该结果来更改模型的某些配置[1],以提高验证数据上的准确性。这都很好。然而,如果这个周期足够多次,那么我们实际上是在验证数据上进行调优。如果我们使用相同的验证数据来评估模型的最终准确性,那么最终评估的结果将不再具有泛化性,因为模型已经看到了数据,并且评估结果不能保证反映模型在未来未见数据上的表现。这就是将验证数据与测试数据分开的目的。这个想法是我们将在训练数据上拟合我们的模型,并根据验证数据上的评估来调整其超参数。当我们完成并满意整个过程时,我们将在测试数据上仅对模型进行一次评估,以获得最终的、可推广的性能估计。
⁷
这些配置的示例包括模型中的层数、层的大小、训练过程中使用的优化器类型和学习率等。它们被称为模型的超参数,我们将在下一章的 section 3.1.2 中更详细地介绍。
让我们总结一下训练、验证和测试集在 TensorFlow.js 中的作用以及如何使用它们。并非所有项目都会使用这三种类型的数据。经常,快速探索或研究项目只会使用训练和验证数据,而不会保留一组“纯”数据用于测试。虽然不太严谨,但这有时是对有限资源的最佳利用:
-
训练数据—用于梯度下降优化模型权重
- 在 TensorFlow.js 中的用法:通常,使用主要参数(
x和y)对Model.fit(x, y, config)进行调用来使用训练数据。
- 在 TensorFlow.js 中的用法:通常,使用主要参数(
-
验证数据—用于选择模型结构和超参数
- 在 TensorFlow.js 中的用法:
Model.fit()有两种指定验证数据的方式,都作为config参数的一部分。如果您作为用户具有明确的用于验证的数据,则可以指定为config.validationData。相反,如果您希望框架拆分一些训练数据并将其用作验证数据,则可以在config.validationSplit中指定要使用的比例。框架将确保不使用验证数据来训练模型,因此不会有重叠。
- 在 TensorFlow.js 中的用法:
-
测试数据—用于对模型性能进行最终、无偏的估计
- 在 TensorFlow.js 中的用法:通过将其作为
x和y参数传递给Model.evaluate(x, y, config),可以向系统公开评估数据。
- 在 TensorFlow.js 中的用法:通过将其作为
在 列表 2.14 中,验证损失与训练损失一起计算。validationSplit: 0.2字段指示model.fit()机制选择最后 20%的训练数据用作验证数据。这些数据将不用于训练(不影响梯度下降)。
列表 2.14. 在 model.fit() 中包含验证数据
let trainLoss;
let valLoss;
await model.fit(tensors.trainFeatures, tensors.trainTarget, {
batchSize: BATCH_SIZE,
epochs: NUM_EPOCHS,
validationSplit: 0.2,
callbacks: {
onEpochEnd: async (epoch, logs) => {
await ui.updateStatus(
`Epoch ${epoch + 1} of ${NUM_EPOCHS} completed.`);
trainLoss = logs.loss;
valLoss = logs.val_loss;
await ui.plotData(epoch, trainLoss, valLoss);
}
}
});
在浏览器上将此模型训练到 200 个周期大约需要 11 秒。我们现在可以对我们的测试集上评估模型,以查看它是否比基准更好。下一个列表显示了如何使用model.evaluate()来收集模型在我们保留的测试数据上的性能,然后调用我们的自定义 UI 例程来更新视图。
列表 2.15. 在测试数据上评估我们的模型并更新 UI(来自 index.js)
await ui.updateStatus('Running on test data...');
const result = model.evaluate(
tensors.testFeatures, tensors.testTarget, {batchSize: BATCH_SIZE});
const testLoss = result.dataSync()[0];
await ui.updateStatus(
`Final train-set loss: ${trainLoss.toFixed(4)}\n` +
`Final validation-set loss: ${valLoss.toFixed(4)}\n` +
`Test-set loss: ${testLoss.toFixed(4)}`);
在这里,model.evaluate()返回一个标量(记住,一个秩为 0 的张量),其中包含对测试集计算得出的损失。
由于梯度下降中涉及随机性,您可能会得到不同的结果,但以下结果是典型的:
-
最终的训练集损失: 21.9864
-
最终的验证集损失: 31.1396
-
测试集损失: 25.3206
-
基准损失: 85.58
我们从中看到,我们的最终无偏估计错误约为 25.3,远远好于我们的天真基线 85.6。回想一下,我们的错误是使用meanSquaredError计算的。取平方根,我们看到基线估计通常偏离了 9.2 以上,而线性模型仅偏离了约 5.0。相当大的改进!如果我们是世界上唯一拥有这些信息的人,我们可能是 1978 年波士顿最好的房地产投资者!除非,以某种方式,有人能够建立一个更准确的估算……
如果你让好奇心超过了自己,并点击了训练神经网络回归器,你已经知道可以得到更好的估计。在下一章中,我们将介绍非线性深度模型,展示这样的成就是如何可能的。
2.4. 如何解释你的模型
现在我们已经训练了我们的模型,并且它能够做出合理的预测,自然而然地想知道它学到了什么。有没有办法窥视模型,看看它是如何理解数据的?当模型为输入预测了一个特定的价格时,你能否找到一个可以理解的解释来解释它为什么得出这个值?对于大型深度网络的一般情况,模型理解——也称为模型可解释性——仍然是一个活跃的研究领域,在学术会议上填满了许多海报和演讲。但对于这个简单的线性回归模型来说,情况相当简单。
到本节结束时,你将
-
能够从模型中提取学到的权重。
-
能够解释这些权重,并将它们与你对权重应该是什么的直觉进行权衡。
2.4.1. 从学到的权重中提取含义
我们在 section 2.3 中构建的简单线性模型包含了 13 个学到的参数,包含在一个核和一个偏差中,就像我们在 section 2.1.3 中的第一个线性模型一样:
output = kernel · features + bias
核和偏差的值都是在拟合模型时学到的。与 section 2.1.3 中学到的标量线性函数相比,这里,特征和核都是向量,而“·”符号表示内积,是标量乘以向量的一般化。内积,也称为点积,简单地是匹配元素的乘积的和。清单 2.16 中的伪代码更精确地定义了内积。
我们应该从中得出结论,特征的元素与核的元素之间存在关系。对于每个单独的特征元素,例如表 table 2.1 中列出的“犯罪率”和“一氧化氮浓度”,核中都有一个关联的学到的数字。每个值告诉我们一些关于模型对这个特征学到了什么以及这个特征如何影响输出的信息。
清单 2.16. 内积伪代码
function innerProduct(a, b) {
output = 0;
for (let i = 0 ; i < a.length ; i++) {
output += a[i] * b[i];
}
return output;
}
例如,如果模型学到了kernel[i]是正的,那么这意味着如果feature[i]的值较大,则输出将更大。反之,如果模型学到了kernel[j]是负的,那么较大的feature[j]值会减少预测的输出。学到的值在大小上非常小意味着模型认为相关特征对预测的影响很小,而具有大幅度的学习值则表明模型对该特征的重视程度很高,并且特征值的微小变化将对预测产生相对较大的影响。^([8])
⁸
注意,只有在特征已经被归一化的情况下,才能以这种方式比较其大小,就像我们为波士顿房屋数据集所做的那样。
为了具体化,根据绝对值排名,前五个特征值被打印在图 2.13 中,以显示波士顿房屋示例的输出区域中的一个运行。由于初始化的随机性,后续运行可能会学到不同的值。我们可以看到对于我们期望对房地产价格产生负面影响的特征,例如当地居民辍学率和房地产距离理想工作地点的距离,其值是负的。对于我们期望与价格直接相关的特征,例如房产中的房间数量,学到的权重是正的。
图 2.13。根据绝对值排名,这是在波士顿房屋预测问题的线性模型的一个运行中学到的前五个权重。注意对那些你期望对房价产生负面影响的特征的负值。

2.4.2。从模型中提取内部权重
学到的模型的模块化结构使得提取相关权重变得容易;我们可以直接访问它们,但是有几个需要通过的 API 级别以获取原始值。重要的是要记住,由于值可能在 GPU 上,而设备间通信是昂贵的,请求这些值是异步的。列表 2.17 中的粗体代码是对 model.fit 回调的补充,扩展了 列表 2.14 以在每个 epoch 后说明学到的权重。我们将逐步讲解 API 调用。
给定模型,我们首先希望访问正确的层。这很容易,因为这个模型中只有一个层,所以我们可以在 model.layers[0] 处获得它的句柄。现在我们有了层,我们可以使用 getWeights() 访问内部权重,它返回一个权重数组。对于密集层的情况,这将始终包含两个权重,即核和偏置,顺序是这样的。因此,我们可以在以下位置访问正确的张量:
> model.layers[0].getWeights()[0]
现在我们有了正确的张量,我们可以通过调用其 data() 方法来访问其内容。由于 GPU ↔ CPU 通信的异步性质,data() 是异步的,并返回张量值的一个承诺,而不是实际值。在 2.17 节 中,通过将承诺的 then() 方法传递给回调函数,将张量值绑定到名为 kernelAsArr 的变量上。如果取消注释 console.log() 语句,则像下面这样的语句,列出内核值,将在每个纪元结束时记录到控制台:
> Float32Array(12) [-0.44015952944755554, 0.8829045295715332,
0.11802537739276886, 0.9555914402008057, -1.6466193199157715,
3.386948347091675, -0.36070501804351807, -3.0381457805633545,
1.4347705841064453, -1.3844640254974365, -1.4223048686981201,
-3.795234441757202]
2.17. 访问内部模型值
let trainLoss;
let valLoss;
await model.fit(tensors.trainFeatures, tensors.trainTarget, {
batchSize: BATCH_SIZE,
epochs: NUM_EPOCHS,
validationSplit: 0.2,
callbacks: {
onEpochEnd: async (epoch, logs) => {
await ui.updateStatus(
`Epoch ${epoch + 1} of ${NUM_EPOCHS} completed.`);
trainLoss = logs.loss;
valLoss = logs.val_loss;
await ui.plotData(epoch, trainLoss, valLoss);
model.layers[0].getWeights()[0].data().then(kernelAsArr => {
// console.log(kernelAsArr);
const weightsList = describeKerenelElements(kernelAsArr);
ui.updateWeightDescription(weightsList);
});
}
}
});
2.4.3. 解释性的注意事项
在 图 2.13 中的权重讲述了一个故事。作为人类读者,你可能会看到这个并说这个模型已经学会了“每栋房子的房间数”特征与价格输出呈正相关,或者房地产的 AGE 特征,由于其较低的绝对大小而未列出,比这前五个特征的重要性要低。由于我们的大脑喜欢讲故事的方式,很容易就把这些数字说得比证据支持的要多。例如,如果两个输入特征强相关,这种分析的一种失败方式是。
考虑一个假想的例子,其中相同的特征被意外地包含了两次。称它们为 FEAT1 和 FEAT2。假设学习到的两个特征的权重分别为 10 和 -5。你可能会倾向于认为增加 FEAT1 会导致输出增加,而 FEAT2 则相反。然而,由于这些特征是等价的,如果权重反转,模型将输出完全相同的值。
还有一个需要注意的地方是相关性与因果关系之间的区别。想象一个简单的模型,我们希望根据屋顶的湿度来预测外面下雨的程度。如果我们有一个屋顶湿度的测量值,我们可能可以预测过去一小时下了多少雨。但是,我们不能够向传感器泼水来制造雨!
练习
-
在 2.1 节 中的硬编码时间估计问题之所以被选中,是因为数据大致上是线性的。其他数据集在拟合过程中将有不同的损失曲面和动态。您可能希望在这里尝试替换自己的数据,以探索模型的反应。您可能需要调整学习率、初始化或规范化来使模型收敛到一些有趣的东西。
-
在 2.3.5 节 中,我们花了一些时间描述为什么归一化很重要以及如何将输入数据归一化为零均值和单位方差。你应该能够修改示例以去除归一化,并看到模型不再训练。你还应该能够修改归一化例程,例如,使均值不为 0 或标准偏差较低,但不是很低。有些归一化方法会奏效,有些会导致模型永远不收敛。
-
众所周知,波士顿房价数据集的一些特征比其他特征更具有预测性。一些特征只是噪声,意味着它们不携带有用于预测房价的信息。如果我们只移除一个特征,我们应该保留哪个特征?如果我们要保留两个特征:我们该如何选择?尝试使用波士顿房价示例中的代码来探索这个问题。
-
描述梯度下降如何通过以优于随机的方式更新权重来优化模型。
-
波士顿房价示例打印出了绝对值最大的五个权重。尝试修改代码以打印与小权重相关联的特征。你能想象为什么这些权重很小吗?如果有人问你这些权重为什么是什么,你可以告诉他们什么?你会告诉那个人如何解释这些值的时候要注意什么?
总结
-
使用 TensorFlow.js 在五行 JavaScript 中构建、训练和评估一个简单的机器学习模型非常简单。
-
梯度下降,深度学习背后的基本算法结构,从概念上来说很简单,实际上只是指反复以小步骤更新模型参数,以使模型拟合最佳方向的计算方向。
-
模型的损失曲面展示了模型在一系列参数值的拟合程度。损失曲面通常无法计算,因为参数空间的维数很高,但思考一下并对机器学习的工作方式有直观的理解是很有意义的。
-
一个单独的密集层足以解决一些简单的问题,并且在房地产定价问题上可以获得合理的性能。
第三章:添加非线性:超越加权和
本章内容
-
什么是非线性,神经网络隐藏层中的非线性如何增强网络的容量并导致更好的预测准确性
-
超参数是什么,以及调整它们的方法
-
通过在输出层引入非线性进行二分类,以钓鱼网站检测示例为例介绍
-
多类分类以及它与二分类的区别,以鸢尾花示例介绍
在本章中,您将在第二章中奠定的基础上,允许您的神经网络学习更复杂的映射,从特征到标签。我们将介绍的主要增强是非线性——一种输入和输出之间的映射,它不是输入元素的简单加权和。非线性增强了神经网络的表征能力,并且当正确使用时,在许多问题上提高了预测准确性。我们将继续使用波士顿房屋数据集来说明这一点。此外,本章还将更深入地研究过拟合和欠拟合,以帮助您训练模型,这些模型不仅在训练数据上表现良好,而且在模型训练过程中没有见过的数据上达到良好的准确性,这才是模型质量的最终标准。
3.1. 非线性:它是什么,它有什么用处
让我们从上一章的波士顿房屋示例中继续进行。使用一个密集层,您看到训练模型导致的 MSE 对应于大约 5000 美元的误差估计。我们能做得更好吗?答案是肯定的。为了创建一个更好的波士顿房屋数据模型,我们为其添加了一个更多的密集层,如以下代码列表所示(来自波士顿房屋示例的 index.js)。
列表 3.1. 定义波士顿房屋问题的两层神经网络
export function multiLayerPerceptronRegressionModel1Hidden() {
const model = tf.sequential();
model.add(tf.layers.dense({
inputShape: [bostonData.numFeatures],
units: 50,
activation: 'sigmoid',
kernelInitializer: 'leCunNormal' ***1***
}));
model.add(tf.layers.dense({units: 1})); ***2***
model.summary(); ***3***
return model;
};
-
1 指定了如何初始化内核值;参见 3.1.2 节讨论通过超参数优化选择的方式。
-
2 添加一个隐藏层
-
3 打印模型拓扑结构的文本摘要
要查看此模型的运行情况,请首先运行yarn && yarn watch命令,如第二章中所述。一旦网页打开,请点击 UI 中的 Train Neural Network Regressor (1 Hidden Layer)按钮,以开始模型的训练。
模型是一个双层网络。第一层是一个具有 50 个单元的稠密层。它也配置了自定义激活函数和内核初始化程序,我们将在第 3.1.2 节讨论。这一层是一个隐藏层,因为其输出不是直接从模型外部看到的。第二层是一个具有默认激活函数(线性激活)的稠密层,结构上与我们在第二章使用的纯线性模型中使用的同一层一样。这一层是一个输出层,因为其输出是模型的最终输出,并且是模型的predict()方法返回的内容。您可能已经注意到代码中的函数名称将模型称为多层感知器(MLP)。这是一个经常使用的术语,用来描述神经网络,其 1)拥有没有回路的简单拓扑结构(所谓前馈神经网络)和 2)至少有一层隐藏层。本章中您将看到的所有模型都符合这一定义。
清单 3.1 中的model.summary()调用是新的。这是一个诊断/报告工具,将 TensorFlow.js 模型的拓扑结构打印到控制台(在浏览器的开发者工具中或在 Node.js 的标准输出中)。以下是双层模型生成的结果:
_________________________________________________________________
Layer (type) Output shape Param #
=================================================================
dense_Dense1 (Dense) [null,50] 650
_________________________________________________________________
dense_Dense2 (Dense) [null,1] 51
=================================================================
Total params: 701
Trainable params: 701
Non-trainable params: 0
摘要中的关键信息包括:
-
层的名称和类型(第一列)。
-
每一层的输出形状(第二列)。这些形状几乎总是包含一个空维度作为第一(批处理)维度,代表着不确定和可变大小的批处理。
-
每层的权重参数数量(第三列)。这是一个计算各层权重的所有个别数量的计数。对于具有多个权重的层,这是跨所有权重求和。例如,本例中的第一个稠密层包含两个权重:形状为
[12, 50]的内核和形状为[50]的偏置,导致12 * 50 + 50 = 650个参数。 -
模型的总权重参数数量(摘要底部),以及参数中可训练和不可训练的数量。到目前为止,我们看到的模型仅包含可训练参数,这些参数属于模型权重,在调用
tf.Model.fit()时更新。在第五章讨论迁移学习和模型微调时,我们将讨论不可训练权重。
来自第二章纯线性模型的model.summary()输出如下。与线性模型相比,我们的双层模型包含大约 54 倍的权重参数。大部分额外权重来自于添加的隐藏层:
_________________________________________________________________
Layer (type) Output shape Param #
=================================================================
dense_Dense3 (Dense) [null,1] 13
=================================================================
Total params: 13
Trainable params: 13
Non-trainable params: 0
因为两层模型包含更多层和权重参数,其训练和推断消耗更多的计算资源和时间。增加的成本是否值得准确度的提高?当我们为这个模型训练 200 个 epochs 时,我们得到的最终 MSE 在测试集上落在 14-15 的范围内(由于初始化的随机性而产生的变异性),相比之下,线性模型的测试集损失约为 25。我们的新模型最终的误差为美元 3,700-3,900,而纯线性尝试的误差约为 5,000 美元。这是一个显著的改进。
3.1.1. 建立神经网络非线性的直觉
为什么准确度会提高呢?关键在于模型的增强复杂性,正如图 3.1 所示。首先,有一个额外的神经元层,即隐藏层。其次,隐藏层包含一个非线性的激活函数(在代码中指定为activation: 'sigmoid'),在图 3.1 的面板 B 中用方框表示。激活函数^([1])是逐元素的转换。sigmoid 函数是一种“压缩”非线性,它“压缩”了所有从负无穷到正无穷的实数值到一个更小的范围(在本例中是 0 到+1)。它的数学方程和图表如图 3.2 所示。让我们以隐藏的稠密层为例。假设矩阵乘法和加法的结果与偏差的结果是一个由以下随机值数组组成的 2D 张量:
¹
激活函数这个术语来源于对生物神经元的研究,它们通过动作电位(细胞膜上的电压尖峰)相互通信。一个典型的生物神经元从多个上游神经元接收输入,通过称为突触的接触点。上游神经元以不同的速率发出动作电位,这导致神经递质的释放和突触上离子通道的开闭。这反过来导致了接收神经元膜上的电压变化。这与稠密层中的单位所见到的加权和有些相似。只有当电位超过一定的阈值时,接收神经元才会实际产生动作电位(即被“激活”),从而影响下游神经元的状态。在这个意义上,典型生物神经元的激活函数与 relu 函数(图 3.2,右面板)有些相似,它在输入的某个阈值以下有一个“死区”,并且随着输入在阈值以上的增加而线性增加(至少到达某个饱和水平,这并不被 relu 函数所捕捉)。
[[1.0], [0.5], ..., [0.0]],
图 3.1。为波士顿住房数据集创建的线性回归模型(面板 A)和两层神经网络(面板 B)。为了清晰起见,在面板 B 中,我们将输入特征的数量从 12 个减少到 3 个,并将隐藏层的单元数量从 50 个减少到 5 个。每个模型只有一个输出单元,因为这些模型解决单变量(单目标数值)回归问题。面板 B 描绘了模型隐藏层的非线性(sigmoid)激活。

然后,通过将 sigmoid(S)函数应用于每个元素的 50 个元素中的每一个,得到密集层的最终输出,如下所示:
[[S(1.0)], [S(0.5)], ..., [S(0.0)]] = [[0.731], [0.622], ..., [0.0]]
为什么这个函数被称为非线性?直观地说,激活函数的图形不是一条直线。例如,sigmoid 是一条曲线(图 3.2,左侧面板),而 relu 是两条线段的拼接(图 3.2,右侧面板)。尽管 sigmoid 和 relu 是非线性的,但它们的一个特性是它们在每个点上都是平滑且可微的,这使得可以通过它们进行反向传播^([2])。如果没有这个特性,就不可能训练包含这种激活函数的层的模型。
²
如果需要回顾反向传播,请参阅第 2.2.2 节。
图 3.2。用于深度神经网络的两个常用非线性激活函数。左:sigmoid 函数 S(x) = 1 / (1 + e ^ -x)。右:修正线性单元(relu)函数 relu(x) = {0:x < 0, x:x >= 0}

除了 sigmoid 函数之外,在深度学习中还经常使用一些其他类型的可微非线性函数。其中包括 relu 和双曲正切函数(tanh)。在后续的例子中遇到它们时,我们将对它们进行详细描述。
非线性和模型容量
为什么非线性能够提高我们模型的准确性?非线性函数使我们能够表示更多样化的输入-输出关系。现实世界中的许多关系大致是线性的,比如我们在上一章中看到的下载时间问题。但是,还有许多其他关系不是线性的。很容易构想出非线性关系的例子。考虑一个人的身高与年龄之间的关系。身高仅在某一点之前大致与年龄线性变化,之后会弯曲并趋于稳定。另一个完全合理的情景是,房价可以与社区犯罪率呈负相关,但前提是犯罪率在某一范围内。一个纯线性模型,就像我们在上一章中开发的模型一样,无法准确地建模这种类型的关系,而 sigmoid 非线性则更适合于建模这种关系。当然,犯罪率-房价关系更像是一个倒置的(下降的)sigmoid 函数,而不是左侧面板中原始的增长函数。但是我们的神经网络可以毫无问题地建模这种关系,因为 sigmoid 激活前后都是由可调节权重的线性函数。
但是,通过将线性激活替换为非线性激活(比如 sigmoid),我们会失去学习数据中可能存在的任何线性关系的能力吗?幸运的是,答案是否定的。这是因为 sigmoid 函数的一部分(靠近中心的部分)非常接近一条直线。其他经常使用的非线性激活函数,比如 tanh 和 relu,也包含线性或接近线性的部分。如果输入的某些元素与输出的某些元素之间的关系大致是线性的,那么一个带有非线性激活函数的密集层完全可以学习到使用激活函数的接近线性部分的正确权重和偏差。因此,向密集层添加非线性激活会导致它能够学习的输入-输出关系的广度增加。
此外,非线性函数与线性函数不同之处在于级联非线性函数会导致更丰富的非线性函数集合。这里,“级联”是指将一个函数的输出作为另一个函数的输入。假设有两个线性函数,
f(x) = k1 * x + b1
和
g(x) = k2 * x + b2
级联两个函数等同于定义一个新函数h:
h(x) = g(f(x)) = k2 * (k1 * x + b1) + b2 = (k2 * k1) * x + (k2 * b1 + b2)
如您所见,h仍然是一个线性函数。它的核(斜率)和偏差(截距)与f1和f2的不同。斜率现在是(k2 * k1),偏差现在是(k2 * b1 + b2)。级联任意数量的线性函数始终会产生一个线性函数。
但是,请考虑一个经常使用的非线性激活函数:relu。在图 3.3 的底部,我们说明了当您级联两个具有线性缩放的 relu 函数时会发生什么。通过级联两个缩放的 relu 函数,我们得到一个看起来根本不像 relu 的函数。它具有一个新形状(在这种情况下,是由两个平坦部分包围的向下倾斜的部分)。进一步级联阶跃函数与其他 relu 函数将得到一组更多样化的函数,例如“窗口”函数,由多个窗口组成的函数,窗口叠加在更宽的窗口上的函数等(未显示在图 3.3 中)。通过级联 relu 等非线性函数,您可以创建出非常丰富的一系列函数形状。但这与神经网络有什么关系呢?实质上,神经网络是级联函数。神经网络的每一层都可以看作是一个函数,而将这些层堆叠起来就相当于级联这些函数,形成更复杂的函数,即神经网络本身。这应该清楚地说明为什么包含非线性激活函数会增加模型能够学习的输入-输出关系范围。这也让你直观地理解了常用技巧“向深度神经网络添加更多层”以及为什么它通常(但并非总是!)会导致更能拟合数据集的模型。
图 3.3。级联线性函数(顶部)和非线性函数(底部)。级联线性函数总是导致线性函数,尽管具有新的斜率和截距。级联非线性函数(例如 relu 在本例中)会导致具有新形状的非线性函数,例如本例中的“向下阶跃”函数。这说明了为什么在神经网络中使用非线性激活函数以及级联它们会导致增强的表示能力(即容量)。

机器学习模型能够学习的输入-输出关系范围通常被称为模型的容量。从先前关于非线性的讨论中,我们可以看出,具有隐藏层和非线性激活函数的神经网络与线性回归器相比具有更大的容量。这就解释了为什么我们的两层网络在测试集准确度方面比线性回归模型表现出更好的效果。
你可能会问,由于级联非线性激活函数会导致更大的容量(如图 3.3 的底部所示),我们是否可以通过向神经网络添加更多的隐藏层来获得更好的波士顿房价问题模型?multiLayerPerceptronRegressionModel2Hidden()函数位于 index.js 中,它连接到标题为训练神经网络回归器(2 个隐藏层)的按钮。该函数确实执行了这样的操作。请参阅以下代码摘录(来自波士顿房价示例的 index.js)。
列表 3.2. 为波士顿房屋问题定义一个三层神经网络
export function multiLayerPerceptronRegressionModel2Hidden() {
const model = tf.sequential();
model.add(tf.layers.dense({ ***1***
inputShape: [bostonData.numFeatures], ***1***
units: 50, ***1***
activation: 'sigmoid', ***1***
kernelInitializer: 'leCunNormal' ***1***
})); ***1***
model.add(tf.layers.dense({ ***2***
units: 50, ***2***
activation: 'sigmoid', ***2***
kernelInitializer: 'leCunNormal' ***2***
})); ***2***
model.add(tf.layers.dense({units: 1}));
model.summary(); ***3***
return model;
};
-
1 添加第一个隐藏层
-
2 添加另一个隐藏层
-
3 展示模型拓扑的文本摘要
在summary()打印输出中(未显示),你可以看到该模型包含三层——比 列表 3.1 中的模型多一层。它也具有显著更多的参数:3,251 个,相比两层模型中的 701 个。额外的 2,550 个权重参数是由于包括了第二个隐藏层造成的,它由形状为[50, 50]的内核和形状为[50]的偏差组成。
重复训练模型多次,我们可以对三层网络最终测试集(即评估)MSE 的范围有所了解:大致为 10.8–13.4。这相当于对$3,280–$3,660 的误估,超过了两层网络的$3,700–$3,900。因此,我们通过添加非线性隐藏层再次提高了模型的预测准确性,增强了其容量。
避免将层堆叠而没有非线性的谬误
另一种看到非线性激活对改进波士顿房屋模型的重要性的方式是将其从模型中移除。列表 3.3 与 列表 3.1 相同,只是注释掉了指定 S 型激活函数的一行。移除自定义激活会导致该层具有默认的线性激活。模型的其他方面,包括层数和权重参数数量,都不会改变。
列表 3.3. 没有非线性激活的两层神经网络
export function multiLayerPerceptronRegressionModel1Hidden() {
const model = tf.sequential();
model.add(tf.layers.dense({
inputShape: [bostonData.numFeatures],
units: 50,
// activation: 'sigmoid', ***1***
kernelInitializer: 'leCunNormal'
}));
model.add(tf.layers.dense({units: 1}));
model.summary();
return model;
};
- 1 禁用非线性激活函数
这种改变如何影响模型的学习?通过再次点击 UI 中的 Train Neural Network Regressor(1 Hidden Layer)按钮,你可以得知测试集上的 MSE 上升到约 25,而当 S 型激活包含时大约为 14–15 的范围。换句话说,没有 S 型激活的两层模型表现与一层线性回归器大致相同!
这证实了我们关于级联线性函数的推理。通过从第一层中移除非线性激活,我们最终得到了一个两个线性函数级联的模型。正如我们之前展示的,结果是另一个线性函数,而模型的容量没有增加。因此,我们最终的准确性与线性模型大致相同并不奇怪。这提出了构建多层神经网络的常见“陷阱”:一定要在隐藏层中包括非线性激活。没有这样做会导致计算资源和时间的浪费,并有潜在的增加数值不稳定性(观察 图 3.4 的面板 B 中更加不稳定的损失曲线)。稍后,我们将看到这不仅适用于密集层,还适用于其他层类型,如卷积层。
图 3.4. 比较使用(面板 A)和不使用(面板 B)Sigmoid 激活的训练结果。请注意,去除 Sigmoid 激活会导致训练、验证和评估集上的最终损失值更高(与之前的纯线性模型相当)且损失曲线不够平滑。请注意,两个图之间的 y 轴刻度是不同的。

非线性和模型可解释性
在第二章中,我们展示了一旦在波士顿房屋数据集上训练了一个线性模型,我们就可以检查其权重并以相当有意义的方式解释其各个参数。例如,与“每个住宅的平均房间数”特征相对应的权重具有正值,而与“犯罪率”特征相对应的权重具有负值。这些权重的符号反映了房价与相应特征之间的预期正相关或负相关关系。它们的大小也暗示了模型对各种特征的相对重要性。鉴于您刚刚在本章学到的内容,一个自然的问题是:使用一个或多个隐藏层的非线性模型,是否仍然可能提出可理解和直观的权重值解释?
访问权重值的 API 在非线性模型和线性模型之间完全相同:您只需在模型对象或其组成层对象上使用getWeights()方法。以清单 3.1 中的 MLP 为例——您可以在模型训练完成后(model.fit()调用之后)插入以下行:
model.layers[0].getWeights()[0].print();
这行打印了第一层(即隐藏层)的核心值。这是模型中的四个权重张量之一,另外三个是隐藏层的偏置和输出层的核心和偏置。关于打印输出的一件事值得注意的是,它的大小比我们打印线性模型的核心时要大:
Tensor
[[-0.5701274, -0.1643915, -0.0009151, ..., 0.313205 , -0.3253246],
[-0.4400523, -0.0081632, -0.2673715, ..., 0.1735748 , 0.0864024 ],
[0.6294659 , 0.1240944 , -0.2472516, ..., 0.2181769 , 0.1706504 ],
[0.9084488 , 0.0130388 , -0.3142847, ..., 0.4063887 , 0.2205501 ],
[0.431214 , -0.5040522, 0.1784604 , ..., 0.3022115 , -0.1997144],
[-0.9726604, -0.173905 , 0.8167523 , ..., -0.0406454, -0.4347956],
[-0.2426955, 0.3274118 , -0.3496988, ..., 0.5623314 , 0.2339328 ],
[-1.6335299, -1.1270424, 0.618491 , ..., -0.0868887, -0.4149215],
[-0.1577617, 0.4981289 , -0.1368523, ..., 0.3636355 , -0.0784487],
[-0.5824679, -0.1883982, -0.4883655, ..., 0.0026836 , -0.0549298],
[-0.6993552, -0.1317919, -0.4666585, ..., 0.2831602 , -0.2487895],
[0.0448515 , -0.6925298, 0.4945385 , ..., -0.3133179, -0.0241681]]
这是因为隐藏层由 50 个单元组成,导致权重大小为[18, 50]。与线性模型的核心中的12 + 1 = 13个参数相比,该核心有 900 个单独的权重参数。我们能赋予每个单独的权重参数一定的含义吗?一般来说,答案是否定的。这是因为从隐藏层的 50 个输出中很难找到任何一个的明显含义。这些是高维空间的维度,使模型能够学习(自动发现)其中的非线性关系。人类大脑在跟踪这种高维空间中的非线性关系方面并不擅长。一般来说,很难用通俗易懂的几句话来描述隐藏层每个单元的作用,或者解释它如何对深度神经网络的最终预测做出贡献。
此处的模型只有一个隐藏层。当有多个隐藏层堆叠在一起时(就像在清单 3.2 中定义的模型中一样),关系变得更加模糊和更难描述。尽管有研究努力寻找解释深度神经网络隐藏层含义的更好方法,([3])并且针对某些类别的模型正在取得进展,([4])但可以说,深度神经网络比浅层神经网络和某些类型的非神经网络机器学习模型(如决策树)更难解释。通过选择深度模型而不是浅层模型,我们基本上是在为更大的模型容量交换一些可解释性。
³
Marco Tulio Ribeiro,Sameer Singh 和 Carlos Guestrin,“局部可解释的模型无关解释(LIME):简介”,O’Reilly,2016 年 8 月 12 日,
mng.bz/j5vP。⁴
Chris Olah 等,“可解释性的基本构建块”,Distill,2018 年 3 月 6 日,
distill.pub/2018/building-blocks/。
3.1.2. 超参数和超参数优化
我们在清单 3.1 和 3.2 中对隐藏层的讨论一直侧重于非线性激活(sigmoid)。然而,该层的其他配置参数对于确保模型的良好训练结果也很重要。这些包括单位数量(50)和内核的 'leCunNormal' 初始化。后者是根据输入的大小生成进入内核初始值的随机数的特殊方式。它与默认的内核初始化器('glorotNormal')不同,后者使用输入和输出的大小。自然的问题是:为什么使用这个特定的自定义内核初始化器而不是默认的?为什么使用 50 个单位(而不是,比如,30 个)?通过反复尝试各种参数组合,这些选择是为了确保通过尽可能多地尝试各种参数组合获得最佳或接近最佳的模型质量。
参数,如单位数量、内核初始化器和激活函数,是模型的超参数。名称“超参数”表明这些参数与模型的权重参数不同,后者在训练期间通过反向传播自动更新(即,Model.fit() 调用)。一旦为模型选择了超参数,它们在训练过程中不会改变。它们通常确定权重参数的数量和大小(例如,考虑密集层的 units 字段)、权重参数的初始值(考虑 kernelInitializer 字段)以及它们在训练期间如何更新(考虑传递给 Model.compile() 的 optimizer 字段)。因此,它们位于高于权重参数的层次上。因此得名“超参数”。
除了层的大小和权重初始化器的类型之外,模型及其训练还有许多其他类型的超参数,例如
-
模型中的密集层数量,比如 listings 3.1 和 3.2 中的那些
-
用于密集层核的初始化器的类型
-
是否使用任何的权重正则化(参见第 8.1 节),如果是,则是正则化因子
-
是否包括任何的 dropout 层(例如,参见第 4.3.2 节),如果是,则是多少的 dropout 率
-
用于训练的优化器的类型(例如,
'sgd'与'adam'之间的区别;参见 info box 3.1) -
训练模型的时期数是多少
-
优化器的学习率
-
是否应该随着训练的进行逐渐减小优化器的学习率,如果是,以什么速度
-
训练的批次大小
列出的最后五个例子有些特殊,因为它们与模型本身的架构无关;相反,它们是模型训练过程的配置。然而,它们会影响训练的结果,因此被视为超参数。对于包含更多不同类型层的模型(例如,在第四章、第五章和第九章中讨论的卷积和循环层),还有更多可能可调整的超参数。因此,即使是一个简单的深度学习模型可能也有几十个可调整的超参数是很清楚的。
选择良好的超参数值的过程称为超参数优化或超参数调整。超参数优化的目标是找到一组参数,使训练后验证损失最低。不幸的是,目前没有一种确定的算法可以确定给定数据集和涉及的机器学习任务的最佳超参数。困难在于许多超参数是离散的,因此验证损失值对它们不是可微的。例如,密集层中的单元数和模型中的密集层数是整数;优化器的类型是一个分类参数。即使对于那些是连续的超参数(例如,正则化因子),对它们进行训练期间的梯度跟踪通常也是计算上过于昂贵的,因此在这些超参数空间中执行梯度下降实际上并不可行。超参数优化仍然是一个活跃的研究领域,深度学习从业者应该注意。
鉴于缺乏一种标准的、开箱即用的超参数优化方法或工具,深度学习从业者通常采用以下三种方法。首先,如果手头的问题类似于一个经过深入研究的问题(比如,你可以在本书中找到的任何示例),你可以开始应用类似的模型来解决你的问题,并“继承”超参数。稍后,你可以在以该起点为中心的相对较小的超参数空间中进行搜索。
其次,有足够经验的从业者可能对于给定问题的合理良好的超参数有直觉和教育性的猜测。即使是这样主观的选择几乎从来都不是最佳的——它们形成了良好的起点,并且可以促进后续的微调。
第三,对于只有少量需要优化的超参数的情况(例如少于四个),我们可以使用格点搜索——即,穷举地迭代一些超参数组合,对每一个组合训练一个模型至完成,记录验证损失,并取得验证损失最低的超参数组合。例如,假设唯一需要调整的两个超参数是 1)密集层中的单元数和 2)学习率;你可以选择一组单元({10, 20, 50, 100, 200})和一组学习率({1e-5, 1e-4, 1e-3, 1e-2}),并对两组进行交叉,从而得到一共5 * 4 = 20个要搜索的超参数组合。如果你要自己实现格点搜索,伪代码可能看起来像以下清单。
清单 3.4. 用于简单超参数格点搜索的伪代码
function hyperparameterGridSearch():
for units of [10, 20, 50, 100, 200]:
for learningRate of [1e-5, 1e-4, 1e-3, 1e-2]:
Create a model using whose dense layer consists of `units` units
Train the model with an optimizer with `learningRate`
Calculate final validation loss as validationLoss
if validationLoss < minValidationLoss
minValidationLoss := validationLoss
bestUnits := units
bestLearningRate := learningRate
return [bestUnits, bestLearningRate]
这些超参数的范围是如何选择的?嗯,深度学习无法提供正式答案的另一个地方。这些范围通常基于深度学习从业者的经验和直觉。它们也可能受到计算资源的限制。例如,一个单位过多的密集层可能导致模型训练过程太慢或推断时运行太慢。
通常情况下,需要优化的超参数数量较多,以至于在指数增长的超参数组合数量上进行搜索变得计算上过于昂贵。在这种情况下,应该使用比格点搜索更复杂的方法,如随机搜索([5])和贝叶斯([6])方法。
⁵
James Bergstra 和 Yoshua Bengio,“超参数优化的随机搜索”,机器学习研究杂志,2012 年,第 13 卷,第 281–305 页,
mng.bz/WOg1。⁶
Will Koehrsen,“贝叶斯超参数优化的概念解释”,Towards Data Science,2018 年 6 月 24 日,
mng.bz/8zQw。
3.2. 输出的非线性:用于分类的模型
我们到目前为止看到的两个例子都是回归任务,我们试图预测一个数值(如下载时间或平均房价)。然而,机器学习中另一个常见的任务是分类。一些分类任务是二元分类,其中目标是对一个是/否问题的答案。技术世界充满了这种类型的问题,包括
-
是否给定的电子邮件是垃圾邮件
-
是否给定的信用卡交易是合法的还是欺诈的
-
是否给定的一秒钟音频样本包含特定的口语单词
-
两个指纹图像是否匹配(来自同一个人的同一个手指)
另一种分类问题是多类别分类任务,对此类任务也有很多例子:
-
一篇新闻文章是关于体育、天气、游戏、政治还是其他一般话题
-
一幅图片是猫、狗、铲子等等
-
给定电子笔的笔触数据,确定手写字符是什么
-
在使用机器学习玩一个类似 Atari 的简单视频游戏的场景中,确定游戏角色应该向四个可能的方向之一(上、下、左、右)前进,给定游戏的当前状态
3.2.1. 什么是二元分类?
我们将从一个简单的二元分类案例开始。给定一些数据,我们想要一个是/否的决定。对于我们的激励示例,我们将谈论钓鱼网站数据集。任务是,给定关于网页和其 URL 的一组特征,预测该网页是否用于钓鱼(伪装成另一个站点,目的是窃取用户的敏感信息)。
⁷
Rami M. Mohammad, Fadi Thabtah, 和 Lee McCluskey,“Phishing Websites Features,”
mng.bz/E1KO。
数据集包含 30 个特征,所有特征都是二元的(表示值为-1 和 1)或三元的(表示为-1、0 和 1)。与我们为波士顿房屋数据集列出所有单个特征不同,这里我们提供一些代表性的特征:
-
HAVING_IP_ADDRESS—是否使用 IP 地址作为域名的替代(二进制值:{-1, 1}) -
SHORTENING_SERVICE—是否使用 URL 缩短服务(二进制值:{1, -1}) -
SSLFINAL_STATE—URL 是否使用 HTTPS 并且发行者是受信任的,它是否使用 HTTPS 但发行者不受信任,或者没有使用 HTTPS(三元值:{-1, 0, 1})
数据集由大约 5500 个训练示例和相同数量的测试示例组成。在训练集中,大约有 45%的示例是正面的(真正的钓鱼网页)。在测试集中,正面示例的百分比大约是相同的。
这只是最容易处理的数据集类型——数据中的特征已经在一致的范围内,因此无需对其均值和标准偏差进行归一化,就像我们为波士顿房屋数据集所做的那样。此外,相对于特征数量和可能预测数量(两个——是或否),我们有大量的训练示例。总的来说,这是一个很好的健全性检查,表明这是一个我们可以处理的数据集。如果我们想要花更多时间研究我们的数据,我们可能会进行成对特征相关性检查,以了解是否有冗余信息;但是,这是我们的模型可以容忍的。
由于我们的数据与我们用于波士顿房屋(后归一化)的数据相似,我们的起始模型基于相同的结构。此问题的示例代码可在 tfjs-examples 存储库的 website-phishing 文件夹中找到。您可以按照以下方式查看和运行示例:
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/website-phishing
yarn && yarn watch
列表 3.5. 为钓鱼检测定义二分类模型(来自 index.js)
const model = tf.sequential();
model.add(tf.layers.dense({
inputShape: [data.numFeatures],
units: 100,
activation: 'sigmoid'
}));
model.add(tf.layers.dense({units: 100, activation: 'sigmoid'}));
model.add(tf.layers.dense({units: 1, activation: 'sigmoid'}));
model.compile({
optimizer: 'adam',
loss: 'binaryCrossentropy',
metrics: ['accuracy']
});
这个模型与我们为波士顿房屋问题构建的多层网络有很多相似之处。它以两个隐藏层开始,两者都使用 sigmoid 激活。最后(输出)有确切的 1 个单元,这意味着模型为每个输入示例输出一个数字。然而,这里的一个关键区别是,我们用于钓鱼检测的模型的最后一层具有 sigmoid 激活,而不是波士顿房屋模型中的默认线性激活。这意味着我们的模型受限于只能输出介于 0 和 1 之间的数字,这与波士顿房屋模型不同,后者可能输出任何浮点数。
之前,我们已经看到 sigmoid 激活对隐藏层有助于增加模型容量。但是为什么在这个新模型的输出处使用 sigmoid 激活?这与我们手头问题的二分类特性有关。对于二分类,我们通常希望模型产生正类别的概率猜测——也就是说,模型“认为”给定示例属于正类别的可能性有多大。您可能还记得高中数学中的知识,概率始终是介于 0 和 1 之间的数字。通过让模型始终输出估计的概率值,我们获得了两个好处:
-
它捕获了对分配的分类的支持程度。
sigmoid值为0.5表示完全不确定性,其中每个分类都得到了同等的支持。值为0.6表示虽然系统预测了正分类,但支持程度很低。值为0.99表示模型非常确定该示例属于正类,依此类推。因此,我们使得将模型的输出转换为最终答案变得简单而直观(例如,只需在给定值处对输出进行阈值处理,例如0.5)。现在想象一下,如果模型的输出范围可能变化很大,那么找到这样的阈值将会有多难。 -
我们还使得更容易构造一个可微的损失函数,它根据模型的输出和真实的二进制目标标签产生一个衡量模型错失程度的数字。至于后者,当我们检查该模型使用的实际二元交叉熵时,我们将会更详细地阐述。
但是,问题是如何将神经网络的输出强制限制在[0, 1]范围内。神经网络的最后一层通常是一个密集层,它对其输入执行矩阵乘法(matMul)和偏置加法(biasAdd)操作。在matMul或biasAdd操作中都没有固有的约束,以保证结果在[0, 1]范围内。将sigmoid等压缩非线性添加到matMul和biasAdd的结果中是实现[0, 1]范围的一种自然方法。
清单 3.5 中代码的另一个新方面是优化器的类型:'adam',它与之前示例中使用的'sgd'优化器不同。adam与sgd有何不同?正如你可能还记得上一章第 2.2.2 节所述,sgd优化器总是将通过反向传播获得的梯度乘以一个固定数字(学习率乘以-1)以计算模型权重的更新。这种方法有一些缺点,包括当选择较小的学习率时,收敛速度较慢,并且当损失(超)表面的形状具有某些特殊属性时,在权重空间中出现“之”形路径。adam优化器旨在通过以一种智能方式使用梯度的历史(来自先前的训练迭代)的乘法因子来解决这些sgd的缺点。此外,它对不同的模型权重参数使用不同的乘法因子。因此,与一系列深度学习模型类型相比,adam通常导致更好的收敛性和对学习率选择的依赖性较小;因此,它是优化器的流行选择。TensorFlow.js 库提供了许多其他优化器类型,其中一些也很受欢迎(如rmsprop)。信息框 3.1 中的表格提供了它们的简要概述。
TensorFlow.js 支持的优化器
下表总结了 TensorFlow.js 中最常用类型的优化器的 API,以及对每个优化器的简单直观解释。
TensorFlow.js 中常用的优化器及其 API
| 名称 | API(字符串) | API(函数) | 描述 |
|---|---|---|---|
| 随机梯度下降(SGD) | 'sgd' | tf.train.sgd | 最简单的优化器,始终使用学习率作为梯度的乘子 |
| Momentum | 'momentum' | tf.train.momentum | 以一种方式累积过去的梯度,使得对于某个权重参数的更新在过去的梯度更多地朝着同一方向时变得更快,并且当它们在方向上发生大变化时变得更慢 |
| RMSProp | 'rmsprop' | tf.train.rmsprop | 通过跟踪模型不同权重参数的最近梯度的均方根(RMS)值的历史记录,为不同的权重参数设置不同的乘法因子;因此得名 |
| AdaDelta | 'adadelta' | tf.train.adadelta | 类似于 RMSProp,以一种类似的方式为每个单独的权重参数调整学习率 |
| ADAM | 'adam' | tf.train.adam | 可以理解为 AdaDelta 的自适应学习率方法和动量方法的结合 |
| AdaMax | 'adamax' | tf.train.adamax | 类似于 ADAM,但使用稍微不同的算法跟踪梯度的幅度 |
一个明显的问题是,针对你正在处理的机器学习问题和模型,应该使用哪种优化器。不幸的是,在深度学习领域尚无共识(这就是为什么 TensorFlow.js 提供了上表中列出的所有优化器!)。在实践中,你应该从流行的优化器开始,包括 adam 和 rmsprop。在有足够的时间和计算资源的情况下,你还可以将优化器视为超参数,并通过超参数调整找到为你提供最佳训练结果的选择(参见 section 3.1.2)。
3.2.2. 衡量二元分类器的质量:准确率、召回率、准确度和 ROC 曲线
在二元分类问题中,我们发出两个值之一——0/1、是/否等等。在更抽象的意义上,我们将讨论正例和负例。当我们的网络进行猜测时,它要么正确要么错误,所以我们有四种可能的情况,即输入示例的实际标签和网络输出,如 table 3.1 所示。
表 3.1. 二元分类问题中的四种分类结果类型
| 预测 | ||
|---|---|---|
| 正类 | ||
| 正类 | 真正例(TP) | |
| 负类 | 假正例(FP) |
真正的正例(TP)和真正的负例(TN)是模型预测出正确答案的地方;假正例(FP)和假负例(FN)是模型出错的地方。如果我们用计数填充这四个单元格,我们就得到了一个混淆矩阵;表 3.2 显示了我们钓鱼检测问题的一个假设性混淆矩阵。
表 3.2. 一个假设的二元分类问题的混淆矩阵
| 预测 | ||
|---|---|---|
| 正例 | ||
| 正例 | 4 | |
| 负例 | 1 |
在我们假设的钓鱼示例结果中,我们看到我们正确识别了四个钓鱼网页,漏掉了两个,而且有一个误报。现在让我们来看看用于表达这种性能的不同常见指标。
准确率是最简单的度量标准。它量化了多少百分比的示例被正确分类:
Accuracy = (#TP + #TN) / #examples = (#TP + #TN) / (#TP + #TN + #FP + #FN)
在我们特定的例子中,
Accuracy = (4 + 93) / 100 = 97%
准确率是一个易于沟通和易于理解的概念。然而,它可能会具有误导性——在二元分类任务中,我们通常没有相等分布的正负例。我们通常处于这样的情况:正例要远远少于负例(例如,大多数链接不是钓鱼网站,大多数零件不是有缺陷的,等等)。如果 100 个链接中只有 5 个是钓鱼的,我们的网络可以总是预测为假,并获得 95% 的准确率!这样看来,准确率似乎是我们系统的一个非常糟糕的度量。高准确率听起来总是很好,但通常会误导人。监视准确率是件好事,但作为损失函数使用则是一件非常糟糕的事情。
下一对指标试图捕捉准确率中缺失的微妙之处——精确率和召回率。在接下来的讨论中,我们通常考虑的是一个正例意味着需要进一步的行动——一个链接被标记,一篇帖子被标记为需要手动审查——而负例表示现状不变。这些指标专注于我们的预测可能出现的不同类型的“错误”。
精确率是模型预测的正例中实际为正例的比率:
precision = #TP / (#TP + #FP)
根据我们混淆矩阵的数字,我们将计算
precision = 4 / (4 + 1) = 80%
与准确率类似,通常可以操纵精确率。例如,您可以通过仅将具有非常高 S 型输出(例如 >0.95,而不是默认的 >0.5)的输入示例标记为正例,从而使您的模型非常保守地发出正面预测。这通常会导致精确率提高,但这样做可能会导致模型错过许多实际的正例(将它们标记为负例)。这最后一个成本被常与精确率配合使用并补充的度量所捕获,即召回率。
召回率是模型将实际正例分类为正例的比率:
recall = #TP / (#TP + #FN)
根据示例数据,我们得到了一个结果
recall = 4 / (4 + 2) = 66.7%
在样本集中所有阳性样本中,模型发现了多少个?通常会有一个有意识的决定,即接受较高的误报率以降低遗漏的可能性。为了优化这一指标,你可以简单地声明所有样本为阳性;由于假阳性不进入计算,因此你可以在降低精确度的代价下获得 100%的召回率。
我们可以看到,制作一个在准确度、召回率或精确度上表现出色的系统相当容易。在现实世界中的二元分类问题中,同时获得良好的精确度和召回率通常很困难。(如果这样做很容易,你就会面临一个简单的问题,可能根本不需要使用机器学习。)精确度和召回率涉及在对正确答案存在根本不确定的复杂区域调整模型。你会看到更多细致和组合的指标,如在 X%召回率下的精确度,其中 X 通常为 90%——如果我们调整到至少发现 X%的阳性样本,精确度是多少?例如,在图 3.5 中,我们看到经过 400 个轮次的训练后,当模型的概率输出门槛设为 0.5 时,我们的钓鱼检测模型能够达到 96.8%的精确度和 92.9%的召回率。
图 3.5。训练模型用于钓鱼网页检测的一轮结果示例。注意底部的各种指标:精确度、召回率和 FPR。曲线下面积(AUC)在 3.2.3 节中讨论。

如我们已略有提及的,一个重要的认识是,对正预测的选择,不需要在 sigmoid 输出上设置恰好为 0.5 的门槛。事实上,根据情况,它可能最好设定为 0.5 以上(但小于 1)或 0.5 以下(但大于 0)。降低门槛使模型在将输入标记为阳性时更加自由,这会导致更高的召回率但可能降低精确度。另一方面,提高门槛使模型在将输入标记为阳性时更加谨慎,通常会导致更高的精确度但可能降低召回率。因此,我们可以看到精确度和召回率之间存在权衡,这种权衡很难用我们迄今讨论过的任何一种指标来量化。幸运的是,二元分类研究的丰富历史为我们提供了更好的方式来量化和可视化这种权衡关系。我们接下来将讨论的 ROC 曲线是这种常用的工具之一。
3.2.3。ROC 曲线:展示二元分类中的权衡
ROC 曲线被用于广泛的工程问题,其中包括二分类或特定类型事件的检测。全名“接收者操作特性”是一个来自雷达早期的术语。现在,你几乎看不到这个扩展名了。图 3.6 是我们应用程序的一个样本 ROC 曲线。
图 3.6. 在钓鱼检测模型训练期间绘制的一组样本 ROC 曲线。每条曲线对应不同的周期数。这些曲线显示了二分类模型随着训练的进展而逐渐改进的质量。

正如你可能已经在图 3.6 的坐标轴标签中注意到的,ROC 曲线并不是通过将精确度和召回率指标相互绘制得到的。相反,它们是基于两个稍微不同的指标。ROC 曲线的横轴是假阳性率(FPR),定义为
FPR = #FP / (#FP + #TN)
ROC 曲线的纵轴是真阳性率(TPR),定义为
TPR = #TP / (#TP + #FN) = recall
TPR 与召回率具有完全相同的定义,只是使用了不同的名称。然而,FPR 是一些新的东西。分母是实际类别为负的案例数量;分子是所有误报的数量。换句话说,FPR 是将实际上是负的案例错误分类为正的比例,这是一个常常被称为虚警(false alarm)的概率。表 3.3 总结了在二分类问题中遇到的最常见的指标。
表 3.3. 二分类问题中常见的指标
| 指标名称 | 定义 | ROC 曲线或精确度/召回率曲线中的使用方式 |
|---|---|---|
| 准确度(Accuracy) | (#TP + #TN) / (#TP + #TN + # FP + #FN) | (ROC 曲线中不使用) |
| 精确度(Precision) | #TP / (#TP + #FP) | 精确度/召回率曲线的纵轴 |
| 召回率/灵敏度/真阳性率(TPR) | #TP / (#TP + #FN) | ROC 曲线的纵轴(如图 3.6)或精确度/召回率曲线的横轴 |
| 假阳性率(False positive rate,FPR) | #FP / (#FP + #TN) | ROC 曲线的横轴(见图 3.6) |
| 曲线下面积(Area under the curve,AUC) | 将 ROC 曲线的数值积分计算得出;查看代码示例 3.7 以获取示例 | (ROC 曲线不使用,而是从 ROC 曲线计算得到) |
图 3.6 中的七条 ROC 曲线分别绘制于七个不同的训练周期的开头,从第一个周期 (周期 001) 到最后一个周期 (周期 400)。每条曲线都是基于模型在测试数据上的预测结果(而不是训练数据)创建的。代码清单 3.6 显示了如何利用 Model.fit() API 中的 onEpochBegin 回调函数详细实现此过程。这种方法使您可以在训练过程中执行有趣的分析和可视化,而不需要编写 for 循环或使用多个 Model.fit() 调用。
代码清单 3.6 使用回调函数在模型训练中间绘制 ROC 曲线
await model.fit(trainData.data, trainData.target, {
batchSize,
epochs,
validationSplit: 0.2,
callbacks: {
onEpochBegin: async (epoch) => {
if ((epoch + 1)% 100 === 0 ||
epoch === 0 || epoch === 2 || epoch === 4) {
***1***
const probs = model.predict(testData.data);
drawROC(testData.target, probs, epoch);
}
},
onEpochEnd: async (epoch, logs) => {
await ui.updateStatus(
`Epoch ${epoch + 1} of ${epochs} completed.`);
trainLogs.push(logs);
ui.plotLosses(trainLogs);
ui.plotAccuracies(trainLogs);
}
}
});
- 1 每隔几个周期绘制 ROC 曲线。
函数 drawROC() 的主体包含了如何创建 ROC 曲线的细节(参见代码清单 3.7)。它执行以下操作:
-
根据神经网络的 S 型输出(概率)的阈值,可获取不同分类结果的集合。
-
将 TPR 绘制在 FPR 上以形成 ROC 曲线。
-
⁸
如 图 3.6 所示,在训练开始时(周期 001),由于模型的权重是随机初始化的,ROC 曲线非常接近连接点 (0, 0) 和点 (1, 1) 的对角线。这就是随机猜测的样子。随着训练的进行,ROC 曲线越来越向左上角推进——那里的 FPR 接近 0,TPR 接近 1。如果我们专注于任何一个给定的 FPR 级别,例如 0.1,我们可以看到在训练过程中,相应的 TPR 值随着训练的进展而单调递增。简而言之,这意味着随着训练的进行,如果我们将假报警率(FPR)保持不变,就可以实现越来越高的召回率(TPR)。
“理想”的 ROC 曲线向左上角弯曲得越多,就会变成一个类似 γ^([8]) 形状的曲线。在这种情况下,您可以获得 100% 的 TPR 和 0% 的 FPR,这是任何二元分类器的“圣杯”。然而,在实际问题中,我们只能改进模型,将 ROC 曲线推向左上角,但理论上的左上角理想状态是无法实现的。
注释:γ 字母
对于每个分类结果,将其与实际标签(目标)结合使用,计算 TPR 和 FPR。
基于对 ROC 曲线形状及其含义的讨论,我们可以看到通过查看其下方的区域(即 ROC 曲线和 x 轴之间的单位正方形的空间)来量化 ROC 曲线的好坏是可能的。这被称为曲线下面积(AUC),并且也在 listing 3.7 的代码中计算。这个指标比精确率、召回率和准确率更好,因为它考虑了假阳性和假阴性之间的权衡。随机猜测的 ROC 曲线(对角线)的 AUC 为 0.5,而γ形状的理想 ROC 曲线的 AUC 为 1.0。我们的钓鱼检测模型在训练后达到了 0.981 的 AUC。
listing 3.7 的代码用于计算和绘制 ROC 曲线和 AUC
function drawROC(targets, probs, epoch) {
return tf.tidy(() => {
const thresholds = [ ***1***
0.0, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, ***1***
0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, ***1***
0.9, 0.92, 0.94, 0.96, 0.98, 1.0 ***1***
]; ***1***
const tprs = []; // True positive rates.
const fprs = []; // False positive rates.
let area = 0;
for (let i = 0; i < thresholds.length; ++i) {
const threshold = thresholds[i];
const threshPredictions = ***2***
utils.binarize(probs, threshold).as1D(); ***2***
const fpr = falsePositiveRate( ***3***
targets, ***3***
threshPredictions).arraySync(); ***3***
const tpr = tf.metrics.recall(targets, threshPredictions).arraySync();
fprs.push(fpr);
tprs.push(tpr);
if (i > 0) { ***4***
area += (tprs[i] + tprs[i - 1]) * (fprs[i - 1] - fprs[i]) / 2; ***4***
} ***4***
}
ui.plotROC(fprs, tprs, epoch);
return area;
});
}
-
1 一组手动选择的概率阈值
-
2 通过阈值将概率转换为预测
-
3 falsePositiveRate()函数通过比较预测和实际目标来计算假阳性率。该函数在同一文件中定义。
-
4 用于 AUC 计算的面积累积
除了可视化二元分类器的特性外,ROC 还帮助我们在实际情况下做出明智的选择,比如如何选择概率阈值。例如,想象一下,我们是一家商业公司,正在开发钓鱼检测器作为一项服务。我们想要做以下哪项?
-
由于错过了真实的网络钓鱼网站将会在责任或失去合同方面给我们造成巨大的损失,因此将阈值设定相对较低。
-
由于我们更不愿意接受将正常网站误分类为可疑而导致用户提交投诉,因此将阈值设定相对较高。
每个阈值对应于 ROC 曲线上的一个点。当我们将阈值从 0 逐渐增加到 1 时,我们从图的右上角(其中 FPR 和 TPR 都为 1)移动到图的左下角(其中 FPR 和 TPR 都为 0)。在实际的工程问题中,选择 ROC 曲线上的哪个点的决定总是基于权衡这种相反的现实生活成本,并且在不同的客户和不同的业务发展阶段可能会有所不同。
除了 ROC 曲线之外,二元分类的另一个常用可视化方法是精确率-召回率曲线(有时称为 P/R 曲线,在 table 3.3 中简要提到)。与 ROC 曲线不同,精确率-召回率曲线将精确率绘制为召回率的函数。由于精确率-召回率曲线在概念上与 ROC 曲线相似,我们在这里不会深入讨论它们。
在 代码清单 3.7 中值得指出的一点是使用了 tf.tidy()。这个函数确保了在作为参数传递给它的匿名函数内创建的张量被正确地处理,这样它们就不会继续占用 WebGL 内存。在浏览器中,TensorFlow.js 无法管理用户创建的张量的内存,主要是因为 JavaScript 中缺乏对象终结和底层 TensorFlow.js 张量下层的 WebGL 纹理缺乏垃圾回收。如果这样的中间张量没有被正确清理,就会发生 WebGL 内存泄漏。如果允许这样的内存泄漏持续足够长的时间,最终会导致 WebGL 内存不足错误。附录 B 的 章节 1.3 包含了有关 TensorFlow.js 内存管理的详细教程。此外,附录 B 的 章节 1.5 中还有关于这个主题的练习题。如果您计划通过组合 TensorFlow.js 函数来定义自定义函数,您应该仔细研究这些章节。
3.2.4. 二元交叉熵:二元分类的损失函数
到目前为止,我们已经讨论了几种不同的度量标准,用于量化二元分类器的不同表现方面,比如准确率、精确率和召回率(表 3.3)。但我们还没有讨论一个重要的度量标准,一个可以微分并生成梯度来支持模型梯度下降训练的度量标准。这就是我们在 代码清单 3.5 中简要看到的 binaryCrossentropy,但我们还没有解释过:
model.compile({
optimizer: 'adam',
loss: 'binaryCrossentropy',
metrics: ['accuracy']
});
首先,你可能会问,为什么不能直接以精确度、准确度、召回率,或者甚至 AUC 作为损失函数?毕竟这些指标容易理解。此外,在之前我们见过的回归问题中,我们使用了 MSE 作为训练的损失函数,这是一个相当容易理解的指标。答案是,这些二分类度量指标都无法产生我们需要训练的梯度。以精确度指标为例:要了解为什么它不友好的梯度,请认识到计算精确度需要确定模型的预测哪些是正样本,哪些是负样本(参见 表 3.3 的第一行)。为了做到这一点,必须应用一个 阈值函数,将模型的 sigmoid 输出转换为二进制预测。这里就是问题的关键:虽然阈值函数(在更技术的术语中称为step function)几乎在任何地方都是可微分的(“几乎”是因为它在 0.5 的“跳跃点”处不可微分),但其导数始终恰好为零(参见图 3.7)!如果您试图通过该阈值函数进行反向传播会发生什么呢?因为上游梯度值在某些地方需要与该阈值函数的所有零导数相乘,所以您的梯度最终将全是零。更简单地说,如果将精确度(或准确度、召回率、AUC 等)选为损失,底层阶跃函数的平坦部分使得训练过程无法知道在权重空间中向哪个方向移动可以降低损失值。
图 3.7 用于转换二分类模型的概率输出的阶跃函数,几乎在每个可微点都是可微分的。不幸的是,每个可微分点的梯度(导数)恰好为零。

因此,如果使用精确度作为损失函数,便无法计算有用的梯度,从而阻止了在模型的权重上获得有意义的更新。此限制同样适用于包括准确度、召回率、FPR 和 AUC 在内的度量。虽然这些指标对人类理解二分类器的行为很有用,但对于这些模型的训练过程来说是无用的。
我们针对二分类任务使用的损失函数是二进制交叉熵,它对应于我们的钓鱼检测模型代码中的 'binaryCrossentropy' 配置(见列表 3.5 和 3.6)。算法上,我们可以用以下伪代码来定义二进制交叉熵。
列表 3.8 二进制交叉熵损失函数的伪代码^([9])
⁹
binaryCrossentropy的实际代码需要防范prob或1 - prob等恰好为零的情况,否则如果将这些值直接传递给log函数,会导致无穷大。这是通过在将它们传递给对数函数之前添加一个非常小的正数(例如1e-6,通常称为“epsilon”或“修正因子”)来实现的。
function binaryCrossentropy(truthLabel, prob):
if truthLabel is 1:
return -log(prob)
else:
return -log(1 - prob)
在此伪代码中,truthLabel 是一个数字,取 0 到 1 的值,指示输入样本在现实中是否具有负(0)或正(1)标签。prob 是模型预测的样本属于正类的概率。请注意,与 truthLabel 不同,prob 应为实数,可以取 0 到 1 之间的任何值。log 是自然对数,以 e(2.718)为底,您可能还记得它来自高中数学。binaryCrossentropy 函数的主体包含一个 if-else 逻辑分支,根据 truthLabel 是 0 还是 1 执行不同的计算。图 3.8 在同一图中绘制了这两种情况。
图 3.8。二元交叉熵损失函数。两种情况(truthLabel = 1 和 truthLabel = 0)分别绘制在一起,反映了 代码清单 3.8 中的 if-else 逻辑分支。

在查看 图 3.8 中的图表时,请记住较低的值更好,因为这是一个损失函数。关于损失函数需要注意的重要事项如下:
-
如果
truthLabel为 1,prob值接近 1.0 会导致较低的损失函数值。这是有道理的,因为当样本实际上是正例时,我们希望模型输出的概率尽可能接近 1.0。反之亦然:如果truthLabel为 0,则当概率值接近 0 时,损失值较低。这也是有道理的,因为在这种情况下,我们希望模型输出的概率尽可能接近 0。 -
与 图 3.7 中显示的二进制阈值函数不同,这些曲线在每个点都有非零斜率,导致非零梯度。这就是为什么它适用于基于反向传播的模型训练。
你可能会问的一个问题是,为什么不重复我们为回归模型所做的事情——只是假装 0-1 值是回归目标,并使用 MSE 作为损失函数?毕竟,MSE 是可微分的,并且计算真实标签和概率之间的 MSE 会产生与binaryCrossentropy一样的非零导数。答案与 MSE 在边界处具有“递减收益”有关。例如,在 表 3.4 中,我们列出了当 truthLabel 为 1 时一些 prob 值的 binaryCrossentropy 和 MSE 损失值。当 prob 接近 1(期望值)时,MSE 相对于binaryCrossentropy的减小速度会越来越慢。因此,当 prob 已经接近 1(例如,0.9)时,它不太好地“鼓励”模型产生较高(接近 1)的 prob 值。同样,当 truthLabel 为 0 时,MSE 也不如 binaryCrossentropy 那样好,不能生成推动模型的 prob 输出向 0 靠近的梯度。
表 3.4. 比较假想的二分类结果的二元交叉熵和 MSE 值
| 真实标签 | 概率 | 二元交叉熵 | MSE |
|---|---|---|---|
| 1 | 0.1 | 2.302 | 0.81 |
| 1 | 0.5 | 0.693 | 0.25 |
| 1 | 0.9 | 0.100 | 0.01 |
| 1 | 0.99 | 0.010 | 0.0001 |
| 1 | 0.999 | 0.001 | 0.000001 |
| 1 | 1 | 0 | 0 |
这展示了二分类问题与回归问题不同的另一个方面:对于二分类问题,损失(binaryCrossentropy)和指标(准确率、精确率等)是不同的,而对于回归问题通常是相同的(例如,meanSquaredError)。正如我们将在下一节看到的那样,多类别分类问题也涉及不同的损失函数和指标。
3.3. 多类别分类
在 第 3.2 节 中,我们探讨了如何构建二分类问题的结构;现在我们将快速进入 非二分类 的处理方式——即,涉及三个或更多类别的分类任务。^([10]) 我们将使用用于说明多类别分类的数据集是 鸢尾花数据集,这是一个有着统计学根源的著名数据集(参见 en.wikipedia.org/wiki/Iris_flower_data_set)。这个数据集关注于三种鸢尾花的品种,分别为 山鸢尾、变色鸢尾 和 维吉尼亚鸢尾。这三种鸢尾花可以根据它们的形状和大小来区分。在 20 世纪初,英国统计学家罗纳德·费舍尔测量了 150 个鸢尾花样本的花瓣和萼片(花的不同部位)的长度和宽度。这个数据集是平衡的:每个目标标签都有确切的 50 个样本。
¹⁰
不要混淆 多类别 分类和 多标签 分类。在多标签分类中,单个输入示例可能对应于多个输出类别。一个例子是检测输入图像中各种类型物体的存在。一个图像可能只包括一个人;另一个图像可能包括一个人、一辆车和一个动物。多标签分类器需要生成一个表示适用于输入示例的所有类别的输出,无论该类别是一个还是多个。本节不涉及多标签分类。相反,我们专注于更简单的单标签、多类别分类,其中每个输入示例都对应于>2 个可能类别中的一个输出类别。
在这个问题中,我们的模型以四个数值特征(花瓣长度、花瓣宽度、萼片长度和萼片宽度)作为输入,并尝试预测一个目标标签(三种物种之一)。该示例位于 tfjs-examples 的 iris 文件夹中,您可以使用以下命令查看并运行:
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/iris
yarn && yarn watch
3.3.1. 对分类数据进行 one-hot 编码
在研究解决鸢尾花分类问题的模型之前,我们需要强调这个多类别分类任务中分类目标(物种)的表示方式。到目前为止,在本书中我们看到的所有机器学习示例都涉及更简单的目标表示,例如下载时间预测问题中的单个数字以及波士顿房屋问题中的数字,以及钓鱼检测问题中的二进制目标的 0-1 表示。然而,在鸢尾问题中,三种花的物种以稍微不那么熟悉的方式称为 one-hot 编码 进行表示。打开 data.js,您将注意到这一行:
const ys = tf.oneHot(tf.tensor1d(shuffledTargets).toInt(), IRIS_NUM_CLASSES);
这里,shuffledTargets 是一个普通的 JavaScript 数组,其中包含按随机顺序排列的示例的整数标签。其元素的值均为 0、1 和 2,反映了数据集中的三种鸢尾花品种。通过调用 tf.tensor1d(shuffledTargets).toInt(),它被转换为 int32 类型的 1D 张量。然后将结果的 1D 张量传递到 tf.oneHot() 函数中,该函数返回形状为 [numExamples, IRIS_NUM_CLASSES] 的 2D 张量。numExamples 是 targets 包含的示例数,而 IRIS_NUM_CLASSES 简单地是常量 3。您可以通过在先前引用的行下面添加一些打印行来查看 targets 和 ys 的实际值,例如:
const ys = tf.oneHot(tf.tensor1d(shuffledTargets).toInt(), IRIS_NUM_CLASSES);
// Added lines for printing the values of `targets` and `ys`.
console.log('Value of targets:', targets);
ys.print();[11]
¹¹
与
targets不同,ys不是一个普通的 JavaScript 数组。相反,它是由 GPU 内存支持的张量对象。因此,常规的 console.log 不会显示其值。print()方法是专门用于从 GPU 中检索值,以形状感知和人性化的方式进行格式化,并将其记录到控制台的方法。
一旦您进行了这些更改,Yarn watch 命令在终端启动的包捆绑器进程将自动重建 Web 文件。然后,您可以打开用于观看此演示的浏览器选项卡中的开发工具,并刷新页面。console.log() 和 print() 调用的打印消息将记录在开发工具的控制台中。您将看到的打印消息将类似于这样:
Value of targets: (50) [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0]
Tensor
[[1, 0, 0],
[1, 0, 0],
[1, 0, 0],
...,
[1, 0, 0],
[1, 0, 0],
[1, 0, 0]]
或者
Value of targets: (50) [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1]
Tensor
[[0, 1, 0],
[0, 1, 0],
[0, 1, 0],
...,
[0, 1, 0],
[0, 1, 0],
[0, 1, 0]]
等等。用言语来描述,以整数标签 0 为例,您会得到一个值为 [1, 0, 0] 的值行;对于整数标签为 1 的示例,您会得到一个值为 [0, 1, 0] 的行,依此类推。这是独热编码的一个简单明了的例子:它将一个整数标签转换为一个向量,该向量除了在对应标签的索引处的值为 1 之外,其余都为零。向量的长度等于所有可能类别的数量。向量中只有一个 1 值的事实正是这种编码方案被称为“独热”的原因。
对于您来说,这种编码可能看起来过于复杂了。在一个类别中使用三个数字来表示,为什么不使用一个单一的数字就能完成任务呢?为什么我们选择这种复杂的编码而不是更简单和更经济的单整数索引编码呢?这可以从两个不同的角度来理解。
首先,对于神经网络来说,输出连续的浮点型值要比整数值容易得多。在浮点型输出上应用舍入也不够优雅。一个更加优雅和自然的方法是,神经网络的最后一层输出几个单独的浮点型数值,每个数值通过一个类似于我们用于二元分类的 S 型激活函数的精心选择的激活函数被限制在 [0, 1] 区间内。在这种方法中,每个数字都是模型对输入示例属于相应类别的概率的估计。这正是独热编码的用途:它是概率分数的“正确答案”,模型应该通过其训练过程来拟合。
第二,通过将类别编码为整数,我们隐含地为类别创建了一个顺序。例如,我们可以将 鸢尾花 setosa 标记为 0,鸢尾花 versicolor 标记为 1,鸢尾花 virginica 标记为 2。但是,这样的编号方案通常是人为的和不合理的。例如,这种编号方案暗示 setosa 比 versicolor 更“接近” virginica,这可能并不正确。神经网络基于实数进行操作,并且基于诸如乘法和加法之类的数学运算。因此,它们对数字的数量和顺序敏感。如果将类别编码为单一数字,则成为神经网络必须学习的额外非线性关系。相比之下,独热编码的类别不涉及任何隐含的排序,因此不会以这种方式限制神经网络的学习能力。
就像我们将在第九章中看到的那样,独热编码不仅用于神经网络的输出目标,而且还适用于分类数据形成神经网络的输入。
3.3.2. Softmax 激活函数
了解了输入特征和输出目标的表示方式后,我们现在可以查看定义模型的代码(来自 iris/index.js)。
列表 3.9. 用于鸢尾花分类的多层神经网络
const model = tf.sequential();
model.add(tf.layers.dense(
{units: 10, activation: 'sigmoid', inputShape: [xTrain.shape[1]]}));
model.add(tf.layers.dense({units: 3, activation: 'softmax'}));
model.summary();
const optimizer = tf.train.adam(params.learningRate);
model.compile({
optimizer: optimizer,
loss: 'categoricalCrossentropy',
metrics: ['accuracy'],
});
在列表 3.9 中定义的模型导致了以下摘要:
_________________________________________________________________
Layer (type) Output shape Param #
=================================================================
dense_Dense1 (Dense) [null,10] 50
________________________________________________________________
dense_Dense2 (Dense) [null,3] 33
=================================================================
Total params: 83
Trainable params: 83
Non-trainable params:
________________________________________________________________
通过查看打印的概述,我们可以看出这是一个相当简单的模型,具有相对较少的(83 个)权重参数。输出形状[null, 3]对应于分类目标的独热编码。最后一层使用的激活函数,即softmax,专门设计用于多分类问题。softmax 的数学定义可以写成以下伪代码:
softmax([x1, x2, ..., xn]) =
[exp(x1) / (exp(x1) + exp(x2) + ... + exp(xn)),
exp(x2) / (exp(x1) + exp(x2) + ... + exp(xn)),
...,
exp(xn) / (exp(x1) + exp(x2) + ... + exp(xn))]
与我们之前见过的 sigmoid 激活函数不同,softmax 激活函数不是逐元素的,因为输入向量的每个元素都以依赖于所有其他元素的方式进行转换。具体来说,输入的每个元素被转换为其指数(以* e*=2.718 为底)的自然指数。然后指数被除以所有元素的指数的和。这样做有什么作用?首先,它确保了每个数字都在 0 到 1 的区间内。其次,保证了输出向量的所有元素之和为 1。这是一个理想的属性,因为 1)输出可以被解释为分配给各个类别的概率得分,2)为了与分类交叉熵损失函数兼容,输出必须满足此属性。第三,该定义确保输入向量中的较大元素映射到输出向量中的较大元素。举个具体的例子,假设最后一个密集层的矩阵乘法和偏置相加生成了一个向量
[-3, 0, -8]
它的长度为 3,因为密集层被配置为具有 3 个单元。请注意,这些元素是浮点数,不受特定范围的约束。softmax 激活函数将向量转换为
[0.0474107, 0.9522698, 0.0003195]
您可以通过运行以下 TensorFlow.js 代码(例如,在页面指向js.tensorflow.org时,在开发工具控制台中)来自行验证这一点:
const x = tf.tensor1d([-3, 0, -8]);
tf.softmax(x).print();
Softmax 函数的输出有三个元素。1)它们都在[0, 1]区间内,2)它们的和为 1,3)它们的顺序与输入向量中的顺序相匹配。由于这些属性的存在,输出可以被解释为被模型分配的(概率)值,表示所有可能的类别。在前面的代码片段中,第二个类别被分配了最高的概率,而第一个类别被分配了最低的概率。
因此,当使用这种多类别分类器的输出时,你可以选择最高 softmax 元素的索引作为最终决策——也就是输入属于哪个类别的决策。这可以通过使用方法 argMax() 来实现。例如,这是 index.js 的摘录:
const predictOut = model.predict(input);
const winner = data.IRIS_CLASSES[predictOut.argMax(-1).dataSync()[0]];
predictOut 是形状为 [numExamples, 3] 的二维张量。调用它的 argMax0 方法会导致形状被减少为 [numExample]。参数值 -1 表示 argMax() 应该在最后一个维度上查找最大值并返回它们的索引。例如,假设 predictOut 有以下值:
[[0 , 0.6, 0.4],
[0.8, 0 , 0.2]]
那么,argMax(-1) 将返回一个张量,指示沿着最后(第二个)维度找到的最大值分别在第一个和第二个示例的索引为 1 和 0:
[1, 0]
3.3.3. 分类交叉熵:多类别分类的损失函数
在二元分类示例中,我们看到了如何使用二元交叉熵作为损失函数,以及为什么其他更易于人类理解的指标,如准确率和召回率,不能用作损失函数。多类别分类的情况相当类似。存在一个直观的度量标准——准确率——它是模型正确分类的例子的比例。这个指标对于人们理解模型的性能有重要意义,并且在 列表 3.9 中的这段代码片段中使用:
model.compile({
optimizer: optimizer,
loss: 'categoricalCrossentropy',
metrics: ['accuracy'],
});
然而,准确率对于损失函数来说是一个糟糕的选择,因为它遇到了与二元分类中的准确率相同的零梯度问题。因此,人们为多类别分类设计了一个特殊的损失函数:分类交叉熵。它只是将二元交叉熵推广到存在两个以上类别的情况。
列表 3.10. 用于分类交叉熵损失的伪代码
function categoricalCrossentropy(oneHotTruth, probs):
for i in (0 to length of oneHotTruth)
if oneHotTruth(i) is equal to 1
return -log(probs[i]);
在前面的伪代码中,oneHotTruth 是输入示例的实际类别的独热编码。probs 是模型的 softmax 概率输出。从这段伪代码中可以得出的关键信息是,就分类交叉熵而言,probs 中只有一个元素是重要的,那就是与实际类别对应的索引的元素。probs 的其他元素可以随意变化,但只要它们不改变实际类别的元素,就不会影响分类交叉熵。对于 probs 的特定元素,它越接近 1,交叉熵的值就越低。与二元交叉熵类似,分类交叉熵直接作为 tf.metrics 命名空间下的一个函数可用,你可以用它来计算简单但说明性的示例的分类交叉熵。例如,使用以下代码,你可以创建一个假设的独热编码的真实标签和一个假设的 probs 向量,并计算相应的分类交叉熵值:
const oneHotTruth = tf.tensor1d([0, 1, 0]);
const probs = tf.tensor1d([0.2, 0.5, 0.3]);
tf.metrics.categoricalCrossentropy(oneHotTruth, probs).print();
这给出了一个约为 0.693 的答案。这意味着当模型对实际类别分配的概率为 0.5 时,categoricalCrossentropy的值为 0.693。你可以根据 pseudo-code(伪代码)进行验证。你也可以尝试将值从 0.5 提高或降低,看看categoricalCrossentropy如何变化(例如,参见 table 3.5)。表中还包括一列显示了单热真实标签和probs向量之间的 MSE。
表 3.5. 不同概率输出下的分类交叉熵值。不失一般性,所有示例(行)都是基于有三个类别的情况(如鸢尾花数据集),并且实际类别是第二个类别。
| One-hot truth label | probs (softmax output) | Categorical cross entropy | MSE |
|---|---|---|---|
| [0, 1, 0] | [0.2, 0.5, 0.3] | 0.693 | 0.127 |
| [0, 1, 0] | [0.0, 0.5, 0.5] | 0.693 | 0.167 |
| [0, 1, 0] | [0.0, 0.9, 0.1] | 0.105 | 0.006 |
| [0, 1, 0] | [0.1, 0.9, 0.0] | 0.105 | 0.006 |
| [0, 1, 0] | [0.0, 0.99, 0.01] | 0.010 | 0.00006 |
通过比较表中的第 1 行和第 2 行,或比较第 3 行和第 4 行,可以明显看出更改probs中与实际类别不对应的元素不会改变二元交叉熵的值,尽管这可能会改变单热真实标签和probs之间的 MSE。同样,就像在二元交叉熵中一样,当probs值接近 1 时,MSE 显示出递减的回报,并且在这个区间内,MSE 不适合鼓励正确类别的概率值上升,而分类熵则更适合作为多类别分类问题的损失函数。
3.3.4. 混淆矩阵:多类别分类的细致分析
点击示例网页上的从头开始训练模型按钮,你可以在几秒钟内得到一个经过训练的模型。正如图 3.9 所示,模型经过 40 个训练周期后几乎达到了完美的准确度。这反映了鸢尾花数据集是一个相对较小且在特征空间中类别边界相对明确的数据集的事实。
图 3.9. 40 个训练周期后鸢尾花模型的典型结果。左上方:损失函数随训练周期变化的图表。右上方:准确度随训练周期变化的图表。底部:混淆矩阵。

图 3.9 的底部显示了描述多类分类器行为的另一种方式,称为混淆矩阵。混淆矩阵根据其实际类别和模型预测类别将多类分类器的结果进行了细分。它是一个形状为[numClasses, numClasses]的方阵。索引[i, j](第 i 行和第 j 列)处的元素是属于类别i并由模型预测为类别j的示例数量。因此,混淆矩阵的对角线元素对应于正确分类的示例。一个完美的多类分类器应该产生一个没有对角线之外的非零元素的混淆矩阵。这正是图 3.9 中的混淆矩阵的情况。
除了展示最终的混淆矩阵外,鸢尾花示例还在每个训练周期结束时使用onTrainEnd()回调绘制混淆矩阵。在早期周期中,您可能会看到一个不太完美的混淆矩阵,与图 3.9 中的混淆矩阵不同。图 3.10 中的混淆矩阵显示,24 个输入示例中有 8 个被错误分类,对应的准确率为 66.7%。然而,混淆矩阵告诉我们不仅仅是一个数字:它显示了哪些类别涉及最多的错误,哪些涉及较少。在这个特定的示例中,所有来自第二类的花都被错误分类(要么作为第一类,要么作为第三类),而来自第一类和第三类的花总是被正确分类。因此,您可以看到,在多类分类中,混淆矩阵比简单的准确率更具信息量,就像精确率和召回率一起形成了比二分类准确率更全面的衡量标准一样。混淆矩阵可以提供有助于与模型和训练过程相关的决策的信息。例如,某些类型的错误可能比混淆其他类别对更为昂贵。也许将一个体育网站误认为游戏网站不如将体育网站误认为钓鱼网站那么严重。在这些情况下,您可以调整模型的超参数以最小化最昂贵的错误。
图 3.10. 一个“不完美”混淆矩阵的示例,在对角线之外存在非零元素。该混淆矩阵是在训练收敛之前的仅 2 个周期后生成的。

到目前为止,我们所见的模型都将一组数字作为输入。换句话说,每个输入示例都表示为一组简单的数字列表,其中长度固定,元素的排序不重要,只要它们对馈送到模型的所有示例都一致即可。虽然这种类型的模型涵盖了重要和实用的机器学习问题的大量子集,但它远非唯一的类型。在接下来的章节中,我们将研究更复杂的输入数据类型,包括图像和序列。在 第四章 中,我们将从图像开始,这是一种无处不在且广泛有用的输入数据类型,为此已经开发了强大的神经网络结构,以将机器学习模型的准确性推向超人级别。
练习
-
当创建用于波士顿房屋问题的神经网络时,我们停留在一个具有两个隐藏层的模型上。鉴于我们所说的级联非线性函数会增强模型的容量,那么将更多的隐藏层添加到模型中会导致评估准确性提高吗?通过修改 index.js 并重新运行训练和评估来尝试一下。
-
是什么因素阻止了更多的隐藏层提高评估准确性?
-
是什么让您得出这个结论?(提示:看一下训练集上的误差。)
-
-
看看 清单 3.6 中的代码如何使用
onEpochBegin回调在每个训练时期的开始计算并绘制 ROC 曲线。您能按照这种模式并对回调函数的主体进行一些修改,以便您可以在每个时期的开始打印精度和召回率值(在测试集上计算)吗?描述这些值随着训练的进行而如何变化。 -
研究 清单 3.7 中的代码,并理解它是如何计算 ROC 曲线的。您能按照这个示例并编写一个新的函数,名为
drawPrecisionRecallCurve(),它根据名称显示一个精度-召回率曲线吗?写完函数后,从onEpochBegin回调中调用它,以便在每个训练时期的开始绘制一个精度-召回率曲线。您可能需要对 ui.js 进行一些修改或添加。 -
假设您得知二元分类器结果的 FPR 和 TPR。凭借这两个数字,您能计算出整体准确性吗?如果不能,您需要什么额外信息?
-
二元交叉熵(3.2.4 节)和分类交叉熵(3.3.3 节)的定义都基于自然对数(以 e 为底的对数)。如果我们改变定义,让它们使用以 10 为底的对数会怎样?这会如何影响二元和多类分类器的训练和推断?
-
将超参数网格搜索的伪代码转换为实际的 JavaScript 代码,并使用该代码对列表 3.1 中的两层波士顿房屋模型进行超参数优化。具体来说,调整隐藏层的单位数和学习率。可以自行决定要搜索的单位和学习率的范围。注意,机器学习工程师通常使用近似几何序列(即对数)间隔进行这些搜索(例如,单位= 2、5、10、20、50、100、200,...)。
摘要
-
分类任务与回归任务不同,因为它们涉及进行离散预测。
-
分类有两种类型:二元和多类。在二元分类中,对于给定的输入,有两种可能的类别,而在多类分类中,有三个或更多。
-
二元分类通常可以被看作是在所有输入示例中检测一种称为正例的特定类型事件或对象。从这个角度来看,我们可以使用精确率、召回率和 FPR 等指标,除了准确度,来量化二元分类器行为的各个方面。
-
在二元分类任务中,需要在捕获所有正例和最小化假阳性(误报警)之间进行权衡是很常见的。ROC 曲线与相关的 AUC 指标是一种帮助我们量化和可视化这种关系的技术。
-
为了进行二元分类而创建的神经网络应该在其最后(输出)层使用 sigmoid 激活,并在训练过程中使用二元交叉熵作为损失函数。
-
为了创建一个用于多类分类的神经网络,输出目标通常由独热编码表示。神经网络应该在其输出层使用 softmax 激活,并使用分类交叉熵损失函数进行训练。
-
对于多类分类,混淆矩阵可以提供比准确度更细粒度的信息,关于模型所犯错误的信息。
-
表 3.6 总结了迄今为止我们见过的最常见的机器学习问题类型(回归、二元分类和多类分类)的推荐方法。
-
超参数是关于机器学习模型结构、其层属性以及其训练过程的配置。它们与模型的权重参数不同,因为 1)它们在模型的训练过程中不变化,2)它们通常是离散的。超参数优化是一种寻找超参数值以在验证数据集上最小化损失的过程。超参数优化仍然是一个活跃的研究领域。目前,最常用的方法包括网格搜索、随机搜索和贝叶斯方法。
表格 3.6. 最常见的机器学习任务类型,它们适用的最后一层激活函数和损失函数,以及有助于量化模型质量的指标的概述
| 任务类型 | 输出层的激活函数 | 损失函数 | 在 Model.fit() 调用中支持的适用指标 | 额外的指标 |
|---|---|---|---|---|
| 回归 | 'linear' (默认) | 'meanSquaredError' 或 'meanAbsoluteError' | (与损失函数相同) | |
| 二分类 | 'sigmoid' | 'binaryCrossentropy' | 'accuracy' | 精确率,召回率,精确-召回曲线,ROC 曲线,AUC 值 |
| 单标签,多类别分类 | 'softmax' | 'categoricalCrossentropy' | 'accuracy' | 混淆矩阵 |
第四章:使用卷积神经网络识别图像和声音
本章内容涵盖
-
图像和其他知觉数据(例如音频)如何表示为多维张量
-
卷积神经网络是什么、如何工作以及为什么它们特别适用于涉及图像的机器学习任务
-
如何编写和训练一个 TensorFlow.js 中的卷积神经网络来解决手写数字分类的任务
-
如何在 Node.js 中训练模型以实现更快的训练速度
-
如何在音频数据上使用卷积神经网络进行口语识别
持续进行的深度学习革命始于图像识别任务的突破,比如 ImageNet 比赛。涉及图像的有许多有用且技术上有趣的问题,包括识别图像的内容、将图像分割成有意义的部分、在图像中定位对象以及合成图像。这个机器学习的子领域有时被称为计算机视觉([1])。计算机视觉技术经常被移植到与视觉或图像无关的领域(如自然语言处理),这也是为什么学习计算机视觉的深度学习至关重要的另一个原因([2])。但在深入讨论计算机视觉问题之前,我们需要讨论图像在深度学习中的表示方式。
¹
需要注意的是,计算机视觉本身是一个广泛的领域,其中一些部分使用了本书范围以外的非机器学习技术。
²
对于对计算机视觉深度学习特别感兴趣并希望深入了解该主题的读者,可以参考 Mohamed Elgendy 的《深度学习图像处理入门》,Manning 出版社,即将出版。
4.1。从向量到张量:图像的表示
在前两章中,我们讨论了涉及数值输入的机器学习任务。例如,第二章中的下载时长预测问题将单个数字(文件大小)作为输入。波士顿房价问题的输入是一个包含 12 个数字的数组(房间数量、犯罪率等)。这些问题的共同点是,每个输入示例都可以表示为一维(非嵌套)数字数组,对应于 TensorFlow.js 中的一维张量。图像在深度学习中的表示方式有所不同。
为了表示一张图片,我们使用一个三维张量。张量的前两个维度是熟悉的高度和宽度维度。第三个是颜色通道。例如,颜色通常被编码为 RGB 值。在这种情况下,三个颜色值分别是通道,导致第三个维度的大小为 3。如果我们有一张尺寸为 224 × 224 像素的 RGB 编码颜色图像,我们可以将其表示为一个尺寸为 [224, 224, 3] 的三维张量。某些计算机视觉问题中的图像是无颜色的(例如灰度图像)。在这种情况下,只有一个通道,如果将其表示为三维张量,将导致张量形状为 [height, width, 1] (参见图 4.1)。^([3])
³
另一种选择是将图像的所有像素及其关联的颜色值展开为一个一维张量(一个由数字组成的扁平数组)。但是这样做很难利用每个像素的颜色通道与像素间的二维空间关系之间的关联。
图 4.1. 在深度学习中使用张量表示一个 MNIST 图像。为了可视化,我们将 MNIST 图像从 28 × 28 缩小到 8 × 8。这张图片是一个灰度图像,它的高度-宽度-通道(HWC)形状为[8, 8, 1]。这个图示中省略了最后一维的单个颜色通道。

这种编码图像的方式被称为 高度-宽度-通道(HWC)。为了在图像上进行深度学习,我们经常将一组图像合并成一个批次以便进行高效的并行计算。当批量处理图像时,各个图像的维度总是第一维。这与我们在 第二章 和 第三章 中将一维张量组合成批量化的二维张量的方式类似。因此,图像的批次是一个四维张量,它的四个维度分别是图像数量(N)、高度(H)、宽度(W)和颜色通道(C)。这个格式被称为 NHWC。还有另一种格式,它由四个维度的不同排序得出。它被称为 NCHW。顾名思义,NCHW 将通道维度放在高度和宽度维度之前。TensorFlow.js 可以处理 NHWC 和 NCHW 两种格式。但是我们在本书中只使用默认的 NHWC 格式,以保持一致性。
4.1.1. MNIST 数据集
本章我们将着眼于计算机视觉问题中的 MNIST^([4])手写数字数据集。这个数据集非常重要并且频繁使用,通常被称为计算机视觉和深度学习的“Hello World”。MNIST 数据集比大多数深度学习数据集都要旧,也要小。然而熟悉它是很好的,因为它经常被用作示例并且经常用作新颖深度学习技术的第一个测试。
⁴
MNIST 代表 Modified NIST。名称中的“NIST”部分源自数据集约于 1995 年起源于美国国家标准技术研究所。名称中的“modified”部分反映了对原始 NIST 数据集所做的修改,包括 1)将图像标准化为相同的均匀 28 × 28 像素光栅,并进行抗锯齿处理,以使训练和测试子集更加均匀,以及 2)确保训练和测试子集之间的作者集是不相交的。这些修改使数据集更易于处理,并更有利于模型准确性的客观评估。
MNIST 数据集中的每个示例都是一个 28 × 28 的灰度图像(参见图 4.1 作为示例)。这些图像是从 0 到 9 的 10 个数字的真实手写转换而来的。28 × 28 的图像尺寸足以可靠地识别这些简单形状,尽管它比典型的计算机视觉问题中看到的图像尺寸要小。每个图像都附有一个明确的标签,指示图像实际上是 10 个可能数字中的哪一个。正如我们在下载时间预测和波士顿房屋数据集中看到的那样,数据被分为训练集和测试集。训练集包含 60,000 个图像,而测试集包含 10,000 个图像。MNIST 数据集^([5])大致平衡,这意味着这 10 个类别的示例大致相等(即 10 个数字)。
⁵
请参阅 Yann LeCun、Corinna Cortes 和 Christopher J.C. Burges 的《手写数字 MNIST 数据库》
yann.lecun.com/exdb/mnist/。
4.2. 您的第一个卷积网络
鉴于图像数据和标签的表示,我们知道解决 MNIST 数据集的神经网络应该采用何种输入,以及应该生成何种输出。神经网络的输入是形状为 [null, 28, 28, 1] 的 NHWC 格式张量。输出是形状为 [null, 10] 的张量,其中第二个维度对应于 10 个可能的数字。这是多类分类目标的经典一热编码。这与我们在第三章中看到的鸢尾花种类的一热编码相同。有了这些知识,我们可以深入了解卷积网络(作为提醒,卷积网络是图像分类任务(如 MNIST)的选择方法)的细节。名称中的“卷积”部分可能听起来很吓人。这只是一种数学运算,我们将详细解释它。
代码位于 tfjs-examples 的 mnist 文件夹中。与前面的示例一样,您可以按以下方式访问和运行代码:
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/mnist
yarn && yarn watch
清单 4.1 是 mnist 示例中主要 index.js 代码文件的摘录。这是一个函数,用于创建我们用来解决 MNIST 问题的 convnet。此顺序模型的层数(七层)明显多于我们到目前为止看到的示例(一到三层之间)。
清单 4.1。为 MNIST 数据集定义卷积模型
function createConvModel() {
const model = tf.sequential();
model.add(tf.layers.conv2d({ ***1***
inputShape: [IMAGE_H, IMAGE_W, 1], ***1***
kernelSize: 3, ***1***
filters: 16, ***1***
activation: 'relu' ***1***
})); ***1***
model.add(tf.layers.maxPooling2d({ ***2***
poolSize: 2, ***2***
strides: 2 ***2***
})); ***2***
model.add(tf.layers.conv2d({ ***3***
kernelSize: 3, filters: 32, activation: 'relu'})); ***3***
model.add(tf.layers.maxPooling2d({poolSize: 2, strides: 2}));
model.add(tf.layers.flatten()); ***4***
model.add(tf.layers.dense({
units: 64,
activation:'relu'
}));
model.add(tf.layers.dense({units: 10, activation: 'softmax'})); ***5***
model.summary(); ***6***
return model;
}
-
1 第一个 conv2d 层
-
2 卷积后进行池化
-
3 conv2d-maxPooling2d 的重复“模式”
-
4 将张量展平以准备进行密集层
-
5 用于多类分类问题的 softmax 激活函数
-
6 打印模型的文本摘要
代码中的顺序模型由清单 4.1 中的代码构建,由add()方法调用逐个创建七个层。在我们查看每个层执行的详细操作之前,让我们先看一下模型的整体架构,如图 4.2 所示。如图所示,模型的前五层包括一组 conv2d-maxPooling2d 层的重复模式,后跟一个 flatten 层。conv2d-maxPooling2d 层组是特征提取的主力军。每个层将输入图像转换为输出图像。conv2d 层通过“卷积核”操作,该核在输入图像的高度和宽度维度上“滑动”。在每个滑动位置,它与输入像素相乘,然后将产品相加并通过非线性传递。这产生输出图像中的像素。maxPooling2d 层以类似的方式操作,但没有核。通过将输入图像数据通过连续的卷积和池化层,我们得到越来越小且在特征空间中越来越抽象的张量。最后一个池化层的输出通过展平变成一个 1D 张量。展平的 1D 张量然后进入密集层(图中未显示)。
图 4.2。简单 convnet 架构的高级概述,类似于清单 4.1 中的代码构建的模型。在此图中,图像和中间张量的大小比实际模型中定义的大小要小,以进行说明。卷积核的大小也是如此。还要注意,该图显示每个中间 4D 张量中仅有一个通道,而实际模型中的中间张量具有多个通道。

你可以将 convnet 看作是建立在卷积和池化预处理之上的 MLP。MLP 与我们在波士顿房屋和网络钓鱼问题中看到的完全相同:它仅由具有非线性激活的稠密层构成。在这里,convnet 的不同之处在于 MLP 的输入是级联 conv2d 和 maxPooling2d 层的输出。这些层专门设计用于图像输入,以从中提取有用的特征。这种架构是通过多年的神经网络研究发现的:它的准确性明显优于直接将图像的像素值馈入 MLP。
有了对 MNIST convnet 的高层次理解,现在让我们更深入地了解模型层的内部工作。
4.2.1. conv2d 层
第一层是 conv2d 层,它执行 2D 卷积。这是本书中看到的第一个卷积层。它的作用是什么?conv2d 是一个图像到图像的转换-它将一个 4D(NHWC)图像张量转换为另一个 4D 图像张量,可能具有不同的高度、宽度和通道数量。(conv2d 操作 4D 张量可能看起来有些奇怪,但请记住这里有两个额外的维度,一个是用于批处理示例,一个是用于通道。) 直观地看,它可以被理解为一组简单的“Photoshop 滤镜”^([6]),它会产生图像效果,如模糊和锐化。这些效果是通过 2D 卷积实现的,它涉及在输入图像上滑动一个小的像素块(卷积核,或简称核),并且像素与输入图像的小块重叠时,核与输入图像逐像素相乘。然后逐像素的乘积相加形成结果图像的像素。
⁶
我们将这种类比归功于 Ashi Krishnan 在 JSConf EU 2018 上的名为“JS 中的深度学习”的演讲:
mng.bz/VPa0。
与稠密层相比,conv2d 层具有更多的配置参数。kernelSize和filters是 conv2d 层的两个关键参数。为了理解它们的含义,我们需要在概念层面上描述 2D 卷积是如何工作的。
图 4.3 更详细地说明了 2D 卷积。在这里,我们假设输入图像(左上角)张量由一个简单的例子组成,以便我们可以在纸上轻松地绘制它。我们假设 conv2d 操作配置为kernelSize = 3和filters = 3。由于输入图像具有两个颜色通道(仅仅是为了图示目的而具有的相当不寻常的通道数),卷积核是一个形状为[3, 3, 2, 3]的 3D 张量。前两个数字(3 和 3)是由kernelSize确定的核的高度和宽度。第三个维度(2)是输入通道的数量。第四个维度(3)是什么?它是滤波器的数量,等于 conv2d 输出张量的最后一个维度。
图 4.3. 卷积层的工作原理,并附有一个示例。为简化起见,假设输入张量(左上角)仅包含一幅图像,因此是一个 3D 张量。其维度为高度、宽度和深度(色道)。为简单起见,批次维度被省略。输入图像张量的深度为 2。注意图像的高度和宽度(4 和 5)远小于典型真实图像的高宽。深度(2)也低于更典型的 3 或 4 的值(例如 RGB 或 RGBA)。假设 conv2D 层的 filters 属性(滤波器数量)为 3,kernelSize 为 [3, 3],strides 为 [1, 1],进行 2D 卷积的第一步是沿着高度和宽度维度滑动,并提取原始图像的小块。每个小块的高度为 3,宽度为 3,与层的 filterSize 匹配;它的深度与原始图像相同。第二步是计算每个 3 × 3 × 2 小块与卷积核(即“滤波器”)的点积。图 4.4 更详细地说明了每个点积操作。卷积核是一个 4D 张量,由三个 3D 滤波器组成。对三个滤波器分别进行图像小块与滤波器之间的点积。图像小块与滤波器逐像素相乘,然后求和,这导致输出张量中的一个像素。由于卷积核中有三个滤波器,每个图像小块被转换为一个三个像素的堆叠。这个点积操作在所有图像小块上执行,产生的三个像素堆叠被合并为输出张量,这种情况下形状为 [2, 3, 3]。

如果将输出视为图像张量(这是一个完全有效的观察方式!),那么滤波器可以理解为输出中通道的数量。与输入图像不同,输出张量中的通道实际上与颜色无关。相反,它们代表从训练数据中学到的输入图像的不同视觉特征。例如,一些滤波器可能对在特定方向上明亮和暗区域之间的直线边界敏感,而其他滤波器可能对由棕色形成的角落敏感,依此类推。稍后再详细讨论。
先前提到的“滑动”动作表示从输入图像中提取小块。每个小块的高度和宽度都等于 kernelSize(在这个例子中为 3)。由于输入图像的高度为 4,沿着高度维度只有两种可能的滑动位置,因为我们需要确保 3 × 3 窗口不会超出输入图像的边界。同样,输入图像的宽度(5)给出了三个可能的宽度维度滑动位置。因此,我们最终提取出 2 × 3 = 6 个图像小块。
在每个滑动窗口位置,都会进行一次点积操作。回想一下,卷积核的形状为[3, 3, 2, 3]。我们可以沿着最后一个维度将 4D 张量分解为三个单独的 3D 张量,每个张量的形状为[3, 3, 2],如图 4.3 中的哈希线所示。我们取图像块和三维张量之一,将它们逐像素相乘,并将所有3 * 3 * 2 = 18个值求和以获得输出张量中的一个像素。图 4.4 详细说明了点积步骤。图像块和卷积核切片具有完全相同的形状并非巧合——我们基于内核的形状提取了图像块!这个乘加操作对所有三个内核切片重复进行,得到一组三个数字。然后,该点积操作对其余的图像块重复进行,得到图中六列三个立方体。这些列最终被组合成输出,其 HWC 形状为[2, 3, 3]。
图 4.4. 在 2D 卷积操作中的点积(即乘加)操作的示意图,这是图 4.3 中概述的完整工作流程中的一步。为了说明方便,假设图像块(x)只包含一个颜色通道。图像块的形状为[3, 3, 1],与卷积核切片(K)的大小相同。第一步是逐元素相乘,产生另一个[3, 3, 1]张量。新张量的元素被加在一起(由σ表示),和即为结果。

像密集层一样,conv2d 层有一个偏置项,它被加到卷积结果中。此外,conv2d 层通常配置为具有非线性激活函数。在这个例子中,我们使用 relu。回想一下,在第三章的“避免堆叠层而不使用非线性函数的谬论”一节中,我们警告说堆叠两个没有非线性的密集层在数学上等价于使用单个密集层。类似的警告也适用于 conv2d 层:堆叠两个这样的层而没有非线性激活在数学上等价于使用一个具有更大内核的单个 conv2d 层,因此这是一种应该避免的构建卷积网络的低效方式。
哇!关于 conv2d 层如何工作的细节就是这些。让我们退后一步,看看 conv2d 实际上实现了什么。简而言之,这是一种将输入图像转换为输出图像的特殊方式。输出图像通常比输入图像具有较小的高度和宽度。尺寸的减小取决于kernelSize配置。输出图像可能具有比输入更少、更多或相同的通道数,这取决于filters配置。
conv2d 是一种图像到图像的转换。conv2d 转换的两个关键特性是局部性和参数共享:
-
局部性 指的是输出图像中给定像素的值仅受到输入图像中一个小区域的影响,而不是受到输入图像中所有像素的影响。该区域的大小为
kernelSize。这就是 conv2d 与密集层的不同之处:在密集层中,每个输出元素都受到每个输入元素的影响。换句话说,在密集层中,输入元素和输出元素在“密集连接”(因此称为密集层);而 conv2d 层是“稀疏连接”的。虽然密集层学习输入中的全局模式,但卷积层学习局部模式——卷积核的小窗口内的模式。 -
参数共享 指的是输出像素 A 受其小输入区域影响的方式与输出像素 B 受其输入区域影响的方式完全相同。这是因为每个滑动位置的点积都使用相同的卷积核(图 4.3)。
由于局部性和参数共享,conv2d 层在所需参数数量方面是高效的图像到图像变换。特别地,卷积核的大小不随输入图像的高度或宽度而变化。回到列表 4.1 中的第一个 conv2d 层,核的形状为[kernelSize, kernelSize, 1, filter](即[5, 5, 1, 8]),因此总共有 5 * 5 * 1 * 8 = 200 个参数,不管输入的 MNIST 图像是 28 × 28 还是更大。第一个 conv2d 层的输出形状为[24, 24, 8](省略批次维度)。所以,conv2d 层将由 28 * 28 * 1 = 784 个元素组成的张量转换为由 24 * 24 * 8 = 4,608 个元素组成的另一个张量。如果我们要用密集层来实现这个转换,将涉及多少参数?答案是 784 * 4,608 = 3,612,672(不包括偏差),这约是 conv2d 层的 18 千倍!这个思想实验展示了卷积层的效率。
conv2d 的局部性和参数共享之美不仅在于其效率,还在于它(以松散的方式)模拟了生物视觉系统的工作方式。考虑视网膜上的神经元。每个神经元只受到眼睛视野中的一个小区域的影响,称为感受野。位于视网膜不同位置的两个神经元对其各自感受野中的光模式的响应方式几乎相同,这类似于 conv2d 层中的参数共享。更重要的是,conv2d 层在计算机视觉问题中表现良好,正如我们将在这个 MNIST 示例中很快看到的那样。conv2d 是一个很棒的神经网络层,它集效率、准确性和与生物学相关性于一身。难怪它在深度学习中被如此广泛地使用。
4.2.2. maxPooling2d 层
在研究了 conv2d 层之后,让我们看一下顺序模型中的下一层——maxPooling2d 层。像 conv2d 一样,maxPooling2d 是一种图像到图像的转换。但与 conv2d 相比,maxPooling2d 转换更简单。正如图 4.5 所示,它简单地计算小图像块中的最大像素值,并将它们用作输出中的像素值。定义并添加 maxPooling2d 层的代码为
model.add(tf.layers.maxPooling2d({poolSize: 2, strides: 2}));
图 4.5. maxPooling2D 层的工作原理示例。此示例使用一个小的 4 × 4 图像,并假设 maxPooling2D 层配置为poolSize为[2, 2]和strides为[2, 2]。深度维度未显示,但 max-pooling 操作独立地在各维度上进行。

在这种特定情况下,由于指定的poolSize值为[2, 2],图像块的高度和宽度为 2 × 2。沿着两个维度,每隔两个像素提取图像块。这些图像块之间的间隔是由我们在此处使用的strides值决定的:[2, 2]。因此,输出图像的 HWC 形状为[12, 12, 8],高度和宽度是输入图像(形状为[24, 24, 8])的一半,但具有相同数量的通道。
maxPooling2d 层在卷积网络中有两个主要目的。首先,它使卷积网络对输入图像中关键特征的确切位置不那么敏感。例如,我们希望能够识别出数字“8”,无论它是否从 28 × 28 输入图像的中心向左或向右移动(或者从上到下移动),这种特性称为位置不变性。要理解 maxPooling2d 层如何增强位置不变性,需要意识到 maxPooling2d 在操作的每个图像块内部,最亮的像素位于何处并不重要,只要它落入该图像块内即可。诚然,单个 maxPooling2d 层在使卷积网络不受位移影响方面能做的事情有限,因为它的池化窗口是有限的。然而,当在同一个卷积网络中使用多个 maxPooling2d 层时,它们共同努力实现了更大程度的位置不变性。这正是我们 MNIST 模型中所做的事情——以及几乎所有实际卷积网络中所做的事情——其中包含两个 maxPooling2d 层。
作为一个思想实验,考虑当两个 conv2d 层(称为 conv2d_1 和 conv2d_2)直接叠加在一起而没有中间的 maxPooling2d 层时会发生什么。假设这两个 conv2d 层的kernelSize都为 3;那么 conv2d_2 输出张量中的每个像素都是原始输入到 conv2d_1 的 5 × 5 区域的函数。我们可以说 conv2d_2 层的每个“神经元”具有 5 × 5 的感受野。当两个 conv2d 层之间存在一个 maxPooling2d 层时会发生什么(就像我们的 MNIST 卷积网络中的情况一样)?conv2d_2 层的神经元的感受野变得更大:11 × 11。当卷积网络中存在多个 maxPooling2d 层时,较高层次的层可以具有广泛的感受野和位置不变性。简而言之,它们可以看得更广!
第二,一个 maxPooling2d 层也会使输入张量的高度和宽度尺寸缩小,大大减少了后续层次和整个卷积网络中所需的计算量。例如,第一个 conv2d 层的输出张量形状为[26, 26, 16]。经过 maxPooling2d 层后,张量形状变为[13, 13, 16],将张量元素数量减少了 4 倍。卷积网络包含另一个 maxPooling2d 层,进一步缩小了后续层次的权重尺寸和这些层次中的逐元素数学运算的数量。
4.2.3. 卷积和池化的重复模式
在审查了第一个 maxPooling2d 层后,让我们将注意力集中在卷积网络的接下来的两层上,这两层由 list 4.1 中的这些行定义:
model.add(tf.layers.conv2d(
{kernelSize: 3, filters: 32, activation: 'relu'}));
model.add(tf.layers.maxPooling2d({poolSize: 2, strides: 2}));
这两个层与前面的两个层完全相同(除了 conv2d 层的filters配置有一个更大的值并且没有inputShape字段)。这种几乎重复的“基本图案”由一个卷积层和一个池化层组成,在 convnets 中经常见到。它发挥了关键作用:分层特征提取。要理解这意味着什么,考虑一个用于图像中动物分类任务的 convnet。在 convnet 的早期阶段,卷积层中的滤波器(即通道)可能编码低级几何特征,如直线、曲线和角落。这些低级特征被转换成更复杂的特征,如猫的眼睛、鼻子和耳朵(见 图 4.6)。在 convnet 的顶层,一层可能具有编码整个猫的滤波器。层级越高,表示越抽象,与像素级值越远。但这些抽象特征正是在 convnet 任务上取得良好准确率所需要的特征,例如在图像中存在猫时检测出猫。此外,这些特征不是手工制作的,而是通过监督学习以自动方式从数据中提取的。这是我们在 第一章 中描述的深度学习的本质,即逐层表示变换的典型示例。
图 4.6. 通过 convnet 从输入图像中分层提取特征,以猫图像为例。请注意,在此示例中,神经网络的输入在底部,输出在顶部。

4.2.4. 压扁和稠密层
在输入张量通过两组 conv2d-maxPooling2d 变换后,它变成了一个形状为[4, 4, 16]的 HWC 形状的张量(不包括批次维度)。在 convnet 中的下一层是一个压扁层。这一层在前面的 conv2d-maxPooling2d 层和顺序模型的后续层之间形成了一个桥梁。
Flatten 层的代码很简单,因为构造函数不需要任何配置参数:
model.add(tf.layers.flatten());
Flatten 层将多维张量“压缩”成一个一维张量,保留元素的总数。在我们的例子中,形状为[3, 3, 32]的三维张量被压扁成一个一维张量[288](不包括批次维度)。对于压扁操作一个明显的问题是如何排序元素,因为原始的三维空间没有固有的顺序。答案是,我们按照这样的顺序排列元素:当你在压扁后的一维张量中向下移动并观察它们的原始索引(来自三维张量)如何变化时,最后一个索引变化得最快,倒数第二个索引变化得次快,依此类推,而第一个索引变化得最慢。这在 图 4.7 中有所说明。
图 4.7.flatten 层的工作原理。假设输入是一个 3D 张量。为了简单起见,我们让每个维度的大小都设为 2。方块表示元素,元素的索引显示在方块的“面”上。flatten 层将 3D 张量转换成 1D 张量,同时保持元素的总数不变。在展平的 1D 张量中,元素的顺序是这样安排的:当您沿着输出 1D 张量的元素向下走,并检查它们在输入张量中的原始索引时,最后一个维度变化得最快。

flatten 层在我们的卷积网络中起到什么作用呢?它为随后的密集层做好了准备。就像我们在第二章和第三章学到的那样,由于密集层的工作原理(第 2.1.4 节),它通常需要一个 1D 张量(不包括批次维度)作为其输入。
代码中的下两行,将两个密集层添加到卷积网络中。
model.add(tf.layers.dense({units: 64, activation: 'relu'}));
model.add(tf.layers.dense({units: 10, activation: 'softmax'}));
为什么要使用两个密集层而不只是一个?与波士顿房产示例和第三章中的网络钓鱼 URL 检测示例的原因相同:添加具有非线性激活的层增加了网络的容量。实际上,您可以将卷积网络视为在此之上堆叠了两个模型:
-
包含 conv2d、maxPooling2d 和 flatten 层的模型,从输入图像中提取视觉特征
-
一个具有两个密集层的多层感知器(MLP),使用提取的特征进行数字分类预测——这本质上就是两个密集层的用途。
在深度学习中,许多模型都显示了这种特征提取层后面跟着 MLPs 进行最终预测的模式。在本书的其余部分中,我们将看到更多类似的示例,从音频信号分类器到自然语言处理的模型都会有。
4.2.5. 训练卷积网络
现在我们已经成功定义了卷积网络的拓扑结构,下一步是训练并评估训练结果。下一个清单中的代码就是用来实现这个目的的。
清单 4.2. 训练和评估 MNIST 卷积网络
const optimizer = 'rmsprop';
model.compile({
optimizer,
loss: 'categoricalCrossentropy',
metrics: ['accuracy']
});
const batchSize = 320;
const validationSplit = 0.15;
await model.fit(trainData.xs, trainData.labels, {
batchSize,
validationSplit,
epochs: trainEpochs,
callbacks: {
onBatchEnd: async (batch, logs) => { ***1***
trainBatchCount++;
ui.logStatus(
`Training... (` +
`${(trainBatchCount / totalNumBatches * 100).toFixed(1)}%` +
` complete). To stop training, refresh or close page.`);
ui.plotLoss(trainBatchCount, logs.loss, 'train');
ui.plotAccuracy(trainBatchCount, logs.acc, 'train');
},
onEpochEnd: async (epoch, logs) => {
valAcc = logs.val_acc;
ui.plotLoss(trainBatchCount, logs.val_loss, 'validation');
ui.plotAccuracy(trainBatchCount, logs.val_acc, 'validation');
}
}
});
const testResult = model.evaluate(
testData.xs, testData.labels); ***2***
-
1 使用回调函数来绘制训练期间的准确率和损失图
-
2 使用模型没有看见的数据来评估模型的准确性
很多这里的代码都是关于在训练过程中更新用户界面的,例如绘制损失和准确率值的变化。这对于监控训练过程很有用,但对于模型训练来说并不是必需的。让我们来强调一下训练所必需的部分:
-
trainData.xs(model.fit()的第一个参数)包含作为形状为[N, 28, 28, 1]的 NHWC 张量的输入 MNIST 图像表示 -
trainData.labels(model.fit()的第二个参数)。这包括作为形状为`N, -
在
model.compile()调用中使用的损失函数categoricalCrossentropy,适用于诸如 MNIST 的多类分类问题。回想一下,我们在第三章中也使用了相同的损失函数来解决鸢尾花分类问题。 -
在
model.compile()调用中指定的度量函数:'accuracy'。该函数衡量了多大比例的示例被正确分类,假设预测是基于卷积神经网络输出的 10 个元素中最大的元素。再次强调,这与我们在新闻线问题中使用的度量标准完全相同。回想一下交叉熵损失和准确度度量之间的区别:交叉熵可微分,因此可以进行基于反向传播的训练,而准确度度量不可微分,但更容易解释。 -
在
model.fit()调用中指定的batchSize参数。一般来说,使用较大的批量大小的好处是,它会产生对模型权重的更一致和 less 变化的梯度更新,而不是较小的批量大小。但是,批量大小越大,训练过程中所需的内存就越多。您还应该记住,给定相同数量的训练数据,较大的批量大小会导致每个 epoch 中的梯度更新数量减少。因此,如果使用较大的批量大小,请务必相应地增加 epoch 的数量,以免在训练过程中意外减少权重更新的数量。因此,存在一种权衡。在这里,我们使用相对较小的批量大小 64,因为我们需要确保这个示例适用于各种硬件。与其他参数一样,您可以修改源代码并刷新页面,以便尝试使用不同批量大小的效果。 -
在
model.fit()调用中使用的validationSplit。这使得训练过程中排除了trainData.xs和trainData.labels的最后 15% 以供验证。就像你在之前的非图像模型中学到的那样,监控验证损失和准确度在训练过程中非常重要。它让你了解模型是否过拟合。什么是过拟合?简单地说,这是指模型过于关注训练过程中看到的数据的细节,以至于其在训练过程中没有看到的数据上的预测准确性受到负面影响。这是监督式机器学习中的一个关键概念。在本书的后面章节(第八章)中,我们将专门讨论如何发现和对抗过拟合。
model.fit() 是一个异步函数,因此如果后续操作依赖于 fit() 调用的完成,则需要在其上使用 await。这正是这里所做的,因为我们需要在模型训练完成后使用测试数据集对模型进行评估。评估是使用 model.evaluate() 方法进行的,该方法是同步的。传递给 model.evaluate() 的数据是 testData,其格式与前面提到的 trainData 相同,但包含较少数量的示例。这些示例在 fit() 调用期间模型从未见过,确保测试数据集不会影响评估结果,并且评估结果是对模型质量的客观评估。
使用这段代码,我们让模型训练了 10 个 epoch(在输入框中指定),这给我们提供了 figure 4.8 中的损失和准确度曲线。如图所示,损失在训练 epochs 结束时收敛,准确度也是如此。验证集的损失和准确度值与其训练集对应值相差不大,这表明在这种情况下没有明显的过拟合。最终的 model.evaluate() 调用给出了约为 99.0% 的准确度(实际值会因权重的随机初始化和训练过程中示例的随机洗牌而略有变化)。
图 4.8. MNIST 卷积神经网络的训练曲线。进行了十个 epoch 的训练,每个 epoch 大约包含 800 个批次。左图:损失值。右图:准确度值。训练集和验证集的数值由不同颜色、线宽和标记符号表示。验证曲线的数据点比训练数据少,因为验证是在每个 epoch 结束时进行,而不像训练批次那样频繁。
![
99.0% 的准确度如何?从实际角度来看,这是可以接受的,但肯定不是最先进的水平。通过增加卷积层和池化层以及模型中的滤波器数量,可以达到准确率达到 99.5% 的可能性。然而,在浏览器中训练这些更大的卷积神经网络需要更长的时间,以至于最好在像 Node.js 这样资源不受限制的环境中进行训练。我们将在 section 4.3 中准确地介绍如何做到这一点。
从理论角度来看,记住 MNIST 是一个 10 类别分类问题。因此,偶然猜测的准确率是 10%;而 99.0% 远远超过了这个水平。但偶然猜测并不是一个很高的标准。我们如何展示模型中的 conv2d 和 maxPooling2d 层的价值?如果我们坚持使用传统的全连接层,我们会做得像这样吗?
要回答这些问题,我们可以进行一个实验。index.js 中的代码包含了另一个用于模型创建的函数,名为 createDenseModel()。与我们在 列表 4.1 中看到的 createConvModel() 函数不同,createDenseModel() 创建的是仅由展平和密集层组成的顺序模型,即不使用本章学习的新层类型。createDenseModel() 确保它所创建的密集模型和我们刚刚训练的卷积网络之间的总参数数量大约相等 —— 约为 33,000,因此这将是一个更公平的比较。
列表 4.3. 用于与卷积网络进行比较的 MNIST 的展平-仅密集模型
function createDenseModel() {
const model = tf.sequential();
model.add(tf.layers.flatten({inputShape: [IMAGE_H, IMAGE_W, 1]}));
model.add(tf.layers.dense({units: 42, activation: 'relu'}));
model.add(tf.layers.dense({units: 10, activation: 'softmax'}));
model.summary();
return model;
}
列表 4.3 中定义的模型概述如下:
_________________________________________________________________
Layer (type) Output shape Param #
=================================================================
flatten_Flatten1 (Flatten) [null,784] 0
_________________________________________________________________
dense_Dense1 (Dense) [null,42] 32970
_________________________________________________________________
dense_Dense2 (Dense) [null,10] 430
=================================================================
Total params: 33400
Trainable params: 33400
Non-trainable params: 0
_________________________________________________________________
使用相同的训练配置,我们从非卷积模型中获得的训练结果如 图 4.9 所示。经过 10 次训练周期后,我们获得的最终评估准确率约为 97.0%。两个百分点的差异可能看起来很小,但就误差率而言,非卷积模型比卷积网络差三倍。作为一项动手练习,尝试通过增加 createDenseModel() 函数中的隐藏(第一个)密集层的 units 参数来增加非卷积模型的大小。你会发现,即使增加了更大的尺寸,仅有密集层的模型也无法达到与卷积网络相当的准确性。这向你展示了卷积网络的强大之处:通过参数共享和利用视觉特征的局部性,卷积网络可以在计算机视觉任务中实现优越的准确性,其参数数量相等或更少于非卷积神经网络。
图 4.9. 与 图 4.8 相同,但用于 MNIST 问题的非卷积模型,由 列表 4.3 中的 createDenseModel() 函数创建

4.2.6. 使用卷积网络进行预测
现在我们有了一个训练好的卷积网络。我们如何使用它来实际分类手写数字的图像呢?首先,你需要获得图像数据。有许多方法可以将图像数据提供给 TensorFlow.js 模型。我们将列出它们并描述它们何时适用。
从 TypedArrays 创建图像张量
在某些情况下,您想要的图像数据已经存储为 JavaScript 的 TypedArray。这就是我们专注于的 tfjs-example/mnist 示例中的情况。详细信息请参见 data.js 文件,我们不会详细说明其中的机制。假设有一个表示正确长度的 MNIST 的 Float32Array(例如,一个名为 imageDataArray 的变量),我们可以将其转换为我们的模型所期望的形状的 4D 张量,如下所示^([7]):
⁷
参见附录 B 了解如何使用 TensorFlow.js 中的低级 API 创建张量的更全面教程。
let x = tf.tensor4d(imageDataArray, [1, 28, 28, 1]);
tf.tensor4d() 调用中的第二个参数指定要创建的张量的形状。这是必需的,因为 Float32Array(或一般的 TypedArray)是一个没有关于图像尺寸信息的平坦结构。第一个维度的大小为 1,因为我们正在处理 imageDataArray 中的单个图像。与之前的示例一样,在训练、评估和推断期间,模型始终期望有一个批次维度,无论是一个图像还是多个图像。如果 Float32Array 包含多个图像的批次,则还可以将其转换为单个张量,其中第一个维度的大小等于图像数量:
let x = tf.tensor4d(imageDataArray, [numImages, 28, 28, 1]);
tf.browser.fromPixels:从 HTML img、canvas 或 video 元素获取图像张量
浏览器中获取图像张量的第二种方法是在包含图像数据的 HTML 元素上使用 TensorFlow.js 函数 tf.browser.fromPixels() ——这包括 img、canvas 和 video 元素。
例如,假设网页包含一个如下定义的 img 元素
<img id="my-image" src="foo.jpg"></img>
你可以用一行代码获取显示在 img 元素中的图像数据:
let x = tf.browser.fromPixels(
document.getElementById('my-image')).asType('float32');
这将生成形状为 [height, width, 3] 的张量,其中三个通道用于 RGB 颜色编码。末尾的 asType0 调用是必需的,因为 tf.browser.fromPixels() 返回一个 int32 类型的张量,但 convnet 期望输入为 float32 类型的张量。高度和宽度由 img 元素的大小确定。如果它与模型期望的高度和宽度不匹配,您可以通过使用 TensorFlow.js 提供的两种图像调整方法之一 tf.image.resizeBilinear() 或 tf.image.resizeNearestNeigbor() 来改变 tf.browser.fromPixels() 返回的张量大小:
x = tf.image.resizeBilinear(x, [newHeight, newWidth]);
tf.image.resizeBilinear() 和 tf.image.resizeNearestNeighbor() 具有相同的语法,但它们使用两种不同的算法进行图像调整。前者使用双线性插值来生成新张量中的像素值,而后者执行最近邻采样,通常比双线性插值计算量小。
请注意,tf.browser.fromPixels() 创建的张量不包括批次维度。因此,如果张量要被馈送到 TensorFlow.js 模型中,必须首先进行维度扩展;例如,
x = x.expandDims();
expandDims() 通常需要一个维度参数。但在这种情况下,可以省略该参数,因为我们正在扩展默认为该参数的第一个维度。
除了 img 元素外,tf.browser.fromPixels() 也适用于 canvas 和 video 元素。在 canvas 元素上应用 tf.browser.fromPixels() 对于用户可以交互地改变 canvas 内容然后使用 TensorFlow.js 模型的情况非常有用。例如,想象一下在线手写识别应用或在线手绘形状识别应用。除了静态图像外,在 video 元素上应用 tf.browser.fromPixels() 对于从网络摄像头获取逐帧图像数据非常有用。这正是在 Nikhil Thorat 和 Daniel Smilkov 在最初的 TensorFlow.js 发布中展示的 Pac-Man 演示中所做的(参见 mng.bz/xl0e),PoseNet 演示,^([8]) 以及许多其他使用网络摄像头的基于 TensorFlow.js 的网络应用程序。你可以在 GitHub 上查看源代码 mng.bz/ANYK。
⁸
Dan Oved,“使用 TensorFlow.js 在浏览器中进行实时人体姿势估计”,Medium,2018 年 5 月 7 日,
mng.bz/ZeOO。
正如我们在前面的章节中所看到的,应该非常小心避免训练数据和推断数据之间的 偏差(即不匹配)。在这种情况下,我们的 MNIST 卷积网络是使用范围在 0 到 1 之间的图像张量进行训练的。因此,如果 x 张量中的数据范围不同,比如常见的 HTML 图像数据范围是 0 到 255,那么我们应该对数据进行归一化:
x = x.div(255);
有了手头的数据,我们现在准备调用 model.predict() 来获取预测结果。请参见下面的清单。
清单 4.4。使用训练好的卷积网络进行推断
const testExamples = 100;
const examples = data.getTestData(testExamples);
tf.tidy(() => { ***1***
const output = model.predict(examples.xs);
const axis = 1;
const labels = Array.from(examples.labels.argMax(axis).dataSync());
const predictions = Array.from(
output.argMax(axis).dataSync()); ***2***
ui.showTestResults(examples, predictions, labels);
});
-
1 使用 tf.tidy() 避免 WebGL 内存泄漏
-
2 调用 argMax() 函数获取概率最大的类别
该代码假设用于预测的图像批已经以一个单一张量的形式可用,即 examples.xs。它的形状是 [100, 28, 28, 1](包括批处理维度),其中第一个维度反映了我们要运行预测的 100 张图像。model.predict() 返回一个形状为 [100, 10] 的输出二维张量。输出的第一个维度对应于示例,而第二个维度对应于 10 个可能的数字。输出张量的每一行包含为给定图像输入分配的 10 个数字的概率值。为了确定预测结果,我们需要逐个图像找出最大概率值的索引。这是通过以下代码完成的
const axis = 1;
const labels = Array.from(examples.labels.argMax(axis).dataSync());
argMax() 函数返回沿指定轴的最大值的索引。在这种情况下,这个轴是第二维,const axis = 1。argMax() 的返回值是一个形状为 [100, 1] 的张量。通过调用 dataSync(),我们将 [100, 1] 形状的张量转换为长度为 100 的 Float32Array。然后 Array.from() 将 Float32Array 转换为由 100 个介于 0 和 9 之间的整数组成的普通 JavaScript 数组。这个预测数组有一个非常直观的含义:它是模型对这 100 个输入图像的分类结果。在 MNIST 数据集中,目标标签恰好与输出索引完全匹配。因此,我们甚至不需要将数组转换为字符串标签。下一行消耗了预测数组,调用了一个 UI 函数,该函数将分类结果与测试图像一起呈现(见 图 4.10)。
图 4.10. 训练后模型进行预测的几个示例,显示在输入的 MNIST 图像旁边

4.3. 超越浏览器:使用 Node.js 更快地训练模型
在前一节中,我们在浏览器中训练了一个卷积网络,测试准确率达到了 99.0%。在本节中,我们将创建一个更强大的卷积网络,它将给我们更高的测试准确率:大约 99.5%。然而,提高的准确性也伴随着代价:模型在训练和推断期间消耗的内存和计算量更多。成本的增加在训练期间更为显著,因为训练涉及反向传播,这与推断涉及的前向运行相比,需要更多的计算资源。较大的卷积网络将过重且速度过慢,在大多数 web 浏览器环境下训练。
4.3.1. 使用 tfjs-node 的依赖项和导入
进入 TensorFlow.js 的 Node.js 版本!它在后端环境中运行,不受像浏览器标签那样的任何资源限制。TensorFlow.js 的 CPU 版本(此处简称 tfjs-node)直接使用了在 C++ 中编写的多线程数学运算,这些数学运算也被 TensorFlow 的主 Python 版本使用。如果您的计算机安装了支持 CUDA 的 GPU,tfjs-node 还可以使用 CUDA 编写的 GPU 加速数学核心,实现更大的速度提升。
我们增强的 MNIST 卷积神经网络的代码位于 tfjs-examples 的 mnist-node 目录中。与我们所见的示例一样,您可以使用以下命令访问代码:
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/mnist-node
与之前的示例不同之处在于,mnist-node 示例将在终端而不是 web 浏览器中运行。要下载依赖项,请使用 yarn 命令。
如果你检查 package.json 文件,你会看到依赖项 @tensorflow/tfjs-node。通过将 @tensorflow/tfjs-node 声明为依赖项,yarn 将自动下载 C++ 共享库(在 Linux、Mac 或 Windows 系统上分别命名为 libtensorflow.so、libtensorflw .dylib 或 libtensorflow.dll)到你的 node_modules 目录,以供 TensorFlow.js 使用。
一旦 yarn 命令运行完毕,你就可以开始模型训练了
node main.js
我们假设你的路径上已经有了节点二进制文件,因为你已经安装了 yarn(如果你需要更多关于这个的信息,请参见附录 A)。
刚刚描述的工作流将允许你在 CPU 上训练增强的 convnet。如果你的工作站和笔记本电脑内置了 CUDA 启用的 GPU,你也可以在 GPU 上训练模型。所涉及的步骤如下:
-
安装正确版本的 NVIDIA 驱动程序以适配你的 GPU。
-
安装 NVIDIA CUDA 工具包。这是一个库,可以在 NVIDIA 的 GPU 系列上实现通用并行计算。
-
安装 CuDNN,这是基于 CUDA 构建的高性能深度学习算法的 NVIDIA 库(有关步骤 1-3 的更多详细信息,请参见附录 A)。
-
在 package.json 中,将
@tensorflow/tfjs-node依赖项替换为@-tensor-flow/tfjs-node-gpu,但保持相同的版本号,因为这两个软件包的发布是同步的。 -
再次运行
yarn,这将下载包含 TensorFlow.js 用于 CUDA 数学运算的共享库。 -
在 main.js 中,将该行替换为
require('@tensorflow/tfjs-node');使用
require('@tensorflow/tfjs-node-gpu'); -
再次开始训练
node main.js
如果步骤正确完成,你的模型将在 CUDA GPU 上迅猛地训练,在速度上通常是 CPU 版本(tfjs-node)的五倍。与在浏览器中训练相同模型相比,使用 tfjs-node 的 CPU 或 GPU 版本训练速度都显著提高。
在 tfjs-node 中为 MNIST 训练增强的 convnet
一旦在 20 个周期内完成训练,模型应该显示出约 99.6% 的最终测试(或评估)准确度,这超过了我们在 section 4.2 中取得的 99.0% 的先前结果。那么,导致准确度提高的是这个基于节点的模型和基于浏览器的模型之间的区别是什么呢?毕竟,如果你使用训练数据在 tfjs-node 和 TensorFlow.js 的浏览器版本中训练相同的模型,你应该得到相同的结果(除了随机权重初始化的影响)。为了回答这个问题,让我们来看看基于节点的模型的定义。模型是在文件 model.js 中构建的,这个文件由 main.js 导入。
列表 4.5. 在 Node.js 中定义一个更大的 MNIST convnet
const model = tf.sequential();
model.add(tf.layers.conv2d({
inputShape: [28, 28, 1],
filters: 32,
kernelSize: 3,
activation: 'relu',
}));
model.add(tf.layers.conv2d({
filters: 32,
kernelSize: 3,
activation: 'relu',
}));
model.add(tf.layers.maxPooling2d({poolSize: [2, 2]}));
model.add(tf.layers.conv2d({
filters: 64,
kernelSize: 3,
activation: 'relu',
}));
model.add(tf.layers.conv2d({
filters: 64,
kernelSize: 3,
activation: 'relu',
}));
model.add(tf.layers.maxPooling2d({poolSize: [2, 2]}));
model.add(tf.layers.flatten());
model.add(tf.layers.dropout({rate: 0.25})); ***1***
model.add(tf.layers.dense({units: 512, activation: 'relu'}));
model.add(tf.layers.dropout({rate: 0.5}));
model.add(tf.layers.dense({units: 10, activation: 'softmax'}));
model.summary();
model.compile({
optimizer: 'rmsprop',
loss: 'categoricalCrossentropy',
metrics: ['accuracy'],
});
- 1 添加了 dropout 层以减少过拟合
模型的摘要如下:
_________________________________________________________________
Layer (type) Output shape Param #
=================================================================
conv2d_Conv2D1 (Conv2D) [null,26,26,32] 320
_________________________________________________________________
conv2d_Conv2D2 (Conv2D) [null,24,24,32] 9248
_________________________________________________________________
max_pooling2d_MaxPooling2D1 [null,12,12,32] 0
_________________________________________________________________
conv2d_Conv2D3 (Conv2D) [null,10,10,64] 18496
_________________________________________________________________
conv2d_Conv2D4 (Conv2D) [null,8,8,64] 36928
_________________________________________________________________
max_pooling2d_MaxPooling2D2 [null,4,4,64] 0
_________________________________________________________________
flatten_Flatten1 (Flatten) [null,1024] 0
_________________________________________________________________
dropout_Dropout1 (Dropout) [null,1024] 0
_________________________________________________________________
dense_Dense1 (Dense) [null,512] 524800
_________________________________________________________________
dropout_Dropout2 (Dropout) [null,512] 0
_________________________________________________________________
dense_Dense2 (Dense) [null,10] 5130
=================================================================
Total params: 594922
Trainable params: 594922
Non-trainable params: 0
_________________________________________________________________
这些是我们的 tfjs-node 模型和基于浏览器的模型之间的关键区别:
-
基于节点的模型具有四个 conv2d 层,比基于浏览器的模型多一个。
-
基于节点的模型的 hidden dense 层比基于浏览器的模型的对应层单元更多(512 与 100)。
-
总体而言,基于节点的模型的权重参数约为基于浏览器的模型的 18 倍。
-
基于节点的模型在 flatten 层和 dense 层之间插入了两个dropout层。
列表中的前三个差异使基于节点的模型具有比基于浏览器的模型更高的容量。这也是使基于节点的模型在浏览器中训练速度无法接受的原因。正如我们在第三章中学到的那样,更大的模型容量意味着更大的过拟合风险。第四个差异增加了过拟合风险的减轻,即包括 dropout 层。
使用 dropout 层来减少过拟合。
Dropout 是你在本章中遇到的另一个新的 TensorFlow.js 层类型之一。它是减少深层神经网络过拟合最有效和广泛使用的方式之一。它的功能可以简单地描述为:
-
在训练阶段(在
Model.fit()调用期间),它随机将输入张量的一部分作为零(或“丢失”),并且其输出张量是 dropout 层的输出张量。对于本示例来说,dropout 层只有一个配置参数:dropout 比例(例如,如列表 4.5 所示的两个rate字段)。例如,假设一个 dropout 层被配置为有一个 0.25 的 dropout 比例,并且输入张量是值为[0.7, -0.3, 0.8, -0.4]的 1D 张量,则输出张量可以是[0.7, -0.3, 0.0, 0.4],其中 25%的输入张量元素随机选择并设置为值 0。在反向传播期间,dropout 层的梯度张量也会受到类似的归 0 影响。 -
在推理期间(在
Model.predict()和Model.evaluate()调用期间),dropout 层不会随机将输入张量中的元素置零。相反,输入会简单地作为输出传递,不会发生任何改变(即,一个恒等映射)。
图 4.11 展示了一个带有二维输入张量的 dropout 层在训练和测试时的工作示例。
图 4.11. dropout 层的一个示例。在这个示例中,输入张量是 2D 的,形状为[4, 2]。dropout 层的比例被配置为 0.25,导致在训练阶段随机选择输入张量中 25%(即 8 个中的两个)的元素并将它们设置为零。在推理阶段,该层充当一个简单的传递层。

这种简单算法是对抗过拟合最有效的方法之一似乎很奇怪。为什么它有效呢?Geoff Hinton,是 dropout 算法的发明者(神经网络中的许多其他内容也是他的创造),他说他受到了一些银行用于防止员工欺诈的机制的启发。用他自己的话说,
我去了我的银行。出纳员不停地换,我问其中一个为什么。他说他不知道,但他们经常被调动。我想这一定是因为需要员工之间的合作才能成功欺诈银行。这让我意识到,随机移除每个示例上的不同子集神经元将防止共谋,从而减少过拟合。
将这种深度学习的术语引入,向层的输出值引入噪声会破坏不重要的偶然模式,这些模式与数据中的真实模式不相关(Hinton 称之为“共谋”)。在本章末尾的练习 3 中,您应该尝试从 model.js 中的基于节点的卷积网络中移除两个 dropout 层,然后再次训练模型,并查看由此导致的训练、验证和评估准确度的变化。
清单 4.6 显示了我们用于训练和评估增强型卷积网络的关键代码。如果您将此处的代码与 清单 4.2 中的代码进行比较,您就会欣赏到这两个代码块之间的相似之处。两者都围绕着 Model.fit() 和 Model.evaluate() 调用。语法和样式相同,只是在如何呈现或显示损失值、准确度值和训练进度上有所不同(终端与浏览器)。
这展示了 TensorFlow.js 的一个重要特性,这是一个跨越前端和后端的 JavaScript 深度学习框架:
就创建和训练模型而言,在 TensorFlow.js 中编写的代码与您是在 web 浏览器还是在 Node.js 中工作无关。
列表 4.6. 在 tfjs-node 中训练和评估增强型卷积网络
await model.fit(trainImages, trainLabels, {
epochs,
batchSize,
validationSplit
});
const {images: testImages, labels: testLabels} = data.getTestData();
const evalOutput = model.evaluate ***1***
testImages, testLabels);
console.log('\nEvaluation result:');
console.log(
` Loss = ${evalOutput[0].dataSync()[0].toFixed(3)}; `+
`Accuracy = ${evalOutput[1].dataSync()[0].toFixed(3)}`);
- 1 使用模型未见过的数据评估模型
4.3.2. 从 Node.js 保存模型并在浏览器中加载
训练模型会消耗 CPU 和 GPU 资源,并需要一些时间。您不希望浪费训练的成果。如果不保存模型,下次运行 main.js 时,您将不得不从头开始。本节展示了如何在训练后保存模型,并将保存的模型导出为磁盘上的文件(称为 检查点 或 工件)。我们还将向您展示如何在浏览器中导入检查点,将其重新构建为模型,并用于推断。main.js 中 main() 函数的最后一部分是以下清单中的保存模型代码。
列表 4.7. 在 tfjs-node 中将训练好的模型保存到文件系统中
if (modelSavePath != null) {
await model.save(`file://${modelSavePath}`);
console.log(`Saved model to path: ${modelSavePath}`);
}
model对象的save()方法用于将模型保存到文件系统上的目录中。该方法接受一个参数,即以 file://开头的 URL 字符串。请注意,由于我们使用的是 tfjs-node,所以可以将模型保存在文件系统上。TensorFlow.js 的浏览器版本也提供了model.save()API,但不能直接访问机器的本地文件系统,因为浏览器出于安全原因禁止了这样做。如果我们在浏览器中使用 TensorFlow.js,则必须使用非文件系统保存目标(例如浏览器的本地存储和 IndexedDB)。这些对应于 file://以外的 URL 方案。
model.save()是一个异步函数,因为它通常涉及文件或网络输入输出。因此,在save()调用上使用await。假设modelSavePath的值为/tmp/tfjs-node-mnist;在model.save()调用完成后,您可以检查目录的内容,
ls -lh /tmp/tfjs-node-mnist
这可能打印出类似以下的文件列表:
-rw-r--r-- 1 user group 4.6K Aug 14 10:38 model.json
-rw-r--r-- 1 user group 2.3M Aug 14 10:38 weights.bin
在那里,你可以看到两个文件:
-
model.json 是一个包含模型保存拓扑的 JSON 文件。这里所说的“拓扑”包括形成模型的层类型、它们各自的配置参数(例如卷积层的
filters和 dropout 层的rate),以及层之间的连接方式。对于 MNIST 卷积网络来说,连接是简单的,因为它是一个顺序模型。我们将看到连接模式不太平凡的模型,这些模型也可以使用model.save()保存到磁盘上。 -
除了模型拓扑,model.json 还包含模型权重的清单。该部分列出了所有模型权重的名称、形状和数据类型,以及权重值存储的位置。这将我们带到第二个文件:weights.bin。正如其名称所示,weights.bin 是一个存储所有模型权重值的二进制文件。它是一个没有标记的平面二进制流,没有标明个体权重的起点和终点。这些“元信息”在 model.json 中的权重清单部分中可用于。
要使用 tfjs-node 加载模型,您可以使用tf.loadLayersModel()方法,指向 model.json 文件的位置(未在示例代码中显示):
const loadedModel = await tf.loadLayersModel('file:///tmp/tfjs-node-mnist');
tf.loadLayersModel()通过反序列化model.json中保存的拓扑数据来重建模型。然后,tf.loadLayersModel()使用model.json中的清单读取weights.bin中的二进制权重值,并将模型的权重强制设置为这些值。与model.save()一样,tf.loadLayersModel()是异步的,所以我们在这里调用它时使用await。一旦调用返回,loadedModel对象在所有意图和目的上等同于使用 listings 4.5 和 4.6 中的 JavaScript 代码创建和训练的模型。你可以通过调用其summary()方法打印模型的摘要,通过调用其predict()方法执行推理,通过使用evaluate()方法评估其准确性,甚至通过使用fit()方法重新训练它。如果需要,也可以再次保存模型。重新训练和重新保存加载的模型的工作流程将在我们讨论第五章的迁移学习时相关。
上一段中所说的内容同样适用于浏览器环境。你保存的文件可以用来在网页中重建模型。重建后的模型支持完整的tf.LayersModel()工作流程,但有一个警告,如果你重新训练整个模型,由于增强卷积网络的尺寸较大,速度会特别慢且效率低下。在 tfjs-node 和浏览器中加载模型唯一根本不同的是,你在浏览器中应该使用除file://之外的其他 URL 方案。通常,你可以将model.json和weights.bin文件作为静态资产文件放在 HTTP 服务器上。假设你的主机名是localhost,你的文件在服务器路径my/models/下可见;你可以使用以下行在浏览器中加载模型:
const loadedModel =
await tf.loadLayersModel('http:///localhost/my/models/model.json');
在浏览器中处理基于 HTTP 的模型加载时,tf.loadLayersModel()在底层调用浏览器内置的 fetch 函数。因此,它具有以下特性和属性:
-
支持
http://和https://。 -
支持相对服务器路径。事实上,如果使用相对路径,则可以省略 URL 的
http://或https://部分。例如,如果你的网页位于服务器路径my/index.html,你的模型的 JSON 文件位于my/models/model.json,你可以使用相对路径model/model.json:const loadedModel = await tf.loadLayersModel('models/model.json'); -
若要为 HTTP/HTTPS 请求指定额外选项,应该使用
tf.io.browserHTTPRequest()方法代替字符串参数。例如,在模型加载过程中包含凭据和标头,你可以这样做:const loadedModel = await tf.loadLayersModel(tf.io.browserHTTPRequest( 'http://foo.bar/path/to/model.json', {credentials: 'include', headers: {'key_1': 'value_1'}}));
4.4. 语音识别:在音频数据上应用卷积网络
到目前为止,我们已经向您展示了如何使用卷积网络执行计算机视觉任务。但是人类的感知不仅仅是视觉。音频是感知数据的一个重要模态,并且可以通过浏览器 API 进行访问。如何识别语音和其他类型声音的内容和意义?值得注意的是,卷积网络不仅适用于计算机视觉,而且在音频相关的机器学习中也以显著的方式发挥作用。
在本节中,您将看到我们如何使用类似于我们为 MNIST 构建的卷积网络来解决一个相对简单的音频任务。该任务是将短语音片段分类到 20 多个单词类别中。这个任务比您可能在亚马逊 Echo 和 Google Home 等设备中看到的语音识别要简单。特别是,这些语音识别系统涉及比本示例中使用的词汇量更大的词汇。此外,它们处理由多个词连续发音组成的连续语音,而我们的示例处理逐个单词发音。因此,我们的示例不符合“语音识别器”的条件;相反,更准确地描述它为“单词识别器”或“语音命令识别器”。然而,我们的示例仍然具有实际用途(如无需手动操作的用户界面和可访问性功能)。此外,本示例中体现的深度学习技术实际上是更高级语音识别系统的基础。^([9])
⁹
Ronan Collobert、Christian Puhrsch 和 Gabriel Synnaeve,“Wav2Letter: 一种基于端到端卷积网络的语音识别系统”,2016 年 9 月 13 日提交,
arxiv.org/abs/1609.03193。
4.4.1. 声谱图:将声音表示为图像
与任何深度学习应用一样,如果您想要理解模型的工作原理,首先需要了解数据。要理解音频卷积网络的工作原理,我们需要首先查看声音是如何表示为张量的。请回忆高中物理课上的知识,声音是空气压力变化的模式。麦克风捕捉到空气压力变化并将其转换为电信号,然后计算机的声卡可以将其数字化。现代 Web 浏览器提供了WebAudio API,它与声卡通信并提供对数字化音频信号的实时访问(在用户授权的情况下)。因此,从 JavaScript 程序员的角度来看,声音就是一组实值数字的数组。在深度学习中,这种数字数组通常表示为 1D 张量。
你可能会想,迄今为止我们见过的这种卷积网络是如何在 1D 张量上工作的?它们不是应该操作至少是 2D 的张量吗?卷积网络的关键层,包括 conv2d 和 maxPooling2d,利用了 2D 空间中的空间关系。事实证明声音可以被表示为称为声谱图的特殊类型的图像。声谱图不仅使得可以在声音上应用卷积网络,而且在深度学习之外还具有理论上的解释。
如 图 4.12 所示,频谱图是一个二维数组,可以以与 MNIST 图像基本相同的方式显示为灰度图像。水平维度是时间,垂直维度是频率。频谱图的每个垂直切片是一个短时间窗口内的声音的频谱。频谱是将声音分解为不同频率分量的过程,可以粗略地理解为不同的“音高”。就像光可以通过棱镜分解成多种颜色一样,声音可以通过称为傅里叶变换的数学操作分解为多个频率。简而言之,频谱图描述了声音的频率内容如何在一系列连续的短时间窗口(通常约为 20 毫秒)内变化。
图 4.12. “zero” 和 “yes” 这两个孤立口语单词的示例频谱图。频谱图是声音的联合时间-频率表示。你可以将频谱图视为声音的图像表示。沿着时间轴的每个切片(图像的一列)都是时间的短时刻(帧);沿着频率轴的每个切片(图像的一行)对应于特定的窄频率范围(音调)。图像的每个像素的值表示给定时间点上给定频率区段的声音相对能量。本图中的频谱图被渲染为较暗的灰色,对应着较高的能量。不同的语音有不同的特征。例如,类似于“z”和“s”这样的咝音辅音以在 2–3 kHz 以上频率处集中的准稳态能量为特征;像“e”和“o”这样的元音以频谱的低端(< 3 kHz)中的水平条纹(能量峰值)为特征。在声学中,这些能量峰值被称为共振峰。不同的元音具有不同的共振峰频率。所有这些不同语音的独特特征都可以被深度卷积神经网络用于识别单词。

谱图对于以下原因是声音的合适表示。首先,它们节省空间:谱图中的浮点数通常比原始波形中的浮点值少几倍。其次,在宽泛的意义上,谱图对应于生物学中的听力工作原理。内耳内部的一种名为耳蜗的解剖结构实质上执行了傅里叶变换的生物版本。它将声音分解成不同的频率,然后被不同组听觉神经元接收。第三,谱图表示可以更容易地区分不同类型的语音。这在 图 4.12 的示例语音谱图中可以看到:元音和辅音在谱图中都有不同的特征模式。几十年前,在机器学习被广泛应用之前,从谱图中检测不同的元音和辅音的人们实际上尝试手工制作规则。深度学习为我们节省了这种手工制作的麻烦和泪水。
让我们停下来思考一下。看一看 图 4.1 中的 MNIST 图片和 图 4.12 中的声音谱图,你应该能够理解这两个数据集之间的相似之处。两个数据集都包含在二维特征空间中的模式,一双经过训练的眼睛应该能够区分出来。两个数据集都在特征的具体位置、大小和细节上呈现一定的随机性。最后,两个数据集都是多类别分类任务。虽然 MNIST 有 10 个可能的类别,我们的声音命令数据集有 20 个类别(从 0 到 9 的 10 个数字,“上”,“下”,“左”,“右”,“前进”,“停止”,“是”,“否”,以及“未知”词和背景噪音的类别)。正是这些数据集本质上的相似性使得卷积神经网络非常适用于声音命令识别任务。
但是这两个数据集也有一些显著的区别。首先,声音命令数据集中的音频录音有些噪音,可以从 图 4.12 中的示例谱图中看到不属于语音声音的黑色像素点。其次,声音命令数据集中的每个谱图的尺寸为 43×232,与单个 MNIST 图像的 28×28 大小相比显著较大。谱图的尺寸在时间和频率维度之间是不对称的。这些差异将体现在我们将在音频数据集上使用的卷积神经网络中。
定义和训练声音命令卷积神经网络的代码位于 tfjs-models 存储库中。您可以使用以下命令访问代码:
git clone https://github.com/tensorflow/tfjs-models.git
cd speech-commands/training/browser-fft
模型的创建和编译封装在 model.ts 中的createModel()函数中。
4.8 章节的声音命令谱图分类的卷积神经网络
function createModel(inputShape: tf.Shape, numClasses: number) {
const model = tf.sequential();
model.add(tf.layers.conv2d({ ***1***
filters: 8,
kernelSize: [2, 8],
activation: 'relu',
inputShape
}));
model.add(tf.layers.maxPooling2d({poolSize: [2, 2], strides: [2, 2]}));
model.add( tf.layers.conv2d({
filters: 32,
kernelSize: [2, 4],
activation: 'relu'
}));
model.add(tf.layers.maxPooling2d({poolSize: [2, 2], strides: [2, 2]}));
model.add(
tf.layers.conv2d({
filters: 32,
kernelSize: [2, 4],
activation: 'relu'
}));
model.add(tf.layers.maxPooling2d({poolSize: [2, 2], strides: [2, 2]}));
model.add(
tf.layers.conv2d({
filters: 32,
kernelSize: [2, 4],
activation: 'relu'
}));
model.add(tf.layers.maxPooling2d({poolSize: [2, 2], strides: [1, 2]}));
model.add(tf.layers.flatten()); ***2***
model.add(tf.layers.dropout({rate: 0.25})); ***3***
model.add(tf.layers.dense({units: 2000, activation: 'relu'}));
model.add(tf.layers.dropout({rate: 0.5}));
model.add(tf.layers.dense({units: numClasses, activation: 'softmax'}));
model.compile({ ***4***
loss: 'categoricalCrossentropy',
optimizer: tf.train.sgd(0.01),
metrics: ['accuracy']
});
model.summary();
return model;
}
-
1 conv2d+maxPooling2d 的重复模式
-
2 多层感知器开始
-
3 使用 dropout 减少过拟合
-
4 配置多类别分类的损失和指标
我们的音频卷积网络的拓扑结构看起来很像 MNIST 卷积网络。顺序模型以多个重复的 conv2d 层与 maxPooling2d 层组合开始。模型的卷积 - 池化部分在一个展平层结束,在其上添加了 MLP。MLP 有两个密集层。隐藏的密集层具有 relu 激活,最终(输出)层具有适合分类任务的 softmax 激活。模型编译为在训练和评估期间使用 categoricalCrossentropy 作为损失函数并发出准确度指标。这与 MNIST 卷积网络完全相同,因为两个数据集都涉及多类别分类。音频卷积网络还显示出与 MNIST 不同的一些有趣之处。特别是,conv2d 层的 kernelSize 属性是矩形的(例如,[2, 8])而不是方形的。这些值被选择为与频谱图的非方形形状匹配,该频谱图的频率维度比时间维度大。
要训练模型,首先需要下载语音命令数据集。该数据集源自谷歌 Brain 团队工程师 Pete Warden 收集的语音命令数据集(请参阅 www.tensorflow.org/tutorials/sequences/audio_recognition)。它已经转换为浏览器特定的频谱图格式:
curl -fSsL https://storage.googleapis.com/learnjs-data/speech-
commands/speech-commands-data- v0.02-browser.tar.gz -o speech-commands-
data-v0.02-browser.tar.gz &&
tar xzvf speech-commands-data-v0.02-browser.tar.gz
这些命令将下载并提取语音命令数据集的浏览器版本。一旦数据被提取,您就可以使用以下命令启动训练过程:
yarn
yarn train \
speech-commands-data-browser/ \
/tmp/speech-commands-model/
yarn train 命令的第一个参数指向训练数据的位置。以下参数指定了模型的 JSON 文件将保存的路径,以及权重文件和元数据 JSON 文件的路径。就像我们训练增强的 MNIST 卷积网络时一样,音频卷积网络的训练也发生在 tfjs-node 中,有可能利用 GPU。由于数据集和模型的大小都比 MNIST 卷积网络大,训练时间会更长(大约几个小时)。如果您有 CUDA GPU 并且稍微更改命令以使用 tfjs-node-gpu 而不是默认的 tfjs-node(仅在 CPU 上运行),您可以显著加快训练速度。要做到这一点,只需在上一个命令中添加标志 --gpu:
yarn train \
--gpu \
speech-commands-data-browser/ \
/tmp/speech-commands-model/
当训练结束时,模型应该达到约 94% 的最终评估(测试)准确率。
训练过的模型保存在上一个命令中指定的路径中。与我们用 tfjs-node 训练的 MNIST 卷积网络一样,保存的模型可以在浏览器中加载以提供服务。然而,您需要熟悉 WebAudio API,以便能够从麦克风获取数据并将其预处理为模型可用的格式。为了方便起见,我们编写了一个封装类,不仅可以加载经过训练的音频卷积网络,还可以处理数据输入和预处理。如果您对音频数据输入流水线的机制感兴趣,可以在 tfjs-model Git 仓库中的 speech-commands/src 文件夹中研究底层代码。这个封装类可以通过 npm 的 @tensorflow-models/speech-commands 名称使用。Listing 4.9 展示了如何使用封装类在浏览器中进行在线语音命令识别的最小示例。
在 tfjs-models 仓库的 speech-commands/demo 文件夹中,您可以找到一个相对完整的示例,该示例展示了如何使用该软件包。要克隆并运行该演示,请在 speech-commands 目录下运行以下命令:
git clone https://github.com/tensorflow/tfjs-models.git
cd tfjs-models/speech-commands
yarn && yarn publish-local
cd demo
yarn && yarn link-local && yarn watch
yarn watch 命令将自动在默认的网页浏览器中打开一个新的标签页。要看到语音命令识别器的实际效果,请确保您的计算机已准备好麦克风(大多数笔记本电脑都有)。每次识别到词汇表中的一个单词时,屏幕上将显示该单词以及包含该单词的一秒钟的频谱图。所以,这是基于浏览器的单词识别,由 WebAudio API 和深度卷积网络驱动。当然,它没有能力识别带有语法的连接语音?这将需要其他类型的能够处理序列信息的神经网络模块的帮助。我们将在第八章中介绍这些模块。
Listing 4.9. @tensorflow-models/speech-commands 模块的示例用法
import * as SpeechCommands from
'@tensorflow-models/speech-commands'; ***1***
const recognizer =
SpeechCommands.create('BROWSER_FFT'); ***2***
console.log(recognizer.wordLabels()); ***3***
recognizer.listen(result => { ***4***
let maxIndex;
let maxScore = -Infinity;
result.scores.forEach((score, i) => { ***5***
if (score > maxScore) { ***6***
maxIndex = i;
maxScore = score;
}
});
console.log(`Detected word ${recognizer.wordLabels()[maxIndex]}`);
}, {
probabilityThreshold: 0.75
});
setTimeout(() => recognizer.stopStreaming(), 10e3); ***7***
-
1 导入 speech-commands 模块。确保它在 package.json 中列为依赖项。
-
2 创建一个使用浏览器内置快速傅里叶变换(FFT)的语音命令识别器实例
-
3 您可以查看模型能够识别的单词标签(包括“background-noise”和“unknown”标签)。
-
4 启动在线流式识别。第一个参数是一个回调函数,每当识别到概率超过阈值(本例为 0.75)的非背景噪声、非未知单词时,都会调用该函数。
-
5 result.scores 包含与 recognizer.wordLabels() 对应的概率得分。
-
6 找到具有最高得分的单词的索引
-
7 在 10 秒内停止在线流式识别
练习
-
用于在浏览器中对 MNIST 图像进行分类的卷积网络(listing 4.1)有两组 conv2d 和 maxPooling2d 层。修改代码,将该数量减少到只有一组。回答以下问题:
-
这会影响卷积网络可训练参数的总数吗?
-
这会影响训练速度吗?
-
这会影响训练后卷积网络获得的最终准确率吗?
-
-
这个练习与练习 1 相似。但是,与其调整 conv2d-maxPooling2d 层组的数量,不如在卷积网络的 MLP 部分中尝试调整密集层的数量。如果去除第一个密集层,只保留第二个(输出)层,总参数数量、训练速度和最终准确率会发生什么变化,见列表 4.1。
-
从 mnist-node 中的卷积网络(列表 4.5)中移除 dropout,并观察训练过程和最终测试准确率的变化。为什么会发生这种情况?这说明了什么?
-
使用
tf.browser.fromPixels()方法练习从网页中图像和视频相关元素中提取图像数据,尝试以下操作:-
使用
tf.browser.fromPixels()通过img标签获取表示彩色 JPG 图像的张量。-
tf.browser.fromPixels()返回的图像张量的高度和宽度是多少?是什么决定了高度和宽度? -
使用
tf.image.resizeBilinear()将图像调整为固定尺寸 100 × 100(高 × 宽)。 -
重复上一步,但使用替代的调整大小函数
tf.image.resizeNearestNeighbor()。你能发现这两种调整大小函数的结果之间有什么区别吗?
-
-
创建一个 HTML 画布并在其中绘制一些任意形状,例如使用
rect()函数。或者,如果愿意,也可以使用更先进的库,如 d3.js 或 three.js,在其中绘制更复杂的 2D 和 3D 形状。然后,使用tf.browser.fromPixels()从画布获取图像张量数据。
-
摘要
-
卷积网络通过堆叠的 conv2d 和 maxPooling2d 层的层次结构从输入图像中提取 2D 空间特征。
-
conv2d 层是多通道、可调节的空间滤波器。它们具有局部性和参数共享的特性,使它们成为强大的特征提取器和高效的表示转换器。
-
maxPooling2d 层通过在固定大小的窗口内计算最大值来减小输入图像张量的大小,从而实现更好的位置不变性。
-
卷积网络的 conv2d-maxPooling2d“塔”通常以一个 flatten 层结束,其后是由密集层组成的 MLP,用于分类或回归任务。
-
受限于其资源,浏览器只适用于训练小型模型。要训练更大的模型,建议使用 tfjs-node,即 TensorFlow.js 的 Node.js 版本;tfjs-node 可以使用与 Python 版本的 TensorFlow 相同的 CPU 和 GPU 并行化内核。
-
模型容量增加会增加过拟合的风险。通过在卷积网络中添加 dropout 层可以缓解过拟合。在训练期间,dropout 层会随机将给定比例的输入元素归零。
-
Convnets 不仅对计算机视觉任务有用。当音频信号被表示为频谱图时,卷积神经网络也可以应用于它们,以实现良好的分类准确性。
第五章:转移学习:重用预训练的神经网络
本章涵盖
-
什么是转移学习,为什么它对许多类型的问题而言比从头开始训练模型更好
-
如何通过将其从 Keras 转换为 TensorFlow.js 来利用最先进的预训练卷积神经网络的特征提取能力
-
转移学习技术的详细机制,包括层冻结、创建新的转移头和微调
-
如何使用转移学习在 TensorFlow.js 中训练简单的目标检测模型
在第四章中,我们看到了如何训练卷积神经网络来对图像进行分类。现在考虑以下情景。我们用于分类手写数字的卷积神经网络对某位用户表现不佳,因为他们的手写与原始训练数据非常不同。我们能否通过使用我们可以从他们那里收集到的少量数据(比如,50 个样本)来改进模型,从而更好地为用户提供服务?再考虑另一种情况:一个电子商务网站希望自动分类用户上传的商品图片。但是公开可用的卷积神经网络(例如 MobileNet^([1]))都没有针对这种特定领域的图像进行训练。在给定少量标记图片(比如,几百张)的情况下,是否可以使用公开可用的图像模型来解决定制分类问题?
¹
Andrew G. Howard 等人,“MobileNets: 面向移动视觉应用的高效卷积神经网络”,2017 年 4 月 17 日提交,
arxiv.org/abs/1704.04861。
幸运的是,本章的主要焦点——一种称为转移学习的技术,可以帮助解决这类任务。
5.1. 转移学习简介:重用预训练模型
本质上,转移学习是通过重用先前学习结果来加速新的学习任务。它涉及使用已经在数据集上训练过的模型来执行不同但相关的机器学习任务。已经训练好的模型被称为基础模型。转移学习有时涉及重新训练基础模型,有时涉及在基础模型的顶部创建一个新模型。我们将新模型称为转移模型。正如图 5.1 所示,用于这个重新训练过程的数据量通常比用于训练基础模型的数据量要小得多(就像本章开头给出的两个例子一样)。因此,与基础模型的训练过程相比,转移学习通常需要的时间和资源要少得多。这使得在像浏览器这样的资源受限环境中使用 TensorFlow.js 进行转移学习成为可能。因此,对于 TensorFlow.js 学习者来说,转移学习是一个重要的主题。
图 5.1. 迁移学习的一般工作流程。大型数据集用于基础模型的训练。这个初始训练过程通常很长且计算量大。然后重新训练基础模型,可能成为新模型的一部分。重新训练过程通常涉及比原始数据集小得多的数据集。重新训练所涉及的计算量远远小于初始训练,并且可以在边缘设备上进行,例如运行 TensorFlow.js 的笔记本电脑或手机。

描述迁移学习中的关键短语“不同但相关”在不同情况下可能意味着不同的事情:
-
本章开头提到的第一个场景涉及将模型调整为特定用户的数据。尽管数据与原始训练集不同,但任务完全相同——将图像分类为 10 个数字。这种类型的迁移学习被称为模型适应。
-
其他迁移学习问题涉及与原始标签不同的目标。本章开头提到的商品图像分类场景属于这一类别。
与从头开始训练新模型相比,迁移学习的优势是什么?答案有两个方面:
-
从数据量和计算量两个方面来看,迁移学习更加高效。
-
它借助基础模型的特征提取能力,建立在先前训练成果的基础上。
这些观点适用于各种类型的问题(例如分类和回归)。在第一个观点上,迁移学习使用来自基础模型(或其子集)的训练权重。因此,与从头开始训练新模型相比,它需要更少的训练数据和训练时间才能收敛到给定精度水平。在这方面,迁移学习类似于人类学习新任务的方式:一旦你掌握了一个任务(例如玩纸牌游戏),学习类似的任务(例如玩类似的纸牌游戏)在将来会变得更容易和更快。对于我们为 MNIST 构建的 convnet 这样的神经网络来说,节省的训练时间成本可能相对较小。然而,对于在大型数据集上训练的较大模型(例如在 TB 级图像数据上训练的工业规模 convnet),节省可以是相当可观的。
关于第二点,迁移学习的核心思想是重用之前的训练结果。通过从一个非常大的数据集中学习,原始神经网络已经非常擅长从原始输入数据中提取有用的特征。只要迁移学习任务中的新数据与原始数据不相差太大,这些特征对于新任务将是有用的。研究人员已经为常见的机器学习领域组装了非常大的数据集。在计算机视觉领域,有 ImageNet^([2]),其中包含大约一千个类别的数百万张带标签的图像。深度学习研究人员已经使用 ImageNet 数据集训练了深度卷积神经网络,包括 ResNet、Inception 和 MobileNet(我们将很快接触到的最后一个)。由于 ImageNet 中图像的数量和多样性,训练在其上的卷积神经网络是一般类型图像的良好特征提取器。这些特征提取器对于处理像前述情景中的小数据集这样的小数据集将是有用的,但是使用小数据集训练这样有效的特征提取器是不可能的。迁移学习的机会也存在于其他领域。例如,在自然语言处理领域,人们已经在包含数十亿个单词的大型文本语料库上训练了词嵌入(即语言中所有常见单词的向量表示)。这些嵌入对于可用的远小于大文本数据集的语言理解任务是有用的。话不多说,让我们通过一个例子来看看迁移学习是如何在实践中工作的。
²
不要被名字所迷惑。“ImageNet”指的是一个数据集,而不是一个神经网络。
5.1.1. 基于兼容输出形状的迁移学习:冻结层
让我们从一个相对简单的例子开始。我们将在 MNIST 数据集的前五个数字(0 到 4)上训练一个卷积神经网络。然后,我们将使用得到的模型来识别剩余的五个数字(5 到 9),这些数字在原始训练中模型从未见过。虽然这个例子有些人为,但它展示了迁移学习的基本工作流程。你可以通过以下命令查看并运行这个例子:
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/mnist-transfer-cnn
yarn && yarn watch
在打开的演示网页中,通过点击“重新训练”按钮来开始迁移学习过程。你可以看到这个过程在新的五个数字(5 到 9)上达到了约 96%的准确率,这需要在一台性能较强的笔记本电脑上大约 30 秒。正如我们将要展示的,这比不使用迁移学习的替代方案(即从头开始训练一个新模型)要快得多。让我们逐步看看这是如何实现的。
我们的例子从 HTTP 服务器加载预训练的基础模型,而不是从头开始训练,以免混淆工作流程的关键部分。回想一下第 4.3.3 节,TensorFlow.js 提供了tf.loadLayersModel()方法来加载预训练模型。这在 loader.js 文件中调用:
const model = await tf.loadLayersModel(url);
model.summary();
模型的打印摘要看起来像 图 5.2。正如你所见,该模型由 12 层组成。^([3]) 其中的大约 600,000 个权重参数都是可训练的,就像迄今为止我们见过的所有 TensorFlow.js 模型一样。请注意,loadLayersModel() 不仅加载模型的拓扑结构,还加载所有权重值。因此,加载的模型已经准备好预测数字 0 到 4 的类别。但是,这不是我们将使用模型的方式。相反,我们将训练模型来识别新的数字(5 到 9)。
³
在这个模型中,你可能没有看到激活层类型。激活层是仅对输入执行激活函数(如 relu 和 softmax)的简单层。假设你有一个具有默认(线性)激活的稠密层;在其上叠加一个激活层等同于使用具有非默认激活的稠密层。这就是我们在 第四章 中的例子所做的。但是有时也会看到前一种风格。在 TensorFlow.js 中,你可以通过以下代码获取这样的模型拓扑:`const model = tf.sequential(); model.add(tf.layers.dense({untis: 5, inputShape})); model.add(tf.layers.activation({activation: 'relu'}))。
图 5.2. MNIST 图像识别和迁移学习卷积神经网络的打印摘要

查看回调函数以重新训练按钮(在 index.js 的 retrainModel() 函数中)时,如果选择了冻结特征层选项(默认情况下选择了),你会注意到一些代码行将模型的前七层的 trainable 属性设置为 false。
那是做什么用的?默认情况下,模型加载后通过 loadLayersModel() 方法或从头创建后,模型的每个层的 trainable 属性都是 true。 trainable 属性在训练期间(即调用 fit() 或 fitDataset() 方法)中使用。它告诉优化器是否应该更新层的权重。默认情况下,模型的所有层的权重都在训练期间更新。但是,如果你将某些模型层的属性设置为 false,那么这些层的权重在训练期间将不会更新。在 TensorFlow.js 的术语中,这些层变为不可训练或冻结。列表 5.1 中的代码冻结了模型的前七层,从输入 conv2d 层到 flatten 层,同时保持最后几层(稠密层)可训练。
列表 5.1. 冻结卷积网络的前几层以进行迁移学习
const trainingMode = ui.getTrainingMode();
if (trainingMode === 'freeze-feature-layers') {
console.log('Freezing feature layers of the model.');
for (let i = 0; i < 7; ++i) {
this.model.layers[i].trainable = false; ***1***
}
} else if (trainingMode === 'reinitialize-weights') {
const returnString = false ;
this.model = await tf.models.modelFromJSON({ ***2***
modelTopology: this.model.toJSON(null, returnString) ***2***
}); ***2***
}
this.model.compile({ ***3***
loss: 'categoricalCrossentropy',
optimizer: tf.train.adam(0.01),
metrics: ['acc'],
});
this.model.summary(); ***4***
-
1 冻结层
-
2 创建一个与旧模型具有相同拓扑结构但重新初始化权重值的新模型
-
3 冻结将不会在调用 fit() 时生效,除非你首先编译模型。
-
4 在 compile() 后再次打印模型摘要。您应该看到模型的一些权重已变为不可训练。
然而,仅设置层的 trainable 属性是不够的:如果您只修改 trainable 属性并立即调用模型的 fit() 方法,您将看到这些层的权重在 fit() 调用期间仍然会被更新。在调用 Model.fit() 之前,您需要调用 Model.compile() 以使 trainable 属性更改生效,就像在 列表 5.1 中所做的那样。我们之前提到 compile() 调用配置了优化器、损失函数和指标。但是,该方法还允许模型在这些调用期间刷新要更新的权重变量列表。在 compile() 调用之后,我们再次调用 summary() 来打印模型的新摘要。通过将新摘要与 图 5.2 中的旧摘要进行比较,您会发现一些模型的权重已变为不可训练:
Total params: 600165
Trainable params: 590597
Non-trainable params: 9568
您可以验证非可训练参数的数量,即 9,568,是两个冻结层中的权重参数之和(两个 conv2d 层的权重)。请注意,我们已冻结的一些层不包含权重(例如 maxPooling2d 层和 flatten 层),因此当它们被冻结时不会对非可训练参数的计数产生贡献。
实际的迁移学习代码显示在 列表 5.2 中。在这里,我们使用了与从头开始训练模型相同的 fit() 方法。在此调用中,我们使用 validationData 字段来衡量模型在训练期间未见过的数据上的准确性。此外,我们将两个回调连接到 fit() 调用,一个用于在用户界面中更新进度条,另一个用于使用 tfjs-vis 模块绘制损失和准确率曲线(更多细节请参见 第七章)。这显示了 fit() API 的一个方面,我们之前没有提到过:您可以给 fit() 调用一个回调或一个包含多个回调的数组。在后一种情况下,所有回调将在训练期间被调用(按照数组中指定的顺序)。
列表 5.2 使用 Model.fit() 进行迁移学习
await this.model.fit(this.gte5TrainData.x, this.gte5TrainData.y, {
batchSize: batchSize,
epochs: epochs,
validationData: [this.gte5TestData.x, this.gte5TestData.y],
callbacks: [ ***1***
ui.getProgressBarCallbackConfig(epochs),
tfVis.show.fitCallbacks(surfaceInfo, ['val_loss', 'val_acc'], { ***2***
zoomToFit: true, ***2***
zoomToFitAccuracy: true, ***2***
height: 200, ***2***
callbacks: ['onEpochEnd'], ***2***
}), ***2***
]
});
-
1 给
fit()调用添加多个回调是允许的。 -
2 使用 tfjs-vis 绘制迁移学习过程中的验证损失和准确率
迁移学习的结果如何?正如您在图 5.3 的 A 面板中所看到的,经过 10 个 epoch 的训练后,准确率达到约 0.968,大约需要在一台相对更新的笔记本电脑上花费约 15 秒,还算不错。但与从头开始训练模型相比如何呢?我们可以通过一个实验演示从预训练模型开始相对于从头开始的价值,即在调用 fit() 前随机重新初始化预训练模型的权重。在点击重新训练按钮之前,在训练模式下拉菜单中选择重新初始化权重选项即可。结果显示在同一图表的 B 面板中。
图 5.3. 在 MNIST 卷积网络上的迁移学习的损失和验证曲线。面板 A:使用预训练模型的前七层冻结得到的曲线。面板 B:使用模型的所有权重随机重新初始化得到的曲线。面板 C:不冻结任何预训练模型层获得的曲线。请注意三个面板之间的 y 轴有所不同。面板 D:一个多系列图,显示了面板 A–C 中的损失和准确度曲线在相同轴上以便进行比较。

通过比较 B 面板和 A 面板,可以看出模型权重的随机重新初始化导致损失从一个显著更高的值开始(0.36 对比 0.30),准确度则从一个显著更低的值开始(0.88 对比 0.91)。重新初始化的模型最终的验证准确率也比重复使用从基本模型中的权重的模型低(约 0.954 对比 ~0.968)。这些差异反映了迁移学习的优势:通过重复使用模型的初始层(特征提取层)中的权重,相对于从头开始学习,模型获得了一个良好的起步。这是因为迁移学习任务中遇到的数据与用于训练原始模型的数据相似。数字 5 到 9 的图像与数字 0 到 4 的图像有很多共同点:它们都是带有黑色背景的灰度图像;它们有类似的视觉模式(相似宽度和曲率的笔画)。因此,模型从数字 0 到 4 中学习提取的特征对学习分类新数字(5 到 9)也很有用。
如果我们不冻结特征层的权重会怎样?在训练模式下拉菜单中选择不冻结特征层选项可以进行此实验。结果显示在图 5.3 的 C 面板中。与 A 面板的结果相比,有几个值得注意的差异:
-
没有特征层冻结时,损失值开始较高(例如,在第一个时期之后:0.37 对比 0.27);准确率开始较低(0.87 对比 0.91)。为什么会这样?当预训练模型首次开始在新数据集上进行训练时,预测结果将包含大量错误,因为预训练权重为五个新数字生成基本上是随机的预测。因此,损失函数将具有非常高的值和陡峭的斜率。这导致在训练的早期阶段计算的梯度非常大,进而导致所有模型的权重出现大幅波动。因此,所有层的权重都将经历一个大幅波动的时期,这导致面板 C 中看到的初始损失较高。在正常的迁移学习方法(面板 A)中,模型的前几层被冻结,因此免受这些大的初始权重扰动的影响。
-
由于这些大的初始扰动,采用无冻结方法达到的最终准确率(约为 0.945,面板 C)与采用正常的迁移学习方法相比(约为 0.968,面板 A)并没有明显提高。
-
当模型的任何一层都没有被冻结时,训练时间会更长。例如,在我们使用的其中一台笔记本电脑上,使用冻结特征层训练模型大约需要 30 秒,而没有任何层冻结的模型训练大约需要两倍长(60 秒)。图 5.4 以示意的方式说明了其中的原因。在反向传播期间,冻结的层被从方程中排除,这导致每个
fit()调用的批次速度大大加快。
图 5.4. 模型冻结某些层加快训练速度的示意图。在该图中,通过指向左边的黑色箭头显示了反向传播路径。面板 A:当没有任何层被冻结时,所有模型的权重(v[1]–v[5])在每个训练步骤(每个批次)中都需要更新,因此将参与反向传播,由黑色箭头表示。请注意,特征(x)和目标(y)永远不会包括在反向传播中,因为它们的值不需要更新。面板 B:通过冻结模型的前几层,一部分权重(v[1]–v[3])不再是反向传播的一部分。相反,它们变成了类似于 x 和 y 的常数,只是作为影响损失计算的因素。因此,执行反向传播所需的计算量减少,训练速度提高。

这些观点为迁移学习的层冻结方法提供了理由:它利用了基础模型的特征提取层,并在新训练的早期阶段保护它们免受大的权重扰动,从而在较短的训练周期内实现更高的准确性。
在我们继续下一节之前,有两点需要注意。首先,模型适应——重新训练模型以使其在特定用户的输入数据上更有效的过程——使用的技术与此处展示的技术非常相似,即冻结基础层,同时让顶层的权重通过对用户特定数据的训练而发生变化。尽管本节解决的问题并不涉及来自不同用户的数据,而是涉及具有不同标签的数据。其次,你可能想知道如何验证冻结层(在这种情况下是 conv2d 层)的权重在fit()调用之前和之后是否确实相同。这个验证并不是很难做到的。我们把它留给你作为一个练习(参见本章末尾的练习 2)。
5.1.2. 不兼容输出形状上的迁移学习:使用基础模型的输出创建一个新的 m 模型
在前一节中看到的迁移学习示例中,基础模型的输出形状与新输出形状相同。这种属性在许多其他迁移学习案例中并不成立(参见图 5.5)。例如,如果你想要使用最初在五个数字上进行训练的基础模型来对四个新数字进行分类,先前描述的方法将不起作用。更常见的情况是:给定一个已经在包含 1,000 个输出类别的 ImageNet 分类数据集上训练过的深度卷积网络,你手头有一个涉及更少输出类别的图像分类任务(图 5.5 中的 B 案例)。也许这是一个二元分类问题——图像是否包含人脸——或者这是一个具有少数类别的多类分类问题——图片中包含什么类型的商品(回想一下本章开头的例子)。在这种情况下,基础模型的输出形状对于新问题不起作用。
图 5.5. 根据新模型的输出形状和激活方式是否与原模型相同,迁移学习可分为三种类型。情况 A:新模型的输出形状和激活函数与基础模型相匹配。将 MNIST 模型迁移到 5.1.1 节中的新数字就是这种类型的迁移学习示例。情况 B:新模型具有与基础模型相同的激活类型,因为原任务和新任务是相同类型的(例如,都是多类分类)。然而,输出形状不同(例如,新任务涉及不同数量的类)。这种类型的迁移学习示例可在 5.1.2 节(通过网络摄像头控制类似于 Pac-Man^(TM 4) 的视频游戏)和 5.1.3(识别一组新的口语单词)中找到。情况 C:新任务与原始任务的类型不同(例如,回归与分类)。基于 MobileNet 的目标检测模型就是这种类型的示例。

在某些情况下,甚至机器学习任务的类型也与基础模型训练的类型不同。例如,您可以通过对分类任务训练的基础模型应用迁移学习来执行回归任务(预测一个数字,如图 5.5 中的情况 C)5.2 节中,您将看到迁移学习的更加有趣的用途——预测一系列数字,而不是单个数字,用于在图像中检测和定位对象。
这些情况都涉及期望的输出形状与基础模型不同。这使得需要构建一个新模型。但因为我们正在进行迁移学习,所以新模型不会从头开始创建。相反,它将使用基础模型。我们将在 tfjs-examples 存储库中的 webcam-transfer-learning 示例中说明如何做到这一点。
要查看此示例的实际操作,请确保您的设备具有前置摄像头——示例将从摄像头收集用于迁移学习的数据。现在大多数笔记本电脑和平板电脑都配备了内置的前置摄像头。但是,如果您使用的是台式电脑,可能需要找到一个网络摄像头并将其连接到设备上。与之前的示例类似,您可以使用以下命令来查看和运行演示:
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/webcam-transfer-learning
这个有趣的演示将您的网络摄像头转换为游戏控制器,通过对 MobileNet 的 TensorFlow.js 实现进行迁移学习,让您可以用它玩 Pac-Man 游戏。让我们走过运行演示所需的三个步骤:数据收集、模型迁移学习和游戏进行^([4])。
⁴
Pac-Man 是万代南梦宫娱乐公司的商标。
迁移学习的数据来自于您的网络摄像头。一旦演示在您的浏览器中运行,您将在页面右下角看到四个黑色方块。它们的排列方式类似于任天堂家庭电脑控制器上的四个方向按钮。它们对应着模型将实时识别的四个类别。这四个类别对应着 Pac-Man 将要移动的四个方向。当您点击并按住其中一个时,图像将以每秒 20–30 帧的速度通过网络摄像头收集。方块下面的数字告诉您目前已经为此控制器方向收集了多少图像。
为了获得最佳的迁移学习质量,请确保您:1)每个类别至少收集 50 张图像;2)在数据收集过程中稍微移动和摆动您的头部和面部,以使训练图像包含更多的多样性,这有利于您从迁移学习中获得的模型的稳健性。在这个演示中,大多数人会在四个方向(上、下、左、右;参见图 5.6)转动头部,以指示 Pac-Man 应该朝哪个方向移动。但您可以使用任何您想要的头部位置、面部表情甚至手势作为输入图像,只要输入图像在各个类别之间足够视觉上有区别即可。
图 5.6. 网络摄像头迁移学习示例的用户界面^([5])
⁵
这个网络摄像头迁移学习示例的用户界面是由吉姆博·威尔逊(Jimbo Wilson)和山姆·卡特(Shan Carter)完成的。您可以在
youtu.be/YB-kfeNIPCE?t=941查看这个有趣示例的视频录制。

收集完训练图像后,点击“训练模型”按钮,这将开始迁移学习过程。迁移学习应该只需要几秒钟。随着进展,您应该看到屏幕上显示的损失值变得越来越小,直到达到一个非常小的正值(例如 0.00010),然后停止变化。此时,迁移学习模型已经被训练好了,您可以用它来玩游戏了。要开始游戏,只需点击“播放”按钮,等待游戏状态稳定下来。然后,模型将开始对来自网络摄像头的图像流进行实时推理。在每个视频帧中,赢得的类别(由迁移学习模型分配的概率分数最高的类别)将在用户界面的右下角用明亮的黄色突出显示。此外,它会导致 Pac-Man 沿着相应的方向移动(除非被墙壁挡住)。
对于那些对机器学习不熟悉的人来说,这个演示可能看起来像魔术一样,但它基于的只是一个使用 MobileNet 执行四类分类任务的迁移学习算法。该算法使用通过网络摄像头收集的少量图像数据。这些图像通过您收集图像时执行的点击和按住操作方便地标记。由于迁移学习的力量,这个过程不需要太多数据或太多的训练时间(它甚至可以在智能手机上运行)。这就是这个演示的工作原理的简要概述。如果您希望了解技术细节,请在下一节中与我们一起深入研究底层的 TensorFlow.js 代码。
深入研究网络摄像头迁移学习
列表 5.3 中的代码(来自 webcam-transfer-learning/index.js)负责加载基础模型。特别地,我们加载了一个可以在 TensorFlow.js 中高效运行的 MobileNet 版本。信息框 5.1 描述了这个模型是如何从 Python 的 Keras 深度学习库转换而来的。一旦模型加载完成,我们使用 getLayer() 方法来获取其中一个层。getLayer() 允许您通过名称(在本例中为 'conv_pw_13_relu')指定一个层。您可能还记得另一种从 第 2.4.2 节 访问模型层的方法——即通过索引到模型的 layers 属性,该属性将所有模型的层作为 JavaScript 数组保存。当模型由少量层组成时,这种方法很容易使用。我们正在处理的 MobileNet 模型有 93 层,这使得这种方法变得脆弱(例如,如果将来向模型添加更多层会发生什么?)。因此,基于名称的 getLayer() 方法更可靠,如果我们假设 MobileNet 的作者在发布新版本模型时会保持关键层的名称不变的话。
列表 5.3. 加载 MobileNet 并从中创建一个“截断”模型
async function loadTruncatedMobileNet() {
const mobilenet = await tf.loadLayersModel( ***1***
'https://storage.googleapis.com/' + ***1***
'tfjs-models/tfjs/mobilenet_v1_0.25_224/model.json'); ***1***
const layer = mobilenet.getLayer( ***2***
'conv_pw_13_relu'); ***2***
return tf.model({ ***3***
inputs: mobilenet.inputs, ***3***
outputs: layer.output ***3***
}); ***3***
}
-
1 storage.google.com/tfjs-models 下的 URL 设计为永久和稳定的。
-
2 获取 MobileNet 的一个中间层。这个层包含对于自定义图像分类任务有用的特征。
-
3 创建一个新模型,它与 MobileNet 相同,只是它在 'conv_pw_13_relu' 层结束,也就是说,最后几层(称为“头部”)被截断
将 Python Keras 模型转换为 TensorFlow .js 格式
TensorFlow.js 具有与 Keras 高度兼容和互操作的特性,Keras 是最受欢迎的 Python 深度学习库之一。从这种兼容性中获益的其中一个好处是,你可以利用 Keras 中的许多所谓的“应用程序”。这些应用程序是一组在大型数据集(如 ImageNet)上预训练的深度卷积神经网络(详见 keras.io/applications/)。Keras 的作者们已经在库中辛苦地对这些卷积神经网络进行了训练,并使它们可通过库随时重用,包括推理和迁移学习,就像我们在这里所做的那样。对于在 Python 中使用 Keras 的人来说,导入一个应用程序只需一行代码。由于前面提到的互操作性,一个 TensorFlow.js 用户也很容易使用这些应用程序。以下是所需步骤:
-
确保已安装名为
tensorflowjs的 Python 包。最简单的安装方法是通过pip命令:pip install tensorflowjs -
通过 Python 源文件或者像 ipython 这样的交互式 Python REPL 运行以下代码:
import keras import tensorflowjs as tfjs model = keras.applications.mobilenet.MobileNet(alpha=0.25) tfjs.converters.save_keras_model(model, '/tmp/mobilnet_0.25')
前两行导入了所需的keras和tensorflowjs模块。第三行将 MobileNet 加载到一个 Python 对象(model)中。实际上,你可以以几乎与打印 TensorFlow.js 模型摘要相同的方式打印模型的摘要:即model.summary()。你可以看到模型的最后一层(模型的输出)确实具有形状(None, 1000)(在 JavaScript 中相当于[null, 1000]),反映了 MobileNet 模型在 ImageNet 分类任务上训练的 1000 类。我们为这个构造函数调用指定的alpha=0.25关键字参数选择了一个更小的 MobileNet 版本。你可以选择更大的alpha值(如0.75, 1),同样的转换代码仍将继续工作。
前一代码片段中的最后一行使用了tensorflowjs模块中的一个方法,将模型保存到指定目录中。在该行运行结束后,将在磁盘上的/tmp/mobilenet_0.25 路径下创建一个新目录,其内容如下所示:
group1-shard1of6
group1-shard2of6
...
group1-shard6of6
model.json
这与我们在第 4.3.3 节中看到的格式完全相同,当时我们展示了如何在 Node.js 版本的 TensorFlow.js 中使用其save()方法将训练好的 TensorFlow.js 模型保存到磁盘上。因此,对于从磁盘加载此转换模型的 TensorFlow.js 程序而言,保存的格式与在 TensorFlow.js 中创建和训练模型的格式是相同的:它可以简单地调用tf.loadLayersModel()方法并指向模型.json 文件的路径(无论是在浏览器中还是在 Node.js 中),这正是 listing 5.3 中发生的事情。
载入的 MobileNet 模型已经准备好执行模型最初训练的机器学习任务——将输入图像分类为 ImageNet 数据集的 1,000 个类别。请注意,该特定数据集非常强调动物,特别是各种品种的猫和狗(这可能与互联网上此类图像的丰富性有关!)。对于对此特定用法感兴趣的人,tfjs-example 仓库中的 MobileNet 示例展示了如何做到这一点(github.com/tensorflow/tfjs-examples/tree/master/mobilenet)。然而,在本章中,我们不专注于直接使用 MobileNet;相反,我们探讨如何使用载入的 MobileNet 进行迁移学习。
先前展示的 tfjs.converters.save_keras_model() 方法能够转换和保存不仅是 MobileNet 还有其他 Keras 应用,例如 DenseNet 和 NasNet。在本章末尾的练习 3 中,您将练习将另一个 Keras 应用(MobileNetV2)转换为 TensorFlow.js 格式并在浏览器中加载它。此外,应指出 tfjs.converters.save_keras_model() 通常适用于您在 Keras 中创建或训练的任何模型对象,而不仅仅是来自 keras.applications 的模型。
一旦获得 conv_pw_13_relu 层,我们该怎么做?我们创建一个包含原始 MobiletNet 模型层的新模型,从其第一(输入)层到 conv_pw_13_relu 层。这是本书中首次看到这种模型构建方式,因此需要一些仔细的解释。为此,我们首先需要介绍符号张量的概念。
创建符号张量模型
到目前为止,您已经看到了张量。Tensor 是 TensorFlow.js 中的基本数据类型(也缩写为 dtype)。一个张量对象携带着给定形状和 dtype 的具体数值,支持由 WebGL 纹理(如果在启用 WebGL 的浏览器中)或 CPU/GPU 内存(如果在 Node.js 中)支持的存储。然而,SymbolicTensor 是 TensorFlow.js 中另一个重要的类。与携带具体值不同,符号张量仅指定形状和 dtype。可以将符号张量视为“槽”或“占位符”,可以稍后插入一个实际张量值,前提是张量值具有兼容的形状和 dtype。在 TensorFlow.js 中,层或模型对象接受一个或多个输入(到目前为止,您只看到了一个输入的情况),这些输入被表示为一个或多个符号张量。
让我们使用一个类比来帮助你理解符号张量。想象一下编程语言(比如 Java 或 TypeScript,或者其他你熟悉的静态类型语言)中的函数。函数接受一个或多个输入参数。函数的每个参数都有一个类型,规定了可以作为参数传递的变量类型。然而,参数本身并不包含任何具体的值。参数本身只是一个占位符。符号张量类似于函数的参数:它指定了可以在该位置使用的张量的种类(形状和 dtype 的组合)。类似地,静态类型语言中的函数有一个返回类型。这与模型或层对象的输出符号张量相似。它是模型或层对象输出的实际张量值形状和 dtype 的“蓝图”。
⁶
张量形状和符号张量形状之间的区别在于前者始终具有完全指定的维度(比如
[8, 32, 20]),而后者可能具有未确定的维度(比如[null, null, 20])。你已经在模型摘要的“输出形状”列中见过这一点。
在 TensorFlow.js 中,模型对象的两个重要属性是其输入和输出。这两者都是符号张量的数组。对于具有一个输入和一个输出的模型,这两个数组的长度都为 1。类似地,层对象具有两个属性:输入和输出,每个都是一个符号张量。符号张量可以用于创建新模型。这是 TensorFlow.js 中创建模型的新方法,与你之前见过的方法有所不同:即使用 tf.sequential() 创建顺序模型,然后调用 add() 方法。在新方法中,我们使用 tf.model() 函数,它接受一个包含两个必填字段 inputs 和 outputs 的配置对象。inputs 字段需要是一个符号张量(或者是一个符号张量数组),outputs 亦然。因此,我们可以从原始 MobileNet 模型中获取符号张量,并将它们提供给 tf.model() 调用。结果是一个由原始 MobileNet 的一部分组成的新模型。
这个过程在 图 5.7 中以示意图形式说明。(请注意,为了简单的图示,该图将实际 MobileNet 模型的层数减少了。)重要的是要意识到,从原始模型中提取的符号张量并不是孤立的对象。相反,它们携带关于它们属于哪些层以及层如何相互连接的信息。对于熟悉数据结构中图的读者来说,原始模型是一个符号张量的图,连接边是层。通过在原始模型中指定新模型的输入和输出为符号张量,我们正在提取原始 MobileNet 图的一个子图。这个子图成为新模型,包含 MobileNet 的前几层(特别是前 87 层),而最后 6 层则被略过。深度卷积网络的最后几层有时被称为头部。我们在 tf.model() 调用中所做的可以称为截断模型。截断的 MobileNet 保留了提取特征的层,同时丢弃了头部。为什么头部包含六层?这是因为这些层是 MobileNet 最初训练的 1,000 类分类任务所特有的。这些层对我们面对的四类分类任务没有用处。
图 5.7. 示意图解释了如何从 MobileNet 创建新的(“截断的”)模型。在 代码清单 5.3 中的 tf.model() 调用中查看相应的代码。每一层都有一个输入和一个输出,都是 SymbolicTensor 实例。在原始模型中,SymbolicTensor0 是第一层的输入,也是整个模型的输入。它被用作新模型的输入符号张量。此外,我们将中间层的输出符号张量(相当于 conv_pw_13_relu)作为新模型的输出张量。因此,我们得到一个由原始模型的前两层组成的模型,如图的底部所示。原始模型的最后一层,即输出层,有时被称为模型的头部,被丢弃。这就是为什么有时会将这样的方法称为截断模型的原因。请注意,这个图示了具有少量层的模型,以便清楚地表达。实际上,在 代码清单 5.3 中的代码涉及一个比这个图示的层多得多(93 层)的模型。

基于嵌入的迁移学习
截断的 MobileNet 的输出是原始 MobileNet 的中间层激活。但是 MobileNet 的中间层激活对我们有何用呢?答案可以在处理每个四个黑色方块的点击和保持事件的函数中看到(列表 5.4)每当摄像头可用的时候(通过 capture() 方法),我们调用截断的 MobileNet 的 predict() 方法,并将输出保存在一个名为 controllerDataset 的对象中,稍后将用于迁移学习。
⁷
有关 TensorFlow.js 模型的常见问题是如何获取中间层的激活。我们展示的方法就是答案。
但是如何解释截断的 MobileNet 的输出?对于每个图像输入,它都是一个形状为 [1, 7, 7, 256] 的张量。它不是任何分类问题的概率,也不是任何回归问题的预测值。它是输入图像在某个高维空间中的表示。该空间具有 7 * 7 * 256,约为 12.5k,维度。尽管空间具有很多维度,但与原始图像相比,它是低维的,原始图像由于具有 224 × 224 的图像尺寸和三个颜色通道,有 224 * 224 * 3 ≈ 150k 个维度。因此,截断的 MobileNet 的输出可以被视为图像的有效表示。这种输入的低维表示通常称为嵌入。我们的迁移学习将基于从网络摄像头收集到的四组图像的嵌入。
列表 5.4. 使用截断的 MobileNet 获取图像嵌入
ui.setExampleHandler(label => {
tf.tidy(() => { ***1***
const img = webcam.capture();
controllerDataset.addExample(
truncatedMobileNet.predict(img), ***2***
label);
ui.drawThumb(img, label);
});
});
-
1 使用 tf.tidy() 来清理中间张量,比如 img。有关在浏览器中使用 TensorFlow.js 内存管理的教程,请参见附录 B,第 B.3 节。
-
2 获取 MobileNet 的输入图像的内部激活
现在我们有了获取网络摄像头图像嵌入的方法,我们如何使用它们来预测给定图像对应的方向呢?为此,我们需要一个新模型,该模型以嵌入作为其输入,并输出四个方向类的概率值。以下代码(来自 index.js)创建了这样一个模型。
列表 5.5. 使用图像嵌入预测控制器方向
model = tf.sequential({
layers: [
tf.layers.flatten({ ***1***
inputShape: truncatedMobileNet.outputs[0].shape.slice(1) ***1***
}), ***1***
tf.layers.dense({ ***2***
units: ui.getDenseUnits(), ***2***
activation: 'relu', ***2***
kernelInitializer: 'varianceScaling', ***2***
useBias: true ***2***
}), ***2***
tf.layers.dense({ ***3***
units: NUM_CLASSES, ***3***
kernelInitializer: 'varianceScaling', ***3***
useBias: false, ***3***
activation: 'softmax' ***3***
}) ***3***
]
});
-
1 将截断的 MobileNet 的[7, 7, 256]嵌入层展平。slice(1) 操作丢弃了第一个(批次)维度,该维度存在于输出形状中,但是不需要在层的工厂方法的 inputShape 属性中,因此它可以与密集层一起使用。
-
2 一个具有非线性(relu)激活的第一个(隐藏的)密集层
-
3 最后一层的单元数应该与我们想要预测的类的数量相对应。
与 MobileNet 截断版相比,清单 5.5 中创建的新模型具有更小的尺寸。它仅由三层组成:
-
输入层是一个展平层。它将来自截断模型的 3D 嵌入转换为 1D 张量,以便后续的密集层可以采用。我们在
inputShape中设置其与截断的 MobileNet 的输出形状匹配(不包括批处理维度),因为新模型将接收来自截断的 MobileNet 的嵌入。 -
第二层是隐藏层。它是隐藏的,因为它既不是模型的输入层也不是输出层。相反,它被夹在其他两层之间,以增强模型的能力。这与第三章中遇到的 MLP 非常相似。它是一个带有 relu 激活的密集的隐藏层。回想一下,在第三章的“避免堆叠没有非线性的层的谬论”一节中,我们讨论了使用类似这样的隐藏层的非线性激活的重要性。
-
第三层是新模型的最终(输出)层。它具有适合我们面临的多类分类问题的 softmax 激活(即,四个类别:每个 Pac-Man 方向一个)。
因此,我们可以将 MLP 建立在 MobileNet 的特征提取层的顶部。即使在这种情况下,特征提取器(截断的 MobileNet)和 MLP 都是两个分离的模型(见图 5.8)。由于这种两个模型的设置,不可能直接使用图像张量(形状为[numExamples,224,224,3])来训练新的 MLP。相反,新的 MLP 必须在图像的嵌入上进行训练——即截断的 MobileNet 的输出。幸运的是,我们已经收集了那些嵌入张量(清单 5.4)。我们只需要在嵌入张量上调用其 fit() 方法即可训练新的 MLP。在 index.js 的 train() 函数中执行此操作的代码十分简单,我们不再详细介绍。
图 5.8. Webcam-transfer-learning 示例背后的迁移学习算法的概要

一旦迁移学习完成,截断模型和新头将一起用于从网络摄像头的输入图像获取概率分数。您可以在 index.js 的 predict() 函数中找到代码,显示在 列表 5.6 中。特别是,涉及两个 predict() 调用。第一个调用将图像张量转换为其嵌入,使用截断的 MobileNet;第二个使用与迁移学习训练的新头将嵌入转换为四个方向的概率分数。列表 5.6 中的随后代码获取获胜索引(在四个方向的最大概率分数中对应的索引)并使用它来控制 Pac-Man 并更新 UI 状态。与之前的示例一样,我们不涵盖示例的 UI 部分,因为它不是机器学习算法的核心。您可以使用下一个列表中的代码自行研究和玩耍 UI 代码。
列表 5.6. 在迁移学习后从网络摄像头输入图像获取预测
async function predict() {
ui.isPredicting();
while (isPredicting) {
const predictedClass = tf.tidy(() => {
const img = webcam.capture(); ***1***
const embedding = truncatedMobileNet.predict( ***2***
img); ***2***
const predictions = model.predict(activation); ***3***
return predictions.as1D().argMax(); ***4***
});
const classId = (await predictedClass.data())[0]; ***5***
predictedClass.dispose();
ui.predictClass(classId); ***6***
await tf.nextFrame();
}
ui.donePredicting();
}
-
1 从网络摄像头捕获一帧
-
2 从截断的 MobileNet 获取嵌入
-
3 使用新头模型将嵌入转换为四个方向的概率分数
-
4 获取最大概率分数的索引
-
5 将索引从 GPU 下载到 CPU
-
6 根据获胜方向更新 UI:控制 Pac-Man 并更新其他 UI 状态,如控制器上相应“按钮”的突出显示
这结束了我们讨论与迁移学习算法相关的 webcam-transfer-learning 示例的部分。这个示例中我们使用的方法的一个有趣之处是训练和推断过程涉及两个独立的模型对象。这对我们的教育目的来说是有好处的,因为它说明了如何从预训练模型的中间层获取嵌入。这种方法的另一个优点是它暴露了嵌入,并且使得应用直接使用这些嵌入的机器学习技术更容易。这种技术的一个例子是k 最近邻(kNN,在信息框 5.2 中讨论)。然而,直接暴露嵌入也可能被视为以下原因的缺点:
-
这导致稍微复杂一些的代码。例如,推断需要两个
predict()调用才能对单个图像执行推断。 -
假设我们希望保存模型以供以后会话使用或转换为非 TensorFlow.js 库。那么截断模型和新的头模型需要分别保存,作为两个单独的构件。
-
在一些特殊情况下,迁移学习将涉及基础模型的某些部分的反向传播(例如截断的 MobileNet 的前几层)。当基础和头部是两个分开的对象时,这是不可能的。
在接下来的部分中,我们将展示一种通过形成单个模型对象来克服这些限制的方法进行迁移学习。这将是一个端到端模型,因为它可以将原始格式的输入数据转换为最终的期望输出。
基于嵌入的 k 最近邻分类
在机器学习中,解决分类问题的非神经网络方法有很多。其中最著名的之一就是 k 最近邻(kNN)算法。与神经网络不同,kNN 算法不涉及训练步骤,更容易理解。
我们可以用几句话来描述 kNN 分类的工作原理:
-
你选择一个正整数k(例如,3)。
-
你收集一些带有真实类别标签的参考示例。通常收集的参考示例数量至少是k的几倍。每个示例都被表示为一系列实值数字,或者一个向量。这一步类似于神经网络方法中的训练示例的收集。
-
为了预测新输入的类别,你计算新输入的向量表示与所有参考示例的距离。然后对距离进行排序。通过这样做,你可以找到在向量空间中距离输入最近的k个参考示例。这些被称为输入的“k个最近邻居”(算法的名字来源)。
-
你观察k个最近邻居的类别,并使用它们中最常见的类别作为输入的预测。换句话说,你让k个最近的邻居“投票”来预测类别。
这个算法的一个示例如下图所示。

在二维嵌入空间中的 kNN 分类示例。在这种情况下,k=3,有两个类别(三角形和圆形)。三角形类别有五个参考示例,圆形类别有七个。输入示例表示为一个正方形。与输入相连的三个最近邻居由连线表示。因为三个最近邻居中有两个是圆形,所以输入示例的预测类别将是圆形。
正如您从前面的描述中可以看到的,kNN 算法的一个关键要求是,每个输入示例都表示为一个向量。像我们从截断的 MobileNet 获取的那样的嵌入是这样的向量表示的良好候选者,原因有两个。首先,与原始输入相比,它们通常具有较低的维度,因此减少了距离计算所需的存储和计算量。其次,由于它们已经在大型分类数据集上进行了训练,所以这些嵌入通常捕捉到输入中的更重要的特征(例如图像中的重要几何特征;参见图 4.5),并忽略了不太重要的特征(例如亮度和大小)。在某些情况下,嵌入给我们提供了原本不以数字形式表示的事物的向量表示(例如第九章中的单词嵌入)。
与神经网络方法相比,kNN 不需要任何训练。在参考样本数量不太多且输入维度不太高的情况下,使用 kNN 可以比训练神经网络并对其进行推断的计算效率更高。
然而,kNN 推断不随数据量的增加而扩展。特别是,给定 N 个参考示例,kNN 分类器必须计算 N 个距离,以便为每个输入进行预测。当 N 变大时,计算量可能变得难以处理。相比之下,神经网络的推断不随训练数据的量而变化。一旦网络被训练,训练数据的数量就不重要了。网络正向传播所需的计算量仅取决于网络的拓扑结构。
^a
但是,请查看研究努力设计近似 kNN 算法但运行速度更快且规模比 kNN 更好的算法:Gal Yona,“利用局部敏感哈希进行快速近似重复图像搜索”,Towards Data Science,2018 年 5 月 5 日,
mng.bz/1wm1。
如果您有兴趣在您的应用程序中使用 kNN,请查看基于 TensorFlow.js 构建的 WebGL 加速 kNN 库:mng.bz/2Jp8。
5.1.3. 通过微调充分利用迁移学习:音频示例
在前几节中,迁移学习的示例处理了视觉输入。在这个例子中,我们将展示迁移学习也适用于表示为频谱图像的音频数据。回想一下,我们在第 4.4 节中介绍了用于识别语音命令(孤立的、短的口头单词)的卷积网络。我们构建的语音命令识别器只能识别 18 个不同的单词(如“one”、“two”、“up” 和 “down”)。如果你想为其他单词训练一个识别器呢?也许你的特定应用程序需要用户说特定的单词,比如“red” 或 “blue”,甚至是用户自己选的单词;或者你的应用程序面向的是讲英语以外语言的用户。这是迁移学习的一个经典例子:在手头数据量很少的情况下,你可以尝试从头开始训练一个模型,但使用预训练模型作为基础可以在更短的时间内和更少的计算资源下获得更高的准确度。
如何在语音命令示例应用中进行迁移学习
在我们描述如何在这个示例中进行迁移学习之前,最好让你熟悉如何通过 UI 使用迁移学习功能。要使用 UI,请确保您的计算机连接了音频输入设备(麦克风),并且在系统设置中将音频输入音量设置为非零值。要下载演示代码并运行它,请执行以下操作(与第 4.4.1 节相同的过程):
git clone https://github.com/tensorflow/tfjs-models.git
cd tfjs-models/speech-commands
yarn && yarn publish-local
cd demo
yarn && yarn link-local && yarn watch
当 UI 启动时,请允许浏览器访问麦克风的请求。图 5.9 显示了演示的示例截图。当演示页面启动时,将自动从互联网上加载预训练的语音命令模型,使用指向 HTTPS URL 的 tf.loadLayersModel() 方法。模型加载完成后,"开始" 和 "输入转移词" 按钮将被启用。如果点击 "开始" 按钮,演示将进入推理模式,连续检测屏幕上显示的 18 个基本单词。每次检测到一个单词时,屏幕上相应的单词框将点亮。但是,如果点击 "输入转移词" 按钮,屏幕上将会出现一些额外的按钮。这些按钮是从右侧的文本输入框中的逗号分隔的单词创建的。默认单词是 "noise"、"red" 和 "green"。这些是转移学习模型将被训练识别的单词。但是,如果你想为其他单词训练转移模型,可以自由修改输入框的内容,只要保留 "noise" 项即可。"noise" 项是特殊的一个,你应该收集背景噪声样本,即没有任何语音声音的样本。这允许转移模型区分语音和静音(背景噪声)的时刻。当你点击这些按钮时,演示将从麦克风记录 1 秒的音频片段,并在按钮旁边显示其频谱图。单词按钮中的数字跟踪到目前为止已经收集到的特定单词的示例数量。
图 5.9. 语音命令示例的转移学习功能的示例截图。在这里,用户已经为转移学习输入了一组自定义单词:"feel"、"seal"、"veal" 和 "zeal",以及始终需要的 "noise" 项。此外,用户已经收集了每个单词和噪声类别的 20 个示例。

如同机器学习问题中的一般情况,你能够收集的数据越多(在可用时间和资源允许的范围内),训练出的模型就会越好。示例应用程序至少需要每个单词的八个示例。如果你不想或无法自己收集声音样本,可以从mng.bz/POGY(文件大小:9 MB)下载预先收集好的数据集,并在 UI 的数据集 IO 部分使用上传按钮上传。
数据集准备好后,通过文件上传或你自己的样本收集,"开始迁移学习" 按钮将变为可用状态。你可以点击该按钮启动迁移模型的训练。该应用在你收集的音频频谱图上执行 3:1 的分割,随机选择其中 75%用于训练,剩余的 25%用于验证。应用程序在迁移学习过程中显示训练集损失和准确度值以及验证集值。一旦训练完成,可以点击 "开始" 按钮,让演示程序连续识别迁移词,此时你可以经验性地评估迁移模型的准确度。
⁸
这也是为什么演示要求你每个单词至少收集八个样本的原因。如果单词更少,在验证集中每个单词的样本数量将很少,可能会导致不可靠的损失和准确度估计。
你应该尝试不同的词汇组合,观察它们在经过迁移学习后对精确度的影响。默认集合中,“red”和“green”这两个词在音位内容方面非常不同。例如,它们的起始辅音是两个非常不同的声音,“r”和“g”。它们的元音也听起来非常不同(“e”和“ee”);结尾辅音也很不同(“d”和“n”)。因此,只要每个单词收集的样本数量不太小(例如>=8),使用的时代数不太小(这会导致欠拟合)或太大(这会导致过拟合;请参阅第八章),你就能够在迁移训练结束时获得几乎完美的验证精度。
为使模型的迁移学习任务更具挑战性,使用由 1)更具混淆性的单词和 2)更大的词汇组成的集合。这就是我们在图 5.9 的屏幕截图中所做的。在该截图中,使用了四个听起来相似的单词:“feel”、“seal”、“veal”和“zeal”。这些单词的元音和结尾辅音相同,开头的辅音也相似。它们甚至可能会让一个不注意或在坏电话线路上听的人听起来混淆。从图的右下角的准确度曲线可以看出,模型要达到 90%以上的准确度并不是一件容易的事,必须通过额外的“微调”阶段来补充初始的迁移学习 - 这是一种迁移学习技巧。
深入了解迁移学习中的微调
微调是一种技术,它可以帮助您达到仅通过训练迁移模型的新头部无法达到的准确度水平。如果您希望了解微调的工作原理,本节将更详细地解释。您需要消化一些技术细节。但通过它,您将深入理解迁移学习及其相关的 TensorFlow.js 实现,这将是值得的努力。
构建单个迁移学习模型
首先,我们需要了解语音迁移学习应用程序如何为迁移学习创建模型。列表 5.7(来自 speech-commands/src/browser_ fft_recognizer.ts 的代码)中的代码从基础语音命令模型(您在 第 4.4.1 节中学到的模型)创建一个模型。它首先找到模型的倒数第二个(倒数第二个)密集层,并获取其输出符号张量(代码中的truncatedBaseOutput)。然后,它创建一个仅包含一个密集层的新头模型。这个新头的输入形状与truncatedBaseOutput符号张量的形状匹配,其输出形状与迁移数据集中的单词数匹配(在图 5.9 的情况下为五个)。密集层配置为使用 softmax 激活,适用于多类别分类任务。(请注意,与书中大多数其他代码清单不同,以下代码是用 TypeScript 编写的。如果您不熟悉 TypeScript,可以简单地忽略类型标记,例如void和tf.SymbolicTensor`。)
列表 5.7. 将迁移学习模型创建为单个tf.Model对象^([9])
⁹
关于此代码列表的两点说明:1)代码是用 TypeScript 编写的,因为它是可重用的 @tensorflow-models/speech-commands 库的一部分。2)出于简化的目的,此代码中删除了一些错误检查代码。
private createTransferModelFromBaseModel(): void {
const layers = this.baseModel.layers;
let layerIndex = layers.length - 2;
while (layerIndex >= 0) { ***1***
if (layers[layerIndex].getClassName().toLowerCase() === 'dense') { ***1***
break; ***1***
} ***1***
layerIndex--; ***1***
} ***1***
if (layerIndex < 0) {
throw new Error('Cannot find a hidden dense layer in the base model.');
}
this.secondLastBaseDenseLayer = ***2***
layers[layerIndex]; ***2***
const truncatedBaseOutput = layers[layerIndex].output as ***3***
tf.SymbolicTensor; ***3***
this.transferHead = tf.layers.dense({ ***4***
units: this.words.length, ***4***
activation: 'softmax', ***4***
inputShape: truncatedBaseOutput.shape.slice(1) ***4***
})); ***4***
const transferOutput = ***5***
this.transferHead.apply(truncatedBaseOutput) as tf.SymbolicTensor; ***5***
this.model = ***6***
tf.model({inputs: this.baseModel.inputs, outputs: transferOutput});***6***
}
-
1 找到基础模型的倒数第二个密集层
-
2 获取稍后在微调过程中将解冻的层(请参阅列表 5.8)
-
3 找到符号张量
-
4 创建模型的新头
-
5 在截断的基础模型输出上“应用”新的头部,以获取新模型的最终输出作为符号张量。
-
6 使用
tf.model()API 创建一个新的用于迁移学习的模型,指定原始模型的输入作为其输入,新的符号张量作为输出。
新的头部以一种新颖的方式使用:其apply()方法使用截断的基础输出符号张量作为输入参数进行调用。apply()是 TensorFlow.js 中每个层和模型对象上都可用的方法。apply()方法的作用是什么?顾名思义,它“应用”新的头模型于输入,并给出输出。要认识到的重要事项如下:
-
输入和输出都是符号化的——它们是具体张量值的占位符。
-
图 5.10 给出了一个图形示例:符号输入(
truncatedBaseOutput)不是一个孤立的实体;而是基模型倒数第二个密集层的输出。该密集层从另一层接收输入,该层又从其上游层接收输入,依此类推。因此,truncatedBaseOutput携带着基模型的一个子图,即基模型的输入到倒数第二个密集层的输出之间的子图。换句话说,它是基模型的整个图,减去倒数第二个密集层之后的部分。因此,apply()调用的输出包含该子图以及新的密集层。输出和原始输入在调用tf.model()函数时共同使用,得到一个新模型。这个新模型与基模型相同,只是其头部被新的密集层替换了(参见图 5.10 的底部部分)。
图 5.10. 示出了创建迁移学习的新端到端模型的方式的示意图。在阅读此图时,请参考 list 5.7。与 list 5.7 中的变量对应的图的某些部分用固定宽度字体标记。步骤 1:获取原始模型倒数第二个密集层的输出符号张量(由粗箭头指示)。它将在步骤 3 中被使用。步骤 2:创建新的头模型,包含一个单输出的密集层(标记为“dense 3”)。步骤 3:使用步骤 1 中的符号张量作为输入参数调用新头模型的apply()方法。此调用将该输入与步骤 1 中的截断的基模型连接起来。步骤 4:将apply()调用的返回值与原始模型的输入符号张量一起在调用tf.model()函数时使用。此调用返回一个新模型,其中包含了原始模型的所有层,从第一层到倒数第二个密集层,以及新头的密集层。实际上,这将原始模型的旧头和新头交换,为后续在迁移数据上训练做准备。请注意,为了简化可视化效果,图中省略了实际语音命令模型的一些(七个)层。在此图中,有颜色的层是可训练的,而白色的层是不可训练的。

请注意,这里的方法与我们在 5.1.2 节 中如何融合模型的方法不同。在那里,我们创建了一个被截断的基础模型和一个新的头模型作为两个独立的模型实例。因此,对每个输入示例进行推断涉及两个 predict() 调用。在这里,新模型期望的输入与基础模型期望的音频频谱张量相同。同时,新模型直接输出新单词的概率分数。每次推断仅需一个 predict() 调用,因此是一个更加流畅的过程。通过将所有层封装在单个模型中,我们的新方法在我们的应用中具有一个额外的重要优势:它允许我们通过参与识别新单词的任何层执行反向传播。这使我们能够执行微调技巧。这是我们将在下一节中探讨的内容。
通过解冻层进行微调
微调是转移学习的可选步骤,紧随模型训练的初始阶段。在初始阶段,来自基础模型的所有层都被冻结(它们的trainable属性设置为false),权重更新仅发生在头部层。我们在本章前面的 mnist-transfer-cnn 和 webcam-transfer-learning 示例中已经看到了这种初始训练类型。在微调期间,基础模型的一些层被解冻(它们的trainable属性设置为true),然后模型再次在转移数据上进行训练。这种层解冻在 图 5.11 中以示意图显示。代码在 TensorFlow.js 中显示了如何为语音命令示例执行此操作,详见 清单 5.8(来自 speech-commands/src/browser_fft_recognizer.ts)。
图 5.11。展示了转移学习初始阶段(面板 A)和微调阶段(面板 B)期间冻结和未冻结(即可训练)层的示意图,代码见 清单 5.8。注意 dense1 紧随 dense3 之后的原因是,dense2(基础模型的原始输出)已被截断为转移学习的第一步(参见 图 5.10)。

清单 5.8。初始转移学习,然后进行微调^([10])
¹⁰
一些错误检查代码已被删除,以便集中关注算法的关键部分。
async train(config?: TransferLearnConfig):
Promise<tf.History|[tf.History, tf.History]> {
if (config == null) {
config = {};
}
if (this.model == null) {
this.createTransferModelFromBaseModel();
}
this.secondLastBaseDenseLayer.trainable = false; ***1***
this.model.compile({ ***2***
loss: 'categoricalCrossentropy', ***2***
optimizer: config.optimizer || 'sgd', ***2***
metrics: ['acc'] ***2***
}); ***2***
const {xs, ys} = this.collectTransferDataAsTensors();
let trainXs: tf.Tensor;
let trainYs: tf.Tensor;
let valData: [tf.Tensor, tf.Tensor];
try {
if (config.validationSplit != null) {
const splits = balancedTrainValSplit( ***3***
xs, ys, config.validationSplit); ***3***
trainXs = splits.trainXs;
trainYs = splits.trainYs;
valData = [splits.valXs, splits.valYs];
} else {
trainXs = xs;
trainYs = ys;
}
const history = await this.model.fit(trainXs, trainYs, { ***4***
epochs: config.epochs == null ? 20 : config.epochs, ***4***
validationData: valData, ***4***
batchSize: config.batchSize, ***4***
callbacks: config.callback == null ? null : [config.callback] ***4***
}); ***4***
if (config.fineTuningEpochs != null && config.fineTuningEpochs > 0) {***5***
this.secondLastBaseDenseLayer.trainable = ***5***
true;
const fineTuningOptimizer: string|tf.Optimizer =
config.fineTuningOptimizer == null ? 'sgd' :
config.fineTuningOptimizer;
this.model.compile({ ***6***
loss: 'categoricalCrossentropy', ***6***
optimizer: fineTuningOptimizer, ***6***
metrics: ['acc'] ***6***
}); ***6***
const fineTuningHistory = await this.model.fit(trainXs, trainYs, { ***7***
epochs: config.fineTuningEpochs, ***7***
validationData: valData, ***7***
batchSize: config.batchSize, ***7***
callbacks: config.fineTuningCallback == null ? ***7***
null : ***7***
[config.fineTuningCallback] ***7***
}); ***7***
return [history, fineTuningHistory];
} else {
return history;
}
} finally {
tf.dispose([xs, ys, trainXs, trainYs, valData]);
}
}
-
1 确保所有截断基础模型的层,包括稍后将进行微调的层,在转移训练的初始阶段都被冻结
-
2 为初始转移训练编译模型
-
3 如果需要 validationSplit,则以平衡的方式将转移数据分割为训练集和验证集
-
4 调用 Model.fit() 进行初始转移训练
-
5 对于微调,解冻基础模型的倒数第二个密集层(截断基础模型的最后一层)
-
6 在解冻层之后重新编译模型(否则解冻不会生效)
-
7 调用 Model.fit() 进行微调
有几个关于 列表 5.8 中代码需要指出的重要事项:
-
每次您通过更改它们的
trainable属性来冻结或解冻任何层时,都需要再次调用模型的compile()方法,以使更改生效。我们已经在 第 5.1.1 节 中讨论了这一点,当我们谈到 MNIST 迁移学习示例时。 -
我们保留了训练数据的一部分用于验证。这样做可以确保我们观察的损失和准确率反映了模型在反向传播期间没有见过的输入上的表现。然而,我们为验证而从收集的数据中拆分出一部分的方式与以前不同,并且值得注意。在 MNIST 卷积网络示例(列表 4.2 在 第四章)中,我们使用了
validationSplit参数,让Model.fit()保留最后的 15–20% 的数据用于验证。但是,在这里使用相同的方法效果不佳。为什么?因为与早期示例中的数据量相比,我们这里的训练集要小得多。因此,盲目地将最后几个示例拆分为验证可能会导致一些词在验证子集中表示不足。例如,假设您为“feel”、“seal”、“veal” 和 “zeal” 中的每个词收集了八个示例,并选择最后的 32 个样本(8 个示例)的 25% 作为验证。那么,平均而言,验证子集中每个单词只有两个示例。由于随机性,一些单词可能最终只在验证子集中有一个示例,而其他单词可能根本没有示例!显然,如果验证集缺少某些单词,它将不是用于测量模型准确性的很好的集合。这就是为什么我们使用一个自定义函数(balancedTrainValSplit在 列表 5.8)。此函数考虑了示例的真实单词标签,并确保所有不同的单词在训练和验证子集中都得到公平的表示。如果您有一个涉及类似小数据集的迁移学习应用程序,那么做同样的事情是个好主意。
那么,微调为我们做了什么呢?在迁移学习的初始阶段之上,微调提供了什么附加价值?为了说明这一点,我们将面板 A 中初始阶段和微调阶段的损失和准确率曲线连续绘制在一起,如图 5.12 所示。这里涉及的迁移数据集包含了我们在图 5.9 中看到的相同的四个单词。每条曲线的前 100 个纪元对应于初始阶段,而最后的 300 个纪元对应于微调。你可以看到,在初始训练的前 100 个纪元结束时,损失和准确率曲线开始变平并开始进入递减回报的区域。在验证子集的准确率在约 84% 时达到平稳状态。(请注意,仅查看 训练子集 的准确率曲线是多么具有误导性,因为它很容易接近 100%。)然而,解冻基础模型中的密集层,重新编译模型,并开始微调训练阶段,验证准确率就不再停滞,可以提高到 90–92%,这是一个非常可观的准确率增加 6–8 个百分点。验证损失曲线也可以看到类似的效果。
图 5.12. 面板 A:迁移学习和随后微调(图例中标为 FT)的示例损失和准确率曲线。注意曲线初始部分和微调部分之间的拐点。微调加速了损失的减少和准确率的提高,这是由于基础模型的顶部几层解冻以及模型容量的增加,以及向迁移学习数据中的独特特征的调整所致。面板 B:在不进行微调的情况下训练迁移模型相同数量的纪元(400 纪元)的损失和准确率曲线。注意,没有微调时,验证损失收敛到较高值,验证准确率收敛到比面板 A 低的值。请注意,虽然进行了微调(面板 A)的最终准确率达到约 0.9,但在没有进行微调但总纪元数相同的情况下(面板 B),准确率停留在约 0.85。

为了说明微调相对于不进行微调的迁移学习的价值,我们在图 5.12 的面板 B 中展示了如果不微调基础模型的顶部几层,而将迁移模型训练相同数量(400)的纪元时会发生什么。在面板 A 中发生的在第 100 纪元时进行微调的损失或准确率曲线上没有“拐点”。相反,损失和准确率曲线趋于平稳,并收敛到较差的值。
那么为什么微调有帮助呢?可以理解为增加了模型的容量。通过解冻基本模型的一些最顶层,我们允许转移模型在比初始阶段更高维的参数空间中最小化损失函数。这类似于向神经网络添加隐藏层。解冻的密集层的权重参数已经针对原始数据集进行了优化(由诸如“one”、“two”、“yes”和“no”之类的单词组成的数据集),这可能对转移单词不是最优的。这是因为帮助模型区分这些原始单词的内部表示可能不是使转移单词最容易区分的表示。通过允许进一步优化(即微调)这些参数以用于转移单词,我们允许表示被优化用于转移单词。因此,我们在转移单词上获得验证准确性的提升。请注意,当转移学习任务很难时(如四个易混淆的单词:“feel”、“seal”、“veal”和“zeal”),更容易看到这种提升。对于更容易的任务(更不同的单词,如“red”和“green”),验证准确性可能仅仅通过初始的转移学习就可以达到 100%。
你可能想问的一个问题是,在这里我们只解冻了基本模型的一层,但是解冻更多的层会有帮助吗?简短的答案是,这取决于情况,因为解冻更多的层会使模型的容量更高。但正如我们在第四章中提到的,并且将在第八章中更详细地讨论,更高的容量会导致过拟合的风险增加,特别是当我们面对像这里收集到的音频示例这样的小数据集时。更不用说训练更多层所需的额外计算负载了。鼓励你作为本章末尾的一部分来进行自己的实验。
让我们结束 TensorFlow.js 中关于迁移学习的这一部分。我们介绍了三种在新任务上重用预训练模型的不同方法。为了帮助你决定在将来的迁移学习项目中使用哪种方法,我们在 table 5.1 中总结了这三种方法及其相对优缺点。
表 5.1. TensorFlow.js 中三种迁移学习方法及其相对优势和缺点的总结
| 方法 | 优势 | 缺点 |
|---|---|---|
| 使用原始模型并冻结其前几层(特征提取层)(section 5.1.1)。 |
- 简单而方便
|
- 仅当迁移学习所需的输出形状和激活与基本模型的形状和激活匹配时才起作用
|
| 从原始模型中获取内部激活作为输入示例的嵌入,并创建一个以该嵌入作为输入的新模型(section 5.1.2)。 |
|---|
-
适用于需要与原始输出形状不同的迁移学习情况
-
嵌入张量是直接可访问的,使得 k 最近邻(kNN,见信息框 5.2)分类器等方法成为可能
|
-
需要管理两个独立的模型实例
-
很难微调原始模型的层
|
| 创建一个包含原始模型的特征提取层和新头部层的新模型(请参见第 5.1.3 节)。 |
|---|
-
适用于需要与原始输出形状不同的迁移学习情况
-
只需要管理一个模型实例
-
允许对特征提取层进行微调
|
- 无法直接访问内部激活(嵌入)张量
|
5.2. 通过卷积神经网络进行目标检测的迁移学习
到目前为止,你在本章中所看到的迁移学习示例都有一个共同点:在迁移后机器学习任务的性质保持不变。特别是,它们采用了一个在多类分类任务上训练的计算机视觉模型,并将其应用于另一个多类分类任务。在本节中,我们将展示这并不一定是这样的。基础模型可以用于非常不同于原始任务的任务,例如当你想使用在分类任务上训练的基础模型来执行回归(拟合数字)时。这种跨领域迁移是深度学习的多功能性和可重复使用性的良好例证,是该领域成功的主要原因之一。
为了说明这一点,我们将使用新的任务——目标检测,这是本书中第一个非分类计算机视觉问题类型。目标检测涉及在图像中检测特定类别的物体。它与分类有何不同?在目标检测中,检测到的物体不仅会以类别(它是什么类型的物体)的形式报告,还会包括有关物体在图像中内部位置的一些附加信息(物体在哪里)。后者是一个普通分类器无法提供的信息。例如,自动驾驶汽车使用的典型目标检测系统会分析输入图像的一个框架,以便该系统不仅输出图像中存在的有趣对象的类型(如车辆和行人),还输出这些对象在图像坐标系内的位置、表面积和姿态等信息。
示例代码位于 tfjs-examples 仓库的 simple-object-detection 目录中。请注意,此示例与您迄今为止看到的示例不同,因为它将在 Node.js 中的模型训练与浏览器中的推理结合起来。具体来说,模型训练使用 tfjs-node(或 tfjs-node-gpu)进行,训练好的模型将保存到磁盘上。然后使用 parcel 服务器来提供保存的模型文件,以及静态的 index.html 和 index.js,以展示浏览器中模型的推理。
您可以使用的运行示例的命令序列如下(其中包含一些您在输入命令时不需要包含的注释字符串):
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/simple-object-detection
yarn
# Optional step for training your own model using Node.js:
yarn train \
--numExamples 20000 \
--initialTransferEpochs 100 \
--fineTuningEpochs 200
yarn watch # Run object-detection inference in the browser.
yarn train命令会在您的机器上进行模型训练,并在完成后将模型保存到./dist文件夹中。请注意,这是一个长时间运行的训练任务,如果您有 CUDA 启用的 GPU,则最好处理,因为这可以将训练速度提高 3 到 4 倍。为此,您只需要向yarn train命令添加--gpu标志:
yarn train --gpu \
--numExamples 20000 \
--initialTransferEpochs 100 \
--fineTuningEpochs 200
如果您没有时间或资源在自己的机器上对模型进行训练,不用担心:您可以直接跳过yarn train命令,直接执行yarn watch。在浏览器中运行的推理页面将允许您通过 HTTP 从集中位置加载我们已经为您训练好的模型。
5.2.1. 基于合成场景的简单目标检测问题
最先进的目标检测技术涉及许多技巧,这些技巧不适合用于初学者教程。我们在这里的目标是展示目标检测的本质,而不受太多技术细节的困扰。为此,我们设计了一个涉及合成图像场景的简单目标检测问题(见图 5.13)。这些合成图像的尺寸为 224 × 224,色深为 3(RGB 通道),因此与将成为我们模型基础的 MobileNet 模型的输入规范匹配。正如图 5.13 中的示例所示,每个场景都有一个白色背景。要检测的对象可以是等边三角形或矩形。如果对象是三角形,则其大小和方向是随机的;如果对象是矩形,则其高度和宽度是随机变化的。如果场景仅由白色背景和感兴趣的对象组成,则任务将太容易,无法展示我们技术的强大之处。为了增加任务的难度,在场景中随机分布了一些“噪声对象”。这些对象包括每个图像中的 10 个圆和 10 条线段。圆的位置和大小以随机方式生成,线段的位置和长度也是如此。一些噪声对象可能位于目标对象的顶部,部分遮挡它。所有目标和噪声对象都具有随机生成的颜色。
图 5.13. 简单物体检测使用的合成场景示例。面板 A:一个旋转的等边三角形作为目标对象。面板 B:一个矩形作为目标对象。标记为“true”的框是感兴趣对象的真实边界框。请注意,感兴趣对象有时可能会被一些噪声对象(线段和圆)部分遮挡。

随着输入数据被完全描述,我们现在可以为我们即将创建和训练的模型定义任务。该模型将输出五个数字,这些数字被组织成两组:
-
第一组包含一个数字,指示检测到的对象是三角形还是矩形(不考虑其位置、大小、方向和颜色)。
-
剩下的四个数字组成了第二组。它们是检测到的物体周围边界框的坐标。具体来说,它们分别是边界框的左 x 坐标、右 x 坐标、顶部 y 坐标和底部 y 坐标。参见图 5.13 作为示例。
使用合成数据的好处是 1)真实标签值会自动知道,2)我们可以生成任意数量的数据。每次生成场景图像时,对象的类型和其边界框都会自动从生成过程中对我们可用。因此,不需要对训练图像进行任何劳动密集型的标记。这种输入特征和标签一起合成的非常高效的过程在许多深度学习模型的测试和原型环境中使用,并且这是一种你应该熟悉的技术。然而,用于真实图像输入的训练物体检测模型需要手动标记的真实场景。幸运的是,有这样的标记数据集可用。通用物体和背景(COCO)数据集就是其中之一(参见cocodataset.org)。
训练完成后,模型应能够以相当高的准确性定位和分类目标对象(如图 5.13 中所示的示例所示)。要了解模型如何学习这个物体检测任务,请跟随我们进入下一节中的代码。
5.2.2. 深入了解简单物体检测
现在让我们构建神经网络来解决合成对象检测问题。与以前一样,我们在预训练的 MobileNet 模型上构建我们的模型,以使用模型的卷积层中的强大的通用视觉特征提取器。这是 列表 5.9 中的 loadTruncatedBase() 方法所做的。然而,我们的新模型面临的一个新挑战是如何同时预测两个东西:确定目标对象的形状以及在图像中找到其坐标。我们以前没有见过这种“双任务预测”类型。我们在这里使用的技巧是让模型输出一个张量,该张量封装了两个预测,并且我们将设计一个新的损失函数,该函数同时衡量模型在两个任务中的表现如何。我们可以训练两个单独的模型,一个用于分类形状,另一个用于预测边界框。但是与使用单个模型执行两个任务相比,运行两个模型将涉及更多的计算和更多的内存使用,并且不利用特征提取层可以在两个任务之间共享的事实。(以下代码来自 simple-object-detection/train.js。)
列表 5.9. 基于截断 MobileNet 定义简单对象学习模型^([11])
¹¹
为了清晰起见,一些用于检查错误条件的代码已被删除。
const topLayerGroupNames = [ ***1***
'conv_pw_9', 'conv_pw_10', 'conv_pw_11']; ***1***
const topLayerName =
`${topLayerGroupNames[topLayerGroupNames.length - 1]}_relu`;
async function loadTruncatedBase() {
const mobilenet = await tf.loadLayersModel(
'https://storage.googleapis.com/' +
'tfjs-models/tfjs/mobilenet_v1_0.25_224/model.json');
const fineTuningLayers = [];
const layer = mobilenet.getLayer(topLayerName); ***2***
const truncatedBase = ***3***
tf.model({ ***3***
inputs: mobilenet.inputs, ***3***
outputs: layer.output ***3***
}); ***3***
for (const layer of truncatedBase.layers) {
layer.trainable = false; ***4***
for (const groupName of topLayerGroupNames) {
if (layer.name.indexOf(groupName) === 0) { ***5***
fineTuningLayers.push(layer);
break;
}
}
}
return {truncatedBase, fineTuningLayers};
}
function buildNewHead(inputShape) { ***6***
const newHead = tf.sequential(); ***6***
newHead.add(tf.layers.flatten({inputShape})); ***6***
newHead.add(tf.layers.dense({units: 200, activation: 'relu'})); ***6***
newHead.add(tf.layers.dense({units: 5})); ***6*** ***7***
return newHead; ***6***
} ***6***
async function buildObjectDetectionModel() { ***8***
const {truncatedBase, fineTuningLayers} = await loadTruncatedBase(); ***8***
const newHead = buildNewHead(truncatedBase.outputs[0].shape.slice(1));***8***
const newOutput = newHead.apply(truncatedBase.outputs[0]); ***8***
const model = tf.model({ ***8***
inputs: truncatedBase.inputs, ***8***
outputs: newOutput ***8***
}); ***8***
return {model, fineTuningLayers};
}
-
1 设置要解冻以进行微调的层
-
2 获取中间层:最后一个特征提取层
-
3 形成截断的 MobileNet
-
4 冻结所有特征提取层以进行迁移学习的初始阶段
-
5 跟踪在微调期间将解冻的层
-
6 为简单对象检测任务构建新头模型
-
7 长度为 5 的输出包括长度为 1 的形状指示器和长度为 4 的边界框(请参见 图 5.14)。
-
8 将新的头模型放在截断的 MobileNet 顶部,形成整个对象检测模型
“双任务”模型的关键部分由 列表 5.9 中的 buildNewHead() 方法构建。模型的示意图显示在 图 5.14 的左侧。新头部由三层组成。一个展平层将截断的 MobileNet 基础的最后一个卷积层的输出形状为后续可以添加的密集层。第一个密集层是具有 relu 非线性的隐藏层。第二个密集层是头部的最终输出,因此也是整个对象检测模型的最终输出。该层具有默认的线性激活。这是理解该模型如何工作的关键,因此需要仔细查看。
图 5.14. 对象检测模型及其基于的自定义损失函数。请参见 列表 5.9,了解模型(左侧部分)的构建方式。请参见 列表 5.10,了解自定义损失函数的编写方式。

正如你从代码中看到的那样,最终的稠密层输出单元数为 5。这五个数字代表什么?它们结合了形状预测和边界框预测。有趣的是,决定它们含义的不是模型本身,而是将用于模型的损失函数。之前,你看到过各种类型的损失函数,可以直接使用字符串名称,如"meanSquaredError",并适用于各自的机器学习任务(例如,请参阅第三章表 3.6)。然而,这只是在 TensorFlow.js 中指定损失函数的两种方法之一。另一种方法,也是我们在这里使用的方法,涉及定义一个满足某个特定签名的自定义 JavaScript 函数。该签名如下:
-
有两个输入参数:1)输入示例的真实标签和 2)模型的对应预测。它们都表示为 2D 张量。这两个张量的形状应该是相同的,每个张量的第一个维度都是批处理大小。
-
返回值是一个标量张量(形状为
[]的张量),其值是批处理中示例的平均损失。
我们根据这个签名编写的自定义损失函数在列表 5.10 中显示,并在图 5.14 的右侧进行了图形化说明。customLossFunction的第一个输入(yTrue)是真实标签张量,其形状为[batchSize, 5]。
第二个输入(yPred)是模型的输出预测,其形状与yTrue完全相同。在yTrue的第二轴上的五个维度(如果我们将其视为矩阵,则为五列)中,第一个维度是目标对象形状的 0-1 指示器(三角形为 0,矩形为 1)。这是由数据合成方式确定的(请参阅 simple-object-detection/synthetic_images.js)。其余四列是目标对象的边界框,即其左、右、上和下值,每个值范围从 0 到 CANVAS_SIZE(224)。数字 224 是输入图像的高度和宽度,来自于 MobileNet 的输入图像大小,我们的模型基于此。
列表 5.10。为对象检测任务定义自定义损失函数
const labelMultiplier = tf.tensor1d([CANVAS_SIZE, 1, 1, 1, 1]);
function customLossFunction(yTrue, yPred) {
return tf.tidy(() => {
return tf.metrics.meanSquaredError(
yTrue.mul(labelMultiplier), yPred); ***1***
});
}
- 1
yTrue的形状指示器列通过 CANVAS_SIZE(224)缩放,以确保形状预测和边界框预测对损失的贡献大致相等。
自定义损失函数接收yTrue并将其第一列(0-1 形状指示器)按CANVAS_SIZE进行缩放,同时保持其他列不变。然后它计算yPred和缩放后的yTrue之间的均方误差。为什么我们要缩放yTrue中的 0-1 形状标签?我们希望模型输出一个代表它预测形状是三角形还是矩形的数字。具体地说,它输出一个接近于 0 的数字表示三角形,一个接近于CANVAS_SIZE(224)的数字表示矩形。因此,在推理时,我们只需将模型输出的第一个值与CANVAS_SIZE/2(112)进行比较,以获取模型对形状更像是三角形还是矩形的预测。那么如何衡量这种形状预测的准确性以得出损失函数呢?我们的答案是计算这个数字与 0-1 指示器之间的差值,乘以CANVAS_SIZE。
为什么我们这样做而不像在第三章中钓鱼检测示例中使用二进制交叉熵呢?我们需要在这里结合两个准确度指标:一个是形状预测,另一个是边界框预测。后者任务涉及预测连续值,可以视为回归任务。因此,均方误差是边界框的自然度量标准。为了结合这些度量标准,我们只需“假装”形状预测也是一个回归任务。这个技巧使我们能够使用单个度量函数(列表 5.10 中的tf.metric.meanSquaredError()调用)来封装两种预测的损失。
但是为什么我们要将 0-1 指示器按CANVAS_SIZE进行缩放呢?如果我们不进行这种缩放,我们的模型最终会生成一个接近于 0-1 的数字,作为它预测形状是三角形(接近于 0)还是矩形(接近于 1)的指示器。在[0, 1]区间内的数字之间的差异显然要比我们从比较真实边界框和预测边界框得到的差异要小得多,后者在 0-224 的范围内。因此,来自形状预测的误差信号将完全被来自边界框预测的误差信号所掩盖,这对于我们得到准确的形状预测没有帮助。通过缩放 0-1 形状指示器,我们确保形状预测和边界框预测对最终损失值(customLossFunction()的返回值)的贡献大致相等,因此当模型被训练时,它将同时优化两种类型的预测。在本章末尾的练习 4 中,我们鼓励你自己尝试使用这种缩放。
¹²
这里的一个替代方法是采用基于缩放和
meanSquaredError的方法,将yPred的第一列作为形状概率分数,并与yTrue的第一列计算二元交叉熵。然后,将二元交叉熵值与在yTrue和yPred的其余列上计算的 MSE 相加。但在这种替代方法中,需要适当缩放交叉熵,以确保与边界框损失的平衡,就像我们当前的方法一样。缩放涉及一个自由参数,其值需要仔细选择。在实践中,它成为模型的一个额外超参数,并且需要时间和计算资源来调整,这是该方法的一个缺点。为了简单起见,我们选择了当前方法,而不采用该方法。
数据准备就绪,模型和损失函数已定义,我们准备好训练我们的模型!模型训练代码的关键部分显示在 清单 5.11(来自 simple-object-detection/train.js)。与我们之前见过的微调(第 5.1.3 节)类似,训练分为两个阶段:初始阶段,在此阶段仅训练新的头部层;微调阶段,在此阶段将新的头部层与截断的 MobileNet 基础的顶部几层一起训练。需要注意的是,在微调的 fit() 调用之前,必须(再次)调用 compile() 方法,以便更改层的 trainable 属性生效。如果在您自己的机器上运行训练,很容易观察到损失值在微调阶段开始时出现显着下降,这反映了模型容量的增加以及解冻特征提取层对目标检测数据中的唯一特征的适应能力。在微调期间解冻的层的列表由 fineTuningLayers 数组确定,在截断 MobileNet 时填充(参见 清单 5.9 中的 loadTruncatedBase() 函数)。这些是截断的 MobileNet 的顶部九层。在本章末尾的练习 3 中,您可以尝试解冻更少或更多的基础顶部层,并观察它们如何改变训练过程产生的模型的准确性。
清单 5.11. 训练目标检测模型的第二阶段
const {model, fineTuningLayers} = await buildObjectDetectionModel();
model.compile({ ***1***
loss: customLossFunction, ***1***
optimizer: tf.train.rmsprop(5e-3) ***1***
}); ***1***
await model.fit(images, targets, { ***2***
epochs: args.initialTransferEpochs, ***2***
batchSize: args.batchSize, ***2***
validationSplit: args.validationSplit ***2***
}); ***2***
// Fine-tuning phase of transfer learning.
for (const layer of fineTuningLayers) { ***3***
layer.trainable = true; ***4***
}
model.compile({ ***5***
loss: customLossFunction, ***5***
optimizer: tf.train.rmsprop(2e-3) ***5***
}); ***5***
await model.fit(images, targets, {
epochs: args.fineTuningEpochs,
batchSize: args.batchSize / 2, ***6***
validationSplit: args.validationSplit
}); ***7***
-
1 在初始阶段使用相对较高的学习率
-
2 执行迁移学习的初始阶段
-
3 开始微调阶段
-
4 解冻一些层进行微调
-
5 在微调阶段使用稍低的学习率
-
6 在微调阶段,我们将 batchSize 减少以避免由于反向传播涉及更多权重并消耗更多内存而引起的内存不足问题。
-
7 执行微调阶段
微调结束后,模型将保存到磁盘,并在浏览器内推断步骤期间加载(通过yarn watch命令启动)。如果加载托管模型,或者您已经花费了时间和计算资源在自己的计算机上训练了一个相当不错的模型,那么您在推断页面上看到的形状和边界框预测应该是相当不错的(在初始训练的 100 个周期和微调的 200 个周期后,验证损失在<100)。推断结果是好的但并非完美(请参阅 图 5.13 中的示例)。当您检查结果时,请记住,在浏览器内评估是公平的,并且反映了模型的真实泛化能力,因为训练模型在浏览器中要解决的示例与迁移学习过程中它所见到的训练和验证示例不同。
结束本节时,我们展示了如何将之前在图像分类上训练的模型成功地应用于不同的任务:对象检测。在此过程中,我们演示了如何定义一个自定义损失函数来适应对象检测问题的“双重任务”(形状分类 + 边界框回归)的特性,以及如何在模型训练过程中使用自定义损失。该示例不仅说明了对象检测背后的基本原理,还突显了迁移学习的灵活性以及它可能用于的问题范围。在生产应用中使用的对象检测模型当然比我们在这里使用合成数据集构建的玩具示例更复杂,并且涉及更多技巧。信息框 5.3 简要介绍了一些有关高级对象检测模型的有趣事实,并描述了它们与您刚刚看到的简单示例的区别以及您如何通过 TensorFlow.js 使用其中之一。
生产对象检测模型

TensorFlow.js 版本的 Single-Shot Detection (SSD) 模型的一个示例对象检测结果。注意多个边界框及其关联的对象类和置信度分数。
对象检测是许多类型应用的重要任务,例如图像理解、工业自动化和自动驾驶汽车。最著名的最新对象检测模型包括 Single-Shot Detection^([a])(SSD,示例推断结果如图所示)和 You Only Look Once (YOLO)^([b])。这些模型在以下方面与我们在简单对象检测示例中看到的模型类似:
^a
Wei Liu 等人,“SSD: Single Shot MultiBox Detector,” 计算机科学讲义 9905,2016,
mng.bz/G4qD。^b
Joseph Redmon 等人,“You Only Look Once: Unified, Real-Time Object Detection,” IEEE 计算机视觉与模式识别会议论文集 (CVPR), 2016, pp. 779–788,
mng.bz/zlp1.
-
它们预测对象的类别和位置。
-
它们是建立在 MobileNet 和 VGG16 等预训练图像分类模型上的,并通过迁移学习进行训练。
^c
Karen Simonyan 和 Andrew Zisserman,“Very Deep Convolutional Networks for Large-Scale Image Recognition,” 2014 年 9 月 4 日提交,
arxiv.org/abs/1409.1556.
然而,它们在很多方面也与我们的玩具模型不同:
-
真实的目标检测模型预测的对象类别比我们的简单模型多得多(例如,COCO 数据集有 80 个对象类别;参见
cocodataset.org/#home)。 -
它们能够在同一图像中检测多个对象(请参见示例图)。
-
它们的模型架构比我们简单模型中的更复杂。例如,SSD 模型在截断的预训练图像模型顶部添加了多个新头部,以便预测输入图像中多个对象的类别置信度分数和边界框。
-
与使用单个
meanSquaredError度量作为损失函数不同,真实目标检测模型的损失函数是两种类型损失的加权和:1)对于对象类别预测的类似 softmax 交叉熵的损失和 2)对于边界框的meanSquaredError或meanAbsoluteError-like 损失。两种类型损失值之间的相对权重被精心调整以确保来自两种错误来源的平衡贡献。 -
真实的目标检测模型会为每个输入图像产生大量的候选边界框。这些边界框被“修剪”以便保留具有最高对象类别概率分数的边界框作为最终输出。
-
一些真实的目标检测模型融合了关于对象边界框位置的“先验知识”。这些是关于边界框在图像中位置的教育猜测,基于对更多标记的真实图像的分析。这些先验知识通过从一个合理的初始状态开始而不是完全随机的猜测(就像我们简单的目标检测示例中一样)来加快模型的训练速度。
一些真实的目标检测模型已被移植到 TensorFlow.js 中。例如,你可以玩的最好的模型之一位于 tfjs-models 存储库的 coco-ssd 目录中。要看它的运行情况,执行以下操作:
git clone https://github.com/tensorflow/tfjs-models.git
cd tfjs-models/coco-ssd/demo
yarn && yarn watch
如果你对了解更多真实目标检测模型感兴趣,你可以阅读以下博文。它们分别是关于 SSD 模型和 YOLO 模型的,它们使用不同的模型架构和后处理技术:
-
“理解 SSD MultiBox——深度学习中的实时目标检测” by Eddie Forson:
mng.bz/07dJ。 -
“使用 YOLO、YOLOv2 和现在的 YOLOv3 进行实时目标检测” by Jonathan Hui:
mng.bz/KEqX。
到目前为止,在本书中,我们处理的是交给我们并准备好探索的机器学习数据集。它们格式良好,已经通过我们之前的数据科学家和机器学习研究人员的辛勤工作进行了清理,以至于我们可以专注于建模,而不用太担心如何摄取数据以及数据是否正确。这对本章中使用的 MNIST 和音频数据集是真实的;对于我们在第三章中使用的网络钓和鸢尾花数据集也是如此。
我们可以肯定地说,这永远不是您将遇到的真实世界机器学习问题的情况。事实上,大多数机器学习从业者的时间都花在获取、预处理、清理、验证和格式化数据上。在下一章中,我们将教您在 TensorFlow.js 中可用的工具,使这些数据整理和摄取工作流更容易。
¹³
Gil Press,“清理大数据:调查显示,最耗时、最不受欢迎的数据科学任务”,《福布斯》,2016 年 3 月 23 日,
mng.bz/9wqj。
练习
-
当我们在第 5.1.1 节中访问 mnist-transfer-cnn 示例时,我们指出设置模型层的
trainable属性在训练期间不会生效,除非在训练之前调用模型的compile()方法。通过对示例的 index.js 文件中的retrainModel()方法进行一些更改来验证这一点。具体来说,-
在带有
this.model .compile()行之前添加一个this.model.summary()调用,并观察可训练和不可训练参数的数量。它们显示了什么?它们与在compile()调用之后获得的数字有何不同? -
独立于前一项,将
this.model.compile()调用移到特征层的trainable属性设置之前。换句话说,在compile()调用之后设置这些层的属性。这样做会如何改变训练速度?速度是否与仅更新模型的最后几层一致?你能找到其他确认,在这种情况下,模型的前几层的权重在训练期间是否被更新的方法吗?
-
-
在第 5.1.1 节的迁移学习中(列表 5.1),我们通过将其
trainable属性设置为false来冻结了前两个 conv2d 层,然后开始了fit()调用。你能在 mnist-transfer-cnn 示例的 index.js 中添加一些代码来验证 conv2d 层的权重确实没有被fit()调用改变吗?我们在同一节中尝试的另一种方法是在不冻结层的情况下调用fit()。你能验证在这种情况下层的权重值确实被fit()调用改变了吗?(提示:回想一下在第 2.4.2 节的第二章中,我们使用了模型对象的layers属性和其getWeights()方法来访问权重的值。) -
将 Keras MobileNetV2^([14])(不是 MobileNetV1!—我们已经完成了)应用转换为 TensorFlow.js 格式,并将其加载到浏览器的 TensorFlow.js 中。详细步骤请参阅信息框 5.1。你能使用
summary()方法来检查 MobileNetV2 的拓扑结构,并确定其与 MobileNetV1 的主要区别吗?¹⁴
Mark Sandler 等人,“MobileNetV2: Inverted Residuals and Linear Bottlenecks”,2019 年 3 月 21 日修订,
arxiv.org/abs/1801.04381。 -
列表 5.8 中微调代码的一个重要方面是在基础模型中解冻密集层后再次调用
compile()方法。你能做到以下几点吗?-
使用与练习 2 相同的方法来验证密集层的权重(核心和偏置)确实没有被第一次
fit()调用(用于迁移学习的初始阶段)所改变,并且确实被第二次fit()调用(用于微调阶段)所改变。 -
尝试在解冻行之后(更改 trainable 属性值的行)注释掉
compile()调用,看看这如何影响你刚刚观察到的权重值变化。确信compile()调用确实是让模型的冻结/解冻状态变化生效的必要步骤。 -
更改代码,尝试解冻基础 speech-command 模型的更多承载权重的层(例如,倒数第二个密集层之前的 conv2d 层),看看这对微调的结果有何影响。
-
-
在我们为简单对象检测任务定义的自定义损失函数中,我们对 0-1 形状标签进行了缩放,以便来自形状预测的误差信号与来自边界框预测的误差信号匹配(参见 listing 5.10 中的代码)。试验一下,如果在代码中删除
mul()调用会发生什么。说服自己这种缩放对于确保相当准确的形状预测是必要的。这也可以通过在compile()调用中简单地将customLossFunction的实例替换为meanSquaredError来完成(参见 listing 5.11)。还要注意,训练期间移除缩放需要伴随着对推断时的阈值调整:将推断逻辑中的阈值从CANVAS_SIZE/2更改为1/2(在 simple-object-detection/index.js 中)。 -
在简单对象检测示例中的微调阶段,涉及到解冻截断的 MobileNet 基础的前九个顶层(参见 listing 5.9 中的
fineTuningLayers如何填充)。一个自然的问题是,为什么是九个?在这个练习中,通过在fineTuningLayers数组中包含更少或更多的层来改变解冻层的数量。当你在微调期间解冻更少的层时,你期望在以下情况下会看到什么:1) 最终损失值和 2) 每个纪元在微调阶段花费的时间?实验结果是否符合你的期望?解冻更多的层在微调期间会怎样?
摘要
-
迁移学习是在与模型最初训练的任务相关但不同的学习任务上重新使用预训练模型或其一部分的过程。这种重用加快了新的学习任务的速度。
-
在迁移学习的实际应用中,人们经常会重用已经在非常大的分类数据集上训练过的卷积网络,比如在 ImageNet 数据集上训练过的 MobileNet。由于原始数据集的规模庞大以及包含的示例的多样性,这些预训练模型带来了强大的、通用的特征提取器卷积层,适用于各种计算机视觉问题。这样的层对于在典型的迁移学习问题中可用的少量数据来说很难,如果不是不可能的话,进行训练。
-
我们在 TensorFlow.js 中讨论了几种通用的迁移学习方法,它们在以下方面有所不同:1) 新层是否被创建为迁移学习的“新头部”,以及 2) 是否使用一个模型实例或两个模型实例进行迁移学习。每种方法都有其优缺点,并适用于不同的用例(参见 table 5.1)。
-
通过设置模型层的
trainable属性,我们可以防止在训练(Model.fit()函数调用)期间更新其权重。这被称为冻结,并用于在迁移学习中“保护”基础模型的特征提取层。 -
在一些迁移学习问题中,我们可以在初始训练阶段之后解冻一些基础模型的顶层,从而提升新模型的性能。这反映了解冻层对新数据集中独特特征的适应性。
-
迁移学习是一种多用途、灵活的技术。基础模型可以帮助我们解决与其初始训练内容不同的问题。我们通过展示如何基于 MobileNet 训练目标检测模型来阐明这一点。
-
TensorFlow.js 中的损失函数可以定义为在张量输入和输出上操作的自定义 JavaScript 函数。正如我们在简单目标检测示例中展示的,为了解决实际的机器学习问题,通常需要自定义损失函数。
第三部分:TensorFlow.js 的高级深度学习。
阅读完第一部分和第二部分,您现在应该熟悉在 TensorFlow.js 中进行基本深度学习的方法。第三部分旨在为想要更牢固掌握技术和对深度学习有更广泛理解的用户提供帮助。第六章介绍了如何处理、转换和使用机器学习数据的技巧。第七章介绍了用于可视化数据和模型的工具。第八章关注欠拟合和过拟合等重要现象以及如何有效应对。在这个讨论的基础上,我们介绍了机器学习的通用工作流程。第九章至第十一章是三个高级领域的实践之旅:序列导向模型、生成模型和强化学习。它们将使您熟悉深度学习最激动人心的前沿技术。
第六章:处理数据
本章涵盖内容
-
如何使用
tf.dataAPI 来使用大型数据集训练模型 -
探索您的数据以查找和解决潜在问题
-
如何使用数据增强来创建新的“伪样本”以提高模型质量
大量高质量数据的广泛可用是导致当今机器学习革命的主要因素。如果没有轻松获取大量高质量数据,机器学习的急剧上升就不会发生。现在数据集可以在互联网上随处可得——在 Kaggle 和 OpenML 等网站上免费共享——同样可以找到最先进性能的基准。整个机器学习领域都是通过可用的“挑战”数据集前进的,这些数据集设定了一个标准和一个共同的基准用于社区。如果说机器学习是我们这一代的太空竞赛,那么数据显然就是我们的火箭燃料;它是强大的,它是有价值的,它是不稳定的,它对于一个正常工作的机器学习系统绝对至关重要。更不用说污染的数据,就像污染的燃料一样,很快就会导致系统性失败。这一章是关于数据的。我们将介绍组织数据的最佳实践,如何检测和清除问题,以及如何高效使用数据。
¹
看看 ImageNet 如何推动目标识别领域,或者 Netflix 挑战对协同过滤做出了什么贡献。
²
感谢 Edd Dumbill 将这个类比归功于“大数据是火箭燃料”,大数据,卷 1,号 2,第 71-72 页。
“但我们不是一直在处理数据吗?”你可能会抗议。没错,在之前的章节中,我们处理过各种数据源。我们使用合成和网络摄像头图像数据集来训练图像模型。我们使用迁移学习从音频样本数据集构建了一个口语识别器,并访问了表格数据集以预测价格。那么还有什么需要讨论的呢?我们不是已经能够熟练处理数据了吗?
在我们之前的例子中回顾我们对数据使用的模式。我们通常需要首先从远程源下载数据。然后我们(通常)对数据应用一些转换,将数据转换为正确的格式,例如将字符串转换为独热词汇向量,或者规范化表格源的均值和方差。然后我们总是需要对数据进行分批处理,并将其转换为表示为张量的标准数字块,然后再连接到我们的模型。这一切都是在我们运行第一个训练步骤之前完成的。
下载 - 转换 - 批处理的模式非常常见,TensorFlow.js 提供了工具,使这些类型的操作更加简单、模块化和不容易出错。本章将介绍 tf.data 命名空间中的工具,最重要的是 tf.data.Dataset,它可以用于惰性地流式传输数据。惰性流式传输的方法允许按需下载、转换和访问数据,而不是将数据源完全下载并在访问时将其保存在内存中。惰性流式传输使得更容易处理数据源,这些数据源太大,无法放入单个浏览器标签页甚至单台机器的 RAM 中。
我们首先介绍 tf.data.Dataset API,并演示如何配置它并与模型连接起来。然后,我们将介绍一些理论和工具,帮助你审查和探索数据,并解决可能发现的问题。本章还介绍了数据增强,这是一种通过创建合成伪示例来扩展数据集,提高模型质量的方法。
6.1. 使用 tf.data 管理数据
如果你的电子邮件数据库有数百 GB 的容量,并且需要特殊凭据才能访问,你该如何训练垃圾邮件过滤器?如果训练图像数据库的规模太大,无法放入单台机器上,你该如何构建图像分类器?
访问和操作大量数据是机器学习工程师的关键技能,但到目前为止,我们所处理的应用程序都可以在可用内存中容纳得下数据。许多应用程序需要处理大型、笨重且可能涉及隐私的数据源,此技术就不适用了。大型应用程序需要一种技术,能够按需逐块从远程数据源中访问数据。
TensorFlow.js 附带了一个集成库,专门用于这种类型的数据管理。它的构建目标是以简洁易读的方式,让用户能够快速地摄取、预处理和路由数据,灵感来自于 TensorFlow Python 版本中的 tf.data API。假设你的代码使用类似于 import 语句来导入 TensorFlow.js,像这样:
import * as tf from '@tensorflow/tfjs';
这个功能在 tf.data 命名空间下可用。
6.1.1. tf.data.Dataset 对象
与tfjs-data的大多数交互都通过一种称为Dataset的单一对象类型进行。tf.data.Dataset对象提供了一种简单、可配置和高性能的方式来迭代和处理大型(可能是无限的)数据元素列表^([3])。在最粗略的抽象中,你可以将数据集想象成任意元素的可迭代集合,不太不同于 Node.js 中的Stream。每当需要从数据集中请求下一个元素时,内部实现将根据需要下载、访问或执行函数来创建它。这种抽象使得模型能够在内存中一次性存储的数据量比可以想象的要多。它还使得在有多个要跟踪的数据集时,将数据集作为一流对象进行共享和组织变得更加方便。Dataset通过仅流式传输所需的数据位而不是一次性访问整个数据来提供内存优势。Dataset API 还通过预取即将需要的值来提供性能优化。
³
在本章中,我们将经常使用术语元素来指代
Dataset中的项。在大多数情况下,元素与示例或数据点是同义词——也就是说,在训练数据集中,每个元素都是一个(x, y)对。当从 CSV 源中读取数据时,每个元素都是文件的一行。Dataset足够灵活,可以处理异构类型的元素,但不建议这样做。
6.1.2. 创建tf.data.Dataset
截至 TensorFlow.js 版本 1.2.7,有三种方法可以将tf.data.Dataset连接到某个数据提供程序。我们将对每种方法进行详细介绍,但 table 6.1 中包含了简要摘要。
表 6.1. 从数据源创建一个tf.data.Dataset对象
| 如何获得新的 tf.data.Dataset | API | 如何使用它构建数据集 |
|---|---|---|
| 从 JavaScript 数组中获取元素;也适用于像 Float32Array 这样的类型化数组 | tf.data.array(items) | const dataset = tf.data.array([1,2,3,4,5]); 有关更多信息,请参见 listing 6.1。 |
| 从(可能是远程)CSV 文件中获取,其中每一行都是一个元素 | tf.data.csv( source,
csvConfig) | const dataset = tf.data.csv("https://path/to/my.csv"); 有关更多信息,请参见 listing 6.2。唯一必需的参数是从中读取数据的 URL。此外,csvConfig 接受一个带有键的对象来帮助指导 CSV 文件的解析。例如,
-
columnNames—可以提供一个字符串数组来手动设置列的名称,如果它们在标题中不存在或需要被覆盖。
-
delimiter—可以使用单字符字符串来覆盖默认的逗号分隔符。
-
columnConfigs—可以提供一个从字符串列名到 columnConfig 对象的映射,以指导数据集的解析和返回类型。columnConfig 将通知解析器元素的类型(字符串或整数),或者如果列应被视为数据集标签。
-
configuredColumnsOnly—是否仅返回 CSV 中包含的列或仅返回列配置对象中包含的列的数据。
更多详细信息请查阅js.tensorflow.org上的 API 文档。 |
| 从生成元素的通用生成函数 | tf.data.generator(generatorFunction) | function* countDownFrom10() { for (let i=10; i>0; i--) {
yield(i);
}
}
const dataset =
tf.data.generator(countDownFrom10); 详见清单 6.3。请注意,在没有参数的情况下调用 tf.data.generator()时传递给 tf.data.generator()的参数将返回一个 Generator 对象。 |
从数组创建 tf.data.Dataset
创建新的tf.data.Dataset最简单的方法是从一个 JavaScript 数组中构建。假设已经存在一个内存中的数组,您可以使用tf.data.array()函数创建一个由该数组支持的数据集。当然,这不会比直接使用数组带来任何训练速度或内存使用上的好处,但通过数据集访问数组提供了其他重要的好处。例如,使用数据集更容易设置预处理,并通过简单的model.fitDataset()和model.evaluateDataset()API 使我们的训练和评估更加简单,就像我们将在第 6.2 节中看到的那样。与model.fit(x, y)相比,model.fitDataset(myDataset)不会立即将所有数据移入 GPU 内存,这意味着可以处理比 GPU 能够容纳的更大的数据集。请注意,V8 JavaScript 引擎的内存限制(64 位系统上为 1.4 GB)通常比 TensorFlow.js 一次可以在 WebGL 内存中容纳的内存要大。使用tf.data API 也是良好的软件工程实践,因为它使得以模块化的方式轻松地切换到另一种类型的数据而无需改变太多代码。如果没有数据集抽象,很容易让数据集源的实现细节泄漏到模型训练中的使用中,这种纠缠将需要在使用不同实现时解开。
要从现有数组构建数据集,请使用tf.data.array(itemsAsArray),如下面的示例所示。
清单 6.1. 从数组构建tf.data.Dataset
const myArray = [{xs: [1, 0, 9], ys: 10},
{xs: [5, 1, 3], ys: 11},
{xs: [1, 1, 9], ys: 12}];
const myFirstDataset = tf.data.array(myArray); ***1***
await myFirstDataset.forEachAsync(
e => console.log(e)); ***2***
// Yields output like
// {xs: Array(3), ys: 10}
// {xs: Array(3), ys: 11}
// {xs: Array(3), ys: 12}
-
1 创建由数组支持的 tfjs-data 数据集。请注意,这不会克隆数组或其元素。
-
2 使用
forEachAsync()方法迭代数据集提供的所有值。请注意,forEachAsync()是一个异步函数,因此应该在其前面使用 await。
我们使用forEachAsync()函数迭代数据集的元素,该函数依次生成每个元素。有关Dataset.forEachAsync函数的更多详细信息,请参见第 6.1.3 节。
数据集的元素可能包含 JavaScript 基元^([4])(如数字和字符串),以及元组、数组和这些结构的嵌套对象,除了张量。在这个小例子中,数据集的三个元素都具有相同的结构。它们都是具有相同键和相同类型值的对象。tf.data.Dataset通常支持各种类型的元素,但常见的用例是数据集元素是具有相同类型的有意义的语义单位。通常,它们应该表示同一类型的示例。因此,除非在非常不寻常的用例中,每个元素都应具有相同的类型和结构。
⁴
如果您熟悉 Python TensorFlow 中
tf.data的实现,您可能会对tf.data.Dataset可以包含 JavaScript 基元以及张量感到惊讶。
从 CSV 文件创建 tf.data.Dataset
一种非常常见的数据集元素类型是表示表的一行的键值对象,例如 CSV 文件的一行。下一个列表显示了一个非常简单的程序,该程序将连接并列出波士顿房屋数据集,我们首先在 chapter 2 中使用过的数据集。
列表 6.2. 从 CSV 文件构建 tf.data.Dataset
const myURL =
"https://storage.googleapis.com/tfjs-examples/" +
"multivariate-linear-regression/data/train-data.csv";
const myCSVDataset = tf.data.csv(myURL); ***1***
await myCSVDataset.forEachAsync(e => console.log(e)); ***2***
// Yields output of 333 rows like
// {crim: 0.327, zn: 0, indus: 2.18, chas: 0, nox: 0.458, rm: 6.998,
// age: 45.8, tax: 222}
// ...
-
1 创建由远程 CSV 文件支持的 tfjs-data 数据集
-
2 使用 forEachAsync() 方法在数据集提供的所有值上进行迭代。请注意,forEachAsync() 是一个异步函数。
这里我们使用 tf.data.csv() 而不是 tf.data.array(),并指向 CSV 文件的 URL。这将创建一个由 CSV 文件支持的数据集,并且在数据集上进行迭代将遍历 CSV 行。在 Node.js 中,我们可以通过使用以 file:// 为前缀的 URL 句柄连接到本地 CSV 文件,如下所示:
> const data = tf.data.csv(
'file://./relative/fs/path/to/boston-housing-train.csv');
在迭代时,我们看到每个 CSV 行都被转换为 JavaScript 对象。从数据集返回的元素是具有 CSV 的每列的一个属性的对象,并且属性根据 CSV 文件中的列名命名。这对于与元素交互非常方便,因为不再需要记住字段的顺序。Section 6.3.1 将详细描述如何处理 CSV 并通过一个例子进行说明。
从生成器函数创建 tf.data.Dataset
创建tf.data.Dataset的第三种最灵活的方式是使用生成器函数构建。这是使用tf.data.generator()方法完成的。tf.data.generator()接受一个 JavaScript 生成器函数(或function*)^([5])作为参数。如果您对生成器函数不熟悉,它们是相对较新的 JavaScript 功能,您可能希望花一点时间阅读它们的文档。生成器函数的目的是在需要时“产出”一系列值,可以是永远或直到序列用尽为止。从生成器函数产生的值将流经并成为数据集的值。一个非常简单的生成器函数可以产生随机数,或者从连接的硬件中提取数据的快照。一个复杂的生成器函数可以与视频游戏集成,产生屏幕截图、得分和控制输入输出。在下面的示例中,非常简单的生成器函数产生骰子掷得的样本。
⁵
了解有关 ECMAscript 生成器函数的更多信息,请访问
mng.bz/Q0rj。
清单 6.3。构建用于随机掷骰子的tf.data.Dataset
let numPlaysSoFar = 0; ***1***
function rollTwoDice() {
numPlaysSoFar++;
return [Math.ceil(Math.random() * 6), Math.ceil(Math.random() * 6)];
}
function* rollTwoDiceGeneratorFn() { ***2***
while(true) { ***2***
yield rollTwoDice(); ***2***
}
}
const myGeneratorDataset = tf.data.generator( ***3***
rollTwoDiceGeneratorFn); ***3***
await myGeneratorDataset.take(1).forEachAsync( ***4***
e => console.log(e)); ***4***
// Prints to the console a value like
// [4, 2]
-
1 numPlaysSoFar 被 rollTwoDice()闭合,这使我们可以计算数据集执行该函数的次数。
-
2定义了一个生成器函数(使用 function*语法),可以无限次调用 rollTwoDice()并产生结果。
-
3数据集在此处创建。
-
4获取数据集中仅一个元素的样本。
take()方法将在第 6.1.4 节中描述。
关于在清单 6.3 中创建的游戏模拟数据集,有一些有趣的要点。首先,请注意,这里创建的数据集myGeneratorDataset是无限的。由于生成器函数永远不会返回,我们可以从数据集中无限次取样。如果我们在此数据集上执行forEachAsync()或toArray()(参见第 6.1.3 节),它将永远不会结束,并且可能会使我们的服务器或浏览器崩溃,所以要小心。为了使用这样的对象,我们需要创建一些其他数据集,它是无限数据集的有限样本,使用take(n)。稍后会详细介绍。
其次,请注意,数据集会关闭局部变量。这对于记录和调试,以确定生成器函数执行了多少次非常有帮助。
此外,请注意数据直到被请求才存在。在这种情况下,我们只能访问数据集的一项样本,并且这会反映在numPlaysSoFar的值中。
生成器数据集非常强大且非常灵活,允许开发人员将模型连接到各种提供数据的 API,例如来自数据库查询、通过网络逐段下载的数据,或者来自一些连接的硬件。有关tf.data.generator() API 的更多详细信息请参见信息框 6.1。
tf.data.generator()参数规范
tf.data.generator()API 是灵活且强大的,允许用户将模型连接到许多类型的数据提供者。传递给tf.data.generator()的参数必须符合以下规范:
-
它必须可调用且不带参数。
-
在不带参数的情况下调用时,它必须返回符合迭代器和可迭代协议的对象。这意味着返回的对象必须具有一个
next()方法。当调用next()而没有参数时,它应该返回 JavaScript 对象{value: ELEMENT, done: false},以便将值ELEMENT传递给下一步。当没有更多值可返回时,它应该返回{value: undefined, done: true}。
JavaScript 的生成器函数返回Generator对象,符合此规范,因此是使用tf.data.generator()的最简单方法。该函数可能闭包于局部变量,访问本地硬件,连接到网络资源等。
表 6.1 包含以下代码,说明如何使用tf.data.generator():
function* countDownFrom10() {
for (let i = 10; i > 0; i--) {
yield(i);
}
}
const dataset = tf.data.generator(countDownFrom10);
如果出于某种原因希望避免使用生成器函数,而更愿意直接实现可迭代协议,还可以以以下等效方式编写前面的代码:
function countDownFrom10Func() {
let i = 10;
return {
next: () => {
if (i > 0) {
return {value: i--, done: false};
} else {
return {done: true};
}
}
}
}
const dataset = tf.data.generator(countDownFrom10Func);
6.1.3. 访问数据集中的数据
一旦将数据作为数据集,您必然会想要访问其中的数据。创建但从不读取数据结构实际上并不实用。有两种 API 可以访问数据集中的数据,但tf.data用户应该只需要偶尔使用这些 API。更典型的情况是,高级 API 将为您访问数据集中的数据。例如,在训练模型时,我们使用model.fitDataset()API,如第 6.2 节所述,它会为我们访问数据集中的数据,而我们,用户,从不需要直接访问数据。然而,在调试、测试和理解Dataset对象工作原理时,了解如何查看内容很重要。
从数据集中访问数据的第一种方法是使用Dataset.toArray()将其全部流出到数组中。这个函数的作用正是它的名字听起来的样子。它遍历整个数据集,将所有元素推入数组中,并将该数组返回给用户。用户在执行此函数时应小心,以免无意中生成一个对 JavaScript 运行时来说太大的数组。如果,例如,数据集连接到一个大型远程数据源或是从传感器读取的无限数据集,则很容易犯这个错误。
从数据集中访问数据的第二种方法是使用dataset.forEachAsync(f)在数据集的每个示例上执行函数。提供给forEachAsync()的参数将逐个应用于每个元素,类似于 JavaScript 数组和集合中的forEach()构造——即本地的Array.forEach()和Set.forEach()。
需要注意的是,Dataset.forEachAsync() 和 Dataset.toArray() 都是异步函数。这与 Array.forEach() 相反,后者是同步的,因此在这里可能很容易犯错。Dataset.toArray() 返回一个 Promise,并且通常需要使用 await 或 .then() 来获取同步行为。要小心,如果忘记使用 await,则 Promise 可能不会按照您期望的顺序解析,从而导致错误。一个典型的错误是数据集看起来为空,因为在 Promise 解析之前已经迭代了其内容。
Dataset.forEachAsync() 是异步的而 Array.forEach() 不是的原因是,数据集正在访问的数据通常需要创建、计算或从远程源获取。异步性使我们能够在等待期间有效地利用可用的计算资源。这些方法在 table 6.2 中进行了总结。
表 6.2. 迭代数据集的方法
tf.data.Dataset 对象的实例方法 |
功能 | 示例 |
|---|
| .toArray() | 异步地迭代整个数据集,并将每个元素推送到一个数组中,然后返回该数组 | const a = tf.data.array([1, 2, 3, 4, 5, 6]); const arr = await a.toArray();
console.log(arr);
// 1,2,3,4,5,6 |
| .forEachAsync(f) | 异步地迭代数据集的所有元素,并对每个元素执行 f | const a = tf.data.array([1, 2, 3]); await a.forEachAsync(e => console.log("hi " + e));
// hi 1
// hi 2
// hi 3 |
6.1.4. 操作 tfjs-data 数据集
当我们可以直接使用数据而不需要任何清理或处理时,这当然是非常好的。但在作者的经验中,除了用于教育或基准测试目的构建的示例外,这几乎从未发生。在更常见的情况下,数据必须在某种程度上进行转换,然后才能进行分析或用于机器学习任务。例如,源代码通常包含必须进行过滤的额外元素;或者需要解析、反序列化或重命名某些键的数据;或者数据已按排序顺序存储,因此在使用它来训练或评估模型之前,需要对其进行随机洗牌。也许数据集必须分割成用于训练和测试的非重叠集。预处理几乎是不可避免的。如果你遇到了一个干净且可直接使用的数据集,很有可能是有人已经为你清理和预处理了!
tf.data.Dataset 提供了可链式 API 的方法来执行这些操作,如表 6.3 中所述。每一个这些方法都返回一个新的 Dataset 对象,但不要被误导以为所有数据集元素都被复制或每个方法调用都迭代所有数据集元素!tf.data.Dataset API 只会懒惰地加载和转换元素。通过将这些方法串联在一起创建的数据集可以看作是一个小程序,它只会在从链的末端请求元素时执行。只有在这个时候,Dataset 实例才会爬回操作链,可能一直爬回到请求来自远程源的数据。
表 6.3. tf.data.Dataset 对象上的可链式方法
| tf.data.Dataset 对象的实例方法 | 它的作用 | 示例 |
|---|---|---|
| .filter(谓词) | 返回一个仅包含谓词为真的元素的数据集 | myDataset.filter(x => x < 10); 返回一个数据集,仅包含 myDataset 中小于 10 的值。 |
| .map(转换) | 将提供的函数应用于数据集中的每个元素,并返回一个新数据集,其中包含映射后的元素 | myDataset.map(x => x * x); 返回一个数据集,包含原始数据集的平方值。 |
| .mapAsync(异步转换) | 类似 map,但提供的函数必须是异步的 | myDataset.mapAsync(fetchAsync); 假设 fetchAsync 是一个异步函数,可以从提供的 URL 获取数据,将返回一个包含每个 URL 数据的新数据集。 |
| .batch(批次大小,
smallLastBatch?) | 将连续的元素跨度捆绑成单一元素组,并将原始元素转换为张量 | const a = tf.data.array([1, 2, 3, 4, 5, 6, 7, 8])
.batch(4);
await a.forEach(e => e.print());
// 输出:
// 张量 [1, 2, 3, 4]
// 张量 [5, 6, 7, 8] |
| .concatenate(数据集) | 将两个数据集的元素连接在一起形成一个新的数据集 | myDataset1.concatenate(myDataset2) 返回一个数据集,首先迭代 myDataset1 中的所有值,然后迭代 myDataset2 中的所有值。 |
|---|---|---|
| .repeat(次数) | 返回一个将多次(可能无限次)迭代原始数据集的 dataset | myDataset.repeat(NUM_EPOCHS) 返回一个 dataset,将迭代 myDataset 中所有的值 NUM_EPOCHS 次。如果 NUM_EPOCHS 为负数或未定义,则结果将无限次迭代。 |
| .take(数量) | 返回一个仅包含前数量个示例的数据集 | myDataset.take(10); 返回一个仅包含 myDataset 的前 10 个元素的数据集。如果 myDataset 中的元素少于 10 个,则没有变化。 |
| .skip(count) | 返回一个跳过前 count 个示例的数据集 | myDataset.skip(10); 返回一个包含 myDataset 中除了前 10 个元素之外所有元素的数据集。如果 myDataset 包含 10 个或更少的元素,则返回一个空数据集。 |
| .shuffle( bufferSize,
种子?
) | 生成原始数据集元素的随机洗牌数据集 注意:此洗牌是通过在大小为 bufferSize 的窗口内随机选择来完成的;因此,超出窗口大小的排序被保留。| const a = tf.data.array( [1, 2, 3, 4, 5, 6]).shuffle(3);
await a.forEach(e => console.log(e));
// 输出,例如,2, 4, 1, 3, 6, 5 以随机洗牌顺序输出 1 到 6 的值。洗牌是部分的,因为不是所有的顺序都是可能的,因为窗口比总数据量小。例如,最后一个元素 6 现在成为新顺序中的第一个元素是不可能的,因为 6 需要向后移动超过 bufferSize(3)个空间。|
这些操作可以链接在一起创建简单但强大的处理管道。例如,要将数据集随机分割为训练和测试数据集,可以按照以下列表中的步骤操作(参见 tfjs-examples/iris-fitDataset/data.js)。
列表 6.4. 使用 tf.data.Dataset 创建训练/测试分割
const seed = Math.floor(
Math.random() * 10000); ***1***
const trainData = tf.data.array(IRIS_RAW_DATA)
.shuffle(IRIS_RAW_DATA.length, seed); ***1***
.take(N); ***2***
.map(preprocessFn);
const testData = tf.data.array(IRIS_RAW_DATA)
.shuffle(IRIS_RAW_DATA.length, seed); ***1***
.skip(N); ***3***
.map(preprocessFn);
-
1 我们在训练和测试数据中使用相同的洗牌种子;否则它们将独立洗牌,并且一些样本将同时出现在训练和测试中。
-
2 获取训练数据的前 N 个样本
-
3 跳过测试数据的前 N 个样本
在这个列表中有一些重要的考虑事项需要注意。我们希望将样本随机分配到训练和测试集中,因此我们首先对数据进行洗牌。我们取前 N 个样本作为训练数据。对于测试数据,我们跳过这些样本,取剩下的样本。当我们取样本时,数据以 相同的方式 进行洗牌非常重要,这样我们就不会在两个集合中都有相同的示例;因此当同时采样两个管道时,我们使用相同的随机种子。
还要注意,我们在跳过操作之后应用 map() 函数。也可以在跳过之前调用 .map(preprocessFn),但是那样 preprocessFn 就会对我们丢弃的示例执行——这是一种计算浪费。可以使用以下列表来验证这种行为。
列表 6.5. 说明 Dataset.forEach skip() 和 map() 交互
let count = 0;
// Identity function which also increments count.
function identityFn(x) {
count += 1;
return x;
}
console.log('skip before map');
await tf.data.array([1, 2, 3, 4, 5, 6])
.skip(6) ***1***
.map(identityFn)
.forEachAsync(x => undefined);
console.log(`count is ${count}`);
console.log('map before skip');
await tf.data.array([1, 2, 3, 4, 5, 6])
.map(identityFn) ***2***
.skip(6)
.forEachAsync(x => undefined);
console.log(`count is ${count}`);
// Prints:
// skip before map
// count is 0
// map before skip
// count is 6
-
1 先跳过再映射
-
2 先映射再跳过
dataset.map() 的另一个常见用法是对输入数据进行归一化。我们可以想象一种情况,我们希望将输入归一化为零均值,但我们有无限数量的输入样本。为了减去均值,我们需要先计算分布的均值,但是计算无限集合的均值是不可行的。我们还可以考虑取一个代表性的样本,并计算该样本的均值,但如果我们不知道正确的样本大小,就可能犯错误。考虑一个分布,几乎所有的值都是 0,但每一千万个示例的值为 1e9。这个分布的均值是 100,但如果你在前 100 万个示例上计算均值,你就会相当偏离。
我们可以使用数据集 API 进行流式归一化,方法如下(示例 6.6)。在此示例中,我们将跟踪我们已经看到的样本数量以及这些样本的总和。通过这种方式,我们可以进行流式归一化。此示例操作标量(不是张量),但是针对张量设计的版本具有类似的结构。
示例 6.6. 使用 tf.data.map() 进行流式归一化
function newStreamingZeroMeanFn() { ***1***
let samplesSoFar = 0;
let sumSoFar = 0;
return (x) => {
samplesSoFar += 1;
sumSoFar += x;
const estimatedMean = sumSoFar / samplesSoFar;
return x - estimatedMean;
}
}
const normalizedDataset1 =
unNormalizedDataset1.map(newStreamingZeroMeanFn());
const normalizedDataset2 =
unNormalizedDataset2.map(newStreamingZeroMeanFn());
- 1 返回一个一元函数,它将返回输入减去迄今为止其所有输入的均值。
请注意,我们生成了一个新的映射函数,它在自己的样本计数器和累加器上关闭。这是为了允许多个数据集独立归一化。否则,两个数据集将使用相同的变量来计数调用和求和。这种解决方案并不是没有限制,特别是在 sumSoFar 或 samplesSoFar 中可能发生数值溢出的情况下,需要谨慎处理。
6.2. 使用 model.fitDataset 训练模型
流式数据集 API 不错,我们已经看到它可以让我们进行一些优雅的数据操作,但是 tf.data API 的主要目的是简化将数据连接到模型进行训练和评估的过程。那么 tf.data 如何帮助我们呢?
自第二章以来,每当我们想要训练一个模型时,我们都会使用 model.fit() API。回想一下,model.fit()至少需要两个必要参数 - xs 和 ys。作为提醒,xs 变量必须是一个表示一系列输入示例的张量。ys 变量必须绑定到表示相应输出目标集合的张量。例如,在上一章的 示例 5.11 中,我们使用类似的调用对我们的合成目标检测模型进行训练和微调。
model.fit(images, targets, modelFitArgs)
其中images默认情况下是一个形状为[2000, 224, 224, 3]的秩为 4 的张量,表示一组 2000 张图像。modelFitArgs配置对象指定了优化器的批处理大小,默认为 128。回顾一下,我们可以看到 TensorFlow.js 被提供了一个内存中^([6])的包含 2000 个示例的集合,表示整个数据集,然后循环遍历该数据,每次以 128 个示例为一批完成每个时期的训练。
⁶
在GPU内存中,通常比系统 RAM 有限!
如果这些数据不足,我们想要用更大的数据集进行训练怎么办?在这种情况下,我们面临着一对不太理想的选择。选项 1 是加载一个更大的数组,然后看看它是否起作用。然而,到某个时候,TensorFlow.js 会耗尽内存,并发出一条有用的错误消息,指示它无法为训练数据分配存储空间。选项 2 是我们将数据上传到 GPU 中的不同块中,并在每个块上调用model.fit()。我们需要执行自己的model.fit()协调,每当准备好时,就对我们的训练数据的部分进行迭代训练我们的模型。如果我们想要执行多个时期,我们需要回到一开始,以某种(可能是打乱的)顺序重新下载我们的块。这种协调不仅繁琐且容易出错,而且还干扰了 TensorFlow 自己的时期计数器和报告的指标,我们将被迫自己将它们拼接在一起。
Tensorflow.js 为我们提供了一个更方便的工具,使用model.fitDataset()API:
model.fitDataset(dataset, modelFitDatasetArgs)
model.fitDataset() 的第一个参数接受一个数据集,但数据集必须符合特定的模式才能工作。具体来说,数据集必须产生具有两个属性的对象。第一个属性名为xs,其值为Tensor类型,表示一批示例的特征;这类似于model.fit()中的xs参数,但数据集一次产生一个批次的元素,而不是一次产生整个数组。第二个必需的属性名为ys,包含相应的目标张量。^([7]) 与model.fit()相比,model.fitDataset()提供了许多优点。首先,我们不需要编写代码来管理和协调数据集的下载——这在需要时以一种高效、按需流式处理的方式为我们处理。内置的缓存结构允许预取预期需要的数据,有效利用我们的计算资源。这个 API 调用也更强大,允许我们在比 GPU 容量更大得多的数据集上进行训练。事实上,我们可以训练的数据集大小现在只受到我们拥有多少时间的限制,因为只要我们能够获得新的训练样本,我们就可以继续训练。这种行为在 tfjs-examples 存储库中的数据生成器示例中有所体现。
⁷
对于具有多个输入的模型,期望的是张量数组而不是单独的特征张量。对于拟合多个目标的模型,模式类似。
在此示例中,我们将训练一个模型来学习如何估计获胜的可能性,一个简单的游戏机会。和往常一样,您可以使用以下命令来查看和运行演示:
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/data-generator
yarn
yarn watch
此处使用的游戏是一个简化的纸牌游戏,有点像扑克牌。每个玩家都会被分发 N 张牌,其中 N 是一个正整数,并且每张牌由 1 到 13 之间的随机整数表示。游戏规则如下:
-
拥有相同数值牌最多的玩家获胜。例如,如果玩家 1 有三张相同的牌,而玩家 2 只有一对,玩家 1 获胜。
-
如果两名玩家拥有相同大小的最大组,则拥有最大面值组的玩家获胜。例如,一对 5 比一对 4 更大。
-
如果两名玩家甚至都没有一对牌,那么拥有最高单张牌的玩家获胜。
-
平局将随机解决,50/50。
要说服自己,每个玩家都有平等的获胜机会应该很容易。因此,如果我们对自己的牌一无所知,我们应该只能猜测我们是否会赢得比赛的时间一半。我们将构建和训练一个模型,该模型以玩家 1 的牌作为输入,并预测该玩家是否会获胜。在图 6.1 的屏幕截图中,您应该看到我们在大约 250,000 个示例(50 个周期 * 每周期 50 个批次 * 每个批次 100 个样本)训练后,在这个问题上达到了约 75% 的准确率。在这个模拟中,每手使用五张牌,但对于其他数量也可以达到类似的准确率。通过使用更大的批次和更多的周期,可以实现更高的准确率,但是即使在 75% 的情况下,我们的智能玩家也比天真的玩家在估计他们将获胜的可能性时具有显著的优势。
图 6.1. 数据生成器示例的用户界面。游戏规则的描述和运行模拟的按钮位于左上方。在此下方是生成的特征和数据管道。Dataset-to-Array 按钮运行链接数据集操作,模拟游戏,生成特征,将样本批次化,取 N 个这样的批次,将它们转换为数组,并将数组打印出来。右上角有用于使用此数据管道训练模型的功能。当用户点击 Train-Model-Using-Fit-Dataset 按钮时,model.fitDataset() 操作接管并从管道中提取样本。下方打印了损失和准确率曲线。在右下方,用户可以输入玩家 1 的手牌值,并按下按钮从模型中进行预测。更大的预测表明模型认为该手牌更有可能获胜。值是有替换地抽取的,因此可能会出现五张相同的牌。

如果我们使用 model.fit() 执行此操作,那么我们需要创建和存储一个包含 250,000 个示例的张量,以表示输入特征。这个例子中的数据相当小——每个实例只有几十个浮点数——但是对于上一章中的目标检测任务,250,000 个示例将需要 150 GB 的 GPU 内存,^([8]) 远远超出了 2019 年大多数浏览器的可用范围。
⁸
numExamples × width × height × colorDepth × sizeOfInt32 = 250,000 × 224 × 224 × 3 × 4 bytes 。
让我们深入了解此示例的相关部分。首先,让我们看一下如何生成我们的数据集。下面清单中的代码(从 tfjs-examples/data-generator/index.js 简化而来)与 清单 6.3 中的掷骰子生成器数据集类似,但更复杂一些,因为我们存储了更多信息。
清单 6.7. 为我们的卡片游戏构建 tf.data.Dataset
import * as game from './game'; ***1***
let numSimulationsSoFar = 0;
function runOneGamePlay() {
const player1Hand = game.randomHand(); ***2***
const player2Hand = game.randomHand(); ***2***
const player1Win = game.compareHands( ***3***
player1Hand, player2Hand); ***3***
numSimulationsSoFar++;
return {player1Hand, player2Hand, player1Win}; ***4***
}
function* gameGeneratorFunction() {
while (true) {
yield runOneGamePlay();
}
}
export const GAME_GENERATOR_DATASET =
tf.data.generator(gameGeneratorFunction);
await GAME_GENERATOR_DATASET.take(1).forEach(
e => console.log(e));
// Prints
// {player1Hand: [11, 9, 7, 8],
// player2Hand: [10, 9, 5, 1],
// player1Win: 1}
-
1 游戏库提供了
randomHand()和compareHands()函数,用于从简化的类似扑克牌的卡片游戏生成手牌,以及比较两个这样的手牌以确定哪位玩家赢了。 -
2 模拟简单的类似扑克牌的卡片游戏中的两名玩家
-
3 计算游戏的赢家
-
4 返回两名玩家的手牌以及谁赢了
一旦我们将基本生成器数据集连接到游戏逻辑,我们希望以对我们的学习任务有意义的方式格式化数据。具体来说,我们的任务是尝试从 player1Hand 预测 player1Win 位。为了做到这一点,我们需要使我们的数据集返回形式为 [batchOf-Features, batchOfTargets] 的元素,其中特征是从玩家 1 的手中计算出来的。下面的代码简化自 tfjs-examples/data-generator/index.js。
清单 6.8. 构建玩家特征的数据集
function gameToFeaturesAndLabel(gameState) { ***1***
return tf.tidy(() => {
const player1Hand = tf.tensor1d(gameState.player1Hand, 'int32');
const handOneHot = tf.oneHot(
tf.sub(player1Hand, tf.scalar(1, 'int32')),
game.GAME_STATE.max_card_value);
const features = tf.sum(handOneHot, 0); ***2***
const label = tf.tensor1d([gameState.player1Win]);
return {xs: features, ys: label};
});
}
let BATCH_SIZE = 50;
export const TRAINING_DATASET =
GAME_GENERATOR_DATASET.map(gameToFeaturesAndLabel) ***3***
.batch(BATCH_SIZE); ***4***
await TRAINING_DATASET.take(1).forEach(
e => console.log([e.shape, e.shape]));
// Prints the shape of the tensors:
// [[50, 13], [50, 1]]
-
1 获取一局完整游戏的状态,并返回玩家 1 手牌的特征表示和获胜状态
-
2
handOneHot的形状为[numCards, max_value_card]。此操作对每种类型的卡片进行求和,结果是形状为[max_value_card]的张量。 -
3 将游戏输出对象格式的每个元素转换为两个张量的数组:一个用于特征,一个用于目标
-
4 将 BATCH_SIZE 个连续元素分组成单个元素。如果它们尚未是张量,则还会将数据从 JavaScript 数组转换为张量。
现在我们有了一个符合规范的数据集,我们可以使用 model.fitDataset() 将其连接到我们的模型上,如下清单所示(简化自 tfjs-examples/data-generator/index.js)。
清单 6.9. 构建并训练数据集的模型
// Construct model.
model = tf.sequential();
model.add(tf.layers.dense({
inputShape: [game.GAME_STATE.max_card_value],
units: 20,
activation: 'relu'
}));
model.add(tf.layers.dense({units: 20, activation: 'relu'}));
model.add(tf.layers.dense({units: 1, activation: 'sigmoid'}));
// Train model
await model.fitDataset(TRAINING_DATASET, { ***1***
batchesPerEpoch: ui.getBatchesPerEpoch(), ***2***
epochs: ui.getEpochsToTrain(),
validationData: TRAINING_DATASET, ***3***
validationBatches: 10, ***4***
callbacks: {
onEpochEnd: async (epoch, logs) => {
tfvis.show.history(
ui.lossContainerElement, trainLogs, ['loss', 'val_loss'])
tfvis.show.history( ***5***
ui.accuracyContainerElement, trainLogs, ['acc', 'val_acc'],
{zoomToFitAccuracy: true})
},
}
}
-
1 此调用启动训练。
-
2 一个 epoch 包含多少批次。由于我们的数据集是无限的,因此需要定义此参数,以告知 TensorFlow.js 何时执行 epoch 结束回调。
-
3 我们将训练数据用作验证数据。通常这是不好的,因为我们会对我们的表现有偏见。但在这种情况下,由于训练数据和验证数据是由生成器保证独立的,所以这不是问题。
-
4 我们需要告诉 TensorFlow.js 从验证数据集中取多少样本来构成一个评估。
-
5 model.fitDataset() 创建与 tfvis 兼容的历史记录,就像 model.fit() 一样。
正如我们在前面的清单中看到的,将模型拟合到数据集与将模型拟合到一对 x、y 张量一样简单。只要我们的数据集以正确的格式产生张量值,一切都能正常工作,我们能从可能是远程来源的流数据中获益,而且我们不需要自己管理编排。除了传入数据集而不是张量对之外,在配置对象中还有一些差异值得讨论:
-
batchesPerEpoch—正如我们在 清单 6.9 中看到的,model.fitDataset()的配置接受一个可选字段来指定构成一个周期的批次数。当我们把整个数据交给model.fit()时,计算整个数据集中有多少示例很容易。它就是data.shape[0]!当使用fitDataset()时,我们可以告诉 TensorFlow.js 一个周期何时结束有两种方法。第一种方法是使用这个配置字段,fitDataset()将在那么多批次之后执行onEpochEnd和onEpochStart回调。第二种方法是让数据集本身结束作为信号,表明数据集已经耗尽。在 清单 6.7 中,我们可以改变while (true) { ... }到
for (let i = 0; i<ui.getBatchesPerEpoch(); i++) { ... }模仿这种行为。
-
validationData—当使用fitDataset()时,validationData也可以是一个数据集。但不是必须的。如果你想要的话,你可以继续使用张量作为validationData。验证数据集需要符合返回元素格式的相同规范,就像训练数据集一样。 -
validationBatches—如果你的验证数据来自一个数据集,你需要告诉 TensorFlow.js 从数据集中取多少样本来构成一个完整的评估。如果没有指定值,那么 TensorFlow.js 将继续从数据集中提取,直到返回一个完成信号。因为 清单 6.7 中的代码使用一个永不结束的生成器来生成数据集,这永远不会发生,程序会挂起。
其余配置与 model.fit() API 完全相同,因此不需要进行任何更改。
6.3. 访问数据的常见模式
所有开发人员都需要一些解决方案,将其数据连接到其模型中。这些连接范围从常见的股票连接,到众所周知的实验数据集,如 MNIST,到完全自定义连接,到企业内的专有数据格式。在本节中,我们将回顾 tf.data 如何帮助简化和可维护这些连接。
6.3.1. 处理 CSV 格式数据
除了使用常见的股票数据集之外,访问数据的最常见方式涉及加载存储在某种文件格式中的准备好的数据。由于其简单性、易读性和广泛支持,数据文件通常存储在 CSV(逗号分隔值)格式^([9]) 中。其他格式在存储效率和访问速度方面具有其他优势,但 CSV 可能被认为是数据集的共通语言。在 JavaScript 社区中,我们通常希望能够方便地从某个 HTTP 终端流式传输数据。这就是为什么 TensorFlow.js 提供了对从 CSV 文件中流式传输和操作数据的本机支持。在 section 6.1.2 中,我们简要描述了如何构建由 CSV 文件支持的 tf.data.Dataset。在本节中,我们将深入介绍 CSV API,以展示 tf.data 如何使与这些数据源的工作变得非常容易。我们将描述一个示例应用程序,该应用程序连接到远程 CSV 数据集,打印其模式,计算数据集的元素数量,并为用户提供选择和打印单个示例的便利。查看使用熟悉的命令的示例:
⁹
截至 2019 年 1 月,数据科学和机器学习挑战网站 kaggle.com/datasets 拥有 13,971 个公共数据集,其中超过三分之二以 CSV 格式托管。
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/data-csv
yarn && yarn watch
这将弹出一个网站,指导我们输入托管的 CSV 文件的 URL,或通过点击建议的四个 URL 之一,例如波士顿房屋 CSV。请参见 figure 6.2 进行说明。在 URL 输入框下方,提供了三个按钮来执行三个操作:1) 计算数据集中的行数,2) 检索 CSV 的列名(如果存在),以及 3) 访问并打印数据集的指定样本行。让我们看看这些是如何工作的,以及 tf.data API 如何使它们变得非常容易。
图 6.2. 我们的 tfjs-data CSV 示例的 Web UI。点击顶部的预设 CSV 按钮之一或输入您自己托管的 CSV 的路径(如果有)。如果您选择自己托管的文件,请确保为您的 CSV 启用 CORS 访问。

我们之前看到,使用类似以下命令从远程 CSV 创建一个 tfjs-data 数据集非常简单:
const myData = tf.data.csv(url);
其中 url 可以是使用 http://、https:// 或 file:// 协议的字符串标识符,或者是 RequestInfo。此调用实际上并不会向 URL 发出任何请求,以检查文件是否存在或是否可访问,这是由于惰性迭代。在 列表 6.10 中,CSV 首先在异步 myData.forEach() 调用处获取。我们在 forEach() 中调用的函数将简单地将数据集中的元素转换为字符串并打印,但我们可以想象对此迭代器执行其他操作,比如为集合中的每个元素生成 UI 元素或计算报告的统计信息。
列表 6.10. 打印远程 CSV 文件中的前 10 条记录
const url = document.getElementById('queryURL').value;
const myData = tf.data.csv(url); ***1***
await myData.take(10).forEach(
x => console.log(JSON.stringify(x)))); ***2***
// Output is like
// {"crim":0.26169,"zn":0,"indus":9.9,"chas":0,"nox":0.544,"rm":6.023, ...
// ,"medv":19.4}
// {"crim":5.70818,"zn":0,"indus":18.1,"chas":0,"nox":0.532,"rm":6.75, ...
// ,"medv":23.7}
// ...
-
1 通过提供 URL 到
tf.data.csv()创建 tfjs-data 数据集。 -
2 创建由 CSV 数据集的前 10 行组成的数据集。然后,使用 forEach() 方法迭代数据集提供的所有值。请注意,forEach() 是一个异步函数。
CSV 数据集通常使用第一行作为包含每列名称的元数据标题。默认情况下,tf.data.csv() 假定是这种情况,但可以使用作为第二个参数传递的 csvConfig 对象进行控制。如果 CSV 文件本身未提供列名,则可以像这样手动提供:
const myData = tf.data.csv(url, {
hasHeader: false,
columnNames: ["firstName", "lastName", "id"]
});
如果为 CSV 数据集提供了手动的 columnNames 配置,则它将优先于从数据文件读取的标题行。默认情况下,数据集将假定第一行是标题行。如果第一行不是标题行,则必须配置缺少的部分并手动提供 columnNames。
一旦 CSVDataset 对象存在,就可以使用 dataset.columnNames() 查询其列名,该方法返回列名的有序字符串列表。columnNames() 方法是特定于 CSVDataset 子类的,不是通常从其他来源构建的数据集中可用的。示例中的 Get Column Names 按钮连接到使用此 API 的处理程序。请求列名将导致 Dataset 对象向提供的 URL 发送获取调用以访问和解析第一行;因此,下面列表中的异步调用(从 tfjs-examples/csv-data/index.js 简化而来)。
列表 6.11. 从 CSV 中访问列名
const url = document.getElementById('queryURL').value;
const myData = tf.data.csv(url);
const columnNames = await myData.columnNames(); ***1***
console.log(columnNames);
// Outputs something like [
// "crim", "zn", "indus", ..., "tax",
// "ptratio", "lstat"] for Boston Housing
- 1 联系远程 CSV 文件以收集和解析列标题。
现在我们有了列名,让我们从数据集中获取一行。在清单 6.12 中,我们展示了 Web 应用程序如何打印 CSV 文件的单个选择的行,用户通过输入元素选择行。为了满足这个请求,我们首先使用Dataset.skip()方法创建一个新的数据集,与原始数据集相同,但跳过前n - 1个元素。然后,我们将使用Dataset.take()方法创建一个在一个元素后结束的数据集。最后,我们将使用Dataset.toArray()将数据提取到标准的 JavaScript 数组中。如果一切顺利,我们的请求将产生一个包含指定位置的一个元素的数组。该序列在下面的清单中组合在一起(从 tfjs-examples/csv-data/index.js 中精简)。
清单 6.12. 访问远程 CSV 中的选择行
const url = document.getElementById('queryURL').value;
const sampleIndex = document.getElementById( ***1***
'whichSampleInput').valueAsNumber; ***1***
const myData = tf.data.csv(url); ***2***
const sample = await myData
.skip(sampleIndex) ***3***
.take(1) ***4***
.toArray(); ***5***
console.log(sample);
// Outputs something like: [{crim: 0.3237, zn: 0, indus: 2.18, ..., tax:
// 222, ptratio: 18.7, lstat: 2.94}]
// for Boston Housing.
-
1 sampleIndex 是由 UI 元素返回的一个数字。
-
2 创建名为 myData 的数据集,配置为从 url 读取,但实际上还没有连接
-
3 创建一个新的数据集,但跳过前 sampleIndex 个值
-
4 创建一个新的数据集,但只保留第一个元素。
-
5 这是实际上导致 Dataset 对象联系 URL 并执行获取的调用。请注意,返回类型是一个对象数组,本例中仅包含一个对象,键对应于标题名称和与那些列关联的值。
现在我们可以取得行的输出,正如你从清单 6.12 中的console.log输出中所看到的(在评论中重复),它以将列名映射到值的对象形式呈现,并对其进行样式化以插入到我们的文档中。需要注意的是:如果我们请求一个不存在的行,例如 300 个元素数据集的第 400 个元素,我们将得到一个空数组。
在连接远程数据集时,常常会犯错,使用错误的 URL 或不当的凭证。在这些情况下,最好捕获错误并向用户提供一个合理的错误消息。由于Dataset对象实际上直到需要数据时才联系远程资源,因此重要的是要注意将错误处理写在正确的位置。下面的清单显示了我们 CSV 示例 Web 应用程序中如何处理错误的一个简短片段(从 tfjs-examples/csv-data/index.js 中精简)。有关如何连接受身份验证保护的 CSV 文件的更多详细信息,请参见信息框 6.2。
清单 6.13. 处理由于连接失败引起的错误
const url = 'http://some.bad.url';
const sampleIndex = document.getElementById(
'whichSampleInput').valueAsNumber;
const myData = tf.data.csv(url); ***1***
let columnNames;
try {
columnNames = await myData.columnNames(); ***2***
} catch (e) {
ui.updateColumnNamesMessage(`Could not connect to ${url}`);
}
-
1 将这行代码放在 try 块中也没有帮助,因为坏的 URL 在这里并没有被获取。
-
2 从坏连接引起的错误将在此步骤抛出。
在 6.2 节 中,我们学习了如何使用 model.fitDataset()。我们看到该方法需要一个以非常特定形式产生元素的数据集。回想一下,形式是一个具有两个属性 {xs, ys} 的对象,其中 xs 是表示输入批次的张量,ys 是表示相关目标批次的张量。默认情况下,CSV 数据集将返回 JavaScript 对象形式的元素,但我们可以配置数据集以返回更接近我们训练所需的元素。为此,我们将需要使用 tf.data.csv() 的 csvConfig.columnConfigs 字段。考虑一个关于高尔夫的 CSV 文件,其中有三列:“club”、“strength” 和 “distance”。如果我们希望从俱乐部和力量预测距离,我们可以在原始输出上应用映射函数,将字段排列成 xs 和 ys;或者,更容易的是,我们可以配置 CSV 读取器来为我们执行此操作。表 6.4 显示了如何配置 CSV 数据集以分离特征和标签属性,并执行批处理,以便输出适合输入到 model.fitDataset() 中。
表 6.4. 配置 CSV 数据集以与 model.fitDataset() 一起使用
| 数据集的构建和配置方式 | 构建数据集的代码 | dataset.take(1).toArray()[0] 的结果(从数据集返回的第一个元素) |
|---|---|---|
| 默认的原始 CSV | dataset = tf.data.csv(csvURL) |
| 在 columnConfigs 中配置标签的 CSV | columnConfigs = {distance: {isLabel: true}};
dataset = tf.data.csv(csvURL,
{columnConfigs}); | {xs: {club: 1, strength: 45}, ys: {distance: 200}} |
| 在 columnConfigs 中配置后进行批处理的 CSV | columnConfigs = {distance: {isLabel: true}};
dataset = tf.data
.csv(csvURL,
{columnConfigs})
.batch(128); | [xs: {club: Tensor, strength: Tensor},
ys: {distance:Tensor}] 这三个张量中的每一个的形状都是 [128]。 |
| 在 columnConfigs 中配置后进行批处理和从对象映射到数组的 CSV | columnConfigs = {distance: {isLabel: true}}。
dataset = tf.data
.csv(csvURL,
{columnConfigs})
.map(({xs, ys}) =>
{
return
{xs:Object.values(xs),
ys:Object.values(ys)};
})
.batch(128); | {xs: Tensor, ys: Tensor} 请注意,映射函数返回的项目形式为 {xs: [number, number], ys: [number]}。批处理操作会自动将数值数组转换为张量。因此,第一个张量 (xs) 的形状为 [128,2]。第二个张量 (ys) 的形状为 [128, 1]。 |
通过认证获取 CSV 数据
在之前的示例中,我们通过简单提供 URL 来连接到远程文件中的数据。这在 Node.js 和浏览器中都很好用,而且非常容易,但有时我们的数据是受保护的,我们需要提供 Request 参数。tf.data.csv() API 允许我们在原始字符串 URL 的位置提供 RequestInfo,如下面的代码所示。除了额外的授权参数之外,数据集没有任何变化:
> const url = 'http://path/to/your/private.csv'
> const requestInfo = new Request(url);
> const API_KEY = 'abcdef123456789'
> requestInfo.headers.append('Authorization', API_KEY);
> const myDataset = tf.data.csv(requestInfo);
6.3.2. 使用 tf.data.webcam() 访问视频数据
TensorFlow.js 项目最令人兴奋的应用之一是直接训练和应用机器学习模型到移动设备上直接可用的传感器。使用移动设备上的内置加速计进行运动识别?使用内置麦克风进行声音或语音理解?使用内置摄像头进行视觉辅助?有很多好主意,而我们才刚刚开始。
在第五章 中,我们探讨了在转移学习的背景下使用网络摄像头和麦克风的工作。我们看到了如何使用摄像头来控制一款“吃豆人”游戏,并使用麦克风来微调语音理解系统。虽然并非每种模态都可以通过方便的 API 调用来使用,但 tf.data 对于使用网络摄像头有一个简单易用的 API。让我们来看看它是如何工作以及如何使用它来预测训练好的模型。
使用 tf.data API,从网络摄像头获取图像流并创建数据集迭代器非常简单。列表 6.14 展示了来自文档的一个基本示例。我们注意到的第一件事是调用 tf.data.webcam()。这个构造函数通过接受一个可选的 HTML 元素作为其输入参数来返回一个网络摄像头迭代器。该构造函数仅在浏览器环境中有效。如果在 Node.js 环境中调用 API,或者没有可用的网络摄像头,则构造函数将抛出一个指示错误来源的异常。此外,浏览器会在打开网络摄像头之前向用户请求权限。如果权限被拒绝,构造函数将抛出一个异常。负责任的开发应该用用户友好的消息来处理这些情况。
列表 6.14. 使用 tf.data.webcam() 和 HTML 元素创建数据集
const videoElement = document.createElement('video'); ***1***
videoElement.width = 100;
videoElement.height = 100;
onst webcam = await tf.data.webcam(videoElement); ***2***
const img = await webcam.capture(); ***3***
img.print();
webcam.stop(); ***4***
-
1 元素显示网络摄像头视频并确定张量大小
-
2 视频数据集对象的构造函数。该元素将显示来自网络摄像头的内容。该元素还配置了创建的张量的大小。
-
3 从视频流中获取一帧并将其作为张量提供
-
4 停止视频流并暂停网络摄像头迭代器
创建网络摄像头迭代器时,重要的是迭代器知道要生成的张量的形状。有两种方法可以控制这个。第一种方法,如 列表 6.14 所示,使用提供的 HTML 元素的形状。如果形状需要不同,或者视频根本不需要显示,可以通过一个配置对象提供所需的形状,如 列表 6.15 所示。请注意,提供的 HTML 元素参数是未定义的,这意味着 API 将创建一个隐藏的元素在 DOM 中作为视频的句柄。
列表 6.15. 使用配置对象创建基本网络摄像头数据集
const videoElement = undefined;
const webcamConfig = {
facingMode: 'user',
resizeWidth: 100,
resizeHeight: 100};
const webcam = await tf.data.webcam(
videoElement, webcamConfig); ***1***
- 1 使用配置对象构建网络摄像头数据集迭代器,而不是使用 HTML 元素。在这里,我们还指定了设备上要使用的摄像头,对于具有多个摄像头的设备,“user” 指的是面向用户的摄像头;作为“user”的替代,“environment” 指的是后置摄像头。
也可以使用配置对象裁剪和调整视频流的部分。使用 HTML 元素和配置对象并行,API 允许调用者指定要从中裁剪的位置和期望的输出大小。输出张量将插值到所需的大小。请参阅下一个清单,以查看选择方形视频的矩形部分并缩小尺寸以适应小模型的示例。
清单 6.16。从网络摄像头裁剪和调整数据
const videoElement = document.createElement('video');
videoElement.width = 300;
videoElement.height = 300; ***1***
const webcamConfig = {
resizeWidth: 150,
resizeHeight: 100, ***2***
centerCrop: true ***3***
};
const webcam = await tf.data.webcam(
videoElement, webcamConfig); ***4***
-
1 没有显式配置,videoElement 将控制输出大小,这里是 300 × 300。
-
2 用户请求从视频中提取 150 × 100 的内容。
-
3 提取的数据将来自原始视频的中心。
-
4 从此网络摄像头迭代器中捕获的数据取决于 HTML 元素和 webcamConfig。
强调一些这种类型的数据集与我们迄今为止使用的数据集之间的一些明显区别是很重要的。例如,从网络摄像头中产生的值取决于您何时提取它们。与以 CSV 数据集不同,不管它们是以多快或慢的速度绘制的,它们都会按顺序产生行。此外,可以根据用户需要从网络摄像头中提取样本。API 调用者在完成后必须明确告知流结束。
使用 capture() 方法从网络摄像头迭代器中访问数据,该方法返回表示最新帧的张量。API 用户应该将这个张量用于他们的机器学习工作,但必须记住使其释放,以防止内存泄漏。由于涉及网络摄像头数据的异步处理的复杂性,最好直接将必要的预处理函数应用于捕获的帧,而不是使用由 tf.data 提供的延迟 map() 功能。
换句话说,不要使用 data.map() 处理数据,
// No:
let webcam = await tfd.webcam(myElement)
webcam = webcam.map(myProcessingFunction);
const imgTensor = webcam.capture();
// use imgTensor here.
tf.dispose(imgTensor)
将函数直接应用于图像:
// Yes:
let webcam = await tfd.webcam(myElement);
const imgTensor = myPreprocessingFunction(webcam.capture());
// use imgTensor here.
tf.dispose(imgTensor)
不应在网络摄像头迭代器上使用 forEach() 和 toArray() 方法。为了从设备中处理长序列的帧,tf.data.webcam() API 的用户应该自己定义循环,例如使用 tf.nextFrame() 并以合理的帧率调用 capture()。原因是,如果您在网络摄像头上调用 forEach(),那么框架会以浏览器的 JavaScript 引擎可能要求它们从设备获取的速度绘制帧。这通常会比设备的帧速率更快地创建张量,导致重复的帧和浪费的计算。出于类似的原因,不应将网络摄像头迭代器作为 model.fit() 方法的参数传递。
示例 6.17 展示了来自我们在第五章中看到的网络摄像头迁移学习(Pac-Man)示例中的简化预测循环。请注意,外部循环将持续到 isPredicting 为 true 为止,这由 UI 元素控制。内部上循环的速率由调用 tf.nextFrame() 控制,该调用与 UI 的刷新率固定。以下代码来自 tfjs-examples/webcam-transfer-learning/index.js。
示例 6.17. 在预测循环中使用 tf.data.webcam()
async function getImage() { ***1***
return (await webcam.capture()) ***2***
.expandDims(0)
.toFloat()
.div(tf.scalar(127))
.sub(tf.scalar(1));
while (isPredicting) {
const img = await getImage(); ***3***
const predictedClass = tf.tidy(() => {
// Capture the frame from the webcam.
// Process the image and make predictions...
...
await tf.nextFrame(); ***4***
}
-
1 从网络摄像头捕获一帧图像,并将其标准化为 -1 到 1 之间。返回形状为 [1, w, h, c] 的批处理图像(1 个元素的批处理)。
-
2 这里的网络摄像头指的是从 tfd.webcam 返回的迭代器;请参见 示例 6.18 中的 init()。
-
3 从网络摄像头迭代器绘制下一帧
-
4 在执行下一个预测之前等待下一个动画帧
最后需要注意的是:在使用网络摄像头时,通常最好在进行预测之前绘制、处理并丢弃一张图像。这样做有两个好处。首先,通过模型传递图像可以确保相关的模型权重已经加载到 GPU 上,防止启动时出现任何卡顿或缓慢。其次,这会给网络摄像头硬件时间来热身并开始发送实际的帧。根据硬件不同,有时在设备启动时,网络摄像头会发送空白帧。在下一节中,您将看到一个片段,展示了如何在网络摄像头迁移学习示例(来自 webcam-transfer-learning/index.js)中完成这个操作。
示例 6.18. 从 tf.data.webcam() 创建视频数据集
async function init() {
try {
webcam = await tfd.webcam(
document.getElementById('webcam')); ***1***
} catch (e) {
console.log(e);
document.getElementById('no-webcam').style.display = 'block';
}
truncatedMobileNet = await loadTruncatedMobileNet();
ui.init();
// Warm up the model. This uploads weights to the GPU and compiles the
// WebGL programs so the first time we collect data from the webcam it
// will be quick.
const screenShot = await webcam.capture();
truncatedMobileNet.predict(screenShot.expandDims(0)); ***2***
screenShot.dispose(); ***3***
}
-
1 视频数据集对象的构造函数。‘webcam’ 元素是 HTML 文档中的视频元素。
-
2 对从网络摄像头返回的第一帧进行预测,以确保模型完全加载到硬件上
-
3 从
webcam.capture()返回的值是一个张量。必须将其销毁以防止内存泄漏。
6.3.3. 使用 tf.data.microphone() 访问音频数据
除了图像数据外,tf.data 还包括专门处理从设备麦克风收集音频数据的功能。与网络摄像头 API 类似,麦克风 API 创建了一个惰性迭代器,允许调用者根据需要请求帧,这些帧被整齐地打包成适合直接输入模型的张量。这里的典型用例是收集用于预测的帧。虽然技术上可以使用此 API 生成训练数据流,但与标签一起压缩它将是具有挑战性的。
示例 6.19 展示了使用 tf.data.microphone() API 收集一秒钟音频数据的示例。请注意,执行此代码将触发浏览器请求用户授权访问麦克风。
示例 6.19. 使用 tf.data.microphone() API 收集一秒钟的音频数据
const mic = await tf.data.microphone({ ***1***
fftSize: 1024,
columnTruncateLength: 232,
numFramesPerSpectrogram: 43,
sampleRateHz: 44100,
smoothingTimeConstant: 0,
includeSpectrogram: true,
includeWaveform: true
});
const audioData = await mic.capture(); ***2***
const spectrogramTensor = audioData.spectrogram; ***3***
const waveformTensor = audioData.waveform; ***4***
mic.stop(); ***5***
-
1 麦克风配置允许用户控制一些常见的音频参数。我们在正文中详细说明了其中的一些。
-
2 执行从麦克风捕获音频。
-
3 音频频谱数据以形状 [43, 232, 1] 的张量返回。
-
4 除了频谱图数据之外,还可以直接检索波形数据。此数据的形状将为 [fftSize * numFramesPerSpectrogram, 1] = [44032, 1]。
-
5 用户应该调用 stop() 来结束音频流并关闭麦克风。
麦克风包括一些可配置参数,以使用户对如何将快速傅里叶变换(FFT)应用于音频数据有精细控制。用户可能希望每个频域音频数据的频谱图有更多或更少的帧,或者他们可能只对音频频谱的某个特定频率范围感兴趣,例如对可听到的语音所必需的那些频率。列表 6.19 中的字段具有以下含义:
-
sampleRateHz:44100- 麦克风波形的采样率。这必须是精确的 44,100 或 48,000,并且必须与设备本身指定的速率匹配。如果指定的值与设备提供的值不匹配,将会抛出错误。
-
fftSize: 1024-
控制用于计算每个不重叠的音频“帧”的样本数。每个帧都经过 FFT 处理,较大的帧具有更高的频率敏感性,但时间分辨率较低,因为帧内的时间信息 丢失了。
-
必须是介于 16 和 8,192 之间的 2 的幂次方,包括 16 和 8,192。在这里,
1024意味着在约 1,024 个样本的范围内计算频率带内的能量。 -
请注意,最高可测频率等于采样率的一半,即约为 22 kHz。
-
-
columnTruncateLength: 232-
控制保留多少频率信息。默认情况下,每个音频帧包含
fftSize点,或者在我们的情况下是 1,024,覆盖从 0 到最大值(22 kHz)的整个频谱。然而,我们通常只关心较低的频率。人类语音通常只有高达 5 kHz,因此我们只保留表示零到 5 kHz 的数据部分。 -
这里,232 = (5 kHz/22 kHz) * 1024。
-
-
numFramesPerSpectrogram: 43-
FFT 是在音频样本的一系列不重叠窗口(或帧)上计算的,以创建频谱图。此参数控制每个返回的频谱图中包含多少个窗口。返回的频谱图将具有形状
[numFramesPerSpectrogram, fftSize, 1],在我们的情况下为[43, 232, 1]。 -
每个帧的持续时间等于
sampleRate/fftSize。在我们的情况下,44 kHz * 1,024 约为 0.023 秒。 -
帧之间没有延迟,因此整个频谱图的持续时间约为 43 * 0.023 = 0.98,或者约为 1 秒。
-
-
smoothingTimeConstant: 0- 要将前一帧的数据与本帧混合多少。它必须介于 0 和 1 之间。
-
includeSpectogram: True- 如果为真,则会计算并提供声谱图作为张量。如果应用程序实际上不需要计算声谱图,则将其设置为 false。这只有在需要波形时才会发生。
-
includeWaveform: True- 如果为真,则保留波形并将其作为张量提供。如果调用者不需要波形,则可以将其设置为 false。请注意,
includeSpectrogram和includeWaveform中至少一个必须为 true。如果它们都为 false,则会报错。在这里,我们将它们都设置为 true,以显示这是一个有效的选项,但在典型应用中,两者中只需要一个。
- 如果为真,则保留波形并将其作为张量提供。如果调用者不需要波形,则可以将其设置为 false。请注意,
与视频流类似,音频流有时需要一些时间才能启动,设备的数据可能一开始就是无意义的。常见的是遇到零和无穷大,但实际值和持续时间是依赖于平台的。最佳解决方案是通过丢弃前几个样本来“预热”麦克风一小段时间,直到数据不再损坏为止。通常,200 毫秒的数据足以开始获得干净的样本。
6.4. 您的数据可能存在缺陷:处理数据中的问题
几乎可以保证您的原始数据存在问题。如果您使用自己的数据源,并且您还没有花费数小时与专家一起研究各个特征、它们的分布和它们的相关性,那么很有可能存在会削弱或破坏您的机器学习模型的缺陷。我们,本书的作者,可以自信地说这一点,因为我们在指导多个领域的许多机器学习系统的构建以及构建一些自己的系统方面具有丰富的经验。最常见的症状是某些模型没有收敛,或者收敛到远低于预期精度的水平。另一个相关但更为阴险和难以调试的模式是模型在验证和测试数据上收敛并表现良好,但在生产中未能达到预期。有时确实存在建模问题、糟糕的超参数或只是倒霉,但到目前为止,这些错误的最常见根本原因是数据存在缺陷。
在幕后,我们使用的所有数据集(如 MNIST、鸢尾花和语音命令)都经过了手动检查、剪裁错误示例、格式化为标准和合适的格式以及其他我们没有讨论的数据科学操作。数据问题可以以多种形式出现,包括缺失字段、相关样本和偏斜分布。在处理数据时存在如此丰富和多样的复杂性,以至于有人可以写一本书来讲述。事实上,请参阅 Ashley Davis 的用 JavaScript 进行数据整理,以获取更详尽的阐述!^([10])
¹⁰
由 Manning Publications 出版,www.manning.com/books/data-wrangling-with-javascript。
数据科学家和数据管理人员在许多公司已经成为全职专业角色。这些专业人员使用的工具和他们遵循的最佳实践是多样的,并且通常取决于正在审查的具体领域。在本节中,我们将介绍基础知识,并指出一些工具,帮助你避免长时间模型调试会话的痛苦,只是发现数据本身存在缺陷。对于更全面的数据科学处理,我们将提供你可以进一步学习的参考资料。
6.4.1. 数据理论
为了知道如何检测和修复不好的数据,我们必须首先知道好的数据是什么样子。机器学习领域的许多理论基础在于我们的数据来自概率分布的假设。在这种表述中,我们的训练数据包含一系列独立的样本。每个样本都描述为一个(x, y)对,其中y是我们希望从x部分预测出的部分。继续这个假设,我们的推断数据包含一系列来自与我们的训练数据完全相同分布的样本。训练数据和推断数据之间唯一重要的区别是在推断时,我们无法看到y。我们应该使用从训练数据中学到的统计关系来估计样本的y部分。
我们的真实数据有许多方面可能与这种理想情况不符。例如,如果我们的训练数据和推断数据来自不同的分布,我们称之为数据集偏斜。举个简单例子,如果你正在根据天气和时间等特征来估计道路交通情况,而你所有的训练数据都来自周一和周二,而你的测试数据来自周六和周日,你可以预期模型的准确性将不如最佳。工作日的汽车交通分布与周末的不同。另一个例子,想象一下我们正在构建一个人脸识别系统,我们训练系统来识别基于我们本国的一组标记数据。我们不应该感到惊讶的是,当我们在具有不同人口统计数据的地点使用时,系统会遇到困难和失败。你在真实机器学习环境中遇到的大多数数据偏差问题会比这两个例子更微妙。
数据中偏斜的另一种可能性是在数据收集过程中出现了某种变化。例如,如果我们正在采集音频样本来学习语音信号,然后在构建训练集的过程中,我们的麦克风损坏了一半,所以我们购买了升级版,我们可以预期我们训练集的后半部分会与前半部分具有不同的噪声和音频分布。据推测,在推断时,我们将仅使用新麦克风进行测试,因此训练集和测试集之间也存在偏差。
在某个层面上,数据集的偏斜是无法避免的。对于许多应用程序,我们的训练数据必然来自过去,我们传递给应用程序的数据必然来自现在。产生这些样本的潜在分布必然会随着时间的变化而改变,如文化、兴趣、时尚和其他混淆因素的变化。在这种情况下,我们所能做的就是理解偏斜并尽量减少其影响。因此,生产环境中的许多机器学习模型都会不断使用最新可用的训练数据进行重新训练,以尝试跟上不断变化的分布。
我们的数据样本无法达到理想状态的另一种方式是缺乏独立性。我们的理想状态是样本是独立同分布(IID)的。但在某些数据集中,一个样本会提供下一个样本的可能值的线索。这些数据集的样本不是独立的。样本与样本的依赖关系最常见的方式是通过排序现象引入到数据集中。为了访问速度和其他各种很好的理由,我们作为计算机科学家已经接受了对数据进行组织的训练。事实上,数据库系统通常会在我们不经意的情况下为我们组织数据。因此,当你从某个源流式传输数据时,必须非常谨慎,确保结果的顺序没有某种模式。
考虑以下假设。我们希望为房地产应用程序建立一个对加利福尼亚州住房成本的估计。我们获得了一个来自全州各地的住房价格的 CSV 数据集^([11]),以及相关特征,如房间数、开发年龄等。我们可能会考虑立即开始从特征到价格的训练函数,因为我们有数据,而且我们知道如何操作。但是知道数据经常有缺陷,我们决定先看看数据。我们首先使用数据集和 Plotly.js 绘制一些特征与数组中的索引的图。请参考图 6.3 中的插图^([12])和下面的清单(摘自codepen.io/tfjs-book/pen/MLQOem)了解如何制作这些插图。
¹¹
关于在这里使用的加利福尼亚住房数据集的描述可在机器学习速成课程的网址
mng.bz/Xpm6中找到。¹²
图 6.3 中的插图是使用 CodePen 制作的,网址是
codepen.io/tfjs-book/pen/MLQOem。
图 6.3. 四个数据集特征与样本索引的图。理想情况下,在一个干净的 IID 数据集中,我们预期样本索引对特征值没有任何信息。我们可以看到,对于某些特征,y 值的分布显然取决于 x。尤其令人震惊的是,“经度”特征似乎是按照样本索引排序的。

列表 6.20. 使用 tfjs-data 构建特征与索引的绘图
const plottingData = {
x: [],
y: [],
mode: 'markers',
type: 'scatter',
marker: {symbol: 'circle', size: 8}
};
const filename = 'https://storage.googleapis.com/learnjs-data/csv-
datasets/california_housing_train.csv';
const dataset = tf.data.csv(filename);
await dataset.take(1000).forEachAsync(row => { ***1***
plottingData.x.push(i++);
plottingData.y.push(row['longitude']);
});
Plotly.newPlot('plot', [plottingData], {
width: 700,
title: 'Longitude feature vs sample index',
xaxis: {title: 'sample index'},
yaxis: {title: 'longitude'}
});
- 1 获取前 1,000 个样本并收集它们的值和它们的索引。别忘了等待,否则你的图表可能会是空的!
想象一下,如果我们使用这个数据集构建了一个训练-测试分割,其中我们取前 500 个样本进行训练,剩余的用于测试。会发生什么?从这个分析中可以看出,我们将用来自一个地理区域的数据进行训练,而用来自另一个地理区域的数据进行测试。图 6.3 中的经度面板显示了问题的关键所在:第一个样本来自一个经度更高(更向西)的地方。特征中可能仍然有大量信号,模型会“工作”一定程度,但准确性或质量不如如果我们的数据真正是 IID。如果我们不知道更好的话,我们可能会花费几天甚至几周时间尝试不同的模型和超参数,直到找出问题所在并查看我们的数据!
我们可以做些什么来清理这个问题?修复这个特定问题非常简单。为了消除数据和索引之间的关系,我们可以将数据随机洗牌成随机顺序。然而,这里有一些需要注意的地方。TensorFlow.js 数据集有一个内置的洗牌例程,但它是一个流式窗口洗牌例程。这意味着样本在固定大小的窗口内随机洗牌,但没有更进一步。这是因为 TensorFlow.js 数据集流式传输数据,它们可能传输无限数量的样本。为了完全打乱一个永无止境的数据源,你首先需要等待直到它完成。
那么,我们能否使用这个经度特征的流式窗口洗牌?当然,如果我们知道数据集的大小(在这种情况下是 17,000),我们可以指定窗口大小大于整个数据集,然后一切都搞定了。在非常大的窗口大小极限下,窗口化洗牌和我们的常规穷举洗牌是相同的。如果我们不知道我们的数据集有多大,或者大小是不可行的大(即,我们无法一次性在内存缓存中保存整个数据集),我们可能不得不凑合一下。
图 6.4,使用 codepen.io/tfjs-book/pen/JxpMrj 创建,说明了使用 tf.data 的 .Dataset 的 shuffle() 方法进行四种不同窗口大小的数据洗牌时会发生什么:
for (let windowSize of [10, 50, 250, 6000]) {
shuffledDataset = dataset.shuffle(windowSize);
myPlot(shuffledDataset, windowSize)
}
图 6.4. 四个不同的洗牌数据集的经度与样本索引的四个图。每个洗牌窗口大小不同,从 10 增加到 6,000 个样本。我们可以看到,即使在窗口大小为 250 时,索引和特征值之间仍然存在强烈的关系。在开始附近有更多的大值。直到我们使用的洗牌窗口大小几乎与数据集一样大时,数据的 IID 特性才几乎恢复。

我们看到,即使对于相对较大的窗口大小,索引与特征值之间的结构关系仍然很明显。直到窗口大小达到 6,000 时,我们才能用肉眼看到数据现在可以视为 IID。那么,6,000 是正确的窗口大小吗?在 250 和 6,000 之间是否有一个数字可以起作用?6,000 仍然不足以捕捉我们在这些示例中没有看到的分布问题吗?在这里的正确方法是使用一个windowSize >= 数据集中的样本数量来对整个数据集进行洗牌。对于由于内存限制、时间限制或可能是无限数据集而无法进行此操作的数据集,您必须戴上数据科学家的帽子并检查分布以确定适当的窗口大小。
6.4.2. 检测和清理数据问题
在前一节中,我们已经讨论了如何检测和修复一种类型的数据问题:样本间的依赖关系。当然,这只是数据可能出现的许多问题类型之一。由于出现数据问题的种类和代码出错的种类一样多,因此对所有可能出错的事情进行全面处理远远超出了本书的范围。不过,我们还是要在这里介绍一些问题,这样当你遇到问题时就能识别出它们,并且知道要搜索哪些术语以获取更多信息。
离群值
离群值是我们数据集中非常不寻常的样本,而且在某种程度上不符合基础分布。例如,如果我们正在处理健康统计数据集,我们可能会期望典型成年人的体重在大约 40 至 130 公斤之间。如果我们的数据集中有 99.9%的样本在这个范围内,但偶尔我们遇到了一些荒谬的样本报告,如 145,000 公斤,或者 0 公斤,或者更糟糕的 NaN,我们会将这些样本视为离群值。快速的在线搜索显示,关于处理离群值的正确方式有很多不同的观点。理想情况下,我们的训练数据中应该只有很少的离群值,并且我们应该知道如何找到它们。如果我们能编写一个程序来拒绝离群值,我们就可以将它们从我们的数据集中移除,然后继续训练而不受它们的影响。当然,我们也希望在推断时触发相同的逻辑;否则,我们会引入偏差。在这种情况下,我们可以使用相同的逻辑通知用户,他们的样本构成了系统的离群值,并且他们必须尝试其他方法。
¹³
在我们的输入特征中摄取 NaN 值将在我们的模型中传播该 NaN 值。
在特征级别处理离群值的另一种常见方法是通过提供合理的最小值和最大值来夹紧数值。在我们的案例中,我们可能会用以下方式替换体重
weight = Math.min(MAX_WEIGHT, Math.max(weight, MIN_WEIGHT));
在这种情况下,还有一个好主意是添加一个新的特征,指示异常值已被替换。这样,原始值 40 公斤就可以与被夹为 40 公斤的值-5 公斤区分开来,给网络提供了学习异常状态与目标之间关系的机会,如果这样的关系存在的话:
isOutlierWeight = weight > MAX_WEIGHT | weight < MIN_WEIGHT;
缺失数据
经常,我们面临一些样本缺少某些特征的情况。这可能发生在任何数量的原因。有时数据来自手工录入的表单,有些字段被跳过了。有时传感器在数据收集时损坏或失效。对于一些样本,也许一些特征根本没有意义。例如,从未出售过的房屋的最近销售价格是多少?或者没有电话的人的电话号码是多少?
与异常值一样,有许多方法可以解决缺失数据的问题,数据科学家对于在哪些情况下使用哪些技术有不同的意见。哪种技术最好取决于一些考虑因素,包括特征缺失的可能性是否取决于特征本身的值,或者“缺失”是否可以从样本中的其他特征预测出来。信息框 6.3 概述了缺失数据类别的术语表。
缺失数据的类别
随机缺失(MAR):
-
特征缺失的可能性并不取决于隐藏的缺失值,但它可能取决于其他一些观察到的值。
-
示例:如果我们有一个自动化的视觉系统记录汽车交通,它可能记录,除其他外,车牌号码和时间。有时,如果天黑了,我们就无法读取车牌。车牌的存在与车牌值无关,但可能取决于(观察到的)时间特征。
完全随机缺失(MCAR)
-
特征缺失的可能性不取决于隐藏的缺失值或任何观察到的值。
-
示例:宇宙射线干扰我们的设备,有时会破坏我们数据集中的值。破坏的可能性不取决于存储的值或数据集中的其他值。
非随机缺失(MNAR)
-
特征缺失的可能性取决于给定观察数据的隐藏值。
-
示例:个人气象站跟踪各种统计数据,如气压、降雨量和太阳辐射。然而,下雪时,太阳辐射计不会发出信号。
当数据在我们的训练集中缺失时,我们必须应用一些修正才能将数据转换为固定形状的张量,这需要每个单元格中都有一个值。处理缺失数据的四种重要技术。
最简单的技术是,如果训练数据丰富且缺失字段很少,就丢弃具有缺失数据的训练样本。但是,请注意,这可能会在您的训练模型中引入偏差。为了明显地看到这一点,请想象一个问题,其中从正类缺失数据的情况远比从负类缺失数据的情况要常见得多。您最终会学习到错误的类别可能性。只有当您的缺失数据是 MCAR 时,您才完全可以放心地丢弃样本。
第 6.21 列表。通过删除数据处理缺失的特征
const filteredDataset =
tf.data.csv(csvFilename)
.filter(e => e['featureName']); ***1***
- 1 仅保留值为'truthy'的元素:即不为 0、null、未定义、NaN 或空字符串时
处理缺失数据的另一种技术是用某个值填充缺失数据,也称为插补。常见的插补技术包括用该特征的均值、中位数或众数替换缺失的数值特征值。缺失的分类特征可以用该特征的最常见值(也是众数)替换。更复杂的技术包括从可用特征构建缺失特征的预测器,并使用它们。事实上,使用神经网络是处理缺失数据的“复杂技术”之一。使用插补的缺点是学习器不知道特征缺失了。如果缺失信息与目标变量有关,则在插补中将丢失该信息。
第 6.22 列表。使用插补处理缺失的特征
async function calculateMeanOfNonMissing( ***1***
dataset, featureName) { ***1***
let samplesSoFar = 0;
let sumSoFar = 0;
await dataset.forEachAsync(row => {
const x = row[featureName];
if (x != null) { ***2***
samplesSoFar += 1;
sumSoFar += x;
}
});
return sumSoFar / samplesSoFar; ***3***
}
function replaceMissingWithImputed( ***4***
row, featureName, imputedValue)) { ***4***
const x = row[featureName];
if (x == null) {
return {...row, [featureName]: imputedValue};
} else {
return row;
}
}
const rawDataset tf.data.csv(csvFilename);
const imputedValue = await calculateMeanOfNonMissing(
rawDataset, 'myFeature');
const imputedDataset = rawDataset.map( ***5***
row => replaceMissingWithImputed( ***5***
row, 'myFeature', imputedValue)); ***5***
-
1 用于计算用于插补的值的函数。在计算均值时,请记住只包括有效值。
-
2 这里将未定义和 null 值都视为缺失。一些数据集可能使用哨兵值,如 -1 或 0 来表示缺失。请务必查看您的数据!
-
3 请注意,当所有数据都丢失时,这将返回 NaN。
-
4 如果 featureName 处的值缺失,则有条件地更新行的函数
-
5 使用 tf.data.Dataset map()方法将替换映射到所有元素上
有时缺失值会被替换为哨兵值。例如,缺失的体重值可能会被替换为 -1,表示未称重。如果您的数据看起来是这种情况,请注意在将其视为异常值之前先处理哨兵值(例如,根据我们先前的示例,用 40 kg 替换这个 -1)。
可以想象,如果缺失特征与要预测的目标之间存在关系,模型可能能够使用哨兵值。实际上,模型将花费部分计算资源来学习区分特征何时用作值,何时用作指示器。
管理缺失数据可能最稳健的方法是既使用插补来填补一个值,又添加一个第二个指示特征,以便在该特征缺失时通知模型。在这种情况下,我们将缺失的体重替换为一个猜测值,同时添加一个新特征weight_missing,当体重缺失时为 1,提供时为 0。这使得模型可以利用缺失情况,如果有价值的话,并且不会将其与体重的实际值混淆。
列表 6.23. 添加一个特征来指示缺失
function addMissingness(row, featureName)) { ***1***
const x = row[featureName];
const isMissing = (x == null) ? 1 : 0;
return {...row, [featureName + '_isMissing']: isMissing};
}
const rawDataset tf.data.csv(csvFilename);
const datasetWithIndicator = rawDataset.map(
(row) => addMissingness(row, featureName); ***2***
-
1 将一个新特征添加到每一行的函数,如果特征缺失则为 1,否则为 0
-
2 使用 tf.data.Dataset map() 方法将附加特征映射到每一行
偏斜
在本章的前面,我们描述了偏斜的概念,即一个数据集与另一个数据集之间的分布差异。当将训练好的模型部署到生产中时,这是机器学习实践者面临的主要问题之一。检测偏斜涉及对数据集的分布进行建模并比较它们是否匹配。快速查看数据集统计信息的简单方法是使用像 Facets(pair-code.github.io/facets/)这样的工具。参见图 6.5 以查看截图。Facets 将分析和总结您的数据集,以便您查看每个特征的分布,这将帮助您快速发现数据集之间的不同分布问题。
图 6.5. Facets 的截图显示了 UC Irvine Census Income 数据集的训练集和测试集的每个特征值分布情况(参见archive.ics.uci.edu/ml/datasets/Census+Income)。该数据集是默认加载在pair-code.github.io/facets/上的,但您可以导航到该网站并上传自己的 CSV 文件进行比较。这个视图被称为 Facets 概览。

一个简单、基础的偏斜检测算法可能会计算每个特征的均值、中位数和方差,并检查数据集之间的任何差异是否在可接受范围内。更复杂的方法可能会尝试根据样本预测它们来自哪个数据集。理想情况下,这应该是不可能的,因为它们来自相同的分布。如果能够预测数据点是来自训练还是测试,这是偏斜的迹象。
错误字符串
非常常见的情况是,分类数据是以字符串形式提供的特征。例如,当用户访问您的网页时,您可能会记录使用的浏览器,值为FIREFOX、SAFARI和CHROME。通常,在将这些值送入深度学习模型之前,这些值会被转换为整数(通过已知词汇表或哈希),然后映射到一个n维向量空间(见 9.2.3 节关于词嵌入)。一个常见的问题是,一个数据集中的字符串与另一个数据集中字符串的格式不同。例如,训练数据可能有FIREFOX,而在服务时间,模型收到FIREFOX\n,包括换行符,或者"FIREFOX",包括引号。这是一个特别阴险的形式的偏差,应该像处理偏差一样处理。
其他需要注意的数据内容。
除了前面提到的问题之外,在将数据提供给机器学习系统时,还有一些需要注意的事项:
-
过度不平衡的数据——如果有一些特征在你的数据集中几乎每个样本都取相同的值,你可以考虑将它们去掉。这种类型的信号很容易过拟合,而深度学习方法对非常稀疏的数据不能很好地处理。
-
数值/分类区别——一些数据集将使用整数表示列举集合的元素,而当这些整数的等级顺序没有意义时,这可能会导致问题。例如,如果我们有一个音乐类型的列举集合,比如
ROCK,CLASSICAL等,和一个将这些值映射到整数的词汇表,很重要的是,当我们把这些值传递到模型中时,我们要像处理列举值一样处理这些值。这意味着使用 one-hot 或嵌入(见第九章)对值进行编码。否则,这些数字将被解释为浮点数值,根据它们的编码之间的数字距离,可能会提示虚假的术语之间的关系。 -
规模巨大的差异——这是之前提到过的,但在这一节中重申,可以出现数据错误的情况。要注意具有大量差异的数值特征。它们可能导致训练不稳定。通常情况下,在训练之前最好对数据进行 z 标准化(规范化均值和标准差)。只需要确保在服务时间中使用与训练期间相同的预处理即可。你可以在 tensorflow/tfjs-examples iris example 中看到一个例子,我们在第三章中探讨过。
-
偏见、安全和隐私——显然,在负责任的机器学习开发中,有很多内容远远超出了一本书章的范围。如果你正在开发机器学习解决方案,花时间了解至少管理偏见、安全和隐私的基本最佳实践至关重要。一个好的起点是谷歌在
ai.google/education/responsible-ai-practices上的负责任 AI 实践页面。遵循这些实践只是做一个好人和一个负责任的工程师的正确选择——显然是重要的目标本身。此外,仔细关注这些问题也是一个明智的选择,因为即使是偏见、安全或隐私的小失误也可能导致令人尴尬的系统性故障,迅速导致客户寻找更可靠的解决方案。
一般来说,你应该花时间确信你的数据符合你的预期。有许多工具可以帮助你做到这一点,从像 Observable、Jupyter、Kaggle Kernel 和 Colab 这样的笔记本,到图形界面工具如 Facets。在 Facets 中,看 图 6.6 ,还有另一种探索数据的方式。在这里,我们使用 Facets 的绘图功能,也就是 Facets Dive,来查看纽约州立大学(SUNY)数据集中的点。Facets Dive 允许用户从数据中选择列,并以自定义的方式直观地表达每个字段。在这里,我们使用下拉菜单将 Longitude1 字段用作点的 x 位置,将 Latitude1 字段用作点的 y 位置,将 City 字符串字段用作点的名称,并将 Undergraduate Enrollment 用作点的颜色。我们期望在二维平面上绘制的纬度和经度能揭示出纽约州的地图,而确实是我们所看到的。地图的正确性可以通过将其与 SUNY 的网页 www.suny.edu/attend/visit-us/campus-map/ 进行比较来验证。
图 6.6. Facets 的另一张截图,这次探索的是数据-csv 示例中的纽约州校园数据集。在这里,我们看到 Facets Dive 视图,它允许您探索数据集的不同特征之间的关系。每个显示的点都是数据集中的一个数据点,这里我们将点的 x 位置设置为 Latitude1 特征,y 位置是 Longitude1 特征,颜色与 Undergraduate Enrollment 特征相关,前面的字是设置为 City 特征的,其中包含每个数据点所在的城市的名称。从这个可视化中,我们可以看到纽约州的大致轮廓,西边是布法罗,东南边是纽约。显然,Selden 市包含了一个根据本科生注册人数计算的最大校园之一。

6.5. 数据增强
所以,我们已经收集了我们的数据,我们将其连接到一个tf.data.Dataset以便进行简单操作,我们仔细检查并清理了其中的问题。还有什么其他方法可以帮助我们的模型成功呢?
有时,你拥有的数据不足,希望通过程序化地扩展数据集,通过对现有数据进行微小更改来创建新的示例。例如,回顾一下第四章中的 MNIST 手写数字分类问题。MNIST 包含 60,000 个训练图像,共 10 个手写数字,每个数字 6,000 个。这足以学习我们想要为我们的数字分类器提供的所有类型的灵活性吗?如果有人画了一个太大或太小的数字会发生什么?或者稍微旋转了一下?或者有偏斜?或者用笔写得粗或细了?我们的模型还会理解吗?
如果我们拿一个 MNIST 样本数字,并将图像向左移动一个像素,那么数字的语义标签不会改变。向左移动的 9 仍然是一个 9,但我们有了一个新的训练示例。这种从实际示例变异生成的程序化示例称为伪示例,将伪示例添加到数据的过程称为数据增强。
数据增强采取的方法是从现有的训练样本中生成更多的训练数据。对于图像数据,各种变换如旋转、裁剪和缩放通常会产生看起来可信的图像。其目的是增加训练数据的多样性,以增加训练模型的泛化能力(换句话说,减轻过拟合),这在训练数据集大小较小时特别有用。
图 6.7 展示了应用于由猫图像组成的输入示例的数据增强,来自带标签的图像数据集。通过应用旋转和偏斜等方式增强数据,使得示例的标签即“CAT”不变,但输入示例发生了显著变化。
图 6.7。通过随机数据增强生成猫图片。单个标记示例可以通过提供随机旋转、反射、平移和偏斜来产生整个训练样本族。喵。

如果你使用这个数据增强配置来训练一个新的网络,那么这个网络永远不会看到相同的输入两次。但它所看到的输入仍然存在很强的相互关联性,因为它们来自少量原始图像——你无法产生新的信息,只能重新混合现有信息。因此,这可能不足以完全消除过拟合。使用数据增强的另一个风险是,训练数据现在不太可能与推断数据的分布匹配,引入了偏差。额外训练伪例的好处是否超过偏差成本取决于应用程序,并且这可能只是需要测试和实验的内容。
代码清单 6.24 展示了如何将数据增强作为dataset.map()函数包括在内,将允许的变换注入到数据集中。请注意,增强应逐个样本应用。还需要注意的是,不应将增强应用于验证或测试集。如果在增强数据上进行测试,则在推断时不会应用增强,因此会对模型的能力产生偏见。
6. 在使用数据增强对数据集进行训练的示例中的代码清单。
function augmentFn(sample) { ***1***
const img = sample.image;
const augmentedImg = randomRotate(
randomSkew(randomMirror(img)))); ***2***
return {image: augmentedImg, label: sample.label};
}
const (trainingDataset, validationDataset} = ***3***
getDatsetsFromSource(); ***3***
augmentedDataset = trainingDataset ***4***
.repeat().map(augmentFn).batch(BATCH_SIZE); ***4***
// Train model
await model.fitDataset(augmentedDataset, { ***5***
batchesPerEpoch: ui.getBatchesPerEpoch(),
epochs: ui.getEpochsToTrain(),
validationData: validationDataset.repeat(), ***6***
validationBatches: 10,
callbacks: { ... },
}
}
-
1 数据增强函数以{图像,标签}格式的样本作为输入,并返回相同格式下的新样本。
-
2 假设随机旋转、随机扭曲和随机镜像在其他地方由某个库定义。旋转、扭曲等的数量在每次调用时随机生成。数据增强只应依赖于特征,而不是样本的标签。
-
3 该函数返回两个 tf.data.Datasets,每个元素类型为{image, label}。
-
4 增强应用于单个元素的批处理之前。
-
5 我们在增强的数据集上拟合模型。
-
6 重要!不要对验证集应用数据增强。在这里对验证数据进行重复操作,因为数据不会自动循环。每次验证测量只会取 10 批数据,根据配置。
希望本章能让你深刻理解在将机器学习模型应用于数据之前了解数据的重要性。我们介绍了 Facets 等开箱即用的工具,您可以使用它们来检查数据集并深入了解它们。然而,当您需要更灵活和自定义的数据可视化时,需要编写一些代码来完成这项工作。在下一章中,我们将教您 tfjs-vis 的基础知识,这是由 TensorFlow.js 的作者维护的可支持此类数据可视化用例的可视化模块。
练习
-
将第五章的简单物体检测示例扩展,使用
tf.data .generator()和model.fitDataset()代替提前生成完整数据集。此结构有何优势?如果为模型提供更大的图像数据集进行训练,性能会有明显提升吗? -
通过对示例中的 MNIST 示例添加小偏移、缩放和旋转来添加数据增强。这是否有助于性能?在使用数据流进行验证和测试时,是否对数据流进行增强更合适,还是只在“真实”的自然样本上测试?
-
使用第 6.4.1 节中的技术绘制其他章节中使用的一些数据集的一些特征。数据符合独立性的预期吗?有异常值吗?还有缺失值吗?
-
将我们在这里讨论过的一些 CSV 数据集导入 Facets 工具中。哪些特征看起来可能会引起问题?有什么意外情况吗?
-
考虑一些我们在早期章节中使用的数据集。哪些数据增强技术适用于那些数据?
概要
-
数据是推动深度学习革命的关键力量。没有大型、组织良好的数据集,大多数深度学习应用都无法实现。
-
TensorFlow.js 内置了
tf.dataAPI,使得流式传输大型数据集、以各种方式转换数据,并将其连接到模型进行训练和预测变得简单。 -
有几种方法可以构建
tf.data.Dataset对象:从 JavaScript 数组、从 CSV 文件,或者从数据生成函数。从远程 CSV 文件流式传输数据集可以在一行 JavaScript 代码中完成。 -
tf.data.Dataset对象具有可链接的 API,使得在机器学习应用中常见的洗牌、过滤、批处理、映射和执行其他操作变得简单而方便。 -
tf.data.Dataset以延迟流式传输的方式访问数据。这使得处理大型远程数据集变得简单高效,但付出的代价是处理异步操作。 -
tf.Model对象可以直接使用其fitDataset()方法从tf.data.Dataset进行训练。 -
审计和清理数据需要时间和关注,但这是任何你打算投入实际应用的机器学习系统都必不可少的一步。在数据处理阶段检测和管理偏差、缺失数据和异常值等问题,最终会节省建模阶段的调试时间。
-
数据增强可以用于扩展数据集,包括程序生成的伪示例。这可以帮助模型覆盖原始数据集中未充分表示的已知不变性。
第七章:数据和模型的可视化
本章内容
-
如何使用 tfjs-vis 执行自定义数据可视化
-
如何在模型训练后查看内部工作并获得有用的见解
可视化对于机器学习从业者来说是一项重要的技能,因为它涉及到机器学习工作流的每个阶段。在我们构建模型之前,我们通过可视化来检查数据;在模型工程和训练期间,我们通过可视化来监测训练过程;模型训练完毕后,我们使用可视化来了解其工作原理。
在第六章中,你了解到在应用机器学习之前,可视化和了解数据的好处。我们介绍了如何使用 Facets,这是一个基于浏览器的工具,可以帮助你快速、交互式地查看数据。在本章中,我们将介绍一个新工具 tfjs-vis,它可以帮助你以自定义、程序化的方式可视化数据。这样做的好处,相较于只看数据的原始格式或使用 Facets 等现成工具,是更灵活、多样的可视化范式以及更深入理解数据的可能性。
除了数据可视化外,我们还会展示如何在深度学习模型训练后使用可视化。我们将使用深入的例子,通过可视化内部激活和计算卷积神经网络层最大程度“激发”的模式,来窥视神经网络“黑盒”的潜力。这将完整展现可视化如何在每个阶段与深度学习相辅相成的故事。
完成本章后,你应该知道为什么可视化是任何机器学习工作流不可或缺的一部分。你还应该熟悉在 TensorFlow.js 框架中可视化数据和模型的标准方式,并能够将它们应用到自己的机器学习问题中。
7.1 数据可视化
让我们从数据可视化开始,因为这是机器学习实践者在解决新问题时首先做的事情。我们假设可视化任务比 Facets 能够覆盖的更高级(例如,数据不在一个小的 CSV 文件中)。因此,我们首先会介绍一个基本的图表 API,它可以帮助你在浏览器中创建简单且广泛使用的绘图类型,包括折线图、散点图、条形图和直方图。在完成使用手工编写的数据的基本示例后,我们将通过一个涉及可视化有趣真实数据集的示例将事物整合起来。
7.1.1 使用 tfjs-vis 可视化数据
tfjs-vis 是一个与 TensorFlow.js 紧密集成的可视化库。本章将介绍其许多功能之一,即其 tfvis.render.* 命名空间下的轻量级图表 API。这个简单直观的 API 允许你在浏览器中制作图表,重点关注机器学习中最常用的图表类型。为了帮助你开始使用 tfvis.render,我们将给你介绍一个 CodePen,地址为 codepen.io/tfjs-book/pen/BvzMZr,该 CodePen 展示了如何使用 tfvis.render 创建各种基本数据图。
¹
此绘图 API 是建立在 Vega 可视化库之上的:
vega.github.io/vega/。
tfjs-vis 的基础知识
首先,注意 tfjs-vis 是独立于主要的 TensorFlow.js 库的。你可以从 CodePen 如何用 <script> 标签导入 tfjs-vis 来看出这一点:
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-vis@latest">
</script>
这与导入主要的 TensorFlow.js 库的方式不同:
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest">
</script>
tfjs-vis 和 TensorFlow.js 的 npm 包有所不同(分别是 @tensorflow/tfjs-vis 和 @tensorflow/tfjs)。在一个依赖于 TensorFlow.js 和 tfjs-vis 的网页或 JavaScript 程序中,这两个依赖都必须被导入。
线图
最常用的图表类型可能是 线图(一个曲线,将一个数量绘制成有序数量)。线图有一个水平轴和一个垂直轴,通常分别称为 x 轴 和 y 轴。这种类型的可视化在生活中随处可见。例如,我们可以通过线图将一天中温度的变化情况绘制出来,其中水平轴是一天中的时间,垂直轴是温度计的读数。线图的水平轴也可以是其他东西。例如,我们可以使用线图来显示高血压药物的治疗效应(它降低了多少血压)与剂量(每天使用多少药物)之间的关系。这样的绘图被称为 剂量-反应曲线。另一个非时间线图的很好的例子是我们在第三章中讨论的 ROC 曲线。那里,x 轴和 y 轴都与时间无关(它们是二元分类器的假阳性和真阳性率)。
要使用 tfvis.render 创建线图,可以使用 linechart() 函数。正如 CodePen 的第一个示例(也是清单 7.1)所示,该函数需要三个参数:
-
第一个参数是用于绘制图表的 HTML 元素。可以使用空的
<div>元素。 -
第二个参数是图表中数据点的值。这是一个包含
value字段并指向一个数组的普通 JavaScript 对象(POJO)。数组由多个 x-y 值对组成,每个值对由一个包含名为x和y字段的 POJO 表示。x和y值分别是数据点的 x 和 y 坐标。 -
第三个参数(可选)包含线图的其他配置字段。在这个例子中,我们使用
width字段来指定结果图的宽度(以像素为单位)。在后面的例子中您将看到更多的配置字段。^([2])²
js.tensorflow.org/api_vis/latest/包含 tfjs-vis API 的完整文档,在这里您可以找到关于此函数的其他配置字段的信息。
清单 7.1. 使用tfvis.render.linechart()创建一个简单的折线图
let values = [{x: 1, y: 20}, {x: 2, y: 30},
{x: 3, y: 5}, {x: 4, y: 12}]; ***1***
tfvis.render.linechart(document.getElementById('plot1'), ***2***
{values}, ***3***
{width: 400}); ***4***
-
1 数据系列是一个包含 x-y 对的数组。
-
2 第一个参数是将绘制图表的 HTML 元素。这里的'plot1'是一个空的 div 的 ID。
-
3 第二个参数是一个包含键“值”的对象。
-
4 自定义配置作为第三个参数传递。在这种情况下,我们只配置了图的宽度。
由清单 7.1 中的代码创建的折线图显示在图 7.1 的左侧面板中。这是一个只有四个数据点的简单曲线。但是,linechart()函数可以支持更多数据点的曲线(例如,数千个)。然而,如果你尝试一次绘制太多数据点,你最终会遇到浏览器的资源限制。限制与浏览器和平台相关,应当通过实证方法来确定。一般来说,为了使用户界面流畅响应,限制图表中可呈现的数据大小是一个好习惯。
图 7.1. 使用tfvis.render.linechart()创建的折线图。左侧:使用清单 7.1 中的代码创建的单个系列。右侧:使用清单 7.2 中的代码在同一个坐标轴上创建的两个系列。

有时您想在同一张图中绘制两条曲线,以显示它们之间的关系(例如相互比较)。您可以使用tfvis.render.linechart()制作这些类型的图表。示例显示在图 7.1 的右侧面板中,代码在清单 7.2 中。
这些被称为多系列图表,每条线称为系列。要创建多系列图表,必须在传递给linechart()的第一个参数中包括一个附加字段series。该字段的值是一个字符串数组。这些字符串是系列的名称,并将作为图表中的图例呈现。在示例代码中,我们将系列称为'My series 1'和'My series 2'。
对于多系列图表,第一个参数的value字段也需要恰当地指定。对于我们的第一个示例,我们提供了一个点数组,但是对于多系列图表,我们必须提供一个数组的数组。嵌套数组的每个元素都是一个系列的数据点,并且具有与我们在清单 7.1 中绘制单系列图表时看到的值数组相同的格式。因此,嵌套数组的长度必须与series数组的长度匹配,否则将出现错误。
由清单 7.2 创建的图表显示在图 7.1 的右侧面板中。如您在本书的电子版本中图表中所见,tfjs-vis 选择两种不同的颜色(蓝色和橙色)来渲染两条曲线。这种默认的着色方案通常很有效,因为蓝色和橙色很容易区分。如果有更多的系列需要呈现,其他新颜色将自动选择。
此示例图表中的两个系列在 x 坐标的值(1、2、3 和 4)完全相同。但是,在多系列图表中,不同系列的 x 坐标值不一定相同。您可以尝试在本章末尾的练习 1 中尝试这种情况。但是,需要注意的是,将两条曲线绘制在同一个线条图表中并不总是明智的做法。例如,如果两条曲线具有非常不同并且不重叠的 y 值范围,则将它们绘制在同一个线条图表中会使每个曲线的变化更难以看到。在这种情况下,最好将它们绘制在单独的线条图表中。
在清单 7.2 中还值得指出的是轴的自定义标签。 我们使用配置对象中的xLabel和yLabel字段(传递给linechart()的第三个参数)来标记我们选择的自定义字符串的 x 和 y 轴。 通常,标记轴是一种良好的实践,因为它使图表更易于理解。 如果您没有指定xLabel和yLabel,tfjs-vis 将始终将您的轴标记为x和y,这就是清单 7.1 和图 7.1 的左面板所发生的。
清单 7.2。使用 tfvis.render.linechart()制作带有两个系列的线条图表
values = [ ***1***
[{x: 1, y: 20}, {x: 2, y: 30}, {x: 3, y: 5}, {x: 4, y: 12}], ***1***
[{x: 1, y: 40}, {x: 2, y: 0}, {x: 3, y: 50}, {x: 4, y: -5}] ***1***
]; ***1***
let series = ['My series 1', 'My series 2']; ***2***
tfvis.render.linechart(
document.getElementById('plot2'), {values, series}, {
width: 400,
xLabel: 'My x-axis label', ***3***
yLabel: 'My y-axis label' ***3***
});
-
1 为了在相同的轴上显示多个系列,使值成为由多个 x-y 对数组组成的数组。
-
2 在绘制多个系列时,必须提供系列名称。
-
3 覆盖默认的 x 和 y 坐标轴标签。
散点图
散点图 是您可以使用 tfvis.render 创建的另一种图表类型。散点图与折线图最显著的区别在于,散点图不使用线段连接数据点。这使得散点图适用于数据点间顺序不重要的情况。例如,散点图可以将几个国家的人口与人均国内生产总值进行绘制。在这样的图中,主要信息是 x 值和 y 值之间的关系,而不是数据点之间的顺序。
在 tfvis.render 中,让您创建散点图的函数是 scatterplot()。正如 清单 7.3 中的示例所示,scatterplot() 可以呈现多个系列,就像 linechart() 一样。事实上,scatterplot() 和 linechart() 的 API 实际上是相同的,您可以通过比较 清单 7.2 和 清单 7.3 来了解。清单 7.3 创建的散点图显示在 图 7.2 中。
图 7.2. 包含两个系列的散点图,使用 清单 7.3 中的代码制作。

清单 7.3. 使用 tfvis.render.scatterplot() 制作散点图
values = [ ***1***
[{x: 20, y: 40}, {x: 32, y: 0}, {x: 5, y: 52}, {x: 12, y: -6}], ***1***
[{x: 15, y: 35}, {x: 0, y: 9}, {x: 7, y: 28}, {x: 16, y: 8}] ***1***
]; ***1***
series = ['My scatter series 1', 'My scatter series 2'];
tfvis.render.scatterplot(
document.getElementById('plot4'),
{values, series},
{
width: 400,
xLabel: 'My x-values', ***2***
yLabel: 'My y-values' ***2***
});
-
1 与 linechart() 一样,使用 x-y 对数组的数组来在散点图中显示多个系列
-
2 记得始终标记你的轴。
柱状图
如其名称所示,柱状图 使用柱形显示数量的大小。这些柱通常从底部的零开始,以便可以从柱形的相对高度读取数量之间的比率。因此,当数量之间的比率很重要时,柱状图是一个不错的选择。例如,自然而然地使用柱状图来显示公司几年来的年收入。在这种情况下,柱形的相对高度使观众对收入在季度之间的变化情况有直观的感觉。这使得柱状图与折线图和散点图有所不同,因为这些值不一定“锚定”在零点上。
要使用tfvis.render创建条形图,请使用barchart()。您可以在代码清单 7.4 中找到一个示例。代码创建的条形图显示在图 7.3 中。barchart()的 API 类似于linechart()和scatterplot()的 API。但是,应该注意一个重要的区别。传递给barchart()的第一个参数不是由value字段组成的对象。相反,它是一个简单的索引-值对数组。水平值不是用一个叫做x的字段指定的,而是用一个叫做index的字段指定的。同样,垂直值不是用一个叫做y的字段指定的,而是与一个叫做value的字段关联的。为什么有这种区别?这是因为条形图中条形的水平值不一定是一个数字。相反,它们可以是字符串或数字,正如我们在图 7.3 的示例中所示。
图 7.3. 由代码清单 7.4 生成的包含字符串和数字命名条的条形图

代码清单 7.4. 使用tfvis.render.barchart()创建条形图
const data = [
{index: 'foo', value: 1},{index: 'bar', value: 7}, ***1***
{index: 3, value: 3}, ***1***
{index: 5, value: 6}]; ***1***
tfvis.render.barchart(document.getElementById('plot5'), data, {
yLabel: 'My value',
width: 400
});
- 1 请注意条形图的索引可以是数字或字符串。请注意元素的顺序很重要。
直方图
先前描述的三种图表类型允许您绘制某个数量的值。有时,详细的定量值并不像值的分布那样重要。例如,考虑一位经济学家查看国家普查结果中的年度家庭收入数据。对于经济学家来说,详细的收入数值并不是最有趣的信息。它们包含了太多信息(是的,有时候太多信息可能是一件坏事!)。相反,经济学家想要更简洁的收入数值摘要。他们对这些值是如何分布感兴趣——即有多少个值低于 2 万美元,有多少个值介于 2 万美元和 4 万美元之间,或者介于 4 万美元和 6 万美元之间,等等。直方图是一种适合这种可视化任务的图表类型。
直方图将值分配到区间中。每个区间只是一个值的连续范围,有一个下界和一个上界。区间被选择为相邻的,以覆盖所有可能的值。在前面的例子中,经济学家可能使用诸如 0 ~ 20k、20k ~ 40k、40k ~ 60k 等的区间。一旦选择了这样一组N个区间,您就可以编写一个程序来计算落入每个区间的单个数据点的数量。执行此程序将给您N个数字(每个区间一个)。然后,您可以使用垂直条形图绘制这些数字。这就给您一个直方图。
tfvis.render.histogram() 会为您执行所有这些步骤。这样可以省去您确定箱界限并按箱计数示例的麻烦。要调用 histogram(),只需传递一个数字数组,如下面的列表所示。这些数字不需要按任何顺序排序。
第 7.5 节。使用 tfvis.render.histogram() 可视化值分布。
const data = [1, 5, 5, 5, 5, 10, -3, -3];
tfvis.render.histogram(document.getElementById('plot6'), data, { ***1***
width: 400 ***1***
}); ***1***
// Histogram: with custom number of bins.
// Note that the data is the same as above.
tfvis.render.histogram(document.getElementById('plot7'), data, {
maxBins: 3, ***2***
width: 400
});
-
1 使用自动生成的箱。
-
2 指定了明确的箱数。
在 列表 7.5 中,有两个略有不同的 histogram() 调用。第一个调用除了绘图宽度之外没有指定任何自定义选项。在这种情况下,histogram() 使用其内置的启发式方法来计算箱。结果是七个箱:–4 ~ –2,–2 ~ 0,0 ~ 2,...,8 ~ 10,如图 7.4 的左面板所示。在这七个箱中,直方图显示在 4 ~ 6 箱中具有最高值,其中包含 4 个计数,因为数据数组中的四个值为 5。直方图的三个箱(–2 ~ 0,2 ~ 4 和 6 ~ 8)的值为零,因为数据点的元素都没有落入这三个箱中。
图 7.4。相同数据的直方图,使用自动计算的箱(左)和明确指定的箱数(右)绘制。生成这些直方图的代码在 列表 7.5 中。

因此,我们可以认为默认的启发式方法对于我们特定的数据点来说生成了太多的箱。如果箱数较少,那么不太可能会有任何箱是空的。您可以使用配置字段 maxBins 来覆盖默认的箱子启发式方法并限制箱子数量。这就是列表 7.5 中第二个 histogram() 调用所做的,其结果在图 7.4 中右侧显示。您可以看到通过将箱数限制为三个,所有箱都变得非空。
热图
热图 将数字的 2D 数组显示为彩色单元格的网格。每个单元格的颜色反映了 2D 数组元素的相对大小。传统上,“较冷”的颜色(如蓝色和绿色)用于表示较低的值,而“较暖”的颜色(如橙色和红色)则用于表示较高的值。这就是为什么这些图被称为热图。在深度学习中最常见的热图例子可能是混淆矩阵(参见第三章中的鸢尾花示例)和注意力矩阵(参见第九章中的日期转换示例)。tfjs-vis 提供了函数 tfvis.render.heatmap() 来支持此类可视化的渲染。
列表 7.6 展示了如何制作一个热图来可视化涉及三个类别的虚构混淆矩阵。混淆矩阵的值在第二个输入参数的 values 字段中指定。类别的名称,用于标记热图的列和行,是作为 xTickLabels 和 yTickLabels 指定的。不要将这些刻度标签与第三个参数中的 xLabel 和 yLabel 混淆,后者用于标记整个 x 和 y 轴。图 7.5 展示了生成的热图绘图。
图 7.5. 由 列表 7.6 中的代码渲染的热图。它展示了一个涉及三个类别的虚构混淆矩阵。

列表 7.6. 使用 tfvis.render.heatmap() 可视化 2D 张量
tfvis.render.heatmap(document.getElementById('plot8'), {
values: [[1, 0, 0], [0, 0.3, 0.7], [0, 0.7, 0.3]], ***1***
xTickLabels: ['Apple', 'Orange', 'Tangerine'], ***2***
yTickLabels: ['Apple', 'Orange', 'Tangerine'] ***2***
}, {
width: 500,
height: 300,
xLabel: 'Actual Fruit', ***3***
yLabel: 'Recognized Fruit', ***3***
colorMap: 'blues' ***4***
});
-
1 传递给 heatmap() 的值可以是嵌套的 JavaScript 数组(如此处所示)或 2D tf.Tensor。
-
2 xTickLabels 用于标记沿 x 轴的单个列。不要与 xLabel 混淆。同样,yTickLabels 用于标记沿 y 轴的单个行。
-
3 xLabel 和 yLabel 用于标记整个坐标轴,不同于 xTickLabel 和 yTickLabel。
-
4 除了这里展示的“蓝色”色图外,还有“灰度”和“翠绿”。
这就是我们对 tfvis.render 支持的四种主要图表类型的快速介绍。如果你未来的工作涉及使用 tfjs-vis 进行数据可视化,很有可能会经常使用这些图表。表 7.1 提供了图表类型的简要摘要,以帮助您决定在给定的可视化任务中使用哪种图表。
表 7.1. tfjs-vis 在 tfvis.render 命名空间下支持的五种主要图表类型的摘要
| 图表名称 | tfjs-vis 中对应的函数 | 适合的可视化任务和机器学习示例 |
|---|---|---|
| 折线图 | tfvis.render.linechart() | 一个标量(y 值)随另一个具有固有顺序(时间、剂量等)的标量(x 值)变化。多个系列可以在同一坐标轴上绘制:例如,来自训练集和验证集的指标,每个指标都根据训练轮次数量绘制。 |
| 散点图 | tfvis.render.scatterplot() | x-y 标量值对,没有固有的顺序,例如 CSV 数据集的两个数值列之间的关系。多个系列可以在同一坐标轴上绘制。 |
| 条形图 | tfvis.render.barchart() | 一组属于少数类别的值,例如几个模型在相同分类问题上实现的准确率(以百分比数字表示)。 |
| 直方图 | tfvis.render.histogram() | 分布的主要兴趣是一组值,例如密集层内核中参数值的分布。 |
| 热力图 | tfvis.render.heathmap() | 一种二维数字数组,以 2D 网格单元格的形式进行可视化,每个元素的颜色用于反映对应值的大小:例如,多类别分类器的混淆矩阵(3.3 节);序列到序列模型的注意力矩阵(9.3 节)。 |
7.1.2. 一个综合案例研究:使用 tfjs-vis 可视化天气数据
上一节的 CodePen 示例使用的是小型的手动编码数据。在本节中,我们将展示如何在更大更有趣的真实数据集上使用 tfjs-vis 的图表功能。这将展示出 API 的真正强大之处,并且为在浏览器中进行数据可视化的价值提供论据。这个示例还将突出一些在解决实际问题时可能遇到的微妙之处和陷阱。
我们将使用的数据是 Jena-weather-archive 数据集。它包括在德国耶拿(Jena)地区的一个位置上使用各种气象仪器收集的数据,涵盖了八年的时间(2009 年至 2017 年)。可以从 Kaggle 页面上下载该数据集(参见www.kaggle.com/pankrzysiu/weather-archive-jena),它以一个 42MB 的 CSV 文件的形式提供。它包含 15 列,第一列是时间戳,其余列是气象数据,如温度(T deg(C))、气压(p (mbar))、相对湿度(rh (%s))、风速(wv (m/s))等。如果你检查时间戳,你会发现它们之间有 10 分钟的间隔,反映出测量是每隔 10 分钟进行一次。这是一个丰富的数据集,可以进行可视化、探索和尝试机器学习。在接下来的章节中,我们将尝试使用不同的机器学习模型进行天气预报。特别是,我们将使用前 10 天的天气数据来预测第二天的温度。但在我们开始这个令人兴奋的天气预测任务之前,让我们遵循“在尝试机器学习之前,始终查看数据”的原则,看看 tfjs-vis 如何以清晰直观的方式绘制数据。
要下载和运行 Jena-weather 示例,请使用以下命令:
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/jena-weather
yarn
yarn watch
限制数据量以进行高效有效的可视化
Jena-weather 数据集相当大。文件大小为 42MB,比迄今为止本书中看到的所有 CSV 或表格数据集都要大。这导致了两个挑战:
-
第一个挑战是对计算机而言:如果一次绘制八年的所有数据,浏览器选项卡将耗尽资源,变得无响应,并可能崩溃。即使你仅限制在 14 列中的 1 列,仍然有大约 42 万个数据点需要显示。这比 tfjs-vis(或任何 JavaScript 绘图库)能够安全渲染的量要多。
-
第二个挑战是对用户而言:一次查看大量数据并从中提取有用信息是困难的。例如,有人应该如何查看所有 420,000 个数据点并从中提取有用信息?就像计算机一样,人类大脑的信息处理带宽是有限的。可视化设计师的工作是以高效的方式呈现数据的最相关和最有信息量的方面。
我们使用三种技巧来解决这些挑战:
-
我们不是一次性绘制整个八年的数据,而是让用户使用交互式用户界面选择要绘制的时间范围。这就是用户界面中时间跨度下拉菜单的目的(请参见 图 7.6 和 7.7 中的截屏)。时间跨度选项包括 Day、Week、10 Days、Month、Year 和 Full。最后一个对应于整个八年。对于任何其他时间跨度,用户界面允许用户在时间上前后移动。这就是左箭头和右箭头按钮的作用。
图 7.6. 展示了 Jena 气象档案数据集中温度(
T(degC))和气压(p(mbar))的折线图,分别以两种不同的时间尺度绘制。顶部:10 天时间跨度。注意温度曲线中的日常周期。底部:1 年时间跨度。注意温度曲线中的年度周期以及春季和夏季期间气压相对其他季节更稳定的轻微倾向。![]()
图 7.7. Jena 气象演示的散点图示例。该图显示了空气密度(rho,纵轴)和温度(T,横轴)之间的关系,时间跨度为 10 天,可以看到负相关性。
![]()
-
对于任何超过一周的时间跨度,我们在将时间序列绘制到屏幕上之前进行降采样。例如,考虑时间跨度为一个月(30 天)。这个时间跨度的完整数据包含约 30 * 24 * 6 = 4.32k 个数据点。在 清单 7.7 中的代码中,您可以看到我们在显示一个月的数据时仅绘制每六个数据点。这将绘制的数据点数量减少到 0.72k,大大降低了渲染成本。但对于人眼来说,数据点数量的六倍减少几乎没有什么差别。
-
与我们在时间跨度下拉菜单中所做的类似,我们在用户界面中包含一个下拉菜单,以便用户可以选择在任何给定时间绘制什么天气数据。注意标有 Data Series 1 和 Data Series 2 的下拉菜单。通过使用它们,用户可以在同一坐标轴上将任何 1 或任何 2 个 14 列中的数据作为折线图绘制到屏幕上。
7.7 节的示例展示了负责制作与图 7.6 类似的图表的代码。尽管代码调用了tfvis.render.linechart(),与前一节中的 CodePen 示例相似,但与前面列表中的代码相比,它要抽象得多。这是因为在我们的网页中,我们需要根据 UI 状态延迟决定要绘制的数量。
7.7 节。Jena 天气数据作为多系列折线图(在 jena-weather/index.js 中)
function makeTimeSerieChart(
series1, series2, timeSpan, normalize, chartContainer) {
const values = [];
const series = [];
const includeTime = true;
if (series1 !== 'None') {
values.push(jenaWeatherData.getColumnData( ***1***
series1, includeTime, normalize, currBeginIndex,
TIME_SPAN_RANGE_MAP[timeSpan], ***2***
TIME_SPAN_STRIDE_MAP[timeSpan])); ***3***
series.push(normalize ? `${series1} (normalized)` : series1);
}
if (series2 !== 'None') { ***4***
values.push(jenaWeatherData.getColumnData(
series2, includeTime, normalize, currBeginIndex,
TIME_SPAN_RANGE_MAP[timeSpan],
TIME_SPAN_STRIDE_MAP[timeSpan]));
series.push(normalize ? `${series2} (normalized)` : series2);
}
tfvis.render.linechart({values, series: series}, chartContainer, {
width: chartContainer.offsetWidth * 0.95,
height: chartContainer.offsetWidth * 0.3,
xLabel: 'Time', ***5***
yLabel: series.length === 1 ? series[0] : ''
});
}
-
1 jenaWeatherData 是一个帮助我们组织和检索来自 CSV 文件的天气数据的对象。请参阅 jena-weather/data.js。
-
2 指定可视化的时间跨度
-
3 选择适当的步幅(降采样因子)
-
4 利用了 tfjs-vis 的折线图支持多系列的特性。
-
5 总是标记轴。
鼓励您探索数据可视化界面。它包含许多有趣的天气模式,您可以发现。例如,图 7.6 的顶部面板显示了在 10 天内温度(T (degC))和标准化气压(p (mbar))是如何变化的。在温度曲线中,您可以看到一个明显的日循环:温度倾向于在中午左右达到峰值,并在午夜后不久达到最低点。在日循环之上,您还可以看到在这 10 天期间的一个更全局的趋势(逐渐增加)。相比之下,气压曲线在这个时间尺度上没有显示出明显的模式。同一图的底部面板显示了一年时间跨度内的相同测量值。在那里,您可以看到温度的年循环:它在八月左右达到峰值,并在一月左右达到最低点。气压再次显示出一个不太清晰的模式,比起温度,在这个时间尺度上。压力在整个年份内可能以一种略微混沌的方式变化,尽管在夏季周围,似乎有一个较少变化的倾向,而在冬季则相反。通过在不同的时间尺度上查看相同的测量值,我们可以注意到各种有趣的模式。如果我们只看数字 CSV 格式的原始数据,所有这些模式几乎是不可能注意到的。
在图 7.6 中的图表中,你可能已经注意到它们显示的是温度和气压的归一化值,而不是它们的绝对值,这是因为我们在生成这些图表时勾选了 UI 中的“Normalize Data”复选框。我们在第二章中讨论波士顿房价模型时简单提到了归一化。那里的归一化涉及将平均值减去,然后除以标准差的结果。我们这里进行的归一化完全相同。然而,这不仅仅是为了我们机器学习模型的准确性(下一节将介绍),还是为了可视化。为什么呢?如果你尝试在图表显示温度和气压时取消勾选“Normalize Data”复选框,你会立即看到原因。温度测量值的范围在-10 到 40 之间(摄氏度),而气压的范围在 980 到 1,000 之间。在没有归一化的情况下,具有非常不同范围的两个变量会导致 y 轴扩展到非常大的范围,使得两条曲线看起来基本上是平的,并且只有微小的变化。通过归一化,可以避免这个问题,将所有测量值映射到零平均值和单位标准差的分布。
图 7.7 展示了一个将两个气象测量值绘制为散点图的示例,你可以通过勾选“Plot Against Each Other”复选框并确保两个“Data Series”下拉菜单都不是“None”来激活此模式。制作这样的散点图的代码与清单 7.7 中的makeTimeSerieChart()函数相似,因此这里为了简洁起见省略了。如果你对细节感兴趣,可以在相同的文件(jena-weather/index.js)中进行研究。
这个示例散点图展示了归一化空气密度(y 轴)和归一化温度(x 轴)之间的关系。在这里,你可以发现两个变量之间存在较强的负相关性:随着温度的升高,空气密度将降低。这个示例图表使用了 10 天的时间跨度,但你可以验证这种趋势在其他时间跨度下也基本保持不变。这种变量之间的相关性可以通过散点图轻松地可视化,但只通过文本格式的数据很难发现。这再次展示了数据可视化的强大价值。
7.2. 训练后的模型可视化
在之前的章节中,我们展示了可视化对数据的有用之处。在本节中,我们将展示如何在模型训练后可视化模型的各个方面,以获得有用的洞察力。为此,我们将主要关注以图像为输入的卷积神经网络(convnet),因为它们被广泛使用且产生有趣的可视化结果。
你可能听说过深度神经网络是“黑盒子”。不要让这个说法让你误以为在推理或训练神经网络时很难从内部获取任何信息。相反,查看 TensorFlow.js 中编写的模型的每个层在内部做了什么是相当容易的。此外,就卷积神经网络而言,它们学习的内部表示非常适合可视化,主要是因为它们是视觉概念的表示。自 2013 年以来,已经开发了各种各样的技术来可视化和解释这些表示。由于涵盖所有有趣的技术是不切实际的,我们将介绍三种最基本和最有用的技术:
³
这个说法实际上意味着,深度神经网络中发生的大量数学运算,即使可以访问,也比起某些其他类型的机器学习算法,如决策树和逻辑回归,更难以用 layperson 的术语描述。例如,对于决策树,你可以逐个沿着分支点走下去,并解释为什么选择了某个分支,通过用一句简单的句子如“因为因子 X 大于 0.35”来用语言化的方式解释原因。这个问题被称为模型可解释性,与我们在本节中涵盖的内容不同。
-
可视化 convnet 中间层(中间激活)的输出 —— 这有助于理解连续 convnet 层如何转换其输入,并且可以初步了解单个 convnet 滤波器学习的视觉特征。
-
通过找到最大化激活它们的输入图像来可视化 convnet 滤波器 —— 这有助于理解每个滤波器对哪种视觉模式或概念敏感。
-
可视化输入图像中类激活的热图 —— 这有助于理解输入图像的哪些部分在导致 convnet 生成最终分类结果时起着最重要的作用,这也可以有助于解释 convnet 如何达到其输出和“调试”不正确的输出。
我们将使用的代码来展示这些技术是来自 tfjs-examples 仓库的 visualize-convnet 示例。要运行示例,请使用以下命令:
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/visualize-convnet
yarn && yarn visualize
yarn visualize命令与您在先前示例中看到的yarn watch命令不同。除了构建和启动网页之外,它还在浏览器外执行一些额外的步骤。首先,它安装一些所需的 Python 库,然后下载并转换 VGG16 模型(一个知名且广泛使用的深度卷积网络)为 TensorFlow.js 格式。VGG16 模型已经在大规模的 ImageNet 数据集上进行了预训练,并作为 Keras 应用程序提供。一旦模型转换完成,yarn visualize在 tfjs-node 中对转换后的模型进行一系列分析。为什么这些步骤在 Node.js 中而不是浏览器中执行?因为 VGG16 是一个相对较大的卷积网络。^([4]) 因此,其中的一些步骤计算量很大,在 Node.js 中的资源限制较少的环境中运行得更快。如果您使用 tfjs-node-gpu 而不是默认的 tfjs-node,计算速度可以进一步加快(这需要具有所需驱动程序和库的 CUDA 启用 GPU;请参阅附录 A):
⁴
要了解 VGG16 有多大的概念,请意识到其总重量大小为 528 MB,而 MobileNet 的重量大小小于 10MB。
yarn visualize --gpu
一旦在 Node.js 中完成了计算密集的步骤,它们将生成一组图像文件在 dist/folder 中。作为最后一步,yarn visualize将编译并启动一个 Web 服务器,用于一组静态 Web 文件,包括那些图像,除了在浏览器中打开索引页。
yarn visualize命令包含一些额外可配置的标志。例如,默认情况下,它对感兴趣的每个卷积层执行八个过滤器的计算和可视化。您可以使用--filters标志更改过滤器的数量:例如,yarn visualize --filters 32。此外,yarn visualize使用的默认输入图像是随源代码提供的 cat.jpg 图像。您可以使用--image标志使用其他图像文件。^([5]) 现在让我们基于 cat.jpg 图像和 32 个过滤器查看可视化结果。
⁵
最常见的图像格式,包括 JPEG 和 PNG,都受支持。
7.2.1. 可视化卷积神经网络内部激活
在这里,我们计算并显示了给定输入图像的 VGG16 模型的各种卷积层生成的特征图。这些特征图被称为内部激活,因为它们不是模型的最终输出(模型的最终输出是一个长度为 1,000 的向量,表示 1,000 个 ImageNet 类别的概率分数)。相反,它们是模型计算的中间步骤。这些内部激活使我们能够了解输入是如何被网络学习的不同特征分解的。
回顾第四章,卷积层的输出具有 NHWC 形状[numExamples, height, width, channels]。在这里,我们正在处理单个输入图像,因此numExamples为 1。我们想要可视化每个卷积层输出的剩余三个维度:高度、宽度和通道。卷积层输出的高度和宽度由其滤波器大小、填充、步长以及图层输入的高度和宽度确定。一般来说,随着深入到卷积神经网络中,它们会变得越来越小。另一方面,随着深入,channels的值通常会变得越来越大,因为卷积神经网络通过一系列层的表示转换逐渐提取越来越多的特征。卷积层的这些通道不能解释为不同的颜色分量。相反,它们是学习到的特征维度。这就是为什么我们的可视化将它们分成单独的面板并以灰度绘制的原因。图 7.8 展示了给定 cat.jpg 输入图像的 VGG16 的五个卷积层的激活。
图 7.8。VGG16 对 cat.jpg 图像执行推理的几个卷积层的内部激活。左侧显示原始输入图像,以及模型输出的前三个类别和它们关联的概率分数。可视化的五个层分别是命名为block1_conv1、block2_conv1、block3_conv2、block4_conv2和block5_conv3的层。它们按照在 VGG16 模型中的深度从顶部到底部的顺序排序。也就是说,block1_conv1是最靠近输入层的,而block5_conv1是最靠近输出层的。请注意,出于可视化目的,所有内部激活图像都缩放到相同的大小,尽管由于连续的卷积和池化,后续层的激活具有较小的尺寸(较低的分辨率)。这可以从后续层中的粗略像素模式中看出。

在内部激活中你可能注意到的第一件事是随着网络的深入,它们与原始输入的差异越来越大。较早的层(例如block1_conv1)似乎编码相对简单的视觉特征,例如边缘和颜色。例如,标记为“A”的箭头指向一个似乎响应黄色和粉色的内部激活。标记为“B”的箭头指向一个似乎与输入图像中某些方向的边缘有关的内部激活。
但是,后面的层(比如block4_conv2和block5_conv3)显示出越来越多地与输入图像中简单的像素级特征不相关的激活模式。例如,图 7.8 中标记为“C”的箭头指向block4_ conv2中的一个滤波器,它似乎对猫的面部特征进行编码,包括耳朵、眼睛和鼻子。这是我们在第四章的图 4.6 中用示意图展示的逐渐特征提取的具体示例。但请注意,并非所有后续层中的滤波器都能用简单的方式用语言解释清楚。另一个有趣的观察是,激活图的“稀疏性”也随着层的深度增加而增加:在图 7.8 中显示的第一层中,所有滤波器都被输入图像激活(显示出非常量像素模式);然而,在最后一层中,一些滤波器变为空白(常量像素模式;例如,参见图 7.8 右面板的最后一行)。这意味着由那些空白滤波器编码的特征在这个特定的输入图像中是不存在的。
您刚刚目睹了深度卷积神经网络学习到的表示的一个重要的普遍特征:通过层提取的特征随着层的深度越来越抽象。深层的激活承载着越来越少关于输入细节的信息,越来越多关于目标的信息(在本例中是图像属于 1,000 个 ImageNet 类别中的哪一个)。因此,深度神经网络有效地充当着一个 信息蒸馏管道,原始数据进入并被重复地转换,以便过滤掉任务无关的方面,并逐渐放大和精炼对任务有用的方面。即使我们通过一个卷积神经网络的例子展示了这一点,但这个特征对其他深度神经网络(如 MLPs)也是成立的。
卷积神经网络发现有用的输入图像方面可能与人类视觉系统发现的有用方面不同。卷积神经网络的训练受到数据驱动,因此容易受到训练数据的偏见影响。例如,在本章末尾“进一步阅读和探索材料”部分列出的 Marco Ribeiro 和同事的论文指出了一个案例,在这个案例中,由于背景中有雪的存在,一张狗的图像被误分类为狼,这可能是因为训练图像中包含了狼在雪地背景下的实例,但没有包含类似背景下的狗的实例。
通过可视化深度卷积神经网络的内部激活模式,我们获得了这些有用的见解。下一小节描述了如何在 TensorFlow.js 中编写代码来提取这些内部激活。
深入了解如何提取内部激活
提取内部激活的步骤封装在 writeInternalActivationAndGetOutput() 函数中(清单 7.8)。它以已经构建或加载的 TensorFlow.js 模型对象和相关层的名称(layerNames)作为输入。关键步骤是创建一个新的模型对象(compositeModel),其中包括指定层的输出和原始模型的输出。 compositeModel 使用 tf.model() API 构建,就像你在 第五章 的 Pac-Man 和简单物体检测示例中看到的一样。关于 compositeModel 的好处在于它的 predict() 方法返回所有层的激活,以及模型的最终预测(参见名为 outputs 的 const)。清单 7.8 中的其余代码(来自 visualize-convnet/main.js)是关于将层的输出拆分为单独的滤波器并将它们写入磁盘文件的更加平凡的任务。
清单 7.8. 在 Node.js 中计算卷积神经网络的内部激活
async function writeInternalActivationAndGetOutput(
model, layerNames, inputImage, numFilters, outputDir) {
const layerName2FilePaths = {};
const layerOutputs =
layerNames.map(layerName => model.getLayer(layerName).output);
const compositeModel = tf.model( ***1***
{
inputs: model.input,
outputs: layerOutputs.concat(model.outputs[0])
});
const outputs = compositeModel.predict(inputImage); ***2***
for (let i = 0; i < outputs.length - 1; ++i) {
const layerName = layerNames[i];
const activationTensors = ***3***
tf.split(outputs[i],
outputs[i].shape[outputs[i].shape.length – 1],
-1);
const actualNumFilters = filters <= activationTensors.length ?
numFilters :
activationTensors.length;
const filePaths = [];
for (let j = 0; j < actualNumFilters; ++j) {
const imageTensor = tf.tidy( ***4***
() => deprocessImage(tf.tile(activationTensors[j],
[1, 1, 1, 3])));
const outputFilePath = path.join(
outputDir, `${layerName}_${j + 1}.png`);
filePaths.push(outputFilePath);
await utils.writeImageTensorToFile(imageTensor, outputFilePath);
}
layerName2FilePaths[layerName] = filePaths;
tf.dispose(activationTensors);
}
tf.dispose(outputs.slice(0, outputs.length - 1));
return {modelOutput: outputs[outputs.length - 1], layerName2FilePaths};
}
-
1 构建一个模型,返回所有期望的内部激活,以及原始模型的最终输出
-
2 输出是包含内部激活和最终输出的 tf.Tensor 数组。
-
3 将卷积层的激活按滤波器进行拆分
-
4 格式化激活张量并将其写入磁盘
7.2.2. 可视化卷积层对哪些内容敏感:最大激活图像
另一种说明卷积网络学习内容的方式是找到其各种内部层对哪些输入图像敏感。我们所说的对某个输入图像敏感是指在输入图像下,滤波器输出的最大激活(在其输出高度和宽度维度上取平均)。
我们找到最大激活图像的方式是通过一种将“正常”的神经网络训练过程颠倒过来的技巧。图 7.9 的面板 A 简要显示了当我们使用 tf.Model.fit() 训练神经网络时会发生什么。我们冻结输入数据,并允许模型的权重(例如所有可训练层的核和偏差)通过反向传播从损失函数更新。但是,我们完全可以交换输入和权重的角色:我们可以冻结权重,并允许输入通过反向传播进行更新。同时,我们调整损失函数,使其导致反向传播以一种方式来微调输入,该方式最大化了某个卷积滤波器的输出,当在其高度和宽度维度上平均时。该过程在图 7.9 的面板 B 中示意,被称为输入空间中的梯度上升,与 typica 模型训练的基于权重空间中的梯度下降相对应。实现输入空间中的梯度下降的代码将在下一小节中展示,并可以供感兴趣的读者研究。
⁶
这个图可以看作是图 2.9 的简化版本,我们在第二章中用它来介绍反向传播。
图 7.9. 示意图显示了通过输入空间中的梯度上升找到卷积滤波器的最大激活图像的基本思想(面板 B)以及与基于权重空间中的梯度下降的正常神经网络训练过程(面板 A)不同的地方。请注意,该图与先前显示的某些模型图有所不同,因为它将权重从模型中分离出来。这是为了突出两组可以通过反向传播更新的量:权重和输入。

图 7.10 展示了在 VGG16 模型的四个卷积层上执行梯度上升输入空间过程的结果(与我们用来展示内部激活的相同模型)。与先前的插图一样,图层的深度从图的顶部到底部逐渐增加。从这些最大激活输入图像中可以得到一些有趣的模式:
-
首先,这些是彩色图像,而不是前面部分的灰度内部激活图像。这是因为它们的格式是卷积网络的实际输入:由三个(RGB)通道组成的图像。因此,它们可以显示为彩色。
-
最浅的层(
block1_conv1)对全局颜色值和带有特定方向的边缘等简单模式敏感。 -
中间深度层(如
block2_conv1)对由不同边缘模式组合而成的简单纹理做出最大响应。 -
在较深层的滤波器开始响应更复杂的模式,这些模式在某种程度上与自然图像中的视觉特征(当然是来自 ImageNet 训练数据)相似,例如颗粒、孔洞、彩色条纹、羽毛、波纹等。
图 7.10. VGG16 深度卷积网络四个层的最大激活输入图像。这些图像是通过在输入空间中进行 80 次梯度上升计算得出来的。

一般来说,随着层级的加深,模式从像素级逐渐变得更加复杂和大规模。这反映了深度卷积网络逐层对特征进行提炼,组合出各种模式。在分析同一层的滤波器时,尽管它们具有类似的抽象级别,但在详细模式上存在相当大的变化。这突显了每一层以互补的方式提出了同一输入的多种表示,以捕获尽可能多的有用信息,从而解决网络训练的任务。
深入了解输入空间中的梯度上升
在可视化卷积网络的例子中,在 main.js 中的 inputGradientAscent() 函数中实现了输入空间中的梯度上升的核心逻辑,并且在 列表 7.9 中进行了展示。由于其耗时和占用内存,该代码运行在 Node.js 中。^([7]) 注意,尽管梯度上升在输入空间中的基本思想类似于基于权重空间的梯度下降的模型训练(参见 图 7.10),但我们不能直接重用 tf.Model.fit(),因为该函数专门冻结输入并更新权重。相反,我们需要定义一个自定义函数,该函数计算给定输入图像的“损失”。这就是该行定义的函数
⁷
对于小于 VGG16 的卷积网络(如 MobileNet 和 MobileNetV2),可以在合理的时间内在 Web 浏览器中运行该算法。
const lossFunction = (input) =>
auxModel.apply(input, {training: true}).gather([filterIndex], 3);
这里,auxModel是一个使用熟悉的tf.model()函数创建的辅助模型对象。它具有与原始模型相同的输入,但输出给定卷积层的激活。我们调用辅助模型的apply()方法,以获得层激活的值。apply()类似于predict(),因为它执行模型的前向路径。但是,apply()提供了更细粒度的控制,例如将training选项设置为true,就像代码中前一行所做的那样。如果不将training设置为true,则不可能进行反向传播,因为默认情况下,前向传播会为内存效率而处置中间层激活。training标志中的true值使apply()调用保留这些内部激活,从而启用反向传播。gather()调用提取特定滤波器的激活。这是必要的,因为最大激活输入是根据每个过滤器逐个过滤器计算的,并且即使是相同层的过滤器之间的结果也会有所不同(请参见图 7.10 中的示例结果)。
一旦我们有了自定义损失函数,我们就将其传递给tf.grad(),以便获得一个给出损失相对于输入的梯度的函数:
const gradFunction = tf.grad(lossFunction);
这里要注意的重要事情是,tf.grad()不直接给出梯度值;相反,它会在调用时返回一个函数(在前一行中称为gradFunction),该函数在调用时会返回梯度值。
一旦我们有了这个梯度函数,我们就在一个循环中调用它。在每次迭代中,我们使用它返回的梯度值来更新输入图像。这里的一个重要的不明显的技巧是在将梯度值加到输入图像之前对其进行归一化,这确保了每次迭代中的更新具有一致的大小:
const norm = tf.sqrt(tf.mean(tf.square(grads))).add(EPSILON);
return grads.div(norm);
这个迭代更新输入图像的过程重复执行了 80 次,得到了我们在图 7.10 中展示的结果。
列表 7.9. 输入空间中的梯度上升(在 Node.js 中,来自 visualize-convnet/main.js)
function inputGradientAscent(
model, layerName, filterIndex, iterations = 80) {
return tf.tidy(() => {
const imageH = model.inputs[0].shape[1];
const imageW = model.inputs[0].shape[2];
const imageDepth = model.inputs[0].shape[3];
const layerOutput = model.getLayer(layerName).output;
const auxModel = tf.model({ ***1***
inputs: model.inputs, ***1***
outputs: layerOutput ***1***
});
const lossFunction = (input) => ***2***
auxModel.apply(input, {training: true}).gather([filterIndex], 3); ***2***
const gradFunction = tf.grad(lossFunction); ***3***
let image = tf.randomUniform([1, imageH, imageW, imageDepth], 0, 1) ***4***
.mul(20).add(128); ***4***
for (let i = 0; i < iterations; ++i) {
const scaledGrads = tf.tidy(() => {
const grads = gradFunction(image);
const norm = tf.sqrt(tf.mean(tf.square(grads))).add(EPSILON);
return grads.div(norm); ***5***
});
image = tf.clipByValue(
image.add(scaledGrads), 0, 255); ***6***
}
return deprocessImage(image);
});
}
-
1 为原始模型创建一个辅助模型,其输入与原模型相同,但输出为感兴趣的卷积层
-
2 这个函数计算指定过滤器索引处的卷积层输出的值。
-
3 这个函数计算卷积滤波器输出相对于输入图像的梯度。
-
4 生成一个随机图像作为梯度上升的起始点
-
5 重要技巧:将梯度与梯度的大小(范数)相乘
-
6 执行一步梯度上升:沿着梯度方向更新图像
7.2.3. 卷积神经网络分类结果的视觉解释
我们将介绍的最后一个后训练卷积神经网络可视化技术是类激活映射(CAM)算法。CAM 旨在回答的问题是“输入图像的哪些部分对于导致卷积神经网络输出其顶部分类决策起到最重要的作用?”例如,当将 cat.jpg 图像传递给 VGG16 网络时,我们得到了一个“埃及猫”的顶级类别,概率分数为 0.89。但仅凭图像输入和分类输出,我们无法确定图像的哪些部分对于这个决定是重要的。肯定图像的某些部分(如猫的头部)必须比其他部分(例如白色背景)起到更重要的作用。但是否有一种客观的方法来量化任何输入图像的这一点?
答案是肯定的!有多种方法可以做到这一点,CAM 就是其中之一。^([8])给定一个输入图像和一个卷积神经网络的分类结果,CAM 会给出一个热图,为图像的不同部分分配重要性分数。图 7.11 展示了这样的 CAM 生成的热图叠加在三个输入图像上:一只猫,一只猫头鹰和两只大象。在猫的结果中,我们看到猫头的轮廓在热图中具有最高的值。我们可以事后观察到,这是因为轮廓揭示了动物头部的形状,这是猫的一个独特特征。猫头鹰图像的热图也符合我们的预期,因为它突出显示了动物的头部和翅膀。具有两只大象的图像的结果很有趣,因为该图像与其他两个图像不同,它包含了两只个体动物而不是一只。CAM 生成的热图为图像中的两只大象的头部区域分配了高重要性分数。热图明显倾向于聚焦于动物的鼻子和耳朵,这可能反映了长鼻子的长度和耳朵的大小对于区分非洲象(网络的顶级类别)和印度象(网络的第三类别)的重要性。
⁸
CAM 算法首次描述于 Bolei Zhou 等人的“为判别定位学习深度特征”,2016 年,
cnnlocalization.csail.mit.edu/。另一个知名的方法是局部可解释的模型无关解释(LIME)。见mng.bz/yzpq。
图 7.11。VGG16 深度卷积神经网络的三个输入图像的类激活映射(CAMs)。CAM 热图叠加在原始输入图像上。

CAM 算法的技术方面
CAM 算法虽然强大,但其背后的思想实际上并不复杂。简而言之,CAM 图中的每个像素显示了如果增加该像素值一单位量,获胜类别的概率分数将发生多大变化。下面稍微详细介绍了 CAM 中涉及的步骤:
-
找到卷积神经网络中最后一个(即最深的)卷积层。在 VGG16 中,这一层的名称为
block5_conv3。 -
计算网络输出概率对于获胜类别相对于卷积层输出的梯度。
-
梯度的形状为
[1, h, w, numFilters],其中h、w和numFilters分别是该层的输出高度、宽度和过滤器数量。然后,我们在示例、高度和宽度维度上对梯度进行平均,得到一个形状为[numFilters]的张量。这是一个重要性分数的数组,每个卷积层的过滤器都有一个。 -
将重要性分数张量(形状为
[numFilters])与卷积层的实际输出值(形状为[1, h, w, numFilters])进行乘法运算,并使用广播(参见 附录 B,第 B.2.2 节)。这给我们一个新的张量,形状为[1, h, w, numFilters],是层输出的“重要性缩放”版本。 -
最后,平均重要性缩放的层输出沿最后一维(过滤器)进行,并挤压掉第一维(示例),从而得到一个形状为
[h, w]的灰度图像。该图像中的值是图像中每个部分对于获胜分类结果的重要程度的度量。然而,该图像包含负值,并且比原始输入图像的尺寸要小(例如,在我们的 VGG16 示例中为 14 × 14,而原始输入图像为 224 × 224)。因此,我们将负值归零,并在覆盖输入图像之前对图像进行上采样。
详细代码位于 visualize-convnet/main.js 中名为 gradClassActivationMap() 的函数中。尽管该函数默认在 Node.js 中运行,但它所涉及的计算量明显少于前一节中我们看到的在输入空间中进行梯度上升的算法。因此,您应该能够在浏览器中使用相同的代码运行 CAM 算法,并且速度可接受。
在本章中,我们讨论了两个问题:在训练机器学习模型之前如何可视化数据,以及在训练完成后如何可视化模型。我们有意地跳过了其中一个重要步骤——也就是在模型训练过程中对模型进行可视化。这将成为下一章的重点。我们之所以单独提出训练过程,是因为它与欠拟合和过拟合的概念和现象有关,对于任何监督学习任务来说,这些概念和现象都是至关重要的,因此值得特别对待。通过可视化,我们可以更容易地发现和纠正欠拟合和过拟合问题。在下一章中,我们将重新讨论在本章第一部分介绍的 tfjs-vis 库,并了解到它不仅可以用于数据可视化,还可以显示模型训练的进展情况。
进一步阅读和探索材料
-
Marco Tulio Ribeiro, Sameer Singh, and Carlos Guestrin,“为什么我应该相信你?解释任何分类器的预测”,2016 年,
arxiv.org/pdf/1602.04938.pdf。 -
TensorSpace (tensorspace.org) 使用动画 3D 图形在浏览器中可视化卷积神经网络的拓扑和内部激活。它构建在 TensorFlow.js、three.js 和 tween.js 之上。
-
TensorFlow.js tSNE 库 (github.com/tensorflow/tfjs-tsne) 是基于 WebGL 的 t-distributed Stochastic Neighbor Embedding (tSNE) 算法的高效实现。它可以帮助您将高维数据集投影到 2D 空间中,同时保留数据中的重要结构。
练习
-
尝试使用
tfjs.vis.linechart()的以下功能:-
修改 列表 7.2 中的代码,看看当要绘制的两个系列具有不同的 x 坐标值集合时会发生什么。例如,尝试将第一个系列的 x 坐标值设置为 1、3、5 和 7,将第二个系列的 x 坐标值设置为 2、4、6 和 8。您可以从
codepen.io/tfjs-book/pen/BvzMZr上分叉并修改 CodePen。 -
在示例 CodePen 中的线图中,所有的数据系列都是由没有重复 x 坐标值的数据点组成的。了解一下
linechart()函数如何处理具有相同 x 坐标值的数据点。例如,在数据系列中,包括两个具有相同 x 值(例如-5 和 5)的数据点。
-
-
在 "visualize-convnet" 的例子中,使用
yarn visualize命令的--image标志来指定自己的输入图片。由于我们在第 7.2 节中仅使用了动物图片,请尝试探索其他类型的图片内容,例如人物、车辆、家居物品和自然风景。看看你能从内部激活和 CAM 中获得什么有用的见解。 -
在我们计算 VGG16 的 CAM 的示例中,我们计算了相对于最后一个卷积层输出的 胜利 类别的概率分数的梯度。如果我们计算 非胜利 类别(例如较低概率的类别)的梯度会怎样?我们应该期望生成的 CAM 图像 不 强调属于图像实际主题的关键部分。通过修改 visualize-convnet 示例的代码并重新运行确认这一点。具体来说,梯度将计算的类索引作为参数传递给
gradClassActivationMap()函数在 visualize-convnet/cam.js 中。该函数在 visualize-convnet/main.js 中调用。
摘要
-
我们学习了 tfjs-vis 的基本用法,这是一个与 TensorFlow.js 紧密集成的可视化库。它可以用于在浏览器中呈现基本类型的图表。
-
数据可视化是机器学习不可或缺的一部分。对数据进行高效有效的呈现可以揭示模式并提供否则难以获得的见解,正如我们通过使用 Jena-weather-archive 数据所展示的那样。
-
丰富的模式和见解可以从训练好的神经网络中提取出来。我们展示了
-
可视化深度卷积网络的内部层激活。
-
计算哪些层对最大程度响应。
-
确定输入图像的哪些部分与 convnet 的分类决策最相关。这些帮助我们了解 convnet 学到了什么以及在推断过程中它是如何运作的。
-
第八章:欠拟合、过拟合和机器学习的通用工作流程
本章内容
-
为什么可视化模型训练过程很重要,以及要注意的重要事项
-
如何可视化和理解欠拟合和过拟合
-
处理过拟合的主要方式:正则化,以及如何可视化其效果
-
机器学习的通用工作流程是什么,包括哪些步骤,以及为什么它是指导所有监督式机器学习任务的重要配方
在上一章中,您学习了如何使用 tfjs-vis 在开始设计和训练机器学习模型之前可视化数据。本章将从那一章结束的地方开始,并描述 tfjs-vis 如何用于在模型训练过程中可视化模型的结构和指标。这样做的最重要目标是发现 欠拟合 和 过拟合 这两个至关重要的现象。一旦我们能够发现它们,我们将深入研究如何解决它们以及如何使用可视化验证我们的解决方法是否有效。
8.1。温度预测问题的制定
为了演示欠拟合和过拟合,我们需要一个具体的机器学习问题。我们将使用的问题是根据您在上一章中刚刚看到的 Jena-weather 数据集来预测温度。第 7.1 节展示了在浏览器中可视化数据的威力以及使用 Jena-weather 数据集进行此操作的好处。希望您通过在前一节中玩弄可视化 UI 来形成对数据集的直觉。我们现在准备好开始对数据集应用一些机器学习了。但首先,我们需要定义问题。
预测任务可以被看作是一个玩具天气预报问题。我们试图预测的是在某一时刻之后 24 小时的温度。我们试图使用在此前 10 天内进行的 14 种天气测量来进行此预测。
虽然问题定义很简单,但我们从 CSV 文件生成训练数据的方式需要进行一些仔细的解释,因为它与此前在本书中看到的问题的数据生成过程有所不同。在那些问题中,原始数据文件中的每一行都对应一个训练样例。这就是鸢尾花、波士顿房价和钓鱼检测示例的工作方式(见第二章和第三章)。然而,在这个问题中,每个示例是通过从 CSV 文件中对多行进行采样和组合而形成的。这是因为温度预测不仅仅是通过查看某一时刻的数据来进行的,而是通过查看一段时间内的数据来进行的。请参见图 8.1 以了解示例生成过程的示意图。
图 8.1. 示意图显示了如何从表格数据中生成单个训练样本。为了生成示例的特征张量,从 CSV 文件中每隔step行采样一次(例如,step = 6),直到采样到timeSteps行为止(例如,timeSteps = 240)。这形成了一个形状为[timeSteps, numFeatures]的张量,其中numFeatures(默认为 14)是 CSV 文件中特征列的数量。为了生成目标,从进入特征张量的最后一行后延迟(例如,144)步采样温度(T)值。可以通过从 CSV 文件的不同行开始来生成其他示例,但它们遵循相同的规则。这构成了温度预测问题:给定某一段时间(例如,10 天)内的 14 个天气测量值,预测从现在开始的一定延迟(例如,24 小时)内的温度。在jena-weather/data.js中的getNextBatchFunction()函数中实现了此图中所示的代码。

为了生成训练示例的特征,我们在 10 天的时间跨度内对一组行进行采样。我们不使用这 10 天内的所有数据行,而是每隔六行进行一次采样。为什么?有两个原因。首先,对所有行进行采样会给我们带来六倍的数据,并导致更大的模型大小和更长的训练时间。其次,以 1 小时为时间尺度的数据存在很多冗余性(6 小时前的气压通常接近于 6 小时零 10 分钟前的气压)。通过丢弃五分之一的数据,我们可以获得一个更轻量级和性能更好的模型,而不会牺牲太多的预测能力。采样的行被合并成了一个 2D 特征张量,形状为[timeSteps, numFeatures],用于我们的训练示例(参见图 8.1)。默认情况下,timeSteps的值为 240,对应于在 10 天期间均匀分布的 240 个采样时间。numFeatures为 14,对应于 CSV 数据集中可用的 14 个气象仪读数。
获取训练示例的目标更容易:我们只需从进入特征张量的最后一行向前移动一定的时间延迟,并从温度列中提取值。图 8.1 显示了仅生成单个训练示例的方式。要生成多个训练示例,我们只需从 CSV 文件的不同行开始。
您可能已经注意到我们温度预测问题的特征张量(参见图 8.1)有些奇怪:在所有以前的问题中,单个示例的特征张量是 1D 的,当多个示例被批处理时,会得到 2D 张量。然而,在这个问题中,单个示例的特征张量已经是 2D 的,这意味着当我们将多个示例组合成批处理时,我们将获得一个 3D 张量(形状为[batchSize, timeSteps, numFeatures])。这是一个敏锐的观察!2D 特征张量形状源于特征来自一系列事件的事实。特别是,它们是在 240 个时间点上采集的天气测量值。这将此问题与到目前为止您所看到的所有其他问题区分开来,其中给定示例的输入特征不涵盖多个时间点,无论是鸢尾花问题中的花大小测量还是 MNIST 图像中的 28×28 像素值。[¹]
¹
在第四章的语音命令识别问题实际上涉及到一系列事件:即形成频谱图的连续音频帧。然而,我们的方法论将整个频谱图视为图像,从而通过将其视为空间维度来忽略了问题的时间维度。
这是本书中你第一次遇到顺序输入数据。在下一章中,我们将深入探讨如何在 TensorFlow.js 中构建专业化和更强大的模型(RNNs)来处理顺序数据。但在这里,我们将使用我们已经了解的两种模型来解决问题:线性回归器和 MLPs。这为我们学习 RNNs 铺平了道路,并为我们提供了可以与更高级模型进行比较的基线。
在 jena-weather/data.js 中实现了图 8.1 所示数据生成过程的实际代码,在函数getNextBatchFunction()下。这是一个有趣的函数,因为它不是返回一个具体的值,而是返回一个包含名为next()的函数的对象。当调用next()函数时,它会返回实际的数据值。具有next()函数的对象称为迭代器。为什么我们使用这种间接方式而不是直接编写迭代器呢?首先,这符合 JavaScript 的生成器/迭代器规范。[²]我们将很快将其传递给tf.data.generator()API,以便为模型训练创建数据集对象。API 需要此函数签名。其次,我们的迭代器需要可配置;返回迭代器的函数是启用配置的一种好方法。
²
请参阅“迭代器和生成器”,MDN web 文档,
mng.bz/RPWK。
您可以从getNextBatchFunction()的签名中看到可能的配置选项:
getNextBatchFunction(
shuffle, lookBack, delay, batchSize, step, minIndex, maxIndex,
normalize,
includeDateTime)
有相当多的可配置参数。例如,您可以使用 lookBack 参数来指定在进行温度预测时要向后查看多长时间段。您还可以使用 delay 参数来指定温度预测将来要做出的时间。minIndex 和 maxIndex 参数允许您指定要从中提取数据的行范围等。
我们通过将 getNextBatchFunction() 函数传递给 tf.data.generator() 函数,将其转换为 tf.data.Dataset 对象。正如我们在第六章中所描述的,当与 tf.Model 对象的 fitDataset() 方法一起使用时,tf.data.Dataset 对象能够使我们即使数据过大而无法一次性装入 WebGL 内存(或任何适用的后备内存类型)也能训练模型。Dataset 对象将仅当即将进入训练时才在 GPU 上创建批量训练数据。这正是我们在这里为温度预测问题所做的。实际上,由于示例的数量和大小过大,我们无法使用普通的 fit() 方法来训练模型。fitDataset() 调用可以在 jena-weather/models.js 中找到,看起来像以下列表。
列表 8.1。使用 tfjs-vis 对基于 fitDataset 的模型进行可视化训练
const trainShuffle = true;
const trainDataset = tf.data.generator( ***1***
() => jenaWeatherData.getNextBatchFunction(
trainShuffle, lookBack, delay, batchSize, step, TRAIN_MIN_ROW,
TRAIN_MAX_ROW, normalize, includeDateTime)).prefetch(8);
const evalShuffle = false;
const valDataset = tf.data.generator( ***2***
() => jenaWeatherData.getNextBatchFunction(
evalShuffle, lookBack, delay, batchSize, step, VAL_MIN_ROW,
VAL_MAX_ROW, normalize, includeDateTime));
await model.fitDataset(trainDataset, {
batchesPerEpoch: 500,
epochs,
callbacks: customCallback,
validationData: valDataset ***3***
});
-
1 第一个 Dataset 对象将生成训练数据。
-
2 第二个 Dataset 对象将生成验证数据。
-
3 用于
fitDataset()的 validationData 配置可以接受 Dataset 对象或一组张量。这里使用了第一个选项。
fitDataset()的配置对象的前两个字段指定了模型训练的时期数量和每个时期抽取的批次数量。正如您在第六章中学到的那样,它们是 fitDataset() 调用的标准配置字段。然而,第三个字段 (callbacks: customCallback) 是新内容。这是我们可视化训练过程的方式。我们的 customCallback 根据模型训练是在浏览器中进行还是(正如我们将在下一章中看到的)在 Node.js 中进行,而取不同的值。
在浏览器中,tfvis.show.fitCallbacks() 函数提供 customCallback 的值。该函数帮助我们通过只需一行 JavaScript 代码在网页中可视化模型训练。它不仅省去了我们访问并跟踪逐批次和逐时期的损失和指标值的所有工作,而且也消除了手动创建和维护将呈现图表的 HTML 元素的需要:
const trainingSurface =
tfvis.visor().surface({tab: modelType, name: 'Model Training'});
const customCallback = tfvis.show.fitCallbacks(trainingSurface,
['loss', 'val_loss'], {
callbacks: ['onBatchEnd', 'onEpochEnd']
}));
fitCallbacks()的第一个参数指定了一个由tfvis.visor().surface()方法创建的渲染区域,这在 tfjs-vis 的术语中被称为visor surface。Visor 是一个容器,可以帮助你方便地组织所有与浏览器机器学习任务相关的可视化内容。在结构上,Visor 有两个层次的层次结构。在较高的层次上,用户可以使用点击来导航一个或多个选项卡。在较低的级别上,每个选项卡都包含一个或多个surfaces。tfvis.visor().surface()方法通过其tab和name配置字段,允许你在指定的 Visor 选项卡上以指定的名称创建一个表面。Visor surface 不仅限于渲染损失和度量曲线。实际上,我们在第 7.1 节的 CodePen 示例中展示的所有基本图表都可以渲染在 visor surfaces 上。我们将在本章末尾留下这个问题作为练习。
fitCallbacks()的第二个参数指定了在 visor surface 上渲染的损失和度量。在这种情况下,我们绘制了训练和验证数据集的损失。第三个参数包含一个字段,控制绘图更新的频率。通过同时使用onBatchEnd和onEpochEnd,我们将在每个批次和每个 epoch 结束时获得更新。在下一节中,我们将检查fitCallbacks()创建的损失曲线,并使用它们来发现欠拟合和过拟合。
8.2. 欠拟合、过拟合和对策
在训练机器学习模型期间,我们希望监控我们的模型在训练数据中捕捉到的模式。一个无法很好地捕捉模式的模型被称为欠拟合;一个捕捉模式过于完美,以至于它学到的内容在新数据上泛化能力较差的模型被称为过拟合。可以通过正则化等对策来使过拟合的模型恢复正常。在本节中,我们将展示可视化如何帮助我们发现这些模型行为以及对策的影响。
8.2.1. 欠拟合
要解决温度预测问题,让我们首先尝试最简单的机器学习模型:线性回归器。清单 8.2(来自 jena-weather/index.js)中的代码创建了这样一个模型。它使用一个具有单个单位和默认线性激活的密集层来生成预测。然而,与我们在第二章中为下载时间预测问题构建的线性回归器相比,此模型多了一个展平层。这是因为这个问题中特征张量的形状是 2D 的,必须被展平为 1D,以满足用于线性回归的密集层的要求。这个展平过程在图 8.2 中有所说明。重要的是要注意,这个展平操作丢弃了关于数据顺序(时间顺序)的信息。
图 8.2. 将形状为[timeSteps, numFeatures]的 2D 特征张量展平为形状为[timeSteps × numFeatures]的 1D 张量,正如清单 8.2 中的线性回归器和清单 8.3 中的 MLP 模型所做的那样

清单 8.2. 为温度预测问题创建一个线性回归模型
function buildLinearRegressionModel(inputShape) {
const model = tf.sequential();
model.add(tf.layers.flatten({inputShape})); ***1***
model.add(tf.layers.dense({units: 1})); ***2***
return model;
}
-
1 将[batchSize, timeSteps, numFeatures]输入形状压平为[batchSize, timeSteps * numFeatures],以应用密集层
-
2 带有默认(线性)激活的单单元密集层是一个线性回归器。
一旦模型构建完成,我们就为训练编译它
model.compile({loss: 'meanAbsoluteError', optimizer: 'rmsprop'});
这里,我们使用损失函数meanAbsoluteError,因为我们的问题是预测一个连续值(标准化温度)。与之前的一些问题不同,没有定义单独的度量标准,因为 MAE 损失函数本身就是人可解释的度量标准。但是,请注意,由于我们正在预测标准化温度,MAE 损失必须乘以温度列的标准差(8.476 摄氏度),以将其转换为绝对误差的预测。例如,如果我们得到的 MAE 为 0.5,那么它就相当于 8.476 * 0.5 = 4.238 摄氏度的预测误差。
在演示界面中,选择模型类型下拉菜单中的线性回归,并单击“训练模型”以开始训练线性回归器。训练开始后,您将立即在页面右侧弹出的“卡片”中看到模型的表格摘要(请参阅图 8.3 中的屏幕截图)。这个模型摘要表在某种程度上类似于model.summary()调用的文本输出,但在 HTML 中以图形方式呈现。创建表的代码如下:
const surface = tfvis.visor().surface({name: 'Model Summary', tab});
tfvis.show.modelSummary(surface, model);
图 8.3. tfjs-vis 可视化线性回归模型的训练。上图:模型的摘要表。下图:20 次训练时的损失曲线。此图是使用tfvis.show .fitCallbacks()创建的(请参阅 jena-weather/index.js)。

创建了表面后,我们通过将表面传递给tfvis.show.modelSummary()来在其中绘制一个模型摘要表,就像前一个代码片段的第二行那样。
在线性回归选项卡的模型摘要部分下,有一个显示模型训练的损失曲线的图表(图 8.3)。它是由我们在上一节中描述的 fitCallbacks() 调用创建的。从图中,我们可以看到线性回归器在温度预测问题上的表现如何。训练损失和验证损失最终都在 0.9 左右波动,这对应于绝对值为 8.476 * 0.9 = 7.6 摄氏度(请记住,8.476 是 CSV 文件中温度列的标准偏差)。这意味着在训练后,我们的线性回归器平均预测误差为 7.6 摄氏度(或 13.7 华氏度)。这些预测相当糟糕。没有人会想要依靠这个模型进行天气预报!这是一个欠拟合的例子。
欠拟合通常是由于使用不足的表示能力(功率)来建模特征-目标关系而导致的。在这个例子中,我们的线性回归器结构太简单,因此无法捕获前 10 天的天气数据与第二天温度之间的关系。为了克服欠拟合,我们通常通过使模型更强大来增加模型的功率。典型的方法包括向模型添加更多的层(具有非线性激活)和增加层的大小(例如,在密集层中的单位数)。所以,让我们向线性回归器添加一个隐藏层,看看我们能从结果 MLP 中获得多少改进。
8.2.2. 过拟合
创建 MLP 模型的函数位于列表 8.3(来自 jena-weather/index.js)。它创建的 MLP 包括两个密集层,一个作为隐藏层,一个作为输出层,另外还有一个扁平层,其作用与线性回归模型中的相同。您可以看到,与 列表 8.2 中的 buildLinearRegressionModel() 相比,该函数有两个额外的参数。特别是,kernelRegularizer 和 dropoutRate 参数是我们稍后将用来对抗过拟合的方法。现在,让我们看看一个不使用 kernelRegularizer 或 dropoutRate 的 MLP 能够达到什么样的预测准确度。
列表 8.3. 为温度预测问题创建 MLP
function buildMLPModel(inputShape, kernelRegularizer, dropoutRate) {
const model = tf.sequential();
model.add(tf.layers.flatten({inputShape}));
model.add(tf.layers.dense({
units: 32,
kernelRegularizer ***1***
activation: 'relu',
}));
if (dropoutRate > 0) {
model.add(tf.layers.dropout({rate: dropoutRate}));
}
model.add(tf.layers.dense({units: 1})); ***2***
return model;
}
-
1 如果由调用者指定,则向隐藏的密集层的内核添加正则化。
-
2 如果由调用者指定,则在隐藏的密集层和输出密集层之间添加一个 dropout 层。
图 8.4 的面板 A 显示了 MLP 的损失曲线。与线性回归器的损失曲线相比,我们可以看到一些重要的区别:
-
训练和验证损失曲线呈现出发散的模式。这与 图 8.3 中的模式不同,其中两个损失曲线呈现出基本一致的趋势。
-
训练损失收敛到比之前低得多的错误。经过 20 个周期的训练,训练损失约为 0.2,对应于误差为 8.476 * 0.2 = 1.7 摄氏度——比线性回归的结果要好得多。
-
然而,验证损失在前两个周期内短暂下降,然后开始缓慢上升。到第 20 个周期结束时,它的值明显高于训练损失(0.35,约为 3 摄氏度)。
图 8.4. 两种不同 MLP 模型在温度预测问题上的损失曲线。面板 A:没有任何正则化的 MLP 模型。面板 B:与面板 A 中模型相同层大小和数量的 MLP 模型,但是具有密集层核的 L2 正则化。请注意,两个面板之间的 y 轴范围略有不同。

相对于之前的结果,训练损失的四倍以上的减少是由于我们的 MLP 比线性回归模型具有更高的能力,这得益于一个更多的层和几倍于线性回归模型的可训练权重参数。然而,增加的模型能力带来了一个副作用:它导致模型在训练数据上拟合得比验证数据(模型在训练过程中没有看到的数据)显着好。这是过拟合的一个例子。这是一种情况,其中模型对训练数据中的不相关细节“过于关注”,以至于模型的预测开始对未见数据的泛化能力变差。
8.2.3. 使用权重正则化减少过拟合并可视化其工作
在 第四章 中,我们通过向模型添加 dropout 层来减少卷积神经网络的过拟合。在这里,让我们看另一种经常使用的减少过拟合的方法:向权重添加正则化。在 Jena-weather 演示 UI 中,如果选择具有 L2 正则化的 MLP 模型,底层代码将通过以下方式调用 buildMLPModel() 来创建 MLP(列表 8.3):
model = buildMLPModel(inputShape, tf.regularizers.l2());
第二个参数——tf.regularizers.l2() 的返回值——是一个 L2 正则化器。通过将上述代码插入 列表 8.3 中的 buildMLPModel() 函数中,您可以看到 L2 正则化器进入隐藏的密集层配置的 kernelRegularizer。这将 L2 正则化器附加到密集层的内核上。当一个权重(例如密集层的内核)有一个附加的正则化器时,我们称该权重是正则化的。同样,当模型的一些或全部权重被正则化时,我们称该模型为正则化的。
正则化器对于稠密层的kernel和它所属的 MLP 有什么作用呢?它会在损失函数中添加一个额外的项。来看看未经正则化的 MLP 的损失如何计算:它简单地定义为目标和模型预测之间的 MAE。伪代码如下:
loss = meanAbsoluteError(targets, predictions)
在加入正则化后,模型的损失函数会包含一个额外的项。伪代码如下:
loss = meanAbsoluteError(targets, prediciton) + 12Rate * 12(kernel)
在这里,l2Rate * l2(kernel)是损失函数中额外的 L2 正则化项。与 MAE 不同,这个项不依赖于模型的预测结果,而是仅与被正则化的kernel(一层的权重)有关。给定kernel的值,它输出一个只与kernel的值相关的数值。可以将这个数值看作是当前kernel值的不理想程度的度量。
现在让我们来看一下 L2 正则化函数l2(kernel)的详细定义:它计算所有权重值的平方和。举个例子,假设为了简单起见,我们的kernel的形状很小,为[2, 2],其值为[[0.1, 0.2], [-0.3, -0.4]],那么,
l2(kernel) = 0.1² + 0.2² + (-0.3)² + (-0.4)² = 0.3
因此,l2(kernel)始终返回一个正数,对kernel中的大权重值进行惩罚。在总的损失函数中加入这个项,会鼓励kernel的所有元素在绝对值上都变得更小,其他条件保持不变。
现在总的损失函数包含两个不同的项:目标预测不匹配项和与kernel大小有关的项。因此,训练过程不仅会尽量减少目标预测不匹配项,还会减少kernel元素平方和。通常情况下,这两个目标会相互冲突。例如,减小kernel元素大小可能会减小第二个项,但会增加第一个项(均方误差损失)。总的损失函数是如何平衡这两个相互冲突的项的相对重要性的?这就是l2Rate乘子发挥作用的地方。它量化了 L2 项相对于目标预测误差项的重要性。l2Rate的值越大,训练过程就越倾向于减少 L2 正则化项,但会增加目标预测误差。这个参数的默认值是1e-3,可以通过超参数优化进行调整。
L2 正则化如何帮助我们? 图 8.4B 展示了经过正则化的 MLPs 的损失曲线。通过与未经正则化的 MLPs 的曲线(图 8.4A)进行比较,您可以看到使用正则化的模型产生了较少的训练和验证损失曲线。这意味着模型不再“过度关注”训练数据集中的偶发模式,而是从训练集中学到的模式可以很好地推广到验证集中看不见的例子。在我们的经过正则化的 MLPs 中,只有第一个密集层加入了正则化,而第二个密集层没有。但这足以克服这种过拟合情况。在下一节中,我们将更深入地探讨为什么较小的卷积核值会降低过拟合。
可视化正则化对权值的影响
由于 L2 正则化通过鼓励隐藏的 dense 层中的卷积核具有更小的值来起作用,因此我们应该看到经过训练后的 MLPs 中,使用正则化的模型的卷积核的值更小。在 TensorFlow.js 中,我们可以使用 tfjs-vis 库的 tfvis.show.layer() 函数实现这一点。代码清单 8.4 展示了如何使用该函数可视化 TensorFlow.js 模型的权重。该代码在 MLP 模型训练结束时执行。tfvis.show.layer() 函数接受两个参数:可视化器上的渲染和要渲染的层。
代码清单 8.4。展示层权值分布的可视化代码(来自 jena-weather/index.js)
function visualizeModelLayers(tab, layers, layerNames) {
layers.forEach((layer, i) => {
const surface = tfvis.visor().surface({name: layerNames[i], tab});
tfvis.show.layer(surface, layer);
});
}
代码生成的可视化结果见图 8.5。A 和 B 两图分别展示了使用未经正则化和经过正则化的 MLPs 的结果。每个图中,tfvis.show.layer() 函数展示了该层的权值表格,其中包括权值的名称、形状和参数数量、参数值的最小/最大值,以及零值和 NaN 值的数量(最后一个参数可以用于诊断训练过程中出现的问题)。此外,该层的可视化界面还包含了每个权值的值分布展示按钮。当点击此按钮时,它将创建权值的值的直方图。
图 8.5。正则化和未正则化情况下卷积核的值的分布。A 和 B 两幅图分别展示了经过/未经过 L2 正则化的 MLPs 的结果。该可视化结果基于 tfvis.show.layer() 函数生成。请注意两个直方图的 x 轴比例不同。

通过比较两个 MLPs 的绘图,可以看到明显差异:使用 L2 正则化的情况下,卷积核的值分布范围要比未经正则化的情况窄得多。这也反映在最小值和最大值(第一行)以及值的直方图中。这就是正则化的作用!
为什么较小的核值会导致减少过拟合和改善泛化呢?理解这一点的直观方法是 L2 正则化强制执行奥卡姆剃刀原则。一般来说,权重参数中的较大幅度倾向于导致模型拟合到它看到的训练特征中的细微细节,而较小幅度则倾向于让模型忽略这些细节。在极端情况下,核值为零意味着模型根本不关注其对应的输入特征。L2 正则化鼓励模型通过避免大幅度的权重值来更“经济地”运行,并且仅在值得成本的情况下保留这些值(当减少目标预测不匹配项的损失超过正则化损失时)。
L2 正则化只是机器学习从业者工具库中针对过拟合的其中一种武器。在第四章中,我们展示了辍学层的强大威力。辍学是一种对抗过拟合的强大措施。它同样帮助我们减少了这个温度预测问题中的过拟合。你可以通过在演示 UI 中选择带有辍学的 MLP 模型类型来自己看到这一点。辍学启用的 MLP 所获得的训练质量与 L2 正则化的 MLP 相媲美。当我们将其应用于 MNIST 卷积网络时,我们在第 4.3.2 节讨论了辍学是如何以及为什么起作用的,因此我们在这里不再赘述。然而,表 8.1 提供了对抗过拟合最常用的快速概述。它包括了每种方法如何工作的直观描述以及 TensorFlow.js 中对应的 API。对于特定问题使用哪种对抗过拟合的方法的问题通常通过以下两种方式回答:1)遵循解决类似问题的成熟模型;2)将对抗过拟合方法视为一个超参数,并通过超参数优化来搜索它(第 3.1.2 节)。此外,每种减少过拟合的方法本身都包含可调参数,这些参数也可以通过超参数优化确定(参见表 8.1 的最后一列)。
表 8.1. TensorFlow.js 中常用的减少过拟合方法概览
| 方法名称 | 方法如何工作 | TensorFlow.js 中的对应 API | 主要自由参数 |
|---|---|---|---|
| L2 正则化器 | 通过计算权重的参数值的平方和来对权重分配正的损失(惩罚)。它鼓励权重具有较小的参数值。 | tf.regularizers.l2() 例如,见“使用权重正则化减少过拟合”部分。 | L2-正则化率 |
| L1 正则化器 | 类似于 L2 正则化器,鼓励权重参数更小。但是,它对权重的损失基于参数的绝对值之和,而不是平方和。这种正则化损失的定义导致更多的权重参数变为零(即“更稀疏的权重”)。 | tf.regularizers.l1() | L1 正则化率 |
| 组合 L1-L2 正则化器 | L1 和 L2 正则化损失的加权和。 | tf.regularizers.l1l2() | L1 正则化率 L2 正则化率 |
| 丢弃 | 在训练过程中随机将一部分输入设为零(但在推断过程中不设为零),以打破在训练过程中出现的权重参数之间的虚假相关性(或“阴谋”)。 | tf.layers.dropout() 例如,请参阅 4.3.2 节。 | 丢弃率 |
| 批量归一化 | 在训练过程中学习其输入值的均值和标准差,并使用所学统计数据将输入归一化为零均值和单位标准差。 | tf.layers.batchNormalization() | 各种(参见 js.tensorflow.org/api/latest/#layers.batchNormalization) |
| 基于验证集损失的早期停止训练 | 当验证集上的每个周期结束时损失值不再减少时,停止模型训练。 | tf.callbacks.earlyStopping() | minDelta:忽略更改的阈值 patience:最多容忍连续几个周期的无改善 |
在本节关于可视化欠拟合和过拟合的总结中,我们提供了一个简略图表,以快速判断这些状态(图 8.6)。如面板 A 所示,欠拟合是指模型达到次优(高)损失值的状态,无论是在训练集还是验证集上。在面板 B 中,我们看到了典型的过拟合模式,其中训练损失看起来相当令人满意(低),但是验证损失较差(更高)。即使训练集损失继续下降,验证损失也可能趋于平稳甚至上升。面板 C 是我们想要达到的状态,即损失值在训练集和验证集之间没有太大差异,以便最终验证损失较低。请注意,术语“足够低”可以是相对的,特别是对于现有模型无法完美解决的问题。未来可能会推出新模型,并降低相对于面板 C 的可达损失。在那时,面板 C 中的模式将变为欠拟合的情况,我们将需要采用新的模型类型来解决它,可能需要再次经历过拟合和正则化的周期。
图 8.6. 示意图显示了模型训练中欠拟合(面板 A)、过拟合(面板 B)和适度拟合(面板 C)的损失曲线。

最后,请注意,对训练的可视化不仅限于损失。其他指标通常也被可视化以帮助监视训练过程。本书中随处可见此类示例。例如,在第三章中,我们在训练二元分类器以识别网络钓鱼网站时绘制了 ROC 曲线。我们还在训练 iris 花分类器时绘制了混淆矩阵。在第九章中,我们将展示一个在训练文本生成器时显示机器生成文本的示例。该示例不涉及 GUI,但仍会提供关于模型训练状态的有用和直观的实时信息。具体来说,通过查看模型生成的文本,你可以直观地了解当前模型生成的文本质量如何。
8.3. 机器学习的通用工作流程
到目前为止,你已经了解了设计和训练机器学习模型的所有重要步骤,包括获取、格式化、可视化和摄取数据;为数据集选择适当的模型拓扑和损失函数;以及训练模型。你还看到了在训练过程中可能出现的一些最重要的失败模式:欠拟合和过拟合。因此,现在是我们回顾一下迄今为止学到的东西,并思考不同数据集的机器学习模型过程中的共同之处的好时机。结果抽象化就是我们所说的机器学习的通用工作流程。我们将逐步列出工作流程,并扩展每个步骤中的关键考虑因素:
-
确定机器学习是否是正确的方法。首先,考虑一下机器学习是否是解决你的问题的正确方法,只有当答案是肯定的时候才继续下一步。在某些情况下,非机器学习方法同样有效,甚至可能成本更低。例如,通过足够的模型调整工作,你可以训练一个神经网络来“预测”两个整数的和,将整数作为文本输入数据(例如,在 tfjs-examples 仓库中的 addition-rnn 示例)。但这远非是这个问题的最有效或最可靠的解决方案:在这种情况下,CPU 上的传统加法运算就足够了。
-
定义机器学习问题及你尝试使用数据预测什么。在这一步中,你需要回答两个问题:
-
有哪些数据可用? 在监督学习中,只有当有标记的训练数据可用时,你才能学习预测某些东西。例如,我们在本章前面看到的天气预测模型之所以可能,仅仅是因为有了 Jena-weather 数据集。数据的可用性通常是这一阶段的限制因素。如果可用数据不足,你可能需要收集更多数据或者雇人手动标记未标记的数据集。
-
你面临的是什么类型的问题? 是二元分类、多类分类、回归还是其他?识别问题类型将指导你选择模型架构、损失函数等。在你知道输入和输出以及将使用的数据之前,你不能进入下一步。在这个阶段,要注意你隐含假设的假设:
-
你假设在给定输入的情况下可以预测输出(仅凭输入就包含了足够的信息,使模型能够预测该问题中所有可能的示例的输出)。
-
你假设可用的数据足以让模型学习这种输入输出关系。在你有一个可用的模型之前,这些只是等待验证或无效化的假设。并非所有问题都是可解的:仅仅因为你组装了一个大型标记数据集,从 X 到 Y 的映射并不意味着 X 包含足够的信息来推断 Y 的值。例如,如果你试图根据股票的历史价格来预测股票的未来价格,你可能会失败,因为价格历史并不包含足够的有关未来价格的预测信息。你应该意识到一个不可解问题类别是 非平稳 问题,即输入输出关系随时间变化。假设你正在尝试构建一个服装推荐引擎(根据用户的服装购买历史),并且你正在使用一年的数据来训练你的模型。这里的主要问题是人们对服装的品味随时间而改变。在去年验证数据上准确工作的模型不一定今年同样准确。请记住,机器学习只能用于学习训练数据中存在的模式。在这种情况下,获取最新的数据并持续训练新模型将是一个可行的解决方案。
-
-
确定一种可靠地衡量训练模型在目标上成功的方法。对于简单的任务,这可能仅仅是预测准确性、精确率和召回率,或者 ROC 曲线和 AUC 值(参见第三章)。但在许多情况下,它将需要更复杂的领域特定指标,如客户保留率和销售额,这些指标与更高级别的目标(如业务的成功)更加一致。
-
准备评估过程。设计您将用于评估模型的验证过程。特别是,您应将数据分为三组同质但不重叠的集合:训练集、验证集和测试集。验证集和测试集的标签不应泄漏到训练数据中。例如,对于时间预测,验证和测试数据应来自训练数据之后的时间间隔。您的数据预处理代码应该由测试覆盖以防止错误。
-
将数据向量化。将数据转换为张量,也称为n维数组,这是机器学习模型在诸如 TensorFlow.js 和 TensorFlow 等框架中的通用语言。注意以下有关数据向量化的准则:
-
张量取值通常应缩放为小而居中的值:例如,在
[-1, 1]或[0, 1]区间内。 -
如果不同特征(例如温度和风速)具有不同范围的值(异构数据),那么数据应该被归一化,通常是针对每个特征进行零均值和单位标准差的 z 归一化。一旦您的输入数据张量和目标(输出)数据准备好了,您就可以开始开发模型。
-
-
开发一个能击败常识基准线的模型。开发一个能击败非机器学习基准线的模型(例如对于回归问题,预测人口平均值,或者对于时间序列预测问题,预测最后一个数据点),从而证明机器学习确实可以为您的解决方案增加价值。这可能并不总是事实(参见步骤 1)。假设事情进展顺利,您需要做出三个关键选择来构建您的第一个击败基准线的机器学习模型:
-
最后一层激活——这为模型的输出建立了有用的约束条件。该激活应适合您正在解决的问题类型。例如,本书的第三章中的网络钓鱼网站分类器使用了 Sigmoid 激活作为其最后(输出)层,因为该问题具有二分类的性质;而本章的温度预测模型使用了线性激活作为层的激活,因为该问题是回归问题。
-
损失函数——与最后一层激活类似,损失函数应与您正在解决的问题相匹配。例如,对于二分类问题使用
binaryCrossentropy,对于多类分类问题使用categoricalCrossentropy,对于回归问题使用meanSquaredError。 -
优化器配置——优化器是推动神经网络权重更新的驱动器。应该使用什么类型的优化器?其学习率应该是多少?这些通常是由超参数调整回答的问题。但在大多数情况下,您可以安全地从
rmsprop优化器及其默认学习率开始。
-
-
开发一个具有足够容量且过拟合训练数据的模型。通过手动更改超参数逐渐扩展您的模型架构。您希望达到一个过拟合训练集的模型。请记住,监督机器学习中的通用和核心紧张关系在于优化(适合训练期间看到的数据)和泛化(能够为未看到的数据进行准确预测)。理想的模型是一个恰好位于欠拟合和过拟合之间的模型:即,在容量不足和容量过大之间。要弄清楚这个边界在哪里,您必须首先越过它。为了越过它,您必须开发一个过拟合的模型。这通常相当容易。你可能
-
添加更多层
-
使每一层更大
-
为模型训练更多的 epochs。始终使用可视化来监视训练和验证损失,以及您关心的任何其他指标(例如 AUC)在训练和验证集上。当您看到验证集上模型的准确性开始下降(图 8.6,面板 B)时,您已经达到了过拟合。
-
-
为模型添加正则化并调整超参数。下一步是为模型添加正则化,并进一步调整其超参数(通常以自动方式),以尽可能接近既不欠拟合也不过拟合的理想模型。这一步将花费最多的时间,即使它可以自动化。您将反复修改模型,训练它,在验证集上评估它(此时不是测试集),再次修改它,然后重复,直到模型尽可能好。在正则化方面应尝试以下事项:
-
添加具有不同 dropout 率的 dropout 层。
-
尝试 L1 和/或 L2 正则化。
-
尝试不同的架构:增加或减少少量层。
-
更改其他超参数(例如,密集层的单位数)。在调整超参数时要注意验证集的过拟合。因为超参数是根据验证集的性能确定的,它们的值将对验证集过于专门化,因此可能不会很好地推广到其他数据。测试集的目的是在超参数调整后获得模型准确性的无偏估计。因此,在调整超参数时不应使用测试集。
-
这是机器学习的通用工作流程!在第十二章中,我们将为其添加两个更具实践性的步骤(评估步骤和部署步骤)。但是现在,这是一个从模糊定义的机器学习想法到训练完毕并准备好进行一些有用预测的模型的配方。
有了这些基础知识,我们将开始在本书的后续部分探索更高级的神经网络类型。我们将从第九章中设计用于序列数据的模型开始。
练习
-
在温度预测问题中,我们发现线性回归器明显欠拟合了数据,并在训练集和验证集上产生了较差的预测结果。将 L2 正则化添加到线性回归器是否有助于提高这种欠拟合模型的准确性?你可以通过修改文件 jena-weather/models.js 中的
buildLinearRegressionModel()函数自行尝试。 -
在 Jena-weather 示例中预测第二天的温度时,我们使用了 10 天的回溯期来生成输入特征。一个自然的问题是,如果我们使用更长的回溯期会怎样?包含更多数据是否会帮助我们获得更准确的预测?你可以通过修改 jena-weather/index.js 中的
const lookBack并在浏览器中运行训练(例如,使用具有 L2 正则化的 MLP)来找出答案。当然,更长的回溯期会增加输入特征的大小并导致更长的训练时间。因此,问题的另一面是,我们是否可以使用更短的回溯期而不明显牺牲预测准确性?也试试这个。
摘要
-
tfjs-vis 可以在浏览器中辅助可视化机器学习模型的训练过程。具体来说,我们展示了 tfjs-vis 如何用于
-
可视化 TensorFlow.js 模型的拓扑结构。
-
绘制训练过程中的损失和指标曲线。
-
在训练后总结权重分布。我们展示了这些可视化工作流程的具体示例。
-
-
欠拟合和过拟合是机器学习模型的基本行为,应该在每一个机器学习问题中进行监控和理解。它们都可以通过比较训练和验证集的损失曲线来观察。内置的
tfvis.show.fitCallbacks()方法可以帮助你轻松在浏览器中可视化这些曲线。 -
机器学习的通用工作流程是不同类型的监督学习任务的一系列常见步骤和最佳实践。它从确定问题的性质和对数据的需求开始,到找到一个恰到好处的模型,位于欠拟合和过拟合之间的边界上。
第十章:序列和文本的深度学习
本章包括
-
顺序数据与非顺序数据有何不同
-
哪些深度学习技术适用于涉及序列数据的问题
-
如何在深度学习中表示文本数据,包括独热编码,多热编码和词嵌入
-
什么是循环神经网络,以及为什么它们适合于顺序问题
-
什么是一维卷积,以及为什么它是循环神经网络的一个有吸引力的替代品
-
序列到序列任务的独特特性以及如何使用注意力机制来解决它们
本章重点介绍涉及序列数据的问题。序列数据的本质是其元素的排序。您可能已经意识到,我们之前已经处理过序列数据。具体来说,我们在第七章介绍的 Jena-weather 数据是序列数据。数据可以表示为数字数组的数组。外部数组的顺序当然很重要,因为测量是随着时间的推移而进行的。如果您改变外部数组的顺序——例如,上升的气压趋势变成下降的气压趋势——如果您尝试预测未来的天气,它就具有完全不同的含义。序列数据无处不在:股票价格,心电图(ECG)读数,软件代码中的字符串,视频的连续帧以及机器人采取的行动序列。将这些与非序列数据相对比,比如第三章中的鸢尾花:如果您改变这四个数字特征(萼片和花瓣的长度和宽度)的顺序并不重要。^([1])
¹
说服自己这确实是事实,练习本章末尾的练习 1。
本章的第一部分将介绍我们在第一章中提到的一种引人入胜的模型——循环神经网络(RNNs),它们专门设计用于从序列数据中学习。我们将理解循环神经网络的特殊特性,以及这些模型敏感于元素的排序和相关信息的直觉。
本章的第二部分将讨论一种特殊的序列数据:文本,这可能是最常见的序列数据(尤其是在网络环境中!)。我们将首先研究深度学习中如何表示文本以及如何在这些表示上应用循环神经网络。然后我们将转向一维卷积神经网络,并讨论它们为何也在处理文本时非常强大,以及它们如何对某些类型的问题是循环神经网络的有吸引力的替代品。
在本章的最后一部分,我们将进一步探讨比预测数字或类别稍微复杂一点的基于序列的任务。特别是,我们将涉及序列到序列的任务,这涉及从输入序列预测输出序列。我们将用一个例子来说明如何使用一种新的模型架构——注意机制来解决基本的序列到序列任务,这在基于深度学习的自然语言处理领域变得越来越重要。
通过本章结束时,您应该熟悉深度学习中常见类型的顺序数据,它们如何呈现为张量,以及如何使用 TensorFlow.js 编写基本的 RNN、1D 卷积网络和注意网络来解决涉及顺序数据的机器学习任务。
本章中您将看到的层和模型是本书中最复杂的。这是它们为顺序学习任务增强容量所付出的代价。即使我们努力以尽可能直观的方式呈现它们,配以图表和伪代码的帮助,您第一次阅读时可能会觉得其中一些很难理解。如果是这样,请尝试运行示例代码并完成章末提供的练习。根据我们的经验,实践经验使得内化复杂概念和架构变得更加容易,就像本章中出现的那些一样。
9.1. 天气预测的第二次尝试:引入 RNN
我们在第八章中为 Jena 天气问题构建的模型丢弃了顺序信息。在本节中,我们将告诉您为什么会这样,并且我们如何通过使用 RNN 将顺序信息带回来。这将使我们能够在温度预测任务中实现更准确的预测。
9.1.1. 为什么密集层无法建模顺序
由于我们在上一章节中已经详细描述了 Jena 天气数据集,所以在这里我们将仅简要讨论数据集和相关的机器学习任务。该任务涉及使用过去 10 天内一段时间内的 14 个天气仪器(如温度、气压和风速)的读数来预测从某一时刻开始的 24 小时后的温度。仪器读数以 10 分钟的固定间隔进行,但我们将其降采样 6 倍,以每小时一次,以便使模型大小和训练时间可管理。因此,每个训练示例都带有一个形状为[240, 14]的特征张量,其中 240 是 10 天内的时间步数,14 是不同天气仪器读数的数量。
在前一章的任务中,当我们尝试了一个线性回归模型和一个 MLP 时,我们使用了tf.layers.flatten层将 2D 输入特征展平为 1D(参见清单 8.2 和图 8.2)。展平步骤是必要的,因为线性回归器和 MLP 都使用了密集层来处理输入数据,而密集层要求每个输入示例的输入数据为 1D。这意味着所有时间步的信息以一种方式混合在一起,使得哪个时间步首先出现,接下来是哪个时间步,一个时间步距离另一个时间步有多远等等的重要性被抹去了。换句话说,当我们将形状为[240, 14]的 2D 张量展平为形状为[3360]的 1D 张量时,我们如何对 240 个时间步进行排序并不重要,只要我们在训练和推断之间保持一致即可。您可以在本章末尾的练习 1 中通过实验证实这一点。但从理论上讲,这种对数据元素顺序缺乏敏感性的缺点可以用以下方式理解。在密集层的核心是一组线性方程,每个方程都将每个输入特征值[x[1],x[2],…,x[n]]与来自核[k[1],k[2],…,k[n]]的可调系数相乘:
方程式 9.1.

图 9.1 提供了密集层的工作原理的可视化表示:从输入元素到层输出的路径在图形上对称,反映了方程式 9.1 中的数学对称性。当我们处理序列数据时,这种对称性是不可取的,因为它使模型对元素之间的顺序视而不见。
图 9.1. 密集层的内部架构。密集层执行的乘法和加法与其输入对称。与简单 RNN 层(图 9.2)相比,它通过引入逐步计算来打破对称性。请注意,我们假设输入只有四个元素,出于简单起见,省略了偏置项。此外,我们仅显示了密集层的一个输出单元的操作。其余的单元被表示为背景中的一堆模糊的框。

实际上,有一个简单的方法可以显示,我们基于密集层的方法(即 MLP,即使加入正则化)并没有很好地解决温度预测问题:将其准确性与我们从常识、非机器学习方法中获得的准确性进行比较。
我们所说的常识方法是什么?将温度预测为输入特征中的最后一个温度读数。简单地说,就假装从现在起 24 小时后的温度会与当前温度相同!这种方法是“直觉上合理”的,因为我们从日常经验中知道,明天的温度往往接近于今天的温度(也就是说,在同一天的同一时间)。这是一个非常简单的算法,并提供了一个合理的猜测,应该能击败所有其他类似简单的算法(例如,将温度预测为 48 小时前的温度)。
我们在 第八章 中使用的 tfjs-examples 的 jena-weather 目录提供了一个命令,用于评估这种常识方法的准确性:
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/jena-weather
yarn
yarn train-rnn --modelType baseline
yarn train-rnn 命令调用了 train-rnn.js 脚本,并在基于 Node.js 的后端环境中执行计算。^([2]) 我们将在不久的将来回到这种操作模式,当我们探索 RNN 时。该命令应该会给出以下屏幕输出:
²
实现这种常识、非机器学习方法的代码位于 jena-weather/models.js 中名为
getBaselineMeanAbsoluteError()的函数中。它使用Dataset对象的forEachAsync()方法来遍历验证子集的所有批次,计算每个批次的 MAE 损失,并累积所有损失以获得最终损失。
Commonsense baseline mean absolute error: 0.290331
因此,简单的非机器学习方法产生了约为 0.29(以归一化术语表示)的平均绝对预测误差,这与我们从 第八章 中 MLP 获得的最佳验证误差相当(见 图 8.4)。换句话说,MLP,无论是否进行正则化,都无法可靠地击败来自常识基线方法的准确性!
这样的观察在机器学习中并不少见:机器学习并不总是能够击败常识方法。为了击败它,机器学习模型有时需要通过超参数优化进行精心设计或调整。我们的观察还强调了在处理机器学习问题时创建非机器学习基准进行比较的重要性。当然,我们肯定要避免将所有的精力都浪费在构建一个甚至连一个简单且计算成本更低的基线都无法击败的机器学习算法上!我们能够在温度预测问题中击败基线吗?答案是肯定的,我们将依靠 RNN 来做到这一点。现在让我们来看看 RNN 如何捕捉和处理序列顺序。
9.1.2. RNNs 如何建模序列顺序
图 9.2 的 A 面通过使用一个简短的四项序列显示了 RNN 层的内部结构。有几种 RNN 层的变体,图表显示了最简单的变体,称为 SimpleRNN,并且在 TensorFlow.js 中可用作tf.layers.simpleRNN()工厂函数。我们稍后将在本章中讨论更复杂的 RNN 变体,但现在我们将专注于 SimpleRNN。
图 9.2. SimpleRNN 内部结构的“展开”(A 面)和“卷曲”(B 面)表示。卷曲视图(B 面)以更简洁的形式表示与展开视图相同的算法。它以更简洁的方式说明了 SimpleRNN 对输入数据的顺序处理。在面板 B 中的卷曲表示中,从输出(y)返回到模型本身的连接是这些层被称为循环的原因。与图 9.1 中一样,我们仅显示了四个输入元素,并简化了偏差项。

图表显示了输入的时间片段(x[1],x[2],x[3],…)是如何逐步处理的。在每一步中,x[i] 通过一个函数(f())进行处理,该函数表示为图表中心的矩形框。这产生了一个输出(y[i]),它与下一个输入片段(x[i][+1])结合,作为下一步 f() 的输入。重要的是要注意,即使图表显示了四个具有函数定义的单独框,它们实际上表示相同的函数。这个函数(f())称为 RNN 层的cell。在调用 RNN 层期间,它以迭代的方式使用。因此,可以将 RNN 层视为“在for循环中包装的 RNN 单元。”^([3])
³
引述于 Eugene Brevdo。
比较 SimpleRNN 的结构和密集层的结构(图 9.1),我们可以看到两个主要区别:
-
SimpleRNN 逐步处理输入元素(时间步)。这反映了输入的顺序性,这是密集层无法做到的。
-
在 SimpleRNN 中,每个输入时间步的处理都会生成一个输出(y[i])。前一个时间步的输出(例如,y[1])在处理下一个时间步(例如 x[2])时由层使用。这就是 RNN 名称中“循环”部分的原因:来自先前时间步的输出会流回并成为后续时间步的输入。在诸如 dense、conv2d 和 maxPooling2d 之类的层类型中不会发生递归。这些层不涉及输出信息的回流,因此被称为前馈层。
由于这些独特的特征,SimpleRNN 打破了输入元素之间的对称性。它对输入元素的顺序敏感。如果重新排列顺序输入的元素,则输出将随之而改变。这使 SimpleRNN 与密集层有所区别。
图 9.2 的 B 面板是对简单循环神经网络的更抽象的表示。它被称为 rolled RNN 图表,与 A 面板中的 unrolled 图表相对应,因为它将所有时间步骤“卷”成一个循环。滚动图表很好地对应于编程语言中的 for 循环,这实际上是 TensorFlow.js 中实现 simpleRNN 和其他类型的 RNN 的方式。但是,与其显示真实的代码,不如看一下下面的简单 RNN 的伪代码,您可以将其视为图 9.2 中所示的 simpleRNN 结构的实现。这将帮助您专注于 RNN 层的工作原理的本质。
列表 9.1. simpleRNN 的内部计算的伪代码
y = 0 ***1***
for x in input_sequence: ***2***
y = f(dot(W, x) + dot(U, y)) ***3***
-
1 y 对应于图 9.2 中的 y。状态在开始时被初始化为零。
-
2 x 对应于图 9.2 中的 x。for 循环遍历输入序列的所有时间步。
-
3 W 和 U 分别是输入和状态的权重矩阵(即,回路回传并成为重复输入的输出)。这也是时间步骤 i 的输出成为时间步骤 i + 1 的状态(重复输入)的地方。
在列表 9.1 中,您可以看到时间步 i 的输出成为下一个时间步(下一个迭代)的“状态”。State 是 RNN 的一个重要概念。这是 RNN“记住”已经看过的输入序列步骤的方式。在 for 循环中,这个记忆状态与未来的输入步骤结合起来,并成为新的记忆状态。这使得 simpleRNN 能够根据之前序列中出现的元素来不同地处理相同的输入元素。这种基于记忆的敏感性是顺序处理的核心。作为一个简单的例子,如果您试图解码莫尔斯电码(由点和短划组成),则短划的含义取决于先前(以及之后)的点和短划的序列。另一个例子,在英语中,单词 last 可以根据之前的单词有完全不同的含义。
SimpleRNN 的命名适当,因为其输出和状态是相同的东西。稍后,我们将探索更复杂和更强大的 RNN 体系结构。其中一些具有输出和状态作为两个单独的东西;其他甚至具有多个状态。
关于 RNN 的另一件值得注意的事情是 for 循环使它们能够处理由任意数量的输入步骤组成的输入序列。这是通过将序列输入展平并将其馈送到密集层中无法完成的,因为密集层只能接受固定的输入形状。
此外,for 循环反映了 RNN 的另一个重要属性:参数共享。我们所说的是,相同的权重参数(W 和 U)在所有时间步中都被使用。另一种选择是对每个时间步使用唯一的 W(和 U)值。这是不可取的,因为 1)它限制了 RNN 可以处理的时间步数,2)它导致可调参数数量的显著增加,这将增加计算量并增加训练期间过拟合的可能性。因此,RNN 层类似于 convnets 中的 conv2d 层,它们使用参数共享来实现高效计算并防止过拟合——尽管循环和 conv2d 层以不同的方式实现参数共享。虽然 conv2d 层利用了沿空间维度的平移不变性,但 RNN 层利用了沿时间维度的平移不变性。
图 9.2 显示了在推断时间(前向传播)中简单 RNN 中发生的情况。它并不显示在训练期间(后向传播)如何更新权重参数(W 和 U)。然而,RNN 的训练遵循我们在 2.2.2 节(图 2.8)中介绍的相同反向传播规则——即从损失开始,回溯操作列表,取其导数,并通过它们累积梯度值。数学上,递归网络上的后向传播基本上与前向传播相同。唯一的区别是 RNN 层的反向传播沿时间倒退,在像 图 9.2 面板 A 中的展开图中。这就是为什么有时将训练 RNN 的过程称为时间反向传播(BPTT)。
SimpleRNN 的实现
关于 simpleRNN 和 RNN 总体的抽象思考已经足够了。现在让我们看看如何创建一个 simpleRNN 层并将其包含在模型对象中,这样我们就可以比以前更准确地预测温度了。清单 9.2 中的代码(从 jena-weather/train-rnn.js 中摘录)就是这样做的。尽管 simpleRNN 层的内部复杂性很高,但模型本身相当简单。它只有两层。第一层是 simpleRNN,配置为具有 32 个单元。第二个是使用默认线性激活生成温度的连续数值预测的密集层。请注意,因为模型以一个 RNN 开始,所以不再需要展平序列输入(与前一章中为同一问题创建 MLPs 时进行比较时)。实际上,如果我们在 simpleRNN 层之前放置一个 flatten 层,将会抛出错误,因为 TensorFlow.js 中的 RNN 层期望它们的输入至少是 3D(包括批处理维度)。
代码清单 9.2 创建用于温度预测问题的基于 simpleRNN 的模型
function buildSimpleRNNModel(inputShape) {
const model = tf.sequential();
const rnnUnits = 32; ***1***
model.add(tf.layers.simpleRNN({ ***2***
units: rnnUnits,
inputShape
}));
model.add(tf.layers.dense({units: 1})); ***3***
return model;
}
-
1 simpleRNN 层的硬编码单元数是通过超参数的手工调整得到的一个很好的值。
-
2 模型的第一层是一个 simpleRNN 层。不需要对顺序输入进行展平,其形状为 [null, 240, 14]。
-
3 我们用一个具有单个单元且默认线性激活函数的密集层来结束模型,这适用于回归问题。
要查看 simpleRNN 模型的运行情况,请使用以下命令:
yarn train-rnn --modelType simpleRNN --logDir /tmp/
jean-weather-simpleRNN-logs
RNN 模型在后端环境中使用 tfjs-node 进行训练。由于基于 BPTT 的 RNN 训练涉及到大量计算,如果在资源受限的浏览器环境中训练相同的模型将会更加困难和缓慢,甚至不可能完成。如果您已经正确设置了 CUDA 环境,您可以在命令中添加 --gpu 标志来进一步提高训练速度。
前一个命令中的 --logDir 标志使得模型训练过程将损失值记录到指定的目录中。可以使用一个名为 TensorBoard 的工具在浏览器中加载并绘制损失曲线。图 9.3 是 TensorBoard 的一个截图。在 JavaScript 代码级别,通过使用指向日志目录的特殊回调函数来配置 tf.LayersModel.fit() 调用来实现这个功能。信息框 9.1 中包含了关于如何实现这一功能的进一步信息。
图 9.3 Jena-temperature-prediction 问题的 simpleRNN 模型的 MAE 损失曲线。该图是 TensorBoard 的一个截图,显示了基于 Node.js 进行的 simpleRNN 模型训练的日志。

使用 TensorBoard 回调函数在 Node.js 中监控长时间的模型训练
在 第八章 中,我们介绍了来自 tfjs-vis 库的回调函数,以帮助您在浏览器中监控 tf.LayersModel.fit() 的调用。然而,tfjs-vis 是仅适用于浏览器的库,不适用于 Node.js。在 tfjs-node(或 tfjs-node-gpu)中,默认情况下,tf.LayersModel.fit() 在终端中以进度条形式呈现,并显示损失和时间指标。虽然这种方式简洁明了而且信息量大,但文字和数字往往不如图形界面直观和吸引人。例如,对于模型训练后期我们经常寻找的损失值的微小变化,使用图表(具有适当的刻度和网格线)要比使用一段文本更容易发现。
幸运的是,一个名为 TensorBoard 的工具可以帮助我们在后端环境中完成这项工作。TensorBoard 最初是为 TensorFlow(Python)设计的,但 tfjs-node 和 tfjs-node-gpu 可以以兼容格式写入数据,这些数据可以被 TensorBoard 处理。要将损失和指标值记录到 TensorBoard 以用于 tf.LayersModel.fit() 或 tf.LayersModel.fitDataset() 的调用中,请按照下列模式操作:
import * as tf from '@tensorflow/tfjs-node';
// Or '@tensorflow/tfjs-node-gpu'
// ...
await model.fit(xs, ys, {
epochs,
callbacks: tf.node.tensorBoard('/path/to/my/logdir')
});
// Or for fitDataset():
await model.fitDataset(dataset, {
epochs,
batchesPerEpoch,
callbacks: tf.node.tensorBoard('/path/to/my/logdir')
});
这些调用会将损失值和在compile()调用期间配置的任何指标写入目录/path/to/my/logdir。要在浏览器中查看日志,
-
打开一个单独的终端。
-
使用以下命令安装 TensorBoard(如果尚未安装):
pip install tensorboard -
启动 TensorBoard 的后端服务器,并指向在回调创建过程中指定的日志目录:
tensorboard --logdir /path/to/my/logdir -
在 Web 浏览器中,导航至 TensorBoard 进程显示的 http:// URL。然后,类似于 figures 9.3 和 9.5 中显示的损失和指标图表将出现在 TensorBoard 的美观 Web UI 中。
listing 9.2 创建的 simpleRNN 模型的文本摘要如下:
Layer (type) Output shape Param #
=================================================================
simple_rnn_SimpleRNN1 (Simpl [null,32] 1504
_________________________________________________________________
dense_Dense1 (Dense) [null,1] 33
=================================================================
Total params: 1537
Trainable params: 1537
Non-trainable params: 0
_________________________________________________________________
它的权重参数明显少于我们之前使用的 MLP(1,537 与 107,585 相比,减少了 70 倍),但在训练过程中实现了更低的验证 MAE 损失(即更准确的预测)(0.271 与 0.289)。这种对温度预测误差的小但明显的减少突显了基于时间不变性的参数共享的强大力量以及 RNN 在学习诸如我们处理的天气数据之类的序列数据方面的优势。
您可能已经注意到,即使 simpleRNN 涉及相对少量的权重参数,与 MLP 等前馈模型相比,其训练和推断时间要长得多。这是 RNN 的一个主要缺点,即无法在时间步长上并行化操作。这种并行化是不可实现的,因为后续步骤依赖于先前步骤中计算的状态值(参见 figure 9.2 和 listing 9.1 中的伪代码)。如果使用大 O 符号表示,RNN 的前向传递需要 O(n)时间,其中n是输入时间步的数量。后向传递(BPTT)需要另外 O(n)时间。耶拿天气问题的输入未来包含大量(240)时间步,这导致了之前看到的较慢的训练时间。这也是为什么我们在 tfjs-node 而不是在浏览器中训练模型的主要原因。
RNN 的情况与 dense 和 conv2d 等前馈层形成鲜明对比。在这些层中,计算可以在输入元素之间并行化,因为对一个元素的操作不依赖于另一个输入元素的结果。这使得这些前馈层在执行它们的正向和反向传播时可以在 O(n)时间内花费较少的时间(在某些情况下接近 O(1)),借助 GPU 加速。在 section 9.2 中,我们将探索一些更多可并行化的序列建模方法,比如 1D 卷积。然而,熟悉 RNN 仍然是重要的,因为它们对于序列位置是敏感的,而 1D 卷积不是(稍后讨论)。
门控循环单元(GRU):一种更复杂的 RNN 类型
SimpleRNN 并不是 TensorFlow.js 中唯一的循环层。还有两个循环层可用:门控循环单元 (GRU^([4])) 和 LSTM(Long Short-Term Memory 的缩写^([5]))。在大多数实际应用中,你可能会想要使用这两种模型中的一种。SimpleRNN 对于大多数真实问题而言过于简单,尽管其计算成本更低并且其内部机制比 GRU 和 LSTM 更容易理解。但是,简单 RNN 存在一个主要问题:尽管理论上来说,simpleRNN 能够在时间 t 保留对于多个时间步长前的输入信息,但是在实践中,学习这种长期依赖关系非常困难。
⁴
Kyunghyun Cho 等人在 2014
⁵
Sepp Hochreiter 和 Jürgen Schmidhuber 在 1997 年发表的论文《Long Short-Term Memory》中提出了 LSTM 模型,这篇论文发表在《Neural Computation》杂志的第 9 卷第 8 期上,页码从 1735 至 1780。
这是由于梯度消失问题,这是一种类似于前馈网络深度很深时观察到的效应的影响:随着你向网络中添加越来越多的层,从损失函数向早期层反向传播的梯度大小会越来越小。因此,权重的更新也越来越小,直到网络最终变得无法训练。对于 RNN,大量的时间步骤在此问题中扮演了许多层的角色。GRU 和 LSTM 是为解决梯度消失问题而设计的 RNN,GRU 是两者中更简单的一种。让我们看看 GRU 是如何解决这个问题的。
与 simpleRNN 相比,GRU 具有更复杂的内部结构。图 9.4 显示了 GRU 的内部结构的滚动表示。与 simpleRNN 的相同滚动表示进行比较(图 9.2 的面板 B),它包含了更多的细节。输入 (x) 和输出 / 状态(按照 RNN 文献中的约定称为 h)通过 四个 等式生成新的输出 / 状态。相比之下,simpleRNN 仅涉及 一个 等式。这种复杂性也体现在 清单 9.3 中的伪代码中,可以将其视为 图 9.4 中机制的一种实现。为简单起见,我们省略了伪代码中的偏置项。
图 9.4 门控循环单元(GRU)的滚动表示,一种比 simpleRNN 更复杂、更强大的 RNN 层类型。这是一个滚动表示,与 图 9.2 中的面板 B 相似。请注意,我们为了简单起见,在等式中省略了偏置项。虚线表示了从 GRU 单元的输出 (h) 到下一个时间步的同一单元的反馈连接。

代码清单 9.3 Pseudo-code for a GRU layer
h = 0 ***1***
for x_i in input_sequence: ***2***
z = sigmoid(dot(W_z, x) + dot(U_z, h)) ***3***
r = sigmoid(dot(W_r, x) + dot(W_r, h)) ***4***
h_prime = tanh(dot(W, x) + dot(r, dot(U, h))) ***5***
h = dot(1 - z, h) + dot(z, h_prime) ***6***
-
1 这是 图 9.4 中的 h。和 simpleRNN 一样,在最开始状态被初始化为零。
-
2 这个 for 循环遍历输入序列的所有时间步。
-
3 z 被称为更新门。
-
4 r 被称为重置门。
-
5 h_prime 是当前状态的临时状态。
-
6 h_prime (当前临时状态) 和 h (上一个状态) 以加权方式结合(z 为权值)形成新状态。
在 GRU 的所有内部细节中,我们要强调两个最重要的方面:
-
GRU 可以轻松地在许多时间步之间传递信息。这是通过中间量 z 实现的,它被称为更新门。由于更新门的存在,GRU 可以学习以最小的变化在许多时间步内传递相同的状态。特别地,在等式 (1 - z) · h + z · *h' 中,如果 z 的值为 0,则状态 h 将简单地从当前时间步复制到下一个时间步。这种整体传递的能力对于 GRU 如何解决消失梯度问题至关重要。重置门 z 被计算为输入 x 和当前状态 h 的线性组合,然后经过一个 sigmoid 非线性函数。
-
除了更新门 z,GRU 中的另一个“门”被称为所谓的重置门,r。像更新门 z 一样,r 被计算为对输入和当前状态
h的线性组合进行 sigmoid 非线性函数操作。重置门控制有多少当前状态需要“遗忘”。特别地,在等式 tanh(W · x+r · U · h)中,如果 r 的值变为 0,则当前状态 h 的影响被抹除;如果下游方程中的 (1 - z)接近零,那么当前状态 h 对下一个状态的影响将被最小化。因此,r 和 z 协同工作,使得 GRU 能够在适当条件下学习忘记历史或其一部分。例如,假设我们试图对电影评论进行正面或负面的分类。评论可能开始说“这部电影相当令人满意”,但评论过了一半后,又写到“然而,这部电影并不像其他基于类似观点的电影那么出色。” 在这一点上,应该大部分地忘记关于初始赞美的记忆,因为应该更多地权衡评论后部分对该评论最终情感分析结果的影响。
所以,这是 GRU 如何工作的一个非常粗糙和高层次的概述。要记住的重要事情是,GRU 的内部结构允许 RNN 学习何时保留旧状态,何时使用来自输入的信息更新状态。这种学习通过可调权重 W[z]、U[z]、W[r]、W[r]、W 和 U 的更新体现出来(除了省略的偏置项)。
如果你一开始不明白所有细节,不要担心。归根结底,我们在最后几段中对 GRU 的直观解释并不那么重要。理解 GRU 如何以非常详细的层面处理序列数据并不是人类工程师的工作,就像理解卷积神经网络如何将图像输入转换为输出类别概率的细节并不是人类工程师的工作一样。细节是由神经网络在 RNN 结构数据所描述的假设空间中通过数据驱动的训练过程找到的。
要将 GRU 应用于我们的温度预测问题,我们构建一个包含 GRU 层的 TensorFlow.js 模型。我们用于此的代码(摘自 jena-weather/train-rnn.js)几乎与我们用于简单 RNN 模型的代码(代码清单 9.2)完全相同。唯一的区别是模型的第一层的类型(GRU 对比于简单 RNN)。
代码清单 9.4. 为 Jena 温度预测问题创建一个 GRU 模型
function buildGRUModel(inputShape) {
const model = tf.sequential();
const rnnUnits = 32; ***1***
model.add(tf.layers.gru({ ***2***
units: rnnUnits,
inputShape
}));
model.add(tf.layers.dense({units: 1})); ***3***
return model;
}
-
1 硬编码的单元数是一个通过超参数手动调整而发现效果良好的数字。
-
2 模型的第一层是一个 GRU 层。
-
3 模型以具有单个单元和默认线性激活的密集层结束,用于回归问题。
要开始在 Jena 天气数据集上训练 GRU 模型,请使用
yarn train-rnn --modelType gru
图 9.5 显示了使用 GRU 模型获得的训练和验证损失曲线。它获得了约为 0.266 的最佳验证错误,这超过了我们在上一节中从简单 RNN 模型中获得的结果(0.271)。这反映了相较于简单 RNN,GRU 在学习序列模式方面具有更大的容量。在气象仪器读数中确实隐藏着一些序列模式,这些模式有助于提高温度的预测精度;这些信息被 GRU 捕捉到,但简单 RNN 没有。但这是以更长的训练时间为代价的。例如,在我们的一台机器上,GRU 模型的训练速度为每批 3,000 毫秒,而简单 RNN 的训练速度为每批 950 毫秒^([6])。但如果目标是尽可能准确地预测温度,那么这个代价很可能是值得的。
⁶
这些性能数字是从在 CPU 后端运行的 tfjs-node 获得的。如果你使用 tfjs-node-gpu 和 CUDA GPU 后端,你将获得两种模型类型的近似比例的加速。
图 9.5. 在温度预测问题上训练 GRU 模型的损失曲线。将其与简单 RNN 模型的损失曲线进行比较(图 9.3),注意 GRU 模型取得的最佳验证损失的小幅但真实的降低。

9.2. 为文本构建深度学习模型
我们刚刚研究的天气预测问题涉及顺序数值数据。但是最普遍的序列数据可能是文本而不是数字。在像英语这样以字母为基础的语言中,文本可以被视为字符序列或单词序列。这两种方法适用于不同的问题,并且在本节中我们将针对不同的任务使用它们。我们将在接下来的几节中介绍的文本数据的深度学习模型可以执行与文本相关的任务,例如
-
给一段文本分配情感分数(例如,一个产品评论是积极的还是消极的)
-
将一段文本按主题分类(例如,一篇新闻文章是关于政治、金融、体育、健康、天气还是其他)
-
将文本输入转换为文本输出(例如,用于格式标准化或机器翻译)
-
预测文本的即将出现的部分(例如,移动输入方法的智能建议功能)
此列表只是涉及文本的一小部分有趣的机器学习问题,这些问题在自然语言处理领域进行系统研究。尽管我们在本章中只是浅尝神经网络的自然语言处理技术,但这里介绍的概念和示例应该为你进一步探索提供了一个良好的起点(请参阅本章末尾的“进一步阅读资料”部分)。
请记住,本章中的深度神经网络都不真正理解文本或语言的人类意义。相反,这些模型可以将文本的统计结构映射到特定的目标空间,无论是连续情感分数、多类别分类结果还是新序列。这证明对于解决许多实际的、与文本相关的任务来说,这是足够的。自然语言处理的深度学习只是对字符和单词进行的模式识别,方式与基于深度学习的计算机视觉(第四章)对像素进行的模式识别类似。
在我们深入探讨为文本设计的深度神经网络之前,我们首先需要了解机器学习中的文本是如何表示的。
9.2.1. 机器学习中的文本表示:单热编码和多热编码
到目前为止,在本书中我们遇到的大部分输入数据都是连续的。例如,鸢尾花的花瓣长度在一定范围内连续变化;耶拿气象数据集中的天气仪读数都是实数。这些值可以直接表示为浮点型张量(浮点数)。但是,文本不同。文本数据以字符或单词的字符串形式出现,而不是实数。字符和单词是离散的。例如,在“j”和“k”之间没有类似于在 0.13 和 0.14 之间存在数字的东西。在这个意义上,字符和单词类似于多类分类中的类别(例如三种鸢尾花物种或 MobileNet 的 1,000 个输出类别)。文本数据在被馈送到深度学习模型之前需要被转换为向量(数字数组)。这个转换过程称为文本向量化。
有多种文本向量化的方式。独热编码(如我们在第三章中介绍的)是其中之一。在英语中,根据划分标准,大约有 10,000 个最常用的单词。我们可以收集这 10,000 个单词并形成一个词汇表。词汇表中的唯一单词可以按照某种顺序排列(例如,按频率降序排列),以便为任何给定的单词分配一个整数索引。^([7]) 然后,每个英文单词都可以表示为一个长度为 10,000 的向量,其中只有对应索引的元素为 1,所有其余元素为 0。这就是该单词的独热向量化。图 9.6 的 A 面以图形方式展示了这一点。
⁷
一个显而易见的问题是:如果我们遇到一个落在这 10,000 词汇表之外的罕见单词怎么办?这是任何文本导向的深度学习算法所面临的实际问题。在实践中,我们通过向词汇表添加一个名为OOV的特殊项来解决这个问题。OOV 代表词汇表之外。因此,所有不属于词汇表的罕见单词都被归类为该特殊项,并将具有相同独热编码或嵌入向量。更复杂的技术有多个 OOV 桶,并使用哈希函数将罕见单词分配到这些桶中。
图 9.6. 一个单词的独热编码(向量化)(A 面)和一个句子作为一系列单词的独热编码(B 面)。C 面展示了与 B 面中相同句子的简化的多热编码。它是一种更简洁和可扩展的序列表示,但它丢弃了顺序信息。为了可视化,我们假设词汇表的大小只有 14。实际上,在深度学习中使用的英语单词的词汇量要大得多(数量级为数千或数万,例如,10,000)。

如果我们有一个句子而不是单个单词呢?我们可以为构成句子的所有单词获得独热向量,并将它们放在一起形成句子单词的二维表示(参见图 9.6 的面板 B)。这种方法简单而明确。它完美地保留了句子中出现的单词及其顺序的信息。^([8]) 然而,当文本变得很长时,向量的大小可能会变得非常大,以至于无法管理。例如,英语句子平均包含约 18 个单词。考虑到我们的词汇量为 10,000,仅表示一个句子就需要 180,000 个数字,这已经比句子本身占用的空间大得多了。更不用说一些与文本相关的问题涉及段落或整篇文章,其中包含更多的单词,会导致表示的大小和计算量急剧增加。
⁸
这假设没有 OOV(Out of Vocabulary)词。
解决这个问题的一种方法是将所有单词都包含在一个单一向量中,以便向量中的每个元素表示对应的单词是否出现在文本中。图 9.6 的面板 C 进行了说明。在这种表示中,向量的多个元素可以具有值 1。这就是为什么人们有时将其称为多热编码。多热编码具有固定长度(词汇量的大小),不管文本有多长,因此它解决了大小爆炸的问题。但这是以失去顺序信息为代价的:我们无法从多热向量中得知哪些单词先出现,哪些单词后出现。对于一些问题,这可能是可以接受的;对于其他问题,这是不可接受的。有更复杂的表示方法来解决大小爆炸问题,同时保留顺序信息,我们将在本章后面探讨。但首先,让我们看一个具体的与文本相关的机器学习问题,可以使用多热方法以合理的准确率解决。
9.2.2. 情感分析问题的首次尝试
我们将在第一个例子中使用互联网电影数据库(IMDb)数据集来应用机器学习到文本上。该数据集是 imdb.com 上大约 25,000 条电影评论的集合,每个评论都被标记为积极或消极。机器学习任务是二元分类:即给定的电影评论是积极的还是消极的。数据集是平衡的(50% 积极评论和 50% 消极评论)。正如你从在线评论中所期望的那样,示例的单词长度各不相同。有些评论只有 10 个单词,而另一些则可以长达 2,000 个单词。以下是一个典型评论的例子。此示例被标记为消极。数据集中省略了标点符号。
这部电影中的母亲对她的孩子太粗心了,以至于忽视了,我希望我对她和她的行为不要那么生气,因为否则我会享受这部电影的,她太过分了,我建议你快进到你看到她做的事情结束,还有,有没有人厌倦看到拍得这么黑暗的电影了,观众几乎看不到正在拍摄的东西,所以我们为什么看不到夜视了呢
数据被分为训练集和评估集,当您发出类似于模型训练命令时,它们会自动从网络下载并写入您的 tmp 目录
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/sentiment
yarn
yarn train multihot
如果您仔细检查 sentiment/data.js,您会发现它下载和读取的数据文件不包含实际的单词作为字符字符串。相反,这些文件中的单词以 32 位整数表示。虽然我们不会详细介绍该文件中的数据加载代码,但值得一提的是它执行了句子的多热向量化的部分,如下一列表所示。
列表 9.5. 从 loadFeatures() 函数对句子进行多热向量化
const buffer = tf.buffer([sequences.length, numWords]); ***1***
sequences.forEach((seq, i) => { ***2***
seq.forEach(wordIndex => { ***3***
if (wordIndex !== OOV_INDEX) { ***4***
buffer.set(1, i, wordIndex); ***5***
}
});
});
-
1 创建一个 TensorBuffer 而不是一个张量,因为我们将设置其元素值。缓冲区从全零开始。
-
2 遍历所有例子,每个例子都是一个句子
-
3 每个序列(句子)都是一个整数数组。
-
4 跳过多热编码中的词汇表外(OOV)单词
-
5 将缓冲区中的相应索引设置为 1。请注意,每个索引 i 可能有多个 wordIndex 值设置为 1,因此是多热编码。
多热编码的特征被表示为一个形状为 [numExamples, numWords] 的 2D 张量,其中 numWords 是词汇表的大小(在本例中为 10,000)。这种形状不受各个句子长度的影响,这使得这成为一个简单的向量化范例。从数据文件加载的目标的形状为 [numExamples, 1],包含负面和正面标签,分别表示为 0 和 1。
我们应用于多热数据的模型是一个 MLP。实际上,即使我们想要,由于多热编码丢失了顺序信息,也无法对数据应用 RNN 模型。我们将在下一节讨论基于 RNN 的方法。创建 MLP 模型的代码来自 sentiment/train.js 中的 buildModel() 函数,简化后的代码如下列表所示。
列表 9.6. 为多热编码的 IMDb 电影评论构建 MLP 模型
const model = tf.sequential();
model.add(tf.layers.dense({ ***1***
units: 16,
activation: 'relu',
inputShape: [vocabularySize] ***2***
}));
model.add(tf.layers.dense({
units: 16,
activation: 'relu'
}));
model.add(tf.layers.dense({
units: 1,
activation: 'sigmoid' ***3***
}));
-
1 添加两个带有 relu 激活的隐藏密集层以增强表示能力
-
2 输入形状是词汇表的大小,因为我们在这里处理多热向量化。
-
3 为输出层使用 sigmoid 激活以适应二元分类任务
通过运行yarn train multihot --maxLen 500命令,可以看到模型达到大约 0.89 的最佳验证准确率。这个准确率还可以,明显高于机会的准确率(0.5)。这表明通过仅仅查看评论中出现的单词,可以在这个情感分析问题上获得一个相当合理的准确度。例如,像令人愉快和崇高这样的单词与积极的评论相关联,而像糟糕和乏味这样的单词与消极的评论相关联,并且具有相对较高的可靠性。当然,在许多情况下,仅仅看单词并不一定能得到正确的结论。举一个人为的例子,理解句子“别误会,我并不完全不同意这是一部优秀的电影”的真实含义需要考虑顺序信息——不仅是单词是什么,还有它们出现的顺序。在接下来的章节中,我们将展示通过使用一个不丢失顺序信息的文本向量化和一个能够利用顺序信息的模型,我们可以超越这个基准准确度。现在让我们看看词嵌入和一维卷积如何工作。
9.2.3. 文本的更高效表示:词嵌入
什么是词嵌入?就像一位热编码(图 9.6)一样,词嵌入是将一个单词表示为一个向量(在 TensorFlow.js 中是一个一维张量)的一种方式。然而,词嵌入允许向量的元素值被训练,而不是依据一个严格的规则进行硬编码,比如一热编码中的单词到索引映射。换句话说,当一个面向文本的神经网络使用词嵌入时,嵌入向量成为模型的可训练的权重参数。它们通过与模型的其他权重参数一样的反向传播规则进行更新。
这种情况在图 9.7 中示意。在 TensorFlow.js 中,可以使用tf.layer.embedding()层类型来执行词嵌入。它包含一个可训练的形状为[vocabularySize, embeddingDims]的权重矩阵,其中vocabularySize是词汇表中唯一单词的数量,embeddingDims是用户选择的嵌入向量的维度。每当给出一个单词,比如the,你可以使用一个单词到索引的查找表在嵌入矩阵中找到对应的行,该行就是你的单词的嵌入向量。请注意,单词到索引的查找表不是嵌入层的一部分;它是模型以外的一个单独的实体(例如,参见示例 9.9)
图 9.7. 描述嵌入矩阵工作原理的示意图。嵌入矩阵的每一行对应词汇表中的一个单词,每一列是一个嵌入维度。嵌入矩阵的元素值在图中以灰度表示,并随机选择。

如果你有一系列单词,就像图 9.7 中显示的句子一样,你需要按照正确的顺序重复这个查找过程,并将得到的嵌入向量堆叠成一个形状为[sequenceLength, embeddingDims]的二维张量,其中sequenceLength是句子中的单词数量。^([9]) 如果句子中有重复的单词(比如在图 9.7 中的例子中的the),这并不重要:只需让相同的嵌入向量在结果的二维张量中重复出现。
⁹
这种多词嵌入查找过程可以有效地使用
tf.gather()方法进行,这就是 TensorFlow.js 中嵌入层在底层实现的方式。
单词嵌入为我们带来以下好处:
-
它解决了使用独热编码的大小问题。
embeddingDims通常比vocabularySize要小得多。例如,在我们即将在 IMDb 数据集上使用的一维卷积网络中,vocabularySize为 10,000,embeddingDims为 128。因此,在来自 IMDb 数据集的 500 字评论中,表示这个例子只需要 500 * 128 = 64k 个浮点数,而不是 500 * 10,000 = 5M 个数字,就像独热编码一样——这样的向量化更经济。 -
通过不在乎词汇中单词的排序方式,并允许嵌入矩阵通过反向传播来进行训练,就像所有其他神经网络权重一样,单词嵌入可以学习单词之间的语义关系。意思相近的单词应该在嵌入空间中距离更近。例如,意思相近的单词,比如very和truly,它们的向量应该比那些意思更不同的单词的向量更接近,比如very和barely。为什么会这样?一个直观理解它的方式是意识到以下:假设你用意思相近的单词替换电影评论输入中的一些单词;一个训练良好的网络应该输出相同的分类结果。这只有当每一对单词的嵌入向量,它们是模型后续部分的输入,彼此之间非常接近时才会发生。
-
也就是说,嵌入空间具有多个维度(例如,128)的事实应该允许嵌入向量捕获单词的不同方面。例如,可能会有一个表示词性的维度,其中形容词fast与另一个形容词(如warm)比与一个名词(如house)更接近。可能还有另一个维度编码单词的性别方面,其中像actress这样的词比一个男性意义的词(如actor)更接近另一个女性意义的词(如queen)。在下一节(见 info box 9.2),我们将向您展示一种可视化单词嵌入并探索它们在对 IMDb 数据集进行嵌入式神经网络训练后出现的有趣结构的方法。
Table 9.1 提供了一个更简洁的总结,概述了一热/多热编码和词嵌入这两种最常用的词向量化范式之间的差异。
Table 9.1. 比较两种词向量化范式:one-hot/multi-hot 编码和词嵌入
| One-hot 或 multi-hot 编码 | 词嵌入 | |
|---|---|---|
| 硬编码还是学习? | 硬编码。 | 学习:嵌入矩阵是一个可训练的权重参数;这些值通常在训练后反映出词汇的语义结构。 |
| 稀疏还是密集? | 稀疏:大多数元素为零;一些为一。 | 密集:元素取连续变化的值。 |
| 可扩展性 | 不可扩展到大词汇量:向量的大小与词汇量的大小成正比。 | 可扩展到大词汇量:嵌入大小(嵌入维度数)不必随词汇量的增加而增加。 |
9.2.4. 1D 卷积网络
在 chapter 4,我们展示了 2D 卷积层在深度神经网络中对图像输入的关键作用。conv2d 层学习在图像中的小 2D 补丁中表示局部特征的方法。卷积的思想可以扩展到序列中。由此产生的算法称为1D 卷积,在 TensorFlow.js 中通过tf.layers.conv1d()函数提供。conv1d 和 conv2d 的基本思想是相同的:它们都是可训练的提取平移不变局部特征的工具。例如,一个 conv2d 层在图像任务训练后可能变得对某个方向的特定角落模式和颜色变化敏感,而一个 conv1d 层可能在文本相关任务训练后变得对“一个否定动词后跟一个赞美形容词”的模式敏感。^([10])
¹⁰
正如你可能已经猜到的那样,确实存在 3D 卷积,并且它对涉及 3D(体积)数据的深度学习任务非常有用,例如某些类型的医学图像和地质数据。
图 9.8 详细说明了 conv1d 层的工作原理。回想一下,第四章中的 图 4.3 表明,conv2d 层涉及将一个核在输入图像的所有可能位置上滑动。1D 卷积算法也涉及滑动一个核,但更简单,因为滑动仅在一个维度上发生。在每个滑动位置,都会提取输入张量的一个片段。该片段的长度为 kernelSize(conv1d 层的配置字段),在此示例中,它具有与嵌入维度数量相等的第二个维度。然后,在输入片段和 conv1d 层的核之间执行 点(乘法和加法)操作,得到一个输出序列的单个片段。这个操作会在所有有效的滑动位置上重复,直到生成完整的输出。与 conv1d 层的输入张量一样,完整的输出是一个序列,尽管它具有不同的长度(由输入序列长度、kernelSize 和 conv1d 层的其他配置确定)和不同数量的特征维度(由 conv1d 层的 filters 配置确定)。这使得可以堆叠多个 conv1d 层以形成深度的 1D convnet,就像堆叠多个 conv2d 层一样,是 2D convnet 中经常使用的技巧之一。
图 9.8. 示意图说明了 1D 卷积 (tf.layers.conv1d()) 的工作原理。为简单起见,仅显示一个输入示例(图像左侧)。假设输入序列的长度为 12,conv1d 层的核大小为 5。在每个滑动窗口位置,都会提取输入序列的长度为 5 的片段。该片段与 conv1d 层的核进行点乘,生成一个输出序列的滑动。这一过程对所有可能的滑动窗口位置重复进行,从而产生输出序列(图像右侧)。

序列截断和填充
现在我们在文本导向的机器学习中使用 conv1d,准备好在 IMDb 数据上训练 1D convnet 了吗?还不太行。还有一件事要解释:序列的截断和填充。为什么我们需要截断和填充?TensorFlow.js 模型要求 fit() 的输入是一个张量,而张量必须具有具体的形状。因此,尽管我们的电影评论长度不固定(回想一下,它们在 10 到 2,400 个单词之间变化),但我们必须选择一个特定的长度作为输入特征张量的第二个维度(maxLen),这样输入张量的完整形状就是 [numExamples, maxLen]。在前一节使用多热编码时不存在这样的问题,因为来自多热编码的张量具有不受序列长度影响的第二个张量维度。
选择 maxLen 值的考虑如下:
-
应该足够长以捕获大多数评论的有用部分。如果我们选择
maxLen为 20,可能会太短,以至于会剪掉大多数评论的有用部分。 -
它不应该太大,以至于大多数评论远远短于该长度,因为那将导致内存和计算时间的浪费。
两者的权衡使我们选择了每个评论的最大词数为 500(最大值)作为示例。这在用于训练 1D convnet 的命令中通过 --maxLen 标志指定:
yarn train --maxLen 500 cnn
一旦选择了 maxLen,所有的评论示例都必须被调整为这个特定的长度。特别是,比较长的评论被截断;比较短的评论被填充。这就是函数 padSequences() 做的事情(列表 9.7)。截断长序列有两种方式:切掉开头部分(列表 9.7 中的 'pre' 选项)或结尾部分。这里,我们选择了前一种方法,理由是电影评论的结尾部分更有可能包含与情感相关的信息。类似地,填充短序列到期望的长度有两种方式:在句子之前添加填充字符(PAD_CHAR)(列表 9.7 中的 'pre' 选项)或在句子之后添加。在这里,我们也是任意选择了前一种选项。此列表中的代码来自 sentiment/sequence_utils.js。
列表 9.7. 将文本特征加载的一步截断和填充序列
export function padSequences(
sequences, maxLen,
padding = 'pre',
truncating = 'pre',
value = PAD_CHAR) {
return sequences.map(seq => { ***1***
if (seq.length > maxLen) { ***2***
if (truncating === 'pre') { ***3***
seq.splice(0, seq.length - maxLen);
} else {
seq.splice(maxLen, seq.length - maxLen);
}
}
if (seq.length < maxLen) { ***4***
const pad = [];
for (let i = 0; i < maxLen - seq.length; ++i) {
pad.push(value); ***5***
}
if (padding === 'pre') { ***6***
seq = pad.concat(seq);
} else {
seq = seq.concat(pad);
}
}
return seq; ***7***
});
}
-
1 遍历所有的输入序列
-
2 这个特定序列比指定的长度(maxLen)长:将其截断为该长度。
-
3 有两种截断序列的方式:切掉开头 ('pre') 或结尾
-
4 序列比指定的长度短:需要填充。
-
5 生成填充序列
-
6 与截断类似,填充子长度序列有两种方式:从开头 ('pre') 或从后面开始。
-
7 注意:如果 seq 的长度恰好为 maxLen,则将原样返回。
在 IMDb 数据集上构建并运行 1D convnet
现在我们已经准备好了 1D convnet 的所有组件;让我们把它们放在一起,看看我们是否可以在 IMDb 情感分析任务上获得更高的准确率。列表 9.8 中的代码创建了我们的 1D convnet(从 sentiment/train.js 中摘录,简化了)。在此之后展示了生成的 tf.Model 对象的摘要。
列表 9.8. 构建 IMDb 问题的 1D convnet
const model = tf.sequential();
model.add(tf.layers.embedding({ ***1***
inputDim: vocabularySize, ***2***
outputDim: embeddingSize,
inputLength: maxLen
}));
model.add(tf.layers.dropout({rate: 0.5})); ***3***
model.add(tf.layers.conv1d({ ***4***
filters: 250,
kernelSize: 5,
strides: 1,
padding: 'valid',
activation: 'relu'
}));
model.add(tf.layers.globalMaxPool1d({})); ***5***
model.add(tf.layers.dense({ ***6***
units: 250, ***6***
activation: 'relu' ***6***
})); ***6***
model.add(tf.layers.dense({units: 1, activation: 'sigmoid'}));
________________________________________________________________
Layer (type) Output shape Param #
=================================================================
embedding_Embedding1 (Embedd [null,500,128] 1280000
_________________________________________________________________
dropout_Dropout1 (Dropout) [null,500,128] 0
_________________________________________________________________
conv1d_Conv1D1 (Conv1D) [null,496,250] 160250
_________________________________________________________________
global_max_pooling1d_GlobalM [null,250] 0
_________________________________________________________________
dense_Dense1 (Dense) [null,250] 62750
_________________________________________________________________
dense_Dense2 (Dense) [null,1] 251
=================================================================
Total params: 1503251
Trainable params: 1503251
Non-trainable params: 0
_________________________________________________________________
-
1 模型以嵌入层开始,它将输入的整数索引转换为相应的词向量。
-
2 嵌入层需要知道词汇量的大小。否则,它无法确定嵌入矩阵的大小。
-
3 添加一个 dropout 层以防止过拟合
-
4 接下来是 conv1D 层。
-
5 globalMaxPool1d 层通过提取每个过滤器中的最大元素值来折叠时间维度。输出准备好供后续的密集层(MLP)使用。
-
6 在模型顶部添加了一个两层的 MLP
将 JavaScript 代码和文本摘要一起查看是有帮助的。这里有几个值得注意的地方:
-
模型的形状为
[null, 500],其中null是未确定的批次维度(示例数量),500 是每个评论的最大允许单词长度(maxLen)。输入张量包含截断和填充的整数单词索引序列。 -
模型的第一层是嵌入层。它将单词索引转换为它们对应的单词向量,导致形状为
[null, 500, 128]。正如你所看到的,序列长度(500)得到保留,并且嵌入维度(128)反映在形状的最后一个元素上。 -
跟在嵌入层后面的层是 conv1d 层——这个模型的核心部分。它配置为具有大小为 5 的卷积核,默认步幅大小为 1,并且采用“valid”填充。因此,沿着序列维度有 500-5+1=496 个可能的滑动位置。这导致输出形状的第二个元素(
[null, 496, 250])中有一个值为 496。形状的最后一个元素(250)反映了 conv1d 层配置为具有的过滤器数量。 -
接在 conv1d 层后面的 globalMaxPool1d 层与我们在图像卷积网络中看到的 maxPooling2d 层有些相似。但它进行了更激烈的汇集,将沿着序列维度的所有元素折叠成一个单一的最大值。这导致输出形状为
[null, 250]。 -
现在张量具有 1D 形状(忽略批次维度),我们可以在其上构建两个密集层,形成 MLP 作为整个模型的顶部。
用命令yarn train --maxLen 500 cnn开始训练 1D 卷积网络。经过两到三个训练周期后,你会看到模型达到了约 0.903 的最佳验证准确率,相对于基于多热编码的 MLP 得到的准确率(0.890),这是一个小但坚实的提升。这反映了我们的 1D 卷积网络设法学习到的顺序信息,而这是多热编码 MLP 无法学习到的。
那么 1D 卷积网络如何捕捉顺序信息呢?它通过其卷积核来实现。卷积核的点积对元素的顺序敏感。例如,如果输入由五个单词组成,I like it so much,1D 卷积将输出一个特定的值;然而,如果单词的顺序改变为much so I like it,尽管元素集合完全相同,但 1D 卷积的输出将不同。
但需要指出的是,一维卷积层本身无法学习超出其核大小的连续模式。 例如,假设两个远离的单词的顺序影响句子的含义; 具有小于距离的核大小的 conv1d 层将无法学习长距离交互。 这是 RNN(如 GRU 和 LSTM)在一维卷积方面优于的方面之一。
一种一维卷积可以改善这一缺点的方法是深入研究-即,堆叠多个 conv1d 层,以便较高级别的 conv1d 层的“接受场”足够大,以捕获这种长距离依赖关系。 然而,在许多与文本相关的机器学习问题中,这种长距离依赖关系并不起重要作用,因此使用少量 conv1d 层的一维卷积网络就足够了。 在 IMDb 情感示例中,您可以尝试根据相同的 maxLen 值和嵌入维度训练基于 LSTM 的模型:
yarn train --maxLen 500 lstm
注意,LSTM 的最佳验证准确度(类似于但略为复杂于 GRU;请参见 figure 9.4)与一维卷积网络的最佳验证准确度大致相同。 这可能是因为长距离的单词和短语之间的相互作用对于这些电影评论和情感分类任务并不太重要。
因此,您可以看到一维卷积网络是这种文本问题的一种有吸引力的替代选择,而不是 RNN。 这在考虑到一维卷积网络的计算成本远低于 RNN 的计算成本时尤为明显。 从 cnn 和 lstm 命令中,您可以看到训练一维卷积网络的速度约为训练 LSTM 模型的六倍。 LSTM 和 RNN 的性能较慢与它们的逐步内部操作有关,这些操作无法并行化; 卷积是可以通过设计进行并行化的。
使用嵌入式投影仪可视化学习到的嵌入向量

使用嵌入式投影仪在嵌入式投影器中使用 t-SNE 维度约减可视化经过训练的一维卷积网络的词嵌入。
在训练后,一维卷积网络的词嵌入中是否出现了任何有趣的结构? 要找出,请使用 yarn train 命令的可选标志 --embeddingFilesPrefix:
yarn train --maxLen 500 cnn --epochs 2 --embeddingFilesPrefix
/tmp/imdb_embed
此命令将生成两个文件:
-
/tmp/imdb_embed_vectors.tsv-一个包含单词嵌入的数值的制表符分隔值文件。 每一行包含一个单词的嵌入向量。 在我们的情况下,有 10,000 行(我们的词汇量大小),每行包含 128 个数字(我们的嵌入维度)。
-
/tmp/imdb_embed_labels.tsv-一个由与前一个文件中的向量对应的单词标签组成的文件。 每一行是一个单词。
这些文件可以上传到嵌入投影仪(projector.tensorflow.org)进行可视化(见前面的图)。因为我们的嵌入向量驻留在一个高维(128D)空间中,所以需要将它们的维度降低到三个或更少的维度,以便人类能够理解。嵌入投影仪工具提供了两种降维算法:t-分布随机邻域嵌入(t-SNE)和主成分分析(PCA),我们不会详细讨论。但简要地说,这些方法将高维嵌入向量映射到 3D,同时确保向量之间的关系损失最小。t-SNE 是两者中更复杂、计算更密集的方法。它产生的可视化效果如图所示。
每个点云中的点对应我们词汇表中的一个单词。将鼠标光标移动到点上方,悬停在点上以查看它们对应的单词。我们在较小的情感分析数据集上训练的嵌入向量已经显示出与单词语义相关的一些有趣结构。特别是,点云的一端包含许多在积极的电影评论中经常出现的词语(例如优秀、鼓舞人心和令人愉快),而另一端则包含许多听起来消极的词语(糟糕、恶心和自命不凡)。在更大的文本数据集上训练更大的模型可能会出现更有趣的结构,但是这个小例子已经给你一些关于词嵌入方法的威力的暗示。
因为词嵌入是文本导向的深度神经网络的重要组成部分,研究人员创建了预训练词嵌入,机器学习从业者可以直接使用,无需像我们在 IMDb 卷积神经网络示例中那样训练自己的词嵌入。最著名的预训练词嵌入集之一是斯坦福自然语言处理组的 GloVe(全局向量)(参见nlp.stanford.edu/projects/glove/)。
使用预训练的词嵌入(如 GloVe)的优势是双重的。首先,它减少了训练过程中的计算量,因为嵌入层不需要进一步训练,因此可以直接冻结。其次,像 GloVe 这样的预训练嵌入是从数十亿个单词中训练出来的,因此质量比在小数据集上训练可能得到的要高得多,比如这里的 IMDb 数据集。从这些意义上讲,预训练词嵌入在自然语言处理问题中的作用类似于在计算机视觉中所见到的预训练深度卷积基(例如 MobileNet,在第五章中见过)在计算机视觉中的作用。
在网页中使用 1D 卷积神经网络进行推理
在 sentiment/index.js 中,你可以找到部署在 Node.js 中训练的模型以在客户端使用的代码。要查看客户端应用程序的运行情况,请运行命令 yarn watch,就像本书中的大多数其他示例一样。该命令将编译代码,启动一个 web 服务器,并自动打开一个浏览器选项卡以显示 index.html 页面。在页面中,你可以点击一个按钮通过 HTTP 请求加载训练好的模型,并在文本框中执行情感分析。文本框中的电影评论示例可编辑,因此你可以对其进行任意编辑,并观察实时观察到这如何影响二进制预测。页面带有两个示例评论(一个积极的评论和一个消极的评论),你可以将其用作你调试的起点。加载的 1D convnet 运行速度足够快,可以在你在文本框中输入时实时生成情感分数。
推断代码的核心很简单(参见 列表 9.9,来自 sentiment/index.js),但有几个有趣的地方值得指出:
-
该代码将所有输入文本转换为小写,丢弃标点符号,并在将文本转换为单词索引之前删除额外的空白。这是因为我们使用的词汇表只包含小写单词。
-
超出词汇表的词汇——即词汇表之外的词汇——用特殊的单词索引(
OOV_INDEX)表示。这些词汇包括罕见的词汇和拼写错误。 -
我们在训练中使用的相同
padSequences()函数(参见 列表 9.7)在此处用于确保输入到模型的张量具有正确的长度。通过截断和填充来实现这一点,正如我们之前所见。这是使用 TensorFlow.js 进行像这样的机器学习任务的一个好处的一个例子:你可以在后端训练环境和前端服务环境中使用相同的数据预处理代码,从而减少数据偏差的风险(有关数据偏差风险的更深入讨论,请参见 第六章)。
列表 9.9. 在前端使用训练好的 1D convnet 进行推断
predict(text) {
const inputText = ***1***
text.trim().toLowerCase().replace(/(\.|\,|\!)/g, '').split(' '); ***1***
const sequence = inputText.map(word => {
let wordIndex = ***2***
this.wordIndex[word] + this.indexFrom; ***2***
if (wordIndex > this.vocabularySize) {
wordIndex = OOV_INDEX; ***3***
}
return wordIndex;
});
const paddedSequence = ***4***
padSequences([sequence], this.maxLen); ***4***
const input = tf.tensor2d( ***5***
paddedSequence, [1, this.maxLen]); ***5***
const beginMs = performance.now(); ***6***
const predictOut = this.model.predict(input); ***7***
const score = predictOut.dataSync()[0];
predictOut.dispose();
const endMs = performance.now();
return {score: score, elapsed: (endMs - beginMs)};
}
-
1 转换为小写;从输入文本中删除标点符号和额外的空白
-
2 将所有单词映射到单词索引。this.wordIndex 已从 JSON 文件加载。
-
3 超出词汇表的单词被表示为特殊的单词索引:OOV_INDEX。
-
4 截断长评论,并填充短评论到所需长度
-
5 将数据转换为张量表示,以便馈送到模型中
-
6 跟踪模型推断所花费的时间
-
7 实际推断(模型的前向传递)发生在这里。
9.3. 使用注意力机制的序列到序列任务
在 Jena-weather 和 IMDb 情感示例中,我们展示了如何从输入序列中预测单个数字或类别。然而,一些最有趣的序列问题涉及根据输入序列生成输出序列。这些类型的任务被恰当地称为序列到序列(或简称为 seq2seq)任务。seq2seq 任务有很多种,以下列表只是其中的一个小子集:
-
文本摘要—给定一篇可能包含数万字的文章,生成其简洁摘要(例如,100 字或更少)。
-
机器翻译—给定一种语言(例如英语)中的一个段落,生成其在另一种语言(例如日语)中的翻译。
-
自动补全的单词预测—给定句子中的前几个单词,预测它们之后会出现什么单词。这对电子邮件应用程序和搜索引擎 UI 中的自动补全和建议非常有用。
-
音乐创作—给定一系列音符的前导序列,生成以这些音符开头的旋律。
-
聊天机器人—给定用户输入的一句话,生成一个满足某种对话目标的回应(例如,某种类型的客户支持或简单地用于娱乐聊天)。
注意力机制^([11])是一种强大且流行的用于 seq2seq 任务的方法。它通常与 RNNs 一起使用。在本节中,我们将展示如何使用注意力和 LSTMs 来解决一个简单的 seq2seq 任务,即将各种日历日期格式转换为标准日期格式。尽管这是一个有意简化的例子,但你从中获得的知识适用于像之前列出的更复杂的 seq2seq 任务。让我们首先制定日期转换问题。
¹¹
参见 Alex Graves,“Generating Sequences with Recurrent Neural Networks,”2013 年 8 月 4 日提交,
arxiv.org/abs/1308.0850;以及 Dzmitry Bahdanau,Kyunghyun Cho 和 Yoshua Bengio,“Neural Machine Translation by Jointly Learning to Align and Translate,”2014 年 9 月 1 日提交,arxiv.org/abs/1409.0473。
9.3.1. 序列到序列任务的制定
如果你像我们一样,你可能会因为写日历日期的可能方式太多而感到困惑(甚至可能有点恼火),特别是如果你去过不同的国家。有些人喜欢使用月-日-年的顺序,有些人采用日-月-年的顺序,还有些人使用年-月-日的顺序。即使在同一顺序中,对于月份是否写为单词(January)、缩写(Jan)、数字(1)或零填充的两位数字(01),也存在不同的选择。日期的选项包括是否在前面加零以及是否将其写为序数(4th 与 4)。至于年份,你可以写全四位数或只写最后两位数。而且,年、月和日的部分可以用空格、逗号、句点或斜杠连接,或者它们可以在没有任何中间字符的情况下连接在一起!所有这些选项以组合的方式结合在一起,至少产生了几十种写相同日期的方式。
因此,拥有一种算法可以将这些格式的日历日期字符串作为输入,并输出对应的 ISO-8601 格式的日期字符串(例如,2019-02-05)会很好。我们可以通过编写传统程序来非机器学习方式解决这个问题。但考虑到可能的格式数量庞大,这是一项有些繁琐且耗时的任务,结果代码很容易达到数百行。让我们尝试一种深度学习方法——特别是使用基于 LSTM 的注意力编码器-解码器架构。
为了限制本示例的范围,我们从以下示例展示的 18 种常见日期格式开始。请注意,所有这些都是写相同日期的不同方式:
"23Jan2015", "012315", "01/23/15", "1/23/15",
"01/23/2015", "1/23/2015", "23-01-2015", "23-1-2015",
"JAN 23, 15", "Jan 23, 2015", "23.01.2015", "23.1.2015",
"2015.01.23", "2015.1.23", "20150123", "2015/01/23",
"2015-01-23", "2015-1-23"
当然,还有其他日期格式。[12] 但是一旦模型训练和推理的基础奠定,添加对其他格式的支持基本上将是一项重复性的任务。我们把添加更多输入日期格式的部分留给了本章末尾的练习(练习 3)。
¹²
你可能已经注意到的另一件事是,我们使用了一组没有任何歧义的日期格式。如果我们在我们的格式集中同时包含 MM/DD/YYYY 和 DD/MM/YYYY,那么就会有含糊不清的日期字符串:即,无法确定地解释的字符串。例如,字符串“01/02/2019”可以被解释为 2019 年 1 月 2 日或 2019 年 2 月 1 日。
首先,让我们让示例运行起来。就像先前的情感分析示例一样,这个示例包括一个训练部分和一个推理部分。训练部分在后端环境中使用tfjs-node或tfjs-node-gpu运行。要启动训练,请使用以下命令:
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/sentiment
yarn
yarn train
要使用 CUDA GPU 执行训练,请在yarn train命令中使用--gpu标志:
yarn train --gpu
默认情况下,训练运行两个时期,这应该足以将损失值接近零并且转换精度接近完美。 在训练作业结束时打印的样本推断结果中,大多数,如果不是全部,结果应该是正确的。 这些推断样本来自与训练集不重叠的测试集。 训练好的模型将保存到相对路径dist/model,并将在基于浏览器的推断阶段使用。 要启动推断 UI,请使用
yarn watch
在弹出的网页中,您可以在输入日期字符串文本框中键入日期,然后按 Enter 键,观察输出日期字符串如何相应更改。 此外,具有不同色调的热图显示了转换期间使用的注意矩阵(请参见图 9.9)。 注意矩阵包含一些有趣的信息,并且是此 seq2seq 模型的核心。 它特别适合人类解释。 您应该通过与之互动来熟悉它。
图 9.9. 基于注意力的编码器-解码器在工作中进行日期转换,底部右侧显示了特定输入-输出对的注意力矩阵

让我们以图 9.9 中显示的结果为例。 模型的输出("2034-07-18")正确地转换了输入日期("JUL 18, 2034")。 注意矩阵的行对应于输入字符("J", "U", "L", " ", 等等),而列对应于输出字符("2", "0", "3", 等等)。 因此,注意矩阵的每个元素指示了在生成相应输出字符时有多少关注力放在相应的输入字符上。 元素的值越高,关注度就越高。 例如,看看最后一行的第四列: 也就是说,对应于最后一个输入字符("4")和第四个输出字符("4")的那个。 根据颜色刻度表,它具有相对较高的值。 这是有道理的,因为输出的年份部分的最后一位数字确实应该主要依赖于输入字符串的年份部分的最后一位数字。 相比之下,该列中的其他元素具有较低的值,这表明输出字符串中字符"4"的生成并未使用来自输入字符串的其他字符的太多信息。 在输出字符串的月份和日期部分也可以看到类似的模式。 鼓励您尝试使用其他输入日期格式,并查看注意矩阵如何变化。
9.3.2. 编码器-解码器架构和注意力机制
本节帮助您了解编码器-解码器架构如何解决 seq2seq 问题以及注意力机制在其中起什么作用的直觉。 机制的深入讨论将与下面的深入研究部分中的代码一起呈现。
到目前为止,我们见过的所有神经网络都输出单个项目。对于回归网络,输出只是一个数字;对于分类网络,它是对可能类别的单个概率分布。但是我们面临的日期转换问题不同:我们不是要预测单个项目,而是需要预测多个项目。具体来说,我们需要准确预测 ISO-8601 日期格式的 10 个字符。我们应该如何使用神经网络实现这一点?
解决方案是创建一个输出序列的网络。特别是,由于输出序列由来自具有确切 11 个项目的“字母表”的离散符号组成,我们让网络的输出张量形状为 3D 形状:[numExamples, OUTPUT_LENGTH, OUTPUT_VOCAB_SIZE]。第一个维度(numExamples)是传统的示例维度,使得像本书中看到的所有其他网络一样可以进行批处理。OUTPUT_LENGTH为 10,即 ISO-8601 格式输出日期字符串的固定长度。OUTPUT_VOCAB_SIZE是输出词汇表的大小(或更准确地说,“输出字母表”),其中包括数字 0 到 9 和连字符(-),以及我们稍后将讨论的一些具有特殊含义的字符。
这样就涵盖了模型的输出。那么模型的输入呢?原来,模型不是一个输入,而是两个输入。模型可以大致分为两部分,编码器和解码器,如图 9.10 所示。模型的第一个输入进入编码器部分。它是输入日期字符串本身,表示为形状为[numExamples, INPUT_LENGTH]的字符索引序列。INPUT_LENGTH是支持的输入日期格式中最大可能的长度(结果为 12)。比该长度短的输入在末尾用零填充。第二个输入进入模型的解码器部分。它是右移一个时间步长的转换结果,形状为[numExamples, OUTPUT_LENGTH]。
图 9.10. 编码器-解码器架构如何将输入日期字符串转换为输出字符串。ST是解码器输入和输出的特殊起始标记。面板 A 和 B 分别显示了转换的前两个步骤。在第一个转换步骤之后,生成了输出的第一个字符("2")。在第二步之后,生成了第二个字符("0")。其余步骤遵循相同的模式,因此被省略。

等等,第一个输入是有意义的,因为它是输入日期字符串,但是为什么模型将转换结果作为额外的输入呢?这不是模型的输出吗?关键在于转换结果的偏移。请注意,第二个输入并不完全是转换结果。相反,它是转换结果的时延版本。时延为一步。例如,在训练期间,期望的转换结果是 "2034-07-18",那么模型的第二个输入将是 "<ST>2034-07-1",其中 <ST> 是一个特殊的序列起始符号。这个偏移的输入使解码器能够意识到到目前为止已经生成的输出序列。它使解码器更容易跟踪转换过程中的位置。
这类似于人类说话的方式。当你将一个想法用语言表达出来时,你的心智努力分为两个部分:想法本身和你到目前为止所说的内容。后者对于确保连贯、完整和不重复的言论至关重要。我们的模型以类似的方式工作:为了生成每个输出字符,它使用来自输入日期字符串和到目前为止已生成的输出字符的信息。
在训练阶段,转换结果的时延效果是有效的,因为我们已经知道正确的转换结果是什么。但是在推断过程中它是如何工作的呢?答案可以在 图 9.10 的两个面板中看到:我们逐个生成输出字符。如图的面板 A 所示,我们从将一个 ST 符号置于解码器输入的开头开始。通过一步推断(一个 Model.predict() 调用),我们得到一个新的输出项(面板中的 "2")。然后,这个新的输出项被附加到解码器输入中。然后进行转换的下一步。它在解码器输入中看到了新生成的输出字符 "2"(请参阅 图 9.10 的面板 B)。这一步涉及另一个 Model.predict() 调用,并生成一个新的输出字符("0"),然后再次附加到解码器输入中。这个过程重复,直到达到所需的输出长度(在本例中为 10)。注意,输出不包括 ST 项目,因此可以直接用作整个算法的最终输出。
¹³
实现逐步转换算法的代码是
date-conversion-attention/model.js中的函数runSeq2SeqInference()。
注意机制的作用
注意机制的作用是使每个输出字符能够“关注”输入序列中的正确字符。例如,输出字符串"2034-07-18"的"7"部分应关注输入日期字符串的"JUL"部分。这与人类生成语言的方式类似。例如,当我们将语言 A 的句子翻译成语言 B 时,输出句子中的每个单词通常由输入句子中的少数单词确定。
这可能看起来显而易见:很难想象还有什么其他方法可能效果更好。但是,深度学习研究人员在 2014 年至 2015 年左右引入的注意机制的介绍是该领域的重大进展。要理解其历史原因,请查看图 9.10 A 面板中连接编码器框与解码器框的箭头。此箭头表示模型中编码器部分中 LSTM 的最后输出,该输出被传递到模型中解码器部分中的 LSTM 作为其初始状态。回想一下 RNN 的初始状态通常是全零的(例如,我们在 section 9.1.2 中使用的 simpleRNN);但是,TensorFlow.js 允许您将 RNN 的初始状态设置为任何给定形状的张量值。这可以用作向 LSTM 传递上游信息的一种方式。在这种情况下,编码器到解码器的连接使用此机制使解码器 LSTM 能够访问编码的输入序列。
但是,初始状态是将整个输入序列打包成单个向量。事实证明,对于更长且更复杂的序列(例如典型的机器翻译问题中看到的句子),这种表示方式有点太简洁了,解码器无法解压缩。这就是注意机制发挥作用的地方。
注意机制扩展了解码器可用的“视野”。不再仅使用编码器的最终输出,注意机制访问整个编码器输出序列。在转换过程的每一步中,该机制会关注编码器输出序列中特定的时间步,以决定生成什么输出字符。例如,第一次转换步骤可能会关注前两个输入字符,而第二次转换步骤则关注第二个和第三个输入字符,依此类推(见图 9.10 ,其中提供了这种注意矩阵的具体示例)。就像神经网络的所有权重参数一样,注意模型 学习 分配注意力的方式,而不是硬编码策略。这使得模型灵活且强大:它可以根据输入序列本身以及迄今为止在输出序列中生成的内容学习关注输入序列的不同部分。
在不看代码或打开编码器、解码器和注意力机制这些黑盒子的情况下,我们已经尽可能深入地讨论了编码器-解码器机制。如果你觉得这个处理过程对你来说太过高层或太模糊,请阅读下一节,我们将更深入地探讨模型的细节。这对于那些希望更深入了解基于注意力机制的编码器-解码器架构的人来说是值得付出的心智努力。要激励你去阅读它,要意识到相同的架构也支撑着一些系统,比如最先进的机器翻译模型(Google 神经网络机器翻译,或 GNMT),尽管这些生产模型使用了更多层的 LSTM 并且在比我们处理的简单日期转换模型大得多的数据上进行了训练。
9.3.3. 深入理解基于注意力机制的编码器-解码器模型
图 9.11 扩展了图 9.10 中的方框,并提供了它们内部结构的更详细视图。将它与构建模型的代码一起查看最具说明性:date-conversion-attention/model.js中的createModel()函数。接下来我们将逐步介绍代码的重要部分。
图 9.11. 深入理解基于注意力机制的编码器-解码器模型。你可以把这个图像看作是对图 9.10 中概述的编码器-解码器架构的扩展视图,显示了更细粒度的细节。

首先,我们为编码器和解码器中的嵌入和 LSTM 层定义了一些常量:
const embeddingDims = 64;
const lstmUnits = 64;
我们将构建的模型接受两个输入,因此我们必须使用功能模型 API 而不是顺序 API。我们从模型的符号输入开始,分别是编码器输入和解码器输入:
const encoderInput = tf.input({shape: [inputLength]});
const decoderInput = tf.input({shape: [outputLength]});
编码器和解码器都对它们各自的输入序列应用了一个嵌入层。编码器的代码看起来像这样
let encoder = tf.layers.embedding({
inputDim: inputVocabSize,
outputDim: embeddingDims,
inputLength,
maskZero: true
}).apply(encoderInput);
这类似于我们在 IMDb 情感问题中使用的嵌入层,但它是对字符而不是单词进行嵌入。这表明嵌入方法并不局限于单词。事实上,它足够灵活,可以应用于任何有限的、离散的集合,比如音乐类型、新闻网站上的文章、一个国家的机场等等。嵌入层的maskZero: true配置指示下游的 LSTM 跳过所有零值的步骤。这样就可以节省在已经结束的序列上的不必要计算。
LSTM 是一种我们尚未详细介绍的 RNN 类型。我们不会在这里讨论其内部结构。简而言之,它类似于 GRU(图 9.4), 通过使得在多个时间步中传递状态变得更容易来解决梯度消失的问题。Chris Olah 的博文“理解 LSTM 网络”,在本章末尾提供了指针在 “进一步阅读资料” 中,对 LSTM 结构和机制进行了出色的评述和可视化。我们的编码器 LSTM 应用在字符嵌入向量上:
encoder = tf.layers.lstm({
units: lstmUnits,
returnSequences: true
}).apply(encoder);
returnSequences: true 配置使得 LSTM 的输出是输出向量序列,而不是默认的单个向量输出(就像我们在温度预测和情感分析模型中所做的那样)。这一步是下游注意力机制所需的。
跟随编码器 LSTM 的 GetLastTimestepLayer 层是一个自定义定义的层:
const encoderLast = new GetLastTimestepLayer({
name: 'encoderLast'
}).apply(encoder);
它简单地沿着时间维度(第二维度)切片时间序列张量并输出最后一个时间步。这使我们能够将编码器 LSTM 的最终状态发送到解码器 LSTM 作为其初始状态。这种连接是解码器获取有关输入序列信息的方式之一。这在 图 9.11 中用将绿色编码器块中的 h[12] 与蓝色解码器块中的解码器 LSTM 层连接的箭头进行了说明。
代码的解码器部分以类似于编码器的拓扑结构的嵌入层和 LSTM 层开始:
let decoder = tf.layers.embedding({
inputDim: outputVocabSize,
outputDim: embeddingDims,
inputLength: outputLength,
maskZero: true
}).apply(decoderInput);
decoder = tf.layers.lstm({
units: lstmUnits,
returnSequences: true
}).apply(decoder, {initialState: [encoderLast, encoderLast]});
在代码片段的最后一行,注意编码器的最终状态如何用作解码器的初始状态。如果你想知道为什么在这里的代码的最后一行中重复使用符号张量 encoderLast,那是因为 LSTM 层包含两个状态,不像我们在 simpleRNN 和 GRU 中看到的单状态结构。
解码器更强大的另一种方式是获得输入序列的视图,当然,这是通过注意力机制实现的。注意力是编码器 LSTM 输出和解码器 LSTM 输出的点积(逐元素相乘),然后是 softmax 激活:
let attention = tf.layers.dot({axes: [2, 2]}).apply([decoder, encoder]);
attention = tf.layers.activation({
activation: 'softmax',
name: 'attention'
}).apply(attention);
编码器 LSTM 的输出形状为 [null, 12, 64],其中 12 是输入序列的长度,64 是 LSTM 的大小。解码器 LSTM 的输出形状为 [null, 10, 64],其中 10 是输出序列的长度,64 是 LSTM 的大小。在最后一个(LSTM 特征)维度上执行两者的点积,得到 [null, 10, 12] 的形状(即 [null, inputLength, outputLength])。对点积应用 softmax 将值转换为概率分数,保证它们在矩阵的每一列上都是正数且总和为 1。这是我们模型中心的注意力矩阵。其值是早期 图 9.9 中可视化的。
然后,注意力矩阵应用于编码器 LSTM 的序列输出。这是转换过程学习如何在每个步骤上关注输入序列(以其编码形式)中的不同元素的方式。将注意力应用于编码器输出的结果称为上下文:
const context = tf.layers.dot({
axes: [2, 1],
name: 'context'
}).apply([attention, encoder]);
上下文的形状为[null, 10, 64](即[null, outputLength, lstmUnits])。它与解码器的输出连接在一起,解码器的输出形状也为[null, 10, 64]。因此,连接的结果形状为[null, 10, 128]:
const decoderCombinedContext =
tf.layers.concatenate().apply([context, decoder]);
decoderCombinedContext包含进入模型最终阶段的特征向量,即生成输出字符的阶段。
输出字符使用包含一个隐藏层和一个 softmax 输出层的 MLP 生成:
let output = tf.layers.timeDistributed({
layer: tf.layers.dense({
units: lstmUnits,
activation: 'tanh'
})
}).apply(decoderCombinedContext);
output = tf.layers.timeDistributed({
layer: tf.layers.dense({
units: outputVocabSize,
activation: 'softmax'
})
}).apply(output);
多亏了timeDistributed层,所有步骤共享同一个 MLP。timeDistributed层接受一个层,并在其输入的时间维度(即第二维度)上重复调用它。这将输入特征形状从[null, 10, 128]转换为[null, 10, 13],其中 13 对应于 ISO-8601 日期格式的 11 个可能字符,以及 2 个特殊字符(填充和序列起始)。
所有组件齐备后,我们将它们组装成一个具有两个输入和一个输出的tf.Model对象:
const model = tf.model({
inputs: [encoderInput, decoderInput],
outputs: output
});
为了准备训练,我们使用分类交叉熵损失函数调用compile()方法。选择这个损失函数是基于转换问题本质上是一个分类问题——在每个时间步,我们从所有可能字符的集合中选择一个字符:
model.compile({
loss: 'categoricalCrossentropy',
optimizer: 'adam'
});
推理时,对模型的输出张量应用argMax()操作以获取获胜的输出字符。在转换的每一步中,获胜的输出字符都会附加到解码器的输入中,因此下一转换步骤可以使用它(参见图 9.11 右端的箭头)。正如我们之前提到的,这个迭代过程最终产生整个输出序列。
进一步阅读的材料
-
Chris Olah,《理解 LSTM 网络》,博客,2015 年 8 月 27 日,
mng.bz/m4Wa。 -
Chris Olah 和 Shan Carter,《注意力和增强递归神经网络》,Distill,2016 年 9 月 8 日,
distill.pub/2016/augmented-rnns/。 -
Andrej Karpathy,《递归神经网络的不合理有效性》,博客,2015 年 5 月 21 日,
mng.bz/6wK6。 -
Zafarali Ahmed,《如何使用 Keras 可视化您的递归神经网络和注意力》,Medium,2017 年 6 月 29 日,
mng.bz/6w2e。 -
在日期转换示例中,我们描述了一种基于
argMax()的解码技术。这种方法通常被称为贪婪解码技术,因为它在每一步都提取具有最高概率的输出符号。贪婪解码方法的一个流行替代方案是波束搜索解码,它检查更大范围的可能输出序列,以确定最佳序列。你可以从 Jason Brownlee 的文章“如何为自然语言处理实现波束搜索解码器”中了解更多信息,2018 年 1 月 5 日,machinelearningmastery.com/beam-search-decoder-natural-language-processing/。 -
Stephan Raaijmakers,《自然语言处理的深度学习》,Manning Publications,在出版中,www.manning.com/books/deep-learning-for-natural-language-processing。
练习
-
尝试重新排列各种非连续数据的数据元素顺序。确认这种重新排序对建模的损失指标值(例如准确度)没有影响(超出由权重参数的随机初始化引起的随机波动)。你可以为以下两个问题进行此操作:
-
在鸢尾花示例(来自第三章)中,通过修改行来重新排列四个数字特征(花瓣长度、花瓣宽度、萼片长度和萼片宽度)的顺序
shuffledData.push(data[indices[i]]);在 tfjs-examples 仓库的 iris/data.js 文件中。特别是,改变
data[indices[i]]中四个元素的顺序。这可以通过 JavaScript 数组的slice()和concat()方法来完成。请注意,所有示例的重新排列顺序应该是相同的。你可以编写一个 JavaScript 函数来执行重新排序。 -
在我们为 Jena 气象问题开发的线性回归器和 MLP 中,尝试重新排列 240 个时间步长和14 个数字特征(气象仪器测量)。具体来说,你可以通过修改 jena-weather/data.js 中的
nextBatchFn()函数来实现这一点。实现重新排序最容易的地方是samples.set(value, j, exampleRow, exampleCol++);在这里,你可以使用一个执行固定排列的函数将索引
exampleRow映射到一个新值,并以类似的方式映射exampleCol。
-
-
我们为 IMDb 情感分析构建的 1D 卷积神经网络仅包含一个 conv1d 层(参见清单 9.8)。正如我们讨论的那样,在其上叠加更多的 conv1d 层可能会给我们一个更深的 1D 卷积神经网络,能够捕捉到更长一段单词的顺序信息。在这个练习中,尝试修改 sentiment/train.js 中
buildModel()函数中的代码。目标是在现有的层之后添加另一个 conv1d 层,重新训练模型,并观察其分类精度是否有所提高。新的 conv1d 层可以使用与现有层相同数量的滤波器和内核大小。此外,请阅读修改后模型的摘要中的输出形状,并确保您理解filters和kernelSize参数如何影响新 conv1d 层的输出形状。 -
在日期转换注意事项示例中,尝试添加更多的输入日期格式。以下是您可以选择的新格式,按照编码难度递增的顺序排序。您也可以自己想出自己的日期格式:
-
YYYY-MMM-DD 格式:例如,“2012 年 3 月 8 日”或“2012 年 3 月 18 日”。根据单个数字日期是否在前面补零(如 2015/03/12),这实际上可能是两种不同的格式。但是,无论如何填充,此格式的最大长度都小于 12,并且所有可能的字符都已经在 date-conversion-attention/date_format.js 中的
INPUT_VOCAB中。因此,只需向文件添加一个或两个函数即可,这些函数可以模仿现有函数,例如dateTupleToMMMSpaceDDSpaceYY()。确保将新函数添加到文件中的INPUT_FNS数组中,以便它们可以包含在训练中。作为最佳实践,您还应该为新的日期格式函数添加单元测试到 date-conversion-attention/date_format_test.js 中。 -
一个使用序数作为日期部分的格式,比如“3 月 8 日,2012 年”。请注意,这与现有的
dateTupleToMMMSpaceDDComma-SpaceYYYY()格式相同,只是日期数字后缀了序数后缀("st","nd"和"th")。你的新函数应该包括根据日期值确定后缀的逻辑。此外,你需要将date_format_test.js中的INPUT_LENGTH常量修改为一个更大的值,因为此格式中日期字符串的最大可能长度超过了当前值 12。此外,需要将字母"t"和"h"添加到INPUT_VOCAB中,因为它们不出现在任何三个字母月份字符串中。 -
现在考虑一个使用完整的英文月份名称拼写的格式,比如“2012 年 3 月 8 日”。输入日期字符串的最大可能长度是多少?你应该如何相应地更改
date_format.js中的INPUT_VOCAB?
-
摘要
-
由于能够提取和学习事物的顺序信息,循环神经网络(RNN)可以在涉及顺序输入数据的任务中胜过前馈模型(例如 MLP)。我们通过将 simpleRNN 和 GRU 应用于温度预测问题的示例来看到这一点。
-
TensorFlow.js 提供了三种类型的 RNN:simpleRNN、GRU 和 LSTM。后两种类型比 simpleRNN 更复杂,因为它们使用更复杂的内部结构来使得能够在许多时间步骤中保持内存状态,从而缓解了梯度消失问题。GRU 的计算量比 LSTM 小。在大多数实际问题中,您可能希望使用 GRU 和 LSTM。
-
在构建文本的神经网络时,文本输入首先需要表示为数字向量。这称为文本向量化。文本向量化的最常用方法包括 one-hot 和 multi-hot 编码,以及更强大的嵌入方法。
-
在词嵌入中,每个单词被表示为一个稀疏向量,其中元素值通过反向传播学习,就像神经网络的所有其他权重参数一样。在 TensorFlow.js 中执行嵌入的函数是
tf.layers.embedding()。 -
seq2seq 问题与基于序列的回归和分类问题不同,因为它们涉及生成一个新序列作为输出。循环神经网络(RNN)可以与其他类型的层一起用于形成编码器-解码器架构来解决 seq2seq 问题。
-
在 seq2seq 问题中,注意机制使得输出序列的不同项能够选择性地依赖于输入序列的特定元素。我们演示了如何训练基于注意力的编码器-解码器网络来解决简单的日期转换问题,并在推断过程中可视化注意力矩阵。
第十一章:生成深度学习
这一章涵盖了
-
生成深度学习是什么,它的应用以及它与我们迄今看到的深度学习任务有何不同
-
如何使用 RNN 生成文本
-
什么是潜在空间以及它如何成为生成新图像的基础,通过变分自编码器示例
-
生成对抗网络的基础知识
深度神经网络展示了生成看起来或听起来真实的图像、声音和文本的一些令人印象深刻的任务。如今,深度神经网络能够创建高度真实的人脸图像,([1])合成自然音质的语音,([2])以及组织连贯有力的文本,([3])这仅仅是一些成就的名单。这种*生成*模型在许多方面都很有用,包括辅助艺术创作,有条件地修改现有内容,以及增强现有数据集以支持其他深度学习任务。([4])
¹
Tero Karras, Samuli Laine 和 Timo Aila, “一种基于风格的生成对抗网络,” 提交日期:2018 年 12 月 12 日,
arxiv.org/abs/1812.04948. 在thispersondoesnotexist.com/查看演示。²
Aäron van den Oord 和 Sander Dieleman, “WaveNet: 一种用于原始音频的生成模型,” 博客, 2016 年 9 月 8 日,
mng.bz/MOrn.³
“更好的语言模型及其影响”,OpenAI, 2019,
openai.com/blog/better-language-models/.⁴
Antreas Antoniou, Amos Storkey 和 Harrison Edwards, “数据增强生成对抗网络,” 提交日期:2017 年 11 月 12 日,
arxiv.org/abs/1711.04340.
除了在潜在顾客的自拍照上化妆等实际应用外,生成模型还值得从理论上研究。生成模型和判别模型是机器学习中两种根本不同类型的模型。到目前为止,我们在本书中研究的所有模型都是判别模型。这些模型旨在将输入映射到离散或连续的值,而不关心生成输入的过程。回想一下,我们构建的网络针对钓鱼网站、鸢尾花、MNIST 数字和音频声音的分类器,以及对房价进行回归的模型。相比之下,生成模型旨在数学地模拟不同类别示例生成的过程。但是一旦生成模型学习到这种生成性知识,它也可以执行判别性任务。因此,与判别模型相比,可以说生成模型“更好地理解”数据。
本节介绍了文本和图像的深度生成模型的基础知识。在本章结束时,您应该熟悉基于 RNN 的语言模型、面向图像的自编码器和生成对抗网络的思想。您还应该熟悉这些模型在 TensorFlow.js 中的实现方式,并能够将这些模型应用到您自己的数据集上。
10.1. 使用 LSTM 生成文本
让我们从文本生成开始。为此,我们将使用我们在前一章中介绍的 RNN。虽然您将在这里看到的技术生成文本,但它并不局限于这个特定的输出领域。该技术可以适应生成其他类型的序列,比如音乐——只要能够以合适的方式表示音符,并找到一个足够的训练数据集。[5]类似的思想可以应用于生成素描中的笔画,以便生成漂亮的素描[6],甚至是看起来逼真的汉字[7]。
⁵
例如,请参阅 Google 的 Magenta 项目中的 Performance-RNN:
magenta.tensorflow.org/performance-rnn。⁶
例如,请参阅 David Ha 和 Douglas Eck 的 Sketch-RNN:
mng.bz/omyv。⁷
David Ha,“Recurrent Net Dreams Up Fake Chinese Characters in Vector Format with TensorFlow”,博客,2015 年 12 月 28 日,
mng.bz/nvX4。
10.1.1. 下一个字符预测器:生成文本的简单方法
首先,让我们定义文本生成任务。假设我们有一个相当大的文本数据语料库(至少几兆字节)作为训练输入,比如莎士比亚的全部作品(一个非常长的字符串)。我们想要训练一个模型,尽可能地生成看起来像训练数据的新文本。这里的关键词当然是“看起来”。现在,让我们满足于不精确地定义“看起来”的含义。在展示方法和结果之后,这个意义将变得更加清晰。
让我们思考如何在深度学习范式中制定这个任务。在前一章节涉及的日期转换示例中,我们看到一个精确格式化的输出序列可以从一个随意格式化的输入序列中生成。那个文本到文本的转换任务有一个明确定义的答案:ISO-8601 格式中的正确日期字符串。然而,这里的文本生成任务似乎不适合这一要求。没有明确的输入序列,并且“正确”的输出并没有明确定义;我们只想生成一些“看起来真实的东西”。我们能做什么呢?
一个解决方案是构建一个模型,预测在一系列字符之后会出现什么字符。这被称为 下一个字符预测。例如,对于在莎士比亚数据集上训练良好的模型,当给定字符串“Love looks not with the eyes, b”作为输入时,应该以高概率预测字符“u”。然而,这只生成一个字符。我们如何使用模型生成一系列字符?为了做到这一点,我们简单地形成一个与之前相同长度的新输入序列,方法是将前一个输入向左移动一个字符,丢弃第一个字符,并将新生成的字符(“u”)粘贴到末尾。在这种情况下,我们的下一个字符预测器的新输入就是“ove looks not with the eyes, bu”。给定这个新的输入序列,模型应该以高概率预测字符“t”。这个过程,如图 10.1 所示,可以重复多次,直到生成所需长度的序列。当然,我们需要一个初始的文本片段作为起点。为此,我们可以从文本语料库中随机抽样。
图 10.1. 用基于 RNN 的下一个字符预测器生成文本序列的示意图,以初始输入文本片段作为种子。在每个步骤中,RNN 使用输入文本预测下一个字符。然后,将输入文本与预测的下一个字符连接起来,丢弃第一个字符。结果形成下一个步骤的输入。在每个步骤中,RNN 输出字符集中所有可能字符的概率分数。为了确定实际的下一个字符,进行随机抽样。

这种表述将序列生成任务转化为基于序列的分类问题。这个问题类似于我们在第九章中看到的 IMDb 情感分析问题,其中从固定长度的输入中预测二进制类别。文本生成模型基本上做了同样的事情,尽管它是一个多类别分类问题,涉及到 N 个可能的类别,其中 N 是字符集的大小——即文本数据集中所有唯一字符的数量。
这种下一个字符预测的表述在自然语言处理和计算机科学中有着悠久的历史。信息论先驱克劳德·香农进行了一项实验,在实验中,被要求的人类参与者在看到一小段英文文本后猜测下一个字母。[8] 通过这个实验,他能够估计出在给定上下文的情况下,典型英文文本中每个字母的平均不确定性。这种不确定性约为 1.3 位的熵,告诉我们每个英文字母所携带的平均信息量。
⁸
1951 年的原始论文可在
mng.bz/5AzB中获取。
当字母以完全随机的方式出现时,1.3 位的结果比如果 26 个字母完全随机出现所需的位数要少,该数值为 log2 = 4.7 位数。这符合我们的直觉,因为我们知道英语字母并不是随机出现的,而是具有某些模式。在更低的层次上,只有某些字母序列是有效的英语单词。在更高的层次上,只有某些单词的排序满足英语语法。在更高的层次上,只有某些语法上有效的句子实际上是有意义的。
如果你考虑一下,这正是我们的文本生成任务的基础所在:学习所有这些层面的模式。注意,我们的模型基本上是被训练来做 Shannon 实验中的那个志愿者所做的事情——也就是猜测下一个字符。现在,让我们来看一下示例代码以及它是如何工作的。请记住 Shannon 的 1.3 位结果,因为我们稍后会回到它。
10.1.2《LSTM-text-generation》示例
在 tfjs-examples 仓库中的 lstm-text-generation 示例中,我们训练了一个基于 LSTM 的下一个字符预测器,并利用它生成了新的文本。训练和生成都在 JavaScript 中使用 TensorFlow.js 完成。你可以在浏览器中或者使用 Node.js 运行示例。前者提供了更加图形化和交互式的界面,但后者具有更快的训练速度。
要在浏览器中查看此示例的运行情况,请使用以下命令:
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/lstm-text-generation
yarn && yarn watch
在弹出的页面中,你可以选择并加载四个提供的文本数据集中的一个来训练模型。在下面的讨论中,我们将使用莎士比亚的数据集。一旦数据加载完成,你可以点击“创建模型”按钮为它创建一个模型。一个文本框允许你调整创建的 LSTM 将具有的单元数。它默认设置为 128。但你也可以尝试其他值,例如 64。如果你输入由逗号分隔的多个数字(例如 128,128),则创建的模型将包含多个叠放在一起的 LSTM 层。
若要使用 tfjs-node 或 tfjs-node-gpu 在后端执行训练,请使用 yarn train 命令而不是 yarn watch:
yarn train shakespeare \
--lstmLayerSize 128,128 \
--epochs 120 \
--savePath ./my-shakespeare-model
如果你已经正确地设置了 CUDA-enabled GPU,可以在命令中添加 --gpu 标志,让训练过程在 GPU 上运行,这将进一步加快训练速度。--lstmLayerSize 标志在浏览器版本的示例中起到了 LSTM-size 文本框的作用。前面的命令将创建并训练一个由两个 LSTM 层组成的模型,每个 LSTM 层都有 128 个单元,叠放在一起。
此处正在训练的模型具有堆叠 LSTM 架构。堆叠 LSTM 层是什么意思?在概念上类似于在 MLP 中堆叠多个密集层,这增加了 MLP 的容量。类似地,堆叠多个 LSTM 允许输入序列在被最终 LSTM 层转换为最终回归或分类输出之前经历多个 seq2seq 表示转换阶段。图 10.2 给出了这种架构的图解。一个重要的事情要注意的是,第一个 LSTM 的returnSequence属性被设置为true,因此生成包括输入序列的每个单个项目的输出序列。这使得可以将第一个 LSTM 的输出馈送到第二个 LSTM 中,因为 LSTM 层期望顺序输入而不是单个项目输入。
图 10.2. 在模型中如何堆叠多个 LSTM 层。在这种情况下,两个 LSTM 层被堆叠在一起。第一个 LSTM 的returnSequence属性被设置为true,因此输出一个项目序列。第一个 LSTM 的序列输出被传递给第二个 LSTM 作为其输入。第二个 LSTM 输出一个单独的项目而不是项目序列。单个项目可以是回归预测或 softmax 概率数组,它形成模型的最终输出。

清单 10.1 包含构建下一个字符预测模型的代码,其架构如图 10.2 所示(摘自 lstm-text-generation/model.js)。请注意,与图表不同,代码包括一个稠密层作为模型的最终输出。密集层具有 softmax 激活。回想一下,softmax 激活将输出归一化,使其值介于 0 和 1 之间,并总和为 1,就像概率分布一样。因此,最终的密集层输出表示唯一字符的预测概率。
createModel() 函数的 lstmLayerSize 参数控制 LSTM 层的数量和每个层的大小。第一个 LSTM 层的输入形状根据 sampleLen(模型一次接收多少个字符)和 charSetSize(文本数据中有多少个唯一字符)进行配置。对于基于浏览器的示例,sampleLen 是硬编码为 40 的;对于基于 Node.js 的训练脚本,可以通过 --sampleLen 标志进行调整。对于莎士比亚数据集,charSetSize 的值为 71。字符集包括大写和小写英文字母、标点符号、空格、换行符和几个其他特殊字符。给定这些参数,清单 10.1 中的函数创建的模型具有输入形状 [40, 71](忽略批处理维度)。该形状对应于 40 个 one-hot 编码字符。模型的输出形状是 [71](同样忽略批处理维度),这是下一个字符的 71 种可能选择的 softmax 概率值。
清单 10.1. 构建一个用于下一个字符预测的多层 LSTM 模型
export function createModel(sampleLen, ***1***
charSetSize, ***2***
lstmLayerSizes) { ***3***
if (!Array.isArray(lstmLayerSizes)) {
lstmLayerSizes = [lstmLayerSizes];
}
const model = tf.sequential();
for (let i = 0; i < lstmLayerSizes.length; ++i) {
const lstmLayerSize = lstmLayerSizes[i];
model.add(tf.layers.lstm({ ***4***
units: lstmLayerSize,
returnSequences: i < lstmLayerSizes.length - 1, ***5***
inputShape: i === 0 ?
[sampleLen, charSetSize] : undefined ***6***
}));
}
model.add(
tf.layers.dense({
units: charSetSize,
activation: 'softmax'
})); ***7***
return model;
}
-
1 模型输入序列的长度
-
2 所有可能的唯一字符的数量
-
3 模型的 LSTM 层的大小,可以是单个数字或数字数组
-
4 模型以一堆 LSTM 层开始。
-
5 设置
returnSequences为true以便可以堆叠多个 LSTM 层 -
6 第一个 LSTM 层是特殊的,因为它需要指定其输入形状。
-
7 模型以一个密集层结束,其上有一个 softmax 激活函数,适用于所有可能的字符,反映了下一个字符预测问题的分类特性。
为了准备模型进行训练,我们使用分类交叉熵损失对其进行编译,因为该模型本质上是一个 71 路分类器。对于优化器,我们使用 RMSProp,这是递归模型的常用选择:
const optimizer = tf.train.rmsprop(learningRate);
model.compile({optimizer: optimizer, loss: 'categoricalCrossentropy'});
输入模型训练的数据包括输入文本片段和每个片段后面的字符的对,所有这些都编码为 one-hot 向量(参见图 10.1)。在 lstm-text-generation/data.js 中定义的 TextData 类包含从训练文本语料库生成此类张量数据的逻辑。那里的代码有点乏味,但思想很简单:随机从我们的文本语料库中的非常长的字符串中抽取固定长度的片段,并将它们转换为 one-hot 张量表示。
如果您正在使用基于 Web 的演示,页面的“模型训练”部分允许您调整超参数,例如训练时期的数量、每个时期进入的示例数量、学习率等等。单击“训练模型”按钮启动模型训练过程。对于基于 Node.js 的训练,这些超参数可以通过命令行标志进行调整。有关详细信息,您可以通过输入 yarn train --help 命令获取帮助消息。
根据您指定的训练周期数和模型大小,训练时间可能会在几分钟到几个小时之间不等。基于 Node.js 的训练作业在每个训练周期结束后会自动打印模型生成的一些示例文本片段(见 表格 10.1)。随着训练的进行,您应该看到损失值从初始值约为 3.2 不断降低,并在 1.4–1.5 的范围内收敛。大约经过 120 个周期后,损失减小后,生成的文本质量应该会提高,以至于在训练结束时,文本应该看起来有些像莎士比亚的作品,而验证损失应该接近 1.5 左右——并不远离香农实验中的每字符信息不确定性 1.3 比特。但请注意,考虑到我们的训练范式和模型容量,生成的文本永远不会像实际的莎士比亚的写作。
表格 10.1. 基于 LSTM 的下一字符预测模型生成的文本样本。生成基于种子文本。初始种子文本:" "在每小时的关于你的特定繁荣的议会中,和 lo"。^([a]) 根据种子文本后续的实际文本(用于比较):"爱你不会比你的老父亲梅奈尼乌斯对你更差!..."。
^a
摘自 莎士比亚的《科里奥兰纳斯》,第 5 幕,第 2 场。请注意,示例中包括换行和单词中间的停顿(love)。
| 训练周期 | 验证损失 | T = 0 | T = 0.25 | T = 0.5 | T = 0.75 |
|---|---|---|---|---|---|
| 5 | 2.44 | "rle the the the the the the the the the the the the the the the the the the the the the the the the the the the the the " | "te ans and and and and and warl torle an at an yawl and tand and an an ind an an in thall ang ind an tord and and and wa" | "te toll nlatese ant ann, tomdenl, teurteeinlndting fall ald antetetell linde ing thathere taod winld mlinl theens tord y" | "p, af ane me pfleh; fove this? Iretltard efidestind ants anl het insethou loellr ard, |
| 25 | 1.96 | "ve tray the stanter an truent to the stanter to the stanter to the stanter to the stanter to the stanter to the stanter " | "ve to the enter an truint to the surt an truin to me truent me the will tray mane but a bean to the stanter an trust tra" | "ve of marter at it not me shank to an him truece preater the beaty atweath and that marient shall me the manst on hath s" | "rd; not an an beilloters An bentest the like have bencest on it love gray to dreath avalace the lien I am sach me, m" |
| 50 | 1.67 | "世界的世界的世界的世界的世界的世界的世界的世界的世界的世界的世界" | "他们是他们的英语是世界的世界的立场的证明了他们的弦应该世界我" | "他们的愤怒的苦恼的,因为你对于你的设备的现在的将会" | "是我的光,我将做 vall twell。斯伯" |
| 100 | 1.61 | "越多的人越多,越奇怪的是,越奇怪的是,越多的人越多" | "越多的人越多越多" | "越多的人越多。为了这样一个内容," | "和他们的 consent,你将会变成三个。长的和一个心脏和不奇怪的。一位 G" |
| 120 | 1.49 | "打击的打击的打击的打击的打击的打击和打击的打击的打击" | "亲爱的打击我的排序的打击,打击打击,亲爱的打击和" | "为他的兄弟成为这样的嘲笑。一个模仿的" | "这是我的灵魂。Monty 诽谤他你的矫正。这是为了他的兄弟,这是愚蠢的" |
表格 10.1 展示了在四个不同 温度值 下采样的一些文本,这是一个控制生成文本随机性的参数。在生成文本的样本中,您可能已经注意到,较低的温度值与更多重复和机械化的文本相关联,而较高的值与不可预测的文本相关联。由 Node.js 的训练脚本演示的最高温度值默认为 0.75,有时会导致看起来像英语但实际上不是英语单词的字符序列(例如表格中的“stratter”和“poins”)。在接下来的部分中,我们将探讨温度是如何工作的,以及为什么它被称为温度。
10.1.3. 温度:生成文本中的可调随机性
列表 10.2 中的函数 sample() 负责根据模型在文本生成过程的每一步的输出概率来确定选择哪个字符。正如您所见,该算法有些复杂:它涉及到三个低级 TensorFlow.js 操作的调用:tf.div()、tf.log() 和 tf.multinomial()。为什么我们使用这种复杂的算法而不是简单地选择具有最高概率得分的选项,这将需要一个单独的 argMax() 调用呢?
如果我们这样做,文本生成过程的输出将是确定性的。也就是说,如果你多次运行它,它将给出完全相同的输出。到目前为止,我们所见到的深度神经网络都是确定性的,也就是说,给定一个输入张量,输出张量完全由网络的拓扑结构和其权重值决定。如果需要的话,你可以编写一个单元测试来断言其输出值(见第十二章讨论机器学习算法的测试)。对于我们的文本生成任务来说,这种确定性并不理想。毕竟,写作是一个创造性的过程。即使给出相同的种子文本,生成的文本也更有趣些带有一些随机性。这就是tf.multinomial()操作和温度参数有用的地方。tf.multinomial()是随机性的来源,而温度控制着随机性的程度。
列表 10.2。带有温度参数的随机抽样函数
export function sample(probs, temperature) {
return tf.tidy(() => {
const logPreds = tf.div(
tf.log(probs), ***1***
Math.max(temperature, 1e-6)); ***2***
const isNormalized = false;
return tf.multinomial(logPreds, 1, null, isNormalized).dataSync()[0]; ***3***
});
}
-
1 模型的密集层输出归一化的概率分数;我们使用 log()将它们转换为未归一化的 logits,然后再除以温度。
-
2 我们用一个小的正数来防止除以零的错误。除法的结果是调整了不确定性的 logits。
-
3
tf.multinomial()是一个随机抽样函数。它就像一个多面的骰子,每个面的概率不相等,由 logPreds——经过温度缩放的 logits 来确定。
在列表 10.2 的sample()函数中最重要的部分是以下行:
const logPreds = tf.div(tf.log(probs),
Math.max(temperature, 1e-6));
它获取了probs(模型的概率输出)并将它们转换为logPreds,概率的对数乘以一个因子。对数运算(tf.log())和缩放(tf.div())做了什么?我们将通过一个例子来解释。为了简单起见,假设只有三个选择(字符集中的三个字符)。假设我们的下一个字符预测器在给定某个输入序列时产生了以下三个概率分数:
[0.1, 0.7, 0.2]
让我们看看两个不同的温度值如何改变这些概率。首先,让我们看一个相对较低的温度:0.25。缩放后的 logits 是
log([0.1, 0.7, 0.2]) / 0.25 = [-9.2103, -1.4267, -6.4378]
要理解 logits 的含义,我们通过使用 softmax 方程将它们转换回实际的概率分数,这涉及将 logits 的指数和归一化:
exp([-9.2103, -1.4267, -6.4378]) / sum(exp([-9.2103, -1.4267, -6.4378]))
= [0.0004, 0.9930, 0.0066]
正如你所看到的,当温度为 0.25 时,我们的 logits 对应一个高度集中的概率分布,在这个分布中,第二个选择的概率远高于其他两个选择(见图 10.3 的第二面板)。
图 10.3. 不同温度(T)值缩放后的概率得分。较低的 T 值导致分布更集中(更少随机);较高的 T 值导致分布在类别之间更均等(更多随机)。T 值为 1 对应于原始概率(无变化)。请注意,无论 T 的值如何,三个选择的相对排名始终保持不变。

如果我们使用更高的温度,比如说 0.75,通过重复相同的计算,我们得到
log([0.1, 0.7, 0.2]) / 0.75 = [-3.0701, -0.4756, -2.1459]
exp([-3.0701, -0.4756, -2.1459]) / sum([-3.0701, -0.4756, -2.1459])
= [0.0591, 0.7919 0.1490]
与之前的情况相比,这是一个峰值较低的分布,当温度为 0.25 时(请参阅图 10.3 的第四面板)。但是与原始分布相比,它仍然更尖峭。你可能已经意识到,温度为 1 时,你将得到与原始概率完全相同的结果(图 10.3,第五面板)。大于 1 的温度值会导致选择之间的概率分布更“均等”(图 10.3,第六面板),而选择之间的排名始终保持不变。
这些转换后的概率(或者说它们的对数)然后被馈送到 tf.multinomial() 函数中,该函数的作用类似于一个多面骰子,其面的不等概率由输入参数控制。这给我们了下一个字符的最终选择。
所以,这就是温度参数如何控制生成文本的随机性。术语 temperature 源自热力学,我们知道,温度较高的系统内部混乱程度较高。这个类比在这里是合适的,因为当我们在代码中增加温度值时,生成的文本看起来更加混乱。温度值有一个“甜蜜的中间值”。在此之下,生成的文本看起来太重复和机械化;在此之上,文本看起来太不可预测和古怪。
这结束了我们对文本生成 LSTM 的介绍。请注意,这种方法非常通用,可以应用于许多其他序列,只需进行适当的修改即可。例如,如果在足够大的音乐分数数据集上进行训练,LSTM 可以通过逐步从之前的音符中预测下一个音符来作曲。^([9])
⁹
Allen Huang 和 Raymond Wu,“Deep Learning for Music”,2016 年 6 月 15 日提交,
arxiv.org/abs/1606.04930。
10.2. 变分自动编码器:找到图像的高效和结构化的向量表示
前面的部分为您介绍了如何使用深度学习来生成文本等连续数据。在本章的剩余部分,我们将讨论如何构建神经网络来生成图像。我们将研究两种类型的模型:变分自编码器(VAE)和生成对抗网络(GAN)。与 GAN 相比,VAE 的历史更悠久,结构更简单。因此,它为您进入基于深度学习的图像生成的快速领域提供了很好的入口。
10.2.1. 传统自编码器和 VAE: 基本概念
图 10.4 以示意方式显示了自编码器的整体架构。乍一看,自编码器是一个有趣的模型,因为它的输入和输出模型的图像大小是相同的。在最基本的层面上,自编码器的损失函数是输入和输出之间的均方误差(MSE)。这意味着,如果经过适当训练,自编码器将接受一个图像,并输出一个几乎相同的图像。这种模型到底有什么用呢?
图 10.4. 传统自编码器的架构

实际上,自编码器是一种重要的生成模型,而且绝不是无用的。对于前面的问题答案在于小时钟形状的架构(图 10.4)。自编码器的最细部分是一个与输入和输出图像相比具有更少元素的向量。因此,由自编码器执行的图像转换是非平凡的:它首先将输入图像转变为高压缩形式的表示,然后在不使用任何额外信息的情况下从该表示中重新构建图像。中间的有效表示称为潜在向量,或者z-向量。我们将这两个术语互换使用。这些向量所在的向量空间称为潜在空间,或者z-空间。将输入图像转换为潜在向量的自编码器部分称为编码器;将潜在向量转换回图像的后面部分称为解码器。
和图像本身相比,潜在向量可以小几百倍,我们很快会通过一个具体的例子进行展示。因此,经过训练的自编码器的编码器部分是一个非常高效的维度约简器。它对输入图像的总结非常简洁,但包含足够重要的信息,以使得解码器可以忠实地复制输入图像,而不需要使用额外的信息。解码器能够做到这一点,这也是非常了不起的。
我们还可以从信息理论的角度来看待自编码器。假设输入和输出图像各包含N比特的信息。从表面上看,N是每个像素的位深度乘以像素数量。相比之下,自编码器中间的潜在向量由于其小的大小(假设为m比特),只能保存极少量的信息。如果m小于N,那么从潜在向量重构出图像就在理论上不可能。然而,图像中的像素不是完全随机的(完全由随机像素组成的图像看起来像静态噪音)。相反,像素遵循某些模式,比如颜色连续性和所描绘的现实世界对象的特征。这导致N的值比基于像素数量和深度的表面计算要小得多。自编码器的任务是学习这种模式;这也是自编码器能够工作的原因。
在自编码器训练完成后,其解码器部分可以单独使用,给定任何潜在向量,它都可以生成符合训练图像的模式和风格的图像。这很好地符合了生成模型的描述。此外,潜在空间将有望包含一些良好的可解释结构。具体而言,潜在空间的每个维度可能与图像的某个有意义的方面相关联。例如,假设我们在人脸图像上训练了一个自编码器,也许潜在空间的某个维度将与微笑程度相关。当你固定潜在向量中所有其他维度的值,仅变化“微笑维度”的值时,解码器产生的图像将是同一张脸,但微笑程度不同(例如,参见图 10.5)。这将使得有趣的应用成为可能,例如在保持所有其他方面不变的情况下,改变输入人脸图像的微笑程度。可以通过以下步骤来完成此操作。首先,通过应用编码器获取输入的潜在向量。然后,仅修改向量的“微笑维度”即可;最后,通过解码器运行修改后的潜在向量。
图 10.5. “微笑维度”。自编码器所学习的潜在空间中期望的结构的示例。

不幸的是,图 10.4 中所示的 经典自编码器 并不能产生特别有用和良好结构的潜变量空间。它们在压缩方面也不太出色。因此,到 2013 年,它们在很大程度上已经不再流行了。VAE(Variational Autoencoder)则在 2013 年 12 月由 Diederik Kingma 和 Max Welling 几乎同时发现^([10]),而在 2014 年 1 月由 Danilo Rezende、Shakir Mohamed 和 Daan Wiestra 发现^([11]),通过一点统计魔法增加了自编码器的能力,强制模型学习连续且高度结构化的潜变量空间。VAE 已经证明是一种强大的生成式图像模型。
¹⁰
Diederik P. Kingma 和 Max Welling,“Auto-Encoding Variational Bayes”,2013 年 12 月 20 日提交,
arxiv.org/abs/1312.6114。¹¹
Danilo Jimenez Rezende,Shakir Mohamed 和 Daan Wierstra,“Stochastic Backpropagation and Approximate Inference in Deep Generative Models”,2014 年 1 月 16 日提交,
arxiv.org/abs/1401.4082。
VAE 不是将输入图像压缩为潜变量空间中的固定向量,而是将图像转化为统计分布的参数——具体来说是高斯分布的参数。高斯分布有两个参数:均值和方差(或者等效地,标准差)。VAE 将每个输入图像映射到一个均值上。唯一的额外复杂性在于,如果潜变量空间超过 1D,则均值和方差可以是高于一维的,正如我们将在下面的例子中看到的那样。本质上,我们假设图像是通过随机过程生成的,并且在编码和解码过程中应该考虑到这个过程的随机性。然后,VAE 使用均值和方差参数从分布中随机采样一个向量,并使用该随机向量将其解码回原始输入的大小(参见图 10.6)。这种随机性是 VAE 改善鲁棒性、强迫潜变量空间在每个位置都编码有意义表示的关键方式之一:在解码器解码时,潜变量空间中采样的每个点应该是一个有效的图像输出。
图 10.6. 比较经典自编码器(面板 A)和 VAE(面板 B)的工作原理。经典自编码器将输入图像映射到一个固定的潜变量向量上,并使用该向量进行解码。相比之下,VAE 将输入图像映射到一个由均值和方差描述的分布上,从该分布中随机采样一个潜变量向量,并使用该随机向量生成解码后的图像。这个 T 恤图案是来自 Fashion-MNIST 数据集的一个例子。

接下来,我们将通过使用 Fashion-MNIST 数据集展示 VAE 的工作原理。正如其名称所示,Fashion-MNIST^([12]) 受到了 MNIST 手写数字数据集的启发,但包含了服装和时尚物品的图像。与 MNIST 图像一样,Fashion-MNIST 图像是 28 × 28 的灰度图像。有着确切的 10 个服装和时尚物品类别(如 T 恤、套头衫、鞋子和包袋;请参见 图 10.6 作为示例)。然而,与 MNIST 数据集相比,Fashion-MNIST 数据集对机器学习算法来说略微“更难”,当前最先进的测试集准确率约为 96.5%,远低于 MNIST 数据集的 99.75% 最先进准确率。^([13]) 我们将使用 TensorFlow.js 构建一个 VAE 并在 Fashion-MNIST 数据集上对其进行训练。然后,我们将使用 VAE 的解码器从 2D 潜在空间中对样本进行采样,并观察该空间内部的结构。
¹²
Han Xiao、Kashif Rasul 和 Roland Vollgraf,“Fashion-MNIST: 用于机器学习算法基准测试的新型图像数据集”,提交于 2017 年 8 月 25 日,
arxiv.org/abs/1708.07747。¹³
来源:“所有机器学习问题的最新技术结果”,GitHub,2019 年,
mng.bz/6w0o。
10.2.2. VAE 的详细示例:Fashion-MNIST 示例
要查看 fashion-mnist-vae 示例,请使用以下命令:
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/fashion-mnist-vae
yarn
yarn download-data
这个例子由两部分组成:在 Node.js 中训练 VAE 和使用 VAE 解码器在浏览器中生成图像。要开始训练部分,请使用以下命令
yarn train
如果您正确设置了 CUDA 启用的 GPU,则可以使用 --gpu 标志来加速训练:
yarn train --gpu
训练在配备有 CUDA GPU 的合理更新的台式机上大约需要五分钟,没有 GPU 的情况下则需要不到一个小时。训练完成后,使用以下命令构建并启动浏览器前端:
yarn watch
前端将加载 VAE 的解码器,通过使用正则化的 2D 网格的潜在向量生成多个图像,并在页面上显示这些图像。这将让您欣赏到潜在空间的结构。
从技术角度来看,这就是 VAE 的工作原理:
-
编码器将输入样本转换为潜在空间中的两个参数:
zMean和zLogVar,分别是均值和方差的对数(对数方差)。这两个向量的长度与潜在空间的维度相同。例如,我们的潜在空间将是 2D,因此zMean和zLogVar将分别是长度为 2 的向量。为什么我们使用对数方差(zLogVar)而不是方差本身?因为方差必须是非负的,但没有简单的方法来强制该层输出的符号要求。相比之下,对数方差允许具有任何符号。通过使用对数,我们不必担心层输出的符号。对数方差可以通过简单的指数运算(tf.exp())操作轻松地转换为相应的方差。¹⁴
严格来说,长度为 N 的潜在向量的协方差矩阵是一个 N × N 矩阵。然而,
zLogVar是一个长度为 N 的向量,因为我们将协方差矩阵约束为对角线矩阵——即,潜在向量的两个不同元素之间没有相关性。 -
VAE 算法通过使用一个称为
epsilon的向量——与zMean和zLogVar的长度相同的随机向量——从潜在正态分布中随机抽样一个潜在向量。在简单的数学方程中,这一步骤在文献中被称为重参数化,看起来像是z = zMean + exp(zLogVar * 0.5) * epsilon乘以 0.5 将方差转换为标准差,这基于标准差是方差的平方根的事实。等效的 JavaScript 代码是
z = zMean.add(zLogVar.mul(0.5).exp().mul(epsilon));(见 listing 10.3。) 然后,
z将被馈送到 VAE 的解码器部分,以便生成输出图像。
在我们的 VAE 实现中,潜在向量抽样步骤是由一个名为 ZLayer 的自定义层执行的(见 listing 10.3)。我们在 第九章 中简要介绍了一个自定义 TensorFlow.js 层(我们在基于注意力的日期转换器中使用的 GetLastTimestepLayer 层)。我们 VAE 使用的自定义层略微复杂,值得解释一下。
ZLayer 类有两个关键方法:computeOutputShape() 和 call()。computeOutputShape() 被 TensorFlow.js 用来推断给定输入形状的 Layer 实例的输出形状。call() 方法包含了实际的数学计算。它包含了先前介绍的方程行。下面的代码摘自 fashion-mnist-vae/model.js。
listing 10.3 抽样潜在空间(z 空间)的代码示例
class ZLayer extends tf.layers.Layer {
constructor(config) {
super(config);
}
computeOutputShape(inputShape) {
tf.util.assert(inputShape.length === 2 && Array.isArray(inputShape[0]),
() => `Expected exactly 2 input shapes. ` +
`But got: ${inputShape}`); ***1***
return inputShape[0]; ***2***
}
call(inputs, kwargs) {
const [zMean, zLogVar] = inputs;
const batch = zMean.shape[0];
const dim = zMean.shape[1];
const mean = 0;
const std = 1.0;
const epsilon = tf.randomNormal( ***3***
[batch, dim], mean, std); ***3***
return zMean.add( ***4***
zLogVar.mul(0.5).exp().mul(epsilon)); ***4***
}
static get ClassName() { ***5***
return 'ZLayer';
}
}
tf.serialization.registerClass(ZLayer); ***6***
-
1 检查确保我们只有两个输入:zMean 和 zLogVar
-
2 输出(z)的形状将与 zMean 的形状相同。
-
3 从单位高斯分布中获取一个随机批次的 epsilon
-
4 这是 z 向量抽样发生的地方:zMean + standardDeviation * epsilon。
-
5 如果要对该层进行序列化,则设置静态的 className 属性。
-
6 注册类以支持反序列化
如清单 10.4 所示,ZLayer被实例化并被用作编码器的一部分。编码器被编写为一个功能型模型,而不是更简单的顺序模型,因为它具有非线性的内部结构,并且产生三个输出:zMean、zLogVar和z(参见图 10.7 中的示意图)。编码器输出z是因为它将被解码器使用,但为什么编码器包括zMean和zLogVar在输出中?这是因为它们将用于计算 VAE 的损失函数,很快你就会看到。
图 10.7。TensorFlow.js 实现 VAE 的示意图,包括编码器和解码器部分的内部细节以及支持 VAE 训练的自定义损失函数和优化器。

除了ZLayer,编码器还包括两个单隐藏层的 MLP。它们用于将扁平化的输入 Fashion-MNIST 图像转换为zMean和zLogVar向量,分别。这两个 MLP 共享相同的隐藏层,但使用单独的输出层。这种分支模型拓扑结构也是由于编码器是一个功能型模型。
清单 10.4。我们 VAE 的编码器部分(摘自 fashion-mnist-vae/model.js)
function encoder(opts) {
const {originalDim, intermediateDim, latentDim} = opts;
const inputs = tf.input({shape: [originalDim], name: 'encoder_input'});
const x = tf.layers.dense({units: intermediateDim, activation: 'relu'})
.apply(inputs); ***1***
const zMean = tf.layers.dense({units: latentDim, name: 'z_mean'}).apply(x);***2***
const zLogVar = tf.layers.dense({ ***2***
units: latentDim, ***2***
name: 'z_log_var' ***2***
}).apply(x); ***2*** ***3***
const z = ***3***
new ZLayer({name: 'z', outputShape: [latentDim]}).apply([zMean, ***3***
zLogVar]); ***3***
const enc = tf.model({
inputs: inputs,
outputs: [zMean, zLogVar, z],
name: 'encoder',
})
return enc;
}
-
1 编码器底部是一个简单的 MLP,有一个隐藏层。
-
2 与普通的 MLP 不同,我们在隐藏的密集层之后放置了两个层,分别用于预测 zMean 和 zLogVar。这也是我们使用功能型模型而不是更简单的顺序模型类型的原因。
-
3 实例化我们自定义的 ZLayer,并使用它来生成遵循由 zMean 和 zLogVar 指定的分布的随机样本
清单 10.5 中的代码构建了解码器。与编码器相比,解码器的拓扑结构更简单。它使用一个 MLP 将输入的 z 向量(即潜在向量)转换为与编码器输入相同形状的图像。请注意,我们的 VAE 处理图像的方式有些简单和不寻常,因为它将图像扁平化为 1D 向量,因此丢弃了空间信息。面向图像的 VAE 通常使用卷积和池化层,但由于我们图像的简单性(其尺寸较小且仅有一个颜色通道),扁平化方法足够简单地处理此示例的目的。
清单 10.5。我们 VAE 的解码器部分(摘自 fashion-mnist-vae/model.js)
function decoder(opts) {
const {originalDim, intermediateDim, latentDim} = opts;
const dec = tf.sequential({name: 'decoder'}); ***1***
dec.add(tf.layers.dense({
units: intermediateDim,
activation: 'relu',
inputShape: [latentDim]
}));
dec.add(tf.layers.dense({
units: originalDim,
activation: 'sigmoid' ***2***
}));
return dec;
}
-
1 解码器是一个简单的 MLP,将潜在(z)向量转换为(扁平化的)图像。
-
2 Sigmoid 激活是输出层的一个好选择,因为它确保输出图像的像素值被限制在 0 和 1 之间。
将编码器和解码器合并成一个名为 VAE 的单个tf.LayerModel对象时,列表 10.6 中的代码会提取编码器的第三个输出(z 向量)并将其通过解码器运行。然后,组合模型会将解码图像暴露为其输出,同时还有其他三个输出:zMean、zLogVar和 z 向量。这完成了 VAE 模型拓扑结构的定义。为了训练模型,我们需要两个东西:损失函数和优化器。以下列表中的代码摘自 fashion-mnist-vae/model.js。
将编码器和解码器放在一起组成 VAE 时,列表 10.6 中完成。
function vae(encoder, decoder) {
const inputs = encoder.inputs; ***1***
const encoderOutputs = encoder.apply(inputs);
const encoded = encoderOutputs[2]; ***2***
const decoderOutput = decoder.apply(encoded);
const v = tf.model({ ***3***
inputs: inputs,
outputs: [decoderOutput, ...encoderOutputs], ***4***
name: 'vae_mlp',
})
return v;
}
-
1 VAE 的输入与编码器的输入相同:原始输入图像。
-
2 在编码器的所有三个输出中,只有最后一个(z)进入解码器。
-
3 由于模型的非线性拓扑结构,我们使用功能模型 API。
-
4 VAE 模型对象的输出除了 zMean、zLogVar 和z之外还包括解码图像。
当我们访问第五章中的 simple-object-detection 模型时,我们描述了如何在 TensorFlow.js 中定义自定义损失函数的方式。在这里,需要自定义损失函数来训练 VAE。这是因为损失函数将是两个项的总和:一个量化输入和输出之间的差异,另一个量化潜在空间的统计属性。这让人想起了 simple-object-detection 模型的自定义损失函数,其中一个项用于对象分类,另一个项用于对象定位。
如您从列表 10.7 中的代码中所见(摘自 fashion-mnist-vae/model.js),定义输入输出差异项是直接的。我们简单地计算原始输入和解码器输出之间的均方误差(MSE)。然而,统计项,称为Kullbach-Liebler(KL)散度,数学上更加复杂。我们会免去详细的数学[¹⁵],但从直觉上讲,KL 散度项(代码中的 klLoss)鼓励不同输入图像的分布更均匀地分布在潜在空间的中心周围,这使得解码器更容易在图像之间进行插值。因此,klLoss项可以被视为 VAE 的主要输入输出差异项之上添加的正则化项。
¹⁵
Irhum Shafkat 的这篇博文包含了对 KL 散度背后数学的更深入讨论:
mng.bz/vlvr。
第 10.7 节列出了 VAE 的损失函数。
function vaeLoss(inputs, outputs) {
const originalDim = inputs.shape[1];
const decoderOutput = outputs[0];
const zMean = outputs[1];
const zLogVar = outputs[2];
const reconstructionLoss = ***1***
tf.losses.meanSquaredError(inputs, decoderOutput).mul(originalDim); ***1***
let klLoss = zLogVar.add(1).sub(zMean.square()).sub(zLogVar.exp());
klLoss = klLoss.sum(-1).mul(-0.5); ***2***
return reconstructionLoss.add(klLoss).mean(); ***3***
}
-
1 计算“重构损失”项。最小化此项的目标是使模型输出与输入数据匹配。
-
2 计算 zLogVar 和 zMean 之间的 KL 散度。最小化此项旨在使潜变量的分布更接近于潜在空间的中心处正态分布。
-
3 将图像重建损失和 KL-散度损失汇总到最终的 VAE 损失中
我们 VAE 训练的另一个缺失部分是优化器及其使用的训练步骤。优化器的类型是流行的 ADAM 优化器(tf.train .adam())。VAE 的训练步骤与本书中所有其他模型不同,因为它不使用模型对象的fit()或fitDataset()方法。相反,它调用优化器的minimize()方法(列表 10.8)。这是因为自定义损失函数的 KL-散度项使用模型的四个输出中的两个,但在 TensorFlow.js 中,只有在模型的每个输出都具有不依赖于任何其他输出的损失函数时,fit()和fitDataset()方法才能正常工作。
如列表 10.8 所示,minimize()函数以箭头函数作为唯一参数进行调用。这个箭头函数返回当前批次的扁平化图像的损失(代码中的reshaped),这个损失被函数闭包。minimize()计算损失相对于 VAE 的所有可训练权重的梯度(包括编码器和解码器),根据 ADAM 算法调整它们,然后根据调整后的梯度在权重的相反方向应用更新。这完成了一次训练步骤。这一步骤重复进行,遍历 Fashion-MNIST 数据集中的所有图像,并构成一个训练时期。yarn train 命令执行多个训练周期(默认:5 个周期),在此之后损失值收敛,并且 VAE 的解码器部分被保存到磁盘上。编码器部分不保存的原因是它不会在接下来的基于浏览器的演示步骤中使用。
列表 10.8. VAE 的训练循环(摘自 fashion-mnist-vae/train.js)
for (let i = 0; i < epochs; i++) {
console.log(`\nEpoch #${i} of ${epochs}\n`)
for (let j = 0; j < batches.length; j++) {
const currentBatchSize = batches[j].length
const batchedImages = batchImages(batches[j]); ***1***
const reshaped =
batchedImages.reshape([currentBatchSize, vaeOpts.originalDim]);
optimizer.minimize(() => { ***2***
const outputs = vaeModel.apply(reshaped);
const loss = vaeLoss(reshaped, outputs, vaeOpts);
process.stdout.write('.'); ***3***
if (j % 50 === 0) {
console.log('\nLoss:', loss.dataSync()[0]);
}
return loss;
});
tf.dispose([batchedImages, reshaped]);
}
console.log('');
await generate(decoderModel, vaeOpts.latentDim); ***4***
}
-
1 获取一批(扁平化的)Fashion-MNIST 图像
-
2 VAE 训练的单个步骤:使用 VAE 进行预测,并计算损失,以便 optimizer.minimize 可以调整模型的所有可训练权重
-
3 由于我们不使用默认的
fit()方法,因此不能使用内置的进度条,必须自己打印控制台上的状态更新。 -
4 在每个训练周期结束时,使用解码器生成一幅图像,并将其打印到控制台以进行预览
yarn watch 命令打开的网页将加载保存的解码器,并使用它生成类似于图 10.8 所示的图像网格。这些图像是从二维潜在空间中的正则网格的潜在向量获得的。每个潜在维度上的上限和下限可以在 UI 中进行调整。
图 10.8. 在训练后对 VAE 的潜在空间进行采样。该图显示了一个 20 × 20 的解码器输出网格。该网格对应于一个 20 × 20 的二维潜在向量的正则间隔网格,其中每个维度位于[-4, 4]的区间内。

图像网格显示了来自 Fashion-MNIST 数据集的完全连续的不同类型的服装,一种服装类型在潜在空间中沿着连续路径逐渐变形为另一种类型(例如,套头衫变成 T 恤,T 恤变成裤子,靴子变成鞋子)。潜在空间的特定方向在潜在空间的子域内具有一定的意义。例如,在潜在空间的顶部区域附近,水平维度似乎代表“靴子特性与鞋子特性;”在潜在空间的右下角附近,水平维度似乎代表“T 恤特性与裤子特性”,依此类推。
在接下来的章节中,我们将介绍另一种生成图像的主要模型类型:GANs。
10.3. 使用 GANs 进行图像生成
自从 Ian Goodfellow 和他的同事在 2014 年引入了 GANs^([16]) 这项技术以来,它的兴趣和复杂程度迅速增长。如今,GANs 已经成为生成图像和其他数据模态的强大工具。它们能够输出高分辨率图像,有些情况下,这些图像在人类眼中几乎无法与真实图像区分开来。查看 NVIDIA 的 StyleGANs 生成的人脸图像,如 图 10.9^([17]) 所示。如果不是人脸上偶尔出现的瑕疵点和背景中不自然的场景,人类观察者几乎无法将这些生成的图像与真实图像区分开来。
¹⁶
Ian Goodfellow 等人,“生成对抗网络”,NIPS 会议论文集,2014 年,
mng.bz/4ePv。¹⁷
thispersondoesnotexist.com的网站。有关学术论文,请参阅 Tero Karras,Samuli Laine 和 Timo Aila,“用于生成对抗网络的基于样式的生成器架构”,于 2018 年 12 月 12 日提交,arxiv.org/abs/1812.04948。
图 10.9. NVIDIA 的 StyleGAN 生成的示例人脸图像,从 thispersondoesnotexist.com 中采样于 2019 年 4 月

除了“从蓝天中生成引人注目的图像”之外,GAN 生成的图像还可以根据某些输入数据或参数进行条件约束,这带来了更多的特定任务和有用的应用。例如,GAN 可以用于从低分辨率输入(图像超分辨率)生成更高分辨率的图像,填补图像的缺失部分(图像修复),将黑白图像转换为彩色图像(图像着色),根据文本描述生成图像以及根据输入图像中同一人采取的姿势生成该人的图像。此外,已经开发了新类型的 GAN 用于生成非图像输出,例如音乐。^([18]) 除了在艺术、音乐制作和游戏设计等领域中生成无限量的逼真材料的明显价值之外,GAN 还有其他应用,例如通过在获取此类样本代价高昂的情况下生成训练示例来辅助深度学习。例如,GAN 正被用于为训练自动驾驶神经网络生成逼真的街景图像。^([19])
¹⁸
请参阅 Hao-Wen Dong 等人的 MuseGAN 项目:
salu133445.github.io/musegan/。¹⁹
James Vincent,《NVIDIA 使用 AI 让永远阳光的街道下雪》,The Verge,2017 年 12 月 5 日,
mng.bz/Q0oQ。
虽然 VAE 和 GAN 都是生成模型,但它们基于不同的思想。VAE 通过使用原始输入和解码器输出之间的均方误差损失来确保生成的示例的质量,而 GAN 则通过使用鉴别器来确保其输出逼真,我们很快就会解释。此外,GAN 的许多变体允许输入不仅包含潜空间向量,还包括条件输入,例如所需的图像类别。我们将要探索的 ACGAN 就是这方面的一个很好的例子。在这种具有混合输入的 GAN 类型中,潜空间不再与网络输入具有连续性。
在这个部分,我们将深入研究一种相对简单的 GAN 类型。具体而言,我们将在熟悉的 MNIST 手写数字数据集上训练一个辅助分类器 GAN (ACGAN)^([20])。这将给我们一个能够生成与真实 MNIST 数字完全相似的数字图像的模型。同时,由于 ACGAN 的“辅助分类器”部分,我们将能够控制每个生成图像所属的数字类别(0 到 9)。为了理解 ACGAN 的工作原理,让我们一步一步来。首先,我们将解释 ACGAN 的基本“GAN”部分如何工作。然后,我们将描述 ACGAN 通过额外的机制如何使类别标识具有可控性。
²⁰
Augustus Odena、Christopher Olah 和 Jonathon Shlens,“带辅助分类器 GAN 的条件图像合成”,2016 年 10 月 30 日提交,
arxiv.org/abs/1610.09585。
10.3.1. GANs 背后的基本思想
生成对抗网络(GAN)是如何学习生成逼真图片的?它通过其包含的两个子部分之间的相互作用来实现这一点:一个生成器和一个鉴别器。把生成器想象成一个伪造者,其目标是创建高质量的假毕加索画作;而鉴别器则像是一位艺术品经销商,其工作是将假的毕加索画作与真实的区分开来。伪造者(生成器)努力创建越来越好的假画作以欺骗艺术品经销商(鉴别器),而艺术品经销商的工作是成为对画作的评判者越来越好,从而不被伪造者欺骗。我们两个角色之间的这种对抗是“GAN”名称中“对抗性”部分的原因。有趣的是,伪造者和艺术品经销商最终互相帮助变得更好,尽管表面上是对手。
起初,伪造者(生成器)在创建逼真的毕加索画作方面表现糟糕,因为其权重是随机初始化的。结果,艺术品经销商(鉴别器)很快就学会了区分真假毕加索画作。这里是所有这些工作的重要部分:每次伪造者给艺术品经销商带来一幅新画作时,他们都会得到详细的反馈(来自艺术品经销商),指出画作的哪些部分看起来不对劲,以及如何改变画作使其看起来更真实。伪造者学习并记住这一点,以便下次他们来到艺术品经销商那里时,他们的画作看起来会稍微好一些。这个过程重复多次。结果发现,如果所有参数都设置正确,我们最终会得到一个技艺精湛的伪造者(生成器)。当然,我们也会得到一个技艺精湛的鉴别器(艺术品经销商),但通常在 GAN 训练完成后我们只需要生成器。
图 10.10 更详细地描述了如何训练通用 GAN 模型的判别器部分。为了训练判别器,我们需要一批生成的图像和一批真实图像。生成的图像由生成器生成。但生成器无法从空气中制作图像。相反,它需要作为输入的随机向量。潜在向量在概念上类似于我们在第 10.2 节中用于 VAE 的向量。对于生成器生成的每个图像,潜在向量是形状为[latentSize]的一维张量。但像本书中大多数训练过程一样,我们一次对一批图像执行步骤。因此,潜在向量的形状为[batchSize, latentSize]。真实图像直接从实际 MNIST 数据集中提取。为了对称起见,我们在每个训练步骤中绘制与生成的图像完全相同数量的batchSize真实图像。
图 10.10. 示出 GAN 判别器部分训练算法的示意图。请注意,为简单起见,该图省略了 ACGAN 的数字类部分。有关 ACGAN 生成器训练的完整图表,请参见图 10.13。

生成的图像和真实图像随后被连接成一批图像,表示为形状为[2 * batchSize, 28, 28, 1]的张量。判别器在这批合并图像上执行,输出每个图像是真实的预测概率分数。这些概率分数可以轻松地通过二元交叉熵损失函数与基准真值进行测试(我们知道哪些是真实的,哪些是生成的!)。然后,熟悉的反向传播算法发挥作用,借助优化器(图中未显示)更新判别器的权重参数。这一步使判别器略微朝着正确的预测方向推进。请注意,生成器仅通过提供生成的样本参与此训练步骤,但它不会通过反向传播过程进行更新。下一步训练将更新生成器(图 10.11)。
图 10.11. 示出 GAN 生成器部分训练算法的示意图。请注意,为简单起见,该图省略了 ACGAN 的数字类部分。有关 ACGAN 生成器训练过程的完整图表,请参见图 10.14。

图 10.11 说明了生成器的训练步骤。我们让生成器生成另一批生成图像。但与鉴别器的训练步骤不同,我们不需要任何真实的 MNIST 图像。鉴别器被赋予了这批生成图像以及一批二进制真实性标签。我们假装这些生成图像都是真实的,将真实性标签设置为全部为 1。静下心来思考一下:这是 GAN 训练中最重要的技巧。当然,这些图像都是生成的(并非真实的),但我们让真实性标签表明它们是真实的。鉴别器可能(正确地)对一些或所有的输入图像分配较低的真实性概率。但是如果这样做,由于虚假的真实性标签,二进制交叉熵损失将得到较大的值。这将导致反向传播更新生成器,以使鉴别器的真实性得分稍微增加。请注意,反向传播只更新生成器,不对鉴别器进行任何更改。这是另一个重要的技巧:它确保生成器最终产生的图像看起来更真实一些,而不是降低鉴别器对真实性的要求。这是通过冻结模型的鉴别器部分实现的,这是我们在第五章中用于迁移学习的一种操作。
总结生成器训练步骤:冻结鉴别器并向其提供全是 1 的真实性标签,尽管它得到的是由生成器生成的生成图像。由于这样,对生成器的权重更新将导致其生成的图像在鉴别器中看起来稍微更真实。只有当鉴别器相当擅长区分真实和生成的图像时,这种训练生成器的方式才会奏效。我们如何确保这一点?答案是我们已经讨论过的鉴别器训练步骤。因此,你可以看到这两个训练步骤形成了一种复杂的阴阳动态,其中 GAN 的两个部分相互抵触并互相帮助。
这就是对通用 GAN 训练的高级概览。在下一节中,我们将介绍鉴别器和生成器的内部架构以及它们如何融入有关图像类别的信息。
10.3.2. ACGAN 的构建模块
清单 10.9 显示了创建 MNIST ACGAN 判别器部分的 TensorFlow.js 代码(摘自 mnist-acgan/gan.js)。在判别器的核心是一种类似于我们在第四章中看到的深度卷积网络。其输入具有 MNIST 图像的经典形状,即 [28, 28, 1]。输入图像通过四个 2D 的卷积(conv2d)层,然后被展平并经过两个全连接层处理。其中一个全连接层为输入图像的真实性二进制预测输出,另一个输出 10 个数字类别的 softmax 概率。判别器是一个有两个全连接层输出的函数模型。图 10.12 的面板 A 提供了判别器的一个输入-两个输出拓扑结构的示意图。
图 10.12。ACGAN 的判别器(面板 A)和生成器(面板 B)部分的内部拓扑示意图。为简洁起见,省略了某些细节(例如判别器中的 dropout 层)。有关详细的代码,请参见清单 10.9 和 10.10。

清单 10.9。创建 ACGAN 的判别器部分。
function buildDiscriminator() {
const cnn = tf.sequential();
cnn.add(tf.layers.conv2d({
filters: 32,
kernelSize: 3,
padding: 'same',
strides: 2,
inputShape: [IMAGE_SIZE, IMAGE_SIZE, 1] ***1***
}));
cnn.add(tf.layers.leakyReLU({alpha: 0.2}));
cnn.add(tf.layers.dropout({rate: 0.3})); ***2***
cnn.add(tf.layers.conv2d(
{filters: 64, kernelSize: 3, padding: 'same', strides: 1}));
cnn.add(tf.layers.leakyReLU({alpha: 0.2}));
cnn.add(tf.layers.dropout({rate: 0.3}));
cnn.add(tf.layers.conv2d(
{filters: 128, kernelSize: 3, padding: 'same', strides: 2}));
cnn.add(tf.layers.leakyReLU({alpha: 0.2}));
cnn.add(tf.layers.dropout({rate: 0.3}));
cnn.add(tf.layers.conv2d(
{filters: 256, kernelSize: 3, padding: 'same', strides: 1}));
cnn.add(tf.layers.leakyReLU({alpha: 0.2}));
cnn.add(tf.layers.dropout({rate: 0.3}));
cnn.add(tf.layers.flatten());
const image = tf.input({shape: [IMAGE_SIZE, IMAGE_SIZE, 1]});
const features = cnn.apply(image);
const realnessScore = ***3***
tf.layers.dense({units: 1, activation: 'sigmoid'}).apply(features); ***3***
const aux = tf.layers.dense({units: NUM_CLASSES, activation: 'softmax'}) ***4***
.apply(features); ***4***
return tf.model({inputs: image, outputs: [realnessScore, aux]});
}
-
1 判别器只接受 MNIST 格式的图像作为输入。
-
2 使用 Dropout 层来对抗过拟合。
-
3 判别器的两个输出之一是二进制真实性分类的概率分数。
-
4 第二个输出是 10 个 MNIST 数字类别的 softmax 概率。
清单 10.10 中的代码负责创建 ACGAN 生成器。正如我们之前暗示的那样,生成器的生成过程需要一个叫做潜在向量(代码中称为 latent)的输入。这体现在其第一个全连接层的 inputShape 参数中。然而,如果你仔细检查代码,就会发现生成器实际上接受两个输入。这在 图 10.12 的面板 B 中有描述。除了潜在向量外,也就是一个形状为 [latentSize] 的一维张量,生成器需要一个额外的输入,名为 imageClass,形状简单,为 [1]。这是告诉模型要生成哪个 MNIST 数字(0 到 9)的方式。例如,如果我们想要模型生成数字 8 的图像,我们应该将形状为 tf.tensor2d([[8]]) 的张量值输入到第二个输入(请记住,即使只有一个示例,模型也始终期望批量张量)。同样,如果我们想要模型生成两个图像,一个是数字 8,另一个是数字 9,则馈送的张量应为 tf.tensor2d([[8], [9]])。
一旦 imageClass 输入进入生成器,嵌入层将其转换为与 latent 相同形状的张量 ([latentSize])。这一步在数学上类似于我们在 第九章 中用于情感分析和日期转换模型的嵌入查找过程。期望的数字类别是一个整数量,类似于情感分析数据中的单词索引和日期转换数据中的字符索引。它被转换为与单词和字符索引转换为 1D 向量的方式相同的 1D 向量。然而,我们在这里对 imageClass 使用嵌入查找是为了不同的目的:将其与 latent 向量合并并形成一个单一的组合向量(在 清单 10.10 中命名为 h)。这个合并是通过一个 multiply 层完成的,该层在两个相同形状的向量之间执行逐元素相乘。结果张量的形状与输入相同 ([latentSize]),并传入生成器的后续部分。
生成器立即在合并的潜在向量 (h) 上应用一个密集层,并将其重塑为 3D 形状 [3, 3, 384]。这种重塑产生了一个类似图像的张量,随后可以由生成器的后续部分转换为具有标准 MNIST 形状 ([28, 28, 1]) 的图像。
生成器不使用熟悉的 conv2d 层来转换输入,而是使用 conv2dTranspose 层来转换其图像张量。粗略地说,conv2dTranspose 执行与 conv2d 的逆操作(有时称为反卷积)。conv2d 层的输出通常比其输入具有更小的高度和宽度(除了 kernelSize 为 1 的情况之外),如您在 第四章 中的 convnets 中所见。然而,conv2dTranspose 层的输出通常比其输入具有更大的高度和宽度。换句话说,虽然 conv2d 层通常缩小其输入的维度,但典型的 conv2dTranspose 层扩展它们。这就是为什么在生成器中,第一个 conv2dTranspose 层接受高度为 3 和宽度为 3 的输入,但最后一个 conv2dTranspose 层输出高度为 28 和宽度为 28 的原因。这就是生成器将输入潜在向量和数字索引转换为标准 MNIST 图像尺寸的图像的方式。以下清单中的代码摘录自 mnist-acgan/gan.js; 为了清晰起见,删除了一些错误检查代码。
清单 10.10. 创建 ACGAN 的生成器部分
function buildGenerator(latentSize) {
const cnn = tf.sequential();
cnn.add(tf.layers.dense({
units: 3 * 3 * 384, ***1***
inputShape: [latentSize],
activation: 'relu'
}));
cnn.add(tf.layers.reshape({targetShape: [3, 3, 384]}));
cnn.add(tf.layers.conv2dTranspose({ ***2***
filters: 192,
kernelSize: 5,
strides: 1,
padding: 'valid',
activation: 'relu',
kernelInitializer: 'glorotNormal'
}));
cnn.add(tf.layers.batchNormalization());
cnn.add(tf.layers.conv2dTranspose({ ***3***
filters: 96,
kernelSize: 5,
strides: 2,
padding: 'same',
activation: 'relu',
kernelInitializer: 'glorotNormal'
}));
cnn.add(tf.layers.batchNormalization());
cnn.add(tf.layers.conv2dTranspose({ ***4***
filters: 1,
kernelSize: 5,
strides: 2,
padding: 'same',
activation: 'tanh',
kernelInitializer: 'glorotNormal'
}));
const latent = tf.input({shape: [latentSize]}); ***5***
const imageClass = tf.input({shape: [1]}); ***6***
const classEmbedding = tf.layers.embedding({ ***7***
inputDim: NUM_CLASSES,
outputDim: latentSize,
embeddingsInitializer: 'glorotNormal'
}).apply(imageClass);
const h = tf.layers.multiply().apply( ***8***
[latent, classEmbedding]); ***8***
const fakeImage = cnn.apply(h);
return tf.model({ ***9***
inputs: [latent, imageClass], ***9***
outputs: fakeImage ***9***
}); ***9***
}
-
1 单元的数量被选择为当输出被重塑并通过后续的 conv2dTranspose 层时,最终输出的张量的形状与 MNIST 图像完全匹配 ([28, 28, 1])。
-
2 从 [3, 3, ...] 上采样至 [7, 7, ...]
-
3 上采样至 [14, 14, ...]
-
4 上采样至 [28, 28, ...]
-
5 这是生成器的两个输入之一:作为伪图像生成的“种子”的潜在(z-空间)向量。
-
6 生成器的第二个输入:控制生成的图像属于哪个 MNIST 数字类别的类标签
-
7 通过嵌入查找将期望标签转换为长度为 latentSize 的向量
-
8 通过乘法将潜在向量和类别条件嵌入组合起来
-
9 最终创建模型,以顺序卷积网络为核心。
10.3.3. 更深入地了解 ACGAN 的训练
最后一节应该让你更好地理解了 ACGAN 的鉴别器和生成器的内部结构,以及它们如何整合数字类别信息(ACGAN 名字中的“AC”部分)。有了这些知识,我们就可以扩展 figures 10.10 和 10.11,以全面了解 ACGAN 的训练方式。
Figure 10.13 是 figure 10.10 的扩展版本。它展示了 ACGAN 的鉴别器部分的训练。与之前相比,这一训练步骤不仅提高了鉴别器区分真实和生成(伪造)图像的能力,还磨练了其确定给定图像(包括真实和生成的图像)属于哪个数字类别的能力。为了更容易与之前的简单图表进行比较,我们将已在 figure 10.10 中看到的部分灰暗显示,并突出显示新的部分。首先,注意到生成器现在有了一个额外的输入(数字类别),这使得指定生成器应该生成什么数字成为可能。此外,鉴别器不仅输出真实性预测,还输出数字类别预测。因此,鉴别器的两个输出头都需要进行训练。对于真实性预测的训练与之前相同(figure 10.10);类别预测部分的训练依赖于我们知道生成和真实图像属于哪些数字类别。模型的两个头部编译了不同的损失函数,反映了两种预测的不同性质。对于真实性预测,我们使用二元交叉熵损失,但对于数字类别预测,我们使用了稀疏分类交叉熵损失。你可以在 mnist-acgan/gan.js 的下一行中看到这一点:
discriminator.compile({
optimizer: tf.train.adam(args.learningRate, args.adamBeta1),
loss: ['binaryCrossentropy', 'sparseCategoricalCrossentropy']
});
图 10.13. 说明 ACGAN 的鉴别器部分是如何训练的示意图。这个图表在 figure 10.10 的基础上添加了与数字类别相关的部分。图表的其余部分已经在 figure 10.10 中出现,并且被灰暗显示。

如 图 10.13 中的两条弯曲箭头所示,当更新鉴别器的权重时,通过反向传播的梯度会相互叠加。图 10.14 是 图 10.11 的扩展版本,提供了 ACGAN 生成器部分训练的详细示意图。该图显示了生成器学习如何根据指定的数字类别生成正确的图像,以及学习如何生成真实的图像。与 图 10.13 类似,新添加的部分被突出显示,而已经存在于 图 10.11 的部分则被隐藏。从突出显示的部分中,可以看到我们在训练步骤中输入的标签现在不仅包括真实性标签,还包括数字类别标签。与以前一样,真实性标签都是故意虚假的。但是新添加的数字类别标签更加真实,因为我们确实将这些类别标签给了生成器。
图 10.14. 示意图,说明 ACGAN 的生成器部分是如何训练的。这个图是 图 10.11 的扩展,显示了与数字类别相关的部分。图的其余部分已经在图 10.11 中出现,已被隐藏。

先前,我们看到虚假真实标签与鉴别器的真实概率输出之间的任何差异会被用来更新 ACGAN 的生成器,使其在“欺骗”鉴别器方面更加优秀。在这里,鉴别器的数字分类预测发挥了类似的作用。例如,如果我们告诉生成器生成一个数字 8 的图像,但是鉴别器将图像分类为 9,则稀疏分类交叉熵的值将较高,并且与之关联的梯度将有较大的幅度。因此,生成器权重的更新将导致生成器生成一个更像数字 8 的图像(根据鉴别器的判断)。显然,只有当鉴别器在将图像分类为 10 个 MNIST 数字类别方面足够好时,这种训练生成器的方法才会起作用。这就是前一个鉴别器训练步骤所帮助确保的。再次强调,在 ACGAN 的训练过程中,我们看到了鉴别器和生成器部分之间的阴阳动力学。
GAN 训练:一大堆诡计
训练和调整 GAN 的过程众所周知地困难。您在 mnist-acgan 示例中看到的训练脚本是研究人员大量试错的结晶。像深度学习中的大多数事物一样,这更像是一种艺术而不是精确科学:这些技巧是启发式的,没有系统理论的支持。它们得到了对手头现象的直觉理解,并且在经验上被证明效果良好,尽管不一定在每种情况下都有效。
以下是本节中 ACGAN 中使用的一些值得注意的技巧列表:
-
我们在生成器的最后一个 conv2dTranspose 层中使用 tanh 作为激活函数。在其他类型的模型中,tanh 激活函数出现得较少。
-
随机性有助于诱导鲁棒性。因为 GAN 的训练可能导致动态平衡,所以 GAN 很容易陷入各种各样的困境中。在训练过程中引入随机性有助于防止这种情况发生。我们通过两种方式引入随机性:在鉴别器中使用 dropout,以及为鉴别器的真实标签使用“soft one”值(0.95)。
-
稀疏梯度(许多值为零的梯度)可能会妨碍 GAN 的训练。在其他类型的深度学习中,稀疏性通常是一种理想的特性,但在 GAN 中不是这样。梯度中的稀疏性可能由两个因素引起:最大池化操作和 relu 激活函数。建议使用步幅卷积进行下采样,而不是最大池化,这正是生成器创建代码中所示的内容。建议使用 leakyReLU 激活函数,其中负部分具有小的负值,而不是严格的零。这也在清单 10.10 中显示。
10.3.4. 查看 MNIST ACGAN 训练和生成
mnist-acgan 示例可以通过以下命令检出和准备:
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/mnist-acganyarn
运行示例涉及两个阶段:在 Node.js 中进行训练,然后在浏览器中进行生成。要启动训练过程,只需使用以下命令:
yarn train
训练默认使用 tfjs-node。然而,像我们之前见过的涉及卷积神经网络的示例一样,使用 tfjs-node-gpu 可以显著提高训练速度。如果您的计算机上正确设置了支持 CUDA 的 GPU,您可以在yarn train命令中追加--gpu标志来实现。训练 ACGAN 至少需要几个小时。对于这个长时间运行的训练任务,您可以使用--logDir标志通过 TensorBoard 监控进度:
yarn train --logDir /tmp/mnist-acgan-logs
一旦在单独的终端中使用以下命令启动了 TensorBoard 进程,
tensorboard --logdir /tmp/mnist-acgan-logs
您可以在浏览器中导航到 TensorBoard URL(由 TensorBoard 服务器进程打印)以查看损失曲线。图 10.15 显示了训练过程中的一些示例损失曲线。GAN 训练的损失曲线的一个显著特征是,它们并不总是像大多数其他类型的神经网络的损失曲线那样趋向于下降。相反,判别器的损失(图中的 dLoss)和生成器的损失(图中的 gLoss)都以非单调方式变化,并相互交织形成复杂的舞蹈。
图 10.15. ACGAN 训练作业中的样本损失曲线。dLoss 是判别器训练步骤的损失。具体来说,它是真实性预测的二元交叉熵和数字类别预测的稀疏分类交叉熵的总和。gLoss 是生成器训练步骤的损失。与 dLoss 类似,gLoss 是来自二元真实性分类和多类数字分类的损失的总和。

在训练接近结束时,两者的损失都不会接近零。相反,它们只是趋于平稳(收敛)。此时,训练过程结束并将模型的生成器部分保存到磁盘上,以便在浏览器内生成步骤中进行服务:
await generator.save(saveURL);
要运行浏览器内生成演示,请使用命令 yarn watch。它将编译 mnist-acgan/index.js 和相关的 HTML 和 CSS 资源,然后会在您的浏览器中打开一个标签页并显示演示页面。^([21])
²¹
您还可以完全跳过训练和构建步骤,直接导航到托管的演示页面,网址为
mng.bz/4eGw。
演示页面加载了从前一阶段保存的训练好的 ACGAN 生成器。由于判别器在此演示阶段并不真正有用,因此它既不保存也不加载。有了生成器加载后,我们可以构建一批潜在向量,以及一批期望的数字类别索引,并调用生成器的 predict()。执行此操作的代码位于 mnist-acgan/index.js 中:
const latentVectors = getLatentVectors(10);
const sampledLabels = tf.tensor2d(
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [10, 1]);
const generatedImages =
generator.predict([latentVectors, sampledLabels]).add(1).div(2);
我们的数字类别标签批次始终是一个有序的 10 元素向量,从 0 到 9。这就是为什么生成的图像批次总是一个从 0 到 9 的有序图像数组。这些图像使用 tf.concat() 函数拼接在一起,并在页面上的 div 元素中呈现(参见图 10.16 中的顶部图像)。与随机抽样的真实 MNIST 图像(参见图 10.16 中的底部图像)相比,这些 ACGAN 生成的图像看起来就像真实的一样。此外,它们的数字类别身份看起来是正确的。这表明我们的 ACGAN 训练是成功的。如果您想查看 ACGAN 生成器的更多输出,请点击页面上的 Generator 按钮。每次点击按钮,都会生成并显示页面上的新批次包含 10 张假图像。您可以玩一下,直观地感受图像生成的质量。
图 10.16. ACGAN 训练模型的生成器部分生成的样本图片(顶部的 10 x 1 面板)。底部的面板展示了一个 10 x 10 的真实 MNIST 图像网格,以进行比较。点击“显示 Z-向量滑块”按钮,您可以打开一个填满了 100 个滑块的区域。这些滑块允许您改变潜在向量(z-向量)的元素,并观察其对生成的 MNIST 图像的影响。请注意,如果您逐个更改滑块,大多数滑块对图像的影响都很微小且不易察觉。但偶尔,您会发现一个具有更大且更明显影响的滑块。

进一步阅读材料
-
Ian Goodfellow、Yoshua Bengio 和 Aaron Courville,“深度生成模型”,深度学习,第二十章,麻省理工学院出版社,2017 年。
-
Jakub Langr 和 Vladimir Bok,《GAN 行动中:生成对抗网络的深度学习》,Manning 出版社,2019 年。
-
Andrej Karpathy,“循环神经网络的不合理有效性”,博客,2015 年 5 月 21 日,
karpathy.github.io/2015/05/21/rnn-effectiveness/。 -
Jonathan Hui,“GAN—什么是生成对抗网络 GAN?” Medium,2018 年 6 月 19 日,
mng.bz/Q0N6。 -
GAN 实验室,一个用 TensorFlow.js 构建的交互式网络环境,用于理解和探索 GAN 的工作原理:Minsuk Kahng 等人,
poloclub.github.io/ganlab/。
练习
-
除了莎士比亚文本语料库外,lstm-text-generation 示例还配置了其他几个文本数据集,并准备好供您探索。运行它们的训练,并观察其效果。例如,使用未压缩的 TensorFlow.js 代码作为训练数据集。在模型训练期间和之后,观察生成的文本是否表现出以下 JavaScript 源代码的模式以及温度参数如何影响这些模式:
-
较短程模式,例如关键字(例如,“for”和“function”)
-
中程模式,例如代码的逐行组织
-
较长程模式,例如括号和方括号的配对,以及每个“function”关键字后必须跟着一对括号和一对花括号
-
-
在 fashion-mnist-vae 示例中,如果您将 VAE 的自定义损失中的 KL 散度项删除会发生什么?通过修改 fashion-mnist-vae/model.js 中的
vaeLoss()函数(清单 10.7)来测试。从潜在空间采样的图像是否仍然看起来像 Fashion-MNIST 图像?空间是否仍然展现出可解释的模式? -
在 mnist-acgan 示例中,尝试将 10 个数字类别合并为 5 个(0 和 1 将成为第一类,2 和 3 将成为第二类,依此类推),并观察在训练后这如何改变 ACGAN 的输出。当您指定第一类时,您期望看到生成的图像是什么?例如,当您指定第一类时,您期望 ACGAN 生成什么?提示:要进行此更改,您需要修改 mnist-acgan/data.js 中的
loadLabels()函数。需要相应修改 gan.js 中的常量NUM_CLASSES。此外,generateAnd-VisualizeImages()函数(位于 index.js 中)中的sampledLabels变量也需要修改。
总结
-
生成模型与我们在本书早期章节中学习的判别模型不同,因为它们旨在模拟训练数据集的生成过程,以及它们的统计分布。由于这种设计,它们能够生成符合分布并且看起来类似于真实训练数据的新样本。
-
我们介绍了一种模拟文本数据集结构的方法:下一个字符预测。LSTM 可以用来以迭代方式执行此任务,以生成任意长度的文本。温度参数控制生成文本的随机性(多么随机和不可预测)。
-
自动编码器是一种由编码器和解码器组成的生成模型。首先,编码器将输入数据压缩为称为潜在向量或 z-向量的简明表示。然后,解码器尝试仅使用潜在向量来重构输入数据。通过训练过程,编码器变成了一个高效的数据摘要生成器,解码器则具有对示例的统计分布的知识。VAE 对潜在向量添加了一些额外的统计约束,使得在 VAE 训练后组成这些向量的潜在空间显示出连续变化和可解释的结构。
-
GAN 基于鉴别器和生成器之间的竞争和合作的想法。鉴别器试图区分真实数据示例和生成的数据示例,而生成器旨在生成“欺骗”鉴别器的虚假示例。通过联合训练,生成器部分最终将能够生成逼真的示例。ACGAN 在基本 GAN 架构中添加了类信息,以便指定要生成的示例的类别。
第十一章:深度强化学习的基础知识
本章内容
-
强化学习与前面几章讨论的监督学习有什么不同
-
强化学习的基本范例:智能体、环境、行动和奖励以及它们之间的交互
-
解决强化学习问题的两种主要方法背后的一般思想:基于策略和基于值的方法
到目前为止,在本书中,我们主要关注一种叫做监督学习的机器学习方法。在监督学习中,我们通过给出一个输入来训练模型给我们正确的答案。无论是给输入图像赋予一个类别标签(第四章)还是根据过去的天气数据预测未来温度(第八章和第九章),这种模式都是一样的:将静态输入映射到静态输出。在我们访问的第九章和第十章中生成序列的模型要稍微复杂一些,因为输出是一系列项而不是单个项。但是通过将序列拆分成步骤,这些问题仍然可以归结为一对一的输入输出映射。
在本章中,我们将介绍一种非常不同的机器学习类型,称为强化学习(RL)。在强化学习中,我们的主要关注点不是静态输出;相反,我们训练一个模型(或者在强化学习术语中称为智能体)在一个环境中采取行动,目的是最大化称为奖励的成功指标。例如,RL 可以用于训练一个机器人在建筑物内航行并收集垃圾。实际上,环境不一定是物理环境;它可以是任何一个智能体采取行动的真实或虚拟空间。国际象棋棋盘是训练智能体下棋的环境;股票市场是训练智能体交易股票的环境。强化学习范式的普遍性使其适用于广泛的实际问题(图 11.1)。另外,深度学习革命中一些最为引人瞩目的进展涉及将深度学习的能力与强化学习相结合。这包括可以以超人的技巧打败 Atari 游戏的机器人和可以在围棋和国际象棋游戏中击败世界冠军的算法^([1])。
¹
David Silver 等人,“通过自我对弈用通用强化学
图 11.1。强化学习的实际应用示例。左上:解决象棋和围棋等棋类游戏。右上:进行算法交易。左下:数据中心的自动资源管理。右下:机器人的控制和行动规划。所有图像均为免费许可证,并从www.pexels.com下载。

引人入胜的强化学习话题在一些基本方式上与我们在前几章中看到的监督学习问题有所不同。与监督学习中学习输入-输出映射不同,强化学习是通过与环境交互来发现最优决策过程。在强化学习中,我们没有给定标记的训练数据集;相反,我们被提供了不同类型的环境来探索。此外,时间是强化学习问题中不可或缺且基础性的维度,与许多监督学习问题不同,后者要么缺乏时间维度,要么将时间更多地视为空间维度。由于强化学习的独特特征,本章将涉及一种与前几章非常不同的词汇和思维方式。但不要担心。我们将使用简单而具体的例子来说明基本概念和方法。此外,我们的老朋友,深度神经网络及其在 TensorFlow.js 中的实现,将仍然与我们同在。它们将构成本章中我们将遇到的强化学习算法的重要支柱(尽管不是唯一的!)。
在本章结束时,您应该熟悉强化学习问题的基本公式化,理解强化学习中两种常用神经网络(策略网络和 Q 网络)背后的基本思想,并知道如何使用 TensorFlow.js 的 API 对这些网络进行训练。
11.1. 强化学习问题的制定
图 11.2 描绘了强化学习问题的主要组成部分。代理是我们(强化学习从业者)直接控制的对象。代理(例如在建筑物中收集垃圾的机器人)以三种方式与环境交互:
-
在每一步中,代理程序采取一种 行动,这改变了环境的状态。例如,在我们的垃圾收集机器人的背景下,可供选择的行动集可能是
{前进,后退,左转,右转,抓取垃圾,将垃圾倒入容器}。 -
偶尔,环境会向代理程序提供一个 奖励,在人性化的术语中,可以理解为即时愉悦或满足的衡量。但更抽象地说,奖励(或者,如我们稍后将看到的,一段时间内的奖励总和)是一个代理试图最大化的数字。它是一个重要的数值,以类似于损失值引导监督学习算法的方式引导强化学习算法。奖励可以是正的或负的。在我们的垃圾收集机器人的例子中,当一袋垃圾成功倒入机器人的垃圾容器时,可以给予正奖励。此外,当机器人撞倒垃圾桶,撞到人或家具,或者在容器外倒垃圾时,应给予负奖励。
-
除了奖励外,代理还可以通过另一个渠道观察环境的状态,即观察。这可以是环境的完整状态,也可以只是代理可见的部分,可能通过某个不完美的渠道而失真。对于我们的垃圾收集机器人来说,观察包括来自其身体上的相机和各种传感器的图像和信号流。
图 11.2:强化学习问题的基本公式的示意图。在每个时间步,代理从可能的行动集合中选择一个行动,从而导致环境状态的变化。环境根据其当前状态和选择的行动向代理提供奖励。代理可以部分或完全观察到环境的状态,并将使用该状态来决定未来的行动。

刚定义的公式有些抽象。让我们看看一些具体的强化学习问题,并了解公式所涵盖的可能范围。在此过程中,我们还将浏览所有强化学习问题的分类。首先让我们考虑一下行动。代理可以选择的行动空间可以是离散的,也可以是连续的。例如,玩棋盘游戏的强化学习代理通常有离散的行动空间,因为在这种问题中,只有有限的移动选择。然而,一个涉及控制虚拟类人机器人的强化学习问题需要在双足行走时使用连续的行动空间,因为关节上的扭矩是连续变化的。在本章中,我们将介绍关于离散行动空间的示例问题。请注意,在某些强化学习问题中,可以通过离散化将连续的行动空间转化为离散的。例如,DeepMind 的《星际争霸 II》游戏代理将高分辨率的 2D 屏幕划分成较粗的矩形,以确定将单位移动到哪里或在哪里发起攻击。
²
查看 OpenAI Gym 中的 Humanoid 环境:
gym.openai.com/envs/Humanoid-v2/。³
Oriol Vinyals 等,“星际争霸 II:强化学习的新挑战”,提交日期:2017 年 8 月 16 日,
arxiv.org/abs/1708.04782。
奖励在强化学习问题中起着核心作用,但也呈现出多样性。首先,有些强化学习问题仅涉及正奖励。例如,正如我们稍后将看到的,一个强化学习代理的目标是使一个杆保持在移动的推车上,则它只会获得正奖励。每次它保持杆竖立时,它都会获得少量正奖励。然而,许多强化学习问题涉及正负奖励的混合。负奖励可以被看作是“惩罚”或“处罚”。例如,一个学习向篮筐投篮的代理应该因进球而获得正奖励,而因投篮失误而获得负奖励。
奖励的发生频率也可能不同。一些强化学习问题涉及连续的奖励流。比如前文提到的倒立摆问题:只要杆子还没倒下,智能体每一个时间步长都会获得(正面的)奖励。而对于下棋的强化学习智能体,则只有在游戏结束(胜利、失败或平局)时才会获得奖励。两种极端之间还有其他强化学习问题。例如,我们的垃圾收集机器人在两次成功垃圾转移之间可能完全没有任何奖励——也就是在从 A 点到 B 点的移动过程中。此外,训练打 Atari 游戏 Pong 的强化学习智能体也不会在电子游戏的每一步(帧)都获得奖励;相反,在球拍成功击中乒乓球并将其反弹到对手处时,才会每隔几步(帧)获得正面的奖励。本章我们将介绍一些奖励频率高低不同的强化学习问题。
观察是强化学习问题中的另一个重要因素。它是一个窗口,通过它智能体可以看到环境的状态,并且基于这个状态做出决策,而不仅仅是依据任何奖励。像动作一样,观察可以是离散的(例如在棋盘游戏或者扑克游戏中),也可以是连续的(例如在物理环境中)。你可能会问:为什么我们的强化学习公式将观察和奖励分开,即使它们都可以被看作是环境向智能体提供反馈的形式?答案是为了概念上的清晰和简单易懂。尽管奖励可以被视为观察,但它是智能体最终“关心”的。而观察可以包含相关和无关的信息,智能体需要学会过滤并巧妙地使用。
一些强化学习问题通过观察向智能体揭示环境的整个状态,而另一些问题则仅向智能体提供部分状态信息。第一类问题的例子包括棋类游戏(如棋类和围棋)。对于后一类问题,德州扑克等纸牌游戏是一个很好的例子,在这种游戏中你无法看到对手的手牌,而股票交易也是其中的一个例子。股票价格受许多因素的影响,例如公司的内部运营和市场上其他股票交易者的想法。但是,智能体只能观察到股票价格的逐时历史记录,可能还加上公开的信息,如财经新闻。
这个讨论建立了强化学习发生的平台。关于这个表述值得指出的一个有趣的事情是,代理与环境之间的信息流是双向的:代理对环境进行操作;环境反过来提供给代理奖励和状态信息。这使得强化学习与监督学习有所不同,监督学习中信息流主要是单向的:输入包含足够的信息,使得算法能够预测输出,但输出并不会以任何重要的方式影响输入。
强化学习问题的另一个有趣而独特的事实是,它们必须沿着时间维度发生,以便代理-环境交互由多个轮次或步骤组成。时间可以是离散的或连续的。例如,解决棋盘游戏的 RL 代理通常在离散的时间轴上操作,因为这类游戏是在离散的回合中进行的。视频游戏也是如此。然而,控制物理机器人手臂操纵物体的 RL 代理面临着连续的时间轴,即使它仍然可以选择在离散的时间点采取行动。在本章中,我们将专注于离散时间 RL 问题。
这个关于强化学习的理论讨论暂时就够了。在下一节中,我们将开始亲手探索一些实际的强化学习问题和算法。
11.2. 策略网络和策略梯度:车杆示例
我们将解决的第一个强化学习问题是模拟一个物理系统,在该系统中,一个装有杆的小车在一维轨道上移动。这个问题被恰如其名地称为车杆问题,它是由安德鲁·巴托(Andrew Barto)、理查德·萨顿(Richard Sutton)和查尔斯·安德森(Charles Anderson)在 1983 年首次提出的。自那时以来,它已经成为控制系统工程的基准问题(在某种程度上类似于 MNIST 数字识别问题用于监督学习),因为它的简单性和良好构建的物理学和数学,以及解决它并非完全微不足道。在这个问题中,代理的目标是通过施加左右方向的力来控制小车的运动,以尽可能长时间地保持杆的平衡。
⁴
安德鲁·G·巴托(Andrew G. Barto)、理查德·S·萨顿(Richard S. Sutton)和查尔斯·W·安德森(Charles W. Anderson),“可以解决困难学习控制问题的类神经自适应元件”,IEEE 系统、人类和控制论交易,1983 年 9 月/10 月,页码 834–846,
mng.bz/Q0rG。
11.2.1. 作为强化学习问题的车杆
在进一步探讨之前,你应该通过玩车杆示例来直观地理解这个问题。车杆问题简单轻便,我们完全可以在浏览器中进行模拟和训练。图 11.3 提供了车杆问题的可视化描述,你可以在通过yarn watch命令打开的页面中找到。要查看和运行示例,请使用
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/cart-pole
yarn && yarn watch
图 11.3. 小车杆问题的视觉渲染。A 面板:四个物理量(小车位置x,小车速度x′,杆倾角 θ 和杆角速度 θ')构成环境状态和观察。在每个时间步长,代理可以选择向左施加力或向右施加力的行动,这将相应地改变环境状态。B 和 C 面板:导致游戏结束的两个条件——要么小车向左或向右移动太多(B),要么杆从垂直位置倾斜太多(C)。

点击“创建模型”按钮,然后再点击“训练”按钮。然后您应该在页面底部看到一个动画,显示一个未经训练的代理执行车杆任务。由于代理模型的权重被初始化为随机值(关于模型的更多信息稍后再说),它的表现会非常糟糕。从游戏开始到结束的所有时间步有时在 RL 术语中称为一个episode。我们在这里将术语game和episode互换使用。
正如图 11.3 中的 A 面板所示,任何时间步中小车沿轨道的位置由称为x的变量捕获。它的瞬时速度表示为x'。此外,杆的倾斜角由另一个称为 θ 的变量捕获。杆的角速度(θ 变化的速度和方向)表示为 θ'。因此,这四个物理量(x,x',θ 和 θ')每一步都由代理完全观察到,并构成此 RL 问题的观察部分。
模拟在满足以下任一条件时结束:
-
x 的值超出预先指定的边界,或者从物理角度来说,小车撞到轨道两端的墙壁之一(图 11.3 的 B 面板)。
-
当 θ 的绝对值超过一定阈值时,或者从物理角度来说,杆过于倾斜,偏离了垂直位置(图 11.3 的 C 面板)。
环境还在第 500 个模拟步骤后终止一个 episode。这样可以防止游戏持续时间过长(一旦代理通过学习变得非常擅长游戏,这种情况可能会发生)。步数的上限在用户界面中是可以调整的。直到游戏结束,代理在模拟的每一步都获得一个单位的奖励(1)。因此,为了获得更高的累积奖励,代理需要找到一种方法来保持杆站立。但是代理如何控制小车杆系统呢?这就引出了这个 RL 问题的行动部分。
如图 11.3 A 面板中的力箭头所示,智能体在每一步只能执行两种可能的动作:在小车上施加向左或向右的力。智能体必须选择其中一种力的方向。力的大小是固定的。一旦施加了力,模拟将执行一组数学方程来计算环境的下一个状态(x、x'、θ 和 θ' 的新值)。详细内容涉及熟悉的牛顿力学。我们不会详细介绍这些方程,因为在这里理解它们并不重要,但是如果您感兴趣,可以在 cart-pole 目录下的 cart-pole/cart_pole.js 文件中找到它们。
类似地,渲染小车摆杆系统的 HTML 画布的代码可以在 cart-pole/ui.js 中找到。这段代码突显了使用 JavaScript(特别是 TensorFlow.js)编写 RL 算法的优势:UI 和学习算法可以方便地用同一种语言编写,并且彼此紧密集成。这有助于可视化和直观理解问题,并加速开发过程。为了总结小车摆杆问题,我们可以用经典强化学习框架来描述它(参见 table 11.1)。
表 11.1. 在经典强化学习框架中描述了小车摆杆问题
| 抽象 RL 概念 | 在小车摆杆问题中的实现 |
|---|---|
| 环境 | 一个运载杆子并在一维轨道上移动的小车。 |
| 动作 | (离散)在每一步中,在左侧施加力和右侧施加力之间进行二进制选择。力的大小是固定的。 |
| 奖励 | (频繁且仅为正值)对于游戏每一步,智能体会收到固定的奖励(1)。当小车撞到轨道一端的墙壁,或者杆子从直立位置倾斜得太厉害时,该情节就会结束。 |
| 观测 | (完整状态,连续)每一步,智能体可以访问小车摆杆系统的完整状态,包括小车位置(x)和速度(x'),以及杆倾斜角(θ)和角速度(θ')。 |
11.2.2. 策略网络
现在小车摆杆强化学习问题已经描述完毕,让我们看看如何解决它。从历史上看,控制理论家们曾经为这个问题设计过巧妙的解决方案。他们的解决方案基于这个系统的基本物理原理。[5] 但是在本书的背景下,我们不会这样来解决这个问题。在本书的背景下,这样做有点类似于编写启发式算法来解析 MNIST 图像中的边缘和角落,以便对数字进行分类。相反,我们将忽略系统的物理特性,让我们的智能体通过反复试错来学习。这符合本书其余部分的精神:我们不是在硬编码算法,也不是根据人类知识手动设计特征,而是设计了一种允许模型自主学习的算法。
⁵
如果您对传统的、非 RL 方法解决小车-杆问题感兴趣,并且不怕数学,可以阅读麻省理工学院 Russ Tedrake 的控制理论课程的开放课程 Ware:
mng.bz/j5lp。
我们如何让代理在每一步决定动作(向左还是向右的力)?鉴于代理可用的观察和代理每一步需要做出的决定,这个问题可以被重新制定为一个简单的输入输出映射问题,就像在监督学习中那样。一个自然的解决方案是构建一个神经网络,根据观察来选择一个动作。这是策略网络背后的基本思想。
这个神经网络将一个长度为 4 的观察向量(x,x',θ和θ')作为输入,并输出一个可以转化为左右决定的数字。该网络架构类似于我们在第三章中为仿冒网站构建的二元分类器。抽象地说,每一步,我们将查看环境,并使用我们的网络决定采取哪些行动。通过让我们的网络玩一些回合,我们将收集一些数据来评价那些决定。然后,我们将发明一种方法来给这些决定分配质量,以便我们可以调整我们的网络的权重,使其在将来做出更像“好”的决定,而不像“坏”的决定。
该系统的细节与我们之前的分类器工作在以下方面有所不同:
-
模型在游戏过程中多次被调用(在每个时间步长)。
-
模型的输出(图 11.4 中的策略网络框中的输出)是对数而不是概率分数。通过 S 形函数将对数值转换为概率分数。我们之所以不直接在策略网络的最后(输出)层中包含 S 形非线性,是因为我们需要对数值进行训练,我们很快就会看到原因。
图 11.4。策略网络如何融入我们解决小车-杆问题的解决方案。策略网络是一个 TensorFlow.js 模型,通过使用观察向量(x,x',θ和θ')作为输入,输出左向力动作的概率。通过随机抽样将概率转换为实际行动。
![]()
-
由 S 形函数输出的概率必须转换为具体的动作(向左还是向右)。这是通过随机抽样
tf.multinomial()函数调用完成的。回想一下,在 lstm-text-generation example 中,我们使用tf.multinomial()来对字母表上的 softmax 概率进行抽样以抽取下一个字符。在这里的情况稍微简单一些,因为只有两个选择。
最后一点有着更深层次的含义。考虑到我们可以直接将 tf.sigmoid() 函数的输出通过应用阈值(例如,当网络的输出大于 0.5 时选择左侧动作,否则选择右侧动作)转换为一个动作。为什么我们更倾向于使用 tf.multinomial() 的更复杂的随机抽样方法,而不是这种更简单的方法?答案是我们希望tf.multinomial() 带来的随机性。在训练的早期阶段,策略网络对于如何选择力的方向一无所知,因为其权重是随机初始化的。通过使用随机抽样,我们鼓励它尝试随机动作并查看哪些效果更好。一些随机试验将会失败,而另一些则会获得良好的结果。我们的算法会记住这些良好的选择,并在将来进行更多这样的选择。但是除非允许代理随机尝试,否则这些良好的选择将无法实现。如果我们选择了确定性的阈值方法,模型将被困在其初始选择中。
这将我们带入了强化学习中一个经典而重要的主题,即探索与利用。探索指的是随机尝试;这是 RL 代理发现良好行动的基础。利用意味着利用代理已学到的最优解以最大化奖励。这两者是相互不兼容的。在设计工作 RL 算法时,找到它们之间的良好平衡非常关键。起初,我们想要探索各种可能的策略,但随着我们逐渐收敛于更好的策略,我们希望对这些策略进行微调。因此,在许多算法中,训练过程中的探索通常会逐渐减少。在 cart-pole 问题中,这种减少是隐含在 tf.multinomial() 抽样函数中的,因为当模型的置信水平随着训练增加时,它会给出越来越确定的结果。
清单 11.1(摘自 cart-pole/index.js)展示了创建策略网络的 TensorFlow.js 调用。清单 11.2 中的代码(同样摘自 cart-pole/index.js)将策略网络的输出转换为代理的动作,并返回用于训练目的的对数概率。与我们在前几章遇到的监督学习模型相比,这里的模型相关代码并没有太大不同。
然而,这里根本不同的是,我们没有一组可以用来教模型哪些动作选择是好的,哪些是坏的标记数据集。如果我们有这样的数据集,我们可以简单地在策略网络上调用 fit() 或 fitDataset() 来解决问题,就像我们在前几章中对模型所做的那样。但事实是我们没有,所以智能体必须通过玩游戏并观察到的奖励来弄清楚哪些动作是好的。换句话说,它必须“通过游泳学会游泳”,这是 RL 问题的一个关键特征。接下来,我们将详细看一下如何做到这一点。
策略网络 MLP:基于观察选择动作
createModel(hiddenLayerSizes) { ***1***
if (!Array.isArray(hiddenLayerSizes)) {
hiddenLayerSizes = [hiddenLayerSizes];
}
this.model = tf.sequential();
hiddenLayerSizes.forEach((hiddenLayerSize, i) => {
this.model.add(tf.layers.dense({
units: hiddenLayerSize,
activation: 'elu',
inputShape: i === 0 ? [4] : undefined ***2***
}));
});
this.model.add(tf.layers.dense({units: 1})); ***3***
}
}
-
hiddenLayerSize 控制策略网络除最后一层(输出层)之外的所有层的大小。
-
inputShape 仅在第一层需要。
-
最后一层被硬编码为一个单元。单个输出数字将被转换为选择左向力动作的概率。
从策略网络输出获取 logit 和动作的方法示例
getLogitsAndActions(inputs) {
return tf.tidy(() => {
const logits = this.policyNet.predict(inputs);
const leftProb = tf.sigmoid(logits); ***1***
const leftRightProbs = tf.concat( ***2***
[leftProb, tf.sub(1, leftProb)], 1); ***2***
const actions = tf.multinomial( ***3***
leftRightProbs, 1, null, true); ***3***
return [logits, actions];
});
}
-
将 logit 转换为左向动作的概率值
-
计算两个动作的概率值,因为 tf.multinomial() 需要它们。
-
根据概率值随机抽样动作。四个参数分别是概率值、抽样数量、随机种子(未使用),以及一个指示概率值是否归一化的标志。
训练策略网络:REINFORCE 算法
现在关键问题是如何计算哪些动作是好的,哪些是坏的。如果我们能回答这个问题,我们就能够更新策略网络的权重,使其在未来更有可能选择好的动作,这与监督学习类似。很快能想到的是我们可以使用奖励来衡量动作的好坏。但是车杆问题涉及奖励:1)总是有一个固定值(1);2)只要剧集没有结束,就会在每一步发生。所以,我们不能简单地使用逐步奖励作为度量标准,否则所有动作都会被标记为同样好。我们需要考虑每个剧集持续的时间。
一个简单的方法是在一个剧集中求所有奖励的总和,这给了我们剧集的长度。但是总和能否成为对动作的良好评估?很容易意识到这是不行的。原因在于剧集末尾的步骤。假设在一个长剧集中,智能体一直很好地平衡车杆系统,直到接近结束时做了一些不好的选择,导致剧集最终结束。简单的总和方法会将最后的不良动作和之前的良好动作平等评估。相反,我们希望将更高的分数分配给剧集早期和中间部分的动作,并将较低的分配给靠近结尾的动作。
这引出了 奖励折扣 的概念,一个简单但在 RL 中非常重要的概念:某一步的价值应等于即时奖励加上预期未来奖励。未来奖励可能与即时奖励同等重要,也可能不那么重要。可以通过折扣系数 γ 来量化相对平衡。γ 通常设置为接近但略小于 1 的值,如 0.95 或 0.99。我们可以用公式表示:
公式 11.1。

在 公式 11.1 中,v[i] 表示第 i 步状态的总折扣奖励,可以理解为该特定状态的价值。它等于在该步骤给予智能体的即时奖励 (r[i]),加上下一步奖励 (r[i][+1]) 乘以折扣系数 γ,再加上再后两步的折扣奖励,以此类推,直到该事件结束(第 N 步)。
为了说明奖励折扣,我们展示了这个公式如何将原始奖励转换为更有用的价值度量方式,如 图 11.5 所示。面板 A 的顶部图显示了来自一段短情节的所有四步原始奖励。底部图显示了根据 公式 11.1 计算的折扣奖励。为了比较,面板 B 显示了来自长度为 20 的更长情节的原始和折扣总奖励。从两个面板可以看出,折扣总奖励值在开头较高,在结尾较低,这是有意义的,因为我们要为一个游戏结束的动作分配较低的值。此外,长情节的开头和中段的值(面板 B)高于短情节的开头(面板 A)。这也是有意义的,因为我们要为导致更长情节的动作分配更高的值。
图 11.5。面板 A:对四步情节的奖励进行奖励折扣(公式 11.1)。面板 B:与面板 A 相同,但来自一个包含 20 步的情节(即比面板 A 的情节长五倍)。由于折扣,与靠近结尾的动作相比,为每个情节的开始动作分配更高的值。

奖励折扣公式为我们提供了一组比单纯地求和更有意义的值。但我们仍然面临着如何使用这些折扣奖励价值来训练策略网络的问题。为此,我们将使用一种名为 REINFORCE 的算法,该算法由罗纳德·威廉姆斯在 1992 年发明。^([6]) REINFORCE 的基本思想是调整策略网络的权重,使其更有可能做出良好的选择(选择分配更高的折扣奖励)并减少做出不良选择(分配更低的折扣奖励)。
⁶
Ronald J. Williams,“Simple Statistical Gradient-Following Algorithms for Connectionist Reinforcement Learning,” Machine Learning, vol. 8, nos. 3–4, pp. 229–256,
mng.bz/WOyw.
为了达到此目的,我们需要计算改变参数的方向,以使给定观察输入更有可能进行动作。这是通过 代码清单 11.3(摘自 cart-pole/index.js)实现的。函数 getGradientsAndSaveActions() 在游戏的每个步骤中被调用。它比较逻辑回归(未归一化的概率得分)和该步骤选择的实际动作,并返回相对于策略网络权重的两者不一致性的梯度。这可能听起来很复杂,但直观上是相当简单的。返回的梯度告诉策略网络如何更改其权重,以使选择更类似于实际选择。这些梯度与训练集的奖励一起构成了我们强化学习方法的基础。这就是为什么该方法属于被称为 策略梯度 的强化学习算法家族的原因。
代码清单 11.3 通过比较逻辑回归和实际动作来获取权重的梯度。
getGradientsAndSaveActions(inputTensor) {
const f = () => tf.tidy(() => {
const [logits, actions] =
this.getLogitsAndActions(inputTensor); ***1***
this.currentActions_ = actions.dataSync();
const labels =
tf.sub(1, tf.tensor2d(this.currentActions_, actions.shape));
return tf.losses.sigmoidCrossEntropy( ***2***
labels, logits).asScalar(); ***2***
});
return tf.variableGrads(f); ***3***
}
-
1
getLogitsAndActions()在 代码清单 11.2 中定义。 -
2 sigmoid 交叉熵损失量化其在游戏中实际执行的动作与策略网络输出的逻辑回归之间的差异。
-
3 计算损失相对于策略网络权重的梯度。
在训练期间,我们让代理对象玩一些游戏(比如 N 个游戏),并根据 方程式 11.1 收集所有折扣奖励以及所有步骤中的梯度。然后,我们通过将梯度与折扣奖励的归一化版本相乘来结合折扣奖励和梯度。奖励归一化在这里是一个重要的步骤。它线性地转移和缩放了 N 个游戏中所有折扣奖励,使得它们的总体均值为 0 和总体标准偏差为 1。图 11.6 显示了在折扣奖励上应用此归一化的示例。它说明了短剧集(长度为 4)和较长剧集(长度为 20)的归一化、折扣奖励。从这张图中可以明确 REINFORCE 算法所偏向的步骤是什么:它们是较长剧集的早期和中间部分的动作。相比之下,所有来自较短(长度为 4)剧集的步骤都被赋予 负 值。负的归一化奖励意味着什么?这意味着当它用于稍后更新策略网络的权重时,它将使网络远离未来给定相似状态输入时进行类似动作的选择。这与正的归一化奖励相反,后者将使策略网络向未来在类似的输入条件下做出相似的动作方向
图 11.6. 对两个长度为 4(面板 A)和 20(面板 B)的情节中的折现奖励进行归一化。我们可以看到,归一化的折现奖励在长度为 20 的情节开始部分具有最高值。策略梯度方法将使用这些折现奖励值来更新策略网络的权重,这将使网络更不可能选择导致第一个情节(长度 = 4)中不良奖励的动作选择,并且更有可能选择导致第二个情节开始部分(长度 = 20)中良好奖励的选择(在相同的状态输入下,即)。

对折现奖励进行归一化,并使用它来缩放梯度的代码有些冗长但不复杂。它在 cart-pole/index.js 中的 scaleAndAverageGradients() 函数中,由于篇幅限制这里不列出。缩放后的梯度用于更新策略网络的权重。随着权重的更新,策略网络将对从分配了更高折现奖励的步骤中的动作输出更高的 logits,并对从分配了较低折现奖励的步骤中的动作输出较低的 logits。
这基本上就是 REINFORCE 算法的工作原理。基于 REINFORCE 的 cart-pole 示例的核心训练逻辑显示在 列表 11.4 中。它是前面描述的步骤的重述:
-
调用策略网络以基于当前代理观察获得 logits。
-
基于 logits 随机采样一个动作。
-
使用采样的动作更新环境。
-
记住以下内容以备后续更新权重(步骤 7):logits 和所选动作,以及损失函数相对于策略网络权重的梯度。这些梯度被称为 策略梯度。
-
从环境中接收奖励,并将其记住以备后用(步骤 7)。
-
重复步骤 1–5 直到完成
numGames情节。 -
一旦所有
numGames情节结束,对奖励进行折扣和归一化,并使用结果来缩放步骤 4 中的梯度。然后使用缩放后的梯度来更新策略网络的权重。(这是策略网络的权重被更新的地方。) -
(未在 列表 11.4 中显示)重复步骤 1–7
numIterations次。
将这些步骤与代码中的步骤进行比较(从 cart-pole/index.js 中摘录),以确保您能够看到对应关系并按照逻辑进行。
列表 11.4. Cart-pole 示例中实现 REINFORCE 算法的训练循环
async train(
cartPoleSystem, optimizer, discountRate, numGames, maxStepsPerGame) {
const allGradients = [];
const allRewards = [];
const gameSteps = [];
onGameEnd(0, numGames);
for (let i = 0; i < numGames; ++i) { ***1***
cartPoleSystem.setRandomState(); ***2***
const gameRewards = [];
const gameGradients = [];
for (let j = 0; j < maxStepsPerGame; ++j) { ***3***
const gradients = tf.tidy(() => {
const inputTensor = cartPoleSystem.getStateTensor();
return this.getGradientsAndSaveActions( ***4***
inputTensor).grads; ***4***
});
this.pushGradients(gameGradients, gradients);
const action = this.currentActions_[0];
const isDone = cartPoleSystem.update(action); ***5***
await maybeRenderDuringTraining(cartPoleSystem);
if (isDone) {
gameRewards.push(0);
break;
} else {
gameRewards.push(1); ***6***
}
}
onGameEnd(i + 1, numGames);
gameSteps.push(gameRewards.length);
this.pushGradients(allGradients, gameGradients);
allRewards.push(gameRewards);
await tf.nextFrame();
}
tf.tidy(() => {
const normalizedRewards = ***7***
discountAndNormalizeRewards(allRewards, discountRate); ***7***
optimizer.applyGradients( ***8***
scaleAndAverageGradients(allGradients, normalizedRewards)); ***8***
});
tf.dispose(allGradients);
return gameSteps;
}
-
1 循环指定次数的情节
-
2 随机初始化一个游戏情节
-
3 循环游戏的步骤
-
4 跟踪每步的梯度以备后续 REINFORCE 训练
-
5 代理在环境中采取一个动作。
-
6 只要游戏尚未结束,代理每步都会获得一个单位奖励。
-
7 对奖励进行折扣和归一化(REINFORCE 的关键步骤)
-
8 使用来自所有步骤的缩放梯度更新策略网络的权重
要看到 REINFORCE 算法的运行情况,请在演示页面上指定 25 个时期,并单击“训练”按钮。默认情况下,训练期间实时显示环境的状态,以便您可以看到学习代理的重复尝试。要加快训练速度,请取消选中“训练期间渲染”复选框。在合理更新的笔记本电脑上,25 个时期的训练需要几分钟,并且应足以达到天花板性能(默认设置下游戏每轮 500 步)。图 11.7 显示了典型的训练曲线,该曲线将平均每轮长度作为训练迭代的函数绘制出来。请注意,训练进度显示出一些戏剧性的波动,平均步数随着迭代次数以非单调和高度嘈杂的方式变化。这种波动在强化学习训练工作中并不罕见。
图 11.7. 一条曲线显示了智能体在车杆问题的每个训练迭代中生存的平均步数与训练迭代次数的关系。在约第 20 次迭代时达到完美分数(在本例中为 500 步)。这个结果是在隐藏层大小为 128 的情况下获得的。曲线的高度非单调和波动形状在强化学习问题中并不罕见。

训练完成后,单击“测试”按钮,您应该会看到代理在许多步骤上很好地保持车杆系统平衡的表现。由于测试阶段不涉及最大步数(默认为 500 步),因此代理可以保持游戏进行超过 1,000 步。如果持续时间过长,您可以单击“停止”按钮终止模拟。
总结这一节,图 11.8 概括了问题的表述以及 REINFORCE 策略梯度算法的作用。这张图展示了解决方案的所有主要部分。在每个步骤中,代理使用一个名为策略网络的神经网络来估计向左行动(或等效地,向右行动)是更好的选择的可能性。这种可能性通过一个随机抽样过程转换为实际行动,该过程鼓励代理早期探索并在后期遵守估计的确定性。行动驱动环境中的车杆系统,该系统反过来为代理提供奖励,直到本集的结束。这个过程重复了多个集,期间 REINFORCE 算法记住了每一步的奖励、行动和策略网络的估计。当 REINFORCE 需要更新策略网络时,它通过奖励折现和归一化区分网络中的好估计和坏估计,然后使用结果来推动网络的权重朝着未来做出更好的估计。这个过程迭代了多次,直到训练结束(例如,当代理达到阈值性能时)。
图 11.8. 展示了基于 REINFORCE 算法的解决方案对车杆问题的示意图。该图是图 11.4 中图示的扩展视图。

抛开所有优雅的技术细节,让我们退后一步,看一看这个例子中体现的 RL 的大局。基于 RL 的方法相对于非机器学习方法(如传统控制理论)具有明显的优势:普适性和人力成本的经济性。在系统具有复杂或未知特性的情况下,RL 方法可能是唯一可行的解决方案。如果系统的特性随时间变化,我们不必从头开始推导新的数学解:我们只需重新运行 RL 算法,让代理适应新情况。
RL 方法的劣势,这仍然是 RL 研究领域中一个未解决的问题,是它需要在环境中进行许多次重复试验。在车杆示例中,大约需要 400 个游戏回合才能达到目标水平的熟练程度。一些传统的、非 RL 方法可能根本不需要试验。实施基于控制理论的算法,代理应该能够从第 1 个回合就平衡杆子。对于像车杆这样的问题,RL 对于重复试验的渴望并不是一个主要问题,因为计算机对环境的模拟是简单、快速和廉价的。然而,在更现实的问题中,比如自动驾驶汽车和物体操纵机器臂,RL 的这个问题就变得更加尖锐和紧迫。没有人能承担在训练代理时多次撞车或者摧毁机器臂的成本,更不用说在这样的现实问题中运行 RL 训练算法将需要多么长的时间。
这就结束了我们的第一个 RL 示例。车杆问题具有一些特殊的特征,在其他 RL 问题中不适用。例如,许多 RL 环境并不会在每一步向代理提供正面奖励。在某些情况下,代理可能需要做出几十个甚至更多的决策,才能获得积极的奖励。在正面奖励之间的空隙中,可能没有奖励,或者只有负面奖励(可以说很多现实生活中的努力,比如学习、锻炼和投资,都是如此!)。此外,车杆系统在“无记忆”方面是“无记忆”的,即系统的动态不取决于代理过去的行为。许多 RL 问题比这更复杂,因为代理的行为改变了环境的某些方面。我们将在下一节中研究的 RL 问题将展示稀疏的正面奖励和一个随着行动历史而变化的环境。为了解决这个问题,我们将介绍另一个有用且流行的 RL 算法,称为 deep Q-learning。
11.3. 价值网络和 Q 学习:蛇游戏示例
我们将使用经典的动作游戏 snake 作为我们深度 Q 学习的示例问题。就像我们在上一节中所做的那样,我们将首先描述 RL 问题及其带来的挑战。在这样做的过程中,我们还将讨论为什么策略梯度和 REINFORCE 对这个问题不会非常有效。
11.3.1. 蛇作为一个强化学习问题
蛇游戏首次出现在 1970 年代的街机游戏中,已经成为一个广为人知的视频游戏类型。tfjs-examples 中的 snake-dqn 目录包含一个简单变体的 JavaScript 实现。您可以通过以下代码查看:
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/snake-dqn
yarn
yarn watch
在由yarn watch命令打开的网页中,你可以看到贪吃蛇游戏的棋盘。你可以加载一个预先训练并托管的深度 Q 网络(DQN)模型,并观察它玩游戏。稍后,我们将讨论如何从头开始训练这样的模型。现在,通过观察,你应该能直观地感受到这款游戏是如何运行的。如果你还不熟悉贪吃蛇游戏,它的设置和规则可以总结如下。
首先,所有动作发生在一个 9×9 的网格世界中(参见图 11.9 的例子)。世界(或棋盘)可以设得更大,但在我们的例子中,9×9 是默认大小。棋盘上有三种类型的方块:蛇、果子和空白。蛇由蓝色方块表示, 只有头部是橙色的,并带有半圆形代表蛇的嘴巴。果子由内部有圆圈的绿色方块表示。空白方块是白色的。游戏按步骤进行,或者按视频游戏术语来说是帧。在每一帧中,代理必须从三个可能的动作中为蛇选择:直行、左转或右转(原地不动不是选项)。当蛇的头部与果子方块接触时,代理被奖励呈积极反应,这种情况下果子方块将消失(被蛇“吃掉”),蛇的长度会在尾部增加一个。一个新的果子将出现在空白方块中。如果代理在某一步没有吃到果子,它将受到负奖励。游戏终止(蛇“死亡”)是指当蛇的头部离开边界(如图 11.9 的面板 B)或撞到自己的身体时。
图 11.9. 贪吃蛇游戏:一个网格世界, 玩家控制蛇吃果子。蛇的“目标”是通过有效的移动模式尽可能多地吃果子(面板 A)。每次吃一个果子蛇的长度增加 1。游戏结束(蛇“死掉”)是当蛇离开边界(面板 B)或撞到自己的身体(面板 C)时。注意,在面板 B 中,蛇的头部到达边缘位置,然后发生了向上的运动(直行动作),导致游戏终止。简单到达边缘方块并不会导致终止。吃掉每个果子会导致一个很大的正奖励。在没有吃果子的情况下移动一个方块会导致一个较小幅度的负奖励。游戏终止(蛇死亡)也会导致一个负奖励。

蛇游戏中的一个关键挑战是蛇的增长。如果没有这个规则,游戏会简单得多。只需一遍又一遍地将蛇导航到水果,智能体可以获得无限的奖励。然而,有了长度增长规则,智能体必须学会避免撞到自己的身体,随着蛇吃更多的水果和变得更长,这变得更加困难。这是蛇 RL 问题的非静态方面,推车杆环境所缺乏的,正如我们在上一节末尾提到的。
表 11.2 在经典 RL 表述中描述了蛇问题。与推车杆问题的表述(表 11.1)相比,最大的区别在于奖励结构。在蛇问题中,正奖励(每吃一颗水果+10)出现不频繁——也就是说,只有在蛇移动到达水果后,经历了一系列负奖励后才会出现。考虑到棋盘的大小,即使蛇以最有效的方式移动,两个正奖励之间的间隔也可能长达 17 步。小的负奖励是一个惩罚,鼓励蛇走更直接的路径。没有这个惩罚,蛇可以以蜿蜒的间接方式移动,并且仍然获得相同的奖励,这将使游戏和训练过程不必要地变长。这种稀疏而复杂的奖励结构也是为什么策略梯度和 REINFORCE 方法在这个问题上效果不佳的主要原因。策略梯度方法在奖励频繁且简单时效果更好,就像推车杆问题一样。
表 11.2. 在经典 RL 表述中描述蛇游戏问题
| 抽象 RL 概念 | 在蛇问题中的实现 |
|---|---|
| 环境 | 一个包含移动蛇和自我补充水果的网格世界。 |
| 动作 | (离散) 三元选择:直行,左转,或右转。 |
| 奖励 | (频繁,混合正负奖励)
-
吃水果——大正奖励 (+10)
-
移动而不吃水果——小负奖励 (–0.2)
-
死亡——大负奖励 (–10)
|
| 观测 | (完整状态,离散) 每一步,智能体可以访问游戏的完整状态:即棋盘上每个方块的内容。 |
|---|
蛇的 JavaScript API
我们的 JavaScript 实现可以在文件 snake-dqn/snake_ game.js 中找到。我们只会描述SnakeGame类的 API,并略过实现细节,如果你感兴趣,可以自行学习。SnakeGame类的构造函数具有以下语法:
const game = new SnakeGame({height, width, numFruits, initLen});
这里,棋盘的大小参数,height和width,默认值为 9。numFruits是棋盘上任意给定时间存在的水果数量,默认值为 1。initLen,蛇的初始长度,默认值为 2。
game对象暴露的step()方法允许调用者在游戏中执行一步:
const {state, reward, done, fruitEaten} = game.step(action);
step() 方法的参数表示动作:0 表示直行,1 表示向左转,2 表示向右转。step() 方法的返回值具有以下字段:
-
state—动作后立即棋盘的新状态,表示为具有两个字段的普通 JavaScript 对象:-
s—蛇占据的方块,以[x, y]坐标数组形式表示。此数组的元素按照头部对应第一个元素,尾部对应最后一个元素的顺序排列。 -
f—水果占据的方块的[x, y]坐标。请注意,此游戏状态的表示设计为高效,这是由 Q 学习算法存储大量(例如,成千上万)这样的状态对象所必需的,正如我们很快将看到的。另一种方法是使用数组或嵌套数组来记录棋盘上每个方块的状态,包括空的方块。这将是远不及空间高效的方法。
-
-
reward—蛇在步骤中立即执行动作后获得的奖励。这是一个单一数字。 -
done—一个布尔标志,指示游戏在动作发生后是否立即结束。 -
fruitEaten—一个布尔标志,指示蛇在动作中是否吃到了水果。请注意,这个字段部分冗余于reward字段,因为我们可以从reward推断出是否吃到了水果。它包含在内是为了简单起见,并将奖励的确切值(可能是可调节的超参数)与水果被吃与未被吃的二进制事件解耦。
正如我们将在稍后看到的,前三个字段(state、reward 和 done)在 Q 学习算法中将发挥重要作用,而最后一个字段(fruitEaten)主要用于监视。
11.3.2. 马尔可夫决策过程和 Q 值
要解释我们将应用于蛇问题的深度 Q 学习算法,首先需要有点抽象。特别是,我们将以基本水平介绍马尔可夫决策过程(MDP)及其基本数学。别担心:我们将使用简单具体的示例,并将概念与我们手头的蛇问题联系起来。
从 MDP 的视角看,RL 环境的历史是通过有限数量的离散状态的一系列转换。此外,状态之间的转换遵循一种特定类型的规则:
下一步环境的状态完全由代理在当前步骤采取的状态和动作决定。
关键是下一个状态仅取决于两件事:当前状态和采取的动作,而不是其他。换句话说,MDP 假设你的历史(你如何到达当前状态)与决定下一步该做什么无关。这是一个强大的简化,使问题更易处理。什么是 非马尔可夫决策过程?这将是一种情况,即下一个状态不仅取决于当前状态和当前动作,还取决于先前步骤的状态或动作,可能一直追溯到情节开始。在非马尔可夫情况下,数学会变得更加复杂,解决数学问题需要更多的计算资源。
对于许多强化学习问题来说,马尔可夫决策过程的要求是直观的。象棋游戏是一个很好的例子。在游戏的任何一步中,棋盘配置(以及轮到哪个玩家)完全描述了游戏状态,并为玩家提供了计算下一步移动所需的所有信息。换句话说,可以从棋盘配置恢复棋局而不知道先前的移动。 (顺便说一句,这就是为什么报纸可以以非常节省空间的方式发布国际象棋谜题的原因。)像贪吃蛇这样的视频游戏也符合马尔可夫决策过程的公式化。蛇和食物在棋盘上的位置完全描述了游戏状态,这就足以从那一点恢复游戏或代理决定下一步行动。
尽管诸如国际象棋和贪吃蛇等问题与马尔可夫决策过程完全兼容,但它们都涉及天文数字级别的可能状态。为了以直观和视觉的方式呈现马尔可夫决策过程,我们需要一个更简单的例子。在 图 11.10 中,我们展示了一个非常简单的马尔可夫决策过程问题,其中只有七种可能的状态和两种可能的代理动作。状态之间的转换受以下规则管理:
-
初始状态始终为 s[1]。
-
从状态 s[1] 开始,如果代理采取动作 a[1],环境将进入状态 s[2]。如果代理采取动作 a[2],环境将进入状态 s[3]。
-
从每个状态 s[2] 和 s[3],进入下一个状态的转换遵循一组类似的分叉规则。
-
状态 s[4]、s[5]、s[6] 和 s[7] 是终止状态:如果达到任何一个状态,那么该情节结束。
图 11.10. 马尔可夫决策过程(MDP)的一个非常简单具体的例子。状态表示为标有 s[n] 的灰色圆圈,而动作表示为标有 a[m] 的灰色圆圈。由动作引起的每个状态转换的奖励标有 r = x。

因此,在这个强化学习问题中,每个阶段都恰好持续三个步骤。在这个强化学习问题中,代理应该如何决定在第一步和第二步采取什么行动?考虑到我们正在处理一个强化学习问题,只有在考虑奖励时,这个问题才有意义。在马尔可夫决策过程中,每个动作不仅引起状态转移,而且还导致奖励。在 图 11.10 中,奖励被描述为将动作与下一个状态连接的箭头,标记为 r = <reward_value>。代理的目标当然是最大化总奖励(按比例折现)。现在想象一下我们是第一步的代理。让我们通过思考过程来确定我们将选择 a[1] 还是 a[2] 更好的选择。假设奖励折现因子(γ)的值为 0.9。
思考过程如下。如果我们选择动作 a[1],我们将获得–3 的即时奖励并转移到状态 s[2]。如果我们选择动作 a[2],我们将获得 3 的即时奖励并转移到状态 s[3]。这是否意味着 a[2] 是更好的选择,因为 3 大于 –3?答案是否定的,因为 3 和 –3 只是即时奖励,并且我们还没有考虑以下步骤的奖励。我们应该看看每个 s[2] 和 s[3] 的最佳可能结果是什么。从 s[2] 得到的最佳结果是通过采取动作 a[2] 而产生的结果,该动作获得了 11 的奖励。这导致我们从状态 s[1] 采取动作 a[1] 可以期望的最佳折现奖励:
| 当从状态 s[1] 采取动作 a[1] 时的最佳奖励 = 即时奖励 + 折现未来奖励 |
|---|
同样,从状态 s[3] 得到的最佳结果是我们采取动作 a[1],这给我们带来了–4 的奖励。因此,如果我们从状态 s[1] 采取动作 a[2],我们可以得到的最佳折现奖励是
| 当从状态 s[1] 采取动作 a[2] 时的最佳奖励 = 即时奖励 + 折现未来奖励 |
|---|
我们在这里计算的折现奖励是我们所说的 Q-values 的示例。 Q-value 是给定状态的动作的预期总累积奖励(按比例折现)。从这些 Q-values 中,很明显 a[1] 是在状态 s[1] 下更好的选择——这与仅考虑第一个动作造成的即时奖励的结论不同。本章末尾的练习 3 将指导您完成涉及随机性的更现实的 MDP 情景的 Q-value 计算。
描述的示例思考过程可能看起来微不足道。但它引导我们得到一个在 Q 学习中起着核心作用的抽象。Q 值,表示为 Q(s, a),是当前状态 (s) 和动作 (a) 的函数。换句话说,Q(s, a) 是一个将状态-动作对映射到在特定状态采取特定动作的估计值的函数。这个值是长远眼光的,因为它考虑了最佳未来奖励,在所有未来步骤中都选择最优动作的假设下。
由于它的长远眼光,Q(s, a) 是我们在任何给定状态决定最佳动作的全部内容。特别是,鉴于我们知道 *Q*(*s*, *a*) 是什么,最佳动作是在所有可能动作中给出最高 Q 值的动作:
方程 11.2.

其中 N 是所有可能动作的数量。如果我们对 Q(s, a) 有一个很好的估计,我们只需在每一步简单地遵循这个决策过程,我们就能保证获得最高可能的累积奖励。因此,找到最佳决策过程的强化学习问题被简化为学习函数 Q(s, a)。这就是为什么这个学习算法被称为 Q 学习的原因。
让我们停下来,看看 Q 学习与我们在小车杆问题中看到的策略梯度方法有何不同。策略梯度是关于预测最佳动作的;Q 学习是关于预测所有可能动作的值(Q 值)。虽然策略梯度直接告诉我们选择哪个动作,但 Q 学习需要额外的“选择最大值”的步骤,因此稍微间接一些。这种间接性带来的好处是,它使得在涉及稀疏正奖励(如蛇)的问题中更容易形成奖励和连续步骤值之间的连接,从而促进学习。
奖励与连续步骤的值之间有什么联系?当我们解决简单的 MDP 问题时,我们已经窥见了这一点,见图 11.10。这种连接可以用数学方式表示为:
方程 11.3.

s[next] 是我们在状态 s[i] 中选择动作 a 后到达的状态。这个方程被称为贝尔曼方程,是我们在简单的早期示例中得到数字 6 和 -0.6 的抽象。简单来说,这个方程表示:
⁷
归因于美国应用数学家理查德·E·贝尔曼(1920–1984)。参见他的书 Dynamic Programming,普林斯顿大学出版社,1957 年。
在状态 s[i] 采取动作 a 的 Q 值是两个术语的总和:
- 由于 a 而产生的即时奖励,以及
- 从下一个状态中获得的最佳 Q 值乘以一个折扣因子(“最佳”是指在下一个状态选择最优动作的意义上)
贝尔曼方程是使 Q 学习成为可能的因素,因此很重要理解。你作为程序员会立即注意到贝尔曼方程(方程 11.3)是递归的:方程右侧的所有 Q 值都可以使用方程本身进一步展开。我们在图 11.10 中解释的示例在两步之后结束,而真实的 MDP 问题通常涉及更多步骤和状态,甚至可能包含状态-动作-转换图中的循环。但贝尔曼方程的美丽和力量在于,它允许我们将 Q 学习问题转化为一个监督学习问题,即使对于大状态空间也是如此。我们将在下一节解释为什么会这样。
11.3.3。深度 Q 网络
手工制作函数Q(s, a)可能很困难,因此我们将让函数成为一个深度神经网络(在本节中之前提到的 DQN),并训练其参数。这个 DQN 接收一个表示环境完整状态的输入张量——也就是蛇板配置,这个张量作为观察结果提供给智能体。正如图 11.11 所示,该张量的形状为[9, 9, 2](不包括批次维度)。前两个维度对应于游戏板的高度和宽度。因此,张量可以被视为游戏板上所有方块的位图表示。最后一个维度(2)是代表蛇和水果的两个通道。特别地,蛇被编码在第一个通道中,头部标记为 2,身体标记为 1。水果被编码在第二个通道中,值为 1。在两个通道中,空方块用 0 表示。请注意,这些像素值和通道数目是或多或少任意的。其他值排列(例如,蛇头为 100,蛇身为 50,或者将蛇头和蛇身分成两个通道)也可能有效,只要它们保持三种实体(蛇头、蛇身和水果)是不同的。
图 11.11。蛇游戏的板状态如何表示为形状为[9, 9, 2]的三维张量

请注意,这种游戏状态的张量表示比我们在上一节中描述的由字段s和f组成的 JSON 表示要不太空间有效,因为它总是包含板上的所有方块,无论蛇有多长。这种低效的表示仅在我们使用反向传播来更新 DQN 的权重时使用。此外,在任何给定时间,由于我们即将访问的基于批次的训练范式,这种方式下只存在一小部分(batchSize)游戏状态。
将有效表示的棋盘状态转换为图 11.11 中所示张量的代码可以在 snake-dqn/snake_game.js 的getStateTensor()函数中找到。这个函数在 DQN 的训练过程中会被频繁使用,但我们这里忽略其细节,因为它只是根据蛇和水果的位置机械地为张量缓冲区的元素赋值。
你可能已经注意到这种[height, width, channel]的输入格式恰好是卷积神经网络设计来处理的。我们使用的 DQN 是熟悉的卷积神经网络架构。定义 DQN 拓扑的代码可以在列表 11.5 中找到(从 snake-dqn/dqn.js 中摘录,为了清晰起见删除了一些错误检查代码)。正如代码和图 11.12 中的图示所示,网络由一组 conv2d 层后跟一个 MLP 组成。额外的层包括 batchNormalization 和 dropout 被插入以增加 DQN 的泛化能力。DQN 的输出形状为[3](排除批次维度)。输出的三个元素是对应动作(向左转,直行和向右转)的预测 Q 值。因此,我们对Q(s, a)的模型是一个神经网络,它以状态作为输入,并输出给定该状态的所有可能动作的 Q 值。
图 11.12 作为蛇问题中Q(s, a)函数的近似所使用的 DQN 的图示示意图。在“Online DQN”框中,“BN”代表 BatchNormalization。

列表 11.5 创建蛇问题的 DQN
export function createDeepQNetwork(h, w, numActions) {
const model = tf.sequential();
model.add(tf.layers.conv2d({ ***1***
filters: 128,
kernelSize: 3,
strides: 1,
activation: 'relu',
inputShape: [h, w, 2] ***2***
}));
model.add(tf.layers.batchNormalization()); ***3***
model.add(tf.layers.conv2d({
filters: 256,
kernelSize: 3,
strides: 1,
activation: 'relu'
}));
model.add(tf.layers.batchNormalization());
model.add(tf.layers.conv2d({
filters: 256,
kernelSize: 3,
strides: 1,
activation: 'relu'
}));
model.add(tf.layers.flatten()); ***4***
model.add(tf.layers.dense({units: 100, activation: 'relu'}));
model.add(tf.layers.dropout({rate: 0.25})); ***5***
model.add(tf.layers.dense({units: numActions}));
return model;
}
-
1 DQN 具有典型的卷积神经网络架构:它始于一组 conv2d 层。
-
2 输入形状与代理观察的张量表示相匹配,如图 11.11 所示。
-
3 batchNormalization 层被添加以防止过拟合并提高泛化能力
-
4 DQN 的 MLP 部分以一个 flatten 层开始。
-
5 与 batchNormalization 类似,dropout 层被添加以防止过拟合。
让我们停下来思考一下为什么在这个问题中使用神经网络作为函数Q(s, a)是有意义的。蛇游戏具有离散状态空间,不像连续状态空间的车杆问题,后者由四个浮点数组成。因此,Q(s, a)函数原则上可以实现为查找表,即将每个可能的棋盘配置和动作组合映射为Q的值。那么为什么我们更喜欢 DQN 而不是这样的查找表呢?原因在于,即使是相对较小的棋盘尺寸(9×9),可能的棋盘配置也太多了,导致了查找表方法的两个主要缺点。首先,系统 RAM 无法容纳如此庞大的查找表。其次,即使我们设法构建了具有足够 RAM 的系统,代理在 RL 期间访问所有状态也需要耗费非常长的时间。DQN 通过其适度大小(约 100 万参数)解决了第一个(内存空间)问题。它通过神经网络的泛化能力解决了第二个(状态访问时间)问题。正如我们在前面章节中已经看到的大量证据所示,神经网络不需要看到所有可能的输入;它通过泛化学习来插值训练示例。因此,通过使用 DQN,我们一举解决了两个问题。
⁸
一个粗略的估算表明,即使我们将蛇的长度限制为 20,可能的棋盘配置数量也至少达到 10¹⁵数量级。例如,考虑蛇长度为 20 的特定情况。首先,为蛇头选择一个位置,共有 81 种可能性(9 * 9 = 81)。然后第一段身体有四个可能的位置,接着第二段有三个可能的位置,依此类推。当然,在一些身体姿势的配置中,可能的位置会少于三个,但这不应显著改变数量级。因此,我们可以估算出长度为 20 的蛇可能的身体配置数量约为 81 * 4 * 3¹⁸ ≈ 10¹²。考虑到每种身体配置有 61 种可能的水果位置,关节蛇-水果配置的估算增加到了 10¹⁴。类似的估算可以应用于更短的蛇长度,从 2 到 19。将从长度 2 到 20 的估算数字求和得到了 10¹⁵数量级。与我们的蛇棋盘上的方块数量相比,视频游戏如 Atari 2600 游戏涉及更多的像素,因此更不适合查找表方法。这就是为什么 DQNs 是解决这类视频游戏使用 RL 的适当技术之一,正如 DeepMind 的 Volodymyr Mnih 及其同事在 2015 年的里程碑式论文中所示的那样。
11.3.4. 训练深度 Q 网络
现在,我们有了一个 DQN,可以在蛇游戏的每一步估计出三个可能行动的 Q 值。为了实现尽可能大的累积奖励,我们只需要在每一步运行 DQN,并选择具有最高 Q 值的动作即可。我们完成了吗?并没有,因为 DQN 还没有经过训练!没有适当的训练,DQN 只会包含随机初始化的权重,它给出的动作不会比随机猜测更好。现在,蛇的强化学习问题已经被减少为如何训练 DQN 的问题,这是我们在本节中要讨论的主题。这个过程有些复杂。但别担心:我们将使用大量的图表以及代码摘录,逐步详细说明训练算法。
深度 Q 网络训练的直觉
我们将通过迫使 DQN 与贝尔曼方程相匹配来训练我们的 DQN。如果一切顺利,这意味着我们的 DQN 将同时反映即时奖励和最优折现未来奖励。
我们该如何做到这一点呢?我们需要的是许多输入-输出对的样本,其中输入是实际采取的状态和动作,而输出是 Q 的“正确”(目标)值。计算输入样本需要当前状态 s[i] 和我们在该状态下采取的动作 a[j],这两者都可以直接从游戏历史中获取。计算目标 Q 值需要即时奖励 r[i] 和下一个状态 s[i][+1],这两者也可以从游戏历史中获取。我们可以使用 r[i] 和 s[i][+1],通过应用贝尔曼方程来计算目标 Q 值,其细节将很快涉及到。然后,我们将计算由 DQN 预测的 Q 值与贝尔曼方程中的目标 Q 值之间的差异,并将其称为我们的损失。我们将使用标准的反向传播和梯度下降来减少损失(以最小二乘的方式)。使这成为可能和高效的机制有些复杂,但基本的直觉却相当简单。我们想要一个 Q 函数的估计值,以便能做出良好的决策。我们知道我们对 Q 的估计必须与环境奖励和贝尔曼方程相匹配,因此我们将使用梯度下降来实现。简单!
回放内存:用于 DQN 训练的滚动数据集
我们的 DQN 是一个熟悉的卷积网络,在 TensorFlow.js 中作为 tf.LayersModel 的一个实例实现。关于如何训练它,首先想到的是调用其 fit() 或 fitDataset() 方法。然而,我们在这里不能使用常规方法,因为我们没有一个包含观察到的状态和相应 Q 值的标记数据集。考虑这样一个问题:在 DQN 训练之前,没有办法知道 Q 值。如果我们有一个给出真实 Q 值的方法,我们就会在马尔科夫决策过程中使用它并完成。因此,如果我们局限于传统的监督学习方法,我们将面临一个先有鸡还是先有蛋的问题:没有训练好的 DQN,我们无法估计 Q 值;没有良好的 Q 值估计,我们无法训练 DQN。我们即将介绍的强化学习算法将帮助我们解决这个先有鸡还是先有蛋的问题。
具体来说,我们的方法是让代理者随机玩游戏(至少最初是如此),并记住游戏的每一步发生了什么。随机游戏部分很容易通过随机数生成器实现。记忆部分则通过一种称为重放内存的数据结构实现。图 11.13 展示了重放内存的工作原理。它为游戏的每一步存储五个项目:
-
s[i],第 i 步的当前状态观察(棋盘配置)。
-
a[i],当前步骤实际执行的动作(可以是 DQN 选择的,如图 11.12,也可以是随机选择)。
-
r[i],在此步骤接收到的即时奖励。
-
d[i],一个布尔标志,指示游戏在当前步骤后立即结束。由此可见,重放内存不仅仅是为了一个游戏回合。相反,它将来自多个游戏回合的结果连接在一起。一旦前一场游戏结束,训练算法就会简单地开始新的游戏,并将新记录追加到重放内存中。
-
s[i][+1],如果 d[i] 为假,则是下一步的观察。(如果 d[i] 为真,则存储 null 作为占位符。)
图 11.13. 在 DQN 训练过程中使用的重放内存。每一步都将五条数据推到重放内存的末尾。在 DQN 的训练过程中对这些数据进行抽样。

这些数据片段将用于 DQN 的基于反向传播的训练。回放记忆可以被视为 DQN 训练的“数据集”。然而,它不同于监督学习中的数据集,因为它会随着训练的进行而不断更新。回放记忆有一个固定长度M(在示例代码中默认为 10,000)。当一个记录(s[i], a[i], r[i], d[i], s[i][+1])被推到其末尾时,一个旧的记录会从其开始弹出,这保持了一个固定的回放记忆长度。这确保了回放记忆跟踪了训练中最近M步的发生情况,除了避免内存不足的问题。始终使用最新的游戏记录训练 DQN 是有益的。为什么?考虑以下情况:一旦 DQN 训练了一段时间并开始“熟悉”游戏,我们将不希望使用旧的游戏记录来教导它,比如训练开始时的记录,因为这些记录可能包含不再相关或有利于进一步网络训练的幼稚移动。
实现回放记忆的代码非常简单,可以在文件 snake-dqn/replay_memory.js 中找到。我们不会描述代码的详细信息,除了它的两个公共方法,append()和sample():
-
append()允许调用者将新记录推送到回放记忆的末尾。 -
sample(batchSize)从回放记忆中随机选择batchSize个记录。这些记录完全均匀地抽样,并且通常包括来自多个不同情节的记录。sample()方法将用于在计算损失函数和随后的反向传播期间提取训练批次,我们很快就会看到。
epsilon-greedy 算法:平衡探索和利用
一个不断尝试随机事物的智能体将凭借纯运气偶然发现一些好的动作(在贪吃蛇游戏中吃几个水果)。这对于启动智能体的早期学习过程是有用的。事实上,这是唯一的方法,因为智能体从未被告知游戏的规则。但是,如果智能体一直随机行为,它在学习过程中将无法走得很远,因为随机选择会导致意外死亡,而且一些高级状态只能通过一连串良好的动作达到。
这就是蛇游戏中探索与开发的两难境地的体现。我们在平衡摇摆杆的示例中看到了这个两难境地,其中的策略梯度方法通过逐渐增加训练过程中的多项式采样的确定性来解决这个问题。在蛇游戏中,我们没有这个便利,因为我们的动作选择不是基于 tf.multinomial(),而是选择具有最大 Q 值的动作。我们解决这个问题的方式是通过参数化动作选择过程的随机性,并逐渐减小随机性参数。特别地,我们使用所谓的epsilon-greedy 策略。该策略可以用伪代码表示为`
x = Sample a random number uniformly between 0 and 1.
if x < epsilon:
Choose an action randomly
else:
qValues = DQN.predict(observation)
Choose the action that corresponds to the maximum element of qValues
这个逻辑在训练的每一步都会应用。epsilon 的值越大(接近 1),选择动作的随机性越高。相反,epsilon 的值越小(接近 0),基于 DQN 预测的 Q 值选择动作的概率越高。随机选择动作可以看作是对环境的探索("epsilon" 代表 "exploration"),而选择最大 Q 值的动作被称为贪心。现在你明白了 "epsilon-greedy" 这个名字的来历。
如 代码清单 11.6 所示,实现蛇 DQN 示例中 epsilon-greedy 算法的实际 TensorFlow.js 代码与之前的伪代码具有密切的一对一对应关系。该代码摘自 snake-dqn/agent.js。
代码清单 11.6。实现 epsilon-greedy 算法的部分蛇 DQN 代码
let action;
const state = this.game.getState();
if (Math.random() < this.epsilon) {
action = getRandomAction(); ***1***
} else {
tf.tidy(() => { ***2***
const stateTensor = ***2***
getStateTensor(state, ***2***
this.game.height, ***2***
this.game.width); ***2***
action = ALL_ACTIONS[
this.onlineNetwork.predict( ***3***
stateTensor).argMax(-1).dataSync()[0]]; ***3***
});
}
-
1 探索:随机选择动作
-
2 将游戏状态表示为张量
-
3 贪心策略:从 DQN 获取预测的 Q 值,并找到对应于最高 Q 值的动作的索引
epsilon-greedy 策略在早期需要探索和后期需要稳定行为之间保持平衡。它通过逐渐减小 epsilon 的值,从一个相对较大的值逐渐减小到接近(但不完全等于)零。在我们的蛇 DQN 示例中,epsilon 在训练的前 1 × 105 步中以线性方式逐渐减小从 0.5 到 0.01。请注意,我们没有将 epsilon 减小到零,因为在智能体的训练的高级阶段,我们仍然需要适度的探索程度来帮助智能体发现新的智能举动。在基于 epsilon-greedy 策略的 RL 问题中,epsilon 的初始值和最终值都是可调节的超参数,epsilon 的降低时间也是如此。
在 epsilon-greedy 策略设定下的深度 Q 学习算法背景下,接下来让我们详细了解 DQN 的训练细节。
提取预测的 Q 值
尽管我们正在使用一种新方法来解决 RL 问题,但我们仍然希望将我们的算法塑造成监督学习,因为这样可以让我们使用熟悉的反向传播方法来更新 DQN 的权重。这样的制定需要三个要素:
-
预测的 Q 值。
-
“真实”的 Q 值。请注意,在这里,“真实”一词带有引号,因为实际上并没有办法获得 Q 值的基本真实值。这些值只是我们在训练算法的给定阶段能够得到的Q(s, a)的最佳估计值。因此,我们将其称为目标 Q 值。
-
一个损失函数,它以预测和目标 Q 值作为输入,并输出一个量化两者之间不匹配的数字。
在这个小节中,我们将看看如何从回放记忆中获取预测的 Q 值。接下来的两个小节将讨论如何获取目标 Q 值和损失函数。一旦我们有了这三个要素,我们的蛇 RL 问题基本上就变成了一个简单的反向传播问题。
图 11.14 说明了如何从回放记忆中提取预测的 Q 值的 DQN 训练步骤。应该将这个图表与实现代码清单 11.7 一起查看,以便更好地理解。
图 11.14. 如何从回放记忆和在线 DQN 中获取预测的 Q 值。这是 DQN 训练算法中监督学习部分的两个部分中的第一个部分。这个工作流的结果,即 DQN 预测的 Q 值actionQs,是将与targetQs一起用于计算 MSE 损失的两个参数之一。查看图 11.15 以了解计算targetQs的工作流程。

特别地,我们从回放记忆中随机抽取batchSize(默认为N = 128)条记录。正如之前所描述的,每条记录都有五个项目。为了获得预测的 Q 值,我们只需要前两个。第一个项目,包括N个状态观察,一起转换成一个张量。这个批处理的观察张量由在线 DQN 处理,它给出了预测的 Q 值(在图表和代码中都是qs)。然而,qs包含的 Q 值不仅包括实际选择的动作,还包括未选择的动作。对于我们的训练,我们希望忽略未选择动作的 Q 值,因为没有办法知道它们的目标 Q 值。这就是第二个回放记忆项发挥作用的地方。
第二项包含实际选择的动作。它们被格式化成张量表示(图和代码中的 actionTensor)。然后使用 actionTensor 选择我们想要的 qs 元素。这一步骤在图中标记为选择实际动作的框中完成,使用了三个 TensorFlow.js 函数:tf.oneHot()、mul() 和 sum()(参见清单 11.7 中的最后一行)。这比切片张量稍微复杂一些,因为在不同的游戏步骤可以选择不同的动作。清单 11.7 中的代码摘自 snake-dqn/agent.js 中的 SnakeGameAgent.trainOnReplayBatch() 方法,为了清晰起见进行了些许省略。
清单 11.7. 从回放内存中提取一批预测的 Q 值
const batch = this.replayMemory.sample(batchSize); ***1***
const stateTensor = getStateTensor(
batch.map(example => example[0]), ***2***
this.game.height, this.game.width);
const actionTensor = tf.tensor1d(
batch.map(example => example[1]), ***3***
'int32');
const qs = this.onlineNetwork.apply( ***4***
stateTensor, {training: true}) ***4***
.mul(tf.oneHot(actionTensor, NUM_ACTIONS)).sum(-1); ***5***
-
1 从回放内存中随机选择一批大小为 batchSize 的游戏记录
-
2 每个游戏记录的第一个元素是代理的状态观察(参见图 11.13)。它由 getStateTensor() 函数(参见图 11.11)将其从 JSON 对象转换为张量。
-
3 游戏记录的第二个元素是实际选择的动作。它也被表示为张量。
-
4 apply() 方法与 predict() 方法类似,但显式指定了“training: true”标志以启用反向传播。
-
5 我们使用 tf.oneHot()、mul() 和 sum() 来隔离仅针对实际选择的动作的 Q 值,并丢弃未选择的动作的 Q 值。
这些操作给了我们一个名为 actionQs 的张量,其形状为 [N],其中 N 是批次大小。这就是我们寻找的预测的 Q 值,即我们所处的状态 s 和我们实际采取的动作 a 的预测 Q(s, a)。接下来,我们将探讨如何获取目标 Q 值。
提取目标 Q 值:使用贝尔曼方程
获取目标 Q 值比获取预测值稍微复杂一些。这是理论上的贝尔曼方程将被实际应用的地方。回想一下,贝尔曼方程用两个因素描述了状态-动作对的 Q 值:1) 即时奖励和 2) 下一步状态可用的最大 Q 值(通过一个因子折现)。前者很容易获得。它直接作为回放内存的第三项可得到。图 11.15 中的 rewardTensor 用示意图的方式说明了这一点。
图 11.15. 如何从重播记忆和目标 DQN 获取目标 Q 值(targetQs)。此图与图 11.14 共享重播记忆和批量采样部分。应该与列表 11.8 中的代码一起检查。这是进入 DQN 训练算法的监督学习部分的两个部分之一。targetQs在计算中起着类似于前几章中监督学习问题中的真实标签的作用(例如,MNIST 示例中的已知真实标签或 Jena-weather 示例中的已知真实未来温度值)。贝尔曼方程在计算targetQs中起着关键作用。与目标 DQN 一起,该方程允许我们通过形成当前步骤的 Q 值和随后步骤的 Q 值之间的连接来计算targetQs的值。

要计算后者(最大的下一步 Q 值),我们需要来自下一步的状态观察。幸运的是,下一步观察被存储在重播记忆中的第五项。我们取随机抽样批次的下一步观察,将其转换为张量,并通过名为目标 DQN的 DQN 的副本运行它(见图 11.15)。这给了我们下一步状态的估计 Q 值。一旦我们有了这些值,我们沿着最后(动作)维度进行max()调用,这导致从下一步状态中获得的最大 Q 值(在列表 11.8 中表示为nextMaxQTensor)。遵循贝尔曼方程,这个最大值乘以折扣因子(图 11.15 中的γ和列表 11.8 中的gamma)并与即时奖励相结合,产生目标 Q 值(在图和代码中均为targetQs)。
注意,只有当当前步骤不是游戏剧集的最后一步时(即,它不会导致蛇死亡),下一步 Q 值才存在。如果是,那么贝尔曼方程的右侧将仅包括即时奖励项,如图 11.15 所示。这对应于列表 11.8 中的doneMask张量。此列表中的代码摘自 snake-dqn/agent.js 中的SnakeGameAgent.trainOnReplayBatch()方法,为了清晰起见做了一些小的省略。
图 11.8. 从重播记忆中提取一批目标(“真实”)Q 值
const rewardTensor = tf.tensor1d(
batch.map(example => example[2])); ***1***
const nextStateTensor = getStateTensor(
batch.map(example => example[4]), ***2***
this.game.height, this.game.width);
const nextMaxQTensor =
this.targetNetwork.predict(nextStateTensor) ***3***
.max(-1); ***4***
const doneMask = tf.scalar(1).sub(
tf.tensor1d(batch.map(example => example[3]))
.asType('float32')); ***5***
const targetQs = ***6***
rewardTensor.add(nextMaxQTensor.mul( ***6***
doneMask).mul(gamma)); ***6***
-
1 重播记录的第三项包含即时奖励值。
-
2 记录的第五项包含下一状态观察。它被转换为张量表示。
-
3 目标 DQN 用于下一个状态张量,它产生下一步所有动作的 Q 值。
-
4 使用
max()函数提取下一步可能的最高奖励。这在贝尔曼方程的右侧。 -
5 doneMask 在终止游戏的步骤上具有值 0,并在其他步骤上具有值 1。
-
6 使用贝尔曼方程来计算目标 Q 值。
正如你可能已经注意到的,在深度 Q 学习算法中的一个重要技巧是使用两个 DQN 实例。它们分别被称为 在线 DQN 和 目标 DQN。在线 DQN 负责计算预测的 Q 值(参见上一小节的 图 11.14)。它也是我们在 epsilon-greedy 算法决定采用贪婪(无探索)方法时选择蛇行动的 DQN。这就是为什么它被称为“在线”网络。相比之下,目标 DQN 仅用于计算目标 Q 值,就像我们刚刚看到的那样。这就是为什么它被称为“目标”DQN。为什么我们使用两个 DQN 而不是一个?为了打破不良反馈循环,这可能会导致训练过程中的不稳定性。
在线 DQN 和目标 DQN 是由相同的 createDeepQNetwork() 函数创建的(清单 11.5)。它们是两个具有相同拓扑结构的深度卷积网络。因此,它们具有完全相同的层和权重集。权重值周期性地从在线 DQN 复制到目标 DQN(在默认设置的 snake-dqn 中每 1,000 步)。这使目标 DQN 与在线 DQN 保持同步。没有这种同步,目标 DQN 将过时,并通过产生贝尔曼方程中最佳下一步 Q 值的劣质估计来阻碍训练过程。
Q 值预测和反向传播的损失函数
有了预测和目标 Q 值,我们使用熟悉的 meanSquaredError 损失函数来计算两者之间的差异(图 11.16)。在这一点上,我们已经成功将我们的 DQN 训练过程转化为一个回归问题,类似于以前的例子,如波士顿房屋和耶拿天气。来自 meanSquaredError 损失的误差信号驱动反向传播;由此产生的权重更新用于更新在线 DQN。
图 11.16. 将 actionQs 和 targetQs 结合在一起,以计算在线 DQN 的 meanSquaredError 预测误差,从而使用反向传播来更新其权重。这张图的大部分部分已经在 图 11.12 和 11.13 中展示过。新添加的部分是 meanSquaredError 损失函数和基于它的反向传播步骤,位于图的右下部分。

图 11.16 中的示意图包括我们已经在 图 11.12 和 11.13 中展示过的部分。它将这些部分放在一起,并添加了新的框和箭头,用于 meanSquaredError 损失和基于它的反向传播(见图的右下部分)。这完成了我们用来训练蛇游戏代理的深度 Q 学习算法的完整图景。
代码清单 11.9 中的代码与图 11.16 中的图表紧密对应。这是在蛇 DQN / agent.js 中的 SnakeGameAgent 类的 trainOnReplayBatch()方法,它在我们的强化学习算法中发挥着核心作用。该方法定义了一个损失函数,该函数计算预测 Q 值和目标 Q 值之间的 meanSquaredError。然后,它使用tf.variableGrads()函数(附录 B,第 B.4 节包含了有关 TensorFlow.js 的梯度计算函数(如tf.variableGrads())的详细讨论)计算在线 DQN 权重相对于 meanSquaredError 的梯度。通过优化器使用计算出的梯度来更新 DQN 的权重。这将促使在线 DQN 朝着更准确的 Q 值估计方向移动。重复数百万次后,这将导致 DQN 能够引导蛇达到不错的性能。对于下面的列表,已经展示了负责计算目标 Q 值(targetQs)的代码部分(参见代码清单 11.8)。
代码清单 11.9。训练 DQN 的核心函数
trainOnReplayBatch(batchSize, gamma, optimizer) {
const batch = this.replayMemory.sample(batchSize); ***1***
const lossFunction = () => tf.tidy(() => { ***2***
const stateTensor = getStateTensor(
batch.map(example => example[0]),
this.game.height,
this.game.width);
const actionTensor = tf.tensor1d(
batch.map(example => example[1]), 'int32');
const qs = this.onlineNetwork ***3***
.apply(stateTensor, {training: true})
.mul(tf.oneHot(actionTensor, NUM_ACTIONS)).sum(-1);
const rewardTensor = tf.tensor1d(batch.map(example => example[2]));
const nextStateTensor = getStateTensor(
batch.map(example => example[4]),
this.game.height, this.game.width);
const nextMaxQTensor =
this.targetNetwork.predict(nextStateTensor).max(-1);
const doneMask = tf.scalar(1).sub(
tf.tensor1d(batch.map(example => example[3])).asType('float32'));
const targetQs = ***4***
rewardTensor.add(nextMaxQTensor.mul(doneMask).mul(gamma));
return tf.losses.meanSquaredError(targetQs, qs); ***5***
});
const grads = tf.variableGrads( ***6***
lossFunction, this.onlineNetwork.getWeights());
optimizer.applyGradients(grads.grads); ***7***
tf.dispose(grads);
}
-
1 从重播缓冲区中获取一组随机示例
-
2 lossFunction 返回标量,将用于反向传播。
-
3 预测的 Q 值
-
4 通过应用贝尔曼方程计算的目标 Q 值
-
5 使用均方误差(MSE)作为预测和目标 Q 值之间差距的度量
-
6 计算损失函数相对于在线 DQN 权重的梯度
-
7 通过优化器使用梯度更新权重
至此,深度 Q 学习算法的内部细节就介绍完了。在 Node.js 环境中,可以使用以下命令开始基于这个算法的训练:
yarn train --logDir /tmp/snake_logs
如果您有支持 CUDA 的 GPU,请将--gpu标志添加到命令中,以加快训练速度。此--logDir标志让该命令在训练过程中将以下指标记录到 TensorBoard 日志目录中:1)最近 100 个游戏周期内累计奖励的运行平均值(cumulativeReward100);2)最近 100 个周期内食用水果数量的运行平均值(eaten100);3)探索参数的值(epsilon);4)每秒钟处理的步数(framesPerSecond)的训练速度。这些日志可以通过使用以下命令启动 TensorBoard 并导航到 TensorBoard 前端的 HTTP URL(默认为:http://localhost:6006)进行查看:
pip install tensorboard tensorboard --logdir /tmp/snake_logs
图 11.17 展示了一组训练过程中典型的对数曲线。在强化学习中,cumulativeReward100 和 eaten100 曲线都经常展现出波动。经过几个小时的训练,模型可以达到 cumulativeReward100 的最佳成绩为 70-80,eaten100 的最佳成绩约为 12。
图 11.17:tfjs-node 中蛇的强化学习训练过程的示例日志。面板显示:1)cumulativeReward100,最近 100 场游戏的累积奖励的移动平均;2)eaten100,最近 100 场游戏中水果被吃的移动平均;3)epsilon,epsilon 的值,您可以从中看到 epsilon-greedy 策略的时间进程;以及 4)framesPerSecond,训练速度的度量。

训练脚本还会在每次达到新的最佳 cumulativeReward100 值时,将模型保存到相对路径./models/dqn。在调用 yarn watch 命令时,从 web 前端加载保存的模型。前端会在游戏的每一步显示 DQN 预测的 Q 值(参见图 11.18)。在训练期间使用的 epsilon-greedy 策略在训练后的游戏中被“始终贪婪”的策略所取代。蛇的动作总是选择对应于最高 Q 值的动作(例如,在图 11.18 中,直行的 Q 值为 33.9)。这可以直观地了解训练后的 DQN 如何玩游戏。
图 11.18:经过训练的 DQN 估计的 Q 值以数字形式显示,并以不同的绿色叠加在游戏的前端。

从蛇的行为中有几个有趣的观察。首先,在前端演示中,蛇实际吃到的水果数量(约为 18)平均要大于训练日志中的 eaten100 曲线(约为 12)。这是因为 epsilon-greedy 策略的移除,这消除了游戏过程中的随机动作。请记住,epsilon 在 DQN 训练的后期维持为一个小但非零的值(参见图 11.17 的第三个面板)。由此引起的随机动作偶尔会导致蛇的提前死亡,这就是探索性行为的代价。其次,蛇在靠近水果之前会经过棋盘的边缘和角落,即使水果位于棋盘的中心附近。这种策略对于帮助蛇在长度适中(例如,10-18)时减少碰到自己的可能性是有效的。这并不是坏事,但也不是完美的,因为蛇尚未形成更聪明的策略。例如,蛇在长度超过 20 时经常陷入一个循环。这就是蛇的强化学习算法能够带给我们的。为了进一步改进蛇的智能体,我们需要调整 epsilon-greedy 算法,以鼓励蛇在长度较长时探索更好的移动方式。[9] 在当前的算法中,一旦蛇的长度需要在其自身周围熟练操纵时,探索的程度太低。
⁹
这就是我们对 DQN 技术的介绍结束了。我们的算法是基于 2015 年的论文“通过深度强化学习实现人类水平的控制”,[10],在该论文中,DeepMind 的研究人员首次证明,结合深度神经网络和强化学习的力量使得机器能够解决许多类似 Atari 2600 的视频游戏。我们展示的 snake-dqn 解决方案是 DeepMind 算法的简化版本。例如,我们的 DQN 仅查看当前步骤的观察,而 DeepMind 的算法将当前观察与前几个步骤的观察结合起来作为 DQN 的输入。但我们的示例捕捉到了这一划时代技术的本质——即使用深度卷积网络作为强大的函数逼近器来估计动作的状态相关值,并使用 MDP 和贝尔曼方程进行训练。强化学习研究人员的后续成就,如征服围棋和国际象棋等游戏,都基于类似的深度神经网络和传统非深度学习强化学习方法的结合。
¹⁰
Volodymyr Mnih 等人,《深度强化学习实现人类水平的控制》,自然, vol. 518, 2015, pp. 529–533,www.nature.com/articles/nature14236/。
进一步阅读材料
-
Richard S. Sutton 和 Andrew G. Barto,《强化学习导论》,A Bradford 书籍,2018。
-
David Silver 在伦敦大学学院的强化学习讲座笔记:
www0.cs.ucl.ac.uk/staff/d.silver/web/Teaching.html。 -
Alexander Zai 和 Brandon Brown,《深度强化学习实战》,Manning 出版社,即将出版,www.manning.com/books/deep-reinforcement-learning-in-action。
-
Maxim Laplan,《深度强化学习实战:应用现代强化学习方法,包括深度 Q 网络,值迭代,策略梯度,TRPO,AlphaGo Zero 等》,Packt 出版社,2018。
练习
-
在小车摆杆示例中,我们使用了一个策略网络,其中包含一个带有 128 个单元的隐藏密集层,因为这是默认设置。这个超参数如何影响基于策略梯度的训练?尝试将其更改为小值,如 4 或 8,并将结果的学习曲线(每游戏平均步数与迭代曲线)与默认隐藏层大小的曲线进行比较。这对模型容量和其估计最佳动作的有效性之间的关系告诉了你什么?
-
我们提到使用机器学习解决类似倒立摆的问题的一个优点是人力经济性。具体来说,如果环境意外改变,我们不需要弄清楚它是如何真正改变的并重新确定物理方程,而是可以让代理人自行重新学习问题。通过以下步骤向自己证明这一点。首先,确保倒立摆示例是从源代码而不是托管的网页启动的。使用常规方法训练一个有效的倒立摆策略网络。其次,编辑 cart-pole/cart_pole.js 中的
this.gravity的值,并将其更改为一个新值(例如,如果您要假装我们将倒立摆的配置移到一个比地球更高重力的外行星上,可以将其改为 12)。再次启动页面,加载您在第一步训练的策略网络,并对其进行测试。你能确认它因为重力的改变而表现明显更差吗?最后,再多训练几次策略网络。你能看到策略又逐渐适应新环境而在游戏中表现越来越好吗? -
(有关 MDP 和贝尔曼方程的练习)我们在 第 11.3.2 节 和 图 11.10 中提供的 MDP 示例在一定程度上是简单的,因为状态转移和相关奖励没有随机性。但是,许多现实世界的问题更适合描述为随机 MDP。在随机 MDP 中,代理人在采取行动后将进入的状态和获得的奖励遵循概率分布。例如,如 图 11.19 所示,如果代理人在状态S[1]采取行动A[1],它将以 0.5 的概率进入状态S[2],以 0.5 的概率进入状态S[3]。与这两个状态转换关联的奖励是不同的。在这种随机 MDP 中,代理人必须计算预期未来奖励,以考虑随机性。预期未来奖励是所有可能奖励的加权平均值,权重为概率。你能应用这种概率方法并在图中估计s[1]的a[1]和a[2]的 Q 值吗?根据答案,在状态s[1]时,a[1]和a[2]哪个是更好的行动?
图 11.19. 练习 3 第一部分的 MDP 图表
![11fig18.jpg]()
现在让我们看一个稍微复杂一点的随机 MDP,其中涉及多个步骤(参见 图 11.20)。在这种稍微复杂的情况下,您需要应用递归的贝尔曼方程,以考虑第一步之后的可能的最佳未来奖励,这些奖励本身也是随机的。请注意,有时在第一步之后,该情节结束,而有时它将持续进行另一步。你能决定在s[1]时哪个行动更好吗?对于这个问题,您可以使用奖励折扣因子 0.9。
图 11.20。练习 3 第二部分中 MDP 的图表
![]()
-
在贪吃蛇-dqn 示例中,我们使用ε-贪心策略来平衡探索和利用的需求。默认设置将ε从初始值 0.5 减小到最终值 0.01,并将其保持在那里。尝试将最终ε值更改为较大的值(例如 0.1)或较小的值(例如 0),并观察蛇代理学习效果的影响。您能解释ε扮演的角色造成的差异吗?
摘要
-
作为一种机器学习类型,强化学习是关于学习如何做出最优决策。在强化学习问题中,代理学习在环境中选择行动以最大化称为累积奖励的指标。
-
与监督学习不同,RL 中没有标记的训练数据集。相反,代理必须通过尝试随机动作来学习在不同情况下哪些动作是好的。
-
我们探讨了两种常用的强化学习算法类型:基于策略的方法(以倒立摆为例)和基于 Q 值的方法(以贪吃蛇为例)。
-
策略是一种算法,代理根据当前状态观察选择动作。策略可以封装在一个神经网络中,该网络将状态观察作为输入并产生动作选择作为输出。这样的神经网络称为策略网络。在倒立摆问题中,我们使用策略梯度和 REINFORCEMENT 方法来更新和训练策略网络。
-
与基于策略的方法不同,Q 学习使用一种称为Q 网络的模型来估算在给定观察状态下行动的值。在贪吃蛇-dqn 示例中,我们演示了深度卷积网络如何作为 Q 网络以及如何通过使用 MDP 假设、贝尔曼方程和一种称为回放 记忆的结构来训练它。
第四部分:总结和结束语。
本书的最后一部分包括两个章节。第十二章解决了 TensorFlow.js 用户在部署模型到生产环境时可能遇到的问题。本章讨论了帮助开发人员更有信心地确保模型正确性的最佳实践,使模型体积更小且运行更高效的技术,以及 TensorFlow.js 模型支持的部署环境的范围。第十三章是对整本书的总结,回顾了关键概念、工作流程和技术。
第十二章:测试、优化和部署模型
—有贡献者:Yannick Assogba,Ping Yu 和 Nick Kreeger
本章内容包括
-
机器学习代码测试和监控的重要性和实用指南
-
如何优化在 TensorFlow.js 中训练或转换的模型,以实现更快的加载和推理
-
如何将 TensorFlow.js 模型部署到各种平台和环境中,从浏览器扩展到移动应用,从桌面应用到单板计算机
正如我们在第一章中提到的,机器学习不同于传统软件工程,因为它自动发现规则和启发式方法。本书的前几章应该已经让你对这个机器学习的独特性有了扎实的理解。但是,机器学习模型及其周围的代码仍然是代码;他们作为整体软件系统的一部分运行。为了确保机器学习模型可靠高效地运行,从业者需要像管理非机器学习代码时那样采取相应的预防措施。
本章重点介绍如何在软件堆栈中使用 TensorFlow.js 进行机器学习的实践应用。第一部分探讨了机器学习代码和模型的测试和监控这一至关重要但经常被忽视的话题。第二部分介绍了帮助你优化训练模型、减小计算量和模型大小,从而加快下载和执行速度的工具和技巧。这对于客户端和服务器端模型部署来说是一个至关重要的考虑因素。在最后一部分,我们将为您介绍 TensorFlow.js 创作的模型可以部署到的各种环境。在此过程中,我们将讨论每个部署选项所涉及的独特优势、限制和策略。
本章结束时,你将熟悉关于在 TensorFlow.js 中深度学习模型的测试、优化和部署的最佳实践。
12.1. 测试 TensorFlow.js 模型
到目前为止,我们已经讨论了如何设计、构建和训练机器学习模型。现在,我们将深入探讨一些在部署训练好的模型时会出现的话题,首先是测试 - 包括机器学习代码和相关的非机器学习代码。当你试图在你的模型和其训练过程周围设置测试时,你面临的一些关键挑战包括模型的大小、训练所需的时间以及训练过程中发生的非确定性行为(例如权重初始化和某些神经网络操作(如 dropout)中的随机性)。当我们从一个单独的模型扩展到一个完整的应用程序时,你还会遇到各种类型的偏差或漂移,包括训练和推断代码路径之间的偏差,模型版本问题以及数据中的人口变化。你会发现,为了实现你对整个机器学习系统的可靠性和信心,测试需要配合强大的监控解决方案。
一个关键考虑因素是,“你的模型版本是如何受控制的?”在大多数情况下,模型被调整和训练直到达到满意的评估精度,然后模型就不需要进一步调整了。模型不会作为正常构建过程的一部分进行重建或重新训练。相反,模型拓扑结构和训练权重应该被检入你的版本控制系统中,更类似于二进制大对象(BLOB)而不是文本/代码工件。改变周围的代码不应该导致你的模型版本号更新。同样,重新训练模型并检入它不应该需要改变非模型源代码。
机器学习系统的哪些方面应该被测试?在我们看来,答案是“每个部分”。图 12.1 解释了这个答案。一个从原始输入数据到准备部署的训练好的模型的典型系统包含多个关键组件。其中一些看起来类似于非机器学习代码,并且适合于传统的单元测试覆盖,而其他一些则显示出更多的机器学习特性,因此需要专门定制的测试或监控处理。但这里的重要信息是永远不要忽视或低估测试的重要性,仅仅因为你正在处理一个机器学习系统。相反,我们会认为,单元测试对于机器学习代码来说更加重要,也许甚至比传统软件开发的测试更加重要,因为机器学习算法通常比非机器学习算法更加难以理解。它们在面对不良输入时可能会悄无声息地失败,导致很难察觉和调试的问题,而针对这些问题的防御措施是测试和监控。在接下来的小节中,我们将扩展图 12.1 的各个部分。
图 12.1. 生产就绪的机器学习系统的测试和监控覆盖范围。图的上半部分包括典型机器学习模型创建和训练管道的关键组件。下半部分显示可以应用于每个组件的测试实践。某些组件适合传统的单元测试实践:创建和训练代码以及执行模型输入数据和输出结果的预处理和后处理代码。其他组件需要更多的机器学习特定的测试和监控实践。这些包括用于数据质量的示例验证、监控经过训练的模型的字节大小和推断速度,以及对训练模型所做预测的细粒度验证和评估。

12.1.1. 传统单元测试
就像非机器学习项目一样,可靠且轻量级的单元测试应该构成测试套件的基础。然而,需要特殊考虑来设置围绕机器学习模型的单元测试。正如您在之前的章节中所见,诸如在评估数据集上的准确率之类的度量通常用于量化在成功超参数调整和训练后模型的最终质量。这些评估指标对于人工工程师的监控很重要,但不适合自动化测试。添加一个测试来断言某个评估指标优于某个阈值(例如,二分类任务的 AUC 大于 0.95,或回归任务的 MSE 小于 0.2)是很诱人的。然而,这些基于阈值的断言应该谨慎使用,如果不完全避免的话,因为它们往往很脆弱。模型的训练过程包含多个随机性来源,包括权重的初始化和训练示例的洗牌。这导致模型训练的结果在不同运行中略有不同。如果您的数据集发生变化(例如,由于定期添加新数据),这将形成额外的变异源。因此,选择阈值是一项困难的任务。太宽容的阈值在发生真正问题时无法捕捉到。太严格的阈值将导致一个不稳定的测试,即经常失败而没有真正的潜在问题。
TensorFlow.js 程序中的随机性通常可以通过在创建和运行模型之前调用 Math.seedrandom() 函数来禁用。例如,以下代码将以确定的种子来设置权重初始化器、数据混洗器和退出层的随机状态,以便随后的模型训练产生确定性结果:
Math.seedrandom(42); ***1***
- 1 42 只是一个任意选择的、固定的随机种子。
如果您需要编写对损失或度量值进行断言的测试,这是一个有用的技巧。
然而,即使确定性种子,仅仅测试 model.fit() 之类的调用还不足以对您的机器学习代码进行良好的覆盖。像其他难以进行单元测试的代码部分一样,您应该努力对易于单元测试的周围代码进行全面的单元测试,并探索模型部分的替代解决方案。您用于数据加载、数据预处理、模型输出的后处理以及其他实用方法的代码应该符合正常的测试实践。此外,还可以对模型本身进行一些非严格的测试,比如测试其输入和输出形状,以及“确保模型在训练一步时不会抛出异常”的风格的测试,可以提供最基本的模型测试环境,以在重构过程中保持信心。(正如您在上一章的示例代码中所注意到的,我们在 tfjs-examples 中使用了 Jasmine 测试框架进行测试,但您可以随意使用您和您的团队偏好的任何单元测试框架和运行器。)
作为实践示例,我们可以看一下我们在 第九章 中探索过的情感分析示例的测试。当您查看代码时,您应该会看到 data_test.js、embedding_test.js、sequence_utils_test.js 和 train_test.js 这四个文件。这三个文件覆盖了非模型代码,它们看起来就像普通的单元测试一样。它们的存在使我们对训练和推理过程中进入模型的数据的源格式有了更高的信心,并且我们对它的处理是有效的。
列表中的最后一个文件与机器学习模型有关,值得我们更加关注。下面的代码片段是其中的一部分。
列表 12.1. 模型 API 的单元测试——其输入输出形状和可训练性
describe('buildModel', () => {
it('flatten training and inference', async () => {
const maxLen = 5;
const vocabSize = 3;
const embeddingSize = 8;
const model = buildModel('flatten', maxLen, vocabSize, embeddingSize);
expect(model.inputs.length).toEqual(1); ***1***
expect(model.inputs[0].shape).toEqual([null, maxLen]); ***1***
expect(model.outputs.length).toEqual(1); ***1***
expect(model.outputs[0].shape).toEqual([null, 1]); ***1***
model.compile({
loss: 'binaryCrossentropy',
optimizer: 'rmsprop',
metrics: ['acc']
});
const xs = tf.ones([2, maxLen])
const ys = tf.ones([2, 1]);
const history = await model.fit(xs, ys, { ***2***
epochs: 2, ***2***
batchSize: 2 ***2***
}); ***2***
expect(history.history.loss.length).toEqual(2); ***2******3***
expect(history.history.acc.length).toEqual(2); ***2***
const predictOuts = model.predict(xs); ***4***
expect(predictOuts.shape).toEqual([2, 1]); ***4***
const values = predictOuts.arraySync(); ***4******5***
expect(values[0][0]).toBeGreaterThanOrEqual(0); ***4******5***
expect(values[0][0]).toBeLessThanOrEqual(1); ***4******5***
expect(values[1][0]).toBeGreaterThanOrEqual(0); ***4******5***
expect(values[1][0]).toBeLessThanOrEqual(1); ***4******5***
}); ***5***
});
-
1 确保模型的输入和输出具有预期的形状
-
2 对模型进行非常简短的训练;这个过程应该很快,但不一定准确。
-
3 检查训练是否报告了每次训练步骤的指标,作为训练是否发生的信号
-
4 对模型进行预测以验证 API 是否符合预期
-
5 确保预测值在可能答案的范围内;我们不想检查实际值,因为训练时间非常短可能会不稳定。
这个测试覆盖了很多方面,所以让我们稍微细分一下。我们首先使用一个辅助函数来构建一个模型。在这个测试中,我们并不关心模型的结构,并将其视为一个黑盒子。然后我们对输入和输出的形状进行断言:
expect(model.inputs.length).toEqual(1);
expect(model.inputs[0].shape).toEqual([null, maxLen]);
expect(model.outputs.length).toEqual(1);
expect(model.outputs[0].shape).toEqual([null, 1]);
这些测试可以检测到错误的批次维度(回归或分类)、输出形状等问题。之后,我们在很少的步骤中编译和训练模型。我们的目标仅是确保模型可以被训练,此时我们不担心准确性、稳定性或收敛性:
const history = await model.fit(xs, ys, {epochs: 2, batchSize: 2})
expect(history.history.loss.length).toEqual(2);
expect(history.history.acc.length).toEqual(2);
此代码片段还检查训练是否报告了所需的分析指标:如果我们进行了实际训练,我们能否检查训练的进度和生成模型的准确性?最后,我们尝试一个简单的:
const predictOuts = model.predict(xs);
expect(predictOuts.shape).toEqual([2, 1]);
const values = predictOuts.arraySync();
expect(values[0][0]).toBeGreaterThanOrEqual(0);
expect(values[0][0]).toBeLessThanOrEqual(1);
expect(values[1][0]).toBeGreaterThanOrEqual(0);
expect(values[1][0]).toBeLessThanOrEqual(1);
我们不检查任何特定的预测结果,因为这可能会因权重值的随机初始化或可能的模型架构未来修订而发生变化。我们所检查的是我们获得了预测并且预测在预期范围内,在这种情况下是 0 到 1。
这里最重要的教训是注意到无论我们如何更改模型架构的内部,只要我们不改变其输入或输出 API,这个测试应该始终通过。如果测试失败了,那么我们的模型就有问题。这些测试仍然是轻量级且快速的,提供了强大的 API 正确性,并适合包含在您使用的常见测试挂钩中。
12.1.2. 使用黄金值进行测试
在前一节中,我们讨论了在不断言阈值指标值或不要求稳定或收敛训练的情况下可进行的单元测试。现在让我们探讨人们通常希望在完全训练后的模型上运行的测试类型,从检查特定数据点的预测开始。也许有一些“明显”的示例需要测试。例如,对于一个物体检测器,具有可识别猫咪的输入图像应被标记为识别了猫咪;对于一个情感分析器,明显是负面的客户评论的文本片段应被分类为负面。这些针对给定模型输入的正确答案是我们所谓的“黄金值”。如果盲目地遵循传统单元测试的思路,很容易陷入使用“黄金值”测试训练后的机器学习模型的误区。毕竟,我们希望一个训练良好的物体检测器总是能够在图像中的猫咪上打上“猫”的标记,对吗?并不完全是这样。基于“黄金值”的测试在机器学习设置中可能会存在问题,因为我们篡改了训练、验证和评估数据分割。
假设你的验证集和测试集有代表性的样本,并且你设置了一个合适的目标指标(准确率、召回率等),为什么要求任何一个例子比另一个例子更准确?机器学习模型的训练关注的是整个验证集和测试集的准确率。对于单个样本的预测可能会随着超参数和初始权重值的选择而变化。如果有些例子必须被正确分类并且很容易识别,为什么不在要求机器学习模型对它们进行分类之前检测它们,而是使用非机器学习代码来处理它们呢?在自然语言处理系统中偶尔会使用这样的例子,其中查询输入的子集(如经常遇到且易于识别的输入)会自动路由到非机器学习模块进行处理,而其余的查询会由机器学习模型处理。你会节省计算时间,并且该部分代码更容易通过传统的单元测试进行测试。虽然在机器学习预测器之前(或之后)添加业务逻辑层似乎多此一举,但它为你提供了控制预测覆盖的钩子。这也是你可以添加监控或日志记录的地方,当你的工具变得更广泛使用时,你可能会需要。有了这个前提,让我们分别探讨三种常见的对金标值的需求。
这种类型的金标值测试的一个常见动机是为了进行完整的端到端测试——给定一个未经处理的用户输入,系统会输出什么?机器学习系统经过训练,通过正常的端用户代码流程请求预测,然后将答案返回给用户。这类似于我们在列表 12.1 中的单元测试,但机器学习系统是在应用程序的其余部分的上下文中。我们可以编写一个类似于列表 12.1 的测试,它不关心预测的实际值,实际上,这将是一个更稳定的测试。但是,当开发人员重新访问测试时,将其与一个有意义且容易理解的示例/预测对结合起来是非常诱人的。
这就是问题出现的时候——我们需要一个其预测已知且保证正确的示例,否则端到端测试将失败。因此,我们添加了一个较小规模的测试,通过端到端测试涵盖的管道的子集来测试该预测。现在,如果端到端测试失败,而较小的测试通过,则我们已将错误隔离到核心机器学习模型与管道的其他部分之间的交互(例如数据摄取或后处理)。如果两者同时失败,我们知道我们的示例/预测不变式被打破了。在这种情况下,它更像是一种诊断工具,但配对失败的可能结果是选择一个新的示例进行编码,而不是重新训练模型。
下一个最常见的来源是某种业务需求。某些可识别的示例集必须比其他示例更准确。如前所述,这是添加一个用于处理这些预测的模型前后业务逻辑层的完美设置。但是,您可以尝试示例加权,其中一些示例在计算整体质量指标时占比更多。这不会保证正确性,但它会使模型倾向于获得这些正确。如果由于无法轻松预先识别触发特殊情况的输入属性而导致业务逻辑层困难,则可能需要探索第二个模型——一个纯粹用于确定是否需要覆盖的模型。在这种情况下,您正在使用模型的集成,并且您的业务逻辑是将两个层的预测组合起来执行正确的操作。
这里的最后一种情况是当你有一个带有用户提供示例的错误报告,该示例产生了错误的结果。如果出于业务原因错误,我们回到了刚才讨论的情况。如果出错只是因为它落入模型性能曲线的失败百分比中,那我们应该做的事情就不多了。这在经过训练的算法的可接受性能范围内;所有模型都有可能出错。您可以将示例/正确预测对添加到适当的训练/测试/评估集中,以便希望在未来生成更好的模型,但不适合使用黄金值进行单元测试。
一个例外是如果您保持模型恒定——您已将模型权重和架构检入版本控制,并且在测试中不重新生成它们。那么使用黄金值来测试将使用模型作为其核心的推理系统的输出可能是适当的,因为模型和示例都不会发生变化。这样的推理系统包含除模型之外的其他部分,例如预处理输入数据并将其馈送到模型的部分以及获取模型输出并将其转换为更适合下游系统使用的形式的部分。这样的单元测试确保了这种预处理和后处理逻辑的正确性。
另一个合理使用黄金值的场景是在单元测试之外:随着模型的演化监控模型的质量(但不作为单元测试)。我们将在下一节讨论模型验证器和评估器时进行详细展开。
12.1.3. 关于持续训练的考虑
在许多机器学习系统中,您会定期获得新的训练数据(每周或每天)。也许你能够使用前一天的日志生成新的、更及时的训练数据。在这种系统中,模型需要经常重新训练,使用最新可用的数据。在这些情况下,人们相信模型的年龄或陈旧程度会影响其能力。随着时间的推移,模型的输入会漂移到与其训练不同的分布,因此质量特征会变差。例如,你可能有一个服装推荐工具,在冬天训练过,但在夏天做出预测。
在你开始探索需要连续训练的系统时,根据这个基本思想,你将拥有多种额外组件来创建你的流水线。关于这些组件的全面讨论超出了本书的范围,但 TensorFlow Extended(TFX)^([1])是一个值得一看的基础设施,可以提供更多的想法。在测试领域中,它列出的最相关的流水线组件是示例验证器,模型验证器和模型评估器。图 12.1 中的图表包含与这些组件对应的框。
¹
Denis Baylor 等人,“TFX:基于 TensorFlow 的生产规模机器学习平台”,KDD 2017,www.kdd.org/kdd2017/papers/view/tfx-a-tensorflow-based-production-scale-machine-learning-platform。
示例验证器是关于测试数据的,这是测试机器学习系统时容易忽视的一个方面。在机器学习实践者中有一句著名的话:“垃圾进,垃圾出。”训练好的机器学习模型的质量受到输入数据质量的限制。具有无效特征值或不正确标签的示例在部署使用时(即使模型训练任务由于坏示例而失败!)可能会影响训练模型的准确性。示例验证器用于确保进入模型训练和评估的数据的属性始终满足某些要求:你有足够的数据,其分布看起来有效,并且没有任何奇怪的离群值。例如,如果你有一组医疗数据,身高(以厘米为单位)应该是一个不大于 280 的正数;患者年龄应该是 0 到 130 之间的正数;口腔温度(以摄氏度为单位)应该是大约在 30 到 45 之间的正数,等等。如果某些数据示例包含超出这些范围的特征或具有“None”或 NaN 等占位符值,我们就知道这些示例有问题,它们应该相应地处理——在大多数情况下,排除在训练和评估之外。通常,这里的错误表明数据收集过程失败,或者当构建系统时持有的假设与“世界变化”的方式不兼容。通常,这更类似于监视和警报,而不是集成测试。
像示例验证器这样的组件还对检测训练服务偏差有用,这是机器学习系统中可能出现的一种特别严重的错误。两个主要原因是 1)属于不同分布的训练和服务数据,以及 2)数据预处理涉及在训练和服务期间行为不同的代码路径。部署到训练和服务环境的示例验证器有潜力捕获通过任一路径引入的错误。
模型验证器扮演着构建模型的人在决定模型是否“足够好”以用于服务的角色。你可以根据自己关心的质量指标对其进行配置,然后它要么“祝福”该模型,要么拒绝它。再次强调,就像示例验证器一样,这更像是一种监视和警报式的交互。你还通常会想要随时间记录和绘制你的质量指标(准确度等),以便查看是否存在可能不会单独触发警报但可能仍然有用于诊断长期趋势并隔离其原因的小规模系统性恶化。
模型评估器是对模型质量统计的更深入的探究,沿着用户定义的轴切割和分析质量。通常,这用于探测模型是否对不同的用户群体——年龄段、教育水平、地理位置等——表现公平。一个简单的例子是查看我们在第 3.3 节中使用的鸢尾花示例,并检查我们的分类准确率在三种鸢尾花物种之间是否大致相似。如果我们的测试或评估集对其中一种人口有异常偏向,那么可能我们总是在最小的人口上出错,但这并没有显示为一个最高级别的准确性问题。与模型验证器一样,随时间变化的趋势通常与个别时点的测量一样有用。
12.2. 模型优化
一旦您费尽心思地创建、训练和测试了您的模型,就该是将其投入使用的时候了。这个过程被称为模型部署,它与模型开发的前几个步骤同样重要。无论模型是要在客户端进行推理还是在后端进行服务,我们总是希望模型能够快速高效。具体而言,我们希望模型能够
-
体积小,因此在网络上或从磁盘加载时速度快
-
当调用其
predict()方法时,尽可能少地消耗时间、计算和内存。
本节描述了 TensorFlow.js 中用于优化训练模型大小和推理速度的技术,然后它们才会发布部署。
优化一词的含义是多重的。在本节的语境中,优化指的是包括模型大小减小和计算加速在内的改进。这不应与权重参数优化技术混淆,比如模型训练和优化器中的梯度下降。这种区别有时被称为模型质量与模型性能。性能指的是模型完成任务所消耗的时间和资源。质量指的是结果与理想结果的接近程度。
12.2.1. 通过后训练权重量化实现模型大小优化
互联网上快速加载小文件的需求对于网页开发者来说应该是非常明确的。如果您的网站目标是非常庞大的用户群或者拥有较慢的互联网连接的用户,这一点尤为重要。^([2])此外,如果您的模型存储在移动设备上(请参阅 12.3.4 节 对使用 TensorFlow.js 进行移动部署的讨论),则模型的大小通常受到有限的存储空间的限制。作为模型部署的挑战,神经网络是庞大的,并且仍在不断增大。深度神经网络的容量(即,预测能力)往往是以增加层数和更大的层尺寸为代价的。在撰写本文时,最先进的图像识别、([3])语音识别、([4])自然语言处理、([5])以及生成模型([6])往往超过 1 GB 的权重大小。由于模型需要同时小巧和强大之间的紧张关系,深度学习中一个极其活跃的研究领域是模型大小优化,即如何设计一个尽可能小但仍能以接近较大神经网络的准确度执行任务的神经网络。有两种一般方法可供选择。在第一种方法中,研究人员设计一个神经网络,旨在从一开始就将模型大小最小化。其次,还有通过这些方法将现有神经网络缩小至更小尺寸的技术。
²
2019 年 3 月,Google 推出了一个涉及使用神经网络以约翰·塞巴斯蒂安·巴赫风格创作音乐的涂鸦(
mng.bz/MOQW)。这个神经网络在浏览器中运行,由 TensorFlow.js 提供动力。该模型以本节描述的方法量化为 8 位整数,将模型的传输大小减少了数倍,降至约 380 KB。如果没有这种量化,将无法将模型提供给谷歌首页(Google 涂鸦出现的地方)等如此广泛的观众。³
Kaiming He 等人,“深度残差学习用于图像识别”,于 2015 年 12 月 10 日提交,
arxiv.org/abs/1512.03385。⁴
Johan Schalkwyk,“一种全神经元设备上的语音识别器”,Google AI 博客,于 2019 年 3 月 12 日,
mng.bz/ad67。⁵
Jacob Devlin 等人,“BERT: 深度双向转换器的预训练用于语言理解”,于 2018 年 10 月 11 日提交,
arxiv.org/abs/1810.04805。⁶
Tero Karras,Samuli Laine 和 Timo Aila,“用于生成对抗网络的基于风格的生成器架构”,于 2018 年 12 月 12 日提交,
arxiv.org/abs/1812.04948。
我们在卷积神经网络章节中介绍过的 MobileNetV2 是第一行研究的产物。[7] 它是一种适用于资源受限环境(如 Web 浏览器和移动设备)部署的小型、轻量级图像模型。与在相同任务上训练的更大的图像模型(如 ResNet50)相比,MobileNetV2 的准确度略差一些。但是它的尺寸(14 MB)比较小(ResNet50 的尺寸约为 100 MB),这使得准确度的轻微降低是值得的。
⁷
Mark Sandler 等人,“MobileNetV2: 反向残差和线性瓶颈”,IEEE 计算机视觉与模式识别会议(CVPR),2018 年,pp. 4510–4520,
mng.bz/NeP7.
即使内置了尺寸压缩功能,MobileNetV2 对于大多数 JavaScript 应用程序来说仍然稍大。考虑到其大小(14 MB)约为平均网页大小的八倍。[8] MobileNetV2 提供了一个宽度参数,如果将其设置为小于 1 的值,则可以减小所有卷积层的尺寸,从而进一步减小尺寸(以及进一步降低准确度)。例如,将宽度设置为 0.25 的 MobileNetV2 版本大约是完整模型大小的四分之一(3.5 MB)。但即便如此,对于对页面权重和加载时间的增加敏感的高流量网站来说,这可能仍然无法接受。
⁸
根据 HTTP Archive,截至 2019 年 5 月,桌面端平均页面权重(HTML、CSS、JavaScript、图像和其他静态文件的总传输大小)约为 1,828 KB,移动端约为 1,682 KB:
httparchive.org/reports/page-weight.
是否有办法进一步减小这种模型的尺寸?幸运的是,答案是肯定的。这将我们带到了提到的第二种方法,即模型无关的尺寸优化。这类技术更加通用,因为它们不需要对模型体系结构本身进行更改,因此应该适用于各种现有的深度神经网络。我们将在这里专门关注的技术称为训练后权重量化。其思想很简单:在模型训练完成后,以更低的数值精度存储其权重参数。 信息框 12.1 描述了对于对底层数学感兴趣的读者如何实现这一点。
基于训练后权重量化的数学原理
神经网络的权重参数在训练过程中以 32 位浮点(float32)数表示。这不仅适用于 TensorFlow.js,还适用于其他深度学习框架,如 TensorFlow 和 PyTorch。尽管这种相对昂贵的表示通常在训练模型时没有问题(例如,配备了充足内存、快速 CPU 和 CUDA GPU 的工作站后端环境),但经验研究表明,对于许多推理用例,我们可以降低权重的精度而不会导致精度大幅度下降。为了降低表示精度,我们将每个 float32 值映射到一个 8 位或 16 位整数值,该值表示该权重中所有值范围内的离散位置。这个过程就是我们所说的量化。
在 TensorFlow.js 中,权重量化是逐个权重进行的。例如,如果神经网络由四个权重变量组成(例如两个密集层的权重和偏置),则每个权重将作为整体进行量化。控制权重量化的方程式如下:
方程 12.1。

在此方程中,B是量化结果将存储的位数。它可以是 8 位或 16 位,如 TensorFlow.js 目前支持的。w[Min]是权重参数的最小值。wScale 是参数的范围(最大值与最小值之间的差异)。当然,只有在wScale 非零时,方程才有效。在wScale 为零的特殊情况下,即当权重的所有参数具有相同值时,quantize(w)将为所有w返回 0。
两个辅助值w[Min]和wScale 与量化后的权重值一起保存,以支持在模型加载期间恢复权重(我们称之为去量化)的过程。控制去量化的方程式如下:
方程 12.2。

无论wScale 是否为零,此方程式都有效。
后训练量化可以大大减小模型大小:16 位量化将模型大小减少约 50%,8 位量化则减少约 75%。这些百分比是近似值,有两个原因。首先,模型大小的一部分用于模型的拓扑结构,如 JSON 文件中所编码的。其次,正如信息框中所述,量化需要存储两个额外的浮点数值(w[Min]和w[Scale]),以及一个新的整数值(量化的位数)。然而,与用于表示权重参数的位数的减少相比,这些通常是次要的。
量化是一种有损转换。由于精度降低,原始权重值中的一些信息会丢失。这类似于将 24 位颜色图像的位深度减少为 8 位(你可能在任天堂的游戏机上见过的类型),这种效果对人眼来说很容易看到。图 12.2 提供了 16 位和 8 位量化导致的离散程度的直观比较。正如你所预期的,8 位量化会导致对原始权重的粗糙表示。在 8 位量化下,对于权重参数的整个范围,只有 256 个可能的值,而在 16 位量化下有 65536 个可能的值。与 32 位浮点表示相比,这两者都是精度的显著降低。
图 12.2。16 位和 8 位权重量化的示例。原始的恒等函数(y = x,面板 A)通过 16 位和 8 位量化减小了尺寸;结果分别显示在面板 B 和面板 C 中。为了使页面上的量化效果可见,我们放大了恒等函数在x = 0 附近的一小部分。

在实践中,由于权重参数的精度损失真的重要吗?在神经网络的部署中,重要的是它在测试数据上的准确性。为了回答这个问题,我们在 tfjs-examples 的量化示例中编译了许多涵盖不同类型任务的模型。你可以在那里运行量化实验,并亲眼看到效果。要查看示例,请使用以下命令:
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/quantization
yarn
示例包含四个场景,每个场景展示了一个数据集和应用于数据集的模型的独特组合。第一个场景涉及使用数值特征(例如物业的中位数年龄、房间总数等)来预测加利福尼亚州地理区域的平均房价。该模型是一个包含了防止过拟合的 dropout 层的五层网络。要训练和保存原始(非量化)模型,请使用以下命令:
yarn train-housing
下面的命令对保存的模型进行 16 位和 8 位量化,并评估这两种量化水平对测试数据集(模型训练期间未见的数据的子集)的模型准确性产生了什么影响:
yarn quantize-and-evaluate-housing
这个命令将许多操作封装在内,以便使用。然而,实际量化模型的关键步骤可以在 quantization/quantize_evaluate.sh 的 shell 脚本中看到。在该脚本中,你可以看到以下 shell 命令,它对路径为MODEL_JSON_PATH的模型进行 16 位量化。你可以按照这个命令的示例来量化自己的 TensorFlow.js 保存的模型。如果选项标志--quantization_bytes设置为1,则将执行 8 位量化:
tensorflowjs_converter \
--input_format tfjs_layers_model \
--output_format tfjs_layers_model \
--quantization_bytes 2 \
"${MODEL_JSON_PATH}" "${MODEL_PATH_16BIT}"
前述命令展示了如何在 JavaScript 中对训练模型执行权重量化。当将模型从 Python 转换为 JavaScript 时,tensorflowjs_converter还支持权重量化,其详细信息显示在信息框 12.2 中。
权重量化和来自 Python 的模型
在第五章中,我们展示了如何将来自 Keras(Python)的模型转换为可以加载和使用 TensorFlow.js 的格式。在此类 Python 到 JavaScript 的转换期间,您可以应用权重量化。要执行此操作,请使用与主文中描述的相同的--quantization_bytes标志。例如,要将由 Keras 保存的 HDF5(.h5)格式的模型转换为具有 16 位量化的模型,请使用以下命令:
tensorflowjs_converter \
--input_format keras \
--output_format tfjs_layers_model \
--quantization_bytes 2 \
"${KERAS_MODEL_H5_PATH}" "${TFJS_MODEL_PATH}"
在此命令中,KERAS_MODEL_H5_PATH是由 Keras 导出的模型的路径,而TFJS_MODEL_PATH是转换并进行权重量化的模型将生成的路径。
由于权重的随机初始化和训练过程中数据批次的随机洗牌,您获得的详细准确性值可能会有轻微变化。然而,总体结论应始终保持不变:正如 table 12.1 的第一行所示,对权重进行 16 位量化会导致住房价格预测的 MAE 发生微小变化,而对权重进行 8 位量化会导致 MAE 相对较大(但在绝对值上仍然微小)的增加。
表 12.1。四个不同模型的评估准确性,经过训练后进行权重量化
| 数据集和模型 | 在无量化和不同量化级别下的评估损失和准确性 |
|---|---|
| 32 位全精度(无量化) | 16 位量化 |
| --- | --- |
| 加利福尼亚房屋:MLP 回归器 | MAE^([a]) = 0.311984 |
| MNIST:卷积神经网络 | 准确率 = 0.9952 |
| Fashion-MNIST:卷积神经网络 | 准确率 = 0.922 |
| ImageNet 1000 子集:MobileNetV2 | Top-1 准确率 = 0.618 Top-5 准确率 = 0.788 |
^a
加利福尼亚房屋模型使用 MAE 损失函数。对于 MAE 而言,较低的值更好,与准确率不同。
量化示例中的第二个场景基于熟悉的 MNIST 数据集和深度卷积网络架构。与住房实验类似,您可以使用以下命令训练原始模型并对其进行量化版本的评估:
yarn train-mnistyarn quantize-and-evaluate-mnist
正如 table 12.1 的第二行所示,16 位和 8 位量化都不会导致模型的测试准确性发生可观的变化。这反映了卷积神经网络是一个多类分类器的事实,因此其层输出值的微小偏差可能不会改变最终的分类结果,该结果是通过argMax()操作获得的。
这一发现是否代表了面向图像的多类分类器?请记住,MNIST 是一个相对容易的分类问题。即使是像本例中使用的简单卷积网络也能达到几乎完美的准确率。当我们面对更难的图像分类问题时,量化如何影响准确率?要回答这个问题,请看量化示例中的另外两个场景。
Fashion-MNIST,你在 第十章 中的变分自动编码器部分遇到的问题,是一个比 MNIST 更难的问题。通过使用以下命令,你可以在 Fashion-MNIST 数据集上训练一个模型,并检查 16 位和 8 位量化如何影响其测试准确率:
yarn train-fashion-mnist
yarn quantize-and-evaluate-fashion-mnist
结果显示在 表 12.1 的第三行,表明由于权重的 8 位量化而导致测试准确率略微下降(从 92.2% 下降到 92.1%),尽管 16 位量化仍然没有观察到变化。
更难的图像分类问题是 ImageNet 分类问题,涉及 1,000 个输出类别。在这种情况下,我们下载了一个预先训练的 MobileNetV2,而不是像在本例的其他三个场景中那样从头开始训练一个模型。预训练模型在 ImageNet 数据集的 1,000 张图像样本上以其非量化和量化形式进行评估。我们选择不评估整个 ImageNet 数据集,因为数据集本身非常庞大(有数百万张图像),并且我们从中得出的结论不会有太大不同。
要更全面地评估模型在 ImageNet 问题上的准确性,我们计算了 top-1 和 top-5 的准确率。Top-1 准确率是仅考虑模型最高单个逻辑输出时的正确预测比率,而 top-5 准确率则是在最高的五个逻辑中有任何一个包含正确标签时将预测视为正确。这是评估 ImageNet 模型准确性的标准方法,因为由于大量类标签,其中一些非常接近,模型通常不会在 top 逻辑中显示正确标签,而是在 top-5 逻辑中之一。要查看 MobileNetV2 + ImageNet 实验的结果,请使用
yarn quantize-and-evaluate-MobileNetV2
不同于前面的三种情况,这个实验显示了 8 位对测试准确率的重大影响(见表 12.1 的第四行)。8 位量化的 MobileNet 的 top-1 和 top-5 准确率都远低于原始模型,使得 8 位量化成为 MobileNet 不可接受的尺寸优化选项。然而,16 位量化的 MobileNet 仍然显示出与非量化模型相当的准确率[⁹]。我们可以看到量化对准确率的影响取决于模型和数据。对于某些模型和任务(如我们的 MNIST convnet),16 位和 8 位量化都不会导致测试准确率的任何可观察降低。在这些情况下,我们应该尽可能在部署时使用 8 位量化模型以减少下载时间。对于一些模型,如我们的 Fashion-MNIST convnet 和我们的房价回归模型,16 位量化不会导致准确率的任何观察到的恶化,但 8 位量化确实会导致准确率略微下降。在这种情况下,您应根据判断是否额外的 25% 模型大小减小超过准确率减少。最后,对于某些类型的模型和任务(如我们的 MobileNetV2 对 ImageNet 图像的分类),8 位量化会导致准确率大幅下降,这在大多数情况下可能是不可接受的。对于这样的问题,您需要坚持使用原始模型或其 16 位量化版本。
⁹
实际上,我们可以看到准确率略微增加,这归因于由仅包含 1,000 个示例的相对较小的测试集上的随机波动。
量化示例中的案例是可能有些简化的典型问题。您手头的问题可能更加复杂,与这些案例大不相同。重要的是,是否在部署之前对模型进行量化以及应该对其进行多少位深度的量化都是经验性问题,只能根据具体情况来回答。在做出决定之前,您需要尝试量化并在真实的测试数据上测试生成的模型。本章末尾的练习 1 让您尝试使用我们在 第十章 中训练的 MNIST ACGAN 模型,并决定对于这样的生成模型是选择 16 位还是 8 位量化是正确的决定。
权重量化和 gzip 压缩
要考虑到的 8 位量化的另一个好处是,在诸如 gzip 等数据压缩技术下提供的附加压缩模型大小的额外减少。gzip 被广泛用于通过网络传输大文件。在通过网络提供 TensorFlow.js 模型文件时,应始终启用 gzip。神经网络的非量化 float32 权重通常不太适合这种压缩,因为参数值中存在类似噪声的变化,其中包含很少的重复模式。我们观察到,对于模型的非量化权重,gzip 通常不能获得超过 10-20%的大小减小。对于具有 16 位权重量化的模型也是如此。然而,一旦模型的权重经过 8 位量化,通常压缩比例会有相当大的增加(对于小型模型可高达 30-40%,对于较大的模型约为 20-30%;见 table 12.2)。
表 12.2。不同量化级别下模型构件的 gzip 压缩比例
| 数据集和模型 | gzip 压缩比例^([a]) |
|---|---|
| 32 位全精度(无量化) | 16 位量化 |
| --- | --- |
| California 房屋:MLP 回归器 | 1.121 |
| MNIST:卷积网络 | 1.082 |
| Fashion-MNIST:卷积网络 | 1.078 |
| ImageNet 1000 个子集:MobileNetV2 | 1.085 |
^a
(模型.json 和权重文件的总大小)/(gzipped tar ball 的大小)
这是由于极大降低的精度(仅 256)下可用的小箱数,导致许多值(例如 0 周围的值)落入相同的箱中,因此导致权重的二进制表示中出现更多的重复模式。这是在不会导致测试准确度不可接受的情况下更喜欢 8 位量化的另一个原因。
总之,通过训练后的权重量化,我们可以大大减少通过网络传输和存储在磁盘上的 TensorFlow.js 模型的大小,尤其是在使用 gzip 等数据压缩技术的帮助下。这种改进的压缩比的好处不需要开发者进行代码更改,因为浏览器在下载模型文件时会自动进行解压缩。但是,这并不会改变执行模型推断调用所涉及的计算量。也不会改变这些调用的 CPU 或 GPU 内存消耗量。这是因为在加载权重后对它们进行去量化(参见方程 12.2 中的信息框 12.1)。就运行的操作、张量的数据类型和形状以及操作输出的张量而言,非量化模型和量化模型之间没有区别。然而,对于模型部署,同样重要的问题是如何使模型在部署时以尽可能快的速度运行,并且使其在运行时消耗尽可能少的内存,因为这可以提高用户体验并减少功耗。在不丢失预测准确性和在模型大小优化之上,有没有办法使现有的 TensorFlow.js 模型运行得更快?幸运的是,答案是肯定的。在下一节中,我们将重点介绍 TensorFlow.js 提供的推断速度优化技术。
12.2.2. 使用 GraphModel 转换进行推断速度优化
这一节的结构如下。我们将首先介绍使用GraphModel转换来优化 TensorFlow.js 模型的推断速度所涉及的步骤。然后,我们将列出详细的性能测量结果,量化了该方法所提供的速度增益。最后,我们将解释GraphModel转换方法在底层的工作原理。
假设您有一个路径为 my/layers-model 的 TensorFlow.js 模型;您可以使用以下命令将其转换为tf.GraphModel:
tensorflowjs_converter \
--input_format tfjs_layers_model \
--output_format tfjs_graph_model \
my/layers-model my/graph-model
此命令将在输出目录 my/graph-model 下创建一个 model.json 文件(如果该目录不存在),以及若干二进制权重文件。表面上看,这一组文件在格式上可能与包含序列化tf.LayersModel的输入目录中的文件相同。然而,输出的文件编码了一种称为tf.GraphModel的不同类型的模型(这个优化方法的同名)。为了在浏览器或 Node.js 中加载转换后的模型,请使用 TensorFlow.js 的tf.loadGraphModel()方法,而不是熟悉的tf.loadLayersModel()方法。加载tf.GraphModel对象后,您可以通过调用对象的predict()方法以完全相同的方式执行推断,就像对待tf.LayersModel一样。例如,
const model = await tf.loadGraphModel('file://./my/graph-model/model.json');***1***
const ys = model.predict(xs); ***2***
-
1 如果在浏览器中加载模型,则可以使用 http:// 或 https:// URL。
-
2 使用输入数据'xs'进行推断。
提高的推理速度带来了两个限制:
-
在撰写本文时,最新版本的 TensorFlow.js(1.1.2)不支持循环层,如
tf.layers.simpleRNN()、tf.layers.gru()和tf.layers.lstm()(见第九章)用于GraphModel转换。 -
载入的
tf.GraphModel对象没有fit()方法,因此不支持进一步的训练(例如,迁移学习)。
表 12.3 比较了两种模型类型在有和没有GraphModel转换时的推理速度。由于GraphModel转换尚不支持循环层,因此仅呈现了 MLP 和卷积神经网络(MobileNetV2)的结果。为了覆盖不同的部署环境,该表呈现了来自 Web 浏览器和后端环境中运行的 tfjs-node 的结果。从这个表中,我们可以看到GraphModel转换始终加快了推理速度。但是,加速比取决于模型类型和部署环境。对于浏览器(WebGL)部署环境,GraphModel转换会带来 20-30%的加速,而如果部署环境是 Node.js,则加速效果更加显著(70-90%)。接下来,我们将讨论为什么GraphModel转换加快了推理速度,以及它为什么在 Node.js 环境中比在浏览器环境中加速更多的原因。
表 12.3 比较了两种模型类型(MLP 和 MobileNetV2)在不同部署环境下进行GraphModel转换优化和不进行优化时的推理速度^([a])
^a
获得这些结果的代码可在
github.com/tensorflow/tfjs/tree/master/tfjs/integration_tests/找到。
| 模型名称和拓扑结构 | 预测时间(毫秒;值越低越好)(在 20 次热身调用之后的 30 次预测调用的平均值) |
|---|---|
| 浏览器 WebGL | tfjs-node(仅 CPU) |
| --- | --- |
| LayersModel | GraphModel |
| --- | --- |
| MLP^([b]) | 13 |
| MobileNetV2(宽度=1.0) | 68 |
^b
MLP 由单元数为 4,000、1,000、5,000 和 1 的密集层组成。前三层使用 relu 激活函数;最后一层使用线性激活函数。
GraphModel 转换如何加速模型推理
GraphModel 转换是如何提高 TensorFlow.js 模型推断速度的?这是通过利用 TensorFlow(Python)对模型计算图进行细粒度的提前分析来实现的。计算图分析后,会对图进行修改,减少计算量同时保持图的输出结果的数值正确性。不要被提前分析和细粒度等术语吓到。稍后我们会对它们进行解释。
为了给出我们所说的图修改的具体例子,让我们考虑一下在 tf.LayersModel 和 tf.GraphModel 中 BatchNormalization 层的工作原理。回想一下,BatchNormalization 是一种在训练过程中改善收敛性和减少过拟合的类型的层。它在 TensorFlow.js API 中可用作 tf.layers.batchNormalization(),并且被诸如 MobileNetV2 这样的常用预训练模型使用。当 BatchNormalization 层作为 tf.LayersModel 的一部分运行时,计算会严格遵循批量归一化的数学定义:
方程式 12.3。

为了从输入 (x) 生成输出,需要六个操作(或 ops),大致顺序如下:
-
sqrt,将var作为输入 -
add,将epsilon和步骤 1 的结果作为输入 -
sub,将x和平均值作为输入 -
div,将步骤 2 和 3 的结果作为输入 -
mul,将gamma和步骤 4 的结果作为输入 -
add,将beta和步骤 5 的结果作为输入
基于简单的算术规则,可以看出方程式 12.3 可以被显著简化,只要 mean、var、epsilon、gamma 和 beta 的值是常量(不随输入或层被调用的次数而变化)。在训练包含 BatchNormalization 层的模型后,所有这些变量确实都变成了常量。这正是 GraphModel 转换所做的:它“折叠”常量并简化算术,从而导致以下在数学上等效的方程式:
方程式 12.4。

k 和 b 的值是在 GraphModel 转换期间计算的,而不是在推断期间:
方程式 12.5。

方程式 12.6。

因此,方程式 12.5 和 12.6 在推断过程中不计入计算量;只有方程式 12.4 计入。将方程式 12.3 和 12.4 进行对比,您会发现常数折叠和算术简化将操作数量从六个减少到了两个(x和k之间的mul操作,以及b和该mul操作结果之间的add操作),从而极大加速了该层的执行速度。但为什么tf.LayersModel不执行此优化?因为它需要支持 BatchNormalization 层的训练,在训练的每一步都会更新mean、var、gamma和beta的值。GraphModel转换利用了这一事实,即这些更新的值在模型训练完成后不再需要。
在 BatchNormalization 示例中看到的优化类型仅在满足两个要求时才可能实现。首先,计算必须以足够细粒度的方式表示——即在基本数学操作(如add和mul)的层面上,而不是 TensorFlow.js 的 Layers API 所在的更粗粒度的层面。其次,所有的计算在执行模型的predict()方法之前都是已知的。GraphModel转换经过了 TensorFlow(Python),可以得到满足这两个条件的模型的图表示。
除了之前讨论的常数折叠和算术优化外,GraphModel的转换还能执行另一种称为op fusion的优化类型。以经常使用的密集层类型(tf.layers.dense())为例。密集层涉及三种操作:输入x和内核W的矩阵乘法(matMul),matMul结果和偏置(b)之间的广播加法,以及逐元素的 relu 激活函数(图 12.3,面板 A)。op fusion 优化使用一种单一操作替换了这三个分开的操作,该单一操作执行了所有等效步骤(图 12.3,面板 B)。这种替换可能看起来微不足道,但由于 1)启动 op 的开销减少(是的,启动 op 总是涉及一定的开销,无论计算后端如何),以及 2)在融合的 op 实现中执行速度优化的更多机会,这导致了更快的计算。
图 12.3. 密集层内部操作的示意图,带有(面板 A)和不带有(面板 B)op fusion。

操作融合优化与我们刚刚看到的常量折叠和算术简化有何不同?操作融合要求特殊融合操作(在本例中为 Fused matMul+relu)在所使用的计算后端中定义并可用,而常量折叠则不需要。这些特殊融合操作可能仅对某些计算后端和部署环境可用。这就是为什么我们在 Node.js 环境中看到了比在浏览器中更大量的推理加速的原因(参见 table 12.3)。Node.js 计算后端使用的是用 C++ 和 CUDA 编写的 libtensorflow,它配备了比浏览器中的 TensorFlow.js WebGL 后端更丰富的操作集。
除了常量折叠、算术简化和操作融合之外,TensorFlow(Python)的图优化系统 Grappler 还能够进行其他许多种类的优化,其中一些可能与如何通过 GraphModel 转换优化 TensorFlow.js 模型相关。然而,由于空间限制,我们不会涵盖这些内容。如果你对此主题想要了解更多,你可以阅读本章末尾列出的 Rasmus Larsen 和 Tatiana Shpeisman 的信息性幻灯片。
总之,GraphModel 转换是由 tensorflowjs_ converter 提供的一种技术。它利用 TensorFlow(Python)的提前图优化能力简化计算图,并减少模型推理所需的计算量。尽管推理加速的详细量取决于模型类型和计算后端,但通常它提供了 20% 或更多的加速比,因此在部署 TensorFlow.js 模型之前执行此步骤是明智的。
如何正确测量 TensorFlow.js 模型的推理时间
tf.LayersModel 和 tf.GraphModel 都提供了统一的 predict() 方法来支持推理。该方法接受一个或多个张量作为输入,并返回一个或多个张量作为推理结果。然而,在基于 WebGL 的浏览器推理环境中,重要的是要注意,predict() 方法仅安排在 GPU 上执行的操作;它不等待它们执行完成。因此,如果你天真地按照以下方式计时 predict() 调用,计时测量结果将是错误的:
console.time('TFjs inference');
const outputTensor = model.predict(inputTensor);
console.timeEnd('TFjs inference'); ***1***
- 1 测量推理时间的不正确方式!
当predict()返回时,预定的操作可能尚未执行完毕。因此,前面的示例将导致比完成推理所需实际时间更短的时间测量。为了确保在调用console.timeEnd()之前操作已完成,需要调用返回的张量对象的以下方法之一:array()或data()。这两种方法都会将保存输出张量元素的纹理值从 GPU 下载到 CPU。为了实现这一点,它们必须等待输出张量的计算完成。因此,正确的计时方法如下所示:
console.time('TFjs inference');
const outputTensor = model.predict(inputTensor);
await outputTensor.array(); ***1***
console.timeEnd('TFjs inference');
- 1
array()调用直到输出张量的计算完成才会返回,从而确保推理时间测量的正确性。
另一个需要记住的重要事情是,与所有其他 JavaScript 程序一样,TensorFlow.js 模型推理的执行时间是变化的。为了获得推理时间的可靠估计,应该将上面代码段放在一个for循环中,以便可以多次执行(例如,50 次),并且可以根据累积的单个测量计算出平均时间。最初的几次执行通常比随后的执行慢,因为需要编译新的 WebGL 着色程序并设置初始状态。因此,性能测量代码通常会忽略前几次运行(例如,前五次),这些被称为热身或预热运行。
如果你对这些性能基准技术有更深入的了解感兴趣,可以通过本章末尾的练习 3 来进行实践。
12.3. 在各种平台和环境上部署 TensorFlow.js 模型
你已经优化了你的模型,它又快又轻,而且所有的测试都通过了。你准备好了! 好消息! 但在你开香槟庆祝之前,还有更多的工作要做。
是时候将你的模型部署到应用程序中,并让它出现在用户基础之前了。在本节中,我们将涵盖一些部署平台。部署到网络和部署到 Node.js 服务是众所周知的途径,但我们还将涵盖一些更奇特的部署情景,比如部署到浏览器扩展程序或单板嵌入式硬件应用。我们将指向简单的例子,并讨论平台重要的特殊注意事项。
12.3.1. 部署到网络时的额外考虑事项
让我们首先重新审视 TensorFlow.js 模型最常见的部署场景:将其部署到网页中。在这种场景下,我们经过训练且可能经过优化的模型通过 JavaScript 从某个托管位置加载,然后模型利用用户浏览器内的 JavaScript 引擎进行预测。一个很好的例子是 第五章 中的 MobileNet 图像分类示例。该示例也可从 tfjs-examples/ mobilenet 进行下载。作为提醒,以下是加载模型并进行预测的相关代码概述:
const MOBILENET_MODEL_PATH =
'https://storage.googleapis.com/tfjs-models/tfjs/mobilenet_v1_0.25_224/model.json';
const mobilenet = await tf.loadLayersModel(MOBILENET_MODEL_PATH);
const response = mobilenet.predict(userQueryAsTensor);
该模型是从 Google 云平台(GCP)存储桶中托管的。对于像这样的低流量、静态应用程序,很容易将模型静态托管在站点内容的其他部分旁边。对于更大、更高流量的应用程序,可以选择通过内容交付网络(CDN)将模型与其他重型资产一起托管。一个常见的开发错误是在设置 GCP、Amazon S3 或其他云服务中的存储桶时忘记考虑跨域资源共享(CORS)。如果 CORS 设置不正确,模型加载将失败,并且您应该会在控制台上收到与 CORS 相关的错误消息。如果您的 Web 应用在本地正常工作,但在发布到分发平台后失败,请注意这一点。
在用户的浏览器加载 HTML 和 JavaScript 后,JavaScript 解释器将发出加载模型的调用。在具备良好的网络连接的现代浏览器中,加载一个小型模型的过程大约需要几百毫秒,但在初始加载后,可以从浏览器缓存中更快地加载模型。序列化格式确保将模型切分为足够小的部分以支持标准浏览器缓存限制。
Web 部署的一个好处是预测直接在浏览器中进行。传递给模型的任何数据都不会通过网络传输,这对于延迟很有好处,且对于隐私保护也非常重要。想象一下,如果模型正在预测辅助输入法的下一个单词,这在我们常见的场景中经常出现,比如 Gmail。如果需要将输入的文本发送到云端服务器,并等待远程服务器的响应,那么预测将会被延迟,输入的预测结果将会变得不太有用。此外,一些用户可能会认为将其不完整的按键输入发送到远程计算机中侵犯了他们的隐私。在用户自己的浏览器中进行本地预测更加安全和注重隐私。
在浏览器中进行预测的缺点是模型安全性。将模型发送给用户使其容易保留该模型并将其用于其他目的。TensorFlow.js 目前(截至 2019 年)在浏览器中没有模型安全的解决方案。其他一些部署场景使用户更难以将模型用于开发者未预期的目的。最大模型安全性的分发路径是将模型保留在你控制的服务器上,并从那里提供预测请求。当然,这需要牺牲延迟和数据隐私。平衡这些问题是产品决策。
12.3.2. 云服务部署
许多现有的生产系统提供机器学习训练预测服务,例如 Google Cloud Vision AI(cloud.google.com/vision)或 Microsoft Cognitive Services(azure.microsoft.com/en-us/services/cognitive-services)。这样的服务的最终用户会进行包含预测输入值的 HTTP 请求,例如用于对象检测任务的图像,响应会对预测的输出进行编码,例如图像中对象的标签和位置。
截至 2019 年,有两种方法可以从服务器上服务于 TensorFlow.js 模型。第一种方法是运行 Node.js 的服务器,使用原生 JavaScript 运行时进行预测。由于 TensorFlow.js 很新,我们不知道有哪些生产用例选择了这种方法,但概念证明很容易构建。
第二条路线是将模型从 TensorFlow.js 转换为可以从已知的现有服务器技术(例如标准的 TensorFlow Serving 系统)服务的格式。从www.tensorflow.org/tfx/guide/serving的文档中可知:
TensorFlow Serving 是一个为生产环境设计的灵活高效的机器学习模型服务系统。TensorFlow Serving 使得部署新的算法和实验变得容易,同时保持相同的服务器体系结构和 API。TensorFlow Serving 提供了与 TensorFlow 模型的开箱即用的集成,但也可以轻松扩展以用于其他类型的模型和数据。
到目前为止,我们已经将 TensorFlow.js 模型序列化为 JavaScript 特定的格式。TensorFlow Serving 期望模型使用 TensorFlow 标准的 SavedModel 格式打包。幸运的是,tfjs-converter 项目使转换为所需格式变得容易。
在第五章(迁移学习)中,我们展示了如何在 TensorFlow.js 中使用 Python 实现的 TensorFlow 构建 SavedModels。要做相反的操作,首先安装 tensorflowjs pip 包:
pip install tensorflowjs
接下来,您必须运行转换器二进制文件,并指定输入:
tensorflowjs_converter \
--input_format=tfjs_layers_model \
--output_format=keras_saved_model \
/path/to/your/js/model.json \
/path/to/your/new/saved-model
这将创建一个新的 saved-model 目录,其中包含 TensorFlow Serving 可理解的所需拓扑和权重格式。然后,您应该能够按照构建 TensorFlow Serving 服务器的说明,并对运行中的模型进行 gRPC 预测请求。也存在托管解决方案。例如,Google Cloud 机器学习引擎提供了一条路径,您可以将保存的模型上传到 Cloud Storage,然后设置为服务,而无需维护服务器或机器。您可以从文档中了解更多信息:cloud.google.com/ml-engine/docs/tensorflow/deploying-models。
从云端提供模型的优点是您完全控制模型。可以很容易地对执行的查询类型进行遥测,并快速检测出问题。如果发现模型存在一些意外问题,可以快速删除或升级,并且不太可能出现在您控制之外的机器上的其他副本。缺点是额外的延迟和数据隐私问题,如前所述。还有额外的成本——无论是货币支出还是维护成本——在操作云服务时,您控制着系统配置。
12.3.3. 部署到浏览器扩展,如 Chrome 扩展
一些客户端应用程序可能需要您的应用程序能够跨多个不同的网站工作。所有主要桌面浏览器都提供了浏览器扩展框架,包括 Chrome、Safari 和 FireFox 等。这些框架使开发人员能够创建通过添加新的 JavaScript 和操作网站的 DOM 来修改或增强浏览体验的体验。
由于扩展是在浏览器执行引擎内部的 JavaScript 和 HTML 之上运行的,您可以在浏览器扩展中使用 TensorFlow.js 的功能与在标准网页部署中相似。模型安全性和数据隐私性与网页部署相同。通过在浏览器内直接执行预测,用户的数据相对安全。模型安全性与网页部署的情况也类似。
作为使用浏览器扩展的可能性的示例,请参阅 tfjs-examples 中的 chrome-extension 示例。此扩展加载一个 MobileNetV2 模型,并将其应用于用户在网络上选择的图像。安装和使用该扩展与我们看到的其他示例有点不同,因为它是一个扩展,而不是托管网站。这个示例需要 Chrome 浏览器。^([10])
¹⁰
较新版本的 Microsoft Edge 也为跨浏览器扩展加载提供了一些支持。
首先,您必须下载并构建扩展,类似于您可能构建其他示例的方式:
git clone https://github.com/tensorflow/tfjs-examples.git
cd tfjs-examples/chrome-extension
yarn
yarn build
扩展构建完成后,可以在 Chrome 中加载未打包的扩展。要这样做,您必须导航至 chrome://extensions,启用开发者模式,然后单击“加载未打包”,如图 12.4 所示。这将弹出一个文件选择对话框,在这里您必须选择在 chrome-extension 目录下创建的 dist 目录。那个包含 manifest.json 文件的目录。
图 12.4。在开发者模式下加载 TensorFlow.js MobileNet Chrome 扩展

安装扩展后,您应该能够在浏览器中对图像进行分类。要这样做,请导航至一些包含图像的网站,例如在此处使用的 Google 图像搜索页面上的“tiger”关键词。然后右键单击要分类的图像。您应该会看到一个名为“使用 TensorFlow.js 对图像进行分类”的菜单选项。单击该菜单选项将使扩展执行 MobileNet 模型的操作,并在图像上添加一些文本,表示预测结果(参见图 12.5)。
图 12.5。TensorFlow.js MobileNet Chrome 扩展可帮助分类网页中的图像。

要删除扩展名,请在“扩展”页面上单击“删除”(参见图 12.4),或右键单击右上角的扩展图标时使用“从 Chrome 菜单中删除”选项。
请注意,运行在浏览器扩展中的模型可以访问与运行在网页中的模型相同的硬件加速,并且确实使用了大部分相同的代码。该模型使用适当的 URL 来调用tf.loadGraphModel(...)进行加载,并使用相同的model.predict(...) API 进行预测。从网页部署迁移技术或概念验证到浏览器扩展相对较容易。
12.3.4。在基于 JavaScript 的移动应用中部署 TensorFlow.js 模型
对于许多产品来说,桌面浏览器提供的覆盖范围不够,移动浏览器也无法提供顾客所期望的平稳动画化的定制产品体验。在这些项目上工作的团队通常面临着如何管理他们的 Web 应用程序代码库以及(通常)Android(Java 或 Kotlin)和 iOS(Objective C 或 Swift)本机应用程序中的存储库的困境。虽然非常庞大的团队可以支持这样的支出,但许多开发人员越来越倾向于通过利用混合跨平台开发框架在这些部署之间重复使用大部分代码。
跨平台应用程序框架,如 React Native、Ionic、Flutter 和渐进式 Web 应用程序,使您能够使用通用语言编写应用程序的大部分功能,然后编译这些核心功能,以创建具有用户期望的外观、感觉和性能的本机体验。跨平台语言/运行时处理大部分业务逻辑和布局,并连接到本机平台绑定以获得标准的外观和感觉。如何选择合适的混合应用程序开发框架是网络上无数博客和视频的主题,因此我们不会在这里重新讨论这个问题,而是将重点放在一个流行的框架上,即 React Native。图 12.6 示例了一个运行 MobileNet 模型的简单 React Native 应用程序。请注意任何浏览器顶部栏的缺失。虽然这个简单的应用程序没有 UI 元素,但如果有的话,你会发现它们与本机 Android 的外观和感觉匹配。为 iOS 构建的相同应用程序也会匹配那些元素。
图 12.6. React Native 构建的样本本机 Android 应用程序的屏幕截图。在这里,我们在本机应用程序中运行了一个 TensorFlow.js MobileNet 模型。

令人高兴的是,React Native 中的 JavaScript 运行时原生支持 TensorFlow.js,无需做任何特殊工作。tfjs-react-native 包目前仍处于 alpha 发布阶段(截至 2019 年 12 月),但通过 expo-gl 提供了基于 WebGL 的 GPU 支持。用户代码如下所示:
import * as tf from '@tensorflow/tfjs';
import '@tensorflow/tfjs-react-native';
该软件包还提供了特殊 API,用于帮助在移动应用程序中加载和保存模型资源。
列表 12.2. 在使用 React-Native 构建的移动应用程序中加载和保存模型
import * as tf from '@tensorflow/tfjs';
import {asyncStorageIO} from '@tensorflow/tfjs-react-native';
async trainSaveAndLoad() {
const model = await train();
await model.save(asyncStorageIO( ***1***
'custom-model-test')) ***1***
model.predict(tf.tensor2d([5], [1, 1])).print();
const loadedModel =
await tf.loadLayersModel(asyncStorageIO( ***2***
'custom-model-test')); ***2***
loadedModel.predict(tf.tensor2d([5], [1, 1])).print();
}
-
1 将模型保存到 AsyncStorage——一种对应用程序全局可见的简单键-值存储系统
-
2 从 AsyncStorage 加载模型
虽然通过 React Native 进行本机应用程序开发仍需要学习一些新的工具,比如 Android Studio 用于 Android 和 XCode 用于 iOS,但学习曲线比直接进行本机开发要平缓。这些混合应用程序开发框架支持 TensorFlow.js 意味着机器学习逻辑可以存在于一个代码库中,而不需要我们为每个硬件平台表面开发、维护和测试一个单独的版本,这对于希望支持本机应用体验的开发人员来说是一个明显的胜利!但是,本机桌面体验呢?
12.3.5. 在基于 JavaScript 的跨平台桌面应用程序中部署 TensorFlow.js 模型
诸如 Electron.js 等 JavaScript 框架允许以类似于使用 React Native 编写跨平台移动应用程序的方式编写桌面应用程序。 使用这样的框架,您只需编写一次代码,就可以在主流桌面操作系统(包括 macOS、Windows 和主要的 Linux 发行版)上部署和运行。 这大大简化了传统开发流程,即为大部分不兼容的桌面操作系统维护单独的代码库。 以此类别中的主要框架 Electron.js 为例。 它使用 Node.js 作为支撑应用程序主要进程的虚拟机; 对于应用程序的 GUI 部分,它使用了 Chromium,一个完整但轻量级的网络浏览器,它与 Google Chrome 共享大部分代码。
TensorFlow.js 兼容 Electron.js,如 tfjs-examples 仓库中简单示例所示。 这个示例位于 electron 目录中,演示了如何在基于 Electron.js 的桌面应用中部署 TensorFlow.js 模型以进行推理。 该应用允许用户搜索文件系统中与一个或多个关键词视觉匹配的图像文件(请参阅 图 12.7 中的截图)。 这个搜索过程涉及在一组图像目录上应用 TensorFlow.js MobileNet 模型进行推理。
图 12.7. 一个使用 TensorFlow.js 模型的示例 Electron.js 桌面应用程序的屏幕截图,来自 tfjs-examples/electron

尽管这个示例应用程序很简单,但它展示了在将 TensorFlow.js 模型部署到 Electron.js 时一个重要的考虑因素:计算后端的选择。 Electron.js 应用程序在基于 Node.js 的后端进程和基于 Chromium 的前端进程上运行。 TensorFlow.js 可以在这两种环境中运行。 因此,同一个模型可以在应用程序的类似 node 的后端进程或类似浏览器的前端进程中运行。 在后端部署的情况下,使用 @tensorflow/tfjs-node 包,而在前端情况下使用 @tensorflow/tfjs 包(图 12.8)。 示例应用程序的 GUI 中的复选框允许您在后端和前端推理模式之间切换(图 12.7),尽管在由 Electron.js 和 TensorFlow.js 驱动的实际应用程序中,您通常会事先决定模型的运行环境。 接下来我们将简要讨论各个选项的优缺点。
图 12.8. 基于 Electron.js 的桌面应用程序架构,利用 TensorFlow.js 进行加速深度学习。TensorFlow.js 的不同计算后端可以从主后端进程或浏览器渲染器进程中调用。不同的计算后端导致模型在不同的底层硬件上运行。无论计算后端的选择如何,在 TensorFlow.js 中加载、定义和运行深度学习模型的代码基本相同。此图中的箭头表示库函数和其他可调用程序的调用。

如图 12.8 所示,不同的计算后端选择会导致深度学习计算在不同的计算硬件上进行。基于 @tensorflow/tfjs-node 的后端部署将工作负载分配到 CPU 上,利用多线程和 SIMD(单指令多数据)功能的 libtensorflow 库。这种基于 Node.js 的模型部署选项通常比前端选项更快,并且由于后端环境没有资源限制,可以容纳更大的模型。然而,它们的主要缺点是包大小较大,这是由于 libtensorflow 的体积较大(对于 tfjs-node,约为 50 MB 带有压缩)。
前端部署将深度学习工作负载分派给 WebGL。对于中小型模型以及推理延迟不是主要关注点的情况,这是一个可接受的选项。此选项导致包大小较小,并且由于对 WebGL 的广泛支持,可在广泛范围的 GPU 上直接运行。
如图 12.8 所示,计算后端的选择在很大程度上是与加载和运行模型的 JavaScript 代码分开的。相同的 API 对所有三个选项都适用。这在示例应用中得到了清楚的展示,其中相同的模块(ImageClassifier 在 electron/image_classifier.js 中)在后端和前端环境下均用于执行推理任务。我们还应指出,尽管 tfjs-examples/electron 示例仅显示推理,但您确实可以将 TensorFlow.js 用于 Electron.js 应用程序中的其他深度学习工作流程,例如模型创建和训练(例如,迁移学习)同样有效。
12.3.6. 在微信和其他基于 JavaScript 的移动应用插件系统上部署 TensorFlow.js 模型
有些地方的主要移动应用分发平台既不是 Android 的 Play Store 也不是 Apple 的 App Store,而是一小部分“超级移动应用程序”,它们允许在其自己的第一方精选体验中使用第三方扩展。
这些超级移动应用程序中有一些来自中国科技巨头,特别是腾讯的微信、阿里巴巴的支付宝和百度。它们使用 JavaScript 作为主要技术,以实现第三方扩展的创建,使得 TensorFlow.js 成为在其平台上部署机器学习的自然选择。然而,这些移动应用程序插件系统中可用的 API 集与本机 JavaScript 中可用的集合不同,因此在那里部署需要一些额外的知识和工作。
让我们以微信为例。微信是中国最广泛使用的社交媒体应用程序,每月活跃用户数超过 10 亿。2017 年,微信推出了小程序,这是一个让应用开发者在微信系统内部创建 JavaScript 小程序的平台。用户可以在微信应用程序内分享和安装这些小程序,这是一个巨大的成功。截至 2018 年第二季度,微信拥有 100 多万个小程序和 6 亿多日活跃用户。还有超过 150 万的开发者在这个平台上开发应用程序,部分原因是 JavaScript 的流行。
WeChat 小程序 API 旨在为开发者提供便捷访问移动设备传感器(摄像头、麦克风、加速计、陀螺仪、GPS 等)的功能。然而,原生 API 在平台上提供的机器学习功能非常有限。TensorFlow.js 作为小程序的机器学习解决方案带来了几个优势。以前,如果开发者想要在他们的应用程序中嵌入机器学习,他们需要在小程序开发环境之外使用服务器端或基于云的机器学习堆栈。这使得大量小程序开发者想要构建和使用机器学习变得更难。搭建外部服务基础设施对于大多数小程序开发者来说是不可能的。有了 TensorFlow.js,机器学习开发就在本地环境中进行。此外,由于它是一个客户端解决方案,它有助于减少网络流量和改善延迟,并利用了 WebGL 的 GPU 加速。
TensorFlow.js 团队创建了一个微信小程序,你可以使用它来为你的小程序启用 TensorFlow.js(见github.com/tensorflow/tfjs-wechat)。该存储库还包含一个使用 PoseNet 来注释移动设备摄像头感知到的人的位置和姿势的示例小程序。它使用了微信新增加的 WebGL API 加速的 TensorFlow.js。如果没有 GPU 加速,模型的运行速度对大多数应用程序来说太慢了。有了这个插件,微信小程序可以拥有与在移动浏览器内运行的 JavaScript 应用程序相同的模型执行性能。事实上,我们已经观察到微信传感器 API 通常优于浏览器中的对应 API。
到 2019 年底,为超级应用插件开发机器学习体验仍然是非常新的领域。获得高性能可能需要一些来自平台维护者的帮助。但是,这仍是将您的应用部署到数以亿计的将超级移动应用作为互联网的人民面前的最佳方式。
12.3.7. 在单板计算机上部署 TensorFlow.js 模型
对许多网页开发者来说,部署到无头单板计算机听起来非常技术化和陌生。然而,多亏了树莓派的成功,开发和构建简单的硬件设备变得前所未有的容易。单板计算机提供了一个平台,可以廉价部署智能,而不依赖于云服务器的网络连接或笨重昂贵的计算机。单板计算机可用于支持安全应用、调节互联网流量、控制灌溉,无所不能。
许多这些单板计算机提供通用输入输出(GPIO)引脚,以便轻松连接物理控制系统,并包含完整的 Linux 安装,允许教育工作者、开发人员和黑客开发各种互动设备。JavaScript 迅速成为构建这些类型设备的一种流行语言。开发人员可以使用像 rpi-gpio 这样的 Node 库在 JavaScript 中以最低层次进行电子交互。
为了帮助支持这些用户,TensorFlow.js 当前在这些嵌入式 ARM 设备上有两个运行时:tfjs-node (CPU^([11]))和tfjs-headless-nodegl (GPU)。整个 TensorFlow.js 库通过这两个后端在这些设备上运行。开发人员可以在设备硬件上运行推断,使用现有模型或自己训练模型!
¹¹
如果您希望在这些设备上利用 ARM NEON 加速 CPU,则应该使用 tfjs-node 软件包。该软件包支持 ARM32 和 ARM64 架构。
近期推出的设备(如 NVIDIA Jetson Nano 和 Raspberry Pi 4)带来了现代图形堆栈的 SoC(系统级芯片)。这些设备上的 GPU 可以被 TensorFlow.js 核心中使用的基础 WebGL 代码利用。无头 WebGL 包(tfjs-backend-nodegl)`允许用户纯粹通过这些设备上的 GPU 加速在 Node.js 上运行 TensorFlow.js(见图 12.9)。通过将 TensorFlow.js 的执行委托给 GPU,开发人员可以继续利用 CPU 来控制设备的其他部分。
图 12.9. 使用无头 WebGL 在树莓派 4 上执行 MobileNet 的 TensorFLow.js

单板计算机部署的模型安全性和数据安全性非常强。计算和执行直接在设备上处理,这意味着数据不需要传输到所有者无法控制的设备上。即使物理设备遭到破坏,也可以使用加密保护模型。
对于 JavaScript 来说,将部署到单板计算机仍然是一个非常新颖的领域,尤其是 TensorFlow.js,但它为其他部署领域不适用的广泛应用提供了可能。
12.3.8. 部署摘要
在本节中,我们介绍了几种不同的方法,可以使您的 TensorFlow.js 机器学习系统走在用户基础的前面(表 12.4 总结了它们)。我们希望我们能激发您的想象力,并帮助您梦想着技术的激进应用!JavaScript 生态系统广阔而广阔,在未来,具有机器学习功能的系统将在我们今天甚至无法想象的领域运行。
表 12.4. TensorFlow.js 模型可以部署到的目标环境以及每个环境可以使用的硬件加速器
| 部署 | 硬件加速器支持 |
|---|---|
| 浏览器 | WebGL |
| Node.js 服务器 | 具有多线程和 SIMD 支持的 CPU;具有 CUDA 支持的 GPU |
| 浏览器插件 | WebGL |
| 跨平台桌面应用程序(如 Electron) | WebGL,支持多线程和 SIMD 的 CPU,或者具有 CUDA 支持的 GPU |
| 跨平台移动应用程序(如 React Native) | WebGL |
| 移动应用程序插件(如微信) | 移动 WebGL |
| 单板计算机(如 Raspberry Pi) | GPU 或 ARM NEON |
进一步阅读材料
-
Denis Baylor 等,“TFX:基于 TensorFlow 的生产规模机器学习平台”,KDD 2017,www.kdd.org/kdd2017/papers/view/tfx-a-tensorflow-based-production-scale-machine-learning-platform。
-
Raghuraman Krishnamoorthi,“为高效推理量化深度卷积网络:一份白皮书”,2018 年 6 月,
arxiv.org/pdf/1806.08342.pdf。 -
Rasmus Munk Larsen 和 Tatiana Shpeisman,“TensorFlow 图优化”,
ai.google/research/pubs/pub48051。
练习
-
在第十章中,我们训练了一个辅助类生成对抗网络(ACGAN)来生成 MNIST 数据集的假冒图像,以类别为单位。具体来说,我们使用的示例位于 tfjs-examples 存储库的 mnist-acgan 目录中。训练模型的生成器部分总共约占用了大约 10 MB 的空间,其中大部分是以 32 位浮点数存储的权重。诱人的是对该模型进行训练后的权重量化以加快页面加载速度。但是,在执行此操作之前,我们需要确保这种量化不会导致生成的图像质量显着下降。测试 16 位和 8 位量化,并确定它们中的任何一个或两者都是可接受的选项。使用 section 12.2.1 中描述的
tensorflowjs_converter工作流程。在这种情况下,您将使用什么标准来评估生成的 MNIST 图像的质量? -
作为 Chrome 扩展运行的 Tensorflow 模型具有控制 Chrome 本身的优势。在第四章中的语音命令示例中,我们展示了如何使用卷积模型识别口语。Chrome 扩展 API 允许您查询和更改标签页。尝试将语音命令模型嵌入到扩展中,并调整它以识别“下一个标签页”和“上一个标签页”短语。使用分类器的结果来控制浏览器标签焦点。
-
信息框 12.3 描述了正确测量 TensorFlow.js 模型的
predict()调用(推断调用)所需时间以及涉及的注意事项。在这个练习中,加载一个 TensorFlow.js 中的 MobileNetV2 模型(如果需要提醒如何做到这一点,请参见 5.2 节 中的简单对象检测示例),并计时其predict()调用:-
作为第一步,生成一个形状为
[1, 224, 224, 3]的随机值图像张量,并按照信息框 12.3 中的步骤对其进行模型推断。将结果与输出张量上的array()或data()调用进行比较。哪一个更短?哪一个是正确的时间测量? -
当正确的测量在循环中执行 50 次时,使用 tfjs-vis 折线图(第七章)绘制单独的时间数字,并直观地了解可变性。你能清楚地看到前几次测量与其余部分明显不同吗?鉴于这一观察结果,讨论在性能基准测试期间执行 burn-in 或预热运行的重要性。
-
与任务 a 和 b 不同,将随机生成的输入张量替换为真实的图像张量(例如,使用
tf.browser.fromPixels()从img元素获取的图像张量),然后重复步骤 b 中的测量。输入张量的内容是否对时间测量产生任何重大影响? -
不要在单个示例(批量大小 = 1)上运行推断,尝试将批量大小增加到 2、3、4 等,直到达到相对较大的数字,例如 32。平均推断时间与批量大小之间的关系是单调递增的吗?是线性的吗?
-
概要
-
对于机器学习代码,良好的工程纪律围绕测试同样重要,就像对非机器学习代码一样重要。然而,避免过分关注“特殊”示例或对“黄金”模型预测进行断言的诱惑。相反,依靠测试模型的基本属性,如其输入和输出规范。此外,请记住,机器学习系统之前的所有数据预处理代码都只是“普通”代码,应该相应地进行测试。
-
优化下载速度和推断速度是 TensorFlow.js 模型客户端部署成功的重要因素。 使用
tensorflowjs_converter二进制文件的后训练权重量化功能,您可以减小模型的总大小,在某些情况下,无需观察到推断精度的损失。tensorflowjs_converter的图模型转换功能可通过操作融合等图转换来加速模型推断。 在部署 TensorFlow.js 模型到生产环境时,强烈建议您测试和采用这两种模型优化技术。 -
经过训练和优化的模型并不是您机器学习应用程序的终点。 您必须找到一种方法将其与实际产品集成。 TensorFlow.js 应用程序最常见的部署方式是在网页中,但这只是各种部署方案中的一个,每种部署方案都有其自身的优势。 TensorFlow.js 模型可以作为浏览器扩展程序运行,在原生移动应用程序中运行,作为原生桌面应用程序运行,甚至在树莓派等单板硬件上运行。
第十四章:总结、结论和展望
本章包括
-
回顾人工智能和深度学习的高层次概念和想法
-
我们在本书中访问的不同类型的深度学习算法的快速概述,它们何时有用,以及如何在 TensorFlow.js 中实现它们
-
来自 TensorFlow.js 生态系统的预训练模型
-
深度学习当前存在的局限性;以及我们将在未来几年看到的深度学习趋势的教育预测
-
如何进一步提升你的深度学习知识并跟上这个快速发展的领域的指导
这是本书的最后一章。之前的章节是对当前深度学习领域的总体概况,通过 TensorFlow.js 和你自己的努力实现。通过这段旅程,你可能已经学到了很多新的概念和技能。现在是时候再次退后一步,重新审视全局,并对你学到的一些最重要的概念进行复习。这最后一章将总结和审查核心概念,同时将你的视野扩展到迄今为止学到的相对基本的概念之外。我们希望确保你意识到这一点,并且准备好自己继续旅程的下一步。
我们将从鸟瞰视角开始,总结你应该从这本书中学到的东西。这应该让你记起你学到的一些概念。接下来,我们将概述深度学习的一些关键局限性。要正确使用工具,你不仅应该知道它 能 做什么,还应该知道它 不能 做什么。本章以资源列表和进一步了解深度学习和 JavaScript 生态系统中人工智能知识和技能的策略结束,并保持与新发展的步伐同步。
13.1. 复习重点概念
这一部分简要总结了这本书的关键要点。我们将从人工智能领域的整体格局开始,以为什么将深度学习和 JavaScript 结合起来会带来独特而令人兴奋的机遇而结束。
13.1.1. 人工智能的各种方法
首先,深度学习与人工智能甚至与机器学习并不是同义词。 人工智能 是一个历史悠久的广泛领域。它通常可以定义为“所有试图自动化认知过程”的尝试——换句话说,思维的自动化。这可以从非常基本的任务,如 Excel 电子表格,到非常高级的努力,如一个可以行走和说话的类人机器人。
机器学习 是人工智能的许多子领域之一。它旨在通过暴露给训练数据自动开发程序(称为 模型)。这个将数据转化为程序(模型)的过程被称为 学习。尽管机器学习已经存在了很长一段时间(至少有几十年了),但它直到 1990 年代才开始在实际应用中蓬勃发展。
深度学习 是机器学习的众多形式之一。在深度学习中,模型由许多步骤的表示转换组成,依次应用(因此有形容词“深度”)。这些操作被结构化为称为 层 的模块。深度学习模型通常是许多层的堆栈或更一般地说是许多层的图。这些层由 权重 参数化,数字值有助于将层的输入转换为其输出,并在训练过程中更新。模型在训练过程中学到的“知识”体现在其权重中。训练过程主要是为这些权重找到一个良好的值。
尽管深度学习只是机器学习的众多方法之一,但与其他方法相比,它已被证明是一个突破性的成功。让我们快速回顾一下深度学习成功背后的原因。
13.1.2. 使深度学习在机器学习子领域中脱颖而出的原因
仅仅在几年的时间里,深度学习在多个历史上被认为对计算机极其困难的任务上取得了巨大突破,特别是在机器感知领域——即从图像、音频、视频和类似感知数据中提取有用信息的能力,具有足够高的准确性。如果有足够的训练数据(特别是标记的训练数据),现在可以从感知数据中提取几乎任何人类可以提取的东西,有时甚至准确度超过人类。因此,有时说深度学习在很大程度上“解决了感知”问题,尽管这只适用于对感知的一个相当狭义的定义(参见 第 13.2.5 节 以了解深度学习的局限性)。
由于其空前的技术成功,深度学习独自引发了第三次,迄今为止最大的 AI 夏季,也被称为 深度学习革命,这是人工智能领域的一个充满兴趣、投资和炒作的时期。这一时期是否会在不久的将来结束,以及之后会发生什么,是人们猜测和讨论的话题。但有一点是确定的:与以往的 AI 夏季形成鲜明对比,深度学习为许多技术公司提供了巨大价值,实现了人类级别的图像分类、目标检测、语音识别、智能助手、自然语言处理、机器翻译、推荐系统、自动驾驶汽车等。炒作可能会减退(理所当然),但深度学习的持续技术影响和经济价值将会保持。从这个意义上说,深度学习可能类似于互联网:可能在几年内被过度炒作,导致不合理的期望和过度投资,但从长远来看,它将成为一个影响技术许多领域并改变我们生活的重大革命。
我们对深度学习特别乐观,因为即使在未来十年内我们在其中不再取得进一步的学术进展,将现有的深度学习技术应用于每一个适用的实际问题仍将改变许多行业的游戏规则(在线广告、金融、工业自动化和残疾人辅助技术,只是列举了一部分)。深度学习无疑是一场革命,目前的进展速度之快令人难以置信,这要归功于资源和人员的指数级投资。从我们的角度来看,未来看起来很光明,尽管短期内的预期可能有些过于乐观;充分发挥深度学习的潜力将需要超过十年的时间。
13.1.3. 怎样从高层次上思考深度学习
深度学习最令人惊讶的一个方面是它的简单性,考虑到它的工作效果以及之前的更复杂的机器学习技术的效果并不如人意。十年前,没有人预料到我们能够仅通过使用梯度下降训练的参数模型在机器感知问题上取得如此惊人的结果。现在事实证明,你只需要足够大的通过梯度下降训练的参数模型以及足够多的带标签示例。正如理查德·费曼曾经关于他对宇宙的理解所说:“这并不复杂,只是有很多。”^([1])
¹
理查德·费曼,采访,“另一种视角下的世界”,约克郡电视台,1972 年。
在深度学习中,一切都被表示为一系列数字——换句话说,是一个向量。一个向量可以被看作是几何空间中的一个点。模型输入(表格数据、图像、文本等)首先被向量化,或者转换为输入向量空间中的一组点。同样地,目标(标签)也被向量化,并转换为其相应的目标向量空间中的一组点。然后,深度神经网络中的每一层对通过它的数据执行一个简单的几何变换。在一起,神经网络中的层链形成了一个由一系列简单的几何变换组成的复杂几何变换。这个复杂的变换试图将输入向量空间中的点映射到目标向量空间中的点。这个变换由层的权重参数化,这些权重根据当前变换的好坏进行迭代更新。这个几何变换的一个关键特征是它是可微的:这就是梯度下降变得可能的原因。
13.1.4. 深度学习的关键技术
正在进行的深度学习革命并非一夜之间开始。相反,和其他任何革命一样,它是一系列因素的积累——一开始缓慢,然后一旦到达关键点就突然加速。就深度学习而言,我们可以指出以下关键因素:
-
渐进式的算法创新,首先涉及两个十年,然后随着深度学习在 2012 年之后投入更多研究力量而加速发展。
²
起始于 Rumelhart、Hinton 和 Williams 的反向传播算法,LeCun 和 Bengio 的卷积层,以及 Graves 和 Schmidthuber 的循环网络。
³
例如,改进的权重初始化方法,新的激活函数,dropout,批量归一化,残差连接。
-
大量的标记数据可用,覆盖许多数据模式,包括知觉(图像、音频和视频)、数字和文本,这使大型模型可以在足够数量的数据上进行训练。这是消费互联网兴起的副产品,由流行的移动设备推动,以及存储介质中的摩尔定律。
-
快速、高度并行化的计算硬件以低成本提供,尤其是 NVIDIA 生产的 GPU——首先是为并行计算重新用途的游戏 GPU,然后是从头设计用于深度学习的芯片。
-
复杂的开源软件堆栈使许多人类开发者和学习者可以使用这种计算能力,同时隐藏了庞大的复杂性:CUDA 语言、WebGL 着色器语言以及框架,如 TensorFlow.js、TensorFlow 和 Keras,其执行自动差分并提供易于使用的高级搭建块,如层、损失函数和优化器。深度学习正在从专家领域(研究人员、AI 研究生和具有学术背景的工程师)转变为每位程序员的工具。TensorFlow.js 在这方面是一个典范性的框架。它将两个丰富活跃的生态系统结合在一起:JavaScript 跨平台生态系统和快速发展的深度学习生态系统。
深度学习革命的广泛和深远影响之一是它与其他不同于它的技术栈(如 C++ 和 Python 生态系统和数字计算领域)的融合。它与 JavaScript 生态系统的跨界融合是其中的一个典型例子。在接下来的部分,我们将回顾为什么将深度学习引入 JavaScript 世界将开启令人兴奋的新机遇和可能性的关键原因。
13.1.5 用 JavaScript 实现深度学习的应用与机遇
训练深度学习模型的主要目的是使其可供用户使用。对于许多类型的输入模态,例如来自网络摄像头的图像、来自麦克风的声音以及用户输入的文本和手势输入,数据是由客户端生成并直接可用的。JavaScript 或许是最成熟和普及的客户端编程语言和生态系统。用 JavaScript 编写的相同代码可以部署为网页和 UI,在各种设备和平台上运行。Web 浏览器的 WebGL API 允许在各种 GPU 上进行跨平台并行计算,TensorFlow.js 利用了这一点。这些因素使 JavaScript 成为部署深度学习模型的一种吸引人的选择。TensorFlow.js 提供了一个转换工具,允许您将使用流行的 Python 框架(如 TensorFlow 和 Keras)训练的模型转换为适合 Web 的格式,并将其部署到网页上进行推理和迁移学习。
除了部署的便利性之外,使用 JavaScript 提供服务和微调深度学习模型还有许多其他优势:
-
与服务器端推理相比,客户端推理放弃了双向数据传输的延迟,有利于可用性,并带来更流畅的用户体验。
-
通过使用设备 GPU 加速在边缘执行计算,客户端深度学习消除了管理服务器端 GPU 资源的需要,显着降低了技术堆栈的复杂性和维护成本。
-
通过保持数据和推理结果在客户端,用户的数据隐私得到了保护。这对于医疗保健和时尚等领域至关重要。
-
浏览器和其他基于 JavaScript 的 UI 环境的视觉和交互性为神经网络的可视化、辅助理解和教学提供了独特的机会。
-
TensorFlow.js 不仅支持推理,还支持训练。这为客户端迁移学习和微调打开了大门,从而实现了机器学习模型的更好个性化。
-
在 Web 浏览器中,JavaScript 提供了一个独立于平台的 API,用于访问设备传感器,如网络摄像头和麦克风,这加速了使用这些传感器输入的跨平台应用程序的开发。
除了其客户端的卓越性能,JavaScript 还将其能力扩展到服务器端。特别是,Node.js 是 JavaScript 中高度流行的用于服务器端应用的框架。使用 Node.js 版本的 TensorFlow.js(tfjs-node),您可以在网页浏览器之外的环境中训练和提供深度学习模型,因此不受资源限制。这利用了 Node.js 的庞大生态系统,并简化了社区成员的技术堆栈。所有这些都可以通过使用与您为客户端编写的基本相同的 TensorFlow.js 代码来实现,这使您更接近“一次编写,到处运行”的愿景,正如本书中的几个示例所示。
13.2. TensorFlow.js 中深度学习工作流程和算法的快速概述
通过历史概述,让我们现在来看看 TensorFlow.js 的技术方面。在本节中,我们将回顾当你面对一个机器学习问题时应该遵循的一般工作流程,并强调一些最重要的考虑因素和常见陷阱。然后,我们将过一下我们在书中涵盖的各种神经网络构建块(层)。此外,我们将调查 TensorFlow.js 生态系统中的预训练模型,这些模型可以加速您的开发周期。为了结束本节,我们将介绍您可以通过使用这些构建块解决的一系列机器学习问题,激励您想象使用 TensorFlow.js 编写的深度神经网络如何帮助您解决自己的机器学习问题。
13.2.1. 监督深度学习的通用工作流程
深度学习是一个强大的工具。但或许有些令人惊讶的是,机器学习工作流程中最困难和耗时的部分通常是设计和训练这些模型之前的一切(以及对于部署到生产环境的模型来说,还有之后的一切)。这些困难的步骤包括充分了解问题域,以便能够确定需要什么样的数据,以及可以以合理的准确性和泛化能力进行什么样的预测,机器学习模型如何适用于解决实际问题的整体解决方案中,以及如何度量模型在完成其工作时成功的程度。尽管这些是任何成功应用机器学习的先决条件,但它们不是像 TensorFlow.js 这样的软件库可以自动化的内容。作为提醒,以下是典型监督学习工作流程的快速摘要:
-
确定机器学习是否是正确的方法。首先,考虑一下机器学习是否是解决您的问题的正确方法,只有在答案是肯定的情况下才继续以下步骤。在某些情况下,非机器学习方法同样有效,甚至可能更好,成本更低。
-
定义机器学习问题。确定可用的数据类型以及您试图使用数据预测的内容。
-
检查您的数据是否足够。确定您已经拥有的数据量是否足以进行模型训练。您可能需要收集更多数据或雇用人员手动标记一个未标记的数据集。
-
确定一种可靠地衡量训练模型成功的方法。对于简单任务,这可能只是预测准确率,但在许多情况下,它将需要更复杂、特定于领域的度量标准。
-
准备评估过程。设计用于评估模型的验证过程。特别是,您应该将数据分成三个同质但不重叠的集合:一个训练集、一个验证集和一个测试集。验证集和测试集的标签不应泄漏到训练数据中。例如,对于时间预测,验证和测试数据应来自训练数据之后的时间间隔。您的数据预处理代码应该经过测试,以防止错误(第 12.1 节)。
-
将数据向量化。将您的数据转换为张量,或者n维数组——TensorFlow.js 和 TensorFlow 等框架中机器学习模型的通用语言。通常需要对张量化的数据进行预处理,以使其更适合您的模型(例如,通过归一化)。
-
超越常识基线。开发一个能够超越非机器学习基线的模型(例如,在回归问题中预测人口平均值或在时间序列预测问题中预测最后一个数据点),从而证明机器学习确实可以为您的解决方案增加价值。这并不总是可能的(见步骤 1)。
-
开发具有足够容量的模型。通过调整超参数和添加正则化逐渐完善模型架构。仅基于验证集的预测准确率进行更改,而不是基于训练集或测试集。请记住,您应该使您的模型过度拟合问题(在训练集上达到比验证集更好的预测准确率),从而确定一个比您需要的容量更大的模型容量。只有在那之后,您才应该开始使用正则化和其他方法来减少过拟合。
-
调整超参数。在调整超参数时要注意验证集的过拟合。因为超参数是基于验证集上的性能确定的,所以它们的值会过度专门化于验证集,因此可能不会很好地推广到其他数据。测试集的目的是在调整超参数后获得模型准确性的无偏估计。因此,在调整超参数时不应使用测试集。
-
验证和评估训练好的模型。正如我们在第 12.1 节中讨论的,用最新的评估数据集测试您的模型,并决定预测精度是否达到为实际用户服务的预定标准。此外,对模型在不同数据切片(子集)上的质量进行更深入的分析,旨在检测任何不公平行为(例如在不同数据切片上的精度差异)或不希望的偏差。^([4]) 只有当模型通过这些评估标准时,才进行最终步骤。
⁴
机器学习中的公平性是一个新兴的研究领域;更多讨论请参见以下链接
mng.bz/eD4Q。 -
优化和部署模型。对模型进行优化,以缩小其尺寸并提高其推理速度。然后将模型部署到服务环境中,如网页、移动应用程序或 HTTP 服务端点(第 12.3 节)。
这个教程是关于监督学习的,它在许多实际问题中都会遇到。本书涵盖的其他类型的机器学习工作流包括(监督)迁移学习、RL(强化学习)和生成式深度学习。监督迁移学习(第五章)与非迁移监督学习的工作流相同,唯一的区别是模型设计和训练步骤是基于预训练模型构建的,并且可能需要比从头开始训练模型更少的训练数据。生成式深度学习的目标与监督学习有所不同——即尽可能创建看起来像真实的假例。实际上,有一些技术将生成模型的训练转化为监督学习,就像我们在第九章中看到的 VAE 和 GAN 示例一样。另一方面,RL 包含一个根本不同的问题形式化,并因此具有截然不同的工作流程——其中主要参与者是环境、代理、行动、奖励结构以及用于解决问题的算法或模型类型。第十一章提供了 RL 中基本概念和算法的快速概述。
13.2.2. 回顾 TensorFlow.js 中的模型和层类型:快速参考
本书涵盖的所有众多神经网络可以分为三类系列:密集连接网络(也称为 MLPs 或多层感知器)、卷积网络和循环网络。这三种基本系列的网络是每个深度学习实践者都应该熟悉的。每种类型的网络适用于特定类型的输入:网络架构(MLP、卷积或循环)对输入数据的结构进行假设—通过反向传播和超参数调整来搜索好模型的假设空间。给定问题是否适用于给定架构完全取决于数据中的结构与网络架构的假设匹配得有多好。
这些不同类型的网络可以像积木一样轻松地组合成更复杂和多模态的网络。从某种意义上说,深度学习层是可微分信息处理的积木。我们快速概述输入数据的模态与适当网络架构之间的映射:
-
向量数据(没有时间序列或串行顺序)—MLPs(密集
layers) -
图像数据(黑白、灰度或彩色)—2D 卷积网络
-
音频数据作为频谱图—2D 卷积网络或 RNNs
-
文本数据—1D 卷积网络或 RNNs
-
时间序列数据—1D 卷积网络或 RNNs
-
体积数据(例如 3D 医学图像)—3D 卷积网络
-
视频数据(图像序列)—要么是 3D 卷积网络(如果你需要捕捉运动效果),要么是一个逐帧 2D 卷积网络用于特征提取,随后是一个 RNN 或 1D 卷积网络来处理生成的特征序列的组合
现在让我们深入了解每个主要架构系列,它们擅长的任务以及如何通过 TensorFlow.js 使用它们。
密集连接网络和多层感知器
密集连接网络和多层感知机这两个术语在很大程度上可以互换使用,但要注意密集连接网络可以只包含一个层,而多层感知机必须至少包含一个隐藏层和一个输出层。为了简洁起见,我们将使用MLP这个术语来指代主要由密集层构建的所有模型。此类网络专门用于无序向量数据(例如,在钓鱼网站检测问题和房价预测问题中的数值特征)。每个密集层试图对输入特征的所有可能的对和该层的输出激活之间的关系进行建模。这通过密集层的核与输入向量之间的矩阵乘法(然后加上偏差向量和激活函数)来实现。每个输出激活受到每个输入特征的影响是这些层和建立在它们上面的网络被称为密集连接(或被一些作者称为完全连接)的原因。这与其他类型的架构(卷积网络和循环神经网络)形成对比,在这些架构中,一个输出元素仅依赖于输入数据中的一部分元素。
MLP 最常用于分类数据(例如,输入特征是属性列表,比如在钓鱼网站检测问题中)。它们也经常被用作大多数分类和回归神经网络的最终输出阶段,这些网络可以包含卷积或循环层作为特征提取器,将特征输入馈送到这样的 MLP 中。例如,我们在第四章和第五章中介绍的二维卷积网络都以一个或两个密集层结尾,我们在第九章中介绍的循环网络也是如此。
让我们简要回顾一下在监督学习中如何选择 MLP 的输出层激活函数以适应不同类型的任务。要进行二元分类,你的 MLP 的最后一个密集层应该正好有一个单元,并使用 sigmoid 激活函数。在训练这样一个二元分类器 MLP 时,应该使用binaryCrossentropy作为损失函数。你的训练数据中的例子应该有二元标签(0 或 1 的标签)。具体来说,TensorFlow.js 的代码如下:
import * as tf from '@tensorflow/tfjs';
const model = tf.sequential();
model.add(tf.layers.dense({units: 32, activation: 'relu', inputShape:
[numInputFeatures]}));
model.add(tf.layers.dense({units: 32, activation: 'relu'}));
model.add(tf.layers.dense({units: 1: activation: 'sigmoid'}));
model.compile({loss: 'binaryCrossentropy', optimizer: 'adam'});
要进行单标签多类别分类(其中每个例子在多个候选类别中只有一个类别),请在你的层堆叠的末尾加上一个包含 softmax 激活函数的密集层,单位数量等于类别的数量。如果你的目标是独热编码,请使用categoricalCrossentropy作为损失函数;如果它们是整数索引,请使用sparseCategoricalCrossentropy。例如:
const model = tf.sequential();
model.add(tf.layers.dense({units: 32, activation: 'relu', inputShape:
[numInputFeatures]});
model.add(tf.layers.dense({units: 32, activation: 'relu'});
model.add(tf.layers.dense({units: numClasses: activation: 'softmax'});
model.compile({loss: 'categoricalCrossentropy', optimizer: 'adam'});
为了执行多标签多类别分类(每个示例可能具有多个正确类别),将你的层堆栈结束为一个包含 sigmoid 激活和单位数量等于所有候选类别数量的密集层。使用binaryCrossentropy作为损失函数。你的目标应该是 k-hot 编码:
const model = tf.sequential();
model.add(tf.layers.dense({units: 32, activation: 'relu', inputShape:
[numInputFeatures]}));
model.add(tf.layers.dense({units: 32, activation: 'relu'}));
model.add(tf.layers.dense({units: numClasses: activation: 'sigmoid'}));
model.compile({loss: 'binaryCrossentropy', optimizer: 'adam'});
为了对连续值向量执行回归,将你的层堆栈结束为一个具有单位数量等于你尝试预测的值数量(通常只有一个数字,比如房价或温度值)的密集层,并使用默认的线性激活函数。多个损失函数可用于回归。最常用的是meanSquaredError和meanAbsoluteError:
const model = tf.sequential();
model.add(tf.layers.dense({units: 32, activation: 'relu', inputShape:
[numInputFeatures]}));
model.add(tf.layers.dense({units: 32, activation: 'relu'}));
model.add(tf.layers.dense({units: numClasses}));
model.compile({loss: 'meanSquaredError', optimizer: 'adam'});
卷积网络
卷积层通过将相同的几何变换应用于输入张量中的不同空间位置(补丁)来查看局部空间模式。这导致了是平移不变的表示,使卷积层高度数据有效和模块化。这个想法适用于任何维度的空间:1D(序列),2D(图像或类似于非图像数量的表示,如声音频谱图),3D(体积)等等。你可以使用tf.layers.conv1d层来处理序列,使用 conv2d 层来处理图像,使用 conv3d 层来处理体积。
卷积网络由一系列卷积和池化层组成。池化层允许你对数据进行空间降采样,这对于保持特征图的合理大小是必需的,因为特征数量增加,同时也允许后续层“看到”卷积网络输入图像的更大空间范围。卷积网络通常以展平层或全局池化层结束,将空间特征图转换为向量,然后可以通过一系列密集层(MLP)处理以实现分类或回归输出。
很可能常规卷积很快就会被一个等价但更快更高效的替代品所取代(或完全取代):深度可分离卷积(tf.layers.separableConv2d层)。当你从头开始构建一个网络时,强烈推荐使用深度可分离卷积。可分离卷积层可以用作tf.layers .conv2d的即插即用替代品,结果是一个更小更快的网络,在其任务上表现同样好或更好。以下是一个典型的图像分类网络(单标签多类别分类,在本例中)。其拓扑结构包含重复的卷积-池化层组的模式:
const model = tf.sequential();
model.add(tf.layers.separableConv2d({
filters: 32, kernelSize: 3, activation: 'relu',
inputShape: [height, width, channels]}));
model.add(tf.layers.separableConv2d({
filters: 64, kernelSize: 3, activation: 'relu'}));
model.add(tf.layers.maxPooling2d({poolSize: 2}));
model.add(tf.layers.separableConv2d({
filters: 64, kernelSize: 3, activation: 'relu'}));
model.add(tf.layers.separableConv2d({
filters: 128, kernelSize: 3, activation: 'relu'}));
model.add(tf.layers.maxPooling2d({poolSize: 2}));
model.add(tf.layers.separableConv2d({
filters: 64, kernelSize: 3, activation: 'relu'}));
model.add(tf.layers.separableConv2d({
filters: 128, kernelSize: 3, activation: 'relu'}));
model.add(tf.layers.globalAveragePooling2d());
model.add(tf.layers.dense({units: 32, activation: 'relu'}));
model.add(tf.layers.dense({units: numClasses, activation: 'softmax'}));
model.compile({loss: 'categoricalCrossentropy', optimizer: 'adam'});
循环网络
RNN 通过一次处理一个时间戳的输入序列并始终保持状态来工作。状态通常是一个向量或一组向量(几何空间中的一个点)。在不是时间上不变的序列(例如,时间序列数据,其中最近的过去比遥远的过去更重要)的情况下,应优先使用 RNN,而不是 1D 卷积网络。
TensorFlow.js 提供了三种 RNN 层类型:simpleRNN、GRU 和 LSTM。对于大多数实际目的,您应该使用 GRU 或 LSTM。LSTM 是这两者中更强大的,但也更消耗计算资源。您可以将 GRU 视为 LSTM 的简化和更便宜的替代品。
为了将多个 RNN 层堆叠在一起,除了最后一层之外的每一层都需要配置为返回其输出的完整序列(每个输入时间步对应一个输出时间步)。如果不需要堆叠 RNN 层,通常 RNN 层只需返回最后一个输出,其中包含有关整个序列的信息。
以下是使用单个 RNN 层与密集层一起执行向量序列的二进制分类的示例:
const model = tf.sequential();
model.add(tf.layers.lstm({
units: 32,
inputShape: [numTimesteps, numFeatures]
}));
model.add(tf.layers.dense({units: 1, activation: 'sigmoid'}));
model.compile({loss: 'binaryCrossentropy', optimizer: 'rmsprop'});
接下来是一个带有 RNN 层堆叠的模型,用于向量序列的单标签多类别分类:
const model = tf.sequential();
model.add(tf.layers.lstm({
units: 32,
returnSequences: true,
inputShape: [numTimesteps, numFeatures]
}));
model.add(tf.layers.lstm({units: 32, returnSequences: true}));
model.add(tf.layers.lstm({units: 32}));
model.add(tf.layers.dense({units: numClasses, activation: 'softmax'}));
model.compile({loss: 'categoricalCrossentropy', optimizer: 'rmsprop'});
用于减轻过拟合和提高收敛性的层和正则化器
除了上述主要的基本层类型之外,还有一些其他类型的层适用于广泛的模型和问题类型,并协助训练过程。没有这些层,许多机器学习任务的最新准确性不会像今天这样高。例如,dropout 和 batchNormalization 层经常插入到 MLP、卷积网络和 RNN 中,以帮助模型在训练过程中更快地收敛并减少过拟合。以下示例显示了包含 dropout 层的回归 MLP:
const model = tf.sequential();
model.add(tf.layers.dense({
units: 32,
activation: 'relu',
inputShape: [numFeatures]
}));
model.add(tf.layers.dropout({rate: 0.25}));
model.add(tf.layers.dense({units: 64, activation: 'relu'}));
model.add(tf.layers.dropout({rate: 0.25}));
model.add(tf.layers.dense({units: 64, activation: 'relu'}));
model.add(tf.layers.dropout({rate: 0.25}));
model.add(tf.layers.dense({
units: numClasses,
activation: 'categoricalCrossentropy'
}));
model.compile({loss: 'categoricalCrossentropy', optimizer: 'rmsprop'});
13.2.3. 从 TensorFlow.js 使用预训练模型
当您要解决的机器学习问题特定于您的应用程序或数据集时,从头开始构建和训练模型是正确的方法,而 TensorFlow.js 使您能够做到这一点。然而,在某些情况下,您面临的问题是通用的,存在预训练模型,这些模型要么完全符合您的要求,要么只需进行轻微调整即可满足您的需求。来自 TensorFlow.js 和第三方开发人员的预训练模型集合。这些模型提供了干净且易于使用的 API。它们还作为 npm 包打包得很好,您可以方便地依赖它们在您的 JavaScript 应用程序(包括 Web 应用程序和 Node.js 项目)中。
在适当的使用案例中使用这些预训练模型可以大大加快您的开发速度。由于不可能列出所有基于 TensorFlow.js 的预训练模型,因此我们只会调查我们所知道的最流行的那些。以@tensorflow-models/为前缀的软件包是由 TensorFlow.js 团队维护的第一方软件包,而其余的是第三方开发者的工作。
@tensorflow-models/mobilenet 是一个轻量级图像分类模型。它能够根据输入图像输出 1,000 个 ImageNet 类别的概率分数。它适用于在网页中为图像标记、从网络摄像头输入流中检测特定内容,以及涉及图像输入的迁移学习任务。虽然@tensorflow-models/mobilenet 关注通用的图像类别,但也有第三方软件包用于更具特定领域的图像分类。例如,nsfwjs 将图像分类为包含色情和其他不当内容与安全内容,这对家长监控、安全浏览等应用非常有用。
如我们在第五章中讨论的那样,目标检测与图像分类不同之处在于,它不仅输出图像中包含的物体,还输出它们在图像坐标系中的位置。@tensorflow-models/coco-ssd 是一个能够检测 90 种对象的目标检测模型。对于每个输入图像,它都能够检测出可能有重叠边界框的多个目标对象(图 13.1,A 面)。
图 13.1。几个使用 TensorFlow.js 构建的预训练 npm 软件包模型的屏幕截图。A 面:@tensorflow-models/coco-ssd 是一个多目标对象检测器。B 面:face-api.js 用于实时人脸和面部特征点检测(通过 Vincent Mühler 的许可从github.com/justadudewhohacks/face-api.js复制)。C 面:handtrack.js 实时跟踪一个或两只手的位置(通过 Victor Dibia 的许可从github.com/victordibia/handtrack.js/复制)。D 面:@tensorflow-models/posenet 使用实时图像输入检测人体的骨骼关键点。E 面:@tensorflow-models/toxicity 检测并标记任何英文文本输入中的七种不当内容。

对于网络应用程序,特定类型的对象特别受到关注,因为它们有可能实现新颖有趣的计算机人类交互。这些包括人脸、手和整个身体。针对每一种类型,都有基于 TensorFlow.js 的专门的第三方模型。对于人脸,face-api.js 和 handsfree 都支持实时人脸跟踪和检测面部特征点(如眼睛或嘴巴;图 13.1,面板 B)。对于手部,handtrack.js 可以实时跟踪一个或两只手的位置(图 13.1,面板 C)。对于整个身体,@tensorflow-models/posenet 实现了高精度、实时的骨架关键点检测(如肩膀、肘部、臀部和膝盖;图 13.1,面板 D)。
对于音频输入模态,@tensorflow-models/speech-commands 提供了一个预训练模型,可以实时检测浏览器的 WebAudio API 中的 18 个英文单词。虽然这不像大词汇连续语音识别那样强大,但它仍然可以在浏览器中实现一系列基于语音的用户交互。
对于文本输入,也有预训练模型可用。例如,来自@tensorflow-models/toxicity 的模型确定给定的英文输入文本在多个维度上的毒性程度(例如,威胁、侮辱或淫秽),这对于辅助内容审核很有用(图 13.1,面板 E)。毒性模型是建立在一个更通用的自然语言处理模型@tensorflow-models/universal-sentence-encoder 之上的,该模型将任何给定的英文句子映射到一个向量,然后可以用于广泛的自然语言处理任务,如意图分类、主题分类、情感分析和问题回答。
需要强调的是,提到的一些模型不仅支持简单的推理,还可以为迁移学习或下游机器学习提供基础,使您能够将这些预训练模型的强大功能应用于您的领域特定数据,而无需进行冗长的模型构建或训练过程。这在一定程度上是由于层和模型的乐高式可组合性。例如,通用句子编码器的输出主要用于下游模型。语音命令模型内置支持您收集新单词类别的语音样本,并基于样本训练一个新的分类器,这对于需要自定义词汇或用户特定语音适应的语音命令应用非常有用。此外,来自 PoseNet 和 face-api.js 等模型的有关头部、手部或身体姿势的时时位置的输出可以输入到一个下游模型中,该模型检测特定的手势或动作序列,这对于许多应用程序非常有用,如辅助使用案例的替代通信。
除了之前提到的面向输入模态的模型之外,还有基于 TensorFlow.js 的第三方预训练模型,面向艺术创造性。例如,ml5.js 包括一个用于图像之间的快速风格转移的模型,以及一个可以自动绘制素描的模型。@magenta/music 提供了一个可以将钢琴音乐转录成谱曲的模型(“音频转谱”),以及一个“旋律的语言模型”,可以根据几个种子音符“写”出旋律,还有其他有趣的预训练模型。
预训练模型的收集庞大且不断增长。JavaScript 社区和深度学习社区都拥有开放的文化和分享精神。随着你在深度学习的旅程中不断前行,你可能会遇到一些有趣且对其他开发者有用的新想法,此时,我们鼓励你将这些模型以我们提到的预训练模型的形式训练、打包并上传到 npm,然后与用户互动并对你的包进行迭代改进。那时,你将真正成为 JavaScript 深度学习社区的一员贡献者。
13.2.4. 可能性的空间
有了所有这些层和预训练模块作为构建模块,你可以构建出哪些有用且有趣的模型呢?记住,构建深度学习模型就像玩乐高积木一样:层和模块可以插在一起,将任何东西映射到任何东西,只要输入和输出表示为张量,并且层具有兼容的输入和输出张量形状。模型的结果层叠执行可微分几何变换,它可以学习输入和输出之间的映射关系,只要关系不过于复杂,以至于超出模型的容量。在这种范式中,可能性的空间是无限的。本节提供了一些示例,以激发你超越我们在本书中强调的基本分类和回归任务的思考。
我们已根据输入和输出模态对建议进行了排序。请注意,其中有不少都在可能性的边界上。虽然模型可以针对任何任务进行训练,只要有足够的训练数据可用,但在某些情况下,这样的模型可能无法很好地泛化远离其训练数据:
-
将向量映射到向量
-
预测性医疗保健——将患者医疗记录映射到预测的治疗结果
-
行为定位——将一组网站属性映射到潜在观众在网站上的行为(包括页面浏览、点击和其他互动)
-
产品质量控制——将与制造产品相关的一组属性映射到关于产品在市场上表现如何的预测(在市场的不同领域的销售和利润)
-
-
将图像映射到向量
-
医学图像人工智能——将医学图像(如 X 光片)映射到诊断结果
-
自动车辆转向—将来自摄像头的图像映射到车辆控制信号,如方向盘转向动作
-
饮食助手—将食物和菜肴的图像映射到预测的健康效应(例如,卡路里计数或过敏警告)
-
化妆品推荐—将自拍图像映射到推荐的化妆品
-
-
将时间序列数据映射到向量
-
脑机接口—将脑电图(EEG)信号映射到用户意图
-
行为定向—将产品购买的过去历史(例如电影或书籍购买)映射到未来购买其他产品的概率
-
地震和余震预测—将地震仪器数据序列映射到地震和随后余震发生的预测概率
-
-
将文本映射到向量
-
电子邮件分类器—将电子邮件内容映射到通用或用户定义的标签(例如,与工作相关的、与家庭相关的和垃圾邮件)
-
语法评分器—将学生写作样本映射到写作质量评分
-
基于语音的医疗分诊—将患者对疾病的描述映射到应该转诊给的医疗部门
-
-
将文本映射到文本
-
回复消息建议—将电子邮件映射到一组可能的响应消息
-
领域特定问答—将客户问题映射到自动回复文本
-
摘要—将长文章映射到简短摘要
-
-
将图像映射到文本
-
自动生成替代文本—给定一幅图像,生成捕捉内容要点的短文本片段
-
盲人移动辅助—将内部或外部环境的图像映射到口头指导和有关潜在移动障碍的警告(例如,出口和障碍物位置)
-
-
将图像映射到图像
-
图像超分辨率—将低分辨率图像映射到更高分辨率的图像
-
基于图像的三维重建—将普通图像映射到同一物体的图像,但从不同角度观察
-
-
将图像和时间序列数据映射到向量
- 医生的多模式助手—将患者的医学图像(例如 MRI)和生命体征历史(血压、心率等)映射到治疗结果的预测
-
将图像和文本映射到文本
- 基于图像的问答—将图像和与之相关的问题(例如,一辆二手车的图像和关于其品牌和年份的问题)映射到一个答案
-
将图像和向量映射到图像
- 服装和化妆品虚拟试穿—将用户的自拍和化妆品或服装的向量表示映射到用户穿着该产品的图像
-
将时间序列数据和向量映射到时间序列数据
- 音乐风格转换—将音乐谱(例如表示为音符时间序列的古典乐曲)和所需风格的描述(例如,爵士乐)映射到所需风格的新音乐谱
正如您可能已经注意到的,此列表中的最后四个类别涉及输入数据中的混合模态。在我们技术史上的这一时刻,生活中的大多数事物都已数字化,因此可以表示为张量,您可以通过深度学习潜在地实现的东西仅受限于您自己的想象力和训练数据的可用性。虽然几乎任何映射都是可能的,但并非每个映射都是。在下一节中,我们将讨论深度学习尚不能做到的事情。
13.2.5。深度学习的限制
可以使用深度学习实现的应用程序空间几乎是无限的。因此,很容易高估深度神经网络的力量,并对它们可以解决的问题过于乐观。本节简要讨论了它们仍然具有的一些限制。
神经网络并不以与人类相同的方式看待世界。
在尝试理解深度学习时,我们面临的一个风险是拟人化,即倾向于误解深度神经网络仿效人类感知和认知。在几个方面,将深度神经网络拟人化是明显错误的。首先,当人类感知到感官刺激(例如带有女孩脸的图像或带有牙刷的图像)时,他们不仅感知到输入的亮度和颜色模式,还提取由这些表面模式表示的更深层次和更重要的概念(例如,年轻女性个体的面孔或口腔卫生产品,以及两者之间的关系)。另一方面,深度神经网络不是这样工作的。当你训练了一个图像标题模型来将图像映射到文本输出时,认为该模型以人类意义理解图像是错误的。在某些情况下,即使是与训练数据中出现的图像类型稍有不同,也可能导致模型生成荒谬的标题(如图 13.2)。
图 13.2。使用深度学习训练的图像标题模型失败

特别是,深度神经网络处理输入的非人类方式被对抗样本突显出来,这些样本是特意设计的,目的是欺骗机器学习模型使其产生分类错误。正如我们在第 7.2 节中通过寻找激活最大的图像来为卷积神经网络滤波器找到的那样,可以在输入空间中进行梯度上升以最大化卷积神经网络滤波器的激活。这个想法可以扩展到输出概率,因此我们可以在输入空间中进行梯度上升,以最大化模型对任何给定输出类别的预测概率。通过给熊猫拍照并添加“长臂猿梯度”,我们可以使模型将图像误分类为长臂猿(图 13.3)。这尽管长臂猿梯度在噪声和幅度上都很小,因此导致的对抗性图像对人类来说看起来与原始熊猫图像无法区分。
图 13.3. 对抗样本:对人眼来说几乎无法察觉的改变可能会影响深度卷积神经网络的分类结果。有关深度神经网络对抗攻击的更多讨论,请参见mng.bz/pyGz。

因此,用于计算机视觉的深度神经网络并没有真正理解图像,至少不是以人类的方式。人类学习与深度学习在如何从有限数量的训练样本中泛化这两种学习方式之间存在鲜明对比的另一个领域。深度神经网络可以做到所谓的局部泛化。图 13.4 展示了一个场景,在这个场景中,深度神经网络和人类被要求仅使用少量(比如,八个)训练样本来学习二维参数空间中单个类别的边界。人类意识到类别边界的形状应该是平滑的,区域应该是连通的,并迅速绘制出一个闭合的曲线作为“猜测的”边界。另一方面,神经网络缺乏抽象和先验知识。因此,它可能会得到一个专门的、不规则的边界,严重过拟合于少量训练样本。训练好的模型将在训练样本之外泛化得非常差。增加更多的样本可以帮助神经网络,但这并不总是可行的。主要问题是神经网络是从零开始创建的,只为了解决这个特定的问题。与人类个体不同,它没有任何可以依赖的先验知识,因此不知道要“期望”什么。^([5])这是当前深度学习算法主要局限性的根本原因:通常需要大量的人工标记的训练数据才能训练出一个泛化准确度良好的深度神经网络。
⁵
有研究工作在训练单个深度神经网络上进行许多不同且看似无关的任务,以促进跨领域知识共享(参见,例如,Lukasz Kaiser 等人,“学习所有任务的一个模型”,2017 年 6 月 16 日提交,
arxiv.org/abs/1706.05137)。但是,这种多任务模型尚未被广泛采用。
图 13.4. 深度学习模型中的局部泛化与人类智能的极端泛化

13.3. 深度学习的趋势
正如我们讨论过的,深度学习在近年取得了惊人的进展,但仍然存在一些局限性。但这个领域并不是静止的;它以惊人的速度不断前进,因此很可能在不久的将来一些局限性会得到改善。本节包含了我们预计在未来几年将见证的深度学习重要突破的一系列合理猜测:
-
首先,无监督或半监督学习可能会有重大进展。这将对所有形式的深度学习产生深远影响,因为尽管标记数据集的构建成本高昂且难以获得,但在各种业务领域都有大量的未标记数据集。如果我们能够发明一种方法,利用少量标记数据来引导从大量未标记数据中学习,它将为深度学习开启许多新的应用。
-
其次,深度学习的硬件可能会继续改进,引入越来越强大的神经网络加速器(例如张量处理单元的未来一代^([6]))。这将使研究人员能够使用越来越大的数据集训练更加强大的网络,并继续推动计算机视觉、语音识别、自然语言处理和生成模型等许多机器学习任务的最新技术准确性。
⁶
Norman P. Jouppi 等人,“数据中心张量处理单元™的性能分析”,2017 年,
arxiv.org/pdf/1704.04760.pdf。 -
设计模型架构和调整模型超参数可能会变得越来越自动化。我们已经在这个领域看到了一个趋势,如 AutoML^([7]) 和 Google Vizier^([8]) 等技术的示例所示。
⁷
Barret Zoph 和 Quoc V. Le,“利用强化学习进行神经架构搜索”,2016 年 11 月 5 日提交,
arxiv.org/abs/1611.01578。⁸
Daniel Golovin,“Google Vizier:一种用于黑盒优化的服务”,2017 年第 23 届 ACM SIGKDD 国际知识发现与数据挖掘会议论文集,第 1487–1495 页,
mng.bz/O9yE。 -
神经网络组件的共享和重用可能会继续增长。基于预训练模型的迁移学习将进一步发展。每天都有最先进的深度学习模型变得越来越强大和通用。它们越来越多地在更大更大的数据集上进行训练,有时候为了自动化架构搜索和超参数调整而需要大量的计算资源(请参阅第一和第二条预测)。因此,与其一次又一次地从头开始训练它们,不如对这些预训练模型进行直接推断或迁移学习,这样做更加明智和经济。在某种程度上,这使得深度学习领域更类似于传统的软件工程,高质量的库被定期依赖和重用,这有利于整个领域的标准化和发展速度。
-
深度学习可能会部署到新的应用领域,改进许多现有解决方案,并开启新的实际应用案例。在我们看来,潜在的应用领域是真正无限的。农业、金融、教育、交通、医疗保健、时尚、体育和娱乐等领域提供了无数等待深度学习从业者探索的机会。
-
随着深度学习渗透到更多的应用领域,对边缘深度学习的重视可能会日益增加,因为边缘设备最接近用户所在地。因此,该领域可能会发明更小、更节能的神经网络架构,实现与现有更大模型相同的预测准确性和速度。
所有这些预测都将影响 JavaScript 中的深度学习,但最后三个预测尤为重要。可以预期未来 TensorFlow.js 将有更强大、更高效的模型可用。
13.4. 进一步探索的指针
最后,我们想给你一些关于在你翻阅完本书最后一页后如何继续学习和更新知识和技能的指导。尽管现代深度学习领域今天我们所知道的只有几年的历史,但它的漫长而缓慢的前史可以追溯到几十年前。自 2013 年以来,随着财政资源和研究人员数量的指数增长,整个领域现在正以疯狂的速度发展。你在本书中学到的很多东西不会保持很长时间的相关性。深度学习的核心思想(从数据中学习,减少手动特征工程,逐层转换表示)可能会更长时间地存在。更重要的是,通过阅读本书你所建立的知识基础将有望使你能够自己了解深度学习领域的新发展和趋势。幸运的是,这个领域有着开放的文化,其中大多数前沿进展(包括许多数据集!)都以公开可访问和免费的预印本的形式发布,附有公开的博客文章和推文。以下是您应该熟悉的一些顶级资源。
13.4.1. 在 Kaggle 上练习真实世界的机器学习问题
获得机器学习(特别是深度学习)的真实世界经验的有效方法是在 Kaggle 竞赛中尝试手气(kaggle.com)。学习机器学习的唯一真正方法是通过实际的编码、模型构建和调整。这正是本书的哲学,体现在其众多的代码示例中,供您学习、调整和修改。但没有什么比在地基础上使用 TensorFlow.js 等库从头开始构建你的模型和机器学习系统更有效的教你如何做机器学习了。在 Kaggle 上,你可以找到一系列不断更新的数据科学竞赛和数据集,其中许多涉及深度学习。
虽然大多数 Kaggle 用户使用 Python 工具(如 TensorFlow 和 Keras)来解决竞赛问题,但 Kaggle 上的大多数数据集都与语言无关。因此,完全有可能使用非 Python 深度学习框架(如 TensorFlow.js)解决大多数 Kaggle 问题。通过参加一些竞赛,也许作为团队的一部分,你将熟悉本书中描述的一些高级最佳实践的实际应用,尤其是超参数调整和避免验证集过拟合。
13.4.2. 阅读 arXiv 上的最新发展
与其他学术领域相比,深度学习研究几乎完全是公开进行的。论文一经完成并经过审查便会公开并免费提供,许多相关软件也是开源的。ArXiv(arxiv.org)—读作“archive”(X 代表希腊字母chi)—是一家数学、物理和计算机科学论文的开放获取预印本服务器。它成为了发表机器学习和深度学习领域最尖端工作的实际方式,因此也成为了学习该领域最新进展的实际方式。这使得该领域能够以极快的速度前进:所有新的发现和发明都能立即供所有人查阅、评论和建立在其基础之上。
ArXiv 的一个重要缺点是每天发布的新论文数量实在太多,以至于不可能都浏览一遍。ArXiv 上的许多论文没有经过同行评审,这使得它很难识别哪些是重要且高质量的。社区已经建立了工具来应对这些挑战。例如,一个名为 ArXiv Sanity Preserver(arxiv-sanity.com)的网站作为 ArXiv 新论文的推荐引擎,可以帮助您跟踪深度学习特定垂直领域(如自然语言处理或目标检测)的新发展。此外,您还可以使用 Google 学术跟踪您感兴趣的领域和您喜欢的作者的出版物。
13.4.3. 探索 TensorFlow.js 生态系统
TensorFlow.js 拥有充满活力且不断发展的生态系统,包括文档、指南、教程、博客圈和开源项目:
-
您使用 TensorFlow.js 的主要参考资料是官方在线文档,网址为www.tensorflow.org/js/。详细的最新 API 文档可在
js.tensorflow.org/api/latest/找到。 -
您可以在 Stack Overflow 上使用“tensorflow.js”标签提出有关 TensorFlow.js 的问题:
stackoverflow.com/questions/tagged/tensorflow.js。 -
有关该库的一般讨论,请使用 Google Group:
groups.google.com/a/tensorflow.org/forum/#!forum/tfjs。 -
您还可以关注在 Twitter 上活跃的 TensorFlow.js 团队成员,包括
最后的话
这就是JavaScript 深度学习的结尾!希望你在 AI、深度学习以及如何在 JavaScript 中使用 TensorFlow.js 执行一些基本的深度学习任务方面学到了一些东西。像任何有趣且有用的话题一样,学习 AI 和深度学习是一次终身的旅程。这同样适用于将 AI 和深度学习应用于实际问题。无论是专业人士还是业余爱好者都是如此。尽管在深度学习方面取得了许多进展,但大部分基本问题仍然没有得到答案,大部分深度学习的潜在威力也几乎没有得到发掘。请继续学习、质疑、研究、想象、探索、构建和分享!我们期待着看到你用深度学习和 JavaScript 构建的作品!
附录 A:安装 tfjs-node-gpu 及其依赖项
要在 Node.js 中使用 GPU 加速版的 TensorFlow.js(tfjs-node-gpu),你需要在你的计算机上安装 CUDA 和 CuDNN。首先,计算机应配备有支持 CUDA 的 NVIDIA GPU。要检查你的计算机中的 GPU 是否满足该要求,请访问 developer.nvidia.com/cuda-gpus。
接下来,我们列出了 Linux 和 Windows 上的驱动程序和库安装的详细步骤,因为这两个操作系统是目前支持 tfjs-node-gpu 的两个操作系统。
A.1. 在 Linux 上安装 tfjs-node-gpu
-
我们假设你已经在系统上安装了 Node.js 和 npm,并且 node 和 npm 的路径已包含在你的系统路径中。如果没有,请查看
nodejs.org/en/download/获取可下载的安装程序。 -
从
developer.nvidia.com/cuda-downloads下载 CUDA Toolkit。务必选择适合你打算使用的 tfjs-node-gpu 版本的适当版本。在撰写本文时,tfjs-node-gpu 的最新版本为 1.2.10,与 CUDA Toolkit 版本 10.0 兼容。此外,请确保选择正确的操作系统(Linux)、架构(例如,用于主流 Intel CPU 的 x86_64)、Linux 发行版和发行版的版本。你将有下载几种类型安装程序的选项。在这里,我们假设你下载了“runfile(local)”文件(而不是,例如,本地 .deb 包)以供后续步骤使用。 -
在你的下载文件夹中,使刚下载的 runfile 可执行。例如,
chmod +x cuda_10.0.130_410.48_linux.run -
使用
sudo来运行 runfile。注意,CUDA Toolkit 安装过程可能需要安装或升级 NVIDIA 驱动程序,如果您机器上已安装的 NVIDIA 驱动程序版本过旧或尚未安装此类驱动程序。如果是这种情况,你需要停止 X 服务器,转到仅 shell 模式。在 Ubuntu 和 Debian 发行版中,你可以使用快捷键 Ctrl-Alt-F1 进入仅 shell 模式。按照屏幕上的提示安装 CUDA Toolkit,然后重新启动机器。如果你在仅 shell 模式下,你可以重新启动回到正常的 GUI 模式。 -
如果步骤 3 完成正确,
nvidia-smi命令现在应该可在你的路径中使用了。你可以使用它来检查 GPU 的状态。它提供了有关安装在你的机器上的 NVIDIA GPU 的名称、温度传感器读数、风扇速度、处理器和内存使用情况,以及当前 NVIDIA 驱动程序版本的信息。当你使用 tfjs-node-gpu 训练深度神经网络时,它是一个方便的实时监视 GPU 的工具。nvidia-smi的典型输出信息如下(请注意,此机器上有两个 NVIDIA GPU):+-----------------------------------------------------------------------------+ | NVIDIA-SMI 384.111 Driver Version: 384.111 | |-------------------------------+----------------------+----------------------+ | GPU Name Persistence-M| Bus-Id Disp.A | Volatile Uncorr. ECC | | Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. | |===============================+======================+======================| | 0 Quadro P1000 Off | 00000000:65:00.0 On | N/A | | 41% 53C P0 ERR! / N/A | 620MiB / 4035MiB | 0% Default | +-------------------------------+----------------------+----------------------+ | 1 Quadro M4000 Off | 00000000:B3:00.0 Off | N/A | | 46% 30C P8 11W / 120W | 2MiB / 8121MiB | 0% Default | +-------------------------------+----------------------+----------------------+ +-----------------------------------------------------------------------------+ | Processes: GPU Memory | | GPU PID Type Process name Usage | |=============================================================================| | 0 3876 G /usr/lib/xorg/Xorg 283MiB | +-----------------------------------------------------------------------------+ -
将 64 位 CUDA 库文件的路径添加到你的
LD_LIBRARY_PATH环境变量中。假设你正在使用 bash shell,你可以将以下行添加到你的 .bashrc 文件中:export LD_LIBRARY_PATH="/usr/local/cuda/lib64:${PATH}"tfjs-node-gpu 在启动时使用
LD_LIBRARY_PATH环境变量来找到所需的动态库文件。 -
从
developer.nvidia.com/cudnn下载 CuDNN。为什么除了 CUDA 还需要 CuDNN 呢?这是因为 CUDA 是一个通用的计算库,除了深度学习之外还可以应用在其他领域(例如流体力学)。CuDNN 是 NVIDIA 基于 CUDA 构建的加速深度神经网络操作的库。NVIDIA 可能要求你创建一个登录账号并回答一些调查问题才能下载 CuDNN。一定要下载与之前步骤安装的 CUDA Toolkit 版本相匹配的 CuDNN 版本。例如,CuDNN 7.6 与 CUDA Toolkit 10.0 一起使用。 -
与 CUDA Toolkit 不同,下载的 CuDNN 没有可执行安装程序。相反,它是一个压缩的 tarball,其中包含了一些动态库文件和 C/C++头文件。这些文件应该被提取并复制到适当的目标文件夹中。你可以使用如下的一系列命令来实现这一点:
tar xzvf cudnn-10.0-linux-x64-v7.6.4.38.tgz cp cuda/lib64/* /usr/local/cuda/lib64 cp cuda/include/* /usr/local/cuda/include -
现在,所有必需的驱动程序和库都已安装完成,你可以通过在 node 中导入 tfjs-node-gpu 来快速验证 CUDA 和 CuDNN:
npm i @tensorflow/tfjs @tensorflow/tfjs-node-gpu node然后,在 Node.js 命令行界面上,
> const tf = require('@tensorflow/tfjs'); > require('@tensorflow/tfjs-node-gpu');如果一切顺利,你应该会看到一系列日志行,确认发现了一个(或多个,取决于你的系统配置)可以被 tfjs-node-gpu 使用的 GPU:
2018-09-04 13:08:17.602543: I tensorflow/core/common_runtime/gpu/gpu_device.cc:1405] Found device 0 with properties: name: Quadro M4000 major: 5 minor: 2 memoryClockRate(GHz): 0.7725 pciBusID: 0000:b3:00.0 totalMemory: 7.93GiB freeMemory: 7.86GiB 2018-09-04 13:08:17.602571: I tensorflow/core/common_runtime/gpu/gpu_device.cc:1484] Adding visible gpu devices: 0 2018-09-04 13:08:18.157029: I tensorflow/core/common_runtime/gpu/gpu_device.cc:965] Device interconnect StreamExecutor with strength 1 edge matrix: 2018-09-04 13:08:18.157054: I tensorflow/core/common_runtime/gpu/gpu_device.cc:971] 0 2018-09-04 13:08:18.157061: I tensorflow/core/common_runtime/gpu/gpu_device.cc:984] 0: N 2018-09-04 13:08:18.157213: I tensorflow/core/common_runtime/gpu/gpu_device.cc:1097] Created TensorFlow device (/job:localhost/replica:0/task:0/device:GPU:0 with 7584 MB memory) -> physical GPU (device: 0, name: Quadro M4000, pci bus id: 0000:b3:00.0, compute capability: 5.2) -
现在,你已经准备好使用 tfjs-node-gpu 的所有功能了。只需确保在你的 package.json 中包含以下依赖项(或其后续版本):
... "dependencies": { "@tensorflow/tfjs": "⁰.12.6", "@tensorflow/tfjs-node": "⁰.1.14", ... } ...在你的主要的 .js 文件中,确保导入基本的依赖项,包括
@tensorflow/tfjs和@tensorflow/tfjs-node-gpu。前者给你提供了 TensorFlow.js 的通用 API,而后者将 TensorFlow.js 操作与基于 CUDA 和 CuDNN 实现的高性能计算内核相连:const tf = require('@tensorflow/tfjs'); require('@tensorflow/tfjs-node-gpu');
A.2. 在 Windows 上安装 tfjs-node-gpu
-
确保您的 Windows 符合 CUDA Toolkit 的系统要求。某些 Windows 版本和 32 位机器架构不受 CUDA Toolkit 的支持。有关更多详情,请参阅
docs.nvidia.com/cuda/cuda-installation-guide-microsoft-windows/index.html#system-requirements。 -
我们假设您已在系统上安装了 Node.js 和 npm,并且 Node.js 和 npm 的路径在您系统的环境变量
Path中可用。如果没有,请访问nodejs.org/en/download/下载安装程序。 -
安装 Microsoft Visual Studio,因为它是安装 CUDA Toolkit 所必需的。请参阅步骤 1 中相同的链接,以了解应安装哪个版本的 Visual Studio。
-
下载并安装 Windows 版 CUDA Toolkit。在撰写本文时,运行 tfjs-node-gpu 需要 CUDA 10.0(最新版本:1.2.10)。请务必为您的 Windows 发行版选择正确的安装程序。支持 Windows 7 和 Windows 10 的安装程序。此步骤需要管理员权限。
-
下载 CuDNN。确保 CuDNN 的版本与 CUDA 的版本匹配。例如,CuDNN 7.6 与 CUDA Toolkit 10.0 匹配。在下载 CuDNN 之前,NVIDIA 可能要求您在其网站上创建帐户并回答一些调查问题。
-
与 CUDA Toolkit 安装程序不同,您刚下载的 CuDNN 是一个压缩文件。解压它,您将看到其中有三个文件夹:cuda/bin、cuda/include 和 cuda/lib/x64。找到 CUDA Toolkit 安装的目录(默认情况下,它类似于 C:/Program Files/NVIDIA CUDA Toolkit 10.0/cuda)。将解压后的文件复制到那里相应名称的子文件夹中。例如,解压的 zip 存档中的 cuda/bin 中的文件应复制到 C:/Program Files/NVIDIA CUDA Toolkit 10.0/cuda/bin。此步骤可能还需要管理员权限。
-
安装 CUDA Toolkit 和 CuDNN 后,请重新启动 Windows 系统。我们发现这对于所有新安装的库都能正确加载以供 tfjs-node-gpu 使用是必要的。
-
安装 npm 包
window-build-tools。这是下一步安装 npm 包@tensorflow/tfjs-node-gpu所必需的:npm install --add-python-to-path='true' --global windows-build-tools -
使用 npm 安装包
@tensorflow/tfjs和@tensorflow/tfjs-node-gpu:npm -i @tensorflow/tfjs @tensorflow/tfjs-node-gpu -
要验证安装是否成功,请打开节点命令行并运行
> const tf = require('@tensorflow/tfjs'); > require('@tensorflow/tfjs-node-gpu');确保这两个命令都能顺利完成。在第二个命令之后,您应该在控制台中看到一些由 TensorFlow GPU 共享库打印的日志行。这些行将列出 tfjs-node-gpu 已识别并将在后续深度学习程序中使用的 CUDA 启用的 GPU 的详细信息。
附录 B:TensorFlow.js 中张量和操作的快速教程
本附录重点介绍 TensorFlow.js API 中与非tf.Model部分有关的内容。虽然tf.Model提供了一套完整的方法来训练和评估模型,并将其用于推理,但通常需要使用 TensorFlow.js 中的非tf.Model部分来处理tf.Model对象。最常见的情况是
-
将您的数据转换为可供
tf.Model对象输入的张量 -
将
tf.Model所做的预测数据转换为张量格式,以便其他程序部分可以使用
正如您将看到的,将数据放入和取出张量并不困难,但有一些传统模式和值得注意的点值得指出。
B.1. 张量的创建和张量轴约定
请记住,张量只是一个数据容器。每个张量都有两个基本属性:数据类型(dtype)和形状。dtype 控制张量中存储的值的类型。给定张量只能存储一种类型的值。截至本文撰写时(版本 0.13.5),支持的 dtype 为 float32、int32 和 bool。
形状是一个整数数组,指示张量中有多少个元素以及它们是如何组织的。可以将其视为张量的“形状和大小”,即张量作为容器的形状(参见 图 B.1)。
图 B.1. 秩为 0、1、2、3 和 4 的张量示例

形状的长度被称为张量的秩。例如,1D 张量,也称为向量,秩为 1。1D 张量的形状是一个包含一个数字的数组,这个数字告诉我们 1D 张量的长度。将秩增加一,我们得到一个 2D 张量,可以将其可视化为 2D 平面上的数字网格(如灰度图像)。2D 张量的形状有两个数字,告诉我们网格的高度和宽度。再增加一秩,我们得到一个 3D 张量。如 图 B.1 中的示例所示,你可以将 3D 张量可视化为 3D 数字网格。3D 张量的形状由三个整数组成;它们告诉我们沿着三个维度的 3D 网格的大小。所以,你看到了规律。秩为 4 的张量(4D 张量)更难以直接可视化,因为我们生活的世界只有三个空间维度。4D 张量经常在许多模型中使用,例如深度卷积网络。TensorFlow.js 支持秩高达 6 的张量。在实践中,秩为 5 的张量仅在某些小众情况下使用(例如涉及视频数据的情况),而秩为 6 的张量甚至更少见。
B.1.1. 标量(秩为 0 的张量)
标量是形状为空数组([])的张量。它没有轴,始终只包含一个值。可以使用 tf.scalar() 函数创建一个新的标量。在 JavaScript 控制台(假设已加载 TensorFlow.js 并在 tf 符号处可用)中执行以下操作:
> const myScalar = tf.scalar(2018);[1]
> myScalar.print();
Tensor
2018
> myScalar.dtype;
"float32"
> myScalar.shape;
[]
> myScalar.rank;
0
¹
请注意,出于空间和清晰起见,我们将跳过由于赋值而产生的 JavaScript 控制台输出行,因为它们对所讨论的问题没有说明性。
我们已创建一个标量张量,其中仅包含值 2018。其形状是空列表,正如预期的那样。它具有默认的 dtype ("float32")。要将 dtype 强制为整数,请在调用 tf.scalar() 时提供 'int32' 作为额外参数:
> const myIntegerScalar = tf.scalar(2018, 'int32');
> myIntegerScalar.dtype;
"int32"
要从张量中获取数据,我们可以使用异步方法data()。该方法是异步的,因为一般来说,张量可能被托管在主内存之外,例如在 GPU 上,作为 WebGL 纹理。检索这些张量的值涉及到不一定能立即解决的操作,我们不希望这些操作阻塞主 JavaScript 线程。这就是为什么data()方法是异步的。还有一个同步函数通过轮询检索张量的值:dataSync()。这种方法很方便,但会阻塞主 JavaScript 线程,所以应该尽量少用(例如,在调试期间)。尽量使用异步的data()方法:
> arr = await myScalar.data();
Float32Array [2018]
> arr.length
1
> arr[0]
2018
要使用dataSync():
> arr = myScalar.dataSync();
Float32Array [2018]
> arr.length
1
> arr[0]
2018
我们看到,对于 float32 类型的张量,data()和dataSync()方法将值作为 JavaScript 的Float32Array原始值返回。如果你期望的是一个普通的数字,这可能有点令人惊讶,但是当考虑到其他形状的张量可能需要返回包含多个数字的容器时,这就更合理了。对于 int32 类型和 bool 类型的张量,data()和dataSync()分别返回Int32Array和Uint8Array。
请注意,即使标量始终包含一个元素,反之则不成立。张量的秩大于 0 的张量也可以只有一个元素,只要其形状中的数字乘积为 1 即可。例如,形状为[1, 1]的 2D 张量只有一个元素,但是它有两个轴。
B.1.2. tensor1d(秩-1 张量)
1D 张量有时被称为秩-1 张量或向量。1D 张量恰好有一个轴,其形状是长度为 1 的数组。下面的代码将在控制台创建一个向量:
> const myVector = tf.tensor1d([-1.2, 0, 19, 78]);
> myVector.shape;
[4]
> myVector.rank;
1
> await myVector.data();
Float32Array(4) [-1.2, 0, 19, 78]
这个 1D 张量有四个元素,可以称为 4 维向量。不要混淆 4D向量和 4D张量!4D 向量是一个只有一个轴并且包含确切四个值的 1D 张量,而 4D 张量有四个轴(并且每个轴上可以有任意数量的维度)。维度可以表示沿着特定轴的元素数量(如我们的 4D 向量)或张量中的轴数(例如,4D 张量),这有时可能会令人困惑。在技术上,更正确和不含糊的是指一个秩-4 张量,但是无论如何都常见到模糊的表示法 4D 张量。在大多数情况下,这不应该是个问题,因为它可以根据上下文来消除歧义。
与标量张量一样,您可以使用data()和dataSync()方法来访问 1D 张量元素的值;例如,
> await myVector.data()
Float32Array(4) [-1.2000000476837158, 0, 19, 78]
或者,您可以使用data()的同步版本,即dataSync(),但要注意,如果可能,应该避免使用dataSync()会阻塞 UI 线程:
> myVector.dataSync()
Float32Array(4) [-1.2000000476837158, 0, 19, 78]
要访问 1D 张量的特定元素的值,您可以简单地索引到data()或dataSync()返回的 TypedArray;例如,
> [await myVector.data()][2]
19
B.1.3. tensor2d(秩-2 张量)
一个二维张量有两个轴。在某些情况下,一个二维张量被称为矩阵,它的两个轴可以被解释为矩阵的行和列索引,分别。您可以将矩阵视为元素的矩形网格(参见图 B.1 的第三面板)。在 TensorFlow.js 中,
> const myMatrix = tf.tensor2d([[1, 2, 3], [40, 50, 60]]);
> myMatrix.shape;
[2, 3]
> myMatrix.rank;
2
第一个轴的条目称为行,第二个轴的条目称为列。在前面的例子中,[1, 2, 3] 是第一行,[1, 40] 是第一列。重要的是要知道,当使用 data() 或 dataSync() 返回数据时,数据将以行优先的方式作为扁平数组返回。换句话说,第一行的元素将首先出现在 Float32Array 中,然后是第二行的元素,依此类推:^([2])
²
这与一些其他数值框架(如 MATLAB 和 R)中看到的列优先排序不同。
> await myMatrix.data();
Float32Array(6) [1, 2, 3, 40, 50, 60]
之前,我们提到 data() 和 dataSync() 方法,当跟随索引时,可以用于访问一维张量的任何元素的值。当用于二维张量时,索引操作变得繁琐,因为 data() 和 dataSync() 返回的 TypedArray 会扁平化二维张量的元素。例如,为了确定与二维张量中第二行第二列的元素对应的 TypedArray 元素,您必须执行如下算术:
> (await myMatrix.data())[1 * 3 + 1];
50
幸运的是,TensorFlow.js 提供了另一组方法,用于将张量的值下载到普通的 JavaScript 数据结构中:array() 和 arraySync()。与 data() 和 dataSync() 不同,这些方法返回正确保留原始张量秩和形状的嵌套 JavaScript 数组。例如,
> JSON.stringify(await myMatrix.array())
"[[1,2,3],[40,50,60]]"
要访问第二行第二列的元素,我们只需对嵌套数组进行两次索引:
> (await myMatrix.array())[1][1]
50
这消除了执行索引算术的需要,并且对于更高维度的张量特别方便。arraySync() 是 array() 的同步版本。与 dataSync() 类似,arraySync() 可能会阻塞 UI 线程,应谨慎使用。
在 tf.tensor2d() 调用中,我们提供了一个嵌套的 JavaScript 数组作为参数。参数由嵌套在另一个数组中的数组行组成。这种嵌套结构由 tf.tensor2d() 用于推断二维张量的形状——即有多少行和多少列,分别。使用 tf.tensor2d() 创建相同的二维张量的另一种方法是提供元素作为平面(非嵌套)JavaScript 数组,并伴随一个第二个参数,指定二维张量的形状:
> const myMatrix = tf.tensor2d([1, 2, 3, 40, 50, 60], [2, 3]);
> myMatrix.shape;
[2, 3]
> myMatrix.rank;
2
在这种方法中,shape 参数中所有数字的乘积必须与浮点数组中的元素数相匹配,否则在 tf.tensor2d() 调用期间将抛出错误。对于秩高于 2 的张量,创建张量还有两种类似的方法:使用一个嵌套数组作为参数,或者使用一个带有形状参数的平坦数组。在本书的不同示例中,您会看到这两种方法都被使用。
B.1.4. 秩为 3 及更高维度的张量
如果您将几个 2D 张量打包到一个新数组中,您将获得一个 3D 张量,您可以将其想象为元素的立方体(图 B.1 中的第四个面板)。在 TensorFlow.js 中,可以按照以前的模式创建秩为 3 的张量:
> const myRank3Tensor = tf.tensor3d([[[1, 2, 3],
[4, 5, 6]],
[[10, 20, 30],
[40, 50, 60]]]);
> myRank3Tensor.shape;
[2, 2, 3]
> myRank3Tensor.rank;
3
另一种执行相同操作的方法是提供一个扁平(非嵌套)值数组,以及一个显式形状:
> const anotherRank3Tensor = tf.tensor3d(
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
[2, 2, 3]);
在这个例子中,tf.tensor3d() 函数可以被更通用的 tf.tensor() 函数替代。这允许你生成任何秩(rank)的张量,最高可达到 6。在下面的示例中,我们创建了一个秩为 3 和一个秩为 6 的张量:
> anotherRank3Tensor = tf.tensor(
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
[2, 2, 3]);
> anotherRank3Tensor.shape;
[2, 2, 3]
> anotherRank3Tensor.rank;
3
> tinyRank6Tensor = tf.tensor([13], [1, 1, 1, 1, 1, 1]);
> tinyRank6Tensor.shape;
[1, 1, 1, 1, 1, 1]
> tinyRank6Tensor.rank;
6
B.1.5. 数据批次的概念
在实践中,在深度学习中你将遇到的所有张量中,第一个轴(轴 0,因为索引从 0 开始)几乎总是批处理轴(有时称为样本轴或批处理维度)。因此,一个模型作为输入获取的实际张量的秩比单个输入特征的秩高 1。这一点贯穿于本书中所有 TensorFlow.js 模型。第一个维度的大小等于批次中的示例数,称为批处理大小。例如,在第三章(清单 3.9)中的鸢尾花分类示例中,每个示例的输入特征由表示为长度为 4 的向量的四个数字组成(形状为 [4] 的 1D 张量)。因此,鸢尾花分类模型的输入是 2D 的,形状为 [null, 4],其中第一个 null 值表示模型运行时将确定的批处理大小(见图 B.2)。这种批处理约定也适用于模型的输出。例如,鸢尾花分类模型为每个个别输入示例输出一个三种可能的鸢尾花类型的独热编码,这是一个形状为 [3] 的 1D 张量。但是,模型的实际输出形状是 2D 的,形状为 [null, 3],其中第一个 null 值是待确定的批处理大小。
图 B.2. 单个示例(左)和批示例(右)的张量形状。批示例的张量比单个示例的张量的秩高一级,并且是tf.Model对象的predict()、fit()和evaluate()方法所接受的格式。批示例张量的形状中的null表示张量的第一维具有一个未确定的大小,在对上述方法进行实际调用时可以是任何正整数。

B.1.6. 张量的真实例子
让我们通过几个与本书中遇到的类似的例子使张量更加具体化。你将操作的数据几乎总是属于以下类别之一。在前面的讨论中,我们遵循批处理约定,并始终将批次中的示例数(numExamples)作为第一个轴包括进去:
-
向量数据—形状为
[numExamples, features]的 2D 张量 -
时间序列(序列)数据—形状为
[numExamples, timesteps, features]的 3D 张量 -
图像—形状为
[numExamples, height, width, channels]的 4D 张量 -
视频—形状为
[numExamples, frame, height, width, channels]的 5D 张量
向量数据
这是最常见的情况。在这样的数据集中,每个单独的数据样本可以被编码为一个向量,因此数据的批将被编码为一个秩为 2 的张量,其中第一个轴是样本轴,第二个轴是特征轴。
让我们看两个例子:
-
一个人口数据集,其中我们考虑每个人的年龄、邮政编码和收入。每个人可以被描述为一个包含 3 个值的向量,因此一个包含 10 万人的完整数据集可以存储在形状为
[100000, 3]的 2D 张量中。 -
一个文本文档数据集,其中我们通过每个单词出现的次数来表示每个文档(例如,从包含 20000 个最常见单词的英语词典中)。每个文档可以被编码为一个包含 20000 个值的向量(词典中每个单词的计数),因此 500 个文档的批可以存储在形状为
[500, 20000]的张量中。
时间序列或序列数据
每当数据中涉及时间(或者序列顺序的概念)时,将其存储在具有显式时间轴的 3D 张量中是有意义的。每个样本被编码为一系列向量(一个 2D 张量),因此样本批将被编码为 3D 张量(参见图 B.3)。
图 B.3. 一个 3D 时间序列数据张量

几乎总是按照惯例,时间轴是第二个轴(索引为 1 的轴),如下例所示:
-
一个股票价格数据集。每分钟我们存储股票的当前价格,过去一分钟内的最高价格和最低价格。因此,每分钟被编码为一个三个值的向量。由于一个小时有 60 分钟,一小时的交易被编码为一个形状为
[60, 3]的二维张量。如果我们有一个包含 250 个独立小时序列的数据集,数据集的形状将是[250, 60, 3]。 -
一个推文数据集,其中我们将每个推文编码为由 128 个唯一字符组成的 280 个字符序列。在这种设置中,每个字符都可以编码为大小为 128 的二进制向量(除了在对应字符的索引处有一个 1 的条目外,全部为零)。然后,每个字符可以被视为一个形状为
[280, 128]的二阶张量。一个包含 100 万条推文的数据集可以存储在一个形状为[1000000, 280, 128]的张量中。
图像数据
图像的数据通常具有三个维度:高度、宽度和颜色深度。尽管灰度图像只有一个颜色通道,但按照惯例,图像张量始终是三阶的,对于灰度图像有一个一维的颜色通道。因此,一个大小为 256 × 256 的 128 个灰度图像批次将存储在一个形状为 [128, 256, 256, 1] 的张量中,而一个包含 128 个彩色图像的批次将存储在一个形状为 [128, 256, 256, 3] 的张量中(参见图 B.4)。这称为 NHWC 约定(有关更多详情,请参见第四章)。
图 B.4. 一个 4D 图像数据张量

一些框架在高度和宽度之前放置通道维度,使用 NCHW 约定。在本书中,我们不使用这个约定,但在其他地方看到形状如 [128, 3, 256, 256] 的图像张量也不要感到惊讶。
视频数据
原始视频数据是少数几种常见的真实世界数据之一,你需要使用到五维张量。视频可以理解为一系列帧,每一帧都是一幅彩色图像。由于每一帧可以存储在一个三维张量 [height, width, colorChannel] 中,一系列帧可以存储在一个四维张量 [frames, height, width, colorChannel] 中,因此一批不同的视频将存储在一个五维张量中,形状为 [samples, frames, height, width, colorChannel]。
例如,一个 60 秒长,144 × 256 分辨率的 YouTube 视频剪辑,每秒采样 4 帧,将有 240 帧。四个这样的视频剪辑批次将存储在一个形状为 [4, 240, 144, 256, 3] 的张量中。总共有 106,168,320 个值!如果张量的数据类型为 'float32',那么每个值将以 32 位存储,因此张量将表示 405 MB。这是大量的数据!你在现实生活中遇到的视频要轻得多,因为它们不是以 float32 存储,并且通常被大幅压缩(例如 MPEG 格式)。
B.1.7. 从张量缓冲区创建张量
我们已经展示了如何使用诸如 tf.tensor2d() 和 tf.tensor() 等函数从 JavaScript 数组创建张量。为此,您必须先确定所有元素的值,并在之前的 JavaScript 数组中设置它们。但是,在某些情况下,从头开始创建这样一个 JavaScript 数组有点繁琐。例如,假设您要创建一个 5 × 5 矩阵,其中所有的非对角线元素都为零,而对角线元素形成一个递增序列,等于行或列索引加 1:
[[1, 0, 0, 0, 0],
[0, 2, 0, 0, 0],
[0, 0, 3, 0, 0],
[0, 0, 0, 4, 0],
[0, 0, 0, 0, 5]]
如果要创建一个满足此要求的嵌套 JavaScript 数组,代码将如下所示:
const n = 5;
const matrixArray = [];
for (let i = 0; i < 5; ++i) {
const row = [];
for (let j = 0; j < 5; ++j) {
row.push(j === i ? i + 1 : 0);
}
matrixArray.push(row);
}
最后,你可以把嵌套的 JavaScript 数组 matrixArray 转换成一个二维张量:
> const matrix = tf.tensor2d(matrixArray);
这段代码看起来有点繁琐。它涉及两个嵌套的 for 循环。有没有简化它的方法?答案是有:我们可以使用 tf.tensorBuffer() 方法创建一个 TensorBuffer。TensorBuffer 对象允许您通过索引指定其元素,并使用 set() 方法更改其值。这与 TensorFlow.js 中的张量对象不同,后者的元素值是不可变的。当您完成设置 TensorBuffer 的所有元素的值后,可以通过其 toTensor() 方法方便地将 TensorBuffer 转换为实际的张量对象。因此,如果我们使用 tf.tensorBuffer() 来实现与上述代码相同的张量创建任务,新代码将如下所示
const buffer = tf.tensorBuffer([5, 5]); ***1***
for (let i = 0; i < 5; ++i) {
buffer.set(i + 1, i, i); ***2***
}
const matrix = buffer.toTensor(); ***3***
-
1 创建 TensorBuffer 时指定张量形状。创建后,TensorBuffer 的所有值都为零。
-
2 第一个参数是所需的值,而其余的参数是要设置的元素的索引。
-
3 从 TensorBuffer 获取实际的张量对象
因此,通过使用 tf.tensorBuffer(),我们将代码行数从 10 减少到 5。
B.1.8. 创建全零和全一张量
通常希望创建一个给定形状的全零张量。你可以使用 tf.zeros() 函数来实现这一点。调用该函数时,将期望的形状作为输入参数提供;例如,
> const x = tf.zeros([2, 3, 3]);
> x.print();
Tensor
[[[0, 0, 0],
[0, 0, 0],
[0, 0, 0]],
[[0, 0, 0],
[0, 0, 0],
[0, 0, 0]]]
创建的张量具有默认的 dtype(float32)。要创建其他 dtype 的全零张量,请将 dtype 指定为 tf.zeros() 的第二个参数。
相关的函数是 tf.zerosLike(),它让您可以创建一个与现有张量具有相同形状和 dtype 的全零张量。例如,
> const y = tf.zerosLike(x);
等同于
> const y = tf.zeros(x.shape, x.dtype);
但更简洁。
类似的方法允许你创建所有元素都相等的张量:tf.ones() 和 tf.onesLike()。
B.1.9. 创建随机值张量
创建具有随机值的张量在许多情况下都很有用,比如权重的初始化。创建具有随机值张量最常用的函数是 tf.randomNormal() 和 tf.randomUniform()。这两个函数具有类似的语法,但导致元素值的分布不同。顾名思义,tf.randomNormal() 返回的张量中的元素值遵循正态(高斯)分布。如果你只用一个形状参数调用该函数,你会得到一个元素遵循 单位 正态分布的张量:均值 = 0,标准差(SD)= 1。例如,
³
对于熟悉统计学的读者,元素值彼此独立。
> const x = tf.randomNormal([2, 3]);
> x.print():
Tensor
[[-0.2772508, 0.63506 , 0.3080665],
[0.7655841 , 2.5264773, 1.142776 ]]
如果你希望正态分布有一个非默认的均值或标准差,你可以将它们作为第二和第三个输入参数提供给 tf.randomUniform()。例如,以下调用创建了一个元素值遵循均值 = -20,标准差 = 0.6 的正态分布的张量:
> const x = tf.randomNormal([2, 3], -20, 0.6);
> x.print();
Tensor
[[-19.0392246, -21.2259483, -21.2892818],
[-20.6935596, -20.3722878, -20.1997948]]
tf.randomUniform() 允许你创建具有均匀分布元素值的随机张量。默认情况下,均匀分布是单位分布,即下界为 0,上界为 1:
> const x = tf.randomUniform([3, 3]);
> x.print();
Tensor
[[0.8303654, 0.3996494, 0.3808384],
[0.0751046, 0.4425731, 0.2357403],
[0.4682371, 0.0980235, 0.7004037]]
如果你想让元素值遵循非单位均匀分布,你可以将下界和上界指定为 tf.randomUniform() 的第二和第三个参数,例如,
> const x = tf.randomUniform([3, 3], -10, 10);
创建一个值在 [-10, 10) 区间内随机分布的张量:
> x.print();
Tensor
[[-7.4774652, -4.3274679, 5.5345411 ],
[-6.767087 , -3.8834026, -3.2619202],
[-8.0232048, 7.0986223 , -1.3350322]]
tf.randomUniform() 可用于创建具有随机值的 int32 类型张量。这在你想要生成随机标签的情况下非常有用。例如,以下代码创建了一个长度为 10 的向量,其中的值随机抽取自整数 0 到 100(区间 [0, 100)):
> const x = tf.randomUniform([10], 0, 100, 'int32');
> x.print();
Tensor
[92, 16, 65, 60, 62, 16, 77, 24, 2, 66]
注意,在这个例子中 'int32' 参数是关键。没有它,你得到的张量将包含 float32 值而不是 int32 值。
B.2.基础张量操作
如果我们无法在张量上执行操作,那么张量就不会有什么用处。TensorFlow.js 支持大量的张量操作。您可以在 js.tensorflow.org/api/latest 查看它们的列表以及它们的文档。描述每一个操作都会很枯燥而冗余。因此,我们将突出一些常用的操作作为示例。常用操作可以分为两种类型:一元和二元。一元运算以一个张量作为输入并返回一个新张量,而二元运算以两个张量作为输入并返回一个新张量。
B.2.1.一元操作
让我们考虑将张量取反的操作——即使用每个输入张量元素的负值——并形成一个具有相同形状和数据类型的新张量。这可以使用 tf.neg() 完成:
> const x = tf.tensor1d([-1, 3, 7]);
> const y = tf.neg(x);
> y.print();
Tensor
[1, -3, -7]
函数式 API 与链式 API
在上一个示例中,我们使用张量x作为输入参数调用函数tf.neg()。TensorFlow.js 提供了一种更简洁的执行数学等价操作的方法:使用张量对象本身的neg()方法,而不是tf.*命名空间下的函数:
> const y = x.neg();
在这个简单的例子中,由于新 API 的存在,由于键入次数较少而节省的打字量可能不会显得那么令人印象深刻。然而,在需要一个接一个地应用多个操作的情况下,第二个 API 将比第一个 API 表现出更大的优势。例如,考虑一个假设的算法,您想要将x取反,计算倒数(每个元素都被 1 除),并在上面应用relu激活函数。这是在第一个 API 中实现算法所需的代码:
> const y = tf.relu(tf.reciprocal(tf.neg(x)));
相比之下,在第二个 API 中,实现代码如下:
> const y = x.neg().reciprocal().relu();
第二种实现在以下几个方面优于第一种实现:
-
字符较少,输入更少,因此制造错误的机会更小。
-
没有必要平衡嵌套的开放和关闭括号(尽管大多数现代代码编辑器都会帮助您完成此操作)。
-
更重要的是,方法出现在代码中的顺序与底层数学操作发生的顺序相匹配。(注意在第一种实现中,顺序被颠倒了。)这在第二种实现中会导致更好的代码可读性。
我们将第一个 API 称为函数式API,因为它基于在tf.命名空间下调用函数。第二个 API 将称为链式API,因为操作按照链式顺序出现(正如您在前面的示例中所看到的)。在 TensorFlow.js 中,大多数操作都可以作为tf.*命名空间下的函数版本和作为张量对象方法的链式版本来访问。您可以根据自己的需求选择这两个 API。在本书中,我们在不同的地方同时使用这两个 API,但对于涉及连续操作的情况,我们更偏向于使用链式 API。
元素级与约减操作
我们提到的一元操作的示例(tf.neg()、tf.reciprocal() 和 tf.relu())具有一个共同特点,即操作独立地应用在输入张量的每个元素上。因此,这类操作返回的张量保留了输入张量的形状。然而,在 TensorFlow.js 中,其他一元操作会导致张量形状比原来的更小。在张量形状的背景下,"更小" 是什么意思呢?在某些情况下,它表示较低的秩。例如,一元操作可能返回一个标量(秩为 0 的张量),而原来的张量是一个 3D 张量(秩为 3)。在其他情况下,它表示某个维度的大小比原来的更小。例如,一元操作可能对一个形状为[3, 20]的输入返回一个形状为[3, 1]的张量。无论形状如何收缩,这些操作都被称为约减操作。
tf.mean()是最常用的约减操作之一。在链式 API 中,它作为Tensor类的mean()方法出现。当没有附加参数时,它计算输入张量的所有元素的算术平均值,而无论其形状如何,并返回一个标量。在链式 API 中,使用它的方式如下所示:
> const x = tf.tensor2d([[0, 10], [20, 30]]);
> x.mean().print();
Tensor
15
有时,我们需要单独计算 2D 张量(矩阵)的每一行的均值,而不是整个张量上的均值。可以通过向mean()方法提供附加参数来实现:
> x.mean(-1).print();
Tensor
[5, 25]
参数-1表示mean()方法应该计算张量的最后一个维度的算术平均值。([4]) 这个维度被称为约减维度,因为它将在输出张量中被“减少”,输出张量的秩变为 1。指定约减维度的另一种方式是使用实际的维度索引:
⁴
这遵循了 Python 的索引约定。
> x.mean(1).print();
请注意,mean()还支持多个约减维度。例如,如果您有一个形状为[10, 6, 3]的 3D 张量,并且希望计算其算术平均值在最后两个维度上进行计算,得到一个形状为[10]的 1D 张量,则可以调用mean()方法,如x.mean([-2, -1])或x.mean([1, 2])。我们在附录的最后给出这个方法作为一个练习。
其他经常使用的约简一元操作包括
-
tf.sum()几乎与tf.mean()相同,但它计算的是和,而不是算术平均值,而是元素。 -
tf.norm(),用于计算元素的范数。有不同类型的范数。例如,1-范数是元素绝对值的总和。2-范数通过对平方元素求和然后取平方根来计算。换句话说,它是欧几里德空间中向量的长度。tf.norm()可用于计算一组数字的方差或标准差。 -
tf.min()和tf.max(),分别计算元素的最小值和最大值。 -
tf.argMax()返回沿减少轴的最大元素的索引。此操作经常用于将分类模型的概率输出转换为获胜类别的索引(例如,请参阅第 3.3.2 节中的鸢尾花分类问题)。tf.argMin()提供了类似的功能以找到最小值。
我们提到逐元素操作保留输入张量的形状。但反之不成立。有些保持形状的操作不是逐元素的。例如,tf.transpose() 操作可以执行矩阵转置,其中输入 2D 张量中的索引 [i, j] 的元素被映射到输出 2D 张量中的索引 [j, i]。如果输入是一个方阵,tf.transpose() 的输入和输出形状将相同,但这不是一个逐元素操作,因为输出张量中 [i, j] 处的值不仅取决于输入张量中 [i, j] 处的值,而是取决于其他索引处的值。
B.2.2. 二元操作
与一元操作不同,二元操作需要两个输入参数。tf.add() 可能是最常用的二元操作。它可能也是最简单的,因为它只是简单地将两个张量相加。例如,
> const x = tf.tensor2d([[0, 2], [4, 6]]);
> const y = tf.tensor2d([[10, 20], [30, 46]]);
> tf.add(x, y).print();
Tensor
[[10, 22],
[34, 52]]
类似的二元操作包括
-
tf.sub()用于两个张量的减法 -
tf.mul()用于两个张量的乘法 -
tf.matMul()用于计算两个张量之间的矩阵积 -
tf.logicalAnd()、tf.logicalOr()和tf.logicalXor()用于在布尔类型张量上执行 AND、OR 和 XOR 操作。
一些二元操作支持 广播,或者对不同形状的两个输入张量进行操作,并根据某种规则将较小形状的输入元素应用于另一个输入的多个元素。详细讨论请参阅信息框 2.4 中的第二章。
B.2.3. 张量的拼接和切片
一元和二元操作是张量输入张量输出(TITO)的,它们以一个或多个张量作为输入,并返回一个张量作为输出。 TensorFlow.js 中的一些常用操作不是 TITO,因为它们将张量与另一个非张量参数一起作为输入。tf.concat()可能是这类函数中最常用的函数。它允许你将多个形状兼容的张量连接成一个单一的张量。只有当张量的形状满足某些约束条件时才能进行连接。例如,可以将[5, 3]张量和[4, 3]张量沿第一个轴合并以得到[9, 3]张量,但如果它们的形状分别为[5, 3]和[4, 2],则无法将它们组合在一起!在给定的形状合法性的情况下,你可以使用tf.concat()函数来连接张量。例如,以下代码沿第一个轴连接一个全零[2, 2]张量和一个全一[2, 2]张量,则得到一个[4, 2]张量,其中“顶部”是全零,而“底部”则是全一:
> const x = tf.zeros([2, 2]);
> const y = tf.ones([2, 2]);
> tf.concat([x, y]).print();
Tensor
[[0, 0],
[0, 0],
[1, 1],
[1, 1]]
由于两个输入张量的形状相同,可以以不同的方式对它们进行连接:即沿第二个轴进行连接。轴可以作为第二个输入参数传递给tf.concat()。这将给我们一个[2, 4]张量,在这个张量中,左半部分都是零,右半部分都是一:
> tf.concat([x, y], 1).print();
Tensor
[[0, 0, 1, 1],
[0, 0, 1, 1]]
除了将多个张量连接成一个以外,有时我们希望执行“反向”操作,检索张量的一部分。例如,假设你创建了一个形状为[3, 2]的二维张量(矩阵),
> const x = tf.randomNormal([3, 2]);
> x.print();
Tensor
[[1.2366893 , 0.6011682 ],
[-1.0172369, -0.5025602],
[-0.6265425, -0.0009868]]
而你想要获取矩阵的第二行。为此,可以使用tf.slice()的链式版本:
> x.slice([1, 0], [1, 2]).print();
Tensor
[[-1.0172369, -0.5025602],]
slice()的第一个参数指示我们想要的输入张量部分从第一个维度的索引 1 和第二个维度的索引 0 开始。换句话说,它应该从第二行和第一列开始,因为我们在这里处理的二维张量是一个矩阵。第二个参数指定所需输出的形状:[1, 2]或在矩阵语言中,1 行 2 列。
如您所看到的,通过查看打印的值,我们成功地检索了 3×2 矩阵的第二行。输出的形状与输入的秩相同(2),但第一个维度的大小为 1。在这种情况下,我们检索第二个维度的全部(所有列)和第一个维度的子集(行的一部分)。这是一种特殊情况,可以使用更简单的语法实现相同的效果:
> x.slice(1, 1).print();
Tensor
[[-1.0172369, -0.5025602],]
在这个更简单的语法中,我们只需要指定请求的块沿第一个维度的起始索引和大小。如果将第二个输入参数传递为 1 而不是 2,则输出将包含矩阵的第一行和第二行:
> x.slice(1, 2).print();
Tensor
[[-1.0172369, -0.5025602],
[-0.6265425, -0.0009868]]
正如你可能猜到的那样,这种更简单的语法与批处理约定有关。它使得从批处理张量中获取单个示例的数据更容易。
但是如果我们想要访问矩阵的 列 呢?在这种情况下,我们将不得不使用更复杂的语法。例如,假设我们想要矩阵的第二列。可以通过以下方式实现
> x.slice([0, 1], [-1, 1]).print();
Tensor
[[0.6011682 ],
[-0.5025602],
[-0.0009868]]
这里,第一个参数([0, 1])是表示我们想要的切片的起始索引的数组。它是沿第一维的第一个索引和第二维的第二个索引。更简单地说,我们希望我们的切片从第一行和第二列开始。第二个参数([-1, 1])指定了我们想要的切片的大小。第一个数字(-1)表示我们想要沿第一维的所有索引(我们想要所有起始行),而第二个数字(1)表示我们只想要沿第二维的一个索引(我们只想要一列)。结果是矩阵的第二列。
看一下 slice() 的语法,你可能已经意识到 slice() 不仅限于检索行或列。事实上,如果开始索引和大小数组被正确指定,它足够灵活,可以让你检索输入二维张量中的任何“子矩阵”(矩阵内的任何连续矩形区域)。更一般地,对于秩大于 0 的张量,slice() 允许你检索输入张量中的任何连续子张量。我们将这留作附录末尾的练习。
除了 tf.slice() 和 tf.concat(),另外两个经常用于将张量分割成部分或将多个张量合并成一个的操作是 tf.unstack() 和 tf.stack()。 tf.unstack() 将张量沿着第一维分割成多个“pieces”。每个片段在第一维上的尺寸为 1。例如,我们可以使用 tf.unstack() 的链式 API:
> const x = tf.tensor2d([[1, 2], [3, 4], [5, 6]]);
> x.print();
Tensor
[[1, 2],
[3, 4],
[5, 6]]
> const pieces = x.unstack();
> console.log(pieces.length);
3
> pieces[0].print();
Tensor
[1, 2]
> pieces[1].print();
Tensor
[3, 4]
> pieces[2].print();
Tensor
[5, 6]
你可能已经注意到,unstack() 返回的“pieces”比输入张量的秩少一。
tf.stack() 是 tf.unstack() 的反向操作。顾名思义,它将具有相同形状的多个张量“堆叠”到一个新张量中。根据先前的示例代码片段,我们将片段重新堆叠在一起:
> tf.stack(pieces).print();
Tensor
[[1, 2],
[3, 4],
[5, 6]]
tf.unstack() 用于从批处理张量中获取与各个示例对应的数据;tf.stack() 用于将各个示例的数据合并成一个批处理张量。
B.3. TensorFlow.js 中的内存管理:tf.dispose()和tf.tidy()
在 TensorFlow.js 中,如果你直接处理张量对象,你需要对它们执行内存管理。特别是在创建和使用张量后,张量需要被释放,否则它将继续占用分配给它的内存。如果未释放的张量数量过多或者总大小过大,它们最终将导致浏览器标签页耗尽 WebGL 内存或导致 Node.js 进程耗尽系统或 GPU 内存(取决于是否使用 tfjs-node 的 CPU 或 GPU 版本)。TensorFlow.js 不会自动对用户创建的张量进行垃圾回收。[5] 这是因为 JavaScript 不支持对象终结。TensorFlow.js 提供了两个内存管理函数:tf.dispose()和tf.tidy()。
⁵
然而,在 TensorFlow.js 函数和对象方法中创建的张量由库本身管理,因此你不需要担心在调用这些函数或方法时包装它们在
tf.tidy()中。其中的示例函数包括tf.confusionMatrix()、tf.Model.predict()和tf.Model.fit()。
例如,考虑使用for循环对 TensorFlow.js 模型进行重复推理的示例:
const model = await tf.loadLayersModel( ***1***
'https://storage.googleapis.com/tfjs-models/tfjs/iris_v1/model.json'); ***1***
const x = tf.randomUniform([1, 4]); ***2***
for (let i = 0; i < 3; ++i) {
const y = model.predict(x);
y.print();
console.log(`# of tensors: ${tf.memory().numTensors}` ); ***3***
}
-
1 从网络上加载预先训练好的模型
-
2 创建一个虚拟输入张量
-
3 检查当前已分配张量的数量
输出将如下所示
Tensor
[[0.4286409, 0.4692867, 0.1020722],]
# of tensors: 14
Tensor
[[0.4286409, 0.4692867, 0.1020722],]
# of tensors: 15
Tensor
[[0.4286409, 0.4692867, 0.1020722],]
# of tensors: 16
正如你在控制台日志中看到的那样,每次调用model.predict()都会生成一个额外的张量,在迭代结束后不会被释放。如果允许for循环运行足够数量的迭代,它最终会导致内存不足错误。这是因为输出张量y没有被正确释放,导致张量内存泄漏。有两种方法可以修复这个内存泄漏。
在第一种方法中,你可以在不再需要输出张量时调用tf.dispose():
for (let i = 0; i < 3; ++i) {
const y = model.predict(x);
y.print();
tf.dispose(y); ***1***
console.log(`# of tensors: ${tf.memory().numTensors}` );
}
- 1 在使用后释放输出张量
在第二种方法中,你可以在for循环的主体部分使用tf.tidy():
for (let i = 0; i < 3; ++i) {
tf.tidy(() => { ***1***
const y = model.predict(x);
y.print();
console.log(`# of tensors: ${tf.memory().numTensors}` );
});
}
- 1
tf.tidy()自动释放传递给它的函数中创建的所有张量,除了由该函数返回的张量。
无论选用哪种方法,你应该看到迭代中分配的张量数量变为常数,表明不再有张量内存泄漏。哪种方法应该优先使用呢?通常,你应该使用tf.tidy()(第二种方法),因为它消除了跟踪需要释放哪些张量的需要。tf.tidy()是一个智能函数,它释放传递给它的匿名函数中创建的所有张量(除了由该函数返回的张量-稍后再说),即使这些张量没有绑定到任何 JavaScript 对象。例如,假设我们稍微修改先前的推理代码,以便使用argMax()获得获胜类别的索引:
const model = await tf.loadLayersModel(
'https://storage.googleapis.com/tfjs-models/tfjs/iris_v1/model.json');
const x = tf.randomUniform([1, 4]);
for (let i = 0; i < 3; ++i) {
const winningIndex =
model.predict(x).argMax().dataSync()[0];
console.log(`winning index: ${winningIndex}`);
console.log(`# of tensors: ${tf.memory().numTensors}` );
}
当这段代码运行时,你会发现它每次迭代泄漏两个张量:
winning index: 0
# of tensors: 15
winning index: 0
# of tensors: 17
winning index: 0
# of tensors: 19
为什么每次迭代泄漏两个张量?因为这行代码:
const winningIndex =
model.predict(x).argMax().dataSync()[0];
生成两个新的张量。第一个是model.predict()的输出,第二个是argMax()的返回值。这两个张量都没有绑定到任何 JavaScript 对象上。它们被创建后立即使用。这两个张量在某种意义上“丢失”——没有 JavaScript 对象可供您用来引用它们。因此,tf.dispose()不能用于清理这两个张量。但是,tf.tidy()仍然可以用来修复内存泄漏,因为它会对新张量执行簿记,无论它们是否绑定到 JavaScript 对象上:
const model = await tf.loadLayersModel(
'https://storage.googleapis.com/tfjs-models/tfjs/iris_v1/model.json');
const x = tf.randomUniform([1, 4]);
for (let i = 0; i < 3; ++i) {
tf.tidy(() => { ***1***
const winningIndex = model.predict(x).argMax().dataSync()[0];
console.log(`winning index: ${winningIndex}`);
console.log(`# of tensors: ${tf.memory().numTensors}` ); ***1***
});
}
- 1
tf.tidy()会自动处理传递给它作为参数的匿名函数中创建的张量,即使这些张量没有绑定到 JavaScript 对象上。
tf.tidy()的示例用法操作的是不返回任何张量的函数。如果该函数返回张量,则不希望将它们处理掉,因为它们需要在后续使用。这种情况在使用 TensorFlow.js 提供的基本张量操作编写自定义张量操作时经常遇到。例如,假设我们想编写一个函数来计算输入张量的标准化值——即,减去平均值并将标准差缩放为 1 的张量:
function normalize(x) {
const mean = x.mean();
const sd = x.norm(2);
return x.sub(mean).div(sd);
}
这个实现有什么问题?^([6]) 就内存管理而言,它泄漏了三个张量:1)均值,2)SD 和 3)一个更微妙的泄漏:sub()调用的返回值。为了修复内存泄漏问题,我们将函数体包装在tf.tidy()中:
⁶
这个实现还有其他问题。例如,它没有对输入张量进行健全性检查,以确保它至少有两个元素,使 SD 不为零,否则将导致除以零和无限结果。但是这些问题与此处的讨论无直接关联。
function normalize(x) {
return tf.tidy(() => {
const mean = x.mean();
const sd = x.norm(2);
return x.sub(mean).div(sd);
});
}
在这里,tf.tidy()为我们完成了三个操作:
-
它会自动处理在匿名函数中创建但未被它返回的张量,包括之前提到的所有泄漏。我们在之前的例子中已经看到这一点了。
-
它检测到
div()调用的输出由匿名函数返回,因此将其转发到自己的返回值。 -
在此期间,它将避免处理该特定张量,以便它可以在
tf.tidy()调用之外使用。
如我们所见,tf.tidy() 是一种智能而强大的内存管理函数。它在 TensorFlow.js 代码库中被广泛使用。在本书的示例中,您还将经常看到它。然而,它有以下重要限制:作为参数传递给 tf.tidy() 的匿名函数 不 能是异步的。如果您有一些需要内存管理的异步代码,您应该使用 tf.dispose() 并手动跟踪待处理的张量。在这种情况下,您可以使用 tf.memory().numTensor 来检查泄漏张量的数量。一个好的做法是编写单元测试来断言不存在内存泄漏。
B.4. 计算梯度
这一部分适用于对在 TensorFlow.js 中执行导数和梯度计算感兴趣的读者。对于本书中的大多数深度学习模型,导数和梯度的计算都是在model.fit()和model.fitDataset()中自动完成的。然而,对于某些问题类型,比如在第七章中找到卷积滤波器的最大激活图像和在第十一章中的 RL,需要显式地计算导数和梯度。TensorFlow.js 提供了支持此类用例的 API。让我们从最简单的情景开始,即一个接受单个输入张量并返回单个输出张量的函数:
const f = x => tf.atan(x);
为了计算函数(f)相对于输入(x)的导数,我们使用tf.grad()函数:
const df = tf.grad(f);
注意,tf.grad()不会立即给出导数的值。相反,它会给出一个函数,即原始函数(f)的导数。您可以使用具体的x值调用该函数(df),这时您就会得到df/dx的值。例如,
const x = tf.tensor([-4, -2, 0, 2, 4]);
df(x).print();
它给出了一个输出,正确反映了在 x 值为-4、-2、0、2 和 4 时atan()函数的导数(参见图 B.5):
Tensor
[0.0588235, 0.2, 1, 0.2, 0.0588235]
图 B.5. 函数atan(x)的图表

tf.grad()仅适用于具有单个输入张量的函数。如果您有一个具有多个输入的函数怎么办?让我们考虑一个简单的例子h(x, y),它只是两个张量的乘积:
const h = (x, y) => x.mul(y);
tf.grads()(带有名称中的“s”)生成一个函数,该函数返回输入函数相对于所有参数的偏导数:
const dh = tf.grads(h);
const dhValues = dh([tf.tensor1d([1, 2]), tf.tensor1d([-1, -2])]);
dhValues[0].print();
dhValues[1].print();
这给出了结果
Tensor
[-1, -2]
Tensor
[1, 2]
这些结果是正确的,因为相对于x的偏导数*y是y,相对于y的偏导数是x。
由tf.grad()和tf.grads()生成的函数只给出导数,而不是原始函数的返回值。在h(x, y)的示例中,如果我们不仅想要得到导数,还想要h的值,那么可以使用tf.valueAndGrads()函数:
const vdh = tf.valueAndGrads(h);
const out = vdh([tf.tensor1d([1, 2]), tf.tensor1d([-1, -2])]);
输出(out)是一个具有两个字段的对象:value,即给定输入值时h的值,以及grads,其格式与由tf.grads()生成的函数的返回值相同,即偏导数张量的数组:
out.value.print();
out.grads[0].print();
out.grads[1].print();
Tensor
[-1, -4]
Tensor
[-1, -2]
Tensor
[1, 2]
讨论的 API 都涉及到计算函数相对于其显式参数的导数。然而,在深度学习中的一个常见情景是函数在计算中使用了权重。这些权重表示为 tf.Variable 对象,并且不作为参数明确传递给函数。对于这样的函数,我们经常需要在训练过程中计算相对于权重的导数。tf.variableGrads() 函数就是为此工作流程提供支持的,它跟踪被求导函数所访问的可训练变量,并自动计算相对于它们的导数。考虑以下示例:
const trainable = true;
const a = tf.variable(tf.tensor1d([3, 4]), trainable, 'a');
const b = tf.variable(tf.tensor1d([5, 6]), trainable, 'b');
const x = tf.tensor1d([1, 2]);
const f = () => a.mul(x.square()).add(b.mul(x)).sum(); ***1***
const {value, grads} = tf.variableGrads(f);
- 1 f(a, b) = a * x ^ 2 + b * x。调用
sum()方法是因为tf.variableGrads()要求被求导函数返回一个标量。
tf.variableGrads() 的输出中的 value 字段是在给定 a、b 和 x 的当前值时 f 的返回值。 grads 字段是一个 JavaScript 对象,它在相应的键名下携带了对两个变量(a 和 b)的导数。例如,f(a, b) 关于 a 的导数是 x ^ 2,f(a, b) 关于 b 的导数是 x,
grads.a.print();
grads.b.print();
这正确给出了
Tensor
[1, 4]
Tensor
[1, 2]
练习
-
使用
tf.tensorBuffer()创建符合以下条件的“恒等 4D 张量”。它的形状应为[5, 5, 5, 5],它应该在所有位置都有 0 值,除了元素索引为四个相同数字(例如,[2, 2, 2, 2])的元素应具有值 1。 -
使用
tf.randomUniform()和默认的[0,1)间隔创建一个形状为[2,4,5]的 3D 张量。使用tf.sum(),编写一行代码对第二和第三维进行减少求和。检查输出。它应该具有形状[2]。你预计元素的值是多少,大约是多少?输出是否符合你的期望?(提示:在[0,1)间隔中随机分布的数字的期望值是多少?考虑到统计独立性,这两个值的总和的期望值是多少?) -
使用
tf.randomUniform()创建一个 4x4 矩阵(形状为[4,4]的 2D 张量)。使用tf.slice()获取位于中心的 2x2 子矩阵。 -
使用
tf.ones(),tf.mul()和tf.concat()创建这样一个 3D 张量:其形状应为[5,4,3]。沿第一轴的第一片(形状为[1,4,3]的张量)应具有所有元素值为 1;第二片沿第一轴应具有所有元素值为 2; 依此类推。- 加分题:该张量具有许多元素,因此仅通过查看
print()的文本输出很难测试其正确性。你如何编写单元测试来检查其正确性?(提示:使用data()、dataSync()或arraySync())。
- 加分题:该张量具有许多元素,因此仅通过查看
-
编写一个 JavaScript 函数,它对两个相同形状的输入 2D 张量(矩阵)执行以下操作。首先,将这两个矩阵相加。其次,逐个元素地将结果矩阵除以 2。第三,矩阵被转置。返回转置操作的结果。
-
你使用了哪些 TensorFlow.js 函数来编写这个函数?
-
你能实现这个函数两次,一次使用功能 API,一次使用链接 API 吗?哪个实现看起来更清晰、更易读?
-
哪些步骤涉及广播?
-
你如何确保这个函数不会泄漏内存?
-
你能写一个单元测试(使用 Jasmine 库,位于
jasmine.github.io/)来确保不存在内存泄漏吗?
-
术语表
激活函数
神经网络层的最后阶段的函数。例如,可以在矩阵乘法的结果上应用修正线性单元(relu)函数,以生成密集层的最终输出。激活函数可以是线性或非线性的。非线性激活函数可用于增加神经网络的表示能力(或容量)。非线性激活函数的示例包括 sigmoid、双曲正切(tanh)和前面提到的 relu。
曲线下面积(AUC)
用于量化 ROC 曲线形状的单个数字。它被定义为 ROC 曲线下的定积分,从假阳性率 0 到 1。见 ROC 曲线。
轴
在 TensorFlow.js 的上下文中,当我们谈论一个 张量 时,一个轴(复数 轴)是张量中独立索引的一个关键。例如,一个秩为 3 的张量有三个轴;秩为 3 的张量的一个元素由三个整数标识,这些整数对应于三个轴。也称为 维度。
反向传播
将可微分机器学习模型的损失值追溯到权重参数梯度的算法。它基于微分的链式法则,并构成了本书中大多数神经网络的训练基础。
时间反向传播(BPTT)
一种特殊形式的反向传播,在其中步骤不是在模型的连续层的操作上进行,而是在连续时间步骤上进行的。它构成了递归神经网络(RNNs)的训练基础。
平衡(数据集)
具有分类标签的数据集的一种质量。不同类别的示例数量越平衡,数据集就越平衡。
批次
在神经网络的训练过程中,通常将多个输入示例聚合成一个张量,该张量用于计算梯度和对网络权重的更新。这样的聚合称为 批次。批次中示例的数量称为 批次大小。
贝尔曼方程
在强化学习中,一种递归方程,将状态-动作对的价值量化为两项之和:1)代理在采取行动后立即获得的奖励;2)代理在下一个状态中可以获得的最佳期望奖励,乘以一个折现因子。第二项假定在下一个状态中选择的行动是最优的。它构成了强化学习算法(如深度 Q 学习)的基础。
二元分类
一个分类任务,其中目标是回答是/否问题,例如某个 X 射线图像是否表明肺炎,或者信用卡交易是否合法或欺诈性。
广播
TensorFlow 允许对具有不同但兼容形状的张量进行成对操作。例如,可以将形状为 [5] 的张量添加到形状为 [13, 5] 的张量中。实际上,较小的张量将重复 13 次以计算输出。关于何时允许广播的规则的详细信息在 章节 2.4 的信息框 中有说明。
容量
机器学习模型能够学习的输入-输出关系的范围。例如,具有具有非线性激活函数的隐藏层的神经网络比线性回归模型具有更大的容量。
类激活图
一种算法,可以可视化输入图像的不同部分对卷积神经网络分类输出的相对重要性。它基于计算网络的最后一个内部卷积层的输出对获胜类别的最终概率得分的梯度。详细讨论见 第 7.2.3 节。
计算机视觉
计算机如何理解图像和视频的研究。这是机器学习的重要组成部分。在机器学习的背景下,常见的计算机视觉任务包括图像识别、分割、字幕生成和目标检测。
混淆矩阵
一个形状为 [numClasses, numClasses] 的方阵(2D 张量)。在多类分类中,混淆矩阵用于量化给定真实类别的示例被分类为每个可能类别的次数。索引 [i, j] 处的元素是真实类别 i 的示例被分类为类别 j 的次数。对角线上的元素对应于正确分类的结果。
常量折叠
一种计算图优化类型,其中包含仅由预定常量节点和它们之间的确定性操作组成的子图被减少为单个常量节点。TensorFlow.js 中的 GraphModel 转换技术利用了常量折叠。
卷积核
在卷积操作中,一个张量对输入张量进行操作以生成输出张量。以图像张量为例:与输入图像相比,卷积核通常在其高度和宽度维度上较小。它在输入图像的高度和宽度维度上“滑动”,并在每个滑动位置上进行点积(乘法和加法)。对于 TensorFlow.js 的卷积层(例如 conv2d),卷积核是其关键权重。
数据增强
通过创建训练样本(x,y)的变异来从现有训练样本中生成更多训练数据的过程,通过产生有效输入 x'的一系列编程转换来暴露模型更多数据的方面,从而更好地泛化,而不需要工程师手动将这些转换类型的不变性构建到模型中。
深度学习
深度神经网络的研究和应用(即使用大量连续的表示转换来解决机器学习问题)。
深度神经网络
具有大量层(从两个到数千个)的神经网络。
维度
在张量的上下文中,与轴同义。 参见轴。
点积
参见内积。
嵌入
在深度学习中,将某个数据片段表示为n维向量空间(n为正整数)的表示。 换句话说,它是将数据片段表示为有序的,长度为n的浮点数数组。 可以为许多类型的数据创建嵌入表示:图像,声音,单词以及来自封闭集的项目。 嵌入通常来自训练的神经网络的中间层。
集成学习
训练一些个体机器学习模型并在同一问题上一起使用它们进行推断的实践。 即使每个单独的模型可能不太准确,但集成模型的准确性可能会更高。 集成模型经常被数据科学竞赛的获奖作品使用,例如 Kaggle 竞赛。
纪元
在训练模型时,对训练数据的完整通过。
Epsilon-贪婪策略
在强化学习中,一种参数化代理方的随机探索行为与最优行为之间平衡的动作选择方法。 epsilon 的值受到 0 和 1 之间的约束。 它越高,代理选择随机动作的可能性就越大。
示例
在机器学习的上下文中,输入数据的个体实例(例如,适用于计算机视觉模型的图像),机器学习模型将为其生成输出预测(例如,图像的标签)。
特征
机器学习模型的输入数据的一个方面。 特征可以采用以下任何形式:
-
数字(例如信用卡交易的货币金额)
-
来自开放集合的字符串(交易名称)
-
分类信息的一部分(例如信用卡品牌名称)
-
一个或多维数组(例如,信用卡客户签名的灰度图像表示为 2D 数组)
-
其他类型的信息(例如日期时间)
输入示例可以由一个或多个特征组成。
特征工程
原始特征数据的转化过程,使其更易于解决机器学习问题。在深度学习之前,通过领域专家进行试错的特征工程。这通常是一个耗时且脆弱的过程,没有找到最优解的保证。深度学习在很大程度上自动化了特征工程。
微调
在迁移学习中,模型训练的一个阶段,在该阶段,基本模型中某些层的权重可以更新。通常,在全部基本模型的权重都被冻结以防止大的初始梯度干扰预训练权重的初始阶段之后。如果使用得当,微调可以增强迁移学习模型的能力,从而在消耗比完全从头开始训练模型少得多的计算资源的同时实现更高的准确性。
生成式对抗网络(GAN)
生成式机器学习模型的一种类型,包括两个部分:鉴别器和生成器。鉴别器被训练为区分真实样本和训练集中的假样本,而生成器被训练为输出能使鉴别器给出高真实度评分的样本(即“欺骗”鉴别器,“使其认为”这些假样本是真实的)。经过适当的训练,生成器能够输出高度逼真的假样本。
金值
在测试机器学习系统时,模型对于给定输入应该生成的正确输出。例如,当给出贝多芬第五交响曲的录音时,神经网络将其分类为音乐流派时的“标准标签”就是一个例子。
梯度下降
梯度下降法是通过沿着系统参数的梯度方向(即与输出值相关的参数的导数)反复改变系统参数,将系统数值输出最小化的过程。这是神经网络训练的主要方法。在神经网络训练的上下文中,系统由工程师选择的神经网络和损失函数组成。系统的参数是神经网络层的权重,迭代过程逐批次地在训练数据上进行。
图形处理单元(GPU)
配备比典型 CPU 核心多得多(数百或数千个)的并行计算芯片。 GPU 最初被设计用于加速 2D 和 3D 图形的计算和渲染。但它们也被证明对于运行深度神经网络所涉及的并行计算非常有用。GPU 是深度学习革命的重要因素,并且在今天深度学习的研究和应用中继续发挥关键作用。TensorFlow.js 通过两个渠道利用 GPU 的并行计算能力:1)web 浏览器的 WebGL API,2)在 Node.js 中绑定到 TensorFlow CUDA 核心。
GraphModel
在 TensorFlow.js 中,从 TensorFlow(Python)转换并加载到 JavaScript 中的模型。 GraphModel 有潜力进行 TensorFlow 内部性能优化,比如 Grappler 的算术优化和 op 融合(详见第 12.2.2 节)。
隐藏层
由一个输出不作为网络的输出暴露,而是只被网络的其他层消耗的层组成的神经网络。例如,在一个定义为 TensorFlow.js 顺序模型的神经网络中,除了最后一个层外,所有层都是隐藏层。
超参数优化
有时也称为超参数调优;搜索在给定机器学习任务上给出最低验证损失的超参数集的过程。
超参数
模型和优化器的可调参数,这些参数不能通过反向传播进行调整。通常学习率和模型结构是常见的超参数示例。超参数可以通过网格搜索或更复杂的超参数调优算法进行调整。
假设空间
在机器学习的上下文中,机器学习问题的可能解集。训练过程涉及在这样的空间中搜索一个良好的解。假设空间由解决问题选择的机器学习模型的类型和架构决定。
ImageNet
由标记的彩色图像组成的大规模公共数据集。这是计算机视觉导向的深度神经网络的重要训练集和基准。ImageNet 在引领深度学习革命的开端方面发挥了重要作用。
填补
一种从数据集中填补缺失值的技术。例如,如果我们有一个汽车数据集,有些汽车缺少“重量”特征,我们可以简单地猜测这些特征的平均重量。也可以使用更复杂的填补技术。
形式
一种有大量层和复杂网络结构的深度卷积神经网络。
独立同分布(IID)
数据样本的统计属性。如果我们假设数据是从一个潜在的分布中抽样得到的,那么如果每个样本来自相同的分布,则样本是相同分布的。如果知道一个样本的值不会给你关于下一个样本的额外信息,那么样本是独立的。
掷骰子的样本是 IID(独立同分布)样本集的一个示例。如果骰子的结果被排序,那么样本是相同分布的但不是独立的。训练数据应该是 IID,否则在训练过程中可能会出现收敛或其他问题。
推断
对输入数据使用机器学习模型生成输出。这是训练模型的最终目的。
内积
也称为点积;是两个形状相同的向量上的数学运算,得到一个单一的标量值。要计算向量a和b之间的内积,对所有有效值的i求和a[i] * b[i]。从几何角度来看,两个向量的内积等于它们的大小乘积和它们之间的余弦值。
Keras
一种流行的深度学习库。今天,在 Kaggle 竞赛中它是使用最频繁的深度学习库。弗朗索瓦·肖莱(François Chollet)是其原始作者,目前是 Google 的软件工程师。Keras 是一个 Python 库。TensorFlow.js 的高级 API,这是本书的重点,是基于 Keras 建模并与之兼容的。
标签
根据手头的任务给定输入示例的期望答案。标签可以是布尔(是/否)答案、数字、文本字符串、一系列可能的类别中的一个、一系列数字或更复杂的数据类型。在监督式机器学习中,模型的目标是生成与标签尽可能匹配的输出。
层
在神经网络的上下文中,数据表示的转换。它的行为类似于数学函数:给定一个输入,它产生一个输出。一个层可以有由它的权重捕获的状态。这些权重可以在神经网络的训练过程中被改变。
LayersModel
使用 TensorFlow.js 的类似 Keras 的高级 API 构建的模型。它也可以从转换后的 Keras(Python)模型加载。LayersModel支持推断(使用其predict()方法)和训练(使用其fit()和fitDataset()方法)。
学习率
在梯度下降期间,模型权重被修改以减少损失。权重的确切变化不仅取决于损失的梯度,还取决于一个参数。在标准梯度下降算法中,通过将梯度乘以学习率来计算权重更新,学习率通常是一个小正常数。tensorflow.js 中 'sgd' 优化器的默认学习率是 0.01。
局部最小值
在优化模型参数时,参数的设置使得参数的任何足够小的变化都会增加损失。类似于碗底的大理石,没有任何更低的小运动。局部最小值与全局最小值有所区别,局部最小值是局部邻域中的最低点,而全局最小值是整体上的最低点。
对数几率
在机器学习中,一个未标准化的概率值。与概率不同,对数几率不限于[0,1]区间,也不要求总和为 1。因此,它们可以更轻松地由神经网络层输出。一组对数几率可以通过称为softmax的操作标准化为概率值。
机器学习
一种人工智能(AI)的子领域,通过使用带有所需答案标签的数据自动发现解决复杂问题的规则。它与经典编程不同,因为它不涉及规则的手工制作。
马尔可夫决策过程(MDP)
在强化学习中,一种决策过程,其中智能体选择的当前状态和动作完全决定了智能体将结束的下一个状态以及智能体将在该步骤中获得的奖励。这是一个重要的简化,使得像 Q-learning 这样的学习算法成为可能。
模型
在机器学习和深度学习中,将输入数据(例如图像)转换为所需输出(例如图像的文本标签)的对象,通过一系列连续的数学操作。模型具有可以在训练期间调整的参数(称为权重)。
模型适应
训练预训练模型或其部分的过程,以使模型在特定用户或特定用例的输入数据上进行推理时达到更好的准确性。这是一种迁移学习的类型,其中输入特征的类型和目标的类型与原始模型不同。
模型部署
将训练好的模型打包到可以用于进行预测的地方的过程。类似于其他软件堆栈的“推向生产”,部署是用户可以真正使用模型的方式。
MobileNet
一个预训练的深度卷积神经网络。通常在 ImageNet 图像分类数据集上进行训练,并可用于迁移学习。在类似的预训练卷积神经网络中,它具有相对较小的尺寸,并且在执行推理时涉及的计算较少,因此更适合在资源受限的环境(如 Web 浏览器)中运行,使用 TensorFlow.js。
多类分类
一个分类问题,其中目标可能具有两个以上的离散标签。例如,一张图片包含什么样的动物或给定内容的网页所使用的(自然)语言是什么。
多热编码
一种通过将与单词对应的元素设置为 1 并将其余元素保持为 0 来表示句子中的单词(或一般情况下,序列中的项目)的方法。这可以看作是独热编码的泛化。它丢弃了关于单词顺序的信息。
多层感知器(MLP)
由前馈拓扑和至少一个隐藏层组成的神经网络。
自然语言处理
计算机科学的一个子领域,研究如何利用计算机来处理和理解自然语言,最突出的是文本和语音。深度学习在自然语言处理中有许多应用。
神经网络
一类受生物神经系统中分层组织启发的机器学习模型。神经网络的层对数据表示进行多步、可分离的转换。
非线性
一个输入输出关系,不符合线性定义(输入的线性组合导致输出的线性组合,最多存在一个常数项的差异)。在神经网络中,非线性关系(例如在层中的 sigmoid 和 relu 激活)以及多个这样的关系的级联可以增加神经网络的容量。
目标检测
一种计算机视觉任务,涉及在图像中检测某些类别的对象及其位置。
独热编码
将分类数据编码为长度为N的向量的方案,该向量由除了对应于实际类别的索引之外的所有零组成。
操作融合
一种计算图优化技术,其中多个操作(或 ops)被替换为一个等效的操作。操作融合减少了操作分派开销,并可以为进一步的操作内存和性能优化提供更多机会。
超出词汇表(OOV)
在深度学习的上下文中,当一个词汇表用于一组离散项时,该词汇表有时不包括所有可能的项。当遇到词汇表之外的项时,它被映射到一个称为超出词汇表的特殊索引,然后可以将其映射到独热编码或嵌入表示中的特殊元素。见词汇表。
过拟合
当模型被适配到训练数据中,以至于模型具有足够的容量来记忆训练数据时,我们会看到训练损失继续下降,但测试或验证损失开始上升。具有这种属性的模型开始失去泛化能力,仅在训练数据中的确切样本上表现良好。我们称处于这种情况下的模型为过拟合。
策略梯度
一种强化学习算法,它计算并利用选定动作的某些度量(如对数几率)相对于策略网络的权重的梯度,以使策略网络逐渐选择更好的动作。
精确度
一个二元分类器的度量,定义为分类器标记为正且实际为正的比率。参见 召回率。
伪示例
基于已知有效的输入训练示例的突变的附加示例,用于补充训练数据。例如,我们可以取 MNIST 数字并应用小的旋转和倾斜。这些变换不会改变图像标签。
Q 网络
在强化学习中,预测给定当前状态观察到所有可能行动的 Q 值的神经网络。Q 学习算法是关于使用代理经验数据训练 Q 网络的过程。
Q 值
在强化学习中,以给定状态下采取行动的预期总未来累积奖励。因此,一个 Q 值是一个行动和状态的函数。它指导了在 Q 学习中选择行动的过程。
随机初始化
在模型拟合之前,为权重分配初始值作为起点的过程。关于什么,具体来说,是好的分布选择以获取初始值的文献基于层类型、大小和任务有很多。
回想
一个二元分类器的度量,定义为分类器标记为正的实际示例的比率。参见 精度。
回归
一种学习问题,期望输出(或标签)是一个数字或数字列表。预测越接近期望输出,越好。
正则化
在机器学习中,对损失函数或训练过程进行各种修改以抵消过度拟合的过程。有几种方式可以执行正则化,其中最常用的是 L1 和 L2 正则化。
强化学习(RL)
一种涉及通过与环境交互来学习最优决策以最大化一个叫做奖励的度量的机器学习方法。本书的第十一章介绍了 RL 的基础知识以及如何使用深度学习技术解决简单的 RL 问题。
ResNet
Residual Network 的缩写;一种广泛用于计算机视觉的流行卷积网络,具有残差连接,即跳过层之间的连接。
ROC 曲线
可视化二元分类器真正的阳性率(召回率)和假阳性率(误报率)之间的权衡的方法。该曲线的名称(接受者操作特征曲线)源于雷达技术的早期阶段。参见 曲线下面积(AUC)。
频谱图
一种类似于图像的二维表示,用于表示一维时间信号(例如声音)。频谱图有两个维度:时间和频率。每个元素表示在给定时间内在给定频率范围内所包含声音的强度或功率。
监督学习
使用标记示例训练机器学习模型的范例。模型的内部参数被改变,以最小化模型对示例的输出与相应实际标签之间的差异。
符号张量
在 TensorFlow.js 中,SymbolicTensor 类的对象,它是张量的形状和数据类型(dtype)的规范。与张量不同,SymbolicTensor 对象没有与具体值相关联。相反,它被用作层或模型的输入或输出的占位符。
张量
一种用于保存数据元素的数据结构,通常是数字。张量可以被视为 n 维网格,其中网格中的每个位置恰好保存一个元素。张量的维数和每个维度的大小被称为张量的 形状。例如,一个 3 × 4 矩阵是一个形状为 [3, 4] 的张量。长度为 10 的向量是一个形状为 [10] 的一维张量。每个张量实例只保存一种类型的元素。张量是这样设计的,因为它允许方便、高效的实现深度学习中常见操作:例如,矩阵点积。
TensorBoard
一个用于 TensorFlow 的监控和可视化工具。它允许用户在浏览器中可视化模型结构和训练性能。TensorFlow.js 可以将训练日志写入与 TensorBoard 兼容的数据格式。
TensorFlow
一个用于加速机器学习的开源 Python 库,重点放在深度神经网络上。它由 Google 的 Brain 团队于 2015 年 11 月发布。其 API 构成了 TensorFlow.js 的蓝图。
训练
改变机器学习模型的内部参数(权重),使模型的输出更接近期望的答案的过程。
训练数据
用于训练机器学习模型的数据。训练数据由各个示例组成。每个示例都是结构化信息(例如,图像、音频或文本),与预期答案(标签)一起。
迁移学习
将之前针对一个任务训练过的机器学习模型,重新训练它以适应一个新任务的相对较少的数据量(与原始训练数据集相比),并在新任务上进行推断的实践。
欠拟合
当一个模型经过太少的优化步骤训练,或者一个模型的表示能力(容量)不足以学习训练数据中的模式时,导致模型不能达到一个体面的质量水平时,我们称该模型为欠拟合。
无监督学习
使用未标记数据的机器学习范式。与使用标记数据的监督学习相对。无监督学习的示例包括聚类(在数据集中发现不同子集的示例)和异常检测(确定给定示例与训练集中的示例是否足够不同)。
验证数据
用于调整超参数的训练数据以外的数据,例如学习率或密集层中的单元数。验证数据允许我们调整学习算法,可能需要多次运行训练。由于验证数据与测试数据是分开的,我们仍然可以依靠测试数据的结果来提供无偏的模型在新的、未见过的数据上的性能估计。
梯度消失问题
在训练深度神经网络中的一个经典问题是,随着层数的增加,权重参数上的梯度越来越小,结果导致权重参数与损失函数之间的距离越来越远。在现代深度学习中,通过改进的激活函数、适当初始化权重和其他技巧来解决这个问题。
矢量化
将非数字数据转化为数字数组(如向量)的过程。例如,文本向量化涉及将字符、单词或句子转换为向量。
可视化工具
在与 TensorFlow.js 紧密集成的可视化库 tfjs-vis 中,可以通过在网页的一侧进行单个函数调用来创建一个可折叠的区域,以容纳可视化用的表面。可以在可视化工具中创建多个标签页来组织各个表面。详见第 8.1 节。
词汇表
在深度学习的背景下,一组离散的、独特的项目,可以用作神经网络的输入或输出。通常,词汇表的每个项目可以映射到一个整数索引,然后可以将其转换为一种 one-hot 或基于嵌入的表示。
权重
神经网络层的一个可调参数。改变权重会改变输入如何转换为输出的数值细节。神经网络的训练主要是关于以系统性的方式更新权重值。
权重量化
一种用于减少模型序列化和传输时大小的技术。它涉及将模型的权重参数以较低的数值精度存储。
词嵌入模型
文本相关的神经网络中将词向量化的一种方式。通过嵌入查找过程,将单词映射到 1D 张量(或向量)。与 one-hot 编码不同,词嵌入涉及到非稀疏向量,其中元素值是连续变化的数字,而不是 0 和 1。


7 ↩︎







浙公网安备 33010602011771号