Python-深度学习第二版-GPT-重译--全-
Python 深度学习第二版(GPT 重译)(全)
前言
序言
如果你拿起这本书,你可能已经意识到深度学习在最近对人工智能领域所代表的非凡进步。我们从几乎无法使用的计算机视觉和自然语言处理发展到了在你每天使用的产品中大规模部署的高性能系统。这一突然进步的后果几乎影响到了每一个行业。我们已经将深度学习应用于几乎每个领域的重要问题,跨越了医学影像、农业、自动驾驶、教育、灾害预防和制造等不同领域。
然而,我认为深度学习仍处于早期阶段。到目前为止,它只实现了其潜力的一小部分。随着时间的推移,它将走向能够帮助的每一个问题——这是一个需要多年时间才能实现的转变。
为了开始将深度学习技术应用于它能解决的每一个问题,我们需要让尽可能多的人能够访问它,包括非专家——那些不是研究人员或研究生的人。为了使深度学习发挥其全部潜力,我们需要彻底实现其民主化。如今,我相信我们正处于一个历史性转变的关键时刻,深度学习正在从学术实验室和大型科技公司的研发部门走向成为每个开发者工具箱中不可或缺的一部分——不同于上世纪 90 年代末网页开发的轨迹。现在几乎任何人都可以建立一个网站或网络应用程序,而在 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ć 等许多在幕后工作的人。
特别感谢技术同行审阅者:Billy O’Callaghan、Christian Weisstanner、Conrad Taylor、Daniela Zapata Riesco、David Jacobs、Edmon Begoli、Edmund Ronald 博士、Hao Liu、Jared Duncan、Kee Nam、Ken Fricklas、Kjell Jansson、Milan Šarenac、Nguyen Cao、Nikos Kanakaris、Oliver Korten、Raushan Jha、Sayak Paul、Sergio Govoni、Shashank Polasa、Todd Cook 以及 Viton Vitanis,以及所有其他向我们反馈书稿的人。
在技术方面,特别感谢担任本书技术编辑的 Frances Buontempo,以及担任本书技术校对的 Karsten Strøbæk。
关于这本书
这本书是为任何希望从零开始探索深度学习或扩展对深度学习理解的人而写的。无论你是实践中的机器学习工程师、软件开发人员还是大学生,你都会在这些页面中找到价值。
你将以一种易于理解的方式探索深度学习——从简单开始,然后逐步掌握最先进的技术。你会发现这本书在直觉、理论和实践之间取得了平衡。它避免使用数学符号,而是更倾向于通过详细的代码片段和直观的心智模型解释机器学习和深度学习的核心思想。你将从丰富的代码示例中学习,其中包括广泛的评论、实用建议以及关于开始使用深度学习解决具体问题所需了解的一切的简单高层解释。
代码示例使用 Python 深度学习框架 Keras,以 TensorFlow 2 作为其数值引擎。它们展示了截至 2021 年的现代 Keras 和 TensorFlow 2 最佳实践。
阅读完这本书后,你将对深度学习是什么、何时适用以及其局限性有扎实的理解。你将熟悉处理和解决机器学习问题的标准工作流程,并且知道如何解决常见问题。你将能够使用 Keras 处理从计算机视觉到自然语言处理的真实问题:图像分类、图像分割、时间序列预测、文本分类、机器翻译、文本生成等等。
谁应该阅读这本书
这本书是为具有 Python 编程经验且想开始学习机器学习和深度学习的人而写的。但这本书也对许多不同类型的读者有价值:
-
如果您是熟悉机器学习的数据科学家,本书将为您提供对深度学习的坚实、实用的介绍,这是机器学习中增长最快、最重要的子领域。
-
如果您是一名深度学习研究人员或从业者,希望开始使用 Keras 框架,您会发现本书是理想的 Keras 入门课程。
-
如果您是在正式环境中学习深度学习的研究生,您会发现本书是对您教育的实用补充,帮助您建立对深度神经网络行为的直觉,并让您熟悉关键的最佳实践。
即使是不经常编码的技术人员,也会发现本书对基本和高级深度学习概念的介绍很有用。
为了理解代码示例,您需要具备合理的 Python 熟练程度。此外,熟悉 NumPy 库将会有所帮助,尽管不是必需的。您不需要有机器学习或深度学习的先前经验:本书从头开始覆盖了所有必要的基础知识。您也不需要有高级数学背景,高中水平的数学知识应该足以让您跟上。
关于代码
本书包含许多源代码示例,既在编号列表中,也在普通文本中。在这两种情况下,源代码都以固定宽度 字体 的形式 呈现,以将其与普通文本分开。
在许多情况下,原始源代码已经被重新格式化;我们添加了换行符并重新调整了缩进以适应书中可用的页面空间。此外,在文本中描述代码时,源代码中的注释通常会从列表中删除。代码注释伴随着许多列表,突出显示重要概念。
本书中的所有代码示例都可以从 Manning 网站 www.manning.com/books/deep-learning-with-python-second-edition 获取,并且作为 Jupyter notebooks 在 GitHub github.com/fchollet/deep-learning-with-python-notebooks 上提供。您可以直接在浏览器中通过 Google Colaboratory 运行它们,这是一个免费使用的托管 Jupyter notebook 环境。您只需要互联网连接和桌面网络浏览器即可开始深度学习。
关于作者
![]() |
弗朗瓦·肖莱是 Keras 的创始人,这是最广泛使用的深度学习框架之一。他目前是 Google 的软件工程师,负责领导 Keras 团队。此外,他还在研究抽象、推理以及如何在人工智能中实现更大的普适性。 |
|---|
关于封面插图
Python 深度学习第二版封面上的人物题为“1568 年波斯女士的习惯”。这幅插图取自托马斯·杰弗里斯(Thomas Jefferys)的《不同国家古代和现代服饰集》(四卷本),伦敦,1757 年至 1772 年出版。封面上说明这些是手工上色的铜版画,使用了胶质增白。
托马斯·杰弗里斯(1719-1771)被称为“乔治三世国王的地理学家”。他是当时主要的地图供应商,为政府和其他官方机构刻制和印刷地图,并制作了各种商业地图和地图集,尤其是北美地区的地图。他作为地图制作者的工作引发了对他测绘和绘制地图的土地的本地服装习俗的兴趣,这些习俗在这个收藏中得到了精彩展示。对遥远土地的着迷和为了愉悦而旅行是 18 世纪末相对较新的现象,这样的收藏品很受欢迎,向游客和坐在家里的旅行者介绍了其他国家的居民。
杰弗里斯(Jefferys)的作品中的多样化插图生动地展示了大约 200 年前世界各国的独特性和个性。自那时以来,着装规范已经发生了变化,当时如此丰富的地区和国家的多样性已经消失。现在很难区分一个大陆的居民和另一个大陆的居民。也许,试图乐观地看待,我们已经用更加多样化的个人生活,或者更加多样化和有趣的智力和技术生活来交换文化和视觉多样性。
在很难区分一本计算机书籍和另一本计算机书籍的时候,曼宁(Manning)通过基于两个世纪前丰富多样的地区生活的书籍封面,重新展现了杰弗里斯(Jefferys)的插图所呈现的丰富多样性,来庆祝计算机行业的创造力和主动性。
一、什么是深度学习?
本章涵盖
-
基本概念的高级定义
-
机器学习发展的时间线
-
深度学习日益普及和未来潜力背后的关键因素
在过去几年中,人工智能(AI)一直是媒体炒作的对象。机器学习、深度学习和人工智能在无数文章中出现,通常是在技术类出版物之外。我们被承诺一个智能聊天机器人、自动驾驶汽车和虚拟助手的未来——有时被描绘成一个阴暗的未来,有时被描绘成乌托邦,人类的工作将变得稀缺,大部分经济活动将由机器人或人工智能代理处理。对于一个未来或现在从事机器学习的从业者来说,能够辨别出噪音中的信号是很重要的,这样你就可以从被炒作的新闻稿中找出改变世界的发展。我们的未来岌岌可危,这是一个你有积极参与的未来:阅读完本书后,你将成为那些开发这些人工智能系统的人之一。所以让我们来解决这些问题:深度学习到目前为止取得了什么成就?它有多重要?我们接下来将走向何方?你应该相信这种炒作吗?
本章提供了围绕人工智能、机器学习和深度学习的基本背景。
1.1 人工智能、机器学习和深度学习
首先,当我们提到人工智能时,我们需要清楚地定义我们所讨论的内容。人工智能、机器学习和深度学习是什么(见图 1.1)?它们之间的关系是怎样的?

图 1.1 人工智能、机器学习和深度学习
1.1.1 人工智能
人工智能诞生于 20 世纪 50 年代,当时一小群计算机科学领域的先驱开始思考计算机是否能够“思考”——这个问题的影响至今仍在探索中。
尽管许多潜在的想法在之前的几年甚至几十年中一直在酝酿,“人工智能”最终在 1956 年作为一个研究领域得以凝结,当时约翰·麦卡锡(John McCarthy)在达特茅斯学院(Dartmouth College)担任年轻的数学助理教授,组织了一个夏季研讨会,提出了以下建议:
这项研究的基础是一个假设,即学习的每个方面或智能的任何其他特征原则上都可以被描述得如此精确,以至于可以制造一台机器来模拟它。我们将尝试找出如何使机器使用语言,形成抽象和概念,解决目前仅保留给人类的问题,并改进自己。我们认为,如果一组精心挑选的科学家们在一起为此工作一个夏天,就可以在这些问题中的一个或多个问题上取得重大进展。
夏天结束时,研讨会没有完全解决它旨在调查的谜团。然而,许多参与者后来成为该领域的先驱,并引发了一场至今仍在进行的知识革命。
简而言之,人工智能可以被描述为自动执行人类通常执行的智力任务的努力。因此,人工智能是一个涵盖机器学习和深度学习的广泛领域,但也包括许多不涉及任何学习的方法。考虑到直到 1980 年代,大多数人工智能教科书根本没有提到“学习”!例如,早期的下棋程序只涉及由程序员精心制作的硬编码规则,并不符合机器学习的条件。事实上,相当长一段时间,大多数专家认为,通过让程序员手工制作足够大量的明确规则来操作存储在明确数据库中的知识,就可以实现人类水平的人工智能。这种方法被称为符号人工智能。它是从 1950 年代到 1980 年代末的人工智能中的主导范式,并在 1980 年代的专家系统繁荣期达到了其最高流行度。
尽管符号人工智能适用于解决定义明确的逻辑问题,例如下棋,但发现解决更复杂、模糊问题的明确规则是困难的,例如图像分类、语音识别或自然语言翻译。出现了一种新的方法来取代符号人工智能:机器学习。
1.1.2 机器学习
在维多利亚时代的英格兰,艾达·洛夫莱斯夫人是查尔斯·巴贝奇的朋友和合作者,他是第一台已知的通用机械计算机——分析引擎的发明者。尽管分析引擎具有远见卓识,超前于其时代,但在 1830 年代和 1840 年代设计时,并不是作为通用计算机,因为通用计算的概念尚未被发明。它只是作为一种使用机械操作来自动执行数学分析领域中某些计算的方式——因此得名为分析引擎。因此,它是早期尝试将数学运算编码为齿轮形式的智力后代,例如帕斯卡计算器或莱布尼茨的步进计算器,后者是帕斯卡计算器的改进版本。由布莱斯·帕斯卡于 1642 年(19 岁时!)设计,帕斯卡计算器是世界上第一台机械计算器——它可以加法、减法、乘法,甚至除法。
1843 年,艾达·洛夫莱斯评论了分析引擎的发明,
分析引擎根本没有创造任何东西的意图。它只能执行我们知道如何命令它执行的任务……它的职责是帮助我们利用我们已经熟悉的东西。
即使有着 178 年的历史视角,洛夫莱斯夫人的观察仍然令人震撼。通用计算机是否能“创造”任何东西,或者它是否总是被束缚在我们人类完全理解的过程中?它是否能够产生任何原创思想?它是否能够从经验中学习?它是否能展现创造力?
她的言论后来被人工智能先驱艾伦·图灵在他 1950 年的里程碑论文“计算机与智能”中引用为“洛夫莱斯夫人的反对意见”,该论文引入了图灵测试以及后来塑造人工智能的关键概念。图灵当时认为——这在当时是极具挑衅性的——计算机原则上可以模拟人类智能的所有方面。
让计算机执行有用工作的通常方法是让人类程序员编写规则——一个计算机程序——以将输入数据转换为适当的答案,就像洛夫莱斯夫人为分析引擎编写逐步指令一样。机器学习将这个过程颠倒过来:机器查看输入数据和相应的答案,并找出规则应该是什么(见图 1.2)。机器学习系统是训练而不是明确编程的。它被呈现许多与任务相关的示例,并在这些示例中找到统计结构,最终使系统能够提出自动化任务的规则。例如,如果您希望自动化标记您的度假照片的任务,您可以向机器学习系统提供许多已由人类标记的图片示例,系统将学习将特定图片与特定标签相关联的统计规则。

图 1.2 机器学习:一种新的编程范式
尽管机器学习在 1990 年代才开始蓬勃发展,但它迅速成为人工智能中最受欢迎和最成功的子领域,这一趋势受到更快硬件和更大数据集的推动。机器学习与数理统计有关,但在几个重要方面与统计学不同,就像医学与化学有关但不能简化为化学一样,因为医学处理具有独特属性的独特系统。与统计学不同,机器学习往往处理大型、复杂的数据集(例如包含数百万图像的数据集,每个图像由数万像素组成),传统的统计分析如贝叶斯分析在这种情况下将不切实际。因此,机器学习,尤其是深度学习,展示了相对较少的数学理论——也许太少了——并且基本上是一门工程学科。与理论物理或数学不同,机器学习是一个非常实践的领域,受到经验发现的驱动,并且深度依赖于软件和硬件的进步。
1.1.3 从数据中学习规则和表示
要定义深度学习并了解深度学习与其他机器学习方法的区别,首先我们需要对机器学习算法的工作原理有一些了解。我们刚刚说过,机器学习发现执行数据处理任务的规则,给定预期的示例。因此,要进行机器学习,我们需要三样东西:
-
输入数据点——例如,如果任务是语音识别,这些数据点可以是人们说话的声音文件。如果任务是图像标记,它们可以是图片。
-
预期输出的示例——在语音识别任务中,这些可以是人类生成的声音文件转录。在图像任务中,预期输出可以是“狗”、“猫”等标签。
-
衡量算法表现的方法——这是为了确定算法当前输出与预期输出之间的距离。该测量用作反馈信号,以调整算法的工作方式。这个调整步骤就是我们所说的学习。
机器学习模型将其输入数据转换为有意义的输出,这个过程是从已知输入和输出示例中“学习”的。因此,机器学习和深度学习的核心问题是有意义地转换数据:换句话说,学习输入数据的有用表示——这些表示使我们更接近预期的输出。
在我们继续之前:什么是表示?在其核心,它是查看数据的不同方式——表示或编码数据。例如,彩色图像可以用 RGB 格式(红-绿-蓝)或 HSV 格式(色调-饱和度-值)编码:这是相同数据的两种不同表示。一些在一种表示中可能困难的任务,在另一种表示中可能变得简单。例如,“选择图像中的所有红色像素”任务在 RGB 格式中更简单,而“使图像饱和度降低”在 HSV 格式中更简单。机器学习模型的全部内容都是找到适合其输入数据的适当表示——使数据更适合手头任务的转换。
让我们具体化一下。考虑一个x轴,一个y轴,以及一些通过它们在(x, y)系统中的坐标表示的点,如图 1.3 所示。

图 1.3 一些示例数据
正如你所看到的,我们有一些白点和一些黑点。假设我们想开发一个算法,可以接受一个点的坐标(x, y)并输出该点可能是黑色还是白色。在这种情况下,
-
输入是我们点的坐标。
-
预期的输出是我们点的颜色。
-
衡量我们的算法是否做得好的一种方法可能是,例如,被正确分类的点的百分比。
我们需要的是我们数据的一个新表示,清晰地将白点与黑点分开。我们可以使用的一种转换,除了许多其他可能性之外,是一个坐标变换,如图 1.4 所示。

图 1.4 坐标变换
在这个新的坐标系中,我们点的坐标可以说是我们数据的新表示。而且这是一个好的表示!通过这个表示,黑/白分类问题可以表达为一个简单的规则:“黑点是那些x>0 的点”,或者“白点是那些x<0 的点”。这个新表示,结合这个简单规则,巧妙地解决了分类问题。
在这种情况下,我们手动定义了坐标变换:我们利用我们的人类智慧提出了我们自己的数据适当表示。对于这样一个极其简单的问题来说这是可以的,但是如果任务是分类手写数字的图像,你能做到同样吗?你能写出明确的、可由计算机执行的图像转换,以阐明 6 和 8 之间的差异,1 和 7 之间的差异,以及各种不同手写之间的差异吗?
这在一定程度上是可能的。基于数字表示的规则,比如“闭环数量”或者垂直和水平像素直方图,可以很好地区分手写数字。但是手动找到这样有用的表示是一项艰苦的工作,而且,正如你可以想象的,由此产生的基于规则的系统是脆弱的——难以维护的噩梦。每当你遇到一个打破你精心考虑的规则的新手写示例时,你将不得不添加新的数据转换和新的规则,同时考虑它们与每个先前规则的互动。
你可能在想,如果这个过程如此痛苦,我们能自动化吗?如果我们尝试系统地搜索不同集合的自动生成的数据表示和基于它们的规则,通过使用一些开发数据集中被正确分类的数字的百分比作为反馈来识别好的表示,我们将会进行机器学习。学习,在机器学习的背景下,描述了一种自动搜索数据转换的过程,产生一些有用的数据表示,通过一些反馈信号引导——这些表示适合于解决手头任务的简单规则。
这些转换可以是坐标变换(就像我们的 2D 坐标分类示例中),或者是取像素直方图并计算循环次数(就像我们的数字分类示例中),但它们也可以是线性投影、平移、非线性操作(比如“选择所有* x > 0 的点”)等。机器学习算法通常不会在发现这些转换时具有创造性;它们只是在预定义的一组操作中搜索,称为假设空间*。例如,在 2D 坐标分类示例中,所有可能的坐标变换空间将是我们的假设空间。
所以,简洁地说,机器学习就是在预定义的可能性空间内,通过反馈信号的指导,搜索一些输入数据的有用表示和规则。这个简单的想法允许解决一系列广泛的智力任务,从语音识别到自动驾驶。
现在你明白了我们所说的学习是什么意思,让我们看看深度学习有什么特别之处。
1.1.4 “深度学习”中的“深度”
深度学习是机器学习的一个特定子领域:一种从数据中学习表示的新方法,强调学习逐渐具有意义的表示的连续层。 “深度学习”中的“深度”并不是指这种方法所达到的更深层次的理解;相反,它代表了这种连续表示层的概念。对数据模型有多少层贡献被称为模型的深度。该领域的其他适当名称可能是分层表示学习或层次表示学习。现代深度学习通常涉及数十甚至数百个连续的表示层,它们都是通过暴露于训练数据中自动学习的。与此同时,其他机器学习方法往往专注于学习数据的一两层表示(比如,获取像素直方图然后应用分类规则);因此,它们有时被称为浅层学习。
在深度学习中,这些分层表示是通过称为神经网络的模型学习的,这些模型以字面意义上相互堆叠的层结构。术语“神经网络”指的是神经生物学,但尽管深度学习中的一些核心概念部分是通过从我们对大脑的理解中汲取灵感而发展的(特别是视觉皮层),深度学习模型并不是大脑的模型。没有证据表明大脑实现了任何类似于现代深度学习模型中使用的学习机制。你可能会看到一些流行科学文章宣称深度学习如何像大脑工作或是模仿大脑,但事实并非如此。对于新手来说,将深度学习与神经生物学有任何关联是令人困惑和适得其反的;你不需要那种“就像我们的思维一样”的神秘感和神秘性,你可能会忘记任何你可能读到的关于深度学习和生物学之间假设联系的内容。对于我们的目的,深度学习是从数据中学习表示的数学框架。
深度学习算法学习的表示是什么样子的?让我们看看一个几层深的网络(见图 1.5)如何转换图像以识别出是哪个数字。

图 1.5 数字分类的深度神经网络
正如你在图 1.6 中看到的,网络将数字图像转换为与原始图像越来越不同且越来越有关于最终结果的信息的表示。你可以将深度网络看作是一个多阶段的信息提炼过程,其中信息通过连续的滤波器并最终纯化(即,对某个任务有用)出来。

图 1.6 由数字分类模型学习到的数据表示
所以从技术上讲,这就是深度学习:学习数据表示的多阶段方式。这是一个简单的想法—但事实证明,非常简单的机制,足够扩展,最终看起来像魔术。
1.1.5 通过三个图形理解深度学习的工作原理
到目前为止,你知道机器学习是关于将输入(如图像)映射到目标(如标签“猫”),这是通过观察许多输入和目标示例来完成的。你还知道深度神经网络通过一系列简单的数据转换(层)的深度序列来实现这种输入到目标的映射,并且这些数据转换是通过示例学习的。现在让我们具体看看这个学习是如何进行的。
层对输入数据执行的操作规范存储在层的权重中,本质上是一堆数字。在技术术语中,我们会说层实现的转换是由其权重参数化的(见图 1.7)。(权重有时也称为层的参数。)在这个背景下,学习意味着找到网络中所有层的权重的一组值,使网络能够正确地将示例输入映射到它们关联的目标。但问题在于:一个深度神经网络可能包含数千万个参数。找到所有这些参数的正确值可能看起来像一项艰巨的任务,特别是考虑到修改一个参数的值将影响所有其他参数的行为!

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

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

图 1.9 损失分数被用作反馈信号来调整权重。
最初,网络的权重被分配随机值,因此网络仅实现一系列随机转换。自然地,它的输出与理想情况相去甚远,损失分数相应地非常高。但随着网络处理每个示例,权重会稍微朝正确方向调整,损失分数会减少。这就是训练循环,重复足够多次(通常是数千个示例的数十次迭代),得到最小化损失函数的权重值。具有最小损失的网络是输出尽可能接近目标的网络:一个经过训练的网络。再一次,这是一个简单的机制,一旦扩展,最终看起来像魔术。
1.1.6 到目前为止深度学习取得了什么成就
尽管深度学习是机器学习的一个相当古老的子领域,但直到 2010 年代初才开始崭露头角。在此后的几年里,它在领域中取得了一场革命性的成就,对感知任务甚至自然语言处理任务产生了显著的结果——这些问题涉及到对人类似乎自然和直观的技能,但长期以来一直是机器难以掌握的。
特别是,深度学习已经在历史上困难的机器学习领域实现了以下突破:
-
近乎人类水平的图像分类
-
近乎人类水平的语音转录
-
近乎人类水平的手写转录
-
大幅改进的机器翻译
-
大幅改进的文本转语音转换
-
诸如 Google Assistant 和 Amazon Alexa 等数字助手
-
近乎人类水平的自动驾驶
-
改进的广告定位,如 Google、百度或必应所使用的
-
网络搜索结果的改进
-
能够回答自然语言问题
-
超人类水平的围棋对弈
我们仍在探索深度学习的全部潜力。我们已经开始将其成功应用于一系列几年前被认为无法解决的问题——自动转录梵蒂冈宗座档案馆中保存的数以万计的古代手稿,使用简单的智能手机检测和分类田间植物疾病,协助肿瘤学家或放射科医生解释医学成像数据,预测洪水、飓风甚至地震等自然灾害等。随着每一个里程碑的实现,我们越来越接近一个时代,在这个时代,深度学习将在人类的每一个活动和领域中协助我们——科学、医学、制造业、能源、交通、软件开发、农业,甚至艺术创作。
1.1.7 不要相信短期炒作
尽管深度学习在近年取得了显著的成就,对于该领域在未来十年能够实现的期望往往比实际可能实现的要高得多。尽管一些像自动驾驶汽车这样改变世界的应用已经近在眼前,但许多其他应用可能在很长一段时间内仍然难以实现,比如可信的对话系统、跨任意语言的人类级机器翻译以及人类级自然语言理解。特别是,对于短期内实现人类级通用智能的讨论不应该太过认真。对于短期内高期望的风险在于,随着技术无法交付,研究投资将枯竭,长期减缓进展。
这种情况已经发生过。在过去的两次中,人工智能经历了一轮强烈的乐观主义,随后是失望和怀疑,导致资金短缺。它始于 20 世纪 60 年代的符号人工智能。在那些早期,关于人工智能的预测飞得很高。符号人工智能方法中最著名的先驱和支持者之一是马文·明斯基,他在 1967 年声称:“在一代人内……创造‘人工智能’的问题将得到实质性解决。”三年后,即 1970 年,他做出了更为精确的预测:“在三到八年内,我们将拥有一台具有普通人类智能的机器。”到 2021 年,这样的成就似乎仍然遥不可及——远远超出我们无法预测需要多长时间才能实现的范围——但在 20 世纪 60 年代和 70 年代初,一些专家相信这一成就就在眼前(就像今天的许多人一样)。几年后,随着这些高期望未能实现,研究人员和政府资金转向其他领域,标志着第一次人工智能寒冬的开始(这是对核冬天的一个参考,因为这是在冷战的高峰之后不久)。
这不会是最后一个。在 20 世纪 80 年代,一种新的符号人工智能专家系统开始在大公司中蓬勃发展。一些最初的成功案例引发了一波投资热潮,全球各地的公司开始建立自己的内部人工智能部门来开发专家系统。到 1985 年左右,公司每年在这项技术上的支出超过 10 亿美元;但到了 90 年代初,这些系统已经被证明难以维护、难以扩展和范围有限,兴趣逐渐消退。于是第二次人工智能寒冬开始了。
我们可能目前正在见证人工智能炒作和失望的第三个周期,我们仍处于强烈乐观的阶段。最好是对短期期望保持适度,确保对该领域技术方面不太熟悉的人清楚了解深度学习能够做什么和不能做什么。
1.1.8 人工智能的承诺
尽管我们可能对人工智能有着不切实际的短期期望,但长期前景看起来是光明的。我们才刚刚开始将深度学习应用于许多重要问题,这些问题可能会发生转变,从医学诊断到数字助手。过去十年来,人工智能研究取得了惊人的快速进展,这在很大程度上是由于人工智能短暂历史中前所未有的资金水平,但迄今为止,这些进展中相对较少的部分已经应用到构成我们世界的产品和流程中。深度学习的大部分研究成果尚未得到应用,或者至少没有应用到它们可以解决的所有行业的所有问题范围内。你的医生还没有使用人工智能,你的会计师也没有。在日常生活中,你可能并不经常使用人工智能技术。当然,你可以向智能手机提出简单问题并得到合理的答案,你可以在 Amazon.com 上获得相当有用的产品推荐,你可以在 Google 照片中搜索“生日”并立即找到上个月女儿生日派对的照片。这与这些技术过去所处的位置相去甚远。但这些工具仍然只是我们日常生活的附件。人工智能尚未过渡到成为我们工作、思考和生活方式的核心。
现在,也许很难相信人工智能会对我们的世界产生巨大影响,因为它尚未被广泛应用——就像在 1995 年,很难相信互联网未来的影响一样。那时,大多数人并没有看到互联网对他们的相关性以及它将如何改变他们的生活。对于今天的深度学习和人工智能也是如此。但不要误解:人工智能即将到来。在不久的将来,人工智能将成为你的助手,甚至是你的朋友;它将回答你的问题,帮助教育你的孩子,并关注你的健康。它将把你的杂货送到家门口,并把你从 A 点开车到 B 点。它将成为你与日益复杂和信息密集的世界接触的接口。更重要的是,人工智能将帮助整个人类向前迈进,通过协助人类科学家在所有科学领域中进行新的突破性发现,从基因组学到数学。
在这个过程中,我们可能会遇到一些挫折,甚至可能会迎来新的人工智能寒冬——就像互联网行业在 1998-99 年被过度炒作并遭受了一场导致在 2000 年代初期投资枯竭的崩溃一样。但我们最终会到达那里。人工智能最终将应用于构成我们社会和日常生活的几乎每一个过程,就像互联网今天一样。
不要相信短期炒作,但要相信长期愿景。人工智能可能需要一段时间才能发挥其真正潜力——一个全面程度还没有人敢于梦想的潜力——但人工智能即将到来,它将以一种奇妙的方式改变我们的世界。
1.2 在深度学习之前:机器学习的简史
深度学习已经达到了公众关注和行业投资的水平,这在人工智能历史上从未见过,但它并不是机器学习的第一个成功形式。可以肯定地说,今天工业中使用的大多数机器学习算法并不是深度学习算法。深度学习并不总是解决问题的正确工具——有时候数据不足以应用深度学习,有时候问题更适合用不同的算法解决。如果深度学习是你与机器学习的第一次接触,你可能会发现自己处于这样一种情况:你手头只有深度学习的锤子,而每个机器学习问题看起来都像一个钉子。避免陷入这种陷阱的唯一方法是熟悉其他方法,并在适当时练习它们。
对经典机器学习方法的详细讨论超出了本书的范围,但我将简要介绍它们,并描述它们开发的历史背景。这将使我们能够将深度学习置于机器学习更广泛的背景中,并更好地理解深度学习的来源和重要性。
1.2.1 概率建模
概率建模是将统计原理应用于数据分析的过程。它是机器学习的最早形式之一,至今仍然广泛使用。在这一类别中最著名的算法之一是朴素贝叶斯算法。
朴素贝叶斯是一种基于应用贝叶斯定理的机器学习分类器,假设输入数据中的特征都是独立的(这是一个强大或“朴素”的假设,这也是名称的由来)。这种形式的数据分析早在计算机出现之前就存在,并且在首次计算机实现之前就通过手工应用(很可能可以追溯到 20 世纪 50 年代)了。贝叶斯定理和统计学的基础可以让您开始使用朴素贝叶斯分类器。
一个密切相关的模型是逻辑回归(简称 logreg),有时被认为是现代机器学习的“Hello World”。不要被它的名字误导——logreg 是一个分类算法而不是回归算法。与朴素贝叶斯类似,logreg 在计算机出现很久之前就存在了,但由于其简单且多功能的特性,至今仍然很有用。它通常是数据科学家在数据集上尝试的第一件事,以了解手头的分类任务。
1.2.2 早期神经网络
早期的神经网络已经被这些页面中涵盖的现代变体完全取代,但了解深度学习的起源仍然很有帮助。尽管神经网络的核心思想早在 20 世纪 50 年代就以玩具形式进行了研究,但这种方法花了几十年才开始。很长一段时间,缺失的部分是训练大型神经网络的有效方法。这种情况在 20 世纪 80 年代中期发生了变化,当时多人独立重新发现了反向传播算法——一种使用梯度下降优化训练参数化操作链的方法(我们将在本书后面精确定义这些概念)——并开始将其应用于神经网络。
1989 年,贝尔实验室首次成功应用神经网络的实际应用来自杨立昆,他将卷积神经网络和反向传播的早期思想结合起来,并将它们应用于手写数字分类问题。由此产生的网络被称为LeNet,在上世纪 90 年代被美国邮政服务用于自动读取信封上的邮政编码。
1.2.3 核方法
当神经网络在 1990 年代开始在研究人员中获得一些尊重时,由于这一初步成功,一种新的机器学习方法崭露头角,并迅速将神经网络送回到遗忘之中:核方法。核方法是一组分类算法,其中最著名的是支持向量机(SVM)。SVM 的现代形式是由弗拉基米尔·瓦普尼克和 Corinna Cortes 在贝尔实验室于 1990 年代初开发,并于 1995 年发表的,尽管早在 1963 年,瓦普尼克和 Alexey Chervonenkis 就已经发表了一个较早的线性形式。
SVM 是一种分类算法,通过找到分隔两类的“决策边界”来工作(见图 1.10)。SVM 通过两个步骤来找到这些边界:
-
数据被映射到一个新的高维表示,其中决策边界可以表示为一个超平面(如果数据是二维的,如图 1.10,超平面将是一条直线)。
-
通过尝试最大化超平面与每个类别最近数据点之间的距离来计算一个良好的决策边界(一个分离超平面),这一步骤称为最大化间隔。这使得边界能够很好地泛化到训练数据集之外的新样本。

图 1.10 决策边界
将数据映射到高维表示的技术,使得分类问题变得更简单,看起来在理论上很不错,但在实践中通常是计算上难以处理的。这就是核技巧的用武之地(核方法以此命名的关键思想)。这是其要点:为了在新表示空间中找到良好的决策超平面,你不必显式计算点在新空间中的坐标;你只需要计算该空间中点对之间的距离,这可以通过核函数有效地完成。核函数是一种计算上易处理的操作,将初始空间中的任意两点映射到目标表示空间中这些点之间的距离,完全绕过了新表示的显式计算。核函数通常是手工制作而非从数据中学习的——在 SVM 的情况下,只有分离超平面是被学习的。
在它们被开发的时候,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 Ciresan 开始使用 GPU 训练的深度神经网络赢得学术图像分类竞赛——这是现代深度学习的第一个实际成功案例。 但转折点发生在 2012 年,当 Hinton 的小组参加了每年一次的大规模图像分类挑战 ImageNet(ImageNet 大规模视觉识别挑战,简称 ILSVRC)。 当时,ImageNet 挑战非常困难,包括在训练了 140 万张图像后,将高分辨率彩色图像分类为 1,000 个不同类别。 2011 年,基于传统计算机视觉方法的获胜模型的前五准确率仅为 74.3%。⁵ 然后,在 2012 年,由 Alex Krizhevsky 领导并由 Geoffrey Hinton 指导的团队取得了 83.6%的前五准确率——这是一个重大突破。 从那时起,每年的比赛都被深度卷积神经网络所主导。 到 2015 年,获胜者的准确率达到 96.4%,而 ImageNet 上的分类任务被认为是一个完全解决的问题。
自 2012 年以来,深度卷积神经网络(convnets)已成为所有计算机视觉任务的首选算法;更一般地,它们适用于所有感知任务。 在 2015 年之后的任何一次重要计算机视觉会议上,几乎不可能找到不涉及 convnets 的演示。 与此同时,深度学习还在许多其他类型的问题中找到了应用,如自然语言处理。 它已经完全取代了 SVM 和决策树在许多应用中的使用。 例如,多年来,欧洲核子研究组织 CERN 一直使用基于决策树的方法来分析大型强子对撞机(LHC)上 ATLAS 探测器的粒子数据,但最终 CERN 转而使用基于 Keras 的深度神经网络,因为它们在大型数据集上具有更高的性能和训练的便利性。
1.2.6 深度学习的不同之处
深度学习之所以迅速崛起的主要原因是它在许多问题上提供了更好的性能。但这并不是唯一的原因。深度学习还使问题解决变得更加容易,因为它完全自动化了曾经是机器学习工作流程中最关键的步骤:特征工程。
以前的机器学习技术——浅层学习——只涉及将输入数据转换为一两个连续的表示空间,通常通过简单的转换,如高维非线性投影(SVM)或决策树。但复杂问题所需的精细表示通常无法通过这种技术实现。因此,人们不得不费尽心思地使初始输入数据更易于通过这些方法处理:他们必须手动为数据工程好表示层。这就是所谓的特征工程。另一方面,深度学习完全自动化了这一步骤:通过深度学习,你可以一次性学习所有特征,而不必自己进行工程设计。这大大简化了机器学习工作流程,通常用一个简单的端到端深度学习模型取代了复杂的多阶段流水线。
你可能会问,如果问题的关键在于具有多个连续的表示层,那么浅层方法是否可以重复应用以模拟深度学习的效果?实际上,连续应用浅层学习方法会产生快速递减的回报,因为在三层模型中的最佳第一表示层并不是一层或两层模型中的最佳第一层。深度学习的革命性之处在于它允许模型同时学习所有表示层,而不是按顺序(贪婪地)学习。通过联合特征学习,每当模型调整其内部特征时,所有依赖于它的其他特征都会自动适应变化,而无需人为干预。一切都由单一的反馈信号监督:模型中的每一次变化都服务于最终目标。这比贪婪地堆叠浅层模型更加强大,因为它允许通过将复杂的抽象表示分解为一系列中间空间(层)来学习它们;每个空间与前一个空间之间只有一个简单的转换。
这是深度学习从数据中学习的两个基本特征:逐渐增加、逐层发展越来越复杂的表示方式,以及这些中间逐步增加的表示是联合学习的,每一层都被更新以同时遵循上面一层的表示需求和下面一层的需求。这两个特性共同使深度学习比以前的机器学习方法更加成功。
1.2.7 现代机器学习格局
了解当前机器学习算法和工具的现状的一个好方法是查看 Kaggle 上的机器学习竞赛。由于其高度竞争的环境(一些比赛有数千名参与者和百万美元的奖金)以及涵盖的各种机器学习问题,Kaggle 提供了一个评估什么有效、什么无效的现实方法。那么哪种算法可靠地赢得比赛?顶尖参与者使用什么工具?
2019 年初,Kaggle 进行了一项调查,询问自 2017 年以来在任何比赛中获得前五名的团队使用的主要软件工具是什么(见图 1.12)。结果表明,顶尖团队倾向于使用深度学习方法(通常通过 Keras 库)或梯度提升树(通常通过 LightGBM 或 XGBoost 库)。

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

图 1.13 工具在机器学习和数据科学行业中的使用情况(来源:www.kaggle.com/kaggle-survey-2020)
从 2016 年到 2020 年,整个机器学习和数据科学行业都被这两种方法主导:深度学习和梯度提升树。具体来说,梯度提升树用于有结构化数据的问题,而深度学习用于感知问题,如图像分类。
使用梯度提升树的用户倾向于使用 Scikit-learn、XGBoost 或 LightGBM。与此同时,大多数深度学习从业者使用 Keras,通常与其母框架 TensorFlow 结合使用。这些工具的共同点是它们都是 Python 库:Python 是迄今为止机器学习和数据科学中最广泛使用的语言。
这是您今天在应用机器学习中应该最熟悉的两种技术:梯度提升树,用于浅层学习问题;深度学习,用于感知问题。在技术上,这意味着您需要熟悉 Scikit-learn、XGBoost 和 Keras 这三个目前主导 Kaggle 竞赛的库。有了这本书,您已经更接近成功了一大步。
1.3 为什么深度学习?为什么现在?
深度学习在计算机视觉中的两个关键思想——卷积神经网络和反向传播——在 1990 年就已经被充分理解。长短期记忆(LSTM)算法,对于时间序列的深度学习至关重要,于 1997 年开发,至今几乎没有改变。那么为什么深度学习直到 2012 年后才起飞?这两个十年发生了什么变化?
总的来说,有三个技术力量推动了机器学习的进步:
-
硬件
-
数据集和基准测试
-
算法进步
因为该领域是由实验结果而不是理论指导的,只有在适当的数据和硬件可用于尝试新想法(或扩展旧想法,通常情况下)时,算法进步才成为可能。机器学习不是数学或物理学,那里可以用一支笔和一张纸做出重大进步。这是一门工程科学。
20 世纪 90 年代和 2000 年代的真正瓶颈是数据和硬件。但在那段时间发生了什么呢:互联网蓬勃发展,高性能图形芯片为游戏市场的需求而开发。
1.3.1 硬件
从 1990 年到 2010 年,现成的 CPU 速度提高了约 5,000 倍。因此,现在可以在笔记本电脑上运行小型深度学习模型,而在 25 年前这是不可行的。
但是,在计算机视觉或语音识别中使用的典型深度学习模型需要比您的笔记本电脑提供的计算能力高出几个数量级。在 2000 年代,像 NVIDIA 和 AMD 这样的公司投资了数十亿美元开发快速、大规模并行芯片(图形处理单元,或 GPU),以提供越来越逼真的视频游戏图形 - 廉价、单一用途的超级计算机,旨在实时在屏幕上渲染复杂的 3D 场景。当 NVIDIA 于 2007 年推出 CUDA(developer.nvidia.com/about-cuda)时,这项投资开始造福科学界,CUDA 是其 GPU 系列的编程接口。少量 GPU 开始取代各种高度可并行化的应用程序中的大型 CPU 集群,从物理建模开始。由许多小矩阵乘法组成的深度神经网络也是高度可并行化的,大约在 2011 年左右,一些研究人员开始编写神经网络的 CUDA 实现 - 丹·西雷桑⁶和亚历克斯·克里兹赫夫斯基⁷是最早的几位。
发生的事情是游戏市场为下一代人工智能应用程序提供了超级计算的资助。有时,大事情从游戏开始。如今,NVIDIA Titan RTX,一款在 2019 年底售价为 2500 美元的 GPU,在单精度(每秒 16 万亿float32操作)方面达到峰值 16 teraFLOPS。这大约是 1990 年世界上最快超级计算机 Intel Touchstone Delta 的 500 倍计算能力。在 Titan RTX 上,只需要几个小时就可以训练出类似于 2012 或 2013 年将赢得 ILSVRC 比赛的 ImageNet 模型。与此同时,大公司在数百个 GPU 的集群上训练深度学习模型。
此外,深度学习行业已经超越了 GPU,正在投资于越来越专门化、高效的深度学习芯片。2016 年,在其年度 I/O 大会上,Google 公布了其张量处理单元(TPU)项目:一种全新的芯片设计,旨在比顶级 GPU 运行深度神经网络更快且更节能。如今,在 2020 年,TPU 卡的第三代代表着 420 teraFLOPS 的计算能力。这比 1990 年的 Intel 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 之所以特别,不仅仅是因为其规模庞大,还因为与之相关的年度竞赛。
正如 Kaggle 自 2010 年以来一直在展示的那样,公开竞赛是激励研究人员和工程师突破界限的绝佳方式。研究人员竞争击败的共同基准已经极大地帮助了深度学习的崛起,突显了其成功与传统机器学习方法的对比。
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)。

图 1.14 OECD 估计的人工智能初创公司的总投资(来源:mng.bz/zGN6)
在深度学习引起关注之前的 2011 年,全球人工智能风险投资总额不到 10 亿美元,几乎全部投向了浅层机器学习方法的实际应用。到了 2015 年,这一数字已经上升到 50 多亿美元,2017 年更是激增至 160 亿美元。在这几年间,数百家初创公司涌现,试图利用深度学习的热潮。与此同时,谷歌、亚马逊和微软等大型科技公司在内部研究部门的投资金额很可能超过了风险投资资金的流入量。
机器学习——特别是深度学习——已经成为这些科技巨头的产品战略的核心。2015 年底,谷歌 CEO 桑达尔·皮查伊表示:“机器学习是我们重新思考我们如何做一切的核心、变革性方式。我们正在全面应用它,无论是搜索、广告、YouTube 还是 Play。我们还处于早期阶段,但你会看到我们以系统化的方式在所有这些领域应用机器学习。”
由于这波投资浪潮,从事深度学习工作的人数在不到 10 年的时间里从几百人增加到数万人,研究进展达到了疯狂的速度。
1.3.5 深度学习的民主化
推动深度学习中新面孔涌入的一个关键因素是该领域使用的工具集的民主化。在早期,进行深度学习需要大量的 C++和 CUDA 专业知识,而这种专业知识很少有人掌握。
如今,基本的 Python 脚本技能就足以进行高级深度学习研究。这主要得益于现已废弃的 Theano 库的发展,以及 TensorFlow 库——这两个用于 Python 的符号张量操作框架支持自动微分,极大地简化了新模型的实现——以及像 Keras 这样的用户友好库的崛起,使得深度学习就像操纵乐高积木一样简单。在 2015 年初发布后,Keras 迅速成为大量新创企业、研究生和转入该领域的研究人员的首选深度学习解决方案。
1.3.6 它会持续吗?
深度神经网络有什么特别之处,使得它们成为公司投资和研究人员涌入的“正确”选择?或者深度学习只是一个可能不会持续的时尚?20 年后我们还会使用深度神经网络吗?
深度学习具有几个属性,这些属性证明了它作为人工智能革命的地位,并且它将会持续存在。也许 20 年后我们不会再使用神经网络,但无论我们使用什么,都将直接继承现代深度学习及其核心概念。这些重要属性可以广泛分为三类:
-
简单性——深度学习消除了特征工程的需求,用简单的端到端可训练模型取代了复杂、脆弱、工程密集型的流水线,通常仅使用五到六种不同的张量操作构建。
-
可扩展性——深度学习非常适合在 GPU 或 TPU 上并行化,因此可以充分利用摩尔定律。此外,深度学习模型通过迭代小批量数据进行训练,使其能够在任意大小的数据集上进行训练。(唯一的瓶颈是可用的并行计算能力量,由于摩尔定律的存在,这是一个快速移动的障碍。)
-
多功能性和可重用性——与许多先前的机器学习方法不同,深度学习模型可以在不从头开始重新启动的情况下训练额外的数据,使其适用于连续在线学习——这对于非常大的生产模型是一个重要的特性。此外,经过训练的深度学习模型是可重用的:例如,可以将经过图像分类训练的深度学习模型放入视频处理流水线中。这使我们能够将以前的工作重新投资到越来越复杂和强大的模型中。这也使得深度学习适用于相当小的数据集。
深度学习仅仅在聚光灯下曝光了几年,我们可能还没有确定其能够做到的全部范围。随着每一年的过去,我们了解到新的用例和工程改进,这些改进消除了以前的限制。在科学革命之后,进展通常遵循 S 形曲线:它从快速进展的阶段开始,逐渐稳定下来,研究人员遇到严重限制,然后进一步的改进变得渐进式。
当我写第一版这本书时,也就是 2016 年,我预测深度学习仍处于 S 形曲线的上半部,接下来几年将会有更多变革性的进展。实践证明这一点是正确的,因为 2017 年和 2018 年见证了基于 Transformer 的深度学习模型在自然语言处理领域的崛起,这在该领域引起了一场革命,同时深度学习在计算机视觉和语音识别领域也持续稳步取得进展。如今,2021 年,深度学习似乎已经进入 S 形曲线的下半部。我们仍然应该期待未来几年的重大进展,但我们可能已经走出了爆炸性进展的初始阶段。
今天,我对深度学习技术应用于解决各种问题感到非常兴奋——问题的范围是无限的。深度学习仍然是一场正在进行中的革命,要实现其全部潜力还需要很多年。
¹ 艾伦·图灵,“计算机器械与智能”,心灵 59,第 236 期(1950 年):433–460。
² 尽管图灵测试有时被解释为一种字面测试——人工智能领域应该设定的目标——但图灵只是将其作为一个概念设备,用于关于认知本质的哲学讨论。
³ 弗拉基米尔·瓦普尼克和科琳娜·科尔特斯,“支持向量网络”,机器学习 20,第 3 期(1995 年):273–297。
⁴ 弗拉基米尔·瓦普尼克和亚历克谢·切尔沃年基斯,“关于一类感知机的注记”,自动化与遥感控制 25(1964 年)。
⁵ “前五准确率”衡量模型在其前五个猜测中多少次选择了正确答案(在 ImageNet 的情况下,共有 1,000 个可能的答案)。
⁶ 参见“用于图像分类的灵活、高性能卷积神经网络”,第 22 届国际人工智能联合会议论文集(2011),www.ijcai.org/Proceedings/11/Papers/210.pdf。
⁷ 参见“使用深度卷积神经网络进行 ImageNet 分类”,神经信息处理系统进展 25(2012),mng.bz/2286。
⁸ ImageNet 大规模视觉识别挑战(ILSVRC),www.image-net.org/challenges/LSVRC。
⁹ 桑达尔·皮查伊,Alphabet 财报电话会议,2015 年 10 月 22 日。
二、神经网络的数学基础
本章涵盖
-
神经网络的第一个例子
-
张量和张量操作
-
神经网络如何通过反向传播和梯度下降学习
理解深度学习需要熟悉许多简单的数学概念:张量、张量操作、微分、梯度下降等。本章的目标是在不过于技术化的情况下建立您对这些概念的直觉。特别是,我们将避开数学符号,这可能会给没有数学背景的人带来不必要的障碍,并且不是解释事物的必要条件。数学操作的最精确、明确的描述是其可执行代码。
为了为引入张量和梯度下降提供足够的背景,我们将从一个神经网络的实际例子开始本章。然后我们将逐点地讨论每个新引入的概念。请记住,这些概念对于您理解以下章节中的实际示例至关重要!
阅读完本章后,您将对深度学习背后的数学理论有直观的理解,并准备好在第三章开始深入研究 Keras 和 TensorFlow。
2.1 神经网络的初步了解
让我们看一个具体的例子,一个使用 Python 库 Keras 学习分类手写数字的神经网络。除非您已经有使用 Keras 或类似库的经验,否则您不会立即理解这个第一个例子的所有内容。没关系。在下一章中,我们将逐个审查示例中的每个元素并详细解释它们。所以如果有些步骤看起来随意或对您来说像魔术一样,请不要担心!我们必须从某个地方开始。
我们要解决的问题是将手写数字的灰度图像(28×28 像素)分类为它们的 10 个类别(0 到 9)。我们将使用 MNIST 数据集,这是机器学习社区中的经典数据集,几乎与该领域本身一样久远并受到密切研究。这是由国家标准技术研究所(MNIST 中的 NIST)在上世纪 80 年代汇编的一组 60000 个训练图像和 10000 个测试图像。您可以将“解决”MNIST 看作是深度学习的“Hello World” - 这是您验证算法是否按预期工作的方法。随着您成为机器学习从业者,您会发现 MNIST 在科学论文、博客文章等中反复出现。您可以在图 2.1 中看到一些 MNIST 样本。

图 2.1 MNIST 样本数字
在机器学习中,分类问题中的类别称为类。数据点称为样本。与特定样本相关联的类称为标签。
您现在不需要在您的机器上尝试复制这个例子。如果您希望这样做,您首先需要设置一个深度学习工作空间,这在第三章中有介绍。
MNIST 数据集已经预装在 Keras 中,以四个 NumPy 数组的形式存在。
列表 2.1 在 Keras 中加载 MNIST 数据集
from tensorflow.keras.datasets import mnist
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()
train_images和train_labels组成训练集,模型将从中学习。然后模型将在测试集test_images和test_labels上进行测试。图像被编码为 NumPy 数组,标签是一个从 0 到 9 的数字数组。图像和标签之间有一一对应关系。
让我们看一下训练数据:
>>> train_images.shape
(60000, 28, 28)
>>> len(train_labels)
60000
>>> train_labels
array([5, 0, 4, ..., 5, 6, 8], dtype=uint8)
这里是测试数据:
>>> test_images.shape
(10000, 28, 28)
>>> len(test_labels)
10000
>>> test_labels
array([7, 2, 1, ..., 4, 5, 6], dtype=uint8)
工作流程如下:首先,我们将向神经网络提供训练数据train_images和train_labels。然后网络将学习将图像和标签关联起来。最后,我们将要求网络为test_images生成预测,并验证这些预测是否与test_labels中的标签匹配。
让我们构建网络—再次提醒您,您不必完全理解这个示例的所有内容。
列表 2.2 网络架构
from tensorflow import keras
from tensorflow.keras import layers
model = keras.Sequential([
layers.Dense(512, activation="relu"),
layers.Dense(10, activation="softmax")
])
神经网络的核心构建块是层。您可以将层视为数据的过滤器:一些数据进入,以更有用的形式输出。具体来说,层从输入的数据中提取表示,希望这些表示对手头的问题更有意义。大部分深度学习都是将一些简单层链接在一起,这些层将实现一种渐进数据精炼形式。深度学习模型就像是数据处理的筛子,由一系列越来越精细的数据过滤器(层)组成。
在这里,我们的模型由两个Dense层的序列组成,这些层是密集连接(也称为全连接)的神经层。第二(也是最后)层是一个 10 路softmax 分类层,这意味着它将返回一个总和为 1 的 10 个概率分数数组。每个分数将是当前数字图像属于我们的 10 个数字类别之一的概率。
为了使模型准备好进行训练,我们需要在编译步骤中选择另外三个事项:
-
优化器—模型将根据其看到的训练数据更新自身的机制,以提高其性能。
-
损失函数—模型如何能够衡量其在训练数据上的表现,从而如何能够引导自己朝着正确的方向前进。
-
在训练和测试过程中监控的指标—在这里,我们只关心准确率(被正确分类的图像的比例)。
损失函数和优化器的确切目的将在接下来的两章中明确。
列表 2.3 编译步骤
model.compile(optimizer="rmsprop",
loss="sparse_categorical_crossentropy",
metrics=["accuracy"])
在训练之前,我们将通过重新调整数据的形状以及缩放数据,使所有值都在[0, 1]区间内来预处理数据。之前,我们的训练图像存储在一个形状为(60000, 28, 28)的uint8类型数组中,值在[0, 255]区间内。我们将其转换为一个形状为(60000, 28 * 28)的float32数组,值在 0 到 1 之间。
列表 2.4 准备图像数据
train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype("float32") / 255
test_images = test_images.reshape((10000, 28 * 28))
test_images = test_images.astype("float32") / 255
现在我们准备训练模型,在 Keras 中通过调用模型的fit()方法来完成——我们将模型与其训练数据拟合。
列表 2.5 “拟合”模型
>>> model.fit(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 - acc: 0.9692
在训练过程中显示两个量:模型在训练数据上的损失和模型在训练数据上的准确率。我们很快就在训练数据上达到了 0.989(98.9%)的准确率。
现在我们有了一个经过训练的模型,我们可以使用它来预测新数字的类别概率—这些图像不是训练数据的一部分,就像测试集中的那些图像一样。
列表 2.6 使用模型进行预测
>>> test_digits = test_images[0:10]
>>> predictions = model.predict(test_digits)
>>> predictions[0]
array([1.0726176e-10, 1.6918376e-10, 6.1314843e-08, 8.4106023e-06,
2.9967067e-11, 3.0331331e-09, 8.3651971e-14, 9.9999106e-01,
2.6657624e-08, 3.8127661e-07], dtype=float32)
数组中索引i处的每个数字对应于数字图像test_digits[0]属于类别i的概率。
这个第一个测试数字在索引 7 处具有最高的概率分数(0.99999106,接近 1),因此根据我们的模型,它必须是一个 7:
>>> predictions[0].argmax()
7
>>> predictions[0][7]
0.99999106
我们可以检查测试标签是否一致:
>>> test_labels[0]
7
我们的模型在对这些以前从未见过的数字进行分类时,平均表现如何?让我们通过计算整个测试集上的平均准确率来检查。
列表 2.7 在新数据上评估模型
>>> test_loss, test_acc = model.evaluate(test_images, test_labels)
>>> print(f"test_acc: {test_acc}")
test_acc: 0.9785
测试集准确率为 97.8%—这比训练集准确率(98.9%)要低得多。训练准确率和测试准确率之间的差距是过拟合的一个例子:机器学习模型在新数据上的表现往往不如在其训练数据上。过拟合是第三章的一个核心主题。
这就结束了我们的第一个示例——你刚刚看到如何构建和训练一个神经网络来对手写数字进行分类,只需不到 15 行的 Python 代码。在本章和下一章中,我们将详细介绍我们刚刚预览的每个移动部分,并澄清幕后发生的事情。你将了解张量,这些数据存储对象进入模型;张量操作,层是由什么组成的;以及梯度下降,它允许你的模型从训练示例中学习。
2.2 神经网络的数据表示
在前面的示例中,我们从存储在多维 NumPy 数组中的数据开始,也称为张量。一般来说,所有当前的机器学习系统都使用张量作为它们的基本数据结构。张量对于这个领域是基础的——以至于 TensorFlow 就是以它们命名的。那么什么是张量?
从本质上讲,张量是数据的容器——通常是数值数据。因此,它是一个数字的容器。你可能已经熟悉矩阵,它们是秩为 2 的张量:张量是对矩阵到任意数量的维度的泛化(请注意,在张量的上下文中,维度通常被称为轴)。
2.2.1 标量(秩为 0 的张量)
只包含一个数字的张量称为标量(或标量张量,或秩为 0 的张量,或 0D 张量)。在 NumPy 中,float32或float64数字是标量张量(或标量数组)。你可以通过ndim属性显示 NumPy 张量的轴数;标量张量有 0 个轴(ndim == 0)。张量的轴数也称为其秩。这是一个 NumPy 标量:
>>> import numpy as np
>>> x = np.array(12)
>>> x
array(12)
>>> x.ndim
0
2.2.2 向量(秩为 1 的张量)
一组数字称为向量,或秩为 1 的张量,或 1D 张量。秩为 1 的张量被称为具有一个轴。以下是一个 NumPy 向量:
>>> x = np.array([12, 3, 6, 14, 7])
>>> x
array([12, 3, 6, 14, 7])
>>> x.ndim
1
这个向量有五个条目,因此被称为5 维向量。不要混淆 5D 向量和 5D 张量!一个 5D 向量只有一个轴,并且沿着轴有五个维度,而一个 5D 张量有五个轴(并且可以有任意数量的维度沿着每个轴)。维度可以表示沿着特定轴的条目数(如我们的 5D 向量的情况),或者张量中轴的数量(比如 5D 张量),这有时可能会令人困惑。在后一种情况下,从技术上讲,谈论秩为 5 的张量更正确(张量的秩是轴的数量),但是不明确的符号5D 张量是常见的。
2.2.3 矩阵(秩为 2 的张量)
一组向量是一个矩阵,或秩为 2 的张量,或 2D 张量。矩阵有两个轴(通常称为行和列)。你可以将矩阵视为一个数字矩形网格。这是一个 NumPy 矩阵:
>>> x = np.array([[5, 78, 2, 34, 0],
[6, 79, 3, 35, 1],
[7, 80, 4, 36, 2]])
>>> x.ndim
2
第一个轴的条目称为行,第二个轴的条目称为列。在前面的示例中,[5, 78, 2, 34, 0]是x的第一行,[5, 6, 7]是第一列。
2.2.4 秩为 3 及更高秩的张量
如果你将这些矩阵打包到一个新数组中,你将得到一个秩为 3 的张量(或 3D 张量),你可以将其视为一个数字立方体。以下是一个 NumPy 秩为 3 的张量:
>>> x = np.array([[[5, 78, 2, 34, 0],
[6, 79, 3, 35, 1],
[7, 80, 4, 36, 2]],
[[5, 78, 2, 34, 0],
[6, 79, 3, 35, 1],
[7, 80, 4, 36, 2]],
[[5, 78, 2, 34, 0],
[6, 79, 3, 35, 1],
[7, 80, 4, 36, 2]]])
>>> x.ndim
3
通过在数组中打包秩为 3 的张量,你可以创建一个秩为 4 的张量,依此类推。在深度学习中,你通常会处理秩为 0 到 4 的张量,尽管如果处理视频数据可能会升到 5。
2.2.5 关键属性
一个张量由三个关键属性定义:
-
轴的数量(秩)—例如,一个秩为 3 的张量有三个轴,一个矩阵有两个轴。这在 Python 库(如 NumPy 或 TensorFlow)中也被称为张量的
ndim。 -
形状—这是一个描述张量沿着每个轴有多少维度的整数元组。例如,前面的矩阵示例的形状为
(3,5),而秩为 3 的张量示例的形状为(3,3,5)。一个向量的形状有一个单一元素,如(5,),而一个标量的形状为空,()。 -
数据类型(通常在 Python 库中称为
dtype)—这是张量中包含的数据类型;例如,张量的类型可以是float16、float32、float64、uint8等。在 TensorFlow 中,您也可能会遇到string张量。
为了更具体地说明这一点,让我们回顾一下在 MNIST 示例中处理的数据。首先,我们加载 MNIST 数据集:
from tensorflow.keras.datasets import mnist
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()
接下来,我们显示张量train_images的轴数,ndim属性:
>>> train_images.ndim
3
这是它的形状:
>>> train_images.shape
(60000, 28, 28)
这是它的数据类型,dtype属性:
>>> train_images.dtype
uint8
因此,我们这里有一个 8 位整数的秩-3 张量。更准确地说,它是一个由 60,000 个 28×28 整数矩阵组成的数组。每个这样的矩阵都是一个灰度图像,系数介于 0 和 255 之间。
让我们使用 Matplotlib 库(Colab 中预装的著名 Python 数据可视化库)显示这个秩-3 张量中的第四个数字;参见图 2.2。

图 2.2 数据集中的第四个样本
列表 2.8 显示第四个数字
import matplotlib.pyplot as plt
digit = train_images[4]
plt.imshow(digit, cmap=plt.cm.binary)
plt.show()
当然,相应的标签是整数 9:
>>> train_labels[4]
9
2.2.6 在 NumPy 中操作张量
在先前的示例中,我们使用语法train_images[i]沿着第一个轴选择了一个特定的数字。在张量中选择特定元素称为张量切片。让我们看看您可以在 NumPy 数组上进行的张量切片操作。
以下示例选择了第 10 到第 100 个数字(不包括第 100 个)并将它们放入形状为(90, 28, 28)的数组中:
>>> my_slice = train_images[10:100]
>>> my_slice.shape
(90, 28, 28)
这等同于更详细的表示法,它为每个张量轴上的切片指定了起始索引和停止索引。请注意,:等同于选择整个轴:
>>> my_slice = train_images[10:100, :, :] # ❶
>>> my_slice.shape
(90, 28, 28)
>>> my_slice = train_images[10:100, 0:28, 0:28] # ❷
>>> my_slice.shape
(90, 28, 28)
❶ 等同于前面的示例
❷ 也等同于前面的示例
通常,您可以在每个张量轴上选择任意两个索引之间的切片。例如,为了选择所有图像右下角的 14×14 像素,您可以这样做:
my_slice = train_images[:, 14:, 14:]
也可以使用负索引。与 Python 列表中的负索引类似,它们表示相对于当前轴末尾的位置。为了将图像裁剪为中心 14×14 像素的补丁,您可以这样做:
my_slice = train_images[:, 7:-7, 7:-7]
2.2.7 数据批次的概念
通常,在深度学习中您会遇到的所有数据张量中的第一个轴(轴 0,因为索引从 0 开始)将是样本轴(有时称为样本维度)。在 MNIST 示例中,“样本”是数字的图像。
此外,深度学习模型不会一次处理整个数据集;相反,它们将数据分成小批次。具体来说,这是我们 MNIST 数字的一个批次,批量大小为 128:
batch = train_images[:128]
这是下一个批次:
batch = train_images[128:256]
和第n批次:
n = 3
batch = train_images[128 * n:128 * (n + 1)]
在考虑这样一个批量张量时,第一个轴(轴 0)被称为批量轴或批量维度。这是您在使用 Keras 和其他深度学习库时经常遇到的术语。
2.2.8 数据张量的现实世界示例
让我们通过几个类似于您以后会遇到的示例来更具体地说明数据张量。您将处理的数据几乎总是属于以下类别之一:
-
向量数据—形状为
(samples,features)的秩-2 张量,其中每个样本是一个数值属性(“特征”)向量 -
时间序列数据或序列数据—形状为
(samples,timesteps,features)的秩-3 张量,其中每个样本是一个长度为timesteps的特征向量序列 -
图像—形状为
(samples,height,width,channels)的秩-4 张量,其中每个样本是一个像素网格,每个像素由一组值(“通道”)表示 -
视频—形状为
(samples,frames,height,width,channels)的秩-5 张量,其中每个样本是一个图像序列(长度为frames)
2.2.9 向量数据
这是最常见的情况之一。在这样的数据集中,每个单个数据点可以被编码为一个向量,因此数据的批次将被编码为一个二阶张量(即向量数组),其中第一个轴是样本轴,第二个轴是特征轴。
让我们看两个例子:
-
一个人们的精算数据集,我们考虑每个人的年龄、性别和收入。每个人可以被描述为一个包含 3 个值的向量,因此一个包含 10 万人的完整数据集可以存储在形状为
(100000, 3)的二阶张量中。 -
一个文本文档数据集,我们通过每个单词在文档中出现的次数(在一个包含 2 万个常见单词的字典中)来表示每个文档。每个文档可以被编码为一个包含 2 万个值的向量(字典中每个单词的计数),因此一个包含 500 个文档的完整数据集可以存储在形状为
(500, 20000)的张量中。
2.2.10 时间序列数据或序列数据
每当数据中涉及时间(或序列顺序的概念)时,将其存储在具有显式时间轴的三阶张量中是有意义的。每个样本可以被编码为一系列向量(一个二阶张量),因此数据的批次将被编码为一个三阶张量(见图 2.3)。

图 2.3 一个三阶时间序列数据张量
按照惯例,时间轴始终是第二轴(索引为 1 的轴)。让我们看几个例子:
-
一个股票价格数据集。每分钟,我们存储股票的当前价格、过去一分钟内的最高价格和最低价格。因此,每分钟被编码为一个三维向量,整个交易日被编码为形状为
(390, 3)的矩阵(一个交易日有 390 分钟),250 天的数据可以存储在形状为(250, 390, 3)的三阶张量中。在这里,每个样本将是一天的数据。 -
一个推文数据集,我们将每条推文编码为一个由 128 个唯一字符组成的字母表中的 280 个字符序列。在这种情况下,每个字符可以被编码为一个大小为 128 的二进制向量(除了在对应字符的索引处有一个 1 条目外,其他都是全零向量)。然后,每条推文可以被编码为形状为
(280, 128)的二阶张量,100 万条推文的数据集可以存储在形状为(1000000, 280, 128)的张量中。
2.2.11 图像数据
图像通常具有三个维度:高度、宽度和颜色深度。尽管灰度图像(如我们的 MNIST 数字)只有一个颜色通道,因此可以存储在二阶张量中,但按照惯例,图像张量始终是三阶的,对于灰度图像有一个一维颜色通道。因此,一个包含 128 个尺寸为 256×256 的灰度图像的批次可以存储在形状为(128, 256, 256, 1)的张量中,而一个包含 128 个彩色图像的批次可以存储在形状为(128, 256, 256, 3)的张量中(见图 2.4)。

图 2.4 一个四阶图像数据张量
图像张量的形状有两种约定:通道最后约定(在 TensorFlow 中是标准的)和通道优先约定(越来越不受青睐)。
通道最后的约定将颜色深度轴放在最后:(样本数, 高度, 宽度, 颜色深度)。与此同时,通道优先的约定将颜色深度轴放在批次轴之后:(样本数, 颜色深度, 高度, 宽度)。使用通道优先的约定,前面的例子将变为(128, 1, 256, 256)和(128, 3, 256, 256)。Keras API 支持这两种格式。
2.2.12 视频数据
视频数据是少数几种需要使用五阶张量的真实世界数据之一。视频可以被理解为一系列帧,每一帧都是一幅彩色图像。因为每一帧可以存储在一个三阶张量中(height, width, color_ depth),一系列帧可以存储在一个四阶张量中(frames, height, width, color_depth),因此一批不同视频可以存储在一个形状为(samples, frames, height, width, color_depth)的五阶张量中。
例如,一个 60 秒、144 × 256 的 YouTube 视频剪辑,每秒采样 4 帧,将有 240 帧。四个这样的视频剪辑批次将存储在一个形状为(4, 240, 144, 256, 3)的张量中。总共有 106,168,320 个值!如果张量的dtype是float32,每个值将以 32 位存储,因此张量将表示 405 MB。非常庞大!在现实生活中遇到的视频要轻得多,因为它们不是以float32存储的,通常会被大幅压缩(例如 MPEG 格式)。
2.3 神经网络的齿轮:张量操作
就像任何计算机程序最终都可以简化为对二进制输入进行的一小组二进制操作(AND、OR、NOR 等)一样,深度神经网络学习到的所有变换都可以简化为应用于数值数据张量的一小组张量操作(或张量函数)。例如,可以对张量进行加法、乘法等操作。
在我们的初始示例中,我们通过将Dense层堆叠在一起来构建我们的模型。一个 Keras 层实例看起来像这样:
keras.layers.Dense(512, activation="relu")
这一层可以被解释为一个函数,它以一个矩阵作为输入并返回另一个矩阵——输入张量的新表示。具体来说,函数如下(其中W是矩阵,b是向量,都是该层的属性):
output = relu(dot(input, W) + b)
让我们详细解释一下。这里有三个张量操作:
-
输入张量和名为
W的张量之间的点积(dot) -
结果矩阵和向量
b之间的加法(+) -
一个
relu操作:relu(x)是max(x,0);relu代表“修正线性单元”
注意 尽管本节完全涉及线性代数表达式,但这里不会找到任何数学符号。我发现,如果将数学概念表达为简短的 Python 代码片段而不是数学方程式,那么没有数学背景的程序员更容易掌握。因此,我们将在整个过程中使用 NumPy 和 TensorFlow 代码。
2.3.1 逐元素操作
relu 操作和加法都是逐元素操作:这些操作独立应用于所考虑张量中的每个条目。这意味着这些操作非常适合于高度并行的实现(矢量化实现,这个术语来自于 20 世纪 70-90 年代的矢量处理器超级计算机架构)。如果你想编写一个逐元素操作的朴素 Python 实现,你会使用一个for循环,就像这个逐元素relu操作的朴素实现中所示:
def naive_relu(x):
assert len(x.shape) == 2 # ❶
x = x.copy() # ❷
for i in range(x.shape[0]):
for j in range(x.shape[1]):
x[i, j] = max(x[i, j], 0)
return x
❶ x 是一个二阶 NumPy 张量。
❷ 避免覆盖输入张量。
你可以对加法做同样的操作:
def naive_add(x, y):
assert len(x.shape) == 2 # ❶
assert x.shape == y.shape
x = x.copy() # ❷
for i in range(x.shape[0]):
for j in range(x.shape[1]):
x[i, j] += y[i, j]
return x
❶ x 和 y 是二阶 NumPy 张量。
❷ 避免覆盖输入张量。
在同样的原则下,你可以进行逐元素乘法、减法等操作。
在实践中,处理 NumPy 数组时,这些操作也作为优化良好的内置 NumPy 函数可用,它们本身将繁重的工作委托给基本线性代数子程序(BLAS)实现。BLAS 是低级别、高度并行、高效的张量操作例程,通常用 Fortran 或 C 实现。
因此,在 NumPy 中,你可以进行以下逐元素操作,速度将非常快:
import numpy as np
z = x + y # ❶
z = np.maximum(z, 0.) # ❷
❶ 逐元素加法
❷ 逐元素 relu
让我们实际计算一下时间差异:
import time
x = np.random.random((20, 100))
y = np.random.random((20, 100))
t0 = time.time()
for _ in range(1000):
z = x + y
z = np.maximum(z, 0.)
print("Took: {0:.2f} s".format(time.time() - t0))
这需要 0.02 秒。与此同时,朴素版本需要惊人的 2.45 秒:
t0 = time.time()
for _ in range(1000):
z = naive_add(x, y)
z = naive_relu(z)
print("Took: {0:.2f} s".format(time.time() - t0))
同样,在 GPU 上运行 TensorFlow 代码时,通过完全向量化的 CUDA 实现执行元素级操作,可以最好地利用高度并行的 GPU 芯片架构。
2.3.2 广播
我们之前天真的实现naive_add仅支持具有相同形状的秩为 2 的张量的加法。但在之前介绍的Dense层中,我们添加了一个秩为 2 的张量和一个向量。当被加的两个张量的形状不同时,加法会发生什么?
在可能的情况下,如果没有歧义,较小的张量将被广播以匹配较大张量的形状。广播包括两个步骤:
-
轴(称为广播轴)被添加到较小的张量中,以匹配较大张量的
ndim。 -
较小的张量沿着这些新轴重复,以匹配较大张量的完整形状。
让我们看一个具体的例子。考虑形状为(32, 10)的X和形状为(10,)的y:
import numpy as np
X = np.random.random((32, 10)) # ❶
y = np.random.random((10,)) # ❷
❶ X 是一个形状为(32, 10)的随机矩阵。
❷ y 是一个 NumPy 向量。
首先,我们向y添加一个空的第一个轴,其形状变为(1, 10):
y = np.expand_dims(y, axis=0) # ❶
❶ y 的形状现在是(1, 10)。
然后,我们沿着这个新轴重复y 32 次,这样我们就得到了一个形状为(32, 10)的张量Y,其中Y[i, :] == y,对于i 在 range(0, 32):
Y = np.concatenate([y] * 32, axis=0) # ❶
❶ 沿着轴 0 重复 y 32 次,得到形状为(32, 10)的 Y。
此时,我们可以继续添加X和Y,因为它们具有相同的形状。
在实现方面,不会创建新的秩为 2 的张量,因为那样会非常低效。重复操作完全是虚拟的:它发生在算法级别而不是内存级别。但想象向量沿着新轴重复 10 次是一个有用的心理模型。以下是天真实现的样子:
def naive_add_matrix_and_vector(x, y):
assert len(x.shape) == 2 # ❶
assert len(y.shape) == 1 # ❷
assert x.shape[1] == y.shape[0]
x = x.copy() # ❸
for i in range(x.shape[0]):
for j in range(x.shape[1]):
x[i, j] += y[j]
return x
❶ x 是一个秩为 2 的 NumPy 张量。
❷ y 是一个 NumPy 向量。
❸ 避免覆盖输入张量。
使用广播,如果一个张量的形状为(a, b, ... n, n + 1, ... m),另一个张量的形状为(n, n + 1, ... m),通常可以执行元素级操作。广播将自动发生在轴a到n - 1。
以下示例通过广播将两个不同形状的张量应用于元素级maximum操作:
import numpy as np
x = np.random.random((64, 3, 32, 10)) # ❶
y = np.random.random((32, 10)) # ❷
z = np.maximum(x, y) # ❸
❶ x 是一个形状为(64, 3, 32, 10)的随机张量。
❷ y 是一个形状为(32, 10)的随机张量。
❸ 输出 z 的形状与 x 相同,为(64, 3, 32, 10)。
2.3.3 张量积
张量积或点积(不要与逐元素乘积,即*运算符混淆)是最常见、最有用的张量操作之一。
在 NumPy 中,使用np.dot函数进行张量积(因为张量积的数学表示通常是一个点):
x = np.random.random((32,))
y = np.random.random((32,))
z = np.dot(x, y)
在数学表示中,您会用一个点(•)表示该操作:
z = x • y
从数学上讲,点操作是什么?让我们从两个向量x和y的点积开始。计算如下:
def naive_vector_dot(x, y):
assert len(x.shape) == 1 # ❶
assert len(y.shape) == 1 # ❶
assert x.shape[0] == y.shape[0]
z = 0.
for i in range(x.shape[0]):
z += x[i] * y[i]
return z
❶ x 和 y 是 NumPy 向量。
您可能已经注意到两个向量之间的点积是一个标量,只有元素数量相同的向量才适用于点积。
您还可以计算矩阵x和向量y之间的点积,返回一个向量,其中系数是y和x的行之间的点积。您可以按如下方式实现它:
def naive_matrix_vector_dot(x, y):
assert len(x.shape) == 2 # ❶
assert len(y.shape) == 1 # ❷
assert x.shape[1] == y.shape[0] # ❸
z = np.zeros(x.shape[0]) # ❹
for i in range(x.shape[0]):
for j in range(x.shape[1]):
z[i] += x[i, j] * y[j]
return z
❶ x 是一个 NumPy 矩阵。
❷ y 是一个 NumPy 向量。
❸ x 的第一个维度必须与 y 的第 0 维度相同!
❹ 此操作返回一个与 y 形状相同的 0 向量。
您还可以重用我们之前编写的代码,这突显了矩阵-向量乘积与向量乘积之间的关系:
def naive_matrix_vector_dot(x, y):
z = np.zeros(x.shape[0])
for i in range(x.shape[0]):
z[i] = naive_vector_dot(x[i, :], y)
return z
请注意,只要两个张量中的一个的ndim大于 1,dot就不再是对称的,也就是说dot(x, y)不等同于dot(y, x)。
当然,点积可以推广到具有任意数量轴的张量。最常见的应用可能是两个矩阵之间的点积。只有当 x.shape[1] == y.shape[0] 时,你才能计算两个矩阵 x 和 y 的点积(dot(x, y))。结果是一个形状为 (x.shape[0], y.shape[1]) 的矩阵,其中系数是 x 的行和 y 的列之间的向量积。这是一个简单的实现:
def naive_matrix_dot(x, y):
assert len(x.shape) == 2 # ❶
assert len(y.shape) == 2 # ❶
assert x.shape[1] == y.shape[0] # ❷
z = np.zeros((x.shape[0], y.shape[1])) # ❸
for i in range(x.shape[0]): # ❹
for j in range(y.shape[1]): # ❺
row_x = x[i, :]
column_y = y[:, j]
z[i, j] = naive_vector_dot(row_x, column_y)
return z
❶ x 和 y 是 NumPy 矩阵。
❷ x 的第一个维度必须与 y 的第 0 维度相同!
❸ 此操作返回一个具有特定形状的零矩阵。
❹ 迭代 x 的行 . . .
❺ . . . 并在 y 的列上。
要理解点积形状兼容性,有助于通过将输入和输出张量对齐来可视化它们,如图 2.5 所示。

图 2.5 矩阵点积框图
在图中,x、y 和 z 被描绘为矩形(系数的字面框)。因为 x 的行和 y 的列必须具有相同的大小,所以 x 的宽度必须与 y 的高度匹配。如果你继续开发新的机器学习算法,你可能会经常画这样的图。
更一般地,你可以按照前面为 2D 情况概述的相同形状兼容性规则,计算更高维度张量之间的点积:
(a, b, c, d) • (d,) → (a, b, c)
(a, b, c, d) • (d, e) → (a, b, c, e)
等等。
2.3.4 张量重塑
理解的第三种张量操作是张量重塑。虽然在我们第一个神经网络示例中的Dense层中没有使用它,但在将手写数字数据输入模型之前对数据进行预处理时使用了它:
train_images = train_images.reshape((60000, 28 * 28))
重塑张量意味着重新排列其行和列以匹配目标形状。显然,重塑后的张量与初始张量具有相同数量的系数。通过简单的例子最容易理解重塑:
>>> x = np.array([[0., 1.],
[2., 3.],
[4., 5.]])
>>> x.shape
(3, 2)
>>> x = x.reshape((6, 1))
>>> x
array([[ 0.],
[ 1.],
[ 2.],
[ 3.],
[ 4.],
[ 5.]])
>>> x = x.reshape((2, 3))
>>> x
array([[ 0., 1., 2.],
[ 3., 4., 5.]])
常见的重塑的一个特殊情况是转置。转置矩阵意味着交换其行和列,使得 x[i, :] 变为 x[:, i]:
>>> x = np.zeros((300, 20)) # ❶
>>> x = np.transpose(x)
>>> x.shape
(20, 300)
❶ 创建一个形状为 (300, 20) 的全零矩阵
2.3.5 张量操作的几何解释
因为张量操作中的张量内容可以被解释为某个几何空间中点的坐标,所以所有张量操作都有几何解释。例如,让我们从以下向量开始:
A = [0.5, 1]
这是二维空间中的一个点(参见图 2.6)。通常将向量描绘为连接原点和点的箭头,如图 2.7 所示。

图 2.6 二维空间中的一个点

图 2.7 作为箭头的二维空间中的点
让我们考虑一个新点,B = [1, 0.25],我们将其添加到之前的点上。这是通过将向量箭头链接在一起几何地完成的,结果位置是代表前两个向量之和的向量(参见图 2.8)。如你所见,将向量 B 添加到向量 A 表示将点 A 复制到一个新位置,其距离和方向从原始点 A 确定为向量 B。如果你将相同的向量加法应用于平面上的一组点(一个“对象”),你将在一个新位置创建整个对象的副本(参见图 2.9)。因此,张量加法表示平移对象(在不扭曲对象的情况下移动对象)到某个方向的某个距离。

图 2.8 两个向量之和的几何解释
一般来说,诸如平移、旋转、缩放、倾斜等基本几何操作可以表示为张量操作。以下是一些例子:
-
平移:正如你刚刚看到的,向点添加一个向量将使点沿着固定方向移动固定量。应用于一组点(如 2D 对象),这称为“平移”(见图 2.9)。
![]()
图 2.9 2D 平移作为向量相加
-
旋转:通过角度θ逆时针旋转 2D 向量(见图 2.10)可以通过与 2 × 2 矩阵
R=[[cos(theta),-sin(theta)],[sin(theta),cos(theta)]]进行点积实现。![]()
图 2.10 2D 旋转(逆时针)作为点积
-
缩放:图像的垂直和水平缩放(见图 2.11)可以通过与 2 × 2 矩阵
S=[[horizontal_factor,0],[0,vertical_factor]]进行点积实现(请注意,这样的矩阵称为“对角矩阵”,因为它只在从左上到右下的“对角线”上有非零系数)。![]()
图 2.11 2D 缩放作为点积
-
线性变换:与任意矩阵进行点积实现了线性变换。请注意,前面列出的缩放和旋转按定义都是线性变换。
-
仿射变换:仿射变换(见图 2.12)是线性变换(通过与某些矩阵进行点积实现)和平移(通过向量相加实现)的组合。你可能已经意识到,这正是
Dense层实现的y=W•x+b计算!没有激活函数的Dense层就是一个仿射层。![]()
图 2.12 平面中的仿射变换
-
带有
relu激活的 Dense 层:关于仿射变换的一个重要观察是,如果你重复应用许多次,最终仍然得到一个仿射变换(因此你可以一开始就应用那一个仿射变换)。让我们尝试两次: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)。通过深度学习,这将被实现为对 3D 空间的一系列简单变换,就像你可以用手指在纸球上一次移动一次一样。

图 2.14 展开复杂数据流形
展开纸团就是机器学习的目的:在高维空间中找到复杂、高度折叠数据流形的整洁表示(流形是一个连续的表面,就像我们折叠的纸张)。此时,你应该对为什么深度学习擅长这一点有很好的直觉:它采用逐步将复杂的几何变换分解为一长串基本变换的方法,这几乎就是人类展开纸团时会遵循的策略。深度网络中的每一层应用一个能稍微解开数据的变换,而深层堆叠的层使得一个极其复杂的解开过程变得可行。
2.4 神经网络的引擎:基于梯度的优化
正如你在前一节中看到的,我们第一个模型示例中的每个神经层将其输入数据转换如下:
output = relu(dot(input, W) + b)
在这个表达式中,W和b是层的属性的张量。它们被称为层的权重或可训练参数(分别是kernel和bias属性)。这些权重包含了模型从训练数据中学到的信息。
最初,这些权重矩阵被填充了小的随机值(这一步被称为随机初始化)。当W和b是随机的时候,当然没有理由期望relu(dot(input, W) + b)会产生任何有用的表示。得到的表示是毫无意义的,但它们是一个起点。接下来要做的是逐渐调整这些权重,基于一个反馈信号。这种逐渐调整,也称为训练,就是机器学习的学习过程。
这发生在所谓的训练循环中,其工作方式如下。重复这些步骤直到损失看起来足够低:
-
绘制一批训练样本
x和相应的目标y_true。 -
在
x上运行模型(称为前向传播)以获得预测值y_pred。 -
计算模型在批次上的损失,这是
y_pred和y_true之间的不匹配度的度量。 -
更新模型的所有权重,以稍微减少这一批次上的损失。
最终,你会得到一个在训练数据上损失非常低的模型:预测值y_pred与期望目标y_true之间的匹配度很低。模型已经“学会”将其输入映射到正确的目标。从远处看,这可能看起来像魔术,但当你将其简化为基本步骤时,它其实很简单。
第一步听起来足够简单——只是 I/O 代码。第二步和第三步仅仅是应用少量张量操作,所以你可以纯粹根据你在前一节中学到的内容来实现这些步骤。困难的部分在于第四步:更新模型的权重。给定模型中的一个单独权重系数,你如何计算这个系数应该增加还是减少,以及增加多少?
一个天真的解决方案是冻结模型中除了正在考虑的一个标量系数之外的所有权重,并尝试不同的值来调整这个系数。假设系数的初始值是 0.3。在一批数据上进行前向传播后,模型在该批次上的损失为 0.5。如果你将系数的值更改为 0.35 并重新运行前向传播,损失增加到 0.6。但如果你将系数降低到 0.25,损失降至 0.4。在这种情况下,似乎通过减小系数-0.05 来有助于最小化损失。这将需要对模型中的所有系数重复进行。
但这样的方法将非常低效,因为你需要为每个单独的系数(通常有成千上万甚至数百万个)计算两次前向传播(这是昂贵的)。幸运的是,有一个更好的方法:梯度下降。
梯度下降是现代神经网络的优化技术。这是其要点。我们模型中使用的所有函数(如 dot 或 +)以平滑连续的方式转换其输入:例如,如果你看 z = x + y,那么 y 的微小变化只会导致 z 的微小变化,如果你知道 y 变化的方向,你就可以推断出 z 变化的方向。从数学上讲,你会说这些函数是可导的。如果你将这些函数链接在一起,你得到的更大函数仍然是可导的。特别是,这适用于将模型系数映射到批量数据上的模型损失的函数:模型系数的微小变化导致损失值的微小、可预测的变化。这使你能够使用一种称为梯度的数学运算符描述损失随着你将模型系数朝不同方向移动而变化的方式。如果你计算这个梯度,你可以使用它来移动系数(一次性全部更新,而不是逐个更新),朝着减小损失的方向移动系数。
如果你已经知道可导的含义和梯度是什么,你可以跳到第 2.4.3 节。否则,接下来的两节将帮助你理解这些概念。
2.4.1 什么是导数?
考虑一个连续、平滑的函数 f(x) = y,将一个数字 x 映射到一个新的数字 y。我们可以以图 2.15 中的函数作为例子。

图 2.15 一个连续、平滑的函数
因为函数是连续的,x 的微小变化只会导致 y 的微小变化——这就是连续性背后的直觉。假设你将 x 增加一个小因子 epsilon_x:这会导致 y 有一个小的 epsilon_y 变化,如图 2.16 所示。

图 2.16 对于连续函数,x 的微小变化导致 y 的微小变化。
此外,因为函数是平滑的(其曲线没有任何突然的角度),当 epsilon_x 足够小,围绕某一点 p,可以将 f 近似为斜率 a 的线性函数,使得 epsilon_y 变为 a * epsilon_x:
f(x + epsilon_x) = y + a * epsilon_x
显然,这种线性近似仅在 x 足够接近 p 时才有效。
斜率 a 在 p 处被称为 f 的导数。如果 a 是负的,这意味着在 p 附近将 x 稍微增加会导致 f(x) 减少(如图 2.17 所示),如果 a 是正的,将 x 稍微增加会导致 f(x) 增加。此外,a 的绝对值(导数的大小)告诉你这种增加或减少会有多快发生。

图 2.17 在 p 处的 f 的导数
对于每个可导函数 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 的导数,那么你的任务就完成了:导数完全描述了当你改变 x 时 f(x) 的演变方式。如果你想减小 f(x) 的值,你只需要将 x 沿着导数的相反方向移动一点。
2.4.2 张量操作的导数:梯度
我们刚刚看的函数将标量值x转换为另一个标量值y:你可以将其绘制为二维平面上的曲线。现在想象一个将标量元组(x, y)转换为标量值z的函数:那将是一个矢量操作。你可以将其绘制为三维空间中的二维表面(由坐标x, y, z索引)。同样,你可以想象将矩阵作为输入的函数,将秩-3 张量作为输入的函数等。
导数的概念可以应用于任何这样的函数,只要它们描述的表面是连续且光滑的。张量操作(或张量函数)的导数称为梯度。梯度只是将导数的概念推广到以张量作为输入的函数。还记得对于标量函数,导数代表函数曲线的局部斜率吗?同样,张量函数的梯度代表函数描述的多维表面的曲率。它描述了当输入参数变化时函数输出如何变化。
让我们看一个基于机器学习的例子。
-
一个输入向量
x(数据集中的样本) -
一个矩阵
W(模型的权重) -
一个目标
y_true(模型应该学会将其与x关联起来的内容) -
一个损失函数
loss(旨在衡量模型当前预测与y_true之间的差距)
你可以使用W计算目标候选y_pred,然后计算目标候选y_pred与目标y_true之间的损失或不匹配:
y_pred = dot(W, x) # ❶
loss_value = loss(y_pred, y_true) # ❷
❶ 我们使用模型权重W来对x进行预测。
❷ 我们估计预测有多大偏差。
现在我们想要使用梯度来找出如何更新W以使loss_value变小。我们该如何做?
给定固定的输入x和y_true,前述操作可以解释为将W(模型的权重)的值映射到损失值的函数:
loss_value = f(W) # ❶
❶ f 描述了当 W 变化时损失值形成的曲线(或高维表面)。
假设当前W的值为W0。那么在点W0处的f的导数是一个张量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[i, j]系数的导数,假设所有其他系数都是常数。grad_ij称为相对于W[i, j]的f 的偏导数。
具体来说,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是必需的,因为当您接近W0时,grad(loss_value, W0)仅近似曲率,因此您不希望离W0太远。
2.4.3 随机梯度下降
鉴于可微函数,从理论上讲,可以通过分析找到其最小值:已知函数的最小值是导数为 0 的点,因此您只需找到所有导数为 0 的点,并检查这些点中哪个点的函数值最低。
应用于神经网络,意味着找到分析上产生最小可能损失函数的权重值的组合。这可以通过解方程grad(f(W), W) = 0来实现W。这是一个N个变量的多项式方程,其中N是模型中的系数数量。虽然对于N = 2或N = 3可以解决这样的方程,但对于真实的神经网络来说,这是不可行的,因为参数数量从不少于几千个,通常可以达到几千万个。
相反,您可以使用本节开头概述的四步算法:根据随机数据批次的当前损失值逐渐修改参数。因为您正在处理可微函数,所以可以计算其梯度,这为您实现第 4 步提供了一种高效的方法。如果您根据梯度的相反方向更新权重,那么每次损失都会减少一点:
-
绘制一批训练样本
x和相应的目标y_true。 -
在
x上运行模型以获得预测值y_pred(这称为前向传递)。 -
计算模型在批次上的损失,即
y_pred和y_true之间的不匹配度的度量。 -
计算损失相对于模型参数的梯度(这称为反向传递)。
-
将参数稍微朝着梯度的相反方向移动,例如
W-=learning_rate*gradient,从而在批次上减少一点损失。学习率(这里是learning_rate)将是一个标量因子,调节梯度下降过程的“速度”。
很简单!我们刚刚描述的是小批量随机梯度下降(mini-batch SGD)。术语随机指的是每个数据批次都是随机抽取的(随机是随机的科学同义词)。图 2.18 说明了在 1D 中发生的情况,当模型只有一个参数且您只有一个训练样本时。

图 2.18 SGD 沿着 1D 损失曲线下降(一个可学习参数)
如您所见,直观上选择合理的learning_rate因子值很重要。如果太小,曲线下降将需要许多迭代,并且可能会陷入局部最小值。如果learning_rate太大,您的更新可能会使您完全随机地移动到曲线上的位置。
请注意,小批量 SGD 算法的一个变体是在每次迭代中绘制单个样本和目标,而不是绘制一批数据。这将是真正的SGD(而不是小批量SGD)。或者,走向相反的极端,您可以在所有可用数据上运行每一步,这被称为批量梯度下降。然后,每次更新将更准确,但成本更高。在这两个极端之间的有效折衷方案是使用合理大小的小批量。
尽管图 2.18 展示了在 1D 参数空间中的梯度下降,但在实践中,您将在高维空间中使用梯度下降:神经网络中的每个权重系数都是空间中的一个自由维度,可能有成千上万甚至数百万个。为了帮助您建立对损失曲面的直觉,您还可以将梯度下降可视化为 2D 损失曲面上的过程,如图 2.19 所示。但您不可能可视化训练神经网络的实际过程——您无法以人类能理解的方式表示一个 1000000 维空间。因此,要记住通过这些低维表示形成的直觉在实践中可能并不总是准确的。这在深度学习研究领域历史上一直是一个问题。

图 2.19 梯度下降在 2D 损失曲面上(两个可学习参数)
另外,还有多种 SGD 的变体,它们在计算下一个权重更新时考虑了先前的权重更新,而不仅仅是查看梯度的当前值。例如,有带有动量的 SGD,以及 Adagrad、RMSprop 等几种。这些变体被称为优化方法或优化器。特别是,许多这些变体中使用的动量概念值得关注。动量解决了 SGD 的两个问题:收敛速度和局部最小值。考虑图 2.20,显示了损失作为模型参数函数的曲线。

图 2.20 一个局部最小值和一个全局最小值
如您所见,在某个参数值附近,存在一个局部最小值:在该点附近,向左移动会导致损失增加,但向右移动也是如此。如果正在通过具有较小学习率的 SGD 优化考虑的参数,则优化过程可能会卡在局部最小值处,而不是朝着全局最小值前进。
您可以通过使用动量来避免这些问题,动量从物理学中汲取灵感。在这里一个有用的心理形象是将优化过程视为一个小球沿着损失曲线滚动。如果它有足够的动量,小球就不会卡在峡谷中,最终会到达全局最小值。动量的实现是基于每一步移动小球的不仅仅是当前斜率值(当前加速度),还有当前速度(由过去加速度产生)。在实践中,这意味着根据不仅仅是当前梯度值,还有先前参数更新来更新参数w,就像在这个简单实现中一样:
past_velocity = 0.
momentum = 0.1 # ❶
while loss > 0.01: # ❷
w, loss, gradient = get_current_parameters()
velocity = past_velocity * momentum - learning_rate * gradient
w = w + momentum * velocity - learning_rate * gradient
past_velocity = velocity
update_parameter(w)
❶ 恒定的动量因子
❷ 优化循环
2.4.4 链式求导:反向传播算法
在前面的算法中,我们随意假设因为一个函数是可微的,我们可以轻松计算它的梯度。但这是真的吗?在实践中如何计算复杂表达式的梯度?在我们本章开始的两层模型中,如何计算损失相对于权重的梯度?这就是反向传播算法的作用。
链式法则
反向传播是一种利用简单操作的导数(如加法、relu 或张量乘积)来轻松计算这些原子操作任意复杂组合的梯度的方法。关键是,神经网络由许多张量操作链在一起组成,每个操作都有简单的已知导数。例如,列表 2.2 中定义的模型可以表示为由变量W1、b1、W2和b2(分别属于第一和第二个Dense层)参数化的函数,涉及原子操作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)):
def fg(x):
x1 = g(x)
y = f(x1)
return y
然后链式法则表明grad(y, x) == grad(y, x1) * grad(x1, x)。只要您知道f和g的导数,就可以计算fg的导数。链式法则之所以被命名为链式法则,是因为当您添加更多中间函数时,它开始看起来像一个链条:
def fghj(x):
x1 = j(x)
x2 = h(x1)
x3 = g(x2)
y = f(x3)
return y
grad(y, x) == (grad(y, x3) * grad(x3, x2) *
grad(x2, x1) * grad(x1, x))
将链式法则应用于神经网络梯度值的计算会产生一种称为反向传播的算法。让我们看看具体是如何工作的。
使用计算图进行自动微分
以计算图的方式思考反向传播是一种有用的方式。计算图是 TensorFlow 和深度学习革命的核心数据结构。它是操作的有向无环图 - 在我们的情况下,是张量操作。例如,图 2.21 显示了我们第一个模型的图表示。

图 2.21 我们两层模型的计算图表示
计算图在计算机科学中是一个非常成功的抽象,因为它使我们能够将计算视为数据:可计算表达式被编码为一种可用作另一个程序的输入或输出的机器可读数据结构。例如,您可以想象一个接收计算图并返回实现相同计算的大规模分布式版本的新计算图的程序 - 这意味着您可以分发任何计算而无需自己编写分发逻辑。或者想象一个接收计算图并可以自动生成其表示的表达式的导数的程序。如果您的计算表达为显式图数据结构而不是.py 文件中的 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)。

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

图 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也会变化相同的量。 -
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)。

图 2.25 从loss_val到w的反向图路径
通过将链式法则应用于我们的图表,我们得到了我们要找的内容:
-
grad(loss_val,w)=1*1*2=2 -
grad(loss_val,b)=1*1=1
注意:如果在反向图中存在多条连接两个感兴趣节点a和b的路径,我们可以通过对所有路径的贡献求和来得到grad(b, a)。
通过这样,你刚刚看到了反向传播的过程!反向传播简单地是将链式法则应用于计算图。没有更多了。反向传播从最终损失值开始,从顶层向底层向后计算每个参数对损失值的贡献。这就是“反向传播”这个名字的由来:我们在计算图中“反向传播”不同节点的损失贡献。
如今,人们在现代框架中实现神经网络,这些框架能够进行自动微分,例如 TensorFlow。自动微分是使用你刚刚看到的计算图实现的。自动微分使得能够检索任意可微张量操作组合的梯度成为可能,而无需额外工作,只需编写前向传播。在 2000 年代我用 C 语言编写我的第一个神经网络时,我不得不手动编写梯度。现在,由于现代自动微分工具,你永远不必自己实现反向传播。算你运气好!
TensorFlow 中的梯度磁带
你可以利用 TensorFlow 强大的自动微分功能的 API 是GradientTape。它是一个 Python 范围,将在其中运行的张量操作“记录”为计算图(有时称为“磁带”)。然后可以使用此图检索任何输出相对于任何变量或一组变量(tf.Variable类的实例)的梯度。tf.Variable是一种特定类型的张量,用于保存可变状态,例如神经网络的权重始终是tf.Variable实例。
import tensorflow as tf
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(tf.random.uniform((2, 2))) # ❶
with tf.GradientTape() as tape:
y = 2 * x + 3
grad_of_y_wrt_x = tape.gradient(y, x) # ❷
❶ 实例化一个形状为(2, 2)且初始值全为零的变量。
❷ grad_of_y_wrt_x是一个形状为(2, 2)(像 x 一样)的张量,描述了 y = 2 * a + 3 在 x = [[0, 0], [0, 0]]周围的曲率。
它也适用于变量列表:
W = tf.Variable(tf.random.uniform((2, 2)))
b = tf.Variable(tf.zeros((2,)))
x = tf.random.uniform((2, 2))
with tf.GradientTape() as tape:
y = tf.matmul(x, W) + b # ❶
grad_of_y_wrt_W_and_b = tape.gradient(y, [W, b]) # ❷
❶ matmul 是在 TensorFlow 中表示“点积”的方式。
❷ grad_of_y_wrt_W_and_b 是两个张量列表,形状与 W 和 b 相同。
你将在下一章学习关于梯度带的知识。
2.5 回顾我们的第一个例子
你已经接近本章的结束,现在应该对神经网络背后的运作有一个大致的了解。在本章开始时是一个神奇的黑匣子,现在已经变成了一个更清晰的画面,如图 2.26 所示:模型由相互链接的层组成,将输入数据映射到预测结果。损失函数然后将这些预测与目标进行比较,产生一个损失值:衡量模型预测与预期值匹配程度的指标。优化器使用这个损失值来更新模型的权重。

图 2.26 网络、层、损失函数和优化器之间的关系
让我们回到本章的第一个例子,并根据你学到的知识来逐一审查每个部分。
这是输入数据:
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()
train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype("float32") / 255
test_images = test_images.reshape((10000, 28 * 28))
test_images = test_images.astype("float32") / 255
现在你明白了输入图像存储在 NumPy 张量中,这里格式化为(60000, 784)(训练数据)和(10000, 784)(测试数据)的float32张量。
这是我们的模型:
model = keras.Sequential([
layers.Dense(512, activation="relu"),
layers.Dense(10, activation="softmax")
])
现在你明白了这个模型由两个Dense层的链条组成,每个层对输入数据应用了一些简单的张量操作,并且这些操作涉及权重张量。权重张量是属于层的属性,是模型的知识所在。
这是模型编译步骤:
model.compile(optimizer="rmsprop",
loss="sparse_categorical_crossentropy",
metrics=["accuracy"])
现在你明白了sparse_categorical_crossentropy是用作学习权重张量的反馈信号的损失函数,训练阶段将尝试最小化它。你还知道这种损失的减少是通过小批量随机梯度下降来实现的。具体规则由作为第一个参数传递的rmsprop优化器定义。
最后,这是训练循环:
model.fit(train_images, train_labels, epochs=5, batch_size=128)
现在你明白了当你调用fit时会发生什么:模型将开始在 128 个样本的小批量数据上进行 5 次迭代(每次迭代所有训练数据都被称为epoch)。对于每个批次,模型将计算损失相对于权重的梯度(使用源自微积分链式法则的反向传播算法),并将权重朝着减少该批次损失值的方向移动。
在这 5 个 epoch 之后,模型将执行 2,345 次梯度更新(每个 epoch 469 次),并且模型的损失将足够低,以至于模型能够以高准确度对手写数字进行分类。
在这一点上,你已经了解了大部分关于神经网络的常识。让我们通过逐步在 TensorFlow 中“从头开始”重新实现那个第一个例子来证明它。
2.5.1 在 TensorFlow 中从头开始重新实现我们的第一个例子
有什么比从头开始实现一切更能展示出完全、明确的理解呢?当然,“从头开始”在这里是相对的:我们不会重新实现基本的张量操作,也不会实现反向传播。但我们会降到一个低到几乎不使用任何 Keras 功能的水平。
如果你现在还不理解这个例子中的每一个细节,不要担心。下一章将更详细地深入探讨 TensorFlow API。现在,只需尝试理解正在发生的事情的要点——这个例子的目的是帮助你通过具体实现来澄清对深度学习数学的理解。让我们开始吧!
一个简单的 Dense 类
你之前学过Dense层实现以下输入转换,其中W和b是模型参数,activation是逐元素函数(通常是relu,但对于最后一层可能是softmax):
output = activation(dot(W, input) + b)
让我们实现一个简单的 Python 类NaiveDense,它创建两个 TensorFlow 变量W和b,并公开一个__call__()方法,应用前述转换。
import tensorflow as tf
class NaiveDense:
def __init__(self, input_size, output_size, activation):
self.activation = activation
w_shape = (input_size, output_size) # ❶
w_initial_value = tf.random.uniform(w_shape, minval=0, maxval=1e-1)
self.W = tf.Variable(w_initial_value)
b_shape = (output_size, # ❷
b_initial_value = tf.zeros(b_shape)
self.b = tf.Variable(b_initial_value)
def __call__(self, inputs):: # ❸
return self.activation(tf.matmul(inputs, self.W) + self.b)
@property
def weights(self): # ❹
return [self.W, self.b]
❶ 创建一个形状为(input_size, output_size)的矩阵 W,用随机值初始化。
❷ 创建一个形状为(output_size,)的向量 b,用零初始化。
❸ 应用前向传播。
❹ 用于检索层权重的便利方法
一个简单的 Sequential 类
现在,让我们创建一个NaiveSequential类来链接这些层。它包装了一系列层,并公开一个__call__()方法,简单地按顺序在输入上调用底层层。它还具有一个weights属性,方便跟踪层的参数。
class NaiveSequential:
def __init__(self, layers):
self.layers = layers
def __call__(self, inputs):
x = inputs
for layer in self.layers:
x = layer(x)
return x
@property
def weights(self):
weights = []
for layer in self.layers:
weights += layer.weights
return weights
使用这个NaiveDense类和这个NaiveSequential类,我们可以创建一个模拟的 Keras 模型:
model = NaiveSequential([
NaiveDense(input_size=28 * 28, output_size=512, activation=tf.nn.relu),
NaiveDense(input_size=512, output_size=10, activation=tf.nn.softmax)
])
assert len(model.weights) == 4
一个批生成器
接下来,我们需要一种方法以小批量迭代 MNIST 数据。这很容易:
import math
class BatchGenerator:
def __init__(self, images, labels, batch_size=128):
assert len(images) == len(labels)
self.index = 0
self.images = images
self.labels = labels
self.batch_size = batch_size
self.num_batches = math.ceil(len(images) / batch_size)
def next(self):
images = self.images[self.index : self.index + self.batch_size]
labels = self.labels[self.index : self.index + self.batch_size]
self.index += self.batch_size
return images, labels
2.5.2 运行一个训练步骤
这个过程中最困难的部分是“训练步骤”:在一个数据批次上运行模型后更新模型的权重。我们需要
-
计算模型对批次中图像的预测。
-
计算这些预测的损失值,给定实际标签。
-
计算损失相对于模型权重的梯度。
-
将权重沿着梯度相反的方向移动一小步。
要计算梯度,我们将使用在第 2.4.4 节中介绍的 TensorFlow GradientTape对象:
def one_training_step(model, images_batch, labels_batch):
with tf.GradientTape() as tape: # ❶
predictions = model(images_batch) # ❶
per_sample_losses = tf.keras.losses.sparse_categorical_crossentropy(# ❶
labels_batch, predictions) # ❶
average_loss = tf.reduce_mean(per_sample_losses) # ❶
gradients = tape.gradient(average_loss, model.weights) # ❷
update_weights(gradients, model.weights) # ❸
return average_loss
❶ 运行“前向传播”(在 GradientTape 范围内计算模型的预测)。
❷ 计算损失相对于权重的梯度。输出梯度是一个列表,其中每个条目对应于模型权重列表中的一个权重。
❸ 使用梯度更新权重(我们将很快定义这个函数)。
正如你已经知道的,“权重更新”步骤的目的(由前面的update_weights函数表示)是将权重向“减少此批次上的损失”的方向移动一点。移动的大小由“学习率”确定,通常是一个小量。实现这个update_weights函数的最简单方法是从每个权重中减去gradient * learning_rate:
learning_rate = 1e-3
def update_weights(gradients, weights):
for g, w in zip(gradients, weights):
w.assign_sub(g * learning_rate) # ❶
❶ assign_sub是 TensorFlow 变量的-=的等效操作。
在实践中,你几乎永远不会手动实现这样的权重更新步骤。相反,你会使用 Keras 中的Optimizer实例,就像这样:
from tensorflow.keras import optimizers
optimizer = optimizers.SGD(learning_rate=1e-3)
def update_weights(gradients, weights):
optimizer.apply_gradients(zip(gradients, weights))
现在我们的每批训练步骤已经准备好,我们可以继续实现整个训练时期。
2.5.3 完整的训练循环
训练的一个时期简单地包括对训练数据中的每个批次重复进行训练步骤,完整的训练循环只是一个时期的重复:
def fit(model, images, labels, epochs, batch_size=128):
for epoch_counter in range(epochs):
print(f"Epoch {epoch_counter}")
batch_generator = BatchGenerator(images, labels)
for batch_counter in range(batch_generator.num_batches):
images_batch, labels_batch = batch_generator.next()
loss = one_training_step(model, images_batch, labels_batch)
if batch_counter % 100 == 0:
print(f"loss at batch {batch_counter}: {loss:.2f}")
让我们来试一下:
from tensorflow.keras.datasets import mnist
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()
train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype("float32") / 255
test_images = test_images.reshape((10000, 28 * 28))
test_images = test_images.astype("float32") / 255
fit(model, train_images, train_labels, epochs=10, batch_size=128)
2.5.4 评估模型
我们可以通过对测试图像的预测取argmax,并将其与预期标签进行比较来评估模型:
predictions = model(test_images)
predictions = predictions.numpy() # ❶
predicted_labels = np.argmax(predictions, axis=1)
matches = predicted_labels == test_labels
print(f"accuracy: {matches.mean():.2f}")
❶ 在 TensorFlow 张量上调用.numpy()会将其转换为 NumPy 张量。
完成了!正如你所看到的,手动完成“几行 Keras 代码可以完成的工作”需要做很多工作。但是因为你已经经历了这些步骤,现在应该对在调用fit()时神经网络内部发生的事情有一个清晰的理解。拥有这种低级别的心智模型,了解代码在幕后执行的操作,将使你更能利用 Keras API 的高级功能。
摘要
-
张量构成现代机器学习系统的基础。它们具有各种
dtype、rank和shape。 -
你可以通过张量操作(如加法、张量积或逐元素乘法)来操作数值张量,这可以被解释为编码几何变换。总的来说,深度学习中的一切都可以被解释为几何解释。
-
深度学习模型由一系列简单的张量操作组成,由权重参数化,它们本身也是张量。模型的权重是存储其“知识”的地方。
-
学习意味着找到一组值,使模型的权重最小化给定一组训练数据样本及其对应目标的损失函数。
-
学习是通过随机抽取数据样本及其目标,并计算模型参数相对于批次上的损失的梯度来实现的。然后,模型参数向相反方向移动一点(移动的大小由学习率定义)。这被称为小批量随机梯度下降。
-
整个学习过程之所以可能,是因为神经网络中的所有张量操作都是可微的,因此可以应用导数的链式法则来找到将当前参数和当前数据批次映射到梯度值的梯度函数。这被称为反向传播。
-
你将经常在未来章节中看到的两个关键概念是损失和优化器。这是在开始向模型输入数据之前需要定义的两件事。
-
损失是在训练过程中你将尝试最小化的量,因此它应该代表你尝试解决的任务的成功度量。
-
优化器指定了损失的梯度将如何用于更新参数的确切方式:例如,可以是 RMSProp 优化器、带动量的 SGD 等。
-
三、Keras 和 TensorFlow 简介
本章内容包括
-
仔细研究 TensorFlow、Keras 及它们之间的关系
-
设置深度学习工作空间
-
深入了解核心深度学习概念如何转化为 Keras 和 TensorFlow
本章旨在为您提供开始实践深度学习所需的一切。我将为您快速介绍 Keras(keras.io)和 TensorFlow(tensorflow.org),这是本书中将使用的基于 Python 的深度学习工具。您将了解如何设置深度学习工作空间,使用 TensorFlow、Keras 和 GPU 支持。最后,基于您在第二章中对 Keras 和 TensorFlow 的初步接触,我们将回顾神经网络的核心组件以及它们如何转化为 Keras 和 TensorFlow 的 API。
到本章结束时,您将准备好进入实际的现实世界应用程序,这将从第四章开始。
3.1 什么是 TensorFlow?
TensorFlow 是一个基于 Python 的免费、开源的机器学习平台,主要由 Google 开发。与 NumPy 类似,TensorFlow 的主要目的是使工程师和研究人员能够在数值张量上操作数学表达式。但是 TensorFlow 在以下方面远远超出了 NumPy 的范围:
-
它可以自动计算任何可微表达式的梯度(正如您在第二章中看到的),使其非常适合机器学习。
-
它不仅可以在 CPU 上运行,还可以在 GPU 和 TPU 上运行,高度并行的硬件加速器。
-
在 TensorFlow 中定义的计算可以轻松地分布到许多机器上。
-
TensorFlow 程序可以导出到其他运行时,例如 C++、JavaScript(用于基于浏览器的应用程序)或 TensorFlow Lite(用于在移动设备或嵌入式设备上运行的应用程序)等。这使得 TensorFlow 应用程序在实际环境中易于部署。
重要的是要记住,TensorFlow 远不止是一个单一的库。它实际上是一个平台,拥有庞大的组件生态系统,其中一些由 Google 开发,一些由第三方开发。例如,有用于强化学习研究的 TF-Agents,用于工业强度机器学习工作流管理的 TFX,用于生产部署的 TensorFlow Serving,以及预训练模型的 TensorFlow Hub 存储库。这些组件共同涵盖了非常广泛的用例,从前沿研究到大规模生产应用。
TensorFlow 的扩展性相当不错:例如,奥克岭国家实验室的科学家们已经使用它在 IBM Summit 超级计算机的 27000 个 GPU 上训练了一个 1.1 艾克斯佛洛普的极端天气预测模型。同样,谷歌已经使用 TensorFlow 开发了非常计算密集的深度学习应用程序,例如下棋和围棋代理 AlphaZero。对于您自己的模型,如果有预算,您可以实际上希望在小型 TPU 架或在 Google Cloud 或 AWS 上租用的大型 GPU 集群上扩展到约 10 petaFLOPS。这仍然约占 2019 年顶级超级计算机峰值计算能力的 1%!
3.2 什么是 Keras?
Keras 是一个基于 TensorFlow 的 Python 深度学习 API,提供了一种方便的方式来定义和训练任何类型的深度学习模型。Keras 最初是为研究而开发的,旨在实现快速的深度学习实验。
通过 TensorFlow,Keras 可以在不同类型的硬件上运行(见图 3.1)—GPU、TPU 或普通 CPU,并且可以无缝地扩展到数千台机器。

图 3.1 Keras 和 TensorFlow:TensorFlow 是一个低级张量计算平台,而 Keras 是一个高级深度学习 API
Keras 以优先考虑开发者体验而闻名。它是为人类而设计的 API,而不是为机器。它遵循减少认知负荷的最佳实践:提供一致简单的工作流程,最小化常见用例所需的操作数量,并在用户出错时提供清晰可行的反馈。这使得 Keras 对初学者易于学习,对专家使用高效。
截至 2021 年底,Keras 已经拥有超过一百万用户,包括学术研究人员、工程师、数据科学家、初创公司和大公司的研究生和爱好者。Keras 在 Google、Netflix、Uber、CERN、NASA、Yelp、Instacart、Square 等公司中被使用,以及数百家从事各行各业各种问题的初创公司。你的 YouTube 推荐源自 Keras 模型。Waymo 自动驾驶汽车是使用 Keras 模型开发的。Keras 也是 Kaggle 上的热门框架,大多数深度学习竞赛都是使用 Keras 赢得的。
由于 Keras 拥有庞大且多样化的用户群,它不会强迫你遵循单一的“正确”模型构建和训练方式。相反,它支持各种不同的工作流程,从非常高级到非常低级,对应不同的用户配置文件。例如,你有多种构建模型和训练模型的方式,每种方式都代表着可用性和灵活性之间的某种权衡。在第五章中,我们将详细审查这种工作流程的一部分。你可以像使用 Scikit-learn 一样使用 Keras——只需调用 fit(),让框架自行处理——或者像使用 NumPy 一样使用它——完全控制每一个细节。
这意味着你现在学习的所有内容在你成为专家后仍然是相关的。你可以轻松入门,然后逐渐深入到需要从头开始编写更多逻辑的工作流程中。在从学生转变为研究人员,或者从数据科学家转变为深度学习工程师时,你不必切换到完全不同的框架。
这种哲学与 Python 本身的哲学非常相似!有些语言只提供一种编写程序的方式——例如,面向对象编程或函数式编程。而 Python 是一种多范式语言:它提供了一系列可能的使用模式,它们都可以很好地协同工作。这使得 Python 适用于各种非常不同的用例:系统管理、数据科学、机器学习工程、Web 开发……或者只是学习如何编程。同样,你可以将 Keras 视为深度学习的 Python:一种用户友好的深度学习语言,为不同用户配置文件提供各种工作流程。
3.3 Keras 和 TensorFlow:简史
Keras 比 TensorFlow 早八个月发布。它于 2015 年 3 月发布,而 TensorFlow 则于 2015 年 11 月发布。你可能会问,如果 Keras 是建立在 TensorFlow 之上的,那么在 TensorFlow 发布之前它是如何存在的?Keras 最初是建立在 Theano 之上的,Theano 是另一个提供自动微分和 GPU 支持的张量操作库,是最早的之一。Theano 在蒙特利尔大学机器学习算法研究所(MILA)开发,从许多方面来看是 TensorFlow 的前身。它开创了使用静态计算图进行自动微分和将代码编译到 CPU 和 GPU 的想法。
在 TensorFlow 发布后的 2015 年底,Keras 被重构为多后端架构:可以使用 Keras 与 Theano 或 TensorFlow,而在两者之间切换就像更改环境变量一样简单。到 2016 年 9 月,TensorFlow 达到了技术成熟的水平,使其成为 Keras 的默认后端选项成为可能。2017 年,Keras 添加了两个新的后端选项:CNTK(由微软开发)和 MXNet(由亚马逊开发)。如今,Theano 和 CNTK 已经停止开发,MXNet 在亚马逊之外并不广泛使用。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 的全面重新设计,考虑了四年多的用户反馈和技术进步。
到这个时候,你一定迫不及待地想要开始实践运行 Keras 和 TensorFlow 代码了。让我们开始吧。
3.4 设置深度学习工作空间
在开始开发深度学习应用程序之前,你需要设置好你的开发环境。强烈建议,尽管不是绝对必要的,你应该在现代 NVIDIA GPU 上运行深度学习代码,而不是在计算机的 CPU 上运行。一些应用程序——特别是使用卷积网络进行图像处理的应用程序——在 CPU 上会非常慢,即使是快速的多核 CPU。即使对于可以在 CPU 上运行的应用程序,使用最新 GPU 通常会使速度提高 5 到 10 倍。
要在 GPU 上进行深度学习,你有三个选择:
-
在你的工作站上购买并安装一块物理 NVIDIA GPU。
-
使用 Google Cloud 或 AWS EC2 上的 GPU 实例。
-
使用 Colaboratory 提供的免费 GPU 运行时,这是 Google 提供的托管笔记本服务(有关“笔记本”是什么的详细信息,请参见下一节)。
Colaboratory 是最简单的入门方式,因为它不需要购买硬件,也不需要安装软件——只需在浏览器中打开一个标签页并开始编码。这是我们推荐在本书中运行代码示例的选项。然而,Colaboratory 的免费版本只适用于小型工作负载。如果你想扩大规模,你将不得不使用第一或第二个选项。
如果你还没有可以用于深度学习的 GPU(一块最新的高端 NVIDIA GPU),那么在云中运行深度学习实验是一个简单、低成本的方式,让你能够扩展到更大的工作负载,而无需购买任何额外的硬件。如果你正在使用 Jupyter 笔记本进行开发,那么在云中运行的体验与本地运行没有任何区别。
但是,如果你是深度学习的重度用户,这种设置在长期内甚至在几个月内都是不可持续的。云实例并不便宜:在 2021 年中期,你将为 Google Cloud 上的 V100 GPU 每小时支付 2.48 美元。与此同时,一块可靠的消费级 GPU 的价格在 1500 到 2500 美元之间——即使这些 GPU 的规格不断改进,价格也保持相对稳定。如果你是深度学习的重度用户,请考虑设置一个带有一块或多块 GPU 的本地工作站。
另外,无论您是在本地运行还是在云端运行,最好使用 Unix 工作站。虽然在 Windows 上直接运行 Keras 在技术上是可能的,但我们不建议这样做。如果您是 Windows 用户,并且想在自己的工作站上进行深度学习,最简单的解决方案是在您的机器上设置一个 Ubuntu 双系统引导,或者利用 Windows Subsystem for Linux(WSL),这是一个兼容层,使您能够从 Windows 运行 Linux 应用程序。这可能看起来有点麻烦,但从长远来看,这将为您节省大量时间和麻烦。
3.4.1 Jupyter 笔记本:运行深度学习实验的首选方式
Jupyter 笔记本是运行深度学习实验的绝佳方式,特别是本书中的许多代码示例。它们在数据科学和机器学习社区中被广泛使用。笔记本是由 Jupyter Notebook 应用程序生成的文件(jupyter.org),您可以在浏览器中编辑。它结合了执行 Python 代码的能力和用于注释您正在进行的操作的丰富文本编辑功能。笔记本还允许您将长实验分解为可以独立执行的较小部分,这使得开发交互式,并且意味着如果实验的后期出现问题,您不必重新运行之前的所有代码。
我建议使用 Jupyter 笔记本来开始使用 Keras,尽管这不是必需的:您也可以运行独立的 Python 脚本或在诸如 PyCharm 这样的 IDE 中运行代码。本书中的所有代码示例都作为开源笔记本提供;您可以从 GitHub 上下载它们:github.com/fchollet/deep-learning-with-python-notebooks。
3.4.2 使用 Colaboratory
Colaboratory(简称 Colab)是一个免费的 Jupyter 笔记本服务,无需安装,完全在云端运行。实际上,它是一个网页,让您可以立即编写和执行 Keras 脚本。它为您提供免费(但有限)的 GPU 运行时,甚至还有 TPU 运行时,因此您不必购买自己的 GPU。Colaboratory 是我们推荐用于运行本书中代码示例的工具。
使用 Colaboratory 的第一步
要开始使用 Colab,请访问 colab.research.google.com 并单击 New Notebook 按钮。您将看到图 3.2 中显示的标准笔记本界面。

图 3.2 一个 Colab 笔记本
您会在工具栏中看到两个按钮:+ Code 和 + Text。它们分别用于创建可执行的 Python 代码单元格和注释文本单元格。在代码单元格中输入代码后,按 Shift-Enter 将执行它(参见图 3.3)。

图 3.3 创建一个代码单元格
在文本单元格中,您可以使用 Markdown 语法(参见图 3.4)。按 Shift-Enter 在文本单元格上将渲染它。

图 3.4 创建一个文本单元格
文本单元格对于为您的笔记本提供可读的结构非常有用:使用它们为您的代码添加部分标题和长说明段落或嵌入图像。笔记本旨在成为一种多媒体体验!
使用 pip 安装软件包
默认的 Colab 环境已经安装了 TensorFlow 和 Keras,因此您可以立即开始使用它,无需任何安装步骤。但是,如果您需要使用 pip 安装某些内容,您可以在代码单元格中使用以下语法进行安装(请注意,该行以 ! 开头,表示这是一个 shell 命令而不是 Python 代码):
!pip install package_name
使用 GPU 运行时
要在 Colab 中使用 GPU 运行时,请在菜单中选择 Runtime > Change Runtime Type,并选择 GPU 作为硬件加速器(参见图 3.5)。

图 3.5 使用 Colab 的 GPU 运行时
如果 GPU 可用,TensorFlow 和 Keras 将自动在 GPU 上执行,所以在选择了 GPU 运行时后,你无需做其他操作。
你会注意到在硬件加速器下拉菜单中还有一个 TPU 运行时选项。与 GPU 运行时不同,使用 TensorFlow 和 Keras 的 TPU 运行时需要在代码中进行一些手动设置。我们将在第十三章中介绍这个内容。目前,我们建议你选择 GPU 运行时,以便跟随本书中的代码示例。
现在你有了一个开始在实践中运行 Keras 代码的方法。接下来,让我们看看你在第二章学到的关键思想如何转化为 Keras 和 TensorFlow 代码。
3.5 TensorFlow 的第一步
正如你在之前的章节中看到的,训练神经网络围绕着以下概念展开:
-
首先,低级张量操作——支撑所有现代机器学习的基础设施。这转化为 TensorFlow API:
-
张量,包括存储网络状态的特殊张量(变量)
-
张量操作,如加法、
relu、matmul -
反向传播,一种计算数学表达式梯度的方法(在 TensorFlow 中通过
GradientTape对象处理)
-
-
其次,高级深度学习概念。这转化为 Keras API:
-
层,这些层组合成一个模型
-
一个损失函数,定义用于学习的反馈信号
-
一个优化器,确定学习如何进行
-
指标用于评估模型性能,如准确度
-
执行小批量随机梯度下降的训练循环
-
在上一章中,你已经初步接触了一些对应的 TensorFlow 和 Keras API:你已经简要使用了 TensorFlow 的Variable类、matmul操作和GradientTape。你实例化了 Keras 的Dense层,将它们打包成一个Sequential模型,并用fit()方法训练了该模型。
现在让我们深入了解如何使用 TensorFlow 和 Keras 在实践中处理所有这些不同概念。
3.5.1 常量张量和变量
要在 TensorFlow 中做任何事情,我们需要一些张量。张量需要用一些初始值创建。例如,你可以创建全为 1 或全为 0 的张量(见列表 3.1),或者从随机分布中抽取值的张量(见列表 3.2)。
列表 3.1 全为 1 或全为 0 的张量
>>> import tensorflow as tf
>>> x = tf.ones(shape=(2, 1)) # ❶
>>> print(x)
tf.Tensor(
[[1.]
[1.]], shape=(2, 1), dtype=float32)
>>> x = tf.zeros(shape=(2, 1)) # ❷
>>> print(x)
tf.Tensor(
[[0.]
[0.]], shape=(2, 1), dtype=float32)
❶ 等同于 np.ones(shape=(2, 1))
❷ 等同于 np.zeros(shape=(2, 1))
列表 3.2 随机张量
>>> x = tf.random.normal(shape=(3, 1), mean=0., stddev=1.) # ❶
>>> print(x)
tf.Tensor(
[[-0.14208166]
[-0.95319825]
[ 1.1096532 ]], shape=(3, 1), dtype=float32)
>>> x = tf.random.uniform(shape=(3, 1), minval=0., maxval=1.) # ❷
>>> print(x)
tf.Tensor(
[[0.33779848]
[0.06692922]
[0.7749394 ]], shape=(3, 1), dtype=float32)
❶ 从均值为 0、标准差为 1 的正态分布中抽取的随机值张量。等同于 np.random.normal(size=(3, 1), loc=0., scale=1.)。
❷ 从 0 到 1 之间均匀分布的随机值张量。等同于 np.random.uniform(size=(3, 1), low=0., high=1.)。
NumPy 数组和 TensorFlow 张量之间的一个重要区别是 TensorFlow 张量不可赋值:它们是常量。例如,在 NumPy 中,你可以这样做。
列表 3.3 NumPy 数组是可赋值的
import numpy as np
x = np.ones(shape=(2, 2))
x[0, 0] = 0.
尝试在 TensorFlow 中做同样的事情,你会得到一个错误:“EagerTensor 对象不支持项目赋值。”
列表 3.4 TensorFlow 张量不可赋值
x = tf.ones(shape=(2, 2))
x[0, 0] = 0. # ❶
❶ 这将失败,因为张量不可赋值。
要训练一个模型,我们需要更新它的状态,这是一组张量。如果张量不可赋值,我们该怎么办?这就是变量发挥作用的地方。tf.Variable是 TensorFlow 中用来管理可修改状态的类。你在第二章末尾的训练循环实现中已经简要看到它的作用。
要创建一个变量,你需要提供一些初始值,比如一个随机张量。
列表 3.5 创建一个 TensorFlow 变量
>>> v = tf.Variable(initial_value=tf.random.normal(shape=(3, 1)))
>>> print(v)
array([[-0.75133973],
[-0.4872893 ],
[ 1.6626885 ]], dtype=float32)>
变量的状态可以通过其assign方法修改,如下所示。
列表 3.6 给 TensorFlow 变量赋值
>>> v.assign(tf.ones((3, 1)))
array([[1.],
[1.],
[1.]], dtype=float32)>
它也适用于一部分系数。
列表 3.7 给 TensorFlow 变量的子集赋值
>>> v[0, 0].assign(3.)
array([[3.],
[1.],
[1.]], dtype=float32)>
同样,assign_add() 和 assign_sub() 是+= 和 -= 的高效等价物,如下所示。
列表 3.8 使用assign_add()
>>> v.assign_add(tf.ones((3, 1)))
array([[2.],
[2.],
[2.]], dtype=float32)>
3.5.2 张量操作:在 TensorFlow 中进行数学运算
就像 NumPy 一样,TensorFlow 提供了大量的张量操作来表达数学公式。以下是一些示例。
列表 3.9 几个基本数学操作
a = tf.ones((2, 2))
b = tf.square(a) # ❶
c = tf.sqrt(a) # ❷
d = b + c # ❸
e = tf.matmul(a, b) # ❹
e *= d # ❺
❶ 求平方。
❷ 求平方根。
❸ 两个张量相加(逐元素)。
❹ 两个张量的乘积(如第二章中讨论的)。
❺ 两个张量相乘(逐元素)。
重要的是,前面的每个操作都是即时执行的:在任何时候,你都可以打印出当前的结果,就像在 NumPy 中一样。我们称之为即时执行。
3.5.3 再看一下 GradientTape API
到目前为止,TensorFlow 看起来很像 NumPy。但这里有一件 NumPy 做不到的事情:检索任何可微表达式相对于其任何输入的梯度。只需打开一个GradientTape范围,对一个或多个输入张量应用一些计算,并检索结果相对于输入的梯度。
列表 3.10 使用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.11 使用带有常量张量输入的GradientTape
input_const = tf.constant(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.12 使用嵌套的梯度磁带计算二阶梯度
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) # ❶
❶ 我们使用外部磁带来计算内部磁带的梯度。自然地,答案是 4.9 * 2 = 9.8。
3.5.4 一个端到端的示例:在纯 TensorFlow 中的线性分类器
你已经了解了张量、变量和张量操作,也知道如何计算梯度。这足以构建基于梯度下降的任何机器学习模型。而你只是在第三章!
在机器学习工作面试中,你可能会被要求在 TensorFlow 中从头开始实现一个线性分类器:这是一个非常简单的任务,可以作为筛选具有一些最低机器学习背景和没有背景的候选人之间的过滤器。让我们帮你通过这个筛选器,并利用你对 TensorFlow 的新知识来实现这样一个线性分类器。
首先,让我们想出一些线性可分的合成数据来处理:2D 平面上的两类点。我们将通过从具有特定协方差矩阵和特定均值的随机分布中绘制它们的坐标来生成每一类点。直观地,协方差矩阵描述了点云的形状,均值描述了它在平面上的位置(参见图 3.6)。我们将为两个点云重复使用相同的协方差矩阵,但我们将使用两个不同的均值值——点云将具有相同的形状,但不同的位置。
列表 3.13 在 2D 平面上生成两类随机点
num_samples_per_class = 1000
negative_samples = np.random.multivariate_normal( # ❶
mean=[0, 3], # ❶
cov=[[1, 0.5],[0.5, 1]], # ❶
size=num_samples_per_class) # ❶
positive_samples = np.random.multivariate_normal( # ❷
mean=[3, 0], # ❷
cov=[[1, 0.5],[0.5, 1]], # ❷
size=num_samples_per_class) # ❷
❶ 生成第一类点:1000 个随机的 2D 点。cov=[[1, 0.5],[0.5, 1]] 对应于一个从左下到右上方向的椭圆形点云。
❷ 用不同均值和相同协方差矩阵生成另一类点。
在上述代码中,negative_samples 和 positive_samples 都是形状为 (1000, 2) 的数组。让我们将它们堆叠成一个形状为 (2000, 2) 的单一数组。
列表 3.14 将两类堆叠成形状为 (2000, 2) 的数组
inputs = np.vstack((negative_samples, positive_samples)).astype(np.float32)
让我们生成相应的目标标签,一个形状为 (2000, 1) 的零和一的数组,其中 targets[i, 0] 为 0,如果 inputs[i] 属于类 0(反之亦然)。
列表 3.15 生成相应的目标值 (0 和 1)
targets = np.vstack((np.zeros((num_samples_per_class, 1), dtype="float32"),
np.ones((num_samples_per_class, 1), dtype="float32")))
接下来,让我们用 Matplotlib 绘制我们的数据。
列表 3.16 绘制两类点(参见图 3.6)
import matplotlib.pyplot as plt
plt.scatter(inputs[:, 0], inputs[:, 1], c=targets[:, 0])
plt.show()

图 3.6 我们的合成数据:2D 平面上的两类随机点
现在让我们创建一个线性分类器,它可以学会分离这两个斑点。线性分类器是一个仿射变换(prediction = W • input + b),训练以最小化预测与目标之间差的平方。
正如你将看到的,这实际上比第二章末尾看到的玩具两层神经网络的端到端示例要简单得多。然而,这次你应该能够逐行理解代码的一切。
让我们创建我们的变量,W 和 b,分别用随机值和零值初始化。
列表 3.17 创建线性分类器变量
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.18 前向传播函数
def model(inputs):
return 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.19 均方误差损失函数
def square_loss(targets, predictions):
per_sample_losses = tf.square(targets - predictions) # ❶
return tf.reduce_mean(per_sample_losses) # ❷
❶ per_sample_losses 将是一个与目标和预测相同形状的张量,包含每个样本的损失分数。
❷ 我们需要将这些每个样本的损失函数平均为单个标量损失值:这就是 reduce_mean 所做的。
接下来是训练步骤,它接收一些训练数据并更新权重 W 和 b,以使数据上的损失最小化。
列表 3.20 训练步骤函数
learning_rate = 0.1
def training_step(inputs, targets):
with tf.GradientTape() as tape: # ❶
predictions = model(inputs) # ❶
loss = square_loss(predictions, targets) # ❶
grad_loss_wrt_W, grad_loss_wrt_b = tape.gradient(loss, [W, b]) # ❷
W.assign_sub(grad_loss_wrt_W * learning_rate) # ❸
b.assign_sub(grad_loss_wrt_b * learning_rate) # ❸
return loss
❶ 前向传播,在梯度磁带范围内
❷ 检索损失相对于权重的梯度。
❸ 更新权重。
为了简单起见,我们将进行批量训练而不是小批量训练:我们将对所有数据运行每个训练步骤(梯度计算和权重更新),而不是在小批量中迭代数据。一方面,这意味着每个训练步骤将需要更长时间运行,因为我们将一次计算 2,000 个样本的前向传播和梯度。另一方面,每个梯度更新将更有效地减少训练数据上的损失,因为它将包含所有训练样本的信息,而不是仅仅 128 个随机样本。因此,我们将需要更少的训练步骤,并且我们应该使用比通常用于小批量训练更大的学习率(我们将使用learning_rate = 0.1,在列表 3.20 中定义)。
列表 3.21 批量训练循环
for step in range(40):
loss = training_step(inputs, targets)
print(f"Loss at step {step}: {loss:.4f}")
经过 40 步,训练损失似乎已经稳定在 0.025 左右。让我们绘制我们的线性模型如何对训练数据点进行分类。因为我们的目标是 0 和 1,给定输入点将被分类为“0”,如果其预测值低于 0.5,将被分类为“1”,如果高于 0.5(见图 3.7):
predictions = model(inputs)
plt.scatter(inputs[:, 0], inputs[:, 1], c=predictions[:, 0] > 0.5)
plt.show()

图 3.7 我们模型对训练输入的预测:与训练目标非常相似
请记住,给定点[x, y]的预测值简单地为prediction == [[w1], [w2]] • [x, y] + b == w1 * x + w2 * y + b。因此,类 0 被定义为w1 * x + w2 * y + b < 0.5,类 1 被定义为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。
让我们绘制这条直线(如图 3.8 所示):
x = np.linspace(-1, 4, 100) # ❶
y = - W[0] / W[1] * x + (0.5 - b) / W[1] # ❷
plt.plot(x, y, "-r") # ❸
plt.scatter(inputs[:, 0], inputs[:, 1], c=predictions[:, 0] > 0.5) # ❹
❶ 生成 100 个在-1 到 4 之间均匀间隔的数字,我们将用它们来绘制我们的直线。
❷ 这是我们直线的方程。
❸ 绘制我们的直线("-r"表示“将其绘制为红色线”)。
❹ 绘制我们模型的预测在同一图中。

图 3.8 我们模型,可视化为一条直线
这才是线性分类器的真正含义:找到一个线(或者在更高维空间中,一个超平面)的参数,将两类数据清晰地分开。
3.6 神经网络的解剖:理解核心 Keras API
到目前为止,你已经了解了 TensorFlow 的基础知识,并且可以使用它从头开始实现一个玩具模型,比如前一节中的批量线性分类器,或者第二章末尾的玩具神经网络。这是一个坚实的基础,可以继续建立。现在是时候转向更具生产力、更健壮的深度学习路径了:Keras API。
3.6.1 层:深度学习的构建模块
神经网络中的基本数据结构是层,你在第二章中已经介绍过。层是一个数据处理模块,它以一个或多个张量作为输入,并输出一个或多个张量。一些层是无状态的,但更频繁的情况是层有一个状态:层的权重,一个或多个使用随机梯度下降学习的张量,它们一起包含网络的知识。
不同类型的层适用于不同的张量格式和不同类型的数据处理。例如,简单的向量数据,存储在形状为(samples, features)的秩-2 张量中,通常由密集连接层处理,也称为全连接或密集层(Keras 中的Dense类)。序列数据,存储在形状为(samples, timesteps, features)的秩-3 张量中,通常由循环层处理,例如LSTM层,或 1D 卷积层(Conv1D)。图像数据,存储在秩-4 张量中,通常由 2D 卷积层(Conv2D)处理。
你可以把层想象成深度学习的乐高积木,这个比喻在 Keras 中是明确的。在 Keras 中构建深度学习模型是通过将兼容的层剪辑在一起形成有用的数据转换流水线。
Keras 中的基础 Layer 类
一个简单的 API 应该围绕一个单一的抽象进行中心化。在 Keras 中,这就是Layer类。Keras 中的一切都是一个Layer或与Layer紧密交互的东西。
一个Layer是一个封装了一些状态(权重)和一些计算(前向传播)的对象。权重通常在build()中定义(尽管它们也可以在构造函数__init__()中创建),计算在call()方法中定义。
在前一章中,我们实现了一个NaiveDense类,其中包含两个权重W和b,并应用了计算output = activation(dot(input, W) + b)。这就是在 Keras 中相同层的样子。
列表 3.22 作为Layer子类实现的Dense层
from tensorflow import keras
class SimpleDense(keras.layers.Layer): # ❶
def __init__(self, units, activation=None):
super().__init__()
self.units = units
self.activation = activation
def build(self, input_shape): # ❷
input_dim = input_shape[-1]
self.W = self.add_weight(shape=(input_dim, self.units), # ❸
initializer="random_normal")
self.b = self.add_weight(shape=(self.units,),
initializer="zeros")
def call(self, inputs): # ❹
y = tf.matmul(inputs, self.W) + self.b
if self.activation is not None:
y = self.activation(y)
return y
❶ 所有的 Keras 层都继承自基础的 Layer 类。
❷ 权重的创建发生在build()方法中。
❸ add_weight()是一个创建权重的快捷方法。也可以创建独立的变量并将它们分配为层属性,如self.W = tf.Variable(tf.random.uniform(w_shape))。
❹ 我们在call()方法中定义了前向传播计算。
在接下来的部分中,我们将详细介绍这些build()和call()方法的目的。如果你现在还不理解,不要担心!
一旦实例化,像这样的层可以像函数一样使用,以 TensorFlow 张量作为输入:
>>> my_dense = SimpleDense(units=32, activation=tf.nn.relu) # ❶
>>> input_tensor = tf.ones(shape=(2, 784)) # ❷
>>> output_tensor = my_dense(input_tensor) # ❸
>>> print(output_tensor.shape)
(2, 32))
❶ 实例化我们之前定义的层。
❷ 创建一些测试输入。
❸ 在输入上调用层,就像调用函数一样。
你可能会想,为什么我们要实现call()和build(),因为我们最终只是简单地调用了我们的层,也就是说,使用了它的__call__()方法?这是因为我们希望能够及时创建状态。让我们看看它是如何工作的。
自动形状推断:动态构建层
就像乐高积木一样,你只能“连接”兼容的层。这里的层兼容性概念特指每个层只接受特定形状的输入张量,并返回特定形状的输出张量。考虑以下示例:
from tensorflow.keras import layers
layer = layers.Dense(32, activation="relu") # ❶
❶ 一个具有 32 个输出单元的密集层
这个层将返回一个张量,其中第一个维度已经被转换为 32。它只能连接到一个期望 32 维向量作为输入的下游层。
在使用 Keras 时,大多数情况下你不必担心大小的兼容性,因为你添加到模型中的层会动态构建以匹配传入层的形状。例如,假设你写下以下内容:
from tensorflow.keras import models
from tensorflow.keras import layers
model = models.Sequential([
layers.Dense(32, activation="relu"),
layers.Dense(32)
])
层没有接收到关于它们输入形状的任何信息——相反,它们自动推断它们的输入形状为它们看到的第一个输入的形状。
在我们在第二章中实现的Dense层的玩具版本中(我们称之为NaiveDense),我们必须显式地将层的输入大小传递给构造函数,以便能够创建其权重。这并不理想,因为这将导致模型看起来像这样,其中每个新层都需要知道其前一层的形状:
model = NaiveSequential([
NaiveDense(input_size=784, output_size=32, activation="relu"),
NaiveDense(input_size=32, output_size=64, activation="relu"),
NaiveDense(input_size=64, output_size=32, activation="relu"),
NaiveDense(input_size=32, output_size=10, activation="softmax")
])
如果一个层用于生成其输出形状的规则很复杂,情况会变得更糟。例如,如果我们的层返回形状为(batch, input_ size * 2 if input_size % 2 == 0 else input_size * 3)的输出会怎样?
如果我们要将我们的NaiveDense层重新实现为一个能够自动推断形状的 Keras 层,它将看起来像之前的SimpleDense层(见列表 3.22),具有其build()和call()方法。
在SimpleDense中,我们不再像NaiveDense示例中那样在构造函数中创建权重;相反,我们在一个专门的状态创建方法build()中创建它们,该方法接收层首次看到的第一个输入形状作为参数。build()方法在第一次调用层时(通过其__call__()方法)会自动调用。事实上,这就是为什么我们将计算定义在单独的call()方法中而不是直接在__call__()方法中的原因。基础层的__call__()方法基本上是这样的:
def __call__(self, inputs):
if not self.built:
self.build(inputs.shape)
self.built = True
return self.call(inputs)
有了自动形状推断,我们之前的示例变得简单而整洁:
model = keras.Sequential([
SimpleDense(32, activation="relu"),
SimpleDense(64, activation="relu"),
SimpleDense(32, activation="relu"),
SimpleDense(10, activation="softmax")
])
请注意,自动形状推断并不是Layer类的__call__()方法处理的唯一事情。它还处理许多其他事情,特别是在eager和graph执行之间的路由(这是你将在第七章学习的概念),以及输入掩码(我们将在第十一章中介绍)。现在,只需记住:当实现自己的层时,将前向传播放在call()方法中。
3.6.2 从层到模型
深度学习模型是一系列层的图。在 Keras 中,这就是Model类。到目前为止,你只看到过Sequential模型(Model的子类),它们是简单的层堆叠,将单个输入映射到单个输出。但随着你的学习,你将接触到更广泛的网络拓扑。以下是一些常见的拓扑结构:
-
双分支网络
-
多头网络
-
残差连接
网络拓扑可能会变得非常复杂。例如,图 3.9 显示了 Transformer 的层图拓扑,这是一种常见的用于处理文本数据的架构。

图 3.9 变压器架构(在第十一章中介绍)。这里面有很多内容。在接下来的几章中,你将逐步理解它。
在 Keras 中通常有两种构建这种模型的方法:你可以直接子类化Model类,或者你可以使用 Functional API,它让你用更少的代码做更多的事情。我们将在第七章中涵盖这两种方法。
模型的拓扑定义了一个假设空间。你可能还记得,在第一章中我们将机器学习描述为在预定义的可能性空间内搜索一些输入数据的有用表示,使用来自反馈信号的指导。通过选择网络拓扑,你将限制你的可能性空间(假设空间)到一系列特定的张量操作,将输入数据映射到输出数据。接下来,你将搜索这些张量操作中涉及的权重张量的良好值集。
要从数据中学习,您必须对其进行假设。这些假设定义了可以学到的内容。因此,您的假设空间的结构——模型的架构——非常重要。它编码了您对问题的假设,模型开始的先验知识。例如,如果您正在处理一个由单个Dense层组成且没有激活函数(纯仿射变换)的模型的两类分类问题,那么您假设您的两类是线性可分的。
选择正确的网络架构更多地是一门艺术而不是一门科学,尽管有一些最佳实践和原则可以依靠,但只有实践才能帮助你成为一个合格的神经网络架构师。接下来的几章将教授您构建神经网络的明确原则,并帮助您培养对于特定问题的有效性或无效性的直觉。您将建立对于不同类型问题适用的模型架构的坚实直觉,如何在实践中构建这些网络,如何选择正确的学习配置,以及如何调整模型直到产生您想要看到的结果。
3.6.3 “compile”步骤:配置学习过程
一旦模型架构被定义,您仍然必须选择另外三个事项:
-
损失函数(目标函数)—在训练过程中将被最小化的数量。它代表了任务的成功度量。
-
优化器—根据损失函数确定网络要如何更新。它实现了随机梯度下降(SGD)的特定变体。
-
Metrics—在训练和验证过程中要监视的成功度量,例如分类准确度。与损失不同,训练不会直接为这些指标进行优化。因此,指标不需要可微分。
一旦您选择了损失、优化器和指标,您可以使用内置的compile()和fit()方法开始训练您的模型。或者,您也可以编写自己的自定义训练循环——我们将在第七章中介绍如何做到这一点。这是更多的工作!现在,让我们看看compile()和fit()。
compile()方法配置训练过程——你在第二章的第一个神经网络示例中已经见过它。它接受optimizer、loss和metrics(一个列表)作为参数:
model = keras.Sequential([keras.layers.Dense(1)]) # ❶
model.compile(optimizer="rmsprop", # ❷
loss="mean_squared_error", # ❸
metrics=["accuracy"]) # ❹
❶ 定义一个线性分类器。
❷ 通过名称指定优化器:RMSprop(不区分大小写)。
❸ 通过名称指定损失:均方误差。
❹ 指定一个指标列表:在这种情况下,只有准确度。
在前面对compile()的调用中,我们将优化器、损失和指标作为字符串传递(例如"rmsprop")。这些字符串实际上是转换为 Python 对象的快捷方式。例如,"rmsprop"变成了keras.optimizers.RMSprop()。重要的是,也可以将这些参数指定为对象实例,如下所示:
model.compile(optimizer=keras.optimizers.RMSprop(),
loss=keras.losses.MeanSquaredError(),
metrics=[keras.metrics.BinaryAccuracy()])
如果您想传递自定义损失或指标,或者如果您想进一步配置您正在使用的对象,例如通过向优化器传递learning_rate参数:
model.compile(optimizer=keras.optimizers.RMSprop(learning_rate=1e-4),
loss=my_custom_loss,
metrics=[my_custom_metric_1, my_custom_metric_2])
在第七章中,我们将介绍如何创建自定义损失和指标。一般来说,您不必从头开始创建自己的损失、指标或优化器,因为 Keras 提供了广泛的内置选项,很可能包括您需要的内容:
优化器:
-
SGD(带有或不带有动量) -
RMSprop -
Adam -
Adagrad -
等等。
损失:
-
CategoricalCrossentropy -
SparseCategoricalCrossentropy -
BinaryCrossentropy -
MeanSquaredError -
KLDivergence -
CosineSimilarity -
等等。
指标:
-
CategoricalAccuracy -
SparseCategoricalAccuracy -
BinaryAccuracy -
AUC -
Precision -
Recall -
等等。
在本书中,您将看到许多这些选项的具体应用。
3.6.4 选择损失函数
为正确的问题选择正确的损失函数非常重要:你的网络会尽其所能缩小损失,因此如果目标与当前任务的成功并不完全相关,你的网络最终可能会执行一些你不希望的操作。想象一下,通过使用这个选择不当的目标函数(“最大化所有活着人类的平均幸福感”)进行 SGD 训练的愚蠢、全能的 AI。为了简化工作,这个 AI 可能选择杀死除少数人外的所有人类,并专注于剩下人的幸福感——因为平均幸福感不受剩余人数的影响。这可能不是你想要的结果!请记住,你构建的所有神经网络都会像这样无情地降低它们的损失函数,因此明智地选择目标,否则你将面临意想不到的副作用。
幸运的是,对于常见问题如分类、回归和序列预测,你可以遵循简单的准则来选择正确的损失函数。例如,对于两类分类问题,你将使用二元交叉熵,对于多类分类问题,你将使用分类交叉熵,依此类推。只有在处理真正新的研究问题时,你才需要开发自己的损失函数。在接下来的几章中,我们将明确详细地介绍为各种常见任务选择哪些损失函数。
3.6.5 理解 fit() 方法
在 compile() 之后是 fit()。fit() 方法实现了训练循环本身。以下是它的关键参数:
-
用于训练的数据(输入和目标)。通常会以 NumPy 数组或 TensorFlow
Dataset对象的形式传递。你将在接下来的章节中更多地了解DatasetAPI。 -
训练的轮数:训练循环应该迭代传递的数据多少次。
-
在每个迷你批次梯度下降的 epoch 中使用的批次大小:用于计算一次权重更新步骤的训练示例数量。
第 3.23 节 使用 NumPy 数据调用 fit()
history = model.fit(
inputs, # ❶
targets, # ❷
epochs=5, # ❸
batch_size=128 # ❹
)
❶ 输入示例,作为 NumPy 数组
❷ 相应的训练目标,作为 NumPy 数组
❸ 训练循环将在数据上迭代 5 次。
❹ 训练循环将以 128 个示例的批次迭代数据。
调用 fit() 返回一个 History 对象。该对象包含一个 history 字段,它是一个将诸如 "loss" 或特定指标名称映射到每个 epoch 值列表的字典。
>>> history.history
{"binary_accuracy": [0.855, 0.9565, 0.9555, 0.95, 0.951],
"loss": [0.6573270302042366,
0.07434618508815766,
0.07687718723714351,
0.07412414988875389,
0.07617757616937161]}
3.6.6 监控验证数据上的损失和指标
机器学习的目标不是获得在训练数据上表现良好的模型,这很容易——你只需遵循梯度。目标是获得在一般情况下表现良好的模型,特别是在模型从未遇到过的数据点上表现良好。仅仅因为一个模型在训练数据上表现良好并不意味着它会在从未见过的数据上表现良好!例如,你的模型可能最终只是记忆训练样本和它们的目标之间的映射,这对于预测模型从未见过的数据的目标是无用的。我们将在第五章中更详细地讨论这一点。
为了监视模型在新数据上的表现,通常会将训练数据的一个子集保留为验证数据:你不会在这些数据上训练模型,但会用它们来计算损失值和指标值。你可以通过在 fit() 中使用 validation_data 参数来实现这一点。与训练数据类似,验证数据可以作为 NumPy 数组或 TensorFlow Dataset 对象传递。
第 3.24 节 使用 validation_data 参数
model = keras.Sequential([keras.layers.Dense(1)])
model.compile(optimizer=keras.optimizers.RMSprop(learning_rate=0.1),
loss=keras.losses.MeanSquaredError(),
metrics=[keras.metrics.BinaryAccuracy()])
indices_permutation = np.random.permutation(len(inputs)) # ❶
shuffled_inputs = inputs[indices_permutation] # ❶
shuffled_targets = targets[indices_permutation] # ❶
num_validation_samples = int(0.3 * len(inputs)) # ❷
val_inputs = shuffled_inputs[:num_validation_samples] # ❷
val_targets = shuffled_targets[:num_validation_samples] # ❷
training_inputs = shuffled_inputs[num_validation_samples:] # ❷
training_targets = shuffled_targets[num_validation_samples:] # ❷
model.fit(
training_inputs, # ❸
training_targets, # ❸
epochs=5,
batch_size=16,
validation_data=(val_inputs, val_targets) # ❹
)
❶ 为了避免在验证数据中只有一个类的样本,使用随机索引排列来对输入和目标进行洗牌。
❷ 保留 30%的训练输入和目标用于验证(我们将排除这些样本进行训练,并保留它们来计算验证损失和指标)。
❸ 用于更新模型权重的训练数据
❹ 仅用于监控验证损失和指标的验证数据
在验证数据上的损失值称为“验证损失”,以区别于“训练损失”。请注意,保持训练数据和验证数据严格分开是至关重要的:验证的目的是监测模型学习的内容是否实际上对新数据有用。如果模型在训练过程中看到任何验证数据,您的验证损失和指标将是有缺陷的。
请注意,如果您想在训练完成后计算验证损失和指标,可以调用evaluate()方法:
loss_and_metrics = model.evaluate(val_inputs, val_targets, batch_size=128)
evaluate()将在传递的数据上以批量(大小为batch_size)迭代,并返回一个标量列表,其中第一个条目是验证损失,后一个条目是验证指标。如果模型没有指标,只返回验证损失(而不是列表)。
3.6.7 推断:在训练后使用模型
一旦您训练好模型,您将想要使用它在新数据上进行预测。这被称为推断。为此,一个简单的方法就是简单地__call__()模型:
predictions = model(new_inputs) # ❶
❶ 接受一个 NumPy 数组或 TensorFlow 张量,并返回一个 TensorFlow 张量
然而,这将一次性处理new_inputs中的所有输入,如果你要处理大量数据可能不可行(特别是可能需要比你的 GPU 更多的内存)。
进行推断的更好方法是使用predict()方法。它将以小批量迭代数据,并返回一个预测的 NumPy 数组。与__call__()不同,它还可以处理 TensorFlow 的Dataset对象。
predictions = model.predict(new_inputs, batch_size=128) # ❶
❶ 接受一个 NumPy 数组或数据集,并返回一个 NumPy 数组
例如,如果我们对之前训练过的线性模型使用predict()在一些验证数据上,我们会得到对应于模型对每个输入样本的预测的标量分数:
>>> predictions = model.predict(val_inputs, batch_size=128)
>>> print(predictions[:10])
[[0.3590725 ]
[0.82706255]
[0.74428225]
[0.682058 ]
[0.7312616 ]
[0.6059811 ]
[0.78046083]
[0.025846 ]
[0.16594526]
[0.72068727]]
目前,这就是您需要了解的关于 Keras 模型的全部内容。您已经准备好在下一章节中使用 Keras 解决真实世界的机器学习问题了。
摘要
-
TensorFlow 是一个工业强度的数值计算框架,可以在 CPU、GPU 或 TPU 上运行。它可以自动计算任何可微表达式的梯度,可以分布到许多设备,还可以将程序导出到各种外部运行时,甚至 JavaScript。
-
Keras 是使用 TensorFlow 进行深度学习的标准 API。这是我们将在整本书中使用的。
-
TensorFlow 的关键对象包括张量、变量、张量操作和梯度带。
-
Keras 的核心类是
Layer。一个层封装了一些权重和一些计算。层被组装成模型。 -
在开始训练模型之前,您需要选择一个优化器,一个损失和一些指标,您可以通过
model.compile()方法指定。 -
要训练一个模型,您可以使用
fit()方法,它为您运行小批量梯度下降。您还可以使用它来监视您在验证数据上的损失和指标,这是模型在训练过程中没有看到的一组输入。 -
一旦您的模型训练完成,您可以使用
model.predict()方法在新输入上生成预测。
四、入门神经网络:分类和回归
本章涵盖
-
您的第一个真实世界机器学习工作流示例
-
处理矢量数据上的分类问题
-
处理矢量数据上的连续回归问题
本章旨在帮助您开始使用神经网络解决实际问题。您将巩固从第二章和第三章中获得的知识,并将所学应用于三个新任务,涵盖神经网络的三种最常见用例 — 二元分类、多类分类和标量回归:
-
将电影评论分类为正面或负面(二元分类)
-
根据主题对新闻线进行分类(多类分类)
-
给定房地产数据估计房屋价格(标量回归)
这些示例将是您与端到端机器学习工作流的第一次接触:您将介绍数据预处理、基本模型架构原则和模型评估。
分类和回归术语表
分类和回归涉及许多专门术语。您在早期示例中已经遇到了一些,您将在未来章节中看到更多。它们具有精确的、机器学习特定的定义,您应该熟悉它们:
-
Sample 或 input — 进入您的模型的一个数据点。
-
Prediction 或 output — 您的模型输出的内容。
-
Target — 真相。根据外部数据源,您的模型理想情况下应该预测的内容。
-
Prediction error 或 loss value — 您的模型预测与目标之间距离的度量。
-
Classes — 在分类问题中可供选择的可能标签集。例如,当对猫和狗图片进行分类时,“狗”和“猫”是两个类别。
-
Label — 分类问题中类别注释的特定实例。例如,如果图片 #1234 被注释为包含“狗”类,则“狗”是图片 #1234 的一个标签。
-
Ground-truth 或 annotations — 数据集中的所有目标,通常由人类收集。
-
Binary classification — 一个分类任务,其中每个输入样本应该被分类到两个互斥的类别中。
-
Multiclass classification — 一个分类任务,其中每个输入样本应该被分类到两个以上的类别中:例如,分类手写数字。
-
Multilabel classification — 一个分类任务,其中每个输入样本可以被分配多个标签。例如,给定图像可能同时包含猫和狗,并且应该同时用“猫”标签和“狗”标签进行注释。每个图像的标签数量通常是可变的。
-
Scalar regression — 目标是一个连续标量值的任务。预测房价是一个很好的例子:不同的目标价格形成一个连续空间。
-
Vector regression — 目标是一组连续值的任务:例如,一个连续的矢量。如果您正在针对多个值进行回归(例如图像中边界框的坐标),那么您正在进行矢量回归。
-
Mini-batch 或 batch — 模型同时处理的一小组样本(通常在 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 数据集
from tensorflow.keras.datasets import imdb
(train_data, train_labels), (test_data, test_labels) = imdb.load_data(
num_words=10000)
参数num_words=10000表示您只会保留训练数据中出现频率最高的前 10,000 个单词。罕见单词将被丢弃。这使我们可以处理可管理大小的向量数据。如果我们不设置这个限制,我们将使用训练数据中的 88,585 个独特单词,这是不必要的庞大数量。其中许多单词只在一个样本中出现,因此无法有意义地用于分类。
变量train_data和test_data是评论列表;每个评论是一个单词索引列表(编码为单词序列)。train_labels和test_labels是 0 和 1 的列表,其中 0 代表负面,1 代表正面:
>>> train_data[0]
[1, 14, 22, 16, ... 178, 32]
>>> train_labels[0]
1
因为我们限制自己只使用前 10,000 个最常见的单词,所以没有单词索引会超过 10,000:
>>> max([max(sequence) for sequence in train_data])
9999
为了好玩,这里是如何快速将其中一个评论解码回英文单词。
列表 4.2 将评论解码回文本
word_index = imdb.get_word_index() # ❶
reverse_word_index = dict(
[(value, key) for (key, value) in word_index.items()]) # ❷
decoded_review = " ".join(
[reverse_word_index.get(i - 3, "?") for i in train_data[0]]) # ❸
❶ word_index 是一个将单词映射到整数索引的字典。
❷ 将其反转,将整数索引映射到单词
❸ 解码评论。请注意,索引偏移了 3,因为 0、1 和 2 是“填充”、“序列开始”和“未知”保留索引。
4.1.2 准备数据
您不能直接将整数列表输入神经网络。它们的长度各不相同,但神经网络期望处理连续的数据批次。您必须将列表转换为张量。有两种方法可以做到这一点:
-
填充列表,使它们的长度相同,将它们转换为形状为
(samples, max_length)的整数张量,并从能够处理这种整数张量的层开始构建模型(Embedding层,我们稍后会详细介绍)。 -
多热编码您的列表以将它们转换为 0 和 1 的向量。这意味着,例如,将序列
[8, 5]转换为一个 10,000 维的向量,除了索引 8 和 5 外,其他都是 0,而索引 8 和 5 是 1。然后,您可以使用一个Dense层,能够处理浮点向量数据,作为模型中的第一层。
让我们选择后一种解决方案来对数据进行向量化,这样您可以最大程度地清晰地进行操作。
列表 4.3 通过多热编码对整数序列进行编码
import numpy as np
def vectorize_sequences(sequences, dimension=10000):
results = np.zeros((len(sequences), dimension)) # ❶
for i, sequence in enumerate(sequences):
for j in sequence:
results[i, j] = 1. # ❷
return results
x_train = vectorize_sequences(train_data) # ❸
x_test = vectorize_sequences(test_data) # ❹
❶ 创建一个形状为(len(sequences), dimension)的全零矩阵
❷ 将结果[i]的特定索引设置为 1
❸ 向量化训练数据
❹ 向量化测试数据
现在样本看起来是这样的:
>>> x_train[0]
array([ 0., 1., 1., ..., 0., 0., 0.])
您还应该对标签进行向量化,这很简单:
y_train = np.asarray(train_labels).astype("float32")
y_test = np.asarray(test_labels).astype("float32")
现在数据已准备好输入神经网络。
4.1.3 构建您的模型
输入数据是向量,标签是标量(1 和 0):这是您可能会遇到的最简单的问题设置之一。在这样的问题上表现良好的模型类型是具有relu激活的一堆密集连接(Dense)层。
对于这样一堆Dense层,有两个关键的架构决策:
-
要使用多少层
-
选择每层使用多少个单元
在第五章,你将学习指导你做出这些选择的正式原则。目前,你将不得不相信我做出以下架构选择:
-
两个中间层,每个有 16 个单元
-
第三层将输出关于当前评论情感的标量预测

图 4.1 三层模型
图 4.1 展示了模型的外观。以下代码展示了 Keras 实现,类似于你之前看到的 MNIST 示例。
代码清单 4.4 模型定义
from tensorflow import keras
from tensorflow.keras import layers
model = keras.Sequential([
layers.Dense(16, activation="relu"),
layers.Dense(16, activation="relu"),
layers.Dense(1, activation="sigmoid")
])
传递给每个 Dense 层的第一个参数是层中的单元数:层的表示空间的维度。你从第二章和第三章记得,每个具有 relu 激活的 Dense 层实现以下张量操作链:
output = relu(dot(input, W) + b)
有 16 个单元意味着权重矩阵 W 的形状为 (input_dimension, 16):与 W 的点积将把输入数据投影到一个 16 维表示空间(然后你将添加偏置向量 b 并应用 relu 操作)。你可以直观地理解表示空间的维度为“模型在学习内部表示时允许的自由度有多大”。拥有更多单元(更高维的表示空间)允许你的模型学习更复杂的表示,但会使模型在计算上更昂贵,并可能导致学习不需要的模式(这些模式会提高训练数据的性能,但不会提高测试数据的性能)。
中间层使用 relu 作为它们的激活函数,最后一层使用 sigmoid 激活以输出一个概率(介于 0 和 1 之间的分数,指示样本有多大可能具有目标“1”:评论有多大可能是积极的)。relu(线性整流单元)是一个用于将负值归零的函数(参见图 4.2),而 sigmoid “压缩”任意值到 [0, 1] 区间(参见图 4.3),输出可以解释为概率。

图 4.2 线性整流单元函数
最后,你需要选择一个损失函数和一个优化器。因为你面临的是一个二元分类问题,你的模型的输出是一个概率(你的模型以具有 sigmoid 激活的单单元层结束),最好使用 binary_crossentropy 损失。这并不是唯一可行的选择:例如,你可以使用 mean_squared_error。但是当你处理输出概率的模型时,交叉熵通常是最佳选择。交叉熵是信息论领域的一种量,用于衡量概率分布之间的距离,或者在这种情况下,地面实况分布和你的预测之间的距离。

图 4.3 Sigmoid 函数
激活函数是什么,为什么它们是必要的?
没有像 relu 这样的激活函数(也称为非线性),Dense 层将由两个线性操作组成——点积和加法:
output = dot(input, W) + b
该层只能学习输入数据的线性变换(仿射变换):该层的假设空间将是将输入数据转换为 16 维空间的所有可能线性变换的集合。这样的假设空间太受限制,不会受益于多层表示,因为深度堆叠的线性层仍然实现线性操作:增加更多层不会扩展假设空间(正如你在第二章中看到的)。
为了获得一个更丰富的假设空间,从而受益于深度表示,你需要一个非线性或激活函数。relu 是深度学习中最流行的激活函数,但还有许多其他候选项,它们都有类似奇怪的名称:prelu、elu 等等。
至于优化器的选择,我们将选择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[:10000]
partial_x_train = x_train[10000:]
y_val = y_train[:10000]
partial_y_train = y_train[10000:]
现在我们将在 512 个样本的小批量中对模型进行 20 个时代(对训练数据中的所有样本进行 20 次迭代)的训练。同时,我们将通过将验证数据作为validation_data参数传递来监视我们分离出的 10,000 个样本上的损失和准确性。
列表 4.7 训练您的模型
history = model.fit(partial_x_train,
partial_y_train,
epochs=20,
batch_size=512,
validation_data=(x_val, y_val))
在 CPU 上,每个时代不到 2 秒——训练在 20 秒内结束。在每个时代结束时,模型会在验证数据的 10,000 个样本上计算其损失和准确性,会有一个轻微的暂停。
请注意,对model.fit()的调用会返回一个History对象,就像您在第三章中看到的那样。这个对象有一个成员history,它是一个包含训练过程中发生的一切数据的字典。让我们来看一下:
>>> history_dict = history.history
>>> history_dict.keys()
[u"accuracy", u"loss", u"val_accuracy", u"val_loss"]
字典包含四个条目:每个在训练和验证期间监视的指标一个。在接下来的两个列表中,让我们使用 Matplotlib 将训练和验证损失并排绘制出来(参见图 4.4),以及训练和验证准确性(参见图 4.5)。请注意,由于模型的不同随机初始化,您自己的结果可能会略有不同。

图 4.4 训练和验证损失

图 4.5 训练和验证准确性
列表 4.8 绘制训练和验证损失
import matplotlib.pyplot as plt
history_dict = history.history
loss_values = history_dict["loss"]
val_loss_values = history_dict["val_loss"]
epochs = range(1, len(loss_values) + 1)
plt.plot(epochs, loss_values, "bo", label="Training loss") # ❶
plt.plot(epochs, val_loss_values, "b", label="Validation loss") # ❷
plt.title("Training and validation loss")
plt.xlabel("Epochs")
plt.ylabel("Loss")
plt.legend()
plt.show()
❶ "bo"代表"蓝色点"。
❷ "b"代表"实线蓝色线"。
列表 4.9 绘制训练和验证准确性
plt.clf() # ❶
acc = history_dict["accuracy"]
val_acc = history_dict["val_accuracy"]
plt.plot(epochs, acc, "bo", label="Training acc")
plt.plot(epochs, val_acc, "b", label="Validation acc")
plt.title("Training and validation accuracy")
plt.xlabel("Epochs")
plt.ylabel("Accuracy")
plt.legend()
plt.show()
❶ 清除图形
正如您所看到的,训练损失随着每个时代的进行而减少,而训练准确性则随着每个时代的进行而增加。这是在运行梯度下降优化时您所期望的情况——您试图最小化的量应该在每次迭代中都减少。但验证损失和准确性并非如此:它们似乎在第四个时代达到峰值。这是我们之前警告过的一个例子:在训练数据上表现更好的模型不一定会在以前从未见过的数据上表现更好。准确来说,您所看到的是过拟合:在第四个时代之后,您过度优化了训练数据,最终学习到的表示是特定于训练数据的,无法推广到训练集之外的数据。
在这种情况下,为了防止过拟合,您可以在四个时代后停止训练。一般来说,您可以使用一系列技术来减轻过拟合,我们将在第五章中介绍。
让我们从头开始训练一个新模型四个时代,然后在测试数据上评估它。
列表 4.10 从头开始重新训练模型
model = keras.Sequential([
layers.Dense(16, activation="relu"),
layers.Dense(16, activation="relu"),
layers.Dense(1, activation="sigmoid")
])
model.compile(optimizer="rmsprop",
loss="binary_crossentropy",
metrics=["accuracy"])
model.fit(x_train, y_train, epochs=4, batch_size=512)
results = model.evaluate(x_test, y_test)
最终结果如下:
>>> results
[0.2929924130630493, 0.88327999999999995] # ❶
❶ 第一个数字 0.29 是测试损失,第二个数字 0.88 是测试准确性。
这种相当天真的方法实现了 88%的准确性。使用最先进的方法,您应该能够接近 95%。
4.1.5 使用训练好的模型在新数据上生成预测
在训练完模型后,您会想要在实际环境中使用它。您可以使用predict方法生成评论为正面的可能性,就像您在第三章中学到的那样:
>>> model.predict(x_test)
array([[ 0.98006207]
[ 0.99758697]
[ 0.99975556]
...,
[ 0.82167041]
[ 0.02885115]
[ 0.65371346]], dtype=float32)
正如您所看到的,模型对某些样本非常自信(0.99 或更高,或 0.01 或更低),但对其他样本不太自信(0.6、0.4)。
4.1.6 进一步的实验
以下实验将帮助您确信您所做的架构选择都是相当合理的,尽管仍有改进的空间:
-
在最终分类层之前,您使用了两个表示层。尝试使用一个或三个表示层,看看这样做如何影响验证和测试准确性。
-
尝试使用更多单元或更少单元的层:32 个单元,64 个单元等等。
-
尝试使用
mse损失函数而不是binary_crossentropy。 -
尝试使用
tanh激活(这是早期神经网络中流行的激活函数)而不是relu。
4.1.7 总结
这是您应该从这个示例中了解到的内容:
-
通常,您需要对原始数据进行大量预处理,以便能够将其(作为张量)馈送到神经网络中。单词序列可以编码为二进制向量,但也有其他编码选项。
-
具有
relu激活的Dense层堆叠可以解决各种问题(包括情感分类),您可能经常会使用它们。 -
在二元分类问题(两个输出类别)中,您的模型应该以一个具有一个单元和
sigmoid激活的Dense层结束:您的模型的输出应该是一个介于 0 和 1 之间的标量,编码为概率。 -
在二元分类问题上,具有标量 S 形输出的损失函数应该使用
binary_crossentropy。 -
rmsprop优化器通常是一个足够好的选择,无论您的问题是什么。这是您无需担心的一件事。 -
随着神经网络在训练数据上变得更好,最终会开始过拟合,并且在从未见过的数据上获得越来越糟糕的结果。一定要始终监视在训练集之外的数据上的性能。
4.2 新闻线分类:一个多类别分类示例
在前一节中,您看到了如何使用密集连接的神经网络将向量输入分类为两个互斥类别。但是当您有两个以上的类别时会发生什么?
在本节中,我们将构建一个模型,将路透社新闻线分类为 46 个互斥主题。因为我们有很多类别,所以这个问题是多类别分类的一个实例,因为每个数据点应该被分类为一个类别,所以这个问题更具体地是单标签多类别分类的一个实例。如果每个数据点可以属于多个类别(在这种情况下是主题),我们将面临一个多标签多类别分类问题。
4.2.1 路透社数据集
您将使用路透社数据集,这是路透社在 1986 年发布的一组简短新闻线及其主题。这是一个简单、广泛使用的文本分类玩具数据集。有 46 个不同的主题;一些主题比其他主题更有代表性,但每个主题在训练集中至少有 10 个示例。
与 IMDB 和 MNIST 一样,路透社数据集作为 Keras 的一部分打包提供。让我们来看看。
列表 4.11 加载路透社数据集
from tensorflow.keras.datasets import reuters
(train_data, train_labels), (test_data, test_labels) = reuters.load_data(
num_words=10000)
与 IMDB 数据集一样,参数num_words=10000将数据限制为数据中出现频率最高的 10,000 个单词。
您有 8,982 个训练示例和 2,246 个测试示例:
>>> len(train_data)
8982
>>> len(test_data)
2246
与 IMDB 评论一样,每个示例都是一个整数列表(单词索引):
>>> train_data[10]
[1, 245, 273, 207, 156, 53, 74, 160, 26, 14, 46, 296, 26, 39, 74, 2979,
3554, 14, 46, 4689, 4329, 86, 61, 3499, 4795, 14, 61, 451, 4329, 17, 12]
如果您感兴趣,这是如何将其解码回单词的方法。
列表 4.12 将新闻线解码回文本
word_index = reuters.get_word_index()
reverse_word_index = dict(
[(value, key) for (key, value) in word_index.items()])
decoded_newswire = " ".join(
[reverse_word_index.get(i - 3, "?") for i in train_data[0]]) # ❶
❶ 请注意,索引偏移了 3,因为 0、1 和 2 是“填充”、“序列开始”和“未知”保留索引。
与示例相关联的标签是介于 0 和 45 之间的整数—一个主题索引:
>>> train_labels[10]
3
4.2.2 准备数据
您可以使用与前一个示例中完全相同的代码对数据进行向量化。
列表 4.13 对输入数据进行编码
x_train = vectorize_sequences(train_data) # ❶
x_test = vectorize_sequences(test_data) # ❷
❶ 向量化训练数据
❷ 向量化测试数据
要将标签向量化,有两种可能性:你可以将标签列表转换为整数张量,或者你可以使用独热编码。独热编码是一种广泛使用的分类数据格式,也称为分类编码。在这种情况下,标签的独热编码包括将每个标签嵌入为一个全零向量,其中标签索引的位置为 1。下面的列表显示了一个示例。
列表 4.14 编码标签
def to_one_hot(labels, dimension=46):
results = np.zeros((len(labels), dimension))
for i, label in enumerate(labels):
results[i, label] = 1.
return results
y_train = to_one_hot(train_labels) # ❶
y_test = to_one_hot(test_labels) # ❷
❶ 向量化训练标签
❷ 向量化测试标签
请注意,Keras 中有一种内置的方法可以做到这一点:
from tensorflow.keras.utils import to_categorical
y_train = to_categorical(train_labels)
y_test = to_categorical(test_labels)
4.2.3 构建你的模型
这个主题分类问题看起来与之前的电影评论分类问题相似:在这两种情况下,我们都试图对短文本进行分类。但是这里有一个新的约束:输出类别的数量从 2 个增加到了 46 个。输出空间的维度大得多。
在像我们一直使用的Dense层堆叠中,每一层只能访问前一层输出中存在的信息。如果一层丢失了与分类问题相关的一些信息,这些信息将永远无法被后续层恢复:每一层都可能成为信息瓶颈。在前面的例子中,我们使用了 16 维的中间层,但 16 维的空间可能太有限,无法学习区分 46 个不同的类别:这样的小层可能充当信息瓶颈,永久丢失相关信息。
出于这个原因,我们将使用更大的层。让我们选择 64 个单元。
列表 4.15 模型定义
model = keras.Sequential([
layers.Dense(64, activation="relu"),
layers.Dense(64, activation="relu"),
layers.Dense(46, activation="softmax")
])
还有两件事情你应该注意关于这个架构。
首先,我们用一个大小为 46 的Dense层结束模型。这意味着对于每个输入样本,网络将输出一个 46 维的向量。这个向量中的每个条目(每个维度)将编码一个不同的输出类别。
其次,最后一层使用了softmax激活函数。你在 MNIST 示例中看到了这种模式。这意味着模型将输出 46 个不同输出类别的概率分布,对于每个输入样本,模型将产生一个 46 维的输出向量,其中output[i]是样本属于类别i的概率。这 46 个分数将总和为 1。
在这种情况下使用的最佳损失函数是categorical_crossentropy。它衡量两个概率分布之间的距离:在这里,模型输出的概率分布与标签的真实分布之间的距离。通过最小化这两个分布之间的距离,你训练模型输出尽可能接近真实标签。
列表 4.16 编译模型
model.compile(optimizer="rmsprop",
loss="categorical_crossentropy",
metrics=["accuracy"])
4.2.4 验证你的方法
让我们在训练数据中留出 1,000 个样本作为验证集使用。
列表 4.17 设置一个验证集
x_val = x_train[:1000]
partial_x_train = x_train[1000:]
y_val = y_train[:1000]
partial_y_train = y_train[1000:]
现在,让我们训练模型 20 个周期。
列表 4.18 训练模型
history = model.fit(partial_x_train,
partial_y_train,
epochs=20,
batch_size=512,
validation_data=(x_val, y_val))
最后,让我们展示其损失和准确率曲线(见图 4.6 和 4.7)。

图 4.6 训练和验证损失

图 4.7 训练和验证准确率
列表 4.19 绘制训练和验证损失
loss = history.history["loss"]
val_loss = history.history["val_loss"]
epochs = range(1, len(loss) + 1)
plt.plot(epochs, loss, "bo", label="Training loss")
plt.plot(epochs, val_loss, "b", label="Validation loss")
plt.title("Training and validation loss")
plt.xlabel("Epochs")
plt.ylabel("Loss")
plt.legend()
plt.show()
列表 4.20 绘制训练和验证准确率
plt.clf() # ❶
acc = history.history["accuracy"]
val_acc = history.history["val_accuracy"]
plt.plot(epochs, acc, "bo", label="Training accuracy")
plt.plot(epochs, val_acc, "b", label="Validation accuracy")
plt.title("Training and validation accuracy")
plt.xlabel("Epochs")
plt.ylabel("Accuracy")
plt.legend()
plt.show()
❶ 清除图表
模型在九个周期后开始过拟合。让我们从头开始训练一个新模型,训练九个周期,然后在测试集上评估它。
列表 4.21 从头开始重新训练模型
model = keras.Sequential([
layers.Dense(64, activation="relu"),
layers.Dense(64, activation="relu"),
layers.Dense(46, activation="softmax")
])
model.compile(optimizer="rmsprop",
loss="categorical_crossentropy",
metrics=["accuracy"])
model.fit(x_train,
y_train,
epochs=9,
batch_size=512)
results = model.evaluate(x_test, y_test)
这里是最终结果:
>>> results
[0.9565213431445807, 0.79697239536954589]
这种方法达到了约 80%的准确率。对于一个平衡的二元分类问题,一个纯随机分类器达到的准确率将是 50%。但在这种情况下,我们有 46 个类别,它们可能不会被平等地表示。一个随机基线的准确率会是多少呢?我们可以尝试快速实现一个来进行经验性检查:
>>> import copy
>>> test_labels_copy = copy.copy(test_labels)
>>> np.random.shuffle(test_labels_copy)
>>> hits_array = np.array(test_labels) == np.array(test_labels_copy)
>>> hits_array.mean()
0.18655387355298308
正如你所看到的,一个随机分类器的分类准确率约为 19%,所以从这个角度看,我们模型的结果似乎相当不错。
4.2.5 在新数据上生成预测
在新样本上调用模型的predict方法会返回每个样本的 46 个主题的类概率分布。让我们为所有测试数据生成主题预测:
predictions = model.predict(x_test)
“predictions”中的每个条目都是长度为 46 的向量:
>>> predictions[0].shape
(46,)
这个向量中的系数总和为 1,因为它们形成一个概率分布:
>>> np.sum(predictions[0])
1.0
最大的条目是预测的类别——具有最高概率的类别:
>>> np.argmax(predictions[0])
4
4.2.6 处理标签和损失的另一种方式
我们之前提到另一种编码标签的方式是将它们转换为整数张量,就像这样:
y_train = np.array(train_labels)
y_test = np.array(test_labels)
这种方法唯一改变的是损失函数的选择。列表 4.21 中使用的损失函数categorical_crossentropy期望标签遵循分类编码。对于整数标签,你应该使用sparse_categorical_crossentropy:
model.compile(optimizer="rmsprop",
loss="sparse_categorical_crossentropy",
metrics=["accuracy"])
这个新的损失函数在数学上仍然与categorical_crossentropy相同;它只是有一个不同的接口。
4.2.7 拥有足够大的中间层的重要性
我们之前提到,由于最终的输出是 46 维的,你应该避免中间层的单元远远少于 46。现在让我们看看当我们引入信息瓶颈时会发生什么,即通过具有明显低于 46 维的中间层,例如 4 维:
列表 4.22 具有信息瓶颈的模型
model = keras.Sequential([
layers.Dense(64, activation="relu"),
layers.Dense(4, activation="relu"),
layers.Dense(46, activation="softmax")
])
model.compile(optimizer="rmsprop",
loss="categorical_crossentropy",
metrics=["accuracy"])
model.fit(partial_x_train,
partial_y_train,
epochs=20,
batch_size=128,
validation_data=(x_val, y_val))
现在模型的验证准确率达到了约 71%,绝对下降了 8%。这种下降主要是因为我们试图将大量信息(足以恢复 46 个类别的分离超平面的信息)压缩到一个过低维度的中间空间中。模型能够将大部分必要信息压缩到这些四维表示中,但并非全部。
4.2.8 进一步实验
就像前面的例子一样,我鼓励你尝试以下实验,以培养你对这类模型需要做出的配置决策的直觉:
-
尝试使用更大或更小的层:32 个单元,128 个单元等。
-
在最终的 softmax 分类层之前使用了两个中间层。现在尝试使用一个单独的中间层,或者三个中间层。
4.2.9 总结
这个例子给我们的启示是:
-
如果你试图在N个类别中对数据点进行分类,你的模型应该以大小为N的
Dense层结束。 -
在单标签多类分类问题中,你的模型应该以
softmax激活结束,这样它将输出关于N个输出类别的概率分布。 -
对于这类问题,几乎总是应该使用分类交叉熵作为损失函数。它最小化了模型输出的概率分布与目标的真实分布之间的距离。
-
在多类分类中有两种处理标签的方式:
-
通过分类编码(也称为独热编码)对标签进行编码,并使用
categorical_crossentropy作为损失函数 -
将标签编码为整数并使用
sparse_categorical_crossentropy损失函数
-
-
如果你需要将数据分类到大量类别中,你应该避免由于中间层太小而在模型中创建信息瓶颈。
4.3 预测房价:回归示例
之前的两个例子被视为分类问题,目标是预测输入数据点的单个离散标签。另一种常见的机器学习问题是回归,它包括预测连续值而不是离散标签:例如,根据气象数据预测明天的温度,或者根据规格说明预测软件项目完成所需的时间。
注意不要混淆回归和逻辑回归算法。令人困惑的是,逻辑回归并不是一个回归算法,而是一个分类算法。
4.3.1 波士顿房价数据集
在本节中,我们将尝试预测上世纪 70 年代中期波士顿郊区房屋的中位价格,根据当时有关该郊区的数据点,例如犯罪率、当地财产税率等。我们将使用的数据集与前两个示例有一个有趣的区别。它的数据点相对较少:仅有 506 个,分为 404 个训练样本和 102 个测试样本。输入数据中的每个特征(例如犯罪率)具有不同的比例。例如,一些值是比例,取值介于 0 和 1 之间,其他值介于 1 和 12 之间,其他值介于 0 和 100 之间,依此类推。
列表 4.23 加载波士顿房屋数据集
from tensorflow.keras.datasets import boston_housing
(train_data, train_targets), (test_data, test_targets) = (
boston_housing.load_data())
让我们看一下数据:
>>> train_data.shape
(404, 13)
>>> test_data.shape
(102, 13)
正如你所看到的,我们有 404 个训练样本和 102 个测试样本,每个样本有 13 个数值特征,例如人均犯罪率、每个住宅的平均房间数、高速公路的可达性等。
目标值是占有住房的中位数值,以千美元为单位:
>>> train_targets
[ 15.2, 42.3, 50\. ... 19.4, 19.4, 29.1]
价格通常在$10,000 和$50,000 之间。如果听起来很便宜,请记住这是上世纪 70 年代中期,这些价格没有考虑通货膨胀。
4.3.2 准备数据
将取值范围差异很大的值输入神经网络可能会有问题。模型可能能够自动适应这种异质数据,但这肯定会使学习变得更加困难。处理这种数据的一种广泛最佳实践是进行特征归一化:对于输入数据中的每个特征(输入数据矩阵中的一列),我们减去该特征的均值并除以标准差,使得该特征以 0 为中心,具有单位标准差。这在 NumPy 中很容易实现。
列表 4.24 归一化数据
mean = train_data.mean(axis=0)
train_data -= mean
std = train_data.std(axis=0)
train_data /= std
test_data -= mean
test_data /= std
请注意,用于归一化测试数据的量是使用训练数据计算的。你绝对不应该在工作流程中使用在测试数据上计算的任何量,即使是像数据归一化这样简单的操作也不行。
4.3.3 构建你的模型
由于可用样本很少,我们将使用一个非常小的模型,其中包含两个中间层,每个层有 64 个单元。一般来说,训练数据越少,过拟合就会越严重,使用一个小模型是缓解过拟合的一种方法。
列表 4.25 模型定义
def build_model():
model = keras.Sequential([ # ❶
layers.Dense(64, activation="relu"),
layers.Dense(64, activation="relu"),
layers.Dense(1)
])
model.compile(optimizer="rmsprop", loss="mse", metrics=["mae"])
return model
❶ 因为我们需要多次实例化相同的模型,所以我们使用一个函数来构建它。
模型以一个单元结束,没有激活函数(它将是一个线性层)。这是标量回归的典型设置(一种回归,你试图预测一个单一连续值)。应用激活函数会限制输出的范围;例如,如果在最后一层应用sigmoid激活函数,模型只能学习预测 0 到 1 之间的值。在这里,因为最后一层是纯线性的,模型可以自由地学习预测任何范围内的值。
请注意,我们使用mse损失函数来编译模型—均方误差,即预测值与目标值之间的差的平方。这是回归问题中广泛使用的损失函数。
在训练过程中,我们还监控一个新的指标:平均绝对误差(MAE)。它是预测值与目标值之间的差的绝对值。例如,在这个问题上的 MAE 为 0.5 意味着你的预测平均偏差为$500。
4.3.4 使用 K 折验证验证你的方法
在我们继续调整模型参数(例如用于训练的时代数)的同时评估我们的模型,我们可以将数据分割为训练集和验证集,就像我们在之前的示例中所做的那样。但是由于数据点很少,验证集最终会变得非常小(例如,约 100 个示例)。因此,验证分数可能会根据我们选择用于验证和训练的数据点而变化很大:验证分数可能在验证拆分方面具有很高的方差。这将阻止我们可靠地评估我们的模型。
在这种情况下,最佳做法是使用K 折交叉验证(参见图 4.8)。

图 4.8 K=3 的 K 折交叉验证
它包括将可用数据分割为K个分区(通常K=4 或 5),实例化K个相同的模型,并在K-1 个分区上训练每个模型,同时在剩余分区上进行评估。然后使用的模型的验证分数是获得的K个验证分数的平均值。在代码方面,这很简单。
列表 4.26 K 折交叉验证
k = 4
num_val_samples = len(train_data) // k
num_epochs = 100
all_scores = []
for i in range(k):
print(f"Processing fold #{i}")
val_data = train_data[i * num_val_samples: (i + 1) * num_val_samples] # ❶
val_targets = train_targets[i * num_val_samples: (i + 1) * num_val_samples]
partial_train_data = np.concatenate( # ❷
[train_data[:i * num_val_samples],
train_data[(i + 1) * num_val_samples:]],
axis=0)
partial_train_targets = np.concatenate(
[train_targets[:i * num_val_samples],
train_targets[(i + 1) * num_val_samples:]],
axis=0)
model = build_model() # ❸
model.fit(partial_train_data, partial_train_targets, # ❹
epochs=num_epochs, batch_size=16, verbose=0)
val_mse, val_mae = model.evaluate(val_data, val_targets, verbose=0) # ❺
all_scores.append(val_mae)
❶ 准备验证数据:来自分区#k 的数据
❷ 准备训练数据:来自所有其他分区的数据
❸ 构建 Keras 模型(已编译)
❹ 训练模型(静默模式,verbose = 0)
❺ 在验证数据上评估模型
使用num_epochs = 100运行此操作将产生以下结果:
>>> all_scores
[2.112449, 3.0801501, 2.6483836, 2.4275346]
>>> np.mean(all_scores)
2.5671294
不同的运行确实显示了相当不同的验证分数,从 2.1 到 3.1。平均值(2.6)比任何单个分数更可靠—这就是 K 折交叉验证的全部意义。在这种情况下,我们平均偏差为$2,600,考虑到价格范围为$10,000 到$50,000,这是一个显著的差距。
让我们尝试将模型训练更长一点:500 个时代。为了记录模型在每个时代的表现如何,我们将修改训练循环以保存每个折叠的每个时代验证分数日志。
列表 4.27 保存每个折叠的验证日志
num_epochs = 500
all_mae_histories = []
for i in range(k):
print(f"Processing fold #{i}")
val_data = train_data[i * num_val_samples: (i + 1) * num_val_samples] # ❶
val_targets = train_targets[i * num_val_samples: (i + 1) * num_val_samples]
partial_train_data = np.concatenate( # ❷
[train_data[:i * num_val_samples],
train_data[(i + 1) * num_val_samples:]],
axis=0)
partial_train_targets = np.concatenate(
[train_targets[:i * num_val_samples],
train_targets[(i + 1) * num_val_samples:]],
axis=0)
model = build_model() # ❸
history = model.fit(partial_train_data, partial_train_targets, # ❹
validation_data=(val_data, val_targets),
epochs=num_epochs, batch_size=16, verbose=0)
mae_history = history.history["val_mae"]
all_mae_histories.append(mae_history)
❶ 准备验证数据:来自分区#k 的数据
❷ 准备训练数据:来自所有其他分区的数据
❸ 构建 Keras 模型(已编译)
❹ 训练模型(静默模式,verbose=0)
然后我们可以计算所有折叠的每个时代 MAE 分数的平均值。
列表 4.28 构建连续平均 K 折验证分数的历史
average_mae_history = [
np.mean([x[i] for x in all_mae_histories]) for i in range(num_epochs)]
让我们绘制这个;参见图 4.9。

图 4.9 按时代划分的验证 MAE
列表 4.29 绘制验证分数
plt.plot(range(1, len(average_mae_history) + 1), average_mae_history)
plt.xlabel("Epochs")
plt.ylabel("Validation MAE")
plt.show()
由于缩放问题,可能有点难以阅读图表:前几个时代的验证 MAE 远高于后续数值。让我们省略前 10 个数据点,这些数据点与曲线的其余部分处于不同的比例尺。
列表 4.30 绘制验证分数,不包括前 10 个数据点
truncated_mae_history = average_mae_history[10:]
plt.plot(range(1, len(truncated_mae_history) + 1), truncated_mae_history)
plt.xlabel("Epochs")
plt.ylabel("Validation MAE")
plt.show()
正如您在图 4.10 中所看到的,验证 MAE 在 120-140 个时代后停止显着改善(这个数字包括我们省略的 10 个时代)。在那之后,我们开始过拟合。

图 4.10 按时代划分的验证 MAE,不包括前 10 个数据点
一旦您完成调整模型的其他参数(除了时代数,您还可以调整中间层的大小),您可以使用最佳参数在所有训练数据上训练最终的生产模型,然后查看其在测试数据上的表现。
列表 4.31 训练最终模型
model = build_model() # ❶
model.fit(train_data, train_targets, # ❷
epochs=130, batch_size=16, verbose=0)
test_mse_score, test_mae_score = model.evaluate(test_data, test_targets)
❶ 获取一个新的、已编译的模型
❷ 在所有数据上对其进行训练
这是最终结果:
>>> test_mae_score
2.4642276763916016
我们仍然有一点不到$2,500 的差距。这是一个进步!就像前两个任务一样,您可以尝试改变模型中的层数或每层的单元数,看看是否可以减少测试误差。
4.3.5 在新数据上生成预测
在我们的二元分类模型上调用predict()时,我们为每个输入样本检索到介于 0 和 1 之间的标量分数。对于我们的多类分类模型,我们为每个样本检索到所有类别的概率分布。现在,对于这个标量回归模型,predict()返回模型对样本价格的猜测,单位为千美元:
>>> predictions = model.predict(test_data)
>>> predictions[0]
array([9.990133], dtype=float32)
测试集中的第一栋房子预测价格约为$10,000。
4.3.6 总结
从这个标量回归示例中,您应该得出以下结论:
-
回归使用不同的损失函数进行,与我们用于分类的不同。均方误差(MSE)是回归常用的损失函数。
-
同样,用于回归的评估指标与用于分类的评估指标不同;自然地,准确性的概念不适用于回归。常见的回归指标是平均绝对误差(MAE)。
-
当输入数据中的特征具有不同范围的值时,每个特征应作为预处理步骤独立缩放。
-
当数据量很少时,使用 K 折验证是可靠评估模型的好方法。
-
当可用的训练数据很少时,最好使用只有少数中间层(通常只有一个或两个)的小型模型,以避免严重过拟合。
摘要
-
在向量数据上,机器学习任务的三种最常见类型是二元分类、多类分类和标量回归。
-
本章前面的“总结”部分总结了您对每个任务学到的重要知识点。
-
回归使用不同的损失函数和不同的评估指标,与分类不同。
-
-
在将原始数据输入神经网络之前,通常需要对其进行预处理。
-
当您的数据具有不同范围的特征时,作为预处理的一部分,应独立缩放每个特征。
-
随着训练的进行,神经网络最终开始过拟合,并在以前未见过的数据上获得更糟糕的结果。
-
如果您没有太多的训练数据,可以使用只有一个或两个中间层的小型模型,以避免严重过拟合。
-
如果您的数据被分成许多类别,如果将中间层设置得太小,可能会导致信息瓶颈。
-
当您处理少量数据时,K 折验证可以帮助可靠评估您的模型。
五、机器学习的基础知识
本章涵盖
-
理解泛化和优化之间的紧张关系,这是机器学习中的基本问题
-
机器学习模型的评估方法
-
改进模型拟合的最佳实践
-
实现更好泛化的最佳实践
在第四章中的三个实际例子之后,你应该开始熟悉如何使用神经网络解决分类和回归问题,并且见证了机器学习的核心问题:过拟合。本章将把你对机器学习的一些新直觉形式化为一个坚实的概念框架,强调准确模型评估的重要性以及训练和泛化之间的平衡。
5.1 泛化:机器学习的目标
在第四章中提出的三个例子——预测电影评论、主题分类和房价回归——我们将数据分为训练集、验证集和测试集。很快就明显看到了不在训练数据上评估模型的原因:在几个周期后,从未见过的数据的性能开始与训练数据的性能分歧,而训练数据的性能始终随着训练的进行而改善。模型开始过拟合。过拟合在每个机器学习问题中都会发生。
机器学习中的基本问题是优化和泛化之间的紧张联系。优化指的是调整模型以在训练数据上获得最佳性能的过程(机器学习中的学习),而泛化指的是训练好的模型在从未见过的数据上的表现。当然,游戏的目标是获得良好的泛化,但你无法控制泛化;你只能将模型拟合到其训练数据。如果你做得太好,过拟合就会发生,泛化就会受到影响。
但是是什么导致了过拟合?我们如何实现良好的泛化?
5.1.1 欠拟合和过拟合
对于你在上一章中看到的模型,在保留验证数据上的性能随着训练的进行而改善,然后在一段时间后必然达到顶峰。这种模式(如图 5.1 所示)是普遍存在的。你会在任何模型类型和任何数据集中看到这种情况。

图 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”(一种原产于安第斯山脉的水果)只出现在一篇文本中,并且这篇文本恰好是负面情感的,一个调节不好的模型可能会对这个词赋予很高的权重,并且总是将提到 cherimoyas 的新文本分类为负面,然而,客观上,cherimoya 并没有什么负面的东西。
重要的是,一个特征值不需要只出现几次就会导致虚假相关性。考虑一个在你的训练数据中出现 100 次的单词,它与积极情感相关的概率为 54%,与消极情感相关的概率为 46%。这种差异很可能是一个完全的统计偶然,然而你的模型很可能会学会利用这个特征来进行分类任务。这是过拟合的最常见来源之一。
这里有一个引人注目的例子。以 MNIST 为例。通过将 784 个白噪声维度连接到现有数据的 784 个维度上,创建一个新的训练集,因此一半的数据现在是噪声。为了比较,还创建一个通过连接 784 个全零维度而得到的等效数据集。我们连接的无意义特征完全不影响数据的信息内容:我们只是在添加一些东西。人类分类准确度不会受到这些转换的影响。
列表 5.1 向 MNIST 添加白噪声通道或全零通道
from tensorflow.keras.datasets import mnist
import numpy as np
(train_images, train_labels), _ = mnist.load_data()
train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype("float32") / 255
train_images_with_noise_channels = np.concatenate(
[train_images, np.random.random((len(train_images), 784))], axis=1)
train_images_with_zeros_channels = np.concatenate(
[train_images, np.zeros((len(train_images), 784))], axis=1)
现在,让我们在这两个训练集上训练第二章中的模型。
列表 5.2 在带有噪声通道或全零通道的 MNIST 数据上训练相同的模型
from tensorflow import keras
from tensorflow.keras import layers
def get_model():
model = keras.Sequential([
layers.Dense(512, activation="relu"),
layers.Dense(10, activation="softmax")
])
model.compile(optimizer="rmsprop",
loss="sparse_categorical_crossentropy",
metrics=["accuracy"])
return 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.3 绘制验证准确性比较
import matplotlib.pyplot as plt
val_acc_noise = history_noise.history["val_accuracy"]
val_acc_zeros = history_zeros.history["val_accuracy"]
epochs = range(1, 11)
plt.plot(epochs, val_acc_noise, "b-",
label="Validation accuracy with noise channels")
plt.plot(epochs, val_acc_zeros, "b--",
label="Validation accuracy with zeros channels")
plt.title("Effect of noise channels on validation accuracy")
plt.xlabel("Epochs")
plt.ylabel("Accuracy")
plt.legend()
尽管数据在两种情况下都包含相同的信息,但通过虚假相关性的影响,使用噪声通道训练的模型的验证准确性最终会降低约一个百分点(参见图 5.6)——纯粹是通过虚假相关性的影响。你添加的噪声通道越多,准确性就会进一步下降。

图 5.6 噪声通道对验证准确性的影响
嘈杂的特征不可避免地导致过拟合。因此,在你不确定所拥有的特征是信息性的还是干扰性的情况下,通常在训练之前进行特征选择是很常见的。例如,将 IMDB 数据限制为最常见的前 10000 个单词就是一种粗糙的特征选择。进行特征选择的典型方法是为每个可用特征计算一些有用性评分——衡量特征相对于任务的信息性的度量,比如特征与标签之间的互信息——并且只保留高于某个阈值的特征。这样做将过滤掉前面示例中的白噪声通道。
5.1.2 深度学习中泛化性质的本质
深度学习模型的一个显著特点是,只要具有足够的表征能力,它们就可以被训练来拟合任何东西。
不相信?试着洗牌 MNIST 标签并在此基础上训练一个模型。尽管输入和洗牌标签之间没有任何关系,训练损失仍然可以很好地下降,即使是使用相对较小的模型。当然,由于在这种情况下没有泛化的可能性,验证损失不会随时间改善。
列表 5.4 使用随机洗牌标签拟合 MNIST 模型
(train_images, train_labels), _ = mnist.load_data()
train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype("float32") / 255
random_train_labels = train_labels[:]
np.random.shuffle(random_train_labels)
model = keras.Sequential([
layers.Dense(512, activation="relu"),
layers.Dense(10, activation="softmax")
])
model.compile(optimizer="rmsprop",
loss="sparse_categorical_crossentropy",
metrics=["accuracy"])
model.fit(train_images, random_train_labels,
epochs=100,
batch_size=128,
validation_split=0.2)
实际上,你甚至不需要用 MNIST 数据来做这个实验——你可以只生成白噪声输入和随机标签。只要模型有足够的参数,你也可以在这上面拟合一个模型。它最终只会记住特定的输入,就像一个 Python 字典一样。
如果是这样的话,那么深度学习模型到底是如何泛化的呢?它们难道不应该只是学习训练输入和目标之间的临时映射,就像一个高级dict一样吗?我们有什么期望这种映射会适用于新的输入呢?
事实证明,深度学习中的泛化性质与深度学习模型本身关系不大,而与现实世界中信息的结构有很大关系。让我们看看这里到底发生了什么。
流形假设
MNIST 分类器的输入(预处理之前)是一个 28×28 的整数数组,取值范围在 0 到 255 之间。因此,可能的输入值总数是 256 的 784 次方——远远大于宇宙中的原子数量。然而,这些输入中很少有看起来像有效 MNIST 样本的:实际手写数字只占据了所有可能的 28×28 uint8数组父空间中的一个微小子空间。而且,这个子空间不仅仅是在父空间中随机分布的一组点:它具有高度结构化。
首先,有效手写数字的子空间是连续的:如果你拿一个样本并稍微修改它,它仍然可以被识别为相同的手写数字。此外,所有有效子空间中的样本都通过平滑路径连接在一起。这意味着如果你拿两个随机的 MNIST 数字 A 和 B,存在一个“中间”图像序列,将 A 变形为 B,使得两个连续数字非常接近彼此(见图 5.7)。也许在两个类之间边界附近会有一些模糊的形状,但即使这些形状看起来仍然非常像数字。

图 5.7 不同的 MNIST 数字逐渐变形成彼此,显示手写数字空间形成了一个“流形”。此图像是使用第十二章的代码生成的。
从技术角度来说,你会说手写数字形成了一个流形,位于可能的 28×28 uint8数组空间中。这是一个大词,但概念相当直观。一个“流形”是某个父空间中的低维子空间,局部类似于线性(欧几里得)空间。例如,在平面上的平滑曲线是 2D 空间中的 1D 流形,因为对于曲线的每个点,你都可以画出一个切线(曲线可以在每个点处用一条直线来近似)。在 3D 空间中的平滑曲面是 2D 流形。依此类推。
更一般地,流形假设认为所有自然数据都位于编码它的高维空间中的低维流形上。这是关于宇宙中信息结构的一个非常强烈的陈述。据我们所知,这是准确的,也是深度学习有效的原因。这对于 MNIST 数字是正确的,但也适用于人脸、树形态、人声和甚至自然语言。
流形假设意味着
-
机器学习模型只需要适应潜在输入空间(潜在流形)中相对简单、低维、高度结构化的子空间。
-
在这些流形中的一个中,总是可以在两个输入之间插值,也就是说,通过一个连续路径将一个变形为另一个,路径上的所有点都落在流形上。
在深度学习中,插值样本之间的能力是理解泛化的关键。
插值作为泛化的来源
如果你处理可以插值的数据点,你可以通过将它们与流形上靠近的其他点联系起来,开始理解以前从未见过的点。换句话说,你可以通过插值来填补空白,从而理解空间的整体。
请注意,潜在流形上的插值与父空间中的线性插值是不同的,如图 5.8 所示。例如,在两个 MNIST 数字之间的像素的平均值通常不是一个有效的数字。

图 5.8 线性插值和潜在流形上的插值之间的差异。数字的潜在流形上的每个点都是一个有效的数字,但两个数字的平均值通常不是。
至关重要的是,虽然深度学习通过在学习的数据流形上进行插值来实现泛化,但假设插值就是泛化的全部是错误的。这只是冰山一角。插值只能帮助你理解与之前看到的非常接近的事物:它实现了局部泛化。但值得注意的是,人类总是处理极端的新颖性,并且做得很好。你不需要事先在无数例子上接受训练,以便应对你将要遇到的每种情况。你每一天都与以往任何一天都不同,也与自人类诞生以来的任何一天都不同。你可以在纽约待一周,上海待一周,班加罗尔待一周,而无需为每个城市进行数千次的学习和排练。
人类能够进行极端泛化,这是由于插值之外的认知机制所实现的:抽象、世界的符号模型、推理、逻辑、常识、对世界的内在先验——我们通常称之为理性,与直觉和模式识别相对。后者在很大程度上是插值性质的,但前者不是。这两者对智能都是至关重要的。我们将在第十四章中更多地讨论这个问题。
为什么深度学习有效
还记得第二章中的揉皱纸球的比喻吗?一张纸代表了 3D 空间中的 2D 流形(见图 5.9)。深度学习模型是一种展开纸球的工具,也就是说,是为了解开潜在流形。

图 5.9 展开复杂的数据流形
一个深度学习模型基本上是一个非常高维的曲线—一条平滑连续的曲线(受模型架构先验的额外约束),因为它需要是可微的。这条曲线通过梯度下降逐渐和增量地拟合到数据点。深度学习的本质是关于取一个大的、复杂的曲线—一个流形—并逐渐调整其参数,直到它拟合一些训练数据点。
曲线涉及足够多的参数,可以拟合任何东西——实际上,如果你让你的模型训练足够长的时间,它最终将纯粹地记忆其训练数据,根本无法泛化。然而,你要拟合的数据并不是由稀疏分布在基础空间中的孤立点组成。你的数据在输入空间内形成了一个高度结构化的低维流形—这就是流形假设。由于随着梯度下降的进行,将模型曲线拟合到这些数据中是逐渐平稳进行的,因此在训练过程中会有一个中间点,此时模型大致近似于数据的自然流形,正如你在图 5.10 中所看到的。

图 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
np.random.shuffle(data) # ❶
validation_data = data[:num_validation_samples] # ❷
training_data = data[num_validation_samples:] # ❸
model = get_model() # ❹
model.fit(training_data, ...) # ❹
validation_score = model.evaluate(validation_data, ...) # ❹
... # ❺
model = get_model() # ❻
model.fit(np.concatenate([training_data, # ❻
validation_data]), ...) # ❻
test_score = model.evaluate(test_data, ...) # ❻
❶ 通常适合对数据进行洗牌。
❷ 定义验证集
❸ 定义训练集
❹ 在训练数据上训练模型,并在验证数据上评估
❺ 在这一点上,您可以调整您的模型,重新训练它,评估它,再次调整它。
❻ 一旦调整了超参数,通常会从头开始在所有非测试数据上训练最终模型。
这是最简单的评估协议,但存在一个缺陷:如果可用的数据很少,那么您的验证和测试集可能包含的样本太少,无法统计代表手头的数据。这很容易识别:如果在分割之前对数据进行不同的随机洗牌轮次导致模型性能的度量值非常不同,那么您就会遇到这个问题。K 折验证和具有洗牌功能的迭代 K 折验证是解决这个问题的两种方法,接下来将讨论。
K 折验证
使用这种方法,将数据分成K个大小相等的分区。对于每个分区i,在剩余的K - 1个分区上训练模型,并在分区i上评估。然后,您的最终得分是获得的 K 个分数的平均值。当您的模型的性能根据训练-测试分割显示出显著变化时,这种方法是有帮助的。与留出验证一样,这种方法并不免除您使用一个不同的验证集进行模型校准。
从图 5.13 的示意图上看,K 折交叉验证看起来像是。列表 5.6 显示了一个简单的实现。

图 5.13 K 折交叉验证,K=3
列表 5.6 K 折交叉验证(为简单起见省略了标签)
k = 3
num_validation_samples = len(data) // k
np.random.shuffle(data)
validation_scores = []
for fold in range(k):
validation_data = data[num_validation_samples * fold: # ❶
num_validation_samples * (fold + 1)] # ❶
training_data = np.concatenate( # ❷
data[:num_validation_samples * fold], # ❷
data[num_validation_samples * (fold + 1):]) # ❷
model = get_model() # ❸
model.fit(training_data, ...)
validation_score = model.evaluate(validation_data, ...)
validation_scores.append(validation_score)
validation_score = np.average(validation_scores) # ❹
model = get_model() # ❺
model.fit(data, ...) # ❺
test_score = model.evaluate(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 模型
(train_images, train_labels), _ = mnist.load_data()
train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype("float32") / 255
model = keras.Sequential([
layers.Dense(512, activation="relu"),
layers.Dense(10, activation="softmax")
])
model.compile(optimizer=keras.optimizers.RMSprop(1.),
loss="sparse_categorical_crossentropy",
metrics=["accuracy"])
model.fit(train_images, train_labels,
epochs=10,
batch_size=128,
validation_split=0.2)
该模型很快达到了 30%–40% 的训练和验证准确率,但无法超越这一范围。让我们尝试将学习率降低到一个更合理的值1e-2。
列表 5.8 具有更合适学习率的相同模型
model = keras.Sequential([
layers.Dense(512, activation="relu"),
layers.Dense(10, activation="softmax")
])
model.compile(optimizer=keras.optimizers.RMSprop(1e-2),
loss="sparse_categorical_crossentropy",
metrics=["accuracy"])
model.fit(train_images, train_labels,
epochs=10,
batch_size=128,
validation_split=0.2)
现在模型能够训练。
如果你发现自己处于类似情况,尝试
-
降低或增加学习率。学习率过高可能导致更新远超适当拟合,就像前面的例子中一样,而学习率过低可能使训练过于缓慢,看起来停滞不前。
-
增加批量大小。具有更多样本的批次将导致更具信息性和更少噪声的梯度(方差更低)。
最终,你会找到一个能够开始训练的配置。
5.3.2 利用更好的架构先验知识
你有一个适合的模型,但由于某种原因你的验证指标根本没有改善。它们仍然不比随机分类器获得的好:你的模型训练了,但泛化能力不强。发生了什么?
这可能是你会遇到的最糟糕的机器学习情况。这表明你的方法在根本上有问题,而且可能不容易判断。以下是一些建议。
首先,可能是你使用的输入数据根本不包含足够的信息来预测目标:问题的表述是不可解的。这就是之前当我们尝试拟合一个 MNIST 模型时发生的情况,其中标签被洗牌:模型训练得很好,但验证准确率停留在 10%,因为用这样的数据集明显不可能泛化。
也可能是你使用的模型类型不适合当前的问题。例如,在第十章中,你会看到一个时间序列预测问题的例子,其中一个密集连接的架构无法击败一个微不足道的基准线,而一个更合适的循环架构确实能够很好地泛化。使用对问题做出正确假设的模型对于实现泛化是至关重要的:你应该利用正确的架构先验知识。
在接下来的章节中,你将学习到用于各种数据模态(图像、文本、时间序列等)的最佳架构。一般来说,你应该始终确保阅读关于你正在攻击的任务类型的架构最佳实践——很可能你不是第一个尝试的人。
5.3.3 增加模型容量
如果你成功得到一个适合的模型,其中验证指标在下降,并且似乎至少达到了一定程度的泛化能力,恭喜你:你已经接近成功了。接下来,你需要让你的模型开始过拟合。
考虑以下的小模型——一个简单的逻辑回归——在 MNIST 像素上训练。
列表 5.9 在 MNIST 上的简单逻辑回归
model = keras.Sequential([layers.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)
你会得到类似于图 5.14 的损失曲线:
import matplotlib.pyplot as plt
val_loss = history_small_model.history["val_loss"]
epochs = range(1, 21)
plt.plot(epochs, val_loss, "b--",
label="Validation loss")
plt.title("Effect of insufficient model capacity on validation loss")
plt.xlabel("Epochs")
plt.ylabel("Loss")
plt.legend()

图 5.14 不足模型容量对损失曲线的影响
验证指标似乎停滞不前,或者改善非常缓慢,而不是达到峰值然后逆转。验证损失降至 0.26,然后停在那里。你可以拟合,但你无法明显过拟合,即使在对训练数据进行多次迭代后。你在职业生涯中很可能经常遇到类似的曲线。
记住,总是可以过拟合的。就像训练损失不下降的问题一样,这是一个总是可以解决的问题。如果你似乎无法过拟合,很可能是你的模型的表征能力的问题:你需要一个更大的模型,一个具有更多容量的模型,也就是说,能够存储更多信息的模型。你可以通过添加更多层、使用更大的层(具有更多参数的层)或使用更适合问题的层来增加表征能力(更好的架构先验)。
让我们尝试训练一个更大的模型,一个具有两个中间层,每个层有 96 个单元:
model = keras.Sequential([
layers.Dense(96, activation="relu"),
layers.Dense(96, activation="relu"),
layers.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)
现在验证曲线看起来正是应该的:模型快速拟合,并在 8 个时期后开始过拟合(见图 5.15)。

图 5.15 具有适当容量的模型的验证损失
5.4 改善泛化能力
一旦你的模型展现出一定的泛化能力并且能够过拟合,就是时候将注意力转移到最大化泛化上了。
5.4.1 数据集整理
你已经学到了深度学习中泛化是源自数据的潜在结构。如果你的数据使得在样本之间平滑插值成为可能,你将能够训练一个泛化的深度学习模型。如果你的问题过于嘈杂或基本上是离散的,比如说,列表排序,深度学习将无法帮助你。深度学习是曲线拟合,而不是魔术。
因此,确保你正在使用一个合适的数据集是至关重要的。在数据收集上投入更多的精力和金钱几乎总是比在开发更好的模型上投入相同的精力和金钱产生更大的回报。
-
确保你有足够的数据。记住你需要对输入-输出空间进行密集采样。更多的数据将产生更好的模型。有时,一开始看起来不可能的问题会随着更大的数据集而变得可解。
-
最小化标记错误——可视化你的输入以检查异常,并校对你的标签。
-
清洁你的数据并处理缺失值(我们将在下一章中介绍)。
-
如果你有很多特征,而不确定哪些是真正有用的,那就进行特征选择。
提高数据泛化潜力的一个特别重要的方法是特征工程。对于大多数机器学习问题,特征工程是成功的关键因素。让我们来看看。
5.4.2 特征工程
特征工程是利用你对数据和手头的机器学习算法(在本例中是神经网络)的知识,通过在数据进入模型之前应用硬编码(非学习的)转换来使算法更好地工作的过程。在许多情况下,期望机器学习模型能够从完全任意的数据中学习是不合理的。数据需要以一种使模型工作更轻松的方式呈现给模型。
让我们看一个直观的例子。假设你正在开发一个模型,可以接受时钟的图像作为输入,并输出一天中的时间(见图 5.16)。

图 5.16 读取时钟上时间的特征工程
如果你选择将图像的原始像素作为输入数据,那么你将面临一个困难的机器学习问题。你将需要一个卷积神经网络来解决它,并且需要耗费相当多的计算资源来训练网络。
但是如果你已经在高层次上理解了问题(你了解人类如何读取时钟面上的时间),你可以为机器学习算法想出更好的输入特征:例如,编写一个五行的 Python 脚本来跟踪时钟指针的黑色像素,并输出每个指针尖端的(x, y)坐标。然后一个简单的机器学习算法可以学会将这些坐标与适当的时间关联起来。
你甚至可以更进一步:进行坐标变换,将(x, y)坐标表示为相对于图像中心的极坐标。你的输入将变为每个时钟指针的角度theta。此时,你的特征使问题变得如此简单,以至于不需要机器学习;一个简单的四舍五入操作和字典查找就足以恢复大致的时间。
这就是特征工程的本质:通过以更简单的方式表达问题来使问题变得更容易。使潜在流形更加平滑、简单、更有组织。通常这需要深入理解问题。
在深度学习之前,特征工程曾经是机器学习工作流程中最重要的部分,因为经典的浅层算法没有足够丰富的假设空间来自动学习有用的特征。你向算法呈现数据的方式对其成功至关重要。例如,在卷积神经网络在 MNIST 数字分类问题上取得成功之前,解决方案通常基于硬编码的特征,如数字图像中的循环次数、图像中每个数字的高度、像素值的直方图等。
幸运的是,现代深度学习消除了大部分特征工程的需求,因为神经网络能够自动从原始数据中提取有用的特征。这是否意味着只要使用深度神经网络,你就不必担心特征工程了?不,有两个原因:
-
优秀的特征仍然可以让您更优雅地解决问题,同时使用更少的资源。例如,使用卷积神经网络解决读取时钟面的问题是荒谬的。
-
优秀的特征让您可以用更少的数据解决问题。深度学习模型学习特征的能力依赖于有大量的训练数据可用;如果只有少量样本,那么它们的特征中的信息价值就变得至关重要。
5.4.3 使用早停法
在深度学习中,我们总是使用远远超参数化的模型:它们的自由度远远超过拟合数据的潜在流形所需的最小自由度。这种过度参数化并不是问题,因为你永远不会完全拟合一个深度学习模型。这样的拟合根本不会泛化。你总是会在达到最小可能的训练损失之前中断训练。
找到训练过程中达到最具泛化性拟合的确切点——欠拟合曲线和过拟合曲线之间的确切边界——是改善泛化的最有效的事情之一。
在上一章的例子中,我们会先训练我们的模型比需要的时间更长,以找出产生最佳验证指标的时期数量,然后我们会重新训练一个新模型,确切地达到那个时期数量。这是相当标准的,但它要求你做冗余的工作,有时可能很昂贵。当然,你可以在每个时期结束时保存你的模型,一旦找到最佳时期,就重用你最接近的已保存模型。在 Keras 中,通常使用EarlyStopping回调来实现这一点,它会在验证指标停止改善时立即中断训练,同时记住已知的最佳模型状态。你将在第七章学习如何使用回调。
5.4.4 正则化您的模型
正则化技术是一组最佳实践,积极阻碍模型完美拟合训练数据的能力,目的是使模型在验证期间表现更好。这被称为“正则化”模型,因为它倾向于使模型更简单,更“规则”,其曲线更平滑,更“通用”;因此,它对训练集不那么特定,更能够通过更接近地逼近数据的潜在流形来泛化。
请记住,正则化模型是一个应该始终由准确的评估程序指导的过程。只有通过测量,您才能实现泛化。
让我们回顾一些最常见的正则化技术,并在实践中应用它们来改进第四章的电影分类模型。
减小网络的大小
您已经学到,一个太小的模型不会过拟合。缓解过拟合的最简单方法是减小模型的大小(模型中可学习参数的数量,由层数和每层单元的数量确定)。如果模型的记忆资源有限,它将无法简单地记住其训练数据;因此,为了最小化损失,它将不得不求助于学习具有关于目标的预测能力的压缩表示——这正是我们感兴趣的表示类型。同时,请记住,您应该使用具有足够参数的模型,以便它们不会欠拟合:您的模型不应该缺乏记忆资源。在容量过大和容量不足之间需要找到一个折衷。
不幸的是,没有一个神奇的公式可以确定正确的层数或每个层的正确大小。你必须评估一系列不同的架构(当然是在验证集上,而不是在测试集上)以找到适合你数据的正确模型大小。找到合适模型大小的一般工作流程是从相对较少的层和参数开始,并增加层的大小或添加新层,直到看到验证损失的收益递减。
让我们尝试在电影评论分类模型上进行这个操作。以下列表显示了我们的原始模型。
列表 5.10 原始模型
from tensorflow.keras.datasets import imdb
(train_data, train_labels), _ = imdb.load_data(num_words=10000)
def vectorize_sequences(sequences, dimension=10000):
results = np.zeros((len(sequences), dimension))
for i, sequence in enumerate(sequences):
results[i, sequence] = 1.
return results
train_data = vectorize_sequences(train_data)
model = keras.Sequential([
layers.Dense(16, activation="relu"),
layers.Dense(16, activation="relu"),
layers.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.Sequential([
layers.Dense(4, activation="relu"),
layers.Dense(4, activation="relu"),
layers.Dense(1, activation="sigmoid")
])
model.compile(optimizer="rmsprop",
loss="binary_crossentropy",
metrics=["accuracy"])
history_smaller_model = model.fit(
train_data, train_labels,
epochs=20, batch_size=512, validation_split=0.4)
图 5.17 显示了原始模型和较小模型的验证损失的比较。

图 5.17 IMDB 评论分类中原始模型与较小模型的比较
正如你所看到的,较小的模型开始过拟合的时间比参考模型晚(在六个周期而不是四个周期之后),一旦开始过拟合,其性能下降速度更慢。
现在,让我们在基准模型中添加一个容量更大的模型——远远超出问题所需的容量。虽然通常使用远超参数化的模型来学习是标准做法,但确实存在记忆容量过大的情况。如果你的模型开始过拟合并且其验证损失曲线看起来波动较大(尽管波动的验证指标也可能是使用不可靠的验证过程的症状,比如验证分割太小),那么你会知道你的模型太大了。
列表 5.12 具有更高容量的模型版本
model = keras.Sequential([
layers.Dense(512, activation="relu"),
layers.Dense(512, activation="relu"),
layers.Dense(1, activation="sigmoid")
])
model.compile(optimizer="rmsprop",
loss="binary_crossentropy",
metrics=["accuracy"])
history_larger_model = model.fit(
train_data, train_labels,
epochs=20, batch_size=512, validation_split=0.4)
图 5.18 显示了较大模型与参考模型的比较。

图 5.18 原始模型与 IMDB 评论分类中更大模型的比较
较大模型几乎立即开始过拟合,仅经过一个周期,过拟合程度更严重。其验证损失也更加嘈杂。它非常快地将训练损失降至接近零。模型容量越大,就越能快速对训练数据进行建模(导致训练损失较低),但也越容易过拟合(导致训练和验证损失之间的差异较大)。
添加权重正则化
你可能熟悉奥卡姆剃刀原理:对于某事的两种解释,最有可能正确的解释是最简单的解释——即做出更少假设的解释。这个想法也适用于神经网络学习的模型:在给定一些训练数据和网络架构的情况下,多组权重值(多个模型)可以解释数据。简单模型比复杂模型更不容易过拟合。
在这种情况下,简单模型是指参数值分布熵较低的模型(或者是参数较少的模型,正如你在前一节中看到的)。因此,减轻过拟合的常见方法是通过对模型的复杂性施加约束,强制其权重只取小值,这使得权重值的分布更规则。这被称为权重正则化,通过向模型的损失函数添加与权重较大相关的成本来实现。这个成本有两种形式:
-
L1 正则化—添加的成本与权重系数的绝对值成比例(权重的L1 范数)。
-
L2 正则化—添加的成本与权重系数的平方值成比例(权重的L2 范数)。在神经网络的背景下,L2 正则化也被称为权重衰减。不要让不同的名称使你困惑:数学上,权重衰减与 L2 正则化是相同的。
在 Keras 中,通过将权重正则化器实例作为关键字参数传递给层来添加权重正则化。让我们向我们最初的电影评论分类模型添加 L2 权重正则化。
清单 5.13 向模型添加 L2 权重正则化
from tensorflow.keras import regularizers
model = keras.Sequential([
layers.Dense(16,
kernel_regularizer=regularizers.l2(0.002),
activation="relu"),
layers.Dense(16,
kernel_regularizer=regularizers.l2(0.002),
activation="relu"),
layers.Dense(1, activation="sigmoid")
])
model.compile(optimizer="rmsprop",
loss="binary_crossentropy",
metrics=["accuracy"])
history_l2_reg = model.fit(
train_data, train_labels,
epochs=20, batch_size=512, validation_split=0.4)
在前面的清单中,l2(0.002)表示层的权重矩阵中的每个系数将会为模型的总损失增加 0.002 * weight_coefficient_value ** 2。请注意,因为这种惩罚仅在训练时添加,所以该模型的损失在训练时会比在测试时高得多。
图 5.19 显示了 L2 正则化惩罚的影响。正如您所看到的,具有 L2 正则化的模型比参考模型更加抵抗过拟合,尽管两个模型具有相同数量的参数。

图 5.19 L2 权重正则化对验证损失的影响
作为 L2 正则化的替代,您可以使用以下 Keras 权重正则化器之一。
清单 5.14 Keras 中可用的不同权重正则化器
from tensorflow.keras import regularizers
regularizers.l1(0.001) # ❶
regularizers.l1_l2(l1=0.001, l2=0.001) # ❷
❶ L1 正则化
❷ 同时使用 L1 和 L2 正则化
请注意,权重正则化更常用于较小的深度学习模型。大型深度学习模型往往过于参数化,对权重值施加约束对模型容量和泛化能力的影响不大。在这些情况下,更倾向于使用不同的正则化技术:丢弃。
添加丢弃
Dropout 是神经网络中最有效和最常用的正则化技术之一;它是由杰夫·辛顿及其多伦多大学的学生开发的。应用于层的 Dropout 在训练期间会随机丢弃(设置为零)层的一些输出特征。假设给定层在训练期间对于给定输入样本会返回一个向量 [0.2, 0.5, 1.3, 0.8, 1.1]。应用 Dropout 后,这个向量将随机分布一些零条目:例如,[0, 0.5, 1.3, 0, 1.1]。丢弃率是被置零的特征的分数;通常设置在 0.2 和 0.5 之间。在测试时,没有单位被丢弃;相反,层的输出值会按照与丢弃率相等的因子进行缩放,以平衡训练时更多单位处于活动状态的事实。
考虑一个包含层输出 layer_output 的 NumPy 矩阵,形状为 (batch_size, features)。在训练时,我们随机将矩阵中的一部分值置零:
layer_output *= np.random.randint(0, high=2, size=layer_output.shape) # ❶
❶ 在训练时,输出中的单位有 50% 被丢弃
在测试时,我们通过丢弃率缩小输出。在这里,我们缩小了 0.5(因为之前我们丢弃了一半的单位):
layer_output *= 0.5 # ❶
❶ 在测试时
请注意,这个过程可以通过在训练时执行这两个操作并在测试时保持输出不变来实现,这通常是实践中的实现方式(参见图 5.20):
layer_output *= np.random.randint(0, high=2, size=layer_output.shape) # ❶
layer_output /= 0.5 # ❷
❶ 在训练时
❷ 请注意,在这种情况下我们是放大而不是缩小。

图 5.20 在训练时应用丢弃到激活矩阵,训练期间进行重新缩放。在测试时,激活矩阵保持不变。
这种技术可能看起来奇怪而武断。为什么这有助于减少过拟合?辛顿说,他受到了银行使用的防欺诈机制的启发,其中包括其他事物。他自己的话是:“我去了我的银行。出纳员经常变动,我问其中一个原因。他说他不知道,但他们经常换岗。我想这一定是因为需要员工之间的合作才能成功欺诈银行。这让我意识到,随机地在每个示例中删除不同的神经元子集将防止阴谋,从而减少过拟合。” 核心思想是在层的输出值中引入噪声可以打破不重要的偶然模式(辛顿称之为阴谋),如果没有噪声,模型将开始记忆。
在 Keras 中,您可以通过Dropout层在模型中引入辍学,该层应用于其前一层的输出。让我们在 IMDB 模型中添加两个Dropout层,看看它们在减少过拟合方面的效果如何。
列表 5.15 向 IMDB 模型添加辍学
model = keras.Sequential([
layers.Dense(16, activation="relu"),
layers.Dropout(0.5),
layers.Dense(16, activation="relu"),
layers.Dropout(0.5),
layers.Dense(1, activation="sigmoid")
])
model.compile(optimizer="rmsprop",
loss="binary_crossentropy",
metrics=["accuracy"])
history_dropout = model.fit(
train_data, train_labels,
epochs=20, batch_size=512, validation_split=0.4)
图 5.21 显示了结果的图表。这明显比参考模型有所改进——它似乎也比 L2 正则化效果更好,因为达到的最低验证损失有所改善。

图 5.21 辍学对验证损失的影响
总结一下,以下是在神经网络中最大化泛化并防止过拟合的最常见方法:
-
获取更多训练数据,或更好的训练数据。
-
开发更好的特征。
-
减少模型的容量。
-
添加权重正则化(适用于较小的模型)。
-
添加辍学。
总结
-
机器学习模型的目的是泛化:在以前未见过的输入上准确执行。这比看起来更难。
-
深度神经网络通过学习一个能够成功插值训练样本之间的参数模型来实现泛化——这样的模型可以说已经学会了训练数据的“潜在流形”。这就是为什么深度学习模型只能理解与训练时非常接近的输入。
-
机器学习中的基本问题是优化和泛化之间的紧张关系:要实现泛化,您必须首先对训练数据进行良好拟合,但随着时间的推移,改进模型对训练数据的拟合将不可避免地开始损害泛化。每一个深度学习最佳实践都涉及管理这种紧张关系。
-
深度学习模型泛化的能力来自于它们设法学习逼近其数据的潜在流形,因此可以通过插值理解新的输入。
-
在开发模型时,准确评估模型的泛化能力至关重要。您可以使用各种评估方法,从简单的留出验证到 K 折交叉验证和带有洗牌的迭代 K 折交叉验证。请记住,始终保留一个完全独立的测试集用于最终模型评估,因为您的验证数据可能会泄漏到模型中。
-
当您开始处理模型时,您的目标首先是实现具有一定泛化能力且可能过拟合的模型。实现此目标的最佳实践包括调整学习率和批量大小,利用更好的架构先验,增加模型容量,或者简单地延长训练时间。
-
当您的模型开始过拟合时,您的目标转为通过模型正则化来改善泛化。您可以减少模型的容量,添加辍学或权重正则化,并使用提前停止。自然地,更大或更好的数据集始终是帮助模型泛化的首要方法。
¹ 马克·吐温甚至称之为“人类所知最美味的水果”。
六、机器学习的通用工作流程
本章涵盖
-
制定机器学习问题的步骤
-
开发一个可工作模型的步骤
-
部署模型到生产环境并进行维护的步骤
我们先前的示例假设我们已经有一个标记的数据集可以开始训练模型。在现实世界中,这通常不是这样。您不是从数据集开始,而是从问题开始。
想象一下,您正在开设自己的机器学习咨询公司。您注册公司,建立一个漂亮的网站,通知您的网络。项目开始涌入:
-
为图片分享社交网络设计的个性化照片搜索引擎——输入“婚礼”并检索您在婚礼上拍摄的所有照片,无需任何手动标记。
-
在新兴聊天应用的帖子中标记垃圾邮件和冒犯性文本内容。
-
为在线广播用户构建音乐推荐系统。
-
为电子商务网站检测信用卡欺诈。
-
预测显示广告的点击率,以决定在特定时间向特定用户提供哪个广告。
-
在饼干制造线上的传送带上标记异常的饼干。
-
使用卫星图像预测尚未知晓的考古遗址的位置。
伦理注意事项
有时您可能会被提供道德上可疑的项目,比如“构建一个从某人面部照片中评估其信任度的 AI”。首先,该项目的有效性存疑:不清楚为什么信任度会反映在某人的脸上。其次,这样的任务打开了各种道德问题的大门。为这个任务收集数据集将等同于记录标记图片的人的偏见和成见。您将在这些数据上训练的模型只会将这些偏见编码到一个黑盒算法中,给予它们一层薄薄的合法性外衣。在我们这样一个技术素养较低的社会中,“AI 算法说这个人不可信”似乎比“约翰·史密斯说这个人不可信”更具权威性和客观性,尽管前者只是对后者的学习近似。您的模型将在规模上洗钱和操作化人类判断的最糟糕的方面,对真实人们的生活产生负面影响。
技术从来都不是中立的。如果您的工作对世界产生任何影响,这种影响具有道德方向:技术选择也是伦理选择。始终要谨慎选择您希望您的工作支持的价值观。
如果您可以从keras.datasets导入正确的数据集并开始拟合一些深度学习模型将会非常方便。不幸的是,在现实世界中,您将不得不从头开始。
在本章中,您将学习一个通用的逐步蓝图,您可以使用它来处理和解决任何机器学习问题,就像前面列表中的问题一样。这个模板将汇集和巩固您在第四章和第五章中学到的一切,并为您提供更广泛的背景,以便扎实您将在接下来的章节中学到的内容。
机器学习的通用工作流程大致分为三个部分:
-
定义任务—理解问题领域和客户要求背后的业务逻辑。收集数据集,了解数据代表什么,并选择如何衡量任务的成功。
-
开发模型—准备数据以便机器学习模型处理,选择一个模型评估协议和一个简单的基准来超越,训练一个具有泛化能力且可以过拟合的第一个模型,然后正则化和调整您的模型,直到实现最佳的泛化性能。
-
部署模型—向利益相关者展示您的工作,将模型部署到 Web 服务器、移动应用程序、网页或嵌入式设备,监控模型在实际环境中的性能,并开始收集构建下一代模型所需的数据。
让我们深入了解。
6.1 定义任务
没有对您正在做的事情的背景有深刻的理解,您无法做出好的工作。您的客户为什么要解决这个特定的问题?他们将从解决方案中获得什么价值——您的模型将如何使用,它将如何融入客户的业务流程?有哪些可用的数据,或者可以收集哪些数据?什么样的机器学习任务可以映射到业务问题?
6.1.1 界定问题
构建机器学习问题通常需要与利益相关者进行许多详细的讨论。以下是您应该牢记的问题:
-
您的输入数据将是什么?您试图预测什么?只有在有训练数据可用的情况下,您才能学会预测某些东西:例如,只有在有电影评论和情感注释可用时,您才能学会对电影评论的情感进行分类。因此,在这个阶段,数据可用性通常是限制因素。在许多情况下,您将不得不自己收集和注释新的数据集(我们将在下一节中介绍)。
-
您面临的是什么类型的机器学习任务?是二元分类?多类分类?标量回归?向量回归?多类别、多标签分类?图像分割?排名?还是其他类型,如聚类、生成或强化学习?在某些情况下,可能机器学习甚至不是理解数据的最佳方式,您应该使用其他方法,比如传统的统计分析。
-
照片搜索引擎项目是一个多类别、多标签分类任务。
-
垃圾邮件检测项目是一个二元分类任务。如果将“具有攻击性内容”设置为单独的类别,则它是一个三分类任务。
-
音乐推荐引擎最终最好不是通过深度学习来处理,而是通过矩阵分解(协同过滤)。
-
信用卡欺诈检测项目是一个二元分类任务。
-
点击率预测项目是一个标量回归任务。
-
异常饼干检测是一个二元分类任务,但它还需要一个对象检测模型作为第一阶段,以正确裁剪原始图像中的饼干。请注意,被称为“异常检测”的一组机器学习技术在这种情况下并不适用!
-
从卫星图像中寻找新的考古遗址项目是一个图像相似度排名任务:您需要检索看起来最像已知考古遗址的新图像。
-
-
现有解决方案是什么样的?也许您的客户已经有一个手工制作的算法来处理垃圾邮件过滤或信用卡欺诈检测,其中有很多嵌套的
if语句。也许目前是一个人手动处理正在考虑的过程——监控饼干工厂的传送带并手动移除坏饼干,或者制作歌曲推荐播放列表以发送给喜欢特定艺术家的用户。您应该确保了解已经存在的系统以及它们是如何工作的。 -
您是否需要处理特定的约束?例如,您可能会发现您正在为其构建垃圾邮件检测系统的应用程序严格端到端加密,因此垃圾邮件检测模型将不得不存在于最终用户的手机上,并且必须在外部数据集上进行训练。也许饼干过滤模型有延迟约束,因此它将需要在工厂的嵌入式设备上运行,而不是在远程服务器上运行。您应该了解您的工作将适用的完整背景。
一旦你完成了研究,你应该知道你的输入是什么,你的目标是什么,以及问题映射到什么类型的机器学习任务。在这个阶段要注意你所做的假设:
-
你假设可以根据输入来预测目标。
-
你假设可用的数据(或者你即将收集的数据)足够信息丰富,可以学习输入和目标之间的关系。
在你有一个可用的模型之前,这些只是等待验证或无效的假设。并不是所有问题都可以通过机器学习解决;仅仅因为你已经收集了输入 X 和目标 Y 的示例并不意味着 X 包含足够的信息来预测 Y。例如,如果你试图根据股票市场的最近价格历史来预测股票的走势,你很可能不会成功,因为价格历史并不包含太多预测信息。
6.1.2 收集数据集
一旦你了解了任务的性质,知道你的输入和目标将是什么,就是数据收集的时候了——大多数机器学习项目中最费力、耗时和昂贵的部分。
-
图片搜索引擎项目要求你首先选择你想要分类的标签集——你选择了 1 万个常见的图像类别。然后你需要手动为数十万张过去用户上传的图片打上这个标签集中的标签。
-
对于聊天应用的垃圾信息检测项目,由于用户聊天是端到端加密的,你无法使用其内容来训练模型。你需要获得一个包含数万条未经过滤的社交媒体帖子的独立数据集,并手动将其标记为垃圾信息、冒犯性内容或可接受内容。
-
对于音乐推荐引擎,你可以只使用用户的“喜欢”。不需要收集新数据。同样对于点击率预测项目:你有过去多年广告的点击率记录。
-
对于饼干标记模型,你需要在传送带上方安装摄像头,收集数万张图片,然后需要有人手动标记这些图片。目前知道如何做这件事的人在饼干工厂工作,但似乎并不太困难。你应该能够训练人员来做这件事。
-
卫星图像项目将需要一个考古学家团队收集一个现有感兴趣地点的数据库,对于每个地点,你需要找到在不同天气条件下拍摄的现有卫星图像。为了得到一个好的模型,你需要成千上万个不同的地点。
你在第五章学到,模型的泛化能力几乎完全来自于它所训练的数据的属性——你拥有的数据点数量,标签的可靠性,特征的质量。一个好的数据集是值得关注和投资的资产。如果你有额外的 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 范围内的整数,表示灰度值。在将这些数据馈送到网络之前,我们必须将其转换为float32并除以 255,以便最终得到 0-1 范围内的浮点值。类似地,在预测房价时,我们开始时的特征具有各种范围——一些特征具有较小的浮点值,而其他特征具有相当大的整数值。在将这些数据馈送到网络之前,我们必须独立地对每个特征进行归一化,使其具有标准差为 1 和均值为 0。
一般来说,将相对较大的值(例如,多位整数,远大于网络权重初始值的值)或异构数据(例如,一个特征在 0-1 范围内,另一个在 100-200 范围内)输入神经网络是不安全的。这样做可能会触发大的梯度更新,阻止网络收敛。为了让你的网络学习更容易,你的数据应具有以下特征:
-
取小值—通常,大多数值应该在 0-1 范围内。
-
保持同质性—所有特征的取值应该在大致相同的范围内。
此外,以下更严格的归一化实践是常见的,可以帮助,尽管并不总是必要的(例如,在数字分类示例中我们没有这样做):
-
将每个特征独立归一化为均值为 0。
-
将每个特征独立归一化为标准差为 1。
这在 NumPy 数组中很容易实现:
x -= x.mean(axis=0) # ❶
x /= x.std(axis=0)
❶ 假设 x 是一个形状为(样本数,特征数)的 2D 数据矩阵
处理缺失值
有时你的数据中可能会有缺失值。例如,在房价示例中,第一个特征(数据中索引为 0 的列)是人均犯罪率。如果这个特征并非所有样本都有呢?那么你的训练或测试数据中就会有缺失值。
你可以选择完全丢弃该特征,但不一定必须这样做。
-
如果特征是分类的,创建一个新类别表示“该值缺失”是安全的。模型将自动学习这对目标意味着什么。
-
如果特征是数值的,避免输入像
"0"这样的任意值,因为它可能会在特征形成的潜在空间中创建不连续,使得模型更难泛化。相反,考虑用数据集中该特征的平均值或中位数替换缺失值。你也可以训练一个模型,根据其他特征的值来预测该特征的值。
请注意,如果你预期测试数据中会有缺失的分类特征,但网络是在没有任何缺失值的数据上训练的,那么网络就不会学会忽略缺失值!在这种情况下,你应该人为生成带有缺失条目的训练样本:多次复制一些训练样本,并丢弃你预计在测试数据中可能缺失的一些分类特征。
6.2.2 选择评估协议
正如你在前一章中学到的,模型的目的是实现泛化,你在整个模型开发过程中做出的每个建模决策都将由寻求衡量泛化性能的验证指标指导。你的验证协议的目标是准确估计你选择的成功指标(如准确率)在实际生产数据上的表现。这个过程的可靠性对于构建一个有用的模型至关重要。
在第五章中,我们回顾了三种常见的评估协议:
-
保持留出验证集—当你有大量数据时,这是一个好方法。
-
进行 K 折交叉验证—当你的样本量太少,无法依靠留出验证来进行可靠评估时,这是正确的选择。
-
进行迭代的 K 折验证—这是在数据有限的情况下进行高精度模型评估的方法。
选择其中之一。在大多数情况下,第一个就足够好了。但是要记住,始终要注意你的验证集的代表性,并小心不要在训练集和验证集之间有重复的样本。
6.2.3 击败基准
当你开始着手模型本身时,你的初始目标是实现统计功效,就像你在第五章看到的那样:也就是说,开发一个能够击败简单基准的小模型。
在这个阶段,你应该专注于以下三件最重要的事情:
-
特征工程—过滤掉无信息的特征(特征选择)并利用你对问题的了解开发可能有用的新特征。
-
选择正确的架构先验—你将使用什么类型的模型架构?密集连接网络、卷积网络、循环神经网络、Transformer?深度学习是否是解决任务的好方法,还是应该使用其他方法?
-
选择足够好的训练配置—你应该使用什么损失函数?什么批量大小和学习率?
选择正确的损失函数
往往不可能直接优化衡量问题成功的指标。有时没有简单的方法将指标转化为损失函数;毕竟,损失函数需要仅通过一个小批量数据就能计算(理想情况下,损失函数应该能够计算一个数据点)并且必须可微分(否则,你无法使用反向传播来训练你的网络)。例如,广泛使用的分类指标 ROC AUC 不能直接优化。因此,在分类任务中,通常会优化 ROC AUC 的代理指标,如交叉熵。一般来说,你可以希望交叉熵越低,ROC AUC 就越高。
以下表格可以帮助你为几种常见问题类型选择最后一层激活函数和损失函数。
为你的模型选择正确的最后一层激活函数和损失函数
| 问题类型 | 最后一层激活函数 | 损失函数 |
|---|---|---|
| 二元分类 | sigmoid | binary_crossentropy |
| 多类单标签分类 | softmax | categorical_crossentropy |
| 多类多标签分类 | sigmoid | binary_crossentropy |
对于大多数问题,你可以从现有的模板开始。你不是第一个尝试构建垃圾邮件检测器、音乐推荐引擎或图像分类器的人。确保你研究先前的技术以识别在你的任务上表现良好的特征工程技术和模型架构。
注意,并非总是能够达到统计功效。如果在尝试多个合理的架构后仍无法超越简单的基准线,可能是你所问的问题在输入数据中没有答案。记住,你正在提出两个假设:
-
你假设可以根据输入预测输出。
-
你假设可用数据足够信息丰富,可以学习输入和输出之间的关系。
很可能这些假设是错误的,如果是这样,你必须回到起点。
6.2.4 扩展规模:开发一个过拟合的模型
一旦你获得了具有统计功效的模型,问题就变成了,你的模型是否足够强大?它是否有足够的层和参数来正确地建模手头的问题?例如,逻辑回归模型在 MNIST 上具有统计功效,但不足以很好地解决问题。记住,机器学习中的普遍张力在于优化和泛化之间。理想的模型是一个恰好处于欠拟合和过拟合、欠容量和过容量之间的模型。要弄清楚这个边界在哪里,首先你必须跨越它。
要弄清楚你需要多大的模型,你必须开发一个过拟合的模型。这相当容易,正如你在第五章学到的那样:
-
添加层。
-
让层变得更大。
-
训练更多的 epochs。
始终监控训练损失和验证损失,以及你关心的任何指标的训练和验证值。当你看到模型在验证数据上的表现开始下降时,你已经过拟合了。
6.2.5 正则化和调整你的模型
一旦您达到了统计功效并且能够过度拟合,您就知道自己走在正确的道路上。此时,您的目标是最大化泛化性能。
这个阶段将花费最多的时间:您将反复修改您的模型,训练它,在验证数据上评估(此时不是测试数据),再次修改它,并重复,直到模型达到最佳状态。以下是您应该尝试的一些事项:
-
尝试不同的架构;添加或删除层。
-
添加丢弃(dropout)。
-
如果您的模型很小,添加 L1 或 L2 正则化。
-
尝试不同的超参数(例如每层的单元数或优化器的学习率)以找到最佳配置。
-
可选地,迭代数据整理或特征工程:收集和注释更多数据,开发更好的特征,或删除看起来不具信息量的特征。
可以通过使用自动化超参数调整软件(如 KerasTuner)来自动化大部分工作。我们将在第十三章中介绍这个内容。
要注意以下事项:每次您使用验证过程的反馈来调整模型时,都会泄漏有关验证过程的信息到模型中。这样做几次是无害的;但如果在许多迭代中系统地重复这样做,最终会导致您的模型过度拟合验证过程(即使没有任何模型直接在任何验证数据上训练)。这会使评估过程变得不太可靠。
一旦您开发出令人满意的模型配置,您可以在所有可用数据(训练和验证)上训练最终的生产模型,并最后一次在测试集上评估它。如果测试集上的性能明显低于在验证数据上测量的性能,这可能意味着您的验证程序毕竟不可靠,或者在调整模型参数时开始过拟合验证数据。在这种情况下,您可能需要切换到更可靠的评估协议(如迭代 K 折验证)。
6.3 部署模型
您的模型已成功通过了测试集上的最终评估,它已准备好部署并开始其生产生活。
6.3.1 向利益相关者解释您的工作并设定期望
成功和客户信任在于始终满足或超越人们的期望。您交付的实际系统只是这个画面的一半;另一半是在推出前设定适当的期望。
非专业人士对人工智能系统的期望往往是不切实际的。例如,他们可能期望系统“理解”其任务,并能在任务背景下行使类似人类常识的能力。为了解决这个问题,您应该考虑展示一些模型的失败模式的示例(例如,展示被错误分类的样本是什么样子,特别是那些误分类看起来令人惊讶的样本)。
他们可能还期望人类水平的表现,特别是对以前由人类处理的流程。大多数机器学习模型,因为它们(不完美地)训练来逼近人类生成的标签,并没有真正达到那里。您应该清楚地传达模型表现的期望。避免使用抽象的陈述,如“模型的准确率为 98%”(大多数人心理上会四舍五入到 100%),而更倾向于谈论假阴性率和假阳性率。您可以说,“使用这些设置,欺诈检测模型将有 5%的假阴性率和 2.5%的假阳性率。每天,平均有 200 笔有效交易会被标记为欺诈并送去人工审核,平均会漏掉 14 笔欺诈交易。平均会正确捕捉 266 笔欺诈交易。”清楚地将模型的性能指标与业务目标联系起来。
你还应该确保与利益相关者讨论关键启动参数的选择——例如,应该在哪个概率阈值下标记交易(不同的阈值会产生不同的假阴性和假阳性率)。这些决策涉及权衡,只有深刻理解业务背景才能处理。
6.3.2 发布推理模型
当你到达一个可以保存训练模型的 Colab 笔记本时,一个机器学习项目并没有结束。你很少会将在训练过程中操作的完全相同的 Python 模型对象投入生产。
首先,你可能希望将模型导出为除 Python 之外的其他形式:
-
你的生产环境可能根本不支持 Python——例如,如果是移动应用程序或嵌入式系统。
-
如果应用程序的其余部分不是用 Python 编写的(可能是 JavaScript、C++ 等),使用 Python 来提供模型可能会引入显著的开销。
其次,由于你的生产模型只用于输出预测(一个称为推理的阶段),而不是用于训练,你有机会进行各种优化,使模型更快速并减少其内存占用。
让我们快速看一下你可以使用的不同模型部署选项。
将模型部署为 REST API
这可能是将模型转化为产品的常见方式:在服务器或云实例上安装 TensorFlow,并通过 REST API 查询模型的预测。你可以使用类似 Flask(或任何其他 Python Web 开发库)的东西构建自己的服务应用程序,或者使用 TensorFlow 的自己的库来将模型作为 API 进行部署,称为TensorFlow Serving(www.tensorflow.org/tfx/guide/serving)。使用 TensorFlow Serving,你可以在几分钟内部署一个 Keras 模型。
当你需要使用这种部署设置时
-
将消费模型预测的应用程序将可靠地访问互联网(显然)。例如,如果你的应用程序是一个移动应用程序,从远程 API 提供预测意味着应用程序在飞行模式或低连接环境下无法使用。
-
应用程序没有严格的延迟要求:请求、推理和答复往返通常需要大约 500 毫秒。
-
发送用于推理的输入数据并不高度敏感:数据需要以解密形式在服务器上可用,因为模型需要查看它(但请注意,应该使用 SSL 加密进行 HTTP 请求和答复)。
例如,图像搜索引擎项目、音乐推荐系统、信用卡欺诈检测项目和卫星图像项目都非常适合通过 REST API 进行服务。
将模型部署为 REST API 时一个重要的问题是,你是想自己托管代码,还是想使用完全托管的第三方云服务。例如,Google 的产品 Cloud AI Platform 允许你简单地将你的 TensorFlow 模型上传到 Google Cloud Storage(GCS),并提供一个 API 端点来查询它。它会处理许多实际细节,如批处理预测、负载平衡和扩展。
在设备上部署模型
有时,你可能需要让你的模型与使用它的应用程序运行在同一设备上——也许是智能手机、机器人上的嵌入式 ARM CPU,或者微型设备上的微控制器。你可能见过一款相机,能够自动检测你对准的场景中的人和面孔:那很可能是一款直接在相机上运行的小型深度学习模型。
当你需要使用这种设置时
-
你的模型具有严格的延迟约束或需要在低连接环境中运行。如果你正在构建一款沉浸式增强现实应用程序,查询远程服务器不是一个可行的选择。
-
您的模型可以足够小,以便在目标设备的内存和功率约束下运行。您可以使用 TensorFlow 模型优化工具包来帮助实现此目标(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:深入探讨
本章涵盖
-
使用
Sequential类、功能 API 和模型子类创建 Keras 模型 -
使用内置的 Keras 训练和评估循环
-
使用 Keras 回调函数自定义训练
-
使用 TensorBoard 监控训练和评估指标
-
从头开始编写训练和评估循环
您现在对 Keras 有了一些经验——您熟悉 Sequential 模型、Dense 层以及用于训练、评估和推断的内置 API——compile()、fit()、evaluate() 和 predict()。您甚至在第三章中学习了如何从 Layer 类继承以创建自定义层,以及如何使用 TensorFlow 的 GradientTape 实现逐步训练循环。
在接下来的章节中,我们将深入研究计算机视觉、时间序列预测、自然语言处理和生成式深度学习。这些复杂的应用将需要比 Sequential 架构和默认的 fit() 循环更多的内容。所以让我们首先把你变成一个 Keras 专家!在本章中,您将全面了解如何使用 Keras API:这是您将需要处理下一个遇到的高级深度学习用例的关键方法。
7.1 一系列工作流程
Keras API 的设计遵循“逐步揭示复杂性”的原则:使入门变得容易,同时使处理高复杂性用例成为可能,只需要在每一步进行增量学习。简单用例应该易于接近,任意高级工作流程应该是可能的:无论您想做多么小众和复杂的事情,都应该有一条明确的路径。这条路径建立在您从更简单工作流程中学到的各种东西之上。这意味着您可以从初学者成长为专家,仍然可以以不同的方式使用相同的工具。
因此,并没有一种“真正”的使用 Keras 的方式。相反,Keras 提供了一系列工作流程,从非常简单到非常灵活。有不同的构建 Keras 模型的方式,以及不同的训练方式,满足不同的需求。因为所有这些工作流程都基于共享的 API,如 Layer 和 Model,所以任何工作流程的组件都可以在任何其他工作流程中使用——它们可以相互通信。
7.2 构建 Keras 模型的不同方式
Keras 有三种构建模型的 API(见图 7.1):
-
Sequential 模型,最易接近的 API——基本上就是一个 Python 列表。因此,它仅限于简单的层堆叠。
-
功能 API 专注于类似图形的模型架构。它在可用性和灵活性之间找到了一个很好的中间点,因此它是最常用的模型构建 API。
-
模型子类化,一种低级选项,您可以从头开始编写所有内容。如果您想要对每一点都有完全控制,这是理想的选择。但是,您将无法访问许多内置的 Keras 功能,并且更容易出错。

图 7.1 逐步揭示模型构建的复杂性
7.2.1 Sequential 模型
构建 Keras 模型的最简单方法是使用已知的 Sequential 模型。
列表 7.1 Sequential 类
from tensorflow import keras
from tensorflow.keras import layers
model = keras.Sequential([
layers.Dense(64, activation="relu"),
layers.Dense(10, activation="softmax")
])
请注意,可以通过 add() 方法逐步构建相同的模型,这类似于 Python 列表的 append() 方法。
列表 7.2 逐步构建一个顺序模型
model = keras.Sequential()
model.add(layers.Dense(64, activation="relu"))
model.add(layers.Dense(10, activation="softmax"))
在第四章中,您看到层只有在第一次调用它们时才会构建(也就是说,创建它们的权重)。这是因为层的权重形状取决于它们的输入形状:在输入形状未知之前,它们无法被创建。
因此,前面的 Sequential 模型没有任何权重(列表 7.3),直到您实际在一些数据上调用它,或者使用输入形状调用其 build() 方法(列表 7.4)。
列表 7.3 尚未构建的模型没有权重
>>> model.weights # ❶
ValueError: Weights for model sequential_1 have not yet been created.
❶ 在那时,模型尚未构建。
列表 7.4 第一次调用模型以构建它
>>> model.build(input_shape=(None, 3)) # ❶
>>> model.weights # ❷
[<tf.Variable "dense_2/kernel:0" shape=(3, 64) dtype=float32, ... >,
<tf.Variable "dense_2/bias:0" shape=(64,) dtype=float32, ... >
<tf.Variable "dense_3/kernel:0" shape=(64, 10) dtype=float32, ... >,
<tf.Variable "dense_3/bias:0" shape=(10,) dtype=float32, ... >]
❶ 构建模型 - 现在模型将期望形状为(3,)的样本。输入形状中的 None 表示批次大小可以是任意值。
❷ 现在你可以检索模型的权重。
模型构建完成后,你可以通过summary()方法显示其内容,这对调试很有帮助。
列表 7.5 summary()方法
>>> model.summary()
Model: "sequential_1"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
dense_2 (Dense) (None, 64) 256
_________________________________________________________________
dense_3 (Dense) (None, 10) 650
=================================================================
Total params: 906
Trainable params: 906
Non-trainable params: 0
_________________________________________________________________
如你所见,这个模型恰好被命名为“sequential_1”。你可以为 Keras 中的所有内容命名 - 每个模型,每个层。
列表 7.6 使用name参数为模型和层命名
>>> model = keras.Sequential(name="my_example_model")
>>> model.add(layers.Dense(64, activation="relu", name="my_first_layer"))
>>> model.add(layers.Dense(10, activation="softmax", name="my_last_layer"))
>>> model.build((None, 3))
>>> model.summary()
Model: "my_example_model"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
my_first_layer (Dense) (None, 64) 256
_________________________________________________________________
my_last_layer (Dense) (None, 10) 650
=================================================================
Total params: 906
Trainable params: 906
Non-trainable params: 0
_________________________________________________________________
逐步构建 Sequential 模型时,能够在添加每个层后打印当前模型的摘要非常有用。但在构建模型之前无法打印摘要!实际上,有一种方法可以让你的Sequential动态构建:只需提前声明模型输入的形状即可。你可以通过Input类实现这一点。
列表 7.7 预先指定模型的输入形状
model = keras.Sequential()
model.add(keras.Input(shape=(3,))) # ❶
model.add(layers.Dense(64, activation="relu"))
❶ 使用 Input 声明输入的形状。请注意,shape 参数必须是每个样本的形状,而不是一个批次的形状。
现在你可以使用summary()来跟踪模型输出形状随着添加更多层而变化的情况:
>>> model.summary()
Model: "sequential_2"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
dense_4 (Dense) (None, 64) 256
=================================================================
Total params: 256
Trainable params: 256
Non-trainable params: 0
_________________________________________________________________
>>> model.add(layers.Dense(10, activation="softmax"))
>>> model.summary()
Model: "sequential_2"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
dense_4 (Dense) (None, 64) 256
_________________________________________________________________
dense_5 (Dense) (None, 10) 650
=================================================================
Total params: 906
Trainable params: 906
Non-trainable params: 0
_________________________________________________________________
处理转换输入的层(如第八章中将学习的卷积层)时,这是一个相当常见的调试工作流程。
7.2.2 功能 API
Sequential 模型易于使用,但其适用性极为有限:它只能表达具有单个输入和单个输出的模型,按顺序一个接一个地应用各个层。实际上,很常见遇到具有多个输入(例如图像及其元数据)、多个输出(关于数据的不同预测)或非线性拓扑的模型。
在这种情况下,你将使用功能 API 构建模型。这是你在实际应用中遇到的大多数 Keras 模型所使用的方法。它既有趣又强大,感觉就像玩乐高积木一样。
一个简单的例子
让我们从一些简单的东西开始:我们在上一节中使用的两个层的堆叠。其功能 API 版本如下列表所示。
列表 7.8 具有两个Dense层的简单功能模型
inputs = keras.Input(shape=(3,), name="my_input")
features = layers.Dense(64, activation="relu")(inputs)
outputs = layers.Dense(10, activation="softmax")(features)
model = keras.Model(inputs=inputs, outputs=outputs)
让我们一步一步地过一遍这个过程。
我们首先声明了一个Input(请注意,你也可以为这些输入对象命名,就像其他所有内容一样):
inputs = keras.Input(shape=(3,), name="my_input")
这个inputs对象保存了关于模型将处理的数据形状和 dtype 的信息:
>>> inputs.shape
(None, 3) # ❶
>>> inputs.dtype # ❷
float32
❶ 模型将处理每个样本形状为(3,)的批次。每批次的样本数量是可变的(由 None 批次大小表示)。
❷ 这些批次将具有 dtype float32。
我们称这样的对象为符号张量。它不包含任何实际数据,但它编码了模型在使用时将看到的实际数据张量的规格。它代表未来的数据张量。
接下来,我们创建了一个层并在输入上调用它:
features = layers.Dense(64, activation="relu")(inputs)
所有 Keras 层都可以在实际数据张量和这些符号张量上调用。在后一种情况下,它们将返回一个新的符号张量,带有更新的形状和 dtype 信息:
>>> features.shape
(None, 64)
在获得最终输出后,我们通过在Model构造函数中指定其输入和输出来实例化模型:
outputs = layers.Dense(10, activation="softmax")(features)
model = keras.Model(inputs=inputs, outputs=outputs)
这是我们模型的摘要:
>>> model.summary()
Model: "functional_1"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
my_input (InputLayer) [(None, 3)] 0
_________________________________________________________________
dense_6 (Dense) (None, 64) 256
_________________________________________________________________
dense_7 (Dense) (None, 10) 650
=================================================================
Total params: 906
Trainable params: 906
Non-trainable params: 0
_________________________________________________________________
多输入,多输出模型
与这个玩具模型不同,大多数深度学习模型看起来不像列表,而更像图形。例如,它们可能具有多个输入或多个输出。正是对于这种类型的模型,功能 API 真正发挥作用。
假设你正在构建一个系统,根据优先级对客户支持票据进行排名并将其路由到适当的部门。你的模型有三个输入:
-
票据的标题(文本输入)
-
票据的文本主体(文本输入)
-
用户添加的任何标签(假定为独热编码的分类输入)
我们可以将文本输入编码为大小为vocabulary_size的一维数组(有关文本编码技术的详细信息,请参阅第十一章)。
您的模型还有两个输出:
-
票证的优先级分数,介于 0 和 1 之间的标量(sigmoid 输出)
-
应处理票证的部门(对部门集合进行 softmax)
您可以使用几行代码使用函数式 API 构建此模型。
列表 7.9 多输入、多输出函数式模型
vocabulary_size = 10000
num_tags = 100
num_departments = 4
title = keras.Input(shape=(vocabulary_size,), name="title") # ❶
text_body = keras.Input(shape=(vocabulary_size,), name="text_body") # ❶
tags = keras.Input(shape=(num_tags,), name="tags") # ❶
features = layers.Concatenate()([title, text_body, tags]) # ❷
features = layers.Dense(64, activation="relu")(features) # ❸
priority = layers.Dense(1, activation="sigmoid", name="priority")(features)# ❹
department = layers.Dense(
num_departments, activation="softmax", name="department")(features) # ❹
model = keras.Model(inputs=[title, text_body, tags], # ❺
outputs=[priority, department]) # ❺
❶ 定义模型输入。
❷ 通过将它们连接起来,将输入特征组合成一个张量 features。
❸ 应用中间层以将输入特征重新组合为更丰富的表示。
❹ 定义模型输出。
❺ 通过指定其输入和输出来创建模型。
函数式 API 是一种简单、类似于乐高的、但非常灵活的方式,用于定义这样的层图。
训练多输入、多输出模型
您可以像训练序贯模型一样训练模型,通过使用输入和输出数据的列表调用fit()。这些数据列表应与传递给Model构造函数的输入顺序相同。
列表 7.10 通过提供输入和目标数组列表来训练模型
import numpy as np
num_samples = 1280
title_data = np.random.randint(0, 2, size=(num_samples, vocabulary_size)) # ❶
text_body_data = np.random.randint(0, 2, size=(num_samples, vocabulary_size)) # ❶
tags_data = np.random.randint(0, 2, size=(num_samples, num_tags)) # ❶
priority_data = np.random.random(size=(num_samples, 1)) # ❷
department_data = np.random.randint(0, 2, size=(num_samples, num_departments))# ❷
model.compile(optimizer="rmsprop",
loss=["mean_squared_error", "categorical_crossentropy"],
metrics=[["mean_absolute_error"], ["accuracy"]])
model.fit([title_data, text_body_data, tags_data],
[priority_data, department_data],
epochs=1)
model.evaluate([title_data, text_body_data, tags_data],
[priority_data, department_data])
priority_preds, department_preds = model.predict(
[title_data, text_body_data, tags_data])
❶ 虚拟输入数据
❷ 虚拟目标数据
如果您不想依赖输入顺序(例如,因为您有许多输入或输出),您还可以利用给Input对象和输出层命名的名称,并通过字典传递数据。
列表 7.11 通过提供输入和目标数组的字典来训练模型
model.compile(optimizer="rmsprop",
loss={"priority": "mean_squared_error", "department":
"categorical_crossentropy"},
metrics={"priority": ["mean_absolute_error"], "department":
["accuracy"]})
model.fit({"title": title_data, "text_body": text_body_data,
"tags": tags_data},
{"priority": priority_data, "department": department_data},
epochs=1)
model.evaluate({"title": title_data, "text_body": text_body_data,
"tags": tags_data},
{"priority": priority_data, "department": department_data})
priority_preds, department_preds = model.predict(
{"title": title_data, "text_body": text_body_data, "tags": tags_data})
函数式 API 的强大之处:访问层连接性
函数式模型是一种显式的图数据结构。这使得可以检查层如何连接并重用先前的图节点(即层输出)作为新模型的一部分。它还很好地适应了大多数研究人员在思考深度神经网络时使用的“心智模型”:层的图。这使得两个重要用例成为可能:模型可视化和特征提取。
让我们可视化我们刚刚定义的模型的连接性(模型的拓扑结构)。您可以使用plot_model()实用程序将函数式模型绘制为图形(参见图 7.2)。
keras.utils.plot_model(model, "ticket_classifier.png")

图 7.2 由plot_model()在我们的票证分类器模型上生成的图
您可以在此图中添加模型中每个层的输入和输出形状,这在调试过程中可能会有所帮助(参见图 7.3)。
keras.utils.plot_model(
model, "ticket_classifier_with_shape_info.png", show_shapes=True)

图 7.3 添加形状信息的模型图
张量形状中的“None”表示批处理大小:此模型允许任意大小的批处理。
访问层连接性还意味着您可以检查和重用图中的单个节点(层调用)。model.layers 模型属性提供组成模型的层列表,对于每个层,您可以查询layer.input 和layer.output。
列表 7.12 检索函数式模型中层的输入或输出
>>> model.layers
[<tensorflow.python.keras.engine.input_layer.InputLayer at 0x7fa963f9d358>,
<tensorflow.python.keras.engine.input_layer.InputLayer at 0x7fa963f9d2e8>,
<tensorflow.python.keras.engine.input_layer.InputLayer at 0x7fa963f9d470>,
<tensorflow.python.keras.layers.merge.Concatenate at 0x7fa963f9d860>,
<tensorflow.python.keras.layers.core.Dense at 0x7fa964074390>,
<tensorflow.python.keras.layers.core.Dense at 0x7fa963f9d898>,
<tensorflow.python.keras.layers.core.Dense at 0x7fa963f95470>]
>>> model.layers[3].input
[<tf.Tensor "title:0" shape=(None, 10000) dtype=float32>,
<tf.Tensor "text_body:0" shape=(None, 10000) dtype=float32>,
<tf.Tensor "tags:0" shape=(None, 100) dtype=float32>]
>>> model.layers[3].output
<tf.Tensor "concatenate/concat:0" shape=(None, 20100) dtype=float32>
这使您能够进行特征提取,创建重用另一个模型中间特征的模型。
假设您想要向先前的模型添加另一个输出—您想要估计给定问题票证解决所需时间,一种难度评级。您可以通过三个类别的分类层来实现这一点:“快速”、“中等”和“困难”。您无需从头开始重新创建和重新训练模型。您可以从先前模型的中间特征开始,因为您可以访问它们,就像这样。
列表 7.13 通过重用中间层输出创建新模型
features = model.layers[4].output # ❶
difficulty = layers.Dense(3, activation="softmax", name="difficulty")(features)
new_model = keras.Model(
inputs=[title, text_body, tags],
outputs=[priority, department, difficulty])
❶ 层[4] 是我们的中间密集层
让我们绘制我们的新模型(参见图 7.4):
keras.utils.plot_model(
new_model, "updated_ticket_classifier.png", show_shapes=True)

图 7.4 我们新模型的绘图
7.2.3 继承 Model 类
你应该了解的最后一个模型构建模式是最高级的一个:Model子类化。你在第三章学习了如何子类化Layer类来创建自定义层。子类化Model与此类似:
-
在
__init__()方法中,定义模型将使用的层。 -
在
call()方法中,定义模型的前向传递,重用先前创建的层。 -
实例化你的子类,并在数据上调用它以创建其权重。
将我们之前的例子重写为一个子类模型
让我们看一个简单的例子:我们将使用Model子类重新实现客户支持票务管理模型。
图 7.14 一个简单的子类模型
class CustomerTicketModel(keras.Model):
def __init__(self, num_departments):
super().__init__() # ❶
self.concat_layer = layers.Concatenate() # ❷
self.mixing_layer = layers.Dense(64, activation="relu") # ❷
self.priority_scorer = layers.Dense(1, activation="sigmoid") # ❷
self.department_classifier = layers.Dense( # ❷
num_departments, activation="softmax")
def call(self, inputs): # ❸
title = inputs["title"]
text_body = inputs["text_body"]
tags = inputs["tags"]
features = self.concat_layer([title, text_body, tags])
features = self.mixing_layer(features)
priority = self.priority_scorer(features)
department = self.department_classifier(features)
return priority, department
❶ 不要忘记调用 super()构造函数!
❷ 在构造函数中定义子层。
❸ 在 call()方法中定义前向传递。
一旦你定义了模型,你可以实例化它。请注意,它只会在第一次在一些数据上调用它时创建它的权重,就像Layer子类一样:
model = CustomerTicketModel(num_departments=4)
priority, department = model(
{"title": title_data, "text_body": text_body_data, "tags": tags_data})
到目前为止,一切看起来与Layer子类化非常相似,这是你在第三章遇到的工作流程。那么,Layer子类和Model子类之间的区别是什么呢?很简单:一个“层”是你用来创建模型的构建块,而一个“模型”是你实际上将要训练、导出用于推断等的顶层对象。简而言之,一个Model有fit()、evaluate()和predict()方法。层没有。除此之外,这两个类几乎是相同的。(另一个区别是你可以保存模型到磁盘上的文件中,我们将在几节中介绍。)
你可以像编译和训练 Sequential 或 Functional 模型一样编译和训练Model子类:
model.compile(optimizer="rmsprop",
loss=["mean_squared_error", "categorical_crossentropy"], # ❶
metrics=[["mean_absolute_error"], ["accuracy"]]) # ❶
model.fit({"title": title_data, # ❷
"text_body": text_body_data, # ❷
"tags": tags_data}, # ❷
[priority_data, department_data], # ❸
epochs=1)
model.evaluate({"title": title_data,
"text_body": text_body_data,
"tags": tags_data},
[priority_data, department_data])
priority_preds, department_preds = model.predict({"title": title_data,
"text_body": text_body_data,
"tags": tags_data})
❶ 作为损失和指标参数传递的结构必须与 call()返回的完全匹配——这里是两个元素的列表。
❷ 输入数据的结构必须与 call()方法所期望的完全匹配——这里是一个具有标题、正文和标签键的字典。
❸ 目标数据的结构必须与 call()方法返回的完全匹配——这里是两个元素的列表。
Model子类化工作流是构建模型的最灵活方式。它使你能够构建无法表示为层的有向无环图的模型——想象一下,一个模型在call()方法中使用层在一个for循环内,甚至递归调用它们。任何事情都是可能的——你有控制权。
警告:子类模型不支持的内容
这种自由是有代价的:对于子类模型,你需要负责更多的模型逻辑,这意味着你的潜在错误面更大。因此,你将需要更多的调试工作。你正在开发一个新的 Python 对象,而不仅仅是将 LEGO 积木拼在一起。
函数式模型和子类模型在本质上也有很大的不同。函数式模型是一个显式的数据结构——层的图,你可以查看、检查和修改。子类模型是一段字节码——一个带有包含原始代码的call()方法的 Python 类。这是子类化工作流程灵活性的源泉——你可以编写任何你喜欢的功能,但它也引入了新的限制。
例如,因为层之间的连接方式隐藏在call()方法的内部,你无法访问该信息。调用summary()不会显示层连接,并且你无法通过plot_model()绘制模型拓扑。同样,如果你有一个子类模型,你无法访问层图的节点进行特征提取,因为根本没有图。一旦模型被实例化,其前向传递就变成了一个完全的黑匣子。
7.2.4 混合和匹配不同的组件
重要的是,选择这些模式之一——Sequential 模型、Functional API 或 Model 子类化——不会将您排除在其他模式之外。Keras API 中的所有模型都可以平滑地相互操作,无论它们是 Sequential 模型、Functional 模型还是从头开始编写的子类化模型。它们都是同一系列工作流的一部分。
例如,您可以在 Functional 模型中使用子类化层或模型。
列表 7.15 创建包含子类化模型的 Functional 模型
class Classifier(keras.Model):
def __init__(self, num_classes=2):
super().__init__()
if num_classes == 2:
num_units = 1
activation = "sigmoid"
else:
num_units = num_classes
activation = "softmax"
self.dense = layers.Dense(num_units, activation=activation)
def call(self, inputs):
return self.dense(inputs)
inputs = keras.Input(shape=(3,))
features = layers.Dense(64, activation="relu")(inputs)
outputs = Classifier(num_classes=10)(features)
model = keras.Model(inputs=inputs, outputs=outputs)
相反地,您可以将 Functional 模型用作子类化层或模型的一部分。
列表 7.16 创建包含 Functional 模型的子类化模型
inputs = keras.Input(shape=(64,))
outputs = layers.Dense(1, activation="sigmoid")(inputs)
binary_classifier = keras.Model(inputs=inputs, outputs=outputs)
class MyModel(keras.Model):
def __init__(self, num_classes=2):
super().__init__()
self.dense = layers.Dense(64, activation="relu")
self.classifier = binary_classifier
def call(self, inputs):
features = self.dense(inputs)
return self.classifier(features)
model = MyModel()
7.2.5 记住:使用合适的工具来完成工作
您已经了解了构建 Keras 模型的工作流程的范围,从最简单的工作流程 Sequential 模型到最先进的工作流程模型子类化。何时应该使用其中一个而不是另一个?每种方法都有其优缺点——选择最适合手头工作的方法。
一般来说,Functional API 为您提供了易用性和灵活性之间的很好的权衡。它还为您提供了直接访问层连接性的功能,这对于模型绘图或特征提取等用例非常强大。如果您可以使用 Functional API——也就是说,如果您的模型可以表示为层的有向无环图——我建议您使用它而不是模型子类化。
今后,本书中的所有示例都将使用 Functional API,仅因为我们将使用的所有模型都可以表示为层的图。但是,我们将经常使用子类化层。一般来说,使用包含子类化层的 Functional 模型既具有高开发灵活性,又保留了 Functional API 的优势。
7.3 使用内置的训练和评估循环
逐步披露复杂性的原则——从非常简单到任意灵活的工作流程的访问,一步一步——也适用于模型训练。Keras 为您提供了不同的训练模型的工作流程。它们可以简单到在数据上调用 fit(),也可以高级到从头开始编写新的训练算法。
您已经熟悉了 compile()、fit()、evaluate()、predict() 的工作流程。作为提醒,请查看以下列表。
列表 7.17 标准工作流程:compile()、fit()、evaluate()、predict()
from tensorflow.keras.datasets import mnist
def get_mnist_model(): # ❶
inputs = keras.Input(shape=(28 * 28,))
features = layers.Dense(512, activation="relu")(inputs)
features = layers.Dropout(0.5)(features)
outputs = layers.Dense(10, activation="softmax")(features)
model = keras.Model(inputs, outputs)
return model
(images, labels), (test_images, test_labels) = mnist.load_data() # ❷
images = images.reshape((60000, 28 * 28)).astype("float32") / 255
test_images = test_images.reshape((10000, 28 * 28)).astype("float32") / 255
train_images, val_images = images[10000:], images[:10000]
train_labels, val_labels = labels[10000:], labels[:10000]
model = get_mnist_model()
model.compile(optimizer="rmsprop", # ❸
loss="sparse_categorical_crossentropy", # ❸
metrics=["accuracy"]) # ❸
model.fit(train_images, train_labels, # ❹
epochs=3, # ❹
validation_data=(val_images, val_labels)) # ❹
test_metrics = model.evaluate(test_images, test_labels) # ❺
predictions = model.predict(test_images) # ❻
❶ 创建一个模型(我们将其分解为一个单独的函数,以便以后重用)。
❷ 加载数据,保留一些用于验证。
❸ 通过指定其优化器、要最小化的损失函数和要监视的指标来编译模型。
❹ 使用 fit() 训练模型,可选择提供验证数据以监视在未见数据上的性能。
❺ 使用 evaluate() 在新数据上计算损失和指标。
❻ 使用 predict() 在新数据上计算分类概率。
有几种方法可以自定义这个简单的工作流程:
-
提供您自己的自定义指标。
-
将 callbacks 传递给
fit()方法以安排在训练过程中的特定时间点执行的操作。
让我们来看看这些。
7.3.1 编写自己的指标
指标对于衡量模型性能至关重要——特别是用于衡量模型在训练数据和测试数据上性能差异的指标。用于分类和回归的常用指标已经是内置的 keras.metrics 模块的一部分,大多数情况下您会使用它们。但是,如果您正在做一些与众不同的事情,您将需要能够编写自己的指标。这很简单!
Keras 指标是 keras.metrics.Metric 类的子类。像层一样,指标在 TensorFlow 变量中存储内部状态。与层不同,这些变量不会通过反向传播进行更新,因此您必须自己编写状态更新逻辑,这发生在 update_state() 方法中。
例如,这里有一个简单的自定义指标,用于测量均方根误差(RMSE)。
列表 7.18 通过子类化Metric类实现自定义指标
import tensorflow as tf
class RootMeanSquaredError(keras.metrics.Metric): # ❶
def __init__(self, name="rmse", **kwargs): # ❷
super().__init__(name=name, **kwargs) # ❷
self.mse_sum = self.add_weight(name="mse_sum", initializer="zeros")# ❷
self.total_samples = self.add_weight( # ❷
name="total_samples", initializer="zeros", dtype="int32") # ❷
def update_state(self, y_true, y_pred, sample_weight=None): # ❸
y_true = tf.one_hot(y_true, depth=tf.shape(y_pred)[1]) # ❹
mse = tf.reduce_sum(tf.square(y_true - y_pred))
self.mse_sum.assign_add(mse)
num_samples = tf.shape(y_pred)[0]
self.total_samples.assign_add(num_samples)
❶ 子类化 Metric 类。
❷ 在构造函数中定义状态变量。就像对于层一样,你可以访问add_weight()方法。
❸ 在update_state()中实现状态更新逻辑。y_true参数是一个批次的目标(或标签),而y_pred表示模型的相应预测。你可以忽略sample_weight参数——我们这里不会用到它。
❹ 为了匹配我们的 MNIST 模型,我们期望分类预测和整数标签。
你可以使用result()方法返回指标的当前值:
def result(self):
return tf.sqrt(self.mse_sum / tf.cast(self.total_samples, tf.float32))
与此同时,你还需要提供一种方法来重置指标状态,而不必重新实例化它——这使得相同的指标对象可以在训练的不同时期或在训练和评估之间使用。你可以使用reset_state()方法来实现这一点:
def reset_state(self):
self.mse_sum.assign(0.)
self.total_samples.assign(0)
自定义指标可以像内置指标一样使用。让我们试用我们自己的指标:
model = get_mnist_model()
model.compile(optimizer="rmsprop",
loss="sparse_categorical_crossentropy",
metrics=["accuracy", RootMeanSquaredError()])
model.fit(train_images, train_labels,
epochs=3,
validation_data=(val_images, val_labels))
test_metrics = model.evaluate(test_images, test_labels)
现在你可以看到fit()进度条显示你的模型的 RMSE。
7.3.2 使用回调
在大型数据集上进行数十个时期的训练运行,使用model.fit()有点像发射纸飞机:过了初始冲动,你就无法控制它的轨迹或着陆点。如果你想避免不良结果(从而浪费纸飞机),更明智的做法是使用不是纸飞机,而是一架可以感知环境、将数据发送回操作员并根据当前状态自动做出转向决策的无人机。Keras 的回调API 将帮助你将对model.fit()的调用从纸飞机转变为一个智能、自主的无人机,可以自我反省并动手采取行动。
回调是一个对象(实现特定方法的类实例),它在对fit()的模型调用中传递给模型,并在训练过程中的各个时刻被模型调用。它可以访问有关模型状态和性能的所有可用数据,并且可以采取行动:中断训练、保存模型、加载不同的权重集,或者以其他方式改变模型的状态。
以下是一些使用回调的示例:
-
模型检查点——在训练过程中保存模型的当前状态。
-
提前停止——当验证损失不再改善时中断训练(当然,保存在训练过程中获得的最佳模型)。
-
在训练过程中动态调整某些参数的值——比如优化器的学习率。
-
在训练过程中记录训练和验证指标,或者在更新时可视化模型学习到的表示——你熟悉的
fit()进度条实际上就是一个回调!
keras.callbacks模块包括许多内置回调(这不是一个详尽的列表):
keras.callbacks.ModelCheckpoint
keras.callbacks.EarlyStopping
keras.callbacks.LearningRateScheduler
keras.callbacks.ReduceLROnPlateau
keras.callbacks.CSVLogger
让我们回顾其中的两个,以便让你了解如何使用它们:EarlyStopping和ModelCheckpoint。
EarlyStopping 和 ModelCheckpoint 回调
当你训练一个模型时,有很多事情是你无法从一开始就预测的。特别是,你无法知道需要多少个时期才能达到最佳的验证损失。到目前为止,我们的例子采用了训练足够多个时期的策略,以至于你开始过拟合,使用第一次运行来确定适当的训练时期数量,然后最终启动一个新的训练运行,使用这个最佳数量。当然,这种方法是浪费的。更好的处理方式是在测量到验证损失不再改善时停止训练。这可以通过EarlyStopping回调来实现。
EarlyStopping回调会在监控的目标指标停止改进一定数量的时期后中断训练。例如,此回调允许您在开始过拟合时立即中断训练,从而避免不得不为更少的时期重新训练模型。此回调通常与ModelCheckpoint结合使用,后者允许您在训练过程中持续保存模型(可选地,仅保存迄今为止的当前最佳模型:在时期结束时表现最佳的模型版本)。
列表 7.19 在fit()方法中使用callbacks参数
callbacks_list = [ # ❶
keras.callbacks.EarlyStopping( # ❷
monitor="val_accuracy", # ❸
patience=2, # ❹
),
keras.callbacks.ModelCheckpoint( # ❺
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=(val_images, val_labels)) # ❾
❶ 回调通过fit()方法中的 callbacks 参数传递给模型,该参数接受一个回调函数列表。您可以传递任意数量的回调函数。
❷ 当改进停止时中断训练
❸ 监控模型的验证准确率
❹ 当准确率连续两个时期没有改善时中断训练
❺ 在每个时期结束后保存当前权重
❻ 目标模型文件的路径
❼ 这两个参数意味着除非 val_loss 有所改善,否则您不会覆盖模型文件,这样可以保留训练过程中看到的最佳模型。
❽ 您正在监视准确率,因此它应该是模型指标的一部分。
❾ 请注意,由于回调将监视验证损失和验证准确率,您需要将 validation_data 传递给 fit()调用。
请注意,您也可以在训练后手动保存模型——只需调用model.save('my_checkpoint_path')。要重新加载保存的模型,只需使用
model = keras.models.load_model("checkpoint_path.keras")
7.3.3 编写自己的回调函数
如果您需要在训练过程中执行特定操作,而内置回调函数没有涵盖,您可以编写自己的回调函数。通过继承keras.callbacks.Callback类来实现回调函数。然后,您可以实现以下任意数量的透明命名方法,这些方法在训练过程中的各个时刻调用:
on_epoch_begin(epoch, logs) # ❶
on_epoch_end(epoch, logs) # ❷
on_batch_begin(batch, logs) # ❸
on_batch_end(batch, logs) # ❹
on_train_begin(logs) # ❺
on_train_end(logs) # ❻
❶ 在每个时期开始时调用
❷ 在每个时期结束时调用
❸ 在处理每个批次之前调用
❹ 在处理每个批次后立即调用
❺ 在训练开始时调用
❻ 在训练结束时调用
这些方法都带有一个logs参数,其中包含有关先前批次、时期或训练运行的信息——训练和验证指标等。on_epoch_*和on_batch_*方法还将时期或批次索引作为它们的第一个参数(一个整数)。
这里有一个简单的示例,它保存了训练过程中每个批次的损失值列表,并在每个时期结束时保存了这些值的图表。
列表 7.20 通过继承Callback类创建自定义回调
from matplotlib import pyplot as plt
class LossHistory(keras.callbacks.Callback):
def on_train_begin(self, logs):
self.per_batch_losses = []
def on_batch_end(self, batch, logs):
self.per_batch_losses.append(logs.get("loss"))
def on_epoch_end(self, epoch, logs):
plt.clf()
plt.plot(range(len(self.per_batch_losses)), self.per_batch_losses,
label="Training loss for each batch")
plt.xlabel(f"Batch (epoch {epoch})")
plt.ylabel("Loss")
plt.legend()
plt.savefig(f"plot_at_epoch_{epoch}")
self.per_batch_losses = []
让我们试驾一下:
model = get_mnist_model()
model.compile(optimizer="rmsprop",
loss="sparse_categorical_crossentropy",
metrics=["accuracy"])
model.fit(train_images, train_labels,
epochs=10,
callbacks=[LossHistory()],
validation_data=(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,您可以
-
在训练过程中可视化监控指标
-
可视化您的模型架构
-
可视化激活和梯度的直方图
-
在 3D 中探索嵌入
如果您监控的信息不仅仅是模型的最终损失,您可以更清晰地了解模型的作用和不作用,并且可以更快地取得进展。
使用 TensorBoard 与 Keras 模型和fit()方法的最简单方法是使用keras.callbacks.TensorBoard回调。
在最简单的情况下,只需指定回调写入日志的位置,然后就可以开始了:
model = get_mnist_model()
model.compile(optimizer="rmsprop",
loss="sparse_categorical_crossentropy",
metrics=["accuracy"])
tensorboard = keras.callbacks.TensorBoard(
log_dir="/full_path_to_your_log_dir",
)
model.fit(train_images, train_labels,
epochs=10,
validation_data=(val_images, val_labels),
callbacks=[tensorboard])
一旦模型开始运行,它将在目标位置写入日志。如果您在本地计算机上运行 Python 脚本,则可以使用以下命令启动本地 TensorBoard 服务器(请注意,如果您通过pip安装了 TensorFlow,则tensorboard可执行文件应该已经可用;如果没有,则可以通过pip install tensorboard手动安装 TensorBoard):
tensorboard --logdir /full_path_to_your_log_dir
然后,您可以转到命令返回的 URL 以访问 TensorBoard 界面。
如果您在 Colab 笔记本中运行脚本,则可以作为笔记本的一部分运行嵌入式 TensorBoard 实例,使用以下命令:
%load_ext tensorboard
%tensorboard --logdir /full_path_to_your_log_dir
在 TensorBoard 界面中,您将能够监视训练和评估指标的实时图表(参见图 7.7)。

图 7.7 TensorBoard 可用于轻松监控训练和评估指标。
7.4 编写自己的训练和评估循环
fit()工作流在易用性和灵活性之间取得了很好的平衡。这是您大部分时间将使用的方法。但是,即使使用自定义指标、自定义损失和自定义回调,它也不意味着支持深度学习研究人员可能想要做的一切。
毕竟,内置的fit()工作流仅专注于监督学习:一种已知目标(也称为标签或注释)与输入数据相关联的设置,您根据这些目标和模型预测的函数计算损失。然而,并非所有形式的机器学习都属于这一类别。还有其他设置,其中没有明确的目标,例如生成学习(我们将在第十二章中讨论)、自监督学习(目标来自输入)和强化学习(学习受偶尔“奖励”驱动,类似训练狗)。即使您正在进行常规监督学习,作为研究人员,您可能希望添加一些需要低级灵活性的新颖功能。
每当您发现内置的fit()不够用时,您将需要编写自己的自定义训练逻辑。您已经在第二章和第三章看到了低级训练循环的简单示例。作为提醒,典型训练循环的内容如下:
-
运行前向传播(计算模型的输出)在梯度磁带内以获得当前数据批次的损失值。
-
检索损失相对于模型权重的梯度。
-
更新模型的权重以降低当前数据批次上的损失值。
这些步骤将根据需要重复多个批次。这基本上是fit()在幕后执行的操作。在本节中,您将学习如何从头开始重新实现fit(),这将为您提供编写任何可能想出的训练算法所需的所有知识。
让我们详细了解一下。
7.4.1 训练与推断
在你迄今为止看到的低级训练循环示例中,第 1 步(前向传播)通过predictions = model(inputs)完成,第 2 步(检索梯度)通过gradients = tape.gradient(loss, model.weights)完成。在一般情况下,实际上有两个你需要考虑的细微之处。
一些 Keras 层,比如Dropout层,在训练和推理(当你用它们生成预测时)时有不同的行为。这些层在它们的call()方法中暴露了一个training布尔参数。调用dropout(inputs, training=True)会丢弃一些激活条目,而调用dropout(inputs, training=False)则不会做任何操作。扩展到 Functional 和 Sequential 模型,它们的call()方法中也暴露了这个training参数。记得在前向传播时传递training=True给 Keras 模型!因此我们的前向传播变成了predictions = model(inputs, training=True)。
另外,请注意,当你检索模型权重的梯度时,不应该使用tape.gradients(loss, model.weights),而应该使用tape .gradients(loss, model.trainable_weights)。实际上,层和模型拥有两种权重:
-
可训练权重—这些权重通过反向传播来更新,以最小化模型的损失,比如
Dense层的核和偏置。 -
不可训练权重—这些权重在前向传播过程中由拥有它们的层更新。例如,如果你想让一个自定义层记录到目前为止处理了多少批次,那么这些信息将存储在不可训练权重中,每个批次,你的层会将计数器加一。
在 Keras 内置层中,唯一具有不可训练权重的层是BatchNormalization层,我们将在第九章讨论。BatchNormalization层需要不可训练权重来跟踪通过它的数据的均值和标准差的信息,以便执行特征归一化的在线近似(这是你在第六章学到的概念)。
考虑到这两个细节,监督学习训练步骤最终看起来像这样:
def train_step(inputs, targets):
with tf.GradientTape() as tape:
predictions = model(inputs, training=True)
loss = loss_fn(targets, predictions)
gradients = tape.gradients(loss, model.trainable_weights)
optimizer.apply_gradients(zip(model.trainable_weights, gradients))
7.4.2 指标的低级使用
在低级训练循环中,你可能想要利用 Keras 指标(无论是自定义的还是内置的)。你已经了解了指标 API:只需为每个目标和预测批次调用update_state(y_true, y_pred),然后使用result()来查询当前指标值:
metric = keras.metrics.SparseCategoricalAccuracy()
targets = [0, 1, 2]
predictions = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
metric.update_state(targets, predictions)
current_result = metric.result()
print(f"result: {current_result:.2f}")
你可能还需要跟踪标量值的平均值,比如模型的损失。你可以通过keras.metrics.Mean指标来实现这一点:
values = [0, 1, 2, 3, 4]
mean_tracker = keras.metrics.Mean()
for value in values:
mean_tracker.update_state(value)
print(f"Mean of values: {mean_tracker.result():.2f}")
当你想要重置当前结果(在训练周期的开始或评估的开始)时,请记得使用metric.reset_state()。
7.4.3 完整的训练和评估循环
让我们将前向传播、反向传播和指标跟踪结合到一个类似于fit()的训练步骤函数中,该函数接受一批数据和目标,并返回fit()进度条显示的日志。
列表 7.21 编写逐步训练循环:训练步骤函数
model = get_mnist_model()
loss_fn = keras.losses.SparseCategoricalCrossentropy() # ❶
optimizer = keras.optimizers.RMSprop() # ❷
metrics = [keras.metrics.SparseCategoricalAccuracy()] # ❸
loss_tracking_metric = keras.metrics.Mean() # ❹
def train_step(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(gradients, model.trainable_weights)) # ❻
logs = {} # ❼
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() # ❽
return logs # ❾
❶ 准备损失函数。
❷ 准备优化器。
❸ 准备要监视的指标列表。
❹ 准备一个 Mean 指标追踪器来跟踪损失的平均值。
❺ 进行前向传播。注意我们传递了training=True。
❻ 进行反向传播。注意我们使用了model.trainable_weights。
❼ 跟踪指标。
❽ 跟踪损失平均值。
❾ 返回当前的指标值和损失。
我们需要在每个周期开始和运行评估之前重置指标的状态。这里有一个实用函数来做到这一点。
列表 7.22 编写逐步训练循环:重置指标
def reset_metrics():
for metric in metrics:
metric.reset_state()
loss_tracking_metric.reset_state()
现在我们可以布置完整的训练循环。请注意,我们使用tf.data.Dataset对象将我们的 NumPy 数据转换为一个迭代器,该迭代器按大小为 32 的批次迭代数据。
列表 7.23 编写逐步训练循环:循环本身
training_dataset = tf.data.Dataset.from_tensor_slices(
(train_images, train_labels))
training_dataset = training_dataset.batch(32)
epochs = 3
for epoch in range(epochs):
reset_metrics()
for inputs_batch, targets_batch in training_dataset:
logs = train_step(inputs_batch, targets_batch)
print(f"Results at the end of epoch {epoch}")
for key, value in logs.items():
print(f"...{key}: {value:.4f}")
这就是评估循环:一个简单的for循环,反复调用test_step()函数,该函数处理一个数据批次。test_step()函数只是train_step()逻辑的一个子集。它省略了处理更新模型权重的代码——也就是说,所有涉及GradientTape和优化器的内容。
列表 7.24 编写逐步评估循环
def test_step(inputs, targets):
predictions = model(inputs, training=False) # ❶
loss = loss_fn(targets, predictions)
logs = {}
for metric in metrics:
metric.update_state(targets, predictions)
logs["val_" + metric.name] = metric.result()
loss_tracking_metric.update_state(loss)
logs["val_loss"] = loss_tracking_metric.result()
return logs
val_dataset = tf.data.Dataset.from_tensor_slices((val_images, val_labels))
val_dataset = val_dataset.batch(32)
reset_metrics()
for inputs_batch, targets_batch in val_dataset:
logs = test_step(inputs_batch, targets_batch)
print("Evaluation results:")
for key, value in logs.items():
print(f"...{key}: {value:.4f}")
❶ 注意我们传递了 training=False。
恭喜你——你刚刚重新实现了fit()和evaluate()!或几乎:fit()和evaluate()支持许多更多功能,包括大规模分布式计算,这需要更多的工作。它还包括几个关键的性能优化。
让我们来看看其中一个优化:TensorFlow 函数编译。
7.4.4 使用 tf.function 使其更快
你可能已经注意到,尽管实现了基本相同的逻辑,但自定义循环的运行速度明显比内置的fit()和evaluate()慢。这是因为,默认情况下,TensorFlow 代码是逐行执行的,急切执行,类似于 NumPy 代码或常规 Python 代码。急切执行使得调试代码更容易,但从性能的角度来看远非最佳选择。
将你的 TensorFlow 代码编译成一个可以全局优化的计算图更有效。要做到这一点的语法非常简单:只需在要执行之前的任何函数中添加@tf.function,如下面的示例所示。
列表 7.25 为我们的评估步骤函数添加@tf.function装饰器
@tf.function # ❶
def test_step(inputs, targets):
predictions = model(inputs, training=False)
loss = loss_fn(targets, predictions)
logs = {}
for metric in metrics:
metric.update_state(targets, predictions)
logs["val_" + metric.name] = metric.result()
loss_tracking_metric.update_state(loss)
logs["val_loss"] = loss_tracking_metric.result()
return logs
val_dataset = tf.data.Dataset.from_tensor_slices((val_images, val_labels))
val_dataset = val_dataset.batch(32)
reset_metrics()
for inputs_batch, targets_batch in val_dataset:
logs = test_step(inputs_batch, targets_batch)
print("Evaluation results:")
for key, value in logs.items():
print(f"...{key}: {value:.4f}")
❶ 这是唯一改变的一行。
在 Colab CPU 上,我们从运行评估循环需要 1.80 秒,降低到只需要 0.8 秒。速度更快!
记住,在调试代码时,最好急切地运行它,不要添加任何@tf.function装饰器。这样更容易跟踪错误。一旦你的代码运行正常并且想要加快速度,就在你的训练步骤和评估步骤或任何其他性能关键函数中添加@tf.function装饰器。
7.4.5 利用 fit() 与自定义训练循环
在之前的章节中,我们完全从头开始编写自己的训练循环。这样做为你提供了最大的灵活性,但同时你会写很多代码,同时错过了fit()的许多便利功能,比如回调或内置的分布式训练支持。
如果你需要一个自定义训练算法,但仍想利用内置 Keras 训练逻辑的强大功能,那么实际上在fit()和从头编写的训练循环之间有一个中间地带:你可以提供一个自定义训练步骤函数,让框架来处理其余部分。
你可以通过重写Model类的train_step()方法来实现这一点。这个函数是fit()为每个数据批次调用的函数。然后你可以像往常一样调用fit(),它将在幕后运行你自己的学习算法。
这里有一个简单的例子:
-
我们创建一个继承
keras.Model的新类。 -
我们重写了方法
train_step(self,data)。它的内容几乎与我们在上一节中使用的内容相同。它返回一个将度量名称(包括损失)映射到它们当前值的字典。 -
我们实现了一个
metrics属性,用于跟踪模型的Metric实例。这使得模型能够在每个时期开始和在调用evaluate()开始时自动调用reset_state()模型的度量,因此你不必手动执行。
列表 7.26 实现一个自定义训练步骤以与fit()一起使用
loss_fn = keras.losses.SparseCategoricalCrossentropy()
loss_tracker = keras.metrics.Mean(name="loss") # ❶
class CustomModel(keras.Model):
def train_step(self, data): # ❷
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(gradients, model.trainable_weights))
loss_tracker.update_state(loss) # ❹
return {"loss": loss_tracker.result()} # ❺
@property
def metrics(self): # ❻
return [loss_tracker] # ❻
❶ 这个度量对象将用于跟踪训练和评估过程中每个批次损失的平均值。
❷ 我们重写了 train_step 方法。
❸ 我们使用self(inputs, training=True)而不是model(inputs, training=True),因为我们的模型就是类本身。
❹ 我们更新跟踪平均损失的损失跟踪器指标。
❺ 通过查询损失跟踪器指标返回到目前为止的平均损失。
❻ 任何你想要在不同 epoch 之间重置的指标都应该在这里列出。
现在,我们可以实例化我们的自定义模型,编译它(我们只传递了优化器,因为损失已经在模型外部定义),并像往常一样使用fit()进行训练:
inputs = keras.Input(shape=(28 * 28,))
features = layers.Dense(512, activation="relu")(inputs)
features = layers.Dropout(0.5)(features)
outputs = layers.Dense(10, activation="softmax")(features)
model = CustomModel(inputs, outputs)
model.compile(optimizer=keras.optimizers.RMSprop())
model.fit(train_images, train_labels, epochs=3)
有几点需要注意:
-
这种模式不会阻止你使用 Functional API 构建模型。无论你是构建 Sequential 模型、Functional 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所做的。
因此,我们可以写下
class CustomModel(keras.Model):
def train_step(self, data):
inputs, targets = data
with tf.GradientTape() as tape:
predictions = self(inputs, training=True)
loss = self.compiled_loss(targets, predictions) # ❶
gradients = tape.gradient(loss, model.trainable_weights)
optimizer.apply_gradients(zip(gradients, model.trainable_weights))
self.compiled_metrics.update_state(targets, predictions) # ❷
return {m.name: m.result() for m in self.metrics} # ❸
❶ 通过self.compiled_loss计算损失。
❷ 通过self.compiled_metrics更新模型的指标。
❸ 返回一个将指标名称映射到它们当前值的字典。
让我们试试:
inputs = keras.Input(shape=(28 * 28,))
features = layers.Dense(512, activation="relu")(inputs)
features = layers.Dropout(0.5)(features)
outputs = layers.Dense(10, activation="softmax")(features)
model = CustomModel(inputs, outputs)
model.compile(optimizer=keras.optimizers.RMSprop(),
loss=keras.losses.SparseCategoricalCrossentropy(),
metrics=[keras.metrics.SparseCategoricalAccuracy()])
model.fit(train_images, train_labels, epochs=3)
这是很多信息,但现在你已经了解足够多的内容来使用 Keras 做几乎任何事情了。
摘要
-
Keras 提供了一系列不同的工作流程,基于逐步透露复杂性的原则。它们之间可以平滑地互操作。
-
你可以通过
Sequential类、Functional API 或通过子类化Model类来构建模型。大多数情况下,你会使用 Functional API。 -
训练和评估模型的最简单方法是通过默认的
fit()和evaluate()方法。 -
Keras 回调提供了一种简单的方法,在调用
fit()期间监视模型,并根据模型的状态自动采取行动。 -
你也可以通过重写
train_step()方法完全控制fit()的行为。 -
除了
fit(),你还可以完全从头开始编写自己的训练循环。这对于实现全新训练算法的研究人员非常有用。
八、计算机视觉深度学习简介
本章涵盖
-
理解卷积神经网络(卷积网络)
-
使用数据增强来减轻过拟合
-
使用预训练的卷积网络进行特征提取
-
对预训练的卷积网络进行微调
计算机视觉是深度学习最早也是最大的成功故事。每天,你都在与深度视觉模型互动——通过 Google 照片、Google 图像搜索、YouTube、相机应用中的视频滤镜、OCR 软件等等。这些模型也是自动驾驶、机器人、AI 辅助医学诊断、自动零售结账系统甚至自动农业等尖端研究的核心。
计算机视觉是在 2011 年至 2015 年间导致深度学习初次崛起的问题领域。一种称为卷积神经网络的深度学习模型开始在那个时候在图像分类竞赛中取得非常好的结果,首先是 Dan Ciresan 在两个小众竞赛中获胜(2011 年 ICDAR 汉字识别竞赛和 2011 年 IJCNN 德国交通标志识别竞赛),然后更引人注目的是 2012 年秋季 Hinton 的团队赢得了备受关注的 ImageNet 大规模视觉识别挑战赛。在其他计算机视觉任务中,很快也涌现出更多有希望的结果。
有趣的是,这些早期的成功并不足以使深度学习在当时成为主流——这花了几年的时间。计算机视觉研究社区花了很多年投资于除神经网络之外的方法,他们并不准备放弃这些方法,只因为有了一个新的玩家。在 2013 年和 2014 年,深度学习仍然面临着许多资深计算机视觉研究人员的强烈怀疑。直到 2016 年,它才最终占据主导地位。我记得在 2014 年 2 月,我曾劝告我的一位前教授转向深度学习。“这是下一个大事!”我会说。“嗯,也许只是一时的热潮,”他回答。到了 2016 年,他的整个实验室都在做深度学习。一个时机已经成熟的想法是无法阻挡的。
本章介绍了卷积神经网络,也被称为卷积网络,这种深度学习模型现在几乎在计算机视觉应用中被普遍使用。你将学会将卷积网络应用于图像分类问题,特别是涉及小训练数据集的问题,如果你不是一个大型科技公司,这是最常见的用例。
8.1 卷积网络简介
我们将要深入探讨卷积网络是什么以及为什么它们在计算机视觉任务中取得如此成功的理论。但首先,让我们从一个简单的卷积网络示例开始,该示例对 MNIST 数字进行分类,这是我们在第二章中使用全连接网络执行的任务(当时我们的测试准确率为 97.8%)。即使卷积网络很基础,它的准确率也会远远超过我们在第二章中使用的全连接模型。
以下列表显示了基本卷积网络的外观。它是一堆Conv2D和MaxPooling2D层。你马上就会看到它们的作用。我们将使用我们在上一章中介绍的函数式 API 构建模型。
列表 8.1 实例化一个小型卷积网络
from tensorflow import keras
from tensorflow.keras import layers
inputs = keras.Input(shape=(28, 28, 1))
x = layers.Conv2D(filters=32, kernel_size=3, activation="relu")(inputs)
x = layers.MaxPooling2D(pool_size=2)(x)
x = layers.Conv2D(filters=64, kernel_size=3, activation="relu")(x)
x = layers.MaxPooling2D(pool_size=2)(x)
x = layers.Conv2D(filters=128, kernel_size=3, activation="relu")(x)
x = layers.Flatten()(x)
outputs = layers.Dense(10, activation="softmax")(x)
model = keras.Model(inputs=inputs, outputs=outputs)
重要的是,卷积神经网络以形状为(image_height, image_width, image_channels)的张量作为输入,不包括批处理维度。在这种情况下,我们将配置卷积网络以处理大小为(28, 28, 1)的输入,这是 MNIST 图像的格式。
让我们展示一下我们卷积网络的架构。
列表 8.2 显示模型的摘要
>>> model.summary()
Model: "model"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
input_1 (InputLayer) [(None, 28, 28, 1)] 0
_________________________________________________________________
conv2d (Conv2D) (None, 26, 26, 32) 320
_________________________________________________________________
max_pooling2d (MaxPooling2D) (None, 13, 13, 32) 0
_________________________________________________________________
conv2d_1 (Conv2D) (None, 11, 11, 64) 18496
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 5, 5, 64) 0
_________________________________________________________________
conv2d_2 (Conv2D) (None, 3, 3, 128) 73856
_________________________________________________________________
flatten (Flatten) (None, 1152) 0
_________________________________________________________________
dense (Dense) (None, 10) 11530
=================================================================
Total params: 104,202
Trainable params: 104,202
Non-trainable params: 0
_________________________________________________________________
你可以看到每个Conv2D和MaxPooling2D层的输出是形状为(height, width, channels)的三维张量。随着模型深入,宽度和高度维度会逐渐缩小。通道的数量由传递给Conv2D层的第一个参数控制(32、64 或 128)。
在最后一个Conv2D层之后,我们得到了一个形状为(3, 3, 128)的输出——一个 3×3 的 128 通道特征图。下一步是将这个输出馈送到一个类似你已经熟悉的密集连接分类器的地方:一堆Dense层。这些分类器处理向量,这些向量是 1D 的,而当前的输出是一个秩为 3 的张量。为了弥合这个差距,我们使用Flatten层将 3D 输出展平为 1D,然后再添加Dense层。
最后,我们进行 10 路分类,所以我们的最后一层有 10 个输出和 softmax 激活。
现在,让我们在 MNIST 数字上训练卷积神经网络。我们将重用第二章 MNIST 示例中的许多代码。因为我们要进行 10 路分类,并且输出是 softmax,所以我们将使用分类交叉熵损失,因为我们的标签是整数,所以我们将使用稀疏版本,sparse_categorical_crossentropy。
列表 8.3 在 MNIST 图像上训练卷积神经网络
from tensorflow.keras.datasets import mnist
(train_images, train_labels), (test_images, test_labels) = mnist.load_data()
train_images = train_images.reshape((60000, 28, 28, 1))
train_images = train_images.astype("float32") / 255
test_images = test_images.reshape((10000, 28, 28, 1))
test_images = test_images.astype("float32") / 255
model.compile(optimizer="rmsprop",
loss="sparse_categorical_crossentropy",
metrics=["accuracy"])
model.fit(train_images, train_labels, epochs=5, batch_size=64)
让我们在测试数据上评估模型。
列表 8.4 评估卷积神经网络
>>> test_loss, test_acc = model.evaluate(test_images, test_labels)
>>> print(f"Test accuracy: {test_acc:.3f}")
Test accuracy: 0.991
相比之前第二章的密集连接模型的测试准确率为 97.8%,基本卷积神经网络的测试准确率为 99.1%:我们将错误率降低了约 60%(相对)。不错!
但是,为什么这个简单的卷积神经网络效果如此出色,相比之下要好于密集连接模型?为了回答这个问题,让我们深入了解Conv2D和MaxPooling2D层的作用。
8.1.1 卷积操作
密集连接层和卷积层之间的根本区别在于:Dense层在其输入特征空间中学习全局模式(例如,对于 MNIST 数字,涉及所有像素的模式),而卷积层学习局部模式——在图像的情况下,是在输入的小 2D 窗口中找到的模式(见图 8.1)。在前面的例子中,这些窗口都是 3×3 的。

图 8.1 图像可以被分解为局部模式,如边缘、纹理等。
这个关键特征赋予了卷积神经网络两个有趣的特性:
-
它们学习的模式是平移不变的。在学习了图片右下角的某个模式后,卷积神经网络可以在任何地方识别它:例如,在左上角。密集连接模型如果出现在新位置,就必须重新学习这个模式。这使得卷积神经网络在处理图像时具有数据效率(因为视觉世界在根本上是平移不变的):它们需要更少的训练样本来学习具有泛化能力的表示。
-
它们可以学习空间模式的层次结构。第一个卷积层将学习小的局部模式,如边缘,第二个卷积层将学习由第一层特征组成的更大模式,依此类推(见图 8.2)。这使得卷积神经网络能够高效地学习越来越复杂和抽象的视觉概念,因为视觉世界在根本上是空间层次结构的。

图 8.2 视觉世界形成了视觉模块的空间层次结构:基本线条或纹理组合成简单的对象,如眼睛或耳朵,这些对象组合成高级概念,如“猫”。
卷积在称为特征图的秩为 3 的张量上运行,具有两个空间轴(高度和宽度)以及一个深度轴(也称为通道轴)。对于 RGB 图像,深度轴的维度为 3,因为图像具有三个颜色通道:红色、绿色和蓝色。对于像 MNIST 数字这样的黑白图片,深度为 1(灰度级)。卷积操作从其输入特征图中提取补丁,并对所有这些补丁应用相同的变换,生成一个输出特征图。这个输出特征图仍然是一个秩为 3 的张量:它有一个宽度和一个高度。它的深度可以是任意的,因为输出深度是层的一个参数,而该深度轴中的不同通道不再代表 RGB 输入中的特定颜色;相反,它们代表滤波器。滤波器编码输入数据的特定方面:在高层次上,单个滤波器可以编码“输入中存在面孔”的概念,例如。
在 MNIST 示例中,第一个卷积层接收大小为(28, 28, 1)的特征图,并输出大小为(26, 26, 32)的特征图:它在输入上计算 32 个滤波器。这 32 个输出通道中的每一个包含一个 26×26 的值网格,这是滤波器在输入上的响应图,指示了该滤波器模式在输入的不同位置的响应(见图 8.3)。

图 8.3 响应图的概念:在输入的不同位置显示模式存在的 2D 地图
这就是术语特征图的含义:深度轴中的每个维度都是一个特征(或滤波器),而张量output[:, :, n]是该滤波器在输入上的 2D 空间响应图。
卷积由两个关键参数定义:
-
从输入中提取的补丁的大小—通常为 3×3 或 5×5。在示例中,它们是 3×3,这是一个常见选择。
-
输出特征图的深度—这是卷积计算的滤波器数量。示例从深度为 32 开始,最终深度为 64。
在 Keras 的Conv2D层中,这些参数是传递给层的第一个参数:Conv2D(output_depth, (window_height, window_width))。
卷积通过在 3D 输入特征图上滑动大小为 3×3 或 5×5 的窗口,在每个可能的位置停止,并提取周围特征的 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。

图 8.5 5×5 输入特征图中 3×3 补丁的有效位置
如果您想获得与输入相同空间维度的输出特征图,可以使用填充。填充包括在输入特征图的每一侧添加适当数量的行和列,以便使每个输入瓦片周围都能放置中心卷积窗口。对于 3×3 窗口,您在右侧添加一列,在左侧添加一列,在顶部添加一行,在底部添加一行。对于 5×5 窗口,您添加两行(见图 8.6)。

图 8.6 对 5×5 输入进行填充以便提取 25 个 3×3 补丁
在Conv2D层中,填充可以通过padding参数进行配置,该参数接受两个值:"valid"表示无填充(只使用有效的窗口位置),"same"表示“填充以使输出具有与输入相同的宽度和高度”。padding参数默认为"valid"。
理解卷积步幅
影响输出大小的另一个因素是步幅的概念。到目前为止,我们对卷积的描述假定卷积窗口的中心瓦片都是连续的。但是两个连续窗口之间的距离是卷积的一个参数,称为步幅,默认为 1。可以进行步幅卷积:步幅大于 1 的卷积。在图 8.7 中,您可以看到在 5×5 输入(无填充)上使用步幅 2 进行 3×3 卷积提取的补丁。

图 8.7 2×2 步幅下的 3×3 卷积补丁
使用步幅 2 意味着特征图的宽度和高度会被下采样 2 倍(除了边界效应引起的任何变化)。步幅卷积在分类模型中很少使用,但对于某些类型的模型非常有用,您将在下一章中看到。
在分类模型中,我们倾向于使用最大池化操作来对特征图进行下采样,您在我们的第一个卷积神经网络示例中看到了它的作用。让我们更深入地看一下。
8.1.2 最大池化操作
在卷积神经网络示例中,您可能已经注意到在每个MaxPooling2D层之后特征图的大小减半。例如,在第一个MaxPooling2D层之前,特征图为 26×26,但最大池化操作将其减半为 13×13。这就是最大池化的作用:大幅度地对特征图进行下采样,类似于步幅卷积。
最大池化包括从输入特征图中提取窗口并输出每个通道的最大值。它在概念上类似于卷积,不同之处在于,最大池化不是通过学习的线性变换(卷积核)来转换局部补丁,而是通过硬编码的max张量操作来转换。与卷积的一个重要区别是,最大池化通常使用 2×2 窗口和步幅 2 进行,以便将特征图下采样 2 倍。另一方面,卷积通常使用 3×3 窗口和无步幅(步幅 1)。
为什么要以这种方式对特征图进行下采样?为什么不删除最大池化层,一直保持相当大的特征图?让我们看看这个选项。我们的模型将如下所示。
列表 8.5 一个结构不正确的卷积神经网络,缺少最大池化层
inputs = keras.Input(shape=(28, 28, 1))
x = layers.Conv2D(filters=32, kernel_size=3, activation="relu")(inputs)
x = layers.Conv2D(filters=64, kernel_size=3, activation="relu")(x)
x = layers.Conv2D(filters=128, kernel_size=3, activation="relu")(x)
x = layers.Flatten()(x)
outputs = layers.Dense(10, activation="softmax")(x)
model_no_max_pool = keras.Model(inputs=inputs, outputs=outputs)
这里是模型的摘要:
>>> model_no_max_pool.summary()
Model: "model_1"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
input_2 (InputLayer) [(None, 28, 28, 1)] 0
_________________________________________________________________
conv2d_3 (Conv2D) (None, 26, 26, 32) 320
_________________________________________________________________
conv2d_4 (Conv2D) (None, 24, 24, 64) 18496
_________________________________________________________________
conv2d_5 (Conv2D) (None, 22, 22, 128) 73856
_________________________________________________________________
flatten_1 (Flatten) (None, 61952) 0
_________________________________________________________________
dense_1 (Dense) (None, 10) 619530
=================================================================
Total params: 712,202
Trainable params: 712,202
Non-trainable params: 0
_________________________________________________________________
这种设置有什么问题?有两个问题:
-
这不利于学习特征的空间层次结构。第三层中的 3×3 窗口只包含来自初始输入的 7×7 窗口的信息。卷积网络学到的高级模式仍然相对于初始输入非常小,这可能不足以学会分类数字(尝试仅通过查看 7×7 像素窗口来识别数字!)。我们需要最后一个卷积层的特征包含关于整个输入的信息。
-
最终的特征图每个样本有 22×22×128 = 61,952 个总系数。这是巨大的。当你将其展平以在顶部放置一个
Dense层大小为 10 时,该层将有超过一百万个参数。对于这样一个小模型来说,这太大了,会导致严重的过拟合。
简而言之,使用降采样的原因是减少要处理的特征图系数的数量,并通过使连续的卷积层查看越来越大的窗口(就覆盖原始输入的部分而言)来引入空间滤波器层次结构。
注意,最大池化并不是唯一实现这种降采样的方法。正如你已经知道的,你也可以在先前的卷积层中使用步幅。你也可以使用平均池化代替最大池化,其中每个局部输入块通过取该块上每个通道的平均值来进行转换,而不是最大值。但是最大池化往往比这些替代方案效果更好。原因在于特征往往编码了特定模式或概念在特征图的不同块上的空间存在(因此术语特征图),查看不同特征的最大存在比查看它们的平均存在更具信息量。最合理的子采样策略是首先通过非步幅卷积生成密集特征图,然后查看特征在小块上的最大激活,而不是查看输入的稀疏窗口(通过步幅卷积)或平均输入块,这可能导致您错过或稀释特征存在信息。
此时,你应该了解卷积网络的基础知识——特征图、卷积和最大池化,并且应该知道如何构建一个小型卷积网络来解决诸如 MNIST 数字分类之类的玩具问题。现在让我们转向更有用、实际的应用。
8.2 在小数据集上从头开始训练卷积网络
不得不使用非常少的数据训练图像分类模型是一种常见情况,在实践中,如果你在专业环境中进行计算机视觉,你可能会遇到这种情况。少量样本可以是从几百到几万张图像。作为一个实际例子,我们将专注于在一个包含 5,000 张猫和狗图片的数据集中对图像进行分类(2,500 只猫,2,500 只狗)。我们将使用 2,000 张图片进行训练,1,000 张用于验证,2,000 张用于测试。
在本节中,我们将回顾一种基本策略来解决这个问题:使用你拥有的少量数据从头开始训练一个新模型。我们将从头开始训练一个小型卷积网络,使用 2,000 个训练样本,没有任何正则化,来建立一个可以实现的基准。这将使我们达到约 70%的分类准确率。在那时,主要问题将是过拟合。然后我们将介绍数据增强,这是一种在计算机视觉中减轻过拟合的强大技术。通过使用数据增强,我们将改进模型,使准确率达到 80-85%。
在下一节中,我们将回顾将深度学习应用于小数据集的另外两种基本技术:使用预训练模型进行特征提取(这将使我们达到 97.5% 的准确率)和微调预训练模型(这将使我们达到最终准确率 98.5%)。这三种策略——从头开始训练一个小模型、使用预训练模型进行特征提取以及微调预训练模型——将构成您未来解决使用小数据集进行图像分类问题的工具箱。
8.2.1 深度学习在小数据问题上的相关性
什么样的“足够样本”可以用来训练模型是相对的——相对于您尝试训练的模型的大小和深度。仅凭几十个样本无法训练卷积网络解决复杂问题,但如果模型小且经过良好的正则化,任务简单,那么几百个样本可能就足够了。因为卷积网络学习局部、平移不变的特征,它们在感知问题上具有高效的数据利用率。在非常小的图像数据集上从头开始训练卷积网络将产生合理的结果,尽管数据相对较少,无需进行任何自定义特征工程。您将在本节中看到这一点。
此外,深度学习模型天生具有高度的可重用性:您可以拿一个在大规模数据集上训练的图像分类或语音转文本模型,仅进行轻微更改就可以在完全不同的问题上重用它。具体来说,在计算机视觉领域,现在有许多预训练模型(通常在 ImageNet 数据集上训练)可以公开下载,并且可以用来从很少的数据中启动强大的视觉模型。这是深度学习的最大优势之一:特征重用。您将在下一节中探索这一点。
让我们开始获取数据。
8.2.2 下载数据
我们将使用的 Dogs vs. Cats 数据集不随 Keras 打包。它是由 Kaggle 在 2013 年底作为计算机视觉竞赛的一部分提供的,当时卷积网络还不是主流。您可以从 www.kaggle.com/c/dogs-vs-cats/data 下载原始数据集(如果您还没有 Kaggle 帐户,需要创建一个—不用担心,这个过程很简单)。您将可以使用 Kaggle API 在 Colab 中下载数据集(请参阅“在 Google Colaboratory 中下载 Kaggle 数据集”侧边栏)。
在 Google Colaboratory 中下载 Kaggle 数据集
Kaggle 提供了一个易于使用的 API,用于以编程方式下载托管在 Kaggle 上的数据集。例如,您可以使用它将 Dogs vs. Cats 数据集下载到 Colab 笔记本中。这个 API 可以作为 kaggle 包使用,在 Colab 上预先安装。在 Colab 单元格中运行以下命令就可以轻松下载这个数据集:
!kaggle competitions download -c dogs-vs-cats
然而,API 的访问权限仅限于 Kaggle 用户,因此为了运行上述命令,您首先需要进行身份验证。kaggle 包将在位于 ~/.kaggle/kaggle.json 的 JSON 文件中查找您的登录凭据。让我们创建这个文件。
首先,您需要创建一个 Kaggle API 密钥并将其下载到本地计算机。只需在 Web 浏览器中导航到 Kaggle 网站,登录,然后转到“我的帐户”页面。在您的帐户设置中,您会找到一个 API 部分。点击“创建新的 API 令牌”按钮将生成一个 kaggle.json 密钥文件,并将其下载到您的计算机。
其次,转到您的 Colab 笔记本,并通过在笔记本单元格中运行以下代码将 API 密钥 JSON 文件上传到您的 Colab 会话:
from google.colab import files
files.upload()
当您运行此单元格时,您将看到一个“选择文件”按钮出现。点击它并选择您刚下载的 kaggle.json 文件。这将上传文件到本地的 Colab 运行时。
最后,创建一个 ~/.kaggle 文件夹(mkdir ~/.kaggle),并将密钥文件复制到其中(cp kaggle.json ~/.kaggle/)。作为安全最佳实践,您还应确保该文件仅可由当前用户,即您自己(chmod 600)读取:
!mkdir ~/.kaggle
!cp kaggle.json ~/.kaggle/
!chmod 600 ~/.kaggle/kaggle.json
您现在可以下载我们即将使用的数据:
!kaggle competitions download -c dogs-vs-cats
第一次尝试下载数据时,您可能会收到“403 Forbidden”错误。这是因为您需要在下载数据之前接受与数据集相关的条款 - 您需要转到 www.kaggle.com/c/dogs-vs-cats/rules(登录到您的 Kaggle 帐户)并点击“我理解并接受”按钮。您只需要执行一次此操作。
最后,训练数据是一个名为 train.zip 的压缩文件。确保您安静地解压缩它(unzip -qq):
!unzip -qq train.zip
我们数据集中的图片是中等分辨率的彩色 JPEG 图片。图 8.8 展示了一些示例。

图 8.8 显示了来自狗与猫数据集的样本。大小没有被修改:样本具有不同的大小、颜色、背景等。
毫不奇怪,最早的狗与猫 Kaggle 竞赛,即 2013 年,是由使用卷积网络的参赛者赢得的。最好的参赛作品达到了高达 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 张狗的图片
让我们通过几次调用 shutil 来实现。
列表 8.6 将图片复制到训练、验证和测试目录
import os, shutil, pathlib
original_dir = pathlib.Path("train") # ❶
new_base_dir = pathlib.Path("cats_vs_dogs_small") # ❷
def make_subset(subset_name, start_index, end_index): # ❸
for category in ("cat", "dog"):
dir = new_base_dir / subset_name / category
os.makedirs(dir)
fnames = [f"{category}.{i}.jpg"
for i in range(start_index, end_index)]
for fname in fnames:
shutil.copyfile(src=original_dir / fname,
dst=dir / fname)
make_subset("train", start_index=0, end_index=1000) # ❹
make_subset("validation", start_index=1000, end_index=1500) # ❺
make_subset("test", start_index=1500, end_index=2500) # ❻
❶ 包含原始数据集解压缩后的目录路径
❷ 我们将存储我们较小数据集的目录
❸ 复制猫(和狗)图片的实用函数,从索引 start_index 到索引 end_index 复制到子目录 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 构建模型
我们将重用你在第一个示例中看到的相同的通用模型结构:卷积网络将是交替的 Conv2D(带有 relu 激活)和 MaxPooling2D 层的堆叠。
但由于我们处理的是更大的图片和更复杂的问题,我们将相应地使我们的模型更大:它将有两个额外的 Conv2D 和 MaxPooling2D 阶段。这既增加了模型的容量,也进一步减小了特征图的大小,以便在达到 Flatten 层时它们不会过大。在这里,因为我们从大小为 180 像素 × 180 像素的输入开始(这是一个有点随意的选择),我们最终得到了在 Flatten 层之前大小为 7 × 7 的特征图。
注意:特征图的深度在模型中逐渐增加(从 32 增加到 256),而特征图的大小在减小(从 180 × 180 减小到 7 × 7)。这是您几乎在所有卷积网络中看到的模式。
因为我们正在处理一个二分类问题,所以我们将模型以一个单元(大小为 1 的 Dense 层)和一个 sigmoid 激活结束。这个单元将编码模型正在查看的是一个类还是另一个类的概率。
最后一个小差异:我们将使用一个 Rescaling 层开始模型,它将重新缩放图像输入(其值最初在 [0, 255] 范围内)到 [0, 1] 范围内。
列表 8.7 实例化一个用于狗与猫分类的小型卷积网络
from tensorflow import keras
from tensorflow.keras import layers
inputs = keras.Input(shape=(180, 180, 3)) # ❶
x = layers.Rescaling(1./255)(inputs) # ❷
x = layers.Conv2D(filters=32, kernel_size=3, activation="relu")(x)
x = layers.MaxPooling2D(pool_size=2)(x)
x = layers.Conv2D(filters=64, kernel_size=3, activation="relu")(x)
x = layers.MaxPooling2D(pool_size=2)(x)
x = layers.Conv2D(filters=128, kernel_size=3, activation="relu")(x)
x = layers.MaxPooling2D(pool_size=2)(x)
x = layers.Conv2D(filters=256, kernel_size=3, activation="relu")(x)
x = layers.MaxPooling2D(pool_size=2)(x)
x = layers.Conv2D(filters=256, kernel_size=3, activation="relu")(x)
x = layers.Flatten()(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs=inputs, outputs=outputs)
❶ 模型期望尺寸为 180 × 180 的 RGB 图像。
❷ 将输入重新缩放到 [0, 1] 范围,通过将它们除以 255。
让我们看看随着每一层的连续变化,特征图的维度如何改变:
>>> model.summary()
Model: "model_2"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
input_3 (InputLayer) [(None, 180, 180, 3)] 0
_________________________________________________________________
rescaling (Rescaling) (None, 180, 180, 3) 0
_________________________________________________________________
conv2d_6 (Conv2D) (None, 178, 178, 32) 896
_________________________________________________________________
max_pooling2d_2 (MaxPooling2 (None, 89, 89, 32) 0
_________________________________________________________________
conv2d_7 (Conv2D) (None, 87, 87, 64) 18496
_________________________________________________________________
max_pooling2d_3 (MaxPooling2 (None, 43, 43, 64) 0
_________________________________________________________________
conv2d_8 (Conv2D) (None, 41, 41, 128) 73856
_________________________________________________________________
max_pooling2d_4 (MaxPooling2 (None, 20, 20, 128) 0
_________________________________________________________________
conv2d_9 (Conv2D) (None, 18, 18, 256) 295168
_________________________________________________________________
max_pooling2d_5 (MaxPooling2 (None, 9, 9, 256) 0
_________________________________________________________________
conv2d_10 (Conv2D) (None, 7, 7, 256) 590080
_________________________________________________________________
flatten_2 (Flatten) (None, 12544) 0
_________________________________________________________________
dense_2 (Dense) (None, 1) 12545
=================================================================
Total params: 991,041
Trainable params: 991,041
Non-trainable params: 0
_________________________________________________________________
对于编译步骤,我们将继续使用 RMSprop 优化器。因为我们最后一层是一个单一的 sigmoid 单元,所以我们将使用二元交叉熵作为损失函数(作为提醒,请查看第六章中表 6.1,了解在各种情况下使用哪种损失函数的速查表)。
列表 8.8 配置用于训练的模型
model.compile(loss="binary_crossentropy",
optimizer="rmsprop",
metrics=["accuracy"])
8.2.4 数据预处理
正如你现在所知,数据在被馈送到模型之前应该被格式化为适当预处理的浮点张量。目前,数据以 JPEG 文件的形式存储在驱动器上,因此将其传递到模型的步骤大致如下:
-
读取图片文件。
-
将 JPEG 内容解码为 RGB 像素网格。
-
将它们转换为浮点张量。
-
调整它们到共享大小(我们将使用 180 × 180)。
-
将它们打包成批次(我们将使用 32 张图像的批次)。
这可能看起来有点令人生畏,但幸运的是,Keras 有工具可以自动处理这些步骤。特别是,Keras 提供了实用函数 image_dataset_from_directory(),它可以让您快速设置一个数据管道,可以自动将磁盘上的图像文件转换为预处理张量的批次。这就是我们将在这里使用的方法。
调用 image_dataset_from_directory(directory) 首先会列出 directory 的子目录,并假定每个子目录包含一个类别的图像。然后,它将索引每个子目录中的图像文件。最后,它将创建并返回一个配置为读取这些文件、对其进行洗牌、解码为张量、调整大小为共享大小并打包成批次的 tf.data.Dataset 对象。
列表 8.9 使用 image_dataset_from_directory 读取图像
from tensorflow.keras.utils import image_dataset_from_directory
train_dataset = image_dataset_from_directory(
new_base_dir / "train",
image_size=(180, 180),
batch_size=32)
validation_dataset = image_dataset_from_directory(
new_base_dir / "validation",
image_size=(180, 180),
batch_size=32)
test_dataset = image_dataset_from_directory(
new_base_dir / "test",
image_size=(180, 180),
batch_size=32)
理解 TensorFlow Dataset 对象
TensorFlow 提供了 tf.data API 来为机器学习模型创建高效的输入管道。其核心类是 tf.data.Dataset。
Dataset 对象是一个迭代器:你可以在 for 循环中使用它。它通常会返回输入数据和标签的批次。你可以直接将 Dataset 对象传递给 Keras 模型的 fit() 方法。
Dataset 类处理许多关键功能,否则实现起来会很麻烦,特别是异步数据预取(在模型处理上一个批次数据的同时预处理下一个批次数据,从而保持执行流畅而没有中断)。
Dataset 类还提供了一个用于修改数据集的函数式 API。这里有一个快速示例:让我们从一个大小为 16 的随机数 NumPy 数组创建一个 Dataset 实例。我们将考虑 1,000 个样本,每个样本是一个大小为 16 的向量:
import numpy as np
import tensorflow as tf
random_numbers = np.random.normal(size=(1000, 16))
dataset = tf.data.Dataset.from_tensor_slices(random_numbers) # ❶
❶ 使用 from_tensor_slices() 类方法可以从 NumPy 数组、元组或字典中创建一个 Dataset。
起初,我们的数据集只产生单个样本:
>>> for i, element in enumerate(dataset):
>>> print(element.shape)
>>> if i >= 2:
>>> break
(16,)
(16,)
(16,)
我们可以使用 .batch() 方法对数据进行分批处理:
>>> batched_dataset = dataset.batch(32)
>>> for i, element in enumerate(batched_dataset):
>>> print(element.shape)
>>> if i >= 2:
>>> break
(32, 16)
(32, 16)
(32, 16)
更广泛地说,我们可以访问一系列有用的数据集方法,例如
-
.shuffle(buffer_size)—在缓冲区内对元素进行洗牌 -
.prefetch(buffer_size)—预取 GPU 内存中的一组元素,以实现更好的设备利用率。 -
.map(callable)—对数据集的每个元素应用任意转换(函数callable,期望接受数据集产生的单个元素作为输入)。
.map()方法特别常用。这里有一个例子。我们将用它将我们的玩具数据集中的元素从形状(16,)改变为形状(4, 4):
>>> reshaped_dataset = dataset.map(lambda x: tf.reshape(x, (4, 4)))
>>> for i, element in enumerate(reshaped_dataset):
>>> print(element.shape)
>>> if i >= 2:
>>> break
(4, 4)
(4, 4)
(4, 4)
在本章中,你将看到更多map()的应用。
让我们看看其中一个Dataset对象的输出:它产生大小为(32, 180, 180, 3)的 RGB 图像批次和整数标签(形状为(32,))。每个批次中有 32 个样本(批次大小)。
列表 8.10 显示Dataset产生的数据和标签的形状
>>> for data_batch, labels_batch in train_dataset:
>>> print("data batch shape:", data_batch.shape)
>>> print("labels batch shape:", labels_batch.shape)
>>> break
data batch shape: (32, 180, 180, 3)
labels batch shape: (32,)
让我们在我们的数据集上拟合模型。我们将使用fit()中的validation_data参数来监视单独的Dataset对象上的验证指标。
请注意,我们还将使用ModelCheckpoint回调来在每个周期后保存模型。我们将配置它的路径,指定保存文件的位置,以及参数save_best_only=True和monitor="val_loss":它们告诉回调只在当前val_loss指标的值低于训练过程中任何先前时间的值时保存新文件(覆盖任何先前的文件)。这确保了你保存的文件始终包含模型对验证数据表现最佳的训练周期状态。因此,如果开始过拟合,我们不必重新训练一个更少周期的模型:我们只需重新加载保存的文件。
列表 8.11 使用Dataset拟合模型
callbacks = [
keras.callbacks.ModelCheckpoint(
filepath="convnet_from_scratch.keras",
save_best_only=True,
monitor="val_loss")
]
history = model.fit(
train_dataset,
epochs=30,
validation_data=validation_dataset,
callbacks=callbacks)
让我们绘制模型在训练和验证数据上的损失和准确率随训练过程的变化(见图 8.9)。
列表 8.12 显示训练过程中损失和准确率的曲线
import matplotlib.pyplot as plt
accuracy = history.history["accuracy"]
val_accuracy = history.history["val_accuracy"]
loss = history.history["loss"]
val_loss = history.history["val_loss"]
epochs = range(1, len(accuracy) + 1)
plt.plot(epochs, accuracy, "bo", label="Training accuracy")
plt.plot(epochs, val_accuracy, "b", label="Validation accuracy")
plt.title("Training and validation accuracy")
plt.legend()
plt.figure()
plt.plot(epochs, loss, "bo", label="Training loss")
plt.plot(epochs, val_loss, "b", label="Validation loss")
plt.title("Training and validation loss")
plt.legend()
plt.show()

图 8.9 简单卷积网络的训练和验证指标
这些图表是过拟合的特征。训练准确率随时间线性增加,直到接近 100%,而验证准确率在 75%时达到峰值。验证损失在仅十个周期后达到最小值,然后停滞,而训练损失随着训练的进行线性减少。
让我们检查测试准确率。我们将从保存的文件重新加载模型以评估它在过拟合之前的状态。
列表 8.13 在测试集上评估模型
test_model = keras.models.load_model("convnet_from_scratch.keras")
test_loss, test_acc = test_model.evaluate(test_dataset)
print(f"Test accuracy: {test_acc:.3f}")
我们得到了 69.5% 的测试准确率。(由于神经网络初始化的随机性,你可能得到与此相差不到一个百分点的数字。)
因为我们有相对较少的训练样本(2,000),过拟合将是我们关注的首要问题。你已经了解到一些可以帮助减轻过拟合的技术,如 dropout 和权重衰减(L2 正则化)。现在我们将使用一个新的技术,特定于计算机视觉,并在使用深度学习模型处理图像时几乎普遍使用:数据增强。
8.2.5 使用数据增强
过拟合是由于样本量太少,导致无法训练出能够泛化到新数据的模型。如果有无限的数据,你的模型将暴露于手头数据分布的每一个可能方面:你永远不会过拟合。数据增强采取生成更多训练数据的方法,通过一些随机转换增强样本,生成看起来可信的图像。目标是,在训练时,你的模型永远不会看到完全相同的图片。这有助于让模型接触数据的更多方面,从而更好地泛化。
在 Keras 中,可以通过在模型开头添加一些数据增强层来实现。让我们通过一个示例开始:下面的 Sequential 模型链接了几个随机图像转换。在我们的模型中,我们会在Rescaling层之前包含它。
列表 8.14 定义要添加到图像模型中的数据增强阶段
data_augmentation = keras.Sequential(
[
layers.RandomFlip("horizontal"),
layers.RandomRotation(0.1),
layers.RandomZoom(0.2),
]
)
这些只是一些可用的层(更多内容,请参阅 Keras 文档)。让我们快速浏览一下这段代码:
-
RandomFlip("horizontal")—对通过它的随机 50%图像应用水平翻转 -
RandomRotation(0.1)—将输入图像旋转一个在范围[–10%,+10%]内的随机值(这些是完整圆的分数—以度为单位,范围将是[–36 度,+36 度]) -
RandomZoom(0.2)—通过在范围[-20%,+20%]内的随机因子放大或缩小图像
让我们看一下增强后的图像(参见图 8.10)。
列表 8.15 显示一些随机增强的训练图像
plt.figure(figsize=(10, 10))
for images, _ in train_dataset.take(1): # ❶
for i in range(9):
augmented_images = data_augmentation(images) # ❷
ax = plt.subplot(3, 3, i + 1)
plt.imshow(augmented_images[0].numpy().astype("uint8")) # ❸
plt.axis("off")
❶ 我们可以使用 take(N)仅从数据集中取样 N 批次。这相当于在第 N 批次后的循环中插入一个中断。
❷ 将增强阶段应用于图像批次。
❸ 显示输出批次中的第一张图像。对于九次迭代中的每一次,这是同一图像的不同增强。

图 8.10 通过随机数据增强生成一个非常好的男孩的变化
如果我们使用这个数据增强配置训练一个新模型,那么模型将永远不会看到相同的输入两次。但是它看到的输入仍然高度相关,因为它们来自少量原始图像—我们无法产生新信息;我们只能重新混合现有信息。因此,这可能不足以完全消除过拟合。为了进一步对抗过拟合,我们还将在密集连接分类器之前向我们的模型添加一个Dropout层。
关于随机图像增强层,还有一件事你应该知道:就像Dropout一样,在推断时(当我们调用predict()或evaluate()时),它们是不活动的。在评估期间,我们的模型的行为将与不包括数据增强和 dropout 时完全相同。
列表 8.16 定义一个包含图像增强和 dropout 的新卷积神经网络
inputs = keras.Input(shape=(180, 180, 3))
x = data_augmentation(inputs)
x = layers.Rescaling(1./255)(x)
x = layers.Conv2D(filters=32, kernel_size=3, activation="relu")(x)
x = layers.MaxPooling2D(pool_size=2)(x)
x = layers.Conv2D(filters=64, kernel_size=3, activation="relu")(x)
x = layers.MaxPooling2D(pool_size=2)(x)
x = layers.Conv2D(filters=128, kernel_size=3, activation="relu")(x)
x = layers.MaxPooling2D(pool_size=2)(x)
x = layers.Conv2D(filters=256, kernel_size=3, activation="relu")(x)
x = layers.MaxPooling2D(pool_size=2)(x)
x = layers.Conv2D(filters=256, kernel_size=3, activation="relu")(x)
x = layers.Flatten()(x)
x = layers.Dropout(0.5)(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs=inputs, outputs=outputs)
model.compile(loss="binary_crossentropy",
optimizer="rmsprop",
metrics=["accuracy"])
让我们使用数据增强和 dropout 来训练模型。因为我们预计过拟合会在训练期间发生得更晚,所以我们将训练三倍的时期—一百个时期。
列表 8.17 训练正则化的卷积神经网络
callbacks = [
keras.callbacks.ModelCheckpoint(
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 = keras.models.load_model(
"convnet_from_scratch_with_augmentation.keras")
test_loss, test_acc = test_model.evaluate(test_dataset)
print(f"Test accuracy: {test_acc:.3f}")
我们获得了 83.5%的测试准确性。看起来不错!如果你在使用 Colab,请确保下载保存的文件(convnet_from_scratch_with_augmentation.keras),因为我们将在下一章中用它进行一些实验。
通过进一步调整模型的配置(例如每个卷积层的滤波器数量,或模型中的层数),我们可能能够获得更高的准确性,可能高达 90%。但是,仅通过从头开始训练我们自己的卷积神经网络,要想获得更高的准确性将会变得困难,因为我们的数据量太少。为了提高这个问题上的准确性,我们将不得不使用一个预训练模型,这是接下来两节的重点。
8.3 利用预训练模型
一种常见且高效的小图像数据集深度学习方法是使用预训练模型。预训练模型是先前在大型数据集上训练过的模型,通常是在大规模图像分类任务上。如果原始数据集足够大且足够通用,那么预训练模型学习到的空间特征层次结构可以有效地充当视觉世界的通用模型,因此,其特征对许多不同的计算机视觉问题都可能有用,即使这些新问题可能涉及与原始任务完全不同的类别。例如,您可以在 ImageNet 上训练一个模型(其中类别主要是动物和日常物品),然后将这个训练好的模型重新用于识别图像中的家具物品等远程任务。与许多较旧的、浅层学习方法相比,学习到的特征在不同问题之间的可移植性是深度学习的一个关键优势,这使得深度学习在小数据问题上非常有效。
在这种情况下,让我们考虑一个在 ImageNet 数据集上训练的大型卷积网络(140 万标记图像和 1000 个不同类别)。ImageNet 包含许多动物类别,包括不同品种的猫和狗,因此您可以期望它在狗与猫的分类问题上表现良好。
我们将使用 VGG16 架构,这是由 Karen Simonyan 和 Andrew Zisserman 于 2014 年开发的。虽然这是一个较老的模型,远非当前技术水平,并且比许多其他最新模型要重,但我选择它是因为其架构类似于您已经熟悉的内容,并且没有引入任何新概念。这可能是您第一次遇到这些可爱的模型名称之一——VGG、ResNet、Inception、Xception 等;如果您继续进行计算机视觉的深度学习,您将经常遇到它们。
使用预训练模型有两种方法:特征提取和微调。我们将涵盖这两种方法。让我们从特征提取开始。
8.3.1 使用预训练模型进行特征提取
特征提取包括使用先前训练模型学习到的表示来从新样本中提取有趣的特征。然后,这些特征通过一个新的分类器,该分类器是从头开始训练的。
正如您之前看到的,用于图像分类的卷积网络由两部分组成:它们从一系列池化和卷积层开始,然后以一个密集连接的分类器结束。第一部分被称为模型的卷积基础。在卷积网络的情况下,特征提取包括获取先前训练网络的卷积基础,将新数据通过它运行,并在输出之上训练一个新的分类器(参见图 8.12)。

图 8.12 在保持相同卷积基础的情况下交换分类器
为什么只重用卷积基?我们能否也重用密集连接的分类器?一般来说,应该避免这样做。原因是卷积基学习到的表示可能更通用,因此更具重用性:卷积网络的特征图是图片上通用概念的存在图,这些概念可能无论面临什么计算机视觉问题都有用。但分类器学习到的表示必然是特定于模型训练的类集合的——它们只包含关于整个图片中这个或那个类别存在概率的信息。此外,密集连接层中的表示不再包含有关对象在输入图像中位置的信息;这些层摆脱了空间的概念,而对象位置仍然由卷积特征图描述。对于需要考虑对象位置的问题,密集连接特征基本上是无用的。
请注意,特定卷积层提取的表示的泛化程度(因此可重用性)取决于模型中该层的深度。模型中较早的层提取局部、高度通用的特征图(如视觉边缘、颜色和纹理),而较高层提取更抽象的概念(如“猫耳”或“狗眼”)。因此,如果您的新数据集与原始模型训练的数据集差异很大,您可能最好只使用模型的前几层进行特征提取,而不是使用整个卷积基。
在这种情况下,因为 ImageNet 类别集包含多个狗和猫类别,重用原始模型的密集连接层中包含的信息可能是有益的。但我们选择不这样做,以涵盖新问题的类别集与原始模型的类别集不重叠的更一般情况。让我们通过使用在 ImageNet 上训练的 VGG16 网络的卷积基从猫和狗图片中提取有趣的特征,然后在这些特征之上训练一个狗与猫的分类器来实践这一点。
VGG16 模型,以及其他模型,已经预先打包在 Keras 中。您可以从 keras.applications 模块导入它。许多其他图像分类模型(都在 ImageNet 数据集上预训练)都作为 keras.applications 的一部分可用:
-
Xception
-
ResNet
-
MobileNet
-
EfficientNet
-
DenseNet
-
等等。
让我们实例化 VGG16 模型。
列表 8.19 实例化 VGG16 卷积基
conv_base = keras.applications.vgg16.VGG16(
weights="imagenet",
include_top=False,
input_shape=(180, 180, 3))
我们向构造函数传递三个参数:
-
weights指定了初始化模型的权重检查点。 -
include_top指的是是否包括(或不包括)网络顶部的密集连接分类器。默认情况下,这个密集连接分类器对应于 ImageNet 的 1,000 个类。因为我们打算使用我们自己的密集连接分类器(只有两个类:cat和dog),所以我们不需要包含它。 -
input_shape是我们将馈送到网络的图像张量的形状。这个参数是完全可选的:如果我们不传递它,网络将能够处理任何大小的输入。在这里,我们传递它,以便我们可以可视化(在下面的摘要中)随着每个新的卷积和池化层特征图的大小如何缩小。
这是 VGG16 卷积基架构的详细信息。它类似于您已经熟悉的简单卷积网络:
>>> conv_base.summary()
Model: "vgg16"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
input_19 (InputLayer) [(None, 180, 180, 3)] 0
_________________________________________________________________
block1_conv1 (Conv2D) (None, 180, 180, 64) 1792
_________________________________________________________________
block1_conv2 (Conv2D) (None, 180, 180, 64) 36928
_________________________________________________________________
block1_pool (MaxPooling2D) (None, 90, 90, 64) 0
_________________________________________________________________
block2_conv1 (Conv2D) (None, 90, 90, 128) 73856
_________________________________________________________________
block2_conv2 (Conv2D) (None, 90, 90, 128) 147584
_________________________________________________________________
block2_pool (MaxPooling2D) (None, 45, 45, 128) 0
_________________________________________________________________
block3_conv1 (Conv2D) (None, 45, 45, 256) 295168
_________________________________________________________________
block3_conv2 (Conv2D) (None, 45, 45, 256) 590080
_________________________________________________________________
block3_conv3 (Conv2D) (None, 45, 45, 256) 590080
_________________________________________________________________
block3_pool (MaxPooling2D) (None, 22, 22, 256) 0
_________________________________________________________________
block4_conv1 (Conv2D) (None, 22, 22, 512) 1180160
_________________________________________________________________
block4_conv2 (Conv2D) (None, 22, 22, 512) 2359808
_________________________________________________________________
block4_conv3 (Conv2D) (None, 22, 22, 512) 2359808
_________________________________________________________________
block4_pool (MaxPooling2D) (None, 11, 11, 512) 0
_________________________________________________________________
block5_conv1 (Conv2D) (None, 11, 11, 512) 2359808
_________________________________________________________________
block5_conv2 (Conv2D) (None, 11, 11, 512) 2359808
_________________________________________________________________
block5_conv3 (Conv2D) (None, 11, 11, 512) 2359808
_________________________________________________________________
block5_pool (MaxPooling2D) (None, 5, 5, 512) 0
=================================================================
Total params: 14,714,688
Trainable params: 14,714,688
Non-trainable params: 0
_________________________________________________________________
最终的特征图形状为(5, 5, 512)。这是我们将在其上放置一个密集连接分类器的特征图。
在这一点上,我们可以有两种方式继续:
-
运行卷积基在我们的数据集上,将其输出记录到磁盘上的 NumPy 数组中,然后使用这些数据作为输入到一个独立的、与本书第四章中看到的类似的密集连接分类器。这种解决方案运行快速且成本低,因为它只需要为每个输入图像运行一次卷积基,而卷积基是整个流程中最昂贵的部分。但出于同样的原因,这种技术不允许我们使用数据增强。
-
通过在
conv_base顶部添加Dense层来扩展我们的模型,并在输入数据上端对端地运行整个模型。这将允许我们使用数据增强,因为每个输入图像在模型看到时都会经过卷积基。但出于同样的原因,这种技术比第一种要昂贵得多。
我们将涵盖这两种技术。让我们逐步了解设置第一种技术所需的代码:记录conv_base在我们的数据上的输出,并使用这些输出作为新模型的输入。
无数据增强的快速特征提取
我们将通过在训练、验证和测试数据集上调用conv_base模型的predict()方法来提取特征作为 NumPy 数组。
让我们迭代我们的数据集以提取 VGG16 特征。
列表 8.20 提取 VGG16 特征和相应标签
import numpy as np
def get_features_and_labels(dataset):
all_features = []
all_labels = []
for images, labels in dataset:
preprocessed_images = keras.applications.vgg16.preprocess_input(images)
features = conv_base.predict(preprocessed_images)
all_features.append(features)
all_labels.append(labels)
return np.concatenate(all_features), np.concatenate(all_labels)
train_features, train_labels = get_features_and_labels(train_dataset)
val_features, val_labels = get_features_and_labels(validation_dataset)
test_features, test_labels = get_features_and_labels(test_dataset)
重要的是,predict()只期望图像,而不是标签,但我们当前的数据集产生的批次包含图像和它们的标签。此外,VGG16模型期望使用keras.applications.vgg16.preprocess_input函数预处理输入,该函数将像素值缩放到适当的范围。
提取的特征目前的形状为(samples, 5, 5, 512):
>>> train_features.shape
(2000, 5, 5, 512)
在这一点上,我们可以定义我们的密集连接分类器(注意使用了 dropout 进行正则化),并在我们刚刚记录的数据和标签上对其进行训练。
列表 8.21 定义和训练密集连接分类器
inputs = keras.Input(shape=(5, 5, 512))
x = layers.Flatten()(inputs) # ❶
x = layers.Dense(256)(x)
x = layers.Dropout(0.5)(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs, outputs)
model.compile(loss="binary_crossentropy",
optimizer="rmsprop",
metrics=["accuracy"])
callbacks = [
keras.callbacks.ModelCheckpoint(
filepath="feature_extraction.keras",
save_best_only=True,
monitor="val_loss")
]
history = model.fit(
train_features, train_labels,
epochs=20,
validation_data=(val_features, val_labels),
callbacks=callbacks)
❶ 注意在将特征传递给密集层之前使用了 Flatten 层。
训练非常快,因为我们只需要处理两个Dense层——即使在 CPU 上,一个时代也不到一秒。
让我们在训练过程中查看损失和准确率曲线(见图 8.13)。

图 8.13 普通特征提取的训练和验证指标
列表 8.22 绘制结果
import matplotlib.pyplot as plt
acc = history.history["accuracy"]
val_acc = history.history["val_accuracy"]
loss = history.history["loss"]
val_loss = history.history["val_loss"]
epochs = range(1, len(acc) + 1)
plt.plot(epochs, acc, "bo", label="Training accuracy")
plt.plot(epochs, val_acc, "b", label="Validation accuracy")
plt.title("Training and validation accuracy")
plt.legend()
plt.figure()
plt.plot(epochs, loss, "bo", label="Training loss")
plt.plot(epochs, val_loss, "b", label="Validation loss")
plt.title("Training and validation loss")
plt.legend()
plt.show()
我们达到了约 97%的验证准确率——比我们在前一节使用从头开始训练的小模型取得的结果要好得多。然而,这有点不公平的比较,因为 ImageNet 包含许多狗和猫实例,这意味着我们预训练的模型已经具有了完成任务所需的确切知识。当您使用预训练特征时,情况并不总是如此。
然而,图表也表明我们几乎从一开始就过拟合了——尽管使用了相当大的 dropout 率。这是因为这种技术没有使用数据增强,而数据增强对于防止小图像数据集过拟合是至关重要的。
结合数据增强的特征提取
现在让我们回顾一下我提到的第二种特征提取技术,这种技术速度较慢,成本较高,但允许我们在训练过程中使用数据增强:创建一个将conv_base与新的密集分类器连接起来的模型,并在输入上端对端地进行训练。
为了做到这一点,我们首先要冻结卷积基。冻结一层或一组层意味着在训练过程中阻止它们的权重被更新。如果我们不这样做,卷积基先前学到的表示将在训练过程中被修改。因为顶部的Dense层是随机初始化的,非常大的权重更新会通过网络传播,有效地破坏先前学到的表示。
在 Keras 中,通过将其trainable属性设置为False来冻结一个层或模型。
列表 8.23 实例化和冻结 VGG16 卷积基
conv_base = keras.applications.vgg16.VGG16(
weights="imagenet",
include_top=False)
conv_base.trainable = False
将trainable设置为False会清空层或模型的可训练权重列表。
列表 8.24 在冻结前后打印可训练权重列表
>>> conv_base.trainable = True
>>> print("This is the number of trainable weights "
"before freezing the conv base:", len(conv_base.trainable_weights))
This is the number of trainable weights before freezing the conv base: 26
>>> conv_base.trainable = False
>>> print("This is the number of trainable weights "
"after freezing the conv base:", len(conv_base.trainable_weights))
This is the number of trainable weights after freezing the conv base: 0
现在我们可以创建一个新模型,将
-
一个数据增强阶段
-
我们冻结的卷积基础
-
一个密集分类器
列表 8.25 向卷积基添加数据增强阶段和分类器
data_augmentation = keras.Sequential(
[
layers.RandomFlip("horizontal"),
layers.RandomRotation(0.1),
layers.RandomZoom(0.2),
]
)
inputs = keras.Input(shape=(180, 180, 3))
x = data_augmentation(inputs) # ❶
x = keras.applications.vgg16.preprocess_input(x) # ❷
x = conv_base(x)
x = layers.Flatten()(x)
x = layers.Dense(256)(x)
x = layers.Dropout(0.5)(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs, outputs)
model.compile(loss="binary_crossentropy",
optimizer="rmsprop",
metrics=["accuracy"])
❶ 应用数据增强。
❷ 应用输入值缩放。
使用这种设置,只有我们添加的两个Dense层的权重将被训练。总共有四个权重张量:每层两个(主要权重矩阵和偏置向量)。请注意,为了使这些更改生效,您必须首先编译模型。如果在编译后修改权重的可训练性,那么您应该重新编译模型,否则这些更改将被忽略。
让我们训练我们的模型。由于数据增强,模型开始过拟合的时间会更长,所以我们可以训练更多的 epochs——让我们做 50 个。
注意 这种技术足够昂贵,只有在您可以访问 GPU(例如 Colab 中提供的免费 GPU)时才应尝试——在 CPU 上无法实现。如果无法在 GPU 上运行代码,则应采用前一种技术。
callbacks = [
keras.callbacks.ModelCheckpoint(
filepath="feature_extraction_with_data_augmentation.keras",
save_best_only=True,
monitor="val_loss")
]
history = model.fit(
train_dataset,
epochs=50,
validation_data=validation_dataset,
callbacks=callbacks)
让我们再次绘制结果(参见图 8.14)。正如您所看到的,我们达到了超过 98%的验证准确率。这是对先前模型的一个强大改进。

图 8.14 使用数据增强进行特征提取的训练和验证指标
让我们检查测试准确率。
列表 8.26 在测试集上评估模型
test_model = keras.models.load_model(
"feature_extraction_with_data_augmentation.keras")
test_loss, test_acc = test_model.evaluate(test_dataset)
print(f"Test accuracy: {test_acc:.3f}")
我们得到了 97.5%的测试准确率。与先前的测试准确率相比,这只是一个适度的改进,考虑到验证数据的强大结果,有点令人失望。模型的准确性始终取决于您评估的样本集!某些样本集可能比其他样本集更难,对一个样本集的强大结果不一定会完全转化为所有其他样本集。
8.3.2 微调预训练模型
用于模型重用的另一种广泛使用的技术,与特征提取相辅相成,即微调(参见图 8.15)。微调包括解冻用于特征提取的冻结模型基础的顶部几层,并同时训练模型的这部分新添加部分(在本例中是全连接分类器)和这些顶部层。这被称为微调,因为它略微调整了被重用模型的更抽象的表示,以使它们对手头的问题更相关。

图 8.15 微调 VGG16 网络的最后一个卷积块
我之前说过,为了能够在顶部训练一个随机初始化的分类器,需要冻结 VGG16 的卷积基。出于同样的原因,只有在顶部的分类器已经训练好后,才能微调卷积基的顶层。如果分类器尚未训练好,那么在训练过程中通过网络传播的误差信号将会太大,并且之前由微调层学到的表示将被破坏。因此,微调网络的步骤如下:
-
在已经训练好的基础网络上添加我们的自定义网络。
-
冻结基础网络。
-
训练我们添加的部分。
-
解冻基础网络中的一些层。(请注意,不应解冻“批量归一化”层,在这里不相关,因为 VGG16 中没有这样的层。有关批量归一化及其对微调的影响,将在下一章中解释。)
-
同时训练这两个层和我们添加的部分。
在进行特征提取时,您已经完成了前三个步骤。让我们继续进行第四步:我们将解冻我们的conv_base,然后冻结其中的各个层。
作为提醒,这是我们的卷积基的样子:
>>> conv_base.summary()
Model: "vgg16"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
input_19 (InputLayer) [(None, 180, 180, 3)] 0
_________________________________________________________________
block1_conv1 (Conv2D) (None, 180, 180, 64) 1792
_________________________________________________________________
block1_conv2 (Conv2D) (None, 180, 180, 64) 36928
_________________________________________________________________
block1_pool (MaxPooling2D) (None, 90, 90, 64) 0
_________________________________________________________________
block2_conv1 (Conv2D) (None, 90, 90, 128) 73856
_________________________________________________________________
block2_conv2 (Conv2D) (None, 90, 90, 128) 147584
_________________________________________________________________
block2_pool (MaxPooling2D) (None, 45, 45, 128) 0
_________________________________________________________________
block3_conv1 (Conv2D) (None, 45, 45, 256) 295168
_________________________________________________________________
block3_conv2 (Conv2D) (None, 45, 45, 256) 590080
_________________________________________________________________
block3_conv3 (Conv2D) (None, 45, 45, 256) 590080
_________________________________________________________________
block3_pool (MaxPooling2D) (None, 22, 22, 256) 0
_________________________________________________________________
block4_conv1 (Conv2D) (None, 22, 22, 512) 1180160
_________________________________________________________________
block4_conv2 (Conv2D) (None, 22, 22, 512) 2359808
_________________________________________________________________
block4_conv3 (Conv2D) (None, 22, 22, 512) 2359808
_________________________________________________________________
block4_pool (MaxPooling2D) (None, 11, 11, 512) 0
_________________________________________________________________
block5_conv1 (Conv2D) (None, 11, 11, 512) 2359808
_________________________________________________________________
block5_conv2 (Conv2D) (None, 11, 11, 512) 2359808
_________________________________________________________________
block5_conv3 (Conv2D) (None, 11, 11, 512) 2359808
_________________________________________________________________
block5_pool (MaxPooling2D) (None, 5, 5, 512) 0
=================================================================
Total params: 14,714,688
Trainable params: 14,714,688
Non-trainable params: 0
_________________________________________________________________
我们将微调最后三个卷积层,这意味着所有层直到block4_pool应该被冻结,而层block5_conv1、block5_conv2和block5_conv3应该是可训练的。
为什么不微调更多层?为什么不微调整个卷积基?你可以。但你需要考虑以下几点:
-
较早的卷积基层编码更通用、可重复使用的特征,而较高层编码更专业化的特征。对更专业化的特征进行微调更有用,因为这些特征需要在新问题上重新利用。微调较低层会有快速减少的回报。
-
您训练的参数越多,过拟合的风险就越大。卷积基有 1500 万个参数,因此在您的小数据集上尝试训练它是有风险的。
因此,在这种情况下,只微调卷积基的前两三层是一个好策略。让我们从前一个示例中结束的地方开始设置这个。
列表 8.27 冻结直到倒数第四层的所有层
conv_base.trainable = True
for layer in conv_base.layers[:-4]:
layer.trainable = False
现在我们可以开始微调模型了。我们将使用 RMSprop 优化器,使用非常低的学习率。使用低学习率的原因是我们希望限制对我们正在微调的三层表示所做修改的幅度。更新过大可能会损害这些表示。
列表 8.28 微调模型
model.compile(loss="binary_crossentropy",
optimizer=keras.optimizers.RMSprop(learning_rate=1e-5),
metrics=["accuracy"])
callbacks = [
keras.callbacks.ModelCheckpoint(
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 = keras.models.load_model("fine_tuning.keras")
test_loss, test_acc = model.evaluate(test_dataset)
print(f"Test accuracy: {test_acc:.3f}")
在这里,我们获得了 98.5% 的测试准确率(再次强调,您自己的结果可能在一个百分点内)。在围绕这个数据集的原始 Kaggle 竞赛中,这将是顶尖结果之一。然而,这并不是一个公平的比较,因为我们使用了预训练特征,这些特征已经包含了关于猫和狗的先前知识,而竞争对手当时无法使用。
积极的一面是,通过利用现代深度学习技术,我们成功地仅使用了比比赛可用的训练数据的一小部分(约 10%)就达到了这个结果。在能够训练 20,000 个样本和 2,000 个样本之间存在巨大差异!
现在您已经掌握了一套处理图像分类问题的工具,特别是处理小数据集。
总结
-
卷积神经网络是计算机视觉任务中最好的机器学习模型类型。即使在一个非常小的数据集上,也可以从头开始训练一个,并取得不错的结果。
-
卷积神经网络通过学习一系列模块化的模式和概念来表示视觉世界。
-
在一个小数据集上,过拟合将是主要问题。数据增强是处理图像数据时对抗过拟合的强大方式。
-
通过特征提取,可以很容易地在新数据集上重用现有的卷积神经网络。这是处理小图像数据集的有价值的技术。
-
作为特征提取的补充,您可以使用微调,这会使现有模型先前学习的一些表示适应新问题。这会稍微提高性能。
¹ Karen Simonyan 和 Andrew Zisserman,“Very Deep Convolutional Networks for Large-Scale Image Recognition”,arXiv(2014),arxiv.org/abs/1409.1556。
九、高级计算机视觉深度学习
本章涵盖
-
计算机视觉的不同分支:图像分类、图像分割、目标检测
-
现代卷积神经网络架构模式:残差连接、批量归一化、深度可分离卷积
-
可视化和解释卷积神经网络学习的技术
上一章通过简单模型(一堆Conv2D和MaxPooling2D层)和一个简单的用例(二进制图像分类)为您介绍了计算机视觉的深度学习。但是,计算机视觉不仅仅是图像分类!本章将深入探讨更多不同应用和高级最佳实践。
9.1 三个基本的计算机视觉任务
到目前为止,我们专注于图像分类模型:输入一幅图像,输出一个标签。“这幅图像可能包含一只猫;另一幅可能包含一只狗。”但是图像分类只是深度学习在计算机视觉中的几种可能应用之一。一般来说,有三个您需要了解的基本计算机视觉任务:
-
图像分类——目标是为图像分配一个或多个标签。它可以是单标签分类(一幅图像只能属于一个类别,排除其他类别),也可以是多标签分类(标记图像所属的所有类别,如图 9.1 所示)。例如,当您在 Google Photos 应用上搜索关键字时,背后实际上是在查询一个非常庞大的多标签分类模型——一个包含超过 20,000 个不同类别的模型,经过数百万图像训练。
-
图像分割——目标是将图像“分割”或“划分”为不同区域,每个区域通常代表一个类别(如图 9.1 所示)。例如,当 Zoom 或 Google Meet 在视频通话中在您身后显示自定义背景时,它使用图像分割模型来精确区分您的面部和背景。
-
目标检测——目标是在图像中绘制矩形(称为边界框)围绕感兴趣的对象,并将每个矩形与一个类别关联起来。例如,自动驾驶汽车可以使用目标检测模型监视其摄像头视野中的汽车、行人和标志。

图 9.1 三个主要的计算机视觉任务:分类、分割、检测
计算机视觉的深度学习还涵盖了除这三个任务之外的一些更专业的任务,例如图像相似性评分(估计两幅图像在视觉上的相似程度)、关键点检测(在图像中定位感兴趣的属性,如面部特征)、姿势估计、3D 网格估计等。但是,开始时,图像分类、图像分割和目标检测构成了每位机器学习工程师都应熟悉的基础。大多数计算机视觉应用都可以归结为这三种任务之一。
在上一章中,您已经看到了图像分类的实际应用。接下来,让我们深入了解图像分割。这是一种非常有用且多功能的技术,您可以直接使用到目前为止学到的知识来处理它。
请注意,我们不会涵盖目标检测,因为这对于入门书籍来说太专业且太复杂。但是,您可以查看 keras.io 上的 RetinaNet 示例,该示例展示了如何在 Keras 中使用大约 450 行代码从头构建和训练目标检测模型(keras.io/examples/vision/retinanet/)。
9.2 图像分割示例
使用深度学习进行图像分割是指使用模型为图像中的每个像素分配一个类别,从而将图像分割为不同区域(如“背景”和“前景”,或“道路”、“汽车”和“人行道”)。这一类技术可以用于图像和视频编辑、自动驾驶、机器人技术、医学成像等各种有价值的应用。
有两种不同的图像分割类型,你应该了解:
-
语义分割,其中每个像素独立地分类为语义类别,如“猫”。如果图像中有两只猫,相应像素都映射到相同的通用“猫”类别(见图 9.2)。
-
实例分割,不仅试图按类别对图像像素进行分类,还要解析出各个对象实例。在一幅图像中有两只猫,实例分割会将“猫 1”和“猫 2”视为两个不同的像素类别(见图 9.2)。

图 9.2 语义分割 vs. 实例分割
在这个示例中,我们将专注于语义分割:我们将再次查看猫和狗的图像,并学习如何区分主题和背景。
我们将使用牛津-IIIT 宠物数据集(www.robots.ox.ac.uk/~vgg/data/pets/),其中包含 7,390 张各种品种的猫和狗的图片,以及每张图片的前景-背景分割掩模。分割掩模是图像分割中的标签等效物:它是与输入图像大小相同的图像,具有单个颜色通道,其中每个整数值对应于输入图像中相应像素的类别。在我们的情况下,我们的分割掩模像素可以取三个整数值中的一个:
-
1 (前景)
-
2 (背景)
-
3 (轮廓)
让我们开始下载并解压我们的数据集,使用wget和tar shell 工具:
!wget http:/ /www.robots.ox.ac.uk/~vgg/data/pets/data/images.tar.gz
!wget http:/ /www.robots.ox.ac.uk/~vgg/data/pets/data/annotations.tar.gz
!tar -xf images.tar.gz
!tar -xf annotations.tar.gz
输入图片以 JPG 文件的形式存储在 images/文件夹中(例如 images/Abyssinian_1.jpg),相应的分割掩模以 PNG 文件的形式存储在 annotations/trimaps/文件夹中(例如 annotations/trimaps/Abyssinian_1.png)。
让我们准备输入文件路径列表,以及相应掩模文件路径列表:
import os
input_dir = "images/"
target_dir = "annotations/trimaps/"
input_img_paths = sorted(
[os.path.join(input_dir, fname)
for fname in os.listdir(input_dir)
if fname.endswith(".jpg")])
target_paths = sorted(
[os.path.join(target_dir, fname)
for fname in os.listdir(target_dir)
if fname.endswith(".png") and not fname.startswith(".")])
现在,其中一个输入及其掩模是什么样子?让我们快速看一下。这是一个示例图像(见图 9.3):
import matplotlib.pyplot as plt
from tensorflow.keras.utils import load_img, img_to_array
plt.axis("off")
plt.imshow(load_img(input_img_paths[9])) # ❶
❶ 显示第 9 个输入图像。

图 9.3 一个示例图像
这是它对应的目标(见图 9.4):
def display_target(target_array):
normalized_array = (target_array.astype("uint8") - 1) * 127 # ❶
plt.axis("off")
plt.imshow(normalized_array[:, :, 0])
img = img_to_array(load_img(target_paths[9], color_mode="grayscale")) # ❷
display_target(img)
❶ 原始标签为 1、2 和 3。我们减去 1,使标签范围从 0 到 2,然后乘以 127,使标签变为 0(黑色)、127(灰色)、254(接近白色)。
❷ 我们使用 color_mode="grayscale",以便加载的图像被视为具有单个颜色通道。

图 9.4 对应的目标掩模
接下来,让我们将输入和目标加载到两个 NumPy 数组中,并将数组分割为训练集和验证集。由于数据集非常小,我们可以将所有内容加载到内存中:
import numpy as np
import random
img_size = (200, 200) # ❶
num_imgs = len(input_img_paths) # ❷
random.Random(1337).shuffle(input_img_paths) # ❸
random.Random(1337).shuffle(target_paths) # ❸
def path_to_input_image(path):
return img_to_array(load_img(path, target_size=img_size))
def path_to_target(path):
img = img_to_array(
load_img(path, target_size=img_size, color_mode="grayscale"))
img = img.astype("uint8") - 1 # ❹
return img
input_imgs = np.zeros((num_imgs,) + img_size + (3,), dtype="float32") # ❺
targets = np.zeros((num_imgs,) + img_size + (1,), dtype="uint8") # ❺
for i in range(num_imgs): # ❺
input_imgs[i] = path_to_input_image(input_img_paths[i]) # ❺
targets[i] = path_to_target(target_paths[i]) # ❺
num_val_samples = 1000 # ❻
train_input_imgs = input_imgs[:-num_val_samples] # ❼
train_targets = targets[:-num_val_samples] # ❼
val_input_imgs = input_imgs[-num_val_samples:] # ❼
val_targets = targets[-num_val_samples:] # ❼
❶ 我们将所有内容调整为 200 × 200。
❷ 数据中的样本总数
❸ 对文件路径进行洗牌(它们最初是按品种排序的)。我们在两个语句中使用相同的种子(1337),以确保输入路径和目标路径保持相同顺序。
❹ 减去 1,使我们的标签变为 0、1 和 2。
❺ 将所有图像加载到 input_imgs 的 float32 数组中,将它们的掩模加载到 targets 的 uint8 数组中(顺序相同)。输入有三个通道(RGB 值),目标有一个单通道(包含整数标签)。
❻ 保留 1,000 个样本用于验证。
❼ 将数据分割为训练集和验证集。
现在是定义我们的模型的时候了:
from tensorflow import keras
from tensorflow.keras import layers
def get_model(img_size, num_classes):
inputs = keras.Input(shape=img_size + (3,))
x = layers.Rescaling(1./255)(inputs) # ❶
x = layers.Conv2D(64, 3, strides=2, activation="relu", padding="same")(x)# ❷
x = layers.Conv2D(64, 3, activation="relu", padding="same")(x)
x = layers.Conv2D(128, 3, strides=2, activation="relu", padding="same")(x)
x = layers.Conv2D(128, 3, activation="relu", padding="same")(x)
x = layers.Conv2D(256, 3, strides=2, padding="same", activation="relu")(x)
x = layers.Conv2D(256, 3, activation="relu", padding="same")(x)
x = layers.Conv2DTranspose(256, 3, activation="relu", padding="same")(x)
x = layers.Conv2DTranspose(
256, 3, activation="relu", padding="same", strides=2)(x)
x = layers.Conv2DTranspose(128, 3, activation="relu", padding="same")(x)
x = layers.Conv2DTranspose(
128, 3, activation="relu", padding="same", strides=2)(x)
x = layers.Conv2DTranspose(64, 3, activation="relu", padding="same")(x)
x = layers.Conv2DTranspose(
64, 3, activation="relu", padding="same", strides=2)(x)
outputs = layers.Conv2D(num_classes, 3, activation="softmax", # ❸
padding="same")(x) # ❸
model = keras.Model(inputs, outputs)
return model
model = get_model(img_size=img_size, num_classes=3)
model.summary()
❶ 不要忘记将输入图像重新缩放到[0-1]范围。
❷ 请注意我们在所有地方都使用 padding="same",以避免边界填充对特征图大小的影响。
❸ 我们以每像素三路 softmax 结束模型,将每个输出像素分类为我们的三个类别之一。
这是model.summary()调用的输出:
Model: "model"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
input_1 (InputLayer) [(None, 200, 200, 3)] 0
_________________________________________________________________
rescaling (Rescaling) (None, 200, 200, 3) 0
_________________________________________________________________
conv2d (Conv2D) (None, 100, 100, 64) 1792
_________________________________________________________________
conv2d_1 (Conv2D) (None, 100, 100, 64) 36928
_________________________________________________________________
conv2d_2 (Conv2D) (None, 50, 50, 128) 73856
_________________________________________________________________
conv2d_3 (Conv2D) (None, 50, 50, 128) 147584
_________________________________________________________________
conv2d_4 (Conv2D) (None, 25, 25, 256) 295168
_________________________________________________________________
conv2d_5 (Conv2D) (None, 25, 25, 256) 590080
_________________________________________________________________
conv2d_transpose (Conv2DTran (None, 25, 25, 256) 590080
_________________________________________________________________
conv2d_transpose_1 (Conv2DTr (None, 50, 50, 256) 590080
_________________________________________________________________
conv2d_transpose_2 (Conv2DTr (None, 50, 50, 128) 295040
_________________________________________________________________
conv2d_transpose_3 (Conv2DTr (None, 100, 100, 128) 147584
_________________________________________________________________
conv2d_transpose_4 (Conv2DTr (None, 100, 100, 64) 73792
_________________________________________________________________
conv2d_transpose_5 (Conv2DTr (None, 200, 200, 64) 36928
_________________________________________________________________
conv2d_6 (Conv2D) (None, 200, 200, 3) 1731
=================================================================
Total params: 2,880,643
Trainable params: 2,880,643
Non-trainable params: 0
_________________________________________________________________
模型的前半部分与你用于图像分类的卷积网络非常相似:一堆Conv2D层,逐渐增加滤波器大小。我们通过每次减少两倍的因子三次对图像进行下采样,最终得到大小为(25, 25, 256)的激活。这前半部分的目的是将图像编码为较小的特征图,其中每个空间位置(或像素)包含有关原始图像大空间块的信息。你可以将其理解为一种压缩。
这个模型的前半部分与你之前看到的分类模型之间的一个重要区别是我们进行下采样的方式:在上一章的分类卷积网络中,我们使用MaxPooling2D层来对特征图进行下采样。在这里,我们通过向每个卷积层添加步幅来进行下采样(如果你不记得卷积步幅的详细信息,请参阅第 8.1.1 节中的“理解卷积步幅”)。我们这样做是因为在图像分割的情况下,我们非常关心图像中信息的空间位置,因为我们需要将每个像素的目标掩模作为模型的输出。当你进行 2×2 最大池化时,你完全破坏了每个池化窗口内的位置信息:你返回每个窗口一个标量值,对于窗口中的四个位置中的哪一个位置的值来自于零了解。因此,虽然最大池化层在分类任务中表现良好,但对于分割任务,它会对我们造成相当大的伤害。与此同时,步幅卷积在下采样特征图的同时保留位置信息做得更好。在本书中,你会注意到我们倾向于在任何关心特征位置的模型中使用步幅而不是最大池化,比如第十二章中的生成模型。
模型的后半部分是一堆Conv2DTranspose层。那些是什么?嗯,模型的前半部分的输出是形状为(25, 25, 256)的特征图,但我们希望最终输出与目标掩模的形状相同,即(200, 200, 3)。因此,我们需要应用一种逆转换,而不是迄今为止应用的转换的一种—一种上采样特征图而不是下采样的方法。这就是Conv2DTranspose层的目的:你可以将其视为一种学习上采样的卷积层。如果你有形状为(100, 100, 64)的输入,并将其通过层Conv2D(128, 3, strides=2, padding="same"),你将得到形状为(50, 50, 128)的输出。如果你将此输出通过层Conv2DTranspose(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 = [
keras.callbacks.ModelCheckpoint("oxford_segmentation.keras",
save_best_only=True)
]
history = model.fit(train_input_imgs, train_targets,
epochs=50,
callbacks=callbacks,
batch_size=64,
validation_data=(val_input_imgs, val_targets))
让我们显示我们的训练和验证损失(见图 9.5):
epochs = range(1, len(history.history["loss"]) + 1)
loss = history.history["loss"]
val_loss = history.history["val_loss"]
plt.figure()
plt.plot(epochs, loss, "bo", label="Training loss")
plt.plot(epochs, val_loss, "b", label="Validation loss")
plt.title("Training and validation loss")
plt.legend()

图 9.5 显示训练和验证损失曲线
你可以看到我们在中途开始过拟合,大约在第 25 个时期。让我们重新加载根据验证损失表现最佳的模型,并演示如何使用它来预测分割掩模(见图 9.6):
from tensorflow.keras.utils import array_to_img
model = keras.models.load_model("oxford_segmentation.keras")
i = 4
test_image = val_input_imgs[i]
plt.axis("off")
plt.imshow(array_to_img(test_image))
mask = model.predict(np.expand_dims(test_image, 0))[0]
def display_mask(pred): # ❶
mask = np.argmax(pred, axis=-1)
mask *= 127
plt.axis("off")
plt.imshow(mask)
display_mask(mask)
❶ 显示模型预测的实用程序

图 9.6 一个测试图像及其预测的分割掩模
我们预测掩模中有一些小的人为瑕疵,这是由前景和背景中的几何形状引起的。尽管如此,我们的模型似乎运行良好。
到目前为止,在第八章和第九章的开头,你已经学会了如何执行图像分类和图像分割的基础知识:你已经可以用你所知道的知识做很多事情了。然而,有经验的工程师开发的用于解决现实世界问题的卷积神经网络并不像我们迄今在演示中使用的那么简单。你仍然缺乏使专家能够快速准确地决定如何组合最先进模型的基本思维模型和思维过程。为了弥合这一差距,你需要了解架构模式。让我们深入探讨。
9.3 现代卷积神经网络架构模式
一个模型的“架构”是创建它所做选择的总和:使用哪些层,如何配置它们,以及如何连接它们。这些选择定义了你的模型的假设空间:梯度下降可以搜索的可能函数空间,由模型的权重参数化。像特征工程一样,一个好的假设空间编码了你对手头问题及其解决方案的先验知识。例如,使用卷积层意味着你事先知道你的输入图像中存在的相关模式是平移不变的。为了有效地从数据中学习,你需要对你正在寻找的内容做出假设。
模型架构往往是成功与失败之间的区别。如果你做出不恰当的架构选择,你的模型可能会陷入次优指标,无论训练数据量多大都无法拯救它。相反,一个好的模型架构将加速学习,并使你的模型能够有效利用可用的训练数据,减少对大型数据集的需求。一个好的模型架构是减少搜索空间的大小或使其更容易收敛到搜索空间的良好点。就像特征工程和数据整理一样,模型架构的目标是简化问题,以便梯度下降解决。记住,梯度下降是一个相当愚蠢的搜索过程,所以它需要尽可能多的帮助。
模型架构更像是一门艺术而不是一门科学。有经验的机器学习工程师能够直观地拼凑出高性能模型,而初学者常常难以创建一个能够训练的模型。关键词在于直觉:没有人能给你清晰的解释什么有效什么无效。专家依赖于模式匹配,这是他们通过广泛实践经验获得的能力。你将在本书中培养自己的直觉。然而,这也不完全是关于直觉的——实际上并没有太多的科学,但就像任何工程学科一样,有最佳实践。
在接下来的章节中,我们将回顾一些关键的卷积神经网络架构最佳实践:特别是残差连接、批量归一化和可分离卷积。一旦你掌握了如何使用它们,你将能够构建高效的图像模型。我们将把它们应用到我们的猫狗分类问题中。
让我们从鸟瞰图开始:系统架构的模块化-层次结构-重用(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 残差连接
你可能知道电话游戏,也称为英国的中国耳语和法国的阿拉伯电话,其中一个初始消息被耳语给一个玩家,然后由下一个玩家耳语给下一个玩家,依此类推。最终的消息与其原始版本几乎没有任何相似之处。这是一个有趣的比喻,用于描述在嘈杂信道上的顺序传输中发生的累积错误。
实际上,在顺序深度学习模型中的反向传播与电话游戏非常相似。你有一系列函数,就像这样:
y = f4(f3(f2(f1(x))))
游戏的名字是根据记录在f4输出上的错误来调整链中每个函数的参数。要调整f1,你需要通过f2、f3和f4传播错误信息。然而,链中的每个连续函数都会引入一定量的噪声。如果你的函数链太深,这种噪声开始压倒梯度信息,反向传播停止工作。你的模型根本无法训练。这就是梯度消失问题。
修复很简单:只需强制链中的每个函数都是非破坏性的——保留前一个输入中包含的信息的无噪声版本。实现这一点的最简单方法是使用残差连接。这很简单:只需将层或层块的输入添加回其输出(见图 9.9)。残差连接充当信息捷径,绕过具有破坏性或嘈杂块(例如包含relu激活或 dropout 层的块)的错误梯度信息,使其能够无噪声地通过深度网络传播。这项技术是在 2015 年由微软的 He 等人开发的 ResNet 系列模型中引入的。¹

图 9.9 处理块周围的残差连接
在实践中,你可以这样实现一个残差连接。
列表 9.1 伪代码中的残差连接
x = ... # ❶
residual = x # ❷
x = block(x) # ❸
x = add([x, residual]) # ❹
❶ 一些输入张量
❷ 保存原始输入的指针。这被称为残差。
❸ 这个计算块可能会具有破坏性或嘈杂,这没关系。
❹ 将原始输入添加到层的输出中:最终输出将始终保留有关原始输入的完整信息。
请注意,将输入添加回块的输出意味着输出应当有与输入相同的形状。但是,如果您的块包括具有增加滤波器数量或最大池化层的卷积层,则情况并非如此。在这种情况下,使用没有激活的 1 × 1 Conv2D层线性地将残差投影到所需的输出形状(请参见列表 9.2)。您通常会在目标块中的卷积层中使用padding="same",以避免由于填充而导致空间下采样,并且您会在残差投影中使用步幅以匹配由最大池化层引起的任何下采样(请参见列表 9.3)。
列表 9.2 残差块,其中滤波器数量发生变化
from tensorflow import keras
from tensorflow.keras import layers
inputs = keras.Input(shape=(32, 32, 3))
x = layers.Conv2D(32, 3, activation="relu")(inputs)
residual = x # ❶
x = layers.Conv2D(64, 3, activation="relu", padding="same")(x) # ❷
residual = layers.Conv2D(64, 1)(residual) # ❸
x = layers.add([x, residual]) # ❹
❶ 将残差单独放在一边。
❷ 这是我们创建残差连接的层:它将输出滤波器的数量从 32 增加到 64。请注意,我们使用 padding="same"以避免由于填充而导致下采样。
❸ 残差只有 32 个滤波器,因此我们使用 1 × 1 Conv2D 将其投影到正确的形状。
❹ 现在块输出和残差具有相同的形状,可以相加。
列表 9.3 目标块包含最大池化层的情况
inputs = keras.Input(shape=(32, 32, 3))
x = layers.Conv2D(32, 3, activation="relu")(inputs)
residual = x # ❶
x = layers.Conv2D(64, 3, activation="relu", padding="same")(x) # ❷
x = layers.MaxPooling2D(2, padding="same")(x) # ❷
residual = layers.Conv2D(64, 1, strides=2)(residual) # ❸
x = layers.add([x, residual]) # ❹
❶ 将残差单独放在一边。
❷ 这是我们创建残差连接的两层块:它包括一个 2 × 2 最大池化层。请注意,我们在卷积层和最大池化层中都使用 padding="same"以避免由于填充而导致下采样。
❸ 我们在残差投影中使用 strides=2 以匹配由最大池化层创建的下采样。
❹ 现在块输出和残差具有相同的形状,可以相加。
为了使这些想法更具体,这里是一个简单卷积网络的示例,结构化为一系列块,每个块由两个卷积层和一个可选的最大池化层组成,并在每个块周围有一个残差连接:
inputs = keras.Input(shape=(32, 32, 3))
x = layers.Rescaling(1./255)(inputs)
def residual_block(x, filters, pooling=False): # ❶
residual = x
x = layers.Conv2D(filters, 3, activation="relu", padding="same")(x)
x = layers.Conv2D(filters, 3, activation="relu", padding="same")(x)
if pooling:
x = layers.MaxPooling2D(2, padding="same")(x)
residual = layers.Conv2D(filters, 1, strides=2)(residual) # ❷
elif filters != residual.shape[-1]:
residual = layers.Conv2D(filters, 1)(residual) # ❸
x = layers.add([x, residual])
return x
x = residual_block(x, filters=32, pooling=True) # ❹
x = residual_block(x, filters=64, pooling=True) # ❺
x = residual_block(x, filters=128, pooling=False) # ❻
x = layers.GlobalAveragePooling2D()(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs=inputs, outputs=outputs)
model.summary()
❶ 应用具有残差连接的卷积块的实用函数,可以选择添加最大池化
❷ 如果我们使用最大池化,我们添加一个步幅卷积以将残差投影到预期形状。
❸ 如果我们不使用最大池化,只有在通道数量发生变化时才投影残差。
❹ 第一个块
❺ 第二个块;请注意每个块中滤波器数量的增加。
❻ 最后一个块不需要最大池化层,因为我们将在其后立即应用全局平均池化。
这是我们得到的模型摘要:
Model: "model"
__________________________________________________________________________________________________
Layer (type) Output Shape Param # Connected to
==================================================================================================
input_1 (InputLayer) [(None, 32, 32, 3)] 0
__________________________________________________________________________________________________
rescaling (Rescaling) (None, 32, 32, 3) 0 input_1[0][0]
__________________________________________________________________________________________________
conv2d (Conv2D) (None, 32, 32, 32) 896 rescaling[0][0]
__________________________________________________________________________________________________
conv2d_1 (Conv2D) (None, 32, 32, 32) 9248 conv2d[0][0]
__________________________________________________________________________________________________
max_pooling2d (MaxPooling2D) (None, 16, 16, 32) 0 conv2d_1[0][0]
__________________________________________________________________________________________________
conv2d_2 (Conv2D) (None, 16, 16, 32) 128 rescaling[0][0]
__________________________________________________________________________________________________
add (Add) (None, 16, 16, 32) 0 max_pooling2d[0][0]
conv2d_2[0][0]
__________________________________________________________________________________________________
conv2d_3 (Conv2D) (None, 16, 16, 64) 18496 add[0][0]
__________________________________________________________________________________________________
conv2d_4 (Conv2D) (None, 16, 16, 64) 36928 conv2d_3[0][0]
__________________________________________________________________________________________________
max_pooling2d_1 (MaxPooling2D) (None, 8, 8, 64) 0 conv2d_4[0][0]
__________________________________________________________________________________________________
conv2d_5 (Conv2D) (None, 8, 8, 64) 2112 add[0][0]
__________________________________________________________________________________________________
add_1 (Add) (None, 8, 8, 64) 0 max_pooling2d_1[0][0]
conv2d_5[0][0]
__________________________________________________________________________________________________
conv2d_6 (Conv2D) (None, 8, 8, 128) 73856 add_1[0][0]
__________________________________________________________________________________________________
conv2d_7 (Conv2D) (None, 8, 8, 128) 147584 conv2d_6[0][0]
__________________________________________________________________________________________________
conv2d_8 (Conv2D) (None, 8, 8, 128) 8320 add_1[0][0]
__________________________________________________________________________________________________
add_2 (Add) (None, 8, 8, 128) 0 conv2d_7[0][0]
conv2d_8[0][0]
__________________________________________________________________________________________________
global_average_pooling2d (Globa (None, 128) 0 add_2[0][0]
__________________________________________________________________________________________________
dense (Dense) (None, 1) 129 global_average_pooling2d[0][0]
==================================================================================================
Total params: 297,697
Trainable params: 297,697
Non-trainable params: 0
__________________________________________________________________________________________________
使用残差连接,您可以构建任意深度的网络,而无需担心梯度消失。
现在让我们继续下一个重要的卷积神经网络架构模式:批量归一化。
9.3.3 批量归一化
归一化是一类方法,旨在使机器学习模型看到的不同样本更相似,这有助于模型学习并很好地泛化到新数据。数据归一化的最常见形式是您在本书中已经多次看到的:通过从数据中减去均值使数据以零为中心,并通过将数据除以其标准差使数据具有单位标准差。实际上,这假设数据遵循正态(或高斯)分布,并确保该分布居中并缩放为单位方差:
normalized_data = (data - np.mean(data, axis=...)) / np.std(data, axis=...)
本书中的先前示例在将数据馈送到模型之前对数据进行了归一化。但是数据归一化可能在网络操作的每次转换之后感兴趣:即使进入Dense或Conv2D网络的数据具有 0 均值和单位方差,也没有理由预期这将是数据输出的情况。归一化中间激活是否有帮助?
批量归一化就是这样。这是一种层类型(Keras 中的BatchNormalization),由 Ioffe 和 Szegedy 于 2015 年引入;²它可以在训练过程中随着均值和方差随时间变化而自适应地归一化数据。在训练过程中,它使用当前数据批次的均值和方差来归一化样本,在推断过程中(当可能没有足够大的代表性数据批次可用时),它使用训练过程中看到的数据的批次均值和方差的指数移动平均值。
尽管原始论文指出批量归一化通过“减少内部协变量转移”来运作,但没有人确切知道为什么批量归一化有帮助。有各种假设,但没有确定性。你会发现这在深度学习中很常见——深度学习不是一门确切的科学,而是一组不断变化的、经验性的最佳工程实践,由不可靠的叙事编织在一起。有时你会觉得手中的书告诉你如何做某事,但并没有完全令人满意地解释为什么它有效:这是因为我们知道如何做但不知道为什么。每当有可靠的解释时,我会确保提到。批量归一化不是这种情况之一。
实际上,批量归一化的主要效果似乎是有助于梯度传播——就像残差连接一样——从而允许更深的网络。一些非常深的网络只有包含多个BatchNormalization层才能训练。例如,批量归一化在许多与 Keras 捆绑在一起的高级卷积网络架构中被广泛使用,如 ResNet50、EfficientNet 和 Xception。
BatchNormalization 层可以在任何层之后使用——Dense、Conv2D等:
x = ...
x = layers.Conv2D(32, 3, use_bias=False)(x) # ❶
x = layers.BatchNormalization()(x)
❶ 因为 Conv2D 层的输出被归一化,所以该层不需要自己的偏置向量。
注意Dense和Conv2D都涉及偏置向量,这是一个学习的变量,其目的是使层仿射而不是纯线性。例如,Conv2D返回,概略地说,y = conv(x, kernel) + bias,而Dense返回y = dot(x, kernel) + bias。因为归一化步骤将使层的输出以零为中心,所以在使用BatchNormalization时不再需要偏置向量,可以通过选项use_bias=False创建该层。这使得该层稍微更加精简。
重要的是,我通常建议将前一层的激活放在批量归一化层之后(尽管这仍然是一个争论的话题)。所以,不要像列表 9.4 中所示那样做,而要像列表 9.5 中所示那样做。
列表 9.4 如何不使用批量归一化
x = layers.Conv2D(32, 3, activation="relu")(x)
x = layers.BatchNormalization()(x)
列表 9.5 如何使用批量归一化:激活放在最后
x = layers.Conv2D(32, 3, use_bias=False)(x) # ❶
x = layers.BatchNormalization()(x)
x = layers.Activation("relu")(x) # ❷
❶ 注意这里缺少激活。
❷ 我们将激活放在 BatchNormalization 层之后。
这种方法的直观原因是,批量归一化将使你的输入以零为中心,而你的relu激活使用零作为保留或丢弃激活通道的中心:在激活之前进行归一化最大化了relu的利用。也就是说,这种顺序最佳实践并不是绝对关键的,所以如果你进行卷积,然后激活,然后批量归一化,你的模型仍然会训练,并且不一定会看到更糟糕的结果。
关于批量归一化和微调
批量归一化有许多怪癖。其中一个主要的怪癖与微调有关:在微调包含BatchNormalization层的模型时,我建议将这些层保持冻结(将它们的trainable属性设置为False)。否则,它们将继续更新其内部均值和方差,这可能会干扰周围Conv2D层应用的非常小的更新。
现在让我们来看看我们系列中的最后一个架构模式:深度可分离卷积。
9.3.4 深度可分离卷积
如果我告诉你,有一种层可以作为Conv2D的即插即用替代品,可以使你的模型更小(可训练权重参数更少)、更精简(浮点操作更少),并使其在任务上表现更好几个百分点,你会怎么想?这正是深度可分离卷积层(Keras 中的SeparableConv2D)所做的。这个层在每个输入通道上执行空间卷积,然后通过点卷积(1×1 卷积)混合输出通道,如图 9.10 所示。

图 9.10 深度可分离卷积:深度卷积后跟点卷积
这相当于将空间特征的学习与通道特征的学习分开。就像卷积依赖于图像中的模式不与特定位置绑定一样,深度可分离卷积依赖于中间激活中的空间位置高度相关,但不同通道高度独立。因为这个假设通常对深度神经网络学习到的图像表示是正确的,它作为一个有用的先验,帮助模型更有效地利用其训练数据。一个对其将要处理的信息结构有更强先验的模型是一个更好的模型——只要这些先验是准确的。
深度可分离卷积相比常规卷积需要更少的参数,并涉及更少的计算,同时具有可比较的表征能力。它导致更小的模型收敛更快,更不容易过拟合。当你在有限数据上从头开始训练小模型时,这些优势变得尤为重要。
当涉及到大规模模型时,深度可分离卷积是 Xception 架构的基础,这是一个性能优异的卷积神经网络,与 Keras 捆绑在一起。你可以在论文“Xception: 使用深度可分离卷积进行深度学习”中了解更多关于深度可分离卷积和 Xception 的理论基础。³
硬件、软件和算法的共同演进
考虑一个具有 3×3 窗口、64 个输入通道和 64 个输出通道的常规卷积操作。它使用了 336464 = 36,864 个可训练参数,当你将其应用于图像时,它运行的浮点操作数量与这个参数数量成比例。同时,考虑一个等效的深度可分离卷积:它只涉及 3364 + 6464 = 4,672 个可训练参数,并且浮点操作数量比例更少。这种效率改进只会随着滤波器数量或卷积窗口大小的增加而增加。
因此,你会期望深度可分离卷积会明显更快,对吧?等一下。如果你正在编写这些算法的简单 CUDA 或 C 实现,这是正确的——事实上,在 CPU 上运行时,你确实会看到有意义的加速,其中底层实现是并行化的 C。但实际上,你可能正在使用 GPU,并且你在其上执行的远非“简单”的 CUDA 实现:它是一个cuDNN 内核,这是一段被极致优化的代码,直到每个机器指令。花费大量精力优化这段代码是有意义的,因为 NVIDIA 硬件上的 cuDNN 卷积每天负责许多 exaFLOPS 的计算。但这种极端微观优化的副作用是,其他方法几乎没有机会在性能上竞争——即使是具有显著内在优势的方法,比如深度可分离卷积。
尽管多次要求 NVIDIA 进行优化,深度可分离卷积并没有像常规卷积那样受益于几乎相同级别的软件和硬件优化,因此它们仍然只比常规卷积快,即使它们使用的参数和浮点运算量减少了平方倍。不过,需要注意的是,即使深度可分离卷积并没有加速,仍然是一个好主意:它们较低的参数数量意味着你不太容易过拟合,并且它们假设通道应该是不相关的导致模型收敛更快,表示更加稳健。
在这种情况下的轻微不便可能在其他情况下变成一道不可逾越的障碍:因为整个深度学习的硬件和软件生态系统都被微调为一组非常特定的算法(特别是通过反向传播训练的卷积网络),所以偏离传统路线的成本极高。如果你尝试使用替代算法,比如无梯度优化或脉冲神经网络,那么你设计的前几个并行 C++ 或 CUDA 实现将比一个老式的卷积网络慢几个数量级,无论你的想法多么聪明和高效。说服其他研究人员采纳你的方法将是一项艰巨的任务,即使它确实更好。
可以说,现代深度学习是硬件、软件和算法之间的共同演化过程的产物:NVIDIA GPU 和 CUDA 的可用性导致了反向传播训练的卷积网络的早期成功,这又促使 NVIDIA 优化其硬件和软件以适应这些算法,进而导致研究社区围绕这些方法形成共识。在这一点上,找到一条不同的道路将需要对整个生态系统进行多年的重新设计。
9.3.5 将其整合在一起:一个迷你 Xception 风格的模型
以下是迄今为止学到的卷积网络架构原则的提醒:
-
你的模型应该组织成重复的层块,通常由多个卷积层和一个最大池化层组成。
-
你的层中的滤波器数量应随着空间特征图的大小减小而增加。
-
深而窄比宽而浅更好。
-
在层块周围引入残差连接有助于训练更深的网络。
-
在卷积层之后引入批量归一化层可能是有益的。
-
将
Conv2D层替换为SeparableConv2D层可能是有益的,因为它们更节省参数。
让我们将这些想法整合到一个单一模型中。其架构将类似于 Xception 的较小版本,并且我们将应用它到上一章的狗与猫任务中。对于数据加载和模型训练,我们将简单地重用我们在第 8.2.5 节中使用的设置,但我们将用以下卷积网络替换模型定义:
inputs = keras.Input(shape=(180, 180, 3))
x = data_augmentation(inputs) # ❶
x = layers.Rescaling(1./255)(x) # ❷
x = layers.Conv2D(filters=32, kernel_size=5, use_bias=False)(x) # ❸
for size in [32, 64, 128, 256, 512]: # ❹
residual = x
x = layers.BatchNormalization()(x)
x = layers.Activation("relu")(x)
x = layers.SeparableConv2D(size, 3, padding="same", use_bias=False)(x)
x = layers.BatchNormalization()(x)
x = layers.Activation("relu")(x)
x = layers.SeparableConv2D(size, 3, padding="same", use_bias=False)(x)
x = layers.MaxPooling2D(3, strides=2, padding="same")(x)
residual = layers.Conv2D(
size, 1, strides=2, padding="same", use_bias=False)(residual)
x = layers.add([x, residual])
x = layers.GlobalAveragePooling2D()(x) # ❺
x = layers.Dropout(0.5)(x) # ❻
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs=inputs, outputs=outputs)
❶ 我们使用与之前相同的数据增强配置。
❷ 不要忘记输入重新缩放!
❸ 需要注意的是,支持可分离卷积的假设“特征通道在很大程度上是独立的”在 RGB 图像中并不成立!红色、绿色和蓝色通道在自然图像中实际上高度相关。因此,我们模型中的第一层是一个常规的 Conv2D 层。之后我们将开始使用 SeparableConv2D。
❹ 我们使用一系列具有增加特征深度的卷积块。每个块由两个经过批量归一化的深度可分离卷积层和一个最大池化层组成,并在整个块周围有一个残差连接。
❺ 在原始模型中,我们在密集层之前使用了一个 Flatten 层。在这里,我们使用了一个 GlobalAveragePooling2D 层。
❻ 像原始模型一样,我们为了正则化添加了一个 dropout 层。
这个卷积神经网络的可训练参数数量为 721,857,略低于原始模型的 991,041 个可训练参数,但仍在同一数量级。图 9.11 显示了其训练和验证曲线。

图 9.11 具有类似 Xception 架构的训练和验证指标
您会发现我们的新模型的测试准确率为 90.8%,而上一章中的朴素模型为 83.5%。正如您所看到的,遵循架构最佳实践确实对模型性能产生了即时且显著的影响!
此时,如果您想进一步提高性能,您应该开始系统地调整架构的超参数 — 这是我们将在第十三章中详细讨论的一个主题。我们在这里没有经历这一步骤,因此前述模型的配置纯粹基于我们讨论的最佳实践,再加上在评估模型大小时的一点直觉。
请注意,这些架构最佳实践适用于计算机视觉的一般情况,不仅仅是图像分类。例如,Xception 被用作 DeepLabV3 中的标准卷积基础,这是一种流行的最先进的图像分割解决方案。
这就结束了我们对基本卷积神经网络架构最佳实践的介绍。有了这些原则,您将能够在各种计算机视觉任务中开发性能更高的模型。您现在已经在成为熟练的计算机视觉从业者的道路上走得很顺利。为了进一步加深您的专业知识,我们需要讨论最后一个重要主题:解释模型如何得出预测。
9.4 解释卷积神经网络学习的内容
在构建计算机视觉应用程序时的一个基本问题是可解释性:当您只能看到一辆卡车时,为什么您的分类器认为特定图像包含一个冰箱?这在深度学习用于补充人类专业知识的用例中尤为重要,比如在医学成像用例中。我们将通过让您熟悉一系列不同的技术来结束本章,以便可视化卷积神经网络学习的内容并理解它们所做的决定。
人们常说深度学习模型是“黑匣子”:它们学习的表示很难以提取并以人类可读的形式呈现。尽管对于某些类型的深度学习模型来说这在一定程度上是正确的,但对于卷积神经网络来说绝对不是真的。卷积神经网络学习的表示非常适合可视化,这在很大程度上是因为它们是视觉概念的表示。自 2013 年以来,已经开发出了各种技术来可视化和解释这些表示。我们不会对它们进行全面调查,但我们将介绍其中三种最易于访问和有用的方法:
-
可视化中间卷积网络输出(中间激活) — 有助于理解连续的卷积网络层如何转换其输入,并初步了解单个卷积滤波器的含义
-
可视化卷积神经网络滤波器 — 有助于准确理解卷积神经网络中每个滤波器对哪种视觉模式或概念具有接受性
-
可视化图像中类激活的热图 — 有助于理解图像的哪些部分被识别为属于给定类别,从而使您能够在图像中定位对象
对于第一种方法 — 激活可视化 — 我们将使用我们在第 8.2 节中从头开始在狗与猫分类问题上训练的小型卷积网络。对于接下来的两种方法,我们将使用一个预训练的 Xception 模型。
9.4.1 可视化中间激活
可视化中间激活包括显示模型中各种卷积和池化层返回的值,给定某个输入(层的输出通常称为激活,激活函数的输出)。这可以让我们看到输入是如何被网络学习的不同滤波器分解的。我们想要可视化具有三个维度的特征图:宽度、高度和深度(通道)。每个通道编码相对独立的特征,因此正确的可视化这些特征图的方式是独立绘制每个通道的内容作为 2D 图像。让我们从加载你在第 8.2 节保存的模型开始:
>>> from tensorflow import keras
>>> model = keras.models.load_model(
"convnet_from_scratch_with_augmentation.keras")
>>> model.summary()
Model: "model_1"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
input_2 (InputLayer) [(None, 180, 180, 3)] 0
_________________________________________________________________
sequential (Sequential) (None, 180, 180, 3) 0
_________________________________________________________________
rescaling_1 (Rescaling) (None, 180, 180, 3) 0
_________________________________________________________________
conv2d_5 (Conv2D) (None, 178, 178, 32) 896
_________________________________________________________________
max_pooling2d_4 (MaxPooling2 (None, 89, 89, 32) 0
_________________________________________________________________
conv2d_6 (Conv2D) (None, 87, 87, 64) 18496
_________________________________________________________________
max_pooling2d_5 (MaxPooling2 (None, 43, 43, 64) 0
_________________________________________________________________
conv2d_7 (Conv2D) (None, 41, 41, 128) 73856
_________________________________________________________________
max_pooling2d_6 (MaxPooling2 (None, 20, 20, 128) 0
_________________________________________________________________
conv2d_8 (Conv2D) (None, 18, 18, 256) 295168
_________________________________________________________________
max_pooling2d_7 (MaxPooling2 (None, 9, 9, 256) 0
_________________________________________________________________
conv2d_9 (Conv2D) (None, 7, 7, 256) 590080
_________________________________________________________________
flatten_1 (Flatten) (None, 12544) 0
_________________________________________________________________
dropout (Dropout) (None, 12544) 0
_________________________________________________________________
dense_1 (Dense) (None, 1) 12545
=================================================================
Total params: 991,041
Trainable params: 991,041
Non-trainable params: 0
_________________________________________________________________
接下来,我们将获得一张输入图像——一张猫的图片,不是网络训练过的图片的一部分。
列表 9.6 对单个图像进行预处理
from tensorflow import keras
import numpy as np
img_path = keras.utils.get_file( # ❶
fname="cat.jpg", # ❶
origin="https://img-datasets.s3.amazonaws.com/cat.jpg") # ❶
def get_img_array(img_path, target_size):
img = keras.utils.load_img( # ❷
img_path, target_size=target_size) # ❷
array = keras.utils.img_to_array(img) # ❸
array = np.expand_dims(array, axis=0) # ❹
return array
img_tensor = get_img_array(img_path, target_size=(180, 180))
❶ 下载一个测试图片。
❷ 打开图像文件并调整大小。
❸ 将图像转换为形状为(180, 180, 3)的 float32 NumPy 数组。
❹ 添加一个维度,将数组转换为“批量”中的单个样本。现在其形状为(1, 180, 180, 3)。
让我们展示这张图片(见图 9.12)。
列表 9.7 显示测试图片
import matplotlib.pyplot as plt
plt.axis("off")
plt.imshow(img_tensor[0].astype("uint8"))
plt.show()

图 9.12 测试猫图片
为了提取我们想要查看的特征图,我们将创建一个接受图像批量作为输入的 Keras 模型,并输出所有卷积和池化层的激活。
列表 9.8 实例化一个返回层激活的模型
from tensorflow.keras import layers
layer_outputs = []
layer_names = []
for layer in model.layers: # ❶
if isinstance(layer, (layers.Conv2D, layers.MaxPooling2D)): # ❶
layer_outputs.append(layer.output) # ❶
layer_names.append(layer.name) # ❷
activation_model = keras.Model(inputs=model.input, outputs=layer_outputs) # ❸
❶ 提取所有 Conv2D 和 MaxPooling2D 层的输出,并将它们放入列表中。
❷ 保存层的名称以备后用。
❸ 创建一个模型,给定模型输入,将返回这些输出。
当输入一张图像时,这个模型会返回原始模型中层的激活值,作为一个列表。这是你在本书中第一次实际遇到多输出模型,因为你在第七章学习过它们;到目前为止,你看到的模型都只有一个输入和一个输出。这个模型有一个输入和九个输出:每个层激活一个输出。
列表 9.9 使用模型计算层激活
activations = activation_model.predict(img_tensor) # ❶
❶ 返回一个包含九个 NumPy 数组的列表:每个数组代表一层的激活。
例如,这是原始模型第一卷积层对猫图像输入的激活:
>>> first_layer_activation = activations[0]
>>> print(first_layer_activation.shape)
(1, 178, 178, 32)
这是一个具有 32 个通道的 178×178 特征图。让我们尝试绘制原始模型第一层激活的第五个通道(见图 9.13)。
列表 9.10 可视化第五个通道
import matplotlib.pyplot as plt
plt.matshow(first_layer_activation[0, :, :, 5], cmap="viridis")

图 9.13 测试猫图片上第一层激活的第五个通道
这个通道似乎编码了一个对角边缘检测器,但请注意,你自己的通道可能会有所不同,因为卷积层学习的特定滤波器并不是确定性的。
现在,让我们绘制网络中所有激活的完整可视化(见图 9.14)。我们将提取并绘制每个层激活中的每个通道,并将结果堆叠在一个大网格中,通道并排堆叠。
列表 9.11 可视化每个中间激活的每个通道
images_per_row = 16
for layer_name, layer_activation in zip(layer_names, activations): # ❶
n_features = layer_activation.shape[-1] # ❷
size = layer_activation.shape[1] # ❷
n_cols = n_features // images_per_row
display_grid = np.zeros(((size + 1) * n_cols - 1, # ❸
images_per_row * (size + 1) - 1)) # ❸
for col in range(n_cols):
for row in range(images_per_row):
channel_index = col * images_per_row + row
channel_image = layer_activation[0, :, :, channel_index].copy()# ❹
if channel_image.sum() != 0: # ❺
channel_image -= channel_image.mean() # ❺
channel_image /= channel_image.std() # ❺
channel_image *= 64 # ❺
channel_image += 128 # ❺
channel_image = np.clip(channel_image, 0, 255).astype("uint8") # ❺
display_grid[
col * (size + 1): (col + 1) * size + col, # ❻
row * (size + 1) : (row + 1) * size + row] = channel_image # ❻
scale = 1. / size # ❼
plt.figure(figsize=(scale * display_grid.shape[1], # ❼
scale * display_grid.shape[0])) # ❼
plt.title(layer_name) # ❼
plt.grid(False) # ❼
plt.axis("off") # ❼
plt.imshow(display_grid, aspect="auto", cmap="viridis") # ❼
❶ 迭代激活(和相应层的名称)。
❷ 层激活的形状为(1, size, size, n_features)。
❸ 准备一个空网格,用于显示该激活中的所有通道。
❹ 这是一个单通道(或特征)。
❺ 将通道值归一化到[0, 255]范围内。所有零通道保持为零。
❻ 将通道矩阵放入我们准备好的空网格中。
❼ 显示该层的网格。

图 9.14 测试猫图片上每个层激活的每个通道
这里有几点需要注意:
-
第一层充当各种边缘检测器的集合。在这个阶段,激活保留了初始图片中几乎所有的信息。
-
随着深入,激活变得越来越抽象,越来越难以直观解释。它们开始编码更高级别的概念,如“猫耳朵”和“猫眼”。更深层次的表现包含的关于图像视觉内容的信息越来越少,包含的与图像类别相关的信息越来越多。
-
激活的稀疏性随着层的深度增加而增加:在第一层中,几乎所有滤波器都被输入图像激活,但在后续层中,越来越多的滤波器为空白。这意味着滤波器编码的模式在输入图像中找不到。
我们刚刚证明了深度神经网络学习的表示的一个重要普遍特征:随着层深度的增加,由层提取的特征变得越来越抽象。更高层的激活包含的关于特定输入的信息越来越少,包含的关于目标的信息越来越多(在本例中,图像的类别:猫或狗)。深度神经网络有效地充当信息蒸馏管道,原始数据输入(在本例中是 RGB 图片),并被反复转换,以便过滤掉不相关的信息(例如,图像的具体视觉外观),并放大和精炼有用的信息(例如,图像的类别)。
这类似于人类和动物感知世界的方式:观察一个场景几秒钟后,人类可以记住其中存在的抽象对象(自行车,树),但无法记住这些对象的具体外观。事实上,如果你试图凭记忆画一辆普通的自行车,很可能你无法得到一个近似正确的结果,即使你一生中见过成千上万辆自行车(例如,参见图 9.15)。现在就试试吧:这种效应绝对是真实的。你的大脑已经学会完全抽象化其视觉输入——将其转化为高级视觉概念,同时过滤掉不相关的视觉细节——这使得记住你周围事物的外观变得极其困难。

图 9.15 左:试图凭记忆画一辆自行车。右:原理图自行车的样子。
9.4.2 可视化卷积滤波器
检查卷积网络学习的滤波器的另一种简单方法是显示每个滤波器应该响应的视觉模式。这可以通过输入空间中的梯度上升来实现:将梯度下降应用于卷积网络的输入图像的值,以最大化特定滤波器的响应,从一个空白输入图像开始。生成的输入图像将是所选滤波器最大响应的图像。
让我们尝试使用在 ImageNet 上预训练的 Xception 模型的滤波器。这个过程很简单:我们将构建一个损失函数,最大化给定卷积层中给定滤波器的值,然后我们将使用随机梯度下降来调整输入图像的值,以最大化这个激活值。这将是我们利用GradientTape对象进行低级梯度下降循环的第二个示例(第一个示例在第二章中)。
首先,让我们实例化加载了在 ImageNet 数据集上预训练权重的 Xception 模型。
列表 9.12 实例化 Xception 卷积基础
model = keras.applications.xception.Xception(
weights="imagenet",
include_top=False) # ❶
❶ 分类层对于这个用例是无关紧要的,所以我们不包括模型的顶层。
我们对模型的卷积层感兴趣——Conv2D和SeparableConv2D层。我们需要知道它们的名称,以便检索它们的输出。让我们按深度顺序打印它们的名称。
列表 9.13 打印 Xception 中所有卷积层的名称
for layer in model.layers:
if isinstance(layer, (keras.layers.Conv2D, keras.layers.SeparableConv2D)):
print(layer.name)
你会注意到这里的SeparableConv2D层都被命名为类似block6_sepconv1、block7_sepconv2等。Xception 被结构化为包含几个卷积层的块。
现在,让我们创建一个第二个模型,返回特定层的输出——一个特征提取器模型。因为我们的模型是一个功能 API 模型,它是可检查的:我们可以查询其一个层的 output 并在新模型中重用它。无需复制整个 Xception 代码。
第 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 需要通过 keras.applications.xception.preprocess_input 函数对输入进行预处理)。
第 9.15 节 使用特征提取器
activation = feature_extractor(
keras.applications.xception.preprocess_input(img_tensor)
)
现在,让我们使用我们的特征提取器模型定义一个函数,该函数返回一个标量值,量化给定输入图像在给定层中“激活”给定滤波器的程度。这是我们在梯度上升过程中将最大化的“损失函数”:
import tensorflow as tf
def compute_loss(image, filter_index): # ❶
activation = feature_extractor(image)
filter_activation = activation[:, 2:-2, 2:-2, filter_index] # ❷
return tf.reduce_mean(filter_activation) # ❸
❶ 损失函数接受一个图像张量和我们正在考虑的滤波器的索引(一个整数)。
❷ 请注意,我们通过仅涉及损失中的非边界像素来避免边界伪影;我们丢弃激活边缘两侧的前两个像素。
❸ 返回滤波器激活值的平均值。
model.predict(x) 和 model(x) 的区别
在上一章中,我们使用 predict(x) 进行特征提取。在这里,我们使用 model(x)。这是为什么?
y = model.predict(x) 和 y = model(x)(其中 x 是输入数据的数组)都表示“在 x 上运行模型并检索输出 y”。然而它们并不完全相同。
predict() 在批处理中循环数据(实际上,您可以通过 predict(x, batch_size=64) 指定批处理大小),并提取输出的 NumPy 值。它在原理上等同于这样:
def predict(x):
y_batches = []
for x_batch in get_batches(x):
y_batch = model(x).numpy()
y_batches.append(y_batch)
return np.concatenate(y_batches)
这意味着 predict() 调用可以扩展到非常大的数组。与此同时,model(x) 在内存中进行,不会扩展。另一方面,predict() 不可微分:如果在 GradientTape 范围内调用它,则无法检索其梯度。
当您需要检索模型调用的梯度时,应该使用 model(x),如果只需要输出值,则应该使用 predict()。换句话说,除非您正在编写低级梯度下降循环(就像我们现在所做的那样),否则始终使用 predict()。
让我们设置梯度上升步骤函数,使用 GradientTape。请注意,我们将使用 @tf.function 装饰器来加快速度。
为了帮助梯度下降过程顺利进行的一个不明显的技巧是通过将梯度张量除以其 L2 范数(张量中值的平方的平均值的平方根)来对梯度张量进行归一化。这确保了对输入图像的更新的幅度始终在相同范围内。
第 9.16 节 通过随机梯度上升最大化损失
@tf.function
def gradient_ascent_step(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 # ❺
return image # ❻
❶ 明确监视图像张量,因为它不是 TensorFlow 变量(只有变量在梯度磁带中会自动被监视)。
❷ 计算损失标量,指示当前图像激活滤波器的程度。
❸ 计算损失相对于图像的梯度。
❹ 应用“梯度归一化技巧”。
❺ 将图像稍微移动到更强烈激活目标滤波器的方向。
❻ 返回更新后的图像,以便我们可以在循环中运行步骤函数。
现在我们有了所有的部分。让我们将它们组合成一个 Python 函数,该函数接受一个层名称和一个滤波器索引作为输入,并返回表示最大化指定滤波器激活的模式的张量。
第 9.17 节 生成滤波器可视化的函数
img_width = 200
img_height = 200
def generate_filter_pattern(filter_index):
iterations = 30 # ❶
learning_rate = 10. # ❷
image = tf.random.uniform(
minval=0.4,
maxval=0.6,
shape=(1, img_width, img_height, 3)) # ❸
for i in range(iterations): # ❹
image = gradient_ascent_step(image, filter_index, learning_rate) # ❹
return image[0].numpy()
❶ 应用的梯度上升步骤数
❷ 单步幅的振幅
❸ 使用随机值初始化图像张量(Xception 模型期望输入值在 [0, 1] 范围内,因此这里选择以 0.5 为中心的范围)。
❹ 反复更新图像张量的值,以最大化我们的损失函数。
结果图像张量是一个形状为(200, 200, 3)的浮点数组,其值可能不是在[0, 255]范围内的整数。因此,我们需要对这个张量进行后处理,将其转换为可显示的图像。我们使用以下简单的实用函数来实现。
列表 9.18 将张量转换为有效图像的实用函数
def deprocess_image(image):
image -= image.mean() # ❶
image /= image.std() # ❶
image *= 64 # ❶
image += 128 # ❶
image = np.clip(image, 0, 255).astype("uint8") # ❶
image = image[25:-25, 25:-25, :] # ❷
return image
❶ 将图像值归一化到[0, 255]范围内。
❷ 中心裁剪以避免边缘伪影。
让我们试试(见图 9.16):
>>> plt.axis("off")
>>> plt.imshow(deprocess_image(generate_filter_pattern(filter_index=2)))

图 9.16 block3_sepconv1层中第二通道响应最大的模式
看起来block3_sepconv1层中的滤波器 0 对水平线模式有响应,有点类似水或毛皮。
现在来看有趣的部分:你可以开始可视化每一层中的每一个滤波器,甚至是模型中每一层中的每一个滤波器。
列表 9.19 生成层中所有滤波器响应模式的网格
all_images = [] # ❶
for filter_index in range(64):
print(f"Processing filter {filter_index}")
image = deprocess_image(
generate_filter_pattern(filter_index)
)
all_images.append(image)
margin = 5 # ❷
n = 8
cropped_width = img_width - 25 * 2
cropped_height = img_height - 25 * 2
width = n * cropped_width + (n - 1) * margin
height = n * cropped_height + (n - 1) * margin
stitched_filters = np.zeros((width, height, 3))
for i in range(n): # ❸
for j in range(n):
image = all_images[i * n + j]
stitched_filters[
row_start = (cropped_width + margin) * i
row_end = (cropped_width + margin) * i + cropped_width
column_start = (cropped_height + margin) * j
column_end = (cropped_height + margin) * j + cropped_height
stitched_filters[
row_start: row_end,
column_start: column_end, :] = image
keras.utils.save_img( # ❹
f"filters_for_layer_{layer_name}.png", stitched_filters)
❶ 生成并保存层中前 64 个滤波器的可视化。
❷ 准备一个空白画布,供我们粘贴滤波器可视化。
❸ 用保存的滤波器填充图片。
❹ 将画布保存到磁盘。
这些滤波器可视化(见图 9.17)告诉你很多关于卷积神经网络层如何看待世界的信息:卷积神经网络中的每一层学习一组滤波器,以便它们的输入可以被表达为滤波器的组合。这类似于傅里叶变换将信号分解为一组余弦函数的方式。随着你在模型中深入,这些卷积神经网络滤波器组中的滤波器变得越来越复杂和精细:
-
模型中第一层的滤波器编码简单的方向边缘和颜色(或在某些情况下是彩色边缘)。
-
位于堆栈中稍微靠上的层,如
block4_sepconv1,编码由边缘和颜色组合而成的简单纹理。 -
更高层的滤波器开始类似于自然图像中发现的纹理:羽毛、眼睛、叶子等。

图 9.17 层block2_sepconv1、block4_sepconv1和block8_sepconv1的一些滤波器模式
9.4.3 可视化类激活热图
我们将介绍最后一种可视化技术——这对于理解哪些部分的图像导致卷积神经网络做出最终分类决策是有用的。这对于“调试”卷积神经网络的决策过程特别有帮助,尤其是在分类错误的情况下(一个称为模型可解释性的问题领域)。它还可以让你在图像中定位特定的对象。
这类技术的通用类别称为类激活映射(CAM)可视化,它包括在输入图像上生成类激活热图。类激活热图是与特定输出类相关联的一组分数的 2D 网格,针对任何输入图像中的每个位置计算,指示每个位置相对于考虑的类的重要性。例如,给定一个输入到狗与猫卷积神经网络中的图像,CAM 可视化将允许你为“猫”类生成一个热图,指示图像的不同部分有多像猫,还可以为“狗”类生成一个热图,指示图像的哪些部分更像狗。
我们将使用的具体实现是一篇名为“Grad-CAM: 基于梯度定位的深度网络的视觉解释”的文章中描述的实现。
Grad-CAM 包括获取给定输入图像的卷积层的输出特征图,并通过类别相对于通道的梯度对该特征图中的每个通道进行加权。直观地,理解这个技巧的一种方式是想象你正在通过“输入图像如何激活不同通道”的空间地图来“每个通道对于类别的重要性有多大”,从而产生一个“输入图像如何激活类别”的空间地图。
让我们使用预训练的 Xception 模型演示这种技术。
列表 9.20 加载带有预训练权重的 Xception 网络
model = keras.applications.xception.Xception(weights="imagenet") # ❶
❶ 请注意,我们在顶部包含了密集连接的分类器;在所有以前的情况下,我们都将其丢弃。
考虑图 9.18 中显示的两只非洲大象的图像,可能是母象和幼象,在热带草原上漫步。让我们将这幅图像转换为 Xception 模型可以读取的内容:该模型是在大小为 299×299 的图像上训练的,根据keras.applications.xception .preprocess_input实用程序函数中打包的一些规则进行预处理。因此,我们需要加载图像,将其调整大小为 299×299,将其转换为 NumPy 的float32张量,并应用这些预处理规则。
列表 9.21 为 Xception 预处理输入图像
img_path = keras.utils.get_file(
fname="elephant.jpg",
origin="https://img-datasets.s3.amazonaws.com/elephant.jpg") # ❶
def get_img_array(img_path, target_size):
img = keras.utils.load_img(img_path, target_size=target_size) # ❷
array = keras.utils.img_to_array(img) # ❸
array = np.expand_dims(array, axis=0) # ❹
array = keras.applications.xception.preprocess_input(array) # ❺
return array
img_array = get_img_array(img_path, target_size=(299, 299))
❶ 下载图像并将其存储在本地路径 img_path 下。
❷ 返回一个大小为 299×299 的 Python Imaging Library(PIL)图像。
❸ 返回一个形状为(299,299,3)的 float32 NumPy 数组。
❹ 添加一个维度,将数组转换为大小为(1,299,299,3)的批处理。
❺ 预处理批处理(这样做是按通道进行颜色归一化)。

图 9.18 非洲大象的测试图片
您现在可以在图像上运行预训练网络,并将其预测向量解码回人类可读格式:
>>> preds = model.predict(img_array)
>>> print(keras.applications.xception.decode_predictions(preds, top=3)[0])
[("n02504458", "African_elephant", 0.8699266),
("n01871265", "tusker", 0.076968715),
("n02504013", "Indian_elephant", 0.02353728)]
该图像的前三个预测类别如下:
-
非洲大象(概率为 87%)
-
雄象(概率为 7%)
-
印度大象(概率为 2%)
网络已将图像识别为包含非洲大象数量不确定的图像。预测向量中最大激活的条目对应于“非洲大象”类别,索引为 386:
>>> np.argmax(preds[0])
386
为了可视化图像的哪些部分最像非洲大象,让我们设置 Grad-CAM 过程。
首先,我们创建一个模型,将输入图像映射到最后一个卷积层的激活。
列表 9.22 设置返回最后一个卷积输出的模型
last_conv_layer_name = "block14_sepconv2_act"
classifier_layer_names = [
"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)
其次,我们创建一个模型,将最后一个卷积层的激活映射到最终的类别预测。
列表 9.23 重新应用最后一个卷积输出的分类器
classifier_input = keras.Input(shape=last_conv_layer.output.shape[1:])
x = classifier_input
for layer_name in classifier_layer_names:
x = model.get_layer(layer_name)(x)
classifier_model = keras.Model(classifier_input, x)
然后,我们计算输入图像的顶部预测类别相对于最后一个卷积层的激活的梯度。
列表 9.24 检索顶部预测类别的梯度
import tensorflow as tf
with tf.GradientTape() as tape:
last_conv_layer_output = last_conv_layer_model(img_array) # ❶
tape.watch(last_conv_layer_output) # ❶
preds = classifier_model(last_conv_layer_output) # ❷
top_pred_index = tf.argmax(preds[0]) # ❷
top_class_channel = preds[:, top_pred_index] # ❷
grads = tape.gradient(top_class_channel, last_conv_layer_output) # ❸
❶ 计算最后一个卷积层的激活并让磁带观察它。
❷ 检索与顶部预测类别对应的激活通道。
❸ 这是顶部预测类别相对于最后一个卷积层的输出特征图的梯度。
现在我们对梯度张量应用池化和重要性加权,以获得我们的类别激活热图。
列表 9.25 梯度池化和通道重要性加权
pooled_grads = tf.reduce_mean(grads, axis=(0, 1, 2)).numpy() # ❶
last_conv_layer_output = last_conv_layer_output.numpy()[0]
for i in range(pooled_grads.shape[-1]): # ❷
last_conv_layer_output[:, :, i] *= pooled_grads[i] # ❷
heatmap = np.mean(last_conv_layer_output, axis=-1) # ❸
❶ 这是一个向量,其中每个条目是给定通道的梯度的平均强度。它量化了每个通道相对于顶部预测类的重要性。
❷ 将最后一个卷积层的输出中的每个通道乘以“这个通道的重要性”。
❸ 结果特征图的通道均值是我们的类别激活热图。
为了可视化目的,我们还将将热图归一化到 0 和 1 之间。结果显示在图 9.19 中。
列表 9.26 热图后处理
heatmap = np.maximum(heatmap, 0)
heatmap /= np.max(heatmap)
plt.matshow(heatmap)

图 9.19 独立类别激活热图
最后,让我们生成一幅将原始图像叠加在我们刚刚获得的热图上的图像(见图 9.20)。
列表 9.27 将热图叠加在原始图片上
import matplotlib.cm as cm
img = keras.utils.load_img(img_path) # ❶
img = keras.utils.img_to_array(img) # ❶
heatmap = np.uint8(255 * heatmap) # ❷
jet = cm.get_cmap("jet") # ❸
jet_colors = jet(np.arange(256))[:, :3] # ❸
jet_heatmap = jet_colors[heatmap] # ❸
jet_heatmap = keras.utils.array_to_img(jet_heatmap) # ❹
jet_heatmap = jet_heatmap.resize((img.shape[1], img.shape[0])) # ❹
jet_heatmap = keras.utils.img_to_array(jet_heatmap) # ❹
superimposed_img = jet_heatmap * 0.4 + img # ❺
superimposed_img = keras.utils.array_to_img(superimposed_img) # ❺
save_path = "elephant_cam.jpg" # ❻
superimposed_img.save(save_path) # ❻
❶ 加载原始图像。
❷ 将热图重新缩放到 0-255 的范围。
❸ 使用“jet”颜色图重新着色热图。
❹ 创建包含重新着色的热图的图像。
❺ 将热图和原始图像叠加,热图透明度为 40%。
❻ 保存叠加的图像。

图 9.20 测试图片上的非洲象类激活热图
这种可视化技术回答了两个重要问题:
-
网络为什么认为这幅图像包含非洲象?
-
非洲象在图片中的位置在哪里?
特别值得注意的是,小象的耳朵被强烈激活:这可能是网络区分非洲象和印度象的方式。
摘要
-
您可以使用深度学习执行三项基本的计算机视觉任务:图像分类、图像分割和目标检测。
-
遵循现代卷积神经网络架构的最佳实践将帮助您充分利用您的模型。其中一些最佳实践包括使用残差连接、批量归一化和深度可分离卷积。
-
卷积神经网络学习的表示易于检查——卷积神经网络与黑匣子相反!
-
您可以生成卷积神经网络学习的滤波器的可视化,以及类活动的热图。
¹ Kaiming He 等,“深度残差学习用于图像识别”,计算机视觉与模式识别会议(2015),arxiv.org/abs/1512.03385。
² Sergey Ioffe 和 Christian Szegedy,“批量归一化:通过减少内部协变量转移加速深度网络训练”,第 32 届国际机器学习会议论文集(2015), arxiv.org/abs/1502.03167。
³ François Chollet,“Xception:使用深度可分离卷积的深度学习”,计算机视觉与模式识别会议(2017),arxiv.org/abs/1610.02357。
⁴ Liang-Chieh Chen 等,“具有空洞可分离卷积的编码器-解码器用于语义图像分割”,ECCV(2018),arxiv.org/abs/1802.02611。
⁵ Ramprasaath R. Selvaraju 等,arXiv(2017),arxiv.org/abs/1610.02391。








浙公网安备 33010602011771号