深度学习内部原理-全-
深度学习内部原理(全)
原文:Inside Deep Learning
译者:飞龙
前置内容
前言
从一个高层级的“五万英尺”视角和一个实用的“手头”视角来看,深度学习代表了显著的技术融合。具体来说,深度学习在新的颠覆性问题解决方法、科学技术、算法方法、现实世界应用、高级数学、计算工具、计算资源和计算机与数据科学领域最优秀的大脑的交汇处生存和繁荣。有些人会说神经网络并不新鲜。这是真的。有些人会说计算机视觉在卷积神经网络引起众人关注之前就已经存在了。这也是真的。还有些人说,机器学习和人工智能在过去几十年中已经经历了多次春天和冬天。这是对怀疑者的一次胜利。对吗?我说,“不对!”
现在可以使用计算算法解决难题,并自动化这些解决方案的能力,其深度和广度是我们以前从未见过的。以前无法克服的难题现在可以解决,例如自动驾驶汽车的安全运行所需的难题,或实时语言翻译,或接近通过图灵测试的对话式聊天机器人,或者相对容易生成既娱乐又令人恐惧的文本和视觉伪造。所有这一切之所以成为可能,是因为高级数学算法的成熟和可访问性、无处不在的快速计算资源、普遍可接受的编码语言以及无处不在的数据、数据、数据!
深度学习将这些许多工具、技术和才能(这就是融合)汇集在一起(这就是多样化的现实世界应用的健康补充),在无数不同的实际应用中(这就是一个健康的多样性)。深度神经网络出色地完成了在复杂数据(图像、视频、音频、文档、口语)中简洁地自动编码显著特征的工作,我们可能称之为降维或解释性特征生成,然后将这些隐含(潜在)的超模式应用于从这些复杂数据源中激发决策和行动。无论是图像理解、语言理解还是上下文理解,新的深度学习技术和组件在数据丰富的环境中实现了令人兴奋的功能:物体检测和识别、行为检测和识别、异常检测、内容(图像、视频、音频)生成以及相关性(注意力)确定。
这本优秀的书籍引导我们穿越深度学习的世界,从基础构建块到解决数据密集型实际应用中难题的高级模型。我们以深思熟虑且有益的顺序介绍了基础概念:学习、机器学习、神经网络、深度网络、深度学习、卷积网络和循环网络,以及更多。在整个旅程中,我们都有详尽的解释、代码片段、样例问题及其解决方案、评估技术、为读者设计的练习,以及来自该领域世界领先的研究者和实践者的宝贵建议。这本书所体现的永恒智慧、建议、实用性和基础力量,使其成为当前以及未来多年内宝贵的资源。
读者将在本书中找到所有层次的深度学习入门点。如果你想了解诸如反向传播、激活函数或 softmax(不仅是在文字上,还有清晰的例子),你可以在本书中学习。如果你想了解 CNN、RNN、LSTM 和 GRU 之间的区别,你可以在本书中探索它们。如果你想构建这些事物,你可以学习如何做到这一点。如果你想深入了解注意力机制、生成网络、自动编码或迁移学习,所有这些内容也都在这里。如果深度学习是一个建设项目,这本书提供了一个地方,你需要的所有东西:基础、构建块、工具、专家建议、最新进展、深入的信息解释、清晰的“展示如何”示例,以及评估你构建的最终产品的指标。
拿起这本书,你将无法放下它(至少,不会轻易或迅速放下)。它是一个丰富的、引人入胜的深度学习数学、算法和模型知识库——正如标题所说!
——Kirk Borne,博士,首席科学官,DataPrime.ai
前言
我在本科期间就开始了机器学习和深度学习的旅程,那是在我为了满足外语要求而出国留学到英格兰的一个学期(是的,你读对了)。我立刻爱上了这个基本理念以及机器学习对许多学科和生活的积极影响,但我发现自己对这个学科所需的数学准备得非常不足。
从那时起,我强迫自己成长和学习,填补那些空白:我获得了该领域的博士学位;我在波士顿咨询集团担任首席科学家,领导多个机器学习和深度学习研究团队;我还作为马里兰大学巴尔的摩郡分校的客座教授指导博士生。我有机会帮助那些与我有着相同热情和挑战的人成长和指导他们,这也催生了今天这本书的诞生。我该如何帮助我的员工、学生和同事更快、更清晰地获得他们需要的知识,而比我经历的要少痛苦?
这本书是我对深度学习的知识汇总。它涵盖了我想让理想候选人理解的全部关键主题。它包括广泛的主题领域,这样你就可以开始识别和重用模式,让你在任何领域都能有效。对我来说,这本书不仅要提供“使用这个模型解决这类问题”的机械指令,还要深入探讨为什么和如何选择不同的模型。希望你在阅读完这本书后,能够理解数学、代码和直觉之间的相互作用是如何构建和发展的,这样你就能跟上该领域的新发展。
致谢
在我的生活中,我得到了许多我没有赚取的祝福和机会,对此我非常感激,但我没有足够的空间来感谢他们所有人。感谢波士顿咨询集团帮助我达到这一点的许多人,特别是大卫·卡彭、德鲁·法里斯、约书亚·沙利文和史蒂文·埃斯卡瓦奇。我还感谢我支持过的客户,在我没有特殊背景的时候对我充满信心,并营造了一个充满生产力、成长和支持的环境。
感谢我在 UMBC 的“现代实用深度学习”课程中三年的学生,他们阅读了这本书的初稿:你们的反馈显著地塑造了它。感谢我的导师查尔斯·尼古拉斯,他给了我许多教训;感谢厄贡·西姆塞克,他给了我创建课程的自由;还要感谢许多其他教授、学生和朋友。感谢所有通过各种形式提供反馈并表达他们对这本书感激之情的人,即使是在它早期和不够完善的形式中。你们的声音比你们知道的更有分量,是我完成这本书不可或缺的动力源泉。
感谢普渡大学的许多教授,他们帮助塑造了我的计算机科学和机器学习职业生涯,以及我思考问题的方法,特别是格雷格·弗雷德里克森、詹妮弗·内维尔、安南特·格拉马、沃伊切赫·斯潘科夫斯基和查尔斯·基利安。
在曼宁,整个团队都非常友好,与我合作愉快。我特别感谢弗朗西斯·莱夫科维茨,她不断推动、挑逗、刺激我走出写作的舒适区。你真正帮助我提高了这本书的写作和可读性。
感谢所有审稿人:Abdul Basit Hafeez、Adam Słysz、Al Krinker、Andrei Paleyes、Dze Richard Fang、Ganesh Swaminathan、Gherghe Georgios、Guillaume Alleon、Guillermo Alcantara Gonzalez、Gustavo A. Patino、Gustavo Velasco-Hernandez、Izhar Haq、Jeff Neumann、Levi D. McClenny、Luke Kupka、Marc-Philippe Huget、Mohana Krishna、Nicole Königstein、Ninoslav Čerkez、Oliver Korten、Richard Vaughan、Sergio Govoni、Thomas Joseph Heiman、Tiklu Ganguly、Todd Cook、Tony Holdroyd 和 Vishwesh Ravi Shrimali。你们的建议帮助使这本书变得更好。
最后,我想感谢我的妻子,艾希莉,多年来一直耐心地听我抱怨这个过程。还要感谢我的母亲,贝丽尔,以及保罗,鼓励我承担这个艰巨的任务。
关于这本书
在撰写这本书的过程中,我试图回到我最初学习的时候:我发现了什么令人困惑、令人畏惧和误导性的内容?什么帮助我最终掌握了一个概念,让一些代码工作起来,或者意识到没有人知道为什么它有效?然后我想到了我现在的知识:哪些技术通常效果最好,我希望能让我的员工和学生具备哪些技能?在过去的三年里,我努力将这些想法提炼成一本供你阅读的书。为了做到这一点,我在书中发展了一些关键策略:
-
大量的代码和可视化结果——整天盯着数字很难,数学可能非常抽象且难以推理。尤其是当你第一次学习时,更容易通过视觉看到某些内容,并观察它在代码更改时如何变化。这本书使用图表和曲线图而不是密集的表格,我非常重视那些你可以查看数据和结果的图表和图像数据集。你正在学习的技巧适用于其他类型的数据,如音频或无线电频率,但你不需要独特的背景知识来理解它们。
-
多种解释——人工智能是一个由许多父母孕育的领域,来自认知科学、电气工程、计算机科学、心理学等领域。因此,经常有不同视角来理解相同的方法。出于这个原因,我试图为许多主题,特别是复杂主题,提供多种解释或表示。这有助于加深观点,并允许你选择对你最有意义的解释。
-
适合我们的数学——如果你只是阅读一个新方程式就能“理解”,那你就是一个巫师。我不是巫师,也不认为你是。数学对于深度学习和理解非常重要,所以我们谈论它,但我以更易于消化的方式重新表达数学。这包括将方程式重写为代码,将方程式着色为描述相同功能的句子,将方程式映射到 NumPy 表达式,以及其他帮助你真正理解底层方法的策略,而不仅仅是建立在它之上的代码。
谁应该阅读这本书?
如果你熟悉机器学习的基础知识,并且能够使用 Python 轻松完成任务,你将能够阅读并理解这本书。这意味着你熟悉标准机器学习概念,如训练与测试性能、过拟合与欠拟合,以及像逻辑回归和线性回归、k-means 聚类、最近邻搜索和主成分分析(PCA)这样的基础算法。你应该已经使用过 scikit-learn 提供的这些工具,并且了解生态系统中的其他工具,如 NumPy、pandas 和通用面向对象开发。你不需要了解 PyTorch,因为我们在本书中会涵盖它,但我鼓励你在阅读章节时查阅 PyTorch 文档(pytorch.org/docs/stable/index.html),以获取详细的技术细节。
如果你想要理解深度学习背后的神秘之处,并开始构建对它如何工作、何时使用以及如何自信地运用它的理解,你应该阅读这本书!我努力在展示代码、实用细节和“实际工作”知识之间找到一个平衡,这些知识包括数学、统计学和理论理解,这些可以帮助你脱颖而出,并跟上这个快速发展的领域。如果你能坚持到底,你会发现每一章都既有挑战性又富有成效。本书的内容应该为任何初级到中级机器学习工程师、数据科学家或研究人员提供一个坚实的基础。甚至我合作的更资深的研究人员也发现这些内容很有用,我使用的许多代码已在生产环境中应用。我的几位博士生也发现这些代码在“可用性”和“可定制性”之间找到了平衡,这节省了他们的时间,并帮助他们更快地完成研究。
本书是如何组织的:一个路线图
本书分为两大部分和 14 个章节。第一部分(第一章至第六章)专注于深度学习的基础:编码框架、基本架构类型、不同组件的术语,以及构建和训练神经网络的技术。这些都是你可以用来构建更大、更复杂系统的基本工具。然后,在第二部分(第七章至第十四章)中,我们开始添加新的设计选择或策略。每一章都有助于扩展深度学习在新的任务或问题上的应用,拓宽我们对深度学习可以做什么的认识,并为我们提供新的杠杆来调整不同的设计权衡(例如,速度与精度)。
虽然跳到与你日常工作特别相关的章节可能很有吸引力,但这本书并不是可以跳过章节的书!本书是精心构建的,需要按线性顺序阅读。每一章都是基于前一章引入的概念或技术,这样你就可以逐渐构建起广泛技能的深入理解。
第一部分,“基础方法”,包含六个章节:
-
第一章讨论了 PyTorch 及其基本工作原理,展示了如何使用该框架。
-
第二章涵盖了最基本的神经网络类型——全连接网络——以及如何在 PyTorch 中编写代码来训练任意网络。这包括一个演示,说明全连接网络与线性模型的关系。
-
第三章介绍了卷积及其如何使卷积神经网络成为基于图像的深度学习的主导技术。
-
第四章介绍了循环神经网络,它们如何编码序列信息,以及它们如何用于文本分类问题。
-
第五章介绍了可以应用于任何神经网络的较新训练技术,以在更短的时间内获得更高的准确率,并解释了它们如何实现这一目标。
-
第六章发展了今天普遍使用的现代设计模式,将你对设计神经网络的了解带入现代时代。
第二部分,“构建高级网络”,包含八个章节:
-
第七章介绍了自动编码作为在没有标记数据的情况下训练神经网络的技术,允许无监督学习。
-
第八章介绍了图像分割和目标检测作为两种可以在图像中找到多个项目的技术。
-
第九章开发了生成对抗网络,这是一种无监督方法,可以生成合成数据,是许多现代图像修改和深度伪造技术的基石。
-
第十章教你如何实现注意力机制,这是网络先验中最重要的一项近期进展。注意力机制允许深度网络选择性地忽略输入中的无关或不重要的部分。
-
第十一章使用注意力机制构建了开创性的 Seq2Seq 模型,并展示了如何使用在生产系统中部署的相同方法构建一个英语到法语翻译器。
-
第十二章介绍了一种新的策略,通过重新思考网络的设计来避免循环网络(由于它们的缺点)。这包括 Transformer 架构,它是当前自然语言处理最佳工具的基础。
-
第十三章涵盖了迁移学习,这是一种使用在一个数据集上训练的网络来提高另一个数据集性能的方法。这允许使用更少的标记数据,使其成为现实工作中最有用的技巧之一。
-
第十四章通过回顾现代神经网络的一些最基本组件,并教你三种最近发表的、大多数从业者还不知道的技术来构建更好的模型,结束了本书。
关于数学符号
这里列出了书中常用的符号和符号风格,以及它们的代码等效,作为快速参考和入门指南。
| 符号 | 含义 | 代码 |
|---|---|---|
| x 或 x ∈ ℝ | 小写字母用于表示单个浮点值,∈ ℝ 明确表示该值是“在实数中”。 | x = 3.14 或 x = np.array(3.14) |
| x 或 x ∈ ℝ^d | 粗体小写表示 d 个值的向量。 | x = np.zeros(d) |
| X 或 x ∈ ℝ^(r, c) | 大写字母表示矩阵或更高阶的张量;用“,”分隔的数字/字母的数量明确表示轴的数量。 | X = np.zeros((r,c)) |
| X^⊤ 或 x^⊤ | 表示转置矩阵或向量。 | np.transpose(x) 或 np.transpose(X) |
![]() |
表达式或函数 f() 的求和。 | result = 0``for i in range(start, end+1):`` result += f(i) |
![]() |
表达式或函数 f() 的乘积。 | result = 1``for i in range(start, end+1):`` result *= f(i) |
| ∥ x∥[2] | 矩阵或张量的 2-范数,表示其值的“大小”。> | result = 0``for val in x:`` result += val**2``result = np.sqrt(result) |
关于练习
每一章都以一系列练习结束,以帮助您练习所学内容。为了鼓励您独立解决问题,没有提供答案。相反,作者和出版社邀请您在 Manning 在线平台 Inside Deep Learning Exercises (liveproject.manning.com/project/945)上与您的读者分享和讨论您的解决方案。一旦您提交了自己的解决方案,您将能够看到其他读者提交的解决方案,并看到哪些是作者认为最好的。
关于 Google Colab
虽然深度学习确实需要 GPU 才能运行,但我已经设计好每一章都可以在 Google Colab 上运行:这是一个免费或根据您的使用情况收取很少费用的平台,您可以通过它访问 GPU 计算能力。一个好的 GPU 至少需要 600 美元,这样您就可以在投资之前先进行自我教育。附录将帮助您设置 Colab,如果您之前没有使用过它,但基本上它就是一个云端的 Jupyter 笔记本。
关于代码
代码使用 固定宽度字体 如此显示,并且在文本中通常作为正常英语引用。例如,如果我们正在谈论“书”,并且有一个名为 book 的变量,我们将使用固定宽度字体同时引用概念和代码,然后当我们只谈论概念时,我们将切换回正常字体。因此,我可能会谈论 book 有一个获取 pages() 方法的属性,您可以阅读它,将书籍的概念与实现该概念的代码联系起来。
非常短的代码片段以类似“流畅”的风格出现,就像文本的另一段一样,而较长的代码则出现在图中。非常长的代码部分被拆分,可以在 GitHub 上找到,网址为 github.com/EdwardRaff/Inside-Deep-Learning。有时代码本身并不长,但其中一行代码很长,因此非常长的代码行会通过“连续性标记”()进行换行,以告知您这是一行代码被拆分成了多行。
liveBook 讨论论坛
购买 Inside Deep Learning 包括免费访问 liveBook,曼宁的在线阅读平台。使用 liveBook 的独家讨论功能,您可以在全局或特定章节或段落中添加评论。要访问论坛,请访问 livebook.manning.com/#!/book/inside-deep-learning/discussion。您还可以在 livebook.manning.com/#!/discussion 了解更多关于曼宁论坛和行为准则的信息。
曼宁对读者的承诺是提供一个平台,在这里读者之间以及读者与作者之间可以进行有意义的对话。这并不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未支付报酬)。我们建议您尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要书籍仍在印刷中,论坛和先前讨论的存档将可通过出版社的网站访问。
其他在线资源
我将在我的网站上保留额外的资源和书籍更新,并提供直接链接到书籍页面:insidedeeplearningpytorch.com。就像生活中的所有事情一样,获取不同的观点可能是有益的,或者您可能会发现当概念以多种方式解释时更容易学习。为此,您可能会发现 Michael Nielsen 的书籍 (neuralnetworksanddeeplearning.com/) 对深入了解一些简单神经网络的细节很有价值。我本人也从 Christopher Olah 的博客 (colah.github.io/) 中学到了很多,他在那里对深度学习的许多高级和新主题进行了非常详尽和有洞察力的解释。您也可以通过 Twitter @EdwardRaffML 关注我,了解更多关于深度学习的信息。
关于作者

爱德华·拉夫,博士,是博思艾伦汉密尔顿的首席科学家,在那里他与战略创新小组的机器学习研究团队共同领导。他的工作涉及监督内部研究、招募和培养技术人才、与大学合作伙伴合作以及专注于高端机器学习的企业发展。拉夫博士还协助几家客户进行高级研究。
他对写作、开发和教授机器学习的热情源于分享他对机器学习所有领域的热情。他是 Java 统计分析工具(JSAT)的作者,这是一个用于 Java 快速机器学习的库。他目前指导五名博士生,并发表了超过 60 篇论文,获得三项最佳论文奖。
关于封面
《深度学习内部》的封面图是“Indien du Mexique”,或称“墨西哥印第安人”,取自雅克·格拉塞·德·圣索沃尔于 1797 年出版的作品集。每一幅插图都是手工精心绘制和着色的。
在那个时代,人们通过他们的服饰就能轻易识别出他们居住的地方以及他们的职业或社会地位。曼宁通过基于几个世纪前丰富多样的地域文化的书封面来庆祝计算机行业的创新精神和主动性,这些文化通过如这一系列图片般的作品被重新带回生活。
第一部分. 基础方法
深度学习似乎非常新颖。自动驾驶汽车、计算机化的个人助理和聊天机器人正在渗透我们的社会——深度学习的应用和现实世界的影响似乎在不到十年的时间内从零发展到无处不在。因此,你想要直接深入学习如何制作你自己的自动驾驶机器人吸尘器,它能告诉你你的猫把橡皮筋和消失的袜子藏在哪里。
但如果你真的想深入理解深度学习,你不能只是盲目地汲取知识。其基础已有六十多年的历史——一棵终于结出果实的树。你需要学习那些作为现代深度学习基础的基本技术,然后我们才能在此基础上构建,以扩展你的知识。
这一部分书籍通过现代框架逐步介绍了关键概念和技术。第一章介绍了具体的框架,PyTorch,以及自动梯度优化等核心概念。第二章展示了如何构建你的第一个简单的深度学习网络。第三章和第四章探讨了如何通过结构扩展网络,使其在图像和文本数据上工作得更好。回到起点,第五章和第六章重新审视了优化以及我们如何设计网络,将我们最初学习的旧风格提升到现代时代。这消除了网络为何被设计成这样的神秘性以及改进是如何工作的,并为你在书籍第二部分的更高级设计做好了准备。
1 学习的机制
本章节涵盖
-
使用 Google Colab 进行编码
-
介绍 PyTorch,一种基于张量的深度学习 API
-
利用 PyTorch 的 GPU 加速运行更快的代码
-
理解自动微分作为学习的基础
-
使用
Dataset接口准备数据
深度学习,也称为 神经网络 或 人工神经网络,在机器学习的质量、准确性和可用性方面取得了显著的进步。10 年前被认为不可能的技术现在已被广泛部署或被认为在技术上可行。像 Cortana、Google、Alexa 和 Siri 这样的数字助手无处不在,并能对自然语言进行反应。自动驾驶汽车在经过改进以最终部署的过程中已经在路上行驶了数百万英里。我们终于可以统计和计算互联网中有多少是猫的照片。深度学习对于所有这些用例以及更多用例的成功都起到了关键作用。
本书向您介绍了当今深度学习中最常见和最有用的技术。一个重要的焦点是如何使用和编码这些网络,以及它们如何在深层工作以及为什么这样做。有了更深入的理解,您将更好地装备自己,以选择最适合您问题的最佳方法,并跟上这个快速发展的领域的进步。为了最大限度地利用本书,您应该熟悉 Python 编程,并对微积分、统计学和线性代数课程有一些基本的记忆。您还应该有机器学习(ML)的先前经验,尽管您不是专家也行;ML 主题会快速介绍,但我们的目标是进入深度学习的细节。
让我们更清晰地了解深度学习是什么,以及本书是如何介绍它的。深度学习是机器学习的一个子领域,而机器学习又是人工智能(AI)的一个子领域。(有些人可能会对我的这种分类方式感到不满。这是一种过度简化的方式。)广义上,我们可以将 AI 描述为让计算机做出看起来聪明的决策。我说“看起来”,因为很难定义什么是“聪明”或“智能”;AI 应该做出我们认为合理的决策,以及一个聪明人可能会做出的决策。你的 GPS 告诉你如何回家,使用了某些老式的 AI 技术(这些经典的方法有时被称为“老式的传统 AI”,或 GOFAI),选择最快的路线回家是一个明智的决策。让计算机玩电子游戏已经通过纯 AI 方法实现:只需要编码游戏的规则;AI 不需要被展示如何下棋。图 1.1 显示 AI 是这些领域的最外层。

图 1.1 AI、机器学习和深度学习的(简化)层次结构
在机器学习(ML)中,我们开始向 AI 提供之前智能和不太智能的决策的例子。例如,我们可以通过给它提供国际象棋大师们玩过的例子游戏来改进我们的国际象棋 AI(每场比赛都有一个赢家和一个输家——一组明智和不太明智的决策)。这是一个以监督为中心的定义,但关键组成部分是我们有反映现实世界的数据。
注意:有一句俗语说数据即真理,但这也是一种过于简化的说法。许多偏见可能会影响你接收到的数据,给你一个有偏见的对世界的看法。这是一个高级话题,适合另一本书来探讨!
深度学习本身并非一个算法,而是由数百个像积木一样的小算法组成。成为一名优秀实践者的部分是了解有哪些积木可用,以及如何将它们组合在一起来为你的问题创建一个更大的模型。每个积木都是为了解决特定问题而设计的,为模型提供有价值的信息。图 1.2 展示了我们如何将积木组合起来以应对三种情况。本书的一个目标就是涵盖广泛的积木,以便你了解并理解它们如何用于不同类型的问题。有些积木是通用的(“数据是一个序列”可以用于任何类型的序列),而有些则更具体(“数据是一个图像”仅适用于图像),这影响了你何时以及如何使用它们。

图 1.2 深度学习的一个定义特征是从可重用积木中构建模型。不同的积木适用于不同类型的数据,并且可以混合搭配来处理不同的问题。第一行展示了如何重复使用相同类型的积木来构建一个更深层次的模型,这可以提高准确性。
第一行使用两个“数据是一个图像”的积木来创建一个深度模型。重复应用积木是深度学习中的“深度”来源。增加深度使模型能够解决更复杂的问题。这种深度通常是通过多次堆叠相同类型的积木来获得的。图中的第二行展示了序列问题的案例:例如,文本可以被表示为一系列单词。但并非所有单词都有意义,因此我们可能希望给模型一个帮助它学习忽略某些单词的积木。第三行展示了如何使用我们已知的积木来描述新的问题。如果我们想让我们的 AI 观看视频并预测正在发生的事情(例如,“跑步”、“网球”或“可爱的狗攻击”),我们可以使用“数据是一个图像”和“数据是一个序列”的积木来创建一系列图像——一个视频。
这些构建模块定义了我们的模型,但在所有机器学习(ML)中,我们还需要数据和一种学习机制。当我们说学习时,我们不是在谈论人类学习的方式。在机器(和深度)学习中,学习是使模型对数据进行智能预测的机械过程。这是通过称为优化或函数最小化的过程来实现的。在我们看到任何数据之前,我们的模型返回随机输出,因为所有的参数(控制计算内容的数字)都被初始化为随机值。在常见的工具如线性回归中,回归系数是参数。通过优化模块来处理数据,我们使我们的模型学习。这为我们提供了图 1.3 中的更大图景。

图 1.3 深度学习的“汽车”。这辆汽车由许多不同的构建模块组成,我们可以使用各种构建模块来构建用于不同任务的汽车。但是,我们需要燃料和轮子来让汽车行驶。轮子是学习任务,这是通过称为优化的过程来完成的;而燃料是数据。
在本书的大部分章节中,你将学习到可以用来构建针对不同应用场景的深度学习模型的新构建模块。你可以将每个模块视为一种(可能非常简单)的算法。我们讨论了每个模块的用途,并解释了它们是如何或为什么工作,以及如何在代码中将它们组合起来以创建一个新的模型。由于构建模块的性质,我们可以从简单的任务(例如,可以使用非深度机器学习算法解决的简单预测问题)逐步过渡到更复杂的例子,如机器翻译(例如,让计算机从英语翻译成法语)。我们从 20 世纪 60 年代以来用于训练和构建神经网络的基本方法和方法开始,但使用现代框架。随着我们在本书中的进展,我们将在所学的基础上继续前进,引入新的模块,扩展旧的模块,或者从现有的模块中构建新的模块。
话虽如此,这本书不是一本代码片段的食谱,可以随意应对任何新问题。目标是让你熟悉深度学习研究人员用来描述新和改进模块的语言,以便你能识别出何时一个新模块可能是有用的。数学通常可以简洁地表达复杂的变化,因此我将分享构建模块背后的数学。
我们不会进行很多数学运算——也就是说,推导或证明数学。相反,我会展示数学:呈现最终的方程式,解释它们的作用,并附上有用的直觉。我称之为直觉,因为我们只通过最基本所需的数学。解释正在发生的高层次想法以及为什么结果是这样需要比我要你拥有的更多的数学。当我展示方程式时,尽可能地将相应的 PyTorch 代码交织在一起,这样你就可以开始建立方程式和实现它们的深度学习代码之间的心理地图。
本章首先介绍我们的计算环境:Google Colab。接下来,我们将讨论 PyTorch 和张量,这是我们在 PyTorch 中表示信息的方式。然后,我们将深入了解图形处理单元(GPU)的使用,这使得 PyTorch 运行速度快,以及自动微分,这是 PyTorch 用来使神经网络模型学习的“机制”。最后,我们快速实现一个 PyTorch 需要的数据集对象,以便将数据输入模型进行学习过程。这为我们提供了推动深度学习汽车前进的燃料和轮子,从第二章开始。从那时起,我们可以专注于仅仅深度学习。
本书设计为线性阅读。每一章都使用了前一章中开发的技能或概念。如果你已经熟悉了章节中的概念,可以自由地跳到下一章。但如果你对深度学习是新手,我鼓励你一章接一章地学习,而不是跳到一个听起来更有趣的章节,因为这些概念可能具有挑战性,一步一步地学习将使整体过程更容易。
1.1 Colab 入门
我们将在深度学习的所有工作中使用 GPU。遗憾的是,这是一个计算密集型的实践,GPU 基本上是入门的必需品,尤其是在你开始处理更大规模的应用时。我经常在我的工作中使用深度学习,并定期启动需要几天时间在多个 GPU 上训练的任务。我的某些研究实验每次运行可能需要一个月的计算时间。
很不幸,GPU 价格昂贵。目前,对于大多数想要开始深度学习的人来说,最好的选择是花费 600-1200 美元购买高端的 NVIDIA GTX 或 Titan GPU。也就是说,如果你的电脑可以升级到高端 GPU。如果不能,你可能需要至少 1500-2500 美元来构建一个配备这些 GPU 的优质工作站。这只是为了学习深度学习而付出的高昂成本。
Google 的 Colab(colab.research.google.com)在有限的时间内免费提供 GPU。我为本书中的每个示例都设计了在 Colab 的时间限制内运行。附录中包含了设置 Colab 的说明。一旦设置好,常见的数据科学和机器学习工具如seaborn、matplotlib、tqdm和pandas都是内置的,随时可以使用。Colab 的操作方式类似于熟悉的 Jupyter 笔记本,你在单元格中运行代码,输出会直接显示在下方。本书是一本 Jupyter 笔记本,因此你可以运行代码块(如下一个)以获得相同的结果(如果代码单元格不打算运行,我会告诉你):
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np
from tqdm.autonotebook
import tqdm import pandas as pd
随着我们这本书的进展,我不会反复展示所有的导入,因为这大部分是浪费纸张。相反,它们作为代码下载副本的一部分在线提供,可以在github.com/EdwardRaff/Inside-Deep-Learning找到。
1.2 世界作为张量
深度学习已经在电子表格、音频、图像和文本上得到了应用,但深度学习框架并不使用类或对象来区分数据类型。相反,它们使用一种数据类型,我们必须将我们的数据转换为这种格式。对于 PyTorch 来说,这种对世界的单一视角是通过一个tensor对象实现的。张量用于表示数据,任何深度学习块的输入/输出,以及控制我们网络行为的参数。张量对象中内置了两个基本功能:使用 GPU 进行快速并行计算的能力,以及自动进行一些微积分(导数)的能力。如果你在 Python 中有先前的机器学习经验,你应该对 NumPy 也有先前的经验,NumPy 也使用了张量概念。在本节中,我们快速回顾了张量概念,并说明了 PyTorch 中的张量与 NumPy 的不同之处,这为我们深度学习构建块奠定了基础。
我们首先导入torch库,并讨论张量,它们也被称为 n 维数组。NumPy 和 PyTorch 都允许我们创建 n 维数组。零维数组称为标量,是任何单个数字(例如,3.4123)。一维数组是向量(例如,[1.9, 2.6, 3.1, 4.0, 5.5]),二维数组是矩阵。标量、向量和矩阵都是张量。实际上,n 维数组的任何 n 值仍然是张量。张量这个词指的是 n 维数组的整体概念。
我们关注张量,因为它们是组织我们大部分数据和算法的一种方便方式。这是 PyTorch 提供的第一个基础,我们通常将 NumPy 张量转换为 PyTorch 张量。图 1.4 显示了四个张量、它们的形状以及表示形状的数学方法。按照这个模式扩展,四维张量可以写成(B,C,W,H)或作为ℝ^(B, C, W, H)。

图 1.4 展示了张量的示例,随着我们从左到右移动,维度或轴越来越多。标量代表单个值。向量是一系列值,这是我们通常思考一个数据点的方式。矩阵是一系列值的网格,通常用于数据集。三维张量可以用来表示序列数据集。
张量维度
当写出张量的维度,如(B,C,W,H)时,我们经常使用具有常见符号或含义的单字母名称。这是一个有用的缩写,你将在代码中经常看到它。大多数将在后面的章节中详细解释,但以下是一些你将看到的常见维度字母:
-
B—正在使用的批次数。
-
D 或 H—隐藏层中的神经元/输出数量(有时 N 也用于此)。
-
C—输入中的通道数(例如,将“红色、绿色、蓝色”视为三个通道)或模型可能输出的类别/类别的数量。
-
W 和 H—图像的宽度和高度(几乎总是与图像通道的“C”维度一起使用)。
-
T—序列中元素的数量(更多内容请参考第四章)。
我们使用常见的符号来将数学符号与特定形状的张量关联起来。大写字母如 X 或 Q 代表具有两个或更多维度的张量。如果我们谈论一个向量,我们使用小写粗体字母如 x 或 h。最后,我们使用小写非粗体字母如 x 或 h 来表示标量。
在讨论和实现神经网络时,我们经常提到较大矩阵中的一行或较大向量中的一个标量。这如图 1.5 所示,通常被称为切片。所以如果我们有一个矩阵 X,我们可以使用x[i]来引用 X 的第 i 行。在代码中,这表示为x_i = X[i,:]。如果我们想要第 i 行和第 j 列,它变为x[i, j],因为它是一个对单个值的引用——使其成为一个标量。代码版本是x_ij = X[i,j]。

图 1.5 张量可以被切割以从较大的张量中获取子张量。例如,在红色部分,我们从较大的矩阵中获取一个行向量;在蓝色部分,我们从矩阵中获取一个列向量。根据张量所代表的内容,这可以让我们操作数据的不同部分。
要使用 PyTorch,我们需要将其导入为torch包。有了它,我们就可以立即开始创建张量。每次你在另一个列表内部嵌套一个列表时,你都会创建 PyTorch 将产生的张量的新维度:
import torch
torch_scalar = torch.tensor(3.14)
torch_vector = torch.tensor([1, 2, 3, 4])
torch_matrix = torch.tensor([[1, 2,],
[3, 4,],
[5, 6,],
[7, 8,]]) ❶
torch_tensor3d = torch.tensor([
[
[ 1, 2, 3],
[ 4, 5, 6],
],
[
[ 7, 8, 9],
[10, 11, 12],
],
[
[13, 14, 15],
[16, 17, 18],
],
[
[19, 20, 21],
[22, 23, 24],
]
])
❶ 你不必像我这样格式化它;这只是为了清晰起见。
如果我们打印这些张量的形状,你应该看到之前显示的相同形状。同样,虽然标量、向量和矩阵是不同的事物,但它们都统一在更大的张量范畴之下。我们关注这一点是因为我们使用不同形状的张量来表示不同类型的数据。我们将在稍后详细讨论这些细节;现在,我们专注于 PyTorch 提供的用于处理张量的机制:
print(torch_scalar.shape)
print(torch_vector.shape)
print(torch_matrix.shape)
print(torch_tensor3d.shape)
torch.Size([])
torch.Size([4])
torch.Size([4, 2])
torch.Size([4, 2, 3])
如果您在 Python 中做过任何机器学习或科学计算,您可能已经使用过 NumPy 库。正如您所期望的,PyTorch 支持将 NumPy 对象转换为它们的 PyTorch 对应物。由于它们都表示数据为张量,这是一个无缝的过程。以下两个代码块展示了我们如何在 NumPy 中创建一个随机矩阵,然后将其转换为 PyTorch 的Tensor对象:
x_np = np.random.random((4,4))
print(x_np)
[[0.05095622 0.64330091 0.98293797 0.27355789]
[0.37754388 0.51127555 0.29976254 0.97804978]
[0.28363853 0.48929802 0.77875258 0.19889717]
[0.23659932 0.21207824 0.25225453 0.54866766]]
x_pt = torch.tensor(x_np)
print(x_pt)
tensor([[0.0510, 0.6433, 0.9829, 0.2736],
[0.3775, 0.5113, 0.2998, 0.9780],
[0.2836, 0.4893, 0.7788, 0.1989],
[0.2366, 0.2121, 0.2523, 0.5487]], dtype=torch.float64)
NumPy 和 torch 都支持多种不同的数据类型。默认情况下,NumPy 使用 64 位浮点数,而 PyTorch 默认使用 32 位浮点数。然而,如果您从 NumPy 张量创建 PyTorch 张量,它将使用与给定 NumPy 张量相同的类型。您可以在前面的输出中看到这一点,其中 PyTorch 告诉我们dtype=torch.float64,因为它不是默认选择。
对于深度学习,我们最关心的类型是 32 位浮点数、64 位整数(Longs)和布尔值(即二进制True/False)。大多数操作不会改变张量类型,除非我们明确创建或将其转换为新的类型。为了避免类型问题,您可以在调用函数时明确指定您想要创建的张量类型。以下代码使用dtype属性检查我们的张量中包含的数据类型:
print(x_np.dtype, x_pt.dtype)
float64 torch.float64
x_np = np.asarray(x_np, dtype=np.float32) ❶
x_pt = torch.tensor(x_np, dtype=torch.float32)
print(x_np.dtype, x_pt.dtype)
float32 torch.float32
❶ 让我们强制它们成为 32 位浮点数。
使用 32 位浮点数或 64 位整数作为dtype的主要例外是当我们需要执行逻辑运算(如布尔 AND、OR、NOT)时,我们可以使用这些运算来快速创建二进制掩码。
掩码是一个张量,它告诉我们另一个张量的哪些部分是有效的。我们在一些更复杂的神经网络中使用掩码。例如,假设我们想在张量中找到所有大于 0.5 的值。PyTorch 和 NumPy 都允许我们使用标准的逻辑运算符来检查这类情况:
b_np = (x_np > 0.5)
print(b_np)
print(b_np.dtype)
[[False True True False]
[False True False True]
[False False True False]
[False False False True]]
bool
b_pt = (x_pt > 0.5)
print(b_pt)
print(b_pt.dtype)
tensor([[False, True, True, False],
[False, True, False, True],
[False, False, True, False],
[False, False, False, True]])
torch.bool
虽然 NumPy 和 PyTorch 的 API 并不完全相同,但它们共享许多具有相同名称、行为和特性的函数:
np.sum(x_np)
[13]: 7.117571
torch.sum(x_pt)
[14]: tensor(7.1176)
虽然许多函数是相同的,但有些并不完全相同。它们的行为或所需的参数可能存在细微差异。这些差异通常是因为 PyTorch 版本对这些方法用于神经网络设计和执行的方式进行了特定的更改。以下是一个transpose函数的示例,其中 PyTorch 要求我们指定要转置的两个维度。NumPy 则不提出异议地转置这两个维度:
np.transpose(x_np)
[15]: array([[0.05095622, 0.37754387, 0.28363854, 0.23659933],
[0.6433009 , 0.51127553, 0.48929802, 0.21207824],
[0.982938 , 0.29976255, 0.77875257, 0.25225455],
[0.2735579 , 0.97804976, 0.19889717, 0.54866767]], dtype=float32)
torch.transpose(x_pt, 0, 1)
[16]: tensor([[0.0510, 0.3775, 0.2836, 0.2366],
[0.6433, 0.5113, 0.4893, 0.2121],
[0.9829, 0.2998, 0.7788, 0.2523],
[0.2736, 0.9780, 0.1989, 0.5487]])
PyTorch 这样做是因为我们通常希望为深度学习应用转置张量的维度,而 NumPy 则试图保持更通用的期望。如下所示,我们可以从本章的开头开始转置torch_tensor3d中的两个维度。最初它的形状是(4,2,3)。如果我们转置第一和第三维度,我们得到形状为(3,2,4):
print(torch.transpose(torch_tensor3d, 0, 2).shape)
torch.Size([3, 2, 4])
由于存在这样的差异,如果你尝试使用一个你熟悉的函数,但突然发现它没有按预期工作,你应该始终在 pytorch.org/docs/stable/index.html 上双检查 PyTorch 文档。这也是在使用 PyTorch 时打开的好工具。PyTorch 中有许多不同的函数可以帮助你,我们无法全部进行审查。
1.2.1 PyTorch GPU 加速
PyTorch 给我们的第一个重要功能,超出了 NumPy 的能力,是使用 GPU 加速数学计算。GPU 是你电脑中专门为 2D 和 3D 图形设计的硬件,主要用于加速视频(观看高清电影)或玩视频游戏。这与神经网络有什么关系呢?嗯,制作 2D 和 3D 图形所需的大部分数学都是基于张量或至少与张量相关。因此,GPU 在快速执行我们想要做的事情方面变得越来越擅长。随着图形和 GPU 的变得更好、更强大,人们意识到它们也可以用于科学计算和机器学习。
从高层次来看,你可以将 GPU 视为巨大的张量计算器。在处理任何与神经网络相关的事情时,你应该几乎总是使用 GPU。这是一个很好的搭配,因为神经网络是计算密集型的,而 GPU 在执行我们需要的精确类型计算方面非常快。如果你想在专业环境中进行深度学习,你应该投资一台配备强大 NVIDIA GPU 的电脑。但就目前而言,我们可以免费使用 Colab 来解决问题。
使用 GPU 的技巧是避免在 少量 数据上进行计算。这是因为你的电脑的 CPU 必须首先将数据移动到 GPU,然后请求 GPU 执行数学运算,等待 GPU 完成运算,然后将结果从 GPU 复制回来。这个过程中的步骤相当慢;如果我们只计算少量的事情,使用 GPU 比使用 CPU 做数学运算要慢。
什么是“太小”?这取决于你的 CPU、GPU 和你正在做的数学。如果你担心这个问题,你可以进行一些基准测试,看看使用 CPU 是否更快。如果是这样,你可能在处理的数据太少。
让我们用矩阵乘法来测试这个方法——这是一种基本的线性代数运算,在神经网络中很常见。如果我们有矩阵 X^(n, m) 和 Y^(m, p),我们可以计算出一个结果矩阵 C^(n, p) = X^(n, m)Y^(m, p)。请注意,C 的行数与 X 相同,列数与 Y 相同。在实现神经网络时,我们会进行很多改变张量 shape 的操作,就像当我们相乘两个矩阵时发生的情况一样。这是错误的一个常见来源,因此在编写代码时你应该考虑张量的形状。
我们可以使用 timeit 库:它允许我们多次运行代码,并告诉我们运行所需的时间。我们创建一个较大的矩阵 X,多次计算 XX,看看这需要多长时间运行:
import timeit
x = torch.rand(2**11, 2**11)
time_cpu = timeit.timeit("x@x", globals=globals(), number=100)
运行这段代码需要一点时间,但不算太长。在我的电脑上,它运行了 6.172 秒,这个时间存储在 time_cpu 变量中。现在,我们如何让 PyTorch 使用我们的 GPU?首先,我们需要创建一个 device 引用。我们可以使用 torch.device 函数请求 PyTorch 给我们一个。如果你有一个 NVIDIA GPU,并且 CUDA 驱动程序安装正确,你应该能够传入 cuda 作为字符串,并得到代表该设备的对象:
print("Is CUDA available? :", torch.cuda.is_available())
device = torch.device("cuda")
Is CUDA available? : True
现在我们有了想要使用的 GPU(设备)的引用,我们需要请求 PyTorch 将该对象移动到指定的设备。幸运的是,这可以通过一个简单的 to 函数来完成;然后我们可以使用之前的相同代码:
x = x.to(device)
time_gpu = timeit.timeit("x@x", globals=globals(), number=100)
当我运行这段代码时,执行 100 次乘法的时间是 0.6191 秒,这几乎是瞬间 9.97 倍的速度提升。这是一个相当理想的情况,因为矩阵乘法在 GPU 上非常高效,而且我们创建了一个大矩阵。你应该尝试减小矩阵的大小,看看这对你的速度提升有何影响。
注意,这只有在涉及的所有对象都在同一设备上时才有效。比如说,你运行以下代码,其中变量 x 已经被移动到 GPU 上,而 y 没有被移动(因此默认在 CPU 上):
x = torch.rand(128, 128).to(device)
y = torch.rand(128, 128)
x*y
你最终会得到一个错误信息,它说:
RuntimeError: expected device cuda:0 but got device cpu
错误信息告诉你第一个变量在哪个设备上(cuda:0),但第二个变量在另一个设备上(cpu)。如果我们改为写 y*x,你会看到错误变为 expected device cpu but got device cuda:0。每次你看到这样的错误时,你都有一个阻止你将所有内容移动到同一计算设备上的错误。
另一件需要注意的事情是如何将 PyTorch 数据转换回 CPU。例如,我们可能希望将一个张量转换回 NumPy 数组,以便我们可以将其传递给 Matplotlib 或保存到磁盘。PyTorch 的 tensor 对象有一个 .numpy() 方法可以完成这个操作,但如果你调用 x.numpy(),你会得到这个错误:
TypeError: can't convert CUDA tensor to numpy. Use Tensor.cpu()
to copy the tensor to host memory first.
相反,你可以使用方便的快捷函数 .cpu() 将对象移回 CPU,在那里你可以正常与之交互。所以,当你想要访问你工作的结果时,你经常会看到 x.cpu().numpy() 这样的代码。
.to() 和 .cpu() 方法使得编写突然加速的 GPU 代码变得容易。一旦在 GPU 或类似的计算设备上,几乎 PyTorch 伴随的 每个 方法都可以使用,并且会带来不错的速度提升。但有时我们希望将张量和其他 PyTorch 对象存储在列表、字典或其他标准 Python 集合中。为了帮助解决这个问题,我们可以定义这个 moveTo 函数,它会递归地遍历常见的 Python 和 PyTorch 容器,并将找到的每个对象移动到指定的设备上:
def moveTo(obj, device):
"""
obj: the python object to move to a device, or to move its
➥ contents to a device
device: the compute device to move objects to
"""
if isinstance(obj, list):
return [moveTo(x, device) for x in obj]
elif isinstance(obj, tuple):
return tuple(moveTo(list(obj), device))
elif isinstance(obj, set):
return set(moveTo(list(obj), device))
elif isinstance(obj, dict):
to_ret = dict()
for key, value in obj.items():
to_ret[moveTo(key, device)] = moveTo(value, device)
return to_ret
elif hasattr(obj, "to"):
return obj.to(device)
else:
return obj
some_tensors = [torch.tensor(1), torch.tensor(2)]
print(some_tensors) print(moveTo(some_tensors, device))
[tensor(1), tensor(2)]
[tensor(1, device='cuda:0'), tensor(2, device='cuda:0')]
我们第一次打印数组时,看到了tensor(1)和tensor(2);但使用moveTo函数后,出现了device=cuda:0。我们不会经常使用这个函数,但当我们使用它时,它会使我们的代码更容易阅读和编写。有了这个,我们现在有了使用 GPU 加速编写快速代码的基础。
我们为什么关心 GPU?
使用 GPU 本质上是为了速度。它实际上可能是等待神经网络训练数小时或数分钟的区别——这还是在考虑非常大的网络或巨大的数据集之前。我试图让这本书中训练的每一个神经网络在 10 分钟或更短的时间内完成,在大多数情况下不到 5 分钟,当使用 GPU 时。这意味着使用玩具问题和调整它们以展示代表现实生活的行为。
为什么不使用真实世界的数据和问题进行学习?因为真实世界的神经网络可能需要数天或数周来训练。我为我的日常工作所做的某些研究可能需要使用多个GPU一个月的时间来训练。我们编写的代码对于真实世界的任务来说是完全良好和有效的,但我们需要等待更长的时间才能得到结果。
这漫长的计算时间也意味着你需要学会如何在模型训练时保持高效。一种方法是在备用机器上为你的下一个模型开发新代码,或者在你使用 GPU 时使用 CPU。你将无法训练它,但你可以推送一小部分数据以确保没有错误发生。这也是为什么我想让你学习如何将深度学习中使用的数学映射到代码中:这样,当你的模型忙于训练时,你可以阅读关于最新和最伟大的深度学习工具的信息,这些工具可能对你有所帮助。
1.3 自动微分
到目前为止,我们已经看到 PyTorch 提供了一个类似于 NumPy 的 API,用于在张量上执行数学运算,并且当可用时使用 GPU 来执行更快的数学运算。PyTorch 给我们提供的第二个主要基础是自动微分:只要我们使用 PyTorch 提供的函数,PyTorch 就可以为我们自动计算导数(也称为梯度)。在本节中,我们将了解这意味着什么以及自动微分如何与最小化函数的任务相结合。在下一节中,我们将看到如何使用 PyTorch 提供的简单 API 将这些内容封装起来。
你的第一个想法可能是,“什么是导数,我为什么要关心它?”记住,从微积分中我们知道函数f(x)的导数告诉我们f(x)的值变化有多快。我们关心这一点,因为我们可以使用函数f(x)的导数来帮助我们找到输入x*,它是**f**(*x*)的**最小化者**。**x**是最小化者意味着f(x*)的值小于**f**(*x*+z),无论我们设置 z 的值是多少。用数学的方式来说,就是f(x^) ≤ f(z),∀x^ ≠ z:

另一种说法是,如果我编写以下代码,我就会陷入无限循环的等待:
while f(x_star) <= f(random.uniform(-1e100, 1e100)):
pass
我们为什么要最小化一个函数呢?对于本书中讨论的所有机器学习和深度学习类型,我们通过定义 损失函数 来训练神经网络。损失函数以数值和可量化的方式告诉网络,它在问题上的表现有多“糟糕”。所以如果损失值高,事情就进行得不好。高损失意味着网络正在输掉比赛,而且输得很惨。如果损失为零,则网络完美地解决了问题。我们通常不允许损失为负,因为这会让思考变得混乱。
当你阅读关于神经网络的数学时,你经常会看到损失函数被定义为 ℓ(x),其中 x 是网络的输入,ℓ(x) 给出网络收到的损失。正因为如此,损失函数返回标量。这很重要,因为我们可以比较标量,并说一个绝对比另一个大或小,这样就可以明确网络在游戏中的表现有多糟糕。导数通常定义与一个单一变量相关,但我们的网络将有许多变量(参数)。当我们对多个变量求导时,我们称之为 梯度;你可以将关于导数和单一变量的相同直觉应用到多个变量的梯度上。
我们已经说过梯度是有帮助的,也许你还记得从微积分课程中学到的关于使用导数和梯度最小化函数的内容。让我们通过微积分来回顾一下如何找到函数的极小值。
假设我们有一个函数 f(x) = (x−2)²。让我们用一些 PyTorch 代码来定义它,并绘制函数的形状:
def f(x):
return torch.pow((x-2.0), 2)
x_axis_vals = np.linspace(-7,9,100)
y_axis_vals = f(torch.tensor(x_axis_vals)).numpy()
sns.lineplot(x=x_axis_vals, y=y_axis_vals, label='$f(x)=(x-2)²$')
[22]: <AxesSubplot:>

1.3.1 使用导数最小化损失
我们可以清楚地看到这个函数的极小值在 x = 2,在那里我们得到 f(2) = 0。但这是一个故意设计得比较简单的问题。假设我们无法绘制它;我们可以使用微积分来帮助我们找到答案。
我们将 f(x) 的导数表示为 f′(x),并且我们可以通过微积分得到答案:f′(x) = 2 ⋅ x − 4。函数的极小值存在于 临界点,即 f′(x) = 0 的点。因此,让我们通过解 x 来找到它们。在我们的例子中,我们得到
2 ⋅ x − 4 = 0
2 ⋅ x = 4
(两边同时加 4)
x = 4/2 = 2
(两边同时除以 2)。
这要求我们解方程,当 f′(x) = 0 时。PyTorch 无法为我们做到这一点,因为我们将要开发更复杂的函数,在这些函数中找到 确切 的答案是不可能的。但是,假设我们有一个当前猜测,x^?,我们相当确信它不是最小值。我们可以使用 f′(x^?) 来帮助我们确定如何调整 x^?,以便我们更接近最小值。
这怎么可能呢?让我们同时绘制 f(x) 和 f′(x):
def fP(x): ❶
return 2*x-4
y_axis_vals_p = fP(torch.tensor(x_axis_vals)).numpy()
sns.lineplot(x=x_axis_vals, y=[0.0]*len(x_axis_vals),
➥ label="0", color=’black’) ❷
sns.lineplot(x=x_axis_vals, y=y_axis_vals,
➥ label=’Function to Minimize $f(x) = (x-2)²$’)
sns.lineplot(x=x_axis_vals, y=y_axis_vals_p,
➥ label="Gradient of the function $f’(x)=2 x - 4$")
[23]: <AxesSubplot:>
❶ 手动定义 f(x) 的导数
❷ 在 0 处画一条黑色线,这样我们就可以很容易地判断某个值是正数还是负数

看看橙色线。当我们离最小值 (x = 2) 太远时,我们看到 f′(x^?) < 0。当我们位于最小值右侧时,我们反而得到 f′(x) > 0。只有当我们处于最小值时,我们才看到 f′(x^?) = 0。所以如果 f′(x^?) < 0,我们需要增加 x^?;如果 f′(^?x) > 0,我们需要减小 x^? 的值。梯度的 符号告诉我们应该朝哪个 方向移动以找到最小值。这个过程称为 梯度下降,总结在图 1.6 中。

图 1.6 使用函数的导数 f′(x) 来最小化函数 f(x) 的过程称为 梯度下降,此图展示了它是如何进行的。我们迭代地计算 f′(x) 来决定 x 应该更大还是更小,以使 f(x) 的值尽可能小。当我们的位置足够接近梯度为零时,这个过程停止。如果你已经进行了很多更新,你也可以提前停止:“足够接近就是足够好”在深度学习中是成立的,我们很少需要完美地最小化一个函数。
我们也关心 f′(x^?) 的 大小。因为我们正在看一维函数,大小只是 f′(x^?) 的绝对值:即,|f′(x^?)|。大小给我们一个我们离最小值有多远的想法。所以 f′(x^?) 的符号 (<0 或 >0) 告诉我们应该朝哪个 方向移动,而大小 (|f′(x)|) 告诉我们应该移动多 远。
这不是巧合。对于任何函数,这总是始终成立的。如果我们能计算导数,我们就能找到一个最小值。你可能正在想,“我不太记得我的微积分了,”或者抱怨我跳过了如何计算 f′(x) 的步骤。这就是我们使用 PyTorch 的原因:自动微分会为我们计算 f′(x) 的值。让我们用 f(x) = (x−2)² 的玩具例子来看看它是如何工作的。
1.3.2 使用自动微分计算导数
现在我们已经理解了使用函数的导数来最小化函数的概念,让我们来看看在 PyTorch 中实现它的机制。首先,让我们创建一个新的变量来最小化。我们这样做与之前类似,但我们会添加一个新的标志告诉 PyTorch 保持跟踪梯度。这存储在一个名为 grad 的变量中,因为我们还没有计算任何东西,所以它还不存在:
x = torch.tensor([-3.5], requires_grad=True)
print(x.grad)
None
我们看到当前没有梯度。不过,让我们尝试计算 f(x),看看现在我们设置了 requires_grad=True 后是否有什么变化:
value = f(x)
print(value)
tensor([30.2500], grad\_fn=<PowBackward0>)
现在我们打印返回变量的值时,得到的结果略有不同。在第一部分,打印了值 30.25,这是* f(−3.5)的正确值。但我们还看到了这个新的grad_fn=<PowBackward0>。一旦我们告诉 PyTorch 开始计算梯度,它就开始跟踪我们做的每一个*计算。它使用这些信息来反向计算所有使用过并且设置了requires_grad标志为True的梯度的梯度。
一旦我们得到一个单一的标量值,我们可以告诉 PyTorch 回过头来使用这些信息来计算梯度。这是通过.backward()函数完成的,之后我们在原始对象中看到梯度:
value.backward()
print(x.grad)
tensor([-11.])
这样,我们现在已经计算了变量x的梯度。PyTorch 和自动微分的力量之一是,只要使用 PyTorch 函数实现,你就可以让函数f(x)做几乎所有的事情。我们为计算x的梯度编写的代码不会改变。PyTorch 会为我们处理所有计算的细节。
1.3.3 将它们组合起来:使用导数最小化函数
现在 PyTorch 可以为我们计算梯度,我们可以使用 PyTorch 函数f(x)的自动微分来数值地找到答案f(2) = 0。我们首先用数学符号描述它,然后用代码描述。
我们从当前的猜测开始,x[cur] = − 3.5。我任意选择了 3.5;在现实生活中,你通常会随机选择一个值。我们还使用x[prev]跟踪我们的前一个猜测。由于我们还没有做任何事情,将前一步设置为任何大值(例如,x[prev] = x[cur] * 100)是可以的。
接下来,我们比较当前的猜测和之前的猜测是否相似。我们通过检查∥x[cur] − x[prev]∥[2] > ϵ来做这件事。函数∥z∥[2]被称为范数或2-范数。范数是测量向量矩阵幅度最常见和标准的方式。对于一维情况(就像这个一样),2-范数与绝对值相同。如果我们没有明确说明我们谈论的是哪种范数,你应该始终假设是 2-范数。值ϵ是表示任意小值的常见数学符号。所以,读法是这样的

现在我们知道,∥x[cur] − x[prev]∥[2] > ϵ 是我们检查我们的猜测之间是否存在大的(> ϵ)幅度(∥ ⋅ ∥[2])变化(x[cur] − x[prev])的方法。如果这是假的,∥x[cur] − x[prev]∥[2] ≤ ϵ,这意味着变化很小,我们可以停止。一旦我们停止,我们就接受x[cur]作为 x 的值,它是使f(x)最小化的值。如果不是,我们需要一个新的、更好的猜测。
为了得到这个新猜测,我们向导数的相反方向移动。这看起来是这样的:x[cur] = x[cur] − η ⋅ f′(x[cur])。值 η 被称为 学习率,通常是一个很小的值,如 η = 0.1 或 η = 0.01。我们这样做是因为梯度 f′(x) 告诉我们移动的方向,但只提供了一个 相对 答案,关于我们有多远。它没有告诉我们在这个方向上应该走多远。由于我们不知道要走多远,我们希望保守一些,走得慢一些。图 1.7 展示了原因。

图 1.7 展示了学习率 η(也称为步长)对学习的影响的三个例子。在左侧,η 小于必要的值。这仍然达到了最小值,但比所需的步数多。如果我们知道 η 的完美值,我们可以将其设置得恰到好处,以采取最小的步数达到最小值(中间)。在右侧,η 太大,导致发散。我们永远无法达到解!
通过在当前方向上采取更小的步长,我们不会“驶过”答案并需要掉头。看看我们函数的先前例子,了解这是如何发生的。如果我们有 完全正确 的最佳 η 值(中间图像),我们可以一步到达最小值。但我们不知道这个值是多少。如果我们保守地选择一个可能小于我们需要的值,我们可能需要更多步数才能到达答案,但最终我们会到达那里(左侧图像)。如果我们设置学习率过高,我们可能会错过解并围绕它弹跳(右侧图像)。
这可能听起来像很多令人害怕的数学,但当你看到执行这项工作的代码时,你可能会感觉好一些。它只有几行长。在循环结束时,我们打印 x[cur] 的值,并看到它等于 2.0;PyTorch 找到了答案。注意,当我们定义 PyTorch Tensor 对象时,它有一个子成员 .grad,用于存储该变量的计算梯度,以及一个 .data 成员,用于存储底层值。你通常不应该访问这些字段,除非你有特定的原因;现在,我们正在使用它们来演示 autograd 的机制:
x = torch.tensor([-3.5], requires_grad=True)
x_cur = x.clone()
x_prev = x_cur*100 ❶
epsilon = 1e-5 ❷
eta = 0.1 ❸
while torch.linalg.norm(x_cur-x_prev) > epsilon:
x_prev = x_cur.clone() ❹
value = f(x) ❺
value.backward()
x.data -= eta * x.grad
x.grad.zero_() ❻
x_cur = x.data ❼
print(x_cur)
tensor([2.0000])
❶ 将初始的“前一个”解设置得更大,以便它与当前解不同,while 循环将开始
❷ 当前值和前一个值足够接近,以至于我们停止
❸ 学习率
❹ 创建一个克隆,以便 x_prev 和 x_cur 不指向同一个对象
❺ 接下来的几行计算函数、梯度和更新。我们希望 autograd 能够工作,因此我们需要访问 .data 成员字段。
❻ 将旧梯度置零,因为 PyTorch 不会为我们做这件事
❼ 访问 .data 以避免 autograd 机制。我们希望在不产生任何副作用的情况下更改值。
我一直听说的反向传播是什么?
许多书籍都是从一个名为 反向传播 的算法开始讨论深度学习的。这是用于计算神经网络中所有梯度的原始算法的名称。我个人认为反向传播是一个非常令人畏惧的起点,因为它涉及更多的数学和绘图,但它完全被自动微分所封装。使用现代框架如 PyTorch,你不需要了解反向传播的机制就可以开始。如果你想了解反向传播以及它是如何与自动微分一起工作的,我喜欢 Andrew W. Trask 的书 Grokking Deep Learning(Manning,2019)第六章中的方法。
1.4 优化参数
我们刚才所做的是找到函数 f(⋅) 的最小值,这被称为 优化。因为我们使用损失函数 ℓ(⋅) 来指定我们网络的目标,所以我们可以优化 f(⋅) 以最小化我们的损失。如果我们达到损失 ℓ(⋅) = 0,我们的网络似乎已经解决了问题。这就是我们为什么关心优化,并且它是大多数现代神经网络训练的基础。图 1.8 展示了其工作原理的简化。

图 1.8 神经网络如何使用损失 ℓ(⋅) 和优化过程。神经网络由其参数 θ 控制。为了对数据进行有用的预测,我们需要改变参数。我们通过首先计算损失 ℓ(⋅),它告诉我们网络做得有多糟糕。由于我们想要最小化损失,我们可以使用梯度来改变参数!这使网络能够做出有用的预测。
由于优化的重要性,PyTorch 包含了两个额外的概念来帮助我们:参数和优化器。模型的 Parameter 是一个值,我们使用 Optimizer 来改变它,以尝试减少我们的损失 ℓ(⋅)。我们可以使用 nn.Parameter 类轻松地将任何张量转换为 Parameter。为此,让我们重新解决之前的最小化 f(x) = (x−2)² 的问题,初始猜测为 x[cur] = 3.5。我们首先要做的是为 x 的值创建一个 Parameter 对象,因为这是我们打算改变的:
x_param = torch.nn.Parameter(torch.tensor([-3.5]), requires_grad=True)
x_param 对象现在是 nn.Parameter,其行为与张量相同。我们可以在 PyTorch 中使用 Parameter 的任何地方使用张量,代码将正常工作。但现在我们可以创建一个 Optimizer 对象。我们使用的最简单的优化器称为 SGD,代表 随机梯度下降。单词 gradient 在那里是因为我们正在使用函数的梯度/导数。Descent 意味着我们正在最小化或 下降 到我们正在最小化的函数的更低值。我们将在下一章中了解到 stochastic 部分。
要使用 SGD,我们需要创建一个与 Parameters 的 list 相关的关联对象,这些 Parameters 是我们想要调整的。我们还可以指定学习率 η 或接受默认值。以下代码指定 η 以匹配原始代码:
optimizer = torch.optim.SGD([x_param], lr=eta)
现在我们可以将之前的丑陋循环重写成一个更干净、看起来更接近我们实际训练神经网络的代码。我们将固定次数地遍历优化问题,这通常被称为epochs。zero_grad方法为我们之前手动为每个输入参数所做的清理工作。我们计算损失,对那个损失调用.backward(),然后要求优化器执行一次.step()的优化:
for epoch in range(60):
optimizer.zero_grad() ❶
loss_incurred = f(x_param)
loss_incurred.backward()
optimizer.step() ❷
print(x_param.data)
❶ x.grad.zero_()
❷ x.data -= eta * x.grad
代码打印出tensor(2.0000),就像之前一样。这将在我们的网络中实际上有数百万个参数时使我们的生活变得更简单。
你会注意到代码中的一个显著变化:我们不是在达到零梯度或前一次和当前解之间的差异非常小之前进行优化。相反,我们正在做一件更简单的事情:固定次数的步骤。在深度学习中,我们很少能达到损失为零,而且我们不得不等待很长时间才能发生这种情况。所以大多数人会选择一个他们愿意等待的固定 epochs 数,然后看看最终的结果是什么。这样,我们就能更快地得到答案,而且通常足够好用来使用。
为什么选择 PyTorch?
深度学习框架有很多,包括 TensorFlow 和 Keras,MXNet,以及其他基于 PyTorch 构建的如 fast.ai,以及一些新的如 JAX。我的观点是,PyTorch 在“让事情变得简单”和“让事情变得可访问”之间取得了比大多数其他工具更好的平衡。NumPy-like 函数调用使得开发变得相对容易,更重要的是,更容易调试。虽然 PyTorch 有像Optimizer这样的良好抽象,但我们刚刚看到在不同的抽象级别之间切换是多么的无痛。这是另一个使调试更容易的不错特性,当你遇到奇怪的 bug 或想尝试一个异国情调的想法时。PyTorch 在经典深度学习任务之外的使用也很灵活。其他平台也有自己的优点和缺点,但这些都是我选择 PyTorch 编写这本书的原因。
1.5 加载数据集对象
我们已经对 PyTorch 的基本工具了解了一些。现在我们想要开始训练一个神经网络。但首先我们需要一些数据。使用 ML 的常见符号,我们需要一组输入数据 X 和相关的输出标签 y。在 PyTorch 中,我们用Dataset对象来表示这一点。通过使用这个接口,PyTorch 提供了高效的加载器,它可以自动处理使用多个 CPU 核心来预取数据,并在任何时候保持有限的数据量在内存中。让我们先从加载一个熟悉的 scikit-learn 数据集开始:MNIST。我们将它从 NumPy 数组转换为 PyTorch 喜欢的形式。
PyTorch 使用Dataset类来表示数据集,并编码了数据集中有多少项以及如何获取数据集中的第n项的信息。让我们看看它是什么样子:
from torch.utils.data import Dataset
from sklearn.datasets import fetch_openml
X, y = fetch_openml(’mnist_784’, version=1, return_X_y=True) ❶
print(X.shape)
(70000, 784)
❶ 从 https://www.openml.org/d/554 加载数据
我们已经加载了包含 70,000 行和 784 个特征的经典 MNIST 数据集。现在我们将创建一个简单的 Dataset 类,它接受 X, y 作为输入。我们需要定义一个 __getitem__ 方法,它将返回数据标签作为一个 tuple(inputs, outputs)。inputs 是我们想要给模型作为输入的对象,而 outputs 用于输出。我们还需要实现 __len__ 函数,它返回数据集的大小:
class SimpleDataset(Dataset):
def __init__(self, X, y):
super(SimpleDataset, self).__init__()
self.X = X
self.y = y
def __getitem__(self, index):
inputs = torch.tensor(self.X[index,:], dtype=torch.float32) ❶
targets = torch.tensor(int(self.y[index]), dtype=torch.int64)
return inputs, targets
def __len__(self):
return self.X.shape[0]
dataset = SimpleDataset(X, y) ❷
❶ 这“工作”原本可以放在构造函数中,但你应该养成将其放在 getitem 的习惯。
❷ 创建 PyTorch 数据集
注意到我们在构造函数中只做了最小量的工作,而不是将其移动到 __getitem__ 函数中。这是一个有意的设计,并且是一个你应该在深度学习工作中效仿的习惯。在许多情况下,我们需要进行非平凡的预处理、准备和转换,以便将数据转换成神经网络可以学习的形式。如果你将这些任务放入 __getitem__ 函数中,你将获得 PyTorch 在需要时执行工作的好处,同时你等待 GPU 完成处理其他批次数据,使你的整体过程更加计算高效。当你处理大型数据集时,这变得尤为重要,因为预处理可能会造成初始阶段的长时间延迟或需要额外的内存,而仅在需要时进行准备可以节省你大量的存储空间。
注意你可能想知道为什么我们使用 int64 作为目标张量的类型。为什么不使用 int32 或甚至 int8,如果我们知道我们的标签在一个更小的范围内,或者 uint32 如果不会出现负值?令人不满意的答案是,对于任何需要 int 类型的场景,PyTorch 都是硬编码为仅与 int64 一起工作,所以你只能使用它。同样,当需要浮点值时,PyTorch 的大部分功能将仅与 float32 一起工作,所以你必须使用 float32 而不是 float64 或其他类型。虽然有一些例外,但在学习基础知识时,它们不值得深入研究。
现在我们有一个简单的数据集对象。它将整个数据集保存在内存中,这对于小型数据集来说是可行的,但我们希望在将来修复它。我们可以确认数据集仍然有 70,000 个示例,每个示例有 784 个特征,就像之前一样,并且可以快速确认我们实现的长度和索引函数按预期工作:
print("Length: ", len(dataset))
example, label = dataset[0]
print("Features: ", example.shape) ❶
print("Label of index 0: ", label)
Length: 70000
Features: torch.Size([784])
Label of index 0: tensor(5)
❶ 返回 784
MNIST 是一个手绘数字的数据集。我们可以通过将数据重塑回图像来可视化它,以确认我们的数据加载器正在正常工作:
plt.imshow(example.reshape((28,28)))
[34]: <matplotlib.image.AxesImage at 0x7f4721b9fc50>

1.5.1 创建训练和测试分割
现在我们已经将所有数据放在了一个数据集中。然而,像优秀的机器学习从业者一样,我们应该创建一个训练分割和一个测试分割。在某些情况下,我们有一个专门用于训练和测试的数据集。如果是这种情况,你应该从相应的数据源创建两个单独的 Dataset 对象——一个用于训练,一个用于测试。
在这种情况下,我们有一个数据集。PyTorch 有一个简单的实用工具可以将语料库拆分为训练集和测试集。假设我们想用 20% 的数据用于测试。我们可以使用 random_split 方法这样做:
train_size = int(len(dataset)*0.8) test_size =
len(dataset)-train_size
train_dataset, test_dataset = torch.utils.data.random_split(dataset, (train_size, test_size)) print("{} examples for training and {} for testing".format( len(train_dataset), len(test_dataset)))
56000 examples for training and 14000 for testing
现在我们有了训练集和测试集。实际上,前 60,000 个点是 MNIST 的标准训练集,最后 10,000 个点是标准测试集。但重点是展示如何自己创建随机分割的功能。
通过这样,我们已经了解了 PyTorch 提供的所有基础工具:
-
一个类似于 NumPy 的张量 API,支持 GPU 加速
-
自动微分,它使我们能够解决优化问题
-
数据集的抽象
我们将在此基础上构建,你可能会注意到这开始影响你未来对神经网络的思考。它们不会神奇地完成所要求的事情,而是尝试通过损失函数 ℓ(⋅) 指定的目标进行数值求解。我们需要小心地定义或选择 ℓ(⋅),因为这将决定算法学习的内容。
练习
在 Manning 在线平台 Inside Deep Learning Exercises (liveproject.manning.com/project/945) 上分享和讨论你的解决方案。一旦你提交了自己的答案,你将能够看到其他读者提交的解决方案,并看到作者认为哪些是最好的。
-
编写一系列
for循环,计算torch_tensor3d中的平均值。 -
编写代码,索引到
torch_tensor3d并打印出值 13。 -
对于每个 2 的幂(即,2^i 或
2**i)直到 2¹¹,创建一个随机矩阵 X ∈ ℝ(2i, 2^i)(即,X.shape应该给出(2**i, 2**i))。计算 XX(即,X @ X)在 CPU 和 GPU 上的时间,并绘制加速图。对于什么矩阵大小 CPU 比 GPU 快? -
我们使用 PyTorch 找到 f(x) = (x−2)² 的数值解。编写代码找到 f(x) = sin(x − 2) · (x + 2)² + √|cos(x)| 的解。你得到什么答案?
-
编写一个新的函数,该函数接收两个输入,x 和 y,其中
f(x,y) = exp (sin(x)²)/(x−y)² + (x−y)²
使用初始参数值 x = 0.2 和 y = 10 的
Optimizer。它们会收敛到什么? -
创建一个名为
libsvm2Dataset的函数,该函数接收一个指向 libsvm 数据集文件的路径(参见www.csie.ntu.edu.tw/ cjlin/libsvmtools/datasets/以下载更多数据集),并创建一个新的数据集对象。检查其长度是否正确,以及每一行是否具有预期的特征数量。 -
挑战性:使用 NumPy 的
memmap功能将 MNIST 数据集写入磁盘。然后创建一个MemmapedSimpleDataset,它以 mem-mapped 文件作为输入,在__getitem__方法中从磁盘读取矩阵以创建 PyTorch 张量。你认为这会有什么用?
摘要
-
PyTorch 几乎使用张量来表示一切,张量是多维数组。
-
使用 GPU,PyTorch 可以加速使用张量进行的任何操作。
-
PyTorch 跟踪我们对张量的操作以执行自动微分,这意味着它可以计算梯度。
-
我们可以使用梯度来最小化一个函数;由梯度改变值的参数。
-
我们使用损失函数来量化网络在任务上的表现,并使用梯度来最小化该损失,从而学习网络的参数。
-
PyTorch 提供了一个
Dataset抽象,这样我们就可以让 PyTorch 处理一些繁琐的任务并最小化内存使用。
2 全连接网络
本章节涵盖
-
在 PyTorch 中实现训练循环
-
更改回归和分类问题的损失函数
-
实现和训练一个全连接网络
-
使用更小的数据批次加速训练
现在我们已经了解了 PyTorch 如何为我们提供张量来表示数据和参数,我们可以继续构建我们的第一个神经网络。这始于展示 PyTorch 中 学习 的发生。正如我们在第一章中描述的,学习基于优化的原则:我们可以计算一个损失来衡量我们做得如何,并使用梯度来最小化这个损失。这就是网络参数从数据中“学习”的方式,也是许多不同的机器学习(ML)算法的基础。因此,损失函数的优化是 PyTorch 构建的基础。因此,要在 PyTorch 中实现任何类型的神经网络,我们必须将问题表述为一个优化问题(记住这也可以称为 函数最小化)。
在本章中,我们首先学习如何设置这种学习优化方法。这是一个广泛适用的概念,我们编写的代码几乎可以用于任何神经网络。这个过程被称为 训练循环,甚至适用于简单的线性回归和逻辑回归等机器学习算法。由于我们专注于训练的机制,我们将从这两个基本算法开始,以便我们能够专注于 PyTorch 中分类和回归的训练循环的工作方式。
使逻辑回归/线性回归成立的权重向量也被称为 线性层 或 全连接层。这意味着在 PyTorch 中,两者都可以被视为单一层模型。由于神经网络可以描述为一系列层的序列,我们将修改我们原始的逻辑回归和线性模型,使其成为完整的神经网络。在这个过程中,你将了解非线性层的重要性,以及逻辑回归和线性回归如何相互关联以及与神经网络的关系。
在掌握训练循环、分类和回归损失函数以及定义全连接神经网络的概念之后,我们将涵盖深度学习的基础概念,这些概念几乎会在你训练的每个模型中重复出现。为了完善本章,我们将重构我们的代码到一个方便的辅助函数中,并学习在小型数据组(称为 批次)上训练而不是使用整个数据集的实用价值。
2.1 神经网络作为优化
在第一章中,我们使用了 PyTorch 的自动微分功能来优化(即最小化)一个函数。我们定义了一个损失函数来最小化,并使用 .backward() 函数来计算梯度,这告诉我们如何改变参数以最小化函数。如果我们将损失函数的输入设为神经网络,我们可以使用完全相同的方法来训练神经网络。这创建了一个称为训练循环的过程,有三个主要组成部分:训练数据(带有正确答案)、模型和损失函数,以及通过梯度进行更新。这三个组成部分在图 2.1 中概述。

图 2.1 PyTorch 中训练模型的主要三个步骤。1. 输入数据驱动学习。模型用于做出预测,损失分数衡量预测和真实标签之间的差异。3. 使用 PyTorch 的自动微分来更新模型的参数,提高其预测能力。
2.1.1 神经网络的训练符号表示
在我们开始之前,让我们介绍一些在本书中重复使用的标准符号。我们用 x 表示输入特征,用 f() 表示神经网络模型。与 x 相关的标签用 y 表示。我们的模型接受 x 并产生一个预测 ŷ。写成这样,就是 ŷ = f(x)。这个符号在深度学习论文中广泛使用,熟悉它将帮助你跟上新方法的开发。
我们的模式需要参数进行调整。改变参数允许网络改变其预测,以尝试减少损失函数。我们将使用 Θ 表示我们模型的所有参数。如果我们想明确一点,我们可能会说 ŷ = fΘ 来表明模型的预测和行为取决于其参数 Θ 的值。你也会看到 Θ 被称为模型的状态。
我们有描述我们模型的符号和语言,但我们还需要一种方法来将目标表述为函数最小化。为此,我们使用损失函数的概念。损失函数量化我们的模型在预测真实值 y 的目标上做得有多糟糕。如果 y 是我们的目标,ŷ 是预测,我们将我们的损失函数表示为 ℓ(ŷ,y).
现在我们已经拥有了抽象地描述学习为函数最小化问题的所有必要条件。假设我们有一个包含 N 个示例的训练集,通过优化方程 minΘ Σ[i]^N[=1] ℓ(f[Θ](x[i]), y[i]). 让我们用英语写出这个方程的含义;我们将为每个英语描述着色,以匹配相同的数学表达:

通过查看数学的每一部分,我们可以看到这只是在描述我们的目标,而且数学允许我们在更小的空间中传达一个长句子。作为代码,它可能看起来像这样:¹
def F(X, y, f, theta):
total_loss = 0
for i in range(N):
total_loss += loss(f(X[i,:], theta), y[i])
return total_loss
求和 (Σ[i]^N[=1]) 遍历所有 N 对输入 (x[i]) 和输出 (y[i]),并确定我们做得有多糟糕(ℓ(⋅,⋅))。这为我们提供了计算损失的方程,但并没有最小化它。最大的问题是:我们如何调整 Θ 来进行这种最小化?
我们通过梯度下降来实现这一点,这也是为什么 PyTorch 提供自动微分的原因。假设 Θ[k] 是我们想要改进的模型的 当前 状态。我们如何找到下一个状态 Θ[k + 1],希望它能减少我们模型的损失?我们想要解决的方程是 Θ[k][+1] = Θ[k] − η · 1/N Σ[i]^N[=1] ∇Θ[k]ℓ(f[Θ][k](x[i]), y[i])。再次,让我们用英语写出这个方程的含义,并将其映射回符号:

这个方程展示了被称为 梯度下降 的数学原理。它看起来几乎与我们在第一章中优化简单函数时所做的完全一样。最大的区别是那个花哨的新 ∇ 符号。这个 nabla 符号 ∇ 用于表示 梯度。在上一个章节中,我们使用 导数 和 梯度 互换,因为我们只有一个参数。由于我们现在有一组参数,梯度是我们用来指代每个参数的导数的语言。如果我们只想改变参数的选定子集 z,我们将其写为 ∇[z]。这意味着 ∇ 将是一个具有每个参数一个值的张量。
梯度 (∇) 告诉我们如何调整 Θ,就像之前一样,我们朝着相反的方向前进。重要的是要记住,PyTorch 提供了自动微分。这意味着如果我们使用 PyTorch API 和框架,我们不必担心如何计算 ∇[Θ]。我们也不必跟踪 Θ 中的所有内容。
我们需要做的只是定义我们的模型 f(⋅) 的样子以及我们的损失函数 ℓ(⋅,⋅) 是什么。这将为我们处理几乎所有的工作。我们可以编写一个执行整个过程的函数。
2.1.2 构建线性回归模型
我们描述的框架是使用梯度下降训练模型 f(⋅) 的,它具有广泛的应用性。图 2.1 中展示的迭代数据和执行这些梯度更新的过程是训练循环。使用 PyTorch 和这种方法,我们可以重新创建许多类型的机器学习方法,如线性回归和逻辑回归。要做到这一点,我们只需以正确的方式定义 f(⋅)。我们将从重新创建线性回归算法开始,这是面包和黄油算法,以介绍 PyTorch 为我们提供的代码基础设施,以便我们可以在稍后构建更大的神经网络。
首先要确保我们拥有所有需要的标准导入。从 PyTorch,这包括torch.nn和torch.nn.functional,它们提供了我们在整本书中使用的常见构建块。torch.utils.data提供了处理Datasets的工具,而idlmam提供了我们在前几章中编写的代码,随着我们的进展:
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import *
from idlmam import *
2.1.3 训练循环
现在我们有了那些额外的导入,让我们先写一个训练循环。假设我们有一个损失函数loss_func(ℓ(⋅,⋅)),它接受一个prediction(ŷ)和一个target(y),返回一个分数,表示模型(f(⋅))做得有多好。我们需要一个迭代器来为我们加载训练数据。² 这个training_loader将为我们提供与训练相关的inputs 和labels 对。
图 2.2 展示了训练循环的步骤。黄色的准备部分显示了在开始训练之前需要完成的对象创建。我们必须选择将执行所有计算(通常是 GPU)的设备,定义我们的模型 f(⋅),并为模型的参数 θ 创建一个优化器。红色区域表示循环的开始/重复,为我们提供了新的数据以进行训练。蓝色区域计算模型当前参数 Θ 的预测 ŷ 和损失 ℓ(ŷ,y)。绿色部分使用损失计算梯度和对参数 Θ 的更新以改变参数 Θ。请注意,颜色与我们在图 2.1 中看到的步骤相匹配,但提供了更多细节,显示了我们需要调用的 PyTorch 函数。

图 2.2 展示了训练循环过程图。它包括我们在最初描述的 PyTorch 中训练模型时的相同三个主要步骤,并使用匹配的颜色编码。新的是初始化在每次训练循环中重复使用的对象。实心区域表示步骤,虚线箭头表示效果。
使用 PyTorch,我们可以编写足够少的代码来训练许多不同类型的神经网络。下一个代码块中的train_simple_network函数遵循图 2.2 过程中的所有部分。首先,我们创建一个optimizer,它接受将被改变的模型的parameters() Θ。然后,我们将模型移动到正确的计算device,并重复优化过程一定数量的epochs。每个 epoch 意味着我们使用了一次每个数据点 x[i]。
每个 epoch 包括使用model.train()将模型放入训练模式。training_loader以元组(x,y)的形式提供我们的数据,我们将它们移动到相同的计算device。这些元组的内部循环使用zero_grad()清理优化器状态,然后将inputs传递给model以获取预测y_hat。我们的loss_fun接受预测y_hat和真实labels来计算一个loss,描述了我们的网络做得有多糟糕。然后我们使用loss.backward()计算梯度,并使用optimizer的step()进行一步。
简单训练循环的代码如下:
def train_simple_network(model, loss_func, training_loader, epochs=20, device="cpu"):
optimizer = torch.optim.SGD(model.parameters(), lr=0.001) ❶
model.to(device) ❷
for epoch in tqdm(range(epochs), desc="Epoch"): ❸
model = model.train() ❹
running_loss = 0.0
for inputs, labels in tqdm(training_loader, desc="Batch", leave=False): ❸
inputs = moveTo(inputs, device) ❺
labels = moveTo(labels, device) ❺
optimizer.zero_grad() ❻
y_hat = model(inputs) ❼
loss = loss_func(y_hat, labels) ❽
loss.backward() ❾
optimizer.step() ❿
running_loss += loss.item() ⓫
❶ 这里完成了黄色步骤。创建优化器并将模型移动到计算设备。SGD 是对参数Θ的随机梯度下降。
❷ 将模型放置在正确的计算资源(CPU 或 GPU)上
❸ 两个 for 循环处理红色步骤,多次(epochs)迭代所有数据(批次)。
❹ 将我们的模型置于训练模式
❺ 将数据批次移动到我们正在使用的设备上。这是最后一个红色步骤。
❻ 首先是一个黄色步骤:准备优化器。大多数 PyTorch 代码首先这样做,以确保一切都在干净和准备就绪的状态。PyTorch 将梯度存储在可变的数据结构中,因此在使用之前我们需要将其设置为干净的状态。否则,它将包含来自先前迭代的旧信息。
❼ 这行代码和下一行执行两个蓝色步骤。这行代码计算fθ。
❽ 计算损失
❾ 剩下的两个黄色步骤计算梯度并对优化器进行“.step()"操作。这行代码计算∇[Θ]。
❿ 更新所有参数 Θ[k + 1] = Θ[k] − η ⋅ ∇[Θ[k]]ℓ(ŷ,y)
⓫ 获取我们想要的信息
2.1.4 定义数据集
我们刚才描述的代码足以训练本书中设计的几乎所有神经网络。现在我们需要一些数据、一个网络和一个损失函数来工作。让我们先训练一个简单的 线性回归 模型。你应该还记得,从机器学习课程或培训中,在 回归 问题中,我们想要预测一个数值。例如,根据汽车的特征(例如,重量(磅)、发动机大小、生产年份)预测每加仑英里数(mpg)将是一个回归问题,因为 mpg 可能是 20、24、33.7,或者假设甚至可以是 178.1342 或几乎任何数字。
在这里,我们创建了一个包含线性和非线性成分的合成回归问题,并添加了一些噪声使其更有趣。由于线性成分非常强,线性模型可以做得不错,但不会完美:
X = np.linspace(0, 20, num=200) ❶
y = X + np.sin(X)*2 + np.random.normal(size=X.shape) ❷
sns.scatterplot(x=X, y=y)
[6]: <AxesSubplot:>
❶ 创建一维输入
❷ 创建输出

我们创建了一个简单的玩具问题,具有强烈的线性趋势和上下波动的小但一致的振荡。我们使用这样的玩具问题来进行实验,以便我们可以看到结果,并获得对正在发生的事情的更直观的理解。但是,我们需要以 PyTorch 能够理解的形式来表示这个问题。下一个代码块创建了一个简单的数据集对象,它知道我们有一个一维问题。训练数据以(n,1)矩阵的形式排列,其中 n 是数据点的数量。标签(y)采用类似的形式。当我们获取一个项目时,我们会抓取数据集的正确行,并返回一个 PyTorch tensor对象,它是torch.float32类型,这是 PyTorch 中大多数事物的默认类型。
除了 Dataset,我们还需要一个 DataLoader,它已经被 PyTorch 为我们实现了。Dataset 定义了如何获取任何特定的数据点,而 DataLoader 决定获取哪些数据点。标准的方法是一次随机选择一个数据点,这确保了你的模型学习的是数据而不是数据的顺序。³ PyTorch 的 DataLoader 有很多内置的功能,我们根据需要介绍其中的一些,但你可以自由地在pytorch.org/docs/stable/data.html的文档中了解更多。关于 DataLoader 最重要的是,当你的模型忙于在 GPU 上训练时,你的 DataLoader 正在忙于获取下一个数据,这样 GPU 就尽可能忙碌。(这种性能优化通常被称为 pipelining。)
这是代码:
class Simple1DRegressionDataset(Dataset):
def __init__(self, X, y):
super(Simple1DRegressionDataset, self).__init__()
self.X = X.reshape(-1,1)
self.y = y.reshape(-1,1)
def __getitem__(self, index):
return torch.tensor(self.X[index,:], dtype=torch.float32),
➥ torch.tensor(self.y[index], dtype=torch.float32)
def __len__(self):
return self.X.shape[0]
training_loader = DataLoader(Simple1DRegressionDataset(X, y), shuffle=True)
如何使用 reshape
reshape 函数的理解非常重要,因为我们在整本书中都会使用它的行为。假设我们有一个包含六个总值的张量。这可能是一个长度为 6 的向量,一个 2 × 3 矩阵,3 × 2,或者一个具有一个尺寸为“1”的三个维度的张量。只要总值的数量保持不变,我们就可以 重新解释 张量,使其具有不同的形状,值被重新排列。以下图显示了如何做到这一点。reshape 的特殊之处在于它允许我们指定除了一个维度之外的所有维度,并且它自动将剩余的值放入未指定的维度中。这个剩余的维度用 –1 表示;随着我们添加更多的维度,我们可以以更多的方式要求 NumPy 或 PyTorch 重塑张量。

有四种不同的张量形状可以用来表示相同的六个值。reshape 函数接受你想要的任何数量的轴作为结果张量的参数。如果你知道确切的尺寸,你可以指定它;或者如果你不知道一个轴的尺寸,你可以使用 –1 来表示一个剩余的位置。
view 函数也有类似的行为。为什么有两个具有相同行为的函数?因为 view 使用更少的内存并且可以更快,但如果使用不当可能会抛出错误。你将在 PyTorch 的更高级使用中学习这些细节;但为了简单起见,你可以 始终 安全地调用 reshape。
注意:如果你在你的张量中有总共 N 个项目,你可以将其重塑为任何新的维度数,只要最终的项目数加起来等于 N。所以我们可以将 N = 6 项的张量转换成形状为 (3,2),因为 3 × 2 = 6。这也意味着如果我们继续插入尺寸为 1 的维度,我们可以有 任何 数量的总维度。例如,我们可以通过执行 (3,1,1,2,1) 将 N = 6 的值转换成一个具有五个维度的张量,因为 3 × 1 × 1 × 2 × 1 = 6。
2.1.5 定义模型
到目前为止,我们已经成功创建了一个训练循环和一个用于加载数据集的 Dataset 对象。我们最后缺少的是实现线性回归算法作为网络的 PyTorch 模型。这是图 2.2 中的模型框,我在图 2.3 中将其单独隔离出来。

图 2.3 模型框表示我们想要训练的神经网络 f[Θ](∙)。这可能是一个小型网络或一个非常复杂的网络。它被封装在一个具有单一参数集 Θ 的模型对象中。几乎任何网络定义的过程都是相同的。
在这个情况下,定义我们将使用的网络非常简单。我们想要一个简单的线性函数,而线性函数意味着权重矩阵 W。将权重矩阵 W 应用到输入 x 上意味着取矩阵向量积。因此,我们想要一个看起来像这样的模型 f(⋅):

向量 x 包含了我们所有的 d 个特征(在这种情况下,d = 1),矩阵 W 对于每个特征有一行,对于每个输出有一列。我们使用 W^(d, C) 来明确指出它是一个有 d 行和 C 列的张量。这是我们在这本书中用来精确描述某些对象形状的常见符号。由于我们预测的是一个单一值,这意味着 C = 1。
注意,尽管这个线性函数并不完整。如果 x = 0,那么 f(x) = 0。这对模型来说是一个非常强的约束。相反,我们添加一个没有与 x 交互的 偏差项 b:

添加一个偏差项允许模型根据需要将其解向左或向右移动。幸运的是,PyTorch 有一个实现这种线性函数的 Module,我们可以通过 nn.Linear(d, C) 来访问它。这创建了一个具有 d 个输入和 C 个输出的线性层——这正是我们想要的。
注意:偏差项始终是 a + b 在不与其他任何东西交互的一侧。由于偏差项几乎总是被使用,但写起来很麻烦,因此它们经常被省略,并假设它们隐含存在。在这本书中我们也这样做。除非我们明确说明,否则请假设偏差是隐含存在的。如果你看到三个权重矩阵,假设有三个偏差项,每个对应一个。
模块是 PyTorch 组织现代神经网络设计构建块的方式。模块有一个 forward 函数,它接受输入并产生输出(如果我们创建自己的 Module,我们需要实现这个函数),还有一个 backward 函数(除非我们有干预的理由,否则 PyTorch 会为我们处理这个函数)。torch.nn 包中提供了许多标准模块,我们可以使用张量、Parameter 和 torch.nn.functional 对象来构建新的模块。模块也可能包含其他模块,这是我们构建更大网络的方式。
2.1.6 定义损失函数
因此 nn.Linear 给我们模型 f(x),但我们仍然需要决定一个损失函数 ℓ。同样,PyTorch 使得对于标准的回归问题,这变得非常简单。假设真实值是 y,我们的预测是 ŷ = f(x)。我们如何量化 y 和 ŷ 之间的差异?我们只需查看它们之间的绝对差异:ℓ(y,ŷ) = |y−ŷ|。
为什么是 绝对 差异?如果我们没有取绝对值,ŷ < y 将产生一个正损失,鼓励模型 f(x) 的预测更小。但如果是 ŷ > y,那么 y − ŷ 将产生一个 负 损失。如果您觉得用 y 和 ŷ 这样的符号进行推理感到害怕,试着插入一些实际值。所以如果 ŷ = 100 和 y = 1,并且我们计算损失为 y − ŷ = 1 − 100 = − 99,我们最终会得到一个损失为 –99!负损失会令人困惑(那会是什么,利润?)并且会鼓励网络在预测已经太大时进一步增大其预测。我们的目标是 ŷ = y,但由于网络会盲目地前进,试图 最小化 损失,它将学会使 ŷ 不切实际地大,以利用负损失。这就是为什么我们需要我们的损失函数始终返回零或正值。否则,损失将没有意义。请记住,损失函数是错误的惩罚,负惩罚意味着鼓励。
另一个选项是取 y 和 ŷ 之间的平方差:ℓ(y,ŷ) = (y−ŷ)²。这又导致了一个函数,仅在 y = ŷ 时为零,并且随着 y 和 ŷ 相互远离而增长。
这两种选项都在 PyTorch 中预先实现了。前者被称为 L1 损失,因为它对应于取不同(即,∥y − ŷ∥[1])的 1-范数。后者通常被称为均方误差(MSE,也称为 L2)损失,是最受欢迎的,因此我们将继续使用它:
| 损失函数 ℓ(y,ŷ) | PyTorch 模块 |
|---|---|
| |y−ŷ| | torch.nn.L1Loss |
| (y−ŷ)² | torch.nn.MSELoss |
在两个损失函数之间进行选择
您有两个损失函数,L1 和 MSE,两者都适用于回归问题。您如何选择使用哪一个?我们不会深入探讨损失函数之间细微的差异,因为 PyTorch 中内置的任何损失函数都会给出合理的结果。您只需要知道哪些损失函数适用于哪种类型的问题(如回归与分类)。
理解数学背后的含义将帮助您做出这些选择。在这种情况下,均方误差(MSE)损失具有平方项,使得大的差异变得更大(例如,100²将变为 10,000);而 L1 将保持差异不变(例如,|100| = 100)。因此,如果您试图解决的问题是小差异可以接受但大差异真的很糟糕,那么 MSE 损失可能是一个更好的选择。如果您的问题是偏离 200 比偏离 100 更糟糕两倍,那么 L1 损失更有意义。这并不是选择这两种选项的完整图景,但这是一个做出初始选择的好方法。
2.1.7 将其组合:在数据上训练线性回归模型
我们现在拥有创建线性回归所需的一切:Dataset、train_simple_network函数、loss_func ℓ和nn.Linear模型。以下代码显示了我们可以如何快速设置所有这些,并将其传递给我们的函数以训练模型:
in_features = 1
out_features = 1
model = nn.Linear(in_features, out_features)
loss_func = nn.MSELoss()
device = torch.device("cuda")
train_simple_network(model, loss_func, training_loader, device=device)
它是否有效?我们是否有训练好的模型?这很容易找到答案,尤其是这是一个一维问题。我们只需绘制我们模型对所有数据的预测。我们将使用with torch.no_grad():上下文来获取这些预测:它告诉 PyTorch,在no_grad()块的作用域内进行的任何计算,不要计算梯度。我们只希望在训练期间计算梯度。梯度计算需要额外的时间和内存,如果在预测后想要再次训练模型,可能会导致错误。因此,良好的做法是在进行预测时确保我们使用no_grad()块。以下代码块使用no_grad()来获取预测:
with torch.no_grad():
Y_pred = model(torch.tensor(X.reshape(-1,1), device=device,
➥ dtype=torch.float32)).cpu().numpy()
sns.scatterplot(x=X, y=y, color=’blue’, label=’Data’) ❶
sns.lineplot(x=X, y=Y_pred.ravel(), color=’red’, label=’Linear Model’) ❷
[10]: <AxesSubplot:>
❶ 数据
❷ 我们模型学到了什么

此代码绘制了网络的输出结果,并且它学习到了对非线性数据的好线性拟合。这正是我们要求模型做到的,结果看起来是正确的。
注意:在新数据上进行预测也称为推理。这在机器学习从业者中是常见的术语——尤其是在深度学习社区中——因为神经网络通常需要 GPU,所以部署模型是一件大事。公司通常会购买专为推理设计的 GPU,以减少内存来提高成本效益。
你现在已经使用了构建神经网络的所有机制和工具。我们描述的每个组件——损失函数ℓ、指定我们网络的Module和训练循环——都可以以零散的方式替换,以构建更强大和复杂的模型。
2.2 构建我们的第一个神经网络
现在我们已经学会了如何创建训练循环并使用梯度下降来修改模型,以便它学会解决问题。这是我们在这本书中所有学习的基础框架。要训练一个神经网络,我们只需要替换我们定义的 model 对象。关键是知道如何定义这些模型。如果我们能很好地定义一个神经网络,它应该能够捕捉和模拟数据的非线性部分。对于我们的玩具示例,这意味着获取较小的振荡,而不仅仅是较大的线性趋势。
当我们讨论神经网络和深度学习时,我们通常是在谈论 层。层是我们用来定义我们的 model 的构建块,PyTorch 的 Module 类中的大多数类实现了具有不同目的的不同层。我们刚刚构建的线性回归模型可以描述为有一个 输入 层(输入数据本身)和一个 线性层 (nn.Linear) 进行预测。
2.2.1 全连接网络的符号表示
我们的第一个神经网络将是一个简单的 前馈 全连接 神经网络。它被称为 前馈,因为每一层的输出直接流入下一层。所以每一层有一个输入和一个输出,并按顺序进行。它被称为 全连接,因为每个网络输入都与前一层的所有内容相连。
让我们从所谓的 隐藏层 开始。输入 x 被认为是 输入层,一个具有 C 个输出(C 个预测)的 ℝ^C 维输出被称为 输出层。在我们的线性回归模型中,我们实际上只有一个输入层和一个输出层。你可以把隐藏层想象成输入和输出之间的任何东西。
我们如何做到这一点?好吧,最简单的方法是在输入和输出之间再添加一个矩阵。所以,而不是

我们添加了一个新的矩阵的第二层,得到类似这样的结构:

注意矩阵维度的新的值 n。它是我们需要调整和处理的新的超参数。这意味着我们可以决定 n 的值应该是多少。它被称为 隐藏层大小 或 第一隐藏层的神经元数量。为什么是神经元?
如果你将每个中间输出作为一个 节点 并绘制表示权重的箭头,你得到的就是可以描述为 网络 的东西。这如图 2.4 所示。连接输入到隐藏节点的线条对应于一个 nn.Linear(3, 4) 层,这是一个 W^(3 × 4) 的矩阵。该矩阵的每一列对应于 n = 4 个神经元中的一个的输入或该层的输出。每一行是输入与每个输出的连接。因此,如果我们想知道第二个输入到第四个输出的连接强度,我们会索引 W[1,3]。同样地,从隐藏节点到图输出的线条是一个 nn.Linear(4, 1) 层。

图 2.4 一个简单的具有一个输入层(d = 3 个输入)、一个隐藏层(n = 4 个神经元)和一个输出层的输出层的正向全连接网络。连接只直接进入下一层,同一层中的每个节点都与前一层的每个神经元相连。
注意,连接节点/神经元彼此的所有箭头都只从左到右移动。这就是前馈属性。此外,注意同一层中的每个节点都与下一层的每个节点相连。这就是全连接属性。
这种网络解释部分灵感来源于大脑中神经元的工作方式。图 2.5 展示了神经元及其连接的简单玩具模型。左侧是一个神经元,它有许多与其他神经元相连的树突,充当输入。当其他神经元放电时,树突会接收到电信号并将这些信号携带到神经元的核(中心),该核将所有信号相加。最后,神经元从其轴突发出一个新的信号。

图 2.5 生物神经元连接的简化图。通过类比,树突是神经元之间的连接/权重,轴突携带神经元的输出结果。这是对真实神经元工作方式的松散启发和过度简化。
警告:尽管神经网络最初是受大脑中神经元连接和布线方式的启发,但不要让这种类比带你走得太远。前面的描述是一个非常简化的模型。神经网络的功能与我们关于大脑实际工作方式的有限了解相去甚远。你应该将其视为一种轻微的启发,而不是字面上的类比。
2.2.2 PyTorch 中的全连接网络
根据这些酷炫的图表,并保持只添加一个小变化的想法,我们可以依次插入两个线性层,我们将拥有我们的第一个神经网络。这就是nn.Sequential Module发挥作用的地方。这是一个接受模块列表或序列作为其输入的Module。然后它以正向方式运行该序列,使用一个Module的输出作为下一个Module的输入,直到我们没有更多的Module。图 2.6 展示了我们如何为具有三个输入和四个隐藏神经元的玩具网络这样做。

图 2.6 将概念性的前馈全连接网络转换为 PyTorch Module。nn.Sequential是一个包装器,它接受两个nn.Linear层。第一个nn.Linear定义了从输入到隐藏层的映射,第二个nn.Linear定义了从隐藏层到输出的映射。
将此应用于实践很容易,因为所有其他我们编写的代码仍然有效。以下代码创建了一个新的简单model,它是由两个nn.Linear层组成的序列。然后我们只需将model传递给相同的train_simple_network函数,并继续之前的操作。这个模型有一个输入,一个输出,以及一个包含 10 个神经元的单个隐藏层:⁴
model = nn.Sequential( ❶
nn.Linear(1, 10), ❷
nn.Linear(10, 1), ❸
)
train_simple_network(model, loss_func, training_loader)
❶ 输入“层”隐式地是输入。
❷ 隐藏层
❸ 输出层
注意:nn.Sequential类提供了在 PyTorch 中指定神经网络的最简单方法,我们在这本书的每个网络中都使用它!因此,熟悉它是值得的。最终,我们将构建更复杂的网络,这些网络不能完全描述为前馈过程。尽管如此,我们仍然会使用nn.Sequential类来帮助我们组织可以那样组织的网络的子组件。这个类基本上是你组织 PyTorch 中模型的必备工具。
现在,我们可以使用具有隐藏层的花哨神经网络进行推理,看看我们得到什么。我们使用与之前完全相同的推理代码。唯一的区别是我们的model对象,我们已经重新设计了它。你可能注意到了一个名为ravel()的新 NumPy 函数,它被用来生成绘图;使用这个函数与在 PyTorch 张量上调用reshape(-1)相同,我们之所以调用它是因为Y_pred的初始形状为(N,1):
with torch.no_grad():
Y_pred = model(torch.tensor(X.reshape(-1,1), ❶
dtype=torch.float32)).cpu().numpy()
sns.scatterplot(x=X, y=y, color=’blue’, label=’Data’) ❷
sns.lineplot(x=X, y=Y_pred.ravel(), color=’red’, label=’Model’) ❸
[12]: <AxesSubplot:>
❶ 形状为 (N, 1)
❷ 数据
❸ 我们模型学到的内容

那是什么情况?我们使我们的模型 f(⋅)变得更加复杂,训练时间更长,但效果可能相同甚至更差!一点线性代数就能解释为什么会这样。回想一下,我们定义了

这里 W[(h[1])]^(d × n) 是我们的隐藏层。由于我们有一个特征 d = 1 和 n = 10 个隐藏单元,W[(out)]^(n × C) 是我们的输出层,其中我们仍然有来自前一层的 n = 10 个隐藏单元和 C = 1 个总输出。但我们可以简化这两个权重矩阵。如果我们有一个形状为 (a,b) 的矩阵和一个形状为 (b,c) 的第二个矩阵,我们将它们相乘,我们得到一个形状为 (a,c) 的新矩阵。这意味着

因此

这与我们最初使用的线性模型是等价的。这表明添加任意数量的顺序线性层等同于使用一个线性层。线性操作产生线性操作,通常是冗余的。在代码中连续放置多个线性层是我在新手或初级从业者编写的代码中常见的一个错误。
2.2.3 添加非线性
为了获得任何形式的益处,我们需要在每一步之间引入 非线性。通过在每个线性操作之后插入一个非线性函数,我们允许网络构建更复杂的函数。我们称这种以这种方式使用的非线性函数为 激活函数。从生物学的类比来看,一个神经元线性地求和所有输入,最终 触发 或 激活,向大脑中的其他神经元发送信号。
我们应该使用什么作为我们的激活函数?我们将首先查看的两个是 sigmoid (σ(⋅)) 和 双曲正切 (tanh (⋅)) 函数,它们是原始激活函数中的两个,并且仍然被广泛使用。
tanh 函数是一个历史上流行的非线性函数。它将所有值映射到范围 [−1,1] 内:

Sigmoid 是历史悠久的非线性函数,也是 σ 符号最常被使用的场合。它将所有值映射到范围 [0,1] 内:

让我们快速绘制一下这些函数的图像。输入在 x 轴上,激活值在 y 轴上:
activation_input = np.linspace(-2, 2, num=200)
tanh_activation = np.tanh(activation_input)
sigmoid_activation = np.exp(activation_input)/(np.exp(activation_input)+1)
sns.lineplot(x=activation_input, y=activation_input,
➥ color=’black’, label="linear")
sns.lineplot(x=activation_input, y=tanh_activation,
➥ color=’red’, label="tanh(x)")
ax = sns.lineplot(x=activation_input, y=sigmoid_activation,
➥ color=’blue’, label="*σ*(*x*)")
ax.set_xlabel(’Input value x’)
ax.set_ylabel(’Activation’)
[13]: Text(0, 0.5, 'Activation')

正如承诺的那样,sigmoid 激活 (σ(x)) 将所有值映射到最小值 0 和最大值 1。tanh 函数的范围是从 -1 到 1。注意,在 0 附近的输入范围内,tanh 函数 看起来 是线性的,但随后会发散:这是可以接受的,甚至可能是期望的。从学习的角度来看,重要的是 tanh (⋅) 或 σ(⋅) 都不能被线性函数 完美 地拟合。
我们将在未来的章节中更多地讨论这些函数的性质。现在,让我们使用 tanh 函数。我们将定义一个新的模型,该模型与以下内容匹配:

我们不是直接将 nn.Linear 层堆叠在一起,而是在第一个线性层之后调用 tanh 函数。在使用 PyTorch 时,我们通常希望以一个 nn.Linear 层结束我们的网络。对于这个模型,我们有两个 nn.Linear 层,所以我们需要 2 - 1 = 1 个激活函数。这就像在我们的顺序网络规范中添加一个 nn.Tanh 节点一样简单,这是 PyTorch 内置的。让我们看看训练这个新 model 时会发生什么:
model = nn.Sequential( nn.Linear(1, 10), ❶
nn.Tanh(), ❷
nn.Linear(10, 1), ❸
)
train_simple_network(model, loss_func, training_loader, epochs=200)
with torch.no_grad():
Y_pred = model(torch.tensor(X.reshape(-1,1),
➥ dtype=torch.float32)).cpu().numpy()
sns.scatterplot(x=X, y=y, color=’blue’, label=’Data’) ❹
sns.lineplot(x=X, y=Y_pred.ravel(), color=’red’, label=’Model’) ❺
[15]: <AxesSubplot:>
❶ 隐藏层
❷ 激活函数
❸ 输出层
❹ 数据
❺ 我们模型学到的内容

数字的再现性
在本书的这一部分,你可能注意到你并不总是得到与我完全相同的结果。有时你可能会得到 非常不同 的结果。这是 正常 的,也是 可以接受的。每个神经网络的初始权重都是随机值,不同的随机值集合可能会给你不同的结果。不要因此感到害怕;习惯结果的变异性可能是有价值的。
如果你了解随机种子,你可能正在想,“如果你设置一个随机种子,知道随机结果会是什么,这难道不能解决问题吗?”这在高层次上是正确的,但不幸的是,PyTorch 中层的输出并不总是确定的。它们经常利用硬件特定的优化,这可能会略微改变结果,甚至在某些情况下从一次运行到下一次运行会有所不同。PyTorch 正在努力支持更多确定性行为,但还不是所有情况都适用,也不是所有 CUDA 版本都适用。如果我尝试启用它,可能会使你的所有东西都停止工作。所以,很遗憾,我们每次运行代码时都会得到略有不同的结果。
另一个选择是运行每个实验 5 到 15 次,并绘制带有误差线的平均值,以显示可能发生的情况的变异性。但这样,代码示例的运行时间将会是 5 到 15 倍更长。我选择只运行一次,以保持时间合理。
我们可以看到,网络现在正在学习一个非线性函数,其弯曲部分会移动和适应以匹配数据的行为。然而,这并不完美,尤其是在图表右侧输入 x 的较大值上。我们还需要比之前更多的epochs进行训练。这也是很常见的情况。这也是我们为什么在深度学习中大量使用 GPU 的原因之一:我们有更大的模型,这意味着每次更新都需要更多的计算;而且更大的模型需要更多的更新才能收敛,从而导致更长的训练时间。
通常情况下,需要学习的函数越复杂,我们就需要进行的训练轮数就越多——甚至可能需要更多的数据。然而,有许多方法可以提高我们的神经网络从数据中学习的质量和速度,这些方法我们将在本书的后续部分更详细地讨论。目前,我们的目标是学习基础知识。
2.3 分类问题
你现在已经通过扩展线性回归模型构建了你的第一个神经网络,但关于分类问题呢?在这种情况下,你有一个输入可能属于的 C 个不同的类别。例如,一辆车可能是一辆 SUV、轿车、敞篷车或卡车。正如你可能已经猜到的,你需要一个看起来像nn.Linear(n, C)的输出层,其中 n 是前一层中的隐藏单元数,C 是类别/输出的数量。如果我们有少于 C 个输出,要做出 C 个预测将是困难的。
与我们如何从线性回归走向非线性回归神经网络一样,我们也可以从逻辑回归走向非线性分类网络。回想一下,逻辑回归是分类问题中一个流行的算法,它找到一个线性解决方案来尝试分离 C 个类别。
2.3.1 分类玩具问题
在我们构建逻辑模型之前,我们需要一个数据集。在 PyTorch 中使用时,将数据加载并放入 Dataset 对象中始终是第一步和最重要的步骤。对于这个例子,我们使用 scikit-learn 的 make_moons 类:
from sklearn.datasets import make_moons
X, y = make_moons(n_samples=200, noise=0.05)
sns.scatterplot(x=X[:,0], y=X[:,1], hue=y, style=y)
[16]: <AxesSubplot:>

月亮数据集现在有 d = 2 个输入特征,散点图显示了两个类别作为圆圈和十字。这是一个很好的玩具问题,因为线性分类模型可以很好地将圆圈和十字分开,但不能 完美 解决这个问题。
为了使我们的工作更简单,我们使用内置的 TensorDataset 对象来包装我们当前的数据。这仅在我们能够将所有数据放入 RAM 中时才有效。但如果你可以,这是准备数据的最简单方法。你可以使用你喜欢的 pandas 或 NumPy 方法来加载数据,然后开始建模。
尽管如此,我们确实做了一项重要的更改。我们的标签向量 y 现在是 torch.long 类型,而不是 torch.float32 类型。为什么?因为标签现在代表类别,从 0 开始,到 C − 1 结束,以表示 C 个不同的类别。没有 0.25 类;只允许整数!因此,我们使用长数据类型(64 位整数)而不是浮点值,因为我们只关心整数。例如,如果我们的类别是 猫、鸟 和 车,我们将使用 0, 1, 2 来表示这三个类别。你可能认识这种表示方式与独热编码非常接近,其中每个类别都有自己的维度。PyTorch 在幕后为我们完成最后一步,以避免像独热编码那样无谓地表示所有非存在的类别:
classification_dataset = torch.utils.data.TensorDataset(
➥ torch.tensor(X, dtype=torch.float32), torch.tensor(y, dtype=torch.long))
training_loader = DataLoader(classification_dataset)
现在我们定义一个线性分类模型,就像我们之前做的那样。在这种情况下,我们有两个特征,我们有两个输出(每个类别一个),所以我们的模型稍微大一些。请注意,尽管 目标 向量 y 被表示为一个单独的整数,但网络有 C 个显式输出。这是因为标签是 绝对 的:每个数据点只有一个真实类别。然而,网络必须始终考虑所有 C 个类别作为潜在选项,并为每个类别分别做出预测:
in_features = 2
out_features = 2
model = nn.Linear(in_features, out_features)
2.3.2 分类损失函数
最大的问题是,我们使用什么作为损失函数?当我们做回归问题时,这个问题很容易回答。我们有两个输入,它们都是浮点值,所以我们只需相减就可以确定两个值之间的距离。
然而,这种情况是不同的。现在我们的预测是 ŷ ∈ ℝ^C,因为我们需要对我们的 C 个不同类别中的每一个进行预测。但我们的标签是整数集的一个值,y ∈ {0, 1, …, C − 1}。如果我们能定义一个损失函数 ℓ(ŷ, y),它接受一个预测向量 ŷ ∈ ℝ^C 并将其与正确的类别 y 进行比较,我们就可以重用图 2.2 中的训练循环以及我们之前定义的神经网络中的所有内容。幸运的是,这个函数已经存在,但由于它在本书的整个过程中都至关重要,因此详细讨论它是值得的。我们需要两个组件:softmax 函数和交叉熵,这两个函数结合在一起通常被称为 交叉熵损失。
虽然许多教程都乐于说“使用交叉熵”而不解释它是什么,但我们将稍微绕个弯,并解释交叉熵的机制,这样你就可以建立一个更强的心理基础。本质上是首先将一些 C 个分数(这些值可以是任何数字)转换成 C 个概率(值必须在 0 和 1 之间),然后根据真实类别 y 的概率计算损失。有许多统计论据支持为什么这样做,如果你需要多次阅读这一节或稍后回来,那也是可以的。
Softmax
首先,我们直观地希望 ŷ 中具有最大值的维度对应于正确的类别标签 y(这与使用 np.argmax 相同)。如果我们用数学表达,我们希望:

我们也希望我们的预测是一个合理的概率。为什么?假设正确的类别是 y = k,我们成功并且有 ŷ[k] 作为最大的值。这是对还是错?如果 ŷ[k] − ŷ[j] = 0.00001 呢?这个差异非常小,我们希望有一种方法告诉模型应该使差异更大。
如果我们将 ŷ 转换为概率,它们必须总和为 1。这意味着

这样我们就可以知道,当 ŷ[k] = 1 时,模型对其预测有信心,而所有其他值 j ≠ k 的结果都是 ŷ[j] = 0。如果模型不太自信,我们可能会看到 ŷ[k] = 0.9;如果它完全错误,ŷ[k] = 0。ŷ 总和为 1 的约束使得我们很容易解释结果。
但我们如何确保这一点呢?我们从最后一个 nn.Linear 层得到的值可以是任何东西,尤其是在我们刚开始训练并且还没有教会模型什么是正确的时候。我们将使用 软最大值(或 softmax)函数:它将所有东西转换成非负数,并确保这些值的总和为 1.0。具有最大值的索引 k 在之后也将具有最大的值,即使它是负数,只要其他所有索引的值都更小。较小的值也会得到较小的但非零的值。因此,softmax 给 0, 1, …, C − 1 中的每个值分配一个 [0,1] 范围内的值,这样它们的总和为 1。
以下方程定义了 softmax 函数,我在数学中将其简称为“sm”,以便使更长的方程更容易阅读:

让我们快速看一下对两个不同向量调用 softmax 的结果:


在第一种情况下,4 是最大值,因此它获得了最大的归一化分数 0.705。第二种情况(-1)是最大值,它获得了 0.844 的分数。为什么第二种情况的结果分数更大,尽管-1 比 4 小?因为 softmax 是相对的,4 只比 3 大 1;第二种情况(-1)比-4 大 3,因为差异更大,所以这种情况获得了更高的分数。
为什么叫 softmax?
在我们继续之前,我觉得解释一下为什么 softmax 函数被称为 softmax 是有帮助的。我们可以使用这个分数来计算一个“软”的最大值,其中每个值都贡献了答案的一部分。如果我们取 softmax 分数和原始值之间的点积,它大约等于最大值。让我们看看这是如何发生的:

sm(x)^⊤x 的值大约等于找到 x 的最大值。因为每个值至少贡献了答案的一部分,所以 sm(x)^⊤x ≤ max[i]x[i]也成立。所以 softmax 函数可以接近最大值,但只有在所有值都相同的情况下才等于最大值。
交叉熵
在手头有了 softmax 函数之后,我们拥有了定义分类问题良好损失函数所需的两个工具之一。我们需要的第二个工具被称为交叉熵损失。如果我们有两个概率分布 p 和 q,它们之间的交叉熵是

为什么是交叉熵?这是一个统计工具,它告诉我们如果我们使用 q 定义的分布来编码信息时,需要多少额外的信息才能编码数据实际上遵循的分布 p。这是以比特(一个字节的八分之一)来衡量的,用来编码某物所需的比特越多,p 和 q 之间的拟合就越差。这个解释省略了交叉熵函数所做的一些精确性,但给你一个高层次上的直观理解。交叉熵归结为告诉我们两个分布有多不同。
想象这是在尝试最小化成本。想象我们正在为一组人订购午餐,我们预计 70%的人会吃鸡肉,30%的人会吃火鸡(见图 2.7)。这就是预测分布 q。实际上,5%的人想要火鸡,95%的人想要鸡肉。这就是真实分布 p。在这种情况下,我们认为我们想要订购比实际需要的更多的火鸡。但如果我们订购了所有的火鸡,我们就会缺少鸡肉,并且浪费了/未使用的火鸡。如果我们知道我们实际需要什么,我们就不会有这么多剩余的食物。交叉熵只是量化这些分布差异的一种方式,这样我们就可以订购正确数量的每种东西。

图 2.7 显示了两种不同的分布:左侧的实际情况和右侧的我们的预测。为了学习,我们需要一个损失函数来精确地告诉我们这两个分布有多不同。交叉熵解决了这个问题。在大多数情况下,我们的标签将现实定义为 100%属于一个类别,而其他所有类别为 0%。
现在,结合这两个工具,我们得到了一个简单的损失函数和方法。我们首先应用 softmax 函数(sm(x)),然后计算交叉熵。如果ŷ是我们从网络输出的向量,而 y 是正确的类别索引,这简化为

这可能看起来有点神秘,但没关系。结果是简化方程式得来的,推导不是这本书的重点。我们详细地解释了这些,因为 softmax 和交叉熵函数在今天的深度学习研究中无处不在,多花点力气了解它们的作用会在书后的学习中使你的生活更容易。重要的是要知道 softmax 函数的作用(将归一化输入转换为概率)以及它可以与交叉熵一起使用来量化两个分布(概率数组)之间的差异。
我们使用这个损失函数,因为它有很强的统计基础和解释性。它确保我们可以将结果解释为概率分布。对于线性模型的情况,它导致众所周知的算法逻辑回归。
使用 softmax 后跟交叉熵是如此标准和众所周知,以至于 PyTorch 将它们集成到一个单一的损失函数CrossEntropyLoss中,它为我们执行这两个步骤。这是好的,因为手动实现 softmax 和交叉熵函数可能会导致棘手的数值稳定性问题,并且不如你想象的那么直接。
2.3.3 训练分类网络
现在我们可以训练一个模型并查看它的表现如何:
loss_func = nn.CrossEntropyLoss()
train_simple_network(model, loss_func, training_loader, epochs=50)
我们的模型训练完成后,让我们可视化结果。由于这是一个二维函数,它比我们之前的回归案例要复杂一些。我们使用等高线图来显示算法的决策面:深蓝色代表第一类,深红色代表第二类,颜色过渡表示模型置信度的降低和增加。原始数据点以各自的蓝色和橙色标记显示:
def visualize2DSoftmax(X, y, model, title=None):
x_min = np.min(X[:,0])-0.5
x_max = np.max(X[:,0])+0.5
y_min = np.min(X[:,1])-0.5
y_max = np.max(X[:,1])+0.5
xv, yv = np.meshgrid(np.linspace(x_min, x_max, num=20),
➥ np.linspace(y_min, y_max, num=20), indexing=’ij’)
xy_v = np.hstack((xv.reshape(-1,1), yv.reshape(-1,1)))
with torch.no_grad():
logits = model(torch.tensor(xy_v, dtype=torch.float32))
y_hat = F.softmax(logits, dim=1).numpy()
cs = plt.contourf(xv, yv, y_hat[:,0].reshape(20,20),
➥ levels=np.linspace(0,1,num=20), cmap=plt.cm.RdYlBu)
ax = plt.gca()
sns.scatterplot(x=X[:,0], y=X[:,1], hue=y, style=y, ax=ax)
if title is not None:
ax.set_title(title)
visualize2DSoftmax(X, y, model)

注意:请注意,我们调用 PyTorch 函数 F.softmax 来将原始输出转换为实际的概率分布。通常将输入 softmax 的值称为 logits,而输出 ŷ 称为概率。在这本书中,我们将尽量避免过多使用 logits 这个术语,但你应该熟悉它。它经常出现在人们讨论实现或方法细节的时候。
我们现在可以看到我们的模型在这组数据上的结果。总体来说,这是一项不错的工作:大多数蓝色圆圈都在蓝色区域,红色十字都在红色区域。有一个中间地带,错误正在发生,因为我们的问题不能完全用线性模型解决。
现在我们对回归问题做同样的处理:添加一个隐藏层来增加神经网络的复杂性。在这种情况下,我们添加了两个隐藏层只是为了展示这是多么容易。我随意选择了 n = 30 个隐藏单元用于两个隐藏层:
model = nn.Sequential(
nn.Linear(2, 30),
nn.Tanh(),
nn.Linear(30, 30),
nn.Tanh(), nn.Linear(30, 2),
)
train_simple_network(model, loss_func, training_loader, epochs=250)
你应该注意到这些模型开始需要一些时间来训练:当我运行这个模型时,250 个 epochs 需要 36 秒。不过,结果似乎值得等待:如果我们查看我们的数据图,我们会看到模型对于明显是圆形或十字的区域有更高的置信度。你还可以看到,随着神经网络学习两个类别之间的非线性分离,阈值开始弯曲和曲线:
visualize2DSoftmax(X, y, model)

2.4 更好的训练代码
我们现在已经成功训练了用于回归和分类问题的全连接网络。我们还有很多改进方法的空间。特别是,我们一直在同一数据集上训练和评估。这并不好:你永远不能通过在 训练 数据上测试来判断你的模型在 新 数据上的表现如何。这给了模型一个机会通过记住每个训练数据点的答案来作弊,而不是学习底层任务。当我们处理分类问题时,还有一个问题:最小化交叉熵损失并不是我们的真正目标。我们的目标是减少错误,但我们不能以 PyTorch 可以处理的方式对错误进行可微分的定义,所以我们使用了交叉熵作为代理指标。在分类问题中,每经过一个 epoch 就报告损失并不那么有帮助,因为这不是我们的真正目标。
我们将讨论我们可以对训练代码进行的一些修改,以获得更强大的工具。像所有优秀的机器学习从业者一样,我们正在创建和使用一个训练集和一个测试集。我们还将评估我们关心的其他指标,以便在训练过程中跟踪性能。
2.4.1 自定义指标
如前所述,我们关注的指标(例如,准确率)可能与我们用于训练模型的损失(例如,交叉熵)不同。这些指标可能不完全匹配的许多方式,因为损失函数必须具有可微分的属性,而我们的真正目标大多数时候并不具备这个属性。因此,我们通常会有两套评分:开发者和人理解问题的指标以及让网络理解问题的损失函数。
为了帮助解决这个问题,我们将修改我们的代码,以便我们可以传递函数来从标签和预测值计算不同的指标。我们还想知道这些指标在训练和验证数据集上的变化情况,因此我们将记录多个版本:每个数据集类型一个。
为了使我们的工作更轻松,我们将使我们的代码与 scikit-learn 库提供的多数指标兼容(scikit-learn.org/stable/modules/classes.html)。为此,让我们假设我们有一个数组y_true,它包含每个数据点的正确输出标签。我们还需要另一个数组y_pred,它包含我们模型的预测。如果我们进行回归,每个预测都是一个标量ŷ。如果是分类,每个预测都是一个向量ŷ。
我们需要一种方式让用户(也就是你)指定要评估的函数以及存储结果的地方。对于评分函数,我们可以使用一个字典score_funcs,它以指标的名称作为键,以函数引用作为值。这看起来像
score_funcs={’Acc’:accuracy_score, ’F1’: f1_score}
如果我们使用 scikit-learn 的metrics类提供的函数(见scikit-learn.org/stable/modules/model_evaluation.html)。这样,我们可以指定我们想要的任何自定义指标,只要我们实现一个score_func(y_ture, y_pred)函数。然后我们只需要一个地方来存储计算出的评分。在每个循环的每个 epoch 之后,我们可以使用另一个字典results,它将字符串作为键映射到一个list的结果。我们将使用列表,以便每个 epoch 有一个评分:
results[prefix+" loss"].append(np.mean(running_loss))
for name, score_func in score_funcs.items():
results[prefix+" "+name].append(score_func(y_true, y_pred))
如果我们只使用每个评分函数的name,我们就无法区分训练集和测试集上的评分。这很重要,因为如果存在很大的差距,可能表明过拟合,而小的差距可能表明欠拟合。因此,我们使用一个prefix来区分train和test评分。
注意:如果我们对评估很认真,我们应该只使用验证/测试性能来调整和更改我们的代码、超参数、网络架构等。这也是我们需要确保我们区分训练和验证性能的另一个原因。你不应该永远使用训练性能来决定模型的表现如何。
2.4.2 训练和测试遍历
我们正在修改我们的训练函数,以更好地支持现实生活中的工作。这包括支持一个训练 epoch,其中我们改变模型权重,以及一个测试 epoch,其中我们只记录我们的性能。确保测试 epoch永远不调整模型权重是至关重要的。
执行一次训练或评估需要大量的不同输入:
-
model—运行一个 epoch 的 PyTorchModule,代表我们的模型 f(⋅) -
optimizer—更新网络权重的对象,只有在执行训练 epoch 时才应使用 -
data_loader—DataLoader对象,它返回(输入,标签)对的元组 -
loss_func—损失函数 ℓ(⋅,⋅),它接受两个参数,即model的输出(ŷ = f(x))和标签(y),并返回用于训练的损失 -
device—执行训练的计算位置 -
results—一个字符串到列表的字典,用于存储结果,如前所述 -
score_funcs—一个用于评估model性能的评分函数字典,如前所述 -
prefix—放置在results字典中的任何分数的字符串前缀
最后,由于神经网络训练可能需要一段时间,让我们包括一个可选参数desc,为进度条提供一个描述性字符串。这将为我们提供一个函数所需的所有输入,该函数处理一个 epoch,我们可以给出以下签名:
def run_epoch(model, optimizer, data_loader, loss_func, device,
results, score_funcs, prefix="", desc=None):
在此函数的开始,我们需要分配空间来存储结果,如损失、预测和开始计算的时间:
running_loss = []
y_true = []
y_pred = []
start = time.time()
训练循环看起来几乎与迄今为止我们所使用的循环完全相同。唯一需要改变的是是否使用优化器。我们可以通过查看model.training标志来检查这一点,如果我们的模型处于训练模式(model = model.train())则该标志为True,如果处于评估/推理模式(model = model.eval())则该标志为False。我们可以在每个循环的末尾将损失和优化器调用包裹在一个if语句中:
if model.training:
loss.backward()
optimizer.step()
optimizer.zero_grad()
最后,我们需要将labels和预测y_hat分别存储到y_true和y_pred中。这可以通过调用.detach().cpu().numpy()来完成,将两者都从 PyTorch 张量转换为 NumPy 数组。然后我们只需将当前正在处理的标签列表扩展到所有标签列表中:
if len(score_funcs) > 0: labels =
labels.detach().cpu().numpy() ❶
y_hat = y_hat.detach().cpu().numpy()
y_true.extend(labels.tolist()) ❷
y_pred.extend(y_hat.tolist())
❶ 将标签和预测移回 CPU 以供后续使用
❷ 将预测结果累加到迄今为止的预测中
2.4.3 保存检查点
我们将要做的最后一个修改是保存最近完成 epoch 的简单检查点。在 PyTorch 中,可以使用torch.load和torch.save函数来完成此目的。虽然使用这些方法的方式不止一种,但我们建议使用这里所示的字典式方法,它允许我们将模型、优化器状态和其他信息都保存在一个对象中:
torch.save({
’epoch’: epoch,
’model_state_dict’: model.state_dict(),
’optimizer_state_dict’: optimizer.state_dict(),
’results’ : results
}, checkpoint_file)
第二个参数checkpoint_file是我们应该保存文件的路径。我们可以将任何可序列化的对象放入此字典以保存。在我们的情况下,我们表示训练 epoch 的数量、model状态(权重/参数 Θ)以及optimizer使用的任何状态。
我们需要能够保存我们的模型,这样当我们准备好使用它时,我们就不需要从头开始重新训练。在每个 epoch 后保存是一个更好的主意,尤其是当你开始训练可能需要几周才能完成的网络时。有时我们的代码可能在许多 epoch 后失败,或者电力故障可能会中断我们的工作。通过在每个 epoch 后保存模型,我们可以从最后一个 epoch 恢复训练,而不是从头开始。
2.4.4 将所有内容整合:更好的模型训练函数
现在我们已经拥有了构建更好的神经网络训练函数所需的一切:不仅是我们讨论过的网络(例如,全连接)几乎本书中讨论的所有网络。这个新函数的签名看起来是这样的:
def train_simple_network(model, loss_func, train_loader,
test_loader=None, score_funcs=None, epochs=50,
device="cpu", checkpoint_file=None):
参数如下:
-
model—运行一个 epoch 的 PyTorchModule,它代表我们的模型 f(⋅) -
loss_func—损失函数ℓ(⋅,⋅),它接受两个参数,即model的输出(ŷ = f(x)和labels(y),并返回用于训练的损失 -
train_loader—返回用于训练模型的(input, label)对的DataLoader对象 -
test_loader—返回用于评估模型的(input, label)对的DataLoader对象 -
score_funcs—一个用于评估model性能的评分函数字典,如前所述 -
device—执行训练的计算位置 -
checkpoint_file—一个字符串,指示保存模型检查点到磁盘的位置
这个新功能的核心内容如下所示,完整的版本可以在书中提供的 idlmam.py 文件中找到:
optimizer = torch.optim.SGD(model.parameters(), lr=0.001) ❶
model.to(device) ❷
for epoch in tqdm(range(epochs), desc="Epoch"):
model = model.train() ❸
total_train_time += run_epoch(model, optimizer, train_loader,
loss_func, device, results, score_funcs,
prefix="train", desc="Training")
results["total time"].append( total_train_time )
results["epoch"].append( epoch )
if test_loader is not None: ❹
model = model.eval()
with torch.no_grad():
run_epoch(model, optimizer, test_loader, loss_func, device,
results, score_funcs, prefix="test", desc="Testing")
❶ 执行账务和设置;准备优化器
❶ 将模型放置在正确的计算资源(CPU 或 GPU)上
❶ 将我们的模型置于训练模式
❶ 如果 checkpoint_file 不为空,则保存检查点
我们使用run_epoch函数在将模型置于正确模式后执行训练步骤,该函数记录训练结果。然后,如果提供了test_loader,我们切换到model.eval()模式并进入with torch.no_grad()上下文,这样我们就不会以任何方式修改模型,并可以检查其在保留数据上的性能。我们分别使用前缀"train"和"test"来表示训练和测试运行的结果。
最后,我们有一个新的训练函数将结果转换为 pandas DataFrame,这将使我们以后更容易访问和查看它们:
return pd.DataFrame.from_dict(results)
使用这个新的改进代码,让我们在月亮数据集上重新训练我们的模型。由于准确率是我们真正关心的,我们导入 scikit-learn 中的准确度度量。让我们包括 F1 分数度量来展示代码如何同时处理两个不同的度量:
from sklearn.metrics import accuracy_score
from sklearn.metrics import f1_score
我们还希望更好地评估并包括一个验证集。由于月亮数据是合成的,我们可以轻松创建一个新的验证数据集。与其像以前那样进行 200 个训练周期,让我们生成一个更大的训练集:
X_train, y_train = make_moons(n_samples=8000, noise=0.4)
X_test, y_test = make_moons(n_samples=200, noise=0.4)
train_dataset = TensorDataset(torch.tensor(X_train, dtype=torch.float32),
➥ torch.tensor(y_train, dtype=torch.long))
test_dataset = TensorDataset(torch.tensor(X_test, dtype=torch.float32),
➥ torch.tensor(y_test, dtype=torch.long))
training_loader = DataLoader(train_dataset, shuffle=True)
testing_loader = DataLoader(test_dataset)
我们已经有了重新训练模型所需的一切。我们将使用 model.pt 作为保存模型结果的存储位置。我们所需做的只是声明一个新的model对象并调用我们的新train_simple_network函数:
model = nn.Sequential(
nn.Linear(2, 30),
nn.Tanh(),
nn.Linear(30, 30),
nn.Tanh(),
nn.Linear(30, 2),
)
results_pd = train_simple_network(model, loss_func, training_loader,
➥ epochs=5, test_loader=testing_loader, checkpoint_file=’model.pt’,
➥ score_funcs={’Acc’:accuracy_score,’F1’: f1_score})
是时候看看一些结果了。首先,让我们看看我们是否可以加载我们的检查点model而不是使用我们已训练的模型。要加载一个model,我们首先需要定义一个新的model,它具有与原始模型相同的所有子模块,并且它们都需要相同的大小。这是必要的,以确保所有权重都匹配。如果我们保存了一个具有 30 个神经元的第二隐藏层的模型,我们需要有一个具有 30 个神经元的新的模型;否则,将会有太多或太少,并且会发生错误。
我们使用torch.load和torch.save函数的一个原因是因为它们提供的map_location参数。这为我们处理从数据到正确计算设备加载模型提供了便利。一旦我们加载了结果字典,我们就可以使用load_state_dict函数将原始模型的状态恢复到这个新对象中。然后我们可以将模型应用于数据,并看到我们得到了相同的结果:
model_new = nn.Sequential(
nn.Linear(2, 30),
nn.Tanh(),
nn.Linear(30, 30),
nn.Tanh(),
nn.Linear(30, 2),
)
visualize2DSoftmax(X_test, y_test, model_new, title="Initial Model") plt.show()
checkpoint_dict = torch.load(’model.pt’, map_location=device)
model_new.load_state_dict(checkpoint_dict[’model_state_dict’])
visualize2DSoftmax(X_test, y_test, model_new, title="Loaded Model") plt.show()

你可以很容易地看出,初始模型并不给出很好的预测,因为它的权重是随机值且未经过训练。如果你运行几次代码,你应该会看到许多略有不同但同样无用的结果。但当我们把之前的model状态加载到model_new中时,我们得到了我们预期的清晰结果。
注意:在这个例子中,我们只加载了模型的州因为只做了预测;除非你想继续训练,否则不需要优化器。如果你想继续训练,你需要在你的代码中添加一行optimizer.load_state_dict(checkpoint['optimizer_state_dict'])。
我们的新训练函数被编写为在每次周期结束后返回一个包含模型信息的 pandas DataFrame对象。这为我们提供了一些有价值的信息,我们可以轻松地可视化。例如,我们可以快速绘制训练和验证准确率作为完成周期的函数:
sns.lineplot(x=’epoch’, y=’train Acc’, data=results_pd, label=’Train’)
sns.lineplot(x=’epoch’, y=’test Acc’, data=results_pd, label=’Test’)
[29]: <AxesSubplot:xlabel='epoch', ylabel='train Acc'>

现在很容易看出,通过使用更多的数据,我们的模型在大约两个周期内达到了噪声训练数据的顶峰。提供了两个分数函数,所以让我们看看 F1 分数作为实际训练时间(秒)的函数。如果我们想要比较两个不同的模型学习速度的快慢,这将在未来更有用:
sns.lineplot(x=’total time’, y=’train F1’, data=results_pd, label=’Train’)
sns.lineplot(x=’total time’, y=’test F1’, data=results_pd, label=’Test’)
[30]: <AxesSubplot:xlabel='total time', ylabel='train F1'>

对于这个玩具数据集,F1 分数和准确率给出了非常相似的分数,因为这两个类别具有相似的行为,并且在大小上平衡。你应该注意到的更有趣的趋势是,训练准确率在上升后趋于稳定,但验证准确率随着每个训练周期的上下波动而更加崎岖。这是正常的。模型将开始过度拟合训练数据,这使得其性能看起来稳定,并在完成学习并开始记忆更具有挑战性的数据点时缓慢上升。由于验证数据是分开的,这些可能对新的数据有益或有害的小变化对模型来说是未知的,因此它无法调整以使它们始终正确。我们保留一个单独的验证或测试集是至关重要的,这样我们就可以看到模型在新的数据上实际表现的较少偏差的视图。
2.5 批量训练
如果你查看上一张图的 x 轴,当我们把 F1 分数作为训练时间的函数来绘制时,你可能注意到,只用d = 2 个特征,在仅仅 8,000 个数据点上训练一个模型几乎需要一分钟。鉴于这个漫长的训练时间,我们如何才能扩大到更大的数据集呢?
我们需要在数据批次上进行训练。数据批次只是数据的一个更大的组。假设我们有以下数据集,包含N = 4 个项目:

我们当前的代码,在一个周期内,将执行四次更新:针对数据集中的每个项目。这就是为什么它被称为随机梯度下降(SGD)。随机这个词是行话,意思是“随机的”,但通常有一些潜在的目的或无形的手在驱动这种随机性。SGD 名称中的随机部分来自于我们只使用部分洗牌后的数据来计算梯度,而不是整个数据集。由于它是洗牌的,我们每次都会得到不同的结果。
如果我们将所有 N 个数据点通过模型,并在整个数据集上计算损失,即 ∇ Σ[i]^N[=1] ℓ(f(x[i]), y[i]),我们得到的是真实梯度。这也可以通过一次性处理所有数据而不是逐个处理数据点来使我们的训练更加计算效率高。因此,我们不是将形状为(d)的向量作为输入传递给model f(⋅),而是传递一个形状为(N,d)的矩阵。PyTorch 模块默认为此情况设计;我们只需要一种方法来告诉 PyTorch 将我们的数据分组到更大的批次中。结果证明,DataLoader具有内置的此功能,通过可选的batch_size参数。如果没有指定值,则默认为batch_size=1。如果我们将其设置为batch_size=len(train_dataset),我们执行真正的梯度下降:
training_loader = DataLoader(train_dataset, batch_size=len(train_dataset),
➥ shuffle=True)
testing_loader = DataLoader(test_dataset, batch_size=len(test_dataset))
model_gd = nn.Sequential(
nn.Linear(2, 30),
nn.Tanh(),
nn.Linear(30, 30),
nn.Tanh(),
nn.Linear(30, 2),
)
results_true_gd = train_simple_network(model_gd, loss_func,
➥ training_loader, epochs=5, test_loader=testing_loader,
➥ checkpoint_file=’model.pt’,
➥ score_funcs={’Acc’:accuracy_score,’F1’: f1_score})
五个训练周期仅用了 0.536 秒。显然,一次性在更多数据上训练使我们能够从现代 GPU 提供的并行性中受益。但如果我们绘制准确率,我们会看到训练梯度下降(B=N)产生了一个不太准确的模型:
sns.lineplot(x=’total time’, y=’test Acc’, data=results_pd,
➥ label=’SGD, B=1’)
sns.lineplot(x=’total time’, y=’test Acc’, data=results_true_gd,
➥ label=’GD, B=N’)
[32]: <AxesSubplot:xlabel='total time', ylabel='test Acc'>

让我们通过一个玩具示例来解释为什么会发生这种情况。图 2.8 显示了一个我们正在优化的函数,如果我们使用梯度下降(它查看所有数据),我们采取的步骤将引导我们走向正确的方向。但是每一步都很昂贵,所以我们只能采取少数几步。这个例子显示我们总共进行了四次更新/步骤,对应于四个周期。

图 2.8 左边的图像显示了数据四个周期的梯度下降。这意味着它只能前进四步,但每一步都是正确的方向。在右边,SGD 通过仅查看一些数据在每个周期进行多次更新。这意味着每一步都是嘈杂的,并不总是指向正确的方向,但它通常是朝着有用的方向,因此 SGD 通常可以在更少的周期内朝着目标取得更多进展。
当我们使用 SGD 时,我们每个周期执行 N 次更新,因此我们得到更多更新或步骤,对于固定数量的周期。但是,由于使用每个更新仅一个数据点的随机或随机行为,我们采取的步骤是嘈杂的。它们并不总是指向正确的方向。更大的总步骤数最终使我们更接近答案,但代价是运行时间的增加,因为我们失去了同时处理所有数据的计算效率。
我们在实践中使用的解决方案是在这两个极端之间取得平衡。让我们选择一个足够大的批量大小,以便更有效地使用 GPU,但又要足够小,以便我们仍然能够在每个 epoch 中进行更多的更新。我们用 B 表示批量大小;对于大多数应用,你会发现B ∈ [32,256]是一个不错的选择。另一个好的经验法则是使批量大小尽可能大,以便可以放入 GPU 内存,并添加更多的训练 epoch,直到模型收敛。这需要做更多的工作,因为随着你开发你的网络并做出改变,你可以放入 GPU 的最大批量大小可能会改变。
注意:因为我们只使用验证数据来评估我们的模型表现,而不是更新模型的权重,所以用于验证数据的批量大小没有特定的权衡。我们只需增加批量大小到运行最快的程度即可。无论测试数据使用的批量大小如何,结果都将相同。在实践中,大多数人为了简单起见,会使用相同的批量大小进行训练和测试数据。
这里是代码:
training_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
model_sgd = nn.Sequential(
nn.Linear(2, 30),
nn.Tanh(),
nn.Linear(30, 30),
nn.Tanh(),
nn.Linear(30, 2),
)
results_batched = train_simple_network(model_sgd, loss_func,
➥ training_loader, epochs=5, test_loader=testing_loader,
➥ checkpoint_file=’model.pt’,
➥ score_funcs={’Acc’:accuracy_score,’F1’: f1_score})
现在,如果我们把结果作为时间的函数来绘制,我们会看到绿色的线给出了两者之间的最佳结果。它只运行了 1.044 秒,并且几乎达到了相同的准确度。你会发现使用这样的数据批量几乎没有缺点,并且在现代深度学习中是首选的方法。
sns.lineplot(x=’total time’, y=’test Acc’, data=results_pd, label=’SGD, B=1’)
sns.lineplot(x=’total time’, y=’test Acc’, data=results_true_gd,
➥ label=’GD, B=N’)
sns.lineplot(x=’total time’, y=’test Acc’, data=results_batched,
➥ label=’SGD, B=32’)
[35]: <AxesSubplot:xlabel='total time', ylabel='test Acc'>

练习
在 Manning 在线平台 Inside Deep Learning Exercises 上分享和讨论你的解决方案(liveproject.manning.com/project/945)。一旦你提交了自己的答案,你将能够看到其他读者提交的解决方案,并看到哪些是作者认为最好的。
-
数据的输入范围可以对神经网络产生重大影响。这适用于输入和输出,例如回归问题。尝试将 scikit-learn 的
StandardScaler应用于本章开始时的玩具回归问题的目标 y,并在其上训练一个新的神经网络。改变输出的尺度是否有助于或损害模型的预测? -
曲线下方的面积(AUC)指标在 scikit-learn 中并不遵循标准模式,因为它需要
y_pred是一个形状为(N)的向量,而不是形状为(N,2)的矩阵。为 AUC 编写一个包装函数,使其与我们的train_simple_network函数兼容。 -
编写一个新的函数
resume_simple_network,从磁盘加载checkpoint_file,恢复optimizer和model的状态,并继续训练到指定的总 epoch 数。所以如果模型在 20 个 epoch 后保存,而你指定了 30 个 epoch,它应该只进行 10 个额外的 epoch 训练。 -
在进行实验时,我们可能希望回到之前的不同时代的模型版本,特别是如果我们试图确定某些奇怪行为开始出现的时间。修改
train_simple_network函数,添加一个新参数checkpoint_every_x,该参数在每个x个时期保存一个不同文件名的模型版本。这样,你可以回退并加载特定版本,而不会用每个时期的模型填满你的硬盘驱动器。 -
深度学习的“深度”部分指的是神经网络中的层数。尝试向用于
make_moons分类问题的模型中添加更多层(最多 20 层)。更多的层如何影响性能? -
尝试更改
make_moons分类问题中隐藏层中使用的神经元数量。它如何影响性能? -
使用 scikit-learn 加载威斯康星乳腺癌数据集(
scikit-learn.org/ stable/modules/generated/sklearn.datasets.load_breast_cancer.html),将其转换为TensorDataset,然后将其分为 80%用于训练和 20%用于测试。尝试为这些数据构建自己的分类神经网络。 -
我们在
make_moons数据集上,以批大小B = {1, 32, N}看到了结果。编写一个循环,在相同的数据集上为小于 N 的每个 2 的幂次批大小训练一个新的模型(即B = {2, 4, 8, 16, 32, 64, …}),并绘制结果。你注意到准确性和/或训练时间方面有任何趋势吗?
摘要
-
train_simple_network函数抽象了细节,可以用于几乎任何神经网络。 -
CrossEntropyLoss用于分类问题,而MSE和L1损失用于回归问题。 -
nn.Linear层可以用来实现线性回归和逻辑回归。 -
完全连接的网络可以通过添加更多带有非线性的
nn.Linear层,被视为线性回归和逻辑回归的扩展。 -
可以使用
nn.SequentialModule来组织子Module以创建更大的网络。 -
通过在
DataLoader中使用batch_size选项,我们可以通过计算效率与优化步数之间的权衡。
¹ 一些机器学习研究中的常见约定:当你有一个较大的函数要优化,该函数由许多较小的项的总损失组成时,将较大的函数表示为 F,将内部函数表示为 f。这并不是一成不变的,但我喜欢使用常见的符号,这样你就可以熟悉它们——即使它们并不非常具有信息量。↩
² 你可以将所有数据放在一个巨大的数组中,但这是一种不好的做法,因为你在任何时候都将整个数据集保留在内存中。迭代器可以动态加载数据,这避免了额外的内存使用。当你处理比计算机内存大的数据集时,这是至关重要的。↩
³ 有一些很好的数学可以更好地证明这一点,但我们不会深入探讨。↩
⁴ 尺寸与图中所示不同,因为为了得到有趣的结果我需要 10 个神经元,但 10 个神经元在图中绘制过多。而且,用单个输入而不是三个输入来绘制函数也更容易。但我希望这个图能展示所有输入都连接到下一层的所有项目。这是一个必要的矛盾,对此我表示歉意。↩
3 卷积神经网络
本章涵盖
-
张量如何表示空间数据
-
定义卷积及其用途
-
构建和训练卷积神经网络(CNN)
-
通过添加池化使卷积神经网络更鲁棒
-
通过增强图像数据来提高准确性
卷积神经网络(CNNs)在 2011 年和 2012 年同时复兴了神经网络领域,并开启了深度学习的新时代。CNNs 仍然是许多深度学习最成功应用的核心,包括自动驾驶汽车、智能设备使用的语音识别系统和光学字符识别。这一切都源于卷积是一种强大而简单的工具,它帮助我们将关于问题的信息编码到网络架构的设计中。我们不是专注于特征工程,而是更多地关注我们网络的架构设计。
卷积成功的原因在于它们能够学习空间模式,这使得它们成为处理任何类似图像的数据的默认方法。当你对一个图像应用卷积时,你可以学会检测简单的模式,如水平或垂直线、颜色变化或网格模式。当你将卷积堆叠在层中时,它们开始识别更复杂的模式,建立在之前更简单的卷积之上。
本章的目标是教会你构建自己的卷积神经网络(CNN)所需的所有基础知识,以解决新的图像分类问题。首先,我们讨论了图像是如何被表示到神经网络中的。图像是二维的这一点是一个重要的结构或意义,我们将将其编码到我们组织张量数据的具体方式中。你应该始终关注你数据的结构,因为选择与结构相匹配的正确架构是提高模型准确性的最佳方式。接下来,我们将揭示卷积的神秘面纱,展示卷积如何检测简单模式,并解释为什么它们是处理图像结构数据的良好方法。然后,我们将创建一个卷积层,它可以作为前一章中使用的nn.Linear层的替代品。最后,我们将构建一些 CNN,并讨论一些提高其准确性的额外技巧。
3.1 空间结构先验信念
到目前为止,你已经知道如何构建和训练一个非常简单的神经网络。你所学的知识适用于任何类型的表格数据(也称为列式数据),其中你的数据和特征可能以电子表格的形式组织。然而,其他算法(例如随机森林和 XGBoost)通常更适合此类数据。如果你只有列式数据,你可能不想使用神经网络。
神经网络非常有用,当我们使用它们来施加先验信念时,它们开始优于其他方法。我们用非常字面的方式使用“先验信念”这个词:在我们查看数据之前,我们相信数据/问题/世界是如何工作的。具体来说,深度学习在施加结构先验方面最成功。通过我们设计网络的方式,我们向数据内在性质或结构传递了一些知识。编码到神经网络中最常见的结构类型是空间相关性(即,本章中的图像)和序列关系(例如,天气从一天到另一天的变化)。图 3.1 展示了你想要使用 CNN 的一些情况。

图 3.1 列式数据(可以放入电子表格中的数据)应使用全连接层,因为数据没有结构,全连接层不会传递先验信念。音频和图像具有与卷积神经网络观察世界的方式相匹配的空间属性,因此对于这类数据,你应该几乎总是使用卷积神经网络。听一本书很难,所以我们还是坚持使用图像而不是音频。
有几种方法可以将我们已知(或相信)的问题结构编码到神经网络中,而且这个列表还在不断增长。现在,我们将讨论占主导地位的基于图像的世界的卷积神经网络(CNN)。首先,我们需要了解如何在 PyTorch 中将图像及其结构编码为张量,这样我们才能理解卷积如何使用这种结构。之前,我们的输入没有结构。我们的数据可以用一个(N,D)矩阵来表示,其中 N 是数据点,D 是特征。我们可以重新排列特征的顺序,这样做不会改变数据的含义,因为数据组织没有结构或重要性。唯一重要的是,如果列 j 对应一个特定的特征,我们总是将那个特征的价值放在列 j 中(即,我们只需要保持一致)。
然而,图像是有结构的。像素之间存在顺序。如果你打乱了像素的顺序,你将从根本上改变图片的意义。实际上,如果你这样做,你可能会得到一个无法理解的图像。图 3.2 展示了这是如何工作的。

图 3.2 打乱你的数据将破坏你的数据结构。左:对于列式数据,打乱没有实际影响,因为数据没有特殊结构。右:当图像被打乱时,它就不再可识别。图像的结构性质在于相邻像素之间相互关联。卷积神经网络编码了这样一个想法:彼此靠近的项是相关的,这使得卷积神经网络非常适合图像。
假设我们拥有 N 张图像,每张图像的高度为 H,宽度为 W。作为一个起点,我们可能会考虑一个图像数据的矩阵形状
(N,W,H)
这给我们一个三维张量。如果我们只有黑白图像,这将是可行的。但彩色图像怎么办?我们需要在我们的表示中添加一些通道。每个通道具有相同的宽度和高度,但代表不同的感知概念。颜色通常使用红色、绿色和蓝色(RGB)通道来表示,我们通过混合红色、绿色和蓝色来创建最终的彩色图像。这如图 3.3 所示。

图 3.3 中的彩色图像由三个相同大小的子图像表示,称为通道。每个通道代表一个不同的概念,最常见的是红色、绿色和蓝色(RGB)。一般来说,每个通道代表不同位置的不同类型的特征。
为了包含颜色,我们需要向张量添加一个通道维度:
(N,C,W,H)
现在我们有一个具有结构的四维张量。通过结构,我们指的是张量的轴和访问数据的顺序具有特定的含义(我们不能随意调整它们)。如果x是一批彩色图像,x[3,2,0,0]表示,“从第四张图像(N=3)中获取左上像素(0,0)的蓝色值(C=2)。”或者,我们可以使用x[3,:,0,0]获取红色、绿色和蓝色值。这意味着我们在处理位置 i 和 j 的像素值;我们知道我们需要访问索引x[:,:,i,j]。更重要的是,我们需要了解右下角相邻像素的信息,我们可以使用x[:,:,i+1,j+1]来访问。由于输入是结构化的,无论 i 和 j 的值如何,这都是正确的。卷积使用这种方法,当卷积查看图像中位置i、j的像素时,它也可以考虑相邻像素的位置。
注意 RGB 是图像最常用的标准,但并非唯一选项。其他流行的数据表示方法使用色调、饱和度和值(HSV)以及青色、品红色、黄色和黑色(即黑色)(CMYK)。这些标准通常被称为颜色空间。
3.1.1 使用 PyTorch 加载 MNIST
尽管这已经有点陈词滥调,但我们将从无处不在的 MNIST 数据集开始探索这一切的含义。这是一个包含 0 到 9 数字的黑白图像集合;每个图像宽度为 28 像素,高度为 28 像素。PyTorch 在名为torchvision的包中提供了一个方便的加载器来处理这个数据集。如果你用 PyTorch 处理图像,你几乎肯定想使用这个包。尽管 MNIST 是一个玩具问题,但我们将在大多数章节中使用它,因为它允许我们在几分钟内运行示例,而真实数据集则需要数小时甚至数周才能完成一次运行。我已经设计这些章节,以便你将学到的方法和经验可以应用到实际问题中。
我们如下加载torchvision包:
import torchvision
from torchvision import transforms
现在我们可以使用以下代码加载 MNIST 数据集。第一个参数 ./data 告诉 PyTorch 我们希望数据存储在哪里,download=True 表示如果数据集尚未存在,则下载数据集。MNIST 有预定义的训练和测试分割,我们可以通过将 train 标志设置为 True 或 False 分别获取:
mnist_data_train = torchvision.datasets.MNIST("./data", train=True,
➥ download=True)
mnist_data_test = torchvision.datasets.MNIST("./data", train=False,
➥ download=True)
x_example, y_example = mnist_data_train[0]
type(x_example)
[5]: PIL.Image.Image
现在,你会注意到返回的数据的 type 并不是一个张量。我们得到一个 PIL.Image.Image (pillow.readthedocs.io/en/stable),因为数据集 是 图像。我们需要使用一个 transform 将图像转换为张量,这就是为什么我们从 torchvision 导入 transforms 包。我们可以简单地指定 ToTensor 转换,它将 Python Imaging Library (PIL) 图像转换为 PyTorch 张量,其中最小可能值是 0.0,最大值是 1.0,因此它已经在我们可工作的良好数值范围内。让我们现在重新定义这些数据集对象来做这件事。只需在方法调用中添加 transform=transforms.ToTensor() 即可,如下所示,其中我们加载训练和测试分割并打印训练集第一个示例的形状:
mnist_data_train = torchvision.datasets.MNIST("./data", train=True,
➥ download=True, transform=transforms.ToTensor())
mnist_data_test = torchvision.datasets.MNIST("./data", train=False,
➥ download=True, transform=transforms.ToTensor())
x_example, y_example = mnist_data_train[0]
print(x_example.shape)
torch.Size([1, 28, 28])
我们已经从数据集中获取了一个单独的示例,它对于 C = 1 个通道(它是黑白图像)的宽度和高度为 28 像素,形状为 (1,28,28)。如果我们想可视化一个图像的张量表示,该图像是灰度的,imshow 期望它只有宽度和高度(即形状为 (W,H))。imshow 函数还需要我们明确地告诉它使用灰度。为什么?因为 imshow 是为更广泛的科学可视化设计的,你可能希望有其他选项:
imshow(x_example[0,:], cmap=’gray’)
[7]: <matplotlib.image.AxesImage at 0x7f6a1fea3090>

好的,很明显,那是一个数字 5。由于我们正在学习图像如何表示为张量,让我们做一个彩色版本。如果我们把相同的数字堆叠在顶部,我们将得到一个形状为 (3,28,28) 的张量。因为张量的 结构 有意义,这立即使它成为一个彩色图像,因为它有三个通道。下面的代码正是这样做的,将第一张灰度图像堆叠三次并打印其形状:
x_as_color = torch.stack([x_example[0,:], x_example[0,:], x_example[0,:]],
➥ dim=0)
print(x_as_color.shape)
torch.Size([3, 28, 28])
现在我们来可视化彩色版本。这里我们需要稍微小心一点。在 PyTorch 中,一个图像表示为 (N,C,W,H),²,但 imshow 期望一个单独的图像为 (W,H,C)。因此,在使用 imshow 时我们需要对维度进行 置换。如果我们的张量有 r 个维度,permute 函数需要 r 个输入:原始张量在 新顺序 中出现的索引 0, 1, …, r − 1。由于我们的图像当前是 (C,W,H),保持这个顺序意味着 (0,1,2)。我们希望索引 0 的通道成为最后一个维度,宽度优先,高度其次,即 (1,2,0)。让我们试试:
imshow(x_as_color.permute(1,2,0))
[9]: <matplotlib.image.AxesImage at 0x7f6b681c60d0>

为什么这张彩色图像仍然是黑白的?因为原始图像是黑白的。我们在红色、绿色和蓝色通道中复制了相同的值,这就是如何在彩色图像中表示黑白图像。如果我们清零红色和蓝色通道,我们得到一个绿色的数字:
x_as_color = torch.stack([x_example[0,:], x_example[0,:], x_example[0,:]])
x_as_color[0,:] = 0 ❶
x_as_color[2,:] = 0 ❷
imshow(x_as_color.permute(1,2,0))
[10]: <matplotlib.image.AxesImage at 0x7f6a1fc8b810>
❶ 没有红色。我们让绿色保持不变。
❷ 没有蓝色

改变这张图像的颜色是
-
不同的通道如何影响数据所代表的内容
-
这意味着这是一种结构化的数据表示
为了确保这两点清晰,让我们将三幅不同的图像堆叠成一幅彩色图像。我们将重复使用堆叠中第一幅图像中的相同数字 5。它将进入红色通道,因此我们应该看到红色 5 与两个其他数字混合,分别是绿色和蓝色:
x1, x2, x3 = mnist_data_train[0], mnist_data_train[1],
➥ mnist_data_train[2] ❶
x1, x2, x3 = x1[0], x2[0], x3[0] ❷
x_as_color = torch.stack([x1[0,:], x2[0,:], x3[0,:]], dim=0)
imshow(x_as_color.permute(1,2,0))
[11]: <matplotlib.image.AxesImage at 0x7f6a1fc00650>
❶ 捕获 3 个图像
❷ 丢弃标签

您应该看到一个红色的 5,一个绿色的 0 和一个蓝色的 4。在两幅图像重叠的地方,颜色混合了,因为它们被放置在不同的颜色通道中。例如,在中间,4 和 5 相交,红色+蓝色=紫色。数据顺序有意义,我们不能随意重新排序,否则可能会破坏结构,从而破坏数据。
让我们更明确地看看这个问题。如果我们在一个通道内打乱数据,会发生什么?它是否仍然具有相同的重要结构意义?让我们最后一次看看数字 5,但随机打乱张量中的值:
rand_order = torch.randperm(x_example.shape[1] * x_example.shape[2])
x_shuffled = x_example.view(-1)[rand_order].view(x_example.shape)
imshow(x_shuffled[0,:], cmap=’gray’)
[11]: <matplotlib.image.AxesImage at 0x7f6a1fb72cd0>

如您所见,这完全改变了图像的意义。它不再是 5,实际上什么都没有。一个值的地理位置及其邻近值本质上是该值意义的一部分。一个像素的值不能与其邻居分离。这是我们试图在本章中捕捉的结构化空间先验。现在我们已经学会了如何用张量表示图像的结构,我们可以学习卷积来利用这种结构。
3.2 卷积是什么?
现在我们有了像图像一样的数据形状,我们该改变什么?我们希望在模型中放入一个先验:空间关系。卷积编码的先验是相邻的事物相关,而远离的事物没有关系。想想上一节中数字 5 的图片。选择任何一个黑色像素:它的大多数邻近像素也是黑色的。选择任何一个白色像素:它的大多数邻居是白色或白色色调。这是一种空间相关性。这实际上并不重要发生在图像的哪个位置,因为这种图像的本质使得它倾向于无处不在发生。
卷积是一个有两个输入的数学函数。卷积接受一个输入图像和一个滤波器(也称为核),并输出一个新图像。目标是让滤波器从输入中识别某些模式并在输出中突出显示它们。卷积可以用于对具有 r 维的任何张量施加空间先验;图 3.4 中展示了简单的一个例子。现在,我们只是试图了解卷积的作用——我们稍后会了解它是如何工作的。

图 3.4 卷积的示例。输入图像和滤波器通过卷积结合。输出是一个经过修改的新图像。滤波器的目的是识别或识别输入中的某些模式。在这个例子中,滤波器识别了数字 7 顶部的水平线。
虽然我们一直在提到图像,但卷积并不局限于只处理二维数据。为了帮助我们理解卷积是如何工作的,我们将从一个一维例子开始,因为这使数学更容易理解。一旦我们理解了一维卷积,我们用于图像的二维版本将很快跟上。因为我们想要创建多个卷积层,我们还将学习填充,这对于此目的来说是必要的。最后,我们将讨论权重共享,这是一种在本书中重新出现的关于卷积的不同思考方式。
3.2.1 一维卷积
要了解卷积是如何工作的,我们先从一维图像说起,因为在一维中比在二维中更容易展示细节。一维图像的形状为(C,W),代表通道数和宽度。没有高度,因为我们只讨论一维,而不是二维。对于一个形状为(C,W)的一维输入,我们可以定义一个形状为(C,K)的滤波器。我们可以选择 K 的值,并且需要 C 在图像和滤波器之间匹配。由于通道数必须始终匹配,我们简称这种滤波器为“大小为 K 的滤波器”。如果我们将大小为 K 的滤波器应用于形状为(C,W)的输入,我们得到一个形状为(C,W−2⋅⌊K/2⌋)的输出。³ 让我们看看图 3.5 中它是如何工作的。

图 3.5 一个一维输入“1, 0, 2, –1, 1, 2”与滤波器“1, 0, –1”进行卷积。这意味着我们取输入项的每个长度为三的子序列,将这些项与滤波器值相乘,然后将结果相加。
左边是一个形状为(1,6)的输入,我们正在应用一个大小为 3 的过滤器,其值为[1,0,−1]。输出在右边。对于每个输出,你可以看到箭头指向该输出对应的空间相关输入。所以对于第一个输出,− 1,只有前三个输入是相关的;输入中的其他内容不会影响该特定输出。其值是通过将前三个输入与核中的三个值相乘然后求和来计算的。输出的第二个值是通过使用第二组三个输入值来计算的。注意,它总是使用过滤器中的相同三个值,应用于输入的每个位置。以下代码展示了如何在原始 Python 中实现这一点:
filter = [1, 0, -1]
input = [1, 0, 2, -1, 1, 2]
output = []
for i in range(len(input)-len(filter)): ❶
result = 0
for j in range(len(filter)): ❷
result += input[i+j]*filter[j]
output.append(result) ❸
❶ 将过滤器滑过输入
❷ 在此位置应用过滤器
❸ 输出已准备好使用。
实际上,我们在输入的每个位置上滑动过滤器,计算每个位置的一个值,并将其存储在输出中。这就是卷积的含义。由于输入边缘的值不足,输出的大小减少了 2 ⋅ ⌊3/2⌋。接下来,我们将展示如何在 2D 中实现这一点,然后我们将拥有 CNN 的基础。
3.2.2 2D 卷积
随着我们张量中维度 r 数量的增加,卷积的概念及其工作方式保持不变:我们在输入周围滑动一个过滤器,将过滤器中的值与图像的每个区域相乘,然后求和。我们只需使过滤器形状相应匹配。让我们看看一个与我们将尝试处理的图像相一致的 2D 示例:图 3.6 介绍了⊛运算符,它表示卷积。

图 3.6 一个数字 1 与 2D 过滤器卷积的图像。绿色区域显示了当前正在卷积的图像部分,过滤器中的值以浅蓝色显示。输出中的结果显示为橙色。通过将绿色/橙色区域在整个图像上滑动,你将产生输出结果。
再次,2D 输出是通过在每个位置将过滤器值(成对)相乘然后求和得到的。输入中突出显示的区域用于创建输出中的值。在输入单元格的右下角,你可以看到我们正在乘以的过滤器值。在深度学习中,我们几乎总是使用方形过滤器,这意味着过滤器的所有 r 维都有相同数量的值。因此,在这种情况下,我们可以称之为大小为 K 的 2D 过滤器,或者简称为 K。在这种情况下,K = 3。2D 卷积的代码将循环次数加倍:
filter = [[0, 1, 0], [0, 1, 0], [0, 1, 0]]
input = [[0,0,1,1,0,0],
[...],
[0,1,1,1,1,1]
]
height, width = len(input), len(input[0])
output = []
for i in range(height-len(filter)): ❶
row_out = [] for j in range(width-len(filter)): ❷
result = 0 for k_i in range(len(filter)): ❸
for k_j in range(len(filter)):
result += input[i+k_i][j+k_j]*filter[k_i][k_j]
row_out.append(result) ❹
output.append(row_out) ❺
❶ 将过滤器滑过行
❷ 将过滤器滑过列
❸ 在此位置应用过滤器
❹ 构建输出行
❺ 将行添加到最终输出。输出已准备好使用。
由于这个二维输入的形状为 (1,6,6) 且核的形状为 (1,3,3),我们将宽度和高度缩小 2 ⋅ ⌊3/2⌋ = 2。这意味着高度是 6 像素 − 2 = 4 像素,我们得到的宽度结果相同:6 − 2 = 4 像素宽。我们现在有了 CNN 图像分类的基础操作。
3.2.3 填充
注意,每次我们应用卷积时,输出都会比原始输入更瘦更长。这意味着如果我们不断地应用卷积,最终我们将一无所有。这不是我们想要的,因为我们将会创建多个卷积层。大多数现代深度学习设计实践都保持输入和输出大小相同,这样我们就可以更容易地推理我们网络的形状,并且可以将其做得尽可能深,而不用担心输入会消失。解决方案称为填充。你应该几乎总是默认使用填充,这样你就可以在不改变张量形状的情况下更改你的架构。图 3.7 展示了这对于相同的二维图像是如何工作的。

图 3.7 与之前相同的卷积,具有相同的输入和滤波器——但是输入被填充了值为 0 的像素,使图像在每个方向上变大一个像素。这导致输出图像的宽度和高度与原始图像相同。
我们在图像周围添加了一行/列的零,并像它实际上更大一样处理它。这个特定的例子称为零填充一次,因为我们向图像的所有边缘添加了一个值,而这个值被填充为 0。如果我们使用大小为 K 的卷积滤波器,我们可以使用 ⌊K/2⌋ 的填充来确保我们的输出保持与输入相同的大小。再次强调,尽管高度和宽度可以填充到不同的程度,但我们通常在每个维度上使用相同的填充量,因为我们的滤波器在每个维度上具有相同的大小。
3.2.4 权重共享
另一种思考卷积的方法,引入了一个重要的概念,称为权重共享。让我们再次看看一维情况,因为它更容易编写代码。想象你有一个神经网络 fΘ,其参数(或权重)为 Θ,它接收一个具有 K 个特征的输入向量 z,z ∈ ℝ^K。现在假设我们有一个更大的输入 x,具有 C = 1 个通道和 D 个特征,其中 D > K。我们不能在 x 上使用 fΘ,因为形状 D ≠ K 不匹配。
我们可以将网络 fΘ 应用到这个更大的数据集的一种方法是将网络滑动到输入的切片上,并共享每个位置的权重 Θ。一些 Python 伪代码如下所示:
x = torch.rand(D) ❶
output = torch.zeros(D-K//2*2)
for i in range(output.shape[0]):
output[i] = f(x[i:i+K], theta)
❶ 一些输入向量
现在,如果我们定义我们的网络为f = nn.Linear(K, 1),它将实现精确的 1D 卷积。这个洞察力可以教会我们一些关于卷积的重要性质以及如何使用它们来设计深度神经网络。目前,这教会我们的主要事情是,卷积是线性操作,在空间上工作。就像上一章中的nn.Linear层一样,一个卷积后面跟着第二个卷积相当于一个稍微不同的卷积。这意味着:
-
永远不要重复卷积,因为这样做是多余的。
-
在使用卷积后包含一个非线性激活函数。
注意:如果我们有一个大小为(1,3,5)的矩形核,输出图像的宽度将是输入宽度的 2 ⋅ ⌊3/2⌋ = 2 个像素更小。输出图像的高度将是 2 ⋅ ⌊5/2⌋ = 4,每边减去两个像素。虽然矩形核是可能的,但很少使用。同时请注意,我们一直使用的是奇数大小的核——没有能被 2 整除的。这主要是因为表示上的方便,因为过滤器使用输入的精确中心来产生每个输出。像矩形核一样,偶数大小的过滤器也是可能的,但很少使用。
3.3 卷积如何有利于图像处理
我们已经花费了很多时间讨论卷积是什么。现在,是时候看看它们能做什么了。卷积在计算机视觉应用中有着丰富的历史;只要我们选择合适的核,这个简单的操作就可以定义许多有用的东西。
首先,让我们再次查看 MNIST 中数字 4 的特定图像。我们加载 SciPy 的convolve函数并定义img_index,这样你就可以更改你正在处理的图像,并看到这些卷积在其他输入上的工作方式:
from scipy.signal import convolve
img_indx = 58 img =
mnist_data_train[img_indx][0][0,:]
plt.imshow(img, vmin=0, vmax=1, cmap=’gray’)
[13]: <matplotlib.image.AxesImage at 0x7f6a1f963b50>

一种常见的计算机视觉操作是对图像进行模糊。模糊涉及取局部平均像素值并用其邻居的平均值替换每个像素。这可以用来消除小的噪声伪影或软化锐利的边缘。这是通过一个模糊核完成的,其中

我们可以将这个数学公式直接转换为代码。矩阵是通过np.asarray调用的,我们已经加载了图像,卷积操作⊛是通过convolve函数完成的。当我们展示输出图像时,我们得到数字 4 的模糊版本:
blur_filter = np.asarray([[1,1,1],
[1,1,1],
[1,1,1]
])/9.0
blurry_img = convolve(img, blur_filter)
plt.imshow(blurry_img, vmin=0, vmax=1, cmap=’gray’)
plt.show()

卷积的一个特别常见的应用是执行 边缘检测。在任何计算机视觉应用中,知道边缘在哪里都是很好的。它们可以帮助你确定道路的边缘在哪里(你希望你的车保持在车道内)或者找到物体(不同形状的边缘容易识别)。在这个 4 的例子中,边缘是数字的轮廓。所以如果图像局部区域的所有像素都是 相同 的,我们希望所有内容都相互抵消,结果没有输出。我们只希望在存在局部变化时才有输出。再次强调,这可以描述为一个核,其中当前像素周围的所有内容都是负的,中心像素的权重与所有邻居相同:

此过滤器在像素周围所有内容都与自身不同时达到最大值。让我们看看会发生什么:
edge_filter = np.asarray([[-1,-1,-1], ❶
[-1, 8,-1],
[-1,-1,-1]
])
edge_img = convolve(img, edge_filter)
plt.imshow(edge_img, vmin=0, vmax=1, cmap=’gray’)
plt.show()
❶ 我们可以通过关注像素与其邻居之间的差异来找到边缘。

正如承诺的那样,滤波器找到了数字的边缘。响应只发生在边缘,因为那里变化最大。在数字外部没有响应,因为中心的高权重抵消了所有邻居,这在数字的 内部 区域也是正确的。因此,我们现在已经找到了图像中的所有边缘。
我们还可能想要寻找特定角度的边缘。如果我们将自己限制在 3 × 3 核,最容易找到水平和垂直边缘。让我们通过使滤波器水平方向上的核值改变符号来创建一个用于水平边缘的滤波器:

h_edge_filter = np.asarray([[-1,-1,-1], ❶
[0, 0,0],
[1, 1, 1]
])
h_edge_img = convolve(img, h_edge_filter)
plt.imshow(h_edge_img, vmin=0, vmax=1, cmap=’gray’)
plt.show()
❶ 我们可以只寻找水平边缘。

我们只识别了图像的水平边缘,主要是 4 的底部横条。随着我们定义更有用的核,你可以想象我们如何组合 组合 滤波器来开始识别更高级的概念。想象一下,如果我们只有垂直和水平滤波器:我们无法分类所有 10 个数字,但图 3.8 显示了我们可以如何缩小答案。

图 3.8 仅具有垂直和水平边缘滤波器的示例。如果只有垂直滤波器开启,我们可能正在查看数字 1。如果只有水平滤波器开启,我们可能正在查看数字 7,但我们希望有一个额外的对角线滤波器来帮助我们确认。如果水平和垂直滤波器都响应,我们可能正在查看数字 4 或 9;没有更多的滤波器很难判断。
在许多年代中,手动设计所有可能需要的过滤器是计算机视觉的一个重要部分。在卷积之上进行卷积也可以识别更大的概念:例如,在我们识别了水平和垂直边缘之后,一个新的过滤器可能将这些作为输入,并寻找中心有一个水平边缘在上、侧面有一个垂直边缘的空隙。这将得到一个类似 O 的形状,可以区分 9 和 4。
然而,多亏了深度学习,我们不需要费尽心思去想象和测试我们可能需要的所有过滤器。相反,我们可以让神经网络自己学习过滤器。这样,我们节省了自己繁重的过程,并且核函数针对我们关心的特定问题进行了优化。
3.4 实践应用:我们的第一个 CNN
既然我们已经讨论了什么是卷积,让我们看看一些数学符号和 PyTorch 代码。我们看到了我们可以取一个图像 I ∈ ℝ^(C, W, H) 并使用一个过滤器 g ∈ R^(C, K, K) 进行卷积,以得到一个新的结果图像 ℝ^(W′, H′)。我们可以用数学公式表示为

这意味着每个过滤器都会单独查看其上的所有 C 输入通道。图 3.9 展示了使用 1D 输入示例,因为它更容易可视化。

图 3.9 1D 卷积具有两个通道的示例。因为有两个通道,1D 输入看起来像一个矩阵,有两个轴。过滤器也有两个轴,每个通道一个。这个过滤器从上到下滑动,并产生一个输出通道。一个过滤器总是产生一个输出通道,无论有多少输入通道。
因为输入的形状为 (C,W),所以过滤器的形状为 (C,K)。因此,当输入有多个通道时,核将分别为每个通道单独有一个值。这意味着对于彩色图像,我们可以在一个操作中找到一个过滤器,它寻找“红色水平线、蓝色垂直线,没有绿色”。但这也意味着在应用 一个过滤器 之后,我们得到 一个输出。
3.4.1 使用多个过滤器构建卷积层
考虑到前面的示例,我们可能需要不止一个过滤器。我们想要 C[out] 个不同的过滤器;让我们用 C[in] 来表示输入中的通道数。在这种情况下,我们有一个表示所有过滤器的张量 G ∈ ℝ^(C[out], C[in], K, K),因此当我们写 R = I ⊛ G 时,我们得到一个新的结果 R ∈ ℝ^(K, W′, H′)。
我们如何将这个数学符号转换为使用多个过滤器 G 对输入图像 I 进行卷积?PyTorch 提供了nn.Conv1d、nn.Conv2d和nn.Conv3d函数来处理这个问题。这些函数中的每一个都实现了一个卷积层,用于一维、二维和三维数据。图 3.10 展示了作为一个机械过程正在发生的事情。

图 3.10 nn.Conv2d函数定义了一个卷积层,并在三个步骤中工作。首先,它接受一个输入图像,该图像具有C[in]通道。作为构建的一部分,nn.Conv2d接受要使用的过滤器数量:C[out]。每个过滤器依次应用于输入,并将结果组合。因此,输入张量的形状是(B,C[in],W,H),输出张量的形状为(B,C[out],W,H)。
所有三种标准卷积大小都使用相同的过程:Conv1d处理形状为(Batch, Channels, Width)的张量,Conv2d处理形状为(Batch, Channels, Width, Height)的张量,而Conv2d处理形状为(Batch, Channels, Width, Height, Depth)的张量。输入中的通道数是C[in],卷积层由C[out]个过滤器/核组成。由于每个过滤器产生一个输出通道,因此该层的输出具有C[out]个通道。值 K 定义了正在使用的核的大小。
3.4.2 使用每层多个过滤器
为了帮助大家更好地理解这个过程,让我们深入探讨并详细展示整个过程的全部步骤。图 3.11 展示了输入图像(C[in] = 3,C[out] = 2,K = 3)的所有步骤和数学计算。

图 3.11 示例展示了如何将卷积应用于单个图像的多个通道。左:输入图像,红色显示填充。中:两个过滤器(W[1]和W[2])的参数(及其相关的偏置项,b[1],b[2])。每个过滤器依次与输入进行卷积以产生一个通道。右:结果堆叠以创建一个新图像,该图像具有两个通道,每个通道对应一个过滤器。这个过程由 PyTorch 的nn.Conv2d类为我们完成。
输入图像具有C[in] = 3 个通道,我们将使用nn.Conv2d(C_in, C_out, 3, padding=3//2)(x) = output对其进行处理。由于C[out] = 2,这意味着我们使用两个不同的过滤器处理输入,为每个位置添加偏置项,并得到两个具有与原始图像相同高度和宽度的结果图像(因为我们使用了填充)。由于我们指定C[out] = 2,结果被堆叠成一个具有两个通道的更大的单张图像。
3.4.3 通过展平将卷积层与线性层混合
当我们有一个全连接层时,我们为单个隐藏层(n 个隐藏单元/神经元)编写了如图 3.12 所示的图。我们用来描述一个具有一个卷积隐藏层的网络的数学符号非常相似:


图 3.12 全连接网络中的一个隐藏层。左:与右侧方程匹配的 PyTorch Module。颜色显示哪个Module映射到方程的哪个部分。
那个方程可能看起来有点吓人,但如果我们放慢速度,其实并不那么糟糕。它之所以令人畏惧,是因为我们在方程中包含了每个张量的形状,并标注了我们的新 nn.Conv2d module 如何发挥作用。我们包含形状信息是为了让您了解我们如何处理不同大小的输入——如果我们去掉那些额外细节,它看起来就不会那么吓人:
f(x) = tanh(x⊛W((*h*[1]))+**b**((h[1])))W^((out)) + b^((out)) (3.1)
现在很清楚,我们唯一改变的是将点积(一个由 {}^⊤ 表示的线性操作)替换为卷积(一个由 ⊛ 表示的空间线性操作)。
这几乎是完美的,但我们有一个问题:卷积的输出形状为 (C,W,H),但我们的线性层 (W^((out)) + b^((out))) 预期的是形状为 (C×W×H) 的东西——那就是 一个 维度,其中包含了所有三个原始维度。本质上,我们需要将卷积的输出 重塑,以去除空间解释,这样我们的线性层就可以处理结果并计算一些预测;参见图 3.13。

图 3.13 卷积网络中的一个隐藏层。左:与右侧方程匹配的 PyTorch Module。颜色显示哪个 Module 映射到方程的哪个部分。
这被称为 展平,是现代深度学习中的一种常见操作。我们可以认为上一页的方程 3.1 隐含地使用了这种展平操作。PyTorch 提供了一个名为 nn.Flatten 的 module 来执行此操作。我们通常将带有隐式偏差项的 f(x) = tanh (x⊛W((*h*[1])))**W**((out)) 写作。
3.4.4 PyTorch 首个 CNN 的代码
让我们最终定义一些用于训练基于 CNN 的模型的代码。首先,我们需要获取 CUDA 计算设备并为训练集和测试集创建一个 DataLoader。我们使用批大小 B 为 32:
if torch.cuda.is_available():
device = torch.device("cuda")
else:
device = torch.device("cpu")
B = 32
mnist_train_loader = DataLoader(mnist_data_train, batch_size=B, shuffle=True)
mnist_test_loader = DataLoader(mnist_data_test, batch_size=B)
现在我们将定义一些变量。同样,由于 PyTorch 在数据批次中工作,当我们从 PyTorch 的角度思考时,我们的张量形状以 B 开头;由于输入由图像组成,初始形状是 (B,C,W,H)。我们定义了 B = 32,因为我们定义了它,C = 1,因为 MNIST 是黑白图像。我们将定义一些辅助变量,如 K 来表示我们的滤波器大小,以及 filters 来表示我们想要构建的滤波器数量。
第一个模型是 model_linear,因为它只使用了 nn.Linear 层。它从调用 nn.Flatten() 开始。注意我们在代码中放入的特定注释 #(B, C, W, H) -> (B, C*W*H) = (B,D):这是为了提醒我们,我们正在使用这个操作改变张量的形状。原始形状 (B,C,W,H) 在左边,新的形状 (B,C×W×H) 在右边。由于我们有变量 D 来表示特征的总数,我们还包括了一个关于它等于什么值的注释:=(B,D)。在编写代码时,很容易丢失张量的形状,这是引入错误的最简单方法之一。当张量的形状被改变时,我总是包括这样的注释。
D = 28*28 ❶
C = 1 ❷
classes = 10 ❸
filters = 16 ❹
K = 3 ❺
model_linear = nn.Sequential( ❻
nn.Flatten(), # (B, C, W, H) -> (B, C*W*H) = (B,D)
nn.Linear(D, 256),
nn.Tanh(),
nn.Linear(256, classes),
)
model_cnn = nn.Sequential( ❼
nn.Conv2d(C, filters, K, padding=K//2), ❽
nn.Tanh(), ❾
nn.Flatten(), ❿
nn.Linear(filters*D, classes),
)
❶ 我们使用输入中的值数来帮助确定后续层的大小:28 * 28 图像。
❷ 输入中有多少个通道?
❸ 有多少个类别?
❹ 我们应该使用多少个过滤器?
❺ 我们应该使用多大的过滤器?
❻ 为了比较,让我们定义一个类似复杂性的线性模型。
❼ 简单的卷积网络。Conv2d 按照模式 Conv2d(输入通道数,输出通道数,滤波器大小)。
❽ x ⊛ G
❾ 激活函数作用于任何大小的张量。
❿ 从 (B, C, W, H) 转换为 (B, D),这样我们就可以使用线性层。
model_linear 是一个简单的全连接层,我们可以用它来比较。我们的第一个 CNN 由 model_cnn 定义,我们使用 nn.Conv2d 模块输入一个卷积。然后我们可以像以前一样应用我们的非线性激活函数 tanh。我们只有在准备好使用 nn.Linear 层将张量减少为每个类的一组预测时才对张量进行一次展平。这就是为什么 nn.Flatten() 模块出现在调用 nn.Linear 之前的原因。
CNN 的表现是否优于全连接模型?让我们来看看。我们可以训练一个 CNN 和一个全连接模型,在测试集上测量精度,并查看每个时期的精度:
loss_func = nn.CrossEntropyLoss()
cnn_results = train_simple_network(model_cnn, loss_func,
➥ mnist_train_loader, test_loader=mnist_test_loader,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device, epochs=20)
fc_results = train_simple_network(model_linear, loss_func,
➥ mnist_train_loader, test_loader=mnist_test_loader,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device, epochs=20)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_results, label=’CNN’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_results,
➥ label=’Fully Connected’)
[20]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

我们的 CNN 训练的一个时期比我们全连接网络所能达到的 更好精度。虽然我们的 CNN 训练速度大约慢了 1.138 倍,但结果是非常值得的。为什么它表现得如此出色?因为我们通过域的结构(数据由图像组成)向网络提供了关于问题的信息(卷积)。这并不意味着 CNN 总是更好的:如果 CNN 的假设不真实或不准确,它们将不会表现良好。记住,卷积传递了这样的先验信念:彼此靠近的事物是相关的,而彼此远离的事物则不是相关的。
3.5 添加池化以减轻物体移动
与前馈网络一样,我们可以通过堆叠更多带有非线性插入的层来使卷积网络更强大。但在我们这样做之前,我们喜欢与 CNN 一起使用的特殊类型的层,称为 池化 层。
池化帮助我们解决了我们没有充分利用数据空间性质的问题。这可能看起来有些令人困惑:我们刚刚通过简单地切换到nn.Conv2d显著提高了准确性,并且我们花了大量时间讨论卷积如何通过在输入上滑动一组权重并在每个位置应用它们来编码这种空间先验。问题是,我们最终切换到使用全连接层,它不理解数据的空间性质。因此,nn.Linear层学会在非常特定的位置寻找值(或对象)。
对于 MNIST 来说这不是一个大问题,因为所有的数字都是对齐的,所以它们位于图像的中心。但想象一下,一个数字没有与你的图像完美对齐的情况。这是一个非常真实的问题——池化可以帮助我们解决这个问题。让我们快速从 MNIST 数据集中获取一张图片,并通过将其内容向上或向下移动一个像素来创建两个修改过的版本:
img_indx = 0
img, correct_class = mnist_data_train[img_indx]
img = img[0,:]
img_lr = np.roll(np.roll(img, 1, axis=1), 1, axis=0) ❶
img_ul = np.roll(np.roll(img, -1, axis=1), -1, axis=0)
f, axarr = plt.subplots(1,3) ❷
axarr[0].imshow(img, cmap=’gray’)
axarr[1].imshow(img_lr, cmap=’gray’)
axarr[2].imshow(img_ul, cmap=’gray’)
plt.show()
❶ 移动到右下角,然后是左上角
❷ 绘制图像

显然,所有三个版本的图像都是相同的数字。我们向上或向下、向左或向右移动内容仅几个像素并不重要。但我们的模型并不知道这一点。如果我们对图像的不同版本进行分类,有很大可能会出错。
让我们快速将这个模型放入eval()模式,并编写一个函数来获取单个图像的预测。这发生在下面的pred函数中,它接受一个图像作为输入:
model = model_cnn.cpu().eval() ❶
def pred(model, img):
with torch.no_grad(): ❷
w, h = img.shape ❸
if not isinstance(img, torch.Tensor):
img = torch.tensor(img)
x = img.reshape(1,-1,w,h) ❹
logits = model(x) ❺
y_hat = F.softmax(logits, dim=1) ❻
return y_hat.numpy().flatten() ❼
❶ 由于我们不在训练模式
❷ 评估时始终关闭梯度。
❸ 找到图像的宽度和高度
❹ 将其重塑为(B, C, W, H)
❺ 获取 logits
❻ 将 logits 转换为概率
❼ 将预测转换为 NumPy 数组
这是一种简单的方法将模型应用于单个图像。PyTorch 始终期望事物以批量的形式存在,所以我们重新调整输入以包含一个批处理维度,由于没有其他图像,这个维度等于 1。if not isinstance检查是一些防御性代码,你可以添加以确保你的代码对 NumPy 和 PyTorch 输入张量都有效。还要记住,我们使用的CrossEntropy损失函数隐式地处理 softmax。所以当我们使用CrossEntropy训练的模型时,我们需要调用F.softmax将输出转换为概率。
清理完这些,我们可以为三张图片都获取预测结果,看看图像的微小变化是否可以显著改变网络的预测。记住,每张图像都通过将图像向右下角或左上角移动一个像素来有所不同。直观上,我们预期变化非常小:
img_pred = pred(model, img)
img_lr_pred = pred(model, img_lr)
img_ul_pred = pred(model, img_ul)
print("Org Img Class {} Prob: ".format(correct_class),
➥ img_pred[correct_class])
print("Lower Right Img Class {} Prob: ".format(correct_class),
➥ img_lr_pred[correct_class])
print("Uper Left Img Class {} Prob: ".format(correct_class),
➥ img_ul_pred[correct_class])
Org Img Class 5 Prob: 0.78159285
Lower Right Img Class 5 Prob: 0.44280732
Uper Left Img Class 5 Prob: 0.31534675
显然,我们希望所有三个示例都得到相同的分类。它们本质上是相同的图像,但输出从合理的自信和正确的 78.2%下降到错误的 31.5%。问题是,微小的移动或平移会导致预测发生显著变化。
我们希望的是一个称为平移不变性的性质。对属性 X 的不变性意味着我们的输出不会根据 X 的变化而变化。我们不希望平移(上下移动)改变我们的决策——我们希望具有平移不变性。
池化可以使我们获得部分平移不变性。具体来说,我们将查看最大池化。什么是最大池化?与卷积类似,我们在图像的多个位置应用相同的函数。我们通常坚持使用偶数大小的池化滤波器。正如其名所示,我们滑动max函数在图像周围。你可以将其描述为具有内核大小 K,这是从中选择最大值的窗口大小。这里的大不同之处在于,我们每次移动max函数时移动 K 像素,而在执行卷积时我们每次只移动 1 像素(见图 3.14)。

图 3.14 展示了最大池化的示例,其中K = 2(顶部)和K = 3(底部)。输入的每个区域(左侧)都有颜色表示参与池化的像素组,输出(右侧)显示从输入区域选择了哪个值。请注意,输出比输入小 K 倍。
选择滑动多少像素的选择称为步长。默认情况下,从业者倾向于使用stride=1进行卷积,以便评估每个可能的位置。我们使用stride=K进行池化,使输入缩小 K 倍。对于任何操作,如果你使用stride=Z(对于任何正整数 Z)的值,结果将沿每个维度缩小 Z 倍。
池化的直觉在于它使我们对值的微小变化具有更强的鲁棒性。考虑图 3.14 的左上角,如果你将每个值向右移动一个位置。五个输出值不会改变,这给池化操作带来了一定程度的对平移图像一个像素的不变性。这并不完美,但它有助于减少这种变化的影响。如果我们通过多轮池化累积这种效果,我们可以使效果更强。
就像之前一样,PyTorch 提供了nn.MaxPool1d、nn.MaxPool2d和nn.MaxPool3d来满足你几乎所有的需求。该函数接受内核大小作为输入,这同时也是步长。步长为 K 意味着我们将每个形状维度的尺寸缩小 K 倍。所以如果我们的输入形状为(B,C,W,H),nn.MaxPool2d(K)的输出形状将为(B, C, W/K, H/K)。由于 C 保持不变,我们独立地对每个通道应用这个简单的操作。如果我们使用 2 × 2 的滤波器(大多数应用的规范)进行最大池化,我们最终得到一个四分之一大小的图像(行数和列数减半)。
3.5.1 CNNs with max pooling
向我们的模型定义中添加池化很容易:只需将nn.MaxPool2d(2)插入到nn.Sequential中即可。但在哪里使用最大池化呢?首先,让我们谈谈应用多少次最大池化。每次应用池化,都会将宽度(以及高度,如果是二维的话)缩小 K 倍。所以 n 轮池化意味着缩小到Kⁿ倍,这将使图像非常小。对于 MNIST,我们的宽度只有 28 像素,所以我们可以使用大小为K = 2 的最大池化进行最多四轮。这是因为五轮将给我们 28/2⁵ = 28/32,这小于一个像素的输出。
进行四轮池化是否更有意义?试着想象一下,如果这个问题是你被要求解决的。四轮池化意味着将图像缩小到仅 28/2⁴ = 28/16 = 1.75 像素高。如果你不能猜出用 1.75 像素表示的数字是什么,那么你的 CNN 可能也无法做到。通过视觉上缩小数据是一种很好的方法来估计你应该应用于大多数问题的最大池化量。对于高达 256 × 256 像素的图像,使用两到三轮池化是一个良好的初始下限或估计。
注意:大多数现代 CNN 应用都是在小于 256 × 256 的图像上。这对于现代 GPU 和技术来说处理起来非常大。实际上,如果你的图像大于这个尺寸,第一步是将它们调整大小,使任何维度上的像素数最多为 256。如果你真的需要在更高的分辨率下处理,你可能需要团队中有相关经验的人,因为在这个规模上工作的技巧是独特的,并且通常需要非常昂贵的硬件。
每次应用池化都会将图像缩小 K 倍,这也意味着在每轮池化之后,网络处理的数据更少。如果你处理的是非常大的图像,池化可以帮助减少训练更大模型所需的时间和训练的内存成本。如果你不认为这些问题是问题,那么在每轮池化之后增加滤波器的数量是常见的做法,增加的倍数为K,这样每层的总计算量大致保持不变(即,在行/列数量减半的情况下,增加两倍的滤波器数量可以平衡)。
让我们快速在我们的 MNIST 数据上尝试一下。以下代码定义了一个具有多层卷积和两轮最大池化的更深的 CNN:
model_cnn_pool = nn.Sequential(
nn.Conv2d(C, filters, 3, padding=3//2),
nn.Tanh(),
nn.Conv2d(filters, filters, 3, padding=3//2),
nn.Tanh(),
nn.Conv2d(filters, filters, 3, padding=3//2),
nn.Tanh(),
nn.MaxPool2d(2),
nn.Conv2d(filters, 2*filters, 3, padding=3//2),
nn.Tanh(),
nn.Conv2d(2*filters, 2*filters, 3, padding=3//2),
nn.Tanh(),
nn.Conv2d(2*filters, 2*filters, 3, padding=3//2),
nn.Tanh(),
nn.MaxPool2d(2),
nn.Flatten(),
nn.Linear(2*filters*D//(4**2), classes), ❶
)
cnn_results_with_pool = train_simple_network(model_cnn_pool, loss_func,
➥ mnist_train_loader, test_loader=mnist_test_loader,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device, epochs=20)
❶ 为什么要将线性层中的单元数量减少到 4²倍?因为将 2×2 网格池化到单个值意味着从四个值减少到一个,我们这样做两次。
现在,如果我们将相同的偏移测试图像通过我们的模型,我们应该看到不同的结果。最大池化并不是解决翻译问题的完美解决方案,因此图像每个偏移版本的分数仍然会变化。但它们的变化不那么大。这总体上是一件好事,因为它使我们的模型对现实生活中的问题更加鲁棒。数据并不总是完美对齐,因此我们希望模型能够对我们在现实生活中的测试数据中预期看到的问题具有弹性:
model = model_cnn_pool.cpu().eval()
img_pred = pred(model, img)
img_lr_pred = pred(model, img_lr)
img_ul_pred = pred(model, img_ul)
print("Org Img Class {} Prob: ".format(correct_class) ,
➥ img_pred[correct_class])
print("Lower Right Img Class {} Prob: ".format(correct_class) ,
➥ img_lr_pred[correct_class])
print("Uper Left Img Class {} Prob: ".format(correct_class) ,
➥ img_ul_pred[correct_class])
Org Img Class 5 Prob: 0.7068047
Lower Right Img Class 5 Prob: 0.71668524
Uper Left Img Class 5 Prob: 0.7311974
最后,我们可以查看我们训练的这个新的大网络的准确性,如下面的图表所示。增加更多层导致我们的网络收敛需要更长的时间,但一旦收敛,它就能获得略微更好的准确性:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_results,
➥ label=’Simple CNN’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_results_with_pool,
➥ label=’CNN w/ Max Pooling’)
[27]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

使用更多层通常会导致训练需要更长的时间来收敛,并且每个 epoch 的处理时间也会更长。这种双重打击只有通过使用更多层——使模型更深——才能得到缓解,这是我们通常获得最佳可能准确性的方法。如果你继续对这个更深的模型进行更多 epoch 的训练,你会看到它的准确度会继续提高,这超出了我们最初只包含一个nn.Conv2d层的模型所能达到的水平。
警告 就像冰淇淋一样,好事做得太多也会适得其反,使得网络太深以至于难以学习。在第五章和第六章中,你将学习到改进的技术,这些技术可以帮助你构建多达 100 到 200 层的网络——这大约是我们今天在仍然获得一些好处的情况下可以训练网络的深度极限。请注意,5 到 20 层通常已经足够,深度始终是计算成本和递减回报之间的权衡。
随着我们继续阅读本书,我们将了解一些新的、更好的方法,这些方法有助于解决这些问题,并使收敛更快、更好。但我希望带你走一条更慢、也有些痛苦的路径,这样你就能理解为什么这些新技术被开发出来,以及它们解决了哪些问题。这种更深入的理解将帮助你为在完成本书时我们将要处理的更高级技术做好准备。
3.6 数据增强
虽然可能有些令人失望,但现在你已经知道了一切,可以开始训练和构建用于新问题的 CNN 了。在 PyTorch 中实现 CNN 只需将nn.Linear层替换为nn.Conv2d,然后在结束时之前进行nn.Flatten。但应用 CNN 的实践中还有一个更大的秘密:使用数据增强。一般来说,神经网络是数据饥渴的,这意味着当你有大量多样化的数据时,它们学得最好。由于获取数据需要时间,因此我们将通过根据真实数据创建新的、虚假的数据来增强我们的真实数据。
这个想法很简单。如果我们处理的是 2D 图像,我们可以应用许多转换到图像上,这些转换不会改变其内容的含义,但会改变像素。例如,我们可以旋转图像几度而不改变其内容的含义。PyTorch 在torchvision.transforms包中提供了一系列转换;让我们看看其中的一些:
sample_transforms = { ❶
"Rotation" : transforms.RandomAffine(degrees=45),
"Translation" : transforms.RandomAffine(degrees=0, translate=(0.1,0.1)),
"Shear": transforms.RandomAffine(degrees=0, shear=45),
"RandomCrop" : transforms.RandomCrop((20,20)),
"Horizontal Flip" : transforms.RandomHorizontalFlip(p=1.0),
"Vertical Flip": transforms.RandomVerticalFlip(p=1.0),
"Perspective": transforms.RandomPerspective(p=1.0),
"ColorJitter" : transforms.ColorJitter(brightness=0.9, contrast=0.9)
}
pil_img = transforms.ToPILImage()(img) ❷
f, axarr = plt.subplots(2,4) ❸
for count, (name, t) in enumerate(sample_transforms.items()):
row = count % 4
col = count // 4
axarr[col,row].imshow(t(pil_img), cmap=’gray’)
axarr[col,row].set_title(name)
plt.show()
❶ 给出内置转换的激进值以使它们的影响明显
❷ 使用转换将张量图像转换回 PIL 图像
❸ 绘制每个转换的随机应用

你应该注意的第一件事是,转换几乎总是随机化的,每次我们应用一个转换,它都会给出不同的结果。这些新结果就是我们的增强数据。例如,指定degrees=45表示最大旋转是±45^∘度,应用的数量是随机选择该范围内的值。这样做是为了增加模型看到的输入的多样性。一些转换并不总是应用自己,并提供p参数来控制被选中的概率。我们将这些设置为p=1.0,这样你肯定会看到它们对测试图像有影响。对于实际使用,你可能想要选择p=0.5或p=0.15的值。像许多事情一样,要使用的具体值将取决于你的数据。
并非每个转换都应该总是被使用。确保你的转换保留了数据的本质或含义。例如,水平和垂直翻转对于 MNIST 数据集来说不是一个好主意:对数字 9 进行垂直翻转可能会将其变成 6,这会改变图像的含义。选择一组好的转换的最佳方法是应用它们到数据上,并亲自查看结果;如果你不能再判断正确的答案是什么,那么你的 CNN 可能也做不到。
但一旦你选择了一组你感到舒适的转换,这是一个简单而强大的方法来提高你模型的准确性。以下是一个使用Compose转换在更大管道中创建转换序列的简短示例,我们可以将其应用于即时增强训练数据。PyTorch 提供的所有基于图像的数据集都有transform参数,这样你就可以执行这些更改:
train_transform = transforms.Compose([
transforms.RandomAffine(degrees=5, translate=(0.05, 0.05),
➥ scale=(0.98, 1.02)),
transforms.ToTensor(),
])
test_transform = transforms.ToTensor()
mnist_train_t = torchvision.datasets.MNIST("./data", train=True,
➥ transform=train_transform)
mnist_test_t = torchvision.datasets.MNIST("./data", train=False,
➥ transform=test_transform)
mnist_train_loader_t = DataLoader(mnist_train_t, shuffle=True,
➥ batch_size=B, num_workers=5)
mnist_test_loader_t = DataLoader(mnist_test_t, batch_size=B,
➥ num_workers=5)
注意:在DataLoader类中指定了一个新的重要可选参数:num_workers标志控制用于预加载训练数据批次的线程数量。当 GPU 忙于处理数据批次时,每个线程可以准备下一个批次,以便 GPU 完成时可以立即使用。你应该始终使用此标志,因为它有助于你更有效地使用 GPU。当你开始使用转换时,这是至关重要的,因为 CPU 将不得不花费时间处理图像,这会让 GPU 闲置等待。
现在我们可以重新定义之前用来展示最大池化的相同网络,并调用相同的训练方法。数据增强会自动通过定义这些新的数据加载器来实现。对于测试集,我们只使用简单的ToTensor转换,因为我们希望测试集是确定性的——这意味着如果我们对测试集运行相同的模型五次,我们将得到相同的答案五次:
model_cnn_pool = nn.Sequential(
nn.Conv2d(C, filters, 3, padding=3//2),
nn.Tanh(),
nn.Conv2d(filters, filters, 3, padding=3//2),
nn.Tanh(),
nn.Conv2d(filters, filters, 3, padding=3//2),
nn.Tanh(),
nn.MaxPool2d(2),
nn.Conv2d(filters, 2*filters, 3, padding=3//2),
nn.Tanh(),
nn.Conv2d(2*filters, 2*filters, 3, padding=3//2),
nn.Tanh(),
nn.Conv2d(2*filters, 2*filters, 3, padding=3//2),
nn.Tanh(),
nn.MaxPool2d(2),
nn.Flatten(),
nn.Linear(2*filters*D//(4**2), classes),
)
cnn_results_with_pool_augmented = train_simple_network(model_cnn_pool,
loss_func, mnist_train_loader_t, test_loader=mnist_test_loader_t,
score_funcs={’Accuracy’: accuracy_score}, device=device, epochs=20)
现在我们可以绘制结果图,展示验证准确率的变化。通过仔细选择增强,我们帮助模型更快地学习并收敛到更好的解决方案,准确率达到 96.2%,而不是 95.7%:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_results_with_pool,
➥ label=’CNN w/ Max Pooling’)
sns.lineplot(x=’epoch’, y=’test Accuracy’,
➥ data=cnn_results_with_pool_augmented,
➥ label=’CNN w/ Max Pooling + Augmentation’)
[31]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

设计良好的数据增强管道是深度学习中的特征工程对应物。如果你做得好,它可以对你的结果产生巨大影响,并成为成功与失败的区别。因为 PyTorch 以 PIL 图像为基础,你也可以编写自定义转换并将其添加到管道中。这就是你可以导入像scikit-image这样的工具的地方,它提供了更多高级计算机视觉转换,你可以应用。随着我们学习如何构建更复杂的网络,以及当你处理比 MNIST 更复杂的数据集时,良好的数据增强的影响也会增长。数据增强还会增加更多轮次训练的价值。没有增强,每个轮次都会重新访问完全相同的数据;有了增强,你的模型会看到数据的不同变体,这有助于它更好地泛化到新数据。我们的 10 个轮次不足以看到全部的好处。
尽管良好的数据增强非常重要,但在深度学习中即使没有它也能走得很远。在这本书的大部分内容中,我们不会使用数据增强:部分原因是保持示例简单,无需为每个新的数据集解释数据增强管道的选择,另一个原因是增强是领域特定的。对图像仅有效的增强方法对图像有效。你需要为音频数据想出一套新的转换,而在文本领域进行增强是困难的。在这本书中我们将学习的大部分技术都可以应用于相当广泛的类别问题。
练习
在 Manning 在线平台 Inside Deep Learning Exercises(liveproject.manning.com/project/945)上分享和讨论你的解决方案。一旦你提交了自己的答案,你将能够看到其他读者提交的解决方案,并看到哪些是作者认为最好的。
注意,这些练习在本质上是有意探索性的。目标是让你通过自己的代码和经验了解和发现许多与 CNN 工作相关的常见趋势和属性。
-
尝试将本章中所有网络的训练轮数从 10 轮增加到 40 轮。会发生什么?
-
从
torchvision加载 CIFAR10 数据集,并尝试构建自己的卷积神经网络。尝试使用 2 到 10 层的卷积和 0 到 2 轮的最大池化。什么看起来效果最好? -
检查提供的转换,通过视觉检查看看哪些对 CIFAR10 有意义。是否有任何对 CIFAR10 有用但对 MNIST 不合理的转换?
-
使用你选择的转换训练一个新的 CIFAR10 模型。这对准确率有什么影响?
-
尝试改变 CIFAR10 和 MNIST 中卷积滤波器的大小。这有什么影响?
-
创建一个新的自定义
Shuffle转换,该转换应用于图像中像素的相同固定重排序。使用这个转换如何影响你的 CIFAR10 模型?提示:查看Lambda转换来帮助你实现这一点。
摘要
-
在 PyTorch 张量中表示图像时,我们使用多个通道,其中通道描述了图像的某种不同性质(例如,红色、绿色和蓝色通道)。
-
卷积是一种数学运算,它将一个核(一个小张量)应用于较大输入张量中的每个位置,以产生输出。这使得卷积在本质上具有空间性。
-
卷积层学习多个不同的核,以应用于输入以创建多个输出。
-
卷积不能捕捉平移(上下移动)不变性,我们可以使用池化来使我们的模型对平移更加鲁棒。
-
卷积和池化都有步长选项,它决定了我们在图像上滑动时移动多少像素。
-
当我们的数据是空间性的(例如,图像)时,我们可以将先验(卷积是空间性的)嵌入到我们的网络中,以更快更好地学习解决方案。
-
我们可以通过选择应用数据的一组转换来增强我们的训练数据,从而提高模型准确率。
¹ 如果你有自称贝叶斯的朋友,他们可能会对这个定义感到冒犯,但没关系。我们今天不是在讨论贝叶斯。贝叶斯统计学通常涉及对先验的更精确定义;参见mng.bz/jjJp(斯科特·林奇,2007 年)以获取介绍。↩
² 不同的框架支持不同的排序,原因多种多样,我们不会深入探讨。让我们专注于基础知识以及 PyTorch 的默认行为。↩
³ ⌊x⌋ 是向下取整函数,它将一个值向下舍入:例如,⌊9.9⌋ = 9。天花板函数是 ⌈9.1⌉ = 10。↩
4 循环神经网络
本章涵盖
-
权重共享和处理序列数据
-
在深度学习中表示序列问题
-
结合 RNN 和全连接层进行预测
-
使用不同长度的序列进行填充和打包
上一章向我们展示了如何为特定类型的空间结构开发神经网络:空间局部性。具体来说,我们学习了卷积算子如何赋予我们的神经网络一个先验,即相邻的项是相关的,而远离的项则没有关系。这使得我们能够构建学习更快且为图像分类提供更准确解决方案的神经网络。
现在我们想要开发能够处理一种新型结构的模型:包含 T 个项目且按特定顺序出现的 序列。例如,字母表——a, b, c, d, ...——是由 26 个字符组成的序列。这本书中的每一句话都可以被视为一个单词序列 或 字符序列。你可以使用每小时温度作为序列来尝试预测未来的温度。只要序列中的每个项目都可以表示为一个向量 x,我们就可以使用基于序列的模型来学习它。例如,视频可以被看作是一系列图像;你可以使用卷积神经网络(CNN)将每个图像转换为向量。¹
在所有这些情况下,结构与第三章中的图像和卷积相比具有独特的不同。序列可以有 可变 的项目数量。例如,前两个句子具有可变长度:18 个单词和 8 个单词。相比之下,图像 总是 具有相同的宽度和高度。这对图像来说并不太受限制,因为很容易调整大小而不改变其含义。但我们不能简单地“调整大小”序列,因此我们需要一种可以处理这种新问题(可变长度数据)的方法。
这就是循环神经网络(RNN)发挥作用的地方。它们为我们模型提供了不同的先验:输入遵循序列,顺序很重要。RNN 特别有用,因为它们可以处理具有不同序列长度的输入。在谈论序列和 RNN 时,我们经常将序列称为 时间序列,并将序列中的第 i 个项目称为第 i 个 时间步(使用 t 和 i 表示特定项目或时间点是很常见的,我们都会使用)。这来自于将 RNN 视为在固定间隔处理一系列事件的观点。这个术语很普遍,所以我们将使用 T 来表示 时间,以指代输入序列中的项目数量。再次使用上一段的前两句话,那么对于第一句话中的 18 个单词,T = 18;对于第二句话中的 8 个单词,T = 8。
在本章中,我们使用 RNN 学习如何创建用于序列分类问题的网络。以下是一些例子:
-
情感检测—这个单词序列(例如,一个句子、推文或段落)是否表明了一种积极、消极还是中性的印象?例如,我可能会在 Twitter 上运行句子检测来找出人们是否喜欢这本书。
-
车辆维护—你的汽车可能存储有关行驶了多少英里、行驶时的每加仑英里数、发动机温度等信息。这可以用来预测汽车在接下来的 3、6 或 12 个月内是否需要维修,通常称为预测性维护。
-
天气预报—每天,你可以记录最高温度、平均温度、最低温度、湿度、风速等等。然后你可以预测很多事物,比如第二天的温度、多少人会去商场(公司会很乐意知道这一点),以及交通是否会正常、糟糕还是良好。
当人们深入研究深度学习时,RNNs(递归神经网络)通常很难理解,许多资料将它们视为接收序列并输出新序列的魔法黑盒子。因此,我们将仔细构建我们的方法,逐步理解 RNN 实际上是什么。这是本书中最具挑战性的两个章节之一,如果第一次阅读时不是所有内容都理解,那也是正常的。RNNs 是一个本质上令人费解的概念——我花了几年时间才弄清楚它们。为了帮助你理解本章的概念,我建议使用笔和纸自己绘制过程和图表,从 T=1 的序列开始,然后添加第二个和第三个,依此类推。
在深入研究问题之前,在第 4.1 节中,我们通过权重共享的概念缓慢地逐步定义 RNN 是什么。这是 RNN 工作原理的基础,所以我们首先讨论简单全连接网络的权重共享,以理解这个概念,然后展示这个概念是如何应用于产生 RNN 的。一旦我们有了心理图景,第 4.2 节就转向了加载序列分类问题以及在 PyTorch 中定义 RNN 的机制。PyTorch 中 RNN 的一个常见问题是,你想要训练的序列长度是可变的,但Tensor对象没有任何灵活性:所有维度都必须具有相同的长度。第 4.3 节通过一种称为填充的技术解决了这个Tensor/序列问题,这使得 PyTorch 在批次中的序列长度可变时可以正确运行。第 4.4 节通过两种改进 RNN 以提高其准确性的方法来结束新材料。
4.1 递归神经网络作为权重共享
在我们进入新的主题——循环神经网络(RNN)之前,让我们再谈谈上一章的一个概念:权重共享。为了确保你对这个基本概念熟悉,我们通过解决一个虚构问题来讲解。这样,我们可以在深入研究更复杂的 RNN 之前展示这个过程的工作原理。图 4.1 是对权重共享概念的快速回顾,其中我们在多个位置重复使用一个层。PyTorch 在重复使用层时正确处理了学习中的复杂数学。

图 4.1 权重共享工作原理的概述。方框代表任何类型的神经网络或 PyTorch 模块。方框具有输入/输出关系,通常我们的网络中的每一层都是一个不同的方框。如果我们多次重复使用相同的方框,我们实际上在多个层之间共享相同的权重。
当使用 CNN 时,卷积操作就像有一个单独的小型线性网络,我们在图像上滑动,对每个空间位置应用相同的函数。这是 CNN 的一个隐含属性,我们通过这段小代码将其明确化:
x = torch.rand(D) ❶
output = torch.zeros(D-K//2*2)
for i in range(output.shape[0]):
output[i] = f(x[i:i+K], theta)
❶ 输入向量
此代码重复使用相同的权重 Θ 对多个输入进行操作。我们的 CNN 隐式地这样做。为了帮助理解 RNN 以及它们是如何工作的,我们显式地应用权重共享来展示我们可以以不同的方式使用它。然后我们可以调整我们使用权重共享的方式,以达到原始 RNN 算法。
4.1.1 全连接网络的权重共享
让我们想象一下,我们想要为分类问题创建一个具有三个隐藏层的全连接网络。图 4.2 展示了这个网络作为 PyTorch 模块序列的形状。

图 4.2 一个具有三个隐藏层和一个输出层的简单网络。nn.Sequential 层显示了按使用顺序包裹层序列(最底层在最下面,最顶层在最上面)。
为了确保我们学习如何以代码和数学的方式读取和编写网络定义,下面展示了以方程式形式书写的相同网络。线性层 W 也在图 4.2 中被引用,以便我们可以将各部分相互映射:

我决定在这个方程中明确表示,并展示每个线性层的形状。根据这个方程,我们有 d 个输入特征,每个隐藏层有 n 个神经元,以及类输出。这个明确的细节在接下来会变得很重要。让我们快速实现这个网络用于 MNIST:
mnist_data_train = torchvision.datasets.MNIST("./data", train=True,
➥ download=True, transform=transforms.ToTensor())
mnist_data_test = torchvision.datasets.MNIST("./data", train=False,
➥ download=True, transform=transforms.ToTensor())
mnist_train_loader = DataLoader(mnist_data_train, batch_size=64, shuffle=True)
mnist_test_loader = DataLoader(mnist_data_test, batch_size=64)
D = 28*28 ❶
n = 256 ❷
C = 1 ❸
classes = 10 ❹
model_regular = nn.Sequential( ❺
Flatten(),
nn.Linear(D, n),
nn.Tanh(),
nn.Linear(n, n),
nn.Tanh(),
nn.Linear(n, n),
nn.Tanh(),
nn.Linear(n, classes),
)
❶ 输入中有多少个值?我们使用这个值来帮助确定后续层的大小。
❷ 隐藏层大小
❸ 输入中有多少个通道?
❹ 有多少个类别?
❺ 创建我们的常规模型
这是一个简单的全连接模型,因为我们使用了nn.Linear层。要将它作为分类问题进行训练,我们再次使用train_simple_network函数。(所有这些都应该在 2 章和 3 章中很熟悉。)我们可以训练这个模型并得到以下结果,这并不新鲜:
loss_func = nn.CrossEntropyLoss()
regular_results = train_simple_network(model_regular, loss_func,
mnist_train_loader, test_loader=mnist_test_loader,
score_funcs={’Accuracy’: accuracy_score}, device=device, epochs=10)
现在,让我们假设这是一个非常大的网络——如此之大,以至于我们无法为所有三个隐藏层W[d × n]^((h[1])), W[n × n]^((h[2])), 和 W[n × n]^((h[3]))分配权重。但我们确实想要一个具有三个隐藏层的网络。一个选项是在某些层之间共享权重。我们可以通过简单地用h[2]替换h[3]来实现这一点,这相当于在定义中定义一个对象并重复使用该对象两次。图 4.3 展示了如何使用第二和第三隐藏层来实现这一点,因为它们具有相同的形状。

图 4.3 一个简单的具有权重共享的前馈网络。在创建nn.Sequential对象之前,我们定义了一个nn.Linear(n,n)层,该层被用作第二和第三隐藏层。PyTorch 会找出如何使用这种设置进行学习,并且第二和第三层共享相同的权重。
表达这个的数学方法是改变我们之前的方程,变为以下形式:

唯一改变的是(用红色标出),我们正在两个不同的位置重复使用权重W((*h*[2]))。*这是权重共享*,重复使用层的权重。之所以这样称呼,是因为我们可以假设**W**((h[2]))的两种不同使用是网络中具有相同权重的不同层。我们如何在 PyTorch 中实现这一点?很简单。如果我们将那个线性层视为一个对象,我们重复使用层对象。其他一切工作方式完全相同。
注意:您也可能听到权重共享被称为绑定权重。这是相同的概念,只是名称的不同类比:权重被绑定在一起。有些人更喜欢这种术语,如果权重被稍微不同的方式使用。例如,一个层可能使用 W,而另一个层则使用转置权重W^⊤。
以下代码展示了相同的全连接网络,但第二和第三隐藏层使用了权重共享。我们将想要共享的nn.Linear层声明为一个名为h_2的对象,并在nn.Sequential列表中插入两次。因此,h_2被用作第二和第三隐藏层,PyTorch 将正确地使用完全相同的函数来训练网络——无需任何更改:
h_2 = nn.Linear(n, n) ❶
model_shared = nn.Sequential(
nn.Flatten(),
nn.Linear(D, n),
nn.Tanh(), h_2, ❷
nn.Tanh(), h_2, ❸
nn.Tanh(),
nn.Linear(n, classes),
)
❶ 创建我们计划共享的网络权重层
❷ 第一次使用
❸ 第二次使用:现在共享权重
从编程的角度来看,这段代码似乎很简单。这是一个非常面向对象的设计:我们创建了一个对象,这个对象在两个地方被使用。但是让数学成立并不是一件简单的事情。幸运的是,PyTorch 为你处理了这个问题,相同的训练函数可以很好地处理这种权重共享:
shared_results = train_simple_network(model_shared, loss_func,
mnist_train_loader, test_loader=mnist_test_loader,
score_funcs={’Accuracy’: accuracy_score}, device=device, epochs=10)
使用新的权重共享网络,我们可以绘制两个网络的验证精度,以查看 PyTorch真正使用共享权重学习到了什么,以及结果看起来像什么:
sns.lineplot(x=’epoch’, y=’test Accuracy’, ❶
➥ data=regular_results, label=’Normal’)
sns.lineplot(x=’epoch’, y=’test Accuracy’,
➥ data=shared_results, label=’Shared’)
[10]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>
❶ 绘制结果并进行比较

使用权重共享不会增加训练时间,我们也不会失去任何精度(这不是一个保证)。我们确实得到了一个好处,即略微减少了内存使用,但我们在这里所做的是很少使用的。存在更好的方法来减少内存使用,而这个问题的目的只是演示权重共享。我们关注权重共享,因为它是在创建和训练 RNNs 的基础。
4.1.2 随时间变化的权重共享
现在我们已经了解了权重共享,我们可以讨论它是如何用于创建循环神经网络(RNNs)的。RNN 的目标是使用单个项目来总结项目序列中的每个项目。这个过程在图 4.4 中展示。RNN 接收两个项目:一个表示到目前为止看到的所有内容的张量h[t − 1],其顺序与它看到的顺序相同,以及一个表示序列中最新/下一个项目的张量x[t]。RNN 将历史摘要(h[t − 1])和新的信息(x[t])结合起来,创建到目前为止看到的所有内容的新摘要(h[t])。为了对整个 T 个项目的序列进行预测,我们可以使用 T 个输入之后的输出(h[T]),因为它代表了第 T 个项目以及所有前面的项目。

图 4.4 RNN 过程的示意图。我们用 t 来表示当前的时间点(序列中的第 t 个项目)。RNN 接收所有先前内容的单个表示h[t − 1]和序列中最新项目的信息x[t]。RNN 将这些合并成到目前为止看到的所有内容的新表示h[t],这个过程会一直重复,直到我们到达序列的末尾。
这个想法是 RNN 遍历输入中的所有项目。让我们确保我们给这个讨论的所有部分都给出一些数学符号。我们有 T 个总的时间单位。我们不是只有一个输入x ∈ ℝ^d,而是有 T 个输入x[1],x[2],…,x[T − 1],x[T]。每个输入都是一个大小相同的向量(即,x[j] ∈ ℝ^d)。记住,每个x[1],x[2],…,x[T − 1],x[T]都是某个序列的向量表示。例如,天气可以有一个包含每天的最高、最低和平均温度的向量(x[t] = [high,low,mean]),并且这些天必须按照它们的自然顺序出现(不允许时间旅行)。
我们如何处理跨时间的东西?如果我们有一个网络模块 A,其中 A(x) = h,我们可以使用权重共享将网络模块 A 独立应用于每个项目。因此,我们得到 T 个输出,h[i] = A(x[i])。我们最终会使用这些输出中的一个 h 作为线性层的输入,但首先我们需要逐步构建一个 RNN。在图 4.5 中展示了独立应用 A(⋅) 的朴素方法。

图 4.5 使用网络模块 A 独立处理 T 个不同输入的朴素解决方案。序列中的每个项目 x[i] 都独立于其他序列进行处理。这种方法没有识别数据的序列性质,因为没有路径连接 x[t] 和 x[t][+1]。
我们使用权重共享来将相同的函数/层 A(⋅)应用于每个项目。但我们没有做任何事情来连接跨时间的信息。我们需要我们的 RNN 函数 A 同时接收历史和输入,这样我们就可以有类似这样的代码:
history_summary = 0 ❶
inputs = [...] ❷
for t in range(T):
new_summary = RNN(history_summary, inputs[t]) ❸
history_summary = new_summary
❶ h[0]
❷ x[1], x[2], …, x[T]
❸ h[t] = A(h[t − 1],x[t])
这样,RNN 会同时接收历史和新的项目。这是通过给 A 一个递归权重来实现的。我们用 h[t] 来表示时间步 t 的结果,所以让我们将这个概念纳入我们的模型。首先,让我们看一下带有一些简单注释的方程——你可能在论文或网上看到的那种。花几分钟时间尝试自己解析这些部分,这将有助于你在阅读这类深度学习数学方面的技能提升:

现在我们来看一下这个方程的详细注释版本:

我们有一个权重集(W[d × n]^(cur)),它接收当前的时步(x[i]),并加上第二个权重集(W[n × n]^(prev)),用于前一个时步的结果(h[i − 1])。通过在每个时步重复使用这个新函数,我们得到了跨时间的信息。所有这些都在图 4.6 中展示。

图 4.6 网络 A 被定义为接收两个输入:前一个隐藏状态 h[t − 1] 和当前输入 x[t]。这允许我们展开网络并在时间上共享信息,有效地处理数据的序列性质。
这种在时间上共享信息的方法定义了一个基本的 RNN。其思想是,我们在每个时间步都重复使用相同的函数和权重 A。在时间步 t,模型从隐藏状态h[t − 1]获取关于过去的信息。因为h[t − 1]是由h[t − 2]计算得出的,它包含了过去两个时间步的信息。而且由于h[t − 2]依赖于h[t − 3],它是三个之前步骤。继续这样回溯到默认值h[0],你可以看到h[t − 1]是如何根据这些时间步的顺序从每个之前的时间步获取信息的。这就是 RNN 如何捕捉时间上的信息。但是,当我们需要h[0],而它不存在时,我们在时间开始时(i = 1)怎么办?隐含地,我们假设h[0] =
(即所有零值的向量)以使事情完整。这有助于更明确地绘制出来,如图 4.7 所示;这通常被称为在时间上“展开”RNN。

图 4.7 展开 RNN 的例子,对于 T = 3 个时间步。在这里,我们明确地绘制了每个输入(x[t])和隐藏激活(h[t])的过程。初始隐藏状态h[0]始终设置为所有零值的向量。注意,现在它看起来像是一个前馈模型。
注意它开始看起来与全连接网络非常相似。唯一的区别是我们有多个输入,x[1],…,x[T],每个时间步一个。对于简单的分类问题,我们将使用最后一个激活h[T]作为预测结果,因为h[T]包含了每个之前时间步的信息,并且它是最后一个时间步。这使得h[T]成为唯一一个具有关于整个序列信息的步骤。我们可以这样展开 RNN 的事实是我们可以使用相同的算法来训练它。将 RNN 展开,以便我们反复应用相同的函数(权重共享),只是改变输入,这是 RNN 的本质。
4.2 PyTorch 中的 RNN
现在我们知道了 RNN 是什么,我们需要弄清楚如何在 PyTorch 中使用它。虽然有很多代码为我们提供了实现这一目标的方法,但我们仍然需要自己编写大量的代码。就像这本书中的所有内容一样,第一步是创建一个Dataset来表示我们的数据并加载它,然后是一个model,它使用 PyTorch 的nn.Module类,该类接受输入数据并生成预测。
但是对于 RNN,我们需要向量,而我们使用的大多数数据并不是自然地表示为向量。我们需要做一些额外的工作来解决这个问题。图 4.8 展示了实现这一功能的步骤。

图 4.8 基于长度为 T 的输入序列进行预测的四个高级步骤。我们需要创建序列的向量表示,将这个表示传递给 RNN 以产生 T 个隐藏状态,将其缩减为一个隐藏状态,然后使用全连接层进行预测。
要在 PyTorch 中表示 RNN 的序列数据,我们使用三维输入表示:
(B,T,D)
如前所述,B 告诉我们一个批次中有多少项(即有多少数据点)。T 给出了总的时间步数,D 是每个时间步的特征数量。因为时间在张量对象本身中表示,所以指定模型很容易。
让我们先从一个多对一分类问题开始。我这是什么意思?我们将有多个输入(每个时间步),但我们将只有一个输出:我们试图预测的类别标签。
4.2.1 一个简单的序列分类问题
要创建一个模型,我们首先需要数据。这使我们到达图 4.8 的第 1 步。为了简化问题,我们将从 PyTorch RNN 教程(mng.bz/nrKK)中借用任务:识别一个名称来自哪种语言。例如,“Steven”是一个英文名字。请注意,这个问题不能完美解决——例如,“Frank”可能是英语或德语——因此我们应该预期由于这些问题和过度简化而出现一些错误。我们的目标是编写体现图 4.9 中所示过程的代码。

图 4.9 RNN 处理分类名称来源语言的过程。名称的各个字符构成了输入到 RNN 的序列。我们学习如何将每个字符转换为向量,以及如何让 RNN 处理该序列并返回最终的激活h[T],最后通过一个线性层产生预测。
以下代码下载数据集并提取所有文件。完成后,文件夹结构为 names/[LANG].txt,其中[LANG]表示语言(也是此问题的标签),文本文件的内容是该语言中出现的所有名称列表:
zip_file_url = "https://download.pytorch.org/tutorial/data.zip"
import requests, zipfile, io
r = requests.get(zip_file_url)
z = zipfile.ZipFile(io.BytesIO(r.content))
z.extractall()
由于这个数据集相当小,我们将所有数据加载到内存中。数据存储在字典namge_language_data中,它将语言名称(例如,英语)映射到所有名称的列表。为了简化我们的生活,unicodeToAscii从每个名称中删除非 ASCII 字符。字典alphabet包含我们期望看到的所有字符,并将每个项目映射到一个唯一的整数,从 0 开始,依次递增。这很重要。我们的计算机不知道你的序列中的任何字符或项目代表什么。除非你的数据自然存在为数值(例如,外面的温度),否则需要一个转换步骤。我们稍后会了解到这个转换是如何进行的,但我们通过将序列中可能出现的每个唯一项目映射到一个唯一的整数值来标准化这个过程。
这里是代码:
namge_language_data = {}
import unicodedata ❶
import string
all_letters = string.ascii_letters + " .,;’"
n_letters = len(all_letters)
alphabet = {}
for i in range(n_letters):
alphabet[all_letters[i]] = i
def unicodeToAscii(s): ❷
return ”.join(
c for c in unicodedata.normalize(’NFD’, s)
if unicodedata.category(c) != ’Mn’
and c in all_letters
)
for zip_path in z.namelist(): ❸
if "data/names/" in zip_path and zip_path.endswith(".txt"):
lang = zip_path[len("data/names/"):-len(".txt")]
with z.open(zip_path) as myfile:
lang_names = [unicodeToAscii(line).lower()
➥ for line in
➥ str(myfile.read(), encoding=’utf-8’).strip().split("\n")]
namge_language_data[lang] = lang_names
print(lang, ": ", len(lang_names)) ❹
Arabic : 2000
Chinese : 268
Czech : 519
Dutch : 297
English : 3668
French : 277
German : 724
Greek : 203
Irish : 232
Italian : 709
Japanese : 991 Korean : 94
Polish : 139
Portuguese : 74
Russian : 9408
Scottish : 100
Spanish : 298
Vietnamese : 73
❶ 移除 UNICODE 标记以简化我们的处理工作:例如,将“Ślusàrski”转换为“Slusarski"
❷ 将 Unicode 字符串转换为纯 ASCII
❸ 遍历每种语言,打开 zip 文件条目,并读取文本文件中的所有行
❹ 打印出每种语言的名称
现在我们已经创建了一个数据集,你可能注意到它并不平衡:俄罗斯语名称比其他任何语言都要多得多。这是我们评估模型时应该注意的事情。
在将数据加载到内存中后,我们现在可以实施一个Dataset来表示它。data列表包含每个名称,以及与labels列表中关联的索引,指示名称来自哪种语言。一个vocabulary字典将每个唯一项目映射到一个整数值:
class LanguageNameDataset(Dataset):
def __init__(self, lang_name_dict, vocabulary):
self.label_names = [x for x in lang_name_dict.keys()]
self.data = []
self.labels = []
self.vocabulary = vocabulary
for y, language in enumerate(self.label_names):
for sample in lang_name_dict[language]:
self.data.append(sample)
self.labels.append(y)
def __len__(self):
return len(self.data)
def string2InputVec(self, input_string):
"""
This method will convert any input string into a vector of long
values, according to the vocabulary used by this object. input_string: the string to convert to a tensor
"""
T = len(input_string) ❶
name_vec = torch.zeros((T), dtype=torch.long) ❷
for pos, character in enumerate(input_string): ❸
name_vec[pos] = self.vocabulary[character]
return name_vec
def __getitem__(self, idx):
name = self.data[idx]
label = self.labels[idx]
label_vec = torch.tensor([label], dtype=torch.long) ❹
return self.string2InputVec(name), label
❶ 字符串有多长?
❷ 创建一个新的张量来存储结果
❸ 遍历字符串并在张量中放置适当的值
❹ 将正确的类别标签转换为 PyTorch 的张量
注意:词汇表的概念在机器学习和深度学习中很常见。你经常会看到用数学符号Σ来表示词汇表。例如,我们可以更简洁地询问“单词cheese是否在词汇表中?”通过写作“cheese" ∈ Σ。如果我们写“blanket" ∉ Σ,我们表示“blanket”这个项目不在词汇表中。我们还可以使用|Σ|来表示词汇表的大小。
__len__函数很简单:它返回Dataset中的数据点总数。第一个有趣的功能是辅助函数string2InputVec,它接受一个input_string并返回一个新的torch.Tensor作为输出。张量的长度是input_string中的字符数,并且它具有torch.long类型(也称为torch.int64)。张量中的值指示input_string中存在哪些唯一的标记及其顺序。这为我们提供了一个基于张量的新表示,PyTorch 可以使用它。
然后,我们在__getitem__方法中重用string2InputVec。我们从self.data[idx]成员中获取原始字符串,并使用string2InputVec将其转换为 PyTorch 需要的张量表示。返回的值是一个遵循(输入,输出)模式的元组。例如,
(tensor([10, 7, 14, 20, 17, 24]), 0)
表示一个六字符的名称应被分类为第一类(阿拉伯语)。原始字符串通过我们的string2InputVec函数转换为整数张量,以便 PyTorch 可以理解它。
有了这些,我们可以创建一个新的数据集来确定给定名称的语言。以下代码片段创建了一个训练/测试分割,测试分割中有 300 个项目。我们在加载器中使用批量大小为 1(我们将在本章后面回到这个细节):
dataset = LanguageNameDataset(namge_language_data, alphabet)
train_data, test_data = torch.utils.data.random_split(dataset,
➥ (len(dataset)-300, 300))
train_loader = DataLoader(train_data, batch_size=1, shuffle=True)
test_loader = DataLoader(test_data, batch_size=1, shuffle=False)
对类别不平衡进行分层抽样
我们的数据库存在一个类别不平衡的问题,我们目前没有解决。当类别的比例不均匀时,这种情况会发生。在这种情况下,模型可能会学会简单地重复最常见的类别标签。例如,想象你正在尝试预测某人是否患有癌症。你可以创建一个模型,该模型总是预测“没有癌症”,因为幸运的是,大多数人在某个时刻没有癌症。但这种方法对模型来说没有用。
解决类别不平衡是一个独立的主题领域,我们不会深入探讨。但一个简单的改进是使用分层抽样来创建训练/测试分割。这是一个常见的工具,在 scikit-learn 中可用(mng.bz/v4VM)。想法是强制抽样以保持分割中的类别比例。所以如果原始数据是 99% A 和 1% B,你希望你的分层分割有相同的百分比。使用随机抽样,你可能会轻易地得到 99.5% A 和 0.5% B。通常,这不是一个大问题,但类别不平衡可能会显著扭曲你对模型表现好坏的看法。
数据集加载完成后,我们可以讨论 PyTorch 中 RNN 模型的其余部分。
4.2.2 嵌入层
图 4.8 的第 1 步要求我们将输入序列表示为向量序列。我们有LanguageNameDataset对象来加载数据,它使用词汇表(Σ)将每个字符/标记(例如,“Frank”)转换为唯一的整数。我们还需要一种将每个整数映射到相应向量的方法,这可以通过嵌入层来实现。这在图 4.10 中从高层次上展示了这一点。请注意,这是深度学习社区中使用的标准术语,你应该熟悉它。

图 4.10 的子集(a)和(b)都是通过我们实现的LanguageNameDataset完成的。最后的子集(c)是通过一个nn.Embedding层完成的。
嵌入层是查找表,用于将每个整数值映射到特定的向量表示。你告诉嵌入层词汇量的大小(即有多少个唯一项)以及你希望输出维度的大小。图 4.11 展示了这一过程在概念层次上的工作方式。

图 4.11 设计的嵌入层,用于接受五个独特项的词汇表。你必须编写将对象(如字符串)映射到整数的代码。嵌入将每个整数映射到其自己的 d 维向量 x ∈ ℝ^d。
在这个玩具示例中,词汇表包含字符和单词。只要你能一致地将项目映射到整数值,词汇表就不需要是字符串。nn.Embedding 的第一个参数是 5,表示词汇表有五个唯一项。第二个参数 3 是输出维度。你应该把它想象成 nn.Linear 层,其中第二个参数告诉你会有多少个输出。我们可以根据我们认为模型需要将多少信息打包到每个向量中来增加或减少输出大小。在大多数应用中,你希望尝试输出维度的值在 [64,256] 范围内。
哎呀,到处都是嵌入!
作为一种概念,嵌入在实用工作中被高度利用。将每个单词映射到向量并尝试预测附近的单词是像 word2vec (en.wikipedia.org/wiki/Word2vec) 和 Glove (nlp.stanford.edu/projects/glove) 这样的通用工具背后的本质,但这涉及到比我们时间允许的更深入的自然语言处理领域。简而言之,学习将项目表示为向量以便使用其他工具是解决现实世界问题的有效方法。
一旦你有了嵌入,你可以使用最近邻搜索来实现搜索引擎或“你是指”功能,将它们投入 Uniform Manifold Approximation and Projection (UMAP) (umap-learn.readthedocs.io/en/latest) 进行可视化,或者运行你喜欢的非深度算法来进行预测或聚类。在本书的其他地方,我会指出可以用来创建嵌入的方法。
nn.Embedding 层被设计用来处理一系列的事物。这意味着序列可以包含重复项。例如,以下代码片段创建了一个新的输入序列,包含 T = 5 个项目,但词汇量只有 3 个项目。这是可以的,因为输入序列 [0,1,1,0,2] 包含重复项(0 和 1 出现了两次)。embd 对象的维度为 d = 2,并处理输入以创建新的表示 x_seq:
with torch.no_grad():
input_sequence = torch.tensor([0, 1, 1, 0, 2], dtype=torch.long)
embd = nn.Embedding(3, 2)
x_seq = embd(input_sequence)
print(input_sequence.shape, x_seq.shape)
print(x_seq)
torch.Size([5]) torch.Size([5, 2])
tensor([[ 0.7626, 0.1343],
[ 1.5189, 0.6567],
[ 1.5189, 0.6567],
[ 0.7626, 0.1343],
[-0.5718, 0.2879]])
这个 x_seq 是与所有深度学习标准工具兼容的张量表示。请注意,其形状为 (5,2),填充了随机值——这是因为 Embedding 层将所有内容初始化为随机值,这些值在网络训练过程中通过梯度下降被改变。但是矩阵的第一行和第四行具有相同的值,第二行和第三行也是如此。这是因为输出的顺序与输入的顺序相匹配。标记为“0”的唯一项位于第一行和第四行,所以这两个地方使用了相同的向量。对于标记为“1”的唯一项,它作为第二项和第三项重复。
当处理字符串或任何其他不自然存在为向量的内容时,你几乎总是想要使用嵌入层作为第一步。这是将这些抽象概念转换为我们可以处理表示的标准工具,并完成了图 4.8 的第 1 步。
4.2.3 使用最后一个时间步进行预测
在 PyTorch 中使用 RNN 的任务相当简单,因为 PyTorch 提供了标准 RNN 算法的实现。更困难的部分是在 RNN 处理之后提取最后一个时间步 h[T]。我们想要这样做是因为最后一个时间步是唯一一个基于输入顺序从所有 T 个输入中携带信息的时间步。这样,我们可以使用 h[T] 作为全连接子网络的固定长度摘要。这是因为 h[T] 无论输入序列有多长,都具有相同的形状和大小。所以如果我们的 RNN 层有 64 个神经元,h[T] 将是一个 64 维向量,表示为形状为(B,64)的张量。无论我们的序列有一个项目 T = 1 或 T = 100 个项目,h[T] 总是具有形状(B,64)。这个过程在图 4.12 中展示。

图 4.12 展示了将 RNN 应用于预测序列标签的部分。RNN 的输出是一个隐藏状态序列 h[1], h[2], …, h[T]。最后一个时间步 h[T] 包含了整个序列的信息,因此我们希望将其用作整个序列的表示。这样,它就可以进入一个普通的完全连接网络 f(⋅)。
在我们可以指定 PyTorch 中的 RNN 架构之前,我们需要实现一个新的Module来提取最后一个时间步。在 PyTorch 中存储此类信息时,我们需要处理一些特殊之处。我们需要知道两件事:层数和模型是否为双向。这是因为 RNN 将返回足够的信息从任何层提取结果,这为我们提供了实现其他模型的灵活性,我们将在后面的章节中讨论。我们也会在本章后面讨论双向的含义。
以下代码基于 PyTorch 文档的内容,并执行从 RNN 中提取LastTimeStep h[T]的工作。根据我们使用的具体 RNN(更多内容将在第六章中讨论),RNN Module的输出是一个包含两个张量的元组或一个包含三个张量的嵌套元组,最后一个时间步的激活存储在元组的第二个位置:
class LastTimeStep(nn.Module):
"""
A class for extracting the hidden activations of the last time step
following
the output of a PyTorch RNN module.
"""
def __init__(self, rnn_layers=1, bidirectional=False):
super(LastTimeStep, self).__init__()
self.rnn_layers = rnn_layers
if bidirectional:
self.num_directions = 2
else:
self.num_directions = 1
def forward(self, input):
rnn_output = input[0] ❶
last_step = input[1] ❷
if(type(last_step) == tuple):
last_step = last_step[0]
batch_size = last_step.shape[1] ❸
last_step = last_step.view(self.rnn_layers,
➥ self.num_directions, batch_size, -1) ❹
last_step = last_step[self.rnn_layers-1] ❺
last_step = last_step.permute(1, 0, 2) ❻
return last_step.reshape(batch_size, -1) ❼
❶ 结果是一个元组(out, h[t]) 或者一个元组(out, (h[t], c[t]))
❶ 这通常是 h[t],除非它是一个元组,在这种情况下,它是元组的第一个项目。
❷ 根据文档,形状是‘(num_layers * num_directions, batch, hidden_size)’
❸ 重新塑形,使所有内容都分开
❹ 我们想要最后一个层的输出。
❺ 重新排序,使批次排在前面
❻ 将最后两个维度展平为一个维度
PyTorch 中 RNN 的输出是一个形状为(out,h[T])或(out,(h[T],c[T]))的元组。out 对象包含关于每个时间步的信息,而h[T]只包含关于最后一个时间步但针对每个层的信息。因此,我们检查第二个元素是否为tuple,并提取正确的h[T]对象。c[T]是一个提取的上下文张量,它是我们在第六章中讨论的更高级 RNN 的一部分。
一旦我们有了h[T],PyTorch 会将其作为flatten()过的输入提供。我们可以使用view函数来重塑包含层信息、双向内容(我们很快会讨论这一点)、批大小以及隐藏层中神经元的数量 d 的张量。我们知道我们想要最后一层的输出,所以我们可以索引并使用permute函数将批维度移到前面。
这为我们提供了提取 RNN 最后一层所需的内容,因此我们有了实现图 4.8 中的步骤 2 和 3 的工具。第四步是使用全连接层,我们已知如何使用nn.Linear层来完成这项工作。以下代码完成了所有四个步骤的工作。变量D是nn.Embedding结果的大小,hidden_nodes是 RNN 中的神经元数量,classes是我们试图预测的类的数量(在这个应用中,一个名字可能来自的不同语言的数量):
D = 64
vocab_size = len(all_letters)
hidden_nodes = 256
classes = len(dataset.label_names)
first_rnn = nn.Sequential(
nn.Embedding(vocab_size, D), ❶
nn.RNN(D, hidden_nodes, batch_first=True), ❷
LastTimeStep(), ❸
nn.Linear(hidden_nodes, classes), ❹
)
❶ (B, T) -> (B, T, D)
❷ (B, T, D) -> ( (B,T,D) , (S, B, D) )
❸ 双曲正切激活函数内置在 RNN 对象中,所以我们在这里不需要做。我们取 RNN 输出并将其减少到一个项目,(B, D)。
❹ (B, D) -> (B, classes)
当与 RNN 一起工作时,我们经常同时遇到许多复杂的张量形状。因此,我总是在每一行上包含一个注释,说明由于每个操作,张量形状是如何变化的。输入批处理 B 个长度最多为 T 的项目,因此输入的形状为(B,T)。nn.Embedding层将其转换为(B,T,D)的形状,添加了来自嵌入的D维度。
只有当我们指定batch_first=True时,RNN 才接受形状为(B,T,D)的输入。虽然 PyTorch 的其余部分假设批首先,但 RNN 和序列问题通常假设批维度是第三个。在早期实现中,以这种方式排列张量由于底层技术细节而使它们显著更快,这些细节我们不会深入讨论。虽然这种表示顺序仍然可能更快,但差距今天已经不那么大了。因此,我更喜欢使用batch_first选项,使其与 PyTorch 的其余部分更一致。
注意:PyTorch 中的 RNN 类会自行应用非线性激活函数。在这种情况下是隐式的。这是 PyTorch 对 RNN 的行为与框架其余部分不同的另一个情况。因此,你不应该之后应用非线性激活函数,因为它已经为你完成了。
RNN 返回至少包含两个张量的tuple,但我们的LastTimeStep模块被设计为从这个tuple中提取最后一个时间步的h[T],并返回一个固定长度的向量。由于h[T] ∈ ℝ^D,并且我们在处理 B 个批次的物品,这给我们一个形状为(B,D)的张量。这与我们的全连接网络期望的形状相同。这意味着我们现在可以使用标准的全连接层。在这种情况下,我们可以使用一个来创建一个线性层,进行最终的预测。有了这个,我们再次可以使用train_simple_network函数来训练我们的第一个 RNN:
loss_func = nn.CrossEntropyLoss()
batch_one_train = train_simple_network(first_rnn, loss_func,
train_loader, test_loader=test_loader,
score_funcs={’Accuracy’: accuracy_score}, device=device, epochs=5)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=batch_one_train, label=’RNN’)
[19]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

现在我们已经训练了我们的模型,我们可以对其进行一些实验。让我们尝试输入几个名字,看看模型对它们的看法。记住,我们将所有名字都转换为小写,所以不要使用任何大写字母:
pred_rnn = first_rnn.to("cpu").eval()
with torch.no_grad():
preds = F.softmax(pred_rnn(
dataset.string2InputVec("frank").reshape(1,-1)), dim=-1)
for class_id in range(len(dataset.label_names)):
print(dataset.label_names[class_id], ":",
preds[0,class_id].item()*100 , "
Arabic : 0.002683540151338093 \%
Chinese : 0.2679025521501899 \%
Czech : 10.59301644563675 \%
Dutch : 7.299012690782547 \%
English : 36.81915104389191 \%
French : 0.5335223395377398 \%
German : 37.42799460887909 \%
Greek : 0.018611310224514455 \%
Irish : 0.7783998735249043 \%
Italian : 1.1141937226057053 \%
Japanese : 0.00488687728648074 \%
Korean : 0.421459274366498 \%
Polish : 1.1676722206175327 \%
Portuguese : 0.08807195699773729 \%
Russian : 1.2793921865522861 \%
Scottish : 1.6346706077456474 \%
Spanish : 0.14639737782999873 \%
Vietnamese : 0.40296311490237713 \%
对于像“Frank”这样的名字,我们得到的最大响应是英语和德语作为源语言,这两个答案都是合理的。Frank 在英语中是一个常见的名字,并且有德国血统。你可以尝试其他名字,看看行为如何变化。这也展示了我们可以如何将我们的 RNN 应用于变长输入。无论输入字符串的长度如何,你最终都会得到一个预测,而在我们之前的例子中,使用全连接或卷积层时,输入必须始终是相同的大小。这就是我们使用 RNN 的原因之一,以解决这类问题。
关于分类和伦理的注意事项
我们一直在使用的例子是对现实的过度简化。名字不一定来自一个特定的语言,这是我们的模型暗示的,因为每个名字都被标记为一个正确的答案。这是我们模型简化世界以使我们的建模生活更容易的一个例子。
这是一个很好的、如果简化了的话,可以学习的例子,但它也提供了一个很好的机会来讨论机器学习中涉及的一些伦理问题。简化的假设可能是有用的,并有助于解决实际问题,但它们也可能改变你对问题的看法以及模型最终用户对世界的看法。因此,你应该注意在建模时为什么做出这些假设,模型是用来做什么的,以及它是如何得到验证的。这些通常被称为模型卡片。
例如,想象一下有人使用我们的语言模型来尝试确定用户的背景,并根据他们的名字改变显示的内容。这可以吗?可能不行。一个名字可能来自多个来源,人们除了名字和出生地之外,还有许多其他方式来形成自己的身份,因此在这种情况下使用此模型可能不是最好的主意。
另一个常见的入门级问题是情感分类,你试图确定一个句子或文档是否传达了积极、消极或中性的情感。这种技术可能是有用且有效的。例如,一个食品或饮料品牌可能希望监控 Twitter,看看人们是否在提及带有负面情感的产品的提及,以便公司可以调查潜在的产品故障或不良客户体验。同时,积极、消极和中性并不是唯一的情感,一条信息可以传达更复杂的思想。请确保你的用户了解这些限制,并考虑这些选择。如果你想了解更多关于此类决策的伦理和影响,Kate Crawford (www.katecrawford.net) 是这个领域的专家,她在网站上提供了一些易于阅读的阅读材料。
4.3 通过打包提高训练时间
在构建这个模型时,我们使用了批次大小为 1。这不是训练模型的一种非常有效的方式。如果你将批次大小改为 2 会发生什么?试试看。
你应该会收到一个错误信息。问题在于每个名称的长度不同,因此默认情况下,很难将其表示为张量。张量需要所有维度都一致且相同,但我们的时间维度(在这种情况下,每个名称的字符长度)是不一致的。这就是导致错误的原因:我们有两个不同长度的不同序列。
我们在第二章中看到,通过更有效地利用我们的 GPU 计算能力,对数据批次进行训练可以显著减少训练时间,因此我们有充分的理由增加批次大小。然而,由于输入数据大小不同,我们似乎陷入了使用效率低下的批次大小为 1 的困境。
这是一个抽象问题,因为从根本上讲,没有任何东西阻止 RNN 处理不同时间长度的输入。为了解决这个问题,PyTorch 提供了打包序列抽象(mng.bz/4KYV)。PyTorch 中所有可用的 RNN 类型都支持在这个类上工作。
图 4.13 从概念上展示了当我们打包六个不同长度的不同序列时发生的情况。PyTorch 根据长度组织它们,并首先将所有序列的第一个时间步包含在一个时间批次中。随着时间的推移,较短的序列达到其末尾,批次大小减少到尚未到达末尾的序列数量。

图 4.13 打包长度为 3、3、4、5 和 6 的五个项目的示例。在前三个步骤中,我们处理所有五个序列。在第四步t = 4 时,有两个序列已经结束,所以我们丢弃它们,并仅在长度≥4 的序列上继续批次。打包根据长度组织数据,使其快速高效。
让我们通过这个例子来谈谈正在发生的事情。我们正在尝试对一个包含五个名字的批次进行训练:“ben”、“sam”、“lucy”、“frank”和“edward”。打包过程已按长度从短到长对它们进行排序。在这个序列中总共有 T = 6 个步骤,因为“edward”是最长的名字,有六个字符。这意味着我们的 RNN 总共将执行六次迭代。现在让我们看看每个步骤会发生什么:
-
在第一次迭代中,所有五个项目作为一个大批次一起处理。
-
所有五个项目再次作为一个大批次一起处理。
-
所有五个项目再次处理,但我们已经到达了前两个项目“ben”和“sam”的末尾。我们从这个步骤记录它们的最终隐藏状态h[3],因为那时它们已经完成处理。
-
仅处理包含“lucy”、“frank”和“edward”的三个项目批次,因为前两个项目已经完成。PyTorch 自适应地缩小有效批次大小到剩余的数量。“lucy”已完成,其最终状态保存为h[4]。
-
仅处理包含两个项目的批次。“frank”在此步骤后完成,因此h[5]为“frank”保存。
-
最后一步只处理一个项目,“edward”,这使我们到达批次的末尾和最终的隐藏状态:h[6]。
以这种方式处理确保我们为每个项目获得正确的隐藏状态 h[T],即使它们的长度不同。这也使 PyTorch 运行得更快。我们尽可能进行高效的批量计算,并在需要时缩小批次,这样我们只处理仍然有效的数据。
4.3.1 填充和打包
打包实际上涉及两个步骤:填充(使所有内容长度相同)和打包(存储有关使用了多少填充的信息)。为了实现填充和打包,我们需要覆盖DataLoader使用的collate 函数。这个函数的工作是将许多独立的数据点组合成一个更大的项目批次。默认的 collate 函数可以处理形状相同的张量。我们的数据以元组(x[i],y[i])的形式出现,形状分别为(T[i])和(1)。T[i]对于每个项目可能不同,这通常是一个问题,因此我们需要定义一个新的函数,称为pad_and_pack。它是一个两步过程,如图 4.14 所示。

图 4.14 打包和填充输入的步骤,以便在具有不同长度的数据批次上训练 RNN
输入作为列表对象传递给我们的函数。元组中的每个项目都直接来自Dataset类。因此,如果您更改Dataset以执行独特操作,这就是如何更新DataLoader以与之一起工作。在我们的情况下,Dataset返回一个包含(输入,输出)的元组,因此我们的pad_and_pack函数接受一个元组列表。步骤如下:
-
存储每个项目的长度。
-
创建只包含输入和只包含输出标签的新列表。
-
创建输入列表的 填充 版本。这使得所有张量具有相同的大小,并在较短的项后面附加一个特殊标记。PyTorch 可以使用
pad_sequence函数来完成此操作。 -
使用 PyTorch 的
pack_padded_sequence函数创建输入的 打包 版本。这个函数接受填充版本作为输入,以及长度列表,这样函数就知道每个项目最初有多长。
这就是高级概念。以下代码实现了这个过程:
def pad_and_pack(batch):
input_tensors = [] ❶
labels = []
lengths = []
for x, y in batch:
input_tensors.append(x)
labels.append(y)
lengths.append(x.shape[0]) ❷
x_padded = torch.nn.utils.rnn.pad_sequence( ❸
➥ input_tensors, batch_first=False)
x_packed = ❹
torch.nn.utils.rnn.pack_padded_sequence(x_padded, lengths,
➥ batch_first=False, enforce_sorted=False)
y_batched = torch.as_tensor(labels, dtype=torch.long) ❺
return x_packed, y_batched ❻
❶ 将批输入长度、输入和输出组织为单独的列表
❷ 假设形状是 (T, *)
❸ 创建输入的填充版本
❹ 从填充和长度创建打包版本
❺ 将长度转换为张量
❻ 返回一个包含打包输入及其标签的元组
注意关于此代码的两个要点。首先,我们设置了可选参数 batch_first= False,因为我们的输入数据还没有批维度;它只有长度。如果我们有一个批维度,并且它存储为第一个维度(大多数代码的规范),我们将此值设置为 True。
第二,对于打包步骤,我们设置了标志 enforce_sorted=False,因为我们选择不对输入批次按长度进行预排序。如果我们设置 enforce_sorted=True,我们会得到一个错误,因为我们的输入没有排序。PyTorch 的旧版本要求你自己进行此排序,但当前版本可以在未排序的输入上正常工作。它仍然避免了不必要的计算,并且通常速度相同,所以我们选择了这个更简单的选项。
4.3.2 可打包嵌入层
在我们能够构建一个可以批量训练的 RNN 之前,我们还需要一个额外的项目。结果是 PyTorch 的 nn.Embedding 层不能处理打包输入。我发现创建一个新的包装器 Module 最为简单,该包装器在构造函数中接受一个 nn.Embedding 对象,并修复它以处理打包输入。这在上面的代码中显示:
class EmbeddingPackable(nn.Module):
"""
The embedding layer in PyTorch does not support Packed Sequence objects.
This wrapper class will fix that. If a normal input comes in, it will
use the regular Embedding layer. Otherwise, it will work on the packed
sequence to return a new Packed sequence of the appropriate result.
"""
def __init__(self, embd_layer):
super(EmbeddingPackable, self).__init__()
self.embd_layer = embd_layer
def forward(self, input):
if type(input) == torch.nn.utils.rnn.PackedSequence:
sequences, lengths =
➥ torch.nn.utils.rnn.pad_packed_sequence( ❶
➥ input.cpu(), batch_first=True)
sequences = self.embd_layer(sequences.to( ❷
➥ input.data.device))
return torch.nn.utils.rnn.pack_padded_sequence( ❸
➥ sequences, lengths.cpu(),
➥ batch_first=True, enforce_sorted=False)
else:
return self.embd_layer(input) ❹
❶ 解包输入
❷ 嵌入它
❸ 将其打包到一个新的序列中
❹ 适用于正常数据
第一步是检查输入是否为 PackedSequence。如果是打包的,我们需要首先 解包 输入序列。现在我们有一个解包的张量,因为它要么是作为解包提供的,要么是我们自己解包的,所以我们可以调用我们保存的原始 embd_layer。请注意,因为我们的数据是一个打包的 批次,并且批次维度是 第一个 维度,我们必须设置 batch_first=True 标志。解包会给我们原始的 填充 序列 以及它们各自的 长度。下一行对解包的 序列 执行标准嵌入操作,确保将 sequences 移动到原始 input.data 所在的相同计算设备。我们调用 pack_padded_sequence 再次创建现在已嵌入的输入的打包版本。
4.3.3 训练批处理 RNN
我们有了EmbeddingPackable模块和新的pad_and_pack归一化函数,我们就可以准备批量训练 RNN 了。首先我们需要创建新的DataLoader对象。这看起来和之前一样,只是我们指定了可选参数collate_fn=pad_and_pack,以便它使用pad_and_pack函数创建训练数据批量:
B = 16
train_loader = DataLoader(train_data, batch_size=B, shuffle=True,
➥ collate_fn=pad_and_pack)
test_loader = DataLoader(test_data, batch_size=B, shuffle=False,
➥ collate_fn=pad_and_pack)
在这个例子中,我们选择了一次使用 16 个数据点的批量。下一步是定义我们新的 RNN 模块。我们使用nn.Sequential从EmbeddingPackable构建模型,以执行嵌入,使用nn.RNN创建 RNN 层,使用LastTimeStep提取最终隐藏状态,并使用nn.Linear根据输入进行分类:
rnn_packed = nn.Sequential(
EmbeddingPackable(nn.Embedding(vocab_size, D)), ❶
nn.RNN(D, hidden_nodes, batch_first=True), ❷
LastTimeStep(), ❸
nn.Linear(hidden_nodes, classes), ❹
)
rnn_packed.to(device)
❶ (B, T) -> (B, T, D)
❷ (B, T, D) -> ( (B,T,D) , (S, B, D) )
❸ 将 RNN 输出减少到一个项目,(B, D)
❹ (B, D) -> (B, classes)
最后我们可以训练这个模型。它通过调用我们的train_simple_network来工作,因为我们已经将打包和填充的所有问题抽象到collate_fn和EmbeddingPackable对象中。批量训练也更为高效,所以我们训练了 20 个 epoch——是之前的四倍:
packed_train = train_simple_network(rnn_packed, loss_func, train_loader, test_loader=test_loader, score_funcs={’Accuracy’:
accuracy_score}, device=device, epochs=20)
如果我们看这个模型的准确率,它总体上与批量大小为 1 的训练模型非常相似,但可能略差。对于训练 RNN 来说,这种行为并不罕见——随着时间的推移和多个输入的权重共享可能会使学习 RNN 变得困难。因此,人们通常将 RNN 的批量大小保持相对较小以提高性能,但在接下来的两个章节中,我们将学习帮助解决这个问题的一些技术。
这是图表:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=batch_one_train,
➥ label=’RNN: Batch=1’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=packed_train,
➥ label=’RNN:Packed Input’)
[26]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

然而,这个图是查看性能作为epoch函数的。通过将数据打包到更大的批量中,训练速度要快得多。如果我们看准确率作为总等待时间的函数,打包变得更加具有竞争力:
sns.lineplot(x=’total time’, y=’test Accuracy’, data=batch_one_train,
➥ label=’RNN: Batch=1’)
sns.lineplot(x=’total time’, y=’test Accuracy’, data=packed_train,
➥ label=’RNN:Packed Input’)
[27]: <AxesSubplot:xlabel='total time', ylabel='test Accuracy'>

4.3.4 同时打包和未打包的输入
我们编写代码的方式给我们带来了一点点小的好处,这使得代码在不同场景下更加灵活。RNN 层可以接受打包的序列对象或正常张量。我们新的EmbeddingPackable也支持这两种类型的输入。我们一直在使用的LastTimeStep函数总是返回一个正常张量,因为没有理由或价值将最后一步打包。因此,我们刚才编写的相同代码将适用于打包和非打包输入。我们可以通过尝试预测一些新名字的语言起源来确认这一点:
pred_rnn = rnn_packed.to("cpu").eval()
with torch.no_grad():
preds = F.softmax(pred_rnn(dataset.string2InputVec(
"frank").reshape(1,-1)), dim=-1)
for class_id in range(len(dataset.label_names)):
print(dataset.label_names[class_id], ":",
preds[0,class_id].item()*100 , "%")
Arabic : 0.586722744628787 \%
Chinese : 0.5682710558176041 \%
Czech : 15.79725593328476 \%
Dutch : 5.215919017791748 \%
English : 42.07158088684082 \%
French : 1.7968742176890373 \%
German : 13.949412107467651 \%
Greek : 0.40299338288605213 \%
Irish : 2.425672672688961 \%
Italian : 5.216174945235252 \%
Japanese : 0.3031977219507098 \%
Korean : 0.7202120032161474 \%
Polish : 2.772565931081772 \%
Portuguese : 0.9149040095508099 \%
Russian : 4.370814561843872 \%
Scottish : 1.0111995041370392 \%
Spanish : 1.2703102082014084 \%
Vietnamese : 0.6059217266738415 \%
这样使得代码在训练(在数据批量上)和预测(我们可能不想等待数据批量)时更容易重用。代码在可能或可能不支持打包输入的其他代码中重用也更容易。
这种不一致的支持源于 RNN 和序列处理起来更为复杂。我们投入了大量额外的工作来使代码能够处理数据批次,而网上许多代码并没有这样做。当你学习关于训练或使用 RNN 的新技术时,它可能不支持像这个代码那样打包的输入。通过以我们这种方式编写代码,你可以获得两全其美的效果:使用标准工具进行更快的批量训练,并且与其他可能没有投入同样努力的工具兼容。
4.4 更复杂的 RNN
更复杂的 RNN 可用:特别是,我们可以创建具有多层和从右到左处理信息(除了从左到右)的 RNN。这两个变化都提高了 RNN 模型的准确率。虽然我们似乎有两个新的概念要学习关于 RNN,但 PyTorch 使得这两个概念都很容易添加,只需付出最小的努力。
4.4.1 多层
就像我们学过的其他方法一样,你可以堆叠多个 RNN 层。然而,由于训练 RNN 的计算复杂性,PyTorch 提供了高度优化的版本。你不需要手动在序列中插入多个 nn.RNN() 调用,而是可以传递一个选项来告诉 PyTorch 使用多少层。图 4.15 展示了一个具有两层 RNN 的示意图。

图 4.15 展示了一个具有两层 RNN 的示例。箭头显示了从一层 RNN 单元到另一层的连接。同一层中颜色相同的块共享权重。输入向量从底部进入,最后一个 RNN 的输出可以进入一个全连接层以产生预测。
在 RNN 中添加多个层会重复隐藏单元从上一层的相同级别和当前时间步的上一级结果中获取输入的模式。如果我们想要一个具有三个循环层的模型,只需将 num_layers=3 添加到我们的 RNN 和 LastTimeStep 对象中就足够简单了:
rnn_3layer = nn.Sequential(
EmbeddingPackable(nn.Embedding(vocab_size, D)), ❶
nn.RNN(D, hidden_nodes, num_layers=3, batch_first=True), ❷
LastTimeStep(rnn_layers=3), ❸
nn.Linear(hidden_nodes, classes), ❹
)
rnn_3layer.to(device)
rnn_3layer_results = train_simple_network(rnn_3layer, loss_func,
train_loader, test_loader=test_loader, lr=0.01,
score_funcs={’Accuracy’: accuracy_score}, device=device, epochs=20,
)
❶ (B, T) -> (B, T, D)
❷ (B, T, D) -> ( (B,T,D) , (S, B, D) )
❸ 将 RNN 输出减少到一个项目,(B, D)
❹ (B, D) -> (B, classes)
绘制三层方法的准确率图表明,它通常表现更好,但通常不会更差。其中一些又与 RNN 的复杂性有关。我的建议是查看在架构中使用两到三层循环组件。虽然更多层可以做得更好,但它变得非常昂贵,并且训练 RNN 的困难可能会阻碍深度带来的收益。我们将在本书的后面部分了解其他可以带来更多优势的技术。
下面是图表:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=packed_train,
➥ label=’RNN: 1-Layer’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=rnn_3layer_results,
➥ label=’RNN: 3-Layer’)
[30]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

4.4.2 双向 RNN
更高级的改进是创建一个 双向 RNN。你可能已经注意到,我们的 RNN 总是从左到右进行,但这可能会使学习变得具有挑战性。如果我们需要的信息出现在输入序列的前面呢?RNN 必须确保信息在多个时间步中存活,每个时间步都是引入噪声或使其他信息模糊过去的机会。
要思考为什么保留信息随时间推移变得困难的一个好方法是通过想象一个极端场景。比如说,你的 RNN 层中只有 32 个神经元,但时间序列有 10 亿步长。RNN 中的信息完全存在于只有 32 个值的宇宙中,这根本不足以在经过十亿次操作后保持该信息完整。
为了让 RNN 更容易地从长序列中获取所需信息,我们可以让 RNN 同时向两个方向遍历输入,并将这些信息与 RNN 的下一层共享。这意味着在 RNN 的第二层,时间步 1 有关于时间步 T 的 一些 信息。信息在 RNN 中更均匀地积累,这可以使学习更容易。这种 RNN 的连接在图 4.16 中显示。

图 4.16 双向 RNN。每个时间步的输出都进入两个 RNN:一个从左到右处理输入(绿色),另一个从右到左处理输入(红色)。两个 RNN 的输出通过连接组合,在每个步骤创建一个新项目。然后这个项目进入两个输出。
注意,现在的最后一个时间步现在部分来自序列中最左侧和最右侧的项目。我们的 LastTimeStep 函数已经处理了这一点,这就是我们为什么要实现它的原因。它允许我们无缝地处理这个新特性。
高效且准确地实现双向 RNN 并非易事。幸运的是,PyTorch 再次让这变得简单:只需设置 bidirectional=True 标志。但请注意,现在的最终隐藏状态现在大了两倍,因为我们有了来自每个方向的最终激活,所以我们的 LastTimeStep 的值是预期的两倍。只要我们记得在最终的 nn.Linear 层中乘以 hidden_nodes*2,这仍然可以工作,只需做少许改动。新的代码如下:
rnn_3layer_bidir = nn.Sequential(
EmbeddingPackable(nn.Embedding(vocab_size, D)), ❶
nn.RNN(D, hidden_nodes, num_layers=3,
batch_first=True, bidirectional=True), ❷
LastTimeStep(rnn_layers=3, bidirectional=True), ❸
nn.Linear(hidden_nodes*2, classes), ❹
)
rnn_3layer_bidir.to(device)
rnn_3layer_bidir_results = train_simple_network(rnn_3layer_bidir,
➥ loss_func, train_loader, test_loader=test_loader,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device,
➥ epochs=20, lr=0.01)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=packed_train,
➥ label=’RNN: 1-Layer’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=rnn_3layer_results,
➥ label=’RNN: 3-Layer’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=rnn_3layer_bidir_results,
➥ label=’RNN: 3-Layer BiDir’)
[32]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>
❶ (B, T) -> (B, T, D)
❷ (B, T, D) -> ( (B,T,D) , (S, B, D) )
❸ 将 RNN 输出减少到一项,(B, D)
❹ (B, D) -> (B, classes)

结果表明,双向 RNN 有明显的优势。只要可能,就使用双向 RNN,因为它使网络学习跨时间访问信息变得容易得多。这导致我们简单问题的准确性有所提高。但也会有你不想或不能使用双向版本的情况;这些例子将在本书的后面部分介绍。
练习
在 Manning 在线平台 Inside Deep Learning Exercises(liveproject.manning.com/project/945)上分享和讨论你的解决方案。一旦你提交了自己的答案,你将能够看到其他读者提交的解决方案,并看到作者认为哪些是最好的。
-
修改
LanguageNameDataset,使得构造函数中的vocabulary对象不需要作为参数传入,而是可以从输入数据集中推断出来。这意味着你需要遍历数据集并创建一个包含所有实际出现的字符的字典。一种实现方式是创建一个默认值vocabulary=None,并使用is vocabulary None:来改变行为。 -
在
LanguageNameDataset的构造函数中添加一个标志unicode=False。修改任何需要的代码,以便当unicode=True时,Language-``NameDataset将保留在vocabulary=None时看到的所有 Unicode 字符(这取决于练习 1)。使用unicode=True训练一个新的 RNN 分类器。这如何影响结果? -
在
LanguageNameDataset的构造函数中添加一个新的min_count=1参数。如果vocabulary=None,它应该用特殊的"UNK"标记替换出现次数太少的任何字符,表示未知值。设置min_count=300如何影响词汇表的大小,以及结果会发生什么变化? -
这个任务的原始训练/测试集分割是通过随机采样数据集创建的。创建你自己的函数来执行分层分割:选择一个测试集,其中每个类的比例相同。这如何影响你的表面结果?
-
将 RNN 实现中的最后一个输出层
nn.Linear(hidden_nodes, classes)替换为一个具有两个隐藏层和一个输出层的全连接网络。这如何影响模型的准确性? -
你可以使用归一化函数来实现有趣的功能。为了更好地理解其工作原理,实现你自己的
collate_fn,该函数从一批训练数据中删除一半的项目。使用这个模型的两个 epoch 进行训练与使用一个 epoch 的正常collate_fn进行训练获得相同的结果吗?为什么或为什么不? -
比较训练一个三层双向 RNN,批大小为B = {1, 2, 4, 8},进行五个 epoch。哪个批大小似乎在速度和准确性之间提供了最佳平衡?
摘要
-
递归神经网络(RNNs)用于处理以序列形式到来的数据(例如,文本)。
-
RNN 通过使用权重共享来工作,因此相同的权重被用于序列中的每个项目。这使得它们能够处理可变长度的序列。
-
文本问题需要一个标记的词汇表。嵌入层将这些标记转换为 RNN 的输入向量。
-
隐藏状态被传递到序列的下一步,并向上传递到下一层。右上角的隐藏状态(最后一层,正在处理的最后一个项目)是整个序列的表示。
-
为了在序列批次上训练,我们将它们填充到相同的长度,并使用打包函数,以确保只处理输入的有效部分。
-
通过双向处理数据(从左到右和从右到左)可以提高 RNN 的准确率。
¹ 这是一个真实的模型类型,但它往往需要很长时间来训练。我们不会做任何如此复杂的例子。↩
5 现代训练技术
本章涵盖了
-
使用学习率调度改进长期训练
-
使用优化器改进短期训练
-
结合学习率调度和优化器来提高任何深度模型的结果
-
使用 Optuna 调整网络超参数
到目前为止,我们已经学习了神经网络的基础知识以及三种类型的架构:全连接、卷积和循环。这些网络使用一种称为随机梯度下降(SGD)的方法进行训练,这种方法自 20 世纪 60 年代以来一直被使用。自那时以来,已经发明了新的改进方法来学习我们网络的参数,如动量和学习率衰减,这些方法可以通过在更少的更新中收敛到更好的解决方案来提高任何问题的任何神经网络。在本章中,我们将了解深度学习中一些最成功和最广泛使用的 SGD 变体。
因为神经网络没有约束,它们往往最终会陷入具有许多局部最小值的复杂优化问题,如图 5.1 所示。这些最小值似乎通过尽可能多的减少损失来实现,这意味着我们的模型已经学习到了,但全局上下文表明其他最小值可能更好。并非所有局部最小值都是“坏的”;如果一个最小值足够接近全局最小值,我们可能会得到好的结果。但有些局部最小值根本无法给我们提供我们想要的预测准确性的模型。因为梯度下降是“局部”做出决定的,一旦我们达到局部最小值,我们就不知道这个最小值有多“好”,或者如果我们想要尝试找到一个新的最小值,我们应该朝哪个方向前进。

图 5.1 神经网络可能遇到的损失景观示例。y 轴上的损失是我们想要最小化的,但有许多局部最小值。有一个全局最小值是最佳解决方案,有两个局部最小值几乎一样好——还有一个坏的最小值,它将不起作用。
在本章中,我们学习了学习率调度和优化器,这些可以帮助我们更快地达到更好的最小值,从而使得神经网络在更少的 epoch 中达到更高的准确性。同样,这适用于你将训练的每一个网络,并且在现代设计中无处不在。
为了做到这一点,我们快速回顾一下梯度下降,看看它如何可以被分成两部分:梯度和学习率调度。PyTorch 为这两者都提供了特殊的类,因此我们编写了一个新的train_network函数,将我们通过书籍看到的train_simple_network函数替换掉,该函数结合了这两个抽象。这些新的抽象将在 5.1 节中介绍。然后我们开始填充这些抽象的细节以及它们是如何工作的,首先是学习率调度在 5.2 节中,然后是梯度更新策略在 5.3 节中。
我们还忽略了一个第二个优化问题,那就是如何选择所有这些超参数,比如层数、每层的神经元数量等等。在第 5.4 节中,我们将学习如何使用一个名为 Optuna 的工具来选择超参数。Optuna 具有特殊功能,使其在拥有许多超参数时设置超参数变得非常出色,并且避免了训练许多不同网络以尝试参数的完整成本。
本章篇幅略长,因为我们解释了为什么这些使用梯度的较新技术可以减少获得良好解决方案所需的迭代次数,而许多其他资料则直接跳到使用这些改进方法。这一点也很重要,因为研究人员一直在努力改进策略,而我们从原始的随机梯度下降(SGD)到现代方法的额外努力将帮助你欣赏未来改进的潜力,并理解为什么它们可能有所帮助。特别是 Optuna 部分,可能会显得有些奇怪,因为我们不会在其他章节中使用这个主题,因为它可能会妨碍学习其他技术。但 Optuna 及其调整超参数的方法是深度学习中的一个关键实际技能,这将帮助你保持高效并构建更精确的解决方案。
5.1 两部分的梯度下降
到目前为止,我们一直将学习通过梯度下降视为一个单一的方程和过程。我们选择一个损失函数 ℓ 和一个网络架构 f,然后梯度下降就更新权重。因为我们想改进梯度下降的工作方式,我们首先需要识别和理解它的两个组成部分。通过认识到这两个组成部分具有不同的行为,我们可以制定策略来改进每一个,以便我们的网络在更少的迭代次数中学习更精确的解决方案。让我们先快速回顾一下梯度下降是如何工作的。
记住,我们在深度学习中所做的一切都是通过将网络视为一个巨大的函数 fΘ 来实现的,我们需要使用相对于 f 的参数 (Θ) 的梯度 (∇) 来调整其权重。因此,我们执行

这被称为梯度下降,是所有现代神经网络训练的基础。然而,我们在如何执行这个关键步骤方面还有很大的改进空间。我们用 g^t 作为梯度的简写,因为我们在这个部分中经常提到它。请注意,我们像它是一个序列的一部分那样包含了这个 ^t 上标。这是因为当我们学习时,我们为处理的数据的每一批都得到一个新的梯度,因此我们的模型是从一系列梯度中学习的。我们将在后面利用这一点。
使用这种简写,可以更清楚地看出这个过程只有两个部分:梯度 g^t 和学习率 η,正如我们在这里所看到的。我们只能改变这两个部分来尝试改进:

5.1.1 添加学习率调度器
让我们讨论早期更新方程的问题。首先,存在一个关于学习率 η 的问题。我们为 每个 epoch 的训练中的每个批次 选择 一个 学习率。这可能是一个不合理的期望。以一个类比来说明,想象一辆火车从一个城市开往另一个城市。火车不会以全速行驶,然后到达目的地后立即停下来。相反,火车在接近目的地时会减速。否则,火车会呼啸而过目的地。
因此,我们可以讨论的第一种改进类型是将学习率 η 作为优化过程中我们进展程度的函数来调整(t)。让我们称这个函数为 L,并给它一个 初始 学习率 η[0] 和当前的迭代步骤 t 作为输入:

我们很快会回顾如何定义 L(η[0],t) 的细节。现在,重要的是要理解我们已经创建了一个用于调整学习率的抽象,这个抽象被称为 学习率调度器。我们可以根据需要或问题使用这个调度器来替换 L。在我们用代码展示这个功能之前,我们需要讨论更新方程的第二个部分,它与 PyTorch 中的学习率调度器 L(⋅,⋅) 密切相关。
5.1.2 添加优化器
这一部分没有令人满意的名称;PyTorch 称之为优化器,但这也描述了整个过程。尽管如此,我们仍将使用这个名字,因为它是最常见的——这是我们 使用 梯度 g^t 的方式。所有信息和学习都来自 g^t;它控制网络学习的内容以及学习效果的好坏。学习率 η 简单地控制我们跟随该信息的速度。但梯度 g^t 的好坏取决于我们用来训练模型的数据。如果我们的数据有噪声(而且几乎总是如此),我们的梯度也会有噪声。
这些噪声梯度及其对学习的影响在图 5.2 中用三种其他情况展示。一个是理想的梯度下降路径,这几乎从未发生。其他两个是真实问题。例如,我们有时会得到一个g^t 梯度,它只是太大。这发生在我们向网络添加数百层时,是一个常见的问题,称为梯度爆炸。从数学上讲,这将是一个∥g^t∥[2] → ∞的情况。如果我们使用这个梯度,我们可能采取比我们原本打算的更大的步骤(即使η很小),这可能会降低性能甚至完全阻止我们学习。相反的情况也会发生;梯度可能太小∥g^t∥[2] → 0,导致我们无法在训练中取得进展(即使η很大)。这被称为梯度消失,几乎在任何架构中都会发生,但当我们使用 tanh(⋅)和σ(⋅)激活函数时尤其常见。¹ 在查看这四种情况时,请记住,优化也意味着最小化,因此从高值(红色)到低值(绿色)的过程实际上是神经网络“学习”的方式。

图 5.2 展示了梯度下降的三个场景的玩具示例。每个场景都是一个从红色(高值,不好)到深绿色(低值,好)的 2D 优化等高线图。理想情况在左上角,每个梯度正好指向解。右上角显示了噪声梯度,导致下降方向不完全正确,需要更多步骤。左下角显示了梯度爆炸,我们开始采取过大的步骤,远离解。右下角显示了梯度消失,梯度变得如此之小,以至于我们必须采取过多的步骤才能到达解。
在这些情况下,天真地使用原始梯度 g^t 可能会误导我们。再次强调,我们可能需要引入一个抽象,它将 g^t 作为输入,并执行更聪明的操作以避免这些情况。我们称这个函数为 G,它改变梯度,使其表现更好并帮助加速学习。因此,我们现在再次更新我们的方程,得到:

现在我们有一个新的梯度下降方程。它具有之前所有的基本组件,还有一些额外的抽象使其更加灵活。这两种策略——调整学习率和梯度——在现代深度学习中无处不在。因此,它们在 PyTorch 中都有接口。所以让我们定义一个新的train_simple_network函数版本,它允许这两种改进。然后我们可以使用这个新函数来比较新技术的效果,并在以后继续使用它们。
L(⋅,⋅) 和 G(⋅) 之间的相互作用
学习率计划和梯度更新最终解决类似的目标,那么为什么要有两者而不是选择其一?你可以把它们看作是在两个时间尺度上工作。学习率计划L(⋅,⋅)在每个 epoch 中运行一次来调整全局进度率。梯度更新函数G(⋅)在每个 batch 上操作,所以如果你有数百万个数据点,你可能要调用G(⋅)数百万次,但最多只调用L(⋅,⋅)几百次。所以你可以把这些东西看作是在长期和短期策略之间进行平衡,以最小化函数。像生活中的许多事情一样,保持平衡比仅仅关注短期或长期目标要好。
5.1.3 实现优化器和调度器
PyTorch 为我们提供了两个接口来实现我们描述的L(⋅,⋅)和G(⋅)函数。第一个是torch.optim.Optimizer类,你之前已经使用过。我们开始替换SGD对象,但使用具有相同接口的其他优化器,所以几乎不需要更改代码。新的类是_LRScheduler,它为我们提供了几个选项。为了实现我们的train_network函数,我们只需对train_simple_network代码进行一些修改。图 5.3 显示了高级过程,其中只有黄色中的一个新步骤,对更新步骤的轻微更改,使用G(⋅)表示我们可以更改梯度使用的方式。

图 5.3 新的训练循环过程图。主要变化是黄色的新步骤 1,显示我们有一个学习率计划L(⋅,⋅)。该计划决定了步骤 4 中过程使用的学习率η[t]。其他一切保持不变。
更新训练代码
现在,让我们谈谈我们需要对train_simple_network代码进行的三个更改。我们首先更改的是函数签名,因此现在有两个新的选项可用:优化器和调度器。新的签名如下所示,其中optimizer用于G(⋅),而lr_schedule用于L(⋅,⋅):
def train_network(model, loss_func, train_loader, val_loader=None,
➥ test_loader=None, score_funcs=None, epochs=50, device="cpu",
➥ checkpoint_file=None, optimizer=None, lr_schedule=None):
注意,我们将两者都设置为默认值为None。指定一个计划是始终可选的,你可能想根据手头的任务更改所使用的计划。一些问题只需要几个 epoch,而另一些则需要数百甚至数千个 epoch,这两个因素都取决于你有多少数据。出于这些原因,我不喜欢要求学习率计划始终被使用。我更喜欢先不使用它,然后根据问题添加它。然而,我们必须始终使用某种类型的优化器。所以如果没有给出,我们将添加以下代码来使用一个好的默认值(我们将在本章后面了解它是如何工作的):
if optimizer == None:
optimizer = torch.optim.AdamW(model.parameters()) ❶
❶ AdamW 是一个好的默认优化器。
意想不到的是,我们已完成了一半的新代码。我们不需要对run_epoch方法进行任何修改,因为通常是在每个epoch之后而不是每个batch(一个 epoch 由多个 batch 组成)更改学习率。因此,在我们的run_epoch函数完成后,我们可以添加以下代码:
if lr_schedule is not None:
if isinstance(lr_schedule, ReduceLROnPlateau):
lr_schedule.step(val_running_loss)
else:
lr_schedule.step() ❶
❶ 在 PyTorch 中,惯例是在每个 epoch 后更新学习率。
再次,我们将在稍后了解ReduceLROnPlateau。它是学习率调度器家族中的一个独特且特殊的成员,它需要一个额外的参数。否则,我们只需在每个 epoch 结束时调用step()函数,学习率调度就会自动更新。你可能认识这个方法是我们用于run_epoch中的Optimizer类的相同方法,它在每个 batch 结束时调用optimizer.step()。这是一个有意的设计选择,以使两个紧密耦合的类保持一致。你可以在 idlmam.py 文件中的代码中找到完整的函数定义(github.com/EdwardRaff/Inside-Deep-Learning/blob/main/idlmam.py)。
使用新的训练代码
这就是准备我们的代码以进行一些新的改进学习所需的所有步骤。现在我们已经实现了新的加载函数,让我们训练一个神经网络。我们使用 Fashion-MNIST 数据集,因为它稍微更具挑战性,同时保留了与原始 MNIST 语料库相同的大小和形状,这将使我们能够在合理的时间内完成一些测试。
在这里,我们加载 Fashion-MNIST,定义一个多层全连接网络,然后以与使用我们旧的train_simple_network方法等效的方式进行训练。为此,我们需要通过指定 SGD 优化器来稍微增加一些仪式感:
epochs = 50 ❶
B = 256 ❷
train_data = torchvision.datasets.FashionMNIST("./data", train=True,
transform=transforms.ToTensor(), download=True)
test_data = torchvision.datasets.FashionMNIST("./data", train=False,
transform=transforms.ToTensor(), download=True)
train_loader = DataLoader(train_data, batch_size=B, shuffle=True) test_loader = DataLoader(test_data, batch_size=B)
❶ 50 个 epoch 的训练
❷ 一个可尊敬的平均批量大小
现在我们编写一些更熟悉的代码,一个包含三个隐藏层的全连接网络:
D = 28*28 ❶
n = 128 ❷
C = 1 ❸
classes = 10 ❹
fc_model = nn.Sequential(
nn.Flatten(),
nn.Linear(D, n),
nn.Tanh(),
nn.Linear(n, n),
nn.Tanh(),
nn.Linear(n, n),
nn.Tanh(),
nn.Linear(n, classes),
)
❶ 输入中有多少个值?我们使用这个值来帮助确定后续层的大小。
❷ 隐藏层大小
❸ 输入中有多少个通道?
❹ 有多少个类别?
最后,我们需要确定一个默认的起始学习率η[0]。如果我们不提供任何类型的学习率调度L(⋅,⋅),则η[0]将用于每个 epoch 的训练。我们将使用η[0] = 0.1,这比通常想要的更激进(也就是说,大)。我选择这个较大的值是为了更容易展示我们可以选择的调度的影响。在正常使用中,不同的优化器往往有不同的首选默认值,但使用η[0] = 0.001 通常是一个好的起点:
eta_0 = 0.1
在此基础上,我们可以定义一个与之前相同的简单SGD优化器。我们只需要显式调用torch.optim.SGD并将其传递给我们自己,这可以在以下代码中看到。注意,我们在optimizer的构造函数中设置了默认学习率η[0]。这是 PyTorch 中的标准流程,我们可能使用的任何LRSchedule对象都将进入optimizer对象以改变学习率:
loss_func = nn.CrossEntropyLoss()
fc_results = train_network(fc_model, loss_func, train_loader,
➥ test_loader=test_loader, epochs=epochs,
➥ optimizer=torch.optim.SGD(fc_model.parameters(), lr=eta_0),
➥ score_funcs={’Accuracy’: accuracy_score}, device=device)
就像之前一样,我们可以使用 seaborn 库,通过返回fc_results pandas 数据框来快速绘制结果。以下代码和输出显示了我们可以得到的结果,明显地,随着训练的深入,我们得到了更清晰的学习效果,但偶尔也会出现回归。这是因为我们的学习率有点过于激进,但这种行为在具有非激进学习率(如η[0] = 0.001)的实际问题中是常见的:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_results, label=’Fully
➥ Connected’)
[12]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

5.2 学习率调度
现在我们来谈谈实现我们之前描述的学习率调整(L(η[0],t))的方法。在 PyTorch 中,这些被称为学习率调度器,它们将optimizer对象作为输入,因为它们直接改变optimizer对象中使用的学习率η。高级方法如图 5.4 所示。我们唯一需要改变的是用于L(η[0],t)的方程,以在调度方案之间切换。

图 5.4 每个学习率调度的通用过程。我们在训练开始前设置初始值,进行一次训练 epoch,然后改变η[t + 1],这是下一个 epoch 使用的。梯度优化器G(⋅)在每个 epoch 中被多次使用,并且独立于学习率调度。
我们将讨论四种调整学习率的方案,你应该了解。每个方案都有其优缺点。你可以选择你的方案来尝试稳定一个训练不一致的模型(准确率在各个 epoch 之间上下波动),减少训练所需的 epoch 数量以节省时间,或者最大化最终模型的准确率。对于本书的大部分内容,我们使用非常少的训练迭代(10 到 50 次)以确保运行在合理的时间内。当你处理现实生活中的问题时,通常需要训练 100 到 500 个 epoch。你进行的训练 epoch 越多,学习率调度方案的影响就越大,因为改变学习率的机会就越多。所以虽然我们在这本书的其余部分可能不会大量使用这些方案,但你仍然应该了解它们。
我们首先讨论现代使用中最基本的四种学习率调度方案以及它们帮助解决的优化问题类型,以及如何解决。我们将从直观的角度来讨论这些内容,因为证明它们比我们在这本书中想要做的数学要复杂得多。一旦我们回顾了这四种方法,我们将进行一次比较它们的实验。
5.2.1 指数衰减:平滑波动训练
我们首先讨论的方法可能不是最常见的方法,但它是其中最简单的一种。它被称为指数衰减率。如果你的模型行为不稳定,损失或准确率大幅增加和减少,指数衰减率是一个不错的选择。你可以使用指数衰减来帮助稳定训练并获得更一致的结果。我们选择一个 0 < γ < 1 的值,在每次 epoch 后乘以我们的学习率。它由以下函数定义,其中 t 代表当前 epoch:

PyTorch 通过 torch.optim.lr_scheduler.ExponentialLR 类提供这一功能。因为我们通常进行很多个 epoch,所以确保 γ 不要设置得太激进是很重要的:从期望的最终学习率开始是一个好主意,并将其称为η[min]。然后,如果你总共训练了 T 个 epoch,你可以设置

确保你选择的 γ 值能够达到你期望的最小值,并确保学习过程持续进行。
例如,假设初始学习率是 η[0] = 0.001,你希望最小值为 η[min] = 0.0001,并且你训练了 T = 50 个 epoch。你需要设置 γ ≈ 0.91201。以下代码模拟了这一过程,并展示了如何编写代码来计算 γ:
T=50 ❶
epochs_input = np.linspace(0, 50, num=50) ❷
eta_init = 0.001 ❸
eta_min = 0.0001 ❹
gamma = np.power(eta_min/eta_init,1./T) ❺
effective_learning_rate = eta_init * np.power(gamma, epochs_input) ❻
sns.lineplot(x=epochs_input, y=eta_init, color=’red’, label="$\eta_0$")
ax = sns.lineplot(x=epochs_input, y=effective_learning_rate, color=’blue’,
➥ label="$\eta_0 \cdot \gamma^t$")
ax.set_xlabel(’Epoch’)
ax.set_ylabel(’Learning Rate’)
[13]: Text(0, 0.5, 'Learning rate')
❶ 总 epoch 数
❶ 生成所有 t 值
❶ 假设初始学习率 η[0]
❶ 假设期望的最小学习率 η[min]
❶ 计算衰减率 γ
❶ 所有 η[t] 值

指数衰减率会平滑且一致地降低每个 epoch 的学习率。从高层次来看,有很多人实现这一目标的方法。有些人使用线性衰减 (η[0]/(γ⋅t)) 而不是指数衰减 (η[0] ⋅ γ^t),还有各种其他方法可以达到相同的目标;没有人能给你一个明确的操作手册或流程图来选择指数衰减及其相关家族成员。它们都遵循一个相似的理由来解释为什么它们有效,我们将通过以下内容进行讲解。
尤其是指数学习率调度器有助于解决接近解但未能完全达到解的问题。图 5.5 展示了这种情况。黑色线条显示了参数 Θ 随着它们从一个步骤到下一个步骤的变化所采取的路径。初始权重是随机的,所以我们从一个不好的权重集 Θ 开始,并且最初更新将我们推向局部最小值。但随着我们接近最小值,我们开始在该最小值周围弹跳——有时比前一步更接近,有时又远离,这导致损失上下波动。

图 5.5 指数学习率帮助解决的极小化问题示例。当你离解还很远时,大的 η 有助于你更快地到达正确的 区域。但一旦你处于正确的区域,η 就太大,无法 达到 最佳解。相反,它不断超过局部最小值(在这种情况下,它是唯一的极小值,因此也是全局最小值)。
回到我们关于火车前往目的地的类比,当你离目标还很远时,开 快 是很棒的,因为它能更快地让你接近目标。但一旦你接近目的地,减速是个好主意。如果车站只有 100 英尺远,火车以每小时 100 英里的速度行驶,火车就会飞快地驶过车站。你希望火车开始减速,以便到达一个精确的位置,这就是指数学习率的作用;示例见图 5.6。

图 5.6 在每一步缩小 η 会使得优化随着接近目的地而减速。这有助于它收敛到局部最小值而不是在其周围弹跳。
使用指数学习率的高招是设置 η[min]。我通常建议将其设置为 η[0] 的 10 到 100 倍小。在机器学习中,10 到 100 倍的减少是正常的,这也是我们在使用的计划中会看到的一个主题。以下代码展示了我们如何通过在构造时传入 optimizer 作为参数来创建指数衰减计划。我们还使用第一行将学习到的权重重置为随机值,这样我们就不必一次又一次地重新指定相同的神经网络:
fc_model.apply(weight_reset) ❶
eta_min = 0.0001 ❷
gamma_expo = (eta_min/eta_0)**(1/epochs) ❸
optimizer = torch.optim.SGD(fc_model.parameters(), ❹
➥ lr=eta_0)
scheduler = torch.optim.lr_scheduler. ❺
➥ ExponentialLR(optimizer, gamma_expo)
fc_results_expolr = train_network(fc_model, loss_func, train_loader,
➥ test_loader=test_loader, epochs=epochs, optimizer=optimizer,
➥ lr_schedule=scheduler, score_funcs={’Accuracy’: accuracy_score},
➥ device=device)
❶ 重新随机化模型权重,这样我们就不需要再次定义模型
❷ 所需的最终学习率 η[min]
❸ 计算导致 η[min] 的 γ
❹ 设置优化器
❺ 选择一个计划并传入优化器
5.2.2 步长下降调整:更好的平滑
第二种策略是我们刚刚讨论的指数衰减的一个特别受欢迎的变体。步长下降 方法具有相同的动机,也有助于稳定学习,但通常在训练后提供更高的精度。步长下降和指数衰减之间的区别是什么?我们不是不断略微调整学习率,而是让它保持固定一段时间,然后仅几次大幅下降。这如图 5.7 所示。

图 5.7 步长下降策略要求我们决定一个初始学习率、衰减因子 γ 和频率 S。每经过 S 个训练周期,我们就将学习率按 γ 的因子减少。
这种方法的逻辑是,在优化的早期,我们仍然离解很远,所以我们应该尽可能快地前进。对于前 S 个轮次,我们以最大速度 η[0] 向解前进。这(希望)比指数衰减更好,因为指数衰减会立即减速,因此(至少在开始时)是反效果的。相反,让我们以最大速度继续前进,并简单地一次性或两次立即降低学习率。这样,我们可以尽可能长时间地以最大速度前进,但最终会减速以收敛到解。
我们也可以用更数学的符号表达这种策略。如果我们想每 S 个轮次降低一次学习率,我们得到

如果你将这个方程与指数衰减日进行比较,你应该注意到它们看起来几乎相同并且紧密相连。如果我们将步长下降策略设置为每轮都下降(S = 1),我们得到 γ^(⌊t/1⌋) = γ^t,这正好是指数衰减。因此,步长下降策略减少了我们降低学习率的频率,并通过增加衰减量来平衡这一点。
关于从 η[0] 到 η[min] 下降 10 到 100 倍的规则同样适用,所以我们通常将 γ 设置在 0.1、0.5 的范围内,并设置 S 以确保学习率在训练过程中只调整两到三次。同样,PyTorch 使用 StepLR 类提供了这一点。以下代码显示了 StepLR 与指数衰减相比可能的样子;你可以看到,它在大多数轮次中具有更高的学习率,但在训练结束时的学习率较低:
[15]: Text(0, 0.5, 'Learning Rate')

与所有学习率调度一样,我们在构造时传入optimizer。以下代码显示了这一点,其中我们通过一个因子 γ = 0.3 进行四次学习率下降。最后一次下降发生在最后几个轮次,我们总共的轮次要少得多,这意味着前三次非常重要。尽管四次下降等于学习率减少了 1/0.3⁴ ≈ 123 倍,但大多数训练都是在 1/0.3³ ≈ 37 倍的下降(或更少)下进行的:
fc_model.apply(weight_reset)
optimizer = torch.optim.SGD(fc_model.parameters(), lr=eta_0)
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, ❶
epochs//4, gamma=0.3)
fc_results_steplr = train_network(fc_model, loss_func, train_loader,
➥ test_loader=test_loader, epochs=epochs, optimizer=optimizer,
➥ lr_schedule=scheduler, score_funcs={’Accuracy’: accuracy_score},
➥ device=device)
❶ 指示它每 4 个轮次下降一个因子 f γ,所以总共发生四次。
5.2.3 余弦退火:更高的精度但更低的稳定性
下一个学习率策略既奇特又有效:余弦退火。余弦退火与指数衰减和步长学习率的逻辑和策略不同。后两者只降低学习率,但余弦退火会降低和增加学习率。这种方法对于获得最佳结果非常有效,但并不提供相同程度的不稳定性,因此可能不适合表现不佳的数据集和网络。
余弦退火也有一个初始学习率 η[0] 和一个最小率 η[min];区别在于我们在最小和最大学习率之间交替。数学遵循以下方程,其中 T[max] 是周期之间的 epoch 数量:

余弦项上下波动,就像余弦函数通常所做的那样,我们将余弦重新缩放,使其在 η[0] 处达到最大值而不是 1,在 η[min] 处达到最小值而不是-1。PyTorch 通过 CosineAnnealingLR 类提供这一点。T[max] 成为模型的新超参数。我喜欢将 T[max] 设置为 10 到 50 个总振荡波谷,并且我们希望始终结束在一个波谷(通过减速到达目的地)。例如,如果我们想要 S 个波谷,我们使用 T[max] = T/(S⋅2−1)。以下代码通过设置 T[max] = T/(2⋅2−1) 显示了余弦调度中的两个振荡波谷:
cos_lr = eta_min +
0.5*(eta_init-eta_min)*(1+np.cos(epochs_input/(T/3.0)*np.pi)) ❶
sns.lineplot(x=epochs_input, y=eta_init, color=’red’, label="$\eta_0$")
sns.lineplot(x=epochs_input, y=cos_lr, color=’purple’, label="$\cos$")
sns.lineplot(x=epochs_input, y=[eta_init]*18+[eta_init/3.16]*16 + *16, color=’green’,
➥ label="StepLR") % ax = sns.lineplot(x=epochs_input, y=effective_learning_rate, color=’blue’,
➥ label="$\eta_0 \cdot \gamma^t$")
ax.set_xlabel(’Epoch’)
ax.set_ylabel(’Learning Rate’)
[17]: Text(0, 0.5, 'Learning Rate')
❶ 计算每个 t 值的余弦调度 η[t]

乍一看,这个余弦调度似乎没有道理。我们为什么要上下波动学习率呢?当我们记得神经网络不是凸函数时,这就有意义了。凸函数只有一个局部最小值,并且每个梯度都会引导我们到达最优解。但神经网络是非凸的,可能有很多局部最小值,这些可能不是好的解。如果我们的模型首先走向这些局部最小值之一,而我们只减少学习率,我们可能会陷入这个次优区域。
图 5.8 显示了在训练具有多个最小值的神经网络时可能出错的情况。网络从一个糟糕的位置开始(因为初始权重 Θ 是随机的)并朝着局部最小值前进。这是一个不错的解决方案,但附近存在更好的解决方案。优化很困难,我们无法知道我们的最小值有多好或有多少个;所以我们最终陷入次优位置。

图 5.8 如果我们得到一个不幸的起点(因为它导致次优最小值),我们的搜索可能会带我们到次优最小值(浅绿色)。如果我们缩小学习率,我们的搜索可能会卡住。唯一的逃脱方式是增加学习率。
尽管如此,还有一线希望。通过实验(以及我们不打算看的困难数学),存在一个共同现象,即次优局部最小值存在于通往更好最小值的过程中(图 5.8 显示了这一点)。因此,如果我们缩小学习率,然后后来增加学习率 η,我们可以给我们的模型一个机会逃离当前局部最小值并找到一个新的替代最小值。较大的学习率希望带我们到一个新的更好的区域,而衰减又允许我们更精确地聚焦于一个更精细的解决方案。图 5.9 显示了余弦退火如何工作。

图 5.9 梯度下降首先将我们带到次优最小值,但随着学习率的再次增加,我们跳出该区域,向更好的解决方案靠近。搜索继续在更好的解决方案周围弹跳,直到余弦调度再次降低学习率,使模型收敛到一个更精确的答案。
你可能会想知道什么阻止我们从更好的解决方案弹跳到一个更差的解决方案。技术上,没有什么可以阻止这种情况发生。然而,研究人员已经开发了许多关于神经网络的理论,这让我们有信心认为从好的解决方案弹跳出来是不太可能的。这些结果的核心是,梯度下降喜欢找到宽的盆地作为好的解决方案,并且由于解决方案是宽的,因此优化很难跳出。这让我们对这种疯狂的余弦调度有更多的信心,并且从经验上看,它在许多任务(图像分类、自然语言处理等)上都表现良好。事实上,余弦退火已经如此成功,以至于已经提出了几十种替代方案。
实现此方法的代码与之前的学习率调度非常相似。本例使用 epochs//3 作为 T[max] 的值,这意味着它执行了两次下降。我总是坚持使用奇数作为周期的除数,以便学习以一个下降到小学习率结束。此外,下降的次数不应超过周期数的四分之一——否则,学习率将在每个周期之间波动过大。以下是代码:
fc_model.apply(weight_reset)
optimizer = torch.optim.SGD(fc_model.parameters(), lr=eta_0)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, epochs//3,
➥ eta_min=0.0001) ❶
fc_results_coslr = train_network(fc_model, loss_func, train_loader,
➥ test_loader=test_loader, epochs=epochs, optimizer=optimizer,
➥ lr_schedule=scheduler, score_funcs={’Accuracy’: accuracy_score},
➥ device=device)
❶ 告诉余弦先下降,然后上升,再下降(总共三次)。如果我们进行超过 10 个周期,我会将这个值推得更高。
许多关于余弦退火的版本和风味
余弦退火本身是一种有效的学习率调度,但其原始设计是为了一个稍微不同的目的。你可能还记得你在之前的工作或机器学习培训中,集成是提高任务预测准确性的好方法:训练许多模型,然后平均你的预测以得到一个最终更好、更准确的答案。但是,仅仅为了平均而训练 20 到 100 个神经网络是非常昂贵的。这就是余弦退火发挥作用的地方。与其训练 20 个模型,不如用余弦退火训练一个模型。每次学习率在下降中达到最低点时,就复制那些权重,并使用它们作为你的模型之一。所以,如果你想有一个由 20 个网络组成的集成,并且你正在进行 T 个周期的训练,你使用 T[max] = T/(20⋅2−1)。这会在学习率中产生恰好 20 次下降。
自 PyTorch 1.6 以来,一个更花哨的版本,称为随机权重平均(SWA)的想法被内置了(pytorch.org/docs/stable/optim.html#stochastic-weight-averaging)。使用 SWA,你可以平均每个波谷的参数 Θ,给你一个一个模型,其准确性更接近于模型集合。这是一个罕见的好处,你以一个模型的成本和存储来获得集合的优势。
5.2.4 验证平台期:基于数据的调整
我们之前讨论的所有学习率计划都不依赖于外部信息。你所需要知道的就是一个初始学习率 η[0],一个最小学习率 η[min],以及不多的事情。它们都不使用关于学习进展如何的信息。但我们有什么可用信息可以使用,我们应该如何使用它?我们拥有的主要信息是每个训练 epoch 的损失 ℓ,我们可以将其添加到我们的方法中,以尝试最大化我们最终模型的准确性。这就是基于平台期的策略所做的事情,它通常会为你提供最终模型的最佳可能准确性。让我们看看基线网络 fc_model 的训练和测试集损失:
sns.lineplot(x=’epoch’, y=’test loss’,
➥ data=fc_results, label=’Test Loss’)
sns.lineplot(x=’epoch’, y=’train loss’,
➥ data=fc_results, label=’Train Loss’)
[19]: <AxesSubplot:xlabel='epoch', ylabel='test loss'>

训练损失持续下降,这是正常的,因为神经网络很少会欠拟合数据。当我们的模型在特定数据集上训练并反复看到相同的数据(这就是一个 epoch:看一次所有数据),模型在预测训练数据的正确答案方面会变得不合理地好。这就是典型的过拟合,它发生的程度取决于你的网络有多大。我们期望训练损失总是下降,所以它不是关于学习进展的好信息来源。但如果我们看看测试损失,我们会看到它并不总是随着每个 epoch 而持续改善。测试损失在某个点开始稳定或平台期。
如果测试损失已经稳定,那么这就是降低学习率的良好时机。² 如果我们已经处于一个最佳位置,降低学习率不会造成任何伤害。如果我们正在围绕一个更好的解决方案弹跳,降低学习率可以帮助我们根据我们用来证明指数衰减合理性的相同逻辑来改进。但现在我们是在根据数据,而不是基于一个固定的任意计划来降低学习率,这样做似乎是有必要的。
这就是“在平台期降低学习率”计划的背后思想,该计划由 PyTorch 中的 ReduceLROnPlateau 类实现。这也是为什么我们的代码中需要将最后一个验证损失作为参数传递给 step 函数,看起来像 lr_schedule.step(results["val loss"][-1])。这样,ReduceLROnPlateau 类就可以将当前损失与最近的损失历史进行比较。
ReduceLROnPlateau有三个主要参数来控制其工作效果。首先是patience,它告诉我们降低学习率之前想要看到多少个没有改进的 epoch。10 是一个常见的值,因为你不希望过早地降低学习率。相反,你希望在改变速度之前有一些一致的证据表明没有更多的进步。
第二个参数是threshold,它定义了什么算是没有改进。精确的 0 阈值意味着如果过去patience个 epoch 中任何低于之前最佳损失的量都算作改进。这可能会过于刻板,因为损失可能每 epoch 都在以微小的、无意义的量下降。如果我们使用 0.01 的阈值,这意味着损失需要下降超过 1%才能算作改进。但这也可能过于宽松:当你训练数百个 epoch 时,你可能会取得缓慢但稳定的进步,而降低学习率不太可能加快收敛。设置这个值需要一些工作,所以我们坚持使用默认值0.0001,但如果你想要榨取最大可能的性能,这是一个值得尝试的超参数。
最后一个参数是factor,它表示每次我们确定已经达到一个平台期时,想要降低学习率η的倍数。这与StepLR类中的γ参数作用相同。再次强调,0.1 到 0.5 范围内的值都是合理的选项。
但是,在使用ReduceLROnPlateau调度之前,我们需要认识到一个关键问题:你不能使用测试数据来选择何时改变学习率。这样做将会导致过拟合,因为你正在使用关于测试数据的信息来做出决策,然后使用测试数据来评估你的结果。相反,你需要有训练、验证和测试的分割。我们正常的代码一直使用验证数据作为测试数据,这是可以接受的,因为我们没有在训练时查看验证数据来做出决策。ReduceLROnPlateau的这个重要细节过程在图 5.10 中得到了总结。

图 5.10 展示了基于平台期的学习率调度策略。底部部分,用红色表示,展示了从训练数据中切下一部分作为我们的验证集。这样做,或者与测试集一起有一个特殊的验证集,是避免过拟合的必要条件。上部部分,用绿色表示,展示了当我们的验证损失没有减少时,我们只降低学习率η。
以下代码展示了如何正确地执行此操作。首先,我们使用 random_split 类来创建 80/20 的 训练 数据分割。20%的分割将成为我们的验证集,ReduceLROnPlateau 将使用它来检查我们是否应该降低学习率;而 80%用于训练模型参数 Θ。请注意对 train_network 的调用,我们使用 train_loader、val_loader 和 test_loader 正确设置每个组件。在模型的结果中,我们需要确保我们正在查看 测试 结果而不是 验证 结果。除了这些谨慎的预防措施外,此代码与之前没有太大不同:
fc_model.apply(weight_reset) ❶
train_sub_set, val_sub_set = torch.utils.data.random_split(train_data,
➥ [int(len(train_data)*0.8), ❷
➥ int(len(train_data)*0.2)])
train_sub_loader = DataLoader(train_sub_set,
➥ batch_size=B, shuffle=True)
val_sub_loader = DataLoader(val_sub_set, batch_size=B) ❸
optimizer =torch.optim.SGD(fc_model.parameters(), lr=eta_0)
scheduler =torch.optim.lr_scheduler.ReduceLROnPlateau( ❹
➥ optimizer, mode='min', factor=0.2, patience=10)
fc_results_plateau = train_network(fc_model, loss_func, train_loader, ❺
➥ val_loader=val_sub_loader, test_loader=test_loader, epochs=epochs,
➥ optimizer=optimizer, lr_schedule=scheduler, score_funcs={'Accuracy':
➥ accuracy_score}, device=device)
❶ 再次重置权重,这样我们就不需要定义一个新的模型
❷ 由于我们没有明确的验证集和测试集,因此创建训练集和验证集
❸ 为训练集和验证集创建加载器。我们的测试加载器保持不变——永远不要更改你的测试数据!
❹ 使用 gamma=0.2 设置我们的平台期计划
❺ 训练模型!
不要自食其果!
基于平台期的学习率调整是一种非常流行且成功的策略。使用关于当前模型表现如何的信息,在许多问题上都能得到最佳结果。但是,你不应该在所有情况下盲目使用它。有两个特殊情况,ReduceLROnPlateau 可能表现不佳,甚至可能误导你得到过于自信的结果。
首先是一种情况,就是你没有太多数据。ReduceLROnPlateau 策略需要训练集和验证集来工作,因此减少了你用于学习模型参数的数据量。如果你只有 100 个训练点,可能很难用 10 到 30%的数据进行验证。你需要足够的数据来估计参数 Θ 并判断学习是否已经达到平台期——如果你没有很多数据,这可能是一个过于艰巨的任务。
第二种情况是当你的数据严重违反了相同且独立分布(IID)的假设。例如,如果你的数据包括来自同一人的多个样本,或者依赖于事件发生日期的事件(例如,如果你想预测天气,你不能有来自未来的数据!),天真地应用随机分割来创建验证集可能会导致结果不佳。在这种情况下,你需要确保你的训练、验证和测试分割之间没有任何意外的泄漏。以天气为例,你可能想确保你的训练分割只包含 1980 年至 2004 年的数据,验证数据为 2005 年至 2010 年,测试数据为 2011 年至 2016 年。这样,就不会发生意外的时空旅行,这将是一个重大的 IID 违规。
5.2.5 比较计划
现在我们已经训练了四种常见的学习率调度,我们可以比较它们的结果,看看哪种表现最好。结果如下面的图表所示,测试精度在 y 轴上。一些趋势很快就会很明显,值得讨论。简单的 SGD 表现不错,但始终在 epoch 之间上下波动。我们正在查看的每个学习率调度都提供了一种某种好处:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_results, label=’SGD’)
sns.lineplot(x=’epoch’, y=’test Accuracy’,
➥ data=fc_results_expolr, label=’+Exponential Decay’)
sns.lineplot(x=’epoch’, y=’test Accuracy’,
➥ data=fc_results_steplr, label=’+StepLR’)
sns.lineplot(x=’epoch’, y=’test Accuracy’,
➥ data=fc_results_coslr, label=’+CosineLR’)
sns.lineplot(x=’epoch’, y=’test Accuracy’,
➥ data=fc_results_plateau, label=’+Plateau’)
[22]: AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

指数衰减的好处是平滑且一致:从 epoch 到 epoch,它几乎具有相同的行为。不幸的是,它趋向于精度较低的一侧,并且比简单的 SGD 表现略差。但一致性是有价值的,如果我们提前结束一个 epoch,SGD 的表现会更差。
StepLR的调度在行为上与指数调度非常相似,但在开始时由于快速增加而在后期减速并稳定,因此在训练的后期表现得更好。
余弦调度最终表现得更好,达到了简单 SGD、StepLR或指数衰减的最高精度。大约在 27 个 epoch 时,性能突然下降,因为学习率再次增加;然后当学习率再次衰减时,性能在更高的精度上重新稳定。这就是为什么我建议将余弦方法设置为以一个低谷结束。
平台期方法也做得很好,并且恰好在这个测试集中获得了第二名。随着 epoch 的增加,平台期会通过进一步降低学习率来自我稳定。使用模型实际表现的反馈,而不是假设行为,可以帮助从模型中获得最后一丝精度。
表 5.1 总结了各种方法的优缺点以及何时可能需要使用每种方法。不幸的是,没有一种方法适合所有情况,每种方法都有可能不奏效的情况。这很大程度上取决于你的数据,你只有在训练数据并找出结果后才会知道。这就是为什么我喜欢从没有学习率调度开始,然后根据发生的情况添加这四种方法之一。
表 5.1 流程控制语句
| 指数衰减 | StepLR | 余弦 LR | 平台期 | |
|---|---|---|---|---|
| 优点 | 结果非常一致。几乎不需要调整。 | 结果一致。几乎不需要调整。通常可以提高精度。 | 通常可以提高精度。高级版本可以显著提高精度。 | 通常可以显著提高精度并减少训练的 epoch 数。 |
| 优点 | 可以适度降低最终精度。 | 在训练 100+个 epoch 时最有帮助。 | 需要一些参数调整;不一定总是有效。 | 并非所有数据都用于训练。过度拟合的风险更高。 |
| 当何时使用 | 训练相同的模型,但使用不同的初始权重,可能会得到截然不同的结果。指数衰减可以帮助稳定训练。 | 你希望以最少的努力提高最终模型的准确性。 | 你希望在不做太多额外工作的前提下,榨取最大性能,并且理想情况下你有足够的时间进行几次额外的训练。 | 你希望在不做太多额外工作的前提下,榨取最大性能,并且理想情况下你有大量数据。 |
尽管平台方法表现良好,但我们在这本书中不会过多使用它。部分原因是它需要额外的代码,这可能会在我们试图关注新概念时分散注意力。此外,有时我们会以特定的方式设置学习问题,以便你看到仅需要几分钟而不是数小时或更长时间运行的实际问题行为,而平台方法使得设置这些场景变得更加困难。但这些初步结果表明,你应该将其作为你工具箱中一个强大的工具铭记在心。
5.3 更好地利用梯度
我们现在知道了几种方法可以改变学习率 η,以改善模型的学习速度,并允许它学习更准确的解决方案。我们通过定义不同的学习率计划 L(⋅,⋅),这些计划着眼于长期。将 L(⋅,⋅) 视为设定旅程的期望速度。但短期内的绕道、坑洼和其他障碍可能需要改变速度。这就是为什么我们还想用函数 G(⋅) 修改梯度 g。我们首先单独关注梯度更新方案,然后将它们与学习率计划结合起来以获得更好的结果。首先,我们讨论一些广泛的动机,然后深入探讨最常见的方法。这三种方法都是用 PyTorch(它们是内置的)对代码进行一行更改,并且可以显著提高你模型的准确性。
记住,我们使用 g^t 来表示我们用于更新网络的第 t 个梯度。因为我们是在批量中进行随机梯度下降,这是一个有噪声的梯度。即使在 g^t 中存在噪声,g^t 中仍然有价值的信息我们没有充分利用。考虑第 j 个参数的梯度 g[j]^t。如果它每次都几乎保持相同的值呢?从数学上讲,这将是一个情况,其中
g[j]^t ≈ g[j]^(t − 1) ≈ g[j]^(t − 2) ≈ g[j]^(t − 3) ≈ …
这告诉我们第 j 个参数需要每次都向同一方向移动。图 5.11 展示了这种情况。

图 5.11 示例中,梯度值可能持续不断地返回相同的值。我们正在直接走向解决方案,但如果使用更大的学习率,我们可以更快地到达那里。
我们不一定想增加学习率 η,因为其他维度可能有不同的行为。每个网络参数都得到其自己的梯度值,而网络可以有数百万个参数。所以如果索引 j 的梯度持续相同,我们可能应该增加我们迈出的距离(即增加学习率 η)仅针对索引 j。我们需要确保这仅与索引 j 相关,因为不同索引 i 的梯度可能并不一致。
因此,我们希望有一个全局学习率 η 和所有参数的个性化学习率 η[j]。当全局学习率保持固定(除非我们使用学习率计划)时,我们让一些算法调整每个 η[j] 值,以尝试提高收敛性。大多数梯度更新方案通过重新缩放梯度 g^t 来工作,这相当于为每个参数提供其个性化的学习率 η[j]。
5.3.1 带有动量的 SGD:适应梯度一致性
如果参数 j 的梯度持续指向同一方向,我们希望增加学习率。我们可以将这描述为希望梯度积累动量。如果在一个方向上的梯度持续返回相似值,它应该开始在该方向上迈出更大和更大的步伐。图 5.12 展示了一个动量可以帮助解决的问题的例子。

图 5.12 原始优化几乎被困在左上角和右下角之间来回振荡,但它却在右上角缓慢而持续地取得进展,这有助于找到解决方案。如果我们能在那个方向上积累一些动量,我们就不需要那么多步骤。
这种动量策略是早期为改进 SGD 和解决图中显示的问题而开发的,至今仍被广泛使用。让我们谈谈它是如何工作的数学原理。
我们正常的梯度更新方程,再次,看起来是这样的:
Θ[t + 1] = Θ[t] − η ⋅ g^t
动量思想的初始版本是拥有一个动量权重 μ,它在范围 (0,1) 内具有一些值(即 μ ∈ (0,1))³。接下来,我们添加一个称为 v 的速度项,它累积我们的动量。因此,我们的速度 v 包含了我们之前梯度步长的一部分(μ),具体细节如下面的方程所示:

因为我们的速度依赖于我们之前的速度,它考虑了所有之前的梯度更新。接下来,我们简单地用 v 代替 g 来执行梯度更新:
Θ^(t + 1) = Θ^t − v(t + 1)
惊人的缩小历史
动量方程高度依赖于 μ 是一个小于 1 的值这一事实。这是因为它对旧梯度有缩小作用。一种看待这个问题的方式是写出我们反复应用动量时方程的变化。让我们详细写出这一点。
下表显示了当我们持续应用动量 μ 时速度 v 发生的情况。最左边的列是新值 Θ,我们以 Θ[1] 作为初始随机权重开始。还没有之前的速度 v,所以没有发生任何变化。从第二轮开始,我们可以扩展速度项,如右图所示。

注意,每次我们用动量更新时,每个以前的梯度 都会对当前更新做出贡献!也请注意,每次我们应用另一轮动量时,μ 上方的指数都会变大。这使得非常旧的梯度的贡献在持续更新时几乎为零。如果我们使用标准的动量 μ = 0.9,旧梯度在经过仅仅 88 次更新后,其贡献不到 0.01%。由于我们通常在每个 epoch 中进行数千到数百万次更新,这是一个非常短期的影响。
你可能会查看之前的方程并担心:如果我们考虑了所有的旧梯度,如果其中一些不再有用怎么办?这就是为什么我们保持动量项 μ ∈ (0,1)。如果我们目前处于更新 t,并且考虑 k 步之前的过去梯度,其贡献是 μk**g**t,这会迅速变得很小。如果我们有 μ = 0.9,40 步之前的梯度对当前速度 v 的贡献仅为 0.9⁴⁰ ≈ 0.01。值 μ 帮助我们忘记过去的梯度,以便学习可以适应,并且如果我们始终朝同一方向前进,学习可以增长得更大。
这种策略是显著提高你的模型精度和训练时间的一种简单方法。它可以解决我们查看的两个问题;图 5.13 展示了解决方案。

图 5.13 左边,动量解决了小学习率的问题。方向一致,因此动量积累并增加了有效学习率。右边,动量持续积累——但最初,在右上方向上缓慢积累。振荡可能会继续,但达到最小值所需的步骤更少。
如果你想要从你的网络中获得 最佳可能 的精度,使用带有某种形式的动量的 SGD 仍然被认为是最佳选择之一。缺点是找到给出 绝对最佳结果 的 μ 和 η 值的组合并不容易,并且需要训练许多模型。我们将在本章的最后部分讨论更智能地搜索 μ 和 η 的方法。
Nesterov 动量:适应变化的一致性
存在第二种动量类型,并且值得提及,因为它在实践中通常表现更好。这种版本被称为 Nesterov 动量。
在常规动量中,我们取当前权重(即 g^t)的梯度,然后朝着我们的梯度与速度结合的方向移动。如果我们积累了大量的动量,在优化过程中进行转弯可能会很困难。让我们看看图 5.14 中可能出现的错误示例。

图 5.14 想象一下,到达这个点已经花费了很长时间,因此优化器已经积累了大量的动量(即 ∥v^t∥[2] > 0)。当我们到达紫色点时,动量使我们绕着期望的区域 转弯,因为动量,即使在衰减后,也大于梯度。由于它更大,它将权重更多地保持在同一方向,而不是新的方向。
从技术上来说,这个问题展示了动量表现正确:动量以一致的方向携带 Θ[t + 1]。但这已经不再是一个好主意了,需要经过几次迭代才能纠正动量并开始朝向实际解的方向前进。
在 Nesterov 动量中,我们选择耐心等待。在步骤 t 时,正常动量立即计算新的梯度 g^t 并将速度 v^t 相加。Nesterov 首先对速度进行操作,然后在我们移动后计算一个新的梯度。这样,如果我们移动的方向是错误的,Nesterov 很可能有一个更大的梯度来更快地将我们推回正确的方向。这看起来像以下的一系列方程,其中我使用 t′ 来表示耐心步骤。
首先,我们仅基于之前的速度计算 Θ^(t′)。我们还没有查看新数据,这意味着如果情况发生变化(或者这可能是个好方向——我们不知道,因为我们还没有查看数据):

第二,我们最终使用修改后的权重 Θ^(t′) 来查看新的数据 x。这就像窥视近未来,以便我们可以改变或纠正我们关于如何更新参数的答案。如果速度在正确的方向上,实际上没有什么变化。但如果速度将带我们走向错误的方向,我们可以改变航向并更快地抵消这种影响:

最后,我们再次使用包含新鲜梯度的新的速度来修改 Θ^(t′),从而得到最终的更新权重 Θ^(t + 1):

总结一下:第一个方程根据 仅 当前的速度(没有新的批次和新的信息)来改变参数 Θ。第二个方程使用旧速度和经过第一个方程改变 Θ 后的梯度来计算一个新的速度。最后,第三个方程根据这些结果进行一步操作。虽然这看起来像是额外的工作,但有一种巧妙的方法可以组织它,使其所需的时间与正常动量(我们不会深入讨论,因为它有点复杂)完全相同。图 5.15 展示了 Nesterov 动量如何影响前面的例子。

图 5.15 在相同的紫色点上,右侧显示了更新是如何计算的。我们不是在起点计算 g^t,而是首先跟随动量,这是错误的方向。这导致在新位置处的梯度在正确的方向上更大。结合起来,我们得到一个更接近解的更小步长。原始的标准动量结果以黑色淡出显示。
让我们通过另一个场景来推理,以锻炼我们对正在发生的事情的心理模型。试着自己画一个图来帮助你理解。
考虑标准动量情况,我们的动量已经很有用,使我们能够朝正确的方向前进,并假设我们已经达到了目标。我们现在处于函数的最小值,问题已经解决。但是,我们永远无法确定我们是否处于最小值,所以我们运行优化过程的下一步。由于我们已经积累的动量,我们即将滚过最小值。在正常动量中,我们计算梯度——如果我们处于解的位置,梯度 g^t = 0,因此没有变化。然后我们添加速度 v^t,它将我们带离。
现在,考虑在 Nesterov 动量下的这种场景。我们处于解的位置,首先我们跟随速度,将我们推向目标之外。然后我们计算梯度,它认识到我们需要朝相反的方向前进,因此朝向目标移动。当我们把这两个结合起来时,它们几乎相互抵消(我们向前或向后迈出更小的一步,具体取决于哪个的幅度更大,g^t 或 v^t)。在一步之内,我们已经开始改变我们的优化器前进的方向,而正常动量则需要两步。
这就是为什么 Nesterov 动量通常是首选动量版本的原因。现在我们已经讨论了这些想法,我们可以将它们转化为代码。SGD 类只需要我们设置 momentum 标志为一个非零值,如果我们想要动量,并且 nesterov=True 如果我们想要它是 Nesterov 动量。以下代码使用两种版本训练我们的网络。
fc_model.apply(weight_reset)
optimizer = torch.optim.SGD(fc_model.parameters(), lr=eta_0,
➥ momentum=0.9, nesterov=False)
fc_results_momentum = train_network(fc_model, loss_func, train_loader,
➥ test_loader=test_loader, epochs=epochs, optimizer=optimizer,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device)
fc_model.apply(weight_reset)
optimizer = torch.optim.SGD(fc_model.parameters(), lr=eta_0,
➥ momentum=0.9, nesterov=True)
fc_results_Nesterov = train_network(fc_model, loss_func, train_loader,
➥ test_loader=test_loader, epochs=epochs, optimizer=optimizer,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device)
比较带有动量的 SGD
在这里,我们绘制了 vanilla SGD 和两种动量版本的结果。两种动量版本都表现出显著更好的性能,学习速度更快,给出的解更准确。但你通常不会看到动量的一种版本比另一种版本表现得更好或更差。虽然 Nesterov 解决了一个实际问题,但正常动量在更多梯度更新后最终会自行纠正。然而,我更喜欢使用 Nesterov 版本,因为在我的经验中,如果一个动量比另一个好很多,通常就是 Nesterov:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_results, label=’SGD’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_results_momentum,
➥ label=’SGD w/ Momentum’) sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_results_nestrov,
➥ label=’SGD w/ Nesterov Momentum’)
[25]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

5.3.2 Adam:向动量添加方差
目前最受欢迎的优化技术之一被称为 自适应矩估计,简称 Adam。Adam 与我们刚刚描述的带有动量的 SGD 密切相关。Adam 是我目前最喜欢的方案,因为它有默认参数,直接使用,所以我无需花时间调整它们。对于带有动量的 SGD(普通或 Nesterov 风味)则不是这样。我们必须重命名一些术语,以便使数学与你将在其他地方了解的方式一致:速度 v 变为 m,动量权重 μ 变为 β[1]。现在我们可以描述 Adam 的第一步,其中有一个主要的变化用红色标出:

这描述了动量更新方程,但现在我们正在通过 (1 − β[1]) 对 当前 梯度 g^t 进行降权。这使得 m^t 成为先前速度和当前梯度的 加权平均。更具体地说,因为我们添加了 (1−β[1]) 项,现在这被称为 指数移动平均。它被称为 指数,因为 z 步之前的梯度 g^(t − z) 有一个指数化的贡献 β[1]^z,它被称为 移动平均,因为它计算的是一种加权平均,其中大部分权重在最近的项上,所以平均随着最近的数据移动。
由于我们现在讨论的是多个更新过程中的 平均 或 均值 梯度,我们可以谈论梯度的 方差。如果一个参数 g[j]^t 具有高方差,我们可能不希望让它对动量贡献太多,因为它很可能会再次改变。如果一个参数 g[j]^t 具有低方差,它是一个可靠的方向,因此我们应该在动量计算中给它 更多 的权重。
要实现这个想法,我们可以通过观察梯度的平方值来计算随时间变化的信息方差。通常,对于方差,我们会在平方之前减去平均值,但在这个场景中这样做很困难。因此,我们使用平方值作为方差的近似,并记住它并不提供完美的信息。使用 ⊙ 表示两个向量之间的逐元素乘积(a ⊙ b = [a[1]⋅b[1],a[2]⋅b[2],…]),可以得到速度方差 v 的这个方程:

我们保存 m^t 和 v^t,但在使用它们之前进行一次修改。为什么?因为我们处于优化过程的早期,意味着 t 是一个小的值,m^t 和 v^t 给出的均值和方差的估计是有偏的。它们是有偏的,因为它们被初始化为零(即 m⁰ = v⁰ =
),所以如果我们天真地使用它们,早期的估计将会 太小。
考虑当 t = 1 时的情况。在这种情况下,m¹ = (1−β[1]) ⋅ g。真正的平均值只是 g,但我们通过一个因子 1 − β[1] 对其进行了折价。为了解决这个问题,我们只需进行如下调整:

现在我们有了 m̂ 和 v̂,它们分别给出了均值和无偏估计的方差,我们将它们一起用来更新我们的权重 Θ:

这里发生了什么?分子 η ⋅ m̂ 正在计算带有动量项的 SGD,但现在我们正在通过方差对每个维度进行归一化。这样,如果一个参数的值自然波动很大,我们不会那么快地调整到新的大幅波动——因为它通常相当嘈杂。如果一个参数通常具有很小的方差并且非常一致,我们就会快速适应任何观察到的变化。ϵ 项是一个非常小的值,所以我们永远不会除以零。
这就是 Adam 的直觉。原始作者提出了以下值:η = 0.001,β[1] = 0.9,β[2] = 0.999,和 ϵ = 10^(-8)。
个人而言,我推荐将 Adam 或其衍生版本作为任何优化问题的默认选择。为什么?因为它是一个优化器,使用默认值通常表现良好,无需进一步调整。它可能不会给你带来 最佳可能 的性能,但你通常可以通过调整参数来提高结果,但默认值通常效果不错。而且如果它们不起作用,其他参数设置通常也不会起作用。
大多数优化器都不具备这种属性,或者至少没有 Adam 表现得那么明显。例如,SGD 对动量和学习率项非常敏感,你通常需要进行一些调整才能从正常的 SGD 中获得良好的性能。由于 Adam 不 需要 这种挑剔的调整,你不需要进行太多的实验来找出什么有效。因此,你可以在确定最终架构并准备好榨取最后一点精度之前,先保存一个详细的优化过程。最终,这使得 Adam 成为一个节省时间的好方法:在架构上花时间,将优化器的调整留到最后。
Adam 的其他版本
Adam 算法的原始论文中包含了一个错误,但提出的算法仍然对绝大多数问题表现良好。修复这个错误的版本被称为 AdamW,并且是我们在这本书中使用的默认版本。
Adam 的另一个扩展是 NAdam,其中 N 代表 Nesterov。正如你可能猜到的,这个版本将 Adam 调整为使用 Nesterov 动量而不是标准动量。第三种版本是 AdaMax,它用 max 操作替换了 Adam 中的一些乘法操作,以提高算法的数值稳定性。所有这些版本都有其优缺点,但这本书的范围之外,但任何 Adam 变体都会为你服务得很好。
现在我们已经了解了 Adam,让我们试试它。以下代码再次重置了我们反复训练的神经网络的权重,但这次使用 AdamW:
fc_model.apply(weight_reset)
optimizer = torch.optim.AdamW(fc_model.parameters()) ❶
fc_results_adam = train_network(fc_model, loss_func, train_loader,
➥ test_loader=test_loader, epochs=epochs, optimizer=optimizer,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device)
❶ 我们不设置 Adam 的学习率,因为您应该始终使用默认值,并且 Adam 对学习率的较大变化更为敏感。
现在,我们可以绘制 AdamW 以及我们之前已经查看的三个 SGD 版本。结果显示,AdamW 在具有动量两种风格的 SGD 中的表现一样好,但其行为在下降不那么频繁或剧烈时更为稳定:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_results, label=’SGD’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_results_momentum,
➥ label=’SGD w/ Momentum’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_results_nestrov,
➥ label=’SGD w/ Nestrov Momentum’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_results_adam,
➥ label=’AdamW’)
[27]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

我们还可以将这些新的优化器与我们所学到的学习率计划相结合。在这里,我们使用 Nesterov 动量结合余弦退火计划来训练 Adam 和 SGD:
fc_model.apply(weight_reset) optimizer =
torch.optim.AdamW(fc_model.parameters()) ❶
scheduler = torch.optim.lr_scheduler.
➥ CosineAnnealingLR(optimizer, epochs//3)
fc_results_adam_coslr = train_network(fc_model, loss_func, train_loader,
➥ test_loader=test_loader, epochs=epochs, optimizer=optimizer,
➥ lr_schedule=scheduler, score_funcs={’Accuracy’: accuracy_score},
➥ device=device)
fc_model.apply(weight_reset) optimizer =
torch.optim.SGD(fc_model.parameters(), lr=eta_0, momentum=0.9, nesterov=True) ❷
scheduler = torch.optim.lr_scheduler.
➥ CosineAnnealingLR(optimizer, epochs//3)
fc_results_nesterov_coslr = train_network(fc_model, loss_func,
➥ train_loader, test_loader=test_loader, epochs=epochs,
➥ optimizer=optimizer, lr_schedule=scheduler,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device)
❶ 带有余弦退火的 Adam
❶ 带有余弦退火的 SGD+Nesterov
现在我们可以比较以下代码和图中带有和没有余弦计划的比较结果。我们再次看到相似的趋势,即添加学习率计划会给准确度带来提升,并且带有 AdamW 的版本表现得更稳定:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_results_nesterov,
➥ label=’SGD w/ Nesterov’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_results_nesterov_coslr,
➥ label=’SGD w/ Nesterov+CosineLR’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_results_adam,
➥ label=’AdamW’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_results_adam_coslr,
➥ label=’AdamW+CosineLR’)
[29]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

5.3.3 梯度裁剪:避免梯度爆炸
我们还有一个技巧要讨论,它与 Adam 和 SGD 这样的优化器以及学习率计划都兼容。它被称为 梯度裁剪,它帮助我们解决梯度爆炸问题。与迄今为止我们所讨论的所有内容中的数学直觉和逻辑不同,梯度裁剪非常简单:如果梯度的任何(绝对)值大于某个阈值 z,只需将其设置为 z 的最大值。所以如果我们使用 z = 5,我们的梯度是 g = [1,−2,1000,3,−7],裁剪后的版本变为 clip5 = [1,−2,5,3,−5]。其想法是,任何大于我们的阈值 z 的值明显表明了 方向;但它被设置为不合理的 距离,所以我们强制将其裁剪为合理的值。
以下代码展示了如何将梯度裁剪添加到任何神经网络中。我们使用 model.parameters() 函数获取参数 Θ,并使用 register_hook 注册一个在每次使用梯度时执行的回调。在这种情况下,我们简单地取表示梯度的张量 grad 并使用 clamp 函数,该函数返回一个新版本的 grad,其中没有任何值小于 -5 或大于 5。就这么简单:
fc_model.apply(weight_reset)
for p in fc_model.parameters():
p.register_hook(lambda grad: torch.clamp(grad, -5, 5))
optimizer = torch.optim.AdamW(fc_model.parameters())
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, epochs//3)
fc_results_nesterov_coslr_clamp = train_network(fc_model, loss_func,
➥ train_loader, test_loader=test_loader, epochs=epochs,
➥ optimizer=optimizer, lr_schedule=scheduler,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device)
绘制结果,您会发现它们通常是一样的。在这种情况下,它们稍微差一点,但本可以更好。这取决于问题:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_results_nesterov_coslr,
➥ label=’AdamW+CosineLR’)
sns.lineplot(x=’epoch’, y=’test Accuracy’,
➥ data=fc_results_nesterov_coslr_clamp, label=’AdamW+CosineLR+Clamp’)
[31]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

梯度爆炸通常是一个 灾难性的 问题。如果你的模型有这个问题,它可能是在学习一个退化的解或者根本不收敛。由于这个数据集和网络没有这个问题,梯度裁剪并不有益。
在大多数应用中,除非我的模型一开始就学不好,否则我不会使用梯度裁剪。如果它们学得很好,我可能不会有爆炸性梯度,所以我专注于其他变化。如果模型不好,我会测试梯度裁剪,看看是否能解决这个问题。如果我正在处理循环神经网络,我总是使用梯度裁剪,因为循环连接往往会引起爆炸性梯度,这在那种情况下是一个常见问题。但如果你想要始终包含裁剪,这样做也是一个同样有效的策略。使用裁剪值 z = 5 或 z = 10 是常见的,并且对大多数问题都有效。
5.4 使用 Optuna 进行超参数优化
现在我们已经改进了训练方法,我们将在整本书中重用许多这些技术,因为它们对任何和每一个你将要训练的神经网络都很有用。到目前为止的改进主要集中在我们在有梯度时进行优化。但超参数是我们想要优化但没有梯度的事物,例如要使用的初始学习率 η 和动量项 μ 的值。我们还想优化我们网络的架构:我们应该使用两层还是三层?每个隐藏层的神经元数量又是多少?
机器学习中大多数人首先学习的超参数调整方法是称为网格搜索。虽然很有价值,但由于其随着变量增加而呈指数增长的代价,网格搜索一次只能优化一个或两个变量。在训练神经网络时,我们通常至少有三个参数想要优化(层数、每层的神经元数量和学习率 η)。我们改用一种更新的方法——Optuna——来调整超参数,它效果更好。与网格搜索不同,它需要的决策更少,能找到更好的参数值,可以处理更多的超参数,并且可以适应你的计算预算(即你愿意等待多长时间)。
话虽如此,超参数调整仍然非常昂贵,这些示例不能在几分钟内运行,因为它们需要训练数十个模型。在现实世界中,甚至可能有数百个模型。这使得 Optuna 在未来章节中的应用变得不切实际,因为我们没有时间,但了解它仍然是你需要掌握的关键技能。
5.4.1 Optuna
为了执行一种更智能的超参数优化类型,我们使用一个名为 Optuna 的库。只要您可以将目标描述为一个单一的数值,Optuna 就可以与任何框架一起使用。幸运的是,我们有准确度或误差作为我们的目标,因此我们可以使用 Optuna。Optuna 通过使用贝叶斯技术将超参数问题建模为其自己的机器学习任务,从而在超参数优化方面做得更好。我们本可以花整整一章(或更多)来详细说明 Optuna 的工作原理的技术细节:简而言之,它训练自己的机器学习算法来根据其超参数(特征)预测模型的准确度(标签)。然后 Optuna 根据它预测为良好的超参数顺序尝试新的模型,训练模型以了解其表现如何,将此信息添加到模型中以提高模型,然后选择一个新的猜测。但就目前而言,让我们专注于如何将 Optuna 作为工具使用。
首先,让我们简要了解一下 Optuna 的工作原理。类似于 PyTorch,它有一个“通过运行定义”的概念。对于 Optuna,我们定义一个函数,我们希望最小化(或最大化),该函数接受一个 trial 对象作为输入。这个 trial 对象用于获取我们想要调整的每个参数的“猜测”,并在结束时返回一个分数。返回的值是浮点数和整数,就像我们亲自使用的那样,这使得它很容易使用。图 5.16 展示了这是如何工作的。

图 5.16 您为 Optuna 提供一个函数,该函数执行图中概述的三个步骤。Optuna 使用自己的算法为每个超参数选择值,您可以使用 trial.suggest 函数告诉 Optuna 关于这些超参数的信息;此函数还告知 Optuna 您希望考虑的最小值和最大值。您告诉 Optuna 进行此过程多少次,每次执行时,黑盒都会在挑选新值尝试时变得更好。
让我们看看我们想要最小化的玩具函数:f(x,y) = abs ((x−3)⋅(y+2))。很容易看出,存在一个最小值在 x = 3 和 y = − 2。但 Optuna 能否找出这个值?首先,我们需要导入 Optuna 库,这是一个简单的 pip 命令:
!pip install optuna
现在我们可以导入 Optuna:
import optuna
接下来,我们需要定义要最小化的函数。toyFunc 接收一个 trial 对象。Optuna 通过使用 trial 对象获取每个参数的猜测来确定存在多少个超参数。这是通过 suggest_uniform 函数完成的,该函数要求我们提供一个可能的值范围(我们必须为任何超参数优化方法做这件事):
def toyFunc(trial):
x = trial.suggest_uniform(’x’, -10.0, 10.0) ❶❷
y = trial.suggest_uniform(’y’, -10.0, 10.0) ❶❸
return abs((x-3)*(y+2)) ❹
❶ 这两个调用请求 Optuna 提供两个参数,并为每个参数定义最小值和最大值。
❷ x ∼ 𝒰(−10,10)
❸ y ∼ 𝒰(−10,10)
❹ 计算并返回结果。Optuna 尝试最小化这个值:| (x-3)(y+2) |。
这就是我们需要的全部。现在我们可以使用 create_study 函数构建任务,并使用 optimize 调用指定 Optuna 进行最小化函数的试验次数:
study = optuna.create_study(direction=’minimize’) ❶
study.optimize(toyFunc, n_trials=100) ❷
❶ 如果你使用 direction=‘maximize’,Optuna 将尝试最大化 toyFunc 返回的值。
❷ 告诉 Optuna 要最小化的函数,以及它有 100 次尝试的机会。
当你运行代码时,你应该会看到一个长长的输出列表,类似于以下内容:
Finished trial#12 with value: 2.285 with parameters:
{'x': 0.089, 'y': -2.785}. Best is trial#9 with value: 0.535\. Finished trial#13 with value: 3.069 with parameters:
{'x': -1.885, 'y': -2.628}. Best is trial#9 with value: 0.535\. Finished trial#14 with value: 0.018 with parameters:
{'x': -3.183, 'y': -1.997}. Best is trial#14 with value: 0.018.
当我运行这段代码时,Optuna 得到了一个非常精确的答案:5.04 ⋅ 10^(−5)非常接近真正的最小值零。它返回的值也接近我们所知的真正答案。我们可以通过study.best_params来访问这些信息,它包含一个dict对象,将超参数映射到组合起来给出最佳结果的值:
print(study.best_params) ❶
{'x': 2.984280340674378, 'y': -1.8826725682225192\}
❶ 这个字典包含了 Optuna 找到的最佳参数值。
我们还可以使用study对象来获取有关优化过程的信息。Optuna 之所以强大,是因为它使用机器学习来探索参数值的空间。通过指定具有最小和最大值的参数,我们为 Optuna 提供了约束条件——它试图在探索空间以了解其外观和根据其对空间的当前理解最小化分数之间取得平衡。
我们可以使用等高线图来查看一个示例:
fig = optuna.visualization.plot_contour(study)

注意看 Optuna 如何花费大部分精力测试接近最小值的值,而在空间的大范围极端区域投入的精力却非常少。Optuna 很快就能判断在这些空间区域中找不到更好的解决方案,因此停止探索这些区域。这使得它有更多时间寻找最佳解决方案,并能更好地处理超过两个参数的情况。
5.4.2 Optuna 与 PyTorch
现在,你已经了解了使用 Optuna 的所有基础知识;是时候将它与 PyTorch 结合,进行一些高级参数搜索了。我们将为 Optuna 定义一个新的函数来优化我们的神经网络。我们不想做得太过分,因为没有梯度的情况下优化仍然非常困难,Optuna 也不是万能的子弹。但我们可以使用 Optuna 来帮助我们做出一些决策。例如,每个层应该有多少个神经元,以及有多少层?图 5.17 展示了我们如何定义一个函数来完成这个任务:
-
创建训练/验证分割(我们使用 plateau schedule 完成了这个步骤)。
-
请求 Optuna 给我们提供三个关键的超参数。
-
使用参数定义我们的模型。
-
从验证分割中计算并返回结果。

图 5.17 Optuna 可以用来优化我们的神经网络训练的objective函数定义的四个步骤。显示为代码的两个最重要的步骤是获取超参数和计算结果!
这里需要注意的一个重要事项是,我们必须仅使用原始训练数据创建新的训练和验证分割。为什么?因为我们将会多次重用验证集,我们不希望过度拟合到验证数据的特定细节。因此,我们创建一个新的验证集,并保存我们的原始验证数据直到最后。这样,我们只使用真正的验证数据一次来确定我们在优化网络架构方面的表现。
这个函数几乎没有多少新代码;大部分都是我们在几个章节中用来创建数据加载器、构建网络 Module 和调用我们的 train_network 函数的相同代码。PyTorch 的一些重要变化是我们设置了 disable_tqdm=True,因为 Optuna 在它试图优化的函数中与进度条不兼容。
添加动态数量的层
起初可能并不明显,但我们可以非常容易地使用 nn.Sequential 对象的变量数量层来适应 Optuna 告诉我们使用的内容。代码如下:
sequential_layers = [ ❶
nn.Flatten(), nn.Linear(D, n), nn.Tanh(),
]
for _ in range(layers-1): ❷
sequential_layers.append( nn.Linear(n, n) )
sequential_layers.append( nn.Tanh() ) sequential_layers.append(
nn.Linear(n, classes) ) ❸ fc_model =
nn.Sequential(*sequential_layers) ❹
❶ 至少有一个接受 D 个输入的隐藏层
❶ 根据 Optuna 为“layers”参数提供的变量数量添加隐藏层
❶ 输出层
❶ 将层列表转换为 PyTorch 顺序模块
这将模型规范拆分成几个部分,以便隐藏层的数量是一个通过 for _ in range(layers-1): 循环填充的变量。对于小型网络来说,这会稍微啰嗦一些,但可以使相同的代码能够处理各种层,如果我们想添加更多层,代码会更少。
从 Optuna 获取建议
其他变化是 Optuna 通过 trial 对象提供的不同 suggest 函数。有 suggest_int 用于整数,这对于像神经元数量(76.8 个神经元没有意义)和层数这样的链接是有意义的。我们之前看到了 suggest_uniform,它适用于被简单随机范围覆盖的浮点值(如动量项 μ,应该在 0 和 1 之间)。另一个重要的选项是 suggest_loguniform,它提供指数间隔的随机值。这是你应该用于参数按数量级改变的情况,如学习率(η = 0.001, 0.01 和 0.1 相差 10 倍)。下一个代码片段展示了我们如何通过指定适当的 suggest 函数并提供我们愿意考虑的最小和最大值来从 Optuna 获取三个超参数建议:
n = trial.suggest_int('neurons_per_layer', 16, 256)
layers = trial.suggest_int('hidden_layers', 1, 6)
eta_global = trial.suggest_loguniform('learning_rate', 1e-5, 1e-2)
这最后只是简单地训练我们的网络,并从最后一个 epoch 中获取验证准确率。你必须记住这是一个验证分割,我们没有使用测试集。我们应该在找到超参数后仅使用测试集来确定整体准确率。以下代码搜索此问题的超参数:
study = optuna.create_study(direction=’maximize’)
study.optimize(objective, n_trials=10) ❶
print(study.best_params)
{'neurons\_per\_layer': 181, 'hidden\_layers': 3, 'learning\_rate':
0.005154640793181943\}
❶ 通常我们会进行 50 到 100 次试验,但这里我们使用较少的试验,以便这个笔记本在合理的时间内运行。
你可以看到 Optuna 选定的参数。现在我们已经训练了网络,一个练习是使用这些信息训练一个新的模型,以确定你在真实验证集上得到的最终验证准确度。这样做就完成了整个超参数优化过程,如图 5.18 所示。

图 5.18 正确进行超参数优化所需遵循的所有步骤。合并或跳过这些步骤可能很有吸引力,但这样做可能会给你关于模型实际表现如何的误导性结果。
可视化 Optuna 结果
不仅仅是查看最终答案,我们还可以查看 Optuna 随时间所取得的进展以及其他优化过程的视图。这样做可以帮助我们建立一些关于“良好”参数范围的直觉。当设置新的实验时,这些信息可能会有所帮助,这样我们就可以希望优化过程更接近真实解,从而减少所需的优化尝试次数。
以下是最简单的选项之一:根据尝试的试验次数来绘制验证准确度(和个别试验)图。顶部的红线显示当前最佳结果,每个蓝色点表示一次实验的结果。如果准确度的大幅提升仍在发生(红线上升),我们有很好的理由增加 Optuna 运行的试验次数。如果准确度已经长时间处于平台期,我们可以在未来减少试验次数:
fig = optuna.visualization.plot_optimization_history(study)
fig.show()

我们还可能想要了解每个超参数相对于目标(准确度)的表现。这可以通过切片图来完成。以下示例为每个超参数制作散点图,目标值位于 y 轴上;点的颜色表示结果来自哪个试验。这样,你可以看到是否有任何超参数与目标有特别强的关系,以及 Optuna 解决这个问题需要多长时间。在这个特定例子中,大多数情况下,三到六个隐藏层表现良好,学习率高于η > 0.001 也是一致的。这类信息可以帮助我们在未来的试验中缩小搜索范围,甚至如果某个超参数似乎对目标影响不大,可以消除该超参数。这两者都将帮助 Optuna 在未来的运行中通过更少的尝试收敛到解决方案。以下是代码:
fig = optuna.visualization.plot_slice(study)
fig.show()

Optuna 还可以帮助你理解超参数之间的相互作用。一个选项是 plot_contour() 函数,它创建一个网格,显示每对不同的超参数组合如何影响结果。另一个选项是 plot_parallel_coordinate() 函数,它在一个图表中显示每个试验的所有结果。这两个都稍微难读一些,并且需要更多的试验来产生真正有趣的结果,所以我建议你在有机会进行 100 次试验时自己尝试它们。
5.4.3 使用 Optuna 进行剪枝试验
Optuna 支持的另一个特别有用的功能是在训练神经网络时提前剪枝试验。其想法是优化神经网络是迭代的:我们多次遍历数据集,并且我们(希望)每次迭代都会有所改进。这是我们未使用的信息。如果我们能在处理过程中早期确定一个模型不会成功,我们可以节省大量时间。
假设我们开始测试一组参数,学习从一开始就失败,前几个周期结果非常糟糕。为什么还要继续训练到结束?模型极不可能一开始就退化,后来成为最佳表现者之一。如果我们向 Optuna 报告中间结果,Optuna 可以基于已经完成的试验剪枝掉看起来不好的试验。
我们可以通过替换 def objective(trial): 函数的最后两行代码来实现这一点。我们不是用 10 个周期调用一次 train_network,而是用循环调用 10 次,每次 1 个周期。在每个周期之后,我们使用 trial 对象的 report 函数让 Optuna 了解我们当前的进展,并询问 Optuna 我们是否应该停止。修改后的代码如下:
for epoch in range(10): ❶
results = train_network(fc_model, loss_func, t_loader,
➥ val_loader=v_loader, epochs=1, optimizer=optimizer,
➥ lr_schedule=scheduler, score_funcs={’Accuracy’: accuracy_score},
➥ device=device, disable_tqdm=True) ❷
cur_accuracy = results[’val Accuracy’].iloc[-1]
trial.report(cur_accuracy, epoch) ❸
if trial.should_prune(): ❹
raise optuna.exceptions.TrialPruned() ❺
return cur_accuracy ❻
❶ 我们自己为每个周期进行循环。
❷ 只进行一次训练周期,但重用相同的模型和优化器。这会反复训练同一个模型。
❸ 让 Optuna 了解我们的表现如何
❹ 询问 Optuna 是否看起来没有希望
❺ 如果是这样,就停止尝试。
❻ 我们已经到达了终点:给出最终答案。
通过这个代码更改,我将使用 Optuna 运行一个新的试验,但故意将神经元的数量设置为下降到 1(太小了)和将学习率设置为 η = 100(太大了)。这将创建一些 非常糟糕 的模型,这些模型很容易被剪枝掉,只是为了展示这个新的剪枝功能。所有这些更改只需要在 objective 函数中发生:我们调用相同的 create_study 和 optimize 函数,并自动获得剪枝。下面的代码片段展示了这一点,但我将 n_trials=20 设置为给剪枝更多机会发生,因为它依赖于 Optuna 找到的最佳 当前 模型(它不知道坏运行看起来像什么,直到它看到可以与之比较的好运行):
study2 = optuna.create_study(direction=’maximize’)
study2.optimize(objectivePrunable, n_trials=20)
现在你应该能在运行此代码时看到几个来自 Optuna 的TrialState.PRUNED日志。当我运行它时,20 次试验中有 10 次在早期就被剪枝了。这些模型在剪枝之前经过了多少个 epoch 的训练?我们可以让 Optuna 绘制所有试验及其中间值的图表,以帮助我们更好地理解这一点。这是通过plot_intermediate_values()函数完成的,如下所示:
fig = optuna.visualization.plot_intermediate_values(study2)
fig.show()

看起来所有 10 次试验都在经过数据集的 1 个或 2 个 epoch 后就被剪枝了。这是过程非常早的阶段:Optuna 几乎将有效试验的数量减少了一半。我们也看到一些案例,即使有“更好”的模型被提前剪枝,表现不佳的模型仍然被允许运行到完成。这是因为剪枝是基于 Optuna 到目前为止看到的最佳模型。在早期,Optuna 允许不良模型运行到完成,因为它还没有意识到它们是坏模型。只有经过更多的试验并看到更好的模型后,它才会意识到原始模型本可以剪枝。因此,剪枝并不能避免所有不良模型,但它可以避免许多不良模型。
仔细观察图表,你应该能够看到 Optuna 剪枝了一些正在发散(变差)的模型,以及一些看起来可能会改进但表现不佳,以至于无法与 Optuna 已经看到的模型竞争的模型。这些也是很好的剪枝案例,也是 Optuna 节省我们时间的一部分。
注意:虽然 Optuna 是我最喜欢的工具之一,但我们在这本书中不会再使用它。这纯粹是一个计算问题,因为我想要坚持使用只需几分钟就能运行的示例。Optuna 需要训练多个网络,这意味着乘以训练时间。仅仅 10 次试验并不多,但如果没有任何超参数,一个需要 6 分钟的示例,使用 Optuna 可能需要一个小时或更长时间。然而,当你工作时,你应该绝对使用 Optuna 来帮助你构建你的神经网络。
练习
在 Manning 在线平台上的 Inside Deep Learning Exercises 部分分享和讨论你的解决方案(liveproject.manning.com/project/945)。一旦你提交了自己的答案,你将能够看到其他读者提交的解决方案,并查看作者认为哪些是最好的。
-
修改
train_network函数,使其能够接受lr_schedule=ReduceLROnPlateau作为一个有效的参数。如果train_network函数接收到这个字符串参数,它应该检查是否提供了验证和测试集,如果是的话,适当地设置ReduceLROnPlateau调度器。 -
使用批大小B = 1, 4, 32, 64, 128 重新运行使用
AdamW、带有 Nesterov 动量的 SGD 和余弦退火计划的实验。批大小变化如何影响这三个工具的有效性和准确性? -
编写代码创建一个具有 n = 256 个神经元的神经网络,并添加一个参数来控制网络中隐藏层的数量。然后使用简单的随机梯度下降法(SGD)和带有余弦退火的
AdamW训练具有 1、6、12 和 24 个隐藏层的网络。这些新的优化器如何影响你学习这些深层网络的能力? -
使用本章中的每个新优化器重新训练上一章中的三层双向循环神经网络(RNN)。它们如何影响结果?
-
在练习 4 的实验中添加梯度裁剪。这有助于 RNN 吗?
-
编写你自己的函数,使用 Optuna 优化全连接神经网络的参数。一旦完成,使用这些超参数创建一个新的网络,使用所有训练数据对其进行训练,并在保留的测试集上进行测试。你在 FashionMNIST 上得到的结果是什么,Optuna 对准确率的猜测与你的测试集性能有多接近?
-
重新做练习 6,但将隐藏层替换为卷积层,并添加一个新参数来控制执行多少轮最大池化。与练习 6 的结果相比,它在 FashionMNIST 上的表现如何?
摘要
-
梯度下降的两个主要组成部分是我们如何使用梯度(优化器)以及我们跟随它们的速度(学习率计划)。
-
通过使用关于梯度历史的信息,我们可以加快模型的学习速度。
-
将动量添加到优化器中允许在梯度变得非常小的情况下进行训练。
-
梯度裁剪减轻了梯度爆炸的问题,即使在梯度变得非常大时也能进行训练。
-
通过调整学习率,我们可以缓解优化的学习视图,以实现进一步的改进。
-
我们可以使用像 Optuna 这样的强大工具,而不是网格搜索,来寻找神经网络(如层数和神经元数量)的超参数。
-
通过检查每个周期的结果,我们可以通过早期剪枝不良模型来加速超参数调整过程。
¹ 更多内容请见第六章。↩
² 如果测试损失开始增加,这表明更严重的过拟合,那么这也是减缓学习速度的另一个好理由。这有助于减缓过拟合。但理想情况下,测试损失应该已经稳定。↩
³ 我认为用 μ 表示动量很令人困惑,但这是最常用的符号,所以我会坚持使用它,这样你可以学习它。↩
6 个常见的设计构建模块
本章涵盖了
-
添加新的激活函数
-
插入新层以改善训练
-
跳过层作为有用的设计模式
-
将新的激活、层和跳过组合成比它们各自总和更强大的新方法
到目前为止,我们已经学习了三种最常见和最基础的神经网络类型:全连接、卷积和循环。我们通过改变优化器和学习率计划来改进了所有这些架构,这改变了我们更新模型(权重)参数的方式,几乎免费地为我们提供了更精确的模型。我们迄今为止所学的一切也都有很长的保质期,并教会了我们关于几十年来(以及继续)存在的问题。它们为您提供了良好的基础,让您能够说一口流利的深度学习语言,以及一些构成更大算法的基本构建模块。
现在我们有了更好的工具来训练我们的模型,我们可以学习关于设计神经网络的新方法。许多这些新方法不会工作,如果没有像Adam(在上一章中讨论过)这样的学习改进,这就是为什么我们现在才讨论它们!本章中的大多数想法都不到 10 年历史,但非常有用——但它们仍在不断发展。再过四年,一些可能被更新的选项所取代。但现在,既然我们有一些共享的术语来讨论深度学习的机制,我们可以检查为什么本章中的技术有助于构建更好的模型。任何表现更好的东西都可能解决相同的基本问题,因此这些经验教训应该是永恒的。¹
本章为您提供了当前生产中用于模型训练的一些最广泛使用的构建模块。因此,我们花费时间讨论了为什么这些新技术能够有效,这样您可以学会识别它们背后的逻辑和推理,并开始培养自己对如何进行自我调整的直觉。了解这些技术同样重要,因为许多新开发的方法都是本章中我们所学内容的变体。
本章我们讨论了五种适用于前馈模型的新方法以及一种对 RNN 的改进。我们大致按照它们被发明的顺序介绍前五种方法,因为每个方法在设计时都倾向于使用先前的技术。单独来看,它们可以提高准确性和加快训练速度;但结合在一起,它们的整体效果大于各部分之和。第一种方法是一种新的激活函数,称为线性整流单元(ReLU)。然后我们在线性/卷积层和我们的非线性激活层之间夹入一种新的归一化层。之后,我们了解两种设计选择,跳跃连接和1 × 1 卷积,它们重复使用我们已知的层。就像醋本身很糟糕,但在更大的食谱中却很神奇一样,跳跃连接和 1 × 1 卷积结合在一起创造了第五种方法,即残差层。残差层为 CNN 提供了最大的准确度提升之一。
最后,我们介绍了长短期记忆(LSTM)层,这是一种 RNN。我们之前讨论的原始 RNN 现在不再广泛使用,因为它很难实现,但解释 RNN 的一般工作原理要容易得多。LSTMs 已经成功使用了 20 多年,是解释本章中许多课程如何重新组合成一种新解决方案的绝佳途径。我们还将简要提及一种“节食”版本的 LSTM,它对内存的需求略低,我们将使用它来确保我们的模型可以在 Colab 的低端免费 GPU 上安全运行。
设置基线
以下代码再次加载 Fashion-MNIST 数据集,因为我们使用它既足够困难以使我们能够看到改进,又足够简单,以至于我们不需要等待很长时间才能看到结果。我们使用这个数据集来创建一个基线——一个展示我们当前方法可以完成什么工作的模型——我们可以将其与我们的新技术对准确度的影响进行比较:
train_data = torchvision.datasets.FashionMNIST("./", train=True,
➥ transform=transforms.ToTensor(), download=True)
test_data = torchvision.datasets.FashionMNIST("./", train=True,
➥ transform=transforms.ToTensor(), download=True)
train_loader = DataLoader(train_data, batch_size=128, shuffle=True)
➥ test_loader = DataLoader(test_data, batch_size=128)
在本章中,我们为几乎每个示例训练全连接和卷积网络,因为某些技术的代码对于每种类型的网络看起来都略有不同。你应该比较全连接和 CNN 模型之间的代码变化,以了解哪些部分与基本思想相关,哪些是我们在实现网络类型时的副作用。这将帮助你将来将这些技术应用到其他模型中!
让我们定义一些基本的超参数和细节,这些将在本章和整本书中重复使用。这里我们有指定我们特征的代码,全连接层的隐藏神经元数量,卷积网络的通道和滤波器数量,以及总的类别数:
W, H = 28, 28 ❶
D = 28*28 ❷
n = 256 ❸
C = 1 ❹
n_filters = 32 ❺
classes = 10 ❻
❶ 我们图像的宽度和高度是多少?
❷ 输入中有多少个值?我们使用这个来确定后续层的大小:28 * 28 图像。
❸ 隐藏层大小
❹ 输入中有多少个通道?
❺ 输入中有多少个通道?
❻ 有多少个类别?
一个全连接和卷积基线
最后,我们可以定义我们的模型。与之前章节相比,每个全连接和 CNN 模型我们都添加了几个额外的层。在本章中,我们将介绍构建网络的新方法,并将与这些更简单的起始模型进行比较。首先,我们在这里定义全连接网络。由于全连接网络的简单性,我们可以使用*运算符进行列表解包,使相同的代码适用于几乎任何数量的隐藏层。我们可以解包一个列表推导式[define_block for _ in range(n)]来创建n层的define_block层:
fc_model = nn.Sequential(
nn.Flatten(),
nn.Linear(D, n), nn.Tanh(), ❶
*[nn.Sequential(nn.Linear(n, n),nn.Tanh()) for _ in range(5)], ❷
nn.Linear(n, classes),
)
❶ 第一个隐藏层
❷ 现在由于每个剩余层都有相同的输入/输出大小,我们可以使用列表解包来构建它们。
接下来,我们定义我们的 CNN 层。如果我们正在构建一个更深的模型,我们可以写出更优雅的代码,但到目前为止我们学到的方法还不够好,无法学习到深层的 CNN。因此,我们保持了 CNN 的相对简单,以便更容易进行比较。但一旦你了解了本章中的技术,你将能够构建更深层的网络,这些网络能够可靠地收敛到更精确的解!以下是代码:
cnn_model = nn.Sequential(
nn.Conv2d(C, n_filters, 3, padding=1), nn.Tanh(),
nn.Conv2d(n_filters, n_filters, 3, padding=1), nn.Tanh(),
nn.Conv2d(n_filters, n_filters, 3, padding=1), nn.Tanh(),
nn.MaxPool2d((2,2)),
nn.Conv2d( n_filters, 2*n_filters, 3, padding=1), nn.Tanh(),
nn.Conv2d(2*n_filters, 2*n_filters, 3, padding=1), nn.Tanh(),
nn.Conv2d(2*n_filters, 2*n_filters, 3, padding=1), nn.Tanh(),
nn.MaxPool2d((2,2)),
nn.Conv2d(2*n_filters, 4*n_filters, 3, padding=1), nn.Tanh(),
nn.Conv2d(4*n_filters, 4*n_filters, 3, padding=1), nn.Tanh(),
nn.Flatten(),
nn.Linear(D*n_filters//4, classes),
)
我们使用新的train_network函数从这一点开始训练所有模型。记住,我们修改了此方法以默认使用AdamW优化器,因此我们不需要指定:
loss_func = nn.CrossEntropyLoss()
fc_results = train_network(fc_model, loss_func, train_loader,
➥ test_loader=test_loader, epochs=10,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device)
cnn_results = train_network(cnn_model, loss_func, train_loader,
➥ test_loader=test_loader, epochs=10,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device)
最后,一个小改动:在本章完成我们对神经网络的处理后,我们将使用del命令删除一些神经网络。如果你不幸地从 Colab 获取了低端 GPU,运行这些示例可能会耗尽内存。让我们明确地告诉 Python 我们已经完成,这样我们就可以回收 GPU 内存并避免这种烦恼:
del fc_model
del cnn_model
现在,让我们看看初始结果,使用 seaborn 进行绘图(到这一点应该非常熟悉了)。我们正在比较我们两个初始模型和未来将要添加的改进。这样,我们可以看到这些改进不仅适用于一种类型的网络。不出所料,CNN 的性能远优于全连接网络。我们预期这一点,因为我们正在处理图像,正如我们在第三章中学到的,卷积层是将关于像素及其关系的“结构先验”编码到我们网络中的强大方式:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_results,
➥ label=’Fully Connected’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_results,
➥ label=’CNN’)
[12]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

拥有这些结果在手,我们可以深入本章的其余部分!我们每个部分都讨论你可以在神经网络中使用的新Module类型及其使用它的直觉或原因,然后将其应用于基线网络。
6.1 更好的激活函数
我们一直非常依赖 tanh(⋅)激活函数,以及在一定程度上依赖 sigmoid 函数σ(⋅),贯穿整本书。它们是神经网络使用的原始激活函数中的两个,但并非只有这两种选择。目前,我们作为社区还没有确定性地知道什么使得一个激活函数比另一个更好,也没有一个选项你应该总是使用。但我们已经了解了一些通常在激活函数中不希望看到的事情。tanh(⋅)和σ(⋅)都可能导致一个称为“梯度消失”的问题。
6.1.1 梯度消失
记住,我们定义的每一个架构都是通过将网络视为一个巨大的函数 fΘ来学习的,其中我们需要使用相对于 f 的参数(Θ)的梯度(∇)来根据损失函数ℓ(⋅,⋅)调整其权重。所以我们执行

但如果∇[Θ]fΘ非常小怎么办?如果发生这种情况,Θ的值几乎不会改变,因此没有学习:


虽然(在第五章中讨论的)动量可以帮助解决这个问题,但如果梯度一开始就没有消失那就更好了。这是因为正如数学所显示的,如果我们接近零,就无事可做了。
tanh 和 sigmoid 激活是如何导致这个梯度消失问题的?让我们再次绘制这两个函数:
def sigmoid(x):
return np.exp(activation_input)/(np.exp(activation_input)+1)
activation_input = np.linspace(-5, 5, num=200) tanh_activation =
np.tanh(activation_input) sigmoid_activation =
sigmoid(activation_input)
sns.lineplot(x=activation_input, y=tanh_activation, color=’red’,
➥ label="tanh(x)")
sns.lineplot(x=activation_input, y=sigmoid_activation, color=’blue’,
➥ label="*σ*(x)")
[13]: <AxesSubplot:>

这两个激活都有一个称为“饱和”的特性,即当输入继续变化时,激活停止变化。对于 tanh(⋅)和σ(⋅),如果输入 x 继续增大,这两个激活都会饱和在值 1.0。如果激活函数的输入是 100,你将输入值加倍,你仍然会从输出得到一个值(几乎是)1.0。这就是饱和的含义。这两个激活函数在图表的左侧也饱和,所以当输入 x 变得非常小的时候,tanh(⋅)将饱和在-1.0,而σ(⋅)将饱和在 0。
让我们绘制这些函数的导数——我们看到饱和度会产生不理想的结果:
def tanh_deriv(x):
return 1.0 - np.tanh(x)**2
def sigmoid_derivative(x):
return sigmoid(x)*(1-sigmoid(x))
tanh_deriv = tanh_deriv(activation_input)
sigmoid_deriv = sigmoid_derivative(activation_input)
sns.lineplot(x=activation_input, y=tanh_deriv, color=’red’,
➥ label="tanh’(x)")
sns.lineplot(x=activation_input, y=sigmoid_deriv, color=’blue’,
➥ label="*σ*′(x)")
[14]: <AxesSubplot:>

你在图中看到问题了吗?随着激活函数开始饱和,其梯度开始消失。这发生在任何饱和的激活函数上。由于我们的权重变化基于梯度∇的值,如果太多神经元开始饱和,我们的网络将停止学习。
这并不意味着你永远不应该使用 tanh(⋅)和σ(⋅);有些情况下你想要饱和(我们将在本章末尾看到一个 LSTM 的例子)。如果你没有特定的理由想要饱和,我建议避免使用饱和的激活函数——这是我们接下来要学习做的事情。
注意饱和激活不是梯度消失的唯一原因。你可以通过查看其直方图(使用 .grad 成员变量)来检查你的梯度是否消失。如果你使用可以饱和的激活函数,你也可以绘制激活函数的直方图来检查这是否是梯度消失的原因。例如,如果你使用 σ(⋅) 作为你的激活函数,并且直方图显示 50% 的激活值在 0.01 与 1.0 或 0.0 之间,你就知道饱和是问题的根源。
6.1.2 修正线性单元 (ReLUs):避免梯度消失
现在我们知道,默认情况下饱和的激活函数可能不是一个很好的激活函数来使用。我们应该使用什么替代方案呢?最常见的方法是使用一种称为ReLU(修正线性单元)的激活函数,²,它有一个非常简单的定义:

ReLU 就做这么多。如果输入是正的,返回值不变。如果输入是负的,返回值是零。这可能会让人感到惊讶,因为我们一直强调非线性的重要性。但事实证明,几乎任何非线性都是足够的来学习。选择这样一个简单的激活函数也导致了一个简单的导数:

这就是全部。对于所有可能输入的一半,ReLU 的导数是一个常数。对于大多数用例,只需将 tanh (⋅) 或 σ(⋅) 替换为 ReLU 激活函数,就可以使你的模型在更少的迭代次数中收敛到更精确的解。然而,ReLU 对于非常小的网络通常表现较差。
为什么?ReLU 相比于有消失梯度的激活函数,对于 x <= 0 的输入没有梯度。如果你有很多神经元,一些神经元“死亡”并停止激活是可以接受的;但如果你没有足够的额外神经元,这就会成为一个严重的问题。这个问题可以通过一个简单的修改来解决:对于负输入,而不是返回 0,让我们返回其他值。这引出了我们所说的Leaky ReLU。³ Leaky ReLU 取一个“泄漏”因子 α,它应该是小的。在 α ∈ [0.01,0.3] 范围内的值通常被使用,并且在这种情况下,具体值的影响相对较小。
Leaky ReLU 的数学定义是什么?同样,它是一个简单的变化,通过一个因子 α 减少了负值。这个新的激活和导数可以简洁地定义为以下:

用代码表达同样的意思,我们有
def leaky_relu(x, alpha=0.1): ❶
return max(alpha*x, x) def leaky_reluP(x, alpha=0.1): ❷
if x > 0:
return 1
else:
return alpha
❶ 将激活函数转换为代码
❷ 激活函数的导数,其中 x 是应用激活的原输入
改进 ReLU 的直觉是,当x <= 0 时,存在一个硬“地板”。因为在这个地板的水平上没有变化,所以没有梯度。相反,我们希望地板“泄漏”以便它发生变化——但变化缓慢。即使它只改变一点点,我们也可以得到一个梯度。让我们绘制所有这些激活函数,看看它们看起来像什么:
activation_input = np.linspace(-5, 5, num=200) relu_activation =
np.maximum(0,activation_input) leaky_relu_activation =
np.maximum(0.3*activation_input,activation_input)
sns.lineplot(x=activation_input, y=tanh_activation, color=’red’,
➥ label="tanh(x)")
sns.lineplot(x=activation_input, y=sigmoid_activation, color=’blue’,
➥ label="*σ*(*x*)")
sns.lineplot(x=activation_input, y=relu_activation, color=’green’,
➥ label="ReLU(x)")
sns.lineplot(x=activation_input, y=leaky_relu_activation, color=’purple’,
➥ label="LeakyReLU(x)")
[15]: <AxesSubplot:>

我们可以看到,当输入增大时,ReLU 和 LeakyReLU 的行为呈线性,只是随着输入的增加而增加。当输入变小时,两者仍然是线性的,但 ReLU 被固定在零,而 LeakyReLU 在减小。两者的非线性仅仅是改变线的斜率,这已经足够了。现在让我们用更多的代码来绘制梯度:
relu_deriv = 1.0*(activation_input > 0)
leaky_deriv = 1.0*(activation_input > 0) + 0.3*(activation_input <= 0)
sns.lineplot(x=activation_input, y=tanh_deriv, color=’red’,
➥ label="tanh’(x)")
sns.lineplot(x=activation_input, y=sigmoid_deriv, color=’blue’,
➥ label="*σ*′(*x*)")
sns.lineplot(x=activation_input, y=relu_deriv, color=’green’,
➥ label="ReLU’(x)")
sns.lineplot(x=activation_input, y=leaky_deriv, color=’purple’,
➥ label="LeakyReLU’(x)")
[16]: <AxesSubplot:>

因此,LeakyReLU 的梯度值永远不会自行消失,但层之间的交互仍然可能导致梯度爆炸或消失。我们将在本章的后续部分使用残差层和 LSTMs 来解决这个问题,但至少 LeakyReLU 不会像 tanh(⋅)和σ(⋅)激活那样导致问题。
6.1.3 使用 LeakyReLU 激活进行训练
现在我们已经了解了为什么想要使用 LeakyReLU,让我们通过测试使用它进行训练并查看准确性是否有所提高来检查它的效果。我们将使用 LeakyReLU 训练我们模型的新版本,我们通常发现它等于或优于标准的 ReLU,因为它的行为略好(激活和梯度中没有硬零)。首先,我们定义要使用的泄漏率。PyTorch 使用默认值α = 0.01,这是一个相当保守的值,足以避免正常 ReLU 的零梯度。我们使用α = 0.1,这是我首选的默认值,但这不是一个关键的选择:
leak_rate = 0.1 ❶
❶ 我们希望 LeakyReLU 泄漏多少。在[0.01, 0.3]范围内的任何值都是可以的。
接下来,我们定义两种架构的新版本,只将nn.Tanh()函数更改为nn.LeakyReLU。首先是全连接模型,仍然只有五行代码:
fc_relu_model = nn.Sequential(
nn.Flatten(),
nn.Linear(D, n), nn.LeakyReLU(leak_rate),
*[nn.Sequential(nn.Linear(n, n), nn.LeakyReLU(leak_rate))
➥ for _ in range(5)],
nn.Linear(n, classes),
)
CNN 模型也可以用同样的方式实现,但函数名太长,难以输入和阅读。让我们看看另一种组织代码的方法。我们定义一个cnnLayer函数,它接受每一层的输入和输出大小,并返回由Conv2d和激活函数组成的完整层。这样,当我们尝试新想法时,只需更改这个函数,其余的代码也会随之改变;我们不需要进行太多编辑。我们还可以添加一些便利的功能,比如自动计算填充大小,使用常见的默认值,如核大小,并保持输出大小与输入相同:
def cnnLayer(in_filters, out_filters=None, kernel_size=3):
"""
in_filters: how many channels are coming into the layer
out_filters: how many channels this layer should learn / output, or ‘None‘
if we want to have the same number of channels as the input.
kernel_size: how large the kernel should be
"""
if out_filters is None:
out_filters = in_filters ❶
padding=kernel_size//2 ❷
return nn.Sequential( ❸
nn.Conv2d(in_filters, out_filters, kernel_size, padding=padding),
nn.LeakyReLU(leak_rate)
)
❶ 这是一个常见的模式,所以如果不需要,我们可以将其自动化为默认设置。
❷ 填充以保持相同大小
❸ 将层和激活合并为一个单一单元
现在我们 CNN 的代码更加简洁,更容易阅读。cnnLayer函数也使得像全连接模型那样使用列表解包变得更加容易。以下是我们通用的 CNN 代码块。忽略对象名称,这个代码块可以通过改变cnnLayer函数的定义,被重新用于许多不同风格的 CNN 隐藏层:
cnn_relu_model = nn.Sequential(
cnnLayer(C, n_filters), cnnLayer(n_filters), cnnLayer(n_filters),
nn.MaxPool2d((2,2)),
cnnLayer(n_filters, 2*n_filters),
cnnLayer(2*n_filters),
cnnLayer(2*n_filters),
nn.MaxPool2d((2,2)),
cnnLayer(2*n_filters, 4*n_filters), cnnLayer(4*n_filters),
nn.Flatten(),
nn.Linear(D*n_filters//4, classes),
)
我们已经准备好训练这两个模型。像往常一样,PyTorch 模块化的设计意味着我们不需要更改其他任何东西:
fc_relu_results = train_network(fc_relu_model, loss_func, train_loader,
➥ test_loader=test_loader, epochs=10,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device)
del fc_relu_model
cnn_relu_results = train_network(cnn_relu_model, loss_func, train_loader,
➥ test_loader=test_loader, epochs=10,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device)
del cnn_relu_model
让我们比较一下我们的新relu_results和原始的fc_results以及cnn_results。你应该会发现 LeakyReLU 在 CNN 和全连接网络中都比其 tanh 对应物表现更好。它不仅更准确,而且在数值上更优美,实现起来也更简单。计算 tanh 所需的 exp()函数需要相当的计算量,但 ReLU 只有简单的乘法和 max()操作,这要快得多。下面是代码:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_results, label=’FC’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_relu_results,
➥ label=’FC-ReLU’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_results,
➥ label=’CNN’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_relu_results,
➥ label=’CNN-ReLU’)
[22]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

ReLU 变体
因此,ReLU 家族提供了更好的准确度,稍微快一点,而且从头开始实现时代码更少。出于这些原因,ReLU 已经迅速成为社区中许多人的默认首选;由于它已经在大多数现代神经网络中成功使用了几年,因此它是一个很好的实用选择。
ReLU 激活函数还有许多其他版本,其中一些已经内置到 PyTorch 中。有PReLU,它试图学习 LeakyReLU 中α应该是什么值,从而将其作为一个超参数去除。ReLU6引入了在你想有那种行为时的有意饱和。还有 ReLU 的“平滑”扩展,如CELU、GELU和ELU,它们被推导出来具有某些特性。这些只是 PyTorch 中已有的;你可以在网上找到更多 ReLU 的变体和替代方案(mng.bz/VBeX)。我们没有时间或空间去探讨所有这些,但从 tanh 到 ReLU 的改进并不像 ReLU 及其泄漏变体到这些新风味之间的差异那么大。如果你想了解更多关于这些其他激活函数的信息,它们值得一试,因为我们已经看到它们可以带来很大的差异。但通常,使用任何 ReLU 变体作为默认选择都是安全且明智的。
6.2 正则化层:神奇地更好的收敛
为了解释归一化层及其工作原理,让我们谈谈如何处理一个具有 n 行和 d 个特征的正常数据集X = {x[1], x[2], …, x[n]}的归一化。在你开始将矩阵 X 输入你最喜欢的机器学习算法之前,你通常以某种方式对特征进行归一化或标准化。这可能确保所有值都在[0,1]的范围内,或者减去均值μ并除以标准差σ。⁴ 通过减去均值并除以标准差进行标准化可能是你以前做过的事情,鉴于其普遍性,但让我们写出三个步骤(其中ϵ是一个很小的值,如 10^(−15),以避免所有值都相同时的除零错误):

这使得数据 X̂具有均值为零和标准差为 1。我们这样做是因为大多数算法对输入数据的尺度很敏感。这种尺度敏感性意味着如果你将你的数据集中的每个特征乘以 1,000,它将改变你的模型最终学习到的内容。通过执行归一化或标准化,我们确保我们的数据在一个合理的数值范围内(-1 到 1 是一个不错的选择),这使得我们的优化算法更容易运行。
6.2.1 归一化层放在哪里?
在我们训练神经网络之前,我们通常在将数据传递到网络的第一个层之前再次进行归一化或标准化。但如果我们把这种归一化过程应用到神经网络的每一层呢?这会让网络学习得更快吗?如果我们包含一些额外的细节,结果证明答案是肯定的!具有归一化层的新网络的组织结构如图 6.1 所示。

图 6.1 三种具有三个隐藏层和一个输出层的网络版本:(a) 我们迄今为止所学的常规方法;(b) 和 (c) 添加归一化层的两种不同方式
让我们用x[l]表示第 l 层的输入,同样,用μ[l]和σ[l]表示第 l 层输入的均值和标准差。归一化层在每一层都应用了一个额外的技巧:让我们让网络学习如何缩放数据,而不是假设均值为 0 和标准差为 1 是最好的选择。这表现为

第一个项与我们之前所拥有的相同类型:从数据中移除均值并除以标准差。这里的关键补充是γ,它允许网络改变数据的尺度,以及β,它允许网络将数据左移/右移。由于网络控制γ和β,它们是学习参数(因此γ和β包含在所有参数Θ的集合中)。通常初始化γ =
和 β =
,以便在开始时,每个层都在执行简单的标准化。随着训练的进行,梯度下降允许我们按需缩放(改变γ)或平移(改变β)结果。
归一化层取得了非凡的成功,我经常把它们描述为“魔法小精灵的粉末”:你把一些归一化层撒到你的网络中,突然它开始更快地收敛到更准确的解。甚至那些根本无法训练的网络突然开始工作。我们将讨论两种最广泛使用的归一化层:批和层。⁵ 讨论完每种类型后,我们可以解释何时选择其中一种而不是另一种(但你几乎总是应该使用某种形式的归一化层)。这两种方法之间的唯一区别是计算每个层的均值μ和标准差σ的内容;它们都使用相同的方程式,并遵循图 6.1 中的图示。
6.2.2 批归一化
第一种也是最流行的归一化层类型是批归一化(BN)。根据输入数据的结构,BN 的应用方式不同。如果我们正在处理全连接层(PyTorch 维度(B, D)),我们将批中 B 个项目的特征值 D 的平均值和标准差取出来。因此,我们在给定的批中的数据特征上进行归一化。这意味着μ, σ, γ, 和 β的形状为(D),批中的每个项目都通过该批数据的平均值和标准差进行归一化。
为了使这一点更清晰,让我们看看一些基于形状为(B, D)的张量的假设 Python 代码,该代码计算μ和σ。我们明确地使用for循环来使其更清晰。如果你要真正实现这个功能,你应该尝试使用像torch.sum和torch.mean这样的函数来使这个过程运行得更快:
B, D = X.shape ❶
*μ* = torch.zeros((D))
*σ* = torch.zeros((D))
for i in range(B): ❷
*μ* += X[i,:] ❸
*μ* /= B
for i in range(B): ❹
*σ* += (X[i,:]-*μ*)*(X[i,:]-*μ*)
*σ* += 1e-5
*σ* = torch.sqrt(*σ*)
❶ 这个 BN 示例使用了显式的循环:你不会为真实情况编写这样的 torch 代码!在这个例子中,X 的形状是(B, D)。
❷ 遍历批中的每个项目
❸ 平均特征值
❹ 以相同的方式处理标准差
由于 BN 在训练过程中对批次大小敏感,并且无法使用批次大小为 1 的情况,因此它需要一些巧妙的方法在推理/预测时间使用,因为你不希望你的预测依赖于其他数据的存在!为了解决这个问题,大多数实现都会保留所有先前看到的批次中均值和标准差的运行估计,并在训练完成后使用这个单一估计对所有预测进行操作。PyTorch 已经为你处理了这个问题。
如果我们有一维数据,形状为(B,C,D)呢?在这种情况下,我们在批次上归一化通道。这意味着μ、σ、γ和β每个都具有(C)的形状。这是因为我们希望将每个通道中的 D 个值视为具有相同的性质和结构,因此平均是在所有 B 批次中通道的B × D个值上进行的。然后我们对每个通道中的所有值应用相同的缩放γ和偏移β。如果我们有形状为(B,C,W,H)的二维数据呢?与一维情况类似,μ、σ、γ和β的形状也是(C)。对于任何具有通道的 z 维结构化数据,我们始终在通道上使用 BN。以下表格总结了根据张量形状,你应该寻找哪个 PyTorch 模块:
| 张量形状 | PyTorch 模块 |
|---|---|
| (B,D) | torch.nn.BatchNorm1d(D) |
| (B,C,D) | torch.nn.BatchNorm1d(C) |
| (B,C,W,H) | torch.nn.BatchNorm2d(C) |
| (B,C,W,H,D) | torch.nn.BatchNorm3d(C) |
如果我们在推理时间对输入张量 X 应用 BN,代码可能看起来像这样:
BN = torch.tensor((B, C, W, H))
for j in range(C):
BN[:,j,:,:] = (X[:,j,:,:]-*μ*[j]/*σ*[j])**γ*[j] + *β*[j]
6.2.3 使用批归一化进行训练
当我们为 Fashion-MNIST 创建数据加载器时,我们使用了 128 的批次大小,因此我们应该没有问题将 BN 应用于我们的架构。根据前面的表格,我们在全连接网络的每个nn.Linear层后添加BatchNorm1d——这就是我们需要做的唯一更改!让我们看看下一个代码片段中的样子。我将 BN 放在线性层之后而不是之前,以匹配大多数人做的,这样当你阅读其他代码时,它看起来会熟悉一些:⁶
fc_bn_model = nn.Sequential(
nn.Flatten(),
nn.Linear(D, n), nn.BatchNorm1d(n), nn.LeakyReLU(leak_rate),
*[nn.Sequential(nn.Linear(n, n), nn.BatchNorm1d(n),
➥ nn.LeakyReLU(leak_rate)) for _ in range(5)],
nn.Linear(n, classes),
)
感谢我们如何组织我们的 CNN,我们可以在下一个代码块中重新定义cnnLayer函数以改变其行为。我们所有要做的就是在我们 CNN 的每个nn.Conv2d层之后添加nn.BatchNorm2d。然后我们运行定义cnn_relu_model的相同代码,但这次将其重命名为cnn_bn_model:
def cnnLayer(in_filters, out_filters=None, kernel_size=3):
if out_filters is None:
out_filters = in_filters ❶
padding=kernel_size//2 ❷
return nn.Sequential( ❸
nn.Conv2d(in_filters, out_filters, kernel_size, padding=padding),
nn.BatchNorm2d(out_filters), ❹
nn.LeakyReLU(leak_rate)
)
❶ 这是一个常见的模式,所以如果不特别要求,我们可以将其自动化为默认设置。
❶ 填充以保持相同大小
❶ 将层和激活合并为一个单元
❶ 唯一的变化:在卷积后添加 BatchNorm2d!
接下来是我们熟悉的代码块,用于分别训练基于批归一化的全连接和 CNN 模型:
fc_bn_results = train_network(fc_bn_model, loss_func, train_loader,
➥ test_loader=test_loader, epochs=10,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device)
del fc_bn_model
cnn_bn_results = train_network(cnn_bn_model, loss_func, train_loader,
➥ test_loader=test_loader, epochs=10,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device)
del cnn_bn_model
我们新网络的输出结果如下,与添加 ReLU 激活函数之前的最佳结果进行比较。我们再次看到在所有方面都提高了准确性,特别是对于我们的 CNN:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_relu_results,
➥ label=’FC-ReLU’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_bn_results,
➥ label=’FC-ReLU-BN’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_relu_results,
➥ label=’CNN-ReLU’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_bn_results,
➥ label=’CNN-ReLU-BN’)
[27]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

注意:观察完全连接模型和基于 CNN 的模型之间的改进差异,我们可能会推断出完全连接架构开始达到其最佳状态,而无需变得更大或更深。现在这并不重要,但我提到它作为使用多个模型来推断关于你的数据假设的例子。
为什么 BN 效果如此之好?我能给出的最佳直觉是我们之前走过的逻辑:归一化有助于确保我们每层之后的实际数值在一个普遍的“良好”范围内;通过γ和β,网络可以决定这个范围的确切位置。但找到这个工作原理的根本原因是深度学习中的一个活跃研究领域!不幸的是,还没有人有一个真正确定的答案。
6.2.4 层归一化
另一种流行的归一化方法是(令人困惑地)称为层归一化(LN),其中我们查看特征的平均激活而不是批次的平均。这意味着批处理中的每个示例都有自己的μ和σ值,但共享一个学习到的γ和β。再次,我们可以通过一个带有明确代码的例子来查看这一点:
B, D = X.shape ❶
*μ* = torch.zeros((B))
*σ* = torch.zeros((B))
for j in range(D):
*μ* += X[:, j] ❷
*μ* /= D
for j in range(D):
*σ* += (X[:,j]-*μ*)*(X[:,j]-*μ*) ❸
*σ* += 1e-5
*σ* = torch.sqrt(*σ*)
❶ 所以在这个例子中,X 的形状是(B, D)。
❷ 注意这已经从 X[i,:]之前进行了更改!
❸ 再次,从 X[i,:]进行了更改!
LN 和 BN 之间的唯一区别就是我们平均的是什么!在 LN 中,批处理 B 中存在的示例数量并不重要,因此我们可以使用 LN 时批处理更小。
有了这些,我们可以深入到我们重复的例子,将这种方法应用到我们的相同网络架构中。与 BN 不同,BN 在 PyTorch 中为每种张量形状有不同的类,而 LN 有一个类适用于所有架构。有一些细微的原因与某些问题相关,在这些问题中,LN 比 BN 更受欢迎,并且需要额外的灵活性。
6.2.5 使用层归一化进行训练
nn.LayerNorm类接受一个单一参数,它是一个整数列表。如果你正在处理形状为(B,D)的完全连接层,你使用[D]作为列表,得到nn.LayerNorm([D])作为层构造。在这里,你可以看到我们使用 LN 的完全连接网络的代码。对于完全连接层,只需将 BN 替换为 LN:
fc_ln_model = nn.Sequential(
nn.Flatten(),
nn.Linear(D, n), nn.LayerNorm([n]), nn.LeakyReLU(leak_rate),
*[nn.Sequential(nn.Linear(n, n), nn.LayerNorm([n]),
➥ nn.LeakyReLU(leak_rate)) for _ in range(5)],
nn.Linear(n, classes),
)
为什么 LN 需要一个整数列表?这个列表告诉 LN,从右到左,哪些值需要平均。所以如果我们有一个形状为(B,C,W,H)的张量 2D 问题,我们给 LN 最后一个三个维度作为列表[C, W, H]。这涵盖了所有特征,这是我们希望 LN 进行归一化的。这使得 LN 对于 CNN 来说稍微复杂一些,因为我们还需要注意宽度和高度的大小,并且每次应用最大池化时都会发生变化。
以下是绕过这个问题的新的 cnnLayer 函数。我们添加了一个 pool_factor 参数,用于跟踪池化应用了多少次。之后是一个 LN 对象,其列表 [out_filters, W//(2**pool_factor), H//(2**pool_factor)] 根据池化应用的次数调整宽度和高度。
注意:这也是使用填充与我们的卷积层一起使用的另一个原因。通过填充卷积,使得输出具有与输入相同的宽度和高度,我们简化了需要跟踪的事情。目前,我们需要在每一轮池化时除以 2。如果我们还必须跟踪卷积进行了多少次,代码将会更加复杂。这也会使得对网络定义的更改变得更加困难。
这里是代码:
def cnnLayer(in_filters, out_filters=None, pool_factor=0,kernel_size=3):
if out_filters is None:
out_filters = in_filters ❶
padding=kernel_size//2 ❷
return nn.Sequential( ❸
nn.Conv2d(in_filters, out_filters, kernel_size, padding=padding),
nn.LayerNorm([out_filters,
➥ W//(2**pool_factor), H//(2**pool_factor)]), ❹
nn.LeakyReLU(leak_rate)
)
❶ 这是一个常见的模式,所以如果不需要询问,我们就将其自动化为默认设置。
❷ 填充以保持相同大小
❸ 将层和激活合并为一个单一单元
❹ 唯一的改变:在卷积后切换到 LayerNorm!
现在我们有了新的 cnnLayer 函数,我们可以创建一个使用 LN 的 cnn_ln_model。以下代码展示了它的创建,因为我们必须在执行池化后添加 pool_factor 参数:
cnn_ln_model = nn.Sequential(
cnnLayer(C, n_filters),
cnnLayer(n_filters),
cnnLayer(n_filters),
nn.MaxPool2d((2,2)), ❶
cnnLayer(n_filters, 2*n_filters, pool_factor=1),
cnnLayer(2*n_filters, pool_factor=1),
cnnLayer(2*n_filters, pool_factor=1),
nn.MaxPool2d((2,2)), ❷
cnnLayer(2*n_filters, 4*n_filters, pool_factor=2),
cnnLayer(4*n_filters, pool_factor=2),
nn.Flatten(),
nn.Linear(D*n_filters//4, classes),
)
❶ 我们已经进行了一轮池化,所以池化因子=1。
❷ 现在我们已经进行了两轮池化,所以池化因子=2。
这需要做一点额外的工作,但并不痛苦。我们现在可以训练这两个新的模型:
fc_ln_results = train_network(fc_ln_model, loss_func, train_loader,
➥ test_loader=test_loader, epochs=10,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device)
del fc_ln_model
cnn_ln_results = train_network(cnn_ln_model, loss_func, train_loader,
➥ test_loader=test_loader, epochs=10,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device)
del cnn_ln_model
让我们用 LN、BN 和没有归一化层的基于 ReLU 的模型来绘制结果。LN 的神奇魔力似乎并不那么强烈。对于 CNN,LN 相比没有归一化的模型是一个改进,但不如 BN。对于全连接层,LN 似乎与非归一化的变体更一致:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_relu_results,
➥ label=’FC-ReLU’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_bn_results,
➥ label=’FC-ReLU-BN’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_relu_results,
➥ label=’CNN-ReLU’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_bn_results,
➥ label=’CNN-ReLU-BN’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_ln_results,
➥ label=’FC-ReLU-LN’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_ln_results,
➥ label=’CNN-ReLU-LN’)
[32]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

初看之下,LN 并不显得那么有帮助。它的代码更难以包含,并且在模型准确度方面表现并不完全出色。不过,更复杂的代码确实有其目的。BN 对于全连接层和卷积模型非常有用,因此 PyTorch 可以轻松地将它硬编码到这两个任务中。LN 几乎对任何架构(例如 RNN)都有帮助,而明确告诉 LN 要归一化哪些整数的列表允许我们使用相同的 Module 来处理这些不同用例。
6.2.6 使用哪个归一化层?
尽管归一化层已经存在了几年,但完全理解它们以及何时使用它们是一个活跃的研究问题。对于非循环网络,如果你可以在批处理大小 B ≥ 64 上进行训练,使用 BN 是一个好主意。如果你的批处理大小不够大,你将无法得到 μ 和 σ 的良好估计,你的结果可能会受到影响。如果你的问题适合这种大批量的情况,BN 通常会通常提高你的结果,但也有一些情况它不起作用。所以如果你发现你的模型难以训练,尝试测试没有 BN 的模型版本,看看你是否处于 BN 反而会伤害而不是帮助的奇怪情况。幸运的是,这些情况很少见,所以如果我有 CNN 和大型批处理,我仍然默认包括 BN。
LN 对于循环架构特别受欢迎,值得添加到你的 RNN 中。每次你重用具有权重共享的子网络时,LN 应该是你的首选:BN 的统计假设一个分布,而在你进行权重共享时,你会得到多个分布,这可能会引起问题。LN 在批处理大小 B ≤ 32 的情况下也可能会有效,尽管它通常不会像 BN 那样为 CNN 和全连接网络提供相同级别的改进。这可能是一个因素,如果你认为扩大你的网络将提高性能,但你不能不缩小批处理大小(由于内存不足)来扩大网络。
虽然 BN(批量归一化)和 LN(层归一化)是最流行和最广泛使用的归一化层,但它们远非唯一正在开发和使用的。值得留意新的归一化层方法;使用它们通常只需对代码进行简单的修改。
6.2.7 层归一化的一个特性
由于 LN(层归一化)已经变得非常重要,我想分享一些关于使归一化层特别不同寻常且有时令人困惑的深入见解。让我们来谈谈网络的强度,或者更技术性地,容量。
我在这里稍微宽松地使用这些词来指代一个问题的复杂度或神经网络复杂度。⁷ 一个好的心理模型是将复杂度想象成看起来随机或无规律的;一些例子在图 6.2 中展示。一个函数越复杂,我们的神经网络必须越复杂或越强大才能近似它。

图 6.2 我们可以将函数的复杂度想象成它看起来有多扭曲和无规律。这张图显示了左侧一个非常低复杂度的函数(线性);随着向右移动,复杂度增加。
当我们谈论模型时,事情变得有趣,因为有两个不同的因素在相互作用:
-
模型在技术上能够表示什么(即,如果有一个先知能告诉你给定模型的完美参数)?
-
我们的最优化过程实际上能学到什么?
在第五章中,我们看到了我们的优化方法并不完美,所以一个模型能够学习的内容必须小于或等于它能够表示的内容。每次我们添加一个额外的层或在层中增加神经元的数量,我们都会增加网络的容量。
我们可以将欠拟合数据描述为具有比我们试图解决的问题的复杂性更少的模型。这就像试图用微波炉举办婚礼:你无法满足这种情况的需求。这,加上另外两种重要的情况,如图 6.3 所示。第一种情况可能导致过拟合,但这并不意味着我们可以解决这个问题。这是好事,因为我们有工具来处理它(例如,使模型更小或添加正则化)。我们可以通过给所有数据点随机标签来轻松检查我们是否处于第二种情况:如果训练损失下降,则模型有足够的容量来记住整个数据集。

图 6.3 展示了在指定神经网络时可能出现的三种常见情况。关注复杂性的相对比较,而不是显示的确切类型。第一种情况在我们模型太小的时候很常见。第二种和第三种情况在我们使模型更大时都常见,但第三种情况更难以推理。归一化层帮助我们从糟糕的第三种情况转移到更好的第二种情况。
图 6.3 中的第三种情况是最为恶劣的,我们没有任何好的方法来检查这是否是我们遇到的问题。通常只有在发明了更好的方法(例如,从 SGD 到带有动量的 SGD)时才会被发现。归一化层是独一无二的,因为它们不会增加容量。换句话说,它们的表示能力不会改变,但它们可以学习的内容却得到了提升。
在这方面,归一化层相当独特,我们作为社区仍在努力弄清楚为什么它们工作得如此之好。因为它们不会增加容量,许多实践者和研究人员都对此行为感到困扰。如果我们可以不包含这些归一化层,那会更好,因为它们不会影响容量,但到目前为止,它们的价值太高,无法放弃。
批量归一化不会增加表示能力的证明
我说归一化层不会给网络增加任何表示能力,这听起来可能有些奇怪。我们添加了一种新的层,而增加更多的层通常会使网络能够表示更复杂的函数。那么,归一化层为什么不同呢?
我们可以用一点代数来回答这个问题!记住,我们说过归一化层的格式是

但是,那个 x 值是线性层的输出。因此,我们可以将这个表达式重写为

使用代数,我们可以采取以下步骤来简化这一点。首先,分子中操作顺序的顺序在有或没有括号的情况下都不会改变,所以让我们去掉它们:

现在,让我们将 γ 移到左边,并应用于分子中的项。我们将偏置项 b 和均值 μ 的两个平移分组:

接下来,我们将 σ 的除法独立应用于每个项:

最左侧的项涉及向量矩阵乘积 x^⊤W,因此我们可以将所有与 γ 和 σ 相关的逐元素操作移动到 W 上,并将 x 视为之后发生的事情(结果相同):

你看到答案了吗?我们遇到了与第二章和第三章中讨论的相同情况。归一化是一个 线性操作,任何连续的线性操作序列都等价于一个线性操作!跟随 BN 的线性层序列 等价 于一个不同的、单一的 nn.Linear 层,其权重为 W̃ + 和偏置 b̃:

如果你使用卷积层,你会得到相同的结果。这导致一些深度学习研究人员甚至实践者感到困惑,因为批量归一化(BN)非常有效,但其效用巨大,难以拒绝。
6.3 跳跃连接:一种网络设计模式
现在我们已经了解了一些新的 Module,它们可以改进我们的网络,让我们转向了解可以整合到我们网络中的新 设计。第一个被称为 跳跃连接。在正常的正向传播网络中,一个层的输出直接传递到下一层。有了跳跃连接,这一点仍然成立,但我们还会“跳过”下一层,并将连接到前一层。有 许多 种方法可以做到这一点,图 6.4 展示了几个选项。

图 6.4 最左侧的图显示了一个正常的正向传播设计。右侧的两个图显示了实现跳跃连接的两种不同方法。连接上的黑色点表示所有输入输出结果的连接。为了简单起见,只显示了线性层,但网络还会包含归一化和激活层。
左边的图像是我们一直在使用的正常网络。第二幅图显示了每个其他层“跳过”到下一层的策略。当这种情况发生时,每第二个层的输入数量会根据前两层输入的输出大小增加。这个想法是,图中的每个实心点都表示输出的连接。所以如果 x 和 h 在图中连接,它们将输入到下一层:[x,h]。在代码中,这将是类似于 torch.cat([x, h], dim=1) 的东西。这样,两个输入 x 和 h 的形状是(B,D)和(B,H)。我们想要堆叠特征,以便结果将具有(B,D+H)的形状。图中的第三个示例显示了多个输入跳过到特定层,给它提供了三倍多的输入。这导致最终层的输入大小根据进入它的三个层的三倍增加,所有层的输出大小都是 h。
为什么使用跳过连接?部分直觉是跳过连接可以使优化过程更容易。换句话说,跳过连接可以缩小网络容量(它能表示什么)和它能学习的内容(它学习表示的内容)之间的差距。图 6.5 突出了这一点。考虑左边的正常网络示例。梯度包含了我们需要学习和调整每个参数所需的信息。在左边,第一隐藏层需要等待三个其他层处理并传递梯度,才能获得任何信息。但每一步也是噪声的机会,如果我们有太多的层,学习就会变得无效。右边的网络对于非常深的网络,将梯度的路径减少了一半。

图 6.5 每个操作都会使网络更复杂,但也会使梯度更复杂,在深度(容量)和可学习性(优化)之间产生权衡。跳过连接创建了一条操作更少的短路径,可以使梯度更容易学习。为了简单起见,只显示了线性层,但也会包含归一化和激活层。
一种更极端的选项是将每个隐藏层通过跳过连接直接连接到输出层。这如图 6.6 所示。每个隐藏层都能直接从输出层获得一些关于梯度的信息,从而提供了对梯度的更直接访问,同时梯度也是从更长路径逐层处理。这种更直接的反馈可以使学习更容易。它还可以为需要高、低级细节的某些应用带来好处。想象一下,你正在尝试区分哺乳动物:高级细节,如形状,可以很容易地区分鲸鱼和狗,但低级细节,如毛发的风格,对于区分不同的狗品种很重要。

图 6.6 创建密集跳转连接,其中所有内容都连接到输出层,这显著缩短了从输出梯度回到每一层的路径长度。当不同层学习不同类型的特征时,这也可能很有帮助。为了简单起见,只显示了线性层,但实际网络还会包含归一化和激活层。
噪声的噪声定义
在这里,以及本书中的许多地方,我们使用一个非常宽泛的噪声定义。噪声可以是添加到网络中的实际噪声,也可以是消失或爆炸的梯度,但通常它只是意味着难以使用。梯度不是魔法;它是一个寻找最佳当前选择的贪婪过程。
想象一下玩一个电话游戏,试图将一条信息从一个人传递到另一个人。在电话传递过程中,每个人贪婪地倾听并传递信息给下一个人。但是当信息回到电话链的起点时,它通常是被严重扭曲的原话版本。电话线中的人越多,信息中发生错误的可能性就越大。如果每个人都是网络中的一个隐藏层,那么我们在训练中就会遇到同样的问题!梯度是信息,每个隐藏层在尝试将其传递回去时都会改变信息。如果太深,信息就会被电话游戏扭曲得太厉害,以至于没有用了。
然而,让每一层都直接连接到输出可能会过度设计,并最终使学习问题更加困难。想象一下,如果你有 100 个隐藏层,它们都直接连接到输出层——这将是一个巨大的输入,输出层需要处理。尽管如此,这种方法已经通过组织“块”密集跳转连接而成功使用⁸。
6.3.1 实现全连接跳过
既然我们已经讨论了跳转连接,那么让我们为全连接网络实现一个。这个例子展示了如何创建图 6.6 中所示的第二种跳转连接风格,其中大量层跳转到最终一层。然后我们可以多次重用这个层,以创建一种混合策略,每隔一层就实现图 6.5 中的第一种风格。
图 6.7 展示了这是如何工作的。每一组跳转连接定义了一个由SkipFC Module定义的块。通过堆叠多个块,我们重新创建了图 6.6 中使用的网络风格。图 6.5 可以使用单个SkipFC(6,D,N)重新创建。

图 6.7 我们为具有跳转连接的全连接网络定义的架构。每个虚线块表示一个密集连接块,并使用我们定义的一个SkipFC对象创建。
要做到这一点,我们需要存储一个隐藏层的列表。在 PyTorch 中,这应该使用 ModuleList 类来完成。计算机科学家在命名事物时并不非常富有创造力,所以正如其名所暗示的,它是一个只存储 Module 类型的对象的 list。这是很重要的,这样 PyTorch 就知道它应该搜索 ModuleList 以找到更多的 Module 对象。这样,它仍然可以使用自动微分,并使用单个 .parameters() 函数获取所有参数。
以下 PyTorch 模块定义了一个用于创建跳过连接的类。它创建了一个包含 n_layers 个总层的多个层的较大块,以密集式跳过连接的形式。单独使用时,它可以创建密集网络;或者,连续使用时,它可以创建交错跳过连接:
class SkipFC(nn.Module):
def __init__(self, n_layers, in_size, out_size, leak_rate=0.1):
"""
n_layers: how many hidden layers for this block of dense skip
➥ connections
in_size: how many features are coming into this layer
out_size: how many features should be used for the final layer of
➥ this block.
leak_rate: the parameter for the LeakyReLU activation function.
"""
super().__init__()
l = n_layers-1 ❶
self.layers = nn.ModuleList([
➥ nn.Linear(in_size*l, out_size) if i == l ❷
➥ else nn.Linear(in_size, in_size)
➥ for i in range(n_layers)])
self.bns = nn.ModuleList([
➥ nn.BatchNorm1d(out_size) if i == l ❷
➥ else nn.BatchNorm1d(in_size)
➥ for i in range(n_layers)])
self.activation = nn.LeakyReLU(leak_rate) ❸
def forward(self, x): activations = [] ❹
for layer, bn in zip(self.layers[:-1], self.bns[:-1]): ❺
x = self.activation(bn(layer(x)))
activations.append( x )
x = torch.cat(activations, dim=1) ❻
return self.activation(self.bns-1)) ❼
❶ 最后一层将被不同处理,所以让我们获取它的索引,以便在接下来的两行中使用。
❷ 线性和批归一化层分别存储在 layers 和 bns 中。一个列表推导式在一行中创建了所有层。“if i == l”允许我们单独选择最后一层,它需要使用 out_size 而不是 in_size。
❸ 由于我们正在编写自己的前向函数而不是使用 nn.Sequential,我们可以多次使用一个激活对象。
❹ 首先,我们需要一个位置来存储这个块中每个层(除了最后一层)的激活值。所有激活值都将组合成最后一层的输入,这就是跳过连接的原因!
❺ 将线性和归一化层压缩成成对的元组,使用 [:-1] 来选择每个列表中除了最后一个项目之外的所有项目。
❻ 将激活值连接在一起,作为最后一层的输入
❼ 我们手动使用最后一个线性层和批归一化层来处理这个连接的输入,从而得到结果。
使用 SkipFC 类,我们可以轻松地创建包含跳过连接的网络。图 6.7 展示了如何使用三个这样的对象,然后是一个线性层来定义一个网络,我们在下一块代码中这样做。请注意,我们仍然使用 nn.Sequential 对象来组织所有这些代码,现在它以前馈方式执行;非前馈跳过被 SkipFC 对象封装。这有助于使我们的代码更短、更易于阅读和组织。将非前馈部分封装到自定义 Module 中的这种方法是我组织自定义网络的首选方法:
fc_skip_model = nn.Sequential(
nn.Flatten(),
SkipFC(2, D, n),
SkipFC(2, n, n),
SkipFC(2, n, n),
nn.Linear(n, classes),
)
fc_skip_results = train_network(fc_skip_model, loss_func, train_loader,
➥ test_loader=test_loader, epochs=10,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device)
del fc_skip_model
完成这些后,我们可以查看这个新网络的结果。以下代码调用 seaborn 来绘制我们迄今为止训练的完整连接网络。结果中等,跳过连接并没有明显优于没有 BN 的网络:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_relu_results,
➥ label=’FC-ReLU’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_bn_results,
➥ label=’FC-ReLU-BN’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=fc_skip_results,
➥ label=’FC-ReLU-BN-Skip’)
[35]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

我之前解释的关于跳跃连接帮助学习和梯度流的内容仍然有效,但这并不是全部。部分问题是,在发明归一化层来帮助解决相同问题之前,跳跃连接本身更有效。由于我们的网络都包含 BN,因此跳跃连接有点多余。部分问题是我们的网络并不深,所以差异并不大。
6.3.2 实现卷积跳跃
由于跳跃连接被广泛使用,尽管我们迄今为止已经看到了,让我们重复这个练习来针对卷积网络。这将是一次快速的远足,因为代码几乎相同。我重复这个练习是因为跳跃连接作为更大解决方案组件的重要性及其广泛使用。
在以下代码中,我们用新的 SkipConv2d 类替换了我们的 SkipFC 类。forward 函数是相同的。唯一的区别是我们定义了一些辅助变量,如核大小和填充,这些在全连接层中不存在,并用 Conv2d 和 BatchNorm2d 替换了 lns 和 bns 的内容:
class SkipConv2d(nn.Module):
def __init__(self, n_layers, in_channels, out_channels,
➥ kernel_size=3, leak_rate=0.1):
super().__init__()
l = n_layers-1 ❶
f = (kernel_size, kernel_size) ❷
pad = (kernel_size-1)//2
self.layers = nn.ModuleList([ ❸
➥ nn.Conv2d(in_channels*l, out_channels,
➥ kernel_size=f, padding=pad) if i == l
➥ else nn.Conv2d(in_channels, in_channels,
➥ kernel_size=f, padding=pad)
➥ for i in range(n_layers)])
self.bns = nn.ModuleList([ ❸
➥ nn.BatchNorm2d(out_channels) if i == l
➥ else nn.BatchNorm2d(in_channels)
➥ for i in range(n_layers)])
self.activation = nn.LeakyReLU(leak_rate)
def forward(self, x): ❹
activations = []
for layer, bn in zip(self.layers[:-1], self.bns[:-1]):
x = self.activation(bn(layer(x)))
activations.append( x )
x = torch.cat(activations, dim=1) ❺
return self.activation(self.bns-1))
❶ 最后一次卷积将具有不同的输入和输出通道数,所以我们仍然需要那个索引。
❷ 简单的辅助值
❸ 定义了使用的层,使用相同的“if i == l”列表推导式改变最后层的构建。我们将通过它们的通道组合卷积,因此最后层的输入和输出通道会改变。
❹ 这段代码与 SkipFC 类相同,但值得强调的是可能改变其功能的最重要的一行……
❺... 这是对所有激活的连接。我们的张量组织为 (B, C, W, H),这是 PyTorch 的默认设置。但你可以改变它,有时人们使用 (B, W, H, C)。在这种情况下,C 通道将位于索引 3 而不是 1,所以你会使用 cat=3。这也是你可以如何将此代码适配到 RNN 中的方法。
现在我们可以定义一个使用跳跃连接的 CNN Module。但是有一个重大问题:我们的输入只有三个通道。这对网络来说太少了,无法学习到有用的东西。为了解决这个问题,我们在开始处插入一个带有 无激活函数 的单个 Conv2d 层。这在数学上是多余的,但我们的代码组织起来更容易,因为 SkipConv2d 开始构建一个更大的滤波器集:
cnn_skip_model = nn.Sequential(
nn.Conv2d(C, n_filters, (3,3), padding=1),
SkipConv2d(3, n_filters, 2*n_filters),
nn.MaxPool2d((2,2)),
nn.LeakyReLU(),
SkipConv2d(3, 2*n_filters, 4*n_filters),
nn.MaxPool2d((2,2)),
SkipConv2d(2, 4*n_filters, 4*n_filters),
nn.Flatten(),
nn.Linear(D*n_filters//4, classes),
)
cnn_skip_results = train_network(cnn_skip_model, loss_func, train_loader,
➥ test_loader=test_loader, epochs=10,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device)
del cnn_skip_model
注意,我们不能在 MaxPool2d 层之间进行跳跃。池化会改变图像的宽度和高度,如果我们尝试连接形状为 (B,C,W,H) 和 (B,C,W/2,H/2) 的两个张量,将会出错,因为轴的大小不同。当我们连接时,唯一可以有不同的轴是我们要连接的轴!所以如果我们是在 C 轴(dim=1)上连接,我们可以做 (B,C,W,H) 和 (B,C/2,W,H)。
下一段代码绘制了跳跃 CNN 和之前的结果,结果与刚才看到的故事相同。但进行这项练习很重要,以确保你对跳跃连接感到舒适,并让你准备好我们刚才看到的两个问题——需要调整跳跃连接的通道数,以及跳过池化层的困难(我们将在下一两个部分中解决第一个问题,第二个池化问题也是很好的理解):
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_relu_results,
➥ label=’CNN-ReLU’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_bn_results,
➥ label=’CNN-ReLU-BN’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_skip_results,
➥ label=’CNN-ReLU-BN-Skip’)
[38]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

再次,结果相当不确定。在实际经验中,跳跃连接有时可以单独带来很大的差异,但这非常依赖于具体问题。那么我们为什么要学习它们呢?结合另一个技巧,跳跃连接形成了一个更强大且通常更有效的技术的基本构建块之一,即所谓的残差层,它确实更有效。但我们需要逐步构建这种更复杂的方法,以了解其基础。让我们了解另一个我们可以与跳跃连接结合使用的成分,以创建我不断吹嘘的这个传说中的“残差”层。
6.4 1 × 1 卷积:在通道中共享和重塑信息
我们迄今为止使用卷积所做的一切都是为了捕捉空间信息,我们说过卷积的目的是捕捉这种空间先验,即彼此(在空间上)靠近的值是相互关联的。因此,我们的卷积有一个大小为 k 的核,这样我们就可以捕捉到⌊k/2⌋个邻居的信息。
但如果我们设k = 1 呢?这给我们关于邻居的没有任何信息,因此捕捉到没有空间信息。乍一看,这似乎使卷积成为一个无用的操作。然而,使用这种邻居盲目的卷积可能有有价值的原因。一个特定的应用是作为一种计算成本低的操作来改变给定层的通道数。这将是基于我们的 CNN 跳跃连接的第一层的一个更好的选择。我们不想有一个完全隐藏的层;我们只想让通道数 C 成为一个更方便的值。使用k = 1 通常被称为 1 × 1 卷积,这样做比我们使用的正常 3 × 3 层快≈9 倍(3²/1 = 9,忽略开销),并且需要的内存更少。
这是因为我们在执行卷积时,有C[in]个输入通道和C[out]个输出通道。因此,当k = 1 时,卷积不是看空间邻居,而是通过抓取一个C[in]值的堆栈来查看空间通道,并一次性处理它们。您可以在图 6.8 中看到这一点,该图展示了将 1 × 1 卷积应用于具有三个通道的图像。

图 6.8 1 × 1 卷积应用于图像的示例。中间的滤波器看起来像一根棍子,因为它宽度和高度都是 1。它应用于每个像素位置,而不考虑任何相邻像素。
从本质上讲,我们正在给网络提供一个新的先验:它应该尝试在通道之间共享信息,而不是查看相邻位置。另一种思考方式是,如果每个通道都学会了寻找不同类型的模式,我们正在告诉网络关注这个位置找到的模式,而不是让它尝试构建新的空间模式。
例如,假设我们正在处理一个图像,一个通道已经学会了识别水平边缘,另一个通道识别垂直边缘,另一个通道识别 45 度角的边缘,等等。如果我们想让一个通道学会识别任何边缘,我们可以通过仅查看通道值(即,是否任何这些角度相关的边缘检测滤波器被触发?)来实现,而不考虑相邻像素。如果这样的识别是有用的,k = 1 卷积可以帮助提高学习效率和降低计算成本!
6.4.1 使用 1 × 1 卷积进行训练
这样的卷积很容易实现:我们在用于批量归一化的cnnLayer函数的基础上,添加了一个名为infoShareBlock的第二个辅助函数。这个新函数接受输入滤波器的数量,并应用一个 1 × 1 卷积以保持输出大小,并希望在这个过程中进行有用的学习:
def infoShareBlock(n_filters):
return nn.Sequential(
nn.Conv2d(n_filters, n_filters, (1,1), padding=0),
nn.BatchNorm2d(n_filters),
nn.LeakyReLU())
以下代码通过在每个隐藏层块中添加一次infoShareBlock来实现新的方法。我选择在两轮正常隐藏层之后进行。infoShareBlock相当便宜,所以可以在周围随意添加。我有时在每个网络区域(例如,每个池化一次)添加一个;其他人更规律地添加它们。你可以对你的问题进行实验,看看它是否有用,并找出什么有效:
cnn_1x1_model = nn.Sequential(
cnnLayer(C, n_filters),
cnnLayer(n_filters),
infoShareBlock(n_filters), ❶
cnnLayer(n_filters),
nn.MaxPool2d((2,2)),
cnnLayer(n_filters, 2*n_filters),
cnnLayer(2*n_filters),
infoShareBlock(2*n_filters),
cnnLayer(2*n_filters), nn.MaxPool2d((2,2)),
cnnLayer(2*n_filters, 4*n_filters),
cnnLayer(4*n_filters),
infoShareBlock(4*n_filters),
nn.Flatten(),
nn.Linear(D*n_filters//4, classes),
)
cnn_1x1_results = train_network(cnn_1x1_model, loss_func, ❷
➥ train_loader, test_loader=test_loader, epochs=10,
➥ score_funcs=’Accuracy’: accuracy_score, device=device)
del cnn_1x1_model
❶ 在 2x cnnLayers 之后的第一个信息块
❷ 训练此模型
当我们绘制结果时,我们发现我们的准确率并没有提高多少。为什么?好吧,我们给出的关于信息共享的例子可以通过更大的滤波器来完成。所以这个过程并不一定允许我们学习到我们之前无法学习到的东西:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_relu_results,
➥ label=’CNN-ReLU’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_bn_results,
➥ label=’CNN-ReLU-BN’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_1x1_results,
➥ label=’CNN-ReLU-BN-1x1’)
[42]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

虽然 1 × 1 卷积运行起来更便宜,但我们不能总是使用它们并期望得到更好的结果。它们是一个更策略性的工具,需要经验和直觉的混合来学习何时值得将其添加到模型中。正如本节开头提到的,它们最常用的一个用途是作为快速简单地更改给定层中通道数的一种方式。这是它们在下一节中实现残差层所提供的关键功能。
6.5 残差连接
我们已经了解了两种方法,单独来看,它们似乎并不那么有用。有时它们表现得更好,有时则更差。但如果我们以正确的方式将跳过连接和 1 × 1 卷积结合起来,我们得到一种称为残差连接的方法,它更快地收敛到更精确的解。称为残差连接的具体设计模式非常受欢迎,当与全连接或卷积层一起工作时,您几乎总是应该将其作为指定模型架构的默认方法。残差连接背后的策略、思想和直觉在本章和本书的多次出现中反复出现,残差连接已成为广泛使用的设计组件。它分为两种类型的连接:标准块和瓶颈变体。
6.5.1 残差块
第一种连接类型是残差块,如图 6.9 所示。该块是一种跳过连接,其中两个层在末尾结合,创建长路径和短路径。然而,在残差块中,短路径没有操作。我们只是简单地保留输入不变!一旦长路径计算出其结果 h,我们就将其加到输入 x 上以得到最终结果x + h。我们通常将长路径表示为子网络F(⋅) = h,我们可以将所有残差连接描述如下:


图 6.9 残差块架构的示例。块的左侧是短路径,它不对输入进行任何操作或更改。块的右侧是残差连接,通过两轮 BN/线性激活执行跳过连接,产生中间结果 h。输出是输入 x 和 h 的总和。
当我们开始将多个残差块一个接一个地组合起来时,我们创建了一个设计非常有趣的架构。您可以在图 6.10 中看到这一点,其中我们通过网络得到一条长路径和一条短路径。短路径通过尽可能少地进行操作,使得学习深度架构变得更容易。操作越少,梯度中的噪声机会就越少,这使得传播一个有用的梯度比其他情况下更远变得容易。长路径随后执行实际工作,通过跳过连接(使用加法而不是拼接)学习复杂性的单元。

图 6.10 具有多层残差块的架构。这通过网络创建了一条长路径和一条短路径。顶部的短路径使得将梯度传播回许多层变得容易,通过尽可能避免工作,允许更深的层次。长路径执行实际工作,让网络一次学习一个复杂函数。
这种类型的残差块可以很容易地转换为全连接的对应版本。但是,当我们处理图像时,我们喜欢进行几轮最大池化以帮助建立一些平移不变性,并在每轮池化后加倍通道数,以在每个网络层中进行一致的计算和工作。但是,残差块 需要 输入和输出具有 完全相同的形状,因为我们使用的是加法而不是拼接。这就是瓶颈层发挥作用的地方,正如我们很快就会看到的。
6.5.2 实现残差块
我们所描述的残差块被称为 类型 E,并且是受欢迎的残差配置之一。正如你可能从名称中猜到的,人们在标准化、卷积以及每个残差块中的层数上尝试了大量的不同重排。它们都倾向于工作得很好,但为了简单起见,我们将坚持使用类型 E。注意我们如何可以使用 nn.Sequential 来组织我们的代码,使其与我们阅读数学的方式相同,这有助于我们保持定义简单且易于检查正确性:
class ResidualBlockE(nn.Module):
def __init__(self, channels, kernel_size=3, leak_rate=0.1):
"""
channels: how many channels are in the input/output to this layer
kernel_size: how large of a filter should we use
leak_rate: parameter for the LeakyReLU activation function
"""
super().__init__()
pad = (kernel_size-1)//2 ❶
self.F = nn.Sequential( ❷
nn.Conv2d(channels, channels, kernel_size, padding=pad),
nn.BatchNorm2d(channels),
nn.LeakyReLU(leak_rate),
nn.Conv2d(channels, channels, kernel_size, padding=pad),
nn.BatchNorm2d(channels),
nn.LeakyReLU(leak_rate),
)
def forward(self, x):
return x + self.F(x) ❸
❶ 我们的卷积层需要多少填充来保持输入形状?
❷ 在子网络中定义我们使用的卷积和 BN 层:仅两个隐藏层的卷积/BN/激活
❸ F() 包含了长路径的所有工作:我们只需将其添加到输入中。
6.5.3 剩余瓶颈
残差层是跳过连接思想的简单扩展,通过使短路径尽可能少地工作来帮助梯度流动并最小化噪声。但是,我们在进行池化后需要一种处理不同通道数的方法。解决方案是 1 × 1 卷积。我们可以使用 1 × 1 层来完成最小的工作量,仅改变输入中的通道数,根据需要增加或减少通道数。首选的方法是创建一个 残差瓶颈,如图 6.11 所示。短路径仍然很短,没有激活函数,只是执行一个 1 × 1 卷积然后是 BN,将原始通道数 C 转换为所需的通道数 C′。

图 6.11 瓶颈连接的示例,其中输入的形状为 (B,C,W,H),目标是输出形状为 (B,C′,W,H)。短路径需要改变形状,因此使用 1 × 1 卷积来完成所需的最小工作量以改变通道数。长路径应该是一个鼓励压缩的瓶颈,因此它从 1 × 1 卷积开始以减少通道数,然后是正常卷积,最后以另一个 1 × 1 卷积结束,将通道数扩展回所需的尺寸 C′。
这种方法被称为瓶颈,是因为右侧的长路径F(⋅)有三个隐藏层。第一个隐藏层使用另一个 1 × 1 卷积来减少通道数 C,然后再进行中间的正常隐藏层,最后通过一个最终的 1 × 1 卷积将通道数恢复到原始数量。瓶颈背后有两个原因和解释。
第一个原因是残差网络原始作者的的设计选择。他们希望通过使网络更深来增加其容量。使瓶颈收缩再扩张可以减少参数数量,为添加更多层节省宝贵的 GPU 内存!作者们用这个方法训练了一个总共有 152 层的网络。在当时,这是一个极其深的网络,并在原始的 ImageNet 数据集(被研究人员广泛用作基准)上取得了新的记录。
第二个原因是借鉴了压缩的概念:使事物变得更小。机器学习的一个研究领域将压缩作为一种工具,使模型学习有趣的东西。想法是,如果你迫使模型从一个大量参数的状态转换到一个小参数状态,你迫使模型创建更多有意义的紧凑表示。作为一个粗略的类比,想想你如何只用三个字母“猫”来传达一个具有特定形状、饮食习惯等的特定物种动物。如果你说“the orange house cat”,你就可以用很少的信息快速缩小你正在谈论的图像。这就是压缩背后的想法:能够压缩意味着某种程度的智能。
6.5.4 实现残差瓶颈
让我们将残差瓶颈的想法转换为代码。它看起来非常类似于ResidualBlockE。主要区别在于我们有两个子网络:第一个是长路径,编码在self.F中;短路径有一个成员变量self.shortcut。我们用一个小技巧来实现它:如果瓶颈不改变通道数,我们使用Identity函数来实现。这个函数简单地返回输入作为输出。如果通道数确实改变,我们用一个小nn.Sequential覆盖定义,执行 1 × 1 卷积后跟 BN:
class ResidualBottleNeck(nn.Module):
def __init__(self, in_channels, out_channels,
➥ kernel_size=3, leak_rate=0.1):
super().__init__()
pad = (kernel_size-1)//2 ❶
bottleneck = max(out_channels//4, in_channels) ❷
self.F = nn.Sequential(
nn.BatchNorm2d(in_channels), ❹
nn.LeakyReLU(leak_rate),
nn.Conv2d(in_channels, bottleneck, 1, padding=0),
nn.BatchNorm2d(bottleneck), ❺
nn.LeakyReLU(leak_rate),
nn.Conv2d(bottleneck, bottleneck, kernel_size, padding=pad),
nn.BatchNorm2d(bottleneck), ❸❻
nn.LeakyReLU(leak_rate),
nn.Conv2d(bottleneck, out_channels, 1, padding=0)
)
self.shortcut = nn.Identity() ❼
if in_channels != out_channels: ❽
self.shortcut = nn.Sequential(
nn.Conv2d(in_channels, out_channels, 1, padding=0),
nn.BatchNorm2d(out_channels)
)
def forward(self, x):
return self.shortcut(x) + self.F(x) ❾
❶ 我们的反卷积层需要多少填充才能保持输入形状?
❷ 瓶颈应该更小,所以输出/4 或输入。你也可以尝试将最大值改为最小值;这不是一个大问题。
❸ 定义了我们需要的三组 BN 和卷积层。注意,对于 1 个卷积,我们使用 padding=0,因为 1 不会改变形状!
❹ 压缩
❺ 正常层进行完整卷积
❻ 扩展回原状
❼ 默认情况下,我们的快捷方式是恒等函数,它简单地返回输入作为输出。
❽ 如果我们需要改变形状,让我们将快捷方式转换为一个包含 1 个卷积和 BM 的小层。
❾ “shortcut(x)”在功能上等同于“x”;我们尽可能少做工作以保持张量形状不变。
现在我们可以定义一个残差网络了!要使其与我们的原始网络完全相同是很困难的,因为每个残差层还包括两到三个轮次的层,但以下定义使我们接近这个目标。由于我们的网络规模较小,我们在残差块轮次之后添加了一个LeakyReLU。你不必包含这个,因为通过残差层的长路径中包含激活函数。对于非常深的网络(30+块),我建议不要在块之间包含激活函数,以帮助信息通过所有这些层。但这不是一个关键细节,两种方法都能很好地工作。你总是可以尝试一下,看看哪种方法最适合你的问题和网络规模。同时注意,将我们自己的块定义为Module允许我们在相对较少的代码行中指定这个非常复杂的网络:
cnn_res_model = nn.Sequential(
ResidualBottleNeck(C, n_filters), ❶
nn.LeakyReLU(leak_rate), ❷
ResidualBlockE(n_filters),
nn.LeakyReLU(leak_rate),
nn.MaxPool2d((2,2)),
ResidualBottleNeck(n_filters, 2*n_filters),
nn.LeakyReLU(leak_rate),
ResidualBlockE(2*n_filters),
nn.LeakyReLU(leak_rate),
nn.MaxPool2d((2,2)),
ResidualBottleNeck(2*n_filters, 4*n_filters),
nn.LeakyReLU(leak_rate), ResidualBlockE(4*n_filters),
nn.LeakyReLU(leak_rate),
nn.Flatten(),
nn.Linear(D*n_filters//4, classes),
)
cnn_res_results = train_network(cnn_res_model, loss_func, train_loader,
➥ test_loader=test_loader, epochs=10,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device)
❶ 以 BottleNeck 开始,因为我们需要更多的通道。在开始残差块之前,先开始一个正常的隐藏层也是很常见的。
❷ 在每个残差后插入一个激活函数。这是可选的。
现在,如果我们绘制结果,我们应该最终看到一致性的改进!虽然对于这个数据集,差异不是很大,但对于更大和更具挑战性的问题,差异将会更加显著。Fashion-MNIST 几乎没有改进的空间。我们已经达到了超过 98%的准确率,这比我们开始的 93%有显著提高:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_results,
➥ label=’CNN’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_relu_results,
➥ label=’CNN-ReLU’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_bn_results,
➥ label=’CNN-ReLU-BN’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=cnn_res_results,
➥ label=’CNN-ReLU-BN-Res’)
[47]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

说到这里,很难过分强调残差连接对现代深度学习产生的影响有多大,你应该几乎总是默认实现残差风格的网络。你经常会看到人们提到ResNet-X,其中X代表特定残差网络架构中的总层数。这是因为成千上万的研究人员和从业者喜欢以 ResNet 作为他们的基准,并添加或删除一些部分来定制它以适应他们的问题。在许多情况下,除了输出层外没有其他变化地使用 ResNet 在图像分类问题上可以得到一个强大的结果。因此,当解决现实问题时,它应该成为你的首选工具之一。
6.6 长短期记忆 RNNs
我们在第四章中描述的 RNN 在实践中的应用很少。它在训练和解决复杂问题上是臭名昭著的困难。已经发表了多种 RNN 的不同变体,但始终有效且表现良好的选项被称为长短期记忆(LSTM)网络。LSTMs 是一种在 1997 年最初开发的 RNN 架构。尽管它们已经存在了一段时间(以及 2006 年的一次小调整),但 LSTMs 仍然是可用的循环架构中最好的选择之一。
6.6.1 RNNs:快速回顾
让我们快速回顾一下简单的 RNN 在图 6.12 和方程 6.1 中的样子。我们可以用它的输入 x[t] 和前一个隐藏状态 h[t] 连接成一个更大的输入来简洁地描述它。它们通过一个 nn.Linear 层进行处理,并经过 tanh 非线性变换,这成为输出:


图 6.12 展示了我们在第四章中学到的简单 RNN。随时间变化的信息被捕获在隐藏激活 h[t] 中,这些激活被输入到下一个 RNN 层,并用作任何后续层的输出。小黑点表示连接。
学习 RNN 的一个挑战是,当通过时间展开时,许多操作都在同一个张量上执行。RNN 很难学习如何使用这个有限的空间从变量时间步长的固定大小表示中提取所需的信息,并添加信息到该表示中。这个问题与残差层解决的问题类似:当操作较少时,梯度很容易发送信息回传。如果我们有一个包含 50 个步骤的序列,这就如同尝试学习一个有 50 层且梯度噪声有 50 次机会干扰的网络。
6.6.2 LSTMs 和门控机制
获取梯度在多个时间步长中传递信号的难度是 LSTM 试图解决的主要问题之一。为了做到这一点,LSTM 创建了两组状态:一个隐藏状态 h[t] 和一个上下文状态 C[t]。h[t] 执行工作并尝试学习复杂函数,而上下文 C[t] 则试图简单地保存有用的信息以供以后使用。你可以将 C[t] 视为专注于 长期 信息,而 h[t] 则专注于 短期 信息;因此,我们得到了 长短期记忆 这个名字。
LSTM 使用一种称为 门控 的策略来实现这一点。门控机制产生一个在[0,1]范围内的值。这样,如果你乘以门控的结果,你可以移除所有内容(门控返回 0,任何时间 0 的值都是 0)或者允许所有内容通过(门控返回 1)。LSTM 被设计为具有三个门:
-
遗忘门 允许我们忘记上下文 C[t] 中的内容。
-
输入门 控制我们想要添加或输入到 C[t] 中的内容。
-
输出门 决定了我们希望在最终输出 h[t] 中包含多少上下文 C[t]。
图 6.13 展示了这一过程在高级别上的样子。

图 6.13 LSTM 的策略涉及三个依次操作的门,允许短期 h[t] 和长期 C[t] 之间的交互。遗忘门是红色,输入门是蓝色,输出门是绿色。
这种门控是通过使用 sigmoid σ(⋅) 和 tanh (⋅) 激活函数来实现的。实现门控机制是一种你希望饱和的情况,因为你需要你的输出值在一个非常特定的范围内,以便你可以创建一个先验,一些输入需要被允许,而一些则需要被阻止。sigmoid 激活函数 σ(⋅) 产生一个在[0,1]范围内的值。如果我们取另一个值α并将其与 sigmoid 的结果相乘,我们得到 z = α ⋅ σ(⋅)。如果 σ(⋅) = 0,那么我们实际上已经关闭了包含任何关于α信息的 z 门。如果 σ(⋅) = 1,那么我们得到 z = α,并且实际上让所有信息都通过门。对于中间的值,我们最终调节了从α流经网络的信息/内容量。我们可以在图 6.14 中看到 LSTM 如何使用这种门控方法。

图 6.14 展示了 LSTM 的详细操作以及每个门是如何实现和连接的。×表示两个值相乘,+表示值相加。相同的颜色编码适用(红色代表遗忘门,蓝色代表输入门,绿色代表输出门)。
上下文向量位于上半部分,短期隐藏状态位于下半部分。这种设置有一些与我们所学的残差网络相似的性质。上下文 C[t] 类似于一条短路径:对其执行的操作很少(且简单),这使得梯度能够更容易地流回非常长的序列。LSTM 的下半部分执行繁重的任务(如残差路径),使用线性层和非线性激活函数,并试图学习更复杂的函数,这些函数是必要的,但同时也使得梯度传播到开始部分变得更加困难。
这种门控和存在短路径与长路径的想法是 LSTM 工作良好并且比我们所学到的简单 RNN 更好的主要秘密。就像所有 RNN 一样,LSTM 也使用时间上的权重共享。因此,我们已经研究了时间上单个输入的 LSTM 单元,并且相同的权重在时间上的每个项目上都被重复使用。
Chris Olah 对 LSTM 的非常详尽描述和解释可在 colah.github.io/posts/2015-08-Understanding-LSTMs 找到;他还描述了关于 窥视孔连接 的更深层细节,这是现代 LSTM 中的一个标准改进。窥视孔背后的想法是将旧上下文 C[t − 1] 连接到决定遗忘命运和输入门的 nn.Linear 层,其想法是在决定忘记之前,你真的应该知道你即将忘记什么。同样,将其添加到输入门可以帮助你避免添加你已经拥有的冗余信息。遵循这一逻辑,另一个窥视孔连接将 C[t] 添加到最后一个 nn.Linear 层,该层控制输出,其想法是在决定是否输出之前,你应该知道你正在查看什么。
LSTM 有许多变体,包括另一个流行的 RNN,称为 门控循环单元 (GRU)。GRU 与 LSTM 有相同的灵感,但试图让隐藏状态 h[t] 执行双重任务,作为短期和长期记忆。这使得 GRU 更快,使用更少的内存,并且更容易编写代码,这就是我们使用它的原因。缺点是 GRU 不总是像 LSTM 那样准确。相比之下,带有窥视孔连接的 LSTM 是一个经过验证且难以超越的方法,这就是人们通常所说的“LSTM”。
6.6.3 训练 LSTM
既然我们已经讨论了 LSTM 是什么,那么让我们来实现一个并尝试它。我们将重用第四章中的数据集和问题,其中我们试图预测一个名字可能来自的原语言。我们首先要做的是使用相同的代码和我们的 LanguageNameDataset 类再次设置这个问题。简要来说,以下是创建数据加载器对象的块回顾:
dataset = LanguageNameDataset(namge_language_data, alphabet) ❶
train_lang_data, test_lang_data = torch.utils.data.random_split(dataset,
➥ (len(dataset)-300, 300))
train_lang_loader = DataLoader(train_lang_data, batch_size=32,
➥ shuffle=True, collate_fn=pad_and_pack)
test_lang_loader = DataLoader(test_lang_data, batch_size=32,
➥ shuffle=False, collate_fn=pad_and_pack)
❶ 重新使用第四章中的代码
让我们设置一个新的 RNN 作为我们的基线。我使用的是一个三层结构,但我没有使其双向。虽然双向层有助于解决跨时间获取信息的问题,但我希望使这个问题变得更糟,这样我们就能更好地看到 LSTM 的好处:
rnn_3layer = nn.Sequential( ❶
EmbeddingPackable(
➥ nn.Embedding(len(all_letters), 64)), ❷
nn.RNN(64, n, num_layers=3, batch_first=True), ❸
LastTimeStep(rnn_layers=3), ❹
nn.Linear(n, len(namge_language_data)), ❺
)
for p in rnn_3layer.parameters(): ❻
p.register_hook(lambda grad: torch.clamp(grad, -5, 5))
rnn_results = train_network(rnn_3layer, loss_func, train_lang_loader,
➥ test_loader=test_lang_loader,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device, epochs=10)
❶ 简单的老式循环神经网络 (RNN)
❷ (B, T) -> (B, T, D)
❸ (B, T, D) -> ( (B,T,D) , (S, B, D) )
❹ 我们需要将 RNN 输出缩减为一个项目,(B, D)。
❺ (B, D) -> (B, classes)
❻ 应用梯度裁剪以最大化其性能
接下来我们实现一个 LSTM 层。由于 LSTM 直接集成在 PyTorch 中,因此很容易将其结合。我们只需将每个 nn.RNN 层替换为 nn.LSTM!我们还在重用第四章中的 LastTimeStep 层:如果你回顾一下代码,你现在知道为什么我们有了这个注释:“结果是 (out, h_t) 或 (out, (h_t, c_t)) 的元组。”这是因为当我们使用 LSTM 时,我们得到隐藏状态 h_t 和上下文状态 c_t!第四章中的这个添加使我们的代码对现在使用的 LSTM 有了更多的未来保障。网络的 LSTM 版本如下,我们只改变了一行来使其发生:
lstm_3layer = nn.Sequential(
EmbeddingPackable(nn.Embedding(len(all_letters), 64)), ❶
nn.LSTM(64, n, num_layers=3, ❷
➥ batch_first=True),
LastTimeStep(rnn_layers=3), ❸
nn.Linear(n, len(namge_language_data)), ❹
)
for p in lstm_3layer.parameters(): ❺
p.register_hook(lambda grad: torch.clamp(grad, -5, 5))
lstm_results = train_network(lstm_3layer, loss_func, train_lang_loader,
➥ test_loader=test_lang_loader,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device, epochs=10)
❶ (B, T) -> (B, T, D)
❷ nn.RNN 变成了 nn.LSTM,现在我们升级到了 LSTMs w/(B, T, D) -> ( (B,T,D) , (S, B, D) )。
❸ 我们需要将 RNN 输出减少到一个项目,即 (B, D)。
❹ (B, D) -> (B, classes)
❺ 我们仍然希望对每种 RNN,包括 LSTM,使用梯度裁剪。
如果我们绘制结果,我们可以看到 LSTM 有助于改进它们相对于 RNN。我们还有一些证据表明,在约四个周期后,LSTM 开始出现过度拟合。如果我们真的在尝试训练,我们可能想要减少神经元的数量以防止过度拟合,或者使用验证步骤帮助我们学习在四个周期后停止:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=rnn_results,
➥ label=’RNN: 3-Layer’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=lstm_results,
➥ label=’LSTM: 3-Layer’)
[53]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

LSTM 是少数经过验证且经久不衰的深度学习方法之一,它在过去几十年中几乎没有变化。如果你需要一个 RNN,LSTM 是一个很好的默认选择。我们提到的门控循环单元(GRU)也是一个不错的选择,尤其是当你需要更少的计算资源时。我们不会深入探讨 GRU 的细节,但它是对 LSTM 设计的有意简化:它更简单,因为它不需要上下文状态 C[t],而是试图让隐藏状态 h[t] 执行双重职责,即作为隐藏状态和上下文状态。更详细地解释这一点可能有些复杂,但如果你想要了解更多,我喜欢这个例子 blog.floydhub.com/gru-with-pytorch。如果你有更短、更简单的数据,或者如果计算限制是一个问题,GRU 值得检查,因为它需要的内存更少。我喜欢使用 GRU,因为它的代码稍微简单一些,因为只有一个隐藏状态,但确实有一些应用中 LSTM 的表现更好。你可以使用 GRU 或 LSTM 并获得良好的结果,但最好的结果可能来自调整(例如,使用 Optuna)一个 LSTM。
练习
在 Inside Deep Learning 练习的 Manning 在线平台上分享和讨论你的解决方案 (liveproject.manning.com/project/945)。一旦你提交了自己的答案,你将能够看到其他读者提交的解决方案,并看到作者认为哪些是最好的。
-
尝试在我们的各种 CNN 中将 LeakyReLU 激活函数替换为
nn.PReLU激活函数。它的表现是更好还是更差? -
编写一个
for循环来训练具有 1 到 20 层隐藏层的 CNN 模型,一次使用 BN 层,一次不使用。BN 如何影响学习更深层次模型的能力? -
MATH: 我们使用代数证明了线性层后跟 BN 与一个线性层等价。尝试用同样的数学方法来证明 BN 后跟线性层也等价于另一个不同的线性层。
-
将
ResidualBlockE重新实现为全连接层而不是卷积层。使用残差连接的全连接模型是否仍然能提高性能? -
编写一个
for循环来训练一个使用更多组合的ResidualBlockE层的残差模型。残差块是否允许你训练更深层次的模型? -
尝试创建一个双向 LSTM 层。你得到更好的还是更差的结果?
-
尝试使用不同层数和神经元的各种 LSTM 和 GRU 网络进行训练,并比较它们达到期望精度水平所需的时间。你看到任何相对的优缺点吗?
摘要
-
一组称为 ReLU 线性单元的激活函数需要更少的 epoch 就能达到更好的准确性。
-
在每个线性层和激活函数之间插入一个归一化层提供了另一个提升。
-
批标准化对于大多数问题都工作得很好,但在权重共享(如循环架构)和小批量大小的情况下。
-
层归一化并不总是那么准确,但几乎总是任何网络设计中的一个安全添加。
-
跳跃连接提供了一种不同的策略来组合层,可以在网络中创建长路径和短路径。
-
1 × 1 卷积可以用来调整卷积层的通道数。
-
一种称为残差层的模式设计允许我们构建更深层次的网络,通过混合跳跃连接和 1 × 1 卷积来提高准确性。
-
tanh (⋅) 和 σ(⋅) 激活函数对于创建门控机制很有用,这些机制编码了一种新的先验。
-
门控是一种策略,强制神经元的激活介于 0 和 1 之间,然后将另一个层的输出乘以这个值。
-
通过使用称为长短期记忆(LSTM)的方法可以改进 RNN,这是一种谨慎的门控应用。
¹ 通过阅读 20 世纪 90 年代的神经网络论文,你可以学到很多东西。即使它们今天不再使用,过去解决方案的创造性也可能为你解决现代问题提供良好的教训和直觉。在我撰写这本书的时候,我正在研究一些主要被遗忘的 1995 年的工作所启发的科研。↩
² V. Nair 和 G. E. Hinton,“Rectified linear units improve restricted Boltzmann machines”,第 27 届国际机器学习会议论文集,第 807-814 页,2010。↩
³ A. L. Maas, A. Y. Hannun, 和 A. Y. Ng,"Rectifier nonlinearities improve neural network acoustic models",《第 30 届国际机器学习会议论文集》,第 28 卷,第 6 页,2013。↩
⁴ 让人烦恼的是,σ 用来表示标准差和 sigmoid 激活函数。你将不得不使用上下文来确保你能区分它们。↩
⁵ 是的,这些名字很令人困惑。我也不喜欢它们。↩
⁶ 我更喜欢将 BN 放在线性层之前,因为我认为它与我们已阐述的直觉更吻合。在我的经验中,也有成千上万的激活的特定案例,在这些案例中,线性层之前的 BN 可以表现得更好。但这些只是些小细节。我更愿意展示常见的内容,而不是对那些晦涩的细节发表意见。↩
⁷ 定义“复杂度”或“容量”的含义是可能的,但它很快就会变得非常技术性。这也像是一个对这个书来说太深奥的兔子洞。↩
⁸ 最突出的例子是一个名为 DenseNet 的网络 (github.com/liuzhuang13/DenseNet)↩
第二部分. 构建高级网络
对于像我这样的业余木匠来说,锤子不过是一个将钉子敲入木头、砸开坚硬的西瓜、甚至在我干墙上添加有意且美观的后现代主义孔洞的钝器。而对于专业木匠来说,它是一个多功能的工具,与其他一些工具一起使用,可以用来制作美丽的家具和艺术品,甚至创造新的工具。在本书的第一部分,你学习了工具;现在我们将把你培养成一个工匠。第二部分更少关注新的深度学习方法,更多地关注如何结合你已经知道的方法来构建解决不同类型问题的美丽新架构,以及你可以使用的新工具来提高工作效率并扩展你的能力。
前几章的每一章都专注于一种新的任务。第七章向你展示了如何使用神经网络进行无监督学习。第八章让你能够对每张图像进行多次预测,以便你可以检测单个对象及其位置。第九章将无监督学习推进了一步,到生成模型,其中模型可以创建或修改图像。第十章和第十一章协同工作,教你如何预测整个序列,以便你可以构建一个将英语翻译成法语模型的模型。
最后三章遵循着回顾和改进的主题。第十二章提供了具有不同权衡的 RNN 替代方案,逐步发展到 Transformer 架构,该架构已迅速成为现代自然语言处理的关键。第十三章探讨了迁移学习的技巧,在更短的时间内提供更高的准确性来解决现实世界的问题。最后,第十四章涵盖了三个训练深度神经网络的前沿改进,这些改进不仅有用,还展示了你走了多远:从零开始,理解该领域的一些最新进展和研究。
7 自动编码和自监督
本章涵盖
-
无标签训练
-
自动编码用于投影数据
-
使用瓶颈约束网络
-
添加噪声以提升性能
-
预测生成模型中的下一个项目
你现在已经了解了指定神经网络用于分类和回归问题的几种方法。这些是经典的机器学习问题,对于每个数据点 x(例如,水果的图片),我们都有一个相关的答案 y(例如,新鲜或腐烂)。但如果我们没有标签 y 呢?我们有没有任何有用的学习方法?你应该认识到这是一个无监督学习场景。
人们之所以对自监督感兴趣,是因为标签很昂贵。通常更容易获取大量数据,但知道每个数据点是什么需要大量工作。想想一个情感分类问题,你试图预测一个句子是否传达了积极的观点(例如,“我爱我正在读的这本书。”)或消极的观点(例如,“这本书的作者不擅长讲笑话。”)。阅读句子、做出判断并保存信息并不难。但如果你想要构建一个好的情感分类器,你可能需要标记数十万到数百万个句子。你真的愿意花几天或几周时间标记这么多句子吗?如果我们能够以某种方式学习而不需要这些标签,那将使我们的生活变得更加容易。
深度学习中越来越常见的无监督学习策略之一被称为自监督。自监督背后的想法是,我们使用回归或分类损失函数ℓ来进行学习,并预测关于输入数据 x 本身的一些信息。在这些情况下,标签隐含地与数据一起存在,并允许我们使用我们已经学会使用的相同工具。想出巧妙的方法来获取隐含标签是自监督的关键。
图 7.1 展示了自监督可以采取的许多方法中的三种:修复,即隐藏输入的一部分,然后尝试预测被隐藏的内容;图像排序,即将图像分解成多个部分,打乱它们的顺序,然后尝试将它们放回正确的顺序;以及自动编码,即给出输入图像并预测输入。使用自监督,我们可以训练没有标签的模型,然后使用模型学到的知识进行数据聚类,执行诸如识别噪声/不良数据等任务,或者用更少的数据构建有用的模型(后者将在第十三章中演示)。

图 7.1 三种不同类型的自监督问题:图像修复、图像排序和自动编码。在每种情况下,我们不需要知道图像的内容是什么,因为网络将尝试预测原始图像,无论其内容如何。在第一种情况(红色)中,我们随机遮挡图像的一部分,并要求网络填充缺失的部分。在第二种情况中,我们将图像分割成块,打乱它们的顺序,并要求网络将它们放回正确的顺序。在最后一种情况中,我们只是要求网络从原始图像中预测原始图像。
有许多方法可以创建自监督问题,研究人员一直在提出新的方法。在本章中,我们专注于一种特定的自监督方法,称为自动编码,这是图 7.1 中的第三个例子,因为自动编码的关键是从输入预测输入。一开始这可能看起来像是一个疯狂的想法。当然,对于网络来说,学习返回与给定相同的输入是一个简单的问题。这就像定义一个函数一样
def superUsefulFunction(x):
return x
这个superUsefulFunction将实现一个完美的自动编码器。所以如果问题这么简单,它有什么用呢?这是我们本章要学习的内容。诀窍是约束网络,给它一个障碍,使其无法学习到平凡解。想象一下学校里的考试,老师给你一个开卷考试,有 100 个问题。如果你有足够的时间,你只需简单地阅读书籍,找到答案,并写下它们。但如果你被约束在仅一个小时的时间内完成考试,你就没有时间去书中查找所有内容。相反,你被迫学习基本概念,这些概念帮助你重建所有问题的答案。约束有助于促进学习和理解。同样的想法也适用于自动编码器:当平凡解被排除在外时,网络被迫学习一些更有用的东西(基本概念)来解决该问题。
本章进一步解释了自动编码的概念,并展示了面包和黄油般的主成分分析(PCA)实际上是通过秘密地作为一个自动编码器来工作的。我们将对 PCA 的 PyTorch 版本进行一些小的修改,将其变成一个完整的自动编码神经网络。当我们使自动编码网络更大时,更好地约束它变得更加重要,我们通过降噪策略来展示这一点。最后,我们将这些概念应用于序列模型,如 RNN,这给出了自回归模型。
7.1 自动编码是如何工作的
让我们先更详细地描述一下自动编码的概念。自动编码意味着我们通常学习两个函数/网络:首先是一个函数 f^(in)(x) = z,它将输入 x 转换成新的表示z ∈ ℝ^(D′),然后是一个函数 f^(out)(z) = x,它将新的表示 z 转换回原始表示。我们称这两个函数为编码器 f^(in)和解码器 f^(out),整个过程总结在图 7.2 中。在没有标签的情况下,新的表示 z 以这种方式学习,以便以紧凑的形式捕获有关数据结构的有用信息,这对机器学习算法来说是友好的。

图 7.2 自动编码的过程。输入通过编码器,生成数据 z 的新表示。表示 z 可以用于许多任务,如聚类、搜索,甚至分类。解码器试图从编码 z 中重建原始输入 x。
这假设存在一个要找到的表示 z,它以某种方式捕捉了关于数据 x 的信息,但未指定。因为我们从未观察过它,所以我们不知道 z 应该是什么样子。因此,我们称其为数据的潜在表示,因为它对我们来说是不可见的,但它从训练模型中产生。¹
这乍一看可能显得非常愚蠢。我们难道不能学习一个什么也不做的函数 f^(in),它将输入作为输出返回吗?这可以轻易地解决问题,但就像从银行贷款然后立即还清一样。你技术上满足了所有明确的目标,但这样做并没有完成任何事情。这种愚蠢是我们要避免的危险捷径。诀窍是设置问题,让网络不能这样作弊。有几种不同的方法可以做到这一点,我们将在本章中了解其中的两种:瓶颈和去噪。这两种方法都通过约束网络,使其不可能学习到原始解决方案。
7.1.1 原始成分分析是一个瓶颈自动编码器
本章的这一部分内容在理解上可能稍微有些挑战,但这是值得的。你将获得对经典算法的新视角,这将帮助你进一步理解那些通常不被视为深度学习的现有工具实际上是如何深度相关的。
为了说明如何约束一个网络,使其不能学习简单的 superUsefulFunction 方法,我们首先讨论一个你可能知道但可能不知道实际上是自动编码器的著名算法:称为 主成分分析 (PCA) 的特征工程和降维技术。PCA 用于将 D 维空间中的特征向量转换到较低维度 D′,这可以称为 编码器。PCA 还包括一个很少使用的 解码器 步骤,其中你可以(近似地)转换回原始的 D 维空间。以下注释的方程式定义了 PCA 解决的优化问题:

PCA 是一个重要且广泛使用的算法,因此如果您想使用它,您应该使用现有的实现之一。但 PyTorch 足够灵活,让我们可以自己实现 PCA。如果您仔细观察方程式,PCA 是一个 回归 问题。如何?让我们看看方程式的主要部分,并尝试用深度学习风格的方程式再次注释它:

我们有一个权重矩阵 W 作为编码器,其转置 W^⊤ 作为解码器。这意味着 PCA 正在使用 权重共享(记得第三章和第四章中的这个概念?)。原始输入在左侧,我们使用了 2-范数(∥ ⋅ ∥[2]²),这是均方误差损失中使用的。方程式中的“约束条件”部分是一个要求权重矩阵以特定方式行为的 约束。让我们以我们为神经网络所做的方式重写这个方程式:

∥WW^⊤ − I∥[2]² 是基于“约束条件”的正则化惩罚。我个人认为,使用损失函数将 PCA 重新表达为自动编码器有助于更好地理解它。这也更明确地表明,我们正在使用 f(⋅) 作为包含编码器 f^(in)(⋅) 和解码器 f^(out)(⋅) 序列的单个网络。
现在我们已经将我们的 PCA 写成所有数据点的损失函数。我们知道 PCA 是有效的,如果 PCA 是一个自动编码器,那么自动编码的思路可能不像最初看起来那么疯狂。那么 PCA 是如何让它工作的呢?PCA 提供的洞察是,我们使 中间表示过于小了。记住 PCA 首先做的事情是从 D 维下降到 D′ < D。想象一下,如果 D = 1,000,000 且 D′ = 2,那么在只有 2 个特征的情况下保存足够关于一百万个特征的信息以完美重建输入是不可能的。所以 PCA 能做的最好的事情就是学习可能的最佳 2 个特征,这迫使 PCA 学习一些有用的东西。这是使自动编码工作起来的主要技巧:将你的数据推入比开始时更小的表示。
7.1.2 实现 PCA
现在我们已经了解了 PCA 是一个自动编码器,让我们着手将其转换为 PyTorch 代码。我们需要定义我们的网络函数 f(x),由以下方程给出。但我们如何实现最右边的 W^⊤ 部分?

实现这个功能的主要技巧是重用 PyTorch 中 nn.Linear 层的权重。我们在这里通过实现 PyTorch 中的 PCA 来强调 PCA 是一个自动编码器 的事实。一旦我们实现了 PCA,我们将对其进行一些修改,将其转换为深度自动编码器,类似于我们在第二章中从线性回归过渡到神经网络的方式。首先,让我们快速定义一些常数,包括我们正在处理的特征数量、隐藏层的大小以及其他标准项:
D = 28*28 ❶
n = 2 ❷
C = 1 ❸
classes = 10 ❹
❶ 输入中有多少个值?28 * 28 张图像。我们使用这个信息来帮助确定后续层的尺寸。
❷ 隐藏层大小
❸ 输入中有多少个通道?
❹ 有多少个类别?
接下来,让我们实现这个缺失的层来表示 W^⊤。我们称这个新层为 转置层。为什么?因为我们使用的数学操作被称为 转置。我们还添加了一些逻辑,以便为权重转置层提供一个自定义的偏置项,因为输入层有一个形状为 W ∈ ℝ^(D × D′) 的矩阵和一个偏置向量 b ∈ ℝ^(D′)。这意味着 W^⊤ ∈ ℝ^(D′ × D),但我们实际上无法对 b 进行有意义的转置。因此,如果有人想要偏置项,它必须是一个新的独立项。
我们接下来介绍新的 TransposeLinear 模块。这个类实现了 Transpose 操作 W^⊤。需要将转置矩阵 W 作为构造函数中的 linearLayer 传入。这样,我们就可以在原始 nn.Linear 层和这个转置版本之间共享权重。
class TransposeLinear(nn.Module): ❶
def __init__(self, linearLayer, bias=True):
"""
linearLayer: is the layer that we want to use the transpose of to
➥ produce the output of this layer. So the Linear layer represents
➥ W, and this layer represents W^T. This is accomplished via
➥ weight sharing by reusing the weights of linearLayer
bias: if True, we will create a new bias term b that is learned
separately from what is in
linearLayer. If false, we will not use any bias vector.
"""
super().__init__()
self.weight = linearLayer.weight ❷
if bias:
self.bias = nn.Parameter(torch.Tensor(
➥ linearLayer.weight.shape[1])) ❸
else:
self.register_parameter(’bias’, None) ❹
def forward(self, x): ❺
return F.linear(x, self.weight.t(), self.bias) ❻
❶ 我们这个类扩展了 nn.Module。所有 PyTorch 层都必须扩展这个。
❷ 创建一个新的变量 weight 来存储对原始权重项的引用
❸ 创建一个新的偏置向量。默认情况下,PyTorch 知道如何更新模块和参数。由于张量既不是模块也不是参数,参数类封装了张量类,这样 PyTorch 就知道这个张量中的值需要通过梯度下降进行更新。
❹ 参数类不能接受 None 作为输入。因此,如果我们想让偏置项存在但可能不被使用,我们可以使用 register_parameter 函数来创建它。这里重要的是,PyTorch 不论模块的参数如何,总是看到相同的参数。
❺ 前向函数是接收输入并产生输出的代码。
❻ PyTorch 的 F 目录包含许多模块使用的函数。例如,线性函数在给定输入(我们使用权重的转置)和偏置(如果为 None,它知道不执行任何操作)时执行线性变换。
现在我们已经完成了TransposeLinear层的实现,我们可以实现 PCA。首先,是架构,我们将它分解为编码器和解码器部分。因为 PyTorch Modules 也是由Modules 构建的,所以我们可以将编码器和解码器定义为单独的部分,并将它们作为最终Module的组件使用。
注意,因为输入以形状为(B,1,28,28)的图像形式传入,而我们使用的是线性层,我们首先需要将输入展平成一个形状为(B,28*28)的向量。但在解码步骤中,我们希望保持与原始数据相同的形状。我们可以使用我提供的View层将其转换回来。它的工作方式与张量上的.view和.reshape函数类似,但作为一个Module,为了方便:
linearLayer = nn.Linear(D, n, bias=False) ❶
pca_encoder =
nn.Sequential( ❷
nn.Flatten(), linearLayer,
)
pca_decoder = nn.Sequential( ❸
TransposeLinear(linearLayer, bias=False),
View(-1, 1, 28, 28) ❹
)
pca_model = nn.Sequential( ❺
pca_encoder,
pca_decoder
)
❶ 由于我们将共享线性层的权重,让我们单独定义它。
❷ 编码器展平后使用线性层。
❸ 解码器使用我们的 TransposeLinear 层和现在共享的 linearLayer 对象。
❹ 将数据形状恢复到原始形式
❺ 定义一个最终 PCA 模型,它由编码器序列后跟解码器组成
PCA 初始化和损失函数
我们已经有了训练这个自动编码器所需的一切。但要使其真正成为 PCA,我们需要添加WW^⊤ = I约束。这个约束有一个名字:正交性。我们不会深入探讨为什么 PCA 有这个,但我们会将其作为一个很好的练习包括在内。我们通过使用nn.init.orthogonal_函数给模型提供一个初始的随机正交权重集来开始我们的模型,这只需要一行代码:
nn.init.orthogonal_(linearLayer.weight)
我们在训练过程中不会严格强制正交性,因为实现这一功能的代码会比我想象的要丑陋一些。相反,我们采取了一种常见且简单的方法来鼓励正交性,但并不要求它。² 这通过将等式 W^TW = I 转换为一个惩罚或正则化器 ∥WW^⊤ − I∥[2]² 来实现。这是因为如果惩罚为 0,则 W 是正交的;如果惩罚不为零,它将增加损失,因此梯度下降将尝试使 W 更加正交。
实现这一点并不困难。我们使用均方误差(MSE)损失函数 ℓMSE,x) 来训练自监督部分。我们只需将惩罚的损失添加到这个损失函数中即可:

以下代码块实现了这一点。作为额外步骤,我们将正则化器的强度降低 0.1 倍,以强调自动编码部分比正交性部分更重要:
mse_loss = nn.MSELoss() ❶
def mseWithOrthoLoss(x, y): ❷
W = linearLayer.weight ❸
I = torch.eye(W.shape[0]).to(device) ❹
normal_loss = mse_loss(x, y) ❺
regularization_loss = 0.1*mse_loss(torch.mm(W, W.t()), I) ❻
return normal_loss + regularization_loss ❼
❶ 原始损失函数
❷ 我们的 PCA 损失函数
❸ 从我们之前保存的 linearLayer 对象中获取 W
❹ 目标正则化的单位矩阵
❺ 计算原始损失 ℓMSE,x)
❻ 计算正则化器惩罚 ℓMSE
❼ 返回两个损失的加和
7.1.3 使用 PyTorch 实现 PCA
现在我们想为 MNIST 数据集创建一个包装器。为什么?因为默认的 MNIST 数据集将分别以对(x,y)的形式返回输入和标签。但在这个案例中,输入是标签,因为我们正在尝试从输入预测输出。因此,我们扩展了 PyTorch 的 Dataset 类,以接受原始元组 x, y,并返回一个元组 x, x。这样,我们的代码保持了元组中第一个元素是输入,第二个元素是期望的输出/标签的约定:
class AutoEncodeDataset(Dataset):
"""Takes a dataset with (x, y) label pairs and converts it to (x, x) pairs.
This makes it easy to reuse other code"""
def __init__(self, dataset):
self.dataset = dataset
def __len__(self):
return len(self.dataset)
def __getitem__(self, idx):
x, y = self.dataset.__getitem__(idx)
return x, x ❶
❶ 丢弃原始标签
注意:如果你正在为一个实际问题实现自动编码器,你的代码看起来可能更像是 x = self.dataset.__getitem__(idx),因为你不知道标签 y。然后你可以 return x, x。
拥有这个 AutoEncodeDataset 包装器在手,我们可以加载原始 MNIST 数据集,并用 AutoEncodeDataset 包装它,然后我们就可以开始训练了:
train_data = AutoEncodeDataset(torchvision.datasets.MNIST("./", train=True,
➥ transform=transforms.ToTensor(), download=True))
test_data_xy = torchvision.datasets.MNIST("./", train=False,
➥ transform=transforms.ToTensor(), download=True)
test_data_xx = AutoEncodeDataset(test_data_xy)
train_loader = DataLoader(train_data, batch_size=128, shuffle=True)
test_loader = DataLoader(test_data_xx, batch_size=128)
现在我们可以以训练其他神经网络相同的方式训练这个 PCA 模型。AutoEncodeDataset 使得输入也充当标签,pca_model 结合了编码和解码数据的序列,而 mseWithOrthoLoss 实现了一个 PCA 特定的损失函数,该函数结合了:1) 使输出看起来像输入 ℓMSE,x),和 2) 维护 PCA 所需的正交权重 (∥W^⊤W − I∥[2]² = 0):
train_network(pca_model, mseWithOrthoLoss, train_loader,
➥ test_loader=test_loader, epochs=10, device=device)
7.1.4 可视化 PCA 结果
你可能已经注意到我们使用了隐藏层大小 n = 2。这是故意的,因为这让我们能够 绘制 结果,并建立一些关于自动编码器如何工作的良好视觉直觉。这是因为当 n = 2 时,我们可以使用 PCA 将数据可视化在二维空间中。这是 PCA 的一个非常常见的用例。即使我们使用了更大的目标维度,将数据投影下来可以使搜索相似数据更快和/或更准确。因此,有一个函数可以接受数据集并将其全部编码到低维空间中是有用的。以下函数就是这样做的,并复制了标签,这样我们就可以将我们的结果与 MNIST 测试数据的真实情况进行比较:
def encode_batch(encoder, dataset_to_encode):
"""
encoder: the PyTorch network that takes in a dataset and converts it to
➥ a new dimension
dataset_to_encode: a PyTorch ‘Dataset‘ object that we want to convert.
Returns a tuple (projected, labels) where ‘projected‘ is the encoded
➥ version of the dataset, and ‘labels‘ are the original labels
➥ provided by the ‘dataset_to_encode‘
"""
projected = [] ❶
labels = []
encoder = encoder.eval() ❷
encoder = encoder.cpu() ❸
with torch.no_grad(): ❹
for x, y in DataLoader(dataset_to_encode, batch_size=128):
z = encoder(x.cpu()) ❺
projected.append( z.numpy() ) ❻
labels.append( y.cpu().numpy().ravel() )
projected = np.vstack(projected) ❼
labels = np.hstack(labels)
return projected, labels ❽
projected, labels = encode_batch(pca_encoder, test_data_xy) ❾
❶ 创建空间以存储结果
❷ 切换到评估模式
❸ 为了简单起见,切换到 CPU 模式,但你不一定需要这样做。
❹ 我们不想训练,所以使用 torch.no_grad!
❺ 对原始数据进行编码
❻ 存储编码版本和标签
❼ 将结果转换为单个大型 NumPy 数组
❽ 返回结果
❾ 投影我们的数据
使用encode_batch函数,我们现在已经将 PCA 应用于数据集,并且可以使用 seaborn 绘制结果。这应该看起来像一个非常熟悉的 PCA 图:一些类与其他类有相当好的分离,而有些则聚集在一起。以下代码中有这样一个奇怪的片段:hue=[str(l) for l in labels], hue_order=[str(i) for i in range(10)],这是为了使图表更容易阅读。如果我们使用hue=labels,代码将正常工作,但 seaborn 会给所有数字相似的色彩,这将很难阅读。通过将标签转换为字符串(hue=[str(l) for l in labels]),我们让 seaborn 给每个类一个更明显的颜色,并使用hue_order来让 seaborn 按我们期望的顺序绘制类:
sns.scatterplot(x=projected[:,0], y=projected[:,1],
➥ hue=[str(l) for l in labels],
➥ hue_order=[str(i) for i in range(10)], legend="full")
[15]: <AxesSubplot:>

从这张图中,我们可以得到一些关于编码质量的看法。例如,区分 0 和 1 类与其他所有类可能很容易。尽管如此,有些其他类可能很难区分;在一个真正无监督的场景中,如果我们不知道真正的标签,我们将无法轻易发现不同的概念。我们可以用来帮助判断的另一件事是编码/解码过程。如果我们做得好,输出应该与输入相同。首先,我们定义一个简单的辅助函数来在左侧绘制原始输入 x,在右侧绘制编码-解码版本:
def showEncodeDecode(encode_decode, x):
"""
encode_decode: the PyTorch Module that does the encoding and decoding
➥ steps at once
x: the input to plot as is, and after encoding & decoding it
"""
encode_decode = encode_decode.eval() ❶
encode_decode = encode_decode.cpu() ❷
with torch.no_grad(): ❸
x_recon = encode_decode(x.cpu())
f, axarr = plt.subplots(1,2) ❹
axarr[0].imshow(x.numpy()[0,:])
axarr[1].imshow(x_recon.numpy()[0,0,:])
❶ 切换到评估模式
❷ 将操作移至 CPU,这样我们就不必考虑任何设备是什么,因为这个函数的性能不敏感
❸ 如果您不在训练,请始终使用 no_grad
❹ 使用 Matplotlib 创建一个左侧为原始数据的并排图
我们在本章中重用这个函数。首先让我们看看一些不同数字的输入-输出组合:
showEncodeDecode(pca_model, test_data_xy[0][0])
showEncodeDecode(pca_model, test_data_xy[2][0])
showEncodeDecode(pca_model, test_data_xy[10][0]) ❶
❶ 展示了三个数据点的输入(左侧)和输出(右侧)



这些结果与我们根据二维图所预期的相符。经过编码和解码后,0 和 1 类看起来就像 1 和 0。至于 7 类,则不然。为什么?嗯,我们正在将 784 个维度压缩到 2 个。这是大量的信息压缩——远远超过我们合理期望的 PCA 能够做到的。
7.1.5 一个简单的非线性 PCA
PCA 是我们能设计的最简单的自动编码器之一,因为它是一个完全线性的模型。如果我们只添加一点我们所学的,会发生什么?我们可以添加一个单非线性并去除权重共享,将其转换为一个小的非线性自动编码器。让我们看看这会是什么样子:
pca_nonlinear_encode = nn.Sequential( ❶
nn.Flatten(),
nn.Linear(D, n),
nn.Tanh(), ❷
)
pca_nonlinear_decode = nn.Sequential( ❸
nn.Linear(n, D), ❹
View(-1, 1, 28, 28)
)
pca_nonlinear = nn.Sequential( ❺
pca_nonlinear_encode,
pca_nonlinear_decode
)
❶ 使用 Tanh 非线性增强编码器
❷ 唯一真正的变化:在最后添加一个非线性操作
❸ 解码器获得自己的线性层,使其看起来更像一个正常网络。
❹ 我们不再绑定权重,为了简单起见。
❺ 将它们组合成编码器-解码器函数 f(⋅)
由于我们不再在编码器和解码器之间共享权重,所以我们不关心权重是否正交。因此,当我们训练这个模型时,我们使用正常的均方误差损失:
train_network(pca_nonlinear, mse_loss, train_loader,
➥ test_loader=test_loader, epochs=10, device=device)
在以下代码块中,我们再次绘制所有二维编码和我们的三个编码-解码图像,以直观地查看发生了什么变化。这让我们可以主观地判断质量是否有所提高:
projected, labels = encode_batch(pca_nonlinear_encode, test_data_xy)
sns.scatterplot(x=projected[:,0], y=projected[:,1],
➥ hue=[str(l) for l in labels],
➥ hue_order=[str(i) for i in range(10)], legend="full" )
[20]: <AxesSubplot:>

showEncodeDecode(pca_nonlinear, test_data_xy[0][0])
showEncodeDecode(pca_nonlinear, test_data_xy[2][0])
showEncodeDecode(pca_nonlinear, test_data_xy[10][0])



总体来说,变化是明显的,但并不明显不同。二维图仍然有很多重叠。编码-解码图像显示了一些伪影:0、1 和 7 在定性上相似,但风格不同。目的是什么?我们将 PCA 转换为一个具有一个非线性函数的自动编码器,因此我们修改了 PCA 算法;并且因为我们使用 PyTorch 进行操作,所以它训练起来并且直接工作。现在我们可以尝试进行更大的改动以获得更好的结果。作为深度学习的一个主题,如果我们通过添加更多层来使这个模型更深,我们应该能够成功地提高结果。
7.2 设计自动编码神经网络
PCA 是一种非常流行的降维和可视化方法,任何你可能使用 PCA 的情况,你可能会想使用自动编码网络。自动编码网络有相同的概念,但我们将编码器和解码器设计成更大的网络,具有更多层,以便它们可以学习更强大和复杂的编码器和解码器。因为我们已经看到 PCA 是一个自动编码器,所以自动编码网络可能是一个更准确的选择,因为它能够学习更复杂的函数。
自动编码网络也适用于异常值检测。你想要检测异常值,以便你可以手动审查它们,因为异常值不太可能被模型很好地处理。自动编码器可以通过查看其重建输入的好坏来检测异常值。如果你可以很好地重建输入,数据可能看起来像你见过的正常数据。如果你不能成功重建输入,它可能不寻常,因此是一个异常值。这个过程总结在图 7.3 中。你可以使用这种方法来找到训练数据中的潜在不良数据点或验证用户提交的数据(例如,如果有人上传他们的脸部照片到一个寻找耳部感染的应用程序,你应该将脸部检测为异常值,而不是做出诊断)。

图 7.3 自动编码器检测异常值的示例应用。使用数据集来训练自动编码器,然后计算所有重建误差。假设 0.1%的数据是异常的,它应该是最难重建的。如果你找到了前 0.1%错误率的阈值,你可以将这个阈值应用于新数据以检测异常值。异常值通常表现不佳,可能最好以不同的方式处理它们或给予它们额外的审查。异常值检测可以在训练数据或新测试数据上执行。你可以将 0.1%更改为与你在数据集中相信正在发生的情况相匹配。
现在我们来谈谈如何设置基于深度学习的自动编码器。标准方法是在编码器和解码器之间建立一个对称架构:保持每边的层数相同,并将它们以相反的顺序放置(编码器从大到小,解码器从小到大)。我们还使用了一种瓶颈风格的编码器,这意味着层中的神经元数量逐渐减少。这如图 7.4 所示。

图 7.4 标准自动编码器设计的示例。输入从左侧进入。编码器一开始很大,然后在每一步逐渐减小隐藏层的大小。因为自动编码器通常是对称的,所以我们的解码器将接收小的表示 z,并开始将其扩展回原始大小。
自动编码器不必须是对称的。如果它们不是对称的,它们也能正常工作。这样做纯粹是为了使思考和理解网络更容易。这样,你将网络中层的数量、每层的神经元数量等决策减少了一半。
在编码器中的瓶颈非常重要。就像 PCA 所做的那样,通过将其压缩到更小的表示,我们使得网络无法作弊并学习到立即返回输入作为输出的天真解决方案。相反,网络必须学会识别高级概念,例如“有一个位于中心的圆圈”,这可以用于编码(然后解码)数字 6 和 0。通过学习多个高级概念,网络被迫开始学习有用的表示。
注意:自动编码器也可以被视为生成嵌入的一种方式。网络中的编码器部分尤其适合作为嵌入构建的候选者。这种方法是无监督的,因此你不需要标签,编码器的低维输出更适合与常见的可视化工具和最近邻搜索工具一起使用。
7.2.1 实现自动编码器
由于我们已经经历了实现 PCA 的痛苦,因此在以这种方式完成时,自动编码器应该更容易、更直接,主要是因为我们不再在层之间共享任何权重,也不再需要权重上的正交约束。为了简单起见,我们专注于全连接网络用于自动编码器,但这些概念具有广泛的应用性。首先,让我们定义另一个辅助函数getLayer,它为我们创建一个单独的隐藏层,以便放置在网络上,类似于我们在第六章中所做的:
def getLayer(in_size, out_size):
"""
in_size: how many neurons/features are coming into this layer
out_size: how many neurons/outputs this hidden layer should produce
"""
return nn.Sequential( ❶
nn.Linear(in_size, out_size),
nn.BatchNorm1d(out_size),
nn.ReLU())
❶ 将隐藏层的概念“块”组织成一个 Sequential 对象
拥有这个辅助函数后,以下代码展示了如何使用我们所学到的更高级的工具(如批量归一化和 ReLU 激活)轻松实现自动编码器。它采用了一种简单的策略,即通过固定的模式减少每个隐藏层中的神经元数量。在这种情况下,我们将神经元数量除以 2,然后是 3,然后是 4,依此类推,直到解码器的最后一层,我们直接跳到目标大小 D′。用于减少层数量的模式并不重要,只要层的尺寸持续减小:
auto_encoder = nn.Sequential( ❶
nn.Flatten(),
getLayer(D, D//2), ❷
getLayer(D//2, D//3),
getLayer(D//3, D//4),
nn.Linear(D//4, n), ❸
)
auto_decoder = nn.Sequential( ❹
getLayer(n, D//4), ❺
getLayer(D//4, D//3),
getLayer(D//3, D//2),
nn.Linear(D//2, D),
View(-1, 1, 28, 28) ❻
)
auto_encode_decode = nn.Sequential( ❼
auto_encoder,
auto_decoder
)
❶ 将 2、3、4 等除以,是许多可用的模式之一。
❷ 每一层的输出尺寸都比前一层小。
❸ 跳到目标维度
❹ 解码器以相反的顺序执行相同的层/尺寸,以保持对称性
❺ 由于我们处于解码器中,每一层都在增加尺寸。
❻ 调整形状以匹配原始形状
❼ 结合成一个深度自动编码器
和往常一样,我们可以使用完全相同的函数来训练这个网络。我们坚持使用均方误差,这在自动编码器中非常常见:
train_network(auto_encode_decode, mse_loss, train_loader,
➥ test_loader=test_loader, epochs=10, device=device)
7.2.2 可视化自动编码器结果
我们的新自动编码器表现如何?2D 图显示了投影维度 z 中更多的分离。类别 0、6 和 3 与其他类别非常好地分离。此外,中间区域(其中相邻的类别更多)在现有类别中至少具有更多的连续性和均匀性。类别在中部区域有独特的家园,而不是相互涂抹:
projected, labels = encode_batch(auto_encoder, test_data_xy)
sns.scatterplot(x=projected[:,0], y=projected[:,1],
➥ hue=[str(l) for l in labels],
➥ hue_order=[str(i) for i in range(10)], legend="full")
[25]: <AxesSubplot:>

这也是使用自动编码器探索未知数据的一种方法。如果我们不知道类别标签,我们可能会从这种投影中得出结论,数据中可能至少存在两个到四个不同的子群体。
我们还可以查看一些编码-解码周期的示例。与之前不同,重建现在清晰,模糊度大大减少。但并不完美:类别 4 通常很难与其他类别分离,并且重建质量较低:
showEncodeDecode(auto_encode_decode, test_data_xy[0][0])
showEncodeDecode(auto_encode_decode, test_data_xy[2][0])
showEncodeDecode(auto_encode_decode, test_data_xy[6][0])
showEncodeDecode(auto_encode_decode, test_data_xy[23][0])




尝试与代码互动,观察不同数据点的结果。如果你这样做,你可能会开始注意到,重建并不总是保持输入的风格。这一点在数字 5 这里尤为明显:重建比原始输入更平滑、更纯净。这是好事还是坏事?
从我们训练模型的角度来看,这是一个坏事情。重建与输入不同,而目标是精确地重建输入。
然而,我们的真正目标并不是仅仅学会从输入本身重建输入。我们已经有输入了。我们的目标是学习数据的有用表示,而无需知道数据的标签。从这个角度来看,这种行为是好事:这意味着可能有多个不同的潜在“5”可以作为输入,并且会被映射到相同的“5”重建。从这个意义上说,网络已经自学了存在一个规范或典型的 5,而无需明确告知关于 5 的概念,甚至是否存在不同的数字。
但数字 4 的例子是一个失败的案例。网络重建了一个完全不同的数字,因为限制条件太严格:网络被迫降低到仅两个维度,并且无法在如此小的空间内学习数据中的所有复杂性。这意味着排除了 4 的概念。类似于 PCA,如果你给网络更大的瓶颈(更多可用的特征),重建的质量将稳步提高。使用两个维度非常适合在散点图中进行可视化,但对于其他应用,你可能想使用更多一些的特征。(这,就像机器学习中的大多数事情一样,是特定于问题的。你应该确保你有方法来测试你的结果,以便进行比较,然后使用这个测试来确定你应该使用多少特征)。
7.3 更大的自动编码器
我们迄今为止所做的所有自动编码都是基于将数据投影到两个维度,我们之前已经说过这使问题变得异常困难。你的直觉应该告诉你,如果我们把目标维度大小D′稍微增大,我们的重建质量应该会提高。但如果我们把目标大小做得比原始输入更大呢?这会起作用吗?我们可以轻松修改我们的自动编码器来尝试这一点,看看会发生什么。在下面的代码块中,我们只是在编码器的第一层之后简单地跳到D′ = 2 ⋅ D,并在整个过程中保持这个神经元数量:
auto_encoder_big = nn.Sequential(
nn.Flatten(),
getLayer(D, D*2),
getLayer(D*2, D*2),
getLayer(D*2, D*2),
nn.Linear(D*2, D*2),
)
auto_decoder_big = nn.Sequential(
getLayer(D*2, D*2),
getLayer(D*2, D*2),
getLayer(D*2, D*2),
nn.Linear(D*2, D),
View(-1, 1, 28, 28)
)
auto_encode_decode_big = nn.Sequential(
auto_encoder_big,
auto_decoder_big
)
train_network(auto_encode_decode_big, mse_loss, train_loader,
➥ test_loader=test_loader, epochs=10, device=device)
由于我们有很多维度,我们无法制作一个 2D 图。但我们仍然可以在数据上执行编码/解码比较,以查看我们的新自动编码器表现如何。如果我们绘制一些示例,就会变得明显,我们现在有非常好的重建,包括来自原始输入的细微细节。例如,下面的 7 在左上角略有上升,底部略有加粗,这些都在重建中存在。之前完全混乱的 4 现在有大量独特的曲线和风格,这些也被忠实保留:
showEncodeDecode(auto_encode_decode_big, test_data_xy[0][0])
showEncodeDecode(auto_encode_decode_big, test_data_xy[6][0])
showEncodeDecode(auto_encode_decode_big, test_data_xy[10][0])



因此,问题是,这个自动编码器是否比之前的更好?我们是否学习到了有用的表示?这是一个很难回答的问题,因为我们使用输入重建作为网络的损失,但这并不是我们真正关心的。我们希望网络学习到有用的表示。这是经典无监督学习问题的一个变体:如果你不知道你在找什么,你怎么知道你做得好不好?
7.3.1 对噪声的鲁棒性
为了帮助我们回答哪个自动编码器更好的问题,D′ = 2 或 D′ = 2 ⋅ D,我们将向我们的数据添加一些噪声。为什么?我们可以使用的一个直觉是,如果一种表示法是好的,它应该是鲁棒的。想象一下,如果我们一直使用的干净数据就像一条路,我们的模型就像一辆车。如果这条路干净且平坦,车就能很好地行驶。但如果路上有坑洼和裂缝(即噪声)呢?一辆好车仍然应该能够成功驾驶。同样,如果我们有噪声数据,一个理想模型仍然会表现良好。
我们有很多种不同的方法可以使我们的数据变得噪声。其中一种最简单的方法是添加来自正态分布的噪声。我们用 N(μ,σ) 表示正态分布,其中 μ 是返回的均值值,σ 是标准差。如果 s 是从正态分布中抽取的值,我们用 s ∼ N(μ,σ) 表示。
为了使我们的数据变得噪声,我们使用 PyTorch 构造一个表示正态分布的对象,并扰动输入数据,以便我们得到
= x + s,其中 s ∼ N(μ,σ)。为了表示正态分布 N(μ,σ),PyTorch 提供了 torch.distributions.Normal 类:
normal = torch.distributions.Normal(0, 0.5) ❶
❶ 第一个参数是均值 μ;第二个是标准差 σ
这个类有一个执行 s∼ 步的 sample 方法。我们使用 sample_shape 参数来告诉它我们想要一个形状为 sample_shape 的张量,用从这个分布中抽取的随机值填充。以下函数接受一个输入 x 和与 x 形状相同的噪声样本,这样我们就可以添加它,创建我们的噪声样本
= x + s:
def addNoise(x, device=’cpu’):
"""
We will use this helper function to add noise to some data.
x: the data we want to add noise to
device: the CPU or GPU that the input is located on.
"""
return x +
normal.sample(sample_shape=
➥ torch.Size(x.shape)).to(device) ❶
❶ x + s
在我们放置了简单的 addNoise 函数之后,我们可以用我们的大型模型尝试它。我们故意将噪声量设置得相当大,以便使模型之间的变化和差异更加明显。对于以下输入数据,你应该看到重建结果混乱,有额外的线条。由于噪声是随机的,你可以多次运行代码以查看不同的版本:
showEncodeDecode(auto_encode_decode_big, addNoise(test_data_xy[6][0]))
showEncodeDecode(auto_encode_decode_big, addNoise(test_data_xy[23][0]))


这似乎表明,我们的 D′ = 2 ⋅ D 的大型自动编码器并不非常鲁棒。如果我们将相同的噪声数据应用到原始自动编码器上,该编码器使用 D = 2 会发生什么?你可以看到接下来。5 被重建得几乎与之前完全一样:有点模糊,但清楚地是一个 5:
showEncodeDecode(auto_encode_decode, addNoise(test_data_xy[6][0]))
showEncodeDecode(auto_encode_decode, addNoise(test_data_xy[23][0]))


如果你多次运行 4,有时你会从解码器中得到一个 4,有时你会得到其他东西。这是因为噪声每次都不同,我们的 2D 图显示 4 正在被与其他许多类别混淆。
基于这个实验,我们可以看到,随着我们使编码维度 D′ 变小,模型变得更加鲁棒。如果我们让编码维度变得过大,它可能在处理容易的数据时擅长重建,但它对变化和噪声不鲁棒。这部分原因是因为当 D′ ≥ D 时,模型很容易学习一个简单的方法。它有足够的容量来复制输入并学会重复它所接受的内容。通过用较小的容量(D′ ≤ D)约束模型,它学习解决任务的唯一方法就是创建输入数据的更紧凑表示。理想情况下,你试图找到一个维度 D′,它可以在很好地重建数据的同时,尽可能使用最小的编码维度。
7.4 去噪自动编码器
要平衡使 D′ 足够小以保持鲁棒性,同时又要足够大以在重建方面表现良好,并非易事。但有一个技巧我们可以使用,这将允许我们拥有较大的 D′ > D 并学习一个鲁棒模型。这个技巧就是创建所谓的 去噪自动编码器。去噪自动编码器在向编码器输入添加噪声的同时,仍然期望解码器能够生成一个干净的图像。因此,我们的数学从 ℓ(f(x),x) 变为 ℓ(f(
),x)。如果我们这样做,就没有简单的解决方案只是复制输入,因为我们是在将其交给网络之前对其进行扰动的。网络必须学习如何去除输入的噪声,或者去噪,从而允许我们在仍然获得鲁棒表示的同时使用 D′ > D。
去噪网络有很多实际应用。如果你可以创建出在现实生活中可能遇到的问题的合成噪声,你可以创建出去除噪声并通过使数据更干净来提高准确性的模型。例如,scikit-image (scikit-image.org) 这样的库提供了许多可以用来生成噪声图像的转换,我本人也使用这种方法来改进指纹识别算法。³ 我如何使用去噪自动编码器在图 7.5 中展示,这也是去噪自动编码器通常设置的总结。原始(或有时非常干净)的数据在开始时进入,我们对其应用噪声生成过程。噪声看起来越像你在真实数据中看到的问题,效果越好。噪声/损坏的数据版本作为自动编码器的输入,但损失是针对原始干净数据计算的。

图 7.5 展示了应用于指纹图像的去噪自动编码器处理过程。这使用了特殊的软件来生成超逼真的指纹图像,目的是去除噪声,使指纹处理更不易出错。即使使用更简单和不现实的噪声,你仍然可以得到良好的结果。
7.4.1 使用高斯噪声进行去噪
我们将对之前的 auto_encoder_big 模型进行仅有的一个修改:在编码子网络的开始处添加一个新层,该层仅在训练时向输入添加噪声。通常的假设是,我们的训练数据相对干净且已准备就绪,我们添加噪声是为了使其更健壮。如果我们正在 使用 模型,并且不再训练,我们希望得到最好的答案——这意味着我们希望得到尽可能干净的数据。在那个阶段添加噪声会使我们的生活更加困难,如果输入已经存在噪声,我们只会使问题更加复杂。
因此,我们首先需要的是一个新的 AdditiveGaussNoise 层。它接受输入 x in。如果我们处于训练模式(由 self.training 表示),我们向输入添加噪声;否则,我们返回未扰动的输入:
class AdditiveGaussNoise(nn.Module): ❶
def __init__(self):
super().__init__()
def forward(self, x):
if self.training: ❷
return addNoise(x, device=device)
else: ❸
return x
❶ 在此对象的构造函数中我们不需要做任何事情。
❷ 每个 PyTorch 模块对象都有一个 self.training 布尔值,可以用来检查我们是否处于训练(True)或评估(False)模式。
❸ 现在是训练阶段:返回给定的数据。
接下来,我们重新定义与之前相同的大型自动编码器,其中 D′ = 2 ⋅ D。唯一的区别是我们将 AdditiveGaussNoise 层插入到网络的开始部分:
dnauto_encoder_big = nn.Sequential(
nn.Flatten(),
AdditiveGaussNoise(), ❶
getLayer(D, D*2),
getLayer(D*2, D*2),
getLayer(D*2, D*2),
nn.Linear(D*2, D*2),
)
dnauto_decoder_big = nn.Sequential(
getLayer(D*2, D*2),
getLayer(D*2, D*2),
getLayer(D*2, D*2),
nn.Linear(D*2, D),
View(-1, 1, 28, 28)
)
dnauto_encode_decode_big = nn.Sequential(
dnauto_encoder_big,
dnauto_decoder_big
)
train_network(dnauto_encode_decode_big, mse_loss, train_loader,
➥ test_loader=test_loader, epochs=10, device=device) ❷
❶ 仅添加!我们希望在这里添加噪声会有所帮助。
❷ 按常规训练
它做得怎么样?接下来,我们可以看到在有噪声和无噪声的情况下重建的相同数据。新的去噪模型在创建我们迄今为止开发的各个模型的重建方面明显是最好的。在两种情况下,去噪自编码器都捕捉到了单个数字的大部分风格。去噪方法仍然遗漏了一些细节,这可能是由于它们太小,以至于模型不确定它们是风格的真实部分还是噪声的一部分。例如,4 号字底部的装饰和 5 号字顶部的装饰在重建后缺失:
showEncodeDecode(dnauto_encode_decode_big, test_data_xy[6][0])
showEncodeDecode(dnauto_encode_decode_big, addNoise(test_data_xy[6][0]))


showEncodeDecode(dnauto_encode_decode_big, test_data_xy[23][0])
showEncodeDecode(dnauto_encode_decode_big, addNoise(test_data_xy[23][0]))


去噪方法在训练自编码器时非常受欢迎,将你自己的扰动引入数据中的技巧被广泛用于构建更准确和鲁棒的模型。随着你对深度学习和不同应用了解的更多,你会发现许多形式和变体都基于这种方法。
除了帮助学习更鲁棒的表现形式之外,去噪方法本身也可以是一个有用的模型。噪声在许多情况下都可能自然发生。例如,在执行光学字符识别(OCR)以将图像转换为可搜索文本时,你可以从相机的损坏、文档的损坏(例如,水或咖啡污渍)、光照变化、物体投下的阴影等中获得噪声。许多 OCR 系统通过学习添加类似于真实生活中看到的噪声,并要求模型在噪声中学习而得到改进。
基于 Dropout 的去噪
添加高斯噪声可能很麻烦,因为我们需要确定确切需要添加多少噪声,这可能会从数据集到数据集而变化。第二种更受欢迎的方法是使用dropout。
Dropout 是一个非常简单的想法:以一定的概率 p,将任何给定的特征值置零。这迫使网络变得鲁棒,因为它永远不能依赖于任何特定的特征或神经元值,因为 p%的时间,特征或值将不存在。Dropout 是一个非常受欢迎的正则化器,可以应用于网络的输入和隐藏层。
以下代码块训练了一个基于 dropout 的去噪自编码器。默认情况下,dropout 使用p = 50%,这对于隐藏层来说是合适的,但对于输入来说过于激进。因此,对于输入,我们只应用p = 20%:
dnauto_encoder_dropout = nn.Sequential(
nn.Flatten(),
nn.Dropout(p=0.2), ❶
getLayer(D, D*2),
nn.Dropout(), ❷
getLayer(D*2, D*2),
nn.Dropout(),
getLayer(D*2, D*2), nn.Dropout(), nn.Linear(D*2, D*2)
)
dnauto_decoder_dropout = nn.Sequential(
getLayer(D*2, D*2),
nn.Dropout(),
getLayer(D*2, D*2),
nn.Dropout(),
getLayer(D*2, D*2),
nn.Dropout(),
nn.Linear(D*2, D),
View(-1, 1, 28, 28)
)
dnauto_encode_decode_dropout = nn.Sequential(
dnauto_encoder_big,
dnauto_decoder_big
)
train_network(dnauto_encode_decode_dropout, mse_loss, ❸
➥ train_loader, test_loader=test_loader, epochs=10, device=device)
❶ 对于输入,我们通常只丢弃 5%到 20%的值。
❷ 默认情况下,dropout 使用 50%的概率将值置零。
❸ 按常规训练
现在模型已经训练好了,让我们将其应用于一些测试数据。Dropout 可以鼓励很大的鲁棒性,我们可以通过将其应用于 dropout 噪声和高斯噪声来展示这一点。后者是网络以前从未见过的,但这并没有阻止自编码器忠实地确定一个准确的重建:
showEncodeDecode(dnauto_encode_decode_dropout,
➥ test_data_xy[6][0]) ❶
showEncodeDecode(dnauto_encode_decode_dropout,
➥ addNoise(test_data_xy[6][0])) ❷
showEncodeDecode(dnauto_encode_decode_dropout,
➥ nn.Dropout()(test_data_xy[6][0])) ❸
❶ 清洁数据
❷ 高斯噪声
❸ Dropout 噪声



退火与重生
dropout 的起源可以追溯到 2008 年早期去噪自编码器a,但最初只应用于输入。后来它被发展成为一种更通用的正则化器b,并在神经网络作为领域和研究领域的重生中发挥了重要作用。
与大多数正则化方法一样,dropout 的目标是提高泛化能力和减少过拟合。它在这一点上做得相当出色,并且有一个吸引人且直观的故事来解释它是如何工作的。多年来,dropout 一直是获取良好结果的关键工具,没有它几乎不可能实现网络。dropout 仍然作为一个正则化器发挥作用,并且有用且被广泛使用,但它不再像以前那样无处不在。我们迄今为止学到的工具,如归一化层、更好的优化器和残差连接,为我们提供了 dropout 的大部分好处。
使用 dropout 并不是一件“坏”事,我通过提到“dropout 的衰落”而夸张了。这项技术只是随着时间的推移而变得不那么受欢迎。我未经证实的理论是,首先,它训练起来稍微慢一些,需要大量的随机数和增加的内存使用,而现在我们可以在不付出这些代价的情况下获得其大部分好处。其次,dropout 在训练和测试时的应用方式不同。在训练期间,你失去了大约 50%的神经元,使得网络实际上变得更小。但在验证期间,你得到所有的 100%神经元。这可能导致测试性能看起来比训练性能更好的困惑情况,因为训练和测试是以不同的方式评估的。(技术上,批量归一化也是如此,但这种情况并不常见。)我认为人们选择了其他方法略微便宜且不那么令人困惑的结果。尽管如此,dropout 仍然是一个很好的默认选择,可以作为新架构的正则化器使用,在这些架构中,你不确定哪些是有效的或无效的。
^a P. Vincent, H. Larochelle, Y. Bengio, and P.A. Manzagol, “Extracting and composing robust features with denoising autoencoders,” in Proceedings of the 25th International Conference on Machine Learning, New York: Association for Computing Machinery, 2008, pp. 1096–1103, doi.org/10.1145/1390156.1390294.↩
^b N. Srivastava, G. Hinton, A. Krizhevsky, I. Sutskever, and R. Salakhutdinov, “Dropout: a simple way to prevent neural networks from overfitting,” The Journal of Machine Learning Research, vol. 15, no. 1, pp. 1929–1958, 2014.↩
7.5 时间序列和序列的自动回归模型
自动编码方法在图像、信号以及带有表格数据的全连接模型中都非常成功。但如果我们面临的是一个序列问题呢?特别是如果我们的数据是由离散标记表示的语言,那么很难向字母或单词等添加有意义的噪声。相反,我们可以使用一个自回归模型,这是一种专门为时间序列问题设计的解决方案。
你可以使用自回归模型来基本上应用于所有你可能使用自动编码模型的应用。你可以使用自回归模型学习到的表示作为输入到另一个不理解序列的 ML 算法中。例如,你可以在《Inside Deep Learning》的书评上训练一个自回归模型,然后使用 k-means 或 HDBSCAN 等聚类算法对这些评论进行聚类。⁴ 由于这些算法不自然地接受文本作为输入,自回归模型是快速扩展你最喜欢的 ML 工具范围的好方法。
假设你有 t 步的数据:x[1],x[2],…,x[t − 1],x[t]。自回归模型的目标是在给定序列中所有前面的项目的情况下预测x[t + 1]。用数学方式写这个就是ℙ(x[t + 1]|x[1],x[2],…,x[t]),这意味着

自回归方法仍然是一种自我监督的形式,因为序列中的下一个项目是数据本身的一个简单组成部分。如果你把“这是一个句子”看作是一系列字符的序列,根据定义,你知道T是第一个项目,h是第二个,i是第三个,以此类推。
图 7.6 说明了自回归模型在高级别上的发生情况。一个基于序列的模型显示在绿色块中,并接收输入x[i]。因此,第 i 步的预测是x̂[i]。然后我们使用损失函数ℓ来计算当前预测x̂[i]和下一个输入x[i + 1]之间的损失,ℓ(x̂[i], x[i][+1])。所以对于一个有 T 个时间步长的输入,我们有 T - 1 次损失计算:最后一个时间步 T 不能用作输入,因为没有 T + 1 项可以与之比较。

图 7.6 展示了自回归设置的示例。输入位于底部,输出位于顶部。对于一个输入x[i],自回归模型的预测是x̂[i],而标签****y[i] = x[i + 1]。
你可能已经从这张图的形状中猜到,我们将使用循环神经网络来实现我们的自回归模型。RNNs 非常适合像这样的基于序列的问题。与之前使用 RNNs 相比,一个大的变化是我们将在每个步骤进行预测,而不仅仅是最后一个步骤。
一种由安德烈·卡帕西(Andrej Karpathy)普及的自动回归模型称为char-RNN(字符 RNN)。这是一种自动回归方法,其中输入/输出是字符,我们将展示在莎士比亚数据上实现 char-RNN 模型的一种简单方法。
注意:虽然 RNN 是用于自动回归模型的适当且常见的架构,但双向 RNN 不是。这是因为自动回归模型正在预测未来。如果我们使用双向模型,我们将在序列中拥有关于未来内容的信息,而知道未来就是作弊!当我们要对整个序列进行预测时,双向 RNN 是有用的,但现在我们正在对输入进行预测,我们需要强制执行无双向策略,以确保我们的模型不会看到它们不应该看到的信息。
7.5.1 实现 char-RNN 自回归文本模型
我们首先需要我们的数据。安德烈·卡帕西(Andrej Karpathy)在网上分享了一些莎士比亚的文本,我们将下载这些文本。这个文本中大约有 10 万个字符,所以我们把数据存储在一个名为shakespear_100k的变量中。我们使用这个数据集来展示训练自回归模型的过程,以及它的生成能力:
from io import BytesIO
from zipfile import ZipFile
from urllib.request import urlopen
import re
all_data = [] resp = urlopen(
➥ "https://cs.stanford.edu/people/karpathy/char-rnn/shakespear.txt")
shakespear_100k = resp.read()
shakespear_100k = shakespear_100k.decode(’utf-8’).lower()
现在我们将构建一个包含此数据集中所有字符的词汇 Σ。你可以做出的一个改变是不使用lower()函数将所有内容转换为小写。因为我们正在探索深度学习,这些早期的决定对我们模型最终的使用方式和有用性非常重要。所以,你应该学会识别这样的选择作为选择。我选择使用全部小写数据,因此我们的词汇量更小。这降低了任务的难度,但意味着我们的模型无法学习关于大写的信息。
下面是代码:
vocab2indx = {} ❶
for char in shakespear_100k:
if char not in vocab2indx: ❷
vocab2indx[char] = len(vocab2indx) ❸
indx2vocab = {} ❹
for k, v in vocab2indx.items(): ❺
indx2vocab[v] = k
print("Vocab Size: ", len(vocab2indx))
print("Total Characters:", len(shakespear_100k))
Vocab Size: 36
Total Characters: 99993
❶ 词汇 Σ
❸ 根据当前词汇大小设置索引
❹ 将每个新字符添加到词汇中
❹ 从索引返回原始字符的有用代码
❺ 遍历所有键值对并创建一个具有逆映射的字典
接下来,我们采取一个非常简单的方法来构建一个自动回归数据集。由于这些字符是从莎士比亚的一个剧中提取的,所以它们在一个长序列中。如果我们把这个序列分成足够长的块,我们几乎可以保证每个块都会包含几个完整的句子。我们通过索引到一个位置start并获取文本的切片[start:start+chunk_size]来获得每个块。由于数据集是自动回归的,所以我们的标签是比一个字符多出的标记。这可以通过获取一个偏移一个字符的新切片来实现,即[start+1:start+1+chunk_size]。这如图 7.7 所示。

图 7.7 红色表示获取输入,黄色表示输出,使用六个字符的块。这使得为模型创建数据集变得容易,其中每个批次的长度都相同,简化了我们的代码,并确保最大 GPU 利用率(在填充的输入/输出上不做任何工作)。
以下代码使用此策略从大型文本语料库中实现自回归问题的数据集。我们假设语料库存在为一个长字符串,并且将多个文件连接成一个长字符串是可以接受的,因为我们的块的大小小于大多数文档。虽然我们给我们的模型增加了难度,需要从随机位置开始学习,这个位置可能是一个单词的中间部分,但它使我们能够轻松地实现所有代码:
class AutoRegressiveDataset(Dataset):
"""
Creates an autoregressive dataset from one single, long, source
➥ sequence by breaking it up into "chunks".
"""
def __init__(self, large_string, max_chunk=500):
"""
large_string: the original long source sequence that chunks will
➥ be extracted from
max_chunk: the maximum allowed size of any chunk.
"""
self.doc = large_string
self.max_chunk = max_chunk
def __len__(self):
return (len(self.doc)-1) // self.max_chunk ❶
def __getitem__(self, idx):
start = idx*self.max_chunk ❷
sub_string = self.doc[start:start+self.max_chunk] ❸
x = [vocab2indx[c] for c in sub_string] ❹
sub_string = self.doc[start+1:start+self.max_chunk+1] ❺
y = [vocab2indx[c] for c in sub_string] ❻
return torch.tensor(x, dtype=torch.int64), torch.tensor(y,
➥ dtype=torch.int64)
❶ 项数是字符数除以块大小。
❷ 计算第 idx 个块的起始位置
❸ 获取输入子字符串
❹ 根据我们的词汇表将子字符串转换为整数
❺ 通过移动 1 位来获取标签子字符串
❻ 根据我们的词汇表将标签子字符串转换为整数
现在是棘手的部分:实现自回归 RNN 模型。为此,我们使用门控循环单元(GRU)而不是长短期记忆(LSTM),因为 GRU 只有隐藏状态h[t],没有上下文状态c[t],所以代码会更容易阅读。我们实现的高级策略在图 7.8 中给出。

图 7.8 自回归 RNN 设计。输入(从底部开始)为黄色,其中nn.Embedding层将每个字符转换为向量。这些向量被送入绿色显示的 RNN 层,该层按顺序处理每个字符。然后一组全连接层独立地处理每个 RNN 隐藏状态h[t],通过权重共享来做出关于下一个标记的预测。
定义自回归构造函数
我们的构造函数接受一些熟悉的参数。我们想知道词汇表的大小num_embeddings、嵌入层中的维度embd_size、每个隐藏层中的神经元数量hidden_size以及 RNN 层的数量layers=1:
class AutoRegressive(nn.Module):
def __init__(self, num_embeddings, embd_size, hidden_size, layers=1):
super(AutoRegressive, self).__init__()
self.hidden_size = hidden_size
self.embd = nn.Embedding(num_embeddings, embd_size)
我们对架构的第一个主要改变是我们不使用正常的nn.GRU模块。正常的nn.RNN、nn.LSTM和nn.GRU模块一次接受所有时间步,并一次返回所有输出。你可以使用这些来实现自回归模型,但我们将使用nn.GRUCell模块。GRUCell一次处理一个序列项。这可能会慢一些,但可以更容易地处理未知和可变长度的输入。这种方法总结在图 7.9 中。Cell类在我们完成模型训练后将很有用,但我不想破坏惊喜——我们稍后会回到为什么我们这样做的原因。

图 7.9 展示了 PyTorch 中 RNN 和 cell 类之间主要区别的示例。左侧:正常的 RNN 在一次操作中处理整个序列,这使得它们更快,但需要一次性提供所有数据。右侧:cell 类逐个处理项目,在没有所有输入已经可用的情况下更容易使用。
如果我们想要多个layers的 RNN,我们必须手动指定和运行它们。我们可以通过使用ModuleList来指定一组中的多个模块来实现这一点。这意味着我们的初始化代码在self.embd之后看起来像这样:
self.layers = nn.ModuleList(
[nn.GRUCell(embd_size, hidden_size)] + [nn.GRUCell(hidden_size, hidden_size)
for i in range(layers-1)]) self.norms = nn.ModuleList(
[nn.LayerNorm(hidden_size) for i in range(layers)])
我们将GRUCell层的规范分为两部分。首先是第一个层的一个项目列表,因为它必须从embd_size输入到hidden_size输出。第二是所有剩余层,使用[nn.GRUCell(hidden_size, hidden_size) for i in range(layers-1)],这之所以有效,是因为这些层的输入和输出大小相同。为了好玩,我还包括了一个LayerNorm归一化层,用于每个 RNN 结果。
在我们的构造函数中,我们还需要紫色层,这些层接受隐藏状态h[t]并输出类的预测。这是通过一个小型全连接网络完成的:
self.pred_class = nn.Sequential(
nn.Linear(hidden_size, hidden_size), ❶
nn.LeakyReLU(),
nn.LayerNorm(hidden_size), ❶
nn.Linear(hidden_size, num_embeddings) ❷
)
❶ (B, *, D)
❷ (B, *, D) -> B(B, *, VocabSize)
注意,我们将此模块的一个组件定义为整个网络。这将帮助我们模块化我们的设计,并使我们的代码更容易阅读。如果您想返回并更改从隐藏 RNN 状态到预测的子网络,您只需更改pred_class对象,其余代码将正常工作。
实现自回归前向函数
该模块的forward函数将组织两个其他辅助函数完成的工作。首先,我们将输入标记嵌入到它们的向量形式中,因为这可以一次性完成。因为我们使用的是GRUCell类,我们需要自己跟踪隐藏状态。因此,我们使用initHiddenStates(B)函数为每个 GRU 层创建初始隐藏状态h[0] =
。然后,我们使用for循环获取每个 t 项目,并使用一个step函数逐个步骤处理它们,该函数接受输入x[t]和 GRU 隐藏状态列表h_prevs。GRU 隐藏状态存储在列表last_activations中,以获取每个时间步的预测。最后,我们可以通过stack将结果组合在一起返回一个单一的 tensor:
def forward(self, input): ❶
B = input.size(0) ❷
T = input.size(1) ❸
x = self.embd(input) ❹
h_prevs = self.initHiddenStates(B) ❺
last_activations = []
for t in range(T):
x_in = x[:,t,:] ❻
last_activations.append(self.step(x_in, h_prevs))
last_activations = torch.stack(last_activations, dim=1) ❼
return last_activations
❶ 输入应为(B,T)。
❷ 批量大小是多少?
❸ 最多有多少个时间步?
❹ (B,T,D)
❺ 初始隐藏状态
❻ (B, D)
❼ (B,T,D)
initHiddenStates很容易实现。我们可以使用torch.zeros函数创建一个全零值的 tensor。我们只需要一个参数B来指定批量大小的,然后我们可以从对象的成员中获取hidden_size和layers的数量:
def initHiddenStates(self, B):
"""
Creates an initial hidden state list for the RNN layers.
B: the batch size for the hidden states.
"""
return [torch.zeros(B, self.hidden_size, device=device)
for _ in range(len(self.layers))]
step 函数稍微复杂一些。首先,我们检查输入的形状,如果它只有一个维度,我们假设我们需要嵌入标记值以生成向量。然后我们检查隐藏状态 h_prevs,如果它们没有提供,则使用 initHiddenStates 初始化它们。这些都是好的防御性代码步骤,以确保我们的函数可以灵活且避免错误:
def step(self, x_in, h_prevs=None):
"""
x_in: the input for this current time step and has shape (B)
if the values need to be embedded, and (B, D) if they
have already been embedded.
h_prevs: a list of hidden state tensors each with shape
(B, self.hidden_size) for each layer in the network.
These contain the current hidden state of the RNN layers
and will be updated by this call.
"""
if len(x_in.shape) == 1: ❶
x_in = self.embd(x_in) ❷
if h_prevs is None:
h_prevs = self.initHiddenStates(x_in.shape[0])
for l in range(len(self.layers)): ❸
h_prev = h_prevs[l]
h = self.normsl) ❹
h_prevs[l] = h
x_in = h
return self.pred_class(x_in) ❺
❶ 准备所有三个参数以最终形式呈现。首先,(B);我们需要嵌入它。
❷ 现在 (B, D)
❸ 处理输入
❹ 将当前输入与之前的隐藏状态一起推入
❺ 对标记进行预测
在这些防御性编码步骤之后,我们简单地遍历层数并处理结果。x_in 是层的输入,它被传递到当前层 self.layers[l] 和归一化层 self.norms[l]。之后,我们进行一些次要的记录工作,存储新的隐藏状态 h_prevs[l] = h 并设置 x_in = h,以便下一层准备好输入以进行处理。一旦这个循环完成,x_in 就有了最后一个 RNN 层的结果,因此我们可以直接将其输入到 self.pred_class 对象中,从 RNN 隐藏状态预测下一个字符。
线性层随时间的一个快捷方式
你可能会注意到这段代码中关于张量形状的注释 (B, D)。这是因为 nn.Linear 层有一个特殊的技巧,允许它们同时独立地应用于多个输入。我们总是使用形状为 (B, D) 的张量上的线性模型,线性模型可以接收 D 个输入并返回 D′ 个输出。因此,我们会从 (B, D) 变为 (B, D′)。如果我们有一个序列中的 T 个项目,我们有一个形状为 (B, T, D) 的张量。将线性模型应用于每个时间步的原始方法需要 for 循环,看起来像这样:
def applyLinearLayerOverTime(x):
results = [] ❶
B, T, D = x.shape
for t in range(T):
results.append(linearLayer(x[:,t,:]) ❷
return torch.stack(results, dim=0).view(B, T, -1) ❸
❶ 存储每个步骤结果的地点
❷ 获取每个步骤的结果
❸ 将所有内容堆叠成一个张量并正确地调整形状
这比我们想要的代码要多,而且由于 for 循环,它将运行得更慢。PyTorch 有一个简单的技巧,即 nn.Linear 层应用于张量的 最后一个 轴,无论轴的数量是多少。这意味着整个函数可以用 linearLayer 代替,你将得到 完全相同的结果。这样,任何全连接网络都可以用于单个时间步或时间步组,而无需做任何特殊的事情。尽管如此,保留像 (B, D) 和 (B, T, D) 这样的注释仍然很好,这样你可以提醒自己 如何 使用你的网络。
定义了我们的模型后,我们几乎完成了。接下来,我们快速创建一个新的 AutoRegressiveDataset,使用 shakespear_100k 数据作为输入,并使用一个可观的批量大小创建数据加载器。我们还创建了一个具有 32 维嵌入、128 个隐藏神经元和 2 个 GRU 层的 AutoRegressive 模型。我们包括梯度裁剪,因为 RNN 对这个问题很敏感:
autoRegData = AutoRegressiveDataset(shakespear_100k, max_chunk=250)
autoReg_loader = DataLoader(autoRegData, batch_size=128, shuffle=True)
autoReg_model = AutoRegressive(len(vocab2indx), 32, 128, layers=2)
autoReg_model = autoReg_model.to(device)
for p in autoReg_model.parameters():
p.register_hook(lambda grad: torch.clamp(grad, -2, 2))
实现自回归损失函数
我们最后需要的是一个损失函数ℓ。我们在每个步骤都做出预测,因此我们想要使用适用于分类问题的CrossEntropyLoss。然而,我们需要计算多个损失,每个时间步一个。我们可以通过编写自己的损失函数CrossEntLossTime来解决,该函数计算每个步骤的交叉熵。类似于我们的forward函数,我们将每个预测x[:,t,:]和相应的标签y[:t]切片,以便我们最终得到预测和标签的标准(B,C)和(B)形状,可以直接调用CrossEntropyLoss。然后我们将每个时间步的损失相加,得到一个单一的返回总损失:
def CrossEntLossTime(x, y):
"""
x: output with shape (B, T, V)
y: labels with shape (B, T)
"""
cel = nn.CrossEntropyLoss()
T = x.size(1)
loss = 0
for t in range(T): ❶
loss += cel(x[:,t,:], y[:,t]) ❷
return loss
❶ 对于序列中的每个项目 ...
❷ ... 计算预测错误的总和。
现在我们可以最终训练我们的自回归模型了。我们使用相同的train_network函数,但将新的CrossEntLossTime函数作为损失函数ℓ传入——一切正常工作:
train_network(autoReg_model, CrossEntLossTime, autoReg_loader, epochs=100,
➥ device=device)
7.5.2 自回归模型是生成模型
我们将最后一个细节留到了最后,因为它比解释它更容易看到。自回归模型不仅是自监督的;它们还属于一类称为生成模型的类别。这意味着它们可以生成看起来像原始训练数据的新数据。为此,我们将我们的模型切换到eval模式,并创建一个存储我们的生成输出的张量sampling。从模型生成的任何输出都可以称为样本,生成该样本的过程称为采样,如果你想要听起来很酷(并且这是你需要记住的好术语):
autoReg_model = autoReg_model.eval()
sampling = torch.zeros((1, 500), dtype=torch.int64, device=device)
要从自回归模型中采样,我们通常需要给模型一个种子。这是模型给出的一些原始文本;然后模型被要求预测接下来会发生什么。设置种子的代码如下,其中“EMILIA:”是我们的初始种子,就像角色 Emilia 即将在剧中说话一样:
seed = "EMILIA:".lower()
cur_len = len(seed)
sampling[0,0:cur_len] = torch.tensor([vocab2indx[x] for x in seed])
自回归模型的采样过程如图 7.10 所示。我们将种子作为模型的初始输入传递,并忽略正在做出的预测。这是因为我们的种子正在帮助构建 RNN 的隐藏状态 h,其中包含关于每个先前输入的信息。一旦我们处理完整个种子,我们就没有更多的输入了。种子用完输入后,我们使用模型的前一个输出x̂[t]作为下一个时间步t + 1 的输入。这是因为自回归模型已经学会了预测接下来会发生什么。如果它在这一点上做得很好,它的预测可以用作输入,我们最终在过程中生成新的序列。

图 7.10 给模型一个种子,并忽略正在做出的预测。一旦种子用完,我们使用时间步 t 的预测作为下一个步骤t + 1 的输入。
但我们如何将预测作为输入使用呢?我们的模型正在预测看到 每个 不同字符作为下一个可能输出的概率。但下一个输入需要是一个 特定 的字符。这可以通过根据模型输出的概率 采样 预测来实现。所以如果字符 a 有 100% 的预测概率是下一个,模型 将 返回 a。如果我们有 80% 的 a,10% 的 b 和 10% 的 c,我们 可能 选择 a 作为下一个类别,但我们也可以选择 b 或 c。下面的代码就是这样做的:
for i in tqdm(range(cur_len, sampling.size(1))):
with torch.no_grad():
h = autoReg_model(sampling[:,0:i]) ❶
h = h[:,-1,:] ❷
h = F.softmax(h, dim=1) ❸
next_tokens = torch.multinomial(h, 1) ❹
sampling[:,i] = next_tokens ❺
cur_len += 1 ❻
❶ 处理所有前面的项目
❷ 获取最后一步
❸ 计算概率
❹ 采样下一个预测
❺ 设置下一个预测
❻ 增加长度为一
注意 正如自编码器可以制作出优秀的嵌入,自回归模型也是如此。前述代码中的隐藏状态 h 可以用作一个嵌入,它总结了迄今为止处理过的整个序列。这是一种从词嵌入到句子或段落嵌入的好方法。
现在我们有一个我们预测的新序列,但它看起来是什么样子呢?这就是为什么我们使用 indx2vocab dict 保存了从标记到我们词汇表的反向映射:我们可以使用它将每个整数映射回一个字符,然后 join 它们来创建输出。下面的代码将我们的生成样本转换回我们可以阅读的文本:
s = [indx2vocab[x] for x in sampling.cpu().numpy().flatten()]
print("".join(s))
emilia:
to hen the words tractass of nan wand,
no bear to the groung, iftink sand'd sack,
i will ciscling
bronino:
if this, so you you may well you and surck, of wife where sooner you.
corforesrale:
where here of his not but rost lighter'd therefore latien ever
un'd
but master your brutures warry:
why,
thou do i mus shooth and,
rity see! more mill of cirfer put,
and her me harrof of that thy restration stucied the bear:
and quicutiand courth, for sillaniages:
so lobate thy trues not very repist
你应该注意我们生成输出的几个方面。虽然它看起来有点像莎士比亚的风格,但它很快就会退化。这是因为随着每一步数据的增加,我们离 真实 数据就越远,我们的模型 将 做出不切实际的选择,从而成为错误并负面影响未来的预测。所以,我们生成的越长,质量就会越低。
7.5.3 使用温度改变样本
模型很少为任何标记给出 零 的概率,这意味着我们最终会选择一个不正确或不切实际的下一个标记。如果你 99% 确定下一个字符应该是 a,为什么给模型 1% 的机会去选择可能错误的东西呢?为了鼓励模型选择最可能的预测,我们可以在生成过程中添加一个 温度。这个 温度 是一个标量,我们在计算 softmax 之前将模型的预测除以它来使概率。这如图 7.11 所示,你可以将温度推到极端值,如无穷大或零。具有无穷大温度的东西会导致均匀随机的行为(这不是我们想要的),而具有零温度的东西会冻结并反复返回(最可能)的相同东西(这也不是我们想要的)。

图 7.11 爱德华将吃什么?如果温度设置得非常高,爱德华的选择将是随机的,无论初始概率如何。如果温度为零或接近零,爱德华将总是选择烧烤,因为它比任何其他选项更有可能。温度越高 = 随机性越大,不允许有负温度。
而不是使用这些极端值,我们可以通过将温度设置为略大于或略小于 1.0 的值来专注于添加一个小效果。temperature=1.0 的默认值不会改变概率,因此与我们已经做的事情相同:计算原始概率并根据这些概率采样预测。但如果你使用 temperature < 1,那么原本有更大选择机会的项目(如烧烤)将获得更大的优势并增加其概率(想想,“富者愈富”)。如果我们使 temperature > 1,我们最终会给低概率项目更多的选择机会,这将以原本更高的概率为代价。温度的影响总结在图 7.12 的例子中,展示了选择下一顿饭的概率。

图 7.12 温度如何影响选择下一顿饭的概率的例子。我默认最有可能吃烧烤,因为它很美味。提高温度鼓励多样性,最终在极端最低温度(零)时随机选择每个项目。降低温度减少多样性,最终在极端最高可能温度(趋向无穷大)时只选择最有可能的原始项目。
在实践中,0.75 的值是一个很好的默认值⁵(我通常看到最低端是 0.65,高端是 0.8),因为它保持了多样性但避免了选择原本不太可能的项目(例如,烧烤是我最喜欢的食物类别,但一些多样性对你有好处,也更现实)。以下代码将温度添加到采样过程中:
cur_len = len(seed)
temperature = 0.75 ❶
for i in tqdm(range(cur_len, sampling.size(1))):
with torch.no_grad():
h = autoReg_model(sampling[:,0:i])
h = h[:,-1,:] ❷
h = F.softmax(h/temperature, dim=1) ❸
next_tokens = torch.multinomial(h, 1)
sampling[:,i] = next_tokens
cur_len += 1
❶ 主要添加:控制温度和我们的采样行为
❷ 获取最后一步
❸ 制作概率
为什么叫温度为温度?
你应该遵循的类比是一个水壶,温度实际上是水的温度。如果温度非常高,水开始沸腾,水分子在四处弹跳时处于随机位置。随着温度的降低,水开始结冰,原子保持静止,形成有序的图案。高温 = 混乱(读作,随机),低温是静止的(读作,没有随机性)。
如果我们打印出我们的预测,我们应该看到一些感觉稍微更合理的东西。它并不完美,一个更大的模型和更多的训练轮次可以帮助提高这一点。但添加一个 temperature 是一个常见的技巧,有助于控制生成过程:
s = [indx2vocab[x] for x in sampling.cpu().numpy().flatten()]
print("".join(s))
emilia:
therefore good i am to she prainns siefurers.
king ford:
beor come, sir, i chaed
to me, the was the strong arl and the boy fear mabber lord,,
coull some a clock dightle eyes, agaary must her was flord but the hear fall
the cousion a tarm:
i am a varstiend the her apper the service no that you shall give yet somantion,
and lord, and commind cure, i why had they helbook.
mark ars:
who her throw true in go speect proves of the wrong and further gooland, before
but i am so are berether, i
所以你可能想,为什么不进一步降低温度呢?我们难道不应该总是选择最可能的预测吗?这涉及到一些关于评估生成和自回归模型难度的深层次问题,尤其是在输入类似于人类文本的情况下,这种文本一开始就并不那么可预测。如果我告诉你我能完美预测某人将要说什么,你可能会感到难以置信。这怎么可能呢?我们应该始终对我们的模型应用同样的标准。如果我们总是选择最可能的预测,我们就是在假设模型可以完美预测一个人接下来会说什么。我们可以通过将温度设置为非常低的值(如 0.05)来尝试这一点:
cur_len = len(seed)
temperature = 0.05 ❶
for i in tqdm(range(cur_len, sampling.size(1))):
with torch.no_grad():
h = autoReg_model(sampling[:,0:i])
h = h[:,-1,:] ❷
h = F.softmax(h/temperature, dim=1) ❸
next_tokens = torch.multinomial(h, 1)
sampling[:,i] = next_tokens
cur_len += 1
s = [indx2vocab[x] for x in sampling.cpu().numpy().flatten()]
print("".join(s))
emilia:
i will straight the shall shall shall be the with the shall shall shall shall
be the with the shall be the with the shall shall shall be the shall shall
shall she shall shall shall shall shall be the with the shall shall shall
shall be the shall be the shall shall shall shall shall shall shall shall be
the prove the will so see and the shall be the will the shall shall shall
shall shall shall be the with the shall shall shall shall be the shall be the
with the shall shall shall be the wi
❶ 非常低的温度:几乎总是选择最可能的项
❷ 获取最后一个时间步
❸ 计算概率
该模型已经变得非常重复。当你选择最可能的标记作为下一个时,通常会发生这种情况;模型会退化到反复选择最常见的单词/标记的序列。
温度的热门替代品
调整温度只是选择更逼真的生成输出方式的可能技术之一。每种技术都有其优缺点,但你应该了解三种替代方案:束搜索、top-k 采样和核采样。Hugging Face 的团队有一篇很好的博客文章,从高层次介绍了这些方法(huggingface.co/blog/how-to-generate)。
7.5.4 更快的采样
你可能已经注意到采样过程需要的时间出奇地长——大约 45 到 50 秒才能生成 500 个字符——然而我们只需在每次 epoch 中几秒钟内就能训练超过 10 万个字符。这是因为每次我们做出预测时,我们需要将整个生成的序列重新输入到模型中,以获取下一个预测。使用大 O 符号表示,我们正在做O(n²)的工作来生成一个O(n)长度的序列。
处理序列的每个步骤的GRUCell使得我们能够轻松解决这个问题。我们将for循环分成两部分,每部分直接使用step函数而不是模块的forward函数。第一个循环将种子推入模型,更新一个明确创建的隐藏状态集合h_prevs。之后,我们可以编写一个新的循环来生成新内容,并在采样下一个字符后调用step函数来更新模型。这个过程在以下代码中展示:
seed = "EMILIA:".lower() ❶
cur_len = len(seed)
sampling = torch.zeros((1, 500), dtype=torch.int64, device=device)
sampling[0,0:cur_len] = torch.tensor([vocab2indx[x] for x in seed])
temperature = 0.75 ❷
with torch.no_grad():
h_prevs = autoReg_model.initHiddenStates(1) ❸
for i in range(0, cur_len): ❹
h = autoReg_model.step(sampling[:,i], h_prevs=h_prevs)
for i in tqdm(range(cur_len, sampling.size(1))): ❺
h = F.softmax(h/temperature, dim=1) ❻
next_tokens = torch.multinomial(h, 1)
sampling[:,i] = next_tokens
cur_len += 1 h = autoReg_model.step(sampling[:,i],
h_prevs=h_prevs) ❼
❶ 设置我们的种子和存储生成内容的存储位置
❷ 选择温度
❸ 初始化隐藏状态以避免重复工作
❹ 将种子推入
❺ 逐个字符生成新文本
❻ 计算概率
❼ 现在只将新的样本推入模型
这段新代码的运行时间不到一秒。这要快得多,而且随着我们想要生成的序列越长,它运行得越快,因为它具有更好的 Big O 复杂度O(n)来生成O(n)个标记。接下来我们打印出生成的结果,你可以看到它从本质上与之前相同:
s = [indx2vocab[x] for x in sampling.cpu().numpy().flatten()]
print("".join(s))
emilia:
a-to her hand by hath the stake my pouse of we to more should there had the
would break fot his good, and me in deserved
to not days all the wead his to king; her fair the bear so word blatter with
my hath thy hamber--
king dige:
it the recuse.
mark:
wey, he to o hath a griec, and could would there you honour fail;
have at i would straigh his boy:
coursiener:
and to refore so marry fords like i seep a party. or thee your honour great
way we the may her with all the more ampiled my porn
因此,你现在已经了解了自动编码器和自回归模型的所有基础知识,这两种相关的方法共享相同的基本思想:使用输入数据作为目标输出。这些技术可以特别强大,可以模拟/替换昂贵的模拟(让网络学习预测接下来会发生什么),清理噪声数据(注入真实的噪声并学习如何去除它),并且通常在没有标记数据的情况下训练有用的模型。后者的说明将在第十三章中变得重要,当你学习如何进行迁移学习时,这是一种允许你使用未标记数据来改进较小标记数据集上的结果的技术。
练习
在 Manning 在线平台 Inside Deep Learning Exercises (liveproject.manning.com/project/945)上分享和讨论你的解决方案。一旦你提交了自己的答案,你将能够看到其他读者提交的解决方案,并看到作者认为哪些是最好的。
-
创建一个新的 MNIST 数据集版本,其中不包含数字 9 和 5,并在该数据集上训练一个自动编码器。然后在该测试数据集上运行自动编码器,并记录每个 10 个类别的平均误差(均方误差)。你在结果中看到任何模式吗?自动编码器能将 9 和 5 识别为异常值吗?
-
使用目标大小D′ = 64 维度的瓶颈式自动编码器进行训练。然后使用 k-means (
scikit-learn.org/stable/modules/clustering.html#k-means)在原始版本的 MNIST 和使用D′ = 64 维度编码的版本上创建k = 10 个聚类。使用 scikit-learn 中的同质性得分 (mng.bz/nYQV)来评估这些聚类。哪种方法表现最好:原始图像上的 k-means 还是编码表示上的 k-means? -
使用去噪方法实现去噪卷积网络。这可以通过不执行任何池化操作来实现,这样输入的大小保持不变。
-
有时人们会在编码器和解码器之间共享权重的情况下训练深度自动编码器。尝试实现一个使用
TransposeLinear层的所有解码器层的深度瓶颈自动编码器。当只有 n=1,024、8,192、32,768 和 MNIST 的所有 60,000 个样本时,比较共享权重与不共享权重的网络。 -
挑战性任务: 为 MNIST 训练一个非对称去噪自编码器,其中编码器是一个全连接网络,解码器是一个卷积网络。提示: 您需要在编码器末尾添加一个
View层,该层将形状从 (B,D) 改变为 (B,C,28,28),其中 D 是编码器中最后一个nn.LinearLayer的神经元数量,D = C ⋅ 28 ⋅ 28。这个网络的结果比章节中的全连接网络好还是差,您认为架构的混合如何影响这个结果? -
挑战性任务: 将 MNIST 数据集重塑为像素序列,并在像素上训练一个自回归模型。这需要使用实值输入和输出,因此您将不会使用
nn.Embedding层,并且您需要切换到 MSE 损失函数。训练后,尝试从这个自回归像素模型生成多个数字。 -
将自回归模型中的
GRUCell转换为LSTMCell,并训练一个新的模型。你认为哪个模型的生成输出更好? -
AutoRegressiveDataset可以从句子中间开始输入,因为它天真地抓取输入的子序列。编写一个新版本,它只在新行的开始处(即换行符‘\n’之后)选择序列的开始,然后返回下一个max_chunk个字符(如果块之间有重叠是可以的)。在这个数据集的新版本上训练一个模型。你认为这会改变生成输出的特征吗? -
在对句子进行自回归模型训练后,使用
LastTimeStep类提取代表每个句子的特征向量。然后将这些向量输入到您喜欢的聚类算法中,看看您是否可以找到任何具有相似风格或类型的句子组。注意: 您可能需要子采样更少的句子以使聚类算法运行更快。
摘要
-
自我监督是一种通过使用输入的一部分 作为我们试图预测的标签 来训练神经网络的方法。
-
自我监督被认为是无监督的,因为它可以应用于任何数据,并且不需要任何过程或人类手动标记数据。
-
自编码是自我监督最受欢迎的形式之一。它通过让网络预测输入作为输出,但以某种方式约束网络,使其不能天真地返回原始输入。
-
两种流行的约束是瓶颈设计,它强制维度在扩展回之前缩小,以及去噪方法,其中在输入被提供给网络之前改变输入,但网络仍然必须预测未改变输出。
-
如果我们有一个序列问题,我们可以使用自回归模型,这些模型查看序列中的每个先前输入来预测下一个输入。
-
自回归方法的好处是它是 生成性的,这意味着我们可以从模型中创建合成数据!
¹ 潜在通常指的是那些(不易)可见但确实存在且/或可以增强的事物。例如,潜在感染存在但没有症状,所以你看不见它。但如果不治疗,它将出现并变得更加明显。在我们这个语境中,“潜在”幸运地并没有那么可怕。↩
² 技术上,这意味着我们不是在学习 PCA 算法——但我们正在学习与它非常相似的东西。足够接近就是好的。↩
³ E. Raff, “神经指纹增强”,在 第 17 届 IEEE 国际机器学习与应用会议(ICMLA),2018 年,第 118–124 页,https://doi.org/10.1109/ICMLA.2018.00025。↩
⁴ 如果你想要通过文本数据最大化你的结果,你应该查找“主题建模”算法。有深度主题模型,但它们超出了我们在这本书中涵盖的范围。话虽如此,我见过有人用这种自回归方法取得了相当的成功,这种方法比主题模型更灵活,更适合新的情况和数据类型。↩
⁵ 这是对文本生成的一个很好的默认设置。其他任务可能需要你调整温度以选择更好的值。↩
8 目标检测
本章涵盖
-
对每个像素进行预测
-
与图像分割一起工作
-
使用转置卷积放大图像
-
使用 Faster R-CNN 进行目标检测的边界框
-
过滤结果以减少误报
想象一下:你想要构建一个系统,用来统计公园里不同种类的鸟。你将相机对准天空,对于照片中的每只鸟,你都想知道它的物种名称。但是,如果图片中没有鸟?或者只有 1 只?或者 12 只?为了适应这些情况,你需要首先检测图像中的每只鸟,然后对检测到的每只鸟进行分类。这个两步过程被称为目标检测,它有多种形式。广义上,它们都涉及识别图像的子组件。因此,系统不是对每张图像生成一个预测,这是我们迄今为止模型所做的那样,而是从单张图像生成多个预测。
即使通过数据增强、更好的优化器和残差网络进行改进,我们之前构建的图像分类模型都假设图像是期望的类别。通过这种方式,我们的意思是图像内容与训练数据匹配。例如,我们的 MNIST 模型假设图像总是包含一个数字,而这个数字是 0 到 9 之间的一个。我们的普通 CNN 模型根本不具备图像可能是空的或包含多个数字的概念。为了处理这些情况,我们的模型需要能够检测单张图像中包含的内容,以及这些内容在图像中的位置。目标检测就是解决这个问题的方法。
在本章中,我们将学习两种用于目标检测任务的方法。首先,我们深入了解图像分割,这是一种成本较高但较为简单的方法。类似于第七章中自回归模型对序列中的每个项目进行预测,图像分割对图像中的每个像素进行预测。图像分割成本较高,因为你需要有人为图像中的每个单独像素进行标注。这种成本往往可以通过结果的有效性、实现分割模型的可操作性以及需要这种程度细节的应用来证明其合理性。为了提高图像分割,我们学习了转置卷积操作,它允许我们撤销池化操作的缩小效果,这样我们就可以获得池化的好处,同时仍然可以在每个像素上进行预测。有了这个,我们构建了一个名为U-Net的基础架构,它已成为图像分割的事实上的设计方法。
本章的后半部分超越了逐像素预测,转向了可变数量的预测。这是通过一个不太精确但标注成本也较低的选择实现的:基于边界框的对象检测。边界框标签是刚好足够大的框,可以捕捉到图像中整个对象。这更容易标注:你只需要点击并拖动一个框(有时可能比较粗糙)围绕对象。但是,有效的对象检测模型难以实现且训练成本高昂。由于从头开始实现对象检测非常具有挑战性,我们学习了 PyTorch 内置的基于区域提议的检测器。区域提议方法被广泛使用且易于获取,因此我们涵盖了其如何工作的细节,这些细节可以推广到其他方法。我们将跳过那些使特定实现变得复杂的细节,但如果你想了解更多,我会提供参考。
8.1 图像分割
图像分割是寻找图像中对象的一种简单方法。图像分割是一个分类问题,但与我们对 MNIST 所做的方法不同,我们是对每个像素进行分类。因此,一个 200 × 200 的图像将有 200 × 200 = 40,000 次分类。图像分割任务中的类别通常是我们可以检测到的不同类型的对象。例如,图 8.1 中的图像有一个马、一个人和一些汽车作为类别对象。

图 8.1 展示了来自 PASCAL VOC 2012 数据集的输入图像(左侧)和分割后的真实标签(右侧)。每个像素都被赋予了一个类别,对于不属于标记对象的像素,默认为“无类别”或“背景”类别。
目标是生成真实标签,其中每个像素都被分类为属于人、马、汽车或背景。我们有一个具有四个独特类别的分类问题;碰巧单个输入涉及到做出许多预测。因此,如果我们有一个 128 × 128 的图像,我们就需要进行 128² = 16,384 次分类。
如果我们能够成功分割图像,那么我们可以执行对象检测。对于图 8.1,我们可以找到连接在一起的粉红色像素块(person blob),以确定图像中存在人,以及他们在图像中的位置。分割也可以是目的本身。例如,医学¹医生可能想要识别细胞活检图像中肿瘤细胞的百分比,以确定某人的癌症是如何进展的(比之前更大的百分比意味着它在增长并变得更糟;更小的百分比意味着癌症正在缩小,治疗正在起作用)。在医学研究中,另一个常见的任务是手动标记图像中不同类型细胞的数量,并使用细胞类型的相对数量来确定患者的整体健康状况或其他医疗属性。在这些任务中,我们不仅想知道对象在哪里:我们还想知道它们在图像中的确切大小以及它们之间的相对比例。
图像分割的任务是使用卷积神经网络的一个绝佳机会。卷积神经网络(CNN)被设计用来对输入的局部区域产生局部输出。因此,我们设计图像分割网络的战略将使用卷积层作为输出层,而不是像之前那样使用nn.Linear层。当我们设计一个只包含卷积层(以及非线性层和归一化层)的网络时,我们称这样的网络为全卷积网络。²
8.1.1 核检测:加载数据
对于我们对图像分割的第一次介绍,我们将使用 2018 年数据科学 Bowl(www.kaggle.com/c/data-science-bowl-2018)的数据。这个竞赛的目标是检测细胞核及其相应的大小。但我们现在只是对图像进行分割。我们将下载这个数据集,为这个问题设置一个Dataset类,然后指定一个全卷积网络来对整个图像进行预测。当你下载数据并解压时,所有文件都应该在 stage1/train 文件夹中。
数据被组织成多个路径。所以如果我们有路径/文件夹 data0,那么在路径 data0/images/some_file_name.png 中可以找到显微镜下的细胞图像。对于图像中的每一个细胞核,在 data0/masks/name_i.png 下都有一个文件。Dataset对象加载这些文件,这样我们就可以开始工作了。为了简化当前的工作,我们在Dataset类中进行了某些归一化和准备。我们移除了这些图像附带的一个 alpha 通道(通常用于透明图像),重新排列通道,使其成为 PyTorch 喜欢的第一个维度,并计算每个输入的标签。我们准备这些数据的方式总结在图 8.2 中。

图 8.2 这是我们处理数据科学 Bowl 数据以进行图像分割的方式。根目录下的每个文件夹都有几个子文件夹:一个包含一个图像的 images 文件夹(我知道,有点混乱)和一个包含每个核的二进制 mask 的 masks 文件夹。mask 被分开用于对象检测(我们稍后会讨论),因此我们将它们全部合并到一个图像中,该图像指示 mask 的位置(类别 1)和不存在的位置(类别 0)。
子目录中的所有图像尺寸完全相同,如图 8.2 所示,但它们在不同目录之间的大小不同。因此,为了简化问题,我们将所有内容调整到 256 × 256。这样,我们就不必担心填充图像以使它们具有相同的大小。为了构建标签,我们希望有一个形状相同的图像,其中 0 表示无类别或 1 表示存在核。我们可以通过将每个图像转换为名为mask的二进制值数组来实现这一点,其中 1 = True = 核存在。然后我们可以在 mask 上执行逻辑or操作,以获得一个最终 mask,其中每个核存在的像素都有一个 1。
以下代码是 2018 年数据科学 Bowl 数据集的类。我们的Dataset类遍历每个 mask 并将它们or在一起,以便我们得到一个显示包含所有对象的像素的单个 mask。这是在__getitem__中完成的,它返回一个包含输入图像和我们要预测的 mask(即包含核的所有像素)的元组:
class DSB2018(Dataset):
"""Dataset class for the 2018 Data Science Bowl."""
def __init__(self, paths):
"""paths: a list of paths to every image folder in the dataset"""
self.paths = paths
def __len__(self):
return len(self.paths)
def __getitem__(self, idx):
img_path = glob(self.paths[idx] + "/images/*")[0] ❶
mask_imgs = glob(self.paths[idx] + "/masks/*") ❷
img = imread(img_path)[:,:,0:3] ❸
img = np.moveaxis(img, -1, 0) ❹
img = img/255.0 ❺
a _different_ nuclei/ masks = [imread(f)/255.0 ❻
for f in mask_imgs]
final_mask = np.zeros(masks[0].shape) ❼
for m in masks: final_mask = np.logical_or(final_mask, m) final_mask =
final_mask.astype(np.float32)
img, final_mask = torch.tensor(img),
torch.tensor(final_mask).unsqueeze(0) ❽
img = F.interpolate(img.unsqueeze(0), (256, 256)) ❾
final_mask = F.interpolate(final_mask.unsqueeze(0), (256, 256))
return img.type(torch.FloatTensor)[0], ❿
final_mask.type(torch.FloatTensor)[0]
❶ 每个 images 路径中只有一个图像,所以我们通过[0]在末尾找到我们找到的第一个东西。
❷ 但每个 mask 路径中都有多个 mask 图像。
❸ 图像形状为(W, H, 4)。最后一个维度是一个未使用的 alpha 通道。我们剪掉 alpha 以获得(W, H, 3)。
❹ 我们希望它是(3, W, H),这是 PyTorch 的正常形状。
❺ 图像的最后一步:将其缩放到范围[0, 1]。
❻ 每个 mask 图像的形状为(W, H),如果像素是核,则其值为 1,如果图像是背景,则其值为 0。
❼ 由于我们只想进行简单的分割,我们创建一个包含每个 mask 中所有核像素的最终 mask。
❽ 数据集中的每个图像大小并不相同。为了简化问题,我们将每个图像调整大小为(256, 256)。首先,我们将它们转换为 PyTorch 张量。
❾ 可以使用 interpolate 函数来调整一批图像的大小。我们将每个图像变成一个“批次”1。
❿ 形状为(B=1, C, W, H)。我们需要将它们转换回 FloatTensors 并获取批次中的第一个项目。这将返回一个包含(3, 256, 256),(1, 256, 256)的元组。
8.1.2 在 PyTorch 中表示图像分割问题
现在我们已经可以加载数据集了,让我们可视化一些数据。语料库包含来自各种来源的细胞图像:一些看起来像是黑白图像,而另一些则使用了染料而具有颜色。下面的代码块加载数据,并在左侧显示原始图像,在右侧显示显示所有核精确位置的 mask:
dsb_data = DSB2018(paths) ❶
plt.figure(figsize=(16,10))
plt.subplot(1, 2, 1) ❷
plt.imshow(dsb_data[0][0].permute(1,2,0).numpy())
plt.subplot(1, 2, 2) ❸
plt.imshow(dsb_data[0][1].numpy()[0,:], cmap=’gray’)
[7]: <matplotlib.image.AxesImage at 0x7fd24a8a5350>
❶ 创建 Dataset 类对象
❷ 绘制原始图像
❸ 绘制掩码

plt.figure(figsize=(16,10)) ❶ plt.subplot(1, 2, 1)
plt.imshow(dsb_data[1][0].permute(1,2,0).numpy())
plt.subplot(1, 2, 2)
plt.imshow(dsb_data[1][1].numpy()[0,:], cmap=’gray’)
[8]: <matplotlib.image.AxesImage at 0x7fd24a7eb6d0>
❶ 让我们绘制一个彩色的第二张图像。

如我们所见,存在许多类型的输入图像切片。有些有很多细胞核,有些很少,细胞核可以彼此靠近或相隔很远。让我们快速创建训练和测试分割,使用较小的 16 图像批次大小来工作。我们使用较小的批次,因为这些图像较大——256 × 256 而不是仅仅 28 × 28——并且我想确保即使 Colab 给你较小的实例,批次也能适合你的 GPU:
train_split, test_split = torch.utils.data.random_split(dsb_data,
➥ [500, len(dsb_data)-500])
train_seg_loader = DataLoader(train_split, batch_size=16, shuffle=True)
test_seg_loader = DataLoader(test_split, batch_size=16)
由于这些是彩色图像,我们使用 C = 3 个通道作为输入:红色、绿色和蓝色。我随意选择了 32 个过滤器用于我们的卷积层。以下代码中的最后一个设置项是使用 BCEWithLogitLoss 而不是 CrossEntropyLoss。名称中的 BCE 部分代表 二元 交叉熵。它是 CrossEntropyLoss 的一个特殊版本,仅适用于双类问题。因为我们 知道 只有两个类别(细胞核和背景),所以我们的网络输出可以是一个像素一个神经元,用于是/否风格的预测。如果我们使用 CrossEntropyLoss,我们每个像素就需要两个输出,这对我们的代码来说有点难看:
C = 3 ❶
n_filters = 32 ❷
loss_func = nn.BCEWithLogitsLoss() ❸
❶ 输入中有多少个通道?
❷ 通常应该考虑的最小过滤器值。如果我们想尝试优化架构,我们可以使用 Optuna 来选择更好的过滤器数量。
❸ BCE 损失隐式地假设了一个二元问题。
注意:当你只有两个类别时,使用 BCEWithLogitLoss 和 CrossEntropyLoss 的二元交叉熵会收敛到相同的结果。它们在 数学上等价,所以选择是编码偏好。我更喜欢在双类问题上使用 BCEWithLogitLoss,因为它让我一看到损失函数就清楚我在处理二元输出/预测,这让我对问题有更多的了解。一般来说,给你的类别命名并以告诉你在代码中发生什么的方式编写代码是好的。在某个时候,你将不得不回过头来看你以前写的旧代码,这些细节将帮助你记住发生了什么。
8.1.3 构建我们的第一个图像分割网络
因为我们需要对每个像素进行预测,所以我们的网络输出 f(⋅) 必须具有与原始输入相同的高度和宽度。因此,如果我们的输入是 (B, C, W, H),我们的输出需要是 (B, class, W, H)。通道数可以根据类别的数量而改变。一般来说,我们将为每个可以预测输入的类别有一个通道。在这种情况下,我们有两个类别,因此我们可以使用一个输出通道与二元交叉熵损失。因此,我们有一个形状为 (B, 1, W, H) 的输出。如果我们以只有一个滤波器的卷积层结束我们的网络,我们的模型最终输出将只有一个通道。因此,我们使用卷积层作为最后一层。
保持相同的 W 和 H 值的最简单方法是永远不使用池化,并且始终使用填充,以便输出与输入大小相同。记住,从第三章中,使用大小为 k 的滤波器意味着设置填充 = ⌊k/2⌋ 将确保输出的高度和宽度与输入相同。我们也在定义我们的网络时使用这个约束。
以下代码将这两个选择结合到一个简单的神经网络中。它遵循我们通常的模式,即重复卷积、归一化和非线性:
def cnnLayer(in_filters, out_filters, kernel_size=3): ❶
"""
in_filters: how many channels are in the input to this layer
out_filters: how many channels should this layer output
kernel_size: how large should the filters of this layer be
"""
padding = kernel_size//2
return nn.Sequential(
nn.Conv2d(in_filters, out_filters, kernel_size, padding=padding),
nn.BatchNorm2d(out_filters),
nn.LeakyReLU(), ❷
)
segmentation_model = nn.Sequential( ❸
cnnLayer(C, n_filters), ❹
*[cnnLayer(n_filters, n_filters) for _ in range(5)], ❺
nn.Conv2d(n_filters, 1, (3,3), padding=1), ❻
)
seg_results = train_network(segmentation_model,
➥ loss_func, train_seg_loader, epochs=10,
➥ device=device, val_loader=test_seg_loader) ❼
❶ 定义我们的辅助函数,该函数为 CNN 创建一个隐藏层
❷ 我们没有设置泄漏值,以使代码更短。
❸ 指定一个用于图像分割的模型
❹ 第一层将通道数更改为大数。
❺ 创建了五个额外的隐藏层
❻ 对每个位置进行预测。我们使用一个通道,因为我们有一个二元问题,并且使用 BCEWithLogitsLoss 作为我们的损失函数。现在的形状是 (1, W, H)。
❷ 训练分割模型
现在我们已经训练了一个模型,让我们直观地检查一些结果。以下代码显示了如何从测试数据集中抓取一个项目,将其通过模型传递,并得到一个预测。由于我们使用二元交叉熵损失,我们需要使用 torch.sigmoid (σ) 函数将原始输出(也称为 logits)转换为正确的形式。记住,sigmoid 将所有内容映射到 [0,1] 的范围内,因此阈值为 0.5 告诉我们是否应该选择“存在核”或“不存在”的最终答案。然后我们可以绘制结果,显示图像的原始输入(左)、真实值(中)和预测(右):
index = 6 ❶
with torch.no_grad(): ❷
logits = segmentation_model(test_split[index][0]. ❸
➥ unsqueeze(0).to(device))[0].cpu()
pred = torch.sigmoid(logits) >= 0.5 ❹
plt.figure(figsize=(16,10)) ❺
plt.subplot(1, 3, 1)
plt.imshow(test_split[index][0].permute(11,2,0).numpy(), ❻
➥ cmap=’gray’)
plt.subplot(1, 3, 2)
plt.imshow(test_split[index][1].numpy()[0,:], cmap=’gray’) ❼
plt.subplot(1, 3, 3) plt.imshow(pred.numpy()[0,:], cmap=’gray’) ❽
[12]: Text(-240, -50, 'Error: Phantom object')
❶ 从数据集中选择一个特定示例,以显示特定结果。将其更改为查看数据集的其他条目。
❷ 如果我们不进行训练,我们不希望有梯度,所以请不要梯度!
❸ 将测试数据点推入模型。记住,原始输出称为 logits。
❹ 将 σ 应用到 logits 以进行预测,然后应用阈值以获得预测掩码。
❺ 绘制输入、真实值和预测。
❻ 首先绘制网络的原始输入。
❷ 第二是真实值。
❽ 第三是网络做出的预测。

总体来说,结果非常好。我们甚至正确地处理了大部分字面意义上的边缘情况(图像边界的核)。出现在图像边缘的对象通常更难正确预测。但也有一些错误:对于非常大的核,我们的分割模型在错误的位置放置了一个不属于那里的洞。我们还检测到了一些不存在的核。我用红色箭头标注了输出结果以突出这些错误。
部分问题可能在于我们网络的感受野太小,无法准确处理大型核。对于每一层的卷积,最大范围增加⌈k/2⌉。由于我们有六个卷积层,这仅使我们获得 2⋅6=12 像素的宽度。虽然简单的方法是添加更多层或增加卷积的宽度,但这些可能会变得昂贵。其中一个原因可能是因为我们从未进行过任何池化,所以每次我们添加一层或加倍滤波器的数量,我们都会增加我们方法使用的总内存。
8.2 扩展图像大小的转置卷积
我们更倾向于以某种方式使用池化,以便我们获得较小输出(更少内存)和较大感受野的好处,然后稍后扩展回更大的形式。我们可以通过所谓的转置卷积来实现这一点。在正常卷积中,一个输出的值由多个输入决定。因为每个输出有多个输入,所以输出小于输入,因此每个输出都得到其全部贡献。关于转置卷积的一个简单思考方式是想象一个输入对多个输出做出贡献。这如图 8.3 所示,应用于一个小 2×2 图像。因为转置版本有一个输入对应多个输出,所以我们需要使输出大于原始输入,以便每个输入贡献的输出都得到表示。

图 8.3 转置卷积的逐步计算。左侧的绿色区域显示了正在使用的输入部分,右侧的橙色显示了正在改变的输出部分。在每一步中,输入乘以滤波器并加到给定位置的输出上。因为输入按滤波器的大小扩展,所以输出的尺寸大于输入。
图 8.4 展示了顶部卷积和底部转置卷积的示例。两者都在各自的模式中使用相同的图像和滤波器。就像常规卷积一样,转置版本将每个位置的贡献相加以达到最终值。因此,请注意,用红色虚线框显示的内部区域在常规和转置卷积之间有相同的结果。区别在于我们如何解释边界情况:常规卷积缩小,而转置卷积扩展。每个转置卷积都有一个等效的常规卷积,只是改变了应用的一些填充和其他参数。

图 8.4 展示了在相同输入图像和相同滤波器作用下,常规卷积(顶部)和转置卷积(底部)的示例。常规卷积通过滤波器的大小缩小输出,而转置版本通过内核的大小扩展输出。
这里需要记住的重要一点是,转置卷积为我们提供了一种扩大尺寸的方法。特别是,我们可以添加一个步长来产生加倍效果,以抵消池化引起的减半效果。步长是在应用卷积时我们的滤波器滑动的距离。默认情况下,我们使用步长s = 1,这意味着我们每次滑动滤波器一个位置。图 8.5 展示了当我们使用步长s = 2 时会发生什么。常规卷积在输入上以 2 的步长前进,而转置卷积在输出上以 2 的步长前进。因此,步长 2 的卷积减半了尺寸,而步长 2 的转置卷积加倍了尺寸。

图 8.5 展示了步长s = 2 如何影响常规和转置卷积。阴影区域显示了输入/输出映射。对于卷积,输入移动滤波器两个位置,使输出更小。转置卷积仍然使用每个输入位置,但输出移动两个位置。
我们将转置卷积纳入架构的方式是,每隔几层,我们会进行一次池化。如果我们在一个 2×2 的网格中进行池化(标准做法),我们将模型的感受野宽度加倍。每次我们进行池化,我们最终会看到一个更高层次的图像视图。当我们达到网络的一半时,我们开始执行转置卷积以恢复到正确的尺寸。转置卷积之后的层给模型一个机会来细化高层次视图。类似于我们如何在上一个章节中设计我们的自编码器,我们使池化和转置轮次对称。
8.2.1 实现带有转置卷积的网络
使用转置卷积,我们可以扩展网络的输出,这意味着我们可以使用池化,然后在后面撤销宽度和高度的减少。让我们试一试,看看这能为我们的模型提供任何实际的价值。为了保持这些示例小且运行快速,我们只进行了一轮池化和转置卷积;但如果你做得更多,你可能会看到更好的结果。以下代码重新定义了我们的网络,包含一轮最大池化和随后的一轮转置卷积:
segmentation_model2 = nn.Sequential(
cnnLayer(C, n_filters), ❶
cnnLayer(n_filters, n_filters),
nn.MaxPool2d(2), ❷
cnnLayer(n_filters, 2*n_filters),
cnnLayer(2*n_filters, 2*n_filters),
cnnLayer(2*n_filters, 2*n_filters),
nn.ConvTranspose2d(2*n_filters, n_filters, (3,3), ❸
padding=1, output_padding=1, stride=2),
nn.BatchNorm2d(n_filters),
nn.LeakyReLU(),
cnnLayer(n_filters, n_filters), ❹
nn.Conv2d(n_filters, 1, (3,3), padding=1), ❺
)
seg_results2 = train_network(segmentation_model2, loss_func,
➥ train_seg_loader, epochs=10, device=device, val_loader=test_seg_loader)
❶ 第一层将通道数增加到大量。
❷ 缩小高度和宽度各 2
❸ 将高度和宽度加倍,抵消单个 MaxPool2d 的效果
❹ 回到正常的卷积
❺ 每个位置的预测。形状现在是(B,1,W,H)。
现在我们已经训练了这个新模型,让我们用相同的数据来测试一下看看会发生什么:
index = 6 ❶
with torch.no_grad(): ❷
pred = segmentation_model2(test_split[index][0]. ❸
unsqueeze(0).to(device))[0].cpu()
pred = torch.sigmoid(pred) >= 0.5 ❹
plt.figure(figsize=(16,10)) ❺
plt.subplot(1, 3, 1)
plt.imshow(test_split[index][0].permute(1,2,0).numpy(), cmap=’gray’) ❻
plt.subplot(1, 3, 2)
plt.imshow(test_split[index][1].numpy()[0,:], cmap=’gray’) ❼
plt.subplot(1, 3, 3)
plt.imshow(pred.numpy()[0,:], cmap=’gray’) ❽
[15]: <matplotlib.image.AxesImage at 0x7fd24804e1d0>
❶ 与之前相同的示例
❷ 如果我们不进行训练,我们不想有梯度,所以请不要梯度!
❸ 将测试数据点推过模型。原始输出称为 logits。
❹ 将σ应用于 logits 以进行预测,然后应用阈值以获得预测掩码
❺ 绘制输入、真实值和预测值
❻ 首先绘制网络的原输入
❼ 第二个是真实值
❽ 第三个是我们网络做出的预测

漏洞已经修补;核对象检测显示了漂亮的白色区域。网络在处理一些边缘情况时也做得稍微好一些。在较小的表示(池化后的轮次)上工作有助于鼓励输出中更柔和、更平滑的掩码。但我们绝不能只看一张图片就决定我们是否有所改进,所以让我们检查验证损失:
sns.lineplot(x=’epoch’, y=’val loss’, data=seg_results, label=’CNN’)
sns.lineplot(x=’epoch’, y=’val loss’, data=seg_results2,
➥ label=’CNN w/ transposed-conv’)
[17]: <AxesSubplot:xlabel='epoch', ylabel='val loss'>

根据验证误差,我们总体上比之前做得稍微好一些。同样重要的是学习速度,我们可以看到这种方法能够在更少的训练轮次中更快地取得进展。这种更快的学习是一个重要的好处,随着我们处理更难、更大的问题,其重要性也在增长。
8.3 U-Net:观察细粒度和粗粒度细节
目前,我们有两种方式来建模图像分割问题。第一种方法,来自第 8.1 节,没有使用池化,执行了许多轮卷积层。这使得一个模型可以观察微小的细粒度细节,但可能会真的错过森林中的树木。
第二种,来自第 8.2 节,使用多轮最大池化,然后在架构的末尾使用转置卷积层。你可以将这种方法视为逐步查看图像的高级区域。你进行的池化轮数越多,模型在做出决策时考虑的级别就越高。
最大池化/上采样对于检测较大的物体和宽边界效果很好,而细粒度模型对于小物体和细微的物体边界效果更好。我们希望有一种方法来获得两者的最佳效果,同时捕捉到细部和高级事物。
我们可以通过将跳过连接(来自第六章)包含到我们的方法中来实现这种两全其美的效果。³这样做创建了一种称为U-Net的架构方法,其中我们创建了三个子网络来处理输入:
-
一个输入子网络,它将隐藏层应用于全分辨率(最低级别特征)输入。
-
一个瓶颈子网络,它在最大池化后应用,允许它观察较低分辨率的(高级特征),然后使用转置卷积将其结果扩展回与原始输入相同的宽度和高度。
-
一个输出子网络,它结合了两个先前网络的结果。这使得它能够同时观察低级和高级细节。
图 8.6 展示了 U-Net 风格方法的一个单独块。

图 8.6 展示了 U-Net 块的架构设计,它分为三个子网络。每个子网络都有多个卷积隐藏层。第一个子网络的结果被发送到两个位置:第二个瓶颈子网络(在经过最大池化后),以及与第二个子网络的结果结合后的第三个子网络。
将其扩展为更大的 U-Net 架构是通过反复将瓶颈子网络变成另一个U-Net 块来实现的。这样,你得到一个能够同时学习观察多个不同分辨率的网络。当你绘制将 U-Net 块插入到 U-Net 块中的图时,你最终得到一个如图 8.7 所示的 U 形。此图还显示,每次我们将分辨率缩小 2 倍时,我们倾向于将滤波器的数量增加 2 倍。这样,网络的每个级别都有大致相当的工作量和计算量。

图 8.7 展示了 U-Net 风格架构的示例。经过几轮卷积后,使用最大池化将图像缩小几次。最终,转置卷积将结果上采样,并且每次上采样都包括一个在池化之前连接到先前结果的跳过连接。结果被连接在一起,并进入新的卷积输出轮。这种架构形成了一个 U 形。
图 8.7 显示了每个输入/输出对一组 Conv2d、BatchNorm 和 ReLu 激活,但你可以有任意数量的隐藏层块。虽然 U-Net 既指一个特定的架构,也指一种架构风格,但我会总体上指这种风格。在下一节中,我们将定义一些实现 U-Net 风格模型的代码。
8.3.1 实现 U-Net
为了使我们的实现更简单,我们接受in_channels的数量,并使用mid_channels作为卷积中应使用的滤波器数量。如果我们希望输出具有不同的通道数,我们使用 1 × 1 卷积将mid_channels转换为out_channels。由于每个块可以有多个layers,我们也将它作为一个参数。最后,我们需要的是用作瓶颈的sub_network。因此,我们的构造函数文档如下:
class UNetBlock2d(nn.Module): ❶
def __init__(self, in_channels, mid_channels, out_channels=None,
➥ layers=1, sub_network=None, filter_size=3):
"""
in_channels: the number of channels in the input to this block
mid_channels: the number of channels to have as the output for each
➥ convolutional filter
out_channels: if not ‘None‘, ends the network with a 1x1
➥ convolution to convert the number of output channels to a
➥ specific number.
layers: how many blocks of hidden layers to create on both the
➥ input and output side of a U-Net block
sub_network: the network to apply after shrinking the input by a
➥ factor of 2 using max pooling. The number of output channels
➥ should be equal to ‘mid_channels‘
filter_size: how large the convolutional filters should be
"""
super().__init__()
❶ 我们的这个类扩展了 nn.Module;所有 PyTorch 层都必须扩展这个。
现在让我们逐步了解构造函数的内容。一个块(步骤 1)的输入将始终具有形状 (B,in_channels,W,H) 并产生形状(B,mid_channels,W,H)。但步骤 3 的输出部分将有两个可能的形状:要么是 (B,2⋅mid_channels,W,H),因为它结合了步骤 1 和步骤 2 的结果,使其通道数增加 2 倍;要么是 (B,mid_channels,W,H) 如果没有瓶颈。因此,我们需要检查是否有sub_network,并相应地更改输出块的输入数量。一旦完成,我们就可以为步骤 1 和步骤 3 构建隐藏层。对于步骤 2,我们使用子网络self.bottleneck来表示在应用nn.MaxPool2d后对图像的缩小版本上应用的模型。下一块代码显示了所有这些,并将步骤 1 组织到self.in_model中,步骤 2 组织到self.bottleneck中,步骤 3 组织到out_model中:
in_layers = [cnnLayer(in_channels, mid_channels, filter_size)] ❶
if sub_network is None: ❷
inputs_to_outputs = 1
else:
inputs_to_outputs = 2
out_layers = [cnnLayer(mid_channels*inputs_to_outputs,
➥ mid_channels, filter_size)] ❸
for _ in range(layers-1): ❹
in_layers.append(cnnLayer(mid_channels, mid_channels, filter_size))
out_layers.append(cnnLayer(m
id_channels, mid_channels, filter_size))
if out_channels is not None: ❺
out_layers.append(nn.Conv2d(mid_channels, out_channels, 1, padding=0))
self.in_model = nn.Sequential(*in_layers) ❻
if sub_network is not None: ❼
self.bottleneck = nn.Sequential(
nn.MaxPool2d(2), ❽
sub_network, ❾
nn.ConvTranspose2d(mid_channels, mid_channels, ❿
➥ filter_size, padding=filter_size//2,
➥ output_padding=1, stride=2)
)
else:
self.bottleneck = None
self.out_model = nn.Sequential(*out_layers) ⓫
❶ 开始准备用于处理输入的层
❷ 如果我们有子网络,我们将输出输入的数量加倍。现在让我们来解决这个问题。
❸ 准备用于制作最终输出的层,该输出包含来自任何子网络的额外输入通道
❹ 创建用于输入和输出的额外隐藏层
❺ 使用 1 × 1 卷积确保特定的输出大小
❻ 定义我们的三个总子网络。1) in_model 执行初始的卷积轮次。
❼ 2) 我们的子网络作用于最大池化结果。我们将池化和上采样直接添加到子模型中。
❽ 缩小
❾ 处理较小的分辨率
❿ 向上扩展
⓫ 3) 处理连接结果的输出模型,或者如果没有给出子网络,则直接从 in_model 获取输出
这样就消除了所有硬编码。最后一步是实现使用它的forward函数。通过将所有部分组织成不同的nn.Sequential对象,这一步相当简单。我们通过应用in_model于输入x来获取全尺寸结果。接下来,我们检查是否有bottleneck,如果有,我们应用它并将结果与全尺寸结果连接。最后,我们应用out_model:
def forward(self, x):
full_scale_result = self.in_model(x) ❶
if self.bottleneck is not None: ❷
bottle_result = self.bottleneck(full_scale_result) ❸
full_scale_result = torch.cat( ❹
[full_scale_result, bottle_result], dim=1)
return self.out_model(full_scale_result) ❺
❶ 计算当前尺度的卷积。(B, C, W, H)
❷ 检查是否有瓶颈需要应用
❸ (B, C, W, H) 形状,因为瓶颈同时进行池化和扩展
❹ 形状 (B, 2*C, W, H)
❺ 计算连接(或未连接!)结果的输出
这为我们提供了一个由UNetBlock2d类表示的单个 U-Net 块。通过这个单一的UNetBlock2d模块,我们可以通过指定sub_network本身也是一个UNetBlock2d来实现整个 U-Net 架构。然后我们可以根据需要重复这个过程。以下代码将三个UNetBlock2d嵌套在一起,随后进行一次卷积,以达到我们期望的输出大小:
unet_model = nn.Sequential(
UNetBlock2d(3, 32, layers=2, sub_network=
UNetBlock2d(32, 64, out_channels=32, layers=2, sub_network=
UNetBlock2d(64, 128, out_channels=64, layers=2)
),
),
nn.Conv2d(32, 1, (3,3), padding=1), ❶
)
unet_results = train_network(unet_model, loss_func, train_seg_loader,
➥ epochs=10, device=device, val_loader=test_seg_loader)
❶ 形状现在是 (B, 1, W, H)
现在我们已经训练了这个模型,让我们将结果与我们的前两个分割模型进行比较。注意,U-Net 方法是两者的最佳结合,既提供了更低的总体损失,又比之前精细或粗粒度的模型学习得更快。U-Net 比其他方法更快地收敛到相同的或更好的精度。它还很有用,因为我们不必猜测确切需要使用多少层池化。我们可以简单地选择比我们认为必要的稍微多一点的池化(U-Net 块)次数,然后让 U-Net 自己学习是否应该使用低分辨率的结果。这是可能的,因为 U-Net 通过每个块的拼接和输出子网络保持了每个分辨率级别的信息:
sns.lineplot(x=’epoch’, y=’val loss’, data=seg_results, label=’CNN’)
sns.lineplot(x=’epoch’, y=’val loss’, data=seg_results2,
➥ label=’CNN w/ transposed-conv’)
sns.lineplot(x=’epoch’, y=’val loss’, data=unet_results, label=’UNet’)
[20]: <AxesSubplot:xlabel='epoch', ylabel='val loss'>

U-Net 方法对于任何图像分割问题或任何需要在对图像内的多个点进行预测的相关任务都是一个强大的起点。它也是我们之前已经学习的一些相似概念的重复:结合跳跃连接和 1 × 1 卷积使我们能够构建一个更具有表现力和强大的模型。这也展示了我们如何调整这些概念,将某些先验应用到我们正在处理的数据中。我们相信我们想要小的局部细节和更粗糙的高级细节来做出更好的决策,因此我们使用了跳跃连接和转置卷积将这个先验嵌入到我们架构的设计中。学会识别这些机会并付诸实践,与几乎任何其他可能做的事情相比,将在你的结果中产生最大的差异。
8.4 使用边界框进行目标检测
图像分割在概念上很简单,即单个网络运行一次,为每个像素获取预测,但为每个像素进行标记是昂贵的。现在我们将学习一个更复杂的方法,其中多个组件协同工作,以执行基于边界框的目标检测。这种策略在一次遍历中找到对象,第二步确定特定位置存在的具体对象。这使得标记更容易,但网络在技术上更复杂。
尤其是我们要介绍一个名为 Faster R-CNN 的算法⁴,它已经成为对象检测的事实上的基准。大多数其他方法都是 Faster R-CNN 的变体。像大多数对象检测器一样,Faster R-CNN 使用 边界框 的概念进行标签和预测。图 8.8 显示了交通标志的潜在边界框标签和预测。边界框方法通常更受欢迎,因为它更便宜且更容易标记。你只需要软件来标注图像周围的框(你可以在github.com/heartexlabs/awesome-data-labeling#images上找到一些免费可用的软件),这比费力地标记每个像素所需的图像分割要容易得多。

图 8.8 以交通标志为目标,基于边界框的对象检测。绿色框表示真实值,一个刚好足够包含整个对象的框。红色框表示一个潜在的预测,它很接近但并不完全正确。水不会遵从低级交通标志的 whimsical desires。
我们希望模型通过围绕任何感兴趣的对象绘制一个框来检测对象。由于对象可能有奇怪的大小或角度,目标是框应该包围对象,因此框应该 刚好 足够大,可以容纳整个对象。我们不希望框更大,因为那样我们就可以通过标记与图像大小相同的框来作弊。我们也不希望框更小,因为那样它会错过对象的一部分,我们不希望单个对象有多个框。
让模型预测围绕对象的边界框是一个难题。我们如何表示边界框作为预测?每个像素都得到自己的边界框预测吗?这不会导致大量误报吗?我们如何高效地完成这项工作,而不需要编写大量的糟糕的for循环?对于这些问题有众多不同的方法,但我们将重点关注 Faster R-CNN。这个算法的高级策略是理解其他更复杂的对象检测器的好基础,并且默认情况下集成在 PyTorch 中。
8.4.1 Faster R-CNN
假设我们有一辆自动驾驶汽车,我们希望汽车在交通标志处停车,因为我们不希望因为开发了一辆横冲直撞的汽车而入狱。我们需要尽可能多的图像,这些图像既包含又不含交通标志,并且交通标志周围已经画了框。Faster R-CNN 是一个复杂的算法,有很多部分,但到这一点,你已经学到了足够多的知识来理解构成整体的所有部分。Faster R-CNN 通过三个步骤来解决这个问题:
-
处理图像并提取特征。
-
使用这些特征来检测潜在/提议的对象。
-
对每个潜在对象进行判断,确定它是什么对象,或者它根本就不是对象。
图 8.9 给出了 Faster R-CNN 算法的概述;我们稍后会详细介绍。我们描述的三个步骤可以分解为三个子网络:一个用于提取特征图的主干网络,一个用于寻找对象的区域建议网络(RPN),以及一个预测正在观察的对象类型的感兴趣区域池化网络(RoI 池化或简称 RoI)网络。Faster R-CNN 是对我们所学内容的扩展,因为主干网络是一个类似于我们用于图像分割的全卷积网络,除了我们让最终的卷积层输出一些C′通道而不是仅仅 1 个通道。

图 8.9 展示了 Faster R-CNN 应用于之前提到的停车标志的示意图。主干网络扫描图像,是最大的网络,承担了产生良好特征的重任,以便算法的其他部分可以使用。接下来是区域建议网络,它也是全卷积的,但非常小。它重用主干的特征图来做出预测或提出可能存在于图像中的对象的位置。最后,一个感兴趣区域网络对特征图的每个子区域进行最终判断,确定是否存在对象——如果存在,它是什么对象。
我们不会从头开始实现 Faster R-CNN,因为它包含许多重要的细节,并且总共需要数百行代码来实现。但我们将回顾所有关键组件,因为许多其他目标检测器都是基于这种方法构建的。Faster R-CNN 也内置在 PyTorch 中,因此您在使用它时不需要做太多工作。以下几节将总结主干、区域建议和感兴趣区域子网络的工作方式,按照使用的顺序。
主干网络
主干网络本质上是指任何像我们刚刚定义的对象分割网络那样工作的神经网络。它接收一个具有宽度、高度和通道数(C、W、H)的图像,并输出一个新的特征图(C′、W′、H′)。主干网络可以有不同的宽度和高度作为输出,只要输出的高度和宽度始终是输入高度和宽度的倍数(即,如果 W′ = W ⋅ z,那么 H′ = H ⋅ z,你必须保持宽度和高度之间的比例)。这些额外的细节在图 8.10 中展示。

图 8.10 主干网络接收原始图像并创建一个新的图像,即特征图。它具有多个通道 C′(用户定义的值)和新的宽度 W′ 和高度 H′。主干网络是 Faster R-CNN 中唯一的较大网络,旨在承担所有繁重的工作,以便其他网络可以更小、更快。
主干网络的目标是提取所有特征,这样其他子网络可以更小,且不需要非常复杂。这使得运行速度更快(只需运行一次一个大网络)并有助于协调另外两个子网络(它们从相同的表示开始工作)。主干网络是使用 U-Net 风格方法的一个绝佳位置,这样你可以检测并区分高级对象(例如,一辆车与一只猫不需要太多细节)以及只能通过观察更精细的低级细节来区分的相似对象(例如,不同品种的狗,如约克夏梗和澳大利亚梗)。
区域提议网络(RPN)
一旦主干网络为我们提供了一个丰富的特征表示,即(C′,W′,H′)张量,RPN 就会确定图像中特定位置是否存在物体。它是通过预测两件事来做到这一点的:
-
一个有四个位置的边界框(左上角、右上角、左下角和右下角)
-
对箱子进行二分类预测,将其归类为“有物体”或“没有物体”
在这种情况下,我们试图预测多少个类别或具体存在什么物体并不重要——唯一的目标是确定是否存在物体以及物体的位置。本质上,所有的问题类别都被合并成一个“有物体”的超类。
为了使模型更加鲁棒,我们可以将这些总共六个预测(4 个框坐标+2 个有物体/无物体)进行 k 次。⁵ 理念是在学习过程中给模型 k 次机会来预测正确的框形状。一个常见的默认值是使用k = 9 次猜测。这允许 RPN 在图像的特定位置对物体的尺寸或形状进行多次预测,并且这是大多数实现中包含的常见优化。RPN 的整体过程如图 8.11 所示。

图 8.11 进一步说明了区域提议网络(RPN)的工作原理。它从主干网络接收特征图,并在每个位置进行多次预测。它预测多个框,并且对于每个框,它预测该框是否包含物体。被预测为“物体”的框成为 RPN 的输出:原始输入中可能包含物体的区域列表。
由于我们在每个位置进行 k 次预测,我们知道会有比必要的更多误报(如果物体在那个位置,只有那 k 次预测中的一个是最接近的,其他的就变成了误报)。这也是为什么预测被称为提议而不是预测的部分原因。我们预计会有比实际物体多得多的提议,在后续的处理过程中,我们将进行更多的工作来清理这些误报。
RPN可以通过单个卷积层实现,使用nn.Conv2d(C, 6*k, 1),它会在图像的每个位置滑动并做出六个预测。这是一个使用一对一卷积进行局部预测的技巧。在实际实现中,这通常通过两层来完成,类似于以下这样:
nn.Sequential(
nn.Conv2d(C’, 256, (1,1)), ❶
nn.BatchNorm2d(256),
nn.LeakyReLU(),
nn.Conv2d(256, 6*k, (1,1)), ... ❷
)
❶ 一层用于非线性
❷ 在这里添加了一些代码来将输出分成一组四个和另一组两个。这种方法取决于实现策略。
只添加一个额外的层就给模型提供了一些非线性能力,使其能做出更好的预测。我们可以使用这样一个小的网络,因为骨干网络已经完成了繁重的工作。所以,一个小型、快速、几乎非线性的 RPN 网络在骨干网络上运行。RPN 的任务是预测对象在哪里以及它们的形状是什么。
区域兴趣(RoI)池化
最后一步是 RoI 池化。RPN 的输出给我们提供了W′ ⋅ H′ ⋅ k 个总潜在区域兴趣的位置。RoI 池化从 RPN 中获取每个提议,并抓取由骨干网络产生的特征图对应的区域作为其输入。但是,这些区域可能大小不同,区域可能重叠。在训练时,我们需要对所有这些区域进行预测,以便模型可以学会抑制假阳性并检测假阴性。在测试时,我们只需要对 RPN 子网络得到较高分数的提议进行预测。在训练和测试中,我们都面临一个问题,即提议的大小是可变的。因此,我们需要设计一个可以处理可变大小输入并仍然做出单一预测的网络。
为了实现这一点,我们使用自适应池化。在正常池化中,我们说我们想通过多少来缩小输入(例如,我们通常将图像缩小 2 倍)。在自适应池化中,我们声明我们希望输出有多大,自适应池化根据输入的大小调整缩放因子。例如,如果我们想得到一个 3 × 3 的输出,而输入是 6 × 6,自适应池化将在一个 2 × 2 的网格中进行(6/2 = 3)。但是,如果输入是 12 × 12,池化将在一个 4 × 4 的网格中进行,以得到 12/4 = 3。这样,我们总是得到相同大小的输出。RoI 池化过程在图 8.12 中更详细地展示。

图 8.12 区域兴趣(RoI)网络是最后一步。RPN 的结果告诉我们特征图中哪些区域可能包含一个对象。这些区域的切片被提取出来,使用nn.AdaptiveMaxPool2d调整大小到标准的小形状,然后一个小型全连接隐藏层对每个提议进行预测。这是确定具体存在什么对象的最终判断。
这个 RoI 子网络的代码可能看起来像这样:
nn.Sequential(
nn.AdaptiveMaxPool2d((7,7)), ❶
nn.Flatten(), ❷
nn.Linear(7*7, 256), ❸
nn.BatchNorm1d(256),
nn.LeakyReLU(),
nn.Linear(256, classes),
)
❶ 对于任何 W 和 H 的输入,形状为 (B, C, 7, 7)
❷ 现在是 (B, C77)
❸ 假设 C=1 和 256 个隐藏神经元
RoI 网络从自适应池化开始,以强制所有预测具有特定的大小。这是一个非常小的 7 × 7,以使 RoI 网络小巧且运行速度快,因为我们有很多提议要处理。它如此之小,以至于我们只需跟随两轮 nn.Linear 层而不是卷积层,因为已经缩小了很多。然后我们可以将这个网络应用于 RPN 网络识别的每个区域,无论大小,并得到预测。
8.4.2 在 PyTorch 中使用 Faster R-CNN
实现 Faster R-CNN 的细节并不简单,这是一个难以完全正确的算法。(如果你想要细节,请查看文章“使用 R-CNN 进行对象检测和分类”在 mng.bz/RqnK。)幸运的是,PyTorch 内置了 Faster R-CNN。尽管训练它很昂贵,但我们将从 MNIST 创建一个玩具问题来展示其基本原理。
我们的玩具数据集将是一个更大的 100 × 100 图像,其中包含随机数量的 MNIST 数字,位置也随机。目标是检测这些图像的位置并将它们正确分类。我们的数据集将返回一个元组。元组中的第一个项目是我们想要在上面进行对象检测的 100 × 100 图像。元组中的第二个项目是一个包含两个子张量的字典。
第一个子张量通过名称 boxes 索引。如果图像中有 k 个对象,则其形状为 (k,4) 并存储 float32 值,给出框的四个角。第二个项目通过 labels 索引,并且仅在进行训练时是必要的。这个张量看起来更像是之前使用过的,形状为 (k) int64 值,为要检测的每个 k 个对象提供类别 ID。
实现 R-CNN 数据集
以下代码块显示了我们的 Dataset 类,该类实现了我们的玩具 MNIST 检测器。注意计算 offset 的注释,因为边界框的角都是绝对位置,因此我们需要根据它们与起始角的相对距离来计算两个角:
class Class2Detect(Dataset):
"""This class is used to create a simple convesion of a dataset from
➥ a classification problem, to a detection problem. """
def __init__(self, dataset, toSample=3, canvas_size=100):
"""
dataset: the source dataset to sample items from as the "objects"
➥ to detect
toSample: the maximum number of "objects" to put into any image
canvas_size: the width and height of the images to place objects
➥ inside of.
"""
self.dataset = dataset self.toSample = toSample self.canvas_size = canvas_size
def __len__(self):
return len(self.dataset)
def __getitem__(self, idx):
boxes = []
labels = []
final_size = self.canvas_size img_p =
torch.zeros((final_size,final_size), ❶
➥ dtype=torch.float32)
for _ in range(np.random.randint(1,self.toSample+1)): ❷
rand_indx = np.random.randint(0,len(self.dataset))
img, label = self.dataset[rand_indx] ❸
_, img_h, img_w = img.shape
offsets = np.random.randint(0,final_size - ❹
➥ np.max(img.shape),size=(4))
offsets[1] = final_size - img.shape[1]
- offsets[0] ❺
offsets[3] = final_size - img.shape[2] -
offsets[2]
with torch.no_grad():
img_p = img_p + F.pad(img, tuple(offsets)) ❻
xmin = offsets[0] ❼
xmax = offsets[0]+img_w ❽
ymin = offsets[2] ❾
ymax = offsets[2]+img_h
boxes.append( [xmin, ymin, xmax, ymax] )
labels.append( label ) ❿
target = {}
target["boxes"] = torch.as_tensor(boxes, dtype=torch.float32)
target["labels"] = torch.as_tensor(labels, dtype=torch.int64)
return img_p, target
❶ 创建一个更大的图像,用于存储要检测的所有“对象”
❷ 从 self.toSample 中采样对象以放置到图像中。我们正在调用 PRNG,因此此函数不是确定性的。
❸ 从原始数据集中随机选择一个对象及其标签
❹ 获取该图像的高度和宽度
❺ 随机选择 x 轴和 y 轴的偏移量,实际上是将图像放置在随机位置
❻ 改变末尾的填充,以确保我们得到一个特定的 100,100 形状
❽ 为“boxes”创建值。所有这些都在绝对像素位置。xmin 由随机选择的偏移量确定。
❽ xmax 是偏移量加上图像的宽度。
❾ y 的最小值/最大值遵循相同的模式。
❿ 向具有正确标签的框中添加
实现 R-CNN 的 collate 函数
PyTorch 的 Faster R-CNN 实现不使用我们迄今为止使用的模式(一个包含所有填充到相同大小的张量的张量)来接受输入批次。原因是 Faster R-CNN 是设计用来处理高度可变大小的图像的,因此我们不会为每个项目都有相同的 W 和 H 值。相反,Faster R-CNN 希望有一个张量列表和一个字典列表。我们必须使用自定义的 collate 函数来实现这一点。以下代码创建我们的训练集和测试集,以及所需的 collate 函数和加载器:
train_data = Class2Detect(torchvision.datasets.MNIST("./", train=True,
➥ transform=transforms.ToTensor(), download=True))
test_data = Class2Detect(torchvision.datasets.MNIST("./", train=False,
➥ transform=transforms.ToTensor(), download=True))
def collate_fn(batch):
"""
batch is going to contain a python list of objects. In our case, our
➥ data loader returns (Tensor, Dict) pairs
The FasterRCNN algorithm wants a List[Tensors] and a List[Dict]. So we
➥ will use this function to convert the
batch of data into the form we want, and then give it to the Dataloader
➥ to use
"""
imgs = []
labels = []
for img, label in batch:
imgs.append(img)
labels.append(label)
return imgs, labels
train_loader = DataLoader(train_data, batch_size=128, shuffle=True,
➥ collate_fn=collate_fn)
检查 MNIST 检测数据
现在我们已经设置了所有数据。让我们看看一些数据,以了解其内容:
x, y = train_data[0] ❶
imshow(x.numpy()[0,:])
[24]: <matplotlib.image.AxesImage at 0x7fd248227510>
❶ 抓取带有标签的图像

这张图像有三个随机位置的项目:在这种情况下,8、1 和 0。让我们看看标签对象 y。它是一个 Python dict 对象,因此我们可以看到键和值,并索引 dict 来查看它包含的各个项目。
print(y) ❶
print("Boxes: ", y[’boxes’]) ❷
print("Labels: ", y[’labels’]) ❸
\{'boxes': tensor([[14., 60., 42., 88.],
[23., 21., 51., 49.],
[29., 1., 57., 29.]]), 'labels': tensor([0, 1, 8])\}
Boxes: tensor([[14., 60., 42., 88.],
[23., 21., 51., 49.],
[29., 1., 57., 29.]])
Labels: tensor([0, 1, 8])
❶ 打印出所有内容
❷ 打印出显示所有三个对象角落像素位置的张量
❸ 打印出显示所有三个对象标签的张量
所有三个输出看起来都很合理。y 的 boxes 部分的形状为 (3,4),而 labels 的形状为 (3)。如果我们将 boxes 的第一行与前面的图像进行比较,我们可以看到在 x 轴上,它大约从 30 开始,延伸到 60,这与 boxes 张量中的值相对应。同样适用于高度值(y 坐标),它从大约 10 开始,下降到 30。
这里重要的是,框和标签以一致顺序出现。标签 4 可以是第一个标签,只要 boxes 的第一行具有对象 4 的正确位置。
定义一个 Faster R-CNN 模型
在此基础上,让我们构建一个小型主干网络来使用。我们简单地迭代多个“卷积、批归一化、ReLU”块,并逐渐增加过滤器的数量。我们需要做的最后一件事是将 backbone.out_channels 值添加到我们创建的网络中。这告诉 Faster R-CNN 实现值 C′,这是主干网络产生的特征图中的通道数。这用于为我们设置 RPN 和 RoI 子网络。RPN 和 RoI 网络都非常小,我们没有太多参数可以调整,我们也不想使它们更大,因为那样训练和推理将会非常昂贵。以下是代码:
C = 1 ❶
classes = 10 ❷
n_filters = 32 ❸
backbone = nn.Sequential(
cnnLayer(C, n_filters),
cnnLayer(n_filters, n_filters),
cnnLayer(n_filters, n_filters),
nn.MaxPool2d((2,2)),
cnnLayer(n_filters, 2*n_filters),
cnnLayer(2*n_filters, 2*n_filters),
cnnLayer(2*n_filters, 2*n_filters),
nn.MaxPool2d((2,2)),
cnnLayer(2*n_filters, 4*n_filters),
cnnLayer(4*n_filters, 4*n_filters),
)
backbone.out_channels = n_filters*4 ❹
❶ 输入中有多少个通道?
❷ 有多少个类别?
❸ 我们的主干网络中有多少个过滤器?
❹ 让 Faster R-CNN 知道期望多少个输出通道
现在我们可以定义我们的 Faster R-CNN 模型。我们给它提供骨干网络,并告诉它存在多少个类别以及如何归一化图像(如果你的图像值在 [0,1] 范围内,对于大多数图像来说,0.5 的平均值和 0.23 的偏差是好的默认值)。我们还告诉 Faster R-CNN 最小和最大的图像尺寸。为了使这个运行更快,我们将它们都设置为唯一的图像尺寸 100。但对于真实数据,这是用来尝试在多个尺度上检测对象的;这样它就可以处理近距离或远距离的对象。然而,这需要更多的计算来运行,甚至更多的训练来训练。
注意:在现实问题中,应该如何设置 Faster R-CNN 的最小和最大尺寸?这里有一个经验法则:如果对于人类来说太小而无法完成,那么网络可能也无法完成。在你的数据中,尝试以不同的图像分辨率查找你关心的对象。如果你能找到你的对象的最小分辨率为 256 × 256,那么这是一个好的最小尺寸。如果你需要将图像放大到 1024 × 1024 以发现对象,那么这是一个好的最大尺寸。
截至 PyTorch 1.7.0,当使用自己的骨干网络时,你还必须指定一些关于 RPN 和 RoI 网络的信息。这是通过 AnchorGenerator 和 MultiScaleRoIAlign 对象分别完成的。AnchorGenerator 控制 RPN 生成的提议数量,以不同的长宽比(例如,1.0 是一个正方形,0.5 是一个长宽比为 2:1 的矩形,2 是一个长宽比为 1:2 的矩形)生成,以及那些预测应该有多高(即你的对象可能有多大或小?对于现实世界问题,你可能使用 16 到 512 像素)。MultiScaleRoIAlign 需要我们告诉 FasterRCNN 骨干网络的哪个部分提供特征图(它支持多个特征图作为一项高级功能),RoI 网络的大小,以及如何处理从 RPN 预测的分数像素位置。以下代码将这些内容组合在一起:
anchor_generator = AnchorGenerator(sizes=((32),), ❶
➥ aspect_ratios=((1.0),))
roi_pooler = torchvision.ops.MultiScaleRoIAlign( ❷
➥ featmap_names=[’0’], output_size=7,
➥ sampling_ratio=2)
model = FasterRCNN(backbone, num_classes=10, ❸
➥ image_mean = [0.5], image_std = [0.229],
➥ min_size=100, max_size=100,
➥ rpn_anchor_generator=anchor_generator,
➥ box_roi_pool=roi_pooler)
❶ 应该生成多少个提议 k?每个长宽比都会是一个,并且这个过程会针对多个图像尺寸重复进行。为了使这个运行更快,我们告诉 PyTorch 只查找大小为 32 × 32 的正方形图像。
❷ 告诉 PyTorch 使用骨干网络的最终输出作为特征图 ([]); 使用自适应池化到 7 × 7 网格(output_size=7)。sampling_ratio 的命名不佳:它控制当预测分数像素位置时 RoI 如何从特征图中抓取切片的细节(例如,5.8 而不是 6)。我们不会深入这些低级细节;对于大多数工作,2 是一个合理的默认值。
❸ 创建 FasterRCNN 对象。我们给它提供骨干网络、类别数量、处理图像的最小和最大尺寸(我们知道所有我们的图像都是 100 像素),从图像中减去的平均值和标准差,以及锚生成(RPN)和 RoI 对象。
实现一个 Faster R-CNN 训练循环
由于对张量列表和字典列表的不寻常使用,我们无法使用我们的标准train_network函数来处理这种情况。因此,我们编写了一个最小的训练循环来为我们完成这项训练。主要技巧是将每个列表(输入和标签)中的每个项目移动到我们想要使用的计算设备上。这是因为.to(device)方法仅存在于 PyTorch nn.Module类中,Python 的标准列表和字典没有这些功能。幸运的是,我们早期定义了一个moveTo函数,它执行此操作并且适用于列表和字典。
第二个奇怪之处在于,FasterRCNN对象在训练模式和评估模式下的行为不同。在训练模式下,它期望将标签与输入一起传递,以便它可以计算每个预测的损失。它还返回每个单独预测损失的列表,而不是单个标量。因此,我们需要将这些单个损失全部加起来以获得最终的损失总和。以下代码显示了用于训练一个FasterRCNN周期的简单循环:
model = model.train() model.to(device) optimizer =
torch.optim.AdamW(model.parameters())
for epoch in tqdm(range(1), desc="Epoch", disable=False):
running_loss = 0.0
for inputs, labels in tqdm(train_loader, desc="Train Batch",
➥ leave=False, disable=False):
inputs = moveTo(inputs, device) ❶
labels = moveTo(labels, device)
optimizer.zero_grad() losses =
model(inputs, labels) ❷
loss = 0 ❸
for partial_loss in losses.values():
loss += partial_loss
loss.backward() ❹
optimizer.step()
running_loss += loss.item()
❶ 将批次移动到我们使用的设备
❶ RCNN 需要model(inputs, labels),而不仅仅是model(inputs)。
❶ 计算损失。RCNN 给我们一个损失列表来累加。
❶ 按照常规进行。
这就是使用 PyTorch 提供的 Faster R-CNN 实现所需的所有步骤。现在让我们看看它的表现如何。首先,将模型设置为eval模式,这将改变 Faster R-CNN 实现处理输入和返回输出的方式:
model = model.eval()
model = model.to(device)
接下来,让我们快速从测试数据集中获取一个项目并看看它是什么样子。在这种情况下,我们看到有三个对象,8、0 和 4:
x, y = test_data[0]
print(y) ❶
{'boxes': tensor([[31., 65., 59., 93.],
[10., 36., 38., 64.],
[64., 24., 92., 52.]]), 'labels': tensor([8, 0, 4])\}
❶ 这是我们要获取的地面实况。
让我们进行一个预测。由于我们处于eval模式,PyTorch 希望有一个list图像列表来进行预测。它也不再需要将labels对象作为第二个参数传递,这很好,因为如果我们已经知道所有对象的位置,我们就不会这样做:
with torch.no_grad():
pred = model([x.to(device)])
检查结果
现在我们可以查看我们的结果。PyTorch 的实现返回一个包含三个项目的dict列表:boxes用于预测项的位置,labels用于预测每个项目的类别,scores用于与每个预测相关的置信度。以下代码显示了此图像的pred内容:
print(pred)
[\{'boxes': tensor([[31.9313, 65.4917, 59.7824, 93.3052],
[64.1321, 23.8941, 92.0808, 51.8841],
[70.3358, 26.2407, 96.2834, 53.7900],
[64.9917, 24.2980, 92.9516, 52.2016],
[30.9127, 65.1308, 58.6978, 93.3224]], device='cuda:0'), 'labels':
tensor([8, 4, 1, 9, 5], device='cuda:0'), 'scores': tensor([0.9896, 0.9868,
0.1201, 0.0699, 0.0555], device='cuda:0')\}]
每个字典都有一个boxes张量,其形状为(k′,4),用于k′个预测。labels张量具有(k′)形状,为每个对象预测了标签。最后,scores张量也具有(k′)形状,并为每个返回的预测返回一个范围在[0,1]之间的分数。这些分数是 RPN 子网络的“对象”分数。
在这种情况下,模型对其找到了一个 8 和一个 4(≥0.9 分)的信心很高,但对其他类别的信心较低。通过将这些结果打印成图片,更容易理解这些结果,所以我们快速定义了一个函数来做这件事:
import matplotlib.patches as patches
def plotDetection(ax, abs_pos, label=None):
"""
ax: the matplotlib axis to add this plot to
abs_pos: the positions of the bounding box
label: the label of the prediction to add
"""
x1, y1, x2, y2 = abs_pos
rect = patches.Rectangle((x1,y1),x2-x1,y2-y1,
linewidth=1,edgecolor=’r’,facecolor=’none’) ❶
ax.add_patch(rect) if label is not None: ❷
plt.text(x1+0.5, y1, label, color=’black’,
➥ bbox=dict(facecolor=’white’, edgecolor=’white’, pad=1.0))
return
def showPreds(img, pred):
"""
img: the image to show the bounding box predictions for
pred: the Faster R-CNN predictions to show on top of the image
"""
fig,ax = plt.subplots(1)
ax.imshow(img.cpu().numpy()[0,:]) ❸
boxes = pred[’boxes’].cpu() ❹
labels = pred[’labels’].cpu()
scores = pred[’scores’].cpu()
num_preds = labels.shape[0]
for i in range(num_preds): ❺
plotDetection(ax, boxes[i].cpu().numpy(),
➥ label=str(labels[i].item()))
plt.show()
❶ 为边界框制作一个矩形。
❷ 如果提供了标签,则添加标签。
❸ 绘制图像。
❹ 获取预测。
❺ 对于每个预测,绘制其是否有足够高的分数。
拥有这段代码,我们可以在该图像上绘制 Faster R-CNN 的结果。我们可以清楚地看到,网络在 4 和 8 上做得很好,但完全错过了 0。我们还从网络中得到了一些虚假预测,识别 4 和 8 的子部分为其他数字。例如,看看 4 的右半部分。如果你没有注意到左半部分,你可能会说它是数字 1——或者你可能正在看整个图像,认为它是不完整的 9。8 也有类似的问题。如果你忽略左上角的环,它看起来像 6;如果你忽略 8 的右半部分,你可能会被原谅为称之为 9:
showPreds(x, pred[0])

假重叠对象是目标检测器中常见的问题。有时这些重叠对象是同一对象的预测(例如,识别几个 8)或者像我们这里看到的误标记预测。
8.4.3 抑制重叠盒子
解决这个问题的简单有效的方法是抑制重叠的盒子。我们如何知道要抑制哪些盒子?我们想要确保我们选择的是正确的盒子来使用,但我们也不希望丢弃正确预测相邻对象的盒子。
一种称为非极大值抑制(NMS)的简单方法可以用来做这个。NMS 使用两个盒子之间的交集与并集(IoU)来确定它们是否重叠太多。IoU 是一个分数:1 表示盒子具有完全相同的位置,0 表示没有重叠。图 8.13 显示了它是如何计算的。

图 8.13 交集与并集分数是通过将两个盒子之间的重叠面积除以两个盒子并集的面积来计算的。
IoU 通过将两个盒子交集的大小除以两个盒子的大小来计算。这样,我们得到一个对两个盒子位置相似性的大小敏感度量。NMS 通过取每对具有大于某个指定阈值的 IoU 的盒子,并只保留 RPN 网络中分数最高的一个来工作。
让我们快速看看 NMS 方法在我们数据上的工作方式。我们可以从 PyTorch 导入它作为nms函数:
from torchvision.ops import nms
让我们通过从model返回的pred中打印出这些字段来提醒自己我们拥有的盒子和它们相关的分数:
print(pred[0][’boxes’])
tensor([[31.9313, 65.4917, 59.7824, 93.3052],
[64.1321, 23.8941, 92.0808, 51.8841],
[70.3358, 26.2407, 96.2834, 53.7900],
[64.9917, 24.2980, 92.9516, 52.2016],
[30.9127, 65.1308, 58.6978, 93.3224]], device='cuda:0')
print(pred[0][’scores’])
tensor([0.9896, 0.9868, 0.1201, 0.0699, 0.0555], device='cuda:0')
nms函数愉快地接受盒子张量作为第一个参数,分数张量作为第二个参数。第三个和最后一个参数是调用两个盒子为不同对象的阈值。以下代码表示,如果两个盒子的 IoU 达到 50%或更高,它们是同一项,我们应该保留分数最高的盒子:
print(nms(pred[0][’boxes’], pred[0][’scores’], 0.5))
tensor([0, 1], device='cuda:0')
我们使用 50%的重叠阈值,nms返回一个大小相等或更小的张量,告诉我们应该保留哪些索引。在这种情况下,它表示应该保留分数最高的盒子 0 和 1。
让我们修改我们的预测函数,使用 NMS 清理 Faster R-CNN 的输出。我们还添加了一个min_score标志,我们可以用它来抑制不太可能是有意义的预测:
def showPreds(img, pred, iou_max_overlap=0.5, min_score=0.05,
➥ label_names=None):
"""
img: the original image object detection was performed on
pred: the output dictionary from FasterRCNN for evaluation on img
iou_max_overlap: the iou threshold at which non-maximum suppression
➥ will be performed
min_score: the minimum RPN network score to consider an object
"""
fig,ax = plt.subplots(1)
img = img.cpu().numpy()
if img.shape[0] == 1:
ax.imshow(img[0,:])
else:
ax.imshow(np.moveaxis(img, 0, 2))
boxes = pred[’boxes’].cpu()
labels = pred[’labels’].cpu()
scores = pred[’scores’].cpu()
selected = nms(boxes, scores, iou_max_overlap).cpu().numpy()
for i in selected:
if scores[i].item() > min_score:
if label_names is None:
label = str(labels[i].item())
else:
label = label_names[labels[i].item()]
plotDetection(ax, boxes[i].cpu().numpy(), label=label)
plt.show()
接下来,我们再次使用改进的showPreds函数绘制此图像,我们看到更好的、更干净的结果:只有 4 和 8 单独存在。唉,0 仍然未被检测到,我们除了更多数据和更多训练轮次外,别无他法来修复这个问题:
showPreds(x, pred[0])

8.5 使用预训练的 Faster R-CNN
PyTorch 还提供了一个预训练的 Faster R-CNN 模型。它是在一个称为 COCO 的数据集上训练的,我们可以实例化它并查看此模型使用的类名:
rcnn = torchvision.models.detection. ❶
fasterrcnn_resnet50_fpn(pretrained=True)
❶ R-CNN 检测器是为一组特定的类设置的。你可以通过设置 num_classes=10 和 pretrained_backbone=True,然后用你的数据训练它来为你的问题重用它,就像我们用 MNIST 做的那样:
我们将此模型设置为eval模式,因为它不需要训练,我们还定义了以下NAME列表,其中包含所有预训练 R-CNN 知道如何检测的对象的类名:
rcnn = rcnn.eval()
NAME = [ ❶
’__background__’, ’person’, ’bicycle’, ’car’, ’motorcycle’, ’airplane’,
➥ ’bus’, ’train’, ’truck’, ’boat’, ’traffic light’, ’fire hydrant’,
➥ ’N/A’, ’stop sign’, ’parking meter’, ’bench’, ’bird’, ’cat’, ’dog’,
➥ ’horse’, ’sheep’, ’cow’, ’elephant’, ’bear’, ’zebra’, ’giraffe’,
➥ ’N/A’, ’backpack’, ’umbrella’, ’N/A’, ’N/A’, ’handbag’, ’tie’,
➥ ’suitcase’, ’frisbee’, ’skis’, ’snowboard’, ’sports ball’, ’kite’,
➥ ’baseball bat’, ’baseball glove’, ’skateboard’, ’surfboard’,
➥ ’tennis racket’, ’bottle’, ’N/A’, ’wine glass’, ’cup’, ’fork’,
➥ ’knife’, ’spoon’, ’bowl’, ’banana’, ’apple’, ’sandwich’, ’orange’,
➥ ’broccoli’, ’carrot’, ’hot dog’, ’pizza’, ’donut’, ’cake’, ’chair’,
➥ ’couch’, ’potted plant’, ’bed’, ’N/A’, ’dining table’, ’N/A’, ’N/A’,
➥ ’toilet’, ’N/A’, ’tv’, ’laptop’, ’mouse’, ’remote’, ’keyboard’,
➥ ’cell phone’, ’microwave’, ’oven’, ’toaster’, ’sink’,
➥ ’refrigerator’, ’N/A’, ’book’, ’clock’, ’vase’, ’scissors’,
➥ ’teddy bear’, ’hair drier’, ’toothbrush’ ]
❶ COCO_INSTANCE_CATEGORY_NAMES。这些来自 PyTorch 文档:pytorch.org/vision/0.8/models.html。
让我们尝试从互联网上下载一些图像,看看我们的模型表现如何。请记住,随机图像包含许多这个算法以前从未见过的东西。这将给你一些关于你未来如何可能使用 Faster R-CNN 模型以及它们可能以何种有趣方式失败的想法。以下代码导入了一些用于从 URL 抓取图像的库,以及三个用于尝试检测对象的 URL:
from PIL import Image
import requests
from io import BytesIO
urls = [
"https://hips.hearstapps.com/hmg-prod.s3.amazonaws.com/images/
➥ 10best-cars-group-cropped-1542126037.jpg",
"https://miro.medium.com/max/5686/1*ZqJFvYiS5GmLajfUfyzFQA.jpeg",
"https://www.denverpost.com/wp-content/uploads/2018/03/
➥ virginia_umbc_001.jpg?w=910"
]
response = requests.get(urls[0]) img =
Image.open(BytesIO(response.content))
一旦我们加载了图像,我们就按照 PyTorch 预训练模型的要求重新格式化它。这包括将像素值归一化到[0,1]的范围内,并重新排列维度以成为通道、宽度、高度。以下代码完成了这项工作,之后我们进行预测:
img = np.asarray(img)/256.0
img = torch.tensor(img, dtype=torch.float32).permute((2,0,1))
with torch.no_grad():
pred = rcnn([img]) ❶
❶ 将图像传递到模型
现在我们可以检查结果了。你可能发现你需要为每张图像设置不同的nms阈值或min_score才能获得最佳结果。调整这些参数可能非常依赖于问题。它取决于假阳性与假阴性的相对成本,训练图像在风格/内容上与测试图像之间的差异,以及你最终将如何使用你的目标检测器:
showPreds(img, pred[0], iou_max_overlap=0.15, min_score=0.15,
➥ label_names=NAME)

练习
在 Manning 在线平台 Inside Deep Learning Exercises 上分享和讨论你的解决方案(liveproject.manning.com/project/945)。一旦你提交了自己的答案,你将能够看到其他读者提交的解决方案,并看到作者认为哪些是最好的。
-
现在你已经知道了如何在池化后扩大张量,你可以仅使用瓶颈方法实现卷积自动编码器。回到第七章,通过在编码器中使用两轮池化,在解码器中使用两轮转置卷积来重新实现卷积自动编码器。
-
你可能已经注意到,转置卷积可以在其输出中创建不均匀间隔的伪影,这在我们的示例图中有所体现。这些并不总是问题,但你还可以做得更好。实现你自己的
Conv2dExpansion(n_filters_in)类,采用以下方法:首先,使用nn.Upsample对图像进行上采样,以将张量的宽度和高度扩大 2 倍。如果你偏离了一个像素,使用nn.ReflectionPad2d对输出进行填充以达到所需的形状。最后,应用正常的nn.Conv2d以进行一些混合并改变通道数。将这种新方法与转置卷积进行比较,看看你是否能识别出任何优缺点。 -
在 Data Science Bowl 数据集上比较具有三轮池化和转置卷积的网络以及具有三轮池化和
Conv2dExpansion的网络。你是否看到了结果中的任何差异?你认为你看到了还是没看到,为什么? -
步长卷积可以用作替代最大池化的图像缩小方法。修改 U-Net 架构,通过用步长卷积替换所有池化操作来创建一个真正的全卷积模型。性能如何变化?
-
修改第六章中的残差网络,使用
nn.AdaptiveMaxPooling后跟一个nn.Flatten(),一个线性隐藏层,然后一个线性层进行预测。(注意:nn.MaxPool2d类的数量不应改变。)这能提高你在 Fashion-MNIST 上的结果吗?尝试比较原始残差网络和你的自适应池化变体在 CIFAR-10 数据集上的性能,看看是否有更大的性能差异。 -
修改 Faster R-CNN 的训练循环,包括在每个训练周期后计算测试集上的测试损失的测试传递。提示:你需要保持模型在
train模式,因为当它在eval模式时,其行为会改变。 -
尝试实现一个具有残差连接的 Faster R-CNN 检测器骨干网络的网络。性能是变好了还是变差了?
-
我们为
Class2Detect的边界框比较宽松,因为我们假设数字占据了整个图像。修改此代码以在数字上找到紧密的边界框,并重新训练检测器。这如何改变你在一些测试图像上看到的结果? -
挑战性任务: 使用图像搜索引擎下载至少 20 张猫的图片和 20 张狗的图片。然后在网上找到用于标注图像边框的软件,并创建你自己的猫/狗检测 Faster R-CNN。你需要为所使用的任何标注软件编写一个
Dataset类。
摘要
-
在图像分割中,我们对图像中的每个像素进行预测。
-
目标检测使用与图像分割模型类似的骨干网络,并通过两个较小的网络来提出对象的位置和决定存在的对象。
-
你可以使用转置卷积来上采样图像,转置卷积通常用于逆转最大池化的影响。
-
一种用于目标检测的类似残差块的 U-Net 块结合了最大池化和转置卷积,以创建全尺寸和低分辨率的路径,允许通过更少的计算实现更精确的模型。
-
自适应最大池化将任何大小的输入图像转换为特定大小的目标,这对于设计能够对任意大小输入进行训练和预测的网络很有用。
-
可以使用非极大值抑制来减少目标检测的误报。
¹ 我使用“医疗”这个词纯粹是出于自我利益的理由,因为我也是一个医生,但不是一个特别有用的医生。但总有一天,飞机上会发生机器学习紧急情况,我会准备好的
² 当你称一个模型为全卷积但仍然使用池化层时,有些人会感到不满。我认为这是吹毛求疵。一般来说,任何只使用卷积且没有nn.Linear层(或某些其他非卷积层,如 RNN)的网络都可以称为全卷积。↩
³ O. Ronneberger, P. Fischer, 和 T. Brox,"U-Net: 用于生物医学图像分割的卷积网络",在N. Navab, J. Hornegger, W.M. Wells, 和 A.F. Frangi编辑的《医学图像计算与计算机辅助干预——MICCAI 2015》中,Springer International Publishing,2015 年,第 234–241 页。↩
⁴ 是的,“更快”确实是名字的一部分。这很好,是好的营销手段
⁵ 这也可以通过训练将对象/非对象作为二元进行实现,即 4 个框加 1 个。不过,大多数在线论文和资源都使用+2 的方法,所以我将坚持使用这种方法来描述。↩
9 生成对抗网络
本章涵盖
-
使用生成模型处理全连接和卷积网络
-
使用潜在向量编码概念
-
训练两个相互协作的网络
-
使用条件模型操纵生成
-
使用向量算术操纵生成
到目前为止,我们所学的知识大多是一种一对一的映射。每个输入都有一个正确的类别/输出。狗只能是“狗”;句子只能是“正面”或“负面”。但我们也可能遇到多对一的问题,其中存在多个可能的答案。例如,我们可能有“七”这个概念作为输入,需要创建几种不同类型的数字 7 的图片。或者,为了给老式的黑白照片上色,我们可以生成多个可能的有效彩色图像。对于多对一问题,我们可以使用生成对抗网络(GAN)。像自编码器等其他无监督模型一样,我们可以将 GAN 学习到的表示作为其他 AI/ML 算法和任务的输入。但 GAN 学习到的表示通常更有意义,允许我们以新的方式操纵我们的数据。例如,我们可以拍摄一个皱眉的人的照片,并让算法改变图像,使这个人微笑。
GAN 是目前最成功的生成模型类型之一:可以创建看起来像训练数据的新的数据,而不仅仅是制作简单的复制。如果你有足够的数据和 GPU 计算能力,你可以使用 GAN 创建一些非常逼真的图像。图 9.1 展示了最新的最先进的 GAN 能够达到的效果。随着 GAN 在生成内容方面变得更好,它通常也变得更擅长操纵内容。例如,我们可以使用 GAN 来改变图 9.1 中人物的发色或让他们皱眉。网站thispersondoesnotexist.com展示了高端 GAN 能够做到的更多示例。

图 9.1 此人实际上并不存在!该图像是使用名为 StyleGAN 的 GAN(https://github.com/lucidrains/stylegan2-pytorch)生成的。
与自编码器类似,GANs 以自监督的方式学习,因此我们不需要标记数据来创建 GAN。尽管如此,也有方法使 GANs 使用标签,这样它们也可以是监督模型,具体取决于你想要做什么。你可以使用 GAN 做任何你可能使用自编码器做的事情,但 GANs 通常是用作制作看起来逼真的合成数据的首选工具(例如,为用户可视化产品),操纵图像以及解决具有多个有效输出的多对多问题。GANs 已经变得如此有效,以至于存在数百种针对不同类型问题的专门变体,如文本生成、图像处理、音频、数据增强等。本章重点介绍 GANs 如何工作和失败的基本原理,为你理解其他专门版本提供一个良好的基础。
首先,我们通过两个竞争性神经网络之间的对抗游戏来学习 GANs 的工作原理,一个称为判别器,另一个称为生成器。我们构建的第一个 GAN 将遵循原始方法,这样我们可以展示 GANs 最常见的常见问题,即模式坍塌,这可能会阻止它们学习。然后,我们将了解一种改进的 GANs 训练方法,这有助于减少(但无法解决)这个问题。模式坍塌问题无法解决,但可以通过使用改进的 Wasserstein GAN 来减少。Wasserstein GAN 使得学习一个合理的 GAN 变得更容易,然后我们使用它来展示如何制作用于图像生成的卷积GAN 和包含标签信息的条件 GAN。关于 GANs 的最后一个技术概念是我们通过改变它产生的潜在空间来操纵 GAN 产生的内容。我们以对使用如此强大的方法所涉及的许多事情和道德责任的简要讨论结束本章。
9.1 理解生成对抗网络
当我们谈论 GAN 时,通常有两个子网络:G,被称为生成器网络;和 D,称为判别器网络。这些网络具有竞争目标,使它们成为对手。图 9.2 显示了它们如何相互作用。生成器的目标是创建看起来逼真的新数据并将其提供给判别器。判别器的目标是确定一个输入是否来自真实数据集或是由生成器提供的伪造数据。

图 9.2 生成器和判别器如何相互作用的示例。生成器的目标是生成伪造数据并将其提供给判别器。判别器的目标是确定图像是否来自 GAN。
GANs 可能相当复杂,因此我们将从高层次开始,逐步展开细节。图 9.3 显示了这一细节的下一级,我们将缓慢地介绍它。

图 9.3 GANs 通常的训练过程图。生成器 G 学习为随机值 z 赋予意义以产生有意义的输出
。判别器接收真实和虚假数据,但不知道哪一个是真实的——并试图在一个标准的分类问题中学习差异。然后 G 的损失是在 D 的输出上计算的,但它是相反的。当 D 没有错误时,G 会有错误,而当 D 有错误时,G 没有错误。
G 通过接收一个潜在向量z ∈ ℝ^m 并预测与真实数据大小和形状相同的输出来工作。我们可以选择潜在向量 z 的维度数 m(它是一个超参数):它应该足够大,可以表示我们数据中的不同概念(这需要一些手动尝试和错误)。例如,我们可能想要学习的潜在属性包括微笑/皱眉、发色和发型。z 的值被称为潜在,因为我们从未观察到它们实际是什么:模型必须从数据中推断它们。如果我们做得好,生成器 G 可以使用这个潜在表示 z 作为数据的一个更小、更紧凑的表示——有点像有损压缩的形式。你可以把生成器想象成一个素描艺术家,他必须接收一个描述(潜在向量 z),然后根据这个描述为输出构建一个准确的图片!同样,一个素描艺术家的画作是他们如何解释你的描述的结果,这些潜在向量的意义取决于生成器 G 如何解释它们。在实践中,我们使用每个潜在变量从高斯分布(即z[i] ∼ N(0,1))采样的简单表示。这使得我们可以轻松地采样新的 z 值,从而可以产生和控制合成数据。生成器必须学习如何从这些值中制作出有意义的物品。
D 的目标是确定输入数据是来自真实数据还是虚假数据。因此,D 的工作是一个简单的分类问题。如果输入x ∈ ℝ^d 来自真实数据,则标签y = 1 = y[1] = real。如果输入 x 来自生成器 G,则标签是y = 0 = y[0] fake。这类似于一个自编码器,我们使用监督损失函数,但标签是平凡的。我们实际上使用所有训练数据来定义真实类别,而 G 输出的任何内容都属于虚假类别。
9.1.1 损失计算
这最后一部分可能看起来很复杂:D 和 G 都从 D 的输出中计算它们的损失,而 G 的损失在某种程度上是 D 的相反。让我们明确一下涉及的内容。我们使用ℓ(⋅,⋅)来表示我们的分类损失(softmax 的二进制交叉熵,我们多次使用过)。有两个模型(G 和 D)和两个类别(y[real]和y[fake]),给出表中的四种组合:

当处理 D 的损失 loss[D] 时,我们有真实数据和伪造数据。对于真实数据,我们希望说它看起来是真实的。这相当直接,但让我们仍然标注这个方程:

这给出了 D 在真实数据上的损失。它在伪造数据上的损失也是一个直接的分类,除了我们将真实数据 x 替换为生成器的输出 G(z),我们使用 y[伪造] 作为目标,因为 D 的输入是来自 G 的伪造数据。再次,让我们标注这一点:

接下来是生成器 G 的损失。对于真实数据,这很简单:G 不关心 D 对真实数据的说法,所以当使用真实数据时,G 没有任何变化。但对于伪造数据,G 是关心的。它希望 D 将其伪造数据称为真实数据,因为这意味着它已经成功地欺骗了 D。这涉及到标签的交换。让我们也标注这个方程:

将损失合并
现在我们知道了如何计算损失的各个组成部分,我们可以将它们组合起来。我们必须将它们列为两个损失,因为我们正在训练两个不同的网络。首先,我们有判别器的损失:

这只是将上一页表中的两个值简单组合起来。我们只是更明确地指出,这个损失是在所有数据 n 上计算的(这将成为批次而不是整个数据集)。由于涉及到求和 Σ 的 for 循环,我们也写 z ∼ 𝒩(0,1) 以明确指出每次使用不同的随机向量 z。
我们有生成器的损失,它只有一个求和符号,因为 G 不关心 D 对真实数据的说法。G 只关心伪造数据,如果 G 能欺骗 D 将伪造数据称为真实数据,那么 G 就是成功的。这导致以下结果:

期望
你可能会注意到涉及随机数据 z 的方程中有些奇怪的地方。我们在求和 n 项,但从未访问过任何东西。数据集有多大并不重要;我们可以采样 z 的值的一半或两倍。除非我们不断增加 n 的值,否则我们永远不会超出索引范围。
这是因为我们正在近似我们可能称之为期望的东西。方程 1/n Σ[i]^n[=1] ℓ (D(G(z∼ 𝒩(0,1)), y[真实] 基本上是在问,“如果 z ∼ 𝒩(0,1),我们期望 ℓ(D(G(z),y[真实]) 的平均值是多少?另一种写法是

这是以数学方式询问如果你将 n 设置为 ∞,确切答案是什么。显然,我们没有时间永远采样,但基于期望的这种符号在阅读关于 GANs 的内容时非常常见。因此,花点额外的时间熟悉这个符号是值得的,这样你就可以准备好阅读其他材料。
前一个方程中的符号 𝔼 代表 期望。如果你有一个分布 p 和一个函数 f,写作 𝔼[z ∼ p]f(z) 是一种花哨的说法,“如果我从分布 p 中无限期地抽取值 z,那么 f(z) 的平均值是多少?”有时我们可以用数学来证明期望将是什么,并直接计算结果——但在这里不会发生这种情况。相反,我们可以为批次中的每个项目抽取一个值。这种方法是期望的 近似,因为我们抽取的是 有限 步骤而不是 无限 步骤。
让我们通过用期望符号重写 损失[D] 来结束这次小插曲。对于 D 在真实数据上的预测,我们有一个求和,因为真实数据量是有限的。对于 D 在伪造数据上的预测,我们有一个期望,因为伪造数据是无限的。这给我们以下方程:

9.1.2 GAN 游戏
我们已经讨论了如何分别计算判别器和生成器的两个损失 loss[D] 和 loss[G]。我们如何训练具有两个损失的两种网络?通过轮流计算 loss[D] 和 loss[G],从各自的损失更新两个网络,并重复。这使得训练过程变成了 G 和 D 之间的一种游戏。每个都根据自己在游戏中的表现得到自己的分数,每个都试图提高自己的分数,损害对方的分数。
这个游戏(训练过程)开始于生成器产生糟糕的、看起来随机的结果,这些结果对判别器来说很容易与真实数据区分开来(见图 9.4)。D 尝试根据图像预测来源(训练数据或 G),并得到从真实和伪造数据计算出的损失。G 的损失仅从伪造数据计算,与 D 的损失设置相同,但标签已交换。标签交换是因为 G 想让 D 说 G 的工作是真实的而不是伪造的。

图 9.4 GAN 训练的开始。D 接收一个包含多个图像的批次。其中一些来自真实训练数据,其他的是伪造的(由 G 创建)。对于每一次预测,D 都会根据图像的来源(真实与伪造)是否正确接收一个损失。G 的损失仅从判别器的预测计算。
因为 G 的损失是基于判别器 D 的说法,它学会了改变其预测,使其看起来更像 D 认为的真实数据的样子。随着生成器 G 的改进,判别器 D 需要改进其区分真实和伪造数据的方法。这个循环永远重复(或者直到收敛)。图 9.5 展示了在这个游戏的多轮中结果可能如何演变。

图 9.5 多轮训练 G 和 D。一开始,D 很容易,因为伪造的数据看起来很随机。最终 G 变得更好,欺骗了 D。D 学习更好地区分伪造数据,这使得 G 在制作更真实的数据方面变得更好。最终,D 和 G 都在各自的工作上做得相当不错。
我们已经给出了如何设置 GAN 进行训练的完整描述。关于如何考虑或解释 GAN 设置的另一个细节被称为 min-max。如果我们认为 D 是返回输入来自真实数据的概率,我们希望改变 D 以 最大化 D 在真实数据上的概率,同时改变 G 以 最小化 D 在伪造数据上的性能。这个有用的概念伴随着一个看起来很吓人的方程:

我不喜欢这个方程来解释 GAN 在实际层面的情况——它看起来比我认为的更吓人。我包括它是因为你会在阅读有关 GAN 的内容时经常看到这个方程。但如果你仔细看,这实际上是我们在开始时使用的同一个方程。你可以用求和 Σ 替换期望 𝔼,用 ℓ(D(x),y[real]) 替换 log (D(x)),用 ℓ(D(G(z)),y[fake]) 替换 log (1−D(G(z)))。这样你就能得到我们最初拥有的东西。我们通过轮流优化 D 和 G 以实现它们各自的目标来解决这个 min-max 游戏。我也喜欢用损失函数 ℓ 来写它,因为这样会更明显地表明我们可以改变损失函数来改变我们 GAN 的行为。在 GAN 中改变损失 ℓ 是极其常见的,所以这是一个重要的细节,我不想隐瞒。
9.1.3 实现我们的第一个 GAN
现在我们已经理解了生成器 G 和判别器 D 所玩的游戏,我们可以开始实施它了。我们需要为 D 和 G 定义 Modules,编写一个新的训练循环,该循环轮流更新 D 和 G,并添加一些额外的辅助代码来记录有用的信息和可视化我们的结果。首先,让我们预定义一些值:批大小 batch_size B,我们隐藏层的 neurons 数量,以及训练我们的 GAN 的 num_epochs 轮数。两个新的是 latent_d 和 out_shape。
变量 latent_d 是我们潜在变量 z 的维度数。我们可以使这个值更小或更大;这是我们模型的一个新超参数。如果我们维度太少或太多,我们会有困难进行训练。我的建议是从 64 或 128 开始,并继续增加维度,直到你得到看起来很好的结果。我们还有一个变量 out_shape,它纯粹用于将网络的输出重塑为给定的维度。它从 -1 开始,用于批处理维度。这样,生成器的结果将是我们想要的任何形状。这将在我们制作卷积 GAN 时变得更重要;现在,我们以全连接网络开始。fcLayer 函数为我们提供了构建本章中模型的快捷方式。
这里是代码:
batch_size = 128
latent_d = 128
neurons = 512
out_shape = (-1, 28, 28) ❶
num_epochs = 10
def fcLayer(in_neurons, out_neurons, leak=0.1): ❷
"""
in_neurons: how many inputs to this layer
out_neurons: how many outputs for this layer
leak: the leaky relu leak value.
"""
return nn.Sequential(
nn.Linear(in_neurons, out_neurons),
nn.LeakyReLU(leak),
nn.LayerNorm(out_neurons)
)
❶ 你也可以使用(–1,1,28,28)来表示一个通道,但这会使 NumPy 代码稍显繁琐。
❷ 我们的辅助函数
定义 D 和 G 网络
接下来,我们实现一个函数,该函数定义了生成器 G 和判别器 D。生成器需要知道 latent_d,因为这将作为输入大小。G 和 D 都需要知道 out_shape,因为它是生成器的输出也是判别器的输入。我还包括了一个可选标志 sgimoidG,用于控制生成器何时以 nn.Sigmoid 激活 σ(⋅) 结束。对于本章中我们训练的一些 GAN,我们希望在输出被约束在[0,1]范围内时应用 σ(⋅),因为我们的 MNIST 数据也被约束在[0,1]范围内。但我们还举了一个没有这种约束的例子。我还任意定义了一些具有 LeakyReLU 和层归一化(LN)的隐藏层。
这里是代码:
def simpleGAN(latent_d, neurons, out_shape, sigmoidG=False, leak=0.2):
"""
This function will create a simple GAN for us to train. It will return
➥ a tuple (G, D), holding the generator and discriminator network
➥ respectively.
latent_d: the number of latent variables we will use as input to the
➥ generator G.
neurons: how many hidden neurons to use in each hidden layer
out_shape: the shape of the output of the discriminator D. This should
➥ be the same shape as the real data.
sigmoidG: true if the generator G should end with a sigmoid activation,
➥ or False if it should just return unbounded activations
"""
G = nn.Sequential( fcLayer(latent_d, neurons, leak),
fcLayer(neurons, neurons, leak),
fcLayer(neurons, neurons, leak),
nn.Linear(neurons, abs(np.prod(out_shape)) ), ❶
View(out_shape) ❷
)
if sigmoidG: ❸
G = nn.Sequential(G,
nn.Sigmoid())
D = nn.Sequential( nn.Flatten(),
fcLayer(abs(np.prod(out_shape)), neurons, leak),
fcLayer(neurons, neurons, leak),
fcLayer(neurons, neurons, leak),
nn.Linear(neurons, 1 ) ❹
)
return G, D
❶ np.prod 函数将形状中的每个值相乘,从而得到所需输出的总数。abs 函数消除了批维度中“–1”的影响。
❷ 将输出重塑为 D 期望的任何形状
❸ 有时我们希望或不希望 G 返回一个 sigmoid 值(即,[0,1]),所以我们将其放在一个条件语句中。
❹ D 有一个输出用于二分类问题。
使用此函数,我们可以通过调用 simpleGAN 函数快速定义新的 G 和 D 模型:
G, D = simpleGAN(latent_d, neurons, out_shape, sigmoidG=True)
GAN 的起始配方
GAN 因其难以训练而臭名昭著。第一次尝试往往效果不佳,可能需要大量手动调整才能使其工作并产生像本章开头示例那样的清晰结果。网上有很多技巧可以帮助 GAN 训练良好,但其中一些仅适用于特定类型的 GAN。其他则更可靠,适用于多种架构。以下是我尝试构建任何 GAN 时的建议:
-
使用具有大泄漏值(如 α = 0.1 或 α = 0.2)的 LeakyReLU 激活函数。对于我们判别器没有消失梯度这一点尤为重要,因为训练 G 的梯度必须首先完全通过 D!较大的泄漏值有助于避免这个问题。
-
使用 LN 而不是批归一化(BN)。有些人发现他们用 BN 可以得到最好的结果。在其他情况下,我们可以 证明 BN 会导致问题。因此,我更喜欢从 LN 开始,它在训练 GAN 时具有更一致和可靠的性能。如果我在努力获得最后一丝性能时,我会尝试仅在生成器中将 LN 替换为 BN。
-
使用特定于 Adam 优化器的学习率 η = 0.0001 和 β[1] = 0 以及 β[2] = 0.9。这比 Adam 的正常默认值慢,但更适合 GAN。这是我最后会尝试更改以获得更好结果的事情。
实现 GAN 训练循环
我们如何训练我们的两个网络?每个网络 G 和 D 都有自己的优化器,我们将轮流使用它们!因此,我们不使用我们在本书的大部分内容中使用的相同的train_network函数。这是学习框架及其提供的工具很重要的一个原因:并不是所有东西都可以轻易抽象出来,并且与任何你未来可能想要训练的神经网络一起工作。
首先,让我们做一些设置。让我们将我们的模型移动到 GPU 上,指定我们的二元交叉熵损失函数(因为真实与伪造是一个二元问题),并为每个网络设置两个不同的优化器:
G.to(device)
D.to(device)
loss_func = nn.BCEWithLogitsLoss() ❶
real_label = 1 ❷
fake_label = 0 ❷
optimizerD = torch.optim.AdamW(D.parameters(), ❸
➥ lr=0.0001, betas=(0.0, 0.9))
optimizerG = torch.optim.AdamW(G.parameters(), ❸
➥ lr=0.0001, betas=(0.0, 0.9))
❶ 初始化 BCEWithLogitsLoss 函数。BCE 损失用于二元分类问题,我们的问题就是这样(真实与伪造)。
❷ 在训练期间建立真实和伪造标签的约定
❸ 为 G 和 D 设置 Adam 优化器
接下来,我们抓取 MNIST 作为我们的数据集。在本章中,我们不会真正使用测试集,因为我们专注于生成新的数据:
train_data = torchvision.datasets.MNIST("./", train=True,
➥ transform=transforms.ToTensor(), download=True)
test_data = torchvision.datasets.MNIST("./", train=False,
➥ transform=transforms.ToTensor(), download=True)
train_loader = DataLoader(train_data, batch_size=batch_size, shuffle=True,
➥ drop_last=True)
test_loader = DataLoader(test_data, batch_size=batch_size)
现在我们需要训练这个 GAN。我们将这个过程分为两个步骤,一个用于 D,一个用于 G,如图 9.6 所示。

图 9.6 GAN 的训练步骤。在几个 epoch 中,我们运行这两个步骤。在第 1 步中,我们在真实和伪造数据上计算判别器损失,然后使用其优化器更新 D 的权重。在第 2 步中,我们更新生成器。由于这两个步骤都需要 G 的输出,我们在两个步骤之间重用G(z)。
希望到这一点,你已经开始感到舒适地混合代码和数学符号。这张图几乎列出了我们成功实现生成对抗网络(GAN)所需的所有细节!让我们将其翻译成完整的代码。首先,我们有for循环和一些设置。我们使用两个数组来存储每个批次的损失,以便在训练后查看。损失函数对于 GAN 来说可以特别有信息量。我们还创建了y[真实]和y[伪造],这两个变量在两个步骤中都会使用,并将数据移动到正确的设备上。以下代码展示了这一点:
G_losses = [] D_losses = []
for epoch in tqdm(range(num_epochs)):
for data, class_label in tqdm(train_loader, leave=False):
real_data = data.to(device) ❶
y_real = torch.full((batch_size,1), real_label, ❶
➥ dtype=torch.float32, device=device)
y_fake = torch.full((batch_size,1), fake_label, ❶
➥ dtype=torch.float32, device=device)
❶ 准备批次并制作标签
现在我们可以进行第一步:更新判别器。实现这一点不需要做太多工作。我们计算完每个错误组件后调用 backward,作为一个轻微的效率优化。但我们仍然稍后把错误加在一起以保存组合错误。这里的一个大技巧是你应该注意到的,就是在将假图像传递给 D 时使用 fake.detach()。detach() 方法返回一个相同对象的新版本,该版本不会进一步传递梯度。我们这样做是因为 fake 对象是使用 G 计算的,所以天真地使用 fake 对象在我们的计算中会导致我们输入一个对 G 有利的梯度,因为我们正在计算判别器的损失!由于第一步只是应该改变 D 和此时的 G 梯度对 G 的目标(它想打败判别器!)是有害的,所以我们调用 .detach() 以确保 G 不获得任何梯度。
这里是代码:
D.zero_grad()
errD_real = loss_func(D(real_data), y_real) ❶
errD_real.backward()
z = torch.randn(batch_size, latent_d, device=device) ❷
fake = G(z) ❹
errD_fake = loss_func(D(fake.detach()), y_fake) ❺
errD_fake.backward() ❻❸
errD = errD_real + errD_fake ❼❸
optimizerD.step() ❽❸
❶ 真实数据
❷ 使用全假批次进行训练并生成一个潜在向量批次 z ∼ 𝒩(
, 1)。
❸ 使用 G 生成一个假图像批次,并使用 D 对假批次进行分类。我们将此保存以在第二步中重用。
❹ x[fake] = G(z)
❺ 在全假批次上计算 D 的损失;注意使用 fake.detach()。ℓ(D(x[real]),y[real])
❻ 计算该批次的梯度
❼ 将全真实和全假批次的梯度相加
❽ 更新 D
我们循环体的最后一部分是第二步:更新生成器 G。我们重用 fake 对象,这样我们就不需要创建一个新的对象,这可以节省我们的时间。由于我们还想改变 G,所以我们直接使用原始的 fake 对象——没有调用 .fake()。这里需要的代码非常少,我们将 G 和 D 的错误追加到一个列表中,以便稍后绘制它们:
G.zero_grad()
errG = loss_func(D(fake), y_real) ❶
errG.backward() ❷
optimizerG.step() ❸
G_losses.append(errG.item())
D_losses.append(errD.item())
❶ 基于此输出计算 G 的损失:ℓ(D(x[fake]), y[real])
❷ 为 G 计算梯度
❸ 更新 G
检查结果
运行该代码成功训练了一个 GAN。因为潜在的 z 来自高斯分布(z ∼ 𝒩(
, I ) ),我们可以轻松地采样它们并计算 G(z) 来获取合成数据。我们还可以查看 D(G(z)) 的值,以了解判别器对每个样本真实性的看法。我们训练模型的方式是,值为 1 表示判别器认为输入肯定是真实的,而 0 表示判别器认为它肯定是假的。以下代码将一些新的潜在值采样到一个名为 noise 的变量中,这是你在 GAN 中将看到的另一个常见名称。我们用 G 生成假数字并计算它们看起来有多真实的分数:
with torch.no_grad():
noise = torch.randn(batch_size, latent_d, device=device) ❶
fake_digits = G(noise)
scores = torch.sigmoid(D(fake_digits))
fake_digits = fake_digits.cpu() scores =
scores.cpu().numpy().flatten()
❶ z ∼ 𝒩(
, I)
接下来是一段 Matplotlib 代码,用于绘制所有生成的图像,每个数字上方都有红色的分数。该图会根据我们使用的批量大小自动调整大小。该代码快速计算可以从给定批次填充的最大正方形图像。我们将scores作为一个可选参数,因为我们的未来 GANs 可能不会有这种类型的分数:
def plot_gen_imgs(fake_digits, scores=None):
batch_size = fake_digits.size(0)
fake_digits = fake_digits.reshape(-1,
➥ fake_digits.size(-1), fake_digits.size(-1))
i_max = int(round(np.sqrt(batch_size)))
j_max = int(np.floor(batch_size/float(i_max)))
f, axarr = plt.subplots(i_max,j_max, figsize=(10,10))
for i in range(i_max):
for j in range(j_max):
indx = i*j_max+j
axarr[i,j].imshow(fake_digits[indx,:].numpy(), cmap=’gray’,
vmin=0, vmax=1)
axarr[i,j].set_axis_off()
if scores is not None:
axarr[i,j].text(0.0, 0.5, str(round(scores[indx],2)),
➥ dict(size=20, color=’red’))
plot_gen_imgs(fake_digits, scores)
❶ 此代码假设我们正在处理黑白图像。

我们已经生成了合成数据,其中一些看起来相当不错!一些样本看起来不太真实,这是可以接受的。学习生成模型的问题本质上比学习判别模型更具挑战性。如果你再次运行此代码,你应该会得到新的结果并开始注意到一些问题。
你可能会注意到的第一个模式是生成器更喜欢一些数字而不是其他数字。当我运行这个时,我通常看到很多 0s、3s 和 8s 被生成。其他数字非常罕见。第二,判别器几乎总是正确地称生成的样本为伪造的。这两个问题相关。
几个数字的重复是一个问题,因为这意味着我们的生成器没有对整个分布进行建模。这使得其整体输出不太真实,即使单个数字看起来不错。判别器在检测伪造方面非常出色是一个潜在问题,因为我们使用了二元交叉熵(BCE)损失!由于 BCE 涉及 sigmoid,如果 D 变得非常好并预测样本是恶意的概率为 0%,这将导致 G 的梯度消失。这是因为 G 的梯度来自 D 预测的相反方向。如果 D 预测得非常完美,G 就无法学习。
D 可能会通过完美的预测赢得游戏的危险很重要,因为 D 和 G 之间的游戏是不公平的。我说的不公平是什么意思?让我们看看判别器和生成器在训练过程中的损失。我们可以快速绘制 G 和 D 的损失,并将它们进行比较,看看它们的进展情况:
plt.figure(figsize=(10,5))
plt.title("Generator and Discriminator Loss During Training")
plt.plot(G_losses,label="G")
plt.plot(D_losses,label="D")
plt.xlabel("iterations")
plt.ylabel("Loss")
plt.legend()
plt.show()

尽管生成器已经显著改进,但它始终比 D 表现差。判别器在整个过程中始终保持在零损失附近,因为它有一个本质上更容易解决的问题。判别总是比生成更容易!因此,D 很容易在游戏中对生成器 G 取得胜利。这就是为什么生成器倾向于关注几个数字而忽略其他数字的部分原因。早期,生成器 G 发现一些数字比其他数字更容易用它们来欺骗 D。除了欺骗 D 之外,G 没有其他惩罚,所以它将所有努力都投入到似乎效果最好的任何事物上。
为什么判别比生成更容易?
让我们快速偏离一下,如果你愿意简单地相信判别比生成更容易,你可以跳过这一部分。一点数学将展示为什么它更容易。让我们从 Bayes 定理的快速复习开始:

如果我们想用统计术语来描述这个问题,生成器 G 正在尝试学习被称为联合分布的东西。存在真实数据 x 和标签 y,联合分布表示为 ℙ(x,y)。如果你可以从联合分布中采样,你就有了一个生成模型。如果它做得很好,你应该得到 y 可能指示存在的所有不同类别。
判别器的任务是条件分布,我们将其写作 ℙ(y∣x)。阅读这个表达式的方式是:“给定输入 x,标签 y 的概率(ℙ)是多少?”
如果我们已经有联合分布,我们可以立即通过应用贝叶斯定理恢复条件分布:

底部缺失的项不是问题,因为它可以计算为 ℙ(x) = ∑[y∈Y] ℙ(x,y)。所以如果我们知道 G,我们就可以免费得到一个判别器 D!但是没有方法可以重新排列这个方程,从 D 中得到 G 而不添加“额外的东西”。生成(联合分布)的问题本质上需要更多信息,因此比判别(条件分布)更难。这就是为什么 D 的任务比 G 更容易。
9.2 模式坍塌
我们生成器只生成一些数字的问题是一个称为模式坍塌的现象。因为生成器有一个更难解决的问题,它试图找到在游戏中作弊的方法。一种作弊的方法是只生成容易的数据,忽略其他情况。如果生成器每次都能生成完美的 0 来欺骗判别器,它就会赢得游戏——即使它不能生成任何其他数字!为了制作更好的生成器,我们需要解决这个问题;在本节中,我们进行另一个实验来更好地理解它。
最容易的数据点通常与数据的模式相关联。模式是在分布中找到的最常见值。我们用它来帮助理解模式坍塌问题。为此,我们生成一个高斯变量的网格。以下代码是这样做的,使得高斯变量以零为中心:
gausGrid = (3, 3) ❶
samples_per = 10000 ❷
❶ 网格应该有多大?
❷ 网格中每个项目的样本数量是多少?
接下来是一些快速代码,它遍历网格中的每个项目并计算一些样本。我们在样本中使用小的标准差,这样就可以清楚地看到存在九个分离良好的模式:
X = [] ❶
for i in range(gausGrid[0]): ❷
for j in range(gausGrid[1]): ❷
z = np.random.normal(0, 0.05, size=(samples_per, 2)) ❸
z[:,0] += i/1.0-(gausGrid[0]-1)/2.0 ❹
z[:,1] += j/1.0-(gausGrid[1]-1)/2.0 ❺
X.append(z) ❻
X = np.vstack(X) ❼
❶ 我们在这里存储所有数据。
❷ 这两个循环到达每个均值的中心。
❸ 样本了一组紧密聚集的点
❹ 将这个随机样本偏移到特定的 x 轴位置
❺ y 轴上的偏移
❻ 收集所有样本
❼ 将这个列表转换成一个形状为 (N, 2) 的大型 NumPy 张量
最后我们可以绘制这些样本!我们使用核密度估计(kde)图来平滑 2D 网格的视觉效果。我们有足够的样本,每个模式看起来都完美,我们可以清楚地看到在一个漂亮的网格中存在九个模式:
plt.figure(figsize=(10,10))
sns.kdeplot(x=X[:,0], y=X[:,1], shade=True, fill=True, thresh=-0.001) ❶
[18]: <AxesSubplot:>
❶ 绘制完美的玩具数据

现在我们有一些简单漂亮的数据;当然,我们的 GAN 应该能够解决这个问题。只有两个变量,分布是高斯分布——实际上是我们能希望得到的最容易和最简单的分布。让我们为这个问题设置一个 GAN,看看会发生什么!以下代码使用TensorDataset类快速创建一个数据集。我们使用 32 个潜在维度,因为这是一个小问题,不需要 128 个维度,并且我们每层使用 256 个神经元。这应该足够学习这个问题:
toy_dataset = torch.utils.data.TensorDataset(
➥ torch.tensor(X, dtype=torch.float32))
toy_loader = DataLoader(toy_dataset, batch_size=batch_size, shuffle=True,
➥ drop_last=True) latent_d = 64
G, D = simpleGAN(latent_d, 512, (-1, 2)) ❶
❶ 为我们的玩具问题创建了一个仅有两个输出特征的新 GAN
在此基础上,我们可以重新运行我们创建的for循环来训练我们的 GANs 100 个 epoch。然后我们可以生成一些合成数据,这可以通过另一个no_grad块快速完成:
with torch.no_grad():
noise = torch.randn(X.shape[0], latent_d, device=device) ❶
fake_samples = G(noise).cpu().numpy() ❷
❶ 从随机 z ∼ 𝒩(0,1) 中采样一些
❷ 创建假数据 G(z)
现在让我们使用以下kdeplot调用可视化生成的样本。理想情况下,我们应该看到九个圆圈的网格图像。然而,你通常只会看到其中一两个高斯分布是由 G 生成的!更糟糕的是,GAN 并不总是能很好地学习一个高斯分布。它不是学习更大的形状和宽度,而是学习了一个模式的模式,专注于样本最多的中心区域:
plt.figure(figsize=(10,10))
sns.kdeplot(x=fake_samples[:,0], y=fake_samples[:,1],
➥ shade=True, thresh=-0.001) ❶
plt.xlim(-1.5, 1.5) ❷
plt.ylim(-1.5, 1.5) ❸
[23]: (-1.5, 1.5)
❶ 绘制 G 从我们的玩具数据中学到的内容
❷ 手动设置 x 轴的范围,使其与我们的数据集原始范围一致
❸ 同样适用于 y 轴

这种行为是模式坍塌现象的一个例子。GAN 不是学习整个输入空间,而是选择了对自己最方便的选项,并过度拟合到这一点。希望这个玩具问题能让你看到 GAN 陷入模式坍塌陷阱是多么容易。我们在这个章节中学到的所有技巧,甚至是最前沿的方法,都只能减轻这个问题——我们还没有解决这个问题。即使你训练的 GAN 在你的问题上似乎表现良好,你也应该手动检查结果,并将它们与真实数据进行比较。寻找在真实数据中出现但在生成输出中不出现的模式和风格,以确定是否发生了模式坍塌以及其严重程度。一个生成看起来很好的输出但坍塌到几个模式上的 GAN 很容易让你误以为你的结果比实际情况要好。
9.3 Wasserstein GAN:缓解模式坍塌
GANs(生成对抗网络)目前是一个高度活跃的研究领域,有众多不同的方法来处理模式坍塌问题。本节将讨论一种已被证明是可靠的改进方法,许多其他方法都是基于它构建的更复杂的解决方案。这种方法被称为Wasserstein GAN(WGAN)。¹ “Wasserstein”这个名字来源于推导这种改进的数学方法,我们在此不进行讨论。相反,我们将讨论结果以及为什么它有效。
和之前一样,我们将损失函数分为两部分:判别器的损失和生成器的损失。在这个新方案中,我们不对判别器 D 的输出使用 sigmoid 激活函数:这样,我们就有更少的梯度消失问题。尽管如此,判别器仍然会输出一个单一值。
9.3.1 WGAN 判别器损失
判别器的损失是 D 对假数据的评分与 D 对真实数据的评分之间的差异。这看起来像是 D(G(z)) − D(x),所以 D 尽可能多地想要最小化这个值。这个分数不能饱和,这有助于缓解梯度消失的问题。更重要的是,我们将包括对 D 的复杂性的惩罚。我们的想法是让 D 处于不利地位,这样它就必须更努力地学习更强大的模型,我们希望这能让 D 和 G 之间的游戏更加公平。我们之所以这样做,是因为 D 有一个更容易的问题。判别器的损失变为

我们稍后会对这个方程进行注释,但首先让我们像在论文中看到的那样,逐句讲解一下。记住,默认情况下,目标是使损失函数最小化。最小化这个方程有什么作用呢?首先,有 G 分数项和 D 分数项的减法。G 分数给出了生成器输出真实性的评分,而 D 分数给出了真实数据外观的评分。为了最小化这个,判别器 D 想要最小化(大负值!)G 分数并最大化(大正值!)D 分数。因此,高分数值表示更高的真实性。因为分数没有受到任何限制,我们实际上是在比较假数据与真实数据的相对分数。这里的重要部分是,无论判别器做得有多好,这两个值只是相减的数字,所以梯度应该很容易计算,没有任何数值问题。
另一个有趣的补充是右侧的复杂性惩罚项。它是如何惩罚复杂性的呢?它通过取判别器 D 的梯度(∇)的范数(∥ ⋅ ∥[2])来做到这一点。值ϵ是在[0,1]范围内随机选择的,所以惩罚应用于真实和假数据的随机混合,这确保 D 不能通过学习部分数据的简单函数和另一部分数据的复杂函数来作弊。最后,值λ是我们控制的超参数。我们设置得越大,对复杂性的惩罚就越大。
现在我们已经走过了 D 这个新损失函数的各个部分,让我们用一些彩色注释重复这个方程,总结一下正在发生的事情:

我们在损失函数中包含一个梯度项意味着计算损失需要计算梯度的梯度!解决这个数学问题很困难,但幸运的是 PyTorch 在这里为我们做了这件事。我们可以专注于理解这个方程说了什么以及如何推导它。
梯度告诉你朝哪个方向移动以最小化一个函数,较大的值意味着你处于你试图最小化的函数的更陡峭的部分。一个总是返回相同值(即没有变化)的简单函数有一个零梯度,这是唯一使复杂度惩罚为零的方法。因此,我们的函数越远离简单地返回相同值,其惩罚就越高。这就是这个方程的这一部分如何惩罚复杂模型的小技巧。要使惩罚项为零,需要在所有情况下返回相同的值,这本质上意味着忽略输入。这是一个无用的简单函数,但它将最小化损失的这一部分:总是有一个拉力作用于 D,希望它远离学习任何复杂的东西。一般来说,当你在一个损失函数中看到类似∥∇f(x)∥的东西时,你应该理解它是在惩罚复杂性。
9.3.2 WGAN 生成器损失
关于判别器和其新潮的损失函数,我们已经做了很多阐述。相比之下,生成器的损失函数要简单得多。判别器想要最大化 G 分数,而生成器想要最小化 G 分数!我们的损失是带有符号反转的 G 分数,这样 G 就被最小化了:
loss[G] = − D(G(z))
为什么生成器的损失如此简单?这回到了我们已经讨论过的三个原因。首先,生成器 G 不关心 D 对真实数据 x 的看法。这意味着我们可以从损失中移除D(x)项。其次,D 有一个更容易的问题。复杂度惩罚是为了使 D 处于不利地位,而当我们谈论 G 的损失时,我们不想用一个不必要的惩罚来使 G 处于不利地位。第三,G 的目标是欺骗 D。G 分数剩下的唯一部分是D(G(z))。由于 G 有一个相反的目标,我们在前面放一个负号,得到最终的损失 − D(G(z))。
9.3.3 实现 WGAN
这种新的方法通常缩写为 WGAN-GP,其中 GP 代表梯度惩罚。² WGAN-GP 通过减少梯度消失的机会,并在 G 和 D 之间平衡竞争环境,使得 G 更容易跟上 D,从而帮助解决模式坍塌问题。让我们用这种方法在玩具网格上训练一个新的 GAN,看看它是否有所帮助。
由于我们将多次使用 WGAN-GP,我们将定义一个train_wgan函数来完成这项工作。这个函数遵循我们原始 GAN 训练循环相同的设置和组织,但函数的每个部分都会有一些变化。这包括函数开始时的准备,向 D 和 G 提供输入,添加梯度惩罚,以及计算最终的 WGAN-GP 损失。我们首先定义一个函数,带有参数来接收网络、加载器、潜在维度的数量、训练的 epoch 数以及要使用的设备。
更新训练准备
这段代码使用了一个简单的技巧:在 isinstance(data, tuple) or len(data) 周围的 if 语句。这样,我们有一个训练循环,当用户提供一个只包含未标记数据 x 的 Dataloader 或提供数据 y(但不会使用标签;我们只是不会为此抛出任何错误)时,它将工作:
def train_wgan(D, G, loader, latent_d, epochs=20, device="cpu"):
G_losses = [] D_losses = []
G.to(device) D.to(device)
optimizerD = torch.optim.AdamW(D.parameters(), ❶
➥ lr=0.0001, betas=(0.0, 0.9))
optimizerG = torch.optim.AdamW(G.parameters(), ❷
➥ lr=0.0001, betas=(0.0, 0.9))
for epoch in tqdm(range(epochs)):
for data in tqdm(loader, leave=False):
if isinstance(data, tuple) or len(data) == 2:
data, class_label = data
class_label = class_label.to(device)
elif isinstance(data, tuple) or len(data) == 1:
data = data[0]
batch_size = data.size(0)
D.zero_grad()
G.zero_grad()
real = data.to(device)
❶ 为 D 设置 Adam 优化器
❷ 为 G 设置 Adam 优化器
更新 D 和 G 输入
在循环体中,我们计算 D 在真实和伪造数据上的结果。这里的一个重要变化是,当 fake 进入判别器时,我们并没有调用 detach() 函数,因为我们需要在下一步的梯度惩罚计算中包含 G:
D_success = D(real) ❶
noise = torch.randn(batch_size, latent_d, device=device) ❷
fake = G(noise) ❸
D_failure = D(fake) ❹
❶ 步骤 1:D 分数、G 分数和梯度惩罚。D 在真实数据上的表现如何?
❷ 使用所有伪造的批次进行训练并生成一批潜在向量
❸ 使用 G 生成一个伪造的图像批次
❹ 使用 D 对所有伪造的批次进行分类
计算梯度惩罚
当 fake 计算出来后,我们可以计算梯度惩罚。首先,我们在 eps 变量中为 ϵ 选择 [0,1] 范围内的随机值。我们必须确保它有与训练数据相同的轴,以便张量可以相乘。所以如果我们的数据形状为 (B,D),eps 将具有形状 (B,1)。如果我们的数据形状为 (B,C,W,H),eps 需要形状为 (B,1,1,1)。这样,批次中的每个项目都乘以一个值,我们可以计算进入 D 的 mixed 输入。
下一行代码中有一个 autograd.grad 函数调用。说实话,这个函数让我感到害怕,每次需要使用它时我都得查一下。它以 PyTorch 可以用来计算梯度的方式为我们计算梯度 ∇。
基本上,这个函数执行的功能与调用 .backward() 相同,但它返回一个新张量作为对象,而不是将其存储在 .grad 字段中。如果你不理解这个函数,那也无所谓,因为它的用途很专业。但我会给那些感兴趣的人提供一个快速的高级解释。outputs=output 告诉 PyTorch 我们会调用 .backward() 的内容,而 inputs=mixed 告诉 PyTorch 导致这个结果的初始输入。grad_outputs=torch.ones_like(output) 给 PyTorch 提供一个初始值来开始梯度计算,将其设置为全 1,这样我们最终得到所有部分的期望梯度。选项 create_graph=True, retain_graph=True 告诉 PyTorch 我们希望自动微分在结果上工作(这做了梯度的梯度):
eps_shape = [batch_size]+[1]*(len(data.shape)-1) ❶
eps = torch.rand(eps_shape, device=device)
mixed = eps*real + (1-eps)*fake
output = D(mixed)
grad = torch.autograd.grad(outputs=output, inputs=mixed,
➥ grad_outputs=torch.ones_like(output), create_graph=True,
➥ retain_graph=True, only_inputs=True, allow_unused=True)[0]
D_grad_penalty = ((grad.norm(2, dim=1) - 1) ** 2)
errD = (D_failure-D_success).mean() + D_grad_penalty.mean()*10 ❷
errD.backward()
optimizerD.step() ❸
❶ 现在计算用于计算梯度惩罚项的张量。
❷ 计算 D 的损失
❸ 更新 D
计算 WGAN-GP 损失
当 grad 变量最终到手时,我们可以快速计算总损失。*10 是控制惩罚强度的 λ 项。我将其硬编码为 10,因为这在这个方法中是一个很好的默认值,但更好的编码会将 lambda 作为函数的参数,默认值为 10。
第二步在代码的最后一段中计算 G 的更新,我们首先重新将梯度归零。这是一个防御性编码步骤,因为跟踪我们何时以及何时没有改变梯度是很棘手的。然后我们在noise变量中采样一个新的潜在变量 z,计算-D(G(noise)),取平均值,然后进行更新:
D.zero_grad() ❺
G.zero_grad() ❶
noise = torch.randn(batch_size, latent_d, device=device) ❷
output = -D(G(noise)) errG = output.mean() ❸
errG.backward() ❹
optimizerG.step() ❺
❶ 第 2 步:-D(G(z))
❷ 由于我们刚刚更新了 D,执行一个所有伪造批次通过 D 的前向传递。
❸ 基于此输出计算 G 的损失
❹ 计算 G 的梯度
❺ 更新 G
我们通过记录 G 和 D 各自的损失来结束函数,以便稍后查看,并将这些损失作为函数调用的结果返回:
D_losses.append(errD.item())
G_losses.append(errG.item())
return D_losses, G_losses
现在我们有了这个新的train_wgan函数,我们可以尝试在早期用九个高斯分布在 3 × 3 网格上的玩具问题上训练一个新的 Wasserstein GAN。我们只需调用以下代码片段:
G, D = simpleGAN(latent_d, 512, (-1, 2))
train_wgan(D, G, toy_loader, latent_d, epochs=20, device=device)
G, D = G.eval(), D.eval()
减少模式坍塌的结果
以下代码生成一些新的样本。结果并不完美,但它们比我们之前得到的好得多。GAN 已经学会了覆盖更广泛的输入数据,分布的形状更接近我们知道的真值。我们可以一般地看到所有九个模式都被表示出来,这是好的,尽管其中一些不如其他强烈:
with torch.no_grad():
noise = torch.randn(X.shape[0], latent_d, device=device)
fake_samples_w = G(noise).cpu().numpy()
plt.figure(figsize=(10,10))
ax = sns.kdeplot(x=fake_samples_w[:,0], y=fake_samples_w[:,1],
➥ shade=True, thresh=-0.001)
plt.xlim(-1.5, 1.5)
plt.ylim(-1.5, 1.5)
[27]: (-1.5, 1.5)

模式坍塌问题并没有通过 Wasserstein 方法得到解决,但它得到了极大的改善。Wasserstein 方法以及大多数 GAN 的一个缺点是它需要更多的迭代才能收敛。提高 WGAN 训练的一个常见技巧是对于生成器的每次更新,更新判别器五次,这进一步增加了获得最佳可能结果所需的总 epoch 数。对于大多数问题,你应该进行大约 200 到 400 个 epoch 来训练一个真正好的 GAN。即使使用 WGAN,如果你再次训练它,你经常会看到它坍塌。这是训练 GAN 的臭名昭著的一部分,但更多的数据和更多的训练 epoch 将提高其性能。
话虽如此,让我们回到我们最初的 MNIST 问题,看看我们的 WGAN-GP 是否提高了结果。我们重新定义潜在维度大小为 128,输出形状与 MNIST 相匹配。我们再次为 G 使用 sigmoid 输出,因为 MNIST 的值都在[0,1]的范围内。以下代码使用我们的新方法进行训练:
latent_d = 128
out_shape = (-1, 1, 28, 28)
G, D = simpleGAN(latent_d, neurons, out_shape, sigmoidG=True)
D_losses, G_losses = train_wgan(D, G, train_loader, latent_d, epochs=40,
➥ device=device)
G = G.eval()
D = D.eval()
注意:为什么更新判别器 D 的次数要多于更新生成器 G 的次数?这样判别器就有更多机会更新并捕捉到生成器 G 的行为。这是可取的,因为 G 只能变得和判别器 D 一样好。由于复杂性惩罚,这并没有给 D 带来太大的不公平优势。
现在我们生成一些合成数据,但我们不会查看分数:分数不再是概率,因此很难将其解释为单个值。这就是为什么我们将scores作为plot_gen_imgs的可选参数:
with torch.no_grad():
noise = torch.randn(batch_size, latent_d, device=device)
fake_digits = G(noise)
scores = D(fake_digits)
fake_digits = fake_digits.cpu()
scores = scores.cpu().numpy().flatten()
plot_gen_imgs(fake_digits)

总体而言,我们的 MNIST GAN 样本看起来比以前好得多。你应该能在大多数样本中找到所有 10 个数字的例子,即使有些数字有点难看或不规则。你也应该看到单个特定数字内的样式更加多样化!这些都是我们 GAN 质量的主观巨大改进。一个缺点是,我们的 0、3 和 8 现在没有我们之前在模式崩溃时那么清晰:我们的 WGAN-GP 可能需要更多的训练周期,而且它现在正在做更多的事情。当原始 GAN 崩溃时,它可以使用整个网络来表示仅三个数字。现在每个数字占用的网络更少,因为我们正在表示所有 10 个数字。这也是我建议将生成的结果与真实数据比较以决定结果是否良好的原因之一。高质量的崩溃结果可能会让你对 GAN 的质量产生错误的看法。
我们还可以再次绘制判别器和生成器的损失。以下代码执行此操作,并使用卷积来平滑平均损失,以便我们可以关注趋势。在解释 WGAN-GP 的损失时,请记住,生成器 G 希望有一个大的损失,而判别器 D 希望有一个小的损失:
plt.figure(figsize=(10,5))
plt.title("Generator and Discriminator Loss During Training")
plt.plot(np.convolve(G_losses, np.ones((100,))/100, mode=’valid’) ,label="G")
plt.plot(np.convolve(D_losses, np.ones((100,))/100, mode=’valid’) ,label="D")
plt.xlabel("iterations")
plt.ylabel("Loss")
plt.legend()
plt.show()

解释这个图,G 随着训练的进行而改进,这是好事,也是继续训练的理由。但是,判别器 D 似乎已经停止了改进,这可能是一个迹象,表明 D 现在不足以学习更好的表示。这是一个问题,因为 G 的质量只有当 D 在任务上变得更好时才会提高。我们可以做出的一个主要改变是实现卷积 GAN,这样生成器(和判别器)就能获得卷积的结构先验,并且(我们希望)两者都能做得更好!
9.4 卷积 GAN
与大多数深度学习应用一样,要改善 GAN 的结果,最佳方法是选择一个适合你问题的架构。由于我们正在处理图像,我们可以使用卷积架构来提高我们的结果。这需要对潜在表示 z 进行一些调整。我们的代码为 z 中的 D 维度创建了一个形状为(B,D)的向量。对于卷积 G,我们的潜在应该是什么形状?
9.4.1 设计卷积生成器
一种方法是创建一个形状为(B,C′,W′,H′)的潜在张量,这样它已经像图像一样形状,但没有正确的输出形状(B,C,W,H)。我们使这个潜在的张量的宽度和高度比真实数据小,并使用转置卷积将其扩展到真实数据的大小。但对于C′,我们希望使用比原始数据更多的通道。这样,模型就可以学会将潜在通道解释为具有不同类型的意义,而且只有一个或三个通道可能太小。
以下代码设置了训练卷积生成对抗网络的参数。首先,我们确定start_size,它将是初始宽度 W′和高度 H′。我们将 W′设置为 W/4,这样我们就可以通过转置卷积进行两轮扩展。如果我们不使用一些丑陋的代码,就不能再小了,因为那会让我们降到 28/4=7 像素:再次除以 2 将是 3.5 像素,这不是我们容易处理的事情。接下来,我们定义latent_channels C′为 16。这是一个超参数,我简单地选择了一个较小的值,如果我的结果不好,我会增加它。由于我们有一个形状为(B,C′,W′,H′)的潜在变量,我们定义in_shape元组来表示它,作为我们一直在使用的out_shape元组的对应物。我们的网络将使用in_shape来重塑 G 的输入;out_shape仍将用于重塑 G 的输出和 D 的输入:
start_size = 28//4 ❶
latent_channels = 16
latent_d_conv = latent_channels*(start_size**2) ❷
in_shape = (-1, latent_channels, start_size, start_size )
❶ 初始宽度和高度,以便我们可以进行两轮转置卷积
❷ 潜在空间中需要的值数量
此代码在设置我们问题的变量同时,也便于调整以适应新的问题。如果你有更大的图像,可以尝试使用超过两轮的扩展和超过 16 个潜在通道,这可以通过更改前两行轻松实现。
实现卷积辅助函数
在定义我们的卷积架构之前,我们需要几个变量。我们以n_filters=32个过滤器开始我们的生成器,并使用 LeakyReLU 激活函数的漏率 0.2。我们还定义了核大小为k_size=5。通常,我们使用 3x3 的核大小来增加 CNN 的深度。对于 GAN,我经常发现稍微大一点的核大小可以改善结果。特别是,如果我们使用步长的倍数作为核大小,我们的转置卷积将会有更平滑的输出,所以我们给它们一个单独的核大小k_size_t=4。这有助于确保当转置卷积扩展输出时,输出中的每个位置都会得到其值的均匀数量的贡献:
n_filters = 32 ❶
k_size= 5 ❷
k_size_t = 4 ❸
leak = 0.2
def cnnLayer(in_channels, out_channels, filter_size, ❹
➥ wh_size, leak=0.2):
return nn.Sequential(
nn.Conv2d(in_channels, out_channels, filter_size,
➥ padding=filter_size//2),
nn.LeakyReLU(leak),
nn.LayerNorm([out_channels, wh_size, wh_size]),
)
def tcnnLayer(in_channels, out_channels, wh_size, leak=0.2): ❺
return nn.Sequential(
nn.ConvTranspose2d(in_channels, out_channels, k_size_t,
➥ padding=1, output_padding=0, stride=2),
nn.LeakyReLU(leak),
nn.LayerNorm([out_channels, wh_size, wh_size]),
)
❶ 潜在空间中的通道数
❷ 默认用于卷积生成对抗网络的核大小
❸ 转置卷积的默认核大小
❹ 创建隐藏卷积层的辅助函数
❺ 与 cnnLayer 类似,但我们使用转置卷积来扩展大小
实现卷积生成对抗网络
现在我们定义我们的 CNN GAN。对于 G,我们从View(in_shape)开始,这样潜在向量 z 就变成了我们指定的所需输入形状:(B,C′,W′,H′)。接下来是一系列的卷积、激活和 LN 操作。记住,对于 CNN,我们需要跟踪输入的宽度和高度,这就是为什么我们将 MNIST 的高度 28x28 编码到我们的架构中。我们遵循在每次转置卷积前后使用两到三个 CNN 层的简单模式:
G = nn.Sequential(
View(in_shape),
cnnLayer(latent_channels, n_filters, k_size, 28//4, leak),
cnnLayer(n_filters, n_filters, k_size, 28//4, leak),
cnnLayer(n_filters, n_filters, k_size, 28//4, leak),
tcnnLayer(n_filters, n_filters//2, 28//2, leak),
cnnLayer(n_filters//2, n_filters//2, k_size, 28//2, leak),
cnnLayer(n_filters//2, n_filters//2, k_size, 28//2, leak),
tcnnLayer(n_filters//2, n_filters//4, 28, leak),
cnnLayer(n_filters//4, n_filters//4, k_size, 28, leak),
cnnLayer(n_filters//4, n_filters//4, k_size, 28, leak),
nn.Conv2d(n_filters//4, 1, k_size, padding=k_size//2),
nn.Sigmoid(),
)
9.4.2 设计卷积判别器
接下来,我们实现判别器 D。这需要从我们的正常设置中做出一些更多的改变。再次,这些是一些可以帮助改进你的 GANs 的技巧:
-
D 和 G 是非对称的。我们可以使 D 的网络比 G 小,以帮助 G 在竞争中取得优势。即使在 WGAN-GP 中的梯度惩罚下,D 仍然有一个更容易的学习任务。我发现的一个有用的经验法则是 D 的层数是 G 的三分之二。然后在一个训练运行中绘制 D 和 G 的损失,以查看 D 是否陷入困境(损失没有下降),此时应该变得更大,或者 G 是否陷入困境(损失没有增加),此时也许 D 应该缩小或 G 应该变得更大。
-
使用
nn.AvgPool2d而不是最大池化切换到平均池化。这有助于最大化梯度流动,因为每个像素都对答案有相同的贡献,所以所有像素都得到梯度的一部分。 -
使用
AdaptiveAvgPool或AdaptiveMaxPool函数结束你的网络,这最容易做到。这可以帮助 D 不依赖于值的精确位置,而我们所真正关心的是内容的整体外观。
以下代码将这些想法纳入 D 的定义中。再次,我们使用 LN,因此我们需要跟踪高度和宽度,从全尺寸开始然后缩小。我们最终使用 4 × 4 作为自适应池化的大小,因此下一个线性层有 4 × 4 = 16 个值每个通道作为输入。如果我们对 64 × 64 或 256 × 256 的图像做这件事,我可能会将自适应池化提升到 7 × 7 或 9 × 9:
D = nn.Sequential(
cnnLayer(1, n_filters, k_size, 28, leak),
cnnLayer(n_filters, n_filters, k_size, 28, leak),
nn.AvgPool2d(2), ❶
cnnLayer(n_filters, n_filters, k_size, 28//2, leak),
cnnLayer(n_filters, n_filters, k_size, 28//2, leak),
nn.AvgPool2d(2),
cnnLayer(n_filters, n_filters, 3, 28//4, leak),
cnnLayer(n_filters, n_filters, 3, 28//4, leak),
nn.AdaptiveAvgPool2d(4), ❷
nn.Flatten(),
nn.Linear(n_filters*4**2,256),
nn.LeakyReLU(leak),
nn.Linear(256,1),
)
❶ 为了避免稀疏梯度,我们使用平均池化而不是最大池化。
❷ 这是一种自适应池化,因此我们知道在这个点上大小是 4 × 4,以便在池化时更加激进(通常对卷积 GANs 有帮助)并且使编码更容易。
训练和检查我们的卷积 GAN
通过将 View 逻辑移动到网络而不是训练代码中,我们可以为我们的 CNN GANs 重复使用 train_wgan 函数。以下代码训练它们:
D_losses, G_losses = train_wgan(D, G, train_loader, latent_d_conv,
➥ epochs=15, device=device)
G = G.eval()
D = D.eval()
十个时期对于训练一个 GAN 来说不是很多时间,但接下来我们可视化了一些 CNN GANs 的随机样本,数字看起来比之前好得多。再次,卷积方法在图像上工作得更好并不令人惊讶,但我们确实需要学习一些额外的技巧来使这工作得很好:
with torch.no_grad():
noise = torch.randn(batch_size, latent_d_conv, device=device)
fake_digits = G(noise)
scores = D(fake_digits)
fake_digits = fake_digits.cpu() scores =
scores.cpu().numpy().flatten()
plot_gen_imgs(fake_digits)

更多的训练轮数会改善我们的 WGAN-GP 吗?以下代码再次绘制了每个批次中 G 和 D 损失的平滑版本。有一个明显的趋势,G 的得分在增加,这表明 G 在生成方面变得更好;但 D 的得分却保持平稳(没有变好或变坏)。更多的训练可能会改善这一点,但这并不保证。如果 D 的得分在下降(因为 D 想要负值),使得图表呈漏斗状(前 300 次迭代就有这种形状),那就更好了。这意味着 G 和 D 都在改进,我们更有信心更多的训练轮数会改善 GAN 的结果:
plt.figure(figsize=(10,5))
plt.title("Conv-WGAN Generator and Discriminator Loss")
plt.plot(np.convolve(G_losses, np.ones((100,))/100, mode=’valid’) ,label="G")
plt.plot(np.convolve(D_losses, np.ones((100,))/100, mode=’valid’) ,label="D")
plt.xlabel("iterations")
plt.ylabel("Loss")
plt.legend()
plt.show()

9.5 条件 GAN
另一种帮助改善 GAN 训练的方法是创建一个条件 GAN。条件 GAN 是监督学习而不是无监督学习,因为我们使用了属于每个数据点 x 的标签 y。如图 9.7 所示,这是一个相当简单的改变。

图 9.7 展示了条件生成对抗网络(GAN)的例子,其中 D(判别器)出现了错误,而 G(生成器)表现良好。唯一的改变是我们将数据的标签 y 作为输入同时提供给 G 和 D。G 通过获取标签来知道它需要创建什么,而 D 通过获取标签来知道它在寻找什么。
我们不是要求模型从 x 预测 y,而是告诉 G 生成 y 的一个示例。你可以将正常的 GAN 视为要求模型生成任何真实数据,而条件 GAN 则要求模型生成我们可以将其分类为 y 的真实数据。这通过提供额外信息帮助模型。它不需要自己确定有多少个类别,因为通过提供标签 y,你告诉模型 G 要生成什么类型的数据。为了实现这一点,我们还向判别器 D 提供了关于 y 的信息。
另一种思考方式是,条件 GAN 允许我们学习一种一对一映射。我们之前所有的神经网络都是一对一的。对于任何输入 x,都有一个正确的输出 y。但如图 9.8 所示,条件模型让我们为任何有效的输入创建多个有效的输出。

图 9.8 展示了条件 GAN 作为一对一映射的例子。输入是“猫”,右侧的输出显示了多个有效的输出。为了获得这种多样化的输出,潜在变量 z 为 G 提供了创建多个输出的方法。
使用条件 GAN 进行一对一映射也允许我们开始操纵 G 生成的结果。给定一个单个的潜在向量 z,我们可以要求模型使用G(z∣y=1)生成一个 1,或者使用G(z∣y=3)生成一个 3。如果我们使用相同的 z 值做这两件事,生成的 1 和 3 将具有相似的特征,如线条粗细和倾斜。
9.5.1 实现条件 GAN
我们可以不费吹灰之力实现条件 GAN。要做到这一点,我们首先需要修改 G 和 D 以接受两个输入:潜在变量 z 和标签 y。让我们定义一个ConditionalWrapper类,它为我们完成这项工作。我们的方法是Conditional- Wrapper将接受一个正常的 G 或 D 网络,并将其用作子网络,类似于我们实现 U-Net 的方式。ConditionalWrapper接受 z 和 y,并将它们组合成一个新的潜在值ẑ。然后我们将新的潜在值ẑ传递给原始网络(G 或 D)。
以下Module定义实现了这个想法。在构造函数中,我们创建了一个nn.Embedding层,将标签 y 转换为与 z 相同大小的向量。我们还创建了一个combiner网络,它接受一个大小是潜在向量 z 两倍的输入,并返回一个与 z 相同大小的输出。这个网络故意很小,没有超过两个隐藏层,所以它刚好足够组合我们的两个输入值 z 和 y。
forward函数随后可以以非常少的步骤进行操作。它将 y 嵌入到一个向量中。由于 y 的嵌入和 z 具有相同的形状,我们可以将它们连接在一起以形成一个双倍大小的输入。这个输入进入combiner,然后直接进入原始net(G 或 D)。
下面是代码:
class ConditionalWrapper(nn.Module):
def __init__(self, input_shape, neurons, classes, main_network,
➥ leak=0.2):
"""
input_shape: the shape that the latent variable z
➥ should take.
neurons: neurons to use in hidden layers
classes: number of classes in labels y
main_network: either the generator G or discriminator D
"""
super().__init__() self.input_shape = input_shape
self.classes = classes
input_size = abs(np.prod(input_shape)) ❶
self.label_embedding = nn.Embedding(classes, input_size) ❷
self.combiner = nn.Sequential( ❸
nn.Flatten(),
fcLayer(input_size*2, input_size, leak=leak), ❹
nn.Linear(input_size, input_size), ❺
nn.LeakyReLU(leak),
View(input_shape), ❻
nn.LayerNorm(input_shape[1:]),
)
self.net = main_network
def forward(self, x, condition=None): ❼
if condition is None: ❽
condition = torch.randint(0, self.classes, size=(x.size(0),),
➥ device=x.get_device())
embd = self.label_embedding(condition) ❾
embd = embd.view(self.input_shape) ❿
x = x.view(self.input_shape)
x_comb = torch.cat([x, embd], dim=1) ⓫
return self.net(self.combiner(x_comb)) ⓬
❶ 确定潜在形状的潜在参数数量
❷ 创建一个嵌入层以将标签转换为向量
❸ 在前向函数中,我们将标签和原始数据连接成一个向量。然后这个combiner接受这个非常大的张量,并创建一个大小仅为原始input_shape的新张量。这完成了将条件信息(来自标签嵌入)合并到潜在向量中的工作。
❹ 一个全连接层
❺ 第二个全连接层,但首先应用线性层和激活函数
❻ 因此我们可以重塑输出并基于目标输出形状进行归一化。这使得条件包装器对线性模型和卷积模型非常有用。
❼ 前向函数接收一个输入并产生一个输出。
❽ 如果没有给出标签,我们可以随机选择一个。
❾ 将标签嵌入并按需重塑
❿ 确保标签嵌入和数据 x 具有相同的形状,这样我们就可以将它们连接起来
⓫ 将潜在输入与嵌入的标签连接
⓬ 返回网络在组合输入上的结果
9.5.2 训练条件 GAN
使用此代码,将我们的普通全连接生成对抗网络(GANs)转换为条件 GANs 变得容易。以下代码片段创建了一个新的全连接 GAN。我们唯一改变的是定义classes=10的数量,并使用我们新的ConditionalWrapper独立包装 G 和 D:
latent_d = 128
out_shape = (-1, 1, 28, 28)
in_shape = (-1, latent_d)
classes = 10
G, D = simpleGAN(latent_d, neurons, out_shape, sigmoidG=True)
G = ConditionalWrapper(in_shape, neurons, classes, G)
D = ConditionalWrapper(out_shape, neurons, classes, D)
现在我们需要一种方法来训练这些条件模型,因为 train_wgan 函数不使用标签。这是一个简单的改动。我们可以定义一个新的函数 train_c_wgan,它具有完全相同的代码,除了每次我们在代码中有 G(noise) 时,我们将其更改为 G(noise, class_label)。同样,每次我们看到类似 D(real) 的内容时,我们将其更改为 D(real, class_label)。就是这样——我们只需在每次使用 G 或 D 时添加 , class_label)!这为我们提供了创建条件 GANs 的工具和代码。以下代码块训练了我们刚刚定义的模型:
D_losses, G_losses = train_c_wgan(D, G, train_loader, latent_d,
➥ epochs=20, device=device)
G = G.eval()
D = D.eval()
9.5.3 使用条件 GANs 控制生成
让我们可视化这个新 GAN 的结果,并展示我们如何同时控制这个过程。以下代码生成 10 个不同的潜在向量 z,并为每个 z 值创建 10 个副本。然后我们创建一个 labels 张量,从 0 到 9 计数,覆盖所有 10 个类别。这样,我们创建 G(z∣y=0), G(z∣y=1), …, G(z∣y=9)。
每个数字都是在使用相同的潜在向量生成。因为条件控制生成的类别,潜在 z 被迫学习 风格。如果你查看每一行,你将看到在每种情况下,一些基本风格在所有输出中都被保持,无论类别如何。
以下是代码:
with torch.no_grad():
noise = torch.randn(10, latent_d, device=device). ❶
➥ repeat((1,10)).view(-1, latent_d) labels =
torch.fmod(torch.arange(0, noise.size(0), ❷
➥ device=device), classes)
fake_digits = G(noise, labels) ❸
scores = D(fake_digits, labels)
fake_digits = fake_digits.cpu()
scores = scores.cpu().numpy().flatten()
plot_gen_imgs(fake_digits) ❹
❶ 生成 10 个潜在噪声向量,并重复 10 次。我们重用相同的潜在代码。
❷ 从 0 到 9 计数,然后回到 0。这重复了 10 次。
❸ 使用相同的潜在噪声生成 10 张图像,但每次都更改标签。
❹ 当我们绘制结果时,我们应该看到从 0 到 9 的数字网格,其中每一行使用相同的潜在向量,并具有相似的可视属性。

注意,如果一行开头的 0 有粗线,那么该行中所有的数字都有粗线。如果 0 向右倾斜,那么所有的数字都向右倾斜。这就是条件 GANs 让我们控制生成内容的方式。我们可以将这种方法扩展到更多条件属性,这将使我们能够更多地控制 G 生成输出内容和方式。但这需要大量的标记输出,而这现在总是有可能的。
9.6 遍历 GANs 的潜在空间
我们已经看到 GANs 可以在创建假数据方面做得非常出色,随着训练的进行,潜在表示 z 开始学习关于数据的有趣属性。即使我们没有标签,这也是正确的。但我们也可以通过改变潜在向量 z 本身来控制 GAN 生成的结果。这样做可以让我们通过仅标记少量图像来操纵图像,从而确定如何正确地改变 z。
9.6.1 从 Hub 获取模型
由于训练 GAN 是昂贵的,在本节中我们使用 PyTorch Hub 下载一个预训练的 GAN。Hub 是一个人们可以上传有用预训练模型的存储库,PyTorch 内置了对这些模型的下载和使用集成。我们将下载一个在更高分辨率的面部图像上训练的示例,以便比 MNIST 更有趣地查看。
首先要做的:我们从 Hub 加载所需的 GAN 模型。这是通过 hub.load 函数完成的,其中第一个参数是要加载的存储库,后续参数取决于存储库。在这种情况下,我们加载了一个名为 PGAN 的模型,该模型是在名人高分辨率数据集上训练的。我们还加载了 torchvision 包,它为 PyTorch 提供了视觉特定的扩展:
import torchvision
model = torch.hub.load(’facebookresearch/pytorch_GAN_zoo:hub’, ’PGAN’,
➥ model_name=’celebAHQ-512’, pretrained=True, useGPU=False)
PGAN Hub 模型的具体细节
加载到 Hub 的代码定义了一个 buildNoiseData 函数,它接受我们想要生成的样本数量,并生成用于生成这么多图像的噪声向量。然后我们可以使用模型的 test 方法从噪声生成图像。让我们试试吧!
num_images = 2
noise, _ = model.buildNoiseData(num_images)
with torch.no_grad():
generated_images = model.test(noise)
使用 torchvision 辅助函数绘制图像,你应该会看到一个合成的男性和女性:
grid = torchvision.utils.make_grid(generated_images.clamp(min=-1, max=1),
➥ scale_each=True, normalize=True)
plt.imshow(grid.permute(1, 2, 0).cpu().numpy())
[50]: <matplotlib.image.AxesImage at 0x7f2abd028f90>

就像我们之前的 GAN 一样,这些图像是通过学习将噪声转换为逼真图像创建的!如果我们打印噪声值,我们会看到来自高斯分布的随机采样值(下面没有隐藏任何魔法):
print(noise)
tensor([[-0.0766, 0.3599, -0.7820, {\ldots}, -1.0038, 0.5046, -0.4253],
[ 0.5643, 0.4867, 0.2295, {\ldots}, 1.1609, -1.2194, 0.8006]])
9.6.2 插值 GAN 输出
GAN 的一个酷特性是,当你从数学上操作时,学习到的潜在值 z 往往表现得很好。所以如果我们取我们的两个噪声样本,我们可以在噪声向量之间进行插值(比如说,一个的 50% 和另一个的 50%),以生成图像的插值。这通常被称为 在潜在空间中行走:如果你有一个潜在向量,并走到第二个潜在向量的一定距离,你最终得到的是代表你原始和目标潜在值的混合体。
如果我们将此应用于我们的两个样本,男性图像会逐渐过渡到女性,头发变暗成棕色,笑容变宽。在代码中,我们从第一个图像到第二个图像在潜在空间中移动了八步,改变了每个潜在对结果贡献的比例:
steps = 8 interpolated_z = [] ❶
for x in torch.arange(0,steps)/float(steps)+0.5/steps:
z_mix = x*noise[0,:] +
(1-x)*noise[1,:] ❷
interpolated_z.append(z_mix)
with torch.no_grad(): ❸
mixed_g = model.test(torch.stack(interpolated_z)).clamp(min=-1, max=1)
grid = torchvision.utils.make_grid(
mixed_g.clamp(min=-1, max=1), scale_each=True,
➥ normalize=True) ❹
plt.figure(figsize=(15,10)) plt.imshow(grid.permute(1, 2, 0).cpu().numpy())
[52]: <matplotlib.image.AxesImage at 0x7f2af48f42d0>
❶ 保存插值图像的位置
❷ 取第一个潜在向量的步长/步数和第二个的 (1 - 步长/步数),也就是行走
❸ 从插值中生成图像
❹ 当可视化生成的输出时,看起来像是一种混合。

我们甚至可以更进一步。如果我们愿意对我们的数据集中的几个实例进行标记,我们可以提取具有语义意义的向量。图 9.9 展示了这在高层次上可能的工作方式。

图 9.9 生成图像的示例以及你可能能找到的语义向量类型。左侧显示的是 G 的原始潜在变量及其相关输出。通过添加这些语义向量,我们可以改变生成的图像。这些被称为潜在向量,因为我们没有告诉 GAN 它们是什么:它们对 GAN 是隐藏的,GAN 必须从数据模式中自己学习它们。
这基本上意味着我们可以找到一个向量 z[smile],我们可以将其添加到任何其他潜在向量中,使其某人微笑,或者从另一个潜在向量中减去以移除微笑。好事是,我们 从未告诉 G 关于任何这些潜在属性的信息。G 学习自己这样做,如果我们能发现语义向量,我们就可以进行这些修改!为此,让我们首先生成一些随机的图像来使用:
set_seed(3) ❶
noise, _ = model.buildNoiseData(8*4) ❷
with torch.no_grad():
generated_images = model.test(noise)
grid = torchvision.utils.make_grid(
generated_images.clamp(min=-1, max=1),
➥ scale_each=True, normalize=True) ❸
plt.figure(figsize=(13,6))
plt.imshow(grid.permute(1, 2, 0).cpu().numpy())
[53]: <matplotlib.image.AxesImage at 0x7f2abc505710>
❶ 获取一致的结果
❷ 通过添加性别向量到我们的原始潜在向量来生成新的图像
❸ 可视化它们

9.6.3 标记潜在维度
现在我们有 32 个示例面孔。如果我们能识别出我们对每个生成的图像关心的某些属性,我们就可以对它们进行标记,并尝试学习哪些噪声控制了输出的不同方面。³ 让我们识别出男性和女性,以及那些微笑或不微笑的人。我为每个属性创建了一个数组,对应于每个图像,因此我们有 32 个“男性”标签和 32 个“微笑”标签。本质上,我们正在为我们关心的属性创建自己的标签 y,但我们将能够用很少的标记示例提取这些语义⁴ 向量。G 已经独立学习了概念:
male = [0, 1, 0, 0, 1, 0, 0, 0, ❶
1, 1, 1, 1, 0, 1, 0, 0, ❶
1, 0, 0, 1, 0, 0, 1, 0, ❶
0, 0, 0, 0, 0, 0, 1, 0] ❶
smile = [1, 1, 0, 0, 1, 0, 1, 1, ❶
0, 0, 0, 0, 0, 1, 0, 0, ❶
1, 0, 1, 0, 1, 1, 1, 1, ❶
0, 0, 1, 1, 0, 0, 0, 1] ❶
male = np.array(male, dtype=np.bool)
smile = np.array(smile, dtype=np.bool)
male = torch.tensor(np.expand_dims(male, axis=-1)) ❷
smile = torch.tensor(np.expand_dims(smile, axis=-1)) ❷
❶ 为哪些图像显然是男性或微笑的进行标记。我手动检查生成的图像以创建这些列表。
❷ 将形状从 (32) 转换为 (32, 1)
计算语义向量
现在我们想计算平均男性向量和平均非男性向量。我们对微笑也做同样的事情,所以让我们定义一个简单的函数,该函数使用二进制标签 male 和 smile 来提取表示差异的向量。我们生成的标签越多,结果越好:
def extractVec(labels, noise):
posVec = torch.sum(noise*labels, axis=0)/torch.sum(labels) ❶
negVec = torch.sum(noise* labels, axis=0)/torch.sum( labels) ❷
return posVec-negVec ❸
❶ 获取所有具有类别标签 0 的平均值
❷ 所有具有类别标签 1 的平均值
❸ 通过取平均值之间的差异来近似潜在概念之间的差异
使用语义向量操纵图像
现在我们可以使用 extractVec 函数提取一个男性性别向量。如果我们将其添加到任何潜在向量 z 中,我们就会得到一个新的潜在向量,使其更具男性特征。生成的图像中的其他所有内容通常应保持不变,例如背景、发色、头部位置等。让我们试试:
gender_vec = extractVec(male, noise) ❶
with torch.no_grad():
generated_images = model.test(noise+gender_vec) ❷
grid = torchvision.utils.make_grid(
➥ generated_images.clamp(min=-1, max=1),
➥ scale_each=True, normalize=True) ❸
plt.figure(figsize=(13,6))
plt.imshow(grid.permute(1, 2, 0).cpu().numpy())
[56]: <matplotlib.image.AxesImage at 0x7f2abc4f5850>
❶ 提取性别向量
❷ 通过将性别向量添加到我们的原始潜在向量来生成新的图像
❸ 绘制结果

总体来说,结果相当不错。虽然不是完美无缺,但我们并没有使用很多例子来发现这些向量。我们还可以减去这个性别向量,从图像中去除男性特征。在这种情况下,我们会让每个图像看起来更女性化。以下代码通过简单地改变+为-来实现这一点:
with torch.no_grad():
generated_images = model.test(noise-gender_vec)
grid = torchvision.utils.make_grid(generated_images.clamp(min=-1, max=1),
➥ scale_each=True, normalize=True)
plt.figure(figsize=(13,6))
plt.imshow(grid.permute(1, 2, 0).cpu().numpy())
[57]: <matplotlib.image.AxesImage at 0x7f2abd67d590>

也许你希望每个人都快乐!我们可以使用我们提取的smile向量来做同样的事情,让每个人笑得更多:
smile_vec = extractVec(smile, noise)
with torch.no_grad():
generated_images = model.test(noise+smile_vec)
grid = torchvision.utils.make_grid(generated_images.clamp(min=-1, max=1),
➥ scale_each=True, normalize=True)
plt.figure(figsize=(13,6))
plt.imshow(grid.permute(1, 2, 0).cpu().numpy())
[58]: <matplotlib.image.AxesImage at 0x7f2ab47d0750>

9.7 深度学习中的伦理
在第四章中,我们简要地偏离了主题,讨论了理解我们如何建模世界以及使用这些模型如何影响他人感知世界的重要性。现在我们已经了解了 GANs,这是一个值得再次讨论的话题。
GANs 学习到的语义概念基于数据,并不一定代表世界运作的真实情况。例如,大多数人都会认同并表现出自己是男性或女性,这在我们的数据中有所体现,因此 GAN 学习到男性和女性之间的一种相对线性的关系,作为一个统一的光谱。但这并不符合许多不适合仅限于男性或女性类别的人的现实。因此,在使用 GAN 对图像进行性别操纵之前,你应该三思而后行。
对于你构建的任何系统(机器学习或其他),你应该问自己一些简单的问题:它将如何影响使用该系统的多数人?它将如何影响使用该系统的少数人?是否有受益或受损的人,这些人是否应该得到这种利益或损害?你的部署是否会以积极或消极的方式改变人们的行为,或者甚至被有良好意图的用户滥用?通常,从微观和宏观层面思考可能出错的地方。
我并不是试图向你强加任何哲学或道德信念体系。伦理是一个非常复杂的话题,我无法在一个章节的小节中公正地处理它(而且这也不是本书的主题)。但是,随着深度学习的发展,我们能够自动化许多不同的事情。这可能会给社会带来好处,让人们从繁重和耗人的工作中解放出来,但它也可能以新的高效规模放大和复制不希望存在的差异。因此,我希望你有所警觉,并开始训练自己思考这些类型的考虑。以下是一些你可以使用的资源链接,以帮助你加深对这些问题的理解:
-
首先,一篇较老的文章,它从更广泛的角度捕捉了许多这些担忧:B. Friedman 和 H. Nissenbaum,“计算机系统中的偏见”,ACM Trans. Inf. Syst. 第 14 卷,第 3 期,第 330-347 页,1996 年,
doi.org/10.1145/230538.230561,nissenbaum.tech.cornell.edu/papers/Bias%20in%20Computer%20Systems.pdf。 -
我之前提到了凯特·克劳福德(www.katecrawford.net),我还推荐蒂姆尼特·盖布卢(
scholar.google.com/citations?user=lemnAcwAAAAJ)和鲁本·宾斯(www.reubenbinns.com/)。 -
尤其是鲁本·宾斯的论文“机器学习中的公平性:政治哲学的启示”,R. Binns,机器学习研究会议论文集 第 81 卷,第 1-11 页,2018 年,
proceedings.mlr.press/v81/binns18a/binns18a.pdf。它易于理解,并从不同的角度阐述了棘手的公平性概念意味着什么。
你不需要掌握这些知识就能成为一名优秀的深度学习研究人员或实践者,但你应该学会思考和考虑这些问题。如果你知道得足够多,仅仅说,“这可能会是一个问题;我们应该寻求一些指导”,你比许多今天的实践者更有准备。
尤其是操纵图像的能力,导致了被称为深度伪造的研究领域和问题。GANs 是这个问题的关键组成部分。它们被用来模仿声音和改变视频,使口型与提供的音频相匹配,并且它们是误导他人的强大工具。你现在已经知道足够多的知识来开始构建或使用能够做到这一点的系统,而这也带来了考虑这些行动和后果的责任。如果你将代码发布到网上,它是否容易被滥用来伤害他人?将代码放入公共领域的利益是什么?这些利益是否超过了危害?在这些情况下,你也可能想要考虑如何减轻这些危害。如果你创建了一个可能被滥用的生成器,也许你可以致力于开发一个检测器,可以判断图像是否来自你的模型/方法,从而帮助检测任何恶意使用。
这些问题可能看起来很抽象,但随着你技能和能力的提升,它们将成为实际问题。例如,最近的一份报告⁵显示,深度伪造正被用来骗取人们数十万美元。我的目标不是强迫你采取任何特定的行动;正如我之前所说的,道德不是一个黑白分明、答案简单的问题。但这是你在工作时应该开始深思熟虑的问题。
练习
在 Manning 在线平台 Inside Deep Learning 练习中分享和讨论你的解决方案(liveproject.manning.com/project/945)。一旦你提交了自己的答案,你将能够看到其他读者提交的解决方案,并看到作者认为哪些是最好的。
-
提高卷积生成器 G 的另一个技巧是从一个或两个全连接的隐藏层开始,并将它们的输出重塑为一个张量 (B,C′,W′,H′)。试着亲自实现这个方法。你认为结果看起来更好吗?
-
我们在
ConditionalWrapper类中的combiner网络是为全连接网络设计的。这将是我们判别器 D 使用时的问题。修改ConditionalWrapper类,当input_shape指示main_network是 CNN 时,为combiner定义一个小型 CNN。使用此方法训练条件 CNN GAN。 -
操作新图像 x 的一个挑战是为其获取一个潜在向量 z。修改
train_wgan函数以接受一个可选的网络 E,该网络是 编码器 网络。E 接受图像 x 并尝试预测生成 x 的潜在向量 z。因此,它将有一个损失 ∥E(G(z)) − z∥[2]。测试编码器,并可视化 G(z) 生成的图像,然后 G(E(G(Z))),然后 G(E(G(E(G(Z)))))(即生成一个图像,然后生成该图像的编码版本,然后生成前一个图像的编码版本)。 -
你可以创建解决复杂任务(如 修复)的 GAN,其中你需要填充图像的缺失部分。用上一章的 U-Net 架构替换生成器 G。使用
RandomErasing转换创建噪声输入
,这是 GAN 的输入。判别器保持不变,并且不再有潜在变量 z。训练此模型,并检查它在修复任务上的表现如何。 -
一旦你让练习 3 运行起来,就创建一个新的版本,其中你的 U-Net G 接收噪声图像
和潜在向量 z。这个模型与第一个模型的行为有何不同? -
挑战性: 阅读论文“Image-to-Image Translation with Conditional Adversarial Networks” (
arxiv.org/abs/1611.07004)。它定义了一个名为 pix2pix 的 GAN。看看你是否能理解论文的大部分内容。如果你觉得有勇气,尝试自己实现它,因为你已经了解并使用了论文中的每个组件! -
使用 PGAN 模型生成更多图像,并创建自己的语义向量来操作它们。
摘要
-
GAN 通过设置两个相互对抗的网络来工作。每个网络都帮助对方学习。
-
GAN 是一种生成建模方法,这意味着生成器 G 模型比判别器 D 模型有更困难的任务。这种难度差异导致了一个称为模式坍塌的问题,其中生成器只关注数据的较容易的部分。
-
几种减少梯度消失的细微技巧帮助我们更快、更好地训练 GAN,无论是全连接还是卷积架构。
-
GAN 可能会遭受模式坍塌,导致它们过度关注最常见或最简单的事物,而不是学习生成多样化的输出。
-
Wasserstein GAN 在判别器上增加了一个惩罚项,防止它过于擅长 GAN 游戏,从而有助于减少模式坍塌。
-
我们可以在某些标签上对 GAN 的输出进行条件化,使我们能够创建一对一或多对一模型,并控制 GAN 生成的内容。
-
GANs 学习具有语义意义的潜在表示。我们可以提取重复某个概念的向量,并使用它们来操纵图像。
-
GANs 提高了考虑模型伦理影响的需求,并且在部署模型时,你应该始终问自己可能会出什么问题。
¹ 由 M. Arjovsky、S. Chintala 和 L. Bottou 提出,“Wasserstein 生成对抗网络”,第 34 届国际机器学习会议论文集,第 70 卷,第 214–223 页,2017 年。我们展示了 I. Gulrajani、F. Ahmed、M. Arjovsky、V. Dumoulin 和 A. C. Courville 的版本,“改进 Wasserstein GAN 的训练”,神经信息处理系统进展,第 30 卷,第 5767–5777 页,2017 年。↩
² 虽然有一个先出现的 WGAN,但我发现 WGAN-GP 更容易使用,并且其行为更一致。↩
³ 更好的方法是一个活跃的研究问题,但我们的简单方法效果惊人。↩
⁴ 人们对于“语义”的含义有着许多不同的心理模型,这对许多研究人员来说是个烦恼。我们不会陷入这个兔子洞。↩
⁵ L. Edmonds,“诈骗者使用深度伪造视频在 Skype 聊天中冒充美国海军上将,从加州寡妇那里骗走了近 30 万美元,”每日邮报,2020 年,mng.bz/2j0X。↩
10 注意力机制
本章涵盖
-
理解注意力机制及其使用时机
-
为注意力机制添加上下文以实现上下文敏感的结果
-
使用注意力处理可变长度项目
想象一下在繁忙的咖啡馆和几个朋友聊天。周围有其他对话和人们下单以及用手机交谈。尽管有所有这些噪音,但你,凭借你复杂而精致的大脑和耳朵,可以只关注重要的东西(你的朋友!),并选择性地忽略你周围发生的、不相关的事情。这里重要的是你的注意力是适应性的。只有当没有更重要的事情发生时,你才会忽略背景声音,只听你的朋友。如果响起火灾警报,你会停止关注你的朋友,将注意力集中在这个新的、重要的事情上。因此,注意力是关于适应输入的相对重要性。
深度学习模型也可以学习关注某些输入或特征,而忽略其他特征。它们通过注意力机制来完成这项任务,这是我们可以强加给网络的另一种先验信念。注意力机制帮助我们处理输入中可能部分无关的情况,或者当我们需要关注模型中输入的多个特征中的一个时。例如,如果你正在将一本书从英语翻译成法语,你不需要理解整本书就能翻译第一句话。你将在英语翻译中输出的每个单词将仅取决于同一句子中附近的一两个单词,你可以忽略大多数周围的法语句子和内容。
我们希望赋予我们的网络忽略多余和分散注意力的输入,专注于最重要的部分的能力,这就是注意力机制的目标。如果你认为你的某些输入特征相对于其他特征更重要或更不重要,你应该考虑在你的模型中使用基于注意力的方法。例如,如果你想实现语音识别、目标检测、聊天机器人或机器翻译等领域的最先进结果,你可能会使用注意力机制。
在本章中,我们通过一些玩具问题来了解注意力机制的工作原理,以便在下一章中,我们可以构建更加复杂和高级的东西。首先,我们从 MNIST 数据集中创建一个玩具问题,这个问题对于一个普通网络来说太难解决,但通过一种简单的注意力机制可以轻松且更好地解决,这种机制学会了如何评估输入中每个项目的重要性。然后,我们将简单的注意力机制改进为一个完整的方案,该方案考虑了一些上下文,以更好地推断输入中项目的重要性。这样做也将使我们能够使注意力机制适用于可变长度的数据,这样我们就可以处理填充数据。
10.1 注意力机制学习相对输入的重要性
既然我们已经讨论了注意力的直觉,让我们创建一个玩具数据集。我们将修改 MNIST 数据集以创建一种新的任务,所以让我们快速加载它:
mnist_train = torchvision.datasets.MNIST("./", train=True,
➥ transform=transforms.ToTensor(), download=True)
mnist_test = torchvision.datasets.MNIST("./", train=False,
➥ transform=transforms.ToTensor(), download=True)
注意力机制在我们有 多个项目 作为模型输入时最有用。由于 MNIST 是一个单个数字,我们将 MNIST 中的每个项目增强为数字袋子。我们为此使用全连接层(即平展的 MNIST,忽略图像性质),因此我们不再有一个数字批(B,D),而是一个 T 个数字(B,T,D)。那么我为什么叫它袋子而不是序列呢?因为我们不关心数字在张量中的呈现顺序。我们只需要一个足够大的张量来容纳袋子中的所有内容。
给定一个包含数字 x[1],x[2],…,x[T] 的数字袋子,我们有一个标签 y,它等于袋子中的最大数字。如果我们的袋子包含 0,2,9,那么这个袋子的标签就是“9。”以下代码实现了一个 LargestDigit 类来包装输入数据集,并通过随机填充一个包含 toSample 个项目的袋子并选择最大标签值来创建新项目:
class LargestDigit(Dataset):
"""
Creates a modified version of a dataset where some number of samples
➥ are taken, and the true label is the largest label sampled. When
➥ used with MNIST, the labels correspond to their values (e.g., digit "6"
➥ has label 6)
"""
def __init__(self, dataset, toSample=3):
"""
dataset: the dataset to sample from
toSample: the number of items from the dataset to sample
"""
self.dataset = dataset
self.toSample = toSample
def __len__(self):
return len(self.dataset)
def __getitem__(self, idx):
selected = np.random.randint(0,
➥ len(self.dataset), size=self.toSample) ❶
x_new = torch.stack([self.dataset[i][0] for i in selected]) ❷
y_new = max([self.dataset[i][1] for i in selected]) ❸
return x_new, y_new ❹
❶ 从数据集中随机选择 n=self.toSample 个项目
❷ 将形状为 (B, *) 的 n 个项目堆叠成 (B, n, *)
❸ 标签是最大标签。
❹ 返回 (data, label) 对!
注意为什么不说这是一个集合呢?集合意味着不允许重复,而一个袋子允许重复发生。这符合 Python set类的行为,其中重复的项会自动从集合中移除。我们正在创建的问题类型类似于一个名为多实例学习的研究领域,¹,如果你想了解其他在数据袋上工作的模型类型。
这是对模型来说更难的一个版本,需要从 MNIST 数据集中学习。给定一个带有标签的袋子,模型必须自行推断输入中的哪个项目是最大的,使用这个信息来逐渐学会识别所有 10 个数字,并学会这些数字是有序的,然后返回袋子中的最大数字。
10.1.1 训练我们的基线模型
以下代码块设置了我们的训练/测试加载器,并使用批大小 B = 128 个项目和 10 个训练周期:
B = 128
epochs = 10
largest_train = LargestDigit(mnist_train)
largest_test = LargestDigit(mnist_test)
train_loader = DataLoader(largest_train, batch_size=B, shuffle=True)
test_loader = DataLoader(largest_test, batch_size=B)
如果我们绘制数据集中的一个项目,我们应该看到修改后的数据集与我们描述的相匹配。以下代码从数据集中随机抽取一个样本并获取数字 8,2 和 6. “8” 是最大的标签,所以 8 是正确答案。在这种情况下,数字 2 和 6 并不重要,因为 2 < 8 和 6 < 8. 它们可以是任何小于 8 的数字,以任何顺序排列,结果都不会改变。我们希望我们的模型学会忽略较小的数字:
x, y = largest_train[0]
f, axarr = plt.subplots(1,3, figsize=(10,10))
for i in range(3):
axarr[i].imshow(x[i,0,:].numpy(), cmap=’gray’, vmin=0, vmax=1)
print("True Label is = ", y)
True Label is = 8

现在我们有了这个玩具问题,让我们训练一个简单的全连接网络,并将其视为我们可能尝试的任何其他分类问题。这将是我们的基线,并展示新版本的 MNIST 有多难:
neurons = 256
classes = 10
simpleNet =
nn.Sequential( nn.Flatten(),
nn.Linear(784*3,neurons), ❶
nn.LeakyReLU(),
nn.BatchNorm1d(neurons),
nn.Linear(neurons,neurons),
nn.LeakyReLU(),
nn.BatchNorm1d(neurons),
nn.Linear(neurons,neurons),
nn.LeakyReLU(),
nn.BatchNorm1d(neurons),
nn.Linear(neurons, classes )
)
simple_results = train_network(simpleNet, nn.CrossEntropyLoss(),
➥ train_loader, val_loader=test_loader, epochs=epochs,
➥ score_funcs=’Accuracy’: accuracy_score, device=device)
❶ 784*3,因为一个图像中有 784 个像素,捆绑中有 3 个图像
我们已经训练好了模型,可以绘制结果。由于这是 MNIST,我们只需使用全连接层就可以轻松地获得 98%的准确率。但是,这个捆绑数据集导致全连接网络几乎只能达到 92%的准确率,即使我们使用了像 LeakyReLUs 和批量归一化这样的花哨技巧:
sns.lineplot(x=’epoch’, y=’val Accuracy’, data=simple_results,
➥ label=’Regular’)
[11]: <AxesSubplot:xlabel='epoch', ylabel='val Accuracy'>

10.1.2 注意力机制机制
接下来,我们将设计一种非常简单的注意力机制,它将朝着我们在本章后面学习到的更完整的注意力机制迈出一步。我们使用的主要工具是第二章中的 softmax 函数 s**m(x)。记住,一旦我们计算出 p = s**m(x),p 就代表一个概率分布。概率分布的所有值都大于或等于零,所有值的总和为 1。用数学方式表达就是 0 ≤ p[i] ≤ 1 和 ∑[i]p[i] = 1。记住,注意力机制的主要功能是忽略输入的一部分,我们通过将忽略的部分与接近零的小值相乘来实现这一点。如果我们把某物乘以零,我们得到零,实际上是从输入中删除它。多亏了 softmax 计算,我们可以学习如何将输入乘以接近零的小值,从而有效地学习如何忽略它们。
图 10.1 展示了注意力机制的三步主要步骤。前两个步骤是微不足道的,并且将根据你的网络和问题而变化:简单地将你的特征输入到网络中,并在应用注意力机制之前有一些初始隐藏层,这样它们就可以学习到一个有用的表示。然后进行最后的步骤,执行注意力操作:
-
使用 得分 函数为每个输入 x[t] 分配一个得分。
-
对所有得分计算 softmax。
-
将每个项目乘以其 softmax 得分,然后将所有结果相加得到输出 x̄。

图 10.1 展示了注意力机制的工作原理图。输入序列 x[t] 的数量可以是可变的(即,你可以有 T = 1 个项目,T = 13,或者 T = 你喜欢的任何数量)。一个 得分 函数为每个项目 t 分配一个原始重要性值。softmax 函数创建相对得分。输出然后是输入的加权平均值。
让我们用更数学的符号重新表述一些内容,以填补一些细节。注意力机制通常的工作方式是,我们将输入表示为 T 个不同的组件 x[1], x[2], …, x[T],每个组件都有一个张量表示 x[t] ∈ ℝ^D。这 T 个项目可以是输入中的自然断点(例如,在这个玩具问题中我们自然有不同的图像),或者它们可以是强制的(例如,我们可以将单个图像分割成子图像)。每个输入 x[t] 被转换成一个新的张量 h[t] = F(x[t]),其中 F(⋅) 是一个神经网络。这意味着注意力机制的输入不必是模型中的第一个输入,但可以是已经完成的一些计算的结果。
注意 这也意味着 x[t] 不必是一个一维张量。例如,网络 F(⋅) 可能是一个卷积神经网络,x[t] ∈ ℝ^((C,W,H)). 我们现在将其简化为一维是为了方便。然而,我们通常确实希望 h[t] 是一维的。
因此,处理后的序列 h[1], h[2], …, h[T] 是注意力机制的真正输入。接下来,我们需要为每个输入 x[i] 学习一个重要性分数 α̃[i]。假设一个不同的函数 α̃[i] = score(F(x[i])) 学习计算这个分数。再次,函数 score(⋅) 是 另一个 神经网络。这意味着我们的模型本身将学会如何为我们评分输入。
然后我们想要将重要性归一化为一个概率,所以我们得到 α = α[1], α[2], …, α[T] = s**m(α̃[1],α̃[2],…,α̃[T])。有了这些组合,我们现在可以计算表示 h[t] 的加权平均。具体来说,我们首先计算由 α 表示的 softmax 分数:

接下来我们计算注意力机制的输出 x̄:

如果第 j 个项目 x[j] 不重要,希望我们的网络学会将项目 j 的值设置为 α[j] ≈ 0,在这种情况下,它将成功地忽略第 j 个项目!这个想法并不太复杂,特别是与一些我们之前学过的早期项目(如 RNNs)相比。但这种方法已被证明非常强大,并且可以为许多问题带来显著的改进。
10.1.3 实现简单的注意力机制
现在我们已经了解了注意力机制的工作原理,让我们为我们的捆绑 MNIST 问题实现一个简单的注意力机制。我们还没有考虑我们数据卷积的特性,所以如果我们想将我们的图像捆绑 (B,T,C,W,H) 转换为特征向量捆绑 (B,T,C⋅W⋅H),我们需要一个新的 nn.Flatten 函数版本,它保留张量的前两个轴。这通过以下 Flatten2 类很容易实现。它只是创建输入的一个视图,但明确使用前两个轴作为视图的起始点,并将所有剩余的值放在末尾:
class Flatten2(nn.Module):
"""
Takes a vector of shape (A, B, C, D, E, ...)
and flattens everything but the first two dimensions,
giving a result of shape (A, B, C*D*E*...)
"""
def forward(self, input):
return input.view(input.size(0), input.size(1), -1)
下一步是创建一些类来实现我们的注意力机制。我们需要的主要是一个 Module,它接受注意力权重 α 和提取的特征表示 h[1],h[2],…,h[T],并计算加权平均值 x̄ = Σ[i]^T[=1] α[i] · h[i]。我们称这个为 Combiner。我们接受一个网络 featureExtraction,它为输入中的每个项目计算 h[t] = F(x[t]),以及一个网络 weightSelection,它从提取的特征中计算 α。
定义组合模块
这个 Combiner 的前向函数相当简单。我们计算 features 和 weights,正如我们描述的那样。我们进行一些张量操作,以确保 weights 的形状允许我们与特征进行成对乘法,因为成对乘法需要张量具有相同数量的轴。此外,请注意,我们在每一行都添加了关于每个张量形状的注释。注意力机制涉及许多变化的形状,因此包含这样的注释是个好主意:
class Combiner(nn.Module):
"""
This class is used to combine a feature extraction network F and an
➥ importance prediction network W, and combine their outputs by adding
➥ and summing them together.
"""
def __init__(self, featureExtraction, weightSelection):
"""
featureExtraction: a network that takes an input of shape (B, T, D)
➥ and outputs a new
representation of shape (B, T, D’).
weightSelection: a network that takes in an input of shape
➥ (B, T, D’) and outputs
a tensor of shape (B, T, 1) or (B, T). It should be normalized,
➥ so that the T
values at the end sum to one (torch.sum(_, dim=1) = 1.0)
"""
super(Combiner, self).__init__()
self.featureExtraction = featureExtraction
self.weightSelection = weightSelection
def forward(self, input):
"""
input: a tensor of shape (B, T, D)
return: a new tensor of shape (B, D’)
"""
features = self.featureExtraction(input) ❶
weights = self.weightSelection(features) ❷
if len(weights.shape) == 2: ❸
weights.unsqueeze(2) ❹
r = features*weights ❺
return torch.sum(r, dim=1) ❻
❶ (B, T, D) h[i] = F(x[i])
❷ (B, T) 或 (B, T, 1) 用于 α
❸ (B, T) 形状
❹ 现在 (B, T, 1) 形状
❺ (B, T, D);计算 α[i] ⋅ h[i]
❻ 对 T 维度求和,得到 (B, D) 最终形状
定义骨干网络
现在我们已经准备好为这个问题定义基于注意力的模型。首先,我们快速定义两个变量——T 表示包中项目的数量,D 表示特征的数量:
T = 3
D = 784
首先要定义的是特征提取网络。我们也可以称这个网络为骨干网络,因为它遵循与第八章中 Faster R-CNN 的骨干网络相同的直觉。这个网络将承担所有繁重的工作,为输入中的每个项目学习一个良好的表示 h[i]。
使其易于实现的小技巧是记住 nn.Linear 层可以作用于形状为 (B,T,D) 的张量。如果你有一个这种形状的输入,nn.Linear 层将独立地应用于所有 T 个项目,就像你写了一个这样的 for 循环:
for i in range(T):
h_i = linear(x[:,i,:]) ❶
h_is.append(h_i.unsqueeze(1)) ❷
h = torch.cat(h_is, dim=1) ❸
❶ (B, D)
❷ 使其变为 (B, 1, D)
❸ (B, T, D)
因此,我们可以使用 nn.Linear 后跟任何激活函数来分别应用这个骨干到每个输入:
backboneNetwork = nn.Sequential( Flatten2(), ❶
nn.Linear(D,neurons), ❷
nn.LeakyReLU(),
nn.Linear(neurons,neurons),
nn.LeakyReLU(),
nn.Linear(neurons,neurons),
nn.LeakyReLU(), ❸
)
❶ 形状现在是 (B, T, D)
❷ 形状变为 (B, T, 神经元)
❸ 仍然 (B, T, 神经元) 在输出路径上
定义一个注意力子网络
现在我们需要一个网络来计算注意力机制权重 α。遵循骨干逻辑,我们假设特征提取网络已经完成了繁重的工作,因此我们的注意力子网络可以相对较小。我们有一个隐藏层,然后是一个显式输出大小为 1 的第二层。我们需要这样做,因为输入中的每个项目都会得到 一个 分数。然后我们在 T 维度上应用 softmax 来归一化每个包组的这些分数:
attentionMechanism = nn.Sequential(
nn.Linear(neurons,neurons), ❶
nn.LeakyReLU(),
nn.Linear(neurons, 1 ), ❷
nn.Softmax(dim=1),
)
❶ 形状是 (B, T, 神经元)
❷ (B, T, 1)
训练一个简单的注意力模型及其结果
基于特征提取主干和权重计算注意力机制,我们现在可以定义一个完整的基于注意力的网络。它从一个Combiner开始,该Combiner接收我们定义的两个子网络,然后是任意数量的我们想要的完全连接层。通常,主干已经做了很多工作,所以在这个步骤中你通常只需要两到三个隐藏层。然后我们训练模型:
simpleAttentionNet = nn.Sequential(
Combiner(backboneNetwork, attentionMechanism), ❶
nn.BatchNorm1d(neurons),
nn.Linear(neurons,neurons),
nn.LeakyReLU(),
nn.BatchNorm1d(neurons),
nn.Linear(neurons, classes )
)
simple_attn_results = train_network(simpleAttentionNet,
➥ nn.CrossEntropyLoss(), train_loader, val_loader=test_loader,
➥ epochs=epochs, score_funcs={’Accuracy’: accuracy_score}, device=device)
❶ 输入是(B, T, C, W, H)。组合器使用主干和注意力进行处理。结果是(B, 神经元)。
训练完成后,我们可以查看我们模型的准确率。仅仅经过一个 epoch,我们简单的注意力网络就已经比常规网络表现更好:
sns.lineplot(x=’epoch’, y=’val Accuracy’, data=simple_results,
➥ label=’Regular’)
sns.lineplot(x=’epoch’, y=’val Accuracy’, data=simple_attn_results,
➥ label=’Simple Attention’)
[18]: <AxesSubplot:xlabel='epoch', ylabel='val Accuracy'>

我们还可以从数据集中选择随机样本,并查看注意力机制如何选择输入。以下是一些简单的代码来运行一个样本;以下图形显示了红色覆盖在每个数字上的注意力权重。以“0, 9, 0”作为输入,注意力机制正确地将几乎所有的权重放在数字 9 上,允许它做出准确的分类:
x, y = largest_train[0] ❶
x = x.to(device) ❷
with torch.no_grad(): weights =
attentionMechanism(backboneNetwork(x.unsqueeze(0))) ❸
weights = weights.cpu().numpy().ravel() ❹
f, axarr = plt.subplots(1,3, figsize=(10,10)) ❺
for i in range(3):
axarr[i].imshow(x[i,0,:].cpu().numpy(), ❻
➥ cmap=’gray’, vmin=0, vmax=1)
axarr[i].text(0.0, 0.5, str(round(weights[i],2)), ❼
➥ dict(size=40, color=’red’))
print("True Label is = ", y)
True Label is = 9
❶ 选择一个数据点(它是一个包)
❷ 将其移动到计算设备
❸ 应用得分(F(x))
❹ 转换为 NumPy 数组
❺ 为所有三个数字绘制一个图
❻ 绘制数字
❼ 在左上角绘制注意力得分

我们已经看到这个简单的注意力如何帮助我们更快、更好地学习当只有输入子集重要时的网络。但有两个悬而未决的问题。首先,我们有一个天真的期望,即批处理中的所有内容大小相同。我们需要一些填充来解决这个问题,就像我们在我们的 RNNs 中使用的那样。
第二个问题是我们的得分缺乏上下文。一个项目的重要性取决于其他项目。想象你正在看电影。你关注的是电影,而不是外面的鸟或正在运行的洗碗机,因为电影更重要。但如果你听到火灾警报,情况就会改变。现在,我们的attentionMechanism网络独立地查看每个项目,所以电影和火灾警报都可能得到高分数——但在现实中,它们的分数应该相对于其他存在的事物是相对的。
10.2 添加一些上下文
我们对注意力机制方法的第一项改进是向得分中添加上下文。我们保留所有其他部分不变。通过上下文,我们指的是项目x[i]的得分应该依赖于所有其他项目x[j ≠ i]。现在我们有一个主干特征提取器F(⋅)和一个重要性计算器得分(⋅)。但这意味着任何输入x[i]的重要性是在没有任何关于其他输入的上下文的情况下确定的,因为得分(⋅)是独立应用于一切的。
为了了解我们为什么需要上下文,让我们回到那个嘈杂的咖啡馆,你在那里和朋友聊天。你为什么忽略所有的背景噪音?因为你有一个上下文,知道你的朋友在说话,他们对你来说比其他对话更重要。但如果你的朋友不在场,你可能会发现自己正在倾听周围随机的对话。
换句话说,注意力机制使用全局信息来做出局部决策。局部决策以权重α的形式出现,上下文提供了全局信息。²
我们如何将上下文添加到注意力机制中?图 10.2 展示了我们是如何实现这一点的。

图 10.2 左侧的注意力机制过程保持不变,除了分数组件。右侧放大了分数的工作方式。分数块接收来自骨干/特征提取网络的h[...]结果。对于每个分数α̃[t],使用两个输入:h[t],我们想要计算其分数,以及
,它代表所有输入h[...]的上下文。这个上下文![bar_h.png]可以是所有项目的简单平均值。
我们将 score(⋅,⋅)设计成一个网络,它接收两个输入:输入的张量(B,T,H)和第二个形状为(B,H)的张量,包含每个序列的上下文。我们在这里使用 H 来表示状态h[t] = F(x[t])中的特征数量,以使其与原始输入x[i]的大小 D 区分开来。这个形状为(B,H)的第二个上下文张量没有时间/序列维度 T,因为这个向量打算用作第一个输入的所有 T 个项目的上下文。这意味着包中的每个项目在计算其分数时都会得到相同的上下文。
我们可以使用最简单的上下文形式是所有提取特征的平均值。所以如果我们有h[i] = F(x[i]),我们可以从所有这些计算平均![bar_h.png],给模型一个大致的了解,它可以选择的所有选项。这使得我们的注意力计算分为三个步骤:
-
计算从每个输入提取的特征的平均值。这把所有东西都视为同等重要:
![图片]()
-
计算 T 个输入的注意力分数α。这里唯一的改变是我们将![bar_h.png]作为每个分数计算的第二个参数:
![图片]()
-
计算提取特征的加权平均值。这部分与之前相同:
![图片]()
这个简单的流程为我们提供了一个新的注意力分数框架,使我们能够根据其他存在的项目来调整项目的分数。我们仍然希望我们的分数网络非常简单和轻量级,因为骨干 F(⋅) 将会承担繁重的工作。从表示 h 和上下文
计算分数有三种常见方法,通常称为 点积、通用 和 加法 分数。请注意,这些方法中没有一种是普遍认为比其他更好的——注意力机制仍然非常新颖,以下每种方法在不同的情境下可能比其他方法更有效或更无效。我现在能给你的最好建议是尝试所有三种方法,看看哪一种最适合你的问题。我们将逐一讨论它们,因为它们很简单,然后在我们的小问题实例上运行所有这些方法,看看会发生什么。在描述中,我使用 H 来指代进入注意力机制的维度/神经元的数量。
10.2.1 点积分数
点积分数是一种最简单的评分方法,但也可以是最有效的。其想法是,如果我们有一个项目 h[t] 和一个上下文
,我们取它们的点积 h[t]^⊤
,以得到一个分数。我们取点积是因为它衡量两个向量在方向和大小上的对齐程度。在第七章中,我们讨论了向量是正交的:这个概念可以帮助你理解这一点。如果两个向量是正交的,它们的点积是 0,这意味着它们没有关系。它们越不正交,点积就越大。如果它是一个大的正数值,它们非常相似;如果它是一个大的负数值,它们非常不同。除了这个技巧之外,我们还把结果除以向量的维度 H 的平方根。这给我们带来了点积分数的以下方程:

为什么要除以 √H?担忧的是,正常的点积计算可能会导致更大的数值(非常大的正数或大负数)。大数值在计算 softmax 时会导致梯度消失,正如我们在 tanh 和 sigmoid(σ)激活函数中看到的那样。因此,我们除以√H来尝试避免大数值——使用平方根的具体选择是启发式地做出的,因为它往往有效。
实现这种方法相当简单。我们需要使用批矩阵乘法方法 torch.bmm,因为我们将会一次性计算所有 T 个时间步的得分。每个项目具有 (T,H) 的形状,而上下文——在我们使用 unsqueeze 函数向其末尾添加一个大小为 1 的维度之后——将具有 (D,1) 的形状。将形状为 (T,H) × (H,1) 的两个矩阵相乘得到 (T,1),这正是我们需要的形状:每个项目一个得分。torch.bmm 对批处理中的每个项目应用此操作,因此 torch.bmm((B, T, H), (B, H, 1)) 的输出形状为 (B,T,1)。接下来的 DotScore 将其实现为一个可重用的 Module,其中 forward 函数接受 states 和 context。我们将重用此模式来处理其他两种得分方法:
class DotScore(nn.Module):
def __init__(self, H):
"""
H: the number of dimensions coming into the dot score.
"""
super(DotScore, self).__init__()
self.H = H
def forward(self, states, context):
"""
states: (B, T, H) shape
context: (B, H) shape
output: (B, T, 1), giving a score to each of the T items based
➥ on the context
"""
T = states.size(1) scores =
torch.bmm(states,context.unsqueeze(2))
➥ / np.sqrt(self.H) ❶
return scores
❶ 计算
。 (B, T, H) -> (B, T, 1)。
10.2.2 一般得分
由于点积得分非常有效,我们能否改进它?如果
中的一个特征不是非常有用,我们能否学习不使用它?这就是 一般 得分背后的想法。我们不是简单地计算每个项目 h[t] 与上下文
之间的点积,而是在它们之间添加一个矩阵 W。这给我们

其中 W 是一个 H × H 矩阵。一般得分可以称为点积得分的推广,因为它可以学习与点积得分相同的解,但一般得分也可以学习点积得分无法学习的解。这种情况发生在模型学习将 W 设置为单位矩阵 I 时,因为对于任何可能的输入 z,I****z = z。
同样,这种实现很简单,因为一般得分有一个称为 双线性关系 的特性。双线性函数看起来像

其中 x[1] ∈ ℝ^(H1) 和 x[2] ∈ ℝ^(H1) 是输入,b 和 W 是需要学习的参数。PyTorch 提供了一个 nn.Bilinear(H1, H2, out_size) 函数,可以为我们计算这个值。在我们的情况下,H1 = H2 = H,我们的输出大小是 1(每个项目一个得分值)。
我们需要了解两个技巧来实现这个方法,以便在一次调用中计算所有 T 个得分。我们有一个形状为 (B,T,H) 的张量用于我们的项目 h[1],h[2],…,h[T],但一个形状为 (B,H) 的张量用于我们的上下文
。我们需要这两个张量具有相同数量的轴,以便在双线性函数中使用。技巧是将上下文堆叠成 T 个副本,这样我们就可以创建一个形状为 (B,T,H) 的矩阵:
class GeneralScore(nn.Module):
def __init__(self, H):
"""
H: the number of dimensions coming into the dot score.
"""
super(GeneralScore, self).__init__() self.w =
nn.Bilinear(H, H, 1) ❶
def forward(self, states, context):
"""
states: (B, T, H) shape
context: (B, H) shape
output: (B, T, 1), giving a score to each of the T items based
on the context
"""
T = states.size(1)
context = torch.stack([context for _ in range(T)], dim=1) ❷
scores = self.w(states, context) ❸
return scores
❶ 存储 W
❷ 重复值 T 次。 (B, H) -> (B, T, H)。
❸ 计算 h[t]^⊤ W
。 (B, T, H) -> (B, T, 1)。
注意:我们引入 GeneralScore 是为了尝试改进 DotScore,这是合理的,因为它们之间有很强的相关性。然而,在实践中,今天似乎没有哪一个比另一个更好。正如我们提到的,注意力机制非常新,我们作为一个社区仍在探索它们。有时你会看到 Dot 比 General 表现得更好,有时则不是。
10.2.3 加性注意力
我们将要讨论的最后一种得分通常被称为 加性 或 连接 注意力。对于给定项及其上下文,我们使用向量 v 和矩阵 W 作为层的参数,形成一个小的神经网络,如下面的方程所示:

这个方程是一个简单的一层神经网络。W 是隐藏层,后面跟着 tanh 激活,v 是输出层,有一个输出³,这是必要的,因为得分应该是一个单一值。上下文
通过简单地将其与项目 h[t] 连接起来纳入模型,这样项目和它的上下文就成为了这个全连接网络的输入。
加性层的背后的思想相当简单:让一个小型神经网络为我们确定权重。虽然实现它需要一点巧妙,但操作预期是什么呢?v 和 W 都可以用 nn.Linear 层来处理,并且你需要使用 torch.cat 函数将两个输入 h[t] 和
连接起来,所以你可能这样注释前面的方程:

虽然这样描述是可行的,但它在计算上效率不高。它之所以慢,是因为我们不仅仅有 h[t]——我们有一个形状为 (B,T,H) 的大张量,其中包含 h[1],h[2],…,h[T]。因此,我们不想将其分成 T 个不同的项目并多次调用注意力函数,我们希望以这种方式实现它,即一次计算 T 个注意力得分,我们可以通过使用与一般得分相同的技巧来实现这一点。只需确保在堆叠技巧之后进行连接,以便张量具有相同的形状——并使用 dim=2 以便它们在特征维度 H 而不是时间维度 T 上进行连接。这个过程的一个图示如图 10.3 所示,以下代码显示了如何实现它:
class AdditiveAttentionScore(nn.Module):
def __init__(self, H):
super(AdditiveAttentionScore, self).__init__()
self.v = nn.Linear(H, 1)
self.w = nn.Linear(2*H, H) ❶
def forward(self, states, context):
"""
states: (B, T, H) shape
context: (B, H) shape
output: (B, T, 1), giving a score to each of the T items based
➥ on the context
"""
T = states.size(1) ❷
context = torch.stack([context for _ in range(T)], dim=1) ❸
state_context_combined = torch.cat( (states, context), dim=2) ❹
scores = self.v(torch.tanh(
➥ self.w(state_context_combined))) ❺
return scores
❶ 2*H 因为我们要连接两个输入
❷ 重复值 T 次
❸ (B, H) -> (B, T, H)
❹ (B, T, H) + (B, T, H) -> (B, T, 2*H)
❺ (B, T, 2*H) -> (B, T, 1)

图 10.3 加性注意力的实现图。状态和上下文在左侧表示,上下文通过复制重塑以具有与状态相同的形状。这使得它们很容易连接起来,然后输入到神经网络中。
10.2.4 计算注意力权重
现在我们已经得到了我们想要的各个注意力得分,我们将定义一个简单的辅助Module,它接受原始得分
和提取的表示 h[…] 并计算最终的输出 x̄。这将通过添加一个新步骤来替换我们的Combiner模块:对输入应用掩码。
我们的玩具 MNIST 示例开始时所有袋子的大小都相同,这在现实生活中从未发生过。你将与之工作的任何数据集很可能包含包含可变数量项的输入(例如,要翻译的句子中的单词),如果我们对数据批次进行训练,我们必须处理这种不一致性。这就是为什么我们需要额外的逻辑来计算最终的权重α,以布尔掩码作为输入。掩码告诉我们输入的哪些部分是真实的(True)以及哪些是填充的(False),以使张量形状保持一致。这与第四章中我们填充较小项以与批次中最大的袋子/序列大小相同时的 RNN 几乎相同。掩码取代了打包代码的角色。
我们在这里使用的技巧是手动将每个具有False值的项的得分设置为非常大的负数,例如-1000。我们这样做是因为 exp(-1000) ≈ 5.076 × 10^(-435),因此这很可能会下溢到零并导致期望的梯度消失。当计算下溢到零时,其梯度及其贡献为零,有效地消除了其对模型的影响。以下代码使用新的ApplyAttention类来完成此操作:
class ApplyAttention(nn.Module):
"""
This helper module is used to apply the results of an attention
➥ mechanism to a set of inputs.
"""
def __init__(self):
super(ApplyAttention, self).__init__()
def forward(self, states, attention_scores, mask=None):
"""
states: (B, T, H) shape giving the T different possible inputs
attention_scores: (B, T, 1) score for each item at each context
mask: None if all items are present. Else a boolean tensor of shape
(B, T), with ‘True‘ indicating which items are present / valid.
returns: a tuple with two tensors. The first tensor is the
➥ final context
from applying the attention to the states (B, H) shape. The
➥ second tensor
is the weights for each state with shape (B, T, 1).
"""
if mask is not None:
attention_scores[ mask] = -1000.0 ❶
weights = F.softmax(attention_scores, dim=1) ❷
final_context = (states*weights).sum(dim=1) ❸
return final_context, weights
❶ 将所有未出现的项设置为一个大负值,导致梯度消失
❷ 为每个得分计算权重。(B,T,1)仍然如此,但 sum(T) = 1
❸ (B, T, D) * (B, T, 1) -> (B, D)
在注意力机制中与掩码一起工作非常常见。为了使其更容易,我们可以为从任何具有形状(B,T,…)的张量计算掩码定义一个很好的辅助函数。想法是,我们期望任何缺失项或未出现的项都将其张量填充为常数值,通常是零。因此,我们寻找时间 T 维度上具有此常数值的任何维度。如果一切等于该常数,则应返回False值;否则,我们想要一个True值来指示现有的值是可用的。以下getMaskByFill函数就是这样做的。输入x是我们想要为其获取掩码的,time_dimension告诉我们张量的哪个维度用于表示 T,而fill表示用于表示输入填充/无效部分的特殊常数:
def getMaskByFill(x, time_dimension=1, fill=0):
"""
x: the original input with three or more dimensions, (B, ..., T, ...)
which may have unused items in the tensor. B is the batch size,
and T is the time dimension.
time_dimension: the axis in the tensor ‘x‘ that denotes the time dimension
fill: the constant used to denote that an item in the tensor is not in use,
and should be masked out (‘False‘ in the mask).
return: A Boolean tensor of shape (B, T), where ‘True‘ indicates the value
at that time is good to use, and ‘False‘ that it is not.
"""
to_sum_over = list(range(1,len(x.shape))) ❶
if time_dimension in to_sum_over:
to_sum_over.remove(time_dimension)
with torch.no_grad():
mask = torch.sum((x != fill), dim=to_sum_over) > 0 ❷
return mask
❶ 跳过第一个维度 0,因为那是批次维度
❷ (x!=fill) 确定可能未使用的位置,因为这些位置缺少我们用来表示未使用的填充值。然后我们计算该时间槽中所有非填充值的数量(减少改变形状为(B,T))。如果任何条目不等于此值,则表示该项必须在使用中,因此返回 true 值。
让我们快速看看这个新函数是如何工作的。我们创建一个输入矩阵,包含一个批次的B = 5 个输入,T = 3 个时间步长,以及一个 7 × 7 的单通道图像。这将是一个形状为(B=5,T=3,1,7,7)的张量,我们使其如此,即第一个批次的最后一个项目未使用,整个第四个项目未使用。我们的 mask 应该看起来像这样:

以下代码创建了这个假设数据,并计算了适当的 mask,然后将其打印出来:
with torch.no_grad():
x = torch.rand((5,3,1,7,7))
x[0,-1,:] = 0 ❶
x[3,:] = 0 ❷
x[4,0,0,0] = 0 ❸
mask = getMaskByFill(x)
print(mask)
tensor([[ True, True, False],
[ True, True, True],
[ True, True, True],
[False, False, False],
[ True, True, True]])
❶ 不要使用第一个输入的最后一个项目。
❷ 不要使用第四项中的任何一项!
❸ 让它看起来我们好像没有使用第五项的一部分,但实际上我们还在使用!这一行被添加进来以表明即使在复杂的输入上这也有效。
返回的 mask 产生了正确的输出。这个函数允许我们为文本序列、序列/图像包以及几乎任何类型的非标准张量形状创建 mask。
10.3 将所有内容组合起来:一个带有上下文的完整注意力机制
使用分数方法(例如,点积)、getMaskByFill函数和我们的新ApplyAttention函数,我们可以定义一个完整的基于注意力的网络,该网络对输入是自适应的。对于大多数基于注意力的模型,我们由于涉及注意力的所有非顺序步骤,不使用nn.Sequential来定义主模型。相反,我们使用nn.Sequential来定义和组织较大网络使用的子网络。
让我们定义一个SmarterAttentionNet,这是我们整个网络。在构造函数内部,我们定义了一个用于我们的backbone的网络,该网络计算h[i] = F(x[i]),以及一个prediction_net,该网络计算我们的预测ŷ = f( x̄)。参数input_size、hidden_size和out_size定义了输入中的特征数量(D)、隐藏神经元(H)以及我们的模型应该做出的预测(MNIST 的 10 个类别)。我们还添加了一个可选参数来选择哪个注意力分数用作score_net:
class SmarterAttentionNet(nn.Module):
def __init__(self, input_size, hidden_size, out_size, score_net=None):
super(SmarterAttentionNet, self).__init__()
self.backbone = nn.Sequential(
Flatten2(), ❶
nn.Linear(input_size,hidden_size), ❷
nn.LeakyReLU(),
nn.Linear(hidden_size,hidden_size),
nn.LeakyReLU(),
nn.Linear(hidden_size,hidden_size),
nn.LeakyReLU(),
) ❸
self.score_net = AdditiveAttentionScore(hidden_size) ❹
if (score_net is None) else score_net
self.apply_attn = ApplyAttention()
self.prediction_net = nn.Sequential( ❺
nn.BatchNorm1d(hidden_size),
nn.Linear(hidden_size,hidden_size),
nn.LeakyReLU(),
nn.BatchNorm1d(hidden_size),
nn.Linear(hidden_size, out_size ) ❻
)
❶ 形状现在是 (B, T, D)
❷ 形状变为 (B, T, H)
❸ 返回 (B, T, H)
❹ 尝试更改它并看看结果如何变化!
❺ (B, H), x̄ 作为输入
❻ (B, H)
接下来我们定义一个forward函数来计算网络的输出结果。我们从输入中计算mask,然后是来自骨干网络的隐藏状态h[...],这些状态是在相同输入上计算的。我们使用平均向量
作为上下文h_context。我们不是用torch.mean来计算这个值,而是分两步自己计算,这样我们只除以有效项目的数量。否则,一个有 2 个有效项目但 8 个填充项目的输入将被除以 10 而不是 2,这是不正确的。这个计算还包括+1e-10来给分子添加一个微小的值:这个添加是一种防御性编码,以防我们收到一个包含零项目的包,我们不会执行除以零的操作。这将产生一个NaN(不是一个数字),这会导致静默失败。
这里是代码:
def forward(self, input):
mask = getMaskByFill(input)
h = self.backbone(input) ❶
h_context = (mask.unsqueeze(-1)*h).sum(dim=1) ❷
h_context = h_context/(mask.sum(dim=1).unsqueeze(-1)+1e-10) ❸
scores = self.score_net(h, h_context) ❹
final_context, _ = self.apply_attn(h, scores, mask=mask) ❺
return self.prediction_net(final_context) ❻
❶ (B, T, D) -> (B, T, H)
❷ h_context = torch.mean(h, dim=1) 计算 torch.mean 但忽略被屏蔽的部分。首先,将所有有效的项目加在一起。(B, T, H) -> (B, H)。
❸ 将数量除以有效项目的数量,并在袋子全部为空的情况下加上一个小的值
❹ (B, T, H) , (B, H) -> (B, T, 1)
❺ 结果是 (B, H) 形状
❻ (B, H) -> (B, classes)
forward 函数的最后三行再次相当简单。score_net 计算得分 α,apply_attn 函数应用我们选择的任何注意力函数,prediction_net 从 final_context x̄ 中做出预测。
让我们训练一些更好的注意力模型。以下代码块为每个选项创建一个注意力网络,DotScore,GeneralScore 和 AdditiveAttentionScore:
attn_dot = SmarterAttentionNet(D, neurons, classes,
➥ score_net=DotScore(neurons))
attn_gen = SmarterAttentionNet(D, neurons, classes,
➥ score_net=GeneralScore(neurons))
attn_add = SmarterAttentionNet(D, neurons, classes,
➥ score_net=AdditiveAttentionScore(neurons))
attn_results_dot = train_network(attn_dot, nn.CrossEntropyLoss(),
➥ train_loader, val_loader=test_loader,epochs=epochs,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device)
attn_results_gen = train_network(attn_gen, nn.CrossEntropyLoss(),
➥ train_loader, val_loader=test_loader,epochs=epochs,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device)
attn_results_add = train_network(attn_add, nn.CrossEntropyLoss(),
➥ train_loader, val_loader=test_loader,epochs=epochs,
➥ score_funcs={’Accuracy’: accuracy_score}, device=device)
以下代码绘制了这三个结果。所有三个改进的注意力得分趋势相似。它们一开始比我们开始的简单注意力学习得更快,收敛得更快,也许新的方法正在收敛到一个更好的结果。你将看到每次运行之间的一些变化,但这个数据集还不够大或复杂,不足以引起行为上的真正有趣的变化:
sns.lineplot(x=’epoch’, y=’val Accuracy’, data=simple_results,
➥ label=’Regular’)
sns.lineplot(x=’epoch’, y=’val Accuracy’, data=simple_attn_results,
➥ label=’Simple Attention’)
sns.lineplot(x=’epoch’, y=’val Accuracy’, data=attn_results_dot,
➥ label=’Dot’)
sns.lineplot(x=’epoch’, y=’val Accuracy’, data=attn_results_gen,
➥ label=’General’)
sns.lineplot(x=’epoch’, y=’val Accuracy’, data=attn_results_add,
➥ label=’Additive’)
[29]: <AxesSubplot:xlabel='epoch', ylabel='val Accuracy'>

我们新代码的另一个好处是能够处理可变大小的输入,其中较短的/较小的项目被填充以匹配批次中最长/最大的项目的长度。为了确保它正常工作,我们定义了一个新的 LargestDigitVariable 数据集,该数据集随机选择放入每个袋子中的项目数量,最多达到某个指定的最大数量。这将使训练问题变得更加具有挑战性,因为网络需要确定袋子中项目的数量与袋子标签之间是否存在任何关系。
以下代码做了这件事,只有两个真正的变化。首先,我们的 __getitem__ 方法计算要采样多少个项目。其次,我们为了简单起见在数据集对象内部实现填充,所以如果 how_many < maxToSample,我们用零值填充输入以使所有内容大小相同:
class LargestDigitVariable(Dataset):
"""
Creates a modified version of a dataset where some variable number of
➥ samples are taken, and the true label is the largest label sampled.
➥ When used with MNIST the labels correspond to their values (e.g.,
➥ digit "6" has label 6). Each datum will be padded with 0 values if
➥ the maximum number of items was not sampled. """
def __init__(self, dataset, maxToSample=6):
"""
dataset: the dataset
to sample from toSample: the number of items from the dataset to sample
"""
self.dataset = dataset
self.maxToSample = maxToSample
def __len__(self):
return len(self.dataset)
def __getitem__(self, idx):
how_many = np.random.randint(1,self.maxToSample, size=1)[0] ❶
selected = np.random.randint(0,len(self.dataset), size=how_many) ❷
x_new = torch.stack([self.dataset[i][0] for i in selected] + ❸
[torch.zeros((1,28,28)) for i in range(self.maxToSample-how_many)])
y_new = max([self.dataset[i][1] for i in selected]) ❹
return x_new, y_new ❺
❶ 新的:我们应该选择多少个项目?
❷ 随机从数据集中选择 n=self.toSample 个项目
❸ 将形状为 (B, *) 的 n 个项目堆叠成 (B, n, *)。新的:用零值填充到最大大小。
❹ 标签是最大标签。
❺ 返回 (data, label) 对
下一个代码块创建训练和测试集加载器,到这本书的这一部分你应该已经很熟悉了:
largestV_train = LargestDigitVariable(mnist_train)
largestV_test = LargestDigitVariable(mnist_test)
trainV_loader = DataLoader(largest_train, batch_size=B, shuffle=True)
testV_loader = DataLoader(largest_test, batch_size=B)
我们通常会在这个新的数据集上训练我们的新模型。但这里有一个酷的地方:我们新的注意力机制设计可以处理可变数量的输入,这要归功于填充,并且注意力先验包括只关注输入子集的概念。所以尽管我们的数据现在有一个新的、更大的形状,有更多的可能输入,我们仍然可以重复使用我们已经训练过的相同网络。
而不是训练一个新的模型,以下代码通过测试集运行并使用仅针对大小为 3 的包训练的我们的模型进行预测,并希望它在大小为 1 到 6 的包上也能很好地工作:
attn_dot = attn_dot.eval()
preds = []
truths = []
with torch.no_grad():
for inputs, labels in testV_loader:
pred = attn_dot(inputs.to(device))
pred = torch.argmax(pred, dim=1).cpu().numpy()
preds.extend(pred.ravel())
truths.extend(labels.numpy().ravel())
print("Variable Length Accuracy: ", accuracy_score(preds, truths))
Variable Length Accuracy: 0.967
你应该在这个新问题中看到大约 96%或 97%的准确率,我们训练的是它的一个更容易的版本。这让你对注意力方法的力量有了概念。它通常能够很好地泛化到输入数据的变化和序列长度的变化,这也是注意力非常成功的一部分。它们已经迅速成为今天大多数自然语言处理任务的常用工具。
并非总是可以像这样泛化。如果我们继续将输入长度增加到我们训练的长度(增加maxToSample参数),准确率最终会开始下降。但我们可以通过在预期的输入长度上训练来抵消这一点。
这里需要注意的重要事情是,注意力方法可以处理可变数量的输入并泛化到所提供的内容之外。这,加上对输入的选择性,使得这种方法非常强大。
练习
在 Manning 在线平台上的 Inside Deep Learning Exercises(liveproject.manning.com/project/945)分享和讨论你的解决方案。一旦你提交了自己的答案,你将能够看到其他读者提交的解决方案,并看到作者认为哪些是最佳的。
-
为
LargestDigit问题训练一个新的卷积模型。为了使这可行,你需要将你的数据从形状(B,T,C,W,H)转换为(BT,C,W,H),这实际上是将批大小扩大以运行你的 2D 卷积。一旦你完成卷积,你可以回到(B,T,C,W,H*)的形状。你能够用这种方法获得多高的准确率? -
GeneralScore使用一个以随机值开始的矩阵 W。但你可以将其初始化为W = I/√H + ε,其中ε是某些小的随机值(例如在-0.01 到 0.01 的范围内),以避免硬零。这将使GeneralScore以DotScore的行为开始。尝试自己实现这一点。 -
在加性注意力分数中使用
tanh激活函数是有些任意的。尝试通过在 PyTorch 中将tanh激活函数替换为其他选项来使用多个不同的加性注意力版本。你认为哪些激活函数可能特别有效或无效? -
让我们实现一个更难的
LargestDigitVariable数据集版本。为了确定一个包的标签,将包内容的标签相加(例如,3、7 和 2 在包“12”中)。如果总和是偶数,则返回最大的标签;如果是奇数,则返回最小的标签。例如,“3”+“7”+“2”= 12,所以标签应该是“7”。但如果包是“4”+“7”+“2”= 13,则标签应该是“2”。 -
你不必使用输入的平均值作为你的上下文;你可以使用你认为可能工作得更好的任何东西。为了演示这一点,实现一个使用 注意力子网络 来计算上下文的注意力网络。这意味着将有一个初始上下文,它是平均
,用于计算 新的 上下文 x̄[cnt**x],然后用于计算 最终 输出 x̄。这对你在练习 4 中的准确性有影响吗?
摘要
-
注意力机制编码了一个先验,即序列或集合中的某些项目比其他项目更重要。
-
注意力机制使用上下文来决定哪些项目更重要或不太重要。
-
需要一个得分函数来确定在给定上下文中每个输入的重要性。点积、一般和加法得分都很受欢迎。
-
注意力机制可以处理可变长度的输入,并且在泛化它们方面特别出色。
-
与 RNNs 类似,注意力机制需要掩码来处理数据批次。
¹ 请参阅 J. Foulds 和 E. Frank 的文章,“多实例学习假设综述”,《知识工程评论》,第 25 卷,第 1 期,第 1-25 页,2010 年,www.cs.waikato.ac.nz/~eibe/pubs/FouldsAndFrankMIreview.pdf。↩
² 如果你想变得复杂一些,上下文可以是你认为有效的任何东西。但最简单的方法是将上下文作为所有输入的函数。↩
因为 v 只有一个输出,所以通常将其写成向量而不是只有一个列的矩阵。↩
11 序列到序列
本章涵盖
-
准备序列到序列数据集和加载器
-
将 RNN 与注意力机制结合
-
构建机器翻译模型
-
解释注意力分数以理解模型的决定
现在我们已经了解了注意力机制,我们可以利用它们来构建一些新颖而强大的东西。特别是,我们将开发一个名为 序列到序列(简称 Seq2Seq)的算法,它可以执行机器翻译。正如其名所示,这是一种让神经网络将一个序列作为输入并产生不同序列作为输出的方法。Seq2Seq 已被用于让计算机执行符号计算¹,总结长文档²,甚至将一种语言翻译成另一种语言。我将一步步地展示我们如何将英语翻译成法语。实际上,谷歌使用的生产机器翻译工具基本上采用了同样的方法,你可以在ai.googleblog.com/2016/09/a-neural-network-for-machine.html上了解相关信息。如果你能将你的输入/输出想象成一系列事物,那么 Seq2Seq 有很大机会帮助你解决这个任务。
聪明的读者可能会想,RNN 接收一个序列并为每个输入产生一个输出。所以它接收一个序列并输出一个序列。这不是“序列到序列”吗?你是一个明智的读者,但这也是我为什么称 Seq2Seq 为一种设计方法的部分原因。理论上,你可以让 RNN 做任何 Seq2Seq 能做的事情,但让它工作起来会很困难。一个问题是一个单独的 RNN 假设输出序列的长度与输入相同,这很少是真实的。Seq2Seq 将输入和输出解耦成两个独立阶段和部分,因此工作得更好。我们很快就会看到这是如何发生的。
Seq2Seq 是我们在本书中实现的最复杂的算法,但我会向你展示如何将其分解成更小、更易于管理的子组件。利用我们关于组织 PyTorch 模块的知识,这将只会带来一点痛苦。我们通过我们的数据集和 DataLoader 的任务特定设置来准备训练,描述 Seq2Seq 实现的子模块,并以展示注意力机制如何让我们窥视神经网络的黑盒,理解 Seq2Seq 是如何进行翻译的作为结束。
11.1 将序列到序列视为一种去噪自编码器
接下来,我们将详细介绍 Seq2Seq,如何实现它,以及如何将其应用于将英语翻译成法语。但首先,我想展示一个例子,看看它如何工作得很好,以及注意力提供的可解释性³。图 11.1 展示了大型 Seq2Seq 翻译模型的结果,从法语翻译成英语。输出中的每个单词都对原始句子应用了不同的注意力,其中黑色值表示 0(不重要)和白色值表示 1(非常重要)。

图 11.1 展示了 Dzmitry Bahdanau、Kyunghyun Cho 和 Yoshua Bengio 发表的论文“通过联合学习对齐和翻译进行神经机器翻译”的结果。输入在左侧,输出在顶部。在更大语料库上训练更多轮次时,注意力结果产生了一个清晰、紧凑的注意力图。
对于输出中的大多数项目,来自输入的相关项非常少,才能得到正确的翻译。但有些情况需要从输入中提取多个单词才能正确翻译,而另一些则需要重新排序。传统的机器翻译方法,在更单词级别的层面上工作,通常需要复杂的启发式代码来处理需要多个上下文单词和可能不寻常顺序的情况。但现在我们可以让神经网络为我们学习细节,并了解模型是如何学习执行翻译的。
Seq2Seq 是如何学习做到这一点的呢?从高层次来看,Seq2Seq 算法在序列上训练去噪自编码器,而不是在静态图像上。你可以把原始英语看作是带噪声的输入,法语看作是干净的输出,并要求 Seq2Seq 模型学习如何去除噪声。由于我们处理的是序列,这通常涉及到 RNN。图 11.2 展示了一个简单的图。我们有一个编码器和解码器,将输入转换为输出,就像去噪自编码器一样;区别在于 Seq2Seq 模型在序列上工作,而不是在图像或全连接输入上。

图 11.2 展示了序列到序列方法的概述。输入序列 x[1],x[2],…,x[T] 进入编码器。编码器生成序列的表示 h[encoded]。解码器接收这个表示并输出一个新的序列 y[1],y[2],…,y[T’]。
在这里,我们有一些原始输入序列 X = x[1],x[2],…,x[T],目标是输出一个 新 的序列 Y = y[1],y[2],…,y[T′]。这些序列不必相同。x[j] ≠ y[j],它们甚至可以是不同长度的,所以 T ≠ T′ 也是可能的。我们把这描述为去噪方法,因为输入序列 X 被映射到一个相关的序列 Y,几乎就像 X 是 Y 的噪声版本一样。
训练 Seq2Seq 模型并不容易。像任何使用 RNN 的东西一样,它计算上具有挑战性。它也是一个困难的学习问题;编码器 RNN 接收 X 并产生一个最终的隐藏状态激活h[T],解码器 RNN 必须将其作为输入来产生新的序列 Y。这需要将大量信息压缩到一个单一的向量中,我们已经看到 RNN 训练的困难性。
11.1.1 添加注意力创建 Seq2Seq
让 Seq2Seq 模型工作良好的秘诀是添加一个注意力机制。我们不是强迫模型学习一个表示h[编码]来表示整个输入,而是为输入序列中的每个项目x[i]学习一个表示h[i]。然后我们在输出的每个步骤使用注意力机制来查看所有输入。这更详细地展示在图 11.3 中,我们继续扩展 Seq2Seq 不同部分的细节,直到我们可以实现整个方法。注意,编码器的最后一个隐藏状态h[T]成为解码器的初始隐藏状态,但我们还没有指出解码器在每个时间步的输入是什么。我们将在本章的后面讨论这个问题;因为部分太多,不能一次性全部讨论。

图 11.3 Seq2Seq 操作的高级图。解码器的第一个输入是编码器的最后一个输出。解码器产生一系列输出,这些输出被用作注意力机制的环境。通过改变环境,我们改变了模型在输入中寻找的内容。
现在我们一起来讨论图 11.1 和图 11.3。图 11.3 中的编码器网络的 T 个输入/隐藏状态将变成图 11.1 中原始法语的 T 行。图 11.3 中的每个注意力块变成图 11.1 中的一列。因为注意力为输入中的每个项目(填充一行)输出一个从 0 到 1 的分数,我们可以用热图来展示这些分数,低分用深色表示,高分用白色表示,从而显示输入中哪些部分对于产生输出中的每个部分最为重要。因此,我们得到了完整的图 11.1。这也说明了注意力如何捕捉到重要性是一个相对概念。输入中每个词的重要性会根据我们试图产生的结果或其他存在的词而变化。
你可能想知道为什么注意力机制能提高基于 RNN 的编码器和解码器的性能:长短期记忆(LSTM)中的门控机制不是也在做同样的事情,根据当前上下文有选择地允许/不允许(门控开启/关闭)信息吗?从高层次来看,是的,这种方法有相似之处。关键的区别在于信息的可用性。如果你像图 11.2 所示的那样,只用两个 RNN 作为编码器和解码器,解码器 RNN 的隐藏状态必须学会如何表示(1),它在创建输出过程中的进度有多远,(2)整个原始输入序列,以及(3)如何不使两者相互干扰。通过在解码器 RNN 的输出上使用注意力机制,RNN 现在只需要学习#1,因为所有原始输入项都将在后续的注意力过程中可用,这缓解了#2,也意味着#3 不再是问题。虽然 Seq2Seq 是一个复杂的算法,但你已经学习和使用了实现它的每一个步骤。这实际上是将深度学习的许多构建块组合在一起以产生一个强大结果的练习。
11.2 机器翻译和数据加载器
在考虑大局目标——构建 Seq2Seq 翻译模型的情况下——我们将从下往上工作。最底层开始于定义什么是翻译以及加载数据。然后我们可以继续处理 Seq2Seq 模型的输入,最后添加注意力机制以逐个产生输出。
广义上,机器翻译是研究人员用来研究如何让计算机将一种语言(例如,英语)翻译成另一种语言(例如,法语)的术语。机器翻译作为一个问题也将有助于巩固 Seq2Seq 模型输入和输出的不同序列以及可能的不同长度。
输入 X 所使用的语言——在我们的案例中,是英语——被称为源语言。目标语言,法语,被称为目标语言。我们的输入序列 X 可能是字符串“what a nice day”,目标字符串 Y 为“quelle belle journée”。翻译的一个困难方面是这些序列的长度不同。如果我们使用单词作为我们的标记(也称为我们的字母表Σ),我们的源序列 X 和目标序列 Y 是:

如果我们能成功将序列 X 转换为 Y,我们就完成了机器翻译的任务。几个细微之处可能会使这变得困难:
-
如前所述,序列的长度可能不同。
-
序列之间可能存在复杂的关系。例如,一种语言可能将形容词放在名词之前,而另一种语言可能将名词放在形容词之前。
-
可能不存在一对一的关系。例如,“what a nice day”和“what a lovely day”都可以有相同的从英语到法语的翻译。翻译通常是一个多对多任务,其中多个有效输入映射到多个有效输出。
如果你问一个自然语言处理(NLP)研究人员,他们会给你一个更长的列表,解释为什么以及机器翻译具有挑战性。但这也是使用 Seq2Seq 模型的一个绝佳机会,因为你不需要查看整个输入来决定输出中每个单词的决策。例如,“journée”可以翻译为“day”或“daytime”;它不是一个同音异义词。因此,我们可以几乎独立于任何其他上下文来翻译这个单词。单词“amende”需要更多的上下文,因为它是一个“fine”和“almond”的同音异义词;如果你不知道某人是在谈论食物还是金钱,你就无法翻译它。我们的注意力机制可以帮助我们忽略那些对翻译没有提供任何有用上下文的输入。这也是为什么 Seq2Seq 模型能够在这一任务上做得很好,即使我们无法列举出所有翻译困难的理由。
11.2.1 加载一个小型的英语-法语数据集
要构建一个机器翻译数据集,我们需要一些数据。我们将重用一小部分英语-法语翻译语料库。以下代码快速下载它并进行一些轻微的预处理:移除标点符号并将所有内容转换为小写。虽然有可能学习这些事情,但这样做需要更多的数据,而我们希望这个例子在有限的数据量下快速运行:
from io import BytesIO
from zipfile import ZipFile
from urllib.request import urlopen
import re
all_data = []
resp = urlopen("https://download.pytorch.org/tutorial/data.zip")
zipfile = ZipFile(BytesIO(resp.read()))
for line in zipfile.open("data/eng-fra.txt").readlines():
line = line.decode(’utf-8’).lower() ❶
line = re.sub(r"[-.!?]+", r" ", line) ❷
source_lang, target_lang = line.split("\t")[0:2]
all_data.append( (source_lang.strip(), target_lang.strip()) ) ❸
❶ 只需小写即可。
❷ 不需要标点符号。
❸ (英语,法语)
为了帮助我们获得一些直观感受,以下代码打印了语料库的前几行,以展示数据。我们已经在数据中看到了许多困难。像“run”这样的单词本身就有多个正确的翻译,而且一些单个的英语单词可以变成一个或多个法语单词。这是在我们甚至查看语料库中更长的句子之前:
for i in range(10):
print(all_data[i])
('go', 'va')
('run', 'cours')
('run', 'courez')
('wow', 'ça alors')
('fire', 'au feu')
('help', "à l'aide")
('jump', 'saute')
('stop', 'ça suffit')
('stop', 'stop')
('stop', 'arrête toi')
为了使训练更快,让我们限制自己只使用包含六个或更少单词的句子。你可以尝试增加这个限制,看看模型的表现如何,但我想让这些例子快速训练。一个现实世界的翻译任务可以使用相同的代码,但需要更多的时间和数据进行学习,但这可能需要数天的训练,所以让我们保持简短:
short_subset = [] ❶
MAX_LEN = 6
for (s, t) in all_data:
if max(len(s.split(" ")), len(t.split(" "))) <= MAX_LEN:
short_subset.append((s,t))
print("Using ", len(short_subset), "/", len(all_data))
Using 66251 / 135842
❶ 我们使用的子集
构建字母表
现在的short_subset列表包含了我们将用于模型的全部英语-法语翻译对,我们可以为我们的模型构建一个词汇表或字母表。和之前一样,一个词汇表会给每个唯一的字符串分配一个唯一的 ID 作为标记,从 0 开始计数。但我们还添加了一些特殊的标记,以给我们的模型提供有用的提示。首先,因为不是所有句子长度都相同,我们使用一个PAD_token来表示填充,表示张量中的值没有被使用,因为底层句子已经结束。
我们引入的两个新事物是 句子开始(SOS)和 句子结束(EOS)标记。这些在机器翻译以及许多其他 NLP 任务中都很常见。SOS_token 标记通常放置在源序列 X 的开头,以指示算法翻译已经开始。EOS_token 更有用,因为它附加到目标序列 Y 的末尾,表示句子已完成。这非常有用,因为模型可以学习如何结束翻译。当模型完成时,它输出一个 EOS_token,我们可以停止这个过程。你可能认为标点符号是一个好的停止点,但这种方法会阻止我们将模型扩展到一次翻译句子或段落。一旦批次中的每个项目都生成了一个 EOS_token,我们就知道可以安全地停止。这也很有帮助,因为输出可能具有不同的长度,EOS 标记帮助我们确定每个不同生成的序列应该有多长。
以下代码定义了我们的 SOS、EOS 和填充标记,并创建了一个字典 word2indx 来创建映射。我们还将 PAD_token 定义为映射到索引 0,这样我们就可以轻松地使用我们的 getMaskByFill 函数。类似于我们的自回归模型,创建了一个逆字典 indx2word,这样我们可以在完成后更容易地查看我们的结果(阅读单词比阅读整数序列更容易):
SOS_token = "<SOS>" ❶
EOS_token = "<EOS>" ❷
PAD_token = "_PADDING_"
word2indx = {PAD_token:0, SOS_token:1, EOS_token:2}
for s, t in short_subset:
for sentence in (s, t):
for word in sentence.split(" "):
if word not in word2indx:
word2indx[word] = len(word2indx)
print("Size of Vocab: ", len(word2indx))
indx2word = {} ❸
for word, indx in word2indx.items():
indx2word[indx] = word
Size of Vocab: 24577
❶ “句子开始标记"
❷ “句子结束标记"
❸ 为后续查看构建逆字典
实现翻译数据集
现在我们创建一个 TranslationDataset 对象,它代表我们的翻译任务。它接受 short_subset 作为底层输入数据,并通过使用我们刚刚创建的词汇表 word2indx 来分割空间,返回 PyTorch int64 张量:
class TranslationDataset(Dataset):
"""
Takes a dataset with tuples of strings (x, y) and
converts them to tuples of int64 tensors.
This makes it easy to encode Seq2Seq problems.
Strings in the input and output targets will be broken up by spaces
"""
def __init__(self, lang_pairs, word2indx):
"""
lang_pairs: a List[Tuple[String,String]] containing the
➥ source,target pairs for a Seq2Seq problem.
word2indx: a Map[String,Int] that converts each word in an input
➥ string into a unique ID.
"""
self.lang_pairs = lang_pairs
self.word2indx = word2indx
def __len__(self):
return len(self.lang_pairs)
def __getitem__(self, idx):
x, y = self.lang_pairs[idx]
x = SOS_token + " " + x + " " + EOS_token
y = y + " " + EOS_token
x = [self.word2indx[w] for w in x.split(" ")] ❶
y = [self.word2indx[w] for w in y.split(" ")] ❶
x = torch.tensor(x, dtype=torch.int64)
y = torch.tensor(y, dtype=torch.int64)
return x, y
bigdataset = TranslationDataset(short_subset, word2indx)
❶ 转换为整数列表
Seq2Seq 任务通常需要大量的训练数据,但我们目前没有这么多,因为我们希望这个示例能在 10 分钟内运行。我们将使用 90% 的数据用于训练,仅 10% 用于测试。
实现翻译数据的 collate 函数
我们还需要定义一个 collate_fn 函数,用于将不同长度的输入合并成一个更大的批次。每个项目已经以 EOS_token 结尾,所以我们只需用我们的 PAD_token 值填充较短的输入,使所有内容长度相同。
为了使此与我们的 train_network 函数兼容,我们还需要在返回 collate_fn 的结果时稍微聪明一点。我们将以一组嵌套元组的形式返回数据,((X,Y),Y),因为 train_network 函数期望包含两个元素的元组(input,outpu**t),而我们的 Seq2Seq 模型在训练时需要 X 和 Y。原因将在下面解释。这样,train_network 函数将分解元组为

以下代码完成了所有工作。pad_batch 是我们的收集函数,它首先找到最长的输入序列长度 max_x 和最长的输出序列长度 max_y。由于我们是在填充(而不是 打包,这仅由 RNNs 支持),我们可以使用 F.pad 函数来完成这项工作。它接受要填充的序列作为第一个输入,以及一个元组告诉它需要向左和向右填充多少。我们只想在序列的右侧(末尾)进行填充,所以我们的元组看起来像 (0, pad_amount):
train_size = round(len(bigdataset)*0.9)
test_size = len(bigdataset)-train_size
train_dataset, test_dataset = torch.utils.data.random_split(bigdataset,
➥ [train_size, test_size]))
def pad_batch(batch):
"""
Pad items in the batch to the length of the longest item in the batch
"""
max_x = max([i[0].size(0) for i in batch]) ❶
max_y = max([i[1].size(0) for i in batch]) ❶
PAD = word2indx[PAD_token]
X = [F.pad(i[0],(0,max_x-i[0].size(0)), value=PAD) ❷
➥ for i in batch]
Y = [F.pad(i[1],(0,max_y-i[1].size(0)), value=PAD) ❷
➥ for i in batch]
X, Y = torch.stack(X), torch.stack(Y)
return (X, Y), Y
train_loader = DataLoader(train_dataset, batch_size=B, shuffle=True,
collate_fn=pad_batch)
test_loader = DataLoader(test_dataset, batch_size=B, collate_fn=pad_batch)
❶ 我们有两个不同的最大长度:输入序列的最大长度和输出序列的最大长度。我们分别确定每个长度,并且只填充所需的精确量。
❷ 我们使用 F.pad 函数将每个张量向右填充。
11.3 Seq2Seq 的输入
当我们谈论 Seq2Seq 模型时,有两个输入集:编码器的输入和解码器的输入。为了指定每个部分的输入内容,我们需要定义 Seq2Seq 的编码器和解码器块在图 11.3 中的样子。您可能不会感到惊讶,我们为每个都使用了 RNN,您可以选择是否要使用门控循环单元(GRU)或 LSTM。当我们稍后编码时,我们将使用 GRU,因为它使代码更容易阅读。
编码器的输入很简单:我们只是输入输入序列 X = x[1], x[1], …, x[T]。解码器产生它认为的输出序列的预测:Ŷ = ŷ[1], ŷ[1], …, ŷ[T’]。我们将 Ŷ 和 Y 之间的交叉熵损失作为训练这个网络的信号。
但我们遗漏了一个重要细节:解码器的输入。循环神经网络(RNNs)通常接受一个先前的隐藏状态(对于解码器来说,这将是对应的 h[encoded])以及当前时间步的输入。解码器的输入有两种选择:自回归风格和教师强制。我们将学习这两种选择,因为同时使用两者比单独使用任何一个都要好。
对于这两种选择,我们使用编码器的最后一个隐藏状态(h[T])作为解码器的初始隐藏状态(h[encoded] = h[T])。我们这样做而不是使用零向量,是为了让梯度通过解码器流向编码器,将它们连接起来。更重要的是,最后一个隐藏状态 h[T] 是整个输入序列的摘要,这种“输入是什么”的上下文将帮助解码器决定输出序列的第一部分应该是什么。
11.3.1 自回归方法
图 11.4 展示了我们实现解码器输入的第一个选项,我们称之为 自回归 选项。对于自回归方法,我们使用时间步 t 的预测标记作为下一个时间步 t + 1 的输入(虚线灰色线条)。

图 11.4 展示了自回归方法在解码步骤中的应用示例。解码器的第一个输入是编码器的最后一个输入。解码器的后续每个输入都是前一步的预测。
另一个细节是第一个输入应该是什么。我们还没有做出任何预测,所以不能使用之前的预测作为输入。有两种子选项,它们的表现非常相似。第一种选项是始终使用 SOS 标记作为输入,这是一个非常合理的想法。从语义上讲,这也很有意义;第一个输入表示“句子开始”,RNN 必须使用上下文来预测第一个单词。第二种选项是使用输入的最后一个标记,这应该是 EOS 标记或填充标记。这导致解码器 RNN 学习到 EOS 和填充具有与“句子开始”相同的语义意义。任何一种选项都是可接受的,并且在实践中选择通常不会造成明显的差异。我们将实现它,使编码器的最后一个项目成为解码器的第一个输入,因为我认为这是一个稍微更通用的方法。
由于我们模型的输出是下一个单词的概率,因此有两种选择下一个输入t + 1 的方式:选择最可能的标记,或者根据给定的概率采样下一个标记。在第六章中,当我们训练自回归模型时,选择最可能的下一个单词导致了不切实际的结果。因此,当我们实现这一点时,我们将采用采样方法。
11.3.2 教师强制方法
第二种方法被称为教师强制。解码器的第一个输入处理方式与自回归方法完全相同,但后续输入不同。我们不是使用预测ŷ[t]作为ŷ[t + 1]的输入,而是使用真实的正确标记y[t],如图 11.5 所示。

图 11.5 展示了教师强制示例。对于解码器,我们忽略预测,并在步骤 t 时输入正确的先前输出y[t - 1]。当计算损失时,预测仍然被使用。
这使得教师强制更容易实现,因为我们不需要猜测接下来会发生什么:我们已经有答案了。这也是为什么我们的代码在训练期间需要真实的标签 Y 作为输入,以便我们可以计算教师强制结果。
11.3.3 教师强制与自回归方法的比较
教师强制方法的好处是给网络提供正确的答案,以便继续其预测。这使得正确预测所有后续标记变得更容易。直觉很简单:如果你在前t - 1 次预测中都错了,那么在t次预测中保持正确就更加困难。教师强制通过允许网络同时学习它需要预测的所有单词来帮助网络。
自回归方法可能学习速度较慢,因为网络必须先正确预测第一个单词,然后才能专注于第二个单词,然后是第三个,依此类推。但是当我们想要对新数据进行预测,而我们不知道答案时,教师强制(teacher forcing)是不可能的:我们必须以自回归的方式做出预测,因此对于模型学习以这种方式进行预测是有好处的。
我们使用的实际解决方案是将自回归和教师强制方法结合起来。对于每个输入,我们随机决定我们想要采取哪种方法,因此我们将同时进行训练。但是在预测时间,我们只执行自回归选项,因为教师强制需要知道答案。一个更复杂的方法是在单个批次中在教师强制和自回归之间切换,但这很难实现。每个批次选择一个选项将使我们的代码更容易编写和阅读。
教师强制的使用也是我们为什么将目标序列 Y 作为网络输入的一部分的原因。我们使用self.training标志在训练和预测我们的模型时切换不同的行为。
11.4 带有注意力的 Seq2Seq
我们迄今为止所展示和描述的技术上足够实现一个序列到序列风格的模型。然而,它不会很好地学习,并且性能会很差。引入注意力机制是使 Seq2Seq 工作起来的关键。注意力机制可以与教师强制和自回归方法一起工作,并将改变我们在 RNN 的第 t 步预测当前单词的方式。因此,我们不是让解码 RNN 预测ŷ[t],而是让它产生一个潜在值ẑ[t]。值ẑ[t]是注意力机制中的上下文。图 11.6 显示了预测第 t 个单词的四个主要组件的过程:
-
编码步骤学习输入的有用表示。
-
解码步骤为输出中的每个项目预测一个上下文。
-
注意力步骤使用上下文产生一个输出x̄[t],并将其与上下文ẑ[t]结合。
-
预测步骤将结合的注意力/上下文结果预测序列的下一个标记。

图 11.6 应用注意力预测输出中每个项目的流程。每个突出显示的区域显示了四个步骤之一:编码、解码上下文、注意力和预测。这会重复进行,直到找到 EOS 标记或达到最大限制。
你可以看到,注意力块(可以使用我们定义的三个评分函数中的任何一个)将上下文ẑ[t]和来自编码 RNN 的隐藏状态h[1],h[2],…,h[T]作为其输入。
注意力机制,结合之前使用的 apply_attn 模块,为每个隐藏状态生成一个权重 α[1],α[2],…,α[T]。然后我们使用隐藏状态和权重来计算当前时间步 t 的最终上下文,x̄[t] = Σ[i]^T[=1] α[i] h[i]。由于每个 h[i] 最受第 i 个输入的影响,这为 Seq2Seq 模型提供了一种只查看输入序列的子集作为预测输出第 t 项相关的方法。
为了完成这个,我们将注意力输出 x̄[t] 与局部上下文 ẑ[t] 连接起来,得到一个新的向量

我们将其输入到一个最终的完全连接网络中,以将其转换为我们的最终预测,ŷ[t]。
这可能看起来有些令人畏惧,但你已经为所有这些步骤编写或使用过代码。编码器使用一个普通的 nn.GRU 层,因为它可以返回形状为 (B,T,H) 的张量,提供所有输出,这是你在第四章中学到的。我们使用 nn.GRUCell 来进行上下文预测 ẑ[t],因为它必须一步一步地进行;你在第六章中用它来自回归模型。在同一章中,你使用采样来选择下一个标记,并使用类似教师强制的方法训练模型。我们刚刚学习和使用了注意力机制,你在第七章中学习了将连接输出输入到另一个层中用于 U-Net。
11.4.1 实现 Seq2Seq
我们已经准备好实现 Seq2Seq 模型。为了设置我们的构造函数,让我们谈谈模型本身需要哪些组件。
首先,我们需要一个 nn.Embedding 层来将标记转换为特征向量,类似于第七章中的 char-RNN 模型。我们使用 padding_idx 选项来指定哪个标记值用于表示填充,因为存在多个不同的序列长度。
接下来,我们需要一个编码器 RNN 和一个解码器 RNN。我们使用 GRU,因为它相对于 LSTM 来说编写代码要简单一些。特别是,我们将分别对每个 RNN 进行编码。对于编码器,我们使用正常的 nn.GRU 模块,它接受形状为 (B,T,D) 的张量,因为它期望一次性接受所有 T 项。这更容易编写代码,也让我们能够轻松地使用双向选项。由于我们有整个输入,双向选项是一个不错的选择。⁴
解码器 RNN 不能是双向的,因为我们一次生成一个输出项。这也意味着我们不能使用正常的 nn.GRU 模块,因为它期望所有 T′ 个输出项同时准备好,但我们不知道每个输出有多长,直到我们遇到所有的 EOS 标记。为了解决这个问题,我们使用 nn.GRUCell。这将需要我们手动跟踪解码器的隐藏状态和多层,我们必须编写一个 for 循环来持续迭代预测,直到我们得到完整的结果。
为了确保在出现不良预测的情况下不会陷入无限循环,我们包含一个 max_decode_length 来强制执行通过解码器 RNN 的最大解码步骤数。最后,我们需要我们的 ApplyAttention 模块和一些 score_net 来计算分数(我们使用 DotScore),以及一个小型网络 predict_word 来预测下一个单词。以下代码片段涵盖了我们所讨论的所有内容,并为我们的 Seq2Seq 模型创建了一个构造函数:
class Seq2SeqAttention(nn.Module):
def __init__(self, num_embeddings, embd_size, hidden_size,
➥ padding_idx=None, layers=1, max_decode_length=20):
super(Seq2SeqAttention, self).__init__()
self.padding_idx = padding_idx
self.hidden_size = hidden_size
self.embd = nn.Embedding(num_embeddings, embd_size,
➥ padding_idx=padding_idx)
self.encode_layers = nn.GRU(input_size=
➥ embd_size, hidden_size=hidden_size//2, ❶
➥ num_layers=layers, bidirectional=True)
self.decode_layers = nn.ModuleList([ ❷
➥ nn.GRUCell(embd_size, hidden_size)] +
➥ [nn.GRUCell(hidden_size, hidden_size)
➥ for i in range(layers-1)])
self.score_net = DotScore(hidden_size)
self.predict_word = nn.Sequential( ❸
nn.Linear(2*hidden_size, hidden_size),
nn.LeakyReLU(),
nn.LayerNorm(hidden_size),
nn.Linear(hidden_size, hidden_size),
nn.LeakyReLU(),
nn.LayerNorm(hidden_size),
nn.Linear(hidden_size, num_embeddings)
)
self.max_decode_length = max_decode_length
self.apply_attn = ApplyAttention()
❶ 我们将隐藏大小设置为预期长度的一半,因为我们使编码器双向。这意味着我们得到两个隐藏状态表示,我们将它们连接起来,得到所需的大小。
❷ 解码器是单向的,我们需要使用 GRUCells 以便我们可以逐个步骤地进行解码。
❸ predict_word 是一个小型全连接网络,它将注意力机制的结果和局部上下文转换为对下一个单词的预测。
现在,我们可以讨论如何实现 Seq2Seq 算法的 forward 函数。图 11.7 概述了该过程。

图 11.7 forward 函数的概述及其实现中的七个步骤。每个块显示一个工作单元,箭头显示顺序任务。
我们将按照我们的图中的顺序遍历这些块,解释正在发生的事情,并展示实现它们的代码。请注意,在图中,我们已经将两个列表分开:all_attentions 和 all_predictions。这些列表收集注意力和预测分数,以便我们可以从模型中获取并查看注意力分数,或者将它们传递给任何我们可能想要使用的后续模块。
准备、嵌入和掩码块
在我们的函数中,我们首先需要做一些准备和组织工作。输入可以是形状为 (B,T) 的张量,或者是一个包含两个张量的元组 ((B,T),(B,T′)),这取决于我们是在测试模式还是训练模式。我们检查输入的内容,并适当地提取 input 和 target 值。我们 embd 所有的输入值,并计算一些有用的东西,比如我们的 mask;从 mask 中,我们可以确定每个序列的长度。长度是 True 值的数量,因此我们可以通过简单的 sum 调用得到 seq_lengths。我们还获取正在使用的计算设备,以便稍后当我们需要为解码器采样下一个输入时使用:
if isinstance(input, tuple): ❶
input, target = input
else:
target = None
B = input.size(0) ❷
T = input.size(1) ❸
x = self.embd(input) ❹
device = x.device ❺
mask = getMaskByFill(x) ❻
seq_lengths = mask.sum(dim=1).view(-1) ❼
❶ 输入应为 (B, T) 或 ((B, T), (B, T’))
❷ 批处理大小是多少?
❸ 最大输入时间步数是多少?
❹ (B, T, D)
❺ 获取模型当前所在的设备。我们稍后会需要这个信息。
❻ 形状 (B, T)
❶ 形状(B),包含非零值的数量
编码块
现在我们数据和掩码已经准备好了,我们需要将数据推送到我们的编码器网络。为了最大化吞吐量,我们在将其输入到 RNN 之前对输入数据进行打包,由于我们已经从mask中计算了seq_lengths,所以这既简单又快速。此外,h_last即使在变长项中也包含最后一个激活,简化了我们的代码。我们确实需要为后续的注意力机制解包h_encoded,并将其重塑为(B,T,D),因为我们使用的是双向模型。一些类似的形状操作将确保h_last的形状为(B,D),而不是我们双向方法默认的(2,B,D/2)。
这里是代码:
x_packed = pack_padded_sequence(x, seq_lengths.cpu(), ❶
➥ batch_first=True, enforce_sorted=False)
h_encoded, h_last =self.encode_layers(x_packed)
h_encoded, _ = pad_packed_sequence(h_encoded) ❷
h_encoded = h_encoded.view(B, T, -1) ❸
hidden_size = h_encoded.size(2) ❹
h_last = h_last.view(-1, 2, B, hidden_size//2)[-1,:,:,:] ❺
h_last = h_last.permute(1, 0, 2).reshape(B, -1) ❻
❶ 使用序列长度为编码器 RNN 创建打包的输入。
❷ (B, T, 2, D//2),因为它是双向的
❸ (B, T, D)。现在h_encoded是编码器 RNN 在输入上运行的结果!
❹ 获取最后一个隐藏状态有点复杂。首先,输出被重塑为(num_layers, directions, batch_size, hidden_size);然后我们抓取第一个维度中的最后一个索引,因为我们想要最后一层的输出。
❺ 现在的形状是(2, B, D/2)。
❻ 重排为(B, 2, D/2)并将最后两个维度展平到(B, D)
解码准备块
在开始解码块之前,我们需要做一些准备工作。首先,我们存储一个列表,其中包含解码器 RNN 的前一个隐藏状态激活。我们这样做是因为我们使用的是GRUCell,它需要我们跟踪隐藏状态激活,以便我们可以使 RNN 的顺序步骤运行得更加高效。⁵
为了使我们的代码更简单,我们重用embd层来编码解码器的输入。这是可以的,因为embd层做的工作非常少;大部分工作都是由解码器层完成的。由于我们将使编码器的最后一个输入成为解码器的第一个输入,我们需要获取这个输入。执行input[:,seq_lengths-1]看起来应该可以工作,但它返回一个形状为(B,B,D)的张量,而不是(B,D)。为了使它按我们的意愿工作,我们需要使用gather函数,它沿着指定的轴(1)收集指定的索引。
所有这些都在以下解码器准备代码块中,它以确定我们需要运行解码器的步骤数结束:
h_prevs = [h_last for l in range(len(self.decode_layers))] ❶
decoder_input = self.embd(input.gather(1,
➥ seq_lengths.view(-1,1)-1).flatten()) ❷
steps = min(self.max_decode_length, T) ❸
if target is not None: ❹
steps = target.size(1)
❶ 为解码器生成新的隐藏状态
❷ 从输入中获取最后一个项目(应该是 EOS 标记)作为解码器的第一个输入。我们也可以硬编码 SOS 标记。(B, D)。
❸ 我们应该进行多少解码步骤?
❹ 如果我们在训练,目标值会告诉我们确切需要走多少步。我们知道确切的解码长度。
计算上下文和注意力块
现在,我们需要计算上下文和注意力结果。这是在t步骤的for循环中运行的。变量decodr_input是我们的当前输入:从上一个准备步骤中选择的值或我们在接下来的两个步骤中计算的值。
我们将GRUCell放在decode_layers列表中,我们遍历这个列表,自己将批量推过层,就像我们在第六章中用自回归模型做的那样。一旦完成,我们的结果ẑ[t]存储在一个名为h_decoder的变量中。对score_net的调用获取归一化分数,然后apply_attn返回变量context和weights中的x̄[t]和 softmax 权重α。这在上面的代码块中显示:
x_in = decoder_input ❶
for l in range(len(self.decode_layers)):
h_prev = h_prevs[l]
h = self.decode_layersl
h_prevs[l] = h
x_in = h
h_decoder = x_in ❷
scores = self.score_net(h_encoded, h_decoder) ❸
context, weights = self.apply_attn(h_encoded, scores, ❹
➥ mask=mask)
all_attentions.append( weights.detach() ) ❺
❶ (B, D)
❷ (B, D)。我们现在有了解码器在这个时间步的隐藏状态。
❸ 这就是注意力机制。让我们看看所有之前的编码状态,看看哪些看起来相关。(B, T, 1) ![../Images/tilde_alpha.png]。
❹ (B, D)用于 x̄和(B, T)用于α
❺ 保存注意力权重以供后续可视化。我们正在断开连接权重,因为我们不想用它们计算任何其他东西;我们只想保存它们的值以进行可视化。
计算预测块
在完成前面的任务后,我们可以使用torch.cat将x̄[t]和ẑ[t]组合起来,并将结果输入到predict_word中以获取我们的最终预测ŷ[t]对于tth 输出:
word_pred = torch.cat((context, h_decoder), dim=1) ❶
word_pred = self.predict_word(word_pred) ❷
all_predictions.append(word_pred)
❶ 通过连接注意力结果和初始上下文来计算最终表示:(B, D) + (B, D) -> (B, 2*D)。
❷ 通过将下一个标记推入一个小型全连接网络来获取关于下一个标记的预测:(B, 2*D) -> (B, V)。
这又是nn.Sequential帮助使代码整洁的情况,因为predict_word是一个完整的神经网络,我们不需要考虑它。
选择解码器的下一个输入块
我们几乎完成了 Seq2Seq 的实现,只剩下一部分需要实现:在步骤t + 1 选择decoder_input的下一个值。这是在with torch.no_grad()上下文中完成的,这样我们就可以做我们需要的工作。首先,我们检查模型是否处于self.training模式,因为如果不是,我们可以退出并简单地选择最可能的词。如果我们处于训练模式,我们检查是否应该使用teacher_forcing,并防御性地检查我们的target值是否可用。如果两者都为真,我们将t + 1 的输入设置为当前时间步 t 应该发生的真实输出。否则,我们以自回归的方式采样下一个词。以下是代码:
if self.training:
if target is not None and teacher_forcing:
next_words = target[:,t].squeeze() ❶
else:
next_words = torch.multinomial( ❷
➥ F.softmax(word_pred, dim=1), 1)[:,-1]
else:
next_words = torch.argmax(word_pred, dim=1) ❸
❶ 我们有目标和选定的教师强制,所以使用正确的下一个答案。
❷ 根据预测采样下一个标记
❸ 我们正在尝试做出实际预测,所以我们取最可能的词。我们可以通过使用温度和采样来改进这一点,就像我们在 char-RNN 模型中做的那样。
在这段代码中,有两点值得注意,可以改进。首先,在测试时间,我们不是取最可能的下一个词,而是可以像自回归模型那样进行采样。其次,我们可以添加一个温度选项,就像我们之前使用的那样,以改变我们选择更可能词的频率。我没有做出这些更改,以使代码更简单。
一旦选择了next_words并退出with torch.no_grad()块,我们就可以设置decoder_input = self.embd(next_words.to(device))。我们需要等待no_grad()上下文消失,以便跟踪这一步的梯度。
返回结果块
最后,我们到达了 Seq2Seq 实现的终点。最后一步是返回我们的结果。如果我们处于训练模式,我们只需要通过堆叠所有T′个单词的预测来返回预测,得到torch.stack(all_predictions, dim=1)。如果我们处于评估模式,我们还想获取注意力分数,以便我们可以检查它们。与预测类似,它们也被堆叠在一起:
if self.training: ❶
return torch.stack(all_predictions, dim=1)
else: ❷
return torch.stack(all_predictions, dim=1),
➥ torch.stack(all_attentions, dim=1).squeeze()
❶ 在训练时,只有预测是重要的。
❶ 在评估时,我们还想查看注意力权重。
11.4.2 训练和评估
我们已经定义了一个 Seq2Seq 模型,现在我们可以尝试使用了。我们使用 20 个训练周期和几个层。由于这是一个 RNN,我们在训练中也使用了梯度裁剪。64 维的嵌入维度和 256 个隐藏神经元的大小属于较小的一侧,以便使运行更快;如果我有更多的时间和数据,我更愿意将这些值设置为 128 和 512。
下面是代码:
epochs = 20 seq2seq = Seq2SeqAttention(len(word2indx), 64, 256,
➥ padding_idx=word2indx[PAD_token], layers=3, max_decode_length=MAX_LEN+2)
for p in seq2seq.parameters():
p.register_hook(lambda grad: torch.clamp(grad, -10, 10))
定义损失函数
我们最后需要的是一个损失函数来训练我们的网络。标准的nn.CrossEntropyLoss无法处理我们的输出形状为(B,T,V)的情况,其中 V 是词汇表的大小。相反,我们遍历输出的所有 T 个时间步,并从输入和标签中切下正确的部分,这样我们就可以调用nn.CrossEntropyLoss而不会出现任何错误。这与我们在第六章中用于训练自回归模型的方法相同。
我们所做的唯一改变是使用ignore_index值。如果标签y=ignore_index,nn.CrossEntropyLoss不会为该值计算任何损失。我们可以使用这一点来处理填充标记,因为我们不希望网络学习预测填充;我们希望它在适当的时候预测 EOS 标记,然后完成。这允许我们的损失函数理解输出也有填充:
def CrossEntLossTime(x, y):
"""
x: output with shape (B, T, V)
y: labels with shape (B, T’)
"""
if isinstance(x, tuple):
x, _ = x
cel = nn.CrossEntropyLoss(ignore_index=word2indx[PAD_token]) ❶
T = min(x.size(1), y.size(1))
loss = 0
for t in range(T):
loss += cel(x[:,t,:], y[:,t])
return loss
❶ 我们不希望为填充过的项目计算损失!
这样,我们可以使用我们的 Seq2Seq 模型和新的损失函数调用train_network函数。目前我们不使用验证损失,有几点原因。我们需要在我们的train_network函数中添加更多代码来支持它,因为我们的 Seq2Seq 模型在评估时会改变其输出。更大的问题是,我们使用的损失函数看起来并不直观。尽管如此,我们之后绘制训练损失图,以确保它在下降,以确认学习正在发生:
seq2seq_results = train_network(seq2seq, CrossEntLossTime,
➥ train_loader,epochs=epochs, device=device)
sns.lineplot(x=’epoch’, y=’train loss’, data=seq2seq_results,
➥ label=’Seq2Seq’)
[19]: <AxesSubplot:xlabel='epoch', ylabel='train loss'>

关于验证损失的 BLEU?
评估 Seq2Seq 模型可能特别具有挑战性。当存在多个有效翻译时,你怎么知道一个翻译是错误的?这是我们训练过程中忽略的东西,但不知何故,模型仍然表现得相当出色。
我们用于训练的损失函数是人们通常用来训练 Seq2Seq 模型的东西,但人们倾向于使用不同的评估指标来确定他们的模型有多好。这些评估指标相当复杂,所以我们坚持使用更主观的评估,以避免在本章中添加太多内容。如果你想了解更多,机器翻译通常与 BLEU(en.wikipedia.org/wiki/BLEU)分数进行比较。但如果你不是在进行翻译任务,BLEU 可能不是最好的指标,因为 BLEU 是专门为翻译设计的。
可视化注意力分数图
观察网络的损失,我们可以清楚地看到它在训练过程中有所下降。为了帮助我们评估结果,我们可以进行一些翻译并查看注意力机制的结果。这将是一种主观分析,但我鼓励在处理 Seq2Seq 模型时进行主观评估。在存在多对多映射的情况下,客观评估往往很困难,因此与数据打交道有助于理解正在发生的事情。在每一个时间步 t,注意力机制会告诉我们哪些输入单词对于预测每个输出单词是重要的。这有助于我们了解模型是否在学习合理的东西。
我们定义了以下plot_heatmap函数来快速绘制注意力结果。results函数将使用它来接收输入,将其翻译成法语,并显示预测、注意力和真实翻译:
def plot_heatmap(src, trg, scores):
fig, ax = plt.subplots() heatmap = ax.pcolor(scores, cmap=’gray’)
ax.set_xticklabels(trg, minor=False, rotation=’vertical’)
ax.set_yticklabels(src, minor=False)
ax.xaxis.tick_top() ❶
ax.set_xticks(np.arange(scores.shape[1]) + 0.5, minor=False)
ax.set_yticks(np.arange(scores.shape[0]) + 0.5, minor=False)
ax.invert_yaxis()
plt.colorbar(heatmap)
plt.show()
❶ 将主要刻度放在每个单元格的中间,并将 x 刻度放在顶部
在定义结果函数之前,让我们快速将我们的seq2seq模型置于评估模式。这样,我们就能得到注意力图。然后函数可以使用我们的逆映射indx2word来查看原始数据和预测应该是什么。我们打印出输入和目标,以及seq2seq的预测。最后,我们展示注意力分数的热图,这将帮助我们主观地评估结果。这个函数的输入仅仅是我们要考虑的测试集索引:
seq2seq = seq2seq.eval().cpu()
def results(indx):
eng_x, french_y = test_dataset[indx]
eng_str = " ".join([indx2word[i] for i in eng_x.cpu().numpy()])
french_str = " ".join([indx2word[i] for i in french_y.cpu().numpy()])
print("Input: ", eng_str)
print("Target: ", french_str)
with torch.no_grad():
preds, attention = seq2seq(eng_x.unsqueeze(0))
p = torch.argmax(preds, dim=2)
pred_str = " ".join([indx2word[i] for i in p[0,:].cpu().numpy()])
print("Predicted: ", pred_str)
plot_heatmap(eng_str.split(" "), pred_str.split(" "),
➥ attention.T.cpu().numpy())
现在,我们可以看看一些结果。根据你的模型训练运行的不同,你可能会得到略微不同的结果。对于第一句话,我得到的翻译是“les animaux ont peur du feu”,谷歌翻译说这翻译回英文句子是“some animals are afraid of fire”。在这种情况下,模型得到了一个基本上正确的结果,但它使用了“les”,这翻译成“the”而不是更合适的“certain”。
注意力图显示,“les”确实在查看输入的正确部分(“一些”),但可能过于关注句子的开头。如果我必须猜测,“the”可能比“some”更常见于句首,网络基于这一点犯了错误。但我们也可以看到,“du feu”正确地关注了输入中的“of fire”部分,从而产生了正确的输出。虽然数学并不完美,但模型出于可理解的原因选择了具有相似意义的单词。
我们还可以看到如何使用动作机制来更好地理解我们的模型结果。这是注意力机制的一个非常强大的特点:它们为我们提供了对通常不透明的神经网络的一定程度的可解释性。在这种情况下,可能需要获取更多具有各种起始标记/短语的多样化句子。
下面是代码:
results(12)
Input: <SOS> some animals are afraid of fire <EOS>
Target: certains animaux craignent le feu <EOS>
Predicted: les animaux ont peur du feu <EOS> <EOS>

下一个翻译展示了对于我经常看到翻译差异较大的句子,结果如下:“今天天气怎么样”,将原文的前四个词从“天气如何”进行了改变。虽然与目标不同,但结果仍然是一个相当正确的翻译。这表明 Seq2Seq 在学习和处理这些困难问题时具有一定的能力,尽管存在这些问题,这种方法仍然能够成功学习。这也是为什么评估机器翻译任务的平等性可以非常困难。在这个例子中,注意力机制并不那么清晰,因为在这种情况下,没有单个单词意味着“天气”。
下面是代码:
results(13)
Input: <SOS> what is the weather like today <EOS>
Target: comment est le temps aujourd'hui <EOS>
Predicted: quel temps fait il aujourd'hui <EOS> <EOS> <EOS>

下一个例子显示了另一个合理的翻译——谷歌翻译为我返回了相同的结果。看起来模型可能替换了一些同义词或改变了性别化的单词,但为了确定这一点,我需要知道法语。这也是为什么我喜欢机器学习的原因之一——我可以让计算机做许多我自己无法做到的事情:
results(16)
Input: <SOS> no one disagreed <EOS>
Target: personne ne fut en désaccord <EOS>
Predicted: personne n'exprima de désaccord <EOS>

如果你拥有进行更大训练集的计算资源,并且有更多时间,你就可以得到如图 11.1 所示的翻译结果。这不仅显示了从法语到英语的较长句子的转换,而且还显示了注意力机制的更精细输出。可以明显看出正在翻译的确切单词,并且可以看到模型正确地改变了“zone économique européenne”到“European Economic Area”的顺序,适应了语言的细微差别。
虽然之前的代码仍然缺少一个最大化性能的主要技巧,但这并不是一个玩具实现。这种方法已经在现实世界的机器翻译系统中使用,并且在 2019 年被新的方法所取代。
练习
在 Inside Deep Learning Exercises 的 Manning 在线平台上分享和讨论您的解决方案(liveproject.manning.com/project/945)。一旦您提交了自己的答案,您将能够看到其他读者提交的解决方案,并看到作者认为哪些是最好的。
-
默认情况下,解码器用户 h[T] 作为其初始隐藏状态。尝试将其更改为使用解码器输出的平均值,即 1/T Σ[t]^T[=1] h[t]。
-
我们的 Seq2Seq 模型是硬编码为
DotScore的。尝试使用GeneralScore以及您对GeneralScore的新初始化在翻译任务上。您的评估结果显示哪个表现更好或更差? -
挑战性任务: 为图像加法任务创建一个新的数据集。将输入设为随机数量的 MNIST 图像,输出值设为表示数字之和的字符序列。因此,输入可能是一系列表示 6、1 和 9 的图像,然后输出应该是字符串“16。”然后修改 Seq2Seq 模型以训练和运行此问题。提示: 修改编码器和
collate_fn。 -
写出为什么练习 3 中的任务具有挑战性的原因。网络需要学习哪些内容来解决这个问题?尝试阐述解决该问题需要多少不同的心理概念和任务,以及它们是如何通过网络的损失函数显式或隐式定义的。
-
将 Seq2Seq 模型修改为使用 LSTM 层而不是 GRU 层。这将需要对 LSTM 的自身上下文结果进行一些额外的跟踪。
-
尝试训练不使用任何教师强制——只使用自回归方法的模型。它们的性能如何比较?
摘要
-
我们可以训练一个类似于去噪自编码的模型,称为序列到序列模型,用于解决具有多对多映射的问题。
-
机器翻译是一个多对多的问题,我们可以通过 Seq2Seq 模型来实现并做得很好。
-
注意力分数可以用作模型的可解释输出,使我们更好地理解模型是如何做出决策的。
-
自回归方法可以与教师强制结合使用,以帮助模型更快、更好地学习。
¹ G. Lample 和 F. Charton, F. “深度学习在符号数学中的应用”,载于 ICLR 2020 会议论文集。↩
² A. See, P. J. Liu, 和 C. D. Manning, “Get to the point: summarization with pointer-generator networks,” 计算机语言学协会,2017。↩
³ “可解释性”是什么,或者它是否真的存在,在许多机器学习圈子中实际上是一个热烈讨论的话题。为了获得一篇优秀的观点文章,请参阅 Zachary Lipton 的“模型可解释性的神话”一文,见 arxiv.org/abs/1606.03490。↩
⁴ 双向 RNN 的最大选择通常是“在测试时我能否访问到未来的/整个序列?”对于我们的翻译任务来说,这是成立的,所以这是值得做的。但并非总是如此!如果我们想制作一个实时接收 现场语音 的 Seq2Seq 模型,我们就需要使用一个非双向模型,因为我们不会知道说话者的未来词汇。↩
⁵ 如果我们想用 LSTMCell 来替换它,我们需要为 LSTM 的上下文状态再准备另一个列表。↩
12 RNNs 的 12 种网络设计替代方案
本章涵盖
-
克服 RNNs 的限制
-
使用位置编码向模型添加时间
-
将 CNN 适应基于序列的问题
-
将注意力扩展到多头注意力
-
理解 Transformer
循环神经网络——特别是 LSTMs——已经用于分类和解决序列问题超过二十年。虽然它们长期以来一直是可靠的工具,但它们有几个不希望的特性。首先,RNNs 非常慢。它们需要很长时间来训练,这意味着需要等待结果。其次,它们在更多层(难以提高模型精度)或更多 GPU(难以使它们训练更快)的情况下扩展得不好。通过跳过连接和残差层,我们已经了解了许多使全连接和卷积网络通过更多层来获得更好结果的方法。但 RNNs 似乎不喜欢深度。你可以添加更多层和跳过连接,但它们并不显示出与提高精度相同的程度的好处。
在本章中,我们探讨一些可以帮助我们解决一个或两个这些问题的方法。首先,我们通过违背先前的信念来解决 RNNs 的缓慢问题。我们使用 RNNs 是因为我们知道数据是序列,但我们假装它不是一个正常序列,这样我们就可以更快地做些事情——并且可能更不准确。接下来,我们探讨一种不同的方式来表示数据的序列组件,以增强这些更快的替代方案并恢复一些准确性。
最后,我们了解 Transformer(不是受版权保护的类型),它们的训练速度甚至更慢,但与深度和强大的计算设备相比,扩展得更好。基于 Transformer 的模型正在迅速成为自然语言处理中许多任务最高精度解决方案的基础,并且很可能成为你作为从业者生活的一部分。
12.1 TorchText:文本问题的工具
为了帮助我们测试 RNNs 许多替代方案的相对优缺点,我们首先需要的是一个数据集和一些基线结果。对于本章,我简要介绍了并使用了torchtext包。像torchvision一样,它是 PyTorch 的一个子项目。而torchvision提供了针对基于视觉问题的额外工具,torchtext则提供了围绕基于文本问题的额外工具。我们不会深入探讨它包含的所有特殊功能;我们只是用它来方便地访问更难的数据集。
12.1.1 安装 TorchText
首先要做的就是快速确认torchtext和可选依赖sentencepiece已安装;否则,我们无法使所有期望的功能正常工作。以下代码在通过 Colab(或任何 Jupyter 笔记本)运行时安装这两个包:
# !conda install -c pytorch torchtext
# !conda install -c powerai sentencepiece
# !pip install torchtext
# !pip install sentencepiece
12.1.2 在 TorchText 中加载数据集
现在我们可以导入 torchtext 和相关的 datasets 包,我们将加载 AG News 数据集。这个数据集有四个类别,torchtext 将提供快速准备训练的实用工具。但首先,我们导入包,获取数据集迭代器(这是 torchtext 喜欢提供数据的方式),并将训练数据和测试数据分别放入各自的列表中:
import torchtext
from torchtext.datasets import AG_NEWS
train_iter, test_iter = AG_NEWS(root=’./data’, split=(’train’, ’test’))
train_dataset = list(train_iter)
test_dataset = list(test_iter)
现在我们已经加载了 AG News 数据集,该数据集为每个文档提供了四个可能的主题类别:世界、体育、商业和科技。以下代码块打印出该数据集的一个示例。我们有一个表示输入 x 的单词 string 和表示类 y 的标签。不幸的是,torchtext 违背了通常按数据/输入顺序首先返回,然后是标签的常规趋势(x, y),而是交换了顺序为 (y,x):
print(train_dataset[0])
(3, "Wall St. Bears Claw Back Into the Black (Reuters) Reuters - Short-
sellers, Wall Street's dwindling\textbackslash{}\textbackslash{}band of
ultra-cynics, are seeing green again.")
我们需要将每个句子分解成一组离散的标记(即单词),并构建一个词汇表(Σ)来将每个单词映射到唯一的整数值,因为 nn.Embedding 层只接受整数输入。torchtext 提供了一些工具,使这个过程变得非常简单,并可以与一些现有的 Python 工具一起使用。Python 带来了 Counter 对象来计算不同标记出现的次数,torchtext 提供了 tokenizer 和 Vocab 对象来分割字符串并为我们做账簿记录:
from torchtext.data.utils import get_tokenizer ❶
tokenizer = get_tokenizer(’basic_english’) ❷
from collections import Counter ❸
from torchtext.vocab import Vocab ❹
counter = Counter()
for (label, line) in train_dataset: ❺
counter.update(tokenizer(line)) ❻
vocab = Vocab(counter, min_freq=10, ❼
➥ specials=(’<unk>’, ’<SOS>’, ’<EOS>’, ’<PAD>’))
❶ 分词器将“this is a string”这样的字符串分解成标记列表,如 [‘this’,‘is’,‘a’,‘string’]。
❷ 我们对默认的英语风格分词器没有问题。
❸ 这个数据集有多少行
❹ 我们需要创建一个包含训练集中所有单词的词汇表。
❺ 遍历训练数据
❻ 计算唯一标记的数量以及它们出现的频率(例如,我们经常看到“the”,但“sasquatch”可能只出现一次或根本不出现)
❼ 创建一个词汇对象,删除任何至少没有出现 10 次的单词,并为未知、句子开头、句子结尾和填充添加特殊的词汇项
现在我们需要两个额外的辅助函数来完成 torchtext 特定的设置。text_transform 接收一个字符串并将其转换为基于词汇 Σ 的整数列表。另一个更简单的项目是 label_transform,它确保标签处于正确的格式(你可能需要根据 torchtext 中的不同数据集进行更多更改):
def text_transform(x): ❶
return [vocab[’<SOS>’]] + [vocab[token] ❷
for token in tokenizer(x)] + [vocab[’<EOS>’]]
def label_transform(x):
return x-1 ❸
print(text_transform(train_dataset[0][1])) ❹
[1, 434, 428, 4, 1608, 14841, 116, 69, 5, 851, 16, 30, 17, 30, 18, 0, 6, 434,
377, 19, 12, 0, 9, 0, 6, 45, 4012, 786, 328, 4, 2]
❶ 将字符串转换为整数列表
❷ 词汇表就像一个字典,处理未知标记。我们可以分别用开始和结束标记进行前缀和后缀。
❸ 标签最初是 [1, 2, 3, 4],但我们需要 [0, 1, 2, 3]。
❹ 将第一个数据点的文本转换为标记列表
torchtext 的具体细节现在基本已经处理完毕;我们可以进行一些初始的 PyTorch 仪式来设置环境。我们想要记录词汇表中有多少个标记以及类别的数量,并决定嵌入维度、批量大小和训练轮数。在下面的起始代码中,我为每个参数选择了任意值。我们还保存了代表我们的填充标记 "<PAD>" 的整数,因为我们将在批处理数据集时需要它:
VOCAB_SIZE = len(vocab)
NUM_CLASS = len(np.unique([z[0] for z in train_dataset]))
print("Vocab: ", VOCAB_SIZE)
print("Num Classes: ", NUM_CLASS)
padding_idx = vocab["<PAD>"]
embed_dim = 128
B = 64
epochs = 15
Vocab: 20647
Num Classes: 4
我们还定义了用于 PyTorch 的 DataLoader 类的 collate_fn 函数。collate_fn 通常是最好的实现填充的地方,因为它允许你根据当前批次数据所需的最小量进行填充。它也是一个方便的地方,可以反转标签和输入顺序,以匹配我们期望的标准 (input, label) 模式。这样,我们返回的批次将遵循(x,y)的模式,就像我们的其他代码所期望的那样,我们可以重用我们迄今为止所做的一切。
所有这些都在 pad_batch 函数中完成,它遵循正常的填充方法。我们首先确定批次中所有项的最长序列,并将其值存储在 max_len 变量中。然后我们使用 F.pad 函数将每个批次中的项向右填充。这使得所有项具有相同的长度,我们可以将它们 stack 成一个单一的张量。注意,我们确保使用 padding_idx 作为填充的值:
def pad_batch(batch):
"""
Pad items in the batch to the length of the longest item in the batch.
Also, re-order so that the values are returned (input, label)
"""
labels = [label_transform(z[0]) for z in batch] ❶
texts = [torch.tensor(text_transform(z[1]), ❷
➥ dtype=torch.int64) for z in batch]
max_len = max([text.size(0) for text in texts]) ❸
texts = [F.pad(text, (0,max_len-text.size(0)), ❹
➥ value=padding_idx) for text in texts]
x, y = torch.stack(texts), torch.tensor(labels, dtype=torch.int64) ❺
return x, y
❶ 获取并转换批次中的每个标签
❷ 获取并标记化每个文本,并将它们放入一个张量中
❸ 在这个批次中,最长的序列是哪一个?
❹ 使用 whatever amount makes it max_len 对每个文本张量进行填充
❺ 将 x 和 y 合并为一个单一的张量
现在,我们可以像以前多次做的那样构建我们的 DataLoader,以向我们的模型提供数据。多亏了 Dataset 抽象,我们不需要知道 torchtext 实现其加载器的细节,我们可以看到,通过设置 collate_fn = pad_batch,我们可以通过数据集返回数据的方式解决任何数据集可能存在的怪癖。我们不需要重写数据集,我们选择的 collate_fn 简单地以我们想要的方式清理 Dataset 的输出:
train_loader = DataLoader(train_dataset, batch_size=B, shuffle=True,
➥ collate_fn=pad_batch)
test_loader = DataLoader(test_dataset, batch_size=B, collate_fn=pad_batch)
12.1.3 定义基线模型
现在我们已经准备好了数据集合并函数和加载器,我们可以训练一些模型了。让我们先实现一个基线 RNN 模型,我们可以将其与每个替代方案进行比较,从而为我们提供一个衡量利弊的标尺。我们将使用基于门控循环单元(GRU)的 RNN,并带有三个双向层,以尝试最大化我们获得的准确率。我们在这里使用 GRU 是因为它比 LSTM 快,并且我们希望给它尽可能多的机会在运行时比较中获胜。虽然我们可以探索不同的隐藏维度大小、学习率和其他旋钮,但这将是一个相当直接和标准的第一次尝试,我会用它来构建 RNN。所以,让我们选择我们的损失函数并调用我们方便的 train_network:
gru = nn.Sequential(
nn.Embedding(VOCAB_SIZE, embed_dim, ❶
➥ padding_idx=padding_idx),
nn.GRU(embed_dim, embed_dim, num_layers=3, ❷
➥ batch_first=True, bidirectional=True),
LastTimeStep(rnn_layers=3, bidirectional=True), ❸
nn.Linear(embed_dim*2, NUM_CLASS), ❹
)
loss_func = nn.CrossEntropyLoss()
gru_results = train_network(gru, loss_func, train_loader,
➥ val_loader=test_loader, score_funcs={’Accuracy’: accuracy_score},
➥ device=device, epochs=epochs)
❶ (B, T) -> (B, T, D)
❷ (B, T, D) -> ( (B,T,D) , (S, B, D) )
❸ 我们需要将 RNN 输出减少到一个项目,(B, 2*D)。
❹ (B, D) -> (B, classes)
我们第一次尝试的结果显示在下面,看起来做得相当不错,在前几个时期后有一些过拟合,但稳定在约 91.5% 的准确率。在本章的其余部分,随着我们开发新的替代方案,我们将它们绘制在同一个图上以进行整体比较。例如,我们想要考虑最大准确率(如果我们更好地正则化,我们可能能够实现什么),稳定准确率(我们可以用很少的努力得到什么),以及达到某些准确率水平的时期(我们需要训练多长时间)。
下面是代码:
sns.lineplot(x=’epoch’, y=’val Accuracy’, data=gru_results, label=’GRU’)
[16]: <AxesSubplot:xlabel='epoch', ylabel='val Accuracy'>

12.2 时间上平均嵌入
GRU 基准已经准备好供我们比较,所以让我们学习处理可变长度序列分类问题的第一个新方法。我们不是在时间序列上执行复杂的 RNN 操作,而是对序列中的所有项目进行平均。所以如果我们有一个形状为 (B,T,D) 的输入,我们计算第二个轴上的平均值,创建一个新的形状为 (B,D) 的张量。它有易于实现的优点;速度快;并且消除了时间维度,允许我们在那个点之后应用我们想要的任何方法(例如残差连接)。图 12.1 展示了这看起来是什么样子。

图 12.1 时间上平均嵌入的示例。时间上的 T 个输入通过权重共享由相同的神经网络处理。因为没有跨时间的连接,模型不知道它们有顺序。时间维度通过简单地平均所有表示来解析,这样就可以在之后运行一个常规的全连接网络来生成输出。
由于我们在时间维度上平均,我们忽略了数据有顺序的事实。另一种说法是我们忽略了数据的结构以简化我们的模型。我们可能想这样做以使我们的训练和预测更快,但这也可能适得其反,导致结果质量更差。
我们可以采用的一种简单的时间平均实现方法是使用我们在第八章中学到的自适应池化。自适应池化通过取期望输出大小并调整池化大小来强制输出达到期望的大小来实现。Faster R-CNN 使用自适应池化将任何输入缩小到 7 × 7 的网格。相反,我们将稍微利用一下自适应池化是如何工作的。如果我们的输入形状为(B,T,D),我们想要在最后两个维度上执行自适应池化,因此我们使用自适应 2D 池化。我们使池化的目标形状非对称,形状为(1,D)。我们的输入在最后一个维度上已经具有 D 的形状,所以最后一个维度不会被改变。时间维度缩小到 1,强制它进行时间平均。由于其自适应池化,即使 T 在批次之间发生变化,此代码也能正常工作。我们可以在下面的代码中非常快速地定义并尝试此模型。
如前所述,此代码依赖于这样一个事实:如果你给 PyTorch nn.Linear层一个形状为(B,T,D)的张量,它将独立地对所有 T 个不同的输入应用线性层,从而在单个调用中有效地执行权重共享。我们使用具有显式目标形状(1,D)的nn.AdaptiveAvgPool2d,将输入张量从(B,T,D)减少到(B,1,D)。然后我们可以用一个隐藏层和一个nn.Linear来预测类别:
simpleEmbdAvg = nn.Sequential(
nn.Embedding(VOCAB_SIZE, embed_dim, padding_idx=padding_idx), ❶
nn.Linear(embed_dim, embed_dim),
nn.LeakyReLU(),
nn.Linear(embed_dim, embed_dim),
nn.LeakyReLU(),
nn.Linear(embed_dim, embed_dim),
nn.LeakyReLU(),
nn.AdaptiveAvgPool2d((1,embed_dim)), ❷
nn.Flatten(), ❸
nn.Linear(embed_dim, embed_dim),
nn.LeakyReLU(),
nn.BatchNorm1d(embed_dim),
nn.Linear(embed_dim, NUM_CLASS)
)
simpleEmbdAvg_results = train_network(simpleEmbdAvg, loss_func, train_loader,
➥ val_loader=test_loader, score_funcs={’Accuracy’: accuracy_score},
➥ device=device, epochs=epochs)
❶ (B, T) -> (B, T, D)
❷ (B, T, D) -> (B, 1, D)
❸ (B, 1, D) -> (B, D)
下面的两行代码绘制了结果。查看这两种方法的准确率,我们发现 GRU 表现更好。如果你多次运行,你可能会发现平均嵌入方法有时开始对数据进行过拟合。所以,乍一看,这种替代方案并不值得:
sns.lineplot(x=’epoch’, y=’val Accuracy’, data=gru_results, label=’GRU’)
sns.lineplot(x=’epoch’, y=’val Accuracy’, data=simpleEmbdAvg_results,
➥ label=’Average Embedding’)
[18]: <AxesSubplot:xlabel='epoch', ylabel='val Accuracy'>

但我们应该问为什么平均嵌入方法能起作用。我们通过忽略关于数据我们知道的一些真实情况来实现这种方法:它有一个顺序,并且顺序很重要。你不能只是重新排列句子中的单词,就能得到一个可理解的结果。
平均嵌入方法可以得到合理的准确率,因为通常有办法在问题中作弊。例如,AG 新闻的四个类别是世界、体育、商业和科技。所以如果你的模型看到包含“NFL”、“touchdown”和“win”这些单词的句子,这些单词出现的顺序实际上并不重要。你仅从这些单词的存在就能猜测这可能是一篇体育文章。同样,如果你看到“banking”和“acquisition”这些单词,你就可以判断有很高的可能性这是一篇商业文章,而不需要了解句子的其他内容。
平均嵌入可以得到合理的准确率,因为我们并不总是“需要”我们的先验知识来做得好。平均嵌入的潜在好处在于我们观察模型训练所需的时间。以下代码重新创建了图表,但将total_time放在 x 轴上。这清楚地表明,训练平均嵌入大约比 GRU 模型快三倍。如果你有一个巨大的数据集,训练 GRU 可能需要一周时间,那么 3 倍的速度提升就非常吸引人了:
sns.lineplot(x=’total time’, y=’val Accuracy’, data=gru_results, label=’GRU’)
sns.lineplot(x=’total time’, y=’val Accuracy’, data=simpleEmbdAvg_results,
➥ label=’Average Embedding’)
[19]: <AxesSubplot:xlabel='total time', ylabel='val Accuracy'>

对嵌入进行平均最终违反了一个先前的信念,即数据有顺序且顺序很重要,以换取一个更简单、更快的模型。我经常构建这样的模型,仅仅因为我正在处理大量数据,并且我希望更快地得到一些初步结果。但这也是一个更大任务的练习:学习识别当你有一个可接受的模型,但违反了你的一些信念时。
在这个特定场景中,我们可以解释为什么一个违反我们信念(时间很重要)的模型(单个单词信息量太大)能够工作。在未来,你可能会发现这样一个更简单的模型表现异常出色,而一个优秀的从业者不应该仅仅根据结果的好坏来评价模型。如果你无法合理地解释为什么一个违反这个假设的模型能够工作,你应该深入挖掘你的数据,直到你能够解释为止。看看模型做对和做错的地方,并试图弄清楚模型是如何学习完成这项工作的。模型做错或做对的数据点中是否存在任何明显的模式?输入数据中是否有不应该存在的东西?有没有一些无害的因素,比如长度与目标之间意外地很好地相关?
我无法提前告诉你如何进行这个过程,除了对你的模型成功感到惊讶之外。把自己放在一个作弊者的心态中,试图找出作弊的方法。手动标记一些数据,看看你是否能想出不同的方法来得到答案,并看看你的方法与你的模型偏差如何匹配。例如,如果你可以通过挑选几个关键词来标记一个句子,那么单词的顺序就不像你想象的那么重要了。
在进行这个过程时,你可能会遇到“信息泄露”的情况,即关于标签的信息错误或不切实际地渗入你的训练数据中。发现这一点是好事,这意味着在继续进行任何更多模型开发之前,你需要修复信息泄露,因为它会污染你试图构建的任何模型。根据我的经验,过于简单的模型比预期工作得更好,几乎总是表明我最终会发现的信息泄露。
信息泄露是如何发生的?
当你在构建数据时,如果数据中存在与你的标签 y 强相关的某些内容,但相关性并不自然存在,信息泄露就可能以无限可能的方式发生。相反,它是创建和组织数据过程中产生的错误,通常是由于数据准备中的错误。人们经常无意中将标签 y放置在输入特征 x 中,模型很快就会学会利用这一点!
我将从我在构建恶意软件检测模型方面发表的一些工作中举一个例子。^a 许多人使用微软 Windows 干净安装的数据来表示良性或安全数据。然后他们在网上找到了一些恶意软件,并构建了一个看起来几乎完美的准确率的分类器。当我们深入研究数据时,我们发现几乎微软发布的所有内容都带有“版权所有 微软公司”的字符串。这个字符串最终泄露了标签是“良性”的信息,因为它只出现在良性数据中,从未出现在恶意软件数据中。但在现实中,“版权所有 微软公司”的字符串与文件是否为恶意软件无关,因此模型在新数据上表现不佳。
信息泄露也可能以愚蠢或微妙的方式发生。有一个关于军事想要在 1960 年至 2000 年之间某个时间检测图像中是否存在坦克的 ML 模型的轶事(www.gwern.net/Tanks)。故事是这样的(我第一次听到时是这样的):他们收集了大量坦克和空地的图片,并训练了一个网络,该网络获得了完美的准确率。但每张坦克的图片都有太阳,而非坦克的图片没有太阳。模型学会了检测太阳而不是坦克,因为识别太阳更容易。这从未真正发生过,但这个故事是一个关于信息如何以意想不到的方式泄露的有趣故事。
^a E. Raff 等人,“对恶意软件分类中字节 n-gram 特征的调查”,J Comput Virol Hack Tech,第 14 卷,第 1-20 页,2018 年,doi.org/10.1007/s11416-016-0283-1。↩
12.2.1 时间加权平均与注意力
虽然我们之前实现的代码是可行的,但它稍微有些不正确。为什么?因为nn.AdaptiveAvgPool2d对填充输入一无所知,这导致嵌入的值为 0。这会影响到较短的序列的幅度。让我们看看这是如何发生的。为了简单起见,假设我们将标记嵌入到一维中。那么我们可能有三组看起来像这样的数据点:

当我们计算每个项目的平均值时,我们希望得到这个:

但我们在这一批中填充了所有内容,使其长度与批次中最长的项目相同!填充值都是零,所以计算结果变为

这显著改变了x̄[1]和x̄[3]的值!为了解决这个问题,我们需要像在上一章中计算注意力机制的上下文向量一样,我们自己实现平均。
但如果我们计算那个上下文向量,为什么不应用注意力机制呢?注意力将学习所有输入词的加权平均,并且理论上可以学习根据所有可用信息忽略某些词。
为了尝试这个,我们将实现一个新的Module,它执行嵌入并基于哪些值被填充或未填充来计算一个掩码。由于掩码的形状为(B,T),我们可以通过对第二个时间维度求和来知道每个批次中有多少有效项。然后我们可以对时间上的所有项求和,除以适当的值,并得到传递给之前定义的注意力机制的上下文向量。
以下代码实现了一个EmbeddingAttentionBad类,该类负责将每个输入标记通过嵌入层,运行一些具有时间共享权重的隐藏层,然后应用第十章中我们的一种注意力机制来计算加权平均结果。它需要知道vocab_size和嵌入维度D作为参数,可选的embd_layers用于更改隐藏层的数量,以及padding_idx来告知它用于表示填充的值:
class EmbeddingAttentionBag(nn.Module):
def __init__(self, vocab_size, D, embd_layers=3, padding_idx=None):
super(EmbeddingAttentionBag, self).__init__()
self.padding_idx = padding_idx
self.embd = nn.Embedding(vocab_size, D, padding_idx=padding_idx)
if isinstance(embd_layers, int):
self.embd_layers = nn.Sequential( ❶
*[nn.Sequential(nn.Linear(embed_dim, embed_dim),
nn.LeakyReLU()) for _ in range(embd_layers)]
)
else:
self.embd_layers = embd_layers
self.attn = AttentionAvg(AdditiveAttentionScore(D)) ❷
def forward(self, input):
"""
input: (B, T) shape, dtype=int64
output: (B, D) shape, dtype=float32
"""
if self.padding_idx is not None:
mask = input != self.padding_idx
else:
mask = input == input ❸
x = self.embd(input) ❹
x = self.embd_layers(x) ❺
context = x.sum(dim=1)/(mask.sum(dim=1).unsqueeze(1)+1e-5) ❻
return self.attn(x, context, mask=mask) ❼
❶ (B, T, D) -> (B, T, D)
❷ 第十章中定义的函数
❸ 所有条目都是 True。掩码的形状为(B, T)。
❹ (B, T, D)
❺ (B, T, D)
❻ 对时间进行平均。(B, T, D) -> (B, D)。
❼ 如果我们想要进行正常平均,我们现在就可以返回上下文变量了!((B, T, D), (B, D)) -> (B, D)。
使用这个新模块,我们可以在以下代码块中构建一个简单的新的网络。它从计算嵌入、时间共享的隐藏层和注意力的EmbeddingAttentionBag开始。然后我们跟随一个隐藏层和nn.Linear来生成预测:
attnEmbd = nn.Sequential( ❶
EmbeddingAttentionBag(VOCAB_SIZE, embed_dim,
➥ padding_idx=padding_idx), ❷
nn.Linear(embed_dim, embed_dim),
nn.LeakyReLU(),
nn.BatchNorm1d(embed_dim),
nn.Linear(embed_dim, NUM_CLASS)
)
attnEmbd_results = train_network(attnEmbd, loss_func, train_loader,
➥ val_loader=test_loader, score_funcs={’Accuracy’: accuracy_score},
➥ device=device, epochs=epochs)
❶ 现在我们可以定义一个简单的模型了!
❷ (B, T) -> (B, D)
现在我们可以绘制结果。基于注意力的嵌入训练时间稍长,但仍然比 RNN 快 2 倍以上。这是有道理的,因为注意力版本执行了更多的操作。我们还可以看到,注意力提高了我们模型的准确性,接近 GRU。随着注意力嵌入的准确性随着更新次数的增加而急剧下降,它更容易过拟合:
sns.lineplot(x=’total time’, y=’val Accuracy’, data=gru_results, label=’GRU’)
sns.lineplot(x=’total time’, y=’val Accuracy’, data=simpleEmbdAvg_results,
➥ label=’Average Embedding’)
sns.lineplot(x=’total time’, y=’val Accuracy’, data=attnEmbd_results,
➥ label=’Attention Embedding’
[22]: <AxesSubplot:xlabel='total time', ylabel='val Accuracy'>

注意力嵌入的最大问题仍然是它对句子中单词的顺序没有任何感知。这是模型中缺失的时间成分。为了进一步强调这一点,让我们展示另一种看待问题的方法。考虑输入句子“红色的狐狸追逐蓝色的狗。”我们的模型将每个单词嵌入到其向量表示中,我们最终在嵌入(加权或未加权,取决于你是否使用了注意力)上执行某种平均。让我们看看如果嵌入维度为D = 1 且该维度的值设置为整数时,这个句子是如何工作的。这给我们以下设置:

这个等式展示了一个未加权的平均值,但将其变为加权平均值只会改变最终的结果——不理解数据顺序性的基本缺陷仍然存在。因此,例如,如果我们交换句子中的“狐狸”和“狗”,意义就会改变——但嵌入方法将返回相同的结果。这在下述等式中得到体现,并展示了网络无法检测到意义的微妙变化:

如果存在非常嘈杂数据的风险,这可能会成为一个特别的问题。下一个示例随机重排句子中的所有单词。句子的意义已经丢失,但模型仍然坚持认为没有任何变化。所有这些例子都与基于嵌入的模型相当:

对于解决问题来说,序列顺序越重要,当前平均向量的方法就越难以做出正确的分类。这也适用于基于注意力的版本,因为它没有意识到顺序。每个单词h[i]根据上下文
分配一个权重α[i],因此它看不到缺失的信息!这使得平均方法在复杂问题上的实用性非常特定于问题。想想看,在句子中使用否定语言,如“不”、“不是”或“没有”的任何情况。有了否定,语言顺序至关重要。这些都是你应该考虑的事情,作为使用嵌入方法获得更快的训练时间的代价。
注意:由于我在谈论缺点,仍然值得指出的是,基于注意力的嵌入与 GRU 的准确性非常接近。这意味着什么?如果我在数据集上看到这些结果,我会有两个初步假设:(1)我的数据顺序对于当前问题并不像我想象的那么重要,或者(2)我需要一个更大的 RNN,有更多的层或更多的隐藏神经元来捕捉数据顺序中存在的复杂信息。我会首先通过训练更大的模型来调查假设#2,因为这只需要计算时间。调查假设#1 需要我个人的时间来深入挖掘数据,而我通常更重视我的时间而不是电脑的时间。如果有一种通过运行新模型来回答问题的方法,我更喜欢这样做。
12.3 时间上的池化和一维 CNN
由于我们已经确定序列顺序信息的缺乏是导致结果更好的一个严重瓶颈,让我们尝试一种不同的策略,它保留了一些时间信息,但不是全部。我们已经了解并使用了卷积,它包括一个空间先验:相邻的事物很可能相关。这捕捉了我们之前嵌入方法所缺乏的很多序列顺序。RNN 和一维卷积在形状上相似,如图 12.2 所示。

图 12.2 示例显示了 RNN(左)和一维卷积(右)使用的张量形状,没有第一个批量维度。在每个情况下,我们都有一个轴表示张量代表的空间(T 与 W),另一个表示位置的特性(D 与 C)。
这表明一维卷积和 RNN 具有相同的形状,但每个轴分配的意义略有不同。RNN 的空间维度在时间轴 T 的索引 1 处。一维卷积的空间维度在索引 2 处,宽度与输入数据(类似于图像的宽度!)相同。对于空间先验,RNN 分配了这样的信念:所有项目的确切顺序都是重要的。相比之下,卷积分配的信念是只有相邻的项目是相关的。RNN 和 CNN 都编码了序列中特定项目的特征信息;它们只是对那些数据分配了不同的解释。
一维卷积对时间和顺序的感觉比 RNN 弱,但总比没有好。例如,否定词通常出现在它们试图否定的词之前,因此一维 CNN 可以捕捉到这种简单的空间关系。如果我们能重新排列我们的张量,使其空间维度与卷积期望的相匹配,我们就可以使用一维 CNN 来捕捉一些关于时间的信息,并将其用作我们的分类器。图 12.3 显示了我们可以通过交换张量的轴来进行这种重新排列。

图 12.3 将 RNN 的张量形状转换为适合使用一维卷积的形状。我们使用 .permute 函数交换第二和第三轴(保留批次轴)。这会将空间维度移动到一维卷积期望的位置。
与在图像上使用二维卷积不同,我们可以在单词上使用一维卷积。nn.Conv1d 层将数据视为 (B,C,W),其中 B 仍然是批次大小,C 是通道数,W 是输入的宽度(或单词数)。嵌入层的输出是 (B,T,D),其中 T 是序列的长度。如果我们重新排列输出为 (B,D,T),它将符合一维卷积的一般期望。我们可以使用这个技巧将基本上 任何 我们原本会使用 RNN 的问题转换为可以应用卷积网络的问题。
但仍然有一个问题。对于我们一直在使用的所有 CNN,输入都是一个固定大小:例如,MNIST 总是 28 × 28 的图像。但我们的问题中序列长度 T 是可变的!同样,我们可以使用自适应池化来帮助我们解决这个问题。
经过一系列的一维卷积、激活函数和正常池化操作后,我们张量的最终形状将是 (B,D′,T′),其中 D′ 和 T′ 表示通道数 (D′) 和序列长度 (T′) 可能已经被我们的卷积和正常池化操作所改变。如果我们只对最后一个维度使用自适应池化,我们可以将形状减少到 (B,D′,1),这将与原始输入的长度无关。如果我们使用自适应 max 池化,我们将选择每个通道的最大激活作为该通道在最终分类问题中的代表。这意味着我们可以在之后切换到使用线性层并执行我们想要的分类问题,因为从这一点开始形状将保持一致!下面的代码展示了如何将这些操作组合起来定义一个用于分类序列的一维卷积神经网络:
def cnnLayer(in_size, out_size): ❶
return nn.Sequential(
nn.Conv1d(in_size, out_size, kernel_size=k_size, padding=k_size//2),
nn.LeakyReLU(),
nn.BatchNorm1d(out_size))
k_size = 3
cnnOverTime = nn.Sequential(
nn.Embedding(VOCAB_SIZE, embed_dim,
➥ padding_idx=padding_idx), ❷
LambdaLayer(lambda x : x.permute(0,2,1)), ❸
cnnLayer(embed_dim, embed_dim), ❹
cnnLayer(embed_dim, embed_dim),
nn.AvgPool1d(2), ❺
cnnLayer(embed_dim, embed_dim*2),
cnnLayer(embed_dim*2, embed_dim*2),
nn.AvgPool1d(2), ❻
cnnLayer(embed_dim*2, embed_dim*4),
cnnLayer(embed_dim*4, embed_dim*4),
nn.AdaptiveMaxPool1d(1), ❼
nn.Flatten(), ❽
nn.Linear(4*embed_dim, embed_dim),
nn.LeakyReLU(),
nn.BatchNorm1d(embed_dim),
nn.Linear(embed_dim, NUM_CLASS)
)
cnn_results = train_network(cnnOverTime, loss_func, train_loader,
➥ val_loader=test_loader, score_funcs={’Accuracy’: accuracy_score},
➥ device=device, epochs=epochs)
❶ 我有点偷懒;我们也应该将 k_size 作为参数。
❷ (B, T) -> (B, T, D)
❸ (B, T, D) -> (B, D, T)
❹ 我们假设 D 是在这份数据的新解释中通道的数量。
❺ (B, D, T) -> (B, D, T/2)
❻ (B, 2D, T/2) -> (B, 2D, T/4)
❼ 现在我们已经进行了一些池化和卷积操作,将张量形状减少到固定长度。(B, 4D, T/4) -> (B, 4D, 1)
❽ (B, 4D, 1) -> (B, 4D)
注意:通常,我们可以根据个人偏好选择最大池化或平均池化。在这种情况下,有很好的理由选择nn.AdaptiveMaxPool1d而不是nn.AdaptiveAvgPool1d:批处理中的所有项目不一定具有相同的长度,并且接收填充的批处理项目将返回一个零值的向量。这意味着填充发生处的激活可能具有较小的值,因此可能不会被最大池化操作选中。这有助于我们的架构正常工作,即使我们忽略了不同输入具有不同长度的情况。这是一个棘手的解决方案,这样我们就不必过多考虑填充问题。
我们刚刚编写的策略,将具有 RNN 的序列问题转换为 1D 卷积,在分类任务中非常受欢迎。它为我们提供了大量的空间信息,同时允许我们更快地训练。以下代码显示了结果:
sns.lineplot(x=’total time’, y=’val Accuracy’, data=gru_results, label=’GRU’)
sns.lineplot(x=’total time’, y=’val Accuracy’, data=simpleEmbdAvg_results,
➥ label=’Average Embedding’)
sns.lineplot(x=’total time’, y=’val Accuracy’, data=attnEmbd_results,
➥ label=’Attention Embedding’)
sns.lineplot(x=’total time’, y=’val Accuracy’, data=cnn_results,
➥ label=’CNN Adaptive Pooling’)
[24]: <AxesSubplot:xlabel='total time', ylabel='val Accuracy'>

在运行带有自适应池化的 CNN 后,一些结果显示了各种优点和缺点。在积极的一面,CNN 似乎没有过度拟合,这意味着如果我们训练更多轮次,其性能可能会进一步提高。这也意味着 CNN 的最终准确率比平均嵌入更好。
我们当前的 CNN 也非常简单。正如我们通过添加更多层、残差连接和其他技巧来改进 2D CNNs 一样,我们可以重新实现这些方法(例如,残差连接)并将它们应用到此处以获得更好的结果。这使我们比 RNN 有优势,因为 RNN 不太可能随着我们添加所有这些我们学到的花哨功能而有很大改进。
在更令人失望的一侧,CNN 的峰值准确率并不如这个数据集上的平均嵌入那么好。我们已经知道,由于原始嵌入方法做得很好,数据的空间特性并不关键。空间信息对问题越重要,我们越期望这种 CNN 方法比嵌入方法表现更好。但训练这两个模型可以给你一些关于数据中顺序重要性的初步信息。
12.4 位置嵌入为任何模型添加序列信息
RNNs 能够捕捉输入数据的所有序列特性,而我们已看到 CNNs 可以捕捉其中的一些序列特性子集。我们的嵌入速度快且可能非常准确,但通常由于缺乏这种序列信息而过度拟合数据。这很难修复,因为序列信息来自于我们的模型设计(即使用 RNN 或 CNN 层)。
但如果我们能够将序列信息嵌入到嵌入中,而不是依赖于架构来捕捉序列信息,那会怎么样?如果嵌入本身包含关于它们相对顺序的信息,我们能否改进算法的结果?这正是最近一种称为位置编码的技术背后的想法。
图 12.4 展示了这个过程是如何工作的。我们将位置 t(例如,第一、第二、第三等)表示为一个向量。然后我们将这些向量加到输入上,使得输入包含自身以及每个项目在输入中的位置信息。这将在网络的输入中编码顺序信息,现在网络需要学习如何提取和使用关于时间的信息。

图 12.4 我们最初使用的平均嵌入方法,增加了位置嵌入。与输入分开,向量值编码了我们在一个较大序列中的位置信息。这被添加到表示输入的向量中,创建了一个内容和序列信息的混合。
最大的问题是,我们如何创建这种神话般的编码?这是一个稍微有些数学的方法,但并不复杂。我们需要定义一些符号以便讨论。让我们用h[i] ∈ ℝ^D 来表示在输入标记x[i]后得到的嵌入(来自nn.Embedding)。然后我们有一个嵌入序列h[1],h[2],…,h[t],…,h[T]。我们想要一个位置向量P(t),我们可以将其添加到我们的嵌入中,以创建一个改进的嵌入
[t],它包含关于原始内容h[t]及其作为输入中的第 t 个项目的位置信息——类似于这样:

然后,我们可以用
[1],
[2],…,
[t],…,
[T]作为我们网络其余部分的输入,知道顺序性质已经被放入嵌入中!结果我们发现可以用一个令人惊讶简单的方法做到这一点。我们定义一个函数来表示P(t),使用正弦和余弦函数,并使用正弦和余弦函数的输入来表示向量 t 的位置。为了说明这是如何工作的,让我们绘制正弦(t)看起来像什么:
position = np.arange(0, 100)
sns.lineplot(x=position, y=np.sin(position), label="sin(position)")
[25]: <AxesSubplot:>

正弦函数上下波动。如果我们计算出 sin(t) = y,知道 y 告诉我们输入 t 可能是什么!因为对于所有整数 c,sin(π⋅c) = 0,我们可以判断我们是否在序列顺序中的某个π的倍数(π≈第三个项目,或 2π≈第六个项目,或 2π≈第九个项目等)。如果y = 0,那么我们可能在t = 3 ≈ π或t = 6 ≈ 2π,但我们无法确定我们处于这些特定位置中的哪一个——只知道我们处于大约π≈3.14 的倍数位置。在这个例子中,我们有 100 个可能的位置,因此我们可以用这种方法判断我们处于约 32 个可能位置中的一个。
我们可以通过添加第二个正弦调用来改善这种情况,但带有频率成分 f。因此我们计算 sin(t/f*),其中 f = 1(我们已绘制的)和 f = 10:
position = np.arange(0, 100)
➥ sns.lineplot(x=position, y=np.sin(position), label="sin(position)")
➥ sns.lineplot(x=position, y=np.sin(position/10), label="sin(position/10)")
[26]: <AxesSubplot:>

使用两个 f 的值,可以唯一地识别一些位置。如果 sin (t) = 0 且 sin (t/100) = 0,我们可能处于的四个可能位置只有 t = 0,31,68,94,这是唯一四个两种情况都(近似)为真的位置。如果 sin (t) = 0 但 sin (t/10) ≠ 0,那么我们知道 0,31,68 和 94 不是选项。此外,正弦函数的不同值也关于我们的位置提供了信息。如果 sin (t/10) = − 1,我们知道我们必须在位置 48,因为它是唯一一个输出为 − 1 的选项,给定最大时间 T = 100。
这表明,如果我们继续向计算中添加频率 f,我们就可以从值的组合中开始推断出输入序列中的确切位置。我们定义一个位置编码函数 P(t),它通过在不同频率 f[1],f[2],…,f[D/2] 处创建正弦和余弦值来返回一个 D 维向量。我们只需要 D/2 个频率,因为我们为每个频率使用一个正弦值和一个余弦值。这给我们

作为我们编码向量的表示。但如何定义 f[k]?最初提出这一点的论文¹建议使用以下:

让我们快速看一下这个例子是什么样的。为了简单起见,我们使用 D = 6 维度,并仅绘制正弦分量,以便图表不会太拥挤:
dimensions = 6
position = np.expand_dims(np.arange(0, 100), 1)
div = np.exp(np.arange(0, dimensions*2, 2) *
➥ (-math.log(10000.0) / (dimensions*2))) ❶ for i in range(dimensions):
sns.lineplot(x=position[:,0], y=np.sin(position*div)[:,i],
➥ label="Dim-"+str(i))
❶ 以数值稳定的方式计算频率 f

当我们开始添加更多维度和更多频率时,识别时间中的唯一位置变得更容易。选择图表 x 轴上的任何位置,你都可以识别一个独特的六个维度的值组合,这些值将不会与其他 x 轴上的位置共享。这就是我们的网络将用来从数据中提取关于位置信息的方法。
这种位置编码的具体形式也具有一些很好的数学特性,这使得神经网络更容易学习。例如,单个线性层可以学习通过固定量(即向左或向右移动 t 个单位)来移动位置编码,帮助网络学习在时间组件上执行逻辑。
12.4.1 实现位置编码模块
我们将向这种方法添加两个在实践中发现有帮助的东西。首先,我们不希望对内容(原始嵌入 h[t])和位置 P(t) 给予相同的权重。因此,我们使用以下方程来提高内容的相对重要性:

其次,我们在结果向量上添加了 dropout,这样我们就不会学习到对位置编码 P(t) 固定值的过拟合。现在让我们定义一个新的 PyTorch Module,它为我们应用这个位置编码。当前实现需要提前知道最大序列长度 T,使用构造函数中的max_len参数。我从 PyTorch 示例mng.bz/B1Wl² 中借用了这个位置编码的代码,并稍作修改:
class PositionalEncoding(nn.Module):
def __init__(self, d_model, dropout=0.1, max_len=5000, batch_first=False):
super(PositionalEncoding, self).__init__()
self.dropout = nn.Dropout(p=dropout)
self.d_model = d_model
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
div_term = torch.exp(torch.arange(0, d_model, 2).float() *
(-math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
pe = pe.unsqueeze(0).transpose(0, 1)
self.register_buffer(’pe’, pe) ❶
self.batch_first = batch_first
def forward(self, x):
if self.batch_first: ❷
x = x.permute(1, 0, 2)
x = x *np.sqrt(self.d_model) + self.pe[:x.size(0), :] ❸
x = self.dropout(x) ❹
if self.batch_first: ❺
x = x.permute(1, 0, 2)
return x
❶ 这样做是为了当我们调用 .to(device) 时,这个数组将移动到它那里。
❷ 此代码适用于 (T, B, D) 数据,因此如果输入是 (B, T, D),则需要重新排序。
❸ 混合输入和位置信息
❹ 正则化以避免过拟合
❺ 返回到 (B, T, D) 形状
12.4.2 定义位置编码模型
使用我们新的位置编码,我们可以重新定义之前简单的平均方法,通过在nn.Embedding层之后直接插入我们的PositionalEncoding类。它以任何方式都不会影响张量的形状,因此其他所有内容都可以保持不变。它只是用我们新的位置编码 P(t) 改变了张量中的值:
simplePosEmbdAvg = nn.Sequential(
nn.Embedding(VOCAB_SIZE, embed_dim, padding_idx=padding_idx), ❶
PositionalEncoding(embed_dim, batch_first=True),
nn.Linear(embed_dim, embed_dim),
nn.LeakyReLU(),
nn.Linear(embed_dim, embed_dim),
nn.LeakyReLU(),
nn.Linear(embed_dim, embed_dim),
nn.LeakyReLU(),
nn.AdaptiveAvgPool2d((1,None)), ❷
nn.Flatten(), ❸
nn.Linear(embed_dim, embed_dim),
nn.LeakyReLU(),
nn.BatchNorm1d(embed_dim),
nn.Linear(embed_dim, NUM_CLASS)
)
❶ (B, T) -> (B, T, D)
❷ (B, T, D) -> (B, 1, D)
❸ (B, 1, D) -> (B, D)
适应基于注意力的嵌入也很简单。当我们定义EmbeddingAttentionBag时,我们添加了可选参数embd_layers。如果embd_layers是一个 PyTorch Module,它将使用该网络在 (B,T,D) 项的批次上运行一个隐藏层。我们定义这个Module,它将从PositionalEncoding模块开始,因为embd_layers的输入已经嵌入。这在以下代码中分为两部分完成。首先,我们将embd_layers定义为位置编码,然后是三轮隐藏层,然后我们定义attnPosEmbd为一个具有位置编码的基于注意力的网络。现在我们可以训练这两个新的平均网络,并将它们与原始的平均和基于注意力的版本进行比较。如果准确率有所提高,那么我们就知道信息顺序确实很重要:
embd_layers = nn.Sequential( ❶
*([PositionalEncoding(embed_dim, batch_first=True)]+
[nn.Sequential(nn.Linear(embed_dim, embed_dim), nn.LeakyReLU())
➥ for _ in range(3)])
)
attnPosEmbd = nn.Sequential(
➥ EmbeddingAttentionBag(VOCAB_SIZE, embed_dim,
➥ padding_idx=padding_idx,
➥ embd_layers=embd_layers), ❷
nn.Linear(embed_dim, embed_dim),
nn.LeakyReLU(),
nn.BatchNorm1d(embed_dim),
nn.Linear(embed_dim, NUM_CLASS)
)
posEmbdAvg_results = train_network(simplePosEmbdAvg, loss_func,
➥ train_loader, val_loader=test_loader, score_funcs={’Accuracy’:
➥ accuracy_score}, device=device, epochs=epochs)
attnPosEmbd_results = train_network(attnPosEmbd, loss_func, train_loader,
➥ val_loader=test_loader, score_funcs={’Accuracy’: accuracy_score},
➥ device=device, epochs=epochs)
❶ (B, T, D) -> (B, T, D)
❷ (B, T) -> (B, D)
位置编码结果
以下代码绘制了所有嵌入模型的成果,包括带位置编码和不带位置编码的情况。我们的位置编码提供了显著的好处,整体准确率得到提升,且准确率下降幅度较小,这表明过拟合程度较低。对训练时间的影响也微乎其微——模型训练时间仅比原始模型多几秒钟:
sns.lineplot(x=’total time’, y=’val Accuracy’, data=simpleEmbdAvg_results,
➥ label=’Average Embedding’)
sns.lineplot(x=’total time’, y=’val Accuracy’, data=posEmbdAvg_results,
➥ label=’Average Positional Embedding’)
sns.lineplot(x=’total time’, y=’val Accuracy’, data=attnEmbd_results,
➥ label=’Attention Embedding’)
sns.lineplot(x=’total time’, y=’val Accuracy’, data=attnPosEmbd_results,
➥ label=’Attention Positional Embedding’)
[31]: <AxesSubplot:xlabel='total time', ylabel='val Accuracy'>

这些结果为我们关于序列顺序有助于防止过拟合的假设提供了证据,尤其是在基于注意力的方法中。使用位置编码的两种版本仍然有所波动,但与更多的训练轮数相比,准确率下降并不快。这展示了我们可以如何使用一种完全不同的方法将序列信息编码到我们的模型中。
我们基于注意力的嵌入绝对是一个更好的想法:让我们仅比较这一点与早期的 GRU 结果。使用这种组合,我们在准确率方面开始与基于 GRU 的 RNN 相匹配,甚至超越,而且训练速度要快两倍以上!这是一个相当不错的组合。以下是代码:
sns.lineplot(x=’total time’, y=’val Accuracy’, data=gru_results, label=’GRU’)
sns.lineplot(x=’total time’, y=’val Accuracy’, data=attnEmbd_results,
➥label=’Attention Embedding’)
sns.lineplot(x=’total time’, y=’val Accuracy’, data=attnPosEmbd_results,
➥label=’Attention Positional Embedding’
[32]: <AxesSubplot:xlabel='total time', ylabel='val Accuracy'>

可以得出的结论是,位置编码是将序列信息编码到我们的网络中的一种便宜、快速且有效的方法。令人惊讶的是,位置编码并非独立发明,而是在本章下一节的主题——转换器(transformers)——的背景下共同发明的。因此,在大多数当前的深度学习中,你不会看到在转换器之外使用位置编码,但我发现它们在转换器之外也非常有用,作为一种快速简单的方法,可以赋予模型时间/有序数据的概念。
12.5 转换器:大数据的大模型
我们将要学习的最后一个 RNN 的替代方案是转换器架构。它由两个主要子组件组成:位置编码(我们刚刚学习过)和多头注意力。转换器是一个非常新的发展,由于一些优点而变得极其流行。然而,它们最适合处理大数据(至少 50 GB,根据我的经验)和大量计算(你需要 4+个 GPU)的大问题,所以我们在这个章节中不会看到全部的好处。尽管如此,了解转换器仍然很重要,它是当前深度学习一些重大进步的基础。最新的、最优秀的机器翻译、问答、少样本学习和数十个 NLP 任务的模型都是用不同的转换器变体构建的。本节的目标是帮助你理解标准的、原始的、无特殊添加的转换器。在本章之后,我建议阅读 Jay Alammar 的博客文章“Illustrated Transformer”(jalammar.github.io/illustrated-transformer/),以详细了解转换器的逐步解析。
12.5.1 多头注意力
要理解 transformer 的工作原理,我们需要了解多头注意力(MHA),它是我们在第十章中学到的注意力机制的扩展。像正常注意力一样,MHA 涉及使用 softmax 函数和nn.Linear层来学习有选择地忽略或关注输入的不同部分。我们原始的注意力机制有一个上下文,这使得它可以寻找一种类型的模式。但如果你想要同时寻找多种不同的事物(比如一个由否定陈述“not” precede 的正陈述“good”)呢?这就是多头注意力发挥作用的地方。每个“头”的多头注意力可以学习寻找不同类型的模式,类似于卷积层中的每个滤波器可以学习寻找不同的模式。MHA 如何工作的总体策略如图 12.5 所示。

图 12.5 多头注意力模块的概述。有三个序列作为输入:查询 Q 和键值 K, V,它们是配对的。MHA 返回一个 D 维向量,回答每个查询q[i] ∈ Q。
在直观层面上,你可以将 MHA 视为回答关于键值对的字典的问题或查询。由于这是一个神经网络,键和值都是向量。因为每个键都有自己的值,所以键张量 K 和值张量 V 必须具有相同的项目数 T。因此,它们都具有(T,D)的形状,其中 D 表示特征数。
按照这个高级类比,你可以对键值字典提出尽可能多或尽可能少的问题。查询列表 Q 是其自身的张量,长度为T′。
MHA 的输出对每个查询都有一个响应,并且具有相同的维度,因此 MHA 的输出形状为(T′,D)。MHA 的总头数不会以改变卷积层输出通道数的方式改变其输出的尺寸。这只是 MHA 设计中的一个奇特之处。相反,MHA 试图将所有答案混合成一个单一的输出。
为了解释这个类比,我们可以将 MHA 视为一种深度学习替代品,类似于标准的 Python 字典对象。字典d = {key : value}代表一组键,每个键都有一个特定的值。你可以用类似d[query]的方式查询这个字典。如果query in d,你将得到其关联的值,如果没有,你将得到None。MHA 层有相同的目标,但不需要查询和键之间像字典那样完美的匹配。相反,它稍微宽松一些,多个相似的键可以响应单个查询,如图 12.6 所示。

图 12.6 示例:Python 字典和多头注意力(MHA)如何具有查询、键和值的理念。在字典(顶部)中,查询和键必须完全匹配才能得到值。在 MHA(底部)中,每个键根据键与查询的相似程度,对每个查询的回答都有贡献。这确保了始终有一个答案。
你的直觉反应可能是担心当没有任何东西看起来与查询相似时给出答案:返回一个乱码答案/值不是坏事吗?这实际上是一件好事,因为 MHA 可以在训练过程中学习需要调整一些键/值。记得在第三章中我们看到了如何手动指定有用的卷积,但我们没有这样做,而是让神经网络学习应该使用哪些卷积。MHA 也是这样工作的:它开始时使用随机初始化且无意义的键和值,但在训练过程中学会了调整它们以变得有用。
联想记忆
查询一个模糊的神经元云并从中检索特定表示的想法相当古老。这是所谓的联想记忆的合理描述,你可以在thegradient.pub/dont-forget-about-associative-memories上阅读其历史的简要总结。
简而言之,与联想记忆相关的想法可以追溯到 20 世纪 60 年代,这对于那些没有意识到人工智能作为一个领域有多古老的人来说很有趣。虽然它们目前在从业者中并不流行,但仍然有现代研究在使用它们。我认为了解这些内容也有助于你自己拓宽对问题和人工智能/机器学习的思考。许多领域,如认知科学、心理学、神经学和电气工程,都帮助塑造了早期和基础性的工作,但遗憾的是,联想记忆并没有得到它们应得的认可。
从注意力到 MHA
当我们单独描述 MHA 时,它可能看起来过于复杂和晦涩;相反,让我们使用第十章中我们了解到的注意力机制来描述它。这与大多数讨论 MHA 的方式不同,需要更长的时间,但我认为这更容易理解。
让我们先写出我们正常注意力机制所做方程的表达式。我们有一个评分函数,它接受两个项目并返回一个α̃值,该值表示这对的重要性/相似性水平。评分函数可以是我们在前面学到的点积、一般或加法评分中的任何一种——但每个人在 MHA 中都使用点积评分,因为这就是人们所做的事情。
我们为 T 个不同的输入计算几个得分,并将它们输入到 softmax 函数中,以计算一个 T 得分的最终向量 α,它说明了 T 个项目中的每一个的重要性。然后我们计算 α 和原始 T 个项目的点积,这产生了一个向量,它是我们原始注意力机制的输出结果。所有这些都在以下方程式中展示:

现在,让我们对如何实现这一点做一些修改。首先,让我们将我们的上下文向量
重命名为查询向量 q 并将其作为得分函数的输入。上下文或查询告诉我们我们在寻找什么。我们不再使用 h[1],h[2],…,h[T] 来确定重要性 α 和输出向量,而是将它们分成两组不同的张量,它们不必相同。由于偶然,我们将用于 α 的张量称为键 K = [k[1],k[2],…,k[T]] 和值 V = [v[1],v[2],…,v[T]]。然后我们得到以下三个参数的得分函数,它使用原始的两个参数得分函数:

我们到目前为止真正所做的一切就是将我们的得分函数进行泛化,使其更加灵活。我们可以将搜索(q,K,V)视为“给定查询 q,根据其键 K 从字典中给出平均值 V。”这是一个泛化,因为如果我们调用得分(1/T Σ[i]^T[=1] h[i], [..., h[i], ...] ,[..., h[i], ...]),我们会得到与之前相同的结果!
这为我们提供了一个查询的结果。为了将此扩展到多个查询,我们多次调用三个参数的得分函数。这为我们提供了一个 MHA 的一个头的输出结果,通常用名为 Attention 的函数表示:

这个方程式是我们刚才所做内容的直接改编。我们现在有多个查询 Q = [q[1],q[2],…,q[T′]],因此我们多次调用 score 并将 T′ 个结果堆叠成一个更大的矩阵。
有了这些,我们最终可以定义 MHA 函数。如果我们有 z 个头,你可能已经猜对了,我们调用 Attention 函数 z 次!但输入仍然是 Q,K 和 V。为了使每个头学会寻找不同的事物,每个头都有三个 nn.Linear 层 WQ,*W*K 和 W^V。这些线性层的任务是防止所有调用都计算完全相同的事情,因为那将是愚蠢的。然后我们将所有 z 个结果连接成一个大的向量,并以一个最终输出 nn.Linear 层 W^O 结尾,这个层的唯一任务是确保 MHA 的输出有 D 维。所有这些都像以下方程式所示:

这使我们从原始的注意力机制过渡到更复杂的 MHA。因为 MHA 相当复杂并且有多个层,所以它不需要像卷积层需要的过滤器 C 那样多的头 z。而通常我们希望有 32 到 512 个过滤器(基于什么能得到最佳结果),对于 MFA,我们通常希望不超过z = 8 或z = 16 个头。
我们不会实现 MHA 函数,因为 PyTorch 为我们提供了一个很好的实现。但让我们快速看一下一些伪 Python 代码,以了解它是如何进行的。首先,我们创建W[i]Q、*W*[i]K、W[i]^V 层和输出W^O。这发生在构造函数中,我们可以使用 PyTorch 的ModuleList来存储我们稍后想要使用的模块列表:
self.wqs = nn.ModuleList([nn.Linear(D, D) for _ in range(z)])
self.wks = nn.ModuleList([nn.Linear(D, D) for _ in range(z)])
self.wvs = nn.ModuleList([nn.Linear(D, D) for _ in range(z)])
self.wo = nn.Linear(z*D, D)
然后我们可以进入forward函数。为了简单起见,我们假设已经存在一个Attention函数。我们基本上只需要反复调用这个Attention函数,应用构造函数中定义的线性层。可以使用zip命令使这个过程更加简洁,给我们一个包含W[i]Q、*W*[i]K 和W[i]^V 的元组,并将结果附加到heads列表中。它们在最后通过连接组合,并应用最终的W^O 层:
def forward(Q, K, V):
heads = []
for wq, wk, wv in zip(self.wqs, self.wks, self.wvs):
heads.append(Attention(wq(Q), wk(K), wv(V)))
return self.wo(torch.cat(heads, dim=2) )
多头注意力标准方程
我们使用了上一章中的得分函数来展示 MHA 实际上只是我们已学内容的扩展。我认为这次旅程有助于巩固我们对 MHA 的理解。MHA 也可以用更少的方程来表示:我发现它们更难以理解,但展示它们是值得的,因为这是大多数人写作的方式!
主要区别在于Attention函数的编写方式。通常它是三个矩阵乘法的结果:

这与我之前展示的等价,但在我看来,这种方式进行注意力的过程并不明显。这个版本是实现 MHA 的首选方式,因为它运行得更快。
12.5.2 Transformer 块
现在我们知道了 MHA 块的样子,我们可以描述 transformer 了!有两种类型的 transformer 块:编码器和解码器。它们在图 12.7 中展示,并使用了熟悉的残差连接概念。

图 12.7 两种类型的 transformer 块:编码器(左)和解码器(右)。它们都使用了层归一化、残差连接和 MHA 层。每个中的第一个 MHA 被称为自注意力,因为 Q、K 和 V 使用了相同的输入。
编码器块可以用于几乎任何基于序列的网络架构(例如,情感分类);它不需要与解码器配对。它从与 MHA 的残差连接开始;查询、键和值使用相同的输入序列,因此这被描述为自注意力层,因为没有外部上下文或输入。然后发生第二个残差连接,它只使用线性层。你可以多次重复这个编码器块来创建一个更深更强的网络:原始论文³使用了六个这样的块,因此这已成为一个常见的默认值。
解码器块几乎总是与编码器块一起使用,通常用于具有多个输出的序列任务(例如,机器翻译)。唯一的区别是在第一个 MHA 残差连接之后插入第二个 MHA 残差连接。这个第二个 MHA 使用编码器块的输出作为键和值,而前一个 MHA 的结果用作查询。这样做是为了你可以构建类似于第十一章的序列到序列模型或自回归风格的模型。
你可能会注意到在这个设计中没有像我们的 RNN 那样的显式时间连接。Transformers 从位置编码中获取所有的序列信息!这就是为什么我们首先需要了解位置编码,这样我们就可以重复使用它们来创建一个 transformer 模型。
警告:编码器和解码器同时查看和处理所有时间步。这对于你试图预测下一个项目的自回归模型来说可能是个问题。天真地使用 transformer 进行自回归模型意味着 transformer 可以查看未来的输入,这在目标是预测未来时是一种作弊行为!对于此类应用,你需要使用特殊的掩码来防止 transformer 查看未来。这个掩码具有三角形形状,因此时间上的交互受到限制,由Transformer类的generate_square_subsequent_mask()方法提供。
以下代码块实现了一个使用 transformer 对序列进行分类的简单方法。它从一个Embedding层开始,接着是PositionalEncoding,然后是三个TransformerEncoder层。理想情况下,我们在这里会使用六个层;但是 transformer 比 RNN 更昂贵,所以我们必须对模型进行限制以使这个示例快速运行。transformer 运行后,我们仍然得到一个形状为(B,T,D)的输出:我们使用正常的注意力机制将其降低到形状为(B,D)的单个向量,以便进行预测。注意在这段代码中,transformer 需要它们的张量以(T,B,D)的顺序组织,因此我们需要重新排列几次轴才能使一切正常工作:
class SimpleTransformerClassifier(nn.Module):
def __init__(self, vocab_size, D, padding_idx=None):
super(SimpleTransformerClassifier, self).__init__()
self.padding_idx = padding_idx self.embd = nn.Embedding(vocab_size, D, padding_idx=padding_idx)
self.position = PositionalEncoding(D, batch_first=True)
self.transformer = nn.TransformerEncoder( ❶
➥ nn.TransformerEncoderLayer(
➥ d_model=D, nhead=8),num_layers=3)
self.attn = AttentionAvg(AdditiveAttentionScore(D))
self.pred = nn.Sequential(
nn.Flatten(), ❷
nn.Linear(D, D),
nn.LeakyReLU(),
nn.BatchNorm1d(D),
nn.Linear(D, NUM_CLASS)
)
def forward(self, input):
if self.padding_idx is not None:
mask = input != self.padding_idx
else:
mask = input == input ❸
x = self.embd(input) ❹
x = self.position(x) ❺
x = self.transformer(x.permute(1,0,2)) ❻
x = x.permute(1,0,2) ❼
context = x.sum(dim=1)/mask.sum(dim=1).unsqueeze(1) ❽
return self.pred(self.attn(x, context, mask=mask))
simpleTransformer = SimpleTransformerClassifier( ❾
➥ VOCAB_SIZE, embed_dim, padding_idx=padding_idx)
transformer_results = train_network(simpleTransformer, loss_func,
➥ train_loader, val_loader=test_loader, score_funcs={’Accuracy’:
➥ accuracy_score}, device=device, epochs=epochs)
❶ 我们 transformer 实现的主要工作
❷ (B, 1, D) -> (B, D)
❸ 所有条目都是 True。
❹ (B, T, D)
❺ (B, T, D)
❻ 因为我们的其余代码是 (B, T, D),但变压器以 (T, B, D) 作为输入,我们必须在前后改变维度的顺序。
❼ (B, T, D)
❽ 平均时间
❾ 构建和训练此模型
现在,我们可以绘制我们所有方法的成果。变压器在所有方法中达到了最高的准确率,并且在我们持续训练的过程中仍在提高。如果我们进行更多的迭代并使用更多的层,它们可能会进一步提高!但这会增加训练时间,而变压器已经比 GRU 模型慢:
sns.lineplot(x=’total time’, y=’val Accuracy’, data=gru_results, label=’GRU’)
sns.lineplot(x=’total time’, y=’val Accuracy’, data=attnEmbd_results,
➥ label=’Attention Embedding’)
sns.lineplot(x=’total time’, y=’val Accuracy’, data=attnPosEmbd_results,
➥ label=’Attention Positional Embedding’)
sns.lineplot(x=’total time’, y=’val Accuracy’, data=cnn_results,
➥ label=’CNN Adaptive Pooling’)
sns.lineplot(x=’total time’, y=’val Accuracy’, data=transformer_results,
➥ label=’Transformer’)
[34]: <AxesSubplot:xlabel='total time', ylabel='val Accuracy'>

我们的结果将如何?默认情况下,基于注意力的嵌入和位置编码是一个很好的工具。它并不总是优于现代的 RNN,如 GRU 或 LSTM,但它是一个运行速度更快的良好候选方案。对于大多数问题,您都可以考虑这两种方法。不过,如果您有额外的计算资源,RNNs 的好处是它们被研究得更深入,理解得更透彻,因此您可能更容易信任它们的结果。
当您需要尽可能高的准确率并且有大量的 GPU(和数据)可用以支付其高昂的价格时,变压器是您会使用的工具。RNNs 有一个坏习惯,就是在三到六层后准确率会达到平台期,这与深度学习趋势相反,即更多的层可以得到更强大的模型和更好的结果。总的来说,变压器通常有 24+层,并且随着层数的增加而提高准确率。但话虽如此,当您在处理需要使用多个 GPU 的庞大数据集时,变压器可以更准确 并且 更快。
这是因为变压器会同时处理序列中的所有 T 个项,而 RNNs 需要逐个处理它们。以这种方式进行工作使得变压器更适合扩展到多个 GPU,因为可以分割和同时运行的工作更多。使用 RNN 时,您不能分割工作,因为每一步都依赖于前一步,所以您必须等待。尽管如此,研究人员和公司正在使用 数百到数千个 GPU 来在数百 GB 或更多的数据上训练单个模型。这告诉您,要真正看到变压器的优势,需要多大的规模。
虽然变压器尚未准备好取代循环神经网络(RNNs),但它们是另一种可以使用的方案。当您拥有大量数据和计算资源时,它们目前是最大化准确率的首选。在下一章中,我们将学习如何在不需要一千个 GPU 的情况下,为您的自身问题提取变压器的优势 而不 需要一千个 GPU!
练习
在 Manning 在线平台 Inside Deep Learning Exercises 上分享和讨论您的解决方案(liveproject.manning.com/project/945)。一旦您提交了自己的答案,您将能够看到其他读者提交的解决方案,并看到作者认为哪些是最佳的。
-
使用 Optuna 尝试优化基于注意力的平均嵌入分类器。尝试调整
nn.Embedding层使用的初始维度、所有后续层中的隐藏神经元数量、总隐藏层数量,以及要在 AG News 语料库上使用的评分函数(点积、通用、加法)。你能将精度提高多少? -
回到第六章,将
ResidualBlockE和ResidualBottleNeck层转换为它们的 1D 对应层ResidualBlockE1D和ResidualBottleNeck1D。然后使用它们尝试改进本章在 AG News 语料库上的 1D CNN。你能将精度提高到多高? -
使用你最好的 1D CNN 对 AG News 进行处理,尝试向网络中添加位置编码。这对你的结果有何影响?
-
PyTorch 提供了一个
nn.MultiheadAttention模块,实现了 MHA 方法。为基于注意力的平均嵌入分类器提出自己的修改,以使用 MHA,并尝试使其具有更高的精度。 -
Transformer 通常与学习率计划配合使用效果最佳。尝试第五章中我们学习到的计划,看看你是否可以减少
SimpleTransformerClassifier学习所需的周期数或提高其最终精度。
摘要
-
我们可以通过故意减少模型对数据序列性质的理解来减少运行时间。
-
我们可以通过修改数据的输入来编码位置信息,而不是使用在结构中编码位置信息的 RNN 或 CNN,这可以为其他更快的替代方案提供序列信息或提高精度。
-
多头注意力是第十章中提到的注意力的推广,涉及多个上下文或查询和多个结果。它可以在更高的计算成本下获得更高的精度。
-
Transformer 是一种强大的 RNN 替代方案,能够在高昂的计算成本下获得高精度。它们在拥有大量数据和计算资源时表现最佳,因为它们的扩展性和扩展性优于 RNN。
¹ A. Vaswani 等人,“Attention is all you need”,Advances in Neural Information Processing Systems,第 30 卷,第 5998-6008 页,2017。↩
² 此代码受 BDS-3 许可证保护;请查看本书的代码库以获取包含的许可证。↩
³ 引入位置编码的“Attention Is All You Need”论文!(见脚注 1。)↩
13 迁移学习
本章涵盖了
-
将预训练网络转移到新的问题上
-
理解冻结权重和热权重之间的区别
-
通过迁移学习用更少的数据进行学习
-
使用基于 transformer 的模型进行文本问题的迁移学习
现在,你已经了解了一系列从头开始在新数据上训练模型的技术。但是,如果你没有时间等待大模型训练怎么办?或者,如果你一开始就没有很多数据怎么办?理想情况下,我们可以使用来自更大、精心整理的数据集的信息来帮助我们更快地在更少的迭代中学习一个更准确的模型,针对我们新的、较小的数据集。
正是在这里,迁移学习发挥了作用。迁移学习背后的理念是,如果有人已经花费了精力在大量数据上训练了一个大模型,那么你很可能可以使用这个已经训练好的模型作为你问题的起点。本质上,你希望将模型从相关问题中提取的所有信息迁移到你的问题上。当这成为可能时,迁移学习可以为你节省数周的时间,提高准确性,并且总体上工作得更好。这尤其有价值,因为你可以用更少的标记数据获得更好的结果,这可以节省大量的时间和金钱。这使得迁移学习成为你应该知道的、在实际工作中最实用的工具之一。
当原始较大的数据集和你要应用迁移学习的小型目标数据之间存在内在相似性时,迁移学习效果最佳。这对于 CNN 尤其如此,因为图像在本质上是非常相似的。即使你有一个风景照片的源数据集和一个包含猫和狗的目标数据集,我们在第三章中讨论的结构先验仍然成立:相邻的像素彼此相关,而远离像素的影响很小。
本章专注于一种特定的迁移学习类型,它重新使用了先前训练网络的实际权重和架构来解决新问题。在我们展示了如何使用 CNN 进行图像迁移学习之后,我们将看到如何使用基于 transformer 的模型进行文本分类模型的迁移学习。就在几年前,文本迁移学习还远没有这么容易或成功。但迁移学习允许我们绕过 transformer 巨大的训练成本,以极小的代价获得其好处。
13.1 转移模型参数
在任何新的机器学习应用中取得成功的关键是能够访问到经过准确标记的代表性数据。但是,获取大量标记数据需要时间、精力和金钱。整个公司都存在只是为了帮助人们使用像亚马逊的 Mechanical Turk 这样的服务来标记他们的数据。同时,我们在投入大量时间收集和标记大量语料库之前,希望有证据表明我们的方法将有效。这使我们陷入了两难:我们希望构建一个良好的初始模型来查看任务是否可行,但获取足够的数据来构建一个良好的初始模型是昂贵的。
我们希望通过使用 相关 数据来帮助我们构建模型,以更少的数据和计算时间来制作一个准确的模型。本质上,我们希望将我们在一个领域 学习 到的东西转移到另一个但相关的领域。这就是 迁移学习 的理念。实际上,迁移学习是你在深度学习工具库中应该知道的最有用的方法之一。特别是如果你在计算机视觉或基于文本的应用领域做任何工作,迁移学习可以非常强大。
在本章中我们学习到的最成功的迁移学习方法之一是将一个模型的 权重 θ 转移到另一个模型。原始模型是在一个包含高质量数据的大型数据集上训练的,这个数据集与我们真正关心的较小数据集有一些结构上的相似性。例如,图像有很多结构上的相似性——因此我们可以使用在几乎任何大型图像分类任务上训练的模型来帮助我们完成更细致的任务。为了做到这一点,我们需要对原始模型 f 进行最小的修改或添加,使其适应新的问题。这种高级方法在图 13.1 中有所描述;我们很快就会看到如何执行这些机械细节。

图 13.1 在大型数据集上训练大模型。这是一个一次性成本,因为我们可以通过将其转移到许多不同的较小任务中来重复使用大模型。这是通过编辑模型但几乎保持所有原始架构和权重 θ 完整来实现的。然后我们在新的数据集上训练修改后的模型。
13.1.1 准备图像数据集
要开始迁移学习,我们将下载一个作为微软 Kaggle 竞赛一部分组织的 Cats-vs-Dogs 数据集(www.kaggle.com/shaunthesheep/microsoft-catsvsdogs-dataset),因此它将是一个二元分类问题。这是我们第一次在 PyTorch 中创建新的图像分类数据集,因此我们将作为有用的练习逐步说明这些步骤。以下代码片段下载包含数据集的 zip 文件并将其提取到名为 PetImages 的文件夹中。请注意,这个 zip 文件中的两个文件不幸损坏。我们需要删除这两个文件,以便数据加载器正常工作,这就是为什么我们有一个 bad_files 列表来指示损坏的图像并删除它们。以下是代码:
data_url_zip = "https://download.microsoft.com/download/3/E/1/
➥ 3E1C3F21-ECDB-4869-8368-6DEBA77B919F/kagglecatsanddogs_3367a.zip"
from io import BytesIO
from zipfile import ZipFile
from urllib.request import urlopen
import re
if not os.path.isdir(’./data/PetImages’): ❶
resp = urlopen(data_url_zip)
zipfile = ZipFile(BytesIO(resp.read()))
zipfile.extractall(path = ’./data’)
bad_files = [ ❷
’./data/PetImages/Dog/11702.jpg’,
"./data/PetImages/Cat/666.jpg"
]
for f in bad_files:
if os.path.isfile(f):
os.remove(f)
❶ 如果我们还没有这样做,请下载此数据集!
❷ 此文件有问题,将会搞乱数据加载器!
一些图像也有损坏的 EXIF 数据。EXIF 数据是关于图像的元数据(例如照片拍摄地点),对于我们想要做的事情并不重要。因此,我们禁用了关于此问题的任何警告:
import
warnings warnings.filterwarnings("ignore", ❶
➥ "(Possibly )?corrupt EXIF data", UserWarning)
❶ 请不要打扰我们处理这些坏文件,谢谢。
现在我们使用 PyTorch 提供的 ImageFolder 类为这个类别创建一个 Dataset。ImageFolder 期望一个根目录,每个类别有一个子文件夹。文件夹的名称是类别的名称,该文件夹中的每个图像都作为具有特定类别标签的数据集的示例加载;参见图 13.2。

图 13.2 PyTorch 的 ImageFolder 类接受一个根文件夹的路径。它假设每个子文件夹代表一个类别,并且每个子文件夹应该填充该类别的图像。本例展示了两个类别,猫和狗,以及非常艺术化的插图。
ImageFolder 类接受一个可选的 transform 对象,我们使用它来简化加载过程。这个 transform 是我们在第三章中使用的数据增强相同的转换类。由于图像大小各不相同,我们可以使用一个 Compose 转换来调整大小、裁剪并将它们标准化为相同的形状。这样,我们可以像在 MNIST(所有 28 × 28)和 CIFAR-10(32 × 32)上一样在数据批次上训练。我们的 Compose 转换构建了一个子转换的管道,按顺序运行,在这种情况下将执行以下操作:
-
调整图像大小,使最小维度为 130 像素。例如,260 × 390 的图像将变为 130 × 195(保持相同的宽高比)。
-
裁剪出中心 128 × 128 像素。
-
将图像转换为 PyTorch 张量,这包括将像素值从 [0,255] 标准化到 [0,1]。
除了转换之外,我们还对数据进行 80% 用于训练和剩余 20% 用于测试集的划分。以下代码使用我们的猫和狗数据集设置所有这些:
all_images = torchvision.datasets.ImageFolder("./data/PetImages",
➥ transform=transforms.Compose(
[
transforms.Resize(130), ❶
transforms.CenterCrop(128), ❷
transforms.ToTensor(), ❸
]))
train_size = int(len(all_images)*0.8) ❹
test_size = len(all_images)-train_size ❺
train_data, test_data = torch.utils.data.random_split ❻
➥ (all_images, (train_size, test_size))
❶ 最小的宽/高变为 130 像素。
❷ 取中心 128 × 128 的图像
❸ 将其转换为 PyTorch 张量
❹ 选择 80%用于训练
❺ 20%的剩余部分用于测试
❻ 创建指定大小的随机分割
在数据集就绪后,我们现在可以创建用于训练和测试的DataLoader(使用批大小 B = 128)。该数据集的训练集中有超过 20,000 个样本,在图像总数上比 MNIST 小,但图像的大小比我们之前处理的大得多(128 × 128 而不是 32 × 32 或更小):
B = 128
train_loader = DataLoader(train_data, batch_size=B, shuffle=True)
test_loader = DataLoader(test_data, batch_size=B)
我们有一个数据集和加载器对象;让我们看看数据。类别 0 是猫类,类别 1 是狗类。下一块代码使用角落中的类别编号可视化了一些数据。这让我们对这个数据集的复杂性有了了解:
f, axarr = plt.subplots(2,4, figsize=(20,10)) ❶
for i in range(2): ❷
for j in range(4): ❸
x, y = test_data[i*4+j] ❹
axarr[i,j].imshow(x.numpy().transpose(1,2,0)) ❺
axarr[i,j].text(0.0, 0.5, str(round(y,2)),
➥ dict(size=20, color=’red’)) ❻
❶ 创建一个包含八个图像的网格(2 × 4)
❷ 行
❸ 列
❹ 从测试语料库中抓取一个图像
❺ 绘制图像
❻ 在左上角绘制标签

虽然只有两个类别,但图像的内容比我们使用的其他玩具数据集(如 MNIST 和 CIFAR)更为多样和复杂。动物有多种姿势,相机可能曝光过度或不足,图像中可能有多个不同的背景下的动物,人类也可能出现在照片中。这种复杂性需要学习,但仅使用 20,000 个样本来学习如何分类猫与狗将是一个挑战。
13.2 使用 CNN 进行迁移学习和训练
让我们训练一个模型。特别是,我们使用较小的 ResNet 架构作为起点。ResNet 是我们在第六章中学习的残差连接的架构,ResNet-X 通常指的是几个使用残差层的特定神经网络之一(例如,ResNet-50 或 ResNet-101)。我们使用 ResNet-18,这是(常见)ResNet 中最小的。我们可以使用torchvision.models类获取模型的实例,该类为各种计算机视觉任务提供了许多流行的预构建架构。
ResNet 在网络的末端使用自适应池化,这意味着我们可以重用 ResNet 架构来解决具有任意大小输入图像的分类问题。问题是 ResNet 是为一个名为 ImageNet 的数据集设计的,该数据集有 1,000 个输出类别。由于 ImageNet 及其 1,000 个类别是 ResNet 模型预训练的基础,这使得 ImageNet 成为我们的源域。因此,该架构将以一个有 1,000 个输出的nn.Linear层结束。我们的目标域(猫与狗)有两个类别,因此我们希望它只有一或两个输出(分别用于具有二元交叉熵和 softmax 的训练)。图 13.3 显示了这种情况以及我们如何实现迁移学习。

图 13.3 左侧显示了 ResNet 的总结。我们想要将其修改为右侧的样子,其中只有最后一个 nn.Linear 层被改变。每个 nn.Linear 层的输入是 512,因为最终的卷积层有 C = 512 个通道,使用自适应平均池化到 1 × 1 意味着输出中只有 512 个值,无论原始图像的大小如何。
我们可以利用 PyTorch 的面向对象特性,相对容易地将现有模型适应到我们新的问题上。PyTorch 的 ResNet 中的最后一层全连接层被命名为fc,因此我们可以深入到网络中并替换掉 fc 对象!由于 nn.Linear 层在名为 in_features 的对象中保持输入数量,我们可以以通用的方式替换这最后一层,而无需我们硬编码输入数量。下面的代码展示了这个过程,这个过程通常被称为 手术,因为我们正在从模型中移除一部分并替换成新的部分。¹ 这只需要两行代码,并且非常容易完成:
model = torchvision.models.resnet18()
model.fc = nn.Linear(model.fc.in_features, 2) ❶
❶ 执行一些“手术”
图 13.4 中总结了这两行代码。默认情况下,ResNet 模型具有随机权重,所以这基本上是给我们一个新的 ResNet,以便从头开始训练以解决我们的问题。

图 13.4 通过 PyTorch 中的权重转移模型机制的演示。原始模型使用一组权重初始化(默认为随机;可能为预训练)。我们用适合我们目的的新版本替换了模型的顶部部分。
这给我们一个可以训练来预测图像是猫还是狗的模型。下面的代码使用了我们多次使用过的标准 CrossEntropyLoss 和我们的常规 train_network 函数。网络比本书中我们构建的大多数网络都要大,图像也更大,因此训练这个示例需要更长的时间来运行:
loss = nn.CrossEntropyLoss()
normal_results = train_network(model, loss, train_loader, epochs=10,
➥ device=device, test_loader=test_loader, score_funcs={’Accuracy’:
➥ accuracy_score})
现在我们已经训练了模型,我们可以进行我们非常熟悉的模型训练常规方法的结果绘图过程。我们得到了一些相当不错的结果,而无需进行太多思考。我们只是使用了 ResNet-18 并运行它,这是许多人用来解决实际问题的常见方法:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=normal_results,
➥ label=’Regular’)
[13]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

注意像 ResNet-18 这样的架构以及 torchvision.models 包中的其他架构已经经过大量测试,并在各种问题上表现出良好的效果。所以如果你需要进行任何类型的图像分类,这几乎总是一个好的起点,利用他人已经完成的设计良好的架构的辛勤工作。但到目前为止我们所做的是创建了一个具有随机初始权重的 ResNet 的新版本。迁移学习涉及使用一组已经在另一个数据集上训练过的权重 Θ,这可以显著提高我们的结果。
13.2.1 调整预训练网络
我们已经初始化了一个默认的 ResNet 模块,对其进行了手术以适应我们的分类任务,并使用本书中一直使用的梯度下降工具对其进行训练。要将这变成真正的迁移学习,唯一缺少的是在源域(例如,ImageNet)上预训练 ResNet 模型,而不是随机初始化(默认设置)。
幸运的是,预训练已经完成。PyTorch 在torchvision.models下提供的所有模型都有一个选项来设置标志pretrained=True,这将返回一个已经在指定原始数据集上训练过的模型版本。对于 ResNet,原始数据集是 ImageNet。所以让我们快速获取我们的新模型。对于我们最初的 ResNet-18 手术,我们基本上有相同的两行代码,只是我们添加了pretrained=True标志:
model_pretrained = torchvision.models.resnet18(pretrained=True) ❶
model_pretrained.fc = nn.Linear(model_pretrained.fc.in_features, 2) ❷
❶ 在数据集上训练过的模型
❶ 进行一些手术
如前所述,我们将这个网络的完全连接层替换成了一个新层。原始的卷积滤波器是预训练的,但最后的完全连接层是随机初始化的,因为我们用一个新的nn.Linear层替换了它——默认情况下,PyTorch 中的所有模块都以随机权重开始。希望由于 ImageNet 是一个如此庞大的数据集,有 100 万张训练图像,我们可以更快地学习到一个更好的模型。我们可能需要从头开始学习这个nn.Linear层,但所有前面的卷积层应该有一个更好的起点,因为它们是在大量数据上训练的。
预训练为什么有效
在我们在猫/狗问题上训练model_pretrained之前,我们应该问,“这种工作的直觉是什么?”为什么从已经训练过的模型的权重开始可以帮助我们在新的问题上?在第三章,当我们第一次讨论卷积网络时,我们看到了卷积如何学会在不同角度找到边缘。卷积还可以学会找到颜色或颜色变化,锐化或模糊图像等。所有这些对于任何基于图像的问题都是广泛有用的。所以预训练之所以有效,其核心在于卷积在大数据集上学会检测到的东西可能是我们想要在任何其他基于图像的问题中检测到的东西。如果这些是一般有用的东西,CNN 学会如何检测它们,那么 CNN 可能从更多的数据中而不是更少的数据中学习得更好。因此,来自更大数据集的预训练网络应该已经学会寻找我们关心的模式,而最后的nn.Linear层只需要学会如何将这些模式组装成决策。从头开始训练将需要学习模式和如何从它们中做出分类决策。
转移学习通常在源数据集大于目标数据集时效果最佳,因为您需要足够的数据才能在仅从您关心的数据从头开始训练时获得优势。此外,源数据与您想要工作的目标领域的相关性也是一个因素。如果数据的相关性足够高,源模型学习得好的东西(因为它有更多的数据)更有可能在目标数据上重用。这种平衡行为在图 13.5 中得到了总结。

图 13.5 源数据集和目标数据集大小及相关性的权衡。右上角是最好的位置,左上角次优且不一定总是有效。下半部分是进行有效迁移学习的一个统一困难的地方。
幸运的是,对于几乎任何大型图像数据集,它们的相关性通常足够高,使我们处于图 13.5 的右上角:像素相关性的结构和可推广性非常强,这是用网络的第一层最容易可视化的。因为第一层卷积层接受图像的红色、绿色和蓝色通道,我们可以将每个滤波器视为一个图像,并绘制它以查看它在寻找什么。让我们首先为我们的预训练模型做这件事,看看它作为起点有什么。首先,我们需要从第一层卷积层获取滤波器的权重。对于 ResNet,这被定义为conv1层:
filters_pretrained = model_pretrained.conv1.weight.data.cpu().numpy() ❶
❶ 获取第一组卷积滤波器的权重,将它们移动到 CPU,并将它们转换为 NumPy 张量
filters_pretrained对象现在有了模型使用的权重副本。它的形状为(64,3,7,7),因为 ResNet-18 的第一层有 64 个滤波器,期望输入有 3 个通道(红色、绿色和蓝色),并且 64 个滤波器在宽度和高度上都是 7 × 7。由于我们想要绘制这些,让我们首先将滤波器归一化到[0,1]的范围内,因为这是 Matplotlib 对彩色图像的期望:
filters_pretrained = filters_pretrained-np.min(filters_pretrained) ❶
filters_pretrained = filters_pretrained/np.max(filters_pretrained) ❷
❶ 将所有值移至[0, 最大值]范围内
❷ 重新缩放,使所有值都在[0, 1]范围内
Matplotlib 还期望图像格式为(W,H,C),但 PyTorch 使用(C,W,H)。为了解决这个问题,我们将通道维度(1,因为维度 0 是滤波器的数量)移动到最后的位置(-1),以匹配 Matplotlib 的期望:
filters_pretrained = np.moveaxis(filters_pretrained, 1, -1) ❶
❶ 权重的形状为(#Filters, C, W, H),但 Matplotlib 期望(W, H, C),因此我们移动了通道维度。
查看滤波器
接下来我们可以绘制过滤器。你应该会看到它们之间有许多共同的模式,比如不同角度和频率的黑白边缘(一条白色和一条黑色线,与几条线)。这些黑白过滤器作为边缘检测器,以不同的重复率检测不同角度和模式的边缘。你还看到一些具有单一颜色如蓝色、红色、紫色或绿色的过滤器。这些过滤器检测特定的颜色模式。如果你有一个足够大、多样化的训练集,即使是一个完全不同的问题,你也会在第一个卷积层中看到类似的结果:
i_max = int(round(np.sqrt(filters_pretrained.shape[0]))) ❶
j_max = int(np.floor(filters_pretrained.shape[0]/ ❷
➥ float(i_max)))
f, axarr = plt.subplots(i_max,j_max, ❸
➥ figsize=(10,10))
for i in range(i_max): ❹
for j in range(j_max): ❺
indx = i*j_max+j ❻
axarr[i,j].imshow(filters_pretrained[indx,:]) ❼
axarr[i,j].set_axis_off() ❽
❶ 将项目数开平方以形成一个图像的正方形网格
❷ 除以行数
❸ 创建用于绘制图像的网格
❹ 每一行
❺ 每一列
❻ 对过滤器进行索引
❼ 绘制特定的过滤器
❽ 关闭编号的坐标轴以避免杂乱

但我们是通过 ImageNet 的 100 万张训练图像得到这个结果的。我们大约有 20,000 张,这要少得多。当我们从这些数据中从头开始学习过滤器时会发生什么?让我们看看我们训练的model,并找出答案。这使用与之前相同的代码,但我们将其包装在一个名为visualizeFilters的函数中,该函数接受要可视化的张量。我们传入原始model中训练的conv1过滤器的第一个,我们可以看到产生的过滤器:
filters_catdog = model.conv1.weight.data.cpu().numpy() ❶
visualizeFilters(filters_catdog) ❷
❶ 本章开始时我们训练的模型的过滤器
❷ 绘制结果

预训练的 ResNet-18 有清晰、锐利的过滤器,很容易看出每个过滤器学到了什么来检测。在这里,过滤器看起来像包含了噪声。我们看到一些形成黑白边缘检测过滤器的证据,但它们也受到了颜色信息的影响。这意味着一个用于检测边缘的过滤器在边缘不明显的物品上也会部分激活,但颜色是正确的——这可能会在后续造成问题。
通常情况下,你不应该根据过滤器的样子来判断模型的质量,而应该根据模型在真实、多样化的测试数据上的行为来判断。在这种情况下,我们知道在 RGB 图像模型的第一层中,好的过滤器通常是什么样的,并且可以做出合理的比较。这些过滤器看起来并不好。
但仅仅因为过滤器不同并不意味着它们总是更差。要判断这一点,我们需要训练预训练网络并在测试数据上比较准确率。如果预训练模型有更高的准确率,那么初始卷积过滤器的质量可能是性能较差的一个有效解释。
13.2.2 预训练 ResNet 的预处理
现在我们已经对为什么要使用预训练网络有了些直觉,让我们训练一个新的网络,看看它是否有所改进。确保我们的输入数据与预训练模型期望的相匹配是至关重要的。特别是torchvision.models中的 ImageNet 模型对每个输入颜色通道使用零均值和单位方差(μ = 0,σ = 1)进行标准化。所使用的特定系数来自 ImageNet,所以我们快速定义一个新的Module来在输入传递给预训练网络之前对其进行归一化。
警告:如果你通过预训练网络进行迁移学习,你必须确保你的数据预处理与模型最初训练的方式相匹配。否则,模型将无法获得它最初期望的内容,权重将变得毫无意义。使用的预处理并不总是有很好的文档记录,这可能会让人烦恼,而且这是一个需要特别注意的关键细节。
下一段代码执行了这个归一化。执行所有工作的整个模型作为baseModel传入,我们用Module包装它以预先归一化输入。这种归一化是由 ResNet 最初训练的方式指定的,我们使用requires_grad=False以确保归一化在训练过程中不被改变:
class NormalizeInput(nn.Module):
def __init__(self, baseModel):
"""
baseModel: the original ResNet model that needs to have its inputs
➥ pre-processed
"""
super(NormalizeInput, self).__init__()
self.baseModel = baseModel ❶
self.mean = nn.Parameter(torch.tensor(
➥ [0.485, 0.456, 0.406]).view(1,3,1,1), ❷
➥ requires_grad=False) ❸
self.std = nn.Parameter(torch.tensor(
➥ [0.229, 0.224, 0.225]).view(1,3,1,1), ❷
➥ requires_grad=False) ❸
def forward(self, input):
input = (input-self.mean)/self.std ❹
return self.baseModel(input) ❹
❶ 我们想要使用的模型。我们首先需要对其进行输入归一化。
❷ 用于 ImageNet 归一化的均值和标准差。我们只能接受这些大家普遍使用的“魔法”数字。
❸ requires_grad=False:我们不想在训练过程中改变这些值!
❹ 归一化输入并将其输入到我们想要使用的模型中
警告:你在线上看到的很多代码都硬编码了这个归一化步骤,或者将其硬编码到数据加载器中。我不喜欢这两种方法。归一化是特定于在 ImageNet 上预训练的网络。这意味着它不是数据的一部分,因此它不应该成为Dataset类中使用的转换的一部分。如果你想要切换到其他东西,你可能不想使用相同的归一化。我更喜欢将归一化部分作为模型的一部分,因为这些都是归一化值来源的地方!我似乎在这个问题上属于少数,所以当你阅读其他人的代码时要留心。
这个NormalizeInput类执行了用于 PyTorch 中预训练 ResNet 模型的归一化。现在我们可以用这个归一化Module包装我们的预训练模型,以获得正确的行为。这样,我们数据格式化和预训练权重期望之间就没有不匹配。我喜欢这种方法,因为它将特定于这种情况的预处理特殊性封装到其自己的类中。如果我们想更换不同的模型或用我们自己的转换增强数据加载器,我们可以这样做,而不用担心必须发生的模型特定预处理,因为模型特定处理是模型的一部分。这在这里通过一行代码实现,即用这个特定的归一化器包装预训练模型:
model_pretrained = NormalizeInput(model_pretrained)
我们模型的预处理与预训练模型所期望的相匹配,我们最终可以继续训练网络。有两大主要方法可以这样做,我们将讨论。
13.2.3 使用 warm start 进行训练
我们有一个预训练模型,它被设置为正确地预处理数据,而我们也有我们的新数据。前进的最简单方式是调用train_network函数,并传入model_pretrained参数,看看会发生什么。以下行训练了这个模型,我们可以检查结果并看看它的表现如何。我们称之为warmstart_results`,因为这个迁移学习的方法被称为warm start:
warmstart_results = train_network(model_pretrained, loss, train_loader,
➥ epochs=10, device=device, test_loader=test_loader, score_funcs={’Accuracy’:
➥ accuracy_score})
值得注意的是,warm start 和迁移学习并不是同义词。用 warm start 训练任何模型是指你使用任何一组你期望比使用默认随机值更接近你期望解的初始权重值Θ[ini**t]。Warm start 是一种常见的优化方法,迁移学习并不是唯一发生 warm start 的情况。所以如果有人告诉你他们正在使用 warm start,这并不一定意味着他们正在进行任何类型的迁移学习。简而言之,warm start 仅仅意味着你有一个你相信比随机权重更好的初始权重集。恰好有一种迁移学习的方法是通过这种 warm-start 策略。
迁移学习之外的 warm start
在迁移学习之外,warm start 的一个常见应用是线性模型的超参数优化。因为线性模型通常使用精确求解器(你收敛到唯一正确答案),它们的运行时间部分取决于过程开始时使用的值。当你训练 10+个模型,每个模型都有一个不同的正则化惩罚λ时,具有一个λ值的模型解可能与具有略微不同值λ + ϵ的解相似。由于你将到达正确答案,无论起始点如何,你可以使用之前为λ找到的解来 warm start λ + ϵ的解。
这种技术在 Lasso 正则化模型和支持向量机中非常受欢迎,因为它们的训练成本较高,并且需要执行超参数搜索以获得良好的结果。这就是 scikit 的 LassoCV 类等工具所发生的情况 (mng.bz/nrB8)。
由于我们的预训练权重来自训练另一个问题的模型,因此权重是我们如何从原始域迁移知识到新域的方式。这种方法的另一个名称是微调:我们有一些通用的好东西,我们想要对其进行轻微调整以适应我们的特定问题。
通过使用预训练权重调用 train_network 函数,我们正在执行这种轻微的调整,因为梯度下降会改变网络中的每个权重以尝试最小化损失。当我们绘制结果以查看这是否是一个好主意时,准确性出现了显著差异。预启动不仅达到了更高的准确性:它在一个单个周期后达到了更高的准确性。这意味着我们可以通过不训练 10 个周期来使整个过程快 10 倍。我们显然事先不知道这一点,但这说明了使用预训练时可以看到的优势:你收敛得更快,通常到更好的解决方案:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=normal_results,
➥ label=’Regular’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=warmstart_results,
➥ label=’Warm’)
[24]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

这种在更短时间内获得更高精度的双重胜利是迁移学习成为解决新问题最有用的工具之一的部分原因。我们还可以通过比较微调前后的权重来了解预启动有多有用。以下代码再次调用 visualizeFilters 来查看 ResNet-18 模型微调后的卷积过滤器。这些过滤器基本上与我们开始时的一样,这是一个很好的指标,表明它们确实是许多问题的通用良好过滤器。如果不是这样,SGD 会更多地改变它们以提高其准确性:
filters_catdog_finetuned = model_pretrained.baseModel. ❶
➥ conv1.weight.data.cpu().numpy()
visualizeFilters(filters_catdog_finetuned) ❷
❶ 在微调预启动模型后获取过滤器
❷ 绘制过滤器,它们看起来与预训练模型的初始过滤器非常相似

13.2.4 使用冻结权重进行训练
在这种情况下,我们还有另一种迁移学习的选项,称为权重冻结或使用冻结权重。权重冻结是指我们决定不改变层的参数/系数。梯度仍然会在层中计算并反向传播,但当我们执行梯度更新时,我们不做任何改变——就像我们将学习率 η 设置为 0 一样。
无法使用权重冻结来冻结网络的所有层;这意味着没有东西可以训练!只有当我们调整模型的一些参数时,训练才有意义。一种常见的方法是冻结所有卷积和归一化层的权重,只更改全连接层的权重。这隐含地假设从原始域学习到的过滤器与或优于在这个新域上学习到的过滤器。
要做到这一点,我们首先将模型中每个参数的 requires_grad 标志设置为 False。这样,在反向传播后,没有任何参数保存梯度,因此当优化器执行更新步骤时,不会发生任何变化。在冻结整个模型后,我们替换模型的完全连接层,该层默认具有 requires_grad=True。我们希望这样做,因为新的完全连接层是我们唯一想要调整的层。然后我们可以像使用预热方法一样构建和训练模型。以下代码执行冻结过程,然后训练模型:
model_frozen = torchvision.models.resnet18(pretrained=True)
for param in model_frozen.parameters(): ❶
param.requires_grad = False
model_frozen.fc = nn.Linear(model_frozen.fc.in_features, 2) ❷
model_frozen = NormalizeInput(model_frozen)
frozen_transfer_results = train_network(model_frozen, loss, train_loader,
➥ epochs=10, device=device, test_loader=test_loader,
➥ score_funcs={’Accuracy’: accuracy_score})
❶ 关闭所有参数的梯度更新!
❷ 我们的新全连接层默认 requires_grad = True。
接下来我们绘制结果。冻结模型的结果非常稳定。这很合理,因为它调整的参数数量要少得多。它的表现略逊于预热模型,但仍然远优于从头开始训练的朴素方法:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=normal_results,
➥ label=’Regular’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=warmstart_results,
➥ label=’Warm Start’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=frozen_transfer_results,
➥ label=’Frozen’)
[27]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

那么,预热权重和冻结权重哪个更好?这个单一的结果似乎表明了一个小的权衡:预热权重更准确,但冻结权重更一致。这是真的,但它并没有讲述整个故事。要了解这个故事,请继续阅读:下一节将讨论在这个权衡中可能起到决定性作用的因素——数据集大小。
13.3 使用较少标签进行学习
到目前为止,我们已经有了预热和冻结权重作为我们执行迁移学习的两种主要方法。这些是在实践中进行迁移学习最常见和最成功的方法。但你应该在什么时候使用哪一种?你总是可以尝试两者,看看哪个效果最好,但当你有极少的训练数据时,冻结权重具有特定的优势。
为什么会这样?想象一下,一个理想的参数集 Θ 将为问题提供最佳性能。² 您找到任何一组参数 Θ 的能力取决于您可以获得多少数据(以及计算资源)。一种简化的思考方式是,您对真实参数的估计是对它们的噪声视图:

你拥有的数据越多,你能够构建的模型就越好,ϵ → 0。如果你没有数据,你只能随机选择答案,因为你只能随机选择 Θ,所以 ϵ → ∞。显然,你的训练数据大小 N 影响你估计参数的能力。
另一个因素是你有多少个参数。想象一下,如果你有关于随机人群身高的 1,000 个数据点。你可能会从这个 1,000 人的样本中非常准确地估计出一般人群的平均身高和身高标准差。但如果你想要记录1 万亿种不同的事物,比如 DNA 与身高、体重、发色、健康、疾病、左撇子/右撇子、恶搞倾向等所有可能的相互作用?有太多的相互作用,而你从 1,000 个人中得到关于所有这些事物的准确答案的能力将极其低。参数数量 D 是你估计模型好坏的一个因素。粗略地说,我们可以这样讲

这是一个非常粗略的直观形式。解决方案质量、特征数量 D 和数据点数量 N 之间没有真正的线性关系。³ 重点是要说明,如果你没有足够的数据 N,而你有很多参数 D,你将无法学习一个好的模型。
这让我们更了解何时使用预热与冻结权重。当我们冻结权重时,它们就不再是我们可以修改的参数,这实际上减少了方程中的 D 项,并更好地估计剩余的参数。这也是为什么冻结方法比预热方法更稳定的原因:通过减少参数数量 D,噪声因素被减弱。
这在我们拥有较少标记数据时尤其有价值。为了展示这一点,我们可以通过随机采样一小部分用于训练来模拟我们的猫狗分类器的情况:批量大小的两倍,总共 256 个训练图像。这通常远远不够的数据来从头开始学习任何类型的 CNN:
train_data_small, _ = torch.utils.data.random_split(
➥ train_data, (B*2,len(train_data)-B*2)) ❶
train_loader_small = DataLoader(train_data_small, ❷
➥ batch_size=B, shuffle=True)
❶ 将小数据集设置为批量大小的 2 倍
❷ ❶ 为这个微小的数据集制作加载器
现在我们有一个小得多的数据集。我们可以使用所有三种方法来训练一个模型:从头开始、使用预热启动和使用冻结权重。我们的初步结果显示,预热启动的表现略好于冻结。如果我们理解正确,在这种情况下冻结权重应该比预热启动表现更好。为了测试这一点,让我们训练每个选项:
model = torchvision.models.resnet18() ❶
model.fc = nn.Linear(model.fc.in_features, 2)
normal_small_results = train_network(model, loss, train_loader_small,
➥ epochs=10, device=device, test_loader=test_loader,
➥ score_funcs={’Accuracy’: accuracy_score})
model = torchvision.models.resnet18(pretrained=True) ❷
model.fc = nn.Linear(model.fc.in_features, 2) ❸
model = NormalizeInput(model)
warmstart_small_results = train_network(model, loss, train_loader_small,
➥ epochs=10, device=device, test_loader=test_loader,
➥ score_funcs={’Accuracy’: accuracy_score})
model = torchvision.models.resnet18(pretrained=True) ❹
for param in model.parameters(): ❺
param.requires_grad = False
model.fc = nn.Linear(model.fc.in_features, 2) ❻
model = NormalizeInput(model)
frozen_transfer_small_results = train_network(model, loss,
➥ train_loader_small, epochs=10, device=device, test_loader=test_loader,
➥ score_funcs={’Accuracy’: accuracy_score})
❶ 1. 从头开始训练
❷ ❷ 2. 预热模型的训练
❸ 执行一些手术
❹ 3. 使用冻结权重进行训练
❺ 关闭所有参数的梯度更新
❻ 我们的新全连接层默认 requires_grad = True。
注意,我们没有改变这三个选项中的任何代码。它们都以与之前相同的方式机械地操作;唯一的区别是我们给每个模型提供的数据量有多小。结果将在下面绘制,我们可以看到巨大的影响,这与我们对参数数量 D 和数据集大小 N 如何影响使用预热和冻结权重进行学习的理解相匹配:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=normal_small_results,
➥ label=’Regular’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=warmstart_small_results,
➥ label=’Warm Start’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=frozen_transfer_small_results,
➥ label=’Frozen’)
[30]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

我们在结果上看到了显著的差异。从头开始训练仍然是效果最差的,测试集上的准确率勉强超过 50%。使用预热启动更好,准确率约为 80%;但使用冻结的卷积层效果最佳,约为 91%,几乎与在 20,000 个样本上训练的结果一样好。因此,当我们的训练数据非常有限时,冻结方法是最好的,但其进一步改进的能力也是有限的。这就是预热启动发挥作用的地方;如果您有足够的标记数据(但仍然不是太多),预热模型可以开始领先于冻结模型。
注意:如果您正在进行基于计算机视觉的在职工作,我几乎总是建议从预训练模型开始。这种方法非常有效,如果您想使用当前的工具并构建一个产品,就没有必要从头开始训练模型。值得从头训练一个模型来看看您是否有一个罕见的问题,预训练不起作用,但除此之外,预训练模型会使您的生活更轻松。如果您可以使用预训练模型构建一个可行的解决方案,您最终可以构建一个数据收集和标注过程,以创建自己的大型语料库,这有助于改进事物;但这是一项重大的投资。预训练帮助您在不支付高昂成本的情况下达到一个可行的初步解决方案。
虽然我们没有在这里展示,您也可以在预热启动和冻结启动之间取得平衡。正如我们之前提到的,计算机视觉任务的第一层卷积通常学习到在广泛问题中通用的过滤器。随着您深入网络,过滤器变得更加专业化。在非常深的模型中,最后的卷积过滤器通常只对当前任务有用。
您可以通过冻结网络的前几层,但允许后续层作为预热启动来利用这一点。这种混合方法可能需要一些试错来选择停止冻结权重的深度,这取决于您的原始模型(例如,ResNet 的哪个版本或其他架构),它在多少数据上进行了训练,以及您的新目标领域数据。调整冻结与不冻结不同层实际上成为您在使用预训练网络时修改模型的新方法,因为您不能向已经存在的东西添加更多神经元或层。这并不总是会产生巨大差异,所以许多人跳过这一步,专注于设计更多/更好的数据增强管道,以获得更好的时间回报。
13.4 使用文本进行预训练
使用预训练网络进行迁移学习成功的关键在于学习鲁棒的特征/模式,这些模式具有广泛的应用性。直到最近,这种方法在自然语言处理(NLP)相关任务中还没有成功。感谢我们在第十二章中了解到的新模型transformer,这种情况终于开始改变。
尤其是基于转换器的一组算法显著提高了我们在文本问题上的结果质量。这些预训练模型中的第一个被称为 BERT⁴(是的,是以《芝麻街》上的角色 Bert 的名字命名的)。为了开始调整预训练的 BERT 模型,我们将重用上一章的 AG News 数据集(加载torchtext、tokenizer和Vocab对象,以及text_transform和相关的label_transform),并将 AG News 的训练集和测试集分别命名为train_dataset_text和test_dataset_text)。
唯一真正的变化是我们创建了一个包含 256 个标记项的小型语料库版本。在有限训练数据上进行学习是迁移学习获得最大结果回报的地方,同时也帮助这些示例快速运行:
train_data_text_small, _ = torch.utils.data.random_split( ❶
train_dataset_text, (256,len(train_dataset_text)-256))
❶ 切割出一个小型数据集
现在我们训练第十二章中相同的 GRU 模型作为基线。在完整数据集上,GRU 能够达到 92%的准确率。使用这个较小的标记集,我们预计准确率会下降。以下代码块重用了上一章的相同pad_batch函数来训练相同的 GRU 模型,但我们只有 256 个标记示例:
embed_dim = 128
gru = nn.Sequential(
nn.Embedding(VOCAB_SIZE, embed_dim), ❶
nn.GRU(embed_dim, embed_dim, num_layers=3,
➥ batch_first=True, bidirectional=True), ❷
LastTimeStep(rnn_layers=3, bidirectional=True), ❸
nn.Linear(embed_dim*2, NUN_CLASS), ❹
)
train_text_loader = DataLoader(train_data_text_small,
batch_size=32, shuffle=True, collate_fn=pad_batch) ❺
test_text_loader = DataLoader(test_dataset_text, batch_size=32,
➥ collate_fn=pad_batch)
gru_results = train_network(gru, nn.CrossEntropyLoss(),
➥ train_text_loader, test_loader=test_text_loader,
➥ device=device, epochs=10,
➥ score_funcs={’Accuracy’: accuracy_score}) ❻
❶ (B, T) -> (B, T, D)
❷ (B, T, D) -> ( (B,T,D) , (S, B, D) )
❸ 将 RNN 输出减少到一个项目,(B, 2*D)
❹ (B, D) -> (B, classes)
❺ 使用 collate_fn 创建训练和测试加载器
❻ 训练我们的基线 GRU 模型
13.4.1 使用 Hugging Face 库的转换器
我们的基线 GRU 已经训练好,代表了我们会使用的典型方法。对于迁移学习版本,我们创建一个冻结的 BERT 模型来获得这个预训练文本模型的好处。我们首先需要的是一个包含一些预训练架构的实现。幸运的是,Hugging Face (huggingface.co/transformers) 库已经迅速成为研究人员放置 BERT 最新和最伟大扩展的事实上工具和仓库。要安装它,请运行以下命令:
!pip install transformers
我们将使用一个名为 DistilBERT 的模型⁵,这是一个经过蒸馏的 BERT 模型版本,已经被压缩成一个具有更少参数的小型网络。这样做只是为了使示例运行得更快,因为一般来说,转换器模型在计算上都很昂贵。BERT 类型模型成功的一部分是使用数十个 GPU 在数百 GB 的数据上训练大型模型。转换器继续从更多层和更大的数据集中受益,并且能够在许多 GPU 上很好地并行化,这比 RNN 要大得多,这也是转换器之所以如此强大的原因之一。但是,对于许多人来说,从头开始训练转换器/BERT 模型的投资太大。能够使用转换器进行迁移学习是它们对我们这些没有数十个 GPU 可用的人如此相关的部分。
由于使用预训练的 BERT 模型正迅速变得流行,这些模型还附带了一个方便的from_pretrained函数,它可以接受不同的字符串,指定在不同设置下训练的 BERT 模型。例如,一个可能在区分大小写的输入上训练,另一个可能在忽略大小写的输入上训练。官方文档(huggingface.co/transformers/model_doc/distilbert.html)描述了哪些选项可用。我们使用不区分大小写的选项,因为我们有较少的数据(更少的案例意味着更少的参数和在小数据集上的更好性能):
from transformers import DistilBertTokenizer, DistilBertModel ❶
tokenizer = DistilBertTokenizer.from_pretrained(’distilbert-base-uncased’) ❷
bert_model = DistilBertModel.from_pretrained(’distilbert-base-uncased’)
❶ 加载 DistilBert 类
❷ 初始化 tokenizer(将字符串转换为输入张量)和模型(输入张量到输出张量)
注意,我们不仅有bert_model,还有一个新的 tokenizer。这样做是为了我们可以使用原始 BERT 训练中使用的相同编码过程,将新的字符串转换为 BERT 的输入。我们不能在不同模型之间混合匹配 tokenizer。这类似于我们使用特定的归一化均值和标准差来使用预训练的 ResNet-18 模型。我们需要对新目标域的初始输入以与原始域相同的方式进行处理。tokenizer对象接受原始字符串作为输入,并执行与原始模型以相同方式训练时使用的所有预处理操作,使我们的生活更加简单。
实现 BERT 模型的collate_fn策略看起来与我们的 GRU 模型非常相似。我们不是调用text_transform,而是在原始字符串上调用 Hugging Face 提供的tokenizer。特别是有一个batch_encode_plus函数,它接受字符串列表并将其转换为处理(如果有需要,带有掩码)的数据批次。我们简单地添加return_tensors=pt参数来让 Hugging Face 知道我们想要 PyTorch 张量(它也支持 TensorFlow)和padding=True标志,以便较短的句子被填充到相同长度:
def huggingface_batch(batch):
"""
Pad items in the batch to the length of the longest item in the batch.
Also, re-order so that the values are returned (input, label)
"""
labels = [label_transform(z[0]) for z in batch] ❶
texts = [z[1] for z in batch] ❷
texts = tokenizer.batch_encode_plus(texts, ❸
return_tensors=’pt’, padding=True)[’input_ids’]
x, y = texts, torch.tensor(labels, dtype=torch.int64) ❹
return x, y train_text_bert_loader = DataLoader(
➥ train_data_text_small, batch_size=32, shuffle=True,
➥ collate_fn=huggingface_batch) ❺
test_text_bert_loader = DataLoader(test_dataset_text, batch_size=32,
➥ collate_fn=huggingface_batch)
❶ 前三条与之前相同。
❷ 修改:不要使用旧的 text_transform;获取原始文本。
❸ 新增:Hugging Face 为我们编码一批字符串。
❹ 回到旧代码:将它们堆叠起来并返回张量。
❺ 使用新的 collage_fn 创建我们的数据加载器
13.4.2 使用 no-grad 冻结权重
我们还需要一个带有冻结权重的 BERT 模型。由于输出包含填充,我们定义了一个Module类来找出填充的掩码,并适当地使用它。BERT 给我们一个形状为 (B,T,D) 的输出张量,我们需要将其减少到 (B,D) 以进行分类预测。第十二章中的getMaskByFill函数给我们填充掩码,这样我们就可以重用注意力层,只对有效的(非填充)标记进行平均。我们可以使用bert_model.config.dim变量访问 BERT 使用的隐藏神经元数 D。Hugging Face 中的每个模型都有一个.config变量,其中包含有关模型配置的各种信息。
我们也利用这个机会展示一种不同的冻结权重的方法。我们不需要手动为每个参数设置 requires_grad=False,而是可以使用 with torch.no_grad(): 上下文。它具有相同的效果,为任何需要的反向传播计算梯度,但立即忘记它们,因此在梯度更新期间不会使用。如果我们想使冻结自适应或使代码更明确地表明梯度将不会用于其中的一部分,这将很方便。这种方法的缺点是,实现具有混合热和冻结层的模型比较困难。
下面是代码:
class BertBasedClassifier(nn.Module): ❶
def __init__(self, bert_model, classes):
"""
bert_model: the BERT-based classification model to use as a frozen
➥ initial layer of the network
classes: the number of output neurons/target classes for this
➥ classifier.
"""
super(BertBasedClassifier, self).__init__()
self.bert_model = bert_model ❷
self.attn = AttentionAvg( ❸
➥ AdditiveAttentionScore(
➥ bert_model.config.dim))
self.fc1 = nn.Linear(bert_model.config.dim, ❹
➥ bert_model.config.dim)
self.pred = nn.Linear(bert_model.config.dim, classes) ❺
def forward(self, input):
mask = getMaskByFill(input) ❻
with torch.no_grad(): ❼
x = self.bert_model(input)[0] ❽
cntxt = x.sum(dim=1)/(mask.sum(dim=1).unsqueeze(1)+1e-5) ❾
x = self.attn(x, cntxt, mask) ❿
x = F.relu(self.fc1(x)) ⓫
return self.pred(x)
bertClassifier = BertBasedClassifier(bert_model, NUN_CLASS) ⓬
bert_results = train_network(bertClassifier, nn.CrossEntropyLoss(),
➥ train_text_bert_loader, test_loader=test_text_bert_loader,
➥ device=device, epochs=10, score_funcs={’Accuracy’: accuracy_score})
❶ 我们为 BERT 模型的冻结训练创建的新类
❷ 我们从 BERT 得到一个形状为 (B, T, D) 的张量,因此我们定义了一些自己的层,从 (B, T, D) 到形状为 (B, classes) 的预测
❸ 注意力降低到 (B, D) 形状
❹ 进行一些特征提取
❺ 对类别做出预测
❻ 输入是 (B, T)。
❼ 使用 no_grad()进行冻结。
❽ Hugging Face 返回一个元组,所以解包它! (B, T, D)
❾ 计算平均嵌入
❿ 应用注意力
⓫ 进行预测并返回
⓬ 构建分类器
如前所述,我们可以使用我们方便的train_network函数来训练这个基于 BERT 的分类器:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=gru_results,
➥ label=’Regular-GRU’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=bert_results,
➥ label=’Frozen-BERT’)
[41]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

查看结果,我们可以看到 GRU 正在学习,但速度非常慢。GRU 的最高准确率约为 40%,不到它在完整训练集上可以达到的 92%的一半。我们的冻结 BERT 模型达到了约 84%,这是一个显著的提升。然而,这种情况的代价也更高。正如我们之前提到的,BERT 风格的模型通常非常大,因此计算成本很高。训练和应用我们的 BERT 分类器比 GRU 慢了大约 10 倍。
从模型准确率的角度来看,这显然是一个净胜,因为 GRU 永远无法单独达到 84% 的准确率。然而,基于 BERT 的模型可能在实际应用中太慢了。这取决于可用的资源和问题的具体细节。这是一个需要了解的重要权衡,在使用预训练方法时并不罕见。本质上,我们想要使用预训练模型,因为它已经在更大的数据集上进行了训练,但这也意味着模型变得更大,以最大化准确率。
在我个人的经验中,我发现随着你为你的问题建立标记数据集,预训练的转换器通常会开始失去它们的优势。而预训练的 CNN 几乎总是优于从头开始训练的 CNN,我经常发现,对于转换器来说,并没有如此明确、一致的结果。有时它们更好,有时则不然。这与非深度方法(如 Lasso 惩罚逻辑回归)相比尤其如此,它在许多文本数据集上表现出很强的竞争力。
尽管如此,现在使用预训练模型进行文本数据是可能的。它可能会随着时间的推移而改进,并且当训练样本非常少时是一个强大的工具。将其保留在你的工具箱中,但如果你有一个基于文本的问题,探索替代方案。
练习
在 Manning 在线平台 Inside Deep Learning Exercises (liveproject.manning.com/project/945) 上分享和讨论你的解决方案。一旦你提交了自己的答案,你将能够看到其他读者提交的解决方案,并看到作者认为哪些是最好的。
-
使用
dir命令来探索 PyTorchresnet18模型的子组件,并编写你自己的函数def warmFrozenResnet18(num_frozen),该函数只冻结网络中的前num_frozen个卷积层。 -
使用你的
warmFrozenResnet18函数,在 N = 256,N = 1,024 和 N = 8,192 个标记的训练样本中,探索暖层与冻结层程度之间的权衡。 -
重复前两个练习,但使用
MobileNet类。 -
回到第八章,在那里我们训练了 Faster R-CNN 来检测 MNIST 数字的位置。作为 MNIST 分类器进行自己的预训练,然后使用该预训练的主干网络来训练 Faster R-CNN。在多张图像上测试结果,并描述你看到的结果中的任何差异。注意: 如果你使
backbone有两个部分:一个只包含卷积层和池化的较大特征处理子网络,另一个进行任何最终池化(可选)、展平和类别标签预测的第二个预测子网络,这将更容易。然后你可以只为 Faster R-CNN 使用特征处理子网络。 -
当你拥有大量数据但其中大部分未标记时,可以使用自动编码器进行无监督预训练。为猫狗问题编写一个去噪自动编码器,该编码器在全部数据集上训练,然后将编码器部分作为分类器的预热启动。你可以将编码器特征视为处理骨干,并需要在上面添加一个预测子网络。
-
Hugging Face 有特殊的类来简化使用预训练模型的过程。查阅
DistilBertForSequenceClassification类的文档,并用 Hugging Face API 的内置方法替换我们的 AG News 数据集的方法。它们是如何比较的?
摘要
-
你可以通过重用已经在某个数据集上训练好的网络的权重,将它们应用于新的数据集,并对网络的最后一层进行手术以使其匹配你的问题来迁移知识。
-
预训练模型的权重在训练过程中可以是温暖的(允许改变)或冻结的(保持不变),在数据量多或少时分别提供不同的好处。
-
当我们拥有的标记数据非常少时,迁移学习的优势最为显著。
-
对于卷积网络和计算机视觉问题进行预训练非常有效,而对于文本问题进行预训练仍然有用,但由于模型大小和计算时间的差异,存在更多的权衡。
-
对于文本的预训练,最好使用在大语料库上训练的 Transformer 类型模型。
¹ 我从未有过完全成功的手术,我认为我对此很满意。请注意,我是一个博士,而不是医学博士,因此我对适当的手术礼仪和如何安全地切除东西的了解有限。↩
² 这通常被描述为拥有一个可以神奇地为你提供完美解决方案的先知。↩
³ 我过于简化的说法非常简化,但直觉是很好的。这是一个对于大多数机器学习都适用的直觉,但并不总是适用于深度学习。证明深度学习中的事情非常困难:已经取得了很大进展,但当 D 变得很大时,你应该总是对直觉进行二次怀疑。对这些高维空间进行推理是困难的。关于学习神经网络参数的奇怪之处,我鼓励你阅读 C. Zhang 等人,“理解深度学习需要重新思考泛化”,国际学习表示会议,2017。↩
⁴ J. Devlin, M.-W. Chang, K. Lee, and K. Toutanova,“BERT:用于语言理解的深度双向 Transformer 的预训练”,2019 年北美计算语言学协会分会会议:人机语言技术会议论文集(长篇和短篇论文),第 4171-4186 页,2019。↩
⁵ V. Sanh, L. Debut, J. Chaumond, 和 T. Wolf, “DistilBERT,BERT 的精简版:更小、更快、更便宜、更轻便,” ArXiv e-prints,第 2-6 页,2019 年,arxiv.org/abs/1910.01108。↩
14 高级构建块
本章涵盖了
-
使用抗锯齿池化提高平移不变性
-
通过改进残差连接更快地收敛
-
通过混合数据来对抗过拟合
本书中的练习迄今为止都是设计得让你可以在最少的计算时间内了解真实的技术。但是当你处理现实世界的问题时,它们通常需要数百个 epoch 和比你所训练的更深层次的模型,并且它们必须处理比本书中的示例更大的输入。
当处理这些更大的数据集时,有时需要额外的工具来获得最佳结果。本章涵盖了研究人员为改进深度学习模型而开发的最新和最优秀的技巧:这些技巧在训练大型数据集的多个 epoch 时通常效果最佳。我们专注于简单、广泛适用、有效且易于实施的方法。对于这些更高级的技术,你通常不会在较小的模型或仅训练 10 到 20 个 epoch(如本书大多数内容所示)时看到全部的好处。在实践中,这些技术在训练 100 到 300 个 epoch 时最能体现出其价值。我已经设计了实验,在相对较短的时间内展示了一些好处,但你应该期待在更大的问题上获得更显著的好处。
我们介绍了三种你可以安全地添加到几乎任何模型中并获得实质性改进的方法。抗锯齿池化改进了我们一直在使用的池化操作,使其更适合几乎任何 CNN 应用。这种池化通过更好地处理图像内容中的小位移来提高准确性。接下来,我们看看一种新的残差连接方法,称为 ReZero,它让我们的网络在何时以及如何使用跳跃连接方面有更多的灵活性。因此,它们更准确,在更少的 epoch 中收敛,同时使学习更快。最后,我们讨论了一种构建损失函数的新方法:MixUp,这是通过减少过拟合来改进几乎任何神经网络结果的一种日益增长的方法的基础。
14.1 池化的问题
池化是 CNN 的早期组成部分,并且在这些年里变化最少。正如我们在第三章中讨论的,池化层帮助我们赋予模型平移不变性:当我们上下或左右移动图像内容时,它们会产生相同或相似的答案。它们还增加了后续层的感受野,允许每个卷积层一次性查看更多的输入并获取额外的上下文。尽管它们无处不在,但一个微妙的缺陷已经困扰 CNN 数十年,而最近我们才注意到它并提出了一个简单的解决方案。问题的核心是,原始池化丢失了比必要更多的信息,并因此引入了噪声。我们通过一个示例来展示这种信息丢失是如何发生的,然后讨论解决方案并开发一个新的池化层来修复它。
为了演示问题,我们将从维基百科下载一张斑马的图片。¹ 这是一张斑马的事实很重要,您将在接下来的时刻看到。以下代码从给定的 URL 下载图片,并将其转换为 Python Imaging Library (PIL)图像:
import requests
from PIL import Image
from io import BytesIO
url = "https://upload.wikimedia.org/wikipedia/
➥ commons/9/9c/Zebra_in_Mikumi.JPG"
response = requests.get(url)
img = Image.open(BytesIO(response.content))
现在我们将图像的最短边调整到 1,000 像素,并裁剪出中心内容。这一步的主要目的是修改代码,以便您可以之后尝试不同的图像。然后ToTensor转换将 PIL 图像转换为适当的 PyTorch Tensor,其值缩放到范围[0,1]:
to_tensor = transforms.ToTensor() ❶
resize = torchvision.transforms.Resize(1000) ❷
crop = torchvision.transforms.CenterCrop((1000, 1000)) ❸
img_tensor_big = to_tensor(crop(resize(img))) ❹
❶ 将 PIL 图像转换为 PyTorch 张量
❷ 将最短边调整到 1,000 像素
❸ 裁剪出中心 1,000 × 1,000 像素
❹ 将所有三个转换步骤组合起来以转换图像
接下来,简单应用ToPILImage将图像转换回原始的 PIL 图像对象。Jupyter 笔记本足够智能,可以自动显示这些图像,您应该会看到图像中的两只斑马。注意,虽然背景内容模糊且不聚焦,但斑马清晰且捕捉得很好。它们的毛发和黑白条纹清晰易见,包括它们面部上更密集的条纹集合:
to_img = transforms.ToPILImage()
to_img(img_tensor_big)

现在我们使用最大池化将图像缩小四倍。我们将其缩小四倍而不是两倍,以加剧池化的问题。以下代码执行池化并打印图像,使我们能够看到确实有问题:
shrink_factor = 4 ❶
img_tensor_small = F.max_pool2d(img_tensor_big,
➥ (shrink_factor,shrink_factor)) ❷
to_img(img_tensor_small) ❸
❶ 执行多少池化
❷ 应用池化
❸ 结果图像

虽然背景看起来仍然不错,但斑马的条纹中有很多像素化的图案,通常被称为锯齿。锯齿在黑白条纹较密集的地方会更严重。在斑马的臀部附近,它们是显而易见的,但并不可怕;靠近胸部时,它们会令人分心;而且面部看起来严重混乱,缺乏原始照片中的任何细节。
这个问题被称为混叠。一个简化的解释是,当你试图将精细、详细的信息采样到更小的空间时,就会发生混叠。我们在这里强调“采样”这个词,因为混叠发生在我们从更大的表示中选择精确值来填充较小的表示时。这意味着许多不同的输入可以导致相同的块状锯齿,而且你尝试缩小输入的程度越大,问题就越严重。
让我们看看一个玩具示例,说明混叠是如何发生的。图 14.1 展示了三种不同的单维模式。第一种具有黑白块,以固定模式交替排列。第二种显示了成对的块,第三种有一对相邻的白色块但没有相邻的黑色块。尽管模式不同,简单的最大池化对所有这些模式都给出了相同的输出。

图 14.2 混叠问题可能导致信息丢失的示例。三个不同的输入进入最大池化层,但我们获得了三个相同的输出。这是一个极端情况,但它展示了基本问题。
这并不好。但这是一个问题吗?我们已经构建了几个似乎工作得很好的 CNN,而且人们已经使用了最大池化几十年。也许这并不是一个大问题,除非我们想要构建斑马检测器。虽然我们的 CNN 可以工作,但混叠问题确实是存在的,并且每次我们在架构中添加另一个池化层时,问题都会变得更糟。通过理解为什么混叠是一个问题,我们可以积累起我们需要了解如何解决问题的知识。
14.1.1 混叠问题损害了平移不变性
为了说明为什么混叠是一个问题,我们将使用 CIFAR-10 数据集,因为与 MNIST 相比,它具有更高的复杂性。CIFAR 图像的高度和宽度为 32 × 32,但我们通过随机裁剪图像选择了一个较小的 24 × 24 像素块。(在获取较小的子图像时,通常的做法是使用随机裁剪进行训练以获得更多样性,并使用中心裁剪进行测试集以获得一致的结果。)这样,我们可以测试我们的卷积神经网络(CNN)在上下或左右方向上最多八像素的平移,以观察图像内容平移对 CNN 预测的影响。池化应该提高平移不变性(即,即使你移动了东西,你也会得到相同的结果),但我们将会展示混叠如何阻止我们获得全部的好处。
注意:在训练略小于原始图像大小的裁剪时,对于 128 × 128 或更大的图像,这是一种相当常见的做法。这样做可以为你的模型输入提供额外的多样性,避免在多个训练周期中多次显示完全相同的图像,并给模型带来更多的现实感(数据很少完美居中)。我们通常不会对如此小的 32 × 32 像素图像这样做,因为内容很少,但我们需要这样做,以便我们有像素可以平移。
下一段代码设置了 CIFAR-10,使用我们描述的子图像。我们在训练期间使用随机的 24 × 24 裁剪,并创建了相同测试集的两个不同版本。一般来说,你的测试应该是确定的——如果你做同样的事情,你希望得到相同的结果。如果用相同的权重运行相同的模型每次都给出不同的测试结果,那么确定模型变化是否带来了任何改进是非常困难的。为了使我们的测试确定,我们的测试加载器采用中心裁剪,这样测试图像每次都是相同的。但我们也想看看一些结果,因为不同的偏移发生时,我们制作了一个第二个版本,它返回原始的 32 × 32 图像:我们可以手动裁剪它们来查看偏移如何改变模型的预测:
B = 128
epochs = 30
train_transform = transforms.Compose( ❶
[
transforms.RandomCrop((24,24)),
transforms.ToTensor(),
])
test_transform = transforms.Compose( ❷
[
transforms.CenterCrop((24,24)),
transforms.ToTensor(),
])
trainset = torchvision.datasets.CIFAR10(root=’./data’, train=True,
➥ download=True, transform=train_transform)
train_loader = torch.utils.data.DataLoader(trainset, batch_size=B,
➥ shuffle=True, num_workers=2)
testset_nocrop = torchvision.datasets.CIFAR10( ❸
➥ root=’./data’, train=False, download=True,
➥ transform=transforms.ToTensor())
testset = torchvision.datasets.CIFAR10(root=’./data’, train=False,
➥ download=True, transform=test_transform)
test_loader = torch.utils.data.DataLoader(testset, ❹
➥ batch_size=B, shuffle=False, num_workers=2)
cifar10_classes = (’plane’, ’car’, ’bird’, ’cat’, ❺
➥ ’deer’, ’dog’, ’frog’, ’horse’, ’ship’, ’truck’)
❶ 训练时的转换:随机裁剪到 PyTorch 张量
❷ 测试时的转换:将中心裁剪到 PyTorch 张量
❸ 一个包含完整 32 × 32 图像的测试集版本,这样我们就可以测试特定的裁剪
❹ 评估期间使用的测试加载器是确定性的中心裁剪。
❺ 将 CIFAR-10 的类别索引映射回其原始名称
让我们看看使用我们的随机裁剪后数据看起来像什么。以下代码从训练集中选择相同的图像四次,然后与类别标签一起绘制。每次,图像都会稍微移动一下,增加额外的复杂性:
f, axarr = plt.subplots(1,4, figsize=(20,10)) ❶
for i in range(4):
x, y = trainset[30] ❷
axarr[i].imshow(x.numpy().transpose(1,2,0)) ❸
axarr[i].text(0.0, 0.5,cifar10_classes[y].upper(), ❹
➥ dict(size=30, color=’black’))
❶ 制作一个 1 × 4 的网格
❷ 从训练集中抓取一个特定的项目(我喜欢飞机)
❸ 重新排序到 NumPy 和 Matplotlib 喜欢的 (W, H, C) 形状,用于图像
❹ 在角落绘制带有类别名称的图表

现在,让我们使用卷积和据说有缺陷的最大池化来训练一个简单的网络。我们在这里只使用两轮最大池化,相当于我们用于斑马示例中相同数量的池化。这足以展示池化在非斑马输入上的问题。一般来说,问题随着更多的池化而变得更加严重。在这段代码和本章的其余部分中,我们添加了第六章中我们信任的 CosineAnnealingLR 调度器。它有助于最大化结果,并允许我们在仅 30 个周期内展示一些通常在训练 100 个周期后才能看到的行为(这并不意味着你不应该在实际生产问题上进行 100 个周期的训练,只是我们可以更快地展示这种行为)。以下是代码:
C = 3 ❶
h = 16 ❷
filter_size = 3
pooling_rounds = 2
def cnnLayer(in_size, out_size, filter_size): ❸
return nn.Sequential(
nn.Conv2d(in_size, out_size, filter_size, padding=filter_size//2),
nn.BatchNorm2d(out_size),
nn.ReLU())
normal_CNN = nn.Sequential( ❹
cnnLayer(C, h, filter_size),
cnnLayer(h, h, filter_size),
nn.MaxPool2d(2), cnnLayer(h, h, filter_size),
cnnLayer(h, h, filter_size),
nn.MaxPool2d(2),
cnnLayer(h, h, filter_size),
cnnLayer(h, h, filter_size),
nn.Flatten(), nn.Linear(h*(24//(2**pooling_rounds))**2, ❺
➥ len(cifar10_classes))
)
loss = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(normal_CNN.parameters()) ❻
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, epochs)
normal_results = train_network(normal_CNN, loss, ❼
➥ train_loader, epochs=epochs, device=device,
➥ test_loader=test_loader, optimizer=optimizer,
➥ lr_schedule=scheduler,
➥ score_funcs={’Accuracy’: accuracy_score})
❶ 输入通道的数量
❷ 隐藏层的通道数
❸ 作为我们多次做过的辅助函数
❹ 一个正常的 CNN,由两个 CNN 层块组成,层块之间有最大池化
❺ 
❻ 设置我们的优化器,带有学习率调度器以最大化性能
❼ 按常规训练我们的模型
我们已经训练了我们的模型,目前看起来没有问题。随着每个训练周期的增加,准确率稳步提高,这是正常且好的。这个数据集比 MNIST 更具挑战性,因此获得 74.35%的准确率是合理的。问题出现在我们查看同一图像的不同版本时:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=normal_results,
➥ label=’Regular’)
[13]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

以下代码从测试集中取一个完整的 32 × 32 图像,并对所有 64 个可能的 24 × 24 子图像进行预测。这是通过将x_crop变量传递到网络中完成的,并且使用prob_y计算正确类别的概率:
test_img_id = 213 ❶
x, y = testset_nocrop[test_img_id] ❷
offset_predictions = [] ❸
normal_CNN = normal_CNN.eval()
for i in range(8): ❹
for j in range(8): ❺
x_crop = x[:,i:i+24, j:j+24].to(device) ❻
with torch.no_grad():
prob_y = F.softmax(normal_CNN( ❼
➥ x_crop.unsqueeze(0)), dim=-1)
➥ .cpu().numpy()[0,y]
offset_predictions.append((x_crop, prob_y)) ❽
❶ 要抓取的测试图像
❷ 获取原始 32 × 32 图像
❸ 保存每个 24 × 24 子图像的预测
❹ 对于上下位移
❺ 对于左右位移
❻ 抓取裁剪的图像
❼ 对图像进行分类并获取正确类别的概率
❽ 保存结果分数
现在我们绘制所有 64 张图像,并在每张图像上方显示模型预测正确类别的概率。从视觉上看,这些图像几乎完全相同,因为它们都是相同原始输入的子图像。我们应该对所有它们得到相似的预测,因为它们本质上都是相同的:
f, axarr = plt.subplots(8,8, figsize=(10,10)) ❶
for i in range(8): ❷
pos = 0 ❸
for x, score in offset_predictions[i*8:][:8]: ❹
axarr[i, pos].imshow(x.cpu().numpy().transpose(1,2,0)) ❺
axarr[i, pos].text(0.0, 0.5, ❻
➥ str(round(score,2)),
➥ dict(size=20, color=’green’))
pos += 1 ❼
❶ 8 × 8 的图像网格
❷ 对于每一行
❸ 跟踪我们访问的具体位移
❹ 抓取下一个八个图像以填充列
❺ 绘制 24 × 24 的子图像
❻ 在左上角打印正确类别的概率
❼ 移动到下一个图像位置

你能看到问题吗?虽然模型总是正确的(这次),但其置信度可以发生剧烈变化,从 34.84%的置信度波动到 99.48%的置信度,认为图像是一辆卡车。这是最大池化的问题:它实际上并不是平移不变的。如果它是,我们会对这些图像中的每一个得到相同的置信度分数。你可以尝试更改test_img_id变量,看看其他测试图像是否也会出现这种情况。现在我们知道最大池化不能提供良好的平移不变性;失败的程度比我们希望的更大。
14.1.2 通过模糊进行反走样
如果我们不仅仅选择最大值,我们可以构建一个更好的池化版本。记得在第四章我们使用手工卷积来模糊输入吗?模糊的效果是混合相邻位置之间的信息。如果我们首先进行一些模糊以混合信息,我们可以在池化中区分那些通常会导致混叠的模式。让我们看看模糊如何帮助我们构建一个更好的池化操作,它可以抵抗混叠问题,并且可以作为最大池化的直接替代:
图 14.2 展示了我们可以如何使用模糊来处理我们在图 14.1 中展示的相同 1D 序列。记住,这些模式最初在使用标准最大池化时都产生了相同的输出。在这种情况下,我们仍然会使用最大池化,但我们将使用 stride=1(带有填充)以确保输出与输入大小相同。这给我们提供了一个新的表示,它返回每个可能局部区域的最大值。

图 14.2 一种帮助减轻混叠的最大池化方法:也称为 反混叠。首先,我们使用步长为 1 的最大池化来获得与输入相同大小的输出。然后,我们应用步长为 2 的模糊内核/卷积,这缩小了输出到所需的大小,并保持了最大池化的行为,但允许我们区分三个输出来自三个不同的输入。这种方法比简单的最大池化捕获更多的信息。
正常的最大池化会取每隔一个的最大值,因为我们使用 2 作为输入来说明我们想要将每个维度减少 2 倍。相反,我们会在组内 平均 最大值以选择最终表示。对于最左边的示例,这不会影响输出。但它确实改变了其他两个示例:中间示例的输出现在有一个黑白图案,对应于白色细胞较多的位置。这使我们能够更好地区分这些相似的模式,并减少出现的混叠问题。
我们需要一个执行模糊的函数。正如我们在第三章关于卷积神经网络中看到的,使用正确的内核进行卷积可以执行模糊。我们应该选择哪一个?Richard Zhang,他展示了如何解决这个问题²,使用了一个略大于池化大小的二项式滤波器。二项式滤波器将大部分权重放在中心,并减少远离项的权重,因此模糊集中在当前位置的项上,而不是远处的案例。对于大小为 k 的 1D 滤波器,第 i 个二项式滤波器值等于

对于 k = 2 到 k = 7,下表显示了滤波器的值。最大的值总是在中间,而值向边缘递减。结果是加权平均值,它将最大的重点放在滤波器当前所在的位置的值上,然后逐渐减少到更远的项。这就是产生模糊效果的原因,这将帮助我们解决混叠问题:

幸运的是,这个模式与所谓的二项分布(在我们除以所有 k 值的总和,使它们加起来为 1.0 之后)相匹配,SciPy 库方便地实现了这个分布。因此,我们可以使用它来实现一个新的BlurLayer。我们为它提供了一个输入选项D,即我们输入数据的维度数(一维、二维或三维),一个kernel_size,它告诉二项滤波器核有多宽,以及一个stride,它控制输入的缩小程度。kernel_size和stride的作用方式与卷积层相同。
下面是代码:
class BlurLayer(nn.Module):
def __init__(self, kernel_size=5, stride=2, D=2):
"""
kernel_size: how wide should the blurring be
stride: how much should the output shrink by
D: how many dimensions in the input. D=1, D=2, or D=3 for tensors of
➥ shapes (B, C, W), (B, C, W, H), (B, C, W, H, Z) respectively.
"""
super(BlurLayer, self).__init__()
base_1d = scipy.stats.binom.pmf(list(range( ❶
➥ kernel_size)), kernel_size, p=0.5)
if D <= 0 or D > 3: ❷
raise Exception() ❸
if D >= 1: z =
base_1d ❹
if D >= 2:
z = base_1d[:,None]*z[None,:] ❺
if D >= 3:
z = base_1d[:,None,None]*z ❻
self.weight = nn.Parameter(torch.tensor(z, ❼
➥ dtype=torch.float32).unsqueeze(0),
➥ requires_grad=False)
self.stride = stride
def forward(self, x):
C = x.size(1) ❽
ks = self.weight.size(0) ❾
if len(self.weight.shape)-1 == 1:
return F.conv1d(x, torch.stack( ⓬
➥ [self.weight]*C), stride=self.stride,
➥ groups=C, padding=ks//self.stride) ❿
elif len(self.weight.shape)-1 == 2:
return F.conv2d(x, torch.stack( ⓬
➥ [self.weight]*C), stride=self.stride,
➥ groups=C, padding=ks//self.stride)
elif len(self.weight.shape)-1 == 3:
return F.conv3d(x, torch.stack( ⓬
➥ [self.weight]*C), stride=self.stride,
➥ groups=C, padding=ks//self.stride)
else:
raise Exception() ⓫
❶ 创建一个 1D 二项分布。这计算了所有 k 值的归一化 filter_i 值。
❷ z 是一个 1D 滤波器。
❸ D 的无效选项
❹ 我们做得很好。
❺ 2D 滤波器可以通过乘以两个 1D 滤波器来创建。
❻ 3D 滤波器可以通过将 2D 版本与 1D 版本相乘来创建。
❼ 应用滤波器是一个卷积,因此我们将滤波器作为此层的参数保存。requires_grad=False,因为我们不希望它改变。
❽ 有多少个通道?
❾ 我们内部滤波器的宽度是多少?
❿ 使用groups参数将单个滤波器应用于每个通道,因为我们没有像正常卷积层那样的多个滤波器。
⓫ 我们永远不应该到达这段代码:如果我们做到了,我们知道我们有一个错误!
⓬ 所有三次调用都是相同的:我们只需要知道要调用哪个卷积函数。
使用这个BlurLayer,我们可以实现我们之前讨论的用于修复最大池化插值问题的策略。首先,让我们尝试在原始斑马图像上应用它,以证明其有效性。我们再次调用max_pool2d,但我们将stride=1设置为不缩小图像。之后,我们创建一个BlurLayer,并将核大小设置为我们要缩小的因子大小或更大。这意味着如果我们想以 z 的因子进行池化,我们的模糊滤波器的kernel_size应该≥ z。然后,将BlurLayer的stride设置为我们要缩小的量:
tmp = F.max_pool2d(img_tensor_big, (shrink_factor,shrink_factor), ❶
➥ stride=1, padding=shrink_factor//2) img_tensor_small_better =
BlurLayer(kernel_size=int(1.5*shrink_factor), ❷
➥ stride=shrink_factor)(tmp.unsqueeze(0))
to_img(img_tensor_small_better.squeeze()) ❸
❶ 以 1 的步长应用最大池化
❷ 模糊最大池化结果
❸ 展示结果

斑马的图像变得更加清晰。丑陋的锯齿状不再覆盖斑马(仍然有一些块,但远没有原始图像中的那么多)。如果我们观察斑马的鬃毛或面部,很难判断条纹图案的密度,但至少看起来很平滑。此外,在此之前,很难区分斑马面部和胸部的图案,以及前腿的图案——块状输出使得很难判断发生了什么。有了抗锯齿,我们可以区分出面部必须具有非常精细的细节级别和密集的图案,而躯体的图案在角度上有更多变化。
值得注意的是背景中的草地和树木。由于原始图像中的树木模糊,两种图像中的树木看起来很相似:它们实际上被预先模糊处理了。前景中的草地看起来与原始池化图像略有不同,这再次是因为我们新的方法是对图像进行抗锯齿处理。
14.1.3 应用抗锯齿池化
既然我们已经证明了最大池化存在问题,我们可以创建一个新的MaxPool2dAA(AA 代表抗锯齿),用作原始nn.MaxPool2d的替代品。第一个参数是我们想要池化的程度,就像原始版本一样。我们还包含一个ratio,它控制模糊滤波器应该比缩小比例大多少。我们将其设置为合理的默认值 1.7 倍更大。这样,如果我们想要池化更大的量,代码将自动选择更大的滤波器尺寸进行模糊:
class MaxPool2dAA(nn.Module):
def __init__(self, kernel_size=2, ratio=1.7):
"""
kernel_size: how much to pool by
ratio: how much larger the blurring filter should be than the
➥ pooling size
"""
super(MaxPool2dAA, self).__init__()
blur_ks = int(ratio*kernel_size) ❶
self.blur = BlurLayer(kernel_size=blur_ks, ❷
➥ stride=kernel_size, D=2)
self.kernel_size = kernel_size ❸
def forward(self, x):
ks = self.kernel_size
tmp = F.max_pool2d(x, ks, stride=1, padding=ks//2) ❹
return self.blur(tmp) ❺
❶ 使用稍大的滤波器进行模糊
❷ 创建模糊核
❸ 存储池化大小
❹ 使用步长=1 进行池化
❺ 模糊结果
接下来,我们可以定义来自我们第一个网络的aaPool_CNN模型,除了我们将每个池化操作替换为我们的新抗锯齿版本。其余的训练代码也是相同的:
aaPool_CNN = nn.Sequential( ❶
cnnLayer(C, h, filter_size),
cnnLayer(h, h, filter_size),
MaxPool2dAA(2), cnnLayer(h, h, filter_size),
cnnLayer(h, h, filter_size),
MaxPool2dAA(2),
cnnLayer(h, h, filter_size),
cnnLayer(h, h, filter_size),
nn.Flatten(),
nn.Linear((24//(2**pooling_rounds))**2*h, len(cifar10_classes))
)
optimizer = torch.optim.AdamW(aaPool_CNN.parameters())
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, epochs)
aaPool_results = train_network(aaPool_CNN, loss, train_loader,
➥ epochs=epochs, device=device, test_loader=test_loader,
➥ optimizer=optimizer, lr_schedule=scheduler,
➥ score_funcs={’Accuracy’: accuracy_score})
❶ 与常规架构相同,但用我们的抗锯齿版本替换池化
当我们查看训练结果时,抗锯齿模型几乎总是优于网络的常规版本。这两个模型具有相同数量的层和相同数量的参数需要学习,因此这种影响完全是由于改变了池化操作:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=normal_results,
➥ label=’Regular’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=aaPool_results,
➥ label=’Anti-Alias Pooling’)
[22]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

新的aaPool_CNN更快地收敛到更好的解决方案:在 30 个 epoch 后达到%准确率。这本身就是一个使用这种方法作为常规池化的替代品的原因。但我们不知道它是否解决了翻译问题。
现在,让我们看看图像移动如何改变我们新模型的预测。以下代码对测试集中的test_img_id执行相同的测试,计算模型对所有 64 种可能的移动的正确类别的置信度。然后我们在 y 轴上绘制正确类别的概率;x 轴显示我们移动了多少像素。理想情况下,我们应该在图表上看到一条垂直的实线,表明模型始终返回相同的结果:
x, y = testset_nocrop[test_img_id] ❶
offset_predictions_aa = [] ❷
aaPool_CNN = aaPool_CNN.eval()
for i in range(8): ❸
for j in range(8): ❹
x_crop = x[:,i:i+24, j:j+24].to(device) ❺
with torch.no_grad(): prob_y =
F.softmax(aaPool_CNN( ❻
➥ x_crop.unsqueeze(0)), dim=-1)
➥ .cpu().numpy()[0,y]
offset_predictions_aa.append((x_crop, prob_y)) ❼
sns.lineplot(x=list(range(8*8)), y=[val for img,val in offset_predictions],
➥ label=’Regular’) ax =
sns.lineplot(x=list(range(8*8)), y=[val for img,val in offset_predictions_aa],
➥ label=’Anti-Alias Pooling’) ax.set(xlabel=’Pixel shifts’,
➥ ylabel=’Predicted probability of correct class’)
[23]: [Text(0.5, 0, 'Pixel shifts'), Text(0, 0.5, 'Predicted probability of correct class')]
❶ 获取原始 32 × 32 图像
❷ 保存每个 24 × 24 子图像的预测
❸ 用于上下移动
❹ 用于左右移动
❺ 获取裁剪的图像
❻ 对图像进行分类并获取正确类别的概率
❽ 保存结果分数

在这个图中,aaPool_CNN表现得更好。虽然它并没有完美地解决混叠问题,但返回的预测比原始模型更一致。如果你尝试更改test_img_id,你应该会看到这种情况通常如此,但并非总是如此。如果我们训练更多的周期,比如说 100 个,aaPool_CNN的预测一致性将继续增加,而原始 CNN 将始终受到混叠问题的困扰。
注意:当你解决像平移不变性这样的具体问题时,结果有时可能会以不太直观的方式改善。也许原始模型在 60%到 95%的概率之间波动,但新模型稳定地返回正确类别的 40%概率。这意味着原始模型在该样本上更准确,但新模型更一致。这种情况可能会发生,但我们相信,更高的一致性和对平移的鲁棒性应该与更好的结果大多数时候相关。
当我们使用步长≥2 的卷积或平均池化时,这些混叠问题也可能发生。可以使用BlurLayer替换nn.AvgPool2d层来减少影响。步长卷积(即stride=s)的修复方式与修复最大池化相同:将卷积的原始步长替换为stride=1,应用任何归一化和激活函数,然后以kernel_size=s 的BlurLayer结束。
这种修复最大池化的方法非常新颖,是在 2019 年发明并在顶级会议的论文集中发表的。如果你已经读到这儿并且感觉你理解了,恭喜你——你现在可以理解和欣赏前沿的深度学习研究了!
14.2 改进的残差块
接下来的两种技术需要更大的网络和问题才能看到它们的全部好处。我们仍然使用 CIFAR-10,并可以展示它们的一些改进,但它们在更大和更具挑战性的问题上表现得更加成功。我们将从改进的残差块开始,这是我们在第六章首次了解到的。残差策略通常看起来像以下方程
h = ReLU(x+F(x))
其中 F(⋅) 代表一系列重复两次的卷积、归一化和激活函数的小序列。这会在网络中创建跳跃连接,使得学习具有更多层的深层网络更容易。这些深层网络也倾向于更快地收敛到更好的解决方案。
剩余连接并没有什么特别的问题。正如我们之前提到的,它们非常有效,并且已经被应用于许多其他架构(例如 U-Net 和 transformers)。但是,一些小的调整可以在结果上提供一致的改进,并且具有很好的直观逻辑。
一种称为 ReZero³ 的技术可以进一步改进残差方法,使其收敛得更快,并得到更好的解决方案。这种方法简单得惊人,并且很容易集成到你的残差块定义中。其想法是路径 F(x) 是有噪声的,可能会给我们的计算增加不必要的复杂性,至少在早期是这样。我们更希望网络从简单开始,根据需要逐步引入复杂性来解决手头的问题。把它想象成一次构建一个解决方案,而不是试图一次性构建一切。
这是通过以下方程实现的,其中 α 是网络学习的参数,但我们初始化 α = 0 作为训练的开始:
h = x + α ⋅ ReLU(F(x))
因为 α = 0,所以在开始时我们得到简化的网络 h = x。这并没有对输入造成任何影响。如果我们的所有层都像这样,那么它们可能根本不存在——如果我们移除它们,我们仍然会得到相同的答案,因为没有任何东西被改变。但在梯度下降过程中,α 的值会改变,突然 ReLU(F(x)) 的内部项开始激活并开始对解决方案做出贡献。网络可以选择在这个项中对非线性操作给予多少重视,或者通过改变 α(正或负)的大小来关注原始值 x。它越大,网络就越使用 F(⋅) 中的计算。
14.2.1 有效深度
ReZero 的好处是微妙的,所以在我们实现它之前,让我们先注释方程并更详细地解释它。我们有 ReZero 残差方程如下

我们在训练开始时将 α = 0 设置为初始值,而不是随机值,这是这里的关键。如果 α = 0,我们得到 h = x,这是最简单的可能函数,因为它 什么也不做。这有什么帮助呢?让我们研究 ReZero 在图 14.3 中创建的架构,以了解这是如何成为一项好处的。乍一看,它似乎与常规残差连接非常相似。

图 14.3 ReZero 网络两层架构。顶部的快捷路径允许轻松的梯度流向层,底部的长路径做所有重工作。我们不是简单地添加长路径中子网络的输出结果,而是在添加之前将它们乘以 α。
其优雅之处在于它一开始并没有做什么。因为 α 值一开始被设置为 α = 0,子网络都实际上消失了,留下我们一个 线性 架构,如图 14.4 所示。这是我们能够创建的最简单的架构之一,这意味着初始学习发生得非常快,因为有效参数的总数被最小化了。

图 14.4 ReZero 在训练开始时两个网络层的表现。顶部的快捷路径形成一个单一的线性层(x^((1)) = x^((2))),因为没有任何东西被添加到它们中。长路径中的子网络实际上已经不存在了,因为它们的贡献被乘以α = 0。
但随着训练的进行,梯度下降过程可能会改变α的值。一旦α被改变,子网络F(x)就开始做出贡献。因此,这种方法开始时是一个线性模型(所有α = 0 且不起作用),随着时间的推移逐渐变得更加复杂。影响如图 14.5 所示。

图 14.5 ReZero 架构,展示了几个训练周期的训练情况。随着模型的训练,它可以学会独立地改变每个α,慢慢地将隐藏层重新添加到网络中。这样,它逐渐学会使用深度并基于数据选择它希望使用的总有效深度。在这种情况下,ReZero 学习到它只需要它的一个层;对于第二个,它学会了保持α ≈ 0。
经过几个训练周期后,我们得到了最终的有效结构。每个残差块的α值可能已经学会了不同的值,决定何时开始对解决方案做出贡献。这也意味着α的值可能仍然接近零,因此它实际上不存在。ReZero 让我们的网络自己决定使用多少层残差块以及何时使用它们。它可以选择不将任何α值设置为接近零,这也是可以的。有时,通过从小开始并随着时间的推移变得更加复杂,更容易学会如何使用所有层,这正是 ReZero 所允许的。
14.2.2 实现 ReZero
那就是 ReZero 技巧的秘密!原始的发现者有更多的数学理论来展示它为什么有帮助,但我们可以专注于实现它。我们改进的残差块将这个变化作为一个可选选项来选择。我们将函数F(⋅)分成它自己的nn.Sequential模块,如果ReZero标志设置为True,我们将self.alpha设置为网络的一个可学习的Parameter。就像我们学到的原始残差连接一样,当通道数或步长发生变化时,我们也使用一个shortcut对象,这样我们就可以使用 1 × 1 卷积来使形状匹配:
class ResidualBlock(nn.Module):
def __init__(self, in_channels, channels, kernel_size=3, stride=1,
➥ activation=nn.ReLU(), ReZero=True):
"""
in_channels: how many channels come into this residual block
channels: the number of output channels for this residual block
kernel_size: the size of the filters to use in this residual block
stride: the stride of the convolutions in this block. Larger
➥ strides will shrink the output.
activation: what activation function to use
ReZero: whether or not ReZero style initializations should be used.
"""
super().__init__()
self.activation = activation
pad = (kernel_size-1)//2 ❶
filter_size = (kernel_size,kernel_size)
self.F = nn.Sequential( ❷
nn.Conv2d(in_channels, channels, filter_size,
➥ padding=pad, bias=False),
nn.BatchNorm2d(channels), activation,
nn.Conv2d(channels, channels, filter_size, padding=pad,
➥ stride=stride, bias=False),
nn.BatchNorm2d(channels),
)
self.alpha = 1.0 ❸
if ReZero:
self.alpha = nn.Parameter(torch.tensor([0.0]),
➥ requires_grad=True)
self.shortcut = nn.Identity() ❹
if in_channels != channels or stride != 1:
self.shortcut = nn.Sequential(
nn.Conv2d(in_channels, channels, 1, padding=0,
➥ stride=stride, bias=False),
nn.BatchNorm2d(channels),
)
def forward(self, x):
f_x = self.F(x) ❺
x = self.shortcut(x)
if isinstance(self.alpha,nn.Parameter): ❻
return x + self.alpha * self.activation(f_x)
else: ❼
return self.activation(x + f_x)
❶ 如何填充以保持 W/H 不变
❸ 网络的复杂分支,应用了两轮层
❸ 如果我们不使用 ReZero,α是一个浮点数,如果我们使用 ReZero,它是一个参数
❹ Shortcut 是恒等函数。它将输入作为输出返回,除非由于通道数或步长的变化,F 的输出将具有不同的形状:在这种情况下,我们将快捷方式变成一个 1 × 1 卷积,作为一个投影来改变其形状。
❽ 根据需要计算 F(x)和 x 的结果
❻ ReZero
❼ 正常残差块
这只是一个简单的更改,现在让我们试试。首先,我们为 CIFAR-10 训练一个相对较深的网络。以下模块包含 28 个ResidualBlock,每个ResidualBlock包含 2 层卷积,总共 56 层卷积。由于需要使示例运行得更快以便你可以更改它们,所以这比本书中大多数网络都要深。但 ReZero 的优势在于能够学习极其深的网络。如果你想的话,可以将这个网络扩展到数千个隐藏层,它仍然可以学习。
这里是代码:
resnetReZero_cifar10 = nn.Sequential( ❶
ResidualBlock(C, h, ReZero=True),
*[ResidualBlock(h, h, ReZero=True) for _ in range(6)],
ResidualBlock(h, 2*h, ReZero=True, stride=2), ❷
*[ResidualBlock(2*h, 2*h, ReZero=True) for _ in range(6)],
ResidualBlock(2*h, 4*h, ReZero=True, stride=2),
*[ResidualBlock(4*h, 4*h, ReZero=True) for _ in range(6)],
ResidualBlock(4*h, 4*h, ReZero=True, stride=2),
*[ResidualBlock(4*h, 4*h, ReZero=True) for _ in range(6)],
nn.AdaptiveAvgPool2d(1),
nn.Flatten(),
nn.Linear(4*h, len(cifar10_classes)), ❸
)
optimizer = torch.optim.AdamW(resnetReZero_cifar10.parameters())
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, epochs)
resnetReZero_results = train_network(resnetReZero_cifar10, loss,
➥ train_loader, epochs=epochs, device=device, test_loader=test_loader,
➥ optimizer=optimizer, lr_schedule=scheduler,
➥ score_funcs={’Accuracy’: accuracy_score})
❶ 使用 ReZero 方法训练新的残差网络
❷ 我们不使用池化,而是使用步长卷积层。这样可以保持跳跃连接完整,而不需要额外的代码。
❸ 我们使用了自适应池化到 1 × 1,这使得计算最终层的输入数量更容易。
这训练了我们的 ReZero 模型。接下来,我们重复相同的网络,但将ReZero=False设置为创建一个更标准的残差网络以进行比较。这两个网络之间的唯一区别是α作为参数的简单乘法,起始值为 0。由于代码除了ReZero标志外都是相同的,我们将跳过代码。
现在,我们可以绘制结果。两种残差网络的表现都显著优于简单方法,这是我们所预期的。你可能会发现 ReZero 在第一个 epoch 的表现不如其他选项:它的初始行为类似于线性模型,因为所有子网络都被α = 0 所阻塞。但随着训练的进行,ReZero 开始更快地收敛到解决方案,在大数据集或 100+层的情况下,通常只需要一半的 epoch。由于我们的网络仍然相对较小,所以我们没有看到可能出现的那么大的差异。但当我们使这个网络更深,参数更多时,两种方法之间的差异就更加显著。
这里是代码:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=normal_results,
➥ label=’Regular’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=resnet_results,
➥ label=’ResNet’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=resnetReZero_results,
➥ label=’ResNet ReZero’)
[27]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

ReZero 方法可以用于超越残差网络风格的架构。原始论文的作者在github.com/majumderb/rezero上分享了代码,你可以使用他们的 ReZero 增强变压器来提高基于变压器的模型的训练。这对于变压器来说尤其有价值,因为训练变压器需要很长时间,并且通常需要更复杂的技巧来调整学习率以最大化其性能。ReZero 技术有助于最小化这种复杂性。我个人在 ReZero 方法上取得了很大的成功,这是一个简单、易于添加的方法,几乎不需要努力就能使事情变得更快、更好。
14.3 MixUp 训练减少过拟合
我们学习的最后一种方法有助于减轻过拟合。假设我们有两个输入,x[i]和x[j],以及相应的标签y[i]和y[j]。如果f(⋅)是我们的神经网络,ℓ是我们的损失函数,我们通常计算我们的损失如下:

到目前为止,这已经为我们服务得很好了,但有时模型会学习过度拟合数据。大多数正则化的方法都涉及对网络本身应用惩罚。例如,Dropout 通过随机强制某些权重变为零来改变网络的权重,这样网络就不能依赖于特定的神经元来做出决策。
MixUp⁴采取不同的方法。我们不是改变或惩罚网络,而是改变损失函数和输入。这样,我们可以改变激励(模型总是试图最小化损失)而不是削弱模型。给定两个输入x[i]和x[j],我们将它们混合成一个新的输入
和一个新的标签 ỹ。我们通过取一个随机值λ ∈ [0,1],并在这两个之间取加权平均值来实现这一点:

以这种方式写方程一开始可能感觉有些奇怪。我们平均两个图像并将平均图像输入到网络中?如果一张图像是 75%的猫和 25%的斑马,答案应该是 75%的猫和 25%的斑马,但如何处理平均标签呢?在实践中,我们做的是数学上等效的事情,并使用新的输入
和两个标签y[i]和y[j]。我们计算我们的损失如下:

因此,我们有一个输入
,它是y[i]和y[j]之间的加权平均值,我们取两个可能预测之间的加权损失。这让我们对如何在实践中实现 MixUp 损失有了直观的理解。
让我们通过图 14.6 所示的示例来深入了解这个问题。我们有 logits:神经网络在 softmax 函数将其转换为概率之前的输出。然后我们有从这些 logits 计算出的 softmax 结果。如果正确的类别是类别 0,其 logit 值不需要比其他的大很多就能获得高置信度。但 softmax永远不会分配 100%的概率,所以数据点的损失永远不会降到零。因此,模型始终会得到一个小但一致的激励,推动类别 0 的 logit更高,以推动正确类别的分数不断提高。这可能会将模型推到不切实际的高分,以获得 softmax 结果的小幅增加。

图 14.6 正则化损失如何导致过拟合的示例,鼓励模型做出越来越自信的预测,因为这将降低损失。分数永远不会达到零,因为正确的类别永远不会达到 1.0,所以始终有激励推动 logit 更高。
MixUp 反而帮助模型学习调整其预测,使其大小仅与有理由相信的程度相匹配——而不是像图 14.7 中所示的那样,全押在它相信的事情上。这就像一个允许用户对事件下注的赌博网站。你不希望因为事件更有可能发生就提供 1 百万比 1 的赔率——你希望赔率反映你对正确性的现实假设。

图 14.7 在 MixUp 中,没有过度预测的相同激励。相反,模型试图根据选择两个不同类别的多少来达到一个目标百分比。MixUp 混合标签和输入,因此每个类别的信号与 MixUp 百分比λ成比例。
如果我们有λ%的混合,模型将需要学习具体预测λ%和(1−λ)%,以最大化其得分(最小化损失)。因此,模型没有理由在任何预测上全押。
14.3.1 选择混合率
剩下的唯一要决定的事情是如何选择λ。它需要位于[0,1]的范围内,以便作为一个平均值有意义,但我们并不一定想要在这个范围内均匀地选择λ。当我们实际使用我们的网络f(⋅)时,图像将不带混合地进来。它们将是正常图像,因此我们希望用很多λ ≈ 0 和λ ≈ 1 来训练,因为这两种情况对应于没有混合。如果我们从[0,1]中均匀地采样λ,这种情况不太可能发生。λ接近极端值是可以接受的,因为它仍然会限制整体行为并惩罚过度自信的预测。
我们使用所谓的beta 分布。beta 分布通常有两个参数,a 和 b,我们用λ ∼ Beta(a,b)表示从这个分布中抽取的样本。如果a = b,则 beta 分布是对称的。如果值是a = b < 1,则分布呈 U 形,使得λ ≈ 0 和λ ≈ 1 的值更可能,而λ ≈ 1/2 的值更不可能。这正是我们想要的,因此该模型在测试时对干净输入的预测做得很好,但训练时被训练得对稍微有噪声的值和偶尔非常噪声的值更加鲁棒。
Hongyi Zhang 等人撰写的原始论文建议使用α ∈ [0.1,0.4]的值,并将两个 beta 参数都设置为这个值(a = b = α)。以下来自 SciPy 的代码片段显示了这种分布的外观。x 轴是λ的值,y 轴是概率密度函数(PDF),给出了每个λ值的相对概率:
range_01 = np.arange(100)[1:]/100 ❶
for alpha in [0.1, 0.2, 0.3, 0.4]: ❷
plt.plot(range_01, scipy.stats.beta(alpha, alpha). ❸
pdf(range_01), lw=2, ls=’-’, alpha=0.5,
label=r’$α='+str(alpha)+"$")
plt.xlabel(r"λ ∼ Beta(*α*, *α*)")
plt.ylabel(r"PDF")
plt.legend()
[28]: <matplotlib.legend.Legend at 0x7fb37a70f590>
❶ 沿 x 轴绘制 100 步以进行绘图
❷ 四个超参数值以进行演示
❸ 绘制每个选项的 beta 分布

14.3.2 实现 MixUp
现在我们已经了解了 MixUp 的数学形式以及我们如何采样λ。让我们谈谈实现策略。首先,我们需要损失函数,我们将称之为 ℓ[M]。实现此函数需要四件事:我们网络处理混合数据
(即 ŷ = f(
)) 的预测 ŷ 将是第一个输入。我们的真实标签 y 由其他三个组成部分组成:原始标签 y[i],y[j] 和混合值 λ。使用原始损失函数 ℓ(⋅,⋅),我们可以将其写成

使用这种方法,我们可以将 ŷ 视为我们正常的输出/预测,y[i],y[j],λ 作为标签 元组。因此,我们定义一个 MixupLoss 函数,它接受 ŷ 和 y。如果 y 是 Python 元组,我们知道我们需要计算 MixUp 损失 ℓ[M]。如果是正常张量,我们正在计算原始损失函数 ℓ:
class MixupLoss(nn.Module):
def __init__(self, base_loss=nn.CrossEntropyLoss()):
"""
base_loss: the original loss function to use as a sub-component of
➥ Mixup, or to use at test time to see how well we are doing.
"""
super(MixupLoss, self).__init__()
self.loss = base_loss
def forward(self, y_hat, y):
if isinstance(y, tuple): ❶
if len(y) != 3:
raise Exception() ❷
y_i, y_j, lambda_ = y ❸
return lambda_ * self.loss(y_hat, y_i) +
➥ (1 - lambda_) * self.loss(y_hat, y_j)
return self.loss(y_hat, y) ❹
❶ 我们应该进行 MixUp 操作!
❷ 应该有一个包含 y_i,y_j 和 lambda 的元组!
❸ 将元组分解为其组成部分
❹ 否则,y 是一个正常张量和一组正常标签!按照正常方式计算。
现在我们有一个可以接受正常批次数据并给出正常损失的损失函数。但在训练时,我们需要提供一个包含 y[i],y[j],λ 的元组来触发 MixUp 损失,并且我们需要以某种方式创建一个混合输入
的批次。特别是,我们需要获取一个包含 B 个数据点 x[1],x[2],…,x[B] 以及标签 y[1],y[2],…,y[B] 的批次,并将它们与一个新的数据批次混合以创建
[1],
[2],…,
[B],这似乎需要对我们加载器进行复杂的修改。
而不是处理新的一批数据,我们将仅对一批数据进行洗牌,并将洗牌后的版本视为新的一批。如何组织这一过程的高级总结如图 14.8 所示。如果我们只需要一批数据,我们可以修改collate_fn来修改批次。这种洗牌排序的常见数学表示是 π(i) = i′,其中 π(⋅) 是一个表示数据 排列(即随机洗牌)的函数。

图 14.8 实现 MixUp 训练的过程。阴影区域是我们所做的修改。其他所有内容都与训练和实现任何其他神经网络相同。
例如,假设我们有一个包含 B = 4 个项目的批次。值

可能给出以下可能的 3! = 6 种排序:

函数 torch.randperm(B) 将给我们一个长度为 B 的数组,它是一个随机排列 π(⋅)。使用每个批次的随机重新排序,我们可以使用 DataLoader 的 collate_fn 来改变训练数据的批次,使用相同数据的随机分组。数据点被配对给自己几乎是不可能的,如果是这样,这个过程将暂时降级为正常训练。因此,我们得到一个新的训练数据批次:

一旦批次被改变并包含一组混合的训练实例
,我们可以为标签返回一个元组 y:

现在,我们可以定义 MixupCollator 作为我们的特殊 collate_fn。它接受一个 collate_fn,这是用户可以指定的方法,用于改变批次的创建方式。MixupCollator 还负责为每个批次采样一个新的 λ 值,因此它需要将变量 α 作为第二个参数,以控制混合的激进程度。我们提供了合理的默认值,以常规方式采样批次,并且混合的激进程度处于中等范围:
from torch.utils.data.dataloader import default_collate
class MixupCollator(object):
def __init__(self, alpha=0.25, base_collate=default_collate):
"""
alpha: how aggressive the data mixing is: recommended to be
in [0.1, 0.4], but could be in [0, 1]
base_collate: how to take a list of datapoints and convert them
into one larger batch. By default uses the same default as
PyTorch’s DataLoader class.
"""
self.alpha = alpha
self.base_collate = base_collate
def __call__(self, batch):
x, y = self.base_collate(batch) ❶
lambda_ = np.random.beta(self.alpha, self.alpha) ❷
B = x.size(0)
shuffled_order = torch.randperm(B) ❸
x_tilde = lambda_ * x + (1 - lambda_) * x[shuffled_order, :] ❹
y_i, y_j = y, y[shuffled_order] ❺
return x_tilde, (y_i, y_j, lambda_) ❻
❶ 批次以列表的形式进入。我们将它转换成实际的数据批次。
❷ 样本 lambda 的值。注意末尾的“_”,因为 lambda 是 Python 中的一个关键字。
❸ 创建一个随机打乱的顺序 pi
❹ 计算输入数据的混合版本
❺ 获取标签
❻ 返回一个包含两个项目的元组:输入数据和 MixupLoss 需要的另一个包含三个项目的元组
使用这两个类,我们可以轻松地将 MixUp 方法集成到几乎任何模型中。需要构建一个新的训练加载器,使用 collate_fn=MixupCollator(),并且我们需要将 MixupLoss() 传递给我们的 train_network 函数作为使用的损失函数。其余的代码与之前一样,但增加了 MixUp 训练:
train_loader_mixup = torch.utils.data.DataLoader(trainset, batch_size=B, ❶
➥ num_workers=2, shuffle=True, collate_fn=MixupCollator())
resnetReZero_cifar10.apply(weight_reset) ❷
optimizer = torch.optim.AdamW( ❸
➥ resnetReZero_cifar10.parameters())
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, epochs)
resnetReZero_mixup_results = train_network(resnetReZero_cifar10,
➥ MixupLoss(loss), train_loader_mixup, epochs=epochs, ❹
➥ device=device, test_loader=test_loader,
➥ optimizer=optimizer, lr_schedule=scheduler,
➥ score_funcs={’Accuracy’: accuracy_score})
❶ 将数据加载器替换为使用我们的 MixupCollator 的新加载器
❷ 由于懒惰,也重置了权重
❸ 优化器和调度器保持不变。
❹ 使用 MixUp 训练时,将我们的正常损失函数包装在新的 MixupLoss 中
如果我们绘制准确率图,我们会看到我们的 ResNet ReZero 模型结合 MixUp 进一步提高了收敛率,最终准确率为 88.12%:
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=normal_results,
➥ label=’Regular’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=resnet_results,
➥ label=’ResNet’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=resnetReZero_results,
➥ label=’ResNet ReZero’)
sns.lineplot(x=’epoch’, y=’test Accuracy’, data=resnetReZero_mixup_results,
➥ label=’ResNet ReZero + MixUp’)
[32]: <AxesSubplot:xlabel='epoch', ylabel='test Accuracy'>

将结果视为时间的函数,我们看到 ReZero 和 MixUp 方法只是运行时间的增量增加。仅使用 ResNet 方法更深的模型是大部分计算成本来源。因此,如果您正在构建更深的网络,这两种方法都很容易添加,并且可以提高模型的准确性。
警告:本章我们学到的前两种方法几乎总是值得放心使用。MixUp 有时会损害准确性,但通常是一个不错的改进,所以值得一试一次新的架构,一次带 MixUp,一次不带 MixUp,看看它对你的问题表现如何。MixUp 在应用于像 transformers 和 RNNs 这样的东西时也更为复杂,尽管它的扩展会有帮助(代码可能有些丑陋)。为了简单起见,我现在坚持使用标准的 MixUp,但我知道如果需要,我可以查看它的扩展。
这些方法的唯一缺点是,它们的益处通常只有在处理更大的模型或我们由于网络的复杂性而已经面临过拟合风险时才能看到。如果它们损害了你的准确性,那么很可能你需要使你的网络更深,或者加宽层,以便它们能够有所帮助。
练习
在 Manning 在线平台 Inside Deep Learning Exercises 上分享和讨论你的解决方案(liveproject.manning.com/project/945)。一旦你提交了自己的答案,你将能够看到其他读者提交的解决方案,并看到作者认为哪些是最好的。
-
我们查看了一个特定示例中 CIFAR-10 图像翻译(移动)概率的变化。相反,计算 CIFAR-10 测试集中所有类别概率变化的中间偏差,对于原始 CNN 和抗锯齿版本(即,对于每张图像,计算 64 个概率值及其概率的标准偏差。为每张图像计算该值,并取中间值)。结果是否显示了你预期的,即抗锯齿减少了概率变化的中间值?
-
根据本章描述,实现具有抗锯齿版本的步长卷积和平均池化的版本。然后实现新的卷积网络,用标准步长卷积和抗锯齿版本的步长卷积以及平均池化来替换最大池化。使用与
MaxPool2dAA相同的 CIFAR-10 示例自行测试它们。 -
实现第六章中瓶颈类的 ReZero 版本以及一个全连接残差块的 ReZero 版本。在 CIFAR-100 上对它们进行测试,看看它们与非 ReZero 版本相比如何。
-
我们的 ReZero 残差网络在其定义的每个部分之间有 6 个残差层块。尝试训练一个具有 18 个块的 ReZero 和一个普通残差块,看看每种方法的表现如何变化?
-
为 ReZero 残差网络实现具有抗锯齿版本的步长卷积,并在 CIFAR-10 上对其进行训练。它如何影响准确性和收敛性?
-
挑战性: 论文“Manifold mixup: better representations by interpolating hidden states”⁵描述了 MixUp 的一个改进版本,该版本在网络的随机选择隐藏层而不是输入层进行混合。尝试自己实现它,并在 CIFAR-10 和 CIFAR-100 上测试。提示:通过定义一个自定义的
Module为网络,并使用ModuleList来存储候选混合的层序列(即,你不需要每一个可能的隐藏层都是选项)。
摘要
-
当使用简单的池化时会发生混叠。它干扰了模型的平移不变性。
-
结合模糊操作有助于缓解混叠问题,这反过来又有助于提高我们模型的准确性。
-
在残差块的子网络中添加一种门控α,给模型一个在时间上引入复杂性的机会,从而提高收敛速度,并使模型收敛到更精确的解。
-
当交叉熵和其他损失对预测类别过于自信时,它们可能会过拟合。
-
MixUp 通过惩罚过自信的预测来对抗它们,迫使模型学习如何权衡其赌注。
-
反走样池化、ReZero 残差和 MixUp 非常有用,可以一起使用,但它们的优势在大型数据集和训练 100+个 epoch 时最为明显。
¹ 图片由 Sajjad Fazel 提供:commons.wikimedia.org/wiki/User:SajjadF。↩
² R. Zhang,“使卷积网络再次具有平移不变性,”在第 36 届国际机器学习会议论文集中,第 97 卷,第 7324–7334 页,2019。↩
³ T. Bachlechner 等人,“ReZero is all you need: fast convergence at large depth," arxiv.org/abs/2003.04887,2020。↩
⁴ H. Zhang 等人,“Mixup: beyond empirical risk minimization," ICLR,2018。↩
⁵ V. Verma 等人,“第 36 届国际机器学习会议论文集”,第 97 卷,第 6438–6447 页,2019。↩
附录。设置 Colab
本书中的所有代码都是使用 Python 3 作为 Jupyter 笔记本编写的。虽然你可以使用 Anaconda(www.anaconda.com/products/individual)在你的电脑上自行设置 Jupyter 和软件库,但本书采用的方法是使用 Google 的 Colab 服务(colab.research.google.com)。Google Colab 预先安装了所有需要的软件库,并提供有限期限内的免费图形处理单元(GPU)。这足以让你开始学习,而无需投资数百或数千美元的计算机配置。本附录将指导你如何设置 Google Colab;我鼓励你获取专业版,目前每月只需几美元。这不是一个赞助推荐——我不会得到任何回扣,我从未为 Google 工作。这只是我喜欢的一个产品,我认为它使深度学习更容易获得。
A.1 创建 Colab 会话
前往colab.research.google.com/notebooks/intro.ipynb,它提供了一个默认的入门笔记本,如图 A.1 所示。点击右上角的登录按钮登录到 Colab,这样你就可以保存你的工作并使用 GPU。

图 A.1 当你第一次访问 Colab 时应该看到的第一个屏幕。登录按钮位于右上角。
点击登录后,你会看到 Google 的标准登录屏幕(图 A.2)。如果你已经有了一个 Google 账户,你可以用那个账户登录;否则,点击创建账户按钮,系统会引导你完成账户创建过程。

图 A.2 Colab 使用的是 Google 的标准登录页面。如果你有一个当前的 Google 账户,请使用它;否则,点击创建账户按钮,系统会引导你创建账户。
登录后,你会回到图 A.1 中的第一个屏幕。这个 Colab 显示的第一个笔记本也是一个关于 Colab 和 Jupyter 的迷你教程,如果你不熟悉它们的话。
添加 GPU
现在我们来谈谈免费的 GPU。Colab 不保证你一定能获得 GPU,默认情况下也不提供 GPU。你必须明确请求将 GPU 附加到你的笔记本上。要使用 Colab 访问 GPU,请从网页顶部的菜单中选择运行 > 更改运行类型(图 A.3)。

图 A.3 要将 GPU 附加到你的笔记本上,请选择运行 > 更改运行类型。
在下一个窗口中,硬件加速器列表提供了以下选项:None、GPU 和 TPU,如图 A.4 所示。None 选项表示仅 CPU,这是默认设置。GPU 和 TPU 在现有的 CPU 之上增加了资源。GPU 和 TPU 都被称为协处理器,并提供更专业的功能。目前,PyTorch 中的 TPU 支持非常新,所以我建议选择 GPU 选项(正如我在整本书中所做的那样)。这样做将重新启动笔记本(您将丢失所有已完成的工作,因此请尽早这样做),并且应该返回一个带有 GPU 并准备使用的笔记本。

图 A.4 None 选项表示仅 CPU。其他两个选项也包含 CPU。选择 GPU 选项以将 GPU 添加到您的笔记本中。
GPU 是昂贵且需求量大的商品,因此您并不能无限地访问这个 GPU。没有公开的公式,但当前对 GPU 的需求以及您个人使用 GPU 的程度将影响您获得的 GPU 质量。
如我之前提到的,虽然这样做不是必需的,但我鼓励您注册 Colab Pro 以简化您的生活(colab.research.google.com/signup)。专业版 Colab 并没有从根本上改变 Colab 的工作方式,但它为您提供了更高的 GPU 访问优先级和更长的运行时间。Colab Pro 每月仅需 10 美元:对于便携式 GPU 访问来说,这是一个相当不错的交易,而且比购买新硬件便宜得多。根据我的经验,我发现 Colab Pro 总是意味着我可以无问题地获得 GPU。
测试您的 GPU
在笔记本设置窗口中点击“保存”按钮,您将在 Colab 会话中访问到 Nvidia GPU!您可以通过运行以下命令来双重检查:
!nvidia-smi
! 是 Jupyter 笔记本的一个特殊功能。它不是运行 Python 代码,而是在主机计算机的命令行上运行代码。nvidia-smi 是一个程序,它提供了您计算机上所有 GPU 的信息以及它们的当前利用率。我运行了这个命令,并得到了图 A.5 中所示的输出,表明为我的使用分配了一个带有 7.6 GB RAM 的 Tesla P4。您运行时可能会得到不同的结果,这是正常的。没有精确控制是我们为免费 GPU 支付的代价。

图 A.5 nvidia-smi 命令的示例输出。如果您看到类似的内容,那么一切准备就绪,可以使用了。
这就是你需要做的,以便准备好在 Colab 中运行代码。它预装了大多数机器学习库,并已准备好使用。例如,这本书使用 seaborn 和 Matplotlib 来轻松绘制/可视化我们的结果,使用 NumPy 进行初始数据加载和处理数组,以及使用 pandas 来检查我们的结果。tqdm 库是另一个有用的实用工具,它提供了带有估计完成时间的进度条,如图 A.6 所示。

图 A.6 本书中的训练代码为每次调用创建这样的进度条。这很有用,因为训练神经网络可能需要一段时间,你可以判断你还有多少时间。五分钟?来杯咖啡。一小时?阅读一本关于一个戴帽子和酷炫装扮的人的引人入胜的书籍的另一章,他分享了一些学习智慧的小贴士。
我们可以简单地导入这些库,它们就准备好了。有时,你可能需要安装一个包,这可以通过使用相同的 ! 小技巧的标准 pip 命令来完成。当需要时,书中会显示该命令。我提到的所有库都是机器学习从业者工具箱中的常用工具,在深入本书之前,你应该至少对它们有所了解。







浙公网安备 33010602011771号