R-深度学习第二版-全-

R 深度学习第二版(全)

原文:zh.annas-archive.org/md5/e4fa128a428bebc7c809e7bf24e4df20

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

如果你拿起这本书,你可能已经意识到了近年来深度学习对人工智能领域所代表的非凡进步。我们从几乎无法使用的计算机视觉和自然语言处理到在你每天使用的产品中大规模部署的高性能系统。这一突然进步的后果影响几乎每个行业。我们已经将深度学习应用于跨越医学成像、农业、自动驾驶、教育、灾害预防和制造等不同领域的一系列重要问题。

然而,我认为深度学习仍处于早期阶段。到目前为止,它只实现了其潜力的一小部分。随着时间的推移,它将走向能够帮助的每一个问题——这是一个需要几十年时间的转变。

要开始将深度学习技术部署到每一个可以解决的问题上,我们需要让尽可能多的人包括非专家——那些不是研究人员或研究生的人——能够接触到它。为了使深度学习发挥其全部潜力,我们需要彻底民主化它。今天,我相信我们正处于一个历史性转变的关键时刻,深度学习正在从学术实验室和大型科技公司的研发部门走向成为每个开发者工具箱中无处不在的一部分——类似于上世纪 90 年代末的 web 开发轨迹。现在几乎任何人都可以建立一个网站或网络应用,而在 1998 年,这可能需要一个小团队的专业工程师。在不久的将来,任何有想法和基本编码技能的人都将能够构建能够从数据中学习的智能应用程序。

当我在 2015 年 3 月发布了 Keras 深度学习框架的第一个版本时,AI 的民主化并不是我的初衷。我已经在机器学习领域做了几年的研究,并建立了 Keras 来帮助我进行实验。但自 2015 年以来,成千上万的新人进入了深度学习领域;其中许多人选择了 Keras 作为他们的首选工具。当我看到许多聪明的人以意想不到的强大方式使用 Keras 时,我开始非常关心 AI 的可访问性和民主化。我意识到,我们传播这些技术的越广泛,它们就变得越有用和有价值。可访问性很快成为 Keras 开发的一个明确目标,在短短几年内,Keras 开发者社区在这方面取得了巨大的成就。我们已经让数十万人掌握了深度学习,这些人又在解决一些直到最近被认为无法解决的问题。

你手中的这本书是让尽可能多的人了解深度学习的又一步。Keras 一直需要一个配套课程,以同时涵盖深度学习的基础知识、深度学习最佳实践和 Keras 使用模式。在 2016 年和 2017 年,我尽力制作了这样一个课程,它成为了本书的第一版,于 2017 年 12 月发布。它迅速成为了一本机器学习畅销书,销量超过了 5 万册,并被翻译成了 12 种语言。

然而,深度学习领域发展迅速。自第一版发布以来,许多重要的进展已经发生——TensorFlow 2 的发布、Transformer 架构的日益流行等等。因此,2019 年底,我开始更新我的书。我最初相当幼稚地认为,它将包含大约 50%的新内容,并且最终长度大致与第一版相同。实际上,在两年的工作后,它的长度超过了三分之一,约有 75%是全新内容。它不仅是一次更新,而且是一本全新的书。

我写这本书的重点是尽可能使深度学习背后的概念以及它们的实现变得易于理解。这样做并不需要我简化任何内容——我坚信在深度学习中没有难懂的理念。我希望你会发现这本书有价值,并且它能让你开始构建智能应用程序并解决对你而言重要的问题。

致谢

首先,我要感谢 Keras 社区让这本书得以实现。在过去的六年里,Keras 已经发展成为拥有数百名开源贡献者和超过一百万用户的平台。你们的贡献和反馈让 Keras 成为今天的样子。

更加个人化地,我要感谢我的妻子在 Keras 的开发和这本书的写作过程中给予我的无尽支持。

我还要感谢 Google 支持 Keras 项目。看到 Keras 被采用为 TensorFlow 的高级 API 真是太棒了。Keras 和 TensorFlow 之间的顺畅集成极大地有利于 TensorFlow 用户和 Keras 用户,并使深度学习变得更加易于使用。

我要感谢 Manning 出版社的人员让这本书得以实现:出版商 Marjan Bace 以及编辑和制作团队的所有人,包括 Michael Stephens、Jennifer Stout、Aleksandar Dragosavljević、Andy Marinkovich、Pamela Hunt、Susan Honeywell、Keri Hales、Paul Wells,以及许多在幕后工作的其他人。

非常感谢所有审阅者:Arnaldo Ayala Meyer、Davide Cremonesi、Dhinakaran Venkat、Edward Lee、Fernando García Sedano、Joel Kotarski、Marcio Nicolau、Michael Petrey、Peter Henstock、Shahnawaz Ali、Sourav Biswas、Thiago Britto Borges、Tony Dubitsky、Vlad Navitski,以及所有其他给我们提供反馈的人。你们的建议帮助这本书变得更好。

在技术方面,特别感谢担任本书技术校对的 Ninoslav Cerkez。

致谢

首先,我要感谢 Keras 社区让这本书得以实现。在过去的六年里,Keras 已经发展成为拥有数百名开源贡献者和超过一百万用户的平台。你们的贡献和反馈让 Keras 成为今天的样子。

更加个人化地,我要感谢我的妻子在 Keras 的开发和这本书的写作过程中给予我的无尽支持。

我还要感谢 Google 支持 Keras 项目。看到 Keras 被采用为 TensorFlow 的高级 API 真是太棒了。Keras 和 TensorFlow 之间的顺畅集成极大地有利于 TensorFlow 用户和 Keras 用户,并使深度学习变得更加易于使用。

我要感谢 Manning 出版社的人员让这本书得以实现:出版商 Marjan Bace 以及编辑和制作团队的所有人,包括 Michael Stephens、Jennifer Stout、Aleksandar Dragosavljević、Andy Marinkovich、Pamela Hunt、Susan Honeywell、Keri Hales、Paul Wells,以及许多在幕后工作的其他人。

非常感谢所有审阅者:Arnaldo Ayala Meyer、Davide Cremonesi、Dhinakaran Venkat、Edward Lee、Fernando García Sedano、Joel Kotarski、Marcio Nicolau、Michael Petrey、Peter Henstock、Shahnawaz Ali、Sourav Biswas、Thiago Britto Borges、Tony Dubitsky、Vlad Navitski,以及所有其他给我们提供反馈的人。你们的建议帮助这本书变得更好。

在技术方面,特别感谢担任本书技术校对的 Ninoslav Cerkez。

关于本书

本书是为任何希望从零开始探索深度学习或扩展对深度学习理解的人编写的。无论你是一名实践的机器学习工程师、数据科学家还是大学生,你都会在这些页面中找到价值。

你将以一种易于理解的方式探索深度学习——从简单开始,然后逐步掌握最先进的技术。你会发现本书在直觉、理论和动手实践之间取得了平衡。它避免使用数学符号,而更倾向于通过详细的代码片段和直观的心理模型来解释机器学习和深度学习的核心思想。你将从丰富的代码示例中学习,这些示例包括广泛的评论、实用的建议以及对开始使用深度学习解决具体问题所需知道的一切的简单高级解释。

代码示例使用了深度学习框架 Keras,其数值引擎为 TensorFlow 2。它们展示了截至 2022 年的现代 Keras 和 TensorFlow 2 最佳实践。

读完本书后,你将对深度学习是什么、什么时候适用以及它的局限性有一个扎实的理解。你将熟悉处理和解决机器学习问题的标准工作流程,并且你将知道如何解决常遇到的问题。你将能够使用 Keras 解决从计算机视觉到自然语言处理的实际问题:图像分类、图像分割、时间序列预测、文本分类、机器翻译、文本生成等等。

谁应该阅读本书?

这本书是为具有 R 编程经验的人们编写的,他们想要开始学习机器学习和深度学习。但这本书也对许多不同类型的读者有价值:

  • 如果你是一个熟悉机器学习的数据科学家,这本书将为你提供一个扎实的、实用的深度学习入门,这是机器学习中增长最快、最重要的子领域。

  • 如果你是一名希望开始使用 Keras 框架的深度学习研究人员或实践者,你会发现本书是理想的 Keras 速成课程。

  • 如果你是一名在正式环境中学习深度学习的研究生,你会发现本书是对你教育的一个实用补充,帮助你建立对深度神经网络行为的直觉,并让你熟悉关键的最佳实践。

即使是不经常编码的技术人员,他们也会发现本书对基本和高级深度学习概念的介绍很有用。

要理解代码示例,你需要具备合理的 R 熟练程度。你不需要有机器学习或深度学习的先前经验:本书从零开始覆盖了所有必要的基础知识。你也不需要有高级数学背景——高中水平的数学应该足以跟上。

关于代码

本书包含许多源代码示例,其格式既有编号的代码段,也有与普通文本并排的代码。在这两种情形中,源代码都采用固定宽度字体进行格式化,以与普通文本区分开。运行代码后的输出也采用固定宽度字体格式化,但左侧还带有一个垂直灰条。在整本书中,您会发现代码和代码输出以此交替呈现:

print("R 真是棒极了!")

[1] "R 真是棒极了!"

在许多情况下,原始源码已经被重新格式化;我们添加了换行符和重新排列缩进,以适应书中可用的页面空间。在极少数情况下,甚至这些也不够用,代码段中包含了行延续标志(Image)。此外,在文本中描述代码时,源代码中的注释通常会被移除。代码标注伴随许多代码片段,突出重要概念。

您可以从这本书的 liveBook(在线)版本中获取可执行的代码片段,网址为 livebook.manning.com/book/deep-learning-with-r-second-edition/,并且在 GitHub 上获得 R 脚本,网址为 github.com/t-kalinowski/deep-learning-with-R-2nd-edition-code

liveBook 讨论论坛

购买Deep Learning with R, Second Edition,包含免费访问 liveBook,Manning 的在线阅读平台。通过 liveBook 的独家讨论功能,您可以对全书或特定部分或段落附加评论。您可以为自己做笔记,提出和回答技术问题,还可以从作者和其他用户那里得到帮助。要访问论坛,请前往 livebook.manning.com/book/deep-learning-with-r-second-edition/。您也可以在 livebook.manning.com/discussion 了解更多有关 Manning 论坛和行为规则的信息。

Manning 对我们读者的承诺是提供一个场所,读者个人之间以及读者与作者之间能够进行有意义的对话。这并不意味着作者有义务参与特定数量的讨论,作者对论坛的贡献仍然是自愿的(且未付酬)。我们建议您尝试向作者提出一些具有挑战性的问题,以免他们失去兴趣!只要这本书还在售卖中,论坛和以前的讨论记录将可以从出版商网站上访问到。

关于封面插图

Deep Learning with R, Second Edition 封面上的插图“1700 年中国女士的风格”取自托马斯·杰弗里斯(Thomas Jefferys)的一本书,出版时间介于 1757 年至 1772 年之间。

在那些日子里,通过人们的服装很容易辨认出他们的居住地和职业或地位。曼宁通过基于几个世纪前丰富多样的地区文化的书封面,以及像这样的图片收藏,庆祝了计算机业务的独创性和主动性。

关于作者

FRANÇOIS CHOLLET 是 Keras 的创建者,这是最广泛使用的深度学习框架之一。他目前是 Google 的软件工程师,负责领导 Keras 团队。此外,他还进行关于抽象、推理以及如何在人工智能中实现更大的一般性的研究。

TOMASZ KALINOWSKI 是 RStudio 的软件工程师,担任 TensorFlow 和 Keras R 包的维护者。在之前的角色中,他曾作为科学家和工程师工作,将机器学习应用于各种各样的数据集和领域。

J.J. ALLAIRE 是 RStudio 的创始人,也是 RStudio IDE 的创建者。J.J. 是 TensorFlow 和 Keras 的 R 接口的作者。

第一章:深度学习是什么?

本章涵盖

  • 基础概念的高级定义

  • 机器学习发展时间线

  • 深度学习崛起及未来潜力背后的关键因素

在过去几年中,人工智能(AI)一直是媒体炒作的对象。机器学习、深度学习和人工智能在无数文章中被提及,常常出现在非技术类刊物中。我们被承诺一个智能聊天机器人、自动驾驶汽车和虚拟助手的未来——有时候是被描绘成灰暗的,有时候是乌托邦的,一个未来,人类的工作将会稀缺,大部分经济活动将由机器人或 AI 代理处理。对于一个未来或现在的机器学习从业者来说,能够识别噪音中的信号非常重要,这样你就可以从炒作的新闻稿中找出改变世界的发展。我们的未来岌岌可危,而且这是一个你要积极参与的未来:在阅读完本书后,你将成为那些开发这些 AI 系统的人之一。因此,让我们来解决这些问题:到目前为止,深度学习已经取得了什么成就?它有多重要?我们接下来将走向何方?你应该相信这种炒作吗?

本章提供了围绕人工智能、机器学习和深度学习的基本背景。

1.1 人工智能、机器学习和深度学习

首先,当我们提到人工智能时,我们需要清楚地定义我们谈论的是什么。人工智能、机器学习和深度学习是什么(见图 1.1)?它们之间有何关联?

图像

图 1.1 人工智能、机器学习和深度学习

1.1.1 人工智能

人工智能诞生于 20 世纪 50 年代,当时来自计算机科学新兴领域的一小撮先驱开始思考计算机是否能够“思考”——这个问题的影响我们今天仍在探索。

尽管许多潜在的想法在之前的年份甚至几十年里一直在酝酿,但是“人工智能”最终在 1956 年成为一个研究领域,当时达特茅斯学院数学系的年轻助理教授约翰·麦卡锡(John McCarthy)根据以下提议组织了一个暑期研讨会:

这项研究的基础假设是,学习的每个方面或智能的任何其他特征原则上都可以被如此精确地描述,以至于可以制造出一台机器来模拟它。我们将尝试找出如何让机器使用语言、形成抽象和概念、解决目前仅保留给人类的各种问题,并改进自己。我们认为,如果一组精心挑选的科学家们一起为此进行一个夏天的工作,就可以在这些问题中的一个或多个方面取得重大进展。

夏末,研讨会在没有完全解决其旨在调查的谜团的情况下结束了。尽管如此,参加的人很多,其中许多人后来成为该领域的先驱,并引发了一场至今仍在进行的知识革命。

简言之,AI 可以被描述为试图自动化人类通常执行的智力任务。因此,AI 是一个通用领域,包括机器学习和深度学习,但也包括许多不涉及任何学习的其他方法。考虑到直到 20 世纪 80 年代,大多数 AI 教科书根本没有提到“学习”!例如,早期的国际象棋程序仅涉及程序员制定的硬编码规则,并且不符合机器学习的条件。事实上,相当长的一段时间,大多数专家都相信,通过让程序员手工制作足够大的一组明确规则来操作明确数据库中存储的知识,可以实现人类级别的人工智能。这种方法被称为符号 AI。它是 20 世纪 50 年代到 20 世纪 80 年代末的 AI 的主导范式,并且在 20 世纪 80 年代的专家系统繁荣时期达到了最高的流行度。

尽管符号 AI 被证明适合解决定义良好的逻辑问题,例如下棋,但发现为解决更复杂、模糊的问题(例如图像分类、语音识别或自然语言翻译)制定明确规则是不可行的。一个新的方法出现取代了符号 AI 的位置:机器学习

1.1.2 机器学习

在维多利亚时代的英国,阿达·洛夫莱斯(Lady Ada Lovelace)是查尔斯·巴贝奇(Charles Babbage)的朋友和合作者,他是分析引擎的发明者:第一个已知的通用机械计算机。尽管分析引擎具有远见和超前的时间,但在 19 世纪 30 年代和 40 年代设计时,分析引擎并不是作为一台通用计算机存在的,因为通用计算的概念尚未发明。它仅仅被设计为使用机械操作来自动执行来自数学分析领域的某些计算——因此被称为分析引擎。因此,它是早期尝试用齿轮形式编码数学运算的思想的继承者,例如帕斯卡林或莱布尼茨的阶梯计算器,帕斯卡林的改进版本。由布莱斯·帕斯卡(Blaise Pascal)于 1642 年设计(当时年仅 19 岁!),帕斯卡林是世界上第一台机械计算器——它可以进行加法、减法、乘法,甚至是除法。

1843 年,阿达·洛夫莱斯(Ada Lovelace)评论了分析引擎的发明:

分析引擎完全没有假装要创造任何东西。它只能执行我们知道如何指示它执行的任务……它的职责是帮助我们利用我们已经了解的东西。

即使有着 179 年的历史背景,洛夫莱斯夫人的这一观察依然令人震惊。一个通用计算机能否“创造”出任何东西,还是它只会执行我们人类完全理解的过程?它能有任何原创思考吗?它能从经验中学习吗?它能展现创造力吗?

后来 AI 先驱艾伦·图灵在他 1950 年的里程碑论文“计算机器和智能”中引用了洛夫莱斯夫人的这一评论作为“洛夫莱斯夫人的反驳”,并介绍了图灵测试以及将会塑造 AI 的关键概念。图灵当时具有高度挑衅性的观点是,计算机原则上可以模拟人类智能的方方面面。

让计算机做有用的工作的通常方式是由人类程序员编写规则,即一份计算机程序,用于将输入数据转化为适当的答案,就像洛夫莱斯夫人编写分析机执行的步骤指令一样。而机器学习则颠倒了这种思路:机器查看输入数据和相应的答案,并找出规则应该是什么(见图 1.2)。机器学习系统是被训练而不是明确地进行编程。它被呈现给了任务相关的许多示例,并且在这些示例中找到了统计结构,最终允许系统制定自动化任务的规则。例如,如果你想自动化处理你的度假照片的标记任务,你可以向机器学习系统提供许多由人类已经标记过的照片示例,而系统会学习用于将特定照片与特定标签相关联的统计规则。

图片

图 1.2 机器学习:一种新的编程范式

尽管机器学习在 1990 年代才开始蓬勃发展,但它迅速成为人工智能最受欢迎和最成功的子领域,这一趋势是由更快的硬件速度和更大的数据集所推动的。机器学习与数理统计有关,但在几个重要方面与统计学不同,就像医学与化学有关但不能完全归纳为化学一样,因为医学处理了具有自身特性的独立系统。与统计学不同,机器学习倾向于处理大型复杂数据集(例如由数百万个由数万个像素组成的图像组成的数据集),对于这些数据集,诸如贝叶斯分析之类的经典统计分析是不实际的。因此,机器学习,尤其是深度学习,在数学理论方面相对较少——也许太少了,并且从根本上是一门工程学科。与理论物理或数学不同,机器学习是一个非常实际的领域,它是由实证结果推动并深度依赖于软件和硬件的进步。

1.1.3 从数据中学习规则和表示

要定义深度学习并理解深度学习与其他机器学习方法之间的区别,首先我们需要对机器学习算法做些了解。我们刚才说过,机器学习发现了执行数据处理任务的规则,给出了预期的示例。所以,要进行机器学习,我们需要以下三件事:

  • 输入数据点——例如,如果任务是语音识别,这些数据点可能是人们说话的声音文件。如果任务是图像标记,它们可以是图片。

  • 预期输出的示例——在语音识别任务中,这些可以是由人类生成的声音文件的文本转录。在图像任务中,预期的输出可以是诸如“狗”、“猫”等标签。

  • 评估算法工作是否良好的方法——这是确定算法当前输出与预期输出之间距离的必要条件。该度量用作调整算法工作方式的反馈信号。这一调整步骤就是我们所谓的学习

机器学习模型将其输入数据转换为有意义的输出,这个过程是从已知输入和输出的示例中“学习”的。因此,机器学习和深度学习的核心问题是有意义地转换数据:换句话说,学习有用的表示手头的输入数据——这些表示使我们更接近预期的输出。

在我们进一步之前:什么是表示?在其核心,它是查看数据的不同方式——表示或编码数据。例如,彩色图像可以用 RGB 格式(红-绿-蓝)或 HSV 格式(色调-饱和度-亮度)进行编码:这是同一数据的两种不同表示。对于某些使用一种表示可能很困难的任务,使用另一种表示可能变得更容易。例如,任务“选择图像中的所有红色像素”在 RGB 格式中更简单,而“使图像的饱和度降低”在 HSV 格式中更简单。机器学习模型的全部意义在于为其输入数据找到适当的表示——将数据转换为更适合手头任务的形式。

让我们具体一点。考虑一个x轴,一个y轴,以及一些通过它们在(x, y)系统中的坐标表示的点,如图 1.3 所示。

图像

图 1.3 一些样本数据

正如您所见,我们有一些白点和一些黑点。假设我们想要开发一个算法,该算法可以接受点的坐标(x, y)并输出该点可能是黑色还是白色。在这种情况下,我们有以下数据:

  • 输入是我们点的坐标。

  • 预期的输出是我们点的颜色。

  • 评估我们的算法是否良好的一种方法可以是,例如,被正确分类的点的百分比。

我们需要的是一种能清晰地将白点与黑点分开的新数据表示。我们可以使用的一种转换,除了许多其他可能性外,是一个坐标变换,如图 1.4 所示。

在这个新的坐标系统中,我们点的坐标可以说是我们数据的新表示。而且这个表示是很好的!有了这个表示,黑/白分类问题可以表达为一个简单的规则:“黑点是满足x > 0 的点”,或者“白点是满足x < 0 的点”。这个新的表示,加上这个简单的规则,很好地解决了分类问题。

Image

图 1.4 坐标变换

在这种情况下,我们手动定义了坐标变换:我们利用我们的人类智慧来得出我们自己的适当数据表示。对于这样一个极其简单的问题来说,这是可以的,但如果任务是对手写数字的图像进行分类,你能做到同样的吗?你能否写出明确的、可由计算机执行的图像转换,以揭示 6 和 8 之间、1 和 7 之间的差异,以及各种不同手写的区别?

在一定程度上是可能的。基于数字表示的规则,比如“封闭环数量”或者垂直和水平像素直方图,可以很好地区分手写数字。但是手动找到这样有用的表示是很费力的工作,而且,你可以想象到,由此产生的基于规则的系统是脆弱的——维护起来是一场噩梦。每当你遇到一个打破你精心考虑的规则的新的手写样本时,你就不得不添加新的数据转换和新的规则,同时考虑它们与每个以前的规则的相互作用。

你可能会想,如果这个过程如此痛苦,我们能不能自动化它呢?如果我们尝试系统地搜索不同的自动生成的数据表示及基于它们的规则集合,并通过使用一些开发数据集中正确分类的数字的百分比作为反馈来确定好的表示,那我们就是在做机器学习了。学习,在机器学习的背景下,描述的是一种自动搜索数据转换的过程,产生对一些数据有用的表示,受到某种反馈信号的指导——这些表示适合于解决手头任务的简单规则。

这些转换可以是坐标变换(如我们的 2-D 坐标分类示例中的转换),也可以是像素直方图和计数循环(如我们的数字分类示例中的转换),还可以是线性投影、平移、非线性操作(如“选择所有满足 x > 0 的点”)等等。机器学习算法通常不会自行发现这些转换的创造性方式;它们只是在预定义的一组操作中进行搜索,称为假设空间。例如,在 2-D 坐标分类示例中,所有可能的坐标变换构成了我们的假设空间。

这就是机器学习的精简定义:在一定的可能性空间内,通过接收反馈信号的指导,对一些输入数据搜索有用的表示和规则。这个简单的想法能够解决一系列智能任务,从语音识别到自动驾驶。现在您已经理解了我们所说的学习的含义,让我们来看看深度学习有何特殊之处。

1.1.4 “深度学习”中的“深度”

深度学习是机器学习的一个特定子领域:一种从数据中学习表示的新方法,强调学习越来越有意义的连续层次的表示。所谓“深度”并不是指这种方法达到了任何更深的理解,而是指连续层级表示的思想。对数据模型有多少层贡献被称为模型的深度。该领域其他适当的名称可以是分层表示学习层次表示学习。现代深度学习通常涉及到连续的几十甚至上百层的表示,它们都是从训练数据中自动学习得来的。与此同时,其他机器学习方法往往只专注于学习一两层的数据表示(例如,计算像素直方图然后应用分类规则);因此,它们有时被称为浅层学习

在深度学习中,这些分层表示是通过称为神经网络的模型学习的,这些模型结构化地堆叠在一起。术语“神经网络”是指神经生物学,但尽管深度学习中的一些核心概念部分是通过从我们对大脑的理解中汲取灵感(特别是视觉皮层)而开发出来的,但深度学习模型并不是大脑的模型。没有证据表明大脑实现了任何类似于现代深度学习模型中使用的学习机制。你可能会遇到一些流行科学文章宣称深度学习就像大脑工作或者是根据大脑建模的,但事实并非如此。对于初学者来说,将深度学习与神经生物学有任何联系是令人困惑和适得其反的;你不需要对“就像我们的思维一样”这种神秘感和神秘感的掩饰,你也可以忘记任何关于深度学习和生物学之间的假设联系的东西。对于我们的目的来说,深度学习是从数据中学习表示的数学框架。

深度学习算法学到的表示是什么样子的?让我们看看一个多层次网络(参见图 1.5)如何将一个数字图像转换为识别它是什么数字的表示。

正如你在图 1.6 中所看到的,该网络将数字图像转换为越来越不同于原始图像且越来越有关最终结果的表示。你可以把深度网络想象成一个多阶段的信息精炼过程,在这个过程中信息经过连续的筛选,并且逐渐纯化(即,关于某个任务而言有用)。

图像

图 1.5 用于数字分类的深度神经网络

图像

图 1.6 由数字分类模型学到的数据表示

所以从技术上讲,深度学习是什么?是一种学习数据表示的多阶段方法。这是一个简单的想法——但事实证明,非常简单的机制,足够大规模地扩展,最终看起来就像魔术一样。

1.1.5 用三个图来理解深度学习是如何工作的

到目前为止,你已经知道机器学习是关于将输入(比如图像)映射到目标(比如标签“猫”),这是通过观察大量输入和目标示例来完成的。你也知道深度神经网络通过一系列简单的数据转换(层)的深度序列来进行这种输入到目标的映射,并且这些数据转换是通过接触示例来学习的。现在让我们具体看看这个学习是如何发生的。

一个层对其输入数据执行的操作规范存储在层的权重中,本质上是一堆数字。在技术术语中,我们会说层实现的转换由其权重参数化(参见图 1.7)。 (权重有时也被称为层的参数。)在这个上下文中,学习意味着找到网络中所有层的权重的一组值,使得网络能够正确地将示例输入映射到其关联的目标。但是有个问题:一个深度神经网络可能包含数千万个参数。找到所有这些参数的正确值可能看起来是一项艰巨的任务,特别是考虑到修改一个参数的值将影响所有其他参数的行为!

图片

图 1.7 神经网络由其权重参数化。

要控制某物,首先你需要能够观察它。要控制神经网络的输出,你需要能够测量这个输出与你期望的有多远。这是网络的损失函数的工作,有时也称为目标函数成本函数。损失函数接受网络的预测和真实目标(你希望网络输出的内容)并计算一个距离分数,捕捉网络在这个特定示例上表现如何(参见图 1.8)。

图片

图 1.8 损失函数衡量网络输出的质量。

深度学习的基本技巧是将此分数用作反馈信号,稍微调整权重的值,使得当前示例的损失分数降低(参见图 1.9)。这种调整是优化器的工作,它实现了所谓的反向传播算法:深度学习中的核心算法。下一章将更详细地解释反向传播的工作原理。

图片

图 1.9 损失分数被用作反馈信号来调整权重。

最初,网络的权重被赋予随机值,因此网络只是实现一系列随机变换。自然地,它的输出远离理想状态,相应地损失分数也很高。但是随着网络处理每个示例,权重稍微朝正确的方向调整,损失分数减少。这是训练循环,重复足够多次(通常在数千个示例上进行数十次迭代),产生使损失函数最小化的权重值。具有最小损失的网络是输出尽可能接近目标的网络:一个经过训练的网络。再一次,这是一个简单的机制,一旦扩展,就会看起来像魔法。

1.1.6 到目前为止深度学习取得了什么成就

尽管深度学习是机器学习的一个相当古老的子领域,但它直到 2010 年代初才声名鹊起。在此后的几年里,它在领域中取得了革命性的成就,在感知任务甚至自然语言处理任务上取得了显著成果——这些问题涉及到对人类来说似乎自然而直观但长期以来一直难以实现的技能。特别是,深度学习使以下历史上困难的机器学习领域取得了突破:

  • 接近人类水平的图像分类

  • 接近人类水平的语音转录

  • 接近人类水平的手写转录

  • 显著改进的机器翻译

  • 显著改进的文本转语音转换

  • 数字助手,如谷歌助手和亚马逊 Alexa

  • 接近人类水平的自动驾驶

  • 改进的广告定位,如谷歌、百度或必应所使用的

  • 改进的网络搜索结果

  • 回答自然语言问题的能力

  • 超人类的围棋对弈

我们仍在探索深度学习的全部潜力。我们已经开始将其成功应用于许多曾被认为几年前无法解决的各种问题——自动转录梵蒂冈宗座档案馆中保存的数万份古老手稿,使用简单的智能手机在田间检测和分类植物疾病,协助肿瘤学家或放射科医生解释医学成像数据,预测洪水、飓风甚至地震等自然灾害,等等。随着每一个里程碑的实现,我们越来越接近一个深度学习在人类努力的每个活动和领域中都能协助我们的时代——科学、医学、制造业、能源、交通运输、软件开发、农业,甚至艺术创作。

1.1.7 不要相信短期炒作

尽管深度学习在近年来取得了显著成就,但对该领域未来十年能够取得的成就的期望往往高于可能实现的水平。尽管一些像自动驾驶汽车这样的改变世界的应用已经可以实现,但更多的应用可能会在很长一段时间内仍然难以实现,比如可信的对话系统,跨任意语言的人类级机器翻译以及人类级自然语言理解。特别是,对于短期内达到人类级通用智能的讨论不应该太认真。对短期内的高期望的风险在于,随着技术的失灵,研究投资将枯竭,长时间内的进展将放缓。

这已经发生过。在过去两次,AI 经历了密集的乐观期,随之而来的是失望和怀疑,资金匮乏也是其结果。它始于 20 世纪 60 年代的符号 AI。在那些早期,关于 AI 的预测风靡一时。符号 AI 方法中最知名的先驱和支持者之一是马文·明斯基,他在 1967 年声称,“一代人内…创造‘人工智能’的问题将被很大程度上解决。”三年后,即 1970 年,他对此作了更为明确的预测:“在三到八年内,我们将拥有一台具有平均人类智能的机器。”在 2022 年,这样的成就似乎远在未来—而且我们无法预测需要多长时间—但在 20 世纪 60 年代和 70 年代初,一些专家相信这个成就就在不远的将来(今天也有很多人持相同看法)。几年后,随着这些极高的期望未能实现,研究人员和政府资金开始远离这一领域,标志着第一个AI 寒冬的开始(这是对核寒冬的一种参考,因为这是冷战达到高潮后不久)。

这不会是最后一个。在 20 世纪 80 年代,对符号 AI 的新看法,专家系统,开始在大公司中蓬勃发展。一些最初的成功案例引发了一波投资热潮,全球各大公司开始成立自己的内部 AI 部门开发专家系统。大约在 1985 年,公司每年在这项技术上的支出超过 10 亿美元;但到了 20 世纪 90 年代初,这些系统已被证明难以维护、难以扩展并且范围有限,导致兴趣消减。于是第二次 AI 寒冬开始了。

我们可能目前正见证着 AI 炒作和失望的第三个周期,而我们仍处于密集乐观的阶段。最好是在短期内控制我们的期望,并确保对这个领域技术方面不太熟悉的人清楚地知道深度学习能做什么,以及不能做什么。

1.1.8 AI 的承诺

尽管我们可能对人工智能抱有不切实际的短期期望,但长期前景看起来光明。我们只是刚刚开始将深度学习应用于许多重要问题,它可能会产生变革性的影响,从医学诊断到数字助理。过去 10 年来,人工智能研究取得了惊人的进展,这在很大程度上是由于人工智能短暂历史上前所未有的资金水平,但到目前为止,这种进展相对较少地融入到构成我们世界的产品和流程中。深度学习的大部分研究成果尚未应用,或者至少尚未应用于所有行业中它们可以解决的全部问题。你的医生尚未使用人工智能,你的会计师也没有。你在日常生活中可能很少使用人工智能技术。当然,你可以向你的智能手机提出简单的问题,并得到合理的答案,你可以在 Amazon.com 上获得相当有用的产品推荐,你可以在 Google 照片上搜索“生日”,并立即找到上个月你女儿生日聚会的照片。这与这些技术过去的水平相距甚远。但这样的工具仍然只是我们日常生活的附件。人工智能尚未过渡到成为我们工作、思考和生活方式的核心。

现在,人工智能可能会对我们的世界产生重大影响似乎难以置信,因为它尚未被广泛应用——就像在 1995 年,很难相信互联网未来的影响一样。那时,大多数人看不到互联网与他们有何关系,也看不到它将如何改变他们的生活。对于深度学习和人工智能今天也是如此。但不要误解:人工智能即将到来。在不久的将来,人工智能将成为你的助手,甚至是你的朋友;它将回答你的问题,帮助教育你的孩子,并监督你的健康。它将把你的杂货送到家门口,并把你从 A 点开到 B 点。它将成为你接入日益复杂和信息密集的世界的接口。更重要的是,人工智能将帮助整个人类向前迈进,通过协助人类科学家在各个科学领域取得新的突破性发现,从基因组学到数学。

在这个过程中,我们可能会遇到一些挫折,甚至可能出现新的人工智能寒冬——就像互联网行业在 1998 年至 1999 年被过度炒作,并遭受了在 2000 年初导致投资枯竭的崩溃一样。但我们最终会成功。人工智能最终将被应用到几乎构成我们社会和日常生活的每一个过程中,就像互联网今天一样。

不要相信短期的炒作,但要相信长期的愿景。人工智能可能需要一段时间才能发挥其真正的潜力——一个尚未有人敢于梦想其全部潜力的潜力——但人工智能即将到来,它将以一种奇妙的方式改变我们的世界。

1.2 在深度学习之前:机器学习的简要历史

深度学习已经达到了公众关注和工业投资的水平,这在人工智能历史上从未有过,但它并不是机器学习的第一种成功形式。可以说,今天工业界使用的大多数机器学习算法都不是深度学习算法。深度学习并不总是解决问题的正确工具——有时候没有足够的数据来应用深度学习,有时候用不同的算法解决问题更好。如果深度学习是你第一次接触机器学习,你可能会发现自己处于这样一种情况:你手头只有深度学习的“锤子”,每个机器学习问题开始看起来都像是一根钉子。避免落入这种陷阱的唯一方法是熟悉其他方法,并在适当时练习它们。

对古典机器学习方法的详细讨论超出了本书的范围,但我将简要介绍它们,并描述它们被发展的历史背景。这将使我们能够将深度学习置于机器学习的更广泛背景中,并更好地理解深度学习的起源和意义。

1.2.1 概率建模

概率建模是将统计学原理应用于数据分析的过程。这是最早的机器学习形式之一,至今仍然被广泛使用。这个类别中最著名的算法之一是朴素贝叶斯算法。

朴素贝叶斯是一种基于贝叶斯定理的机器学习分类器,假设输入数据中的特征都是独立的(这是一个强大或“朴素”的假设,这也是名称的由来)。这种形式的数据分析早在计算机出现之前就存在了,几十年前就是通过手工应用(很可能可以追溯到 20 世纪 50 年代)。贝叶斯定理和统计学的基础可以追溯到 18 世纪,这些是你开始使用朴素贝叶斯分类器所需要的全部。

一个密切相关的模型是逻辑回归(简称 logreg),有时被认为是现代机器学习的“Hello World”。不要被它的名字所误导——logreg 是一个分类算法,而不是回归算法。与朴素贝叶斯类似,logreg 在计算机出现很久之前就存在了,但由于其简单且多才多艺的性质,直到今天仍然很有用。它通常是数据科学家在处理数据集时尝试的第一件事,以了解手头的分类任务的感觉。

1.2.2 早期神经网络

早期的神经网络已经被这些页面中涵盖的现代变体完全取代,但了解深度学习的起源仍然很有帮助。虽然神经网络的核心理念早在 20 世纪 50 年代就以玩具形式进行了研究,但这种方法花了几十年才开始起步。长时间以来,缺失的部分是训练大型神经网络的有效方法。这种情况在 20 世纪 80 年代中期发生了变化,当时有多人独立重新发现了反向传播算法——一种使用梯度下降优化来训练参数化操作链的方法(我们将在本书后面精确定义这些概念),并开始将其应用于神经网络。

1989 年,贝尔实验室的 Yann LeCun 将卷积神经网络和反向传播的早期理念结合起来,并将它们应用于手写数字分类问题,从而取得了神经网络的第一个成功的实际应用。得到的网络被称为 LeNet,它在 1990 年代被美国邮政服务用于自动读取邮寄信封上的邮政编码。

1.2.3 核方法

随着神经网络在 1990 年代开始在研究人员中获得一些尊重,得益于这一第一个成功,一种新的机器学习方法崭露头角,并迅速将神经网络送回了遗忘:核方法。核方法是一组分类算法,其中最著名的是 支持向量机(SVM)。SVM 的现代形式是由贝尔实验室的 Vladimir Vapnik 和 Corinna Cortes 在 20 世纪 90 年代初开发的,并于 1995 年发表³,尽管早在 1963 年,Vapnik 和 Alexey Chervonenkis 就已经发表了一个较早的线性形式⁴。

SVM 是一种分类算法,它通过找到分隔两个类别的“决策边界”(参见图 1.10)来工作。SVM 在以下两个步骤中找到这些边界:

  1. 1 数据被映射到一个新的高维表示,其中决策边界可以表示为一个超平面(如果数据是二维的,如图 1.10,超平面将是一条直线)。

  2. 2 通过试图最大化超平面与每个类别最近数据点之间的距离来计算一个良好的决策边界(一个分隔超平面),这一步骤被称为 最大化间隔。这使得边界能够很好地泛化到训练数据集之外的新样本。

图片

图 1.10 决策边界

将数据映射到高维表示中,以使分类问题变得更简单的技术在纸上看起来很好,但在实践中通常是计算上难以处理的。这就是核技巧的作用(核方法的关键思想)。它的要点是:为了在新的表示空间中找到好的决策超平面,你不需要显式计算点在新空间中的坐标;你只需要计算该空间中一对点之间的距离,这可以通过使用核函数以高效的方式完成。核函数是一种计算可行的操作,将你的初始空间中的任何两个点映射到这些点在目标表示空间中的距离,完全绕过新表示的显式计算。核函数通常是手工制作而非从数据中学习的,在 SVM 的情况下,只需要学习分离超平面。

当它们被开发出来时,SVM 在简单分类问题上表现出了最先进的性能,并且是少数几种机器学习方法之一,支持广泛的理论并易于进行深入的数学分析,使它们受到长期以来极大的关注。

但是,SVM 难以扩展到大型数据集,并且在诸如图像分类之类的感知问题上没有提供良好的结果。因为 SVM 是一种浅层方法,将 SVM 应用于感知问题需要先手动提取有用的表示(称为特征工程步骤),这很困难且易于出错。例如,如果要使用 SVM 对手写数字进行分类,则不能从原始像素开始;你应该首先手动查找有用的表示,使问题更易于处理,例如前面提到的像素直方图。

1.2.4 决策树、随机森林和梯度提升机。

决策树是类似流程图的结构,可以让你分类输入的数据点或者根据输入预测输出值(见图 1.11)。它们易于可视化和解释。从数据中学习的决策树在 2000 年代开始受到重视,并且到 2010 年,它们通常被更喜欢使用于核方法。

图片

图 1.11 决策树:学习的参数是关于数据的问题。一个问题可以是,“数据中的系数 2 是否大于 3.5?”

特别是,随机森林算法引入了一种稳健的、实用的决策树学习方法,涉及构建大量专门的决策树,然后对它们的输出进行集成。随机森林适用于各种问题——你可以说它们几乎总是浅层机器学习任务的第二好算法。当流行的机器学习竞赛网站 Kaggle(kaggle.com)在 2010 年启动时,随机森林很快成为该平台上的宠儿——直到 2014 年,梯度提升机取代了它。梯度提升机与随机森林类似,是一种基于弱预测模型集成的机器学习技术,通常是决策树。它使用梯度提升,一种通过迭代训练新模型来改善任何机器学习模型的方法,这些新模型专门用于解决前一模型的弱点。应用于决策树时,梯度提升技术的使用导致模型大部分时间都严格优于随机森林,同时具有类似的性质。它可能是今天处理非感知数据的最佳算法之一,如果不是最佳。与深度学习一样,它是 Kaggle 竞赛中最常用的技术之一。

1.2.5 回到神经网络

大约在 2010 年左右,尽管神经网络几乎被整个科学界抛弃,但仍有一些人在继续研究神经网络,并取得了重要的突破:多伦多大学的 Geoffrey Hinton 小组、蒙特利尔大学的 Yoshua Bengio 小组、纽约大学的 Yann LeCun 小组以及瑞士的 IDSIA。

2011 年,来自 IDSIA 的 Dan Cireşan 开始用 GPU 训练的深度神经网络赢得学术图像分类比赛,这是现代深度学习的第一个实际成功案例。但真正的转折点是在 2012 年,Hinton 的团队参加了每年一次的大规模图像分类挑战赛 ImageNet(简称 ImageNet Large Scale Visual Recognition Challenge,或简称 ILSVRC)。当时,ImageNet 挑战赛以其难度大而臭名昭著,需要在训练了 140 万张图像后,将高分辨率彩色图像分类为 1000 个不同的类别。2011 年,基于经典计算机视觉方法的获胜模型的前五准确率仅为 74.3%。⁵ 然后,在 2012 年,由 Alex Krizhevsky 领导的团队,在 Geoffrey Hinton 的指导下,取得了 83.6%的前五准确率,这是一个重大突破。从那时起,每年的比赛都被深度卷积神经网络所主导。到 2015 年,获胜者的准确率达到了 96.4%,ImageNet 上的分类任务被认为是一个完全解决的问题。

自 2012 年以来,深度卷积神经网络(卷积神经网络)已成为所有计算机视觉任务的首选算法;更一般地说,它们适用于所有感知任务。在 2015 年之后的任何重要计算机视觉会议上,几乎不可能找到不涉及卷积神经网络的演示。与此同时,深度学习在许多其他类型的问题中也找到了应用,比如自然语言处理。它已完全取代了在广泛范围内的应用中的支持向量机和决策树。例如,几年来,欧洲核子研究组织(CERN)一直使用基于决策树的方法来分析大型强子对撞机(LHC)上的 ATLAS 探测器的粒子数据,但最终 CERN 转而使用基于 Keras 的深度神经网络,因为它们在性能和大型数据集的训练方面更为出色。

1.2.6 深度学习的特点在哪里?

深度学习迅速发展的主要原因是它在许多问题上提供了更好的性能。但这并不是唯一的原因。深度学习还使问题解决变得更容易,因为它完全自动化了机器学习工作流程中曾经最关键的步骤:特征工程。

以前的机器学习技术——浅层学习——涉及将输入数据转换成仅有一个或两个连续的表示空间,通常是通过简单的转换,比如高维非线性投影(支持向量机)或决策树。但是,复杂问题所需的精细表示通常无法通过这些技术实现。因此,人们不得不极力使初始输入数据更易于这些方法处理:他们不得不手工构建数据的良好表示层。这就是所谓的特征工程。另一方面,深度学习完全自动化了这一步骤:通过深度学习,你可以一次学习所有特征,而不是自己进行特征工程。这大大简化了机器学习工作流程,通常用单个简单的端到端深度学习模型替代了复杂的多阶段管道。

你可能会问,如果问题的关键是拥有多个连续的表示层,那么浅层方法能否被重复应用以模拟深度学习的效果?实际上,浅层学习方法的连续应用会产生快速减小的回报,因为三层模型中的最优第一表示层并不是单层或双层模型中的最优第一层。深度学习的变革性在于它允许模型同时学习所有层的表示,而不是连续地(贪婪地,正如它被称为的那样)学习。通过联合特征学习,每当模型调整其内部特征时,所有依赖于它的其他特征都会自动适应变化,而无需人为干预。一切都由单一的反馈信号监督:模型中的每一次变化都服务于最终目标。这比贪婪地堆叠浅层模型要强大得多,因为它允许将复杂的、抽象的表示分解成一系列长期的中间空间(层);每个空间与前一个空间之间只相差一个简单的转换。

这些是深度学习从数据中学习的两个基本特征:逐渐增加的、逐层的方式发展越来越复杂的表示,以及这些中间增量表示是共同学习的,每一层都更新以同时遵循上一层的表示需求和下一层的需求。这两个属性的结合使得深度学习比以前的机器学习方法成功得多。

1.2.7 现代机器学习格局

要了解当前机器学习算法和工具的现状,一个很好的方法是看一看 Kaggle 上的机器学习竞赛。由于其高度竞争的环境(一些比赛有数千名参赛者和百万美元的奖金)以及涵盖的机器学习问题的广泛多样性,Kaggle 提供了一种实际的评估什么有效、什么无效的方式。那么,哪种算法可靠地赢得了竞赛?顶级参赛者使用什么工具?

在 2019 年初,Kaggle 进行了一项调查,询问了自 2017 年以来任何竞赛中进入前五名的团队使用的主要软件工具(见图 1.12)。结果表明,顶级团队倾向于使用深度学习方法(通常通过 Keras 库)或梯度提升树(通常通过 LightGBM 或 XGBoost 库)。

图片

图 1.12 Kaggle 顶级团队使用的机器学习工具

不仅仅是竞赛冠军。Kaggle 还每年对全球机器学习和数据科学专业人士进行调查。这项调查有数万名受访者,是关于行业状况的最可靠来源之一。图 1.13 显示了不同机器学习软件框架的使用百分比。

从 2016 年到 2020 年,整个机器学习和数据科学行业都被这两种方法主导:深度学习和梯度提升树。具体来说,梯度提升树用于有结构化数据可用的问题,而深度学习用于图像分类等感知问题。

图像

图 1.13 工具在机器学习和数据科学行业中的使用情况(来源:www.kaggle.com/kaggle-survey-2020

梯度提升树的用户倾向于使用 Scikit-Learn、XGBoost 或 LightGBM。与此同时,大多数深度学习从业者使用 Keras,通常与其母框架 TensorFlow 结合使用。这些工具的共同点是它们都作为 R 或 Python 库提供:R 和 Python 是迄今为止最广泛使用的机器学习和数据科学语言。

要在今天的应用机器学习中取得成功,你应该最熟悉以下两种技术:梯度提升树,用于浅层学习问题;深度学习,用于感知问题。从技术上讲,这意味着你需要熟悉 XGBoost 和 Keras——目前在 Kaggle 竞赛中占据主导地位的库。有了这本书,你已经离成功更近了一大步。

1.3 为什么深度学习?为什么现在?

深度学习在计算机视觉领域的两个关键思想——卷积神经网络和反向传播——在 1990 年已经被充分理解。长短期记忆(LSTM)算法,这对于时间序列的深度学习至关重要,于 1997 年开发,并且自那时以来几乎没有改变。为什么深度学习直到 2012 年之后才起飞?这两个十年发生了什么变化?总的来说,以下三个技术力量推动了机器学习的进步:

  • 硬件

  • 数据集和基准

  • 算法进步

因为该领域是由实验发现而不是理论来指导的,所以只有在适当的数据和硬件可用于尝试新的想法(或者扩展旧的想法,通常情况下是这样)时,算法的进步才成为可能。机器学习不是数学或物理学,主要进步不能只靠一支笔和一张纸完成。这是一门工程科学。

1990 年代和 2000 年代的真正瓶颈是数据和硬件。但在此期间发生了以下事情:互联网蓬勃发展,高性能图形芯片为游戏市场的需求而开发。

1.3.1 硬件

在 1990 年至 2010 年之间,现成的 CPU 速度提高了约 5000 倍。因此,现在可以在笔记本电脑上运行小型深度学习模型,而在 25 年前这是不可行的。

但是,在计算机视觉或语音识别中使用的典型深度学习模型需要比您的笔记本电脑提供的计算能力高出几个数量级。在 2000 年代,像 NVIDIA 和 AMD 这样的公司投资了数十亿美元来开发快速、高度并行的芯片(图形处理单元,或 GPU),以推动越来越逼真的视频游戏的图形——便宜的、单一用途的超级计算机,设计用于实时在屏幕上渲染复杂的三维场景。当时,NVIDIA 于 2007 年推出了 CUDA(developer.nvidia.com/about-cuda),这是其 GPU 系列的编程接口。一小部分 GPU 开始取代各种高度可并行化应用程序中的大型 CPU 集群,从物理建模开始。由于深度神经网络主要由许多小矩阵乘法组成,因此也具有高度可并行化性,并且大约在 2011 年左右,一些研究人员开始编写神经网络的 CUDA 实现——Dan Cireşan⁶和 Alex Krizhevsky⁷是最早的。

发生的事情是,游戏市场为下一代人工智能应用程序提供了超级计算的资助。有时,大事物起源于游戏。NVIDIA Titan RTX 是一款于 2019 年底售价为 2500 美元的 GPU,可在单精度下提供 16 teraflops 的峰值计算能力(每秒 16 万亿次 float32 运算)。这大约是 1990 年世界上最快的超级计算机英特尔 Touchstone Delta 的 500 倍。在 Titan RTX 上,只需几个小时即可训练出 2012 年或 2013 年左右赢得 ILSVRC 竞赛的 ImageNet 模型。与此同时,大公司使用数百个 GPU 的集群来训练深度学习模型。

更重要的是,深度学习行业已经超越了 GPU,并且正在投资于越来越专门化、高效的深度学习芯片。2016 年,在其年度 I/O 大会上,Google 公布了其张量处理单元(TPU)项目:一种新的芯片设计,从头开始开发,以比顶级 GPU 更快、更节能地运行深度神经网络。2020 年,第三代 TPU 卡代表着 420 teraflops 的计算能力。这比 1990 年的英特尔 Touchstone Delta 高出 10,000 倍。

这些 TPU 卡设计成可组装成大型配置,称为“pods”。一个 pod(1024 个 TPU 卡)峰值达到 100 petaflops。就规模而言,这大约是当前最大超级计算机 IBM Summit 在奥克岭国家实验室的峰值计算能力的 10%,它由 27,000 个 NVIDIA GPU 组成,峰值约为 1.1 exaflops。

1.3.2 数据

人工智能有时被誉为新的工业革命。如果深度学习是这场革命的蒸汽机,那么数据就是其煤炭:为我们的智能机器提供动力的原材料,没有它,一切都不可能。在数据方面,除了过去 20 年存储硬件的指数级进步(遵循摩尔定律)外,互联网的崛起是游戏规则的改变者,使得收集和分发非常大的机器学习数据集成为可能。今天,大公司使用图像数据集、视频数据集和自然语言数据集,这些数据集如果没有互联网就无法收集。例如,Flickr 上用户生成的图像标签一直是计算机视觉的宝藏。YouTube 视频也是如此。而维基百科是自然语言处理的关键数据集。

如果说有一个数据集推动了深度学习的崛起,那就是 ImageNet 数据集,包括 140 万张图像,已手动注释为 1000 个图像类别(每个图像一个类别)。但是,使 ImageNet 特殊的不仅仅是其规模,还有与之相关的每年一度的竞赛⁸。

自 2010 年以来,Kaggle 一直在展示,公开竞赛是激励研究人员和工程师突破瓶颈的极佳途径。拥有研究人员竞争超越的共同基准极大地推动了深度学习的崛起,突显了其对传统机器学习方法的成功。

1.3.3 算法

除了硬件和数据之外,直到 2000 年代后期,我们缺乏可靠的方法来训练非常深的神经网络。因此,神经网络仍然相对浅层,只使用一两层表示;因此,它们无法与更精细的浅层方法(如 SVM 和随机森林)相抗衡。关键问题是通过深层堆栈的层进行梯度传播。用于训练神经网络的反馈信号随着层数的增加而逐渐消失。

2009 年至 2010 年间,随着以下简单但重要的算法改进的出现,使得更好的梯度传播成为可能:

  • 更好的神经层激活函数

  • 更好的权重初始化方案,从逐层预训练开始,然后迅速被抛弃

  • 更好的优化方案,例如 RMSprop 和 Adam

只有当这些改进开始允许训练具有 10 个或更多层的模型时,深度学习才开始展现其优势。最终,在 2014 年、2015 年和 2016 年,发现了更先进的改进梯度传播的方式,如批归一化、残差连接和深度可分离卷积。

今天,我们可以从头开始训练任意深度的模型。这解锁了使用极大模型的可能性,这些模型具有相当大的表征能力——也就是说,它们编码了非常丰富的假设空间。这种极端的可扩展性是现代深度学习的一个显著特征。大规模模型架构,具有数十层和数千万参数的特征,为计算机视觉(例如,ResNet、Inception 或 Xception 等架构)和自然语言处理(例如,基于大型 Transformer 的架构,如 BERT、GPT-3 或 XLNet)带来了关键进展。

1.3.4 新一轮投资浪潮

深度学习成为 2012 年至 2013 年计算机视觉的新技术代表,最终也成为所有感知任务的新技术代表,行业领袖们开始关注。随之而来的是一波渐进的行业投资浪潮,远远超出了人工智能历史上以往的任何投资规模(参见图 1.14)。

在深度学习受到关注之前的 2011 年,全球人工智能的风险投资总额不到 10 亿美元,几乎完全投向了浅层机器学习方法的实际应用。到了 2015 年,这一数字已经上升到了 50 多亿美元,而在 2017 年,更是激增到了惊人的 160 亿美元。在这几年中,数百家初创公司纷纷涌现,试图利用深度学习的热潮。与此同时,谷歌、亚马逊和微软等大型科技公司投资于内部研究部门的金额很可能超过了风险投资资金的流动。

图片

图 1.14 经济合作与发展组织(OECD)对人工智能初创公司总投资的估计(来源:mng.bz/zGN6

机器学习——特别是深度学习——已经成为这些科技巨头产品战略的核心。2015 年底,谷歌 CEO 桑达尔·皮查伊表示:“机器学习是我们重新思考我们如何做一切的核心、变革性方式。我们正在审慎地将其应用于我们所有的产品,无论是搜索、广告、YouTube 还是 Play。我们还处于早期阶段,但你会看到我们——以系统的方式——在所有这些领域应用机器学习。”⁹

由于这一波投资浪潮,从不到 10 年的时间里,从事深度学习研究的人数从几百人增加到了数万人,研究进展达到了狂热的速度。

1.3.5 深度学习的民主化

推动深度学习领域涌现新面孔的关键因素之一是该领域使用的工具集的民主化。在早期,进行深度学习需要具有重要的 C++和 CUDA 专业知识,而这方面的人才寥寥无几。

如今,基本的 R 或 Python 脚本技能就足以进行高级的深度学习研究。这主要是由 TensorFlow 库的发展推动的——一种支持自动微分的符号张量操作框架,极大地简化了新模型的实现——以及用户友好型库(例如 Keras),它使得深度学习就像操纵乐高积木一样容易。在 2015 年初发布后,Keras 很快成为了大量新创企业、研究生和转向该领域的研究人员的首选深度学习解决方案。

1.3.6 它会持续下去吗?

深度神经网络有什么特别之处,使得它们成为公司投资和研究人员涌入的“正确”方法?或者深度学习只是一个可能不会持续的潮流?20 年后我们还会使用深度神经网络吗?

深度学习具有几个属性,证明了它作为人工智能革命的地位,并且它将持续存在。也许 20 年后我们不会再使用神经网络,但我们使用的任何东西都将直接继承现代深度学习及其核心概念。这些重要属性可以大致分为以下三个类别:

  • 简单性—深度学习消除了对特征工程的需求,用简单的、端到端可训练的模型取代了复杂、脆弱且工程量大的流水线,这些模型通常只使用五六种不同的张量操作构建而成。

  • 可伸缩性—深度学习非常适合在 GPU 或 TPU 上进行并行化处理,因此它可以充分利用摩尔定律。此外,深度学习模型是通过迭代处理小批量数据进行训练的,这使得它们可以在任意大小的数据集上进行训练。(唯一的瓶颈是可用的并行计算能力量,感谢摩尔定律,这是一个不断发展的障碍。)

  • 多功能性和可重用性—与许多先前的机器学习方法不同,深度学习模型可以在不从头开始重新启动的情况下训练额外的数据,这使得它们对连续在线学习是可行的——这对于非常大的生产模型是一种重要的性质。此外,经过训练的深度学习模型是可重用的:例如,可以将经过训练用于图像分类的深度学习模型应用到视频处理流水线中。这使得我们可以将之前的工作再投入到日益复杂和强大的模型中。这也使得深度学习适用于相当小的数据集。

深度学习仅在几年内成为热点,我们可能尚未确定其能够完成的全部范围。随着每一年的过去,我们了解到新的用例和工程改进,这些改进解决了以前的限制。在一场科学革命之后,进展通常遵循一个 Sigmoid 曲线:它开始于快速进展的阶段,随着研究人员遇到严重限制,逐渐稳定,然后进一步的改进变得渐进性。

当我在 2016 年撰写本书的第一版时,我预测深度学习仍处于那个 Sigmoid 函数的前半部分,在接下来的几年里将会有更多变革性的进展。这在实践中得到了证实——2017 年和 2018 年见证了基于 Transformer 的深度学习模型在自然语言处理领域的崛起,这在该领域引起了一场革命,同时深度学习在计算机视觉和语音识别领域也持续稳步取得进展。如今,2022 年,深度学习似乎已经进入了那个 Sigmoid 函数的后半部分。我们仍然应该期待未来几年的重大进展,但我们可能已经走出了最初阶段的爆炸性进展。

今天,我对深度学习技术在解决所有可能的问题上的部署感到非常兴奋——列表是无穷无尽的。深度学习仍然是一场正在进行中的革命,要实现其全部潜力还需要很多年。

  1. ¹ A.M. 图灵,“计算机机器和智能”,心灵 59, no. 236 (1950): 433–460。

  2. ² 尽管图灵测试有时被解释为一个字面上的测试——AI 领域应该设定的目标——但图灵只是将其作为一个关于认知本质的哲学讨论中的概念设备。

  3. ³ 弗拉迪米尔·瓦普尼克和科琳娜·科特斯,“支持向量网络”,机器学习 20, no. 3 (1995): 273–297。

  4. ⁴ 弗拉迪米尔·瓦普尼克和亚历克谢·切尔沃能基斯,“关于一类感知器的注记”,自动化与遥控 25 (1964)。

  5. ⁵ “前五准确率”衡量模型在其前五个猜测中选择正确答案的频率(在 ImageNet 的情况下,有 1000 个可能的答案)。

  6. ⁶ 请参阅“灵活、高性能的卷积神经网络用于图像分类”,第 22 届国际人工智能联合会议论文集 (2011),mng.bz/nN0K.

  7. ⁷ 请参阅“用深度卷积神经网络进行 ImageNet 分类”,神经信息处理系统的进展 25 (2012),mng.bz/2286.

  8. ⁸ ImageNet 大规模视觉识别挑战(ILSVRC),www.image-net.org/challenges/LSVRC.

  9. ⁹ Sundar Pichai,Alphabet 盈利电话会议,2015 年 10 月 22 日。

第二章:神经网络的数学基础

本章内容涵盖

  • 神经网络的第一个示例

  • 张量和张量操作

  • 神经网络如何通过反向传播和梯度下降进行学习

理解深度学习需要熟悉许多简单的数学概念:张量、张量操作、微分、梯度下降等等。本章的目标是在不过度技术化的情况下建立你对这些概念的直觉。特别是,我们将避开数学符号,这对那些没有数学背景的人来说可能会引入不必要的障碍,而且并不是必要的来解释事物。描述数学操作最精确、无歧义的方式是其可执行的代码。

为了为引入张量和梯度下降提供足够的背景,我们将在本章开始时介绍一个神经网络的实际示例。然后逐点地介绍每个新引入的概念。请记住,这些概念对你理解后面章节中的实际示例非常重要。

读完本章后,你将对深度学习背后的数学理论有直观的理解,并准备好在第三章中深入研究 Keras 和 TensorFlow。

2.1 神经网络的首次尝试

让我们来看一个具体的示例,一个使用 Keras 学习分类手写数字的神经网络。除非你已经有了使用 Keras 或类似库的经验,否则你不会立即完全理解这个第一个示例的全部内容。没关系,在下一章中,我们将逐个回顾示例中的每个元素,并详细解释它们。所以如果有些步骤看起来随意或者像魔术一样,别担心——我们得从某个地方开始。

我们要解决的问题是将手写数字(28×28 像素)的灰度图像分类成它们的 10 个类别(0 到 9)。我们将使用MNIST 数据集,这是机器学习界的经典数据集,它几乎和这个领域一样久了,并且得到了广泛的研究。它是由国家标准与技术研究所(MNIST 中的 NIST)在 20 世纪 80 年代编制的一组 60,000 个训练图像和 10,000 个测试图像。你可以把“解决”MNIST 看作是深度学习的“Hello World”—用它来验证你的算法是否按预期工作。随着你成为一个机器学习实践者,你会在科学论文、博客文章等各种场合中一次又一次地看到 MNIST。你可以在图 2.1 中看到一些 MNIST 样本。

Image

图 2.1 MNIST 样本数字

在机器学习中,分类问题中的类别被称为。数据点被称为样本。与特定样本相关联的类被称为标签

你现在不需要在你的机器上尝试重现下面代码列表中显示的示例。如果你愿意,你首先需要设置一个深度学习工作空间,这在第三章中有介绍。MNIST 数据集在 Keras 中预装,以四个 R 数组的形式组织成了两个名为 train 和 test 的列表。

列表 2.1 在 Keras 中加载 MNIST 数据集

library(tensorflow)

library(keras)

mnist <- dataset_mnist()

train_images <- mnist\(train\)x

train_labels <- mnist\(train\)y

test_images <- mnist\(test\)x

test_labels <- mnist\(test\)y

train_images 和 train_labels 构成了训练集,模型将从中学习。然后模型将在测试集 test_images 和 test_labels 上进行测试。图像被编码为 R 数组,标签是一个从 0 到 9 的数字数组。图像和标签之间是一一对应的。让我们来看看训练数据,如下所示:

str(train_images)

int[1:60000, 1:28, 1:28] 0 0 0 0 0 0 0 0 0 0 …

str(train_labels)

int [1:60000(1d)] 5 0 4 1 9 2 1 3 1 4 …

这是测试数据:

str(test_images)

int [1:10000, 1:28, 1:28] 0 0 0 0 0 0 0 0 0 0 …

str(test_labels)

int [1:10000(1d)] 7 2 1 0 4 1 4 9 5 9 …

工作流程将如下:首先,我们将向神经网络提供训练数据 train_images 和 train_labels。然后网络将学会关联图像和标签。最后,我们将要求网络为 test_images 生成预测,并验证这些预测是否与 test_labels 中的标签相匹配。

让我们构建网络,如下面的示例所示。再次提醒,你现在不必理解这个示例的所有内容。

列表 2.2 网络架构

model <- keras_model_sequential(list(

layer_dense(units = 512, activation = "relu"),

layer_dense(units = 10, activation = "softmax")

))

神经网络的核心构建块是。你可以把层想象成数据的过滤器:一些数据进去,以更有用的形式出来。具体来说,层会从输入的数据中提取表示——希望这些表示对手头的问题更有意义。大部分深度学习都是将简单的层链接在一起,实现逐步数据精炼的形式。深度学习模型就像是用于数据处理的筛子,由一系列越来越精炼的数据过滤器——即层组成。

在这里,我们的模型由两个 Dense 层的序列组成,这些层是密集连接的(也称为全连接)神经层。第二(也是最后)层是一个 10 通路的softmax 分类层,这意味着它将返回一个包含 10 个概率分数的数组(总和为 1)。每个分数都是当前数字图像属于我们 10 个数字类别之一的概率。

为了使模型准备好训练,我们需要在编译步骤中选择以下三个要素,如列表 2.3 所示:

  • 优化器——模型将通过它看到的训练数据更新自身的机制,以改善其性能。

  • 损失函数——模型如何在训练数据上测量其性能,以及如何使其朝着正确的方向调整自身。

  • 训练和测试期间要监控的指标——在这里,我们只关心准确率(被正确分类的图像的比例)。

损失函数和优化器的确切目的将在接下来的两章中清晰地解释。

列表 2.3 编译步骤

编译(model,

optimizer = "rmsprop",

loss = "稀疏分类交叉熵",

指标 = "准确度")

请注意,我们不保存 compile() 的返回值,因为模型是就地修改的。

在训练之前,我们将通过重塑数据并缩放数据来对数据进行预处理,以使其符合模型的期望形状,并使其所有值都在 [0, 1] 区间内,如下所示。之前,我们的训练图像存储在一个形状为 (60000, 28, 28) 的整数数组中,值在 [0, 255] 区间内。我们将其转换为一个形状为 (60000, 28 * 28) 的双精度数组,值介于 0 和 1 之间。

列表 2.4 准备图像数据

train_images <- array_reshape(train_images, c(60000, 28 * 28))

train_images <- train_images / 255

test_images <- array_reshape(test_images, c(10000, 28 * 28))

test_images <- test_images / 255

请注意,我们使用的是 array_reshape() 函数而不是 dim•() 函数来重塑数组。我们稍后会解释为什么,当我们谈论张量重塑时。

现在我们已经准备好训练模型了,在 Keras 中,这是通过调用模型的 fit() 方法来完成的——我们将模型与其训练数据拟合起来。

列表 2.5 “拟合”模型

fit(model, train_images, train_labels, epochs = 5, batch_size = 128)

Epoch 1/5

60000/60000 [===========================] - 5s - loss: 0.2524 - acc:

0.9273

Epoch 2/5

51328/60000 [=====================>.....] - ETA: 1s - loss: 0.1035 -

准确率: 0.9692

训练期间显示两个数量:模型在训练数据上的损失和模型在训练数据上的准确度。我们很快就能在训练数据上达到 0.989(98.9%)的准确率。

现在我们有了一个经过训练的模型,我们可以用它来预测数字的类别概率——那些不属于训练数据的图像,比如测试集中的图像。

列表 2.6 使用模型进行预测

test_digits <- test_images[1:10, ]

predictions <- predict(model, test_digits)

str(predictions)

num [1:10, 1:10] 3.10e-09 3.53e-11 2.55e-07 1.00 8.54e-07 …

predictions[1, ]

[1] 3.103298e-09 1.175280e-10 1.060593e-06 4.761311e-05 4.189971e-12

[6] 4.062199e-08 5.244305e-16 9.999473e-01 2.753219e-07 3.826783e-06

数组中索引 i 处的每个数字(predictions[1, ])对应于数字图像 test_digits[1, ] 属于类 i 的概率。这第一个测试数字在索引 8 处有最高的概率分数(0.9999473,接近 1),因此根据我们的模型,它必须是 7(因为我们从 0 开始计数):

which.max(predictions[1, ])

[1] 8

predictions[1, 8]

[1] 0.9999473

我们可以检查测试标签是否一致:

test_labels[1]

[1] 7

平均而言,我们的模型在分类这样的全新数字时表现如何?让我们通过计算整个测试集的平均准确率来检查。

列表 2.7 在新数据上评估模型

metrics <- evaluate(model, test_images, test_labels)

metrics["accuracy"]

准确率

0.9795

测试集准确率为 97.9%,比训练集准确率(98.9%)低得多。训练准确率和测试准确率之间的差距是过拟合的一个例子:机器学习模型在新数据上的表现往往不如在其训练数据上表现好。过拟合是第三章的一个核心主题。

这就完成了我们的第一个示例。你刚刚看到了如何使用不到 15 行的 R 代码构建和训练一个神经网络来对手写数字进行分类。在本章和下一章中,我们将详细介绍我们刚刚预览的每个移动部件并澄清幕后发生的事情。你将了解张量,即输入模型的数据存储对象;张量操作,层由哪些组成;以及梯度下降,它允许您的模型从其训练示例中学习。

2.2 神经网络的数据表示

在前面的示例中,我们从存储在多维数组中的数据开始,也称为张量。一般来说,所有当前的机器学习系统都使用张量作为其基本数据结构。张量对于该领域是基础性的—以至于 TensorFlow 就是以它们命名的。那么,张量是什么?

在其核心,张量是数据的容器—通常是数字数据—因此,它是数字的容器。你可能已经熟悉矩阵,它们是秩 2 的张量:张量是矩阵到任意数量的维度的泛化(请注意,在张量的上下文中,维度通常被称为)。

R 提供了张量的实现:数组对象(通过 base:: array() 构造)是张量。在本节中,我们专注于定义张量周围的概念,所以我们将继续使用 R 数组。在本书的后面(第三章),我们介绍了张量的另一个实现(Tensorflow 张量)。

2.2.1 标量(秩 0 张量)

只能包含一个数字的张量称为标量(或标量张量,或秩 0 张量,或 0D 张量)。R 没有表示标量的数据类型(所有数字对象都是向量),但长度为 1 的 R 向量在概念上类似于标量。

2.2.2 向量(秩 1 张量)

数组称为向量,或排名 1 张量,或 1D 张量。排名 1 张量被称为具有一个轴。以下是一个张量向量:

x <- as.array(c(12, 3, 6, 14, 7))

str(x)

num [1:5(1d)] 12 3 6 14 7

length(dim(x))

[1] 1

这个向量有五个条目,因此被称为五维向量。不要混淆 5D 向量和 5D 张量! 5D 向量只有一个轴,并且沿着其轴有五个维度,而 5D 张量有五个轴(并且可以沿每个轴具有任意数量的维度)。维度既可以表示沿特定轴的条目数量(例如我们的 5D 向量的情况),也可以表示张量中的轴数(例如 5D 张量),这有时可能会令人困惑。在后一种情况下,从技术上讲,谈论排名 5 的张量(张量的排名是轴的数量)更加正确,但是不明确的符号 5D 张量 是常见的。

2.2.3 矩阵(排名 2 张量)

向量数组称为矩阵,或排名 2 张量,或 2D 张量。矩阵有两个轴(通常称为)。您可以将矩阵直观地解释为数字的矩形网格:

x <- array(seq(3 * 5), dim = c(3, 5))

x

图片

dim(x)

[1] 3 5

来自第一个轴的条目称为,来自第二个轴的条目称为。在上一个示例中,c(1, 4, 7, 10, 13) 是 x 的第一行,c(1, 2, 3) 是第一列。

2.2.4 排名 3 和更高等级的张量

如果您向 dim 提供一个长度为 3 的向量,则会获得排名为 3 的张量(或 3D 张量),您可以将其直观地解释为数字的立方体或排名为 2 的张量的堆叠:

x <- array(seq(2 * 3 * 4), dim = c(2, 3, 4))

str(x)

int [1:2, 1:3, 1:4] 1 2 3 4 5 6 7 8 9 10 …

length(dim(x))

[1] 3

通过堆叠排名 3 的张量,您可以创建排名 4 的张量,依此类推。在深度学习中,您通常会处理排名为 0 到 4 的张量,尽管如果处理视频数据,您可能会升到 5。

2.2.5 关键属性

张量由以下三个关键属性定义:

  • 轴数(排名)—例如,排名 3 的张量有三个轴,矩阵有两个轴。这可以从 length(dim(x)) 获取。

  • 形状—这是一个整数向量,描述张量沿每个轴具有多少维度。例如,前一个矩阵示例具有形状(3, 5),而排名为 3 的张量示例具有形状(2, 3, 4)。向量具有具有单个元素的形状,例如(5)。 R 数组不区分 1D 向量和标量张量,但在概念上,张量也可以是形状为()的标量。

  • 数据类型 —— 这是张量中包含的数据的类型。R 数组支持 R 内置数据类型,如 double 和 integer。然而,从概念上讲,张量可以支持任何类型的同构数据类型,其他张量实现也提供了对诸如 float16、float32、float64(对应于 R 的 double)、int32(R 的整数类型)等类型的支持。在 TensorFlow 中,你还可能遇到字符串张量。

为了使这更具体,让我们回顾一下在 MNIST 示例中处理的数据。首先,我们加载 MNIST 数据集:

library(keras)

mnist <- dataset_mnist()

训练图像 <- mnist\(train\)x

训练标签 <- mnist\(train\)y

测试图像 <- mnist\(test\)x

测试标签 <- mnist\(test\)y

接下来,我们显示了张量训练图像的轴数:

length(dim(训练图像))

[1] 3

这是它的形状:

dim(训练图像)

[1] 60000 28 28

这是它的 R 数据类型:

typeof(训练图像)

[1] "integer"

所以我们这里有一个整数的三阶张量。更准确地说,它是一个 60,000 个 28 × 28 整数矩阵的堆叠。每个这样的矩阵都是一个灰度图像,像素强度值介于 0 到 255 之间。

让我们显示这个三阶张量中的第五个数字(见 图 2.2)。

图片

图 2.2 我们数据集中的第五个样本

清单 2.8 显示第五个数字

数字 <- 训练图像[5, , ]

plot(as.raster(abs(255 - 数字), max = 255))

对应的标签自然是整数 9:

训练标签[5]

[1] 9

2.2.6 在 R 中操作张量

在前面的示例中,我们使用语法 train_images[i, , ] 沿着第一个轴选择了一个特定的数字。在张量中选择特定元素称为 张量切片。让我们看看你可以在 R 数组上进行的张量切片操作。

注意 TensorFlow 张量的切片与 R 数组类似,但存在一些差异。在本节中,我们将关注 R 数组,并在第三章开始讨论 TensorFlow 张量。

以下示例选择数字 10 到 99,并将它们放入形状为 (90, 28, 28) 的数组中:

我的切片 <- 训练图像[10:99, , ]

dim(我的切片)

[1] 90 28 28

通常,你可以在每个张量轴上的任意两个索引之间选择切片。例如,要选择所有图像右下角的 14 × 14 像素,你可以这样做:

我的切片 <- 训练图像[, 15:28, 15:28]

dim(我的切片)

[1] 60000    14    14

2.2.7 数据批次的概念

通常,在深度学习中,你将遇到的所有数据张量的第一个轴都是 样本轴(有时称为 样本维度)。在 MNIST 示例中,“样本”是数字的图像。

另外,深度学习模型不会一次处理整个数据集;相反,它们将数据分成小批量。具体来说,这是我们 MNIST 数字的一个批量,批量大小为 128:

批量 <- 训练图像[1:128, , ]

接下来是下一个批量:

批量 <- 训练图像[129:256, , ]

n 个批量:

n <- 3

batch <- train_images[seq(to = 128 * n, length.out = 128), , ]

在考虑这样一个批张量时,第一个轴被称为批轴批量维度。这是在使用 Keras 和其他深度学习库时经常遇到的术语。

2.2.8 数据张量的真实示例

让我们通过一些类似于您将来会遇到的示例来使数据张量更加具体化。您要处理的数据几乎总是属于以下几���类别之一:

  • 向量数据—形状为(样本,特征)的等级 2 张量,其中每个样本是一个数字属性(“特征”)的向量

  • 时间序列数据或序列数据—形状为(样本,时间步长,特征)的等级 3 张量,其中每个样本是一系列(长度为时间步长)的特征向量

  • 图像—形状为(样本,高度,宽度,通道)的等级 4 张量,其中每个样本是一个 2D 像素网格,并且每个像素由一组值(“通道”)表示

  • 视频—形状为(样本,帧数,高度,宽度,通道)的等级 5 张量,其中每个样本是图像序列(长度为帧数)

2.2.9 向量数据

这是最常见的几种情况之一。在这样的数据集中,每个单个数据点可以被编码为一个向量,因此一个数据批将被编码为等级 2 张量(即矩阵),其中第一个轴是样本轴,第二个轴是特征轴

让我们来看看下面的两个示例:

  • 一个保险数据集,其中我们考虑每个人的年龄、性别和收入。每个人可以被描述为一个 3 个值的向量,因此整个涵盖 100,000 人的数据集可以存储在形状为(100000,3)的等级 2 张量中。

  • 一个文本文档数据集,我们通过每个单词出现的次数来表示每个文档(在一个包含 20,000 个常见单词的词典中)。每个文档可以被编码为一个包含 20,000 个值的向量(词典中每个单词的计数),因此整个包含 500 个文档的数据集可以存储在形状为(500, 20000)的张量中。

2.2.10. 时间序列数据或序列数据

每当数据中涉及时间(或序列顺序的概念)时,将其存储在具有显式时间轴的等级 3 张量中是有意义的。每个样本可以被编码为一系列向量(等级 2 张量),因此数据批将被编码为等级 3 张量(参见图 2.3)。

图像

图 2.3 时间序列数据等级 3 张量

时间轴按照惯例始终是第二轴。让我们看几个示例:

  • 一个股票价格数据集。每分钟,我们存储股票的当前价格,过去一分钟内的最高价格和最低价格。因此,每分钟被编码为一个 3D 向量,整个交易日被编码为形状为(390,3)的矩阵(一个交易日有 390 分钟),并且 250 天的数据可以存储在形状为(250,390,3)的等级 3 张量中。在这里,每个样本将是一天的数据。

  • 一个推文数据集,其中我们将每个推文编码为由 128 个唯一字符组成的 280 个字符序列。在这种情况下,每个字符可以编码为大小为 128 的二进制向量(除了字符对应的索引处有一个 1 之外,其余全为零)。然后每个推文可以编码为形状为 (280, 128) 的二阶张量,并且一个包含一百万个推文的数据集可以存储在形状为 (1000000, 280, 128) 的张量中。

2.2.11. 图像数据

图像通常具有三个维度:高度、宽度和颜色深度。尽管灰度图像(如我们的 MNIST 数字)只有一个颜色通道,因此可以存储在二阶张量中,但按照惯例,图像张量始终是三阶的,对于灰度图像有一个一维的颜色通道。因此,尺寸为 256 × 256 的 128 个灰度图像批次可以存储在形状为 (128, 256, 256, 1) 的张量中,而尺寸为 256 × 256 的 128 个彩色图像批次可以存储在形状为 (128, 256, 256, 3) 的张量中(参见图 2.4)。

Image

图 2.4 一个四阶图像数据张量

图像张量的形状有两种约定:channels-last 约定(在 TensorFlow 中是标准的)和 channels-first 约定(越来越不受青睐)。channels-last 约定将颜色深度轴放在最后:(samples, height, width, color_depth)。与此同时,channels-first 约定将颜色深度轴放在批处理轴之后:(samples, color_depth, height, width)。使用 channels-first 约定,前面的例子将变为 (128, 1, 256, 256) 和 (128, 3, 256, 256)。Keras API 支持这两种格式。

2.2.12. 视频数据

视频数据是你需要使用五阶张量的少数几种真实世界数据之一。视频可以被理解为帧的序列,其中每一帧都是一幅彩色图像。因为每一帧可以存储在三阶张量中(高度、宽度、颜色深度),一系列帧可以存储在四阶张量中(帧、高度、宽度、颜色深度),因此不同视频的批次可以存储在形状为 (samples, frames, height, width, color_depth) 的五阶张量中。

例如,一个 60 秒长、144 × 256 像素的 YouTube 视频片段,以每秒 4 帧采样,将有 240 帧。四个这样的视频片段的批次将存储在形状为 (4, 240, 144, 256, 3) 的张量中。这总共有 106,168,320 个值!如果张量的数据类型为 R 整数,每个值将以 32 位存储,因此张量将表示 405 MB。太重了!你在现实生活中遇到的视频要轻得多,因为它们不是以 R 整数的形式存储的,而且通常被大幅压缩(例如 MPEG 格式)。

2.3 神经网络的齿轮:张量操作

就像任何计算机程序最终都可以归结为对二进制输入的少量二进制操作(AND、OR、NOR 等)一样,所有深度神经网络学到的转换都可以归结为应用于数值数据张量的少数张量操作(或张量函数)。例如,可以对张量进行加法、乘法等操作。在我们的初始示例中,我们通过将密集层堆叠在一起来构建模型。Keras 层实例如下所示:

layer_dense(units = 512, activation = "relu")

<keras.layers.core.dense.Dense object at 0x7f7b0e8cf520>

这一层可以被解释为一个函数,它以一个矩阵作为输入,并返回另一个矩阵——输入张量的一个新表示。具体来说,函数如下(其中 W 是一个矩阵,b 是一个向量,都是该层的属性):

output <- relu(dot(W, input) + b)

让我们来解释一下。我们在这里有以下三个张量操作:

  • 在输入张量和命名为 W 的张量之间的点积(dot)操作

  • 结果矩阵和向量 b 之间的加法(+)

  • 一个 relu 操作:relu(x)是逐元素 max(x, 0);relu代表修正线性单元

尽管本节完全涉及线性代数表达式,但你在这里找不到任何数学符号。我发现,如果将数学概念表达为简短的代码片段,而不是数学方程式,那么没有数学背景的程序员更容易掌握这些概念。因此,我们将在整个过程中使用 R 和 TensorFlow 代码。

2.3.1 逐元素操作

relu 操作和加法是逐元素操作:这些操作独立地应用于考虑的张量中的每个条目。这意味着这些操作非常适合于大规模并行实现(向量化实现,这个术语来自于 20 世纪 70-90 年代的向量处理器超级计算机架构)。如果你想编写一个逐元素操作的简单的 R 实现,你可以使用 for 循环,就像下面对逐元素 relu 操作的简单实现一样:

naive_relu <- functsion(x) {

stopifnot(length(dim(x)) == 2)➊

for (i in 1:nrow(x))

for (j in 1:ncol(x))

x[i, j] <- max(x[i, j], 0)

x

}

x 是一个秩为 2 的张量(一个矩阵)。

你可以对加法做同样的操作:

naive_add <- function(x, y) {

stopifnot(length(dim(x)) == 2, dim(x) == dim(y))➊

for (i in 1:nrow(x))

for (j in 1:ncol(x))

x[i, j] <- x[i, j] + y[i, j]

x

}

x 和 y 都是秩为 2 的张量。

按照相同的原则,你可以进行逐元素乘法、减法等操作。

在实践中,当处理 R 数组时,这些操作也作为优化良好的内置 R 函数可用,它们自己将重活交给了基本线性代数子程序(BLAS)实现。BLAS 是低级别、高度并行、高效的张量操作例程,通常用 Fortran 或 C 实现。因此,在 R 中,您可以执行以下逐元素操作,速度非常快:

z <- x + y➊

z[z < 0] <- 0➋

逐元素相加

逐元素 relu

让我们实际计算一下这里的时间差异:

random_array <- function(dim, min = 0, max = 1)

数组(runif(prod(dim), min, max),

dim)

x <- random_array(c(20, 100))

y <- random_array(c(20, 100))

system.time({

for (i in seq_len(1000)) {

z <- x + y

z[z < 0] <- 0

}

})[["elapsed"]]

[1] 0.009

这需要 0.009 秒。与此同时,简单版本需要惊人的 0.72 秒:

system.time({

for (i in seq_len(1000)) {

z <- naive_add(x, y)

z <- naive_relu(z)

}

})[["elapsed"]]

[1] 0.724

同样,当在 GPU 上运行 TensorFlow 代码时,逐元素操作通过完全矢量化的 CUDA 实现执行,这些实现可以最佳地利用高度并行的 GPU 芯片架构。

2.3.2 广播

我们先前的简单实现 naive_add 仅支持具有相同形状的秩 2 张量的加法。但是在前面介绍的 layer_dense() 中,我们将秩 2 张量与向量相加。当正在添加的两个张量的形状不同时,加法会发生什么情况?

我们希望的是较小的张量 广播 以匹配较大张量的形状。广播由以下两个步骤组成:

  1. 1 轴(称为 广播轴)被添加到较小的张量中,以匹配较大张量的 length(dim(x))。

  2. 2 较小的张量沿着这些新轴重复,以匹配较大张量的完整形状。

注意,Tensorflow Tensors,在第三章中介绍了丰富的广播功能。然而,在这里,我们正在使用 R 数组从头构建机器学习概念,并且故意避免了在操作两个不同维度数组时的 R 隐式重复行为。我们可以通过构建较小的张量来匹配较大张量的形状来实现我们自己的重复使用方法,这样我们再次回到了执行标准逐元素操作的地步。

让我们看一个具体的例子。考虑形状为 (32, 10) 的 X 和形状为 (10) 的 y:

X <- random_array(c(32, 10))➊

y <- random_array(c(10))➋

X 是形状为 (32, 10) 的随机矩阵。

y 是形状为 (10) 的随机向量。

首先,我们给 y 添加一个大小为 1 的第一个轴,其形状变为 (1, 10):

dim(y) <- c(1, 10)

str(y)➊

num [1, 1:10] 0.885 0.429 0.737 0.553 0.426 …

y 的形状现在是 (1, 10)。

然后,我们沿着这个新轴重复 y 32 次,使我们最终得到一个形状为 (32, 10) 的张量 Y,其中 Y[i, ] == y,对于 i 在 seq(32) 中:

Y <- y[rep(1, 32), ]➊

str(Y)

num [1:32, 1:10] 0.885 0.885 0.885 0.885 0.885 …

沿轴 1 重复 y 32 次以获取 Y,其形状为 (32, 10)。

在这一点上,我们可以继续添加 X 和 Y,因为它们具有相同的形状。

在实现方面,理想情况下我们不希望创建新的二维张量,因为那样非常低效。在大多数张量实现中,包括 R 和 TensorFlow,在算法级别上进行的是完全虚拟的重复操作,而不是在内存级别上。但是,请注意,R 的循环利用和 TensorFlow(以及 NumPy)的广播在行为上有所不同(我们在第三章中会详细介绍)。不管怎样,将向量重复 10 次并伴随一个新轴是一个有用的心理模型。下面是一个简单实现的样子:

naive_add_matrix_and_vector <- 函数(x, y) {

stopifnot(length(dim(x)) == 2,➊

length(dim(y)) == 1,➋

ncol(x) == 维度(y))

for (i in seq(维度(x)[1]))

for (j in seq(维度(x)[2]))

x[i, j] <- x[i, j] + y[j]

x

}

x 是一个二阶张量。

y 是一个向量。

2.3.3 张量积

张量积点积(不要与逐元素乘积,即操作符混淆)是最常见、最有用的张量运算之一。在 R 中,逐元素乘积使用 * 操作符进行,而点积使用 %% 操作符进行:

x <- 随机数组(c(32))

y <- 随机数组(c(32))

z <- x %*% y

在数学表示中,你会用一个点(•)来表示这个操作:

z = x • y

从数学上讲,点运算做什么?让我们从两个向量 x 和 y 的点积开始。它的计算方法如下:

naive_vector_dot <- 函数(x, y) {

stopifnot(length(dim(x)) == 1,➊

length(dim(y)) == 1,➊

维度(x) == 维度(y))

z <- 0

for (i in seq_along(x))

z <- z + x[i] * y[i]

z

}

➊ x 和 y 是大小相同的一维向量。

你可能已经注意到,两个向量之间的点积是一个标量,只有元素数量相同的向量才能进行点积运算。

你也可以对矩阵 x 和向量 y 进行点积运算,它返回一个向量,其中的系数是 y 和 x 的行之间的点积:

naive_matrix_vector_dot <- 函数(x, y) {

stopifnot(length(dim(x)) == 2,➊

length(dim(y)) == 1,➋

nrow(x) == 维度(y))➌

z <- 数组(0, 维度 = 维度(y))➍

for (i in 1:nrow(x))

for (j in 1:ncol(x))

z[i] <- z[i] + x[i, j] * y[j]

z

}

x 是一个二维张量(矩阵)。

y 是一个一维张量(向量)。

x 的第一个维度必须与 y 的第一个维度相同!

此操作返回一个与 y 形状相同的零向量。

你也可以重用我们之前编写的代码,这突出了矩阵-向量乘积和向量乘积之间的关系:

naive_matrix_vector_dot <- 函数(x, y) {

z <- 数组(0, 维度 = c(nrow(x)))

for (i in 1:nrow(x))

z[i] <- 通过求向量内积得到(x[i, ], y)

z

}

请注意,一旦其中一个张量的 length(dim(x)) 大于 1,%% 就不再是对称的,也就是说 x %% y 不等于 y %*% x。

当然,点积可以推广到具有任意数量轴的张量。最常见的应用可能是两个矩阵之间的点积。只有当 ncol(x) == nrow(y)时,您才能取两个矩阵 x 和 y 的点积(x %*% y)。结果是一个形状为(nrow(x), ncol(y))的矩阵,其中的系数是 x 的行和 y 的列之间的向量积。这里展示了朴素实现:

naive_matrix_dot <- function(x, y) {

stopifnot(length(dim(x)) == 2,➊

length(dim(y)) == 2,

ncol(x) == nrow(y))➋

z <- array(0, dim = c(nrow(x), ncol(y)))➌

for (i in 1:nrow(x))➍

for (j in 1:ncol(y)) {➎

row_x <- x[i, ]

column_y <- y[, j]

z[i, j] <- naive_vector_dot(row_x, column_y)

}

z

}

x 和 y 是 2D 张量(矩阵)。

x 的第一个维度必须与 y 的第一个维度相同!

此操作返回一个具有特定形状的零矩阵。

迭代 x 的行...

... 和 y 的列。

为了理解点积形状兼容性,有助于通过对齐输入和输出张量来可视化它们,如图 2.5 所示。

在图中,x、y 和 z 被描绘为矩形(系数的文字框)。由于 x 的行和 y 的列必须具有相同的大小,因此 x 的宽度必须与 y 的高度匹配。如果您继续开发新的机器学习算法,您可能经常会画出这样的图表。

图像

图 2.5 矩阵点积盒子图示

更一般地,您可以按照与 2D 情况下相同的形状兼容性规则,取高维张量之间的点积:

(a, b, c, d) • (d) -> (a, b, c)

(a, b, c, d) • (d, e) -> (a, b, c, e)

诸如此类。

2.3.4 张量重塑

理解的第三种张量操作是张量重塑。尽管它在我们第一个神经网络示例的 layer_dense()中没有使用,但我们在将手写数字数据预处理并输入模型之前使用了它,如下所示:

train_images <- array_reshape(train_images, c(60000, 28 * 28))

请注意,我们使用 array_reshape()函数而不是dim<-()函数来重塑 R 数组。这样做是为了使用行优先语义(而不是 R 的默认列优先语义)重新解释数据,这与 Keras 调用的数值库(NumPy、TensorFlow 等)解释数组维度的方式兼容。当重塑将传递给 Keras 的 R 数组时,应始终使用 array_reshape()函数。

重塑张量意味着重新排列其行和列以匹配目标形状。显然,重塑后的张量具有与初始张量相同的总系数数。通过简单示例最好理解重塑:

x <- array(1:6)

x

[1] 1 2 3 4 5 6

array_reshape(x, dim = c(3, 2))

[,1] [,2]

[1,]     1    2

[2,]     3    4

[3,]     5    6

array_reshape(x, dim = c(2, 3))

[,1] [,2] [,3]

[1,]     1    2     3

[2,]     4    5     6

重塑的一个常见特例是转置。 转置矩阵意味着交换其行和列,以便 x[i, ] 变为 x[, i]。我们可以使用 t() 函数来转置矩阵:

x <- array(1:6, dim = c(3, 2))

x

[,1] [,2]

[1,]     1    4

[2,]     2    5

[3,]     3    6

t(x)

[,1] [,2] [,3]

[1,]     1    2     3

[2,]     4    5     6

2.3.5 张量操作的几何解释

因为张量操作中的张量的内容可以解释为某些几何空间中点的坐标,所以所有张量操作都有一个几何解释。例如,我们来考虑加法。我们将从以下向量开始:

A = c(0.5, 1)

这是 2D 空间中的一个点(见图 2.6)。通常将向量描绘为将原点与点连接的箭头,如图 2.7 所示。

让我们考虑一个新点,B = c(1, 0.25),我们将其添加到之前的点上。这在几何上通过串联向量箭头来完成,结果位置是代表前两个向量之和的向量的位置(见图 2.8)。 如您所见,将向量 B 添加到向量 A 表示将点 A 复制到新位置,其距离和方向从原始点 A 决定的位置。 如果将相同的向量加法应用于平面上的一组点(一个对象),则将在新位置创建整个对象的副本(见图 2.9)。 因此,张量加法表示平移对象(将对象移动而不会扭曲)一定量的动作在某个方向上。

Image

图 2.6 2D 空间中的一个点

Image

图 2.7 2D 空间中的一个点被描绘为箭头

一般来说,诸如平移、旋转、缩放、扭曲等基本几何操作都可以表达为张量操作。以下是一些示例:

Image

图 2.8 两个向量之和的几何解释

  • 平移— 正如你刚才看到的,将向量添加到一个点将使该点沿着固定方向移动固定量。 应用于一组点(如二维对象),这称为“平移”(见图 2.9)。

Image

图 2.9 2D 平移作为向量加法

  • 旋转— 将 2D 向量逆时针旋转一个角度 theta(见图 2.10)可以通过与 2 × 2 矩阵 R = rbind(c(cos(theta), -sin(theta)), c(sin(theta), cos(theta)))的点积来实现。

Image

图 2.10 2D 旋转(逆时针)作为点积

  • 缩放—通过与 2×2 矩阵 S=rbind(c(horizontal_factor,0), c(0,vertical_factor))的点积实现图像的垂直和水平缩放(参见图 2.11)。请注意,这样的矩阵被称为对角矩阵,因为它只在其“对角线”(从左上到右下)上有非零系数。

图像

图 2.11 作为点积的二维缩放

  • 线性变换—与任意矩阵的点积实现线性变换。请注意,前面列出的缩放旋转都是线性变换的定义。

  • 仿射变换—仿射变换(参见图 2.12)是线性变换(通过与某些矩阵的点积实现)和平移(通过矢量加法实现)的组合。正如您可能已经认识到的那样,这正是由 layer_dense()实现的 y=W•x+b 计算!没有激活函数的 Dense 层就是一个仿射层。

图像

图 2.12 平面中的仿射变换

  • 有关仿射变换的一个重要观察是,如果您重复应用许多仿射变换,您最终仍将得到一种仿射变换(因此,您可以从一开始就应用该一种仿射变换)。让我们尝试两个:affine2(affine1(x))=W2•(W1•x+b1)+b2=(W2•W1)•x+(W2•b1+b2)。其中,线性部分是矩阵 W2•W1,平移部分是向量 W2•b1+b2。因此,完全由 Dense 层组成且没有激活函数的多层神经网络等价于一个单一的 Dense 层。这种“深度”神经网络实际上只是一个线性模型的伪装!这就是为什么需要激活函数,例如 ReLU(在图 2.13 中展示)。由于激活函数,Dense 层的链可以实现非常复杂的非线性几何变换,从而为深度神经网络提供非常丰富的假设空间。我们将在下一章中详细介绍这个想法。

图像

图 2.13 仿射变换后的 ReLU 激活

2.3.6 深度学习的几何解释

您刚刚学习到神经网络完全由输入数据的简单几何变换组成,这些张量运算只是在高维空间中实现的非常复杂的几何变换系列。因此,您可以将神经网络解释为非常复杂的几何变换,由一系列简单步骤实现。

在 3D 视图中,以下心理形象可能会有所帮助。想象两张彩色纸:一张红色,一张蓝色。将一张放在另一张上面。现在将它们揉成一个小球。那个揉皱的纸球就是你的输入数据,而每张纸是分类问题中的一类数据。神经网络的目的是找出一个能够将纸球展平的变换,使得两类数据重新变得清晰可分(参见图 2.14)。对于深度学习来说,这将被实现为对三维空间的一系列简单变换,就像你可以用手指在纸球上做的那样,一次移动一个轴。

图片

图 2.14 解开复杂的数据流形

展平纸球正是机器学习的目标:在高维空间中为复杂的、高度折叠的数据流形找到整洁的表示(流形是一种连续的曲面,就像我们揉皱的纸)。此时,你应该对为什么深度学习在这方面表现出色有一个相当好的直觉:它采取了将复杂几何变换逐步分解为一长串基本变换的方法,这几乎就是人类用来展平纸球的策略。深度网络中的每一层都应用了一个轻微地解缠数据的变换,而深层的层叠则使得这个极其复杂的解缠过程变得可行。

2.4 神经网络的引擎:基于梯度的优化

如前几节所示,我们第一个模型示例中的每个神经层将其输入数据转换为以下形式:

output <- relu(dot(input, W) + b)

在这个表达式中,W 和 b 是层的属性的张量。通常称为层的权重可训练参数(分别是 kernel 和 bias 属性)。这些权重包含了模型从训练数据中学到的信息。

最初,这些权重矩阵被填充为小的随机值(这一步被称为随机初始化)。当然,没有理由指望当 W 和 b 是随机时,relu(dot(input, W) + b) 会产生任何有用的表示。由此产生的表示是毫无意义的,但它们是一个起点。接下来的步骤是根据反馈信号逐渐调整这些权重。这种逐渐调整,也称为训练,就是机器学习的学习过程。这发生在所谓的训练循环中,工作方式如下。重复以下步骤,直到损失似乎足够低:

  1. 1 随机选择一批训练样本 x,并对应的目标值 y_true。

  2. 2 在 x 上运行模型(称为前向传播)以获得预测结果 y_pred。

  3. 3 计算模型在批量数据上的损失,衡量 y_pred 和 y_true 之间的不匹配程度。

  4. 更新模型的所有权重,以稍微减少此批次上的损失。

最终,您将得到一个模型,其训练数据上的损失非常低:预测 y_pred 和期望目标 y_true 之间的低不匹配。模型已经“学会”将其输入映射到正确的目标。从远处看,它可能看起来像是魔术,但当您将其简化为基本步骤时,它变得简单。

第 1 步听起来很容易——只是 I/O 代码。步骤 2 和步骤 3 仅仅是一些张量操作的应用,因此您可以纯粹地从前一节中学到的内容来实现这些步骤。困难的部分是步骤 4:更新模型的权重。给定模型中的一个单独的权重系数,您如何计算系数是否应增加或减少,以及增加或减少多少?

一个天真的解决方案是冻结模型中的所有权重,除了正在考虑的一个标量系数,并尝试不同的值用于此系数。假设系数的初始值为 0.3。在一批数据的前向传递之后,模型对该批次的损失为 0.5。如果将系数的值更改为 0.35 并重新运行前向传递,则损失增加到 0.6。但如果将系数降低到 0.25,则损失降至 0.4。在这种情况下,似乎通过-0.05 更新系数将有助于最小化损失。这必须对模型中的所有系数重复进行。

但这样的方法将非常低效,因为您需要为每个单独的系数计算两次前向传递(这些操作很昂贵)(其中有许多系数——通常是数千个,有时甚至高达数百万个)。幸运的是,有一个更好的方法:梯度下降。

梯度下降是驱动现代神经网络的优化技术。这是它的要点:我们模型中使用的所有函数(如点或+)都以平滑连续的方式转换其输入。例如,如果您查看 z = x + y,小的 y 变化仅导致 z 的小变化,并且如果您知道 y 变化的方向,您可以推断 z 变化的方向。数学上,您会说这些函数是可微的。如果您链接这样的函数,您得到的较大函数仍然是可微的。特别是,这适用于将模型的系数映射到一批数据上的模型损失的函数:模型的系数的微小变化导致损失值的微小且可预测的变化。这使您能够使用称为梯度的数学运算符描述损失如何随着您在不同方向上移动模型的系数而变化。如果计算此梯度,则可以使用它将系数(一次性一次性更新,而不是逐个更新)向减少损失的方向移动。

如果您已经知道可微分的含义和梯度是什么,可以直接跳到第 2.4.3 节。否则,接下来的两节将帮助您理解这些概念。

2.4.1 什么是导数?

考虑一个连续且平滑的函数 f(x) = y,将一个数 x 映射到一个新数 y。我们可以用 图 2.15 中的函数作为示例。

因为函数是连续的,x 的微小变化只能导致 y 的微小变化——这就是连续性背后的直觉。假设您将 x 增加一个小因子 epsilon_x:这将导致 y 发生一个小的 epsilon_y 变化,如 图 2.16 所示。

此外,由于函数是平滑的(其曲线没有任何突然的角度),当 epsilon_x 足够小,围绕某一点 p 时,可以将 f 近似为斜率为 a 的线性函数,使得 epsilon_y 变为 * epsilon_x:

f(x + epsilon_x) = y + a * epsilon_x

显然,这种线性近似仅在 x 足够接近 p 时有效。

图像

图 2.15 一个连续且平滑的函数

图像

图 2.16 对于一个连续函数,x 的微小变化会导致 y 的微小变化。

图像

图 2.17 在 p 点的 f 的导数

斜率 a 被称为 p 点处 f 的导数。如果 a 是负的,意味着在 p 点周围 x 的微小增加会导致 f(x) 的减少(如 图 2.17 所示),如果 a 是正的,x 的微小增加会导致 f(x) 的增加。此外,a 的绝对值(导数的大小)告诉您这种增加或减少将发生的速度。

对于每个可微分函数 f(x)(可微分意味着“可以求导以找到导数”:例如,平滑、连续函数可以求导),都存在一个导数函数 f’(x),将 x 的值映射到这些点上 f 的局部线性近似的斜率。例如,cos(x) 的导数是 -sin(x),f(x) = a * x 的导数是 f’(x) = a,等等。

当涉及优化时,能够对函数进行微分是一种非常强大的工具,优化是找到最小化 f(x) 值的 x 的任务。如果您尝试通过因子 epsilon_x 更新 x 以最小化 f(x),并且您知道 f 的导数,那么您的任务就完成了:导数完全描述了 f(x) 随着 x 变化的方式。如果您想要减少 f(x) 的值,您只需要将 x 沿着导数的相反方向移动一点。

2.4.2 张量运算的导数:梯度

我们刚刚看的函数将一个标量值 x 转换为另一个标量值 y:你可以将其绘制为二维平面上的曲线。现在想象一个将一组标量(x,y)转换为标量值 z 的函数:这将是一个矢量操作。你可以将其绘制为三维空间中的二维表面(由坐标 x、y、z 索引)。同样,你可以想象将矩阵作为输入的函数,将秩为 3 的张量作为输入的函数,依此类推。

差分的概念可以应用于任何这样的函数,只要它们描述的表面是连续的和光滑的。张量操作(或张量函数)的导数称为梯度。梯度只是将导数的概念推广到以张量作为输入的函数。记得标量函数的导数代表函数曲线的局部斜率吗?以同样的方式,张量函数的梯度代表函数描述的多维表面的曲率。它表征了函数的输出在其输入参数变化时的变化情况。

让我们看一个机器学习中的例子。考虑以下情况:

  • 一个输入向量,x(数据集中的一个样本)

  • 一个矩阵,W(模型的权重)

  • 一个目标,y_true(模型应该学习将其与 x 相关联)

  • 一个损失函数,loss_fn()(用于衡量模型当前预测和 y_true 之间的差距)

你可以使用 W 计算一个目标候选 y_pred,然后计算目标候选 y_pred 与目标 y_true 之间的损失或不匹配:

y_pred <- dot(W, x)➊

loss_value <- loss_fn(y_pred, y_true)➋

我们使用模型权重 W 对 x 进行预测。

我们估计预测的偏差有多大。

现在,我们想使用梯度来找出如何更新 W 以使 loss_value 变小。我们该如何做?给定固定的输入 x 和 y_true,前述操作可以解释为将 W 的值(模型的权重)映射到 loss 值的函数:

loss_value <- f(W)➊

f()描述了当 W 变化时损失值形成的曲线(或高维表面)。

假设当前值为 W0。那么 f()在点 W0 处的导数是一个张量 grad(loss_value, W0),与 W 具有相同的形状,其中每个系数 grad(loss_ value, W0)[i, j]指示修改 W0[i, j]时观察到的 loss_value 变化的方向和大小。该张量 grad(loss_value, W0)是函数 f(W) = loss_value 在 W0 处的梯度,也称为“关于 W 在 W0 附近的 loss_value 的梯度”。

偏导数

张量操作 grad(f(W), W)(将矩阵 W 作为输入)可以表示为标量函数 grad_ij(f(W), w_ij)的组合,其中每个函数将返回 loss_value = f(W)相对于 W 的系数 W[i, j]的导数,假设所有其他系数都是常数。grad_ij 称为 f 相对于 W[i, j]的偏导数

具体来说,grad(loss_value, W0) 代表什么?你之前看到过,函数 f(x) 的导数可以解释为 f() 曲线的斜率。同样,grad(loss_value, W0) 可以解释为描述 loss_value = f(W) 在 W0 附近的 最陡上升方向 的张量,以及这种上升的斜率。每个偏导数描述了特定方向上 f() 的斜率。

出于这个原因,就像对于函数 f(x),你可以通过将 x 沿着导数相反的方向移动一点来减少 f(x) 的值一样,对于张量的函数 f(W),你可以通过将 W 沿着梯度相反的方向移动来减少 loss_value = f(W):例如,W1 = W0 - step * grad(f(W0), W0)(其中 step 是一个小的缩放因子)。这意味着朝着 f 的最陡上升方向反向移动,直观上应该使你在曲线上更低。注意,缩放因子 step 是必需的,因为 grad(loss_value, W0) 仅在接近 W0 时近似曲率,所以你不希望离 W0 太远。

2.4.3 随机梯度下降

给定一个可微分函数,理论上可以在分析上找到其最小值:众所周知,函数的最小值是导数为 0 的点,所以你所要做的就是找到导数为 0 的所有点,并检查这些点中哪个点函数具有最小值。

应用于神经网络,这意味着在理论上找到使损失函数可能最小的权重值组合。这可以通过求解方程 grad(f(W), W) = 0 来完成对 W。这是一个包含 N 个变量的多项式方程,其中 N 是模型中的系数数目。尽管可以解决 N = 2 或 N = 3 的方程,但在实际的神经网络中,解决这样的方程是不可行的,因为参数的数量从来不少于几千,并且通常可以达到几千万。

相反,你可以使用本节开头概述的四步算法:根据当前数据的随机批次的当前损失值逐渐修改参数,如下所示。因为你正在处理一个可微分的函数,你可以计算其梯度,这给你了一个实现步骤 4 的高效方法。如果你更新权重与梯度相反的方向,损失每次都会减少一点:

  1. 1 绘制一批训练样本 x 和相应的目标 y_true。

  2. 2 运行模型在 x 上以获得预测值,y_pred(这称为 前向传播)。

  3. 3 计算模型在批处理上的损失,即 y_pred 和 y_true 之间的不匹配程度的度量。

  4. 4 计算损失相对于模型参数的梯度(这称为 反向传播)。

  5. 5 将参数稍微朝着与梯度相反的方向移动一点,例如,W = W - (learning_rate * gradient),从而稍微减少批处理上的损失。学习速率(这里是 learning_rate)将是调节梯度下降过程“速度”的标量因子。

很简单!我们刚刚描述的称为小批量随机梯度下降(小批量 SGD)。术语随机是指每个数据批次都是随机绘制的(随机随机的科学同义词)。图 2.18 说明了在一维情况下的情况,当模型只有一个参数时,而你只有一个训练样本。

正如你所看到的,直观地选择合理的学习率因子是很重要的。如果太小,曲线下降将需要很多次迭代,并且可能会陷入局部最小值。如果学习速率太大,你的更新可能会将你带到曲线上完全随机的位置。

图像

图 2.18 在一维损失曲线上的 SGD(一个可学习参数)

注意,小批量 SGD 算法的一种变体是在每次迭代中绘制单个样本和目标,而不是绘制一批数据。这将是真正的SGD(与小批量SGD 相对)。或者,走向相反的极端,你可以在所有可用数据上运行每一步,这称为批量梯度下降。然后,每个更新将更加准确,但成本更高。在这两个极端之间的有效折衷是使用合理大小的小批量。

虽然图 2.18 说明了在一维参数空间中的梯度下降,但在实际中,你会在高维空间中使用梯度下降:神经网络中的每个权重系数都是空间中的自由维度,可能有成千上万甚至数百万个。为了帮助你对损失曲面建立直觉,你也可以将梯度下降可视化为沿着二维损失曲面的过程,如图 2.19 所示。但你不可能可视化神经网络训练的实际过程——你无法用对人类有意义的方式表示一个 100 万维的空间。因此,要记住,你通过这些低维表示所形成的直觉在实践中可能并不总是准确的。这在深度学习研究领域历史上一直是一个问题的根源。

图像

图 2.19 二维损失曲面上的梯度下降(两个可学习参数)

此外,存在多种不同的 SGD 变种,其在计算下一个权重更新时,不仅仅考虑当前梯度值,还考虑之前的权重更新值。例如,动量 SGD,以及 AdaGrad、RMSprop 等等。这些变种被称为优化方法优化器。特别是,许多这些变种中使用的动量的概念值得关注。动量解决了 SGD 的两个问题:收敛速度和局部最小值。考虑图 2.20,它展示了一种模型参数的损失曲线。

Image

图 2.20 局部最小值和全局最小值

如你所见,在某一参数值附近,存在一个局部最小值:在该点附近,向左移动会导致损失增加,向右移动也会导致损失增加。如果正在通过小学习率使用 SGD 来优化考虑的参数,优化过程可能会陷入局部最小值而无法到达全局最小值。

你可以通过使用动量来避免这样的问题,动量受到物理学的启发。一个有用的心理形象是将优化过程想象成一个小球沿着损失曲线滚动的过程。如果它有足够的动量,小球不会被困在沟壑中,并最终到达全局最小值。动量是通过根据当前斜率值(当前加速度)和当前速度(过去加速度的结果)来每步移动球实现的。实际上,这意味着更新参数 w 不仅基于当前梯度值,而且还基于先前的参数更新,如下面的简单实现所示:

过去速度 <- 0

动量 <- 0.1➊

重复 {➋

p <- get_current_parameters()➌

如果 (p$loss <= 0.01)

跳出

速度 <- 过去速度 * 动量 + 学习率 * p\(gradient w <- p\)w + 动量 * 速度 - 学习率 * p$gradient

过去速度 <- 速度

更新参数(w)

}

常数动量因子

优化循环

p 包含:w,损失,梯度

2.4.4 链式导数:反向传播算法

在前面的算法中,我们随意地假设因为一个函数是可导的,我们可以很容易地计算它的梯度。但这是真的吗?我们如何在实践中计算复杂表达式的梯度?在我们本章一开始的两层模型中,我们如何得到损失对于权重的梯度?这就是反向传播算法的用武之地。

链式法则

反向传播是一种使用简单操作的导数(如加法、relu 或张量乘积)来轻松计算这些原子操作的任意复合操作的梯度的方法。关键是,神经网络由许多链接在一起的张量操作组成,每个操作的导数都是简单且已知的。例如,例 2.2 中定义的模型可以表示为由变量 W1、b1、W2 和 b2(分别属于第一和第二个密集层)参数化的函数,涉及原子操作 dot、relu、softmax 和 +,以及我们的损失函数 loss,这些都很容易可导:

loss_value <- loss(y_true,

softmax(dot(relu(dot(inputs, W1) + b1), W2) + b2))

微积分告诉我们,这样的函数链可以使用以下恒等式进行求导,称为链式法则

考虑两个函数 f 和 g,以及复合函数 fg,使得 fg(x) == f(g(x)):

fg <- function(x) {

x1 <- g(x)

y <- f(x1)

y

}

然后链式法则表明 grad(y, x) == grad(y, x1) * grad(x1, x)。只要你知道 f 和 g 的导数,就能计算出 fg 的导数。链式法则之所以这样命名是因为当你添加更多的中间函数时,它开始看起来像一个链条:

fghj <- function(x) {

x1 <- j(x)

x2 <- h(x1)

x3 <- g(x2)

y <- f(x3)

y

}

grad(y, x) == (grad(y, x3) * grad(x3, x2) *

图片 grad(x2, x1) * grad(x1, x))

将链式法则应用于神经网络梯度值的计算会产生一种称为反向传播的算法。让我们具体了解一下它的工作原理。

使用计算图的自动微分

一个有用的思考反向传播的方式是使用计算图。计算图是 TensorFlow 和深度学习革命的核心数据结构。它是一个有向无环图的操作数据结构——在我们的情况下,张量操作。例如,图 2.21 显示了我们第一个模型的图形表示。

图片

图 2.21 一种两层模型的计算图表示

计算图在计算机科学中是一个极其成功的抽象,因为它们使我们能够将计算视为数据:可计算的表达式被编码为一种机器可读的数据结构,可以用作另一个程序的输入或输出。例如,您可以想象一个接收计算图并返回实现相同计算的大规模分布式版本的新计算图的程序。这意味着您可以分发任何计算而不必自己编写分发逻辑。或者想象一个接收计算图并可以自动生成表示其代表的表达式的导数的程序。如果您的计算以明确的图形数据结构而不是比如说.R 文件中的 ASCII 字符行表示,则执行这些操作要容易得多。

要清楚地解释反向传播,让我们看一个真正基本的计算图示例(见图 2.22)。我们将考虑一个简化版本的图 2.21,其中我们只有一个线性层,并且所有变量都是标量。我们将取两个标量变量 w 和 b,一个标量输入 x,并对它们进行一些操作以将它们组合成输出 y。最后,我们将应用绝对值误差损失函数:loss_val = abs(y_true - y)。因为我们想要以最小化 loss_val 的方式更新 w 和 b,所以我们对计算 grad(loss_val, b)和 grad(loss_val, w)感兴趣。

图片

图 2.22 一个基本的计算图示例

让我们为图中的“输入节点”设置具体值,也就是说,输入 x,目标 y_true,w 和 b。我们将这些值传播到图中的所有节点,从上到下,直到达到 loss_val。这是正向传播(见图 2.23)。

现在让我们“反转”图表:对于图表中从 A 到 B 的每条边,我们将创建一个从 B 到 A 的相反边,并询问,当 A 变化时,B 变化多少?也就是说,grad(B, A)是多少?我们将用这个值注释每条倒置边。这个反向图表代表反向传播(见图 2.24)。

我们有以下:

  • grad(loss_val, x2) = 1,因为当 x2 变化一个量 epsilon 时,loss_val = abs (4 -x2)也变化相同的量。

  • grad(x2, x1) = 1,因为当 x1 变化一个量 epsilon 时,x2 = x1 + b = x1 + 1 也变化相同的量。

图片

图 2.23 运行正向传播

图片

图 2.24 运行反向传播

  • grad(x2, b) = 1,因为当 b 变化一个量 epsilon 时,x2 = x1 + b = 6 + b 也变化相同的量。

  • grad(x1, w) = 2,因为当 w 变化一个量 epsilon 时,x1 = x * w = 2 * w 变化为 2 * epsilon。

关于这个反向图,链式法则告诉我们,可以通过沿着连接两个节点的路径的每个边缘的导数相乘来获得相对于另一个节点的节点的导数,例如,grad(loss_val, w) = grad(loss_val, x2) * grad(x2, x1) * grad(x1, w)(参见 图 2.25)。

通过将链式法则应用于我们的图,我们获得了我们所要寻找的内容:

  • grad(loss_val, w) = 1 * 1 * 2 = 2

  • grad(loss_val, b) = 1 * 1 = 1

如果在反向图中存在连接两个感兴趣节点 a 和 b 的多条路径,则通过求和所有路径的贡献来获得 grad(b, a)。

这样一来,你就看到了反向传播的实际应用!反向传播简单地将链式法则应用于计算图。没有更多了。反向传播从最终损失值开始,从顶层向底层逆向工作,计算每个参数在损失值中的贡献。这就是“反向传播”这个名字的由来:我们在计算图中“反向传播”不同节点的损失贡献。

如今,人们在现代框架中实现神经网络,这些框架能够进行自动微分,例如 TensorFlow。自动微分是使用刚刚看到的计算图实现的。自动微分使得可以检索可微张量操作的任意组合的梯度,而无需除了编写正向传递之外的任何额外工作。当我(弗朗索瓦)在 2000 年代用 C 编写我的第一个神经网络时,我不得不手动编写我的梯度。现在,由于现代自动微分工具,你再也不必自己实现反向传播了。算你幸运!

Image

图 2.25 从 loss_val 到 w 的反向图路径

TensorFlow 中的梯度带

你可以利用 TensorFlow 强大的自动求导功能的 API 是 GradientTape()。它是一个上下文管理器,将记录其范围内运行的张量操作,以计算图的形式(有时称为“带子”)。然后可以使用此图检索任何输出相对于任何变量或一组变量(TensorFlow Variable 类的实例)的梯度。tf$Variable 是一种特定类型的张量,用于保存可变状态,例如,神经网络的权重始终是 TensorFlow Variable 实例:

library(tensorflow)

x <- tf$Variable(0)➊

with(tf$GradientTape() %as% tape, {➋

y <- 2 * x + 3➌

})➍

grad_of_y_wrt_x <- tape$gradient(y, x)➎

实例化一个初始值为 0 的标量变量。

打开一个 GradientTape 范围。

在范围内,对我们的变量应用一些张量操作。

退出作用域。

使用带子检索输出 y 相对于我们的变量 x 的梯度。

GradientTape() 与张量操作一起工作如下:

x <- tf$Variable(array(0, dim = c(2, 2)))➊

with(tf$GradientTape() %as% tape, {

y <- 2 * x + 3

})

grad_of_y_wrt_x <- as.array(tape$gradient(y, x))➋

创建一个形状为 (2, 2) 的变量,并将其初始值设为全零。

grad_of_y_wrt_x 是一个形状为 (2, 2)(与 x 相同)的张量,描述了 y = 2 * a + 3 在 x = array(0, dim = c(2, 2)) 周围的曲率。

注意,tape$gradient() 返回一个 TensorFlow 张量,我们用 as.array() 将其转换为 R 数组。GradientTape() 也可以用于变量列表:

W <- tf$Variable(random_array(c(2, 2)))

b <- tf$Variable(array(0, dim = c(2)))

x <- random_array(c(2, 2))

with(tf$GradientTape() %as% tape, {

y <- tf$matmul(x, W) + b➊

})

grad_of_y_wrt_W_and_b <- tape$gradient(y, list(W, b))

str(grad_of_y_wrt_W_and_b)➋

从中可以学到更多有关梯度带的知识。

$ :<tf.Tensor: shape=(2, 2), dtype=float64, numpy=…>

$ :<tf.Tensor: shape=(2), dtype=float64, numpy=array([2., 2.])>

matmul 是 TensorFlow 中表示“点乘”的方法。

grad_of_y_wrt_W_and_b 是两个张量列表,分别具有与 W 和 b 相同的形状。

你将在下一章节学到更多关于梯度带的内容。

2.5 回顾我们的第一个示例

你已接近本章的结尾,现在你应该对神经网络背后的操作有了一般的理解。本章开始时的神奇黑盒已经变成了一个更清晰的图景,正如图 2.26 所示:模型由相互连接的层组成,将输入数据映射到预测结果。损失函数然后将这些预测与目标进行比较,产生一个损失值:衡量模型预测与预期值匹配程度的指标。优化器使用这个损失值来更新模型的权重。

图像

图 2.26 神经网络、层、损失函数和优化器之间的关系

让我们回到本章的第一个示例,并根据你学到的内容逐个审视它。

这是输入数据:

library(keras)

mnist <- dataset_mnist()

train_images <- mnist\(train\)x

train_images <- array_reshape(train_images, c(60000, 28 * 28))

train_images <- train_images / 255

test_images <- mnist\(test\)x

test_images <- array_reshape(test_images, c(10000, 28 * 28))

test_images <- test_images / 255

train_labels <- mnist\(train\)y

test_labels <- mnist\(test\)y

现在你明白了输入图像以 R 数组的形式存储,形状分别为 (60000, 784)(训练数据)和 (10000, 784)(测试数据)。

这是我们的模型:

model <- keras_model_sequential(list(

layer_dense(units = 512, activation = "relu"),

layer_dense(units = 10, activation = "softmax")

List of 2

现在你明白了这个模型由两个 Dense 层的链组成,每个层对输入数据应用了几个简单的张量操作,这些操作涉及权重张量。权重张量是层的属性,它们是模型知识的存储位置。

这是模型编译步骤:

编译(model,

优化器 = "rmsprop",

损失 = "稀疏分类交叉熵",

度量 = c("准确度"))

现在你明白了,稀疏分类交叉熵是用于学习权重张量的反馈信号,训练阶段将尝试最小化这个损失。你还知道,这种损失的减少是通过小批量随机梯度下降来实现的。具体的梯度下降使用规则由传递为第一个参数的 rmsprop 优化器定义。

最后,这是训练循环:

训练(model, train_images, train_labels, epochs = 5, batch_size = 128)

现在你明白了调用 fit 时会发生什么:模型将开始以 128 个样本的迷你批次迭代训练数据,重复五次(对所有训练数据的每次迭代称为一个epoch)。对于每个批次,模型将计算损失相对于权重的梯度(使用反向传播算法,这来源于微积分中的链式法则),并使权重沿着能够减少该批次损失的方向移动。

在这五个 epochs 之后,模型将进行了 2,345 次梯度更新(每个 epoch 469 次),并且模型的损失将足够低,使得模型能够以高精度对手写数字进行分类。

此时,你已经知道了关于神经网络的所有知识。让我们通过在 TensorFlow 中一步步从头重新实现第一个示例的简化版本来证明这一点。

2.5.1 在 TensorFlow 中从头重新实现我们的第一个示例

还有什么比从头开始实现一切更能充分、明确地展示理解力呢?当然,这里的“从头”是相对的:我们不会重新实现基本张量操作,也不会实现反向传播。但我们会深入到如此低的层次,以至于几乎不会使用任何 Keras 功能。

不用担心如果你还没有完全理解这个例子的每一个细节。下一章将更深入地探讨 TensorFlow API。目前,只需尝试跟随正在发生的事情的要点即可——本例子的目的是通过具体的实现帮助你明确深度学习的数学原理。让我们开始吧!

一个简单的 DENSE 类

你之前学到过 Dense 层实现了如下输入变换,其中 W 和 b 是模型参数,激活函数()是一个逐元素函数(通常是 relu(),但在最后一层会是 softmax()):

output <- 激活(dot(W, 输入) + b)

让我们在 R 环境中实现一个简单的 Dense 层,带有 NaiveDense 类属性、两个 TensorFlow 变量 W 和 b,以及一个应用前述变换的 call() 方法:

layer_naive_dense <- function(input_size, output_size, activation) {

self <- new.env(parent = emptyenv())

attr(self, "class") <- "NaiveDense"

self$activation <- activation

w_shape <- c(input_size, output_size)

w_initial_value <- random_array(w_shape, min = 0, max = 1e-1)

self\(W <- tf\)Variable(w_initial_value)➊

b_shape <- c(output_size)

b_initial_value <- array(0, b_shape)

self\(b <- tf\)Variable(b_initial_value)➋

self\(weights <- list(self\)W, self$b)➌

self$call <- function(inputs) {➍

self\(activation(tf\)matmul(inputs, self\(W) + self\)b)➎

}

self

}

创建一个形状为(input_size, output_size)的矩阵 W,用随机值进行初始化。

创建一个形状为(output_size)的向量 b,用零进行初始化。

方便地检索所有层的权重的属性。

在一个名为 call 的函数中应用前向传播。

在这个函数中我们坚持使用 TensorFlow 操作,以便 GradientTape 可以追踪它们。(我们将在第三章学习更多关于 TensorFlow 操作的知识。)

一个简单的顺序类

现在,让我们创建一个 naive_model_sequential()来链接这些层,如下一个代码片段所示。它包装了一个层的列表,并公开了一个 call()方法,该方法简单地按顺序在输入上调用底层层。它还具有一个 weights 属性,用于轻松跟踪层的参数:

naive_model_sequential <- function(layers) {

self <- new.env(parent = emptyenv())

attr(self, "class") <- "NaiveSequential"

self$layers <- layers

weights <- lapply(layers, function(layer) layer$weights)

self$weights <- do.call(c, weights)➊

self$call <- function(inputs) {

x <- inputs

for (layer in self$layers)

x <- layer$call(x)

x

}

self

}

展开嵌套列表一层。

使用这个 NaiveDense 类和这个 NaiveSequential 类,我们可以创建一个模拟 Keras 模型:

model <- naive_model_sequential(list(

layer_naive_dense(input_size = 28 * 28, output_size = 512,

activation = tf\(nn\)relu),

layer_naive_dense(input_size = 512, output_size = 10,

activation = tf\(nn\)softmax)

))

stopifnot(length(model$weights) == 4)

一个批量生成器

接下来,我们需要一种方法来在小批量上迭代 MNIST 数据。这很容易:

new_batch_generator <- function(images, labels, batch_size = 128) {

self <- new.env(parent = emptyenv())

attr(self, "class") <- "BatchGenerator"

stopifnot(nrow(images) == nrow(labels))

self$index <- 1

self$images <- images

self\(labels <- labels self\)batch_size <- batch_size

self$num_batches <- ceiling(nrow(images) / batch_size)

self$get_next_batch <- function() {

start <- self$index

if(start > nrow(images))

return(NULL)➊

end <- start + self$batch_size - 1

if(end > nrow(images))

end <- nrow(images)➋

self$index <- end + 1

indices <- start:end

list(images = self$images[indices, ],

labels = self$labels[indices])

}

self

}

生成器已完成。

最后一批可能会较小。

2.5.2 运行一个训练步骤

这个过程中最困难的部分是“训练步骤”:在一批数据上运行模型后更新模型的权重。我们需要做以下工作:

  1. 1 计算批量图像的模型预测。

  2. 2 计算这些预测的损失值,给定实际标签。

  3. 3 计算损失相对于模型权重的梯度。

  4. 4 将权重朝着梯度相反的方向移动一个小量。

要计算梯度,我们将使用我们在第 2.4.4 节中介绍的 TensorFlow GradientTape 对象:

one_training_step <- function(model, images_batch, labels_batch) {

with(tf$GradientTape() %as% tape, {

predictions <- model$call(images_batch)➊

每个样本的损失 <

loss_sparse_categorical_crossentropy(labels_batch, predictions)

average_loss <- mean(per_sample_losses)

})

gradients <- tape\(gradient(average_loss, model\)weights)➋

update_weights(gradients, model$weights)➌

average_loss

}

运行前向传播(在 GradientTape 范围内计算模型的预测)。

计算损失相对于权重的梯度。输出梯度是一个列表,其中每个条目对应于模型$weights 列表中的一个权重。

使用梯度更新权重(我们将很快定义此函数)。

正如你已经知道的,"weight update" 步骤的目的(由前面的 update_weights() 函数表示)是在一个方向上将权重移动“一点”,以减少这个批次上的损失。移动的大小由“学习率”确定,通常是一个小量。实现这个 update_weights() 函数的最简单方法是从每个权重中减去梯度 * 学习率:

学习率 <- 1e-3

update_weights <- function(gradients, weights) {

stopifnot(length(gradients) == length(weights))

for (i in seq_along(weights))

weights[[i]]$assign_sub(➊

gradients[[i]] * learning_rate)

}

x$assign_sub(value) 相当于 x <- x - value 对于 TensorFlow 变量。

实际上,你几乎不会手动实现像这样的权重更新步骤。而是会使用来自 Keras 的 Optimizer 实例:

optimizer <- optimizer_sgd(learning_rate = 1e-3)

update_weights <- function(gradients, weights)

optimizer$apply_gradients(zip_lists(gradients, weights))

zip_lists() 是一个辅助函数,用于将梯度和权重列表转换为 (gradient, weight) 对的列表。我们用它将梯度与权重配对给优化器。例如:

str(zip_lists(

gradients = list("grad_for_wt_1", "grad_for_wt_2", "grad_for_wt_3"),

权重 = list("weight_1", "weight_2", "weight_3")))

List of 3

$ :List of 2

..$ gradients: chr "grad_for_wt_1"

..$ weights : chr "weight_1"

$ :List of 2

..$ gradients: chr "grad_for_wt_2"

..$ weights : chr "weight_2"

$ :List of 2

..$ 梯度: chr "grad_for_wt_3"

..$ weights : chr "weight_3"

现在我们的每批训练步骤已经准备好了,我们可以继续实现整个训练的一个 epoch。

2.5.3 完整的训练循环

训练的一个 epoch 简单地是重复训练数据中每个批次的训练步骤,而完整的训练循环简单地是一个 epoch 的重复:

fit <- function(model, images, labels, epochs, batch_size = 128) {

for (epoch_counter in seq_len(epochs)) {

cat("Epoch ", epoch_counter, "\n")

batch_generator <- new_batch_generator(images, labels)

for (batch_counter in seq_len(batch_generator$num_batches)) {

batch <- batch_generator$get_next_batch()

loss <- one_training_step(model, batch\(images, batch\)labels)

if (batch_counter %% 100 == 0)

cat(sprintf("第 %s 批次的损失:%.2f\n", batch_counter, loss))

}

}

}

让我们来试一试:

mnist <- dataset_mnist()

train_images <- array_reshape(mnist\(train\)x, c(60000, 28 * 28)) / 255

test_images <- array_reshape(mnist\(test\)x, c(10000, 28 * 28)) / 255

test_labels <- mnist\(test\)y

train_labels <- mnist\(train\)y

fit(model, train_images, train_labels, epochs = 10, batch_size = 128)

第 1 轮

batch 100: 2.37 时的损失

batch 200: 2.21 时的损失

batch 300: 2.15 时的损失

batch 400: 2.09 时的损失

第 2 轮

batch 100: 1.98 时的损失

batch 200: 1.83 时的损失

batch 300: 1.83 时的损失

batch 400: 1.75 时的损失

第 9 轮

batch 100: 0.85 时的损失

batch 200: 0.68 时的损失

batch 300: 0.83 时的损失

batch 400: 0.76 时的损失

第 10 轮

batch 100: 0.80 时的损失

batch 200: 0.63 时的损失

batch 300: 0.78 时的损失

batch 400: 0.72 时的损失

2.5.4 评估模型

我们可以通过对测试图像的预测取其最大值所在列(max.col()),然后与预期标签进行比较来评估模型:

predictions <- model$call(test_images)

predictions <- as.array(predictions)➊

predicted_labels <- max.col(predictions) - 1➋ ➌

matches <- predicted_labels == test_labels

cat(sprintf("准确率:%.2f\n", mean(matches)))

准确率:0.82

将 TensorFlow 张量转换为 R 数组。

max.col(x) 是对 apply(x, 1, which.max) 的矢量化实现。

减 1 是因为位置与标签偏移 1,例如,第一个位置对应数字 0。

完成啦!正如你所见,手工完成“几行 Keras 代码所能完成的事情”是相当多的工作。但是因为你已经经历了这些步骤,现在应该对在调用 fit() 时神经网络内部发生的情况有一个清晰的理解。拥有这种低级别的心理模型,知道代码在幕后正在做什么,将使你更能够利用 Keras API 的高级特性。

概要

  • 张量构成现代机器学习系统的基础。它们具有各种类型、秩和形状的变体。

  • 你可以通过张量操作(如加法、张量乘积或逐元素乘法)来操作数值张量,这可以解释为编码几何变换。一般来说,深度学习中的一切都可以被解释为几何的。

  • 深度学习模型由简单的张量操作链组成,由权重参数化,这些权重本身也是张量。模型的权重是存储其“知识”的地方。

  • 学习意味着找到模型权重的一组数值,以最小化给定一组训练数据样本及其对应目标的损失函数。

  • 学习通过绘制随机的数据样本批次及其目标,并计算模型参数相对于批次上的损失的梯度来进行。然后,模型参数按梯度相反的方向移动一点(移动的大小由学习率定义)。这称为小批量随机梯度下降。

  • 整个学习过程之所以成为可能,是因为神经网络中的所有张量操作都是可微分的,因此可以应用求导的链式法则来找到将当前参数和当前数据批次映射到梯度值的梯度函数。这称为反向传播。

  • 未来的章节中,你将经常看到的两个关键概念是损失优化器。在开始向模型输入数据之前,这两个概念是你需要定义的事物:

    • 损失是你在训练过程中尝试最小化的量,因此它应该代表你试图解决的任务的成功度量。

    • 优化器指定了损失的梯度将如何用于更新参数的具体方式:例如,可以是 RMSprop 优化器、具有动量的随机梯度下降(SGD)等。

第三章:Keras 和 TensorFlow 入门

本章包括

  • 对 TensorFlow、Keras 及其关系的更深入了解

  • 设置一个深度学习工作空间

  • 深度学习核心概念如何转换为 Keras 和 TensorFlow 的概述

本章旨在为您提供开始在实践中进行深度学习所需的一切。我将简要介绍 Keras(keras.rstudio.com)和 TensorFlow(tensorflow.rstudio.com),这是我们将在本书中使用的基于 R 的深度学习工具。您将了解如何使用 TensorFlow、Keras 和 GPU 支持设置深度学习工作空间。最后,建立在您在第二章中对 Keras 和 TensorFlow 的初次接触之上,我们将回顾神经网络的核心组件以及它们如何转换为 Keras 和 Tensor-Flow 的 API。

在本章结束时,你将准备好进入实际的、真实世界的应用程序,这将从第四章开始。

3.1 TensorFlow 是什么?

TensorFlow 是一个免费、开源的机器学习平台,主要由 Google 开发。与 R 本身类似,TensorFlow 的主要目的是让科学家、工程师和研究人员能够在数值张量上操作数学表达式。但是 TensorFlow 为 R 带来了以下新功能:

  • 它可以自动计算任何可微分表达式的梯度(正如你在第二章中看到的那样),这使其非常适合机器学习。

  • 它不仅可以在 CPU 上运行,还可以在 GPU 和 TPU 上运行,后者是高度并行的硬件加速器。

  • 在 TensorFlow 中定义的计算可以轻松分布到许多机器上。

  • TensorFlow 程序可以导出到其他运行时,比如 C++、Java-Script(用于基于浏览器的应用程序)或 TensorFlow Lite(用于在移动设备或嵌入式设备上运行的应用程序)。这使得 TensorFlow 应用在实际环境中容易部署。

重要的是要记住,TensorFlow 不仅仅是一个单一的库。它实际上是一个平台,是许多组件的家园,其中一些由 Google 开发,一些由第三方开发。例如,有 TF-Agents 用于强化学习研究,TFX 用于工业强度的机器学习工作流管理,TensorFlow Serving 用于生产部署,以及 TensorFlow Hub 预训练模型的存储库。总之,这些组件涵盖了非常广泛的用例,从前沿研究到大规模生产应用。

TensorFlow 的扩展性相当不错:例如,来自奥克岭国家实验室的科学家们已经利用它在 IBM Summit 超级计算机的 27,000 个 GPU 上训练了一个 1.1 艾克斯弗洛普的极端天气预测模型。同样,谷歌已经利用 TensorFlow 开发了计算密集型的深度学习应用程序,比如下棋和围棋代理 AlphaZero。对于你自己的模型,如果你有预算,你可以实际上希望在一个小型 TPU 机架或一个大型的在 Google Cloud 或 AWS 上租用的 GPU 集群上实现大约 10 百万亿次浮点运算的规模。这仍然只是 2019 年顶级超级计算机峰值计算能力的大约 1%!

3.2 什么是 Keras?

Keras 是一个深度学习 API,建立在 TensorFlow 之上,提供了一种方便的方式来定义和训练任何类型的深度学习模型。Keras 最初是为研究而开发的,旨在实现快速的深度学习实验。

通过 TensorFlow,Keras 可以在不同类型的硬件(见 图 3.1)上运行——GPU、TPU 或普通 CPU——并且可以无缝地扩展到数千台机器。

Keras 以优化开发者体验而闻名。它是一个面向人类而不是机器的 API。它遵循减少认知负荷的最佳实践:它提供一致和简单的工作流程,它最小化了常见用例所需的操作数量,并且在用户出错时提供清晰和可操作的反馈。这使得 Keras 对于初学者来说易于学习,对于专家来说使用起来高效。

图像

图 3.1 Keras 和 TensorFlow:TensorFlow 是一个低级张量计算平台,而 Keras 是一个高级深度学习 API。

截至 2021 年末,Keras 已经有超过一百万的用户,包括学术研究人员、工程师、数据科学家,以及初创公司和大公司的研究生和爱好者。Keras 在谷歌、Netflix、Uber、CERN、NASA、Yelp、Instacart、Square,以及数百家在各行各业解决各种问题的初创公司中被使用。你的 YouTube 推荐来自 Keras 模型。Waymo 自动驾驶汽车是用 Keras 模型开发的。Keras 也是 Kaggle 的一个流行框架,这是一个机器学习竞赛网站,大多数深度学习竞赛都是使用 Keras 赢得的。

因为 Keras 拥有大量且多样化的用户基础,它不会强制要求你遵循单一的“正确”方式来构建和训练模型。相反,它提供了多种不同的工作流程,从非常高级到非常低级,适用于不同的用户群体。例如,你有多种方式来构建和训练模型,每一种都代表了可用性和灵活性之间的某种权衡。在第五章中,我们将详细回顾这一系列工作流程中的大部分内容。你可以像使用大多数其他高级框架一样使用 Keras——只需调用 fit() 然后让框架执行它的工作;或者你可以像使用基本的 R 一样,完全掌控每一个细节。

这意味着,当你刚开始学习的内容,即使在你成为专家之后仍然适用。你可以轻松开始,然后逐渐深入到更复杂的工作流程,其中你需要从头开始编写越来越多的逻辑。你不需要在从学生到研究员,或者从数据科学家到深度学习工程师的过程中,切换到完全不同的框架。

这种哲学与 R 本身的哲学非常相似!有些语言只提供一种编程方式——例如,面向对象编程或函数式编程。而 R 是一种多范式语言:它提供了各种可能的用法模式,这些模式都可以很好地协同工作。这使得 R 适用于非常不同的用例:数据科学、机器学习工程、网页开发……或者只是学习编程。同样,你可以将 Keras 视为深度学习领域的 R:一种用户友好的深度学习语言,它为不同的用户群体提供了多种工作流程。

3.3 Keras 和 TensorFlow:简史

Keras 比 TensorFlow 早八个月。Keras 于 2015 年 3 月发布,而 TensorFlow 于 2015 年 11 月发布。你可能会问,如果 Keras 是建立在 TensorFlow 之上的,它是如何在 TensorFlow 发布之前就存在的?Keras 最初是建立在 Theano 之上的,这是另一个张量操作库,它提供了自动微分和 GPU 支持——这是同类中的最早期的。Theano 由蒙特利尔大学(Université de Montréal)的蒙特利尔学习算法研究所(MILA)开发,在许多方面是 TensorFlow 的前身。它开创了使用静态计算图进行自动微分并将代码编译到 CPU 和 GPU 的理念。

在 2015 年底,TensorFlow 发布后,Keras 被重构为一个多后端架构:可以使用 Theano 或 TensorFlow 来使用 Keras,并且在两者之间切换只需更改一个环境变量。到 2016 年 9 月,TensorFlow 已经达到了技术成熟的水平,使其成为 Keras 的默认后端选项是可行的。2017 年,Keras 增加了两个新的后端选项:由 Microsoft 开发的 CNTK 和由 Amazon 开发的 MXNet。现在,Theano 和 CNTK 已停止开发,MXNet 在 Amazon 以外的使用不是很广泛。Keras 又恢复了成为基于 TensorFlow 的单后端 API。

Keras 和 TensorFlow 多年来一直保持着一种共生关系。在 2016 年和 2017 年期间,Keras 成为了开发 TensorFlow 应用的用户友好方式,将新用户引入 TensorFlow 生态系统。到 2017 年底,大多数 TensorFlow 用户使用 Keras 或与 Keras 相结合。2018 年,TensorFlow 领导团队选择 Keras 作为 TensorFlow 官方的高级 API。因此,Keras API 是在 2019 年 9 月发布的 TensorFlow 2.0 中的重点,这是 TensorFlow 和 Keras 的广泛重新设计,考虑了四年的用户反馈和技术进步。

3.4 Python 和 R 接口:简史

TensorFlow 和 Keras 的 R 接口分别于 2016 年底和 2017 年初发布,并由 RStudio 进行主要开发和维护。

Keras 和 TensorFlow 的 R 接口是基于 reticulate 包构建的,该包在 R 中嵌入了完整的 Python 过程。对于大多数用户来说,这只是一个实现细节。但是,随着你的学习进展,这将成为一个巨大的优点,因为这意味着你可以完全访问Python 和 R中可用的所有内容。

在本书中,我们使用 R 接口来使用 Keras,这个接口可以很好地与 R 网格协作。然而,在第十三章中,我们展示了如何直接从 R 中使用 Python 库,即使没有方便的 R 界面可用。

到了这个点,你一定渴望开始实际运行 Keras 和 TensorFlow 的代码。让我们开始吧!

3.5 设置深度学习工作区

在开始开发深度学习应用程序之前,需要设置开发环境。强烈建议您使用现代 NVIDIA GPU 而不是计算机的 CPU 运行深度学习代码,虽然这不是严格必需的。一些应用程序,特别是使用卷积网络进行图像处理的应用程序,甚至在快速的多核 CPU 上都会变得非常缓慢。即使是可以实际在 CPU 上运行的应用程序,使用最近的 GPU 通常也会使速度提高 5 倍或 10 倍。

在 GPU 上进行深度学习,你有以下三种选择:

  • 在你的工作站上购买并安装一个物理 NVIDIA GPU。

  • 在 Google Cloud 或 Amazon EC2 上使用 GPU 实例。

  • 使用 Kaggle、Colaboratory 或类似提供商的免费 GPU 运行时

豆瓣云、Kaggle 等免费在线服务提供商是入门的最简单途径,因为它们无需购买硬件,也不需要安装软件——只需打开浏览器的一个标签页并开始编码。然而,这些服务的免费版本只适用于小工作负载。如果你想扩大规模,你必须使用第一或第二个选项。

如果你还没有可用于深度学习的 GPU(最近的、高端的 NVIDIA GPU),那么在云中运行深度学习实验是一种简单、低成本的方法,让你无需购买任何额外的硬件就可以移动到更大的工作负载。

然而,如果你是深度学习的重度用户,这种设置在长期内甚至超过几个月都不可持续。云实例不便宜:在 2021 年中期,你将支付 2.48 美元每小时的 Google Cloud V100 GPU。同时,一个稳定的消费级 GPU 的成本将在 1,500-2,500 美元之间——尽管这些 GPU 的规格不断得到改进,但这个价格已经相当稳定。如果你是深度学习的重度用户,请考虑在本地工作站上安装一个或多个 GPU。

此外,无论是本地运行还是在云中运行,最好使用 Unix 工作站。虽然在 Windows 上直接运行 Keras 在技术上是可能的,但我们不建议这样做。如果你是 Windows 用户,并且想在自己的工作站上进行深度学习,最简单的解决方案是在机器上设置 Ubuntu 双启动,或者使用 Windows 子系统用于 Linux(WSL),这是一种兼容层,使你能够从 Windows 运行 Linux 应用程序。这可能看起来麻烦,但它将在长期内为你节省大量时间和麻烦。

3.5.1 安装 Keras 和 TensorFlow

在本地计算机上安装 Keras 和 TensorFlow 很简单:

  1. 1 确保已安装 R。最新的安装说明始终可在cloud.r-project.org找到。

  2. 2 安装 RStudio,可在mng.bz/v6JM下载。(如果你希望使用其他环境的 R,则可以跳过此步骤。)

  3. 3 从 R 控制台中运行以下命令:

install.packages("keras")➊

library(reticulate)

virtualenv_create("r-reticulate", python = install_python())➋

library(keras)

install_keras(envname = "r-reticulate")➌

这也会安装所有 R 依赖项,如 reticulate。

设置 R(reticulate)与可用的 Python 安装。

安装 TensorFlow 和 Keras(Python 模块)。

就是这样!你现在拥有一个可用的 Keras 和 TensorFlow 安装文件。

安装 CUDA

请注意,如果您的机器上有 NVIDIA GPU 并且希望 TensorFlow 使用它,则还需要下载并安装 CUDA、cuDNN 和 GPU 驱动程序,可从developer.nvidia.com/cuda-downloadsdeveloper.nvidia.com/cudnn进行下载。

每个 TensorFlow 版本都需要特定版本的 CUDA 和 cuDNN,很少情况下最新的 CUDA 版本与最新的 TensorFlow 版本兼容。通常,您需要确定 Tensor-Flow 需要的特定 CUDA 版本,然后从 CUDA 工具包存档中安装,地址为 developer.nvidia.com/cuda-toolkit-archive

您可以通过查阅mng.bz/44pV来找到当前 TensorFlow 版本所需的 CUDA 版本。如果您正在运行较旧版本的 TensorFlow,则可以在www.tensorflow.org/install/source#gpu的“Tested Build Configurations”表中找到对应于您的 TensorFlow 版本的条目。要查找安装在您的机器上的 TensorFlow 版本,请使用:

tensorflow::tf_config()

TensorFlow v2.8.0

Image (~/.virtualenvs/r-reticulate/lib/python3.9/site-packages/tensorflow)

Python v3.9 (~/.virtualenvs/r-reticulate/bin/python)

在本文写作时,最新的 TensorFlow 2.8 版本需要 CUDA 11.2 和 cuDNN 8.1。

请注意,您可以在终端上运行特定命令来安装所有 CUDA 驱动程序,但这些命令的有效期非常短(更不用说针对每个操作系统特定)。我们在书中不包含任何这样的命令,因为它们可能会在书印刷之前就过时了。相反,您可以始终在 tensorflow.rstudio.com/installation/ 找到最新的安装说明。

现在,您有一种实践运行 Keras 代码的方法。接下来,让我们看看您在第二章学到的关键思想如何转化为 Keras 和 TensorFlow 代码。

3.6 TensorFlow 的初始步骤

正如您在前几章中所看到的,训练神经网络围绕以下概念展开:

  • 首先是低级张量操作——现代机器学习的基础设施。这对应 TensorFlow 的 API:

    • 张量(tensors),包括存储网络状态的特殊张量(变量

    • 如加法、relu、matmul 等张量操作

    • 反向传播(Backpropagation),一种计算数学表达式梯度的方法(在 TensorFlow 中通过 GradientTape 对象处理)

  • 其次,是高级深度学习概念。这对应于 Keras 的 API

    • (layers),它们被组合成一个模型(model)

    • 损失函数(loss function)定义了用于学习的反馈信号。

    • 决定学习过程的优化器(optimizer)

    • 度量指标(metrics)用于评估模型的性能,如准确率。

    • 执行小批量随机梯度下降的 训练循环

在前一章中,你已经简要了解了一些对应的 TensorFlow 和 Keras API。你已经简要使用了 TensorFlow 的 Variable 类、matmul 操作和 GradientTape。你已经实例化了 Keras 的 dense 层,将它们打包成了一个 sequential 模型,并使用 fit() 方法对该模型进行了训练。

现在让我们更深入地了解如何使用 TensorFlow 和 Keras 在实践中处理所有这些不同的概念。

3.6.1 TensorFlow 张量

要在 TensorFlow 中做任何事情,我们都需要一些张量。在前一章中,我们介绍了一些张量的概念和术语,并使用了你可能已经熟悉的 R 数组作为一个示例实现。在这里,我们超越了概念,介绍了 TensorFlow 使用的张量的具体实现。

TensorFlow 张量非常类似于 R 数组;它们是数据的容器,还具有一些元数据,如形状和类型。你可以使用 as_tensor() 将 R 数组转换为 TensorFlow 张量:

r_array <- array(1:6, c(2, 3))

tf_tensor <- as_tensor(r_array)

tf_tensor

tf.Tensor(

[[1  3  5]

[2  4  6]], shape=(2, 3), dtype=int32)

与 R 数组类似,张量可以使用许多你已经熟悉的相同张量操作:如 dim()、length()、内置数学泛型如 + 和 log() 等等:

dim(tf_tensor)

[1]  2  3

tf_tensor + tf_tensor

tf.Tensor(

[[ 2  6  10]

[ 4  8  12]], shape=(2, 3), dtype=int32)

适用于张量的 R 泛型集合非常广泛:

methods(class = "tensorflow.tensor")

Image

这意味着你通常可以为 TensorFlow 张量编写与 R 数组相同的代码。

3.7 张量属性

与 R 数组不同,张量具有一些可以使用 $ 访问的属性:

tf_tensor$ndim➊

[1]  2

ndim 返回一个标量整数,张量的秩,相当于 length(dim(x))。

长度为 1 的 R 向量会自动转换为秩为 0 的张量,而长度大于 1 的 R 向量会转换为秩为 1 的张量:

as_tensor(1)$ndim

[1] 0

as_tensor(1:2)$ndim

[1]  1

tf_tensor$shape

TensorShape([2, 3])

tf_tensor$shape 返回一个 tf.TensorShape 对象。这是一个支持未定义或未指定维度的类对象,并且具有各种方法和属性:

methods(class = class(shape())[1])

Image

目前,你只需要知道你可以使用 as.integer() 将 TensorShape 转换为整数向量(dim(x) 是 as.integer(x$shape) 的缩写),并且你可以使用 shape() 函数手动构造一个 TensorShape 对象:

shape(2, 3)

TensorShape([2, 3])

tf_tensor$dtype

tf.int32

tf_tensor$dtype 返回数组的数据类型。TensorFlow 支持的数据类型比基本的 R 更多。例如,基本的 R 只有一个整数类型,而 TensorFlow 提供了 13 种整数类型!R 的整数类型对应于 int32。不同的数据类型在内存消耗和可以表示的值的范围之间进行不同的权衡。例如,具有 int8 数据类型的张量在内存中只占用 int32 数据类型的四分之一的空间,但它只能表示 -128 到 127 之间的整数,而不是 -2147483648 到 2147483647。

本书还将处理浮点数据。在 R 中,默认的浮点数数据类型 double 会转换为 tf.float64:

r_array <- array(1)

typeof(r_array)

[1] "double"

as_tensor(r_array)$dtype

tf.float64

在本书的大部分内容中,我们将使用更小的 float32 作为默认的浮点数数据类型,以换取一些精度,以获得更小的内存占用和更快的计算速度:

as_tensor(r_array, dtype = "float32")

tf.Tensor([1.], shape=(1), dtype=float32)

3.7.1 张量的形状和重塑

as_tensor() 还可以选择接受一个形状参数,你可以用它来扩展一个标量或重塑一个张量。例如,要创建一个零数组,你可以写成:

as_tensor(0, shape = c(2, 3))

tf.Tensor(

[[0. 0. 0.]

[0. 0. 0.]], shape=(2, 3), dtype=float32)

对于不是标量(length(x) > 1)的 R 向量,你也可以重塑张量,只要数组的总大小保持不变:

as_tensor(1:6, shape = c(2, 3))

tf.Tensor(

[[1 2 3]

[4 5 6]], shape=(2, 3), dtype=int32)

请注意,张量是按行填充的。这与 R 不同,R 是按列填充数组的:

array(1:6, dim = c(2, 3))

[,1]   [,2]   [,3]

[1,]      1      3       5

[2,]      2      4       6

行主要和列主要顺序之间的差异(也称为 C 和 Fortran 顺序)是在转换 R 数组和张量时要注意的事项之一。R 数组始终是 Fortran 顺序的,而 TensorFlow 张量始终是 C 顺序的,这种区别在任何时候重塑数组时都变得重要。

当你处理张量时,重塑将使用 C 风格的顺序。每当你处理 R 数组时,如果你想明确指定重塑行为,你可以使用 array_reshape():

array_reshape(1:6, c(2, 3), order = "C")

[,1]   [,2]   [,3]

[1,]      1      2       3

[2,]      4      5       6

array_reshape(1:6, c(2, 3), order = "F")

[,1]   [,2]   [,3]

[1,]      1      3       5

[2,]      2      4       6

最后,array_reshape() 和 as_tensor() 还允许你保留一个轴的大小未指定,它将自动推断使用数组的大小和剩余轴的大小。你可以为你想要推断的轴传递 -1 或 NA:

array_reshape(1:6, c(-1, 3))

[,1]   [,2]   [,3]

[1,]      1      2       3

[2,]      4      5       6

as_tensor(1:6, shape = c(NA, 3))

tf.Tensor(

[[1 2 3]

[4 5 6]], 形状=(2, 3), 数据类型=int32)

3.7.2 张量切片

子集张量类似于子集 R 数组,但不完全相同。切片张量提供了一些 R 数组没有的便利,反之亦然。

张量允许您使用提供给切片范围的一个端点的缺失值进行切片,这意味着“该方向上的张量的其余部分”(R 数组不提供此切片便利) (R 数组不提供此切片便利)。例如,重新访问第二章中的示例,我们要切片 MNIST 图像的一部分,我们可以提供一个 NA 给切片:

train_images <- as_tensor(dataset_mnist()\(train\)x)

my_slice <- train_images[, 15:NA, 15:NA]

请注意,表达式 15:NA 将在其他上下文中产生 R 错误;它只在张量切片操作的括号中起作用。

还可以使用负索引。请注意,与 R 数组不同,负索引不会丢弃元素;相反,它们指示相对于当前轴末端的索引位置。(因为这是与标准 R 子集行为的改变,所以在第一次遇到负切片索引时会发出警告。)要将图像裁剪为 14 × 14 像素的补丁居中,你可以这样做:

my_slice <- train_images[, 8:-8, 8:-8]

警告:

负数采用 Python 风格解释

图像 在子集张量的情况下。

详细信息请参阅?.tensorflow.tensor`。

要关闭此警告,

![图像 设置options(tensorflow.extract.warn_negatives_pythonic = FALSE)

你还可以随时使用特殊的 all_dims()对象来隐式捕获剩余维度,而不必提供调用时所需的逗号(,)的确切数量。例如,假设你只想取前 100 个图像,你可以写成

my_slice <- train_images[1:100, all_dims()]

而不是

my_slice <- train_images[1:100, , ]

这在编写可以处理不同秩的张量的代码时非常方便,例如,在批量维度上取匹配的模型输入和目标的切片。

3.7.3 张量广播

我们在第二章介绍了广播。当我们对两个不同大小的张量进行操作,并且我们希望较小的张量广播以匹配较大张量的形状时,就会执行广播。广播包括以下两个步骤:

  1. 1 轴(称为广播轴)被添加到较小的张量中,以匹配较大张量的 ndim。

  2. 2 较小的张量沿着这些新轴重复,以匹配较大张量的完整形状。

使用广播,如果一个张量的形状为(a, b, ... n, n + 1, ... m),另一个张量的形状为(n, n + 1, ... m),通常可以执行元素级别操作。然后广播将自动发生在轴 a 到 n - 1 上。

以下示例通过广播将两个不同形状的张量应用于元素级别+操作:

x <- as_tensor(1, shape = c(64, 3, 32, 10))

y <- as_tensor(2, shape = c(32, 10))

z <- x + y➊

输出 z 的形状与 x 相同,为 (64, 3, 32, 10)。

每当您希望明确广播语义时,可以使用 tf$newaxis 在张量中插入大小为 1 的维度:

z <- x + y[tf\(newaxis, tf\)newaxis, , ]

3.7.4 The tf module

张量需要用一些初始值创建。通常可以使用 as_tensor() 来创建张量,但是 tf 模块还包含许多用于创建张量的函数。例如,您可以创建全 1 或全 0 的张量,或者从随机分布中绘制值的张量。

library(tensorflow)

tf$ones(shape(1, 3))

tf.Tensor([[1. 1. 1.]], shape=(1, 3), dtype=float32)

tf$zeros(shape(1, 3))

tf.Tensor([[0. 0. 0.]], shape=(1, 3), dtype=float32)

tf\(random\)normal(shape(1, 3), mean = 0, stddev = 1)➊

tf.Tensor([ 0.79165614

![Image -0.35886717 0.13686056]], shape=(1, 3), dtype=float32)

tf\(random\)uniform(shape(1, 3))➋

tf.Tensor([[0.93715847 0.67879045 0.60081327]], shape=(1, 3), dtype=float32)

从均值为 0、标准差为 1 的正态分布中绘制的随机值张量。等同于 array(rnorm(3 * 1, mean = 0, sd = 1), dim = c(1, 3)。

从 0 到 1 之间的均匀分布中绘制的随机值张量。等同于 array(runif(3 * 1, min = 0, max = 1), dim = c(1, 3))。

请注意,tf 模块公开了完整的 Python TensorFlow API。需要注意的一点是,Python API 经常期望整数,而像 2 这样的裸 R 数字文字会产生 double 而不是整数。在 R 中,我们可以通过添加 L 来指定整数文字,例如 2L。

tf$ones(c(2, 1))➊

Error in py_call_impl(callable, dots\(args, dots\)keywords):

TypeError: Cannot convert [2.0, 1.0] to EagerTensor of dtype int32

tf$ones(c(2L, 1L))➋

tf.Tensor(

[[1.]

[1.]], shape=(2, 1), dtype=float32)

在这里提供 R doubles 会产生错误。

提供整数文字以避免错误。

在处理 tf 模块时,我们经常会在需要 Python API 时使用带有 L 后缀的文字整数。

需要注意的另一件事是 tf 模块中的函数使用基于 0 的索引计数约定,即列表的第一个元素是元素 0。例如,如果您想沿着 2D 数组的第一个轴(换句话说,矩阵的列均值)取均值,可以这样做:

m <- as_tensor(1:12, shape = c(3, 4))

tf$reduce_mean(m, axis = 0L, keepdims = TRUE)

tf.Tensor([[5 6 7 8]], shape=(1, 4), dtype=int32)

相应的 R 函数,但是使用基于 1 的计数约定:

mean(m, axis = 1, keepdims = TRUE)

tf.Tensor([[5 6 7 8]], shape=(1, 4), dtype=int32)

您可以轻松地从 RStudio IDE 访问 tf 模块中函数的帮助。在 tf 模块的函数上将光标悬停在 F1 上,即可打开带有相应文档的网页,网址为 www.tensorflow.org

3.7.5 常量张量和变量

R 数组和 TensorFlow 张量之间的一个重要区别是 TensorFlow 张量是不可修改的:它们是常量。例如,在 R 中,你可以这样做。

清单 3.1 R 数组可赋值

x <- array(1, dim = c(2, 2))

x[1, 1] <- 0

在 TensorFlow 中尝试做同样的事情,你将会得到一个错误:“EagerTensor 对象不支持项目赋值。”

清单 3.2 TensorFlow 张量不可赋值

x <- as_tensor(1, shape = c(2, 2))

x[1, 1] <- 0➊

Error in [<-.tensorflow.tensor(*tmp*, 1, 1, value = 0):

TypeError: 'tensorflow.python.framework.ops.EagerTensor'

对象不支持项目赋值

这将失败,因为张量不可修改。

要训练一个模型,我们需要更新它的状态,这是一组张量。如果张量是不可修改的,那么我们该怎么做呢?这就是变量发挥作用的地方。tf$Variable 是 TensorFlow 中用来管理可修改状态的类。你已经在第二章结束时的训练循环实现中简要地看到了它的作用。

要创建一个变量,你需要提供一些初始值,比如一个随机张量。

清单 3.3 创建 TensorFlow 变量

v <- tf\(Variable(initial_value = tf\)random$normal(shape(3, 1)))

v

<tf.Variable 'Variable:0' shape=(3, 1) dtype=float32, numpy=

array([[-1.1629326 ],

[ 0.53641343],

[ 1.4736737 ]], dtype=float32)>

可以通过其 assign 方法就地修改变量的状态,如下所示。

清单 3.4 给 TensorFlow 变量赋值

v\(assign(tf\)ones(shape(3, 1)))

<tf.Variable 'UnreadVariable' shape=(3, 1) dtype=float32, numpy=

array([[1.],

[1.],

[1.]], dtype=float32)>

它也适用于一部分系数。

清单 3.5 给 TensorFlow 变量的子集赋值

v[1, 1]$assign(3)

<tf.Variable 'UnreadVariable' shape=(3, 1) dtype=float32, numpy=

array([[3.],

[1.],

[1.]], dtype=float32)>

类似地,assign_add() 和 assign_sub() 是 x <- x + value 和 x <- x - value 的高效等效方法。

清单 3.6 使用 assign_add()

v\(assign_add(tf\)ones(shape(3, 1)))

<tf.Variable 'UnreadVariable' shape=(3, 1) dtype=float32, numpy=

array([[4.],

[2.],

[2.]], dtype=float32)>

3.7.6 张量运算:在 TensorFlow 中进行数学运算

TensorFlow 提供了大量的张量操作来表达数学公式。以下是一些例子。

清单 3.7 几个基本的数学运算

a <- tf$ones(c(2L, 2L))

b <- tf$square(a)➊

c <- tf$sqrt(a)➋

d <- b + c➌

e <- tf$matmul(a, b)➍

e <- e * d➎

进行平方运算。

进行平方根运算。

对两个张量进行加法(逐元素)。

对两个张量进行乘法(如第二章中所讨论的)。

对两个张量进行乘法(逐元素)。

注意,其中一些操作是通过它们对应的 R 泛型调用的。例如,调用 sqrt(x) 如果 x 是一个张量,就会调用 tf$sqrt(x)。

重要的是,前面的每个操作都是即时执行的:在任何时候,你都可以打印当前结果,就像普通的 R 代码一样。我们称之为eager execution(急切执行)。

3.7.7 对 GradientTape API 的第二次查看

到目前为止,TensorFlow 看起来很像基本 R,只是函数的名称不同,并且具有一些不同的张量功能。但是,这里有一件 R 不能轻易做到的事情:检索任何可微表达式相对于其输入的梯度。只需使用 with() 打开一个 tf$GradientTape() 作用域,对一个或多个输入张量应用一些计算,并检索结果相对于输入的梯度。

列表 3.8 使用 GradientTape

input_var <- tf$Variable(initial_value = 3)

with(tf$GradientTape() %as% tape, {

result <- tf$square(input_var)

})

gradient <- tape$gradient(result, input_var)

这通常用于检索模型损失相对于其权重的梯度:gradients <- tape$gradient(loss, weights)。你在第二章中见过这种情况。

到目前为止,你只看到了 tape\(gradient() 中输入张量是 TensorFlow 变量的情况。实际上,这些输入可以是任意的张量。但是,默认只跟踪*可训练变量*。对于常数张量,你必须手动调用 tape\)watch() 来标记它为被跟踪的。

列表 3.9 使用带有常数张量输入的 GradientTape

input_const <- as_tensor(3)

with(tf$GradientTape() %as% tape, {

tape$watch(input_const)

result <- tf$square(input_const)

})

gradient <- tape$gradient(result, input_const)

为什么这是必要的呢?因为预先存储与任何内容的梯度计算相关的信息将会太昂贵。为了避免浪费资源,记录必须知道要观察什么。可训练变量默认被观察,因为计算损失相对于可训练变量列表的梯度是梯度记录最常见的用途。

梯度记录是一个强大的实用工具,甚至可以计算二阶梯度,也就是说,梯度的梯度。例如,物体位置相对于时间的梯度是物体的速度,而二阶梯度是它的加速度。

如果你测量一个垂直轴上一个下落的苹果的位置随时间变化,并发现它验证了 position(time) = 4.9 * time ^ 2,那么它的加速度是多少?让我们使用两个嵌套的梯度记录来找出答案。

列表 3.10 使用嵌套梯度记录计算二阶梯度

time <- tf$Variable(0)

with(tf$GradientTape() %as% outer_tape, {➊

with(tf$GradientTape() %as% inner_tape, {

position <- 4.9 * time ^ 2

})

speed <- inner_tape$gradient(position, time)

})

acceleration <- outer_tape$gradient(speed, time)

加速度

tf.Tensor(9.8, shape=(), dtype=float32)

我们使用外部磁带计算内部磁带的梯度。自然,答案是 4.9 * 2 = 9.8。

3.7.8 一个端到端的例子:一个纯 TensorFlow 线性分类器

你了解张量、变量和张量操作,以及如何计算梯度。这足以基于梯度下降构建任何机器学习模型。而你只到第三章!

在机器学习的面试中,可能会要求你在 TensorFlow 中从头开始实现一个线性分类器:这是一个非常简单的任务,为了筛选出那些具有一些最小机器学习背景的候选人和那些没有背景的候选人。让我们通过这个筛选并利用你对 TensorFlow 的新知识实现这样一个线性分类器。

首先,让我们构造一些漂亮的线性可分的合成数据:在 2D 平面上的两类点。我们将通过从具有特定协方差矩阵和特定均值的随机分布中绘制它们的坐标来生成每个点类。直观地说,协方差矩阵描述了点云的形状,而均值描述了其在平面中的位置。我们将为两个点云重用相同的协方差矩阵,但使用两个不同的均值值——点云将具有相同的形状,但不同的位置。

清单 3.11 在 2D 平面中生成两类随机点

num_samples_per_class <- 1000

Sigma <- rbind(c(1, 0.5),

c(0.5, 1))

negative_samples <-

MASS::mvrnorm(n = num_samples_per_class,➊

mu = c(0, 3), Sigma = Sigma)

positive_samples <-

MASS::mvrnorm(n = num_samples_per_class,➋

mu = c(3, 0), Sigma = Sigma)

生成第一类点:1000 个随机 2D 点。Sigma 对应于从左下角到右上角定向的椭圆形点云。

使用不同的平均值和相同的协方差矩阵生成另一类点。

在前面的代码中,负样本和正样本都是形状为(1000,2)的数组。让我们将它们堆叠成一个形状为(2000,2)的单个数组。

清单 3.12 将两个类堆叠成形状为(2000,2)的数组

inputs <- rbind(negative_samples, positive_samples)

让我们生成相应的目标标签,一个形状为(2000,1)的零和一的数组,其中当 inputs[i]属于类 1(反之亦然)时,targets[i,1]为 0。

清单 3.13 生成相应的目标(0 和 1)

targets <- rbind(array(0, dim = c(num_samples_per_class, 1)),

array(1, dim = c(num_samples_per_class, 1)))

接下来,让我们绘制我们的数据。

清单 3.14 绘制两个点类

plot(x = inputs[, 1], y = inputs[, 2],

col = ifelse(targets[, 1] == 0, "purple", "green"))

Image

现在让我们创建一个线性分类器,它可以学习将这两个斑块分开。线性分类器是一个仿射变换(预测 = W • 输入 + b),训练目标是最小化预测值与目标值之间的差值的平方。正如您将看到的那样,这实际上比您在第二章末尾看到的玩具两层神经网络的端到端示例要简单得多。但是,这一次您应该能够逐行理解代码的每一部分。

让我们创建我们的变量 W 和 b,分别初始化为随机值和零值。

清单 3.15 创建线性分类器变量

input_dim <- 2➊

output_dim <- 1➋

W <- tf$Variable(initial_value =

tf\(random\)uniform(shape(input_dim, output_dim)))

b <- tf\(Variable(initial_value = tf\)zeros(shape(output_dim)))

输入将是 2D 点。

输出预测将是每个样本的单个评分(如果样本被预测为类 0,则接近 0,如果样本被预测为类 1,则接近 1)。

以下是我们的前向传播函数。

清单 3.16 前向传播函数

model <- function(inputs)

tf$matmul(inputs, W) + b

因为我们的线性分类器是在 2D 输入上运行的,W 实际上只是两个标量系数,w1 和 w2:W = [[w1], [w2]]。与此同时,b 是一个单一的标量系数。因此,对于给定的输入点[x, y],其预测值为 prediction = [[w1], [w2]] • [x, y] + b = w1 * x + w2 * y + b。

以下清单显示了我们的损失函数。

清单 3.17 均方误差损失函数

square_loss <- function(targets, predictions) {

per_sample_losses <- (targets - predictions)²➊

mean(per_sample_losses)➋

}

per_sample_losses 将是一个与 targets 和 predictions 相同形状的张量,包含每个样本的损失评分。

我们需要将每个样本的损失评分平均为单个标量损失值:这就是 mean()的作用。

请注意,在 square_loss()中,targets 和 predictions 都可以是张量,但不一定是。这是 R 接口的一个好处——像 mean()、^和-这样的通用函数使您可以为张量写与 R 数组相同的代码。当 targets 和 predictions 是张量时,通用函数将派发到 tf 模块中的函数。我们也可以直接使用 tf 模块的函数编写等效的 square_loss:

square_loss <- function(targets, predictions) {

per_sample_losses <- tf\(square(tf\)subtract(targets, predictions))

tf$reduce_mean(per_sample_losses)

}

接下来是��练步骤,它接收一些训练数据,并更新权重 W 和 b,以使数据上的损失最小化。

清单 3.18 训练步骤函数

学习率 <- 0.1

training_step <- function(inputs, targets) {

with(tf$GradientTape() %as% tape, {

predictions <- model(inputs)➊

loss <- square_loss(predictions, targets)

})

grad_loss_wrt <- tape$gradient(loss, list(W = W, b = b))➋

W\(assign_sub(grad_loss_wrt\)W * 学习率)➌

b\(assign_sub(grad_loss_wrt\)b * learning_rate)➌

损失

}

在梯度带范围内进行前向传播

检索损失相对于权重的梯度。

更新权重。

为了简单起见,我们将进行批量训练而不是小批量训练:我们将对所有数据运行每个训练步骤(梯度计算和权重更新),而不是在小批量中迭代数据。一方面,这意味着每个训练步骤将花费更长的时间运行,因为我们将一次计算 2000 个样本的前向传播和梯度。另一方面,每个梯度更新将更有效地减少训练数据的损失,因为它将涵盖所有训练样本的信息,而不是仅仅 128 个随机样本。因此,我们将需要更少的训练步骤,并且应该使用比我们通常用于小批量训练的学习率更大的学习率(我们将使用在列表 3.18 中定义的 learning_rate = 0.1)。

列表 3.19 批量训练循环

inputs <- as_tensor(inputs, dtype = "float32")

for (step in seq(40)) {

loss <- training_step(inputs, targets)

cat(sprintf("第%s 步的损失:%.4f\n", step, loss))

}

第 1 步的损失:0.7263

第 2 步的损失:0.0911

第 39 步的损失:0.0271

第 40 步的损失:0.0269

经过 40 个步骤,训练损失似乎在 0.025 左右稳定下来。让我们绘制一下我们的线性模型是如何对训练数据点进行分类的。因为我们的目标是零和一,所以如果一个给定的输入点的预测值低于 0.5,它将被分类为 0,如果高于 0.5 则被分类为 1:

predictions <- model(inputs)➊

inputs <- as.array(inputs)➊

predictions <- as.array(predictions)

绘制(inputs[, 1], inputs[, 2],

col = ifelse(predictions[, 1] <= 0.5, "purple", "green"))

➊ 将张量转换为 R 数组以进行绘图。

Image

请记住,给定点[x, y]的预测值简单地是预测 == [[w1], [w2]] • [x, y] + b == w1 * x + w2 * y + b。因此,类 1 被定义为(w1 * x + w2 * y + b) < 0.5,类 2 被定义为(w1 * x + w2 * y + b) > 0.5。您会注意到,您所看到的实际上是二维平面上的一条线的方程:w1 * x + w2 * y + b = 0.5。在线的上方是类 1,在线的下方是类 0。您可能习惯了以 y = a * x + b 的形式看到线方程;在相同的格式中,我们的线变成了 y = - w1 / w2 * x + (0.5 - b) / w2。

让我们绘制这条线:

绘制(x = inputs[, 1], y = inputs[, 2],

col = ifelse(predictions[, 1] <= 0.5, "purple", "green"))➊

斜率 <- -W[1, ] / W[2, ]➋

截距 <- (0.5 - b) / W[2, ]➋

abline(as.array(intercept), as.array(slope), col = "red")➌

绘制我们模型的预测。

这些是我们线的方程值。

绘制我们的线。

Image

这真的是线性分类器的全部意义所在:找到一个线(或者在更高维空间中,一个超平面)的参数,将两类数据整齐地分开。

3.8 神经网络概览:理解核心 Keras APIs

到目前为止,你已经了解了 TensorFlow 的基础知识,并可以使用它从头开始实现一个玩具模型,例如前一节中的批量线性分类器,或第二章末尾的玩具神经网络。这为你打下了坚实的基础。现在是时候转向更高效、更可靠的深度学习路径了:Keras API。

3.8.1 Layers: Deep learning 的构建模块

神经网络中的基本数据结构是,你在第二章中已经介绍过它。层是一个数据处理模块,它接受一个或多个张量作为输入,并输出一个或多个张量。有些层是无状态的,但更常见的是层具有状态:层的权重,一个或多个通过随机梯度下降学到的张量,它们一起包含了网络的知识

不同类型的层适用于不同的张量格式和不同类型的数据处理。例如,简单的向量数据,以 (samples, features) 形状的 rank 2 张量存储,通常通过密集连接层(由 Keras 中的 layer_dense() 函数构建)进行处理。序列数据,以 (samples, timesteps, features) 形状的 rank 3 张量存储,通常通过循环层进行处理,例如 LSTM 层(layer_lstm())或 1D 卷积层(layer_conv_1d())。图像数据以 rank 4 张量存储,通常通过二维卷积层(layer_conv_2d())进行处理。

你可以将层想象为深度学习中的乐高积木,这个比喻在 Keras 中得到了明确的体现。在 Keras 中构建深度学习模型是通过将兼容的层裁剪在一起形成有用的数据转换流程。

KERAS 中的 LAYER CLASS

一个简单的 API 应该围绕一个抽象概念展开。在 Keras 中,这个抽象概念就是 Layer 类。在 Keras 中,每个东西都是一个 Layer,或者与 Layer 密切交互的东西。

Layer 是一个封装了一些状态(权重)和计算操作(前向传播)的对象。权重通常在 build() 方法中定义(虽然它们也可以在 initialize() 方法中创建),计算操作在 call() 方法中定义。

在上一章中,我们实现了一个包含两个权重 W 和 b,并应用计算 output = activation(dot(input, W) + b) 的 layer_naive_dense()。这就是在 Keras 中同一层的样子。

列出 3.20 以一个 Keras Layer 类实现密集层

layer_simple_dense <- new_layer_class(

classname = "SimpleDense",

initialize = function(units, activation = NULL) {

super$initialize()

self$units <- as.integer(units)

self$activation <- activation

},

build = function(input_shape) {➊

input_dim <- input_shape[length(input_shape)]➋

self\(W <- self\)add_weight(

shape = c(input_dim, self$units),➌

initializer = "random_normal")

self\(b <- self\)add_weight(

shape = c(self$units),

initializer = "zeros")

},

call = function(inputs) {➍

y <- tf\(matmul(inputs, self\)W) + self$b

if (!is.null(self$activation))

y <- self$activation(y)

y

}

)

权重的创建发生在 build() 方法中。

取最后一个维度。

add_weight() 是创建权重的快捷方法。也可以创建独立的变量,并将它们分配为层属性,就像这样:self\(W < - tf\)Variable(tf\(random\)normal(w_shape)).

我们在 call() 方法中定义了前向传播计算。

这一次,我们不是构建一个空的 R 环境,而是使用 Keras 提供的 new_layer_class() 函数。new_layer_class() 返回一个层实例生成器,就像第二章中的 layer_naive_dense() 一样,但它还为我们提供了一些额外的方便功能(比如与 %>%(管道操作符) 的组合,我们稍后会介绍)。

在接下来的部分,我们将详细介绍这些 build() 和 call() 方法的目的。如果你现在还不理解一切,不用担心!

层可以通过调用以 layer_ 前缀开头的 Keras 函数来实例化。然后,一旦实例化,层实例就可以像函数一样使用,以 TensorFlow 张量作为输入:

my_dense <- layer_simple_dense(units = 32,➊

activation = tf\(nn\)relu)

input_tensor <- as_tensor(1, shape = c(2, 784))➋

output_tensor <- my_dense(input_tensor)➌

output_tensor$shape

TensorShape([2, 32])

实例化我们之前定义的层。

创建一些测试输入。

在输入上调用该层,就像调用函数一样。

你可能会想,为什么我们要实现 call() 和 build() 方法,因为我们最终是通过直接调用它来使用我们的层的?这是因为我们想要能够及时创建状态。让我们看看它是如何工作的。

自动形状推断:即时构建层

就像玩乐高积木一样,你只能“clip”(夹合)那些兼容的层在一起。这里的 层兼容性 概念特指每个层只接受特定形状的输入张量,并返回特定形状的输出张量。考虑以下示例:

layer <- layer_dense(units = 32, activation = "relu")➊

具有 32 个输出单元的密集层

此层将返回一个张量,其中第一个维度已被转换为 32。它只能连接到期望其输入为 32 维向量的下游层。

使用 Keras 时,大多数情况下你不必担心大小兼容性,因为你添加到模型中的层会动态构建以匹配传入层的形状。例如,假设你写了以下代码:

model <- keras_model_sequential(list(

layer_dense(units = 32, activation = "relu"),

layer_dense(units = 32)

))

这些图层没有收到任何关于其输入形状的信息——相反,它们自动推断其输入形状为它们看到的第一个输入的形状。在我们在第二章中实现的密集层的玩具版本(我们命名为 layer_naive_dense())中,我们必须显式地将层的输入大小传递给构造函数,以便能够创建其权重。这不是理想的,因为它会导致模型看起来像下面的代码片段,其中每个新图层都需要知道其之前图层的形状:

model <- model_naive_sequential(list(

layer_naive_dense(input_size = 784, output_size = 32,

activation = "relu"),

layer_naive_dense(input_size = 32, output_size = 64,

activation = "relu"),

layer_naive_dense(input_size = 64, output_size = 32,

activation = "relu"),

layer_naive_dense(input_size = 32, output_size = 10,

activation = "softmax") ))

如果一个图层用于生成其输出形状的规则很复杂,情况会更糟。例如,如果我们的图层返回形状为 if (input_size %% 2 == 0) c(batch, input_size * 2) else c(input_size * 3) 的输出,会更糟。

如果我们要将 layer_naive_dense() 重新实现为一个能够进行自动形状推断的 Keras 图层,它会看起来像之前的 layer_simple_dense() 图层(见清单 3.20),具有其 build() 和 call() 方法。

在 layer_simple_dense() 中,我们不再像 layer_naive_dense() 示例中在构造函数中创建权重;相反,我们在一个专门的状态创建方法 build() 中创建它们,该方法接收图层看到的第一个输入形状作为参数。第一次调用图层时,build() 方法会自动调用。实际上,在调用图层时实际调用的函数不是直接调用 call(),而是可选地首先调用 build() 然后调用 call()。

调用图层时实际调用的函数原理上看起来像这样:

layer <- function(inputs) {

if(!self$built) {

self\(build(inputs\)shape)

self$built <- TRUE

}

self$call(inputs)

}

使用自动形状推断,我们之前的例子变得简单而整洁:

model <- keras_model_sequential(list(

layer_simple_dense(units = 32, activation = "relu"),

layer_simple_dense(units = 64, activation = "relu"),

layer_simple_dense(units = 32, activation = "relu"),

layer_simple_dense(units = 10, activation = "softmax")

))

请注意,自动形状推断并不是图层类处理的唯一事项。它处理更多事情,特别是 eagergraph 执行之间的路由(你将在第七章学到的概念)以及输入掩码(我们将在第十一章介绍)。现在,只需记住:在实现自己的图层时,将正向传播放在 call() 方法中。

使用 %>%(管道操作符)组合图层

虽然你可以直接创建图层实例并对其进行操作,但大多数情况下,你只需要做的是将新的图层实例与某些东西组合在一起,比如一个序贯模型。因此,所有图层生成函数的第一个参数都是对象。如果提供了对象,则会创建一个新的图层实例,然后立即与对象组合在一起。

以前,我们通过传递图层列表来构建 keras_model_sequential(),但我们也可以逐层添加图层来构建模型:

model <- keras_model_sequential()

layer_simple_dense(model, 32, activation = "relu")

layer_simple_dense(model, 64, activation = "relu")

layer_simple_dense(model, 32, activation = "relu")

layer_simple_dense(model, 10, activation = "softmax")

在这里,由于 model 被作为第一个参数提供给 layer_simple_dense(),该图层被构建然后与模型组合在一起(通过调用 model$add(layer))。注意,模型会被就地修改——在以这种方式组合图层时,我们不需要保存对 layer_simple_dense()的调用的输出:

model$layers 的长度

[1] 4

一个微妙之处在于,当图层构造函数将图层与对象合成时,它会返回合成的结果,而不是图层实例。���此,如果把一个 keras_model_sequential()作为第一个参数提供,也会返回相同的模型,除了现在多了一个图层。

这意味着你可以使用管道(%>%)运算符将图层添加到序贯模型中。这个操作符来自 magrittr 包;它是将左侧的值作为右侧函数的第一个参数传递的简写。

你可以像这样在 Keras 中使用%>%:

model <- keras_model_sequential() %>%

layer_simple_dense(32, activation = "relu") %>%

layer_simple_dense(64, activation = "relu") %>%

layer_simple_dense(32, activation = "relu") %>%

layer_simple_dense(10, activation = "softmax")

这和用一个图层列表调用 keras_model_sequential()有什么区别?没有——用两种方法都会得到相同的模型。

使用%>%会导致更易读和更简洁的代码,所以我们将在整本书中采用这种形式。如果你使用 RStudio,可以使用 Ctrl-Shift-M 键盘快捷键插入%>%。要了解更多关于管道运算符的信息,请参见r4ds.had.co.nz/pipes.html

3.8.2 从图层到模型

深度学习模型是图层的图形。在 Keras 中,这是 Model 类型。到目前为止,你只看到过序贯模型,它们是简单的图层堆叠,将单个输入映射到单个输出。但随着你向前迈进,你将接触到更广泛的网络拓扑结构。这些是一些常见的拓扑结构:

  • 两个分支网络

  • 多头网络

  • 残差连接

网络拓扑结构可能变得相当复杂。例如,图 3.2 显示了 Transformer 的图层图形的拓扑结构,它是一种用于处理文本数据的常见架构。

图像

图 3.2 transformers 架构(在第十一章中介绍)。这里面有很多内容。在接下来的几章中,您将逐步理解它。

在 Keras 中通常有两种构建这种模型的方法:您可以直接定义一个新的model_class(),或者您可以使用功能 API,这样您就可以用更少的代码做更多的事情。我们将在第七章中涵盖这两种方法。

模型的拓扑结构定义了一个假设空间。您可能还记得,在第一章中我们将机器学习描述为在预定义的可能性空间内寻找一些输入数据的有用表示,使用来自反馈信号的指导。通过选择网络拓扑结构,您将您的可能性空间(假设空间)限制为一系列将输入数据映射到输出数据的特定张量操作。接下来您将要搜索的是这些张量操作中涉及的权重张量的一组良好值。

要从数据中学习,您必须对其进行假设。这些假设定义了可以学到什么。因此,您的假设空间的结构——您模型的架构——非常重要。它编码了您对问题的假设——模型开始时具有的先验知识。例如,如果您正在处理一个由单个无激活函数(纯仿射变换)的layer_dense()组成的模型的二分类问题,则您假设您的两个类是线性可分的。

选择正确的网络架构更多地是一门艺术而不是科学,尽管您可以依靠一些最佳实践和原则,但只有实践才能帮助您成为一名合格的神经网络架构师。接下来的几章将教给您构建神经网络的显式原则,并帮助您培养对于什么对于特定问题有效或无效的直觉。您将建立对于不同种类问题适用的模型架构的坚实直觉,以及如何在实践中构建这些网络、如何选择正确的学习配置,以及如何调整模型直至产生您想要看到的结果。

3.8.3 “编译”步骤:配置学习过程

一旦模型架构被定义,您仍然需要选择另外三个东西:

  • 损失函数(目标函数) — 训练过程中将被最小化的数量。它代表了当前任务的成功度量。

  • 优化器 — 根据损失函数确定网络将如何更新。它实现了随机梯度下降(SGD)的特定变体。

  • 度量 — 训练和验证过程中要监视的成功指标,例如分类准确率。与损失不同,训练不会直接为这些度量优化。因此,度量不需要可微分。

选择了损失函数、优化器和指标之后,您可以使用 compile() 和 fit() 方法开始训练模型。或者,您也可以编写自己的自定义训练循环——我们将在第七章中介绍如何做到这一点。这要花费更多的工作!现在,让我们来看看 compile() 和 fit()。

compile() 方法配置训练过程——在第二章的第一个神经网络示例中,您已经了解了它。它接受优化器、损失函数和指标作为参数:

model <- keras_model_sequential() %>% layer_dense(1)➊

model %>% compile(optimizer = "rmsprop",➋

loss = "mean_squared_error",➌

metrics = "accuracy")➍

定义线性分类器。

使用名称指定优化器:RMSprop(不区分大小写)。

使用名称指定损失函数:均方误差。

指定(可能是多个)指标:在这种情况下,仅为准确性。

模型的就地修改

我们使用 %>% 操作符调用 compile()。我们也可以写网络编译步骤:

compile(model,

optimizer = "rmsprop",

loss = "mean_squared_error",

metrics = "accuracy")

使用 %>% 进行编译不仅仅是为了紧凑,而是为了提供语法上的提示,以提醒您 Keras 模型的一个重要特点:与您在 R 中使用的大多数对象不同,Keras 模型是在原地修改的。这是因为 Keras 模型是在训练期间状态更新的层的有向无环图。您不是在网络上操作,而是对网络对象进行某些操作。将网络放在 %>% 左侧,而不将结果保存到一个新变量中,向读者发出您正在就地修改的信号。

在调用 compile() 中,我们将优化器、损失函数和指标作为字符串(例如“rmsprop”)传递。这些字符串实际上是快捷方式,将转换为 R 对象。例如,“rmsprop”变成 optimizer_rmsprop()。重要的是,也可以将这些参数指定为对象实例:

model %>% compile(

optimizer = optimizer_rmsprop(),

loss = loss_mean_squared_error(),

metrics = metric_binary_accuracy()

)

如果您想传递自己的自定义损失或指标,或者如果您想进一步配置正在使用的对象,例如通过将 learning_rate 参数传递给优化器,则这非常有用:

model %>% compile(

optimizer = optimizer_rmsprop(learning_rate = 1e-4),

loss = my_custom_loss,

metrics = c(my_custom_metric_1, my_custom_metric_2)

)

在第七章,我们将介绍如何创建自定义的损失函数和指标。一般来说,您不必从头开始创建自己的损失函数、指标或优化器,因为 Keras 提供了广泛的内置选项,很可能包括您需要的内容。

优化器:

ls(pattern = "^optimizer_", "package:keras")

[1] "optimizer_adadelta" "optimizer_adagrad" "optimizer_adam"

[4] "optimizer_adamax" "optimizer_nadam" "optimizer_rmsprop"

[7] "optimizer_sgd"

损失函数:

ls(pattern = "^loss_", "package:keras")

[1] "loss_binary_crossentropy"

[2] "loss_categorical_crossentropy"

[3] "loss_categorical_hinge"

[4] "loss_cosine_proximity"

[5] "loss_cosine_similarity"

[6] "loss_hinge"

[7] "loss_huber"

[8] "loss_kl_divergence"

[9] "loss_kullback_leibler_divergence"

[10] "loss_logcosh"

[11] "loss_mean_absolute_error"

[12] "loss_mean_absolute_percentage_error"

[13] "loss_mean_squared_error"

[14] "loss_mean_squared_logarithmic_error"

[15] "loss_poisson"

[16] "loss_sparse_categorical_crossentropy"

[17] "loss_squared_hinge"

指标:

ls(pattern = "^metric_", "package:keras")

[1] "metric_accuracy"

[2] "metric_auc"

[3] "metric_binary_accuracy"

[4] "metric_binary_crossentropy"

[5] "metric_categorical_accuracy"

[6] "metric_categorical_crossentropy"

[7] "metric_categorical_hinge"

[8] "metric_cosine_proximity"

[9] "metric_cosine_similarity"

[10] "metric_false_negatives"

[11] "metric_false_positives"

[12] "metric_hinge"

[13] "metric_kullback_leibler_divergence"

[14] "metric_logcosh_error"

[15] "metric_mean"

[16] "metric_mean_absolute_error"

[17] "metric_mean_absolute_percentage_error"

[18] "metric_mean_iou"

[19] "metric_mean_relative_error"

[20] "metric_mean_squared_error"

[21] "metric_mean_squared_logarithmic_error"

[22] "metric_mean_tensor"

[23] "metric_mean_wrapper"

[24] "metric_poisson"

[25] "metric_precision"

[26] "metric_precision_at_recall"

[27] "metric_recall"

[28] "metric_recall_at_precision"

[29] "metric_root_mean_squared_error"

[30] "metric_sensitivity_at_specificity"

[31] "metric_sparse_categorical_accuracy"

[32] "metric_sparse_categorical_crossentropy"

[33] "metric_sparse_top_k_categorical_accuracy"

[34] "metric_specificity_at_sensitivity"

[35] "metric_squared_hinge"

[36] "metric_sum"

[37] "metric_top_k_categorical_accuracy"

[38] "metric_true_negatives"

[39] "metric_true_positives"

在本书中,你将看到许多这些选项的具体应用。

3.8.4 选择损失函数

选择正确的损失函数非常重要:你的网络会尽其所能采取任何捷径来最小化损失,因此,如果目标与手头任务的成功不完全相关,你的网络最终会做出你可能不希望的事情。想象一下,通过 SGD 训练的愚蠢的全能 AI,其目标函数选择不当:“最大化所有活着的人的平均幸福感。”为了简化其工作,这个 AI 可能选择杀死除了少数人之外的所有人,并专注于剩下人的幸福感,因为平均幸福感不受剩余人数的影响。这可能不是你想要的结果!请记住,你构建的所有神经网络在降低损失函数方面都会同样无情,所以明智地选择目标,否则你将不得不面对意想不到的副作用。

幸运的是,对于常见问题,如分类、回归和序列预测,您可以遵循简单的指南来选择正确的损失。例如,对于两类分类问题,您将使用二元交叉熵,对于多类分类问题,您将使用分类交叉熵,依此类推。只有当您处理真正的新研究问题时,您才需要开发自己的损失函数。在接下来的几章中,我们将明确详细说明选择哪些损失函数适用于各种常见任务。

3.8.5 理解 fit()方法

编译()之后是 fit()。fit()方法实现了训练循环本身。以下是其关键参数:

  • 训练所需的数据(输入和目标)。通常以 R 数组、张量或 TensorFlow 数据集对象的形式传递。您将在接下来的章节中更多了解 tfdatasets API。

  • 训练的周期数:训练循环应该遍历传递的数据的次数。

  • 在每个迷你批次梯度下降的周期中使用的批次大小:用于计算一个权重更新步骤的梯度的训练示例数

清单 3.21 使用 R 数组调用 fit()

history <- model %>%

fit(inputs,➊

目标,➋

epochs = 5,➌

batch_size = 128)➍

输入示例,作为 R 数组

相应的训练目标,作为 R 数组

训练循环将遍历数据五次。

训练循环将遍历 128 个示例的批次数据。

对 fit()的调用返回一个历史对象。此对象包含一个指标属性,它是每个周期的“损失”和特定指标名称的值的命名列表:

str(history$metrics)

长度为 2 的列表

$ 损失             : num [1:5] 14.2 13.6 13.1 12.6 12.1

$ 二元准确度: num [1:5] 0.55 0.552 0.554 0.557 0.559

3.8.6 监控验证数据上的损失和指标

机器学习的目标不是获得在训练数据上表现良好的模型,这很容易——你只需跟随梯度。目标是获得在一般情况下表现良好的模型,特别是在模型以前从未遇到过的数据点上表现良好。仅仅因为一个模型在训练数据上表现良好,并不意味着它在以前从未见过的数据上也会表现良好!例如,您的模型可能仅仅记忆了您的训练样本和它们的目标之间的映射,这对于预测模型以前从未见过的数据的目标将是无用的。在第五章中,我们将更详细地讨论这一点。

为了密切关注模型在新数据上的表现,将一部分训练数据作为验证数据是标准做法:您不会在这些数据上训练模型,但会使用它来计算损失值和指标值。您可以通过在 fit() 中使用 validation_data 参数来实现此目的。像训练数据一样,验证数据可以作为 R 数组或 TensorFlow Dataset 对象传递。

清单 3.22 使用 validation_data 参数

model <- keras_model_sequential() %>%

layer_dense(1)

model %>% compile(optimizer_rmsprop(learning_rate = 0.1),

loss = loss_mean_squared_error(),

metrics = metric_binary_accuracy())

n_cases <- dim(inputs)[1]

num_validation_samples <- round(0.3 * n_cases)➊

val_indices <-

sample.int(n_cases, num_validation_samples)➋

val_inputs <- inputs[val_indices, ]

val_targets <- targets[val_indices, , drop = FALSE]➌

training_inputs <- inputs[-val_indices, ]

training_targets <-

targets[-val_indices, , drop = FALSE]➌

model %>% fit(

training_inputs,

training_targets,➍

epochs = 5,

batch_size = 16,

validation_data = list(val_inputs, val_targets)➎

)

将 30% 的训练输入和目标保留用于验证(我们将排除这些样本进行训练,并保留它们以计算验证损失和指标)。

生成 num_validation_samples 个随机整数,范围在 [1, n_cases]。

传递 drop = FALSE 以防止 R 数组 [ 方法删除大小为 1 的维度,而是返回形状为 (num_validation_samples, 1) 的数组。

训练数据,用于更新模型的权重

验证数据,仅用于监控验证损失和指标

在验证数据上的损失值称为验证损失,以区别于训练损失。请注意,严格保持训练数据和验证数据分开是至关重要的:验证的目的是监控模型学习的内容是否实际上对新数据有用。如果任何验证数据在训练期间被模型看到,您的验证损失和指标将是错误的。

请注意,如果您想在训练完成后计算验证损失和指标,您可以调用 evaluate() 方法:

loss_and_metrics <- evaluate(model, val_inputs, val_targets,

batch_size = 128)

evaluate() 将在传递的数据上以批次(批量大小)迭代,并返回数字向量,其中第一个条目是验证损失,后续条目是验证指标。如果模型没有指标,则仅返回验证损失(长度为 1 的 R 向量)。

3.8.7 推断:在训练后使用模型

一旦您训练好了模型,您会想要将其用于在新数据上进行预测。这被称为推断。为了做到这一点,一个朴素的方法是简单地调用模型:

predictions <- model(new_inputs)➊

接受一个 R 数组或 TensorFlow 张量,并返回一个 TensorFlow 张量。

然而,这将一次性处理 new_inputs 中的所有输入,如果你要处理大量数据可能不可行(特别是可能需要比你的 GPU 拥有的内存更多)。

进行推断的更好方法是使用 predict()方法。它将在小批量数据上迭代,并返回一个 R 数组的预测。与调用模型不同的是,它还可以处理 TensorFlow Dataset 对象:

predictions <- model %>%

predict(new_inputs, batch_size = 128)➊

接受一个 R 数组或 TF Dataset 并返回一个 R 数组。

例如,如果我们对先前训练的线性模型使用 predict()在一些验证数据上,我们会得到对应于模型对每个输入样本的预测的标量分数:

predictions <- model %>%

predict(val_inputs, batch_size = 128)

head(predictions, 10)

[,1]

[1,] -0.11416233

[2,]  0.43776459

[3,] -0.02436411

[4,] -0.19723934

[5,] -0.24584538

[6,] -0.18628466

[7,] -0.06967193

[8,]  0.19761485

[9,] -0.28266442

[10,]  0.43299851

目前,这就是你需要了解的关于 Keras 模型的全部内容。你已经准备好在下一章中使用 Keras 解决真实世界的机器学习问题了。

摘要

  • TensorFlow 是一个工业强度的数值计算框架,可以在 CPU、GPU 或 TPU 上运行。它可以自动计算任何可微表达式的梯度,可以分布到多个设备,并且可以导出程序到各种外部运行时——甚至 JavaScript。

  • Keras 是使用 TensorFlow 进行深度学习的标准 API。这是本书中我们将使用的内容

  • TensorFlow 的关键对象包括张量、变量、张量操作和梯度带。

  • Keras 中的核心类型是 Layer。一个封装了一些权重和一些计算。层被组装成模型

  • 在开始训练模型之前,你需要选择一个优化器,一个损失,和一些度量标准,你可以通过 model %>% compile()方法来指定。

  • 要训练一个模型,你可以使用 fit()方法,它为你运行小批量梯度下降。你还可以用它来监视你在验证数据上的损失和指标,验证数据是模型在训练过程中不见的一组输入。

  • 一旦你的模型训练好了,你就可以使用 model %>% predict()方法在新输入上生成预测。

第四章:从神经网络开始:分类与回归

本章涵盖

  • 你的第一个真实世界机器学习工作流程的例子

  • 处理基于向量数据的分类问题

  • 处理基于向量数据的连续回归问题

本章旨在帮助你开始使用神经网络解决实际问题。你将巩固从第二章和第三章中获得的知识,并将你所学的知识应用于以下三个新任务,涵盖神经网络最常见的三个用例:

  • 将电影评论分类为积极或消极(二元分类

  • 按主题对新闻线索进行分类(多类分类)

  • 在给定房地产数据的情况下估算房屋价格(标量回归

这些例子将是你与端到端机器学习工作流程的第一次接触:你将了解数据预处理、基本模型架构原则和模型评估。

分类与回归词汇表

分类与回归涉及许多专业术语。你已经在早期的例子中遇到了一些,未来的章节中你还将看到更多。它们有以下精确的、特定于机器学习的定义,你应该熟悉它们:

  • 样本或输入——进入模型的一个数据点。

  • 预测或输出——从你的模型中输出的结果。

  • 目标——真相。根据外部数据源,你的模型理想情况下应该预测的结果。

  • 预测错误或损失值——衡量模型预测与目标之间距离的指标。

  • ——在分类问题中可以选择的一组可能的标签。例如,在分类猫和狗的图片时,“狗”和“猫”是两个类。

  • 标签——在分类问题中,某一类注释的具体实例。例如,如果图片#1234 被标注包含“狗”这一类,则“狗”是图片#1234 的一个标签。

  • 真实值或注释——数据集的所有目标,通常由人类收集。

  • 二元分类——一种分类任务,每个输入样本需要被归类到两个互斥的类别中。

  • 多类分类——一种分类任务,每个输入样本需要被归类到两个以上的类别中,例如,对手写数字的分类。

  • 多标签分类——一种分类任务,每个输入样本可以被分配多个标签。例如,给定的图像可能同时包含一只猫和一只狗,应该用“猫”标签和“狗”标签进行标注。每张图片的标签数量通常是可变的。

  • 标量回归——一个目标是连续标量值的任务。预测房价就是一个很好的例子:不同的目标价格构成了一个连续的空间。

  • 矢量回归——目标是一组连续值的任务,例如,连续矢量。如果你对多个值进行回归(例如图像中边界框的坐标),那么你在进行矢量回归。

  • 小批量或批处理——一组较小的样本(通常介于 8 到 128 之间)由模型同时处理。样本数量通常是 2 的幂,以便在 GPU 上分配内存。在训练过程中,小批量用于计算对模型权重应用单次梯度下降更新。

到本章结束时,你将能够使用神经网络处理矢量数据上的简单分类和回归任务。然后你将准备好在第五章开始构建更加系统、基于理论的机器学习理解。

4.1 电影评论分类:一个二元分类的例子

二分类问题,或者二元分类,是机器学习问题中最常见的一类。在这个例子中,你将学习如何根据评论的文本内容将电影评论分类为正面或负面。

4.1.1 IMDB 数据集

你将使用 IMDB 数据集:一组来自互联网电影数据库的 50,000 条高度极化的评论。它们被分为 25,000 条用于训练和 25,000 条用于测试,每组包含 50% 的负面评论和 50% 的正面评论。

和 MNIST 数据集一样,IMDB 数据集也随 Keras 一起打包。它已经经过预处理:评论(单词序列)已被转换为整数序列,其中每个整数代表词典中的特定单词。这让我们可以专注于模型构建、训练和评估。在第十一章中,你将学习如何从头开始处理原始文本输入。

下面的代码将加载数据集(当你第一次运行时,约 80 MB 的数据将被下载到你的机器上)。

代码 4.1:加载 IMDB 数据集

library(keras)

imdb <- dataset_imdb(num_words = 10000)

c(c(train_data, train_labels), c(test_data, test_labels)) %<-% imdb

使用多重赋值(%<-%)运算符

参数 num_words = 10000 表示你只会保留训练数据中最常出现的前 10,000 个单词。稀有单词将被丢弃。这使得

imdb <- dataset_imdb(num_words = 10000)

train_data <- imdb$train$x

train_labels <- imdb$train$y

test_data <- imdb$test$x

test_labels <- imdb$test$y

Keras 内置的数据集都是包含训练和测试数据的嵌套列表。这里,我们使用 zeallot 包的多重赋值运算符(%<-%)将列表解包为一组不同的变量。同样,这也可以写成如下形式:

多赋值版本更好,因为它更简洁。%<-% 操作符在 R Keras 包附加时会自动可用。我们可以处理可管理大小的向量数据。如果我们没有设置这个限制,我们将使用训练数据中的 88,585 个唯一单词,这是不必要的庞大数量。其中许多单词仅出现在单个样本中,因此不能用于分类。

变量 train_data 和 test_data 是评论列表;每个评论是一个单词索引列表(编码为一系列单词)。train_labels 和 test_labels 是 0 和 1 的列表,其中 0 代表 负面,1 代表 正面

str(train_data)

25000 的列表

$ : int [1:218] 1 14 22 16 43 530 973 1622 1385 65 …

$ : int [1:189] 1 194 1153 194 8255 78 228 5 6 1463 …

$ : int [1:141] 1 14 47 8 30 31 7 4 249 108 …

$ : int [1:550] 1 4 2 2 33 2804 4 2040 432 111 …

$ : int [1:147] 1 249 1323 7 61 113 10 10 13 1637 …

$ : int [1:43] 1 778 128 74 12 630 163 15 4 1766 …

$ : int [1:123] 1 6740 365 1234 5 1156 354 11 14 5327 …

$ : int [1:562] 1 4 2 716 4 65 7 4 689 4367 …

[list output truncated]

str(train_labels)

int [1:25000] 1 0 0 1 0 0 1 0 1 0 …

因为我们限制在最常见的前 10,000 个单词范围内,所以没有单词索引会超过 10,000:

max(sapply(train_data, max))

[1] 9999

为了好玩,下面是如何快速将其中一个评论解码回英文单词。

列表 4.2 将评论解码回文本

word_index <- dataset_imdb_word_index()➊

reverse_word_index <- names(word_index)➋

names(reverse_word_index) <- as.character(word_index)➋

`decoded_words <- train_data[[1]] %>%

sapply(function(i) {

如果 (i > 3) reverse_word_index[[as.character(i - 3)]]➌

else "?"

})`

decoded_review <- paste0(decoded_words, collapse = " ")

cat(decoded_review, "\n")

? 这部电影简直太棒了,演员阵容、拍摄地点、场景、故事情节、导演都非常适合他们所扮演的角色,你可以想象自己就在那里,罗伯特?是一位了不起的演员,而且现在也是导演……

word_index 是一个将单词映射到整数索引的命名向量。

将其反转,将整数索引映射到单词

解码评论。请注意,索引偏移了 3,因为 0、1 和 2 分别保留为 "填充"、"序列开始" 和 "未知" 的索引。

4.1.2 准备数据

你不能直接将整数列表馈送到神经网络中。它们的长度各不相同,但神经网络期望处理连续的数据批次。你必须将列表转换为张量。你可以通过以下两种方式做到这一点:

  • 对列表进行填充,使它们具有相同的长度,将它们转换为形状为 (samples, max_length) 的整数张量,并从能处理这种整数张量的层开始构建你的模型(嵌入层,我们将在本书后面详细介绍)。

  • 多热编码 你的列表,将它们转换为 0 和 1 的向量。这意味着,例如,将序列 [8, 5] 转换为一个 10,000 维的向量,除了索引 8 和 5 外全部为 0,索引 8 和 5 为 1。然后你可以使用一个 layer_dense(),它能处理浮点向量数据,作为你模型中的第一层

让我们选择后一种解决方案来向量化数据,这样你就可以手动进行,以获得最大的清晰度。

列表 4.3 通过多热编码对整数序列进行编码

vectorize_sequences <- function(sequences, dimension = 10000)

{ results <- array(0, dim = c(length(sequences), dimension))➊

for (i in seq_along(sequences)) {

sequence <- sequences[[i]]

for (j in sequence)

results[i, j] <- 1➋

}

results

}

x_train <- vectorize_sequences(train_data)➌

x_test <- vectorize_sequences(test_data)➍

创建形状为 (length(sequences), dimension) 的全零矩阵。

将结果的特定索引设置为 1。

向量化训练数据。

向量化测试数据。

现在样本看起来是这样的:

str(x_train)

num [1:25000, 1:10000] 1 1 1 1 1 1 1 1 1 1 …

你还应该将标签向量化,这是将整数转换为浮点数的直接转换:

y_train <- as.numeric(train_labels)

y_test <- as.numeric(test_labels)

现在数据已经准备好输入神经网络了。

4.1.3 构建你的模型

输入数据是向量,标签是标量(1 和 0):这是你可能会遇到的最简单的问题设置之一。在这种问题上表现良好的模型类型是一个简单的密集连接层堆叠(layer_dense()),使用 relu 激活。

你需要做两个关键的架构决策来设计这样一堆密集层:

  • 使用多少层

  • 每个层选择多少个单元

图片

图 4.1 三层模型

在第五章,你将学习形式原理,指导你进行这些选择。目前,你将不得不相信我做出以下架构选择:

  • 两个中间层,每个有 16 个单元

  • 第三个层将输出关于当前评论情感的标量预测

图 4.1 展示了模型的样子。下面的列表展示了 Keras 实现,与之前你看到的 MNIST 示例类似。

列表 4.4 模型定义

model <- keras_model_sequential() %>%

layer_dense(16, activation = "relu") %>%

layer_dense(16, activation = "relu") %>%

layer_dense(1, activation = "sigmoid")

传递给每个 layer_dense() 的第一个参数是层中的 单元 数:层的表示空间的维度。从第二章和第三章记得,每个带有 relu 激活的 layer_dense() 实现以下张量操作链:

output <- relu(dot(input, W) + b)

有 16 个单元意味着权重矩阵 W 的形状为(输入维度,16):与 W 的点积将将输入数据投影到 16 维表示空间(然后您将添加偏置向量 b 并应用 relu 操作)。您可以直观地将表示空间的维数理解为“在学习内部表示时为模型提供多少自由度”。具有更多单元(更高维的表示空间)允许您的模型学习更复杂的表示,但会使模型更加计算密集,并可能导致学习不需要的模式(这些模式会提高在训练数据上的性能,但不会提高在测试数据上的性能)。

中间层使用 relu 作为它们的激活函数,最终层使用 sigmoid 激活,以便输出一个概率(介于 0 和 1 之间的分数,指示样本有多大可能性具有目标“1”:评论有多可能是积极的)。relu(修正线性单元)是一个将负值归零的函数(见图 4.2),而 sigmoid“挤压”任意值到[0, 1]区间(见图 4.3),输出可解释为概率的东西。

图片

图 4.2 Relu 函数

图片

图 4.3 Sigmoid 函数

激活函数是什么,为什么它们是必需的?

如果没有像 relu 这样的激活函数(也称为非线性),layer_dense 将由两个线性操作组成:点积和加法:

输出 <- 点积(输入,W)+ b

该层只能学习输入数据的线性转换(仿射转换):该层的假设空间将是将输入数据转换为 16 维空间的所有可能线性转换的集合。这样的假设空间过于受限,不会从多层表示中受益,因为深层线性层的堆叠仍然实现线性操作:添加更多层不会扩展假设空间(正如您在第二章中看到的)。

为了访问一个更丰富的假设空间,从而受益于深度表示,您需要一个非线性或激活函数。relu 是深度学习中最流行的激活函数,但还有许多其他候选激活函数,它们都具有类似奇怪的名称:prelu、elu 等等。

最后,您需要选择损失函数和优化器。因为您面临的是二元分类问题,您的模型的输出是概率(您的模型以带有 sigmoid 激活的单单元层结束),所以最好使用 binary_crossentropy 损失。这不是唯一可行的选择:例如,您可以使用 mean_squared_error。但是当您处理输出概率的模型时,交叉熵通常是最佳选择。交叉熵是信息论领域的一种量,它衡量概率分布之间的距离,或者在这种情况下,衡量真实分布与您的预测之间的距离。至于优化器的选择,我们将选择 rmsprop,这通常是几乎任何问题的良好默认选择。

这是配置模型的步骤,其中使用了 rmsprop 优化器和 binary_crossentropy 损失函数。请注意,在训练过程中我们也会监视准确性。

清单 4.5 编译模型

model %>% compile(optimizer = "rmsprop",

loss = "binary_crossentropy",

metrics = "accuracy")

4.1.4 验证你的方法

正如你在第三章学到的,深度学习模型永远不应该在其训练数据上进行评估——在训练过程中使用验证集来监视模型的准确性是标准做法。在这里,我们将通过从原始训练数据中分离出 10,000 个样本来创建一个验证集。

清单 4.6 设置验证集

x_val <- x_train[seq(10000), ]

partial_x_train <- x_train[-seq(10000), ]

y_val <- y_train[seq(10000)]

partial_y_train <- y_train[-seq(10000)]

现在我们将使用 20 个时期(20 次对训练数据中的所有样本进行迭代)以 512 个样本一组的小批量进行模型训练。同时,我们将监视我们分离出来的 10,000 个样本上的损失和准确性。我们通过将验证数据作为验证数据参数来实现这一点。

清单 4.7 训练你的模型

history <- model %>% fit(

partial_x_train,

partial_y_train,

epochs = 20,

batch_size = 512,

validation_data = list(x_val, y_val)

)

在 CPU 上,每个时期不到 2 秒——在 20 秒内完成训练。每个时期结束时会稍作停顿,因为模型会计算验证数据的 10,000 个样本上的损失和准确性。

请注意,对模型的 fit() 调用会返回一个历史对象,就像你在第三章中看到的那样。此对象具有一个名为 metrics 的成员,它是一个包含训练过程中发生的所有事情的数据的命名列表。让我们来看一下:

str(history$metrics)

4 个列表

$ loss : num [1:20] 0.526 0.326 0.241 0.191 0.154 …

$ accuracy : num [1:20] 0.799 0.899 0.921 0.937 0.951 …

$ val_loss : num [1:20] 0.415 0.327 0.286 0.276 0.285 …

$ val_accuracy: num [1:20] 0.857 0.876 0.891 0.89 0.886 …

指标列表包含四个条目:每个条目分别监视训练和验证期间的指标。 我们将使用历史对象的 plot()方法将训练和验证损失并排绘制出来,以及训练和验证准确性(参见图 4.4)。 请注意,由于模型的不同随机初始化,您自己的结果可能会略有不同。

plot(history)

Image

图 4.4 训练和验证损失以及准确性指标

如您所见,训练损失随每个时期减少,而训练准确性随每个时期增加。 这是当运行梯度下降优化时您可以预期的情况 - 您试图最小化的数量应该在每次迭代中减少。 但是,验证损失和准确性并非如此:它们似乎在第四个时期达到顶峰。 这是我们早些时候警告过的一个例子:在训练数据上表现更好的模型不一定是在以前从未见过的数据上表现更好的模型。 在精确的术语中,您看到的是过度拟合:在第四个时期之后,您过度优化了训练数据,并且最终学习了特定于训练数据的表示,而这些表示不能推广到训练集之外的数据。

训练历史 plot()方法

如果可用(如果不可用,则使用基本图形),则用于绘制训练历史对象的 plot()方法使用 ggplot2 进行绘制。 绘图包括所有指定的指标以及损失; 如果有 10 个或更多时期,则绘制平滑线。 您可以通过 plot()方法的各种参数自定义所有这些行为。 如果要创建自定义可视化,请调用 history 上的 as.data.frame()方法以获得每个指标以及训练与验证的因子的数据框:

history_df <- as.data.frame(history)

str(history_df)

'data.frame': 80 obs. of 4 variables:

$ epoch : int 1 2 3 4 5 6 7 8 9 10 …

$ value : num 0.526 0.326 0.241 0.191 0.154 …

$ metric: 因子,带有 2 个级别"损失","准确性":1 1 1 1 1 1 1 1 1 1 …

$ data : 因子,带有 2 个级别"training","validation":1 1 1 1 1 1 1 …

在这种情况下,为了防止过度拟合,您可以在四个时期后停止训练。 一般来说,您可以使用一系列技术来减轻过度拟合,我们将在第五章中介绍。 让我们从头开始为四个时期训练一个新模型,然后在测试数据上评估它。

列表 4.8 从头开始重新训练模型

model <- keras_model_sequential() %>%

layer_dense(16, activation = "relu") %>%

layer_dense(16, activation = "relu") %>%

layer_dense(1, activation = "sigmoid")

model %>% compile(optimizer = "rmsprop",

损失= "二进制交叉熵",

指标= "准确性")

model %>% fit(x_train, y_train, epochs = 4, batch_size = 512)

结果<- model %>% evaluate(x_test, y_test)

最终结果如下:

结果

损失  准确性

0.2999835 0.8819600➊

第一个数字 0.29 是测试损失,第二个数字 0.88 是测试准确性。

这种相当幼稚的方法可以达到 88%的准确度。采用先进的方法,你应该能够接近 95%。

4.1.5 使用训练好的模型在新数据上生成预测

训练完一个模型后,你会希望在实际环境中使用它。你可以使用 predict()方法生成评论是积极的概率,就像你在第三章中所学到的那样:

model %>% predict(x_test)

[,1]

[1,] 0.20960191

[2,] 0.99959260

[3,] 0.93098557

[4,] 0.83782458

[5,] 0.94010764

[6,] 0.79225385

[7,] 0.99964178

[8,] 0.01294626

...

如你所见,模型对某些样本非常自信(0.99 或更高,或 0.01 或更低),但对其他样本不太自信(0.6,0.4)。

4.1.6 更多实验

以下实验将有助于确信你所做的架构选择都相当合理,尽管仍有改进的空间:

  • 在最终分类层之前,你使用了两个表示层。尝试使用一个或三个表示层,看看这样做对验证和测试准确性的影响。

  • 尝试使用更多单元或更少单元的层:32 个单元,64 个单元,等等。

  • 尝试使用均方误差损失函数而不是二元交叉熵。

  • 尝试使用 tanh 激活函数(在神经网络的早期比较流行)而不是 relu。

4.1.7 总结

以下是你从这个例子中应该得到的结论:

  • 通常,你需要对原始数据进行相当多的预处理才能将其作为张量输入到神经网络中。单词序列可以编码为二进制向量,但也有其他编码选项。

  • 使用具有 relu 激活的 layer_dense()堆叠层可以解决各种问题(包括情感分类),你很可能经常使用它们。

  • 在二分类问题(两个输出类别)中,你的模型应该以一个具有一个单元和 sigmoid 激活函数的 layer_dense()层结束:模型的输出应该是一个介于 0 和 1 之间的标量,代表概率。

  • 在二分类问题上,由于输出为标量 sigmoid 值,你应该使用二元交叉熵损失函数。

  • rmsprop 优化器通常是一个足够好的选择,无论你的问题是什么。这样你就少了一件要担心的事情。

  • 随着神经网络在训练数据上表现越来越好,它们最终会开始过拟合,并且在从未见过的数据上获得越来越差的结果。一定要始终监控在训练集之外的数据上的性能。

4.2. 对新闻线进行分类:一个多类别分类示例。

在前一节中,你看到了如何使用全连接神经网络将向量输入分类为两个互斥的类别。但是当你有超过两个类别时会发生什么呢?

  • 在这一部分中,我们将构建一个模型来将路透社新闻稿分类到 46 个互斥的主题中。因为我们有很多类别,这个问题是多类别分类的一个实例,并且因为每个数据点应该被分类到只有一个类别,所以这个问题更具体地说是单标签多类别分类的一个实例。如果每个数据点可以属于多个类别(在本例中,主题),我们将面临一个多标签多类别分类问题。

- 4.2.1 路透社数据集

  • 你将使用路透社数据集,这是由路透社于 1986 年发布的一组短新闻和它们的主题。它是一个简单、广泛使用的文本分类玩具数据集。数据集包含 46 个不同的主题;一些主题比其他主题更具代表性,但每个主题在训练集中至少有 10 个示例。和 IMDB 以及 MNIST 一样,路透社数据集作为 Keras 的一部分打包提供。让我们来看看。

  • 列表 4.9 加载路透社数据集

  • reuters <- dataset_reuters(num_words = 10000)

  • c(c(train_data, train_labels), c(test_data, test_labels)) %<-% reuters

  • 与 IMDB 数据集一样,参数 num_words = 10000 将数据限制在数据中出现频率最高的 10000 个单词中。你有 8982 个训练示例和 2246 个测试示例:

  • length(train_data)

  • [1] 8982

  • length(test_data)

  • [1] 2246

  • 与 IMDB 评论一样,每个示例都是一个整数列表(单词索引):

  • str(train_data)

  • 列表 8982 的列表

  • $ : int [1:87] 1 2 2 8 43 10 447 5 25 207 …

  • $ : int [1:56] 1 3267 699 3434 2295 56 2 7511 9 56 …

  • $ : int [1:139] 1 53 12 284 15 14 272 26 53 959 …

  • $ : int [1:224] 1 4 686 867 558 4 37 38 309 2276 …

  • $ : int [1:101] 1 8295 111 8 25 166 40 638 10 436 …

  • $ : int [1:116] 1 4 37 38 309 213 349 1632 48 193 …

  • $ : int [1:100] 1 56 5539 925 149 8 16 23 931 3875 …

  • $ : int [1:100] 1 53 648 26 14 749 26 39 6207 5466 …

  • [list output truncated]

  • 在你好奇的情况下,这里是如何将它解码回单词的方法。

  • 列表 4.10 将新闻线路解码回文本

  • word_index <- dataset_reuters_word_index()

  • reverse_word_index <- names(word_index)

  • names(reverse_word_index) <- as.character(word_index)

  • decoded_words <- train_data[[1]] %>%

  • sapply(function(i) {

  • if (i > 3) reverse_word_index[[as.character(i - 3)]]➊

  • else "?"

  • })

  • decoded_review <- paste0(decoded_words, collapse = " ")

  • decoded_review

  • [1] "? ? ? said as a result of its december acquisition of space co it

  • 图像 expects …"

  • 请注意,索引偏移了 3,因为 0、1 和 2 是为“填充”、“序列开始”和“未知”保留的索引。

  • 与示例相关联的标签是介于 0 和 45 之间的整数—一个主题索引:

  • str(train_labels)

  • int [1:8982] 3 4 3 4 4 4 4 3 3 16 …

- 4.2.2 准备数据

  • 你可以用和前面例子中一样的方法对数据进行向量化。

  • 列表 4.11 对输入数据进行编码

  • vectorize_sequences <- function(sequences, dimension = 10000) {

  • results <- matrix(0, nrow = length(sequences), ncol = dimension)

for (i in seq_along(sequences))

results[i, sequences[[i]]] <- 1

results

}

x_train <- vectorize_sequences(train_data)➊

x_test <- vectorize_sequences(test_data)➋

向量化的训练数据

向量化的测试数据

要对标签进行向量化,你有两种选择:你可以将标签列表转换为整数张量,或者你可以使用 独热编码。独热编码是一种广泛使用的类别数据格式,也被称为 类别编码。在这种情况下,标签的独热编码包括将每个标签嵌入为一个全零向量,在标签索引的位置上有一个 1。下面的列表显示了一个示例。

列表 4.12 编码标签

to_one_hot <- function(labels, dimension = 46) {

results <- matrix(0, nrow = length(labels), ncol = dimension)

labels <- labels + 1➊

for(i in seq_along(labels)) {

j <- labels[[i]]

results[i, j] <- 1

}

results

}

y_train <- to_one_hot(train_labels)➋

y_test <- to_one_hot(test_labels)➌

向量化的训练标签

向量化的测试标签

标签是从 0 开始的

请注意,在 Keras 中有一种内置的方法可以做到这一点:

y_train <- to_categorical(train_labels)

y_test <- to_categorical(test_labels)

4.2.3 构建你的模型

这个主题分类问题看起来与之前的电影评论分类问题相似:在这两种情况下,我们都在尝试对短文本片段进行分类。但是这里有一个新的约束:输出类别的数量从 2 增加到 46。输出空间的维度要大得多。

在像我们一直使用的 layer_dense() 堆栈中,每一层只能访问前一层输出中的信息。如果一层丢失了与分类问题相关的信息,这些信息将无法被后续层恢复:每一层都可能成为信息瓶颈。在前一个例子中,我们使用了 16 维的中间层,但 16 维空间可能太小,无法学习将 46 个不同类别分开:如此小的层可能会成为信息瓶颈,永久丢弃相关信息。出于这个原因,我们将使用较大的层。让我们使用 64 个单元。

列表 4.13 模型定义

model <- keras_model_sequential() %>%

layer_dense(64, activation = "relu") %>%

layer_dense(64, activation = "relu") %>%

layer_dense(46, activation = "softmax")

你应该注意这个架构的另外两件事。首先,我们以大小为 46 的 layer_dense() 结束模型。这意味着对于每个输入样本,网络会输出一个 46 维向量。该向量中的每个条目(每个维度)将编码不同的输出类别。

其次,最后一层使用了 softmax 激活函数。你在 MNIST 示例中看到过这种模式。它意味着模型将对 46 个不同的输出类别输出一个概率分布,对于每个输入样本,模型将产生一个 46 维的输出向量,其中 output[i]是样本属于类别 i 的概率。这 46 个分数将总和为 1。

在这种情况下,最好使用 categorical_crossentropy 作为损失函数。它衡量了模型输出的概率分布与标签的真实概率分布之间的距离。通过最小化这两个分布之间的距离,训练模型输出尽可能接近真实标签。

列表 4.14 编译模型

model %>% compile(optimizer = "rmsprop",

损失 = "categorical_crossentropy",

metrics = "accuracy")

4.2.4 验证你的方法

让我们在训练数据中留出 1000 个样本作为验证集。

列表 4.15 设置验证集

val_indices <- 1:1000

x_val <- x_train[val_indices, ]

部分 _x_train <- x_train[-val_indices, ]

y_val <- y_train[val_indices, ]

部分 _y_train <- y_train[-val_indices, ]

现在让我们训练 20 个 epochs 的模型。

列表 4.16 训练模型

历史 <- model %>% fit(

部分 _x_train,

部分 _y_train,

epochs = 20,

batch_size = 512,

validation_data = list(x_val, y_val)

)

最后,让我们显示它的损失和准确率曲线(请参见图 4.5)。

列表 4.17 绘制训练和验证损失和准确率

plot(history)

Image

图 4.5 验证集的损失和准确率曲线

在九个 epochs 之后,模型开始过拟合。让我们从头开始训练一个新模型进行九个 epochs,然后在测试集上评估它。

列表 4.18 从零开始重新训练模型

model <- keras_model_sequential() %>%

layer_dense(64, activation = "relu") %>%

layer_dense(64, activation = "relu") %>%

layer_dense(46, activation = "softmax")

model %>% compile(optimizer = "rmsprop",

损失 = "categorical_crossentropy",

metrics = "accuracy")

model %>% fit(x_train, y_train, epochs = 9, batch_size = 512)

结果 <- model %>% evaluate(x_test, y_test)

这是最终的结果:

结果

损失  准确率

0.9562974 0.7898486

这种方法达到了约 80%的准确率。对于一个平衡的二分类问题,纯随机分类器达到的准确率将为 50%。但是在这种情况下,我们有 46 个类别,并且它们可能不是平均表示的。一个随机基准的准确率是多少?我们可以快速通过经验来检查一下:

mean(test_labels == sample(test_labels))

[1] 0.190561

如你所见,随机分类器的分类准确率约为 19%,所以我们的模型在这个方面看起来相当不错。

4.2.5 在新数据上生成预测

在新样本上调用模型的 predict() 方法会返回每个样本的所有 46 个主题的类别概率分布。让我们为所有的测试数据生成主题预测:

预测 <- model %>% 预测(x_test)

“预测”中的每个条目(行)都是长度为 46 的向量:

str(predictions)

num [1:2246, 1:46] 0.0000873 0.0013171 0.0094679 0.0001123 0.0001032 …

这个向量中的系数之和为 1,因为它们形成了概率分布:

sum(predictions[1, ])

[1] 1

最大的条目是预测类别——具有最高概率的类别:

which.max(predictions[1, ])

[1] 5

4.2.6 处理标签和损失的另一种方式

我们之前提到,编码标签的另一种方式是保留它们的整数值,就像这样:

y_train <- 训练标签

y_test <- 测试标签

这种方法唯一会改变的是损失函数的选择。列表 4.18 中使用的损失函数 categorical_crossentropy,期望标签遵循分类编码。对于整数标签,你应该使用 sparse_categorical_ crossentropy:

model %>% 编译(

optimizer = "rmsprop",

loss = "sparse_categorical_crossentropy",

指标 = "accuracy")

这个新的损失函数在数学上与 categorical_crossentropy 相同;它只是具有不同的接口。

4.2.7 拥有足够大的中间层的重要性

我们之前提到,因为最终输出是 46 维的,所以应该避免中间层的单位少于 46 个。现在让我们看看当我们通过引入一个信息瓶颈,使中间层明显少于 46 维时会发生什么:例如,4 维。

列表 4.19 具有信息瓶颈的模型

model <- keras_model_sequential() %>%

layer_dense(64, 激活 = "relu") %>%

layer_dense(4, 激活 = "relu") %>%

layer_dense(46, 激活 = "softmax")

model %>% 编译(optimizer = "rmsprop",

loss = "categorical_crossentropy",

指标 = "accuracy")

model %>% fit(

partial_x_train,

partial_y_train,

epochs = 20,

batch_size = 128,

验证数据 = list(x_val, y_val)

)

现在模型的验证准确率达到了约 71%,绝对下降了 8%。这个下降主要是因为我们试图将大量信息(足以恢复 46 个类别的分离超平面的信息)压缩到一个维度太低的中间空间中。模型能够将大部分必要的信息压缩到这些 4 维表示中,但不是全部。

4.2.8 进一步的实验

就像前面的例子一样,我鼓励你尝试以下实验来培养你对这种模型配置决策的直觉:

  • 尝试使用更大或更小的层:32 个单元、128 个单元等等。

  • 在最终的 softmax 分类层之前,你使用了两个中间层。现在尝试使用一个单独的中间层,或者三个中间层。

4.2.9 总结

这个例子告诉我们以下几点:如果你试图将数据点分类到N个类别中,你的模型应该以大小为N的 layer_dense()结尾。

在单标签、多类别分类问题中,你的模型应该以 softmax 激活结束,以便输出N个输出类别的概率分布。

categorical_crossentropy 几乎总是你应该在这些问题中使用的损失函数。它最小化了模型输出的概率分布与目标的真实分布之间的距离。

在多类别分类中,你可以通过以下两种方式处理标签:

  • 通过分类编码(也称为独热编码)对标签进行编码,并使用 categorical_crossentropy 作为损失函数。

  • 将标签编码为整数,并使用 sparse_categorical_crossentropy 损失函数

如果你需要将数据分类到大量的类别中,你应该避免因中间层太小而在模型中创建信息瓶颈。

4.3 预测房价:一个回归示例

之前的两个例子都被视为分类问题,其目标是预测输入数据点的单个离散标签。另一种常见的机器学习问题是回归,它包括预测连续值而不是离散标签,例如,根据气象数据预测明天的温度,或者根据软件项目的规格预测完成所需的时间。

不要混淆回归逻辑回归算法。令人困惑的是,逻辑回归并不是一个回归算法,而是一个分类算法。

4.3.1 波士顿房价数据集

在这一部分中,我们将尝试预测 20 世纪 70 年代中期波士顿某郊区房屋的中位价格,给定当时有关郊区的数据点,例如犯罪率、当地房产税率等。我们将使用的数据集与前两个例子有一个有趣的区别。它的数据点相对较少:只有 506 个,分为 404 个训练样本和 102 个测试样本。而且输入数据中的每个特征(例如,犯罪率)都有不同的比例。例如,一些值是比例,取值介于 0 和 1 之间,其他取值介于 1 和 12 之间,还有其他取值介于 0 和 100 之间,依此类推。

列表 4.20 加载波士顿房屋数据集

boston <- dataset_boston_housing()

c(c(train_data, train_targets), c(test_data, test_targets)) %<-% boston

让我们来看看数据:

str(train_data)

num [1:404, 1:13] 1.2325 0.0218 4.8982 0.0396 3.6931 …

str(test_data)

num [1:102, 1:13] 18.0846 0.1233 0.055 1.2735 0.0715 …

正如你所看到的,我们有 404 个训练样本和 102 个测试样本,每个样本都有 13 个数值特征,例如人均犯罪率、每个住宅的平均房间数、高速公路的可达性等。目标是自住房屋的中位数价值,以千美元计算:

str(train_targets)

num [1:404(1d)] 15.2 42.3 50 21.1 17.7 18.5 11.3 15.6 15.6 14.4 …

价格通常在 1 万到 5 万美元之间。如果听起来很便宜,记住这是上世纪 70 年代中期,这些价格并未经过通货膨胀调整。

4.3.2 准备数据

把各种取值范围差异巨大的值喂给神经网络是有问题的。模型可能能够自动适应这样的异构数据,但这肯定会使学习变得更加困难。处理这种数据的广泛最佳实践是进行特征归一化:对于输入数据中的每个特征(输入数据矩阵中的一列),我们减去该特征的平均值,并除以标准差,使得该特征以 0 为中心,具有单位标准差。在 R 中使用 scale()函数可以轻松实现这一点。

列表 4.21 归一化数据

mean <- apply(train_data, 2, mean)

sd <- apply(train_data, 2, sd)

train_data <- scale(train_data, center = mean, scale = sd)

test_data <- scale(test_data, center = mean, scale = sd)

注意,用于归一化测试数据的量是使用训练数据计算的。你绝对不应该在工作流程中使用在测试数据上计算的任何量,即使是像数据归一化这样简单的事情也不行。

4.3.3 构建你的模型

由于可用的样本非常少,我们将使用一个非常小的模型,其中包含两个中间层,每个层都有 64 个单元。一般来说,你拥有的训练数据越少,过拟合就会越严重,使用一个小模型是缓解过拟合的一种方式。

列表 4.22 模型定义

build_model <- function() {➊

model <- keras_model_sequential() %>%

layer_dense(64, activation = "relu") %>%

layer_dense(64, activation = "relu") %>%

layer_dense(1)

model %>% compile(optimizer = "rmsprop",

loss = "mse",

metrics = "mae")

model

}

因为我们需要多次实例化相同的模型,所以我们使用一个函数来构造它。

模型以一个单元和无激活函数(它将是一个线性层)结束。这是标量回归的典型设置(一种回归,你试图预测一个单一的连续值)。应用激活函数会限制输出的范围:例如,如果你在最后一层应用了 sigmoid 激活函数,模型只能学习预测 0 到 1 之间的值。在这里,因为最后一层是纯线性的,模型可以自由地学习预测任何范围内的值。

注意,我们使用 mse 损失函数进行模型编译——均方误差(MSE),即预测值与目标值之间的差的平方。这是解决回归问题时广泛使用的损失函数。

我们还在训练过程中监控一个新的指标:平均绝对误差(MAE)。它是预测值与目标值之间差值的绝对值。例如,在这个问题上的 MAE 为 0.5,意味着您的预测平均偏差为 500 美元。

4.3.4 使用 K 倍验证验证您的方法

为了在我们不断调整模型参数的同时评估我们的模型(例如用于训练的轮次数),我们可以将数据分割为训练集和验证集,就像我们在之前的例子中所做的那样。但是由于我们的数据点很少,验证集最终会变得非常小(例如,大约 100 个示例)。因此,验证分数可能会因我们选择哪些数据点用于验证和哪些用于训练而大不相同:验证分数可能在验证分割方面具有很高的方差。这会阻止我们可靠地评估我们的模型。

在这种情况下的最佳实践是使用K倍交叉验证(参见图 4.6)。

图片

图 4.6 K倍交叉验证,K = 3

它包括将可用数据分成K个分区(通常K = 4 或 5),实例化K个相同的模型,并在训练每个模型时评估剩余分区。然后使用的模型的验证分数是所获得的K个验证分数的平均值。从代码的角度来看,这很简单。

列表 4.23 K倍验证

k <- 4

fold_id <- sample(rep(1:k, length.out = nrow(train_data)))

num_epochs <- 100

all_scores <- numeric()

for (i in 1:k) {

cat("处理第", i, "折\n")

val_indices <- which(fold_id == i)➊

val_data <- train_data[val_indices, ]➊

val_targets <- train_targets[val_indices]➊

partial_train_data <- train_data[-val_indices, ]➋

partial_train_targets <- train_targets[-val_indices]➋

model <- build_model()➌

model %>% fit (➍

partial_train_data,

partial_train_targets,

epochs = num_epochs,

batch_size = 16,

verbose = 0

)

results <- model %>%

evaluate(val_data, val_targets, verbose = 0)➎

all_scores[[i]] <- results[['mae']]

}

处理第 1 折

处理第 2 折

处理第 3 折

处理第 4 折

准备验证数据:来自第 k 个分区的数据。

准备训练数据:来自所有其他分区的数据。

构建 Keras 模型(已编译)。

训练模型(静默模式,verbose = 0)。

在验证数据上评估模型。

运行这个,设定 num_epochs = 100,得到以下结果:

all_scores

[1] 2.435980 2.165334 2.252230 2.362636

mean(all_scores)

[1] 2.304045

不同的运行确实显示了相当不同的验证分数,从 2.1 到 2.4 不等。平均值(2.3)比任何单个分数更可靠——这正是K倍交叉验证的全部意义所在。在这种情况下,我们的平均偏差为 2,300 美元,考虑到价格范围从 10,000 美元到 50,000 美元,这是相当显著的。

让我们尝试更长时间地训练模型:500 个时代。为了记录模型在每个时代的表现如何,我们将修改训练循环以保存每个折叠的每时代验证分数日志。

清单 4.24 在每个折叠保存验证日志

num_epochs <- 500

all_mae_histories <- list()

对于 (i 在 1:k) {

cat("处理折叠 #", i, "\n")

val_indices <- which(fold_id == i)➊

val_data <- train_data[val_indices, ]➊

val_targets <- train_targets[val_indices]➊

partial_train_data <- train_data[-val_indices, ]➋

partial_train_targets <- train_targets[-val_indices]➋

model <- build_model()➌

history <- model %>% fit(➍

partial_train_data, partial_train_targets,

validation_data = list(val_data, val_targets),

epochs = num_epochs, batch_size = 16, verbose = 0

mae_history <- history\(metrics\)val_mae

all_mae_histories[[i]] <- mae_history

}

处理折叠 # 1

处理折叠 # 2

处理折叠 # 3

处理折叠 # 4

all_mae_histories <- do.call(cbind, all_mae_histories)

准备验证数据:来自第 #k 分区的数据。

准备训练数据:来自所有其他分区的数据。

构建 Keras 模型(已编译)。

以静默模式训练模型(verbose = 0)。

然后,我们可以计算所有折叠的每时代 MAE 分数的平均值。

清单 4.25 构建连续均值 K-折验证分数的历史

average_mae_history <- rowMeans(all_mae_histories)

让我们来绘制一下;请参阅 图 4.7。

清单 4.26 绘制验证分数

plot(average_mae_history, xlab = "时代", type = 'l')

图像

图 4.7 通过时代的验证 MAE

由于缩放问题,读取图可能有点困难:最初几个时代的验证 MAE 显著高于后面的值。让我们省略前 10 个数据点,这些数据点与曲线的其余部分在不同的比例上。

清单 4.27 绘制验证分数,排除前 10 个数据点

truncated_mae_history <- average_mae_history[-(1:10)]

plot(average_mae_history, xlab = "时代", type = 'l',

ylim = range(truncated_mae_history))

正如您在图 4.8 中所看到的,验证 MAE 在 100-140 个时代后停止显著改善(这个数字包括我们省略的 10 个时代)。在那之后,我们开始过拟合。

图像

图 4.8 通过时代排除前 10 个数据点的验证 MAE

一旦您完成了模型的其他参数调整(除了时代数量,您还可以调整中间层的大小),您可以使用最佳参数在所有训练数据上训练最终的生产模型,然后查看其在测试数据上的性能。

清单 4.28 训练最终模型

model <- build_model()➊

model %>% fit(train_data, train_targets,➋

epochs = 120, batch_size = 16, verbose = 0)

result <- model %>% evaluate(test_data, test_targets)

获得一个新的、已编译的模型。

在所有数据上训练它。

这是最终结果:

result["mae"]

mae

2.476283

我们还有少了一点的$2,500。这是一个进步!就像前两个任务一样,你可以尝试改变模型中的层数或每层的单位数,看看是否能够减少测试错误。

4.3.5 生成新数据的预测

当在我们的二元分类模型上调用 predict() 时,我们得到了每个输入样本的 0 到 1 之间的标量分数。对于我们的多类分类模型,我们得到了每个样本上所有类别的概率分布。现在,对于这个标量回归模型,predict() 返回模型对样本价格的猜测,单位是千美元:

predictions <- model %>% predict(test_data)

predictions[1, ]

[1] 10.27619

测试集中的第一套房子预计价格约为 10,000 美元。

4.3.6 总结

从这个标量回归示例中你应该了解到:

  • 回归是使用不同的损失函数进行的,而我们用于分类的损失函数不同。均方误差(MSE)是常用于回归的损失函数。

  • 同样,用于回归的评估指标与用于分类的指标不同;自然地,准确度的概念不适用于回归。常见的回归指标是平均绝对误差(MAE)。

  • 当输入数据中的特征具有不同范围的值时,你应该独立地对每个特征进行缩放作为预处理步骤。

  • 当数据很少时,使用K折验证是可靠评估模型的好方法。

  • 当数据很少时,最好使用只有一两个中间层(通常只有一两个)的小型模型,以避免严重过拟合。

摘要

  • 向量数据上的三种最常见的机器学习任务是二元分类、多类分类和标量回归。

    • 章节中的“总结”部分总结了你对每个任务学到的重要知识点。

    • 回归使用不同的损失函数和不同的评估指标,而不是分类

  • 在将原始数据输入神经网络之前,通常需要对其进行预处理。

  • 当你的数据具有不同范围的特征时,作为预处理的一部分,应该独立地对每个特征进行缩放。

  • 随着训练的进行,神经网络最终开始过拟合,并在以前未见过的数据上获得更差的结果。

  • 如果你没有太多的训练数据,使用只有一两个中间层的小型模型,以避免严重过拟合。

  • 如果你的数据被分成许多类别,如果使中间层太小,可能会造成信息瓶颈。

  • 当你处理少量数据时,K折验证可以帮助可靠地评估你的模型。

第五章:机器学习基础

本章内容

  • 了解泛化和优化之间的紧张关系,这是机器学习中的基本问题

  • 机器学习模型的评估方法

  • 改善模型拟合的最佳实践

  • 为实现更好的泛化而采用的最佳实践

在第四章的三个实际示例之后,你应该开始感到熟悉如何使用神经网络解决分类和回归问题,并且你已经目睹了机器学习的核心问题:过拟合。本章将会将一些关于机器学习的新直觉正式化为一个坚实的概念框架,强调准确的模型评估和训练与泛化之间的平衡的重要性。

5.1 泛化:机器学习的目标

在第四章中介绍的三个示例——预测电影评论、主题分类和房价回归——我们将数据分为训练集、验证集和测试集。很快就能明显看到不要在训练模型的相同数据上评估模型的原因:在几个周期后,从未见过的数据上的性能开始与训练数据上的性能分歧,而训练数据的性能随着训练的进行而改善。模型开始过拟合。在每个机器学习问题中都会发生过拟合。

机器学习中的基本问题是优化和泛化之间的紧张关系。优化 是指调整模型以在训练数据上获得最佳性能的过程(机器学习 中的学习),而泛化 是指训练模型在以前从未见过的数据上的表现如何。游戏的目标当然是获得良好的泛化,但你无法控制泛化;你只能使模型适应其训练数据。如果你做得太好,过拟合就会发生,泛化就会受到影响。

但是导致过拟合的原因是什么?我们如何获得良好的泛化?

5.1.1 欠拟合和过拟合

对于你在上一章中看到的模型,随着训练的进行,验证数据的性能开始提高,然后不可避免地在一段时间后达到峰值。这种模式(在图 5.1 中说明)是普遍存在的。你会在任何模型类型和任何数据集中都看到它。

Image

图 5.1 典型的过拟合行为

在训练开始阶段,优化和泛化是相关的:在训练数据上损失越低,测试数据上的损失也越低。当这种情况发生时,你的模型被称为是欠拟合的:仍然有进步空间;网络尚未对训练数据中的所有相关模式建模。但是在对训练数据进行了一定数量的迭代后,泛化停止改善,验证指标停滞不前,然后开始恶化:模型开始过拟合。也就是说,它开始学习对训练数据特定的模式,但这些模式在新数据方面是误导性的或无关紧要的。

当你的数据嘈杂、涉及不确定性或包含罕见特征时,过拟合特别容易发生。让我们看一些具体的例子。

嘈杂的训练数据

在现实世界的数据集中,一些输入无效是相当常见的。例如,MNIST 数字可能是一张全黑的图片,或者类似于图 5.2 的东西。

图片

图 5.2 一些相当奇怪的 MNIST 训练样本

这些是什么?我也不知道。但它们都是 MNIST 训练集的一部分。更糟糕的是,存在完全有效的输入最终被错误标记,就像图 5.3 中的那些一样。

图片

图 5.3 错误标记的 MNIST 训练样本

如果模型极力将这些离群值纳入考虑范围,其泛化性能将下降,如图 5.4 所示。例如,一个看起来与图 5.3 中错误标记的 4 非常接近的 4 可能最终被分类为 9。

图片

图 5.4 处理离群值:稳健拟合 vs. 过拟合

模糊特征

并非所有的数据噪声都来自于不准确性——即使是完全干净且标记整齐的数据,在涉及不确定性和模棱两可的问题时也可能是嘈杂的。在分类任务中,通常情况下,输入特征空间的某些区域同时与多个类相关联。比如说,你正在开发一个模型,它接收香蕉的图像并预测香蕉是未成熟、成熟还是腐烂的。这些类别没有客观的界限,所以同一张图片可能会被不同的人类标记者分类为未成熟或成熟。同样地,许多问题涉及随机性。你可以利用大气压力数据来预测明天是否会下雨,但完全相同的测量结果有时可能会导致雨天,有时可能导致晴天,具有一定的概率。

一个模型可能会对这种概率性数据过拟合,过于自信于特征空间的模糊区域,就像图 5.5 中一样。一个更健壮的拟合会忽略个别数据点,而着眼于更大的视角。

图片

图 5.5 在特征空间的模糊区域中的稳健拟合 vs. 过拟合

罕见特征和伪相关性

如果你只见过两只橘色虎斑猫,并且它们都很反社会,你可能会推断橘色虎斑猫大致上都倾向于反社会。这就是过拟合:如果你接触到了更多种类的猫,包括更多橘色的猫,你会发现猫的颜色与性格并没有很强的关联。

同样地,训练在包含罕见特征值的数据集上的机器学习模型非常容易过拟合。在情感分类任务中,如果词 cherimoya(一种生长在安第斯山脉的水果)只出现在训练数据中的一篇文本中,并且这篇文本恰好是消极情绪,一个没有良好正则化的模型可能会对这个词放很高的权重,并且总是将提到 cherimoya 的新文本分类为消极,然而,客观上讲,cherimoya 并没有任何消极的含义。 [¹]

重要的是,一个特征值不必只出现几次就会导致错误的相关性。考虑一个在训练数据中出现在 100 个样本中的词,54%的情况下与积极情绪相关,46%的情况下与消极情绪相关。这个差异很可能只是一个完全的统计故障,然而你的模型很可能会学习利用这个特征进行分类任务。这是过拟合的最常见来源之一。

这是一个惊人的例子。使用 MNIST 数据集,通过在现有的 784 维数据上连续添加 784 个白噪声维度,创建一个新的训练集,使得一半的数据现在是噪声。为了比较,还创建一个等效的数据集,通过在现有的 784 维数据上连续添加 784 个全零维度。我们的无意义特征的组合对数据的信息内容没有影响:我们只是添加了一些东西。这些转换对人类的分类准确性不会产生影响。

图 5.1 向 MNIST 数据集添加白噪声通道或全零通道

library(keras)

mnist <- dataset_mnist()

train_labels <- mnist\(train\)y

train_images <- array_reshape(mnist\(train\)x / 255,

c(60000, 28 * 28))

random_array <- function(dim) array(runif(prod(dim)), dim)

noise_channels <- random_array(dim(train_images))

train_images_with_noise_channels <- cbind(train_images, noise_channels)

zeros_channels <- array(0, dim(train_images))

train_images_with_zeros_channels <- cbind(train_images, zeros_channels)

现在让我们在这两个训练集上训练第二章的模型。

图 5.2 使用带有噪声通道或全零通道的相同模型进行训练

get_model <- function()

{ model <- keras_model_sequential() %>%

layer_dense(512, activation = "relu") %>%

layer_dense(10, activation = "softmax")

model %>% compile(

optimizer = "rmsprop",

loss = "sparse_categorical_crossentropy",

metrics = "accuracy")

model

}

model <- get_model()

history_noise <- model %>% fit(

train_images_with_noise_channels, train_labels,

epochs = 10,

batch_size = 128,

validation_split = 0.2)

model <- get_model()

history_zeros <- model %>% fit(

train_images_with_zeros_channels, train_labels,

epochs = 10,

batch_size = 128,

validation_split = 0.2)

让我们比较每个模型的验证准确性随时间的演变。结果显示在 图 5.6 中。

清单 5.3 绘制验证准确性比较

plot(NULL,

主题 = "噪声通道对验证准确性的影响",

xlab = "时期", xlim = c(1, history_noise\(params\)epochs),

ylab = "验证准确性", ylim = c(0.9, 1), las = 1)

lines(history_zeros\(metrics\)val_accuracy, lty = 1, type = "o")

lines(history_noise\(metrics\)val_accuracy, lty = 2, type = "o")

legend("bottomright", lty = 1:2,

图例 = c("带零通道的验证准确性",

"带噪声通道的验证准确性"))

图片

图 5.6 噪声通道对验证准确性的影响

尽管数据在两种情况下都持有相同的信息,但使用噪声通道训练的模型的验证准确性最终约低了一个百分点—纯粹是通过虚假相关性的影响。您添加的噪声通道越多,准确性就会进一步下降。

嘈杂的特征不可避免地导致过度拟合。因此,在不确定您拥有的特征是信息性的还是干扰性的情况下,在训练之前进行 特征选择 是很常见的。例如,将 IMDB 数据限制为前 10,000 个最常见的单词是一种粗略的特征选择形式。进行特征选择的典型方式是为每个可用特征计算一些有用性分数—即特征相对于任务的信息量,例如特征与标签之间的互信息—并仅保留高于某个阈值的特征。这样做会过滤掉前面示例中的白噪声通道。

5.1.2 深度学习中泛化性质

深度学习模型的一个显著事实是,只要具有足够的表征能力,它们就可以被训练来适应任何东西。

不信?试着洗牌 MNIST 标签并在其上训练一个模型。即使输入与洗牌后的标签之间完全没有关系,训练损失也会下降得很好,即使是一个相对较小的模型。自然,随着时间的推移,验证损失根本不会改善,因为在这种情况下没有可能进行泛化(请参见以下图表)。

清单 5.4 使用随机洗牌标签拟合 MNIST 模型

c(c(train_images, train_labels), .) %<-% dataset_mnist()➊

train_images <- array_reshape(train_images / 255,

c(60000, 28 * 28))

random_train_labels <- sample(train_labels)

model <- keras_model_sequential() %>%

layer_dense(512, activation = "relu") %>%

layer_dense(10, activation = "softmax")

model %>% compile(optimizer = "rmsprop",

loss = "sparse_categorical_crossentropy",

metrics = "accuracy")

history <- model %>% fit(train_images, random_train_labels,

epochs = 100,

batch_size = 128,

验证分割 = 0.2)

绘制(history)

在 %<-% 多重赋值调用中使用.来忽略一些元素。在这里,我们忽略了 MNIST 的测试部分。

图像

实际上,你甚至不需要用 MNIST 数据做这个——你可以只是生成白噪声输入和随机标签。只要它有足够的参数,你也可以对其进行模型拟合。它最终只会记住特定的输入,就像一个哈希表一样。

如果是这样的话,那么深度学习模型怎么会有泛化能力呢?它们难道不应该只是学会了训练输入和目标之间的一种特定映射,就像一个花哨的哈希表吗?我们对于这种映射能够适用于新输入有什么期望呢?

事实上,深度学习中的泛化性质与深度学习模型本身关系不大,而与现实世界中信息的结构有很大关系。让我们来看看这里真正发生了什么。

流形假设

MNIST 分类器的输入(在预处理之前)是一个 28 × 28 的整数数组,其值介于 0 和 255 之间。因此,可能的输入值的总数是 256 的 784 次方——远远大于宇宙中的原子数。然而,这些输入中很少有看起来像是有效的 MNIST 样本:实际的手写数字只占据了所有可能的 28 × 28 整数数组的父空间中的一小部分子空间。而且,这个子空间不仅仅是在父空间中随机分布的一组点:它具有高度结构化的特性。

首先,有效手写数字的子空间是连续的:如果你对一个样本进行一点点修改,它仍然可以被识别为相同的手写数字。此外,所有有效子空间中的样本都是通过在子空间中穿过的平滑路径连接起来的。这意味着如果你取两个随机的 MNIST 数字 A 和 B,存在一个“中间”图像序列,可以将 A 变形为 B,使得两个连续的数字非常接近(参见图 5.7)。也许在两个类之间的边界上会有一些模糊的形状,但即使这些形状看起来仍然非常像数字。

图像

图 5.7 不同的 MNIST 数字逐渐转变成彼此,显示手写数字的空间形成一个流形。此图是使用第十二章的代码生成的。

从技术角度来看,你会说手写数字在可能的 28 × 28 整数数组空间内形成了一个流形。这是一个大词,但概念上相当直观。流形是某些父空间的低维子空间,局部类似于线性(欧几里得)空间。例如,在平面上的平滑曲线是 2D 空间内的 1D 流形,因为对于曲线上的每一点,你都可以画出一个切线(曲线在每一点都可以用一条直线来近似)。在 3D 空间中的平滑曲面是 2D 流形。等等。

更普遍地,流形假设认为所有自然数据都位于其编码的高维空间内的低维流形上。这是关于宇宙信息结构的一个非常强有力的陈述。据我们所知,这是准确的,也是深度学习有效的原因。对于 MNIST 数字是正确的,但对于人脸、树形态、人类声音以及自然语言也是如此。

流形假设意味着

  • 机器学习模型只需适应其潜在输入空间(潜在流形)内的相对简单、低维、高度结构化的子空间

  • 在这些流形之一中,总是可以在两个输入之间进行插值,也就是说,通过沿着所有点都落在流形上的连续路径将一个输入变形为另一个输入

在样本之间进行插值的能力是理解深度学习中泛化的关键。

插值作为泛化的源头

如果你处理可以插值的数据点,你可以通过将它们与流形上接近的其他点相关联来开始理解以前从未见过的点。换句话说,你可以只使用空间的一个样本来理解整体空间。你可以使用插值来填补空白。

注意,潜在流形上的插值与父空间中的线性插值不同,如图 5.8 所示。例如,在两个 MNIST 数字之间的像素的平均通常不是有效的数字。

图片

图 5.8 线性插值与潜在流形上的插值之间的区别。数字的潜在流形上的每一点都是有效的数字,但两个数字的平均值通常不是。

关键是,尽管深度学习通过对数据流形的学习近似进行插值来实现泛化,但认为插值就是泛化的全部是错误的。这只是冰山一角。插值只能帮助你理解与之前见过的东西非常接近的事物:它实现了局部泛化。但值得注意的是,人类经常处理极端的新奇事物,而且我们做得很好。你不需要事先接受每种情况的无数示例的训练和排练。你每一天的生活都不同于以往任何一天,也不同于人类历史的任何一天。你可以在纽约市待上一周,然后在上海待一周,再在班加罗尔待上一周,而无需为每个城市需要成千上万次的学习和排练。

人类能够进行极端泛化,这是由于与插值不同的认知机制的启用:抽象、世界的符号模型、推理、逻辑、常识、关于世界的先验知识——我们通常称之为理性,与直觉和模式识别相对。后者在性质上主要是插值的,而前者不是。这两者对智力都是至关重要的。我们将在第十四章中更详细地讨论这个问题。

深度学习为何有效

还记得第二章中的揉皱纸团的比喻吗?一张纸代表了三维空间内的二维流形(见图 5.9)。深度学习模型是一种解开纸团的工具,也就是说,是解开潜在流形的工具。

深度学习模型基本上是一个非常高维的曲线——一个平滑连续的曲线(具有来自模型架构先验的结构附加约束),因为它需要可微性。而且,该曲线通过梯度下降平稳地逐渐拟合到数据点。由于其本质,深度学习是关于采取一个大的、复杂的曲线——一个流形——并逐渐调整其参数,直到它适合一些训练数据点。

Image

图 5.9 展开复杂的数据流形

曲线涉及足够多的参数,可以适合任何东西——事实上,如果你让你的模型训练足够长的时间,它将有效地纯粹记住它的训练数据,而不会完全推广。然而,你拟合的数据并不是由稀疏分布在底层空间中的孤立点组成的。你的数据在输入空间内形成了一个高度结构化的低维流形——这就是流形假设。由于拟合模型曲线到这些数据是随着梯度下降的进行逐渐平滑地进行的,因此在训练过程中将会有一个中间点,此时模型大致近似于数据的自然流形,就像你在图 5.10 中所看到的那样。

Image

图 5.10 从随机模型到过度拟合模型并实现鲁棒拟合作为中间状态

沿着模型学习的曲线在那一点移动将接近于沿着数据的实际潜在流形移动。因此,模型将能够通过在训练输入之间进行插值来理解以前未曾见过的输入。

除了它们具有足够的表示能力这一平凡事实之外,深度学习模型具有以下特性,使它们特别适合学习潜在流形:

  • 深度学习模型实现了从输入到输出的平滑连续映射。它必须是平滑连续的,因为它必须是可微的,必须如此(否则你无法进行梯度下降)。这种平滑性有助于逼近潜在流形,其遵循相同的性质。

  • 深度学习模型往往以与其训练数据中信息“形状”相似的方式进行结构化(通过体系结构先验)。这在图像处理模型(在第八章和第九章中讨论)和序列处理模型(第十章中)中尤为明显。更一般地说,深度神经网络以层次化和模块化的方式组织其学习到的表示,这与自然数据组织方式相呼应。

训练数据至关重要

虽然深度学习确实非常适合流形学习,但泛化的能力更多地是由你的数据的自然结构决定而不是你的模型的任何属性。只有在数据形成了可以进行插值的流形时,你才能进行泛化。特征越具有信息性且噪声越少,你就越能够进行泛化,因为你的输入空间将更简单、更有结构。数据整理和特征工程对泛化至关重要。

此外,由于深度学习是曲线拟合,因此要使模型表现出色,必须在输入空间上进行稠密采样训练。在这种情况下,稠密采样意味着训练数据应该密集地覆盖整个输入数据曲面(参见图 5.11)。这在决策边界附近尤为重要。通过足够稠密的采样,就能够通过在过去的训练输入之间插值来理解新的输入,而无需使用常识、抽象推理或对世界的外部知识——这些是机器学习模型无法获得的。因此,你应该始终记住,改善深度学习模型的最佳方法是让其在更多数据或更好的数据上进行训练(当然,添加过于嘈杂或不准确的数据将损害泛化能力)。输入数据曲面的更密集覆盖将产生更好泛化性能的模型。你永远不应该指望深度学习模型能够执行比其训练样本之间的粗略插值更多的任务,因此你应该尽一切可能使插值变得更容易。你在深度学习模型中找到的唯一内容就是你输入的内容:其体系结构中编码的先验知识和其训练数据。

图片

图 5.11 在输入空间上进行密集采样是学习一个能够准确泛化的模型所必需的。

当无法获得更多数据时,下一个最好的解决方案是调节模型被允许存储的信息量或者对模型曲线的平滑性添加约束。如果一个网络只能记住少量模式或者非常规则的模式,那么优化过程将迫使它集中于最突出的模式,这些模式有更好的泛化能力。以这种方式抵抗过拟合的过程称为正则化。我们将在第 5.4.4 节深入介绍正则化技术。

在您开始调整模型以帮助其更好地推广之前,您需要一种评估您当前模型表现的方法。在接下来的部分中,您将学习如何在模型开发过程中监控泛化:模型评估。

5.2 评估机器学习模型

你只能控制你能观察到的东西。因为你的目标是开发能够成功推广到新数据的模型,所以能够可靠地衡量模型的泛化能力至关重要。在本节中,我将正式介绍评估机器学习模型的不同方法。您已经在上一章中看到了大部分方法的实际应用。

5.2.1 训练、验证和测试集

评估模型总归要将可用数据分为三组:训练集、验证集和测试集。您在训练数据上训练模型,并在验证数据上评估模型。一旦您的模型准备投入使用,您可以最后一次在测试数据上对其进行测试,这些数据应尽可能与生产数据相似。然后您可以将模型部署到生产环境中。

你可能会问,为什么不设置两个集合:一个训练集和一个测试集?你可以在训练数据上进行训练,然后在测试数据上进行评估。简单多了!

这是因为开发模型总是涉及调整其配置,例如选择层数或层大小(称为模型的超参数,以区别于参数,即网络的权重)。您通过使用模型在验证数据上的性能作为反馈信号来进行此调整。本质上,这种调整是一种学习:在某个参数空间中寻找良好配置的搜索。因此,基于模型在验证集上的性能调整模型的配置可能会很快导致过度拟合验证集,即使您的模型从未直接在其上训练过。

这种现象的核心是信息泄漏的概念。每当你根据模型在验证集上的性能调整模型的超参数时,一些关于验证数据的信息就会泄漏到模型中。如果你只做一次这样的操作,对一个参数,那么很少的信息会泄漏,你的验证集仍然可以可靠地用来评估模型。但如果你多次重复这个过程——进行一次实验,评估验证集,然后根据结果修改模型——那么越来越多的验证集信息将泄漏到模型中。

最终,你将得到一个在验证数据上表现良好的模型,因为这是你为其优化的。你关心的是在全新数据上的表现,而不是在验证数据上的表现,因此,你需要使用一个完全不同的,之前从未见过的数据集来评估模型:测试数据集。你的模型不应该有关于测试集的任何信息,即使是间接的。如果模型的任何信息都是基于测试集的性能来调整的,那么你的泛化度量将是有缺陷的。

将数据分为训练、验证和测试集可能看起来很简单,但当数据很少时,我们有一些可以派上用场的高级方法。让我们回顾三种经典的评估方法:简单留出验证、K-折交叉验证和带洗牌的重复K-折交叉验证。我们还将谈论使用常识基准线来检查你的训练是否是有效的。

简单的留出验证

将一部分数据作为测试集。在剩余数据上训练,并在测试集上评估。正如前面所看到的,为了防止信息泄漏,你不应该基于测试集来调整模型,因此,你也应该保留一个验证集

简略地说,留出验证看起来像图 5.12。列表 5.5 展示了一个简单的实现。

图片

图 5.12 简单的留出验证切分

列表 5.5 留出验证(为简单起见,标签被省略)

num_validation_samples <- 10000

val_indices <- sample.int(num_validation_samples, nrow(data))➊

validation_data <- data[val_indices, ]➋

training_data <- data[-val_indices, ]➌

model <- get_model()

fit(model, training_data, …)➍

validation_score <- evaluate(model, validation_data, …)➎

model <- get_model()

fit(model, data, …)➏

test_score <- evaluate(model, test_data, …)

从数据的随机抽样中组装验证集通常是合适的。

定义验证集。

定义训练集。

在训练数据上训练模型,并在验证数据上评估。

这时你可以调整你的模型,重新训练它,评估它,再次调整它。

一旦调整好超参数,通常会从所有非测试数据(即结合了训练数据和验证数据)重新训练最终模型。

注意 在这些示例中,我们假设数据是一个二阶张量。根据需要为更高阶的数据添加逗号。例如,如果数据是三阶的,则是 data[idx, , ],如果是四阶的,则是 data[idx, , , ],以此类推。

这是最简单的评估协议,但有一个缺点:如果可用的数据很少,那么您的验证和测试集可能包含的样本太少,无法代表手头的数据。这很容易识别:如果在分割之前对数据进行不同的随机洗牌会得到非常不同的模型性能度量,则会出现这个问题。 K 折验证和迭代 K 折验证是解决此问题的两种方式,下面会讨论。

K 折验证

使用这种方法,将数据分成 K 个相等大小的分区。对于每个分区 i,训练一个模型,使用其余的 K - 1 个分区进行训练,并在分区 i 上评估它。然后,您的最终得分是获得的 K 个得分的平均值。当您的模型的性能基于训练-测试分割表现出显著差异时,这种方法很有帮助。与留出验证类似,这种方法并不免除您使用不同的验证集进行模型校准。

从图 5.13(#fig5-13)可以看出,K 折交叉验证的示意图如下。列表 5.6 显示了一个简单的实现。

图片

图 5.13 K 折交叉验证,其中 K = 3

列表 5.6 K 折交叉验证(为简单起见,标签被省略)

k <- 3

fold_id <- sample(rep(1:k, length.out = nrow(data)))

validation_scores <- numeric()

for (fold in seq_len(k)) {

validation_idx <- which(fold_id == fold)➊

validation_data <- data[validation_idx, ]

training_data <- data[-validation_idx, ]➋

model <- get_model()➌

fit(model, training_data, …)

validation_score <- evaluate(model, validation_data, …)

validation_scores[[fold]] <- validation_score

}

validation_score <- mean(validation_scores)➍

model <- get_model()

fit(model, data, …)➎

test_score <- evaluate(model, test_data, …)

选择验证数据分区。

将剩余的数据用作训练数据。

创建一个全新的模型实例(未训练)。

验证分数:K 折验证的验证分数的平均值

在所有非测试数据上训练最终模型。

迭代 K 折验证与洗牌

这是为了在您可用的数据相对较少且需要尽可能准确地评估模型的情况下。我发现它在 Kaggle 竞赛中非常有帮助。它包括多次应用K折叠验证,在每次将数据分成 K 份之前都会对数据进行洗牌。最终得分是K折叠验证每次运行获得的得分的平均值。请注意,最终您将训练和评估 P * K 个模型(其中 P 是您使用的迭代次数),这可能非常昂贵。

5.2.2 超越常识基线

除了您可用的不同评估协议之外,您还应该了解的一件事是使用常识基线。

训练深度学习模型有点像按下启动平行世界火箭的按钮。你听不到也看不到它。你无法观察到流形学习过程—它发生在一个具有成千上万维度的空间中,即使你将其投影到 3D 中,你也无法解释它。你唯一的反馈是你的验证指标—就像你看不见的火箭上的高度计。

能够判断自己是否有起色尤为重要。你起飞的高度是多少?你的模型似乎有 15%的准确率—这算好吗?在你开始处理数据集之前,你应该始终选择一个微不足道的基准,你会尝试超越它。如果你超过了这个阈值,你就知道你做对了:你的模型实际上正在使用输入数据中的信息来做出概括性的预测,你可以继续前进。这个基准可以是随机分类器的性能,也可以是你能想象到的最简单的非机器学习技术的性能。

例如,在 MNIST 数字分类示例中,一个简单的基准是验证准确度大于 0.1(随机分类器);在 IMDB 示例中,它将是验证准确度大于 0.5;在 Reuters 示例中,由于类别不平衡,它将在 0.18–0.19 左右。如果您有一个二分类问题,其中 90%的样本属于 A 类,而 10%属于 B 类,那么始终预测 A 的分类器已经达到了 0.9 的验证准确度,您需要做得比这更好。

当您在解决以前没有人解决过的问题时,拥有一个常识基准是至关重要的。如果您无法击败一个微不足道的解决方案,那么您的模型毫无价值—也许您正在使用错误的模型,或者您正在处理的问题根本不能用机器学习来解决。是时候回到起点了。

5.2.3 模型评估要记住的事项

在选择评估协议时,请注意以下事项:

  • 数据的代表性 —— 你希望你的训练集和测试集都能代表手头的数据。例如,如果你试图对数字图像进行分类,并且你从一个按类别排序的样本数组开始,那么将数组的前 80%作为你的训练集,剩下的 20%作为测试集将导致你的训练集只包含类别 0-7,而你的测试集将只包含类别 8-9。这似乎是一个荒谬的错误,但这是令人惊讶地常见。因此,你通常应该在将数据拆分为训练集和测试集之前对数据进行随机洗牌

  • 时间箭头 —— 如果你试图根据过去来预测未来(例如,明天的天气,股票走势等),在将数据拆分之前不应该随机洗牌你的数据,因为这样做会产生时间泄漏:你的模型实际上是在未来的数据上训练的。在这种情况下,你应该始终确保测试集中的所有数据都后置于训练集中的数据。

  • 数据中的冗余 —— 如果你的数据中有一些数据点出现了两次(在真实世界的数据中相当常见),那么对数据进行洗牌并将其分为训练集和验证集将导致训练集和验证集之间存在冗余。实际上,你将在部分训练数据上进行测试,这是你可以做的最糟糕的事情!确保你的训练集和验证集是不相交的。

有一个可靠的评估模型性能的方法是你能够监控机器学习中核心的紧张关系 —— 在优化和泛化之间,在欠拟合和过拟合之间。

5.3 改善模型拟合

要达到完美的拟合,你必须首先过拟合。因为你事先不知道边界在哪里,所以必须跨越它才能找到它。因此,当你开始解决一个问题时,你的初始目标是实现一个表现出一定泛化能力并且能够过拟合的模型。一旦你有了这样一个模型,你将专注于通过抗击过拟合来精炼泛化能力。

在这个阶段你会遇到三个常见的问题:

  • 训练没有开始:你的训练损失随时间没有下降。

  • 训练开始得很顺利,但你的模型没有有意义的泛化:你无法打败你设定的常识基线。

  • 训练损失和验证损失随时间都在下降,你可以打败基线,但似乎无法过拟合,这表明你仍然是欠拟合的。

让我们看看你如何解决这些问题,以实现机器学习项目的第一个重要里程碑:获得具有一定泛化能力(能够击败一个琐碎的基线)并且能够过拟合的模型。

5.3.1 调整关键的梯度下降参数

有时候训练无法开始,或者开始后很快停止。损失值停滞不变。这总是可以克服的:记住,你可以使用随机数据来拟合模型。即使问题似乎毫无意义,你仍然应该可以训练出一些东西,即使只是记忆训练数据。

当此情况发生时,通常问题出在梯度下降过程的配置上:优化器的选择、模型权重的初始值分布、学习率或批量大小。所有这些参数都是相互依赖的,因此通常只需调整学习率和批量大小,同时保持其他参数不变即可。

让我们看一个具体的例子:使用值为 1 的不恰当大学习率训练第二章的 MNIST 模型。

图 5.7 使用过高的学习率训练 MNIST 模型

c(c(train_images, train_labels), .) %<-% dataset_mnist()

train_images <- array_reshape(train_images / 255,

c(60000, 28 * 28))

model <- keras_model_sequential() %>%

layer_dense(units = 512, activation = "relu") %>%

layer_dense(units = 10, activation = "softmax")

model %>% compile(optimizer = optimizer_rmsprop(1),

loss = "sparse_categorical_crossentropy",

metrics = "accuracy")

history <- model %>% fit(train_images, train_labels,

epochs = 10, batch_size = 128,

validation_split = 0.2)

plot(history)

图片

模型很快达到了 20% - 30%的训练和验证准确率,但无法超过这个范围。让我们尝试将学习率降低到 1e-2 这样更合理的值。

图 5.8 具有更合适学习率的相同模型

model <- keras_model_sequential() %>%

layer_dense(units = 512, activation = "relu") %>%

layer_dense(units = 10, activation = "softmax")

model %>% compile(optimizer = optimizer_rmsprop(1e-2),

loss = "sparse_categorical_crossentropy",

metrics = "accuracy")

model %>%

fit(train_images, train_labels,

epochs = 10, batch_size = 128,

validation_split = 0.2) ->

history

plot(history)

图片

现在模型可以进行训练了。

如果你遇到类似的情况,请尝试以下操作:

  • 降低或增加学习率 - 过高的学习率可能导致更新远超过合适的拟合,就像前面的例子一样,学习率太低可能会导致训练过程非常缓慢。

  • 增加批量大小 - 具有更多样本的批次会产生更有信息量且噪音较小(方差较低)的梯度。

最终,你将找到一个可以进行训练的配置。

5.3.2 利用更好的架构先验

你有一个适合的模型,但是由于某种原因你的验证指标一直没有改善。它们的表现不会比随机分类器好:模型可以训练但无法推广。发生了什么?

这也许是你可能会遇到的最糟糕的机器学习情况。这表明你的方法存在根本性问题,而且可能不容易找出问题所在。以下是一些提示。

首先,可能是你正在使用的输入数据简单地不包含足够的信息来预测你的目标:所描述的问题是不可解的。这就是早些时候当我们试图拟合一个 MNIST 模型而标签被洗牌时发生的情况:模型可以训练得很好,但验证准确率会停留在 10%,因为使用这样的数据集明显是无法泛化的。

也可能是你正在使用的模型类型不适合手头的问题。例如,在第十章中,你将看到一个时间序列预测问题的例子,其中一个密集连接的架构无法击败一个微不足道的基线,而一个更合适的循环架构确实成功泛化。使用对问题做出正确假设的模型对于实现泛化至关重要:你应该利用正确的架构先验。

在接下来的章节中,你将学习如何针对各种数据模态(图像、文本、时间序列等)选择最佳的架构。通常情况下,你应该确保阅读关于你攻击的任务类型的架构最佳实践——很有可能你不是第一个尝试这个任务的人。

5.3.3 增加模型容量

如果你设法得到一个拟合的模型,其中验证指标下降,并且似乎至少具有一定程度的泛化能力,恭喜你:你几乎成功了。接下来,你需要让你的模型开始过拟合。考虑下面这个小模型——一个简单的逻辑回归——在 MNIST 像素上训练。

清单 5.9 在 MNIST 上的简单逻辑回归

model <- keras_model_sequential() %>%

layer_dense(10, activation = "softmax")

model %>% compile(optimizer = "rmsprop",

loss = "sparse_categorical_crossentropy",

metrics = "accuracy")

history_small_model <- model %>%

fit(train_images, train_labels,

epochs = 20,

batch_size = 128,

validation_split = 0.2)

plot(history_small_model\(metrics\)val_loss, type = 'o',

main = "模型容量不足对验证损失的影响",

xlab = "Epochs", ylab = "Validation Loss")

你得到的损失曲线看起来像图 5.14。

图片

图 5.14 模型容量不足对验证损失的影响

验证指标似乎停滞不前,或者改善得非常缓慢,而不是达到峰值然后反转方向。验证损失达到 0.26 后就停在那里。你可以拟合,但明显无法过拟合,即使在训练数据上进行了多次迭代后也是如此。在你的职业生涯中,你可能经常遇到类似的曲线。

记住,总是应该能够过度拟合。就像训练损失不下降的问题一样,这是一个总是可以解决的问题。如果你似乎无法过度拟合,那很可能是你的模型的表示能力有问题:你需要一个更大的模型,一个能够存储更多信息的模型。你可以通过添加更多层,使用更大的层(参数更多的层),或者使用更适合手头问题的层类型(更好的架构先验)来增加表示能力。

让我们尝试训练一个更大的模型,一个有两个中间层,每个层有 96 个单元:

model <- keras_model_sequential() %>%

layer_dense(96, activation = "relu") %>%

layer_dense(96, activation = "relu") %>%

layer_dense(10, activation = "softmax")

model %>% compile(optimizer = "rmsprop",

loss = "sparse_categorical_crossentropy",

metrics = "accuracy")

history_large_model <- model %>%

fit(train_images, train_labels,

epochs = 20,

batch_size = 128,

validation_split = 0.2)

现在,验证曲线看起来正是应该的样子:模型拟合迅速,并在八个时期后开始过度拟合(见图 5.15):

plot(history_large_model\(metrics\)val_loss, type = 'o',

主要 = "适当容量模型的验证损失",

xlab = "时期", ylab = "验证损失")

图像

图 5.15 适当容量模型的验证损失

5.4 改善泛化

一旦你的模型已经表现出一些泛化能力并且能够过度拟合,就是时候把重点转移到最大化泛化上了。

5.4.1 数据集策划

你已经学到了,深度学习中的泛化源于你的数据的潜在结构。如果你的数据使得在样本之间平滑插值成为可能,你就能训练一个泛化的深度学习模型。如果你的问题过于嘈杂或者本质上是离散的,比如说,列表排序,深度学习将无法帮助你。深度学习是曲线拟合,不是魔法。

因此,确保你正在使用适当的数据集至关重要。在数据收集上花费更多的精力和金钱几乎总是比在开发更好的模型上花费相同的时间和金钱收益更大。

  • 确保你有足够的数据。记住,你需要对输入-输出空间进行密集采样。更多的数据将产生更好的模型。有时,一开始看似不可能的问题在有了更大的数据集后变得可解。

  • 最小化标记错误 —— 可视化你的输入以检查异常,并校对你的标签

  • 清理你的数据并处理缺失值(我们将在下一章中涵盖这个问题)。

  • 如果你有很多特征,并且不确定哪些是真正有用的,请进行特征选择。

改进数据的泛化能力的一个特别重要的方式是特征工程。对于大多数机器学习问题,特征工程是成功的关键因素。让我们来看一下。

5.4.2 特征工程

特征工程是利用你对数据和手头的机器学习算法(在本例中是神经网络)的了解,通过在数据输入模型之前应用硬编码(非学习)的转换来使算法更好地工作的过程。在许多情况下,期望机器学习模型能够从完全任意的数据中学习是不合理的。数据需要以一种使模型工作更轻松的方式呈现给模型。

让我们看一个直观的例子。假设你正在尝试开发一个模型,该模型可以将时钟的图像作为输入,并能够输出当天的时间(见图 5.16)。

图片

图 5.16 读取时钟时间的特征工程

如果你选择使用图像的原始像素作为输入数据,你将面临一个困难的机器学习问题。你将需要一个卷积神经网络来解决它,并且你将不得不耗费相当多的计算资源来训练网络。

但如果你已经在高层次上理解了问题(你了解人类如何在时钟表盘上读取时间),那么你可以为机器学习算法提供更好的输入特征:例如,编写一个五行的 R 脚本来跟踪时钟指针的黑色像素,并输出每个指针尖的(x,y)坐标。然后,一个简单的机器学习算法可以学会将这些坐标与适当的当天时间相关联。

你甚至可以走得更远:进行坐标变换,并将(x,y)坐标表示为相对于图像中心的极坐标。你的输入将成为每个时钟指针的角度θ。在这一点上,你的特征使得问题变得如此简单,以至于不需要任何机器学习;一个简单的取整操作和字典查找就足以恢复大致的当天时间。

这就是特征工程的本质:通过以更简单的方式表达问题来简化问题。使潜在的流形更加平滑、简单和组织良好。通常,这需要深入理解问题。

在深度学习出现之前,特征工程曾经是机器学习工作流程中最重要的部分,因为经典的浅层算法没有足够丰富的假设空间来自行学习有用的特征。你向算法呈现数据的方式对其成功至关重要。例如,在卷积神经网络在 MNIST 数字分类问题上取得成功之前,解决方案通常基于硬编码特征,如数字图像中的循环次数、图像中每个数字的高度、像素值的直方图等。

幸运的是,现代深度学习消除了大部分特征工程的需求,因为神经网络能够从原始数据中自动提取有用的特征。这是否意味着只要使用深度神经网络,你就不必担心特征工程了?不,有以下两个原因:

  • 好的特征仍然可以让你更优雅地解决问题,同时使用更少的资源。例如,使用卷积神经网络解决读取钟面的问题就是荒谬的。

  • 好的特征让你能够用更少的数据解决问题。深度学习模型自行学习特征的能力依赖于有大量的训练数据可用;如果你只有少量样本,那么样本特征中的信息价值就变得至关重要了。

5.4.3 使用提前停止

在深度学习中,我们总是使用大大超参数化的模型:它们的自由度比拟合数据的潜在流形所需的最小自由度要多得多。这种超参数化不是问题,因为你永远不会完全拟合一个深度学习模型。这样的拟合根本无法泛化。在你达到最小可能的训练损失之前,你总是会在训练之前中断。

找到在训练过程中达到最可泛化拟合的确切点——欠拟合曲线和过拟合曲线之间的确切边界——是你可以做的最有效的事情之一,以提高泛化能力。

在上一章的示例中,我们会先训练我们的模型比需要的时间更长,以找出产生最佳验证指标的周期数,然后我们会为确切的周期数重新训练一个新模型。这是相当标准的做法,但它需要你做冗余工作,有时可能是昂贵的。自然地,你可以在每个周期结束时保存你的模型,一旦找到最佳周期,就重新使用你最接近的保存模型。在 Keras 中,使用 callback_early_stopping 这个回调函数是很典型的做法,它会在验证指标停止改善时立即中断训练,同时记住已知的最佳模型状态。你将在第七章学习如何使用回调函数。

5.4.4 正则化你的模型

正则化技术是一组最佳实践,积极阻碍模型完美拟合训练数据的能力,其目标是使模型在验证期间表现更好。这被称为正则化模型,因为它倾向于使模型更简单、更“规则”,其曲线更平滑、更“通用”;因此,它不太具体于训练集,并且更能够通过更紧密地逼近数据的潜在流形进行泛化。

请记住,对模型进行正则化是一个应该始终由准确的评估程序指导的过程。只有当您能够测量到时,您才能实现泛化。

让我们回顾一些最常见的正则化技术,并在实践中应用它们来改进第四章的电影分类模型。

减小网络的规模

您已经学会了,模型太小将不会过度拟合。缓解过拟合的最简单方法是减小模型的大小(模型中可学习参数的数量,由层数和每层单元的数量确定)。如果模型具有有限的记忆资源,它将无法简单地记住其训练数据;因此,为了最小化其损失,它将不得不诉诸于学习具有关于目标的预测能力的压缩表示——这恰好是我们感兴趣的表示类型。与此同时,请记住,您应该使用具有足够参数的模型,使其不会欠拟合:您的模型不应该缺乏记忆资源。需要在过多容量不足容量之间找到一个折衷。

不幸的是,并没有一个神奇的公式来确定正确的层数或每个层的正确大小。您必须评估一系列不同的架构(当然是在验证集上,而不是在测试集上)以找到适合您数据的正确模型大小。找到合适模型大小的一般工作流程是从相对较少的层和参数开始,并增加层的大小或添加新层,直到您看到验证损失的减小收益。

让我们在电影评论分类模型上尝试一下这个。下面的列表显示了我们的原始模型。

列表 5.10 原始模型

c(c(train_data, train_labels), .) %<-% dataset_imdb(num_words = 10000)

vectorize_sequences <- function(sequences, dimension = 10000) {

results <- matrix(0, nrow = length(sequences), ncol = dimension)

for(i in seq_along(sequences))

results[i, sequences[[i]]] <- 1

results

}

train_data <- vectorize_sequences(train_data)

model <- keras_model_sequential() %>%

layer_dense(16, activation = "relu") %>%

layer_dense(16, activation = "relu") %>%

layer_dense(1, activation = "sigmoid")

model %>% compile(optimizer = "rmsprop",

loss = "binary_crossentropy",

metrics = "accuracy")

history_original <- model %>%

fit(train_data, train_labels,

epochs = 20, batch_size = 512, validation_split = 0.4)

现在让我们尝试用这个较小的模型来替换它。

列表 5.11 容量较低的模型版本

model <- keras_model_sequential() %>%

layer_dense(4, activation = "relu") %>%

layer_dense(4, activation = "relu") %>%

layer_dense(1, activation = "sigmoid")

model %>% compile(optimizer = "rmsprop",

loss = "binary_crossentropy",

指标 = "准确率")

history_smaller_model <- model %>%

fit(train_data, train_labels,

epochs = 20, batch_size = 512, validation_split = 0.4)

让我们生成一个图表(图 5.17)来比较原始模型和较小模型的验证损失:

绘图(

NULL,➊

主题 = "IMDB 评论分类的原始模型与较小模型比较",

xlab = "周期",

xlim = c(1, history_original\(params\)epochs),

ylab = "验证损失",

ylim = extendrange(history_original\(metrics\)val_loss),

panel.first = abline(v = 1:history_original\(params\)epochs,➋

lty = "dotted", col = "lightgrey")

)

lines(history_original  \(metrics\)val_loss, lty = 2)

lines(history_smaller_model\(metrics\)val_loss, lty = 1)

legend("topleft", lty = 2:1,

legend = c("原始模型的验证损失",

"验证损失较小模型"))

NULL 告诉 plot() 设置绘图区域但不绘制任何数据。

绘制网格线。

图像

图 5.17 IMDB 评论分类的原始模型与较小模型

如您所见,较小的模型开始过拟合比参考模型晚(在六个周期后而不是四个周期后),一旦开始过拟合,其性能下降得更慢。

现在让我们添加一个具有更大容量的基准模型——远远超出了问题所需的容量。尽管标准工作于对于他们要学习的内容明显过度参数化的模型,但确实存在过多记忆能力的情况。如果您的模型立即开始过拟合,并且其验证损失曲线看起来波动大(尽管波动的验证指标也可能是使用不可靠的验证过程的症状,例如验证拆分太小),那么您将知道您的模型太大。

列表 5.12 容量更高的模型版本

model <- keras_model_sequential() %>%

layer_dense(512, activation = "relu") %>%

layer_dense(512, activation = "relu") %>%

layer_dense(1, activation = "sigmoid")

model %>% compile(optimizer = "rmsprop",

loss = "binary_crossentropy",

指标 = "准确率")

history_larger_model <- model %>%

fit(train_data, train_labels,

epochs = 20, batch_size = 512, validation_split = 0.4)

绘图(

NULL,

主题 =

"IMDB 评论分类的原始模型与更大模型的比较",

xlab = "周期", xlim = c(1, history_original\(params\)epochs),

ylab = "验证损失",

ylim = range(c(history_original\(metrics\)val_loss,

history_larger_model\(metrics\)val_loss)),

panel.first = abline(v = 1:history_original\(params\)epochs,

lty = "dotted", col = "lightgrey")

)

lines(history_original \(metrics\)val_loss, lty = 2)

lines(history_larger_model\(metrics\)val_loss, lty = 1)

legend("左上角", lty = 2:1,

legend = c("原始模型的验证损失",

"更大模型的验证损失"))

图 5.18 显示了更大模型与参考模型的对比情况。

图片

图 5.18 IMDB 评论分类的原始模型和更大模型对比

更大的模型几乎立即开始过拟合,仅经过一个时期,它的过拟合情况更为严重。其验证损失也更加嘈杂。它非常快地获得接近零的训练损失。模型的容量越大,就越能快速建模训练数据(导致较低的训练损失),但也越容易过拟合(导致训练和验证损失之间的差异很大)。

添加权重正则化

您可能熟悉奥卡姆剃刀原则:对于某事物的两种解释,最有可能正确的解释是最简单的解释——即做出较少假设的解释。这个想法也适用于神经网络学习的模型:对于一些训练数据和网络架构,多组权重值(多个模型)可能可以解释数据。较简单的模型不太可能出现过拟合, 高复杂模型则相反。

在这个环境中,简单模型是指参数值分布熵较低(或参数较少的模型,正如在前一节中所看到的)。因此,减轻过拟合的常用方法是通过对模型的复杂性施加约束,强制其权重只取小值,从而使权重值的分布更加规则。这称为权重正则化,它通过在模型的损失函数中增加与大权重相关的代价来实现。这种代价有两种类型:

  • L1 正则化—增加的成本与权重系数的绝对值成正比(权重的L1 范数)。

  • L2 正则化—增加的成本与权重系数的平方值成正比(权重的L2 范数)。在神经网络环境中,L2 正则化也称为权重衰减。不要让不同的名字让你困惑:数学上,权重衰减与 L2 正则化是一样的。

在 Keras 中,通过将权重正则化器实例作为关键字参数加入到层中,可添加权重正则化。让我们向初始的电影评论分类模型中添加 L2 权重正则化。

清单 5.13 向模型添加 L2 权重正则化

model <- keras_model_sequential() %>%

layer_dense(16, activation = "relu",

kernel_regularizer = regularizer_l2(0.002)) %>%

layer_dense(16, activation = "relu",

kernel_regularizer = regularizer_l2(0.002)) %>%

layer_dense(1, activation = "sigmoid")

model %>% compile(optimizer = "rmsprop",

loss = "二元交叉熵",

metrics = "准确性")

history_l2_reg <- model %>% fit(

train_data, train_labels,

epochs = 20, batch_size = 512, validation_split = 0.4)

plot(history_l2_reg)

Image

在前面的清单中,regularizer_l2(0.002)表示层中权重矩阵中的每个系数将会添加 0.002 * weight_coefficient_value ^ 2 到模型的总损失中。请注意,因为这个惩罚只在训练时添加,所以该模型的损失在训练时会比在测试时高得多。

图 5.19 显示了 L2 正则化惩罚的影响。正如你所见,具有 L2 正则化的模型比参考模型更能抵抗过拟合,即使两个模型具有相同数量的参数。

清单 5.14 生成图表以演示 L2 权重正则化的效果

plot(NULL,

main = "L2 权重正则化对验证损失的影响",

xlab = "Epochs",

xlim = c(1, history_original\(params\)epochs),

ylab = "验证损失",

ylim = range(c(history_original\(metrics\)val_loss,

history_l2_reg \(metrics\)val_loss)),

panel.first = abline(v = 1:history_original\(params\)epochs,

lty = "dotted", col = "lightgrey"))

lines(history_original\(metrics\)val_loss, lty = 2)

lines(history_l2_reg \(metrics\)val_loss, lty = 1)

legend("左上角", lty = 2:1,

legend = c("原始模型的验证损失",

"L2 正则化模型的验证损失"))

Image

图 5.19 L2 权重正则化对验证损失的影响

作为 L2 正则化的替代方案,你可以使用以下 Keras 权重正则化器之一。

清单 5.15 Keras 中可用的不同权重正则化器

regularizer_l1(0.001)➊

regularizer_l1_l2(l1 = 0.001, l2 = 0.001)

<keras.regularizers.L1 object at 0x7f81cc3df340>➋

<keras.regularizers.L1L2 object at 0x7f81cc651c40>

L1 正则化

同时使用 L1 和 L2 正则化

注意,权重正则化更常用于较小的深度学习模型。大型深度学习模型往往过度参数化,对权重值施加约束并不会对模型容量和泛化性产生太大影响。在这些情况下,更喜欢使用不同的正则化技术:dropout。

添加 dropout

Dropout是神经网络中最有效、最常用的正则化技术之一;它是由杰弗·欣顿和他在多伦多大学的学生们开发的。应用于一层的 Dropout 在训练期间包括随机地退出(设为零)一定数量的该层输出特征。假设给定的层在训练期间为给定的输入样本返回一个向量 c(0.2, 0.5, 1.3, 0.8, 1.1)。应用 Dropout 后,该向量将随机分布一些零条目,例如,c(0, 0.5, 1.3, 0, 1.1)。退出率是被置零的特征的比例;通常设置在 0.2 和 0.5 之间。在测试时,不会退出任何单元;相反,该层的输出值会按退出率缩小,以平衡更多单元处于活动状态的事实。

考虑一个包含一层输出的矩阵,layer_output,形状为(batch_size, features)。在训练时,我们随机将矩阵中的一部分值置零:

zero_out <- random_array(dim(layer_output)) < .5

layer_output[zero_out] <- 0

在训练时,退出输出中的 50%单元

在测试时,我们通过退出率来缩放输出。在这里,我们以 0.5 的比例缩放(因为我们之前退出了一半的单元):

layer_output <- layer_output * .5

在测试时

请注意,这个过程可以通过在训练时执行两个操作并在测试时保持输出不变来实现,这通常是实践中的实现方式(参见图 5.20):

layer_output[random_array(dim(layer_output)) < dropout_rate] <- 0

layer_output <- layer_output / .5

在训练时。请注意,在这种情况下,我们是放大而不是缩小。

图片

图 5.20 Dropout 应用于训练期间的激活矩阵,训练期间发生重新缩放。在测试时,激活矩阵保持不变。

这种技术可能看起来奇怪而武断。为什么这有助于减少过拟合?欣顿说他受到了银行使用的一种防欺诈机制的启发。用他自己的话说,“我去了我的银行。出纳员不停地换,我问其中一个为什么。他说他不知道,但他们经常变动。我想这一定是因为成功欺诈银行需要员工之间的合作。这让我意识到,在每个例子中随机删除不同子集的神经元将防止阴谋,从而减少过拟合。”其核心思想是,向一层的输出值引入噪音可以打破无意义的模式(欣顿称之为阴谋),如果没有噪音存在,模型将开始记忆。

在 Keras 中,您可以通过 layer_dropout 在模型中引入 Dropout,它将 Dropout 应用于该层之前的输出。让我们在 IMDB 模型中添加两个 layer_dropout,看看它们在减少过拟合方面的效果如何。

图 5.16 向 IMDB 模型添加 Dropout

model <- keras_model_sequential() %>%

layer_dense(16,

layer_dropout(0.5) %>%

layer_dense(16, activation = "relu") %>%

layer_dropout(0.5) %>%

layer_dense(1, activation = "sigmoid")

model %>% compile(optimizer = "rmsprop",

损失 = "binary_crossentropy",

指标 = "accuracy")

history_dropout <- model %>% fit(

训练数据, 训练标签,

epochs = 20, batch_size = 512,

validation_split = 0.4

)

plot(history_dropout)

图像

图 5.21 显示了结果的图表。这是对参考模型的明显改进,它似乎也比 L2 正则化工作得更好,因为达到的最低验证损失也有所改善。

图 5.17 生成一个演示 Dropout 对验证损失的效果的图表

plot(NULL,

主标题 = "Dropout 对验证损失的效果",

x 轴标签 = "Epochs", x 轴范围 = c(1, history_original\(params\)epochs),

y 轴标签 = "验证损失",

y 轴范围 = range(c(history_original\(metrics\)val_loss,

history_dropout \(metrics\)val_loss)),

面板首先 = abline(v = 1:history_original\(params\)epochs,

lty = "dotted", col = "lightgrey"))

lines(history_original\(metrics\)val_loss, lty = 2)

lines(history_dropout \(metrics\)val_loss, lty = 1)

说明框("左上角", lty = 1:2,

图例 = c("Dropout 正则化模型的验证损失",

"原始模型的验证损失"))

图像

图 5.21 Dropout 对验证损失的效果

总结一下,以下是在神经网络中最常见的最大化泛化和防止过拟合的方法:

  • 获得更多的训练数据,或更好的训练数据。

  • 发展更好的特征。

  • 减少模型的容量。

  • 添加权重正则化(适用于较小的模型)。

  • 添加 Dropout

总结

  • 机器学习模型的目的是泛化:在以前未见过的输入上准确执行。这比看起来更难

  • 深度神经网络通过学习一个能够成功插值训练样本之间的参数化模型来实现泛化——这样的模型可以说已经学习了训练数据的“潜在流形”。这就是为什么深度学习模型只能理解与它们在训练中看到的非常接近的输入的原因。

  • 机器学习中的基本问题是优化和泛化之间的紧张关系:为了实现泛化,必须首先获得对训练数据的良好拟合,但是改善模型对训练数据的拟合会在一段时间后开始损害泛化能力。每一个深度学习最佳实践都处理了这种紧张的方式。

  • 深度学习模型的泛化能力来自于它们成功学习近似其数据的潜在流形的事实,因此可以通过插值来理解新的输入。

  • 在开发模型时,能够准确评估模型的泛化能力至关重要。你可以使用一系列评估方法,从简单的留出验证到K-折交叉验证和迭代K-折交叉验证与洗牌。请记住始终保留一个完全独立的测试集用于最终模型评估,因为验证数据泄露到模型中可能已经发生。

  • 当您开始研究一个模型时,您的目标首先是获得具有一定泛化能力并且可以过拟合的模型。做到这一点的最佳实践包括调整学习率和批量大小,利用更好的架构先验,增加模型容量,或者简单地延长训练时间。

  • 当您的模型开始过拟合时,您的目标转向通过模型正则化来提高泛化能力。您可以降低模型的容量,添加 dropout 或权重正则化,并使用早停技术。自然地,一个更大或更好的数据集始终是帮助模型泛化的首选方法。

  1. ¹ 马克·吐温甚至称其为“人类所知最美味的水果。”

第六章:机器学习的通用工作流程

本章涵盖

  • 构建机器学习问题的步骤

  • 开发可行模型的步骤

  • 部署模型到生产环境并进行维护的步骤

我们先前的示例假设我们已经有了一个标记的数据集,并且我们可以立即开始训练模型。但在现实世界中,情况通常并非如此。你并不是从一个数据集开始;你是从一个问题开始的。

想象一下,你正在开设自己的机器学习咨询公司。你注册了公司,建立了一个华丽的网站,通知了你的人脉。随后,以下项目开始涌现:

  • 一个为图片分享社交网络设计的个性化照片搜索引擎——输入“婚礼”并检索出你在婚礼上拍摄的所有照片,无需任何手动标记

  • 在新兴的聊天应用程序的帖子中标记垃圾邮件和冒犯性文本内容

  • 为在线广播电台用户构建音乐推荐系统。

  • 检测电子商务网站的信用卡欺诈。

  • 预测展示广告的点击率,以决定在特定时间为特定用户提供哪个广告。

  • 在饼干生产线上标记异常饼干。

  • 利用卫星图像来预测尚未知晓的考古遗址的位置

伦理说明

有时你可能会被提供伦理上可疑的项目,比如“构建一个从某人面部照片评估其可信度的人工智能。”首先,该项目的有效性存在疑问:不清楚为什么可信度会反映在某人的面部上。其次,这样的任务开启了各种伦理问题的大门。为这个任务收集数据集将意味着记录标记图片的人的偏见和成见。你用这些数据训练的模型只会将这些偏见编码到一个黑匣子算法中,这个算法会赋予它们一层薄薄的合法性。在我们这样一个主要技术文盲的社会中,“AI 算法说这个人不可信”似乎比“约翰·史密斯说这个人不可信”更具权威性和客观性,尽管前者只是对后者的一个学习近似。你的模型将以规模化的方式清洗和操作化人类判断的最糟糕的方面,对真实人生产生负面影响。

技术从来都不是中立的。如果你的工作对世界有任何影响,这种影响都具有道德方向:技术选择也是伦理选择。始终要谨慎选择你的工作要支持的价值观。

如果你可以通过 keras::data-set_mydataset() 访问正确的数据集并开始拟合一些深度学习模型,那将会非常方便。不幸的是,在现实世界中,你将不得不从零开始。

在本章中,您将学习一个通用的、逐步的蓝图,您可以使用它来处理和解决任何机器学习问题,就像前面列表中的问题一样。这个模板将汇集和 consaolidate 你在第四章和第五章中学到的一切,并给你更广泛的上下文,应该锚定你将在下一章学到的东西。

机器学习的通用工作流程大致分为三个部分:

  1. 1 定义任务—了解问题域和客户要求背后的业务逻辑。收集数据集,了解数据代表什么,并选择如何在任务上衡量成功。

  2. 2 开发模型—准备好数据,使其可以被机器学习模型处理,选择一个模型评估协议和一个简单的基准来超越,训练一个具有泛化能力的第一个模型,并且可以过拟合,然后正则化和调整你的模型,直到达到最佳的泛化性能。

  3. 3 部署模型—向利益相关者展示你的工作;将模型部署到 Web 服务器、移动应用程序、网页或嵌入式设备中;在实际应用中监视模型的性能;开始收集构建下一代模型所需的数据。

让我们深入了解。

6.1 定义任务

你如果没有对你正在做的事情的背景有深刻的理解,就无法做出好的工作。你的客户为什么要解决这个特定的问题?他们将从解决方案中获得什么价值——你的模型将如何使用,它将如何融入客户的业务流程?有哪些数据可用或可以收集?什么样的机器学习任务可以映射到业务问题上?

6.1.1 框架问题

框定一个机器学习问题通常涉及与利益相关者的许多详细讨论。以下是你脑海中应该考虑的问题:

你的输入数据将是什么?你想要预测什么?只有在有训练数据可用的情况下,你才能学会预测某些东西:例如,只有在有电影评论和情感注释可用时,你才能学会分类电影评论的情感。因此,在这个阶段,数据可用性通常是限制因素。在许多情况下,你将不得不自己收集和注释新的数据集(我们将在下一节中介绍)。

你面对的是什么类型的机器学习任务?它是二元分类吗?多类分类?标量回归?向量回归?多类别、多标签分类?图像分割?排名?其他一些,如聚类、生成或强化学习?在某些情况下,机器学习甚至可能不是理解数据的最佳方法,你应该使用其他方法,如纯粹的老式统计分析:

  • 照片搜索引擎项目是一个多类别、多标签分类任务。

  • 垃圾邮件检测项目是一个二元分类任务。如果将“攻击性内容”作为一个单独的类,那么它就是一个三元分类任务。

  • 音乐推荐引擎的处理方式最好不是通过深度学习,而是通过矩阵分解(协同过滤)来处理。

  • 信用卡欺诈检测项目是一个二元分类任务。

  • 点击率预测项目是一个标量回归任务。

  • 异常饼干检测是一个二元分类任务,但它还需要一个对象检测模型作为第一阶段,以正确裁剪原始图像中的饼干。请注意,被称为“异常检测”的一组机器学习技术在这种情况下并不适用!

  • 从卫星图像中找到新考古遗址的项目是一个图像相似性排名任务:你需要检索出看起来最像已知考古遗址的新图像。

现有解决方案是什么样的?也许你的客户已经有一个手工制作的算法来处理垃圾邮件过滤或信用卡欺诈检测,其中包含大量的嵌套 if 语句。也许目前是一个人手动处理正在考虑的过程 - 监控饼干厂的传送带并手动移除坏饼干,或者制作歌曲推荐播放列表以发送给喜欢特定艺术家的用户。你应该确保你了解已经存在的系统以及它们是如何工作的。

是否有特定的约束你将需要处理?例如,你可能发现你正在为其构建垃圾邮件检测系统的应用程序是严格端到端加密的,因此垃圾邮件检测模型必须存在于最终用户的手机上,并且必须在外部数据集上进行训练。也许饼干过滤模型有这样的延迟约束,它将需要在工厂的嵌入式设备上运行,而不是在远程服务器上运行。你应该了解你的工作将适合的完整环境。

一旦你完成了研究,你应该知道你的输入是什么,你的目标是什么,以及问题映射到的机器学习任务的广泛类型是什么。在这个阶段要注意你正在做出的假设:

  • 你假设你的目标可以根据你的输入来预测。

  • 你假设可用的数据(或者你即将收集的数据)足够信息丰富,可以学习输入和目标之间的关系。

在你有一个工作模型之前,这些只是等待验证或无效的假设。并不是所有的问题都可以用机器学习来解决;仅仅因为你已经组装了输入 X 和目标 Y 的示例,并不意味着 X 包含足够的信息来预测 Y。例如,如果你试图根据其最近的价格历史来预测股票在股票市场上的走势,你很可能不会成功,因为价格历史并没有太多的预测信息。

6.1.2 收集数据集

一旦您了解任务的性质,并且知道您的输入和目标将是什么,就到了数据收集的时候——这是大多数机器学习项目中最费力、耗时和昂贵的部分:

  • 照片搜索引擎项目需要您首先选择要分类的标签集——您选择了 10,000 个常见图像类别。然后,您需要手动为您过去上传的数十万张用户图片中的每一张打上这个集合中的标签。

  • 对于聊天应用的垃圾邮件检测项目,由于用户聊天是端到端加密的,您无法使用其内容来训练模型。您需要获取一个包含数万条未经筛选的社交媒体帖子的单独数据集,并手动标记它们为垃圾邮件、冒犯性内容或可接受内容。

  • 对于音乐推荐引擎,您可以只使用用户的“喜欢”。不需要收集新数据。同样,对于点击率预测项目:您有过去多年来广告点击率的广泛记录。

  • 对于 cookie 标记模型,您将需要在传送带上方安装摄像头来收集数万张图像,然后需要有人手动标记这些图像。目前知道如何做这件事的人在饼干工厂工作,但似乎并不太困难。您应该能够培训人员来做这件事。

  • 卫星图像项目将需要一支考古学家团队收集现有感兴趣地点的数据库,并且对于每个地点,您需要找到不同天气条件下拍摄的现有卫星图像。要获得一个好的模型,您将需要成千上万个不同的地点。

您在第五章学到,模型的泛化能力几乎完全来自于其所训练的数据的属性:您拥有的数据点数量,标签的可靠性,特征的质量。一个好的数据集是值得关注和投资的资产。如果您额外有 50 小时可用于项目,最有效的分配方式很可能是收集更多数据,而不是寻找增量建模改进。

关于数据比算法更重要的观点最著名的是由谷歌研究人员在 2009 年发表的一篇名为“数据的不合理有效性”的论文中提出的(标题是对尤金·维格纳于 1960 年发表的著名文章“数学在自然科学中的不合理有效性”的变种)。这是在深度学习流行之前,但值得注意的是,深度学习的兴起只是使数据的重要性更加突出。

如果你正在进行监督学习,那么一旦你收集到了输入(例如图像),你将需要对它们进行注释(例如图像的标签)——你将训练模型去预测的目标。有时,注释可以自动获取,例如音乐推荐任务或点击率预测任务的注释。但通常你需要手动注释你的数据。这是一个劳动密集型的过程。

投资于数据标注基础设施

你的数据标注过程将决定你的目标的质量,进而决定你模型的质量。仔细考虑你可用的选项:

  • 是否应该自己注释数据?

  • 是否应该使用像 Mechanical Turk 这样的众包平台来收集标签?

  • 是否应该使用专门的数据标注公司的服务

外包可能会节省时间和金钱,但会带走控制权。使用类似 Mechanical Turk 的东西可能价格便宜且规模化效果好,但你的标注可能会非常嘈杂。

为了选择最佳选项,考虑你正在处理的约束条件:

  • 数据标注员是否需要是专业领域的专家,还是任何人都可以对数据进行注释?猫狗图像分类问题的标签可以由任何人选择,但狗品种分类任务的标签需要专业知识。与此同时,标注骨折的 CT 扫描几乎需要医学学位。

  • 如果注释数据需要专业知识,你是否能够培训人员来做?如果不能,你如何才能获得相关的专家?

  • 你自己是否了解专家是如何提出注释的?如果不了解,你将不得不将你的数据集视为黑盒子,你将无法进行手动特征工程——这并不是关键,但可能会限制你。

如果决定在内部标注数据,问问自己你会使用什么软件记录注释。你可能需要自己开发这个软件。高效的数据标注软件会节省你大量的时间,因此在项目初期投资于它是值得的。

警惕非代表性数据

机器学习模型只能理解与它们以前看到的相似的输入。因此,关键是训练所使用的数据应该代表生产数据。这个问题应该是所有数据收集工作的基础。

假设你正在开发一个应用,用户可以拍摄一盘食物的照片以查找菜名。你使用了一个受到美食爱好者欢迎的图像分享社交网络上的图片来训练模型。但在部署后,愤怒用户的反馈开始涌现:你的应用在 10 次中有 8 次都答错了。发生了什么?你在测试集上的准确率远远超过了 90%!快速查看用户上传的数据发现,随机从随机餐厅拍摄的随机菜品的移动照片与你训练模型的专业质量、光线充足、诱人的照片完全不同:你的训练数据不代表生产数据。这是一个重大错误——欢迎来到机器学习地狱。

如果可能的话,直接从模型将被使用的环境中收集数据。电影评论情感分类模型应该用于新的 IMDB 评论,而不是 Yelp 餐厅评论或 Twitter 状态更新。如果你想评价一条推文的情感,首先收集和注释来自与你预期在生产中的相似用户集的实际推文。如果无法在生产数据上训练,那么确保你完全了解你的训练和生产数据的差异,并且你正在积极地纠正这些差异。

你应该注意的一个相关现象是概念漂移。你几乎在所有真实世界的问题中都会遇到概念漂移,尤其是那些涉及用户生成数据的问题。当生产数据的属性随时间改变时,会导致模型准确性逐渐下降,这就是概念漂移。2013 年训练的音乐推荐引擎可能今天已经不太有效。同样,你处理过的 IMDB 数据集是在 2011 年收集的,而基于它训练的模型可能在处理 2020 年的评论时与 2012 年的评论相比表现不佳,因为词汇、表达和电影类型随时间而演变。概念漂移在诸如信用卡欺诈检测之类的对抗性环境中特别严重,因为欺诈模式几乎每天都在变化。处理快速概念漂移需要不断进行数据收集、注释和模型重新训练。

请记住,机器学习只能用于记忆训练数据中存在的模式。你只能识别你以前见过的东西。使用过去数据训练的机器学习来预测未来是假设未来会像过去一样的假设。这通常并不是情况。

采样偏差问题

一个特别阴险而常见的非代表性数据情况是抽样偏差。当你的数据收集过程与你试图预测的内容互动时,就会产生偏倚的测量结果。一个著名的历史例子发生在 1948 年的美国总统选举中。在选举之夜,《芝加哥论坛报》刊登了标题“杜威击败杜鲁门”。第二天早晨,杜鲁门被宣布为胜利者。《论坛报》的编辑信任了一项电话调查的结果——但是 1948 年的电话用户并不是选民群体的随机、代表性样本。他们更有可能是富裕和保守的,更有可能投票给共和党候选人杜威。

图片

“杜威击败杜鲁门”:抽样偏差的著名例子

如今,每一次电话调查都会考虑到抽样偏差。这并不意味着在政治民意调查中抽样偏差已经成为历史——相反,与 1948 年不同的是,民意调查员们意识到了这一点,并采取措施予以纠正。

6.1.3 了解你的数据

把数据集当作黑匣子是相当糟糕的做法。在开始训练模型之前,您应该探索和可视化您的数据,以获取关于什么使其具有预测性的见解,这将为特征工程提供信息,并筛选出潜在问题:

  • 如果你的数据包含图像或自然语言文本,请直接查看几个样本(以及它们的标签)。

  • 如果你的数据包含数值特征,绘制特征值的直方图是个好主意,以了解取值范围和不同取值的频率。

  • 如果你的数据包含位置信息,请在地图上标出它。是否有明显的模式出现?

  • 一些样本是否缺少一些特征值?如果是,您将需要在准备数据时处理这些值(我们将在下一节介绍如何做到这一点)。

  • 如果你的任务是一个分类问题,请打印出数据中每个类的实例数。各类别是否大致平衡?如果不是,你将需要考虑这种不平衡。

  • 检查是否存在目标泄露:数据中是否存在提供有关目标信息的特征,这些信息在生产环境中可能不可用。如果你正在对医疗记录进行建模,以预测未来某人是否会接受癌症治疗,并且记录中包含“这个人被诊断为患有癌症”这个特征,那么你的目标就被人为地泄漏到了你的数据中。请时刻问自己,您的数据中的每个特征是否都是在生产中以同样的形式可用的。

6.1.4 选择成功的度量标准

控制某物,你需要能够观察它。要在项目上取得成功,你必须首先定义成功的含义。准确性?精确度和召回率?客户保留率?你对成功的度量将指导你在整个项目中做出的所有技术选择。它应直接与你的更高级别目标保持一致,例如你的客户的业务成功。

对于平衡分类问题,其中每个类别的概率相等,准确率和接收器操作特征(ROC)曲线下的面积,简称 ROC AUC,是常见的度量标准。对于类别不平衡问题、排名问题或多标签分类问题,你可以使用精确率和召回率,以及准确率或 ROC AUC 的加权形式。定义自己的自定义度量标准来衡量成功并不少见。要了解机器学习成功度量的多样性以及它们与不同问题领域的关系,浏览 Kaggle(kaggle.com)上的数据科学竞赛是有帮助的;它们展示了各种问题和评估度量标准的广泛范围。

6.2 开发模型

一旦你知道如何衡量你的进展,你就可以开始模型开发。大多数教程和研究项目假定这是唯一的步骤——跳过了问题定义和数据集收集,这些都假定已经完成,并跳过了模型部署和维护,这些都假定由其他人处理。事实上,模型开发只是机器学习工作流程中的一个步骤,如果你问我,这不是最困难的步骤。机器学习中最困难的事情是框定问题、收集、注释和清理数据。所以振作起来——接下来的事情将会比较容易!

6.2.1 准备数据

正如你之前学到的,深度学习模型通常不会直接处理原始数据。数据预处理旨在使手头的原始数据更适合神经网络处理。这包括向量化、标准化或处理缺失值。许多预处理技术是特定于领域的(例如,特定于文本数据或图像数据);我们将在接下来的章节中涵盖这些技术,当我们在实际示例中遇到它们时。现在,我们将回顾所有数据领域通用的基础知识。

向量化

神经网络中的所有输入和目标通常都必须是浮点数据张量(或者在特定情况下,整数或字符串张量)。无论你需要处理的数据是声音、图像还是文本,你都必须首先将其转换为张量,这一步称为数据向量化。例如,在第四章中的两个文本分类示例中,我们从文本表示为整数列表(代表单词序列)开始,然后使用一种称为独热编码的方法将它们转换为 float32 数据张量。在分类数字和预测房价的示例中,数据以向量化形式提供,因此我们可以跳过此步骤。

值归一化

在第二章的 MNIST 数字分类示例中,我们从图像数据开始,这些数据编码为 0-255 范围内的整数,编码灰度值。 在将这些数据馈送到我们的网络之前,我们将其除以 255,以便最终得到 0-1 范围内的浮点值。 类似地,当预测房屋价格时,我们从具有各种范围的特征开始 - 一些特征具有较小的浮点值,而另一些特征具有相当大的整数值。 在将这些数据馈送到我们的网络之前,我们必须独立地对每个特征进行归一化,使其具有标准差为 1 和平均值为 0。

通常,将相对较大的值(例如,比网络权重的初始值大得多的多位整数)或异构数据(例如,其中一个特征在 0-1 范围内,另一个特征在 100-200 范围内的数据)馈入神经网络是不安全的。 这样做可能会触发大的梯度更新,从而阻止网络收敛。 为了使网络更容易学习,您的数据应具有以下特征:

  • 取小值 - 通常,大多数值应在 0-1 范围内。

  • 要同质化 - 所有特征的值应该在大致相同的范围内。

此外,以下更严格的归一化做法是常见的,并且可能有所帮助,尽管并不总是必要的(例如,在数字分类示例中我们没有这样做):

  • 独立地对每个特征进行归一化,使其具有平均值为 0。

  • 独立地对每个特征进行归一化,使其具有标准差为 1

这很容易使用 scale()函数实现:

x <- scale(x)➊

假设 x 是形状为(样本,特征)的 2D 数据矩阵

处理缺失值

您的数据中有时可能会有缺失值。 例如,在房价示例中,第一个特征是人均犯罪率。 如果这个特征在所有样本中都不可用怎么办? 然后,您在训练或测试数据中会有缺失值。 您可以简单地丢弃整个特征,但您不一定必须这样做:

  • 如果该特征是分类的,则安全地创建一个新类别,表示“值丢失”。 模型将自动学习这对于目标意味着什么。

  • 如果该特征是数值的,请避免输入像 0 这样的任意值,因为它可能会在由特征形成的潜在空间中创建不连续性,使得在其上训练的模型更难泛化。 相反,考虑使用数据集中该特征的平均值或中位数值替换缺失值。 您还可以训练一个模型,根据其他特征的值来预测特征值。

请注意,如果您预计测试数据中缺少分类特征,而网络是在没有任何缺失值的数据上训练的,那么该网络将无法学会忽略缺失值!在这种情况下,您应该人为生成含有缺失项的训练样本:多次复制一些训练样本,并删除您预计在测试数据中可能缺失的一些分类特征。

6.2.2 选择评估协议

如您在上一章中所学,模型的目的是实现泛化,而在整个模型开发过程中您做出的每个决策都将由验证指标指导,以衡量泛化性能。您的评估协议的目标是准确估算您选择的成功指标(例如准确率)在实际生产数据上的表现。这个过程的可靠性对于构建有用的模型至关重要。在第五章中,我们回顾了三种常见的评估协议:

  • 维护保留验证集——当您有大量数据时,这是一种可行的方法。

  • 进行 K 折交叉验证——当您缺乏足够样本用于保留验证时,这是一种可靠的选择。

  • 迭代 K 折验证——这是在数据有限时进行高精度模型评估的方法。

选择其中之一。在大多数情况下,首选方法会足够有效。然而,如您所知,始终要注意验证集的代表性,并且要小心训练集和验证集之间没有重复的样本。

6.2.3 击败基准

当您开始真正着手于模型时,您的初始目标是获得统计效力,正如您在第五章中看到的:也就是说,开发一个能够击败简单基准的小模型。在这个阶段,您应该关注以下三点:

  • 特征工程——过滤掉无用的特征(特征选择),并利用您对问题的了解开发可能有用的新特征。

  • 选择正确的架构先验——您将使用哪种类型的模型架构?密集连接的网络、卷积神经网络、循环神经网络、transformers?深度学习是否适合这个任务,还是应该使用其他方法?

  • 选择足够好的训练配置——您应该使用什么损失函数?使用多大批量和学习率?

对于大多数问题,您可以从现有的模板开始。您并不是第一个尝试构建垃圾邮件检测器、音乐推荐引擎或图像分类器的人。确保您研究了先前的技术,以确定最有可能在您的任务上表现良好的特征工程技术和模型架构。

请注意,不一定总能获得统计功效。如果在尝试了多种合理的架构后仍无法击败一个简单的基线,那么可能是你所问的问题在输入数据中没有答案。请记住,你有两个假设:

  • 你假设可以根据输入来预测你的输出。

  • 你假设可用数据足够信息丰富,可以学习输入和输出之间的关系。

很可能这些假设是错误的,如果是这样,你必须回到起点。

选择合适的损失函数

往往不可能直接优化衡量问题成功的度量标准。有时,将度量标准转化为损失函数并不容易;毕竟,损失函数需要仅通过一个小批量数据就可以计算出来(理想情况下,损失函数应该可以计算出一个数据点)并且必须可微分(否则,你不能使用反向传播来训练你的网络)。例如,广泛使用的分类度量 ROC AUC 不能直接优化。因此,在分类任务中,通常会优化 ROC AUC 的代理度量标准,例如交叉熵。一般来说,你可以期望交叉熵越低,ROC AUC 就越高。

以下表格可以帮助你为几种常见的问题类型选择最后一层激活函数和损失函数。

选择合适的最后一层激活函数和损失函数

问题类型 最后一层激活函数 损失函数
二元分类 sigmoid binary_crossentropy
多类别、单标签分类 softmax categorical_crossentropy
多类别、多标签分类 sigmoid binary_crossentropy

6.2.4 扩展规模:开发一个过拟合的模型

一旦你得到了一个具有统计功效的模型,问题就变成了,你的模型是否足够强大?它是否有足够的层和参数来正确地建模手头的问题?例如,逻辑回归模型在 MNIST 上具有统计功效,但不能很好地解决问题。请记住,机器学习中的普遍紧张关系在于优化和泛化之间。理想的模型是站在欠拟合和过拟合之间的边界上,站在容量不足和容量过大之间。要确定这个边界在哪里,首先你必须越过它。

要确定你需要多大的模型,你必须开发一个过拟合的模型。这相当容易,就像你在第五章学到的那样:

  1. 1 增加层。

  2. 2 使层变大。

  3. 3 训练更多的时期。

总是监视训练损失和验证损失,以及你关心的任何指标的训练和验证值。当你发现模型在验证数据上的性能开始下降时,你就已经出现了过拟合。

6.2.5 正则化和调优你的模型

一旦您达到了统计功率并且能够过度拟合,您就知道您正在正确的道路上。在这一点上,您的目标是最大化泛化性能。

这个阶段将花费最多的时间:您将反复修改您的模型,训练它,在验证数据上评估(此时不是测试数据),再次修改它,然后重复,直到模型达到最佳状态。以下是一些您应该尝试的事项:

  • 尝试不同的架构;添加或删除层次

  • 添加丢失。

  • 如果您的模型很小,可以添加 L1 或 L2 正则化。

  • 尝试不同的超参数(例如每层的单元数或优化器的学习率)以找到最佳配置。

  • 可选地,对数据筛选或特征工程进行迭代:收集并注释更多数据,开发更好的特征,或者删除看起来不具信息量的特征。

可以通过使用自动化超参数调整软件(例如 KerasTuner)自动化大部分这项工作。我们将在第十三章中介绍这个。

慎重考虑以下事项:每次您利用验证过程的反馈来调整模型时,都会将关于验证过程的信息泄漏到模型中。这样做几次是无害的;但如果系统地进行多次迭代,最终会导致您的模型过度拟合验证过程(即使没有任何模型直接在任何验证数据上进行训练)。这会使评估过程变得不太可靠。

一旦您开发出令人满意的模型配置,您可以在所有可用数据(训练和验证)上训练最终的生产模型,并最后在测试集上进行最后一次评估。如果结果显示测试集上的性能明显比在验证数据上测量的性能差,这可能意味着您的验证程序毕竟不够可靠,或者您在调整模型参数时开始对验证数据过拟合。在这种情况下,您可能希望切换到更可靠的评估协议(例如迭代的K-折验证)。

6.3 部署模型

您的模型已经成功地通过了对测试集的最终评估——它已经准备好部署并开始其生产生活。

6.3.1 向利益相关者解释您的工作并设定期望

成功和客户信任意味着始终满足或超越人们的期望。您提供的实际系统只是其中一部分;另一部分是在发布之前设定适当的期望。

非专业人士对人工智能系统的期望往往是不现实的。例如,他们可能期望系统“理解”其任务,并能够在任务的上下文中行使类似人类常识的能力。为了解决这个问题,您应该考虑展示一些模型的失败模式的示例(例如,展示一些被错误分类的样本是什么样子,特别是对于那些误分类看起来令人惊讶的样本)。

他们可能还期望人类级别的性能,特别是对于以前由人处理的过程。大多数机器学习模型,因为它们(不完美地)被训练来近似人生成的标签,所以并不完全达到那个水平。您应该清楚地传达模型的性能期望。避免使用抽象的陈述,比如“该模型的准确率为 98%”(大多数人心理上会四舍五入为 100%),而更倾向于讨论例如假阴性率和假阳性率。您可以说,“使用这些设置,欺诈检测模型的假阴性率为 5%,假阳性率为 2.5%。每天,平均会有 200 笔有效交易被标记为欺诈并发送进行手动审查,而平均会漏掉 14 笔欺诈交易。平均会正确捕捉到 266 笔欺诈交易。”将模型的性能指标清晰地与业务目标相关联。

您还应该确保与利益相关者讨论关键的启动参数选择,例如交易应该在何种概率阈值下被标记为可疑(不同的阈值会产生不同的假阴性和假阳性率)。此类决策涉及可以通过对业务背景有深入了解来处理的权衡。

6.3.2 运送一个推理模型

机器学习项目并不会在您获得可以保存已训练模型的脚本时结束。您很少会将在训练过程中操作的确切模型对象投入生产。首先,您可能希望将模型导出到 R 之外的其他内容:

  • 您的生产环境可能根本不支持 R——例如,如果它是一个移动应用程序或嵌入式系统。

  • 如果应用的其余部分不是用 R 编写的(可能是 JavaScript、C++等),则使用 R 来提供模型可能会导致显着的开销。

其次,因为您的生产模型仅用于输出预测(称为推理阶段),而不是用于训练,所以您可以进行各种优化,以使模型更快速、减少其内存占用。让我们快速看一下您可以使用的不同模型部署选项。

将模型部署为 REST API

这可能是将模型转化为产品的常见方式:在服务器或云实例上安装 TensorFlow,并通过 REST API 查询模型的预测结果。您可以使用类似 Shiny(或任何其他 R web 开发库)或 tfdeploy R 包构建自己的服务应用,后者使用 TensorFlow 自己的用于将模型作为 API 部署的库,称为TensorFlow Servingwww.tensorflow.org/tfx/guide/serving)。使用 tfdeploy 和 TensorFlow Serving,您可以在几分钟内部署一个 Keras 模型。

当您:

  • 消费模型的应用程序将始终可靠地访问互联网(显然)。例如,如果您的应用程序是手机应用程序,从远程 API 提供预测意味着不成飞行模式或低网络连通性环境下应用程序将无法使用。

  • 应用程序没有严格的延迟要求:请求、推理和答案的往返通常需要约 500 毫秒。

  • 发送用于推理的输入数据不高度敏感:数据需要以解密的形式在服务器上可用,因为模型需要看它(但请注意,应该使用 SSL 加密 HTTP 请求和答案)。

例如,图像搜索引擎项目、音乐推荐系统、信用卡欺诈检测项目和卫星图像项目都适合通过 REST API 提供服务。

当将模型部署为 REST API 时,一个重要的问题是你是想托管自己的代码,还是想使用一个完全托管的第三方云服务,比如谷歌的 Cloud AI Platform 可以让你简单地将 TensorFlow 模型上传到 Google Cloud Storage (GCS),并提供一个 API 终端点来查询它。它会自动处理许多实际的细节,例如批量处理预测,负载均衡和扩展。

在设备上部署模型

有时候,你需要让模型运行在使用它的应用程序运行的同一个设备上,比如智能手机、机器人上的嵌入式 ARM CPU 或微型设备上的微控制器。你可能已经见过一款相机能够在你拍摄的场景中自动识别出人和面孔:这可能是一个小型深度学习模型直接在相机上运行。

当你需要这样一个设置的时候,你应该:

  • 如果你的模型有严格的延迟限制或需要在低网络连通性环境下运行,那么从远程服务器查询可能不是一个可行的选择,比如你在开发一个沉浸式增强现实应用程序。

  • 你的模型可以足够小,以便在目标设备的内存和功率约束下运行。你可以使用 TensorFlow Model Optimization Toolkit 来帮助实现这一点(www.tensorflow.org/model_optimization)。

  • 对于你的任务,获得最高可能的准确度并不是至关重要的。运行效率和准确性之间总是存在一个权衡,因此内存和电源限制通常要求您使用一个不如在大型 GPU 上运行的最佳模型好的模型。

  • 输入数据是严格保密的,因此不应该在远程服务器上解密

我们的垃圾邮件检测模型将需要作为聊天应用的一部分在最终用户的智能手机上运行,因为消息是端到端加密的,因此无法被远程托管模型读取。同样,恶意 cookie 检测模型具有严格的延迟约束,需要在工厂运行。幸运的是,在这种情况下,我们没有任何功耗或空间约束,因此实际上可以在 GPU 上运行模型。

要在智能手机或嵌入式设备上部署 Keras 模型,您的首选解决方案是 TensorFlow Lite (www.tensorflow.org/lite)。这是一个用于在设备上高效进行深度学习推断的框架,可在 Android 和 iOS 智能手机上运行,以及基于 ARM64 的计算机、树莓派或某些微控制器。它包括一个转换器,可以直接将您的 Keras 模型转换为 TensorFlow Lite 格式。

在浏览器中部署模型

深度学习经常用于基于浏览器或桌面的 JavaScript 应用程序。尽管通常可以让应用程序通过 REST API 查询远程模型,但直接在浏览器中运行模型,即在用户的计算机上运行(如果可用,则利用 GPU 资源)可能具有关键优势。

使用此设置时:

  • 您希望将计算卸载到最终用户,这可以大大降低服务器成本。

  • 输入数据需要保留在最终用户的计算机或手机上。例如,在我们的垃圾邮件检测项目中,聊天应用的 Web 版本和桌面版本(以 JavaScript 编写的跨平台应用程序)应使用在本地运行的模型。

  • 您的应用程序具有严格的延迟约束。虽然在最终用户的笔记本电脑或智能手机上运行的模型很可能比在您自己服务器上的大型 GPU 上运行的模型慢,但您没有额外的 100 毫秒网络往返时间。

  • 在模型已被下载并缓存后,您需要您的应用程序在没有连接的情况下继续工作

只有在您的模型足够小以至于不会占用用户笔记本电脑或智能手机的 CPU、GPU 或 RAM 时,才应选择此选项。此外,由于整个模型将下载到用户的设备上,您应确保模型的任何内容都不需要保密。请注意,鉴于一个经过训练的深度学习模型,通常可以恢复一些关于训练数据的信息:如果模型是在敏感数据上进行训练的,则最好不要将您的经过训练的模型公开。

要在 JavaScript 中部署模型,TensorFlow 生态系统包括 TensorFlow.js (www.tensorflow.org/js),这是一个用于深度学习的 JavaScript 库,几乎实现了所有的 Keras API(最初以 WebKeras 为工作名称开发)以及许多较低级别的 TensorFlow API。您可以轻松地将保存的 Keras 模型导入到 TensorFlow.js 中,以便作为浏览器中的 JavaScript 应用程序或桌面 Electron 应用程序的一部分进行查询。

推断模型优化

在部署在可用功率和内存有严格限制的环境(智能手机和嵌入式设备)或具有低延迟要求的应用程序中,优化推理模型尤为重要。你应该在将模型导入到 TensorFlow.js 或将其导出到 TensorFlow Lite 之前始终寻求优化你的模型。

你可以应用两种流行的优化技术:

  • 权重剪枝——权重张量中的每个系数对预测的贡献并不相等。通过仅保留最显著的系数,可以大大降低模型层中的参数数量。这减少了模型的内存和计算占用,但性能指标略有损失。通过决定要应用多少剪枝,你可以控制尺寸和准确度之间的权衡。

  • 权重量化——深度学习模型是用单精度浮点(float32)权重训练的。然而,可以将权重量化为 8 位有符号整数(int8),以获得一个仅用于推理的模型,其大小是原模型的四分之一,但保持接近原模型的准确度。

TensorFlow 生态系统包括一个权重剪枝和量化工具包(www.tensorflow.org/model_optimization),与 Keras API 深度集成。

6.3.3 在野外监控你的模型

你已经导出了推理模型,将其集成到你的应用程序中,并在生产数据上进行了一次干跑——模型的行为与你预期的完全一致。你已经编写了单元测试以及日志记录和状态监控代码——完美。现在是时候按下大红按钮,部署到生产环境了。

但这还不是结束。一旦部署了模型,你需要继续监控其行为,对新数据的性能、与应用程序其余部分的交互以及对业务指标的最终影响进行监控:

  • 在部署新的音乐推荐系统后,你的在线电台用户参与度是上升还是下降?切换到新的点击率预测模型后,平均广告点击率是否增加了?考虑使用随机化 A/B 测试来将模型本身的影响与其他变化隔离开来:一部分案例应通过新模型处理,而另一部分对照组应坚持使用旧流程。一旦处理了足够多的案例,两者之间结果的差异很可能归因于模型。

  • 如果可能的话,请定期手动审计模型对生产数据的预测。通常可以重用与数据注释相同的基础设施:将一部分生产数据发送到手动注释,然后将模型的预测与新注释进行比较。例如,你绝对应该对图像搜索引擎和坏 cookie 标记系统进行这样的操作。

  • 当无法进行手动审计时,考虑替代性的评估途径,例如用户调查(例如在垃圾邮件和不良内容标记系统的情况下)

6.3.4 维护你的模型

最后,没有一个模型能永远持续。你已经学习了关于概念漂移的内容:随着时间的推移,你的生产数据的特征会发生变化,逐渐降低你的模型的性能和相关性。你的音乐推荐系统的寿命将被计算为几周。对于信用卡欺诈检测系统,将是几天;图像搜索引擎的最佳情况是几年。

一旦你的模型已经启动,你应该准备好训练下一个将取代它的代代相传。因此:

  • 注意生产数据的变化。有新功能可用吗?需要扩展或编辑标签集吗?

  • 继续收集和注释数据,并随时间不断改进你的注释管道。特别是,你应该特别关注收集对你当前的模型分类难度较大的样本——这些样本最有可能帮助改善性能

这就是机器学习的通用工作流程——需要牢记很多东西。要成为专家,需要时间和经验,但不用担心:相对于前几章,你已经更加聪明了。现在你已经熟悉大局——机器学习项目所涉及的整个光谱。虽然本书的大部分内容都将集中在模型开发上,但你现在知道这只是整个工作流程的一部分。始终记住大局!

总结

  • 当你接手一个新的机器学习项目时,首先定义你手头的问题:

    • 理解你所要做的更广泛的背景——终极目标和限制是什么?

    • 收集并注释数据集;确保你深入了解你的数据。

    • 选择如何衡量问题的成功:在验证数据上监测哪些度量标准?

  • 一旦你理解了问题并拥有一个适当的数据集,就可以开发一个模型:

    • 准备你的数据。

    • 选择评估协议:留出验证?K-折验证?应该使用哪一部分数据来进行验证?

    • 获得统计力量:打败一个简单的基准。

    • 扩大规模:开发一个可以过度拟合的模型。

    • 根据验证数据上的性能进行正则化和超参数调整。许多机器学习研究往往只关注这一步,但请铭记大局。

  • 当模型已准备好并在测试数据上有良好的表现时,就该开始部署了:

    • 首先,确保你与利益相关者设定适当的期望。

    • 为推理优化一个最终模型,并在选择的部署环境中发布模型——Web 服务器、移动设备、浏览器、嵌入式设备等等。

    • 监控你的模型在生产中的表现,并不断收集数据,以便开发下一代模型。

第七章:使用 Keras:深入研究

本章内容

  • 使用 keras_model_sequential()、功能 API 和模型子类化创建 Keras 模型

  • 使用内置的 Keras 训练和评估循环

  • 使用 Keras 回调自定义培训

  • 使用 TensorBoard 监控培训和评估指标

  • 从头开始编写训练和评估循环

现在您已经具备了一些使用 Keras 的经验——您熟悉序列模型、密集层和用于训练、评估和推理的内置 API——compile()、fit()、evaluate()和 predict()。您甚至在第三章中学习了如何使用 new_layer_class()创建自定义层,以及如何使用 TensorFlow GradientTape() 实现逐步训练循环。

在接下来的章节中,我们将深入研究计算机视觉、时间序列预测、自然语言处理和生成深度学习等复杂应用。这些复杂的应用需要多于一个 keras_model_sequential()架构和默认的 fit()循环。因此,让我们先将你变成一个 Keras 专家!在本章中,您将获得有关使用 Keras API 的关键方法的完整概述:这是您需要处理接下来会遇到的高级深度学习用例的所有内容。

7.1 工作流的多样性

Keras API 的设计以逐渐揭示复杂性的原则为指导:让开始变得容易,但让处理高复杂度的用例成为可能,每个步骤只需要进行渐进式的学习。简单的用例应该易于接近,并且任意高级工作流程都应该是可能的:无论您想要做什么多么具有特色和复杂,都应该有一条明确的路径,它建立在您从较简单的工作流程中学到的各种事情之上。这意味着您可以从初学者成长为专家,仍然使用相同的工具,只是使用不同的方式。

因此,Keras 没有单一的“真正”使用方式。相反,Keras 提供了一系列工作流,从非常简单到非常灵活。有不同的方法来构建 Keras 模型,以及不同的训练方法,以满足不同的需求。因为所有这些工作流都基于共享的 API,例如层和模型,所以任何工作流的组件都可以在任何其他工作流中使用,它们都可以相互通信。

7.2 构建 Keras 模型的不同方式

在 Keras 中,有三个 API 可以用于构建模型(见图 7.1):

  • Sequential 模型是最易接近的 API,它基本上是一个列表。因此,它仅限于简单的层堆栈。

  • 功能 API注重于类似于图形的模型架构。它代表了易用性和灵活性之间的不错平衡,因此是最常用的模型构建 API。

  • 模型子类化 是一个低级选项,你需要从头开始自己编写一切。这是理想的选择,如果你想要对每一件事情都有完全的控制。然而,你将无法访问许多内置的 Keras 功能,并且更容易犯错误。

图像

图 7.1 逐渐展示模型构建的复杂性

7.2.1 Sequential 模型

构建 Keras 模型的最简单方式是使用 keras_model_sequential(),这是你已经了解的。

清单 7.1 keras_model_sequential()

library(keras)

model <- keras_model_sequential() %>%

layer_dense(64, activation = "relu") %>%

layer_dense(10, activation = "softmax")

注意,可以使用 %>% 逐步构建相同的模型。

清单 7.2 逐步构建 Sequential 模型

model <- keras_model_sequential()

model %>% layer_dense(64, activation = "relu")

model %>% layer_dense(10, activation = "softmax")

在第四章中,你看到层是在第一次调用时构建的(也就是说,创建它们的权重)。这是因为层的权重形状取决于它们的输入形状:直到输入形状被知道,它们才能被创建。

因此,上述的 Sequential 模型在你实际上对其使用一些数据,或者使用其 build() 方法并指定输入形状之前,是没有任何权重的(清单 7.3)。

清单 7.3 尚未构建的模型没有权重

model$weights➊

Error in py_get_attr_impl(x, name, silent):

ValueError: 模型 sequential_1 的权重尚未创建。当模型首次根据输入进行调用或调用 build() 并指定 input_shape 时,才会创建权重。

此时,模型尚未构建。

清单 7.4 第一次调用模型以构建它

model$build(input_shape = shape(NA, 3))➊

str(model$weights)➋

长度为 4 的列表

$ :<tf.Variable 'dense_2/kernel:0' shape=(3, 64) dtype=float32, numpy=…>

$ :<tf.Variable 'dense_2/bias:0' shape=(64) dtype=float32, numpy=…>

$ :<tf.Variable 'dense_3/kernel:0' shape=(64, 10) dtype=float32, numpy=…>

$ :<tf.Variable 'dense_3/bias:0' shape=(10) dtype=float32, numpy=…>

构建模型——现在模型将期望形状为 (3) 的样本。输入形状中的 NA 表示批量大小可以是任何值。

现在你可以检索模型的权重。

在模型构建完成后,你可以通过 print() 方法显示其内容,这对调试很有用。

清单 7.5 print() 方法

model

图像

正如你所看到的,这个模型恰好被命名为“sequential_1”。你可以为 Keras 中的每一个东西都起名字——每一个模型,每一个层。

清单 7.6 使用 name 参数为模型和层命名

model <- keras_model_sequential(name = "my_example_model")

model %>% layer_dense(64, activation = "relu", name = "my_first_layer")

model %>% layer_dense(10, activation = "softmax", name = "my_last_layer")

model$build(shape(NA, 3))

model

图像

在逐步构建 Sequential 模型时,可以在每添加一层后打印当前模型的摘要非常有用。但是直到模型构建完成之前,您无法打印摘要!事实上,有一种方法可以动态构建您的 Sequential 模型:只需提前声明模型输入的形状即可。您可以通过将 input_shape 传递给 keras_model_sequential() 来实现此目的。

清单 7.7 预先指定您模型的输入形状

model <

使用 keras_model_sequential(input_shape = c(3)) %>%

layer_dense(64, activation = "relu")

➊ 提供 input_shape 以声明输入的形状。请注意,shape 参数必须是每个样本的形状,而不是一个批次的形状。

现在您可以使用 print() 跟踪随着您添加更多层,模型输出形状如何变化:

model

图片

model %>% layer_dense(10, activation = "softmax")

model

图片

这是在处理以复杂方式转换其输入的层时的一种非常常见的调试工作流程,例如您将在第八章中了解的卷积层。

7.2.2 Functional API

Sequential 模型易于使用,但其适用性极为有限:它只能表示具有单个输入和单个输出的模型,并以顺序方式一个接一个地应用层。在实践中,遇到具有多个输入(例如,图像及其元数据)、多个输出(关于数据您想要预测的不同事物)或非线性拓扑的模型是非常常见的。

在这种情况下,您将使用 Functional API 构建模型。这是您在野外遇到的大多数 Keras 模型使用的。这很有趣和强大——感觉就像玩乐高积木一样。

一个简单的例子

让我们从简单的东西开始:我们在上一节中使用的两层堆叠。其 Functional API 版本如下所示。

清单 7.8 具有两个 Dense 层的简单 Functional 模型

inputs <- layer_input(shape = c(3), name = "my_input")

features <- inputs %>% layer_dense(64, activation = "relu")

outputs <- features %>% layer_dense(10, activation = "softmax")

model <- keras_model(inputs = inputs, outputs = outputs)

让我们一步一步来。我们首先声明了一个 layer_input()(请注意,您也可以给这些输入对象命名,就像其他所有东西一样):

inputs <- layer_input(shape = c(3), name = "my_input")

此 inputs 对象保存有关模型将处理的数据的形状和 dtype 的信息:

inputs$shape➊

TensorShape([None, 3])

inputs$dtype➋

tf.float32

➊ 该模型将处理每个样本形状为 (3) 的批次。每个批次中的样本数量是可变的(由 None 批次大小指示)。

这些批次的 dtype 将为 float32。

我们称这样的对象为符号张量。它不包含任何实际数据,但它编码了模型在使用时将看到的实际数据张量的规格。它代表未来的数据张量。

接下来,我们创建一个层,并与输入组合:

features <- inputs %>% layer_dense(64, activation = "relu")

在 Functional API 中,将符号张量传送到层构造函数会调用该层的 call() 方法。实质上,这就是发生的事情:

layer_instance <- layer_dense(units = 64, activation = "relu") features <- layer_instance(inputs)

这与 Sequential API 不同,其中将层与模型组合(model %>% layer_dense())意味着这个:

layer_instance <- layer_dense(units = 64, activation = "relu") model$add(layer_instance)

所有 Keras 层既可以在实际数据张量上调用,也可以在这些符号张量上调用。在后一种情况下,它们返回一个新的符号张量,具有更新的形状和 dtype 信息:

features$shape

TensorShape([None, 64])

注意,符号张量几乎可以与所有相同的 R 通用方法一起使用,如 eager 张量。这意味着你也可以像这样获取形状作为 R 整数向量:

dim(features)

[1] NA 64

在获得最终输出后,我们通过在 keras_model() 构造函数中指定其输入和输出来实例化模型:

outputs <- layer_dense(features, 10, activation = "softmax")

model <- keras_model(inputs = inputs, outputs = outputs)

这是我们模型的概要:

model

Image

多输入,多输出模型

与这个玩具模型不同,大多数深度学习模型看起来不像列表,而是像图。例如,它们可能具有多个输入或多个输出。正是对于这种模型,Functional API 才真正发挥作用。

假设你正在构建一个系统,根据优先级对客户支持票进行排名,并将其路由到适当的部门。你的模型有三个输入:

  • 票的标题(文本输入)

  • 票的文本正文(文本输入)

  • 用户添加的任何标签(假定这里是 one-hot 编码的分类输入)

我们可以将文本输入编码为大小为 vocabulary_size 的 1 和 0 数组(有关文本编码技术的详细信息,请参见第十一章)。你的模型还有两个输出:

  • 票的优先级分数,介于 0 和 1 之间的标量(sigmoid 输出)

  • 应处理该票的部门(对部门集合进行 softmax)

你可以用几行代码使用 Functional API 构建此模型。

列表 7.9 多输入,多输出的 Functional 模型

vocabulary_size <- 10000

num_tags <- 100

num_departments <- 4

title <- layer_input(shape = c(vocabulary_size), name = "title")➊

text_body <- layer_input(shape = c(vocabulary_size), name = "text_body")➊

tags <- layer_input(shape = c(num_tags), name = "tags")➊

features <-

layer_concatenate(list(title, text_body, tags)) %>%➋

layer_dense(64, activation = "relu")➌

priority <- features %>%➍

layer_dense(1, activation = "sigmoid", name = "priority")

department <- features %>%➍

layer_dense(num_departments, activation = "softmax", name = "department")

model <- keras_model(➎

inputs = list(title, text_body, tags),

outputs = list(priority, department)

)

定义模型的输入。

将输入特征合并成单个张量,通过连接它们。

应用中间层将输入特征重新组合成更丰富的表示。

定义模型的输出。

通过指定其输入和输出来创建模型。

函数式 API 是一种简单、类似于 LEGO 但非常灵活的方式,可以定义这样的任意层级图。

训练多输入、多输出模型

您可以通过调用 fit()并传递输入和输出数据的列表来训练模型,这与训练 Sequential 模型的方式非常相似。这些数据列表应与您传递给 keras_model()构造函数的输入的顺序相同。

示例 7.10 通过提供输入和目标数组列表来训练模型

num_samples <- 1280

random_uniform_array <- function(dim)

array(runif(prod(dim)), dim)

random_vectorized_array <- function(dim)

array(sample(0:1, prod(dim), replace = TRUE), dim)

title_data <- random_vectorized_array(c(num_samples, vocabulary_size))➊

text_body_data <- random_vectorized_array(c(num_samples, vocabulary_size))➊

tags_data <- random_vectorized_array(c(num_samples, num_tags))➊

priority_data <- random_vectorized_array(c(num_samples, 1))➋

department_data <- random_vectorized_array(c(num_samples, num_departments))➋

model %>% compile(

optimizer = "rmsprop",

loss = c("mean_squared_error", "categorical_crossentropy"),

metrics = c("mean_absolute_error", "accuracy")

)

model %>% fit(

x = list(title_data, text_body_data, tags_data),

y = list(priority_data, department_data),

epochs = 1

)

model %>% evaluate(x = list(title_data, text_body_data, tags_data),

y = list(priority_data, department_data))

图片

c(priority_preds, department_preds) %<-% {➌

model %>% predict(list(title_data, text_body_data, tags_data))

}

虚拟的输入数据

虚拟的目标数据

要在同一表达式中使用%<-%和%>%,您需要用{}或()将管道序列包装起来,以覆盖默认的运算符优先级。

如果您不想依赖输入顺序(例如,因为有多个输入或输出),您也可以利用您给输入形状和输出层指定的名称,并通过命名列表传递数据。

重要提示:使用命名列表时,列表的顺序不能保证保留下来。请务必通过位置或名称跟踪项目,但不能混合使用两者。

示例 7.11 通过提供命名输入和目标数组列表来训练模型

model %>%

compile(optimizer = "rmsprop",

loss = c(priority = "mean_squared_error",

department = "categorical_crossentropy"),

metrics = c(priority = "mean_absolute_error",

department = "accuracy"))

model %>%

fit(list(title = title_data,

text_body = text_body_data,

标签 = tags_data),

list(priority = priority_data,

department = department_data), epochs = 1)

model %>%

评估(list(title = title_data,

text_body = text_body_data,

标签 = tags_data),

list(priority = priority_data,

department = department_data))

图片

c(priority_preds, department_preds) %<-%

预测(model, list(title = title_data,

text_body = text_body_data,

标签 = tags_data))

函数 API 的威力:访问层连接性

功能模型是一个明确的图形数据结构。这使得可以检查层如何连接,并重复使用以前的图形节点(它们是层输出)作为新模型的一部分。它还很好地适应了大多数研究人员在思考深度神经网络时使用的“心理模型”:层的图形。这使两个重要用例成为可能:模型可视化和特征提取。

让我们可视化刚刚定义的模型的连接性(模型的 拓扑)。您可以使用 plot() 方法将功能模型绘制为图形(参见图 7.2):

plot(model)

图片

图 7.2 由我们的票务分类器模型 plot(model) 生成的图表

您可以在模型的每一层中添加此图表的输入和输出形状,这在调试期间可能会有所帮助(参见图 7.3):

plot(model, show_shapes = TRUE)

图片

图 7.3 添加形状信息的模型图

张量形状中的“None”表示批处理大小:该模型允许任意大小的批处理。

访问层连接性也意味着您可以检查和重复使用图中的单个节点(层调用)。模型\(layers 模型属性提供了组成模型的层的列表,对于每个层,您可以查询 layer\)input 和 layer$output。

列表 7.12 在功能模型中检索层的输入或输出

str(model$layers)

7 的列表

$ :<keras.engine.input_layer.InputLayer object at 0x7fc962da63a0>

$ :<keras.engine.input_layer.InputLayer object at 0x7fc962da6430>

$ :<keras.engine.input_layer.InputLayer object at 0x7fc962da68e0>

$ :<keras.layers.merge.Concatenate object at 0x7fc962d2e130>

$ :<keras.layers.core.dense.Dense object at 0x7fc962da6c40>

$ :<keras.layers.core.dense.Dense object at 0x7fc962da6340>

$ :<keras.layers.core.dense.Dense object at 0x7fc962d331f0>

str(model\(layers[[4]]\)input)

3 的列表

$ :<KerasTensor: shape=(None, 10000) dtype=float32 (created by layer

图片 'title')>

$ :<KerasTensor: shape=(None, 10000) dtype=float32 (created by layer

图片 'text_body')>

$ :<KerasTensor: shape=(None, 100) dtype=float32 (created by layer 'tags')>

str(model\(layers[[4]]\)output)

<KerasTensor: shape=(None, 20100) dtype=float32 (created by layer

图片 'concatenate')>

这使您能够进行 特征提取,创建重用另一个模型的中间特征的模型。

假设你想要在前一个模型中添加另一个输出——你想要估计给定问题票的解决时间,一种困难评级。你可以通过三个类别的分类层来实现这一点:“快速”,“中等”和“困难”。你不需要从头开始重新创建和重新训练一个模型。你可以从你以前模型的中间特征开始,因为你可以像这样访问它们。

Listing 7.13 通过重用中间层输出创建一个新模型

features <- model\(layers[[5]]\)output➊

difficulty <- features %>%

layer_dense(3, activation = "softmax", name = "difficulty")

new_model <- keras_model(

inputs = list(title, text_body, tags),

outputs = list(priority, department, difficulty)

)

layer[[5]] 是我们的中间密集层。您也可以使用 get_layer() 按名称检索层。

让我们绘制我们的新模型(见图 7.4):

plot(new_model, show_shapes = TRUE)

图像

图 7.4 我们的新模型绘图:更新的工单分类器

7.2.3 子类化 Model 类

你应该知道的最后一个模型构建模式是最高级的模型子类化。你在第三章学习过如何使用 new_layer_class() 子类化 Layer 类并创建自定义层。使用 new_model_class() 子类化 Model 类非常相似:

  • 在 initialize() 方法中,定义模型将使用的层。

  • 在 call() 方法中,定义模型的前向传播,重用先前创建的层。

  • 实例化你的子类,并在数据上调用它以创建它的权重

重写我们之前的示例作为子类模型

让我们看一个简单的例子:我们将使用 new_model_class() 重新实现客户支持票务管理模型来定义一个 Model 子类。

Listing 7.14 一个简单的子类模型

CustomerTicketModel <- new_model_class(

classname = "CustomerTicketModel",

initialize = function(num_departments) {

super$initialize()➊

self$concat_layer <- layer_concatenate()

self$mixing_layer <-➋

layer_dense(units = 64, activation = "relu")

self$priority_scorer <-

layer_dense(units = 1, activation = "sigmoid")

self$department_classifier <-

layer_dense(units = num_departments, activation = "softmax")

},

call = function(inputs) {➌

title <- inputs$title➍

text_body <- inputs$text_body

tags <- inputs$tags

features <- list(title, text_body, tags) %>%

self$concat_layer() %>%

self$mixing_layer()

priority <- self$priority_scorer(features)

department <- self$department_classifier(features)

list(priority, department)

}

)

别忘了调用 super$initialize()!

在构造函数中定义子层。请注意,我们在这里指定了 units 参数名称,以便我们得到一个层实例。

在 call() 方法中定义前向传播。

对于输入,我们将提供一个带有名称的列表给模型。

我们在第三章中实现了 Model 的最简版本。要注意的主要事项是,我们正在定义一个自定义类,即我们的模型,它是 Model 子类。正如您将在接下来的章节中看到的那样,Model 提供了许多方法和功能,您可以选择加入其中。

定义模型之后,您可以实例化它。请注意,它只会在首次在某些数据上调用它时创建其权重,就像层子类一样:

model <- CustomerTicketModel(num_departments = 4)

c(priority, department) %<-% model(list(title = title_data,

text_body = text_body_data,

tags = tags_data))

模型本质上是一种层(Layer)。这意味着,使用 create_layer_wrapper()就可以轻松地使模型具有与%>%很好地组合的能力,就像这样:

inputs <- list(title = title_data,

text_body = text_body_data,

tags = tags_data)

layer_customer_ticket_model <- create_layer_wrapper(CustomerTicketModel)

outputs <- inputs %>%

layer_customer_ticket_model(num_departments = 4)

c(priority, department) %<-% outputs

到目前为止,所有内容看起来与 Layer 子类化非常相似,这是您在第三章中遇到的工作流程。那么,“层”的子类和“模型”的子类之间有什么区别呢?答案很简单:一个“层”是用来创建模型的构建块,而一个“模型”是您将实际培训、导出推理等的最高级对象。换句话说,模型具有 fit()、evaluate()和 predict()方法,而层则没有。除此之外,这两个类几乎完全相同。(另一个区别是您可以将模型保存在磁盘上的文件中,我们将在几个章节中介绍这个功能。)您可以编译和训练 Model 子类,就像 Sequential 模型或 Functional 模型一样:

model %>%

compile(optimizer = "rmsprop",

loss = c("mean_squared_error",

"categorical_crossentropy"),➊

metrics = c("mean_absolute_error", "accuracy"))

x <- list(title = title_data,➋

text_body = text_body_data,

tags = tags_data)

y <- list(priority_data, department_data)➌

model %>% fit(x, y, epochs = 1)

model %>% evaluate(x, y)

Image

c(priority_preds, department_preds) %<-% {

model %>% predict(x)

}

您作为损失和指标参数传递的内容的结构必须严格匹配 call()方法返回的内容,即两个元素的列表。

输入数据的结构必须严格匹配 call()方法所期望的内容,即一个命名列表,其中包括标题(title)、正文(text_body)和标签(tags)。当按名称匹配时,列表顺序将被忽略!

目标数据的结构必须严格匹配 call()方法返回的内容,即两个元素的列表。

Model 子类化流程是构建模型最灵活的方法。它使您能够构建无法表示为层的有向无环图的模型,例如,模型的 call()方法在 for 循环内使用层,甚至会递归调用它们。任何事情都可能发生——您有权决定。

警告:子类化模型不支持的内容

这种自由是有代价的:通过子类模型,您需要负责更多的模型逻辑,这意味着您的潜在错误范围要大得多。因此,您将需要更多的调试工作。您正在开发一个新的类对象,而不仅仅是将乐高积木拼凑在一起。

Functional 和子类模型在性质上也有很大不同。Functional 模型是一个显式数据结构——层的图形,您可以查看、检查和修改。而子类模型是一组 R 代码——一个具有 call() 方法的类,该方法是一个 R 函数。这就是子类化工作流程的灵活性来源——您可以编写任何功能性代码——但它也引入了新的限制。

例如,因为层之间连接的方式被隐藏在 call() 方法的主体内部,您无法访问该信息。调用 summary() 将不显示层连接性,并且您无法通过 plot() 绘制模型拓扑。同样,如果您有一个子类模型,您无法访问层图的节点来进行特征提取,因为简单地没有图。一旦模型被实例化,其正向传递就变成了一个完全的黑盒。

7.2.4 混合和匹配不同的组件

关键是,选择这些模式之一——Sequential 模型、Functional API 或 Model 子类化——不会将您排除在其他模式之外。Keras API 中的所有模型都可以与彼此平滑地互操作,无论它们是 Sequential 模型、Functional 模型还是从头编写的子类模型。它们都是同一工作流谱系的一部分。例如,您可以在 Functional 模型中使用一个子类化的层或模型。

列表 7.15 创建一个包含子类化模型的 Functional 模型

ClassifierModel <- new_model_class(

classname = "Classifier",

initialize = function(num_classes = 2) {

super$initialize()

if (num_classes == 2) {

num_units <- 1

activation <- "sigmoid"

} else {

num_units <- num_classes

activation <- "softmax"

}

self$dense <- layer_dense(units = num_units, activation = activation)

},

call = function(inputs)

self$dense(inputs)

)

inputs <- layer_input(shape = c(3))

classifier <- ClassifierModel(num_classes = 10)

outputs <- inputs %>%

layer_dense(64, activation = "relu") %>%

classifier()

model <- keras_model(inputs = inputs, outputs = outputs)

相反地,您可以在子类化层或模型中使用 Functional 模型的一部分。

列表 7.16 创建一个包含 Functional 模型的子类模型

inputs <- layer_input(shape = c(64))

outputs <- inputs %>% layer_dense(1, activation = "sigmoid")

binary_classifier <- keras_model(inputs = inputs, outputs = outputs)

MyModel <- new_model_class(

classname = "MyModel",

initialize = function(num_classes = 2) {

super$initialize()

self$dense <- layer_dense(units = 64, activation = "relu")

self$classifier <- binary_classifier

},

call = function(inputs) {

inputs %>%

self$dense() %>%

self$classifier()

}

)

model <- MyModel()

7.2.5 记住:用对的工具做对的事情

你已经学习了一系列构建 Keras 模型的工作流程,从最简单的序列模型到最高级的模型子类化。什么时候应该使用其中一种?每种都有其优缺点,选择最适合当前工作的一种。

总的来说,功能 API 为您提供了易于使用和灵活性的很好的平衡。它还直接给您访问层连接性的能力,对于模型绘制或特征提取等用例非常有用。如果您可以使用功能 API,即,如果您的模型可以表示为层的有向无环图,那么我建议您使用功能 API 而不是模型子类化。

接下来,本书中的所有示例都将使用函数式 API,仅因为我们将使用的所有模型都可以表示为层图。我们还将经常使用子类化层(使用 new_layer_class())。总的来说,在包括子类化层的功能模型中使用,既具有高度的开发灵活性,同时又保留了功能 API 的优势,是最佳选择。

7.3 使用内置的训练和评估循环

逐步透明化复杂性的原则——从极简单到任意灵活的不同工作流的访问,一步一步——也适用于模型训练。Keras 为您提供了不同的模型训练工作流。它们可以简单到在数据上调用 fit(),也可以高级到从头编写新的训练算法。

你已经熟悉了 compile()、fit()、evaluate()、predict() 的工作流程。作为提醒,请看下面的清单。

清单 7.17 标准工作流程:compile()、fit()、evaluate()、predict()

get_mnist_model <- function() {➊

inputs <- layer_input(shape = c(28 * 28))

outputs <- inputs %>%

layer_dense(512, activation = "relu") %>%

layer_dropout(0.5) %>%

layer_dense(10, activation = "softmax")

keras_model(inputs, outputs)

}

c(c(images, labels), c(test_images, test_labels)) %<-%➋

dataset_mnist()

images <- array_reshape(images, c(-1, 28 * 28)) / 255

test_images <- array_reshape(test_images, c(-1, 28 * 28)) / 255

val_idx <- seq(10000)

val_images <- images[val_idx, ]

val_labels <- labels[val_idx]

train_images <- images[-val_idx, ]

train_labels <- labels[-val_idx]

model <- get_mnist_model()

model %>% compile(optimizer = "rmsprop",➌

loss = "sparse_categorical_crossentropy",

metrics = "accuracy")

model %>% fit(train_images, train_labels,➍

epochs = 3,

validation_data = list(val_images, val_labels))

test_metrics <- model %>% evaluate(test_images, test_labels)➎

predictions <- model %>% predict(test_images)➏

创建模型(我们将其分解成一个单独的函数,以便以后重用)。

预留一部分数据作为验证,加载你的数据。

通过指定优化器、最小化的损失函数和要监控的指标来编译模型。

使用 fit()训练模型,可以选择提供验证数据以监控在看不见的数据上的性能。

使用 evaluate()计算新数据的损失和指标。

使用 predict()计算新数据的分类概率。

有几种方法可以自定义这个简单的工作流程:

  • 提供你自己的自定义指标。

  • 在 fit()方法中传递callbacks以安排在训练过程中特定时间点执行的操作。

让我们来看一下这些。

7.3.1 编写自己的指标

指标是衡量模型性能的关键,尤其是衡量其在训练数据和测试数据上性能差异的指标。分类和回归常用的指标已经是 keras 包的一部分,都以 metric_ 前缀开头,大多数情况下你会使用它们。但是,如果你要做一些超出寻常的事情,就需要编写自己的指标。这很简单!

一个 Keras 指标是 Keras Metric 类的子类。和层一样,指标有一个存储在 TensorFlow 变量中的内部状态。与层不同的是,这些变量不是通过反向传播更新的,所以必须自己编写状态更新逻辑,这发生在 update_state()方法中。例如,这是一个简单的自定义指标,用于测量均方根误差(RMSE)。

图 7.18 使用 Metric 类的子类实现自定义指标

library(tensorflow)➊

metric_root_mean_squared_error <- new_metric_class( classname➋

= "RootMeanSquaredError",

initialize = function(name = "rmse", …) {➌

super$initialize(name = name, …)

self\(mse_sum <- self\)add_weight(name = "mse_sum",

initializer = "zeros",

dtype = "float32")

self\(total_samples <- self\)add_weight(name = "total_samples",

initializer = "zeros",

dtype = "int32")

},

update_state = function(y_true, y_pred, sample_weight = NULL) {➍

num_samples <- tf$shape(y_pred)[1]

num_features <- tf$shape(y_pred)[2]

y_true <- tf$one_hot➎(y_true, depth = num_features)➏

mse <- sum((y_true - y_pred) ^ 2)➐

self\(mse_sum\)assign_add(mse)

self\(total_samples\)assign_add(num_samples)

},

result = function() {

sqrt(self$mse_sum /

tf\(cast(self\)total_samples, "float32"))➑

},

reset_state = function() {

self\(mse_sum\)assign(0)

self\(total_samples\)assign(0L)

}

)

我们将使用 tf 模块函数。

定义一个新的类,它是 Metric 基类的子类。

在构造函数中定义状态变量。像层一样,你可以访问 add_weight()方法。

在 update_state()中实现状态更新逻辑。y_true 参数是一个 batch 的目标(或标签),而 y_pred 表示模型的相应预测。你可以忽略 sample_weight 参数——我们这里不会使用它。

记住,tf 模块函数使用基于 0 的计数惯例。y_true 中的值为 0 在 one-hot 向量的第一个位置上放置 1。

为了匹配我们的 MNIST 模型,我们期望分类预测和整数标签。

我们也可以将此写为 tf\(reduce_sum (tf\)square(tf$subtract(y_true, y_pred)))。

将 total_samples 强制转换为与 mse_sum 相匹配的 dtype。

注意,在 update_state() 中我们使用 tf\(shape(y_pred) 而不是 y_pred\)shape。tf\(shape() 返回一个 tf.Tensor 形式的形状,而不是像 y_pred\)shape 那样返回 tf.TensorShape。tf$shape() 允许 tf_function() 编译一个可以操作具有未定义形状的张量的函数,例如我们这里的输入具有未定义的批量维度。我们很快就会了解更多关于 tf_function() 的知识。

您可以使用 result() 方法返回指标的当前值:

result = function()

sqrt(self$mse_sum /

tf\(cast(self\)total_samples, "float32"))

同时,您还需要暴露一种方法来重置指标状态,而无需重新实例化它——这使得可以在不同的训练周期或在训练和评估期间使用相同的指标对象。您可以使用 reset_state() 方法来实现这一点:

reset_state = function() {

self\(mse_sum\)assign(0)

self\(total_samples\)assign(0L)➊

}

注意我们传递的是一个整数,因为 total_samples 具有整数类型。

自定义指标可以像内置指标一样使用。让我们试驾我们自己的指标:

model <- get_mnist_model()

model %>%

compile(optimizer = "rmsprop",

loss = "sparse_categorical_crossentropy",

metrics = list("accuracy", metric_root_mean_squared_error()))

model %>%

fit(train_images, train_labels,

epochs = 3,

validation_data = list(val_images, val_labels))

test_metrics <- model %>% evaluate(test_images, test_labels)

现在,您可以看到 fit() 进度条显示您模型的 RMSE。

使用回调函数

使用 fit() 方法对大型数据集进行数十个时期的训练运行有点像发射一架纸飞机:过了最初的冲动,您就无法控制其轨迹或着陆点。如果您想避免不良结果(从而浪费纸飞机),最明智的做法不是使用纸飞机,而是使用一个可以感知环境、将数据发送回其操作者并根据当前状态自动做出转向决策的无人机。Keras 的 回调 API 将帮助您将对 fit(model) 的调用从纸飞机变成一个聪明的、自主的无人机,它可以自我检查并根据当前状态动态采取行动。

回调是一个对象(实现特定方法的类实例),它在调用 fit() 方法时被传递给模型,并在训练过程中的各个时刻被模型调用。它可以访问模型及其性能的所有可用数据,并且可以采取行动:中断训练,保存模型,加载不同的权重集,或者以其他方式更改模型的状态。以下是您可以使用回调的一些示例:

  • 模型检查点—在训练过程中的不同时间点保存模型的当前状态。

  • 提前停止训练——当验证损失不再改善时中断训练(当然,同时保存训练过程中表现最佳的模型)。

  • 动态调整训练过程中特定参数的值——比如优化器的学习率。

  • 记录训练和验证指标,或者可视化模型学习到的表示的更新过程——fit() 的进度条实际上就是一个回调!

keras 包中包含了许多内置回调(这不是一个详尽列表):

callback_model_checkpoint()

callback_early_stopping()

callback_learning_rate_scheduler()

callback_reduce_lr_on_plateau()

callback_csv_logger()

让我们回顾一下其中的两个示例——callback_early_stopping() 和 callback_model_checkpoint(),以便了解它们的使用方法。

提前停止和模型检查点回调

在训练模型时,有很多事情一开始无法预测。特别是,你无法确定要达到最佳验证损失需要多少个 epoch。我们迄今为止的示例采用的策略是训练足够多的 epoch,直到开始出现过拟合,利用第一次运行来确定需要训练的适当 epoch 数,然后最终从头开始启动新的训练。当然,这种方法是浪费的。更好的处理方法是在测量到验证损失不再改善时停止训练。这可以使用 callback_early_stopping() 实现。

提前停止回调一旦自定监控的指标连续固定轮不再改善,就中断训练。例如,该回调可让你一旦开始出现过拟合,就中断训练,从而避免必须减少训练轮数重新训练模型。此回调通常与 callback_model_checkpoint() 结合使用,后者让你在训练期间不断保存模型(可选地,仅保存到目前为止表现最佳的模型,即在一个 epoch 结束时获得最佳性能的模型版本)。

示例 7.19 在 fit() 方法中使用 callbacks 参数

callbacks_list <- list(

callback_early_stopping(

monitor = "val_accuracy", patience = 2),➊

callback_model_checkpoint(➋

filepath = "checkpoint_path.keras",➌

monitor = "val_loss", save_best_only = TRUE)➍

)

model <- get_mnist_model()

model %>% compile(

optimizer = "rmsprop",

loss = "sparse_categorical_crossentropy",

metrics = "accuracy")➎

model %>% fit(

train_images, train_labels,

epochs = 10,

callbacks = callbacks_list,➏

validation_data = list(val_images, val_labels))➐

当验证准确率连续两轮停止改善时中断训练。

在每个 epoch 后保存当前的权重。

目标模型文件的保存路径

这两个参数意味着您只有在 val_loss 有所改善时才会重写模型文件,这样您就可以保留训练过程中见过的最佳模型。

您监控准确率,所以它应该是模型的度量值之一。

回调函数通过fit()中的 callbacks 参数传递给模型,该参数接受一个回调函数列表。您可以传递任意数量的回调函数。

请注意,由于回调函数会监控 val_loss 和 val_accuracy,您需要将 validation_data 传递给 fit()函数的调用。

注意,您也可以在训练后手动保存模型,只需调用 save_model_tf(model,'my_checkpoint_path')。要重新加载保存的模型,只需使用以下命令:

model <- load_model_tf("checkpoint_path.keras")

7.3.3 编写自己的回调函数

如果您需要在训练过程中执行某个特定的操作,而这个操作不包含在内置的回调函数中,您可以编写自己的回调函数。回调函数通过子类化 Keras Callback 类并使用 new_callback_class()来实现。然后,您可以实现以下任意数量的透明命名方法,在训练的不同阶段调用:

on_epoch_begin(epoch, logs)➊

on_epoch_end(epoch, logs)➋

on_batch_begin(batch, logs)➌

在每个 batch 结束时调用➍

on_train_begin(logs)➎

on_train_end(logs)➏

在每个 epoch 开始时调用

在每个 epoch 结束时调用

在处理每个 batch 之前调用

在处理每个 batch 之后调用

在训练开始时调用

在训练结束时调用

这些方法都带有 logs 参数,它是一个带有关于上一批次、批次或训练运行的信息的有命名的列表,包括训练和验证的指标等。on_epoch_和 on_batch_方法还将 epoch 或 batch 索引作为它们的第一个参数(一个整数)。

这是一个简单的示例,它在训练期间保存了每个 batch 的损失值列表,并在每个 epoch 结束时保存了这些值的图形。

清单 7.20:通过子类化 Callback 类创建自定义回调函数

callback_plot_per_batch_loss_history <- new_callback_class(

classname = "PlotPerBatchLossHistory",

initialize = function(file = "training_loss.pdf") {

private$outfile <- file

},

on_train_begin = function(logs = NULL) {

private$plots_dir <- tempfile()

dir.create(private$plots_dir)

private$per_batch_losses <-

fastmap::faststack(init = self\(params\)steps)

},

on_epoch_begin = function(epoch, logs = NULL) {

private\(per_batch_losses\)reset()

},

on_batch_end = function(batch, logs = NULL) {

private\(per_batch_losses\)push(logs$loss)

},

on_epoch_end = function(epoch, logs = NULL) {

losses <- as.numeric(private\(per_batch_losses\)as_list())

filename <- sprintf("epoch_%04i.pdf", epoch)

filepath <- file.path(private$plots_dir, filename)

pdf(filepath, width = 7, height = 5)

on.exit(dev.off())

plot(losses, type = "o",

ylim = c(0, max(losses)),

panel.first = grid(),

main = sprintf("每批次的训练损失\n(第 %i 个周期)", epoch),

xlab = "Batch", ylab = "Loss")

},

on_train_end = function(logs) {

private$per_batch_losses <- NULL

plots <- sort(list.files(private$plots_dir, full.names = TRUE))

qpdf::pdf_combine(plots, private$outfile)

unlink(private$plots_dir, recursive = TRUE)

}

)

使用 fastmap::faststack() 增长 R 对象

使用 c()<- 来增长 R 向量通常很慢,最好避免。在这个示例中,我们使用 fastmap::faststack() 来更有效地收集每个批次的损失。

在自定义类方法中的 privateself

在所有之前的例子中,我们使用 self 来跟踪实例属性,但在这个回调示例中,我们使用了 private。有什么区别呢?像 self$foo 这样的属性也可以直接从类实例 instance$foo 访问。然而,private 的属性只能从类方法内部访问。

另一个重要的区别是,Keras 自动将分配给 self 的所有内容转换为 Keras 本地格式。这有助于 Keras 自动查找例如与自定义层关联的所有 tf.Variables。然而,这种自动转换有时会对性能产生影响,甚至对某些类型的 R 对象(如 faststack())失败。另一方面,private 是一个纯 R 环境,Keras 不会对其进行任何更改。只有您编写的类方法才会直接与私有属性交互。

model <- get_mnist_model()

model %>% compile(optimizer = "rmsprop",

loss = "sparse_categorical_crossentropy",

metrics = "accuracy")

model %>% fit(train_images, train_labels,

epochs = 10,

callbacks = list(callback_plot_per_batch_loss_history()),

validation_data = list(val_images, val_labels))

我们得到的图像看起来像 [图 7.5。

![图片图 7.5 我们自定义的历史绘图回调的输出### 7.3.4 使用 TensorBoard 进行监控和可视化要进行良好的研究或开发良好的模型,您需要关于实验过程中模型内部情况的丰富而频繁的反馈。这就是进行实验的目的:尽可能多地获取有关模型性能的信息。取得进展是一个迭代过程,一个循环:您从一个想法开始,将其表达为一个实验,试图验证或无效化您的想法。您运行此实验并处理它生成的信息。这激发了您的下一个想法。您能够运行此循环的迭代次数越多,您的想法就会变得越精炼和强大。Keras 帮助您在尽可能短的时间内从想法到实验,快速的 GPU 可以帮助您尽快从实验到结果。但是处理实验结果呢?这就是 TensorBoard 的用武之地(见图 7.6)。图片

图 7.6 进展的循环

TensorBoard (www.tensorflow.org/tensorboard) 是一个可以在本地运行的基于浏览器的应用程序。这是在训练过程中监视模型内部发生的一切的最佳方式。通过 TensorBoard,您可以

  • 在训练过程中可视化监控指标

  • 可视化您的模型架构

  • 可视化激活和梯度的直方图

  • 探索 3 维嵌入

如果您要监视的信息不仅仅是模型的最终损失,您可以更清晰地了解模型的工作情况,并更快地取得进展。使用 Keras 模型和 fit() 方法与 TensorBoard 最简单的方法是使用 callback_tensorboard()。在最简单的情况下,只需指定回调应写入日志的位置,就可以开始了:

model <- get_mnist_model()

model %>% compile(optimizer = "rmsprop",

loss = "sparse_categorical_crossentropy", metrics = "accuracy")

model %>% fit(train_images, train_labels,

epochs = 10,

validation_data = list(val_images, val_labels),

callbacks = callback_tensorboard(log_dir = "logs/"))➊

日志目录的路径

一旦模型开始运行,它将在目标位置写入日志。然后,您可以通过调用 tensorboard() 查看日志;这将启动一个带有 tensorboard 运行的浏览器:

tensorboard(log_dir = "logs/")➊

启动带有 TensorBoard 的浏览器

在 TensorBoard 界面中,您将能够监视您的训练和评估指标的实时图表(参见图 7.7)。

图像

图 7.7 TensorBoard 可用于轻松监控训练和评估指标。

7.4 编写您自己的训练和评估循环

fit() 工作流在易用性和灵活性之间取得了良好的平衡。这是你大部分时间将要使用的内容。然而,它并不意味着支持深度学习研究人员可能想做的一切,即使是使用自定义指标、自定义损失和自定义回调。

毕竟,内置的 fit() 工作流仅专注于 监督学习:一种设置,其中已知 目标(也称为 标签注释)与您的输入数据相关联,并且您计算您的损失作为这些目标和模型预测的函数。然而,并非所有形式的机器学习都属于此类别。还有其他设置,其中没有明确的目标存在,例如 生成式学习(我们将在第十二章中讨论)、自监督学习(其中目标从输入中获取)和 强化学习(学习受偶尔“奖励”驱动,就像训练一只狗一样)。即使您正在进行常规的监督学习,作为研究人员,您可能也希望添加一些需要低级别灵活性的新颖功能。

每当您发现内置的 fit() 不足以应对某种情况时,您将需要编写自己的自定义训练逻辑。在第二章和第三章中,您已经看到了低级别训练循环的简单示例。作为提醒,典型训练循环的内容如下:

  1. 1 在梯度磁带中运行前向传播(计算模型的输出)以获取当前数据批次的损失值。

  2. 2 获取损失相对于模型权重的梯度。

  3. 3 更新模型的权重以降低当前数据批次的损失值。

这些步骤将根据需要重复执行多个批次。这本质上是 fit() 在内部执行的操作。在本节中,您将学习如何从头开始重新实现 fit(),这将为您提供编写任何可能出现的训练算法所需的所有知识。让我们详细看一下。

7.4.1 训练与推断

到目前为止,您已经看到了低级别训练循环示例,其中步骤 1(前向传播)通过 predictions <- model(inputs) 完成,步骤 2(获取梯度磁带计算的梯度)通过 gradients <- tape\(gradient(loss, model\)weights) 完成。在一般情况下,实际上有两个您需要考虑的细微之处。

某些 Keras 层,如 layer_dropout(),在训练推断(用于生成预测时)期间有不同的行为。这些层在其 call() 方法中公开了一个 training 布尔参数。调用 dropout(inputs, training = TRUE) 将会丢弃一些激活项,而调用 dropout(inputs, training = FALSE) 则不会做任何操作。顺延而言,Functional 和 Sequential 模型也在它们的 call() 方法中公开了这个 training 参数。记得在前向传播时传递 training = TRUE!因此,我们的前向传播变成了 predictions <- model(inputs, training = TRUE)。

另外,请注意,当您检索模型权重的梯度时,您不应该使用 tape\(gradients(loss, model\)weights),而应该使用 tape\(gradients(loss, model\)trainable_weights)。实际上,层和模型拥有两种权重:

  • 可训练的权重 —— 这些权重应通过反向传播来最小化模型的损失,例如 Dense 层的核和偏置。

  • 不可训练的权重 —— 这些权重应由拥有它们的层在前向传播中更新。例如,如果您想要一个自定义层来保存到目前为止已处理了多少批次的计数器信息,那么该信息将存储在不可训练的权重中,并且在每个批次中,您的层将计数器递增一次。

在 Keras 内置层中,唯一具有不可训练权重的层是 layer_ batch_normalization(),我们将在第九章中讨论。批量归一化层需要不可训练权重来跟踪通过它的数据的平均值和标准差的信息,以便执行特征归一化的在线近似(这是您在第六章学到的概念)。考虑到这两个细节,监督学习的训练步骤最终看起来像这样:

library(tensorflow)

train_step <- function(inputs, targets) {

with(tf$GradientTape() %as% tape, {

predictions <- model(inputs, training = TRUE)

loss <- loss_fn(targets, predictions)

})

梯度 <- tape\(gradients(loss, model\)trainable_weights)

optimizer\(apply_gradients(zip_lists(gradients, model\)trainable_weights))

}

我们在第二章介绍了 zip_lists()。

7.4.2 度量的低级用法

在低级训练循环中,您可能希望利用 Keras 度量(无论是自定义的还是内置的)。您已经了解了度量 API:只需为每个目标和预测的批次调用 update_state(y_true, y_pred),然后使用 result()来查询当前度量值:

metric <- metric_sparse_categorical_accuracy()

targets <- c(0, 1, 2)

predictions <- rbind(c(1, 0, 0),

c(0, 1, 0),

c(0, 0, 1))

metric$update_state(targets, predictions)

current_result <- metric$result()

sprintf("result: %.2f", as.array(current_result))➊

[1] "result: 1.00"

as.array()将 Tensor 转换为 R 值

您可能还需要跟踪标量值的平均值,例如模型的损失。您可以通过 metric_mean()来实现:

values <- c(0, 1, 2, 3, 4)

mean_tracker <- metric_mean()

for (value in values)

mean_tracker$update_state(value)

sprintf("Mean of values: %.2f", as.array(mean_tracker$result()))

[1] "Mean of values: 2.00"

在想要重置当前结果时,请记得使用 metric$reset_state()(在训练时期的开始或评估的开始时)。

7.4.3 完整的训练和评估循环

让我们将前向传播、反向传播和指标跟踪结合到一个类似 fit()的训练步骤函数中,该函数接受一批数据和目标,并返回 fit()进度条将显示的日志。

清单 7.21 编写逐步训练循环:训练步骤函数

model <- get_mnist_model()

loss_fn <- loss_sparse_categorical_crossentropy()➊

optimizer <- optimizer_rmsprop()➋

metrics <- list(metric_sparse_categorical_accuracy())➌

loss_tracking_metric <- metric_mean()➍

train_step <- function(inputs, targets) {

with(tf$GradientTape() %as% tape, {

predictions <- model(inputs, training = TRUE)➎

loss <- loss_fn(targets, predictions)

})

gradients <- tape$gradient(loss,➏

model$trainable_weights)➏

optimizer$apply_gradients(zip_lists(gradients,

model$trainable_weights))

logs <- list()

for (metric in metrics) {➐

metric$update_state(targets, predictions)

logs[[metric\(name]] <- metric\)result()

}

loss_tracking_metric$update_state(loss)➑

logs\(loss <- loss_tracking_metric\)result()

日志➒

}

准备损失函数。

准备优化器。

准备要监视的指标列表。

准备一个 metric_mean()跟踪器来记录平均损失。

运行前向传播。请注意,我们传递 training = TRUE。

运行反向传播。请注意,我们使用 model$trainable_weights。

跟踪指标。

跟踪损失平均值。

返回指标和损失的当前值。

我们需要在每个迭代开始时和运行评估之前重置指标的状态。下面是一个实用函数来完成这项工作。

清单 7.22 逐步编写训练循环:重置指标

reset_metrics <- function() {

for (指标 in 指标)

metric$reset_state()

loss_tracking_metric$reset_state()

}

现在我们可以布置完整的训练循环了。请注意,我们使用来自 tfdatasets 包的 TensorFlow 数据集对象,将我们的 R 数组数据转换为以大小为 32 的批次迭代的迭代器。机制与我们在第二章实施的数据集迭代器是相同的,只是现在的名称不同了。我们用 tensor_slices_dataset()从 R 数组构建 TensorFlow 数据集实例,用 as_iterator()将其转换为迭代器,然后重复调用 iter_next()来获取下一个批次。在第二章看到的不同之一是,iter_next()返回的是 Tensor 对象,而不是 R 数组。我们将在第八章更详细地介绍 tfdatasets。

清单 7.23 逐步编写训练循环:循环本身

library(tfdatasets)

training_dataset <-

list(inputs = train_images, targets = train_labels) %>%

tensor_slices_dataset() %>%

dataset_batch(32)

迭代周期 <- 3

for (迭代周期 in seq(epochs)) {

reset_metrics()

training_dataset_iterator <- as_iterator(training_dataset)

重复 {

batch <- iter_next(training_dataset_iterator)

if (is.null(batch))➊

中断

logs <- train_step(batch\(inputs, batch\)targets)

}

writeLines(c(

sprintf("第 %s 次迭代结束时的结果", 迭代周期),

sprintf("…%s: %.4f", names(logs), sapply(logs, as.numeric))

))

}

第 1 次迭代结束时的结果

…稀疏分类准确率:0.9156

…损失:0.2687

第 2 次迭代结束时的结果

…稀疏分类准确率:0.9539

…损失:0.1659

第 3 次迭代结束时的结果

…稀疏分类准确率:0.9630

…损失:0.1371

迭代器已耗尽

这是评估循环:一个简单的 for 循环,它不断调用 test_step()函数,该函数处理单个数据批次。test_step()函数只是 train_step()逻辑的一个子集。它省略了处理更新模型权重的代码——也就是说,所有涉及 GradientTape()和优化器的代码。

清单 7.24 逐步编写评估循环

test_step <- function(inputs, targets) {

predictions <- model(inputs, training = FALSE)➊

loss <- loss_fn(targets, predictions)

logs <- list()

for (metric in metrics) {

metric$update_state(targets, predictions)

logs[[paste0("val_", metric\(name)]] <- metric\)result()

}

loss_tracking_metric$update_state(loss)

logs[["val_loss"]] <- loss_tracking_metric$result()

logs

}

val_dataset <- list(val_images, val_labels) %>%

tensor_slices_dataset() %>%

dataset_batch(32)

reset_metrics()

val_dataset_iterator <- as_iterator(val_dataset)

repeat {

batch <- iter_next(val_dataset_iterator)

if(is.null(batch)) break➋

c(inputs_batch, targets_batch) %<-% batch

logs <- test_step(inputs_batch, targets_batch)

}

writeLines(c(

"评估结果:",

sprintf("…%s: %.4f", names(logs), sapply(logs, as.numeric))

))

评估结果:

…val_sparse_categorical_accuracy: 0.9461

…val_loss: 0.1871

请注意我们将 training 参数设置为 FALSE。

一旦数据集批处理迭代器耗尽,iter_next() 将返回 NULL。

恭喜你——你刚刚重新实现了 fit() 和 evaluate()!或者几乎实现了:fit() 和 evaluate() 支持更多功能,包括大规模分布式计算,这需要更多的工作。它还包括几个关键的性能优化。让我们看看其中一个优化:TensorFlow 函数编译。

7.4.4 使用 tf_function() 提高性能

你可能已经注意到,尽管实现了基本相同的逻辑,但自定义循环的运行速度明显比内置的 fit() 和 evaluate() 慢得多。这是因为,默认情况下,TensorFlow 代码是逐行执行的,即时执行,就像使用 R 数组的常规 R 代码一样。即时执行使得调试代码更容易,但从性能的角度来看远非最佳。

将你的 TensorFlow 代码编译成一个可以进行全局优化的 计算图 比逐行解释代码更高效。要做到这一点的语法非常简单:只需在执行之前对你想要编译的任何函数调用 tf_function(),就像下面的示例中所示。

示例 7.25 使用 tf_function() 与我们的评估步骤函数

tf_test_step <- tf_function(test_step)➊

val_dataset_iterator <- as_iterator(val_dataset)➋

reset_metrics()

while(!is.null(iter_next(val_dataset_iterator) -> batch)) {

c(inputs_batch, targets_batch) %<-% batch

logs <- tf_test_step(inputs_batch, targets_batch)➌

}

writeLines(c(

"评估结果:",

sprintf("…%s: %.4f", names(logs), sapply(logs, as.numeric))

))

评估结果:

…val_sparse_categorical_accuracy: 0.5190

…val_loss: 1.6764

将我们之前定义的 test_step 传递给 tf_function()。

重用前一个示例中定义的相同 TF 数据集,但创建一个新的迭代器。

这次使用编译后的测试步骤函数。

在我的机器上,我们的评估循环运行时间从 2.4 秒缩短到只有 0.6 秒。快得多!

当 TF 数据集迭代循环也被编译为图操作时,速度提升甚至更大。你可以像这样使用 tf_function() 编译整个评估循环:

my_evaluate <- tf_function(function(model, dataset) {

reset_metrics()

for (batch in dataset) {

c(inputs_batch, targets_batch) %<-% batch

logs <- test_step(inputs_batch, targets_batch)

}

logs

})

system.time(my_evaluate(model, val_dataset))["elapsed"]

elapsed

0.283

这进一步缩短了评估时间!

请记住,当您调试代码时,最好不要调用 tf_function(),而是急切地运行它。这样更容易跟踪错误。一旦您的代码可行并且想要使其快速,就可以向您的训练步骤和评估步骤或任何其他性能关键的函数添加 tf_function()修饰符。

7.4.5 利用 fit()进行自定义训练循环

在之前的章节中,我们完全从头编写了自己的训练循环。这样做为您提供了最大的灵活性,但同时您需要编写大量的代码,同时错过了许多方便的 fit()功能,例如回调或分布式训练的内置支持。

如果你需要自定义训练算法,但仍想利用内置的 Keras 训练逻辑的优势,那么在 fit()和从头编写训练循环之间实际上有一种中间状态:你可以提供自定义训练步骤功能,让框架完成其他工作。

您可以通过覆盖 Model 类的 train_step()方法来实现这一点。这是由 fit()用于每个数据批次调用的函数。然后您将能够像通常一样调用 fit(),它将在底层运行您自己的学习算法。下面是一个简单的例子:

  • 我们通过调用 new_model_class()来创建一个子类 Model 的新类。

  • 我们覆盖 train_step(data)方法。它的内容几乎与我们在前一节中使用的内容相同。它返回将指标名称(包括损失)映射到其当前值的命名列表。

  • 我们实现了一个跟踪模型的 Metric 实例的 metrics 活动属性(active property)。这使得模型能够在每个纪元(epoch)和在调用 evaluate()时自动调用模型的 metrics 的 reset_state(),因此不必手动执行。

loss_fn <- loss_sparse_categorical_crossentropy()

loss_tracker <- metric_mean(name = "loss")➊

CustomModel <- new_model_class(

classname = "CustomModel",

train_step = function(data) {➋

c(inputs, targets) %<-% data

with(tf$GradientTape() %as% tape, {

predictions <- self(inputs, training = TRUE)➌

loss <- loss_fn(targets, predictions)

})

gradients <- tape\(gradient(loss, model\)trainable_weights)

optimizer\(apply_gradients(zip_lists(gradients, model\)trainable_weights))

loss_tracker$update_state(loss)➍

list(loss = loss_tracker$result())➎

},

metrics = mark_active(function() list(loss_tracker))➏

)

该度量对象将用于跟踪训练和评估期间每个批次损失的平均值。

我们覆盖 train_step 方法。

我们使用 self(inputs, training = TRUE)而不是 model(inputs, training = TRUE),因为我们的模型是类本身。

我们更新损失跟踪器指标,该指标跟踪损失的平均值。

通过查询损失跟踪器指标返回到目前为止的平均损失。

您想在跨时期重置的任何指标都应在此处列出。

现在,我们可以实例化我们的自定义模型,编译它(我们仅传递优化器,因为损失已在模型外部定义),然后像往常一样使用 fit() 进行训练:

inputs <- layer_input(shape = c(28 * 28))

features <- inputs %>%

layer_dense(512, activation = "relu") %>%

layer_dropout(0.5)

outputs <- features %>%

layer_dense(10, activation = "softmax")

model <- CustomModel(inputs = inputs, outputs = outputs)➊

model %>% compile(optimizer = optimizer_rmsprop())

model %>% fit(train_images, train_labels, epochs = 3)

因为我们没有提供 initialize() 方法,所以使用与 keras_model() 相同的签名:inputs、outputs 和可选的 name。

有几个要点需要注意:

  • 这种模式不会阻止您使用功能 API 构建模型。 无论您是构建序贯模型、功能 API 模型还是子类模型,都可以这样做。

  • 当您重写 train_step 时,您不需要调用 tf_function() ——框架会为您执行此操作。

那么,指标呢? 以及如何通过 compile() 配置损失? 在调用 compile() 之后,您可以访问以下内容:

  • self$compiled_loss—您传递给 compile() 的损失函数。

  • self\(compiled_metrics—一个包装器,用于您传递的指标列表,它允许您调用 self\)compiled_metrics$update_state() 一次更新所有指标。

  • self$metrics—您传递给 compile() 的实际指标列表。 请注意,它还包括一个跟踪损失的指标,类似于我们之前手动进行的 loss_tracking_metric。

因此,我们可以这样写:

CustomModel <- new_model_class(

classname = "CustomModel",

train_step = function(data) {

c(inputs, targets) %<-% data

with(tf$GradientTape() %as% tape, {

预测 <- self(inputs, training = TRUE)

loss <- self$compiled_loss(targets, predictions)➊

})

梯度 <- tape\(gradient(loss, model\)trainable_weights)

optimizer\(apply_gradients(zip_lists(gradients, model\)trainable_weights))

self\(compiled_metrics\)update_state(

targets, predictions)➋

results <- list()

for(metric in self$metrics)

results[[metric\(name]] <- metric\)result()

results➌

}

)

通过 self$compiled_loss 计算损失。

通过 self$compiled_metrics 更新模型的指标。

返回一个命名列表,将指标名称映射到其当前值。

让我们试一试:

inputs <- layer_input(shape = c(28 * 28))

特征 <- inputs %>%

layer_dense(512, activation = "relu") %>%

layer_dropout(0.5)

outputs <- features %>% layer_dense(10, activation = "softmax")

model <- CustomModel(inputs = inputs, outputs = outputs)

model %>% compile(optimizer = optimizer_rmsprop(),

loss = loss_sparse_categorical_crossentropy(),

metrics = metric_sparse_categorical_accuracy())

model %>% fit(train_images, train_labels, epochs = 3)

那是大量的信息,但现在你已经了解足够的内容,可以使用 Keras 几乎做任何事情。

总结

  • Keras 提供了一系列不同的工作流程,基于逐步透露复杂性的原则。它们都能够平稳地协同运作。

  • 你可以通过 Sequential API 的 keras_model_sequential(),通过 Functional API 的 keras_model(),或者通过子类化 Model 类的 new_model_class()来构建模型。大多数情况下,你将使用 Functional API。

  • 训练和评估模型的最简单方法是通过默认的 fit()和 evaluate()方法。

  • Keras 回调提供了一种简单的方法,在调用 fit()期间监视模型并根据模型状态自动采取行动。

  • 你还可以通过覆盖 train_ step()方法完全控制 fit()的行为。

  • 除了 fit()之外,你还可以完全从头开始编写自己的训练循环。这对于实现全新训练算法的研究人员非常有用。

第八章:计算机视觉的深度学习介绍

本章内容包括

  • 理解卷积神经网络(卷积网络)

  • 使用数据增强来减轻过拟合

  • 使用预训练的卷积网络进行特征提取

  • 对预训练的卷积网络进行微调

计算机视觉是深度学习的最早和最大的成功故事。每天,您都在通过 Google 照片、Google 图像搜索、YouTube、相机应用中的视频滤镜、OCR 软件等与深度视觉模型进行交互。这些模型还是自动驾驶、机器人技术、AI 辅助医学诊断、自动零售结账系统甚至自动农业等尖端研究的核心。

计算机视觉是在 2011 年至 2015 年间深度学习初期崛起的问题领域。一种称为卷积神经网络的深度学习模型开始在那个时候在图像分类竞赛中取得了非常好的成绩,首先是丹·西雷赛安(Dan Ciresan)在两个小众竞赛中获胜(ICDAR 2011 年中文字符识别竞赛和 IJCNN 2011 年德国交通标志识别竞赛),然后在 2012 年秋季更为显著,辛顿(Hinton)的团队赢得了备受瞩目的 ImageNet 大规模视觉识别挑战赛。很快,许多更有前景的成果开始涌现在其他计算机视觉任务中。

有趣的是,这些早期的成功并没有让深度学习在当时成为主流——这需要几年的时间。计算机视觉研究界花了很多年投资于除了神经网络之外的方法,它们并不完全准备放弃它们,只因为有了新的玩家。在 2013 年和 2014 年,深度学习仍然面临着来自许多资深计算机视觉研究人员的激烈质疑。直到 2016 年,它才最终占据主导地位。我(弗朗索瓦)还记得在 2014 年 2 月劝告我的一位前教授转向深度学习。“这是下一个大事!”我会说。“嗯,也许它只是一时的热门话题,”他回答道。到了 2016 年,他的整个实验室都在进行深度学习。一个时代到来的理念是无法阻挡的。

本章介绍了卷积神经网络,也称为卷积网络,这种类型的深度学习模型现在几乎在计算机视觉应用中被普遍使用。您将学习将卷积网络应用于图像分类问题,特别是那些涉及小训练数据集的问题,如果您不是一家大型技术公司,则这是最常见的用例。

8.1 卷积网络简介

我们即将深入探讨卷积网络是什么以及为什么它们在计算机视觉任务中取得如此成功的理论。但首先,让我们以一个简单的卷积网络示例来实际了解一下,该示例对 MNIST 数字进行分类,这是我们在第二章中使用全连接网络执行的任务(当时我们的测试准确率为 97.8%)。尽管卷积网络将是基本的,但其准确性将远远超出我们第二章中的全连接模型。

以下列表显示了一个基本卷积神经网络的外观。它是一堆layer_conv_2d()layer_max_pooling_2d()层。你很快就会明白它们的作用。我们将使用我们在上一章介绍的 Functional API 来构建模型。

列表 8.1 实例化一个小型卷积神经网络

inputs <— layer_input(shape = c(28, 28, 1))

outputs <— inputs %>%

layer_conv_2d(filters = 32, kernel_size = 3, activation = "relu") %>%

layer_max_pooling_2d(pool_size = 2) %>%

layer_conv_2d(filters = 64, kernel_size = 3, activation = "relu") %>%

layer_max_pooling_2d(pool_size = 2) %>%

layer_conv_2d(filters = 128, kernel_size = 3, activation = "relu") %>%

layer_flatten() %>% layer_dense(10, activation = "softmax")

model <— keras_model(inputs, outputs)

重要的是,卷积神经网络的输入是形状为(图像高度、图像宽度、图像通道数)的张量,不包括批处理维度。在这种情况下,我们将配置卷积神经网络以处理大小为(28、28、1)的输入,这是 MNIST 图像的格式。

让我们显示我们卷积神经网络的结构。

列表 8.2 显示模型摘要

model

图像

你可以看到每个 Conv2D 和 MaxPooling2D 层的输出都是形状为(高度,宽度,通道数)的秩为 3 的张量。随着模型的加深,宽度和高度维度会变小。通道的数量由传递给layer_conv_2d()层的第一个参数(32、64 或 128)来控制。

在最后一个 Conv2D 层之后,我们得到形状为(3、3、128)的输出,即一个 3 × 3 的具有 128 个通道的特征图。下一步是将此输出馈送到一个密集连接的分类器中,就像您已经熟悉的那样:一堆 Dense 层。这些分类器处理向量,这是 1D 的,而当前的输出是一个秩为 3 的张量。为了弥合差距,我们使用 Flatten 层将 3D 输出展平为 1D,然后再添加 Dense 层。最后,我们进行 10 分类,因此我们的最后一层有 10 个输出和 softmax 激活。

现在,让我们在 MNIST 数字上训练卷积神经网络。我们将重用第二章中 MNIST 示例中的大量代码。因为我们要进行 10 分类,并带有 softmax 输出,所以我们将使用分类交叉熵损失,因为我们的标签是整数,所以我们将使用稀疏版本的稀疏分类交叉熵。

列表 8.3 在 MNIST 图像上训练卷积神经网络

c(c(train_images, train_labels), c(test_images, test_labels)) %<—%

dataset_mnist()

train_images <— array_reshape(train_images, c(60000, 28, 28, 1)) / 255

test_images <— array_reshape(test_images, c(10000, 28, 28, 1)) / 255

model %>% compile(optimizer = "rmsprop",

loss = "sparse_categorical_crossentropy"

metrics = c("accuracy"))

model %>% fit(train_images, train_labels, epochs = 5, batch_size = 64)

让我们在测试数据上评估模型。

列表 8.4 评估卷积神经网络

result <— evaluate(model, test_images, test_labels)

cat("测试准确率:", result['accuracy'], "\n")

测试准确率:0.9915

而第二章中的密集连接模型的测试准确率为 97.8%,基本卷积神经网络的测试准确率为 99.1%:我们将错误率减少了约 60%(相对)。不错!

为什么这个简单的卷积神经网络效果这么好,相比之下,与密集连接模型相比如此?为了回答这个问题,让我们深入了解一下 Conv2D 和 MaxPooling2D 层的作用。

8.1.1 卷积操作

密集连接层和卷积层之间的根本区别在于:密集层学习其输入特征空间中的全局模式(例如,对于 MNIST 数字,涉及所有像素的模式),而卷积层学习局部模式——在图像的情况下,输入的小 2D 窗口中发现的模式(参见图 8.1)。在先前的示例中,这些窗口都是 3×3。

图像

图 8.1 图像可以被分解为边缘、纹理等局部模式。

这一关键特性赋予了卷积神经网络两个有趣的特性:

  • 它们学习的模式是平移不变的——在图片的右下角学习了某种模式后,卷积神经网络可以在任何地方识别它——例如,在左上角。密集连接模型如果出现在新位置,就必须重新学习该模式。这使得处理图像时,卷积神经网络在数据效率上更具优势(因为视觉世界在本质上是平移不变的):它们需要更少的训练样本来学习具有泛化能力的表示。

  • 它们可以学习空间模式的层次结构——第一个卷积层将学习小的局部模式,例如边缘,第二个卷积层将学习由第一层特征组成的较大模式,依此类推(参见图 8.2)。这使得卷积神经网络能够高效地学习越来越复杂和抽象的视觉概念,因为视觉世界在本质上是空间层次结构

图像

图 8.2 视觉世界形成了视觉模块的空间层次结构:基本线条或纹理结合成简单对象,如眼睛或耳朵,它们又结合成“猫”等高级概念。

卷积操作在称为特征图的三阶张量上进行,具有两个空间轴(高度宽度)以及一个深度轴(也称为通道轴)。对于 RGB 图像,深度轴的维数为 3,因为图像具有三个颜色通道:红色、绿色和蓝色。对于黑白图片,如 MNIST 数字,深度为 1(灰度级)。卷积操作从其输入特征图中提取补丁,并对所有这些补丁应用相同的变换,生成一个输出特征图。此输出特征图仍然是一个三阶张量:它具有宽度和高度。它的深度可以是任意的,因为输出深度是层的一个参数,该深度轴中的不同通道不再代表 RGB 输入中的特定颜色;相反,它们代表滤波器。滤波器编码输入数据的特定方面:在高层次上,单个滤波器可以编码“输入中存在面部”的概念,例如。

在 MNIST 示例中,第一卷积层接受尺寸为 (28, 28, 1) 的特征图,并输出尺寸为 (26, 26, 32) 的特征图:它在其输入上计算 32 个滤波器。这 32 个输出通道中的每一个都包含一个 26 × 26 的值网格,这是该滤波器在输入上的响应图,指示了该滤波器模式在输入的不同位置的响应(参见图 8.3)。

图像

图 8.3 响应图的概念:输入中不同位置的模式的 2D 地图

这就是术语特征图的含义:深度轴中的每个维度都是一个特征(或滤波器),而二阶张量输出[, , n] 是此滤波器在输入上的 2D 空间响应图

卷积由两个关键参数定义:

  • 从输入中提取的补丁的大小 —— 这些通常是 3 × 3 或 5 × 5。在示例中,它们是 3 × 3,这是一个常见的选择。

  • 输出特征图的深度 —— 这是由卷积计算的滤波器数量。示例从深度为 32 开始,最终达到了深度为 64。

在 layer_conv_2d() 中,这些参数是传递给层的第一个参数(与输入组合的参数之后):inputs %>% layer_conv_2d(output_depth, c(window_ height, window_width))。

卷积的工作原理是通过滑动这些大小为 3 × 3 或 5 × 5 的窗口,遍历 3D 输入特征图,停留在每个可能的位置,并提取周围特征的 3D 补丁(形状为 (window_height, window_width, input_depth))。然后将每个这样的 3D 补丁转化为形状为 (output_depth) 的 1D 向量,通过与一个称为卷积核的学习权重矩阵进行张量积操作——同一个卷积核会在每个补丁中重复使用。所有这些向量(每个补丁一个)然后会在空间上重新组合成一个形状为 (height, width, output_depth) 的 3D 输出图。在输出特征图的每个空间位置都对应于输入特征图的相同位置(例如,输出的右下角包含了输入的右下角的信息)。例如,使用 3 × 3 窗口,向量 output[i, j, ] 来自于 3D 补丁 input[(i-1):(i+1), (j-1):(j+1), ]。完整的过程详见图 8.4。

图像

图 8.4 卷积的工作原理。

请注意,输出的宽度和高度可能与输入的宽度和高度不同,原因有两个:

  • 边界效应可以通过对输入特征图进行填充来抵消。

  • 我将在一秒钟内定义步幅的使用。

让我们更深入地了解这些概念。

理解边界效应和填充

考虑一个 5 × 5 的特征图(总共 25 个格子)。你只能在其中 9 个格子周围居中 3 × 3 窗口,形成一个 3 × 3 的网格(见 图 8.5)。因此,输出特征图将是 3 × 3。它会略微缩小:在这种情况下,每个维度正好缩小两个格子。在早期的例子中,你可以看到这种边界效应:开始时输入是 28 × 28,在第一卷积层之后变成了 26 × 26。

如果您想获得与输入相同空间维度的输出特征图,您可以使用填充。填充包括在输入特征图的每一侧添加适当数量的行和列,以便在每个输入格子周围可以放置中心卷积窗口。对于 3 × 3 窗口,您需要在右边添加一列,左边添加一列,顶部添加一行,底部添加一行。对于 5 × 5 窗口,您需要添加两行(详见 图 8.6)。

图像

图 8.5 在 5 × 5 输入特征图中 3 × 3 补丁的有效位置

图像

图 8.6 对 5 × 5 输入进行填充,以能够提取 25 个 3 × 3 补丁

在 layer_conv_2d() 中,填充可通过 padding 参数进行配置,padding 参数可以取两个值:“valid”,表示不进行填充(只使用有效的窗口位置),以及“same”,表示“以这样一种方式填充,使得输出的宽度和高度与输入相同。” padding 参数的默认值是“valid”。

理解卷积步幅

可以影响输出大小的另一个因素是步幅的概念。到目前为止,我们对卷积的描述假定卷积窗口的中心瓦片都是连续的。但是,两个连续窗口之间的距离是卷积的一个参数,称为其步幅,默认为 1。可以进行步进卷积:步幅大于 1 的卷积。在图 8.7 中,您可以看到在没有填充的情况下,3×3 卷积以步幅 2 在 5×5 输入上提取的补丁。

使用步长 2 意味着特征映射的宽度和高度被下采样了 2 倍(除了由边界效应引起的任何变化)。步进卷积在分类模型中很少使用,但对于某些类型的模型非常方便,你将在下一章中看到。

在分类模型中,我们倾向于使用最大池化操作来对特征映射进行下采样,你在我们的第一个卷积神经网络示例中看到了它的作用。让我们更深入地研究一下。

图片

图 8.7 3 × 3 卷积补丁,步长为 2 × 2

8.1.2 最大池化操作

在卷积神经网络示例中,您可能已经注意到,在每个 layer_max_pooling_2d()之后,特征映射的大小都会减半。例如,在第一个 layer_max_pooling_2d()之前,特征映射为 26×26,但最大池化操作将其减半为 13×13。这就是最大池化的作用:对特征映射进行积极地下采样,就像步进卷积一样。

最大池化包括从输入特征映射中提取窗口,并输出每个通道的最大值。概念上类似于卷积,不同之处在于,不是通过学习的线性转换(卷积核)来转换局部补丁,而是通过硬编码的最大张量操作来转换它们。与卷积的一个重大区别是,最大池化通常使用 2×2 的窗口和步长 2 来进行,以将特征映射下采样 2 倍。另一方面,卷积通常使用 3×3 的窗口和无步长(步长为 1)进行。

为什么要以这种方式下采样特征映射?为什么不删除最大池化层,并一直保持相当大的特征映射?让我们看看这个选择。我们的模型将如下列表所示。

列表 8.5 列表 8.5 缺少最大池化层的结构不正确的卷积神经网络

inputs <— layer_input(shape = c(28, 28, 1))

outputs <— inputs %>%

layer_conv_2d(filters = 32, kernel_size = 3, activation = "relu") %>%

layer_conv_2d(filters = 64, kernel_size = 3, activation = "relu") %>%

layer_conv_2d(filters = 128, kernel_size = 3, activation = "relu") %>%

layer_flatten() %>%

layer_dense(10, activation = "softmax")

model_no_max_pool <— keras_model(inputs = inputs, outputs = outputs)

这是模型的摘要:

model_no_max_pool

图片

这个设置有什么问题?有两个问题:

  • 它不利于学习空间特征的层次结构。第三层中的 3×3 窗口仅包含来自最初输入的 7×7 窗口的信息。卷积网络学习的高级模式与初始输入相比仍然非常小,这可能不足以学习分类数字(尝试通过仅使用 7×7 像素的窗口查看数字来识别数字!)。我们需要来自前一个卷积层的特征包含关于整个输入的信息。

  • 最终的特征映射每个样本有 22×22×128=61,952 个系数

简而言之,使用下采样的原因是减少要处理的特征图系数的数量,并通过使连续的卷积层查看越来越大的窗口(就涵盖原始输入的部分而言)来引导空间滤波器层次结构。

请注意,最大池化并不是您可以实现这种下采样的唯一方法。正如您已经知道的那样,您还可以在之前的卷积层中使用步长。您还可以使用平均池化,而不是最大池化,其中每个本地输入补丁通过在补丁上每个通道的平均值进行变换,而不是最大值。但是,最大池化比这些替代解决方案更有效。原因是特征倾向于在特征地图的不同瓷砖上编码某些模式或概念的空间存在(因此是特征图一词),查看不同特征的最大存在而不是平均存在更具信息性。最合理的子采样策略是首先通过未简化的卷积产生密集的特征映射,然后查看特征在小补丁上的最大激活,而不是查看输入的更稀疏的窗口(通过分步卷积)或平均输入补丁,这可能导致您错过或稀释特征存在信息。

现在,您应该了解卷积神经网络的基础知识-特征图、卷积和最大池化-并且应该知道如何构建一个小型卷积神经网络来解决玩具问题,如 MNIST 数字分类。现在让我们继续探讨更有用,实用的应用。

8.2 从头开始在小数据集上训练 convnet

使用很少的数据来训练图像分类模型是一种常见情况,在实践中,如果您在专业上进行计算机视觉,可能会遇到这种情况。“一些”样本可以从几百个到几万个图像不等。作为实际示例,我们将专注于将图像分类为猫或狗的元素,该数据集包含 5,000 张猫和狗的图片(2,500 只猫,2,500 只狗)。我们将使用 2,000 张图片进行训练,1,000 张用于验证,2,000 张用于测试。

在本节中,我们将回顾一种解决这个问题的基本策略:使用很少的数据从头开始训练一个新模型。我们将首先使用没有正则化的小型卷积神经网络对 2000 个训练样本进行简单训练,以建立一个基线模型来评估可达到的效果。这将使我们的分类准确性达到大约 70%。此时,主要问题在于过拟合。然后,我们将介绍数据增强——一种处理计算机视觉中的过拟合的强大技术。通过使用数据增强,我们将改善模型,使其准确率提高到 80-85%。

在下一节中,我们将回顾应用深度学习于小数据集的另外两种关键技术:使用预训练模型进行特征提取(将准确率提高到 97.5%)和微调预训练模型(将准确率提高到最终的 98.5%)。这三种战略——从头开始训练小模型、使用预训练模型进行特征提取,以及微调预训练模型——将为您应对小数据集的图像分类问题提供工具箱。

8.2.1 深度学习在小数据问题中的相关性。

训练模型所需的“足够样本”是相对的——首先相对于你要训练的模型的规模和深度。使用仅有几十个样本训练卷积神经网络以解决复杂问题是不可能的,但是对于小型、规范良好的模型和简单任务,数百个样本可能足够了。因为卷积神经网络学习局部、平移不变的特征,它们在感知性问题上非常数据高效。即使在非常小的图像数据集上从头开始训练卷积神经网络,也可以产生合理的结果,而不需要进行任何自定义的特征工程。你将在本节中看到这一点的实践演示。

此外,深度学习模型具有高度重用性的本质特点:你可以使用一种基于大规模数据集训练的图像分类或语音转文本模型,并在只做出微小修改的情况下将其应用于完全不同的问题。尤其是在计算机视觉领域,很多预训练模型(通常在 ImageNet 数据集上训练)现在已经公开提供下载,可以使用非常少量的数据来引导强大的视觉模型。这是深度学习的最大优势之一:特征重用。你将在下一节中详细了解这一点。首先,我们需要开始处理数据。

8.2.2 下载数据

我们将使用的 Dogs vs. Cats 数据集并不随 Keras 打包。这是 Kaggle 在 2013 年末作为计算机视觉竞赛的一部分提供的,当时 convnets 还不是主流。您可以从 www.kaggle.com/c/dogs-vs-cats/data 下载原始数据集(如果您还没有 Kaggle 帐户,您需要创建一个——别担心,这个过程很简单)。您也可以使用 Kaggle 命令行 API 下载数据集。

下载 Kaggle 数据集

Kaggle 提供了一个易于使用的 API 来以编程方式下载托管在 Kaggle 上的数据集。您可以使用它将 Dogs vs. Cats 数据集下载到您的本地计算机。例如,通过在 R 中运行单个命令,就可以轻松下载此数据集。

但是,API 的访问权限受限于 Kaggle 用户,因此要运行上述命令,您首先需要进行身份验证。kaggle 包将在位于 ~/.kaggle/kaggle.json 的 JSON 文件中查找您的登录凭据。让我们创建这个文件。

首先,您需要创建一个 Kaggle API 密钥并将其下载到本地计算机。只需在 web 浏览器中导航到 Kaggle 网站,登录,然后转到“我的帐户”页面。在您的帐户设置中,您会找到一个 API 部分。点击“创建新的 API 令牌”按钮将生成一个名为 kaggle.json 的密钥文件,并将其下载到您的计算机上。

最后,创建一个 ~/.kaggle 文件夹。作为安全最佳实践,您还应确保该文件仅由当前用户自己可读(仅适用于 Mac 或 Linux,而不是 Windows)。

因为在接下来的章节中我们将执行大量的文件系统操作,我们将使用 fs R 包,它比基本的 R 文件系统函数更易于使用。(您可以通过 install.packages(“fs”) 从 CRAN 安装它。)

准备 Kaggle API 密钥:

library(fs)

dir_create("~/.kaggle")

file_move("~/Downloads/kaggle.json", "~/.kaggle/")

file_chmod("~/.kaggle/kaggle.json", "0600")➊

将文件标记为仅自己可读

通过 pip 安装 kaggle 包:

reticulate::py_install("kaggle", pip = TRUE)

现在,您可以下载我们即将使用的数据:

system('kaggle competitions download -c dogs-vs-cats')

第一次尝试下载数据时,可能会出现“403 Forbidden”错误。这是因为在下载之前,您需要接受与数据集相关的条款——您需要登录 Kaggle 帐户并点击“我理解并接受”按钮,网址为 www.kaggle.com/c/dogs-vs-cats/rules。您只需要这样做一次。

最后,数据以压缩的 zip 文件 dogs-vs-cats.zip 的形式下载。该 zip 文件本身包含另一个压缩的 zip 文件 train.zip,这是我们将要使用的训练数据。我们使用 zip R 包(可以通过 install.packages(“zip”) 从 CRAN 安装)将 train.zip 解压缩到一个新目录 dogs-vs-cats 中:

zip::unzip('dogs-vs-cats.zip', exdir = "dogs-vs-cats", files = "train.zip")

zip::unzip("dogs-vs-cats/train.zip", exdir = "dogs-vs-cats")

我们数据集中的图片是中等分辨率的彩色 JPEG。图 8.8 展示了一些示例。

图像

图 8.8 狗与猫数据集的样本。大小未经修改:样本的大小、颜色、背景等各不相同。

毫不奇怪,最早在 2013 年的狗与猫 Kaggle 竞赛中,获胜者都是使用了卷积神经网络。最好的参赛作品达到了 95% 的准确率。在这个示例中,我们将会在下一节中实现接近这个准确率,尽管我们将只使用竞争对手可用数据的不到 10% 进行模型训练。

这个数据集包含 25,000 张狗和猫的图片(每类别 12,500 张),大小为 543 MB(压缩后)。下载并解压缩数据后,我们将创建一个新数据集,其中包含三个子集:一个包含每个类别 1,000 个样本的训练集,一个包含每个类别 500 个样本的验证集,以及一个包含每个类别 1,000 个样本的测试集。为什么这样做?因为你在职业生涯中遇到的许多图像数据集只包含几千个样本,而不是几万个样本。有更多的数据可用会使问题变得更容易,因此使用小数据集进行学习是一个好的做法。

我们将要处理的子采样数据集将具有以下目录结构:

cats_vs_dogs_small/

…train/

……cat/➊

……dog/➋

…validation/

……cat/➌

……dog/➍

…test/

……cat/➎

……dog/➏

包含 1,000 张猫的图片

包含 1,000 张狗的图片

包含 500 张猫的图片

包含 500 张狗的图片

包含 1,000 张猫的图片

包含 1,000 张狗的图片

让我们通过几次调用 {fs} 函数来实现这一点。

列表 8.6 将图片复制到训练、验证和测试目录

library(fs)

original_dir <— path("dogs-vs-cats/train")➊

new_base_dir <— path("cats_vs_dogs_small")➋

make_subset <— function(subset_name,➌

start_index, end_index) {

for (category in c("dog", "cat")) {

file_name <— glue::glue("{category}.{ start_index:end_index }.jpg")

dir_create(new_base_dir / subset_name / category)

file_copy(original_dir / file_name,

new_base_dir / subset_name / category / file_name)

}

}

make_subset("train", start_index = 1, end_index = 1000)➍

make_subset("validation", start_index = 1001, end_index = 1500)➎

make_subset("test", start_index = 1501, end_index = 2500)➏

原始数据集解压缩后的目录路径

我们将存储较小数据集的目录

将猫和狗图片在开始索引和结束索引之间复制到子目录 new_base_dir/{subset_name}/cat(和/dog)的实用函数。"subset_name" 将是 "train"、"validation" 或 "test" 中的一个。

创建训练子集,包含每类别的前 1,000 张图片。

创建验证子集,包含每类别的接下来 500 张图片。

用每个类别的接下来的 1,000 张图像创建测试子集。

现在我们有 2,000 张训练图像,1,000 张验证图像和 2,000 张测试图像。每个数据集包含相同数量的来自每个类别的样本:这是一个平衡的二元分类问题,这意味着分类准确度将是一个适当的成功度量。

8.2.3 构建模型

我们将重复使用你在第一个示例中看到的相同的通用模型结构:卷积网络将是交替的 layer_conv_2d()(使用 relu 激活)和 layer_ max_pooling_2d() 层的堆叠。

但因为我们处理的是更大的图像和更复杂的问题,我们将相应地使我们的模型更大:它将具有两个更多的 layer_conv_2d() 和 layer_max_pooling_2d() 阶段。这既增加了模型的容量,又进一步减小了特征图的大小,使得当我们到达 layer_flatten() 时它们不会过大。在这里,因为我们从尺寸为 180 像素 × 180 像素的输入开始(一个相对随意的选择),所以在 layer_flatten() 之前我们得到大小为 7 × 7 的特征图。

特征图的深度在模型中逐渐增加(从 32 增加到 256),而特征图的大小逐渐减小(从 180 × 180 减小到 7 × 7)。这是你几乎在所有卷积网络中都会看到的模式。

因为我们正在处理一个二元分类问题,所以我们将模型结束于一个单元(大小为 1 的 layer_dense())和一个 sigmoid 激活。这个单元将编码模型正在观察的一个类别或另一个类别的概率。

最后一个小差异:我们将以一个 layer_rescaling() 开始模型,它将重新缩放图像输入(其值最初在 [0, 255] 范围内)到 [0, 1] 范围内。

列表 8.7 实例化用于狗与猫分类的小型卷积网络

inputs <— layer_input(shape = c(180, 180, 3))➊

outputs <— inputs %>%

layer_rescaling(1 / 255) %>%➋

layer_conv_2d(filters = 32, kernel_size = 3, activation = "relu") %>%

layer_max_pooling_2d(pool_size = 2) %>%

layer_conv_2d(filters = 64, kernel_size = 3, activation = "relu") %>%

layer_max_pooling_2d(pool_size = 2) %>%

layer_conv_2d(filters = 128, kernel_size = 3, activation = "relu") %>%

layer_max_pooling_2d(pool_size = 2) %>%

layer_conv_2d(filters = 256, kernel_size = 3, activation = "relu") %>%

layer_max_pooling_2d(pool_size = 2) %>%

layer_conv_2d(filters = 256, kernel_size = 3, activation = "relu") %>% layer_flatten() %>%

layer_dense(1, activation = "sigmoid")

model <— keras_model(inputs, outputs)

该模型期望的是尺寸为 180 × 180 的 RGB 图像。

通过将它们除以 255 来将输入重新缩放到 [0, 1] 范围内。

让我们看看随着每一层的连续变化,特征图的维度如何改变:

model

Image

对于编译步骤,我们将继续使用 RMSprop 优化器,因为通常情况下我们会以单个 sigmoid 单元结束模型,所以我们将使用二元交叉熵作为损失函数(作为提醒,在第六章的表 6.1 中可以查看在各种情况下使用哪个损失函数的速查表)。

列表 8.8 配置模型进行训练

model %>% 编译(损失 = "binary_crossentropy",

优化器 = "rmsprop"

指标 = "准确率")

8.2.4 数据预处理

正如你现在所知道的,数据在进入模型之前应该被格式化为适当预处理的浮点张量。目前,数据作为 JPEG 文件存在于驱动器上,因此将其输入模型的步骤大致如下:

  1. 1 读取图片文件。

  2. 2 将 JPEG 内容解码为 RGB 像素网格。

  3. 3 将这些转换为浮点张量。

  4. 4 调整它们为共享大小(我们将使用 180 × 180)。

  5. 5 将它们打包成批次(我们将使用 32 张图像的批次)。

这可能看起来有点令人生畏,但幸运的是,Keras 提供了自动处理这些步骤的实用工具。特别是,Keras 具有实用函数 image_dataset_ from_directory(),它让你快速设置一个数据管道,可以自动将磁盘上的图像文件转换为预处理张量的批次。这是我们将在这里使用的方法。

调用 image_dataset_from_directory(directory) 首先会列出目录的子目录,并假定每个子目录都包含一个类别的图像。然后,它将索引每个子目录中的图像文件。最后,它将创建并返回一个 TF 数据集对象,配置为读取这些文件、对它们进行洗牌、将它们解码为张量、将它们调整为共享大小并将它们打包成批次。

列表 8.9 使用 image_dataset_from_directory 读取图像

训练数据集 <—

从目录创建图像数据集(new_base_dir / "train")

图像大小 = c(180, 180)

批量大小 = 32)

验证数据集 <—

从目录创建图像数据集(new_base_dir / "validation")

图像大小 = c(180, 180)

批量大小 = 32)

测试数据集 <—

从目录创建图像数据集(new_base_dir / "test")

图像大小 = c(180, 180)

批量大小 = 32)

理解 tfdatasets

tfdatasets 包可用于为机器学习模型创建高效的输入管道。其核心对象类型是 TF 数据集。

TF 数据集对象是可迭代的:你可以在它上面调用 as_iterator() 来生成一个迭代器,然后在迭代器上重复调用 iter_next() 来生成数据序列。通常,你会使用 TF 数据集对象来生成输入数据和标签的批次。你可以直接将 TF 数据集对象传递给 Keras 模型的 fit() 方法。

TF 数据集对象处理了许多关键特性,否则你自己实现起来可能会很麻烦,特别是异步数据预取(在模型处理上一个批次数据的同时预处理下一个批次数据,这样可以保持执行流畅,没有中断)。

tfdatasets 包提供了一个函数式 API,用于修改数据集。这里是一个快速示例:让我们从一个整数序列的 R 数组创建一个 TF Dataset 实例。我们将考虑 100 个样本,其中每个样本是一个大小为 6 的向量(换句话说,我们的起始 R 数组是一个形状为 (100, 6) 的矩阵):

library(tfdatasets)

example_array <— array(seq(100*6), c(100, 6))

head(example_array)

图片

dataset <— tensor_slices_dataset(example_array)➊

➊ tensor_slices_dataset() 函数可以用来从一个 R 数组或一个(可选命名的)R 数组列表创建 TF Dataset。

起初,我们的数据集只产生单个样本:

dataset_iterator <— as_iterator(dataset)

for(i in 1:3) {

element <— iter_next(dataset_iterator)

打印(element)

}

tf.Tensor([ 1 101 201 301 401 501], shape=(6), dtype=int32)

tf.Tensor([ 2 102 202 302 402 502], shape=(6), dtype=int32)

tf.Tensor([ 3 103 203 303 403 503], shape=(6), dtype=int32)

注意,默认情况下 TF Dataset 迭代器会产生 Tensorflow 张量。这通常是你想要的,也是 fit() 方法的最合适类型。然而,在某些情况下,你可能更喜欢迭代器产生 R 数组的批次;在这种情况下,你可以调用 as_array_iterator() 而不是 as_iterator():

dataset_array_iterator <— as_array_iterator(dataset)

for(i in 1:3) {

element <— iter_next(dataset_array_iterator)

str(element)

}

int [1:6(1d)] 1 101 201 301 401 501

int [1:6(1d)] 2 102 202 302 402 502

int [1:6(1d)] 3 103 203 303 403 503

我们可以使用 dataset_batch() 来对数据进行分批处理:

batched_dataset <— dataset %>%

dataset_batch(3)

batched_dataset_iterator <— as_iterator(batched_dataset)

for(i in 1:3) {

element <— iter_next(batched_dataset_iterator)

打印(element)

}

tf.Tensor(

[ [ 1 101 201 301 401 501]

[ 2 102 202 302 402 502]

[ 3 103 203 303 403 503]], shape=(3, 6), dtype=int32)

tf.Tensor(

[ [ 4 104 204 304 404 504]

[ 5 105 205 305 405 505]

[ 6 106 206 306 406 506]], shape=(3, 6), dtype=int32)

tf.Tensor(

[ [ 7 107 207 307 407 507]

[ 8 108 208 308 408 508]

[ 9 109 209 309 409 509]], shape=(3, 6), dtype=int32)

更广泛地说,我们可以访问一系列有用的数据集方法,比如

  • dataset_shuffle(buffer_size)—在缓冲区内对元素进行洗牌

  • dataset_prefetch(buffer_size)—预取 GPU 内存中的元素缓冲,以实现更好的设备利用率

  • dataset_map(fn)—对数据集的每个元素应用任意转换(函数 fn,它期望接受数据集产生的单个元素作为输入)

dataset_map() 方法特别常用。这里是一个例子。我们将使用它来将我们的玩具数据集中的元素重新塑形,从形状 (6) 到形状 (2, 3):

reshaped_dataset <— dataset %>%

dataset_map(function(element) tf$reshape(element, shape(2, 3)))➊

reshaped_dataset_iterator <— as_iterator(reshaped_dataset)

for(i in 1:3) {

element <— iter_next(reshaped_dataset_iterator)

打印(element)

}

tf.Tensor(

[[ 1 101 201]

[301 401 501]], 形状=(2, 3), 数据类型=int32)

tf.Tensor(

[[ 2 102 202]

[302 402 502]], 形状=(2, 3), 数据类型=int32)

tf.Tensor(

[[ 3 103 203]

[303 403 503]],

形状=(2, 3), 数据类型=int32)

请注意,tf$reshape() 使用 C 风格(行主序)语义进行重塑。

在本章中,您将看到更多 dataset_map() 操作。

让我们看看这些 Dataset 对象之一的输出:它产生 180 × 180 的 RGB 图像批次(形状为 (32, 180, 180, 3))和整数标签(形状为 (32))。每个批次中有 32 个样本(批量大小)。

列表 8.10 显示由 Dataset 产生的数据和标签的形状

c (data_batch, labels_batch) %<—% iter_next(as_iterator(train_dataset)) data_batch$形状

TensorShape([32, 180, 180, 3])

标签批次$形状

TensorShape([32])

让我们在我们的数据集上拟合模型。我们将使用 fit() 中的 validation_data 参数在单独的 TF Dataset 对象上监视验证指标。

请注意,我们还将使用 callback_model_checkpoint() 在每个时期之后保存模型。我们将配置它,指定保存文件的路径,以及参数 save_best_only = TRUE 和 monitor = “val_loss”:它们告诉回调函数仅在当前 val_loss 度量值低于训练期间任何以前时间的度量值时才保存新文件(覆盖任何先前的文件)。这确保了您保存的文件始终包含模型的状态,对应于其在验证数据上的性能最佳的训练时期。因此,如果我们开始过度拟合,我们不必重新训练一个新模型以进行更少数量的时期:我们可以重新加载我们保存的文件。

列表 8.11 使用 TensorFlow Dataset 拟合模型

回调函数 <— 列表(

callback_model_checkpoint(

文件路径 = "convnet_from_scratch.keras",

save_best_only = TRUE,

monitor = "val_loss"

)

)

历史 <— 模型 %>%

fit(

train_dataset,

时期 = 30,

验证数据 = 验证 _dataset,

回调函数 = 回调函数

)

让我们绘制模型在训练和验证数据上的损失和准确性,以便在训练过程中进行对比(见 图 8.9)。

列表 8.12 显示训练期间的损失和准确性曲线

绘制(历史)

图片

图 8.9 简单卷积网络的训练和验证指标。

这些图表特征是过度拟合的特征。训练准确度随时间线性增加,直到接近 100%,而验证准确度在 75% 处达到峰值。验证损失在仅 10 个时期后达到最小值,然后增加,而训练损失在训练过程中保持线性减少。

让我们检查测试准确度。我们将重新加载模型,以评估它在开始过度拟合之前的状态。

列表 8.13 在测试集上评估模型

测试模型 <— load_model_tf("convnet_from_scratch.keras")

result <— evaluate(test_model, test_dataset)

cat(sprintf("测试准确度:%.3f\n", result["accuracy"]))

测试准确度:0.740

我们得到了 74% 的测试准确度。(由于神经网络初始化的随机性,你可能会得到略有不同的数字。)

由于我们的训练样本相对较少(2,000),过拟合将是我们关注的首要问题。你已经了解到一些可以帮助缓解过拟合的技术,例如随机失活和权重衰减(L2 正则化)。现在我们要使用一种新的技术,针对计算机视觉的,几乎在使用深度学习模型处理图像时通用的一种技术:数据增强

8.2.5 使用数据增强

过拟合是由于样本数量太少而导致的,使得您无法训练出能够泛化到新数据的模型。如果有无限的数据,您的模型将接触到手头数据分布的每一个可能的方面:您永远不会过拟合。数据增强采取的方法是通过对现有训练样本进行一系列随机变换来生成更多的训练数据,从而增强样本。目标是,在训练时,您的模型永远不会看到完全相同的图片两次。这有助于使模型接触到数据的更多方面,从而更好地泛化。

在 Keras 中,可以通过在模型开头添加一些 数据增强层 来完成这个任务。让我们从一个示例开始:以下的 keras_model_sequential() 链接了几个随机图像转换。在我们的模型中,我们会在 layer_rescaling() 之前包含它。

列表 8.14 定义要添加到图像模型中的数据增强阶段

data_augmentation <— keras_model_sequential() %>%

layer_random_flip("horizontal") %>%

layer_random_rotation(0.1) %>%

layer_random_zoom(0.2)

这些只是可用的几个层之一(更多请参阅 Keras 文档)。让我们快速浏览一下这段代码:

  • layer_random_flip(“horizontal”)—将通过它的随机 50% 的图像进行水平翻转

  • layer_random_rotation(0.1)—将输入图像随机旋转一个范围为 [-10%,+10%] 的值(这些是完整圆的一部分——以角度表示,范围将是 [-36 度,+36 度])

  • layer_random_zoom(0.2)—将图像放大或缩小一个范围在 [-20%,+20%] 内的随机因子

让我们来看看增强后的图片(参见 图 8.10)。

列表 8.15 显示一些随机增强的训练图像

library(tfdatasets)

batch <— train_dataset %>%

as_iterator() %>%

iter_next()

c(images, labels) %<—% batch

par(mfrow = c(3, 3), mar = rep(.5, 4))➊

image <— images[1, , , ]

plot(as.raster(as.array(image), max = 255))➋

for (i in 2:9) {

augmented_images <— data_augmentation(images)➌

augmented_image <— augmented_images[1, , , ]

plot(as.raster(as.array(augmented_image), max = 255)➍

}

准备用于九张图片的图形设备。

绘制批次的第一张图片,不进行增强。

将增强阶段应用于图像批次。

显示输出批次中的第一张图像。对于每个八次迭代,这是同一图像的不同增强。

图像

图 8.10 通过随机数据增强生成非常好的狗的变化

如果我们使用这个数据增强配置来训练一个新模型,那么模型将永远不会看到相同的输入两次。但是它看到的输入仍然存在很高的相关性,因为它们来自少量原始图像——我们无法产生新的信息;我们只能重新组合现有的信息。因此,这可能不足以完全消除过拟合。为了进一步对抗过拟合,我们还会在密集连接分类器之前向我们的模型添加一个dropout()层。

关于随机图像增强层,你应该知道的最后一件事:就像layer_dropout()一样,在推理期间(当我们调用predict()evaluate()时),它们是不活跃的。在评估期间,我们的模型的行为与不包括数据增强和 dropout 时完全相同。

列表 8.16 定义一个包括图像增强和 dropout 的新卷积神经网络

inputs <— layer_input(shape = c(180, 180, 3))

outputs <— inputs %>%

data_augmentation() %>%

layer_rescaling(1 / 255) %>%

layer_conv_2d(filters = 32, kernel_size = 3, activation = "relu") %>%

layer_max_pooling_2d(pool_size = 2) %>% layer_conv_2d(filters = 64, kernel_size = 3, activation = "relu") %>%

layer_max_pooling_2d(pool_size = 2) %>%

layer_conv_2d(filters = 128, kernel_size = 3, activation = "relu") %>%

layer_max_pooling_2d(pool_size = 2) %>%

layer_conv_2d(filters = 256, kernel_size = 3, activation = "relu") %>%

layer_max_pooling_2d(pool_size = 2) %>%

layer_conv_2d(filters = 256, kernel_size = 3, activation = "relu") %>%

layer_flatten() %>%

layer_dropout(0.5) %>%

layer_dense(1, activation = "sigmoid")

model <— keras_model(inputs, outputs)

model %>% compile(loss = "binary_crossentropy",

optimizer = "rmsprop",

metrics = "accuracy")

让我们使用数据增强和 dropout 训练模型。因为我们预计过拟合将在训练期间晚得多,所以我们将训练三倍的轮次——一百轮。

列表 8.17 训练正则化的卷积神经网络

callbacks <— list(

callback_model_checkpoint(

filepath = "convnet_from_scratch_with_augmentation.keras",

save_best_only = TRUE,

monitor = "val_loss"

)

)

history <— model %>% fit(

train_dataset,

epochs = 100,

validation_data = validation_dataset,

callbacks = callbacks

)

让我们再次绘制结果:参见图 8.11。由于数据增强和 dropout,我们开始在很晚的时候过拟合,大约在 60–70 轮之后(对比原始模型的 10 轮)。验证准确率最终保持在 80–85% 的范围内,这是我们第一次尝试的显著改进:

图像

图 8.11 使用数据增强的训练和验证指标

让我们检查测试准确率。

列表 8.18 在测试集上评估模型

test_model <— load_model_tf("convnet_from_scratch_with_augmentation.keras")

结果 <— evaluate(test_model, test_dataset)

cat(sprintf("测试准确率:%.3f\n", result["accuracy"]))

测试准确率:0.814

我们得到了 81.4%的测试准确率。看起来不错!如果您正在运行代码,请确保保留保存的文件(convnet_from_scratch_with_augmentation.keras),因为我们将在下一章中用到它进行一些实验。

通过进一步调整模型的配置(例如每个卷积层的滤波器数量,或者模型中的层数),我们可能会获得更高的准确率,可能高达 90%。但是仅通过从头训练我们自己的卷积网络,要达到更高的准确率将会很困难,因为我们的数据量太少。为了提高这个问题上的准确率,我们的下一步将是使用预训练模型,这是接下来两节的重点。

8.3 利用预训练模型

在小图像数据集上进行深度学习的一种常见且非常有效的方法是使用预训练模型。预训练模型是以前在大型数据集上训练过的模型,通常是在大规模图像分类任务上。如果这个原始数据集足够大且足够通用,那么预训练模型学到的特征的空间层次结构可以有效地充当视觉世界的通用模型,因此,它的特征可以对许多不同的计算机视觉问题提供有用的信息,即使这些新问题可能涉及与原始任务完全不同的类别。例如,您可以在 ImageNet 上训练模型(其中大多数类别是动物和日常物品),然后将这个训练好的模型重新用于识别图像中的家具项目等完全不同的目标。深度学习与许多较老的、浅层的学习方法相比的一个关键优势是,它学到的特征在不同问题之间的可移植性,这使得深度学习对于小数据问题非常有效。

在这种情况下,让我们考虑一个在 ImageNet 数据集上训练的大型卷积网络(140 万张带标签的图像和 1000 个不同的类)。ImageNet 包含许多动物类别,包括不同种类的猫和狗,因此您可以期望它在猫狗分类问题上表现良好。

我们将使用由 Karen Simonyan 和 Andrew Zisserman 于 2014 年开发的 VGG16 架构¹。尽管这是一个较老的模型,远非当前技术水平的最新状态,并且比许多其他近期模型更加笨重,但我选择它是因为它的架构与你已经熟悉的相似,并且不需要引入任何新概念就能轻松理解。这可能是你第一次遇到这些可爱的模型名称之一——VGG、ResNet、Inception、Xception 等等;如果你继续进行计算机视觉的深度学习,你将经常遇到它们。

有两种使用预训练模型的方式:特征提取微调。我们将会涵盖这两种方法。让我们从特征提取开始。

8.3.1 使用预训练模型进行特征提取

特征提取包括使用先前训练好的模型学到的表示从新样本中提取有趣的特征。然后这些特征通过一个从头开始训练的新分类器。

正如你之前所看到的,用于图像分类的卷积神经网络包括两部分:它们首先是一系列的池化和卷积层,然后是一个全连接的分类器。第一部分被称为模型的卷积基。在卷积神经网络中,特征提取是指利用先前训练好的网络的卷积基对新数据进行传递,并在其之上训练一个新的分类器(参见图 8.12)。

图像

图 8.12 保持相同的卷积基进行分类器交换

为什么仅重用卷积基?我们能否也重用全连接的分类器?一般来说,应该避免这样做。原因是卷积基学到的表示可能更通用,因此更易重用:卷积神经网络的特征图是图片中通用概念的存在图,无论当前的计算机视觉问题是什么,该特征图都可能是有用的。但是分类器学到的表示将必然特定于模型训练的类别集,它们只包含关于这个或那个类别在整个图片中出现概率的信息。此外,在全连接层中发现的表示不再包含有关物体在输入图像中位置的任何信息;这些层丢弃了空间概念,而卷积特征图仍然描述着物体的位置。对于物体位置很重要的问题,全连接特征基本上是无用的。

请注意,特定卷积层提取的表示的一般性(因此也是可重用性)取决于模型中的层的深度。在模型中更早的层提取局部的高度通用的特征图(例如视觉边缘,颜色和纹理),而更高的层提取更抽象的概念(如“猫耳”或“狗眼”)。因此,如果你的新数据集与原始模型训练的数据集差异很大,你可能最好只使用模型的前几层进行特征提取,而不是整个卷积基。

在这种情况下,由于 ImageNet 类集包含多个狗和猫类别,重用原始模型的密集连接层中包含的信息可能是有益的。但我们选择不这样做,以涵盖新问题的类别集不重叠于原始模型的类别集的更普遍情况。让我们通过使用在 ImageNet 上训练的 VGG16 网络的卷积基础,从猫和狗图像中提取有趣的特征,然后在这些特征的顶部训练一个猫狗分类器来将其付诸实践。

VGG16 模型等等,都预先在 Keras 中打包。它们都作为以 application_ 前缀开头的函数导出。许多其他图像分类模型(都是在 ImageNet 数据集上预训练的)都作为 Keras 应用程序的一部分提供:

  • Xception

  • Mobilenet

  • DenseNet

  • ResNet

  • EfficientNet

  • 等等

让我们实例化 VGG16 模型。

列表 8.19 实例化 VGG16 卷积基础

conv_base <— application_vgg16(

weights = "imagenet",

include_top = FALSE,

input_shape = c(180, 180, 3)

)

我们向应用函数传递了三个参数:

  • weights 指定初始化模型的权重检查点。

  • include_top 指的是是否在网络顶部包含(或不包含)密集连接分类器。默认情况下,这个密集连接分类器对应于 ImageNet 的 1,000 个类别。因为我们打算使用自己的密集连接分类器(只有两个类别:猫和狗),所以我们不需要包含它。

  • input_shape 是我们将要馈送到网络中的图像张量的形状。这个参数是可选的:如果我们不传递它,网络将能够处理任何大小的输入。在这里,我们传递它,以便我们可以可视化(在以下摘要中)随着每个新的卷积和池化层的形状如何收缩。

这是 VGG16 卷积基础架构的详细信息。它与你已经熟悉的简单卷积网络类似:

conv_base

图像

最终的特征图形状为 (5, 5, 512)。这就是我们将要在其顶部添加一个密集连接分类器的特征图。

此时,我们可以采取两种方式:

  • 在我们的数据集上运行卷积基础,将其输出(数组)记录到磁盘上的文件数组中,然后使用这些数据作为章节 4 中所见的独立、密集连接分类器的输入。这种解决方案运行速度快,成本低,因为它只需要对每个输入图像运行一次卷积基础,而卷积基础是流水线中成本最高的部分。但出于同样的原因,这种技术不允许我们使用数据增强。

  • 扩展我们已有的模型(conv_base),在其顶部添加密集层,并在输入数据上端到端地运行整个模型。这将允许我们使用数据增强,因为每个输入图像在被模型看到时都会经过卷积基。但出于同样的原因,这种技术比第一个技术要昂贵得多。

我们将涵盖这两种技术。让我们逐步介绍设置第一种技术所需的代码:记录 conv_base 在我们的数据上的输出,并将这些输出用作新模型的输入。

无数据增强的快速特征提取

我们将首先通过调用 conv_base 模型的 predict() 方法在我们的训练、验证和测试数据集上提取特征作为 R 数组。

让我们遍历我们的数据集以提取 VGG16 特征。

清单 8.20 提取 VGG16 特征和相应的标签

get_features_and_labels <- function(dataset) {

n_batches <- length(dataset)

all_features <- vector("list", n_batches)

all_labels <- vector("list", n_batches)

iterator <- as_array_iterator(dataset)

for (i in 1:n_batches) {

c(images, labels) %<-% iter_next(iterator)

preprocessed_images <- imagenet_preprocess_input(images)

features <- conv_base %>% predict(preprocessed_images)

all_labels[[i]] <- labels

all_features[[i]] <- features

}

all_features <- listarrays::bind_on_rows(all_features)

all_labels <- listarrays::bind_on_rows(all_labels)➊

list(all_features, all_labels)

}

c(train_features, train_labels) %<-% get_features_and_labels(train_dataset)

c(val_features, val_labels) %<-% get_features_and_labels(validation_dataset)

c(test_features, test_labels) %<-% get_features_and_labels(test_dataset)

将一系列 R 数组沿着第一个轴(批处理维度)组合在一起。

重要的是,predict() 仅期望图像,而不是标签,但我们当前的数据集生成的批次包含图像及其标签。此外,VGG16 模型期望经过函数 imagenet_preprocess_input() 预处理的输入,该函数将像素值缩放到合适的范围。提取的特征目前的形状为(样本数,5,5,512):

dim(train_features)

[1] 2000 5 5 512

在这一点上,我们可以定义我们的密集连接的分类器(请注意使用了 dropout 进行正则化),并在我们刚刚记录的数据和标签上对其进行训练。

清单 8.21 定义和训练密集连接的分类器

inputs <- layer_input(shape = c(5, 5, 512))

outputs <- inputs %>%

layer_flatten() %>%➊

layer_dense(256) %>%

layer_dropout(.5) %>%

layer_dense(1, activation = "sigmoid")

model <- keras_model(inputs, outputs)

model %>% compile(loss = "binary_crossentropy",

optimizer = "rmsprop",

metrics = "accuracy")

callbacks <- list(

callback_model_checkpoint(

filepath = "feature_extraction.keras",

save_best_only = TRUE,

monitor = "val_loss"

)

)

history <- model %>% fit(

train_features, train_labels,

epochs = 20,

validation_data = list(val_features, val_labels),

callbacks = callbacks

)

注意在传递特征给 layer_dense() 之前使用了 layer_flatten()

训练非常快,因为我们只需处理两个密集层 - 一个时代少于一秒,甚至在 CPU 上也是如此。

让我们在训练过程中查看损失和准确率曲线(见 图 8.13)。

清单 8.22 绘制结果

绘制(history)

图片

图 8.13 普通特征提取的训练和验证指标

我们达到了约 97% 的验证准确率 - 比我们在上一节使用从头开始训练的小模型取得的结果要好得多。然而,这有点不公平的比较,因为 ImageNet 包含许多狗和猫实例,这意味着我们的预训练模型已经具有了所需任务的确切知识。当您使用预训练特征时,情况并非总是如此。

然而,图表也显示,尽管使用了相当大的丢弃率,但我们几乎从一开始就出现了过拟合。这是因为这种技术不使用数据增强,而数据增强对于防止小图像数据集过拟合是至关重要的。

特征提取与数据增强

现在让我们回顾我提到的第二种做特征提取的技术,它要慢得多,更昂贵,但允许我们在训练过程中使用数据增强:创建一个将卷积基与新的密集分类器链在一起的模型,并在输入上端到端地训练它。

为此,我们首先会 冻结卷积基。冻结层或一组层意味着在训练期间阻止它们的权重更新。如果我们不这样做,那么先前由卷积基学到的表示将在训练期间被修改。因为顶部的密集层是随机初始化的,所以会通过网络传播非常大的权重更新,有效地破坏先前学到的表示。在 Keras 中,我们通过调用 freeze_weights() 来冻结层或模型。

清单 8.23 实例化和冻结 VGG16 卷积基

conv_base <- application_vgg16(

权重 = "imagenet",

include_top = FALSE)

freeze_weights(conv_base)

调用 freeze_weights() 会清空层或模型的可训练权重列表。

清单 8.24 打印在冻结之前和之后的可训练权重列表

unfreeze_weights(conv_base)

cat("这是可训练权重的数量",

"冻结卷积基之前:"

length(conv_base$trainable_weights), "\n")

这是在冻结卷积基之前的可训练权重数量:26

freeze_weights(conv_base)

cat("这是可训练权重的数量",

"冻结卷积基之后:"

length(conv_base$trainable_weights), "\n")

冻结卷积基之后的可训练权重数量为:0

现在我们可以创建一个新模型,将特征链接在一起

  1. 1 数据增强阶段

  2. 2 我们冻结的卷积基

  3. 3 一个密集分类器

数据增强 <- keras_model_sequential() %>%

layer_random_flip("horizontal") %>%

layer_random_rotation(0.1) %>%

layer_random_zoom(0.2)

输入 <- layer_input(shape = c(180, 180, 3))

输出 <- 输入 %>%

data_augmentation() %>%➊

imagenet_preprocess_input() %>%➋

conv_base() %>%

layer_flatten() %>%

layer_dense(256) %>%

layer_dropout(0.5) %>%

layer_dense(1, activation = "sigmoid")

model <- keras_model(inputs, outputs)

model %>% compile(loss = "binary_crossentropy",

优化器 = "rmsprop",

指标 = "准确度")

应用数据增强。

应用输入值缩放。

使用此设置,只有我们添加的两个密集层的权重将会被训练。总共有四个权重张量:每层两个(主要权重矩阵和偏置向量)。注意,为了使这些更改生效,您必须先编译模型。如果您在编译之后修改了权重的可训练性,那么您应该重新编译模型,否则这些更改将被忽略。

让我们训练我们的模型。由于数据增强,模型要过拟合的时间要长得多,所以我们可以训练更多的 epochs——让我们做 50 个。

这项技术够昂贵,只有在有 GPU 的情况下才尝试,CPU 上无法操作。如果无法在 GPU 上运行代码,那么前一项技术就是最佳选择:

回调 <- list(

回调模型检查点(

文件路径 = "使用数据增强进行特征提取.keras",

仅保存最佳结果 = TRUE,

监控 = "val_loss"

)

)

history <- model %>% fit(

训练数据集,

epochs = 50,

验证数据 = 验证数据集,

回调 = 回调

)

让我们再次绘制结果(参见图 8.14)。如您所见,我们达到了超过 98%的验证准确度。这是相对于先前模型的显著提高。

图片

图 8.14 使用数据增强进行特征提取的训练和验证指标

让我们检查一下测试准确度。

清单 8.25 在测试集上评估模型

测试模型 <- load_model_tf(

"使用数据增强进行特征提取.keras")

result <- evaluate(test_model, test_dataset)

cat(sprintf("测试精度:%.3f\n", result["准确度"]))

测试精度:0.977

我们得到了 97.7%的测试准确度。与以前的测试准确度相比,这只是一次适度的提高,这有点令人失望,考虑到在验证数据上的强劲结果。模型的准确度总是取决于您对其进行评估的样本集。有些样本集可能比其他样本集更难,对一个集合的强烈结果未必会完全转化到其他所有集合。

8.3.2 微调预训练模型

进一步复用模型的一个广泛使用的技巧是微调(fine-tuning),与特征提取互补(见图 8.15)。 微调包含解冻一个用于特征提取的模型基底的顶部几层,同时联合训练这个新添加的部分(在本例中是全连接分类器)和这些顶部层。这称为微调,是因为它会稍微调整正在复用的模型的更抽象表示,使其更相关于手头的问题。

我之前说过,必须冻结 VGG16 的卷积基,才能够在其上训练随机初始化的分类器。出于同样的原因,只有在距离分类器先前已经训练好的情况下,才能微调卷积基的顶部层。如果分类器还没有进行训练,训练过程中传播的错误信号会过大,并且微调的层之前学习到的表示将被破坏。因此微调网络的步骤如下:

  1. 1 在已经训练好的基础网络上添加自定义网络。

  2. 2 冻结基础网络。

  3. 3 训练我们添加的部分。

  4. 4 解冻基础网络中的一些层。(请注意,不应解冻“批量标准化”层,因为在 VGG16 中没有这样的层。批量标准化及其对微调的影响将在下一章中解释。)

  5. 5 联合训练这两个层和我们添加的部分。

进行特征提取时,前三个步骤已经完成,现在进行第四个步骤:解冻 conv_base,然后冻结其中的各个层。

Image

图 8.15 调整 VGG16 网络的最后一个卷积块

提醒一下,这就是我们的卷积基长这样子:

conv_base

Image

我们将调整最后三个卷积层,这意味着所有层,包括 block4_pool 之前的层都应该保持冻结状态,而 block5_conv1、block5_conv2 和 block5_conv3 这三个层则应该可训练。

为什么不能调整更多层?为什么不能调整整个卷积基?其实可以,但要考虑以下几点:

  • 卷积基中越往前的层编码的是更加通用、可重复使用的特征,而越往后的层编码的是更加专业化的特征。 fine-tune 更加专业化的特征更有用,因为这些是需要在新问题上转化的。对低层进行调整,收益会逐渐变小。

  • 如果要训练的参数越多,过度拟合的风险就越大。卷积基有 1500 万个参数,因此在小数据集上尝试训练它是很冒险的。

因此,在这种情况下,只微调卷积基准模型的前两层或三层是一个不错的策略。让我们从前一个例子结束的地方开始设定这个。

列表 8.26 冻结直到倒数第四层的所有层

unfreeze_weights(conv_base, from = -4) conv_base➊

Image

from = -4 是 length(conv_base$layers) + 1 - 4 的简写

现在,我们可以开始微调模型了。我们将使用 RMSprop 优化器,并使用非常低的学习率。之所以使用低学习率,是因为我们希望限制对三层表示所做修改的幅度。太大的更新可能会损害这些表示。

列表 8.27 微调模型

model %>% compile(

loss = "binary_crossentropy",

optimizer = optimizer_rmsprop(learning_rate = 1e-5),

metrics = "accuracy"

)

callbacks <- list(

callback_model_checkpoint(

filepath = "fine_tuning.keras",

save_best_only = TRUE,

monitor = "val_loss"

)

)

history <- model %>% fit(

train_dataset,

epochs = 30,

validation_data = validation_dataset,

callbacks = callbacks

)

现在,我们可以对测试数据集评估这个模型:

model <- load_model_tf("fine_tuning.keras")

result <- evaluate(model, test_dataset)

cat(sprintf("测试准确率:%.3f\n", result["accuracy"]))

测试准确率:0.985

在这里,我们获得了 98.5%的测试准确率(再次强调,你自己的结果可能和这个相差不超过一个百分点)。在原始的 Kaggle 竞赛中,这将是顶级结果之一。但是这并不是一个公平的比较,因为我们使用了预训练的特征,这些特征已经包含了关于猫和狗的先前知识,而竞争对手当时无法使用。

从正面来说,借助现代深度学习技术,我们仅使用了竞赛数据中可用的一小部分训练数据(约占总量的 10%)就达到了这一结果。在可以训练 2,000 个样本和 20,000 个样本之间,存在巨大的差距!

现在,你已经掌握了一套处理图像分类问题的工具,特别是处理小数据集时。

摘要

  • 卷积网络是计算机视觉任务中最好的机器学习模型类型。即使在非常小的数据集上,也可以训练一个具有不错结果的模型。

  • 卷积网络通过学习一系列模块化的模式和概念来表示视觉世界。

  • 在小数据集上,过拟合是主要问题。数据增强是处理图像数据时对抗过拟合的有效手段。

  • 通过特征提取,可以很容易地在新数据集上重用现有的卷积网络。这是处理小型图像数据集的一项有价值的技术。

  • 作为特征提取的补充,你可以使用微调(fine-tuning),它可以根据现有模型先前学到的某些表示来适应一个新的问题。这会进一步提高性能。

  1. ¹ Karen Simonyan 和 Andrew Zisserman,《非常深的卷积网络用于大规模图像识别》,arXiv(2014),arxiv.org/abs/1409.1556

第九章:高级计算机视觉深度学习

本章涵盖

  • 计算机视觉的不同分支:图像分类、图像分割和目标检测

  • 现代卷积神经网络架构模式:残差连接、批量归一化和深度可分离卷积

  • 可视化和解释卷积神经网络学到的技术

上一章为您介绍了计算机视觉的深度学习,通过简单模型(layer_conv_2d() 和 layer_max_pooling_2d() 层的堆叠)和一个简单的用例(二进制图像分类)。但计算机视觉不仅仅是图像分类!本章深入探讨了更多不同应用和高级最佳实践。

9.1 三个基本的计算机视觉任务

到目前为止,我们专注于图像分类模型:输入一张图像,输出一个标签:“这张图像可能包含一只猫;那张可能包含一只狗。”但图像分类只是计算机视觉中深度学习的几种可能应用之一。一般来说,你需要了解以下三个基本的计算机视觉任务:

  • 图像分类—其目标是为图像分配一个或多个标签。它可以是单标签分类(图像只能属于一个类别,排除其他类别),也可以是多标签分类(标记图像所属的所有类别,如图 9.1 中所示)。例如,当您在 Google Photos 应用上搜索关键字时,背后实际上在查询一个非常庞大的多标签分类模型——一个包含超过 20,000 个不同类别、在数百万张图像上训练的模型。

Image

图 9.1 三个主要的计算机视觉任务:分类、分割、检测

  • 图像分割—其目标是将图像“分割”或“划分”为不同的区域,每个区域通常代表一个类别(如在图 9.1 中所示)。例如,当 Zoom 或 Google Meet 在视频通话中为您显示自定义背景时,它正在使用图像分割模型将您的脸与背景分开,以像素级的精度。

  • 目标检测—其目标是在图像中绘制矩形(称为边界框)并将每个矩形与一个类别关联起来,围绕感兴趣的对象。例如,自动驾驶汽车可以使用目标检测模型监视其摄像头视野中的汽车、行人和标志。

计算机视觉的深度学习还包括一些更为专业化的任务,例如图像相似度评分(估算两个图像在视觉上的相似度)、关键点检测(定位图像中感兴趣的属性,例如面部特征)、姿态估计、3D 网格估计等等。但是,要入门计算机视觉应用,图像分类、图像分割和对象检测是每个机器学习工程师应该熟悉的基础。大多数计算机视觉应用都可归结为这三种技术之一。

在前一章中,你已经看到了图像分类的应用。接下来,让我们深入了解图片分割技术。这是一种非常有用且多样化的技术,你可以利用已经学到的知识很容易地处理。

注意,我们不会涉及对象检测,因为这对于入门书来说过于专业化和复杂化。但是,你可以在 keras.rstudio.com/examples 上查看 RetinaNet 的示例,它展示了如何使用 Keras 在 R 中从头构建和训练对象检测模型。

9.2 图像分割示例

使用深度学习进行图像分割是指使用模型为图像中的每个像素分配一个类别,从而将图像分割成不同的区域(例如“背景”和“前景”,或“道路”、“汽车”和“人行道”)。这个广泛的技术类别可以用于开发各种有价值的应用,如图像和视频编辑、自动驾驶、机器人、医学成像等等。关于图像分割,还有两种不同的分类应该知道:

  • 语义分割,其中每个像素被独立归类为语义类别,例如“猫”。如果图像中有两只猫,相应的像素则全部映射到同一个通用的“猫”类别(参见图 9.2)。

  • 实例分割,不仅对图像像素按类别分类,还将解析出各个对象实例。在包含两只猫的图像中,实例分割会将“猫 1”和“猫 2”视为两个不同的像素类别(参见图 9.2)。

在这个示例中,我们将专注于语义分割:再次观察猫和狗的图像,这次我们将学习如何将主体和背景分离开来。

我们将使用Oxford-IIIT 宠物数据集,该数据集包含 7,390 张不同品种的猫和狗的图片,以及每张图片的前景-背景分割蒙版。分割蒙版是图像分割标签的等效形式:它是与输入图像大小相同的图像,其中的每个整数值对应于输入图像中相应像素的类别。在我们的情况下,我们的分割蒙版的像素可以有三个整数值:

Image

图 9.2 语义分割与实例分割

  • 1(前景)

  • 2(背景)

  • 3(轮廓)

让我们开始下载和解压我们的数据集,使用 R 提供的 download.file()和 untar()实用程序。就像第八章一样,我们将使用 fs 包进行文件系统操作:

library(fs)

data_dir <- path("pets_dataset")

dir_create(data_dir)

data_url <- path("http://www.robots.ox.ac.uk/~vgg/data/pets/data")

for (filename in c("images.tar.gz", "annotations.tar.gz")) {

download.file(url = data_url / filename,

destfile = data_dir / filename)

untar(data_dir / filename, exdir = data_dir)

}

输入图片存储在 images/文件夹中的 JPG 文件中(例如 images/Abyssinian_1.jpg),相应的分割蒙版存储在 annotations/trimaps/文件夹中具有相同名称的 PNG 文件中(例如 annotations/trimaps/Abyssinian_1.png)。

让我们准备一个包含输入文件路径列和相应蒙版文件路径列表的数据框(技术上来说,是一个 tibble):

input_dir <- data_dir / "images"

target_dir <- data_dir / "annotations/trimaps/"

image_paths <- tibble::tibble(

input = sort(dir_ls(input_dir, glob = "*.jpg")),

target = sort(dir_ls(target_dir, glob = "*.png")))

为了确保我们将图像与正确的目标匹配,我们对这两个列表进行排序。路径向量进行排序是因为目标和图像路径共享相同的基本文件名。然后,为了帮助我们跟踪路径,并确保我们的输入和目标向量保持同步,我们将它们组合成一个两列的数据框(我们使用 tibble()创建数据框):

tibble::glimpse(image_paths)

Rows: 7,390

Columns: 2

$ input fs::path "pets_dataset/images/Abyssinian_1.jpg", "pets_dataset/...

$ target fs::path "pets_dataset/annotations/trimaps/Abyssinian_1.png", "...

这些输入及其蒙版是什么样子的?让我们快速看一下。我们将使用 TensorFlow 的工具来读取图像,这样我们就可以熟悉 API 了。首先,我们定义一个辅助函数,使用 R 的 plot()函数绘制包含图像的 TensorFlow Tensor:

display_image_tensor <- function(x, …, max = 255,

plot_margins = c(0, 0, 0, 0)) {

if(!is.null(plot_margins))

par(mar = plot_margins)➊

x %>%

as.array() %>%➋

drop() %>%➌

as.raster(max = max) %>%➍

plot(…, interpolate = FALSE)➎

}

绘制图像时默认不留白边。

将张量转换为 R 数组。

drop() 移除大小为 1 的轴。例如,如果 x 是一个带有一个颜色通道的灰度图像,它会将张量形状从 (height, width, 1) 挤压成 (height, width)。

将 R 数组转换为 'raster' 对象。

interpolate = FALSE 告诉 R 图形设备绘制具有锐利边缘的像素,不会在像素之间进行混合或插值。

在 as.raster() 调用中,我们设置 max = 255,因为,就像 MNIST 一样,图像被编码为 uint8。无符号 8 位整数只能在 [0, 255] 范围内编码值。通过设置 max = 255,我们告诉 R 图形设备将像素值 255 绘制为白色,0 绘制为黑色,并对介于两者之间的值进行线性插值以生成不同灰度的色阶。

现在我们可以将图像读入张量,并使用我们的辅助函数 display_image_tensor() 查看它(参见 图 9.3):

library(tensorflow)

image_tensor <- image_paths$input[10] %>%

tf\(io\)read_file() %>%

tf\(io\)decode_jpeg()

str(image_tensor)

<tf.Tensor: shape=(448, 500, 3), dtype=uint8, numpy=…>

display_image_tensor(image_tensor)➊

显示输入图像 Abyssinian_107.jpg。

图片

图 9.3 一个示例图像

我们还将定义一个辅助函数来显示目标图像。目标图像也被读取为 uint8,但这次目标图像张量中只有 (1, 2, 3) 的值。为了绘制它,我们减去 1 使标签范围从 0 到 2,并设置 max = 2,使标签变为 0(黑色)、1(灰色)和 2(白色)。

并且这是其相应的目标(参见 图 9.4):

display_target_tensor <- function(target)

display_image_tensor(target - 1, max = 2)

target <- image_paths$target[10] %>%

tf\(io\)read_file() %>%

tf\(io\)decode_png()

str(target)

<tf.Tensor: shape=(448, 500, 1), dtype=uint8, numpy=…>

display_target_tensor(target)

图片

图 9.4 相应的目标掩码

接下来,让我们将输入和目标加载到两个 TF 数据集中,并将文件拆分为训练集和验证集。由于数据集非常小,我们可以将所有内容加载到内存中:

library(tfdatasets)

tf_read_image <➊

function(path, format = "image", resize = NULL, …) {

img <- path %>%

tf\(io\)read_file() %>%

tf$io[paste0("decode_", format)]

if (!is.null(resize))

img <- img %>%

tf\(image\)resize(as.integer(resize))➌

img

}

img_size <- c(200, 200)

tf_read_image_and_resize <- function(…, resize = img_size)

tf_read_image(…, resize = resize)➍

make_dataset <- function(paths_df) {

tensor_slices_dataset(paths_df) %>%

dataset_map(function(path) {➎

image <- path$input %>%

tf_read_image_and_resize("jpeg", channels = 3L)➏

target <- path$target %>%

tf_read_image_and_resize("png", channels = 1L)➐

target <- target - 1➑

list(image, target)

}) %>%

dataset_cache() %>%➒

dataset_shuffle(buffer_size = nrow(paths_df)) %>%➓

dataset_batch(32)

}

num_val_samples <- 1000⓫

val_idx <- sample.int(nrow(image_paths), num_val_samples)

val_paths <- image_paths[val_idx, ]⓬

train_paths <- image_paths[-val_idx, ]

validation_dataset <- make_dataset(val_paths)

train_dataset <- make_dataset(train_paths)

在此,我们定义一个辅助函数,使用 TensorFlow 操作读取并调整图像的大小。

查找 tf$io 子模块中的 decode_image()、decode_jpeg() 或 decode_png()。

确保使用 as.integer() 将 tf 模块函数调用为整数。

我们将所有内容调整为 200 × 200。

传递给 dataset_map() 的 R 函数使用符号张量进行调用,并且必须返回符号张量。在这里,dataset_map() 接收一个参数,即包含输入和目标图像文件路径的两个标量字符串张量的命名列表。

每个输入图像都有三个通道:RGB 值。

每个目标图像都有一个通道:每个像素的整数标签。

减去 1 以使我们的标签变为 0、1 和 2。

缓存数据集会在第一次运行后将完整数据集存储在内存中。如果您的计算机内存不足,请删除此调用,图像文件将在训练过程中根据需要动态加载。

使用数据中的总样本数作为 buffer_size 进行图像洗牌。请确保在缓存后调用 shuffle。

保留 1,000 个样本用于验证。

将数据拆分为训练集和验证集。

现在是时候定义我们的模型了:

get_model <- function(img_size, num_classes) {

conv <- function(…, padding = "same", activation = "relu")➊➋

layer_conv_2d(…, padding = padding, activation = activation)➊

conv_transpose <- function(…, padding = "same", activation = "relu")➊➋

layer_conv_2d_transpose(…, padding = padding, activation = activation)➊

input <- layer_input(shape = c(img_size, 3))

output <- input %>%

layer_rescaling(scale = 1/255) %>%➌

conv(64, 3, strides = 2) %>%

conv(64, 3) %>%

conv(128, 3, strides = 2) %>%

conv(128, 3) %>%

conv(256, 3, strides = 2) %>%

conv(256, 3) %>%

conv_transpose(256, 3) %>%

conv_transpose(256, 3, strides = 2) %>%

conv_transpose(128, 3) %>%

conv_transpose(128, 3, strides = 2) %>%

conv_transpose(64, 3) %>%

conv_transpose(64, 3, strides = 2) %>%

conv(num_classes, 3, activation = "softmax")➍

keras_model(input, output)

}

model <- get_model(img_size = img_size, num_classes = 3)

定义本地函数 conv() 和 conv_transpose(),以便我们可以避免在每次调用时传递相同的参数:padding = "same"、activation = "relu"。

我们在所有地方都使用 padding = "same" 以避免边缘填充对特征图大小的影响。

不要忘记将输入图像重新缩放到 [0–1] 范围内。

我们在模型末尾使用每像素的三路 softmax 将每个输出像素分类到我们的三个类别之一。

这是模型摘要:

model

图片

模型的前半部分紧密地类似于用于图像分类的卷积神经网络:一堆 Conv2D 层,逐渐增加的过滤器大小。我们通过每次减少两倍来三次降采样我们的图像,最终得到大小为(25, 25, 256)的激活值。这个前半部分的目的是将图像编码成更小的特征图,其中每个空间位置(或像素)包含有关原始图像的大空间块的信息。你可以将其理解为一种压缩。

这个模型的前半部分和你之前见过的分类模型之间的一个重要区别是我们进行降采样的方式:在上一章的分类卷积网络中,我们使用 MaxPooling2D 层来降采样特征图。在这里,我们通过在每个其他卷积层中添加步幅来进行降采样(如果你不记得卷积步幅是如何工作的细节,请参见 8.1.1 节中的“理解卷积步幅”)。我们这样做是因为,在图像分割的情况下,我们非常关心图像中信息的空间位置,因为我们需要将像素级的目标掩码作为模型的输出。当你进行 2×2 最大池化时,你完全破坏了每个池化窗口内的位置信息:你返回每个窗口的一个标量值,而对于窗口中的四个位置之一,你完全不知道值是从哪个位置来的。因此,虽然最大池化层在分类任务中表现良好,但在分割任务中,它们会对我们造成相当大的伤害。与此同时,步幅卷积在降采样特征图的同时保留了位置信息方面做得更好。在本书中,你会注意到,我们倾向于在任何关心特征位置的模型中使用步幅而不是最大池化,比如第十二章中的生成模型。

模型的后半部分是一堆 Conv2DTranspose 层。那些是什么?好吧,模型的前半部分的输出是形状为(25, 25, 256)的特征图,但我们希望我们的最终输出具有与目标掩模相同的形状,(200, 200, 3)。因此,我们需要应用一种反向我们到目前为止所应用的转换的方法——一种将特征图上采样而不是下采样的方法。这就是 Conv2DTranspose 层的目的:你可以将其视为一种学习上采样的卷积层。如果你有一个形状为(100, 100, 64)的输入,并且你通过一个层 layer_conv_2d(128, 3, strides = 2, padding = “same”),你会得到一个形状为(50, 50, 128)的输出。如果你将这个输出通过一个层 layer_conv_2d_transpose(64, 3, strides = 2, padding = “same”),你会得到一个形状为(100, 100, 64)的输出,与原始的相同。因此,通过一堆 Conv2D 层将我们的输入压缩成形状为(25, 25, 256)的特征图后,我们可以简单地应用相应的 Conv2DTranspose 层序列,以得到形状为(200, 200, 3)的图像。

我们现在可以编译并拟合我们的模型:

model %>%

compile(optimizer = "rmsprop",

loss = "sparse_categorical_crossentropy")

callbacks <- list(

callback_model_checkpoint("oxford_segmentation.keras",

save_best_only = TRUE))

history <- model %>% fit(

train_dataset,

epochs = 50,

callbacks = callbacks,

validation_data = validation_dataset

)

注意 在训练过程中,你可能会看到类似 Corrupt JPEG premature end of data segment 的警告。图像数据集并不完美,但 tf$io 模块函数可以优雅地恢复。

让我们显示我们的训练和验证损失(参见图 9.5):

绘制(history)

图像

图 9.5 显示训练和验证损失曲线

你可以看到我们在中途开始过拟合,大约在第 25 个周期。让我们根据验证损失重新加载我们表现最佳的模型,并演示如何使用它来预测一个分割掩码(参见图 9.6):

model <- load_model_tf("oxford_segmentation.keras")

test_image <- val_paths$input[309] %>%

tf_read_image_and_resize("jpeg", channels = 3L)

predicted_mask_probs <

model(test_image[tf$newaxis, , , ])➊

predicted_mask <➋

tf$argmax(predicted_mask_probs, axis = -1L)➌

predicted_target <- predicted_mask + 1

par(mfrow = c(1, 2))

display_image_tensor(test_image)

display_target_tensor(predicted_target)

tf$newaxis 添加了一个批次维度,因为我们的模型期望批次的图像。 model() 返回的 Tensor 形状为 (1, 200, 200, 3),dtype=float32。

predicted_mask 是形状为 (1, 200, 200) 的 Tensor,dtype=int64。

tf\(argmax() 类似于 R 中的 which.max()。一个关键区别是 tf\)argmax() 返回基于 0 的值。tf$argmax(x, axis = -1L) 的 R 基本等价是 apply(x, c(1, 2, 3), which.max) - 1L。

图像

图 9.6 测试图像及其预测的分割掩码

我们预测的掩码中有一些小的伪像。尽管如此,我们的模型似乎工作得很好。

到目前为止,在第八章和第九章的开头,你已经学会了如何进行图像分类和图像分割的基础知识:你已经可以用你所知道的知识做很多事情了。然而,有经验的工程师开发用于解决现实世界问题的卷积网络并不像我们迄今为止在演示中使用的那么简单。你仍然缺少使专家能够迅速准确地决定如何组合最先进模型的基本思维模式和思考过程。为了弥补这一差距,你需要学习架构模式。让我们深入了解一下。

9.3 现代卷积网络架构模式

一个模型的“架构”是创建它所做选择的总和:使用哪些层,如何配置它们,以及以何种排列方式连接它们。这些选择定义了你的模型的假设空间:由模型的权重参数化的梯度下降可以搜索的可能函数空间。就像特征工程一样,一个良好的假设空间编码了你对手头问题及其解决方案的先验知识。例如,使用卷积层意味着你事先知道你的输入图像中存在的相关模式是平移不变的。为了有效地从数据中学习,你需要对你要寻找的内容做出假设。

模型架构通常是成功与失败之间的区别。如果你做出不合适的架构选择,你的模型可能会陷入次优的度量指标中,任何数量的训练数据都无法挽救它。相反,一个良好的模型架构将加速学习,并使你的模型能够有效地利用可用的训练数据,减少对大型数据集的需求。一个好的模型架构是一个减少搜索空间大小或以其他方式使其更容易收敛到搜索空间的好点的架构。就像特征工程和数据策划一样,模型架构的目的是为了使梯度下降更容易解决问题。记住,梯度下降是一个相当愚蠢的搜索过程,所以它需要尽可能多的帮助。

模型架构更像是一门艺术而不是科学。经验丰富的机器学习工程师能够直观地在第一次尝试时拼凑出高性能的模型,而初学者往往很难创建一个能够训练的模型。这里的关键词是直觉:没有人能够清楚地解释什么有效,什么无效。专家依赖于模式匹配,这是他们通过广泛的实践经验获得的能力。你将在本书中培养自己的直觉。然而,这并不全是关于直觉——实际上并没有太多的科学内容,但和任何工程学科一样,都有最佳实践。

在接下来的章节中,我们将回顾一些重要的 convnet 架构最佳实践:特别是残差连接、批量归一化可分离卷积。一旦你掌握了如何使用它们,你就能构建高效的图像模型。我们将把它们应用到我们的猫 vs. 狗分类问题上。

让我们从鸟瞰图开始:系统架构的模块化-层次结构-重用(MHR)公式。

9.3.1 模块化、层次结构和重用

如果您想让一个复杂系统变得简单,您可以应用一个通用的方法:只需将您的复杂无序的复杂性结构化为模块,将模块组织成层次结构,并开始在适当的地方重复使用相同的模块(在这种情况下,“重用”是“抽象”的另一个词)。这就是 MHR 公式(模块化-层次化-重用),它是几乎每个领域中“架构”一词被使用的系统架构的基础。它是任何有意义的复杂系统的组织核心,无论是大教堂、您自己的身体、美国海军还是 Keras 代码库(见图 9.7)。

图像

图 9.7 复杂系统遵循分层结构,并被组织成不同的模块,这些模块被多次重用(例如,您的四肢,它们都是同一个蓝图的变体,或者您的 20 个“手指”)。

如果您是一名软件工程师,您已经非常熟悉这些原则:一个有效的代码库是模块化和层次化的,您不会两次实现相同的事物,而是依靠可重用的类和函数。如果您按照这些原则对代码进行分解,您可以说您正在进行“软件架构”。

深度学习本身只是通过梯度下降对连续优化应用此方法:您采用经典的优化技术(在连续函数空间上的梯度下降),并将搜索空间结构化为模块(层),组织成深层次结构(通常只是一个堆栈,最简单的层次结构),在其中尽可能地重复使用(例如,卷积是关于在不同空间位置重用相同信息)。

同样,深度学习模型架构主要是关于巧妙地利用模块化、层级化和重用。您会注意到,所有流行的卷积神经网络架构不仅被组织成层,它们还被组织成重复的层组(称为“块”或“模块”)。例如,我们在上一章中使用的流行的 VGG16 架构被组织成重复的“卷积,卷积,最大池化”块(见图 9.8)。

此外,大多数卷积神经网络通常具有金字塔状结构(特征层次结构)。例如,回想一下我们在上一章中构建的第一个卷积神经网络中使用的卷积滤波器数量的递增:32,64,128。随着层深度的增加,滤波器的数量也会增加,而特征图的大小相应缩小。您会在 VGG16 模型的块中注意到相同的模式(见图 9.8)。

图像

图 9.8 VGG16 架构:请注意重复的层块和特征图的金字塔状结构。

更深的层次结构本质上是有好处的,因为它们鼓励特征的重复利用,因此也鼓励抽象化。一般来说,窄层次的深度堆栈比大层次的浅堆栈表现更好。然而,你可以堆叠多深的层次有一个限制,这是由于消失的梯度问题所致。这导致我们的第一个基本模型架构模式:残差连接。

关于深度学习研究中消融研究的重要性

深度学习架构通常比设计更进化——它们是通过反复尝试并选择看起来有效的方法而发展起来的。就像在生物系统中一样,如果你拿任何一个复杂的实验性深度学习设置,很可能你可以移除几个模块(或用随机的训练特征替换一些),而不会损失性能。

这一情况被深度学习研究人员面临的激励所加剧:通过使系统比必要复杂,他们可以使其看起来更有趣或更新颖,从而增加他们通过同行评审流程的机会。如果你阅读了大量的深度学习论文,你会注意到它们通常在风格和内容上都被优化,以在主动损害解释的清晰度和结果的可靠性方面通过同行评审。例如,在深度学习论文中,数学很少被用来清晰地形式化概念或推导非显而易见的结果——相反,它被利用作为严肃性的信号,就像一个销售员身上的昂贵西装一样。

研究的目标不应仅仅是发表论文,而应是产生可靠的知识。至关重要的是,理解系统中的因果关系是产生可靠知识的最直接方式。而且有一种非常低成本的方法来探究因果关系:消融研究。消融研究包括系统地尝试去除系统的一部分——使其更简单——以确定其性能实际来自何处。如果你发现 X + Y + Z 给你良好的结果,也试试 X、Y、Z、X + Y、X + Z 和 Y + Z,看看会发生什么。

如果你成为了一名深度学习研究人员,请在研究过程中抛弃噪声:为你的模型做消融研究。始终问自己,“可能存在更简单的解释吗?这种增加的复杂性真的是必要的吗?为什么?”

9.3.2 残差连接

你可能知道电话游戏,也称为英国的 Chinese Whispers 和法国的téléphone arabe,在这个游戏中,一个初始消息被耳语给一名玩家,然后他再耳语给下一个玩家,依此类推。最终的消息与其原始版本几乎没有什么相似之处。这是一个有趣的比喻,说明了在嘈杂的信道上的顺序传输中产生的累积误差。

恰好,顺序深度学习模型中的反向传播与电话游戏非常相似。你有一系列的函数,就像这个:

y = f4(f3(f2(f1(x))))

x <- layer_add(c(x, residual))

保存指向原始输入的指针。这称为残差。

这很容易:只需将图层或图层块的输入添加回其输出即可(参见图 9.9)。残差连接充当信息快捷方式,绕过破坏性或嘈杂的块(例如包含 relu 激活或 dropout 层的块),使早期层的错误梯度信息能够无噪声地传播到深层网络。这项技术是在 2015 年由 ResNet 系列模型(由微软的何等人开发)介绍的。

图片

residual <- x

实际上,您将如下实现残差连接。

图 9.9:处理块周围的残差连接

x <- ...

解决方法很简单:只需强制链中的每个函数为非破坏性-保留前一输入中包含的信息的无噪声版本。实现这一点的最简单方法是使用残差连接。

x <- x %>% layer_conv_2d(64, 3, activation = "relu", padding = "same")

具有不同数量滤波器的残差块

一些输入张量

x <- inputs %>% layer_conv_2d(32, 3, activation = "relu")

此计算块可能是破坏性或嘈杂的,这没问题。

将原始输入添加到层的输出中:因此,最终输出将始终保留有关原始输入的完整信息。

游戏的名字是根据在 f4 的输出上记录的错误来调整链中每个函数的参数(模型的损失)。要调整 f1,您需要通过 f2,f3 和 f4 渗透错误信息。但是,链中的每个连续函数都引入了一定量的噪音。如果您的函数链太深,则此噪音开始淹没梯度信息,并且反向传播停止工作。您的模型根本无法训练。这就是梯度消失的问题。

伪代码中的残差连接

inputs <- layer_input(shape = c(32, 32, 3))

请注意,将输入添加回块的输出意味着输出应具有与输入相同的形状。但是,如果您的块包含具有增加的滤波器数量或最大池化层的卷积层,则情况并非如此。在这种情况下,请使用 1×1 的 layer_conv_2d()层,不带激活函数将残差线性投影到所需的输出形状(请参阅清单 9.2)。您通常会在目标块中的卷积层中使用 padding =“same”,以避免由于填充而导致的空间降采样,并且您会在残差投影中使用步幅以匹配由最大池化层引起的任何降采样(请参阅清单 9.3)。

x <- block(x)

residual <- x

residual <- residual %>% layer_conv_2d(64, 1)

x <- layer_add(c(x, residual))

设置残差。

这是我们创建残差连接的层:它将输出过滤器数量从 32 增加到 64。请注意,我们使用填充 = "same" 来避免由于填充而造成的降采样。

残差仅有 32 个过滤器,因此我们使用 1 × 1 的 layer_conv_2d 将其投影到正确的形状。

现在区块输出和残差具有相同的形状,可以相加。

第 9.3 节 目标区块包括最大池化层的情况

inputs <- layer_input(shape = c(32, 32, 3))

x <- inputs %>% layer_conv_2d(32, 3, 激活 = "relu")

residual <- x➊

x <- x %>%➋

layer_conv_2d(64, 3, 激活 = "relu", 填充 = "same") %>%

layer_max_pooling_2d(2, 填充 = "same")

residual <- residual %>%

layer_conv_2d(64, 1, 步幅 = 2)➌

x <- layer_add(list(x, residual))➍

将残差放在一边。

这是我们创建残差连接的两层区块:它包括一个 2 × 2 的最大池化层。请注意,我们在卷积层和最大池化层中都使用填充 = "same",以避免由于填充而造成的降采样。

我们在残差投影中使用 strides = 2,以匹配由最大池化层创建的降采样。

现在区块输出和残差具有相同的形状,可以相加。

为了使这些想法更加具体,这里有一个简单的卷积网络示例,由一系列区块组成,每个区块由两个卷积层和一个可选的最大池化层组成,并且每个区块周围都有一个残差连接:

inputs <- layer_input(shape = c(32, 32, 3))

x <- layer_rescaling(inputs, 缩放 = 1/255)

residual_block <- function(x, 过滤器, 池化 = FALSE) {➊

residual <- x

x <- x %>%

layer_conv_2d(过滤器, 3, 激活 = "relu", 填充 = "same") %>%

layer_conv_2d(过滤器, 3, 激活 = "relu", 填充 = "same")

if (池化) {➋

x <- x %>% layer_max_pooling_2d(池化大小 = 2, 填充 = "same")

residual <- residual %>% layer_conv_2d(过滤器, 1, 步幅 = 2)

} else if (过滤器 != dim(residual)[4]) {➌

residual <- residual %>% layer_conv_2d(过滤器, 1)

}

layer_add(list(x, residual))

}

outputs <- x %>%

residual_block(过滤器 = 32, 池化 = TRUE) %>%➍

residual_block(过滤器 = 64, 池化 = TRUE) %>%➎

residual_block(过滤器 = 128, 池化 = FALSE) %>%➏

layer_global_average_pooling_2d() %>%

layer_dense(units = 1, 激活 = "sigmoid")

model <- keras_model(inputs = inputs, outputs = outputs)

应用带有残差连接的卷积区块的实用函数,可以选择添加最大池化

如果我们使用最大池化,我们添加一个步幅卷积来将残差投影到预期形状。

如果我们不使用最大池化,只有当通道数量发生变化时,我们才投影残差。

第一个区块

第二个区块;请注意每个区块中过滤器数量的增加。

最后一个区块不需要最大池化层,因为我们将在其后立即应用全局平均池化。

这是我们得到的模型摘要:

model

图像

有了残差连接,可以构建任意深度的网络,而不必担心梯度消失的问题。

现在让我们进入下一个重要的卷积神经网络架构模式:批归一化

9.3.3 批归一化

归一化是一类方法,旨在使机器学习模型看到的不同样本之间更相似,这有助于模型学习和推广到新数据。最常见的数据归一化形式是本书中已经多次使用的形式:通过从数据中减去均值来将数据基准中心化,并通过使用其标准差将数据分配为单位标准差。实际上,这假设数据遵循正态(高斯)分布,并确保此分布居中并缩放到单位方差:

normalize_data <- apply(data, , function(x) (x - mean(x)) / sd(x))

本书中之前的示例在将数据馈送到模型之前对数据进行了归一化。但是,每个网络操作之后,数据归一化可能会引起兴趣:即使进入 Dense 或 Conv2D 网络的数据具有 0 的均值和单位方差,也没有理由预期出来结果仍然是如此。对中间激活进行规范化有用吗?

批归一化就是做到这一点的方法。它是一种层类型(在 Keras 中称为 layer_batch_normalization()),由 Ioffe 和 Szegedy²于 2015 年推出,可以自适应地对数据进行归一化,即使均值和方差在训练过程中随时间变化。在训练过程中,它使用当前批次数据的均值和方差来规范化样本,在推理(当大量具有代表性的数据批次可能不可用时)中,它使用在训练期间看到的数据的批次均值和方差的指数移动平均值。

尽管原论文声称批归一化通过“减少内部协变量转移”起作用,但其真正的机理仍然不为人所知。存在着各种假设,但没有确定的结论。在深度学习中,此类问题是普遍存在的——它不是一门确切的学科,而是一系列不断变革、经验性的最佳工程实践,由于信源不可靠,融合在一起显得有些牵强。 你会发现有时这些书会告诉你如何做某事,但无法令你完全满意地了解为什么会奏效:这是由于我们知道如何做,但我们并不知道为什么会奏效。每当有一个可靠的解释时,我都会提到它。但是批归一化不属于这种情况。

在实践中,批标准化的主要效果似乎是帮助梯度传播——就像残差连接一样——从而允许更深的网络。一些非常深的网络只有包含多个 BatchNormalization 层才能训练。例如,在许多与 Keras 捆绑在一起的高级卷积神经网络架构中,如 ResNet50,EfficientNet 和 Xception 中都大量使用了批标准化。

可以在任何层之后使用 layer_batch_normalization()——如 layer_dense(),layer_conv_2d()等:

x <- …➊

x <- x %>%

layer_conv_2d(32, 3, use_bias = FALSE) %>%➋

layer_batch_normalization()

例如,layer_input(),keras_model_sequential(),或来自另一层的输出

因为 layer_conv_2d()的输出已经标准化,所以该层不需要自己的偏置向量。

layer_dense()和 layer_conv_2d()都涉及到一个bias vector,这是一个学习到的变量,其目的是使层仿射而不仅仅是线性的。例如,layer_conv_2d()返回,概略地说,y = conv(x, kernel) + bias,而 layer_dense()返回 y = dot(x, kernel) + bias。因为归一化步骤将负责将层的输出居中于零,所以在使用 layer_batch_normalization()时不再需要偏置向量,并且可以通过选项 use_bias = FALSE 创建该层。这使得层稍微瘦了一些。

重要的是,我通常建议将前一层的激活放在批标准化层之后(尽管这仍然是一个争论的话题)。因此,与列表 9.4 中所示的做法相反,您应该执行列表 9.5 中所示的操作。

列表 9.4 如何不使用批标准化

x %>%

layer_conv_2d(32, 3, activation = "relu") %>%

layer_batch_normalization()

列表 9.5 如何使用批标准化:激活最后出现

x %>%

layer_conv_2d(32, 3, use_bias = FALSE) %>%➊

layer_batch_normalization() %>%

layer_activation("relu")➋

注意这里缺少激活。

我们在 layer_batch_normalization()之后放置激活。

这种方法的直觉原因是批标准化将使您的输入居中于零,而您的 relu 激活使用零作为保留或丢弃激活通道的枢轴:在激活之前进行归一化可以最大限度地利用 relu 的利用。话虽如此,这种排序最佳实践并不是绝对关键的,因此,如果您先进行卷积,然后激活,然后进行批标准化,您的模型仍将训练,并且您不一定会看到更差的结果。

关于批标准化和微调

Batch Normalization 有很多小窍门。其中一个主要问题与微调相关:当微调包含 BatchNormalization 层的模型时,我建议将这些层冻结(使用 freeze_weights() 将它们的 trainable 属性设置为 FALSE)。否则,它们将不断更新其内部的均值和方差,这可能会干扰周围 Conv2D 层的微小更新:

batch_norm_layer_s3_classname <- class(layer_batch_normalization())[1]

batch_norm_layer_s3_classname

[1] "keras.layers.normalization.batch_normalization.BatchNormalization"

is_batch_norm_layer <- function(x)

inherits(x, batch_norm_layer_s3_classname)

model <- application_efficientnet_b0()

for(layer in model$layers)

if(is_batch_norm_layer(layer))

layer$trainable <- FALSE➊

示例,如何将 trainable <- FALSE 设置为仅冻结 BatchNormalization 层。注意:您也可以调用 freeze_weights(model, which = is_batch_norm_layer) 来实现相同的结果。

现在,让我们来看一下我们系列中的最后一种架构模式:深度可分离卷积。

9.3.4 深度可分离卷积

如果我告诉你,有一种可以直接替换 layer_ conv_2d() 的层,可以让你的模型更小(可训练的权重参数更少)、更轻巧(浮点运算更少),而且在任务上的表现会好几个百分点,你会怎么样?这正是深度可分离卷积层所做的(在 Keras 中为 layer_separable_conv_2d())。这个层会对其输入的每个通道进行独立的空间卷积,然后通过一个逐点卷积(1 × 1 卷积)混合输出通道,如图 9.10 所示。

图片

图 9.10 深度可分离卷积:深度卷积后跟一个逐点卷积

这等价于分离空间特征的学习和通道特征的学习。就像卷积假设图像中的模式与特定位置无关一样,深度可分离卷积假设中间激活的空间位置高度相关,但不同通道高度独立。因为这个假设通常对于深度神经网络学到的图像表示来说是正确的,所以它作为一种有用的先验知识,帮助模型更有效地利用其训练数据。一个具有更强有力的关于它需要处理的信息结构的先验知识的模型是一个更好的模型,只要这些先验知识是准确的。

与普通卷积相比,深度可分离卷积需要较少的参数并且涉及较少的计算,同时具备相当的表征能力。它产生的模型较小,收敛速度更快,且更不容易过拟合。当你使用有限数据从头开始训练小型模型时,这些优势尤为重要。

当涉及到大规模模型时,深度可分离卷积是 Xception 架构的基础,Xception 是一个高性能的卷积神经网络模型,已经内置在 Keras 中。你可以在论文《Xception: Deep Learning with Depthwise Separable Convolutions》中了解更多关于深度可分离卷积和 Xception 的理论基础。³

硬件,软件和算法的协同演化

考虑一个具有 3 × 3 窗口,64 个输入通道和 64 个输出通道的普通卷积操作。它使用了 3 * 3 * 64 * 64 = 36,864 个可训练参数,并且在应用到图像时,运行的浮点操作次数与这个参数数量成比例。此外,考虑一个等效的深度可分离卷积:它只需要 3 * 3 * 64 + 64 * 64 = 4,672 个可训练参数,以及相对较少的浮点操作。当过滤器的数量或卷积窗口的大小变大时,这种效率改进只会增加。

因此,你可能会期望深度可分离卷积的速度明显更快,对吗?等等。如果你是在编写这些算法的简单 CUDA 或 C 实现,这当然是正确的——实际上,当在 CPU 上运行时,你确实能够看到有意义的加速效果,因为底层实现是并行化的 C。但在实践中,你可能正在使用 GPU,并且你在其上执行的代码远非“简单”的 CUDA 实现:它是一个cuDNN kernel,这是一段被极端优化的代码,甚至进行到每个机器指令。投入大量精力来优化这段代码是有道理的,因为基于 NVIDIA 硬件的 cuDNN 卷积每天负责计算出许多 exaFLOPS。但这种极端微观优化的一个副作用是,即使是具有明显内在优势的替代方法(如深度可分离卷积)也很难在性能上与之竞争。

尽管反复要求 NVIDIA,深度可分离卷积并没有得到几乎与常规卷积相同水平的软件和硬件优化,并且因此它们仍然只是与常规卷积一样快,即使它们使用的参数和浮点运算量减少了平方倍。但请注意,即使深度可分离卷积没有加速,使用它们仍然是一个好主意:它们较低的参数数量意味着你不太容易过拟合,而它们的假设是通道应该是不相关的,这导致模型收敛更快并且表示更加健壮。

在这种情况下的轻微不便可能会在其他情况下成为一道不可逾越的障碍:因为整个深度学习的硬件和软件生态系统都已经被微调以适应一组非常特定的算法(特别是通过反向传播训练的卷积网络),所以偏离传统道路的成本极高。

如果您尝试使用替代算法,例如无梯度优化或脉冲神经网络,那么您设计的头几个并行的 C++ 或 CUDA 实现将比一个老式的卷积网络慢几个数量级,无论您的想法多么聪明和高效。即使它明显更好,说服其他研究人员采用您的方法也将是一项艰巨的任务。

你可以说现代深度学习是硬件、软件和算法之间的共同演进过程的产物:NVIDIA GPU 和 CUDA 的可用性导致了反向传播训练卷积网络的早期成功,这导致了 NVIDIA 优化其硬件和软件以适应这些算法,进而导致了研究社区围绕这些方法的巩固。在这一点上,要想找到一条不同的道路将需要对整个生态系统进行多年的重新设计。

9.3.5 把它放在一起:一个迷你的 Xception 类似的模型

作为提醒,这是您迄今为止学到的卷积网络架构原则:

  • 您的模型应该被组织成重复的层,通常由多个卷积层和一个最大池化层组成。

  • 你的层中过滤器的数量应随着空间特征图的大小减少而增加。

  • 深而窄比宽而浅更好。

  • 在层块周围引入残差连接有助于您训练更深的网络。

  • 在您的卷积层之后引入批量归一化层可能是有益的。

  • 将 layer_conv_2d() 替换为 layer_separable_conv_2d() 可能是更加参数有效的。

让我们把这些想法结合到一个单一的模型中。它的架构将类似于 Xception 的一个较小版本,我们将把它应用到上一章中的狗与猫任务中。对于数据加载和模型训练,我们将简单地重用我们在第 8.2.5 节中使用的设置,但我们将用以下卷积网络替换模型定义:

data_augmentation <- keras_model_sequential() %>%➊

layer_random_flip("horizontal") %>%

layer_random_rotation(0.1) %>%

layer_random_zoom(0.2)

inputs <- layer_input(shape = c(180, 180, 3))

x <- inputs %>%

data_augmentation() %>%

layer_rescaling(scale = 1 / 255)➋

x <- x %>%

layer_conv_2d(32, 5, use_bias = FALSE)➌

for (size in c(32, 64, 128, 256, 512)) {➍

residual <- x

x <- x %>%

layer_batch_normalization() %>%

layer_activation("relu") %>%

layer_separable_conv_2d(size, 3, padding = "same", use_bias = FALSE) %>%

layer_batch_normalization() %>%

layer_activation("relu") %>%

layer_separable_conv_2d(size, 3, padding = "same", use_bias = FALSE) %>%

layer_max_pooling_2d(pool_size = 3, strides = 2, padding = "same")

residual <- residual %>%

layer_conv_2d(size, 1, strides = 2, padding = "same", use_bias = FALSE)

x <- layer_add(list(x, residual))

}

outputs <- x %>%

layer_global_average_pooling_2d() %>%➎

layer_dropout(0.5) %>%➏

layer_dense(1, activation = "sigmoid")

model <- keras_model(inputs, outputs)

train_dataset <- image_dataset_from_directory(

"cats_vs_dogs_small/train",

image_size = c(180, 180),

batch_size = 32

)

validation_dataset <- image_dataset_from_directory(

"cats_vs_dogs_small/validation",

image_size = c(180, 180),

batch_size = 32

)

model %>%

compile(

loss = "binary_crossentropy",

optimizer = "rmsprop",

metrics = "accuracy"

)

history <- model %>%

fit(

train_dataset,

epochs = 100,

validation_data = validation_dataset)

我们使用与以前相同的数据增强配置。

不要忘记输入重新缩放!

请注意,可分离卷积背后的假设“特征通道在很大程度上是独立的”并不适用于 RGB 图像!红色、绿色和蓝色通道在自然图像中实际上高度相关。因此,我们模型中的第一层是一个常规的 layer_conv_2d()层。之后我们将开始使用 layer_separable_conv_2d()。

我们应用一系列逐渐增加特征深度的卷积块。每个块包含两个批归一化的深度可分离卷积层和一个最大池化层,并在整个块周围具有残差连接。

在原始模型中,我们在 layer_dense()之前使用了 layer_flatten()。这里,我们使用了 layer_global_average_pooling_2d()。

就像在原始模型中一样,我们为正则化添加了一个 dropout 层。

这个卷积网络的总参数数量为 721,857,略低于我们在第八章中定义的原始模型的 991,041 个参数(清单 8.7),但仍处于同一水平区间。图 9.11 显示了其训练和验证曲线。

Image

图 9.11 带有 Xception 类似架构的训练和验证指标

你会发现我们的新模型的测试准确率为 90.8%,而上一章中的简单模型为 81.4%。正如你所看到的,遵循最佳实践架构确实会立即对模型性能产生巨大影响!

在这一点上,如果您想进一步提高性能,您应该开始系统地调整架构的超参数——这是我们将在第十三章中详细介绍的一个主题。我们没有在这里执行这一步,因此前述模型的配置纯粹基于我们讨论的最佳实践,再加上,在评估模型大小时,一点直觉。

请注意,这些架构最佳实践与计算机视觉一般相关,不仅限于图像分类。例如,Xception 被用作 DeepLabV3 的标准卷积基础,DeepLabV3 是一种流行的最先进的图像分割解决方案。⁴

这就是我们对卷积神经网络(convnet)关键架构最佳实践的介绍。有了这些原则,您将能够在各种计算机视觉任务中开发性能更高的模型。您现在已经在成为熟练的计算机视觉从业者的道路上取得了良好的进展。为了进一步加深您的专业知识,我们还需要涵盖最后一个重要主题:解释模型如何得出其预测结果。

9.4 解释卷积神经网络学到的内容

在构建计算机视觉应用程序时的一个基本问题是可解释性:当您所能看到的只是一辆卡车时,为什么您的分类器会认为特定的图像包含冰箱呢?这尤其与使用深度学习来补充人类专业知识的用例相关,例如在医学成像用例中。我们将通过让您熟悉一系列不同的技术来结束本章,以便可视化卷积神经网络学习的内容并理解它们所做出的决定。

人们经常说深度学习模型是“黑盒子”:它们学习的表示很难以人类可读的形式提取和呈现。尽管对于某些类型的深度学习模型来说这在一定程度上是正确的,但对于卷积神经网络来说绝对不是。卷积神经网络学到的表示非常适合可视化,这在很大程度上是因为它们是视觉概念的表示。自 2013 年以来,已经开发出了各种各样的技术来可视化和解释这些表示。我们不会概述所有这些技术,但我们将涵盖三种最易于访问和有用的技术:

  • 可视化中间卷积神经网络输出(中间激活)——有助于理解连续卷积神经网络层如何转换其输入,并对单个卷积神经网络滤波器的含义有初步了解

  • 可视化卷积神经网络滤波器——有助于准确了解卷积神经网络中每个滤波器对应的视觉模式或概念

  • 可视化图像中类别激活的热图——有助于理解图像的哪些部分被识别为属于给定类别,从而使您能够在图像中定位对象

对于第一种方法——激活可视化——我们将使用在第 8.2 节中针对狗与猫分类问题从头开始训练的小型卷积神经网络。对于接下来的两种方法,我们将使用一个预训练的 Xception 模型。

9.4.1 可视化中间激活

可视化中间激活包括显示模型中各种卷积和池化层返回的值,给定某个输入(层的输出通常称为其激活,激活函数的输出)。这提供了一种查看输入如何被网络学习到的不同滤波器分解的视图。我们希望可视化具有三维特征图,宽度、高度和深度(通道)。每个通道编码相对独立的特征,因此正确的方式是将这些特征图的内容独立地绘制为 2D 图像。让我们从加载你在第 8.2 节中保存的模型开始:

model <- load_model_tf("convnet_from_scratch_with_augmentation.keras")

model

图片

接下来,我们将获取一个输入图片——一张猫的照片,不属于网络训练的图片部分。

列表 9.6 单个图像的预处理

img_path <- get_file(➊

fname = "猫.jpg"

origin = "https://img-datasets.s3.amazonaws.com/cat.jpg")

img_tensor <- img_path %>%➋

tf_read_image(resize = c(180, 180))

下载一个测试图片。

读取并将图像调整为形状为(180, 180, 3)的 float32 张量。

让我们显示图片(见图 9.12)。

列表 9.7 显示测试图片

display_image_tensor(img_tensor)

图片

图 9.12 测试猫图片

为了提取我们想要查看的特征图,我们将创建一个 Keras 模型,该模型以图像批作为输入,并输出所有卷积和池化层的激活。

列表 9.8 实例化返回层激活的模型

conv_layer_s3_classname <-➊

class(layer_conv_2d(NULL, 1, 1))[1]

pooling_layer_s3_classname <-

class(layer_max_pooling_2d(NULL))[1]

is_conv_layer <- function(x) inherits(x, conv_layer_s3_classname)

is_pooling_layer <- function(x) inherits(x, pooling_layer_s3_classname)

layer_outputs <- list()

for (layer in model$layers)

if (is_conv_layer(layer) || is_pooling_layer(layer))

layer_outputs[[layer\(name]] <- layer\)output➋

activation_model <- keras_model(inputs = model$input,➌

outputs = layer_outputs)

创建虚拟的卷积和池化层来确定 S3 类名是什么。这通常是一个很长的字符串,比如“keras.layers.convolutional.Conv2D”,但由于它可能因 Tensorflow 版本而改变,最好不要硬编码它。

提取所有 Conv2D 和 MaxPooling2D 层的输出,并将它们放入一个命名列表中。

创建一个模型,将返回这些输出,给定模型输入。

当输入图像时,该模型以原始模型中的层激活值作为列表返回。这是你在本书中第一次遇到实际中的多输出模型,自第七章学习以来,到目前为止,你所看到的模型都只有一个输入和一个输出。这个模型有一个输入和九个输出:每个层激活一个输出。

列表 9.9 使用模型计算层激活

activations <- activation_model %>%

predict(img_tensor[tf$newaxis, , , ])➊➋

predict() 返回九个 R 数组的列表:每个层激活一个数组。

调用 [tf$newaxis, , , ] 来将 img_tensor 的形状从 (180, 180, 3) 改变为 (1, 180, 180, 3)。换句话说,添加了一个批量维度,因为模型期望输入是一批图像,而不是单个图像。

因为我们在构建模型时传递了一个带有输出名称的命名列表,所以当我们在模型上调用 predict() 时,我们会得到一个带有 R 数组名称的命名列表:

str(activations)

9 项列表

$ conv2d_15      : num [1, 1:178, 1:178, 1:32] 0.00418 0.0016 0.00453 0 …

$ max_pooling2d_9: num [1, 1:89, 1:89, 1:32] 0.01217 0.00453 0.00742 0.00514

$ conv2d_14      : num [1, 1:87, 1:87, 1:64] 0 0 0 0 0.00531 …

$ max_pooling2d_8: num [1, 1:43, 1:43, 1:64] 0 0 0.00531 0 0 …

$ conv2d_13      : num [1, 1:41, 1:41, 1:128] 0 0 0.0288 0 0.0342 …

$ max_pooling2d_7: num [1, 1:20, 1:20, 1:128] 0.0313 0.0288 0.0342 0.4004 0.

$ conv2d_12      : num [1, 1:18, 1:18, 1:256] 0 0 0 0 0 0 0 0 0 0 …

$ max_pooling2d_6: num [1, 1:9, 1:9, 1:256] 0 0 0 0 0 0 0 0 0 0 …

[列表输出已截断]

让我们仔细看看第一层的激活:

first_layer_activation <- activations[[ names(layer_outputs)[1] ]]

dim(first_layer_activation)

[1] 1 178 178 32

这是一个 178 × 178 的特征图,有 32 个通道。让我们尝试绘制原始模型的第一层激活的第五个通道(参见 图 9.13)。

列表 9.10 可视化第五个通道

plot_activations <- function(x, …) {

x <- as.array(x)➊

if(sum(x) == 0)➋

return(plot(as.raster("gray")))

rotate <- function(x) t(apply(x, 2, rev))➌

image(rotate(x), asp = 1, axes = FALSE, useRaster = TRUE,

col = terrain.colors(256), …)

}

plot_activations(first_layer_activation[, , , 5])

将张量转换为数组。

所有零通道(即无激活)都被绘制成灰色矩形,因此它们很容易区分。

将图像顺时针旋转以便更容易查看。

Image

图 9.13 测试猫图片上第一层激活的第五个通道

这个通道似乎编码了一个对角边缘检测器——但请注意,你自己的通道可能会有所不同,因为卷积层学习的特定过滤器并不确定。

现在让我们绘制网络中所有激活的完整可视化(见图 9.14)。我们将提取并绘制每个层激活中的每个通道,并将结果堆叠在一个大网格中,通道并排堆叠。

列表 9.11 可视化每个中间激活中的每个通道

for (layer_name in names(layer_outputs)) {➊

layer_output <- activations[[layer_name]]

n_features <- dim(layer_output) %>% tail(1) ➋

par(mfrow = n2mfrow(n_features, asp = 1.75),➌

mar = rep(.1, 4), oma = c(0, 0, 1.5, 0))

for (j in 1:n_features)

plot_activations(layer_output[, , , j])➍

title(main = layer_name, outer = TRUE)

}

迭代激活(和相应层的名称)。

层激活的形状为(1, 高度, 宽度, 特征数)。

准备在一个图中显示该激活中的所有通道。

这是一个单一的通道(或特征)。

这里有几件事情要注意:

  • 第一层充当各种边缘检测器的集合。在这个阶段,激活几乎保留了初始图片中的所有信息。

  • 随着深入,激活变得越来越抽象,越来越难以直观解释。它们开始编码更高级别的概念,如“猫耳朵”和“猫眼睛”。更深层的表述携带的关于图像视觉内容的信息越来越少,而与图像类别相关的信息越来越多。

图片

图 9.14 测试猫图片中每一层激活的每个通道

  • 随着层的深度,激活的稀疏性增加:在第一层中,几乎所有滤波器都被输入图像激活,但在后续层中,越来越多的滤波器是空白的。这意味着滤波器编码的模式在输入图像中找不到。

我们刚刚证明了深度神经网络学习的表示的一个重要的普遍特性:随着层的深度,由层提取的特征变得越来越抽象。更高层的激活携带的关于正在查看的特定输入的信息越来越少,而携带的关于目标的信息越来越多(在本例中,图像的类别:猫或狗)。深度神经网络有效地充当一种信息蒸馏管道,原始数据(在本例中是 RGB 图片)被反复转换,以过滤掉不相关的信息(例如,图像的特定视觉外观),并放大和精炼有用的信息(例如,图像的类别)。

这类似于人类和动物感知世界的方式:在观察一个场景几秒钟后,人类可以记住其中存在的抽象对象(自行车,树),但不能记住这些对象的具体外观。事实上,如果你试图从记忆中画一个通用自行车,很可能你甚至不能做到远远正确,尽管你一生中见过成千上万辆自行车(参见,例如,图 9.15)。现在就试试吧:这种效应绝对是真实的。你的大脑已经学会完全抽象化它的视觉输入——将其转换为高级视觉概念,同时过滤掉无关的视觉细节——使得记住你周围的事物的外观变得极其困难。

图片

图 9.15 左图:试图从记忆中画出的自行车;右图:原理图中自行车的样子

9.4.2 可视化卷积神经网络滤波器

通过梯度上升在输入空间,可以轻松地检查卷积神经网络学习到的滤波器对应的视觉模式:对卷积神经网络输入图像的值应用梯度下降,以最大化特定滤波器的响应,从一个空白输入图像开始。得到的输入图像将是所选择的滤波器响应最大的图像。

让我们尝试使用在 ImageNet 上预训练的 Xception 模型的滤波器。这个过程很简单:我们将构建一个损失函数,以最大化给定卷积层中给定滤波器的值,然后我们将使用随机梯度下降来调整输入图像的值,以最大化这个激活值。这将是我们的第二个例子,涉及利用 GradientTape()对象的低级梯度下降循环(第一个例子在第二章中)。首先,让我们实例化 Xception 模型,加载在 ImageNet 数据集上预训练的权重。

列表 9.12 实例化 Xception 卷积基础

model <- application_xception(

weights = "imagenet",

include_top = FALSE➊

)

对于这种用例,分类层不相关,因此我们不包括模型的顶层。

我们对模型的卷积层——Conv2D 和 SeparableConv2D 层感兴趣。我们需要知道它们的名称,以便可以检索它们的输出。让我们按深度顺序打印它们的名称。

列表 9.13 打印 Xception 中所有卷积层的名称

for (layer in model$layers)

if(any(grepl("Conv2D", class(layer))))

print(layer$name)

[1] "block1_conv1"

[1] "block1_conv2"

[1] "block2_sepconv1"

[1] "block2_sepconv2"

[1] "conv2d_29"

[1] "block14_sepconv1"

[1] "block14_sepconv2"

你会注意到这里的可分离卷积 2D 层都被命名为类似 block6_sepconv1、block7_sepconv2 等。Xception 被结构化为块,每个块包含多个卷积层。

现在让我们创建一个第二个模型,它返回特定层的输出 - 一个 特征提取器 模型。因为我们的模型是一个 Functional API 模型,它是可检查的:我们可以查询其层的输出,并在新模型中重用它。不需要复制整个 Xception 代码。

Listing 9.14 创建特征提取器模型

layer_name <- "block3_sepconv1"

layer <- model %>% get_layer(name = layer_name)

feature_extractor <- keras_model(inputs = model$input,

outputs = layer$output)

您可以将此替换为 Xception 卷积基中的任何层的名称。

这是我们感兴趣的层对象。

我们使用 model\(input 和 layer\)output 来创建一个模型,给定一个输入图像,返回我们目标层的输出。

要使用此模型,只需在一些输入数据上调用它(请注意,Xception 需要通过 xception_preprocess_input() 函数对输入进行预处理)。

Listing 9.15 使用特征提取器

activation <- img_tensor %>%

.[tf$newaxis, , , ] %>%

xception_preprocess_input() %>%

feature_extractor()

str(activation)

<tf.Tensor: shape=(1, 44, 44, 256), dtype=float32, numpy=…>

请注意,这次我们直接调用模型,而不是使用 predict(),并且 activation 是一个 tf.Tensor,而不是一个 R 数组。(很快会详细介绍。)

让我们使用我们的特征提取模型定义一个函数,它返回一个标量值,量化给定输入图像在给定层中“激活”了多少给定滤波器。这就是我们在梯度上升过程中要最大化的“损失函数”:

compute_loss <- function(image, filter_index) {

activation <- feature_extractor(image)

filter_index <- as_tensor(filter_index, "int32")

filter_activation <-

activation[, , , filter_index, style = "python"]

mean(filter_activation[, 3:-3, 3:-3])

}

损失函数接受一个图像张量和我们正在考虑的滤波器的索引(一个整数)。

在这里,我们将 filter_index 转换为整数张量,以确保在运行此函数时(即通过 tf_function()),我们有一致的行为。

使用 style="python" 告诉 [filter_index 是基于零的。

请注意,我们通过仅涉及损失中的非边界像素来避免边界伪影;我们丢弃激活边缘的前两个像素。

返回滤波器激活值的平均值。

注意 我们稍后将使用 tf_function() 跟踪 compute_loss(),并将 filter_index 作为跟踪张量。当前仅支持 Python 风格(基于 0 的)索引,当索引本身是张量时(这可能会在将来更改)。我们通知 [filter_index 使用 style = “python” 是基于零的。

predict(model, x) 和 model(x) 之间的区别

在上一章中,我们使用 predict(x) 进行特征提取。在这里,我们使用 model(x)。这是为什么?

Both y <- predict(model, x) 和 y <- model(x)(其中 x 是输入数据的数组)意味着“在 x 上运行模型并检索输出 y”。然而,它们并不完全相同。

predict() 循环遍历数据批次(实际上,您可以通过 predict(x, batch_size = 64) 指定批次大小),并提取输出的 R 数组值。它在原理上等同于这样:

predict <- function(model, x) {

y <- list()

for(x_batch in split_into_batches(x)) {

y_batch <- as.array(model(x_batch))

y[[length(y)+1]] <- y_batch

}

unsplit_batches(y)

}

这意味着 predict() 调用可以扩展到非常大的数组。同时,model(x) 在内存中进行,不具有扩展性。另一方面,predict() 不可微分:如果你在 GradientTape() 范围内调用它,就无法检索到其梯度。

当你需要检索模型调用的梯度时,应该使用 model(x),而当你只需要输出值时应该使用 predict()。换句话说,除非你正在编写低级梯度下降循环(就像我们现在这样),否则始终使用 predict()。

让我们设置梯度上升步骤函数,使用 GradientTape()。一个不明显的技巧来帮助梯度下降过程顺利进行是通过将梯度张量归一化,即通过将其除以其 L2 范数(张量中值的平方的平均值的平方根)。这确保了对输入图像所做的更新的大小始终在相同的范围内。

第 9.16 节 损失最大化通过随机梯度上升

gradient_ascent_step <-

function(image, filter_index, learning_rate) {

with(tf$GradientTape() %as% tape, {

tape$watch(image)➊

loss <- compute_loss(image, filter_index)➋

})

grads <- tape$gradient(loss, image)➌

grads <- tf\(math\)l2_normalize(grads)➍

image + (learning_rate * grads)➎

}

显式观察图像张量,因为它不是 TensorFlow 变量(只有变量在梯度磁带中自动观察)。

计算损失标量,指示当前图像激活过滤器的程度。

计算损失相对于图像的梯度。

应用“梯度归一化技巧”。

将图像向更有效激活我们目标过滤器的方向稍微移动一点。返回更新后的图像,以便我们可以在循环中运行步骤函数。

现在我们有了所有的要素。让我们将它们组合成一个 R 函数,该函数以图层名称和过滤器索引作为输入,并返回表示最大化指定过滤器激活的模式的张量。请注意,我们将使用 tf_function() 来加速它。

第 9.17 节 生成滤波器可视化的函数

c(img_width, img_height) %<-% c(200, 200)

generate_filter_pattern <- tf_function(function(filter_index) {

iterations <- 30➊

learning_rate <- 10➋

image <- tf\(random\)uniform(➌

minval = 0.4, maxval = 0.6,

shape = shape(1, img_width, img_height, 3)

)

for (i in seq(iterations))➍

image <- gradient_ascent_step(image, filter_index, learning_rate)

图像[1, , , ]➎

})

用随机值初始化图像张量。(Xception 模型期望输入值在 [0, 1] 范围内,所以这里选择以 0.5 为中心的范围。)

应用的梯度上升步骤数

单步幅的振幅

重复更新图像张量的值,以最大化我们的损失函数。

去掉批次维度并返回图像。

结果图像张量是一个形状为 (200, 200, 3) 的浮点数组,其值可能不是在 [0, 255] 范围内的整数。因此,我们需要对这个张量进行后处理,将其转换为可显示的图像。我们使用以下简单的实用函数来做到这一点。我们将使用张量操作并将其包装在 tf_function() 中以加快速度。

将张量转换为有效图像的实用函数清单 9.18

deprocess_image <- tf_function(function(image, crop = TRUE) {

图像 <- 图像 - mean(图像)➊

图像 <- 图像 / tf\(math\)reduce_std(图像)➋

图像 <- (图像 * 64) + 128

图像 <- tf$clip_by_value(图像, 0, 255)

if(crop)

图像 <- 图像[26:-26, 26:-26, ]➌

图像

})

mean() 调用了 tf\(math\)reduce_mean()。

将图像值标准化到 [0, 255] 范围内。

中心裁剪以避免边缘伪影。

让我们试一试(参见 图 9.16):

generate_filter_pattern(filter_index = as_tensor(2L)) %>%

deprocess_image() %>%

display_image_tensor()

图像

图 9.16:layer block3_sepconv1 中第二通道响应最大的模式

请注意,此处将 filter_index 转换为 as_tensor()。我们这样做是因为 tf_ function() 会为其调用的每种唯一方式编译一个单独的优化函数,并且不同的常量文字会计为唯一的调用签名。如果我们在这里没有调用 as_tensor(),那么在接下来的循环中绘制前 64 个激活时,tf_function() 将追踪并编译 generate_filter_pattern() 64 次!然而,使用张量调用 tf_function() 装饰的函数,即使是一个常量张量,对于 tf_function() 来说并不算是一个唯一的函数签名,而 generate_filter_pattern() 只会被追踪一次。

看起来在 layer block3_sepconv1 的第三个过滤器对水平线条模式有响应,有点像水或毛皮。

现在有趣的部分开始了:你可以开始可视化图层中的每个滤波器,甚至是模型中每个图层中的每个滤波器。

在一层生成所有滤波器响应模式的网格清单 9.19

par(mfrow = c(8, 8))

for (i in seq(0, 63)) {➊

generate_filter_pattern(filter_index = as_tensor(i)) %>%

deprocess_image() %>%

display_image_tensor(plot_margins = rep(.1, 4))

}

生成并绘制图层中前 64 个滤波器的可视化。

这些滤波器可视化(见图 9.17)告诉你一些关于 CNN 层次如何看待世界的方法:在 CNN 中的每个层次学习了一些这样的滤波器,它们的输入可以表示为这些滤波器的组合。这与傅里叶变换将信号分解为一堆余弦函数的方式类似。在这些卷积网络过滤器中,你越深入模型,它们就变得越来越复杂和精细:

Image

图 9.17 层次 block2_sepconv1,block4_sepconv1 和 block8_sepconv1 的一些滤波器模式。

  • 模型中第一层的滤波器编码了简单的方向边缘和颜色(在某些情况下是彩色边缘)。

  • 神经网络层次更高的滤波器(如 block4_sepconv1)编码了由边缘和颜色组合而成的简单纹理。

  • 更高层次的滤波器开始类似于自然图像中的纹理:羽毛、眼睛,树叶等等。

9.4.3 可视化类别激活热图

最后我们介绍一种可视化技术--这对于理解给定图像的哪些部分导致了卷积网络的最终分类决策非常有用。尤其是在分类错误(称为模型可解释性问题域)的情况下,这对“调试”卷积网络的决策过程非常有帮助。它还可以帮助你在图像中定位特定的对象。

这种技术的通用类别被称为类别激活地图(CAM)可视化,它由产生输入图像上的类别激活热图组成。类别激活热图是与特定输出类别相关联的分数字逐渐计算每个输入图像中的所有位置,表示每个位置相对于考虑的类别有多重要。例如,给定输入到狗猫卷积网络的图像,CAM 可视化将允许生成一个关于“猫”的热图,指示图像的不同部分有多像猫,以及一个关于“狗”的热图,指示图像的不同部分有多像狗。

我们将使用的具体实现是一个名为“Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization”的文章中所描述的。

Grad-CAM 由卷积层的输出特征映射、一个输入图像和将每个通道在特征映射中加权的类别相对于通道的梯度组成。直观来说,理解这个技巧的一种方式是,在将“输入图片激发不同通道的强度”空间图加权为“每个类别对于通道的重要性”,形成一个“输入图片激发类别的强度”空间图。我们使用预训练的 Xception 模型来演示这个技巧。

代码清单 9.20:加载预训练权重的 Xception 网络。

model <-application_xception(weights = "imagenet")➊

请注意,我们在顶部包括了密集连接的分类器;在所有先前的情况下,我们都将其丢弃。

考虑一下图 9.18 中显示的两只非洲象的图像,可能是一只母象和一只小象,在大草原上漫步。让我们将这个图像转换为 Xception 模型可以读取的内容:该模型是根据几条规则进行训练的,这些规则封装在 xception_preprocess_input() 实用函数中。

Image

图 9.18 非洲象测试图片

因此,我们需要加载图像,将其调整大小为 299 × 299,将其转换为 float32 张量,并应用这些预处理规则。

Listing 9.21 预处理 Xception 输入图像

img_path <- get_file(➊

fname = "elephant.jpg",

origin = "https://img-datasets.s3.amazonaws.com/elephant.jpg")

img_tensor <- tf_read_image(img_path, resize = c(299, 299))➋

preprocessed_img <- img_tensor[tf$newaxis, , , ] %>%➌

xception_preprocess_input()➍

下载图像并在路径 img_path 下存储到本地。

将图像读取为张量并将其调整大小为 299 × 299。img_tensor 是形状为 (299, 299, 3) 的 float32 类型。

添加一个维度,将数组转换为大小为 (1, 299, 299, 3) 的批次。

对批处理进行预处理(这样可以进行通道颜色归一化)。

现在,您可以在图像上运行预训练网络,并将其预测向量解码回可读的人类格式:

preds <- predict(model, preprocessed_img)

str(preds)

Image

imagenet_decode_predictions(preds, top=3)[[1]]

Image

该图像的前三个预测类别如下:

  • 非洲象(概率为 90%)

  • 带象牙的象(概率为 5%)

  • 印度象(概率为 2%)

网络已经识别出图像中包含未确定数量的非洲象。预测向量中最大激活的条目是对应于“非洲象”类的条目,索引为 387:

which.max(preds[1, ])

[1] 387

要可视化图像的哪些部分最像非洲象,让我们设置 Grad-CAM 过程。首先,我们创建一个模型,将输入图像映射到最后一个卷积层的激活。

Listing 9.22 设置返回最后一个卷积输出的模型

last_conv_layer_name <- "block14_sepconv2_act"

classifier_layer_names <- c("avg_pool", "predictions")➊

last_conv_layer <- model %>% get_layer(last_conv_layer_name)

last_conv_layer_model <- keras_model(model$inputs,

last_conv_layer$output)

Xception 模型中的最后两个层的名称

其次,我们创建一个模型,将最后一个卷积层的激活映射到最终的类预测。

Listing 9.23 在最后一个卷积输出的顶部重新应用分类器

classifier_input <- layer_input(batch_shape = last_conv_layer\(output\)shape)

x <- classifier_input

for (layer_name in classifier_layer_names)

x <- get_layer(model, layer_name)(x)

classifier_model <- keras_model(classifier_input, x)

我们计算关于最后一个卷积层的激活与输入图片之间的梯度。

列表 9.24 检索最高预测类别的梯度

with (tf$GradientTape() %as% tape, {

last_conv_layer_output <- last_conv_layer_model(preprocessed_img)

tape$watch(last_conv_layer_output)➊

preds <- classifier_model(last_conv_layer_output)

top_pred_index <- tf$argmax(preds[1, ])

top_class_channel <- preds[, top_pred_index, style = "python"]➋

})

grads <- tape$gradient(top_class_channel, last_conv_layer_output)➌

计算最后一个卷积层的激活并使其纳入到计算图中。

检索与最高预测类别相对应的激活通道。

这是最高预测类别对于最后一个卷积层的输出特征图的梯度。

现在我们将应用池化操作和重要性加权来获得类别激活的热图。

列表 9.25 梯度池化和通道重要性加权

pooled_grads <- mean(grads, axis = c(1, 2, 3), keepdims = TRUE)➊

heatmap <

(last_conv_layer_output * pooled_grads) %>%➌➍➎➏

mean(axis = -1) %>%➐➑

.[1, , ]➒

pooled_grads 是一个向量,每个条目是给定通道的梯度的均值强度,它量化了每个通道相对于最高预测类别的重要性。

我们利用张量广播规则,避免使用 for 循环。大小为 1 的轴会自动广播以匹配 last_conv_layer_output 的相应轴。

将上一个卷积层的每个通道的输出乘以“此通道的重要性”。

grads 和 last_conv_layer_output 具有相同的形状(1,10,10,2048)。

pooled_grads 的形状为(1,1,1,2048)。

形状:(1,10,10,2048)

结果特征图的逐通道平均值是我们的类别激活热图。

形状:(1,10,10)

去掉批次维度;输出形状:(10,10)。

结果显示在图 9.19 中。

列表 9.26 热图后处理

par(mar = c(0, 0, 0, 0))

绘制激活图(heatmap)

最后,让我们将激活的

图片

图 9.19 独立的类别激活热图

列表 9.27 在原始图像上叠加热图

pal <- hcl.colors(256, palette = "Spectral", alpha = .4, rev = TRUE)

heatmap <- as.array(heatmap)

heatmap[] <- pal[cut(heatmap, 256)]

heatmap <- as.raster(heatmap)

img <- tf_read_image(img_path, resize = NULL)➊

display_image_tensor(img)

rasterImage(heatmap, 0, 0, ncol(img), nrow(img), interpolate = FALSE)➋

加载原始图像,这次不调整大小。

将热图叠加到原始图像上,热图的不透明度为 40%。我们传递 ncol(img) 和 nrow(img),以使热图(像素较少)的绘制大小与原始图像匹配。我们传递 interpolate = FALSE,这样我们就可以清楚地看到激活地图像素边界的位置。

这种可视化技术回答了两个重要问题:

  • 网络为什么认为这张图片包含非洲象

  • 图中非洲象位于哪里

特别值得注意的是,非洲象幼崽的耳朵被强烈激活:这可能是网络区分非洲象和印度象的方式。

图片

图 9.20 测试图片上的非洲象类激活热图

摘要

  • 你可以使用深度学习执行三个基本的计算机视觉任务:图像分类、图像分割和目标检测。

  • 遵循现代卷积神经网络架构的最佳实践将帮助您充分利用模型。其中一些最佳实践包括使用残差连接、批量归一化和深度可分离卷积。

  • 卷积神经网络学习的表示很容易检查——卷积神经网络与黑匣子相反!

  • 您可以生成卷积神经网络学习的滤波器的可视化,以及类活动的热图。

  1. ¹ Kaiming He 等人,“用于图像识别的深度残差学习”,计算机视觉与模式识别会议(2015),arxiv.org/abs/1512.03385

  2. ² Sergey Ioffe 和 Christian Szegedy,“批量归一化:通过减少内部协变量转移加速深度网络训练”,第 32 届国际机器学习会议论文集(2015),arxiv.org/abs/1502.03167

  3. ³ François Chollet,“Xception:具有深度可分离卷积的深度学习”,计算机视觉与模式识别会议(2017),arxiv.org/abs/1610.02357

  4. ⁴ Liang-Chieh Chen 等人,“具有空洞可分离卷积的编码器-解码器用于语义图像分割”,ECCV(2018),arxiv.org/abs/1802.02611

  5. ⁵ Ramprasaath R. Selvaraju 等人,arXiv(2017),arxiv.org/abs/1610.02391

第十章:时间序列的深度学习

这一章涵盖了

  • 涉及时间序列数据的机器学习任务的示例

  • 理解递归神经网络(RNN)

  • 应用 RNNs 到温度预测示例

  • 高级 RNN 用法模式

10.1 不同类型的时间序列任务

时间序列可以是通过定期测量获得的任何数据,例如股票的日价格,城市的小时用电量或商店的每周销售额。时间序列无处不在,无论我们是在研究自然现象(如地震活动,河流中鱼类种群的演变或位置的天气)还是人类活动模式(如访问网站的访客,一个国家的国内生产总值或信用卡交易)。与迄今为止遇到的数据类型不同,处理时间序列涉及对系统的动态进行理解 ——其周期性循环,随时间推移的趋势,其正常情况以及突发的波动。

到目前为止,与时间序列相关的任务中最常见的是预测:预测系列中接下来会发生什么;提前几个小时预测电力消耗,以便您可以预测需求;提前几个月预测收入,以便您可以计划预算;提前几天预测天气,以便您可以安排日程。本章重点关注预测。但实际上,您可以对时间序列进行各种其他操作:

  • 分类 - 为时间序列分配一个或多个分类标签。例如,给定网站访客活动的时间序列,分类访客是机器人还是人。

  • 事件检测 - 在连续数据流中识别特定预期事件的发生。一个特别有用的应用是“热词检测”,其中模型监视音频流并检测出“OK,Google”或“Hey,Alexa”等话语。

  • 异常检测 - 检测连续数据流中发生的任何异常情况。企业网络上的异常活动?可能是攻击者。制造线上的异常读数?该找个人过去看一下了。异常检测通常通过无监督学习来完成,因为您通常不知道要寻找什么类型的异常情况,所以无法针对特定的异常情况进行训练。

在处理时间序列时,你会遇到各种领域特定的数据表示技术。例如,你可能已经听说过傅立叶变换,它包括将一系列值表达为不同频率的波的叠加。傅立叶变换在预处理任何主要由其周期和振荡特性(如声音、摩天大楼的振动或你的脑电波)表征的数据时非常有价值。在深度学习的背景下,傅立叶分析(或相关的梅尔频率分析)和其他领域特定的表示形式可以作为特征工程的一种形式,一种在对数据进行训练之前准备数据的方式,以使模型的工作变得更容易。然而,我们在这些页面上不会涵盖这些技术;我们将专注于建模部分。

在本章中,你将学习循环神经网络(RNNs)以及如何将它们应用于时间序列预测。

10.2 温度预测示例

在本章中,我们所有的代码示例都将针对一个单一的问题:预测未来 24 小时的温度,给定建筑物屋顶上一组传感器记录的最近时期的大气压力和湿度等量的每小时测量的时间序列。正如你将看到的,这是一个相当具有挑战性的问题!

我们将使用这个温度预测任务来突显时间序列数据与你迄今为止遇到的数据集有着根本不同之处。你将看到,密集连接网络和卷积网络并不适合处理这种类型的数据集,而另一种不同的机器学习技术——循环神经网络(RNNs)在这种类型的问题上表现得非常出色。

我们将使用德国耶拿马克斯·普朗克生物地球化学研究所的气象站记录的天气时间序列数据集。[1] 在这个数据集中,每隔 10 分钟记录了 14 种不同的数量(如温度、压力、湿度和风向)数年。原始数据追溯到 2003 年,但我们将下载的数据子集限制在 2009-2016 年。让我们首先下载并解压数据:

网址 <-

"https://s3.amazonaws.com/keras-datasets/jena_climate_2009_2016.csv.zip"

download.file(url, destfile = basename(url))

zip::unzip(zipfile = "jena_climate_2009_2016.csv.zip",

files = "jena_climate_2009_2016.csv")

现在让我们来看看数据。我们将使用 readr::read_csv() 来读取数据。

列表 10.1 检查耶拿天气数据集的数据

full_df <- readr::read_csv("jena_climate_2009_2016.csv")➊

请注意,你也可以跳过上面的 zip::unzip() 调用,并直接将 zip 文件路径传递给 read_csv()。

这将输出一个包含 420,451 行和 15 列的数据框。每一行都是一个时间步长:记录了日期和 14 个与天气相关的值。

full_df

图片

read_csv() 正确解析了所有列为数值向量,除了“Date Time”列,它解析为字符向量而不是日期时间向量。我们不会在“Date Time”列上进行训练,所以这不是问题,但为了完整起见,我们可以将字符列转换为 R POSIXct 格式。请注意,我们传递的时区为“Etc/GMT+1”,而不是“Europe/Berlin”,因为数据集中的时间戳不调整为中欧夏令时间(也称为夏时制),而是始终处于中欧时间:

full_df$Date Time %<>%

as.POSIXct(tz = "Etc/GMT+1", format = "%d.%m.%Y %H:%M:%S")

%<>% 分配管道

在前面的示例中,我们第一次使用了分配管道。x %<>% fn() 是 x <- x %>% fn() 的简写。这是有用的,因为它使您能够编写更易读的代码,避免多次重复使用相同的变量名。我们也可以这样写来达到相同的效果:

full_df$Date Time <- full_df$Date Time %>%

as.POSIXct(tz = "Etc/GMT+1", format = "%d.%m.%Y %H:%M:%S")

通过调用 library(keras),我们可以使用分配管道。

图 10.1 显示了温度(摄氏度)随时间变化的曲线图。在这张图上,你可以清楚地看到温度的年周期性 —— 数据跨越了 8 年。

列表 10.2 绘制温度时间序列

plot(T (degC)~Date Time, data = full_df, pch = 20, cex = .3)

图像

图 10.1 数据集的完整时间范围内的温度(°C)

图 10.2 显示了温度数据的前 10 天的更窄的曲线图。因为数据每 10 分钟记录一次,所以每天有 24 × 6 = 144 个数据点。

列表 10.3 绘制温度时间序列的前 10 天

plot(T (degC)~Date Time, data = full_df[1:1440, ])

图像

图 10.2 数据集的前 10 天内的温度(°C)

在这张图上,你可以看到每日周期性,尤其是最后四天。还要注意,这 10 天的周期来自一个相当寒冷的冬季月份。

始终寻找数据中的周期性

多个时间尺度上的周期性是时间序列数据的一个重要且非常普遍的特性。无论你是在观察天气、购物中心停车位占用情况、网站流量、杂货店销售情况还是健身追踪器中记录的步数,你都会看到每日周期和年周期(人类生成的数据也倾向于具有每周周期)。在探索数据时,请务必寻找这些模式。

通过我们的数据集,如果你试图根据过去几个月的数据预测下个月的平均温度,那问题将会很容易,因为数据具有可靠的年尺度周期性。但是如果以天为单位查看数据,温度看起来就更加混乱。在每天的尺度上,这个时间序列是否可预测?让我们来看看。

在我们所有的实验中,我们将使用数据的前 50%进行训练,接下来的 25%用于验证,最后的 25%用于测试。当处理时间序列数据时,使用比训练数据更近期的验证和测试数据很重要,因为你试图根据过去来预测未来,而不是相反,你的验证/测试拆分应该反映这一点。如果你颠倒时间轴,有些问题会变得更简单!

列表 10.4 计算我们将用于每个数据拆分的样本数

num_train_samples <- round(nrow(full_df) * .5) num_val_samples <- round(nrow(full_df) * 0.25) num_test_samples <- nrow(full_df) - num_train_samples - num_val_samples

train_df <- full_df[seq(num_train_samples), ]➊

val_df <- full_df[seq(from = nrow(train_df) + 1, length.out = num_val_samples), ]➋

test_df <- full_df[seq(to = nrow(full_df), length.out = num_test_samples), ]➌

cat("num_train_samples:", nrow(train_df), "\n")

cat("num_val_samples:", nrow(val_df), "\n")

cat("num_test_samples:", nrow(test_df), "\n")

num_train_samples: 210226

num_val_samples: 105113

num_test_samples: 105112

前 50%的行,1:210226

接下来的 25%的行,210227:315339

最后 25%的行,315340:420451

10.2.1 准备数据

问题的确切表述如下:给定覆盖前五天的数据,每小时采样一次,我们能否在 24 小时内预测温度?

首先,让我们将数据预处理为神经网络可以摄入的格式。这很简单:数据已经是数值型的,所以您不需要进行任何向量化。但是,数据中的每个时间序列都在不同的尺度上(例如,大气压力以 mbar 为单位,约为 1000,而 H2OC 以毫摩尔/摩尔为单位,约为 3)。我们将独立地对每个时间序列(列)进行归一化,以使它们都以类似尺度的小值。我们将使用前 210,226 个时间步作为训练数据,因此我们只会在数据的这一部分计算均值和标准差。

列表 10.5 数据归一化

input_data_colnames <- names(full_df) %>% setdiff(c("Date Time"))➊

normalization_values <-

zip_lists(mean = lapply(train_df[input_data_colnames], mean),

sd = lapply(train_df[input_data_colnames], sd))➋

str(normalization_values)

一个包含 14 个元素的列表

$ p (mbar)       :List of 2

..$ 均值:num 989

..$ 标准差:num 8.51

$ T (degC)       :List of 2

..$ 均值:num 8.83

..$ 标准差:num 8.77

$ Tpot (K)       :List of 2

..$ 均值:num 283

..$ 标准差:num 8.87

$ Tdew (degC)    :List of 2

..$ 均值:num 4.31

..$ 标准差:num 7.08

$ rh (%) :List of 2

..$ 均值:num 75.9

..$ 标准差:num 16.6

$ VPmax (mbar)   :List of 2

..$ 均值:num 13.1

..$ 标准差:num 7.6

$ VPact (mbar)   :List of 2

..$ 均值:num 9.19

..$ 标准差:num 4.15

$ VPdef (mbar)    :List of 2

..$ 均值:num 3.95

..$ 标准差:num 4.77

[list output truncated]

normalize_input_data <- function(df) {

normalize <- function(x, center, scale)➌

(x - center) / scale

for(col_nm in input_data_colnames) {

col_nv <- normalization_values[[col_nm]]

df[[col_nm]] %<>% normalize(., col_nv\(mean, col_nv\)sd)

}

df

}

我们的模型输入将是除了日期时间列之外的所有列。

我们仅使用训练数据来计算归一化值。

你也可以调用 scale(col, center = train_col_mean, scale = train_col_sd),但为了最大的清晰度,我们定义了一个本地函数:normalize()。

接下来,让我们创建一个 TF Dataset 对象,从过去五天的数据中产生数据批次,并提供未来 24 小时的目标温度。由于数据集中的样本高度冗余(样本N和样本N + 1 将具有大部分时间步骤相同),为每个样本显式分配内存将是浪费的。相反,我们将在需要时动态生成样本,只保留原始数据数组,什么都不多余。

我们可以很容易地编写一个 R 函数来做到这一点,但是 Keras 中有一个内置的数据集实用程序(timeseries_dataset_from_array())可以做到这一点,所以我们可以通过使用它来节省一些工作。你通常可以将其用于任何类型的时间序列预测任务。

理解 timeseries_dataset_from_array()

要理解 timeseries_dataset_from_array()的作用,让我们看一个简单的例子。总的思路是,你提供一个时间序列数据的数组(数据参数),timeseries_dataset_from_array()会给你从原始时间序列中提取的窗口(我们称之为“序列”)。

例如,如果你使用 data = [0 1 2 3 4 5 6]和 sequence_length = 3,那么 timeseries_dataset_from_array()将生成以下样本:[0 1 2],[1 2 3],[2 3 4],[3 4 5],[4 5 6]。

你还可以向 timeseries_dataset_from_array()传递一个 targets 参数(一个数组)。targets 数组的第一个条目应该与将从数据数组生成的第一个序列的所需目标相匹配。因此,如果你正在进行时间序列预测,则 targets 应该是与数据相同的数组,偏移了一些量。

例如,如果 data = [0 1 2 3 4 5 6 ...]和 sequence_length = 3,你可以通过传递 targets = [3 4 5 6 ...]来创建一个数据集,以预测系列中的下一步。让我们试试:

library (keras)

int_sequence <- seq(10) ➊

dummy_dataset <- timeseries_dataset_from_array(

data = head(int_sequence, -3), ➋

targets = tail(int_sequence, -3), ➌

sequence_length = 3, ➍

batch_size = 2 ➎

)

library(tfdatasets)

dummy_dataset_iterator <- as_array_iterator(dummy_dataset)

repeat {

batch <- iter_next(dummy_dataset_iterator)

如果(is.null(batch))➏

break

c(inputs, targets) %<-% batch

for (r in 1:nrow(inputs))

cat(sprintf("input: [ %s ] target: %s\n", paste(inputs[r, ], collapse = " "), targets[r]))

cat(strrep("-", 27), "\n") ➐

}

生成一个从 1 到 10 排序的整数数组。

我们生成的序列将从[1 2 3 4 5 6 7]中采样(去除最后 3 个)。

从数据[N]开始的序列的目标将是数据[N + 4](尾部去除前 3 个)。

序列将为三个步长。

序列将以大小为 2 的批次分组。

迭代器已经耗尽。

标记批次。

这段代码打印以下结果:

input: [ 1 2 3 ] target: 4

input: [ 2 3 4 ] target: 5


input: [ 3 4 5 ] target: 6

input: [ 4 5 6 ] target: 7


input: [ 5 6 7 ] target: 8


我们将使用 timeseries_dataset_from_array() 实例化三个数据集:一个用于训练,一个用于验证,一个用于测试。我们将使用以下参数值:

  • sampling_rate = 6—观察将每小时采样一次:我们将每 6 个数据点中保留一个数据点。

  • sequence_length = 120—观察将回溯五天(120 小时)。

  • delay = sampling_rate * (sequence_length + 24 - 1)—序列的目标将是序列结束后 24 小时的温度。

Listing 10.6 实例化用于训练、验证和测试的数据集

sampling_rate <- 6

sequence_length <- 120

delay <- sampling_rate * (sequence_length + 24 - 1)

batch_size <- 256

df_to_inputs_and_targets <- function(df) {

inputs <- df[input_data_colnames] %>%

normalize_input_data() %>%

as.matrix() ➊

targets <- as.array(df$T (degC)) ➋

list (

head(inputs, -delay), ➌

tail(targets, -delay) ➍

)

}

make_dataset <- function(df)

{ c(inputs, targets) %<-% df_to_inputs_and_targets(df)

timeseries_dataset_from_array(

inputs, targets,

sampling_rate = sampling_rate,

sequence_length = sequence_length,

shuffle = TRUE,

batch_size = batch_size

)

}

train_dataset <- make_dataset(train_df)

val_dataset <- make_dataset(val_df)

test_dataset <- make_dataset(test_df)

将数据框转换为数值数组。

我们不对目标进行标准化。

丢弃最后 delay 个样本。

丢弃前 delay 个样本。

每个数据集产生一批(samples,targets)作为一对,其中 samples 是一批包含 256 个样本的数据,每个样本包含 120 个连续小时的输入数据,而 targets 是相应的 256 个目标温度数组。请注意,样本是随机洗牌的,因此批次中的两个连续序列(如 samples[1, ] 和 samples[2, ])不一定在时间上相邻。

Listing 10.7 检查我们数据集之一的输出

c(samples, targets) %<-% iter_next(as_iterator(train_dataset))

cat("样本形状:", format(samples$shape), "\n",

"目标形状:", format(targets$shape), "\n", sep = "")

samples shape: (256, 120, 14)

targets shape: (256)

10.2.2 一个常识性的、非机器学习的基准

在我们开始使用黑盒深度学习模型来解决温度预测问题之前,让我们尝试一种简单的常识方法。这将作为一个理智的检查,并且它将建立一个我们将不得不击败以证明更高级机器学习模型的有用性的基线。当你面对一个尚无已知解决方案的新问题时,这种常识基线可能会很有用。一个经典的例子是不平衡分类任务,其中一些类别比其他类别更常见。如果你的数据集包含 90% 的 A 类实例和 10% 的 B 类实例,那么对分类任务的一种常识方法是当提供一个新样本时总是预测为“A”。这样的分类器在整体上准确率为 90%,因此任何基于学习的方法都应该超过这个 90% 的分数以证明其有用性。有时,这样的基本基线可能会出人意料地难以超越。

在这种情况下,温度时间序列可以安全地假定是连续的(明天的温度很可能接近今天的温度),并且具有每日周期。因此,一个常识性的方法是始终预测 24 小时后的温度将等于现在的温度。让我们使用平均绝对误差(MAE)指标来评估这种方法,其定义如下:

mean(abs(preds - targets))

这是评估代码。我们不再使用 R 中的 for、as_array_iterator() 和 iter_next() 来急切地进行评估,而是可以很容易地使用 TF Dataset 转换来完成。首先我们调用 dataset_unbatch(),使每个数据集元素成为 (samples, target) 的单个案例。接下来我们使用 dataset_map() 计算每对 (samples, target) 的绝对误差,然后使用 dataset_reduce() 累积总误差和总样本数。

请记住,传递给 dataset_map()dataset_reduce() 的函数将使用符号张量调用。使用负数进行张量切片,如 samples[-1, ] 会选择沿该轴的最后一个切片,就好像我们写成了 samples[nrow(samples), ] 一样。

清单 10.8 计算常识基线 MAE

evaluate_naive_method <- function(dataset) {

unnormalize_temperature <- function(x) {

nv <- normalization_values$'T (degC)'

(x * nv$sd) + nv$mean

}

temp_col_idx <- match("T (degC)", input_data_colnames) ➊

reduction <- dataset %>%

%>% dataset_unbatch() %>%

dataset_map(function(samples, target) {

last_temp_in_input <- samples[-1, temp_col_idx] ➋

pred <- unnormalize_temperature(last_temp_in_input) ➌

abs(pred - target)

}) %>%

dataset_reduce(

initial_state = list(total_samples_seen = 0L, total_abs_error = 0),

reduce_func = function(state, element) {

state$total_samples_seen %<>% '+'(1L)

state$total_abs_error %<>% '+'(element)

state

}

) %>%

lapply(as.numeric) ➍

mae <- with(reduction, total_abs_error / total_samples_seen) ➎

mae

}

sprintf("验证 MAE: %.2f", evaluate_naive_method(val_dataset))

sprintf("测试 MAE: %.2f", evaluate_naive_method(test_dataset))

[1] "验证 MAE: 2.43"

[1] "测试 MAE: 2.62"

2,第二列

在输入序列中切出最后一个温度测量值。

回忆一下,我们对特征进行了归一化,因此要得到以摄氏度为单位的温度,需要将其除以标准差,然后加上均值。

将张量转换为 R 数值。

reduction 是一个包含两个 R 标量数字的命名列表。

这种常识基线实现了 2.44 摄氏度的验证 MAE 和 2.62 摄氏度的测试 MAE。因此,如果你总是假设未来 24 小时的温度与当前温度相同,平均偏差将达到两个半度。这还算可以,但你可能不会基于这种启发式方法启动气象预报服务。现在的问题是要利用你对深度学习的知识做得更好。

10.2.3 让我们尝试基本的机器学习模型

就像在尝试机器学习方法之前建立一个常识基线一样,可以在进行复杂且计算昂贵的模型(如 RNN)之前,尝试简单、便宜的机器学习模型(例如小型、密集连接网络)是非常有用的。这是确保你对问题投入的任何进一步复杂性都是合法的,并且能够带来真正收益的最佳方法。

列表 10.9 显示了一个完全连接的模型,它首先将数据展平,然后通过两个 layer_dense() 进行处理。请注意,最后一个 layer_dense() 没有激活函数,这对于回归问题是典型的。我们使用均方误差(MSE)作为损失,而不是 MAE,因为与 MAE 不同,它在零附近是光滑的,对于梯度下降是一个有用的特性。我们将将 MAE 作为指标添加到 compile() 中进行监控。

列表 10.9 训练和评估一个密集连接模型

ncol_input_data <- length(input_data_colnames)

inputs <- layer_input(shape = c(sequence_length, ncol_input_data))

outputs <- inputs %>%

layer_flatten() %>%

layer_dense(16, activation = "relu") %>%

layer_dense(1)

model <- keras_model(inputs, outputs)

callbacks = list (

callback_model_checkpoint("jena_dense.keras", save_best_only = TRUE)➊

)

model %>%

compile(optimizer = "rmsprop",

loss = "mse",

metrics = "mae")

history <- model %>%

fit(train_dataset,

epochs = 10,

validation_data = val_dataset,

callbacks = callbacks)

model <- load_model_tf("jena_dense.keras")➋

sprintf("测试 MAE: %.2f", evaluate(model, test_dataset)["mae"])

[1] "测试 MAE: 2.71"

我们使用回调保存表现最佳的模型。

重新加载最佳模型,并在测试数据上进行评估。

让我们显示验证和训练的损失曲线(参见图 10.3)。

列表 10.10 绘制结果

plot(history, metrics = "mae")

图片

图 10.3 在 Jena 温度预测任务上使用简单的密集连接网络进行训练和验证的 MAE

一些验证损失接近于无学习基线,但不可靠。这显示了首先拥有这个基线的价值所在:结果证明很难超越它。你的常识包含了很多有价值的信息,机器学习模型无法获取。

你可能会想,如果存在一个简单且性能良好的模型,可以从数据到目标(常识基线),为什么你正在训练的模型找不到它并改进它呢?嗯,你正在搜索解决方案的模型空间——也就是你定义的所有可能的两层网络的空间。常识启发式仅仅是这个空间中可以表示的数百万模型中的一个。这就像在一堆草堆里寻找一根针。仅仅因为一个好的解决方案在你的假设空间中技术上存在,并不意味着你能够通过梯度下降找到它。

这在机器学习中是一个非常重要的限制:除非学习算法被硬编码为寻找特定类型的简单模型,否则有时会无法找到简单问题的简单解决方案。这就是为什么利用良好的特征工程和相关的架构先验是必不可少的原因:你需要准确告诉你的模型它应该寻找什么。

10.2.4 让我们尝试一个 1D 卷积模型

谈到利用正确的架构先验,因为我们的输入序列具有日循环,也许一个卷积模型可能有效。时间卷积网络可以在不同的日子重用相同的表示,就像空间卷积网络可以在图像的不同位置重用相同的表示一样。

您已经了解了 layer_conv_2d()和 layer_separable_conv_2d(),它们通过在 2D 网格上刷过小窗口查看它们的输入。这些层还有它们的 1D 甚至 3D 版本:layer_conv_1d()、layer_separable_ conv_1d()和 layer_conv_3d()。² layer_conv_1d()层依赖于在输入序列上滑动的 1D 窗口,而 layer_conv_3d()层依赖于在输入体积上滑动的立方窗口。

因此,您可以构建 1D convnets,与 2D convnets 严格类似。它们非常适合任何遵循平移不变性假设的序列数据(这意味着如果您在序列上滑动一个窗口,窗口的内容应该独立于窗口的位置而遵循相同的属性)。

让我们在我们的温度预测问题上尝试一个。我们将选择一个初始窗口长度为 24,这样我们一次可以查看 24 小时的数据(一个周期)。当我们通过 layer_max_pooling_1d()层对序列进行下采样时,我们将相应地减小窗口大小:

inputs <- layer_input(shape = c(sequence_length, ncol_input_data))

outputs <- inputs %>%

layer_conv_1d(8, 24, activation = "relu") %>%

layer_max_pooling_1d(2) %>%

layer_conv_1d(8, 12, activation = "relu") %>%

layer_max_pooling_1d(2) %>%

layer_conv_1d(8, 6, activation = "relu") %>%

layer_global_average_pooling_1d() %>%

layer_dense(1)

model <- keras_model(inputs, outputs)

callbacks <- list(callback_model_checkpoint("jena_conv.keras", save_best_only = TRUE))

model %>% compile(optimizer = "rmsprop",

loss = "mse",

metrics = "mae")

history <- model %>% fit(

train_dataset,

epochs = 10,

validation_data = val_dataset,

callbacks = callbacks

)

model <- load_model_tf("jena_conv.keras")

sprintf("测试 MAE: %.2f", evaluate(model, test_dataset)["mae"])

[1] "测试 MAE: 3.20"

我们得到了在 图 10.4 中显示的训练和验证曲线。

绘制(history)

图片

图 10.4 在 Jena 温度预测任务中使用一维卷积网络的训练和验证 MAE

事实证明,这个模型的表现甚至比全连接模型还要差,只能达到 3.2 度的测试 MAE,远远低于常识基线。这里出了什么问题呢?有两点:

  • 首先,天气数据并不完全遵循平移不变性的假设。尽管数据具有每日循环,但早晨的数据与晚上或午夜的数据具有不同属性。天气数据只在特定时间尺度上具有平移不变性。

  • 其次,我们的数据顺序非常重要。最近的数据对于预测第二天的温度比五天前的数据要有更多信息。一维卷积网络无法利用这一事实。特别是,我们的最大池化层和全局平均池化层在很大程度上破坏了顺序信息。

10.2.5 首个循环基线

无论全连接方法还是卷积方法都表现不佳,但这并不意味着机器学习不适用于这个问题。全连接方法首先将时间序列展平,从输入数据中除去了时间的概念。卷积方法将数据的每个片段以相同的方式处理,甚至进行了池化,这破坏了顺序信息。我们应该将数据看作它实际的样子:一个序列,因果和顺序至��重要。

有一类神经网络架构专门设计用于这种情况:循环神经网络。其中,长短期记忆(LSTM)层一直非常受欢迎。我们马上会看到这些模型是如何工作的,但首先让我们尝试一下 LSTM 层。

图 10.11 一个简单的基于 LSTM 的模型

inputs <- layer_input(shape = c(sequence_length, ncol_input_data))

outputs <- inputs %>%

layer_lstm(16) %>%

layer_dense(1)

model <- keras_model(inputs, outputs)

callbacks <- list(callback_model_checkpoint("jena_lstm.keras", save_best_only = TRUE))

model %>% compile(optimizer = "rmsprop",

loss = "mse",

metrics = "mae")

history <- model %>% fit(

train_dataset,

epochs = 10,

validation_data = val_dataset,

callbacks = callbacks

)

model <- load_model_tf("jena_lstm.keras")

sprintf("测试 MAE: %.2f", evaluate(model, test_dataset)["mae"])

[1] "测试 MAE: 2.52"

图 10.5 显示了结果。好多了!我们实现了 2.52 度的测试 MAE。基于 LSTM 的模型终于能够击败常识基准(尽管只是一点点),展示了机器学习在此任务上的价值。

但为什么 LSTM 模型的表现要比密集连接或卷积网络更好呢?我们如何进一步改进模型?为了回答这个问题,让我们仔细研究一下递归神经网络。

图像

图 10.5 基于 LSTM 模型的 Jena 温度预测任务的训练和验证 MAE(请注意,我们在此图上省略了第一个时期,因为第一个时期的高训练 MAE 会扭曲比例尺)

10.3 理解递归神经网络

到目前为止,你见过的所有神经网络(如密集连接网络和卷积网络)的一个主要特征是它们没有记忆。它们分别处理每个输入,不存在输入之间的状态保持。对于这些网络来说,要处理一个序列或时间序列的数据点,你必须一次性将整个序列展示给网络:将其转换为一个单独的数据点。例如,在密集连接网络的示例中,我们将五天的数据压平成一个巨大的向量,并一次性处理了它。这样的网络称为 前馈网络

相反,当你阅读当前的句子时,你是逐词处理它,同时记忆之前的内容;这使你对这个句子所传达的含义有流畅的理解。生物智能会增量地处理信息,同时保持一个内部模型,该模型是从过去信息中构建起来的,并随着新信息的到来而不断更新。

递归神经网络(RNN)采用相同的原则,尽管是在一个极简化的版本:它通过迭代序列元素并保持一个包含迄今为止所见信息的状态来处理序列。实际上,RNN 是一种具有内部循环的神经网络(参见图 10.6)。RNN 的状态在处理两个不同的、独立的序列(例如批次中的两个样本)之间重置,所以你仍然将一个序列视为一个单独的数据点:对网络的单个输入。改变的是,这个数据点不再以单个步骤进行处理;相反,网络在序列元素上进行内部循环。

为了澄清循环状态的概念,让我们实现一个玩具 RNN 的前向传播。这个 RNN 接受一系列向量作为输入,我们将其编码为大小为 (时间步长, 输入特征) 的二阶张量。它在时间步长上循环,并在每个时间步长上考虑 t 时刻的当前状态和 t 时刻的输入(形状为 (输入特征)),并将它们组合起来得到 t 时刻的输出。然后,我们将下一个步骤的状态设置为上一个输出。对于第一个时间步长,上一个输出未定义;因此,没有当前状态。因此,我们将状态初始化为称为网络的初始状态的全零向量。在伪代码中,以下清单显示了 RNN。

图像

图 10.6 一个循环网络:具有循环的网络

清单 10.12 RNN 伪代码

state_t <- 0➊

for (input_t in input_sequence) {➋

output_t <- f(input_t,

state_t) state_t <- output_t➌

}

t 时刻的状态

迭代序列元素。

前一个输出成为下一次迭代的状态。

甚至可以详细说明函数 f:将输入和状态转换为输出的过程将由两个矩阵 W 和 U,以及一个偏置向量参数化。这类似于前馈网络中密集连接层所进行的转换。

清单 10.13 RNN 的更详细的伪代码

state_t <- 0

for (input_t in input_sequence) {

output_t <- activation(dot(W, input_t) + dot(U,

state_t) + b) state_t <- output_t

}

为了使这些概念绝对清晰,让我们写一个简单的 R 语言实现简单 RNN 的前向传播。

清单 10.14 简单 RNN 的基本 R 实现

random_array <- function(dim) array(runif(prod(dim)), dim)

timesteps <- 100➊

input_features <- 32➋

output_features <- 64➌

inputs <- random_array(c(timesteps, input_features))➍

state_t <- array(0, dim = output_features)➎

W <- random_array(c(output_features, input_features))➏

U <- random_array(c(output_features, output_features))➏

b <- random_array(c(output_features, 1))➏

successive_outputs <- array(0, dim = c(timesteps, output_features))➏

for(ts in 1:timesteps) {

input_t <- inputs[ts, ]➐

output_t <- tanh((W %% input_t) + (U %% state_t) + b)➑➒

successive_outputs[ts, ] <- output_t➓

state_t <- output_t⓫

}

final_output_sequence <- successive_outputs⓬

输入序列中的时间步长数

输入特征空间的维度

输出特征空间的维度

输入数据:举例时使用的随机噪声

初始状态:全零向量

创建随机权重矩阵。

input_t 是形状为 (输入特征) 的向量。

W %% input_t、U %% input_t 和 b 都具有相同的形状:(输出特征,1)。

将输入与当前状态(上一个输出)组合以获得当前输出。我们使用 tanh 添加非线性(我们可以使用任何其他激活函数)。

将此输出存储起来。

更新网络的状态,以便进行下一个时间步骤。

最终输出是一个形状为(timesteps, output_features)的二阶张量。

很容易。总之,RNN 是一个 for 循环,它重复使用在循环的前一次迭代中计算的数量,没有更多的内容。当然,您可以构建许多不同的满足此定义的 RNN——这个示例是最简单的 RNN 公式之一。RNN 的特征在于它们的步骤函数,例如本例中的下面的函数(见图 10.7)。

output_t <- tanh((W %% input_t) + (U %% state_t) + b)

图片

图 10.7 一个简单的 RNN 在时间上展开

在本例中,最终输出是一个形状为(timesteps, output_features)的二阶张量,其中每个时间步骤是时间 t 处循环的输出。输出张量中的每个时间步长 t 都包含有关输入序列中时间步长 1 到 t 的信息——关于整个过去的信息。因此,在许多情况下,您不需要这个完整的输出序列;您只需要最后一个输出(在循环结束时的 output_t),因为它已经包含有关整个序列的信息。

10.3.1 Keras 中的一个递归层

我们刚刚在 R 中天真地实现的进程对应于一个实际的 Keras 层——layer_simple_rnn()。有一个细微的差别:layer_simple_rnn()处理序列批次,像所有其他 Keras 层一样,而不是在 R 的例子中一次处理一个序列。这意味着它需要形状为(batch_size, timesteps, input_features)的输入,而不是形状为(timesteps, input_features)的输入。在指定初始输入的形状参数时,请注意,您可以将 timesteps 条目设置为 NA,这将使您的网络能够处理任意长度的序列。

清单 10.15 可处理任意长度序列的 RNN 层

num_features <- 14

inputs <- layer_input(shape = c(NA, num_features))

outputs <- inputs %>% layer_simple_rnn(16)

如果您的模型用于处理可变长度的序列,那么这是特别有用的。然而,如果您的所有序列都具有相同的长度,我建议指定完整的输入形状,因为它可以让模型的 print()方法显示输出长度信息,这总是很好的,并且它可以解锁一些性能优化(请参见第 10.4.1 节的侧边栏中的内容,“RNN 运行时性能”)。

Keras 中的所有循环层(layer_simple_rnn()、layer_lstm()和 layer_gru())都可以以两种不同的模式运行:它们可以为每个时间步长返回连续输出的完整序列(形状为(batch_size, timesteps, output_features)的二阶张量),或仅为每个输入序列返回最后一个输出(形状为(batch_size, output_features)的二阶张量)。这两种模式由 return_sequences 参数控制。让我们看一个使用 layer_simple_rnn()并仅返回最后一步输出的例子。

清单 10.16 只返回最后一步输出的 RNN 层

num_features <- 14

steps <- 120

inputs <- layer_input(shape = c(steps, num_features))

outputs <- inputs %>%

layer_simple_rnn(16, return_sequences = FALSE)➊

outputs$shape

TensorShape([None, 16])

请注意,默认情况下 return_sequences = FALSE。

下面的示例返回完整的状态序列。

清单 10.17 返回完整输出序列的 RNN 层

num_features <- 14

steps <- 120

inputs <- layer_input(shape = c(steps, num_features))

outputs <- inputs %>% layer_simple_rnn(16, return_sequences = TRUE)

outputs$shape

TensorShape([None, 120, 16])

有时候,堆叠几个递归层一个接一个地可以增加网络的表示能力。在这样的设置中,你必须让所有中间层返回完整的输出序列。

清单 10.18 堆叠 RNN 层

inputs <- layer_input(shape = c(steps, num_features))

outputs <- inputs %>%

layer_simple_rnn(16, return_sequences = TRUE) %>%

layer_simple_rnn(16, return_sequences = TRUE) %>%

layer_simple_rnn(16)

在实践中,你很少会使用 layer_simple_rnn()。它通常过于简单,无法真正使用。特别是,layer_simple_rnn() 存在一个主要问题:虽然理论上它应该能够在时间 t 保留关于之前许多时间步的输入的信息,但在实践中,这样的长期依赖关系被证明是不可能学习的。这是由于 消失梯度问题,这是一种类似于非递归网络(前馈网络)在许多层深度时观察到的效果:随着你不断向网络添加层,网络最终变得无法训练。这个效果的理论原因是由 Hochreiter、Schmidhuber 和 Bengio 在 1990 年代初研究的³。

幸运的是,layer_simple_rnn() 不是 Keras 中唯一可用的递归层。还有另外两个,layer_lstm() 和 layer_gru(),它们设计用来解决这些问题。

让我们来考虑 layer_lstm()。底层长短期记忆(LSTM)算法由 Hochreiter 和 Schmidhuber 在 1997 年开发⁴;这是他们对消失梯度问题研究的顶点。

这个层是你已经了解的 layer_simple_rnn() 的一个变体;它添加了一种在许多时间步长之间传递信息的方式。想象一条与你正在处理的序列平行运行的传送带。来自序列的信息可以在任何时间点跳上传送带,被传送到以后的时间步长,然后在你需要时完整地跳下来。这基本上就是 LSTM 做的事情:它保存信息以备后用,从而在处理过程中防止旧信号逐渐消失。这应该让你想起第九章学到的 残差连接,基本上是同样的思想。

要详细理解这个过程,让我们从 layer_simple_rnn() 单元开始(参见 图 10.8)。因为你将会有很多权重矩阵,在单元中,用字母 o(Wo 和 Uo)表示output

图像

图 10.8 LSTM 层的起点:一个 SimpleRNN

现在让我们在这个图像中添加一个额外的数据流,它在时间步长之间携带信息。将其称为 c_t 的不同时间步长的值,其中 c 代表carry。这个信息将对单元产生以下影响:它将与输入连接和循环连接(通过一个密集转换:一个与权重矩阵的点积,然后加上偏置,并应用激活函数)相结合,并影响发送到下一个时间步的状态(通过激活函数和乘法操作)。从概念上讲,进位数据流是调制下一个输出和下一个状态的一种方式(见 图 10.9)。到目前为止还是很简单的。

图像

图 10.9 从 SimpleRNN 过渡到 LSTM:添加一个进位轨道

现在让我们来看看一个微妙之处——计算进位数据流的下一个值的方式。它涉及三个不同的转换。这三个转换都采用了 SimpleRNN 单元的形式:

y <- activation((state_t %% U) + (input_t %% W) + b)

但是所有三个转换都有自己的权重矩阵,我们将用字母 i、f 和 k 对它们进行索引。到目前为止我们得到了这样的式子(可能看起来有点随意,但请跟我来)。

清单 10.19 LSTM 架构的伪代码细节(1/2)

output_t < -

activation((state_t %% Uo) + (input_t %% Wo) + (c_t %*% Vo) + bo)

i_t <- activation((state_t %% Ui) + (input_t %% Wi) + bi)

f_t <- activation((state_t %% Uf) + (input_t %% Wf) + bf)

k_t <- activation((state_t %% Uk) + (input_t %% Wk) + bk)

我们通过组合 i_t、f_t 和 k_t 来获得新的进位状态(下一个 c_t)。

清单 10.20 LSTM 架构的伪代码细节(2/2)

c_t+1 = i_t * k_t + c_t * f_t

像 图 10.10 中所示添加这个,就这样。并不是那么复杂——只是稍微复杂了一点。

图像

图 10.10 LSTM 的解剖学

如果你想深入思考,你可以解释每个操作的意图。例如,你可以说将 c_t 和 f_t 相乘是一种有意忽略 carry 数据流中无关信息的方法。与此同时,i_t 和 k_t 提供关于当前状态的信息,用新信息更新 carry 跟踪。但归根结底,这些解释并不重要,因为这些操作 实际上 做什么取决于参数化它们的权重的内容;而权重是以端对端方式学习的,在每一轮训练开始时重新开始,因此不可能将这种或那种操作归因于特定目的。RNN 单元的规范(如上所述)确定了你的假设空间——在训练期间搜索良好模型配置的空间——但它并不确定单元所做的事情;这取决于单元权重。具有不同权重的相同单元可能做着非常不同的事情。因此,构成 RNN 单元的操作组合更好地被解释为对你的搜索的一组 约束,而不是以工程意义上的 设计

可以说,这种约束的选择——如何实现 RNN 单元——最好由优化算法(如遗传算法或强化学习过程)来处理,而不是由人类工程师来处理。在未来,这就是我们构建模型的方式。总之:你不需要理解 LSTM 单元的具体架构;作为人类,你的工作不应该是理解它。只需记住 LSTM 单元的意图:允许过去的信息在以后重新注入,从而解决梯度消失问题。

递归神经网络的高级用法

到目前为止,你已经学会了

  • RNN 是什么以及它们如何工作

  • LSTM 是什么,为什么它在处理长序列时比天真的 RNN 更有效

  • 如何使用 Keras RNN 层处理序列数据

接下来,我们将回顾一些 RNN 的更高级特性,这些特性可以帮助你充分利用你的深度学习序列模型。通过本节结束,你将了解大部分关于使用 Keras 进行递归网络的知识。

我们将涵盖以下内容:

  • 递归丢失 —— 这是一种用于对抗递归层中过拟合的 dropout 变体。

  • 叠加递归层 —— 这增加了模型的表征能力(但增加了计算负载的成本)。

  • 双向递归层 —— 这些以不同方式向递归网络提供相同的信息,增加了准确性并减轻了遗忘问题。

我们将使用这些技术来完善我们的温度预测 RNN。

使用递归丢失来对抗过拟合

让我们回到我们在 10.2.5 节中使用的基于 LSTM 的模型——我们的第一个能够击败常识基线的模型。如果你看一下训练和验证曲线(图 10.5),很明显模型很快就开始过拟合了,尽管只有很少的单元:几个周期后,训练和验证损失开始明显发散。你已经熟悉了对抗这种现象的经典技术:辍学,它随机将一层的输入单元归零,以打破训练数据中的偶然相关性。但如何在循环网络中正确应用辍学并不是一个微不足道的问题。

长久以来人们都知道,在循环层之前应用辍学会阻碍学习,而不是帮助正则化。在 2016 年,Yarin Gal 在他的博士论文中,关于贝叶斯深度学习,⁵ 确定了在循环网络中正确使用辍学的方法:应该在每个时间步骤上应用相同的辍学掩码(相同的丢弃单元模式),而不是使用随机变化的辍学掩码。此外,为了对循环门(如 layer_gru() 和 layer_lstm())形成的表示进行正则化,应该将一个时间恒定的辍学掩码应用于该层的内部循环激活(循环辍学掩码)。在每个时间步上使用相同的辍学掩码允许网络正确传播其学习错误;时间上随机的辍学掩码会干扰这种错误信号,并对学习过程有害。

Yarin Gal 在 Keras 中进行了研究,并直接将这种机制集成到了 Keras 的循环层中。Keras 中的每个循环层都有两个与辍学相关的参数:dropout,指定该层输入单元的辍学率,和 recurrent_dropout,指定循环单元的辍学率。让我们在我们第一个 LSTM 示例的 layer_lstm() 中添加循环辍学,看看这样做如何影响过拟合。

多亏了辍学,我们不需要过多依赖网络大小来进行正则化,因此我们将使用两倍单元数的 LSTM 层,希望能够更具表现力(如果没有辍学,该网络将立即开始过拟合——可以尝试一下)。由于使用辍学进行正则化的网络总是需要更长时间才能完全收敛,所以我们将对模型进行五倍的训练周期。

图示 10.21 训练和评估带有辍学正则化的 LSTM

inputs <- layer_input(shape = c(sequence_length, ncol_input_data))

outputs <- inputs %>%

layer_lstm(32, recurrent_dropout = 0.25) %>%

layer_dropout(0.5) %>%➊

layer_dense(1)

model <- keras_model(inputs, outputs)

callbacks <- list(callback_model_checkpoint("jena_lstm_dropout.keras", save_best_only = TRUE))

model %>% compile(optimizer = "rmsprop",

loss = "mse",

metrics = "mae")

history <- model %>% fit(

train_dataset,

epochs = 50,

validation_data = val_dataset,

callbacks = callbacks

)

为了对稠密层进行正则化,我们在 LSTM 之后添加了一个丢弃层。

图 10.11 显示了结果。成功!在前 15 个 epoch 中,我们不再过拟合。我们的验证 MAE 可以降低到 2.37 度(相比没有学习的基准线改善了 2.5%),测试 MAE 为 2.45 度(相比基准线改善了 6.5%)。还不错。

图片

图 10.11:使用丢弃正则化的 LSTM 在 Jena 温度预测任务上的训练和验证损失

RNN 运行时性能

与第几章中介绍的那种参数极少的循环模型相比,使用多核 CPU 上的 RNN 层通常会快得多,因为它们只涉及小矩阵乘法,并且乘法链由于存在循环而无法很好地并行化。但是,较大的 RNN 可以极大地受益于 GPU 的运行时。

当使用 Keras LSTM 或 GRU 层在 GPU 上使用默认参数时,你的层将利用 cuDNN 核心,这是一个高度优化的、低水平的、由 NVIDIA 提供的底层算法实现(我在上一章中提到过这些)。

通常情况下,cuDNN 核心是个双刃剑:它们快,但不灵活 —— 如果你尝试做一些默认核心不支持的事情,你将遭受劇烈的减速,这几乎强迫你坚持 NVIDIA 提供的功能。

例如,循环丢弃不支持 LSTM 和 GRU 的 cuDNN 核心,因此将其添加到你的层会强制运行时回退到常规的 TensorFlow 实现,其在 GPU 上通常比传统实现要慢两到五倍(即使它的计算成本是相同的)。

当无法使用 cuDNN 加速你的 RNN 层时,你可以尝试展开它。展开一个循环包括删除该循环,并将其内容简单内联化N次。在 RNN 的循环中,展开可以帮助 TensorFlow 优化底层的计算图。然而,它也会显著增加 RNN 的内存消耗。因此,它只适用于相对较小的序列(约 100 个步长或更少)。另外,请注意,只有当模型预知数据中的时间步数时才能这样做(也就是说,如果你向初始层的 layer_input() 传递一个没有任何 NA 条目的形状)。它的工作原理如下:

inputs <- layer_input(shape = c(sequence_length, num_features))➊

x <- inputs %>%

layer_lstm(32, recurrent_dropout = 0.2, unroll = TRUE)➋

sequence_length 不能是 NA。

传递 unroll = TRUE 启用展开。

10.4.2 堆叠循环层

因为你不再过度拟合,但似乎遇到了性能瓶颈,所以你应该考虑增加网络的容量和表达能力。回想一下通用的机器学习工作流程的描述:增加模型的容量直到过度拟合成为主要障碍是一个好主意(假设您已经采取了基本步骤来减轻过度拟合,比如使用丢弃)。只要你没有过度拟合得太严重,你很可能是在容量不足。

增加网络容量通常是通过增加层中的单元数或添加更多层来完成的。递归层堆叠是构建更强大的递归网络的经典方法:例如,不久前,Google 翻译算法是由七个大型 LSTM 层堆叠组成的,这是一个巨大的网络。

在 Keras 中堆叠递归层时,所有中间层都应返回它们的完整输出序列(一个三阶张量),而不是它们在最后一个时间步的输出。就像你已经学过的那样,这是通过指定 return_sequences = TRUE 来完成的。

在下面的示例中,我们将尝试一组两个带有丢弃正则化的递归层。为了改变一下,我们将使用门控循环单元(GRU)层,而不是 LSTM。GRU 与 LSTM 非常相似 —— 你可以把它看作是 LSTM 架构的稍微简化、简化版本。它由 Cho 等人在 2014 年提出,当时递归网络才开始重新引起当时小规模研究社区的兴趣⁶。

清单 10.22 训练和评估一个带有丢弃正则化的堆叠 GRU 模型

inputs <- layer_input(shape = c(sequence_length, ncol_input_data))

outputs <- inputs %>%

layer_gru(32, recurrent_dropout = 0.5, return_sequences = TRUE) %>%

layer_gru(32, recurrent_dropout = 0.5) %>%

layer_dropout(0.5) %>%

layer_dense(1)

model <- keras_model(inputs, outputs)

callbacks <- list(

callback_model_checkpoint("jena_stacked_gru_dropout.keras", save_best_only = TRUE)

)

model %>% compile(optimizer = "rmsprop",

loss = "mse",

metrics = "mae")

history <- model %>% fit(

train_dataset,

epochs = 50,

validation_data = val_dataset,

callbacks = callbacks

)

model <- load_model_tf("jena_stacked_gru_dropout.keras") sprintf("测试 MAE:%.2f", evaluate(model, test_dataset)["mae"])

[1] "测试 MAE:2.42"

图 10.12 显示了结果。我们实现了 2.42 度的测试 MAE(比基线提高了 7.6%)。您可以看到添加的层确实稍微改善了结果,尽管没有戏剧性的改变。您可能会看到在这一点上增加网络容量的回报正在递减。

Image

图 10.12 Jena 温度预测任务上堆叠 GRU 网络的训练和验证损失

10.4.3 使用双向 RNN

本节我们将要看的最后一种技术是双向 RNN。双向 RNN 是常见的 RNN 变体,在某些任务上可以提供比常规 RNN 更好的性能。它经常用于自然语言处理——您可以称其为自然语言处理的瑞士军刀。

RNNs(循环神经网络)明显依赖于顺序:它们按顺序处理其输入序列的时间步长,对时间步长进行混洗或反转可能会完全改变 RNN 从序列中提取的表示。这恰恰是它们在顺序具有意义的问题上表现良好的原因,比如温度预测问题。双向 RNN 利用了 RNN 的顺序敏感性:它使用两个常规 RNN,例如你已经熟悉的 GRU 和 LSTM 层,每个都按一定方向(按时间顺序和逆时间顺序)处理输入序列,然后合并它们的表示。通过双向处理序列,双向 RNN 可以捕捉到单向 RNN 可能忽略的模式。

引人注目的是,本节中的 RNN 层以时间顺序(以较早的时间步长优先)处理序列的事实可能是一个任意的决定。

至少到目前为止,我们没有试图质疑的决定。例如,如果 RNN 按逆时间顺序处理输入序列(首先是更新的时间步长),它们是否能够表现良好呢?让我们尝试一下,看看会发生什么。您只需要修改 TF 数据集,使输入序列沿时间维度反转即可。只需像这样用 dataset_map() 转换数据集:

ds %>%

dataset_map(function(samples, targets) {

list(samples[, NA:NA:-1, ], targets)

})

训练与本节第一个实验中使用的相同基于 LSTM 的模型,您将获得 图 10.13 中显示的结果。

图像

图 10.13 使用 LSTM 在 Jena 温度预测任务上的训练和验证损失,训练的是反转序列

反转顺序的 LSTM 明显表现不佳,甚至不如常识基线,表明在这种情况下,顺序处理对方法的成功很重要。这是很有道理的:底层 LSTM 层通常更擅长记住最近的过去,而不是遥远的过去,自然地,更近期的天气数据点比较旧的数据点更具有预测性(这就是常识基线相当强的原因)。因此,层的时间顺序版本必定会胜过反转顺序版本。

但是,对于包括自然语言在内的许多其他问题来说,并不是这样。直观上讲,理解一个句子中的一个词通常并不依赖于它在句子中的位置。在文本数据中,逆序处理和顺序处理一样有效——你可以很好地反向阅读文本(试试看!)。尽管词序在理解语言方面确实很重要,但是使用的顺序并不关键。重要的是,用于反向序列训练的 RNN 会学习到与用于原始序列训练的 RNN 不同的表示,就像在现实世界中,如果时间倒流,你会有与时间正常流动时完全不同的心理模型——如果你在你生命的最后一天出生并在你的第一天死去的话。在机器学习中,那些不同有用的表示总是值得利用的,它们的区别越大,越好:它们提供了一个新的角度来观察数据,捕捉到了其他方法忽略的数据特征,因此它们可以帮助改善任务的性能。这就是集成学习的直觉,我们将在第十三章中探讨这个概念。

双向 RNN 利用这个思想来改进按照时间顺序的 RNN 的性能。它双向查看输入序列(请参见图 10.14),获得可能更丰富的表示,并捕捉到只有按时间顺序的版本可能会错过的模式。

在 Keras 中实例化一个双向 RNN,你可以使用 bidirectional() 层,它的第一个参数是一个循环层实例。bidirectional() 创建第二个、单独的循环层实例,并使用一个实例按照时间顺序处理输入序列,使用另一个实例按照反向顺序处理输入序列。你可以在我们的温度预测任务中试一下。

图像

图 10.14 双向 RNN 层的工作原理

列表 10.23 训练和评估双向 LSTM

inputs <- layer_input(shape = c(sequence_length, ncol_input_data))

outputs <- inputs %>%➊

bidirectional(layer_lstm(units = 16)) %>%

layer_dense(1)

model <- keras_model(inputs, outputs)

model %>% compile(optimizer = "rmsprop",

loss = "mse",

metrics = "mae")

history <- model %>%

fit(train_dataset,

epochs = 10,

validation_data = val_dataset)

请注意,layer_lstm() 不是直接与 inputs 组合在一起的。

你会发现它的表现不如普通的 layer_lstm()。很容易理解为什么:因为反向半部分在这个任务上明显表现不佳(因为在这种情况下,近期的影响要比远期的影响大得多),所以所有的预测能力必须来自于网络的正向半部分。同时,反向半部分的存在使得网络的容量加倍,并导致网络过拟合的时间提前。

然而,双向 RNN 非常适合文本数据,或者任何其他需要考虑顺序的数据,但是使用的顺序却无关紧要的数据。事实上,在 2016 年的一段时间里,双向 LSTM 被认为是许多自然语言处理任务的最新技术(在 Transformer 架构的兴起之前,你将在下一章学到有关该架构的内容)。

10.4.4 更进一步

你可以尝试许多其他方法来提高温度预测问题的性能:

  • 调整堆叠设置中每个递归层中的单位数量,以及 dropout 的数量。目前的选择主要是任意的,因此可能是次优的。

  • 调整 RMSprop 优化器使用的学习率,或尝试其他优化器。

  • 尝试将layer_dense()堆叠作为递归层之上的回归器,而不是单个layer_dense()

  • 改进模型的输入:尝试使用更长或更短的序列或不同的采样率,或开始进行特征工程

一如既往,深度学习更像是一门艺术而不是科学。我们可以提供指导方针,建议在特定问题上可能有效或无效的方法,但是,最终,每个数据集都是独一无二的;你必须以经验为依据评估不同的策略。目前没有理论可以事先告诉你如何才能最佳地解决问题。你必须不断迭代。

依我所见,通过大约 10%的改进来超过无学习基线可能是你在这个数据集上能做到的最好的。这并不算太好,但这些结果是有道理的:如果你可以访问来自不同位置广阔网格的数据,那么接近未来的天气是高度可预测的,但如果你只有来自单个位置的测量数据,那么天气的演变就不太可预测。你所处的地方的天气演变取决于周围地区的当前天气模式。

市场与机器学习

一些读者肯定会想要将我在这里介绍的技术应用于预测股票市场上证券的未来价格(或者货币汇率等)。然而,市场具有与天气模式等自然现象非常不同的统计特征。在涉及市场时,过去的表现是未来回报的良好预测指标——通过后视镜看路是开车的不好方式。另一方面,机器学习适用于过去未来的良好预测指标的数据集,比如天气、电力消耗或商店的客流量。

请始终记住,所有交易基本上都是信息套利:通过利用其他市场参与者所错过的数据或见解来获得优势。试图使用众所周知的机器学习技术和公开可用的数据来击败市场实际上是一条死路,因为您与其他人相比没有任何信息优势。您可能会浪费时间和资源,却一无所获。

摘要

  • 正如你在第五章中首次学到的,当面对一个新问题时,首先为你选择的指标建立常识基线是很好的。如果你没有一个要超越的基线,那么你无法判断自己是否在取得真正的进步。

  • 在使用昂贵模型之前,请尝试简单模型,以确保额外的开支是合理的。有时,一个简单模型会被证明是你的最佳选择。

  • 当您有数据的顺序很重要,特别是对于时间序列数据时,循环网络是一个很好的选择,并且很容易胜过首先展平时间数据的模型。Keras 中提供的两个基本 RNN 层是 LSTM 层和 GRU 层。

  • 要在循环网络中使用 dropout,您应该使用时间恒定的 dropout 掩码和循环 dropout 掩码。这些都内置在 Keras 循环层中,所以您只需使用循环层的 recurrent_dropout 参数即可。

  • 堆叠的 RNN 提供比单个 RNN 层更多的表示能力。它们也要昂贵得多,因此并不总是值得。尽管它们在复杂问题(如机器翻译)上提供了明显的收益,但它们并不总是与较小、较简单的问题相关。

  1. ¹ Adam Erickson 和 Olaf Kolle,www.bgc-jena.mpg.de/wetter

  2. ² 请注意,没有 layer_separable_conv_3d(),这不是出于任何理论原因,而只是因为我还没有实现它。

  3. ³ 请参阅,例如,Yoshua Bengio,Patrice Simard 和 Paolo Frasconi 的“使用梯度下降学习长期依赖关系”,IEEE Transactions on Neural Networks 5,第 2 号(1994)。

  4. ⁴ Sepp Hochreiter 和 Jürgen Schmidhuber,“长短期记忆”,神经计算 9,第 8 号(1997)。

  5. ⁵ 请参阅 Yarin Gal 的“深度学习中的不确定性”博士论文(2016),mng.bz/WBq1

  6. ⁶ 请参阅 Cho 等人的“关于神经机器翻译的性质:编码器-解码器方法”(2014),arxiv.org/abs/1409.1259

第十一章:文本的深度学习

本章涵盖

  • 为机器学习应用预处理文本数据

  • 文本处理的词袋方法和序列建模方法

  • Transformer 架构

  • 序列到序列学习

11.1 自然语言处理:鸟瞰视角

在计算机科学中,我们将人类语言,如英语或普通话,称为“自然”语言,以区别于为机器设计的语言,如汇编、LISP 或 XML。每种机器语言都是设计出来的:其起点是人类工程师书写一组形式规则,描述该语言中可以做出的语句以及它们的含义。规则先行,人们只有在规则集合完成后才开始使用该语言。而对于人类语言,情况恰恰相反:使用先行,规则随后产生。自然语言像生物体一样经历了演化过程,这就是使其“自然”的原因。其“规则”,如英语的语法,在事后才被正式化,而且常常被其使用者忽视或打破。因此,尽管

可机器读取的语言具有高度结构化和严格的特性,使用精确的语法规则将来自固定词汇表的确切定义的概念编织在一起,而自然语言则杂乱无章——含糊、混乱、庞大且不断变化。

创造能够理解自然语言的算法是一件大事:语言——尤其是文本——是我们大部分交流和文化生产的基础。互联网主要是文本。语言是我们几乎所有知识的存储方式。我们的思维基本上是建立在语言之上的。然而,机器长期以来一直无法理解自然语言。有些人曾天真地认为,你可以简单地书写“英语的规则集”,就像可以书写 LISP 的规则集一样。早期尝试构建自然语言处理(NLP)系统因此是通过“应用语言学”的视角进行的。工程师和语言学家将手工制作复杂的规则集来执行基本的机器翻译或创建简单的聊天机器人,如 1960 年代著名的 ELIZA 程序,它使用模式匹配来维持非常基本的对话。但语言是一种叛逆的东西:它不容易被形式化。经过几十年的努力,这些系统的能力仍然令人失望。

在 20 世纪 90 年代初,手工制定的规则被视为主流方法。但是从上世纪 80 年代末开始,更快的计算机和更多的数据可用性开始使得更好的替代方案成为可能。当你发现自己正在构建一堆堆临时规则的系统时,作为一个聪明的工程师,你很可能会开始问:“我能否使用一组数据来自动化找到这些规则的过程?我能否在某种规则空间内搜索规则,而不是自己想出来?”就这样,你就开始进行机器学习了。因此,从上世纪 80 年代末开始,我们开始看到机器学习方法应用于自然语言处理。最早的方法基于决策树——其目的实际上是自动化之前系统中的 if/then/else 规则的发展。然后,统计方法开始加速发展,从逻辑回归开始。随着时间的推移,学习到的参数模型完全接管了,语言学开始被视为更多的阻碍而不是有用的工具。早期语音识别研究人员 Frederick Jelinek 在 1990 年代开玩笑说:“每次我解雇一个语言学家,语音识别器的性能都会提高。”

这就是现代 NLP 的内容:利用机器学习和大型数据集,使计算机不仅理解语言(这是一个更为高远的目标),而是将语言片段作为输入并返回一些有用的东西,比如预测以下内容:

  • “这段文字的主题是什么?”(文本分类)

  • “这段文字中是否包含滥用内容?”(内容过滤)

  • “这段文字听起来是积极的还是消极的?”(情感分析)

  • “这不完整句子中的下一个词应该是什么?”(语言建模)

  • “你会如何用德语表达这句话?”(翻译)

  • “你会如何用一段话总结这篇文章?”(总结)

  • 诸如此类。

当然,在整个本章中请记住,你将训练的文本处理模型不会具有人类般的语言理解能力;相反,它们只是在其输入数据中寻找统计规律,这足以在许多简单任务中表现良好。就像计算机视觉是应用于像素的模式识别一样,NLP 是应用于单词、句子和段落的模式识别。

从 1990 年代到 2010 年代初,NLP 的工具集——决策树和逻辑回归——进化缓慢。大部分研究重点放在特征工程上。当我(François)在 2013 年赢得了我的第一个 Kaggle NLP 比赛时,你猜对了,我的模型就是基于决策树和逻辑回归的。然而,大约在 2014 年至 2015 年左右,事情开始改变。多位研究人员开始调查循环神经网络的语言理解能力,特别是 LSTM——一种来自上世纪 90 年代末的序列处理算法,直到那时才悄悄地被关注。

在 2015 年初,Keras 发布了第一个开源的、易于使用的 LSTM 实现,刚好在重新激发对循环神经网络的兴趣的浪潮开始时。此前,只有无法被方便地重用的“研究代码”。从 2015 年到 2017 年,循环神经网络在蓬勃发展的自然语言处理领域占据主导地位。特别是双向 LSTM 模型,在许多重要任务(从摘要到问答再到机器翻译)中都达到了最先进的水平。

最后,在 2017 年至 2018 年左右,一种新的架构崛起取代了 RNN:Transformer,您将在本章的下半部分学习有关它的知识。Transformer 在短时间内实现了领域内的重大进展,如今大多数 NLP 系统都基于它们。

让我们深入了解细节。本章将带你从基础知识到使用 Transformer 进行机器翻译。

11.2 准备文本数据

深度学习模型作为可微分函数,只能处理数值张量:它们无法将原始文本作为输入。对文本进行向量化是将文本转换为数值张量的过程。文本向量化过程有很多形式和方式,但它们都遵循相同的模板(参见图 11.1):

  • 首先,您需要对文本进行标准化以便更容易处理,例如转换为小写或去除标点符号。

  • 您将文本拆分为单位(称为令牌),如字符、单词或一组单词。这称为分词

  • 您需要将每个标记转换为数值向量。通常,这将首先涉及索引数据中存在的所有令牌。

让我们回顾一下每个步骤。

图像

图 11.1 从原始文本到向量的转换

11.2.1 文本标准化

考虑以下这两个句子:

  • “sunset came. i was staring at the Mexico sky. Isnt nature splendid??”

  • “Sunset came; I stared at the México sky. Isn’t nature splendid?”

它们非常相似 - 实际上,它们几乎相同。但是,如果您将它们转换为字节字符串,它们的表示将非常不同,因为“i”和“I”是两个不同的字符,“Mexico”和“México”是两个不同的词,“isnt”不是“isn’t”,等等。机器学习模型事先不知道“i”和“I”是同一个字母,“é”是带重音的“e”,“staring”和“stared”是同一个动词的两种形式。

文本标准化是一种基本的特征工程形式,旨在消除您不希望模型处理的编码差异。这不仅适用于机器学习,如果您构建一个搜索引擎,您也需要做同样的处理。

最简单和最广泛使用的标准化方案之一是“转为小写并去除标点符号”。我们的两个句子变为:

  • “sunset came i was staring at the mexico sky isnt nature splendid”

  • “sunset came i stared at the méxico sky isnt nature splendid”

进展已经非常接近了。另一个常见的转换是将特殊字符转换为标准形式,例如用“e”替换“é”,用“ae”替换“æ”等等。我们的标记“méxico”然后会变成“mexico”。

最后,一个更加高级的标准化模式在机器学习的背景下更少见,那就是词干提取:将一个词的变体(例如动词的不同变形形式)转换为一个共享的单一表示,比如将“caught”和“been catching”变为“[catch]”,或者将“cats”变为“[cat]”。通过词干提取,“was staring”和“stared”会变成类似“[stare]”,而我们的两个相似的句子最终将以相同的编码结束:

  • “日落时我盯着墨西哥的天空,大自然真是壮观啊”

通过这些标准化技术,您的模型将需要更少的训练数据,并且将更好地概括——它不需要丰富的“Sunset”和“sunset”示例来学习它们意味着相同的事情,它将能够理解“México”,即使它只在训练集中看到过“mexico”。当然,标准化也可能会擦除一定量的信息,所以始终牢记上下文:例如,如果您正在编写一个从面试文章中提取问题的模型,它应该绝对将“?”视为一个单独的标记,而不是删除它,因为对于这个特定任务来说,它是一个有用的信号。

11.2.2 文本分割(标记化)

一旦您的文本标准化,您需要将其分割成单元以进行向量化(标记化),这一步被称为标记化。您可以通过三种不同的方式来实现这一点:

  • 单词级标记化—其中标记是以空格(或标点符号)分隔的子字符串。在适用时,将单词进一步分割为子单词的变体,例如将“staring”视为“star+ing”或将“called”视为“call+ed”。

  • N-gram 标记化—其中标记是N个连续单词的组合。例如,“the cat”或“he was”将是 2-gram 标记(也称为 bigrams)。

  • 字符级标记化—其中每个字符都是其自己的标记。实际上,这种方案很少使用,您只会在特定的上下文中真正看到它,例如文本生成或语音识别。

一般来说,您将始终使用单词级别或N-gram 标记化。有两种文本处理模型:那些关心单词顺序的模型称为序列模型,而那些将输入单词视为一组并丢弃它们的原始顺序的模型称为词袋模型。如果您正在构建一个序列模型,您将使用单词级标记化,如果您正在构建一个词袋模型,您将使用N-gram 标记化。N-grams 是一种将一小部分局部单词顺序信息注入模型的方法。在本章中,您将学习更多关于每种类型的模型以及何时使用它们的信息。

理解 N-grams 和词袋

词* N * 元组是一组 * N *(或更少)连续的单词,您可以从句子中提取出来。相同的概念也可以应用于字符而不是单词。

这是一个简单的例子。考虑一下句子“the cat sat on the mat。”它可以分解为以下一组二元组:

c("the", "the cat", "cat", "cat sat", "sat",

"sat on", "on", "on the", "the mat", "mat")

它也可以分解为以下一组三元组:

c("the", "the cat", "cat", "cat sat", "the cat sat",

"sat", "sat on", "on", "cat sat on", "on the",

"sat on the", "the mat", "mat", "on the mat")

这样的一组被称为二元组袋或三元组袋。这里的“袋”一词是指你处理的是一组标记而不是列表或序列:标记没有特定的顺序。这系列标记方法被称为词袋(或词袋-N-gram)。

因为词袋不是一个保留顺序的标记方法(生成的标记被理解为一组,而不是一个序列,并且句子的一般结构丢失了),所以它倾向于在浅层语言处理模型中使用而不是在深度学习模型中使用。提取* N * 元组是一种特征工程,深度学习序列模型放弃了这种手动方法,用分层特征学习替换了它。一维卷积神经网络,循环神经网络和 Transformer 能够学习表示单词和字符组合,而不需要明确告诉它们这些组合的存在,通过查看连续的单词或字符序列。

11.2.3 词汇表索引

一旦您的文本被分割成标记,您需要将每个标记编码为数字表示。你可能会以无状态的方式做这个,比如将每个标记哈希成一个固定的二进制向量,但在实践中,你会构建一个包含在训练数据中找到的所有术语(“词汇表”)的索引,并为词汇表中的每个条目分配一个唯一的整数,类似这样:

词汇表 <- character()

for (string in text_dataset) {

tokens <- string %>%

standardize() %>%

tokenize()

词汇表 <- unique(c(词汇表, tokens))

}

你随后可以将整数索引位置转换为向量编码,该编码可以由神经网络处理,比如说一个独热向量:

one_hot_encode_token <- function(token) {

vector <- array(0, dim = length(vocabulary))

token_index <- match(token, vocabulary)

vector[token_index] <- 1

向量

}

注意,在这一步,将词汇表限制为训练数据中最常见的前 20,000 或 30,000 个单词是很常见的。任何文本数据集通常都包含大量的唯一术语,其中大多数只出现一两次。索引这些罕见的术语将导致一个特征空间过大,其中大多数特征几乎没有信息内容。

还记得你在第四章和第五章在 IMDB 数据集上训练你的第一个深度学习模型时吗?你当时使用的来自 dataset_imdb()的数据已经预处理成整数序列,其中每个整数代表一个给定的词。那时,我们使用了 num_words = 10000 的设置,将我们的词汇限制为训练数据中前 10000 个最常见的单词。

现在,有一个重要的细节我们不能忽视:当我们在词汇表索引中查找一个新标记时,它可能并不存在。你的训练数据可能不包含任何“cherimoya”一词(或者你可能将其从索引中排除,因为它太稀有了),所以执行 token_index = match(“cherimoya”, vocabulary)可能返回 NA。为了处理这种情况,你应该使用一个“未知词汇”索引(缩写为OOV 索引)——用于任何不在索引中的标记的通配符。通常是索引 1:实际上你正在执行 token_index = match(“cherimoya”, vocabulary, nomatch = 1)。当将一系列整数解码回单词时,你会用类似“[UNK]”的东西来替换 1(你会称其为“未知标记”)。

“为什么使用 1 而不是 0?” 你可能会问。那是因为 0 已经被使用了。你通常会使用两个特殊的标记:未知标记(索引 1)和掩码标记(索引 0)。虽然未知标记表示“这里有一个我们不认识的词”,但掩码标记告诉我们“忽略我,我不是一个词”。你会特别用它来填充序列数据:因为数据批次需要是连续的,序列数据批次中的所有序列必须具有相同的长度,所以较短的序列应该填充到最长序列的长度。如果你想要创建一个数据批次,其中包含序列 c(5, 7, 124, 4, 89)和 c(8, 34, 21),它看起来应该是这样的:

rbind(c(5,  7, 124, 4, 89),

c(8, 34,  21, 0,  0))

你在第四章和第五章中使用的 IMDB 数据集的整数序列批次是这样填充的。

11.2.4 使用 layer_text_vectorization

到目前为止,我介绍的每个步骤都很容易在纯 R 中实现。也许你可以写出类似这样的代码:

new_vectorizer <- function() {

self <- new.env(parent = emptyenv())

attr(self, "class") <- "Vectorizer"

self$vocabulary <- c("[UNK]")

self$standardize <- function(text) {

text <- tolower(text)

gsub("[[:punct:]]", "", text)➊

}

self$tokenize <- function(text) {

unlist(strsplit(text, "[[:space:]]+"))➋

}

self$make_vocabulary <- function(text_dataset) {➌

tokens <- text_dataset %>%

self$standardize() %>%

self$tokenize()

self\(vocabulary <- unique(c(self\)vocabulary, tokens))

}

self$encode <- function(text) {

tokens <- text %>%

self$standardize() %>%

self$tokenize()

match(tokens, table = self$vocabulary, nomatch = 1)➍

}

self$decode <- function(int_sequence) {

vocab_w_mask_token <- c("", self$vocabulary)

vocab_w_mask_token[int_sequence + 1]➎

}

self

}

vectorizer <- new_vectorizer()

dataset <- c("我写,擦除,重写",➏

"再次擦除,然后",

"虞美人开花了。")

vectorizer$make_vocabulary(dataset)

删除标点符号。

按空格分割并返回一个扁平化的字符向量。

text_dataset 将是一个字符串向量,即 R 字符向量。

nomatch 匹配 "[UNK]"。

掩码令牌通常被编码为 0 整数,并解码为空字符串:" "。

诗人北诗的俳句

它完成了任务:

test_sentence <- "我写,重写,仍然再次重写"

encoded_sentence <- vectorizer$encode(test_sentence)

print(encoded_sentence)

[1] 2 3 5 7 1 5 6

decoded_sentence <- vectorizer$decode(encoded_sentence)

print(decoded_sentence)

[1] "i"      "write"   "rewrite" "and"      "[UNK]"   "rewrite" "again"

但是,使用这样的内容效率不会很高。在实践中,您将使用 Keras layer_text_vectorization(),它快速高效,并且可以直接放入 TF Dataset 流水线或 Keras 模型中。这是 layer_text_vectorization() 的外观:

text_vectorization <

layer_text_vectorization(output_mode = "int")➊

配置该层以返回以整数索引编码的单词序列。还有其他几种可用的输出模式,您很快就会看到它们的作用。

默认情况下,layer_text_vectorization()将使用“转换为小写并删除标点符号”的设置进行文本标准化,并使用“按空格分割”进行标记化。但是重要的是,您可以提供自定义函数进行标准化和标记化,这意味着该层足够灵活,可以处理任何用例。请注意,此类自定义函数应该对 tf.string 类型的张量进行操作,而不是常规的 R 字符向量!例如,默认层行为相当于以下内容:

library(tensorflow)

custom_standardization_fn <- function(string_tensor) {

string_tensor %>%

tf\(strings\)lower() %>% ➊

tf\(strings\)regex_replace("[[:punct:]]", "")➋

}

custom_split_fn <- function(string_tensor) {

tf\(strings\)split(string_tensor)➌

}

text_vectorization <- layer_text_vectorization(

output_mode = "int",

standardize = custom_standardization_fn,

split = custom_split_fn

)

将字符串转换为小写。

用空字符串替换标点符号字符。

按空格分割字符串。

要对文本语料库的词汇进行索引,只需调用该层的 adapt()方法,传入一个 TF Dataset 对象,该对象产生字符串,或者只需传入一个 R 字符向量:

dataset <- c("我写,擦除,重写",

"再次擦除,然后",

"虞美人开花了。")

adapt(text_vectorization, dataset)

请注意,您可以通过 get_vocabulary() 检索计算出的词汇表。如果您需要将编码为整数序列的文本转换回单词,这可能很有用。词汇表中的前两个条目是掩码令牌(索引 0)和 OOV 令牌(索引 1)。词汇表中的条目按频率排序,因此在真实世界的数据集中,像“the”或“a”这样非常常见的词将首先出现。

图 11.1 显示词汇表

get_vocabulary(text_vectorization)

Image

为了演示,让我们尝试对一个示例句子进行编码然后解码:

vocabulary <- text_vectorization %>% get_vocabulary()

test_sentence <- "我写,改写,还在不断改写"

encoded_sentence <- text_vectorization(test_sentence)

decoded_sentence <- paste(vocabulary[as.integer(encoded_sentence) + 1],

collapse = " ")

encoded_sentence

tf.Tensor([ 7  3  5  9  1  5  10], shape=(7), dtype=int64)

decoded_sentence

[1] "我写改写和[UNK]再次改写"

在 TF Dataset 管道中使用 layer_text_vectorization()或作为模型的一部分

因为 layer_text_vectorization()主要是一个字典查找操作,将标记转换为整数,它不能在 GPU(或 TPU)上执行——只能在 CPU 上执行。所以,如果你在 GPU 上训练模型,你的 layer_text_vectorization()将在 CPU 上运行,然后将其输出发送到 GPU。这对性能有重要的影响。

有两种方法可以使用我们的 layer_text_vectorization()。第一种选择是将其放入 TF Dataset 管道中,就像这样:

int_sequence_dataset <- string_dataset %>%➊

dataset_map(text_vectorization,

num_parallel_calls = 4)➋

string_dataset 将是一个产生字符串张量的 TF Dataset。

num_parallel_calls 参数用于在多个 CPU 核心上并行化 dataset_map()调用。

第二种选择是将其作为模型的一部分(毕竟,它是一个 Keras 层),就像这样(伪代码中):

text_input <- layer_input(shape = shape(), dtype = "string")➊

vectorized_text <- text_vectorization(text_input)➋

embedded_input <- vectorized_text %>% layer_embedding(…)

output <- embedded_input %>% …

model <- keras_model(text_input, output)

创建一个期望字符串的符号输入。

将文本向量化层应用于它。

你可以继续在上面链接新的层——就像你的常规 Functional API 模型一样。

这两者之间有一个重要的区别:如果向量化步骤是模型的一部分,它将与模型的其余部分同步进行。这意味着在每个训练步骤中,模型的其余部分(放置在 GPU 上)将不得不等待 layer_text_vectorization()的输出(放置在 CPU 上)准备好才能开始工作。与此同时,将层放入 TF Dataset 管道中使您能够在 CPU 上对数据进行异步预处理:当 GPU 在一批向量化数据上运行模型时,CPU 通过向量化下一批原始字符串来保持忙碌。

如果你在 GPU 或 TPU 上训练模型,你可能会选择第一种选项以获得最佳性能。这是我们将在本章的所有实际示例中所做的。不过,在 CPU 上进行训练时,同步处理是可以接受的:无论选择哪个选项,你都将获得 100%的核心利用率。

现在,如果你要将我们的模型导出到生产环境中,你会希望发布一个接受原始字符串作为输入的模型,就像上述第二个选项的代码片段中一样;否则,你将不得不在生产环境中重新实现文本标准化和标记化(也许是在 JavaScript 中?),并且你将面临引入小的预处理差异可能会影响模型准确性的风险。幸运的是,layer_text_vectorization() 让你可以将文本预处理直接包含到你的模型中,使得部署变得更容易,即使你最初将该层作为 TF Dataset 管道的一部分使用。在本章后面的侧边栏中,“导出处理原始字符串的模型”,你将学习如何导出一个仅进行推断的训练模型,其中包含了文本预处理。

你现在已经学会了关于文本预处理的所有知识。让我们进入建模阶段。

11.3 表示词组的两种方法:集合和序列

机器学习模型应该如何表示单个单词是一个相对没有争议的问题:它们是分类特征(来自预定义集合的值),我们知道如何处理这些特征。它们应该被编码为特征空间中的维度,或者作为类别向量(在这种情况下是单词向量)。然而,一个更为棘手的问题是如何编码单词被编织到句子中的方式:词序。

自然语言中的顺序问题是一个有趣的问题:与时间序列的步骤不同,句子中的单词没有自然的、规范的顺序。不同的语言以非常不同的方式排序相似的单词。例如,英语的句子结构与日语大不相同。甚至在同一种语言中,你通常可以通过稍微重排单词来用不同的方式表达同样的事情。更进一步,如果你完全随机排列一个短句中的单词,你仍然可以大致理解它的意思,尽管在许多情况下,会出现相当大的歧义。顺序显然很重要,但它与意义的关系并不简单。

如何表示词序是不同类型的自然语言处理架构产生的关键问题。你可以做的最简单的事情就是丢弃顺序,将文本视为无序的单词集合——这给你带来了词袋模型。你也可以决定严格按照单词出现的顺序逐个处理单词,就像时间序列中的步骤一样——然后你可以利用上一章的递归模型。最后,还可以采用混合方法:transformers 架构在技术上是无关顺序的,但它将单词位置信息注入到它处理的表示中,这使得它可以同时查看句子的不同部分(不像递归神经网络),同时又具有顺序感知。因为它们考虑了单词顺序,所以递归神经网络和 transformers 被称为序列模型

历史上,大多数早期应用于 NLP 的机器学习都只涉及词袋模型。 对序列模型的兴趣直到 2015 年才开始上升,随着递归神经网络的复兴。 如今,这两种方法都仍然相关。 让我们看看它们是如何工作的以及何时利用它们。

我们将在 IMDB 电影评论情感分类数据集上演示每种方法。 在第四章和第五章中,您使用了 IMDB 数据集的预矢量化版本; 现在让我们处理原始 IMDB 文本数据,就像您在真实世界中处理新的文本分类问题时所做的那样。

11.3.1 准备 IMDB 电影评论数据

让我们从 Andrew Maas 的斯坦福页面下载数据集并解压缩:

url <- "https://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz"

filename <- basename(url)

options(timeout = 60 * 10)

download.file(url, destfile = filename)

untar(filename)

10 分钟超时

您将得到一个名为 aclImdb 的目录,其结构如下:

fs::dir_tree("aclImdb", recurse = 1, type = "directory")

图像

例如,train/pos/目录包含一组 12500 个文本文件,每个文件都包含一个用作训练数据的积极情感电影评论的文本正文。 负面情绪的评论存储在“neg”目录中。 总共,有 25000 个文本文件用于训练,另外 25000 个用于测试。

里面还有一个名为 train/unsup 的子目录,我们不需要。 让我们删除它:

fs::dir_delete("aclImdb/train/unsup/")

查看一下其中一些文本文件的内容。 无论您处理的是文本数据还是图像数据,请务必在开始对其进行建模之前始终检查数据的外观。 这将为您对模型实际正在做什么有基础的直觉:

writeLines(readLines("aclImdb/train/pos/4077_10.txt", warn = FALSE))

我在 90 年代初在英国电视上第一次看到这部电影,当时我很喜欢,但我错过了录制的机会,许多年过去了,但这部电影一直留在我心中,我不再希望能再次在电视上看到它,最让我印象深刻的是结尾,整个城堡部分真的触动了我,它很容易观看,有一个很棒的故事,优美的音乐,等等,我可以说它有多么好,但每个人看完后都会带走自己的最爱,是的,动画效果非常出色,非常美丽,但在很少的几个部分显示出它的年龄,但这现在已经成为它美丽的一部分,我很高兴它已经在 DVD 上发行,因为它是我有史以来最喜欢的十大电影之一。 买下它或者租下它,只是看看它,最好是在夜晚独自一人,旁边备有饮料和食物,这样你就不必停止电影了。

享受吧

接下来,我们将 20%的训练文本文件分离出来,放在一个新目录 aclImdb/val 中以准备验证集。 与之前一样,我们将使用 fs R 软件包:

library(fs)

set.seed(1337)

base_dir <- path("aclImdb")

for (category in c("neg", "pos")) {

filepaths <- dir_ls(base_dir / "train" / category)

num_val_samples <- round(0.2 * length(filepaths))➋

val_files <- sample(filepaths, num_val_samples)

dir_create(base_dir / "val" / category)

file_move(val_files, ➌

base_dir / "val" / category)

}

设置一个种子,以确保我们每次运行代码时都从 sample()调用中获得相同的验证集。

将训练文件的 20%用于验证。

将文件移动到 aclImdb/val/neg 和 aclImdb/val/pos。

还记得吗,在第八章中,我们使用了 image_dataset_from_directory()工具来为目录结构创建图像和标签的批处理 TF Dataset 吗?你可以使用 text_dataset_from_directory()工具来为文本文件做同样的事情。让我们为训练、验证和测试创建三个 TF Dataset 对象:

导入库(keras)

导入库(tfdatasets)

train_ds <- text_dataset_from_directory("aclImdb/train")➊

val_ds <- text_dataset_from_directory("aclImdb/val")

test_ds <- text_dataset_from_directory("aclImdb/test")➋

运行此行应输出“找到 20000 个文件,属于 2 个类”; 如果看到“找到 70000 个文件,属于 3 个类”,这意味着您忘记删除 aclImdb/train/unsup 目录了。

默认的批量大小是 32。如果在您的机器上训练模型时遇到内存不足的错误,可以尝试较小的批量大小:text_dataset_from_directory("aclImdb/train", batch_size = 8)。

这些数据集产生的输入是 TensorFlow tf.string 张量,目标是 int32 张量,编码值为“0”或“1”。

列出 11.2 显示第一批的形状和 dtype

c(inputs, targets) %<-% iter_next(as_iterator(train_ds))

str(inputs)

tf.Tensor: shape=(32), dtype=string, numpy=…>

str(targets)

tf.Tensor: shape=(32), dtype=int32, numpy=…>

inputs[1]

tf.Tensor(b’让我先说,我在租借这部电影之前看过一些评论,有点知道会发生什么。但我还是被它的糟糕程度吓了一跳。

我是个大狼人迷,一直都很喜欢…, dtype=string, numpy=…>

否则,就算了吧。’, shape=(), dtype=string)

targets[1]

tf.Tensor(0, shape=(), dtype=int32)

准备好了。现在让我们尝试从这些数据中学习一些东西。

11.3.2 将单词处理为集合:词袋法

处理机器学习模型的文本的最简单方法是丢弃顺序,并将其视为令牌的集合(“袋”)。你可以查看单个词(unigrams),或者尝试通过查看一组连续令牌(N-grams)来恢复一些局部顺序信息。

单词(UNIGRAMS)与二进制编码

如果你使用单词袋,那么句子“the cat sat on the mat”就成为一个字符向量,我们忽略顺序:

c("cat", "mat", "on", "sat", "the")

这种编码的主要优点是,您可以将整个文本表示为单个向量,其中每个条目都是给定单词的存在指示器。例如,使用二进制编码(多热编码),您可以将文本编码为一个向量,具有与词汇表中的单词数量一样多的维度,其中几乎每个地方都是 0,并且对于编码文本中存在的单词的一些维度为 1。这就是我们在第 4 和 5 章中处理文本数据时所做的。让我们在我们的任务上尝试一下。

首先,让我们使用 layer_text_vectorization() 层处理我们的原始文本数据集,以便它们产生多热编码的二进制单词向量。我们的层将只查看单词(也就是说,unigrams)。

图 11.3 使用 layer_text_vectorization() 预处理我们的数据集

text_vectorization <-

layer_text_vectorization(max_tokens = 20000,➊

output_mode = "multi_hot")➋

text_only_train_ds <- train_ds %>%➌

dataset_map(function(x, y) x)

adapt(text_vectorization, text_only_train_ds)➍

binary_1gram_train_ds <- train_ds %>%➎

dataset_map( ~ list(text_vectorization(.x), .y),

num_parallel_calls = 4)

binary_1gram_val_ds <- val_ds %>%

dataset_map( ~ list(text_vectorization(.x), .y),

num_parallel_calls = 4)

binary_1gram_test_ds <- test_ds %>%

dataset_map( ~ list(text_vectorization(.x), .y),

num_parallel_calls = 4)

将词汇表限制为 20,000 个最常见的单词。否则,我们将索引��练数据中的每个单词 —— 可能是几十万个仅出现一两次的条目,因此并没有信息量。通常,20,000 是文本分类的正确词汇表大小。

将输出标记编码为多热二进制向量。

准备一个仅生成原始文本输入(无标签)的数据集。

使用该数据集通过 adapt() 方法对数据集词汇进行索引。

准备我们的训练、验证和测试数据集的处理版本。确保指定 num_parallel_calls 以利用多个 CPU 内核。

~ 公式函数定义

对于 dataset_map() 的 map_func 参数,我们传递了一个用定义定义的公式,而不是函数。如果 map_func 参数是一个公式,例如 ~ .x + 2,它将被转换为函数。有三种方法可以引用参数:

  • 对于单参数函数,请使用 .x。

  • 对于两个参数函数,请使用 .x 和 .y。

  • 对于更多的参数,使用 ..1, ..2, ..3 等等。

这种语法允许您创建非常紧凑的匿名函数。有关更多详细信息和示例,请参阅 R 中的 ?purrr::map() 帮助页面。

您可以尝试检查这些数据集中的一个的输出。

图 11.4 检查我们的二元一元组数据集的输出

c(inputs, targets) %<-% iter_next(as_iterator(binary_1gram_train_ds))

str(inputs)

<tf.Tensor: shape=(32, 20000), dtype=float32, numpy=…>

str(targets)

<tf.Tensor: shape=(32), dtype=int32, numpy=…>

inputs[1, ]➊

tf.Tensor([1. 1. 1. … 0. 0. 0.], shape=(20000), dtype=float32)

targets[1]

tf.Tensor(1, shape=(), dtype=int32)

输入是 20,000 维向量的批处理。这些向量完全由 1 和 0 组成。

接下来,让我们编写一个可重复使用的模型构建函数,我们将在本节中的所有实验中使用。

清单 11.5 我们的模型构建实用工具

get_model <- function(max_tokens = 20000, hidden_dim = 16) {

inputs <- layer_input(shape = c(max_tokens))

输出 <- inputs %>%

layer_dense(hidden_dim, activation = "relu") %>%

layer_dropout(0.5) %>%

layer_dense(1, activation = "sigmoid")

model <- keras_model(inputs, outputs)

model %>% 编译(optimizer = "rmsprop",

损失 = "binary_crossentropy",

指标 = "准确率")

model

}

最后,让我们训练和测试我们的模型。

清单 11.6 训练和测试二进制单字母模型

model <- get_model()

model

图片

回调 <- list(

callback_model_checkpoint("binary_1gram.keras", save_best_only = TRUE)

)

model %>% 拟合(

dataset_cache(binary_1gram_train_ds),

验证数据 = dataset_cache(binary_1gram_val_ds),➊

epochs = 10,

回调 = callbacks

)

model <- load_model_tf("binary_1gram.keras")

cat(sprintf(

"测试准确率: %.3f\n", evaluate(model, binary_1gram_test_ds)["accuracy"]))

测试准确率: 0.887

我们在数据集上调用 dataset_cache() 来将它们缓存到内存中:这样,我们就只在第一个 epoch 进行预处理一次,并在后续的 epoch 中重新使用预处理的文本。这只适用于数据足够小以适应内存。

这使我们达到了 88.7% 的测试准确率:不错!请注意,在本例中,因为数据集是一个平衡的两类分类数据集(正样本和负样本数量相同),如果不训练实际模型,我们所能达到的“简单基线”只能达到 50%。同时,在这个数据集上不利用外部数据可实现的最佳分数约为 95% 的测试准确率。

二元组使用二进制编码

当然,舍弃单词顺序非常简化,因为即使是原子概念也可以通过多个词表达:术语“美国”传达的概念与单独取词“states”和“united”的含义截然不同。因此,通常你会通过查看N元组而不是单词(最常见的是二元组)向你的词袋表示中重新注入局部顺序信息。

有了二元组,我们的句子变成了:

c("the", "the cat", "cat", "cat sat", "sat",

"坐在", "在", "在地上", "地上的", "垫子")

layer_text_vectorization() 层可以配置为返回任意N-元组:二元组、三元组等等。只需像以下清单中那样传递一个 ngrams = N 的参数。

清单 11.7 配置 layer_text_vectorization() 返回二元组

text_vectorization <

layer_text_vectorization(ngrams = 2,

max_tokens = 20000,

输出模式 = "multi_hot")

让我们测试一下当以这种二进制编码的二元组袋训练时模型的表现(清单 11.8)。

清单 11.8 训练和测试二进制双字母模型

适应(文本向量化, 仅文本训练数据集)

数据集向量化 <- function(数据集) {➊

数据集 %>%

数据集映射(~ 列表(文本向量化(.x), .y),

num_parallel_calls = 4)

}

binary_2gram_train_ds <- 训练数据集 %>% 数据集向量化()

binary_2gram_val_ds <- 验证数据集 %>% 数据集向量化()

binary_2gram_test_ds <- 测试数据集 %>% 数据集向量化()

model <- 获取模型()

model

图像

callbacks <- 列表(回调模型检查点("二元二元组.keras",

save_best_only = TRUE))

model %>% 拟合(

数据集缓存(二元二元组训练数据集),

validation_data = 数据集缓存(二元二元组验证数据集),

epochs = 10,

callbacks = 回调函数

)

model <- 加载模型 _tf("二元二元组.keras")

评估(模型, 二元二元组测试数据集)["准确率"] %>%

sprintf("测试准确率: %.3f\n", .) %>% cat()

测试准确率: 0.895

定义一个辅助函数,用于将 text_vectorization 层应用于文本 TF 数据集,因为我们将在本章中多次执行此操作(使用不同的 text_vectorization 层)。

现在我们的测试准确率达到了 89.5%,这是一个明显的提升!原来局部顺序非常重要。

使用 TF-IDF 编码的二元组

您还可以通过计算每个单词或N-gram 出现的次数来为这个表示添加更多信息,也就是说,通过对文本中的单词进行直方图处理:

c("the" = 2, "the cat" = 1, "cat" = 1, "cat sat" = 1, "sat" = 1,

"坐在" = 1, "在" = 1, "在地毯上" = 1, "地毯上" = 1, "地毯" = 1)

如果您正在进行文本分类,知道一个单词在样本中出现的次数至关重要:任何足够长的电影评论都可能包含“terrible”这个词,无论情感如何,但是包含“terrible”一词多次的评论很可能是负面的。这里是如何使用 layer_text_ vectorization() 计算二元组出现次数的:

清单 11.9 配置 layer_text_vectorization() 以返回令牌计数

text_vectorization <

layer_text_vectorization(ngrams = 2,

max_tokens = 20000,

output_mode = "count")

当然,无论文本内容如何,某些词汇都会比其他词汇出现得更频繁。像“the”、“a”、“is”和“are”这样的词汇将始终主导您的词频直方图,淹没其他词汇,尽管它们在分类上几乎是无用的特征。我们该如何解决这个问题呢?

你已经猜到了:通过归一化。我们可以通过减去均值并除以方差(计算在整个训练数据集上)来简单地规范化词频。那是有道理的。除了大多数向量化的句子几乎完全由零组成(我们之前的示例特征有 12 个非零条目和 19,988 个零条目),这种性质称为“稀疏性”。这是一个很好的属性,因为它大大减少了计算负载并减少了过拟合的风险。如果我们从每个特征中减去平均值,我们将破坏稀疏性。因此,我们使用的任何规范化方案都应该是仅除法。那么,我们应该用什么作为分母呢?最佳做法是采用一种称为 TF-IDF 规范化 的东西 —— TF-IDF 代表“词频,逆文档频率”。

理解 TF-IDF 规范化

一个给定术语在文档中出现的次数越多,该术语对于理解文档内容的重要性就越大。同时,该术语在数据集中出现的频率也很重要:几乎在每个文档中出现的术语(如“the”或“a”)并不特别信息丰富,而在所有文本的一个小子集中出现的术语(如“Herzog”)非常具有特色和重要性。TF-IDF 是将这两个想法融合起来的一个指标。它通过采用“词频”,即术语在当前文档中出现的次数,除以“文档频率”的度量来加权给定术语,后者估计了术语在数据集中出现的频率。您可以如下计算它:

tf_idf <- function(term, document, dataset) {

term_freq <- sum(document == term)➊

doc_freqs <- sapply(dataset, function(doc) sum(doc == term))➋

doc_freq <- log(1 + sum(doc_freqs))

term_freq / doc_freq

}

计算文档中 'term' 出现的次数。

计算 'term' 在整个数据集中出现的次数。

TF-IDF 是如此常见,以至于它已经内置到 layer_text_vectorization() 中。你所需要做的就是将 output_mode 参数切换到 “tf_idf”。

清单 11.10 配置 layer_text_vectorization 以返回 TF-IDF 输出

text_vectorization <

layer_text_vectorization(ngrams = 2,

max_tokens = 20000,

output_mode = "tf_idf")

让我们用这个方案训练一个新模型。

清单 11.11 训练和测试 TF-IDF 二元模型

with(tf$device("CPU"), {➊

适应(text_vectorization, text_only_train_ds) ➋

})

tfidf_2gram_train_ds <- train_ds %>% 数据集向量化()

tfidf_2gram_val_ds <- val_ds %>% 数据集向量化()

tfidf_2gram_test_ds <- test_ds %>% 数据集向量化()

model <- 获取模型()

model

![图像

回调 <- list(callback_model_checkpoint("tfidf_2gram.keras",

save_best_only = TRUE))

model %>% 拟合(

dataset_cache(tfidf_2gram_train_ds),

验证数据 = dataset_cache(tfidf_2gram_val_ds),

epochs = 10,

回调 = 回调

)

model <- load_model_tf("tfidf_2gram.keras")

评估(model, tfidf_2gram_test_ds)["accuracy"] %>%

sprintf("Test acc: %.3f", .) %>% cat("\n")

Test acc: 0.896

我们将此操作固定在 CPU 上,因为它使用了 GPU 设备尚不支持的操作。

adapt() 调用将学习 TF-IDF 权重以及词汇表。

这给我们在 IMDB 分类任务上的测试准确率为 89.6%:在这种情况下似乎并不特别有帮助。然而,对于许多文本分类数据集,使用 TF-IDF 相对于纯二进制编码,会看到增加一个百分点是很典型的。

导出一个处理原始字符串的模型

在前面的例子中,我们在 TF Dataset 管道的一部分中进行了文本标准化、拆分和索引。但是,如果我们想要导出一个独立于此管道的单独模型,我们应该确保它包含自己的文本预处理(否则,你将不得不在生产环境中重新实现,这可能具有挑战性,或者可能导致训练数据和生产数据之间的细微差异)。幸运的是,这很容易。

只需创建一个新模型,该模型重用你的 text_vectorization 层,并将刚训练的模型添加到其中:

inputs <- layer_input(shape = c(1), dtype = "string") ➊

outputs <- inputs %>%

text_vectorization() %>%➋

model() ➌

inference_model <- keras_model(inputs, outputs)➍

一个输入样本将是一个字符串。

应用文本预处理。

应用先前训练过的模型。

实例化端到端模型。

结果生成的模型可以处理原始字符串的批次:

raw_text_data <- "那是一部很棒的电影,我喜欢它。" %>%

as_tensor(shape = c(-1, 1))➊

predictions <- inference_model(raw_text_data)

str(predictions)

<tf.Tensor: shape=(1, 1), dtype=float32, numpy=array([[0.93249124]],

Image dtype=float32)>

cat(sprintf("%.2f percent positive\n",

as.numeric(predictions) * 100))

93.25 percent positive

该模型期望输入为样本批次,即一列矩阵。

11.3.3 将单词作为序列进行处理:序列模型方法

这些最近的例子清楚地表明了单词顺序的重要性:基于顺序的特征的手动工程处理,例如二元组,可以带来很好的准确率提升。现在记住:深度学习的历史是从手动特征工程向模型自动从数据中学习其自己的特征的移动。如果,而不是手动制作基于顺序的特征,我们将模型暴露于原始单词序列,并让其自行找出这样的特征?这就是序列模型的含义。

要实现一个序列模型,你首先要将你的输入样本表示为整数索引序列(一个整数代表一个单词)。然后,你会将每个整数映射到一个向量以获得向量序列。最后,你将这些向量序列馈送到一堆层中,这些层可以对相邻向量的特征进行交叉相关,例如 1D 卷积网络、RNN 或 Transformer。

在 2016 年至 2017 年期间的一段时间里,双向 RNN(特别是双向 LSTM)被认为是序列建模的最先进技术。因为您已经熟悉了这种架构,所以我们将在我们的第一个序列模型示例中使用它。然而,现在几乎所有的序列建模都是用 Transformers 完成的,我们很快就会介绍。奇怪的是,一维卷积网络在自然语言处理中从未很受欢迎,尽管在我的经验中,一堆深度可分离的 1D 卷积往往可以以大大降低的计算成本达到与双向 LSTM 相媲美的性能。

第一个实际例子

让我们尝试在实践中使用第一个序列模型。首先,让我们准备返回整数序列的数据集。

图 11.12 准备整数序列数据集

max_length <— 600 ➊

max_tokens <— 20000

text_vectorization <- layer_text_vectorization(

max_tokens = max_tokens,

output_mode = "int",

output_sequence_length = max_length

)

adapt(text_vectorization, text_only_train_ds)

int_train_ds <- train_ds %>% dataset_vectorize()

int_val_ds <- val_ds %>% dataset_vectorize()

int_test_ds <- test_ds %>% dataset_vectorize()

为了保持可管理的输入大小,我们将在前 600 个单词之后截断输入。这是一个合理的选择,因为平均评论长度为 233 个单词,只有 5% 的评论超过 600 个单词。

接下来,让我们构建一个模型。将整数序列转换为向量序列的最简单方法是对整数进行 one-hot 编码(每个维度代表词汇表中的一个可能的术语)。在这些 one-hot 向量之上,我们将添加一个简单的双向 LSTM。

图 11.13 基于 one-hot 编码向量序列构建的序列模型

inputs <- layer_input(shape(NULL), dtype = "int64")➊

embedded <- inputs %>%

tf$one_hot(depth = as.integer(max_tokens))➋

outputs <- embedded %>%

bidirectional(layer_lstm(units = 32)) %>%➌

layer_dropout(.5) %>%

layer_dense(1, activation = "sigmoid")➍

model <- keras_model(inputs, outputs)

model %>% compile(optimizer = "rmsprop",

loss = "binary_crossentropy",

metrics = "accuracy")

model

图像

一个输入是整数序列。

将整数编码为二进制的 20000 维向量。

添加一个双向 LSTM。

最后,添加一个分类层。

现在,让我们训练我们的模型。

图 11.14 训练一个基本的序列模型

callbacks <- list(

callback_model_checkpoint("one_hot_bidir_lstm.keras",

save_best_only = TRUE))

首先观察到:这个模型训练非常缓慢,特别是与上一节的轻量级模型相比。这是因为我们的输入相当大:每个输入样本都被编码为大小为(600,20000)的矩阵(每个样本 600 个单词,20000 个可能的单词)。这对于单个电影评论来说是 1200 万个浮点数。我们的双向 LSTM 需要做很多工作。其次,模型只能达到 87%的测试准确率——它的表现远不如我们的(非常快速的)二元 unigram 模型。

显然,使用 one-hot 编码将单词转换为向量,这是我们可以做的最简单的事情,不是一个好主意。有一个更好的方法:词嵌入

减小批处理大小以避免内存错误

根据您的机器和 GPU 的可用 RAM,您可能会遇到内存不足错误,尝试训练更大的双向模型。如果发生这种情况,请尝试使用较小的批处理大小进行训练。您可以将较小的 batch_size 参数传递给 text_dataset_from_directory(batch_size = ),或者您可以重新对现有的 TF Dataset 进行重新分批,如下所示:

int_train_ds_smaller <- int_train_ds %>%

dataset_unbatch() %>%

dataset_batch(16)

使用费曼技巧,你只需花上20 min就能深入理解知识点,而且记忆深刻,难以遗忘

epochs = 10, callbacks = callbacks)

model <- load_model_tf("one_hot_bidir_lstm.keras")

sprintf("测试准确率:%.3f", evaluate(model, int_test_ds)["accuracy"])

[1] "测试准确率:0.873"

理解词嵌入

关键的是,当你通过 one-hot 编码对某物进行编码时,你正在做出一个特征工程的决定。你在你的模型中注入了一个关于特征空间结构的基本假设。这个假设是你正在编码的不同标记彼此独立:确实,one-hot 向量彼此正交。在单词的情况下,这个假设显然是错误的。单词形成了一个结构化的空间:它们彼此共享信息。单词“电影”和“影片”在大多数句子中是可以互换的,所以表示“电影”的向量不应该正交于表示“影片”的向量——它们应该是相同的向量,或者足够接近。

为了变得更加抽象,两个词向量之间的几何关系应该反映出这些词之间的语义关系。例如,在一个合理的词向量空间中,你期望同义词被嵌入到相似的词向量中,而且通常,你期望任意两个词向量之间的几何距离(如余弦距离或 L2 距离)与相关词之间的“语义距离”相关联。意思不同的词应该相距较远,而相关的词应该更接近。

词嵌入是单词的向量表示,它恰好实现了这一点:它们将人类语言映射到结构化几何空间。虽然通过一位有效编码获得的向量是二进制的、稀疏的(主要由零组成)和非常高维的(与词汇量中的单词数量相同的维度),但是词嵌入是低维的浮点向量(即稠密向量,与稀疏向量相对);请参见图 11.2。当处理非常大的词汇表时,常见的词嵌入是 256 维、512 维或 1,024 维。另一方面,一位有效编码单词通常会导致 20,000 维或更高维的向量(在这种情况下,捕捉一个 20,000 令牌的词汇表)。因此,词嵌入将更多的信息压缩到更少的维度中。

图片

图 11.2 通过一位有效编码或散列获取的单词表示是稀疏、高维和硬编码的。而单词嵌入是密集、相对低维并且从数据中学习到的。

在是密集向量之外,词向量也是结构化表示,它们的结构是从数据中学习的。相似的单词嵌入紧密的位置,另外,嵌入空间中的特殊方向是有意义的。为了更清楚地了解这一点,让我们来看一个具体的例子。

在图 11.3 中,四个单词被嵌入在一个 2D 平面上:猫,狗,狼老虎。在我们选择的向量表示中,一些语义关系可以被编码为几何变换。例如,相同的向量使我们从老虎,从:这个向量可以被解释为“从宠物到野生动物”的向量。类似地,另一个向量让我们从,从老虎,这可以被解释为“从犬科到猫科”的向量。

在真实世界的词嵌入空间中,常见的有意义的几何变换有“性别”向量和“复数”向量。例如,通过向向量“国王”添加“女性”向量,我们可以得到向量“皇后”。通过添加“复数”向量,我们可以得到“国王们”。词嵌入空间通常具有数千个这样的可解释和潜在有用的向量。

图片

图 11.3 一个词嵌入空间的示例

让我们看看如何在实践中使用这样的嵌入空间。有两种方法可以获得词嵌入:

  • 与您关心的主要任务(如文档分类或情感预测)一起学习单词嵌入。在这种设置中,您从随机单

  • 将预先使用不同的机器学习任务计算得到的单词嵌入加载到您的模型中。这些被称为预训练的词嵌入

让我们逐个审查这些方法。

使用嵌入层学习单词嵌入

是否存在一种理想的单词嵌入空间,能够完美地映射人类语言,并可用于任何自然语言处理任务?可能有,但我们尚未计算出这样的东西。此外,没有人类语言这样的东西——有许多不同的语言,它们并不是同构的,因为语言是特定文化和特定上下文的反映。但更实际的是,一个好的单词嵌入空间的特征取决于你的任务:用于英语电影评论情感分析模型的完美单词嵌入空间可能与用于英语法律文件分类模型的完美嵌入空间不同,因为某些语义关系的重要性因任务而异。

因此,通过每个新任务学习一个新的嵌入空间是合理的。幸运的是,反向传播使这变得容易,而 Keras 使其变得更容易。这是关于学习一层的权重:layer_embedding()。

清单 11.15 实例化一个 layer_embedding

embedding_layer <- layer_embedding(input_dim = max_tokens,

output_dim = 256)➊

➊layer_embedding() 至少需要两个参数:可能的标记数量和嵌入的维度(这里是 256)。

layer_embedding() 最好理解为一个将整数索引(代表特定单词)映射到密集向量的字典。它接受整数作为输入,查找这些整数在内部字典中的位置,并返回相关的向量。它实际上是一个字典查找(见图 11.4)。

图片

图 11.4 嵌入层

嵌入层的输入是一个整数的秩为 2 的张量,形状为 (batch_size, sequence_length),其中每个条目是一个整数序列。然后,该层返回一个形状为 (batch_size, sequence_length, 嵌入维度) 的 3D 浮点张量。

当实例化一个 layer_embedding() 时,它的权重(内部字典中的标记向量)最初是随机的,就像任何其他层一样。在训练期间,这些词向量会通过反向传播逐渐调整,将空间结构化为下游模型可以利用的东西。一旦完全训练完成,嵌入空间将展现出很多结构——一种针对你训练模型的特定问题的结构。

让我们构建一个包含 layer_embedding() 的模型,并在我们的任务上进行基准测试。

清单 11.16 从头开始训练一个 layer_embedding 的模型

inputs <- layer_input(shape(NA), dtype = "int64")

embedded <- inputs %>%

layer_embedding(input_dim = max_tokens, output_dim = 256)

outputs <- embedded %>%

bidirectional(layer_lstm(units = 32)) %>%

layer_dropout(0.5) %>%

layer_dense(1, activation = "sigmoid")

model <- keras_model(inputs, outputs)

model %>%

compile(optimizer = "rmsprop",

loss = "binary_crossentropy",

metrics = "accuracy")

model

Image

callbacks <- list(callback_model_checkpoint("embeddings_bidir_lstm.keras",

save_best_only = TRUE))

model %>%

fit(int_train_ds,

validation_data = int_val_ds,

epochs = 10,

callbacks = callbacks)

model <- load_model_tf("embeddings_bidir_lstm.keras")

evaluate(model, int_test_ds)["accuracy"] %>%

sprintf("测试准确度:%.3f\n", .) %>% cat()

测试准确度:0.842

它的训练速度比独热模型快得多(因为 LSTM 只需处理 256 维向量,而不是 20,000 维向量),其测试准确度可比拟(84%)。然而,我们离基本双字模型的结果还有一段距离。部分原因只是因为模型查看的数据稍微少一些:双字模型处理完整评论,而我们的序列模型在 600 个单词后截断序列。

理解填充和掩码

这里稍微影响模型性能的一件事情是,我们的输入序列中充满了零。这来自我们在 layer_text_vectorization() 中使用 output_sequence_length = max_ length 选项(max_length 等于 600):长于 600 个标记的句子将被截断为 600 个标记的长度,并且短于 600 个标记的句子将在末尾填充零,以便它们可以与其他序列连接以形成连续批次。

我们使用了双向 RNN:两个 RNN 层并行运行,其中一个按照它们的自然顺序处理标记,另一个按照相同的标记逆序处理。以自然顺序查看标记的 RNN 将在最后的迭代中仅看到编码填充的向量 —— 如果原始句子很短,可能会连续数百次迭代。随着暴露于这些无意义的输入,RNN 内部状态中存储的信息将逐渐消失。

我们需要一种方式告诉 RNN 它应该跳过这些迭代。有一个 API 可以做到这一点:掩码。layer_embedding() 能够生成与其输入数据相对应的“掩码”。这个掩码是一个由 1 和 0(或 TRUE/FALSE 布尔值)组成的张量,形状为(batch_size,sequence_length),其中条目 mask[i, t] 表示样本 i 的时间步 t 是否应该跳过(如果 mask[i, t] 为 0 或 FALSE,则会跳过时间步,否则会处理)。

默认情况下,此选项未激活 —— 您可以通过将 mask_zero = TRUE 传递给您的 layer_embedding() 来打开它。您可以使用 compute_mask() 方法检索掩码:

embedding_layer <- layer_embedding(input_dim = 10, output_dim = 256,

mask_zero = TRUE)

some_input <- rbind(c(4, 3, 2, 1, 0, 0, 0),

c(5, 4, 3, 2, 1, 0, 0),

c(2, 1, 0, 0, 0, 0, 0))

mask <- embedding_layer$compute_mask(some_input)

mask

tf.Tensor(

[[ True  True  True  True False False False]

[ True  True  True  True  True False False]

[ True  True False False False False False]], shape=(3, 7), dtype=bool)

实际上,你几乎永远不需要手动管理遮蔽。相反,Keras 会自动将遮蔽传递给每个能够处理它的层(作为附加到它表示的序列的元数据)。这个遮蔽将被 RNN 层用于跳过遮蔽的步骤。如果你的模型返回了整个序列,遮蔽也将被损失函数用于跳过输出序列中的遮蔽步骤。让我们尝试重新训练我们的模型,并启用遮蔽。

清单 11.17 使用启用了遮蔽的嵌入层

inputs <- layer_input(c(NA), dtype = "int64")

embedded <- inputs %>%

layer_embedding(input_dim = max_tokens,

output_dim = 256,

mask_zero = TRUE)

outputs <- embedded %>%

bidirectional(layer_lstm(units = 32)) %>%

layer_dropout(0.5) %>%

layer_dense(1, activation = "sigmoid")

model <- keras_model(inputs, outputs)

model %>% compile(optimizer = "rmsprop",

loss = "binary_crossentropy",

metrics = "accuracy")

模型

图片

callbacks <- list(

callback_model_checkpoint("embeddings_bidir_lstm_with_masking.keras",

save_best_only = TRUE)

)

model %>%fit(

int_train_ds,

validation_data = int_val_ds,

epochs = 10,

callbacks = callbacks

)

model <- load_model_tf("embeddings_bidir_lstm_with_masking.keras")

cat(sprintf("测试准确率: %.3f\n",

evaluate(model, int_test_ds)["accuracy"]))

测试准确率: 0.880

这次我们达到了 88%的测试准确率——虽然只是一个小幅但明显的提高。

使用预训练的词嵌入

有时候你的训练数据非常少,以至于你无法单独使用数据来学习适合特定任务的词汇嵌入。在这种情况下,你可以从预先计算的嵌入空间中加载嵌入向量,这个空间是高度结构化的并具有有用的属性——它捕捉到了语言结构的通用方面。在自然语言处理中使用预训练的词嵌入的理由与在图像分类中使用预训练的卷积网络的理由基本相同:你没有足够的数据可用来自己学习真正强大的特征,但你期望你需要的特征是相当通用的——即,常见的视觉特征或语义特征。在这种情况下,重用在不同问题上学习到的特征是有意义的。

这种单词嵌入通常是使用单词共现统计(关于单词在句子或文档中共现的观察)计算的,使用各种技术,有些涉及神经网络,有些不涉及。将单词计算在一个密集的、低维的嵌入空间中,以无监督的方式进行,最早是由 Bengio 等人在 2000 年代初探索的¹,但是它在研究和工业应用中开始蓬勃发展,仅在发布了最著名和最成功的单词嵌入方案之一后才开始: Word2Vec 算法(code.google.com/archive/p/word2vec),2013 年由 Google 的 Tomas Mikolov 开发。Word2Vec 维度捕获特定的语义属性,如性别。

你可以下载各种预先计算的单词嵌入数据库,并在 Keras 中使用它们作为一个层。其中之一是 Word2Vec。另一个流行的是全球词向量表示(GloVe,nlp.stanford.edu/projects/glove),它是由斯坦福大学的研究人员在 2014 年开发的。这种嵌入技术是基于分解一个单词共现统计矩阵。它的开发者已经提供了数百万个英文标记的预计算嵌入,这些标记是从维基百科和公共爬网数据中获得的。

让我们看看如何开始在 Keras 模型中使用 GloVe 嵌入。相同的方法对 Word2Vec 嵌入或任何其他单词嵌入数据库都有效。我们将从下载 GloVe 文件并解析它们开始。然后我们将把单词向量加载到一个 Keras layer_embedding()层中,我们将用它来构建一个新模型。

首先,让我们下载在 2014 年英文维基百科数据集上预先计算的 GloVe 单词嵌入。这是一个 822 MB 的 zip 文件,包含了 400,000 个单词(或非单词标记)的 100 维嵌入向量:

使用download.file("http://nlp.stanford.edu/data/glove.6B.zip",下载文件。

目标文件 = "glove.6B.zip")

zip::unzip("glove.6B.zip")

让我们解析解压后的文件(一个.txt 文件)以建立一个将单词(作为字符串)映射到它们的向量表示的索引。因为文件结构本质上是一个具有行名的数值矩阵,所以我们将在 R 中创建这样一个结构。

清单 11.18 解析 GloVe 单词嵌入文件

文件路径path_to_glove_file <- "glove.6B.100d.txt"

嵌入维度 <- 100

df <- readr::read_table(

文件路径path_to_glove_file,

col_names = FALSE,➊

列类型 = paste0("c", strrep("n", 100))➋

)

嵌入索引 <- as.matrix(df[, -1])➌

rownames(嵌入索引) <- df[[1]]

colnames(嵌入索引) <- NULL ➍

rm(df)➎

read_table()返回一个数据框。col_names = FALSE告诉read_table()文本文件没有标题行,并且数据本身从第一行开始。

➋ 传递 col_types 并不是必需的,但是是一种最佳实践和对意外情况的良好保护(例如,如果你正在读取一个损坏的文件,或者错误的文件!)。在这里,我们告诉 read_table()第一列是'character'类型,然后下一个 100 列是'numeric'类型。

➌ 第一列是单词,剩余的 100 列是数值嵌入。

➍ 丢弃 read_table()自动创建的列名(R 数据框必须有列名)。

➎ 清除内存中的临时数据框。

这是 embedding_matrix 的样子:

str(embeddings_index)

num [1:400000, 1:100] -0.0382 -0.1077 -0.3398 -0.1529 -0.1897 …

  • attr(*, "dimnames")=List of 2

..$ : chr [1:400000] "the" "," "." "of" …

..$ : NULL

接下来,让我们构建一个嵌入矩阵,你可以加载到一个 layer_embedding()中,它必须是一个形状为(max_words, embedding_dim)的矩阵,其中每个条目 i 包含索引为 i 的单词的 embedding_dim 维向量(在分词期间构建的参考词索引中)。

列表 11.19 准备 GloVe 单词嵌入矩阵

vocabulary <- text_vectorization %>% get_vocabulary() ➊

str(vocabulary)

chr [1:20000] "" "[UNK]" "the" "a" "and" "of" "to" "is" "in" "it" "i" …

tokens <- head(vocabulary[-1], max_tokens)➋

i <- match(vocabulary, rownames(embeddings_index),➌

nomatch = 0)

embedding_matrix <- array(0, dim = c(max_tokens, embedding_dim))➍

embedding_matrix[i != 0, ] <- embeddings_index[i, ]➎➏

str(embedding_matrix)

num [1:20000, 1:100] 0 0 -0.0382 -0.2709 -0.072 …

➊ 检索我们之前 text_vectorization 层索引的词汇表。

➋ [-1]是为了移除第一个位置上的掩码标记""。head(, max_tokens)仅是一个健全性检查 - 我们之前将相同的 max_tokens 传递给了 text_vectorization。

➌ i 是与词汇表中每个对应单词匹配的 embeddings_index 中的行号的整数向量,如果没有匹配的单词,则为 0。

➍ 准备一个全零矩阵,我们将用 GloVe 向量填充。

➎ 用相应的词向量填充矩阵中的条目。嵌入矩阵的行号对应于词汇表中单词的索引位置。在嵌入索引中找不到的单词将全部为零。

➏ R 数组中传递给[的 0 将被忽略。例如:(1:10)[c(1,0,2,0,3)]返回 c(1, 2, 3)。

最后,我们使用 initializer_constant()将预训练的嵌入加载到 layer_embedding()中。为了在训练过程中不破坏预训练的表示,我们通过 trainable = FALSE 来冻结该层:

embedding_layer <- layer_embedding(

input_dim = max_tokens,

output_dim = embedding_dim,

embeddings_initializer = initializer_constant(embedding_matrix),

trainable = FALSE,

mask_zero = TRUE

)

现在我们已经准备好训练一个新模型 - 与我们之前的模型相同,但利用了 100 维的预训练 GloVe 嵌入,而不是 128 维的学习嵌入。

列表 11.20 使用预训练嵌入层的模型

输入 <- layer_input(shape(NA), dtype = "int64")

嵌入 <- embedding_layer(inputs)

输出 <- 嵌入层 %>%

bidirectional(layer_lstm(units = 32)) %>%

layer_dropout(0.5) %>%

layer_dense(1, 激活函数 = "sigmoid")

模型 <- keras_model(inputs, outputs)

模型 %>% compile(optimizer = "rmsprop",

损失函数 = "binary_crossentropy",

指标 = "准确率")

模型

Image Image

回调函数列表 <- list(

callback_model_checkpoint("glove_embeddings_sequence_model.keras",

save_best_only = TRUE)

)

模型 %>%

fit(int_train_ds, validation_data = int_val_ds,

epochs = 10, callbacks = callbacks)

模型 <- load_model_tf("glove_embeddings_sequence_model.keras")

cat(sprintf(

"测试准确率:%.3f\n", evaluate(model, int_test_ds)["accuracy"]))

测试准确率:0.877

在这个特定的任务中,你会发现预训练的嵌入并不是很有用,因为数据集包含足够的样本,可以从头开始学习一个足够专业的嵌入空间。然而,当你处理较小的数据集时,利用预训练的嵌入可能会非常有帮助。

11.4 transformers 架构

从 2017 年开始,一种新的模型架构开始在大多数自然语言处理任务中取代循环神经网络:transformers。transformers 是由 Vaswani 等人在开创性论文“Attention Is All You Need”中引入的。论文的要点就在标题中:事实证明,一个简单的叫做“神经注意力”的机制可以用来构建强大的序列模型,而不需要循环层或卷积层。

这一发现引发了自然语言处理领域乃至更广泛领域的一场革命。神经注意力已经迅速成为深度学习中最具影响力的思想之一。在本节中,你将深入了解它是如何工作以及为什么它对于序列数据如此有效。然后,我们将利用自注意力来创建一个 transformers 编码器,这是 transformers 架构的基本组件之一,并将其应用于 IMDB 电影评论分类任务。

11.4.1 理解自注意力

当你阅读这本书时,你可能会快速浏览某些部分,而对其他部分进行仔细阅读,这取决于你的目标或兴趣是什么。如果你的模型也是这样做呢?这是一个简单但强大的想法:模型看到的所有输入信息对于手头的任务来说并不都是同等重要的,所以模型应该“更加关注”某些特征,而“更少关注”其他特征。这听起来熟悉吗?在这本书中你已经两次遇到了类似的概念:

  • 在卷积神经网络中的最大池化操作会查看空间区域内的一组特征,并选择保留其中的一个特征。这是一种“全有或全无”的注意形式:保留最重要的特征,丢弃其余的。

  • TF-IDF 归一化根据不同单词可能携带的信息量为单词分配重要性分数。重要的单词得到增强,而不相关的单词被淡化。这是一种持续的注意形式。

你可以想象许多不同形式的注意力,但它们都是从计算一组特征的重要性分数开始的,对于更相关的特征得分较高,对于不太相关的特征得分较低(参见图 11.5)。如何计算这些分数,以及如何处理它们,将根据不同方法而异。

Image

图 11.5 深度学习中“注意力”的一般概念:输入特征被赋予“注意力分数”,这些分数可以用来指导输入的下一个表示。

关键的是,这种注意机制不仅可以用于突出或消除某些特征,还可以用于使特征具有上下文意识。你刚刚了解了单词嵌入:捕捉不同单词之间“形状”的语义关系的向量空间。在嵌入空间中,一个单词有一个固定的位置——与空间中的每个其他单词的一组固定关系。但这并不完全符合语言的工作方式:单词的含义通常是上下文特定的。当你标记日期时,你说的“日期”和你约会时的“日期”不同,也不是你在市场上买到的那种日期。当你说“我很快就会见到你”时,单词“见”在“我会把这个项目进行到底”或“我明白你的意思”中的含义略有不同。当然,“他”、“它”、“你”等代词的含义完全是句子特定的,甚至可以在一个句子中多次改变。

显然,一个智能的嵌入空间会根据周围的其他单词为一个单词提供不同的向量表示。这就是自注意力的作用所在。自注意力的目的是通过使用序列中相关单词的表示来调节一个标记的表示。这产生了具有上下文意识的标记表示。考虑一个例句:“火车准时离开了车站。”现在,考虑句子中的一个词:车站。我们在谈论什么样的车站?可能是广播电台吗?也许是国际空间站?让我们通过自注意力算法来算出(参见图 11.6)。

Image

图 11.6 自注意力:计算“站”与序列中每个其他单词之间的注意力分数,然后用它们加权一组单词向量,这成为新的“站”向量。

第一步是计算“station”向量与句子中每个其他单词之间的相关性分数。这些是我们的“注意力分数”。我们简单地使用两个单词向量之间的点积作为衡量它们关系强度的指标。这是一种非常高效的计算距离函数,而且在 Transformers 之前它已经是将两个词嵌入彼此相关联的标准方式。实际上,这些分数还将通过一个缩放函数和一个 softmax,但现在,这只是一个实现细节。

第二步是计算句子中所有单词向量的加权和,权重由我们的相关性分数决定。与“station”密切相关的单词将更多地 contribute to the sum(包括单词“station”本身),而不相关的单词将几乎不贡献任何内容。得到的向量是我们对“station”的新表示:一种包含周围上下文的表示。特别是,它包括“train”向量的一部分,澄清了它实际上是“火车站”。

对于句子中的每个单词,您需要重复此过程,生成一个编码句子的新向量序列。让我们用类似 R 的伪代码来看一下:

self_attention <- function(input_sequence) {

c(sequence_len, embedding_size) %<-% dim(input_sequence)

output <- array(0, dim(input_sequence))

for (i in 1:sequence_len) {➊

pivot_vector <- input_sequence[i, ]

scores <- sapply(1:sequence_len, function(j) ➋

pivot_vector %*% input_sequence[j, ])➌

scores <- softmax(scores / sqrt(embedding_size))➍

broadcast_scores <

as.matrix(scores)[, rep(1, embedding_size)]➎

new_pivot_representation <

colSums(input_sequence * broadcast_scores)➏

output[i, ] <- new_pivot_representation

}

输出

}

softmax <- function(x) {

e <- exp(x - max(x))

e / sum(e)

}

遍历输入序列中的每个标记。

计算标记与每个其他标记之间的点积(注意力分数)。

%*% 用于两个 1D 向量返回一个标量,即点积。scores 的形状为 (sequence_len)。

通过一个标准化因子进行缩放,并应用 softmax。

将分数向量(形状为 (sequence_len))广播成一个形状为 (sequence_len, embedding_size) 的矩阵,即 input_sequence 的形状。

将得分调整后的输入序列求和以生成一个新的嵌入向量。

当然,在实践中,您会使用矢量化实现。Keras 有一个内置层来处理它:layer_multi_head_attention()。这是您如何使用它的方法:

num_heads <- 4

embed_dim <- 256

mha_layer <- layer_multi_head_attention(num_heads = num_heads,

key_dim = embed_dim)

输出 <- mha_layer(输入, 输入, 输入)➊

输入的形状为 (batch_size, sequence_length, embed_dim)。

读到这里,您可能会想:

  • 为什么我们要将输入传递给该层次?这似乎是多余的。

  • 这些“多个头”是什么?听起来很吓人 —— 如果您把它们剪掉,它们也会再长出来吗?

这两个问题都有简单的答案。让我们来看看。

泛化的自注意力:查询-键-值模型

到目前为止,我们只考虑了一个输入序列。然而,Transformer 架构最初是为机器翻译开发的,在那里你必须处理两个输入序列:你当前正在翻译的源序列(如“今天天气如何?”),以及你将其转换为的目标序列(如“¿Qué tiempo hace hoy?”)。Transformer 是一个序列到序列模型:它被设计用来将一个序列转换为另一个序列。你将在本章后面深入学习有关序列到序列模型的内容。

现在让我们退一步。就像我们介绍的那样,自注意力机制执行以下操作,概括地说:

Image

这意味着“对于输入(A)中的每个标记,计算标记与输入(B)中的每个标记的关联程度,并使用这些分数对输入(C)中的标记进行加权求和。”关键是,没有什么需要 A、B 和 C 引用相同的输入序列。在一般情况下,你可以用三个不同的序列来完成这个操作。我们称它们为“查询”,“键”和“值”。操作变成了“对于查询中的每个元素,计算该元素与每个键的关联程度,并使用这些分数对值进行加权求和”:

输出 <- sum( * 两两得分( 查询 ))

这个术语来自搜索引擎和推荐系统(参见图 11.7)。想象一下,你正在输入一个查询,以从你的收藏中检索一张照片,“海滩上的狗”。在内部,数据库中的每张图片都由一组关键词描述——“猫”,“狗”,“派对”等等。我们将这些称为“键”。搜索引擎将首先将您的查询与数据库中的键进行比较。“狗”产生 1 个匹配,“猫”产生 0 个匹配。然后它将根据匹配的强度——相关性对这些键进行排名,并以相关性顺序返回与前N个匹配关联的图片。

Image

图 11.7 从数据库检索图像:将“查询”与一组“键”进行比较,并使用匹配得分对“值”(图像)进行排名。

在概念上,这就是 Transformer 风格的注意力在做的事情。你有一个描述你正在寻找的东西的参考序列:查询。你有一个你想从中提取信息的知识体系:值。每个值被分配一个键,描述了值以一种可以与查询轻松比较的格式。你只需将查询与键匹配即可。然后返回值的加权总和。

在实践中,键和值通常是相同的序列。例如,在机器翻译中,查询将是目标序列,而源序列将扮演键和值的角色:对于目标的每个元素(比如“tiempo”),你希望回到源头(“今天天气如何?”)并识别与之相关的不同部分(“tiempo”和“weather”应该有很强的匹配)。自然地,如果你只是进行序列分类,那么查询、键和值都是相同的:你正在将一个序列与自身进行比较,以丰富每个标记的上下文。

这解释了为什么我们需要将输入传递三次到我们的 layer_multi_ head_attention()层。但为什么要“多头”注意力呢?

11.4.2 多头注意力

“多头注意力”是自注意力机制的一项额外调整,由“Attention Is All You Need”引入。 “多头”这个名字指的是自注意力层的输出空间被分解为一组独立的子空间,分别学习:初始查询、键和值通过三组独立的密集投影发送,生成三个单独的向量。每个向量通过神经注意力进行处理,三个输出被串联回一个单一的输出序列。这样的子空间被称为“头”。全貌如 图 11.8 所示。

Image

图 11.8 多头注意力图层

通过可学习的密集投影的存在,该层实际上可以学到一些东西,而不是成为一个纯粹的状态转换,需要额外的层在其之前或之后才能变得有用。此外,拥有独立的头部可以帮助该层学习每个标记的不同特征组,其中一个组内的特征彼此相关,但与另一个组内的特征大部分是独立的。

这与深度可分离卷积的工作原理相似:在深度可分离卷积中,卷积的输出空间被分解为许多独立的子空间(每个输入通道一对一),它们是独立学习的。《Attention Is All You Need》一文是在已经表明将特征空间分解为独立子空间提供了计算机视觉模型极大收益的时候编写的,无论是深度可分离卷积的情况还是与之密切相关的一种方法,即分组卷积。多头注意力只是将相同的想法应用到自注意力中。

11.4.3 Transformer 编码器

如果添加额外的密集投影如此有用,为什么我们不将其应用到注意力机制的输出上呢?实际上,这是一个绝佳的主意—让我们这样做。我们的模型开始做很多事情,所以我们可能想要添加残差连接,以确保我们不会在途中破坏任何有价值的信息;你在第九章中学到的,对于任何足够深的架构来说,这是非常必要的。还有一件事情是你在第九章中学到的:归一化层应该有助于梯度在反向传播期间更好地流动。让我们也把这些添加进来。

我大致想象当时 transformers 架构的发明者们心中展开的思维过程。将输出因子化为多个独立的空间,添加残差连接,添加归一化层—所有这些都是一种明智之举,可以在任何复杂模型中加以利用的标准架构模式。这些花哨且实用的部件汇聚在一起形成 transformers 编码器—构成 transformers 架构的两个关键部分之一,请见图 11.9。

原始的 transformers 架构由两部分组成:一个transformers 编码��用于处理源序列,一个transformers 解码器利用源序列生成翻译版本。你马上就会了解解码器部分。

至关重要的是,编码器部分可以用于文本分类。这是一个非常通用的模块,接受一个序列并学习将其转化为更有用的表示。让我们实现一个 transformers 编码器,并在电影评论情感分类任务上进行尝试。

图片

图 11.9 transformers 编码器通过将一个layer_multi_head_attention()连接到一个密集投影,并添加归一化以及残差连接。

清单 11.21 作为子类 Layer 实现的 transformers 编码器

layer_transformer_encoder <- new_layer_class(

类名 = "TransformerEncoder",

initialize = function(embed_dim, dense_dim, num_heads, …) {

super$initialize(…)

self$embed_dim <- embed_dim ➊

self$dense_dim <- dense_dim ➋

self$num_heads <- num_heads ➌

self$attention <

layer_multi_head_attention(num_heads = num_heads,

key_dim = embed_dim)

self$dense_proj <- keras_model_sequential() %>%

layer_dense(dense_dim, activation = "relu") %>%

layer_dense(embed_dim)

self$layernorm_1 <- layer_layer_normalization()

self$layernorm_2 <- layer_layer_normalization()

},

call = function(inputs, mask = NULL) {➍

if (!is.null(mask))➎

mask <- mask[, tf$newaxis, ]➎

输入 %>%

{ self$attention(., ., attention_mask = mask) + . } %>% ➏

self$layernorm_1() %>%

{ self$dense_proj(.) + . } %>% ➐

self$layernorm_2()

},

get_config = function() { ➑

config <- super$get_config()

for(name in c("embed_dim", "num_heads", "dense_dim"))

config[[name]] <- self[[name]]

config

}

)

输入令牌向量的大小

内部密集层的大小

注意力头的数量

计算发生在 call()中

由嵌入层生成的遮罩将是 2D 的,但注意力层期望它是 3D 或 4D 的,因此我们扩展其秩。

在注意力层的输出中添加残差连接。

在 dense_proj() 层的输出中添加残差连接。

实现序列化,以便我们可以保存模型。

%>% 和 { }

在上面的示例中,我们使用 %>% 将其传递到用 { } 包装的表达式中。这是 %> 的高级功能,它允许您将管道传递到复杂或复合表达式中。%>% 将在我们使用 . 符号请求的每个位置放置管道参数。例如:

x %>% { fn(., .) + . }

等同于:

fn(x, x) + x

如果我们编写 layer_transformer_encoder() 的 call() 方法而不使用 %>%,它将如下所示:

call = function(inputs, mask = NULL) {

如果 (!is.null(mask))

mask <- mask[, tf$newaxis, ]

attention_output <- self$attention(inputs, inputs,

attention_mask = mask)

proj_input <- self$layernorm_1(inputs + attention_output)

proj_output <- self$dense_proj(proj_input)

self$layernorm_2(proj_input + proj_output)

}

保存自定义层

当您编写自定义层时,请确保实现 get_config() 方法:这使得可以从其配置重新实例化该层,在模型保存和加载过程中非常有用。该方法应返回一个命名的 R 列表,其中包含用于创建层的构造函数参数的值。

所有 Keras 层都可以如下序列化和反序列化:

config <- layer$get_config()

new_layer <- do.call(layer_, config)

其中 layer_ 是原始层构造函数。例如:

layer <- layer_dense(units = 10)

config <- layer$get_config() ➊

new_layer <- do.call(layer_dense, config)➋

config 是一个常规的命名 R 列表。您可以将其安全地保存到磁盘上作为 rds,然后在新的 R 会话中加载它。

配置不包含权重值,因此层中的所有权重都将从头开始初始化。

您还可以通过特殊符号 class 直接从任何现有层访问未包装的原始层构造函数(尽管您很少需要这样做):

layer$__class__

<class ‘keras.layers.core.dense.Dense’>

new_layer <- layer\(`__class__`\)from_config(config)

在自定义层类中定义 get_config() 方法会启用相同的工作流程。例如:

layer <- layer_transformer_encoder(embed_dim = 256, dense_dim = 32,

num_heads = 2)

config <- layer$get_config()

new_layer <- do.call(layer_transformer_encoder, config)

-- 或 --

new_layer <- layer\(`__class__`\)from_config(config)

当保存包含自定义层的模型时,保存的文件将包含这些配置。在从文件加载模型时,您应该向加载过程提供自定义层类,以便它可以理解配置对象:

model <- save_model_tf(model, filename)

model <- load_model_tf(filename,

custom_objects = list(layer_transformer_encoder))

请注意,如果 custom_objects 列表中提供的列表具有名称,则名称将与构建自定义对象时提供的 classname 参数进行匹配:

model <- load_model_tf(

filename,

custom_objects = list(TransformerEncoder = layer_transformer_encoder))

您会注意到,我们在这里使用的归一化层不是像之前在图像模型中使用的 layer_batch_normalization() 那样的层。那是因为 layer_batch_normalization() 不适用于序列数据。相反,我们使用 layer_layer_normalization(),它将每个序列与批次中的其他序列独立地进行归一化。在 R 的伪代码中就像这样:

layer_normalization <- function(batch_of_sequences) {

c(batch_size, sequence_length, embedding_dim) %<-%

dim(batch_of_sequences)➊

means <- variances <-

array(0, dim = dim(batch_of_sequences))

for (b in seq(batch_size))

for (s in seq(sequence_length)) {

embedding <- batch_of_sequences[b, s, ]➋

means[b, s, ] <- mean(embedding)

variances[b, s, ] <- var(embedding)

}

(batch_of_sequences - means) / variances

}

输入形状:(batch_size, sequence_length, embedding_dim)

要计算均值和方差,我们仅在最后一个轴(轴 -1,即嵌入轴)上汇总数据。

与 layer_batch_normalization()(在训练期间)进行比较:

batch_normalization <- function(batch_of_images) {

c(batch_size, height, width, channels) %<-%

dim(batch_of_images) ➊

means <- variances <-

array(0, dim = dim(batch_of_images))

for (ch in seq(channels)) {

channel <- batch_of_images[, , , ch]➋

means[, , , ch] <- mean(channel)

variances[, , , ch] <- var(channel)

}

(batch_of_images - means) / variances

}

输入形状:(batch_size, height, width, channels)

在批次轴(第一个轴)上汇总数据,这会在批次中的样本之间创建交互。

尽管 batch_normalization() 从许多样本中收集信息以获得特征均值和方差的准确统计数据,但 layer_normalization() 在每个序列内部汇集数据,这对于序列数据更为合适。

现在我们已经实现了 TransformerEncoder,我们可以使用它来组装一个类似于您之前看到的基于 LSTM 的文本分类模型。

图 11.22 使用 Transformer 编码器进行文本分类

vocab_size <- 20000

embed_dim <- 256

num_heads <- 2

dense_dim <- 32

inputs <- layer_input(shape(NA), dtype = "int64")

outputs <- inputs %>%

layer_embedding(vocab_size, embed_dim) %>%

layer_transformer_encoder(embed_dim, dense_dim, num_heads) %>%

layer_global_average_pooling_1d() %>%➊

layer_dropout(0.5) %>%

layer_dense(1, activation = "sigmoid")

model <- keras_model(inputs, outputs)

model %>% compile(optimizer = "rmsprop",

loss = "binary_crossentropy",

metrics = "accuracy")

model

Image

由于 TransformerEncoder 返回完整序列,因此我们需要通过全局汇集层将每个序列减少为单个向量进行分类。

让我们进行训练。它达到了 88.5% 的测试准确率。

列表 11.23 训练和评估基于 Transformer 编码器的模型

回调函数 = 列表(callback_model_checkpoint("transformer_encoder.keras",

save_best_only = TRUE))

model %>% fit(

int_train_ds,

验证数据 = int_val_ds,

epochs = 20,

回调函数 = 回调函数

)

model <- load_model_tf(

"transformer_encoder.keras",

custom_objects = layer_transformer_encoder)➊

sprintf("测试准确率:%.3f", evaluate(model, int_test_ds)["accuracy"])

[1] "测试准确率:0.885"

为模型加载过程提供自定义 TransformerEncoder 类。

此时,您应该开始感到有点不安。这里有点不对劲。你能说出是什么吗?

这一部分表面上是关于“序列模型”的。我首先强调了词序的重要性。我说 Transformer 是一种序列处理架构,最初是为机器翻译而开发的。然而……你刚刚看到的 Transformer 编码器根本不是序列模型。你注意到了吗?它由处理序列令牌的密集层和查看令牌 作为集合 的注意层组成。你可以改变序列中令牌的顺序,你会得到完全相同的成对注意分数和完全相同的上下文感知表示。如果你完全打乱每个电影评论中的单词,模型不会注意到,你仍然会得到完全相同的准确性。自注意力是一种集合处理机制,专注于序列元素对之间的关系(见图 11.10)—它对于这些元素是出现在序列的开始、结束还是中间是盲目的。那么我们为什么说 Transformer 是一个序列模型呢?它怎么可能适用于机器翻译,如果它不考虑词序呢?

我在本章前面已经暗示了解决方案:我顺便提到了 Transformer 是一种技术上无序的混合方法,但在处理其表示时手动注入顺序信息。这是缺失的要素!它被称为 位置编码。让我们来看看。

图像

图 11.10 不同类型 NLP 模型的特征

使用位置编码来重新注入顺序信息

位置编码背后的想法非常简单:为了让模型访问词序信息,我们将在每个词嵌入中添加词在句子中的位置。我们的输入词嵌入将有两个组成部分:通常的词向量,表示独立于任何特定上下文的词,以及位置向量,表示词在当前句子中的位置。希望模型能够找出如何最好地利用这些额外信息。

最简单的方案是将单词的位置连接到其嵌入向量中。您会为向量添加一个“位置”轴,并将其填充为 0(对应序列中的第一个单词)、1(对应序列中的第二个单词),依此类推。然而,这可能不是最理想的,因为位置可能是非常大的整数,这将扰乱嵌入向量中的值的范围。如您所知,神经网络不喜欢非常大的输入值或离散的输入分布。

原始的“注意力就是你所需要的一切”论文使用了一个有趣的技巧来编码单词位置:它在单词嵌入中添加了一个向量,其中包含范围在[-1, 1]之间的值,这些值根据位置周期性地变化(它使用余弦函数来实现这一点)。这个技巧提供了一种通过一组小值的向量来唯一地表征大范围内的任何整数的方法。这很聪明,但不是我们要在这种情况下使用的。我们将做一些更简单和更有效的事情:我们将学习位置嵌入向量,就像我们学习嵌入单词索引一样。然后,我们将继续将我们的位置嵌入添加到相应的单词嵌入中,以获得一个位置感知的单词嵌入。这个技术称为“位置嵌入”。让我们来实现它。

图 11.24 实现位置嵌入为子类化的层

layer_positional_embedding <- new_layer_class(

classname = "PositionalEmbedding",

initialize = function(sequence_length, ➊

input_dim, output_dim, …) {

super$initialize(…)

self$token_embeddings <-➋

layer_embedding(input_dim = input_dim,

output_dim = output_dim)

self$position_embeddings <-➌

layer_embedding(input_dim = sequence_length,

output_dim = output_dim)

self$sequence_length <- sequence_length

self$input_dim <- input_dim

self$output_dim <- output_dim

},

call = function(inputs) {

len <- tf$shape(inputs)[-1]➍

positions <-

tf$range(start = 0L, limit = len, delta = 1L)➎

embedded_tokens <- self$token_embeddings(inputs)

embedded_positions <- self$position_embeddings(positions)

embedded_tokens + embedded_positions➏

},

compute_mask = function(inputs, mask = NULL) {➐

inputs != 0

},

get_config = function() {➑

config <- super$get_config()

for(name in c("output_dim", "sequence_length", "input_dim"))

config[[name]] <- self[[name]]

config

}

)

位置嵌入的一个缺点是需要提前知道序列长度。

为标记索引准备一个 layer_embedding()。

为标记位置准备另一个。

tf\(shape(inputs)[-1] 切片出形状的最后一个元素,即嵌入维度的大小。(tf\)shape() 返回张量的形状。)

tf$range() 类似于 R 中的 seq(),生成整数序列:[0, 1, 2, …, limit - 1]。

将两个嵌入向量相加。

像 layer_embedding()一样,这个层应该能够生成一个蒙版,这样我们就可以忽略输入中的填充 0。compute_mask()方法将由框架自动调用,并且蒙版将传播到下一层。

实现序列化以便我们可以保存模型。

你会像使用常规的 layer_embedding()一样使用这个 layer_positional_embedding()。让我们看看它的作用!

将所有内容整合在一起:一个文本分类 Transformer

只需将旧的 layer_embedding()替换为我们的位置感知版本,就可以开始考虑单词顺序。

列表 11.25 结合 Transformer 编码器和位置嵌入

vocab_size <- 20000

sequence_length <- 600

embed_dim <- 256

num_heads <- 2

dense_dim <- 32

inputs <- layer_input(shape(NULL), dtype = "int64")

outputs <- inputs %>%

layer_positional_embedding(sequence_length, vocab_size, embed_dim) %>%

layer_transformer_encoder(embed_dim, dense_dim, num_heads) %>%

layer_global_average_pooling_1d() %>%

layer_dropout(0.5) %>%

layer_dense(1, activation = "sigmoid")

model <-

keras_model(inputs, outputs) %>%

compile(optimizer = "rmsprop",

loss = "binary_crossentropy",

metrics = "accuracy")

model

图片

callbacks <- list(

callback_model_checkpoint("full_transformer_encoder.keras",

save_best_only = TRUE)

)

model %>% fit(

int_train_ds,

validation_data = int_val_ds,

epochs = 20,

callbacks = callbacks

)

model <- load_model_tf(

"full_transformer_encoder.keras",

custom_objects = list(layer_transformer_encoder,

layer_positional_embedding))

cat(sprintf(

"测试准确率:%.3f\n", evaluate(model, int_test_ds)["accuracy"]))

测试准确率:0.886

看这里!我们达到了 88.6%的测试准确率——这种改进证明了单词顺序信息对文本分类的价值。这是我们迄今为止最好的序列模型!然而,它仍然比词袋模型差一档。

11.4.4 何时使用序列模型而不是词袋模型

你可能会听说词袋模型方法已过时,而基于 Transformer 的序列模型是前进的道路,无论你看的是什么任务或数据集。这绝对不是这种情况:在许多情况下,在词袋模型之上放置一小叠稠密层仍然是一个完全有效和相关的方法。事实上,在本章节中我们在 IMDB 数据集上尝试的各种技术中,迄今为止表现最佳的是词袋模型!那么,何时您应该在另一种方法上更倾向于另一种方法?

2017 年,我和我的团队对多种不同类型的文本数据集上各种文本分类技术的性能进行了系统分析,我们发现了一个惊人而令人惊讶的经验法则,用于决定是选择词袋模型还是序列模型(mng.bz/AOzK)——一种黄金常数。事实证明,当面对一个新的文本分类任务时,你应该密切关注训练数据中样本数量与每个样本平均字数之间的比率(见图 11.11)。如果这个比率很小——小于 1,500——那么二元模型的表现会更好(而且作为额外奖励,它的训练和迭代速度也会更快)。如果这个比率高于 1,500,则应选择序列模型。换句话说,当有大量训练数据可用且每个样本相对较短时,序列模型的效果最佳。

图像

图 11.11 选择文本分类模型的一个简单启发式方法:训练样本数与每个样本平均字数之间的比率

所以,如果你要分类的是 1,000 字长的文档,而你有 100,000 个这样的文档(比例为 100),你应该选择一个二元模型。如果你要分类的是平均长度为 40 个字的推文,而你有 50,000 条这样的推文(比例为 1,250),你也应该选择一个二元模型。但如果你的数据集大小增加到 500,000 条推文(比例为 12,500),那就选择一个 Transformer 编码器。那 IMDB 电影评论分类任务呢?我们有 20,000 个训练样本,平均字数为 233,所以我们的经验法则指向一个二元模型,这证实了我们在实践中的发现。

这在直觉上是有道理的:序列模型的输入代表了一个更丰富、更复杂的空间,因此需要更多的数据来映射出这个空间;与此同时,一组简单的术语是一个如此简单的空间,以至于你可以只用几百或几千个样本来训练顶部的逻辑回归。此外,样本越短,模型就越不能丢弃其中包含的任何信息——特别是,单词顺序变得更加重要,丢弃它可能会产生歧义。句子“这部电影太棒了”和“这部电影是一颗炸弹”有非常接近的单字表示,这可能会让词袋模型感到困惑,但序列模型可以告诉哪一个是消极的,哪一个是积极的。对于更长的样本,单词统计将变得更可靠,而从单词直方图中就能更明显地看出主题或情感。

现在,请记住,这个启发式规则是专门为文本分类而开发的。它不一定适用于其他自然语言处理任务——例如,对于机器翻译来说,相比于循环神经网络,Transformer 在处理非常长的序列时表现得特别出色。我们的启发式规则也只是一个经验法则,而不是科学定律,所以请期望它大部分时间都有效,但不一定总是有效。

11.5 超越文本分类:序列到序列学习

你现在拥有了处理大多数自然语言处理任务所需的所有工具。然而,你只看到这些工具在单一问题上的应用:文本分类。这是一个极其流行的用例,但自然语言处理远不止于此。在这一部分,你将通过学习序列到序列模型来深化你的专业知识。

序列到序列模型接受一个序列作为输入(通常是一个句子或段落),并将其转换成另一个序列。这是许多最成功的自然语言处理应用程序的核心任务之一:

  • 机器翻译—将源语言的段落转换成目标语言的等效段落。

  • 文本摘要—将长篇文档转换成保留最重要信息的较短版本。

  • 问答—将输入的问题转换成答案。

  • 聊天机器人—将对话提示转换成对该提示的回复,或将对话历史转换成对话中的下一个回复。

  • 文本生成—将文本提示转换成完成提示的段落。

  • 等等。

序列到序列模型的一般模板描述在图 11.12 中。在训练过程中:

  • 编码器模型将源序列转换为中间表示。

  • 解码器通过查看之前的标记(1 到 i - 1)和编码后的源序列来训练,以预测目标序列中的下一个标记 i。

图片

图 11.12 序列到序列学习:源序列经过编码器处理,然后发送到解码器。解码器查看目标序列至今,并预测偏移一个步骤的目标序列。在推断过程中,我们逐个目标标记地生成并将其送回解码器。

在推断中,我们无法访问目标序列——我们试图从头开始预测它。我们将不得不逐个标记地生成它:

  1. 1 我们从编码器中获得编码后的源序列。

  2. 2 解码器首先查看编码后的源序列以及一个初始的“种子”标记(例如字符串“[start]”),并用它来预测序列中的第一个真实标记。

  3. 3 到目前为止预测的序列被送回解码器,解码器生成下一个标记,依此类推,直到生成一个停止标记(如字符串“[end]”)。

到目前为止,你学到的所有东西都可以重新用于构建这种新型模型。让我们深入了解。

11.5.1 一个机器翻译示例

我们将在一个机器翻译任务上演示序列到序列建模。机器翻译正是 Transformer 开发的初衷!我们将从循环序列模型开始,并将跟进完整的 Transformer 架构。我们将使用www.manythings.org/anki/.上提供的英语到西班牙语翻译数据集。让我们下载它:

download.file(

"http://storage.googleapis.com/download.tensorflow.org/data/spa-eng.zip",

destfile = "spa-eng.zip")

zip::unzip("spa-eng.zip")

文本文件每行包含一个示例:一个英语句子,后跟一个制表符,然后是相应的西班牙句子。让我们使用 readr::read_tsv(),因为我们有制表符分隔的值:

text_file <- "spa-eng/spa.txt"

text_pairs <- text_file %>%➊

readr::read_tsv(col_names = c("english", "spanish"),➋

col_types = c("cc")) %>%➌

within(spanish %<>% paste("[start]", ., "[end]"))➍

使用 read_tsv()读取文件(制表符分隔的值)。

每行包含一个英语短语及其西班牙语翻译,用制表符分隔。

两字符列

我们在西班牙语句子前加上“[start]”,并在后面加上“[end]”,以匹配图 11.12 中的模板。

我们的 text_pairs 看起来是这样的:

str(text_pairs[sample(nrow(text_pairs), 1), ])

tibble [1 × 2] (S3: tbl_df/tbl/data.frame)

$ english: chr "I’m staying in Italy."

$ spanish: chr "[start] Me estoy quedando en Italia. [end]"

让我们对它们进行洗牌并将它们拆分成通常的训练、验证和测试集:

num_test_samples <- num_val_samples <-

round(0.15 * nrow(text_pairs))

num_train_samples <- nrow(text_pairs) - num_val_samples - num_test_samples

pair_group <- sample(c(

rep("train", num_train_samples),

rep("test", num_test_samples),

rep("val", num_val_samples)

))

train_pairs <- text_pairs[pair_group == "train", ]

test_pairs <- text_pairs[pair_group == "test", ]

val_pairs <- text_pairs[pair_group == "val", ]

接下来,让我们准备两个单独的 TextVectorization 层:一个用于英语,一个用于西班牙语。我们需要定制字符串的预处理方式:

  • 我们需要保留我们插入的“[start]”和“[end]”标记。默认情况下,字符[和]将被剥离,但我们希望保留它们,以便我们可以区分单词“start”和起始标记“[start]”。

  • 标点符号在不同语言之间是不同的!在西班牙语 Text-Vectorization 层中,如果我们要剥离标点字符,我们还需要剥离字符¿。

请注意,对于非玩具翻译模型,我们将把标点字符视为单独的标记,而不是剥离它们,因为我们希望能够生成正确标点的句子。在我们的情况下,为了简单起见,我们将摆脱所有标点符号。

我们为西班牙语 TextVectorization 层准备了一个自定义字符串标准化函数:它保留了 [ 和 ],但剥离了 ¿、¡ 和 [:punct:] 类中的所有其他字符。([:punct:] 类的双重否定会互相抵消,就好像根本没有否定一样。然而,外部否定正则表达式分组让我们能够明确排除 [:punct:] 正则表达式类中的 [ 和 ]。我们使用 | 添加了其他不在 [:punct:] 字符类中的特殊字符,比如 ¡ 和 ¿。)

第 11.26 节 将英语和西班牙语文本对转为向量

punctuation_regex <- "[[:punct:][\]]|[¡¿]"➊

library(tensorflow)

custom_standardization <- function(input_string) {➋

input_string %>%

tf\(strings\)lower() %>%

tf\(strings\)regex_replace(punctuation_regex, "")

}

input_string <- as_tensor("[start] ¡corre! [end]")

custom_standardization(input_string)

tf.Tensor(b’[start] corre [end]’, shape=(), dtype=string)➌

基本上,就是 [[:punct:]],除了省略了 "[" 和 "]",添加了 "¿" 和 "¡"。

注意:这次我们使用张量操作。这允许函数被追踪到 TensorFlow 图中。

保留了 [start] 和 [end] 的 [],并去除了 ¡ 和 !。

警告 TensorFlow 正则表达式与 R 正则引擎有细微差异。如果您需要高级正则表达式,请查阅源文档:github.com/google/re2/wiki/Syntax

vocab_size <- 15000➊

sequence_length <- 20

source_vectorization <- layer_text_vectorization(➋

max_tokens = vocab_size,

output_mode = "int",

output_sequence_length = sequence_length

)

target_vectorization <- layer_text_vectorization(➌

max_tokens = vocab_size,

output_mode = "int", output_sequence_length = sequence_length + 1,➍

standardize = custom_standardization

)

adapt(source_vectorization, train_pairs$english)➎

adapt(target_vectorization, train_pairs$spanish)

为了简单起见,我们将只考虑每种语言中的前 15,000 个单词,并将句子限制在 20 个词以内。

英语层

西班牙语层

生成西班牙句子,多了一个额外的标记,因为在训练过程中我们需要将句子向前偏移一步。

学习每种语言的词汇。

最后,我们可以将我们的数据转为 TF Dataset 流水线。我们希望它返回一个对,(inputs, target),其中 inputs 是一个带有两个条目的命名列表,英语句子(编码器输入)和西班牙句子(解码器输入),target 是西班牙句子向前偏移一步。

第 11.27 节 为翻译任务准备数据集

format_pair <- function(pair) {

eng <- source_vectorization(pair$english)➊

spa <- target_vectorization(pair$spanish)

inputs <- list(english = eng,

spanish = spa[NA:-2])➋

targets <- spa[2:NA]➌

list(inputs, targets)➍

}

batch_size <- 64

library(tfdatasets)

make_dataset <- function(pairs) {

tensor_slices_dataset(pairs) %>%

dataset_map(format_pair, num_parallel_calls = 4) %>%

dataset_cache() %>%➎

dataset_shuffle(2048) %>%

dataset_batch(batch_size) %>%

dataset_prefetch(16)

}

train_ds <- make_dataset(train_pairs)

val_ds <- make_dataset(val_pairs)

矢量化层可以使用批量化或非批量化数据调用。在这里,我们在对数据进行批量化之前应用矢量化。

省略西班牙句子的最后一个标记,这样输入和目标的长度就一样了。[NA:-2]删除了张量的最后一个元素。

[2:NA]删除了张量的第一个元素。

目标西班牙句子比源句子提前一步。两者长度仍然相同(20 个单词)。

使用内存缓存来加快预处理速度。

这是我们的数据集输出的样子:

c(inputs, targets) %<-% iter_next(as_iterator(train_ds))

str(inputs)

2 个列表

$ english:<tf.Tensor: shape=(64, 20), dtype=int64, numpy=…>

$ spanish:<tf.Tensor: shape=(64, 20), dtype=int64, numpy=…>

str(targets)

<tf.Tensor: shape=(64, 20), dtype=int64, numpy=…>

数据现在准备好了——是时候构建一些模型了。我们将先从一个递归序列到序列模型开始,然后再转向一个 Transformer 模型。

11.5.2 使用 RNN 进行序列到序列学习

在 2015 年至 2017 年期间,递归神经网络在序列到序列学习中占据主导地位,然后被 Transformer 超越。它们是许多实际机器翻译系统的基础,如第十章所提到的。2017 年左右的谷歌翻译就是由七个大型 LSTM 层堆叠而成。今天仍然值得学习这种方法,因为它为理解序列到序列模型提供了一个简单的入门点。

使用 RNN 将一个序列转换为另一个序列的最简单、天真的方式是保留 RNN 在每个时间步的输出。在 Keras 中,它看起来像这样:

inputs <- layer_input(shape = c(sequence_length), dtype = "int64")

输出 <- 输入 %>%

layer_embedding(input_dim = vocab_size, output_dim = 128) %>%

layer_lstm(32, return_sequences = TRUE) %>%

layer_dense(vocab_size, activation = "softmax")

model <- keras_model(inputs, outputs)

然而,这种方法存在两个主要问题:

  • 目标序列必须始终与源序列具有相同的长度。实际上,情况很少如此。从技术上讲,这并不重要,因为你始终可以在源序列或目标序列中填充其中之一,以使它们的长度匹配。

  • 由于 RNN 的逐步性质,该模型只会查看源序列中的标记 1…N来预测目标序列中的标记N。这种限制使得这种设置对大多数任务不适用,尤其是翻译任务。考虑将“The weather is nice today”翻译成法语,即“Il fait beau aujourd’hui。”你需要能够仅仅从“The”预测出“Il”,仅仅从“The weather”预测出“Il fait”,等等,这是不可能的。

如果你是一个人类翻译员,你会先阅读整个源句子,然后开始翻译它。这在处理词序完全不同的语言时尤其重要,比如英语和日语。而标准的序列到序列模型正是这样做的。

在一个适当的序列到序列设置中(见 图 11.13),你首先会使用一个 RNN(编码器)将整个源序列转换为单个向量(或一组向量)。这可以是 RNN 的最后输出,或者是其最终的内部状态向量。然后,您将使用此向量(或向量)作为另一个 RNN(解码器)的初始状态,该解码器将查看目标序列的元素 1…N,并尝试预测目标序列中的步骤 N+1。

Image

图 11.13 一个序列到序列 RNN:一个 RNN 编码器用于产生编码整个源序列的向量,这个向量被用作另一个 RNN 解码器的初始状态。

让我们在 Keras 中用基于 GRU 的编码器和解码器来实现这一点。与 LSTM 相比,选择 GRU 使事情变得简单一些,因为 GRU 只有一个状态向量,而 LSTM 有多个。让我们从编码器开始。

列表 11.28 基于 GRU 的编码器

embed_dim <- 256

latent_dim <- 1024

source <- layer_input(c(NA), dtype = "int64",

name = "english")➊

encoded_source <- source %>%

layer_embedding(vocab_size, embed_dim,

mask_zero = TRUE) %>%➋

双向(layer_gru(units = latent_dim),

merge_mode = "sum")➌

英文源句子在这里。通过指定输入的名称,我们能够用一个命名的输入列表来拟合()模型。

不要忘记掩码:在这种设置中很关键。

我们编码的源句子是双向 GRU 的最后一个输出。

接下来,让我们添加解码器——一个简单的 GRU 层,它的初始状态是编码的源句子。在其上面,我们添加一个 layer_dense(),为每个输出步骤生成对西班牙语词汇的概率分布。

列表 11.29 基于 GRU 的解码器和端到端模型

decoder_gru <- layer_gru(units = latent_dim, return_sequences = TRUE)

past_target <- layer_input(shape = c(NA), dtype = "int64", name = "spanish")➊

target_next_step <- past_target %>%

layer_embedding(vocab_size, embed_dim,

mask_zero = TRUE) %>%➋

decoder_gru(initial_state = encoded_source) %>%➌

layer_dropout(0.5) %>%

layer_dense(vocab_size, activation = "softmax")➍

seq2seq_rnn <-

keras_model(inputs = list(source, past_target),➎

outputs = target_next_step)

西班牙目标句子放在这里。

不要忘记掩码。

编码的源句子作为解码器 GRU 的初始状态。

预测下一个标记。

端到端模型:将源句子和目标句子映射到未来的目标句子中的一步

在训练期间,解码器将整个目标序列作为输入,但由于 RNN 的逐步性质,它仅查看输入中的令牌 1…N,以预测输出中的令牌N(它对应于序列中的下一个令牌,因为输出旨在偏移一个步骤)。这意味着我们只使用过去的信息来预测未来,正如我们应该的那样;否则,我们将作弊,我们的模型在推断时将无法工作。

让我们开始训练。

Listing 11.30 训练我们的循环序列到序列模型

seq2seq_rnn %>% compile(optimizer = "rmsprop",

loss = "sparse_categorical_crossentropy",

metrics = "accuracy")

seq2seq_rnn %>% fit(train_ds, epochs = 15, validation_data = val_ds)

我们选择准确性作为在训练过程中监视验证集性能的粗略方式。我们达到了 64%的准确率:平均而言,模型在 64%的时间内正确预测了西班牙语句子中的下一个单词。然而,在实践中,下一个标记准确性并不是机器翻译模型的好指标,特别是因为它假设在预测标记N +1 时,已知从 0 到N的正确目标标记。实际上,在推断期间,您正在从头开始生成目标句子,而不能依赖于先前生成的标记完全正确。如果您正在开发真实世界的机器翻译系统,您可能会使用“BLEU 分数”来评估您的模型——这是一个考虑整个生成序列的指标,似乎与人类对翻译质量的感知良好相关。

最后,让我们使用我们的模型进行推断。我们将在测试集中挑选几个句子,并检查我们的模型如何翻译它们。我们将从种子标记“[start]”开始,并将其连同编码的英语源句子一起馈送到解码器模型中。我们将检索下一个标记预测,并将其反复注入解码器,每次迭代抽样一个新的目标标记,直到我们到达“[end]”或达到最大句子长度。

Listing 11.31 使用我们的 RNN 编码器和解码器翻译新句子

spa_vocab <- get_vocabulary(target_vectorization)➊

max_decoded_sentence_length <- 20

decode_sequence <- function(input_sentence) {

tokenized_input_sentence <

source_vectorization(array(input_sentence, dim = c(1, 1)))

decoded_sentence <- "[start]"➋

for (i in seq(max_decoded_sentence_length)) {

tokenized_target_sentence <-

target_vectorization(array(decoded_sentence, dim = c(1, 1)))

next_token_predictions <- seq2seq_rnn %>%

predict(list(tokenized_input_sentence,➌

tokenized_target_sentence))

sampled_token_index <- which.max(next_token_predictions[1, i, ])

sampled_token <- spa_vocab[sampled_token_index]➍

decoded_sentence <- paste(decoded_sentence, sampled_token)

if (sampled_token == "[end]")➎

break

}

decoded_sentence

}

for (i in seq(20)) {

input_sentence <- sample(test_pairs$english, 1)

print(input_sentence)

print(decode_sequence(input_sentence))

print("-")

}

[1] "这件裙子穿在我身上好看吗?"

[1] "[start] este vestido me parece bien [UNK] [end]"

[1] "-"

准备词汇表,将令牌索引预测转换为字符串令牌。

种子令牌

抽样下一个令牌。

将下一个令牌预测转换为字符串,并将其附加到生成的句子中。

退出条件:达到最大长度或抽样到停止令牌。

decode_sequence() 现在工作得很好,尽管可能比我们想象的要慢一些。加速 eager 代码的一个简单方法是使用 tf_function(),我们在第七章首次见到它。让我们重写 decode_sentence(),使其由 tf_function() 编译。这意味着我们将不再使用 eager R 函数,如 seq()、predict() 和 which.max(),而是使用 TensorFlow 的等效函数,如 tf\(range(),直接调用 model(),以及 tf\)argmax()。

因为 tf\(range() 和 tf\)argmax() 返回的是基于 0 的值,我们将设置一个函数局部选项:option(tensorflow.extract.style = “python”)。这样一来,张量的 [ 行为也将是基于 0 的。

tf_decode_sequence <- tf_function(function(input_sentence) {

withr::local_options(

tensorflow.extract.style = "python")➊

tokenized_input_sentence <- input_sentence %>%

as_tensor(shape = c(1, 1)) %>%

source_vectorization()

spa_vocab <- as_tensor(spa_vocab)

decoded_sentence <- as_tensor("[start]", shape = c(1, 1))

for (i in tf$range(as.integer(max_decoded_sentence_length))) {

tokenized_target_sentence <- decoded_sentence %>%

target_vectorization()

next_token_predictions <-

seq2seq_rnn(list(tokenized_input_sentence,

tokenized_target_sentence))

sampled_token_index <-

tf$argmax(next_token_predictions[0, i, ])➋

sampled_token <- spa_vocab[sampled_token_index]➌

decoded_sentence <-

tf\(strings\)join(c(decoded_sentence, sampled_token),

separator = " ")

if (sampled_token == "[end]")

break

}

decoded_sentence

})

for (i in seq(20)) {

input_sentence <- sample(test_pairs$english, 1)

cat(input_sentence, "\n")

cat(input_sentence %>% as_tensor() %>%➍

tf_decode_sequence() %>% as.character(), "\n")

cat("-\n")

}

现在所有使用 [ 进行张量子集的操作都将是基于 0 的,直到此函数退出。

tf$range() 中的 i 是基于 0 的。

tf$argmax() 返回的是基于 0 的索引。

在调用 tf_decode_sequence() 之前转换为张量,然后将输出转换回 R 字符串。

我们的 tf_decode_sentence() 比 eager 版本快了约 10 倍。还不错!

请注意,尽管这种推理设置非常简单,但相当低效,因为每次抽样新单词时,我们都会重新处理整个源句子和整个生成的目标句子。在实际应用中,您应该将编码器和解码器分开为两个单独的模型,并且您的解码器每次仅在抽样迭代中运行一步,重复使用其先前的内部状态。

这是我们的翻译结果。对于一个玩具模型来说,我们的模型效果还不错,尽管仍然会出现许多基本错误。

列表 11.32 递归翻译模型的一些样本结果

谁在这个房间里?

[start] 在这个房间里谁 [end]

那听起来不太危险。

[start] 那不太难 [end]

没人能阻止我。

[start] 没有人能阻止我 [end]

汤姆很友好。

[start] 汤姆很友好 [end]

这个玩具模型有很多改进的方法:我们可以对编码器和解码器都使用堆叠的深度循环层(请注意,对于解码器,这会使状态管理变得更加复杂)。我们可以使用 LSTM 而不是 GRU 等等。除了这些微调之外,RNN 方法用于序列到序列学习具有一些根本上的限制:

  • 源序列表示必须完全保存在编码器状态向量中,这对你能够翻译的句子的大小和复杂度施加了重要的限制。这有点像一个人完全从记忆中翻译句子,而不在产生翻译时再看一眼源语句子。

  • RNN 在处理非常长的序列时会遇到麻烦,因为它们往往会逐渐忘记过去——当你已经到达任一序列的第 100 个标记时,关于序列开头的信息已经几乎消失了。这意味着基于 RNN 的模型无法保持长期的上下文,而这对于翻译长文档可能是必要的。

这些限制正是机器学习社区采用 Transformer 架构解决序列到序列问题的原因。让我们来看一下。

11.5.3 带 Transformer 的序列到序列学习

序列到序列学习是 Transformer 真正发挥作用的任务。神经注意力使得 Transformer 模型能够成功处理比 RNN 更长、更复杂的序列。

作为一个将英语翻译成西班牙语的人,你不会逐个单词地阅读英语句子,将其意义记在脑海中,然后再逐个单词地生成西班牙语句子。这对于一个五个单词的句子可能有效,但对于整个段落而言可能很难奏效。相反,你可能需要在源语句子和正在翻译的语句之间来回切换,并在写下翻译的不同部分时注意源语句子中的不同单词。

这正是你可以通过神经注意力和 Transformer 实现的。你已经熟悉了 Transformer 编码器,它使用自注意力来为输入序列中的每个标记产生上下文感知表示。在序列到序列 Transformer 中,Transformer 编码器自然地扮演编码器的角色,阅读源序列并生成其编码表示。不像以前的 RNN 编码器,Transformer 编码器将编码表示保持为序列格式:它是一系列上下文感知嵌入向量。

模型的第二部分是 Transformer 解码器。就像 RNN 解码器一样,它读取目标序列中的令牌 1…N 并尝试预测令牌 N + 1。至关重要的是,在执行此操作时,它使用神经注意力来识别编码源句子中与当前正在尝试预测的目标令牌最相关的令牌——也许与人类翻译者所做的事情类似。回想一下查询-键-值模型:在 Transformer 解码器中,目标序列充当用于更密切关注源序列不同部分的注意力“查询”的角色(源序列扮演了键和值的角色)。

TRANSFORMER 解码器

图 11.14 展示了完整的序列到序列 Transformer。看一下解码器的内部:你会认识到它看起来非常类似于 Transformer 编码器,只是在应用于目标序列的自注意力块和退出块的密集层之间插入了一个额外的注意力块。

Image

图 11.14 TransformerDecoder 类似于 TransformerEncoder,不同之处在于它具有一个额外的注意力块,其中键和值是由 TransformerEncoder 编码的源序列。编码器和解码器共同形成一个端到端的 Transformer。

让我们来实现它。就像对于 TransformerEncoder 一样,我们将创建一个新的层类。在我们关注 call() 方法之前,这个方法是发生动作的地方,让我们先定义类构造函数,包含我们将需要的层。

TransformerDecoder 列表 11.33

layer_transformer_decoder <- new_layer_class(

classname = "TransformerDecoder",

initialize = function(embed_dim, dense_dim, num_heads, …) {

super$initialize(…)

self$embed_dim <- embed_dim

self$dense_dim <- dense_dim

self$num_heads <- num_heads

self$attention_1 <- layer_multi_head_attention(num_heads = num_heads,

key_dim = embed_dim)

self$attention_2 <- layer_multi_head_attention(num_heads = num_heads,

key_dim = embed_dim)

self$dense_proj <- keras_model_sequential() %>%

layer_dense(dense_dim, activation = "relu") %>%

layer_dense(embed_dim)

self$layernorm_1 <- layer_layer_normalization()

self$layernorm_2 <- layer_layer_normalization()

self$layernorm_3 <- layer_layer_normalization()

self$supports_masking <- TRUE➊

},

get_config = function() {

config <- super$get_config()

for (name in c("embed_dim", "num_heads", "dense_dim"))

config[[name]] <- self[[name]]

config

},

这个属性确保层将其输入掩码传播到其输出;在 Keras 中,掩码必须明确地选择。如果你将一个掩码传递给一个不实现 compute_mask() 并且不公开这个 supports_masking 属性的层,那就是一个错误。

call() 方法几乎是从 图 11.14 的连通性图的直观渲染。但是还有一个额外的细节我们需要考虑:因果填充。因果填充对于成功训练序列到序列 Transformer 是绝对关键的。与 RNN 不同,RNN 逐步查看其输入,因此在生成输出步骤 N(这是目标序列中的标记 N+1)时只能访问步骤 1…N 的信息,TransformerDecoder 是无序的:它一次查看整个目标序列。如果允许它使用其整个输入,它将简单地学会将输入步骤 N+1 复制到输出的位置 N。因此,该模型将实现完美的训练准确度,但当进行推断时,它将完全无用,因为超出 N 的输入步骤是不可用的。

解决方法很简单:我们将屏蔽成对注意力矩阵的上半部分,以防止模型关注未来的任何信息——只有目标序列中的标记 1…N 的信息应该在生成目标标记 N+1 时使用。为此,我们将在我们的 TransformerDecoder 中添加一个 get_causal_attention_mask(inputs) 方法,以检索我们可以传递给我们的 MultiHeadAttention 层的注意力屏蔽。

列出 11.34 TransformerDecoder 生成因果掩码的方法

get_causal_attention_mask = function(inputs) {

c(batch_size, sequence_length, .) %<-%➊

tf\(unstack(tf\)shape(inputs))

x <- tf$range(sequence_length)➋

i <- x[, tf$newaxis]

j <- x[tf$newaxis, ]

mask <- tf$cast(i >= j, "int32")➌ ➍

tf\(tile(mask[tf\)newaxis, , ],

tf$stack(c(batch_size, 1L, 1L)))➎

},

第三个轴是 encoding_length;我们在这里不使用它。

整数序列 [0, 1, 2, … sequence_length-1]

在我们的 >= 操作中使用 Tensor 广播。将 dtype bool 转换为 int32。

掩码是一个形状为 (sequence_length, sequence_length) 的方阵,其中下三角有 1,其他地方为 0。例如,如果 sequence_length 是 4,那么掩码是:

tf.Tensor([[1 0 0 0]

[1 1 0 0]

[1 1 1 0]

[1 1 1 1]], shape=(4, 4), dtype=int32)

向掩码添加一个批量维度,然后沿着批量维度复制(rep()) batch_size 次。返回的张量形状为 (batch_size, sequence_length, sequence_length)。

现在我们可以写下实现解码器前向传递的完整 call() 方法。

11.35 列出了 TransformerDecoder 的前向传递

call = function(inputs, encoder_outputs, mask = NULL) {

causal_mask <- self$get_causal_attention_mask(inputs)➊

if (is.null(mask))➋

mask <- causal_mask

else

mask %<>% { tf\(minimum(tf\)cast(.[, tf$newaxis, ], "int32"),

causal_mask) }➌

inputs %>%

{ self$attention_1(query = ., value = ., key = .,

attention_mask = causal_mask) + . } %>%➍

self$layernorm_1() %>%➎

{ self$attention_2(query = .,

value = encoder_outputs,➏

key = encoder_outputs,➏

attention_mask = mask) + . } %>%➐

self$layernorm_2() %>%➑

{ self$dense_proj(.) + . } %>%➒

self$layernorm_3()

})

检索因果掩码。

在调用中提供的掩码是填充掩码(它描述目标序列中的填充位置)

将填充掩码与因果屏蔽组合。

将因果屏蔽传递给第一个注意力层,该注意力层在目标序列上执行自我注意力。

将带有残差的 attention_1()输出传递给 layernorm_1()。

在调用中使用 encoder_output 作为 attention_2()的 value 和 key 参数。

将组合屏蔽传递给第二个注意力层,该层将源序列与目标序列相关联。

将带有残差的 attention_2()输出传递给 layernorm_2()。

将带有残差的 dense_proj()输出加起来并传递到 layernorm_3()。

将所有内容组合到一起:面向机器翻译的 Transformer

端到端 Transformer 是我们将要训练的模型。它将源序列和目标序列映射到目标序列的下一个步骤。它直接组合了我们迄今为止构建的部分:PositionalEmbedding 层,TransformerEncoder 和 TransformerDecoder。请注意,Transformer-Encoder 和 TransformerDecoder 都形状不变,因此您可以堆叠许多它们来创建更强大的编码器或解码器。在我们的示例中,我们将坚持每个部分单独一个实例。

图 11.36 端到端 Transformer

embed_dim <- 256

dense_dim <- 2048

num_heads <- 8

encoder_inputs <- layer_input(shape(NA), dtype = "int64", name = "english")

encoder_outputs <- encoder_inputs %>%

layer_positional_embedding(sequence_length, vocab_size, embed_dim) %>%

layer_transformer_encoder(embed_dim, dense_dim, num_heads)➊

transformer_decoder <-

layer_transformer_decoder(NULL, embed_dim, dense_dim, num_heads)➋

decoder_inputs <- layer_input(shape(NA), dtype = "int64", name = "spanish")

decoder_outputs <- decoder_inputs %>%

layer_positional_embedding(sequence_length, vocab_size, embed_dim) %>%

transformer_decoder(., encoder_outputs) %>%➌

layer_dropout(0.5) %>%

layer_dense(vocab_size, activation = "softmax")➍

transformer <- keras_model(list(encoder_inputs, decoder_inputs),

decoder_outputs)

编码源句子。

对于第一个参数传递 NULL,以便直接创建并返回一个层实例,而不是将其与任何内容组合。

编码目标句子并将其与编码的源句子结合起来。

针对每个输出位置预测一个单词。

现在我们准备训练我们的模型,我们得到了 67%的准确率,比基于 GRU 的模型高得多。

图 11.37 训练序列到序列的 Transformer

transformer %>%

compile(optimizer = "rmsprop",

loss = "sparse_categorical_crossentropy",

metrics = "accuracy")

transformer %>%

fit(train_ds, epochs = 30, validation_data = val_ds)

最后,让我们尝试使用我们的模型来翻译来自测试集的从未见过的英语句子。设置与我们用于序列到序列 RNN 模型相同;唯一改变的是我们将 seq2seq_rnn 替换为 transformer,并且删除了我们配置的 target_vectorization() 层添加的额外标记。

列表 11.38 使用我们的 Transformer 模型翻译新句子

tf_decode_sequence <- tf_function(function(input_sentence) {

withr::local_options(tensorflow.extract.style = "python")

tokenized_input_sentence <- input_sentence %>%

as_tensor(shape = c(1, 1)) %>%

source_vectorization()

spa_vocab <- as_tensor(spa_vocab)

decoded_sentence <- as_tensor("[start]", shape = c(1, 1))

for (i in tf$range(as.integer(max_decoded_sentence_length))) {

tokenized_target_sentence <-

target_vectorization(decoded_sentence)[, NA:-1]➊

next_token_predictions <-➋

transformer(list(tokenized_input_sentence,

tokenized_target_sentence))

sampled_token_index <- tf$argmax(next_token_predictions[0, i, ])

sampled_token <- spa_vocab[sampled_token_index]➌

decoded_sentence <-

tf\(strings\)join(c(decoded_sentence, sampled_token),

separator = " ")

if (sampled_token == "[end]")➍

break

}

decoded_sentence

})

for (i in sample.int(nrow(test_pairs), 20)) {

c(input_sentence, correct_translation) %<-% test_pairs[i, ]

cat(input_sentence, "\n")

cat(input_sentence %>% as_tensor() %>%

tf_decode_sequence() %>% as.character(), "\n")

cat("-\n")

}

删除最后一个标记;“python” 样式不包括切片结尾。

随机抽取下一个标记。

将下一个标记预测转换为字符串,并附加到生成的句子中。

退出条件

主观上,Transformer 似乎比基于 GRU 的翻译模型表现要好得多。它仍然是一个玩具模型,但是是一个更好的玩具模型。

列表 11.39 Transformer 翻译模型的一些示例结果

这是我小的时候学会的一首歌。

[start] esta es una canción que aprendí cuando

Image era chico [end]➊

她会弹钢琴。

[start] ella puede tocar piano [end]

我不是你认为的那个人。

[start] no soy la persona que tú creo que soy [end]

昨晚可能下了一点雨。

[start] puede que llueve un poco el pasado [end]

尽管源句子没有明确性别,但这个翻译假设了一个男性说话者。请记住,翻译模型经常会对其输入数据做出不合理的假设,这会导致算法偏见。在最糟糕的情况下,模型可能会产生与当前处理的数据无关的记忆信息。

这就结束了关于自然语言处理的这一章节——你刚刚从最基础的知识到了一个完全成熟的 Transformer,可以将英语翻译成西班牙语。教会机器理解语言是你可以添加到自己技能集合中的最新超能力。

总结

  • 有两种 NLP 模型:处理单词集或N-gram 而不考虑其顺序的词袋模型,以及处理单词顺序的序列模型。词袋模型由稠密层构成,而序列模型可以是 RNN、一维卷积网络或 Transformer。

  • 在文本分类方面,训练数据中的样本数量与每个样本的平均单词数之间的比例可以帮助您确定是使用词袋模型还是序列模型。

  • 词嵌入 是语义关系建模为代表这些词的向量之间的距离关系的向量空间。

  • 序列到序列学习 是一种通用、强大的学习框架,可用于解决许多 NLP 问题,包括机器翻译。序列到序列模型由一个编码器(处理源序列)和一个解码器(通过查看编码器处理的源序列的过去标记来尝试预测目标序列中的未来标记)组成。

  • 神经注意力 是一种创建上下文感知词表示的方法。这是 Transformer 架构的基础。

  • Transformer 架构由 TransformerEncoder 和 TransformerDecoder 组成,在序列到序列的任务上产生了出色的结果。前半部分,TransformerEncoder,也可以用于文本分类或任何单输入 NLP 任务。

  1. ¹ Yoshua Bengio 等人,《神经概率语言模型》,《机器学习研究杂志》(2003)。

  2. ² Ashish Vaswani 等人,《关注是你所需要的一切》(2017),arxiv.org/abs/1706.03762

第十二章:生成深度学习

本章涵盖

  • 文本生成

  • DeepDream

  • 神经风格转换

  • 变分自编码器

  • 生成对抗网络

人工智能模拟人类思维过程的潜力不仅限于客观任务,如物体识别,也包括大部分是被动任务,如驾车。它还延伸到创造性活动。当我第一次声称在不久的将来,我们消费的大部分文化内容将在很大程度上在 AI 的帮助下创作时,甚至从事机器学习已久的从业者也对此表示怀疑。那是在 2014 年。几年后,怀疑以惊人的速度消退。2015 年夏天,我们被 Google 的 DeepDream 算法转换成一幅充满狗眼和类似图像的迷幻图片所娱乐;2016 年,我们开始使用智能手机应用程序将照片转换成各种风格的绘画;2016 年夏天,一部实验性的短片《夕阳之泉》是由长短期记忆(LSTM)写的剧本导演而成的。也许你最近听过由神经网络生成的音乐。

当然,到目前为止,我们从 AI 中看到的艺术作品质量相当低。AI 离得上人类编剧、画家和作曲家还远。但替代人类本来就不是重点:人工智能不是要用其他东西取代我们自己的智能,而是要给我们的生活和工作带来更多的智能——不同类型的智能。在许多领域,尤其是创造性领域,AI 将被人类用作增强自身能力的工具:更多的增强智能而不是人工智能

艺术创作的一个重要部分是简单的模式识别和技术技能。而这恰恰是许多人觉得不那么吸引人甚至可以舍弃的部分。这就是 AI 发挥作用的地方。我们的感知模式,我们的语言和我们的艺术作品都具有统计结构。学习这种结构是深度学习算法擅长的。机器学习模型可以学习图像、音乐和故事的统计潜在空间,然后可以从这个空间中采样,创建具有与模型在训练数据中见过的相似特征的新艺术作品。当然,这样的采样本身几乎不是艺术创作的行为。这只是一种纯粹的数学操作:算法没有基于人类生活、人类情感或我们对世界的经验;相反,它是从一个与我们的经验几乎没有共同之处的经验中学习的。只有我们作为人类观众的解释才能赋予模型生成的东西意义。但在一个技艺精湛的艺术家手中,算法生成可以被引导变得有意义和美丽。潜在空间采样可以成为赋予艺术家力量的画笔,增强我们的创造能力,并扩展我们可以想象的空间。更重要的是,它可以通过消除对技术技能和实践的需求使艺术创作更加容易,建立起一种纯粹表达的新媒介,将艺术与工艺分开。

Iannis Xenakis,电子音乐和算法音乐的开创者,于 1960 年代在将自动化技术应用于音乐作曲的背景下美妙地表达了这一想法:¹

从繁琐的计算中解脱出来,作曲家能够将自己专注于新音乐形式提出的一般问题,并在修改输入数据的值时探索这种形式的每个角落。例如,他可以测试从独奏家到室内乐队再到大型管弦乐队的所有器乐组合。在电子计算机的帮助下,作曲家成为一种飞行员:他按下按钮,输入坐标,并监督着一艘在声音空间中航行的宇宙飞船的控制,穿越他曾经只能将其看作是遥远梦想的声音星座和星系。

在本章中,我们将从各个角度探讨深度学习增强艺术创作的潜力。我们将回顾序列数据生成(可用于生成文本或音乐)、DeepDream,以及使用变分自动编码器和生成对抗网络进行图像生成。我们将让您的计算机做出以前从未见过的内容;也许我们还会让您梦想,梦想着技术和艺术交汇的奇妙可能性。让我们开始吧。

12.1 文本生成

在本节中,我们将探讨循环神经网络如何用于生成序列数据。我们以文本生成为例,但完全相同的技术可以推广到任何类型的序列数据:你可以将其应用于音乐音符序列以生成新音乐,应用于笔划数据的时间序列(也许是记录艺术家在 iPad 上绘画时记录下的)以逐笔生成绘画,等等。

序列数据生成绝不仅限于艺术内容生成。它已成功应用于语音合成和聊天机器人的对话生成。谷歌于 2016 年发布的 Smart Reply 功能,能够自动生成一系列快速回复电子邮件或短信的选项,就是由类似的技术驱动的。

12.1.1 生成式深度学习用于序列生成的简要历史

到了 2014 年末,很少有人在机器学习社区甚至见过 LSTM 这个缩写。成功应用循环网络生成序列数据的案例直到 2016 年才开始出现在主流中。但是这些技术具有相当长的历史,从 1997 年 LSTM 算法的开发开始(在第十章讨论过)。这个新算法最初用于逐字符生成文本。

2002 年,当时在瑞士 Schmidhuber 实验室的 Douglas Eck 首次将 LSTM 应用于音乐生成,并取得了令人鼓舞的结果。Eck 现在是 Google Brain 的研究员,在 2016 年,他在那里成立了一个名为 Magenta 的新研究组,专注于将现代深度学习技术应用于产生引人入胜的音乐。有时好的想法需要 15 年才能开始实施。

在 2000 年末和 2010 年初,Alex Graves 通过使用循环网络生成序列数据做出了重要的开创性工作。特别是,他在 2013 年将循环混合密度网络应用于使用笔位置的时间序列生成类似人类手写的工作被一些人视为一个转折点。在那个特定的时间点上,神经网络的这种特定应用捕捉到了“机器梦想”的概念,并且在我开始开发 Keras 的时候是一个重要的灵感来源。几年后,我们很多这样的发展已经司空见惯,但是在当时,很难看到 Graves 的演示而不对可能性感到敬畏。在 2015 年至 2017 年期间,循环神经网络成功用于文本和对话生成,音乐生成和语音合成。

然后在 2017-2018 年,Transformer 架构开始取代递归神经网络,不仅用于监督自然语言处理任务,也用于生成序列模型,特别是语言建模(词级文本生成)。最著名的生成式 Transformer 示例是 GPT-3,这是一种 1750 亿参数的文本生成模型,由初创公司 OpenAI 在庞大的文本语料库上进行训练,包括大多数数字化的书籍,维基百科以及整个互联网爬取的大部分内容。GPT-3 因其生成几乎任何主题的听起来可信的文本段落的能力而在 2020 年成为头条新闻,这种能力引发了最激烈的短暂人工智能热潮之一。

12.1.2 如何生成序列数据?

在深度学习中生成序列数据的通用方法是训练一个模型(通常是 Transformer 或 RNN),以预测序列中下一个标记或下几个标记,使用前面的标记作为输入。例如,给定输入“猫在上面”,模型会被训练以预测目标“垫子”,下一个单词。通常在处理文本数据时,标记通常是单词或字符,并且任何可以模拟给定先前标记情况下下一个标记的概率的网络都称为语言模型。语言模型捕捉了语言的潜在空间:它的统计结构。

一旦你有了训练好的语言模型,你可以从模型中采样(生成新的序列):你馈送一个初始的文本字符串(称为调节数据),请求它生成下一个字符或下一个单词(你甚至可以一次生成多个标记),将生成的输出添加回输入数据,并重复这个过程多次(参见图 12.1)。这个循环允许您生成任意长度的序列,反映了模型训练的数据结构:几乎像人类书写的句子。

Image

图 12.1 使用语言模型逐字逐句生成文本的过程

12.1.3 采样策略的重要性

在生成文本时,选择下一个标记的方式非常重要。一种简单的方法是贪心抽样,总是选择可能性最高的下一个字符。但这种方法会产生重复、可预测的字符串,不像是连贯的语言。一种更有趣的方法是做出稍微意外的选择:通过从下一个字符的概率分布中抽样,在抽样过程中引入随机性。这被称为随机抽样(这里需要注意的是,在这个领域中,随机性称为随机性)。在这样的设置中,如果根据模型,某个词在句子中作为下一个出现的概率为 0.3,那么你将有 30%的概率选择它。需要注意的是,贪心抽样也可以看作是从概率分布中进行抽样:其中某个词的概率为 1,其他所有词的概率都为 0。

从模型的 softmax 输出中以概率的方式抽样是不错的方法:即使是不太可能的单词也有可能被抽样到,这样生成的句子更有趣,有时甚至能创造出之前在训练数据中没有出现过的、听起来很真实的句子。但这种策略存在一个问题:它没有提供一种控制随机性的方法

为什么你想要更多或者更少的随机性呢?考虑一个极端情况:纯随机抽样,你从一个均匀概率分布中抽取下一个词,每个词的概率都是相等的。这种方案具有最大的随机性;换句话说,该概率分布具有最大的熵。显然,它不会产生任何有趣的结果。另一方面,贪心抽样也不会产生有趣的结果,而且没有随机性:相应的概率分布具有最小的熵。从“真实”的概率分布中抽样——即模型的 softmax 函数输出的分布——构成了这两个极端之间的一个中间点。但是,在更高或更低熵的许多其他中间点上也可以进行抽样,你可能想要在其中进行探索。较低的熵会给生成的序列提供一个更可预测的结构(因此,它们有可能看起来更真实),而较高的熵会产生更令人惊讶和富有创造力的序列。在从生成模型进行抽样时,探索不同随机性的产生过程是很有意义的。因为我们——人类——是对生成数据的有趣程度的终极评判者,所以有趣程度是非常主观的,无法事先确定最佳熵值所在的位置。

为了控制采样过程中的随机性,我们将引入一个参数,称为softmax temperature,它描述了用于采样的概率分布的熵:它描述了选择下一个单词的选择是多么令人惊讶或可预测。给定一个温度值,可以通过以下方式从原始概率分布(模型的 softmax 输出)计算出一个新的概率分布,即将其重新加权。

较高的温度会导致更高熵的采样分布,将产生更令人惊讶和结构不明显的生成数据,而较低的温度将导致更少的随机性和更可预测的生成数据(参见 图 12.2)。

图 12.1 将概率分布重新加权为不同温度的示例

重新加权分布 <-

function(original_distribution, temperature = 0.5) {

original_distribution %>% .➊

{ exp(log(.) / temperature) } %>%

{ . / sum(.) } ➋

} ➌

original_distribution 是一个概率值的一维数组,必须总和为 1。temperature 是一个量化输出分布熵的因子。

返回原始分布的重新加权版本。分布的总和可能不再为 1,因此将其除以其总和以获得新的分布。

请注意,reweight_distribution() 将适用于 1D R 向量和 1D Tensorflow 张量,因为 exp、log、/ 和 sum 都是 R 通用函数。

图片

图 12.2 对一个概率分布进行不同的重新加权:低温度 = 更确定性;高温度 = 更随机性

12.1.4 使用 Keras 实现文本生成

让我们在 Keras 实现中将这些想法付诸实践。你首先需要大量的文本数据,可以用来学习语言模型。你可以使用任何足够大的文本文件或文本文件集 - 维基百科、《指环王》等。

在本例中,我们将继续使用上一章的 IMDB 电影评论数据集,并学习生成以前未读过的电影评论。因此,我们的语言模型将是针对这些电影评论的风格和主题的模型,而不是英语语言的通用模型。

准备数据

就像在前一章中一样,让我们下载并解压缩 IMDB 电影评论数据集。(这是我们在第十一章中下载的同一数据集。)

图 12.2 下载并解压缩 IMDB 电影评论数据集

url <— "https://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz"

filename <— basename(url)

options(timeout = 60 * 10)➊

download.file(url, destfile = filename)

untar(filename)

10 分钟超时

你已经熟悉数据的结构:我们得到一个名为 aclImdb 的文件夹,其中包含两个子文件夹,一个用于负面情感的电影评论,一个用于正面情感的评论。每个评论都有一个文本文件。我们将调用 text_dataset_from_directory()并将 label_mode = NULL 作为参数,以创建一个 TF 数据集,该数据集从这些文件中读取并生成每个文件的文本内容。

清单 12.3 从文本文件创建 TF 数据集(一个文件 = 一个样本)

library(tensorflow)

library(tfdatasets)

library(keras)

dataset <— text_dataset_from_directory(

directory = "aclImdb",

label_mode = NULL,

batch_size = 256)

dataset <— dataset %>%

dataset_map( ~ tf\(strings\)regex_replace(.x, "
", " "))➊

去除许多评论中出现的"
" HTML 标签。这在文本分类中并不重要,但在这个例子中我们不想生成"
"标签!

现在让我们使用一个 layer_text_vectorization()来计算我们将使用的词汇表。我们只使用每个评论的前 sequence_length 个单词:当向量化文本时,我们的 layer_text_vectorization()将在超出这个范围时截断任何内容。

清单 12.4 准备一个 layer_text_vectorization()

sequence_length <— 100

vocab_size <— 15000➊

text_vectorization <— layer_text_vectorization(

max_tokens = vocab_size,

output_mode = "int",➋

output_sequence_length = sequence_length➌

)

adapt(text_vectorization, dataset)

我们将只考虑前 15000 个最常见的单词——其他任何单词都将被视为未知标记"[UNK]"。

我们想返回整数单词索引序列。

我们将使用长度为 100 的输入和目标(但因为我们将目标偏移 1,所以模型实际上将看到长度为 99 的序列)。

让我们使用该层来创建一个语言建模数据集,其中输入样本是向量化的文本,相应的目标是将文本偏移一个单词后的相同文本。

清单 12.5 设置语言建模数据集

prepare_lm_dataset <— function(text_batch) {

vectorized_sequences <— text_vectorization(text_batch)➊

x <— vectorized_sequences[, NA:-2]➋

y <— vectorized_sequences[, 2:NA]➌

list(x, y)

}

lm_dataset <— dataset %>%

dataset_map(prepare_lm_dataset, num_parallel_calls = 4)

将一批文本(字符串)转换为一批整数序列。

通过截取序列的最后一个单词来创建输入(删除最后一列)。

通过将序列偏移 1 来创建目标(删除第一列)。

基于 TRANSFORMER 的序列到序列模型

我们将训练一个模型,来预测句子中下一个单词的概率分布,给定一些初始单词。当模型训练完成后,我们将向其提供一个提示,采样下一个单词,将该单词添加回提示中,并重复此过程,直到生成一个短段落。

就像我们在第十章中对温度预测所做的那样,我们可以训练一个模型,该模型以N个词的序列作为输入,简单地预测第N+1 个词。然而,在序列生成的上下文中,这种设置存在几个问题。

首先,模型只有在可用N个词时才能学会产生预测,但有时候只用少于N个词来开始预测是有用的。否则,我们将被限制为仅使用相对较长的提示(在我们的实现中,N = 100 个词)。我们在第十章中并不需要这样做。

其次,我们的训练序列中许多是大部分重叠的。考虑 N = 4。文本“A complete sentence must have, at minimum, three things: a subject, verb, and an object”将用于生成以下训练序列:

  • “完整的句子必须”

  • “完整的句子必须有”

  • “句子必须具有”

  • “等等,直到“动词和一个宾语”

将每个这样的序列视为独立样本的模型将不得不进行大量冗余工作,多次重新编码其大部分已经见过的子序列。在第十章中,这并不是什么大问题,因为我们一开始就没有那么多训练样本,并且我们需要对密集和卷积模型进行基准测试,每次都重新做工作是唯一的选择。我们可以尝试通过使用步幅来对序列进行采样——在两个连续样本之间跳过几个词来减轻这个冗余问题。但这将减少我们的训练样本数量,同时只提供部分解决方案。

为了解决这两个问题,我们将使用序列到序列模型:我们将序列N个词(从1N索引)馈送到我们的模型中,并预测序列偏移一个(从2N+1)。我们将使用因果屏蔽来确保,对于任何i,模型将只使用从1i的词来预测第i+1个词。这意味着我们同时训练模型解决N个大部分重叠但不同的问题:在给定了 1 <= i <= N 个先前词的序列的情况下预测下一个词(参见图 12.3)。在生成时,即使你只用单个词提示模型,它也能给出下一个可能词的概率分布。

图片

图 12.3 与普通的下一个单词预测相比,序列到序列建模同时优化多个预测问题。

请注意,在第十章中的温度预测问题中,我们可以使用类似的序列到序列设置:给定 120 个小时数据点的序列,学习生成一个序列,其中包含未来 24 小时的 120 个温度数据点。您将不仅解决初始问题,还将解决预测 24 小时内温度的 119 个相关问题,给定 1 <= i < 120 的先前每小时数据点。如果您尝试在序列到序列设置中重新训练第十章中的 RNN,您会发现您会获得类似但逐渐变差的结果,因为用相同模型解决这些额外的 119 个相关问题的约束会略微干扰我们实际关心的任务。

在前一章中,您了解到了在一般情况下用于序列到序列学习的设置:将源序列输入到编码器中,然后将编码序列和目标序列一起输入到解码器中,解码器试图预测相同的目标序列,偏移一个步骤。当您进行文本生成时,没有源序列:您只是尝试预测目标序列中的下一个令牌,给定过去的令牌,我们可以仅使用解码器来完成。由于有因果填充,解码器将只查看单词 1…N 来预测单词 N+1

让我们实现我们的模型——我们将重用我们在第十一章中创建的构建模块:layer_positional_embedding() 和 layer_transformer_decoder()。

列表 12.6 一个简单的基于 Transformer 的语言模型

embed_dim <- 256

latent_dim <- 2048

num_heads <- 2

transformer_decoder <-

layer_transformer_decoder(NULL, embed_dim, latent_dim, num_heads)

inputs <- layer_input(shape(NA), dtype = "int64")

outputs <- inputs %>%

layer_positional_embedding(sequence_length, vocab_size, embed_dim) %>%

transformer_decoder(., .) %>%

layer_dense(vocab_size, activation = "softmax")➊

model <—

keras_model(inputs, outputs) %>%

compile(loss = "sparse_categorical_crossentropy",

optimizer = "rmsprop")

对可能的词汇单词进行 softmax 计算,针对每个输出序列时间步。

12.1.5 使用可变温度采样的文本生成回调

我们将使用一个回调函数,在每个 epoch 后使用一系列不同的温度来生成文本。这样可以让您看到随着模型开始收敛,生成的文本如何演变,以及温度在采样策略中的影响。为了种子文本生成,我们将使用提示“这部电影”:我们所有生成的文本都将以此开始。

首先,让我们定义一些函数来生成句子。稍后,我们将在回调中使用这些函数。

vocab <— get_vocabulary(text_vectorization) ➊

sample_next <— function(predictions, temperature = 1.0) {

predictions %>%

reweight_distribution(temperature) %>% ➋

sample.int(length(.), 1, prob = .)➌

}

generate_sentence <—

function(model, prompt, generate_length, temperature) {

sentence <— prompt➍

for (i in seq(generate_length)) {➎

model_preds <— sentence %>%

array(dim = c(1, 1)) %>%

text_vectorization() %>%

predict(model, .) ➏

sampled_word <— model_preds %>%

.[1, i, ] %>%➐

sample_next(temperature) %>%➑

vocab[.]➒

sentence <— paste(sentence, sampled_word)➓

}

sentence

}

我们将使用它来将单词索引(整数)转换回字符串,用于文本解码

用于采样的温度

实现从概率分布中进行可变温度抽样。

用于初始化文本生成的提示

迭代生成多少个单词。

将当前序列输入到我们的模型中。

检索最后一个时间步的预测结果……

……并用它们来采样一个新的标记……

……并将标记整数转换为字符串。

将新单词添加到当前序列并重复。

sample_next()和 generate_sentence()负责从模型生成句子的工作。它们会急切地工作;它们会调用 predict()来生成 R 数组的预测结果,调用 sample.int()来选择下一个标记,并使用 paste()来构建句子作为 R 字符串。

因为我们可能想要生成许多句子,所以对此进行了一些优化是有意义的。我们可以通过将 generate_sentence 重写为 tf_function()来大幅提高其速度(约 25 倍)。为此,我们只需要使用 TensorFlow 相应的函数替换一些 R 函数。我们可以将 for(i in seq())替换为 for(i in tf\(range())。我们也可以用 tf\)random\(categorical()替换 sample.int(),用 tf\)strings$join()代替 paste(),用 model(.)代替 predict(model, .)。sample_next()和 generate_sentence()在 tf_function()中的样子如下:

tf_sample_next <— function(predictions, temperature = 1.0) {

predictions %>%

reweight_distribution(temperature) %>%

{ log(.[tf$newaxis, ]) } %>% ➊

tf\(random\)categorical(1L) %>%

tf$reshape(shape())➋

}

library(tfautograph)➌

tf_generate_sentence <— tf_function(

function(model, prompt, generate_length, temperature) {

withr::local_options(tensorflow.extract.style = "python")

vocab <— as_tensor(vocab)

sentence <— prompt %>% as_tensor(shape = c(1, 1))

ag_loop_vars(sentence)➍

for (i in tf$range(generate_length)) {

model_preds <— sentence %>%

text_vectorization() %>%

model()

sampled_word <— model_preds %>%

.[0, i, ] %>%

tf_sample_next(temperature) %>%

vocab[.]

sentence <— sampled_word %>%

{ tf\(strings\)join(c(sentence, .), " ") }

}

sentence %>% tf$reshape(shape())➎

}

}

tf\(random\)catagorical()期望一个批量的对数概率。

tf\(random\)catagorical()返回形状为(1, 1)的标量整数。重新调整为形状().

对 ag_loop_vars()(稍后详细介绍)

向编译器提供提示,说明sentence是我们在迭代后想要的唯一变量。

从(1, 1)形状重塑为()。注意 tf\(strings\)join()在整个迭代过程中保持 sentence 的(1, 1)形状。

在我的机器上,使用急切生成 generate_sentence() 生成 50 个词的句子大约需要 2.5 秒,而使用 tf_generate_sentence() 只需要 0.1 秒,提高了 25 倍!记住,先通过急切运行原型代码进行原型设计,只有在达到想要的效果后才使用 tf_function()。

for 循环和 autograph

在使用 tf_ 函数(fn, autograph = TRUE)(默认设置)包装之前急切地评估 R 函数时,一个注意事项是 autograph = TRUE 提供了基本 R 没有的功能,比如让 for 能够迭代张量。你仍然可以通过直接调用 tfautograph::autograph() 来急切地评估诸如 for(i in tf$range()) 或 for(batch in tf_ dataset) 这样的表达式,例如:

library(tfautograph)

autograph({

for(i in tf$range(3L))

print(i)

})

tf.Tensor(0, shape=(), dtype=int32)

tf.Tensor(1, shape=(), dtype=int32)

tf.Tensor(2, shape=(), dtype=int32)

或者

fn <— function(x) {

for(i in x) print(i)

}

ag_fn <— autograph(fn)

ag_fn(tf$range(3))

tf.Tensor(0.0, shape=(), dtype=float32)

tf.Tensor(1.0, shape=(), dtype=float32)

tf.Tensor(2.0, shape=(), dtype=float32)

在交互式会话中,你可以通过调用 tfautograph:::attach_ag_mask() 临时全局启用 if、while 和 for 来接受张量。

在 tf_function() 中迭代张量的 for() 循环构建了一个 tf$while_loop(),并且继承了所有相同的限制。循环跟踪的每个张量在整个迭代过程中必须具有稳定的形状和数据类型。

调用 ag_loop_vars(sentence) 给 tf_function() 编译器一个提示,即我们在 for 循环之后感兴趣的唯一变量是 sentence。这通知编译器其他张量,如 sampled_word、i 和 model_preds,都是循环局部变量,并且可以在循环后安全地优化掉。

请注意,在 tf_ 函数() 中迭代常规 R 对象(例如 for(i in seq(0, 49)))不会构建 tf$while_loop(),而是使用常规的 R 语义进行评估,并且会导致 tf_function() 追踪展开的循环(有时这是可取的,对于迭代次数固定的短循环)。

这是我们将在回调中调用 tf_generate_sentence() 来在训练期间生成文本的地方:

列表 12.7 文本生成回调

callback_text_generator <— new_callback_class(

classname = "TextGenerator",

initialize = function(prompt, generate_length,

temperatures = 1,

print_freq = 1L) {

private$prompt <— as_tensor(prompt, "string")

private$generate_length <— as_tensor(generate_length, "int32")

private$temperatures <— as.numeric(temperatures)➊

private$print_freq <— as.integer(print_freq)

},

on_epoch_end = function(epoch, logs = NULL) {

if ((epoch %% private$print_freq) != 0)

return()

for (temperature in private$temperatures) {➋

cat("== 使用温度生成", temperature, "\n")

sentence <— tf_generate_sentence(➌

self$model,

private$prompt,

private$generate_length,➍

as_tensor(temperature, "float32")

)

cat(as.character(sentence), "\n")

}

}

)

text_gen_callback <— callback_text_generator(

prompt = "这部电影",

generate_length = 50,

temperatures = c(0.2, 0.5, 0.7, 1., 1.5) ➎

)

我们将使用各种不同温度来对文本进行抽样,以演示温度对文本生成的影响。

这是一个常规的 R 循环,急切地迭代 R 向量。

请注意,我们仅使用张量和模型调用此函数,而不是 R 数值或字符向量。

这些在 initialize() 中已经被转换为张量了。

我们生成文本的温度集合

让我们拟合() 这个东西。

清单 12.8 拟合语言模型

model %>%

fit(lm_dataset,

epochs = 200,

callbacks = list(text_gen_callback))

这里有一些经过精心挑选的例子,展示了我们在进行了 200 个 epochs 的训练后能够生成的内容。请注意,标点符号不是我们词汇表的一部分,因此我们生成的文本中没有任何标点符号:

  • 使用温度=0.2

    • “这部电影是原电影的[UNK],前半个小时的电影相当不错,但它是一部非常好的电影,对于那个时期来说是一部好电影”

    • “这部电影是电影的[UNK],它是一部非常糟糕的电影,它是一部非常糟糕的电影,它使你笑和哭同时进行,这不是一部电影,我不认为我曾经看过”

  • 使用温度=0.5

    • “这部电影是有史以来最好的流派电影的[UNK],它不是一部好电影,它是关于这部电影的唯一好事,我第一次看到它,我仍然记得它是一部[UNK]电影,我看了很多年”

    • “这部电影是浪费时间和金钱的,我不得不说这部电影完全是浪费时间的,我很惊讶地发现这部电影由一部好电影组成,而且这部电影并不是很好,但它是浪费时间的”

  • 使用温度=0.7

    • “这部电影很有趣,看起来真的很有趣,所有的角色都非常滑稽,而且猫也有点像一个[UNK][UNK]和一顶帽子[UNK]电影的规则可以在另一个场景中被告知,这使得它不会被放在后面”

    • “这部电影是关于[UNK]和一对年轻人在无人之境的小船上的,一个人可能会发现自己暴露于[UNK]的牙医,他们被[UNK]杀死了,我是这本书的超级粉丝,我还没看过原版,所以”

  • 使用温度=1.0

    • “这部电影很有趣,看起来很有趣,所有角色都非常滑稽,而且猫也有点像一个[UNK][UNK]和一顶帽子[UNK]电影的规则可以在另一个场景中被告知,这使得它不会被放在后面”

    • “这部电影是一个杰作,远离了故事情节,但这部电影简直是令人兴奋和沮丧的,它真的很让朋友们开心,像这样的演员们试图直接从地下走向地下,他们把它变成了一个真正的好电视节目”

  • 使用温度=1.5

    • “这部电影可能是关于那 80 个女人中最糟糕的一部电影,它就像是个古怪而有洞察力的演员,例如巴克尔电影,但是在大伙伴圈子里是个伟大的家伙。是的,不装饰有盾,甚至还有[UNK]的土地公园恐龙拉尔夫伊恩必须演一出戏发生故事之后被选派(混合[UNK]巴赫)实际上并不存在。”

    • “这部电影可能是卢卡斯本人为我们国家带来的令人难以置信的有趣事情,而这些事情既夸张又严肃,演员们的表演精彩激烈,柯林的写作更加详细,但在这之前,还有那些燃烧的爱国主义画面,我们预期到你对职责的忠诚以及另一件事情的做法。”

如你所见,低温度值会导致非常乏味和重复的文本,并且有时会导致生成过程陷入循环。随着温度的升高,生成的文本变得更有趣,更令人惊讶,甚至更有创意。当温度变得非常高时,局部结构开始崩溃,输出看起来基本上是随机的。在这里,一个好的生成温度似乎是 0.7 左右。始终使用多个采样策略进行实验!通过学习结构和随机性之间的巧妙平衡,才能使生成变得有趣。

请注意,通过训练更大的模型,使用更多的数据,您可以获得生成样本,其凝聚度和真实性要比本例中的样本高得多——像 GPT-3 这样的模型输出是语言模型可以实现的例证(GPT-3 实际上与我们在此示例中训练的模型相同,但具有深层的 Transformer 解码器堆栈和更大的训练语料库)。但是,除非通过随机偶然和您自己的解释魔力,否则不要期望能够生成任何有意义的文本:您所做的只是从统计模型中采样出来的数据,其中包含哪些词汇跟随哪些词汇的信息。语言模型只是形式而无实质。

自然语言是很多事物:一种沟通渠道;一种在世界上行动的方式;一种社交润滑剂;一种构思、存储和提取自己思想的方式。这些语言使用是它的意义所来源之处。深度学习的“语言模型”,尽管它的名称如此,实际上捕捉到的是语言的这些基本方面。它不能进行交流(因为它没有任何东西可以交流的,也没有人可以交流),它不能在世界上发挥作用(因为它没有能力和目的),它不会社交,并且它没有任何需要通过单词进行处理的思想。语言是心灵的操作系统,所以,为了语言具有意义,它需要一个心灵来利用它。

语言模型的作用是捕获可观察到的工件的统计结构——书籍、在线电影评论、推文——我们在使用语言生活时生成的工件。这些工件具有统计结构的事实是人类实现语言的副作用。想象一下:如果我们的语言能更好地压缩通信,就像计算机对大多数数字通信所做的那样,会怎么样?语言不会失去任何意义,仍然可以完成其许多目的,但它将缺乏任何固有的统计结构,因此无法像你刚刚所做的那样进行建模。

12.1.6 总结

  • 通过训练模型预测给定前一个令牌的下一个令牌(s),可以生成离散序列数据。

  • 在文本的情况下,这样的模型称为语言模型。它可以基于单词或字符。

  • 对下一个令牌进行采样需要在遵循模型判断可能性和引入随机性之间保持平衡。

  • 处理这种情况的一种方法是 softmax 温度的概念。始终尝试不同的温度以找到合适的温度。

12.2 DeepDream

DeepDream 是一种艺术图像修改技术,它使用了卷积神经网络学习到的表示。它最初由谷歌在 2015 年夏季发布,使用了 Caffe 深度学习库来实现(这是在 TensorFlow 的首次公开发布几个月前)。³ 它很快就因为能够生成迷幻图片而成为互联网的轰动,这些图片充满了算法幻觉的工件、鸟类羽毛和狗眼——这是因为 DeepDream convnet 是在 ImageNet 上训练的,那里狗品种和鸟类物种的数量远远超过其他物种。

图片

图 12.4 DeepDream 输出图像示例

DeepDream 算法与第九章介绍的卷积神经网络滤波器可视化技术几乎相同,包括对卷积神经网络的输入进行反向运行:对卷积神经网络中的特定滤波器的激活进行梯度上升。DeepDream 使用了相同的思想,只是有一些简单的区别:

  • 通过 DeepDream,你试图最大化整个层的激活,而不是特定滤波器的激活,因此同时混合了大量特征的可视化。

  • 不是从空白的、略带噪音的输入开始,而是从一个现有的图像开始,因此产生的效果会附着在预先存在的视觉模式上,以某种艺术风格扭曲图像的元素。

  • 输入图像在不同尺度(称为octaves)上处理,这提高了可视化的质量。

让我们制作一些 DeepDreams。

12.2.1 在 Keras 中实现 DeepDream

让我们从获取一张用于梦想的测试图像开始。我们将使用冬季时节的加利福尼亚北部崎岖的海岸景色(图 12.5)。

清单 12.9 获取测试图像

base_image_path <— get_file(

"coast.jpg", origin = "https://img-datasets.s3.amazonaws.com/coast.jpg")

plot(as.raster(jpeg::readJPEG(base_image_path)))

图像

图 12.15 我们的测试图像

接下来,我们需要一个预训练的卷积网络。在 Keras 中,有许多这样的卷积网络可用 — VGG16、VGG19、Xception、ResNet50 等等 — 它们的权重都是在 ImageNet 上预训练的。你可以用任何一个实现 DeepDream,但你选择的基础模型自然会影响你的可视化效果,因为不同的架构会导致不同的学习特征。原始 DeepDream 发布中使用的卷积网络是一个 Inception 模型,在实践中,Inception 被认为能产生外观良好的 DeepDream,所以我们将使用 Keras 提供的 Inception V3 模型。

清单 12.10 实例化预训练的 InceptionV3 模型

model <— application_inception_v3(weights = "imagenet", include_top = FALSE)

我们将使用我们的预训练卷积网络创建一个特征提取器模型,该模型返回各个中间层的激活值,如下面的代码所列。对于每一层,我们选择一个标量分数,以加权各层对我们将在梯度上升过程中寻求最大化的损失的贡献。如果你想要一个完整的层名称列表,以便挑选新的层来尝试,只需使用 print(model)。

清单 12.11 配置每层对 DeepDream 损失的贡献

layer_settings <— c( ➊

mixed4 = 1,

mixed5 = 1.5,

mixed6 = 2,

mixed7 = 2.5

)

输出 <— list()

for(layer_name in names(layer_settings))

outputs[[layer_name]] <—➋

get_layer(model, layer_name)$output➋

feature_extractor <— keras_model(inputs = model$inputs,➌

输出 = 输出)

我们试图最大化激活的层,以及它们在总损失中的权重。你可以调整这些设置以获得新的视觉效果。

在一个命名列表中收集每个层的输出符号张量。

一个模型,返回每个目标层的激活值(作为命名列表)

接下来,我们将计算损失:我们将在每个处理尺度的梯度上升过程中寻求最大化的数量。在第九章中,对于滤波器可视化,我们试图最大化特定层中特定滤波器的值。在这里,我们将同时最大化一组高级层中所有滤波器的激活。具体来说,我们将最大化一组高级层激活的 L2 范数的加权平均值。我们选择的确切层集合(以及它们对最终损失的贡献)对我们能够产生的视觉效果有很大影响,因此我们希望使这些参数易于配置。较低的层会产生几何图案,而较高的层会产生可以在 ImageNet 中识别一些类别的视觉效果(例如,鸟类或狗)。我们将从一个相对任意的配置开始,涉及四个层,但您肯定会希望以后尝试许多不同的配置。

列表 12.12 DeepDream 损失

compute_loss <— function(input_image) {

features <— feature_extractor(input_image)

feature_losses <— names(features) %>%

lapply(function(name) {

coeff <— layer_settings[[name]]

activation <— features[[name]]➊

coeff * mean(activation[, 3:-3, 3:-3, ] ^ 2)➋

})

Reduce(+, feature_losses)➌

}

提取激活。

我们通过仅涉及非边界像素来避免边界伪影。

feature_losses 是一组标量张量。总结每个特征的损失。

现在让我们设置梯度上升过程,我们将在每个八度运行。您会发现它与第九章中的滤波器可视化技术是相同的!DeepDream 算法只是滤波器可视化的多尺度形式。

列表 12.13 DeepDream 梯度上升过程

gradient_ascent_step <— tf_function(➊

function(image, learning_rate) {

with(tf$GradientTape() %as% tape, {

tape$watch(image)

loss <— compute_loss(image)➋

})

grads <— tape$gradient(loss, image) %>%

tf\(math\)l2_normalize()➌

image %<>% +(learning_rate * grads)➍

list(loss, image)

})

gradient_ascent_loop <—➎

function(image, iterations, learning_rate, max_loss = -Inf) {

learning_rate %<>% as_tensor()

for(i in seq(iterations)) {➏

c(loss, image) %<—% gradient_ascent_step(image, learning_rate)

loss %<>% as.numeric()

if(loss > max_loss)➐

break

writeLines(sprintf(

"… 在第 %i 步的损失值为 %.2f", i, loss))

}

image

}

我们通过将其编译为 tf_function() 来快速进行训练步骤。

计算 DeepDream 损失相对于当前图像的梯度。

归一化梯度(我们在第九章中使用的相同技巧)。

重复更新图像,以增加 DeepDream 损失。

这将为给定的图像尺度(八度)运行梯度上升。

这是一个常规的 eager R 循环。

如果损失超过某个阈值,则退出(过度优化会产生不需要的图像伪影)。

最后,DeepDream 算法的外部循环。首先,我们将定义一个 尺度 列表(也称为 八度音阶),用于处理图像。我们将在三个不同的八度音阶上处理我们的图像。对于每个连续的八度音阶,从最小到最大,我们将通过 gradient_ascent_loop() 运行 20 步梯度上升,以最大化我们之前定义的损失。在每个八度音阶之间,我们将通过 40%(1.4×)放大图像:我们将从处理小图像开始,然后逐渐放大它(参见图 12.6)。

图像

图 12.6 DeepDream 过程:连续尺度的空间处理(八度音阶)和在放大时重新注入细节

我们在下面的代码中定义了这个过程的参数。调整这些参数将使您能够实现新的效果!

步骤 <— 20➊

八度音阶数量 <— 3➋

八度音阶比例 <— 1.4➌

迭代次数 <— 30➍

最大损失 <— 15➎

梯度上升步长

在哪些尺度上运行梯度上升的数量

连续尺度之间的大小比例

每个尺度的梯度上升步骤数

如果损失超过这个值,我们将停止梯度上升过程的该尺度。

我们还需要几个实用函数来加载和保存图像。

清单 12.14 图像处理实用程序

预处理图片 <— tf_function(function(image_path) {➊

图像路径 %>%

tf\(io\)read_file() %>%

tf\(io\)decode_image() %>%

tf$expand_dims(axis = 0L) %>%➋

tf$cast("float32") %>%➌

inception_v3_preprocess_input()

})

反向处理图片 <— tf_function(function(img) {➍

img %>%

tf$squeeze(axis = 0L) %>%➎

{ (。* 127.5)+ 127.5 } %>%➏

tf$saturate_cast("uint8")➐

})

显示图片张量 <— function(x, …, max = 255,

绘制边距 = c(0, 0, 0, 0)) {

如果(!is.null(plot_margins))

withr::local_par(mar = plot_margins)➑

x %>%

转换为数组() %>%➒

drop() %>%

转换为栅格(max = max) %>%➓

绘制(…,不插值 = FALSE)

}

加载、调整大小和格式化图片到适当数组的实用函数

添加批处理轴,相当于 .[tf$newaxis, all_dims()]。轴参数基于 0。

从 'uint8' 转换。

将张量数组转换为有效图像并撤消预处理的实用函数

删除第一个维度-批处理轴(必须为大小 1),tf$expand_dims() 的逆操作。

重新缩放,使值在[-1, 1]范围内重新映射到[0, 255]。

saturate_case() 将值剪辑到 dtype 范围:[0, 255]。

在绘制图像时默认没有边距。

将张量转换为 R 数组。

转换为 R 原生栅格格式。

withr::local_*

在这里,我们使用 withr::local_par() 来设置 par(),然后调用 plot()。local_ par() 的作用就像 par(),只是在函数退出时它会恢复先前的 par() 设置。使用像 local_par() 或 local_options() 这样的函数有助于确保您编写的函数不会永久修改全局状态,这使得它们在更多的上下文中更可预测和可用。

您可以用一个单独的 on.exit() 调用来替换 local_par() 并执行相同的操作,如下所示:

display_image_tensor <— function()

<…>

opar <— par(mar = plot_margins)

on.exit(par(opar))

<…>

}

这是外循环。为了避免在每次连续放大后丢失大量图像细节(导致图像越来越模糊或像素化),我们可以使用一个简单的技巧:在每次放大后,我们将丢失的细节重新注入到图像中,这是可能的,因为我们知道原始图像在较大比例时应该是什么样子的。给定一个小图像尺寸 S 和一个较大的图像尺寸 L,我们可以计算原始图像调整为尺寸 L 和尺寸 S 时的差异——这个差异量化了从 SL 过程中丢失的细节。

清单 12.15 在多个连续的八度上运行梯度上升

original_img <— preprocess_image(base_image_path)➊

original_HxW <— dim(original_img)[2:3]

calc_octave_HxW <— function(octave) {

as.integer(round(original_HxW / (octave_scale ^ octave)))

}

octaves <— seq(num_octaves - 1, 0) %>%➋

{ zip_lists(num = .,

HxW = lapply(., calc_octave_HxW)) }

str(octaves)

List of 3

$ :List of 2

..$ num: int 2

..$ HxW: int [1:2] 459 612

$ :List of 2

..$ num: int 1

..$ HxW: int [1:2] 643 857

$ :List of 2

..$ num: int 0

..$ HxW: int [1:2] 900 1200

加载并预处理测试图像。

计算不同八度图像的目标形状。

shrunk_original_img <— original_img %>% tf\(image\)resize(octaves[[1]]$HxW)

img <— original_img ➊

for (octave in octaves) {➋

cat(sprintf("Processing octave %i with shape (%s)\n",

octave\(num, paste(octave\)HxW, collapse = ", ")))

img <— img %>%

tf\(image\)resize(octave$HxW) %>%➌

gradient_ascent_loop(iterations = iterations, ➍

learning_rate = step,

max_loss = max_loss)

upscaled_shrunk_original_img <— ➎

shrunk_original_img %>% tf\(image\)resize(octave$HxW)

same_size_original <—

original_img %>% tf\(image\)resize(octave$HxW)➏

lost_detail <—➐

same_size_original - upscaled_shrunk_original_img

img %<>% +(lost_detail)➑

shrunk_original_img <—

original_img %>% tf\(image\)resize(octave$HxW)

}

img <— deprocess_image(img)

img %>% display_image_tensor()

img %>%

tf\(io\)encode_png() %>%

tf\(io\)write_file("dream.png", .)➒

保存对原始图像的引用(我们需要保留原始图像)。

迭代不同的八度。

将梦想图像放大。

运行梯度上升,改变梦想。

将原始图像的较小版本放大:它会出现像素化。

计算此尺寸下原始图像的高质量版本。

两者之间的区别是在放大时丢失的细节。

重新注入丢失的细节到梦想中。

保存最终结果。

因为原始的 Inception V3 网络是在大小为 299 × 299 的图像上训练的,并且考虑到该过程涉及将图像按合理因子缩小,因此 DeepDream 实现对大小介于 300 × 300 到 400 × 400 之间的图像产生更好的结果。不管怎样,您都可以在任何大小和比例的图像上运行相同的代码。

在 GPU 上,整个过程只需几秒钟。图 12.7 展示了我们在测试图像上的梦幻配置的结果。

图片

图 12.7 在测试图像上运行 DeepDream 代码

我强烈建议您通过调整使用的损失中的哪些层来探索可以做什么。网络中较低的层包含更本地化、不太抽象的表示,并导致看起来更几何化的梦幻图案。位于较高位置的层会导致更具可识别性的视觉模式,基于 ImageNet 中发现的最常见的对象,例如狗的眼睛、鸟的羽毛等。您可以使用 layer_settings 向量中参数的随机生成快速探索许多不同的层组合。图 12.8 展示了在使用不同层配置的图像上获得的一系列结果。

12.2.2 总结

  • DeepDream 包括运行卷积网络的逆向过程,以根据网络学到的表示生成输入。

  • 生成的结果有趣且在某种程度上类似于通过致幻剂扰乱视觉皮层而在人类中引发的视觉现象。

  • 请注意,这个过程不限于图像模型,甚至不限于卷积网络。它可以用于语音、音乐等。

图片

图 12.8 在示例图像上尝试一系列 DeepDream 配置

12.3 神经风格迁移

除了 DeepDream,深度学习驱动的图像修改的另一个重要发展是神经风格迁移,由 Leon Gatys 等人在 2015 年夏天引入。⁴ 神经风格迁移算法自原始引入以来经历了许多改进,并产生了许多变体,并且已经应用到许多智能手机照片应用中。为简单起见,本节重点介绍了原始论文中描述的公式。

神经风格迁移包括将参考图像的风格应用到目标图像上,同时保留目标图像的内容。图 12.9 展示了一个例子。

图片

图 12.9 一个风格迁移的例子

在这个语境中,风格 实质上意味着图像中的纹理、颜色和视觉模式,以及不同空间尺度上的内容,而内容则是图像的更高级宏观结构。例如,图 12.9(使用星夜的文森特·梵高)中的蓝色和黄色圆形笔触被认为是风格,而图宾根照片中的建筑被认为是内容。

与纹理生成紧密相关的风格迁移的概念,在 2015 年神经风格迁移的发展之前,在图像处理社区中已经有了很长的历史。但事实证明,基于深度学习的风格迁移实现提供了无与伦比的结果,远远超过了以前通过经典计算机视觉技术所取得的成就,并引发了计算机视觉创意应用的惊人复兴。

实现风格迁移的关键概念与所有深度学习算法的核心思想相同:你定义一个损失函数来指定你想要实现的目标,并最小化这个损失。我们知道我们想要实现什么:保留原始图像的内容同时采用参考图像的风格。如果我们能够在数学上定义内容风格,那么一个适当的最小化损失函数将是以下内容:

loss <— 距离(style(reference_image) - style(combination_image)) +

距离(content(original_image) - content(combination_image))

这里,distance() 是一个诸如 L2 范数的范数函数,content() 是一个接受图像并计算其内容表示的函数,style() 是一个接受图像并计算其风格表示的函数。最小化这个损失会导致 style(combination_image)接近 style(reference_image),而 content(combination_image)接近 content(original_image),从而实现我们定义的风格迁移。

Gatys 等人所做的一个基本观察是,深度卷积神经网络提供了一种数学定义风格和内容函数的方法。让我们看看如何做到这一点。

12.3.1 内容损失

正如你所了解的那样,网络中较早层的激活包含有关图像的局部信息,而较高层的激活包含越来越全局、抽象的信息。换句话说,卷积网络的不同层的激活提供了图像内容在不同空间尺度上的分解。因此,你会期望图像的内容,即更全局和抽象的部分,会被卷积网络中的上层表示所捕获。

因此,内容损失的一个很好的候选是预训练卷积网络中上层的激活之间的 L2 范数,计算在目标图像上,以及在生成的图像上计算相同层的激活。这确保了,从上层看,生成的图像看起来与原始目标图像相似。假设卷积网络的上层看到的确实是其输入图像的内容,那么这就作为一种保留图像内容的方法。

12.3.2 风格损失

内容损失仅使用单个上层,但由 Gatys 等人定义的风格损失使用卷积神经网络的多个层:您尝试捕获卷积神经网络提取的所有空间尺度的样式参考图像的外观,而不仅仅是一个单一尺度。对于风格损失,Gatys 等人使用一个层激活的Gram 矩阵:给定层的特征图的内积。这个内积可以理解为表示层特征之间的关联的地图。这些特征相关性捕获了特定空间尺度的模式的统计信息,这些模式在经验上对应于在这个尺度上发现的纹理的外观。

因此,风格损失旨在保留不同层次内部激活之间的相似内部关联,在样式参考图像和生成图像之间。反过来,这保证了在样式参考图像和生成图像之间看起来相似的不同空间尺度的纹理。

简而言之,您可以使用预训练的卷积神经网络来定义以下损失:

  • 通过保持原始图像和生成图像之间的类似高级层激活来保留内容。卷积神经网络应该“看到”原始图像和生成图像中包含相同的内容。

  • 通过保持低层和高层激活中的相似相关性来保留样式。特征相关性捕获纹理:生成图像和样式参考图像应该在不同的空间尺度上共享相同的纹理。

现在让我们看一下原始的 2015 年神经风格迁移算法的 Keras 实现。正如你将看到的那样,它与我们在前一节中开发的 DeepDream 实现有许多相似之处。

12.3.3 Keras 中的神经风格迁移

神经风格迁移可以使用任何预训练的卷积神经网络来实现。在这里,我们将使用 Gatys 等人使用的 VGG19 网络。VGG19 是 VGG16 网络的一个简单变体,引入了三个额外的卷积层。以下是一般过程:

  • 设置一个网络,同时计算样式参考图像、基础图像和生成图像的 VGG19 层激活。

  • 使用在这三个图像上计算的层激活来定义先前描述的损失函数,我们将最小化它以实现风格迁移。

  • 设置一个梯度下降过程来最小化这个损失函数。

让我们从定义样式参考图像和基础图像的路径开始。为了确保处理后的图像具有相似的大小(大小差异很大会使风格迁移更加困难),我们将稍后将它们全部调整为共享高度为 400 像素。

列表 12.16 获取风格和内容图像

base_image_path <— get_file(➊

"sf.jpg",origin = "https://img-datasets.s3.amazonaws.com/sf.jpg")

style_reference_image_path <— get_file(➋

"starry_night.jpg",

origin = "https://img-datasets.s3.amazonaws.com/starry_night.jpg")

c(original_height, original_width) %<—% {

base_image_path %>%

tf\(io\)read_file() %>%

tf\(io\)decode_image() %>%

dim() %>% .[1:2]

}

img_height <— 400➌

img_width <— round(img_height * (original_width / original_height))➌

我们想要转换的图像的路径

风格图片的路径

生成图片的尺寸

我们的内容图片显示在图 12.10,而图 12.11 显示了我们的风格图片。

图片

图 12.10 内容图片:旧金山诺布山

图片

图 12.11 风格图片:星夜,梵高

我们还需要一些用于加载、预处理和后处理进入和退出 VGG19 卷积网络的图像的辅助函数。

清单 12.17 辅助函数

preprocess_image <— function(image_path) {➊

image_path %>%

tf\(io\)read_file() %>%

tf\(io\)decode_image() %>%

tf\(image\)resize(as.integer(c(img_height, img_width))) %>%

k_expand_dims(axis = 1) %>%➋

imagenet_preprocess_input()

}

deprocess_image <— tf_function(function(img) {➌

if (length(dim(img)) == 4)

img <— k_squeeze(img, axis = 1)➍

c(b, g, r) %<—% {

img %>%

k_reshape(c(img_height, img_width, 3)) %>%

k_unstack(axis = 3)➎

}

r %<>% +(123.68)➏

g %<>% +(103.939)➏

b %<>% +(116.779)➏

k_stack(c(r, g, b), axis = 3) %>%➐

k_clip(0, 255) %>%

k_cast("uint8")

})

打开、调整大小和将图片格式化为适当的数组的实用函数

添加一个批次维度。

将张量转换为有效图像的实用函数

还接受批次维度大小为 1 的图像。(如果第一个轴不是大小为 1,则会引发错误。)

沿第三个轴拆分,并返回长度为 3 的列表。

通过从 ImageNet 中移除平均像素值来将图像零居中。这是对 imagenet_preprocess_input()执行的转换的逆操作。

请注意,我们正在颠倒通道的顺序,从 BGR 到 RGB。这也是 imagenet_preprocess_input()的反转的一部分。

Keras 后端函数(k_*)

在这个版本的 preprocess_image()和 deprocess_image()中,我们使用了 Keras 后端函数,比如 k_expand_dims(),但在早期版本中,我们使用了 tf 模块中的函数,比如 tf$expand_dims()。有什么区别呢?

Keras 包含一套广泛的后端函数,全部以 k_ 前缀开头。它们是 Keras 库设计为与多个后端一起使用时的遗留物。如今更常见的是直接调用 tf 模块中的函数,这些函数通常暴露更多的功能和能力。然而,keras::k_ 后端函数的一个好处是它们全部是基于 1 的,并且通常会根据需要自动将函数参数强制转换为整数。例如,k_expand_dims(axis = 1)等同于 tf$expand_dims(axis = 0L)。

后端函数不再积极开发,但它们受到 TensorFlow 稳定性承诺的保护,正在维护,并且不会很快消失。您可以放心使用函数如 k_expand_dims()、k_squeeze() 和 k_stack() 来执行常见的张量操作,特别是当使用一致的基于 1 的计数约定更容易推理时。但是,当您发现后端函数的功能有限时,请毫不犹豫地切换到直接使用 tf 模块函数。您可以在 keras.rstudio.com/articles/backend.html 找到有关后端函数的其他文档。

让我们设置 VGG19 网络。就像在 DeepDream 示例中一样,我们将使用预训练的卷积网络来创建一个特征提取器模型,该模型返回中间层的激活——这次是模型中的所有层。

清单 12.18 使用预训练的 VGG19 模型创建特征提取器

model <— application_vgg19(weights = "imagenet",

include_top = FALSE)➊

输出 <— 列表()

for (layer in model$layers)

outputs[[layer\(name]] <— layer\)output

feature_extractor <— keras_model(inputs = model$inputs,➋

outputs = outputs)

建立一个使用预训练的 ImageNet 权重的 VGG19 模型。

返回每个目标层的激活值的模型(作为命名列表)

让我们定义内容损失,它将确保 VGG19 卷积网络的顶层对风格图像和组合图像有相似的视图。

清单 12.19 内容损失

content_loss <— 函数(base_img, combination_img)

sum((combination_img - base_img) ^ 2)

接下来是风格损失。它使用一个辅助函数来计算输入矩阵的 Gram 矩阵:原始特征矩阵中找到的相关性的映射。

清单 12.20 风格损失

gram_matrix <— 函数(x) {➊

n_features <— tf$shape(x)[3]

x %>%

tf$reshape(c(-1L, n_features)) %>%➋

tf$matmul(t(.), .)➌

}

style_loss <— 函数(style_img, combination_img) {

S <— gram_matrix(style_img)

C <— gram_matrix(combination_img)

channels <— 3

size <— img_height * img_width

sum((S - C) ^ 2) /

(4 * (channels ^ 2) * (size ^ 2))

}

x 的形状为 (高度, 宽度, 特征)。

将前两个空间轴展平,并保留特征轴。

输出将具有形状 (n_features, n_features)。

对于这两个损失组件,您还添加了第三个:总变差损失,它作用于生成的组合图像的像素上。它鼓励生成图像中的空间连续性,从而避免过度像素化的结果。您可以将其解释为正则化损失。

清单 12.21 总变差损失

total_variation_loss <— 函数(x) {

a <— k_square(x[, NA:(img_height-1), NA:(img_width-1), ]—

x[, 2:NA             , NA:(img_width-1), ])

b <— k_square(x[, NA:(img_height-1), NA:(img_width-1), ]—

x[, NA:(img_height-1), 2:NA            , ])

sum((a + b) ^ 1.25)

}

你最小化的损失是这三种损失的加权平均值。为了计算内容损失,你只使用一个较高的层——block5_conv2 层——而对于风格损失,你使用一个跨越低层和高层的层列表。你在最后添加了总变差损失。

根据你使用的风格参考图像和内容图像,你可能希望调整 content_weight 系数(内容损失对总损失的贡献)。较高的 content_weight 意味着生成图像中的目标内容将更容易被识别。

清单 12.22 定义你将最小化的最终损失

style_layer_names <— c(➊

"block1_conv1",

"block2_conv1",

"block3_conv1",

"block4_conv1",

"block5_conv1"

)

content_layer_name <— "block5_conv2"➋

total_variation_weight <— 1e—6➌

content_weight <— 2.5e—8➍

style_weight <— 1e—6➎

计算损失 <

函数(组合图像, 基准图像, 风格参考图像) {

input_tensor <

列表(基准图像,

风格参考图像,

combination_image) %>%

k_concatenate(axis = 1)

特征 <— 特征提取器(input_tensor)

layer_features <— features[[content_layer_name]]

base_image_features <— layer_features[1, , , ]

combination_features <— layer_features[3, , , ]

损失 <— 0➏

损失 %<>% +(➐

内容损失(基准图像特征, 组合特征) *

content_weight

)

for (layer_name in style_layer_names) {

layer_features <— features[[layer_name]]

style_reference_features <— layer_features[2, , , ]

combination_features <— layer_features[3, , , ]

损失 %<>% +(➑

风格损失(风格参考特征, 组合特征) *

风格权重 / 长度(风格层名称)

)

}

损失 %<>% +(➒

总变差损失(组合图像) *

total_variation_weight

)

损失➓

}

用于风格损失的层列表

用于内容损失的层

总变差损失的贡献权重

内容损失的贡献权重

风格损失的贡献权重

将损失初始化为 0。

添加内容损失。

为每个风格层添加风格损失。

添加总变差损失。

返回内容损失、风格损失和总变差损失的总和。

最后,让我们设置梯度下降过程。在原始的 Gatys 等人的论文中,优化是使用 L-BFGS 算法进行的,但在 TensorFlow 中不可用,所以我们将使用 SGD 优化器进行小批量梯度下降。我们将利用一个你以前没有见过的优化器特性:学习率调度。我们将逐渐减小学习率,从一个非常高的值(100)到一个更小的最终值(约为 20)。这样,我们将在训练的早期阶段取得快速进展,然后在接近损失最小值时更加谨慎地进行。

清单 12.23 设置梯度下降过程

计算损失和梯度 <— tf_function(➊

函数(组合图像, 基准图像, 风格参考图像) {

with(tf$GradientTape() %as% tape, {

loss <— compute_loss(combination_image,

base_image,

style_reference_image)

})

grads <— tape$gradient(loss, combination_image)

list(loss, grads)

})

optimizer <— optimizer_sgd(

learning_rate_schedule_exponential_decay(

initial_learning_rate = 100, decay_steps = 100,➋

decay_rate = 0.96))➋

base_image <— preprocess_image(base_image_path)

style_reference_image <— preprocess_image(style_reference_image_path)

combination_image <

tf$Variable(preprocess_image(base_image_path))➌

output_dir <— fs::path("style-transfer-generated-images")

iterations <— 4000

for (i in seq(iterations)) {

c(loss, grads) %<—% compute_loss_and_grads(

combination_image, base_image, style_reference_image)

optimizer$apply_gradients(list(➍

tuple(grads, combination_image)))

if ((i %% 100) == 0) {

cat(sprintf("迭代第%i 次:损失 = %.2f\n", i, loss))

img <— deprocess_image(combination_image)

display_image_tensor(img)

fname <— sprintf("combination_image_at_iteration_%04i.png", i)

tf\(io\)write_file(filename = output_dir / fname,➎

contents = tf\(io\)encode_png(img))

}

}

我们通过将训练步骤编译为 tf_function()来加快训练速度。

我们将从学习率 100 开始,并在每 100 步时将其减少 4%。

使用 tf$Variable()存储组合图像,因为我们将在训练过程中更新它。

在减少样式转移损失方向上更新组合图像。

定期保存组合图像。

图 12.12 展示了您将获得的结果。请记住,这种技术实现的仅仅是一种图像重纹理或纹理转移的形式。它最适合具有强烈纹理和高自相似性的风格参考图像以及不需要高细节级别才能识别的内容目标。它通常无法实现诸如将一个肖像的风格转移到另一个肖像之类的相当抽象的能力。该算法更接近于经典信号处理而不是人工智能,因此不要期望它像魔术一样起作用!

Image

图 12.12 风格转移结果

此外,请注意,这种样式转移算法运行速度较慢。但是通过这种设置操作的变换足够简单,以至于它可以被一个小型、快速的前向卷积网络学习,只要您有适当的训练数据可用。因此,首先通过使用此处概述的方法花费大量计算周期为固定风格参考图像生成输入-输出训练示例,然后训练一个简单的卷积网络来学习这种特定于风格的转换,就能实现快速风格转移。一旦完成,给定图像的风格化就是瞬间完成:只需对此小型卷积网络进行正向传递。

12.3.4 结束

  • 样式转移包括创建一个新图像,保留目标图像的内容,同时捕捉参考图像的风格。

  • 内容可以通过卷积网络的高级激活来捕捉。

  • 风格可以通过卷积网络不同层的激活之间的内部相关性来捕捉。

  • 因此,深度学习使得风格迁移可以被形式化为使用预训练卷积网络定义的损失函数的优化过程。

  • 从这个基本思想出发,可以有很多变体和改进的可能性。

12.4 使用变分自编码器生成图像

当今创造性人工智能的最流行和最成功的应用就是图像生成:学习潜在的视觉空间,并从中进行采样,以从实际图像中插值出全新的图片,如虚构的人物、虚构的地方、虚构的猫和狗等等。

在本节和下一节中,我们将回顾与图像生成相关的一些高级概念,以及与这个领域中的两种主要技术相关的实现细节:变分自编码器(VAEs)和生成对抗网络(GANs)。请注意,我在这里介绍的技术并不局限于图像,你可以使用 GANs 和 VAEs 开发音频、音乐甚至文本的潜在空间,但实际上,最有趣的结果是在图像领域获得的,这也是我们在这里的重点。

12.4.1 从图像的潜在空间中采样

图像生成的关键思想是开发一个低维的潜在空间(就像深度学习中的其他所有东西一样,它是一个向量空间),任何点都可以映射到一个“有效”的图像:一个看起来像真实世界的图像。能够实现这种映射的模块,输入为潜在点,输出为图像(像素网格),被称为生成器(在 GANs 的情况下)或解码器(在 VAEs 的情况下)。一旦学习到这样一个潜在空间,你可以从中采样点,并通过将它们映射回图像空间,生成从未见过的图像(见 图 12.13)。这些新图像就是训练图像之间的中间过渡图像。

图片

图 12.13 学习图像的潜在向量空间并使用它来采样新图像

GANs 和 VAEs 是学习图像表示的潜在空间的两种不同策略,各自具有其特点。VAEs 适用于学习结构良好的潜在空间,其中特定方向对数据的变化有着有意义的编码(见 图 12.14)。GANs 生成的图像可能非常逼真,但它们所来自的潜在空间可能没有很强的结构性和连续性。

图片

图 12.14 使用 VAEs 生成的连续面部空间,由 Tom White 生成

12.4.2 图像编辑的概念向量

当我们讨论第十一章的词嵌入时,已经提及了概念向量的想法。思想仍然很简单:给定一个表示空间或嵌入空间的潜在空间,空间中的某些方向可能编码原始数据的有趣变化轴。例如,在面部图像的潜在空间中,可能存在一个微笑向量,使得如果潜在点 z 是某个面孔的嵌入表示,则潜在点 z + s 是相同面孔的嵌入表示,微笑着。一旦你识别出这样一个向量,就可以通过将图像投影到潜在空间中,以有意义的方式移动它们的表示,然后再将它们解码回图像空间进行编辑。对于图像空间中的任何独立变化维度,都存在概念向量,例如在面孔的情况下,通过训练 VAE(CelebA 数据集)的 Tom White 发现了添加太阳镜、摘掉眼镜、将男性面孔变成女性面孔等向量(见图 12.15)。

Image

图 12.15 微笑向量

12.4.3 变分自编码器

变分自编码器是由 Kingma 和 Welling 在 2013 年 12 月⁵ 以及 Rezende、Mohamed 和 Wierstra 在 2014 年 1 月⁶ 同时发现的一种生成模型,特别适用于通过概念向量进行图像编辑的任务。它们是对自编码器的现代化改进(自编码器是一种旨在将输入编码到低维潜在空间中,然后再进行解码的网络类型),将深度学习的思想与贝叶斯推理混合起来。

一个经典的图像自编码器会将图像通过编码器模块映射到潜在向量空间中,然后通过解码器模块将其解码回与原始图像具有相同维度的输出(见图 12.16)。然后,通过使用与输入图像相同的图像作为目标数据进行训练,意味着自编码器学习重建原始输入。通过对代码(编码器的输出)施加各种限制,可以让自编码器学习更多或更少有趣的数据的潜在表示。最常见的情况是限制代码为低维和稀疏(大多数为零),在这种情况下,编码器充当将输入数据压缩为更少信息位的方式。

Image

图 12.16 自编码器将输入x映射到压缩的表示,然后解码回x

在实践中,这样的经典自编码器并不会导致特别有用或结构良好的潜在空间。它们在压缩方面也不太好。因此,出于这些原因,它们在很大程度上已经过时了。而 VAE 则通过一些统计魔法来增强自编码器,迫使它们学习连续、高度结构化的潜在空间。它们已经被证明是图像生成的强大工具。

VAE 不是将其输入图像压缩成潜在空间中的固定代码,而是将图像转换为统计分布的参数:均值和方差。基本上,这意味着我们假设输入图像是由一个统计过程生成的,并且在编码和解码过程中应考虑到这个过程的随机性。然后,VAE 使用均值和方差参数随机采样分布的一个元素,并将该元素解码回原始输入(见图 12.17)。这个过程的随机性提高了鲁棒性,并迫使潜在空间在任何地方都编码有意义的表示:在潜在空间中采样的每一点都被解码为有效的输出。

图像

图 12.17 VAE 将图像映射到两个向量 z_mean 和 z_log_sigma,它们定义了潜在空间上的概率分布,用于对潜在点进行采样以解码。

从技术角度来看,VAE 的工作原理如下:

  1. 1 编码器模块将输入样本 input_img 转换为表示的潜在空间中的两个参数,z_mean 和 z_log_variance。

  2. 2 你随机从假设生成输入图像的潜在正态分布中采样一个点 z,通过 z = z_mean + exp(z_log_variance) * epsilon,其中 epsilon 是一个小值的随机张量。

  3. 3 解码器模块将潜在空间中的这一点映射回原始输入图像。

由于 epsilon 是随机的,这个过程确保了每一个接近编码输入图像(z_mean)的潜在位置的点都可以被解码为与输入图像类似的东西,从而迫使潜在空间连续有意义。

潜在空间中的任意两个接近点将解码为高度相似的图像。连续性,加上潜在空间的低维度,迫使潜在空间中的每个方向编码数据的有意义变化轴,使得潜在空间非常结构化,因此非常适合通过概念向量进行操作。

VAE 的参数通过两个损失函数进行训练:一个 重构损失,强制解码样本与初始输入匹配,一个 正则化损失,有助于学习良好的潜在分布并减少对训练数据的过度拟合。从图示上看,这个过程如下:

c(z_mean, z_log_variance) %<—% encoder(input_img)➊

z <— z_mean + exp(z_log_variance) * epsilon➋

reconstructed_img <— decoder(z) ➌

model <— keras_model(input_img, reconstructed_img)➍

将输入编码为均值和方差参数。

使用一个小的随机 epsilon 画一个潜在点。

将 z 解码回图像。

实例化自动编码器模型,将输入图像映射到其重构。

然后,您可以使用重构损失和正则化损失来训练模型。对于正则化损失,我们通常使用一个表达式(Kullback-Leibler 散度),旨在将编码器输出的分布推向以 0 为中心的一个良好的正常分布。这为编码器提供了对所建模的潜在空间结构的合理假设。

现在让我们看看在实践中实现 VAE 是什么样子的!

12.4.4 使用 Keras 实现 VAE

我们将要实现一个可以生成 MNIST 数字的 VAE。它将有三个部分:

  • 将实际图像转换为潜在空间中的均值和方差的编码器网络

  • 一个采样层,它接受这样的均值和方差,并使用它们从潜在空间中随机采样一个点

  • 将点从潜在空间转换回图像的解码器网络

下面的清单显示了我们将使用的编码器网络,将图像映射到潜在空间上的概率分布的参数。它是一个简单的 convnet,将输入图像 x 映射到两个向量,z_mean 和 z_log_var。一个重要的细节是,我们使用步幅来对特征图进行下采样,而不是使用最大池化。上次我们这样做是在第九章的图像分割示例中。回想一下,一般来说,对于任何关心信息位置的模型来说,步幅优于最大池化——也就是说,在图像中的位置,这个模型是关心的,因为它将不得不产生一个可以用来重构有效图像的图像编码。

清单 12.24 VAE 编码器网络

latent_dim <— 2➊

encoder_inputs <—  layer_input(shape = c(28, 28, 1))

x <— encoder_inputs %>%

layer_conv_2d(32, 3, activation = "relu", strides = 2, padding = "same") %>%

layer_conv_2d(64, 3, activation = "relu", strides = 2, padding = "same") %>%

layer_flatten() %>%

layer_dense(16, activation = "relu")

z_mean <— x %>% layer_dense(latent_dim, name = "z_mean")➋

z_log_var <— x %>% layer_dense(latent_dim, name = "z_log_var")➋

encoder <— keras_model(encoder_inputs, list(z_mean, z_log_var),

name = "encoder")

潜在空间的维数:一个二维平面

输入图像最终被编码为这两个参数。

其摘要如下所示:

Image

接下来是使用 z_mean 和 z_log_var 的代码,假定这些参数是产生 input_img 的统计分布的参数,以生成潜在空间点 z。

清单 12.25 潜在空间采样层

layer_sampler <— new_layer_class(

classname = "Sampler",

call = function(z_mean, z_log_var) {➊

epsilon <— tf\(random\)normal(shape = tf$shape(z_mean))➋

z_mean + exp(0.5 * z_log_var) * epsilon➌

}

}

这里的 z_mean 和 z_log_var 都将具有形状 (batch_size, latent_dim),例如 (128, 2)。

生成与编码器 Flatten 层级别相同数量的随机正态向量。

应用 VAE 采样公式。

下面的清单显示了解码器的实现。我们将向量 z 重塑为图像的维度,然后使用几个卷积层获得最终图像输出,其尺寸与原始输入图像相同。

清单 12.26 VAE 解码器网络,将潜空间点映射到图像

latent_inputs <— layer_input(shape = c(latent_dim))➊

decoder_outputs <— latent_inputs %>%

layer_dense(7 * 7 * 64, activation = "relu") %>% ➋

layer_reshape(c(7, 7, 64)) %>%➌

layer_conv_2d_transpose(64, 3, activation = "relu",

strides = 2, padding = "same") %>% ➍

layer_conv_2d_transpose(32, 3, activation = "relu",

strides = 2, padding = "same") %>%

layer_conv_2d(1, 3, activation = "sigmoid",

padding = "same") ➎

decoder <— keras_model(latent_inputs, decoder_outputs,

name = "decoder")

输入我们将 feed z 的地方

在这里产生的系数的数量与编码器的 Flatten 层级别上相同。

还原 encoder 的 layer_flatten()。

还原 encoder 的 layer_conv_2d()。

输出的形状为 (28, 28, 1)。

它的摘要如下:

decoder

图片

现在让我们创建 VAE 模型本身。这是你第一个不执行监督学习的模型示例(自动编码器是 自监督 学习的示例,因为它使用其输入作为目标)。当你脱离经典的监督学习时,通常会创建一个 new_model_class() 并实现一个自定义的 train_step() 来指定新的训练逻辑,这是你在第七章学到的工作流程。这就是我们在这里要做的。

清单 12.27 自定义 train_step() 的 VAE 模型

model_vae <— new_model_class(

classname = "VAE",

initialize = function(encoder, decoder, …) {

super$initialize(…)

self$encoder <— encoder➊

self$decoder <— decoder

self$sampler <— layer_sampler()

self$total_loss_tracker <

metric_mean(name = "total_loss")➋

self$reconstruction_loss_tracker <➋

metric_mean(name = "reconstruction_loss")➋

self$kl_loss_tracker <➋

metric_mean(name = "kl_loss")➋

},

metrics = mark_active(function() {➌

list(

self$total_loss_tracker,

self$reconstruction_loss_tracker,

self$kl_loss_tracker

)

}),

train_step = function(data) {

with(tf$GradientTape() %as% tape, {

c(z_mean, z_log_var) %<—% self$encoder(data)

z <— self$sampler(z_mean, z_log_var)

reconstruction <— decoder(z)

reconstruction_loss <➍

loss_binary_crossentropy(data, reconstruction) %>%

sum(axis = c(2, 3)) %>% ➎

mean()➏

kl_loss <— -0.5 * (1 + z_log_var - z_mean² - exp(z_log_var))

total_loss <— reconstruction_loss + mean(kl_loss)➐

})

grads <— tape\(gradient(total_loss, self\)trainable_weights)

self\(optimizer\)apply_gradients(zip_lists(grads, self$trainable_weights))

self\(total_loss_tracker\)update_state(total_loss)

self\(reconstruction_loss_tracker\)update_state(reconstruction_loss)

self\(kl_loss_tracker\)update_state(kl_loss)

list(total_loss = self\(total_loss_tracker\)result(),

reconstruction_loss = self\(reconstruction_loss_tracker\)result(),

kl_loss = self\(kl_loss_tracker\)result())

}

}

我们将赋值给 self 而不是 private,因为我们希望层权重由 Keras Model 基类自动跟踪。

我们使用这些指标来跟踪每个周期内的损失平均值。

我们将指标列在 active 属性中,以便在每个周期(或在多次调用 fit()/evaluate()之间)重置它们。

我们对空间维度(第二和第三轴)上的重建损失求和,并在批次维度上取其平均值。

批次中每个案例的总损失;保留批次轴。

取批次中损失总和的平均值。

添加正则化项(Kullback–Leibler 散度)。

最后,我们准备在 MNIST 数字上实例化并训练模型。因为损失在自定义层中已经处理,所以我们在编译时不指定外部损失(loss = NULL),这意味着在训练期间我们不会传递目标数据(正如您所见,我们在 fit()中仅向模型传递 x_train)。

图 12.28 训练 VAE

library(listarrays)➊

c(c(x_train, .), c(x_test, .)) %<—% dataset_mnist()

mnist_digits <

bind_on_rows(x_train, x_test) %>%➋

expand_dims(-1) %>%

{ . / 255 }

str(mnist_digits)

num [1:70000, 1:28, 1:28, 1] 0 0 0 0 0 0 0 0 0 0 …

vae <— model_vae(encoder, decoder)

vae %>% compile(optimizer = optimizer_adam())➌

vae %>% fit(mnist_digits, epochs = 30, batch_size = 128)➍

提供 bind_on_rows()和其他用于操作 R 数组的函数。

我们对所有 MNIST 数字进行训练,因此我们沿批次维度合并训练和测试样本。

请注意,我们在 compile()中不传递损失参数,因为损失已经是 train_step()的一部分。

请注意,我们在 fit()中不传递目标,因为 train_step()不需要任何目标。

一旦模型训练完成,我们就可以使用解码器网络将任意潜空间向量转换为图像。

图 12.29 从二维潜空间中采样图像的示例

n <— 30

digit_size <— 28

z_grid <➊

seq(-1, 1, length.out = n) %>%

expand.grid(., .) %>%

as.matrix()

decoded <— predict(vae$decoder, z_grid)➋

z_grid_i <— seq(n) %>% expand.grid(x = ., y = .)➌

figure <— array(0, c(digit_size * n, digit_size * n))➍

for (i in 1:nrow(z_grid_i)) {

c(xi, yi) %<—% z_grid_i[i, ]

数字 <— 解码[i, , , ]

figure[seq(to = (n + 1 - xi) * digit_size, length.out = digit_size),

seq(to = yi * digit_size, length.out = digit_size)] <—

数字

}

par(pty = "s")➎

lim <— extendrange(r = c(-1, 1),

f = 1 - (n / (n+.5)))➐

plot(NULL, frame.plot = FALSE,

ylim = lim, xlim = lim,

xlab = ~z[1], ylab = ~z[2]) ➑

rasterImage(as.raster(1 - figure, max = 1),➑

lim[1], lim[1], lim[2], lim[2],

interpolate = FALSE)

创建一个线性间隔样本的二维网格。

获取解码后的数字。

将形状为(900,28,28,1)的解码数字转换为形状为(2830,2830)的 R 数组以进行绘图。

我们将显示一个 30×30 的数字网格(共 900 个数字)。

方形图类型

将限制值扩展到(-1,1),位于数字的中心。

将一个公式对象传递给 xlab 以获得适当的下标。

从 1 中减去以反转颜色。

从潜在空间抽样的数字网格(参见图 12.18)显示了不同数字类别的完全连续分布,随着您沿着潜在空间的路径前进,一个数字会变形成另一个数字。这个空间中的特定方向具有意义:例如,“五”的方向,“一”的方向等等。

图片

图 12.18 从潜在空间解码的数字网格

在下一节中,我们将详细介绍生成人工图像的另一个主要工具:生成对抗网络(GANs)。

12.4.5 总结

  • 使用深度学习进行图像生成是通过学习捕获关于图像数据集的统计信息的潜在空间来完成的。通过从潜在空间中抽样和解码点,您可以生成以前未曾见过的图像。有两种主要工具可以实现这一点:VAEs 和 GANs。

  • VAEs 会产生高度结构化、连续的潜在表示。因此,它们非常适合在潜在空间中进行各种图像编辑:人脸交换,将皱眉的脸变成微笑的脸等等。它们还非常适合进行基于潜在空间的动画,比如沿着潜在空间的横截面进行步行动画,或者以连续的方式显示起始图像逐渐变形为不同的图像。

  • GANs 可以生成逼真的单帧图像,但可能不会产生具有稳固结构和高连续性的潜在空间。

我见过的大多数成功应用案例都依赖于 VAEs,但是 GANs 在学术研究领域一直很受欢迎。在下一节中,您将了解它们的工作原理以及如何实现其中之一。

12.5 生成对抗网络简介

生成对抗网络(GANs)由 Goodfellow 等人于 2014 年提出,⁷ 是学习图像潜在空间的替代方法,与 VAEs 相比。它们通过强制生成的图像在统计上几乎无法与真实图像区分开来,从而使得生成的合成图像相当逼真。

理解 GAN 的一种直观方式是想象一位赝造者试图制作一幅假的毕加索画。起初,赝造者在这个任务上并不在行。他把一些假画和真正的毕加索画混在一起,然后把它们展示给一个艺术品经销商。艺术品经销商为每幅画作进行真伪鉴别,并给赝造者反馈,告诉他什么因素使一幅画看起来像毕加索的作品。赝造者回到自己的工作室准备一些新的伪品。随着时间的推移,赝造者在模仿毕加索的风格方面变得越来越能胜任,而经销商在识别伪品方面也变得越来越熟练。最终,他们手里有了一些极好的假毕加索作品。

这就是 GAN 的含义:一个规模化的轨迹生成和专家网络,每个都在训练过程中竭尽全力来胜过对方。因此,GAN 由两部分组成:

  • 生成器网络—以随机向量(潜在空间中的一个随机点)作为输入,并将其解码为合成图像

  • 判别器网络(或对手)—以图像(真实的或合成的)作为输入,并预测该图像是否来自训练集或由生成器网络生成

生成器网络得到训练,以欺骗判别器网络,并因此朝着生成越来越逼真的图像的方向发展:人工图像看起来与真实图像无法区分,以至于判别器网络无法将两者区分开来(见图 12.19)。同时,判别器网络不断适应生成器逐渐提升的能力,为生成的图像设定了很高的真实性标准。训练结束后,生成器可以将输入空间中的任意点转化为一幅可信的图像。与 VAE 不同,这个潜在空间没有明确保证具有有意义结构的特性;尤其是,它不是连续的。

图片

图 12.19 生成器将随机潜在向量转化为图像,判别器则试图区分真实图像和生成的图像。生成器的训练目标是欺骗判别器。

值得注意的是,GAN 是一个优化最小值不固定的系统,与本书中其他训练设置所遇到的情况不同。通常,梯度下降法是在静态损失空间中下坡前进的。但是使用 GAN 时,沿着坡下降的每一步都会稍微改变整个损失空间。这是一个动态系统,优化过程寻求的不是最小值,而是两个力之间的平衡状态。因此,GAN 中的训练非常困难,要使其正常工作需要对模型结构和训练参数进行大量细致的调整。

12.5.1 一个概要的 GAN 实现

在本节中,我们将解释如何以最简形式在 Keras 中实现 GAN。GAN 是先进的,因此深入探讨生成 figure 12.20 中的图像的 StyleGAN2 架构等技术细节超出了本书的范围。我们将在此演示中使用的具体实现是 深度卷积 GAN(DCGAN):一个非常基本的 GAN,其中生成器和判别器都是深度卷积网络。

我们将在大规模 CelebFaces 属性数据集(称为 CelebA)的图像上训练我们的 GAN,这是一个包含 20 万个名人面孔的数据集(mmlab.ie.cuhk.edu.hk/projects/CelebA.html)。为了加快训练速度,我们将图像调整为 64 × 64,因此我们将学习生成 64 × 64 的人脸图像。在图解上,GAN 如下所示:

图片

图 12.20 潜空间居民。由 thispersondoesnotexist.com 使用 StyleGAN2 模型生成的图像。(图片来源:Phillip Wang 是网站作者。使用的模型是 Karras 等人的 StyleGAN2 模型,arxiv.org/abs/1912.04958

  • 生成器网络将形状为(latent_dim)的向量映射到形状为(64,64,3)的图像。

  • 判别器网络将形状为(64,64,3)的图像映射到一个二进制分数,估计图像为真实的概率。

  • GAN 网络将生成器和判别器链接在一起:gan(x) = discriminator(generator(x))。因此,这个 GAN 网络将潜空间向量映射到判别器对这些潜空间向量解码后的真实性的评估。

  • 我们使用真实和假图像的示例以及“真实”/“假”的标签来训练判别器,就像我们训练任何常规图像分类模型一样。

  • 为了训练生成器,我们使用生成器权重相对于 gan 模型损失的梯度。这意味着在每一步,我们将生成器的权重移动到一个方向,使判别器更有可能将生成器解码的图像分类为“真实”。换句话说,我们训练生成器来欺骗判别器。

12.5.2 一袋技巧

训练 GAN 和调整 GAN 实现的过程非常困难。您应该记住一些已知的技巧。像深度学习中的大多数事情一样,这更像是炼金术而不是科学:这些技巧是启发式的,而不是理论支持的指南。

它们受到对手头现象的直觉理解的支持,并且已知在经验上工作良好,尽管不一定在每种情况下都是如此。

以下是本节中 GAN 生成器和判别器实现中使用的一些技巧。这不是一个详尽的 GAN 相关提示列表;您将在 GAN 文献中找到更多:

  • 在判别器中,我们使用步幅而不是池化来对特征图进行下采样,就像我们在 VAE 编码器中所做的那样。

  • 我们使用 正态分布(高斯分布)而不是均匀分布从潜在空间中采样点。

  • 随机性对于诱导鲁棒性是有益的。因为 GAN 训练导致动态平衡,GAN 很可能以各种方式陷入困境。在训练过程中引入随机性有助于防止这种情况发生。我们通过向判别器的标签添加随机噪声来引入随机性。

  • 稀疏梯度会阻碍 GAN 训练。在深度学习中,稀疏性通常是一种可取的特性,但在 GAN 中却不是。有两件事会导致梯度稀疏性:max pooling 操作和 relu 激活。我们建议使用步幅卷积进行下采样,而不是使用 max pooling,并且建议使用 layer_activation_leaky_relu() 而不是 relu 激活。它类似于 relu,但通过允许小的负激活值来放宽稀疏性约束。

  • 在生成的图像中,常见的是看到由于发生器中像素空间覆盖不均匀而引起的棋盘格伪影(参见图 12.21)。为了解决这个问题,我们在发生器和判别器中使用 layer_conv_2d_transpose()layer_conv_2d() 时,使用可被步幅大小整除的核大小。

Image

图 12.21 由于步幅和核大小不匹配导致的棋盘格伪影,导致像素空间覆盖不均匀:GAN 的众多注意事项之一

12.5.3 获取 CelebA 数据集

你可以从网站手动下载数据集:mmlab.ie.cuhk.edu.hk/projects/CelebA.html。因为数据集托管在 Google Drive 上,所以你也可以使用 gdown 下载:

reticulate::py_install("gdown", pip = TRUE)

system("gdown 1O7m1010EJjLE5QxLZiM9Fpjs7Oj6e684")

下载中…

来自:https://drive.google.com/uc?id=1O7m1010EJjLE5QxLZiM9Fpjs7Oj6e684

到:img_align_celeba.zip

32%|                      | 467M/1.44G [00:13<00:23, 41.3MB/s]

安装 gdown。

使用 gdown 下载压缩数据。

一旦你下载了数据,将其解压缩到一个名为 celeba_gan 的文件夹中

清单 12.30 获取 CelebA 数据

zip::unzip("img_align_celeba.zip", exdir = "celeba_gan")

解压数据。

一旦你把未压缩的图像放在一个目录中,你就可以使用 image_dataset_from_directory() 将其转换为 TF Dataset。因为我们只需要图像——没有标签——所以我们会指定 label_mode = NULL

清单 12.31 从图像目录创建 TF Dataset

dataset <- image_dataset_from_directory(

"celeba_gan",

label_mode = NULL,

image_size = c(64, 64),

batch_size = 32,

crop_to_aspect_ratio = TRUE

}

只返回图像,不返回标签。

我们将通过智能组合裁剪和调整大小将图像调整为 64 × 64,以保持长宽比。我们不希望面部比例被扭曲!

最后,让我们将图像重新调整到 [0-1] 范围内。

清单 12.32 重新调整图像大小

library(tfdatasets)

dataset %<>% dataset_map(~ .x / 255)

您可以使用以下代码显示示例图像。

清单 12.33 显示第一张图片

x <— 数据集 %>% as_iterator() %>% iter_next()

display_image_tensor(x[1, , , ], max = 1)

图片

12.5.4 鉴别器

首先,我们将开发一个鉴别器模型,该模型将候选图像(真实或合成的)作为输入,并将其分类为两类之一:“生成的图像”或“来自训练集的真实图像”。与 GAN 常见的许多问题之一是生成器陷入生成看起来像噪声的图像的困境。一个可能的解决方案是在鉴别器中使用 dropout,这就是我们将在这里做的。

清单 12.34 GAN 鉴别器网络

鉴别器 <-

keras_model_sequential(name = "鉴别器",

input_shape = c(64, 64, 3)) %>%

layer_conv_2d(64, kernel_size = 4, strides = 2, padding = "same") %>%

layer_activation_leaky_relu(alpha = 0.2) %>%

layer_conv_2d(128, kernel_size = 4, strides = 2, padding = "same") %>%

layer_activation_leaky_relu(alpha = 0.2) %>%

layer_conv_2d(128, kernel_size = 4, strides = 2, padding = "same") %>%

layer_activation_leaky_relu(alpha = 0.2) %>%

layer_flatten() %>%

layer_dropout(0.2) %>% ➊

layer_dense(1, activation = "sigmoid")

一个 dropout 层:一个重要的技巧!

这是鉴别器模型摘要:

鉴别器

图片

12.5.5 生成器

接下来,让我们开发一个生成器模型,它将一个向量(来自潜在空间 - 在训练期间将随机抽样)转换为候选图像。

清单 12.35 GAN 生成器网络

latent_dim <— 128➊

生成器 <

keras_model_sequential(name = "生成器",

input_shape = c(latent_dim)) %>%

layer_dense(8 * 8 * 128) %>% ➋

layer_reshape(c(8, 8, 128)) %>%➌

layer_conv_2d_transpose(128, kernel_size = 4,

strides = 2, padding = "same") %>% ➍

layer_activation_leaky_relu(alpha = 0.2) %>%

layer_conv_2d_transpose(256, kernel_size = 4,

strides = 2, padding = "same") %>%

layer_activation_leaky_relu(alpha = 0.2) %>%➎

layer_conv_2d_transpose(512, kernel_size = 4,

strides = 2, padding = "same") %>%

layer_activation_leaky_relu(alpha = 0.2) %>%

layer_conv_2d(3, kernel_size = 5, padding = "same",

activation = "sigmoid")

潜在空间将由 128 维向量组成。

产生与编码器中的 Flatten 层相同数量的系数。

恢复编码器中的 layer_flatten()。

恢复编码器中的 layer_conv_2d()。

使用 Leaky Relu 作为激活函数。

这是生成器模型摘要:

生成器

图片

12.5.6 对抗网络

最后,我们将设置 GAN,它将生成器和鉴别器连接在一起。当训练完成后,该模型将使生成器朝着改善其欺骗鉴别器能力的方向移动。该模型将潜在空间点转换为分类决策——“假”或“真”,并且它的目的是使用始终为“这些是真实图像”的标签进行训练。因此,训练 gan 将更新生成器的权重,使鉴别器在查看伪图像时更有可能预测“真实”。

回顾一下,训练循环的基本结构如下所示。对于每个时期,您执行以下操作:

  1. 1 在潜在空间中绘制随机点(随机噪声)。

  2. 2 使用此随机噪声通过生成器生成图像。

  3. 3 将生成的图像与真实图像混合。

  4. 4 使用这些混合图像训练鉴别器,并提供相应的目标:要么是“真实”(对于真实图像),要么是“假的”(对于生成的图像)。

  5. 5 在潜在空间中绘制新的随机点。

  6. 6 使用这些随机向量训练生成器,并将目标全部设置为“这些是真实图像”。这将更新生成器的权重,使它们朝着让鉴别器预测生成图像为“这些是真实图像”的方向移动:这样训练生成器就可以欺骗鉴别器。

让我们来实现它。与我们的 VAE 示例一样,我们将使用一个新的 new_model_class() 来自定义 train_step()。请注意,我们将使用两个优化器(一个用于生成器,一个用于鉴别器),因此我们还将重写 compile() 以允许传递两个优化器。

Listing 12.36 GAN 模型

GAN <— new_model_class(

classname = "GAN",

initialize = function(discriminator, generator, latent_dim) {

super$initialize()

self$discriminator  <— discriminator

self$generator      <— generator

self$latent_dim     <— as.integer(latent_dim)

self$d_loss_metric  <— metric_mean(name = "d_loss")➊

self$g_loss_metric  <— metric_mean(name = "g_loss")➊

},

compile = function(d_optimizer, g_optimizer, loss_fn) {

super$compile()

self$d_optimizer <— d_optimizer

self$g_optimizer <— g_optimizer

self$loss_fn <— loss_fn

},

metrics = mark_active(function() {

list(self$d_loss_metric,

self$g_loss_metric)

}),

train_step = function(real_images) {➋

batch_size <— tf$shape(real_images)[1]

random_latent_vectors <➌

tf\(random\)normal(shape = c(batch_size, self$latent_dim))

generated_images <

self$generator(random_latent_vectors)➍

combined_images <

tf$concat(list(generated_images,

real_images),➎

axis = 0L)

labels <

tf\(concat(list(tf\)ones(tuple(batch_size, 1L)),➏

tf$zeros(tuple(batch_size, 1L))),

axis = 0L)

labels %<>% +(

tf\(random\)uniform(tf$shape(.), maxval = 0.05))➐

with(tf$GradientTape() %as% tape, {

predictions <— self$discriminator(combined_images)

d_loss <— self$loss_fn(labels, predictions)

})

grads <— tape\(gradient(d_loss, self\)discriminator$trainable_weights)

self\(d_optimizer\)apply_gradients(➑

zip_lists(grads, self\(discriminator\)trainable_weights))

random_latent_vectors <➒

tf\(random\)normal(shape = c(batch_size, self$latent_dim))

misleading_labels <— tf$zeros(tuple(batch_size, 1L))➓

with(tf$GradientTape() %as% tape, {

predictions <— random_latent_vectors %>%

self$generator() %>%

self$discriminator()

g_loss <— self$loss_fn(misleading_labels, predictions)

})

grads <— tape\(gradient(g_loss, self\)generator$trainable_weights)

self\(g_optimizer\)apply_gradients(⓫

zip_lists(grads, self\(generator\)trainable_weights))

self\(d_loss_metric\)update_state(d_loss)

self\(g_loss_metric\)update_state(g_loss)

list(d_loss = self\(d_loss_metric\)result(),

g_loss = self\(g_loss_metric\)result())

})

设置指标以跟踪每个训练周期中的两个损失。

train_step 被调用以一批真实图像。

在潜在空间中随机采样。

将其解码为虚假图像。

将其与真实图像组合。

组装标签,区分真实图像和虚假图像。

向标签添加随机噪声——一个重要的技巧!

训练鉴别器。

在潜在空间中随机采样。

组装标签,声明“这些都是真实图像”(这是谎言!)。

训练生成器。

在我们开始训练之前,让我们还设置一个回调来监视我们的结果:它将在每个时期结束时使用生成器创建并保存一些虚假图像。

清单 12.37 在训练期间对生成的图像进行采样的回调

callback_gan_monitor <— new_callback_class(

classname = "GANMonitor",

initialize = function(num_img = 3, latent_dim = 128,

dirpath = "gan_generated_images") {

private$num_img <— as.integer(num_img)

private$latent_dim <— as.integer(latent_dim)

private$dirpath <— fs::path(dirpath)

fs::dir_create(dirpath)

},

on_epoch_end = function(epoch, logs = NULL) {

random_latent_vectors <

tf\(random\)normal(shape = c(private\(num_img, private\)latent_dim))

generated_images <— random_latent_vectors %>%

self\(model\)generator() %>%

{ tf$saturate_cast(. * 255, "uint8") }➊

for (i in seq(private$num_img))

tf\(io\)write_file(

filename = private$dirpath / sprintf("img_%03i_%02i.png", epoch, i),

contents = tf\(io\)encode_png(generated_images[i, , , ])

)

}

}

将其缩放并剪辑到 [0, 255] 的 uint8 范围内,并转换为 uint8。

最后,我们可以开始训练了。

清单 12.38 编译和训练 GAN

epochs <— 100➊

gan <— GAN(discriminator = discriminator,➋

generator = generator,

latent_dim = latent_dim)

gan %>% compile(

d_optimizer = optimizer_adam(learning_rate = 0.0001),

g_optimizer = optimizer_adam(learning_rate = 0.0001),

loss_fn = loss_binary_crossentropy()

}

gan %>% fit(

数据集,

epochs = epochs,

callbacks = callback_gan_monitor(num_img = 10, latent_dim = latent_dim)

}

在第 20 个时期后,您将开始获得有趣的结果。

实例化 GAN 模型。

在训练过程中,您可能会看到对抗损失开始显着增加,而判别损失趋向于零——鉴别器可能最终主导生成器。如果是这种情况,请尝试降低鉴别器学习率并增加鉴别器的丢弃率。图 12.22 展示了我们的 GAN 在经过 30 个周期的训练后能够生成的内容。

图像

图 12.22 大约在第 30 个周期生成的一些图像

12.5.7 总结

  • GAN 由一个生成器网络和一个鉴别器网络组成。鉴别器被训练来区分生成器的输出和来自训练数据集的真实图像,而生成器被训练来欺骗鉴别器。值得注意的是,生成器从未直接看到来自训练集的图像;它对数据的了解来自于鉴别器。

  • GAN 是难以训练的,因为训练 GAN 是一个动态过程,而不是具有固定损失景观的简单梯度下降过程。要正确训练 GAN,需要使用许多启发式技巧以及广泛的调整。

  • GAN 可能会生成高度逼真的图像。但与 VAE 不同,它们学习的潜在空间没有一个整洁的连续结构,因此可能不适用于某些实际应用,例如通过潜在空间概念向量进行图像编辑。

这些技术仅涵盖了这个快速发展领域的基础知识。还有很多东西等待我们去发现——生成式深度学习值得一整本书来探索。

摘要

  • 您可以使用序列到序列模型逐步生成序列数据。这适用于文本生成,也适用于逐音符音乐生成或任何其他类型的时间序列数据。

  • DeepDream 的工作原理是通过在输入空间中通过梯度上升来最大化卷积网络层的激活。

  • 在风格转移算法中,通过梯度下降将内容图像和风格图像结合在一起,生成具有内容图像的高级特征和风格图像的局部特征的图像。

  • VAE 和 GAN 是学习图像潜在空间的模型,然后可以通过从潜在空间中抽样来构想全新的图像。潜在空间中的概念向量甚至可以用于图像编辑。

  1. ¹ Iannis Xenakis, “Musiques formelles: nouveaux principes formels de composition musicale,” La Revue musicale 特刊, 第 253–254 号 (1963).

  2. ² Alex Graves, “Generating Sequences with Recurrent Neural Networks,” arXiv (2013), arxiv.org/abs/1308.0850.

  3. ³ Alexander Mordvintsev, Christopher Olah, 和 Mike Tyka, “DeepDream: A Code Example for Visualizing Neural Networks,” Google Research Blog, 2015 年 7 月 1 日, mng.bz/xXlM.

  4. ⁴ Leon A. Gatys, Alexander S. Ecker, and Matthias Bethge, “艺术风格的神经算法,” arXiv (2015), arxiv.org/abs/1508.06576.

  5. ⁵ Diederik P. Kingma 和 Max Welling,“自动编码变分贝叶斯,” arXiv (2013), arxiv.org/abs/1312.6114.

  6. ⁶ Danilo Jimenez Rezende, Shakir Mohamed, and Daan Wierstra, “深度生成模型中的随机反向传播和近似推断,” arXiv (2014), arxiv.org/abs/1401.4082.

  7. ⁷ Ian Goodfellow 等人,“生成对抗网络,” arXiv (2014), arxiv.org/abs/1406.2661.

第十三章:实践的最佳实践

本章包括

  • 超参数调优

  • 模型集成

  • 混合精度训练

  • 在多个 GPU 或 TPU 上训练 Keras 模型

从这本书开始以来,你已经走了很远。你现在可以训练图像分类模型、图像分割模型、用于向量数据的分类或回归模型、时间序列预测模型、文本分类模型、序列到序列模型,甚至是文本和图像的生成模型。你已经覆盖了所有的基础。

然而,到目前为止,你的模型都是在小规模上训练的——在小数据集上,用单个 GPU——它们通常还没有达到我们所看到的每个数据集的最佳性能。毕竟,这本书是一本入门书。如果你要走进现实世界,在全新的问题上取得最先进的结果,你还需要跨越一定的鸿沟。

这个倒数第二章是关于弥合这一鸿沟,并为你提供你在从机器学习学生到完全成熟的机器学习工程师所需的最佳实践。我们将回顾系统地改进模型性能的基本技术:超参数调优和模型集成。然后我们将看看如何加速和扩展模型训练,使用多 GPU 和 TPU 训练,混合精度,以及利用云中的远程计算资源。

我们还将利用这一章节来展示如何直接访问 Python 包,即使没有方便的 R 包装器可用。这将是你在深度学习旅程中的一个基本技能。你不需要知道 Python 来使用 R 中的 Python 包,但是如果你发现自己曾经阅读过 Python 文档,并问过“所有的下划线是什么?”可以直接去附录,R 用户的 Python 入门指南,它会尽快让你掌握速度。

13.1 充分利用你的模型

盲目尝试不同的架构配置,如果你只需要一个可以正常工作的东西,那么这样做就足够了。在本节中,我们将超越“正常工作”到“出色工作并赢得机器学习竞赛”的一系列必须知道的技术,用于构建最先进的深度学习模型。

13.1.1 超参数优化

在构建深度学习模型时,你必须做出许多看似任意的决定:应该堆叠多少层?每层应该放多少个单元或滤波器?应该使用 relu 作为激活函数,还是其他函数?在给定的层之后应该使用 layer_batch_normalization() 吗?应该使用多少的 dropout?等等。这些架构级参数被称为超参数,以区别于模型的参数,后者通过反向传播进行训练。

实际上,经验丰富的机器学习工程师和研究人员随着时间的推移逐渐形成了有关这些选择的有效性的直觉,他们发展了超参数调整技能。但是,不存在正式的规则。如果您想达到在给定任务上可以实现的极限,您不能满足于这些随意的选择。即使您有很好的直觉,您的初始决策几乎总是次优的。您可以通过手动调整并重复训练模型来改进您的选择-这就是机器学习工程师和研究人员花费大量时间的内容。但是作为人类,您不应该整天瞎折腾超参数,最好交给机器处理。

因此,您需要以系统化、有原则的方式自动探索可能的决策空间。您需要搜索体系结构空间,并经验性地找到最佳的性能体系结构。这就是自动超参数优化领域:这是一个独立的研究领域,也是重要的一个。优化超参数的过程通常是这样的:

  1. 1 自动选择一组超参数。

  2. 2 构建相应的模型。

  3. 3 将其应用于训练数据,并在验证数据上测量性能。

  4. 4 自动选择下一组要尝试的超参数。

  5. 5 重复。

  6. 6 最终,在测试数据上测量性能。

这个过程的关键是算法,它分析验证性能和各种超参数值之间的关系,选择下一组要评估的超参数。有许多不同的技术可用:贝叶斯优化、遗传算法、简单的随机搜索等等。

训练模型的权重相对容易:您在一小批数据上计算损失函数,然后使用反向传播将权重向正确的方向移动。另一方面,更新超参数则具有独特的挑战。考虑以下几点:

  • 超参数空间通常由离散决策组成,因此不是连续或可微分的。因此,您通常无法在超参数空间中执行梯度下降。相反,您必须依赖于无梯度优化技术,这自然比梯度下降效率低得多。

  • 这个优化过程的反馈信号(这组超参数是否导致在这个任务上的高性能模型?)可能非常昂贵:需要从头开始创建和训练一个新模型。

  • 反馈信号可能带有噪声:如果一个训练运行表现更好,是因为更好的模型配置,还是因为您得到了初值较好的权重值?

幸运的是,有一个工具可以使超参数调整更简单:KerasTuner。我们来看看吧。

使用 KerasTuner

让我们首先安装 KerasTuner Python 包:

reticulate::py_install("keras-tuner", pip = TRUE)

KerasTuner 可以让你用一系列可能的选择替换硬编码的超参数值,比如 units = 32,比如 Int(name = “units”, min_value = 16, max_ value = 64, step = 16)。在给定模型中,这组选择被称为超参数调整过程的 搜索空间。要指定搜索空间,定义一个模型构建函数(见下面的列表)。它接受一个 hp 参数,从中你可以取样超参数范围,并返回一个编译过的 Keras 模型。

列表 13.1 一个 KerasTuner 模型构建函数

build_model <- function(hp, num_classes = 10) {

units <- hp$Int(name = "units",➊

min_value = 16L, max_value = 64L, step = 16L)

model <- keras_model_sequential() %>%

layer_dense(units, activation = "relu") %>%

layer_dense(num_classes, activation = "softmax")

optimizer <- hp$Choice(name = "optimizer",➋

values = c("rmsprop", "adam"))

model %>% compile(optimizer = optimizer,

loss = "sparse_categorical_crossentropy",

metrics = c("accuracy"))

model➌

}

从 hp 对象中取样超参数值。取样后,这些值(比如这里的 units 和 optimizer 变量)就是普通的 R 常量了。

有不同类型的超参数可用:Int、Float、Boolean、Choice。

函数返回一个编译过的模型。

如果你想采用更模块化和可配置的方法来构建模型,你也可以子类化 HyperModel 类并定义一个 build 方法,如下所示。

列表 13.2 一个 KerasTuner HyperModel

kt <- reticulate::import("kerastuner")

SimpleMLP(kt$HyperModel) %py_class% {

__init__ <- function(self, num_classes) {➊

self$num_classes <- num_classes

}

build <- function(self, hp) {➋

build_model(hp, self$num_classes)

}

}

hypermodel <- SimpleMLP(num_classes = 10)

通过面向对象的方法,我们可以将模型常量配置为构造函数参数,如 num_classes。

build() 方法与我们先前的 build_model() 独立函数相同,只是现在它由子类 kt$HyperModel 的一个方法调用。

使用 %py_class% 自定义 Python 类

%py_class% 可用于在 R 中定义自定义 Python 类。它反映了定义 Python 类的 Python 语法,并允许将 Python 几乎机械地转换为 R。当使用围绕子类化设计的 Python API 时,比如 kt$HyperModel 时,它尤其有用。在 Python 文档中,KerasTuner 的等效 SimpleMLP 定义(你可能在 Python 文档中找到)将如下所示:

import kerastuner as kt

class SimpleMLP(kt.HyperModel):

def init(self, num_classes):

self.num_classes = num_classes

def build(self, hp):

return build_model(hp, self.num_classes)

hypermodel = SimpleMLP(num_classes=10)

查看 R 中的 ?’%py_class%’ 以获取更多信息和示例。

下一步是定义一个“调谐器”。概括地说,你可以将调谐器想象为一个会重复执行的 for 循环,该循环将

  • 选择一组超参数值

  • 用这些值调用模型构建函数以创建一个模型

  • 训练模型并记录其度量

KerasTuner 有几个内置的调谐器可用——RandomSearch、BayesianOptimization 和 Hyperband。让我们尝试 BayesianOptimization,这是一种试图根据先前选择的结果智能预测哪些新的超参数值可能表现最佳的调谐器:

tuner <- kt$BayesianOptimization(➊

build_model,

objective = "val_accuracy",➋

max_trials = 100L,➌

executions_per_trial = 2L,➍

directory = "mnist_kt_test",➎

overwrite = TRUE )➏

指定模型构建函数(或超模型实例)。

指定调谐器将寻求优化的指标。始终指定验证指标,因为搜索过程的目标是找到具有泛化能力的模型。

尝试结束搜索之前要尝试的不同模型配置(“试验”)的最大数量。

为减少指标方差,您可以多次训练相同的模型并平均结果。executions_per_trial 是每个模型配置(试验)运行的训练轮数(执行次数)。

存储搜索日志的位置

是否覆盖目录中的数据以启动新的搜索。如果您修改了模型构建函数,则将其设置为 TRUE;如果要恢复以前开始的具有相同模型构建函数的搜索,则设置为 FALSE。

您可以通过 search_space_summary()显示搜索空间的概述:

tuner$search_space_summary()

搜索空间摘要

默认搜索空间大小:2

units (Int)

{"default": None,

"conditions": [],

"min_value": 128,

"max_value": 1024,

"step": 128,

"sampling": None}

optimizer (Choice)

{"default": "rmsprop",

"conditions": [],

"values": ["rmsprop", "adam"],

"ordered": False}

目标最大化和最小化

对于内置指标(例如准确性,在我们的情况下),KerasTuner 推断出指标的方向(准确性应最大化,但损失应最小化)。但是,对于自定义指标,您应该自己指定,就像这样:

objective <- kt$Objective(

name = "val_accuracy",➊

direction = "max" ➋

)

tuner <- kt$BayesianOptimization(

build_model,

objective = objective,

)

指标的名称,如在 epoch 日志中找到的

指标的期望方向:"min"或"max"

最后,让我们启动搜索。不要忘记传递验证数据,并确保不要使用测试集作为验证数据——否则,您将很快开始过度拟合您的测试数据,并且您将无法信任您的测试指标:

c(c(x_train, y_train), c(x_test, y_test)) %<-% dataset_mnist()

x_train %<>% { array_reshape(., c(-1, 28 * 28)) / 255 }

x_test %<>% { array_reshape(., c(-1, 28 * 28)) / 255 }

x_train_full <- x_train➊

y_train_full <- y_train➊

num_val_samples <- 10000

c(x_train, x_val) %<-%

list(x_train[seq(num_val_samples), ],➋

x_train[-seq(num_val_samples), ])➋

c(y_train, y_val) %<-%➋

list(y_train[seq(num_val_samples)],➋

y_train[-seq(num_val_samples)])

callbacks <- c(

callback_early_stopping(monitor = "val_loss",

patience = 5)

)

tuner$search(➌

x_train, y_train,

batch_size = 128L,➍

epochs = 100L,➎

validation_data = list(x_val, y_val),

callbacks = callbacks,

verbose = 2L

)

这些要留着以备后用。

设置一个验证集。

这接受与 fit() 相同的参数(它只是将它们传递给每个新模型的 fit())。

确保在 Python 函数期望整数的地方传递整数,而不是双精度数。

使用大量时期数(你事先不知道每个模型需要多少时期),并使用 callback_early_stopping() 在开始过拟合时停止训练。

前述示例将在几分钟内运行完成,因为我们只考虑了几种可能的选择,并且我们是在 MNIST 上进行训练。然而,对于典型的搜索空间和数据集,你经常会发现自己让超参数搜索运行一整夜甚至几天。如果你的搜索过程崩溃了,你总是可以重新启动它——只需在调节器中指定 overwrite = FALSE,这样它就可以从存储在磁盘上的试验日志中恢复。一旦搜索完成,你可以查询最佳的超参数配置,然后可以使用它们创建性能优越的模型,然后重新训练。

查询最佳超参数配置列表 13.3

top_n <- 4L

best_hps <- tuner$get_best_hyperparameters(top_n)➊

返回 HyperParameter 对象的列表,你可以将它们传递给模型构建函数。

通常,当重新训练这些模型时,你可能希望将验证数据包括在训练数据中,因为你不会再进行任何超参数更改,因此不会再在验证数据上评估性能。在我们的示例中,我们将最终模型训练在原始 MNIST 训练数据的全部范围内,而不保留验证集。

在我们能够在完整的训练数据上训练之前,还有一个最后需要解决的参数:训练的最佳时期数。通常情况下,你会希望比在搜索期间更长时间地训练新模型:在 callback_early_stopping() 中使用激进的耐心值可以节省搜索时间,但可能会导致模型欠拟合。只需使用验证集找到最佳时期:

get_best_epoch <- function(hp) {

model <- build_model(hp)

callbacks <- c(

callback_early_stopping(monitor = "val_loss", mode = "min",

patience = 10))➊

history <- model %>% fit(

x_train, y_train,

validation_data = list(x_val, y_val),

epochs = 100,

batch_size = 128,

callbacks = callbacks

)

best_epoch <- which.min(history\(metrics\)val_loss)

print(glue::glue("最佳时期:{best_epoch}"))

invisible(best_epoch)

}

注意非常高的耐心值。

最后,在这个时期计数稍微长一点的完整数据集上训练,因为你在训练更多的数据;在这种情况下多了 20%:

get_best_trained_model <- function(hp) {

best_epoch <- get_best_epoch(hp)

model <- build_model(hp)

model %>% fit(

x_train_full,

y_train_full,

batch_size = 128,

epochs = round(best_epoch * 1.2)

)

model

}

best_models <- best_hps %>%

lapply(get_best_trained_model)

请注意,如果您不担心轻微的性能下降,您可以采取捷径:只需使用调谐器重新加载在超参数搜索期间保存的最佳权重的表现最佳模型,而无需从头开始重新训练新模型:

best_models <- tuner$get_best_models(top_n)

在进行规模化的自动超参数优化时,一个重要问题需要考虑,那就是验证集过拟合。因为您正在根据使用验证数据计算的信号更新超参数,所以您实际上是在验证数据上训练它们,因此它们会迅速过拟合验证数据。请务必牢记这一点。

精心设计正确的搜索空间的艺术

总的来说,超参数优化是一种强大的技术,是在任何任务中获得最先进模型或赢得机器学习竞赛的绝对要求。想想看:很久以前,人们手工制作了进入浅层机器学习模型的特征。那是非常次优的。现在,深度学习自动化了分层特征工程的任务——特征是使用反馈信号学习的,而不是手工调整的,这才是正确的方式。同样,您不应手工制作您的模型架构;您应以有原则的方式对其进行优化。

然而,进行超参数调整并不能替代熟悉模型架构最佳实践。随着选择数量的增加,搜索空间呈组合增长,因此将所有内容转化为超参数并让调谐器进行排序将是非常昂贵的。您需要聪明地设计正确的搜索空间。超参数调整是自动化的,而不是魔术:您使用它来自动化您本来需要手动运行的实验,但您仍然需要手动选择具有潜力产生良好指标的实验配置。

好消息是,通过利用超参数调整,您需要做出的配置决策从微观决策(我应该为这个层选择多少个单元?)升级到更高级的架构决策(我应该在整个模型中使用残差连接吗?)。尽管微观决策是特定于某个模型和某个数据集的,但更高级的决策在不同的任务和数据集中更容易推广。例如,几乎每个图像分类问题都可以通过相同类型的搜索空间模板解决。

按照这个逻辑,KerasTuner 尝试提供与广泛问题类别相关的预制搜索空间,比如图像分类。只需添加数据,运行搜索,就可以得到一个相当不错的模型。您可以尝试超模型 kt\(applications\)HyperXception 和 kt\(applications\)HyperResNet,它们是 Keras 应用模型的可调版本。

超参数调整的未来:自动化机器学习

目前,作为深度学习工程师,你的大部分工作都是用 R 脚本处理数据,然后长时间调整深度网络的架构和超参数,以获得一个可用的模型,甚至是一个最先进的模型,如果你有这个雄心的话。不用说,这不是一个最佳的设置。但自动化可以帮助,而且不仅仅在超参数调整方面停止。

搜索可能的学习速率或可能的层大小只是第一步。我们也可以更加雄心勃勃,尝试从头开始生成模型架构,尽可能少地受到约束,例如通过强化学习或遗传算法。未来,整个端到端的机器学习流水线将自动生成,而不是由工程师手工制作。这被称为自动化机器学习,或者AutoML。你已经可以利用像 AutoKeras (https://github.com/keras-team/autokeras) 这样的库来解决基本的机器学习问题,而几乎不需要你的参与。

今天,AutoML 还处于起步阶段,并且不适用于大规模问题。但当 AutoML 足够成熟,可以广泛采用时,机器学习工程师的工作不会消失——相反,工程师将向价值创造链上移动。他们将开始更多地投入数据筛选、打造真正反映业务目标的复杂损失函数,以及理解他们的模型如何影响部署它们的数字生态系统(例如使用模型预测的用户以及生成模型训练数据的用户)。这些是目前只有最大的公司才能考虑的问题。

时刻着眼于整体,专注于理解基础知识,并记住高度专业化的枯燥工作最终将被自动化。将其视为一份礼物——为你的工作流程带来更大的生产力——而不是对你自身影响的威胁。调整旋钮无休止地不应该是你的工作。

13.1.2 模型集成

获得任务上最佳结果的另一种强大技术是模型集成。集成包括汇总一组不同模型的预测结果以产生更好的预测。如果你看看机器学习竞赛,特别是在 Kaggle 上,你会发现获胜者使用非常庞大的模型集合,无论这些模型有多么好,都必然会击败任何单一模型。

集成依赖于这样的假设:训练独立的不同表现良好的模型很可能是出于不同的原因而优秀的:每个模型都从稍微不同的角度观察数据的各个方面,以进行预测,从而获取“真相”的一部分,但并非全部。你可能熟悉盲人摸象的古老寓言:一群盲人第一次遇到大象,并试图通过触摸来了解大象是什么。每个人触摸大象的不同部分,比如象鼻或一条腿。然后,这些人互相描述大象是什么:“它像一条蛇”,“像一根柱子或一棵树”,等等。这些盲人本质上就是机器学习模型,试图从自己的角度,使用自己的假设(由模型的独特架构和独特的随机权重初始化提供)来理解训练数据的多样性。他们各自都得到了数据的部分真相,但并非全部真相。通过汇集他们的视角,你可以获得更准确的数据描述。大象是由部分组成的:没有一个盲人完全搞对了,但如果他们一起接受采访,他们可以讲出相当准确的故事。

让我们以分类为例。汇集一组分类器的预测(集成分类器)的最简单方法是在推断时对其预测求平均:

preds_a <- model_a %>% 预测(x_val))➊

preds_b <- model_b %>% 预测(x_val)➊

preds_c <- model_c %>% 预测(x_val)➊

preds_d <- model_d %>% 预测(x_val)➊

final_preds <-

0.25 * (preds_a + preds_b + preds_c + preds_d)➋

使用四种不同的模型计算初始预测。

这个新的预测数组应该比任何初始预测更准确。

但是,这只有在分类器大致相当优秀的情况下才会奏效。如果其中一个分类器明显比其他分类器差,那么最终预测可能不如该组中的最佳分类器。

集成分类器的更智能的方式是进行加权平均,其中权重是在验证数据上学习得到的——通常,更好的分类器分配更高的权重,而更差的分类器分配更低的权重。要搜索一组良好的集成权重,可以使用随机搜索或简单的优化算法,比如 Nelder–Mead 算法:

preds_a <- model_a %>% 预测(x_val)

preds_b <- model_b %>% 预测(x_val)

preds_c <- model_c %>% 预测(x_val)

preds_d <- model_d %>% 预测(x_val)

final_preds <

(0.5 * preds_a) + (0.25 * preds_b) +

(0.1 * preds_c) + (0.15 * preds_d)➊

这些权重(0.5、0.25、0.1、0.15)被假设是基于经验学习得到的。

存在许多可能的变体:例如,你可以对预测进行指数平均。一般来说,通过在验证数据上优化权重的简单加权平均法可以提供一个非常强大的基线。

使集成工作的关键是分类器集合的多样性。多样性是力量。如果所有的盲人只触摸大象的鼻子,他们会认为大象和蛇一样,他们将永远无法真正了解大象的真相。多样性是使集成方法工作的原因。在机器学习的术语中,如果你的所有模型在同一方面有偏见,那么你的集成将保留这种偏见。如果你的模型在不同方面有偏见,这些偏见将互相抵消,集成将更强大、更准确。

出于这个原因,你应该使用尽可能好且尽可能不同的模型进行集成。通常这意味着使用非常不同的架构甚至不同品牌的机器学习方法。但通常不值得将多个独立训练的相同网络进行集成,只是其随机初始化和接触训练数据的顺序不同而已。如果你的模型之间唯一的区别是它们的随机初始化和接触训练数据的顺序,那么你的集成将缺乏多样性,并且只会比单个模型提供微小的改进。

在实践中我发现,有一种方法非常有效,但并不适用于所有问题领域,那就是使用一组基于树的方法(如随机森林或梯度提升树)和深度神经网络的集成方法。在 2014 年,我和安德烈·科列夫在 Kaggle 的希格斯玻色子衰变检测挑战赛中获得了第四名,使用了各种树模型和深度神经网络的集成方法。值得注意的是,集成中的一个模型来源于不同的方法(它是一个经过正则化的贪婪森林),与其他模型相比得分显著较低。毫不奇怪,它在集成中被分配了较小的权重。但令我们惊讶的是,它实际上显著提高了整体集成的性能,因为它与其他模型非常不同:它提供了其他模型没有访问权的信息。这正是集成的目的所在。关键不在于你最好的模型有多好,而在于候选模型集合的多样性。

13.2 模型训练的扩展

回顾我们在第七章介绍的“进步循环”概念:你的思想质量取决于它们经历了多少次优化迭代(见图 13.1)。而你能够迭代一个想法的速度取决于你能够多快地建立一个实验,运行这个实验的速度,以及你能够分析所得数据的好坏。

图片

图 13.1 进步循环

随着您对 Keras API 的专业知识的发展,您能够编写深度学习实验的速度将不再是进展周期的瓶颈。下一个瓶颈将变为您能够训练模型的速度。快速的训练基础设施意味着您可以在 10 到 15 分钟内收到结果,因此,您每天可以进行数十次迭代。更快的训练直接提高了您深度学习解决方案的质量

在本节中,您将学习三种可以加速模型训练的方法:

  • 混合精度训练,即使只使用单个 GPU 也可以使用

  • 在多个 GPU 上训练

  • 在 TPU 上训练

让我们开始吧。

使用混合精度加速 GPU 上的训练的方法

如果我告诉您有一种简单的技术可以免费将几乎任何模型的训练速度加快至多 3 倍,您会怎么想?听起来太好了,但事实却是如此,这样的技巧确实存在 —— 它是混合精度训练。要理解它的工作原理,我们首先需要看一下计算机科学中“精度”的概念。

浮点数精度理解

精度对于数字来说就像对于图像的分辨率一样重要。因为计算机只能处理 1 和 0,计算机看到的任何数字都必须被编码为二进制字符串。例如,您可能熟悉 uint8 整数,这些整数是编码在八位上的整数:00000000 表示 uint8 中的 0,11111111 表示 255。要表示超过 255 的整数,您需要添加更多位数 —— 八位不够用。大多数整数存储在 32 位上,您可以使用它来表示范围从 -2147483648 到 2147483647 的有符号整数。

浮点数也是一样的。在数学中,实数形成一个连续的轴:在任意两个数字之间有无穷多个点。您可以始终在实轴上放大。在计算机科学中,这不是真的:在 3 和 4 之间有一个有限数量的中间点。有多少个?好吧,这取决于您正在使用的精度——用于存储数字的位数。您只能放大到某个分辨率。通常会使用三个精度级别:

  • 半精度,或者 float16,其中数字存储在 16 位上

  • 单精度,或者 float32,其中数字存储在 32 位上

  • 双精度,或者 float64,其中数字存储在 64 位上

关于浮点数精度的思考方式是以两个任意数字之间的最小距离为安全处理的基准。在单精度中,约为 1e-7。在双精度中,约为 1e-16。而在半精度中,只有 1e-3。

到目前为止,在本书中你看到的几乎每个模型都使用了单精度数字:它将其状态存储为 float32 权重变量,并在 float32 输入上运行其计算。这足以在不丢失任何信息的情况下运行模型的前向和反向传播——特别是当涉及到小梯度更新时(回想一下,典型的学习速率为 1e-3,看到梯度更新量在 1e-6 的数量级是非常常见的)。

你也可以使用 float64,尽管这样做会很浪费——在双精度中,矩阵乘法或加法等操作要显得更昂贵,因此你将做两倍的工作而没有明显的好处。但是你不能使用 float16 权重和计算做同样的事情;梯度下降过程不会顺利运行,因为你不能表示约为 1e-5 或 1e-6 的小梯度更新。

然而,你可以使用混合方法:这就是混合精度的含义。其思想是在不需要精度的地方利用 16 位计算,并在其他地方使用 32 位值以保持数值稳定性。现代 GPU 和 TPU 具有专门的硬件,可以比等价的 32 位操作更快地运行 16 位操作,并且使用的内存更少。通过尽可能使用这些低精度操作,你可以显著加速这些设备上的训练。同时,通过在单精度中保持对模型的精度敏感部分,你可以在不实质影响模型质量的情况下获得这些好处。

关于浮点数编码的说明

浮点数的一个反直觉事实是可表示的数字并不均匀分布。较大的数字精度较低:在任意N的情况下,2^N 和 2^(N + 1) 之间的可表示值与 1 和 2 之间的可表示值相同。这是因为浮点数分为三个部分编码——符号、有效值(称为“尾数”)和指数,形式如下

* (2 ^ ( - 127)) * 1.

例如,以下图显示了如何编码近似 Pi 的最接近的 float32 值。

Image

value = +1 * (2 ^ (128 - 127)) * 1.570796370562866

value = 3.1415927410125732

通过符号位、整数指数和整数尾数编码的 Pi 数值

因此,将数字转换为其浮点表示时产生的数值误差可能会因考虑的确切值而大相径庭,并且对于绝对值较大的数字,误差往往会变得更大。

而这些好处是相当可观的:在现代 NVIDIA GPU 上,混合精度可以将训练加速最多 3 倍。当在 TPU 上进行训练时(稍后我们会讨论此问题),也会有所益处,它可以将训练加速最多 60%。

注意 dtype 的默认值

单精度是 Keras 和 TensorFlow 中的默认浮点类型:您创建的张量或变量将为 float32,除非您另行指定。但是,对于 R 数组,默认值是 float64!

将 R 数组转换为 TensorFlow 张量将导致一个 float64 张量,这可能不是您想要的:

r_array <- base::array(0, dim = c(2, 2))

tf_tensor <- tensorflow::as_tensor(r_array)

tf_tensor$dtype

tf.float64

在转换 R 数组时,请明确指定数据类型:

r_array <- base::array(0, dim = c(2, 2))

tf_tensor <- tensorflow::as_tensor(r_array, dtype = "float32")➊

tf_tensor$dtype

tf.float32

显式指定 dtype。

请注意,当您使用 R 数组调用 Keras 的 fit() 方法时,它将自动将它们转换为 k_floatx()——默认情况下为 float32。

在实践中的混合精度训练

在 GPU 上进行训练时,可以像这样启用混合精度:

keras::keras\(mixed_precision\)set_global_policy("mixed_float16")➊

keras::keras 是由 reticulate 导入的 Python 模块。

通常,模型的大部分前向传递将在 float16 中完成(除了 softmax 等数值不稳定的操作),而模型的权重将以 float32 存储和更新。

Keras 层具有 variable_dtype 和 compute_dtype 属性。默认情况下,这两个属性都设置为 float32。当您启用混合精度时,大多数层的 compute_ dtype 将切换为 float16,并且这些层将会将它们的输入转换为 float16,并在 float16 中执行计算(使用权重的半精度副本)。然而,由于它们的 variable_dtype 仍然是 float32,所以它们的权重将能够从优化器接收准确的 float32 更新,而不是半精度更新。

请注意,某些操作在 float16 下可能不稳定(特别是 softmax 和交叉熵)。如果您需要针对特定层退出混合精度,只需将参数 dtype = "float32" 传递给该层的构造函数即可。

13.2.2 多 GPU 训练

尽管每年 GPU 的性能都在增强,但深度学习模型变得越来越大,需要越来越多的计算资源。在单个 GPU 上进行训练会限制您的运行速度。解决方法?您可以简单地添加更多的 GPU 并开始进行多 GPU 分布式训练

有两种方式可以将计算分布到多个设备上:数据并行模型并行

使用数据并行时,单个模型在多个设备或多台机器上复制。每个模型副本处理不同的数据批次,然后它们合并结果。

使用模型并行时,单个模型的不同部分在不同的设备上运行,同时处理单个数据批次。这对具有自然并行架构的模型效果最佳,例如具有多个分支的模型。

实际上,模型并行仅用于那些太大而无法放在任何单个设备上的模型:它不是用于加速常规模型训练的方法,而是用于训练更大模型的方法。我们不会在这些页面上涵盖模型并行性;相反,我们将专注于你大部分时间都会使用的数据并行性。让我们看看它是如何工作的。

获取两个或更多 GPU

首先,你需要获取几个 GPU 的访问权限。你需要做以下两件事之一:

  • 获得两到四个 GPU,将它们安装在一台机器上(这将需要一个强大的电源供应器),并安装 CUDA 驱动程序、cuDNN 等。对于大多数人来说,这不是最佳选择。

  • 在 Google Cloud、Azure 或 AWS 上租用一个多 GPU 虚拟机(VM)。你可以使用预安装了驱动程序和软件的 VM 映像,并且几乎没有设置开销。对于那些不是全天候训练模型的人来说,这可能是最佳选择。

我们不会涵盖如何启动多 GPU 云 VM 的详细信息,因为这样的说明相对来说是短暂的,并且这些信息在网上很容易找到。

单主机、多设备同步训练

一旦你能在拥有多个 GPU 的计算机上调用 library(tensorflow),你距离训练分布式模型只有几秒钟的时间。它的工作方式如下:

library(tensorflow)

strategy <- tf\(distribute\)MirroredStrategy()➊

cat("设备数量:", strategy$num_replicas_in_sync, "\n")

with(strategy$scope(), { ➋

model <- get_compiled_model()➌

})

model %>% fit(➍

train_dataset,

epochs = 100,

validation_data = val_dataset,

callbacks = callbacks

)

创建一个“分发策略”对象。MirroredStrategy() 应该是你的首选解决方案。

使用它来打开“策略范围”。

所有创建变量的操作都应该在策略范围内。一般来说,这只涉及模型构建和编译(compile())。

在所有可用设备上训练模型。

这几行代码实现了最常见的训练设置:单主机、多设备同步训练,在 TensorFlow 中也称为“镜像分布策略”。“单主机”意味着考虑的不同 GPU 都在一台机器上(而不是许多机器的集群,每台机器都有自己的 GPU,在网络上进行通信)。“同步训练”意味着每个 GPU 模型副本的状态始终保持一致——在分布式训练的变体中,情况可能并非如此。

当你在 MirroredStrategy() 范围内打开并在其中构建模型时,MirroredStrategy() 对象将在每个可用 GPU 上创建一个模型副本(replica)。例如,如果你有两个 GPU,那么每一步训练的过程如下(见 图 13.2):

  1. 1 从数据集中抽取一批数据(称为全局批次)。

  2. 2 它被分成两个不同的小批次(称为本地批次)。例如,如果全局批次有 256 个样本,那么每个本地批次将有 128 个样本。因为你希望本地批次足够大以保持 GPU 繁忙,所以全局批次大小通常需要非常大。

  3. 3 每个副本独立地处理一个本地批次:它们在自己的设备上运行正向传递,然后向后传递。每个副本输出描述在本地批次的模型损失对先前权重的梯度基础上,如何更新模型中的每个权重变量的“权重增量”。

  4. 4 本地梯度产生的权重增量在两个副本之间高效合并,以获得全局增量,该增量应用于所有副本。因为这是在每个步骤结束时完成的,所以副本始终保持同步:它们的权重始终相等。

图片

图 13.2 MirroredStrategy 训练的一步:每个模型副本计算本地权重更新,然后将这些更新合并并用于更新所有副本的状态。

在分布式训练时,始终将数据作为 TF 数据集对象提供,以保证最佳性能。(也可以将数据作为 R 数组传递,因为这些数组会被 fit() 转换为 TF 数据集对象。)你还应该确保充分利用数据预取:在将数据集传递给 fit() 之前,调用 dataset_prefetch(buffer_size)。如果你不确定要选择什么缓冲区大小,可以尝试保留 tf\(data\)AUTOTUNE 的默认值,它会为你选择缓冲区大小。

下面是一个简单的示例。

清单 13.4 在 MirroredStrategy 范围内构建模型

build_model <- function(input_size) {

resnet <- application_resnet50(weights = NULL,

include_top = FALSE,

pooling = "max")

inputs <- layer_input(c(input_size, 3))

outputs <- inputs %>%

resnet_preprocess_input() %>%

resnet() %>%

layer_dense(10, activation = "softmax")

model <- keras_model(inputs, outputs)

model %>% compile(

optimizer = "rmsprop",

loss = "sparse_categorical_crossentropy",

metrics = "accuracy"

)

model

}

strategy <- tf\(distribute\)MirroredStrategy()

cat("副本数量:", strategy$num_replicas_in_sync, "\n")

副本数量:2

with(strategy$scope(), {

model <- build_model(input_size = c(32, 32))

})

在这种情况下,让我们直接从内存中的 R 数组(通过 fit() 效率高效地转换为 TF 数据集)来训练 CIFAR10 数据集:

c(c(x_train, y_train), c(x_test, y_test)) %<-% dataset_cifar10()

model %>% fit(x_train, y_train, batch_size = 1024)➊

注意,多 GPU 训练需要大批量训练数据才能确保设备得到充分的利用。

在理想的情况下,使用 N 个 GPU 进行训练将会导致速度提升 N 倍。然而,在实践中,分布式训练会引入一些开销,特别是,合并来自不同设备的权重增量需要一些时间。你获得的有效加速度取决于所使用的 GPU 数量:

  • 使用两个 GPU,加速度接近 2×。

  • 使用四个 GPU 时,速度提升约为 3.8×。

  • 使用八个 GPU 时,速度提升约为 7.3×。

这假设你使用了足够大的全局批次大小,以使每个 GPU 都保持满负荷运行。如果你的批次大小太小,本地批次大小将不足以使 GPU 保持忙碌状态。

13.2.3 TPU 训练

除了 GPU 之外,在深度学习世界中存在一种趋势,即将工作流程转移到专门设计用于深度学习工作流程的越来越专业的硬件上(这些专用芯片被称为 ASICs——特定应用集成电路)。各种大大小小的公司都在研发新的芯片,但如今这方面最突出的努力是 Google 的 Tensor Processing Unit(TPU),它可在 Google Cloud 和 Google Colab 上使用。

在 TPU 上进行训练确实涉及一些复杂的步骤,但额外的工作可能是值得的:TPU 的速度非常快。在 TPU V2 上进行训练通常比训练 NVIDIA P100 GPU 快 15 倍。

使用 TPU 时有一些技巧:当你在云中使用 GPU 运行时,你的模型可以直接访问 GPU,无需进行任何特殊操作。但对于 TPU 运行时,情况并非如此;在构建模型之前,你需要执行额外的步骤:连接到 TPU 集群。操作如下:

tpu <- tf\(distribute\)cluster_resolver\(TPUClusterResolver\)connect()

cat("Device:", tpu$master(), "\n")

策略 <- tf\(distribute\)TPUStrategy(tpu)➊

with(strategy$scope(), { … })

像使用 tf\(distribute\)MirroredStrategy()一样使用 TPUStrategy()。

你不必太担心这是做什么的——这只是一个将你的运行时连接到设备的小咒语。打开吧。

与多 GPU 训练类似,使用 TPU 也需要你打开一个分布策略作用域——在这种情况下是 TPUStrategy()作用域。TPUStrategy()遵循与 MirroredStrategy()相同的分布模板——模型会被复制一次,每个 TPU 核心一个,而且这些副本会保持同步。

请注意,TPU 运行时还有一点有趣的地方:它是一个双 VM 设置,这意味着托管笔记本运行时的 VM 与 TPU 所在的 VM 不同。因此,你将无法从存储在本地磁盘上的文件进行训练(即,从托管实例的 VM 链接的磁盘)。TPU 运行时无法从那里读取。关于数据加载,你有两个选择:

  • 从存储在 VM 内存中的数据进行训练(而不是在磁盘上)。如果你的数据是在 R 数组中,那么你已经在做这个了。

  • 将数据存储在 Google Cloud Storage(GCS)存储桶中,并创建一个直接从存储桶读取数据的数据集,而不必从本地下载。TPU 运行时可以从 GCS 读取数据。这是数据集太大无法完全存放在内存中的唯一选择。

你会注意到第一个时期需要一段时间才能开始。那是因为你的模型正在被编译成 TPU 可以执行的东西。一旦这一步完成,训练本身就会飞快进行。

注意 I/O 瓶颈

因为 TPU 可以非常快地处理数据批次,你读取数据从 GCS 中的速度很容易成为瓶颈。

  • 如果你的数据集足够小,你应该将其保留在 VM 的内存中。你可以通过在数据集上调用 dataset_cache()来实现。这样,数据将只从 GCS 读取一次。

  • 如果你的数据集太大无法放入内存,确保将其存储为 TFRecord 文件 - 一种可以非常快地加载的高效二进制存储格式。在keras.rstudio.com上,你会找到示例代码,演示如何将数据格式化为 TFRecord 文件。

利用步骤融合来提高 TPU 利用率

因为 TPU 有大量的计算能力可用,你需要用非常大的批次来训练,以保持 TPU 核心的繁忙。对于小型模型,所需的批量大小可能会变得非常大 - 高达每批 10,000 个样本。当使用巨大的批次时,你应该确保相应地增加你的优化器学习率;你会对权重进行较少的更新,但每次更新将更准确(因为梯度是使用更多数据点计算的),所以你应该以更大的幅度移动权重。

然而,你可以利用一个简单的技巧,保持合理大小的批次,同时保持 TPU 的充分利用:步骤融合。这个想法是在每个 TPU 执行步骤中运行多个训练步骤。基本上,在 VM 内存到 TPU 之间进行更多的工作。要做到这一点,只需在 compile()中指定 steps_per_execution 参数 - 例如,steps_per_execution = 8,以在每个 TPU 执行过程中运行八个训练步骤。对于未充分利用 TPU(或 GPU)的小型模型,这可能会导致显著加速。

总结

  • 你可以利用超参数调优和 KerasTuner 来自动化找到最佳模型配置中的繁琐工作。但要注意验证集过拟合!

  • 一个多样化模型的组合往往可以显著提高预测的质量。

  • 你可以通过开启混合精度来加快 GPU 上的模型训练 - 通常会获得良好的速度提升,几乎没有额外成本。

  • 要进一步扩展你的工作流程,可以使用 tf\(distribute\)Mirrored-Strategy() API 来在多个 GPU 上训练模型。

  • 你甚至可以通过使用 TPUStrategy() API 在 Google 的 TPU 上进行训练。如果你的模型很小,请确保利用步骤融合(通过 compile(…, steps_per_execution = N) 参数)充分利用 TPU 核心。

第十四章:结论

本章内容

  • 本书的重要内容

  • 深度学习的局限性

  • 可能的深度学习、机器学习和人工智能的未来方向

  • 进一步学习和将您的技能应用于实践的资源

您几乎已经读完了本书。 这最后一章将总结和回顾核心概念,同时将您的视野扩展到远远超出您到目前为止所学到的内容。 成为一名有效的人工智能从业者是一段旅程,而完成本书只是您迈出的第一步。 我希望您意识到这一点,并且能够得到适当的装备,以便独自迈出这段旅程的下一步。

我们将从一个鸟瞰本书中您应该带走的内容开始。 这应该可以帮助您恢复对一些您所学概念的记忆。 接下来,我将概述一些深度学习的主要局限性。 要适当地使用一项工具,您不仅应该了解它 做什么,还应该意识到它 不能 做什么。 最后,我将提出一些关于深度学习、机器学习和人工智能未来发展的推测性思考。 如果您想从事基础研究,这对您应该特别有趣。 本章以一份关于进一步学习机器学习和保持与新进展同步的资源和策略的简短清单结束。

14.1 重要概念回顾

本节简要总结了本书的关键内容。 如果您需要一个快速的提醒来帮助您回忆您所学到的内容,您可以阅读这几页。

14.1.1 人工智能的各种方法

首先,深度学习不等同于 AI,甚至不等同于机器学习:

  • 人工智能(AI)是一个古老而广泛的领域,通常可以理解为“自动化人类认知过程的所有尝试。” 这可以从非常基础的东西开始,比如一个 Excel 电子表格,到非常先进的东西,比如一个能够行走和说话的 humanoid 机器人。

  • 机器学习 是人工智能的一个特定子领域,旨在通过纯粹暴露于训练数据来自动开发程序(称为 模型)。 将数据转化为程序的过程称为 学习。 尽管机器学习已经存在很长时间,但直到 1990 年代才开始起飞,然后在 2000 年代成为主导形式的人工智能。

  • 深度学习是机器学习的众多分支之一,其中模型是长链的几何变换,依次应用。这些操作被结构化为称为的模块:深度学习模型通常是层的堆叠——或者更普遍地,层的图形。这些层由权重参数化,在训练期间学习。模型的p 学习是存储在其权重中的知识,并且学习过程包括找到这些权重的“好值”——最小化损失函数的值。由于考虑的几何变换链是可微的,通过梯度下降*有效地更新权重以最小化损失函数。

即使深度学习只是机器学习中的一种方法,它也与其他方法不平等。深度学习是突破性的成功。以下是原因。

14.1.2 深度学习在机器学习领域中所特殊之处

仅仅在几年的时间内,深度学习已经在历史上被认为是计算机极为困难的一系列任务中取得了巨大的突破,尤其是在机器感知的领域:从图像、视频、声音等感知数据中提取有用信息。在充足的训练数据(特别是由人类正确标注的训练数据)的情况下,深度学习可以从感知数据中提取出几乎任何一个人可能做出的内容。因此,有时被称为深度学习“解决了感知问题”——尽管这仅针对感知的一个相当狭窄的定义而言。

由于其前所未有的技术成功,深度学习独自引发了第三个、迄今为止最大的“人工智能夏令营”:一段对人工智能领域充满了浓厚兴趣、投资和炒作的时期。在本书撰写时,我们正处于其中。这一时期是否会在不久的将来结束,以及结束后会发生什么,都是有争议的话题。有一件事情是肯定的:与以往的人工智能夏令营形成鲜明反差的是,深度学习对大型和小型科技公司都带来了巨大的商业价值,实现了人类级别的语音识别、智能助手、人类级别的图像分类、大幅改进的机器翻译等。炒作可能(并很可能会)消退,但深度学习的可持续经济和技术影响将会保持存在。在这个意义上,深度学习可能类似于互联网:它可能在短时间内被过度炒作,但从更长期看,它仍将是一场重大的革命,将改变我们的经济和生活。

我对深度学习特别乐观,因为即使在未来十年内我们不再取得技术上的进步,将现有算法应用到每个适用的问题上都将改变大多数行业的游戏规则。深度学习无异于一场革命,而目前的进展正以令人难以置信的速度发生,这要归功于对资源和人力的指数级投资。站在我现在的位置看,未来看起来很光明,尽管短期内的期望有些过于乐观;要充分发挥深度学习的潜力可能需要数十年时间。

14.1.3 如何思考深度学习

关于深度学习最令人惊讶的是它有多简单。十年前,没有人预料到我们会通过使用简单的参数模型,使用梯度下降训练的方式,在机器感知问题上取得如此惊人的结果。现在,事实证明你所需要的一切只是足够大的参数模型,以及在足够多的示例上使用梯度下降进行训练。正如费曼曾经说过关于宇宙的,“它并不复杂,只是很多而已。”¹

在深度学习中,一切都是向量——也就是说,一切都是几何空间中的。模型输入(文本、图像等)和目标首先被向量化——转换为初始输入向量空间和目标向量空间。深度学习模型中的每一层对通过它的数据进行一次简单的几何变换。模型中层的链条一起形成一个复杂的几何变换,分解为一系列简单的变换。这个复杂的变换试图将输入空间一点一点地映射到目标空间。这个变换由层的权重参数化,这些权重根据模型当前的性能进行迭代更新。这个几何变换的一个关键特性是它必须是可微分的,这是我们能够通过梯度下降来学习其参数的必要条件。直觉上,这意味着从输入到输出的几何变形必须是平滑和连续的——这是一个重要的约束。

将这个复杂的几何变换应用到输入数据的整个过程可以在 3D 中可视化,就好像一个人试图展开一个纸球:皱巴巴的纸球就是模型初始的输入数据的流形。人对纸球施加的每一个移动都类似于一个简单的几何变换,由一个层操作。完整的展开手势序列是整个模型的复杂变换。深度学习模型是用于展开高维数据复杂流形的数学机器。

这就是深度学习的魔力:将意义转化为向量,然后转化为几何空间,然后逐渐学习将一个空间映射到另一个空间的复杂几何变换。你所需要的只是足够高维度的空间来捕捉原始数据中发现的关系的全部范围。

整个过程的关键思想在于:意义来源于事物之间的成对关系(在语言中的单词之间,在图像中的像素之间等),这些关系可以用距离函数来捕捉。但请注意,大脑是否也通过几何空间实现了意义是一个完全独立的问题。从计算的角度来看,向量空间是有效的,但是可以很容易地构想出不同的智能数据结构——特别是图形。神经网络最初是从使用图形作为编码意义的一种方式的想法中产生的,这就是为什么它们被命名为神经网络;周围的研究领域曾经被称为连接主义。现在“神经网络”的名字纯粹是出于历史原因——这是一个极其误导性的名字,因为它们既不是神经也不是网络。特别是神经网络与大脑几乎毫无关系。一个更合适的名称可能应该是层次表示学习深度可微模型链式几何变换,以强调连续几何空间操作是它们的核心。

14.1.4 关键技术

当前正在展开的技术革命并不是始于任何单一的突破性发明。相反,就像任何其他革命一样,它是巨大的 enabling 因素的积累的产物——一开始是渐进的,然后是突然的。在深度学习的情况下,我们可以指出以下关键因素:

  • 渐进的算法创新——这些创新最初在两个十年内缓慢出现(从反向传播开始),然后随着在 2012 年之后更多的研究工作投入到深度学习中而日益加快发展。

  • 大量感知数据的可用性——这是一个前提,以便意识到在足够大的数据上训练的足够大的模型是我们所需要的。这又是消费互联网的崛起和摩尔定律应用于存储介质的副产品。

  • 价格低廉的快速、高度并行计算硬件的可用性——尤其是由 NVIDIA 生产的 GPU——首先是游戏 GPU,然后是从头开始设计用于深度学习的芯片。早在初期,NVIDIA 首席执行官黄仁勋注意到了深度学习的兴起,并决定把公司的未来押注在这一点上,这种决定带来了巨大的回报。

  • 一堆软件层,使这种计算能力可用于人类——CUDA 语言、像 TensorFlow 这样执行自动微分的框架,以及使深度学习对大多数人可接触的 Keras。

未来,深度学习将不仅仅被专家——研究人员、研究生和具有学术背景的工程师所使用——它将成为每个开发者工具箱中的一个工具,就像今天的网络技术一样。每个人都需要构建智能应用程序:正如今天每个企业都需要一个网站一样,每个产品都需要智能地理解用户生成的数据。实现这一未来将需要我们构建使深度学习变得极易使用并且对具备基本编程能力的任何人都可接触的工具。Keras 已经是朝着这个方向迈出的第一步。

14.1.5 通用机器学习工作流程

能够访问一个非常强大的工具,可以将任何输入空间映射到任何目标空间,这很棒,但是机器学习工作流程中困难的部分通常是在设计和训练这样的模型之前发生的一切(对于生产模型来说,在之后发生的部分也是如此)。理解问题领域,以便能够确定尝试预测什么,给定什么数据,以及如何衡量成功,是任何成功应用机器学习的先决条件,并且这不是像 Keras 和 TensorFlow 这样的高级工具可以帮助你解决的问题。作为提醒,以下是第六章中描述的典型机器学习工作流程的简要总结:

  1. 1 定义问题。可用的数据是什么,你想要预测什么?你是否需要收集更多数据或者雇佣人手来手动标记数据集?

  2. 2 确定一种可靠地衡量目标成功的方法。对于简单的任务,这可能是预测准确性,但在许多情况下,它将需要复杂的、特定于领域的指标。

  3. 3 准备您将用于评估模型的验证过程。特别是,您应该定义一个训练集、一个验证集和一个测试集。验证集和测试集的标签不应泄漏到训练数据中:例如,在时间预测中,验证和测试数据应该位于训练数据之后。

  4. 4 通过将数据转换为向量并对其进行预处理,使其更容易被神经网络接近(归一化等)。

  5. 5 开发一个能击败琐碎的常识基线的第一个模型,从而证明机器学习可以解决你的问题。这并不总是成立!

  6. 6 通过调整超参数和添加正则化逐渐完善模型架构。基于验证数据的表现进行更改,而不是测试数据或训练数据。记住,你应该使你的模型过度拟合(因此识别出一个比你需要的模型容量水平更高的水平),然后才开始添加正则化或减小模型尺寸。在调整超参数时要小心验证集的过拟合 - 你的超参数可能最终被过于专门化于验证集。避免这种情况是拥有一个单独的测试集的目的。

  7. 7 将最终模型部署到生产环境 - 作为 web API、JavaScript 或 C++应用的一部分、嵌入式设备等等。继续监控其在真实世界数据上的性能,并利用您的发现来完善模型的下一个迭代版本!

14.1.6 关键的网络架构

你应该熟悉的四个网络架构家族分别是密集连接网络、卷积网络、循环网络Transformer。每种模型类型针对特定的输入模态。网络架构对数据结构有假设:一个良好模型搜索将进行的假设空间。给定架构是否适用于给定问题完全取决于数据结构与网络架构假设之间的匹配。

这些不同类型的网络可以轻松地组合在一起,以实现更大的多模型,就像组合乐高积木一样。从某种意义上说,深度学习层是信息处理的乐高积木。以下是输入模态和适当的网络架构之间的快速映射概述:

  • 矢量数据 - 密集连接模型。

  • 图像数据 - 2D 卷积神经网络。

  • 序列数据 - 用于时间序列的 RNN,或用于离散序列(例如单词序列)的 Transformer。也可以使用 1D 卷积神经网络来处理翻译不变的连续序列数据,例如鸟鸣波形。

  • 视频数据 - 3D 卷积神经网络(如果需要捕捉运动效果),或者是一个用于特征提取的帧级 2D 卷积神经网络,后跟一个序列处理模型的组合。

  • 体积数据 - 3D 卷积神经网络。

现在让我们快速复习每种网络架构的特点。

密集连接网络

密集连接网络是一堆用于处理矢量数据的密集层(其中每个样本都是数字或分类属性的矢量)。这些网络假设输入特征没有特定的结构:它们被称为密集连接,因为密集层的单元与每个其他单元相连。该层试图映射任何两个输入特征之间的关系;这与例如只关注局部关系的 2D 卷积层不同。

密集连接网络通常用于分类数据(例如,输入特征是属性列表的情况),比如第四章中使用的波士顿房价数据集。它们也被用作大多数网络的最终分类或回归阶段。例如,第八章介绍的卷积网络通常以一个或两个密集层结束,第十章介绍的循环网络也是如此。

记住,要进行二元分类,请在层堆叠的最后使用一个具有单个单元和 Sigmoid 激活的密集层,并使用 binary_crossentropy 作为损失函数。你的目标应该是 0 或 1:

inputs <- layer_input(shape = c(num_inputs_features))

outputs <- inputs %>%

layer_dense(32, activation = "relu") %>%

layer_dense(32, activation = "relu") %>%

layer_dense(1, activation = "sigmoid")

model <- keras_model(inputs, outputs)

model %>% compile(optimizer = "rmsprop", loss = "binary_crossentropy")

要执行单标签分类(每个样本只有一个类,不多不少),请在层堆叠的最后使用一个具有与类数相等的单元数和 softmax 激活的密集层。如果你的目标是 one-hot 编码的,使用 categorical_crossentropy 作为损失;如果它们是整数,使用 sparse_categorical_crossentropy:

inputs <- layer_input(shape = c(num_inputs_features))

outputs <- inputs %>%

layer_dense(32, activation = "relu") %>%

layer_dense(32, activation = "relu") %>%

layer_dense(num_classes, activation = "softmax")

model <- keras_model(inputs, outputs)

model %>% compile(optimizer = "rmsprop", loss = "categorical_crossentropy")

要执行多标签分类(每个样本可以有多个类),请在层堆叠的最后使用一个具有与类数相等的单元数和 Sigmoid 激活的密集层,并使用 binary_crossentropy 作为损失函数。你的目标应该是多热编码的:

inputs <- layer_input(shape = c(num_inputs_features))

outputs <- inputs %>%

layer_dense(32, activation = "relu") %>%

layer_dense(32, activation = "relu") %>%

layer_dense(num_classes, activation = "sigmoid")

model <- keras_model(inputs, outputs)

model %>% compile(optimizer = "rmsprop", loss = "binary_crossentropy")

要执行回归以预测一组连续值的向量,可以在层堆叠的最后使用一个具有与你要预测的值数量相等的单元数(通常是一个,比如房屋价格)的密集层,没有激活。回归可以使用各种损失函数,最常见的是均方误差(MSE):

inputs <- layer_input(shape = c(num_inputs_features))

outputs <- inputs %>%

layer_dense(32, activation = "relu") %>%

layer_dense(32, activation = "relu") %>%

layer_dense(num_values)

model <- keras_model(inputs, outputs)

model %>% compile(optimizer = "rmsprop", loss = "mse")

卷积网络

卷积层通过将同一几何变换应用于输入张量中的不同空间位置(patch)来查看空间局部模式。这导致产生的表示是平移不变的,使卷积层具有高数据效率和模块化性。这个想法适用于任何维度的空间:1D(连续序列),2D(图像),3D(体积)等。您可以使用 Conv1D 层处理序列,Conv2D 层处理图像,Conv3D 层处理体积。作为卷积层的更轻、更高效的替代方案,您还可以使用深度可分离卷积层,比如 SeparableConv2D。

卷积神经网络,或者卷积网络,由一系列的卷积层和最大池化层组成。池化层允许对数据进行空间下采样,这对于保持特征图的合理大小是必要的,因为特征数量增加,以及允许后续的卷积层“看到”输入的更大空间范围。卷积网络通常以 Flatten 操作或全局池化层结束,将空间特征图转换为向量,然后通过 Dense 层实现分类或回归。

这是一个典型的图像分类网络(在这种情况下是分类分类),利用可分离卷积层:

inputs <- layer_input(shape = c(height, width, channels))

outputs <- inputs %>%

layer_separable_conv_2d(32, 3, activation = "relu") %>%

layer_separable_conv_2d(64, 3, activation = "relu") %>%

layer_max_pooling_2d(2) %>%

layer_separable_conv_2d(64, 3, activation = "relu") %>%

layer_separable_conv_2d(128, 3, activation = "relu") %>%

layer_max_pooling_2d(2) %>%

layer_separable_conv_2d(64, 3, activation = "relu") %>%

layer_separable_conv_2d(128, 3, activation = "relu") %>%

layer_global_average_pooling_2d() %>%

layer_dense(32, activation = "relu") %>%

layer_dense(num_classes, activation = "softmax")

model <- keras_model(inputs, outputs)

model %>% compile(optimizer = "rmsprop", loss = "categorical_crossentropy")

在构建非常深的卷积网络时,通常会添加批量归一化层以及残差连接——这两种架构模式有助于梯度信息在网络中平滑流动。

RNNS

循环神经网络(RNNs)通过逐个时间步骤处理输入序列,并在整个过程中保持状态(状态通常是一个向量或一组向量)。在序列的情况下,感兴趣的模式不是通过时间平移不变的(例如,在时间序列数据中,最近的过去比遥远的过去更重要),应优先使用 RNNs,而不是 1D 卷积网络。

Keras 提供了三种 RNN 层:SimpleRNN、GRU 和 LSTM。在大多数实际情况下,您应该使用 GRU 或 LSTM。LSTM 是其中更强大的一个,但也更昂贵;你可以将 GRU 视为其更简单、更便宜的替代品。

要在彼此之上堆叠多个 RNN 层,堆栈中倒数第二个层之前的每一层应该返回其输出的完整序列(每个输入时间步对应一个输出时间步)。如果您不再堆叠任何其他 RNN 层,只返回包含有关整个序列信息的最后输出是很常见的。

下面是一个用于二元分类向量序列的单个 RNN 层:

inputs <- layer_input(shape = c(num_timesteps, num_features))

outputs <- inputs %>%

layer_lstm(32) %>%

layer_dense(num_classes, activation = "sigmoid")

model <- keras_model(inputs, outputs)

model %>% compile(optimizer = "rmsprop", loss = "binary_crossentropy")

这是一个用于二元分类向量序列的堆叠 RNN:

inputs <- layer_input(shape = c(num_timesteps, num_features))

outputs <- inputs %>%

layer_lstm(32, return_sequences = TRUE) %>%

layer_lstm(32, return_sequences = TRUE) %>%

layer_lstm(32) %>% layer_dense(num_classes, activation = "sigmoid")

model <- keras_model(inputs, outputs)

model %>% compile(optimizer = "rmsprop", loss = "binary_crossentropy")

TRANSFORMERS

一个 Transformer 看待一组向量(如单词向量),并利用 神经注意力 将每个向量转换为一个考虑到集合中其他向量提供的 上下文 的表示。当涉及的集合是有序序列时,您还可以利用 位置编码 创建可以考虑全局上下文和单词顺序的 Transformer,能够比 RNNs 或 1D convnets 更有效地处理长文段。

Transformers 可用于任何集合处理或序列处理任务,包括文本分类,但它们在 序列到序列学习 方面表现出色,比如将源语言中的段落翻译成目标语言。序列到序列 Transformer 由两部分组成:

  • 一个 TransformerEncoder 将输入向量序列转换为一个具有上下文感知和顺序感知的输出向量序列

  • 一个 TransformerDecoder,它接受 TransformerEncoder 的输出以及目标序列,并预测目标序列中接下来应该出现的内容

如果您只处理单个序列(或集合)的向量,则只会使用 TransformerEncoder。

下面是一个序列到序列的 Transformer,用于将源序列映射到目标序列(这种设置可以用于机器翻译或问答等任务):

encoder_inputs <- layer_input(shape = c(sequence_length),➊

dtype = "int64")

encoder_outputs <- encoder_inputs %>%

layer_positional_embedding(sequence_length, vocab_size, embed_dim) %>%

layer_transformer_encoder(embed_dim, dense_dim, num_heads)

decoder <- layer_transformer_decoder(NULL, embed_dim, dense_dim, num_heads)

decoder_inputs <- layer_input(shape = c(NA),➋

dtype = "int64")

decoder_outputs <- decoder_inputs %>%➌

layer_positional_embedding(sequence_length, vocab_size, embed_dim) %>%

decoder(., encoder_outputs) %>%

layer_dense(vocab_size, activation = "softmax")

transformer <- keras_model(list(encoder_inputs, decoder_inputs),

decoder_outputs)

transformer %>%

compile(optimizer = "rmsprop", loss = "categorical_crossentropy")

源序列

目标序列迄今为止

目标序列一步在未来

这是一个用于整数序列的二元分类的孤立的 TransformerEncoder:

inputs <- layer_input(shape = c(sequence_length), dtype = "int64")

outputs <- inputs %>%

layer_positional_embedding(sequence_length, vocab_size, embed_dim) %>%

layer_transformer_encoder(embed_dim, dense_dim, num_heads) %>%

layer_global_max_pooling_1d() %>%

layer_dense(1, activation = "sigmoid")

model <- keras_model(inputs, outputs)

model %>% compile(optimizer = "rmsprop", loss = "binary_crossentropy")

在第十一章中提供了 TransformerEncoder、TransformerDecoder 和 PositionalEmbedding 层的完整实现。

14.1.7 可能性的空间

你会用这些技术构建什么?记住,构建深度学习模型就像玩乐高积木一样:层可以连接在一起,将基本上任何东西映射到任何东西,只要你有适当的训练数据可用,并且映射通过合理复杂度的连续几何变换是可行的。可能性的空间是无限的。本节提供了一些示例,以激发您超越传统机器学习中的基本分类和回归任务的思考。

我已经按照以下列表中的输入和输出模态对我的建议应用进行了排序。请注意,其中有相当多的任务都超出了可能性的限制,尽管一个模型可能会在所有这些任务上进行训练——在某些情况下,这样的模型可能不会远离其训练数据。第 14.2 至 14.4 节将讨论如何在未来消除这些限制:

  • 将向量数据映射到向量数据:

    • 预测性医疗保健—将患者的医疗记录映射到对患者结果的预测

    • 行为定向—将一组网站属性与用户在网站上停留时间的数据相映射

    • 产品质量控制—将一组相对于制造产品实例的属性映射到产品明年可能失败的概率

  • 将图像数据映射到向量数据:

    • 医疗助手—将医学图像幻灯片映射到关于肿瘤存在的预测

    • 自动驾驶车辆—将汽车行车记录仪视频帧映射到方向盘角度命令以及油门和刹车命令

    • 棋盘游戏 AI—将围棋或国际象棋棋盘映射到下一步的玩家移动

    • 饮食助手—将一道菜的图片映射到其卡路里计数

    • 年龄预测—将自拍照映射到人的年龄

  • 将时间序列数据映射到向量数据:

    • 天气预测—将时间序列的天气数据映射到一个星期后特定地点的温度

    • 脑机接口—将脑磁图(MEG)数据的时间序列映射到计算机命令

    • 行为定向—将用户在网站上的时间序列交互映射到用户购买某物的概率

  • 将文本映射到文本:

    • 机器翻译—将一种语言中的段落映射到另一种语言的翻译版本

    • 智能回复—将电子邮件映射到可能的一行回复

    • 问答—将普通知识问题映射到答案

    • 摘要—将一篇长文章映射到文章的简短摘要

  • 将图像映射到文本:

    • 文本转录—将包含文本元素的图像映射到相应的文本字符串

    • 字幕—将图像映射到描述图像内容的简短字幕

  • 将文本映射到图像:

    • 条件图像生成—将简短的文本描述映射到与描述匹配的图像

    • 标志生成/选择—将公司的名称和描述映射到标志建议

  • 将图像映射到图像:

    • 超分辨率—将缩小的图像映射到相同图像的更高分辨率版本

    • 视觉深度感知—将室内环境的图像映射到深度预测的地图

  • 将图像和文本映射到文本:

    • 视觉问答—将图像和关于图像内容的自然语言问题映射到自然语言答案
  • 将视频和文本映射到文本

    • 视频问答—将短视频和关于视频内容的自然语言问题映射到自然语言答案

几乎什么都可能,但并非一切。在下一节中,您将看到我们用深度学习无法做到的事情。

14.2 深度学习的局限性

可以用深度学习实现的应用空间是无限的。然而,许多应用程序仍然完全超出了当前深度学习技术的范围——即使提供了大量的人工注释数据。比如,假设您可以组合成一个包含数十万甚至数百万个英语软件产品特性描述的数据集,这些描述是由产品经理撰写的,以及由工程师团队开发的相应源代码,以满足这些要求。即使有了这些数据,您也无法训练一个深度学习模型来阅读产品描述并生成相应的代码库。这只是众多例子中的一个。通常情况下,任何需要推理的事情——比如编程或应用科学方法——长期规划——和算法数据处理都超出了深度学习模型的范围,无论您向它们提供多少数据。甚至用深度神经网络学习一个简单的排序算法也是极其困难的。

这是因为深度学习模型只是一系列简单的、连续的几何变换,将一个向量空间映射到另一个。它所能做的就是将一个数据流形 X 映射到另一个流形 Y,假设存在从 X 到 Y 的可学习连续变换。深度学习模型可以解释为一种程序,但是,大多数程序无法表示为深度学习模型。对于大多数任务,要么不存在解决该任务的合理大小的相应神经网络,要么,即使存在一个,它也可能是不可学习的:相应的几何变换可能太复杂,或者可能没有适当的数据可用于学习它。

通过堆叠更多层次和使用更多训练数据来扩展当前的深度学习技术只能表面上缓解其中一些问题。这不会解决深度学习模型所能表示的内容受限以及你可能希望学习的大多数程序无法表示为数据流形的连续几何变换的更根本性问题。

14.2.1 将机器学习模型拟人化的风险

当代人工智能的一个真正风险是误解深度学习模型的作用并高估它们的能力。人类的一个基本特征是我们的心智理论:我们倾向于将意图、信念和知识投射到我们周围的事物上。在岩石上画一个笑脸突然让我们心里认为它“快乐”。应用于深度学习时,这意味着,例如,当我们能够在某种程度上成功地训练一个模型来生成描述图片的标题时,我们会认为模型“理解”了图片的内容和它所生成的标题。然后,当训练数据中的图像与稍微偏离时,我们会对模型生成完全荒谬的标题感到惊讶(见图 14.1)。

图片

图 14.1 基于深度学习的图像标题生成系统的失败

特别是,这一点被对抗样本所突出显示,对抗样本是向深度学习网络提供的样本,旨在使模型误分类。你已经意识到,例如,可能在输入空间中进行梯度上升以生成最大化某些卷积网络滤波器激活的输入——这是第九章介绍的滤波器可视化技术的基础,以及第十二章中的 DeepDream 算法。同样,通过梯度上升,你可以微调图像以最大化给定类别的类别预测。通过在熊猫的图片上添加一个猿猴的梯度,我们可以让神经网络将熊猫分类为猿猴(见图 14.2)。这既证明了这些模型的脆弱性,也显示了它们的输入到输出映射与我们的人类感知之间的深刻差异。

简而言之,深度学习模型在人类意义上并不了解它们的输入。我们对图像、声音和语言的理解基于我们作为人类的感觉运动体验。机器学习模型无法获得这样的经验,因此无法以人类可理解的方式理解它们的输入。通过为大量训练示例进行注释并输入我们的模型,我们使它们学习到将数据映射到特定示例集上的人类概念的几何变换,但这种映射只是我们头脑中原始模型的简化草图 —— 这个模型是从我们作为感知机构的经验中发展而来的。就像镜中的模糊图像(参见图 14.3)。你创建的模型将采取任何可用的捷径来适应其训练数据。例如,图像模型往往更多地依赖于局部纹理,而不是对输入图像的整体理解 —— 对具有豹纹和沙发两种特征的数据集进行训练的模型很可能将豹纹沙发误分类为实际豹子。

Image

图 14.2 对抗样本:图像中微不可见的变化可能颠覆模型对图像的分类。

Image

图 14.3 当前机器学习模型:就像镜子中的模糊图像

作为机器学习实践者,始终要意识到这一点,并且不要陷入这样的误解:神经网络理解自己所执行的任务——它们不理解,至少对我们来说没有意义。它们接受的训练是不同的,远比我们想要教给它们的任务狭窄:它们只是逐点地将训练输入映射到训练目标而已。当它们看到任何偏离其训练数据的东西时,它们就会以一种荒谬的方式崩溃。

14.2.2 自动机与智能代理

深度学习模型从输入到输出的直接几何变换与人类思维和学习方式存在根本差异。不仅仅是因为人类通过自身的经验学习,而不是被呈现给明确的训练示例。人脑与可微分参数化函数完全不同。

让我们稍微放大一点,并问:“智能的目的是什么?”它为什么首次出现?我们只能进行推测,但我们可以做出相当有根据的推测。我们可以从大脑开始看起——这个产生智能的器官。大脑是一种进化适应——一个通过数亿年的随机试错,受自然选择引导的机制,大大扩展了生物适应环境的能力。大脑最初出现在五亿多年前,作为一种存储和执行行为程序的方式。 “行为程序”只是使生物对其环境产生反应的一组指令:“如果发生这种情况,那么做那个。”它们将生物的感觉输入与其运动控制联系起来。起初,大脑将用于硬编码行为程序(作为神经连接模式),这将使生物对其感觉输入做出适当反应。这是昆虫大脑仍然工作的方式——蝇,蚂蚁,秀丽隐杆线虫(见图 14.4),等等。因为这些程序的原始“源代码”是 DNA,它将被解码为神经连接模式,进化突然能够在很大程度上无限制地搜索行为空间——这是一个重大的进化转变。

进化是程序员,而大脑是小心执行进化赋予它们的代码的计算机。由于神经连接是一种非常通用的计算基质,所有启用大脑的物种的感觉运动空间都突然开始经历戏剧性的扩展。眼睛,耳朵,下颚,四条腿,24 条腿——只要你有一个大脑,进化就会为你找到行为程序,使这些行为程序得到很好的利用。大脑可以处理你投入其中的任何模式——或模式的任何组合。

请注意,这些早期的大脑本质上并不是真正的智能。它们非常地是自动机:它们只会执行生物体 DNA 中硬编码的行为程序。它们只能被描述为智能,就像恒温器是“智能”一样。或者是列表排序程序。又或者……是经过训练的深度神经网络(人工类型)。这是一个重要的区别,让我们仔细看看它:自动机和实际智能代理之间有什么区别?

图像

图 14.4 秀丽隐杆线虫的大脑网络:由自然演化“编程”而成的行为自动机。由 Emma Towlson 创建的图像(来源:Yan 等人,“网络控制原则预测秀丽隐杆线虫连接组中的神经元功能”,自然,2017 年 10 月)。

14.2.3 本地泛化 vs. 极端泛化

十七世纪法国哲学家和科学家勒内·笛卡尔于 1637 年写了一篇启发性的评论,完美地捕捉到了这种区别,远在人工智能崛起之前,事实上,远在第一台机械计算机出现之前(他的同事帕斯卡将在五年后创造出来)。笛卡尔告诉我们,关于自动机,

即使这样的机器可能在某些方面做得和我们一样好,甚至可能更好,但它们必然会在其他方面失败,这将显示它们不是通过理解而是仅仅通过其器官的排列来行动。

勒内·笛卡尔,《方法论》(1637 年)

智能就在这里。智能的特征在于理解,而理解则表现为泛化——处理可能出现的任何新情况的能力。你如何区分一个记住了过去三年考试题但对学科没有理解的学生,和一个真正理解了材料的学生?你给他们一个全新的问题。自动机是静态的,设计来在特定环境中完成特定的事情——“如果这样,那么那样”——而智能代理可以根据新的、意想不到的情况即时适应。当自动机暴露在与其“编程”不匹配的情况下时(无论我们是在谈论人类编写的程序,还是演化生成的程序,还是在训练数据集上拟合模型的隐式编程过程),它将失败。与此同时,像人类这样的智能代理将利用他们的理解找到前进的方法。

人类能够做的远不止将即时刺激映射到即时反应,就像深度神经网络或昆虫那样。我们保持着对当前情况、自己以及他人的复杂抽象模型,我们可以利用这些模型来预测不同的可能未来并进行长期规划。你可以合并已知的概念来代表你以前从未经历过的事物——比如想象如果你中了彩票会怎么做,或者想象如果你偷偷地用弹性橡胶制成的精确复制品替换了朋友的钥匙,她会有什么反应。这种处理新奇事物和假设情况的能力,将我们的思维模型空间扩展到远远超出我们可以直接经历的范围——利用抽象推理——是人类认知的定义特征。我称之为极端泛化:利用少量数据甚至没有新数据就能适应新的、以前从未经历过的情况的能力。这种能力是人类和高级动物展示出的智能的关键。

这与自动机器人系统的做法形成了鲜明对比。一个非常刚性的自动机器人将不会有任何泛化能力——它将无法处理任何不是事先准确告知的情况。一个作为硬编码的 if-then-else 语句实现的哈希表或基本问答程序将属于这一类别。深度网络做得稍微好一些:它们可以成功地处理与其熟悉的输入略有偏差的输入,这正是使它们有用的地方。我们在第八章的猫狗模型可以对其之前未见过的猫或狗图片进行分类,只要它们与其训练的内容足够接近。然而,深度网络局限于我称之为本地泛化(见图 14.5):深度网络执行的从输入到输出的映射在输入开始偏离网络在训练时所见的内容时很快就变得没有意义。深度网络只能对已知未知进行泛化——对模型开发期间预期到的并且在训练数据中广泛出现的变化因素,例如宠物图片的不同摄像机角度或光照条件。这是因为深度网络通过流形插值进行泛化(请记住第五章):它们学习的流形需要捕获其输入空间的任何变化因素。这就是为什么基本数据增强对改善深度网络泛化很有帮助。与人类不同,这些模型在面对很少或没有可用数据的情况下没有任何即兴表演的能力(比如中彩票或被交给橡胶钥匙)只与过去情况有抽象共同点的情况。

图片

图 14.5 本地泛化 vs. 极端泛化

例如,考虑学习适当的发射参数以使火箭着陆月球的问题。如果你为此任务使用深度网络,并使用监督学习或强化学习对其进行训练,你将不得不向其提供成千上万甚至数百万次的发射试验:你需要让它暴露于密集的采样输入空间,以便学习从输入空间到输出空间的可靠映射。相比之下,作为人类,我们可以利用我们的抽象能力提出物理模型——火箭科学——并得出一个确切的解决方案,可以在一次或几次试验中将火箭着陆在月球上。同样,如果你开发了一个控制人体的深度网络,并且你希望它能够学会在城市中安全导航而不被汽车撞击,那么这个网络将不得不在各种情况下死亡成千上万次,直到它能够推断出汽车是危险的,并发展出适当的避让行为。放到一个新城市,这个网络将不得不重新学习大部分它所知道的东西。另一方面,人类能够学会安全行为,而不需要死亡一次——这再次归功于我们对新情况的抽象建模能力。

14.2.4 智能的目的

高度适应性智能代理和僵化自动机之间的区别使我们回到了大脑进化的范畴。为什么大脑——最初只是自然进化发展行为自动机的媒介——最终演化成了具有智能的器官?与每个重大的进化里程碑一样,它之所以发生,是因为自然选择的限制性促使它发生。

大脑负责行为生成。如果生物所面临的情境大部分是静态的并且可以事先知晓的,行为生成将是一个简单的问题:进化会通过随机试错找出正确的行为,并将其硬编码到生物的 DNA 中。这个大脑进化的第一阶段——大脑作为自动机——已经是最优的。然而,随着生物复杂性的增加(以及与之相伴的环境复杂性增加),动物所面临的情境变得更加动态和不可预测。你生活中的一天,仔细观察的话,与你过去经历的任何一天都不同,也与任何一个你的进化祖先经历的一天都不同。你必须能够不断面对未知和令人惊讶的情况。进化无法找到并硬编码成你今天早上醒来后成功应对你的一天所执行的行为序列的 DNA。它必须每天都根据实际情况生成行为。

作为一个优秀的行为生成引擎,大脑简单地适应了这一需求。它优化的是适应性和普遍性,而不仅仅是对一组固定情境的适应。这种转变可能在进化历史中多次发生,导致在非常遥远的进化分支上出现了高智能动物,比如猿类、章鱼、渡鸦等。智能是对复杂、动态生态系统所提出挑战的一种应对。

这就是智能的本质:它是利用手边的信息来高效地产生成功行为,应对不确定、不断变化的未来的能力。笛卡尔所谓的“理解”就是这种非凡能力的关键:开采过去经验的力量,开发可重用的模块化抽象,能够快速地重新调整来处理新情况和实现极端概括。

14.2.5 拓宽概括的谱系

作为一个粗糙的夸张,你可以将生物智能的进化历史总结为对泛化程度的缓慢攀升。它始于只能执行局部泛化的类似自动机的大脑。随着时间的推移,进化开始产生越来越广泛泛化的生物体,它们能够在越来越复杂和多变的环境中茁壮成长。最终,在过去几百万年中——在进化的时间尺度上算是瞬间—某些类人猿物种开始趋向于实现极端泛化的生物智能,引发了人类世的开始,并永远改变了地球生命的历史。

过去 70 年来人工智能的进步与这种进化有惊人的相似之处。早期的人工智能系统是纯自动机,例如上世纪 60 年代的 ELIZA 聊天程序,或者是一个 1970 年的 AI,能够通过自然语言命令操纵简单的物体。在 90 年代和 2000 年代,我们看到了能够进行局部泛化的机器学习系统的兴起,它们能够处理一定程度的不确定性和新奇性。在 2010 年代,深度学习进一步扩展了这些系统的局部泛化能力,使工程师能够利用更大的数据集和更具表现力的模型。

如今,我们可能正处于下一个进化步骤的前夕。人们对能够实现广泛泛化的系统越来越感兴趣,我将其定义为处理未知未知的能力,即在单一广泛任务领域内处理(包括系统未经训练处理和其创造者无法预料的情况),例如,一个能够安全处理你提出的任何情况的自动驾驶汽车,或者能够通过“智能测试”的家用机器人——进入一个随机的厨房并煮一杯咖啡。通过结合深度学习和精心制作的世界抽象模型,我们已经在朝着这些目标取得了可见的进展。

然而,目前,人工智能仍然局限于认知自动化:在“人工智能”中的“智能”标签是一个范畴错误。更准确地说,将我们的领域称为“人工认知”,而“认知自动化”和“人工智能”则是其两个几乎独立的子领域。在这个划分中,“人工智能”将是一个几乎一切都有待发现的新领域。

现在,我并不是要贬低深度学习的成就。认知自动化非常有用,深度学习模型能够仅通过暴露于数据而自动化任务的方式代表了一种特别强大的认知自动化形式,比明确编程要实用和多功能得多。做好这一点对于几乎每个行业都是一个改变游戏规则的举措。但它仍然远远落后于人类(或动物)智能。到目前为止,我们的模型只能执行局部泛化:它们通过对 X 到 Y 数据点的密集采样学习到的平滑几何变换,将空间 X 映射到空间 Y,而在空间 X 或 Y 内的任何破坏都会使得这种映射无效。它们只能泛化到与过去数据相似的新情况,而人类认知能够进行极端泛化,迅速适应根本新颖的情况并规划长期未来的情况。

14.3 迈向 AI 更大普适性的路线设定

为了消除我们讨论过的一些限制并创建能够与人类大脑竞争的 AI,我们需要摆脱简单的输入到输出映射,转向推理抽象。在接下来的几节中,我们将看一下前进之路可能是什么样子的。

14.3.1 关于设定正确目标的重要性:捷径法则

生物智能是自然提出的一个问题的答案。同样,如果我们想要开发真正的人工智能,首先,我们需要问正确的问题。

在系统设计中经常出现的一个效应是捷径法则:如果你专注于优化一个成功度量标准,你会实现你的目标,但会牺牲系统中未涵盖你的成功度量标准的一切。你最终会采取通往目标的所有可用捷径。你的创造受到你给自己的激励的影响。

你经常在机器学习竞赛中看到这种情况。2009 年,Netflix 举办了一个挑战赛,承诺给予在电影推荐任务中取得最高分的团队 100 万美元的奖金。最终他们没有采用获胜团队创建的系统,因为它过于复杂且计算密集。获胜者只优化了预测准确性,也就是他们被激励要达到的目标,而牺牲了系统的其他所有理想特性:推断成本、可维护性和可解释性。Kaggle 竞赛中的大多数情况也适用于这一捷径法则:Kaggle 获胜者生成的模型很少能够在生产中使用。

在过去几十年中,快速的规则一直在人工智能中被广泛应用。在 20 世纪 70 年代,心理学家和计算机科学先驱艾伦·纽厄尔对他所在的领域在认知理论方面没有取得任何有意义的进展感到担忧,他提出了一个新的人工智能的宏伟目标:下棋。理论依据是在人类中,下棋似乎涉及——甚至可能需要——一些能力,比如感知、推理和分析、记忆、从书籍中学习等等。当然,如果我们能够构建一个下棋的机器,它也必须具备这些特征,对吗?

二十多年后,梦想成真了:1997 年,IBM 的深蓝超越了全球最佳棋手加里·卡斯帕罗夫。然而,研究人员随后不得不面对这样一个事实,即创造一个国际象棋冠军级人工智能并没有对他们对人类智能的理解有所帮助。深蓝的核心是阿尔法贝塔算法,并不是人脑的一个模型,而且不能推广到除了类似棋盘游戏之外的其他任务。事实证明,建立一个只能下国际象棋而不能构建一个人工智能大脑将比较容易,所以研究人员选择了这个捷径。

到目前为止,人工智能领域的驱动成功指标一直是解决特定的任务,从国际象棋到围棋,从 MNIST 分类到 ImageNet,从 Atari 街机游戏到《星际争霸》和《Dota 2》。因此,这个领域的历史被一系列“成功”所定义,我们在解决这些任务时发现没有任何智能特征。

如果这听起来有些令人惊讶,记住类人型智能不是以特定任务的技能为特征,而是能够适应新颖性,高效地掌握新技能和掌握之前从未见过的任务的能力。通过固定任务,你可以提供一个任意精确的任务描述,无论通过硬编码人类提供的知识,还是通过提供巨大量的数据。你可以让工程师通过添加数据或添加硬编码的知识来“购买”他们的 AI 更多的技能,而不需要增加 AI 的泛化能力(参见图 14.6)。如果你有几乎无限的训练数据,甚至一个非常简单的算法如最近邻搜索也能以超人的技能玩视频游戏。同样,如果你拥有几乎无限量的人工编写的 if-then-else 语句。然而,当你对游戏规则进行一次小的改变——这是一个人类可以立即适应的改变——非智能系统将需要重新训练或从头开始构建。

图片

图 14.6:一个低泛化系统可以在给定无限的任务特定信息的情况下取得任意技能水平的固定任务。

简而言之,通过固定任务,你消除了处理不确定性和新奇性的需求,而因为智能的本质是处理不确定性和新奇性的能力,你实际上是在消除智能的需求。而且因为在特定任务上找到非智能解决方案总是比解决智能的一般问题更容易,所以你会 100%地采取这种捷径。人类可以利用其一般智能来获得任何新任务的技能,但反过来,从一系列特定任务技能到一般智能是没有路径的。

14.3.2 新目标

要使人工智能真正具有智能,并赋予其处理不可预测的变化和永远变化的现实世界的能力,我们首先需要摆脱追求特定任务技能的想法,而是开始针对通用化能力本身。我们需要新的进展指标,这些指标将帮助我们开发越来越智能的系统,指标将指向正确的方向,并给我们提供可操作的反馈信号。只要我们的目标设定为“创建一个解决任务 X 的模型”,那么捷径规则就会适用,我们最终会得到一个只能做 X 任务的模型。

在我看来,智能可以精确地量化为效率比:你可用于世界的相关信息量(可以是过去经验或先天先验知识)与你的未来操作领域之间的转化比率,即你能够产生适当行为的新颖情况集合(你可以将其视为你的技能集)。更智能的代理将能够使用更少的过去经验来处理更广泛的未来任务和情况。要测量这样的比率,你只需要固定系统可用的信息——其经验和先验知识——并在一组已知与系统接触到的情况或任务足够不同的参考情况或任务上测量其性能。试图最大化这一比率应该会引导你走向智能。关键是,为了避免作弊,你需要确保只在系统不是被编程或训练来处理的任务上测试系统——事实上,你需要一些系统的创建者无法预料到的任务

在 2018 年和 2019 年,我开发了一个称为抽象与推理语料库(ARC)⁴的基准数据集,旨在捕捉这种智能的定义。ARC 旨在既能被机器理解,又能被人类理解,它看起来非常类似于人类智商测试,比如雷文氏递进矩阵。在测试时,您会看到一系列“任务”。每个任务都通过三或四个“示例”来解释,这些示例采用输入网格和相应的输出网格的形式(见图 14.7)。然后,您将被给出一个全新的输入网格,您将有三次机会在移动到下一个任务之前产生正确的输出网格。

与智商测试相比,ARC 有两个独特之处。首先,ARC 试图衡量泛化能力,只测试您从未见过的任务。这意味着 ARC 是一个您无法练习的游戏,至少在理论上:您将被测试的任务将具有自己独特的逻辑,您必须即兴理解。您不能仅仅从过去任务中记忆特定策略。

Image

图 14.7 一个 ARC 任务:任务的性质由几个输入输出对示例来演示。给定一个新输入,您必须构建相应的输出。

此外,ARC 试图控制您带入测试的先验知识。您永远不会完全从零开始解决一个新问题——您会带入预先存在的技能和信息。ARC 假设所有的测试者都应该从一组称为“核心知识先验”的知识先验开始,这些知识先验代表了人类与生俱来的“知识系统”。与智商测试不同,ARC 任务永远不会涉及到已获得的知识,比如英语句子等。

毫不奇怪,基于深度学习的方法(包括在大量外部数据上训练的模型,如 GPT-3)完全无法解决 ARC 任务,因为这些任务是非插值的,因此不适合曲线拟合。与此同时,普通人没有任何问题在第一次尝试时解决这些任务,而且无需练习。当你看到这样的情况,即使是五岁的人类也能自然地执行一些现代人工智能技术似乎完全无法达到的事情时,这清楚地表明有趣的事情正在发生——我们漏掉了什么。

如何解决 ARC 需要什么?希望这个挑战会让您思考。这就是 ARC 的全部目的:给您一个不同种类的目标,希望能引导您朝一个新方向——希望是一个富有成效的方向。现在让我们快速看一下您想要回答这个呼唤时需要的关键要素。

实现智能:缺失的要素

到目前为止,你已经了解到智能远不止是深度学习所做的潜在流形插值。但那么,我们需要开始构建真正的智能吗?目前正在逃避我们的核心部分是什么?

14.4.1 智能作为对抽象类比的敏感性

智能是利用你的过去经验(和先天知识)来应对新的、意想不到的未来情况的能力。如果你将要面对的未来 真的是新奇的——与你以前见过的任何东西都没有共同基础——那么无论你有多聪明,你都无法对其做出反应。

智能起作用是因为没有什么是真正没有先例的。当我们遇到新事物时,我们能够通过将其与过去的经验进行类比来理解它,通过用我们随时间收集的抽象概念来表达它。17 世纪的人第一次看到喷气飞机可能会描述它为一只大而吵闹的不拍动翅膀的金属鸟。汽车?那是一辆没有马的马车。如果你试图向小学生教授物理学,你可以解释电是如何像管道中的水,或者时空如何像橡皮片被重物扭曲。

除了这种明确的、明显的类比,我们每秒钟都在不断地进行更小的、隐含的类比。类比是我们生活的导航。去一个新的超市?你会通过将其与你去过的类似店铺联系起来找到路。与陌生人交谈?他们会让你想起你以前遇到过的几个人。甚至看似随机的模式,如云的形状,立即在我们心中唤起生动的形象——一只大象,一艘船,一条鱼。

这些类比不仅仅存在于我们的头脑中:物理现实本身充满了同构。电磁力类似于重力。动物们在结构上彼此相似,因为有着共同的起源。硅晶体类似于冰晶体。等等。

我称之为 万花筒假设:我们对世界的经验似乎充满了难以置信的复杂性和永无止境的新奇,但这片复杂的海洋中的一切都与其他一切相似。描述你所生活的宇宙所需的 独特意义的原子 数量相对较少,你周围的一切都是这些原子的重组,几颗种子,无尽的变化——就像在万花筒内发生的一样,少数玻璃珠被一套镜子反射,产生丰富、似乎不断变化的图案(参见 图 14.8)。

智力的泛化能力——智能——是利用你的经验来识别这些意义原子,它们似乎可以在许多不同的情况下重复使用的能力。一旦提取出来,它们被称为抽象。每当你遇到一个新情况,你都会通过你积累的抽象集合来理解它。你如何识别可重用的意义原子?只需注意到两个事物何时相似——通过注意类比。如果某事重复两次,那么两个实例必定有一个单一的起源,就像在万花筒中一样。抽象是智力的引擎,制作类比是产生抽象的引擎。

简而言之,智能实际上就是对抽象类比的敏感性,事实上就是这样。如果你对类比的敏感性很高,你将从少量经验中提取出强大的抽象,并能够使用这些抽象来在未来的经验空间中最大限度地操作。你将能够将过去的经验最大程度地转化为处理未来新奇事物的能力。

图片

图 14.8 万花筒仅用几颗彩色玻璃珠产生丰富(但重复)的图案。

14.4.2 抽象的两极

如果智能是对类比的敏感性,那么开发人工智能应该从阐明制作类比的逐步算法开始。制作类比始于将事物相互比较。关键是,有两种不同的方式比较事物,从中产生两种不同类型的抽象,两种思维模式,每一种都更适合不同类型的问题。这两极抽象共同构成了我们所有思想的基础。

将事物联系起来的第一种方式是相似性比较,这产生了以价值为中心的类比。第二种方式是确切的结构匹配,这产生了以程序为中心的类比(或结构为中心的类比)。在这两种情况下,你从事物的实例开始,并将相关实例合并在一起,以产生一个捕捉底层实例共同元素的抽象。变化的是你如何确定两个实例之间的关系,以及如何将实例合并成抽象。让我们仔细看看每种类型。

以价值为中心的类比

假设你在后院看到了许多不同种类的甲壳虫。你会注意到它们之间的相似之处。有些会彼此更相似,而有些则不太相似:相似性的概念在隐含地定义了一个平滑连续的距离函数,它定义了一个潜在的流形,你的实例就存在其中。一旦你看过足够多的甲壳虫,你就可以开始将更相似的实例聚集在一起,并将它们合并成一组原型,这些原型捕捉了每个聚类的共享视觉特征(参见图 14.9)。这个原型是抽象的:它看起来不像你见过的任何具体实例,尽管它编码了所有这些实例都共有的属性。当你遇到一个新的甲壳虫时,你不需要将它与之前见过的每一个甲壳虫进行比较,才知道该怎么做。你可以简单地将它与你手里的几个原型进行比较,以找到最接近的原型——甲壳虫的类别——并用它来进行有用的预测:这只甲壳虫可能会咬你吗?它会吃你的苹果吗?

图像

图 14.9 价值为中心的类比通过连续的相似性概念将实例关联起来,以获得抽象的原型。

这听起来耳熟能详吗?这基本上就是无监督机器学习(例如K均值聚类算法)所做的事情的描述。总的来说,现代机器学习,无论是有监督还是无监督的,都通过学习描述由原型编码的实例空间的潜在流形来工作。(还记得你在第九章中可视化的卷积网络特征吗?它们就是视觉原型。)价值为中心的类比是使深度学习模型能够执行局部概括的类比制作方式。

这也是你自己许多认知能力所依赖的。作为人类,你一直在进行价值为中心的类比。这是潜在的抽象的类型,它是模式识别感知直觉的基础。如果你可以在不经思考的情况下完成一项任务,那么你就是在大量依赖价值为中心的类比。如果你正在看电影,并且开始下意识地将不同的角色分类为“类型”,那就是价值为中心的抽象。

程序为中心的类比

至关重要的是,认知不仅仅是价值为中心的类比所能实现的那种即时的、近似的、直觉的分类。还有另一种生成抽象的机制,即较慢、精确、审慎的程序为中心(或结构为中心)类比。

在软件工程中,你经常编写不同的函数或类,它们看起来很相似。当你注意到这些冗余时,你开始问自己,“是否存在一个更抽象的函数,可以执行相同的任务,可以重复使用两次?是否存在一个抽象基类,可以由我的两个类继承?”这里定义的抽象化与程序为中心的类比相对应。你并不是试图通过类和函数之间有多相似来比较它们,就像你通过一个隐含的距离函数来比较两个人的脸一样。相反,你感兴趣的是它们是否有完全相同结构的部分。你正在寻找所谓的子图同构(见图 14.10):程序可以表示为操作符的图,你正在试图找到跨不同程序完全共享的子图(程序子集)。

图片

图 14.10 程序为中心的类比识别和隔离不同实例之间的同构子结构。

这种通过在不同的离散结构内进行精确结构匹配进行类比的方法,不是专业领域如计算机科学或数学所特有的——你经常在不知不觉中使用它。它是推理、规划和严谨性(与直觉相反)的一种基础。每当你思考由离散关系网络连接的对象时(而不是连续相似度函数),你正在利用程序为中心的类比。

认知是这两种抽象的结合

让我们并排比较这两种抽象的两极(见表 14.1)。

表 14.1 抽象的两极

以价值为中心的抽象 以程序为中心的抽象
通过距离关系相互关联 通过精确结构匹配相互关联
连续,以几何为基础 离散,以拓扑学为基础
通过将实例“平均”成“原型”来产生抽象 通过隔离异构子结构跨实例来产生抽象
基础感知和直觉 基础推理和规划
即时、模糊、近似 缓慢、精确、严谨
需要大量经验才能产生可靠结果 经验高效;可以在仅有两个实例的情况下操作

14.4.3 抽象化的两极

我们所做的一切,我们所思考的一切,都是这两种抽象类型的结合。你很难找到仅涉及其中一种的任务。即使是看似“纯粹感知”的任务,比如在场景中识别物体,也涉及对你所看到的物体之间关系的某种程度上的隐含推理。而即使是看似“纯推理”的任务,比如找到一个数学定理的证明,也涉及一定程度的直觉。当数学家将他们的笔放在纸上时,他们已经对他们要走的方向有了模糊的想法。他们采取的离散推理步骤是由高级直觉引导的。

这两个极端是互补的,它们的交织使得极端的泛化成为可能。没有一个思维可以完全没有它们。

14.4.4 这个问题的缺失之处

从这一点上,你应该开始看到现代深度学习中缺失的东西:它非常擅长编码以价值为中心的抽象,但基本上没有生成以程序为中心的抽象的能力。像人类一样的智能是这两种类型的紧密交织,所以我们实际上正在失去我们需要的一半-可以说是最重要的一半。

现在,这里有一个注意事项。到目前为止,我按照完全不同的方式呈现了每种抽象类型,甚至可以说是相反的方式。然而,在实践中,它们更像是一个连续体谱:在某种程度上,你可以通过嵌入离散程序到连续曲面中进行推理,就像你可以通过足够多的系数适应多项式函数来适应离散点集一样。反过来,你也可以使用离散程序来模拟连续距离函数-毕竟,当你在计算机上进行线性代数运算时,你完全是通过作用于一和零的离散程序来处理连续空间的。

然而,显然有些类型的问题更适合其中一个。例如,试着训练一个深度学习模型对一个由五个数字组成的列表进行排序。通过正确的架构,这并不是不可能,但这是一个令人沮丧的练习。你将需要大量的训练数据来做到这一点,即使如此,当模型面对新的数字时,它仍然会偶尔出错。如果你想开始对由 10 个数字组成的列表进行排序,你将需要在更多数据上完全重新训练模型。与此同时,在 R 中编写一个排序算法只需要几行代码,而且一经验证,在更多的示例上工作后,结果程序将可以成功地处理任何大小的列表。这是相当强大的泛化能力:从几个示例和测试示例到一个可以成功处理任何列表的程序。

相反,感知问题对于离散推理过程来说是非常糟糕的。尝试编写一个纯粹的 R 程序来分类 MNIST 数字,而不使用任何机器学习技术:你会遇到麻烦。你会发现自己辛苦地编写能够检测数字中闭合环数量、数字质心坐标等函数。经过数千行代码,你可能会获得……90% 的测试准确率。在这种情况下,拟合参数模型要简单得多;它可以更好地利用可用的大量数据,并且得到更加稳健的结果。如果你有大量数据,并且面临着一个适用于流形假设的问题,请选择深度学习。

出于这个原因,我们不太可能看到一种方法的崛起,将推理问题简化为流形插值,或将感知问题简化为离散推理。人工智能的前进方向是开发一个统一的框架,结合 两种 类型的抽象类比生成。让我们看看这可能是什么样子。

14.5 深度学习的未来

考虑到我们对深度网络的工作原理、它们的限制以及它们目前缺少的东西的了解,我们能否预测中期事物的发展方向呢?以下是一些纯粹个人的想法。请注意,我没有水晶球,所以我预期的许多事情可能无法变为现实。我分享这些预测,不是因为我希望它们在未来被证明完全正确,而是因为它们在现在是有趣的和可行的。

从高层次来看,这些是我认为有前景的主要方向:

  • 更接近通用计算程序的模型,构建在比当前的可微分层更丰富的基本单元之上。这是我们将达到推理和抽象的方式,而目前模型的基本弱点是缺乏这些。

  • 深度学习与程序空间上的离散搜索的融合,前者提供感知和直觉能力,后者提供推理和规划能力。

  • 更大规模、系统化地重复使用先前学习的特征和架构,比如使用可重用和模块化的程序子例程的元学习系统。

另外,请注意,这些考虑并不特定于到目前为止一直是深度学习的主要内容的监督学习类型——相反,它们适用于任何形式的机器学习,包括无监督、自监督和强化学习。你的标签来自何处或你的训练循环是什么样的并不是根本重要的;这些不同分支的机器学习是相同构建的不同方面。让我们深入探讨。

14.5.1 模型作为程序

正如前一节所指出的,我们可以期待的机器学习领域的一个必要的变革性发展是远离仅执行模式识别且只能实现局部泛化的模型,转向能够抽象和推理并能实现极端泛化的模型。当前能够进行基本形式推理的 AI 程序都是由人类程序员硬编码的:例如,依赖于搜索算法、图形操作和形式逻辑的软件。

这可能即将改变,多亏了程序合成——这是一个今天非常小众的领域,但我预计在未来几十年将大获成功。程序合成包括通过使用搜索算法(可能是遗传搜索,如遗传编程)自动生成简单程序来探索可能程序的大空间(见图 14.11)。当找到一个符合所需规格的程序时,搜索就会停止,通常以一组输入输出对提供。这非常类似于机器学习:给定作为输入输出对提供的训练数据,我们找到一个将输入与输出匹配并能推广到新输入的程序。不同之处在于,我们不是学习硬编码程序中的参数值(神经网络),而是通过离散搜索过程生成源代码(见表 14.2)。

Image

图 14.11 程序合成的示意图:给定一个程序规范和一组构建块,一个搜索过程将构建块组装成候选程序,然后将其与规范进行测试。搜索将继续,直到找到一个有效的程序。

表 14.2 机器学习与程序合成

机器学习 程序合成
模型:可微分参数函数 模型:来自编程语言的操作符图
引擎:梯度下降 引擎:离散搜索(例如遗传搜索)
需要大量数据才能产生可靠结果 数据高效;可以使用几个训练样本

14.5.2 机器学习与程序合成

程序合成是我们将如何向我们的人工智能系统添加以程序为中心的抽象能力。这是缺失的拼图。我之前提到深度学习技术在以推理为重点的 ARC 上完全无法使用。与此同时,非常简陋的程序合成方法已经在这一基准上产生了非常有前途的结果。

14.5.3 将深度学习与程序合成融合在一起

当然,深度学习并不会消失。程序合成不是它的替代品;它是它的补充。这是迄今为止缺失的我们人工大脑的半球。我们将结合使用这两者。这将以两种主要方式进行:

  1. 1 开发既包含深度学习模块又包含离散算法模块的系统

  2. 2 利用深度学习使程序搜索过程本身更加高效

让我们逐一审查这些可能的途径。

将深度学习模块和算法模块集成到混合系统中

如今,最强大的人工智能系统是混合型的:它们利用了深度学习模型和手工制作的符号操作程序。例如,在 DeepMind 的 AlphaGo 中,展示出的大部分智能是由人类程序员设计和硬编码的(例如蒙特卡洛树搜索)。从数据中学习只发生在专门的子模块中(价值网络和策略网络)。再比如自动驾驶汽车:自动驾驶汽车能够处理大量情况,因为它维护着周围世界的模型——一个字面上的三维模型——充满了人类工程师硬编码的假设。这个模型通过深度学习感知模块不断更新,将其与汽车周围的环境接口连接起来。

对于这两种系统——AlphaGo 和自动驾驶汽车——人类创建的离散程序和学习的连续模型的结合是解锁一种性能水平的关键,这种性能水平在单独使用任一方法时都是不可能的,例如端到端深度神经网络或没有机器学习元素的软件片段。到目前为止,这种混合系统的离散算法元素是由人类工程师费力地硬编码的。但在未来,这些系统可能会完全学习,没有人类参与。

这会是什么样子呢?考虑一种众所周知的网络类型:RNNs。重要的是要注意,RNNs 比前馈网络具有稍少的限制。这是因为 RNNs 不仅仅是几何转换:它们是几何转换for循环内重复应用的。时间上的 for 循环本身由人类开发人员硬编码:这是网络的内置假设。当然,RNNs 在它们可以表示的东西上仍然极其有限,主要是因为它们执行的每一步都是可微的几何转换,并且它们通过连续几何空间中的点(状态向量)在步骤之间携带信息。现在想象一下,一个神经网络以类似的方式通过编程基元增强,但是这个网络包括了一大堆的编程基元,模型可以自由操纵以扩展其处理功能,例如 if 分支、while 语句、变量创建、用于长期存储的磁盘存储、排序运算符和高级数据结构(如列表、图和哈希表)。这样一个网络可以表示的程序空间将远远超过目前深度学习模型可以表示的内容,其中一些程序可以达到更高的泛化能力。重要的是,这样的程序不会端对端地可微分,尽管特定的模块将保持可微分,因此需要通过离散程序搜索和梯度下降的组合来生成。

我们将摆脱一方面是硬编码的算法智能(手工制作的软件),另一方面是学习到的几何智能(深度学习)。相反,我们将拥有一种混合形式的正式算法模块,这些模块提供推理和抽象能力,并且提供非正式直觉和模式识别能力的几何模块(参见图 14.12)。整个系统将在很少或没有人类参与的情况下学习。这应该大大扩展了可以用机器学习解决的问题范围——在给定适当的训练数据的情况下,我们可以自动生成的程序空间。像 AlphaGo 这样的系统——甚至是 RNNs——可以被看作是这种混合算法-几何模型的史前祖先。

Image

图 14.12 依赖于几何基元(模式识别、直觉)和算法基元(推理、搜索、记忆)的学习程序

使用深度学习指导程序搜索

今天,程序合成面临着一个主要障碍:它的效率极低。夸张地说,程序合成通过尝试搜索空间中的每个可能的程序,直到找到一个与提供的规范匹配的程序来工作。随着程序规范的复杂性增加,或者随着用于编写程序的原语词汇的扩展,程序搜索过程会遇到所谓的组合爆炸,即要考虑的可能程序集增长得非常快——实际上远远快于指数增长。因此,今天,程序合成只能用于生成非常短的程序。你不会很快为你的计算机生成一个新的操作系统。

要取得进展,我们需要通过使程序合成更接近人类编写软件的方式来使程序合成变得高效。当你打开编辑器编写脚本时,你并不会考虑你可能潜在地编写的每个可能的程序。你只考虑了几种可能的方法:你可以利用你对问题的理解和过去的经验来大幅缩减可能要考虑的选项空间。

深度学习可以帮助程序合成实现同样的目标:尽管我们想生成的每个具体程序可能都是一种根本上离散的对象,执行非插值数据操作,但迄今的证据表明所有有用程序的空间可能看起来很像一个连续的流形。这意味着,一个经过数百万次成功程序生成情景训练的深度学习模型可能会开始对程序空间中的路径发展出坚实的直觉,以便搜索过程从规范到相应程序的过程中走出一条路线——就像软件工程师可能对他们即将编写的脚本的整体架构,以及在达到目标时应该使用的中间函数和类有直觉一样。

记住,人类的推理受到价值中心的抽象的重大指导,即模式识别和直觉。程序合成也应该如此。我预计通过学习启发程序搜索的一般方法将在未来 10 到 20 年内受到越来越多的研究关注。

14.5.4 终身学习和模块化子程序重用

如果模型变得更加复杂,并建立在更丰富的算法原语之上,这种增加的复杂性将需要更高的任务重用,而不是每次有新任务或新数据集时都从头开始训练一个新模型。许多数据集不包含足够的信息,让我们能够从头开发一个新的复杂模型,并且使用先前遇到的数据集的信息是必要的(就像你不会每次打开一本新书都从头学习英语一样——那是不可能的)。在每个新任务上从头开始训练模型也是低效的,因为当前任务与先前遇到的任务之间存在很大的重叠。

近年来已经多次做出了一个引人注目的观察:将相同模型训练以同时执行几个松散相关的任务会导致在每个任务上都更好的模型。例如,将同一神经机器翻译模型训练为执行英语到德语翻译和法语到意大利语翻译将产生一个在每种语言对上都更好的模型。类似地,联合训练图像分类模型和图像分割模型,共享相同的卷积基,会产生在两个任务上都更好的模型。这是相当直观的:看似不相关任务之间总是存在一些信息重叠,联合模型能够获取关于每个单独任务的更多信息。

当涉及跨任务重复使用模型时,我们目前使用预训练权重来执行常见功能的模型,例如视觉特征提取。你在第九章中看到了这一过程。未来,我期望这一概括性版本将变得司空见惯:我们将不仅使用先前学习到的特征(子模型权重),还将使用模型架构和训练程序。随着模型越来越像程序,我们将开始重用像人类编程语言中的函数和类那样的程序子例程

想象一下今天的软件开发过程:一旦工程师解决了一个特定问题(比如 HTTP 查询),他们将其打包成一个抽象的、可重用的库。未来面临类似问题的工程师将能够搜索现有的包,下载一个,并在自己的项目中使用它。同样地,未来,元学习系统将能够通过筛选全球高级可重用块的库来组装新的程序。当系统发现自己为几个不同任务开发类似的程序子例程时,它可以想出一个抽象的、可重用的子例程,并将其存储在全球库中(参见 图 14.13)。这些子例程可以是几何的(具有预训练表示的深度学习模块)或算法的(与当代软件工程师操作的库更接近)。

图片

图 14.13 一个元学习者能够快速开发使用可重复使用的基本部件(既有算法的又有几何的)的任务特定模型,从而实现极端泛化

14.5.5 长期愿景

简而言之,这是我对机器学习的长期愿景:

  • 模型将更像程序,并且具有远远超出我们当前处理的连续几何变换的输入数据的能力。这些程序可能会更接近于人类对周围环境和自身的抽象心理模型,并且由于其丰富的算法性质,它们将能够进行更强的泛化。

  • 特别是,模型将结合算法模块几何模块,提供形式推理、搜索和抽象能力与提供非正式直觉和模式识别能力。这将实现价值中心和程序中心抽象的融合。AlphaGo 或自动驾驶汽车(这些系统需要大量手动软件工程和人为设计决策)提供了这样一种符号和几何人工智能融合的早期示例。

  • 这样的模型将自动增长而不是由人类工程师硬编码,使用全球可重复使用子程序库中存储的模块化部件——一个通过学习上千个先前任务和数据集的高性能模型进化而来的库。随着元学习系统识别出频繁的问题解决模式,它们将被转换为可重复使用的子程序——就像软件工程中的函数和类一样——并添加到全球库中。

  • 搜索潜在子程序组合以生成新模型的过程将是一个离散搜索过程(程序合成),但它将受到由深度学习提供的一种程序空间直觉的严格指导。

  • 这个全局子程序库和相关的模型增长系统将能够实现某种形式的人类极端泛化:在给定新任务或情境的情况下,该系统将能够使用极少的数据组装一个适合任务的新工作模型,这要归功于良好泛化的丰富程序化原语以及对类似任务的广泛经验。同样,如果人类有很多先前游戏的经验,他们可以快速学会玩一个复杂的新视频游戏,因为从这些先前经验中派生出来的模型是抽象的和程序化的,而不是刺激和动作之间的基本映射。

  • 因此,这种不断学习的模型增长系统可以被解释为一种人工通用智能(AGI)。但不要期望会发生任何奇异论者的机器人启示录:那纯粹是幻想,源自对智能和技术的长期深刻误解。

14.6 在快速发展的领域保持最新

最后,我想给您一些关于如何在翻阅本书最后一页后继续学习和更新您的知识和技能的指引。现代深度学习领域,就我们今天所知,只有几年的历史,尽管其漫长而缓慢的前史可追溯几十年。自 2013 年以来,随着资金资源和研究人员数量的指数增长,整个领域现在正以疯狂的速度发展。您在本书中学到的内容不会永远保持相关性,并且并非您未来职业生涯所需的全部。

幸运的是,有大量免费在线资源可供您使用,以保持最新并拓展视野。以下是一些。

使用 Kaggle 在真实世界问题上进行练习

获得实际经验的有效方法是尝试在 Kaggle(kaggle.com)上进行机器学习比赛。学习的唯一真实方法是通过实践和实际编码——这是本书的理念,而 Kaggle 比赛是这一理念的自然延伸。在 Kaggle 上,您将找到一系列不断更新的数据科学竞赛,其中许多涉及深度学习,由一些公司准备,这些公司希望获得一些最具挑战性的机器学习问题的新解决方案。为排名靠前的参赛者提供相当可观的奖金。

大多数比赛使用 XGBoost 库(用于浅层机器学习)或 Keras(用于深度学习)获胜,所以您将会完全适应!通过参加一些比赛,也许作为团队的一部分,您将更加熟悉本书中描述的一些高级最佳实践的实际方面,特别是超参数调整,避免验证集过拟合和模型集成。

阅读 arXiv 上的最新发展

与其他一些科学领域相比,深度学习研究完全是公开进行的。论文一经完成就会公开并免费提供访问,而许多相关软件都是开源的。arXiv(arxiv.org)——发音为“archive”(X 代表希腊字母 chi)——是一个开放获取的物理、数学和计算机科学研究论文的预印本服务器。它已成为了解机器学习和深度学习最前沿的事实标准。绝大多数深度学习研究人员在完成论文后不久就会将其上传到 arXiv。这使他们能够立即宣布特定的发现,而不必等待会议接受(这需要数月时间),考虑到研究的快速节奏和领域中的激烈竞争,这是必要的。它还使得领域能够迅速发展:所有新发现都立即对所有人可见,并可以进行进一步的建立。

一个重要的缺点是,每天在 arXiv 上发布的论文数量庞大,甚至无法全部浏览,而且它们没有经过同行评议,这使得难以确定哪些是重要且高质量的。要在噪音中找到信号是具有挑战性的,并且越来越困难。但是一些工具可以帮助您:特别是,您可以使用 Google 学术 (scholar.google.com) 来追踪您喜欢的作者发表的论文。

14.6.3 探索 Keras 生态系统

截至 2021 年末,Keras 拥有超过一百万用户,并且仍在增长,拥有大量教程、指南和相关开源项目的生态系统:

14.7 结语

这就是《R 深度学习,第二版》的结尾。希望你对机器学习、深度学习、Keras,甚至通常的认知学到了一些东西。学习是一生的旅程,特别是在人工智能领域,我们手中的未知远远超过确定性。所以请继续学习、质疑和研究。永远不要停止!因为即使在取得了迄今为止的进步,人工智能中的大部分基本问题仍然没有答案。许多问题甚至还没有得到适当的提出。

  1. ¹ 理查德·费曼,采访,"另一种视角的世界",约克郡电视台,1972 年。

  2. ² 特里·维诺格拉德,“Procedures as a Representation for Data in a Computer Program for Understanding Natural Language”(1971 年)。

  3. ³ 《Fast Company》,"沃兹尼亚克:计算机能泡杯咖啡吗?"(2010 年 3 月),mng.bz/pJMP

  4. ⁴ 弗朗索瓦·肖莱,"关于智能的衡量"(2019 年),arxiv.org/abs/1911.01547

附录:R 用户的 Python 入门指南

你可能会想要阅读和理解一些 Python 代码,甚至将一些 Python 代码转换成 R。本指南旨在使您能够尽快完成这些任务。正如您将看到的那样,R 和 Python 是足够相似的,以至于可以在不必学习所有 Python 的情况下完成这些任务。我们从容器类型的基础知识开始,逐步深入到类、双下划线、迭代器协议、上下文协议等机制!

A.1 空白

在 Python 中,空白很重要。在 R 中,表达式通过 {} 分组成一个代码块。在 Python 中,通过使表达式共享缩进级别来完成。例如,具有 R 代码块的表达式可能是:

if (TRUE) {

cat("This is one expression. \n")

cat("This is another expression. \n")

}

Python 中的等价物:

if True:

print("This is one expression.")

print("This is another expression.")

Python 接受制表符或空格作为缩进间隔符,但当它们混合使用时,规则变得棘手。大多数样式指南建议(和 IDE 默认使用)只使用空格。

A.2 容器类型

在 R 中,list() 是一个您可以使用来组织 R 对象的容器。R 的 list() 功能齐全,没有一个单一的直接等价物在 Python 中支持所有相同的功能。相反,您需要了解 (至少) 四种不同的 Python 容器类型:列表、字典、元组和集合。

A.2.1 列表

Python 列表通常使用裸括号创建:[]。 (Python 内置的 list() 函数更像是一个强制转换函数,与 R 的 as.list() 的精神更接近)。关于 Python 列表最重要的一点是它们在原地修改。请注意在下面的示例中,y 反映了对 x 所做的更改,因为两个符号指向的底层列表对象是在原地修改的:

x = [1, 2, 3]

y = x➊

x.append(4)

print("x is", x)

x is [1, 2, 3, 4]

print("y is", y)

y is [1, 2, 3, 4]

现在 y 和 x 指向同一个列表!

R 用户可能关注的一个 Python 成语是通过 append() 方法增长列表。在 R 中增长列表通常很慢,最好避免。但是因为 Python 的列表在原地修改(并且在添加项时避免了列表的完全复制),所以在原地增长 Python 列表是有效的。

在 Python 列表周围的一些语法糖可能会遇到的情况是 + 和 * 的使用。这些是连接和复制运算符,类似于 R 的 c() 和 rep():

x = [1]

x

[1]

x + x

[1, 1]

x * 3

[1, 1, 1]

你可以使用尾随的 [] 来对列表进行索引,但请注意,索引是从 0 开始的:

x = [1, 2, 3]

x[0]

1

x[1]

2

x[2]

3

try:

x[3]

except Exception as e:

print(e)

列表索引超出范围

在索引时,负数从容器的末尾开始计数:

x = [1, 2, 3]

x[-1]

3

x[-2]

2

x[-3]

1

你可以在括号内使用冒号 (😃 对列表进行切片范围。请注意,切片语法不包含切片范围的结尾。您还可以选择指定步长:

x = [1, 2, 3, 4, 5, 6]

x[0:2]➊

[1, 2]

x[1:]➋

[2, 3, 4, 5, 6]

x[:-2]➌

[1, 2, 3, 4]

x[:]➍

[1, 2, 3, 4, 5, 6]

x[::2]➎

[1, 3, 5]

x[1::2]➏

[2, 4, 6]

获取索引位置为 0 和 1 的项,而不是 2。

获取索引位置为 1 到结尾的项。

获取从开头到倒数第二个的项。

获取所有项(这种习惯用法用于复制列表,以防止原地修改)。

获取所有项,步长为 2。

获取从索引 1 到结尾的所有项,步长为 2。

A.2.2 元组

元组的行为类似于列表,除了它们不可变,而且它们没有像 append() 这样的原地修改方法。它们通常使用裸 () 构造,但括号并不严格要求,你可能会看到一个隐式元组只是由逗号分隔的一系列表达式定义。因为括号也可以用于指定类似于 (x + 3) * 4 这样的表达式中的运算顺序,所以需要一种特殊的语法来定义长度为 1 的元组:尾随逗号。元组最常见的用法是在接受可变数量参数的函数中遇到:

x = (1, 2)

type(x)➊

<class 'tuple'>

len(x)

2

x

(1, 2)

x = (1,)➋

type(x)

<class 'tuple'>

len(x)

1

x

(1,)

x = ()➌

print(f"{type(x) = }; {len(x) = }; {x = }")➍

type(x) = <class 'tuple'>; len(x) = 0; x = ()

x = 1, 2➎

type(x)

<class 'tuple'>

len(x)

2

x = 1,➏

type(x)

<class 'tuple'>

len(x)

1

长度为 2 的元组

长度为 1 的元组

长度为 0 的元组

插值字符串文字的示例。你可以使用 glue::glue() 在 R 中进行字符串插值。

同样是一个元组

注意单个尾随逗号!这是一个元组!

打包和解包

元组是 Python 中 打包解包 语义的容器。Python 提供了在一个表达式中允许你赋值多个符号的便利。这被称为 解包

例如:

x = (1, 2, 3)

a, b, c = x

a

1

b

2

c

3

你可以使用 zeallot::%<-% 从 R 中访问类似的解包行为。

元组解包可以发生在各种情境下,比如迭代:

xx = (("a", 1),

("b", 2))

for x1, x2 in xx:

print("x1 =", x1)

print("x2 =", x2)

x1 = a

x2 = 1

x1 = b

x2 = 2

如果你尝试将容器解包为错误数量的符号,Python 就会引发一个错误:

x = (1, 2, 3)

a, b, c = x➊

a, b = x➋

在 py_call_impl(callable, dots\(args, dots\)keywords) 中出错:

图像 ValueError: 太多值要解包(期望 2 个)

a, b, c, d = x➋

在 py_call_impl(callable, dots\(args, dots\)keywords) 中出错:

图像 ValueError: 没有足够的值来解包(期望 4 个,得到 3 个)

成功

错误:x 的值太多,无法解包。

错误:x 的值不足以解包。

可以解包可变数量的参数,使用 * 作为符号的前缀(当我们谈论函数时,我们将再次看到 * 前缀):

x = (1, 2, 3)

a, *the_rest = x

a

1

the_rest

[2, 3]

你还可以解包嵌套结构:

x = ((1, 2), (3, 4))

(a, b), (c, d) = x

A.2.3 字典(Dictionaries)

字典(Dictionaries)与 R 的环境最相似。它们是一个容器,您可以通过名称检索项目,尽管在 Python 中名称(在 Python 的术语中称为key)不像在 R 中一样需要是字符串。它可以是具有 hash() 方法的任何 Python 对象(意味着它可以是几乎任何 Python 对象)。它们可以使用 {key: value} 这样的语法创建。与 Python 列表一样,它们是就地修改的。请注意,reticulate::r_to_py() 将 R 命名列表转换为字典:

d = {"key1": 1,

"key2": 2}

d2 = d

d

{'key1': 1, 'key2': 2}

d["key1"]

1

d["key3"] = 3

d2➊

{'key1': 1, 'key2': 2, 'key3': 3}

就地修改!

与 R 的环境不同(而不像 R 的命名列表),您不能使用整数索引来从字典中获取特定索引位置的项。字典是无序容器(但是,从 Python 3.7 开始,字典会保留项目插入顺序):

d = {"key1": 1, "key2": 2}

d[1]➊

在 py_call_impl(callable, dots\(args, dots\)keywords) 中出现错误:KeyError: 1

错误:整数 "1" 不是字典中的键之一。

与 R 的命名列表语义最接近的容器是 OrderedDict (mng.bz/7y5m),但在 Python 代码中相对不常见,因此我们不再进一步介绍它。

A.2.4 集合(Sets)

集合(Sets)是一个容器,可以用来有效地跟踪唯一项或去重列表。它们使用 {val1, val2} 构造(类似于字典,但没有 :)。将它们视为只使用键的字典。集合有许多高效的成员操作方法,如 intersection()、issubset()、union() 等:

s = {1, 2, 3}

类型(type)

<class 'set'>

s

{1, 2, 3}

s.add(1)

s

{1, 2, 3}

A.3 使用 for 进行迭代

Python 中的 for 语句可用于遍历任何类型的容器:

for x in [1, 2, 3]:

print(x)

1

2

3

相比之下,R 具有相对有限的可以传递给 for 的对象集合。Python 则提供了迭代器协议接口,这意味着作者可以定义自定义对象,其行为由 for 调用(我们将在讨论类时有一个定义自定义可迭代对象的示例)。您可能希望使用 reticulate 从 R 使用 Python 可迭代对象,因此将语法糖稍微撕开一点,以显示 for 语句在 Python 中的工作原理,以及如何手动遍历它,这将会很有帮助。

发生了两件事:首先,从提供的对象构造了一个迭代器。然后,新的迭代器对象将重复调用 next(),直到耗尽为止:

l = [1, 2, 3]

it = iter(l)➊

it

<list_iterator object at 0x7f5e30fbd190>

创建一个迭代器对象。

调用 next() 来遍历迭代器,直到迭代器耗尽为止:

next(it)

1

next(it)

2

next(it)

3

next(it)

在 py_call_impl(callable, dots\(args, dots\)keywords) 中出现错误:StopIteration

在 R 中,您可以使用 reticulate 以相同的方式遍历迭代器:

library(reticulate)

l <- r_to_py(list(1, 2, 3))

it <- as_iterator(l)

iter_next(it)

1.0

iter_next(it)

2.0

iter_next(it)

3.0

iter_next(it, completed = "StopIteration")

[1] "StopIteration"

遍历字典首先需要理解你是在遍历键、值还是两者都在。字典有允许你指定的方法:

d = {"key1": 1, "key2": 2}

for key in d:

print(key)

key1

key2

for value in d.values():

print(value)

1

2

for key, value in d.items():

print(key, ":", value)

key1 : 1

key2 : 2

A.3.1 Comprehensions

推导式是特殊的语法,允许你构建类似列表或字典的容器,同时在每个元素上执行一个小操作或单个表达式。你可以把它看作是 R 中 lapply 的特殊语法。例如:

x = [1, 2, 3]

l = [element + 100 for element in x]

l

[101, 102, 103]

d = {str(element) : element + 100}

for element in x}

d

{'1': 101, '2': 102, '3': 103}

从 x 构建的列表推导式,其中每个元素加 100

从 x 构建的字典推导式,其中键是一个字符串。Python 的 str()类似于 R 的 as.character()。

A.4 使用 def 定义函数

Python 函数使用 def 语句定义。指定函数参数和默认参数值的语法与 R 非常相似:

def my_function(name = "World"):

print("Hello", name)

my_function()

Hello World

my_function("Friend")

你好,朋友

等效的 R 片段将是:

my_function <- function(name = "World") {

cat("Hello", name, "\n")

}

my_function()

Hello World

my_function("Friend")

你好,朋友

与 R 函数不同,函数中的最后一个值不会自动返回。Python 需要一个明确的 return 语句:

def fn():

1

print(fn())

None

def fn():

返回 1

print(fn())

1

注意 对于高级 R 用户,Python 没有 R 的参数“promises”的等价物。函数参数默认值在函数构造时只计算一次。如果你将一个可变对象作为默认参数值定义为 Python 函数,这可能会让人感到惊讶!

def my_func(x = []):

x.append("was called")

print(x)

my_func()

my_func()

my_func()

['was called']

['was called', 'was called']

['was called', 'was called', 'was called']

你也可以定义 Python 函数,它接受可变数量的参数,类似于 R 中的…

def my_func(*args, **kwargs):

print("args =", args)

print("kwargs =", kwargs)

my_func(1, 2, 3, a = 4, b = 5, c = 6)

args = (1, 2, 3)

kwargs = {'a': 4, 'b': 5, 'c': 6}

args 是一个元组。

kwargs 是一个字典。

在函数定义签名中,和** 打包参数,而在函数调用中,它们解包*参数。在函数调用中解包参数等同于在 R 中使用 do.call():

def my_func(a, b, c):

print(a, b, c)

args = (1, 2, 3)

my_func(*args)

1 2 3

kwargs = {"a": 1, "b": 2, "c": 3}

my_func(**kwargs)

1 2 3

A.5 使用 class 定义类

有人可能会争论,在 R 中,代码的主要组成单位是函数,在 Python 中,它是类。你可以成为一个非常高效的 R 用户,而从不使用 R6、引用类或类似的 R 等价物来实现 Python 类的面向对象风格。

然而,在 Python 中,理解类对象如何工作的基础知识是必不可少的,因为类是你如何组织和查找 Python 方法的方式(与 R 的方法相比,在 R 中,方法是通过从通用方法分派来找到的)。幸运的是,类的基础知识是可以理解的。

如果这是您第一次接触面向对象编程,不要感到 intimidated。我们将从构建一个简单的 Python 类开始作为演示:

class MyClass:

pass➊

MyClass

<class 'main.MyClass'>

type(MyClass)

<class 'type'>

instance = MyClass()

instance

<main.MyClass object at 0x7f5e30fc7790>

type(instance)

<class 'main.MyClass'>

pass 意味着什么都不做。

类似于 def 语句,class 语句绑定了一个新的可调用符号,MyClass。首先注意到强命名约定:类通常是 CamelCase,函数通常是 snake_case。在定义 MyClass 之后,你可以与之交互,并且看到它的类型为‘type’。调用 MyClass()创建了一个类的新对象instance,它的类型是‘MyClass’(现在忽略 main.前缀)。实例打印出其内存地址,这是一个强烈的暗示,表明通常会管理许多类的实例,并且该实例是可变的(默认情况下是就地修改的)。

在第一个例子中,我们定义了一个空类,但当我们检查它时,我们会发现它已经带有一堆属性(在 Python 中,dir()等同于 R 中的 names()):

dir(MyClass)

['class', 'delattr', 'dict', 'dir', 'doc', 'eq',

'format', 'ge', 'getattribute', 'gt', 'hash', 'init',

'init_subclass', 'le', 'lt', 'module', 'ne', 'new',

'reduce', 'reduce_ex', 'repr', 'setattr', 'sizeof',

'str', 'subclasshook', 'weakref']

A.5.1 所有下划线都是什么?

Python 通常通过双下划线包裹名称来表示某些特殊性,而常见的双下划线包裹的标记通常被称为dunder。“特殊”不是一个技术术语;它只是表示该标记调用了 Python 语言的一个特性。一些 dunder 标记仅仅是代码作者可以插入特定语法糖的方式;其他的是解释器提供的值,否则可能很难获得;还有一些是用于扩展语言接口(例如,迭代协议);最后,少数一小部分 dunder 真的很难理解。幸运的是,作为一个希望通过 reticulate 使用一些 Python 特性的 R 用户,你只需要了解一些易于理解的 dunder。

阅读 Python 代码时最常见的特殊方法是 init()。这是一个在调用类构造函数时调用的函数,也就是在类被实例化时。它用于初始化新的类实例。(在非常复杂的代码库中,您可能还会遇到定义了 new() 的类;这是在调用 init() 之前调用的。)

class MyClass:

print("MyClass 的定义主体正在被评估")➊

def init(self):

print(self, "正在初始化")

MyClass 的定义主体正在被评估

instance = MyClass()

<main.MyClass object at 0x7f5e30fcafd0> 正在初始化➋

print(instance)

<main.MyClass object at 0x7f5e30fcafd0>➋

instance2 = MyClass()

<main.MyClass object at 0x7f5e30fc7790> 正在初始化➌

print(instance2)

<main.MyClass object at 0x7f5e30fc7790>➌

注意这是在类第一次被定义时评估的。

注意instanceselfinit() 方法中的相同内存地址。

新实例,新内存地址

请注意以下几点:

  • class 语句采用由共同缩进级别定义的代码块。代码块与任何其他接受代码块的表达式具有完全相同的语义,如 if 和 def。类的主体仅在第一次创建类构造函数时被评估一次。请注意,此处定义的任何对象都将由类的所有实例共享!

  • init() 只是一个普通的函数,使用 def 定义,与任何其他函数一样,只是在类主体内部定义。

  • init() 接受一个参数:self。self 是被初始化的类实例(注意 self 和实例之间的内存地址相同)。还要注意,当调用 MyClass() 创建类实例时,我们没有提供 self;语言会将 self 插入到函数调用中。

  • 每次创建新实例时都会调用 init()。

在类代码块中定义的函数称为方法,方法的重要之处在于每次从类实例中调用它们时,实例都会作为第一个参数插入到函数调用中。这适用于类中定义的所有函数,包括特殊方法。唯一的例外是,如果函数被装饰为 @classmethod 或 @staticmethod:

class MyClass:

def a_method(self):

print("MyClass.a_method() 被调用时使用了", self)

instance = MyClass()

instance.a_method()

MyClass.a_method() 被调用时使用了<main.MyClass object at 0x7f5e30fcadf0>:

MyClass.a_method()➊

在 py_call_impl(callable, dots\(args, dots\)keywords) 中出现错误:

Image TypeError: a_method() 缺少 1 个必需的位置参数:'self'

MyClass.a_method(instance)➋

MyClass.a_method() 被调用时使用了<main.MyClass object at 0x7f5e30fcadf0>

错误:缺少必需的参数 self

与 instance.a_method() 相同

其他值得了解的特殊方法有:

  • getitem—提取切片时调用的函数(相当于在 R 中定义 S3 方法)。

  • getattr—使用.访问属性时调用的函数(相当于在 R 中定义$ S3 方法)。

  • iternext—由 for 循环调用的函数。

  • call—当类实例被像函数一样调用时调用(例如,instance())。

  • bool—由 if 和 while 调用(相当于 as.logical()在 R 中,但只返回标量,而不是向量)。

  • reprstr—用于格式化和漂亮打印的函数(类似于 R 中的 format()、dput()和 print()方法)。

  • enterexit—由 with 语句调用的函数。

  • 许多内置的 Python 函数只是调用 dunder 的语法糖。例如,调用 repr(x)与 x.repr()是相同的(参见docs.python.org/3/library/functions.html)。其他的内置函数,比如 next()、iter()、str()、list()、dict()、bool()、dir()、hash()等等,都是调用 dunder 的语法糖!

A.5.2 迭代器,重新审视

现在我们已经掌握了类的基础知识,是时候重新审视迭代器了。首先,一些术语:

  • 可迭代对象—可以被迭代的东西。具体来说,是定义了一个 iter 方法的类,其作用是返回一个迭代器

  • 迭代器—一种进行迭代的东西。具体来说,是定义了一个 next 方法的类,其作用是每次调用时返回下一个元素,然后在耗尽时引发 StopIteration 异常。常见的情况是看到既是可迭代对象又是迭代器的类,其中 iter 方法只是一个返回 self 的存根。这里是 Python 中 range()的自定义可迭代/迭代器实现(类似于 R 中的 seq()):

class MyRange:

def init(self, start, end):

self.start = start

self.end = end

def iter(self):

self._index = self.start - 1➊

return self

def next(self):

if self._index < self.end:

self._index += 1➋

return self._index

else:

raise StopIteration

for x in MyRange(1, 3):

print(x)

1

2

3

重置我们的计数器。

➋递增 1。

手动执行 for 循环的操作:

r = MyRange(1, 3)

it = iter(r)

next(it)

1

next(it)

2

next(it)

3

next(it)

在 py_call_impl(callable, dots\(args, dots\)keywords)中的错误:StopIteration

A.6 使用 yield 定义生成器

生成器是特殊的 Python 函数,其中包含一个或多个 yield 语句。只要在传递给 def 的代码块中包含 yield,语义就会发生根本变化。您不再只是定义一个普通的函数,而是一个生成器构造函数!反过来,调用生成器构造函数会创建一个生成器对象,它只是另一种类型的迭代器。这里有一个例子:

def my_generator_constructor():

yield 1

yield 2

yield 3

乍一看,它看起来像一个普通的函数:

my_generator_constructor

<function my_generator_constructor at 0x7f5e30fab670>

type(my_generator_constructor)

<class 'function'>

但是调用它会返回一些特殊的东西,一个生成器对象

my_generator = my_generator_constructor()

my_generator

<generator object my_generator_constructor at 0x7f5e3ca52820>

type(my_generator)

<class 'generator'>

生成器对象既是可迭代的,也是迭代器。它的 iter 方法只是返回 self 的一个存根:

iter(my_generator) == my_generator == my_generator.iter()

True

像任何其他迭代器一样逐步执行它:

next(my_generator)

1

my_generator.next()➊

2

next(my_generator)

3

next(my_generator)

py_call_impl(callable, dots\(args, dots\)keywords)中的错误:StopIteration

next(x) is just sugar for calling the dunder x.next().

遇到 yield 就像按下函数执行的暂停按钮:它保留函数体中的所有状态,并将控制返回给迭代生成器对象的任何东西。对生成器对象调用 next()会恢复函数体的执行,直到下一个 yield 被遇到或函数完成。您可以使用 coro::generator()在 R 中创建生成器。

A.7 迭代结束语

迭代在 Python 语言中已经深深融入,R 用户可能会对 Python 中的事物是可迭代的、迭代器或者在幕后由迭代器协议支持的方式感到惊讶。例如,内置的 map()(相当于 R 的 lapply())产生一个迭代器,而不是一个列表。类似地,像(elem for elem in x)的元组推导式会产生一个迭代器。大多数涉及文件的功能都是迭代器。

每当您发现一个迭代器不方便时,您可以使用 Python 内置的 list()或 R 中的 reticulate::iterate()将所有元素材化为列表。此外,如果您喜欢 for 的可读性,您可以使用类似 Python 的 for 的语义来利用 coro::loop()。

A.8 导入和模块

在 R 中,作者可以将其代码捆绑成可共享的扩展,称为 R 包,而 R 用户可以通过 library()或::访问 R 包中的对象。在 Python 中,作者将代码捆绑成模块,用户使用 import 来访问模块。考虑以下行:

import numpy

这个语句让 Python 去文件系统找到一个名为 numpy 的已安装 Python 模块,加载它(通常意味着:评估其 init.py 文件并构造一个模块类型对象),并将其绑定到符号 numpy。在 R 中,这个最接近的相当于可能是:

dplyr <- loadNamespace("dplyr")

A.8.1 模块存储在哪里?

在 Python 中,模块搜索的文件系统位置可以从 sys.path 找到并(修改)。这相当于 R 的.lib-Paths()。sys.path 通常包含当前工作目录的路径,包含内置标准库的 Python 安装路径,管理员安装的模块,用户安装的模块,像 PYTHONPATH 这样的环境变量的值,以及当前 Python 会话中其他代码直接对 sys.path 进行的任何修改(尽管在实践中这相对较少见):

import sys

sys.path

['',➊

'/home/tomasz/.pyenv/versions/3.9.6/bin',

'/home/tomasz/.pyenv/versions/3.9.6/lib/python39.zip',

'/home/tomasz/.pyenv/versions/3.9.6/lib/python3.9',

'/home/tomasz/.pyenv/versions/3.9.6/lib/python3.9/lib-dynload',➋

'/home/tomasz/.virtualenvs/r-reticulate/lib/python3.9/site-packages',➌

'/home/tomasz/opt/R-4.1.2/lib/R/site-library/reticulate/python',➍

'/home/tomasz/.virtualenvs/r-reticulate/lib/python39.zip',

'/home/tomasz/.virtualenvs/r-reticulate/lib/python3.9',

'/home/tomasz/.virtualenvs/r-reticulate/lib/python3.9/lib-dynload']➎

当前目录通常位于模块搜索路径上。

Python 标准库和内建函数

reticulate 代理

其他安装的 Python 包(例如,通过 pip 安装)

更多的标准库和内建函数,这次来自虚拟环境

你可以通过访问 dunder pathfile(在排除安装问题时特别有用)来查看模块是从哪里加载的:

import os

os.file

'/home/tomasz/.pyenv/versions/3.9.6/lib/python3.9/os.py'➊

numpy.path

['/home/tomasz/.virtualenvs/r-reticulate/lib/python3.9/site-packages/numpy']➋

os 模块在这里定义。它只是一个普通的文本文件;看一眼吧!

我们导入的 numpy 模块在这里定义。它是一个有很多内容的目录;浏览一下!

一旦加载了模块,就可以使用 .(相当于::,或者可能是 $.environment,在 R 中)访问模块中的符号:

numpy.abs(-1)

1

还有一种特殊的语法来指定模块在导入时绑定到的符号,以及仅导入一些特定的符号:

import numpy➊

import numpy as np➋

np 是 numpy➌

从 numpy 导入 abs➍

abs 是 numpy.abs➎

从 numpy 导入 abs 作为 abs2➏

abs2 是 numpy.abs➐

导入并绑定到符号 'numpy'。

导入并绑定到自定义符号 'np'。

测试是否相同,类似于 R 的 identical(np, numpy)。返回 True。

仅导入 numpy.abs,并绑定到 abs。

True

仅导入 numpy.abs,并绑定到 abs2。

True

如果你正在寻找 R 的 library() 的 Python 等效,它使包的所有导出符号都可用,可能会使用 import 与 * 通配符,尽管这样做相对较少见。* 通配符将扩展为包含模块中的所有符号,或者如果定义了 all,则包含在其中列出的所有符号:

from numpy import *

Python 不像 R 那样区分包导出和内部符号。在 Python 中,所有模块符号都是平等的,尽管有一种命名约定,即打算为内部符号的符号以单个下划线作为前缀。(两个前导下划线会调用一个称为“名称修饰”的高级语言特性,这超出了本介绍的范围。)

如果你正在寻找 Python 中与 import 语法等效的 R 语法,你可以像这样使用 envir::import_from():

library(envir)

import_from(keras::keras\(applications\)efficientnet,

decode_predictions, preprocess_input,

new_model = EfficientNetB4)

model <- new_model(include_top = TRUE, weights='imagenet')

predictions <- input_data %>%

preprocess_input()

%>% predict(model, .) %>%

decode_predictions()

A.9 整数和浮点数

R 用户通常不需要了解整数和浮点数之间的区别,但在 Python 中情况并非如此。如果这是你第一次接触数字数据类型,以下是必备知识:

  • 整数类型只能表示像 2 或 3 这样的整数,不能表示像 2.3 这样的浮点数。

  • 浮点类型可以表示任何数字,但存在一定程度的不精确性

在 R 中,像 3 这样写一个裸的文字数会产生一个浮点类型,而在 Python 中,它会产生一个整数。你可以通过在 R 中附加一个 L 来产生一个整数文字,例如 3L。许多 Python 函数期望整数,并在提供浮点数时发出错误。例如,假设我们有一个期望整数的 Python 函数:

def a_strict_Python_function(x):

assert isinstance(x, int), "x 不是整数"

print("耶!x 是一个整数")

当从 R 中调用时,必须确保以整数形式调用:

library(reticulate)

py$a_strict_Python_function(3)➊

py$a_strict_Python_function(3L)

py$a_strict_Python_function(as.integer(3))➋

错误:"AssertionError: x 不是整数"

成功

A.10 R 向量怎么办?

R 是一个以数值计算为首要目标的语言。数值向量数据类型已经深深地融入到 R 语言中,以至于语言甚至不区分标量和向量。相比之下,Python 中的数值计算能力通常由第三方包(在 Python 术语中称为模块)提供。

在 Python 中,numpy 模块通常用于处理数据的连续数组。与 R 数值向量最接近的等价物是 1D NumPy 数组,有时是标量数值的列表(一些 Python 爱好者可能会认为这里应该使用 array.array(),但实际 Python 代码中很少遇到,因此我们不再进一步讨论)。

NumPy 数组与 TensorFlow 张量非常相似。例如,它们共享相同的广播语义和非常相似的索引行为。NumPy API 非常广泛,教授完整的 NumPy 接口超出了本入门教程的范围。然而,值得指出一些对习惯于 R 数组的用户可能构成潜在绊脚石的地方:

  • 当对多维 NumPy 数组进行索引时,可以省略尾部维度,并且会被隐式地视为缺失。其结果是对数组进行迭代意味着对第一维进行迭代。例如,这会对矩阵的行进行迭代

import numpy as np

m = np.arange(12).reshape((3,4))

m

array([[ 0, 1, 2, 3],

[ 4, 5, 6, 7],

[ 8, 9, 10, 11]])

m[0, :]➊

array([0, 1, 2, 3])

m[0]➋

array([0, 1, 2, 3])

for row in m:

print(row)

[0 1 2 3]

[4 5 6 7]

[ 8 9 10 11]

第一行

也是第一行

  • 许多 NumPy 操作会直接修改数组!这让 R 用户(和 TensorFlow 用户)感到惊讶,他们习惯于 R(和 TensorFlow)的按需复制语义的便利性和安全性。不幸的是,没有简单的方案或命名约定可以依赖于快速确定特定方法是直接修改还是创建新数组副本。唯一可靠的方法是查阅文档(请参阅 mng.bz/mORP),并在 reticulate::repl_python() 中进行小实验。

A.11 装饰器

装饰器只是接受一个函数作为参数并通常返回另一个函数的函数。任何函数都可以使用 @ 语法调用装饰器,这只是这个简单动作的语法糖:

def my_decorator(func):

func.x = "一个装饰器通过添加属性 x 修改了这个函数"

return func

@my_decorator

def my_function(): pass

def my_function(): pass

my_function = my_decorator(my_function)➊

@decorator 只是这一行的花哨语法。

你可能经常遇到的一个装饰器是 @property,当访问属性时自动调用类方法(类似于 R 中的 makeActiveBinding()):

from datetime import datetime

class MyClass:

@property

def a_property(self):

return f"a_property was accessed at {datetime.now().strftime('%X')}"

instance = MyClass()

instance.a_property

'a_property was accessed at 10:01:53 AM'

你可以使用 %<-active%(或 mark_active())将 Python 的 @property 翻译为 R,就像这样:

import_from(glue, glue)

MyClass %py_class% {

a_property %<-active% function()

glue("a_property was accessed at {format(Sys.time(), '%X')}")

}

instance <- MyClass()

instance$a_property

[1] "a_property was accessed at 10:01:53 AM"

Sys.sleep(1)

instance$a_property

[1] "a_property was accessed at 10:01:54 AM"

A.12 with 和上下文管理

任何定义了 enterexit 方法的对象都实现了“上下文”协议,并且可以传递给 with。例如,这里是一个自定义的上下文管理器的实现,它临时更改了当前工作目录(相当于 R 的 withr::with_dir()):

from os import getcwd, chdir

class wd_context:

def init(self, wd):

self.new_wd = wd

def enter(self):

self.original_wd = getcwd()

chdir(self.new_wd)

def exit(self, *args):➊

chdir(self.original_wd)

getcwd()

'/home/tomasz/deep-learning-w-R-v2/manuscript'

with wd_context("/tmp"):

print("在上下文中,wd 是:", getcwd())

在上下文中,wd 是: /tmp

getcwd()

'/home/tomasz/deep-learning-w-R-v2/manuscript'

exit 接受一些通常被忽略的附加参数。

A.13 进一步学习

希望这篇关于 Python 的简短入门对于自信地阅读 Python 文档和代码,以及通过 reticulate 从 R 使用 Python 模块提供了良好的基础。当然,关于 Python 还有很多,很多需要学习的地方。在谷歌上搜索有关 Python 的问题可靠地会出现大量结果页面,但并不总是按照最有用的顺序排序。针对初学者的博客文章和教程可能很有价值,但请记住 Python 的官方文档通常是非常出色的,当您有问题时它应该是您的首选目的地:

要更全面地学习 Python,内置的官方教程也是非常出色和全面的(但需要投入一定的时间来获得价值):docs.Python.org/3/tutorial/index.html

最后,请不要忘记通过在 reticulate::repl_python()中进行小型实验来巩固您的理解。

谢谢您的阅读!


  1. 1 ↩︎

posted @ 2025-11-19 09:21  绝不原创的飞龙  阅读(21)  评论(0)    收藏  举报