TensorFlow-实战(全)

TensorFlow 实战(全)

原文:zh.annas-archive.org/md5/63f1015b3af62117a4a51b25a6d19428

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

前言

如今,很难找到一个不受机器学习驱动或至少不受其影响的真实世界系统或产品。机器学习在提升用户体验、削减成本和增加公司收益方面发挥着至关重要的作用。TensorFlow 是一个机器学习框架,使开发人员能够快速为各种定制用例开发机器学习解决方案。如果您是一名机器学习实践者,甚至是一名触及机器学习系统的软件工程师,了解 TensorFlow 是值得的,因为它被数百万开发人员用于构建机器学习解决方案。

本书将带领您进行一次信息丰富的旅程,涵盖了大多数流行的机器学习任务以及最先进的模型。您将了解图像分类和分割,以及各种自然语言处理任务,例如语言建模和情感分析。在此过程中,我们将努力保持我们的代码生产质量。这意味着我们将探索我们可以标准化代码和模型的方法,例如构建强大的端到端数据管道,可以处理常见的数据类型,例如图像和文本。我们还将注意其他重要的方面,例如模型的可解释性、类似任务的当前最先进性能等等。我们以 TensorFlow 如何用于构建生产级机器学习管道以为开发人员提供流畅的操作体验结束了本书。

TensorFlow 有良好的文档覆盖范围(尽管某些主题可能需要更好的文档),并且免费提供。也许你会想知道,为什么你还需要这本书。TensorFlow 已经发展成一个复杂的生态系统,其中有许多组成部分。对于初学者来说,很容易在文档中迷失方向,并浪费时间(如果不是天数)。新功能和新版本发布的速度加剧了这个问题。因此,有一个资源能够整合 TensorFlow 的最新和最重要的信息以及最佳实践,并将其呈现为一篇易于理解、解释清晰的文本,对于解决这个问题是有帮助的。

阅读完本书后,您将了解如何构建大多数常见的机器学习模型,例如卷积神经网络、循环神经网络和 Transformer。您将了解通用的机器学习生命周期以及如何将其应用于许多不同的任务。此外,您还将熟悉构建数据管道,可以在几行代码中执行复杂的转换。

我希望读者在他们的机器学习职业生涯中取得成功,并真诚地希望他们能从本书涵盖的各种主题中获益匪浅。

致谢

首先,我要感谢我的父母和我的妻子 Thushani,在整个旅程中一直支持我,并始终站在我的身边。我还要感谢 Manning 的编辑们为我提供的所有支持和鼓励。也感谢 Manning 的制作人员提供的宝贵指导。

致所有抽出时间阅读我的手稿并提供反馈的审稿人:Alessandro Buggin、Amaresh Rajasekharan、Biswanath Chowdhury、Brian Griner、David Cronkite、Dhinakaran Venkat、Eduardo Paluzo Hidalgo、Francisco Rivas、Ganesh Swaminathan、Geoff Clark、Gherghe Georgios、Giri S、Jason Hales、José Antonio Quiles、Joshua A McAdams、Kaniskha Tyagi、Kelvin D. Meeks、Kim Falk Jørgensen、Krzysztof Je˛drzejewski、Lawrence Nderu、Levi McClenny、Nguyen Cao、Nikos Kanakaris、Peter Morgan、Ryan Markoff、Sergio Govoni、Sriram Macharla、Tiklu Ganguly、Todd Cook、Tony Holdroyd、Vidhya Vinay、Vincent Ngo、Vipul Gupta、Vishwesh Ravi Shrimali 和 Wei Luo,感谢您的建议,帮助使这本书变得更好。

关于本书

在本节中,我们将讨论这本书适合谁,不同章节及其内容,以及您可以找到代码的位置。

谁应该阅读这本书?

你必须确定这本书适合你。这本书是为机器学习社区中的广泛受众编写的,以提供低门槛的入门机会,因此初学者以及具有基本到中级知识和经验的机器学习从业者可以进一步提高他们的 TensorFlow 技能。要从本书中获得最大收益,您需要以下内容:

  • 在模型开发生命周期中有经验(通过研究/行业项目)。

  • 在 Python 和面向对象编程(OOP)方面具有适度的知识(例如,类、生成器、列表推导)。

  • 对 NumPy/pandas 库有基本的了解(例如,计算汇总统计信息,pandas series DataFrame 对象是什么)。

  • 对线性代数有基本的了解(例如,基本数学、向量、矩阵、n 维张量、张量运算等)。

  • 对不同深度神经网络的基本了解。

但是,如果您具有以下任何经验,您也将从本书中受益匪浅:

  • 至少有几个月的机器学习研究员、数据科学家或机器学习工程师的经验,甚至是使用机器学习进行大学或学校项目的学生。

  • 有与其他机器学习库(例如,scikit-learn)密切合作的经验,听说过深度学习的惊人成就,并且渴望学习更多有关如何实现它们的知识。

  • 有基本的 TensorFlow 功能使用经验,但希望提高自己的水平以编写更好的 TensorFlow 代码。

本书的组织结构:一份路线图。

《TensorFlow 实战》分为三个部分和 15 章,第一部分从基础知识开始,第二部分进入中等复杂的主题,机器学习从业者应该对此感到舒适,第三部分涵盖了高级机器学习模型、库和工具。

第一部分重点介绍了 TensorFlow 的基本原理,以及如何实现简单的、精简的机器学习模型,如卷积神经网络、循环神经网络和 Transformer:

  • 第一章介绍了 TensorFlow,介绍了 ML 中使用的不同类型的硬件及其权衡,以及何时以及何时使用 TensorFlow。

  • 第二章详细介绍了 TensorFlow 在内部的工作原理,TensorFlow 中找到的不同构建模块,以及如何实现 TensorFlow 中使用的一些常见操作,如卷积。

  • 第三章讨论了 Keras,这是 TensorFlow 中用于轻松构建 ML 模型的子库,以及如何将数据加载到 TensorFlow 中。

  • 第四章首次介绍了建模。在这一章中,我们构建了一个全连接网络、一个卷积神经网络和一个循环神经网络。

  • 第五章将我们带入了深度学习的皇冠明珠:Transformer 模型以及它们的运作原理。

第二部分介绍了几种流行的机器学习任务以及这些任务上性能最佳的模型:

  • 第六章讨论了第一个用例:图像分类。在这一章中,我们使用了一个复杂的 CNN 模型,并对其进行了图像分类数据集的训练。

  • 在第七章,我们深入探讨了更高级的主题,如正则化、更复杂的模型以及模型解释技术。

  • 第八章向我们介绍了图像分割,这是一种重要的技术,能够赋予自动驾驶汽车更强大的能力。我们将训练一个模型,根据像素所属的对象类别来分割图像像素。

  • 第九章是我们深入研究自然语言处理任务的第一步。在这里,我们将训练一个模型,对电影评论中表达的情感进行分类。

  • 在第十章,我们更仔细地研究了语言建模任务,这是当今成功的 Transformer 模型的核心。在这里,我们利用语言建模任务构建了一个能够生成故事的模型。

第三部分深入探讨了更高级的主题,如在 TensorFlow 中使用 Transformer 模型和 TensorBoard 监视和将 ML 工作流投入生产:

  • 第十一章讨论了序列到序列模型,这是 Transformer 模型的前身,在机器翻译等任务中取得了成功。在这里,我们将训练一个序列到序列模型,将英语翻译成德语。

  • 在第十二章,我们继续讨论序列到序列模型,并向读者介绍了一个非常重要的概念:注意力机制。我们将学习如何将注意力机制纳入我们的模型中,这将有助于提高性能并产生富有洞见的可视化效果。

  • 第十三章延续了我们在第五章关于 Transformer 的讨论。在这一章中,我们使用 Transformer 模型解决了两个自然语言处理任务:垃圾邮件分类和问题回答。您还将介绍 Hugging Face 的 Transformers 库。

  • 第十四章重点介绍了 TensorFlow 随附的一个方便工具:TensorBoard。TensorBoard 对于监视和跟踪模型性能至关重要。它还可以用于可视化数据和性能分析。

  • 第十五章,即最后一章,重点介绍了构建生产质量的机器学习流水线。TensorFlow 提供了一个名为 TFX 的库,它提供了一个 API 来将复杂的机器学习工作流标准化为一系列步骤。

您可以根据自己的技能水平选择不同的方法来充分利用本书。例如,如果您是一名在使用 TensorFlow 领域工作了几年(例如 1-3 年)的从业者,您可能会发现第三部分比前面的部分更有用。如果您是初学者,按照时间顺序阅读所有章节是最合理的做法。

有关代码

本书包含许多源代码示例,包括编号列表和与普通文本一起排列的示例。在这两种情况下,源代码都以固定宽度的字体格式化,以将其与普通文本分开。有时,代码也会以粗体显示,以突出显示与章节中先前步骤中已更改的代码,例如当新功能添加到现有代码行时。

在许多情况下,原始源代码已经被重新格式化;我们已经添加了换行符并重新排列了缩进以适应书中的页面空间。在极少数情况下,即使这样做也不够,列表中还包括了行延续标记(➥)。此外,在文本中描述代码时,源代码中的注释通常会被从列表中删除。代码注释会伴随许多列表出现,突出显示重要概念。

您可以在本书的在线版本 liveBook(在线版)中获取可执行代码片段,网址为 livebook.manning.com/book/tensorflow-in-action/。本书除第一章外的所有章节都附带了代码。完整的代码可在 Manning 网站 (www.manning.com) 和 GitHub 上找到,网址为 github.com/thushv89/manning_tf2_in_action

liveBook 讨论论坛

购买《TensorFlow 实战》包括免费访问 Manning 的在线阅读平台 liveBook。使用 liveBook 的独家讨论功能,您可以在全书范围或具体章节或段落中附上评论。您可以轻松地为自己做笔记,提出技术问题,并获得作者和其他用户的帮助。要访问论坛,请访问livebook.manning.com/book/tensorflow-in-action/discussion。您还可以了解有关 Manning 论坛以及守则的更多信息,请访问livebook.manning.com/discussion

Manning 致力于为读者提供一个有意义的对话场所,使读者与作者之间、读者之间的交流得以进行。这不是要求作者必须参与的承诺,作者对论坛的贡献是自愿的(也没有报酬)。我们建议您向作者提出一些具有挑战性的问题,以保持他的兴趣!论坛和以前的讨论档案将可通过出版商的网站访问,只要书还在印刷。

关于作者

Author_Ganegedara

Thushan Ganegedara 是一位经验丰富的机器学习实践者,拥有四年以上的行业经验。目前,他是澳大利亚创业公司 Canva 的高级机器学习工程师,Canva 创立了在线视觉设计软件 Canva,服务着数百万客户。他的工作重点集中在搜索和推荐组上。在加入 Canva 之前,Thushan 是澳大利亚保险公司 QBE Insurance 的高级数据科学家,在该公司开发与保险索赔相关的机器学习解决方案。他还带领团队开发了一个 Speech2Text 管道。Thushan 在悉尼大学获得了机器学习专业的博士学位。

关于封面插图

《TensorFlow 实战》封面上的图像题注为“贝尔恩周围的牛奶女仆”,取自 Jacques Grasset de Saint-Sauveur 于 1797 年出版的一套手绘插图集。每幅图都是精美且手工着色。

在那个时代,通过服装即可轻松辨识居住地和职业。Manning 基于历史上丰富多样的地区文化制作书籍封面,庆祝计算机业的创新和进取精神,援用了像这样的插图集将古老的文化重现于人们眼前。

第一部分:TensorFlow 2 和深度学习的基础

很难找到一个没有将机器学习纳入其工作流程的公司。像谷歌、Airbnb 和 Twitter 这样的科技巨头,甚至小型初创公司都在以微妙和显而易见的方式使用机器学习来推动他们的系统和产品。如果你在谷歌上看到广告或在 Airbnb 上看到引人注目的列表,机器学习就是推动这些决策的核心。而 TensorFlow 是开发这些机器学习用例解决方案的一个推动者。换句话说,TensorFlow 是一个深度学习框架,几乎管理了模型生命周期的所有阶段,从开发和部署到监控性能。

在第一部分,您将被介绍 TensorFlow 框架。我们将对这个多才多艺的框架进行一个轻松的介绍。我们将首先讨论一些高层次的主题,比如机器学习是什么,TensorFlow 如何工作,Keras 库以及如何处理 TensorFlow 中的数据。我们将通过简单的场景来解释在讨论期间获得的知识。我们将看一下流行的深度学习模型的基本版本,如全连接网络、卷积神经网络、循环神经网络和 Transformer 模型。

第一章:TensorFlow 的惊人世界

本章涵盖的内容

  • TensorFlow 是什么

  • 机器学习中的硬件:GPU 和 CPU

  • 何时以及何时不使用 TensorFlow

  • 本书教授的内容

  • 本书适合谁

  • 为什么我们应该关注 TensorFlow

到 2025 年,每秒预计将产生超过 5 百万吉字节的数据(www.weforum.org)。我们通过 Google 搜索查询、推特、Facebook 照片和对 Alexa 的语音命令所做出的微小贡献将积累成空前数量的数据。因此,现在正是在人工智能前沿进行斗争、理解并利用不断增长的数字数据宇宙的最佳时机。毫无疑问,数据本身在我们从中提取信息之前并不是非常有用。例如,如果机器知道图像中有什么,图像就会更有用;如果机器能够表达/转录出说过的话,语音命令就会更有用。机器学习是让你从数据世界跨入信息领域(例如,可操作的见解、有用的模式)的门卫,通过允许机器从数据中学习。机器学习,特别是深度学习方法,在充足的数据存在的情况下提供了无与伦比的性能。随着数据的爆炸式增长,越来越多的用例将出现,可以应用深度学习。当然,我们不能忽视更好的技术淹没了流行的深度学习方法的可能性。然而,无可辩驳的现实是,迄今为止,深度学习一直在不断地胜过其他算法,特别是在充足的数据存在时。

什么是机器学习?

机器学习是一个过程,我们在给定数据作为输入的情况下训练和部署一个计算模型来预测一些输出。机器学习问题通常包括以下步骤:

  1. 理解/探索性数据分析——这是您将要探索的提供给您的数据的地方(例如,了解因变量/自变量)。

  2. 清理数据——现实世界的数据通常是混乱的,因此数据清理对确保模型看到高质量数据至关重要。

  3. 特征工程——需要从现有特征或原始数据中构建新特征。

  4. 建模——在这个阶段,您使用选定的特征和相应的目标来训练模型。

  5. 评估——在训练模型之后,您必须确保它是可靠的,并且可以在未见过的数据(例如,测试数据)上表现良好。

  6. 为利益相关者创建用户界面以使用该模型——在大多数情况下,您需要为用户提供一个仪表板/用户界面,让他们与模型进行交互。

尽管看起来像是一套明确定义的步骤,但典型的机器学习问题并不是从 A 到 B 的直线路径,而是由重复的循环或迭代组成的错综复杂的路径。例如,在特征工程阶段,您可能会意识到您尚未探索某些数据方面,这需要更多的数据探索。

深度学习模型很容易超过数百万(最近甚至数十亿)的参数(即权重和偏差),它们对数据有很大的需求。这意味着我们需要框架来有效地训练和推断深度学习模型,同时利用优化的硬件,如图形处理单元(GPU)或张量处理单元(TPU) (mng.bz/4j0g)。实现这一目标的一方面是开发高度可扩展的数据管道,可以高效地读取和处理数据。

1.1 什么是 TensorFlow?

TensorFlow 是一个机器学习框架,在机器学习社区中已经留下了近五年的烙印。它是一个端到端的机器学习框架,旨在在优化的硬件上(例如 GPU 和 TPU)运行得更快。一个机器学习框架提供了实现机器学习解决方案所需的工具和操作。尽管 TensorFlow 不局限于实现深度神经网络,但这一直是它的主要用途。TensorFlow 还支持以下内容:

TensorFlow 是最早进入繁荣的机器学习市场的框架之一。由 Google 开发和维护,TensorFlow 已发布了 100 多个版本,拥有约 2500 名贡献者,使产品日益壮大和改进。它已经发展成为一个从早期原型制作阶段到模型产业化阶段的整体生态系统。在这些阶段之间,TensorFlow 支持一系列功能:

  • 模型开发 —— 通过堆叠预定义的层或创建自定义层来轻松构建深度学习模型

  • 性能监控 —— 在模型训练时监控模型的性能

  • 模型调试 —— 调试模型训练/预测过程中出现的任何问题,如数值错误

  • 模型服务 —— 模型训练完成后,将模型部署到更广泛的公众中,以便在真实世界中使用

正如您所看到的,TensorFlow 几乎支持构建机器学习解决方案的所有阶段,并最终将其提供给实际用户。所有这些服务都被制作成一个单一便捷的包,并通过一条安装说明即可随时使用。

其他深度学习框架

市场上有几个竞争激烈的深度学习框架,它们使您能够轻松实现和生产化深度学习模型:

  • PyTorch (pytorch.org)—PyTorch 是一个框架,主要是使用一个名为 Torch 的机器库实现的,该机器库是基于 Lua 编程语言构建的。PyTorch 和 TensorFlow 具有类似的功能。

  • MXNet (mxnet.apache.org)—MXNet 是由 Apache 软件基金会维护的另一个机器学习框架。

  • DeepLearning4J (deeplearning4j.konduit.ai/)—DeepLearning4J 是一个基于 Java 的深度学习框架。

解决 ML 问题所需的各种组件将在接下来的章节中详细讨论。

接下来,我们将讨论 TensorFlow 的不同组件。这些组件将从原始数据一直到部署模型供客户访问。

1.1.1 TensorFlow 热门组件概览

正如先前提到的,TensorFlow 是一个端到端的机器学习框架。这意味着 TensorFlow 需要支持机器学习项目的许多不同能力和阶段。在确定了业务问题之后,任何机器学习项目都是从数据开始的。一个重要的步骤是进行探索性数据分析。通常情况下,这是通过使用 TensorFlow 和其他数据操作库(例如,pandas、NumPy)的组合来完成的。在这一步中,我们试图理解我们的数据,因为这将决定我们能够多好地使用它来解决问题。通过对数据有扎实的理解(例如,数据类型、数据特定属性、在将数据提供给模型之前需要进行的各种清理/处理),下一步是找到一种有效的方式来使用数据。TensorFlow 提供了一个全面的 API(应用程序编程接口),称为 tf.data API(或 tensorflow.data API)(www.tensorflow.org/guide/data),它使您能够利用野外发现的数据。具体来说,这个 API 提供了各种对象和函数来开发高度灵活的自定义输入数据管道。根据您的需求,您在 TensorFlow 中有几种其他检索数据的选项:

  • tensorflow-datasets—提供访问一系列流行的机器学习数据集的方法,只需一行代码即可下载。

  • Keras 数据生成器—Keras 是 TensorFlow 的一个子模块,并提供了基于 TensorFlow 低级 API 构建的各种高级功能。数据生成器提供了从各种来源(例如磁盘)加载特定类型的数据(例如图像或时间序列数据)的方法。

Keras 简史

Keras 最初由 François Chollet 创建,作为一个与平台无关的高级 API,可以同时使用两种流行的低级符号数学库之一:TensorFlow 或 Theano。具体来说,Keras 提供了层(例如全连接层、卷积层等),这些层封装了神经网络的核心计算。

此外,Keras 提供了可下载并方便使用的预训练模型。由于 Theano 在 2017 年退出,TensorFlow 成为 Keras 的首选后端。在 2017 年(TensorFlow v1.4 及以上版本),Keras 被整合到 TensorFlow 中,现在是 TensorFlow 的一个子模块,提供了各种可重复使用的层,可用于构建深度学习模型以及预训练模型。

使用这些元素中的任何一个(或它们的组合),您可以编写一个数据处理流水线(例如一个 Python 脚本)。数据会根据您试图解决的问题而变化。例如,在图像识别任务中,数据将是图像及其相应的类别(例如,狗/猫)。对于情感分析任务,数据将是电影评论及其相应的情感(例如,积极/消极/中性)。该流水线的目的是从这些数据集中产生一批数据。通常馈送给深度学习模型的数据集可能有数万(甚至更多)个数据点,并且永远不会完全适合有限的计算机内存,因此我们一次馈送一小批数据(例如,几百个数据点),并以批次方式遍历整个数据集。

接下来是模型构建阶段。深度学习模型有许多不同的类型和规模。有四种主要类型的深度网络:全连接、卷积神经、循环神经和 Transformer。正如您将在后续章节中看到的,这些模型具有不同的能力、优势和劣势。TensorFlow 还提供了不同的 API,用于构建模型的控制程度各不相同。首先,在其最原始的形式中,TensorFlow 提供了各种基本操作(例如矩阵乘法)和用于存储模型输入和输出的数据结构(例如 n 维张量)。这些可以用作构建块,从零开始实现任何深度学习模型。

然而,使用低级 TensorFlow API 构建模型可能会相当麻烦,因为您需要反复使用 TensorFlow 中的各种低级操作,并确保模型中正在进行的计算的正确性。这就是 Keras 的用武之地。Keras(现在是 TensorFlow 的一个子模块)相比 TensorFlow API 提供了几个优势:

  • 它提供 封装了神经网络中经常发生的各种常见功能的层对象。我们将在接下来的章节中更详细地了解可用的层。

  • 它提供了几种高级模型构建 API(例如,Sequential、functional 和 subclassing)。例如,Sequential API 适用于构建从输入到输出经过一系列层的简单模型,而 functional API 更适用于处理更复杂的模型。我们将在第三章中更详细地讨论这些 API。

正如您可以想象的那样,这些功能大大降低了使用 TensorFlow 的障碍。例如,如果您需要实现一个标准的神经网络,您只需要堆叠几个标准的 Keras 层,而如果您要使用低级 TensorFlow API 做同样的事情,那将会花费您数百行代码。但是,如果您需要灵活性来实现复杂的模型,您仍然有自由去这样做。

最后,TensorFlow 提供了其最抽象的 API,称为 Estimator API(www.tensorflow.org/guide/estimator)。这个 API 的设计非常健壮,能够抵御任何用户引起的错误。这种健壮性是通过一个非常受限的 API 来保证的,向用户公开了训练、预测和评估模型的最低功能。

当您构建模型时,TensorFlow 将创建所谓的数据流图。这个图是您的模型的表示以及它执行的操作。然后,如果您有优化的硬件(例如,GPU),TensorFlow 将识别出这些设备,并将此图的部分放置在该特殊硬件上,以便您对模型执行的任何操作尽可能快地执行。附录 A 提供了设置 TensorFlow 和其他所需依赖项以运行代码的详细说明。

1.1.2 构建和部署机器学习模型

在构建模型之后,你可以使用准备好的数据通过 tf.data API 对其进行训练。模型的训练过程非常重要,对于深度学习模型来说,它非常耗时,所以你需要一种方式来定期监视模型的进展,并确保在训练过程中性能保持在合理水平。为此,我们会记录 loss 值,这是对训练和验证数据性能的评估指标,因此如果出现问题,你可以尽快介入。TensorFlow 中有更高级的工具,可以让你以更多选项和便利的方式监视模型的性能和健康状况。TensorBoard(www.tensorflow.org/tensorboard)是一个随 TensorFlow 一起提供的可视化工具,可以用于可视化模型的各种指标(例如准确率、精确度等)在训练过程中的变化。你只需要将你想要可视化的指标记录到一个目录中,然后启动 TensorBoard 服务器,并提供该目录作为参数。TensorBoard 将自动在一个仪表盘上可视化记录的指标。这样,如果出现问题,你将很快注意到,并且记录的指标将帮助你定位模型中的问题。

在训练过程中(甚至在训练过程期间),你需要保存模型,否则在退出 Python 程序后模型将被销毁。此外,如果训练过程在训练中断时被中断,你可以恢复模型并继续训练(如果你已经保存了它)。在 TensorFlow 中,可以以几种方式保存模型。你可以简单地将模型保存为 HDF5 格式(即用于大型文件存储的格式)。另一种推荐的方法是将其保存为 SavedModel(www.tensorflow.org/guide/saved_model),这是 TensorFlow 采用的保存模型的标准方式。在接下来的章节中,我们将看到如何保存不同的格式。

你所完成的所有出色工作都已经得到了回报。现在,你想要欢快地向世界展示你构建的非常聪明的机器学习模型。你希望用户使用这个模型并对其感到惊叹,并且希望它能够成为关于人工智能的新闻标题。为了将模型介绍给用户,你需要提供一个 API。为此,TensorFlow 提供了称为 TensorFlow Serving 的功能(www.tensorflow.org/tfx/guide/serving)。TensorFlow Serving 帮助你部署训练好的模型并为用户和客户提供 API。这是一个复杂的主题,涉及许多不同的子主题,我们将在另一章中讨论它。

我们已经从单纯的数据出发,进行了一次漫长的旅程,最终将模型部署和提供给客户使用。接下来,我们将比较在机器学习中使用的几种流行硬件选择。

1.2 GPU vs. CPU

如果你实现过简单的计算机程序(例如商业网站)或者使用过标准数据科学工具如 NumPy、pandas 或者 scikit-learn,你应该听过 GPU 这个术语。为了获得真正的好处,TensorFlow 依赖于特殊的硬件,比如 GPU。事实上,我们在深度神经网络方面取得的进展很大程度上归功于过去几年 GPU 的进步。GPU 有何特殊之处?它们与计算机的大脑、中央处理单元(CPU)有何不同?

让我们通过类比来理解这一点。想想你通勤上班的方式。如果你早早准备好并有些时间可以浪费,你可能会坐公交车。但是如果你只有 10 分钟的时间参加早上 9 点的重要会议,你可能会决定开车。这两种交通方式有什么不同?它们分别有什么不同的用途?汽车的设计是为了快速将少数人(例如四个)送到目的地(即低延迟)。另一方面,公共汽车慢但可以在一次行程中运载更多人(例如 60 人)(即高吞吐量)。此外,汽车配备了各种传感器和设备,使您的驾驶/乘车更加舒适(例如停车传感器、车道检测、座椅加热器等)。但公共汽车的设计更注重为大量乘客提供基本需求(例如座位、停车按钮等),选项有限使您的乘车愉快(见图 1.1)。

01-01

图 1.1 比较 CPU、GPU 和 TPU。CPU 就像一辆汽车,设计用于快速运输少数人。GPU 就像一辆公共汽车,慢慢地运输许多人。TPU 也像一辆公共汽车,但只在特定场景下运行良好。

CPU 就像一辆汽车,GPU 就像一辆公共汽车。一个典型的 CPU 有少数核心(例如,八个)。CPU 核心快速地执行多种任务(例如 I/O 操作,协调不同设备之间的通信等),但规模较小。为了支持各种操作,CPU 需要支持大量指令。为了使这些指令运行得快,CPU 依赖昂贵的基础设施(例如更多的晶体管、不同级别的缓存等)。总之,CPU 在小规模上快速执行大量指令。相反,一个典型的 GPU 有许多核心(例如,一千多个)。但是 GPU 核心支持有限的指令集,不太注重快速执行它们。

在机器学习的背景下,特别是在深度学习中,我们大多需要重复执行大量的矩阵乘法来训练和推断模型。矩阵乘法是 GPU 高度优化的功能,这使得 GPU 成为理想选择。

我们不应忘记我们的朋友 TPU,它们是优化硬件清单的最新知名添加。TPU 由 Google 发明,可以被视为简化的 GPU。它们是专门针对机器学习和人工智能应用的应用特定集成电路(ASIC)。它们被设计用于低精度高容量运算。例如,GPU 通常使用 32 位精度,而 TPU 使用一种称为 bfloat16 的特殊数据类型(使用 16 位)(mng.bz/QWAe)。此外,TPU 缺乏图形处理功能,如光栅化/纹理映射。TPU 的另一个区别特征是它们比 GPU 要小得多,意味着可以在更小的物理空间内容纳更多的 TPU。

将我们的汽车-公交车类比扩展到 TPU,你可以将 TPU 视为经济型公交车,设计用于在偏远地区短距离旅行。它不能像普通公交车那样舒适地长途旅行或适应各种道路/天气条件,但它可以将你从 A 点运送到 B 点,因此可以完成任务。

1.3 TensorFlow 的使用时机

了解或学习 TensorFlow 的关键组成部分是知道何时以及何时不应该使用 TensorFlow。让我们通过深度学习的视角来看一下这一点。

1.3.1 TensorFlow 的使用时机

TensorFlow 绝不是任何机器学习问题的万能解决方案。只有了解 TensorFlow 的适用范围,才能获得最佳效果。

深度学习模型的原型设计

TensorFlow 是原型设计模型的绝佳工具(例如,全连接网络、卷积神经网络、长短期记忆网络),因为它提供了层对象(在 Keras 中),例如以下内容:

  • 全连接网络的密集层

  • 卷积神经网络的卷积层

  • 用于顺序模型的 RNN(循环神经网络)/ LSTM(长短期记忆)/ GRU(门控循环单元)层

(你不需要了解这些层的底层机制,因为它们将在后面的章节中深入讨论。)TensorFlow 甚至提供了一套预训练模型,因此你可以用更少的代码开发一个简单的模型,包括几个层,或者一个由许多模型组成的复杂集成模型。

实现可以在优化硬件上更快运行的模型

TensorFlow 包含核心(各种低级操作的实现;例如,矩阵乘法)进行了优化,以便在 GPU 和 TPU 上更快地运行。因此,如果你的模型可以利用这些优化的操作(例如,线性回归),并且需要重复运行大量数据的模型,TensorFlow 将有助于更快地运行模型。

控制 TensorFlow 代码在硬件上的运行

尽管利用 GPU/TPU 运行 TensorFlow 代码非常重要,但同样重要的是我们可以在运行代码时控制资源利用(例如,内存)。以下是运行 TensorFlow 代码时可以控制的主要方面:

  • 特定 TensorFlow 操作的运行位置 —— 通常情况下你不需要这样做,但是你可以指定某个操作应在 CPU/GPU/TPU 上运行,或者指定使用哪个 GPU/TPU,特别是当你拥有多个 GPU/TPU 时。

  • GPU 中的内存使用量 —— 你可以告诉 TensorFlow 只分配总 GPU 内存的一定百分比。这对确保 GPU 内存中有一个用于任何涉及图形处理的进程(例如操作系统使用)非常方便。

在云上运行模型/服务化

机器学习模型的最常见目标是为解决现实世界的问题服务;因此,模型需要通过仪表板或 API 向感兴趣的利益相关者提供预测。TensorFlow 的一个独特优势是,当模型达到这个阶段时,你不需要离开它。换句话说,你可以通过 TensorFlow 开发你的模型服务 API。此外,如果你有豪华的硬件(例如 GPU/TPU),TensorFlow 在进行预测时会利用它。

监控模型的训练过程中的模型性能。

在模型训练期间,关注模型性能以防止过度拟合或欠拟合非常关键。即使有 GPU 的帮助,训练深度学习模型仍然可能很繁琐,因为它们的计算需求很高。这使得监控这些模型比运行几分钟的简单模型更加困难。如果要监视运行几分钟的模型,可以将指标打印到控制台并记录到文件中以供参考。

但是,由于深度学习模型经历了大量的训练迭代,当这些指标以图形方式可视化时更容易吸收信息。TensorBoard 正是提供这种功能。你只需要在 TensorFlow 中记录和保持你的性能指标,并将 TensorBoard 指向该记录目录。TensorBoard 将通过自动将此信息转换为图形来处理此操作目录中的信息,我们可以用来分析模型的质量。

创建重型数据管道

我们已经多次指出,深度学习模型对数据有很大的需求量。通常,深度学习模型所依赖的数据集不适合内存。这意味着我们需要以更小、更易处理的数据批次,以低延迟的方式提供大量的数据。正如我们已经看到的,TensorFlow 提供了丰富的 API 来向深度学习模型流式传输数据。我们所需要做的就是理解所提供函数的语法并适当地使用它们。此类数据管道的一些示例情景包括以下内容:

  • 一个消费大量图像并对其进行预处理的管道

  • 一个消费大量以标准格式(例如 CSV [逗号分隔值])呈现的结构化数据并执行标准预处理(例如归一化)的管道。

  • 一个处理大量文本数据并执行简单预处理(例如,文本小写化,去除标点符号)的流水线

1.3.2 不适用 TensorFlow 的情况

掌握工具或框架时了解不应该做什么同样重要。在这一部分,我们将讨论其他工具可能比 TensorFlow 更高效的一些领域。

实现传统的机器学习模型

机器学习拥有大量的模型(例如,线性/逻辑回归、支持向量机、决策树、K 均值),这些模型属于不同类别(例如,监督与非监督学习),并且具有不同的动机、方法、优势和劣势。有许多模型被使用,您不会看到太多性能提升使用优化的硬件(例如,决策树、K 均值等),因为这些模型不具有固有的可并行性。有时您需要运行这些算法作为您开发的新算法的基准,或者以了解机器学习问题的难易程度。

使用 TensorFlow 实现这些方法将会花费比应该更多的时间。在这种情况下,scikit-learn (scikit-learn.org/stable/) 是一个更好的选择,因为该库提供了大量已实现的模型。TensorFlow 确实支持一些算法,如基于提升树的模型 (mng.bz/KxPn)。但根据我的经验,使用 XGBoost (极端梯度提升) (xgboost.readthedocs.io/en/latest/) 实现提升树更加方便,因为它受到其他库的更广泛支持。此外,如果您需要 GPU 优化版本的 scikit-learn 算法,NVIDIA 也提供了一些适用于 GPU 的算法 (rapids.ai/)。

操纵和分析小规模结构化数据

有时我们将使用相对较小结构的数据集(例如,10,000 个样本),这些数据集可以轻松放入内存。如果数据可以完全加载到内存中,pandas 和 NumPy 是探索和分析数据的更好选择。这些是配备有高度优化的 C/C++ 实现的各种数据操作(例如,索引、过滤、分组)和统计相关操作(例如,平均值、总和)的库。对于小数据集,TensorFlow 可能会造成显著的开销(在 CPU 和 GPU 之间传输数据,在 GPU 上启动计算内核),特别是如果运行大量较小、成本较低的操作。此外,pandas/NumPy 在如何操作数据方面更具表现力,因为这是它们的主要关注点。

创建复杂的自然语言处理流水线

如果您正在开发自然语言处理(NLP)模型,则很少会将数据传递给模型而不对数据进行至少简单的预处理(例如,文本小写化、去除标点符号)。但指导您的预处理流水线的实际步骤将取决于您的用例和您的模型。例如,有时会有一些简单步骤(例如,小写化、去除标点符号),或者您可能有一个完整的预处理流水线,需要进行复杂的任务(例如,词干提取、词形还原、拼写纠正)。在前一种情况下,TensorFlow 是一个不错的选择,因为它提供了一些简单的文本预处理功能(例如,小写化、替换文本、字符串分割等)。然而,在后一种情况下,如果诸如词形还原、词干提取、拼写纠正等昂贵步骤主导着预处理流水线,TensorFlow 将阻碍您的进展。对此,spaCy (spacy.io/) 是一个更强大的选择,因为它提供了直观的界面和可用的模型,用于执行标准的 NLP 处理任务。

spaCy 在定义流水线时支持包含 TensorFlow 模型(通过一个特殊包装器)。但作为一个经验法则,在可能的情况下尽量避免这样做。不同库之间的集成通常耗时,并且在复杂设置中甚至可能出错。

表 1.1 总结了 TensorFlow 的各种优点和缺点。

表 1.1 TensorFlow 优缺点总结

任务
原型化深度学习模型 X
实现在优化硬件上运行更快的模型(包括非深度学习) X
在云端将模型投入生产/服务 X
在模型训练期间监控模型 X
创建重型数据流水线 X
实现传统机器学习模型 X
操纵和分析小规模结构化数据 X
创建复杂的自然语言处理流水线 X

1.4 本书将教授您什么?

在接下来的章节中,本书将教授您一些至关重要的技能,这些技能将帮助您主要且有效地解决研究问题。

1.4.1 TensorFlow 基础知识

首先,我们将学习 TensorFlow 的基础知识。我们将学习它提供的不同执行方式,用于实现任何 TensorFlow 解决方案的主要构建模块(例如,tf.Variable、tf.Operation),以及各种低级操作的功能。然后我们将探索由 Keras(TensorFlow 的一个子模块)向用户公开的各种模型构建 API,以及它们的优点和局限性,这将有助于做出何时使用特定模型构建 API 的决定。我们还将研究我们可以为 TensorFlow 模型获取数据的各种方法。与传统方法不同,深度学习模型消耗大量数据,因此拥有高效且可扩展的数据摄入管道(即输入管道)至关重要。

1.4.2 深度学习算法

实现高效的深度学习模型是 TensorFlow 的主要目的之一。因此,我们将讨论各种深度学习算法的架构细节,如全连接神经网络、卷积神经网络(CNN)和循环神经网络(RNN)。请注意,研究这些模型的理论不是本书的目标。我们将只讨论这些模型,以便帮助我们理解如何在 TensorFlow/Keras 中舒适地实现它们。

通过实施和应用这些模型到流行的计算机视觉和 NLP 应用程序,如图像分类、图像分割、情感分析和机器翻译,我们将进一步磨练我们对这些模型的理解。看到这些模型在这些任务上的表现如何,没有人工设计的特征将会很有趣。

接着,我们将讨论一类新的模型,称为 Transformers。Transformers 与卷积神经网络和循环神经网络非常不同。与 CNN 和 RNN 不同,它们每次可以看到完整的时间序列数据,从而导致更好的性能。事实上,Transformers 在许多 NLP 任务上已经超过了以前记录的最先进模型。我们将学习如何在 TensorFlow 中引入这些模型,以提高各种下游任务的性能。

1.4.3 监控和优化

知道如何在 TensorFlow 中实现模型是不够的。仔细检查和监视模型性能是创建可靠机器学习模型的重要步骤。使用可视化工具,如 TensorBoard 来可视化性能指标和特征表示是必备的技能。模型可解释性也已经成为一个重要的话题,因为像神经网络这样的黑盒模型正在成为机器学习中的常见商品。TensorBoard 有一些工具来解释模型或解释为什么模型做出了某个决定。

接下来,我们将探讨如何使模型训练速度更快。训练时间是使用深度学习模型中最突出的瓶颈之一,因此我们将讨论一些使模型训练更快的技术!

1.5 这本书是为谁写的?

本书是为机器学习社区中更广泛的读者群写的,旨在为初学者提供一个相对容易的入门,以及具有基本到中等知识/经验的机器学习从业者,以进一步推动他们的 TensorFlow 技能。为了充分利用本书,您需要以下内容:

  • 通过研究/行业项目在模型开发生命周期中的经验

  • 对 Python 和面向对象编程(OOP)的中等知识(例如,类/生成器/列表推导式)

  • NumPy/pandas 库的基本知识(例如,计算摘要统计信息,pandas series DataFrame 对象是什么)

  • 对线性代数有基本的了解(例如,基本数学,向量,矩阵,n 维张量,张量操作等)

  • 对不同的深度神经网络有基本的熟悉

如果你是以下的人,那么你将会从这本书中受益匪浅

  • 至少有几个月的机器学习研究员,数据科学家,机器学习工程师,甚至是在大学/学校项目中作为学生拥有使用机器学习的经验

  • 与其他机器学习库密切合作(例如,scikit-learn),并听说过深度学习的惊人成绩,并渴望学习如何实现它们

  • 对基本的 TensorFlow 功能有所了解,但希望写出更好的 TensorFlow 代码

你可能在想,在有着大量资源可用的情况下(例如 TensorFlow 文档,StackOverFlow.com 等),学习 TensorFlow 不是很容易(且免费)吗?是和不。如果你只是需要针对问题工作的“一些”解决方案,你可能能够使用现有的资源进行 hack。但很可能这将是一个次优解,因为为了提出一个有效的解决方案,你需要建立对 TensorFlow 执行代码的强大心理形象,理解 API 中提供的功能,理解限制等。同时,逐渐有序地了解 TensorFlow 并理解它也非常困难,而仅仅是随机阅读免费资料是无法做到的。坚实的心理形象和牢固的知识来自于多年的经验(并密切关注新功能,GitHub 问题和 stackoverflow.com 问题),或者来自于一位具有多年经验的作者编写的书籍。这里的重要问题不是“我该如何使用 TensorFlow 解决我的问题?”,而是“我该如何有效地使用 TensorFlow 解决我的问题?”提出一个有效的解决方案需要对 TensorFlow 有扎实的理解。在我看来,一个有效的解决方案可以做到(但不限于)以下几点:

  • 保持相对简洁的代码,同时又不牺牲可读性太多(例如,避免冗余操作,在可能的情况下聚合操作)

  • 使用 API 中最新最棒的特性,避免重复发明轮子,节省时间

  • 尽可能利用优化(例如,避免循环,使用矢量化操作)

如果你让我用几个词来概括这本书,我会说“让读者能够编写有效的 TensorFlow 解决方案”。

1.6 我们真的应该关心 Python 和 TensorFlow 2 吗?

这里我们将了解到你将会大量学习的两项最重要的技术:Python 和 TensorFlow。Python 是我们将使用来实现各种 TensorFlow 解决方案的基础编程语言。但重要的是要知道,TensorFlow 支持许多不同的语言,比如 C++,Go,JavaScript 等等。

我们应该试图回答的第一个问题是:“为什么我们选择 Python 作为我们的编程语言?” Python 的流行度近年来有所增加,特别是在科学界,这是因为大量的库加强了 Python(例如 pandas、NumPy、scikit-learn),这使得进行科学实验/模拟以及记录/可视化/报告结果变得更加容易。在图 1.2 中,您可以看到 Python 如何成为最受欢迎的搜索词(至少在 Google 搜索引擎中是如此)。如果将结果仅限于机器学习社区,您将看到更高的差距。

01-02

图 1.2 不同编程语言的流行程度(2015-2020)

下一个要回答的问题是:“我们为什么选择 TensorFlow?” TensorFlow 几乎从深度学习开始流行就一直存在(mng.bz/95P8)。 TensorFlow 在大约五年的时间里不断改进和修订,随着时间的推移变得越来越稳定。此外,与其他类似的库不同,TensorFlow 提供了一个生态系统的工具,以满足您的机器学习需求,从原型设计到模型训练再到模型。在图 1.3 中,您可以看到 TensorFlow 与其一个流行竞争对手 PyTorch 的比较。

01-03

图 1.3 TensorFlow 和 PyTorch 的流行程度(2015-2020)

我们也值得检查随着数据量的增长,我们所获得的性能增长有多大。图 1.4 比较了一个流行的科学计算库(NumPy)与 TensorFlow 在矩阵乘法任务中的表现。这是在 Intel i5 第九代处理器和 NVIDIA 2070 RTX 8 GB GPU 上测试的。在这里,我们正在乘以两个随机初始化的矩阵(每个矩阵大小为 n × n)。我们记录了 n = 100、1000、5000、7500、1000 时所花费的时间。在图的左侧,您可以看到时间增长的差异。NumPy 显示随着矩阵大小的增长,所花费时间呈指数增长。但是,TensorFlow 显示出大致线性的增长。在图的右侧,您可以看到如果 TensorFlow 操作需要一秒钟需要多少秒。这一信息很清楚:随着数据量的增长,TensorFlow 比 NumPy 做得更好。

01-04

图 1.4 在矩阵乘法任务中比较 NumPy 和 TensorFlow 计算库

摘要

  • 由于提供了大量数据时提供的卓越性能,深度学习已成为一个热门话题。

  • TensorFlow 是一个端到端的机器学习框架,提供生态系统支持的模型原型设计、模型构建、模型监控、模型服务等。

  • TensorFlow 和任何其他工具一样,都有优势和劣势。因此,用户需要权衡这些因素,以解决他们试图解决的问题。

  • TensorFlow 是一个非常好的工具,可以快速原型设计各种复杂度的深度学习模型。

  • TensorFlow 并不适合分析/操作小型数据集或开发复杂的文本处理数据管道。

  • 本书不仅教读者如何实现某些 TensorFlow 解决方案,更教授读者如何在最小化工作量的情况下实现有效的解决方案,同时减少错误的可能性。

第二章:TensorFlow 2

本章介绍了

  • TensorFlow 2 是什么

  • TensorFlow 中的重要数据结构和操作

  • TensorFlow 中常见的与神经网络相关的操作

在上一章中,我们了解到 TensorFlow 是一种端到端的机器学习框架,主要用于实现深度神经网络。TensorFlow 擅长将这些深度神经网络转换为在优化硬件上(例如 GPU 和 TPU)运行更快的计算图。但请记住,这不是 TensorFlow 的唯一用途。表 2.1 概述了 TensorFlow 支持的其他领域。

表 2.1 TensorFlow 提供的各种功能

概率机器学习 TensorFlow 支持实现概率机器学习模型。例如,可以使用 TensorFlow API 实现贝叶斯神经网络等模型(www.tensorflow.org/probability)。
与计算机图形有关的计算 计算机图形计算大部分可以使用 GPU 实现(例如模拟各种光照效果、光线追踪;www.tensorflow.org/graphics)。
TensorFlow Hub:可重用(预训练的)模型 在深度学习中,我们通常试图利用已经在大量数据上训练过的模型来解决我们感兴趣的下游任务。TensorFlow Hub 是一个存放这种用 TensorFlow 实现的模型的仓库(www.tensorflow.org/hub)。
可视化/调试 TensorFlow 模型 TensorFlow 提供了一个仪表板,用于可视化和监控模型性能,甚至可视化数据(www.tensorflow.org/tensorboard)。

在接下来的章节中,我们将展开一次充满惊喜的旅程,探索 TensorFlow 中的花里胡哨的东西,并学习如何在 TensorFlow 擅长的领域中表现出色。换句话说,我们将学习如何使用 TensorFlow 解决现实世界的问题,例如图像分类(即在图像中识别对象)、情感分析(即识别评论/意见中的正面/负面情绪)等等。在解决这些任务的同时,您将学习如何克服过拟合和类别不平衡等现实世界中可能会出现的挑战,这些问题很容易妨碍我们的进展。本章将特别关注在我们进入可以使用深度网络解决的复杂问题之前,为 TensorFlow 提供扎实的基础知识。

首先,我们将在 TensorFlow 2 和 TensorFlow 1 中实现一个神经网络,看看 TensorFlow 在用户友好性方面发展了多少。然后,我们将了解 TensorFlow 提供的基本单元(例如变量、张量和操作),我们必须对此有很好的理解才能开发解决方案。最后,我们将通过一系列有趣的计算机视觉练习来理解几个复杂的数学操作的细节。

2.1 TensorFlow 2 初步

假设你正在参加一门机器学习课程,并被要求使用 TensorFlow 实现一个多层感知机(MLP)(即一种神经网络类型),并针对给定的数据点计算最终输出。你对 TensorFlow 还不熟悉,所以你去图书馆开始研究 TensorFlow 是什么。在研究过程中,你意识到 TensorFlow 有两个主要版本(1 和 2),决定使用最新最好的:TensorFlow 2. 你已经按附录 A 中的要求安装了所需的库。

在继续之前,让我们了解一下 MLP。MLP(图 2.1)是一个简单的神经网络,它有一个输入层,一个或多个隐藏层和一个输出层。这些网络也被称为全连接网络

注 Some research only uses the term MLP to refer to a network made of multiple perceptrons (mng.bz/y4lE) organized in a hierarchical structure. However, in this book, we will use the terms MLP and fully connected network interchangeably.

在每一层中,我们有权重和偏置,用于计算该层的输出。在我们的例子中,我们有一个大小为 4 的输入,一个具有三个节点的隐藏层和一个大小为 2 的输出层。

02-01

图 2.1 多层感知机(MLP)或全连接网络的示意图。有三层:一个输入层,一个隐藏层(带有权重和偏置),一个输出层。输出层使用 softmax 激活产生归一化的概率作为输出。

输入值(x)经过以下计算转换为隐藏值(h):

h = σ(x W[1] + b[1])

其中σ是 sigmoid 函数。Sigmoid 函数是一个简单的非线性逐元素变换,如图 2.2 所示。

02-02

图 2.2 sigmoid 激活函数对不同输入的可视化

x是一个大小为 1 × 4 的矩阵(即一行四列),W[1]是一个大小为 4 × 3 的矩阵(即四行三列),b[1]是 1 × 4 的矩阵(即一行四列)。这给出了一个大小为 1 × 3 的h。最后,输出计算为

y = softmax(h W[2] + b[2])

这里,W[2]是一个 3 × 2 的矩阵,b[2]是一个 1 × 2 的矩阵。Softmax 激活将最后一层的线性分数(即h W[2] + b[2])归一化为实际概率(即沿列求和的值等于 1)。假设输入向量x的长度为K,softmax 激活产生一个K长的向量yy的第i个元素计算如下:

02_02a

其中y[i]是第i个输出元素,x[i]是第i个输入元素。作为一个具体的例子,假设最终层没有 softmax 激活产生,

[16, 4]

应用 softmax 归一化将这些值转换为

[16/(16+4), 4/(16+4)] = [0.8, 0.2]

让我们看看如何在 TensorFlow 2 中实现这一点。您可以在 Jupyter 笔记本(Ch02-Fundamentals-of-TensorFlow-2/2.1.Tensorflow_Fundamentals.ipynb)中找到代码。如何安装必要的库和设置开发环境在附录 A 中描述。最初,我们需要使用导入语句导入所需的库:

import numpy as np
import tensorflow as tf

然后,我们定义网络的输入(x)和变量(或参数)(即w[1]、b[1]、w[2]和b[2]):

x = np.random.normal(size=[1,4]).astype('float32')

init = tf.keras.initializers.RandomNormal()

w1 = tf.Variable(init(shape=[4,3])) 
b1 = tf.Variable(init(shape=[1,3])) 

w2 = tf.Variable(init(shape=[3,2])) 
b2 = tf.Variable(init(shape=[1,2])) 

在这里,x是一个大小为 1×4(即一行四列)的简单 NumPy 数组,其值来自正常分布。然后,我们将网络的参数(即权重和偏差)定义为 TensorFlow 变量。tf.Variable 的行为类似于典型的 Python 变量。在定义时会附加一些值,并且随着时间的推移可能会发生变化。tf.Variable 用于表示神经网络的权重和偏差,在优化或训练过程中会更改这些参数。定义 TensorFlow 变量时,需要为变量提供一个初始化器和一个形状。在这里,我们使用从正态分布随机抽样值的初始化器。请记住,W[1]大小为 4×3,b[1]大小为 1×3,W[2]大小为 3×2,b[2]大小为 1×2,每个参数的形状参数都相应地进行了设置。接下来,我们将多层感知器网络的核心计算定义为一个漂亮的模块化函数。这样,我们可以轻松地重用该函数来计算多层的隐藏层输出:

@tf.function
def forward(x, W, b, act):
    return act(tf.matmul(x,W)+b)

在这里,act 是您选择的任何非线性激活函数(例如 tf.nn.sigmoid)。(您可以在此处查看各种激活函数:www.tensorflow.org/api_docs/python/tf/nn。要注意的是,并非所有函数都是激活函数。表达式 tf.matmul(x,W)+b 优雅地封装了我们之前看到的核心计算(即x W[1]+ b[1]和 h W[2]+b[2])到可重用的表达式中。在这里,tf.matmul 执行矩阵乘法运算。该计算在图 2.3 中说明。

02-03

图 2.3 矩阵乘法和偏置加法的示例输入、权重和偏差说明

将@tf.function 放在函数的顶部是 TensorFlow 知道该函数包含 TensorFlow 代码的一种方式。我们将在下一部分更详细地讨论@tf.function 的目的。这将带我们进入代码的最后部分。由于我们已经定义了输入、所有参数和核心计算,因此可以计算网络的最终输出。

# Computing h
h = forward(x, w1, b1, tf.nn.sigmoid)

# Computing y
y = forward(h, w2, b2, tf.nn.softmax)

print(y)

输出将会是:

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

这里,h 和 y 是各种 TensorFlow 操作(例如 tf.matmul)的结果张量(类型为 tf.Tensor)。输出中的确切值可能会略有不同(请见下面的列表)。

列表 2.1 使用 TensorFlow 2 的多层感知器网络

import numpy as np                                  ❶
import tensorflow as tf                             ❶

x = np.random.normal(size=[1,4]).astype('float32')  ❷

init = tf.keras.initializers.RandomNormal()         ❸

w1 = tf.Variable(init(shape=[4,3]))                 ❹
b1 = tf.Variable(init(shape=[1,3]))                 ❹

w2 = tf.Variable(init(shape=[3,2]))                 ❹
b2 = tf.Variable(init(shape=[1,2]))                 ❹

@tf.function                                        ❺
def forward(x, W, b, act):                          ❻
    return act(tf.matmul(x,W)+b)                    ❻

h = forward(x, w1, b1, tf.nn.sigmoid)               ❼

y = forward(h, w2, b2, tf.nn.softmax)               ❽

print(y)

❶导入 NumPy 和 TensorFlow 库

❷ MLP 的输入(一个 NumPy 数组)

❸ 用于初始化变量的初始化器

❹ 第一层(w1 和 b2)和第二层(w2 和 b2)的参数

❺ 这行告诉 TensorFlow 的 AutoGraph 构建图形。

❻ MLP 层计算,它接受输入、权重、偏置和非线性激活

❼ 计算第一个隐藏层的输出 h

❽ 计算最终输出 y

接下来,我们将看看 TensorFlow 运行代码时背后发生了什么。

2.1.1 TensorFlow 在底层是如何运行的?

在典型的 TensorFlow 程序中,有两个主要步骤:

  1. 定义一个涵盖输入、操作和输出的数据流图。在我们的练习中,数据流图将表示 x、w1、b1、w2、b2、h 和 y 之间的关系。

  2. 通过为输入提供值并计算输出来执行图形。例如,如果我们需要计算 h,则将一个值(例如 NumPy 数组)馈送到 x 并获取 h 的值。

TensorFlow 2 使用一种称为命令式执行的执行样式。在命令式执行中,声明(定义图形)和执行同时发生。这也被称为急切执行代码。

您可能想知道数据流图是什么样的。这是 TensorFlow 用来描述您定义的计算流程的术语,并表示为有向无环图(DAG):箭头表示数据,节点表示操作。换句话说,tf.Variable 和 tf.Tensor 对象表示图中的边,而操作(例如 tf.matmul)表示节点。例如,对于

h = x W[1] + b[1]

将看起来像图 2.4。然后,在运行时,您可以通过向 x 提供值来获取 y 的值,因为 y 依赖于输入 x。

02-04

图 2.4 一个示例计算图。这里的各个元素将在 2.2 节中更详细地讨论。

TensorFlow 如何知道创建数据流图?您可能已经注意到以@符号开头的行悬挂在 forward(...) 函数的顶部。这在 Python 语言中称为装饰器。@tf.function 装饰器接受执行各种 TensorFlow 操作的函数,跟踪所有步骤,并将其转换为数据流图。这是多么酷?这鼓励用户编写模块化代码,同时实现数据流图的计算优势。TensorFlow 2 中这个功能被称为 AutoGraph(www.tensorflow.org/guide/function)。

什么是装饰器?

装饰器通过包装函数来修改函数的行为,这发生在函数被调用之前/之后。一个很好的装饰器示例是在每次调用函数时记录输入和输出。下面是如何使用装饰器的示例:

def log_io(func):
    def wrapper(*args, **kwargs):
        print("args: ", args)
        print(“kwargs: “, kwargs)
        out = func(*args, **kwargs)
        print("return: ", out)
    return wrapper

@log_io
def easy_math(x, y):
    return x + y + ( x * y)

res = easy_math(2,3)

这将输出

args:  (2, 3)
kwargs:  {}
return:  11

预期的。因此,当您添加 @tf.function 装饰器时,它实际上修改了调用函数的行为,通过构建给定函数内发生的计算的计算图。

图 2.5 中的图解描述了 TensorFlow 2 程序的执行路径。第一次调用函数 a(...) 和 b(...) 时,将创建数据流图。然后,将输入传递给函数,以将输入传递给图并获取您感兴趣的输出。

02-05

图 2.5 TensorFlow 2 程序的典型执行。在第一次运行时,TensorFlow 会跟踪所有使用 @tf.function 注释的函数,并构建数据流图。在后续运行中,根据函数调用传递相应的值给图,并检索结果。

AutoGraph

AutoGraph 是 TensorFlow 中的一个很棒的功能,通过在幕后努力工作,减轻了开发者的工作量。要真正欣赏这个功能,请阅读更多内容请访问www.tensorflow.org/guide/function。虽然它相当令人惊叹,但 AutoGraph 不是万能药。因此,了解其优点以及限制和注意事项非常重要:

  • 如果您的代码包含大量重复操作(例如,多次迭代训练神经网络),AutoGraph 将提供性能提升。

  • 如果您运行多个仅运行一次的不同操作,则 AutoGraph 可能会减慢您的速度;因为您仅运行一次操作,构建图仅是一种开销。

  • 要注意将什么包含在您向 AutoGraph 公开的函数内。例如

    • NumPy 数组和 Python 列表将被转换为 tf.constant 对象。

    • 在函数跟踪期间将展开 for 循环,这可能导致大型图最终耗尽内存。

TensorFlow 1,TensorFlow 2 的前身,使用了一种称为声明式基于图的执行的执行风格,它包含两个步骤:

  1. 明确定义一个数据流图,使用各种符号元素(例如占位符输入、变量和操作),以实现你所需的功能。与 TensorFlow 2 不同,这些在声明时不会保存值。

  2. 明确编写代码来运行定义的图,并获取或评估结果。您可以在运行时向先前定义的符号元素提供实际值,并执行图。

这与 TensorFlow 2 非常不同,后者隐藏了数据流图的所有复杂性,通过自动在后台构建它。在 TensorFlow 1 中,您必须显式构建图,然后执行它,导致代码更加复杂且难以阅读。表 2.2 总结了 TensorFlow 1 和 TensorFlow 2 之间的区别。

表 2.2 TensorFlow 1 和 TensorFlow 2 之间的区别

TensorFlow 1 TensorFlow 2
默认情况下不使用急切执行 默认情况下使用急切执行
使用符号占位符表示图形的输入 直接将实际数据(例如,NumPy 数组)提供给数据流图
由于结果不是按命令式评估,因此难以调试 由于操作是按命令式评估的,因此易于调试
需要显式手动创建数据流图 具有 AutoGraph 功能,可以跟踪 TensorFlow 操作并自动创建图形
不鼓励面向对象编程,因为它强制您提前定义计算图 鼓励面向对象编程
由于具有单独的图形定义和运行时代码,代码的可读性较差 具有更好的代码可读性

在下一节中,我们将讨论 TensorFlow 的基本构建模块,为编写 TensorFlow 程序奠定基础。

练习 1

给定以下代码,

# A
import tensorflow as tf
# B
def f1(x, y, z):
    return tf.math.add(tf.matmul(x, y) , z)
#C
w = f1(x, y, z)

tf.function 装饰器应该放在哪里?

  1. A

  2. B

  3. C

  4. 以上任何一项

2.2 TensorFlow 构建模块

我们已经看到了 TensorFlow 1 和 TensorFlow 2 之间的核心差异。在此过程中,您接触到了 TensorFlow API 公开的各种数据结构(例如,tf.Variable)和操作(例如,tf.matmul)。现在让我们看看在哪里以及如何使用这些数据结构和操作。

在 TensorFlow 2 中,我们需要了解三个主要的基本元素:

  • tf.Variable

  • tf.Tensor

  • tf.Operation

你已经看到所有这些被使用了。例如,从前面的 MLP 示例中,我们有这些元素,如表 2.3 所示。了解这些基本组件有助于理解更抽象的组件,例如 Keras 层和模型对象,稍后将进行讨论。

表 2.3 MLP 示例中的 tf.Variable、tf.Tensor 和 tf.Operation 实体

元素 示例
tf.Variable w1, b1, w2 和 b2
tf.Tensor h 和 y
tf.Operation tf.matmul

牢牢掌握 TensorFlow 的这些基本元素非常重要,原因有几个。主要原因是,从现在开始,您在本书中看到的所有内容都是基于这些元素构建的。例如,如果您使用像 Keras 这样的高级 API 构建模型,它仍然使用 tf.Variable、tf.Tensor 和 tf.Operation 实体来进行计算。因此,了解如何使用这些元素以及您可以实现什么和不能实现什么非常重要。另一个好处是,TensorFlow 返回的错误通常使用这些元素呈现给您。因此,这些知识还将帮助我们理解错误并在开发更复杂的模型时迅速解决它们。

2.2.1 理解 tf.Variable

构建典型的机器学习模型时,您有两种类型的数据:

  • 模型参数随时间变化(可变),因为模型针对所选损失函数进行了优化。

  • 模型输出是给定数据和模型参数的静态值(不可变)

tf.Variable 是定义模型参数的理想选择,因为它们被初始化为某个值,并且可以随着时间改变其值。一个 TensorFlow 变量必须具有以下内容:

  • 形状(变量的每个维度的大小)

  • 初始值(例如,从正态分布中抽样的随机初始化)

  • 数据类型(例如 int32、float32)

你可以如下定义一个 TensorFlow 变量

tf.Variable(initial_value=None, trainable=None, dtype=None)

其中

  • 初始值包含提供给模型的初始值。通常使用 tf.keras.initializers 子模块中提供的变量初始化器提供(完整的初始化器列表可以在 mng.bz/M2Nm 找到)。例如,如果你想使用均匀分布随机初始化一个包含四行三列的二维矩阵的变量,你可以传递 tf.keras.initializers.RandomUniform()([4,3])。你必须为 initial_value 参数提供一个值。

  • trainable 参数接受布尔值(即 True 或 False)作为输入。将 trainable 参数设置为 True 允许通过梯度下降更改模型参数。将 trainable 参数设置为 False 将冻结层,以使值不能使用梯度下降进行更改。

  • dtype 指定变量中包含的数据的数据类型。如果未指定,这将默认为提供给 initial_value 参数的数据类型(通常为 float32)。

让我们看看如何定义 TensorFlow 变量。首先,请确保已导入以下库:

import tensorflow as tf
import numpy as np

你可以如下定义一个大小为 4 的一维 TensorFlow 变量,其常量值为 2:

v1 = tf.Variable(tf.constant(2.0, shape=[4]), dtype='float32')
print(v1)

>>> <tf.Variable 'Variable:0' shape=(4,) dtype=float32, numpy=array([2., 2., 2., 2.], dtype=float32)>

在这里,tf.constant(2.0, shape=[4]) 生成一个有四个元素且值为 2.0 的向量,然后将其用作 tf.Variable 的初始值。你也可以使用 NumPy 数组定义一个 TensorFlow 变量:

v2 = tf.Variable(np.ones(shape=[4,3]), dtype='float32')
print(v2)

>>> <tf.Variable 'Variable:0' shape=(4, 3) dtype=float32, numpy=
array([[1., 1., 1.],
       [1., 1., 1.],
       [1., 1., 1.],
       [1., 1., 1.]], dtype=float32)>

在这里,np.ones(shape=[4,3]) 生成一个形状为 [4,3] 的矩阵,所有元素的值都为 1。下一个代码片段定义了一个具有随机正态初始化的三维(3×4×5) TensorFlow 变量:

v3 = tf.Variable(tf.keras.initializers.RandomNormal()(shape=[3,4,5]), dtype='float32')
print(v3)

>>> <tf.Variable 'Variable:0' shape=(3, 4, 5) dtype=float32, numpy=
array([[[-0.00599647, -0.04389469, -0.03364765, -0.0044175 ,
          0.01199682],
        [ 0.05423453, -0.02812728, -0.00572744, -0.08236874,
         -0.07564012],
        [ 0.0283042 , -0.05198685,  0.04385028,  0.02636188,
          0.02409425],
        [-0.04051876,  0.03284673, -0.00593955,  0.04204708,
         -0.05000611]],

       ...

       [[-0.00781542, -0.03068716,  0.04313354, -0.08717368,
          0.07951441],
        [ 0.00467467,  0.00154883, -0.03209472, -0.00158945,
          0.03176221],
        [ 0.0317267 ,  0.00167555,  0.02544901, -0.06183815,
          0.01649506],
        [ 0.06924769,  0.02057942,  0.01060928, -0.00929202,
          0.04461157]]], dtype=float32)>

在这里,你可以看到如果我们打印一个 tf.Variable,可以看到它的属性,如下所示:

  • 变量的名称

  • 变量的形状

  • 变量的数据类型

  • 变量的初始值

你还可以使用一行代码将你的 tf.Variable 转换为 NumPy 数组

arr = v1.numpy()

然后,你可以通过打印 Python 变量 arr 来验证结果

print(arr) 

将返回

>>> [2\. 2\. 2\. 2.]

tf.Variable 的一个关键特点是,即使在初始化后,你也可以根据需要更改其元素的值。例如,要操作 tf.Variable 的单个元素或片段,你可以使用 assign() 操作如下。

为了本练习的目的,让我们假设以下 TensorFlow 变量,它是一个由零初始化的矩阵,有四行三列:

v = tf.Variable(np.zeros(shape=[4,3]), dtype='float32')

你可以如下更改第一行(即索引为 0)和第三列(即索引为 2)中的元素:

v = v[0,2].assign(1)

这会产生下列数组:

>>> [[0\. 0\. 1.]
     [0\. 0\. 0.]
     [0\. 0\. 0.]
     [0\. 0\. 0.]]

请注意,Python 使用以零为基数的索引。这意味着索引从零开始(而不是从一开始)。例如,如果你要获取向量 vec 的第二个元素,你应该使用 vec[1]。

你也可以使用切片改变数值。例如,下面我们就将最后两行和前两列的数值改为另外一些数:

v = v[2:, :2].assign([[3,3],[3,3]])

结果如下:

>>> [[0\. 0\. 1.]
     [0\. 0\. 0.]
     [3\. 3\. 0.]
     [3\. 3\. 0.]]

练习 2

请编写代码创建一个 tf.Variable,其数值为下面的数值,并且类型为 int16。你可以使用 np.array() 来完成该任务。

1 2 3
4 3 2

2.2.2 理解 tf.Tensor

正如我们所见,tf.Tensor 是对某些数据进行 TensorFlow 操作后得到的输出(例如,对 tf.Variable 或者 tf.Tensor 进行操作)。在定义机器学习模型时,tf.Tensor 对象广泛应用于存储输入、层的中间输出、以及模型的最终输出。到目前为止,我们主要看了向量(一维)和矩阵(二维)。但是,我们也可以创建 n 维数据结构。这样的一个 n 维数据结构被称为一个 张量。表 2.4 展示了一些张量的示例。

表 2.4 张量的示例

描述 示例
一个 2 × 4 的二维张量
[
 [1,3,5,7],
 [2,4,6,8]
]

|

一个大小为 2 × 3 × 2 × 1 的四维张量
[
  [
    [[1],[2]],
    [[2],[3]],
    [[3],[4]]
  ],
  [
    [[1],[2]],
    [[2],[3]],
    [[3],[4]]
  ]
]

|

张量也有轴,张量的每个维度都被认为是一个轴。图 2.6 描述了一个 3D 张量的轴。

02-06

图 2.6 一个 2 × 4 × 3 张量,包含三个轴。第一个轴(axis 0)是行维度,第二个轴(axis 1)是列维度,最后一个轴(axis 2)是深度维度。

严格来说,张量也可以只有一个维度(即向量)或者只是一个标量。但是需要区分 tensor 和 tf.Tensor。在讨论模型的数学方面时我们会使用 tensor/vector/scalar,而我们在提到 TensorFlow 代码所输出的任何数据相关输出时都会使用 tf.Tensor。

下面我们将讨论一些会产生 tf.Tensor 的情况。例如,你可以通过一个 tf.Variable 和一个常数相乘来产生一个 tf.Tensor:

v = tf.Variable(np.ones(shape=[4,3]), dtype='float32')
b = v * 3.0

如果你使用 print(type(b).name) 分析前面操作生成的对象类型,你会看到下面的输出:

>>> EagerTensor

EagerTensor 是从 tf.Tensor 继承而来的一个类。它是一种特殊类型的 tf.Tensor,其值在定义后会立即得到计算。你可以通过执行下列命令验证 EagerTensor 实际上是 tf.Tensor:

assert isinstance(b, tf.Tensor)

也可以通过将一个 tf.Tensor 加上另一个 tf.Tensor 来创建一个 tf.Tensor。

a = tf.constant(2, shape=[4], dtype='float32')
b = tf.constant(3, shape=[4], dtype='float32')
c = tf.add(a,b)

print(c) 将打印出下列结果:

>>> [5\. 5\. 5\. 5]

在这个例子中,tf.constant() 用于创建 tf.Tensor 对象 a 和 b。通过将 a 和 b 加在一起,你将得到一个类型为 tf.Tensor 的张量 c。如之前所述,可以通过运行如下代码验证该张量:

assert isinstance(c, tf.Tensor)

tf.Variable 和 tf.Tensor 之间的关键区别在于,tf.Variable 允许其值在变量初始化后发生更改(称为可变结构)。然而,一旦您初始化了一个 tf.Tensor,在执行的生命周期中您就无法更改它(称为不可变数据结构)。tf.Variable 是一种可变数据结构,而 tf.Tensor 是一种不可变数据结构。

让我们看看如果尝试在初始化后更改 tf.Tensor 的值会发生什么:

a = tf.constant(2, shape=[4], dtype='float32')
a = a[0].assign(2.0)

您将收到以下错误:

---------------------------------------------------------------------------

AttributeError                            Traceback (most recent call last)

<ipython-input-19-6e4e6e519741> in <module>()
      1 a = tf.constant(2, shape=[4], dtype='float32')
----> 2 a = a[0].assign(2.0)

AttributeError: 'tensorflow.python.framework.ops.EagerTensor' object has no attribute 'assign'

显然,TensorFlow 对我们尝试修改 tf.Tensor 对象的叛逆行为并不感兴趣。

张量动物园

TensorFlow 有各种不同的张量类型,用于解决各种问题。以下是 TensorFlow 中可用的一些不同的张量类型:

RaggedTensor——一种用于不能有效表示为矩阵的可变序列长度数据集的数据类型

TensorArray——一种动态大小的数据结构,可以从小开始,并随着添加更多数据而伸展(类似于 Python 列表)

SparseTensor——一种用于表示稀疏数据的数据类型(例如,用户-电影评分矩阵)

在下一小节中,我们将讨论一些流行的 TensorFlow 操作。

练习 3

你能写出创建初始化为从正态分布中抽样的值并且形状为 4 × 1 × 5 的 tf.Tensor 的代码吗?您可以使用 np.random.normal()来实现这个目的。

2.2.3 理解 tf.Operation

TensorFlow 的骨干是操作,它允许您对数据进行有用的操作。例如,深度网络中的核心操作之一是矩阵乘法,这使得 TensorFlow 成为实现核心操作的强大工具。就像矩阵乘法一样,TensorFlow 提供了许多低级操作,可用于 TensorFlow。可以在TensorFlow API中找到可用操作的完整列表。

让我们讨论一些您可以使用的流行算术操作。首先,您有基本的算术操作,如加法、减法、乘法和除法。您可以像对待普通 Python 变量一样执行这些操作。为了演示这一点,让我们假设以下向量:

import tensorflow as tf
import numpy as np

a = tf.constant(4, shape=[4], dtype='float32')
b = tf.constant(2, shape=[4], dtype='float32')

我们可以通过执行以下操作来查看 a 和 b 的样子

print(a)
print(b)

这给出

>>> tf.Tensor([4\. 4\. 4\. 4.], shape=(4,), dtype=float32)
>>> tf.Tensor([2\. 2\. 2\. 2.], shape=(4,), dtype=float32)

对 a 和 b 执行加法

c = a+b
print(c)

提供

>>> tf.Tensor([6\. 6\. 6\. 6.], shape=(4,), dtype=float32)

对 a 和 b 执行乘法

e = a*b
print(e)

提供

>>> tf.Tensor([8\. 8\. 8\. 8.], shape=(4,), dtype=float32)

您还可以在张量之间进行逻辑比较。假设

a = tf.constant([[1,2,3],[4,5,6]])
b = tf.constant([[5,4,3],[3,2,1]])

并检查逐元素相等性

equal_check = (a==b)
print(equal_check)

提供

>>> tf.Tensor(
    [[False False  True]
     [False False False]], shape=(2, 3), dtype=bool) 

检查小于或等于元素

leq_check = (a<=b)
print(leq_check)

提供

>>> tf.Tensor(
    [[ True  True  True]
     [False False False]], shape=(2, 3), dtype=bool)

接下来,您有减少运算符,允许您在特定轴或所有轴上减少张量(例如,最小值/最大值/和/乘积):

a = tf.constant(np.random.normal(size=[5,4,3]), dtype='float32')

这里,a 是一个看起来像这样的 tf.Tensor:

>>> tf.Tensor(
    [[[-0.7665215   0.9611947   1.456347  ]
      [-0.52979267 -0.2647674  -0.57217133]
      [-0.7511135   2.2282166   0.6573406 ]
      [-1.1323775   0.3301812   0.1310132 ]]
     ...
     [[ 0.42760614  0.17308706 -0.90879506]
      [ 0.5347165   2.569637    1.3013649 ]
      [ 0.95198756 -0.74183583 -1.2316796 ]
      [-0.03830088  1.1367576  -1.2704859 ]]], shape=(5, 4, 3), dtype=float32)

让我们首先获取此张量的所有元素的总和。换句话说,在所有轴上减少张量:

red_a1 = tf.reduce_sum(a)

这产生

>>> -4.504758

接下来,让我们在轴 0 上获取产品(即,对 a 的每一行进行逐元素乘积):

red_a2 = tf.reduce_prod(a, axis=0)

这产生

>>> [[-0.04612858  0.45068324  0.02033644]
     [-0.27674386 -0.03757533 -0.33719817]
     [-1.4913832  -2.1016302  -0.39335614]
     [-0.00213956  0.14960718  0.01671476]]

现在我们将在多个轴(即 0 和 1)上获取最小值:

red_a3 = tf.reduce_min(a, axis=[0,1])

这产生

>>> [-1.6531237 -1.6245098 -1.4723392]

你可以看到,无论何时在某个维度上执行缩减操作,你都会失去该维度。例如,如果你有一个大小为[6,4,2]的张量,并且在轴 1 上缩减该张量(即第二个轴),你将得到一个大小为[6,2]的张量。在某些情况下,你需要在缩减张量的同时保留该维度(导致一个[6,1,2]形状的张量)。一个这样的情况是使你的张量广播兼容另一个张量(mng.bz/g4Zn)。广播是一个术语,用来描述科学计算工具(例如 NumPy/TensorFlow)在算术操作期间如何处理张量。在这种情况下,你可以将 keepdims 参数设置为 True(默认为 False)。你可以看到最终输出的形状的差异

# Reducing with keepdims=False
red_a1 = tf.reduce_min(a, axis=1)
print(red_a1.shape)

这产生

>>> [5,3]

# Reducing with keepdims=True
red_a2 = tf.reduce_min(a, axis=1, keepdims=True)
print(red_a2.shape)

这产生

>>> red_a2.shape = [5,1,3]

表 2.5 中概述了其他几个重要的函数。

表 2.5 TensorFlow 提供的数学函数

tf.argmax 描述 计算给定轴上最大值的索引。例如,以下示例显示了如何在轴 0 上计算 tf.argmax。
用法 d = tf.constant([[1,2,3],[3,4,5],[6,5,4]])d_max1 = tf.argmax(d, axis=0)
结果 tf.Tensor ([2,2,0])
tf.argmin 描述 计算给定轴上最小值的索引。例如,以下示例显示了如何在轴 1 上计算 tf.argmin。
用法 d = tf.constant([[1,2,3],[3,4,5],[6,5,4]])d_min1 = tf.argmin(d, axis=1)
结果 tf.Tensor([[0],[0],[0]])
tf.cumsum 描述 计算给定轴上向量或张量的累积和
用法 e = tf.constant([1,2,3,4,5])e_cumsum = tf.cumsum(e)
结果 tf.Tensor([1,3,6,10,15])

我们在这里结束了对 TensorFlow 基本原语的讨论。接下来我们将讨论在神经网络模型中常用的一些计算。

练习 4

还有另一个计算平均值的函数叫做 tf.reduce_mean()。给定包含以下值的 tf.Tensor 对象 a,你能计算每列的平均值吗?

0.5 0.2 0.7
0.2 0.3 0.4
0.9 0.1 0.1

2.3 TensorFlow 中与神经网络相关的计算

这里我们将讨论一些支撑深度神经网络的关键低级操作。假设你在学校学习计算机视觉课程。对于你的作业,你必须使用各种数学运算来操作图像,以实现各种效果。我们将使用一张著名的狒狒图像(图 2.7),这是计算机视觉问题的常见选择。

02-07

图 2.7 狒狒的图像

2.3.1 矩阵乘法

你的第一个任务是将图像从 RGB 转换为灰度。为此,你必须使用矩阵乘法。让我们首先了解什么是矩阵乘法。

Lena 的故事

尽管我们在练习中使用了狒狒的图像,但长期以来,一直有一个传统,即使用 Lena(一位瑞典模特)的照片来演示各种计算机视觉算法。关于这是如何成为计算机视觉问题的规范的背后有一个非常有趣的故事,您可以在mng.bz/enrZ上阅读。

使用 tf.matmul()函数在两个张量之间执行矩阵乘法。对于两个矩阵,tf.matmul()执行矩阵乘法(例如,如果您有大小为[4,3]和大小为[3,2]的矩阵,矩阵乘法将得到一个[4,2]张量)。图 2.8 说明了矩阵乘法操作。

02-08

图 2.8 在一个 4×3 矩阵和一个 3×2 矩阵之间进行矩阵乘法,得到一个 4×2 矩阵。

更一般地说,如果您有一个 n×m 矩阵(a)和一个 m×p 矩阵(b),则矩阵乘法 c 的结果如下:

02_08a

但是,如果您有高维张量 a 和 b,则将在 a 的最后一个轴上和 b 的倒数第二个轴上执行总乘积。a 和 b 张量的维度除了最后两个轴外都需要相同。例如,如果您有一个大小为[3,5,7]的张量 a 和大小为[3,7,8]的张量 b,则结果将是一个大小为[3,5,8]的张量。

回到我们的问题,给定三个 RGB 像素,您可以使用以下方法将其转换为灰度像素。

0.3 * R + 0.59 * G + 0.11 * B

这是将任何 RGB 图像转换为灰度图像的常见操作(mng.bz/p2M0),这取决于手头的问题是否重要。例如,要从图像中识别数字,颜色并不那么重要。通过将图像转换为灰度,您实质上通过减少输入的大小(一个通道而不是三个)并去除噪声特征(即,颜色信息),从而帮助模型。

给定一个 512×512×3 的图像,如果您将其与代表所提供权重的 3×1 数组相乘,您将得到大小为 512×512×1 的灰度图像。然后,我们需要删除灰度图像的最后一个维度(因为它是一个),最终得到大小为 512×512 的矩阵。对此,您可以使用 tf.squeeze()函数,该函数删除大小为一的任何维度(请参阅下一个列表)。

列表 2.2 使用矩阵乘法将 RGB 图像转换为灰度图像

from PIL import Image                                          ❶
import tensorflow as tf
import numpy as np

x_rgb = np.array(Image.open("baboon.jpg")).astype('float32')   ❷
x_rgb = tf.constant(x_rgb)                                     ❸

grays = tf.constant([[0.3], [0.59] ,[0.11]])                   ❹

x = tf.matmul(x_rgb, grays)                                    ❺
x = tf.squeeze(x)                                              ❻

❶ PIL 是用于基本图像处理的 Python 库。

❷ 大小为 512×512×3 的 RGB 图像被加载为 NumPy 数组。

❸ 将 NumPy 数组转换为 tf.Tensor。

❹ 作为一个 3×1 数组的 RGB 权重

❺ 执行矩阵乘法以获得黑白图像

❻ 去掉最后一个维度,即 1

矩阵乘法在全连接网络中也是一项重要操作。为了从输入层到隐藏层,我们需要使用矩阵乘法和加法。暂时忽略非线性激活,因为它只是一个逐元素的转换。图 2.9 可视化了您之前构建的 MLP 的隐藏层计算。

02-09

图 2.9 计算发生在隐藏层的插图。x 是输入(1×4),W 是权重矩阵(4×3),b 是偏差(1×3),最终,h 是输出(1×3)。

2.3.2 卷积操作

接下来的任务是实现边缘检测算法。知道可以使用卷积操作检测边缘后,您还想使用 TensorFlow 展示自己的技能。好消息是,您可以做到!

卷积操作在卷积神经网络中非常重要,卷积神经网络是用于图像相关的机器学习任务(例如图像分类、物体检测)的深度网络。卷积操作将 窗口(也称为filterkernel)移动到数据上,同时在每个位置产生单个值。卷积窗口在每个位置都有一些值。对于给定位置,卷积窗口中的值是元素乘积并与数据中与该窗口重叠的部分相加,以产生该位置的最终值。卷积操作如图 2.10 所示。

02-10

图 2.10 卷积操作的计算步骤

根据您选择的卷积窗口的值,您可以产生一些独特的效果。您可以尝试在setosa.io/ev/image-kernels/上尝试一些流行的核。边缘检测也是一种流行的计算机视觉技术,可以使用卷积操作来实现。TensorFlow 提供了 tf.nn.convolution()函数来执行卷积。

首先,我们将黑白狒狒图片存储在 tf.Tensor 中,并将其输入变量命名为 x,x 是一个大小为 512×512 的矩阵。现在,让我们从中创建一个名为 y 的新变量:

y = tf.constant(x)

接下来,让我们定义我们的边缘检测 filter。我们将使用一种名为 近似拉普拉斯滤波器 的边缘检测滤波器,它是一个填有 -1 值的 3 × 3 矩阵,除了最中间的值是 8 外。请注意,内核的总和为零:

filter = tf.Variable(np.array([[-1,-1,-1],[-1,8,-1],[-1,-1,-1]]).astype('float32'))

接下来我们需要对 y 和 filter 进行 reshape,因为 tf.nn.convolution() 函数接受具有非常特定形状的输入和 filter。第一个约束是 y 和 filter 应具有相同的 rank。在这里,rank 指数据中的维数个数。我们这里有 rank2 的张量,将进行二维卷积。要执行 2D 卷积,输入和 kernel 都需要是 rank 4。因此我们需要对输入和 kernel 进行几步重塑:

  1. 在输入的开始和结尾添加两个更多的维度。开始的维度表示批量维度,最后的维度表示通道维度(例如,图像的 RGB 通道)。虽然在我们的示例中值为 1,但我们仍然需要这些维度存在(例如,一个大小为[512,512]的图像将重塑为[1,512,512,1])。

  2. 在 filter 的末尾添加两个大小为 1 的额外维度。这些新维度表示输入和输出通道。我们有一个单通道(即灰度)输入,我们也希望产生一个单通道(即灰度)的输出(例如,一个大小为[3,3]的核将被重塑为[3,3,1,1])。

注意 张量的阶数是指该张量的维数。这与矩阵的秩不同。

如果你不完全明白为什么我们添加了这些额外的维度,不要担心。当我们在后面的章节中讨论卷积神经网络中的卷积操作时,这将更有意义。现在,你只需要理解卷积操作的高级行为就可以了。在 TensorFlow 中,你可以按如下方式重塑 y 和 filter:

y_reshaped = tf.reshape(y, [1,512,512,1])
filter_reshaped = tf.reshape(filter, [3,3,1,1])

这里,y 是一个 512×512 的张量。表达式 tf.reshape(y, [1,512,512,1])将 y(即一个 2D 张量)重塑为一个 4D 张量,大小为 1×512×512×1。同样,filter(即一个大小为 3×3 的 2D 张量)被重塑为一个 4D 张量,大小为 3×3×1×1。请注意,在重塑过程中元素的总数保持不变。现在你可以计算卷积输出如下:

y_conv = tf.nn.convolution(y_reshaped, filter_reshaped)

你可以将边缘检测的结果可视化并将其与原始图像进行比较,如图 2.11 所示。

02-11

图 2.11 原始黑白图像与边缘检测结果的比较

在下一节中,我们将讨论另一种操作,即 pooling 操作。

2.3.3 Pooling 操作

我们接下来的任务是将经过边缘检测后的图像的宽度和高度减半。例如,如果我们有一个 512×512 的图像,并且需要将其调整为 256×256,pooling 操作是实现这一目标的最佳方式。出于这个原因,pooling(或子采样)操作在卷积神经网络中常被用于减小输出的尺寸,以便可以使用更少的参数从数据中学习。

为什么它被称为 pooling 操作?

子采样操作之所以也被称为“pooling”可能是因为这个词的含义以及统计学的原因。pooling一词用于描述将事物合并为一个单一实体,这也正是此操作所做的(例如通过平均值或取最大值)。在统计学中,你会发现术语pooled variance,它是两个群体之间方差的加权平均值(mng.bz/OGdO),本质上将两个方差合并为一个方差。

在 TensorFlow 中,您可以调用 tf.nn.max_pool()函数进行最大池化,调用 tf.nn.avg_pool()函数进行平均池化:

z_avg = tf.nn.avg_pool(y_conv, (1,2,2,1), strides=(1,2,2,1), padding='VALID')
z_max = tf.nn.max_pool(y_conv, (1,2,2,1), strides=(1,2,2,1), padding='VALID')

池化操作是卷积神经网络中常见的另一种操作,它的工作原理与卷积操作类似。但与卷积操作不同的是,池化操作的内核中没有值。在给定位置,池化操作取得与数据中内核重叠的部分的平均值或最大值。在给定位置产生平均值的操作称为平均池化,而产生最大值的操作称为最大池化。图 2.12 说明了最大池化操作。

02-12

图 2.12:最大池化操作。池化窗口在图像上从一个位置移动到另一个位置,同时一次产生一个值(即与池化窗口重叠的图像中的最大值)。

我们有一个形状为[1,510,510,1]的 4D 张量 y_conv。你可能会注意到,这些维度略小于原始图像的大小(即 512)。这是因为,在对具有 h 高度和 w 宽度的图像进行没有额外填充的大小为 c×c 的窗口卷积时,得到的图像的维度为 h-c+1 和 w-c+1。我们可以进行如下所示的池化操作。您可以使用以下函数进行平均池化或最大池化:

z_avg = tf.nn.avg_pool(y_conv, (1,2,2,1), strides=(1,2,2,1), padding='VALID')
z_max = tf.nn.max_pool(y_conv, (1,2,2,1), strides=(1,2,2,1), padding='VALID')

这将得到两个图像,z_avg 和 z_max;它们的形状都是[1,255,255,1]。为了仅保留高度和宽度维度并移除大小为 1 的冗余维度,我们使用 tf.squeeze()函数:

z_avg = np.squeeze(z_avg.numpy())
z_max = np.squeeze(z_max.numpy())

您可以使用 Python 的绘图库 matplotlib 绘制 z_avg 和 z_max 并获得图 2.13 中所示的结果。代码已在笔记本中提供。

02-13

图 2.13:边缘检测后的结果与平均或最大池化后的结果

图 2.13 显示了不同类型池化的效果。仔细观察,您会看到平均池化结果更加一致和连续,而最大池化结果则更加嘈杂。

请注意,与卷积操作不同,我们没有提供滤波器(或内核),因为池化操作没有滤波器。但是我们需要传递窗口的维度。这些维度表示输入的相应维度(即它是一个[batch 维度、高度、宽度、通道]的窗口)。除此之外,我们还传递了两个参数:stride 和 padding。我们将在后面的章节中详细讨论这些参数。

练习 5

给定一个大小为 256×256 的灰度图像 img 和一个大小为 5×5 的卷积滤波器 f。你能编写 tf.reshape()函数调用和 tf.nn.convolution()操作吗?输出的大小会是多少?

很好!现在你已经了解了深度学习网络中最常用的操作。我们将在此结束关于 TensorFlow 基础知识的讨论。在下一章中,我们将讨论 TensorFlow 中提供的一个高级 API,称为 Keras,它对于模型构建特别有用。

摘要

  • TensorFlow 是一个端到端的机器学习框架。

  • TensorFlow 提供了一个生态系统,便于模型原型设计、模型构建、模型监控和模型提供。

  • TensorFlow 1 使用声明式图执行风格(先定义,然后运行),而 TensorFlow 2 使用命令式图执行风格(运行时定义)。

  • TensorFlow 提供了三个主要构建模块:tf.Variable(用于随时间变化的值)、tf.Tensor(随时间固定的值)和 tf.Operation(对 tf.Variable 和 tf.Tensor 对象执行的转换)。

  • TensorFlow 提供了几个用于构建神经网络的操作,如 tf.matmul、tf.nn.convolution 和 tf.nn.max_pool。

  • 您可以使用 tf.matmul 将 RGB 图像转换为灰度图像。

  • 您可以使用 tf.nn.convolution 来检测图像中的边缘。

  • 您可以使用 tf.nn.max_pool 来调整图像大小。

练习答案

练习 1: 2

练习 2: tf.Variable(np.array([[1,2,3],[4,3,2]], dtype=”int16”)

练习 3: tf.constant(np.random.normal(size=[4,1,5]))

练习 4: tf.reduce_mean(a, axis=1)

练习 5:

img_reshaped = tf.reshape(img, [1,256,256,1])
f_reshaped = tf.reshape(f, [5,5,1,1])
y = tf.nn.convolution(img_reshaped, f_reshaped)

最终输出的形状将是 [1,252,252,1]。卷积操作的结果大小为图像大小 - 卷积窗口大小 + 1。

第三章:Keras 和 TensorFlow 2 中的数据检索

本章涵盖的内容

  • Keras 中用于构建模型的不同 API

  • 检索和操作持久化数据

我们已经探讨了低级 TensorFlow API 的细节,比如定义 tf.Variable 对象和 tf.Tensor 对象,这些对象可以用来存储数字和字符串等。我们还查看了 TensorFlow 提供的一些常用功能,如 tf.Operation。最后,我们详细讨论了一些复杂的操作,比如矩阵乘法和卷积。如果你分析任何标准的深度神经网络,你会发现它由矩阵乘法和卷积等标准数学操作构成。

然而,如果你要使用低级别的 TensorFlow API 实现这些网络,你会发现自己在代码中多次复制这些操作,这将耗费宝贵的时间,并使代码难以维护。但好消息是你不需要这样做。TensorFlow 提供了一个名为 Keras 的子模块,它解决了这个问题,这也是本章的重点。Keras 是 TensorFlow 中的一个子库,它隐藏了构建模块,并为开发机器学习模型提供了高级 API。在本章中,我们将看到 Keras 提供了几种不同的 API,可以根据解决方案的复杂性来选择使用。

我们将通过讨论机器学习的另一个重要方面来结束本章:向模型提供数据。通常,我们需要从磁盘(或网络)中检索数据,并在将其提供给模型之前清理和处理数据。我们将讨论 TensorFlow 中几种不同的数据检索工具,如 tf.data 和 tensorflow-datasets API,以及它们如何简化读取和操作最终输入模型的数据。

3.1 Keras 模型构建 API

作为黑客马拉松的一部分,您正在开发一个花卉物种分类器。您的团队将创建几个不同的多层感知器变体,以便将它们的性能与花卉物种识别数据集进行比较。目标是训练能够在给定花卉的多个测量值的情况下输出花卉物种的模型。您需要开发的模型如下:

  • 模型 A——仅从提供的特征中学习的模型(基线)

  • 模型 B——除了使用特征本身外,还使用特征的主成分(详见 3.1.3 节)

  • 模型 C——一个使用非常规隐藏层计算的模型,它使用了一个乘法偏差,除了传统的加法偏差之外,这在神经网络中通常是找不到的(详见 3.1.4 节)

您计划使用 Keras,并且知道它提供了多个模型构建 API。为了快速提供结果,您需要知道在哪个模型中使用哪个 Keras API。

Keras (keras.io/) 最初是作为一个高级 API 启动的,可以使用多个低级后端(例如,TensorFlow、Theano),并允许开发人员轻松构建机器学习模型。换句话说,Keras 隐藏了低级操作的细节,并提供了一个直观的 API,您可以用几行代码构建模型。自从 TensorFlow 1.4 以来,Keras 已经集成到了 TensorFlow 中(www.tensorflow.org/guide/keras/overview)。您可以使用import tensorflow.keras导入 Keras。Keras 有三个主要的 API:

  • 顺序式

  • 函数式

  • 子类化

顺序式 API 是最容易使用的。然而,它是一个非常受限制的 API,只允许您创建一个以一个输入开始,经过一系列层,以及以一个输出结束的网络。接下来,函数式 API 需要更多的工作才能使用。但它也提供了更多的灵活性,例如具有多个输入、并行层和多个输出。最后,子类化 API 可以被认为是最难驾驭的。其思想是创建一个代表您的模型或模型中的一层的 Python 对象,同时使用 TensorFlow 提供的低级功能来实现所需的功能。让我们简要地介绍一下如何使用这些 API。但我们不会止步于此;在接下来的章节中,我们将更详细地了解这些 API。图 3.1 突出显示了 API 之间的主要区别。

03-01

图 3.1 顺序式、函数式和子类化 API 的比较。

在这里,对于模型 A,我们将使用顺序式 API,因为它是最简单的。要实现模型 B,其中将有两个输入层,我们将使用函数式 API。最后,要实现模型 C,其中我们需要实现一个自定义层,我们将使用子类化 API。

3.1.1 引入数据集

假设您决定使用一个名为鸢尾花数据集(archive.ics.uci.edu/ml/datasets/Iris)的流行机器学习数据集。这个数据集记录了几种不同种类的鸢尾花(Iris-setosa、Iris-versicolor 和 Iris-virginica)的萼片长度、萼片宽度、花瓣长度和花瓣宽度。对于每朵花,我们都有萼片长度/宽度和花瓣长度/宽度。正如您所看到的,每个输入都有四个特征,每个输入都可以属于三个类别之一。首先,让我们下载数据,对其进行快速分析,并将其格式化为我们可以方便地用于模型训练的格式。

首先,您需要确保环境设置正确,并且已安装所需的库,如附录 A 中所述。接下来,打开位于 Ch03-Keras-and-Data-Retrieval/3.1.Keras_APIs.ipynb 的 Jupyter 笔记本。现在,如笔记本中的代码所示,我们需要导入 requests 库来下载数据,导入 pandas 来操作该数据,当然,还有 TensorFlow:

import requests
import pandas as pd
import tensorflow as tf

现在我们将下载数据并将数据保存到文件中:

url = "https:/ /archive.ics.uci.edu/ml/machine-learning-databases/iris/iris.data"
r = requests.get(url)

# Writing data to a file
with open('iris.data', 'wb') as f:
  f.write(r.content)

然后,我们使用 pandas 库的 read_csv() 函数读取数据 (mng.bz/j2Op):

iris_df = pd.read_csv('iris.data', header=None)

这里,iris_df 是一个 pandas DataFrame (mng.bz/Wxaw)。在最简单的形式下,数据帧可以被认为是一个按行和列组织的信息矩阵。您可以使用 iris_df.head() 命令检查数据的前几行,其结果如下:

0     1       2       3       4
0     5.1     3.5     1.4     0.2     Iris-setosa
1     4.9     3.0     1.4     0.2     Iris-setosa
2     4.7     3.2     1.3     0.2     Iris-setosa

然后,我们将对数据进行一些装饰性修改,使其看起来更好。我们将提供适当的列名称(可从数据集的网页中获取)

iris_df.columns = ['sepal_length', 'sepal_width', 'petal_width', 'petal_length', 'label']

并将字符串标签映射为整数:

iris_df["label"] = iris_df["label"].map({'Iris-setosa':0, 'Iris-versicolor':1, 'Iris-virginica':2})

我们得到了以下改进后的 pandas DataFrame:

      sepal_length   sepal_width   petal_width   petal_length   label
0     5.1             3.5           1.4           0.2            0
1     4.9             3.0           1.4           0.2            0
2     4.7             3.2           1.3           0.2            0

作为最后一步,我们将通过从每列中减去均值来将数据居中,因为这通常会导致更好的性能:

iris_df = iris_df.sample(frac=1.0, random_state=4321)
x = iris_df[["sepal_length", "sepal_width", "petal_width", "petal_length"]]
x = x - x.mean(axis=0)
y = tf.one_hot(iris_df["label"], depth=3)

在这里,print(x) 将输出

        sepal_length  sepal_width  petal_width  petal_length
31      -0.443333        0.346    -2.258667     -0.798667
23      -0.743333        0.246    -2.058667     -0.698667
70       0.056667        0.146     1.041333      0.601333
100      0.456667        0.246     2.241333      1.301333
44      -0.743333        0.746    -1.858667     -0.798667
..            ...          ...          ...           ...

注意 在对数据进行洗牌后,索引不再按顺序排列。print(y) 将输出

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

对数据进行洗牌是一个重要的步骤:数据是按特定顺序排列的,每个类别都紧跟在另一个后面。但是当数据被洗牌时,每个被呈现给网络的批次都有所有类别的良好混合,这样可以获得最佳结果。您还可以看到我们对 y(或标签)使用了一种称为 独热编码 的转换。独热编码将每个标签转换为唯一的零向量,其中一个元素为一。例如,标签 0、1 和 2 被转换为以下独热编码向量:

0 → [1, 0, 0]

1 → [0, 1, 0]

2 → [0, 0, 1]

3.1.2 顺序 API

数据准备就绪后,是时候实现模型 A 了,这是第一个神经网络。第一个模型非常简单,只需要提供的特征并预测花的种类。您可以使用 Keras 顺序 API,因为它是最简单的,我们所需要做的就是将几个层顺序堆叠在一起。图 3.2 描述了顺序 API 与其他 API 的比较。

03-02

图 3.2 顺序 API 与其他 API 的比较(被标记为灰色)

让我们创建一个具有以下特点的网络:

  • 一个具有 4 个节点的输入层

  • 一个具有 32 个节点的隐藏层

  • 一个具有 16 个节点的隐藏层

  • 一个 3 节点输出层

注意 每层节点数是模型的超参数。在这种情况下,我们任意选择了这些值。但为了获得最佳结果,我们应该使用一个超参数优化算法 (mng.bz/8MJB) 来找到给定问题的最佳超参数。

在定义模型之前,我们需要导入 TensorFlow 中的某些层和顺序模型。然后,您可以使用一行代码即可实现该模型(见下一个清单)。

利用 Sequential API 实现的模型 A,如 3.1 清单所示。

from tensorflow.keras.layers import Dense            ❶
from tensorflow.keras.models import Sequential       ❶
import tensorflow.keras.backend as K                 ❶

K.clear_session()                                    ❷
model = Sequential([                                 ❸
    Dense(32, activation='relu', input_shape=(4,)),  ❸
    Dense(16, activation='relu'),                    ❸
    Dense(3, activation='softmax')                   ❸
])

❶导入必要的模块和类。

❷在创建模型之前清除 TensorFlow 计算图。

❸使用 Sequential API 定义模型。

让我们分析一下我们刚刚做的。您可以使用 Sequential 对象创建一个顺序模型,然后传递一个层的序列,例如 Dense 层。一个层封装了神经网络中可以找到的典型的可重复使用的计算(例如隐藏层计算,卷积操作)。

Dense 层提供了全连接网络中所发生的核心计算(即通过 h = activation(xW + b)从输入(x)到隐藏输出(h))。Dense 层有两个重要参数:隐藏单元的数量和非线性激活函数。通过堆叠一组 Dense 层,您可以构建一个多层的全连接网络。我们正在使用以下层构建网络:

  • Dense(32, activation='relu', input_shape=(4,))

  • Dense(16, activation='relu')

  • Dense(3, activation='softmax')

在第一个 Dense 层中,可以看到传递了一个额外的参数 input_shape。input_shape 是使用 TensorFlow 创建的任何模型的关键属性。您必须确切地知道要传递给模型的输入的形状,因为随后的所有层的输出都取决于输入的形状。实际上,某些层只能处理某些特定的输入形状。

在这个例子中,我们说输入的形状将是[None, 4]。虽然我们只在形状中指定了 4,但 Keras 会自动添加一个未指定的(即 None)维度到 input_shape 中,它表示输入的批次维度。正如您可能已经知道的,深度神经网络以批次的方式处理数据(即一次处理多个示例)。另一个尺寸(大小为 4)是特征维度,意味着网络可以接受具有四个特征的输入。将批次维度设为 None 将批次维度未指定,允许您在模型训练/推断时传递任意数量的示例。

一个层的另一个重要方面是层中使用的非线性激活函数。在这里,我们可以看到前两个层使用了 ReLU(修正线性单元)激活函数。它是前馈模型中非常简单但功能强大的激活函数。ReLU 具有以下功能:

y = max (0, x)

最后一层使用了 softmax 激活函数。正如之前讨论的,softmax 激活函数将最后一层(即 logits)的得分归一化为一个有效的概率分布。具体来说,

03_02a

以一个示例为例,假设最后一层没有使用 softmax 激活函数产生了

[15, 30, 5]

应用 softmax 归一化将这些值转换为

[15/(15+30+5), 30/(15+30+5), 5/(15+30+5)]
= [0.3, 0.6, 0.1]

现在模型已经定义好了,我们需要执行一个关键步骤,称为模型编译,如果我们要成功地使用它的话。对于我们的模型,我们将使用

model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['acc'])

在这里,我们设置了模型的损失函数、优化器和度量标准。损失函数表示模型在给定数据上的表现如何(例如,分类交叉熵)。损失越低,模型就越好。除了损失函数之外,我们还使用了一个优化器,它知道如何改变模型的权重和偏差,以使损失减少。在这里,我们选择了损失函数 categorical_crossentropy(mng.bz/EWej),这通常在多类别分类问题中效果良好,以及优化器 adam(arxiv.org/pdf/1412.6980.pdf),由于其在各种问题中的出色表现,是一个常见的选择。我们还可以选择性地定义度量标准来关注模型(例如,模型准确率)。最后,我们可以使用以下方法检查您刚刚创建的模型

model.summary()

输出

Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
dense_3 (Dense)              (None, 32)                160       
_________________________________________________________________
dense_4 (Dense)              (None, 16)                528       
_________________________________________________________________
dense_5 (Dense)              (None, 3)                 51        
=================================================================
Total params: 739
Trainable params: 739
Non-trainable params: 0
_________________________________________________________________

模型摘要清晰地显示了层数、每个层的类型、每个层的输出形状以及每个层的参数数量。让我们使用之前准备好的数据集来训练这个模型,以对各种鸢尾花进行分类。我们使用方便的 fit()函数来训练一个 Keras 模型:

model.fit(x, y, batch_size=64, epochs=25)

fit()函数接受许多不同的参数:

  • X—数据特征

  • Y—数据标签(独热编码)

  • 批处理大小(可选)—单个批次中的数据点数量

  • epochs(可选)—模型训练期间重复数据集的次数

像 batch_size 和 epochs 这样的值是经验性地选择的。如果你运行前面的代码,你将得到以下结果:

Train on 150 samples
Epoch 1/25
150/150 [==============================] - 0s 2ms/sample - loss: 1.1773 - acc: 0.2667
Epoch 2/25
150/150 [==============================] - 0s 148us/sample - loss: 1.1388 - acc: 0.2933
...
Epoch 24/25
150/150 [==============================] - 0s 104us/sample - loss: 0.6254 - acc: 0.7400
Epoch 25/25
150/150 [==============================] - 0s 208us/sample - loss: 0.6078 - acc: 0.7400

看起来我们的小型项目相当成功,因为我们观察到训练准确率(“acc”)在只有 25 个 epochs 的情况下稳步增长到了 74%。然而,仅仅依靠训练准确率来决定一个模型是否表现更好是不明智的。有各种技术可以做到这一点,我们将在接下来的章节中进行回顾。

机器学习中的可重现性

可重现性是机器学习中的一个重要概念。可重现性意味着你可以运行一个实验,发布结果,并确保对你的研究感兴趣的人可以复现结果。它还意味着你将在多次试验中得到相同的结果。如果你看一下笔记本 ch02/1.Tensorflow_ Fundamentals.ipynb,你会看到我们已经采取的一项措施,以确保结果在多次试验中保持一致。你将在“Library imports and some setups”部分看到以下代码:

def fix_random_seed(seed):
    try:
        np.random.seed(seed)
    except NameError:
        print("Warning: Numpy is not imported. Setting the seed for Numpy failed.")
    try:
        tf.random.set_seed(seed)
    except NameError:
        print("Warning: TensorFlow is not imported. Setting the seed for TensorFlow failed.")
    try:
        random.seed(seed)
    except NameError:
        print("Warning: random module is not imported. Setting the seed for random failed.")

# Fixing the random seed
fix_random_seed(4321)

随机种子是影响研究可重复性的一个常见因素,因为神经网络普遍使用随机初始化。通过固定种子,你可以确保每次运行代码时都会得到相同的随机数序列。这意味着在多次试验中,模型的权重和偏置初始化是相同的,前提是其他条件没有改变。

为了确保你的代码能够产生一致的结果,请在尝试代码练习时调用 fix_random_seed 函数(通过运行第一个代码单元格)。

3.1.3 函数式 API

现在是时候实现第二个模型(即,模型 B)了,该模型使用主成分作为额外的输入。希望这个额外输入(主成分)能为模型提供额外的特征,从而提高模型的性能。主成分是使用一种称为主成分分析(PCA)的算法提取出来的。PCA 是一种降维技术,它会将高维数据投影到一个较低维度的空间中,同时努力保留数据中存在的方差。现在你需要创建一个模型,该模型接受两个不同的输入特征集。

你不能再使用 Sequential API,因为它只能处理顺序模型(即,单输入层通过一系列层产生单输出)。在这里,我们有两个不同的输入:花卉的原始特征和 PCA 特征。这意味着两个层以并行方式工作,产生两个不同的隐藏表示,并将它们连接起来,最后为输入产生类别概率,如图 3.3 所示。函数式 API 对于这种类型的模型是一个很好的选择,因为它可以用于定义具有多个输入或多个输出的模型。

03-03

图 3.3 函数式 API 与其他 API 的对比(灰色块为无法使用的功能)

让我们开始吧。首先,我们需要导入以下层和模型对象,因为它们将成为我们模型的核心:

from tensorflow.keras.layers import Input, Dense, Concatenate
from tensorflow.keras.models import Model

接下来,我们需要创建两个 Input 层(用于原始输入特征和 PCA 特征):

inp1 = Input(shape=(4,))
inp2 = Input(shape=(2,))

原始输入特征的 Input 层将有四个特征列,而 PCA 特征的 Input 层将只有两个特征列(因为我们只保留了前两个主成分)。如果回顾一下我们如何使用 Sequential API 定义模型,你会注意到我们没有使用 Input 层。但在使用函数式 API 时,我们需要明确指定我们需要包含在模型中的 Input 层。

定义了两个 Input 层后,我们现在可以计算这些层的单独隐藏表示:

out1 = Dense(16, activation='relu')(inp1)
out2 = Dense(16, activation='relu')(inp2)

这里,out1 表示 inp1 的隐藏表示(即原始特征),out2 是 inp2 的隐藏表示(即 PCA 特征)。然后我们连接这两个隐藏表示:

out = Concatenate(axis=1)([out1,out2])

让我们更详细地了解在使用 Concatenate 层时会发生什么。Concatenate 层只是沿着给定的轴连接两个或多个输入。在此示例中,我们有两个输入传递给 Concatenate 层(即 [None, 16] 和 [None, 16]),并希望沿着第二个轴(即 axis=1)进行连接。请记住,当您指定 shape 参数时,Keras 会向输入/输出张量添加一个额外的批次维度。这个操作的结果是一个大小为 [None, 32] 的张量。从这一点开始,您只有一个序列的层。我们将定义一个具有 relu 激活函数的 16 节点 Dense 层,最后是一个具有 softmax 归一化的三节点输出层:

out = Dense(16, activation='relu')(out)
out = Dense(3, activation='softmax')(out)

我们需要做一步额外的工作:创建一个 Model 对象,说明输入和输出是什么。现在,我们有一堆层和没有 Model 对象。最后,我们像之前一样编译模型。我们选择 categorical_crossentropy 作为损失函数,adam 作为优化器,像之前一样。我们还将监控训练准确率:

model = Model(inputs=[inp1, inp2], outputs=out)
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['acc'])

这个模型的完整代码在以下清单中提供。

列表 3.2 使用 Keras 函数式 API 实现的模型 B

from tensorflow.keras.layers import Input, Dense, Concatenate
from tensorflow.keras.models import Model
import tensorflow.keras.backend as K

K.clear_session()                                                                ❶

inp1 = Input(shape=(4,))                                                         ❷
inp2 = Input(shape=(2,))                                                         ❷

out1 = Dense(16, activation='relu')(inp1)                                        ❸
out2 = Dense(16, activation='relu')(inp2)                                        ❸

out = Concatenate(axis=1)([out1,out2])                                           ❹

out = Dense(16, activation='relu')(out)
out = Dense(3, activation='softmax')(out) 

model = Model(inputs=[inp1, inp2], outputs=out)                                  ❺
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['acc'])❻

❶ 确保清除 TensorFlow 图

❷ 两个输入层。一个输入层具有四个特征,另一个输入层具有两个特征。

❸ 两个并行隐藏层

❹ 负责将两个并行输出 out1 和 out2 进行拼接的连接层

❺ 模型定义

❻ 使用损失函数、优化器和评估指标编译模型

现在你可以打印模型的摘要了

model.summary()

得到的结果为

Model: "model"
_____________________________________________________________________________________
Layer (type)                    Output Shape         Param #     Connected to 
=====================================================================================
input_1 (InputLayer)            [(None, 4)]          0 
_____________________________________________________________________________________
input_2 (InputLayer)            [(None, 2)]          0 
_____________________________________________________________________________________
dense (Dense)                   (None, 16)           80          input_1[0][0]  
_____________________________________________________________________________________
dense_1 (Dense)                 (None, 16)           48          input_2[0][0]  
_____________________________________________________________________________________
concatenate (Concatenate)       (None, 32)           0           dense[0][0]        
                                                                 dense_1[0][0]      
_____________________________________________________________________________________
dense_2 (Dense)                 (None, 16)           528         concatenate[0][0]  
_____________________________________________________________________________________
dense_3 (Dense)                 (None, 3)            51          dense_2[0][0] 
=====================================================================================
Total params: 707
Trainable params: 707
Non-trainable params: 0
_____________________________________________________________________________________

对于这个摘要表示,你觉得怎么样?你能从这个摘要中推断出它是什么样的模型吗?很遗憾,不能。虽然我们的模型有并行层,但是摘要看起来似乎我们有一系列按顺序处理输入和输出的层。我们有没有办法获得比这更好的表示呢?是的,我们有!

Keras 还提供了以网络图的形式可视化模型的能力。您可以使用下面的代码实现:

tf.keras.utils.plot_model(model)

如果您在 Jupyter 笔记本上运行此命令,您将获得以下图形的内联输出(图 3.4)。现在我们的模型的运行情况更加清晰了。

03-04

图 3.4 使用函数式 API 创建的模型示例。可以在顶部看到并行的输入层和隐藏层。最终的输出层位于底部。

如果您需要将此图保存到文件中,只需执行以下操作:

tf.keras.utils.plot_model(model, to_file='model.png’)

如果您需要在层的名称和类型之外查看输入/输出大小,可以通过将 show_shapes 参数设置为 True 来实现

tf.keras.utils.plot_model(model, show_shapes=True)

这将返回图 3.5。

03-05

图 3.5 使用 show_shapes=True 绘制的 Keras 模型图

请记住我们有两个输入,原始特征(x)和 x 的前两个主成分(我们称之为 x_pca)。您可以如下计算前两个主成分(使用 scikit-learn 库):

from sklearn.decomposition import PCA

pca_model = PCA(n_components=2, random_state=4321)

x_pca = pca_model.fit_transform(x)

PCA 已经在 scikit-learn 中实现。你定义一个 PCA 对象,并将值 2 传递给 n_components 参数。你也固定了随机种子,以确保在各个试验中保持一致性。然后你可以调用 fit_transform(x) 方法来获得最终的 PCA 特征。你可以像之前一样训练这个模型,调用

model.fit([x, x_pca], y, batch_size=64, epochs=10)

遗憾的是,你不会看到很大的准确率提升。结果将与您之前达到的相当。在给定的代码示例中,使用这个模型时你会有大约 6% 的准确率提升。然而,你会发现,如果增加迭代次数,这个差距会变得越来越小。这主要是因为添加 PCA 特征并没有真正增加多少价值。我们将四个维度减少到两个,这不太可能产生比我们已经拥有的更好的特征。让我们在下一个练习中试试运气。

3.1.4 子类化 API

回到研究实验室,看到添加主成分并没有改善结果有点令人沮丧。然而,团队对于你对于在给定模型中使用哪个 API 的了解却印象深刻。一位团队成员建议了一个最终模型。当前,密集层是通过以下方式计算其输出的

h = α(xW + b)

其中 α 是某种非线性。你想看看是否通过添加另一个偏差(即,除了加性偏差外,我们添加了一个乘法偏差)可以改善结果,使得方程变为

h = α([xW + b] × b[mul])

这就是层子类化会拯救一切的地方,因为在 Keras 中没有预先构建的层能够提供这种功能。Keras 提供的最终 API 是子类化 API(见图 3.6),它将允许我们将所需的计算定义为一个计算单元(即,一个层),并在定义模型时轻松重用它。子类化来自软件工程概念中的继承。其思想是你有一个提供某种对象一般功能的超类(例如,一个 Layer 类),然后你从该层中派生(或继承),创建一个更具体的层,实现特定功能。

03-06

图 3.6 子类化 API 与其他 API 的比较(已灰显)

子类化 API 与顺序 API 和函数 API 有着截然不同的风格。在这里,你正在创建一个 Python 类,该类定义了层或模型的基本操作。在本书中,我们将专注于子类化层(即不包括模型)。在我看来,更多的情况下你会对层进行子类化而不是模型,因为层的子类化更加方便,可能在你只有一个模型或多个模型的情况下需要。然而,只有当你创建由许多较小模型组成的更大的复合模型时,才需要模型的子类化。值得注意的是,一旦你学会了层的子类化,扩展到模型的子类化相对容易。

当子类化层时,有三个重要的函数需要从你继承的 Layer 基类中重写:

  • init() — 使用任何它接受的参数初始化层

  • build() — 模型的参数将在这里创建

  • call() — 定义了正向传播期间需要进行的计算

这是你会写的新层。我们将适当地称呼我们的自定义层为 MulBiasDense。注意这一层是如何继承自位于 tensorflow.keras.layers 子模块中的基础层 Layer 的。

列表 3.3 使用 Keras 子类化新层

from tensorflow.keras import layers

class MulBiasDense(layers.Layer):

    def __init__(self, units=32, input_dim=32, activation=None):              ❶
        super(MulBiasDense, self).__init__()                                  ❶
        self.units = units                                                    ❶
        self.activation = activation                                          ❶

    def build(self, input_shape):                                             ❷
        self.w = self.add_weight(shape=(input_shape[-1], self.units),         ❷
                                 initializer='glorot_uniform', trainable=True)❷
        self.b = self.add_weight(shape=(self.units,),                         ❷
                                 initializer='glorot_uniform', trainable=True)❷
        self.b_mul = self.add_weight(shape=(self.units,),                     ❷
                                 initializer='glorot_uniform', trainable=True)❷

    def call(self, inputs):                                                   ❸
        out = (tf.matmul(inputs, self.w) + self.b) * self.b_mul               ❸
        return layers.Activation(self.activation)(out)                        ❸

❶ 定义了定义层所需的各种超参数

❷ 将层中的所有参数定义为 tf.Variable 对象。self.b_mul 代表了乘法偏置。

❸ 定义了在向层馈送数据时需要进行的计算

首先,我们有 init() 函数。层有两个参数:隐藏单元的数量和激活类型。激活默认为 None,意味着如果未指定,则没有非线性激活(即仅进行线性转换):

def __init__(self, units=32, activation=None):
    super(MulBiasDense, self).__init__()
    self.units = units
    self.activation = activation

接下来,我们实现 build() 函数,这是子类化中的一个重要的拼图。所有参数(例如权重和偏置)都是在这个函数内创建的:

def build(self, input_shape):
    self.w = self.add_weight(shape=(input_shape[-1], self.units),
                             initializer='glorot_uniform', trainable=True)
    self.b = self.add_weight(shape=(self.units,),
                             initializer='glorot_uniform', trainable=True)
    self.b_mul = self.add_weight(shape=(self.units,),
                                 initializer='glorot_uniform', trainable=True)

在这里,参数 w、b 和 b_mul 分别指代方程中的 Wbb[mul]。对于每个参数,我们提供了形状、初始化器和一个布尔值以指示可训练性。此处使用的初始化器 'glorot_uniform'(mng.bz/N6A7)是一种流行的神经网络初始化器。最后,我们需要编写 call() 函数,它定义了输入将如何转换为输出:

def call(self, inputs):
    out = (tf.matmul(inputs, self.w) + self.b) * self.b_mul
    return layers.Activation(self.activation)(out)

就是这样:我们的第一个子类化层。值得注意的是,在子类化层时,你需要了解的其他几个函数还有:

  • compute_output_shape() — 通常,Keras 会自动推断出层的输出形状。但是,如果你进行了太多复杂的转换,Keras 可能会迷失方向,你将需要使用这个函数明确地定义输出形状。

  • get_config() - 如果您计划在训练后将模型保存到磁盘,则需要实现此函数,该函数返回由图层使用的参数的字典。

定义了新的层后,可以像以下清单展示的那样使用函数式 API 创建模型。

清单 3.4 使用 Keras 子类化 API 实现的模型 C

from tensorflow.keras.layers import Input, Dense, Concatenate                    ❶
from tensorflow.keras.models import Model                                        ❶
import tensorflow.keras.backend as K                                             ❶
import tensorflow as tf                                                          ❶

K.clear_session()                                                                ❷

inp = Input(shape=(4,))                                                          ❸
out = MulBiasDense(units=32, activation='relu')(inp)                             ❹
out = MulBiasDense(units=16, activation='relu')(out)                             ❹
out = Dense(3, activation='softmax')(out)                                        ❺

model = Model(inputs=inp, outputs=out)                                           ❻
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['acc'])❼

❶ 导入必要的模块和类

❷ 确保我们清除 TensorFlow 图

❸ 定义输入层

❹ 使用新的子类化层 MulBiasDense 定义两个层

❺ 定义 softmax 输出层

❻ 定义最终模型

❼ 使用损失函数、优化器和准确率作为指标编译模型

不幸的是,在我们的实验中,我们尝试的所有架构改进都没有带来明显的改进。但是,您通过知道针对哪个模型使用哪个 API 使同事们感到印象深刻,使小组能够在提交论文的截止日期前准备好结果。表 3.1 进一步总结了我们讨论的 API 的主要优缺点。

表 3.1 使用不同 Keras APIs 的优缺点

Sequential API Pros 使用顺序 API 实现的模型易于理解、简洁。
Cons 无法实现具有多输入/输出等复杂架构特性的模型。
Functional API Pros 可用于实现具有多输入/输出等复杂架构元素的模型。
Cons 开发人员需要手动正确连接各种层并创建模型。
Sub-classing API Pros 可以创建不作为标准层提供的自定义层和模型。
Cons 需要深入理解 TensorFlow 提供的底层功能。
由于用户定义的性质,可能会导致不稳定性和调试困难。

在下一节中,我们将讨论您可以在 TensorFlow 中导入和摄入数据的不同方式。

练习 1

假设您需要创建一个具有单个输入层和两个输出层的全连接神经网络。您认为哪个 API 最适合这项任务?

3.2 获取 TensorFlow/Keras 模型的数据

到目前为止,我们已经看过了如何使用不同的 Keras APIs 实现各种模型。此时,您应该已经知道何时使用哪种 API(有时甚至知道不该使用哪种 API)来查看模型的架构。接下来,我们将学习如何使用 TensorFlow/Keras 读取数据来训练这些模型。

假设您最近加入了一家初创公司,作为一名数据科学家,正在尝试使用包含机器学习模型的软件来识别花的品种(使用图像)。他们已经有一个可以接受一批图像和一批标签并训练模型的自定义数据管道。然而,这个数据管道相当隐晦且难以维护。您的任务是实现一个易于理解和维护的替代数据管道。这是一个通过使用 TensorFlow 快速原型设计数据管道来给您的老板留下深刻印象的绝佳机会。

除非经过数据训练,否则模型没有任何价值。更多(高质量)的数据意味着更好的性能,因此将数据以可伸缩和高效的方式提供给模型非常重要。现在是时候探索 TensorFlow 的特性,以创建实现此目的的输入管道。有两种流行的获取数据的替代方法:

  • tf.data API

  • Keras 数据生成器

您将要处理的数据集(从 mng.bz/DgVa 下载)包含一个包含文件名和标签的 CSV(逗号分隔值)文件以及一个包含 210 个花卉图像(.png 格式)的集合。

注意 还有第三种方法,那就是使用 Python 包访问流行的机器学习数据集。这个包被称为 tensorflow-datasets。这意味着只有当您想要使用包已支持的数据集时,此方法才有效。

现在是时候伸展一下手指,开始实现数据管道了。

3.2.1 tf.data API

让我们看看输入管道可能是什么样子。例如,用于您的图像分类任务的输入管道可能看起来像图 3.7。首先,从文本文件中读取整数标签(存储为 [文件名、标签] 记录)。接下来,读取与文件名对应的图像并将其调整为固定的高度和宽度。然后,将标签转换为 one-hot 编码表示。One-hot 编码表示将整数转换为由零和一组成的向量。然后,将图像和 one-hot 编码标签压缩在一起,以保持图像与其相应标签之间的正确对应关系。现在,这些数据可以直接馈送到 Keras 模型中。

03-07

图 3.7 您将使用 tf.data API 开发的输入管道

在我们的数据集中,我们有一组花卉图像和一个包含文件名及其对应标签的 CSV 文件。我们将按照以下步骤创建数据管道:

  • 将 CSV 文件读取为 tf.data.Dataset。

  • 将文件名和标签作为单独的数据集提取出来。

  • 读取与文件名数据集中的文件名对应的图像文件。

  • 解码图像数据并将其转换为 float32 张量。

  • 将图像调整为 64 × 64 像素。

  • 将标签转换为 one-hot 编码向量。

  • 将图像数据集和 one-hot 向量数据集压缩在一起。

  • 将数据集分批为五个样本的批次。

为了将 CSV 文件读取为一个数据集实体,我们将使用方便的 tf.data.experimental.CsvDataset 对象。您可能会发现,实际上,这是一个实验性的对象。这意味着它的测试程度没有 tf.data API 中的其他功能那么多,并且在某些情况下可能会出现问题。但对于我们的小而简单的示例,不会出现任何问题:

import os # Provides various os related functions

data_dir = os.path.join('data','flower_images') + os.path.sep
csv_ds = tf.data.experimental.CsvDataset(
    os.path.join(data_dir,'flower_labels.csv') , record_defaults=("",-1), header=True
)

tf.data.experimental.CsvDataset 对象需要两个强制参数:一个或多个文件名和一个默认记录,如果记录损坏或不可读,将使用默认记录。在我们的案例中,默认记录是一个空文件名(“”)和标签 -1。您可以通过调用 tf.data.Dataset 打印一些记录

for item in csv_ds.take(5):
    print(item)

在这里,take() 是一个函数,它以数字作为参数,并从数据集中返回那么多的记录。这将输出以下内容:

(<tf.Tensor: shape=(), dtype=string, numpy=b'0001.png'>, <tf.Tensor: shape=(), dtype=int32, numpy=0>)
(<tf.Tensor: shape=(), dtype=string, numpy=b'0002.png'>, <tf.Tensor: shape=(), dtype=int32, numpy=0>)
(<tf.Tensor: shape=(), dtype=string, numpy=b'0003.png'>, <tf.Tensor: shape=(), dtype=int32, numpy=2>)
(<tf.Tensor: shape=(), dtype=string, numpy=b'0004.png'>, <tf.Tensor: shape=(), dtype=int32, numpy=0>)
(<tf.Tensor: shape=(), dtype=string, numpy=b'0005.png'>, <tf.Tensor: shape=(), dtype=int32, numpy=0>)

如果你还记得,flower_labels.csv 文件包含两列:文件名和相应的标签。您可以在数据集输出中看到,每个元组都包含两个元素:文件名和标签。接下来,我们将这两列拆分为两个单独的数据集。这可以很容易地通过使用 map() 函数来完成,该函数将一个给定的函数应用于数据集中的所有记录:

fname_ds = csv_ds.map(lambda a,b: a)
label_ds = csv_ds.map(lambda a,b: b)

Lambda 表达式

Lambda 表达式是一个很棒的工具,它使您可以在代码中使用匿名函数。就像普通函数一样,它们接受参数并返回一些输出。例如,以下函数将添加两个给定值(x 和 y):

lambda x, y : x + y

Lambda 表达式是一种很好的写函数的方式,如果它们只被使用一次,因此不需要名称。学会有效使用 lambda 表达式将使您的代码清晰而简洁。

在这里,我们使用简洁的 lambda 表达式告诉 map() 函数我们想要实现什么。现在,我们可以专注于获取图像数据。为了做到这一点,我们将再次使用 map() 函数。但这一次,我们将编写一个单独的函数来定义需要发生的事情:

import tensorflow as tf

def get_image(file_path):

    # loading the image from disk as a byte string
    img = tf.io.read_file(data_dir + file_path)
    # convert the compressed string to a 3D uint8 tensor
    img = tf.image.decode_png(img, channels=3)
    # Use `convert_image_dtype` to convert to floats in the [0,1] range.
    img = tf.image.convert_image_dtype(img, tf.float32)
    # resize the image to the desired size.
    return tf.image.resize(img, [64, 64])

要从文件名中获取图像张量,我们所需要做的就是将该函数应用于 fname_ds 中的所有文件名:

image_ds = fname_ds.map(get_image)

随着图像数据集的读取,让我们将标签数据转换为独热编码向量:

label_ds = label_ds.map(lambda x: tf.one_hot(x, depth=10))

为了训练图像分类器,我们需要两个项目:一个图像和一个标签。我们确实有这两个作为两个单独的数据集。但是,我们需要将它们合并为一个数据集,以确保一致性。例如,如果我们需要对数据进行洗牌,将数据集合并成一个非常重要,以避免不同的随机洗牌状态,这将破坏数据中的图像到标签的对应关系。tf.data.Dataset.zip() 函数让您可以轻松地做到这一点:

data_ds = tf.data.Dataset.zip((image_ds, label_ds))

我们已经做了大量工作。让我们回顾一下:

  • 读取一个包含文件名和标签的 CSV 文件作为 tf.data.Dataset

  • 将文件名(fname_ds)和标签(label_ds)分开为两个单独的数据集

  • 从文件名加载图像作为数据集(images_ds)同时进行一些预处理

  • 将标签转换为独热编码向量

  • 使用 zip() 函数创建了一个组合数据集

让我们花点时间看看我们创建了什么。tf.data.Dataset 的行为类似于普通的 python 迭代器。这意味着你可以使用循环(例如 for/while)轻松地迭代项,也可以使用 next() 等函数获取项。让我们看看如何在 for 循环中迭代数据:

for item in data_ds:
    print(item)

这会返回以下内容:

>>> (<tf.Tensor: shape=(64, 64, 3), dtype=float32, numpy=
array([[[0.05490196, 0.0872549 , 0.0372549 ],
        [0.06764706, 0.09705883, 0.04411765],
        [0.06862745, 0.09901962, 0.04509804],
        ...,
        [0.3362745 , 0.25686276, 0.21274512],
        [0.26568627, 0.18823531, 0.16176471],
        [0.2627451 , 0.18627453, 0.16960786]]], dtype=float32)>, <tf.Tensor: shape=(10,), dtype=float32, numpy=array([1., 0., 0., 0., 0., 0., 0., 0., 0., 0.], dtype=float32)>)

如你所见,item 是一个元组,第一个元素是图像张量(大小为 64 × 64 × 3),第二个元素是一个独热编码向量(大小为 10)。还有一些工作要做。首先,让我们对数据集进行洗牌,以确保在馈送给模型之前不引入任何有序数据:

data_ds = data_ds.shuffle(buffer_size= 20)

buffer_size 参数起着重要作用。它在运行时指定了加载到内存中用于洗牌的元素数量。在本例中,输入管道将加载 20 条记录到内存中,并在迭代数据时从中随机抽样。较大的 buffer_size 可以提供更好的随机化,但会增加内存需求。接下来,我们将讨论如何从数据集中创建数据批次。

请记住,我们说过 Keras 在创建模型时,如果指定了 input_shape(Sequential API)或 shape(functional API),会自动添加批次维度。这就是深度网络处理数据的方式:作为数据批次(即,不是单个样本)。因此,在将数据馈送到模型之前进行批处理非常重要。例如,如果使用批次大小为 5,如果迭代之前的数据集,你将得到一个大小为 5 × 64 × 64 × 3 的图像张量和一个大小为 5 × 10 的标签张量。使用 tf.data.Dataset API 对数据进行批处理非常简单:

data_ds = data_ds.batch(5)

你可以使用以下方式打印其中一个元素:

for item in data_ds:
    print(item)
    break

运行这个命令后,你将得到以下结果:

(
    <tf.Tensor: shape=(5, 64, 64, 3), dtype=float32, numpy=
    array(
        [
            [
                [
                    [0.5852941 , 0.5088236 , 0.39411768],
                    [0.5852941 , 0.50980395, 0.4009804 ],
                    [0.5862745 , 0.51176476, 0.40490198],
                    ...,
                    [0.82156867, 0.7294118 , 0.62352943],
                    [0.82745105, 0.74509805, 0.6392157 ],
                    [0.8284314 , 0.75098044, 0.64509803]
                ],  

                [
                    [0.07647059, 0.10784315, 0.05882353],
                    [0.07843138, 0.11078432, 0.05882353],
                    [0.11862746, 0.16078432, 0.0892157 ],
                    ...,
                    [0.17745098, 0.23529413, 0.12450981],
                    [0.2019608 , 0.27549022, 0.14509805],
                    [0.22450982, 0.28921568, 0.16470589]
                ]
            ]
        ], 
        dtype=float32
    )>, 
    <tf.Tensor: shape=(5, 10), dtype=float32, numpy=
    array(
        [
            [0., 1., 0., 0., 0., 0., 0., 0., 0., 0.],
            [1., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
            [1., 0., 0., 0., 0., 0., 0., 0., 0., 0.],
            [0., 0., 1., 0., 0., 0., 0., 0., 0., 0.],
            [0., 0., 1., 0., 0., 0., 0., 0., 0., 0.]
        ], 
        dtype=float32
    )>
)

这就是本练习的结束。下面的代码展示了最终的代码的样子。

代码清单 3.5 tf.data 用于花朵图像数据集的输入管道

import tensorflow as tf
import os

data_dir = os.path.join('data','flower_images', 'flower_images') + os.path.sep 
csv_ds = tf.data.experimental.CsvDataset(                               ❶
    os.path.join(data_dir,'flower_labels.csv') , ("",-1), header=True   ❶
)                                                                       ❶
fname_ds = csv_ds.map(lambda a,b: a)                                    ❷
label_ds = csv_ds.map(lambda a,b: b)                                    ❷

def get_image(file_path):

    img = tf.io.read_file(data_dir + file_path)
    # convert the compressed string to a 3D uint8 tensor
    img = tf.image.decode_png(img, channels=3)
    # Use `convert_image_dtype` to convert to floats in the [0,1] range.
    img = tf.image.convert_image_dtype(img, tf.float32)
    # resize the image to the desired size.
    return tf.image.resize(img, [64, 64])

image_ds = fname_ds.map(get_image)                                      ❸
label_ds = label_ds.map(lambda x: tf.one_hot(x, depth=10))              ❹
data_ds = tf.data.Dataset.zip((image_ds, label_ds))                     ❺

data_ds = data_ds.shuffle(buffer_size= 20)                              ❻
data_ds = data_ds.batch(5)                                              ❻

❶ 使用 TensorFlow 从 CSV 文件中读取数据。

❷ 将文件名和整数标签分开为两个数据集对象

❸ 从文件名中读取图像

❹ 将整数标签转换为独热编码标签

❺ 将图像和标签合并为一个数据集

❻ 对数据进行洗牌和分批处理,为模型做准备。

注意,你无法使用我们在鸢尾花数据集练习中创建的模型,因为那些是全连接网络。我们需要使用卷积神经网络来处理图像数据。为了让你有所了解,练习笔记本 3.2.Creating_Input_ Pipelines.ipynb 中提供了一个非常简单的卷积神经网络模型。不用担心这里使用的各种层和它们的参数,我们将在下一章详细讨论卷积神经网络。

model = Sequential([
    Conv2D(64,(5,5), activation='relu', input_shape=(64,64,3)),
    Flatten(),
    Dense(10, activation='softmax')
])

model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['acc'])

使用此输入管道,你可以方便地使用适当的模型馈送数据:

model.fit(data_ds, epochs=10)

运行此命令后,你将获得以下结果:

Epoch 1/10
42/42 [==============================] - 1s 24ms/step - loss: 3.1604 - acc: 0.2571
Epoch 2/10
42/42 [==============================] - 1s 14ms/step - loss: 1.4359 - acc: 0.5190
...
Epoch 9/10
42/42 [==============================] - 1s 14ms/step - loss: 0.0126 - acc: 1.0000
Epoch 10/10
42/42 [==============================] - 1s 15ms/step - loss: 0.0019 - acc: 1.0000

在你上任的第一个星期里迅速取得了一些很好的成果,你自豪地走到老板面前展示你所做的工作。他对你建立的流程的清晰性和高效性感到非常印象深刻。然而,你开始思考,我能用 Keras 数据生成器做得更好吗?

练习 2

想象一下你有一个标签数据集叫 labels_ds(即一个整数标签序列),并且有一些值为 -1 的损坏标签。你能写一个 lambda 函数并将其与 tf.Dataset.map() 函数一起使用来删除这些标签吗?

3.2.2 Keras 数据生成器

另一个获取图像数据的途径是使用 Keras 提供的数据生成器。目前,Keras 提供了两个数据生成器:

tf.keras.preprocessing.image.ImageDataGenerator
tf.keras.preprocessing.sequence.TimeSeriesDataGenerator

虽然不像 tf.data API 那样可定制,但这些生成器仍然提供了一种快速简便的方式将数据输入模型。我们来看看如何使用 ImageDataGenerator 将这些数据提供给模型。ImageDataGenerator (mng.bz/lxpB) 有一个非常长的允许参数列表。在这里,我们只关注如何使 ImageDataGenerator 适应我们所拥有的数据。

然后,为了获取数据,Keras ImageDataGenerator 提供了 flow_from_dataframe() 函数。这个函数对我们来说非常理想,因为我们有一个包含文件名和它们关联标签的 CSV 文件,可以表示为一个 pandas DataFrame。让我们从一些变量定义开始:

data_dir = os.path.join('data','flower_images', 'flower_images')

接下来,我们将使用默认参数定义一个 ImageDataGenerator:

img_gen = ImageDataGenerator()

现在我们可以使用 flow_from_dataframe() 函数:

labels_df = pd.read_csv(os.path.join(data_dir, 'flower_labels.csv'), header=0)
gen_iter = img_gen.flow_from_dataframe(
    dataframe=labels_df, 
    directory=data_dir, 
    x_col='file', 
    y_col='label', 
    class_mode='raw', 
    batch_size=5, 
    target_size=(64,64)
)

我们首先加载包含两列的 CSV 文件:file(文件名)和 label(整数标签)。接下来,我们调用 flow_from_dataframe() 函数,同时还有以下重要参数:

  • dataframe—包含标签信息的数据框

  • directory—定位图像的目录

  • x_col—数据框中包含文件名的列的名称

  • y_col—包含标签的列的名称

  • class_mode—标签的性质(由于我们有原始标签,class_mode 设置为原始)

你可以通过运行下面的代码来查看第一个样本是什么样子的

for item in gen_iter:
    print(item)
    break

这将输出

(
    array([[[[ 10.,  11.,  11.],
             [ 51.,  74.,  46.],
             [ 36.,  56.,  32.],
             ...,
             [  4.,   4.,   3.],
             [ 16.,  25.,  11.],
             [ 17.,  18.,  13.]],
            ...

            [[197., 199., 174.],
             [162., 160., 137.],
             [227., 222., 207.],
             ...,
             [ 57.,  58.,  50.],
             [ 33.,  34.,  27.],
             [ 55.,  54.,  43.]]]], dtype=float32
    ), 
    array([5, 6], dtype=int64)
)

再次,使用批量大小为 5,你会看到一个图像批(即大小为 5 × 64 × 64 × 3)和一个 one-hot 编码的标签批(大小为 5 × 6)生成为一个元组。完整的代码如下所示。

图 3.6 Keras ImageDataGenerator 用于花卉图像数据集

from tensorflow.keras.preprocessing.image import ImageDataGenerator           ❶
import os                                                                     ❶
import pandas as pd                                                           ❶

data_dir = os.path.join('data','flower_images', 'flower_images')              ❷

img_gen = ImageDataGenerator()                                                ❸

print(os.path.join(data_dir, 'flower_labels.csv'))
labels_df = pd.read_csv(os.path.join(data_dir, 'flower_labels.csv'), header=0)❹

gen_iter = img_gen.flow_from_dataframe(                                       ❺
    dataframe=labels_df, directory=data_dir, x_col='file', y_col='label',     ❺
    class_mode='raw', batch_size=2, target_size=(64,64))                      ❺

❶ 导入必要的模块

❷ 定义数据目录

❸ 定义 ImageDataGenerator 来处理图像和标签

❹ 通过读取 CSV 文件作为数据框来定义标签

❺ 从数据框中的文件名和标签读取图像和标签

这看起来比之前的流程更好。你仅用三行代码就创建了一个数据流程。你的知识肯定让你的老板印象深刻,你正在走上快速晋升的道路。

我们将在后面的章节详细讨论 ImageDataGenerator 的参数以及它支持的其他数据检索函数。

然而,要记住简洁并不总是好的。通常,简洁意味着你可以通过这种方法实现的功能有限。对于 tf.data API 和 Keras 数据生成器来说也是如此。tf.data API 尽管需要比 Keras 数据生成器更多的工作,但比 Keras 数据生成器更灵活(并且可以提高效率)。

3.2.3 tensorflow-datasets 包

在 TensorFlow 中检索数据的最简单方法是使用 tensorflow-datasets (www.tensorflow.org/datasets/overview) 包。然而,一个关键的限制是 tensorflow-datasets 只支持一组定义好的数据集,而不像 tf.data API 或 Keras 数据生成器可以用于从自定义数据集中获取数据。这是一个单独的包,不是官方 TensorFlow 包的一部分。如果你按照说明设置了 Python 环境,你已经在你的环境中安装了这个包。如果没有,你可以通过执行以下命令轻松安装它:

pip install tensorflow-datasets

在你的虚拟 Python 环境的终端(例如,Anaconda 命令提示符)中执行上述命令。为了确保软件包安装正确,运行以下行在你的 Jupyter 笔记本中,确保没有出现任何错误:

import tensorflow_datasets as tfds

tensorflow-datasets 提供了许多不同类别的数据集。你可以在www.tensorflow.org/datasets/catalog找到一个全面的可用列表。表 3.2 还概述了一些在 tensorflow-datasets 中可用的热门数据集。

表 3.2 tensorflow-datasets 中可用的几个数据集

数据类型 数据集名称 任务
Audio librispeech 语音识别
ljspeech 语音识别
Images caltech101 图像分类
cifar10 和 cifar100 图像分类
imagenet2012 图像分类
Text imdb_reviews 情感分析
tiny_shakespeare 语言模型
wmt14_translate 机器翻译

让我们使用 tensorflow-datasets 来检索 cifar10 数据集,这是一个广泛使用的图像分类数据集,其中包含属于 10 个类别(例如汽车、船、猫、马等)的 32×32 大小的 RGB 图像。首先,让我们确保它作为一个数据集可用。在 Jupyter 笔记本上执行以下操作:

tfds.list_builders()

我们可以看到 cifar10 是其中一个数据集,正如我们所期望的那样。让我们使用 tfds.load()函数加载数据集。当你首次调用这个方法时,TensorFlow 会先下载数据集,然后为你加载它:

data, info = tfds.load("cifar10", with_info=True)

当它成功下载后,查看(info)变量中可用的信息:

print(info)

>>> tfds.core.DatasetInfo(
    name='cifar10',
    version=3.0.0,
    description='The CIFAR-10 dataset consists of 60000 32x32 colour images in 10 classes, with 6000 images per class. There are 50000 training images and 10000 test images.',
    homepage='https:/ /www.cs.toronto.edu/~kriz/cifar.xhtml',
    features=FeaturesDict({
        'image': Image(shape=(32, 32, 3), dtype=tf.uint8),
        'label': ClassLabel(shape=(), dtype=tf.int64, num_classes=10),
    }),
    total_num_examples=60000,
    splits={
        'test': 10000,
        'train': 50000,
    },
    supervised_keys=('image', 'label'),
    citation="""@TECHREPORT{Krizhevsky09learningmultiple,
        author = {Alex Krizhevsky},
        title = {Learning multiple layers of features from tiny images},
        institution = {},
        year = {2009}
    }""",
    redistribution_info=,
)

这非常有信息量。我们现在知道有 60,000 个 32 × 32 的彩色图像属于 10 个类别。数据集分为 50,000(训练)和 10,000(测试)。现在让我们看看数据变量:

print(data)

>>> {'test': <DatasetV1Adapter 
        shapes: {image: (32, 32, 3), label: ()}, 
        types: {image: tf.uint8, label: tf.int64}>, 
     'train': <DatasetV1Adapter 
        shapes: {image: (32, 32, 3), label: ()}, 
        types: {image: tf.uint8, label: tf.int64}>
    }

我们可以看到它是一个包含键“train”和“test”的字典,每个键都有一个 tf.data.Dataset。幸运的是,我们已经学习了 tf.data.Dataset 的工作原理,所以我们可以快速了解如何准备数据。让我们看一下训练数据。你可以通过以下方式访问这个训练数据集。

train_ds = data["train"]

然而,如果你尝试迭代这个数据集,你会注意到数据并没有被分批。换句话说,数据是一次检索一个样本。但是,正如我们已经说过很多次的那样,我们需要批量数据。修复方法很简单:

train_ds = data["train"].batch(16)

现在,为了看一下 train_ds 中的一批数据是什么样子,你可以执行以下操作:

for item in train_ds:
    print(item)
    break

这将输出

{
    'id': <tf.Tensor: shape=(16,), dtype=string, numpy=
          array(
              [
                  b'train_16399', b'train_01680', b'train_47917', b'train_17307',
                  b'train_27051', b'train_48736', b'train_26263', b'train_01456',
                  b'train_19135', b'train_31598', b'train_12970', b'train_04223',
                  b'train_27152', b'train_49635', b'train_04093', b'train_17537'
              ], 
              dtype=object
          )>, 
    'image': <tf.Tensor: shape=(16, 32, 32, 3), dtype=uint8, numpy=
          array(
              [
                  [
                      [
                          [143,  96,  70],
                          [141,  96,  72],
                          [135,  93,  72],
                          ...,         
                          [128,  93,  60],
                          [129,  94,  61],
                          [123,  91,  58]
                      ]
                  ]
              ], 
              dtype=uint8
          )>, 
    'label': <tf.Tensor: shape=(16,), dtype=int64, numpy=
          array(
              [7, 8, 4, 4, 6, 5, 2, 9, 6, 6, 9, 9, 3, 0, 8, 7], 
              dtype=int64
          )>
}

它将是一个包含三个键的字典:id、image 和 label。id 是每个训练记录的唯一标识。image 将有一个大小为 16 × 32 × 32 × 3 的张量,而 label 将有一个大小为 16 的张量(即整数标签)。当将 tf.data.Dataset 传递给 Keras 模型时,模型期望数据集对象产生一个元组 (x,y),其中 x 是一批图像,y 是标签(例如,one-hot 编码)。因此,我们需要编写一个额外的函数,将数据放入正确的格式:

def format_data(x):
    return (x["image"], tf.one_hot(x["label"], depth=10))

train_ds = train_ds.map(format_data)

通过这个简单的转换,你可以将这个数据集馈送给一个模型,方法如下:

model.fit(train_ds, epochs=25)

这是令人惊讶的工作。现在你知道了为模型检索数据的三种不同方法:tf.data API、Keras 数据生成器和 tensorflow-datasets 包。我们将在这里结束对 Keras API 和不同数据导入 API 的讨论。

练习 3

你能写一行代码导入 caltech101 数据集吗?在你这样做之后,探索这个数据集。

摘要

  • Keras,现在已经集成到 TensorFlow 中,提供了几种高级模型构建 API:串行 API、功能 API 和子类化 API。这些 API 有不同的优缺点。

  • 串行 API 是使用最简单的,但只能用于实现简单的模型。

  • 功能和子类化 API 可能难以使用,但允许开发人员实现复杂的模型。

  • TensorFlow 包含几种获取数据的方法:tf.data API、Keras 数据生成器和 tensorflow-datasets。tf.data。

  • API 提供了向模型提供数据的最可定制方式,但需要更多的工作来获取数据。

  • tensorflow-datasets 是使用最简单的,但是它有限,因为它只支持有限的数据集。

练习答案

练习 1: 功能 API。由于有两个输出层,我们不能使用串行 API。没有必要使用子类化 API,因为我们需要的一切都可以使用 Keras 层完成。

练习 2: labels_ds.map(lambda x: x if x != -1). 你也可以使用 tf.Dataset .filter() 方法(即 labels_ds.filter(lambda x: x != -1))。

练习 3: tfds.load("caltech101", with_info=True)

第四章:涉足深度学习

本章涵盖

  • 使用 Keras 实现和训练全连接神经网络

  • 实现和训练卷积神经网络以对图像进行分类

  • 实现和训练递归神经网络以解决时间序列问题

在第三章,您了解了 TensorFlow 提供的不同模型构建 API 及其优缺点。您还了解了 TensorFlow 中一些检索和操作数据的选项。在本章中,您将学习如何利用这些知识来构建深度神经网络,并使用它们来解决问题。

深度学习是一个广泛的术语,它包含许多不同的算法。深度学习算法有许多不同的类型和颜色,可以根据许多标准进行分类:它们消耗的数据类型(例如,结构化数据、图像、时间序列数据)、深度(浅层、深层和非常深层)等等。我们将要讨论和实现的主要深度网络类型如下:

  • 全连接网络(FCNs)

  • 卷积神经网络(CNNs)

  • 递归神经网络(RNNs)

能够熟练实现这些神经网络是在该领域取得成功的关键技能,无论你是研究生、数据科学家还是研究科学家。这些知识直接延伸到如何熟练实现更复杂的深度神经网络,这些网络在各种问题领域提供了最先进的性能。

在第二章中,我们讨论了 FCN 和 CNN 中的各种操作,例如卷积和池化操作。在本章中,您将再次看到 FCNs,以及 CNNs 的整体实现,展示了卷积和池化操作如何合并形成 CNN。最后,您将了解一个新类型的模型:RNNs。RNNs 通常用于解决时间序列问题,其中的任务是学习数据随时间变化的模式,以便通过查看过去的模式来预测未来。我们还将看到 RNNs 如何用于解决一个有趣的现实世界时间序列问题。

4.1 全连接网络

当您在阁楼找到一些存储盒时,里面有一些珍贵的祖母的照片。不幸的是,它们已经过时了。大多数照片都被划痕、污迹和甚至撕裂了。您知道最近已经使用了深度网络来恢复旧照片和视频。希望能恢复这些照片,您决定使用 TensorFlow 实现图像恢复模型。您首先将开发一个可以恢复手写数字损坏图像的模型,因为这个数据集是 readily available,以便了解模型和训练过程。您认为自动编码器模型(一种 FCN)将是一个很好的起点。这个自动编码器将具有以下规格:

  • 具有 784 个节点的输入层

  • 具有 64 个节点的隐藏层,采用 ReLU 激活

  • 一个包含 32 个节点的隐藏层,使用 ReLU 激活函数

  • 一个包含 64 个节点的隐藏层,使用 ReLU 激活函数

  • 一个包含 784 个节点的输出层,使用 tanh 激活函数

深度学习的超参数优化

你可能已经注意到,在定义神经网络时,我们选择结构超参数(例如,隐藏层中的单元数)有些是凭空选择的。实际上,这些值是通过几轮试错经验选择的。

通常,在机器学习中,这些超参数是使用基于原则的方法选择的,例如超参数优化。但是,超参数优化是一个昂贵的过程,需要评估具有不同超参数选择的数百个模型,以选择最佳的超参数集。这使得它非常难以用于深度学习方法,因为这些方法通常涉及大型、复杂的模型和大量的数据。

因此,在深度学习中,为了限制在超参数优化上花费的时间,你通常会看到以下趋势:

  • 优化一部分超参数以限制探索空间(例如,激活类型而不是隐藏单元数量,正则化参数等)。

  • 使用健壮的优化器、早停、学习率衰减等方法,旨在减少或预防过拟合

  • 使用已发表的模型规范,这些模型提供了最先进的性能

  • 遵循一些经验法则,例如随着网络深入减少输出大小

在本章中,我们将使用经验选择的模型架构。本章的重点是展示如何使用 TensorFlow 2 实现给定的架构,而不是找到架构本身。

让我们检查一下我们将用于实现 FCN 的数据。

4.1.1 理解数据

对于这种情况,我们将使用 MNIST 数字数据集,这是一个简单的数据集,包含手写数字的黑白图像以及表示数字的对应标签。每个图像都有一个数字,从 0 到 9。因此,数据集有 10 个不同的类别。图 4.1 显示了数据集中的几个样本及其表示的数字。

04-01

图 4.1 样本数字图像。每个图像包含一个从 0 到 9 的数字。

在 TensorFlow 中,你可以用一行代码加载 MNIST 数据集。由于其极为常见的用法,加载此数据集已成为各种机器学习库(包括 TensorFlow)的重要组成部分:

from tensorflow.keras.datasets.mnist import load_data
(x_train, y_train), (x_test, y_test) = load_data()

load_data()方法返回两个元组:训练数据和测试数据。在这里,我们只会使用训练图像(即,x_train)数据集。正如我们之前介绍的,这是一个无监督任务。因此,我们不需要图像的标签(即,y_train)来完成这个任务。

比 MNIST 好吗?

请注意,由于过去十年计算机视觉领域的进展,MNIST 被认为过于简单,简单的逻辑回归模型就可以实现超过 92%的测试准确率(mng.bz/j2l9),而最先进的模型则可以达到 99.84%的准确率(mng.bz/d2Pv)。此外,它在计算机视觉社区中被过度使用。因此,一个名为 Fashion-MNIST(github.com/zalandoresearch/fashion-mnist)的新数据集应运而生。这是一个包含属于 10 个类别的图像的黑白数据集。与数字不同,它包含各种时尚类别的图像(例如 T 恤、凉鞋、包等),这比识别数字要困难得多。

你可以打印 x_train 和 y_train 来更好地了解这些数组,使用

print(x_train)
print('x_train has shape: {}'.format(x_train.shape))

这将产生

[[[0 0 0 ... 0 0 0]
  [0 0 0 ... 0 0 0]
  [0 0 0 ... 0 0 0]
  ...
  [0 0 0 ... 0 0 0]
  [0 0 0 ... 0 0 0]
  [0 0 0 ... 0 0 0]]

 ...

 [[0 0 0 ... 0 0 0]
  [0 0 0 ... 0 0 0]
  [0 0 0 ... 0 0 0]
  ...
  [0 0 0 ... 0 0 0]
  [0 0 0 ... 0 0 0]
  [0 0 0 ... 0 0 0]]]

x_train has shape: (60000, 28, 28)

对 y_train 执行相同的操作:

print(y_train)
print('y_train has shape: {}'.format(y_train.shape))

这将得到

[5 0 4 ... 5 6 8]

y_train has shape: (60000,)

然后我们将进行一些基本的数据预处理。我们将通过将它们的像素值从[0, 255]归一化到[-1, 1]来规范化数据集中的所有样本。这是通过减去 128 并逐元素除以 128 来完成的。这很重要,因为自动编码器的最后一层具有 tanh 激活函数,其取值范围为(-1, 1)。tanh 是一个非线性激活函数,类似于 sigmoid 函数,对于给定的输入x,计算如下:

04_01a

因此,我们需要确保向模型提供的内容在最终层可以生成的值范围内。另外,如果您查看 x_train 的形状,您将看到它的形状为(60000, 28, 28)。自动编码器接受一维输入,因此我们需要将图像重塑为大小为 784 的一维向量。这两种转换可以通过以下行来实现:

norm_x_train = ((x_train - 128.0)/128.0).reshape([-1,784])

在这里,reshape([-1, 784])将数据集中的二维图像(大小为 28×28)展开为一个单一维度的向量(大小为 784)。在进行重塑时,您不需要提供重塑张量的所有维度。如果您仅提供除一个维度外的所有维度的大小,NumPy 仍然可以推断出缺失维度的大小,因为它知道原始张量的维度。您希望 NumPy 推断的维度用-1 表示。

也许你会想:“这些图像看起来清晰干净。我们如何训练模型来恢复损坏的图像?” 这很容易解决。我们只需要从原始图像中合成一组相应的损坏图像集。为此,我们将定义 generate_masked_inputs(...)函数:

import numpy as np

def generate_masked_inputs(x, p, seed=None):
    if seed:
        np.random.seed(seed)
    mask = np.random.binomial(n=1, p=p, size=x.shape).astype('float32')
    return x * mask

masked_x_train = generate_masked_inputs(norm_x_train, 0.5)

这个函数将随机(有 50%的概率)将像素设置为零。但让我们更详细地检查我们正在做什么。首先,我们将提供设置随机种子的选项,以便我们可以确定性地改变生成的随机掩码。我们使用二项分布创建一个与 norm_x_train 大小相同的 1 和 0 的掩码。简单来说,二项分布表示如果你多次抛硬币,出现正面(1)或反面(0)的概率。二项分布有几个重要参数:

  • N—试验次数

  • P—成功的概率(1)

  • Size—测试的数量(即,试验集)

在这里,我们有 x.shape 个测试,在每个测试中有一个 50%的成功概率。然后将此掩码与原始张量进行逐元素相乘。这将导致随机分布在图像上的黑色像素(图 4.2)。

04-02

图 4.2 一些合成损坏的图像

接下来,让我们讨论我们将要实现的全连接网络。它被称为自动编码器模型。

4.1.2 自动编码器模型

自动编码器模型和多层感知机(MLP)模型(来自第一章)都是全连接网络(FCN)。之所以称为 FCN,是因为网络中的每一层都将所有输入节点连接到所有输出节点。自动编码器的操作方式与多层感知机类似。换句话说,在自动编码器中看到的计算(例如,正向传播)与 MLP 中完全相同。然而,两者的最终目标不同。MLP 被训练来解决监督任务(例如,分类花的品种),而自动编码器被训练来解决无监督任务(例如,在给定损坏/嘈杂图像的情况下重建原始图像)。现在让我们深入了解自动编码器实际上是做什么的。

监督学习与无监督学习

在监督学习中,模型使用带标签的数据集进行训练。每个输入(例如,图像/音频/电影评论)都有一个相应的标签(例如,图像的对象类别、评论的情感)或连续值(例如,图像对象的边界框)。监督任务的一些示例包括图像分类、目标检测、语音识别和情感分析。

在无监督学习中,模型使用未标记的数据进行训练(例如,从网站提取的没有任何标签的图像/音频/文本)。训练过程根据最终预期结果而显著变化。例如,自动编码器被训练为重建图像,作为基于图像的监督学习任务的预训练步骤。无监督任务的一些示例包括图像重构、使用生成对抗网络生成图像、文本聚类和语言建模。

04-03

图 4.3 一个简单的自动编码器,其中一个层用于压缩,另一个层用于重构。输入图像中的黑色和白色矩形是图像中存在的像素。

图 4.3 描绘了一个具有两层的简单自编码器。自编码器在其功能上有两个阶段:

  • 压缩阶段—将给定图像(即损坏的图像)压缩为压缩的隐藏(即潜在)表示。

  • 重构阶段—从隐藏表示中重构原始图像。

在压缩阶段,计算压缩的隐藏表示如下所示。

h[1] = ReLU(xW[1] + b[1])

其中W[1],b[1]是第一压缩层的权重和偏置,h[1]是层的最终隐藏表示。

类似地,我们计算重构层的输出:

ŷ = ReLU(h[1] W[2] + b[2])

这被称为前向传播,因为您从输入到输出。然后,您计算预期输出(即目标)和预测之间的损失(例如,均方误差[MSE])。例如,单个图像的均方误差计算为

04_03a

其中D是数据的维度(在我们的示例中为 784),y[j]是我们图像中的第j个像素,(ŷ[j])是预测图像的第j个像素。我们为每批图像计算此损失,并优化模型参数以最小化计算的损失。这被称为向后传递。

您可以有任意数量的压缩和重构层。在我们的任务中,我们需要两个压缩层和两个重构层(见下一个列表)。

列表 4.1 去噪自编码器模型。

from tensorflow.keras import layers, models

autoencoder = models.Sequential(
    [layers.Dense(64, activation='relu', input_shape=(784,)),  ❶
    layers.Dense(32, activation='relu'),                       ❶
    layers.Dense(64, activation='relu'),                       ❶
    layers.Dense(784, activation='tanh')]                      ❶
)
autoencoder.compile(loss='mse', optimizer='adam')              ❷
autoencoder.summary()                                          ❸

❶ 定义四个稠密层,其中三个使用 ReLU 激活,一个使用 tanh 激活。

❷ 使用损失函数和优化器编译模型。

❸ 打印摘要。

让我们更详细地讨论我们所做的事情。您应该注意到的第一件事是,我们在这个任务中使用了 Keras Sequential API。这是有道理的,因为这是一个非常简单的深度学习模型。接下来,我们添加了四个稠密层。第一个稠密层接受具有 784 个特征的输入,并产生一个 64 元素的向量。然后第二层接受 64 元素的向量并产生一个 32 元素的向量。第三个稠密层接受 32 元素的向量并产生一个 64 元素的向量,将其传递给最终层,该层产生一个 784 元素的向量(即输入的大小)。前三层使用 ReLU 激活,最后一层使用 tanh 激活,因为最后一层需要产生在(-1, 1)之间的值。让我们再次提醒自己如何计算 ReLU 和 tanh 激活:

ReLU(x) = max (0, x)

04_03b

最后,我们使用均方误差作为损失函数,使用 adam 作为优化器编译模型。我们刚刚描述的模型具有我们在本节开头定义的规格。有了定义好的模型,现在您可以训练模型了。您将使用 64 个大小的批次训练模型 10 个时期:

history = autoencoder.fit(masked_x_train, norm_x_train, batch_size=64, epochs=10)

我们生成的遮罩输入成为输入,原始图像将成为地面真相。当你训练模型时,你会看到随时间推移损失下降:

Train on 60000 samples
Epoch 1/10
60000/60000 [==============================] - 4s 72us/sample - loss: 0.1496
Epoch 2/10
60000/60000 [==============================] - 4s 67us/sample - loss: 0.0992
Epoch 3/10
...
60000/60000 [==============================] - 4s 66us/sample - loss: 0.0821
Epoch 8/10
60000/60000 [==============================] - 4s 66us/sample - loss: 0.0801
Epoch 9/10
60000/60000 [==============================] - 4s 67us/sample - loss: 0.0787
Epoch 10/10
60000/60000 [==============================] - 4s 67us/sample - loss: 0.0777

看起来误差(即,损失值)从大约 0.15 下降到大约 0.078。这是模型正在学习重建图像的一个强有力的指示。你可以通过设置种子来获得类似的结果,使用我们在第二章中使用的 fix_random_seed(...) 函数(提供在笔记本中)。请注意,对于这个任务,我们无法定义像准确度这样的指标,因为这是一个无监督的任务。

去噪自动编码器

通常,自动编码器将给定的输入映射到一个小的潜在空间,然后再返回到原始输入空间以重建原始图像。然而,在这里,我们将自动编码器用于一个特殊目的:还原原始图像或去噪原始图像。这样的自动编码器被称为 去噪。在mng.bz/WxyX上阅读更多关于去噪自动编码器的信息。

现在让我们看看训练好的模型能做什么!它现在应该能够还原一个受损数字的图像了。为了让事情变得有趣,让我们确保我们生成的遮罩是训练数据没有见过的:

x_train_sample = x_train[:10]
y_train_sample = y_train[:10]

masked_x_train_sample = generate_masked_inputs(x_train_sample, 0.5, seed=2048)
norm_masked_x = ((x_train - 128.0)/128.0).reshape(-1, 784)

y_pred = autoencoder.predict(norm_masked_x)

在这里,我们将使用数据集中的前 10 张图像来测试我们刚刚训练的模型。然而,我们通过更改种子确保了随机遮罩不同。你可以使用以下代码显示关于 y_pred 的一些信息

print(y_pred)
print('y_pred has shape: {}'.format(y_pred.shape))

将会给出

[[-0.99999976 -0.99999976 -0.99999976 ... -0.99999976 -0.99999976
  -0.99999976]
 [-0.99999976 -0.99999976 -0.99999976 ... -0.99999976 -0.99999976
  -0.99999976]
 [-0.99999976 -0.99999976 -0.99999976 ... -0.99999976 -0.99999976
  -0.99999976]
 ...
 [-0.99999976 -0.99999976 -0.9999996  ... -0.99999946 -0.99999976
  -0.99999976]
 [-0.99999976 -0.99999976 -0.99999976 ... -0.99999976 -0.99999976
  -0.99999976]
 [-0.99999976 -0.99999976 -0.99999976 ... -0.99999976 -0.99999976
  -0.99999976]]

y_pred has shape: (60000, 784)

最后,你可以通过绘制图像来可视化模型的作用(在笔记本中提供的代码)。图 4.4 说明了损坏的图像(顶行)和模型的输出(底行)。虽然你还没有恢复你祖母的真实照片,但这是一个很好的开始,因为现在你知道了要遵循的方法。

04-04

图 4.4 模型恢复的图像。看起来我们的模型做得很好。

你可能会想,“自动编码器通常能帮你实现什么?”自动编码器是从未标记数据中学习无监督特征的好工具,这在解决更有趣的下游任务时非常方便,比如图像分类。当自动编码器在无监督任务上进行训练时,它们学习了其他任务(例如,图像分类)的有用特征。因此,训练一个自动编码器模型来对图像进行分类将比从头开始训练模型更快地获得性能良好的模型,并且所需的标记数据更少。正如你可能知道的,世界上的未标记数据要比标记数据多得多,因为标记通常需要人为干预,这是耗时且昂贵的。自动编码器的另一个用途是它产生的隐藏表示可以用作聚类图像的低维代理。

在本节中,你学习了自动编码器模型,它是一种 FCN 类型,用于以无监督的方式重构/恢复损坏的图像。这是一种利用大量未标记数据来预训练模型的好方法,这在更下游的有趣任务(如图像分类)中非常有用。你首先学习了架构,然后学习了如何使用 Keras Sequential API 实现自动编码器模型。最后,你对手写图像数据集(MNIST)进行了模型训练以重构数据集中的图像。在训练过程中,为了确保模型在学习,你监控了损失以确保随着时间的推移减少。最后,你使用模型预测了损坏图像的恢复,并确保模型表现良好。

在下一节中,我们将讨论一种不同类型的深度学习网络,它彻底改变了计算机视觉领域:CNN。

练习 1

实现一个接受 512 元素长向量的自动编码器模型。网络有一个 32 节点层,一个 16 节点层,最后是一个输出层。总共有三层。所有这些层都具有 sigmoid 激活。

4.2 卷积神经网络

你一直在一家初创公司担任数据科学家,试图对道路上的交通拥堵建模。公司解决方案中的一个重要模型是构建一个模型,以预测在给定的图像或图像块中是否存在车辆,作为更大计划的一部分。你计划首先在 cifar-10 数据集上开发一个模型,并查看它在分类车辆方面的效果如何。这是一个很好的主意,因为它将在最小的时间和金钱上为自定义数据标记提供一个粗略的近似值。如果我们在这个数据集上能够达到较高的准确度,那是一个非常积极的信号。你了解到 CNN 对于计算机视觉任务非常有效。因此,你计划实现一个 CNN。

4.2.1 理解数据

我们将使用的是 cifar-10 数据集。我们在上一章节简要地查看过这个数据集,它是这项任务的重要基石。它包含各种交通工具(如汽车、卡车)和其他物体(如狗、猫)作为类别。图 4.5 展示了一些类别及其对应的样本。

04-05

图 4.5 cifar-10 数据集的样本图像及其标签

数据集包含 50,000 个训练实例和 10,000 个测试实例。每个实例是一个 32 × 32 的 RGB 图像。这个数据集中有 10 个不同的对象类别。

让我们首先通过执行以下行来加载数据:

import tensorflow_datasets as tfds
data = tfds.load('cifar10')

执行 print(data) 将产生

{'test': <PrefetchDataset 
➥ shapes: {id: (), image: (32, 32, 3), label: ()}, 
➥ types: {id: tf.string, image: tf.uint8, label: tf.int64}>, 'train': <PrefetchDataset 
➥ shapes: {id: (), image: (32, 32, 3), label: ()}, 
➥ types: {id: tf.string, image: tf.uint8, label: tf.int64}>}

如果你稍微探索一下数据,你会意识到

  • 图像以无符号的八位整数类型提供。

  • 标签以整数标签提供(即,未进行 one-hot 编码)。

因此,我们将编写一个非常简单的函数,将图像转换为 float32 数据类型(使数据类型与模型参数一致),并将标签转换为独热编码向量:

import tensorflow as tf

def format_data(x, depth):
    return (tf.cast(x["image"], 'float32'), tf.one_hot(x["label"], depth=depth))

最后,我们将通过将此函数应用于所有训练数据来创建一个批处理数据集:

tr_data = data["train"].map(lambda x: format_data(x, depth=10)).batch(32)

我们可以再次查看数据

for d in tr_data.take(1):
    print(d)

这将产生

(
    <tf.Tensor: shape=(32, 32, 32, 3), dtype=float32, numpy=
    array(
        [[[[143.,  96.,  70.],
           [141.,  96.,  72.],
           [135.,  93.,  72.],
           ...,     
           [ 52.,  34.,  31.],
           [ 91.,  74.,  59.],
           [126., 110.,  88.]]]], 
        dtype=float32)
    >, 
    <tf.Tensor: shape=(32, 10), dtype=float32, numpy=
    array(
        [[0., 0., 0., 0., 0., 0., 0., 1., 0., 0.],
         [0., 0., 0., 0., 0., 0., 0., 0., 1., 0.],
         [0., 0., 0., 0., 1., 0., 0., 0., 0., 0.],
         ... 
         [0., 0., 0., 0., 1., 0., 0., 0., 0., 0.],
         [0., 0., 1., 0., 0., 0., 0., 0., 0., 0.]], 
        dtype=float32)
    >
)

现在我们的数据准备好输入模型了。

4.2.2 实现网络

为了对这些图像进行分类,我们将采用 CNN。CNN 以解决计算机视觉任务而闻名,并且是处理图像相关任务的流行选择,原因有两个主要方面:

  • CNN 在处理图像时保留它们的空间信息(即保持高度和宽度维度不变),而全连接层则需要将高度和宽度维度展开为一个单一维度,从而丢失宝贵的局部信息。

  • 不同于全连接层,其中每个输入都连接到每个输出,卷积操作将一个较小的核移动到整个图像上,每层只需少量参数,使得 CNN 非常高效。

CNN 由一组交错的卷积和池化层以及几个全连接层组成。这意味着 CNN 中有三种主要类型的层:

  • 卷积层

  • 池化层

  • 全连接层

一个卷积层由多个滤波器(即卷积核)组成,这些滤波器在图像上进行卷积以生成特征图。特征图是一个表示给定滤波器在图像中存在程度的表示。例如,如果滤波器表示垂直边缘,则特征图表示图像中垂直边缘存在的位置(以及强度)。再举一个例子,想象一个训练用于识别人脸的神经网络。一个滤波器可能表示眼睛的形状,并且在给定图像中存在眼睛时会高度激活相应区域的输出(见图 4.6)。我们将在本章后面更详细地讨论卷积操作。

04-06

图 4.6 卷积操作的结果,非常抽象。如果我们有一个人脸图像和一个表示眼睛形状/颜色的卷积核,那么卷积结果可以粗略地被认为是该特征(即眼睛)在图像中存在的热图。

卷积层的另一个重要特性是,网络越深(即离输入越远),层学习的高级特征就越多。回到我们的人脸识别例子,较低的层可能学习到各种边缘的存在;下一层学习到眼睛、耳朵和鼻子的形状;下一层学习到两只眼睛的位置、鼻子和嘴巴的对齐等(见图 4.7)。

04-07

图 4.7 卷积神经网络学习到的特征。较低的层(离输入最近)学习到的是边缘/线条,而较高的层(离输入最远)学习到的是更高级的特征。(来源:mng.bz/8MPg

接下来,池化层接收卷积层生成的特征图并减少它们的高度和宽度维度。为什么减少特征图的高度和宽度有用?它帮助模型在机器学习任务中具有平移不变性。例如,如果任务是图像分类,即使物体在训练期间看到的几个像素偏移,该网络仍然能够识别出物体。

最终,为了获得最终的概率分布,你有多个全连接层。但你可能已经怀疑我们在这里面临的问题。卷积/池化层产生三维输出(即高度、宽度和通道维度)。但全连接层接受一维输入。我们如何将卷积/池化层的三维输出连接到一维的全连接层呢?这个问题有一个简单的答案。你将所有三个维度压缩成一个维度。换句话说,这类似于将二维的 RGB 图像展开成一维向量。这为全连接层提供了一维输入。最后,对最后一个全连接层的输出(即网络的得分)应用 softmax 激活,以获得有效的概率分布。图 4.8 描述了一个简单的 CNN。

04-08

图 4.8 一个简单的 CNN。首先,我们有一个带有高度、宽度和通道维度的图像,然后是一个卷积和池化层。最后,最后一个卷积/池化层的输出被展平,并输入到一组全连接层中。

通过深入了解 CNN 的构成,我们将使用 Keras Sequential API 创建以下 CNN。然而,如果你运行此代码,你将会收到一个错误。我们将在接下来的部分调查并修复这个错误(参见下一个列表)。

列表 4.2 使用 Keras Sequential API 定义 CNN

from tensorflow.keras import layers, models
import tensorflow.keras.backend as K

K.clear_session()                                                          ❶
cnn = models.Sequential(
    [layers.Conv2D(
        filters=16, kernel_size= (9,9), strides=(2,2), activation='relu',  ❷
           padding=’valid’, input_shape=(32,32,3)
        ),                                                                 ❷
     layers.Conv2D(
         filters=32, kernel_size= (7,7), activation='relu', padding=’valid’
     ), 
     layers.Conv2D(
         filters=64, kernel_size= (7,7), activation='relu', padding=’valid’
     ), 
     layers.Flatten(),                                                     ❸
     layers.Dense(64, activation='relu'),                                  ❹
     layers.Dense(10, activation='softmax')]                               ❺
)

❶ 清除任何现有的 Keras 状态(例如模型)以重新开始

❷ 定义卷积层;它接受过滤器、内核大小、步幅、激活和填充等参数。

❸ 在将数据输入全连接层之前,我们需要展平最后一个卷积层的输出。

❹ 创建一个中间的全连接层

❺ 最终预测层

你可以看到,该网络由三个卷积层和两个全连接层组成。Keras 提供了你实现 CNN 所需的所有层。如你所见,我们的图像分类网络只需一行代码即可完成。让我们更详细地探索一下这个模型中发生了什么。第一层定义如下:

layers.Conv2D(filters=16,kernel_size=(9,9), strides=(2,2), activation='relu', input_shape=(32,32,3))

卷积神经网络的超参数

在列表 4.2 中的 CNN 网络中,Conv2D 层的 filters、kernel_size 和 strides,Dense 层(除了输出层)中的隐藏单元数以及激活函数被称为模型的超参数。理想情况下,这些超参数需要使用超参数优化算法进行选择,该算法会运行数百(如果不是数千)个具有不同超参数值的模型,并选择最大化预定义度量(例如,模型准确性)的那个。然而,这里我们已经根据经验选择了这些超参数的值,并且不会使用超参数优化。

首先,Conv2D 层是 2D 卷积操作的 Keras 实现。正如您在第一章中记得的那样,我们使用了 tf.nn.convolution 操作来实现这一点。Conv2D 层在幕后执行相同的功能。但是,它隐藏了一些直接使用 tf.nn.convolution 操作时遇到的复杂性(例如,显式定义层参数)。您需要为这一层提供几个重要的参数:

  • 过滤器—输出中将存在的通道数。

  • 核大小—高度和宽度维度上的卷积窗口大小,按顺序。

  • 步长—表示在每次卷积窗口在输入上移动时,跳过的高度和宽度像素数量(按顺序)。在这里有较高的值有助于随着深入,快速减小卷积输出的尺寸。

  • 激活—卷积层的非线性激活。

  • 填充—在执行卷积操作时用于边界的填充类型。填充边界可以更好地控制输出的大小。

  • input_shape—表示(高度,宽度,通道)维度上的输入大小的三维元组。请记住,当使用此参数指定数据的形状时,Keras 会自动添加一个未指定的批量维度。

现在让我们更详细地介绍卷积函数及其参数。我们已经知道,卷积操作将一个卷积窗口(即一个核)在图像上移动,同时取得与图像部分与核重叠的元素的乘积之和(图 4.9)。从数学上讲,卷积操作可以表示为

04_08a

其中xn × n的输入矩阵,fm × m的过滤器,y是输出。

04-09

图 4.9 在移动窗口时进行卷积操作的计算

除了卷积操作期间发生的计算外,在使用 Keras 中的 Conv2D 层时产生的大小和值时,还有四个重要的超参数:

  • 滤波器数量

  • 核高度和宽度

  • 核步长(高度和宽度)

  • 填充类型

我们将讨论的第一个方面是层中滤波器的数量。通常,单个卷积层有多个滤波器。例如,想象一个训练用于识别人脸的神经网络。网络中的一个层可能会学习识别眼睛的形状,鼻子的形状等等。每个这些特征可能由层中的单个滤波器学习。

卷积层接收一个图像,这是一个具有某些高度、宽度和通道的三维张量。例如,如果图像是 RGB 图像,则会有三个通道。如果图像是灰度图像,则通道数将为一。然后,将该张量与 n 个滤波器卷积将导致一个具有某些高度、宽度和 n 个通道的三维输出。这在图 4.10 中显示。在 CNN 中使用时,滤波器是卷积层的参数。这些滤波器被随机初始化,随着时间的推移,它们会演变成有助于解决手头任务的有意义特征。

正如我们之前所说,深度神经网络以批量方式处理数据。CNN 也不例外。您可以看到,我们将 input_shape 参数设置为 (32, 32, 3),其中自动添加了一个未指定的批量维度,使其为 (None, 32, 32, 3)。未指定的维度用 None 表示,意味着模型可以在该维度上取任意数量的项目。这意味着在向模型提供数据时,一个数据批次可以有 3、4、100 或任意数量的图像(根据计算机内存的情况)。因此,Conv2D 层的输入/输出实际上是一个四维张量,具有批量、高度、宽度和通道维度。然后,滤波器将是另一个四维张量,具有核高度、宽度、输入通道和输出通道维度。表 4.1 总结了这些信息。

表 4.1 卷积层的输入、滤波器和输出的维度

维度 示例
输入 [批量大小,高度,宽度,输入通道] [32, 64, 64, 3](即,一批 32 个,64 × 64 的 RGB 图像)
卷积滤波器 [高度,宽度,输入通道,输出通道] [5, 5, 3, 16](即,大小为 5 × 5 的 16 个输入通道的卷积滤波器)
输出 [批量大小,高度,宽度,输出通道] [32, 64, 64, 16](即,一批 32 个,64 × 64 × 16 的张量)

图 4.10 描述了卷积层中输入和输出的外观。

04-10

图 4.10 多个滤波器(随机初始化)的卷积层的计算。我们保留了张量表示的批量维度以避免混乱。

接下来,内核的高度和宽度是在高度和宽度维度上的滤波器大小。图 4.11 描述了不同内核大小导致不同输出的情况。通常,在实现 CNN 时,我们保持内核的高度和宽度相等。因此,我们将内核的高度和宽度维度统称为内核大小。我们可以将输出大小计算为内核和输入大小的函数,如下所示:

size(y) = size(x) - size(f) + 1

例如,如果图像是一个 7 × 7 的矩阵,滤波器是一个 3 × 3 的矩阵,那么输出将是一个 (7 - 3 + 1, 7 - 3 + 1) = 5 × 5 的矩阵。或者,如果图像是一个 7 × 7 的矩阵,滤波器是一个 5 × 5 的矩阵,那么输出将是一个 3 × 3 的矩阵。

04-11

图 4.11 使用 2 和 3 的内核大小的卷积操作。增加内核大小会导致减小输出大小。

从建模的角度来看,增加内核大小(即滤波器大小)意味着增加参数的数量。通常,您应该尝试减少网络中的参数数量并针对较小的内核大小。使用小内核大小鼓励模型使用少量参数学习更健壮的特征,从而更好地泛化模型。

下一个重要参数是步幅。与内核大小类似,步幅有两个组成部分:高度和宽度。直觉上,步幅定义了在进行卷积操作时跳过多少像素/值。图 4.12 说明了没有步幅和步幅 = 2 之间的区别。与之前一样,我们可以将输出大小指定为输入大小、内核大小和步幅的函数:

04_11a

04-12

图 4.12 步幅为 1(即无步幅)与步幅为 2 的卷积操作。增加步幅会导致较小的输出。

从建模的角度来看,步幅是有益的,因为它帮助您控制输出中需要减少的量。您可能已经注意到,即使在没有步幅的情况下,卷积过程中仍会自动减少维度。但是,当使用步幅时,您可以控制要获得的减少而不影响内核大小。

最终,填充决定了图像边界附近发生的情况。正如你已经看到的,当你对图像进行卷积时,你得不到与输入尺寸相同的输出。例如,如果你有一个 4 × 4 的矩阵和一个 2 × 2 的核,你会得到一个 3 × 3 的输出(即,根据我们之前看到的方程size(y) = size(x) - size(f ) + 1,其中x是输入尺寸,f是滤波器尺寸)。这种自动降维会在创建深度模型时产生问题。具体来说,它限制了你可以拥有的层数,因为在某些时候,输入会由于这种自动尺寸减小而变成 1 × 1 像素。因此,这将在将信息传递给随后的全连接层时创建一个非常窄的瓶颈,导致大量信息丢失。

你可以使用填充来缓解这个问题。通过填充,你在图像周围创建一个零边框,以便获得与输入相同大小的输出。更具体地说,你在周围附加一个大小为size(f ) - 1 的零边框,以获得与输入相同大小的输出。例如,如果你有一个大小为 4 × 4 的输入和一个大小为 2 × 2 的核,那么你将垂直和水平应用大小为 2 - 1 = 1 的边框。这意味着核实际上正在处理一个 5 × 5 的输入(即,(4 + 1) × (4 + 1)-大小的输入),结果是一个 4 × 4 的输出。这被称为same padding。注意,你填充的不总是零。虽然目前 Keras 不支持,但有不同的填充策略(一些示例可以在这里找到:www.tensorflow.org/api_docs/python/tf/pad),例如填充

  • 一个常量值

  • 输入的反射

  • 最近的值

如果你不应用填充,那就是valid padding。不应用填充会导致我们之前讨论过的标准卷积操作。填充的差异如图 4.13 所示。

04-13

图 4.13 有效填充与相同填充。有效填充导致输出尺寸减小,而相同填充导致输出与输入尺寸相等。

通过这个,我们结束了对 Conv2D 层的各种超参数的讨论。现在让我们回到我们实现的网络。不幸的是,如果你尝试运行我们讨论过的代码,你会看到一个有些晦涩的错误,就像这样:

---------------------------------------------------------------------------
...

InvalidArgumentError: Negative dimension size caused by subtracting 7 from 6 for 'conv2d_2/Conv2D' (op: 'Conv2D') with input shapes: [?,6,6,32], [7,7,32,64].

我们在这里做错了什么?TensorFlow 似乎在尝试计算卷积层输出时抱怨负尺寸。由于我们已经学会了在各种情况下计算输出大小(例如,带有步幅,带有填充等),我们将计算卷积层的最终输出。我们有以下层:

layers.Conv2D(16,(9,9), strides=(2,2), activation='relu', padding=’valid’, input_shape=(32,32,3))

我们从尺寸为 32×32×3 的输入开始。然后,经过具有 16 个过滤器、卷积核尺寸为 9 和步幅为 2 的卷积操作后,我们得到一个尺寸为(高度和宽度)的输出。

(⌊(32 - 9) / 2⌋ + 1 = 12

这里,我们只关注高度和宽度维度。下一层有 32 个过滤器,卷积核尺寸为 7,没有步幅:

     layers.Conv2D(32, (7,7), activation='relu', padding=’valid’)

该层产生一个尺寸为的输出。

12 - 7 + 1 = 6

最后的卷积层有 64 个过滤器,卷积核尺寸为 7,没有步幅。

     layers.Conv2D(64, (7,7), activation='relu', padding=’valid’), 

这将产生一个尺寸为的输出。

6 - 7 + 1 = 0

我们找到了解决办法!通过我们选择的配置,我们的 CNN 产生了一个无效的零尺寸输出。错误中的“负尺寸”一词指的是产生具有无效尺寸(即小于 1)的输出。输出总是需要大于或等于 1。

让我们通过确保输出永远不会具有负尺寸来修正这个网络。此外,我们将在 CNN 中引入几个交错的最大池化层,这有助于网络学习平移不变特征(参见下一列表)。

列表 4.3 已修正的具有正尺寸的 CNN 模型。

from tensorflow.keras import layers, models
import tensorflow.keras.backend as K

K.clear_session()

cnn = models.Sequential([
     layers.Conv2D(                                                      ❶
         filters=16,kernel_size=(3,3), strides=(2,2), activation='relu', ❶
         padding='same', input_shape=(32,32,3)),                         ❶
     layers.MaxPool2D(pool_size=(2,2), strides=(2,2), padding='same'),   ❷
     layers.Conv2D(32, (3,3), activation='relu', padding='same'),        ❸
     layers.MaxPool2D(pool_size=(2,2), strides=(2,2), padding='same'),   ❹
     layers.Flatten(),                                                   ❺
     layers.Dense(64, activation='relu'),                                ❻
     layers.Dense(32, activation='relu'),                                ❻
     layers.Dense(10, activation='softmax')]                             ❼
)

❶ 第一个卷积层。输出尺寸从 32 减小到 16。

❷ 第一个最大池化层。输出尺寸从 16 减小到 8。

❸ 第二个卷积层。由于没有步幅,输出尺寸保持不变。

❹ 第二个最大池化层。输出尺寸从 8 减小到 4。

❺ 将高度、宽度和通道维度压缩为单一维度。

❻ 两个中间的具有 ReLU 激活函数的密集层。

❼ 使用 softmax 激活的最终输出层。

最大池化由 tensorflow.keras.layers.MaxPool2D 层提供。该层的超参数与 tensorflow.keras.layers.Conv2D 非常相似:

  • pool_size——这类似于 Conv2D 层的卷积核尺寸参数。它是一个表示(窗口高度,窗口宽度)的元组,按照那个顺序。

  • 步幅——这类似于 Conv2D 层的步幅参数。它是一个表示(高度步幅,宽度步幅)的元组,按照那个顺序。

  • 填充——填充可以是 same 或 valid,并且具有与 Conv2D 层中相同的效果。

让我们分析一下我们对 CNN 所做的更改:

  • 我们对所有 Conv2D 和 MaxPool2D 层都使用了 padding='same',这意味着不会自动减小输出尺寸。这消除了意外进入负尺寸的风险。

  • 我们使用步幅参数来控制随着模型深入而输出尺寸的减小。

您可以按照列表 4.1 中的输出尺寸,并确保对于我们拥有的输入图像,输出永远不会小于或等于零。

在 Conv2D 和 MaxPool2D 层之后,我们必须至少有一个全连接层,因为我们正在解决图像分类任务。为了获得最终的预测概率(即给定输入属于输出类的概率),一个全连接层是必不可少的。但在拥有全连接层之前,我们需要将 Conv2D 或 MaxPool2D 层的四维输出(即[batch, height, width, channel]形状)展平为全连接层的二维输入(即[batch, features]形状)。也就是说,除了批处理维度之外,其他所有维度都被压缩为单个维度。为此,我们使用由 Keras 提供的 tensorflow.keras.layers.Flatten 层。例如,如果我们最后一个 Conv2D 层的输出是[None, 4, 4, 64],那么 Flatten 层将这个输出展平为一个[None, 1024]大小的张量。最后,我们添加三个全连接层,其中前两个全连接层具有 64 和 32 个输出节点,并且使用 ReLU 类型的激活函数。最后一个全连接层将有 10 个节点(每个类一个)和 softmax 激活函数。

CNN 的性能瓶颈

通常,在 CNN 中,卷积/池化层之后的第一个全连接层被认为是性能瓶颈。这是因为这一层通常包含网络参数的很大一部分。假设您有一个 CNN,其中最后一个池化层产生一个 8 × 8 × 256 的输出,后面是一个具有 1,024 个节点的全连接层。这个全连接层将包含 16,778,240(超过 1600 万)个参数。如果您不注意 CNN 的第一个全连接层,您很容易在运行模型时遇到内存不足的错误。

是时候在数据上测试我们的第一个 CNN 了。但在此之前,我们必须用适当的参数编译模型。在这里,我们将监视模型的训练准确率:

cnn.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['acc'])

最后,您可以使用我们之前创建的训练数据,并通过调用数据训练模型。

history = cnn.fit(tr_data,epochs=25)

您应该得到以下输出:

Epoch 1/25
1563/1563 [==============================] - 23s 15ms/step - loss: 2.0566 - acc: 0.3195
Epoch 2/25
1563/1563 [==============================] - 13s 8ms/step - loss: 1.4664 - acc: 0.4699
...
Epoch 24/25
1563/1563 [==============================] - 13s 8ms/step - loss: 0.8070 - acc: 0.7174
Epoch 25/25
1563/1563 [==============================] - 13s 8ms/step - loss: 0.7874 - acc: 0.7227

看起来我们在训练准确率(以 acc 表示)方面做得不错,并且在识别车辆任务的训练损失(以 loss 表示)上保持稳定的降低(约为 72.2% 的准确率)。但是通过采用各种技术,我们可以获得更好的准确率,您将在后面的章节中看到。对于团队来说,这是一个非常令人兴奋的消息,因为这意味着他们可以继续努力完成他们的全面解决方案。

在本节中,我们研究了 CNNs。CNNs 在解决计算机视觉问题时表现得非常好。在这个实例中,我们研究了使用 CNN 对图像进行分类到各种类别(例如,动物、车辆等)作为模型检测车辆能力的可行性研究。我们详细研究了 CNN 的技术方面,同时仔细检查了各种操作,如卷积和池化,以及与这些操作相关的参数的影响(例如,窗口大小、步长、填充)。我们发现,如果我们在使用这些参数时不注意输出的变化,可能会导致代码错误。接下来,我们修复了错误并在数据集上训练了模型。最后,我们发现模型显示出有希望的结果,迅速达到了 70%以上的训练准确度。接下来,我们将讨论 RNNs,它们在解决时间序列问题方面投入了大量的投资。

练习 2

考虑以下网络:

from tensorflow.keras import layers, models

models.Sequential([
layers.Conv2D(                                                     
         filters=16, kernel_size=(5,5), padding='valid', input_shape=(64,64,3)
),                        
layers.MaxPool2D(pool_size=(3,3), strides=(2,2), padding='same'),  
layers.Conv2D(32, (3,3), activation='relu', padding='same'),       
layers.MaxPool2D(pool_size=(2,2), strides=(2,2), padding='same'),  
layers.Conv2D(32, (3,3), strides=(2,2), activation='relu', padding='same')
])       

最终的输出大小是多少(忽略批次维度)?

4.3 一步一步地:递归神经网络(RNNs)

您是国家气象局的机器学习顾问。他们拥有过去三十年的 CO2 浓度数据。您被委托开发一个机器学习模型,预测未来五年的 CO2 浓度。您计划实现一个简单的 RNN,它接受 CO2 浓度序列(在本例中,过去 12 个月的值)并预测序列中的下一个值。

很明显,我们面对的是一个时间序列问题。这与我们以往解决的任务非常不同。在以前的任务中,一个输入不依赖于先前的输入。换句话说,您认为每个输入都是 i.i.d(独立同分布)的输入。然而,在这个问题中,情况并非如此。今天的 CO2 浓度将取决于过去几个月的 CO2 浓度。

典型的前馈网络(即全连接网络、CNNs)在没有特殊适应的情况下无法从时间序列数据中学习。然而,有一种特殊类型的神经网络专门设计用于从时间序列数据中学习。这些网络通常被称为 RNNs。RNNs 不仅使用当前输入进行预测,而且在给定时间步长时还使用网络的记忆,从过去的时间步长。图 4.14 描述了前馈网络和 RNN 在预测几个月内 CO2 浓度时的差异。正如您所看到的,如果您使用前馈网络,它必须仅基于上个月来预测下个月的 CO2 水平,而 RNN 则会查看所有以前的月份。

04-14

图 4.14 以 CO2 浓度水平预测任务为例,前馈网络和 RNN 之间的操作差异

4.3.1 理解数据

数据集非常简单(从datahub.io/core/co2-ppm/r/co2-mm-gl.csv下载)。每个数据点都有一个日期(YYYY-MM-DD 格式)和一个浮点值,表示 CSV 格式中的 CO2 浓度。数据以 CSV 文件的形式提供给我们。让我们按如下方式下载文件:

import requests
import os

def download_data():
    """ This function downloads the CO2 data from 
    https:/ /datahub.io/core/co2-ppm/r/co2-mm-gl.csv
    if the file doesn't already exist
    """
    save_dir = "data"
    save_path = os.path.join(save_dir, 'co2-mm-gl.csv')

    # Create directories if they are not there
    if not os.path.exists(save_dir):
        os.makedirs(save_dir)

    # Download the data and save
    if not os.path.exists(save_path):
        url = "https:/ /datahub.io/core/co2-ppm/r/co2-mm-gl.csv"
        r = requests.get(url)
        with open(save_path, 'wb') as f:
            f.write(r.content)
    else:
        print("co2-mm-gl.csv already exists. Not downloading.")
    return save_path

# Downloading the data
save_path = download_data()

我们可以使用 pandas 轻松加载这个数据集:

import pandas as pd
data = pd.read_csv(save_path)

现在我们可以看一下数据的样子,使用 head() 操作,它将提供数据框中的前几个条目:

data.head()

这将得到类似图 4.15 的东西。

04-15

图 4.15 数据集中的示例数据

在这个数据集中,我们唯一感兴趣的两列是日期列和平均列。其中,日期列仅用于可视化目的。让我们将日期列设置为数据框的索引。这样,当我们绘制数据时,x 轴将自动注释相应的日期:

data = data.set_index('Date')

现在我们可以通过创建一条线图来可视化数据(图 4.16):

data[["Average"]].plot(figsize=(12,6))

04-16

图 4.16 CO2 浓度随时间变化的图示

数据的明显特征是它呈上升趋势和短周期性重复。让我们看看我们可以对这些数据做什么样的改进。数据明显的上升趋势构成了一个问题。这意味着数据在一个一致的范围内没有分布。随着时间线的推移,范围不断增加。如果你把数据直接输入模型,通常模型的性能会下降,因为模型必须预测的任何新数据都超出了训练期间看到的数据范围。但是如果你忘记绝对值,思考这些数据与前一个值的相对关系,你会发现它在一个非常小的值范围内波动(大约为-2.0 到+1.5)。事实上,我们可以很容易地测试这个想法。我们将创建一个名为 Average Diff 的新列,其中将包含两个连续时间步之间的相对差异:

data["Average Diff"]=data["Average"] - data["Average"].shift(1).fillna(method='bfill')

如果你在这个阶段执行 data.head(),你会看到类似表 4.2 的东西。

表 4.2 引入平均差异列后数据集中的示例数据

日期 十进制日期 平均值 趋势 平均差异
1980-01-01 1980.042 338.45 337.83 0.00
1980-02-01 1980.125 339.15 338.10 0.70
1980-03-01 1980.208 339.48 338.13 0.33
1980-04-01 1980.292 339.87 338.25 0.39
1980-05-01 1980.375 340.30 338.78 0.43

这里,我们正在从原始平均列中减去一个平移一个时间步长的值的版本的平均列。图 4.17 在视觉上描述了这个操作。

04-17

图 4.17 从原始平均系列到平均差异系列的转换

最后,我们可以可视化值的行为(图 4.18)使用 data["Average Diff"].plot(figsize=(12,6)) 行。

04-18

图 4.18 CO2 浓度值的相对变化(即,Average[t]-Average[t-1])随时间的变化

你能看到区别吗?从不断增长的数据流中,我们已经转变成了在短时间内发生变化的数据流。下一步是为模型创建数据批处理。我们如何为时间序列问题创建数据批处理呢?请记住,我们不能简单地随机采样数据,因为每个输入都取决于其前序输入。

假设我们想要使用过去 12 个 CO2 浓度值(即 12 个时间步)来预测当前的 CO2 浓度值。时间步数是一个必须仔细选择的超参数。为了自信地选择这个超参数,你必须对数据和所使用的模型的内存限制有扎实的了解。

我们首先随机选择序列中的一个位置,并从该位置开始取 12 个值作为输入,并将第 13 个值作为我们感兴趣的要预测的输出,以便每次采样的总序列长度(n_seq)为 13。如果你这样做 10 次,你将得到一个具有 10 个元素的数据批处理。正如你所看到的,这个过程利用了随机性,同时保留了数据的时间特性,并向模型提供数据。图 4.19 对这个过程进行了可视化描述。

04-19

图 4.19 批处理时间序列数据。n_seq 表示我们在给定时间内看到的时间步数,以创建单个输入和输出。

要在 Python 中执行此操作,让我们编写一个函数,以单个数据集的形式给出所有位置的数据。换句话说,该函数返回所有可能的具有 12 个元素的连续序列作为 x,并将每个序列的相应下一个值作为 y。在将数据提供给模型时可以执行洗牌操作,如下一个清单所示。

清单 4.4 用于为模型生成时间序列数据序列的代码

import numpy as np

def generate_data(co2_arr,n_seq):
    x, y = [],[]
    for i in range(co2_arr.shape[0]-n_seq):
        x.append(co2_arr[i:i+n_seq-1])         ❶
        y.append(co2_arr[i+n_seq-1:i+n_seq])   ❷
    x = np.array(x)                            ❸
    y = np.array(y)                            ❸
    return x,y

❶ 提取长度为 n_seq 的值序列

❷ 将序列中的下一个值提取为输出

❸ 将所有内容组合成一个数组

4.3.2 实现模型

了解数据后,我们可以开始实现网络。我们将实现一个具有以下内容的网络:

  • 具有 64 个隐藏单元的 rnn 层

  • 具有 64 个隐藏单元和 ReLU 激活的密集层

  • 具有单输出和线性激活的密集层

from tensorflow.keras import layers, models

rnn = models.Sequential([
    layers.SimpleRNN(64),
    layers.Dense(64, activation='relu'),
    layers.Dense(1)
])

请注意,网络的超参数(例如,隐藏单元的数量)已经经验性地选择,以便在给定问题上良好地工作。第一层是网络中最关键的组件,因为它是从时间序列数据中学习的要素。SimpleRNN 层封装了图 4.20 中所示的功能。

04-20

图 4.20 SimpleRNN 单元的功能。该单元在每个时间步长产生一个内存,从一个输入到另一个输入。下一步会消耗当前输入以及上一个时间步长的内存。

在 RNN 中发生的计算比在 FCN 中更复杂。RNN 按给定顺序(即 x1、x2、x3)从一个输入到另一个输入。在每个步骤中,递归层产生一个输出(即 o1、o2、o3),并将隐藏计算(h0、h1、h2、h3)传递到下一个时间步长。在这里,第一个隐藏状态(h0)通常设为零。

在给定的时间步长上,递归层计算一个隐藏状态,就像 Dense 层一样。然而,涉及的具体计算更加复杂,超出了本书的范围。隐藏状态的大小是递归层的另一个超参数。递归层接受当前输入以及细胞计算的先前隐藏状态。更大尺寸的隐藏状态有助于保持更多内存,但增加了网络的内存需求。由于隐藏状态依赖于上一个时间步长的自身,这些网络被称为 RNNs。

使用 SimpleRNN 的算法

SimpleRNN 层模仿的计算也称为Elman 网络。要了解递归层中发生的具体计算,你可以阅读 J.L. Elman(1990)的论文“Finding Structure in Time”。要了解 RNN 的后续变体及其区别的更高级概述,请参阅mng.bz/xnJgmng.bz/Ay2g

默认情况下,SimpleRNN 不会将隐藏状态暴露给开发者,并且会在时间步长之间自动传播。对于这个任务,我们只需要每个时间步长产生的最终输出,这默认情况下是该层的输出。因此,你可以简单地将 SimpleRNN 连接到 Sequential API 中的一个 Dense 层,而无需进行任何额外的工作。

你是否注意到我们没有为第一层提供 input_shape?只要你在模型拟合期间提供正确形状的数据即可。Keras 会懒惰地构建层,因此在你向模型提供数据之前,模型不需要知道输入大小。但为了避免错误,最好在模型的第一层设置 input_shape 参数。例如,在我们定义的模型中,第一层(即 SimpleRNN 层)可以更改为 layers.SimpleRNN(64, input_shape=x),其中 x 是包含模型接受的数据形状的元组。

这个模型的另一个重要区别是它是一个回归模型,而不是分类模型。在分类模型中,有不同的类别(由输出节点表示),我们尝试将给定的输入与不同的类别(或节点)关联起来。回归模型预测一个连续的值作为输出。在我们的回归模型中,输出中没有类的概念,而是表示 CO2 浓度的实际连续值。因此,我们必须适当地选择损失函数。在这种情况下,我们将使用均方误差(MSE)作为损失。MSE 是回归问题的非常常见的损失函数。我们将使用 MSE 损失和 adam 优化器编译 rnn:

rnn.compile(loss='mse', optimizer='adam')

让我们祈祷并训练我们的模型:

x, y = generate_data(data[“Average Diff”], n_seq=13)
rnn.fit(x, y, shuffle=True, batch_size=64, epochs=25)

你将得到以下异常:

ValueError: 
➥ Input 0 of layer sequential_1 is incompatible with the layer: 
➥ expected ndim=3, found ndim=2\. Full shape received: [None, 12]

看起来我们做错了什么。我们刚刚运行的那行导致了一个异常,它说给层 sequential_1(即 SimpleRNN 层)提供的数据的维度出了问题。具体来说,sequential_1 层期望一个三维输入,但却有一个二维输入。我们需要调查这里发生了什么,并解决这个问题。

问题在于 SimpleRNN(或 tf.keras 中的任何其他顺序层)只接受非常特定格式的数据。数据需要是三维的,按照以下顺序的维度:

  1. 批处理维度

  2. 时间维度

  3. 特征维度

即使对于这些维度中的任何一个,你只有一个元素,它们也需要以大小为 1 的维度存在于数据中。让我们通过打印 x.shape 来查看 x 的维度。你将会得到 x.shape = (429, 12)。现在我们知道了问题所在。我们尝试传递一个二维数据集,但我们应该传递一个三维数据集。在这种情况下,我们需要将 x 重塑为形状为 (492, 12, 1) 的张量。让我们修改我们的 generate_data(...) 函数以反映以下清单中的这种变化。

列表 4.5 具有正确形状数据的先前 generate_data() 函数

import numpy as np

def generate_data(co2_arr,n_seq):
    x, y = [],[]                              ❶
    for i in range(co2_arr.shape[0]-n_seq):   ❷
        x.append(co2_arr[i:i+n_seq-1])        ❸
        y.append(co2_arr[i+n_seq-1:i+n_seq])  ❸
    x = np.array(x).reshape(-1,n_seq-1,1)     ❹
    y = np.array(y) 
    return x,y

❶ 创建两个列表来保存输入序列和标量输出目标。

❷ 遍历数据中所有可能的起始点,以用作输入序列。

❸ 创建第 i 个位置的输入序列和输出目标。

❹ 将 x 从列表转换为数组,并使 x 成为 RNN 可接受的 3D 张量。

现在让我们尝试训练我们的模型:

x, y = generate_data(data[“Average Diff”], n_seq=13)
rnn.fit(x, y, shuffle=True, batch_size=64, epochs=25)

你应该看到模型的 MSE 在下降:

Train on 429 samples
Epoch 1/25
429/429 [==============================] - 1s 2ms/sample - loss: 0.4951
Epoch 2/25
429/429 [==============================] - 0s 234us/sample - loss: 0.0776
...
Epoch 24/25
429/429 [==============================] - 0s 234us/sample - loss: 0.0153
Epoch 25/25
429/429 [==============================] - 0s 234us/sample - loss: 0.0152

我们从大约 0.5 的损失开始,最终损失大约为 0.015。这是一个非常积极的迹象,因为它表明模型正在学习数据中存在的趋势。

4.3.3 使用经过训练的模型预测未来的 CO2 值

到目前为止,我们已经专注于分类任务。对于分类任务,评估模型要比回归任务容易得多。在分类任务中(假设数据集平衡),通过计算数据的总体准确性,我们可以得到一个体现模型表现的不错的代表性数字。在回归任务中,情况并不那么简单。我们无法对回归值进行准确度测量,因为预测的是实际值,而不是类别。例如,均方损失的大小取决于我们正在回归的值,这使它们难以客观解释。为了解决这个问题,我们预测未来五年的数值,并直观地检查模型的预测情况(见下一列表)。

列表 4.6 使用训练模型的未来 CO2 水平预测逻辑

history = data["Average Diff"].values[-12:].reshape(1,-1,1)     ❶
true_vals = []
prev_true = data["Average"].values[-1]                          ❷
for i in range(60):                                             ❸
    p_diff = rnn.predict(history).reshape(1,-1,1)               ❹
    history = np.concatenate((history[:,1:,:],p_diff),axis=1)   ❺
    true_vals.append(prev_true+p_diff[0,0,0])                   ❻
    prev_true = true_vals[-1]                                   ❼

❶ 从中获取开始预测的第一个数据序列,重塑为 SimpleRNN 接受的正确形状

❷ 保存最后一个绝对 CO2 浓度值,以计算相对预测的实际值。

❸ 预测接下来的 60 个月。

❹ 使用数据序列进行预测。

❺ 修改历史记录,以包括最新的预测。

❻ 计算绝对 CO2 浓度。

❼ 更新 prev_true,以便在下一个时间步骤中计算绝对 CO2 浓度。

让我们回顾一下我们所做的事情。首先,我们从我们的训练数据中提取最后 12 个 CO2 值(从平均差值列中)来预测第一个未来的 CO2 值,并将其重塑为模型期望数据的正确形状:

history = data["Average Diff"].values[-12:].reshape(1,-1,1)

然后,我们将预测的 CO2 值记录在 true_vals 列表中。请记住,我们的模型只预测 CO2 值相对于先前 CO2 值的相对运动。因此,在模型预测之后,为了得到绝对 CO2 值,我们需要最后一个 CO2 值。prev_true 捕获了这一信息,最初包含数据的平均列中的最后一个值:

prev_true = data["Average"].values[-1]

现在,接下来的 60 个月(或 5 年),我们可以递归预测 CO2 值,同时使最后预测的值成为网络的下一个输入。要做到这一点,我们首先使用 Keras 提供的 predict(...)方法预测一个值。然后,我们需要确保预测也是一个三维张量(尽管它只是一个单一值)。然后我们修改 history 变量:

history = np.concatenate((history[:,1:,:],p_diff),axis=1)

我们把历史中除了第一个值之外的所有值,并将最后预测的值附加到末尾。然后,我们通过添加 prev_true 值到 p_diff 来附加绝对预测的 CO2 值:

true_vals.append(prev_true+p_diff[0,0,0])

最后,我们将 prev_true 更新为我们预测的最后一个绝对 CO2 值:

prev_true = true_vals[-1]

通过递归执行这组操作,我们可以获得接下来 60 个月的预测值(保存在 true_vals 变量中)。如果我们可视化预测的值,它们应该看起来像图 4.21。

04-21

图 4.21 在接下来的五年里预测的 CO2 浓度。虚线代表当前数据的趋势,实线代表预测的趋势。

做得好!考虑到模型的简单性,预测看起来非常有前景。该模型肯定捕捉到了二氧化碳浓度的年度趋势,并学会了二氧化碳水平将继续上升。你现在可以去找你的老板,事实性地解释为什么我们应该担心未来气候变化和危险水平的二氧化碳。我们在这里结束了对不同神经网络的讨论。

练习 3

受到你在预测二氧化碳浓度方面工作的印象,你的老板给了你数据,并要求你改进模型以预测二氧化碳和温度值。保持其他超参数不变,你会如何改变模型以完成这个任务?确保指定第一层的 input_shape 参数。

总结

  • 完全连接网络(FCNs)是最简单直接的神经网络之一。

  • FCNs 可以使用 Keras Dense 层来实现。

  • 卷积神经网络(CNNs)是计算机视觉任务的热门选择。

  • TensorFlow 提供了各种层,如 Conv2D、MaxPool2D 和 Flatten,这些层帮助我们快速实现 CNNs。

  • CNNs 有一些参数,如卷积核大小、步幅和填充,必须小心设置。如果不小心,这可能导致张量形状不正确和运行时错误。

  • 循环神经网络(RNNs)主要用于学习时间序列数据。

  • 典型的 RNN 期望数据组织成具有批次、时间和特征维度的三维张量。

  • RNN 看的时间步数是一个重要的超参数,应该根据数据进行选择。

练习答案

练习 1: 你可以使用 Sequential API 来做到这一点,你只需要使用 Dense 层。

练习 2

autoencoder = models.Sequential(
    [layers.Dense(32, activation='sigmoid', input_shape=(512,)),
    layers.Dense(16, activation='sigmoid'),
    layers.Dense(512, activation='sigmoid')]
)

练习 3

rnn = models.Sequential([
    layers.SimpleRNN(64, input_shape=(12, 2)),
    layers.Dense(64, activation='relu'),
    layers.Dense(2)
])

第五章:深度学习的最新技术:Transformer

本章内容包括:

  • 为机器学习模型以数值形式表示文本

  • 使用 Keras sub-classing API 构建 Transformer 模型

到目前为止,我们已经看到了许多不同的深度学习模型,包括全连接网络、卷积神经网络和循环神经网络。我们使用全连接网络来重建受损图像,使用卷积神经网络来对车辆进行分类,最后使用 RNN 来预测未来的 CO2 浓度值。在本章中,我们将讨论一种新型的模型,即 Transformer。

Transformer 是最新一代的深度网络。瓦斯瓦尼等人在他们的论文《Attention Is All You Need》(arxiv.org/pdf/1706.03762.pdf)中普及了这个想法。他们创造了“Transformer”这个术语,并解释了它在未来有很大潜力。在随后的几年里,谷歌、OpenAI 和 Facebook 等领先的科技公司实施了更大更好的 Transformer 模型,这些模型在 NLP 领域显著优于其他模型。在这里,我们将参考瓦斯瓦尼等人在论文中介绍的模型来学习它。虽然 Transformer 也存在于其他领域(例如计算机视觉),我们将重点介绍 Transformer 在 NLP 领域中的应用,特别是在机器翻译任务中(即使用机器学习模型进行语言翻译)。本章将省略原始 Transformer 论文中的一些细节,以提高清晰度,但这些细节将在后面的章节中进行介绍。

想要在使用深度学习模型解决实际问题时出类拔萃,了解 Transformer 模型的内部工作原理是必不可少的。如前所述,Transformer 模型在机器学习领域迅速普及。这主要是因为它在解决复杂机器学习问题方面展现出的性能。

5.1 将文本表示为数字

假设你正在参加一个游戏节目。游戏中的一个挑战叫做单词盒子。有一个由透明盒子组成的矩阵(3 行,5 列,10 深度)。你也有一些上面涂有 0 或 1 的球。你被给予了三个句子,你的任务是用 1 和 0 填充所有的盒子来表示这些句子。此外,你可以在一分钟内写一条简短的信息,帮助其他人在以后破译这一信息。之后,另一个队员看着盒子,写下最初给你的原始句子中的尽可能多的单词。

这个挑战本质上是如何将文本转换成数字,用于机器翻译模型。这也是在了解任何 NLP 模型之前需要解决的重要问题。到目前为止我们看到的数据都是数值型数据结构。例如,一张图像可以被表示为一个 3D 数组(高度,宽度和通道维度),其中每个值表示像素强度(即,取值范围在 0 至 255 之间)。但文本呢?我们怎么让计算机理解字符、单词或句子呢?我们将在自然语言处理(NLP)的情境中学习如何用 Transformer 完成这一点。

您有以下一组句子:

  • 我去了海滩。

  • 天气很冷。

  • 我回到了房子。

你要做的第一件事是给词汇表中的每个单词分配一个从 1 开始的 ID。我们将保留数字 0 给我们稍后会看到的特殊标记。假设你分配了以下 ID:

  • I → 1

  • went → 2

  • to → 3

  • the → 4

  • beach → 5

  • 它 → 6

  • was → 7

  • cold → 8

  • came → 9

  • back → 10

  • house → 11

将单词映射到相应的 ID 后,我们的句子变为了下面这个样子:

  • [1, 2, 3, 4, 5]

  • [6, 7, 8]

  • [1, 9, 10, 3, 4, 11]

请记住,您需要填写所有方框,并且最多长度为 5。请注意我们的最后一句有六个单词。这意味着所有句子都需要表示为固定长度。深度学习模型面临类似的问题。它们以批处理的方式处理数据,并且为了高效处理数据,批处理的序列长度需要是固定的。真实世界的句子在长度上可能差异很大。因此,我们需要

  • 用特殊标记(ID 为 0)填充短句

  • 截断长句

使它们具有相同的长度。如果我们填充短句并截断长句,使长度为 5,我们得到以下结果:

  • [1, 2, 3, 4, 5]

  • [6, 7, 8, 0, 0]

  • [1, 9, 10, 3, 4]

这里,我们有一个大小为 3×5 的 2D 矩阵,它表示我们的一批句子。最后要做的一件事是将这些 ID 表示为向量。因为我们的球有 1 和 0,你可以用 11 个球(我们有 10 个不同的单词和特殊标记)代表每个单词,其中由单词 ID 指示的位置上的球为 1,其余为 0。这种方法称为 one-hot 编码。例如,

以下分别代表着各自的 ID:

1 → [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]

。 。 。

10 → [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0]

11 → [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]

现在你可以用 1 和 0 填写方框,使得你得到类似图 5.1 的结果。这样,任何一位有这些 ID 映射的人(提供在一张纸上)都可以解密最初所提供的大部分单词(除了被截断的单词)。

05-01

图 5.1 词盒游戏中的方框。阴影方框代表一个词(即第一个句子中的第一个词“I”,它的 ID 是 1)。你可以看到它被一个 1 和九个 0 所表示。

同样,这是在 NLP 问题中对单词进行的转换。你可能会问:“为什么不直接提供单词 ID?”存在两个问题:

  • 神经网络看到的值范围非常大(0-100,000+)对于一个现实世界的问题。这会导致不稳定性并使训练困难。

  • 输入 ID 会错误地表明具有相似 ID 的单词应该是相似的(例如,单词 ID 4 和 5)。这从未发生过,会使模型混淆并导致性能下降。

因此,将单词转换为某种向量表示是很重要的。有许多将单词转换为向量的方法,例如独热编码和词嵌入。你已经看到了独热编码的工作原理,我们将稍后详细讨论词嵌入。当我们将单词表示为向量时,我们的 2D 矩阵变为 3D 矩阵。例如,如果我们将向量长度设置为 4,你将得到一个 3 × 6 × 4 的 3D 张量。图 5.2 描述了最终矩阵的外观。

05-02

图 5.2 表示一个单词序列批次的 3D 矩阵,其中每个单词由一个向量表示(即矩阵中的阴影块)。有三个维度:批次、序列(时间)和特征。

接下来,我们将讨论流行的 Transformer 模型的各个组成部分,这将为我们提供对这些模型内部执行的基础。

5.2 理解 Transformer 模型

你目前是一名深度学习研究科学家,并最近受邀在当地 TensorFlow 大会上进行有关 Transformer 的研讨会。Transformer 是一类新型的深度学习模型,在众多任务中已经超越了它们的老对手。你计划首先解释 Transformer 网络的架构,然后带领参与者完成几个练习,在这些练习中,他们将使用 Keras 的子类化层实现在 Transformers 中找到的基本计算,最后使用这些计算来实现一个基本的小规模 Transformer。

5.2.1 Transformer 的编码器-解码器视图

Transformer 网络基于编码器-解码器架构。编码器-解码器模式在某些类型的深度学习任务中很常见(例如,机器翻译、问答、无监督图像重建)。其思想是编码器将输入映射到某种潜在(或隐藏)表示(通常较小),而解码器使用潜在表示构建有意义的输出。例如,在机器翻译中,语言 A 的句子被映射到一个潜在向量,解码器从中构建语言 B 中该句子的翻译。你可以将编码器和解码器视为两个独立的机器学习模型,其中解码器依赖于编码器的输出。这个过程如图 5.3 所示。在给定的时间点,编码器和解码器同时处理一批词序列(例如,一批句子)。由于机器学习模型不理解文本,因此该批次中的每个单词都由一个数字向量表示。这是通过一种过程来实现的,例如独热编码,类似于我们在第 5.1 节中讨论的内容。

05-03

图 5.3 机器翻译任务的编码器-解码器架构

编码器-解码器模式在现实生活中也很常见。假设你是法国的导游,带着一群游客去一家餐厅。服务员用法语解释菜单,你需要为团队将其翻译成英语。想象一下你会如何做。当服务员用法语解释菜肴时,你处理这些词语并创建出菜肴的心理图像,然后将该心理图像翻译成一系列英语词语。

现在让我们更深入地了解各个组件及其构成。

5.2.2 更深入地探讨

自然地,你可能会问自己:“编码器和解码器由什么组成?”这是本节的主题。请注意,此处讨论的编码器和解码器与你在第三章中看到的自编码器模型有很大不同。正如之前所述,编码器和解码器分别像多层深度神经网络一样工作。它们由多个层组成,每个层包含子层,封装了对输入进行的某些计算以产生输出。前一层的输出作为下一层的输入。还需要注意的是,编码器和解码器的输入和输出是序列,例如句子。这些模型中的每个层都接收一个元素序列并输出另一个元素序列。那么,编码器和解码器中的单个层包含什么?

每个编码器层包含两个子层:

  • 自注意力层

  • 全连接层

自注意力层的最终输出与全连接层类似(即使用矩阵乘法和激活函数)。典型的全连接层将处理输入序列中的所有元素,并分别处理它们,然后输出一个元素以替换每个输入元素。但自注意力层可以选择和组合输入序列中的不同元素以输出给定元素。这使得自注意力层比典型的全连接层更加强大(见图 5.4)。

05-04

图 5.4 自注意力子层和全连接子层之间的区别。自注意力子层查看序列中的所有输入,而全连接子层只查看正在处理的输入。

为什么以这种方式选择和组合不同的输入元素有好处?在自然语言处理的上下文中,自注意力层使模型在处理某个单词时能够查看其他单词。但这对模型意味着什么?这意味着在编码器处理句子“I kicked the ball and it disappeared”中的单词“it”时,模型可以关注单词“ball”。通过同时看到“ball”和“it”两个单词(学习依赖关系),消除歧义的单词变得更容易。这样的能力对语言理解至关重要。

我们可以通过一个现实世界的例子了解自注意力如何方便地帮助我们解决任务。假设你正在和两个人玩游戏:A 和 B。A 手持写有问题的板子,你需要说出答案。假设 A 一次只透露一个单词,直到问题的最后一个单词被揭示,才揭示你正在回答问题。对于长而复杂的问题,这是具有挑战性的,因为你不能物理上看到完整的问题,必须严重依赖记忆来回答问题。这就是没有自注意力时的感觉。另一方面,假设 B 将整个问题一次性展示在板上,而不是逐字逐句地展示。现在回答问题要容易得多,因为你可以一次看到整个问题。如果问题很复杂,需要复杂的答案,你可以在提供完整答案的各个部分时查看问题的不同部分。这就是自注意力层的作用。

接下来,全连接层以逐元素的方式接受自注意力子层产生的输出元素,并为每个输出元素生成一个隐藏表示。这使得模型更加深入,从而表现更好。

让我们更详细地看一下数据如何通过模型流动,以更好地理解层和子层的组织。假设要将句子“Dogs are great”(英语)翻译成“Les chiens sont super”(法语)。首先,编码器接收完整的句子“Dogs are great”,并为句子中的每个单词生成一个输出。自注意力层选择每个位置的最重要的单词,计算一个输出,并将该信息发送到全连接层以产生更深层的表示。解码器迭代地生成输出单词,一个接着一个。为此,解码器查看编码器的最终输出序列以及解码器预测的所有先前单词。假设最终预测是 les chiens sont super 。这里, 标记了句子的开始, 标记了句子的结束。它接收的第一个输入是一个特殊标记,表示句子的开始(),以及编码器的输出,并产生翻译中的下一个单词:“les”。然后解码器消耗 和 “les” 作为输入,生成单词“chiens”,并继续直到模型达到翻译的末尾(由 标记)。图 5.5 描述了这个过程。

在原始的 Transformer 论文中,编码器有六个层,并且一个单层有一个自注意力子层和一个全连接子层,按顺序排列。首先,自注意力层将英文单词作为时间序列输入。然而,在将这些单词馈送到编码器之前,您需要为每个单词创建一个数值表示,如前面所讨论的。在论文中,词嵌入(附加一些编码)用于表示这些单词。每个嵌入都是一个 512 长的向量。然后自注意力层计算输入句子中每个单词的隐藏表示。如果我们忽略一些实现细节,这个时间步长 t 的隐藏表示可以被看作是所有输入的加权和(在一个单一序列中),其中输入位置 i 的权重由在处理编码器输入中的单词 ew[t] 时选择(或关注)编码器单词 ew[i] 在输入序列中的重要性来确定。编码器在输入序列中的每个位置 t 上都做出这样的决定。例如,在处理句子“我踢了 并且 消失了”中的单词“它”时,编码器需要更多地关注单词“球”而不是单词“the”。自注意力子层中的权重被训练以展示这样的属性。这样,自注意力层为每个编码器输入生成了一个隐藏表示。我们称之为 关注表示/输出

全连接子层然后接管并且非常直观。它有两个线性层,并且在这两个层之间有一个 ReLU 激活函数。它接收自注意力层的输出,并将其转换为隐藏输出使用。

h[1] = ReLU(xW[1] + b[1])

h[2] = h[1]W[2] + b[2]

请注意,第二层没有非线性激活函数。接下来,解码器也有六个层,每个层都有三个子层:

  • 一个掩码自注意力层

  • 一个编码器-解码器注意力层

  • 一个全连接层

掩码自注意力层的操作方式与自注意力层类似。然而,在处理第 s 个单词(即dw[s])时,它会屏蔽 dw[s] 之前的单词。例如,在处理单词“chiens”时,它只能关注单词“”和“les”。这很重要,因为解码器必须能够预测正确的单词,只给出它先前预测的单词,所以强制解码器只关注它已经看到的单词是有意义的。

接下来,编码器-解码器注意力层获取编码器输出和掩码自注意力层产生的输出,并产生一系列输出。该层的目的是计算时间 s 处的隐藏表示(即一个受关注的表示),作为编码器输入的加权和,其中位置 j 的权重由处理解码器单词 dw[s] 时关注编码器输入 e w[j]的重要性确定。

最后,与编码器层相同的全连接层接收自注意力层的输出以生成层的最终输出。图 5.5 以高层次描述了本节讨论的层和操作。

05-05

图 5.5 编码器和解码器中的各个层以及编码器内部、解码器内部和编码器与解码器之间形成的各种连接。方框表示模型的输入和输出。长方形阴影框表示子层的临时输出。

在下一节中,我们将讨论自注意力层的外观。

5.2.3 自注意力层

我们已经在抽象级别上介绍了自注意力层的目的。在处理时间步 t 的单词w[t]时,其目的是确定关注输入序列中的第 i 个单词(即w[i])对理解当前单词有多重要。换句话说,该层需要确定对于每个单词(由 t 索引)所有其他单词(由 i 索引)的重要性。现在让我们以更细粒度的方式理解涉及此过程的计算。

首先,计算涉及三个不同的实体:

  • 查询 — 查询的目的是表示当前正在处理的单词。

  • — 键的目的是表示在处理当前单词时要关注的候选单词。

  • — 值的目的是计算序列中所有单词的加权和,其中每个单词的权重基于它对理解当前单词的重要性。

对于给定的输入序列,需要为输入的每个位置计算查询、键和值。这些是由与每个实体相关联的权重矩阵计算的。

请注意,这是它们关系的简化,实际关系有些复杂和混乱。但这种理解为什么我们需要三个不同的实体来计算自注意力输出提供了动机。

接下来,我们将了解自注意力层如何从输入序列到查询、键和值张量,最终到输出序列。首先,将输入的单词序列使用单词嵌入查找转换为数值表示。单词嵌入本质上是一个巨大的矩阵,其中词汇表中的每个单词都有一个浮点向量(即嵌入向量)。通常,这些嵌入是几百个元素长。对于给定的输入序列,我们假设输入序列的长度为 n 元素,并且每个单词向量的长度为 d[model] 元素。然后我们有一个 n × d[model] 矩阵。在原始 Transformer 论文中,单词向量长度为 512 个元素。

自注意力层中有三个权重矩阵:查询权重(W[q])、键权重(W[k])和值权重(W[v]),分别用于计算查询、键和值向量。W[q] 是 d[model] × d[q],W[k] 是 d[model] × d[k],W[v] 是 d[model] × d[v]。让我们假设这些元素在 TensorFlow 中的维度为 512,就像原始 Transformer 论文中一样。即,

d[model] = d[q] = d[k] = d[v] = 512

我们首先将我们的输入 x 定义为一个 tf.constant,它有三个维度(批量、时间、特征)。Wq、Wk 和 Wv 声明为 tf.Variable 对象,因为这些是自注意力层的参数。

import tensorflow as tf
import numpy as np

n_seq = 7
x = tf.constant(np.random.normal(size=(1,n_seq,512)))
Wq = tf.Variable(np.random.normal(size=(512,512)))
Wk = tf.Variable (np.random.normal(size=(512,512)))
Wv = tf.Variable (np.random.normal(size=(512,512)))

其形状为

>>> x.shape=(1, 7, 512)
>>> Wq.shape=(1, 512)
>>> Wk.shape=(1, 512)
>>> Wv.shape=(1, 512)

接下来,qkv 计算如下:

q = xW[q];形状变换:n × d[model]。d[model] × d[q] = n × d[q]

k = xW[k];形状变换:n × d[model]。d[model] × d[k] = n × d[k]

v = xW[v];形状变换:n × d[model]。d[model] × d[v] = n × d[v]

很明显,计算 qkv 只是一个简单的矩阵乘法。请记住,所有输入(即 x)和输出张量(即 q、k 和 v)前面都有一个批处理维度,因为我们处理数据批次。但为了避免混乱,我们将忽略批处理维度。然后我们按以下方式计算自注意力层的最终输出:

05_05a

在这里,组件 05_05b(将被称为 P)是一个概率矩阵。这就是自注意力层的全部内容。使用 TensorFlow 实现自注意力非常简单。作为优秀的数据科学家,让我们将其创建为可重复使用的 Keras 层,如下所示。

列表 5.1 自注意力子层

import tensorflow as tf
import tensorflow.keras.layers as layers

class SelfAttentionLayer(layers.Layer):

    def __init__(self, d):
        super(SelfAttentionLayer, self).__init__()
        self.d = d                                                                ❶

    def build(self, input_shape):
        self.Wq = self.add_weight(                                                ❷
            shape=(input_shape[-1], self.d), initializer='glorot_uniform',        ❷
            trainable=True, dtype='float32'                                       ❷
        )        
        self.Wk = self.add_weight(                                                ❷
            shape=(input_shape[-1], self.d), initializer='glorot_uniform',        ❷
            trainable=True, dtype='float32'                                       ❷
        )
        self.Wv = self.add_weight(                                                ❷
            shape=(input_shape[-1], self.d), initializer='glorot_uniform',        ❷
            trainable=True, dtype='float32'                                       ❷
        )

    def call(self, q_x, k_x, v_x):
        q = tf.matmul(q_x,self.Wq)                                                ❸
        k = tf.matmul(k_x,self.Wk)                                                ❸
        v = tf.matmul(v_x,self.Wv)                                                ❸

        p = tf.nn.softmax(tf.matmul(q, k, transpose_b=True)/math.sqrt(self.d))    ❹
        h = tf.matmul(p, v)                                                       ❺
        return h,p

❶ 定义自注意力输出的输出维度

❷ 定义计算查询、键和值实体的变量

❸ 计算查询、键和值张量

❹ 计算概率矩阵

❺ 计算最终输出

这是一个快速的复习:

  • init(self, d)—定义层的任何超参数

  • build(self, input_shape)—创建层的参数作为变量

  • call(self, v_x, k_x, q_x)—定义层中发生的计算

如果你看一下 call(self, v_x, k_x, q_x) 函数,它接受三个输入:分别用于计算值、键和查询。在大多数情况下,这些输入是相同的。然而,也有一些情况下,不同的输入被用于这些计算(例如,解码器中的一些计算)。此外,请注意我们同时返回 h(即最终输出)和 p(即概率矩阵)。概率矩阵是一个重要的视觉辅助工具,它帮助我们理解模型何时以及在哪里关注了单词。如果你想获取层的输出,可以执行以下操作

layer = SelfAttentionLayer(512)
h, p = layer(x, x, x)
print(h.shape)

将返回

>>> (1, 7, 512)

练习 1

给定以下输入

x = tf.constant(np.random.normal(size=(1,10,256)))

并假设我们需要一个大小为 512 的输出,编写代码创建 Wq、Wk 和 Wv 作为 tf.Variable 对象。使用 np.random.normal() 函数设置初始值。

5.2.4 使用标量理解自注意力

目前还不太清楚为什么设计了这样的计算方式。为了理解和可视化这个层正在做什么,我们将假设特征维度为 1. 也就是说,一个单词由一个值(即标量)表示。图 5.6 可视化了如果我们假设单一输入序列和输入的维度(d[model])、查询长度(d[q])、键长度(d[k])和值长度(d[v])的维度都为 1. 在我们所做的假设下,W[q]、W[k] 和 W[v] 将是标量。用于计算 qkv 的矩阵乘法本质上变成了标量乘法:

q = (q[1], q[2],..., q[7]),其中 q[i] = x[i] W[q]

k = (k[1], k[2],..., k[7]),其中 k[i] = x[i] W[k]

v = (v[1], v[2],..., v[7]),其中 v[i] = x[i] W[v]

接下来,我们需要计算 P = softmax ((Q.K^T) / √(d[k])) 组件。Q.K^T 本质上是一个 n × n 的矩阵,它代表了每个查询和键组合的项(图 5.6)。Q.K[(i,j)]^T 的第 i 行和第 j 列是按如下计算的

Q.K[(i,j)]^T =q [i] × k [j]

然后,通过应用 softmax 函数,该矩阵被转换为行向量的概率分布。你可能已经注意到 softmax 转换中出现了一个常数 √(d[k])。这是一个归一化常数,有助于防止梯度值过大并实现稳定的梯度。在我们的示例中,你可以忽略这个因为 √(d[k]) = 1。

最后,我们计算最终输出 h = (h[1],h[2],...,h[7]),其中

h[i] = P[(i],[1)] v[1] + P[(i],[2)] v[2] +...+ P[(i],[7)] v[7]

在这里,我们可以更清楚地看到 qkv 之间的关系。当计算最终输出时,qk 被用于计算 v 的软索引机制。例如,当计算第四个输出(即 h[4])时,我们首先对第四行进行硬索引(跟随 q[4]),然后根据该行的列(即 k 值)给出的软索引(即概率),混合各种 v 值。现在更清楚 qkv 的作用是什么了:

  • 查询—帮助构建最终用于索引值(v)的概率矩阵。查询影响矩阵的行,并表示正在处理的当前单词的索引。

  • —帮助构建最终用于索引值(v)的概率矩阵。键影响矩阵的列,并表示根据查询单词需要混合的候选单词。

  • —通过使用查询和键创建的概率矩阵进行索引,用于计算最终输出的隐藏(即关注)表示。

您可以轻松地将图 5.6 中的大灰色框放置在自注意子层上,并仍然产生输出形状(如图 5.5 中所示)(图 5.7)。

05-06

图 5.6 自注意层中的计算。自注意层从输入序列开始,并计算查询、键和值向量的序列。然后将查询和键转换为概率矩阵,该矩阵用于计算值的加权和。

05-07

图 5.7(顶部)和图 5.6(底部)。您可以从底部获取灰色框,并将其插入到顶部的自注意子层中,然后可以看到产生相同的输出序列。

现在让我们扩展我们的自注意层,并重新审视其背后的具体计算及其重要性。回到我们先前的表示法,我们从一个具有 n 个元素的单词序列开始。然后,在嵌入查找之后,为每个单词检索一个嵌入向量,我们有一个大小为 n × d[model] 的矩阵。接下来,我们有权重和偏差来计算每个查询、键和值向量:

q = xW[q],其中 x ∈ ℝ^(n×dmodel)。W[q] ∈ ℝ^(dmodel×dq),而 q ∈ ℝ^(n×d)q

k = xW[k],其中 x ∈ ℝ^(n×dmodel)。W[k] ∈ ℝ^(dmodel×dk),而 k ∈ ℝ^(n×d)k

v = xW[v],其中 x ∈ ℝ^(n×dmodel)。W[v] ∈ ℝ^(dmodel×dv),而 v ∈ ℝ^(n×d)v

例如,查询,或 q,是一个大小为 n × d[q] 的向量,通过将大小为 n × d[model] 的输入 x 与大小为 d[model] × d[q] 的权重矩阵 W[q] 相乘获得。还要记住,正如在原始 Transformer 论文中一样,我们确保查询、键和值向量的所有输入嵌入大小相同。换句话说,

d[model] = d[q] = d[k] = d[v] = 512

接下来,我们使用我们获得的 q 和 k 值计算概率矩阵:

05_06a

最后,我们将这个概率矩阵与我们的值矩阵相乘,以获得自注意力层的最终输出:

05_06b

自注意力层接受一批词序列(例如,一批具有固定长度的句子),其中每个词由一个向量表示,并产生一批隐藏输出序列,其中每个隐藏输出是一个向量。

自注意力与循环神经网络(RNNs)相比如何?

在 Transformer 模型出现之前,RNNs 主导了自然语言处理的领域。RNNs 在 NLP 问题中很受欢迎,因为大多数问题本质上都是时间序列问题。你可以将句子/短语视为一系列单词(即每个单词由一个特征向量表示)在时间上的分布。RNN 通过这个序列,一次消耗一个单词(同时保持一个记忆/状态向量),并在最后产生一些输出(或一系列输出)。但是你会发现,随着序列长度的增加,RNN 的表现越来越差。这是因为当 RNN 到达序列末尾时,它可能已经忘记了开始时看到的内容。

你可以看到,自注意力机制缓解了这个问题,它允许模型在给定时间内查看完整的序列。这使得 Transformer 模型比基于 RNN 的模型表现得好得多。

5.2.5 自注意力作为烹饪比赛

自注意力的概念可能仍然有点难以捉摸,这使得理解自注意力子层中究竟发生了什么变得困难。以下类比可能会减轻负担并使其更容易理解。假设你参加了一个与其他六位选手(总共七位选手)一起的烹饪节目。游戏如下。

你在超市里拿到一件印有号码(从 1 到 7)的 T 恤和一个手推车。超市有七个过道。你必须飞奔到印有 T 恤上号码的过道,墙上会贴着某种饮料的名称(例如,苹果汁,橙汁,酸橙汁)。你需要挑选制作该饮料所需的物品,然后飞奔到你分配的桌子上,制作那种饮料。

假设你是号码 4 并且拿到了橙汁,所以你会前往 4 号过道并收集橙子,一点盐,一颗酸橙,糖等等。现在假设你旁边的对手(编号 3)要制作酸橙汁;他们会挑选酸橙,糖和盐。正如你所看到的,你们选取了不同的物品以及相同物品的不同数量。例如,你的对手没有选择橙子,但你选择了,并且你可能选择了较少的酸橙,与你正在制作酸橙汁的对手相比。

这与自注意力层中发生的情况非常相似。你和你的竞争对手是模型的输入(在一个时间步上)。通道是查询,你需要选择的货品是键。就像通过查询和键来索引概率矩阵以获得“混合系数”(即注意力权重)来获取值一样,你可以通过分配给你的通道号(即查询)和通道中每个货品的数量(即键)来索引你所需要的货品。最后,你制作的饮料就是值。请注意,这个类比并不完全对应于自注意力子层中的计算。然而,你可以在抽象层面上发现这两个过程之间的显著相似之处。我们发现的相似之处如图 5.8 所示。

05-08

图 5.8 以烹饪比赛为背景描述的自注意力。选手是查询,货品是你需要选择的食材,值是你制作的最终饮料。

接下来我们将讨论什么是蒙版自注意力层。

5.2.6 蒙版自注意力层

正如你已经看到的,解码器有一个特殊的额外的自注意子层,称为蒙版自注意力。正如我们已经提到的,这个想法是防止模型通过关注不应关注的单词(也就是模型预测位置之前的单词)来“作弊”。为了更好地理解这一点,假设有两个人在教一个学生从英语翻译成法语。第一个人给出一个英语句子,要求学生逐词翻译,同时给出到目前为止已经翻译的反馈。第二个人给出一个英语句子,要求学生翻译,但提前提供完整的翻译。在第二种情况下,学生很容易作弊,提供高质量的翻译,虽然对语言几乎一无所知。现在让我们从机器学习的角度来理解关注不应关注的单词的潜在危险。

假设我们要将句子 “dogs are great” 翻译为 “les chiens sont super。” 当处理句子 “Dogs are great” 时,模型应该能够关注该句子中的任何单词,因为这是模型在任何给定时间完全可用的输入。但是,在处理句子 “Les chiens sont super” 时,我们需要注意向模型展示什么和不展示什么。例如,在训练模型时,我们通常一次性提供完整的输出序列,而不是逐字节地提供单词,以增强计算效率。在向解码器提供完整输出序列时,我们必须屏蔽当前正在处理的单词之前的所有单词,因为让模型在看到该单词之后的所有内容后预测单词 “chiens” 是不公平的。这是必须做的。如果不这样做,代码会正常运行。但最终,当你将其带到现实世界时,性能会非常差。强制执行这一点的方法是将概率矩阵 p 设为下三角矩阵。这将在注意力/输出计算期间基本上为混合输入的任何内容赋予零概率。标准自注意力和蒙版自注意力之间的差异如图 5.9 所示。

05-09

图 5.9 标准自注意力与蒙版自注意力方法。在标准注意力方法中,给定步骤可以看到来自当前时间步之前或之后的任何其他时间步的输入。然而,在蒙版自注意力方法中,当前时间步只能看到当前输入和之前的时间步。

让我们学习如何在 TensorFlow 中实现这一点。我们对 call() 函数进行了非常简单的更改,引入了一个新参数 mask,该参数表示模型不应该看到的项目,用 1 表示,其余项目用 0 表示。然后,对于模型不应该看到的元素,我们添加一个非常大的负数(即 - 10⁹),以便在应用 softmax 时它们变成零(见清单 5.2)。

清单 5.2 蒙版自注意力子层

import tensorflow as tf

class SelfAttentionLayer(layers.Layer):

    def __init__(self, d):
        ...

    def build(self, input_shape):
        ...

    def call(self, q_x, k_x, v_x, mask=None):   ❶
        q = tf.matmul(x,self.Wq)
        k = tf.matmul(x,self.Wk)
        v = tf.matmul(x,self.Wv)

        p = tf.matmul(q, k, transpose_b=True)/math.sqrt(self.d)
        p = tf.squeeze(p)
        if mask is None:
            p = tf.nn.softmax(p)                ❷
        else:
            p += mask * -1e9                    ❸
            p = tf.nn.softmax(p)                ❸

        h = tf.matmul(p, v)
        return h,p

❶ call 函数接受额外的蒙版参数(即 0 和 1 的矩阵)。

❷ 现在,SelfAttentionLayer 支持蒙版和非蒙版输入。

❸ 如果提供了蒙版,添加一个大的负值以使最终概率为零,以防止看到的单词。

创建蒙版很容易;您可以使用 tf.linalg.band_part() 函数创建三角矩阵

mask = 1 - tf.linalg.band_part(tf.ones((7, 7)), -1, 0)

给出

>>> tf.Tensor(
    [[0\. 1\. 1\. 1\. 1\. 1\. 1.]
     [0\. 0\. 1\. 1\. 1\. 1\. 1.]
     [0\. 0\. 0\. 1\. 1\. 1\. 1.]
     [0\. 0\. 0\. 0\. 1\. 1\. 1.]
     [0\. 0\. 0\. 0\. 0\. 1\. 1.]
     [0\. 0\. 0\. 0\. 0\. 0\. 1.]
     [0\. 0\. 0\. 0\. 0\. 0\. 0.]], shape=(7, 7), dtype=float32)

我们可以通过查看概率矩阵 p 来轻松验证屏蔽是否起作用。它必须是一个下三角矩阵

layer = SelfAttentionLayer(512)
h, p = layer(x, x, x, mask)
print(p.numpy())

给出

>>> [[1\.    0\.    0\.    0\.    0\.    0\.    0\.   ]
     [0.37  0.63  0\.    0\.    0\.    0\.    0\.   ]
     [0.051 0.764 0.185 0\.    0\.    0\.    0\.   ]
     [0.138 0.263 0.072 0.526 0\.    0\.    0\.   ]
     [0.298 0.099 0.201 0.11  0.293 0\.    0\.   ]
     [0.18  0.344 0.087 0.25  0.029 0.108 0\.   ]
     [0.044 0.044 0.125 0.284 0.351 0.106 0.045]]

现在,在计算值时,模型无法看到或关注到它在处理当前单词时尚未看到的单词。

5.2.7 多头注意力

原始 Transformer 论文中讨论了一种称为多头注意力的方法,它是自注意力层的扩展。一旦理解了自注意机制,这个想法就很简单。多头注意力创建多个并行的自注意力头。这样做的动机是,当模型有机会为输入序列学习多个注意力模式(即多组权重)时,它的性能更好。

记住,在单个注意力头中,所有的查询、键和值的维度都设置为 512。换句话说,

d[q] = d[k] = d[v] = 512

使用多头注意力,假设我们使用八个注意力头,

d[q] = d[k] = d[v] = 512/8 = 64

然后将所有注意力头的最终输出连接起来,形成最终输出,它的维度将为 64 × 8 = 512

H = Concat (h¹, h², ... , h⁸)

其中h^i 是第i个注意力头的输出。使用刚刚实现的 SelfAttentionLayer,代码变为

multi_attn_head = [SelfAttentionLayer(64) for i in range(8)]
outputs = [head(x, x, x)[0] for head in multi_attn_head]
outputs = tf.concat(outputs, axis=-1)
print(outputs.shape)

得到

>>> (1, 7, 512)

如你所见,它仍然具有之前的相同形状(没有多个头)。然而,此输出是使用多个头进行计算的,这些头的维度比原始的自注意层要小。

5.2.8 全连接层

与我们刚刚学习的内容相比,全连接层更加简单。到目前为止,自注意力层产生了一个n×d[v]大小的输出(忽略批处理维度)。全连接层将输入数据进行以下转换

h[1] = ReLU(xW[1] + b[1])

这里,W[1]是一个d[v] × d[ff1]的矩阵,b[1]是一个d[ff1]大小的向量。因此,这个操作产生一个n×d[ff1]大小的张量。结果输出传递到另一层,进行以下计算

h[2] = h[1] W[2] + b [2]

这里W[2]是一个d[ff1] × d[ff2]大小的矩阵,b[2]是一个d[ff2]大小的向量。该操作得到一个大小为n×d[ff2]的张量。在 TensorFlow 中,我们可以将这些计算再次封装成一个可重用的 Keras 层(见下一个列表)。

列表 5.3 全连接子层

import tensorflow as tf

class FCLayer(layers.Layer):
    def __init__(self, d1, d2):
        super(FCLayer, self).__init__()
        self.d1 = d1                                                       ❶
        self.d2 = d2                                                       ❷

    def build(self, input_shape):
        self.W1 = self.add_weight(                                         ❸
            shape=(input_shape[-1], self.d1), initializer='glorot_uniform',❸
            trainable=True, dtype='float32'                                ❸
        )
        self.b1 = self.add_weight(                                         ❸
            shape=(self.d1,), initializer='glorot_uniform',                ❸
            trainable=True, dtype='float32'                                ❸
        )        
        self.W2 = self.add_weight(                                         ❸
            shape=(input_shape[-1], self.d2), initializer='glorot_uniform',❸
            trainable=True, dtype='float32'                                ❸
        )
        self.b2 = self.add_weight(                                         ❸
            shape=(self.d2,), initializer='glorot_uniform',                ❸
            trainable=True, dtype='float32'                                ❸
        )  

    def call(self, x):
        ff1 = tf.nn.relu(tf.matmul(x,self.W1)+self.b1)                     ❹
        ff2 = tf.matmul(ff1,self.W2)+self.b2                               ❺
        return ff2

❶ 第一个全连接计算的输出维度

❷ 第二个全连接计算的输出维度

❸ 分别定义 W1、b1、W2 和 b2。我们使用 glorot_uniform 作为初始化器。

❹ 计算第一个全连接计算

❺ 计算第二个全连接计算

在这里,你可以使用 tensorflow.keras.layers.Dense()层来实现此功能。然而,我们将使用原始的 TensorFlow 操作进行练习,以熟悉低级 TensorFlow。在这个设置中,我们将改变 FCLayer,如下面的列表所示。

列表 5.4 使用 Keras Dense 层实现的全连接层

import tensorflow as tf
import tensorflow.keras.layers as layers

class FCLayer(layers.Layer):

    def __init__(self, d1, d2):
        super(FCLayer, self).__init__()
        self.dense_layer_1 = layer.Dense(d1, activation='relu')  ❶
        self.dense_layer_2 = layers.Dense(d2)                    ❷

    def call(self, x):
        ff1 = self.dense_layer_1(x)                              ❸
        ff2 = self.dense_layer_2(ff1)                            ❹
        return ff2

❶ 在子类化层的 init 函数中定义第一个全连接层

❷ 定义第二个稠密层。注意我们没有指定激活函数。

❸ 调用第一个稠密层以获取输出

❹ 使用第一个稠密层的输出调用第二个稠密层以获取最终输出

现在你知道了 Transformer 架构中进行的计算以及如何使用 TensorFlow 实现它们。但请记住,原始 Transformer 论文中解释了各种细微的细节,我们还没有讨论。这些细节大多将在后面的章节中讨论。

练习 2

假设你被要求尝试一种新型的多头注意力机制。与其将较小头的输出(大小为 64)连接起来,而是将输出(大小为 512)相加。使用 SelfAttentionLayer 编写 TensorFlow 代码以实现此效果。您可以使用 tf.math.add_n() 函数按元素对张量列表求和。

将所有内容放在一起 5.2.9

让我们将所有这些元素放在一起创建一个 Transformer 网络。首先让我们创建一个编码器层,其中包含一组 SelfAttentionLayer 对象(每个头一个)和一个 FCLayer(请参阅下一个列表)。

列表 5.5 编码器层

import tensorflow as tf

class EncoderLayer(layers.Layer):

    def __init__(self, d, n_heads):
        super(EncoderLayer, self).__init__()
        self.d = d
        self.d_head = int(d/n_heads) 
        self.n_heads = n_heads
        self.attn_heads = [
            SelfAttentionLayer(self.d_head) for i in range(self.n_heads)
        ]                                           ❶
        self.fc_layer = FCLayer(2048, self.d)       ❷

    def call(self, x):
        def compute_multihead_output(x):            ❸
            outputs = [head(x, x, x)[0] for head in self.attn_heads] 
            outputs = tf.concat(outputs, axis=-1)
            return outputs

        h1 = compute_multihead_output(x)            ❹
        y = self.fc_layer(h1)                       ❺

        return y

❶ 创建多个注意力头。每个注意力头具有 d/n_heads 大小的特征维度。

❷ 创建完全连接的层,其中中间层有 2,048 个节点,最终子层有 d 个节点。

❸ 创建一个函数,给定一个输入来计算多头注意力输出。

❹ 使用定义的函数计算多头注意力。

❺ 获取层的最终输出。

在初始化 EncoderLayer 时,EncoderLayer 接受两个参数:d(输出的维度)和 n_heads(注意力头的数量)。然后,在调用层时,传递一个单一的输入 x。首先计算注意力头(SelfAttentionLayer)的关注输出,然后是完全连接层(FCLayer)的输出。这就包装了编码器层的关键点。接下来,我们创建一个解码器层(请参阅下一个列表)。

列表 5.6 解码器层

import tensorflow as tf

class DecoderLayer(layers.Layer):

    def __init__(self, d, n_heads):
        super(DecoderLayer, self).__init__()
        self.d = d
        self.d_head = int(d/n_heads)
        self.dec_attn_heads = [
            SelfAttentionLayer(self.d_head) for i in range(n_heads)
        ]                                                           ❶
        self.attn_heads = [
            SelfAttentionLayer(self.d_head) for i in range(n_heads)
        ]                                                           ❷
        self.fc_layer = FCLayer(2048, self.d)                       ❸

    def call(self, de_x, en_x, mask=None):
        def compute_multihead_output(de_x, en_x, mask=None):        ❹
            outputs = [
                head(en_x, en_x, de_x, mask)[0] for head in 
➥ self.attn_heads]                                                 ❺
            outputs = tf.concat(outputs, axis=-1)
            return outputs

        h1 = compute_multihead_output(de_x, de_x, mask)             ❻
        h2 = compute_multihead_output(h1, en_x)                     ❼
        y = self.fc_layer(h2)                                       ❽
        return y

❶ 创建处理解码器输入的注意力头。

❷ 创建同时处理编码器输出和解码器输入的注意力头。

❸ 最终完全连接的子层

❹ 计算多头注意力的函数。此函数接受三个输入(解码器的先前输出、编码器输出和可选的掩码)。

❺ 每个头将函数的第一个参数作为查询和键,并将函数的第二个参数作为值。

❻ 计算第一个受关注的输出。这仅查看解码器输入。

❼ 计算第二个受关注的输出。这将查看先前的解码器输出和编码器输出。

❽ 通过完全连接的子层将输出计算为层的最终输出。

解码器层与编码器层相比有几个不同之处。它包含两个多头注意力层(一个被屏蔽,一个未被屏蔽)和一个全连接层。首先计算第一个多头注意力层(被屏蔽)的输出。请记住,我们会屏蔽任何超出当前已处理的解码器输入的解码器输入。我们使用解码器输入来计算第一个注意力层的输出。然而,第二层中发生的计算有点棘手。做好准备!第二个注意力层将编码器网络的最后一个被关注的输出作为查询和键;然后,为了计算值,使用第一个注意力层的输出。将这一层看作是一个混合器,它混合了被关注的编码器输出和被关注的解码器输入。

有了这个,我们可以用两个编码器层和两个解码器层创建一个简单的 Transformer 模型)。我们将使用 Keras 函数式 API(见下一个列表)。

列表 5.7 完整的 Transformer 模型

import tensorflow as tf

n_steps = 25                                                              ❶
n_en_vocab = 300                                                          ❶
n_de_vocab = 400                                                          ❶
n_heads = 8                                                               ❶
d = 512                                                                   ❶
mask = 1 - tf.linalg.band_part(tf.ones((n_steps, n_steps)), -1, 0)        ❷

en_inp = layers.Input(shape=(n_steps,))                                   ❸
en_emb = layers.Embedding(n_en_vocab, 512, input_length=n_steps)(en_inp)  ❹
en_out1 = EncoderLayer(d, n_heads)(en_emb)                                ❺
en_out2 = EncoderLayer(d, n_heads)(en_out1)

de_inp = layers.Input(shape=(n_steps,))                                   ❻
de_emb = layers.Embedding(n_de_vocab, 512, input_length=n_steps)(de_inp)  ❼
de_out1 = DecoderLayer(d, n_heads)(de_emb, en_out2, mask)                 ❽
de_out2 = DecoderLayer(d, n_heads)(de_out1, en_out2, mask)
de_pred = layers.Dense(n_de_vocab, activation='softmax')(de_out2)         ❾

transformer = models.Model(
    inputs=[en_inp, de_inp], outputs=de_pred, name='MinTransformer'       ❿
)
transformer.compile(
    loss='categorical_crossentropy', optimizer='adam', metrics=['acc']
)

❶ Transformer 模型的超参数

❷ 用于屏蔽解码器输入的掩码

❸ 编码器的输入层。它接受一个批量的单词 ID 序列。

❹ 嵌入层将查找单词 ID 并返回该 ID 的嵌入向量。

❺ 计算第一个编码器层的输出。

❻ 解码器的输入层。它接受一个批量的单词 ID 序列。

❼ 解码器的嵌入层

❽ 计算第一个解码器层的输出。

❾ 预测正确输出序列的最终预测层

❿ 定义模型。注意我们为模型提供了一个名称。

在深入细节之前,让我们回顾一下 Transformer 架构的外观(图 5.10)。

05-10

图 5.10 Transformer 模型架构

由于我们已经相当深入地探讨了底层元素,因此网络应该非常易于理解。我们所要做的就是设置编码器模型,设置解码器模型,并通过创建一个 Model 对象来适当地组合这些内容。最初我们定义了几个超参数。我们的模型接受长度为 n_steps 的句子。这意味着如果给定句子的长度小于 n_steps,则我们将填充一个特殊的标记使其长度为 n_steps。如果给定句子的长度大于 n_steps,则我们将截断句子至 n_steps 个词。n_steps 值越大,句子中保留的信息就越多,但模型消耗的内存也越多。接下来,我们有编码器输入的词汇表大小(即,馈送给编码器的数据集中唯一单词的数量)(n_en_vocab)、解码器输入的词汇表大小(n_de_vocab)、头数(n_heads)和输出维度(d)。

有了这个,我们定义了编码器输入层,它接受一个批次的 n_steps 长句子。在这些句子中,每个词都将由一个唯一的 ID 表示。例如,句子“The cat sat on the mat”将被转换为[1, 2, 3, 4, 1, 5]。接下来,我们有一个称为嵌入(Embedding)的特殊层,它为每个词提供了一个 d 元素长的表示(即,词向量)。在这个转换之后,您将得到一个大小为(批量大小,n_steps,d)的输出,这是应该进入自注意力层的输出格式。我们在第三章(第 3.4.3 节)中简要讨论了这种转换。嵌入层本质上是一个查找表。给定一个唯一的 ID(每个 ID 代表一个词),它会给出一个 d 元素长的向量。换句话说,这一层封装了一个大小为(词汇量大小,d)的大矩阵。当定义嵌入层时,您可以看到:

layers.Embedding(n_en_vocab, 512, input_length=n_steps)

我们需要提供词汇量大小(第一个参数)和输出维度(第二个参数),最后,由于我们正在处理长度为 n_steps 的输入序列,我们需要指定 input_length 参数。有了这个,我们就可以将嵌入层的输出(en_emb)传递给一个编码器层。您可以看到我们的模型中有两个编码器层。

下一步,转向解码器,从高层面看,一切都与编码器相同,除了两个不同之处:

  • 解码器层将编码器输出(en_out2)和解码器输入(de_emb 或 de_out1)作为输入。

  • 解码器层还有一个最终的稠密层,用于生成正确的输出序列(例如,在机器翻译任务中,这些将是每个时间步长的翻译词的概率)。

您现在可以定义和编译模型为

transformer = models.Model(
    inputs=[en_inp, de_inp], outputs=de_pred, name=’MinTransformer’
)
transformer.compile(
    loss='categorical_crossentropy', optimizer='adam', metrics=['acc']
)

请注意,在定义模型时,我们可以为其提供一个名称。我们将我们的模型命名为“MinTransformer”。作为最后一步,让我们看一下模型摘要,

transformer.summary()

这将提供以下输出:

Model: "MinTransformer"
_____________________________________________________________________________________________
Layer (type)                    Output Shape         Param #     Connected to                
=============================================================================================
input_1 (InputLayer)            [(None, 25)]         0                                       
_____________________________________________________________________________________________
embedding (Embedding)           (None, 25, 512)      153600      input_1[0][0]               
_____________________________________________________________________________________________
input_2 (InputLayer)            [(None, 25)]         0                                       
_____________________________________________________________________________________________
encoder_layer (EncoderLayer)    (None, 25, 512)      2886144     embedding[0][0]             
_____________________________________________________________________________________________
embedding_1 (Embedding)         (None, 25, 512)      204800      input_2[0][0]               
_____________________________________________________________________________________________
encoder_layer_1 (EncoderLayer)  (None, 25, 512)      2886144     encoder_layer[0][0]         
_____________________________________________________________________________________________
decoder_layer (DecoderLayer)    (None, 25, 512)      3672576     embedding_1[0][0]           
                                                                 encoder_layer_1[0][0]       
_____________________________________________________________________________________________
decoder_layer_1 (DecoderLayer)  (None, 25, 512)      3672576     decoder_layer[0][0]         
                                                                 encoder_layer_1[0][0]       
_____________________________________________________________________________________________
dense (Dense)                   (None, 25, 400)      205200      decoder_layer_1[0][0]       
=============================================================================================
Total params: 13,681,040
Trainable params: 13,681,040
Non-trainable params: 0
_____________________________________________________________________________________________

工作坊参与者将高高兴兴地离开这个工作坊。您已经介绍了 Transformer 网络的基本要点,同时教导参与者实现自己的网络。我们首先解释了 Transformer 具有编码器-解码器架构。然后,我们看了编码器和解码器的组成,它们由自注意力层和全连接层组成。自注意力层允许模型在处理给定输入词时关注其他输入词,这在处理自然语言时非常重要。我们还看到,在实践中,模型在单个注意力层中使用多个注意力头以提高性能。接下来,全连接层创建了所关注输出的非线性表示。在理解基本要素之后,我们使用我们为自注意力层(SelfAttentionLayer)和全连接层(FCLayer)创建的可重用自定义层实现了一个基本的小规模 Transformer 网络。

下一步是在 NLP 数据集上训练这个模型(例如机器翻译)。然而,训练这些模型是一个单独章节的主题。 Transformers 比我们讨论的还要复杂得多。例如,有预训练的基于 Transformer 的模型,你可以随时使用它们来解决 NLP 任务。我们将在后面的章节再次讨论 Transformers。

总结

  • Transformer 网络在几乎所有 NLP 任务中都表现优于其他模型。

  • Transformer 是一种主要用于学习 NLP 任务的编码器 - 解码器型神经网络。

  • 使用 Transformer,编码器和解码器由两个计算子层组成:自我注意层和完全连接层。

  • 自我注意层根据处理当前位置时,与序列中其他位置之间的相对重要性产生一个给定时间步长的输入的加权和。

  • 完全连接层对自我注意层产生的注意输出进行了非线性表示。

  • 解码器在其自我注意层中使用掩码,以确保在产生当前预测时,解码器不会看到任何未来的预测。

练习答案

练习 1

Wq = tf.Variable(np.random.normal(size=(256,512)))
Wk = tf.Variable (np.random.normal(size=(256,512)))
Wv = tf.Variable (np.random.normal(size=(256,512)))

练习 2

multi_attn_head = [SelfAttentionLayer(512) for i in range(8)]
outputs = [head(x)[0] for head in multi_attn_head]
outputs = tf.math.add_n(outputs)

第二部分:瞧,无需双手!深度网络在现实世界中

一个精通机器学习的从业者是一个多面手。他们不仅需要对现代深度学习框架如 TensorFlow 有很好的理解,还需要能够熟练运用其提供的复杂 API 来实现复杂的深度学习模型,以解决计算机视觉和自然语言处理等领域常见的一些机器学习问题。

在第二部分,我们将看一下计算机视觉和自然语言处理中的真实世界问题。首先,我们来看图像分类和图像分割,这是两个流行的计算机视觉任务。对于这些任务,我们分析了在给定问题上表现良好的现代复杂深度学习模型。我们不仅会从头开始实现这些模型,还会理解核心设计决策背后的推理和它们带来的优势。

接下来,我们转向自然语言处理。我们首先看一下情感分析任务以及深度学习如何解决它。我们还探讨解决方案的各个角落,例如基本的 NLP 预处理步骤以及使用词向量来提升性能。然后,我们看一下语言建模:这是一个预训练任务,为现代 NLP 模型带来了巨大的语言理解能力。在这次讨论中,我们再次探讨了语言建模中融入的各种技术,以提高预测质量。

第六章:教机器看图像分类和 CNN

本章涵盖内容

  • 在 Python 中对图像数据进行探索性数据分析

  • 预处理和通过图像流水线提供数据

  • 使用 Keras 功能 API 实现复杂的 CNN 模型

  • 训练和评估 CNN 模型

我们已经对 CNN 做了相当多的工作。CNN 是一种可以处理二维数据(如图像)的网络类型。CNN 使用卷积操作通过在图像上移动一个核(即一个小的值网格)来创建图像(即像素的网格)的特征图,从而产生新的值。CNN 具有多个这样的层,随着它们的深入,它们生成越来越高级的特征图。您还可以在卷积层之间使用最大或平均汇聚层来减少特征图的维数。汇聚层也会在特征图上移动核以创建输入的较小表示。最终的特征图连接到一系列完全连接的层,其中最后一层产生预测结果(例如,图像属于某个类别的概率)。

我们使用 Keras Sequential API 实现了 CNN。我们使用了各种 Keras 层,如 Conv2D、MaxPool2D 和 Dense,以便轻松地实现 CNN。我们已经学习了与 Conv2D 和 MaxPool2D 层相关的各种参数,如窗口大小、步幅和填充方式。

在本章中,我们将更接近地看到卷积神经网络(CNN)在解决令人兴奋的问题时在真实世界数据上的表现。机器学习不仅仅是实现一个简单的 CNN 来学习高度策划的数据集,因为真实世界的数据往往是杂乱无序的。您将学习到探索性数据分析,这是机器学习生命周期的核心。您将探索一个图像数据集,目标是识别图像中的对象(称为图像分类)。然后,我们将深入研究计算机视觉领域的一个最先进的模型,即 inception 模型。在深度学习中,广泛认可的神经网络架构(或模板)在特定任务上表现良好。inception 模型是一种在图像数据上表现出色的模型之一。我们将研究模型的架构以及其中使用的几个新颖设计概念的动机。最后,我们将训练在我们探索过的数据集上的模型,并依靠准确性等指标分析模型的性能。

我们走了很长一段路。我们理解了那里存在的主要深度学习算法的技术方面,并且对我们正确执行探索性数据分析的能力充满信心,因此以信心进入模型阶段。然而,深度网络很快就会变得非常庞大。复杂的网络会牵扯到各种计算和性能问题。因此,任何希望在实际问题中使用这些算法的人都需要学习那些在复杂学习任务中已被证明执行良好的现有模型。

6.1 将数据置于显微镜下:探索性数据分析

你正在与一组数据科学家合作构建一个多才多艺的图像分类模型。最终目标是将此模型用作智能购物助手的一部分。用户可以上传家里内部的照片,助手将根据他们的风格找到合适的产品。团队决定从图像分类模型开始。你需要回到团队,拿到一个很棒的数据集并解释数据的样子以及为什么这个数据集很棒。数据集包含在现实世界中拍摄的日常物品,你将进行探索性数据分析并查看数据集的各种属性(例如,可用类别,数据集大小,图像属性)来了解数据,并识别和解决潜在问题。

探索性数据分析(EDA)是数据科学项目中你将要做的技术发展的基石。该过程的主要目标是通过消除离群值和噪音等烦人问题,最终获得高质量干净的数据集。为了拥有这样的数据集,你需要仔细审查数据,并找出是否存在

  • 类别不平衡(在分类问题中)

  • 损坏的数据

  • 缺失的特征

  • 离群值

  • 需要各种转换的特征(例如,标准化,独热编码)

这绝不是一份详尽的需要注意的事项清单。你进行的探索越多,数据质量就会越好。

在进行探索性数据分析之前发生了什么?

机器学习问题总是源于业务问题。一旦问题得到适当的确认和理解,你可以开始考虑数据:我们有什么数据?我们训练模型来预测什么?这些预测如何转化为为公司带来好处的可操作见解?在勾选这些问题之后,你可以通过探索性数据分析来检索并开始处理数据。毕竟,机器学习项目中的每一步都需要有目的性地完成。

你已经花了几天时间研究,找到了一个适合你问题的数据集。为了开发一个能够理解客户风格偏好的智能购物助手,它应该能够从客户上传的照片中识别尽可能多的家居物品。为此,你计划使用 tiny-imagenet-200 (www.kaggle.com/c/tiny-imagenet)数据集。

ImageNet 数据集

Tiny ImageNet 是原始 ImageNet 数据集(www.kaggle.com/competitions/imagenet-object-localization-challenge)的一个规模较小的重制版,它是年度 ImageNet 大规模视觉识别挑战(ILSVRC)的一部分。每年,全球各地的研究团队竞争开发最先进的图像分类和检测模型。这个数据集拥有大约 1.2 百万张标记的图像,分布在 1,000 个类别中,已成为计算机视觉领域最大的标记图像数据集之一。

这个数据集包含属于 200 个不同类别的图像。图 6.1 展示了一些可用类别的图像。

06-01

图 6.1 tiny-imagenet-200 的一些样本图像。你可以看到这些图像属于各种不同的类别。

首先,我们需要下载数据集。下面的代码将在你的工作目录中创建一个名为 data 的文件夹,下载包含数据的 zip 文件,并为你解压缩。最终,你应该在 data 文件夹中有一个名为 tiny-imagenet-200 的文件夹:

import os
import requests
import zipfile
if not os.path.exists(os.path.join('data','tiny-imagenet-200.zip')):
    url = "http:/ /cs231n.stanford.edu/tiny-imagenet-200.zip"
    r = requests.get(url)

    if not os.path.exists('data'):
        os.mkdir('data')

    with open(os.path.join('data','tiny-imagenet-200.zip'), 'wb') as f:
        f.write(r.content)

    with zipfile.ZipFile(
        os.path.join('data','tiny-imagenet-200.zip'), 'r'
    ) as zip_ref:
        zip_ref.extractall('data')
else:
    print("The file already exists.")

6.1.1 文件夹/文件结构

数据现在应该在 Ch06/data 文件夹中可用了。现在是时候探索数据集了。我们将首先手动浏览提供给我们的文件夹中的数据。你会注意到有三个文件夹和两个文件(图 6.2)。四处看看并探索一下。

06-02

图 6.2 tiny-imagenet-200 数据集中找到的文件夹和文件

文件 wnids.txt 包含一组 200 个 ID(称为wnids或 WordNet IDs,基于词汇数据库 WordNet [wordnet.princeton.edu/]; 图 6.3)。每个 ID 代表一个图像类别(例如,金鱼类)。

06-03

图 6.3 来自 wnids.txt 的示例内容。每行包含一个 wnid(WordNet ID)。

文件 words.txt 以制表符分隔值(TSV)格式提供了对这些 ID 的人性化描述(表 6.1)。请注意,这个文件包含超过 82,000 行(远超过我们的 200 个类别)并来自一个更大的数据集。

表 6.1 来自 words.txt 的示例内容。其中包含数据集中的 wnids 以及它们的描述。

n00001740 entity
n00001930 physical entity
n00002137 抽象,抽象实体
n00002452 东西
n00002684 物体,实物
n00003553 整体,单位
n00003993 同种异体
n00004258 生物,有机物
n00004475 有机体,存在
n00005787 底栖生物
n00005930 矮人
n00006024 异养生物
n00006150 父母
n00006269 生命
n00006400 生物体

训练文件夹包含训练数据。它包含一个名为 images 的子文件夹,在其中,您可以找到 200 个文件夹,每个都有一个标签(即 wnid)。在每个这些子文件夹中,您将找到代表该类别的一系列图像。每个以其名称作为 wnid 的子文件夹包含每类 500 张图像,总共有 100,000 张(在所有子文件夹中)。图 6.4 描述了这种结构,以及训练文件夹中找到的一些数据。

06-04

图 6.4 tiny-imagenet-200 数据集的总体结构。它有三个文本文件(wnids.txt、words.txt 和 val/val_annotations.txt)和三个文件夹(train、val 和 test)。我们只使用 train 和 val 文件夹。

val 文件夹包含一个名为 images 的子文件夹和一组图像(这些图像不像在 train 文件夹中那样被进一步分成子文件夹)。这些图像的标签(或 wnids)可以在 val 文件夹中的 val_annotations.txt 文件中找到。

最后一个文件夹称为测试文件夹,在本章中我们将忽略它。该数据集是竞赛的一部分,数据用于评分提交的模型。我们没有这个测试集的标签。

6.1.2 理解数据集中的类别

我们已经了解了我们拥有的数据的类型以及其可用性。接下来,让我们识别一些数据中的类别。为此,我们将定义一个名为 get_tiny_imagenet_classes() 的函数,该函数读取 wnids.txt 和 words.txt 文件,并创建一个包含两列的 pd.DataFrame(即 pandas DataFrame):wnid 及其相应的类别描述(见下一个列表)。

列表 6.1 获取数据集中类别的类别描述

import pandas as pd                                                      ❶
import os                                                                ❶

data_dir = os.path.join('data', 'tiny-imagenet-200')                     ❷
wnids_path = os.path.join(data_dir, 'wnids.txt')                         ❷
words_path = os.path.join(data_dir, 'words.txt')                         ❷

def get_tiny_imagenet_classes(wnids_path, words_path):                   ❸
    wnids = pd.read_csv(wnids_path, header=None, squeeze=True)           ❹
    words = pd.read_csv(words_path, sep='\t', index_col=0, header=None)  ❹
    words_200 = words.loc[wnids].rename({1:'class'}, axis=1)             ❺
    words_200.index.name = 'wnid'                                        ❻
    return words_200.reset_index()                                       ❼

labels = get_tiny_imagenet_classes(wnids_path, words_path)               ❽
labels.head(n=25)                                                        ❾

❶ 导入 pandas 和 os 包

❷ 定义数据目录、wnids.txt 和 words.txt 文件的路径

❸ 定义一个函数来读取 tiny_imagenet 类别的类别描述

❹ 使用 pandas 读取 wnids.txt 和 words.txt 作为 CSV 文件

❺ 仅获取 tiny-imagenet-200 数据集中存在的类别

❻ 将数据框的索引名称设置为“wnid”

❼ 重置索引,使其成为数据框中的一列(该列的列名为“wnid”)

❽ 执行函数以获取类别描述

❾ 检查数据框的头部(前 25 个条目)

此函数首先读取包含 wnids 列表的 wnids.txt 文件,该列表对应于数据集中可用的类别,作为 pd.Series(即 pandas series)对象。 接下来,它将 words.txt 文件读取为 pd.DataFrame(即 pandas DataFrame),其中包含 wnid 到类别描述的映射,并将其分配给 words。 然后,它选择在 wnids pandas 系列中存在 wnid 的项目。 这将返回一个包含 200 行的 pd.DataFrame(表 6.2)。 请记住,words.txt 中的项数远远大于实际数据集,因此我们只需要选择与我们相关的项。

表 6.2 使用 get_tiny_imagenet_classes()函数生成的标签 ID 及其描述的示例

课程
0 n02124075 埃及猫
1 n04067472 卷轴
2 n04540053 排球
3 n04099969 摇椅,摇椅
4 n07749582 柠檬
5 n01641577 牛蛙,美洲牛蛙
6 n02802426 篮球
7 n09246464 悬崖,跌落,坠落
8 n07920052 浓缩咖啡
9 n03970156 吸盘,管道工的助手
10 n03891332 停车计时器
11 n02106662 德国牧羊犬,德国牧羊犬,德国牧羊犬...
12 n03201208 餐桌,板
13 n02279972 帝王蝴蝶,帝王蝴蝶,小米蝴蝶
14 n02132136 棕熊,棕熊,北极熊
15 n041146614 校车

然后我们将计算每个类别的数据点(即图像)的数量:

def get_image_count(data_dir):    
    # Get the count of JPEG files in a given folder (data_dir)
    return len(
        [f for f in os.listdir(data_dir) if f.lower().endswith('jpeg')]
    )

   # Apply the function above to all the subdirectories in the train folder 
labels["n_train"] = labels["wnid"].apply(
    lambda x: get_image_count(os.path.join(data_dir, 'train', x, 'images'))
)
# Get the top 10 entries in the labels dataframe
labels.head(n=10)

此代码创建一个名为 n_train 的新列,显示每个 wnid 找到了多少个数据点(即图像)。 这可以通过 pandas pd.Series .apply()函数来实现,该函数将 get_image_count()应用于系列 labels[“wnid”]中的每个项目。 具体来说,get_image_count()接受一个路径并返回该文件夹中找到的 JPEG 文件的数量。 当您将此 get_image_count()函数与 pd.Series.apply()结合使用时,它会进入 train 文件夹中的每个文件夹,并计算图像的数量。 一旦运行了标签.head(n=10)行,您应该会得到表 6.3 中显示的结果。

表 6.3 计算了 n_train(训练样本数)的数据示例

课程 n_train
0 n02124075 埃及猫 500
1 n04067472 卷轴 500
2 n04540053 排球 500
3 n04099969 摇椅,摇椅 500
4 n07749582 柠檬 500
5 n01641577 牛蛙,美洲牛蛙 500
6 n02802426 篮球 500
7 n09246464 悬崖,跌落,坠落 500
8 n07920052 浓缩咖啡 500
9 n03970156 吸盘,管道工的助手 500

让我们快速验证结果是否正确。 进入 train 文件夹中的 n02802426 子目录,其中应该包含篮球的图像。 图 6.5 显示了几个示例图像。

06-05

图 6.5 wnid 类别 n02802426(即篮球)的样本图像

你可能会发现这些图像与你预期的截然不同。你可能期望看到清晰放大的篮球图像。但在现实世界中,永远不会出现这种情况。真实数据集是有噪声的。你可以看到以下图像:

  • 篮球几乎看不见(左上角)。

  • 篮球是绿色的(左下角)。

  • 篮球在婴儿旁边(即上下文无关)(中间上方)。

这会让你更加欣赏深度网络,因为这对一堆堆叠的矩阵乘法(即深度网络)是一个困难的问题。需要精确的场景理解才能成功解决此任务。尽管困难,但奖励很大。我们开发的模型最终将用于识别各种背景和上下文中的物体,例如客厅、厨房和室外。这正是这个数据集为模型训练的目的:在各种情境中理解/检测物体。你可能可以想象为什么现代 CAPTCHA 越来越聪明,并且可以跟上能够更准确地分类对象的算法。对于受过适当训练的 CNN 来说,识别具有混乱背景或小遮挡的 CAPTCHA 并不困难。

你还可以快速检查我们生成的 n_train 列的摘要统计数据(例如,平均值、标准差等)。这提供了比查看所有 200 行更容易消化的列的摘要。这是使用 pandas 描述() 函数完成的:

labels["n_train"].describe()

执行此操作将返回以下系列:

count    200.0
mean     500.0
std        0.0
min      500.0
25%      500.0
50%      500.0
75%      500.0
max      500.0
Name: n_train, dtype: float64

你可以看到它返回了列的重要统计信息,如平均值、标准差、最小值和最大值。每个类别都有 500 张图像,这意味着数据集完美地平衡了类别。这是验证我们有一个类平衡数据集的有用方法。

6.1.3 计算数据集上的简单统计量

分析数据的各种属性也是一个重要步骤。根据你处理的数据类型,分析类型会发生变化。在这里,我们将找出图像的平均大小(甚至是 25/50/75 百分位数)。

在实际模型中准备好这些信息可以节省很多时间,因为你必须了解图像大小(高度和宽度)的基本统计信息,以裁剪或填充图像到固定大小,因为图像分类 CNN 只能处理固定大小的图像(见下一个列表)。

列表 6.2 计算图像宽度和高度统计数据

import os                                                                 ❶
from PIL import Image                                                     ❶
import pandas as pd                                                       ❶

image_sizes = []                                                          ❷
for wnid in labels["wnid"].iloc[:25]:                                     ❸
    img_dir = os.path.join(
        'data', 'tiny-imagenet-200', 'train', wnid, 'images'
    )                                                                     ❹
    for f in os.listdir(img_dir):                                         ❺
        if f.endswith('JPEG'):                                            ❺
            image_sizes.append(Image.open(os.path.join(img_dir, f)).size) ❻

img_df = pd.DataFrame.from_records(image_sizes)                           ❼
img_df.columns = ["width", "height"]                                      ❽
img_df.describe()                                                         ❾

❶ 导入 os、PIL 和 pandas 包

❷ 定义一个列表来保存图像大小

❸ 在数据集中循环前 25 类

❹ 在循环中为特定类别定义图像目录

❺ 在该目录中循环所有具有扩展名 JPEG 的图像

❻ 将每个图像的大小(即 (宽度、高度) 元组)添加到 image_sizes 中

❼ 从 image_sizes 中的元组创建数据框架

❽ 适当设置列名

❾ 获取我们获取的图像的宽度和高度的摘要统计信息

在这里,我们从之前创建的标签 DataFrame 中获取前 25 个 wnid(处理所有 wnid 会花费太多时间)。然后,对于每个 wnid,我们进入包含属于它的数据的子文件夹,并使用以下方法获取每个图像的宽度和高度信息

Image.open(os.path.join(img_dir, f)).size 

使用Image.open(<path>).size函数返回给定图像的元组(宽度,高度)。我们将遇到的所有图像的宽度和高度记录在image_sizes列表中。最后,image_sizes列表如下所示:

image_sizes = [(image_1.width, image_1.height), (image_2.width, image_2.height), ..., (image_n.width, image_n.height)]

对于这种格式的数据,我们可以使用pd.DataFrame.from_records()函数将此列表创建为pd.DataFrameimage_sizes中的单个元素是一条记录。例如,(image_1.width, image_1.height)是一条记录。因此,image_sizes是一组记录的列表。当您从记录列表创建pd.DataFrame时,每条记录都变为pandas DataFrame中的一行,其中每条记录中的每个元素都变为列。例如,由于每条记录中都有图像宽度和图像高度作为元素,因此宽度和高度成为pandas DataFrame中的列。最后,我们执行img_df.describe()以获取我们读取的图像的宽度和高度的基本统计信息(表 6.4)。

表 6.4 图像的宽度和高度统计信息

宽度 高度
count 12500.0 12500.0
mean 64.0 64.0
std 0.0 0.0
min 64.0 64.0
25% 64.0 64.0
50% 64.0 64.0
75% 64.0 64.0
max 64.0 64.0

接下来,我们将讨论如何创建数据管道来摄取我们刚刚讨论的图像数据。

练习 1

假设在浏览数据集时,您遇到了一些损坏的图像(即,它们具有负值像素)。假设您已经有了一个名为dfpd.DataFrame(),其中包含一个带有图像文件路径的单列(称为filepath),请使用pandas apply()函数读取每个图像的最小值,并将其分配给名为minimum的列。要读取图像,您可以假设已完成from PIL import Imageimport numpy as np,您还可以使用np.array(<Image>)PIL.Image转换为数组。

6.2 使用 Keras ImageDataGenerator 创建数据管道

您已经很好地探索了数据集,并了解了诸如有多少类别、存在什么样的对象以及图像的大小等信息。现在,您将为三个不同的数据集创建三个数据生成器:训练、验证和测试。这些数据生成器以批量从磁盘中检索数据,并执行任何所需的预处理。这样,数据就可以被模型轻松消耗。为此,我们将使用方便的tensorflow.keras.preprocessing.image.ImageDataGenerator

我们将从定义一个 Keras ImageDataGenerator()开始,以在构建模型时提供数据:

from tensorflow.keras.preprocessing.image import ImageDataGenerator
import os

random_seed = 4321
batch_size = 128
image_gen = ImageDataGenerator(samplewise_center=True, validation_split=0.1)

设置 samplewise_center=True,生成的图像将具有归一化的值。每个图像将通过减去该图像的平均像素值来居中。validation_split 参数在训练数据中扮演着重要的角色。这让我们将训练数据分成两个子集,训练集和验证集,通过从训练数据中分离出一部分(在本例中为 10%)。在机器学习问题中,通常应该有三个数据集:

  • 训练数据—通常是最大的数据集。我们用它来训练模型。

  • 验证数据—保留数据集。它不用于训练模型,而是用于在训练过程中监视模型的性能。请注意,此验证集在训练过程中必须保持固定(不应更改)。

  • 测试数据—保留数据集。与验证数据集不同,这仅在模型训练完成后使用。这表示模型在未见的真实世界数据上的表现。这是因为模型在测试时间之前没有以任何方式与测试数据集交互(与训练和验证数据集不同)。

我们还将为稍后的数据生成定义一个随机种子和批量大小。

创建一个 ImageDataGenerator 后,您可以使用其中的一个 flow 函数来读取来自异构源的数据。例如,Keras 目前提供了以下方法:

  • flow()—从 NumPy 数组或 pandas DataFrame 中读取数据

  • flow_from_dataframe()—从包含文件名和它们关联标签的文件中读取数据

  • flow_from_directory()—从文件夹中读取数据,该文件夹中的图像根据它们所属的类别组织到子文件夹中。

首先,我们将查看 flow_from_directory(),因为我们的训练目录以 flow_from_directory()函数期望数据的确切格式存储。具体来说,flow_from_directory()期望数据的格式如图 6.6 所示。

06-06

图 6.6 流从目录方法所预期的文件夹结构

流方法返回数据生成器,这些生成器是 Python 生成器。生成器本质上是一个返回迭代器(称为generator-iterator)的函数。但为了保持我们的讨论简单,我们将生成器和迭代器都称为生成器。您可以像处理列表一样迭代生成器,并以顺序方式返回项目。这里是一个生成器的例子:

def simple_generator():
    for i in range(0, 100):
        yield (i, i*2)

请注意使用关键字 yield,您可以将其视为 return 关键字。但是,与 return 不同,yield 不会在执行该行后立即退出函数。现在您可以将迭代器定义为

iterator = simple_generator()

您可以将迭代器视为包含[(0, 0), (1, 2), (2, 4), ...,(98, 196), (99, 198)]的列表。然而,在幕后,生成器比列表对象更节省内存。在我们的情况下,数据生成器将在单次迭代中返回一批图像和目标(即,图像和标签的元组)。您可以直接将这些生成器提供给像tf.keras.models.Model.fit()这样的方法,以训练模型。flow_from_directory()方法用于检索数据:

target_size = (56,56)

train_gen = image_gen.flow_from_directory(
    directory=os.path.join('data','tiny-imagenet-200', 'train'), 
    target_size=target_size, classes=None,
    class_mode='categorical', batch_size=batch_size, 
    shuffle=True, seed=random_seed, subset='training'
)
valid_gen = image_gen.flow_from_directory (
    directory=os.path.join('data','tiny-imagenet-200', 'train'), 
    target_size=target_size, classes=None,
    class_mode='categorical', batch_size=batch_size, 
    shuffle=True, seed=random_seed, subset='validation'
)

您可以看到已为这些函数设置了许多参数。需要注意的最重要的参数是subset参数,对于train_gen设置为“training”,对于valid_gen设置为“validation”。其他参数如下:

  • 目录(string)—父目录的位置,在这里数据进一步分成表示类别的子文件夹。

  • 目标大小(int 元组)—图像的目标大小,表示为(高度,宽度)的元组。图像将被调整为指定的高度和宽度。

  • 类别模式(string)—我们将要提供给模型的目标类型。因为我们希望目标是表示每个类别的独热编码向量,所以我们将其设置为'categorical'。可用类型包括“categorical”(默认值)、“binary”(对于只有两类(0 或 1)的数据集)、“sparse”(数值标签而不是独热编码向量)、“input”或 None(没有标签)、以及“raw”或“multi_output”(仅在特殊情况下可用)。

  • 批量大小(int)—单个数据批次的大小。

  • 是否在获取时对数据进行洗牌(bool)—是否在获取时对数据进行洗牌。

  • 随机种子(int)—数据洗牌的随机种子,因此我们每次运行时都能获得一致的结果。

  • 子集(string)—如果validation_split > 0,则需要哪个子集。这需要设置为“training”或“validation”之一。

请注意,即使我们有 64 × 64 的图像,我们也将它们调整为 56 × 56。这是因为我们将使用的模型设计用于 224 × 224 的图像。具有 224 × 224 尺寸的图像使得将模型适应我们的数据变得更加容易。

我们可以让我们的解决方案变得更加闪亮!您可以看到,在train_genvalid_gen之间,使用的参数有很多重复。实际上,除了subset之外,所有参数都相同。这种重复会使代码变得凌乱,并为错误留下余地(如果需要更改参数,则可能会设置一个而忘记另一个)。您可以在 Python 中使用偏函数来创建具有重复参数的偏函数,然后使用它来创建train_genvalid_gen

from functools import partial
target_size = (56,56)

partial_flow_func = partial(
        image_gen.flow_from_directory, 
        directory=os.path.join('data','tiny-imagenet-200', 'train'), 
        target_size=target_size, classes=None,
        class_mode='categorical', batch_size=batch_size, 
        shuffle=True, seed=random_seed)

train_gen = partial_flow_func(subset='training')
valid_gen = partial_flow_func(subset='validation')

这里,我们首先创建一个partial_flow_function(一个 Python 函数),它实质上是flow_from_directory函数,有一些参数已经填充。然后,为了创建train_genvalid_gen,我们只传递了subset参数。这样可以使代码更加清晰。

验证数据检查:不要期望框架为您处理事务

现在我们有了一个训练数据生成器和一个验证数据生成器,我们不应该盲目地承诺使用它们。我们必须确保我们从训练数据随机采样的验证数据在每次遍历训练数据集时保持一致。这似乎是一个应该由框架本身处理的微不足道的事情,但最好不要认为这是理所当然的。如果你这样做

如果不执行此检查,最终你会付出代价,因此最好确保我们在不同试验中获得一致的结果。

为此,你可以对验证数据生成器的输出进行多次迭代,进行固定次数的迭代,并确保每次试验中都获得相同的标签序列。此代码在笔记本中可用(在“验证验证数据的一致性”部分下)。

我们还没有完成。我们需要对 flow_from_directory() 函数返回的生成器进行轻微修改。如果你查看数据生成器中的项,你会看到它是一个元组(x,y),其中 x 是一批图像,y 是一批 one-hot 编码的目标。我们在这里使用的模型有一个最终预测层和两个额外的辅助预测层。总共,该模型有三个输出层,因此我们需要返回(x,(y,y,y))而不是一个元组(x,y),通过三次复制 y。我们可以通过定义一个新的生成器 data_gen_aux() 来修复这个问题,该生成器接受现有的生成器并修改其输出,如所示。这需要对训练数据生成器和验证数据生成器都进行修复:

def data_gen_aux(gen):
    for x,y in gen:
        yield x,(y,y,y)

train_gen_aux = data_gen_aux(train_gen)
valid_gen_aux = data_gen_aux(valid_gen)

是时候为测试数据创建一个数据生成器了。回想一下,我们说过我们正在使用的测试数据(即 val 目录)的结构与训练和 tran_val 数据文件夹不同。因此,它需要特殊处理。类标签存储在一个名为 val_annotations.txt 的文件中,并且图像放置在一个具有扁平结构的单个文件夹中。不用担心;Keras 也为这种情况提供了一个函数。在这种情况下,我们将首先使用 get_test_labels_df() 函数将 val_annotations.txt 读取为一个 pd.DataFrame。该函数简单地读取 val_annotations.txt 文件,并创建一个具有两列的 pd.DataFrame,即图像的文件名和类标签:

def get_test_labels_df(test_labels_path):
    test_df = pd.read_csv(test_labels_path, sep='\t', index_col=None, header=None)
    test_df = test_df.iloc[:,[0,1]].rename({0:"filename", 1:"class"}, axis=1)
    return test_df

test_df = get_test_labels_df(os.path.join('data','tiny-imagenet-200',  'val', 'val_annotations.txt'))

接下来,我们将使用 flow_from_dataframe() 函数创建我们的测试数据生成器。你只需要传递我们之前创建的 test_df(作为 dataframe 参数)和指向图像所在目录的目录参数。请注意,我们为测试数据设置了 shuffle=False,因为我们希望以相同的顺序输入测试数据,以便我们监视的性能指标将保持不变,除非我们更改模型:

    test_gen = image_gen.flow_from_dataframe(
        dataframe=test_df, directory=os.path.join('data','tiny-imagenet-
➥ 200',  'val', 'images'), target_size=target_size, 
➥ class_mode='categorical', batch_size=batch_size, shuffle=False
    )

接下来,我们将使用 Keras 定义一个复杂的计算机视觉模型,并最终在我们准备好的数据上对其进行训练。

练习 2

作为测试过程的一部分,假设你想要查看模型对训练数据中损坏标签的鲁棒性如何。为此,你计划创建一个生成器,以 50% 的概率将标签设置为 0。你将如何修改以下生成器以实现此目的?你可以使用 np.random.normal() 从具有零均值和单位方差的正态分布中随机抽取一个值:

def data_gen_corrupt(gen):
    for x,y in gen:
        yield x,(y,y,y)

6.3 Inception net:实现最先进的图像分类器

你已经分析了数据集,并对数据的外观有了全面的了解。对于图像,你无疑会转向卷积神经网络(CNNs),因为它们是业内最好的。现在是构建一个模型来学习客户个人喜好的时候了。在这里,我们将使用 Keras functional API 复制一个最先进的 CNN 模型(称为 Inception net)。

Inception 网络是一个复杂的 CNN,以其提供的最先进性能而著称。Inception 网络的名字来源于流行的互联网梗“我们需要更深入”,该梗以电影 Inception 中的莱昂纳多·迪卡普里奥为特色。

Inception 模型在短时间内推出了六个不同版本(大约在 2015-2016 年之间)。这证明了该模型在计算机视觉研究人员中有多受欢迎。为了纪念过去,我们将实现首个推出的 Inception 模型(即 Inception 网络 v1),并随后将其与其他模型进行比较。由于这是一个高级 CNN,对其架构和一些设计决策的深入了解至关重要。让我们来看看 Inception 模型,它与典型 CNN 有何不同,最重要的是,它为什么不同。

Inception 模型(或 Inception 网络)不是典型的 CNN。它的主要特点是复杂性,因为模型越复杂(即参数越多),准确率就越高。例如,Inception 网络 v1 几乎有 20 层。但是当涉及到复杂模型时,会出现两个主要问题:

  • 如果你没有足够大的数据集用于一个复杂模型,那么很可能模型会对训练数据过拟合,导致在真实世界数据上的整体性能不佳。

  • 复杂的模型导致更多的训练时间和更多的工程努力来将这些模型适配到相对较小的 GPU 内存中。

这要求以更加务实的方式来解决这个问题,比如回答“我们如何在深度模型中引入稀疏性,以减少过拟合风险以及对内存的需求?”这是 Inception 网络模型中回答的主要问题。

什么是过拟合?

过拟合是机器学习中的一个重要概念,而且常常难以避免。过拟合是指模型学习很好地表示训练数据(即高训练精度),但在未见过的数据上表现不佳(即低测试精度)的现象。当模型试图记住训练样本而不是从数据中学习可泛化的特征时,就会发生这种情况。这在深度网络中很普遍,因为它们通常比数据量更多的参数。过拟合将在下一章中更详细地讨论。

让我们再次回顾 CNN 的基础知识。

6.3.1 CNN 回顾

CNN 主要用于处理图像和解决计算机视觉问题(例如图像分类、目标检测等)。如图 6.7 所示,CNN 有三个组成部分:

  • 卷积层

  • 池化层全连接层

06-07

图 6.7 一个简单的卷积神经网络。首先,我们有一个具有高度、宽度和通道维度的图像,然后是一个卷积和一个池化层。最后,最后一个卷积/池化层的输出被展平并馈送到一组全连接层。

卷积操作将一个固定大小的小核(也称为过滤器)沿输入的宽度和高度维度移动。在这样做时,它在每个位置产生一个单一值。卷积操作使用具有一定宽度、高度和若干通道的输入,并产生具有一定宽度、高度和单一通道的输出。为了产生多通道输出,卷积层堆叠许多这些过滤器,导致与过滤器数量相同数量的输出。卷积层具有以下重要参数:

  • 过滤器数量 — 决定卷积层产生的输出的通道深度(或特征图的数量)

  • 核大小 — 也称为感受野,它决定了过滤器的大小(即高度和宽度)。核大小越大,模型在一次观察中看到的图像部分就越多。但更大的过滤器会导致更长的训练时间和更大的内存需求。

  • 步长 — 决定在卷积图像时跳过多少像素。更高的步长导致较小的输出大小(步长通常仅用于高度和宽度维度)。

  • 填充 — 通过添加零值的虚拟边界来防止卷积操作期间自动降低维度,从而使输出具有与输入相同的高度和宽度。

图 6.8 展示了卷积操作的工作原理。

06-08

图 6.8 在卷积操作中移动窗口时发生的计算

当处理输入时,池化操作表现出与卷积操作相同的行为。但是,所涉及的确切计算是不同的。池化有两种不同的类型:最大池化和平均池化。最大池化在图 6.9 中显示的深灰色框中找到的最大值作为窗口移过输入时的输出。平均池化在窗口移过输入时取深灰色框的平均值作为输出。

注意 CNNs 在输出处使用平均池化,并在其他地方使用最大池化层。已发现该配置提供了更好的性能。

06-09

图 6.9 池化操作如何计算输出。它查看一个小窗口,并将该窗口中的输入最大值作为相应单元的输出。

池化操作的好处在于它使得 CNN 具有平移不变性。平移不变性意味着模型可以识别物体,而不管它出现在何处。由于最大池化的计算方式,生成的特征图是相似的,即使对象/特征与模型训练的位置相差几个像素。这意味着,如果你正在训练一个分类狗的模型,网络将对狗出现的确切位置具有弹性(只有在一定程度上)。

最后,你有一个全连接层。由于我们目前主要关注分类模型,我们需要为任何给定的图像输出一个类别的概率分布。我们通过将少量的全连接层连接到 CNNs 的末尾来实现这一点。全连接层将最后的卷积/池化输出作为输入,并在分类问题中生成类别的概率分布。

正如你所见,CNNs 有许多超参数(例如,层数、卷积窗口大小、步幅、全连接隐藏层大小等)。为了获得最佳结果,需要使用超参数优化技术(例如,网格搜索、随机搜索)来选择它们。

6.3.2 Inception 网络 v1

Inception 网络 v1(也称为 GoogLeNet)(mng.bz/R4GD) 将 CNNs 带入了另一个层次。它不是一个典型的 CNN,与标准 CNN 相比,需要更多的实现工作。乍一看,Inception 网络可能看起来有点可怕(见图 6.10)。但是你只需要理解几个新概念,就可以理解这个模型。主要是这些概念的重复应用使模型变得复杂。

06-10

图 6.10 Inception 网络 v1 的抽象架构。Inception 网络从一个称为干扰的起始开始,这是一个在典型 CNN 中找到的普通卷积/池化层序列。然后,Inception 网络引入了一个称为 Inception 块的新组件。最后,Inception 网络还使用了辅助输出层。

让我们首先在宏观层面理解 Inception 模型中的内容,如图 6.10 所示,暂时忽略诸如层和它们的参数之类的细节。我们将在开发出强大的宏观水平理解后详细阐述这些细节。

Inception 网络以称为stem的东西开始。stem 包含与典型 CNN 的卷积和池化层相同的卷积和池化层。换句话说,stem 是按特定顺序组织的卷积和池化层的序列。

接下来,你有几个Inception blocks,这些块被 max pooling 层交错。一个 Inception block 包含一组并行的具有不同核大小的子卷积层。这使得模型能够在给定深度上以不同大小的感受野查看输入。我们将详细研究这背后的细节和动机。

最后,你有一个全连接层,它类似于典型 CNN 中的最终预测层。你还可以看到还有两个更多的临时全连接层。这些被称为辅助输出层。与最终预测层一样,它们由全连接层和 softmax 激活组成,输出数据集中类别的概率分布。尽管它们与最终预测层具有相同的外观,但它们不会对模型的最终输出做出贡献,但在训练过程中起着重要作用,稳定训练变得越来越艰难,因为模型变得越来越深(主要是由于计算机中数值的有限精度)。

让我们从头开始实现原始的 Inception 网络的一个版本。在此过程中,我们将讨论我们遇到的任何新概念。

注意!我们将构建一个略有不同的 Inception 网络 v1。

我们正在实现与原始 Inception 网络 v1 模型略有不同的东西,以应对某种实际限制。原始 Inception 网络设计用于处理尺寸为 224 × 224 × 3 的输入,属于 1,000 个类别,而我们有尺寸为 64 × 64 × 3 的输入,属于 200 个类别,我们将其调整为 56 × 56 × 3,以便其是 224 的因数(即,56 × 4 = 224)。因此,我们将对原始 Inception 网络进行一些修改。如果你愿意,你可以暂时忽略以下细节。但是如果你感兴趣,我们具体进行以下更改:

  • 使前三个具有步长 2 的层(在 stem 中)的步长为 1,以便我们在拥有较小输入图像时享受模型的全部深度。

  • 将最后一个全连接分类层的大小从 1,000 更改为 200,因为我们只有 200 个类别。

  • 移除一些正则化(即,dropout、loss weighting;这些将在下一章重新引入)。

如果你对这里讨论的模型感到舒适,理解原始的 Inception v1 模型将不会有问题。

首先,我们定义一个创建 Inception net v1 干部结构的函数。干部结构是 Inception 网络的前几层,看起来不过是典型卷积/池化层,但有一个新的层(称为 lambda 层),执行一些称为 局部响应归一化(LRN)的功能。我们将在稍后更详细地讨论该层的目的(请参见下一个清单)。

代码清单 6.3 Inception 网络中的干部结构的定义

def stem(inp):
    conv1 = Conv2D(
        64, (7,7), strides=(1,1), activation='relu', padding='same'
    )(inp)                                                                ❶
maxpool2 = MaxPool2D((3,3), strides=(2,2), padding='same')(conv1)         ❷
lrn3 = Lambda(
    lambda x: tf.nn.local_response_normalization(x)
)(maxpool2)                                                               ❸

conv4 = Conv2D(
    64, (1,1), strides=(1,1), padding='same'
)(lrn3)                                                                   ❹
conv5 = Conv2D(
    192, (3,3), strides=(1,1), activation='relu', padding='same'
)(conv4)                                                                  ❹
    lrn6 = Lambda(lambda x: tf.nn.local_response_normalization(x))(conv5) ❺

    maxpool7 = MaxPool2D((3,3), strides=(1,1), padding='same')(lrn6)      ❻

    return maxpool7                                                       ❼

❶ 第一个卷积层的输出

❷ 第一个最大池化层的输出

❸ 第一个局部响应归一化层。我们定义一个封装了 LRN 功能的 lambda 函数。

❹ 后续的卷积层

❺ 第二个 LRN 层

❻ 最大池化层

❼ 返回最终输出(即最大池化层的输出)

到目前为止,这段代码中的大部分应该已经非常熟悉了。它是一系列层,从输入开始生成输出。

Lambda 层(tf.keras.layers.Lambda)

Keras 中的 lambda 层与标准的 Python lambda 函数具有相似的目的。当用标准 lambda 函数编写时,它们封装了一些通常不可用作 Keras 标准层的计算。例如,您可以如下定义一个 Keras 层,该层取轴上的最大值。但是,您只能在 Keras lambda 函数中使用 TensorFlow / Keras 计算:

x = tf.keras.layers.Input(shape=(10,))
max_out = tf.keras.layers.Lambda(lambda x: tf.reduce_max(x, axis=1))(x)

您可能会注意到 lambda 层的作用与 Keras 的子类化 API 几乎相同。是的,但是 lambda 层不需要子类化 API 中所需的代码支架。对于具有复杂操作的图层(例如 if-else 条件,for 循环等),您可能会发现子类化 API 更容易。

具体来说,我们定义了以下层:

  • 一个卷积层

    • 64 个过滤器,(7,7) 卷积核大小,(2,2) 步长,激活 ReLU,相同填充
  • 一个局部响应归一化层

    • 这是通过使用 tf.keras.layers.Lambda 层来指定的。该层为您提供了一种方便的方法,可以定义一个封装了不容易获得的 TensorFlow / Keras 计算的 Keras 层。局部响应归一化是一种归一化给定输入的技术。
  • 第二个卷积层

    • 192 个过滤器,(3,3) 卷积核大小,(2,2) 步长,ReLU 激活,相同填充
  • 一个局部响应归一化层

  • 一个最大池化层

    • (3,3) 卷积核大小,(2,2) 步长以及相同填充

局部响应归一化

局部响应归一化(LRN)是早期的归一化技术,介绍在论文 “ImageNet Classification with Deep CNNs” (mng.bz/EWPr) 中。

这项技术受到了生物系统中表现出的横向抑制(mng.bz/N6PX)的启发。这指的是激活的神经元抑制邻近神经元的活动的现象(例如,在视网膜感受器中观察到)。本质上,LRN 层通过将卷积输出的每个值除以其邻域中的值(邻域由半径参数化,这是该层的超参数)来标准化每个值。这种规范化创建了神经元之间的竞争,并导致略微更好的性能。我们将不讨论涉及此计算的确切方程,因为这种方法已经过时,并且更好、更有前途的正则化技术,如批量标准化,已经取代了它。

更深入地了解 Inception 块

正如前面所述,Inception 网中的主要突破之一是 Inception 块。与具有固定核大小的典型卷积层不同,Inception 块是具有不同核大小的并行卷积层的集合。具体来说,在 Inception v1 中的 Inception 块包含 1 × 1 卷积、3 × 3 卷积、5 × 5 卷积和池化。图 6.11 显示了 Inception 块的架构。

06-11

图 6.11 Inception 块中的计算,本质上是一组具有不同核大小的并行卷积/池化层

让我们了解为什么这些并行卷积层比具有相同核大小的巨型卷积滤波器块更好。主要优势在于 Inception 块与单个卷积块相比具有高度参数效率。我们可以通过一些数字来确保这一点。假设我们有两个卷积块:一个是 Inception 块,一个是标准卷积块。假设 Inception 块具有以下参数:

  • 一个具有 32 个滤波器的 1 × 1 卷积层

  • 一个具有 16 个滤波器的 3 × 3 卷积层

  • 一个具有 16 个滤波器的 5 × 5 卷积层

如果你要设计一个具有 Inception 块表示能力的标准卷积层,你会需要

  • 一个具有 64 个滤波器的 5 × 5 卷积层

假设我们正在处理一个单通道的输入,Inception 块的参数为 576,由以下给出

1 × 1 × 1 × 32 + 3 × 3 × 1 × 16 + 5 × 5 × 1 × 16 = 576

标准卷积块具有 1,600 个参数:

5 × 5 × 1 × 64 = 1,600

换句话说,与具有 Inception 块表示能力的标准卷积层相比,Inception 块减少了 64% 的参数数量。

Inception 块与稀疏性之间的联系

对于好奇的人们,可能还有一个持续存在的问题:Inception 块是如何引入稀疏性的?想象一下以下两种情况,你有三个卷积滤波器。在一个场景中,你有三个 5 × 5 卷积滤波器,而在另一个场景中,你有一个 1 × 1、3 × 3 和 5 × 5 卷积滤波器。图 6.12 展示了这两种情景之间的差异。

06-12

图 6.12 Inception 块如何促进模型的稀疏性。你可以将 1 × 1 卷积看作是一个高度稀疏的 5 × 5 卷积。

不难看出,当你有三个 5 × 5 卷积滤波器时,它会在卷积层和输入之间创建非常密集的连接。然而,当你有一个 1 × 1、3 × 3 和 5 × 5 卷积层时,输入和层之间的连接更加稀疏。另一种思考方式是,1 × 1 卷积本质上是一个 5 × 5 卷积层,其中除了中心元素外,所有元素都关闭了。因此,1 × 1 卷积是一个高度稀疏的 5 × 5 卷积层。类似地,3 × 3 卷积是一个稀疏的 5 × 5 卷积层。通过引入稀疏性,我们使 CNN 参数高效,并减少了过拟合的可能性。这个解释受到了 mng.bz/Pn8g 中讨论的启发。

1 × 1 卷积作为降维方法

通常,你的模型越深,性能越高(假设你有足够的数据)。正如我们已经知道的,CNN 的深度是有代价的。层数越多,参数就越多。因此,你需要特别注意深度模型的参数数量。

作为一个深度模型,Inception 网络利用 Inception 块内的 1 × 1 卷积滤波器来抑制参数的大幅增加。通过使用 1 × 1 卷积层,将较大的输入产生较小的输出,并将这些较小的输出作为输入传递给 Inception 块中的卷积子层(图 6.13)。例如,如果你有一个 10 × 10 × 256 大小的输入,通过将其与具有 32 个滤波器的 1 × 1 卷积层进行卷积,你将得到一个 10 × 10 × 32 大小的输出。这个输出比原始输入小了八倍。换句话说,1 × 1 卷积减小了大输入的通道深度/维度。

06-13

图 6.13 1 × 1 卷积的计算以及它如何实现输入通道维度的降维

因此,它被认为是一种降维方法。这些 1 × 1 卷积的权重可以被视为网络的参数,并且让网络学习这些滤波器的最佳值来解决给定的任务。

现在是时候定义一个函数,代表这个新的、改进的 Inception 块了,如下清单所示。

代码清单 6.4 定义 Inception 网络的 Inception 块

def inception(inp, n_filters):

    # 1x1 layer
    out1 = Conv2D(
        n_filters[0][0], (1,1), strides=(1,1), activation='relu', 
➥ padding='same'
    )(inp)

    # 1x1 followed by 3x3
    out2_1 = Conv2D(
        n_filters[1][0], (1,1), strides=(1,1), activation='relu', 
➥ padding='same')
(inp)
    out2_2 = Conv2D(
        n_filters[1][1], (3,3), strides=(1,1), activation='relu', 
➥ padding='same'
)(out2_1)

# 1x1 followed by 5x5
out3_1 = Conv2D(
    n_filters[2][0], (1,1), strides=(1,1), activation='relu', 
➥ padding='same'
)(inp)
out3_2 = Conv2D(
    n_filters[2][1], (5,5), strides=(1,1), activation='relu', 
➥ padding='same'
)(out3_1)

# 3x3 (pool) followed by 1x1
out4_1 = MaxPool2D(
    (3,3), strides=(1,1), padding='same'
)(inp)
out4_2 = Conv2D(
    n_filters[3][0], (1,1), strides=(1,1), activation='relu', 
➥ padding='same'
)(out4_1)

out = Concatenate(axis=-1)([out1, out2_2, out3_2, out4_2])
return out

inception() 函数接受一些输入(四维:批次、高度、宽度、通道、维度)和 Inception 块中卷积子层的过滤器尺寸列表。 此列表应按照以下格式具有过滤器尺寸:

[(1x1 filters), (1x1 filters, 3x3 filters), (1x1 filters, 5x5 filters), (1x1 filters)]

外循环对应 Inception 块中的垂直柱,内循环对应每个柱中的卷积层(图 6.14)。

06-14

图 6.14 Inception 块与 Inception 网模型的完整架构并排

然后,我们定义了四个垂直计算流,最后在末端连接到一个:

  • 1 × 1 卷积

  • 1 × 1 卷积接着是一个 3 × 3 卷积

  • 1 × 1 卷积接着是一个 5 × 5 卷积

  • 3 × 3 池化层接着是一个 1 × 1 卷积

使用 1 × 1 卷积进行尺寸缩减的数学视图

如果您不喜欢生动的方法,这里是更简洁和数学化的视图,说明 1 × 1 卷积如何减少维度。 假设您有尺寸为 10 × 10 × 256 的输入。 假设您有尺寸为 1 × 1 × 32 的卷积层:

  • 尺寸(输入)= 10 × 10 × 256

  • 尺寸(层)= 1 × 1 × 32

您可以将您的卷积层表示为 1 × 32 矩阵。 接下来,在 axis = 0(即行维度)上重复列,重复 256 次,得到我们

  • 尺寸(输入)= 10 × 10 × 256

  • 尺寸(层)= 256 × 32

现在您可以将输入与卷积滤波器相乘

  • 尺寸(输出)=(10 × 10 × 256)(256 × 32)

这给我们一个尺寸为

  • 尺寸(输出)= 10 × 10 × 32

比原始输入小得多。

最后,我们将所有这些流的输出连接到最后一个轴上(由 axis = -1 表示)。 请注意,最后一个维度是所有输出的通道维度。 换句话说,我们在通道维度上堆叠这些输出。 图 6.14 说明了 Inception 块如何在整体 Inception 网模型中定位。 接下来,我们将讨论 Inception 网模型的另一个组件,称为辅助输出层。

辅助输出层

最后,我们有两个辅助输出层,帮助稳定我们的深度 CNN。 正如前面提到的,辅助输出存在是为了稳定深度网络的训练。 在 Inception 网中,辅助输出层具有以下(图 6.15)。

  • 一个 5 × 5 平均池化层

  • 一个 1 × 1 卷积层

  • 一个具有 ReLU 激活的 Dense 层,从 1 × 1 卷积层接收平铺输出

  • 一个具有 softmax 的 Dense 层,输出类别的概率

06-15

图 6.15 辅助输出层与完整的 Inception 网架构并排

我们定义一个函数,按如下方式产生辅助输出预测(清单 6.5)。

清单 6.5 定义辅助输出作为 Python 函数

def aux_out(inp,name=None):    
    avgpool1 = AvgPool2D((5,5), strides=(3,3), padding='valid')(inp)       ❶
    conv1 = Conv2D(128, (1,1), activation='relu', padding='same')(avgpool1)❷
    flat = Flatten()(conv1)                                                ❸
    dense1 = Dense(1024, activation='relu')(flat)                          ❹
    aux_out = Dense(200, activation='softmax', name=name)(dense1)          ❺
    return aux_out

❶ 平均池化层的输出。 请注意,它使用有效池化,这导致下一层的输出为 4 × 4 大小。

❷ 1 × 1 卷积层的输出

❸ 将卷积层的输出展平,以便馈送到 Dense 层中

❹ 第一个 Dense 层的输出

❺ 最终预测 Dense 层的输出

aux_out() 函数定义了辅助输出层。它以一个核大小为(5,5) 和步长为(3,3) 的平均池化层开始,以及有效的填充。这意味着该层不会尝试纠正池化引入的维度减少(与相同填充相反)。然后,它后面跟着一个具有 128 个滤波器的卷积层,(1,1) 核大小,ReLU 激活和相同填充。然后,需要一个 Flatten() 层,然后将输出馈送到一个 Dense 层。请记住,Flatten() 层将高度、宽度和通道维度展平为一个单一维度。最后,应用一个具有 200 个节点和 softmax 激活的 Dense 层。有了这些,我们就有了构建自己的 Inception 网络的所有构建模块。

6.3.3 将所有内容整合在一起

我们已经走了很长的路。让我们喘口气,回顾一下我们迄今为止取得的成就:

  • Inception 网络模型的抽象架构和组件包括一个干扰块、Inception 块和辅助输出。

  • 这些组件的详细信息。干扰块类似于标准 CNN 的干扰块(除了全连接层)。Inception 块携带具有不同核大小的子卷积层,这些卷积层鼓励稀疏性并减少过拟合。

  • 辅助输出使网络训练更加平滑,并消除了训练过程中的任何不良数值错误。

我们还定义了封装这些的方法,以便我们可以调用这些方法并构建完整的 Inception 网络。现在我们可以定义完整的 Inception 模型(请参阅下一个列表)。此外,您可以在表 6.5 中找到精确的 Inception 块规范(按照原始论文)的摘要。

列表 6.6 定义完整的 Inception 网络模型

def inception_v1():

    K.clear_session()

    inp = Input(shape=(56,56,3))                                          ❶
    stem_out = stem(inp)                                                  ❷
    inc_3a = inception(stem_out, [(64,),(96,128),(16,32),(32,)])          ❸
    inc_3b = inception(inc_3a, [(128,),(128,192),(32,96),(64,)])          ❸

    maxpool = MaxPool2D((3,3), strides=(2,2), padding='same')(inc_3b)

    inc_4a = inception(maxpool, [(192,),(96,208),(16,48),(64,)])          ❸
    inc_4b = inception(inc_4a, [(160,),(112,224),(24,64),(64,)])          ❸

    aux_out1 = aux_out(inc_4a, name='aux1')                               ❹

    inc_4c = inception(inc_4b, [(128,),(128,256),(24,64),(64,)])
    inc_4d = inception(inc_4c, [(112,),(144,288),(32,64),(64,)])
    inc_4e = inception(inc_4d, [(256,),(160,320),(32,128),(128,)])

    maxpool = MaxPool2D((3,3), strides=(2,2), padding='same')(inc_4e)

    aux_out2 = aux_out(inc_4d, name='aux2')                               ❹

    inc_5a = inception(maxpool, [(256,),(160,320),(32,128),(128,)])
    inc_5b = inception(inc_5a, [(384,),(192,384),(48,128),(128,)])
    avgpool1 = AvgPool2D((7,7), strides=(1,1), padding='valid')(inc_5b)   ❺

    flat_out = Flatten()(avgpool1)                                        ❻
    out_main = Dense(200, activation='softmax', name='final')(flat_out)   ❼

    model = Model(inputs=inp, outputs=[out_main, aux_out1, aux_out2])   
    model.compile(loss='categorical_crossentropy', 
                       optimizer='adam', metrics=['accuracy'])            ❽
    return model

❶ 定义一个输入层。它接收一个大小为 64 × 64 × 3 的批处理输入。

❷ 要定义干扰块,我们使用了之前定义的干扰块() 函数。

❸ 定义 Inception 块。请注意,每个 Inception 块具有不同数量的滤波器。

❹ 定义辅助输出

❺ 最终池化层被定义为一个平均池化层。

❻ Flatten 层将平均池化层展平,并为全连接层做好准备。

❼ 最终预测层,具有 200 个输出节点(每个类别一个)

❽ 在编译模型时,我们对所有输出层和优化器 adam 使用分类交叉熵损失。

您可以看到该模型按照原始论文的规定有九个 Inception 块。此外,它还具有干扰块、辅助输出和最终输出层。层的具体规格列在表 6.5 中。

表 6.5 Inception 网络 v1 模型中 Inception 模块的滤波器计数摘要。C(nxn) 表示 nxn 卷积层,而 MaxP(mxm) 表示 mxm 最大池化层。

Inception 层 C(1 × 1) C(1 × 1); 在 C(3 × 3) 之前 C(3 × 3) C(1 × 1); 在 C(5 × 5) 之前 C(5 × 5) C(1 × 1); 在 MaxP(3 × 3) 之后
Inc_3a 64 96 128 16 32 32
Inc_3b 128 128 192 32 96 64
Inc_4a 192 96 208 16 48 64
Inc_4b 160 112 224 24 64 64
Inc_4c 128 128 256 24 64 64
Inc_4d 112 144 288 32 64 64
Inc_4e 256 160 320 32 128 128
Inc_5a 256 160 320 32 128 128
Inc_5b 384 192 384 48 128 128

层的定义将与您已经看到的相当相似。然而,我们定义模型和编译模型的方式对于一些人来说可能是新的。正如我们讨论的那样,Inception 网络是一个多输出模型。您可以通过传递输出列表而不是单个输出来定义具有多个输出的 Keras 模型:

model = Model(inputs=inp, outputs=[out_main, aux_out1, aux_out2])

在编译模型时,您可以将损失定义为一个字符串列表。如果定义一个字符串,那么该损失将用于所有输出。我们使用分类交叉熵损失(对于最终输出层和辅助输出层)和优化器 adam 来编译模型,adam 是一种广泛用于优化模型的先进优化器,它可以随着模型训练适当地调整学习率。此外,我们将检查模型的准确性:

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

通过定义 inception_v1() 函数,您可以创建一个模型如下:

model = inception_v1()

让我们花点时间回顾一下我们迄今为止所取得的成就。我们已经下载了数据,剖析了数据,并分析了数据以了解具体情况。然后,我们使用 tensorflow.keras.preprocessing.image.ImageDataGenerator 创建了一个图像数据管道。我们将数据分成了三部分:训练、验证和测试。最后,我们定义了我们的模型,这是一个被称为 Inception 网络的最先进的图像分类器。现在我们将看看多年来出现的其他 Inception 模型。

6.3.4 其他 Inception 模型

我们成功地实现了一个 Inception 网络模型,它涵盖了我们需要理解其他 Inception 模型的大部分基础知识。自 v1 模型以来已经有五个更多的 Inception 网络。让我们简要地了解一下 Inception 网络的演变。

Inception v1

我们已经深入讨论了 Inception 网络 v1。Inception 网络 v1 中引入的最大突破如下:

  • Inception 块的概念允许 CNN 在模型的相同深度拥有不同的感受野(即,卷积核大小)。这鼓励模型的稀疏性,导致参数更少,过拟合的机会也更少。

  • 在 Inception 模型的 20 层中,如果不小心,现代 GPU 的内存可能会被耗尽。Inception 网络通过使用 1 × 1 卷积层来减少输出通道深度,以缓解这个问题。

  • 网络越深,在模型训练过程中,梯度不稳定的可能性就越大。这是因为梯度必须经过很长的路径(从顶部到最底部),这可能导致梯度不稳定。在网络中间引入辅助输出层作为正则化器可以缓解这个问题,从而导致梯度稳定。

Inception v2

Inception net v2 出现在 Inception net v1 发布后不久(“Rethinking the Inception Architecture for Computer Vision”,arxiv.org/pdf/1512.00567.pdf)。这个模型的主要贡献如下。

当层的容量(即参数)不足以学习输入的良好表示时,就会发生表示瓶颈。如果在深度递减时层的大小减小得太快,这种情况可能会发生。Inception v2 重新设计了架构,以确保模型中不存在表示瓶颈。这主要通过改变层大小而保持其他细节不变来实现。

进一步减少网络参数以减少过拟合被强化。这是通过用 3 × 3 卷积(也称为因式分解大卷积层)替换更高阶的卷积(例如,5 × 5 和 7 × 7)来实现的。这是如何可能的?让我为您说明一下(图 6.16)。

06-16

图 6.16 一个 5 × 5 卷积层(左)和两个 3 × 3 卷积层(右)

将 5 × 5 卷积表示为两个更小的 3 × 3 卷积操作,我们可以减少 28% 的参数。图 6.17 对比了 Inception v1 块和 Inception v2 块。

06-17

图 6.17 Inception net v1 中的 Inception 块(左)与 Inception net v2 中的 Inception 块(右)

TensorFlow 代码如下:

# 1x1 layer
out1 = Conv2D(64, (1,1), strides=(1,1), activation='relu', padding='same')(inp)
# 1x1 followed by 3x3
out2_1 = Conv2D(
    96, (1,1), strides=(1,1), activation='relu', padding='same'
)(inp)
out2_2 = Conv2D(
    128, (3,3), strides=(1,1), activation='relu', padding='same'
)(out2_1)

# 1x1 followed by 5x5
# Here 5x5 is represented by two 3x3 convolution layers
out3_1 = Conv2D(
    16, (1,1), strides=(1,1), activation='relu', padding='same'
)(inp)
out3_2 = Conv2D(
    32, (3,3), strides=(1,1), activation='relu', padding='same'
)(out3_1)
out3_3 = Conv2D(
    32, (3,3), strides=(1,1), activation='relu', padding='same'
)(out3_2)

# 3x3 (pool) followed by 1x1
out4_1 = MaxPool2D((3,3), strides=(1,1), padding='same')(inp)
out4_2 = Conv2D(
    32, (1,1), strides=(1,1), activation='relu', padding='same'
)(out4_1)

out = Concatenate(axis=-1)([out1, out2_2, out3_3, out4_2])

但我们不必止步于此。我们可以将任何 n × n 卷积操作因式分解为两个 1 × n 和 n × 1 卷积层,例如,对于 3 × 3 卷积层,可以减少 33% 的参数(图 6.18)。经验上发现,将 n × n 操作分解为两个 1 × n 和 n × 1 操作仅在更高层中有用。您可以参考论文以了解这些类型的因式分解何时以及在哪里使用。

06-18

图 6.18 一个 3 × 3 卷积层(左)和一个 3 × 1 和一个 1 × 3 卷积层(右)

Inception v3

Inception v3 是在同一篇论文中引入的 Inception net v2. 与 v2 不同的主要贡献是使用批量标准化层。批量标准化(“Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift”,proceedings.mlr.press/v37/ioffe15.pdf)通过减去给定层 x 的平均值(E(x))和标准差(√(Var(x)))来标准化给定层的输出:

06_18a

这个过程帮助网络稳定其输出值,而不让它们变得太大或太小。接下来,它有两个可训练参数,γβ,用于缩放和偏移归一化输出:

y = γ + β

这样,网络就可以通过学习 γβ 的最佳化来学习归一化的自己变化,以防 不是最佳的归一化配置。此时,你只需要理解批归一化归一化给定层的输出。我们将在下一章更详细地讨论批归一化在 Inception 网模型中的使用方式。

Inception v4

Inception-v4 是在论文“Inception-v4, Inception-ResNet and the Impact of Residual Connections on Learning”(mng.bz/J28P) 中引入的,并没有引入任何新的概念,而是专注于使模型更简单,而不会牺牲性能。主要是,v4 简化了网络的干部和其他元素。由于这主要是为了更好地调整网络的超参数以获得更好的性能,而不是引入任何新的概念,所以我们不会深入研究这个模型。

Inception-ResNet v1 和 Inception-ResNet v2

Inception-ResNet v1 和 v2 在同一篇论文中被介绍,并且是其主要贡献。Inception-ResNet 简化了该模型中使用的 Inception 块,并删除了一些杂乱的细节。更重要的是,它引入了残差连接。残差连接(或 跳过连接)是由 Kaiming He 等人在题为“Deep Residual Learning for Image Recognition”的论文中介绍的 (arxiv.org/ pdf/1512.03385.pdf)。这是一个简单而优雅的概念,却非常强大,它已经成为许多不同领域中表现最佳的模型的原因之一。

如图 6.19 所示,残差连接简单地将较低层(靠近输入)的输入添加到较高层(远离输入)的输入中。这样就在较低层和较高层之间创建了一条捷径,实质上是在结果输出与较低层之间创建了另一个捷径。我们不会在这里深入讨论太多细节,因为我们将在下一章详细讨论 Inception-ResNet 模型。接下来,我们将训练我们刚刚定义的模型,使用我们准备的图像数据。

06-19

图 6.19 残差连接是如何引入到网络中的。这是一个简单的操作,其中你将一层的较低输出(更接近输入)添加到一层的较高输出(远离输入)中。跳过连接可以设计成跳过您喜欢的任意数量的层。图还突出了梯度的流动;您可以看到跳过连接如何允许梯度绕过某些层并传播到较低层。

练习 3

作为研究的一部分,您正在测试一种称为poolception的新技术。概念上类似于 Inception 块,poolception 具有三个并行的池化层,具有以下规格:

  • 一个带有步长为 2 和相同填充的 3×3 最大池化层

  • 一个带有步长为 2 和相同填充的 5×5 最大池化层

  • 一个带有步长为 2 和相同填充的 3×3 平均池化层

最后,这些层的输出在通道轴上串联起来。您能将此实现为一个名为 poolception 的 Python 函数,该函数以前一层的输入 x 作为参数吗?

6.4 训练模型和评估性能

很棒!您已经定义了一个在类似(更大)数据集上表现良好的最先进模型架构之一。您的下一个任务是训练此模型并分析其性能。

模型训练是一个必不可少的步骤,如果您需要一个性能良好的模型,一旦到了使用它的时候。训练模型会优化(即更改)模型的参数,使其能够在给定输入时产生正确的预测。通常,模型训练是在多个时期进行的,其中每个时期可以包含数千个迭代。这个过程可能需要数小时甚至数周,这取决于数据集的大小和模型。正如我们已经讨论过的,由于其众所周知的内存需求,深度神经网络以小批量方式消耗数据。优化模型与单个数据批次的步骤称为迭代。当您以这种批量方式遍历整个数据集时,它被称为时期

最后,一旦训练完成,您需要确保模型在未见过的数据上表现良好。这些未见过的数据在训练过程中不得与模型发生任何交互。深度学习网络最常见的评估指标是准确率。因此,我们测量测试准确率以确保模型的稳健性。

为了训练模型,让我们首先定义一个函数,该函数计算每个时期的步数或迭代次数,给定数据集的大小和批量大小。对于每个时期都运行预定义数量的步骤总是一个好主意。有些情况下,Keras 无法确定步骤的数量,这种情况下,它可能会使模型运行,直到您停止它:

def get_steps_per_epoch(n_data, batch_size):
    if n_data%batch_size==0:
        return int(n_data/batch_size)
    else:
        return int(n_data*1.0/batch_size)+1

这是一个非常简单的计算。每个时期的步数是数据点数(n_data)除以批量大小(batch_size)。如果 n_data 不可被 batch_size 整除,则需要将返回值加 1,以确保不会丢失任何数据。现在让我们在下面的列表中训练模型。

列表 6.7 训练 Inception 网络

from tensorflow.keras.callbacks import CSVLogger
import time
import os

if not os.path.exists('eval'):
    os.mkdir('eval')                                               ❶

csv_logger = CSVLogger(os.path.join('eval','1_eval_base.log'))     ❷

history = model.fit(
    x=train_gen_aux,                                               ❸
    validation_data=valid_gen_aux,                                 ❸
    steps_per_epoch=get_steps_per_epoch(0.9*500*200,batch_size),   ❸
    validation_steps=get_steps_per_epoch(0.1*500*200,batch_size),  ❸
    epochs=50, 
    callbacks=[csv_logger]                                         ❸
)                                                                  ❸

if not os.path.exists('models'):
    os.mkdir("models")
model.save(os.path.join('models', 'inception_v1_base.h5'))         ❹

❶ 创建一个名为 eval 的目录来存储性能结果

❷ 这是一个您传递给 fit() 函数的 Keras 回调函数。它将指标数据写入 CSV 文件。

❸ 通过拟合模型,您可以看到我们正在将训练和验证数据生成器传递给函数。

❹ 将模型保存到磁盘上,以便在需要时重新加载

训练模型时,通常会遵循以下步骤:

  • 为一定数量的周期训练模型。

  • 每个训练周期结束时,在验证数据集上测量性能。

  • 所有训练周期结束后,对测试集的性能进行测量。

当在代码中调用 model.fit()时,它会处理前两个步骤。我们将更详细地查看 model.fit()函数。我们向函数传递以下参数:

  • X—将训练数据生成器传递给模型,其中包含输入(x)和目标(y)。

  • y—通常接收目标值。在这里,我们不指定 y,因为 x 已经包含了目标。

  • validation_data—接收验证数据生成器。

  • steps_per_epoch—训练中每个周期的步数(迭代次数)。

  • validation_steps—验证中每个周期的步数(迭代次数)。

  • epochs—周期数。

  • 回调函数—需要传递给模型的任何回调函数(有关回调函数的完整列表,请访问mng.bz/woEW)。

在训练模型后,您应该会得到以下结果之类的内容:

Train for 704 steps, validate for 79 steps
Epoch 1/50
704/704 [==============================] - 196s 279ms/step - loss: 14.6223 
➥ - final_loss: 4.9449 - aux1_loss: 4.8074 - aux2_loss: 4.8700 - 
➥ final_accuracy: 0.0252 - aux1_accuracy: 0.0411 - aux2_accuracy: 0.0347 
➥ - val_loss: 13.3207 - val_final_loss: 4.5473 - val_aux1_loss: 4.3426 - 
➥ val_aux2_loss: 4.4308 - val_final_accuracy: 0.0595 - val_aux1_accuracy: 
➥ 0.0860 - val_aux2_accuracy: 0.0765
...
Epoch 50/50
704/704 [==============================] - 196s 279ms/step - loss: 0.6361 - 
➥ final_loss: 0.2271 - aux1_loss: 0.1816 - aux2_loss: 0.2274 - 
➥ final_accuracy: 0.9296 - aux1_accuracy: 0.9411 - aux2_accuracy: 0.9264 
➥ - val_loss: 27.6959 - val_final_loss: 7.9506 - val_aux1_loss: 10.4079 - 
➥ val_aux2_loss: 9.3375 - val_final_accuracy: 0.2703 - val_aux1_accuracy: 
➥ 0.2318 - val_aux2_accuracy: 0.2361

注意 在一台配备 Intel Core i5 处理器和 NVIDIA GeForce RTX 2070 8GB 显卡的机器上,训练大约需要 2 小时 45 分钟。您可以通过减少训练周期的数量来减少训练时间。

最后,我们将在测试数据上测试训练好的模型(即 val 文件夹中的数据)。您可以通过调用 model.evaluate()并传递测试数据生成器(test_gen_aux)和测试集的步数(迭代次数)来轻松获取模型的测试性能:

model = load_model(os.path.join('models','inception_v1_base.h5'))
test_res = model.evaluate(test_gen_aux, steps=get_steps_per_epoch(200*50, 
➥ batch_size))
test_res_dict = dict(zip(model.metrics_names, test_res))

您将会得到以下输出:

196/196 [==============================] - 17s 88ms/step - loss: 27.7303 - 
➥ final_loss: 7.9470 - aux1_loss: 10.3892 - aux2_loss: 9.3941 - 
➥ final_accuracy: 0.2700 - aux1_accuracy: 0.2307 - aux2_accuracy: 0.2367

我们可以看到模型达到了约 30%的验证和测试准确率以及惊人的约 94%的训练准确率。这清楚地表明我们没有完全避免过拟合。但这并不完全是坏消息。三十%的准确率意味着模型在验证和测试集中识别了约 3,000/10,000 张图像。就纯粹的数据量而言,这相当于 200 个类别中的 60 个。

注意 过拟合的模型就像一个把所有答案都背下来的学生,而泛化的模型是一个努力理解将在考试中测试的概念的学生。背诵答案的学生只会在考试中表现良好,在现实世界中会失败,而理解概念的学生可以将他们的知识推广到考试和现实世界中。

过度拟合可能会出现几种原因:

  • 模型架构对我们拥有的数据集来说并不是最佳的。

  • 需要更多的正则化来减少过拟合,比如 dropout 和批归一化。

  • 我们没有使用已经在类似数据上训练过的预训练模型。

我们将在下一章中解决这些问题,看到事情会有多大改善将会令人兴奋。

练习 4

如果用一个包含 50,000 个样本且批次大小为 250 的数据集训练模型 10 个周期,那么你会训练模型多少次迭代?假设输入和标签分别存储在变量 x 和 y 中,填写 model.fit() 中的必要参数以根据这个规范训练模型。在不使用数据生成器时,可以使用 batch_size 参数设置批次大小,并在 model.fit() 中忽略 steps_per_epoch 参数(自动推断)。

总结

  • 探索性数据分析(EDA)是机器学习生命周期中必须在开始任何建模之前执行的关键步骤。

  • 分析数据的方面越多,效果越好。

  • Keras 数据生成器可以用来从磁盘读取图像并将它们加载到内存中以训练模型。

  • Inception 网络 v1 是用于图像分类的最新计算机视觉模型之一,旨在减少过拟合和深度模型的内存需求。

  • Inception 网络 v1 包括一个 stem、若干 inception 块和辅助输出。

  • Inception 块是 Inception 网络中的一层,包含多个具有不同卷积核大小的子卷积层,而辅助输出确保模型训练的平稳性。

  • 训练模型时,会使用三个数据集:训练集、验证集和测试集。

  • 通常情况下,我们会在训练数据上训练模型多个周期,在每个周期结束时在验证集上评估性能。最后,在训练结束后,我们会在测试数据集上评估性能。

  • 过拟合的模型就像一个把所有答案都记住的学生,它在训练数据上表现很好,但在将知识概括应用于未见数据分析时效果很差。

练习答案

练习 1

def get_img_minimum(path):
    img = np.array(Image.open(path))
    return np.min(img)

df[“minimum”] = df[“filepath”].apply(lambda x: get_img_minimum(x))

练习 2

def data_gen_corrupt(gen):
    for x,y in gen:
        if np.random.normal()>0:
            y = 0
        yield x,(y,y,y)

练习 3

def poolception(x):
    out1 = MaxPool2D(pool_size=(3,3), strides=(2,2), padding=’same’)(x)
    out2 = MaxPool2D(pool_size=(5,5), strides=(2,2), padding=’same’)(out1)
    out3 = AvgPool2D(pool_size=(3,3), strides=(2,2), padding=’same’)(out2)
    out = Concatenate(axis=-1)([out1, out2, out3])
    return out

练习 4: 总迭代次数 = (数据集大小/批次大小)* 周期数 = (50,000/250)* 10 = 2,000

model.fit(x=x, y=y, batch_size=250, epochs=10)

第七章:教会机器更好地看:改善 CNNs 并让它们承认

本章内容包括

  • 减少图像分类器的过拟合

  • 通过更好的模型架构提升模型性能

  • 使用预训练模型和迁移学习进行图像分类

  • 现代 ML 解释技术来解剖图像分类器

我们已经开发并训练了一款名为 Inception net v1 的最先进的图像分类器,它在一个物体分类数据集上进行了训练。Inception net v1 是计算机视觉中一个广为人知的图像分类模型。你学习了 Inception 块是如何通过在多个尺度上聚合卷积窗口来创建的,这鼓励了模型中的稀疏性。你还看到了如何使用 1 × 1 卷积来保持层的维度最小。最后,我们观察到 Inception net v1 如何在网络中部使用辅助分类层来稳定和维持整个网络中的梯度流。然而,结果并没有真正达到模型的声誉,它在验证和测试准确性上过度拟合,验证和测试准确率约为 30%,而训练准确率则高达约 94%。在本章中,我们将讨论通过减少过拟合和提高验证和测试准确率来改善模型,最终将使我们得到一个在验证和测试集上达到约 80% 准确率(相当于能够准确识别 160/200 类物体)的模型。此外,我们还将研究允许我们探索模型思维的技术,以获得洞察。

本章将带你走过一个激动人心的旅程,在这个旅程中,我们将把一个次优的机器学习模型变成一个显著优秀的模型。这个过程将使人联想起我们在上一章所做的事情。我们将增加一个额外的步骤来解释/解读模型所做的决策。我们将使用特殊的技术来看看模型在做出预测时对图像的哪个部分支付了最多的注意力。这有助于我们对模型建立信任。在这个过程中,我们识别出模型中潜藏的问题,并系统地修复它们以提高性能。我们将讨论几种重要的技术,包括以下内容:

  • 通过使用各种图像变换技术如亮度/对比度调整、旋转和平移来增广数据,为模型创建更多标记数据

  • 实现一个更适合所使用的数据大小和类型的 Inception net 变体

  • 使用迁移学习来利用已经在更大数据集上训练过的模型,并对其进行微调,以在我们拥有的数据集上表现良好

如果你曾经需要为不熟悉的问题实施深度学习解决方案,那么这一章可能会与你产生共鸣。通常,仅仅实施“某些”深度网络并不会让你登上成功的顶峰。如果问题的新颖性或手头问题的定制性质没有处理好,它会阻碍你的进展。这样的难题会把你带入未知领域,你需要小心行事,找到解决方案而不至于筋疲力尽。本章将为任何可能在计算机视觉领域面对类似情况的人提供指导。

7.1 减少过拟合的技术

我们正在追求一个雄心勃勃的目标,即开发一个智能购物助手应用程序,其中会使用图像/物体分类器作为重要组件。为此,我们将使用数据集 tiny-imagenet-200,它是大型 ImageNet 图像分类数据集的一个较小版本,由图像和表示该图像中存在的对象类别组成。该数据集有一个训练子集和一个测试子集。你进一步将训练子集分成训练集(原始的 90%)和验证集(原始的 10%)。

你已经基于著名的 Inception 网络模型开发了一个模型,但它严重过拟合。需要缓解过拟合,因为它会导致模型在训练数据上表现非常好,但在测试/真实世界数据上表现不佳。你知道几种减少过拟合的技术,即数据增强(从现有数据中创建更多数据;对于图像,这包括通过引入随机亮度/对比度调整、平移、旋转等方式创建相同图像的变体)、随机失活(即在训练期间随机关闭网络中的节点)以及提前停止(即在过拟合发生前终止模型训练)。你希望利用 Keras API 来减少过拟合。

通常,减少过拟合需要仔细检查整个机器学习流程。这涉及到输入的数据、模型结构和模型训练。在这一节中,我们将看看所有这些方面,并了解如何防止过拟合。此部分的代码可在 Ch07-Improving-CNNs-and-Explaining/7.1.Image_Classification_Advance.ipynb 中找到。

7.1.1 使用 Keras 进行图像数据增强

首先是在训练集中增加数据。 数据增强是一种常见的方法,可以增加深度学习网络可用的数据量,而无需对新数据进行标记。 例如,在图像分类问题中,您可以通过创建同一图像的多个变换版本(例如,移动图像,更改亮度)并具有与原始图像相同的标签(图 7.1)来从单个图像创建多个数据点。 如前所述,更多数据通过增加泛化能力(减少过拟合)来增强深度学习模型的强度,从而在实际应用中实现可靠的性能。 对于图像数据,您可以使用许多不同的增强技术:

  • 随机调整亮度、对比度等

  • 随机缩放、旋转、平移等

07-01

图 7.1 在增强步骤之后的训练数据和验证数据之间的差异。 图清楚地显示了对训练数据应用的各种转换,而对验证数据未应用,正如我们所预期的那样。

通过向我们之前使用的 ImageDataGenerator 提供几个额外参数,可以轻松地实现这种增强。 让我们定义一个新的 Keras ImageDataGenerator,具有数据增强功能。 在 Keras 中,您可以执行大多数这些增强,几乎不需要去其他地方寻找。 让我们看看 ImageDataGenerator 提供的各种选项(仅显示了最重要的参数)。 图 7.2 说明了此处列出的不同参数的效果。

data_gen = tf.keras.preprocessing.image.ImageDataGenerator(
    featurewise_center=False, samplewise_center=False,
    featurewise_std_normalization=False, samplewise_std_normalization=False,
    zca_whitening=False, rotation_range=0, width_shift_range=0.0,
    height_shift_range=0.0, brightness_range=None, shear_range=0.0, 
➥ zoom_range=0.0,
    channel_shift_range=0.0, horizontal_flip=False, 
    vertical_flip=False, fill_mode=”nearest”, rescale=None,
    preprocessing_function=None, validation_split=0.0
)

其中

  • featurewise_center 指定是否通过减去整个数据集的平均值来使图像居中(例如,True/False)。

  • samplewise_center 指定是否通过减去每个图像的单个平均值来使图像居中(例如,True/False)。

  • featurewise_std_normalization 与 featurewise_center 相同,但是将图像除以标准偏差而不是减去平均值(True/False)。

  • samplewise_std_normalization 与 samplewise_center 相同,但是将图像除以标准偏差而不是减去平均值(True/False)。

  • zca_whitening 是一种特殊类型的图像归一化,旨在减少图像像素中存在的相关性(请参阅mng.bz/DgP0)(True/False)。

  • rotation_range 指定在数据增强期间进行的随机图像旋转的边界(以度为单位)。 具有值在(0, 360)之间的浮点数; 例如,30 表示-30 到 30 的范围; 0 禁用。

  • width_shift_range 指定在数据增强期间在宽度轴上进行的随机移位的边界(作为比例或像素)。

  • 值在(-1, 1)之间的元组被视为宽度的比例(例如,(-0.4, 0.3))。

  • 像素的值在(-inf, inf)之间的元组被视为像素(例如,(-150, 250))。

  • height_shift_range 与 width_shift_range 相同,只是针对高度维度。

  • brightness_range指定在数据增强期间对数据进行的随机亮度调整的范围。

  • 元组中的值介于(-inf,inf)之间,例如,(-0.2,0.5)或(-5,10);0 表示禁用。

  • shear_rangebrightness_range相同,但用于在数据增强期间剪切(即倾斜)图像。

  • 以度为单位的浮点数,例如,30.0。

  • zoom_rangebrightness_range相同,除了在数据增强期间对图像进行缩放。

  • horizontal_flip指定在数据增强期间是否随机水平翻转图像(是/否)。

  • vertical_fliphorizontal_flip相同,但垂直翻转(是/否)。

  • fill_mode定义了通过各种图像变换(例如,将图像向左移动会在右侧创建空白空间)创建的空白空间如何处理。可能的选项是“reflect”,“nearest”和“constant”。图 7.2 的最后一行显示了差异。

  • rescale通过常量值重新缩放输入。

  • preprocessing_function接受一个 Python 函数,该函数可用于引入额外的数据增强/预处理步骤,这些步骤不容易获得。

  • validation_split解决了应该将多少数据用作验证数据的问题。我们不使用此参数,因为我们单独为验证集创建数据生成器,因为我们不希望有增强应用。一个浮点数,例如,0.2。

07-02

图 7.2 不同增强参数及其ImageDataGenerator的值的效果。

通过对不同参数有良好的理解,我们将定义两个图像数据生成器:一个用于数据增强(训练数据),另一个不用于数据增强(测试数据)。对于我们的项目,我们将以以下方式增强数据:

  • 随机旋转图像。

  • 在宽度维度上随机平移。

  • 在高度维度上随机平移。

  • 随机调整亮度。

  • 随机剪切。

  • 随机缩放。

  • 随机水平翻转图像。

  • 随机伽马校正(自定义实现)。

  • 随机遮挡(自定义实现)。

以下列表显示了如何使用验证分割定义ImageDataGenerator

列表 7.1 定义了具有验证分割的ImageDataGenerator

image_gen_aug = ImageDataGenerator(                     ❶
        samplewise_center=False,                        ❷
        rotation_range=30,                              ❸
        width_shift_range=0.2, height_shift_range=0.2,  ❸
        brightness_range=(0.5,1.5),                     ❸
        shear_range=5,                                  ❸
        zoom_range=0.2,                                 ❸
        horizontal_flip=True,                           ❸
        fill_mode='reflect',                            ❸
        validation_split=0.1                            ❹
)
image_gen = ImageDataGenerator(samplewise_center=False) ❺

❶ 定义用于训练/验证数据的ImageDataGenerator

❷ 我们将暂时关闭samplewise_center并稍后重新引入它。

❸ 先前讨论的各种增强参数(经验设置)。

❹ 将训练数据的 10% 部分用作验证数据。

❺ 定义了用于测试数据的单独ImageDataGenerator

我们经验地选择了这些参数的参数。随意尝试其他参数,并查看它们对模型性能的影响。一个重要的事情要注意的是,与以前的例子不同,我们设置了 samplewise_center=False。这是因为我们计划在标准化之前进行少量自定义预处理步骤。因此,我们将关闭 ImageDataGenerator 中的标准化,并稍后重新引入它(通过自定义函数)。接下来,我们将定义训练和测试数据生成器(使用流函数)。与上一章类似的模式,我们将通过同一数据生成器(使用 validation_split 和 subset 参数)获取训练和验证数据生成器(参见下一个列表)。

列表 7.2:定义训练、验证和测试集的数据生成器

partial_flow_func = partial(                                ❶
        image_gen_aug.flow_from_directory, 
        directory=os.path.join('data','tiny-imagenet-200', 'train'), 
        target_size=target_size, classes=None,
        class_mode='categorical', batch_size=batch_size, 
        shuffle=True, seed=random_seed
)

train_gen = partial_flow_func(subset='training')            ❷

valid_gen = partial_flow_func(subset='validation')          ❸

test_df = get_test_labels_df(                               ❹
        os.path.join('data','tiny-imagenet-200',  'val', 
➥ 'val_annotations.txt')
)
test_gen = image_gen.flow_from_dataframe(                   ❺
        test_df, directory=os.path.join('data','tiny-imagenet-200',  'val', 
➥ 'images'),
        target_size=target_size, classes=None,
        class_mode='categorical', batch_size=batch_size, shuffle=False
)

❶ 定义一个偏函数,除了子集参数之外所有参数都已固定。

❷ 获取训练数据子集。

❸ 获取验证数据子集。

❹ 读取存储在 txt 文件中的测试标签。

❺ 定义测试数据生成器。

为了恢复我们的记忆,flow_from_directory(...)具有以下函数签名:

image_gen.flow_from_directory (
    directory=<directory where the images are>, 
    target_size=<height and width or target image>, 
    classes=None,
    class_mode=<type of targets generated such as one hot encoded, sparse, etc.>,
    batch_size=<size of a single batch>, 
    shuffle=<whether to shuffle data or not>, 
    seed=<random seed to be used in shuffling>, 
    subset=<set to training or validation>
)

train_gen 和 valid_gen 使用 image_gen_aug(进行数据增强)来获取数据。train_gen 和 valid_gen 被定义为原始 image_gen.flow_from_directory()的偏函数,它们共享除子集参数之外的所有参数。但是,重要的是要记住,增强仅应用于训练数据,不得应用于验证子集。这是我们需要的期望行为,因为我们希望验证数据集跨多个周期保持固定。接下来,test_gen 使用 image_gen(无数据增强)。

为什么不应该增强验证/测试数据?

在进行数据增强时,应该只对训练数据集进行增强,不要对验证和测试集进行增强。在验证和测试集上进行增强会导致不同测试/运行之间结果不一致(因为数据增强引入了随机修改)。我们希望保持验证和测试数据集在训练期间始终保持一致。因此,数据增强只针对训练数据进行。

记住,Inception Net v1 有三个输出层;因此,生成器的输出需要是一个输入和三个输出。我们通过定义一个新的 Python 生成器,修改内容以实现这一点(见下一个列表)。

列表 7.3:定义带有几个修饰的数据生成器

def data_gen_augmented_inceptionnet_v1(gen, random_gamma=False, 
➥ random_occlude=False):                                                  ❶
    for x,y in gen: 
        if random_gamma:                                                   ❷
            # Gamma correction
            # Doing this in the image process fn doesn't help improve 
➥ performance
            rand_gamma = np.random.uniform(0.9, 1.08, (x.shape[0],1,1,1))  ❸
            x = x**rand_gamma                                              ❸

        if random_occlude:                                                 ❹
            # Randomly occluding sections in the image
            occ_size = 10
            occ_h, occ_w = np.random.randint(0, x.shape[1]-occ_size), 
➥ np.random.randint(0, x.shape[2]-occ_size)                               ❺
            x[:,occ_h:occ_h+occ_size,occ_w:occ_w+occ_size,:] = 
➥ np.random.choice([0.,128.,255.])                                        ❻

        # Image centering
        x -= np.mean(x, axis=(1,2,3), keepdims=True)                       ❼

        yield x,(y,y,y)                                                    ❽

train_gen_aux = data_gen_augmented_inceptionnet_v1(
    train_gen, random_gamma=True, random_occlude=True                      ❾
)
valid_gen_aux = data_gen_augmented_inceptionnet_v1(valid_gen)              ❿
test_gen_aux = data_gen_augmented_inceptionnet_v1(test_gen)                ❿

❶ 定义一个新的函数,引入两种新的增强技术,并修改最终输出的格式。

❷ 检查是否需要伽马校正增强。

❸ 执行伽马校正相关的数据增强。

❹ 检查是否需要随机遮挡数据增强。

❺ 随机定义遮挡的起始 x/y 像素。

❻ 随机为遮挡覆盖添加白色/灰色/黑色。

❼ 对之前关闭的样本居中进行样本级居中。

❽ 确保我们复制目标(y)三次

❾ 训练数据使用随机 gamma 校正和遮挡进行增强。

❿ 验证/测试集不进行增强。

你可以看到data_gen_augmented_inceptionnet_v1返回单个输入(x)和相同输出的三个副本(y)。除了修改输出的格式外,data_gen_augmented_inceptionnet_v1还将使用自定义实现包括两个额外的数据增强步骤(这些步骤不是内置的):

  • Gamma 校正—标准的计算机视觉转换,通过将像素值提高到某个值的幂次方来执行(mng.bz/lxdz)。在我们的情况下,我们在 0.9 和 1.08 之间随机选择这个值。

  • 随机遮挡—我们将在图像上随机遮挡一个随机的补丁(10 × 10),用白色、灰色或黑色像素(随机选择)。

当我们定义 ImageDataGenerator 时,也需要对图像进行居中处理,因为我们将 samplewise_center 参数设置为 False。这通过从每个图像的像素中减去其平均像素值来完成。定义了 data_gen_augmented_inceptionnet_v1 函数后,我们可以为训练/验证/测试数据分别创建修改后的数据生成器 train_gen_aux、valid_gen_aux 和 test_gen_aux。

检查,检查,检查以避免模型性能缺陷

如果你不检查只有训练数据是否被增强,那你可能会陷入麻烦。如果它不能按预期工作,它很容易被忽视。从技术上讲,你的代码是正常工作的,并且没有功能性的 bug。但这会让你在实际情况下不断琢磨为什么模型没有按预期执行。

最后,这个过程中最重要的步骤是验证数据增强是否按照我们的期望进行,而不会以意想不到的方式破坏图像,这会妨碍模型的学习。为此,我们可以绘制由训练数据生成器生成的一些样本以及验证数据生成器生成的样本。我们不仅需要确保数据增强正常工作,还需要确保验证集中不存在数据增强。图 7.3 确保了这一点。

07-03

图 7.3 在增强步骤之后训练数据和验证数据之间的差异。该图清楚地显示了应用于训练数据但未应用于验证数据的各种变换,正如我们所预期的那样。

接下来,我们讨论另一种正则化技术称为 dropout。

7.1.2 Dropout:随机关闭网络的部分以提高泛化能力

现在我们将学习一种称为dropout的技术,以进一步减少过拟合。 Dropout 是 Inception net v1 的一部分,但在前一章中,我们避免使用 dropout 以提高清晰度。

Dropout 是一种用于深度网络的正则化技术。正则化技术的作用是控制深度网络,使其在训练过程中摆脱数值错误或者像过拟合这样的麻烦现象。本质上,正则化使深度网络行为良好。

辍学在每次训练迭代期间随机关闭输出神经元。这有助于模型在训练期间学习冗余特征,因为它不总是能够使用先前学到的特征。换句话说,网络在任何给定时间只有部分参数的全网络可学习,并迫使网络学习多个(即冗余的)特征来分类对象。例如,如果网络试图识别猫,那么在第一次迭代中它可能学习关于胡须的知识。然后,如果与胡须知识相关的节点被关闭,它可能学习关于猫耳朵的知识(见图 7.4)。这导致网络学习了冗余/不同的特征,如胡须、两只尖耳朵等,从而在测试时间表现更好。

07-04

图 7.4 当学习分类猫图像时,辍学如何改变网络。在第一次迭代中,它可能学习有关胡须的知识。在第二次迭代中,由于包含有关胡须信息的部分被关闭,网络可能学习有关尖耳朵的知识。这使网络在测试时具有关于胡须和耳朵的知识。在这种情况下是好的,因为在测试图像中,你看不到猫的胡须!

在每个要应用辍学的层上应用随机的 1 和 0 掩码关闭节点(见图 7.5)。在训练过程中,您还需要对活动节点进行重要的规范化步骤。假设我们正在训练一个辍学率为 50% 的网络(即在每次迭代中关闭一半的节点)。当你的网络关闭了 50% 时,从概念上讲,你的网络总输出会减少一半,与完整网络相比。因此,您需要将输出乘以一个因子 2,以确保总输出保持不变。辍学的这些计算细节在图 7.5 中突出显示。好消息是,您不必实现任何计算细节,因为 TensorFlow 中提供了辍学作为一个层。

07-05

图 7.5 辍学如何运作的计算视角。如果辍学设置为 50%,则每个层中的一半节点(除了最后一层)将被关闭。但在测试时,所有节点都被打开。

Inception 网 v1(见图 7.6)只对全连接层和最后一个平均池化层应用辍学。记住不要在最后一层(即提供最终预测的层)上使用辍学。要执行两个更改:

  • 在辅助输出中的中间全连接层应用 70% 的辍学。

  • 对最后一个平均池化层的输出应用 40% 的 dropout。

07-06

图 7.6 Inception 网络 v1 的抽象架构。Inception 网络以一个称为干线的组件开始,这是一个典型 CNN 中会找到的普通的卷积/池化层序列。然后 Inception 网络引入了一个称为 Inception 块的新组件。最后,Inception 网络还利用了辅助输出层。

在 TensorFlow 中,应用 dropout 就像写一行代码一样简单。一旦你得到了全连接层 dense1 的输出,你就可以使用以下方法应用 dropout:

dense1 = Dropout(0.7)(dense1)

在这里,我们使用了 70% 的 dropout 率(正如原始 Inception 网络 v1 论文中建议的)用于辅助输出。

卷积层上的 dropout

Dropout 主要应用在密集层上,所以人们不禁会想,“为什么我们不在卷积层上应用 dropout 呢?”这仍然是一个争论未决的问题。例如,Nitish Srivastava 等人的原始 dropout 论文(mng.bz/o2Nv)认为,在低卷积层上使用 dropout 可以提高性能。相反,Yarin Gal 等人的论文“具有伯努利近似变分推断的贝叶斯 CNN”(arxiv.org/pdf/1506.02158v6.pdf)认为,在卷积层上应用 dropout 并不会有太大帮助,因为它们的参数数量较低(与密集层相比),已经很好地被正则化了。因此,dropout 可以阻碍卷积层的学习。你需要考虑的一件事是出版时间。dropout 论文是在贝叶斯 CNN 论文之前两年写的。在那段时间内引入的正则化和其他改进可能对改进深度网络产生了重大影响,因此,在卷积层中使用 dropout 的好处可能变得微不足道。你可以在 mng.bz/nNQ4 找到更多非正式的讨论。

辅助输出的最终代码如下列表所示。

列表 7.4 修改了 Inception 网络的辅助输出

def aux_out(inp,name=None):    
    avgpool1 = AvgPool2D((5,5), strides=(3,3), padding='valid')(inp)
    conv1 = Conv2D(128, (1,1), activation='relu', padding='same')(avgpool1)
    flat = Flatten()(conv1)
    dense1 = Dense(1024, activation='relu')(flat) 
    dense1 = Dropout(0.7)(dense1)                     ❶
    aux_out = Dense(200, activation='softmax', name=name)(dense1)
    return aux_out

❶ 应用了 70% 的 dropout 层

接下来,我们将在最后一个平均池化层的输出上应用 dropout,然后是最后的预测层。在将平均池化层的输出(flat_out)馈送到全连接(即密集)层之前,我们必须将其展平。然后,使用以下方法在 flat_out 上应用 dropout:

flat_out = Dropout(0.4)(flat_out)

对于这一层,我们使用了 40% 的 dropout 率,正如论文中所建议的一样。最终的代码(从平均池化层开始)如下所示:

avgpool1 = AvgPool2D((7,7), strides=(1,1), padding='valid')(inc_5b)

flat_out = Flatten()(avgpool1)
flat_out = Dropout(0.4)(flat_out)
out_main = Dense(200, activation='softmax', name='final')(flat_out)

这就结束了对 dropout 的讨论。要牢记的最后一点是,你不应该简单地设置 dropout 率。应该通过超参数优化技术来选择。非常高的 dropout 率会严重削弱你的网络,而非常低的 dropout 率则不会有助于减少过拟合。

7.1.3 早停:如果网络开始表现不佳,则停止训练过程

我们将要介绍的最后一种技术叫做早停(early stopping)。顾名思义,早停会在验证准确度不再提高时停止模型训练。你可能会想:“什么?我以为训练越多越好。”在达到某一点之前,训练得越多越好,但是之后,训练开始降低模型的泛化能力。图 7.7 展示了在训练模型过程中你会获得的典型训练准确度和验证准确度曲线。你可以看到,在某一点之后,验证准确度停止提高并开始下降。这标志着过拟合的开始。你可以看到,无论验证准确度如何,训练准确度都在持续上升。这是因为现代深度学习模型具有足够多的参数来“记住”数据,而不是学习数据中存在的特征和模式。

07-07

图 7.7:过拟合的示意图。在开始时,随着训练迭代次数的增加,训练和验证准确度都会提高。但是在某个时刻之后,验证准确度会趋于平稳并开始下降,而训练准确度则持续上升。这种行为称为过拟合,应该避免。

早停过程非常简单易懂。首先,你定义一个最大的训练轮数。然后模型训练一轮。训练之后,使用评估指标(例如准确度)在验证集上评估模型。如果验证准确度提高了并且还没有达到最大 epoch,则继续训练。否则,停止训练,并完成模型。图 7.8 描述了早停的工作流程。

07-08

图 7.8:早停期间的工作流程。首先,模型训练一轮。然后,测量验证准确度。如果验证准确度提高了并且训练还没有达到最大 epoch,则继续训练。否则,停止训练。

实施早停需要对你的代码进行最小的更改。首先,和之前一样,我们将建立一个计算步数的函数:

def get_steps_per_epoch(n_data, batch_size):
    """ Given the data size and batch size, gives the number of steps to 
➥ travers the full dataset """
    if n_data%batch_size==0:
        return int(n_data/batch_size)
    else:
        return int(n_data*1.0/batch_size)+1

接下来,我们将使用 Keras 提供的 EarlyStopping 回调(mng.bz/v6lr)来在训练过程中启用早停。Keras 回调是在每个 epoch 结束时让某些事情发生的简单方法。例如,对于早停,我们只需在每个 epoch 结束时分析验证准确度,如果没有显示任何改善,就终止训练。回调是实现这一目标的理想选择。我们已经使用了 CSVLogger 回调来记录每个 epoch 的指标数量。EarlyStopping 回调有几个参数:

  • monitor—需要监测的指标以终止训练。可以使用 Keras 模型的 model.metric_names 属性获取定义的指标名称列表。在我们的示例中,这将设置为 val_loss(即在验证数据上计算的损失值)。

  • min_delta—被监测指标所需的最小改变,以被视为改进(即任何改进<min_delta 将被视为“没有改进” [默认为零])。

  • patience—如果在这么多个 epochs 之后没有改进,则训练将停止(默认为零)。

  • mode—可以是 auto/min/max。在 min 中,如果指标停止减少(如损失),则训练将停止。在 max 中,如果指标停止增加(如准确度),则训练将停止。该模式将自动从指标名称中推断(默认为 auto)。

  • baseline—指标的基准值。如果指标未超出基准值,则训练将停止(默认为无)。

  • restore_best_weights—在训练开始和终止之间恢复显示选择指标的最佳权重结果(默认为 false)。

首先,如果不存在,我们将创建一个名为 eval 的目录。这将用于存储由 CSVLogger 返回的 CSV 文件:

# Create a directory called eval which stores model performance
if not os.path.exists('eval'):
    os.mkdir('eval')
# Logging the performance metrics to a CSV file
csv_logger = CSVLogger(os.path.join('eval','2_eval_data_aug_early_stopping.log'))

然后我们定义 EarlyStopping 回调函数。我们选择 val_loss 作为监测的指标,以及五个 epochs 的耐心。这意味着在五个 epochs 内训练将容忍“没有改进”。我们将保留其他参数为默认值:

# Early stopping callback
es_callback = EarlyStopping(monitor='val_loss', patience=5)

最后使用数据和适当的回调函数调用 model.fit()。在这里,我们使用先前定义的 train_gen_aux 和 valid_gen_aux 作为训练和验证数据(分别)。我们还将 epochs 设置为 50,并使用 get_steps_per_epoch 函数设置训练步数和验证步数。最后,我们提供 EarlyStopping 和 CSVLogger 回调函数,所以在指定条件下没有改进时训练停止:

history = model.fit(
    train_gen_aux, validation_data=valid_gen_aux, 
    steps_per_epoch=get_steps_per_epoch(int(0.9*(500*200)),batch_size),
    validation_steps=get_steps_per_epoch(int(0.1*(500*200)),batch_size),
    epochs=50, callbacks=[es_callback, csv_logger]
)

下一个清单展示了训练日志的摘要。

列表 7.5 在训练模型期间提供的训练日志

Train for 703 steps, validate for 78 steps
Epoch 1/50
WARNING:tensorflow:Large dropout rate: 0.7 (>0.5). In TensorFlow 2.x, 
➥ dropout() uses dropout rate instead of keep_prob. Please ensure that 
➥ this is intended.                                                      ❶
WARNING:tensorflow:Large dropout rate: 0.7 (>0.5). In TensorFlow 2.x, 
➥ dropout() uses dropout rate instead of keep_prob. Please ensure that 
➥ this is intended.                                                      ❶
WARNING:tensorflow:Large dropout rate: 0.7 (>0.5). In TensorFlow 2.x, 
➥ dropout() uses dropout rate instead of keep_prob. Please ensure that 
➥ this is intended.                                                      ❶
703/703 [==============================] - 196s 279ms/step - loss: 15.4462 
➥ - final_loss: 5.1507 - aux1_loss: 5.1369 - aux2_loss: 5.1586 - 
➥ final_accuracy: 0.0124 - aux1_accuracy: 0.0140 - aux2_accuracy: 0.0119 
➥ - val_loss: 14.8221 - val_final_loss: 4.9696 - val_aux1_loss: 4.8943 - 
➥ val_aux2_loss: 4.9582 - val_final_accuracy: 0.0259 - val_aux1_accuracy: 
➥ 0.0340 - val_aux2_accuracy: 0.0274
...
Epoch 38/50
703/703 [==============================] - 194s 276ms/step - loss: 
➥ 9.4647 - final_loss: 2.8825 - aux1_loss: 3.3037 - aux2_loss: 3.2785 - 
➥ final_accuracy: 0.3278 - aux1_accuracy: 0.2530 - aux2_accuracy: 0.2572 
➥ - val_loss: 9.7963 - val_final_loss: 3.1555 - val_aux1_loss: 3.3244 - 
➥ val_aux2_loss: 3.3164 - val_final_accuracy: 0.2940 - val_aux1_accuracy: 
➥ 0.2599 - val_aux2_accuracy: 0.2590

❶ 因为我们对一些层使用了高达 70%的丢失率,TensorFlow 会对此进行警告,因为意外的高丢失率可能会影响模型的性能。

看起来模型没有在 50 个 epochs 中训练到利益。在第 38 个 epoch 之后,它决定终止训练。这在于训练在达到第 50 个 epoch 之前停止(如第 38/50 行所示)。另一个重要的观察结果是,你可以看到训练准确度没有像我们在上一章中看到的那样激增到很高的值。训练准确度一直与验证准确度(~30%)相当接近。尽管我们没有看到很大的性能提升,但我们成功地显著减少了过拟合。因此,我们可以着重提高准确度。

注意 在一台配备 Intel Core i5 处理器和 NVIDIA GeForce RTX 2070 8GB 显卡的机器上,训练大约需要 1 小时 30 分钟才能完成 38 个周期。

接下来,我们将重新审视我们的模型。我们将深入研究一些研究,并实现一个已经被证明在这个特定分类问题上运行良好的模型。

练习 1

你手头有一个模型呈现给你,你发现它严重欠拟合。欠拟合发生在你的模型没有足够近似数据分布时。建议你如何改变 dropout 层以减少欠拟合。你可以选择 20%、50%和 80%作为 dropout 率:

model = tf.keras.models.Sequential([
tf.keras.layers.Dense(100, activation=’relu’, input_shape=(250,)),
tf.keras.layers.Dropout(0.5), 
tf.keras.layers.Dense(10, activation=’softmax’)
])
model.compile
    (loss=’categorical_crossentropy’, optimizer=’adam’, metrics=[‘accuracy’])
model.fit(X, y, epochs=25)

练习 2

定义一个早停回调函数,如果验证损失值(即 val_loss)在五个周期后没有增加 0.01,则终止训练。为此目的使用 tf.keras.callbacks.EarlyStopping 回调函数。

7.2 朝向极简主义:Minception 而不是 Inception

我们现在有一个几乎不存在过拟合的模型。然而,模型的测试性能仍然没有达到我们想要的水平。你觉得你需要对这个问题有一个新的视角,并咨询团队中的一位高级数据科学家。你解释了你如何在 tiny-imagenet-200 图像分类数据集上训练了一个 Inception net v1 模型,以及模型的性能不佳。他提到他最近读过一篇论文(cs231n.stanford.edu/reports/2017/pdfs/930.pdf),该论文使用了一个受 Inception-ResNet v2 启发的修改版本的 Inception 网络,在数据集上取得了更好的性能。

他进一步解释了两种新技术,批量标准化和残差连接(它们在修改后的 Inception 网络以及 Inception-ResNet v2 中使用),以及它们在帮助模型训练方面产生的重大影响,特别是在深度模型中。现在,你将实现这个新的修改后的模型,看看它是否会提高性能。

我们看到验证和测试准确率略有提高。但是在性能方面,我们仍然只是触及到了表面。例如,有关这个数据集的测试准确率约为 85%(mng.bz/44ev)。因此,我们需要寻找其他提高模型性能的方法。

你与团队中的高级数据科学家进行的那次会议简直再好不过了。我们将尝试他所读过的新网络。

这个网络主要受到了前一章节简要提及的 Inception-Resnet-v2 网络的启发。这个新网络(我们将其称为 Minception)利用了 Inception-ResNet v2 模型中使用的所有最先进的组件,并对它们进行了修改以适应手头的问题。在本节中,你将深入了解这个新模型。特别是,Minception 网络具有以下元素:

  • 一个干扰项

  • Inception-ResNet 块 A

  • Inception-ResNet 块 B

  • 减少块(一种新的用于减少输出大小的块)

  • 平均池化层

  • 最终预测层

与其他 Inception 模型一样,这个模型也有一个干部和 Inception 块。但是,Minception 与 Inception Net v1 不同,因为它没有辅助输出,因为它们有其他稳定训练的技术。另一个值得注意的区别是,Minception 有两种类型的 Inception 块,而 Inception Net v1 在整个网络中重用相同的格式。在讨论 Minception 的不同方面时,我们将更详细地与我们实现的 Inception Net v1 进行比较。在后面的章节中,我们将更详细地讨论 Inception-ResNet v2 模型的架构,并将其与 Minception 进行比较。此代码可在 Ch07-Improving-CNNs-and-Explaining/7.1.Image_Classification_Advance.ipynb 中找到。

7.2.1 实施干部

首先,我们应该关注模型的干部。为了更新我们的知识,干部是一系列卷积和池化层,类似于典型的 CNN。然而,Minception 的布局更加复杂,如图 7.9 所示。

07-09

图 7.9 比较 Minception 和 Inception-v1 的干部。请注意 Minception 如何分离卷积层的非线性激活。这是因为批量归一化必须插入到卷积输出和非线性激活之间。

您可以看到它在干部上有并行的卷积层流。Minception 的干部与 Inception Net v1 相比非常不同。另一个关键区别是 Minception 不使用局部响应归一化(LRN),而是使用更强大的批量归一化。

Batch normalization:一种多功能的归一化技术,用于稳定和加速深度网络的训练。

批量归一化(BN)是由 Sergey Ioffe 等人在论文“Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift”中引入的。正如其名称所示,它是一种归一化技术,用于归一化深度网络的中间输出。(proceedings.mlr.press/v37/ioffe15.pdf)

“你可能会问,这很重要吗?” 结果深度网络如果没有得到正确的关注,可能会导致严重的头痛。例如,在训练期间,一批未正确缩放/异常输入或不正确的权重初始化可能导致模型不佳。此外,此类问题可能会随着网络深度或时间的推移而放大,导致每个层接收到的输入分布随时间而改变。输入分布随时间发生变化的现象称为协变量转移。这在流数据问题中非常常见。批量归一化是为了解决这个问题而发明的。让我们了解一下 BN 如何解决这个问题。批量归一化层执行以下操作:

  • 使用 x^((k)),网络的第 k^(th) 层的输出进行归一化

  • 07_09a

  • 这里,E[x^((k))] 表示输出的平均值,Var[x^((k))] 表示输出的方差。E[x^((k))] 和 Var[x^((k))] 都是向量。对于具有 n 个节点的全连接层,E[x^((k))] 和 Var[x^((k))] 都是长度为 n 的向量(通过对批次维度求平均计算)。对于具有 f 个滤波器/卷积核的卷积层,E[x^((k))] 和 Var[x^((k))] 将是长度为 f 的向量(通过对批次、高度和宽度维度求平均计算)。

  • 使用两个可训练的超参数 γβ(分别针对每一层)来缩放和偏移归一化后的输出,如下所示:

  • y^((k)) = γ((k))*x̂*((k)) + β^((k))

  • 在这个过程中,计算 E(x) 和 Var(x) 会有些棘手,因为在训练和测试阶段需要对它们进行不同处理。

  • 在训练过程中,根据训练的随机性(即一次只查看一个随机数据批次而不是整个数据集),对于每个批次,只使用该批次的数据计算 E(x)(平均值)和 Var(x)(方差)。因此,对于每个批次,你可以计算出 E(x) 和 Var(x)(不必担心除了当前批次以外的任何事情)。

  • 然后,利用每个数据批次计算出的 E(x) 和 Var(x),我们估算出了总体的 E(x) 和 Var(x)。这是通过计算 E(x) 和 Var(x) 的运行均值来实现的。我们不会讨论运行均值的工作原理。但你可以想象运行均值是对大数据集的真实均值的高效计算的近似表示。

  • 在测试阶段,我们使用之前计算出的基于总体的 E(x) 和 Var(x),并执行之前定义的计算以获得 y^((k))。

由于批归一化涉及的步骤复杂,从头开始实现会需要相当多的工作。幸运的是,你不必这样做。TensorFlow 提供了一个批归一化层(mng.bz/Qv0Q)。如果你有某些密集层的输出(我们称之为 dense1)要应用批归一化,你只需要

dense1_bn = BatchNormalization()(dense1)

然后 TensorFlow 将自动处理批归一化需要在内部发生的所有复杂计算。现在是时候在我们的 Minception 模型中使用这一强大的技术了。在下一个列表中,你可以看到 Minception 网络的基干的实现。我们将编写一个名为 stem 的函数,它允许我们随意开启/关闭批归一化。

列表 7.6 定义 Minception 的基干

def stem(inp, activation='relu', bn=True):                              ❶

    conv1_1 = Conv2D(
        32, (3,3), strides=(2,2), activation=None,                      ❷
        kernel_initializer=init, padding='same'
    )(inp) #62x62
    if bn:
        conv1_1 = BatchNormalization()(conv1_1)                         ❸
    conv1_1 = Activation(activation)(conv1_1)                           ❹

    conv1_2 = Conv2D(
        32, (3,3), strides=(1,1), activation=None,                      ❷
        kernel_initializer=init, padding='same'
    )(conv1_1) # 31x31
    if bn:
        conv1_2 = BatchNormalization()(conv1_2)
    conv1_2 = Activation(activation)(conv1_2)

    conv1_3 = Conv2D(
        64, (3,3), strides=(1,1), activation=None,                      ❷
           kernel_initializer=init, padding='same'
       )(conv1_2) # 31x31
    if bn:
        conv1_3 = BatchNormalization()(conv1_3)
    conv1_3 = Activation(activation)(conv1_3)

    maxpool2_1 = MaxPool2D((3,3), strides=(2,2), 
➥ padding='same')(conv1_3)                                             ❺

    conv2_2 = Conv2D(
        96, (3,3), strides=(2,2), activation=None,
        kernel_initializer=init, padding='same'
    )(conv1_3)                  
    if bn:
        conv2_2 = BatchNormalization()(conv2_2)
    conv2_2 = Activation(activation)(conv2_2)                           ❺

    out2 = Concatenate(axis=-1)([maxpool2_1, conv2_2])                  ❻

    conv3_1 = Conv2D(
        64, (1,1), strides=(1,1), activation=None, 
        kernel_initializer=init, padding='same'
    )(out2)                                                             ❼
    if bn:
        conv3_1 = BatchNormalization()(conv3_1)
    conv3_1 = Activation(activation)(conv3_1)

    conv3_2 = Conv2D(
        96, (3,3), strides=(1,1), activation=None, 
        kernel_initializer=init, padding='same'
    )(conv3_1)                                                          ❼
    if bn:
        conv3_2 = BatchNormalization()(conv3_2)
    conv3_2 = Activation(activation)(conv3_2)

    conv4_1 = Conv2D(
        64, (1,1), strides=(1,1), activation=None, 
        kernel_initializer=init, padding='same'
    )(out2)                                                             ❽
    if bn:
        conv4_1 = BatchNormalization()(conv4_1)
    conv4_1 = Activation(activation)(conv4_1)

    conv4_2 = Conv2D(
        64, (7,1), strides=(1,1), activation=None, 
        kernel_initializer=init, padding='same'
    )(conv4_1)                                                          ❽
    if bn:
        conv4_2 = BatchNormalization()(conv4_2)

    conv4_3 = Conv2D(
        64, (1,7), strides=(1,1), activation=None, 
        kernel_initializer=init, padding='same'
    )(conv4_2)                                                          ❽
    if bn:
        conv4_3 = BatchNormalization()(conv4_3)
    conv4_3 = Activation(activation)(conv4_3)

    conv4_4 = Conv2D(
        96, (3,3), strides=(1,1), activation=None, 
        kernel_initializer=init, padding='same'
    )(conv4_3)                                                          ❽
    if bn:
        conv4_4 = BatchNormalization()(conv4_4)
    conv4_4 = Activation(activation)(conv4_4)

    out34 = Concatenate(axis=-1)([conv3_2, conv4_4])                    ❾

    maxpool5_1 = MaxPool2D((3,3), strides=(2,2), padding='same')(out34) ❿
    conv6_1 = Conv2D(
        192, (3,3), strides=(2,2), activation=None,       
        kernel_initializer=init, padding='same'
    )(out34)                                                            ❿
    if bn:
        conv6_1 = BatchNormalization()(conv6_1)
    conv6_1 = Activation(activation)(conv6_1)

    out56 = Concatenate(axis=-1)([maxpool5_1, conv6_1])                 ❿

    return out56

❶ 定义函数。请注意我们可以随时开启/关闭批归一化。

❷ 到第一个分支的基干的第一部分

❸ 请注意,在应用非线性激活之前,先应用第一个批归一化。

❹ 非线性激活应用于批归一化步骤后的层。

❺ 第一个分支的两个平行流

❻ 连接第一个分割的两个并行流的输出

❼ 第二个分割的第一个流

❽ 第二个分割的第二个流

❾ 连接第二个分割的两个流的输出

❿ 第三个(最终分割)和输出的连接

一个关键变化需要注意,即每一层的非线性激活与层本身分开。这样做是为了能够在层的输出和非线性激活之间插入批量归一化。这是应用批量归一化的原始方式,正如原始论文中所讨论的那样。但是批量归一化应该在非线性激活之前还是之后是一个持续讨论的问题。您可以在mng.bz/XZpp上找到关于这个主题的非正式讨论。

7.2.2 实现 Inception-ResNet 类型 A 块

在我们已经讨论了网络的干部后,让我们继续看看在 Minception 网络中 Inception 块是什么样子的。让我们快速回顾一下 Inception 块是什么以及为什么会开发它。Inception 块的开发旨在最大化卷积层的表示能力,同时鼓励模型参数的稀疏性,而不会使内存需求激增。它通过具有不同感知域大小(即内核大小)的几个并行卷积层来实现这一点。Minception 网络中的 Inception 块主要使用相同的框架。但是,它引入了一个新概念,称为残差连接

残差/跳过连接:稳定梯度的捷径

我们已经简要讨论了残差连接,它引入了数学中可以想象的最简单的操作之一:将输入逐元素添加到输出中。换句话说,您取网络的前一个输出(称为 x)并将其添加到当前输出(称为 y)中,因此您得到最终输出 z 为 z = x + y。

07-09-unnumb-1

如何在卷积层之间添加跳过/残差连接

在实现残差连接时需要注意的一点是确保它们的尺寸匹配,因为这是逐元素的加法。

残差连接的数学观点是什么?

起初可能不太明显,但是跳过连接中的残差之处并不清楚。假设以下情景。您有一个输入 x;接下来您有一些层,F(x) = y,它将输入 x 映射到 y。您实现了以下网络。

07-09-unnumb-2

残差连接的数学观点

y[k] = F(x)

y[k] [+ 1] = F(y[k])

y[k] [+ 2] = y[k] [+ 1] + x

y[k] [+ 2] = y[k] [+ 1] + G(x); 让我们将残差连接视为一个执行恒等映射的层,并将其称为 G。

y[k] [+ 2] - y[k] [+ 1] = G(x) 或

G(x) = y[k] [+ 2] - y[k] [+ 1]; 实际上,G 代表了最终输出和前一个输出之间的残差。

通过将最终输出视为一个将 x 和 y[k] [+ 1] 作为输入的层 H,我们得到以下方程:

G(x) = H(x, y[k] [+ 1]) - F(y[k])

你可以看到残差是如何发挥作用的。本质上,G(x) 是最终层输出与上一层输出之间的残差

实现残差连接再简单不过了。假设你有以下网络:

from tensorflow.keras.layers import Dense, Input, Add

inp = Input(shape=(10,))
d1 = Dense(20, activation='relu')(inp)
d2 = Dense(20, activation='relu')(d1)
d3 = Dense(20, activation='relu')(d2)

你想要从 d1 到 d3 创建一个残差连接。你所需要做的就是

d4 = d3 + d1

或者,如果你想使用一个 Keras 层(与上一个操作等效),你可以这样做

d4 = Add()([d3, d1])

现在你明白了:d4 是一个残差连接的输出。你可能还记得我说过,为了添加残差连接,输出尺寸必须匹配。我们尝试添加两个不兼容的形状。例如,让我们将 Dense 层的节点数从 20 改为 30:

inp = Input(shape=(10,))
d1 = Dense(20, activation='relu')(inp)
d2 = Dense(20, activation='relu')(d1)
d3 = Dense(30, activation='relu')(d2)
d4 = Add()([d3, d1])

如果你尝试运行这段代码,你将会得到以下错误:

---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
...
----> d4 = Add()([d3, d1])
...
ValueError: Operands could not be broadcast together with shapes (30,) (20,)

正如你所看到的,TensorFlow 抱怨它无法广播(在这种情况下,这意味着执行逐元素加法)两个形状分别为 30 和 20 的张量。这是因为 TensorFlow 不知道如何将一个形状为(batch_size,20)的张量与一个形状为(batch_size,30)的张量相加。如果在尝试实现残差连接时出现类似的错误,你应该检查网络输出,并确保它们匹配。要消除此错误,你所需要做的就是按照以下方式更改代码:

inp = Input(shape=(10,))
d1 = Dense(20, activation='relu')(inp)
d2 = Dense(20, activation='relu')(d1)
d3 = Dense(20, activation='relu')(d2)
d4 = Add()([d3, d1])

Minception 有两种类型的 Inception 块(类型 A 和类型 B)。现在让我们将 Inception-ResNet 块(类型 A)写成一个名为 inception_resnet_a 的函数。与之前实现的 Inception 块相比,这个新的 inception 块有以下增加:

  • 使用批量归一化

  • 使用一个从输入到块的最终输出的残差连接

图 7.10 比较 Minception 的 Inception-ResNet 块类型 A 与 Inception Net v1。一个明显的区别是 Inception Net v1 不利用残差连接的优势。

07-10

图 7.10 比较 Inception-ResNet 块 A(Minception)和 Inception 网 v1 的 Inception 块

现在让我们实现 Minception-ResNet 块 A。图 7.11 显示了需要实现的计算类型及其连接性(清单 7.7)。

07-11

图 7.11 Minception-ResNet 块 A 的示意图,带有代码清单 7.7 的注释

清单 7.7 Minception-ResNet 块 A 的实现

def inception_resnet_a(inp, n_filters, initializer, activation='relu', 
➥ bn=True, res_w=0.1):
    out1_1 = Conv2D(
        n_filters[0][0], (1,1), strides=(1,1), 
➥ activation=None, 
        kernel_initializer=initializer, 
➥ padding='same'
    )(inp)                                                        ❶
    if bn:
        out1_1 = BatchNormalization()(out1_1)
    out1_1 = Activation(activation)(out1_1)                       ❶

    out2_1 = Conv2D(
        n_filters[1][0], (1,1), strides=(1,1), 
➥ activation=None, 
        kernel_initializer=initializer, padding='same'
    )(inp)                                                        ❷
    if bn:
        out2_1 = BatchNormalization()(out2_1)
    out2_1 = Activation(activation)(out2_1)                       ❷

    out2_2 = Conv2D(
        n_filters[1][1], (1,1), strides=(1,1), activation=None, 
        kernel_initializer=initializer, padding='same'
    )(out2_1)                                                     ❷
    if bn:
        out2_2 = BatchNormalization()(out2_2)
    out2_2 = Activation(activation)(out2_2)                       ❷

    out2_3 = Conv2D(
        n_filters[1][2], (1,1), strides=(1,1), activation=None, 
        kernel_initializer=initializer, padding='same'
    )(out2_2)                                                     ❷

    out3_1 = Conv2D(
        n_filters[2][0], (1,1), strides=(1,1), activation=None, 
        kernel_initializer=initializer, padding='same'
    )(inp)                                                        ❸
    if bn:
        out3_1 = BatchNormalization()(out3_1)
    out3_1 = Activation(activation)(out3_1)                       ❸

    out3_2 = Conv2D(
        n_filters[2][1], (3,3), strides=(1,1), activation=None, 
        kernel_initializer=initializer, padding='same'
    )(out3_1)                                                     ❸
    if bn:
        out3_2 = BatchNormalization()(out3_2)
    out3_2 = Activation(activation)(out3_2)                       ❸

    out3_3 = Conv2D(
        n_filters[2][2], (3,3), strides=(1,1), activation=None, 
        kernel_initializer=initializer, padding='same'
    )(out3_2)                                                     ❸
    if bn:
        out3_3 = BatchNormalization()(out3_3)
    out3_3 = Activation(activation)(out3_3)                       ❸

    out3_4 = Conv2D(
        n_filters[2][3], (1,1), strides=(1,1), activation=None, 
        kernel_initializer=initializer, padding='same'
    )(out3_3)                                                     ❸
    if bn:
        out3_4 = BatchNormalization()(out3_4)
    out3_4 = Activation(activation)(out3_4)                       ❸

    out4_1 = Concatenate(axis=-1)([out1_1, out2_2, out3_4])       ❹
    out4_2 = Conv2D(
        n_filters[3][0], (1,1), strides=(1,1), activation=None, 
        kernel_initializer=initializer, padding='same'
    )(out4_1)
    if bn:
        out4_2 = BatchNormalization()(out4_2)                               

    out4_2 += res_w * inp                                         ❺
    out4_2 = Activation(activation)(out4_2)                       ❺

        return out4_2

❶ 块中的第一个并行流

❷ 块中的第二个并行流

❸ 块中的第三个并行流

❹ 将三个独立流的输出连接起来。

❺ 合并残差连接(乘以一个因子以改善梯度流动)。

尽管函数看起来很长,但主要是使用卷积层进行乐高堆砌。 图 7.11 为您提供了视觉 Inception 层与代码之间的思维映射。 一个关键观察是批量标准化和非线性激活(ReLU)如何应用于块的顶部部分。 最后的 1×1 卷积使用批量标准化,而不是非线性激活。 非线性激活仅在残余连接之后应用。

现在我们要看看如何实现 Inception-ResNet B 块。

7.2.3 实现 Inception-ResNet 类型 B 块

接下来是 Minception 网络中的 Inception-ResNet 类型 B 块。 我们不会详细讨论这个,因为它与 Inception-ResNet A 块非常相似。 图 7.12 描述了 Inception-ResNet B 块并将其与 Inception-ResNet A 块进行了比较。 块 B 看起来相对简单,只有两个并行流。 代码相关的注释帮助您将 Inception 块的思维模型映射到代码中,如下列表所示。

07-12

图 7.12 Minception 的 Inception-ResNet 块 B(左)和 Minception 的 Inception-ResNet 块 A(右)并排放在一起

列表 7.8 Minception-ResNet 块 B 的实现

def inception_resnet_b(inp, n_filters, initializer, activation='relu', 
➥ bn=True, res_w=0.1):
    out1_1 = Conv2D(
        n_filters[0][0], (1,1), strides=(1,1), activation=None, 
        kernel_initializer=initializer, padding='same'
    )(inp)
    if bn:
        out1_1 = BatchNormalization()(out1_1) 
    out1_1 = Activation(activation)(out1_1)                      ❶

    out2_1 = Conv2D(
        n_filters[1][0], (1,1), strides=(1,1), activation=activation, 
        kernel_initializer=initializer, padding='same'
    )(inp)
    if bn:
        out2_1 = BatchNormalization()(out2_1)
    out2_1 = Activation(activation)(out2_1)                      ❷

    out2_2 = Conv2D(
        n_filters[1][1], (1,7), strides=(1,1), activation=None, 
        kernel_initializer=initializer, padding='same'
    )(out2_1)
    if bn:
        out2_2 = BatchNormalization()(out2_2)
    out2_2 = Activation(activation)(out2_2)                      ❷

    out2_3 = Conv2D(
        n_filters[1][2], (7,1), strides=(1,1), activation=None, 
        kernel_initializer=initializer, padding='same'
    )(out2_2)
    if bn:
        out2_3 = BatchNormalization()(out2_3)
    out2_3 = Activation(activation)(out2_3)                      ❷

    out3_1 = Concatenate(axis=-1)([out1_1, out2_3])              ❸
    out3_2 = Conv2D(
        n_filters[2][0], (1,1), strides=(1,1), activation=None, 
        kernel_initializer=initializer, padding='same'
    )(out3_1)
    if bn:
        out3_2 = BatchNormalization()(out3_2)                    ❹

    out3_2 += res_w * inp                                        ❺
    out3_2 = Activation(activation)(out3_2)

    return out3_2

❶ 块中的第一个并行流

❷ 块中的第二个并行流

❸ 将来自两个并行流的结果连接起来。

❹ 连接结果顶部的最终卷积层

❺ 应用了加权残差连接

这与函数 inception_resnet_a(...)非常相似,具有两个并行流和残余连接。 需要注意的区别是类型 A 块的卷积层数量比类型 B 块多。 另外,类型 A 块使用 5×5 卷积(分解为两个 3×3 卷积层),而类型 B 使用 7×7 卷积(分解为 1×7 和 7×1 卷积层)。 我将让读者自行详细了解该函数。

7.2.4 实现减少块

受 Inception-ResNet 模型的启发,Minception 也使用减少块。 减少块与 ResNet 块非常相似,唯一的区别是块中没有残余连接(见下一列表)。

列表 7.9 Minception 的减少块的实现

def reduction(inp, n_filters, initializer, activation='relu', bn=True):
    # Split to three branches
    # Branch 1
    out1_1 = Conv2D(
        n_filters[0][0], (3,3), strides=(2,2), 
        kernel_initializer=initializer, padding='same'
    )(inp)  
    if bn:
        out1_1 = BatchNormalization()(out1_1)
    out1_1 = Activation(activation)(out1_1)                         ❶

    out1_2 = Conv2D(
        n_filters[0][1], (3,3), strides=(1,1), 
        kernel_initializer=initializer, padding='same'
    )(out1_1)
    if bn:
        out1_2 = BatchNormalization()(out1_2)
    out1_2 = Activation(activation)(out1_2)                         ❶

    out1_3 = Conv2D(
        n_filters[0][2], (3,3), strides=(1,1), 
        kernel_initializer=initializer, padding='same'
    )(out1_2)
    if bn:
        out1_3 = BatchNormalization()(out1_3)
    out1_3 = Activation(activation)(out1_3)                         ❶

    # Branch 2
    out2_1 = Conv2D(
        n_filters[1][0], (3,3), strides=(2,2), 
        kernel_initializer=initializer, padding='same'
    )(inp)
    if bn:
        out2_1 = BatchNormalization()(out2_1)
    out2_1 = Activation(activation)(out2_1)                         ❷

    # Branch 3
    out3_1 = MaxPool2D((3,3), strides=(2,2), padding='same')(inp)   ❸

    # Concat the results from 3 branches
    out = Concatenate(axis=-1)([out1_3, out2_1, out3_1])            ❹

    return out

❶ 卷积的第一个并行流

❷ 卷积的第二个并行流

❸ 池化的第三个并行流

❹ 将所有输出连接起来

我将让图 7.13 自己说明列表 7.9。但正如你所看到的,在抽象层面上,它使用了我们讨论过的 Inception 块相同类型的连接和层。

07-13

图 7.13 减少块的示意图

现在我们要看看如何通过汇总到目前为止实现的所有不同元素来完成 Minception 的拼图。

7.2.5 将所有内容组合在一起

到目前为止,工作进行得很顺利。随着所有基本块准备就绪,我们的 Minception 模型正在成形。接下来,将事物放在它们应该放置的地方就是问题。最终模型使用以下组件:

  • 单个干部

  • 1x Inception-ResNet 块 A

  • 2x Inception-ResNet 块 B

  • 平均池化

  • Dropout

  • 具有 200 个节点和 softmax 激活的最终预测层

此外,我们将对模型的输入进行一些更改。根据原始论文,该模型接收的是 56 × 56 × 3 大小的输入,而不是 64 × 64 × 3 大小的输入。通过以下方式实现:

  • 训练阶段 — 从原始 64 × 64 × 3 大小的图像中随机裁剪一个 56 × 56 × 3 大小的图像

  • 验证/测试阶段 — 从原始图像中心裁剪一个 56 × 56 × 3 大小的图像

此外,我们将在训练期间引入另一个增强步骤,随机对比图像(与论文中使用的相同)。不幸的是,您无法使用 ImageDataGenerator 实现这两个步骤中的任何一个。好消息是,自 TensorFlow 2.2 以来,引入了几个新的图像预处理层(mng.bz/yvzy)。我们可以像模型中的任何其他层一样使用这些层。例如,我们像以前一样从输入开始:

inp = Input(shape=(64,64,3))

然后导入 RandomCrop 和 RandomContrast 层,并按如下方式使用它们:

from tensorflow.keras.layers.experimental.preprocessing import RandomCrop, 
➥ RandomContrast
# Cropping the image to a 56x56 sized image
crop_inp = RandomCrop(56, 56, seed=random_seed)(inp)
# Provide a random contrast between 0.7 and 1.3 where 1.0 is the original 
➥ contrast
crop_inp = RandomContrast(0.3, seed=random_seed)(crop_inp)

最终模型如下所示。

列表 7.10 最终 Minception 模型

import tensorflow as tf
from tensorflow.keras.layers import Input, Conv2D, MaxPool2D, Dropout, 
➥ AvgPool2D, Dense, Concatenate, Flatten, BatchNormalization, Activation
➥ from tensorflow.keras.layers.experimental.preprocessing import RandomCrop, 
➥ RandomContrast
from tensorflow.keras.models import Model
from tensorflow.keras.losses import CategoricalCrossentropy
import tensorflow.keras.backend as K
from tensorflow.keras.callbacks import EarlyStopping, CSVLogger

inp = Input(shape=(64,64,3))                                       ❶

crop_inp = RandomCrop(56, 56, seed=random_seed)(inp)               ❷
crop_inp = RandomContrast(0.3, seed=random_seed)(crop_inp)         ❸

stem_out = stem(crop_inp)                                          ❹

inc_a = inception_resnet_a(stem_out, [(32,),(32,32), (32, 48, 64, 
➥ 384),(384,)], initializer=init)                                 ❺

red = reduction(inc_a, [(256,256,384),(384,)], initializer=init)   ❻

inc_b1 = inception_resnet_b(red, [(192,),(128,160,192),(1152,)], 
➥ initializer=init)                                               ❼
inc_b2 = inception_resnet_b(inc_b1,  [(192,),(128,160,192),(1152,)], 
➥ initializer=init)                                               ❼

avgpool1 = AvgPool2D((4,4), strides=(1,1), padding='valid')(inc_b2)
flat_out = Flatten()(avgpool1)
dropout1 = Dropout(0.5)(flat_out)
out_main = Dense(200, activation='softmax',  kernel_initializer=init, 
➥ name='final')(flat_out)                                         ❽

minception_resnet_v2 = Model(inputs=inp, outputs=out_main)         ❾
minception_resnet_v2.compile(loss=’categorical_crossentropy’, 
➥ optimizer='adam', metrics=['accuracy'])                         ❿

❶ 定义 64 × 64 输入层。

❷ 对输入进行随机裁剪(仅在训练期间激活随机性)。

❸ 在输入上执行随机对比度调整(仅在训练期间激活随机性)。

❹ 定义干部的输出。

❺ 定义 Inception-ResNet 块(类型 A)。

❻ 定义减少层。

❼ 定义 2 个 Inception-ResNet 块(类型 B)。

❽ 定义最终预测层。

❾ 定义模型。

❿ 使用分类交叉熵损失和 adam 优化器编译模型。

最后,我们的 Minception 模型已经准备就绪。它接收一个 64 × 64 × 3 大小的输入(与我们实现的其他模型相同)。然后在训练期间随机(在验证/测试期间居中)裁剪图像并应用随机对比度调整(在训练期间)。这些都会自动处理。接下来,处理后的输入进入网络的干部部分,产生输出干部输出,然后进入类型 A 的 Inception-ResNet 块并流入减少块。接下来,我们有两个连续的 Inception-ResNet 类型 B 块。然后是一个平均池化层,一个扁平化层,将除批次维度之外的所有维度压缩为 1。然后在输出上应用 50% 丢失率的 dropout 层。最后,具有 softmax 激活的 200 个节点的密集层生成最终输出。最后,使用分类交叉熵损失和 adam 优化器编译模型。

这结束了我们对 Minception 模型的讨论。您想知道这将如何提升我们模型的性能吗?在下一节中,我们将训练我们定义的 Minception 模型。

7.2.6 训练 Minception

现在我们开始训练模型了。训练过程与您已经为 Inception Net v1 模型所做的非常相似,只有一个区别。我们将使用学习率缩减计划进一步减少过拟合并改善泛化能力。在此示例中,如果模型的性能在预定义的持续时间内没有改善,学习率调度器将减少学习率(请参见下一个清单)。

清单 7.11 训练 Minception 模型

import time
from tensorflow.keras.callbacks import EarlyStopping, CSVLogger
from functools import partial

n_epochs=50

es_callback = EarlyStopping(monitor='val_loss', patience=10)              ❶
csv_logger = CSVLogger(os.path.join('eval','3_eval_minception.log'))      ❷
lr_callback = tf.keras.callbacks.ReduceLROnPlateau(
    monitor='val_loss', factor=0.1, patience=5, verbose=1, mode='auto'    ❸
)

history = model.fit(                                                      ❹
    train_gen_aux, validation_data=valid_gen_aux, 
    steps_per_epoch=get_steps_per_epoch(int(0.9*(500*200)), batch_size),
    validation_steps=get_steps_per_epoch(int(0.1*(500*200)), batch_size),
    epochs=n_epochs, 
    callbacks=[es_callback, csv_logger, lr_callback]
)

❶ 设置了早停回调

❷ 设置了 CSV 记录器以记录指标

❸ 设置学习率控制回调

❹ 训练模型

在训练深度网络时,使用学习率计划而不是固定学习率非常常见。通常,我们通过在模型训练开始时使用较高的学习率,然后随着模型的进展逐渐减小学习率来获得更好的性能。这是因为,在优化过程中,当模型收敛时,您应该使步长变小(即学习率)。否则,较大的步长会使模型表现不稳定。我们可以在观察到指标没有增加时智能地执行此过程,并在固定间隔内减小学习率。在 Keras 中,您可以通过回调 ReduceLROnPlateau (mng.bz/M5Oo) 轻松将此纳入模型训练中:

lr_callback = tf.keras.callbacks.ReduceLROnPlateau(
    monitor='val_loss', factor=0.1, patience=5, verbose=1, mode='auto'
)

使用回调时,您需要设置以下关键字参数:

  • monitor——定义观察的指标。在我们的示例中,我们将根据验证损失决定何时降低学习率。

  • factor——减少学习率的乘法因子。如果学习率为 0.01,0.1 的因子,这意味着在减少时学习率将为 0.001。

  • 沉着——与早停类似,等待多少个时期在指标没有改善的情况下降低学习率。

  • mode——与早停类似,指标的最小化/最大化是否应被视为改进。

当您训练模型时,您应该得到以下输出:

Train for 703 steps, validate for 78 steps
Epoch 1/50
703/703 [==============================] - 158s 224ms/step - loss: 4.9362 - 
➥ accuracy: 0.0544 - val_loss: 13.1802 - val_accuracy: 0.0246
...
Epoch 41/50
702/703 [============================>.] - ETA: 0s - loss: 2.5830 - 
➥ accuracy: 0.6828
Epoch 00041: ReduceLROnPlateau reducing learning rate to 0.00010000000474974513.
703/703 [==============================] - 136s 194ms/step - loss: 2.5831 - 
➥ accuracy: 0.6827 - val_loss: 3.4446 - val_accuracy: 0.4316
...
Epoch 47/50
702/703 [============================>.] - ETA: 0s - loss: 2.3371 - 
➥ accuracy: 0.7859
Epoch 00047: ReduceLROnPlateau reducing learning rate to 1.0000000474974514e-05.
703/703 [==============================] - 139s 197ms/step - loss: 2.3372 - 
➥ accuracy: 0.7859 - val_loss: 3.2988 - val_accuracy: 0.4720
...
Epoch 50/50
703/703 [==============================] - 137s 194ms/step - loss: 2.3124 - 
➥ accuracy: 0.7959 - val_loss: 3.3133 - val_accuracy: 0.4792

太棒了!通过调整模型架构,我们获得了巨大的准确性提升。我们现在有一个模型,在验证集上的准确率约为 50%(相当于准确识别了 100/200 个类别的对象,或者每个类别的图像有 50% 被准确分类)。您可以在输出中看到 ReduceLROnPlateau 回调所进行的干预。

最后,我们使用以下方式保存模型

if not os.path.exists('models'):
    os.mkdir("models")
model.save(os.path.join('models', 'minception_resnet_v2.h5'))

接下来,我们可以在测试集上衡量模型的性能:

# Load the model from disk
model = load_model(os.path.join('models','minception_resnet_v2.h5'))

# Evaluate the model
test_res = model.evaluate(test_gen_aux, steps=get_steps_per_epoch(500*50, 
➥ batch_size))

这应该在测试集上达到 51%的准确率。这是非常令人兴奋的消息。通过更多关注模型结构,我们几乎将之前模型的性能提升了一倍。

这是一个很好的教训,教会了我们模型架构在深度学习中的至关重要的作用。有一个误解认为深度学习是解决一切问题的灵丹妙药。不是的。例如,你不应该期望任何随意组合在一起的架构能够像一些公开发表的最新技术结果那样好。获得一个表现良好的深度网络可能是对超参数进行了数天甚至数周的优化和凭经验的选择的结果。

在下一节中,我们将利用迁移学习更快地达到更高程度的准确性。我们将下载一个预训练模型,并在特定数据集上进行微调。

注意 在一台配备 Intel Core i5 和 NVIDIA GeForce RTX 2070 8GB 的机器上,训练大约需要 1 小时 54 分钟来运行 50 个 epoch。

练习 3

你有以下卷积块,用于实现图像分类器:

def my_conv_block(input, activation): 
    out_1 = tf.keras.layers.Conv2D(n_filters[0][2], (3,3), strides=(1,1), 
                    kernel_initializer=initializer, padding='same')(input)
    out_final = tf.keras.layers.BatchNormalization()(out_1)
    out_final = tf.keras.layers.Activation(activation)(out_final)
    return out_final  

你想做以下两个改变:

  • 在应用激活后引入批标准化

  • 从卷积层的输出到批标准化层的输出创建一个残差连接。

7.3 如果你无法击败它们,就加入它们:使用预训练网络增强性能

到目前为止,你已经开发了一个很好的图像分类模型,它使用各种方法防止过拟合。公司一直很满意,直到你的老板宣布镇上出现了一个表现比你开发的模型更好的新竞争对手的消息。传言是他们有一个大约 70%准确率的模型。所以,你和你的同事又回到了起点。你相信一种特殊的技术,称为迁移学习,可以帮助。具体来说,你打算使用一个在原始 ImageNet 图像分类数据集上已经训练过的 Inception-ResNet v2 的预训练版本;在 tiny-imagenet-200 数据集上对这个模型进行微调将比到目前为止实现的所有模型都提供更高的准确性。

如果你想接近最新技术水平,你必须尽一切可能获得帮助。开始这个探索的一个好方法是使用预训练模型,然后针对你的任务进行微调。预训练模型是已经在类似任务上训练过的模型。这个过程属于迁移学习的概念。例如,你可以很容易找到在 ILSVRC 任务上预训练过的模型。

7.3.1 迁移学习:在深度神经网络中重用现有知识

迁移学习是一个庞大的话题,需要单独的章节(甚至一本书)来讨论。迁移学习有许多变体。要理解迁移学习的不同方面,请参考ruder.io/transfer-learning/。一种方法是使用预训练模型并针对要解决的任务进行微调。该过程如图 7.14 所示。

07-14

图 7.14 迁移学习的工作原理。首先,我们从一个在解决与我们感兴趣的任务类似/相关的较大数据集上预训练的模型开始。然后,我们传输模型权重(除了最后一层),并在现有权重之上拟合一个新的预测层。最后,我们在新任务上进行微调。

首先,你在一个你已经拥有大型标记数据集的任务上训练模型(称为预训练任务)。例如,在图像分类中,你有几个大型标记数据集,包括 ImageNet 数据集。一旦你在大型数据集上训练了一个模型,你就会得到网络的权重(除了最后的预测层),并拟合一个匹配新任务的新预测层。这给了网络解决新任务的一个非常好的起点。然后,你可以用较小的数据集解决新任务,因为你已经在类似的较大数据集上训练了模型。

我们如何使用迁移学习来解决我们的问题?这并不难。Keras 为图像分类任务提供了一个巨大的模型库(mng.bz/aJdo)。这些模型主要是在 ImageNet 图像分类任务上进行训练的。让我们驯服 Inception 网络系列中产生的野兽:Inception-ResNet v2。请注意,本节的代码可以在 Ch07-Improving-CNNs-and-Explaining/7.2.Transfer_Learning.ipynb 中找到。

Inception-ResNet v2

我们简要讨论了 Inception-ResNet v2 模型。这是最后一个生产的 Inception 模型。Inception-ResNet v2 具有以下特点,使其与其他 Inception 模型区别开来:

  • 重新设计的起始模块,消除了任何表征瓶颈

  • 使用残差连接的 Inception 块

  • 减少输入高度/宽度维度的减少模块

  • 不像早期的 Inception 网络那样使用辅助输出

正如你所看到的,Minception 模型中使用了重新设计的起始模块、Inception-ResNet 块和减少模块。如果你比较一下 Minception 的图表与原始论文中提供的图表,你会看到它们有多么相似。因此,我们不会重复讨论这些组件。如果你仍然想看到不同组件的具体细节和插图,请参考原始论文(arxiv.org/pdf/1602.07261.pdf)。然而,Inception-ResNet v2 的高层架构如下图所示。

07-14-unnumb-3

Inception-ResNet v2 的整体架构

您可以使用一行代码下载 Inception-ResNet v2 模型:

    InceptionResNetV2(include_top=False, pooling='avg')

这里 include_top=False 意味着最终的预测层将被丢弃。这是必要的,因为原始的 inception 网络是为 1000 类设计的。但是,我们只有 200 类。pooling=‘avg’ 确保模型中的最后一个汇合层是平均汇合层。接下来,我们将创建一个新模型,将预训练的 Inception-ResNet v2 模型作为核心,但修改为解决 Tiny ImageNet 分类任务,如下图所示。

列出了基于预训练 Inception-ResNet v2 模型的模型实现

from tensorflow.keras.applications import InceptionResNetV2   ❶
from tensorflow.keras.models import Sequential                ❶
from tensorflow.keras.layers import Input, Dense, Dropout     ❶

model = Sequential([
    Input(shape=(224,224,3)),                                 ❷
    InceptionResNetV2(include_top=False, pooling='avg'),      ❸
    Dropout(0.4),                                             ❹
    Dense(200, activation='softmax')                          ❺
])

adam = tf.keras.optimizers.Adam(learning_rate=0.0001)         ❻
model.compile(loss=’categorical_crossentropy’, optimizer=adam, 
➥ metrics=['accuracy'])
model.summary()

❶ 一些重要的导入

❷ 为 224 × 224 的图像定义输入层

❸ Inception-ResNet v2 模型的预训练权重

❹ 应用了 40% 的 dropout

❺ 最终的预测层有 200 个类

❻ 由于网络已经在 ImageNet 数据上进行了训练,所以使用较小的学习率(经验选择)

在这里,我们定义了一个顺序模型,

  • 首先定义了一个大小为 224 × 224 × 3 的输入层(即,高度 = 224,宽度 = 224,通道 = 3)

  • 将 Inception-ResNet v2 模型定义为一个层

  • 在最后一个平均汇合层上使用 40% 的 dropout

  • 定义了一个使用 Softmax 激活的密集层,具有 200 个节点

我们需要应对的一个关键挑战是,原始的 Inception-ResNet v2 输入大小为 224 × 224 × 3。因此,我们需要找到一种方法来呈现我们的输入(即,64 × 64 × 3)以符合 Inception-ResNet v2 的要求。为了做到这一点,我们将对 ImageDataGenerator 进行一些更改,如下面的列表所示。

列出了生成 224 × 224 图像的修改版 ImageDataGenerator。

def get_train_valid_test_data_generators(batch_size, target_size):

    image_gen_aug = ImageDataGenerator(
        samplewise_center=False, rotation_range=30, width_shift_range=0.2,
        height_shift_range=0.2, brightness_range=(0.5,1.5), shear_range=5, 
        zoom_range=0.2, horizontal_flip=True, validation_split=0.1
    )                                                         ❶
    image_gen = ImageDataGenerator(samplewise_center=False)   ❶

    partial_flow_func = partial(                              ❷
        image_gen_aug.flow_from_directory, 
        directory=os.path.join('data','tiny-imagenet-200', 'train'), 
        target_size=target_size,                              ❸
        classes=None,
        class_mode='categorical', 
        interpolation='bilinear',                             ❹
        batch_size=batch_size, 
        shuffle=True, 
        seed=random_seed)                                        

    # Get the training data subset
    train_gen = partial_flow_func(subset='training')          ❺
    # Get the validation data subset
    valid_gen = partial_flow_func(subset='validation')        ❺

    # Defining the test data generator
    test_df = get_test_labels_df(os.path.join('data','tiny-imagenet-200',  
➥ 'val', 'val_annotations.txt'))                             ❻
    test_gen = image_gen.flow_from_dataframe(
        test_df, 
        directory=os.path.join('data','tiny-imagenet-200',  'val', 'images'), 
        target_size=target_size,                              ❼
        classes=None,
        class_mode='categorical', 
        interpolation='bilinear',                             ❼
        batch_size=batch_size,  
        shuffle=False
    )
    return train_gen, valid_gen, test_gen

batch_size = 32                                               ❽
target_size = (224,224)                                       ❽

# Getting the train,valid, test data generators
train_gen, valid_gen, test_gen = 
➥ get_train_valid_test_data_generators(batch_size, target_size)

train_gen_aux = data_gen_augmented(train_gen, random_gamma=True, 
➥ random_occlude=True)                                       ❾

valid_gen_aux = data_gen_augmented(valid_gen)                 ❾
test_gen_aux = data_gen_augmented(test_gen)                   ❾

❶ 定义了一个数据增广的图片数据生成器和一个标准的图片数据生成器

❷ 定义了部分函数以避免重复参数

❸ 使用 224 × 224 的目标大小

❹ 使用双线性插值使图像变大

❺ 定义了训练和验证集的数据生成器

❻ 定义了测试数据生成器

❼ 使用双线性插值的 224 × 224 目标大小

❽ 定义了批量大小和目标大小

❾ 使用 data_gen_augmented 函数获得训练集/验证集/测试集的修改过的数据生成器

最终,是时候展示我们最好的模型了:

from tensorflow.keras.callbacks import EarlyStopping, CSVLogger
es_callback = EarlyStopping(monitor='val_loss', patience=10)
csv_logger = CSVLogger(os.path.join('eval','4_eval_resnet_pretrained.log'))
n_epochs=30
lr_callback = tf.keras.callbacks.ReduceLROnPlateau(
    monitor='val_loss', factor=0.1, patience=5, verbose=1, mode='auto'
)

history = model.fit(
    train_gen_aux, validation_data=valid_gen_aux, 
    steps_per_epoch=int(0.9*(500*200)/batch_size), validation_steps=int(0.1*(500*200)/batch_size),
    epochs=n_epochs, callbacks=[es_callback, csv_logger, lr_callback]
)

训练将与先前训练 Miniception 模型所使用的训练配置相同。我们不会重复细节。我们使用以下内容:

  • 记录指标

  • 早期停止

  • 学习率调整

注意,在一台配有 NVIDIA GeForce RTX 2070 8GB 的 Intel Core i5 机器上,训练 23 epoch 大约需要 9 小时 20 分钟。

您应该得到类似以下的结果:

Epoch 1/50
2813/2813 [==============================] - 1465s 521ms/step - loss: 
➥ 2.0031 - accuracy: 0.5557 - val_loss: 1.5206 - val_accuracy: 0.6418
...
Epoch 23/50
2813/2813 [==============================] - ETA: 0s - loss: 0.1268 - 
➥ accuracy: 0.9644
Epoch 00023: ReduceLROnPlateau reducing learning rate to 
➥ 9.999999974752428e-08.
2813/2813 [==============================] - 1456s 518ms/step - loss: 
➥ 0.1268 - accuracy: 0.9644 - val_loss: 1.2681 - val_accuracy: 0.7420

这不是好消息吗?通过结合我们所学的所有知识,我们已经达到了约 74% 的验证准确率。让我们快速看一下模型的测试准确率:

# Evaluate the model
test_res = model.evaluate(test_gen_aux, steps=get_steps_per_epoch(500*50, 
➥ batch_size))

这应该显示大约 79% 的准确率。这不是一次轻松的旅程,但显然你已经超过了竞争对手的约 70% 准确率的模型。

在下一节中,我们将看看模型可解释性的重要性。我们将学习一种技术,可以用来解释嵌入模型的知识。

Inception-ResNet v2 对比 Minception

Minception 和 Inception-Resnet-v2 的茎在引入的创新方面是相同的(例如,Inception-ResNet 块,缩减块等)。然而,存在以下低级差异:

  • Inception-ResNet v2 有三种不同的 Inception 块类型;Minception 只有两种。

  • Inception-ResNet v2 有两种不同类型的缩减块;Minception 只有一个。

  • Inception-ResNet v2 有 25 个 Inception 层,但我们实现的 Minception(版本)只有三个。

还有其他一些小的差异,例如 Inception-ResNet v2 在模型的几个层中使用有效填充。如果你想了解详情,请参阅 Inception-ResNet v2 论文。另一个值得注意的观察是,Minception 和 Inception-ResNet v2 都没有使用局部响应归一化(LRN),因为它们使用了更强大的东西:批归一化。

练习 4

你想使用另一个名为 VGGNet(16 层)的不同预训练网络实现一个网络。你可以从 tf.keras.applications.VGG16 获取预训练网络。接下来,你丢弃顶层并在顶部引入一个最大池化层。然后你想在预训练网络的顶部添加两个具有 100(ReLU 激活)和 50(Softmax 激活)个节点的稠密层。实现这个网络。

7.4 Grad-CAM: 让 CNN 供认

公司对你为他们所做的一切感到非常高兴。你成功地建立了一个不仅击败了竞争对手的性能,而且是生产中最好的模型。然而,你的老板希望在发布任何消息之前确认模型是可信的。仅准确性是不够的!你决定演示模型如何进行预测,使用一种名为Grad-CAM的最新模型解释技术。Grad-CAM 使用相对于模型预测而生成的给定输入的梯度的大小来提供模型关注的可视化。图像中某一区域的梯度大小较大意味着图像更关注该区域。通过将梯度大小叠加成热图,你能够产生一个有吸引力的可视化,显示模型在给定输入中关注的内容。

Grad-CAM(梯度类激活映射)是由 Ramprasaath R. Selvaraju 等人在“Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization”中为深度神经网络引入的一种模型解释技术(arxiv.org/pdf/1610.02391.pdf)。深度网络因其不可解释性而臭名昭著,因此被称为黑匣子。因此,我们必须进行一些分析,确保模型正常运行。

下面的代码详细说明了 Grad-CAM 如何发挥其作用,并且实现代码在笔记本 Ch07-Improving-CNNs-and-Explaining/7.3 .Interpreting_CNNs_GradCAM.ipynb 中可用。为了节省本章的篇幅,我们将仅讨论该方法的伪代码,技术细节留给附录 B(参见下一个清单)。

清单 7.14 Grad-CAM 计算的伪代码

Define: model (Trained Inception Resnet V2 model)
Define: probe_ds (A list of image, class(integer) tuples e.g. [(image, 
➥ class-int), (image, class-int), ...]) that we will use to interpret the model
Define: last_conv (Last convolution layer of the model - closest to the 
➥ prediction layer)
Load the model (inceptionnet_resnet_v2.h5)

For img, cls in probe_ds:

    # Computing the gradient map and its associated weights
    Compute the model’s final output (out) and last_conv layer’s output 
➥ (conv_out)
    Compute the gradient d (out[cls]) / d (conv_out) and assign to grad
    Compute channel weights by taking the mean of grad over width and 
➥ height dimensions (Results in a [batch size(=1), 1, 1, # channels in 
➥ last_conv] tensor)

    # Creating the final gradient heatmap
    grad = grad * weights # Multiply grad with weights
    grad = tf.reduce_sum(grad, axis=-1) # Take sum over channels
    grad = tf.nn.relu(grad) # Apply ReLU activation to obtain the gradient 
➥ heatmap

    # Visualizing the gradient heatmap
    Resize the gradient heatmap to a size of 224x224
    Superimpose the gradient heatmap on the original image (img)
    Plot the image and the image with the gradient heatmap superimposed 
➥ side by side

Grad-CAM 执行的关键计算是,给定输入图像,计算与图像真实类别相对应的节点对模型的最后卷积输出的梯度。

图像中每个像素的梯度大小代表了该像素对最终结果的贡献。因此,通过将 Grad-CAM 输出表示为热图,调整大小以匹配原始图像,并将其叠加在原始图像上,你可以获得一个非常引人注目和信息丰富的图,显示了模型关注的不同对象的位置。这些图解释性强,显示了模型是否专注于正确的对象以产生期望的预测。在图 7.15 中,我们展示了模型强烈关注的区域(红色/黑色 = 最高关注,蓝色/浅色 = 较少关注)。

07-15

图 7.15 展示了几个探测图像的 Grad-CAM 输出的可视化。图像中越红/越暗的区域,模型对该部分的关注越多。你可以看到我们的模型已经学会了理解一些复杂的场景,并将其需要关注的模型分开。

图 7.15(即 Grad-CAM 的可视化)显示了我们的模型确实是一个智能模型。它知道在混乱的环境中(例如,对餐桌进行分类)要关注哪些地方以找到给定的对象。如前所述,区域越红/越暗,模型就越专注于该区域进行预测。现在是时候向你的老板展示结果,建立必要的信心,公开新模型了!

我们将在此结束对图像分类的讨论。我们已经学习了许多可以有效解决问题的不同模型和技术。在下一章中,我们将讨论计算机视觉的另一个方面,即图像分割。

总结

  • 图像增强、dropout 和提前停止是在视觉深度网络中防止过拟合的一些常见技术。

  • 大多数常见的图像增强步骤可以通过 Keras ImageDataGenerator 实现。

  • 对于所选问题,重要的是要注意所选择模型的架构。我们不应随意选择一个架构,而是要研究并确定一个在类似问题上已经奏效的架构。否则,可以通过超参数优化来选择架构。Minception 模型的架构已经被证明在我们本章使用的相同数据上表现良好。

  • 迁移学习使我们能够利用已经训练好的模型来解决新任务,从而获得更好的准确性。

  • 在 Keras 中,你可以用一行代码获得给定模型,并将其调整到新的任务中。

  • mng.bz/M5Oo 上有各种预训练网络可供选择。

  • Grad-CAM(梯度类激活映射)是解释你的 CNN 的有效方法。

  • 根据模型对预测产生的梯度大小,Grad-CAM 计算出模型关注最多的地方。

练习答案

练习 1

  1. 如果出现欠拟合,你应该降低 dropout 率,以保持更多节点在训练过程中保持开启状态:

    model = tf.keras.models.Sequential([
    tf.keras.layers.Dense(100, activation=’relu’, input_shape=(250,)), 
    tf.keras.layers.Dropout(0.2), 
    tf.keras.layers.Dense(10, activation=’softmax’)
    ])
    model.compile(loss=’categorical_crossentropy’, optimizer=’adam’, 
    ➥ metrics=[‘accuracy’])
    model.fit(X, y, epochs=25)
    
  2. 提前停止是通过使用 EarlyStopping 回调引入的:

    es_callback = tf.keras.callbacks.EarlyStopping(monitor='val_loss', 
    ➥ patience=5, min_delta=0.1)
    model.fit(X, y, epochs=25, callbacks=[es_callback])
    

练习 2

tf.keras.callbacks.EarlyStopping(monitor='val_loss', min_delta=0.01, patience=5)

练习 3

def my_conv_block(input, activation): 
    out_1 = tf.keras.layers.Conv2D(n_filters[0][2], (3,3), strides=(1,1), 
                   kernel_initializer=initializer, activation=activation,
                   padding='same')(input)

    out_final = tf.keras.layers.BatchNormalization()(out_1)

    out = out_final + out_1 
    return out

练习 4

model = tf.keras.models.Sequential([
    tf.keras.layers.Input(shape=(224,224,3)),                                      
    tf.keras.applications.VGG16(include_top=False, pooling='max'),
    tf.keras.layers.Dense(100, activation=’relu’),                    
    tf.keras.layers.Dense(50, activation='softmax') 
])

第八章:区分事物:图像分割

本章内容涵盖

  • 了解分割数据并在 Python 中处理它

  • 实现一个完整的分割数据管道

  • 实现高级分割模型(DeepLab v3)

  • 使用自定义构建的图像分割损失函数/度量编译模型

  • 对清洁和处理后的图像数据进行图像分割模型训练

  • 评估经过训练的分割模型

在上一章中,我们学习了各种先进的计算机视觉模型和技术,以提高图像分类器的性能。我们了解了 Inception net v1 的架构以及它的后继者(例如 Inception net v2、v3 和 v4)。我们的目标是提高模型在一个包含 200 个不同类别的对象的 64×64 大小的 RGB 图像的图像分类数据集上的性能。在尝试在此数据集上训练模型时,我们学到了许多重要的概念:

  • Inception blocks—一种将具有不同尺寸窗口(或核)的卷积层分组在一起的方法,以鼓励学习不同尺度的特征,同时由于更小尺寸的核而使模型参数高效。

  • 辅助输出—Inception net 不仅在网络末端使用分类层(即具有 softmax 激活的完全连接层),而且还在网络中间使用。这使得从最终层到第一层的梯度能够强劲地传播。

  • 数据增强—使用各种图像转换技术(调整亮度/对比度、旋转、平移等)使用 tf.keras.preprocessing.image.ImageDataGenerator 增加标记数据的数量。

  • Dropout—随机打开和关闭层中的节点。这迫使神经网络学习更健壮的特征,因为网络并不总是激活所有节点。

  • 提前停止—使用验证数据集上的性能作为控制训练何时停止的方法。如果在一定数量的 epochs 中验证性能没有提高,则停止训练。

  • 迁移学习—下载并使用在更大、类似数据集上训练的预训练模型(例如 Inception-ResNet v2)作为初始化,并对其进行微调以在手头的任务上表现良好。

在本章中,我们将学习计算机视觉中另一个重要任务:图像分割。在图像分类中,我们只关心给定图像中是否存在对象。另一方面,图像分割不仅识别同一图像中的多个对象,还识别它们在图像中的位置。这是计算机视觉的一个非常重要的主题,像自动驾驶汽车这样的应用程序依赖于图像分割模型。自动驾驶汽车需要精确定位其周围的物体,这就是图像分割发挥作用的地方。你可能已经猜到,它们在许多其他应用程序中也有它们的根基:

  • 图像检索

  • 识别星系 (mng.bz/gwVx)

  • 医学图像分析

如果您是从事与图像相关问题的计算机视觉/深度学习工程师/研究人员,您的道路很可能会与图像分割相交。图像分割模型将图像中的每个像素分类为预定义的一组对象类别之一。图像分割与我们之前看到的图像分类任务有关。两者都解决了一个分类任务。此外,预训练的图像分类模型被用作分割模型的骨干,因为它们可以提供不同粒度的关键图像特征,以更好更快地解决分割任务。一个关键区别是图像分类器解决了一个稀疏预测任务,其中每个图像都有一个与之关联的单个类标签,而分割模型解决了一个密集预测任务,其中图像中的每个像素都有一个与之关联的类标签。

任何图像分割算法都可以分类为以下类型之一:

  • 语义分割—该算法仅对图像中存在的不同类别的对象感兴趣。例如,如果图像中有多个人,则与所有人对应的像素将被标记为相同的类。

  • 实例分割—该算法对单独识别不同对象感兴趣。例如,如果图像中有多个人,属于每个人的像素将被表示为唯一的类。与语义分割相比,实例分割被认为更难。

图 8.1 描述了语义分割任务中找到的数据与实例分割任务中找到的数据之间的区别。在本章中,我们将重点关注语义分割 (mng.bz/5QAZ)。

08-01

图 8.1 语义分割与实例分割的比较

在下一节中,我们将更仔细地研究我们正在处理的数据。

8.1 理解数据

您正在尝试一个创业想法。这个想法是为小型遥控(RC)玩具开发一种导航算法。用户可以选择导航需要多安全或者冒险。作为第一步,您计划开发一个图像分割模型。图像分割模型的输出将稍后馈送到另一个模型,该模型将根据用户的请求预测导航路径。

对于这个任务,您觉得 Pascal VOC 2012 数据集会是一个很好的选择,因为它主要包含了城市/家庭环境中的室内和室外图像。它包含图像对:一个包含一些对象的输入图像和一个带有注释的图像。在注释图像中,每个像素都有一个分配的颜色,取决于该像素属于哪个对象。在这里,您计划下载数据集并成功将数据加载到 Python 中。

在深入了解/界定您想解决的问题之后,下一个重点应该是了解和探索数据。分割数据与我们迄今为止见过的图像分类数据集不同。一个主要的区别是输入和目标都是图像。输入图像是一个标准图像,类似于您在图像分类任务中找到的图像。与图像分类不同,目标不是标签,而是图像,其中每个像素都有来自预定义颜色调色板的颜色。换句话说,我们感兴趣的每个对象都被分配了一种颜色。然后,在输入图像中对应于该对象的像素以该颜色着色。可用颜色的数量与您想要识别的不同对象(加上背景)的数量相同(图 8.2)。

08-02

图 8.2 图像分类器与图像分割模型的输入和输出

对于这个任务,我们将使用流行的 PASCAL VOC 2012 数据集,该数据集由真实场景组成。数据集为 22 个不同类别提供了标签,如表 8.1 所述。

表 8.1 PASCAL VOC 2012 数据集中的不同类别及其相应的标签

类别 指定标签 类别 指定标签
背景 0 餐桌 11
飞机 1 12
自行车 2 13
3 摩托车 14
4 15
瓶子 5 盆栽植物 16
公共汽车 6 17
汽车 7 沙发 18
8 火车 19
椅子 9 电视/显示器 20
10 边界/未知对象 255

白色像素代表对象边界或未知对象。图 8.3 通过显示每个单独的对象类别的样本来说明数据集。

08-03

图 8.3 PASCAL VOC 2012 数据集的样本。数据集显示了单个示例图像,以及用于 20 种不同对象类别的注释分割。

在图 8.4 中,深入挖掘一下,你可以近距离看到单个样本数据点(最好是以彩色视图查看)。它有两个对象:一把椅子和一只狗。正如所示,不同的颜色分配给不同的对象类别。虽然最好以彩色查看图像,但您仍然可以通过注意在图像中勾勒对象的白色边框来区分不同的对象。

08-04

图 8.4 图像分割中的原始输入图像及其相应的目标标注/分割图像

首先,我们将从mng.bz/6XwZ下载数据集(请参阅下一个清单)。

清单 8.1 下载数据

import os
import requests
import tarfile

# Retrieve the data
if *not* os.path.exists(os.path.join('data','VOCtrainval_11-May-2012.tar')): ❶
    url = "http:/ /host.robots.ox.ac.uk/pascal/VOC/voc2012/VOCtrainval_11-
➥ May-2012.tar"
    # Get the file from web
    r = requests.get(url)                                                  ❷

    if *not* os.path.exists('data'):
        os.mkdir('data')

    # Write to a file
    with open(os.path.join('data','VOCtrainval_11-May-2012.tar'), 'wb') as f:
        f.write(r.content)                                                 ❸
else:
    print("The tar file already exists.")

if *not* os.path.exists(os.path.join('data', 'VOCtrainval_11-May-2012')):    ❹
    with tarfile.open(os.path.join('data','VOCtrainval_11-May-2012.tar'), 'r') as tar:
        tar.extractall('data')
else:
    print("The extracted data already exists")

❶ 检查文件是否已经下载。如果已下载,则不要重新下载。

❷ 从 URL 获取内容。

❸ 将文件保存到磁盘。

❹ 如果文件存在但尚未提取,则提取文件。

数据集的下载与我们以前的经验非常相似。数据作为 tar 文件存在。如果文件不存在,我们会下载文件并解压缩。接下来,我们将讨论如何使用图像库 Pillow 和 NumPy 将图像加载到内存中。在这里,目标图像将需要特殊处理,因为您将看到它们不是使用常规方法存储的。加载输入图像到内存中没有任何意外情况。使用 PIL(即 Pillow)库,可以通过一行代码加载它们:

from PIL import Image

orig_image_path = os.path.join('data', 'VOCtrainval_11-May-2012', 
➥ 'VOCdevkit', 'VOC2012', 'JPEGImages', '2007_000661.jpg')

orig_image = Image.open(orig_image_path)

接下来,您可以检查图像的属性:

print("The format of the data {}".format(orig_image.format))
>>> The format of the data JPEG

print("This image is of size: {}".format(orig_image.shape))
>>> This image is of size: (375, 500, 3)

是时候加载相应的注释/分割的目标图像了。如前所述,目标图像需要特殊关注。目标图像不是作为标准图像存储的,而是作为调色板化图像存储的。调色板化是一种在图像中存储具有固定颜色数量的图像时减少内存占用的技术。该方法的关键在于维护一个颜色调色板。调色板被存储为整数序列,其长度为颜色数量或通道数量(例如,对于 RGB 的情况,一个像素由三个值对应于红、绿和蓝,通道数量为三。灰度图像具有单个通道,其中每个像素由单个值组成)。然后,图像本身存储了一个索引数组(大小为高度×宽度),其中每个索引映射到调色板中的一种颜色。最后,通过将图像中的调色板索引映射到调色板颜色,可以计算出原始图像。图 8.5 提供了这个讨论的视觉展示。

08-05

图 8.5 显示了 PASCAL VOC 2012 数据集中输入图像和目标图像的数值表示。

下一个清单展示了从调色板图像中重新构造原始图像像素的代码。

代码清单 8.2 从调色板图像中重建原始图像

def rgb_image_from_palette(image):

    """ This function restores the RGB values form a palletted PNG image """
    palette = image.get_palette()                                         ❶

    palette = np.array(pallette).reshape(-1,3)                            ❷
    if isinstance(image, PngImageFile):
        h, w = image.height, image.width                                  ❸
        # Squash height and width dimensions (makes slicing easier)
        image = np.array(image).reshape(-1)                               ❹
    elif isinstance(image, np.ndarray):                                   ❺
        h, w = image.shape[0], image.shape[1]
        image = image.reshape(-1)

    rgb_image = np.zeros(shape=(image.shape[0],3))                        ❻
    rgb_image[(image != 0),:] = pallette[image[(image != 0)], :]          ❻
    rgb_image = rgb_image.reshape(h, w, 3)                                ❼

    return rgb_image

❶ 从图像中获取颜色调色板。

❷ 调色板以向量形式存储。我们将其重新整形为一个数组,其中每一行表示一个单独的 RGB 颜色。

❸ 获取图像的高度和宽度。

❹ 将以数组形式存储的调色板图像转换为向量(有助于接下来的步骤)。

❺ 如果图像是以数组而不是 Pillow 图像提供的,将图像作为向量获取。

❻ 首先,我们定义一个与图像长度相同的零向量。然后,对于图像中的所有索引,我们从调色板中获取相应的颜色,并将其分配到 rgb_image 的相同位置。

❼ 恢复原始形状。

在这里,我们首先使用 get_palette()函数获取图像的调色板。这将作为一个一维数组存在(长度为类别数×通道数)。接下来,我们需要将数组重塑为一个(类别数,通道数)大小的数组。在我们的情况下,这将转换为一个(22,3)大小的数组。由于我们将重塑的第一维定义为-1,它将从原始数据的大小和重塑操作的其他维度中自动推断出来。最后,我们定义一个全零数组,它最终将存储图像中找到的索引的实际颜色。为此,我们使用图像(包含索引)索引 rgb_image 向量,并将调色板中匹配的颜色分配给这些索引。

利用我们迄今为止看到的数据,让我们定义一个 TensorFlow 数据管道,将数据转换和转换为模型可接受的格式。

练习 1

你已经提供了一个以 RGB 格式表示的 rgb_image,其中每个像素属于 n 种独特的颜色之一,并且已经给出了一个称为调色板的调色板,它是一个[n,3]大小的数组。你将如何将 rgb_image 转换为调色板图像?

提示 你可以通过使用三个 for 循环来创建一个简单的解决方案:两个循环用于获取 rgb_image 的单个像素,然后最后一个循环用于遍历调色板中的每种颜色。

8.2 认真对待:定义一个 TensorFlow 数据管道

到目前为止,我们已经讨论了将帮助我们为 RC 玩具构建导航算法的数据。在构建模型之前,一个重要的任务是完成从磁盘到模型的可扩展数据摄取方法。提前完成这项工作将节省大量时间,当我们准备扩展或投产时。你认为最好的方法是实现一个 tf.data 管道,从磁盘中检索图像,对它们进行预处理、转换,并使其准备好供模型获取。该管道应该读取图像,将它们重塑为固定大小(对于变尺寸图像),对它们进行数据增强(在训练阶段),分批处理它们,并为所需的 epoch 数重复此过程。最后,我们将定义三个管道:一个训练数据管道,一个验证数据管道和一个测试数据管道。

在数据探索阶段结束时,我们的目标应该是建立一个从磁盘到模型的可靠数据管道。这就是我们将在这里看到的。从高层次来看,我们将建立一个 TensorFlow 数据管道,执行以下任务:

  • 获取属于某个子集(例如,训练、验证或测试)的文件名。

  • 从磁盘中读取指定的图像。

  • 预处理图像(包括对图像进行归一化/调整大小/裁剪)。

  • 对图像执行增强以增加数据量。

  • 将数据分批处理成小批次。

  • 使用几种内置优化技术优化数据检索。

作为第一步,我们将编写一个函数,返回一个生成器,该生成器将生成我们要获取的数据的文件名。我们还将提供指定用户想要获取的子集(例如,训练、验证或测试)的能力。通过生成器返回数据将使编写tf.data流水线更容易(参见下面的代码清单)。

图 8.3 检索给定数据子集的文件名列表

def get_subset_filenames(orig_dir, seg_dir, subset_dir, subset):
    """ Get the filenames for a given subset (train/valid/test)"""

    if subset.startswith('train'):
        ser = pd.read_csv(                                            ❶
            os.path.join(subset_dir, "train.txt"), 
            index_col=None, header=None, squeeze=True
        ).tolist()
        elif subset.startswith('val') or subset.startswith('test'):

        random.seed(random_seed)                                      ❷

        ser = pd.read_csv(                                            ❸
            os.path.join(subset_dir, "val.txt"), 
            index_col=None, header=None, squeeze=True
        ).tolist()

        random.shuffle(ser)                                           ❹

        if subset.startswith('val'):
            ser = ser[:len(ser)//2]                                   ❺
        else:
            ser = ser[len(ser)//2:]                                   ❻
    else:
        raise NotImplementedError("Subset={} is not recognized".format(subset))

    orig_filenames = [os.path.join(orig_dir,f+'.jpg') for f in ser]   ❼
    seg_filenames = [os.path.join(seg_dir, f+'.png') for f in ser]    ❽

    for o, s in zip(orig_filenames, seg_filenames):
        yield o, s                                                    ❾

❶ 读取包含训练实例文件名的 CSV 文件。

❷ 对验证/测试子集执行一次洗牌,以确保我们使用固定的种子得到良好的混合。

❸ 读取包含验证/测试文件名的 CSV 文件。

❹ 修复种子后对数据进行洗牌。

❺ 将第一半部分作为验证集。

❻ 将第二半部分作为测试集。

❼ 形成我们捕获的输入图像文件的绝对路径(取决于子集参数)。

❽ 将文件名对(输入和注释)作为生成器返回。

❾ 形成分段图像文件的绝对路径。

您可以看到,在读取 CSV 文件时我们传递了一些参数。这些参数描述了我们正在读取的文件。这些文件非常简单,每行只包含一个图像文件名。index_col=None表示文件没有索引列,header=None表示文件没有标题,squeeze=True表示输出将被呈现为 pandas Series,而不是 pandas Dataframe。有了这些,我们可以定义一个 TensorFlow 数据集(tf.data.Dataset),如下所示:

filename_ds = tf.data.Dataset.from_generator(
        subset_filename_gen_func, output_types=(tf.string, tf.string)
    )

TensorFlow 有几个不同的函数,用于使用不同的来源生成数据集。由于我们已经定义了函数get_subset_filenames()来返回一个生成器,我们将使用tf.data.Dataset.from_generator()函数。注意,我们需要提供返回数据的格式和数据类型,通过生成器使用output_types参数。函数subset_filename_gen_func返回两个字符串;因此,我们将输出类型定义为两个tf.string元素的元组。

另一个重要方面是我们根据子集从不同的 txt 文件中读取的情况。在相对路径中有三个不同的文件:data\VOCtrainval_11-May-2012\VOCdevkit\VOC2012\ImageSets\Segmentation 文件夹;train.txtval.txttrainval.txt。在这里,train.txt 包含训练图像的文件名,而 val.txt 包含验证/测试图像的文件名。我们将使用这些文件创建不同的流水线,产生不同的数据。

tf.data 是从哪里来的?

TensorFlow 的tf.data流水线可以从各种来源消耗数据。以下是一些常用的检索数据的方法:

tf.data.Dataset.from_generator(gen_fn) ——你已经在实际操作中见过这个函数。如果你有一个生成器(即 gen_fn)产生数据,你希望它通过一个 tf.data 流水线进行处理。这是使用的最简单的方法。

tf.data.Dataset.from_tensor_slices(t)——如果你已经将数据加载为一个大矩阵,这是一个非常有用的函数。t 可以是一个 N 维矩阵,这个函数将在第一个维度上逐个元素地提取。例如,假设你已经将一个大小为 3 × 4 的张量 t 加载到内存中:

t = [ [1,2,3,4],
      [2,3,4,5],
      [6,7,8,9] ]

然后,你可以轻松地设置一个 tf.data 管道,如下所示。tf.data.Dataset.from_tensor_slices(t) 将返回 [1,2,3,4],然后 [2,3,4,5],最后 [6,7,8,9] 当你迭代这个数据管道时。换句话说,你每次看到一行(即从批处理维度中切片,因此称为 from_tensor_slices)。

现在是时候读取我们在上一步获取的文件路径中找到的图像了。TensorFlow 提供了支持,可以轻松加载图像,其中文件名路径为 img_filename,使用函数 tf.io.read_file 和 tf.image.decode_image。在这里,img_filename 是一个 tf.string(即 TensorFlow 中的字符串):

tf.image.decode_jpeg(tf.io.read_file(image_filename))

我们将使用这种模式来加载输入图像。然而,我们需要实现一个自定义图像加载函数来加载目标图像。如果你使用前面的方法,它将自动将图像转换为具有像素值的数组(而不是调色板索引)。但如果我们不执行该转换,我们将得到一个精确符合我们需要的格式的目标数组,因为目标图像中的调色板索引是输入图像中每个对应像素的实际类标签。我们将在 TensorFlow 数据管道中使用 PIL.Image 来加载图像作为调色板图像,并避免将其转换为 RGB:

from PIL import Image

def load_image_func(image):
    """ Load the image given a filename """

    img =  np.array(Image.open(image))        
    return img

然而,你还不能将自定义函数作为 tf.data 管道的一部分使用。它们需要通过将其包装为 TensorFlow 操作来与数据管道的数据流图相协调。这可以通过使用 tf.numpy_function 操作轻松实现,它允许你将返回 NumPy 数组的自定义函数包装为 TensorFlow 操作。如果我们用 y 表示目标图像的文件路径,你可以使用以下代码将图像加载到 TensorFlow 中并使用自定义图像加载函数:

tf.numpy_function(load_image_func, inp=[y], Tout=[tf.uint8])

tf.numpy_function 的黑暗面

NumPy 对各种科学计算有比 TensorFlow 更广泛的覆盖,所以你可能会认为 tf.numpy_function 让事情变得非常方便。但事实并非如此,因为你可能会在 TensorFlow 代码中引入可怕的性能下降。当 TensorFlow 执行 NumPy 代码时,它可能会创建非常低效的数据流图并引入开销。因此,尽量坚持使用 TensorFlow 操作,并且只在必要时使用自定义的 NumPy 代码。在我们的情况下,由于没有其他方法可以加载调色板图像而不将调色板值映射到实际的 RGB,我们使用了一个自定义函数。

请注意,我们将输入(即,inp=[y])和其数据类型(即,Tout=[tf.uint8])都传递给此函数。它们都需要以 Python 列表的形式存在。最后,让我们把我们讨论的所有内容都整理到一个地方:

def load_image_func(image):
    """ Load the image given a filename """

    img =  np.array(Image.open(image))        
    return img

# Load the images from the filenames returned by the above step
    image_ds = filename_ds.map(lambda x,y: (
        tf.image.decode_jpeg(tf.io.read_file(x)), 
        tf.numpy_function(load_image_func, [y], [tf.uint8])
    ))

tf.data.Dataset.map() 函数将在本讨论中大量使用。您可以在侧边栏中找到 map() 函数的详细解释。

刷新器:tf.data.Dataset.map() 函数

此 tf.data 管道将大量使用 tf.data.Dataset.map() 函数。因此,我们提醒自己此函数实现了什么功能是非常有帮助的。

td.data.Dataset.map() 函数将给定的函数或多个函数应用于数据集中的所有记录。换句话说,它使用指定的转换来转换数据集中的数据点。例如,假设 tf.data.Dataset

  dataset = tf.data.Dataset.from_tensor_slices([1, 2, 3, 4])

要获取每个元素的平方,可以使用 map 函数如下

  dataset = dataset.map(lambda x: x**2)

如果在单个记录中有多个元素,则可以利用 map()的灵活性来分别转换它们:

  dataset = tf.data.Dataset.from_tensor_slices([[1,3], [2,4], [3,5], [4,6]])
  dataset = dataset.map(lambda x, y: (x**2, y+x))
which will return,
[[1, 4], [4, 6], [9, 8], [16, 10]]

作为规范化步骤,我们将通过使用将像素值带到 [0,1] 范围的方法

image_ds = image_ds.map(lambda x, y: (tf.cast(x, 'float32')/255.0, y))

请注意,我们保留了目标图像(y)。在我们的管道中继续进行更多步骤之前,我想引起您的注意。这是一个相当常见的警告,因此值得注意。在我们刚刚完成的步骤之后,您可能会觉得,如果您愿意,您可以将数据进行批处理并将其馈送到模型中。例如

image_ds = image_ds.batch(10)

如果您对此数据集进行此操作,将会收到以下错误:

InvalidArgumentError: Cannot batch tensors with different shapes in 
➥ component 0\. First element had shape [375,500,3] and element 1 had 
➥ shape [333,500,3]. [Op:IteratorGetNext]

这是因为您忽略了数据集的一个关键特征和一个健全性检查。除非您使用的是经过筛选的数据集,否则您不太可能找到具有相同尺寸的图像。如果您查看数据集中的图像,您会注意到它们的尺寸不同;它们具有不同的高度和宽度。在 TensorFlow 中,除非您使用像 tf.RaggedTensor 这样的特殊数据结构,否则无法将大小不同的图像一起进行批处理。这正是 TensorFlow 在错误中抱怨的内容。

为了缓解问题,我们需要将所有图像调整为标准大小(请参见列表 8.4)。为此,我们将定义以下函数。它将

  • 将图像调整为较大的尺寸(resize_to_before_crop),然后将图像裁剪为所需大小(input_size),或者

  • 将图像调整为所需大小(input_size)

列表 8.4 使用随机裁剪或调整大小将图像调整为固定大小

def randomly_crop_or_resize(x,y):
    """ Randomly crops or resizes the images """

    def rand_crop(x, y):                                                  ❶
        """ Randomly crop images after enlarging them """
        x = tf.image.resize(x, resize_to_before_crop, method='bilinear')  ❷
        y = tf.cast(                                                      ❸
                tf.image.resize(
                    tf.transpose(y,[1,2,0]),                              ❹
                    resize_to_before_crop, method='nearest'
                ),
                'float32'
            )          

        offset_h = tf.random.uniform(
            [], 0, x.shape[0]-input_size[0], dtype='int32'
        )                                                                 ❺
        offset_w = tf.random.uniform(
            [], 0, x.shape[1]-input_size[1], dtype='int32'
        )                                                                 ❻
        x = tf.image.crop_to_bounding_box(
            image=x, 
            offset_height=offset_h, offset_width=offset_w,
            target_height=input_size[0], target_width=input_size[1]       ❼
        )
        y = tf.image.crop_to_bounding_box(
            image=y, 
            offset_height=offset_h, offset_width=offset_w,
            target_height=input_size[0], target_width=input_size[1]       ❼
        )

        return x, y

    def resize(x, y):
        """ Resize images to a desired size """
        x = tf.image.resize(x, input_size, method='bilinear')             ❽
        y = tf.cast(
                tf.image.resize(
                    tf.transpose(y,[1,2,0]),                                        
                    input_size, method='nearest'                          ❽
                ),
                'float32'
            )          

        return x, y

    rand = tf.random.uniform([], 0.0,1.0)                                 ❾

    if augmentation and \                                                 ❿
        (input_size[0] < resize_to_before_crop[0] or \
         input_size[1] < resize_to_before_crop[1]):
        x, y = tf.cond(
                rand < 0.5,                                               ⓫
                lambda: rand_crop(x, y),
                 lambda: resize(x, y)
                )
        else:
            x, y = resize(x, y)                                           ⓬

        return x, y

❶ 定义一个函数,在调整大小后随机裁剪图像。

❷ 使用双线性插值将输入图像调整为较大的尺寸。

❸ 使用最近邻插值将目标图像调整为较大的尺寸。

❹ 要调整大小,我们首先交换 y 轴的轴,因为它的形状为 [1, height, width]。我们使用 tf.transpose() 函数将其转换回 [height, width, 1](即,单通道图像)。

❺ 定义一个随机变量,在裁剪期间偏移图像的高度。

❻ 定义一个随机变量,在裁剪期间在宽度上对图像进行偏移。

❼ 使用相同的裁剪参数裁剪输入图像和目标图像。

❽ 将输入图像和目标图像都调整为所需大小(不裁剪)。

❾ 定义一个随机变量(用于执行增强)。

❿ 如果启用增强并且调整大小后的图像大于我们请求的输入大小,则执行增强。

⓫ 在增强期间,随机执行 rand_crop 或 resize 函数。

⓬ 如果禁用增强,则只调整大小。

这里,我们定义了一个名为 randomly_crop_or_resize 的函数,其中包含两个嵌套函数 rand_crop 和 resize。rand_crop 首先将图像调整为 resize_to_before_crop 中指定的大小,并创建一个随机裁剪。务必检查是否对输入和目标应用了完全相同的裁剪。例如,应使用相同的裁剪参数对输入和目标进行裁剪。为了裁剪图像,我们使用

x = tf.image.crop_to_bounding_box(
    image=x, 
    offset_height=offset_h, offset_width=offset_w,
    target_height=input_size[0], target_width=input_size[1]                 
)
y = tf.image.crop_to_bounding_box(
    image=y, 
    offset_height=offset_h, offset_width=offset_w,
    target_height=input_size[0], target_width=input_size[1]               
)

参数的含义不言而喻:image 接受要裁剪的图像,offset_height 和 offset_width 决定裁剪的起点,target_height 和 target_width 指定裁剪后的最终大小。resize 函数将使用 tf.image.resize 操作简单地将输入和目标调整为指定大小。

在调整大小时,我们对输入图像使用双线性插值,对目标使用最近邻插值。双线性插值通过计算结果像素的邻近像素的平均值来调整图像大小,而最近邻插值通过从邻居中选择最近的常见像素来计算输出像素。双线性插值在调整大小后会导致更平滑的结果。然而,必须对目标图像使用最近邻插值,因为双线性插值会导致分数输出,破坏基于整数的注释。图 8.6 可视化了所描述的插值技术。

08-06

图 8.6 最近邻插值和双线性插值用于上采样和下采样任务

接下来,我们将在使用这两个嵌套函数的方式上引入一个额外的步骤。如果启用了增强,我们希望裁剪或调整大小在管道中随机地发生。我们将定义一个随机变量(从介于 0 和 1 之间的均匀分布中抽取)并根据随机变量的值在给定时间内执行裁剪或调整大小。可以使用 tf.cond 函数实现这种条件,该函数接受三个参数,并根据这些参数返回输出:

  • Condition——这是一个计算结果为布尔值的计算(即随机变量 rand 是否大于 0.5)。

  • true_fn——如果条件为真,则执行此函数(即对 x 和 y 执行 rand_crop)

  • false_fn——如果条件为假,则执行此函数(即对 x 和 y 执行调整大小)

如果禁用了增强(即通过将augmentation变量设置为False),则仅执行调整大小操作。详细信息澄清后,我们可以在我们的数据管道中使用randomly_crop_or_resize函数如下:

    image_ds = image_ds.map(lambda x,y: randomly_crop_or_resize(x,y))

此时,我们的管道中出现了一个全局固定大小的图像。接下来我们要处理的事情非常重要。诸如图像大小可变和用于加载图像的自定义 NumPy 函数等因素使得 TensorFlow 在几个步骤之后无法推断其最终张量的形状(尽管它是一个固定大小的张量)。如果您检查此时产生的张量的形状,您可能会将它们视为

(None, None, None)

这意味着 TensorFlow 无法推断张量的形状。为了避免任何歧义或问题,我们将设置管道中输出的形状。对于张量t,如果形状不明确但您知道形状,您可以使用手动设置形状

t.set_shape([<shape of the tensor>])

在我们的数据管道中,我们可以设置形状为

def fix_shape(x, y, size):
    """ Set the shape of the input/target tensors """

    x.set_shape((size[0], size[1], 3))
    y.set_shape((size[0], size[1], 1))

    return x, y

image_ds = image_ds.map(lambda x,y: fix_shape(x,y, target_size=input_size))

我们知道跟随调整大小或裁剪的输出将会是

  • 输入图像 —— 一个具有input_size高度和宽度的 RGB 图像

  • 目标图像 —— 一个具有input_size高度和宽度的单通道图像

我们将使用tf.data.Dataset.map()函数相应地设置形状。不能低估数据增强的威力,因此我们将向我们的数据管道引入几个数据增强步骤(见下一篇列表)。

列表 8.5 用于图像随机增强的函数

def randomly_flip_horizontal(x, y):
    """ Randomly flip images horizontally. """

    rand = tf.random.uniform([], 0.0,1.0)                                           ❶

    def flip(x, y):
        return tf.image.flip_left_right(x), tf.image.flip_left_right(y)             ❷

    x, y = tf.cond(rand < 0.5, lambda: flip(x, y), lambda: (x, y))                  ❸

    return x, y

if augmentation:    
    image_ds = image_ds.map(lambda x, y: randomly_flip_horizontal(x,y))             ❹

    image_ds = image_ds.map(lambda x, y: (tf.image.random_hue(x, 0.1), y))          ❺

    image_ds = image_ds.map(lambda x, y: (tf.image.random_brightness(x, 0.1), y))   ❻

    image_ds = image_ds.map(lambda x, y: (tf.image.random_contrast(x, 0.8, 1.2), y))❼

❶ 定义一个随机变量。

❷ 定义一个函数来确定性地翻转图像。

❸ 使用与之前相同的模式,我们使用tf.cond随机执行水平翻转。

❹ 在数据集中随机翻转图像。

❺ 随机调整输入图像的色调(即颜色)(目标保持不变)。

❻ 随机调整输入图像的亮度(目标保持不变)。

❼ 随机调整输入图像的对比度(目标保持不变)。

在列表 8.5 中,我们执行以下翻译:

  • 随机水平翻转图像

  • 随机改变图像的色调(最多 10%)

  • 随机改变图像的亮度(最多 10%)

  • 随机改变图像的对比度(最多 20%)

通过使用tf.data.Dataset.map()函数,我们可以在管道中轻松执行指定的随机增强步骤,如果用户在管道中启用了增强(即通过将augmentation变量设置为True)。请注意,我们仅对输入图像执行一些增强(例如,随机色调、亮度和对比度调整)。我们还将给用户提供具有不同尺寸的输入和目标(即输出)的选项。这通过将输出调整为由output_size参数定义的所需大小来实现。我们用于此任务的模型具有不同尺寸的输入和输出维度:

if output_size:
    image_ds = image_ds.map(
                   lambda x, y: (
                       x, 
                       tf.image.resize(y, output_size, method='nearest')
                   )
    )

再次,这里我们使用最近邻插值来调整目标的大小。接下来,我们将对数据进行洗牌(如果用户将shuffle参数设置为True):

if shuffle:
    image_ds = image_ds.shuffle(buffer_size=batch_size*5)

混洗函数有一个重要参数称为buffer_size,它确定了加载到内存中以随机选择样本的样本数量。buffer_size越高,引入的随机性就越多。另一方面,较高的buffer_size意味着更高的内存消耗。现在是批处理数据的时候了,所以在迭代时不是单个数据点,而是当我们迭代时获得一批数据:

image_ds = image_ds.batch(batch_size).repeat(epochs)

这是使用tf.data.Dataset.batch()函数完成的,将所需的批次大小作为参数传递。在使用tf.data管道时,如果要运行多个周期,还需要使用tf.data.Dataset.repeat()函数重复管道给定次数的周期。

我们为什么需要tf.data.Dataset.repeat()

tf.data.Dataset是一个生成器。生成器的一个独特特点是您只能迭代一次。当生成器到达正在迭代的序列的末尾时,它将通过抛出异常退出。因此,如果您需要多次迭代生成器,您需要根据需要重新定义生成器。通过添加tf.data.Dataset.repeat(epochs),生成器将根据需要重新定义(在此示例中为 epochs 次)。

在我们的tf.data管道完成之前,还需要一步。如果查看目标(y)输出的形状,您将看到它具有通道维度为 1。但是,对于我们将要使用的损失函数,我们需要摆脱该维度:

image_ds = image_ds.map(lambda x, y: (x, tf.squeeze(y)))

对此,我们将使用tf.squeeze()操作,该操作会删除尺寸为 1 的任何维度并返回一个张量。例如,如果您压缩一个尺寸为[1,3,2,1,5]的张量,您将得到一个尺寸为[3,2,5]的张量。最终的代码在清单 8.6 中提供。您可能会注意到两个突出显示的步骤。这是两个流行的优化步骤:缓存和预提取。

清单 8.6 最终的tf.data管道

def get_subset_tf_dataset(
    subset_filename_gen_func, batch_size, epochs, 
    input_size=(256, 256), output_size=None, resize_to_before_crop=None, 
    augmentation=False, shuffle=False
):

    if augmentation and not resize_to_before_crop:
        raise RuntimeError(                                                          ❶
            "You must define resize_to_before_crop when augmentation is enabled."
        )

    filename_ds = tf.data.Dataset.from_generator(
        subset_filename_gen_func, output_types=(tf.string, tf.string)                ❷
    )

 image_ds = filename_ds.map(lambda x,y: (
 tf.image.decode_jpeg(tf.io.read_file(x)),                                   ❸
 tf.numpy_function(load_image_func, [y], [tf.uint8])
 )).cache()

    image_ds = image_ds.map(lambda x, y: (tf.cast(x, 'float32')/255.0, y))           ❹

    def randomly_crop_or_resize(x,y):                                                ❺
        """ Randomly crops or resizes the images """
        ...

        def rand_crop(x, y):
            """ Randomly crop images after enlarging them """
            ...

        def resize(x, y):
            """ Resize images to a desired size """
            ...

    image_ds = image_ds.map(lambda x,y: randomly_crop_or_resize(x,y))                ❻
    image_ds = image_ds.map(lambda x,y: fix_shape(x,y, target_size=input_size))      ❼

    if augmentation:    
        image_ds = image_ds.map(lambda x, y: randomly_flip_horizontal(x,y))          ❽
        image_ds = image_ds.map(lambda x, y: (tf.image.random_hue(x, 0.1), y))       ❽
        image_ds = image_ds.map(lambda x, y: (tf.image.random_brightness(x, 0.1), y))❽
        image_ds = image_ds.map(
            lambda x, y: (tf.image.random_contrast(x, 0.8, 1.2), y)                  ❽
        )

    if output_size:
        image_ds = image_ds.map(
            lambda x, y: (x, tf.image.resize(y, output_size,  method='nearest'))     ❾
        )

    if shuffle:
        image_ds = image_ds.shuffle(buffer_size=batch_size*5)                        ❿
    image_ds = image_ds.batch(batch_size).repeat(epochs)                             ⓫

    image_ds = image_ds.prefetch(tf.data.experimental.AUTOTUNE)                      ⓬

    image_ds = image_ds.map(lambda x, y: (x, tf.squeeze(y)))                         ⓭

    return image_ds                                                                  ⓮

❶ 如果启用了增强,则需要定义resize_to_before_crop

❷ 根据所请求的数据子集返回文件名列表。

❸ 将图像加载到内存中。cache()是一个优化步骤,将在文本中讨论。

❹ 规范化输入图像。

❺ 随机裁剪或调整图像大小的函数

❻ 在图像上执行随机裁剪或调整大小。

❼ 设置结果图像的形状。

❽ 在数据上随机执行各种增强。

❾ 根据需要调整输出图像的大小。

❿ 使用缓冲区对数据进行随机混洗。

⓫ 批处理数据并为所需的周期重复该过程。

⓬ 这是文本中详细讨论的优化步骤。

⓭ 从目标图像中删除不必要的维度。

⓮ 获取最终的tf.data管道。

这不是一次轻松的旅程,但却是一次有益的旅程。我们学到了一些定义数据管道的重要技能:

  • 定义一个生成器,返回要获取的数据的文件名

  • tf.data管道中加载图像

  • 操作图像(调整大小、裁剪、亮度调整等)

  • 数据批处理和重复

  • 为不同数据集定义多个流水线,这些数据集具有不同的要求

接下来,我们将查看一些优化技术,将我们平庸的数据流水线转变为令人印象深刻的数据高速公路。

8.2.1 优化 tf.data 流水线

TensorFlow 是一个用于消耗大型数据集的框架,高效地消耗数据是一个关键优先事项。我们对 tf.data 流水线的讨论中仍然缺少一件事,即 tf.data 流水线可用的优化步骤。在列表 8.6 中,缓存和预取两个步骤被加粗设置。如果您对其他优化技术感兴趣,可以在 www.tensorflow.org/guide/data_performance 上阅读更多。

缓存将在数据通过流水线时将其存储在内存中。这意味着当缓存时,该步骤(例如,从磁盘加载数据)仅在第一个时期发生。随后的时期将从内存中保存的缓存数据中读取。在这里,您可以看到我们将图像加载到内存后进行缓存。这样,TensorFlow 仅在第一个时期加载图像:

image_ds = filename_ds.map(lambda x,y: (
 tf.image.decode_jpeg(tf.io.read_file(x)), 
 tf.numpy_function(load_image_func, [y], [tf.uint8])
)).cache()

Prefetching 是你可以使用的另一个强大武器,它允许你利用设备的多进程能力:

image_ds = image_ds.prefetch(tf.data.experimental.AUTOTUNE)

提供给函数的参数决定了预取多少数据。通过将其设置为 AUTOTUNE,TensorFlow 将根据可用资源决定要获取的最佳数据量。假设一个简单的数据流水线从磁盘加载图像并训练模型。然后,数据读取和模型训练将交替进行。这导致了显着的空闲时间,因为模型在数据加载时空闲,反之亦然。

然而,多亏了预取,情况就不一样了。预取利用后台线程和内部缓冲区,在模型训练时提前加载数据。当下一次迭代到来时,模型可以无缝地继续训练,因为数据已经被提前加载到内存中。图 8.7 显示了顺序执行和预取之间的差异。

08-07

图 8.7 模型训练中的顺序执行与基于预取的执行的区别

接下来,我们将查看图像分割问题的完整 tf.data 流水线。

8.2.2 最终的 tf.data 流水线

最后,您可以使用我们迄今为止定义的函数来定义数据流水线(们)。在这里,我们为三个不同的目的定义了三种不同的数据流水线:训练、验证和测试(见下面的列表)。

列表 8.7 创建训练/验证/测试数据流水线实例

orig_dir = os.path.join(
    'data', 'VOCtrainval_11-May-2012', 'VOCdevkit', 'VOC2012', 'JPEGImages'                 ❶
)
seg_dir = os.path.join(
    'data', 'VOCtrainval_11-May-2012', 'VOCdevkit', 'VOC2012', 'SegmentationClass'          ❷
)
subset_dir = os.path.join(
    'data', 'VOCtrainval_11-May-2012', 'VOCdevkit', 'VOC2012', 'ImageSets',                 ❸
    'Segmentation'
)

partial_subset_fn = partial(
    get_subset_filenames, orig_dir=orig_dir, seg_dir=seg_dir, subset_dir=subset_dir         ❹
)
train_subset_fn = partial(partial_subset_fn, subset='train')                                ❺
val_subset_fn = partial(partial_subset_fn, subset='val')                                    ❺
test_subset_fn = partial(partial_subset_fn, subset='test')                                  ❺

input_size = (384, 384)                                                                     ❻

tr_image_ds = get_subset_tf_dataset(                                                        ❼
    train_subset_fn, batch_size, epochs, 
    input_size=input_size, resize_to_before_crop=(444,444),
    augmentation=True, shuffle=True
)
val_image_ds = get_subset_tf_dataset(                                                       ❽
    val_subset_fn, batch_size, epochs, 
    input_size=input_size, 
    shuffle=False
)
test_image_ds = get_subset_tf_dataset(                                                      ❾
    test_subset_fn, batch_size, 1, 
    input_size=input_size, 
    shuffle=False
)

❶ 包含输入图像的目录

❷ 包含注释图像(目标)的目录

❸ 包含训练/验证/测试文件名的文本文件所在的目录

❹ 从 get_subset_filenames 定义一个可重用的部分函数。

❺ 为训练/验证/测试数据定义三个生成器。

❻ 定义输入图像尺寸。

❼ 定义一个使用数据增强和洗牌的训练数据流水线。

❽ 定义一个不使用数据增强或洗牌的验证数据流水线。

❾ 定义一个测试数据流水线。

首先,我们定义了几个重要的路径:

  • orig_dir—包含输入图像的目录

  • seg_dir—包含目标图像的目录

  • subset_dir—包含列出训练和验证实例的文本文件(train.txt、val.txt)的目录

然后我们将从我们之前定义的 get_subset_filenames() 函数定义一个偏函数,以便我们可以通过设置函数的 subset 参数来获取一个生成器。利用这种技术,我们将定义三个生成器:train_subset_fn、val_subset_fn 和 test_subset_fn。最后,我们将使用 get_subset_tf_dataset() 函数定义三个 tf.data.Datasets。我们的流水线将具有以下特征:

  • 训练流水线—在每个 epoch 上执行数据增强和数据洗牌

  • 验证流水线和测试流水线—无增强或洗牌

我们将定义的模型期望一个 384 × 384 大小的输入和一个输出。在训练数据流水线中,我们将图像调整大小为 444 × 444,然后随机裁剪一个 384 × 384 大小的图像。接下来,我们将看一下解决方案的核心部分:定义图像分割模型。

练习 2

您已经获得了一个包含两个张量的小数据集:张量 a 包含 100 个大小为 64 × 64 × 3 的图像(即,100 × 64 × 64 × 3 的形状),张量 b 包含 100 个大小为 32 × 32 × 1 的分割蒙版(即,100 × 32 × 32 × 1 的形状)。您被要求使用讨论过的函数定义一个 tf.data.Dataset,它将

  • 将分割蒙版调整大小以匹配输入图像大小(使用最近的插值)

  • 使用转换(x - 128)/255 对输入图像进行标准化,其中单个图像是 x

  • 将数据批处理为大小为 32 的批次,并重复五个 epochs

  • 使用自动调优功能预取数据

8.3 DeepLabv3:使用预训练网络对图像进行分割

现在是创建流水线的核心部分的时候了:深度学习模型。根据一位在类似问题上工作的自动驾驶汽车公司同事的反馈,您将实现一个 DeepLab v3 模型。这是一个建立在预训练的 ResNet 50 模型(在图像分类上训练)的基础上的模型,但最后几层被改为执行 空洞卷积 而不是标准卷积。它使用金字塔聚合模块,在不同尺度上使用空洞卷积来生成不同尺度上的图像特征,以产生最终输出。最后,它使用双线性插值层将最终输出调整大小为所需大小。您相信 DeepLab v3 能够提供良好的初始结果。

基于深度神经网络的分割模型可以广泛分为两类:

  • 编码器解码器模型(例如,U-Net 模型)

  • 完全卷积网络(FCN)后跟金字塔聚合模块(例如,DeepLab v3 模型)

编码器-解码器模型的一个著名例子是 U-Net 模型。换句话说,U-Net 具有逐渐创建输入的较小、更粗略表示的编码器。然后,解码器接收编码器生成的表示,并逐渐上采样(即增加输出的大小)直到达到输入图像的大小为止。上采样是通过一种称为转置卷积的操作实现的。最后,你以端到端的方式训练整个结构,其中输入是输入图像,目标是相应图像的分割掩码。我们不会在本章讨论这种类型的模型。然而,我在附录 B 中包含了一个详细的步骤说明(以及模型的实现)。

另一种分割模型引入了一个特殊的模块来替换解码器。我们称之为金字塔聚合模块。它的目的是在不同尺度上收集空间信息(例如来自各种中间卷积层的不同大小的输出),以提供关于图像中存在的对象的细粒度上下文信息。DeepLab v3 是这种方法的一个典型示例。我们将对 DeepLab v3 模型进行详细分析,并借此在分割任务上取得卓越成果。

研究人员和工程师更倾向于使用金字塔聚合模块的方法。可能有很多原因。一个有利可图的原因是,使用金字塔聚合的网络参数较少,比采用基于编码器-解码器的对应网络更少。另一个原因可能是,通常引入新模块(与编码器-解码器相比)提供更多灵活性,可以在多个尺度上设计高效准确的特征提取方法。

金字塔聚合模块有多重要?为了了解这一点,我们首先必须了解完全卷积网络的结构是什么样的。图 8.8 说明了这种分割模型的通用结构。

08-08

图 8.8 全卷积网络使用金字塔聚合模块的一般结构和组织方式

了解金字塔聚合模块的重要性的最佳方式是看看如果没有它会发生什么。如果是这种情况,那么最后一个卷积层将承担建立最终分割掩码(通常是最后一层输出的 16-32 倍大)的巨大且不切实际的责任。毫不奇怪,在最终卷积层和最终分割掩码之间存在巨大的表征瓶颈,从而导致性能不佳。在卷积神经网络中通常强制执行的金字塔结构导致最后一层的输出宽度和高度非常小。

金字塔聚合模块弥合了这一差距。 它通过组合几个不同的中间输出来做到这一点。 这样,网络就有了充足的细粒度(来自较早层)和粗糙的(来自更深层)细节,以构建所需的分割掩模。 细粒度的表示提供了关于图像的空间/上下文信息,而粗糙的表示提供了关于图像的高级信息(例如,存在哪些对象)。 通过融合这两种类型的表示,生成最终输出的任务变得更加可行。

为什么不是金字塔而是摩天大楼呢?

你可能会想,如果随着时间的推移使输出变小会导致信息的丢失,“为什么不保持相同的大小呢?”(因此有了摩天大楼这个术语)。 这是一个不切实际的解决方案,主要有两个原因。

首先,通过池化或步幅减小输出大小是一种重要的正则化方法,它迫使网络学习平移不变特征(正如我们在第六章讨论的那样)。 如果去掉这个过程,我们就会阻碍网络的泛化能力。

其次,不减小输出大小将显著增加模型的内存占用。 这反过来会极大地限制网络的深度,使得创建更深层次的网络更加困难。

DeepLab v3 是一系列模型的金童,这些模型起源于并且是由来自谷歌的几位研究人员在论文“重新思考用于语义图像分割的空洞卷积”(arxiv.org/pdf/1706.05587.pdf)中提出的。

大多数分割模型都面临着由常见且有益的设计原则引起的不利副作用。 视觉模型将步幅/池化结合起来,使网络平移不变。 但这个设计思想的一个不受欢迎的结果是输出大小的不断减小。 这通常导致最终输出比输入小 16-32 倍。 作为密集预测任务,图像分割任务严重受到这种设计思想的影响。 因此,大多数涌现出来的具有突破性的网络都是为了解决这个问题。 DeepLab 模型就是为了解决这个问题而诞生的。 现在让我们看看 DeepLab v3 是如何解决这个问题的。

DeepLab v3 使用在 ImageNet 图像分类数据集上预训练的 ResNet-50(arxiv.org/pdf/1512.03385.pdf)作为提取图像特征的主干。 几年前,它是计算机视觉社区中引起轰动的开创性残差网络之一。 DeepLab v3 对模型进行了几个架构上的改变,以缓解这个问题。 此外,DeepLab v3 引入了一个全新的组件,称为空洞空间金字塔池化(ASPP)。 我们将在接下来的章节中更详细地讨论每个组件。

8.3.1 ResNet-50 模型的快速概述

ResNet-50 模型由多个卷积块组成,后跟一个全局平均池化层和一个具有 softmax 激活的完全连接的最终预测层。卷积块是模型的创新部分。原始模型有 16 个卷积块,组织成五个组。一个单独的块由三个卷积层组成(1 × 1 卷积层,步长为 2,3 × 3 卷积层,1 × 1 卷积层),批量归一化和残差连接。我们在第七章深入讨论了残差连接。接下来,我们将讨论模型中始终使用的核心计算,称为孔卷积。

8.3.2 孔卷积:用孔扩大卷积层的感受野

与标准 ResNet-50 相比,DeepLab v3 骄傲地使用孔卷积的主要变化。孔(法语意为“孔”)卷积,也称为扩张卷积,是标准卷积的变体。孔卷积通过在卷积参数之间插入“孔”来工作。感受野的增加由一个称为 扩张率 的参数控制。更高的扩张率意味着卷积中实际参数之间有更多的孔。孔卷积的一个主要好处是能够增加感受野的大小,而不会损害卷积层的参数效率。

08-09

图 8.9 孔卷积与标准卷积的比较。标准卷积是孔卷积的特例,其中速率为 1。随着扩张率的增加,层的感受野也会增加。

图 8.9 显示了较大的扩张率导致更大的感受野。阴影灰色框的数量表示参数数量,而虚线,轻微阴影的框表示感受野的大小。正如你所见,参数数量保持不变,而感受野增加。从计算上讲,将标准卷积扩展到孔卷积非常简单。你所需要做的就是在孔卷积操作中插入零。

等等!孔卷积如何帮助分割模型?

正如我们讨论的那样,CNN 的金字塔结构提出的主要问题是输出逐渐变小。最简单的解决方案,不改变学习的参数,是减小层的步幅。尽管技术上会增加输出大小,但在概念上存在问题。

要理解这一点,假设 CNN 的第 i 层的步长为 2,并且获得了 h × w 大小的输入。然后,第 i+1 层获得了 h/2 × w/2 大小的输入。通过移除第 i 层的步长,它获得了 h × w 大小的输出。然而,第 i+1 层的核已经被训练成看到一个更小的输出,所以通过增加输入的大小,我们破坏了(或减少了)层的感受野。通过引入空洞卷积,我们补偿了该感受野的减小。

现在让我们看看 ResNet-50 如何被重新用于图像分割。首先,我们从tf.keras.applications模块下载它。ResNet-50 模型的架构如下所示。首先,它有一个步幅为 2 的卷积层和一个步幅为 2 的池化层。之后,它有一系列卷积块,最后是一个平均池化层和完全连接的输出层。这些卷积块具有卷积层的分层组织。每个卷积块由几个子块组成,每个子块由三个卷积层组成(即 1 × 1 卷积、3 × 3 卷积和 1 × 1 卷积),以及批量归一化。

使用 Keras 函数 API 实现 DeepLab v3

从输入开始直到conv4块的网络保持不变。根据原始 ResNet 论文的符号,这些块被标识为conv2conv3conv4块组。我们的第一个任务是创建一个包含输入层到原始 ResNet-50 模型的conv4块的模型。之后,我们将专注于根据 DeepLab v3 论文重新创建最终卷积块(即conv5):

# Pretrained model and the input
inp = layers.Input(shape=target_size+(3,))
resnet50 = tf.keras.applications.ResNet50(
    include_top=False, input_tensor=inp,pooling=None
)

for layer *in* resnet50.layers:
    if layer.name == "conv5_block1_1_conv":
        break
    out = layer.output

resnet50_upto_conv4 = models.Model(resnet50.input, out)

如图所示,我们找到了 ResNet-50 模型中位于“conv5_block1_1_conv”之前的最后一层,这将是conv4块组的最后一层。有了这个,我们可以定义一个临时模型,该模型包含从输入到conv4块组的最终输出的层。后来,我们将专注于通过引入论文中的修改和新组件来增强这个模型。我们将使用扩张卷积重新定义conv5块。为此,我们需要了解 ResNet 块的构成(图 8.10)。我们可以假设它有三个不同的级别。

08-10

图 8.10 ResNet-50 中卷积块的解剖。对于这个示例,我们展示了 ResNet-50 的第一个卷积块。卷积块组的组织包括三个不同的级别。

现在让我们实现一个函数来表示使用扩张卷积时的每个级别。为了将标准卷积层转换为扩张卷积,我们只需将所需的速率传递给tf.keras.layers.Conv2D层的dilation_rate参数即可。首先,我们将实现一个表示级别 3 块的函数,如下清单所示。

清单 8.8 ResNet-50 中的级别 3 卷积块

def block_level3(
    inp, filters, kernel_size, rate, block_id, convlayer_id, activation=True           ❶
):
    """ A single convolution layer with atrous convolution and batch normalization 
    inp: 4-D tensor having shape [batch_size, height, width, channels]
    filters: number of output filters
    kernel_size: The size of the convolution kernel
    rate: dilation rate for atrous convolution
    block_id, convlayer_id - IDs to distinguish different convolution blocks and layers
    activation: If true ReLU is applied, if False no activation is applied
    """

    conv5_block_conv_out = layers.Conv2D(
        filters, kernel_size, dilation_rate=rate, padding='same',                      ❷
        name='conv5_block{}_{}_conv'.format(block_id, convlayer_id)
    )(inp)

    conv5_block_bn_out = layers.BatchNormalization(
        name='conv5_block{}_{}_bn'.format(block_id, convlayer_id)                      ❸
    )(conv5_block_conv_out)

    if activation:
        conv5_block_relu_out = layers.Activation(
            'relu', name='conv5_block{}_{}_relu'.format(block_id, convlayer_id)        ❹
        )(conv5_block_bn_out)

        return conv5_block_relu_out
    else:
        return conv5_block_bn_out                                                      ❺

❶ 在这里,inp 接受具有形状 [批量大小,高度,宽度,通道] 的 4D 输入。

❷ 对输入执行二维卷积,使用给定数量的滤波器、内核大小和扩张率。

❸ 对卷积层的输出执行批量归一化。

❹ 如果激活设置为 True,则应用 ReLU 激活。

❺ 如果激活设置为 False,则返回输出而不进行激活。

级别 3 块具有一个单独的卷积层,具有所需的扩张率和批量归一化层,随后是非线性 ReLU 激活层。接下来,我们将为级别 2 块编写一个函数(见下一个清单)。

清单 8.9 在 ResNet-50 中的 2 级卷积块

def block_level2(inp, rate, block_id):
    """ A level 2 resnet block that consists of three level 3 blocks """

    block_1_out = block_level3(inp, 512, (1,1), rate, block_id, 1)
    block_2_out = block_level3(block_1_out, 512, (3,3), rate, block_id, 2)
    block_3_out = block_level3(
        block_2_out, 2048, (1,1), rate, block_id, 3, activation=False
    )

    return block_3_out

一个 2 级块由具有给定扩张率的三个级别 3 块组成,这些块具有以下规格的卷积层:

  • 1 × 1 卷积层,具有 512 个滤波器和所需的扩张率

  • 3 × 3 卷积层,具有 512 个滤波器和所需的扩张率

  • 1 × 1 卷积层,具有 2048 个滤波器和所需的扩张率

除了使用空洞卷积外,这与 ResNet-50 模型中原始 conv5 块的 2 级块完全相同。所有构建块准备就绪后,我们可以使用空洞卷积实现完整的 conv5 块(见下一个清单)。

清单 8.10 实现最终的 ResNet-50 卷积块组(级别 1)

def resnet_block(inp, rate):
    """ Redefining a resnet block with atrous convolution """

    block0_out = block_level3(
        inp, 2048, (1,1), 1, block_id=1, convlayer_id=0, activation=False ❶
    )
    block1_out = block_level2(inp, 2, block_id=1)                         ❷
    block1_add = layers.Add(
        name='conv5_block{}_add'.format(1))([block0_out, block1_out]      ❸
    )
    block1_relu = layers.Activation(
        'relu', name='conv5_block{}_relu'.format(1)                       ❹
    )(block1_add)
    block2_out = block_level2 (block1_relu, 2, block_id=2) # no relu      ❺
    block2_add = layers.Add(
        name='conv5_block{}_add'.format(2)                                ❻
    )([block1_add, block2_out])
    block2_relu = layers.Activation(
        'relu', name='conv5_block{}_relu'.format(2)                       ❼
    )(block2_add)

    block3_out = block_level2 (block2_relu, 2, block_id=3)                ❽
    block3_add = layers.Add(
        name='conv5_block{}_add'.format(3)                                ❽
    )([block2_add, block3_out])
    block3_relu = layers.Activation(
        'relu', name='conv5_block{}_relu'.format(3)                       ❽
    )(block3_add)

     return block3_relu

❶ 创建一个级别 3 块(block0),为第一个块创建残差连接。

❷ 定义第一个具有扩张率为 2 的 2 级块(block1)。

❸ 从 block0 到 block1 创建一个残差连接。

❹ 对结果应用 ReLU 激活。

❺ 具有扩张率为 2 的第二级 2 块(block2)

❻ 从 block1 到 block2 创建一个残差连接。

❼ 应用 ReLU 激活。

❽ 对 block1 和 block2 应用类似的过程以创建 block3。

这里没有黑魔法。函数 resnet_block 将我们已经讨论的函数的输出放置在一起以组装最终的卷积块。特别地,它具有三个级别 2 块,其残差连接从前一个块到下一个块。最后,我们可以通过使用我们定义的中间模型的输出(resnet50_ upto_conv4)作为输入并使用扩张率为 2 调用 resnet_block 函数来获得 conv5 块的最终输出:

resnet_block4_out = resnet_block(resnet50_upto_conv4.output, 2)

8.3.4 实现空洞空间金字塔池化模块

在这里,我们将讨论 DeepLab v3 模型最令人兴奋的创新。空洞空间金字塔池化(ASPP)模块有两个目的:

  • 聚合通过使用不同扩张率产生的输出获得的图像的多尺度信息

  • 结合通过全局平均池化获得的高度摘要的信息

ASPP 模块通过在最后一个 ResNet-50 输出上执行不同的卷积来收集多尺度信息。具体来说,ASPP 模块执行 1 × 1 卷积、3 × 3 卷积(r = 6)、3 × 3 卷积(r = 12)和 3 × 3 卷积(r = 18),其中 r 是 dilation 率。所有这些卷积都有 256 个输出通道,并实现为级别 3 的块(由函数 block_level3() 提供)。

ASRP 通过执行全局平均池化来捕获高级信息,然后进行 1 × 1 卷积,输出通道为 256,以匹配多尺度输出的输出大小,最后是一个双线性上采样层,用于上采样全局平均池化所缩小的高度和宽度维度。记住,双线性插值通过计算相邻像素的平均值来上采样图像。图 8.11 说明了 ASPP 模块。

08-11

图 8.11 DeepLab v3 模型中使用的 ASPP 模块

ASPP 模块的任务可以概括为一个简明的函数。我们从之前完成的工作中已经拥有了实现此函数所需的所有工具(请参阅下面的清单)。

清单 8.11 实现 ASPP

def atrous_spatial_pyramid_pooling(inp):
    """ Defining the ASPP (Atrous spatial pyramid pooling) module """

    # Part A: 1x1 and atrous convolutions
    outa_1_conv = block_level3(
        inp, 256, (1,1), 1, '_aspp_a', 1, activation='relu'
    )                                                                                 ❶
    outa_2_conv = block_level3(
        inp, 256, (3,3), 6, '_aspp_a', 2, activation='relu'
    )                                                                                 ❷
    outa_3_conv = block_level3(
        inp, 256, (3,3), 12, '_aspp_a', 3, activation='relu'
    )                                                                                 ❸
    outa_4_conv = block_level3(
        inp, 256, (3,3), 18, '_aspp_a', 4, activation='relu'
    )                                                                                 ❹

    # Part B: global pooling
    outb_1_avg = layers.Lambda(
        lambda x: K.mean(x, axis=[1,2], keepdims=True)
    )(inp)                                                                            ❺
    outb_1_conv = block_level3(
        outb_1_avg, 256, (1,1), 1, '_aspp_b', 1, activation='relu'                    ❻
    )
    outb_1_up = layers.UpSampling2D((24,24), interpolation='bilinear')(outb_1_avg)    ❼
    out_aspp = layers.Concatenate()(
        [outa_1_conv, outa_2_conv, outa_3_conv, outa_4_conv, outb_1_up]               ❽
    )   

    return out_aspp

out_aspp = atrous_spatial_pyramid_pooling(resnet_block4_out)                          ❾

❶ 定义一个 1 × 1 卷积。

❷ 定义一个带有 256 个滤波器和 dilation 率为 6 的 3 × 3 卷积。

❸ 定义一个带有 256 个滤波器和 dilation 率为 12 的 3 × 3 卷积。

❹ 定义一个带有 256 个滤波器和 dilation 率为 18 的 3 × 3 卷积。

❺ 定义一个全局平均池化层。

❻ 定义一个带有 256 个滤波器的 1 × 1 卷积。

❼ 使用双线性插值上采样输出。

❽ 连接所有的输出。

❾ 创建一个 ASPP 的实例。

ASPP 模块由四个级别 3 的块组成,如代码所示。第一个块包括一个 1 × 1 卷积,带有 256 个无 dilation 的滤波器(这产生了 outa_1_conv)。后三个块包括 3 × 3 卷积,带有 256 个滤波器,但具有不同的 dilation 率(即 6、12、18;它们分别产生 outa_2_conv、outa_3_conv 和 outa_4_conv)。这涵盖了从图像中聚合多个尺度的特征。然而,我们还需要保留关于图像的全局信息,类似于全局平均池化层(outb_1_avg)。这通过一个 lambda 层实现,该层将输入在高度和宽度维度上进行平均:

outb_1_avg = layers.Lambda(lambda x: K.mean(x, axis=[1,2], keepdims=True))(inp)

平均值的输出接着是一个带有 256 个滤波器的 1 × 1 卷积滤波器。然后,为了将输出大小与以前的输出相同,使用双线性插值的上采样层(这产生 outb_1_up):

outb_1_up = layers.UpSampling2D((24,24), interpolation='bilinear')(outb_1_avg)

最后,所有这些输出都通过 Concatenate 层连接到单个输出中,以产生最终输出 out_aspp。

8.3.5 将所有内容放在一起

现在是时候整合所有不同的组件,创建一个宏伟的分割模型了。接下来的清单概述了构建最终模型所需的步骤。

清单 8.12 最终的 DeepLab v3 模型

inp = layers.Input(shape=target_size+(3,))                               ❶

resnet50= tf.keras.applications.ResNet50(
    include_top=False, input_tensor=inp,pooling=None                     ❷
)

for layer *in* resnet50.layers:
    if layer.name == "conv5_block1_1_conv":
        break
    out = layer.output                                                   ❸

resnet50_upto_conv4 = models.Model(resnet50.input, out)                  ❹

resnet_block4_out = resnet_block(resnet50_upto_conv4.output, 2)          ❺

out_aspp = atrous_spatial_pyramid_pooling(resnet_block4_out)             ❻

out = layers.Conv2D(21, (1,1), padding='same')(out_aspp)                 ❼
final_out = layers.UpSampling2D((16,16), interpolation='bilinear')(out)  ❼

deeplabv3 = models.Model(resnet50_upto_conv4.input, final_out)           ❽

❶ 定义 RGB 输入层。

❷ 下载并定义 ResNet50。

❸ 获取我们感兴趣的最后一层的输出。

❹ 从输入定义一个中间模型,到 conv4 块的最后一层。

❺ 定义删除的 conv5 ResNet 块。

❻ 定义 ASPP 模块。

❼ 定义最终输出。

❽ 定义最终模型。

注意观察模型中的线性层,它没有任何激活函数(例如 sigmoid 或 softmax)。这是因为我们计划使用一种特殊的损失函数,该函数使用 logits(在应用 softmax 之前从最后一层获得的未归一化分数)而不是归一化的概率分数。因此,我们将保持最后一层为线性输出,没有激活函数。

我们还需要执行最后一步操作:将原始 conv5 块的权重复制到我们的模型中新创建的 conv5 块。为此,首先需要将原始模型的权重存储如下:

w_dict = {}
for l *in* ["conv5_block1_0_conv", "conv5_block1_0_bn", 
          "conv5_block1_1_conv", "conv5_block1_1_bn", 
          "conv5_block1_2_conv", "conv5_block1_2_bn", 
          "conv5_block1_3_conv", "conv5_block1_3_bn"]:
    w_dict[l] = resnet50.get_layer(l).get_weights()

在编译模型之前,我们无法将权重复制到新模型中,因为在编译模型之前权重不会被初始化。在这之前,我们需要学习在分割任务中使用的损失函数和评估指标。为此,我们需要实现自定义损失函数和指标,并使用它们编译模型。这将在下一节中讨论。

练习 3

您想要创建一个新的金字塔聚合模块称为 aug-ASPP。这个想法与我们之前实现的 ASPP 模块类似,但有一些区别。假设您已经从模型中获得了两个中间输出:out_1 和 out_2(大小相同)。您必须编写一个函数,aug_aspp,它将获取这两个输出并执行以下操作:

  • 对 out_1 进行 atrous 卷积,r=16,128 个过滤器,3×3 卷积,步幅为 1,并应用 ReLU 激活函数(输出将被称为 atrous_out_1)

  • 对 out_1 和 out_2 进行 atrous 卷积,r=8,128 个过滤器,3×3 卷积,步幅为 1,并对两者应用 ReLU 激活函数(输出将被称为 atrous_out_2_1 和 atrous_out_2_2)

  • 拼接 atrous_out_2_1 和 atrous_out_2_2(输出将被称为 atrous_out_2)

  • 对 atrous_out_1 和 atrous_out_2 进行 1×1 卷积,使用 64 个过滤器并进行拼接(输出将被称为 conv_out)

  • 使用双线性上采样将 conv_out 的大小加倍(在高度和宽度尺寸上),并应用 sigmoid 激活函数

8.4 编译模型:图像分割中的损失函数和评估指标

为了完成 DeepLab v3 模型的最终构建(主要采用 ResNet-50 结构和 ASPP 模块),我们必须定义适当的损失函数和度量来衡量模型的性能。图像分割与图像分类任务有很大的不同,因此损失函数和度量不一定适用于分割问题。一个关键的区别是,在分割数据中通常存在较大的类别不平衡,因为与其他与对象相关的像素相比,“背景”类通常占据了图像的主导地位。为了开始,您阅读了几篇博客文章和研究论文,并将加权分类交叉熵损失和 Dice 损失视为很好的候选项。您专注于三个不同的度量:像素精度,平均(类别加权)精度和平均 IoU。

图像分割模型中使用的损失函数和评估指标与图像分类器中使用的不同。首先,图像分类器接受单个图像的单个类标签,而分割模型预测图像中每个单个像素的类别。这凸显了不仅需要重新构想现有的损失函数和评估指标,而且需要发明适用于分割模型产生的输出的新的损失和评估指标。我们首先讨论损失函数,然后是指标。

8.4.1 损失函数

损失函数是用于优化其目的是找到最小化定义的损失的参数的模型的。深度网络中使用的损失函数必须是可微分的,因为损失的最小化是通过梯度进行的。我们将使用的损失函数包含两个损失函数:

  • 交叉熵损失

  • Dice 损失

交叉熵损失

交叉熵损失是分割任务中最常用的损失之一,可以在 Keras 中仅用一行代码实现。我们已经使用了交叉熵损失很多次,但没有详细分析它。然而,回顾支配交叉熵损失的基础机制是值得的。

对于交叉熵损失函数,需要输入预测目标和真实目标。这两个张量都具有[batch size, height, width, object classes]的形状。对象类维度是给定像素属于哪个对象类别的一种独热编码表示。然后,对每个像素独立地计算交叉熵损失。

8_11a

其中CEi, j)表示图像位置(i, j)处像素的交叉熵损失,c是类别数,y[k]和ŷ[k]分别表示该像素的独热编码向量中元素和预测概率分布的元素。然后在所有像素上求和以获得最终损失。

在这种方法的简单背后,隐藏着一个关键问题。在图像分割问题中,类别不平衡几乎肯定会出现。你几乎找不到每个对象在图像中占据相等面积的真实世界图像。好消息是,处理这个问题并不是很困难。这可以通过为图像中的每个像素分配一个权重来缓解,这个权重取决于它所代表的类别的显 dominance。属于大对象的像素将具有较小的权重,而属于较小对象的像素将具有较大的权重,尽管在最终损失中大小相等。接下来的列表显示了如何在 TensorFlow 中执行此操作。

列表 8.13 计算给定数据批次的标签权重

def get_label_weights(y_true, y_pred):

    weights = tf.reduce_sum(tf.one_hot(y_true, num_classes), axis=[1,2])  ❶

    tot = tf.reduce_sum(weights, axis=-1, keepdims=True)                  ❷

    weights = (tot - weights) / tot  # [b, classes]                       ❸

    y_true = tf.reshape(y_true, [-1, y_pred.shape[1]*y_pred.shape[2]])    ❹

    y_weights = tf.gather(params=weights, indices=y_true, batch_dims=1)   ❺
    y_weights = tf.reshape(y_weights, [-1])                               ❻

    return y_weights

❶ 获取 y_true 中每个类别的总像素数。

❷ 获取 y_true 中的总像素数。

❸ 计算每个类别的权重。更稀有的类别获得更多的权重。

❹ 将 y_true 重塑为 [batch size, height*width] 大小的张量。

❺ 通过收集与 y_true 中索引对应的权重来创建权重向量。

❻ 使 y_weights 成为一个向量。

在这里,对于给定的批次,我们将权重计算为一个序列/向量,其元素数量等于 y_true。首先,我们通过计算 one-hot 编码的 y_true(即具有批次、高度、宽度和类别维度的尺寸)的宽度和高度上的总和来获取每个类别的像素总数。在这里,值大于 num_classes 的类将被忽略。接下来,我们通过对类维度求和来计算每个样本的像素总数,得到 tot(一个 [batch size, 1] 大小的张量)。现在可以计算每个样本和每个类别的权重。

08_11b

其中 n 是像素的总数,n^i 是属于第 i 个类的像素的总数。之后,我们将 y_true 重塑为形状 [batch size, -1],为权重计算的重要步骤做准备。作为最终输出,我们希望从 y_weights 中创建一个张量,其中我们从 y_true 中提取对应于 y_weights 中元素的元素。换句话说,我们从 y_weights 中获取值,其中给定索引由 y_true 中的值给出。最后,结果将与 y_true 的形状和大小相同。这就是我们需要加权样本的全部内容:对每个像素的损失值逐元素乘以权重。为了实现这一点,我们将使用函数 tf.gather(),该函数从给定的张量(params)中收集元素,同时获取表示索引的张量(indices),并返回与索引相同形状的张量:

y_weights = tf.gather(params=weights, indices=y_true, batch_dims=1)

在这里,在执行 gather 时忽略批次维度,我们传递了参数 batch_dims,指示我们有多少批次维度。有了这个,我们将定义一个函数,给出一批预测和真实目标,输出加权交叉熵损失。

现在权重准备好了,我们可以实现第一个分割损失函数。我们将实现加权交叉熵损失。一眼看去,该函数会屏蔽不相关的像素(例如属于未知对象的像素),并展开预测标签和真实标签以消除高度和宽度维度。最后,我们可以使用 TensorFlow 中的内置函数计算交叉熵损失(请参见下一个清单)。

清单 8.14 实现加权交叉熵损失

def ce_weighted_from_logits(num_classes):

    def loss_fn(y_true, y_pred):
        """ Defining cross entropy weighted loss """

        valid_mask = tf.cast(
            tf.reshape((y_true <= num_classes - 1), [-1,1]), 'int32'
        )                                                                ❶

        y_true = tf.cast(y_true, 'int32')                                ❷
        y_true.set_shape([None, y_pred.shape[1], y_pred.shape[2]])       ❷

        y_weights = get_label_weights(y_true, y_pred)                    ❸
        y_pred_unwrap = tf.reshape(y_pred, [-1, num_classes])            ❹
        y_true_unwrap = tf.reshape(y_true, [-1])                         ❹

        return tf.reduce_mean(
            y_weights * tf.nn.sparse_softmax_cross_entropy_with_logits(  ❺
                y_true_unwrap * tf.squeeze(valid_mask), 
                y_pred_unwrap * tf.cast(valid_mask, 'float32')) 
        )

    return loss_fn                                                       ❻

❶ 定义有效掩码,用于屏蔽不必要的像素。

❷ 对 y_true 进行了一些初步设置,将其转换为 int 并设置形状。

❸ 获取标签权重。

❹ 展开 y_pred 和 y_true,以消除批处理、高度和宽度维度。

❺ 使用 y_true、y_pred 和掩码计算交叉熵损失。

❻ 返回计算损失的函数。

你可能会想,“为什么将损失定义为嵌套函数?”如果我们需要向损失函数中包含额外的参数(例如 num_classes),则必须遵循此标准模式。我们正在将损失函数的计算捕获在 loss_fn 函数中,然后创建一个外部函数 ce_weighted_from_logits(),该函数将返回封装损失计算(即 loss_fn)的函数。

具体地,创建有效掩码以指示 y_true 中的标签是否小于类数。任何值大于类数的标签都会被忽略(例如未知对象)。接下来,我们获取权重向量,并使用 get_label_weights() 函数为每个像素指定权重。我们将 y_pred 展开成 [-1, num_classes] 大小的张量,因为 y_pred 包含数据集中所有类别的 logits(即模型输出的未归一化概率分数)。y_true 将展开为一个向量(也就是单维),因为 y_true 只包含类别标签。最后,我们使用 tf.nn.sparse_softmax_cross_entropy_with_logits() 来计算掩码预测和真实目标的损失。该函数有两个参数,标签和 logits,很容易理解。我们可以得出两个重要的观察结果:

  • 我们正在计算稀疏交叉熵损失(而不是标准交叉熵损失)。

  • 我们从 logits 中计算交叉熵损失。

当使用稀疏交叉熵时,我们不需要对标签进行独热编码,因此可以跳过此步骤,这会导致数据管道更具内存效率。这是因为独热编码在模型内部处理。通过使用稀疏损失,我们需要担心的东西就更少了。

从 logits(即未归一化分数)而不是从归一化概率计算损失会导致更好、更稳定的渐变。因此,尽可能地使用 logits 而不是归一化概率。

Dice 损失

我们将讨论的第二种损失函数称为 Dice 损失,其计算方式如下:

08_11c

在这里,可以通过逐元素乘法计算预测和目标张量之间的交集,而可以通过逐元素加法计算预测和目标张量之间的并集。你可能会觉得使用逐元素操作来计算交集和并集是一种奇怪的方式。为了理解背后的原因,我想引用之前提到过的一句话:深度网络中使用的损失函数必须是可微分的

这意味着我们不能使用我们通常用来计算交集和并集的标准方法。相反,我们需要采用可微分计算的方法,得到两个张量之间的交集和并集。交集可以通过预测值和真实目标之间的逐元素乘法来计算。并集可以通过预测值和真实目标之间的逐元素加法来计算。图 8.12 阐明了这些操作如何导致两个张量之间的交集和并集。

08-12

图 8.12 显示了 dice 损失中涉及的计算。交集可以通过逐元素乘法计算为一个可微分函数,而并集可以通过逐元素求和计算。

这个损失函数主要关注最大化预测值和真实目标之间的交集。乘数 2 的使用是为了平衡来自交集和并集之间重叠的值的重复,其出现在分母中(参见下面的列表)。

列表 8.15 实现 dice 损失

def dice_loss_from_logits(num_classes):
    """ Defining the dice loss 1 - [(2* i + 1)/(u + i)]"""    

    def loss_fn(y_true, y_pred):

        smooth = 1.

        # Convert y_true to int and set shape
        y_true = tf.cast(y_true, 'int32')                                      ❶
        y_true.set_shape([None, y_pred.shape[1], y_pred.shape[2]])             ❶

        # Get pixel weights
        y_weights = tf.reshape(get_label_weights(y_true, y_pred), [-1, 1])     ❷

        # Apply softmax to logits      
        y_pred = tf.nn.softmax(y_pred)                                         ❸

        y_true_unwrap = tf.reshape(y_true, [-1])                               ❹
        y_true_unwrap = tf.cast(
            tf.one_hot(tf.cast(y_true_unwrap, 'int32'), num_classes), 
➥ 'float32'
        )                                                                      ❹
        y_pred_unwrap = tf.reshape(y_pred, [-1, num_classes])                  ❹

        intersection = tf.reduce_sum(y_true_unwrap * y_pred_unwrap * y_weights)❺

        union = tf.reduce_sum((y_true_unwrap + y_pred_unwrap) * y_weights)     ❻

        score = (2\. * intersection + smooth) / ( union + smooth)               ❼

        loss = 1 - score                                                       ❽

        return loss

    return loss_fn

❶ y_true 的初始设置

❷ 获取标签权重并将其重塑为 [-1, 1] 的形状。

❸ 对 y_pred 应用 softmax 函数以得到归一化概率。

❹ 将 y_pred 和 one-hot 编码的 y_true 展开为 [-1, num_classes] 的形状。

❺ 使用逐元素乘法计算交集。

❻ 使用逐元素加法计算并集。

❼ 计算 dice 系数。

❽ 计算 dice 损失。

在这里,smooth 是一个平滑参数,我们将用它来避免可能导致除以零而产生 NaN 值的情况。然后我们进行以下操作:

  • 获取每个 y_true 标签的权重

  • 对 y_pred 应用 softmax 激活函数

  • 将 y_pred 展开为 [-1, num_classes] 的张量,将 y_true 展开为大小为 [-1] 的向量

然后计算 y_pred 和 y_true 的交集和并集。具体来说,交集是通过 y_pred 和 y_true 的逐元素乘法计算出来的,而并集是通过 y_pred 和 y_true 的逐元素加法计算出来的。

焦点损失

焦点损失是一种相对较新的损失,它在论文“用于密集目标预测的焦点损失”中介绍(arxiv.org/pdf/1708.02002.pdf)。焦点损失是为了应对分割任务中发现的严重类别不平衡而引入的。具体地,它解决了许多简单示例(例如,来自具有较小损失的常见类的样本)过多的问题,而不是强大的少数困难示例(例如,来自具有较大损失的稀有类的样本)。焦点损失通过引入调制因子来解决这个问题,该调制因子将减小简单示例的权重,因此,损失函数自然更多地关注学习困难示例。

我们将用于优化分割模型的损失函数将是由稀疏交叉熵损失和 Dice 损失相加而得到的损失(见下一列表)。

列表 8.16 最终组合损失函数

def ce_dice_loss_from_logits(num_classes):

    def loss_fn(y_true, y_pred):
        # Sum of cross entropy and dice losses
        loss = ce_weighted_from_logits(num_classes)(
            tf.cast(y_true, 'int32'), y_pred
        ) + dice_loss_from_logits(num_classes)(
            y_true, y_pred
        )    

        return loss

    return loss_fn 

接下来,我们将讨论评估指标。

8.4.2 评估指标

评估指标在模型训练中扮演着重要角色,作为模型的健康检查。这意味着可以通过确保评估指标的行为合理快速识别性能低下或问题。在这里,我们将讨论三种不同的指标:

  • 像素

  • 平均准确率

  • 均交并比

我们将通过利用 TensorFlow 中的一些现有指标来实现这些自定义指标,在这些指标中,你必须从 tf.keras.metrics.Metric 类或其中一个现有指标的基类派生子类。这意味着你需要创建一个新的 Python 类,它继承自其中一个现有具体指标类的基类 tf.keras.metrics.Metric:

class MyMetric(tf.keras.metrics.Metric):

  def __init__(self, name='binary_true_positives', **kwargs):
    super(MyMetric, self).__init__(name=name, **kwargs)

    # Create state related variables

  def update_state(self, y_true, y_pred, sample_weight=None):

    # update state in this function

  def result(self):

    # We return the result computed from the state

  def reset_states():
    # Do what’s required to reset the maintained states
    # This function is called between epochs     

关于指标,你需要了解的第一件事是它是一个有状态的对象,这意味着它维护着一个状态。例如,一个单独的周期有多个迭代,假设你对计算准确率感兴趣。指标需要累积计算准确率所需的值,以便在结束时,它可以计算该周期的平均准确率。在定义指标时,你需要注意三个函数:init、update_state 和 result,以及 reset_states。

让我们具体点,假设我们正在实现一个准确率指标(即 y_pred 中匹配 y_true 元素的百分比)。它需要维护一个总数:我们传递的所有准确率值的总和和计数(我们传递的准确率值的数量)。有了这两个状态元素,我们可以随时计算平均准确率。当实现准确率指标时,你需要实现这些函数:

  • init—定义了两个状态;总数和计数

  • update_state—基于 y_true 和 y_pred 更新总数和计数

  • result—计算平均准确率为总数/计数

  • reset_states—重置总数和计数(这需要在周期开始时发生)

让我们看看这些知识如何转化为我们感兴趣的评估指标。

像素和平均准确率

像素准确度是你可以想到的最简单的指标。它衡量了预测和真实目标之间的像素精度(见下一个清单)。

清单 8.17 实现像素准确度指标

class PixelAccuracyMetric(tf.keras.metrics.Accuracy):

  def __init__(self, num_classes, name='pixel_accuracy', **kwargs):
    super(PixelAccuracyMetric, self).__init__(name=name, **kwargs)    

  def update_state(self, y_true, y_pred, sample_weight=None):

    y_true.set_shape([None, y_pred.shape[1], y_pred.shape[2]])      ❶
    y_true = tf.reshape(y_true, [-1])                               ❷

    y_pred = tf.reshape(tf.argmax(y_pred, axis=-1),[-1])            ❸

    valid_mask = tf.reshape((y_true <= num_classes - 1), [-1])      ❹

    y_true = tf.boolean_mask(y_true, valid_mask)                    ❺
    y_pred = tf.boolean_mask(y_pred, valid_mask)                    ❺

    super(PixelAccuracyMetric, self).update_state(y_true, y_pred)   ❻

❶ 设置 y_true 的形状(以防未定义)。

❷ 将 y_true 重新调整为向量。

❸ 将 y_pred 取 argmax 后重新调整形状为向量。

❹ 定义一个有效的掩码(屏蔽不必要的像素)。

❺ 收集满足 valid_mask 条件的像素/标签。

❻ 使用处理过的 y_true 和 y_pred,使用 update_state()函数计算准确度。

像素准确度计算预测像素和真实像素之间的一一匹配。为了计算这个指标,我们从 tf.keras.metrics.Accuracy 进行子类化,因为它具有我们需要的所有计算。为此,我们重写 update_state 函数如下所示。我们需要注意以下几点:

  • 我们需要作为预防措施设置 y_true 的形状。这是因为在处理 tf.data.Dataset 时,有时会丢失形状。

  • 将 y_true 重新调整为向量。

  • 通过执行 tf.argmax()获取 y_pred 的类别标签,并将其重新调整为向量。

  • 定义一个有效的掩码,忽略不需要的类别(例如,未知对象)。

  • 获取仅满足 valid_mask 过滤器的像素。

一旦完成这些任务,我们只需调用父对象(即,tf.keras.metrics.Accuracy)的 update_state 方法,并传递相应的参数。我们不需要重写 result()和 reset_states()函数,因为它们已经包含了正确的计算。

我们说图像分割问题中普遍存在类别不平衡。通常,背景像素将在图像的大区域中散布,可能导致错误的结论。因此,稍微更好的方法可能是分别计算每个类别的准确度,然后取平均值。这就是平均准确度的作用,它防止了像素准确度的不良特性(见下一个清单)。

清单 8.18 实现平均准确度指标

class MeanAccuracyMetric(tf.keras.metrics.Mean):

  def __init__(self, num_classes, name='mean_accuracy', **kwargs):
    super(MeanAccuracyMetric, self).__init__(name=name, **kwargs)    

  def update_state(self, y_true, y_pred, sample_weight=None):

    smooth = 1            

    y_true.set_shape([None, y_pred.shape[1], y_pred.shape[2]])             ❶

    y_true = tf.reshape(y_true, [-1])                                      ❶
    y_pred = tf.reshape(tf.argmax(y_pred, axis=-1),[-1])                   ❶

    valid_mask = tf.reshape((y_true <= num_classes - 1), [-1])             ❶

    y_true = tf.boolean_mask(y_true, valid_mask)                           ❶
    y_pred = tf.boolean_mask(y_pred, valid_mask)                           ❶

    conf_matrix = tf.cast(                                                            
        tf.math.confusion_matrix(y_true, y_pred, num_classes=num_classes), 
➥ 'float32'                                                               ❷
    )
    true_pos = tf.linalg.diag_part(conf_matrix)                            ❸

    mean_accuracy = tf.reduce_mean(
        (true_pos + smooth)/(tf.reduce_sum(conf_matrix, axis=1) + smooth)  ❹
    )

    super(MeanAccuracyMetric, self).update_state(mean_accuracy)            ❺

❶ 初始设置

❷ 使用 y_true 和 y_pred 计算混淆矩阵。

❸ 获取真正的正例(对角线上的元素)。

❹ 使用每个类别的真正正例和真正类别计数计算平均准确度。

❺ 使用 update_state()函数计算 mean_accuracy 的平均值。

MeanAccuracyMetric 将从 tf.keras.metrics.Mean 分支出来,它计算给定值序列的平均值。计划是在 update_state()函数中计算 mean_accuracy,然后将该值传递给父类的 update_state()函数,以便得到平均准确度的平均值。首先,我们执行我们之前讨论的 y_true 和 y_pred 的初始设置和清理。

08-13

图 8.13 五类分类问题的混淆矩阵示意图。阴影区域表示真正的正例。

然后,从预测和真实目标计算混淆矩阵(图 8.13)。对于一种n种分类问题(即,具有n个可能类别的分类问题),混淆矩阵被定义为一个n×n矩阵。这里,位置(ij)处的元素表示预测为属于第i个类别的实例实际上属于第j个类别。图 8.13 描绘了这种类型的混淆矩阵。我们可以通过提取对角线(即,所有 1 <= i <= n的矩阵中的(ii)元素)来获取真阳性。现在我们可以通过两个步骤计算平均准确度:

  1. 对于所有类别,对真阳性计数进行逐元素除法。这将产生一个向量,其元素表示每个类别的准确性。

  2. 计算步骤 1 产生的向量平均值。

最后,我们将平均准确度传递给其父对象的 update_state()函数。

平均交并比

平均交并比(mean intersection over union)是用于分割任务的流行评估度量,与我们之前讨论的 Dice loss 密切相关,因为它们都使用交和并的概念来计算最终结果(见下一个列表)。

列表 8.19 实现平均交并比度量

class MeanIoUMetric(tf.keras.metrics.MeanIoU):

  def __init__(self, num_classes, name='mean_iou', **kwargs):
    super(MeanIoUMetric, self).__init__(num_classes=num_classes, name=name, **kwargs)    

  def update_state(self, y_true, y_pred, sample_weight=None):

    y_true.set_shape([None, y_pred.shape[1], y_pred.shape[2]])
    y_true = tf.reshape(y_true, [-1])

    y_pred = tf.reshape(tf.argmax(y_pred, axis=-1),[-1])

    valid_mask = tf.reshape((y_true <= num_classes - 1), [-1])

    # Get pixels corresponding to valid mask
    y_true = tf.boolean_mask(y_true, valid_mask)
    y_pred = tf.boolean_mask(y_pred, valid_mask)

    super(MeanIoUMetric, self).update_state(y_true, y_pred)    ❶

❶ 在 y_true 和 y_pred 的初始设置之后,我们只需调用父对象的 update_state()函数即可。

平均交并比的计算已经在 tf.keras.metrics.MeanIoU 中找到。因此,我们将使用它作为我们的父类。我们所需做的就是为 y_true 和 y_pred 进行前述设置,然后调用父对象的 update_state()函数。平均交并比计算为

08_13a

在该计算中使用的各种元素如图 8.14 所示。

08-14

图 8.14 混淆矩阵,以及如何用它来计算假阳性,假阴性和真阳性

我们现在理解了可用于我们的损失函数和评估指标,并已经实现了它们。我们可以将这些损失编译到模型中:

deeplabv3.compile(
    loss=ce_dice_loss_from_logits(num_classes), 
    optimizer=optimizer, 
    metrics=[
        MeanIoUMetric(num_classes), 
        MeanAccuracyMetric(num_classes), 
        PixelAccuracyMetric(num_classes)
    ])

记住,我们从前面删除的卷积块中存储了权重。现在我们已经编译了模型,我们可以使用以下语法将权重复制到新模型中:

# Setting weights for newly added layers
for k, w *in* w_dict.items():    
    deeplabv3.get_layer(k).set_weights(w)

我们现在开始使用数据管道和我们定义的模型来训练模型。

练习 4

您正在设计一个新的损失函数,用于计算 y_true 和 y_pred 之间的不相交并集。两个集合 A 和 B 之间的不相交并集是在 A 或 B 中但不在交集中的元素的集合。您知道可以通过 y_true 和 y_pred 的元素乘法计算交并通过 y_true 和 y_pred 的元素加法计算。编写函数的等式,以根据 y_true 和 y_pred 计算不相交并集。

8.5 训练模型

您已经进入产品第一次迭代的最后阶段。现在是时候充分利用您获得的数据和知识了(即,训练模型)。我们将训练模型进行 25 个周期,并监视像素精度、平均精度和平均 IoU 指标。在训练过程中,我们将衡量验证数据集的性能。

训练模型是最容易的部分,因为我们已经完成了导致训练的艰苦工作。现在只需要在我们刚刚定义的 DeepLab v3 上使用正确的参数调用 fit(),如下面的列表所示。

图 8.20 训练模型

if *not* os.path.exists('eval'):
    os.mkdir('eval')

csv_logger = tf.keras.callbacks.CSVLogger(
    os.path.join('eval','1_pretrained_deeplabv3.log')                      ❶

monitor_metric = 'val_loss'
mode = 'min' if 'loss' *in* monitor_metric else 'max'                        ❷
print("Using metric={} and mode={} for EarlyStopping".format(monitor_metric, mode))
lr_callback = tf.keras.callbacks.ReduceLROnPlateau(
    monitor=monitor_metric, factor=0.1, patience=3, mode=mode, min_lr=1e-8 ❸
)
es_callback = tf.keras.callbacks.EarlyStopping(
    monitor=monitor_metric, patience=6, mode=mode                          ❹
)

# Train the model
deeplabv3.fit(                                                             ❺
    x=tr_image_ds, steps_per_epoch=n_train,
    validation_data=val_image_ds, validation_steps=n_valid, 
    epochs=epochs, callbacks=[lr_callback, csv_logger, es_callback])

❶ 训练记录器

❷ 自动根据指标名称设置后续回调函数的模式。

❸ 学习率调度器

❹ 提前停止回调

❺ 在使用验证集进行学习率调整和提前停止的同时训练模型。

首先,如果不存在,我们将定义一个名为 eval 的目录。训练日志将保存在这个目录中。接下来,我们定义三个不同的回调函数在训练过程中使用:

  • csv_logger—记录训练损失/指标和验证损失/指标

  • lr_callback—如果验证损失在三个周期内没有减少,则将学习率减小 10 倍

  • es_callback—如果验证损失在六个周期内没有减少,则执行提前停止

注意 在一台配备英特尔 Core i5 处理器和 NVIDIA GeForce RTX 2070 8GB 显卡的机器上,训练大约需要 45 分钟来运行 25 个周期。

有了这个,我们使用以下参数调用 deeplabv3.fit():

  • x—生成训练实例的 tf.data 流水线(设置为 tr_image_ds)。

  • steps_per_epoch—每个周期的步数。这是通过计算训练实例的数量并将其除以批次大小获得的(设置为 n_train)。

  • validation_data—生成验证实例的 tf.data 流水线。这是通过计算验证实例的数量并将其除以批次大小来获得的(设置为 val_image_ds)。

  • epochs—周期数(设置为 epochs)。

  • callbacks—我们之前设置的回调函数(设置为 [lr_callback, csv_logger, es_callback])。

在模型训练完成后,我们将在测试集上进行评估。我们还将可视化模型生成的分割结果。

练习 5

您有一个包含 10,000 个样本的数据集,并将其分成 90% 的训练数据和 10% 的验证数据。您使用的训练批次大小为 10,验证批次大小为 20。单个周期中将有多少个训练和验证步骤?

8.6 评估模型

让我们花点时间回顾一下我们到目前为止所做的事情。我们定义了一个数据管道来读取图像并将它们准备为模型的输入和目标。然后我们定义了一个称为 DeepLab v3 的模型,它使用预训练的 ResNet-50 作为其主干网络,并使用称为空洞空间金字塔池的特殊模块来预测最终的分割掩模。然后我们定义了任务特定的损失和指标,以确保我们可以使用各种指标评估模型。之后,我们对模型进行了训练。现在是最终揭晓的时候了。我们将在一个未见过的测试数据集上评估性能,看看模型的表现如何。我们还将可视化模型输出,并将其与真实目标进行比较,将它们并排绘制出来。

我们可以在未见过的测试图像上运行模型,并评估其性能。为此,我们执行以下操作:

deeplabv3.evaluate(test_image_ds, steps=n_valid)

测试集的大小与验证集相同,因为我们将在 val.txt 中列出的图像分成两个相等的验证集和测试集。这将返回大约

  • 62% 的平均 IoU

  • 87% 的平均准确率

  • 91% 的像素准确率

考虑到我们的情况,这些得分非常可观。我们的训练数据集包含不到 1500 张分割图像。使用这些数据,我们能够训练出一个在大小约为 725 的测试数据集上达到约 62% 平均 IoU 的模型。

什么是技术发展的最新成果?

Pascal VOC 2012 的最新性能报告显示约 90% 的平均 IoU (mng.bz/o2m2)。然而,这些模型比我们在这里使用的模型要大得多且复杂得多。此外,它们通常使用一个称为语义边界数据集(SBD)的辅助数据集进行训练(该数据集在论文 mng.bz/nNve 中介绍)。这将使训练数据点数量增加到 10000 多个(几乎是我们当前训练集大小的七倍)。

您可以通过直观检查模块生成的一些结果来进一步研究模型。毕竟,我们正在开发一种视觉模型。因此,我们不应仅仅依靠数字来做出决定和结论。在确定结论之前,我们还应该对结果进行视觉分析。

基于 U-Net 的网络的结果会是什么样的呢?

在为 DeepLab v3 模型提供类似条件的情况下,使用预训练的 ResNet-50 模型作为编码器构建的 U-Net 模型仅能达到约 32.5% 的平均 IoU、78.5% 的平均准确率和 81.5% 的像素准确率。实现细节在 ch08 文件夹的 Jupyter 笔记本中提供。

在一台搭载 Intel Core i5 处理器和 NVIDIA GeForce RTX 2070 8GB 显卡的机器上,训练 25 个周期大约需要 55 分钟。

U-Net 模型的详细解释见附录 B。

为了完成这个调查,我们将从测试集中随机选择一些样本,并要求模型对每个图像进行分割地图的预测。然后我们将将结果并排绘制在一起,以确保我们的模型工作得很好(图 8.15)。

08-15

图 8.15 对比真实标注目标和模型预测结果。可以看出,该模型在将不同背景的物体分离方面表现很好。

我们可以看到,除非是一张非常困难的图像(例如左上角的图像,其中有一个被栅栏遮挡的汽车),我们的模型表现得非常好。它可以以高准确率识别我们分析的样本中几乎所有的图像。可在笔记本中找到显示图像的代码。

这就是我们关于图像分割的讨论。在接下来的几章中,我们将讨论几个自然语言处理问题。

练习 6

您被给予的是

  • 一个模型(称为 model)

  • 名为 batch_image 的图像批次(已预处理并准备好馈送给模型)

  • 相应的目标批次,batch_targets(以独热编码格式表示的真实分割掩码)

编写一个名为 get_top_bad_examples(model, batch_images, batch_targets, n) 的函数,它将返回批次图像中损失最高(最难的)图像的前 n 个索引。对于给定的预测掩码和目标掩码,可以将元素逐个相乘的和作为给定图像的损失。

您可以使用 model.predict() 函数对 batch_images 进行预测,并返回与 batch_targets 相同大小的预测掩码。一旦计算出批次的损失(batch_loss),您可以使用 tf.math.top_k(batch_loss, n) 函数获取具有最高值的元素的索引。 tf.math .top_k() 返回一个元组,包含给定向量的前 n 个值和索引,按顺序排列。

总结

  • 分割模型分为两大类别:语义分割和实例分割。

  • tf.data API 提供了多种功能来实现复杂的数据流水线,例如使用自定义的 NumPy 函数,使用 tf.data.Dataset.map() 执行快速转换以及使用 prefetch 和 cache 等 I/O 优化技术。

  • DeepLab v3 是一种常用的分割模型,它使用了预训练的 ResNet-50 模型作为骨干网络,并通过在卷积操作的权重之间插入孔(即零)来增加感受野。

  • DeepLab v3 模型使用称为空洞空间金字塔池化(atrous spatial pyramid pooling)的模块来在多个尺度上聚合信息,以生成精细分割的输出。

  • 在分割任务中,交叉熵损失和 dice 损失是两种常用的损失函数,而像素准确率、平均准确率和平均 IoU 是常用的评估指标。

  • 在 TensorFlow 中,损失函数可以实现为无状态函数。但是度量指标必须通过从 tf.keras.metrics.Metric 基类或适当的类中继承来实现为具有状态的对象。

  • DeepLab v3 模型在 Pascal VOC 2010 数据集上达到了 62%的平均 IoU 精度。

练习答案

练习 1

palettized_image = np.zeros(shape=rgb_image.shape[:-1])
for i in range(rgb_image.shape[0]):
    for j in range(rgb_image.shape[1]):
        for k in range(palette.shape[0]):
            if (palette[k] == rgb_image[i,j]).all():
                palettized_image[i,j] = k
                break

练习 2

dataset_a = tf.data.Dataset.from_tensor_slices(a)
dataset_b = tf.data.Dataset.from_tensor_slices(b)

image_ds = tf.data.Dataset.zip((dataset_a, dataset_b))

image_ds = image_ds.map(
            lambda x, y: (x, tf.image.resize(y, (64,64),  method='nearest'))
        )

image_ds = image_ds.map(
            lambda x, y: ((x-128.0)/255.0, tf.image.resize(y, (64,64),  method='nearest'))
        )

image_ds = image_ds.batch(32).repeat(5).prefetch(tf.data.experimental.AUTOTUNE)

练习 3

import tensorflow.keras.layers as layers

def aug_aspp(out_1, out_2):

    atrous_out_1 = layers.Conv2D(128, (3,3), dilation_rate=16, 
➥ padding='same', activation='relu')(out_1)

    atrous_out_2_1 = layers.Conv2D(128, (3,3), dilation_rate=8, 
➥ padding='same', activation='relu')(out_1)
    atrous_out_2_2 = layers.Conv2D(128, (3,3), dilation_rate=8, 
➥ padding='same', activation='relu')(out_2)
    atrous_out_2 = layers.Concatenate()([atrous_out_2_1, atrous_out_2_2])

    tmp1 = layers.Conv2D(64, (1,1), padding='same', activation='relu')(atrous_out_1)
    tmp2 = layers.Conv2D(64, (1,1), padding='same', activation='relu')(atrous_out_2)
    conv_out = layers.Concatenate()([tmp1,tmp2])

    out = layers.UpSampling2D((2,2), interpolation='bilinear')(conv_out)
    out = layers.Activation('sigmoid')(out)

    return out

练习 4

out = (y_pred - (y_pred * y_true)) + (y_true - (y_pred * y_true))

练习 5

练习 6

def get_top_n_bad_examples(model, batch_images, batch_targets, n):

    batch_pred = model.predict(batch_images)

    batch_loss = tf.reduce_sum(batch_pred*batch_targets, axis=[1,2,3])

    _, hard_inds = tf.math.top_k(batch_loss, n)

    return hard_inds

第九章:使用 TensorFlow 进行自然语言处理:情感分析

本章涵盖内容

  • 使用 Python 对文本进行预处理

  • 分析对模型重要的文本特定属性

  • 使用 TensorFlow 创建处理文本序列的数据管道

  • 使用递归深度学习模型(LSTM)进行情感分析

  • 对不平衡的产品评论进行模型训练

  • 实现词嵌入以提高模型性能

在之前的章节中,我们看了两个与计算机视觉相关的应用程序:图像分类和图像分割。图像分类侧重于识别图像中是否存在属于某个类别的对象。图像分割任务不仅关注于识别图像中存在的对象,还关注于图像中哪些像素属于某个对象。我们还围绕学习复杂卷积神经网络的基础展开了讨论,比如 Inception net(图像分类)和 DeepLab v3(图像分割)模型。如果我们超越图像,文本数据也是一种重要的数据形式。例如,全球范围内的网络充斥着文本数据。我们可以有把握地认为,它是网络上最常见的数据形式。因此,自然语言处理(NLP)一直是一个根深蒂固的主题,使我们能够利用免费的文本(例如,通过语言建模)的力量,并构建能够利用文本数据产生有意义结果的机器学习产品(例如,情感分析)。

自然语言处理是一个概括性概念,涵盖了与文本相关的各种任务。从简单的任务,比如改变文本的大小写(例如,将大写转换为小写),到复杂的任务,比如翻译语言和词义消歧(根据上下文推断具有相同拼写的单词的含义)都属于自然语言处理的范畴。以下是您在进入自然语言处理领域时将遇到的一些显著任务:

  • 停用词去除——停用词是频繁出现在文本语料库中的无信息词(例如,“and”,“it”,“the”,“am”等)。通常情况下,这些词对文本的语义(或含义)几乎没有或没有任何贡献。为了减少特征空间,许多任务在将文本馈送到模型之前的早期阶段去除停用词。

  • 词形还原——这是另一种减少模型需要处理的特征空间的技术。词形还原将给定单词转换为其基本形式(例如,将 buses 转换为 bus,walked 转换为 walk,went 转换为 go 等),从而减少词汇表的大小,进而减少模型需要学习的数据的维度。

  • 词性标注(PoS)—PoS 标注就是标注给定文本中的每个单词的词性(例如名词、动词、形容词等)。Penn Treebank 项目提供了最流行和最全面的 PoS 标签列表之一。要查看完整列表,请访问mng.bz/mO1W

  • 命名实体识别(NER)—NER 负责从文本中提取各种实体(例如人名/公司名、地理位置等)。

  • 语言建模—语言建模的任务是预测第n(n>1)个单词,给定前面 1 至w-1 个单词。通过在相关数据上训练模型,可以使用语言建模生成歌曲、电影脚本、故事等。由于语言建模所需的数据具有高度的可访问性(即不需要任何标记数据),因此通常作为预训练方法用于为决策支持 NLP 模型注入语言理解。

  • 情感分析—情感分析是根据一段文本的情感来进行评价的任务。例如,情感分析器可以分析产品/电影评论并自动生成一个得分来表示产品质量。

  • 机器翻译—机器翻译是将源语言中的短语/句子翻译为目标语言中的短语/句子的任务。这些模型是使用双语平行文本语料库进行训练的。

作为数据科学家或 ML 研究人员,几乎不可能不遇到 NLP 任务。要快速而成功地解决 NLP 任务,重要的是要了解处理数据、使用标准模型等等。

在本章中,您将学习如何为视频游戏评论分类开发情感分析器。您将开始探索数据,并了解一些常见的 NLP 预处理步骤。您还将注意到数据集不平衡(即对于所有的类别数量不大致相等),并学习该怎么做。然后,我们将开发一个 TensorFlow 数据管道,通过该管道,我们将向模型输入数据进行训练。在这里,您将遇到一种在 NLP 领域中有影响的新型机器学习模型,即长期短期记忆(LSTM)模型。LSTM 模型可以通过迭代地处理序列(例如,句子中以特定顺序排列的一串单词)来生成最终结果。在这个任务中,模型将输出一个二进制值(0 表示负面评价,1 表示正面评价)。在遍历序列时,LSTM 模型保持迄今为止所见到的部分的记忆。这使得 LSTM 模型非常强大,能够处理长序列并学习其中的模式。训练模型后,我们将在某些测试数据上对其进行评估以确保其表现良好,然后保存它。我们将遵循以下高级步骤来开发此模型:

  1. 下载数据。我们将使用 Amazon 的一个视频游戏评论语料库。

  2. 探索并清理数据。加入一些文本清理步骤(例如,词形还原)来清理数据并为建模任务准备语料库。

  3. 创建一个数据管道,将原始文本转换为机器学习模型理解的数值格式。

  4. 在数据管道生成的数据上训练模型。

  5. 在验证集和测试集上评估模型,以确保模型的泛化能力。

  6. 保存训练好的模型,并写下性能结果。

9.1 这段文字是什么?探索和处理文字

你正在为一个流行的在线视频游戏商店构建情感分析器。他们希望比星级数量更多一些,因为星级数量可能由于星级的主观性而不能准确反映情感。高管们相信文字比星级更有价值。你被要求开发一个情感分析器,可以根据文本确定评论的积极或消极程度。

你决定使用亚马逊视频游戏评论数据集。它包含用户发布的各种评论以及星级数量。由于语言的复杂性、拼写错误等原因,文本可能会非常嘈杂。因此,某种类型的预处理将充当生成干净数据的守门人。在本节中,我们将检查数据和一些基本统计数据。然后,我们将执行几个预处理步骤:包括、转换为小写(例如,将“约翰”转换为“john”)、删除标点符号/数字、删除停用词(即无信息的词,如“to”、“the”、“a”等)和词形还原(将单词转换为其基本形式;例如,“walking”转换为“walk”)。

作为第一步,让我们在下一个清单中下载数据集。

清单 9.1 下载亚马逊评论数据集

import os
import requests
import gzip
import shutil

# Retrieve the data
if not os.path.exists(os.path.join('data','Video_Games_5.json.gz')):      ❶
    url = 
➥ "http:/ /deepyeti.ucsd.edu/jianmo/amazon/categoryFilesSmall/Video_Games_
➥ 5.json.gz"
    # Get the file from web
    r = requests.get(url)

    if not os.path.exists('data'):
        os.mkdir('data')

    # Write to a file
    with open(os.path.join('data','Video_Games_5.json.gz'), 'wb') as f:
        f.write(r.content)
else:                                                                     ❷
    print("The tar file already exists.")

if not os.path.exists(os.path.join('data', 'Video_Games_5.json')):        ❸
    with gzip.open(os.path.join('data','Video_Games_5.json.gz'), 'rb') as f_in:
        with open(os.path.join('data','Video_Games_5.json'), 'wb') as f_out:
            shutil.copyfileobj(f_in, f_out)
else:
    print("The extracted data already exists")

❶ 如果 gzip 文件尚未下载,请下载并保存到磁盘上。

❷ 如果 gzip 文件位于本地磁盘中,则无需下载。

❸ 如果 gzip 文件存在但尚未解压,请解压它。

这段代码将数据下载到本地文件夹(如果尚未存在)并提取内容。它将包含一个包含数据的 JSON 文件。 JSON 是一种用于表示数据的格式,主要用于在 Web 请求中传输数据。它允许我们将数据定义为键值对。如果你查看 JSON 文件,你会看到每行有一条记录,每条记录都是一组键值对,键是列名,值是该记录的该列的值。你可以从数据中提取出几条记录:

{"overall": 5.0, "verified": true, "reviewTime": "10 17, 2015", 
➥ "reviewerID": "xxx", "asin": "0700026657", "reviewerName": "xxx", 
➥ "reviewText": "This game is a bit hard to get the hang of, but when you 
➥ do it's great.", "summary": "but when you do it's great.", 
➥ "unixReviewTime": 1445040000}
{"overall": 4.0, "verified": false, "reviewTime": "07 27, 2015", 
➥ "reviewerID": "xxx", "asin": "0700026657", "reviewerName": "xxx", 
➥ "reviewText": "I played it a while but it was alright. The steam was a 
➥ bit of trouble. The more they move ... looking forward to anno 2205 I 
➥ really want to play my way to the moon.", "summary": "But in spite of 
➥ that it was fun, I liked it", "unixReviewTime": 1437955200}
{"overall": 3.0, "verified": true, "reviewTime": "02 23, 2015", 
➥ "reviewerID": "xxx", "asin": "0700026657", "reviewerName": "xxx", 
➥ "reviewText": "ok game.", "summary": "Three Stars", "unixReviewTime": 
➥ 1424649600}

接下来,我们将进一步探索我们拥有的数据:

import pandas as pd

# Read the JSON file
review_df = pd.read_json(
    os.path.join('data', 'Video_Games_5.json'), lines=True, orient='records'
)
# Select on the columns we're interested in 
review_df = review_df[["overall", "verified", "reviewTime", "reviewText"]]
review_df.head()

数据是以 JSON 格式呈现的。pandas 提供了一个 pd.read_json()函数来轻松读取 JSON 数据。在读取 JSON 数据时,你必须确保正确设置 orient 参数。这是因为 orient 参数使 pandas 能够理解 JSON 数据的结构。与具有更一致结构的 CSV 文件相比,JSON 数据是非结构化的。设置 orient='records'将使 pandas 能够正确读取以这种方式结构化的数据(每行一个记录)到一个 pandas DataFrame 中。运行上述代码片段将产生表 9.1 中所示的输出。

表 9.1 Amazon 评论数据集的示例数据

整体 验证 评论时间 评论文本
0 5 True 10 17, 2015 这个游戏有点难以掌握,但...
1 4 False 07 27, 2015 我玩了一会儿,还行。...
2 3 True 02 23, 2015 好吧,游戏还可以。
3 2 True 02 20, 2015 觉得这个游戏有点太复杂,不是我想要的...
4 5 True 12 25, 2014 好游戏,我喜欢它,从那时起就一直在玩...

我们现在将删除评论文本列中的任何空值或 null 值记录:

review_df = review_df[~review_df["reviewText"].isna()]
review_df = review_df[review_df["reviewText"].str.strip().str.len()>0]

正如你可能已经注意到的,有一列显示评论是否来自验证购买者。为了保护我们数据的完整性,让我们只考虑来自验证购买者的评论。但在此之前,我们必须确保在过滤未经验证的评论后有足够的数据。为此,让我们看看不同值(即 True 和 False)的验证列有多少记录。为此,我们将使用 pandas 的内置 value_counts()函数,如下所示:

review_df["verified"].value_counts()

这将会返回

True     332504
False    164915
Name: verified, dtype: int64

这是个好消息。看起来我们从验证购买者那里得到的数据比未验证用户的数据要多。让我们创建一个名为 verified_df 的新 DataFrame,其中只包含验证过的评论:

verified_df = review_df.loc[review_df["verified"], :]

接下来,我们将评估整体列中每个不同评分的评论数量:

verified_df["overall"].value_counts()

这将会给出

5    222335
4     54878
3     27973
1     15200
2     12118
Name: overall, dtype: int64

这是一个有趣的发现。通常,我们希望对每种不同的评分有相等数量的数据。但这在现实世界中从来都不是这样的。例如,在这里,我们有比 4 星评价多四倍的 5 星评价。这被称为类别不平衡。现实世界的数据通常是嘈杂的、不平衡的和肮脏的。当我们进一步研究数据时,我们将看到这些特征。在开发我们的模型时,我们将回到数据中类别不平衡的问题。

Sentiment analysis 被设计为一个分类问题。给定评论(例如,作为单词序列),模型预测一组离散类别中的一个类别。我们将专注于两个类别:积极或消极。我们将假设 5 或 4 星表示积极情感,而 3、2 或 1 星表示消极情感。精明的问题形式化,比如减少类别数量,可以使分类任务变得更容易。为此,我们可以使用方便的内置 pandas 函数 map()。map() 接受一个字典,其中键表示当前值,值表示当前值需要映射到的值:

verified_df["label"]=verified_df["overall"].map({5:1, 4:1, 3:0, 2:0, 1:0})

现在让我们在转换后检查每个类别的实例数量

verified_df["label"].value_counts()

将返回

1    277213
0     55291
Name: label, dtype: int64

积极样本约占 83%,消极样本约占 17%。这在样本数量上存在明显的不一致。我们简单的数据探索的最后一步是确保数据没有顺序。为了对数据进行洗牌,我们将使用 pandas 的 sample() 函数。sample() 技术上是用于从大数据集中抽样一小部分数据的。但通过设置 frac=1.0 和固定的随机种子,我们可以以随机的方式获取全部数据集的洗牌:

verified_df = verified_df.sample(frac=1.0, random_state=random_seed)

最后,我们将输入和标签分别分开成两个变量,因为这将使下一步的处理更容易:

inputs, labels = verified_df["reviewText"], verified_df["label"]

接下来,我们将专注于一项关键任务,这将最终改善进入模型的数据的质量:清理和预处理文本。在这里,我们将专注于执行以下子任务。您将在接下来的讨论中了解每个子任务的更多细节:

  • 将单词的大小写转换为小写。

  • 处理单词的缩写形式(例如,“aren’t”、“you’ll”等)。

  • 将文本标记为单词(称为 分词)。

  • 移除不相关的文本,如数字、标点和停用词。停用词是文本语料库中频繁出现但对于大多数自然语言处理任务来说其存在的价值不足以证明的单词(例如,“and”、“the”、“am”、“are”、“it”、“he”、“she”等)。

  • Lemmatize words. 词形还原是将单词转换为其基本形式的过程(例如,将复数名词转换为单数名词,将过去式动词转换为现在式动词)。

要执行大多数这些任务,我们将依赖于一个著名且广为人知的用于文本处理的 Python 库,称为 NLTK(自然语言工具包)。如果您已设置开发环境,应该已安装 NLTK 库。但我们的工作还没有完成。为了执行一些子任务,我们需要下载 NLTK 提供的几个外部资源:

  • averaged_perceptron_tagger—用于识别词性

  • wordnet omw-1.4-_ 将被用于词形还原(即,将单词转换为其基本形式)

  • stopwords—提供各种语言的停用词列表

  • punkt—用于将文本标记为较小的组件(例如,单词、句子等)

首先让我们这样做:

import nltk

nltk.download('averaged_perceptron_tagger', download_dir='nltk')
nltk.download('wordnet', download_dir='nltk')
nltk.download('omw-1.4', download_dir='nltk')
nltk.download('stopwords', download_dir='nltk')
nltk.download('punkt', download_dir='nltk')
nltk.data.path.append(os.path.abspath('nltk'))

现在我们可以继续我们的项目了。为了理解这里列出的各种预处理步骤,我们将放大一个单独的评价,它只是一个 Python 字符串(即字符序列)。让我们将这个单独的评价称为 doc。

首先,我们可以通过在字符串上调用 lower()函数将字符串转换为小写。lower()是 Python 中的一个内置函数,可用于将给定字符串中的字符转换为小写字符:

    doc = doc.lower()

接下来,如果存在"n't",我们将将其扩展为"not":

import re

doc = re.sub(pattern=r"\w+n\'t ", repl="not ", string=doc)

为了实现这一点,我们将使用正则表达式。正则表达式使我们能够匹配任意模式并以各种方式操纵它们。Python 有一个用于处理正则表达式的内置库,称为 re。在这里,re.sub()将用作 repl 参数(即“not ”)中的字符串替换符合某个模式的单词(即任何字母字符序列后跟“n't;例如,“don’t”,“can’t”),并将它们替换为字符串 doc 中的字符串。例如,“won’t”将被替换为“not”。我们不关心前缀“will”,因为在稍后我们将执行的停用词移除过程中它将被移除。如果你感兴趣,可以在www.rexegg.com/regex-quickstart.xhtml了解更多关于正则表达式语法的信息。

我们将删除如'll、're、'd 和've 等缩写形式。你可能会注意到,这将导致不太完整的词组,比如“wo”(即“won’t”变成“wo”+“not”);但我们可以放心地忽略它们。请注意,我们对“not”的缩写形式处理与其他缩写形式有所不同。这是因为与其他缩写形式不同,如果存在,“not”可以对评价实际传达的意义产生重要影响。我们稍后将再次讨论这个问题:

doc = re.sub(pattern=r"(?:\'ll |\'re |\'d |\'ve )", repl=" ", string=doc)

在这里,为了替换'll、're、'd 和've 的缩写形式,我们再次使用正则表达式。在这里,r"(?:'ll|'re|'d|'ve)"是 Python 中的一个正则表达式,它主要识别出 doc 中的任何出现的'll/'re/'d/'ve。然后我们将使用 re.sub()函数如前所述删除 doc 中的任何数字:

    doc = re.sub(pattern=r"/d+", repl="", string=doc)

接下来的步骤中,我们将删除停用词和任何标点符号。如前所述,停用词是出现在文本中但对文本的含义几乎没有贡献的词。换句话说,即使文本中没有停用词,你仍然能够推断出所说内容的意义。NLTK 库提供了一个停用词列表,因此我们不必自己编写停用词:

from nltk.corpus import stopwords
from nltk import word_tokenize
import string

EN_STOPWORDS = set(stopwords.words('english')) - {'not', 'no'}

(doc) if w not in EN_STOPWORDS and w not in string.punctuation]  

要访问停用词,您只需调用 nltk.corpus 中的 stopwords 并调用 stopwords.words('english')。这将返回一个列表。如果您查看停用词列表中的单词,您会发现几乎所有常见单词(例如,“I”,“you”,“a”,“the”,“am”,“are”等),这些单词在阅读文本时都会遇到。但正如我们之前强调的,词“not”是一个特殊的词,特别是在情感分析的背景下。诸如“no”和“not”之类的单词的存在可以完全改变我们情况下文本的含义。

还要注意一下函数 word_tokenize()的使用。这是一种特殊的处理步骤,称为标记化。在这里,将字符串传递给 word_tokenize()会返回一个列表,其中每个单词都是一个元素。对于像英语这样的语言,单词是由空格字符或句号分隔的,单词标记化可能看起来非常微不足道。但是在其他语言(例如,日语)中,标记之间的分隔并不明显,这可能是一个复杂的任务。

不要让停用词愚弄你!

如果您查看大多数停用词列表,您会发现单词“no”和“not”被视为停用词,因为它们是在文本语料库中常见的单词。但是,对于我们的情感分析任务,这些单词在改变评价的含义(以及可能的标签)方面起着重要作用。评价“这是一个很棒的视频游戏”的含义与“这不是一个很棒的视频游戏”或“这个游戏不好”相反。因此,我们特别从停用词列表中删除单词“no”和“not”。

接下来,我们有另一种称为词形还原的处理方法。词形还原将给定的单词截断/变形为基本形式,例如将复数名词转换为单数名词或将过去时动词转换为现在时,等等。这可以通过 NLTK 包中附带的一个词形还原器对象轻松完成:

lemmatizer = WordNetLemmatizer()

在这里,我们正在下载 WordNetLemmatizer。WordNetLemmatizer 是建立在著名的 WordNet 数据库上的词形还原器。如果您还没有听说过 WordNet,那么它是一个著名的词汇数据库(以网络/图形的形式),您可以将其用于信息检索、机器翻译、文本摘要等任务。WordNet 有多种大小和口味(例如,多语言 WordNet,非英语 WordNet 等)。您可以在线探索 WordNet 并浏览数据库wordnet.princeton.edu/

pos_tags = nltk.pos_tag(tokens)
    clean_text = [
        lemmatizer.lemmatize(w, pos=p[0].lower()) \
        if p[0]=='N' or p[0]=='V' else w \
        for (w, p) in pos_tags
    ]

通过调用 lemmatizer.lemmatize()函数,您可以将任何给定的单词转换为其基本形式(如果尚未处于基本形式)。但是在调用函数时,您需要传递一个重要的参数,称为 pos。pos 是该单词的词性标签(part-of-speech tag)的缩写。词性标注是一项特殊的自然语言处理任务,任务是从给定的一组离散的词性标签中将给定的单词分类到一个词性标签中。以下是一些词性标签的示例:

  • DT—限定词(例如,a,the)

  • JJ—形容词(例如,beautiful,delicious)

  • NN—名词,单数或质量(例如,person,dog)

  • NNS — 名词,复数(例如,people,dogs)

  • NNP — 专有名词,单数(例如,我,他,她)

  • NNPS — 专有名词,复数(例如,we,they)

  • VB — 动词,基本形式(例如,go,eat,walk)

  • VBD — 动词,过去时(例如,went,ate,walked)

  • VBG — 动词,动名词或现在分词(例如,going,eating,walking)

  • VBN — 动词,过去分词(例如,gone,eaten,walked)

  • VBP — 动词,第三人称单数现在时

  • VBZ — 动词,第三人称单数现在时

您可以在 mng.bz/mO1W 找到完整的词性标签列表。值得注意的是标签是如何组织的。您可以看到,如果只考虑标签的前两个字符,您会得到一个更广泛的类别集(例如,NN,VB),其中所有名词都将被分类为 NN,动词将被分类为 VB,以此类推。我们将利用这一特性来简化我们的生活。

回到我们的代码:让我们看看我们如何使用词性标签来将词形还原。在将词形还原为词时,您必须传递该词的词性标签。这一点很重要,因为不同类型的词的词形还原逻辑是不同的。我们首先会得到一个列表,其中包含 tokens(由分词过程返回)中的单词的()元素。然后,我们遍历 pos_tags 列表并调用 lemmatizer.lemmatize() 函数,传入单词和词性标签。我们只会将动词和名词进行词形还原,以节省计算时间。

关于 WordNet 的更多信息

WordNet 是一个以互联网络形式存在的词汇数据库(有时称为词汇本体论)。这些连接基于两个词的相似程度。例如,单词“car”和“automobile”的距离较近,而“dog”和“volcano”则相距甚远。

WordNet 中的单词被分组为同义词集(简称为 synsets)。一个同义词集包含共享共同含义的单词(例如,dog,cat,hamster)。每个单词可以属于一个或多个同义词集。每个同义词集都有词条,这些词条是该同义词集中的单词。

向上一层,词集之间存在关系。有四种不同的关系:

  • 超类词 — 超类词集是比给定词集更一般的词集。例如,“动物”是“宠物”的超类词集。

  • 下义词 — 下义词集是比给定词集更具体的词集。例如,“car”是“vehicle”词集的下义词集。

  • 部分词 — 部分词集是给定词集的一部分(是-部分-关系)。例如,“engine”是“car”词集的部分词集。

  • 成员词 — 成员词集是给定词集所构成的词集(是-构成-关系)。例如,“leaf”是“plant”词集的成员词集。

由于这种互联词集的组织,使用 WordNet 还可以测量两个词之间的距离。相似的词将具有较小的距离,而不同的词将具有较大的距离。

您可以通过从 nltk.corpus 导入 WordNet 来在 NLTK 中尝试这些想法。有关更多信息,请参阅 www.nltk.org/howto/wordnet.xhtml

这结束了我们正在合并的一系列步骤,以构建我们文本的预处理工作流程。我们将这些步骤封装在一个名为 clean_ text() 的函数中,如下所示。

列表 9.2 数据集中评论的预处理逻辑

def clean_text(doc):
    """ A function that cleans a given document (i.e. a text string)"""

    doc = doc.lower()                                        ❶
    doc = doc.replace("n\'t ", ' not ')                      ❷
    doc = re.sub(r"(?:\'ll |\'re |\'d |\'ve )", " ", doc)    ❸
    doc = re.sub(r"/d+","", doc)                             ❹

    tokens = [
        w for w in word_tokenize(doc) if w not in EN_STOPWORDS and w not in 
➥ string.punctuation
    ]                                                        ❺

    pos_tags = nltk.pos_tag(tokens)                          ❻
    clean_text = [
        lemmatizer.lemmatize(w, pos=p[0].lower()) \          ❼
        if p[0]=='N' or p[0]=='V' else w \                   ❼
        for (w, p) in pos_tags                               ❼
    ]

    return clean_text

❶ 转换为小写。

❷ 将缩写形式 n’t 扩展为“not”。

❸ 删除缩写形式,如’ll,’re,’d,’ve,因为它们对此任务没有太多价值。

❹ 删除数字。

❺ 将文本分解为标记(或单词);在此过程中,忽略结果中的停用词。

❻ 获取字符串中标记的词性标签。

❼ 对于每个标记,获取其词性标签;如果是名词(N)或动词(V),则进行词形还原,否则保留原始形式。

你可以通过在示例文本上调用函数来检查函数中完成的处理。

sample_doc = 'She sells seashells by the seashore.'
print("Before clean: {}".format(sample_doc))
print("After clean: {}".format(clean_text(sample_doc)))

返回

Before clean: She sells seashells by the seashore.
After clean: [“sell”, “seashell”, “seashore”]

我们将利用此函数以及 panda 的 apply 函数,在我们的数据 DataFrame 中的每一行文本上应用此处理管道:

inputs = inputs.apply(lambda x: clean_text(x))

你可能想离开电脑一会儿去喝咖啡或看看朋友。运行这个一行代码可能需要接近一个小时。最终结果看起来像表 9.2。

表 9.2 原始文本与预处理文本

原始文本 清理文本(标记化)
在 Wii 和 GameCube 上完美运行。与兼容性或内存丢失无关的问题。 ['work', 'perfectly', 'wii', 'gamecube', ‘no’, 'issue', 'compatibility', 'loss', 'memory']
喜欢这款游戏,而且其他附带的收藏品做得很好。面具很大,几乎适合我的脸,所以令人印象深刻。 ['loved', 'game', 'collectible', 'come', 'well', 'make', 'mask', 'big', 'almost', 'fit', 'face', 'impressive']
这是一个可以的游戏。说实话,我对这类游戏很差劲,对我来说这很困难!我总是死去,这让我沮丧。也许如果我更有技巧,我会更喜欢这款游戏! ["'s", 'okay', 'game', 'honest', 'bad', 'type', 'game', '--', "'s", 'difficult', 'always', 'die', 'depresses', 'maybe', 'skill', 'would', 'enjoy', 'game']
产品描述很好。 ['excellent', 'product', 'describe']
细节水平很高;你可以感受到这款游戏对汽车的热爱。 ['level', 'detail', 'great', 'feel', 'love', 'car', 'game']
我不能玩这个游戏。 ['not', 'play', 'game']

最后,为了避免因运行次数过多而过度依赖咖啡或打扰朋友,我们将数据保存到磁盘上:

inputs.to_pickle(os.path.join('data','sentiment_inputs.pkl'))
labels.to_pickle(os.path.join('data','sentiment_labels.pkl'))

现在,我们将定义一个数据管道,将数据转换为模型理解的格式,并用于训练和评估模型。

练习 1

给定字符串 s,“i-purchased-this-game-for-99-i-want-a-refund”,你想用空格替换短横线“-”,然后仅对文本中的动词进行词形还原。你会如何做?

准备模型的文本

你有一个干净的数据集,其中的文本已经剥离了任何不必要或不合理的语言复杂性,以解决我们正在解决的问题。此外,二元标签是根据每条评论给出的星级生成的。在继续进行模型训练和评估之前,我们必须对数据集进行进一步处理。具体来说,我们将创建三个数据子集——训练、验证和测试——用于训练和评估模型。接下来,我们将查看数据集的两个重要特征:词汇量和示例中序列长度(即单词数量)的分布。最后,你将把单词转换为数字(或数字 ID),因为机器学习模型不理解字符串而理解数字。

在本节中,我们将进一步准备数据以供模型使用。现在,我们有一个非常好的处理步骤布局,可以从杂乱、不一致的评论转变为保留评论语义的简单、一致的文本字符串。但是,我们还没有解决问题的关键!也就是说,机器学习模型理解的是数值数据,而不是文本数据。如果你直接呈现字符串“not a great game”,对模型来说没有任何意义。我们必须进一步完善我们的数据,以便最终得到一系列数字,而不是单词序列。在准备好数据供模型使用的过程中,我们将执行以下子任务:

  • 在预处理后检查词汇表/单词频率的大小。这将稍后用作模型的超参数。

  • 检查序列长度的摘要统计信息(均值、中位数和标准偏差)。这将稍后用作模型的超参数。

  • 创建一个字典,将每个唯一的单词映射到一个唯一的 ID(我们将其称为分词器)。

分割训练/验证和测试数据

警告!在执行这些任务时,你可能会无意中在我们的模型中创建渗漏数据泄露。我们必须确保我们只使用训练数据集来执行这些任务,并将验证和测试数据保持分开。因此,我们的第一个目标应该是分离训练/验证/测试数据。

潜在的自然语言处理数据泄漏

你可能会想:“太好了!我只需加载处理过的文本语料库并在上面执行分析或任务。”不要那么快!这是错误的做法。在执行任何特定于数据的处理/分析之前,如计算词汇量或开发分词器,你必须将数据分割成训练/验证和测试集,然后在训练数据上执行此处理/分析。

验证数据的目的是作为选择超参数的指南,并确定何时停止训练。测试数据集是你的基准,用于评估模型在实际世界中的表现。考虑到验证/测试数据的用途性质,它们不应该成为你分析的一部分,而只应该用于评估性能。在你的分析中使用验证/测试数据来开发模型会给你一个不公平的优势,并导致所谓的数据泄漏。数据泄漏是指直接或间接提供访问你在模型上评估的示例。如果验证/测试数据在我们进行任何分析时被使用,我们在评估阶段之前提供了对这些数据集的访问。带有数据泄漏的模型可能导致在实际世界中性能不佳。

我们知道我们有一个不均衡的数据集。尽管有一个不均衡的数据集,我们必须确保我们的模型能够很好地识别出积极和消极的评论。这意味着我们将要评估的数据集需要是平衡的。为了实现这一点,我们将做以下操作:

  • 创建平衡的(即正负样本数量相等的)验证集和测试集

  • 将剩余的数据点分配给训练集

图 9.1 描述了这个过程。

09-01

图 9.1 分割训练/验证/测试数据的过程

现在我们将看看如何在 Python 中实现这一点。首先,我们分别识别对应于正标签和负标签的索引:

neg_indices = pd.Series(labels.loc[(labels==0)].index)
pos_indices = pd.Series(labels.loc[(labels==1)].index)

分层抽样:不均衡数据集的替代方法

你对验证和测试集的设计将决定你如何定义性能指标来评估训练模型。如果你创建了同样平衡的验证/测试集,那么你可以安全地使用准确率作为评估训练模型的指标。这就是我们将要做的:创建平衡的验证/测试数据集,然后使用准确率作为评估模型的指标。但你可能并不总是那么幸运。有时候可能会出现少数类别非常恐怖,你无法承担创建平衡数据集的情况。

在这种情况下,你可以使用分层抽样。分层抽样创建单独的数据集,大致保持完整数据集中原始类别比例。在这种情况下,你必须谨慎选择你的度量标准,因为标准准确率不能再被信任。例如,如果你关心以高准确率识别正样本而牺牲一些误报率,那么你应该使用召回率(或 F1 分数,对召回率给予更高的权重)作为性能指标。

接下来,我们将定义我们的验证/测试集的大小作为 train_fraction 的函数(一个用户定义的参数,确定留给训练集的数据量)。我们将使用 train_fraction 的默认值 0.8:

n_valid = int(
    min([len(neg_indices), len(pos_indices)]) * ((1-train_fraction)/2.0)
)

它可能看起来像是一个复杂的计算,但事实上,它是一个简单的计算。我们将使用有效分数作为留给训练数据的数据分数的一半(另一半用于测试集)。最后,为了将分数值转换为实际样本数,我们将分数乘以正样本和负样本计数中较小的那个。通过这种方式,我们确保少数类在数据拆分过程中保持为焦点。我们保持验证集和测试集相等。所以

n_test = n_valid

接下来,我们为每种标签类型(正和负)定义三组索引(用于训练/验证/测试数据集)。我们将创建一个漏斗过程来将数据点分配到不同的数据集中。首先,我们执行以下操作:

  1. 从负索引(neg_ test_indices)中随机抽样 n_test 个索引。

  2. 然后从剩余的索引中随机抽样 n_valid 个索引(neg_ valid_inds)。

  3. 剩余的索引被保留为训练实例(neg_train_inds)。

然后,对正索引重复相同的过程,以创建用于训练/验证/测试数据集的三个索引集:

neg_test_inds = neg_indices.sample(n=n_test, random_state=random_seed)
neg_valid_inds = neg_indices.loc[
    ~neg_indices.isin(neg_test_inds)
].sample(n=n_test, random_state=random_seed)
neg_train_inds = neg_indices.loc[
    ~neg_indices.isin(neg_test_inds.tolist()+neg_valid_inds.tolist())
]

pos_test_inds = pos_indices.sample(n=n_test, random_state=random_seed
)
pos_valid_inds = pos_indices.loc[
    ~pos_indices.isin(pos_test_inds)
].sample(n=n_test, random_state=random_seed)
pos_train_inds = pos_indices.loc[        
    ~pos_indices.isin(pos_test_inds.tolist()+pos_valid_inds.tolist())
]

使用负索引和正索引来切片输入和标签,现在是时候创建实际的数据集了:

tr_x = inputs.loc[
    neg_train_inds.tolist() + pos_train_inds.tolist()
].sample(frac=1.0, random_state=random_seed)
tr_y = labels.loc[
    neg_train_inds.tolist() + pos_train_inds.tolist()
].sample(frac=1.0, random_state=random_seed)

v_x = inputs.loc[
    neg_valid_inds.tolist() + pos_valid_inds.tolist()
].sample(frac=1.0, random_state=random_seed)
v_y = labels.loc[
    neg_valid_inds.tolist() + pos_valid_inds.tolist()
].sample(frac=1.0, random_state=random_seed)

ts_x = inputs.loc[
    neg_test_inds.tolist() + pos_test_inds.tolist()
].sample(frac=1.0, random_state=random_seed)
ts_y = labels.loc[
    neg_test_inds.tolist() + pos_test_inds.tolist()
].sample(frac=1.0, random_state=random_seed)

在这里,(tr_x,tr_y),(v_x,v_y)和(ts_x,ts_y)分别代表训练,验证和测试数据集。在这里,以 _x 结尾的数据集来自输入,以 _y 结尾的数据集来自标签。最后,我们可以将我们讨论的逻辑包装在一个单独的函数中,如下面的清单所示。

清单 9.3 拆分训练/验证/测试数据集

def train_valid_test_split(inputs, labels, train_fraction=0.8):
    """ Splits a given dataset into three sets; training, validation and test """

    neg_indices = pd.Series(labels.loc[(labels==0)].index)                   ❶
    pos_indices = pd.Series(labels.loc[(labels==1)].index)                   ❶

    n_valid = int(min([len(neg_indices), len(pos_indices)]) 
       * ((1-train_fraction)/2.0))                                           ❷
    n_test = n_valid                                                         ❷

    neg_test_inds = neg_indices.sample(n=n_test, random_state=random_seed)   ❸
    neg_valid_inds = neg_indices.loc[~neg_indices.isin(
       neg_test_inds)].sample(n=n_test, random_state=random_seed)            ❹
    neg_train_inds = neg_indices.loc[~neg_indices.isin(
        neg_test_inds.tolist()+neg_valid_inds.tolist())]                     ❺

    pos_test_inds = pos_indices.sample(n=n_test)                             ❻
    pos_valid_inds = pos_indices.loc[
        ~pos_indices.isin(pos_test_inds)].sample(n=n_test)                   ❻
    pos_train_inds = pos_indices.loc[
        ~pos_indices.isin(pos_test_inds.tolist()+pos_valid_inds.tolist())    ❻
    ]

    tr_x = inputs.loc[neg_train_inds.tolist() + 
➥ pos_train_inds.tolist()].sample(frac=1.0, random_state=random_seed)       ❼
    tr_y = labels.loc[neg_train_inds.tolist() + 
➥ pos_train_inds.tolist()].sample(frac=1.0, random_state=random_seed)       ❼
    v_x = inputs.loc[neg_valid_inds.tolist() + 
➥ pos_valid_inds.tolist()].sample(frac=1.0, random_state=random_seed)       ❼
    v_y = labels.loc[neg_valid_inds.tolist() + 
➥ pos_valid_inds.tolist()].sample(frac=1.0, random_state=random_seed)       ❼
    ts_x = inputs.loc[neg_test_inds.tolist() + 
➥ pos_test_inds.tolist()].sample(frac=1.0, random_state=random_seed)        ❼
    ts_y = labels.loc[neg_test_inds.tolist() + 
➥ pos_test_inds.tolist()].sample(frac=1.0, random_state=random_seed)        ❼

    print('Training data: {}'.format(len(tr_x)))
    print('Validation data: {}'.format(len(v_x)))
    print('Test data: {}'.format(len(ts_x)))

    return (tr_x, tr_y), (v_x, v_y), (ts_x, ts_y)

❶ 将负数据点和正数据点的索引分开。

❷ 计算有效和测试数据集的大小(针对少数类)。

❸ 获取进入测试集的少数类索引。

❹ 获取进入验证集的少数类索引。

❺ 少数类中其余的索引属于训练集。

❻ 计算用于测试/验证/训练集的多数类索引

❼ 使用创建的索引获取训练/验证/测试数据集。

然后只需调用函数来生成训练/验证/测试数据:

(tr_x, tr_y), (v_x, v_y), (ts_x, ts_y) = train_valid_test_split(data, labels)

接下来,我们将进一步检查语料库,以探索与我们在训练集中拥有的评论相关的词汇量和序列长度。稍后这些将作为模型的超参数。

9.2.2 分析词汇

词汇量是模型的重要超参数。因此,我们必须找到最佳的词汇量,以便能够捕获足够的信息以准确解决任务。为此,我们首先会创建一个长列表,其中每个元素都是一个单词:

data_list = [w for doc in tr_x for w in doc]

此行遍历 tr_x 中的每个文档,然后遍历该文档中的每个单词(w),并创建一个展平的序列,其中包含所有文档中存在的单词。由于我们有一个 Python 列表,其中每个元素都是一个单词,我们可以利用 Python 的内置 Counter 对象来获取一个字典,其中每个单词都映射到一个键,该值表示该单词在语料库中的频率。请注意,我们仅在此分析中使用训练数据集,以避免数据泄漏:

from collections import Counter
cnt = Counter(data_list)

有了我们的单词频率字典,让我们来看看我们语料库中一些最常见的单词:

freq_df = pd.Series(
    list(cnt.values()), 
    index=list(cnt.keys())
).sort_values(ascending=False)

print(freq_df.head(n=10))

这将返回以下结果,您可以看到出现在文本中的最常见的单词。从结果来看,这是合理的。毫不奇怪,像 “game”、“like” 和 “play” 这样的词在频率上优先于其他单词:

game     407818
not      248244
play     128235
's       127844
get      108819
like     100279
great     97041
one       89948
good      77212
time      63450
dtype: int64

更进一步,让我们对文本语料库进行摘要统计。通过这样做,我们可以看到单词的平均频率、标准偏差、最小值、最大值等:

print(freq_df.describe())

这将提供有关单词频率的一些重要基本统计信息。例如,从中我们可以说单词的平均频率是约为 ~76,标准偏差为 ~1754:

count    133714.000000
mean         75.768207
std        1754.508881
min           1.000000
25%           1.000000
50%           1.000000
75%           4.000000
max      408819.000000
dtype: float64

然后,我们将创建一个名为 n_vocab 的变量,该变量将保存在语料库中至少出现 25 次的单词的词汇量的大小。您应该得到接近 11,800 的 n_vocab 值:

n_vocab = (freq_df >= 25).sum()

9.2.3 分析序列长度

请记住,tr_x 是一个 pandas Series 对象,其中每一行都包含一条评论,每个评论都是一个单词列表。当数据处于这种格式时,我们可以使用 pd.Series.str.len() 函数来获取每行的长度(或每条评论中的单词数):

seq_length_ser = tr_x.str.len()

在计算基本统计量时,我们将采取一些不同的方法。我们的目标是找到三个序列长度的区间,以便将它们分为短、中、长序列。我们将在定义 TensorFlow 数据流水线时使用这些桶边界。为此,我们将首先确定截断点(或分位数),以去除数据的前 10% 和后 10%。这是因为顶部和底部切片都充满了异常值,正如你所知,它们会使诸如均值之类的统计量产生偏差。在 pandas 中,您可以使用 quantile() 函数获取分位数,其中您传递一个分数值来指示您感兴趣的分位数:

p_10 = seq_length_ser.quantile(0.1)
p_90 = seq_length_ser.quantile(0.9)

然后,您只需在这些分位数之间过滤数据。接下来,我们使用 describe 函数,其中包含 33% 分位数和 66% 分位数,因为我们希望将其分为三个不同的类别:

seq_length_ser[(seq_length_ser >= p_10) & (seq_length_ser < p_90)].describe(percentiles=[0.33, 0.66])

如果运行此代码,您将得到以下输出:

count    278675.000000
mean         15.422596
std          16.258732
min           1.000000
33%           5.000000
50%          10.000000
66%          16.000000
max          74.000000
Name: reviewText, dtype: float64

根据结果,我们将使用 5 和 15 作为我们的桶边界。换句话说,评论按照以下逻辑进行分类:

  • 长度在 [0, 5) 的评论为短评论。

  • 长度在 [5, 15) 的评论为中等评论。

  • 长度在 [15, inf) 的评论为长评论。

最后两个小节总结了我们分析以找到词汇表大小和序列长度的过程。这里呈现的输出提供了所有信息,以便以有原则的态度选择我们的超参数。

9.2.4 使用 Keras 将文本转换为单词,然后转换为数字

我们有一段干净的、经过处理的文本语料库,以及我们稍后将使用的词汇表大小和序列长度参数。我们的下一个任务是将文本转换为数字。将文本转换为数字有两个标准步骤:

  1. 将文本拆分为标记(例如,字符/单词/句子)。

  2. 创建一个将每个唯一标记映射到唯一 ID 的字典。

例如,如果您有以下句子

the cat sat on the mat

我们将首先将其标记化为单词,得到

[the, cat, sat, on, the, mat]

并且有字典

{the: 1, cat: 2, sat: 3, on: 4, mat: 5}

然后,您可以创建以下序列来表示原始文本:

[1,2,3,4,1,5]

Keras Tokenizer 对象支持这种功能。它接受一段文本语料库,使用一些用户定义的参数进行标记化,自动构建词典,并将其保存为状态。这样,您可以使用 Tokenizer 将任意文本转换为数字,次数不限。让我们看看如何使用 Keras Tokenizer 完成这个过程:

from tensorflow.keras.preprocessing.text import Tokenizer

tokenizer = Tokenizer(
    num_words=n_vocab, 
    oov_token='unk', 
    lower=False, 
    filters='!"#$%&()*+,-./:;<=>?@[\\]^_`{|}~\t\n',
    split=' ', 
    char_level=False
)

您可以看到 Tokenizer 传递了几个参数。让我们稍微详细地看看这些参数:

  • num_words——这定义了词汇表大小,以限制字典的大小。如果 num_words 设置为 1,000,则会考虑语料库中最常见的 1,000 个单词,并为它们分配唯一的 ID。

  • oov_token——这个参数处理落在定义的词汇表大小之外的词。出现在语料库中但未包含在最常见的 num_words 个词中的单词将被替换为此标记。

  • lower——这确定是否对文本进行大小写转换。由于我们已经做过了,我们将其设置为 False。

  • filter——这定义了在标记化文本之前要删除的任何字符。

  • split——这是用于标记化文本的分隔符字符。我们希望单词是标记,因此我们将使用空格,因为单词通常用空格分隔。

  • char_level——这指示是否执行字符级标记化(即,每个字符都是一个标记)。

在我们继续之前,让我们回顾一下我们的数据在当前状态下是什么样子。请记住,我们有

  • 清理后的数据

  • 预处理数据

  • 将每个评论拆分为单独的单词

在这个过程结束时,我们的数据如下所示。首先,我们有输入,它是一个 pd.Series 对象,包含一系列干净的单词列表。文本前面的数字是该记录在 pd.Series 对象中的索引:

122143    [work, perfectly, wii, gamecube, issue, compat...
444818    [loved, game, collectible, come, well, make, m...
79331     ['s, okay, game, honest, bad, type, game, --, ...
97250                        [excellent, product, describe]
324411        [level, detail, great, feel, love, car, game]
...
34481     [not, actually, believe, write, review, produc...
258474    [good, game, us, like, movie, franchise, hard,...
466203    [fun, first, person, shooter, nice, combinatio...
414288                       [love, amiibo, classic, color]
162670    [fan, halo, series, start, enjoy, game, overal...
Name: reviewText, dtype: object

接下来,我们有标签,其中每个标签都是一个二进制标签,用于指示评论是正面评论还是负面评论:

122143    1
444818    1
79331     0
97250     1
324411    1
...
34481     1
258474    1
466203    1
414288    1
162670    0
Name: label, dtype: int64

从某种意义上说,标记文本的第一步已经完成了。如果已经完成了这一步,那么 Keras Tokenizer 足够智能,会跳过这一步。要构建 Tokenizer 的字典,可以调用 tf.keras.preprocessing.text.Tokenizer.fit_on_texts() 函数,如下所示:

tokenizer.fit_on_texts(tr_x.tolist())

fit_on_texts() 函数接受一个字符串列表,其中每个字符串是你正在处理的单个实体(例如,一个句子、一个评论、一个段落等),或者是一个标记列表的列表,其中标记可以是一个单词、一个字符,甚至是一个句子。当你将 Tokenizer 拟合到某些文本时,你可以检查一些内部状态变量。你可以使用以下方式检查单词到 ID 的映射:

tokenizer.word_index[“game”]

这将返回

2

你还可以使用以下方式检查 ID 到单词的映射(即将单词映射到 ID 的反向操作):

tokenizer.index_word[4]

这将返回

“play”

要将文本语料库转换为索引序列,你可以使用 texts_to_sequences() 函数。它接受一个标记列表的列表,并返回一个 ID 列表的列表:

tr_x = tokenizer.texts_to_sequences(tr_x.tolist())
v_x = tokenizer.texts_to_sequences(v_x.tolist())
ts_x = tokenizer.texts_to_sequences(ts_x.tolist())

让我们看一下 text_to_sequences() 函数转换的一些样本的结果:

Text: ['work', 'perfectly', 'wii', 'gamecube', 'issue', 'compatibility', 
➥ 'loss', 'memory']
Sequence: [14, 295, 83, 572, 121, 1974, 2223, 345]
Text: ['loved', 'game', 'collectible', 'come', 'well', 'make', 'mask', 
➥ 'big', 'almost', 'fit', 'face', 'impressive']
Sequence: [1592, 2, 2031, 32, 23, 16, 2345, 153, 200, 155, 599, 1133]

Text: ["'s", 'okay', 'game', 'honest', 'bad', 'type', 'game', '--', "'s", 
➥ 'difficult', 'always', 'die', 'depresses', 'maybe', 'skill', 'would', 
➥ 'enjoy', 'game']
Sequence: [5, 574, 2, 1264, 105, 197, 2, 112, 5, 274, 150, 354, 1, 290, 
➥ 400, 19, 67, 2]

Text: ['excellent', 'product', 'describe']
Sequence: [109, 55, 501]

Text: ['level', 'detail', 'great', 'feel', 'love', 'car', 'game']
Sequence: [60, 419, 8, 42, 13, 265, 2]

太棒了!我们可以看到文本完美地转换为了 ID 序列。我们现在将继续使用 Keras Tokenizer 返回的数据定义 TensorFlow 管道。

练习 2

给定字符串 s,“a_b_B_c_d_a_D_b_d_d”,你能否定义一个 tokenizer,tok,将文本转换为小写形式,按下划线字符“_”拆分,具有 3 个词汇大小,并将 Tokenizer 拟合到 s 上。如果 Tokenizer 忽略从 1 开始的词汇索引词,那么如果你调用 tok.texts_to_sequences([s]),输出会是什么?

9.3 使用 TensorFlow 定义端到端的 NLP 管道

你已经定义了一个干净的数据集,它是模型期望的数字格式。在这里,我们将定义一个 TensorFlow 数据集管道,以从我们定义的数据中生成数据批次。在数据管道中,你将生成一批数据,其中批次由元组(x,y)组成。x 表示一批文本序列,其中每个文本序列都是一个任意长的标记 ID 序列。y 是与批次中的文本序列对应的标签批次。在生成一批示例时,首先根据序列长度将文本序列分配到桶中。每个桶都有一个预定义的允许序列长度间隔。批次中的示例仅由同一桶中的示例组成。

我们现在处于一个很好的位置。我们已经对数据进行了相当多的预处理,并将文本转换为了机器可读的数字。在下一步中,我们将构建一个 tf.data 管道,将 Tokenizer 的输出转换为适合模型的输出。

作为第一步,我们将目标标签(具有值 0/1)连接到输入。这样,我们可以以任何我们想要的方式对数据进行洗牌,并仍然保持输入和目标标签之间的关系:

    data_seq = [[b]+a for a,b in zip(text_seq, labels) ]

接下来,我们将创建一种特殊类型的 tf.Tensor 对象,称为ragged tensor(即 tf.RaggedTensor)。在标准张量中,您具有固定的维度。例如,如果您定义了一个 3×4 大小的张量,则每行都需要有四列(即四个值)。Ragged tensors 是一种支持可变大小的张量的特殊类型。例如,可以将以下数据作为 ragged tensor:

[
  [1,2],
  [3,2,5,9,10],
  [3,2,3]
]

此张量有三行,其中第一行有两个值,第二行有五个值,最后一行有三个值。换句话说,它具有可变的第二维。由于每篇评论的字数不同,因此导致与每篇评论对应的变大小的 ID 序列,这是我们问题的完美数据结构:

max_length = 50    
tf_data = tf.ragged.constant(data_seq)[:,:max_length]

对 tf.RaggedTensor 的初步了解

tf.RaggedTensor 对象是一种特殊类型的张量,可以具有可变大小的维度。您可以在mng.bz/5QZ8了解更多关于 ragged tensors 的信息。有许多方法来定义一个 ragged tensor。

我们可以通过传递包含值的嵌套列表来定义 ragged tensor,以 tf.ragged.constant()函数:

a = tf.ragged.constant([[1, 2, 3], [1,2], [1]])

您还可以定义一系列值并定义分割行的位置:

b = tf.RaggedTensor.from_row_splits([1,2,3,4,5,6,7], row_splits=[0, 3, 3, 6, 7])

在这里,row_splits 参数中的每个值定义了结果张量中随后的行结束在哪里。例如,第一行将包含从索引 0 到 3(即 0, 1, 2)的元素。 这将输出

<tf.RaggedTensor [[1, 2, 3], [], [4, 5, 6], [7]]>

您可以使用 b.shape 获取张量的形状,这将返回

[4, None]

您甚至可以拥有多维的 ragged tensors,其中有多个可变大小的维度,如下所示:

c = tf.RaggedTensor.from_nested_row_splits(
    flat_values=[1,2,3,4,5,6,7,8,9], 
    nested_row_splits=([0,2,3],[0,4,6,9]))

在这里,nested_row_splits 是 1D 张量的列表,其中第 i 个张量表示第 i 个维度的行拆分。c 如下所示:

<tf.RaggedTensor [[[1, 2, 3, 4], [5, 6]], [[7, 8, 9]]]>

您可以在 ragged tensors 上执行切片和索引操作,类似于在普通张量上的操作:

print(c[:1, :, :])

这将返回

<tf.RaggedTensor [[[1, 2, 3, 4], [5, 6]]]>

在这里

print(c[:,:1,:])

这将返回

<tf.RaggedTensor [[[1, 2, 3, 4]], [[7, 8, 9]]]>

最后,随着

print(c[:, :, :2])

您将获得

<tf.RaggedTensor [[[1, 2], [5, 6]], [[7, 8]]]>

我们将限制评论的最大长度为 max_length。这是在假设 max_length 个字足以捕获给定评论中的情感的情况下完成的。这样,我们可以避免由于数据中存在一两个极长的评论而导致最终数据过长。最大长度越高,就能更好地捕获评论中的信息。但是,更高的 max_length 值会带来极大的计算开销:

    text_ds = tf.data.Dataset.from_tensor_slices(tf_data)

我们将使用 tf.data.Dataset.from_tensor_slices()函数创建一个数据集。该函数在我们刚创建的 ragged tensor 上将逐一提取一行(即一篇评论)。重要的是要记住每行的大小都不同。我们将过滤掉任何空评论。您可以使用 tf.data.Dataset.filter()函数来做到这一点:

text_ds = text_ds.filter(lambda x: tf.size(x)>1)

基本上,我们在这里说的是任何大小小于或等于 1 的评论将被丢弃。记住,每条记录至少会有一个元素(即标签)。这是一个重要的步骤,因为空评论会在模型后续处理中引起问题。

接下来,我们将解决一个极其重要的步骤,以及我们令人印象深刻的数据管道的重点。在序列处理过程中,你可能听过分桶(或分箱)这个术语。分桶是指在批处理数据时,使用相似大小的输入。换句话说,一批数据包括长度相似的评论,不会在同一批中有长度差距悬殊的评论。下面的侧栏更详细地解释了分桶的过程。

分桶:相似长度的序列聚集在一起!

让我们来看一个例子。假设你有一个评论列表,[r1(5), r2(11), r3(6), r4(4), r5(15), r6(18), r7(25), r8(29), r9(30)],其中代码 rx 代表评论 ID,括号内的数字代表评论中的单词数。如果你选择批量大小为 3,那么以下列方式分组数据是有意义的:

[r1, r3, r4]
[r2, r5, r6]
[r7, r8, r9]

你可以看到长度相近的评论被分组在一起。这实际上是通过一个称为分桶的过程来实现的。首先,我们创建几个预定义边界的桶。例如,在我们的示例中,可能有三个间隔如下的桶:

[[0,11), [11, 21), [21, inf)) 

然后,根据评论的长度,每个评论被分配到一个桶中。最后,在获取数据批次时,从一个随机选择的桶中随机采样批次数据。

在确定桶之后,我们必须将数据分组,使得最终得到固定序列长度。这是通过在序列的末尾填充零直到我们在该批次中拥有长度相等的所有序列来实现的。假设评论 r1、r3 和 r4 有以下单词 ID 序列:

[10, 12, 48, 21,  5]
[ 1, 93, 28,  8, 20, 10]
[32, 20,  1,  2]

为了将这些序列分组,我们将在短序列的末尾填充零,结果是

[10, 12, 48, 21,  5,  0]
[ 1, 93, 28,  8, 20, 10]
[32, 20,  1,  2,  0,  0]

你可以看到,现在我们有一批数据,具有固定序列长度,可以转换为 tf.Tensor。

幸运的是,为了使用桶化,我们需要关心的只是理解一个方便的 TensorFlow 函数 tf.data.experimental.bucket_by_sequence_length()的语法。实验性命名空间是为尚未完全测试的 TensorFlow 功能分配的特殊命名空间。换句话说,可能存在这些函数可能失败的边缘情况。一旦功能经过充分测试,这些情况将从实验性命名空间移出,进入稳定的命名空间。请注意,该函数返回另一个在数据集上执行桶化的函数。因此,你必须将此函数与 tf.data.Dataset.apply()一起使用,以执行返回的函数。这个语法乍一看可能有点晦涩。但当我们深入研究参数时,事情会变得更清晰。你可以看到,当分析评价的序列长度时,我们正在使用我们之前确定的桶边界:

bucket_boundaries=[5,15]
batch_size = 64
bucket_fn = tf.data.experimental.bucket_by_sequence_length(
        element_length_func = lambda x: tf.cast(tf.shape(x)[0],'int32'), 
        bucket_boundaries=bucket_boundaries, 
        bucket_batch_sizes=[batch_size,batch_size,batch_size], 
        padding_values=0, 
        pad_to_bucket_boundary=False
    )

让我们来检查该函数提供的参数:

  • elment_length_func—这是桶函数的核心,因为它告诉函数如何计算单个记录或实例的长度。如果没有记录的长度,桶是无法实现的。

  • bucket_boundaries—定义桶边界的上限。该参数接受一个按升序排列的值列表。如果你提供了 bucket_bounderies [x, y, z],其中 x < y < z,那么桶的间隔将是[0, x),[x, y),[y, z),[z, inf)。

  • bucket_batch_sizes—每个桶的批次大小。你可以看到,我们为所有桶定义了相同的批次大小。但你也可以使用其他策略,比如更短序列的更大批次大小。

  • padded_values—当将序列调整为相同长度时,定义短序列应该用什么填充。用零填充是一种非常常见的方法。我们将坚持使用这种方法。

  • pad_to_bucket_boundary—这是一个特殊的布尔参数,将决定每个批次的变量维度的最终大小。例如,假设你有一个区间为[0, 11)的桶和一批序列长度为[4, 8, 5]。如果 pad_to_bucket_boundary=True,最终批次将具有变量维度为 10,这意味着每个序列都被填充到最大限制。如果 pad_to_bucket_boundary=False,你将得到变量维度为 8(即批次中最长序列的长度)。

请记住,最初传递给 tf.data.Dataset.from_tensor_slices 函数的是 tf.RaggedTensor。在返回切片时,它将返回相同的数据类型的切片。不幸的是,tf.RaggedTensor 对象与桶函数不兼容。因此,我们执行以下方法将切片转换回 tf.Tensor 对象。我们只需使用 lambda 函数 lambda x: x 调用 map 函数。通过这样做,你可以使用 tf.data.Dataset.apply()函数,并将桶函数作为参数来调用它:

text_ds = text_ds.map(lambda x: x).apply(bucket_fn)

到这一步,我们已经完成了所有的工作。到目前为止,您已经实现了接受任意长度序列数据集的功能,以及使用分桶策略从中抽取一批序列的功能。这里使用的分桶策略确保我们不会将长度差异很大的序列分组在一起,这将导致过多的填充。

如同我们之前做过很多次一样,让我们打乱数据,以确保在训练阶段观察到足够的随机性:

if shuffle:
    text_ds = text_ds.shuffle(buffer_size=10*batch_size)

请记住,我们将目标标签和输入结合在一起,以确保输入和目标之间的对应关系。现在,我们可以使用张量切片语法将目标和输入安全地分割成两个独立的张量,如下所示:

text_ds = text_ds.map(lambda x: (x[:,1:], x[:,0]))    

现在我们可以松口气了。我们已经完成了从原始杂乱的文本到可以被我们的模型消化的干净半结构化文本的旅程。让我们把它封装成一个名为 get_tf_pipeline()的函数,该函数接受一个 text_seq(单词 ID 列表的列表)、标签(整数列表)、批处理大小(整数)、桶边界(整数列表)、最大长度(整数)和随机混洗(布尔值)的参数(参见下面的列表)。

列表 9.4 tf.data 数据管道

def get_tf_pipeline(
    text_seq, labels, batch_size=64, bucket_boundaries=[5,15], 
➥ max_length=50, shuffle=False
):
    """ Define a data pipeline that converts sequences to batches of data """

    data_seq = [[b]+a for a,b in zip(text_seq, labels) ]               ❶

    tf_data = tf.ragged.constant(data_seq)[:,:max_length]              ❷

    text_ds = tf.data.Dataset.from_tensor_slices(tf_data)              ❸

    bucket_fn = tf.data.experimental.bucket_by_sequence_length(        ❹
        lambda x: tf.cast(tf.shape(x)[0],'int32'), 
        bucket_boundaries=bucket_boundaries,                           ❺
        bucket_batch_sizes=[batch_size,batch_size,batch_size], 
        padded_shapes=None,
        padding_values=0, 
        pad_to_bucket_boundary=False
    )

    text_ds = text_ds.map(lambda x: x).apply(bucket_fn)                ❻

    if shuffle:
        text_ds = text_ds.shuffle(buffer_size=10*batch_size)           ❼

    text_ds = text_ds.map(lambda x: (x[:,1:], x[:,0]))                 ❽

    return text_ds

❶ 连接标签和输入序列,以防止洗牌时混乱顺序。

❷ 将变量序列数据集定义为不规则张量。

❸ 从不规则张量创建数据集。

❹ 对数据进行分桶(根据长度将每个序列分配到不同的分桶中)。

❺ 例如,对于分桶边界[5, 15],您可以得到分桶[0, 5]、[5, 15]、[15, 无穷大]。

❻ 应用分桶技术。

❼ 打乱数据。

❽ 将数据分割为输入和标签。

这是一个漫长的旅程。让我们回顾一下我们迄今为止所做的事情。数据管道已经完成并且稳定,现在让我们了解一下可以使用这种类型的顺序数据的模型。接下来,我们��定义情感分析器模型,这是我们一直在等待实现的模型。

练习 3

如果您希望有桶(0, 10]、(10, 25]、(25, 50]、[50, 无穷大)并且始终返回填充到桶的边界上,您将如何修改这个分桶函数?请注意,桶的数量已从文本中的数量发生了变化。

9.4 快乐的评论意味着快乐的顾客:情感分析

想象一下,您已经将评论转换为数字,并定义了一个数据管道,生成输入和标签的批处理。现在是时候使用模型来处理它们,以训练一个能够准确识别发布的评论情感的模型。您听说过长短期记忆模型(LSTMs)是处理文本数据的一个很好的起点。目标是基于 LSTMs 实现一个模型,针对给定的评论产生两种可能的结果之一:负面情感或积极情感。

如果您已经到达这一步,您应该会感到快乐。您已经完成了很多工作。现在是时候奖励自己获取关于一个称为深度顺序模型的引人注目的模型家族的信息。该家族的一些示例模型如下:

  • 简单循环神经网络(RNNs)

  • 长短期记忆(LSTM)网络

  • 门控循环单元(GRUs)

9.4.1 LSTM 网络

之前我们讨论了简单的循环神经网络及其在预测未来 CO2 浓度水平中的应用。在本章中,我们将探讨 LSTM 网络的机制。LSTM 模型在近十年间非常流行。它们是处理序列数据的绝佳选择,通常具有三个重要的维度:

  • 一个批量维度

  • 一个时间维度

  • 一个特征维度

如果你考虑我们讨论的 NLP 管道返回的数据,它具有所有这些维度。批量维度由采样到该批量中的每个不同评论来表示。时间维度由单个评论中出现的词 ID 序列表示。最后,你可以将特征维度看作为 1,因为一个单一的特征由一个单一的数值(即一个 ID)表示(参见图 9.2)。特征维度具有对应于该维度上的特征的值。例如,如果你有一个包含三个特征的天气模型(例如,温度、降水、风速),那么模型的输入将是大小为[<批量大小>, <序列长度>, 3]的输入。

09-02

图 9.2 序列数据的三维视图。通常,序列数据具有三个维度:批量大小、序列/时间和特征。

LSTM 采用了讨论的三个维度的输入。让我们更深入地了解一下 LSTM 是如何在这样的数据上运作的。为了简化讨论,假设批量大小为 1 或仅有一个评论。如果我们假设有一个包含n个词的单一评论r,可以表示为

r = w[1,]w[2,...,]w[t,...,]w[n],其中w[t]表示第t个位置的词的 ID。

在时间步t,LSTM 模型从以下状态开始

  • 上一个输出状态向量h[t-1]

  • 上一个单元状态向量c[t-1]

并计算

  • 使用当前输入w[t]和上一个单元状态c[t-1]以及输出状态h[t-1]来计算当前单元状态c[t]

  • 使用当前输入w[t]、上一个状态h[t-1]和当前单元状态c[t]来计算当前输出状态h[t]

这样,模型就会持续迭代所有时间步(在我们的示例中,它是序列中的单词 ID),直到到达末尾。在这种迭代过程中,模型会持续产生一个单元状态和一个输出状态(图 9.3)。

09-03

图 9.3 LSTM 单元的高层纵向视图。在给定的时间步t,LSTM 单元接收两个先前状态(h[t-1]和c[t-1]),以及输入,并产生两个状态(h[t]和c[t])。

有了对 LSTM 单元的良好高层次理解,让我们来看看推动该模型齿轮的方程式。LSTM 接受三个输入:

  • x[t]—时间步t处的输入

  • h[t-1]—时间步t-1 处的输出状态

  • c[t-1]—时间步t-1 处的单元状态

有了这个,LSTM 会产生两个输出:

  • (c[t])—时刻 t 的单元状态

  • (h[t])—时刻 t 的输出状态

为了产生这些输出,LSTM 模型利用了一个门控机制。这些门控机制决定了多少信息通过它们流向计算的下一阶段。LSTM 单元有三个门控:

  • 一个输入门 (i[t])—确定当前输入对后续计算的影响程度

  • 一个遗忘门 (f[t])—确定在计算新单元状态时有多少先前单元状态被丢弃

  • 一个输出门 (o[t])—确定当前单元状态对最终输出的贡献程度

这些门由可训练的权重构成。这意味着当在特定任务上训练 LSTM 模型时,门控机制将进行联合优化,以产生解决该任务所需的最佳信息流动。

在 LSTM 中,长期记忆和短期记忆是由什么承载的?

单元状态在模型在时间维度上进展的过程中保留了长期信息和关系。事实上,研究发现 LSTM 在学习时间序列问题时可以记住长达数百个时间步骤。

另一方面,输出状态可以被看作是短期记忆,它会查看输入、存储在单元状态中的长期记忆,并决定计算阶段所需的最佳信息量。

你可能还会想,“这个门控机制到底实现了什么?”让我用一个句子来说明。为了解决几乎所有的自然语言处理任务,捕捉句子中的句法和语义信息以及正确解析依赖关系是至关重要的。让我们看看 LSTM 如何帮助我们实现这一目标。假设你获得了以下句子:

狗追着绿球跑,它累了并朝飞机狂吠

在你的脑海中,想象一个 LSTM 模型从一个词跳到另一个词,逐个处理它们。然后假设你在处理句子的各个阶段向模型提问。例如你问“谁跑了?”当它处理短语“狗跑了”时。模型可能会广泛打开输入门来吸收尽可能多的信息,因为模型开始时对语言的外观一无所知。而且如果你仔细考虑,模型实际上不需要关注它的记忆,因为答案离“跑”这个词只有一个词的距离。

接下来,你问“谁累了?”在处理“它累了”时,模型可能希望利用它的单元状态而不是关注输入,因为这个短语中唯一的线索是“它”。如果模型要识别它和狗之间的关系,它将需要稍微关闭输入门并打开遗忘门,以使更多的信息从过去的记忆(关于狗的记忆)流入当前记忆中。

最后,假设当模型达到“对飞机吠叫”的部分时,你问:“被吠叫了什么?”为了产生最终输出,你不需要太多来自过去记忆的信息,因此你可能会紧缩输出门,以避免过多来自过去记忆的信息。我希望这些演示对理解这些门的目的有所帮助。请记住,这只是一种理解这些门目的的比喻。但在实践中,实际行为可能会有所不同。值得注意的是,这些门不是二进制的;相反,门的输出由一个 S 型函数控制,这意味着它在给定时间上产生软开/关状态,而不是硬开/关状态。

为了完成我们的讨论,让我们检查驱动 LSTM 单元中计算的方程式。但你不必详细记忆或理解这些方程式,因为使用 LSTM 不需要这样做。但为了使我们的讨论全面,让我们来看看它们。第一个计算计算输入门:

i[t] = σ(W[ih]h[t-1] + W[ix]x[t] + b[f])

这里,W[ih] 和 W[ix] 是可训练的权重,产生门值,其中 b[i] 是偏置。这里的计算与完全连接层的计算非常相似。该门产生了一个向量,其值介于 0 和 1 之间。你可以看到它与门的相似性(假设 0 表示关闭,1 表示打开)。其余的门遵循类似的计算模式。遗忘门计算为

f[t] = σ(W[fh]h[t-1] + W[fx]x[t] + b[f])

然后计算单元状态。单元状态是从两重计算中计算得到的:

[t] = tanh(W[ch] h[t][-1] + W[cx]x[t] = b[c])

c[t] = f[t]h[t][-1] + i[t][t]

计算相当直观。它使用遗忘门来控制先前的单元状态,其中使用输入门来控制使用 x[t](当前输入)计算的 [t]。最后,输出门和状态计算如下

o[t] = σ(W[oh]h[t-1] + W[ox]x[t] + b[0])

h[t] = o[t]tanh(c[t])

这里,c[t] 是使用通过遗忘门和输入门控制的输入计算得到的。因此,在某种程度上,o[t] 控制着当前输入、当前单元状态、上一个单元状态和上一个输出状态对 LSTM 单元最终状态输出的贡献。在 TensorFlow 和 Keras 中,你可以这样定义一个 LSTM:

import tensorflow as tf
tf.keras.layers.LSTM(units=128, return_state=False, return_sequences=False)

第一个参数 units 是 LSTM 层的超参数。类似于单位数定义了完全连接层的输出大小,units 参数定义了输出、状态和门向量的维度。这个数字越高,模型的代表能力就越强。接下来,return_state=False 表示当在输入上调用该层时,只返回输出状态。如果 return_state=True,则同时返回细胞状态和输出状态。最后,return_sequences=False 表示只返回处理整个序列后的最终状态。如果 return_sequences=True,则在处理序列中的每个元素时返回所有状态。图 9.4 描述了这些参数结果的差异。

09-04

图 9.4 LSTM 层输出的更改导致了 return_state 和 return_sequences 参数的更改。

接下来,让我们定义最终模型。

9.4.2 定义最终模型

我们将使用 Sequential API 定义最终模型。我们的模型将包含以下层(图 9.5):

  • 一个屏蔽层 —— 这一层在决定序列中的哪些输入元素将有助于训练方面起着重要作用。我们很快将学到更多相关内容。

  • 一个独热编码层 —— 这一层将把单词 ID 转换为独热编码序列。这是在将输入馈送给模型之前必须执行的重要转换。

  • 一个 LSTM 层 —— LSTM 层将最终输出状态作为输出返回。

  • 一个具有 512 个节点(ReLU 激活)的 Dense 层 —— Dense 层接收 LSTM 单元的输出,并产生一个临时的隐藏输出。

  • 一个 Dropout —— Dropout 是一种在训练过程中随机关闭输出的正则化技术。我们在第七章中讨论了 Dropout 的目的和工作原理。

  • 具有单个节点(sigmoid 激活)的最终输出层 —— 注意,我们只需要一个节点来表示输出。如果输出值为 0,则是负面情感。如果值为 1,则是正面情感。

09-05

图 9.5 情感分析器的高层模型架构

我们的 tf.data pipeline 生成一个 [, ] 形状的二维张量。在实践中,它们都可以是 None。换句话说,它将是一个 [None, None] 大小的张量,因为我们必须支持模型中可变大小的批次和可变大小的序列长度。大小为 None 的维度意味着模型可以在该维度上接受任何大小的张量。例如,对于一个 [None, None] 张量,当实际数据被检索时,它可以是一个 [5, 10]、[12, 54] 或 [102, 14] 大小的张量。作为模型的入口点,我们将使用一个重塑层包装在 lambda 层中,如下所示:

tf.keras.layers.Lambda(lambda x: tf.expand_dims(x, axis=-1), input_shape=(None,)),

这一层接收到数据管道生成的 [None, None] 输入,并将其重新塑造成一个 [None, None, 1] 大小的张量。这种重新塑造对于接下来的层来说是必要的,这也是讨论下一层的一个绝佳机会。接下来的层是一个特殊用途的遮罩层。我们在之前的章节中没有看到过遮罩层的使用。然而,在自然语言处理问题中,遮罩是常用的。遮罩的需求源自我们在输入序列进行桶装过程中执行的填充操作。在自然语言处理数据集中,很少会看到文本以固定长度出现。通常,每个文本记录的长度都不同。为了将这些大小不同的文本记录批量处理给模型,填充起着至关重要的作用。图 9.6 展示了填充后数据的样子。

09-06

图 9.6 填充前后的文本序列

但是这会引入额外的负担。填充引入的值(通常为零)不携带任何信息。因此,在模型中进行的任何计算都应该忽略它们。例如,当输入中使用填充时,LSTM 模型应该停止处理,并在遇到填充值之前返回最后一个状态。tf.keras.layers.Masking 层帮助我们做到了这一点。遮罩层的输入必须是一个 [批量大小,序列长度,特征维度] 大小的三维张量。这暗示了我们最后一点,即将我们的 tf.data 管道的输出重新塑造为三维张量。在 TensorFlow 中,您可以按如下方式定义一个遮罩:

tf.keras.layers.Masking(mask_value=0)

遮罩层创建了一个特殊的遮罩,并且这个遮罩被传递到模型中的后续层。像 LSTM 层这样的层知道如果从下面的一层传递了一个遮罩,它应该做什么。更具体地说,如果提供了一个遮罩,LSTM 模型将输出它遇到零值之前的状态值。此外,还值得注意 input_shape 参数。我们模型的输入将是一个二维张量:一个任意大小的批次,以及一个任意大小的序列长度(由于桶装)。因此,我们无法在 input_shape 参数中指定一个序列长度,所以模型期望的输入是一个 (None, None, 1) 大小的张量(额外的 None 自动添加以表示批次维度)。

定义了遮罩后,我们将使用自定义层将单词 ID 转换为独热向量。在将数据馈送到 LSTM 之前,这是一个关键步骤。这可以通过以下方式实现:

class OnehotEncoder(tf.keras.layers.Layer):
    def __init__(self, depth, **kwargs):
        super(OnehotEncoder, self).__init__(**kwargs)
        self.depth = depth

    def build(self, input_shape):
        pass

    def call(self, inputs):        

        inputs = tf.cast(inputs, 'int32')

        if len(inputs.shape) == 3:
            inputs = inputs[:,:,0]

        return tf.one_hot(inputs, depth=self.depth)

    def compute_mask(self, inputs, mask=None):
        return mask

    def get_config(self):
        config = super().get_config().copy()
        config.update({'depth': self.depth})
        return config

然后使用以下方式调用它

OnehotEncoder(depth=n_vocab),

这个层有点复杂,所以让我们一步步来。首先,您定义一个称为 depth 的用户定义参数。这定义了最终结果的特征维度。接下来,您必须定义 call() 函数。call() 函数接受输入,将它们转换为'int32',然后如果输入是三维的,则移除最终维度。这是因为我们定义的遮罩层具有大小为 1 的维度来表示特征维度。这个维度在我们用来生成一位热编码向量的 tf.one_hot() 函数中不被理解。因此,它必须被移除。最后,我们返回 tf.one_hot() 函数的结果。记住在使用 tf.one_hot() 时提供 depth 参数。如果没有提供,TensorFlow 尝试自动推断值,这会导致不同批次之间的张量大小不一致。我们定义 compute_mask() 函数来确保我们将遮罩传播到下一层。该层只是获取遮罩并将其传递给下一层。最后,我们定义一个 get_config() 函数来更新该层中的参数。正确返回一组参数对于配置来说是至关重要的;否则,您将在保存模型时遇到问题。我们将 LSTM 层定义为模型的下一层:

tf.keras.layers.LSTM(units=128, return_state=False, return_sequences=False)

关于在模型中传播遮罩的更多信息

当使用遮罩层时,记住一些重要事项是很重要的。首先,在使用遮罩时最好避免使用 lambda 层。这是因为在使用遮罩与 lambda 层同时存在时可能会出现一些问题(例如,github.com/tensorflow/tensorflow/issues/40085)。最佳选择是编写自定义层,就像我们所做的那样。在定义了自定义层之后,您必须重写 compute_mask() 函数以返回(如果需要的话进行修改的)下一层的遮罩。

我们在这里必须特别小心。根据您在定义此层时提供的参数,您将得到截然不同的输出。为了定义我们的情感分析器,我们只想要模型的最终输出状态。这意味着我们对单元状态不感兴趣,也不关心在处理序列期间计算的所有输出状态。因此,我们必须相应地设置参数。根据我们的要求,我们必须设置 return_state=False 和 return_sequences=False。最后,最终状态输出进入一个具有 512 个单位和 ReLU 激活的密集层:

tf.keras.layers.Dense(512, activation='relu'),

密集层后跟一个 Dropout 层,该层在训练期间会丢弃前一个密集层的 50% 输入。

tf.keras.layers.Dropout(0.5)

最后,模型被一个具有单个单元和 sigmoid 激活的密集层加冕,这将产生最终的预测。如果生成的值小于 0.5,则被认为是标签 0,否则为标签 1:

tf.keras.layers.Dense(1, activation='sigmoid')

我们可以按照下一个清单中所示定义完整的模型。

清单 9.5 完整情感分析模型的实现

model = tf.keras.models.Sequential([

    tf.keras.layers.Masking(mask_value=0.0, input_shape=(None,1)),         ❶
    OnehotEncoder(depth=n_vocab),                                          ❷
    tf.keras.layers.LSTM(128, return_state=False, return_sequences=False), ❸
    tf.keras.layers.Dense(512, activation='relu'),                         ❹
    tf.keras.layers.Dropout(0.5),                                          ❺
    tf.keras.layers.Dense(1, activation='sigmoid')                         ❻
])

❶ 创建一个遮罩来屏蔽零输入。

❷ 创建掩码后,将输入转换为 one-hot 编码的输入。

❸ 定义一个 LSTM 层,返回最后一个状态输出向量(从未掩码输入中)。

❹ 用 ReLU 激活函数来定义一个 Dense 层。

❺ 用 50% 的 dropout 率定义一个 Dropout 层。

❻ 用一个节点和 sigmoid 激活函数来定义最终的预测层。

接下来,我们要编译模型。再次,我们必须小心使用的损失函数。到目前为止,我们使用的是 categorical_crossentropy 损失函数。该损失函数用于多类别分类问题(大于两类)。由于我们解决的是二分类问题,我们必须改用 binary_crossentropy 损失函数。使用错误的损失函数可能导致数值不稳定和训练不准确的模型:

model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])

最后,让我们通过运行 model.summary() 来查看模型的概述:

Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
masking (Masking)            (None, None)              0         
_________________________________________________________________
lambda (Lambda)              (None, None, 11865)       0         
_________________________________________________________________
lstm (LSTM)                  (None, 128)               6140928   
_________________________________________________________________
dense (Dense)                (None, 512)               66048     
_________________________________________________________________
dropout (Dropout)            (None, 512)               0         
_________________________________________________________________
dense_1 (Dense)              (None, 1)                 513       
=================================================================
Total params: 6,207,489
Trainable params: 6,207,489
Non-trainable params: 0
_________________________________________________________________

这是我们第一次遇到顺序模型。让我们更详细地复习一下模型概述。首先,我们有一个返回与输入相同大小的输出的掩码层(即,[None, None] 大小的张量)。然后一个 one-hot 编码层返回一个具有 11865 个特征维度(即词汇表大小)的张量。这是因为,与单个整数表示的输入不同,one-hot 编码将其转换为一个大小为词汇表大小的零向量,并将由单词 ID 索引的值设置为 1。LSTM 层返回一个 [None, 128] 大小的张量。记住,我们只获取最终状态输出向量,它将是一个大小为 [None, 128] 的张量,其中 128 是单元数。LSTM 返回的最后一个输出传递到一个具有 512 个节点和 ReLU 激活函数的 Dense 层。接下来是一个 50% 的 dropout 层。最后,一个具有一个节点的 Dense 层产生最终的预测结果:一个介于 0 和 1 之间的值。

在接下来的部分,我们将在训练数据上训练模型,并在验证和测试数据上评估模型的性能。

练习 4

定义一个模型,该模型只有一个 LSTM 层和一个 Dense 层。LSTM 模型有 32 个单元,并接受一个大小为 (None, None, 30) 的输入(包括批次维度),并输出所有状态输出(而不是最终输出)。接下来,一个 lambda 层应该在时间维度上对状态进行求和,得到一个大小为 (None, 32) 的输出。这个输出传递到具有 10 个节点和 softmax 激活函数的 Dense 层。你可以使用 tf.keras.layers.Add 层对状态向量进行求和。你需要使用功能 API 来实现这个模型。

9.5 训练和评估模型

我们已经准备好训练刚刚定义的模型了。作为第一步,让我们定义两个 pipeline:一个用于训练数据,一个用于验证数据。记住,我们分割了数据并创建了三个不同的集合:训练集(tr_x 和 tr_y),验证集(v_x 和 v_y)和测试集(ts_x 和 ts_y)。我们将使用批量大小为 128:

# Using a batch size of 128
batch_size =128

train_ds = get_tf_pipeline(tr_x, tr_y, batch_size=batch_size, shuffle=True)
valid_ds = get_tf_pipeline(v_x, v_y, batch_size=batch_size)

然后是一个非常重要的计算。实际上,做或不做这个计算可以决定你的模型是否能够工作。记住,在第 9.1 节我们注意到数据集中存在显着的类别不平衡。具体来说,数据集中的正类比负类更多。在这里,我们将定义一个加权因子,以在计算损失和更新模型权重时为负样本分配更大的权重。为此,我们将定义加权因子:

weight[neg]= count(正样本)/count(负样本)

这将导致一个 > 1 的因子,因为正样本比负样本多。我们可以使用以下逻辑轻松计算:

neg_weight = (tr_y==1).sum()/(tr_y==0).sum()

这导致 weight[neg]~6(即约为 6)。接下来,我们将定义训练步骤如下:

model.fit(
    x=train_ds, 
    validation_data=valid_ds, 
    epochs=10, 
    class_weight={0:neg_weight, 1:1.0}
)

这里,train_ds 被传递给 x,但实际上包含了输入和目标。valid_ds,包含验证样本,被传递给 validation_data 参数。我们将运行这个 10 次迭代。最后,注意我们使用 class_weight 参数告诉模型负样本必须优先于正样本(因为数据集中的不足表示)。class_weight 被定义为一个字典,其中键是类标签,值表示给定该类样本的权重。当传递时,在损失计算期间,由于负类而导致的损失将乘以 neg_weight 因子,导致在优化过程中更多地关注负样本。实践中,我们将遵循与其他章节相同的模式并使用三个回调运行训练过程:

  • CSV 记录器

  • 学习率调度程序

  • 早停

完整代码如下所示。

列表 9.6 情感分析器的训练过程

os.makedirs('eval', exist_ok=True)

csv_logger = tf.keras.callbacks.CSVLogger(
       os.path.join('eval','1_sentiment_analysis.log'))                       ❶

monitor_metric = 'val_loss'
mode = 'min'
print("Using metric={} and mode={} for EarlyStopping".format(monitor_metric, mode))

lr_callback = tf.keras.callbacks.ReduceLROnPlateau(
    monitor=monitor_metric, factor=0.1, patience=3, mode=mode, min_lr=1e-8    ❷
) 

es_callback = tf.keras.callbacks.EarlyStopping(
    monitor=monitor_metric, patience=6, mode=mode, restore_best_weights=False ❸
)

model.fit(
    train_ds,                                                                 ❹
    validation_data=valid_ds, 
    epochs=10, 
    class_weight={0:neg_weight, 1:1.0}, 
    callbacks=[es_callback, lr_callback, csv_logger])

❶ 将性能指标记录到 CSV 文件中。

❷ 学习率降低回调

❸ 早停回调

❹ 训练模型。

你应该得到类似的结果:

Using metric=val_loss and mode=min for EarlyStopping
Epoch 1/10
2427/2427 [==============================] - 72s 30ms/step - loss: 0.7640 - accuracy: 0.7976 - val_loss: 0.4061 - val_accuracy: 0.8193 - lr: 0.0010

...

Epoch 7/10
2427/2427 [==============================] - 73s 30ms/step - loss: 0.2752 - accuracy: 0.9393 - val_loss: 0.7474 - val_accuracy: 0.8026 - lr: 1.0000e-04
Epoch 8/10
2427/2427 [==============================] - 74s 30ms/step - loss: 0.2576 - accuracy: 0.9439 - val_loss: 0.8398 - val_accuracy: 0.8041 - lr: 1.0000e-04

看起来在训练结束时,我们已经达到了超过 80% 的验证准确率。这是个好消息,因为我们确保验证数据集是一个平衡的数据集。但我们不能太肯定。我们需要在模型没有见过的数据集上测试我们的模型:测试集。在此之前,让我们保存模型:

os.makedirs('models', exist_ok=True)
tf.keras.models.save_model(model, os.path.join('models', '1_sentiment_analysis.h5'))

我们已经创建了测试数据集,并已经定义了处理数据的 NLP 管道,所以只需调用 get_tf_pipeline() 函数与数据:

test_ds = get_tf_pipeline(ts_x, ts_y, batch_size=batch_size)

现在只需调用以下一行代码即可获得模型的测试性能:

model.evaluate(test_ds)

最终结果如下所示:

87/87 [==============================] - 2s 27ms/step - loss: 0.8678 - accuracy: 0.8038

我们现在可以安心地睡觉,知道我们的模型在未见数据上的性能与训练期间看到的验证性能相当。

仅仅好的准确率是我们追求的吗?

简短的答案是否定的。解决一个机器学习任务涉及到许多任务的和谐工作。在执行这些任务的过程中,我们对输入和输出进行各种转换/计算。整个过程的复杂性意味着出错的机会更多。因此,在整个过程中我们应该尽可能检查尽可能多的事情。

单单谈论测试,我们必须确保测试数据在通过数据管道时被正确处理。此外,我们还应该检查最终的预测结果。除了许多其他检查之外,你可以检查最顶部的正面预测和负面预测,以确保模型的决策是合理的。你只需简单地视觉检查输入文本和相应的预测。我们将在下一节中讨论具体内容。

这只会略微增加你的模型时间。但它也可以为你节省数小时的调试时间,以及因发布不准确的模型而导致的尴尬或声誉损失。

在接下来的章节中,我们将通过使用词向量来表示输入模型的标记来进一步增强我们的模型。词向量有助于机器学习模型更好地理解语言。

练习 5

假设你的训练数据集中有三个类别:A、B 和 C。你有 10 条 A 类的记录,25 条 B 类的记录和 50 条 C 类的记录。你认为这三个类别的权重会是多少?记住,大多数类别应该获得较小的权重。

9.6 用词向量注入语义

你已经建立了一个可以以约 80%的准确率测量情绪的模型,但你希望进一步改进。你相信词嵌入将提供所需的优势以达到更高的准确率。词嵌入是将单词编码为特征向量的一种方法。词嵌入与模型训练一起学习词汇表中单词的特征向量。嵌入层引入了一个可训练矩阵,其中每一行表示词汇表中单词的一个可训练向量。这比独热编码要好得多,因为独热编码受到维度灾难的困扰,这意味着随着单词数量的增加,输入模型的维度也会增加。

你应该为拥有一个可以准确分类正面和负面情绪的合理模型感到自豪;80%的准确率是一个很好的起点。但让我们看看我们已经拥有的优秀模型中可以改进的地方。

正面对着我们的一个瓶颈是我们模型中的单热编码层。尽管单热编码很简单,但它是一种非常低效的单词表示。它是单词的局部表示,意味着表示中只有一个元素(设为值 1)携带信息。换句话说,这是一种非常稀疏的表示,其中有大量元素被设置为零且不贡献信息。单热编码还受到维度诅咒的影响。最后,单热编码完全忽略了文本中存在的宝贵语义。使用单热编码,你不能说一只猫和一只狗更相似还是更相似于一座火山。现在的问题是,是否有更好的表示单词的方法?

9.6.1 词嵌入

是时候迎来一个称为词嵌入的新时代的单词表示了。词嵌入,有时也称为词向量,是一种非常丰富和高效的单词表示。与像单热向量这样的局部表示相反,词向量提供了一种分布式表示。这意味着向量中的所有元素都在定义向量所表示的词中发挥作用。换句话说,词向量具有密集的表示,与单热向量的稀疏表示相反。词向量的维度不取决于词汇量的大小,这使你可以节省内存和计算时间。最重要的是,词向量捕捉到了词的语义或相似性。使用词向量,你知道猫更类似于狗而不是火山。在理解词向量之前,你必须理解上下文在单词周围扮演的重要角色。词向量在很大程度上依赖于单词的上下文来生成丰富的单词表示。上下文的重要性被一位英国语言学家 J.R.弗斯的一句名言潜移默化地捕捉到:

你可以通过一个词的周围环境来了解它。

再深入一点,一个词的上下文在定义该词的语义中起着重要作用。例如,看看下面这个句子:

我们的宠物托比是一只 ____;他喜欢玩球。

你认为这里应该填什么词?我们在上下文中看到了“宠物”、“玩耍”和“球”这样的词。很可能是猫或狗。这意味着只有某种类型的词(即某种宠物)会出现在这个上下文中。利用这个特性,词向量可以生成保留文本语义的向量。在这个例子中,词向量将捕捉到猫和狗非常相似(当然,不是生物学上的相似,而是我们与它们互动或感知的方式)。从更技术的角度来看,词向量算法的目标如下:如果词w[i]和w[j]出现在同样的上下文中,对于某个距离度量Dist(a,b),它测量两个向量ab之间的距离:

Dist(w[i], w[j]) ~ 0

实际的词向量算法超出了本书的范围。一些值得注意的算法包括 Skip-gram、CBoW(Continuous Bag-of-Words)、GloVe(全局向量)和 ELMo(来自语言模型的嵌入)。您可以通过阅读 Tomas Mikolov 等人的论文“Efficient Estimation of Word Representations in Vector Space” (arxiv.org/pdf/1301.3781.pdf) 了解更多关于 Skip-gram 和 CBoW 算法的细节。

展示给我词向量算法

词向量算法以无监督的方式训练。训练算法的具体细节会根据算法而异。Skip-gram 算法通过选择一个探针单词作为输入,上下文单词作为目标生成输入目标对。例如,从句子“I went to buy flowers”中,它将生成如下输入目标对:[(went, I), (went, to), (to, went), (to, buy), . . . ]。然后,它将解决预测探测单词的上下文的分类任务,这将导致识别出良好的词向量。然而,像 Skip-gram 这样的词向量算法遭受语料库全局视图的缺乏,因为该算法只考虑单词周围的小上下文。

另一方面,GloVe 使用局部和全局信息生成词向量。为了提取语料库的全局信息,它利用了一个共现矩阵,其中包含单词“i”在语料库中与“j”上下文中出现的次数。您可以在 Pennington 等人的论文“GloVe: Global Representations for Word Vectors” (nlp.stanford.edu/pubs/glove.pdf) 中了解更多信息。GloVe 仍然没有解决模糊词的问题。所谓的模糊词是指根据上下文具有不同含义的词。例如,在句子“I went to the bank to deposit money”和“I walked on the river bank”中,“bank”这个词有完全不同的含义。GloVe 会为这两种情况给出相同的向量,这是不准确的。

进入 ELMo!ELMo 是由 Peters 等人在论文“深度上下文化的词表示”中介绍的 (arxiv.org/pdf/1802.05365.pdf)。ELMo 使用双向 LSTM 模型生成词向量。双向 LSTM 类似于标准 LSTM,但同时正向和反向读取序列。

词向量算法的最终输出是一个 V × d 大小的嵌入矩阵。这个矩阵的第 i 行表示由 ID i 表示的单词的词向量。d 通常 < 300,并使用超参数算法选择。图 9.7 描述了词嵌入矩阵。

09-07

图 9.7 概述了如何使用嵌入矩阵获取词向量。使用输入单词 ID 进行查找以获取对应这些索引的向量。这些向量的实际值在模型训练期间学习。

现在,我们将用单词嵌入增强我们的情感分析器模型。

9.6.2 使用单词嵌入定义最终模型

一般来说,任何深度序列模型都可以从单词嵌入中受益。作为额外的好处,大多数情况下,您不需要担心单词向量算法本身,可以通过引入一个随机初始化的嵌入空间来获得良好的性能。然后,这些嵌入可以与模型的其他部分一起在解决特定 NLP 任务时进行联合训练。按照同样的模式,让我们向我们的情感分析器引入一个随机初始化的嵌入空间。

让我们回顾一下我们之前实现的模型。该模型由一个掩码层、一个独热编码器层和一个 LSTM 层,后面跟着两个 Dense 层(之间有丢弃)组成:

model = tf.keras.models.Sequential([
    # Create a mask to mask out zero inputs
    tf.keras.layers.Masking(mask_value=0.0, input_shape=(None,1)),
    # After creating the mask, convert inputs to onehot encoded inputs
    OnehotEncoder(depth=n_vocab),
    # Defining an LSTM layer
    tf.keras.layers.LSTM(128, return_state=False, return_sequences=False),
    # Defining a Dense layer
    tf.keras.layers.Dense(512, activation='relu'),
    tf.keras.layers.Dropout(0.5),
    tf.keras.layers.Dense(1, activation='sigmoid')
])

除了两个改变外,模型将保持不变:

  • 我们将用 tf.keras.layers.Embedding 层替换独热编码器层。

  • 通过在层中设置 mask_zero=True,掩码功能将被吸收到 tf.keras.layers.Embedding 层中。

tf.keras.layers.Embeddings 层将一个大的可训练矩阵引入模型。该矩阵是一个 (V+1) x d 大小的矩阵,其中 V 是词汇表的大小。额外的一个是因为我们使用了特殊的保留 ID 零。d 是通过超参数优化算法选择的。在下面的模型中,我们将 d = 128 设置为经验值。已经在列表中用粗体标出了已更改的行。

列表 9.7 使用单词嵌入实现情感分析器

model = tf.keras.models.Sequential([                                               ❶

    tf.keras.layers.Embedding(input_dim=n_vocab+1, output_dim=128, mask_zero=True),❷

    tf.keras.layers.LSTM(128, return_state=False, return_sequences=False),         ❸
    tf.keras.layers.Dense(512, activation='relu'),                                 ❹
    tf.keras.layers.Dropout(0.5),                                                  ❺
    tf.keras.layers.Dense(1, activation='sigmoid')                                 ❻
])

model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])  ❼
model.summary()

❶ 创建一个掩码以屏蔽零输入。

❷ 添加一个嵌入层。它将查找作为输入传递的单词 ID 的单词向量。

❸ 定义一个 LSTM 层。

❹ 定义 Dense 层。

❺ 定义一个 Dropout 层。

❻ 使用 S 型激活函数定义最终的 Dense 层。

❼ 使用二元交叉熵编译模型。

空输入、掩码和 LSTM 层

我们通过引入一个过滤器来过滤掉数据集中的空评论,以确保我们的数据集中没有任何空评论。理解我们这样做的原因非常重要。除了起到数据清洗的作用外,它还具有重要的目的。在数据集中有空评论会导致我们的数据流水线中出现全零向量。例如,如果序列长度为 5,则空评论将返回 [0,0,0,0,0]。当使用掩码层时,所有输入将被忽略。这对于 LSTM 层是一个问题性的边缘情况,并将引发以下错误:

UnknownError:  [_Derived_]  CUDNN_STATUS_BAD_PARAM
in tensorflow/stream_executor/cuda/cuda_dnn.cc(1496): 
➥ 'cudnnSetRNNDataDescriptor( data_desc.get(), data_type, layout, 
➥ max_seq_length, batch_size, data_size, seq_lengths_array, 
➥ (void*)&padding_fill)'
         [[{{node cond_38/then/_0/CudnnRNNV3}}]]
         [[sequential/lstm/StatefulPartitionedCall]]
         [[gradient_tape/sequential/embedding/embedding_lookup/Reshape/_42]] 
➥ [Op:__inference_train_function_8225]

Function call stack:
train_function -> train_function -> train_function

由于这个原因,在将数据提供给模型之前,您必须确保从数据中过滤掉空评论。

有了这个,我们将训练我们定义的模型。

9.6.3 训练和评估模型

训练和评估代码与我们之前讨论的实现相同。因此,我们将不再赘述讨论。当您训练新模型时,您将看到类似于以下的结果。

当你训练模型时,你将达到略高于我们以前体验到的验证准确性的验证准确性:

Epoch 1/25
2427/2427 [==============================] - 30s 12ms/step - loss: 0.7552 - 
➥ accuracy: 0.7949 - val_loss: 0.3942 - val_accuracy: 0.8277 - lr: 0.0010
Epoch 2/25

...

Epoch 8/25
2427/2427 [==============================] - 29s 12ms/step - loss: 0.3059 - 
➥ accuracy: 0.9312 - val_loss: 0.6839 - val_accuracy: 0.8130 - lr: 1.0000e-04

评估模型可以通过运行以下代码完成:

test_ds = get_tf_pipeline(ts_x, ts_y, batch_size=128)
model.evaluate(test_ds)

看起来添加一个嵌入层也会导致稍微更高的测试性能:

87/87 [==============================] - 0s 5ms/step - loss: 0.7214 - accuracy: 0.8111

记住我们说过不应该单凭准确性来信任模型。现在让我们深入一点,看看我们的模型是否在做出合理的预测。一个简单的方法是检查测试集中前 k 个正面评价和前 k 个负面评价,并进行视觉检查。当我们完成评估时,我们耗尽了 tf.data 流水线。因此,我们需要重新定义数据流水线:

test_ds = get_tf_pipeline(ts_x, ts_y, batch_size=128)

然后,我们将逐批次进行,并且对于每个批次,我们将把输入、预测和目标分别存储在三个单独的列表中:test_x,test_pred 和 test_y:

test_x = []
test_pred = []
test_y = []
for x, y in test_ds:
    test_x.append(x)    
    test_pred.append(model.predict(x))
    test_y.append(y)
test_x = [doc for t in test_x for doc in t.numpy().tolist()]
test_pred = tf.concat(test_pred, axis=0).numpy()
test_y = tf.concat(test_y, axis=0).numpy()

我们将使用 argsort 来获取排序预测数组的索引。这样,数组的开头将包含最负面的评价的索引,而数组的末尾将包含最正面的评价的索引。让我们取最上面的五个和最下面的五个评价来进行视觉检查:

sorted_pred = np.argsort(test_pred.flatten())
min_pred = sorted_pred[:5]
max_pred = sorted_pred[-5:]

print("Most negative reviews\n")
print("="*50)
for i in min_pred:    
    print(" ".join(tokenizer.sequences_to_texts([test_x[i]])), '\n')

print("\nMost positive reviews\n")
print("="*50)
for i in max_pred:
    print(" ".join(tokenizer.sequences_to_texts([test_x[i]])), '\n')

让我们来检查结果:

Most negative reviews
==================================================
buy game high rating promise gameplay saw youtube story so-so graphic 
➥ mediocre control terrible could not adjust control option preference ...

attempt install game quad core windows 7 pc zero luck go back forth try 
➥ every suggestion rockstar support absolutely useless game ... 

way product 5 star 28 review write tone lot review similar play 2 song 
➥ expert drum say unless play tennis shoe fact screw not flush mean feel 
➥ every kick specifically two screw leave plus pedal completely torn 
➥ mount screw something actually go wrong pedal instal unscrew send back 
➥ ea 

unk interactive stranger unk unk genre develop operation flashpoint various 
➥ real-life computer sims military folk unk know come deliver good 
➥ recreation ultra deadly unk modern combat engagement arma attempt 
➥ simulate `` unk firepower '' soldier combine arm warfare set fictional 
➥ sprawl island nation conveniently mirror terrain middle eastern country 

not cup tea 

Most positive reviews
==================================================
find something love every final fantasy game play thus far ff13 different 
➥ really appreciate square enix 's latest trend shake gameplay new 
➥ release still hammer best look ... 
know little squad base game genre know game fun not unk fun obliterate 
➥ enemy mobile suit satisfy blow zombie head resident evil graphic 
➥ presentation solid best franchise yes ... 

okay hdtv monitor cause ps3 switch game movie widescreen fullscreen every 5 
➥ minute someone tell need get hd component cable look price saw brand 
➥ name sony much money basically name brand pay fancy retail packaging 
➥ generic cable get quality without fancy packaging name brand embed 
➥ cable favor save money 

absolutely phenomenal gaming mouse love programmable size button mouse 
➥ surprising ergonomic design ... 

first motorstorm come unk racing type one pioneer physic base race every 
➥ track lot branch path every branch suitable different class vehicle 
➥ take next level race much bigger apart mud also obstacles individual 
➥ vehicle class small vehicle get stuck plant unk hazard time lot physic 
➥ people complain vehicle slide 

我们可以自信地说,我们的情感分析器取得了成功!模型识别出的负面和正面评价似乎都放在了正确的位置。我们不得不克服许多与数据质量、数据准备和模型设计有关的障碍。在所有这些困难中,我们都坚持了下来!在下一章中,我们将讨论另一个被称为语言建模的 NLP 任务。

练习 6

Skip-gram 等词向量算法使用直接连接到与词汇表大小相同的密集层的嵌入层。如果你有一个大小为 500 的词汇表,想要生成 32 维的词向量,并且有一个具有 500 个单元和 softmax 激活的密集层,你会如何实现一个模型?该模型接受大小为(None,500)的(批量维度为 None)one-hot 编码的单词向量。

摘要

  • NLTK 库提供了一个 API 来执行各种文本预处理任务,例如将文本标记为单词、删除停用词、词形还原等等。

  • 预处理任务需要谨慎处理。例如,在删除停用词时,单词“not”不应该被删除。这是因为在情感分析任务中,“not”这个词承载着非常重要的信息。

  • tensorflow.keras.preprocessing.text.Tokenizer 可以用来将文本转换为数字。这是通过 Tokenizer 首先构建一个将每个唯一单词映射到唯一 ID 的字典。然后给定的文本可以被转换为一系列 ID。

  • 填充是一种将可变长度文本转换为相同长度的技术。

  • 填充工作是通过在给定文本语料库中将所有序列填充到固定长度,通过在末尾或开头插入零来完成的。

  • 在处理文本等变长序列时,还有一种称为“桶装”(bucketing)的策略,用于将相似长度的文本序列一起进行批处理。这有助于模型保持内存占用小,同时不浪费在过多填充上的计算。

  • 在 TensorFlow 中,你可以使用 tf.data.experimental.bucket_by_sequence_length() 来对文本序列进行桶装

  • LSTM(长短期记忆)模型在解决自然语言处理任务方面表现出卓越的性能。

  • LSTM 模型通过从一个时间步到下一个时间步,同时处理该时间步的输入以产生每个时间步的输出来工作。

  • LSTM 模型具有存储长期和短期记忆的机制。这是通过控制 LSTM 单元中信息流动的门控机制实现的。

  • 单词嵌入是一种文本编码方法,优于一热编码,并且在生成单词的数值表示时具有保留单词语义的能力。

练习答案

练习 1

s = s.replace("-", ' ')
   tokens = word_tokenize(s)                                                             
pos_tags = nltk.pos_tag(tokens)
clean_text = [
        lemmatizer.lemmatize(w, pos=p[0].lower()) if p[0]=='V' else w
        for (w, p) in pos_tags
]

练习 2

s = “a_b_B_c_d_a_D_b_d_d”
tok = Tokenizer(num_words = 3, split=”_”, lower=True)
tok.fit_on_texts([s])

Most common words get the lowest word ID (starting from 1). 
➥ tok.texts_to_sequences([s]) will produce [[3,2,2,1,3,1,2,1,1]]

练习 3

bucket_fn = tf.data.experimental.bucket_by_sequence_length(
        lambda x: tf.cast(tf.shape(x)[0],'int32'), 
        bucket_boundaries=[10, 25, 30], 
        bucket_batch_sizes=[batch_size,batch_size,batch_size, batch_size], 
        padded_shapes=None,
        padding_values=0, 
        pad_to_bucket_boundary=True
    )

练习 4

inp = tf.keras.layers.Input(shape=(None, 30))
lstm_out = tf.keras.layers.LSTM(32, return_sequences=True)(inp)
sum_out = tf.keras.layers.Add(axis=1)(lstm_out)
dense_out = tf.keras.layers.Dense(10, activation=’softmax’)(sum_out)

练习 5

A - (25+50)/(10+25+50)
B - (10+50)/(10+25+50)
C - (10+25)/(10+25+50)

练习 6

tf.keras.models.Sequential(
[
    tf.keras.layers.Embedding(input_dim=500, output_dim=32, input_shape=(500,)),
    tf.keras.layers.Dense(500, activation=’softmax’)
])

第十章:用 TensorFlow 进行自然语言处理:语言建模

本章涵盖

  • 使用 TensorFlow 构建自然语言处理数据管道

  • 实现基于 GRU 的语言模型

  • 使用困惑度指标评估语言模型

  • 定义一个推断模型,从训练好的模型中生成新的文本

  • 实现束搜索以提升生成文本的质量

在上一章中,我们讨论了一个重要的自然语言处理任务,称为情感分析。在那一章中,你使用了一个视频游戏评论数据集,并训练了一个模型来分析文本,预测评论是否带有负面或正面情感。你学习了各种预处理步骤,可以执行这些步骤以提高文本的质量,例如去除停用词和词形还原(即将单词转换为基本形式;例如,将复数转换为单数)。你使用了一种特殊类型的模型,称为长短期记忆(LSTM)。LSTM 模型可以处理诸如句子之类的序列,并学习它们之间的关系和依赖以产生结果。LSTM 模型通过保持一个包含有关过去信息的状态(或记忆)来执行这一任务,因为它逐个元素地处理序列。LSTM 模型可以使用它已经看到的过去输入的记忆以及当前输入,在任何给定时间产生一个输出。

在本章中,我们将讨论一项称为语言建模的新任务。语言建模一直是自然语言处理的核心。语言建模是指在给定一系列先前单词的情况下预测下一个单词的任务。例如,给定句子“I went swimming in the ____”,模型将预测单词“pool”。像 BERT(双向编码器表示来自 Transformers 的模型,这是一种基于 Transformer 的模型)这样的开创性模型是使用语言建模任务进行训练的。这是语言建模如何帮助实现创新模型,并在各种领域和用例中得到应用的一个典型例子。

在我看来,语言建模在自然语言处理领域是一个被忽视的角色。它没有得到足够的重视,主要是因为该任务本身的使用案例有限。然而,语言建模可以为解决其他下游使用案例(例如信息检索、问答、机器翻译等)提供急需的语言知识(例如语义、语法、依赖解析等)。因此,作为一个自然语言处理从业者,你必须理解语言建模任务。

在本章中,您将构建一个语言模型。您将学习有关各种预处理步骤的知识,例如使用 n-grams 而不是完整单词作为模型的特征来减小词汇量的大小。您可以通过每 n 个字符分割文本来将任何文本转换为 n-grams(例如,如果使用 bi-grams,则 aabbbccd 变为 aa、bb、bc 和 cd)。您将定义一个 tf.data 数据流水线,它将为我们完成大部分预处理工作。接下来,您将使用一种被称为门控循环单元(GRU)的 LSTM 模型的密切关联方法来执行语言建模任务。GRU 比 LSTM 模型简单得多,训练速度更快,同时保持与 LSTM 模型相似的性能。我们将使用一种称为困惑度的特殊度量标准来衡量模型的好坏。困惑度衡量了模型在给定前几个词的情况下看到下一个词时的惊讶程度。在本章后面的部分,您将更多地了解这个度量标准。最后,您将学习一种称为波束搜索的技术,可以显著提高模型生成的文本的质量。

10.1 处理数据

您一直在密切关注一批新一代的深度学习模型,被称为 Transformer。这些模型是使用语言建模进行训练的。这是一种可以用来训练自然语言处理模型以生成故事/Python 代码/电影剧本的技术,具体取决于使用的训练数据。其思想是当一个由 n 个单词组成的序列中,预测第 n+1 个单词。训练数据可以从文本语料库中轻松生成,只需将一个文本序列作为输入,将其向右移动 1 位以生成目标序列。这可以在字符级、单词级或 n-gram 级别上进行。我们将使用两个连续的单词作为语言建模任务的 n-gram。我们将使用 Facebook 的一个名为 bAbI 的儿童故事数据集(research.fb.com/downloads/babi/)。您将创建一个 TensorFlow 数据流水线,用于执行这些转换以生成输入和目标序列。

10.1.1 什么是语言建模?

我们已经简要讨论了语言建模的任务。简而言之,语言建模对于文本w[1]、w[2],...、w[n][-1]、w[n],其中w[i]是文本中的第i个词,计算给定w[1]、w[2],...、w[n][-1]时,w[n]的概率。在数学上表示为:

P(w [n]|w[1], w[2], ..., w[n][-1])

换句话说,它预测给定w[1]、w[2],...、w[n][-1]时的w[n]。在训练模型时,我们训练它最大化这个概率;换句话说:

argmax[W] P(w [n]|w[1], w[2], ..., w[n][-1])

在这种概率计算中,使用了具有可训练权重/参数W的模型。对于大文本来说,这种计算变得计算上不可行,因为我们需要从当前单词一直回溯到第一个单词。为了使其计算可行,让我们使用马尔科夫性质,它指出你可以使用有限的历史来近似这个序列;换句话说

P(w [n]|w[1], w[2], ..., w[n][-1]) ≈ P(w [n]|w[k], w[k+1], ..., w[n][-1]) for some k

如您所想象的那样,k越小,近似效果越好。

闭合任务

类似 BERT 的 Transformer 模型使用了一种称为masked language modeling的语言模型变体。蒙面语言建模受到了闭合*任务或者填空测试的启发。这个想法是在给出一个带有一个或多个空白处的句子时,要求学生填写空白处的单词。这在语言评估测试中被用来衡量学生的语言能力。在蒙面语言建模中,模型变成了学生。单词随机地从输入中删除,并且要求模型预测缺失的单词。这构成了像 BERT 这样的模型中使用的训练过程的基础。

10.1.2 下载和玩弄数据

作为第一步,让我们使用以下列表中的代码下载数据集。

列表 10.1 下载亚马逊评论数据集

import os
import requests
import tarfile

import shutil
# Retrieve the data
if not os.path.exists(os.path.join('data', 'lm','CBTest.tgz')):      ❶
    url = "http:/ /www.thespermwhale.com/jaseweston/babi/CBTest.tgz"
    # Get the file from web
    r = requests.get(url)

    if not os.path.exists(os.path.join('data','lm')):
        os.mkdir(os.path.join('data','lm'))

    # Write to a file
    with open(os.path.join('data', 'lm', 'CBTest.tgz'), 'wb') as f:  ❷
        f.write(r.content)

else:
    print("The tar file already exists.")

if not os.path.exists(os.path.join('data', 'lm', 'CBTest')):         ❸
    # Write to a file
    tarf = tarfile.open(os.path.join("data","lm","CBTest.tgz"))
    tarf.extractall(os.path.join("data","lm"))  
else:
    print("The extracted data already exists")

❶ 如果尚未下载包含数据的tgz文件,则下载数据。

❷ 将下载的数据写入磁盘。

❸ 如果tgz文件可用但尚未解压缩,则将其解压缩到给定的目录。

如果数据不存在,列表 10.1 将会下载到本地文件夹并解压缩内容。如果您查看数据文件夹(具体地说,data/lm/CBTest/data),您会看到它有三个文本文件:cbt_train.txt,cbt_valid.txt 和 cbt_test.txt。每个文件包含一组故事。我们将在内存中读取这些文件。我们将在下一个列表中定义一个简单的函数来将这些文件读入内存。

列表 10.2 在 Python 中读取故事

def read_data(path):
    stories = []                                           ❶

    with open(path, 'r') as f:    
        s = []                                             ❷
        for row in f:

            if row.startswith("_BOOK_TITLE_"):             ❸
                if len(s)>0:
                    stories.append(' '.join(s).lower())    ❹
                s = []                                     ❺

            s.append(row)                                  ❻

    if len(s)>0:
        stories.append(' '.join(s).lower())                ❼

    return stories

❶ 定义一个列表来保存所有的故事。

❷ 定义一个列表来保存一个故事。

❸ 当我们遇到以 _BOOK_TITLE 开头的行时,它是一个新的故事。

❹ 如果我们看到了一个新故事的开始,将已存在的故事添加到故事列表中。

❺ 重置包含当前故事的列表。

❻ 将当前文本行添加到列表s中。

❼ 在循环结束后处理最后一个故事仍然存在于s中的边界情况。

这段代码打开给定的文件并逐行读取它。我们还有一些额外的逻辑来将文本分割成单独的故事。正如前面所说,每个文件包含多个故事。我们想在最后创建一个字符串列表,其中每个字符串是一个单独的故事。前一个函数就是这样做的。接下来,我们可以读取文本文件并将它们存储在变量中,如下所示:

stories = read_data(os.path.join('data','lm','CBTest','data','cbt_train.txt'))
val_stories = read_data(os.path.join('data','lm','CBTest','data','cbt_valid.txt'))
test_stories = read_data(os.path.join('data','lm','CBTest','data','cbt_test.txt'))

这里,故事将包含训练数据,val_stories 将包含验证数据,最后,test_stories 将包含测试数据。让我们快速查看一些关于数据集的高级信息:

print("Collected {} stories (train)".format(len(stories)))
print("Collected {} stories (valid)".format(len(val_stories)))
print("Collected {} stories (test)".format(len(test_stories)))
print(stories[0][:100])
print('\n', stories[10][:100])

此代码检查每个数据集中有多少个故事,并打印训练集中第 11 个故事的前 100 个字符:

Collected 98 stories (train)
Collected 5 stories (valid)
Collected 5 stories (test)

chapter i. -lcb- chapter heading picture : p1.jpg -rcb- how the fairies 
➥ were not invited to court .

 a tale of the tontlawald long , long ago there stood in the midst of a 
➥ country covered with lakes a 

出于好奇,让我们也分析一下我们要处理的词汇量。为了分析词汇量,我们将首先将我们的字符串列表转换为字符串列表的列表,其中每个字符串都是一个单词。然后,我们可以利用内置的 Counter 对象来获取文本语料库的单词频率。之后,我们将创建一个 pandas Series 对象,其中频率作为值,单词作为索引,并查看有多少单词出现超过 10 次:

from collections import Counter
# Create a large list which contains all the words in all the reviews
data_list = [w for doc in stories for w in doc.split(' ')]

# Create a Counter object from that list
# Counter returns a dictionary, where key is a word and the value is the frequency
cnt = Counter(data_list)

# Convert the result to a pd.Series 
freq_df = pd.Series(
    list(cnt.values()), index=list(cnt.keys())
).sort_values(ascending=False)

# Count of words >= n frequent
n=10
print("Vocabulary size (>={} frequent): {}".format(n, (freq_df>=n).sum()))

这将返回

,      348650
the    242890
.\n    192549
and    179205
to     120821
a      101990
of      96748
i       79780
he      78129
was     66593
dtype: int64

Vocabulary size (>=10 frequent): 14473

近 15,000 个单词;这是相当大的词汇量——而且这只是出现超过 10 次的单词。在上一章中,我们处理了大约 11,000 个单词的词汇。那么为什么我们应该担心额外的 4,000 个单词呢?因为更多的单词意味着模型的特征更多,这意味着参数的数量更多,过拟合的机会也更多。简短的答案是这取决于你的用例。

例如,在上一章中我们拥有的情感分析模型中,最终预测层是一个单节点完全连接层,而不管词汇量大小如何。然而,在语言建模中,最终预测层的维度取决于词汇量大小,因为最终目标是预测下一个单词。这是通过一个 softmax 层来完成的,它表示在给定单词序列的情况下,下一个单词在整个词汇表中的概率似然。随着 softmax 层的增长,不仅内存要求增加,计算时间也要增加。因此,值得研究其他减少词汇量的技术。

大词汇量是 softmax 层的最终弱点吗?

softmax 层的一个主要弱点是其计算复杂度。softmax 层首先需要执行矩阵乘法以获取 logits(即网络最终层输出的未归一化分数)。然后,它需要对最后一个轴进行求和以计算输出的 softmax 概率。具体来说,对于输入 h,softmax 层的 logits 计算为

s = hW + b 其中 WR^(|h|×|V|) [∧] b [∈] R^(|V|)

其中 W 是权重矩阵,b 是该最终层的偏置,|h| 是输入的大小,|V| 是词汇量的大小。然后应用 softmax 归一化

10_00a

这些计算应该使您明白,大词汇量(对于现实世界的应用程序,其大小很容易达到数十万)将在模型训练期间的有限时间内执行此计算时会产生问题。在数千批数据上执行此操作会使问题变得更加严重。因此,已经出现了更好的技术来计算损失,而无需使用所有 logit。两种流行选择是

  • 噪声对比估计(NCE)损失

  • 分层 softmax

噪声对比估计(NCE)

我们将研究驱动这些方法的主要动机,但不会深入探讨具体细节,因为这被认为超出了本书的范围。有关这些主题的详细信息,请参阅ruder.io/word-embeddings-softmax。NCE 仅使用由真实目标和 k 个随机 logit 样本(称为噪声)索引的 logit 来计算损失。您在噪声样本中匹配真实数据分布的越多,结果就越好。具体来说,如果真实目标是s,并且在索引s处的 logit 称为s[i],则使用以下损失:

10_00b

在这里,σ表示 sigmoid 激活是神经网络中常用的激活函数,并且计算为σ (x) = 1/(1 + e^(-x)),其中s[i]表示真实目标 i 的 logit 值,j 表示从词汇表P[n]上的预定义分布中采样的索引。

分层 softmax

与标准 softmax 不同,其中每个节点代表词汇表中的一个元素的平坦结构相比,分层 softmax 将词汇表中的单词表示为二叉树中的叶节点,并且任务变成选择向左还是向右以达到正确的节点。以下图示了形成过程

使用分层 softmax 时,计算层的最大层数。显然,为了推断给定前一序列的单词的概率,该层最多只需进行三步计算(由黑色路径表示),而不是评估所有七个可能的单词。

10-00unnumb

最终层的分层 softmax 表示。黑色路径表示模型必须遵循的路径,以计算 P(home| I went)的概率。

接下来,我们将看到如何在词汇量很大的情况下处理语言。

10.1.3 词汇量太大?N-gram 拯救

在这里,我们开始定义各种文本预处理器和数据流水线的第一步。我们怀疑在大词汇量的情况下继续前进会对我们的建模之旅产生不利影响。让我们找到一些减少词汇量的方法。鉴于儿童故事使用相对简单的语言风格,我们可以将文本表示为 n-gram(以牺牲我们模型的表达能力为代价)。 N-grams 是一种方法,其中单词被分解为固定长度的更细的子单词。例如,句子的二元组(或二元组)

I went to the bookshop

"I ", " w", "we", "en", "nt", "t ", " t", "to", "o ", "th", "he", "e ", " b", "bo", "oo", "ok", "ks", "sh", "ho", "op"

三个元组将是

"I w", " we", "wen", "ent", "nt ", "t t", " to", "to ", "o t", " th", "the", "he ", "e b", " bo", "boo", "ook", "oks", "ksh", "sho", "hop"

单元组(或一元组)将简单地是各个字符。换句话说,我们正在移动一个固定长度的窗口(步长为 1),同时每次读取该窗口内的字符。您也可以通过将窗口移动到长度相等的步长来生成没有重叠的 n-gram。例如,没有重叠的二元组将是

"I ", "we", "nt", " t", "o ", "th", "e ", "bo", "ok", "sh", "op"

使用哪种方法取决于您的用例。对于语言建模任务,使用非重叠方法是有意义的。这是因为通过连接我们生成的 n-gram,我们可以轻松生成可读的文本。对于某些用例,非重叠方法可能不利,因为它导致文本的表示更粗糙,因为它没有捕获文本中出现的所有不同的 n-gram。

通过使用二元组而不是单词来开发您的词汇表,您可以将词汇表的大小减少到一个显著的因子。随着我们很快会看到的 n-gram 方法的许多其他优势。我们将编写一个函数来生成给定文本字符串的 n-gram:

def get_ngrams(text, n):
    return [text[i:i+n] for i in range(0,len(text),n)]

我们在这里所做的一切就是从文本的开头到结尾以步长 n 进行移动,并从位置 i 到 i+n 读取字符序列。我们可以测试这在样本文本上的表现如何:

test_string = "I like chocolates"
print("Original: {}".format(test_string))
for i in list(range(3)):
    print("\t{}-grams: {}".format(i+1, get_ngrams(test_string, i+1)))

这将打印以下输出:

Original: I like chocolates
    1-grams: ['I', ' ', 'l', 'i', 'k', 'e', ' ', 'c', 'h', 'o', 'c', 
➥ 'o', 'l', 'a', 't', 'e', 's']
    2-grams: ['I ', 'li', 'ke', ' c', 'ho', 'co', 'la', 'te', 's']
    3-grams: ['I l', 'ike', ' ch', 'oco', 'lat', 'es']

现在让我们重复使用 n-gram 而不是单词来分析词汇量的过程:

from itertools import chain
from collections import Counter

# Create a counter with the bi-grams
ngrams = 2

text = chain(*[get_ngrams(s, ngrams) for s in stories])
cnt = Counter(text)

# Create a pandas series with the counter results
freq_df = pd.Series(list(cnt.values()), index=list(cnt.keys())).sort_values(ascending=False)

现在,如果我们检查文本中至少出现 10 次的单词数

n_vocab = (freq_df>=10).sum()
print("Size of vocabulary: {}".format(n_vocab))

我们将会看到

Size of vocabulary: 735

哇!与我们有的 15,000 个单词相比,735 个要小得多,更易于管理。

n-gram 的优势

这是使用 n-gram 而不是单词的主要优势之一:

  • 对于小 n 的有限数量的 n-gram 限制了词汇表的大小,从而导致了记忆和计算优势。

  • N-grams 导致词汇表外单词的机会减少,因为通常可以使用过去看到的 n-grams 构造看不见的单词。

10.1.4 文本分词

我们现在将对文本进行分词(即,将字符串拆分为一组较小的标记——单词)。在本节结束时,您将已经为文本生成的二元组定义并拟合了一个标记生成器。首先,让我们从 TensorFlow 中导入 Tokenizer:

from tensorflow.keras.preprocessing.text import Tokenizer

我们不需要进行任何预处理,希望将文本按原样转换为单词 ID。我们将定义 num_words 参数来限制词汇表的大小,以及一个 oov_token,该 token 将用于替换训练语料库中出现次数少于 10 次的所有 n-gram:

tokenizer = Tokenizer(num_words=n_vocab, oov_token='unk', lower=False)

让我们从训练数据的故事中生成 n-gram。train_ngram_stories 将是一个字符串列表的列表,其中内部列表表示单个故事的二元组列表,外部列表表示训练数据集中的所有故事:

train_ngram_stories = [get_ngrams(s,ngrams) for s in stories]

我们将在训练故事的二元组上拟合 Tokenizer:

tokenizer.fit_on_texts(train_ngram_stories)

现在使用已经拟合 Tokenizer,该 Tokenizer 使用训练数据的二元组将所有训练、验证和测试故事转换为 ID 序列:

train_data_seq = tokenizer.texts_to_sequences(train_ngram_stories)

val_ngram_stories = [get_ngrams(s,ngrams) for s in val_stories]
val_data_seq = tokenizer.texts_to_sequences(val_ngram_stories)

test_ngram_stories = [get_ngrams(s,ngrams) for s in test_stories]
test_data_seq = tokenizer.texts_to_sequences(test_ngram_stories)

通过打印一些测试数据来分析转换为单词 ID 后数据的样子。具体来说,我们将打印前三个故事字符串(test_stories)、n-gram 字符串(test_ngram_stories)和单词 ID 序列(test_data_seq):

Original: the yellow fairy book the cat and the mouse in par
n-grams: ['th', 'e ', 'ye', 'll', 'ow', ' f', 'ai', 'ry', ' b', 'oo', 'k ', 
➥ 'th', 'e ', 'ca', 't ', 'an', 'd ', 'th', 'e ', 'mo', 'us', 'e ', 'in', 
➥ ' p', 'ar']
Word ID sequence: [6, 2, 215, 54, 84, 35, 95, 146, 26, 97, 123, 6, 2, 128, 
➥ 8, 15, 5, 6, 2, 147, 114, 2, 17, 65, 52]

Original: chapter i. down the rabbit-hole alice was beginnin
n-grams: ['ch', 'ap', 'te', 'r ', 'i.', ' d', 'ow', 'n ', 'th', 'e ', 'ra', 
➥ 'bb', 'it', '-h', 'ol', 'e ', 'al', 'ic', 'e ', 'wa', 's ', 'be', 'gi', 
➥ 'nn', 'in']
Word ID sequence: [93, 207, 57, 19, 545, 47, 84, 18, 6, 2, 126, 344, 
➥ 38, 400, 136, 2, 70, 142, 2, 66, 9, 71, 218, 251, 17]

Original: a patent medicine testimonial `` you might as well
n-grams: ['a ', 'pa', 'te', 'nt', ' m', 'ed', 'ic', 'in', 'e ', 'te', 'st', 
➥ 'im', 'on', 'ia', 'l ', '``', ' y', 'ou', ' m', 'ig', 'ht', ' a', 's ', 
➥ 'we', 'll']
Word ID sequence: [60, 179, 57, 78, 33, 31, 142, 17, 2, 57, 50, 125, 43, 
➥ 266, 56, 122, 92, 29, 33, 152, 149, 7, 9, 103, 54]

10.1.5 定义一个 tf.data pipeline

现在预处理已经完成,我们将文本转换为单词 ID 序列。我们可以定义 tf.data pipeline,该 pipeline 将提供最终处理好的数据,准备好被模型使用。流程中涉及的主要步骤如图 10.1 所示。

10-01

图 10.1 数据 pipeline 的高级步骤。首先将单个故事分解为固定长度的序列(或窗口)。然后,从窗口化的序列中生成输入和目标。

与之前一样,让我们将单词 ID 语料库定义为 tf.RaggedTensor 对象,因为语料库中的句子具有可变的序列长度:

text_ds = tf.data.Dataset.from_tensor_slices(tf.ragged.constant(data_seq))     

请记住,不规则张量是具有可变大小维度的张量。然后,如果 shuffle 设置为 True(例如,训练时间),我们将对数据进行洗牌,以便故事以随机顺序出现:

if shuffle:
    text_ds = text_ds.shuffle(buffer_size=len(data_seq)//2)    

现在来看棘手的部分。在本节中,我们将看到如何从任意长的文本中生成固定大小的窗口化序列。我们将通过一系列步骤来实现这一点。与 pipeline 的其余部分相比,本节可能略微复杂。这是因为将会有中间步骤导致三级嵌套的数据集。让我们尽可能详细地了解一下这一点。

首先,让我们清楚我们需要实现什么。我们需要为每个单独的故事 S 执行以下步骤:

  • 创建一个包含故事 S 的单词 ID 的 tf.data.Dataset() 对象作为其项。

  • 使用 n_seq+1 大小的窗口和预定义的移位调用 tf.data.Dataset.window() 函数来窗口化单词 ID,窗口() 函数为每个故事 S 返回一个 WindowDataset 对象。

之后,您将获得一个三级嵌套的数据结构,其规范为

tf.data.Dataset(              # <- From the original dataset
  tf.data.Dataset(    # <- From inner dataset containing word IDs of story S only
    tf.data.WindowDataset(...)  # <- Dataset returned by the window() function
  )
)

我们需要展开这个数据集并解开数据集中的嵌套,最终得到一个平坦的 tf.data.Dataset。您可以使用 tf.data.Dataset.flat_map()函数来去除这些内部嵌套。我们将很快看到如何使用 flat_map()函数。具体来说,我们需要使用两个 flat_map 调用来去除两层嵌套,以便最终得到只包含简单张量作为元素的平坦原始数据集。在 TensorFlow 中,可以使用以下代码行来实现此过程:

    text_ds = text_ds.flat_map(
        lambda x: tf.data.Dataset.from_tensor_slices(
            x
        ).window(
            n_seq+1,shift=shift
        ).flat_map(
            lambda window: window.batch(n_seq+1, drop_remainder=True)
        )
    )
    )

这里我们所做的是:首先,我们从一个单一的故事(x)创建一个 tf.data.Dataset 对象,然后在此上调用 tf.data.Dataset.window()函数以创建窗口序列。此窗口序列包含窗口,其中每个窗口都是故事 x 中 n_seq+1 个连续元素的序列。

然后我们调用 tf.data.Dataset.flat_map()函数,在每个窗口元素上,我们将所有单独的 ID 作为一个批次。换句话说,单个窗口元素会产生一个包含该窗口中所有元素的单个批次。确保使用 drop_remainder=True;否则,数据集将返回该窗口中包含较少元素的较小子窗口。使用 tf.data.Dataset.flat_map()而不是 map,确保去除最内层的嵌套。这整个过程称为 tf.data.Dataset.flat_map()调用,该调用立即消除了我们所删除的最内层嵌套后面的下一层嵌套。对于一行代码来说,这是一个相当复杂的过程。如果您还没有完全理解这个过程,我建议您再次仔细阅读一下。

您可能会注意到,我们把窗口大小定义为 n_seq+1 而不是 n_seq。稍后会看到这样做的原因,但是当我们需要从窗口序列生成输入和目标时,使用 n_seq+1 会让我们的生活变得更加容易。

tf.data.Dataset 中 map 和 flat_map 的区别

tf.data.Dataset.map()和 tf.data.Dataset.flat_map()这两个函数可以实现同样的结果,但具有不同的数据集规范。例如,假设数据集

ds = tf.data.Dataset.from_tensor_slices([[1,2,3], [5,6,7]])

使用 tf.data.Dataset.map()函数对元素进行平方

ds = ds.map(lambda x: x**2) 

将导致一个具有元素的数据集

[[1, 4, 9], [25, 36, 49]]

如您所见,结果与原始张量具有相同的结构。使用 tf.data.Dataset.flat_map()函数对元素进行平方

ds = ds.flat_map(lambda x: x**2)

将导致一个具有的数据集

[1,4,9,25,36,49]

如您所见,该最内层嵌套已被展平,产生了一个平坦的元素序列。

我们数据管道中最困难的部分已经完成。现在,您已经有了一个平坦的数据集,每个项目都是属于单个故事的 n_seq+1 个连续单词 ID。接下来,我们将在数据上执行窗口级别的洗牌。这与我们进行的第一个洗牌不同,因为那是在故事级别上进行的(即不是窗口级别):

    # Shuffle the data (shuffle the order of n_seq+1 long sequences)
    if shuffle:
        text_ds = text_ds.shuffle(buffer_size=10*batch_size)    

然后,我们将批处理数据,以便每次迭代数据集时都会获得一批窗口:

    # Batch the data
    text_ds = text_ds.batch(batch_size)    

最后,我们选择序列长度为 n_seq+1 的原因将变得更清晰。现在我们将序列分为两个版本,其中一个序列将是另一个向右移动 1 位的序列。换句话说,该模型的目标将是向右移动 1 位的输入。例如,如果序列是[0,1,2,3,4],那么得到的两个序列将是[0,1,2,3]和[1,2,3,4]。此外,我们将使用预取来加速数据摄取:

    # Split each sequence to an input and a target
    text_ds = tf.data.Dataset.zip(
        text_ds.map(lambda x: (x[:,:-1], x[:, 1:]))
    ).prefetch(buffer_size=tf.data.experimental.AUTOTUNE)    

最后,完整的代码可以封装在下一个清单中的函数中。

图 10.3 从自由文本序列创建的 tf.data 管道

def get_tf_pipeline(data_seq, n_seq, batch_size=64, shift=1, shuffle=True):
    """ Define a tf.data pipeline that takes a set of sequences of text and 
    convert them to fixed length sequences for the model """

    text_ds = tf.data.Dataset.from_tensor_slices(tf.ragged.constant(data_seq))❶

    if shuffle:
        text_ds = text_ds.shuffle(buffer_size=len(data_seq)//2)               ❷

    text_ds = text_ds.flat_map(                                               ❸
        lambda x: tf.data.Dataset.from_tensor_slices(
            x
        ).window(
            n_seq+1, shift=shift
        ).flat_map(
            lambda window: window.batch(n_seq+1, drop_remainder=True)
        )
    ) 

    if shuffle:
        text_ds = text_ds.shuffle(buffer_size=10*batch_size)                  ❹

    text_ds = text_ds.batch(batch_size)                                       ❺

    text_ds = tf.data.Dataset.zip(
        text_ds.map(lambda x: (x[:,:-1], x[:, 1:]))
    ).prefetch(buffer_size=tf.data.experimental.AUTOTUNE)                     ❻

    return text_ds   

❶ 从数据 _seq 创建的不规则张量定义一个 tf.dataset。

❷ 如果设置了 shuffle,对数据进行洗牌(洗牌故事顺序)。

❸ 在这里,我们从更长的序列中创建窗口,给定窗口大小和偏移量,然后使用一系列 flat_map 操作来删除在此过程中创建的嵌套。

❹ 对数据进行洗牌(洗牌窗口生成的顺序)。

❺ 批量处理数据。

❻ 将每个序列拆分为输入和目标,并启用预取。

所有这些辛勤工作,如果不看生成的数据,就不会有太多意义。

ds = get_tf_pipeline(train_data_seq, 5, batch_size=6)

for a in ds.take(1):
    print(a)

这将向您展示

(
<tf.Tensor: shape=(6, 5), dtype=int32, numpy=
array([[161,  12,  69, 396,  17],
       [  2,  72,  77,  84,  24],
       [ 87,   6,   2,  72,  77],
       [276, 484,  57,   5,  15],
       [ 75, 150,   3,   4,  11],
       [ 11,  73, 211,  35, 141]])>, 
<tf.Tensor: shape=(6, 5), dtype=int32, numpy=
array([[ 12,  69, 396,  17,  44],
       [ 72,  77,  84,  24,  51],
       [  6,   2,  72,  77,  84],
       [484,  57,   5,  15,  67],
       [150,   3,   4,  11,  73],
       [ 73, 211,  35, 141,  98]])>
)

很好,你可以看到我们得到了一个张量元组作为单个批处理:输入和目标。此外,您可以验证结果的正确性,因为我们清楚地看到目标是向右移动 1 位的输入。最后一件事:我们将将相同的超参数保存到磁盘上。

  • n-gram 中的 n

  • 词汇量大小

  • 序列长度

print("n_grams uses n={}".format(ngrams))
print("Vocabulary size: {}".format(n_vocab))

n_seq=100
print("Sequence length for model: {}".format(n_seq))

with open(os.path.join('models', 'text_hyperparams.pkl'), 'wb') as f:
    pickle.dump({'n_vocab': n_vocab, 'ngrams':ngrams, 'n_seq': n_seq}, f)

在这里,我们定义序列长度 n_seq=100;这是单个输入/标签序列中我们将拥有的二元组数目。

在本节中,我们了解了用于语言建模的数据,并定义了一个强大的 tf.data 管道,该管道可以将文本序列转换为可直接用于训练模型的输入标签序列。接下来,我们将定义一个用于生成文本的机器学习模型。

练习 1

你有一个序列 x,其值为[1,2,3,4,5,6,7,8,9,0]。你被要求编写一个 tf.data 管道,该管道生成一个输入和目标元组,其中目标是将输入向右移动两个元素(即,输入 1 的目标是 3)。你必须这样做,以便一个单独的输入/目标具有三个元素,并且连续输入序列之间没有重叠。对于前面的序列,它应该生成[([1,2,3], [3,4,5]), ([6,7,8], [8,9,0])]。

10.2 仙境中的 GRU:使用深度学习生成文本

现在我们来到了有奖励的部分:实现一个酷炫的机器学习模型。在上一章中,我们讨论了深度时序模型。鉴于数据的时序性,你可能已经猜到我们将使用其中之一深度时序模型,比如 LSTMs。在本章中,我们将使用一个略有不同的模型,称为门控循环单元(GRUs)。驱动该模型计算的原理与 LSTMs 相同。为了保持我们讨论的清晰度,值得提醒自己 LSTM 模型是如何工作的。

LSTM 是一类专门设计用于处理时序数据的深度神经网络。它们逐个输入地处理一系列输入。LSTM 单元从一个输入到下一个输入,同时在每个时间步产生输出(或状态)(图 10.2)。此外,为了产生给定时间步的输出,LSTMs 使用了它产生的先前输出(或状态)。这个属性对于 LSTMs 非常重要,使它们能够随时间记忆事物。

10-02

图 10.2 LSTM 模型概述以及它如何处理随时间展开的输入序列

让我们总结一下我们在上一章中学到的关于 LSTMs 的知识,因为这将帮助我们比较 LSTMs 和 GRUs。一个 LSTM 有两个状态,称为单元状态和输出状态。单元状态负责维护长期记忆,而输出状态可以被视为短期记忆。在 LSTM 单元内部,输出和中间结果受三个门的控制:

  • 输入门——控制了在给定时间步中当前输入的多少会对最终输出产生影响

  • 遗忘门——控制了多少先前的单元状态影响当前单元状态的计算

  • 输出门——控制了当前单元状态对 LSTM 模型产生的最终输出的贡献

GRU 模型是在 Cho 等人的论文“Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation”中介绍的(arxiv.org/pdf/1406.1078v3.pdf)。GRU 模型可以被认为是 LSTM 模型的简化,同时保持了相当的性能。GRU 单元有两个门:

  • 更新门z[t])——控制了多少先前的隐藏状态传递到当前的隐藏状态

  • 重置门r[t])——控制了多少隐藏状态与新输入一起被重置

与 LSTM 单元不同,GRU 单元只有一个状态向量。总的来说,与 LSTM 模型相比,GRU 有两个主要变化:

  • 输入门和遗忘门被合并成一个称为更新门的门(z[t])。输入门被计算为(1-z[t]),而遗忘门保持z[t]。

  • 与 LSMT 单元中的两个状态(即单元状态和输出状态)相比,这里只有一个状态(h[t])。

图 10.3 描述了 GRU 单元的各个组件。以下是构成 GRU 单元的方程式的完整列表:

r[t] = σ(W[rh]h[t-1] + W[rx]x[t] + b[r])

z[t] = σ(W[zh]h[t-1] + W[zx]x[t] + b[z])

[t] = tanh(W[h](rh[t-1]) + W[x]x[t] + b)

h[t] = (z[th]h[t-1] + (1 - z[t] )[t]

10-03

图 10.3 GRU 单元中发生的计算概览

这次讨论对于理解 GRU 模型不仅非常有帮助,还有助于了解它与 LSTM 单元的区别。你可以在 TensorFlow 中如下定义一个 GRU 单元:

tf.keras.layers.GRU(units=1024, return_state=False, return_sequences=True)

参数 units、return_state 和 return_sequences 的含义与 LSTM 单元的上下文中相同。然而,注意 GRU 单元只有一个状态;因此,如果 return_state=true,则会将相同的状态复制以模拟 LSTM 层的输出状态和单元状态。图 10.4 显示了这些参数对于 GRU 层的作用。

10-04

图 10.4 根据 GRU 单元的 return_state 和 return_sequences 参数的不同返回结果的变化

我们现在知道了定义最终模型所需的一切(清单 10.4)。我们的最终模型将包括

  • 一个嵌入层

  • 一个 GRU 层(1,024 个单元),将所有最终状态向量作为一个形状为 [批量大小,序列长度,单元数] 的张量返回

  • 一个具有 512 个单元和 ReLU 激活的 Dense 层

  • 一个具有 n_vocab 个单元和 softmax 激活的 Dense 层

清单 10.4 实现语言模型

model = tf.keras.models.Sequential([
    tf.keras.layers.Embedding(
        input_dim=n_vocab+1, output_dim=512,input_shape=(None,)           ❶
    ),

    tf.keras.layers.GRU(1024, return_state=False, return_sequences=True), ❷

    tf.keras.layers.Dense(512, activation='relu'),                        ❸

    tf.keras.layers.Dense(n_vocab, name='final_out'),                     ❹
    tf.keras.layers.Activation(activation='softmax')                      ❹
])

❶ 定义一个嵌入层以学习 bigrams 的单词向量。

❷ 定义一个 LSTM 层。

❸ 定义一个 Dense 层。

❹ 定义一个最终的 Dense 层和 softmax 激活。

你会注意到,在 GRU 后的 Dense 层接收到一个三维张量(而不是传递给 Dense 层的典型二维张量)。Dense 层足够智能,能够处理二维和三维输入。如果输入是三维的(就像在我们的情况下),那么将一个接受 [批量大小,单元数] 张量的 Dense 层通过所有步骤传递序列以生成 Dense 层的输出。还要注意我们是如何将 softmax 激活与 Dense 层分开的。这实际上等价于

.Dense(n_vocab, activation=’softmax’, name=’final_out’)

我们不会通过重复在清单 10.4 中显示的内容来拖延对话,因为这已经是不言而喻的。

练习 2

你已经获得了以下模型,并被要求在现有的 GRU 层之上添加另一个具有 512 个单元并返回所有状态输出的 GRU 层。你将对以下代码做出什么改变?

model = tf.keras.models.Sequential([
    tf.keras.layers.Embedding(
        input_dim=n_vocab+1, output_dim=512,input_shape=(None,)   
    ),
    tf.keras.layers.GRU(1024, return_state=False, return_sequences=True),   
    tf.keras.layers.Dense(n_vocab, activation=’softmax’, name='final_out'), ])

在这一节中,我们学习了门控循环单元(GRUs)以及它们与 LSTMs 的比较。最后,我们定义了一个可以在我们之前下载并处理的数据上进行训练的语言模型。在下一节中,我们将学习用于评估生成文本质量的评估指标。

10.3 测量生成文本的质量

性能监控已经成为我们在每一章节模型之旅中不可或缺的一部分。在这里也不例外。性能监控是我们语言模型的一个重要方面,我们需要找到适合语言模型的度量标准。自然地,考虑到这是一个分类任务,你可能会想,“准确率不是一个很好的度量标准吗?”嗯,在这个任务中不完全是这样。

例如,如果语言模型得到了句子“I like my pet dog”,然后当要求预测给定“我喜欢我的宠物 ____”时缺失的单词,模型可能会预测“猫”,准确率为零。但这是不正确的;在这个例子中,“猫”和“狗”一样有意义。这里有更好的解决方案吗?

这就是困惑度!直观地,困惑度 衡量了模型看到前一个词序列后看到目标词时的“惊讶程度”。在理解困惑度之前,你需要了解“熵”是什么意思。

是由著名的克劳德·香农创造的一个术语,他被认为是信息论之父。熵度量了事件的惊奇程度/不确定性/随机性。事件可以由概率分布生成所有可能的结果之一。例如,如果你考虑抛硬币(以概率 p 出现正面)是一个事件,如果 p = 0.5,那么你将有最大的熵,因为这是抛硬币最不确定的情况。如果 p = 1 或 p = 0,则熵最小,因为在抛硬币之前你知道结果是什么。

熵的原始解释是发送信号或消息通知事件所需的位数的期望值。位是内存的单位,可以是 1 或 0。例如,你是一支与 A 国和 B 国交战的军队的指挥官。现在有四种可能性:A 和 B 都投降,A 赢了 B 输了,A 输了 B 赢了,以及 A 和 B 都赢了。如果所有这些事件发生的可能性都是相等的,你需要两位来发送消息,其中每一位表示那个国家是否获胜。随机变量 X 的熵由方程式量化

10_04a

其中 xX 的一个结果。信不信由你,每次我们使用分类交叉熵损失时,我们都在不知不觉中使用了这个方程式。分类交叉熵的关键就在于这个方程式。回到困惑度度量,困惑度就是简单地

困惑度 = 2^(H(X))

由于困惑度是熵的一个函数,它衡量了模型看到目标词时的惊讶程度/不确定性,考虑了前一个词序列。困惑度也可以被认为是给定信号的所有可能组合的数量。例如,假设你发送一个带有两位的消息,所有事件都是等可能的;那么熵 = 2,这意味着困惑度 = 2² = 4。换句话说,两位可以有四种组合:00、01、10 和 11。

从建模角度来看,你可以将困惑度理解为在给定一系列前序词的情况下,模型认为有多少不同的目标适合作为下一个词的空白。这个数字越小越好,因为这意味着模型试图从更小的子集中找到一个词,表明语言理解的迹象。

要实现困惑度,我们将定义一个自定义指标。计算非常简单。我们计算分类交叉熵,然后对其进行指数化以获得困惑度。分类交叉熵简单地是熵的扩展,用于在具有两个以上类别的分类问题中测量熵。对于输入示例(x[i],y[i]),它通常定义为

10_04b

其中y[i]表示真实类别示例所属的独热编码向量,ŷ[i]是C个元素的预测类别概率向量,其中ŷ[i,c]表示示例属于类别c的概率。请注意,在实践中,计算更快的是使用指数(自然)基数,而不是使用 2 作为基数。以下清单描述了这个过程。

10.5 实现困惑度指标

import tensorflow.keras.backend as K

class PerplexityMetric(tf.keras.metrics.Mean):

    def __init__(self, name='perplexity', **kwargs):
      super().__init__(name=name, **kwargs)
      self.cross_entropy = tf.keras.losses.SparseCategoricalCrossentropy(
         from_logits=False, reduction='none'
       )

    def _calculate_perplexity(self, real, pred):   ❶

      loss_ = self.cross_entropy(real, pred)       ❷

      mean_loss = K.mean(loss_, axis=-1)           ❸
      perplexity = K.exp(mean_loss)                ❹

      return perplexity 

    def update_state(self, y_true, y_pred, sample_weight=None):            
      perplexity = self._calculate_perplexity(y_true, y_pred)
      super().update_state(perplexity)

❶ 定义一个函数来计算给定真实和预测目标的困惑度。

❷ 计算分类交叉熵损失。

❸ 计算损失的均值。

❹ 计算均值损失的指数(困惑度)。

我们正在做的事情非常简单。首先,我们对 tf.keras.metrics.Mean 类进行子类化。tf.keras.metrics.Mean 类将跟踪传递给其 update_state()函数的任何输出指标的均值。换句话说,当我们对 tf.keras.metrics.Mean 类进行子类化时,我们不需要手动计算累积困惑度指标的均值,因为训练继续进行。这将由该父类自动完成。我们将定义我们将在 self.cross_entropy 变量中使用的损失函数。然后,我们编写函数 _calculate_perplexity(),该函数接受模型的真实目标和预测。我们计算逐样本损失,然后计算均值。最后,为了得到困惑度,我们对均值损失进行指数化。有了这个,我们可以编译模型:

model.compile(
    loss='sparse_categorical_crossentropy', 
    optimizer='adam', 
    metrics=['accuracy', PerplexityMetric()]
)

在本节中,我们学习了用于评估语言模型的性能指标,如熵和困惑度。此外,我们实现了一个自定义的困惑度指标,用于编译最终模型。接下来,我们将在准备好的数据上训练我们的模型,并评估生成文本的质量。

练习 3

想象一个有三个输出的分类问题。有两种不同预测的情况:

情景 A:标签 [0, 2, 1]

预测:[[0.6, 0.2, 0.2], [0.1, 0.1, 0.8], [0.3, 0.5, 0.2]]

情景 B:标签 [0, 2, 1]

预测:[[0.3, 0.3, 0.4], [0.4, 0.3, 0.3], [0.3, 0.3, 0.4]]

哪一个会有最低的困惑度?

10.4 训练和评估语言模型

在这一部分中,我们将训练模型。在训练模型之前,让我们使用之前实现的 get_tf_pipeline()函数来实例化训练和验证数据集。我们将只使用前 50 个故事(共 98 个)作为训练集,以节省时间。我们每次取 100 个二元组作为一个序列,通过移动窗口来跳过故事,每次移动 25 个二元组。这意味着单个故事序列的起始索引是 0、25、50 等等。我们将使用批大小为 128:

n_seq = 100
train_ds = get_tf_pipeline(
    train_data_seq[:50], n_seq, stride=25, batch_size=128
)
valid_ds = get_tf_pipeline(
    val_data_seq, n_seq, stride=n_seq, batch_size=128
)

要训练模型,我们将像以前一样定义回调。我们将定义

  • 一个 CSV 记录器,将在训练期间记录性能

  • 一个学习率调度器,当性能达到平台期时会减小学习率

  • 如果性能没有提高,则使用早期停止回调来终止训练。

os.makedirs('eval', exist_ok=True)

csv_logger = 
➥ tf.keras.callbacks.CSVLogger(os.path.join('eval','1_language_modelling.
➥ log'))

monitor_metric = 'val_perplexity'
mode = 'min' 
print("Using metric={} and mode={} for EarlyStopping".format(monitor_metric, mode))

lr_callback = tf.keras.callbacks.ReduceLROnPlateau(
    monitor=monitor_metric, factor=0.1, patience=2, mode=mode, min_lr=1e-8
)

es_callback = tf.keras.callbacks.EarlyStopping(
    monitor=monitor_metric, patience=5, mode=mode, 
➥ restore_best_weights=False
)

最后,是时候训练模型了。我想知道我能从训练好的模型中挤出什么样的酷故事:

model.fit(train_ds, epochs=50,  validation_data = valid_ds, 
➥ callbacks=[es_callback, lr_callback, csv_logger])

注意 在配有 NVIDIA GeForce RTX 2070 8 GB 的 Intel Core i5 机器上,训练大约需要 1 小时 45 分钟运行 25 个 epochs。

训练模型后,您将看到接近 9.5 的验证困惑度。换句话说,这意味着对于给定的单词序列,模型认为可能有 9.5 个不同的下一个单词是正确的单词(不完全准确,但是这是一个足够接近的近似值)。困惑度需要仔细判断,因为其好坏倾向于主观。例如,随着词汇量的增加,这个数字可能会上升。但这并不一定意味着模型不好。数字之所以上升是因为模型看到了更多适合场合的词汇,与词汇量较小时相比。

我们将在测试数据上评估模型,以了解我们的模型可以多大程度地预测一些未见的故事,而不会感到惊讶:

batch_size = 128
test_ds = get_tf_pipeline(
    test_data_seq, n_seq, shift=n_seq, batch_size=batch_size
)
model.evaluate(test_ds)

这将给您约

61/61 [==============================] - 2s 39ms/step - loss: 2.2620 - 
➥ accuracy: 0.4574 - perplexity: 10.5495

与我们看到的验证性能相当。最后,保存模型

os.makedirs('models', exist_ok=True)
tf.keras.models.save_model(model, os.path.join('models', '2_gram_lm.h5'))

在本节中,您学习了如何训练和评估模型。您在训练数据集上训练了模型,并在验证和测试集上评估了模型。在接下来的部分中,您将学习如何使用训练好的模型生成新的儿童故事。然后,在接下来的部分中,您将学习如何使用我们刚刚训练的模型生成文本。

练习 4

假设您想使用验证准确性(val_accuracy)而不是验证困惑度(val_perplexity)来定义早期停止回调。您将如何更改以下回调?

es_callback = tf.keras.callbacks.EarlyStopping(
    monitor=’val_perlexity’, patience=5, mode=’min’, 
➥ restore_best_weights=False
)

10.5 从语言模型生成新文本:贪婪解码

语言模型最酷的一点是其具有的生成特性。这意味着模型可以生成新数据。在我们的情况下,语言模型可以利用从训练阶段获取的知识生成新的儿童故事。

但要这么做,我们必须付出额外的努力。文本生成过程与训练过程不同。训练期间我们拥有完整的序列,可以一次性处理任意长度的序列。但是在生成新的文本时,你没有一个可用的文本序列;事实上,你正在尝试生成一个。你从一个随机的词开始,得到一个输出词,然后递归地将当前输出作为下一个输入来生成新的文本。为了促进这个过程,我们需要定义训练模型的一个新版本。让我们更详细地阐述生成过程。图 10.5 比较了训练过程和生成/推理过程。

  • 定义一个初始单词wt。

  • 定义一个初始状态向量ht。

  • 定义一个列表 words,用于保存预测的单词,并将其初始化为初始单词。

  • 对于 t 从 1 到 n:

    • 从模型中获取下一个单词(w[t+1])和状态向量(h[t+1])并分别赋值给w[t]和h[t],这样就创建了一个递归过程,使我们能够生成尽可能多的单词。

    • 将新单词添加到 words 中。

10-05

图 10.5 训练时间和推断/解码阶段的语言模型比较。在推断阶段,我们逐个时间步预测。在每个时间步中,我们将预测的单词作为输入,将新的隐藏状态作为下一个时间步的先前隐藏状态。

我们将使用 Keras 的函数式 API 构建这个模型,如下一个列表所示。首先,让我们定义两个输入。

列表 10.6 是推断/解码语言模型的实现。

inp = tf.keras.layers.Input(shape=(None,))                                 ❶
inp_state = tf.keras.layers.Input(shape=(1024,))                           ❷

emb_layer = tf.keras.layers.Embedding(
    input_dim=n_vocab+1, output_dim=512, input_shape=(None,)
)                                                                          ❸
emb_out = emb_layer(inp)                                                   ❹

gru_layer = tf.keras.layers.GRU(
    1024, return_state=True, return_sequences=True
)
gru_out, gru_state = gru_layer(emb_out, initial_state=inp_state)         ❺❻

dense_layer = tf.keras.layers.Dense(512, activation='relu')                ❼
dense_out = dense_layer(gru_out)                                           ❼

final_layer = tf.keras.layers.Dense(n_vocab, name='final_out')             ❽
final_out = final_layer(dense_out)                                         ❽
softmax_out = tf.keras.layers.Activation(activation='softmax')(final_out)  ❽

infer_model = tf.keras.models.Model(
    inputs=[inp, inp_state], outputs=[softmax_out, gru_state]
)                                                                          ❾

❶ 定义一个能够接受任意长度的单词 ID 序列的输入。

❷ 定义另一个输入,将上一个状态输入进去。

❸ 定义一个嵌入层。

❹ 从输入的单词 ID 中获取嵌入向量。

❺ 定义一个 GRU 层,返回输出和状态。但要注意,对于 GRU 来说它们是相同的。

❻ 从模型中获取 GRU 输出和状态。

❼ 计算第一个全连接层的输出。

❽ 定义一个与词汇表大小相同的最终层,并获取模型的最终输出。

❾ 定义最终模型,该模型接受一个输入和一个状态向量作为输入,并产生下一个单词预测和新的状态向量作为输出。

在定义模型之后,我们必须执行一个重要的步骤。我们必须将训练模型的权重转移到新定义的推断模型中。为此,我们必须识别具有可训练权重的层,从训练模型中获取这些层的权重,并将它们分配给新模型:

# Copy the weights from the original model
emb_layer.set_weights(model.get_layer('embedding').get_weights())
gru_layer.set_weights(model.get_layer('gru').get_weights())
dense_layer.set_weights(model.get_layer('dense').get_weights())
final_layer.set_weights(model.get_layer('final_out').get_weights())

要获取训练模型中特定层的权重,可以调用

model.get_layer(<layer name>).get_weights()

将返回一个带有权重的 NumPy 数组。接下来,为了将这些权重分配给一个层,调用

layer.set_weights(<weight matrix>)

现在我们可以递归地调用新定义的模型来生成任意数量的 bigram。我们将更详细地讨论如何进行这个过程。我们将不再从一个随机单词开始,而是从一段文本序列开始。我们将使用 Tokenizer 将文本转换为 bigrams,然后再转换为单词 ID:

text = get_ngrams(
    "CHAPTER I. Down the Rabbit-Hole Alice was beginning to get very tired 
➥ of sitting by her sister on the bank ,".lower(), 
    ngrams
)

seq = tokenizer.texts_to_sequences([text])

接下来,让我们重置模型的状态(这在这里不是必需的,因为我们是从头开始,但了解我们可以这样做很好)。我们将定义一个全零的状态向量:

# Reset the state of the model initially
model.reset_states()
# Defining the initial state as all zeros
state = np.zeros(shape=(1,1024))

然后,我们将递归地对 seq 变量中的每个 bigram 进行预测,以更新 GRU 模型的状态。一旦我们遍历整个序列,我们将得到最终预测的 bigram(它将成为我们的第一个预测的 bigram),并将其附加到原始的 bigram 序列中:

# Recursively update the model by assining new state to state
for c in seq[0]:    
    out, state = infer_model.predict([np.array([[c]]), state])

# Get final prediction after feeding the input string
wid = int(np.argmax(out[0],axis=-1).ravel())
word = tokenizer.index_word[wid]
text.append(word)

我们将使用上一个预测的最后一个单词的 ID 来定义一个新的输入 x:

# Define first input to generate text recursively from
x = np.array([[wid]])

现在开始有趣的部分。我们将使用之前讨论的方法来预测 500 个 bigram(即 1,000 个字符)。在每次迭代中,我们使用输入 x 和状态向量 state,通过 infer_model 来预测一个新的 bigram 和一个新的状态。然后,我们将这些新的输出递归地替换 x 和 state 变量(请参见下一个列表)。

列表 10.7 使用先前的单词作为输入递归预测新单词

for _ in range(500):

    out, state = infer_model.predict([x, state])                   ❶

    out_argsort = np.argsort(out[0], axis=-1).ravel()              ❷
    wid = int(out_argsort[-1])                                     ❷
    word = tokenizer.index_word[wid]                               ❷

    if word.endswith(' '):                                         ❸
        if np.random.normal()>0.5:
            width = 3                                              ❹
            i = np.random.choice(                                  ❹
                list(range(-width,0)), 
                p=out_argsort[-width:]/out_argsort[-width:].sum()
            )    
            wid = int(out_argsort[i])                              ❹
            word = tokenizer.index_word[wid]                       ❹
    text.append(word)                                              ❺

    x = np.array([[wid]])                                          ❻

❶ 获取下一个输出和状态。

❷ 从输出中获取单词 ID 和单词。

❸ 如果单词以空格结尾,我们引入了一点随机性来打破重复文本。

❹ 根据它们的可能性,从该时间步的前三个输出中选择一个输出。

❺ 累积地将预测附加到文本中。

❻ 递归地将当前预测作为下一个输入。

请注意,需要一些工作才能得到 x 的最终值,因为模型预测的是一个概率预测(赋值给 out),而不是一个单词 ID。此外,我们将使用一些附加的逻辑来提高生成文本中的随机性(或者可以说是熵),通过从前三个单词中随机选择一个单词。但我们不以相等的概率选择它们。相反,让我们使用它们的预测概率来预测单词。为了确保我们不会获得过多的随机性,并避免在单词中间获得随机调整,只有当最后一个字符是空格字符时,才这样做。最终的单词 ID(可以是具有最高概率的单词或随机选择的单词)被赋值给变量 x。这个过程将重复进行 500 步,到最后,你将拥有一个酷炫的机器生成的故事。你可以打印出最终的文本查看其效果。要做到这一点,只需将 bigrams 按照下面的方式连接在文本序列中:

# Print the final output    
print('\n')
print('='*60)
print("Final text: ")
print(''.join(text))

这将显示

Final text: 
chapter i. down the rabbit-hole alice was beginning to get very tired of 
➥ sitting by her sister on the bank , and then they went to the shore , 
➥ and then the princess was so stilling that he was a little girl , 

...

 it 's all right , and i 'll tell you how young things would n't be able to 
➥ do it .
 i 'm not goin ' to think of itself , and i 'm going to be sure to see you .
 i 'm sure i can notice them .
 i 'm going to see you again , and i 'll tell you what i 've got , '

对于简单的单层 GRU 模型来说,这当然不算差。 大多数情况下,模型会输出实际单词。 但是偶尔会出现拼写错误和更频繁的语法错误困扰文本。 我们能做得更好吗? 在下一节中,我们将学习一种称为光束搜索的新技术,用于生成文本。

练习 5

假设您有以下代码,该代码选择下一个单词时没有随机性。 您运行此代码并意识到结果非常糟糕:

for _ in range(500):

    out, new_s = infer_model.predict([x, s])                                    

    out_argsort = np.argsort(out[0], axis=-1).ravel()                               
    wid = int(out_argsort[-1])                                                      
    word = tokenizer.index_word[wid]

    text.append(word)                                                               

    x = np.array([[wid]])       

你认为性能不佳的原因是什么?

10.6 光束搜索:增强序列模型的预测能力

我们可以比贪婪解码做得更好。 光束搜索是一种流行的解码算法,用于像这样的序列/时间序列任务中生成更准确的预测。 光束搜索背后的思想非常简单。 与贪婪解码不同,贪婪解码预测单个时间步长,而光束搜索预测多个时间步长。 在每个时间步长,您获得前 k 个预测并从中分支出。 光束搜索具有两个重要参数:光束宽度和光束深度。 光束宽度控制每个步骤考虑的候选项数,而光束深度确定要搜索的步骤数。 例如,对于光束宽度为 3 和光束深度为 5,可能的选项数为 3⁵ = 243。 图 10.6 进一步说明了光束搜索的工作原理。

10-06

图 10.6 光束搜索的示例。 光束搜索会预测未来几步以进行预测,从而导致更好的解决方案。 在这里,我们正在执行光束搜索,光束宽度为 3,光束深度为 5。

首先,让我们定义一个函数,该函数将接受模型、输入和状态,并返回输出和新状态:

def beam_one_step(model, input_, state):    
    """ Perform the model update and output for one step"""
    output, new_state = model.predict([input_, state])
    return output, new_state

然后,使用这个函数,我们将定义一个递归函数(recursive_fn),该函数将从预定义深度(由 beam_depth 定义)递归地预测前一次预测的下一个单词。在每个时间步长,我们考虑从中分支出的前 k 个候选项(由 beam_width 定义)。递归函数将填充一个名为 results 的变量。results 将包含一个元组列表,其中每个元组表示搜索中的单个路径。具体来说,每个元组包含

  • 路径中的元素

  • 该序列的联合对数概率

  • 传递给 GRU 的最终状态向量

这个函数在下面的列表中概述。

将束搜索实现为递归函数的列表 10.8

def beam_search(
    model, input_, state, beam_depth=5, beam_width=3, ignore_blank=True
):                                                                           ❶
    """ Defines an outer wrapper for the computational function of beam 
➥ search """

    def recursive_fn(input_, state, sequence, log_prob, i):                  ❷
        """ This function performs actual recursive computation of the long 
➥ string"""

        if i == beam_depth:                                                  ❸
            """ Base case: Terminate the beam search """
            results.append((list(sequence), state, np.exp(log_prob)))        ❹
            return sequence, log_prob, state                                 ❹
        else:
            """ Recursive case: Keep computing the output using the 
➥ previous outputs"""
            output, new_state = beam_one_step(model, input_, state)          ❺

            # Get the top beam_widht candidates for the given depth
            top_probs, top_ids = tf.nn.top_k(output, k=beam_width)           ❻
            top_probs, top_ids = top_probs.numpy().ravel(), 
➥ top_ids.numpy().ravel()                                                   ❻

            # For each candidate compute the next prediction
            for p, wid in zip(top_probs, top_ids):                           ❼
                new_log_prob = log_prob + np.log(p)                          ❼
                if len(sequence)>0 and wid == sequence[-1]:                  ❽
                    new_log_prob = new_log_prob + np.log(1e-1)               ❽

                sequence.append(wid)                                         ❾
                _ = recursive_fn(
                    np.array([[wid]]), new_state, sequence, new_log_prob, i+1❿
                )                                         
                sequence.pop()

    results = []
    sequence = []
    log_prob = 0.0
    recursive_fn(input_, state, sequence, log_prob, 0)                       ⓫

    results = sorted(results, key=lambda x: x[2], reverse=True)              ⓬

    return results

❶ 为光束搜索的计算函数定义一个外部包装器。

❷ 定义一个内部函数,该函数递归调用以找到光束路径。

❸ 定义递归终止的基本情况。

❹ 将终止时得到的结果追加到结果中,以便稍后使用。

❺ 在递归过程中,通过调用模型获取输出单词和状态。

❻ 获取该步骤的前 k 个候选项。

❼ 对于每个候选项,计算联合概率。 为了具有数值稳定性,我们将在对数空间中执行此操作。

❽ 每当相同的符号重复时,惩罚联合概率。

❾ 将当前候选项追加到维护当前搜索路径的序列中。

❿ 递归调用函数以找到下一个候选项。

⓫ 调用递归函数以触发递归。

⓬ 根据对数概率对结果进行排序。

最后,我们可以使用这个 beam_search 函数如下:我们将使用 7 的束深度和 2 的束宽。直到 for 循环之前,事情都与我们使用贪婪解码时完全相同。在 for 循环中,我们得到结果列表(按联合概率从高到低排序)。然后,类似于我们以前做的,我们将基于它们的可能性作为下一个预测从前 10 个预测中随机获取下一个预测。以下清单详细说明了这样做的代码。

清单 10.9 实现束搜索解码以生成新故事

text = get_ngrams(
    "CHAPTER I. Down the Rabbit-Hole Alice was beginning to get very tired 
➥ of sitting by her sister on the bank ,".lower(),     
    ngrams
)                                                                ❶

seq = tokenizer.texts_to_sequences([text])                       ❷

state = np.zeros(shape=(1,1024))
for c in seq[0]:    
    out, state = infer_model.predict([np.array([[c]]), state     ❸

wid = int(np.argmax(out[0],axis=-1).ravel())                     ❹
word = tokenizer.index_word[wid]                                 ❹
text.append(word)                                                ❹

x = np.array([[wid]])

for i in range(100):                                             ❺

    result = beam_search(infer_model, x, state, 7, 2)            ❻

    n_probs = np.array([p for _,_,p in result[:10                ❼
    p_j = np.random.choice(list(range(
       n_probs.size)), p=n_probs/n_probs.sum())                  ❼

    best_beam_ids, state, _ = result[p_j]                        ❽
    x = np.array([[best_beam_ids[-1]]])                          ❽

    text.extend([tokenizer.index_word[w] for w in best_beam_ids])

print('\n')
print('='*60)
print("Final text: ")
print(''.join(text))

❶ 从初始文本序列中定义一系列 n 元组。

❷ 将二元组转换为单词 ID。

❸ 使用给定字符串建立模型状态。

❹ 处理序列后获取预测的单词。

❺ 预测 100 个时间步长。

❻ 从束搜索中获取结果。

❼ 基于它们的可能性获取前 10 个结果中的一个。

❽ 用计算出的新值替换 x 和状态。

运行代码清单 10.9,你应该会得到类似以下的文本:

Final text: 

chapter i. down the rabbit-hole alice was beginning to get very tired of 
➥ sitting by her sister on the bank , and there was no reason that her 
➥ father had brought him the story girl 's face .
 `` i 'm going to bed , '' said the prince , `` and you can not be able 
➥ to do it . ''
 `` i 'm sure i shall have to go to bed , '' he answered , with a smile 
➥ .
 `` i 'm so happy , '' she said .
 `` i do n't know how to bring you into the world , and i 'll be sure 
➥ that you would have thought that it would have been a long time .
 there was no time to be able to do it , and it would have been a 
➥ little thing . ''
 `` i do n't know , '' she said .

...

 `` what is the matter ? ''
 `` no , '' said anne , with a smile .
 `` i do n't know what to do , '' said mary .
 `` i 'm so glad you come back , '' said mrs. march , with

用束搜索生成的文本读起来比我们用贪婪解码看到的文本要好得多。当文本是用束搜索生成时,语法更好,拼写错误更少。

多样化的束搜索

随着时间的推移,出现了各种不同的束搜索替代方案。其中一种流行的替代方案称为多样化束搜索,介绍在 Vijayakumar 等人的论文“Diverse Beam Search: Decoding Diverse Solutions from Neural Sequence Models”中(arxiv.org/pdf/1610.02424.pdf)。多样化束搜索克服了普通束搜索的一个关键局限性。也就是说,如果你分析束搜索提出的最优候选序列,你会发现它们之间仅有少数元素的差异。这也可能导致缺乏变化的重复文本。多样化束搜索提出了一个优化问题,在搜索过程中激励所提出的候选者的多样性。你可以在论文中进一步了解这一点。

这就结束了我们对语言建模的讨论。在下一章中,我们将学习一种称为序列到序列问题的新型 NLP 问题。让我们总结一下本章的重点。

练习 6

你使用了行 result = beam_search(infer_model, x, state, 7, 2) 来执行束搜索。你希望每次考虑五个候选项,并且只在搜索空间中搜索三层深度。你会如何更改这行?

总结

  • 语言建模是在给定一系列单词的情况下预测下一个单词的任务。

  • 语言建模是一些领域中表现最佳的模型的核心工作,例如 BERT(一种基于 Transformer 的模型)。

  • 为了限制词汇表的大小并避免计算问题,可以使用 n-gram 表示。

  • 在 n-gram 表示中,文本被分割为固定长度的标记,而不是进行字符级或词级的标记化。然后,一个固定大小的窗口在文本序列上移动,以生成模型的输入和目标。在 TensorFlow 中,您可以使用 tf.data.Dataset.window() 函数来实现这种功能。

  • 门控循环单元(GRU)是一种顺序模型,其操作类似于 LSTM,它在生成每个时间步的状态的同时跳转到序列中的下一个输入。

  • GRU 是 LSTM 模型的紧凑版本,它维护单个状态和两个门,但提供了与之相当的性能。

  • 困惑度量衡量模型看到目标词时对输入序列的惊讶程度。

  • 困惑度量的计算受信息论的启发,其中熵度量用于量化代表事件的随机变量的不确定性,其中结果是根据某些潜在的概率分布生成的。

  • 训练后的语言模型可以用于生成新文本。有两种流行的技术——贪婪解码和束搜索解码:

    • 贪婪解码一次预测一个词,其中预测的词被用作下一个时间步的输入。

    • 束搜索解码预测未来的几个步骤,并选择给出最高联合概率的序列。

练习答案

练习 1

ds = tf.data.Dataset.from_tensor_slices(x)
ds = ds.window(5,shift=5).flat_map(
    lambda window: window.batch(5, drop_remainder=True)
)
ds = ds.map(lambda xx: (xx[:-2], xx[2:]))

练习 2

model = tf.keras.models.Sequential([
    tf.keras.layers.Embedding(
        input_dim=n_vocab+1, output_dim=512,input_shape=(None,)   
    ),

    tf.keras.layers.GRU(1024, return_state=False, return_sequences=True), 
    tf.keras.layers.GRU(512, return_state=False, return_sequences=True), 
    tf.keras.layers.Dense(n_vocab, activation=’softmax’, name='final_out'),
])

练习 3

场景 A 将具有最低的困惑度。

练习 4

es_callback = tf.keras.callbacks.EarlyStopping(
    monitor=’val_accuracy’, patience=5, mode=’max’, restore_best_weights=False
)

练习 5

该行 out, new_s = infer_model.predict([x, s]) 是错误的。在推断模型中,状态没有递归更新。这将导致一个工作模型,但性能较差。应该更正为 out, s = infer_model.predict([x, s])。

练习 6

result = beam_search(infer_model, x, state, 3, 5)

第三部分:针对复杂问题的高级深度网络

自从卷积神经网络、LSTM 等模型问世以来,深度学习已经取得了长足的进展。亿级参数的 Transformer 模型在各方面表现都优于前面提到的模型。由于对更好的模型和快速开发机器学习模型的需求,跟踪和生产化机器学习模型是另一个备受关注的话题。

在第三部分中,我们首先讨论基于 RNN 的模型的一种更复杂的变体——序列到序列模型。然后我们将更详细地讨论基于 Transformer 的模型,并亲身感受它们在垃圾邮件分类和问答等任务中的运用。您还将学习如何利用像 Hugging Face 的 Transformers 这样的高级库快速实现解决方案。

接下来,您将学习如何使用 TensorBoard 跟踪模型的性能。您将学习如何轻松地在时间轴上可视化模型性能,以及性能分析等高级功能。最后,我们将介绍 TFX,这是一个标准化机器学习模型生产化的库。您将开发一个端到端管道,从数据到部署全面管理机器学习工作流程。

第十一章:序列到序列学习:第一部分

本章内容包括:

  • 理解序列到序列数据

  • 构建序列到序列机器翻译模型

  • 训练和评估序列到序列模型

  • 将训练的模型用于生成未见过的文本的翻译

在上一章中,我们探讨了使用深度递归神经网络解决自然语言处理任务的语言建模。在本章中,我们将进一步探讨如何使用递归神经网络解决更复杂的任务。我们将学习各种任务,其中任意长度的输入序列映射到另一个任意长度的序列。机器翻译是这种情况的一个非常适当的例子,它涉及将一种语言中的单词序列转换为另一种语言的单词序列。

此章节的主要目的是构建一个英德机器翻译器。我们首先需要下载一个机器翻译数据集,了解该数据集的结构并进行一些处理以准备好模型。接下来我们将定义一个可以将任意长的序列映射到另一个任意长的序列的机器翻译模型,这是一个基于编码器-解码器的模型,意味着有一个编码器将一个序列(例如一个英语短语)输出为一个潜在表示,并且有一个解码器来解码这个信息以生成目标序列(例如一个德语短语)。此模型的一个特殊特点是其能够内部将原始字符串转换为数值表示。因此,与我们在先前章节创建的其他自然语言处理模型相比,此模型更为全面。定义好模型后,我们将使用处理过的数据集进行训练,并评估其生成序列的每个单词的准确性以及 BLEU(双语评估研究)。BLEU 是一种比准确性更高级的度量,可以模拟人类评估翻译质量的方式。最后,我们将定义一个略微修改过的解码器,该解码器可以递归地生成单词(从一个初始种子开始),同时将前一个预测作为当前时间步的输入。在第一部分中,我们将讨论机器翻译数据,然后深入探讨建模。

11.1 理解机器翻译数据

您正在为前往德国的游客开发一项机器翻译服务。您找到了一份包含英语和德语文本的双语平行语料库(可在www.manythings.org/anki/deu-eng.zip找到)。它在文本文件中并排包含英语文本和相应的德语翻译。这个想法是使用它来训练一个序列到序列模型,在这之前,您必须了解数据的组织方式,将其加载到内存中,并分析词汇量和序列长度。此外,您将处理文本,使其在德语翻译的开头具有特殊标记“sos”(表示“句子开始”)并在翻译的结尾具有“eos”(表示“句子结束”)。这些是重要的标记,在生成模型的翻译时将对我们有所帮助。

让我们首先下载数据集并对其进行浏览。您需要手动下载此数据集(可在www.manythings.org/anki/deu-eng.zip找到),因为此网页不支持通过脚本进行自动检索。下载后,我们将提取包含数据的文本文件:

import os
import requests
import zipfile

# Make sure the zip file has been downloaded
if not os.path.exists(os.path.join('data','deu-eng.zip')):
    raise FileNotFoundError(
        "Uh oh! Did you download the deu-eng.zip from 
➥ http:/ /www.manythings.org/anki/deu-eng.zip manually and place it in the 
➥ Ch11/data folder?"
    )

else:
    if not os.path.exists(os.path.join('data', 'deu.txt')):
        with zipfile.ZipFile(os.path.join('data','deu-eng.zip'), 'r') as zip_ref:
            zip_ref.extractall('data')
    else:
        print("The extracted data already exists")

如果你打开文本文件,它将有以下条目:

Go.    Geh.    CC-BY 2.0 (France) Attribution: tatoeba.org 
➥ #2877272 (CM) & #8597805 (Roujin)
Hi.    Hallo!    CC-BY 2.0 (France) Attribution: tatoeba.org 
➥ #538123 (CM) & #380701 (cburgmer)
Hi.    Grüß Gott!    CC-BY 2.0 (France) Attribution: 
➥ tatoeba.org #538123 (CM) & #659813 (Esperantostern)
...
If someone who doesn't know your background says that you sound like 
➥ a native speaker, ... . In other words, you don't really sound like 
➥ a native speaker.    Wenn jemand, der nicht weiß, woher man 
➥ kommt, sagt, man erwecke doch den Eindruck, Muttersprachler zu sein, 
➥ ... - dass man diesen Eindruck mit anderen Worten eigentlich nicht 
➥ erweckt.    CC-BY 2.0 (France) Attribution: tatoeba.org #953936 
➥  (CK) & #8836704 (Pfirsichbaeumchen)
Doubtless there exists in this world precisely the right woman for 
➥ any given man to marry and vice versa; ..., that probably, since 
➥ the earth was created, the right man has never yet met the right 
➥ woman.    Ohne Zweifel findet sich auf dieser Welt zu jedem Mann 
➥ genau die richtige Ehefrau und umgekehrt; ..., dass seit Erschaffung 
➥ ebenderselben wohl noch nie der richtige Mann der richtigen Frau 
➥ begegnet ist.    CC-BY 2.0 (France) Attribution: tatoeba.org 
➥ #7697649 (RM) & #7729416 (Pfirsichbaeumchen)

数据以制表符分隔的格式呈现,并具有<德语短语><制表符><英语短语><制表符><归属>格式。我们真正关心记录中的前两个以制表符分隔的值。一旦数据下载完成,我们就可以轻松地将数据加载到 pandas DataFrame 中。在这里,我们将加载数据,设置列名,并提取我们感兴趣的列:

import pandas as pd

# Read the csv file
df = pd.read_csv(
    os.path.join('data', 'deu.txt'), delimiter='\t', header=None
)
# Set column names
df.columns = ["EN", "DE", "Attribution"]
df = df[["EN", "DE"]]

我们还可以通过以下方式计算 DataFrame 的大小

print('df.shape = {}'.format(df.shape))

这将返回

df.shape = (227080, 2)

注意:这里的数据会随着时间而更新。因此,您可能会得到与此处显示的略有不同的结果(例如,数据集大小,词汇量,词汇分布等)。

我们的数据集中有约 227,000 个示例。每个示例都包含一个英语短语/句子/段落和相应的德语翻译。我们将再进行一次清理步骤。看起来文本文件中的一些条目存在一些 Unicode 问题。这些问题对于 pandas 来说处理得很好,但对于一些下游 TensorFlow 组件来说会有问题。因此,让我们运行以下清理步骤来忽略数据中的这些问题行:

clean_inds = [i for i in range(len(df)) if b"\xc2" not in df.iloc[i]["DE"].encode("utf-8")]

df = df.iloc[clean_inds]

让我们通过调用 df.head()(表 11.1)和 df.tail()(表 11.2)来分析一些示例。df.head()返回表 11.1 的内容,而 df.tail()生成表 11.2 的内容。

表 11.1 数据开头的一些示例

EN DE
0 Go. Geh.
1 Hi. Hallo!
2 Hi. Grüß Gott!
3 Run! Lauf!
4 Run. Lauf!

表 11.2 数据结尾的一些示例

EN DE
227075 Even if some by non-native speakers... Auch wenn Sätze von Nichtmuttersprachlern mitu...
227076 如果一个不了解你的背景的人... 如果一个不了解你的背景的人...
227077 如果一个不了解你的背景的人... 如果一个陌生人告诉你要按照他们...
227078 如果一个不了解你的背景的人... 如果一个不知道你来自哪里的人...
227079 这个世界上肯定存在... 毫无疑问,这个世界上肯定存在...

示例按长度排序,你可以看到它们从一个单词的示例开始,然后以大约 50 个单词的示例结束。我们将只使用来自该数据集的 50,000 个短语的样本来加快工作流程:

n_samples = 50000
df = df.sample(n=n_samples, random_state=random_seed)

我们设置随机种子为 random_seed=4321。

最后,我们将在德语翻译中引入两个特殊标记:sos 和 eos。sos 标记翻译的开始,eos 标记翻译的结束。正如您将看到的,这些标记在训练后生成翻译时起着重要作用。但为了在训练和推断(或生成)期间保持一致,我们将这些标记引入到所有示例中。可以使用以下方式轻松完成此操作:

start_token = 'sos'
end_token = 'eos'
df["DE"] = start_token + ' ' + df["DE"] + ' ' + end_token

SOS 和 EOS 标记

SOS 和 EOS 的选择只是一种便利,从技术上讲,它们可以由任何两个唯一的标记表示,只要它们不是语料库本身的词汇。使这些标记唯一是重要的,因为当从以前未见过的英文句子生成翻译时,它们起着重要作用。这些作用的具体细节将在后面的部分中讨论。

这是一个非常直接的转换。这将把短语“Grüß Gott!”转换为“sos Grüß Gott!eos”。接下来,我们将从我们抽样的数据中生成一个训练/验证/测试子集:

# Randomly sample 10% examples from the total 50000 randomly
test_df = df.sample(n=n=int(n_samples/10), random_state=random_seed)
# Randomly sample 10% examples from the remaining randomly
valid_df = df.loc[~df.index.isin(test_df.index)].sample(
    n=n=int(n_samples/10), random_state=random_seed
)
# Assign the rest to training data
train_df = df.loc[~(df.index.isin(test_df.index) | 
➥ df.index.isin(valid_df.index))]

我们将把数据的 10%保留为测试数据,另外 10%保留为验证数据,剩下的 80%作为训练数据。数据集将随机抽样(无替换)以得到数据集。然后我们继续分析文本数据集的两个重要特征,就像我们一遍又一遍地做的那样:词汇大小(列表 11.1)和序列长度(列表 11.2)。

列表 11.1 分析词汇大小

from collections import Counter

en_words = train_df["EN"].str.split().sum()                    ❶
de_words = train_df["DE"].str.split().sum()                    ❷

n=10                                                           ❸

def get_vocabulary_size_greater_than(words, n, verbose=True):

    """ Get the vocabulary size above a certain threshold """

    counter = Counter(words)                                   ❹

    freq_df = pd.Series(                                       ❺
        list(counter.values()), 
        index=list(counter.keys())
    ).sort_values(ascending=False)

    if verbose:
        print(freq_df.head(n=10))                              ❻

    n_vocab = (freq_df>=n).sum()                               ❼

    if verbose:
        print("\nVocabulary size (>={} frequent): {}".format(n, n_vocab))

    return n_vocab

print("English corpus")
print('='*50)
en_vocab = get_vocabulary_size_greater_than(en_words, n)

print("\nGerman corpus")
print('='*50)
de_vocab = get_vocabulary_size_greater_than(de_words, n)

❶ 从英文单词中创建一个扁平化列表。

❷ 创建一个扁平化的德文单词列表。

❸ 获取出现次数大于或等于 10 次的单词的词汇大小。

❹ 生成一个计数器对象(即 dict word -> frequency)。

❺ 从计数器创建一个 pandas 系列,然后按最频繁到最不频繁排序。

❻ 打印最常见的单词。

❼ 获取至少出现 10 次的单词的计数。

这将返回

English corpus
==================================================
Tom    9427
to     8673
I      8436
the    6999
you    6125
a      5680
is     4374
in     2664
of     2613
was    2298
dtype: int64

Vocabulary size (>=10 frequent): 2238
German corpus
==================================================
sos      40000
eos      40000
Tom       9928
Ich       7749
ist       4753
nicht     4414
zu        3583
Sie       3465
du        3112
das       2909
dtype: int64

Vocabulary size (>=10 frequent): 2497

接下来,在以下函数中进行序列分析。

列表 11.2 分析序列长度

def print_sequence_length(str_ser):

    """ Print the summary stats of the sequence length """

    seq_length_ser = str_ser.str.split(' ').str.len()             ❶

    print("\nSome summary statistics")                            ❷
    print("Median length: {}\n".format(seq_length_ser.median()))  ❷
    print(seq_length_ser.describe())                              ❷

    print(
        "\nComputing the statistics between the 1% and 99% quantiles (to 
➥ ignore outliers)"
    )
    p_01 = seq_length_ser.quantile(0.01)                          ❸
    p_99 = seq_length_ser.quantile(0.99)                          ❸

    print(
        seq_length_ser[
            (seq_length_ser >= p_01) & (seq_length_ser < p_99)
        ].describe()                                              ❹
    )

❶ 创建包含每个评论的序列长度的 pd.Series。

❷ 获取序列长度的中位数以及摘要统计信息。

❸ 获取给定标记(即 1%和 99%的百分位)的分位数。

❹ 打印定义的分位数之间的数据的摘要统计信息。

接下来,对数据调用此函数以获取统计信息:

print("English corpus")
print('='*50)
print_sequence_length(train_df["EN"])

print("\nGerman corpus")
print('='*50)
print_sequence_length(train_df["DE"])

这产生

English corpus
==================================================
Some summary statistics
Median length: 6.0

count    40000.000000
mean         6.360650
std          2.667726
min          1.000000
25%          5.000000
50%          6.000000
75%          8.000000
max        101.000000
Name: EN, dtype: float64

Computing the statistics between the 1% and 99% quantiles (to ignore outliers)
count    39504.000000
mean         6.228002
std          2.328172
min          2.000000
25%          5.000000
50%          6.000000
75%          8.000000
max         14.000000
Name: EN, dtype: float64

German corpus
==================================================

Some summary statistics
Median length: 8.0

count    40000.000000
mean         8.397875
std          2.652027
min          3.000000
25%          7.000000
50%          8.000000
75%         10.000000
max         77.000000
Name: DE, dtype: float64

Computing the statistics between the 1% and 99% quantiles (to ignore outliers)
count    39166.000000
mean         8.299035
std          2.291474
min          5.000000
25%          7.000000
50%          8.000000
75%         10.000000
max         16.000000
Name: DE, dtype: float64

接下来,让我们打印出两种语言的词汇量和序列长度参数:

print("EN vocabulary size: {}".format(en_vocab))
print("DE vocabulary size: {}".format(de_vocab))

# Define sequence lengths with some extra space for longer sequences
en_seq_length = 19
de_seq_length = 21

print("EN max sequence length: {}".format(en_seq_length))
print("DE max sequence length: {}".format(de_seq_length))

这将返回

EN vocabulary size: 359
DE vocabulary size: 336
EN max sequence length: 19
DE max sequence length: 21

现在我们有了定义模型所需的语言特定参数。在下一节中,我们将看看如何定义一个能够在语言之间进行翻译的模型。

练习 1

您已经获得了以下格式的 pandas Series ser:

0       [a, b, c]
1          [d, e]
2    [f, g, h, i]
...

dtype: object

编写一个名为 vocab_size(ser)的函数来返回词汇量。

11.2 编写英语-德语 seq2seq 机器翻译器

您有一个准备进入模型的干净数据集。您将使用一个序列到序列的深度学习模型作为机器翻译模型。它由两部分组成:一个编码器,用于生成英文(源)文本的隐藏表示,以及一个解码器,用于解码该表示以生成德文(目标)文本。编码器和解码器都是循环神经网络。此外,模型将接受原始文本,并使用 TensorFlow 提供的 TextVectorization 层将原始文本转换为令牌 ID。这些令牌 ID 将传递给一个嵌入层,该层将返回令牌 ID 的单词向量。

我们已经准备好并准备好使用的数据。现在让我们了解一下可以使用此数据的模型。序列到序列学习将任意长的序列映射到另一个任意长的序列。对于我们来说,这提出了一个独特的挑战,因为模型不仅需要能够消耗任意长度的序列,还需要能够生成任意长度的序列作为输出。例如,在机器翻译中,翻译通常比输入的单词少或多。因此,它们需要一种特殊类型的模型。这些模型被称为编码器-解码器seq2seq(缩写为序列到序列)模型。

编码器-解码器模型实际上是两个不同的模型以某种方式相互连接起来。在概念上,编码器接受一个序列并产生一个上下文向量(或思考向量),其中嵌入了输入序列中的信息。解码器接受编码器产生的表示,并对其进行解码以生成另一个序列。由于两个部分(即编码器和解码器)分别在不同的事物上操作(即编码器消耗输入序列,而解码器生成输出序列),因此编码器-解码器模型非常适合解决序列到序列的任务。理解编码器和解码器的另一种方式是:编码器处理源语言输入(即要翻译的语言),解码器处理目标语言输入(即要翻译成的语言)。如图 11.1 所示。

11-01

图 11.1 编码器-解码器架构在机器翻译环境中的高级组件

特别地,编码器包含一个循环神经网络。我们将使用门控循环单元(GRU)模型。它通过输入序列并产生一个最终输出,这是在处理输入序列中的最后一个元素之后 GRU 单元的最终输出。

思想向量

思想向量 是由深度学习领域的泰斗杰弗里·亨滕(Geoffery Hinten)推广的一个术语,他从深度学习的起源就参与其中。思想向量指的是思想的向量化表示。生成准确的思想数值表示将彻底改变我们搜索文档或在网络上搜索(例如,谷歌)的方式。这类似于数值表示单词被称为 单词向量 的方式。在机器翻译的背景下,上下文向量可以称为思想向量,因为它在一个向量中捕捉了句子或短语的本质。

您可以在wiki.pathmind.com/thought-vectors阅读更多关于此的信息。

接下来,我们有解码器,它也由一个 GRU 模型和几个密集层组成。密集层的目的是生成最终的预测(目标词汇中的一个词)。解码器中存在的密集层的权重在时间上是共享的。这意味着,正如 GRU 层在从一个输入移动到另一个输入时更新相同的权重一样,密集层在时间步上重复使用相同的权重。这个过程在图 11.2 中有所描述。

11-02

图 11.2 编码器和解码器模块中的特定组件。编码器有一个 GRU 层,解码器由一个或多个密集层后跟的 GRU 层组成,其权重在时间上是共享的。

到目前为止,在解决 NLP 任务时,将字符串标记转换为数值 ID 被认为是预处理步骤。换句话说,我们会执行标记到 ID 的转换,并将 ID 输入模型。但它并不一定要这样。我们可以定义更加灵活的模型,让这种文本处理在内部完成并学会解决任务。Keras 提供了一些层,可以插入到您的模型中,以使其更加端到端。tensorflow.keras.layers.experimental.preprocessing.TextVectorization 层就是这样一种层。让我们来看看这个层的用法。

11.2.1 文本矢量化层

文本矢量化层接受一个字符串,对其进行标记化,并通过词汇表(或字典)查找将标记转换为 ID。它以字符串列表(或字符串数组)作为输入,其中每个字符串可以是单词/短语/句子(等等)。然后它从语料库中学习词汇。最后,该层可以用于将字符串列表转换为包含该列表中每个字符串的标记 ID 序列的张量。让我们看看这个层的作用。首先,导入该层:

from tensorflow.keras.layers.experimental.preprocessing import 
➥ TextVectorization

然后,按以下方式定义该层。在这里,我们为英语定义了该层。请记住,我们的模型中需要两个 TextVectorization 层,一个用于英语,一个用于德语:

en_vectorize_layer = TextVectorization(
    max_tokens=en_vocab,
    output_mode='int',
    output_sequence_length=None
)

值得停下来看看我们提供的不同参数:

  • max_tokens—指定词汇表中的单词数。如果词汇表中没有某个单词(即,超出词汇表的单词),则将其转换为[UNK]。

  • output_mode—指定最终输出的类型。可以是"int"、"binary"、"count"和"tf-idf"之一。"int"表示层将为每个标记输出一个标记 ID。"binary"意味着输出将是一个[<批量大小>, <词汇大小>]张量,在这个例子中,如果该标记所指示的索引在这个示例中存在,则给定值为 1。"count"给出了与"binary"类似的输出,但其中包含了该示例中标记出现的次数。"tf-id"给出了与"binary"类似的输出,但每个位置处的 TF-IDF 值。

  • output_sequence_length—指定转换为标记 ID 后的批量输入序列的长度。如果设置为 None,则意味着序列长度将设置为批量中最长序列的长度。较短的序列将使用特殊标记(特殊标记默认为"")进行填充。

要充分利用这个层,我们必须在文本语料库上适应它,以便它可以学习词汇表。调用 adapt()函数并向其传递字符串列表(或字符串数组)可以实现这一目的。换句话说,adapt()产生了与 scikit-learn 模型的 fit()方法相同的结果(mng.bz/aJmB)。它接受一些数据并根据数据训练(或适应)模型。对于标记器来说,除其他外,它构建了一个词典(从单词到 ID 的映射):

en_vectorize_layer.adapt(np.array(train_df["EN"].tolist()).astype('str'))

安装该层后,可以获得词汇表

print(en_vectorize_layer.get_vocabulary()[:10])

which prints

['', '[UNK]', 'tom', 'to', 'you', 'the', 'i', 'a', 'is', 'that']

换句话说,词汇表是一个标记列表,其中 ID 对应于它们在列表中的索引。你可以通过计算词汇表的大小来得到:

print(len(en_vectorize_layer.get_vocabulary()))

which returns

2238

接下来,要使用这个层将字符串转换为数字 ID,我们必须将其封装在一个模型中。为此,首先让我们定义一个 Keras 顺序模型。让我们将模型命名为 toy_model,因为这只用于学习文本向量化器的行为:

toy_model = tf.keras.models.Sequential()

定义一个输入层,将其大小设置为接受单列张量(即,一个字符串列表),并将数据类型设置为 tf.string:

toy_model.add(tf.keras.Input(shape=(1,), dtype=tf.string))

然后添加我们定义的文本向量化层:

toy_model.add(en_vectorize_layer)

你可以像使用其他 Keras 模型一样使用它,并将任意文本转换为数字 ID 序列。具体地,你可以在一些输入数据上使用 model.predict()函数,该函数接受输入并根据模型中使用的层进行相应转换:

input_data = [["run"], ["how are you"],["ectoplasmic residue"]]
pred = toy_model.predict(input_data)

最后,按以下方式打印输入和结果

print("Input data: \n{}\n".format(input_data))
print("\nToken IDs: \n{}".format(pred))

which gives

Input data: 
[['run'], ['how are you'], ['ectoplasmic residue']]
Token IDs: 
[[427   0   0]
 [ 40  23   4]
 [  1   1   0]]

该层按照一切都进行。 首先让我们看一下输出的形状。 由于我们设置了 output_sequence_length=None,所以将所有输入示例填充到输入中最长输入的长度。 在这里,“how are you”是最长的,其中有三个单词。 因此,所有行都用零填充,以便每个示例都有三列。 通常,该层返回一个大小为 [, sequence_length] 的输出。

如果单词在词汇表中找到,它将转换为某个数字(例如,“run”转换为 427)。 如果单词未在词汇表中找到(例如,“ectoplasmic”),则会用表示词汇表外单词的特殊 ID(1)替换。

11.2.2 为 seq2seq 模型定义 TextVectorization 层

通过对 TextVectorization 层有很好的理解,让我们定义一个函数,返回一个包装在 Keras Model 对象中的文本向量化层。 此函数名为 get_vectorizer(),接受以下参数:

  • 语料库—接受字符串列表(或数组)(即要构建词汇表的语料库)。

  • n_vocab—词汇量大小。 保留最常见的 n_vocab 个词以构建词汇表。

  • max_length(可选)—结果标记序列的长度。 默认为 None,此时序列长度将为最长文本序列的长度。

  • return_vocabulary(可选)—是否返回词汇表(即字符串标记列表)。

  • name(可选)—用于设置模型名称的字符串

它定义了一个接受字符串批次(总共形状为 [None, 1])的输入层。 接下来,函数定义了一个文本向量化层。 请注意,该层的词汇量为 n_vocab + 2。额外的 2 是为了容纳特殊标记" "和"[UNK]"。 该层适应了传递给函数的文本语料库。 最后,我们使用输入层(inp)和文本向量化层的输出(vectorize_out)定义了一个 Keras 模型。 如果 return_vocabulary 设置为 True,则还会返回 vectorize_layer 的词汇表,如下一列表所示。

列表 11.3 为编码器-解码器模型定义文本向量化器

def get_vectorizer(
    corpus, n_vocab, max_length=None, return_vocabulary=True, name=None
):

    """ Return a text vectorization layer or a model """

    inp = tf.keras.Input(shape=(1,), dtype=tf.string, name='encoder_input')❶

    vectorize_layer = 
➥ tf.keras.layers.experimental.preprocessing.TextVectorization(
        max_tokens=n_vocab+2,                                              ❷
        output_mode='int',
        output_sequence_length=max_length,                
    )

    vectorize_layer.adapt(corpus)                                          ❸

    vectorized_out = vectorize_layer(inp)                                  ❹

    if not return_vocabulary: 
        return tf.keras.models.Model(
            inputs=inp, outputs=vectorized_out, name=name
        )                                                                  ❺
    else:
        return tf.keras.models.Model(
            inputs=inp, outputs=vectorized_out, name=name                  ❻
        ), vectorize_layer.get_vocabulary()        

❶ 定义一个接受字符串列表(或字符串数组)的输入层。

❷ 在定义词汇量大小时,我们使用 n_vocab + 2,因为自动添加了两个特殊标记“(填充)”和“[UNK]”。

❸ 在数据上拟合向量化层。

❹ 获取输入数据的标记 ID。

❺ 仅返回模型。 该模型接受字符串数组并输出标记 ID 的张量。

❻ 除了模型外,返回词汇表。

既然我们已经定义了该函数,让我们使用它并定义两个向量化器,一个用于英文输入,一个用于德文输入:

# Get the English vectorizer/vocabulary
en_vectorizer, en_vocabulary = get_vectorizer(
    corpus=np.array(train_df[“EN”].tolist()), n_vocab=en_vocab, 
    max_length=en_seq_length, name=’en_vectorizer’
)
# Get the German vectorizer/vocabulary
de_vectorizer, de_vocabulary = get_vectorizer(
    corpus=np.array(train_df[“DE”].tolist()), n_vocab=de_vocab, 
    max_length=de_seq_length-1, name=’de_vectorizer’
)

在这里,语料库接受一个文本列表或数组。每个文本都是一个包含英语或德语短语/句子的字符串。n_vocab 定义词汇表的大小,max_length 定义了我们应该对数据进行填充的序列长度。请注意,我们在解码器中使用 de_seq_length-1。这里减 1 的操作是由于在模型训练期间数据呈现给解码器的方式所决定的。当我们到达模型训练时,我们将讨论具体细节。最后,我们可以定义一个名称来跟踪不同的层。

11.2.3 定义编码器

接下来我们来到编码器,我们将在编码器的核心使用一个 GRU 模型。编码器负责处理源输入序列。它的责任是处理源输入并生成一个上下文向量(有时称为思考向量)。该向量以紧凑的、向量化的形式捕获输入序列的本质。通常情况下,这个上下文向量将是 GRU 单元在处理完整输入序列后的最后输出状态。

让我们看看准备编码器所涉及的步骤。为此,我们将使用 Keras Functional 层。序列到序列模型不是顺序的,并且在编码器和解码器之间涉及非线性连接。因此,我们不能使用 Keras Sequential API。首先,我们定义输入层:

# The input is (None,1) shaped and accepts an array of strings
inp = tf.keras.Input(shape=(1,), dtype=tf.string, name='e_input')

输入接受一个字符串列表。请注意,我们将形状设置为(1,)以确保模型接受具有一个列的张量,并将 dtype 设置为 tf.string。接下来,我们将由 inp 前向传递的文本输入进行向量化。

# Vectorize the data (assign token IDs)
vectorized_out = en_vectorizer(inp)

这里,向量化器是由我们之前定义的 get_vectorizer()函数输出的执行文本向量化的模型。

接下来,我们定义一个嵌入层,它将由向量化器返回的标记 ID 转换为单词向量。这是一个具有可训练权重的层。因此,在训练期间,模型将调整单词嵌入以反映解决手头任务的有用表示:

# Define an embedding layer to convert IDs to word vectors
emb_layer = tf.keras.layers.Embedding(
    input_dim=n_vocab+2, output_dim=128, mask_zero=True, name=’e_embedding’
)
# Get the embeddings of the token IDs
emb_out = emb_layer(vectorized_out)

在定义嵌入层时,您需要始终传递词汇表大小(input_dim)和 output_dim。请注意,词汇表大小已经增加了 2 以容纳引入的两个特殊标记(即 UNK 和 PAD)。我们将 output_dim 设置为 128。我们还希望屏蔽过多的零,因此设置 mask_zero=True。最后,我们还将传递一个名称以便于识别该层。

现在我们要来到我们模型的核心:循环神经网络(RNN)。正如前面提到的,我们将使用一个 GRU 模型,但是带有一个额外的特点!我们将使我们的 GRU 模型成为双向的!双向 RNN 是一种特殊类型的 RNN,它同时处理序列的前向和后向。这与标准 RNN 相反,后者只处理序列的前向:

gru_layer = tf.keras.layers.Bidirectional(tf.keras.layers.GRU(128))

双向 RNN:前向和后向阅读文本

标准的循环神经网络逐步阅读文本,一次处理一个时间步,然后输出一系列输出。双向循环神经网络正如其名称所示,不仅向前阅读文本,而且向后阅读文本。这意味着双向循环神经网络有两个输出序列。然后,这两个序列使用组合策略(例如连接)进行组合,以产生最终输出。通常双向循环神经网络的性能优于标准循环神经网络,因为它们可以理解文本前后的关系,如下图所示。

11-02-unnumb

标准 RNN 和双向 RNN 之间的比较

阅读文本倒置为什么有帮助呢?有一些语言是倒序阅读的(例如阿拉伯语、希伯来语)。除非文本经过特殊处理以考虑这种书写风格,否则标准的循环神经网络将很难理解这种语言。通过使用双向循环神经网络,您可以消除模型对语言始终从左到右或从右到左的依赖。

如果考虑英语,可能存在只从前面推断关系是不可能的情况。考虑以下两个句子

约翰走向了克拉伦斯街上的银行。

约翰朝河边的银行走去。

由于这两个句子在“bank”一词之前是相同的,因此在阅读其余部分之前,不可能知道“bank”是指金融机构还是河岸。对于双向循环神经网络来说,这是微不足道的。

然后我们获得 gru_layer 的输出,并将其分配给 gru_out:

gru_out = gru_layer(emb_out)

最后,我们将编码器模型定义为 tf.keras.models.Model 对象。它接受 inp(即 tf.string 类型的单列张量)并输出 gru_out(即双向 GRU 模型的最终状态)。这个 GRU 模型的最终状态被认为是上下文向量,为解码器提供有关源语言句子/短语输入的信息:

encoder = tf.keras.models.Model(inputs=inp, outputs=gru_out)

可以观察在以下清单中显示的逐步构建编码器模型的函数封装方式。

清单 11.4 返回编码器的函数

def get_encoder(n_vocab, vectorizer):
    """ Define the encoder of the seq2seq model"""

    inp = tf.keras.Input(shape=(1,), dtype=tf.string, name='e_input')   ❶

    vectorized_out = vectorizer(inp)                                    ❷

    emb_layer = tf.keras.layers.Embedding(
        n_vocab+2, 128, mask_zero=True, name='e_embedding'              ❸
    )

    emb_out = emb_layer(vectorized_out)                                 ❹

    gru_layer = tf.keras.layers.Bidirectional(
        tf.keras.layers.GRU(128, name='e_gru'),                         ❺
        name='e_bidirectional_gru'
    )

    gru_out = gru_layer(emb_out)                                        ❻

    encoder = tf.keras.models.Model(
        inputs=inp, outputs=gru_out, name='encoder'
    )                                                                   ❼

    return encoder

❶ 输入形状为(None,1),接受一个字符串数组。

❷ 对数据进行向量化(分配令牌 ID)

❸ 定义一个嵌入层,将 ID 转换为词向量。

❹ 获取令牌 ID 的嵌入。

❺ 定义一个双向 GRU 层。编码器同时查看英文文本(即输入)的前向和后向。

❻ 获取 gru 的最后一个输出(模型返回的最后一个输出状态向量)。

❼ 定义编码器模型;它接受一个字符串列表/数组并返回 GRU 模型的最后输出状态。

定义函数后,您可以简单地调用它来构建编码器模型:

encoder = get_encoder(en_vocab, en_vectorizer)

11.2.4 定义解码器和最终模型

编码器已经完成,现在是看看解码器的时候了。解码器看起来会比编码器稍微复杂一些。解码器的核心模型再次是一个 GRU 模型。然后是一个完全连接的隐藏层和一个完全连接的预测层。预测层对每个时间步输出来自德语词汇表的一个单词(通过计算整个词汇表上的概率)。

在模型训练期间,解码器预测给定目标序列中的下一个单词。例如,给定目标序列 [A, B, C, D],解码器将根据以下输入-输出元组对模型进行三个时间步的训练:(A, B),(B, C) 和 (C, D)。换句话说,给定标记 A,预测标记 B;给定标记 B,预测标记 C;依此类推。如果你考虑编码器-解码器模型的端到端过程,将会发生以下步骤:

  1. 编码器处理源输入序列(即英文)并生成上下文向量(即 GRU 模型的最后输出状态)。

  2. 解码器使用编码器产生的上下文向量作为其循环组件的初始状态。

  3. 解码器接收目标输入序列(即德语)并根据前一个标记预测下一个标记。对于每个时间步,它使用完全连接的层和 softmax 层在完整的目标词汇表上预测一个标记。

这种模型训练的方式被称为教师强制,因为你正在用目标序列(即老师)引导解码器。与在编码器-解码器类型模型的训练中不使用教师强制相比,使用教师强制很快会导致更好的性能。让我们深入了解解码器,并看看编码器和解码器如何相互联系以创建更深入的最终模型,如图 11.3 所示。

11-03

图 11.3 最终的序列到序列模型的实现,重点关注各种层和输出

现在是讨论构建解码器和最终模型的具体细节的时候了。我们首先必须做的事情是通过传递一个输入来获得编码器的输出。我们定义了一个与编码器的输入相同的输入层,并将其传递给我们之前定义的编码器模型:

e_inp = tf.keras.Input(shape=(1,), dtype=tf.string, name='e_input_final') 

然后我们将 e_inp 传递给编码器模型,它将给出 GRU 模型的最后输出状态作为输出。这是解码器的重要输入:

d_init_state = encoder(e_inp)

作为解码器的起点,我们定义了一个具有与编码器输入相同规格的输入层:

d_inp = tf.keras.Input(shape=(1,), dtype=tf.string, name='d_input')

然后我们将输入传递给一个文本向量化模型,给定 get_vectorizer() 函数:

vectorized_out = de_vectorizer(inp)

我们像对编码器一样定义一个嵌入层,以便文本向量化层生成的标记 ID 转换为单词向量。请注意,我们为编码器和解码器分别定义了两个单独的嵌入层,因为它们使用来自两种不同语言的序列:

emb_layer = tf.keras.layers.Embedding(
    input_dim=n_vocab+2, output_dim=128, mask_zero=True, name='d_embedding'
)
emb_out = emb_layer(vectorized_out)

现在是时候实现解码器的循环组件了。与编码器类似,我们使用 GRU 模型来处理序列:

gru_layer = tf.keras.layers.GRU(256, return_sequences=True)

但请注意,与编码器相比,在解码器中,我们没有在 GRU 模型上使用双向包装器。解码器不能依赖于向后阅读能力,因为它应该仅根据前一个和当前输入生成下一个输出。还要注意,我们设置了 return_sequences=True:

gru_out = gru_layer(emb_out, initial_state=d_init_state)

最后,将嵌入层的输出传递给 gru_layer,我们得到输出。之前我们声明过 d_init_state(即编码器的输出)是解码器的重要输入之一。在这里,我们将 d_init_state 作为初始状态传递给解码器的 GRU 层。这意味着,解码器不是从零初始化状态向量开始,而是使用编码器的上下文向量作为初始状态。由于我们设置了 return_sequences=True,因此输出将包含所有时间步的输出状态向量,而不仅仅是最后一个时间步。这意味着输出的大小为[<批大小>,<时间步长>,256]。

清单 11.5 定义解码器和最终模型

def get_final_seq2seq_model(n_vocab, encoder, vectorizer):
    """ Define the final encoder-decoder model """    
    e_inp = tf.keras.Input(
        shape=(1,), dtype=tf.string, name='e_input_final'
    )                                                                    ❶

    d_init_state = encoder(e_inp)                                        ❶

    d_inp = tf.keras.Input(shape=(1,), dtype=tf.string, name='d_input')  ❷

    d_vectorized_out = vectorizer(d_inp)                                 ❸

    d_emb_layer = tf.keras.layers.Embedding(
        n_vocab+2, 128, mask_zero=True, name='d_embedding'               ❹
    )
    d_emb_out = d_emb_layer(d_vectorized_out)

    d_gru_layer = tf.keras.layers.GRU(
        256, return_sequences=True, name='d_gru'
    )                                                                    ❺

    d_gru_out = d_gru_layer(d_emb_out, initial_state=d_init_state)       ❻

    d_dense_layer_1 = tf.keras.layers.Dense(
        512, activation='relu', name='d_dense_1'
    )                                                                    ❼
    d_dense1_out = d_dense_layer_1(d_gru_out)                            ❼

    d_dense_layer_final = tf.keras.layers.Dense(
        n_vocab+2, activation='softmax', name='d_dense_final'            ❽
    )
    d_final_out = d_dense_layer_final(d_dense1_out)                      ❽

    seq2seq = tf.keras.models.Model(
        inputs=[e_inp, d_inp], outputs=d_final_out, name='final_seq2seq' ❾
    )

    return seq2seq

❶定义编码器输入层并获取编码器输出(即上下文向量)。

❷输入的形状为(None,1),并接受字符串数组作为输入。我们将德语序列作为输入并要求模型使用偏移一个(即下一个)单词的单词预测它。

❸获取解码器的向量化输出。

❹定义嵌入层,将 ID 转换为单词向量。这是与编码器嵌入层不同的层。

❺定义 GRU 层。与编码器不同,我们不能为解码器定义双向 GRU。

❻获取解码器的 GRU 层输出。

❼定义中间的 Dense 层并获取输出。

❽最终预测层使用 softmax

❾定义完整模型。

我们现在可以定义所需的所有内容,以制定我们的最终模型

# Get the English vectorizer/vocabulary
en_vectorizer, en_vocabulary = get_vectorizer(
    corpus=np.array(train_df["EN"].tolist()), n_vocab=en_vocab, 
    max_length=en_seq_length, name='e_vectorizer'
)
# Get the German vectorizer/vocabulary
de_vectorizer, de_vocabulary = get_vectorizer(
    corpus=np.array(train_df["DE"].tolist()), n_vocab=de_vocab,
    max_length=de_seq_length-1, name='d_vectorizer'
)

# Define the final model
encoder = get_encoder(n_vocab=en_vocab, vectorizer=en_vectorizer)
   final_model = get_final_seq2seq_model(
       n_vocab=de_vocab, encoder=encoder, vectorizer=de_vectorizer
   )

在这里,我们定义英语和德语的向量器(分别为 en_vectorizer 和 de_vectorizer)。然后使用英语词汇量和英语向量器定义编码器。最后,使用德语词汇量(de_vocab)编码器和德语向量器(de_vectorizer)定义最终的编码器-解码器模型。

11.2.5 编译模型

准备好模型进行培训的最后一件事是编译模型。我们将使用稀疏分类交叉熵损失,Adam 优化器和准确度作为指标:

from tensorflow.keras.metrics import SparseCategoricalAccuracy

final_model.compile(
    loss='sparse_categorical_crossentropy', 
    optimizer='adam', 
    metrics=['accuracy']
)

最后,让我们打印模型摘要

final_model.summary()

将输出

Model: "final_seq2seq"
___________________________________________________________________________
Layer (type)                    Output Shape         Param #       
➥ Connected to                     
===========================================================================
d_input (InputLayer)            [(None, 1)]          0                     
___________________________________________________________________________
d_vectorizer (Functional)       (None, 20)           0           
➥ d_input[0][0]                    
___________________________________________________________________________
e_input_final (InputLayer)      [(None, 1)]          0                     
___________________________________________________________________________
d_embedding (Embedding)         (None, 20, 128)      319872      
➥ d_vectorizer[0][0]               
___________________________________________________________________________
encoder (Functional)            (None, 256)          484864      
➥ e_input_final[0][0]              
___________________________________________________________________________
d_gru (GRU)                     (None, 20, 256)      296448      
➥ d_embedding[0][0]                
➥ encoder[0][0]                    
___________________________________________________________________________
d_dense_1 (Dense)               (None, 20, 512)      131584      
➥ d_gru[0][0]                      
___________________________________________________________________________
d_dense_final (Dense)           (None, 20, 2499)     1281987     
➥ d_dense_1[0][0]                  
===========================================================================
Total params: 2,514,755
Trainable params: 2,514,755
Non-trainable params: 0
___________________________________________________________________________

在下一节中,我们将学习如何使用准备好的数据训练我们刚刚定义的模型。

习题 2

与教师强制相反,另一种定义编码器-解码器模型的技术是定义一个模型,其中

  • 编码器接收英语标记序列

  • 解码器在时间轴上重复上下文向量作为输入,以便将相同的上下文向量馈送到解码器的每个时间步

您已提供以下编码器。定义一个具有 GRU 层和两个全连接隐藏层的解码器,以及以 en_inp 开头并生成最终预测的最终模型:

en_inp = tf.keras.Input(shape=(1,), dtype=tf.string, name='e_input')
en_vectorized_out = en_vectorizer(inp)
en_emb_layer = tf.keras.layers.Embedding(
   en_vocab+2, 128, mask_zero=True, name='e_embedding'
)
en_emb_out = emb_layer(vectorized_out)
en_gru_layer = tf.keras.layers.GRU(256, name='e_gru')
en_gru_out = gru_layer(emb_out)

您可以使用 tf.keras.layers.RepeatVector 层重复上下文向量任意次数。例如,如果您将一个 [None, 32] 大小的张量传递给 tf.keras.layers.RepeatVector(5) 层,则通过在时间维度上五次重复 [None, 32] 张量,返回一个 [None, 5, 32] 大小的张量。

11.3 训练和评估模型

您已经定义了一个可以接受原始文本并生成翻译的端到端模型。接下来,您将在之前准备的数据上训练此模型。您将使用训练集来训练模型,并使用验证集来监视其训练性能。最后,模型将在测试数据上进行测试。为了评估模型,我们将使用两个指标:准确率和 BLEU。BLEU 是序列到序列问题中常用的指标,用于衡量输出序列(例如,翻译)的质量。

我们已经定义并编译了模型。现在,我们将在训练数据上训练模型,并在验证和测试数据上评估其性能,涵盖几个指标。我们将使用称为 BLEU 的性能指标来衡量模型的性能。它不是 Keras 提供的标准指标,将使用标准的 Python/NumPy 函数实现。因此,我们将编写自定义的训练/评估循环来分别训练和评估模型。

为了方便模型的训练和评估,我们将创建几个辅助函数。首先,我们将创建一个函数,从我们在开头定义的 Python DataFrame 对象中创建输入和目标(请参阅下一个列表)。

列表 11.6 准备用于模型训练和评估的训练/验证/测试数据

def prepare_data(train_df, valid_df, test_df):
    """ Create a data dictionary from the dataframes containing data """

    data_dict = {}                                                       ❶
    for label, df in zip(
        ['train', 'valid', 'test'], [train_df, valid_df, test_df]
    ):                                                                   ❷
        en_inputs = np.array(df["EN"].tolist())                          ❸
        de_inputs = np.array(
            df["DE"].str.rsplit(n=1, expand=True).iloc[:,0].tolist()     ❹
        )
        de_labels = np.array(
            df["DE"].str.split(n=1, expand=True).iloc[:,1].tolist()      ❺
        )
        data_dict[label] = {
            'encoder_inputs': en_inputs,                                 ❻
            'decoder_inputs': de_inputs, 
            'decoder_labels': de_labels
        }

    return data_dict

❶ 定义一个包含训练/验证/测试数据的字典。

❷ 遍历 train、valid 和 test 数据框。

❸ 将编码器输入定义为英文文本。

❹ 将解码器输入定义为除最后一个令牌外的所有德语文本。

❺ 将解码器输出定义为除第一个令牌外的所有德语文本。

❻ 更新字典,包括编码器输入、解码器输入和解码器输出。

此函数接受三个数据框 train_df、valid_df 和 test_df,并对它们进行一些转换,以返回一个包含三个键的字典:train、valid 和 test。在每个键下,您将找到以下内容:

  • 编码器输入(即,英文单词序列)

  • 解码器输入(即,德语单词序列)

  • 解码器输出(即,德语单词序列)

正如我们之前所述,我们正在使用一种称为 teacher forcing 的技术来提升模型的性能。因此,解码器的目标变成了在给定前一个单词的情况下预测下一个单词。例如,对于例句(“I want a piece of chocolate cake”,“Ich möchte ein Stück Schokoladenkuchen”),编码器输入、解码器输入和解码器输出变为以下内容:

  • [“I”, “want”, “a”, “piece”, “of”, “chocolate”, “cake”](编码器输入)

  • [“Ich”, “möchte”, “ein”, “Stück”](解码器输入)

  • [“möchte”, “ein”, “Stück”, “Schokoladenkuchen”](解码器输出)

可以看到,在每个时间步长,解码器都在根据前一个单词预测下一个单词。prepare_data(...) 函数会执行此操作,下一个列表将显示。然后,我们将编写一个函数来洗牌数据。此函数将用于在训练期间的每个时代开始时对数据进行洗牌。

列表 11.7 对训练数据进行洗牌

def shuffle_data(en_inputs, de_inputs, de_labels, shuffle_indices=None): 
    """ Shuffle the data randomly (but all of inputs and labels at ones)"""

    if shuffle_indices is None:        
        shuffle_indices = np.random.permutation(np.arange(en_inputs.shape[0]))       ❶
    else:        
        shuffle_indices = np.random.permutation(shuffle_indices)                     ❷

    return (
        en_inputs[shuffle_indices], 
        de_inputs[shuffle_indices], 
        de_labels[shuffle_indices]                                                   ❸
    ), shuffle_indices

❶ 如果未传递 shuffle_indices,则自动生成洗牌索引。

❷ 对提供的 shuffle_indices 进行洗牌。

❸ 返回洗牌数据。

shuffle_data() 函数接受由 prepare_data 函数输出的数据(即编码器输入、解码器输入和解码器输出)。可选地,它接受数据索引的洗牌表示。我们允许将洗牌索引传递给函数,这样,通过对已经洗牌的索引进行洗牌,您可以得到数据顺序的新排列。这在训练期间在每个时代生成不同的洗牌配置时非常有用。

如果未传递 shuffle_indices,shuffle_data() 函数将生成数据索引的随机排列。数据索引是由 np.arange(en_ inputs.shape[0]) 生成的,它从 0 到 en_inputs 中的示例数量创建了一个有序数字序列。可以通过调用 np.random.permutation() 函数对给定数组生成给定数组的随机排列。如果已将数组传递给 shuffle_indices 参数,则将在 shuffle_indices 中传递的数组进行洗牌,生成新的洗牌数据配置。最后,我们根据 shuffle_indices 数组确定的顺序返回编码器输入(en_inputs)、解码器输入(de_inputs)和解码器输出(de_labels)进行洗牌。

接下来,我们将编写一个函数来评估模型。在此函数中,我们使用定义的 batch_size 对给定数据上的给定模型进行评估。特别地,我们使用三个指标评估机器翻译模型:

  • 交叉熵损失 ——在预测概率和真实目标之间计算的标准多类交叉熵损失。

  • 准确度 ——标准准确度,根据模型在给定时间步长上是否预测与真实目标相同的单词进行测量。换句话说,预测必须与真实目标完全匹配,从单词到单词。

  • BLEU 分数 ——比准确率更强大的度量标准,基于精确度,但考虑了许多不同值的 n 克隆。

双语评估干预(BLEU)

BLEU 是一种度量标准,用于通过衡量翻译与给定的真实文本(或翻译对应多个真实文本,因为相同的内容可以以不同的语言表达)的相似度来衡量生成文本序列(例如翻译)的质量。它是由 Papineni 等人在论文 “BLEU: A Method for Automatic Evaluation of Machine Translation” 中引入的(www.aclweb.org/anthology/P02-1040.pdf)。BLEU 分数是一种精度度量标准的变体,用于计算候选文本(即预测)与多个参考翻译(即真实文本)之间的相似度。

要了解 BLEU 度量,让我们考虑以下候选项和参考文献:

候选项 1 (C1):the cat was on the red mat

候选项 2 (C2):the cat the cat the cat the

参考文献 1:the cat is on the floor

参考文献 2:there was a cat on the mat

对于候选项 1 和 2 的精度可以计算为

精度 = 匹配任何参考文献的单词数/候选项中的单词数

意味着

精度(C1) = 6/7,精度(C2) = 7/7

这与直觉相矛盾。显然,C1 是与参考文献更匹配的选择。但是精度却讲述了另一个故事。因此,BLEU 引入了一个修改后的精度。在修改后的精度中,对于候选项中的每个唯一单词,您计算该单词在任何一个参考文献中出现的次数,并取其中的最大值。然后您对候选文本中所有唯一单词的这个值求和。例如,对于 C1 和 C2,修改后的单字精度是

修改后的精度(C1) = (2 + 1 + 1 + 2 + 0 + 1) /7 = 5/7,修改后的精度(C2) = (2 + 1)/7 = 3/7

这好多了:C1 的精度比 C2 高,这正是我们想要的。BLEU 将修改后的单字精度扩展到修改后的 n-gram 精度,并为多个 n-gram(例如,单字、二字、三字等)计算修改后的精度。通过在许多不同的 n-gram 上计算修改后的精度,BLEU 可以偏爱具有与参考文献匹配的更长子序列的翻译或候选。

我们将定义一个名为 BLEUMetric 的对象,它将计算给定预测批次和目标的 BLEU 分数,如下列表所示。

列表 11.8 定义 BLEU 度量以评估机器翻译模型

class BLEUMetric(object):

    def __init__(self, vocabulary, name='perplexity', **kwargs):
      """ Computes the BLEU score (Metric for machine translation) """
      super().__init__()
      self.vocab = vocabulary                                                ❶
      self.id_to_token_layer = StringLookup(
          vocabulary=self.vocab, invert=True, 
          num_oov_indices=0
      )                                                                      ❷

    def calculate_bleu_from_predictions(self, real, pred):
        """ Calculate the BLEU score for targets and predictions """

        pred_argmax = tf.argmax(pred, axis=-1)                               ❸

        pred_tokens = self.id_to_token_layer(pred_argmax)                    ❹
        real_tokens = self.id_to_token_layer(real)                           ❹

        def clean_text(tokens):

            """ Clean padding and [SOS]/[EOS] tokens to only keep meaningful words """

            t = tf.strings.strip(                                            ❺
                        tf.strings.regex_replace(                            ❻
                            tf.strings.join(                                 ❼
                                tf.transpose(tokens), separator=' '
                            ),
                        "eos.*", ""),
                   )

            t = np.char.decode(t.numpy().astype(np.bytes_), encoding='utf-8')❽

            t = [doc if len(doc)>0 else '[UNK]' for doc in t ]               ❾

            t = np.char.split(t).tolist()                                    ❿

            return t

        pred_tokens = clean_text(pred_tokens)                                ⓫
        real_tokens = [[r] for r in clean_text(real_tokens)]                 ⓬

        bleu, precisions, bp, ratio, translation_length, reference_length = 
➥ compute_bleu(real_tokens, pred_tokens, smooth=False)                      ⓭

        return bleu

❶ 从拟合的 TextVectorizer 中获取词汇表。

❷ 定义一个 StringLookup 层,它可以将标记 ID 转换为单词。

❸ 获取预测的标记 ID。

❹ 使用词汇表和 StringLookup 将标记 ID 转换为单词。

❺ 剥离字符串中的任何额外空格。

❻ 用空白替换 EOS 标记之后的所有内容。

❼ 将每个序列中的所有标记连接为一个字符串。

❽ 将字节流解码为字符串。

❾ 如果字符串为空,则添加一个 [UNK] 标记。否则,可能会导致数值错误。

❿ 将序列拆分为单独的标记。

⓫ 获取预测和真实序列的干净版本。

⓬ 我们必须将每个真实序列包装在列表中,以利用第三方函数来计算 BLEU。

⓭ 获取给定批次目标和预测的 BLEU 值。

首先,我们定义一个 init(...) 函数和该类的几个属性,例如 vocab,它将由 TextVectorization 层返回的解码器词汇表。接下来,我们定义一个 TensorFlow StringLookup 层,它可以返回给定令牌 ID 的字符串令牌,反之亦然。StringLookup 函数所需的仅仅是解码器 TextVectorization 层的词汇表。默认情况下,StringLookup 层将给定的字符串令牌转换为令牌 ID。设置 invert=true 意味着此层将把给定的令牌 ID 转换为字符串令牌。我们还需要说明我们不希望此层自动为词汇表外的单词添加表示。为此,我们将 num_oov_indices=0。

接下来,我们定义一个名为 calculate_bleu_from_predictions(...) 的函数,它接受一个真实目标的批次和模型给出的预测概率的批次,以计算该批次的 BLEU 分数。首先,它通过获取每个时间步的概率向量的最大索引来计算预测的令牌 ID:

pred_argmax = tf.argmax(pred, axis=-1)  

接下来,使用之前定义的 StringLookup 层生成字符串令牌:

pred_tokens = self.id_to_token_layer(pred_argmax)
real_tokens = self.id_to_token_layer(real)

具体来说,我们将令牌 ID 矩阵(预测和目标)传递给 StringLookup 层。例如,如果

real = [
    [4,1,0],
    [8,2,21]
]

vocabulary = ['', '[UNK]', 'sos', 'eos', 'tom', 'ich', 'nicht', 'ist', 'du', 'sie']

然后

real_tokens = tf.Tensor([
    [b'tom' b'[UNK]' b'']
    [b'du' b'sos' b'[UNK]']
], shape=(2, 3), dtype=string)

之后,我们定义一个函数来执行一些清理工作。定义的函数将截断预测,使得所有 EOS 令牌之后的内容都被移除(包括),并将句子标记为单词列表。该函数的输入是一个张量,其中每一行都是一个令牌列表(即,pred_tokens)。让我们利用这个机会来磨练我们对 TensorFlow 字符串操作的理解。TensorFlow 有一个名为 tf.strings 的命名空间(mng.bz/gw7E),提供各种基本的字符串操作功能:

def clean_text(tokens):

    """ Clean padding and [SOS]/[EOS] tokens to only keep meaningful words """
    # 3\. Strip the string of any extra white spaces
    translations_in_bytes = tf.strings.strip(
        # 2\. Replace everything after the eos token with blank
        tf.strings.regex_replace(
            # 1\. Join all the tokens to one string in each sequence
            tf.strings.join(tf.transpose(tokens), separator=' '),
             "eos.*", ""
        ),
     )

     # Decode the byte stream to a string
     translations = np.char.decode(
         translations_in_bytes.numpy().astype(np.bytes_), encoding='utf-8'
     )

     # If the string is empty, add a [UNK] token
     # Otherwise get a Division by zero error
     translations = [sent if len(sent)>0 else '[UNK]' for sent in translations ]

     # Split the sequences to individual tokens 
     translations = np.char.split(translations).tolist()

     return translations

让我们看看调用几个 tf.string 操作的第一行代码:

translations_in_bytes = tf.strings.strip(
        # 2\. Replace everything after the eos token with blank
        tf.strings.regex_replace(
            # 1\. Join all the tokens to one string in each sequence
            tf.strings.join(tf.transpose(tokens), separator=' '),
             "eos.*", ""
        ),
     )

它在输入字符串张量上执行一系列转换。首先,对令牌调用 tf.strings.join() 将使用给定的分隔符将所有令牌连接成一列。例如

[
    ['a','b','c'],
    ['d', 'e', 'f']
]

变为

['ad', 'be', 'cf']

由于我们的句子跨越行,因此我们首先需要转置令牌,使得句子位于列中。接下来,在张量上调用 tf.strings.regex_replace(),其中每个项都是由连接产生的句子。它将移除跟随 EOS 令牌的所有内容。此字符串模式由 eos.* 正则表达式捕获。最后,我们从生成的字符串中去除任何起始和结束空格。

TensorFlow 将字符串保留为字节格式。为了将字符串转换为 UTF-8 编码,我们有一系列的转换步骤,以将其转换为正确的格式:

  1. 首先,我们必须将数组转换为 NumPy 数组。此数组中的元素将以对象格式存在。

  2. 接下来,我们通过调用 translations_in_bytes.numpy().astype(np.bytes_)将其转换为字节数组。

  3. 最后,我们解码字节数组并将其转换为所需的编码(在我们的情况下为 UTF-8)。

其余代码很容易理解,并在代码中被注释说明。

最后,我们对预测和真实令牌张量调用 clean_text()函数,并将最终结果提供给第三方实现的 BLEU 指标:

pred_tokens = clean_text(pred_tokens)
real_tokens = [[r] for r in clean_text(real_tokens)]

bleu, precisions, bp, ratio, translation_length, reference_length = 
➥ compute_bleu(real_tokens, pred_tokens)

clean_text()函数将预测的翻译和真实翻译(有时称为参考)转换为一个令牌列表的列表。在这里,外部列表表示单个示例,而内部列表表示给定示例中的令牌。作为最后一步,我们将每个参考包装在另一个列表结构中,以便 real_tokens 成为一个令牌列表的列表的列表。这是必要的,因为我们将使用第三方实现的 BLEU 指标。此处使用的 compute_bleu()函数是在 TensorFlow 存储库中找到的第三方实现(mng.bz/e7Ev)。compute_bleu()函数期望两个主要参数:

  • 翻译—一个令牌列表的列表。

  • 参考—一个令牌列表的列表的列表。换句话说,每个翻译可以有多个参考文本,每个参考文本都是一个令牌列表。

然后返回

  • bleu—给定批次的候选参考对的 BLEU 分数。

  • 精度—构建最终 BLEU 分数的个别 n-gram 精度。

  • bp—短候选项的 BLEU 分数的特殊部分(惩罚短候选项)。

  • 比率—候选项长度除以参考文本长度。

  • translation_length—批次中候选项的长度总和。

  • reference_length—批次中参考文本的长度总和。在候选项有多个参考文本的情况下,选择最小值。

让我们测试 compute_bleu()函数的运行情况。假设有一个翻译和一个参考文本。在第一个情景中,翻译开头出现了[UNK]标记,其余与参考完全匹配。在第二个情景中,我们再次有两个[UNK]标记,但它们出现在开头和中间。让我们看看结果:

translation = [['[UNK]', '[UNK]', 'mÃssen', 'wir', 'in', 'erfahrung', 
➥ 'bringen', 'wo', 'sie', 'wohnen']]
reference = [[['als', 'mÃssen', 'mÃssen', 'wir', 'in', 'erfahrung', 
➥ 'bringen', 'wo', 'sie', 'wohnen']]]

bleu1, _, _, _, _, _ = compute_bleu(reference, translation)

translation = [['[UNK]', 'einmal', 'mÃssen', '[UNK]', 'in', 'erfahrung', 
➥ 'bringen', 'wo', 'sie', 'wohnen']]
reference = [[['als', 'mÃssen', 'mÃssen', 'wir', 'in', 'erfahrung', 
➥ 'bringen', 'wo', 'sie', 'wohnen']]]

bleu2, _, _, _, _, _ = compute_bleu(reference, translation)

print("BLEU score with longer correctly predict phrases: {}".format(bleu1))
print("BLEU score without longer correctly predict phrases: 
➥ {}".format(bleu2))

这将打印

BLEU score with longer correctly predict phrases: 0.7598356856515925
BLEU score without longer correctly predict phrases: 0.537284965911771

如果你计算翻译与参考之间的逐词准确率,你会得到相同的结果,因为只有两个[UNK]标记不匹配。然而,这两个情况的 BLEU 分数是不同的。这清楚地表明,BLEU 更偏好能够连续正确翻译更多单词的翻译,而不是出现断裂。

我们已经准备好为模型编写训练和评估循环所需的所有功能。让我们首先编写评估循环(请参阅下一个列表),因为它将在训练循环中用于评估模型。

列表 11.9 评估编码器-解码器模型

def evaluate_model(
    model, vectorizer, en_inputs_raw, de_inputs_raw, de_labels_raw, batch_size
):
    """ Evaluate the model on various metrics such as loss, accuracy and BLEU """

    bleu_metric = BLEUMetric(de_vocabulary)                                   ❶

    loss_log, accuracy_log, bleu_log = [], [], []

    n_batches = en_inputs_raw.shape[0]//batch_size                            ❷
    print(" ", end='\r')

    for i in range(n_batches):                                                ❸

        print("Evaluating batch {}/{}".format(i+1, n_batches), end='\r')      ❹
        x = [
            en_inputs_raw[i*batch_size:(i+1)*batch_size],                     ❺
            de_inputs_raw[i*batch_size:(i+1)*batch_size]
        ]
        y = de_vectorizer(de_labels_raw[i*batch_size:(i+1)*batch_size])       ❺

        loss, accuracy = model.evaluate(x, y, verbose=0)                      ❻
        pred_y = model.predict(x)                                             ❼
           bleu = bleu_metric.calculate_bleu_from_predictions(y, pred_y)      ❽
        loss_log.append(loss)                                                 ❾
        accuracy_log.append(accuracy)                                         ❾
        bleu_log.append(bleu)                                                 ❾

    return np.mean(loss_log), np.mean(accuracy_log), np.mean(bleu_log)

❶ 定义度量标准。

❷ 获取批次数量。

❸ 一次评估一个批次。

❹ 状态更新

❺ 获取输入和目标。

❻ 获取评估指标。

❼ 获取预测以计算 BLEU。

❽ 更新包含损失、准确度和 BLEU 指标的日志。

❾ 计算 BLEU 指标。

evaluate_model() 函数接受几个重要的参数:

  • model—我们定义的编码器-解码器模型。

  • en_inputs_raw—编码器输入(文本)。这将是一个字符串数组,其中每个字符串都是一个英文句子/短语。

  • de_inputs_raw—解码器输入(文本)。这将是一个字符串数组。它将包含每个德语翻译中除最后一个单词之外的所有单词。

  • de_labels_raw—解码器标签(文本)。这将是一个字符串数组。它将包含每个德语翻译中除第一个单词之外的所有单词。

  • de_vectorizer—解码器向量化器,用于将 decoder_labels_raw(文本)转换为标记 ID。

函数定义了一个我们之前定义的 BLEUMetric 对象。它定义了用于累积给定数据集中每个批次的损失、准确度和 BLEU 分数的占位符。然后它遍历每个数据批次,并执行以下操作:

  • 创建批处理输入,作为 en_inputs_raw 和 de_inputs_raw 中对应批次的数据

  • 使用 de_labels_raw 创建目标作为标记 ID

  • 使用批量输入和目标评估模型,以获取批量的损失和准确度分数

  • 使用真实目标和预测计算 BLEU 分数

  • 在先前定义的占位符中累积指标

最后,在模型遍历所有数据批次之后,它将返回均值损失、准确度和 BLEU 分数作为数据集的最终评估基准。

有了这个,我们开始定义训练循环(见下一个清单)。我们将定义一个名为 train_model() 的函数,它将执行以下四个核心任务:

  • 用训练数据训练模型。

  • 用训练数据评估模型。

  • 用验证数据评估模型。

  • 用测试数据评估模型。

图 11.10 使用自定义训练/评估循环训练模型

def train_model(model, vectorizer, train_df, valid_df, test_df, epochs, batch_size):
    """ Training the model and evaluating on validation/test sets """

    bleu_metric = BLEUMetric(de_vocabulary)                                ❶

    data_dict = prepare_data(train_df, valid_df, test_df)                  ❷
    shuffle_inds = None

    for epoch in range(epochs):

        bleu_log = []                                                      ❸
        accuracy_log = []                                                  ❸
        loss_log = []                                                      ❸

        (en_inputs_raw,de_inputs_raw,de_labels_raw), shuffle_inds  = 
➥ shuffle_data(                                                           ❹
            data_dict['train']['encoder_inputs'],
            data_dict['train']['decoder_inputs'],
            data_dict['train']['decoder_labels'],
            shuffle_inds
        )

        n_train_batches = en_inputs_raw.shape[0]//batch_size               ❺

        for i in range(n_train_batches):                                   ❻

            print("Training batch {}/{}".format(i+1, n_train_batches), 
➥ end='\r')                                                               ❼

            x = [                                                          ❽
                en_inputs_raw[i*batch_size:(i+1)*batch_size],  
                de_inputs_raw[i*batch_size:(i+1)*batch_size]
            ]
            y = vectorizer(de_labels_raw[i*batch_size:(i+1)*batch_size])   ❾

            model.train_on_batch(x, y)                                     ❿
            loss, accuracy = model.evaluate(x, y, verbose=0)               ⓫
            pred_y = model.predict(x)                                      ⓬
            bleu = bleu_metric.calculate_bleu_from_predictions(y, pred_y)  ⓭

            loss_log.append(loss)                                          ⓮
            accuracy_log.append(accuracy)                                  ⓮
            bleu_log.append(bleu)                                          ⓮

        val_en_inputs = data_dict['valid']['encoder_inputs']               ⓯
        val_de_inputs = data_dict['valid']['decoder_inputs']               ⓯
        val_de_labels = data_dict['valid']['decoder_labels']               ⓯

        val_loss, val_accuracy, val_bleu = evaluate_model(                 ⓰
            model, 
            vectorizer, 
            val_en_inputs, 
            val_de_inputs, 
            val_de_labels, 
            epochs, 
            batch_size
        )

        print("\nEpoch {}/{}".format(epoch+1, epochs))                     ⓱
        print(
            "\t(train) loss: {} - accuracy: {} - bleu: {}".format(
                np.mean(loss_log), np.mean(accuracy_log), np.mean(bleu_log)
            )
      )
      print(
          "\t(valid) loss: {} - accuracy: {} - bleu: {}".format(
              val_loss, val_accuracy, val_bleu
          )
      )

    test_en_inputs = data_dict['test']['encoder_inputs']
    test_de_inputs = data_dict['test']['decoder_inputs']
    test_de_labels = data_dict['test']['decoder_labels']

    test_loss, test_accuracy, test_bleu = evaluate_model(
            model, 
            vectorizer, 
            test_en_inputs, 
            test_de_inputs, 
            test_de_labels, 
            epochs, 
            batch_size
    )

    print("\n(test) loss: {} - accuracy: {} - bleu: {}".format(
        test_loss, test_accuracy, test_bleu)
    )

❶ 定义指标。

❷ 定义数据。

❸ 在每个时期开始时重置指标日志。

❹ 在每个时期开始时洗牌数据。

❺ 获取训练批次的数量。

❻ 一次训练一个批次。

❼ 状态更新

❽ 获取一批输入(英语和德语序列)。

❾ 获取一批目标(德语序列偏移 1)。

❿ 训练一个步骤。

⓫ 评估模型以获取指标。

⓬ 获取最终预测以计算 BLEU。

⓭ 计算 BLEU 指标。

⓮ 更新时期的日志记录的指标。

⓯ 定义验证数据。

⓰ 在验证数据上评估模型。

⓱ 打印每个时期的评估指标。

让我们分析列表 11.10 中的函数,以了解其行为。如果你退一步思考,它所做的只是调用我们之前定义的函数并显示结果。首先,它通过将数据准备(使用 prepare_data()函数)呈现为具有 train、valid 和 test 键的字典来准备数据。接下来,它经历了几个周期的训练。在每个周期中,它会对训练数据进行打乱,并逐批处理数据。对于每个训练批次,模型都会在该批次的数据上进行训练,并对相同批次进行评估。训练数据的评估日志用于计算训练性能,就像我们在 evaluate_model()函数中看到的那样。然后,在训练循环完成后,模型会在验证数据上进行评估。最后,在训练模型结束时,模型会在测试数据上进行评估。你可以像下面这样调用 train_model()函数:

epochs = 5
batch_size = 128

train_model(final_model, de_vectorizer, train_df, valid_df, test_df, 
➥ epochs, batch_size)

这将输出类似于以下的结果:

Evaluating batch 39/39
Epoch 1/5
    (train) loss: 1.7741597780050375 - accuracy: 0.2443966139585544 - 
➥ bleu: 0.0014343267864378607
    (valid) loss: 1.4453194752717629 - accuracy: 0.3318057709779495 - 
➥ bleu: 0.010740537197906803
Evaluating batch 39/39
...

Epoch 5/5
    (train) loss: 0.814081399104534 - accuracy: 0.5280381464041196 - 
➥ bleu: 0.1409178724874819
    (valid) loss: 0.8876287539800009 - accuracy: 0.514901713683055 - 
➥ bleu: 0.1285171513954398
Evaluating batch 39/39
(test) loss: 0.9077589313189188 - accuracy: 0.5076315150811122 - bleu: 
➥ 0.12664703414801345

注意 在一台配有 NVIDIA GeForce RTX 2070 8 GB 的 Intel Core i5 机器上,训练大约需要 4 分钟 10 秒来运行五个时期。

我们可以做出的第一个观察是,训练的方向是正确的。随着时间的推移,训练和验证指标都有所提高。模型从 24%的训练准确率和 0.001 的 BLEU 分数开始,最终达到了 52%的准确率和 0.14 的 BLEU 分数。在验证过程中,模型将准确率从 33%提高到了 51%,而 BLEU 分数则从 0.01 提高到了 0.12。与之前一样,让我们保存模型,以便以后可以在现实世界中使用。我们将保存模型以及词汇表:

## Save the model
os.makedirs('models', exist_ok=True)
tf.keras.models.save_model(final_model, os.path.join('models', 'seq2seq'))

import json
os.makedirs(os.path.join('models', 'seq2seq_vocab'), exist_ok=True)

# Save the vocabulary files
with open(os.path.join('models', 'seq2seq_vocab', 'en_vocab.json'), 'w') as f:
    json.dump(en_vocabulary, f)    
with open(os.path.join('models', 'seq2seq_vocab', 'de_vocab.json'), 'w') as f:
    json.dump(de_vocabulary, f)

在训练模型时,我们使用目标(即目标语言标记)作为解码器的输入。但是在翻译时,目标是未知的,因此无法这样做。因此,在接下来的部分中,我们在保持模型参数不变的同时修改我们训练的模型。

练习 3

你有以下用于训练模型的函数。这里,en_inputs_raw 代表编码器输入,de_inputs_raw 代表解码器输入,de_labels_raw 代表解码器标签:

for epoch in range(epochs):

    bleu_log = []                            

    n_train_batches = en_inputs_raw.shape[0]//batch_size       
    for i in range(n_train_batches):                        

        print("Training batch {}/{}".format(i+1, n_train_batches), end='\r')    

        x = [                                                   
            en_inputs_raw[i*batch_size:(i+1)*batch_size],  
            de_inputs_raw[i*batch_size:(i+1)*batch_size]
        ]
        y = vectorizer(de_labels_raw[i*batch_size:(i+1)*batch_size])

        model.train_on_batch(x, y)
        pred_y = model.predict(x)

        bleu_log.append(bleu_metric.calculate_bleu_from_predictions(y, pred_y)) 

    mean_bleu = np.mean(bleu_log)

如果给定时期的平均训练 BLEU 分数小于上一个时期,你想改变代码以停止训练。你会如何改变代码?

11.4 从训练到推理:定义推理模型

您已经训练了一个序列到序列的机器翻译模型,并计划使用它为一些看不见的英语短语生成德语翻译。它已经使用教师强制进行了训练,这意味着翻译中的单词已经被输入。您意识到这在推理期间是不可能的,因为任务本身就是生成翻译。因此,您打算使用原始模型的训练权重创建一个新的编码器-解码器模型。在此模型中,解码器递归地操作,其中它将其先前的预测作为下一个时间步的输入。解码器从 SOS 标记开始,并以此方式继续,直到输出 EOS 标记。

训练机器翻译器的最终目标是在现实世界中使用它将看不见的源语言句子(例如,英语)翻译成目标语言(例如,德语)。然而,与我们训练的大多数其他模型不同,我们不能直接从中使用它进行推理。需要额外的工作来填补使用训练模型进行推理之间的差距。在提出解决方案之前,让我们先了解潜在的问题。

在模型训练过程中,我们使用教师强制来提高模型的性能。在教师强制中,解码器被提供目标语言输入(例如,德语),并被要求在每个时间步预测序列中的下一个单词。这意味着训练模型依赖于两个输入:英语序列和德语序列。然而,在推理过程中,我们无法访问德语序列。我们的任务是为给定的英语序列生成这些德语序列。因此,我们需要重新利用我们训练过的模型,以便能够生成德语翻译,而不依赖于整个德语序列是否可用。

解决方法是保持编码器不变,并对解码器进行几处修改。我们将使我们的解码器成为一个递归解码器。这意味着解码器将使用其先前预测的单词作为下一个时间步的输入,直到达到序列的末尾(见图 11.4)。具体来说,我们执行以下操作。对于给定的英语序列

  • 通过将英语序列输入编码器来获取上下文向量。

  • 我们首先将 SOS 标记(x^d[0])(在代码中表示为 start_token)与上下文向量(sid[1])一起输入,并获取解码器的预测(*ŷ*d[1])和输出状态(so^d[1])。

  • 直到解码器的预测(^d[t] [+1])是 EOS(在代码中表示为 end_token)为止

    • 在下一个时间步(t + 1)中,将解码器的预测(d[t])和输出状态(sod[t])在时间 t 的输入(x^d[t] [+1])和初始状态(si^d[t] [+1])一起作为输入。

    • 在下一个时间步中获取解码器的预测(^d[t] [+1])和输出状态(so^d[t] [+1])。

11-04

图 11.4 使用序列到序列模型进行推理(即,从英语输入生成翻译)

为了实现这个目标,我们必须对已训练的模型进行两个重大的改变:

  • 将编码器和解码器分开作为单独的模型。

  • 更改解码器,以便将输入令牌和初始状态作为输入,并输出预测的令牌和下一个状态作为输出。

让我们看看如何在 TensorFlow 中实现这一点。首先,我们将加载刚保存的模型:

model = tf.keras.models.load_model(save_path)

很容易获得编码器模型,因为我们将编码器封装为最终模型中的嵌套模型。可以通过调用 get_layer()函数来取出它。

en_model = model.get_layer("encoder")

之后,我们定义两个输入来表示解码器的两个输入:

d_inp = tf.keras.Input(shape=(1,), dtype=tf.string, name='d_infer_input')
d_state_inp = tf.keras.Input(shape=(256,), name='d_infer_state')

正如我们之前讨论的那样,我们定义了两个输入:一个表示解码器的输入(d_inp),另一个表示解码器 GRU 层的状态输入(d_state_inp)。分析形状,d_inp 接受一堆字符串的数组,就像之前一样。d_state_inp 表示 GRU 模型的状态向量,具有 256 个特征维度。

在定义输入后,我们将重现我们在训练模型中构建解码器时遵循的所有步骤。但是,我们将从训练模型中获取层,而不是创建新的随机初始化层。特别是,我们将有以下层来流输输入:

  • 解码器的向量化层(产生 d_vectorized_out)

  • 解码器的嵌入层(产生 d_emb_out)

  • 解码器的 GRU 层(产生 d_gru_out)

  • 解码器的完全连接的隐藏层(产生 d_dense1_out)

  • 最后的预测层(产生 d_final_out)

我们引入了一个重要的改变来介绍 GRU 模型。请注意,我们将 return_sequences 设置为 False,以确保解码器 GRU 仅返回最后一个输出(即不返回输出序列)。换句话说,GRU 层的输出是一个[None,256]大小的张量。这有助于我们将输出形状匹配到先前定义的 d_state_inp,使递归模型更容易构建。此外,GRU 层以 d_state_inp 作为模型中的 initial_state。这样,我们就可以将输出状态向量作为递归输入馈送到解码器:

# Generate the vectorized output of inp
d_vectorizer = model.get_layer('d_vectorizer')    
d_vectorized_out = d_vectorizer(d_inp)

# Generate the embeddings from the vectorized input
d_emb_out = model.get_layer('d_embedding')(d_vectorized_out)

# Get the GRU layer
d_gru_layer = model.get_layer("d_gru")
# Since we generate one word at a time, we will not need the return_sequences
d_gru_layer.return_sequences = False
# Get the GRU out while using d_state_inp from earlier, as the initial state
d_gru_out = d_gru_layer(d_emb_out, initial_state=d_state_inp) 

# Get the dense output
d_dense1_out = model.get_layer("d_dense_1")(d_gru_out) 

# Get the final output
d_final_out = model.get_layer("d_dense_final")(d_dense1_out) 

是时候定义最终的解码器模型了:

de_model = tf.keras.models.Model(
    inputs=[d_inp, d_state_inp], outputs=[d_final_out, d_gru_out]
)

模型接受 d_inp 和 d_state_inp 作为输入,并产生 d_final_out(即最终预测)和 d_gru_out(即 GRU 输出状态)作为输出。最后,让我们退后一步,并将我们所做的工作封装在一个名为 get_inference_model()的单个函数中,如下面的示例所示。

列出 11.11 号栏目中的机器翻译模型递归推理模型定义

import tensorflow.keras.backend as K
K.clear_session()

def get_inference_model(save_path):
    """ Load the saved model and create an inference model from that """

    model = tf.keras.models.load_model(save_path)                        ❶

    en_model = model.get_layer("encoder")                                ❷

    d_inp = tf.keras.Input(
        shape=(1,), dtype=tf.string, name='d_infer_input'
    )                                                                    ❸
    d_state_inp = tf.keras.Input(shape=(256,), name='d_infer_state')     ❹

    d_vectorizer = model.get_layer('d_vectorizer')                       ❺
    d_vectorized_out = d_vectorizer(d_inp)                               ❺

    d_emb_out = model.get_layer('d_embedding')(d_vectorized_out)         ❻

    d_gru_layer = model.get_layer("d_gru")                               ❼
    d_gru_layer.return_sequences = False                                 ❽
    d_gru_out = d_gru_layer(d_emb_out, initial_state=d_state_inp)        ❾

    d_dense1_out = model.get_layer("d_dense_1")(d_gru_out)               ❿

    d_final_out = model.get_layer("d_dense_final")(d_dense1_out)         ⓫

    de_model = tf.keras.models.Model(
        inputs=[d_inp, d_state_inp], outputs=[d_final_out, d_gru_out]    ⓬
    )

    return en_model, de_model

❶ 加载已保存的训练模型。

❷ 通过调用 get_layer()函数从加载的模型中获取编码器模型。

❸ 定义新推理解码器的第一个输入,一个输入层,以一批字符串为输入。

❹ 定义新推理解码器的第二个输入,一个输入层,以初始状态作为解码器 GRU 的输入状态传递。

❺ 生成解码器的字符串输入的向量化输出。

❻ 从矢量化输入生成嵌入。

❼ 获得解码器的 GRU 层。

❽ 由于我们一次生成一个单词,我们将不需要 return_sequences

❾ 在使用先前的 d_state_inp 作为初始状态时获得 GRU 输出。

❿ 获得密集输出。

⓫ 获得最终输出。

⓬ 定义最终的解码器。

然后,我们将定义一个函数来加载我们刚刚保存的词汇表。我们使用 JSON 格式保存了词汇表,加载词汇表所需的所有操作就是调用打开的词汇文件的 json.load()

def get_vocabularies(save_dir):
    """ Load the vocabulary files from a given path"""

    with open(os.path.join(save_dir, 'en_vocab.json'), 'r') as f:
        en_vocabulary = json.load(f)

    with open(os.path.join(save_dir, 'de_vocab.json'), 'r') as f:
        de_vocabulary = json.load(f)

    return en_vocabulary, de_vocabulary

print("Loading vocabularies")
en_vocabulary, de_vocabulary = get_vocabularies(
    os.path.join('models', 'seq2seq_vocab')
)

print("Loading weights and generating the inference model")
en_model, de_model = get_inference_model(os.path.join('models', 'seq2seq'))
print("\tDone")

接下来,我们已经拥有了生成新翻译所需的一切。如前所述,我们将创建一个输入英文句子(用 sample_en_text 表示)到编码器生成上下文向量的过程/函数。接下来,使用 SOS 标记和上下文向量进行预测,以获得第一个德语标记预测和下一个状态输出。最后,递归地将解码器的输出作为解码器的输入,直到预测的标记为 EOS。以下清单使用我们刚刚构建的推断模型描述了这一功能。

清单 11.12 使用新推断模型生成翻译

def generate_new_translation(en_model, de_model, de_vocabulary, sample_en_text):
    """ Generate a new translation """

    start_token = 'sos'    

    print("Input: {}".format(sample_en_text))                              ❶

    d_state = en_model.predict(np.array([sample_en_text]))                 ❷

    de_word = start_token                                                  ❸

    de_translation = []                                                    ❹

    while de_word != end_token:                                            ❺

        de_pred, d_state = de_model.predict([np.array([de_word]), d_state])❻
        de_word = de_vocabulary[np.argmax(de_pred[0])]                     ❼
        de_translation.append(de_word)                                     ❽

    print("Translation: {}\n".format(' '.join(de_translation)))

❶ 打印输入。

❷ 获取解码器的初始状态。

❸ 解码器的第一个输入词将始终是起始标记(即其值为 sos)。

❹ 我们在这个列表中收集翻译。

❺ 一直预测,直到我们获得结束标记(即它的值为 eos)。

❻ 使用新状态覆盖先前的状态输入。

❼ 从预测的标记 ID 中获取实际的单词。

❽ 将其添加到翻译中。

让我们在数据集中对几个测试输入运行这个,看看我们得到了什么:

for i in range(5):
    sample_en_text = test_df["EN"].iloc[i]
    generate_new_translation(en_model, de_model, de_vocabulary, sample_en_text)

这将输出

Input: The pleasure's all mine.
Translation: die [UNK] [UNK] mir eos

Input: Tom was asking for it.
Translation: tom sprach es zu tun eos

Input: He denied having been involved in the affair.
Translation: er [UNK] sich auf das [UNK] [UNK] eos

Input: Is there something in particular that you want to drink?
Translation: gibt es etwas [UNK] wenn du etwas [UNK] eos

Input: Don't run. Walk slowly.
Translation: [UNK] nicht zu fuß eos

你可以将这些英文短语/句子与谷歌翻译进行比较,看看我们的模型是如何接近它们的。对于在相对简单和小的数据集上训练的模型,我们的模型表现非常好。翻译中存在 [UNK] 标记,因为在语料库中所有不太频繁的词都被替换为 [UNK]。因此,当模型不确定应该在某个位置填充哪个单词时,它可能会输出 [UNK]。

练习 4

您决定使用 LSTM 模型而不是 GRU 模型。正如您所知,LSTM 模型有两个状态:细胞状态和输出状态。您已经构建了编码器,现在正在构建解码器。您计划调整以下代码以使用 LSTM 模型:

d_inp = tf.keras.Input(shape=(1,), dtype=tf.string)       
d_state_inp = tf.keras.Input(shape=(256,))                

d_vectorized_out = de_vectorizer(d_inp)
d_emb_out = tf.keras.layers.Embedding(de_vocab+2, 128, mask_zero=True)(d_vectorized_out)

d_gru_out = tf.keras.layers.GRU(256)(d_emb_out, initial_state=d_state_inp)

d_final_out = tf.keras.layers.Dense(
    de_vocab+2, activation='softmax'
)(d_gru_out)                    

de_model = tf.keras.models.Model(
    inputs=[d_inp, d_state_inp], outputs=[d_final_out, d_gru_out]
)

如果你在 LSTM 层中设置 return_state=True 并在某些兼容输入 x 上调用它,则输出如下

lstm_out, state_h, state_c = tf.keras.layers.LSTM(256, return_state=True)(x)

state_hstate_c 分别代表输出状态和细胞状态。

我们已经使用序列到序列架构训练了一个机器翻译模型。在下一章中,我们将探讨如何使用一种称为注意力的技术进一步改进这个模型。

摘要

  • 编码器-解码器模式在序列到序列任务中很常见,比如机器翻译。

  • 编码器接收源语言输入并生成上下文向量。

  • 上下文向量由解码器用于生成目标语言输出(即翻译)。

  • tf.keras.layers.experimental.preprocessing.TextVectorization 层允许您将标记化(即将字符串转换为标记列表,然后转换为标记 ID)集成到模型中。这使得模型可以接受字符串而不是数值。

  • 在序列到序列任务上训练模型时,可以使用教师强制训练:

    • 在教师强制训练中,编码器像往常一样接收源语言输入并生成上下文向量。然后,解码器消耗并预测翻译中的单词。换句话说,解码器在训练时以这样的方式进行,以便在给定翻译中的前一个单词(或多个单词)的情况下预测下一个单词。
  • 机器翻译模型产生的翻译质量使用 BLEU 分数进行衡量:

    • BLEU 分数使用修改后的精确度指标以及测量翻译的不同 n-gram 上的精确度来计算得分。
  • 使用教师强制训练模型后,需要定义一个单独的推理模型,使用训练后的权重:

    • 此推理模型具有相同的编码器,但解码器将以先前预测的单词作为下一步的输入,并递归预测单词,直到满足预定义的结束条件为止。

练习答案

练习 1

def vocab_size(ser):

    cnt = Counter(ser.sum())
    return len(cnt)

练习 2

# The decoder
en_repeat_out = tf.keras.layers.RepeatVector(de_seq_length)(en_gru_out)
d_gru_layer = tf.keras.layers.GRU(256, return_sequences=True, name='d_gru')
d_gru_out = d_gru_layer(en_repeat_out, initial_state=gru_out)
d_dense_layer_1 = tf.keras.layers.Dense(512, activation='relu', name='d_dense_1')
d_dense1_out = d_dense_layer_1(d_gru_out)
d_dense_layer_final = tf.keras.layers.Dense(
    de_vocab+2, activation='softmax', name='d_dense_final'
)
d_final_out = d_dense_layer_final(d_dense1_out)

# Define the full model
model = tf.keras.models.Model(
    inputs=inp, outputs=d_final_out, name='final_seq2seq'
)

练习 3

prev_bleu = None

for epoch in range(epochs):

    bleu_log = []  

    n_train_batches = en_inputs_raw.shape[0]//batch_size

    for i in range(n_train_batches):

        print("Training batch {}/{}".format(i+1, n_train_batches), end='\r')

        x = [         
            en_inputs_raw[i*batch_size:(i+1)*batch_size],  
            de_inputs_raw[i*batch_size:(i+1)*batch_size]
        ]
        y = vectorizer(de_labels_raw[i*batch_size:(i+1)*batch_size])
        model.train_on_batch(x, y)
        pred_y = model.predict(x)

        bleu_log.append(bleu_metric.calculate_bleu_from_predictions(y, pred_y)) 

    mean_bleu = np.mean(bleu_log)

    # The termination criteria
    if prev_bleu and prev_bleu > mean_bleu:
        break

    prev_bleu = mean_bleu

练习 4

d_inp = tf.keras.Input(shape=(1,), dtype=tf.string)       
d_state_h_inp = tf.keras.Input(shape=(256,))                
d_state_c_inp = tf.keras.Input(shape=(256,))                

d_vectorized_out = de_vectorizer(d_inp)                                         

d_emb_out = tf.keras.layers.Embedding(
    de_vocab+2, 128, mask_zero=True
)(d_vectorized_out)                    

d_lstm_out, d_state_h, d_state_c = tf.keras.layers.LSTM(
 256, return_state=True
)(d_emb_out, initial_state=[d_state_h_inp, d_state_c_inp])

d_final_out = tf.keras.layers.Dense(
 de_vocab+2, activation='softmax'
)(d_lstm_out) 

de_model = tf.keras.models.Model(
    inputs=[d_inp, d_state_h_inp, d_state_c_inp], 
    outputs=[d_final_out, d_state_h, d_state_c]              
)
de_model.summary()

第十二章:序列到序列学习:第二部分

本章内容包括

  • 实现序列到序列模型的注意力机制

  • 从注意力层生成可视化图表以获取模型洞察

在上一章中,我们构建了一个英语到德语的机器翻译器。机器学习模型是一个序列到序列模型,可以学习将任意长的序列映射到其他任意长的序列。它有两个主要组成部分:编码器和解码器。为了达到这个目的,我们首先下载了一个机器翻译数据集,检查了该数据集的结构,并对其进行了一些处理(例如,添加 SOS 和 EOS 标记)以准备好用于模型。接下来,我们使用标准的 Keras 层定义了机器翻译模型。这个模型的一个特殊特点是它能够接受原始字符串并在内部将其转换为数字表示。为了实现这一点,我们使用了 Keras 的 TextVectorization 层。当模型被定义后,我们使用我们处理的数据集进行了训练,并对两个指标进行了评估:生成的序列的每个词的准确率和 BLEU。BLEU 是一个比准确率更高级的指标,它模拟了人类如何评估翻译质量。训练模型时,我们使用了一种称为教师强迫的技术。当使用教师强迫时,我们将目标翻译的解码器提供给目标翻译偏移了 1。这意味着解码器根据前一个词预测目标序列中的下一个词,而不是在不了解目标序列的情况下尝试预测整个目标序列。这导致了更好的性能。最后,我们不得不重新定义我们的模型以适应推断。这是因为我们必须修改解码器,使其一次预测一个词而不是一个序列。这样,我们可以在推断时创建一个递归解码器,该解码器预测一个词并将预测的词作为输入预测序列中的下一个词。

在这一章中,我们将探讨提高模型准确性的方法。为此,我们将使用注意力机制。没有注意力机制,机器翻译模型依赖于处理输入序列后产生的最后输出。通过注意力机制,模型能够在生成翻译过程中从所有时间步骤(处理输入序列时)获得丰富的表示。最后,我们将通过可视化注意力机制来总结本章,以洞察模型在翻译过程中如何关注提供给它的单词。

本章中我们进行的数据和处理与上一章完全相同。因此,我们不会详细讨论数据。您已经提供了在笔记本中加载和处理数据所需的所有代码。但让我们回顾一下我们执行的关键步骤:

  • www.manythings.org/anki/deu-eng.zip手动下载数据集。

  • 数据以制表符分隔的格式和<德语短语><制表符><英语短语><制表符><属性>的格式呈现。我们特别关注记录中的前两个制表符分隔的值。我们将预测给定英语短语的德语短语。

  • 我们从数据集中随机抽样了 50,000 个数据点,并使用其中的 5,000 个(即 10%)作为验证数据,另外的 5,000 个(即 10%)作为测试数据。

  • 我们为每个德语短语添加了一个开始标记(例如,SOS)和一个结束标记(例如,EOS)。这是一个重要的预处理步骤,因为这帮助我们在推断时从我们的递归解码器中递归地推断单词(即,提供 SOS 作为初始种子并继续预测直到模型输出 EOS 或达到最大长度)。

  • 我们查看词汇量和序列长度的摘要统计数据,因为这些超参数对我们的 TextVectorization 层非常重要(该层可以在 tensorflow.keras.layers.experimental.preprocessing.TextVectorization*中找到)。

  • 词汇量设置为语料库中出现次数超过 10 次的唯一单词数,序列长度设置为两种语言的 99%分位数(再加上 5 的缓冲区)。

12.1 着眼于过去:通过注意力改进我们的模型

您有一个可工作的翻译器原型,但仍认为可以通过使用注意力来提高准确性。注意力通过允许解码器查看编码器在整个输入序列上产生的所有输出,为解码器提供了更丰富的输出。您将修改以前实施的模型以包含一个注意力层,该层接受编码器的所有输出(每个时间步长一个)并为每个解码器步骤产生一系列输出,这些输出将与解码器产生的标准输出串联起来。

我们有一个可以从英语翻译成德语的工作机器翻译模型。通过使用所谓的Bahdanau 注意力可以进一步提高该模型的性能。 Bahdanau 注意力是由 Bahdanau 等人在论文“Neural Machine Translation by Jointly Learning to Align and Translate”中介绍的(arxiv.org/pdf/1409.0473.pdf)。我们已经在第五章中讨论过自注意力。这两种注意机制的基本原理是相同的。它们都允许模型以更好地理解语言为目的,在序列中获取历史/未来输入的丰富表示。让我们看看如何将注意力机制与我们现有的编码器-解码器模型结合起来。

注意机制为每个解码器时间步产生一个输出,类似于解码器的 GRU 模型在每个时间步产生一个输出。注意力输出与解码器的 GRU 输出相结合,并馈送到解码器中的后续隐藏层。解码器的每个时间步产生的注意力输出结合了来自所有时间步的编码器输出,这为解码器提供了有关英语输入序列的宝贵信息。注意力层被允许以不同的方式混合编码器输出,以产生每个解码器时间步的输出,具体取决于解码器模型在给定时刻正在处理的翻译部分。您应该能够看到注意力机制有多强大。以前,上下文向量是编码器向解码器可访问的唯一输入。这是一个巨大的性能瓶颈,因为使用小型向量编码句子中存在的所有信息对编码器来说是不切实际的。

让我们更深入地探讨一下,在计算注意力输出时发生的具体计算。假设在位置j(1 < j < T[e])处的编码器输出被表示为h[j],并且在时间i(1 < i < T[d])的解码器 RNN 输出状态被表示为s[i];那么第i次解码步骤的注意力输出c[i]由以下计算得出

e[ij] = v^T tanh(s[i -1] W + h[j]U)

12_00a

12_00b

在这里,W、U 和 v 是权重矩阵(像神经网络权重一样随机初始化)。它们的形状根据隐藏表示 s 和 h 的维度进行定义,这将很快详细讨论。总之,对于给定的解码器位置,这组方程

  • 使用小型全连接网络计算能量值,表示每个编码器输出对于该解码步骤的重要程度

  • 将能量归一化为表示编码器步骤上的概率分布

  • 使用概率分布计算编码器输出的加权和

12.1.1 在 TensorFlow 中实现 Bahdanau 注意力

不幸的是,TensorFlow 没有内置的层可供我们在模型中直接使用以启用注意力机制。因此,我们将使用 Keras 子类化 API 实现一个 Attention 层。我们将称之为 DecoderRNNAttentionWrapper,并且必须实现以下函数:

  • init—定义层在能够正确操作之前需要进行的各种初始化

  • build()—定义与计算相关联的参数(例如,可训练权重)及其形状

  • 调用(call())—定义应由该层进行的计算和最终输出

init() 函数用于初始化层,包括需要正确运行的任何属性。在这种情况下,我们的 DecoderRNNAttentionWrapper 接受一个 cell_fn 作为参数。cell_fn 需要是一个实现了 tf.keras.layers.AbstractRNNCell 接口的 Keras 层对象(mng.bz/pO18)。有几个选项,例如 tf.keras.layers.GRUCell、tf.keras.layers.LSTMCell 和 tf.keras.layers.RNNCell。在这个例子中,我们将使用 tf.keras.layers.GRUCell。

tf.keras.layers.GRUCell 和 tf.keras.layers.GRU 之间的区别

GRUCell 可以看作是 GRU 层的一个抽象,它包含了 RNN 层中最简化的计算。给定一个输入和上一个状态,它计算下一个输出和下一个状态。这是控制 RNN 层的最原始的计算:

output, next_state = tf.keras.layers.GRUCell(input, state)

换句话说,GRUCell 封装了计算输入序列中单个时间步所需的计算。GRU 层是 GRUCell 的完全实现,可以处理整个序列。此外,GRU 层还提供了 return_state 和 return_sequence 等选项来控制 GRU 层产生的输出。

简言之,GRU 层提供了便利的处理输入序列的方式,而 GRUCell 则暴露了更细粒度的实现细节,允许处理序列中的单个时间步。

在这里,我们决定使用 GRU,因为 GRU 模型比 LSTM 简单得多(意味着减少了训练时间),但在 NLP 任务上实现了大致相似的结果:

def __init__(self, cell_fn, units, **kwargs):
    self._cell_fn = cell_fn
    self.units = units
    super(DecoderRNNAttentionWrapper, self).__init__(**kwargs)

接下来,定义了 build() 函数。build 函数声明了用于注意力计算的三个权重矩阵:W、U 和 v。参数 input_shape 包含了输入的形状。我们的输入将是一个包含编码器输出和解码器 RNN 输入的元组:

def build(self, input_shape):

    self.W_a = self.add_weight(
        name='W_a',
        shape=tf.TensorShape((input_shape[0][2], input_shape[0][2])),
        initializer='uniform',
        trainable=True
    )

    self.U_a = self.add_weight(
        name='U_a',
        shape=tf.TensorShape((self._cell_fn.units, self._cell_fn.units)),
        initializer='uniform',
        trainable=True
    )

    self.V_a = self.add_weight(
        name='V_a',
        shape=tf.TensorShape((input_shape[0][2], 1)),
        initializer='uniform',
        trainable=True
    )

    super(DecoderRNNAttentionWrapper, self).build(input_shape)

注意权重定义中最重要的参数是 shape 参数。我们定义它们的形状为

  • W_a(表示 W)的形状为[

  • U_a(表示 U)的形状为[

  • V_a(表示 v)的形状为[

这里,

列表 12.1 解码器 RNN 注意力包装中的注意力计算。

def call(self, inputs, initial_state, training=False):

    def _step(inputs, states):
        """ Step function for computing energy for a single decoder state
        inputs: (batchsize * de_in_dim)
        states: [(batchsize * de_latent_dim)]
        """

        encoder_full_seq = states[-1]                                   ❶

        W_a_dot_h = K.dot(encoder_outputs, self.W_a)                    ❷

        U_a_dot_s = K.expand_dims(K.dot(states[0], self.U_a), 1)        ❸

        Wh_plus_Us = K.tanh(W_a_dot_h + U_a_dot_s)                      ❹

        e_i = K.squeeze(K.dot(Wh_plus_Us, self.V_a), axis=-1)           ❺
        a_i = K.softmax(e_i)                                            ❺

        c_i = K.sum(encoder_outputs * K.expand_dims(a_i, -1), axis=1)   ❻

        s, states = self._cell_fn(K.concatenate([inputs, c_i], axis=-1), 
➥ states)                                                              ❼

        return (s, a_i), states

   """ Computing outputs """

   encoder_outputs, decoder_inputs = inputs                             ❽

   _, attn_outputs, _ = K.rnn(
        step_function=_step, inputs=decoder_inputs, 
➥ initial_states=[initial_state], constants=[encoder_outputs]          ❾
   )

   # attn_out => (batch_size, de_seq_len, de_hidden_size)
   # attn_energy => (batch_size, de_seq_len, en_seq_len)
   attn_out, attn_energy = attn_outputs                                 ❿

   return attn_out, attn_energy

❶ 在调用 _step 函数时,我们将 encoder_outputs 作为常量传递,因为我们需要访问完整的编码器序列。在 _step 函数内部访问它。

❷ 计算 S.Wa,其中 S 表示所有编码器输出,S=[s0, s1, ..., si]。这产生一个大小为[batch size, en_seq_len, hidden size]的输出。

❸ 计算 hj.Ua,其中 hj 表示第 j 个解码步骤。这产生一个大小为[batch_size, 1, hidden size]的输出。

❹ 计算 tanh(S.Wa + hj.Ua)。这产生一个大小为[batch_size, en_seq_len, hidden size]的输出。

❺ 计算能量并对其进行归一化。产生一个大小为[batch_size, en_seq_len]的输出。

❻ 将最终注意力输出(c_i)计算为 h_j(对所有 j)的加权和,其中权重由 a_i 表示。产生一个大小为[batch_size, hidden_size]的输出。

❼ 将当前输入和 c_i 连接起来,并将其馈送到解码器 RNN 以获得输出。

❽ 注意力层的输入是编码器输出和解码器 RNN 输入。

❾ K.rnn()函数对解码器输入中的每个输入执行 _step()函数,以生成所有解码步骤的注意力输出。

❿ 最终输出有两部分:一个大小为[batch size, de_seq_len, hidden size]的注意力输出,以及一个大小为[batch dize, de_seq_len, en_seq_len]的注意力能量输出。

让我们揭开这个函数中做了什么。这个层的输入是一个两个元素的可迭代对象:编码器输出序列(encoder_outputs)和解码器 RNN 输入序列(decoder_inputs)。接下来,我们使用 Keras 的一个特殊后端函数 K.rnn()(mng.bz/OoPR)来迭代这些输入,同时计算所需的最终输出。在我们的例子中,它被称为

_, attn_outputs, _ = K.rnn(
        step_function=_step, inputs=decoder_inputs, initial_states=[initial_state], constants=[encoder_outputs],
   )

在这里,它将 step_function 应用于输入张量的每个时间步切片。例如,decoder_inputs 是一个 [, , ] 大小的输入。然后,K.rnn() 函数将 step_function 应用于每个 [, ] 输出,重复 次。此函数执行的更新是递归更新,意味着它接受一个初始状态并生成一个新状态,直到达到输入序列的结尾。为此,initial_states 提供了起始状态。最后,我们将 encoder_outputs 作为常数传递给 step_function。这非常重要,因为我们需要计算每个解码步骤的注意力时编码器隐藏输出的完整序列。在 step_function 中,常数被附加到状态参数的值之后。因此,您可以将 encoder_outputs 访问为 states 的最后一个元素。

_step 函数执行我们在列表 12.1 中概述的计算,用于单个解码器时间步骤。它接受输入(原始输入的时间维度的一个切片)和状态(在 K.rnn() 函数中用 initial_states 值初始化)。接下来,使用这两个实体计算单个时间步骤的归一化注意力能量(即 α[ij])(a_i)。随后,计算 c_i,这是由 a_i 加权的编码器输出的加权和。然后,它使用当前输入和状态更新 cell_fn(即 GRUCell)。注意,cell_fn 的当前输入是解码器输入和 c_i 的连接(即编码器输入的加权和)。cell 函数然后输出输出状态以及下一个状态。我们返回此信息。换句话说,_step() 函数输出该时间步的输出(即解码器 RNN 输出和计算编码器输入加权和的归一化能量的元组)以及解码器 RNN 的下一个状态。

最后,您可以使用 K.rnn() 函数获得所有解码器时间步骤的 _step 函数的完整输出,如所示。我们只对输出本身感兴趣(由 attn_outputs 表示),将忽略函数输出的其他内容。

当调用 K.rnn() 函数时,该函数输出以下输出:

  • last_output——step_function 在序列结束时产生的最后输出

  • outputs——step_function 产生的所有输出

  • new_states——step_function 在序列结束时产生的最后状态

最后,call() 函数产生两个输出:

  • attn_out——保存所有解码步骤的注意力输出

  • attn_energy——为一批数据提供归一化能量值,其中一个示例的能量矩阵包含所有编码器时间步的能量值,用于每个解码器时间步

我们已经讨论了 DecoderRNNAttentionWrapper 层的最重要的功能。如果你想查看 DecoderRNNAttentionWrapper 的完整子类实现,请参考 Ch11/11.1_seq2seq_machine_translation.ipynb 中的代码。

12.1.2 定义最终模型

在定义最终模型时,get_vectorizer() 和 get_encoder() 函数保持与上一节中显示的相同。所有需要的修改都需要在解码器中进行。因此,让我们定义一个函数,get_final_seq2seq_model_with_attention(),它提供了具有 Bahdanau 注意力的解码器,如下一个列表所示。

列表 12.2 定义具有注意力的最终序列到序列模型

def get_final_seq2seq_model_with_attention(n_vocab, encoder, vectorizer):
    """ Define the final encoder-decoder model """

    e_inp = tf.keras.Input(shape=(1,), dtype=tf.string, name='e_input_final')          
    fwd_state, bwd_state, en_states = encoder(e_inp)                              ❶

    d_inp = tf.keras.Input(shape=(1,), dtype=tf.string, name='d_input')           ❷

    d_vectorized_out = vectorizer(d_inp)                                          ❸

    d_emb_layer = tf.keras.layers.Embedding(
        n_vocab+2, 128, mask_zero=True, name='d_embedding'                        ❹
    )    
    d_emb_out = d_emb_layer(d_vectorized_out)                                     ❹

    d_init_state = tf.keras.layers.Concatenate(axis=-1)([fwd_state, bwd_state])   ❺

    gru_cell = tf.keras.layers.GRUCell(256)                                       ❻
    attn_out, _  = DecoderRNNAttentionWrapper(
        cell_fn=gru_cell, units=512, name="d_attention"
    )([en_states, d_emb_out], initial_state=d_init_state)                         ❼

    d_dense_layer_1 = tf.keras.layers.Dense(512, activation='relu', name='d_dense_1')
    d_dense1_out = d_dense_layer_1(attn_out)                                      ❽

    d_final_layer = tf.keras.layers.Dense(
        n_vocab+2, activation='softmax', name='d_dense_final'
    )
    d_final_out = d_final_layer(d_dense1_out)                                     ❽

    seq2seq = tf.keras.models.Model(
        inputs=[e_inp, d_inp], outputs=d_final_out,                               ❾
        name='final_seq2seq_with_attention'
    )

    return seq2seq

❶ 获取所有时间步长的编码器输出。

❷ 输入的形状为(None,1),接受字符串数组。

❸ 将数据向量化(分配标记 ID)。

❹ 定义一个嵌入层,将 ID 转换为单词向量。

❺ 将解码器的初始状态定义为最后一个正向和反向编码器状态的串联。

❻ 定义一个 GRUCell,然后将其用于注意力层。

❼ 获取注意力输出。将 GRUCell 作为 cell_fn 传递,其中输入是 en_states(即所有编码器状态)和 d_emb_out(解码器 RNN 的输入)。

❽ 定义中间和最终 Dense 层的输出。

❾ 定义一个模型,以编码器和解码器输入作为输入,并输出最终预测(d_final_out)。

我们已经做了所有的艰苦工作。因此,对解码器的更改可以总结为两行代码:

    gru_cell = tf.keras.layers.GRUCell(256)
    attn_out, _  = DecoderRNNAttentionWrapper(
        cell_fn=gru_cell, units=512, name="d_attention"
    )(
        [en_states, d_emb_out], initial_state=d_init_state
    )

我们首先定义一个具有 256 个隐藏单元的 GRUCell 对象。然后我们定义了 DecoderRNNAttentionWrapper,其中 cell_fn 是我们定义的 GRUCell,而单位设置为 512。DecoderRNNAttentionWrapper 中的单位定义了权重和中间注意力输出的维度。我们传递 en_states(即编码器输出序列)和 d_emb_out(即传递给 RNN 的解码器输入序列),并将初始状态设置为编码器的最终状态(即 d_init_state)。

接下来,和以前一样,我们必须定义一个 get_vectorizer() 函数(请参见下一个列表),以获取英语/德语矢量化器。

列表 12.3 定义用于编码器-解码器模型的 TextVectorizers

def get_vectorizer(
    corpus, n_vocab, max_length=None, return_vocabulary=True, name=None
):

    """ Return a text vectorization layer or a model """

    inp = tf.keras.Input(shape=(1,), dtype=tf.string, name='encoder_input')❶

    vectorize_layer = tf.keras.layers.experimental.preprocessing.TextVectorization(
        max_tokens=n_vocab+2,                                              ❷
        output_mode='int',
        output_sequence_length=max_length,        
        name=name
    )

    vectorize_layer.adapt(corpus)                                          ❸

    vectorized_out = vectorize_layer(inp)                                  ❹

    if not return_vocabulary: 
        return tf.keras.models.Model(inputs=inp, outputs=vectorized_out)   ❺
    else:
        return tf.keras.models.Model(
            inputs=inp, outputs=vectorized_out                             ❻
        ), vectorize_layer.get_vocabulary() 

❶ 定义一个输入层,接受字符串列表(或字符串数组)。

❷ 在定义词汇大小时,有两个特殊标记,(填充)和 '[UNK]'(OOV 标记),会自动添加。

❸ 在数据上拟合 vectorizer 层。

❹ 获取输入数据的标记 ID。

❺ 仅返回模型,该模型接受字符串数组并输出标记 ID 的张量。

❻ 除了模型外,返回词汇表。

在下一个列表中显示的 get_encoder() 函数构建了编码器。由于这些已经详细讨论过,因此不会在这里重复。

列表 12.4 返回编码器的函数

def get_encoder(n_vocab, vectorizer):
    """ Define the encoder of the seq2seq model"""

    inp = tf.keras.Input(shape=(1,), dtype=tf.string, name='e_input')  ❶

    vectorized_out = vectorizer(inp)                                   ❷

    emb_layer = tf.keras.layers.Embedding(
        n_vocab+2, 128, mask_zero=True, name='e_embedding'             ❸
    )

    emb_out = emb_layer(vectorized_out)                                ❹

    gru_layer = tf.keras.layers.Bidirectional(
        tf.keras.layers.GRU(128, name='e_gru'),                        ❺
        name='e_bidirectional_gru'
    )

    gru_out = gru_layer(emb_out)                                       ❻
    encoder = tf.keras.models.Model(
        inputs=inp, outputs=gru_out, name='encoder'
    )                                                                  ❼

    return encoder

❶ 输入的形状为(None,1),接受字符串数组。

❷ 对数据进行向量化(分配令牌 ID)。

❸ 定义一个嵌入层来将 ID 转换为单词向量。

❹ 获取令牌 ID 的嵌入。

❺ 定义一个双向 GRU 层。编码器同时查看英文文本(即输入)的前向和后向;这将导致更好的性能。

❻ 获取 GRU 层的输出(模型返回的最后一个输出状态向量)。

❼ 定义编码器模型;这将接受一个字符串列表/数组作为输入,并将 GRU 模型的最后输出状态作为输出返回。

作为最后一步,我们定义了最终模型,并使用与之前模型相同的规格进行编译:

# Get the English vectorizer/vocabulary
en_vectorizer, en_vocabulary = 
➥ get_vectorizer(np.array(train_df["EN"].tolist()), en_vocab, max_length=en_seq_length, name='e_vectorizer')
# Get the German vectorizer/vocabulary
de_vectorizer, de_vocabulary = 
➥ get_vectorizer(np.array(train_df["DE"].tolist()), de_vocab, 
➥ max_length=de_seq_length-1, name='d_vectorizer')

# Define the final model with attention
encoder = get_encoder_with_attention(en_vocab, en_vectorizer)
final_model_with_attention = 
➥ get_final_seq2seq_model_with_attention(de_vocab, encoder, de_vectorizer)

# Compile the model
final_model_with_attention.compile(
    loss='sparse_categorical_crossentropy', 
    optimizer='adam', 
    metrics=['accuracy']
)

12.1.3 训练模型

训练模型非常简单,因为它与以前的情况相同。我们所需要做的就是调用 train_model() 函数,并使用参数模型(一个要训练/评估的 Keras 模型)、矢量化器(将令牌 ID 转换为文本的目标语言矢量化器)、train_df(训练数据)、valid_df(验证数据)、test_df(测试数据)、epochs(表示模型需要训练多少个周期的整数)和 batch_size(训练/评估批次的大小):

epochs = 5
batch_size = 128

train_model(final_model_with_attention, de_vectorizer, train_df, valid_df, 
➥ test_df, epochs, batch_size)

这将输出

Evaluating batch 39/39
Epoch 1/5
    (train) loss: 2.096887740951318 - accuracy: 0.6887444907274002 - 
➥ bleu: 0.00020170408678925458
    (valid) loss: 1.5872839291890461 - accuracy: 0.7375801282051282 - 
➥ bleu: 0.002304922518160425
...

Evaluating batch 39/39
Epoch 5/5
    (train) loss: 0.7739567615282841 - accuracy: 0.8378756006176655 - 
➥ bleu: 0.20010080750506093
    (valid) loss: 0.8180131682982812 - accuracy: 0.837830534348121 - 
➥ bleu: 0.20100039279462362
Evaluating batch 39/39
(test) loss: 0.8390972828253721 - accuracy: 0.8342147454237326 - bleu: 
➥ 0.19782372616582572

与我们之前拥有的最后模型相比,这是相当大的改进。我们的验证和测试 BLEU 分数几乎翻了一番。所有这些都可能是因为我们引入了注意机制来缓解编码器-解码器模型中的巨大性能瓶颈。

注意,在一台配备 NVIDIA GeForce RTX 2070 8 GB 的英特尔 Core i5 机器上,训练大约需要五分钟来运行五个周期。

最后,为了以后使用,我们保存了训练好的模型,以及词汇表:

## Save the model
os.makedirs('models', exist_ok=True)
tf.keras.models.save_model(final_model_with_attention, 
➥ os.path.join('models', 'seq2seq_attention'))

# Save the vocabulary
import json
os.makedirs(
    os.path.join('models', 'seq2seq_attention_vocab'), exist_ok=True
)
with open(os.path.join('models', 'seq2seq_attention_vocab', 
➥ 'de_vocab.json'), 'w') as f:
    json.dump(de_vocabulary, f)

with open(os.path.join('models', 'seq2seq_attention_vocab', 
➥ 'en_vocab.json'), 'w') as f:
    json.dump(en_vocabulary, f)

英德翻译的最新结果

了解我们模型的位置的一种方法是将其与在英德翻译上取得的最新成果进行比较。在编写本书时的 2021 年,模型已经达到了 0.3514 的 BLEU 分数。该模型在文章“Lessons on Parameter Sharing across Layers in Transformers”中由高瀬等人介绍(arxiv.org/pdf/2104.06022v1.pdf)。

这不应被视为与我们模型的确切比较,因为基准模型通常是在 WMT 英德数据集上训练的(nlp.stanford.edu/projects/nmt/),这是一个更大更复杂的数据集。但是,考虑到我们有一个相对简单的模型,并且没有特殊的训练时间优化,0.1978 是一个不错的分数。

有了这个,我们将讨论如何可视化注意权重以查看模型在解码输入时使用的注意模式。

练习 1

您发明了一种新型的注意力机制称为 AttentionX。与 Bahdanau 注意力不同,这种注意力机制需要编码器输入和解码器的 RNN 输出才能产生最终输出。全连接层使用这个最终输出而不是常规的解码器 RNN 输出。在名为 AttentionX 的层中实现了这种新的注意力机制。对于编码器输入 x 和解码器的 RNN 输出 y,可以调用它:

z = AttentionX()([x, y])

最终输出 z 是一个大小为 [<批次大小>,<解码器时间步数>,<隐藏大小>] 的输出。您将如何更改以下解码器以使用这种新的注意力机制?

e_inp = tf.keras.Input(shape=(1,), dtype=tf.string, name='e_input_final')          
fwd_state, bwd_state, en_states = encoder(e_inp)      

d_inp = tf.keras.Input(shape=(1,), dtype=tf.string, name='d_input')     

d_vectorized_out = vectorizer(d_inp)                                

d_emb_layer = tf.keras.layers.Embedding(
    n_vocab+2, 128, mask_zero=True, name='d_embedding'         
)    
d_emb_out = d_emb_layer(d_vectorized_out)     

d_init_state = tf.keras.layers.Concatenate(axis=-1)([fwd_state, bwd_state]) 

gru_out = tf.keras.layers.GRU(256, return_sequences=True)(
    d_emb_out, initial_state=d_init_state
)           

d_dense_layer_1 = tf.keras.layers.Dense(512, activation='relu', name='d_dense_1')
d_dense1_out = d_dense_layer_1(attn_out)         

d_final_layer = tf.keras.layers.Dense(
    n_vocab+2, activation='softmax', name='d_dense_final'
)
d_final_out = d_final_layer(d_dense1_out)   

12.2 可视化注意力

您已经确定基于注意力机制的模型比没有注意力的模型效果更好。但是您还是有怀疑,并想要了解注意力层是否产生有意义的输出。为此,您将可视化模型为几个输入序列生成的注意力模式。

除了性能之外,注意力机制带给模型的另一个优势就是它带来的可解释性。注意力机制的中间输出之一——规范化能量值,可以提供有力的洞见。由于规范化能量值表示每个编码器输出在每个解码时步骤中对解码/翻译的贡献程度,因此它可以用于生成热力图,突出显示与特定德语单词对应的最重要的英语单词。

如果我们回到 DecoderRNNAttentionWrapper,调用它对某个输入进行操作,将会产生两个输出:

  • 解码器 RNN 输出序列

  • 每个解码器位置对所有编码器位置的 alpha(即规范化能量值)

我们要获取的是第二个输出。那个张量拥有解锁注意力机制所带来的强大可解释性的关键。

我们编写一个名为 attention_visualizer() 的函数,该函数将加载保存的模型,并输出模型的预测结果以及有助于生成最终热力图的注意力能量。在这个函数中,我们将加载模型并使用训练后的层来重现解码器的中间和最终输出,就像下一个列表中所示的那样。这类似于我们如何重现模型中的各个步骤,从训练好的模型中创建推理模型一样。

代码清单 12.5 可视化输入文本中的注意力模式的模型

def attention_visualizer(save_path):
    """ Define the attention visualizer model """

    model = tf.keras.models.load_model(save_path)                  ❶

    e_inp = tf.keras.Input(
        shape=(1,), dtype=tf.string, name='e_input_final'
    )                                                              ❷
    en_model = model.get_layer("encoder")                          ❷
    fwd_state, bwd_state, en_states = en_model(e_inp)              ❷

    e_vec_out = en_model.get_layer("e_vectorizer")(e_inp)          ❸

    d_inp = tf.keras.Input(
        shape=(1,), dtype=tf.string, name='d_infer_input'
    )                                                              ❹

    d_vec_layer = model.get_layer('d_vectorizer')                  ❺
    d_vec_out = d_vec_layer(d_inp)                                 ❺

    d_emb_out = model.get_layer('d_embedding')(d_vec_out)          ❻

    d_attn_layer = model.get_layer("d_attention")                  ❻

    d_init_state = tf.keras.layers.Concatenate(axis=-1)(
        [fwd_state, bwd_state]
    )                                                              ❻

    attn_out, attn_states = d_attn_layer(
        [en_states, d_emb_out], initial_state=d_init_state
    )                                                              ❻

    d_dense1_out = model.get_layer("d_dense_1")(attn_out)          ❻

    d_final_out = model.get_layer("d_dense_final")(d_dense1_out)   ❻

    visualizer_model = tf.keras.models.Model(                      ❼
        inputs=[e_inp, d_inp], 
        outputs=[d_final_out, attn_states, e_vec_out, d_vec_out]
    )

    return visualizer_model

❶ 加载模型。

❷ 为模型定义编码器输入并获取编码器的最终输出。

❸ 获取编码器向量化器(用于解释最终输出)。

❹ 定义解码器输入。

❺ 获取解码器向量器和输出。

❻ 接下来的几个步骤只是重复训练模型中的步骤。我们只需获取相应的层,并将上一步的输出传递给当前步骤即可。

❼ 在这里,我们定义了最终用于可视化注意力模式的模型;我们对 attn_states 输出感兴趣(即,归一化的能量值)。我们还需要向可视化添加向量化的令牌 ID。

注意,我们定义的最终模型返回了四个不同的输出,而不是训练好的模型只返回了预测结果。我们还需要一个 get_vocabulary()函数,它将加载保存的词汇表:

def get_vocabularies(save_dir):
    """ Load the vocabularies """

    with open(os.path.join(save_dir, 'en_vocab.json'), 'r') as f:
        en_vocabulary = json.load(f)

    with open(os.path.join(save_dir, 'de_vocab.json'), 'r') as f:
        de_vocabulary = json.load(f)

    return en_vocabulary, de_vocabulary

最后,调用这些函数,使得我们拥有词汇表和模型准备就绪:

print("Loading vocabularies")
en_vocabulary, de_vocabulary = get_vocabularies(
    os.path.join('models', 'seq2seq_attention_vocab')
)

print("Loading weights and generating the inference model")
visualizer_model = attention_visualizer(
    os.path.join('models', 'seq2seq_attention')
)
print("\tDone")

接下来,我们将继续可视化 visualizer_model 生成的输出;我们将使用 Python 库 matplotlib 来可视化几个示例的注意力模式。让我们定义一个名为 visualize_attention()的函数,它接受 visualizer_model、两个词汇表、一个样本英文句子和相应的德文翻译(请参见下面的代码)。然后它将对输入进行预测,检索注意力权重,生成热图,并用英文/德文标记两个轴。

清单 12.6 使用输入文本可视化注意力模式

import matplotlib.pyplot as plt
%matplotlib inline

def visualize_attention(visualizer_model, en_vocabulary, de_vocabulary, 
➥ sample_en_text, sample_de_text, fig_savepath):
    """ Visualize the attention patterns """

    print("Input: {}".format(sample_en_text))

    d_pred, attention_weights, e_out, d_out = visualizer_model.predict(
        [np.array([sample_en_text]), np.array([sample_de_text])]
    )                                                              ❶

    d_pred_out = np.argmax(d_pred[0], axis=-1)                     ❷

    y_ticklabels = []                                              ❸
    for e_id in e_out[0]:                                          ❸
        if en_vocabulary[e_id] == "":                              ❸
            break                                                  ❸
        y_ticklabels.append(en_vocabulary[e_id])                   ❸

    x_ticklabels = []                                              ❹
    for d_id in d_pred_out:                                        ❹
        if de_vocabulary[d_id] == 'eos':                           ❹
            break                                                  ❹
        x_ticklabels.append(de_vocabulary[d_id])                   ❹

    fig, ax = plt.subplots(figsize=(14, 14))

    attention_weights_filtered = attention_weights[
        0, :len(y_ticklabels), :len(x_ticklabels)
    ]                                                              ❺

    im = ax.imshow(attention_weights_filtered)                     ❻

    ax.set_xticks(np.arange(attention_weights_filtered.shape[1]))  ❼
    ax.set_yticks(np.arange(attention_weights_filtered.shape[0]))  ❼
    ax.set_xticklabels(x_ticklabels)                               ❼
    ax.set_yticklabels(y_ticklabels)                               ❼

    ax.tick_params(labelsize=20)                                   ❼
    ax.tick_params(axis='x', labelrotation=90)                     ❼

    plt.colorbar(im)                                               ❽
    plt.subplots_adjust(left=0.2, bottom=0.2)

    save_dir, _ = os.path.split(fig_savepath)                      ❾
    if not os.path.exists(save_dir):                               ❾
        os.makedirs(save_dir, exist_ok=True)                       ❾
    plt.savefig(fig_savepath)                                      ❾

❶ 获取模型的预测结果。

❷ 获取模型预测结果的令牌 ID。

❸ 我们的 y 轴刻度标签将是输入的英文单词。一旦看到填充标记,我们就停止。

❹ 我们的 x 轴刻度标签将是预测的德文单词。一旦看到 EOS 标记,我们就停止。

❺ 我们将只可视化有用的输入和预测的单词,这样像填充值和 EOS 标记之后的内容都将被丢弃。

❻ 生成注意力热图。

❼ 设置 x 轴刻度、y 轴刻度和刻度标签。

❽ 生成色条以了解热图中的值范围。

❾ 将图保存到磁盘。

首先,我们将英文和德文输入文本输入模型以生成预测。由于我们仍在使用教师强迫模型,因此我们需要输入英文和德文输入文本。你可能会想,“这是否意味着我必须准备好德文翻译,并且只能在训练模式下可视化注意力模式?”当然不是!你可以像我们在本章的前一节中所做的那样定义一个推断模型,并且仍然可以可视化注意力模式。我们正在使用训练好的模型本身来可视化模式,因为我想专注于可视化注意力模式,而不是定义推断模型(我们已经为另一个模型完成了这个任务)。

一旦预测和注意权重得到,我们定义两个列表:x_ticklabels 和 y_ticklabels。它们将是热图中两个轴上看到的标签(即英语/德语单词)。我们将在行维度上有英语单词,列维度上有德语单词(图 12.1)。我们还将进行简单的过滤,以消除填充(即“”)和出现在 EOS 标记之后的德语文本,并获得满足这两个条件的范围内的注意力权重。然后,您只需调用 matplotlib 的 imshow() 函数来生成热图,并设置轴的刻度和这些刻度的标签。最后,将图保存到磁盘上。

让我们试试这个!让我们从我们的测试 DataFrame 中选取几个示例并可视化注意力模式。我们将创建 10 个可视化效果,并确保我们选择的这 10 个示例至少有 10 个英语单词,以确保我们不可视化非常短的短语:

# Generate attention patterns for a few inputs
i = 0
j = 0
while j<9:
    sample_en_text = test_df["EN"].iloc[i]
    sample_de_text = test_df["DE"].iloc[i:i+1].str.rsplit(n=1, 
➥ expand=True).iloc[:,0].tolist()
    i += 1

    if len(sample_en_text.split(" ")) > 10:
        j += 1
    else:
        continue

    visualize_attention(
        visualizer_model, en_vocabulary, de_vocabulary, sample_en_text, 
        sample_de_text, os.path.join('plots','attention_{}.png'.format(i))
    )

如果您成功运行此代码,您应该会看到并将 10 个注意力可视化显示并存储在磁盘上。在图 12.1 和 12.2 中,我们展示了两个这样的可视化。

12-01

图 12.1 为输入英文文本可视化的注意力模式

在这些图中,颜色越浅,模型就越关注该单词。在图 12.1 中,我们可以看到,当翻译单词“und”和“maria”时,模型主要关注的是“and”和“mary”分别。例如,如果您去谷歌翻译并为“and”这个词做德语翻译,您将会发现这是正确的。在图 12.2 中,我们可以看到,在生成“hast keine nicht”时,模型关注的是短语“have no idea”。我们可以做出的另一个观察是,注意力模式大致呈对角线。这是有道理的,因为这两种语言大致遵循相同的书写风格。

12-02

图 12.2 为输入英文文本可视化的注意力模式

我们的讨论到此结束关于序列到序列模型的内容。在下一章中,我们将讨论一个家族的模型,这些模型已经几年来一直处于机器学习的最前沿:变压器。

练习 2

你有一个由 attention_matrix 给出的注意力矩阵,用英语单词表示为 english_text_labels,用德语单词表示为 german_text_labels。你会如何创建类似于图 12.1 的可视化?在这里,您将需要使用 imshow()、set_xticks()、set_yticks()、set_xticklabels() 和 set_yticklabels() 函数。

摘要

  • 在序列到序列模型中使用注意力可以极大地提高其性能。

  • 在每个解码时间步中使用注意力,解码器可以看到编码器的所有历史输出,并选择并混合这些输出,以产生一个综合的(例如,求和)表示,这给出了编码器输入的整体视图。

  • 在注意力计算的中间产物之一是归一化的能量值,它给出了每个编码位置对于每个解码步骤的解码时间步骤的重要性的概率分布。换句话说,这是一个矩阵,对于每个编码器时间步和解码器时间步的组合都有一个值。这可以可视化为热图,并且可以用于解释解码器在翻译解码器中的某个令牌时关注了哪些单词。

练习答案

练习 1

e_inp = tf.keras.Input(
    shape=(1,), dtype=tf.string, name='e_input_final'
)          
fwd_state, bwd_state, en_states = encoder(e_inp)      

d_inp = tf.keras.Input(shape=(1,), dtype=tf.string, name='d_input')     

d_vectorized_out = vectorizer(d_inp)                                

d_emb_layer = tf.keras.layers.Embedding(
    n_vocab+2, 128, mask_zero=True, name='d_embedding'         
)    
d_emb_out = d_emb_layer(d_vectorized_out)     

d_init_state = tf.keras.layers.Concatenate(axis=-1)([fwd_state, bwd_state]) 

gru_out = tf.keras.layers.GRU(256, return_sequences=True)(
    d_emb_out, initial_state=d_init_state
)           

attn_out = AttentionX()([en_states, gru_out])

d_dense_layer_1 = tf.keras.layers.Dense(
    512, activation='relu', name='d_dense_1'
)
d_dense1_out = d_dense_layer_1(attn_out)         

d_final_layer = tf.keras.layers.Dense(
    n_vocab+2, activation='softmax', name='d_dense_final'
)
d_final_out = d_final_layer(d_dense1_out)

练习 2

im = ax.imshow(attention_matrix)  

ax.set_xticks(np.arange(attention_matrix.shape[1])) 
ax.set_yticks(np.arange(attention_matrix.shape[0])) 

ax.set_xticklabels(german_text_labels)  
ax.set_yticklabels(english_text_labels)

第十三章:个 Transformer

本章涵盖

  • 实现一个包含所有组件的完整 Transformer 模型

  • 使用 TFHub 中预训练的 BERT 模型实现垃圾邮件分类器

  • 使用 Hugging Face 的 Transformer 库实现问答模型

在第十一章和第十二章,你学习了序列到序列模型,这是一类强大的模型家族,允许我们将任意长度的序列映射到另一个任意长度的序列。我们通过一个机器翻译任务来举例说明这种能力。序列到序列模型由一个编码器和一个解码器组成。编码器接收输入序列(源语言中的句子)并创建该序列的紧凑表示(称为上下文向量)。解码器接收上下文向量以生成最终的目标(即目标语言中的句子)。但是我们看到上下文向量的限制如何限制了模型,并研究了两种改进模型性能的技术。首先,教师强制允许解码器不仅在给定时间内查看上下文向量,还可以查看目标语言中先前的单词。这为解码器提供了更多的信息,以准确地生成最终的预测。其次,注意机制允许解码器窥视编码器输出历史中的任何部分,并使用该信息来生成输出。然而,LSTM 和 GRU 模型仍然相当受限制,因为它们一次只能看到一个序列输出,并且需要依赖有限的状态向量(即记忆)来记住它们所看到的内容。

但是现在镇上出现了一个新的竞争对手。如果有一个词可以概括最近最先进的自然语言处理(NLP)和计算机视觉研究,那就是 Transformer。Transformer 模型是最新型的深度学习模型,通过被冠以 NLP 许多任务的最新技术而令人难忘,击败了先前的领导者,如基于 LSTM 和 GRU 的模型。受到他们在 NLP 领域取得的空前成功的启发,它们现在正在被引入来解决各种计算机视觉问题。

按设计,Transformer 模型使得记忆或使用长序列数据(例如单词序列)中的信息变得轻而易举。与 LSTM 模型不同,后者必须一次查看一个时间步长,Transformer 模型可以一次看到整个序列。这使得 Transformer 模型比其他模型更好地理解语言。此外,由于纵向(即时间)计算的最小化需要对文本进行顺序处理,Transformer 模型享有高度的可并行性。

在本章中,延续我们在第五章的对话,我们将讨论 Transformer 模型的一些更多细节,以便我们对其有一个全面的理解。我们将看到 Transformer 模型如何使用多个嵌入来表示标记以及这些标记在序列中的位置。然后我们将学习 BERT,这是 Transformer 模型的一个变体,它已经在大型文本语料库上进行了训练,可以作为基础层轻松解决下游 NLP 任务,而不需要复杂的模型。BERT 本质上是 Transformer 模型的编码器部分,使用了两种技术进行大量文本的预训练:掩码语言建模(即,序列中的单词被随机掩码,BERT 必须预测掩码单词)和下一个句子预测(即,给定两个句子 A 和 B,预测 B 是否暗示 A)。当我们使用它在 TensorFlow 中实现一个垃圾邮件分类器时,我们将看到 BERT 在实际中的运行情况。接下来,Hugging Face 的 transformers 库(huggingface.co/transformers/)是实现最先进的 Transformer 模型的热门选择,易于使用。如果您打算在 TensorFlow 中实现 Transformer 模型,那么这是最有价值的库之一。出于这个原因,我们将使用 Hugging Face 的 Transformers 库实现一个问答模型。最后,我们将结束本章,讨论 Transformer 模型在计算机视觉中的应用。

作为第一步,让我们重新审视一下我们已经学过的关于 Transformer 模型的知识(见图 13.1),并进一步扩展我们的理解。

13-01

图 13.1 Transformer 模型如何解决 NLP 问题

13.1 更详细的 Transformer

你是一名数据科学家,有人建议你在 NLP 工作流中使用 Transformer 模型。你看了 TensorFlow 提供的 Transformer 模型。然而,你很难从文档中理解这个模型。你认为从零开始实现一个 Transformer 网络是理解赋予 Transformer 模型生命的概念的好方法。因此,你决定按照论文“Attention Is All You Need”(arxiv.org/pdf/1706.03762.pdf)中指定的所有组件来实现一个 Transformer 模型。这个模型将具有诸如嵌入层(标记和位置嵌入)、自注意层、归一化层等组件。

13.1.1 重新审视 Transformer 的基本组件

在第五章,我们讨论了 Transformer 模型的基础,以实现一个简化的 Transformer。现在让我们深入讨论并查看存在于 Transformer 模型中的所有组件。Transformer 模型是基于编码器-解码器的模型。编码器接受一系列输入(例如,Dogs are great)以创建这些标记的隐藏(或潜在)表示。接下来,解码器使用编码器生成的输入标记的表示,并生成一个输出(例如,输入的法语翻译)。

编码器由一堆层组成,其中每一层包含两个子层:

  • 自注意力层—为序列中的每个输入标记生成潜在表示。对于每个输入标记,此层查看整个输入序列,并选择序列中的其他标记,以丰富为该标记生成的隐藏输出的语义(即,关注表示)。

  • 全连接层—生成与关注表示相关的逐元素隐藏表示。

解码器由三个子层组成:

  • 掩码自注意力层—对于每个输入标记,它查看其左侧的所有标记。解码器需要屏蔽右侧的单词,以防止模型看到未来的单词,使得解码器的预测任务变得简单。

  • 编码器-解码器注意力层—对于解码器中的每个输入标记,它查看编码器的输出以及解码器的掩码关注输出,以生成一个语义丰富的隐藏输出。

  • 全连接层—生成解码器关注表示的逐元素隐藏表示。

在我们之前的讨论中,最难理解的是自注意力层。因此,值得重新审视自注意力层中发生的计算。自注意力层中的计算围绕着三个权重矩阵展开:

  • 查询权重矩阵(W[q])

  • 键权重矩阵(W[k])

  • 值权重矩阵(W[v])

这些权重矩阵中的每一个对于给定输入序列中的给定标记(在位置i)产生三个输出:查询、键和值。让我们刷新一下我们在第五章中对这些实体所说的话:

  • 查询q[i])—帮助构建最终用于索引值(v)的概率矩阵。查询影响矩阵的行,并表示正在处理的当前单词的索引。

  • 关键k[i])—帮助构建最终用于索引值(v)的概率矩阵。关键影响矩阵的列,并表示需要根据查询词混合的候选词。

  • Valuev[i]) ——输入的隐藏(即潜在)表示,用于通过查询和密钥创建的概率矩阵索引计算最终输出。正如前面解释的那样,在位置 i 处的最终输出不仅使用第 i 个令牌,还使用输入序列中的其他令牌,这增强了最终表示中捕获的语义。

这些元素的高层目的是生成一个被关注的表示(即给定令牌的潜在或隐藏表示,其由输入序列中其他令牌的信息增强)。为此,模型

  • 为输入序列中的每个位置生成一个查询

  • 对于每个查询,确定每个密钥应该贡献多少(密钥也代表个别令牌)

  • 基于给定查询的密钥的贡献,混合与这些密钥对应的值以生成最终的关注表示

查询、密钥和值都是通过将可训练的权重矩阵与输入令牌的数值表示相乘而生成的。所有这些都需要以可微分的方式进行,以确保梯度可以通过模型进行反向传播。论文提出了以下计算来计算输入令牌的自注意力层的最终表示:

13_01a

这里,Q 表示查询,K 表示密钥,V 表示批量数据中所有输入和每个输入中所有令牌的值。这就是使 Transformer 模型如此强大的原因:与 LSTM 模型不同,Transformer 模型将所有令牌在一个序列中聚合到一个矩阵乘法中,使得这些模型高度可并行化。

Transformer 中的嵌入

当我们讨论 Transformer 模型时,有一件事情被忽略了,那就是它使用的嵌入。我们简要地提到了使用的词嵌入。让我们在这里更详细地讨论这个话题。词嵌入根据单词的上下文提供了语义保持的表示。换句话说,如果两个单词在相同的上下文中使用,它们将具有相似的单词向量。例如,“猫”和“狗”将具有相似的表示,而“猫”和“火山”将具有完全不同的表示。

单词向量最初在 Mikolov 等人的论文中被介绍,题为“Efficient Estimation of Word Representations in Vector Space” (arxiv.org/pdf/1301.3781.pdf)。它有两个变种,skip-gram 和 continuous bag-of-words(CBOW)。由于 skip-gram 稍微比 CBOW 更广泛地被接受,让我们讨论 skip-gram 算法的要点。

第一步是定义一个大小为 V × E 的大矩阵,其中 V 是词汇表的大小,E 是嵌入的大小。嵌入的大小(E)是用户定义的超参数,其中更大的 E 通常会导致更强大的词嵌入。在实践中,你不需要使嵌入的大小超过 300。

接下来,完全无监督地创建输入和目标。给定大量的文本语料库,选择一个单词形式作为输入(探针词),并以探针词周围的单词作为目标。通过定义固定大小的探针词周围窗口来捕获周围的单词。例如,针对窗口大小为 2(在探针词的每一侧),你可以从句子 “angry John threw a pizza at me.” 生成以下输入-目标对。

(John, angry), (John, threw), (John, a), (threw, angry), (threw, John), (threw, a), (threw, pizza), ..., (at, a), (at, pizza), (at, me)

有了带标签的数据,你可以将学习词嵌入的问题框定为分类问题。换句话说,你训练一个模型(即一个词嵌入矩阵的函数),以输入的词为基础预测目标词。该模型包括两个部分,嵌入矩阵和完全连接层,通过 softmax 激活输出预测结果。一旦学习了嵌入,你可以丢弃其周围的其他内容(例如完全连接层),并使用嵌入矩阵用于下游 NLP 任务,例如情感分析、机器翻译等。只需要查找与单词相对应的嵌入向量,即可获得该单词的数字表示。

现代深度学习模型受到原始词向量算法的启发,将学习词嵌入和实际的决策支持 NLP 问题融合到单个模型训练任务中。换句话说,以下一般方法用于将词嵌入纳入到机器学习模型中:

  • 定义一个随机初始化的词嵌入矩阵(或提供免费下载的预训练的嵌入)。

  • 定义使用单词嵌入作为输入并产生输出(例如情感、语言翻译等)的模型(随机初始化)。

  • 在任务上训练整个模型(嵌入 + 模型)。

Transformer 模型中也使用了相同的技术。但是,Transformer 模型中有两个不同的嵌入:

  • Token 嵌入(为模型在输入序列中看到的每个 token 提供唯一的表示)

  • 位置嵌入(为输入序列中的每个位置提供唯一的表示)

Token 嵌入(为模型在输入序列中看到的每个 token 提供唯一的表示)

位置嵌入被用来告诉模型一个标记出现的位置。其主要目的是为了让位置嵌入服务器告诉变压器模型一个单词出现的位置。这是因为,与 LSTMs/GRUs 不同,变压器模型没有序列的概念,因为它们一次处理整个文本。此外,单词位置的改变可能会改变句子或单词的含义。例如,在两个版本中

Ralph loves his tennis ball. It likes to chase the ball
Ralph loves his tennis ball. Ralph likes to chase it

单词“it”指的是不同的东西,单词“it”的位置可以用作识别这种差异的线索。原始的变压器论文使用以下方程式生成位置嵌入:

PE(pos,2i) = sin(pos/10000^(21/d[model]))

PE(pos,2i + 1) = cos(pos/10000^(21/d[model]))

其中 pos 表示序列中的位置,i表示i^(th)特征维度(0 ≤ i < d[model])。偶数特征使用正弦函数,而奇数特征使用余弦函数。图 13.2 展示了当时间步长和特征位置变化时位置嵌入的变化。可以看到,具有较高索引的特征位置具有较低频率的正弦波。作者确切的方程式并不完全清楚。但是,他们确实提到他们没有看到前一个方程式和让模型在训练期间联合学习位置嵌入之间有显著的性能差异。

13-02

图 13.2 位置嵌入随时间步长和特征位置的变化。偶数特征位置使用正弦函数,而奇数位置使用余弦函数。此外,信号的频率随着特征位置的增加而降低。

需要注意的是,标记嵌入和位置嵌入都具有相同的维度(即d[model])。最后,作为模型的输入,标记嵌入和位置嵌入被求和以形成单个混合嵌入向量(图 13.3)。

13-03

图 13.3 在变压器模型中生成的嵌入以及如何计算最终嵌入

13.1.3 残差和规范化

变压器模型的另一个重要特征是残差连接和单个层之间的规范化层的存在。当我们讨论图像分类的高级技术时,我们在第七章中深入讨论了残差连接。让我们简要地重新讨论残差连接的机制和动机。

残差连接是通过将给定层的输出添加到前面一个或多个层的输出而形成的。这反过来形成了模型中的“快捷连接”,并通过减少所谓的梯度消失现象提供了更强的梯度流(见图 13.4)。梯度消失导致最靠近输入的层的梯度非常小,以至于这些层的训练受到阻碍。

13-04

图 13.4 残差连接的数学视角

在 Transformer 模型中,每个层次都会创建残差连接,具体如下:

  • 多头自注意力子层的输入被添加到多头自注意力子层的输出中。

  • 完全连接子层的输入被添加到完全连接子层的输出中。

接下来,通过残差连接增强的输出经过一层层归一化层。层归一化,类似于批归一化,是减少神经网络中“协变量转移”的一种方式,使其能够更快地训练并达到更好的性能。协变量转移是指神经网络激活分布的变化(由数据分布的变化引起),这些变化在模型训练过程中发生。这种分布的变化会在模型训练期间影响一致性,并对模型产生负面影响。层归一化是由 Ba 等人在论文“Layer Normalization”中介绍的(arxiv.org/pdf/1607.06450.pdf)。

批归一化计算激活的均值和方差作为批次中样本的平均值,导致其性能依赖于用于训练模型的小批量大小。

然而,层归一化计算激活的均值和方差(即归一化项)的方式是这样的,即归一化项对每个隐藏单元都是相同的。换句话说,层归一化对于层中的所有隐藏单元都有一个单一的均值和方差值。这与批归一化不同,后者对层中的每个隐藏单元维护单独的均值和方差值。此外,与批归一化不同,层归一化不会对批次中的样本进行平均,而是留下了平均化,对不同的输入具有不同的归一化项。通过每个样本具有一个均值和方差,层归一化摆脱了对小批量大小的依赖。有关此方法的更多细节,请参阅原始论文。

TensorFlow/Keras 中的层归一化

TensorFlow 提供了层归一化算法的方便实现,网址为 mng.bz/YGRB。你可以使用 TensorFlow Keras API 定义的任何模型来使用这个层。

图 13.5 展示了在 Transformer 模型中如何使用残差连接和层归一化。

13-05

图 13.5 残差连接和层归一化层在 Transformer 模型中的使用方式

讨论关于 Transformer 模型中的组件到此结束。我们已经讨论了 Transformer 模型的所有要点,即自注意力层、全连接层、嵌入(标记和位置)、层归一化和残差连接。在下一节中,我们将讨论如何使用一个称为 BERT 的预训练 Transformer 模型来解决垃圾邮件分类任务。

练习 1

你被给定了以下 Transformer 编码器的代码

import tensorflow as tf

# Defining some hyperparameters
n_steps = 25 # Sequence length
n_en_vocab = 300 # Encoder's vocabulary size
n_heads = 8 # Number of attention heads
d = 512 # The feature dimensionality of each layer

# Encoder input layer
en_inp = tf.keras.layers.Input(shape=(n_steps,))
# Encoder input embedddings
en_emb = tf.keras.layers.Embedding(
    n_en_vocab, d, input_length=n_steps
)(en_inp)

# Two encoder layers
en_out1 = EncoderLayer(d, n_heads)(en_emb)
en_out2 = EncoderLayer(d, n_heads)(en_out1)

model = tf.keras.models.Model(inputs=en_inp, output=en_out2)

其中 EncoderLayer 定义了一个典型的 Transformer 编码器层,其中包含自注意力子层和全连接子层。你被要求使用以下方程式集成位置编码

PE(pos, 2i) = sin(pos/10000^(2i/d[model]))

pos 从 0 到 511(d=512 特征),i 从 0 到 24(n_steps=25 时间步),表示时间步。换句话说,我们的位置编码将是一个形状为 [n_steps, d] 的张量。你可以使用 tf.math.sin() 逐元素生成张量的 sin 值。你可以将位置嵌入定义为张量,而不是 tf.keras.layers.Layer 的乘积。最终嵌入应通过将标记嵌入和位置嵌入相加来生成。你会如何做?

13.2 使用预训练的 BERT 进行垃圾邮件分类

你正在为一家邮件服务公司担任数据科学家,公司渴望实现垃圾邮件分类功能。他们希望在公司内部实现此功能并节省成本。通过阅读关于 BERT 及其在解决 NLP 任务中的强大性能的文章,你向团队解释,你需要做的就是下载 BERT 模型,在 BERT 顶部拟合一个分类层,并在标记的数据上端到端地训练整个模型。标记的数据包括一个垃圾消息和一个指示消息是否为垃圾或正常的标签。你被委托负责实现此模型。

现在我们已经讨论了 Transformer 架构的所有移动元素,这使得我们非常有能力理解 BERT。BERT 是一种基于 Transformer 的模型,由 Devlin 等人在论文 “BERT: Pre-Training of Deep Bidirectional Transformers for Language Understanding” 中介绍(arxiv.org/pdf/1810.04805.pdf),它代表了自然语言处理历史上的一个非常重要的里程碑,因为它是一个先驱性模型,证明了在 NLP 领域应用 “迁移学习”的能力。

BERT 是一个在大量文本数据上以无监督方式预训练的 Transformer 模型。因此,你可以使用 BERT 作为基础,获得丰富、语义上准确的文本输入序列的数字表示,这些表示可以直接提供给下游 NLP 模型。由于 BERT 提供的丰富文本表示,你可以让你的决策支持模型不再需要理解语言,而是可以直接专注于手头的问题。从技术角度来看,如果你正在用 BERT 解决分类问题,你所需要做的就是

  • 在 BERT 之上拟合一个分类器(例如逻辑回归层),将 BERT 的输出作为输入

  • 在判别性任务上(即 BERT + 分类器)端到端地训练模型

BERT 的历史

在像 BERT 这样的模型出现之前,解决自然语言处理(NLP)任务既重复又耗时。每次都需要从头开始训练一个模型。更糟糕的是,大多数模型都无法处理长文本序列,限制了它们理解语言的能力。

2017 年,NLP 任务的 Transformer 模型在论文“Attention Is All You Need”(arxiv.org/pdf/1706.03762.pdf)中提出。Transformer 模型在一系列 NLP 任务上击败了之前的主导者,如 LSTMs 和 GRUs。与逐字逐句查看并维护状态(即内存)的循环模型不同,Transformer 模型一次查看整个序列。

然后,在 2018 年,NLP(即在 NLP 中进行迁移学习)迎来了“ImageNet 时刻”。ImageNet 时刻是指 ML 从业者意识到,在其他任务(如目标检测、图像分割)上使用已经在大型 ImageNet 图像分类数据集上训练过的计算机视觉模型,可以更快地获得更好的性能。这实际上催生了在计算机视觉领域广泛使用的迁移学习概念。因此,直到 2018 年,NLP 领域还没有一个非常好的方法来利用迁移学习来提升任务性能。论文“通用语言模型微调用于文本分类”(arxiv.org/pdf/1801.06146.pdf)介绍了在语言建模任务上预训练然后在判别性任务上训练模型的思想(例如,分类问题)。这种方法的优势在于,你不需要像从头训练模型那样多的样本。

2018 年,BERT 被引入。这是自然语言处理历史上两个最出色时刻的结合。换句话说,BERT 是一个在大量文本数据上以无监督方式预训练的 Transformer 模型。

现在我们将更详细地了解 BERT 模型。

13.2.1 理解 BERT

现在让我们更微观地检查 BERT。正如我之前提到的,BERT 是一个 Transformer 模型。确切地说,它是 Transformer 模型的编码器部分。这意味着 BERT 接受一个输入序列(一组标记)并生成一个编码的输出序列。图 13.6 描述了 BERT 的高层架构。

13-06

图 13.6 BERT 的高层架构。它接受一组输入标记并生成使用几个隐藏层生成的隐藏表示的序列。

当 BERT 接受一个输入时,它会在输入中插入一些特殊的标记。首先,在开始时,它插入一个[CLS](分类的缩写形式)标记,用于生成特定类型任务(例如,序列分类)的最终隐藏表示。它表示在关注序列中的所有标记后的输出。接下来,根据输入类型,它还会插入一个[SEP](即“分隔”)标记。[SEP]标记标记了输入中不同序列的结束和开始。例如,在问答中,模型接受问题和可能包含答案的上下文(例如,段落)作为输入,并且[SEP]在问题和上下文之间使用。

接下来,使用三种不同的嵌入空间生成标记的最终嵌入。标记嵌入为词汇表中的每个标记提供了一个独特的向量。位置嵌入编码了每个标记的位置,如前面讨论的。最后,段落嵌入为输入的每个子组件提供了一个不同的表示。例如,在问答中,问题将具有作为其段落嵌入向量的唯一向量,而上下文将具有不同的嵌入向量。这通过在输入序列中的每个不同组件的n个不同嵌入向量来完成。根据输入中为每个标记指定的组件索引,检索相应的段落嵌入向量。n需要事先指定。

BERT 的真正价值来自于它是以自监督方式在大型语料库上预训练的事实。在预训练阶段,BERT 在两个不同的任务上进行训练:

  • 掩码语言建模(MLM)

  • 下一句预测(NSP)

掩码语言建模(MLM)任务灵感来自填空题填空测验,其中学生被给出一句带有一个或多个空白的句子,并被要求填写空白。类似地,给定一个文本语料库,单词从句子中被掩码,然后模型被要求预测掩码的标记。例如,句子

I went to the bakery to buy bread

可能变成

I went to the [MASK] to buy bread

注意:已经有大量基于 Transformer 的模型,每个模型都在前一个模型的基础上进行了构建。您可以在附录 C 中了解更多关于这些模型的信息。

图 13.7 显示了在遮蔽语言建模任务训练过程中 BERT 的主要组成部分。BERT 使用特殊标记([MASK])来表示被遮蔽的单词。然后模型的目标将是单词"bakery"。但这给模型带来了实际问题。特殊标记[MASK]在实际文本中不会出现。这意味着模型在关键问题的微调阶段(即在分类问题上训练时)看到的文本将与在预训练阶段看到的文本不同。这有时被称为预训练-微调不一致性。因此,BERT 的作者建议采取以下方法来处理这个问题。当屏蔽一个单词时,做以下之一:

  • 使用[MASK]标记(使用 80%的概率)。

  • 使用随机单词(以 10%的概率)。

  • 使用真实的单词(以 10%的概率)。

13-07

图 13.7 显示了预训练 BERT 使用的方法。BERT 在两个任务上进行预训练:遮蔽语言建模任务和下一句预测任务。在遮蔽语言建模任务中,输入中的标记被遮蔽,模型被要求预测被遮蔽的标记。在下一句预测任务中,模型被要求预测两个句子是否相邻。

接下来,在下一句预测任务中,模型会得到一对句子 A 和 B(按照顺序),并被要求预测 B 是否是 A 之后的下一句。可以通过在 BERT 之上拟合一个二元分类器,并在选择的句子对上端到端地训练整个模型来完成。以无监督的方式生成模型的输入对是不难的:

  • 通过选择相邻的两个句子生成标签为 TRUE 的样本。

  • 通过随机选择不相邻的两个句子生成标签为 FALSE 的样本。

按照这种方法,为下一句预测任务生成了一个带标签的数据集。然后,使用带标签的数据集对 BERT 和二元分类器进行端到端的训练。图 13.7 突出显示了下一句预测任务中的数据和模型架构。

您可能已经注意到图 13.6 中输入到 BERT 的是特殊标记。除了我们已讨论过的[MASK]标记之外,还有两个特殊的标记具有特殊的用途。

[CLS]标记被附加到输入到 BERT 的任何输入序列上。它表示输入的开始。它还为放置在 BERT 顶部的分类头上使用的输入提供基础。正如您所知,BERT 对序列中的每个输入标记生成一个隐藏表示。按照惯例,与[CLS]标记对应的隐藏表示被用作放置在 BERT 之上的分类模型的输入。

BERT 解决的任务特定 NLP 任务可以分为四种不同的类别。这些基于 General Language Understanding Evaluation(GLUE)基准任务套件中的任务 (gluebenchmark.com):

  • 序列分类—这里,给定一个单一的输入序列,并要求模型为整个序列预测一个标签(例如,情感分析,垃圾邮件识别)。

  • 令牌分类—这里,给定一个单一的输入序列,并要求模型为序列中的每个令牌预测一个标签(例如,命名实体识别,词性标注)。

  • 问答—这里,输入包括两个序列:一个问题和一个上下文。问题和上下文之间由一个 [SEP] 令牌分隔。模型被训练来预测属于答案的令牌范围的起始和结束索引。

  • 多选题—这里的输入由多个序列组成:一个问题,后跟可能是或可能不是问题答案的多个候选项。这些多个序列由令牌 [SEP] 分隔,并作为单个输入序列提供给模型。模型被训练来预测该问题的正确答案(即,类标签)。

BERT 的设计使得它能够在不对基础模型进行任何修改的情况下用于解决这些任务。在涉及多个序列的任务中(例如,问答,多选题),您需要单独告诉模型不同的输入(例如,问题的令牌和问题回答任务中的上下文的令牌)。为了做出这种区分,使用 [SEP] 令牌。[SEP] 令牌在不同序列之间插入。例如,如果您正在解决一个问答任务,您可能会有一个输入如下:

Question: What color is the ball?
Paragraph: Tippy is a dog. She loves to play with her red ball.

然后,BERT 的输入可能如下所示

[CLS] What color is the ball [SEP] Tippy is a dog She loves to play with her red ball [SEP]

BERT 还使用段落嵌入空间来表示一个令牌属于哪个序列。例如,只有一个序列的输入对于所有令牌具有相同的段落嵌入向量(例如,垃圾邮件分类任务)。具有两个或更多序列的输入使用第一个或第二个空间,取决于令牌属于哪个序列。例如,在问答中,模型将使用唯一的段落嵌入向量来编码问题的令牌,其中将使用不同的段落嵌入向量来编码上下文的令牌。现在我们已经讨论了使用 BERT 成功解决下游 NLP 任务所需的所有要素。让我们重申一下有关 BERT 的关键要点:

  • BERT 是一种基于编码器的 Transformer,经过大量文本的预训练。

  • BERT 使用掩码语言建模和下一句预测任务进行模型的预训练。

  • BERT 为输入序列中的每个令牌输出隐藏表示。

  • BERT 有三个嵌入空间:令牌嵌入,位置嵌入和段落嵌入。

  • BERT 使用特殊标记[CLS]来表示输入的开始,并用作下游分类模型的输入。

  • BERT 旨在解决四种类型的 NLP 任务:序列分类、标记分类、自由文本问答和多项选择题答案。

  • BERT 使用特殊标记[SEP]来分隔序列 A 和序列 B。

接下来,我们将学习如何使用 BERT 对垃圾邮件进行分类。

使用 BERT 在 TensorFlow 中对垃圾邮件进行分类

现在是展示您的技能并以最小的努力实现垃圾邮件分类器的时候了。首先,让我们下载数据。我们将在这个练习中使用的数据是一组垃圾邮件和 ham(非垃圾邮件)短信消息,可在mng.bz/GE9v获取。下载数据的 Python 代码已在 Ch13-Transormers-with-TF2-and-Huggingface/13.1_Spam_Classification_with_BERT.ipynb 笔记本中提供。

理解数据

一旦您下载并提取数据,我们就可以快速查看数据中的内容。它将是一个单个的制表符分隔的文本文件。文件的前三个条目如下:

ham    Go until jurong point, crazy.. Available only in bugis n great 
➥ world la e buffet... Cine there got amore wat...
ham    Ok lar... Joking wif u oni...
spam        Free entry in 2 a wkly comp to win FA Cup final tkts 21st 
➥ May 2005 ...

如图所示,每行以单词 ham 或 spam 开头,表示是否安全或垃圾邮件。然后给出消息中的文本,后跟一个制表符。我们的下一个任务是将此数据加载到内存中,并将输入和标签存储在 NumPy 数组中。以下清单显示了执行此操作的步骤。

清单 13.1 将数据从文本文件加载到 NumPy 数组中

inputs = []                                 ❶
labels = []                                 ❷

n_ham, n_spam = 0,0                         ❸
with open(os.path.join('data', 'SMSSpamCollection'), 'r') as f:
    for r in f:                             ❹

        if r.startswith('ham'):             ❺
            label = 0                       ❻
            txt = r[4:]                     ❼
            n_ham += 1                      ❽
        # Spam input
        elif r.startswith('spam'):          ❾
            label = 1                       ❿
            txt = r[5:]                     ⓫
            n_spam += 1
        inputs.append(txt)                  ⓬
        labels.append(label)                ⓭

# Convert them to arrays
inputs = np.array(inputs).reshape(-1,1)     ⓮
labels = np.array(labels)                   ⓯

❶ 输入(消息)存储在这里。

❷ 标签(0/1)存储在这里。

❸ 计算 ham/spam 示例的总数

❹ 读取文件中的每一行。

❺ 如果行以 ham 开头,则为一个 ham 示例。

❻ 将其标记为 0。

❼ 输入是该行中的文本(除了以 ham 开头的单词)。

❽ 增加 n_ham 的计数。

❾ 如果行以 spam 开头,则为垃圾邮件。

❿ 将其标记为 1。

⓫ 输入是该行中的文本(除了以 spam 开头的单词)。

⓬ 将输入文本附加到输入。

⓭ 将标签附加到标签。

⓮ 将输入转换为 NumPy 数组(并将其重新整形为具有一列的矩阵)。

⓯ 将标签列表转换为 NumPy 数组。

您可以打印 n_ham 和 n_spam 变量,并验证有 4827 个 ham 示例和 747 个 spam 示例。换句话说,垃圾邮件示例比 ham 示例少。因此,在训练模型时,我们必须确保考虑到这种不平衡。

处理数据中的类别不平衡

为了抵消类别不平衡,让我们创建平衡的训练/验证和测试数据集。为此,我们将使用 imbalanced-learn 库,这是一个用于操纵不平衡数据集(例如,从不同类别中抽样不同数量的数据)的优秀库。恢复数据集平衡的两种主要策略是:

  • 对多数类进行欠采样(从该类中选择较少的样本以用于最终数据集)

  • 对少数类进行过采样(为最终数据集生成更多来自该类的样本)

我们将在这里使用第一种策略(即欠采样多数类)。更具体地说,我们将首先

  • 通过从数据集中随机抽样数据来创建平衡的测试和验证数据集(每个类别 n 个示例)

  • 将剩余的数据分配给训练集,并使用一种称为near-miss算法的算法对训练集中的多数类进行欠采样。

创建训练验证和测试数据的过程如图 13.8 所示。

13-08

图 13.8 从原始数据集创建训练、验证和测试数据集的过程

首先,让我们从库和 NumPy 库中导入一些欠采样器:

from imblearn.under_sampling import  NearMiss, RandomUnderSampler
import numpy as np

接下来,我们将定义一个变量 n,它表示在验证和测试数据集中每个类别要保留多少个样本:

n=100 # Number of instances for each class for test/validation sets
random_seed = 4321

接下来我们将定义一个随机欠采样器。最重要的参数是 sampling_strategy 参数,它接受一个字典,其中键是标签,值是该标签所需的样本数量。我们还将通过 random_state 参数传递 random_seed 以确保每次运行代码时都获得相同的结果:

rus = RandomUnderSampler(
    sampling_strategy={0:n, 1:n}, random_state=random_seed
)

然后我们调用欠采样器的 fit_resample()函数,使用我们创建的 inputs 和 labels 数组来采样数据:

rus.fit_resample(inputs, labels)

一旦您适应了欠采样器,您可以使用欠采样器的 sample_indices_ 属性获取所选样本的索引。使用这些索引,我们将创建一对新的数组 test_x 和 test_y 来保存测试数据:

test_inds = rus.sample_indices_
test_x, test_y = inputs[test_inds], np.array(labels)[test_inds]

不在测试数据集中的索引被分配到不同的数组:rest_x 和 rest_y。这些将被用来创建验证数据集和训练数据集:

rest_inds = [i for i in range(inputs.shape[0]) if i not in test_inds]
rest_x, rest_y = inputs[rest_inds], labels[rest_inds]

和之前的方法类似,我们从 rest_x 和 rest_y 中欠采样数据来创建验证数据集(valid_x 和 valid_y)。请注意,我们不使用 inputs 和 labels 数组,而是使用这些数组分离出测试数据后剩余的数据:

rus.fit_resample(rest_x, rest_y)
valid_inds = rus.sample_indices_
valid_x, valid_y = rest_x[valid_inds], rest_y[valid_inds]

最后,我们创建训练数据集,该数据集将保存在创建测试和验证数据集后剩余的所有元素:

train_inds = [i for i in range(rest_x.shape[0]) if i not in valid_inds]
train_x, train_y = rest_x[train_inds], rest_y[train_inds]

我们还必须确保训练数据集是平衡的。为了做到这一点,让我们使用比随机选择元素更智能的方式来欠采样数据。我们将在这里使用的欠采样算法称为 near-miss 算法。near-miss 算法会删除与少数类别中的样本太接近的多数类别中的样本。这有助于增加少数类别和多数类别示例之间的距离。在这里,少数类别指的是数据较少的类别,而多数类别指的是数据较多的类别。为了使用 near-miss 算法,它需要能够计算两个样本之间的距离。因此,我们需要将我们的文本转换为一些数值表示。我们将使用 scikit-learn 的 CountVectorizer 来实现这一点:

from sklearn.feature_extraction.text import CountVectorizer

countvec = CountVectorizer()
train_bow = countvec.fit_transform(train_x.reshape(-1).tolist())

train_bow 将包含我们数据的词袋表示。然后我们可以将其传递给 NearMiss 实例。获取数据的方式与之前相同:

from imblearn.under_sampling import  NearMiss

oss = NearMiss()
x_res, y_res = oss.fit_resample(train_bow, train_y)
train_inds = oss.sample_indices_

train_x, train_y = train_x[train_inds], train_y[train_inds]

让我们打印出我们数据集的大小,看看它们是否与我们最初想要的大小相匹配:

Test dataset size
1    100
0    100
dtype: int64

Valid dataset size
1    100
0    100
dtype: int64
Train dataset size
1    547
0    547
dtype: int64

太棒了!我们的数据集都是平衡的,我们准备继续进行工作流的其余部分。

定义模型

准备好数据后,我们将下载模型。我们将使用的 BERT 模型来自 TensorFlow hub (www.tensorflow.org/hub)。TensorFlow hub 是各种模型训练任务的模型仓库。您可以获得多种任务的模型,包括图像分类、对象检测、语言建模、问答等等。要查看所有可用模型的完整列表,请访问 tfhub.dev/

为了成功地利用 BERT 来完成自然语言处理任务,我们需要三个重要的工作:

  • 分词器 —— 确定如何将提供的输入序列分割为标记

  • 编码器 —— 接受标记,计算数值表示,并最终为每个标记生成隐藏表示以及汇总表示(整个序列的单一表示)

  • 分类头 —— 接受汇总表示并为输入生成标签

首先,让我们来看一下分词器。分词器接受单个输入字符串或字符串列表,并将其转换为字符串列表或字符串列表,分别将其拆分为较小的元素。例如,可以通过在空格字符上分割来将句子分割为单词。BERT 中的分词器使用一种称为 WordPiece 算法的算法 (mng.bz/z40B)。它使用迭代方法找到数据集中常见的子词(即单词的部分)。WordPiece 算法的细节超出了本书的范围。欢迎查阅原始论文以了解更多细节。对于本讨论而言,分词器的最重要特征是它将给定的输入字符串(例如,句子)分解为较小标记的列表(例如,子词)。

像 WordPiece 算法这样的子词方法的优点

像 WordPiece 算法这样的子词方法学习单词的较小且常见的部分,并使用它们来定义词汇表。与将整个单词放入词汇表相比,此方法有两个主要优点。

使用子词通常可以减少词汇量的大小。假设单词为[“walk”, “act”, “walked”, “acted”, “walking”, “acting”]。如果使用单独的单词,每个单词都需要在词汇表中成为一个单一项目。然而,如果使用子词方法,词汇表可以缩减为[“walk”, “act”, “##ed”, “##ing”],只有四个单词。在这里,##表示它需要添加另一个子词作为前缀。

第二,子词方法可以处理词汇表中未出现的单词。这意味着子词方法可以通过组合来自词汇表的两个子词(例如,developed = develop + ##ed)来表示出现在测试数据集中但未出现在训练集中的单词。基于单词的方法将无法这样做,而只能使用特殊标记替换看不见的单词。假设子词表[“walk”, “act”, “##ed”, “##ing”, “develop”]。即使训练数据中没有出现“developed”或“developing”这些单词,词汇表仍然可以通过组合来自词汇表的两个子词来表示这些单词。

要设置标记器,让我们首先导入 tf-models-official 库:

import tensorflow_models as tfm

然后,您可以按以下方式定义标记器:

vocab_file = os.path.join("data", "vocab.txt")

do_lower_case = True

tokenizer = tfm.nlp.layers.FastWordpieceBertTokenizer(
    vocab_file=vocab_file, lower_case=do_lower_case
)

在这里,你首先需要获取词汇文件的位置,并定义一些配置,比如在对文本进行标记化之前是否应该将其转换为小写。词汇文件是一个文本文件,每行都有一个子词。这个标记器使用了 Fast WordPiece Tokenization(arxiv.org/abs/2012.15524.pdf),这是原始 WordPiece 算法的高效实现。请注意,vocab_file 和 do_lower_case 是我们将在下一步从 TensorFlow hub 下载的模型工件中找到的设置。但为了方便理解,我们在这里将它们定义为常量。您将在笔记本中找到自动提取它们的代码。接下来,我们可以按照以下方式使用标记器:

tokens = tf.reshape(
    tokenizer(["She sells seashells by the seashore"]), [-1])
print("Tokens IDs generated by BERT: {}".format(tokens))
ids = [tokenizer._vocab[tid] for tid in tokens] 
print("Tokens generated by BERT: {}".format(ids))

它返回

Tokens IDs generated by BERT: [ 2016 15187 11915 18223  2015  2011  1996 11915 16892]
Tokens generated by BERT: ['she', 'sells', 'seas', '##hell', '##s', 'by', 'the', 'seas', '##hore']

您可以在这里看到 BERT 的标记器如何标记句子。有些单词保持原样,而有些单词则分成子词(例如,seas + ##hell + ##s)。如前所述,##表示它不标记一个单词的开头。换句话说,##表示这个子词需要添加另一个子词作为前缀才能得到一个实际的单词。现在,让我们来看看 BERT 模型使用的特殊标记以及为它们分配的 ID 是什么。这也验证了这些标记存在于标记器中:

special_tokens = ['[CLS]', '[SEP]', '[MASK]', '[PAD]']
ids = [tokenizer._vocab.index(tok) for tok in special_tokens]
for t, i in zip(special_tokens, ids):
    print("Token: {} has ID: {}".format(t, i))

这将返回

Token: [CLS] has ID: 101
Token: [SEP] has ID: 102
Token: [MASK] has ID: 103
Token: [PAD] has ID: 0

在这里,[PAD]是 BERT 用来表示填充令牌(0)的另一个特殊标记。在 NLP 中,常常使用填充来将不同长度的句子填充到相同的长度,填充的句子是用零填充的。在这里,[PAD]标记对应了零。

了解了标记器的基本功能后,我们可以定义一个名为 encode_sentence()的函数,将给定的句子编码为 BERT 模型所理解的输入(请参见下一个清单)。

清单 13.2 使用 BERT 的标记器对给定的输入字符串进行编码

def encode_sentence(s):
    """ Encode a given sentence by tokenizing it and adding special tokens """

    tokens = list(
        tf.reshape(tokenizer(["CLS" + s + "[SEP]"]), [-1])
    )                   ❶
    return tokens       ❷

❶ 将特殊的 [CLS] 和 [SEP] 标记添加到序列中并获取标记 ID。

❷ 返回标记 ID。

在这个函数中,我们返回标记化的输出,首先添加 [CLS] 标记,然后将给定的字符串标记化为子词列表,最后添加 [SEP] 标记来标记句子/序列的结束。例如,句子 “I like ice cream”

encode_sentence("I like ice cream")

将返回

[101, 1045, 2066, 3256, 6949, 102]

如我们所见,标记 ID 101(即,[CLS])在开头,而 102(即,[SEP])在结尾。其余的标记 ID 对应于我们输入的实际字符串。仅仅为 BERT 标记化输入是不够的;我们还必须为模型定义一些额外的输入。例如,句子

"I like ice cream"

应该返回一个如下所示的数据结构:

{
    'input_word_ids': [[ 101, 1045, 2066, 3256, 6949,  102,    0,    0]], 
    'input_mask': [[1., 1., 1., 1., 1., 1., 0., 0.]], 
    'input_type_ids': [[0, 0, 0, 0, 0, 0, 0, 0]]
}

让我们讨论这个数据结构中的各种元素。BERT 以字典形式接受输入,其中

  • 关键 input_ids 表示从之前定义的 encode_sentence 函数中获得的标记 ID

  • 关键 input_mask 表示一个与 input_ids 相同大小的掩码,其中 1 表示不应屏蔽的值(例如,输入序列中的实际标记和特殊标记,如 [CLS] 标记 ID 101 和 [SEP] 标记 ID 102),而 0 表示应屏蔽的标记(例如,[PAD] 标记 ID 0)。

  • 输入关键 input_type_ids 是一个大小与 input_ids 相同的由 1 和 0 组成的矩阵/向量。这表示每个标记属于哪个句子。请记住,BERT 可以接受两种类型的输入:具有一个序列的输入和具有两个序列 A 和 B 的输入。input_type_ids 矩阵表示每个标记属于哪个序列(A 或 B)。由于我们的输入中只有一个序列,我们简单地创建一个大小与 input_ids 相同的零矩阵。

函数 get_bert_inputs() 将使用一组文档(即,一个字符串列表,其中每个字符串是一个输入;请参见下一个清单)以这种格式生成输入。

列表 13.3 将给定输入格式化为 BERT 接受的格式

def get_bert_inputs(tokenizer, docs,max_seq_len=None):
    """ Generate inputs for BERT using a set of documents """

    packer = tfm.nlp.layers.BertPackInputs(                      ❶
        seq_length=max_seq_length,
        special_tokens_dict = tokenizer.get_special_tokens_dict()
    )

    packed = packer(tokenizer(docs))                             ❷

    packed_numpy = dict(
        [(k, v.numpy()) for k,v in packed.items()]               ❸
    )
    # Final output
    return packed_numpy                                          ❹

❶ 使用 BertPackInputs 生成标记 ID、掩码和段 ID。

❷ 为 docs 中的所有消息生成输出。

❸ 将 BertPackInputs 的输出转换为一个键为字符串、值为 numpy 数组的字典。

❹ 返回结果。

这里我们使用 BertPackInputs 对象,它接受一个数组,其中每个项目都是包含消息的字符串。然后 BertPackInputs 生成一个包含以下处理过的输出的字典:

  • input_word_ids ——带有 [CLS] 和 [SEP] 标记 ID 的标记 ID,会自动添加。

  • input_mask ——一个整数数组,其中每个元素表示该位置是真实标记(1)还是填充标记(0)。

  • input_type_ids ——一个整数数组,其中每个元素表示每个标记属于哪个段。在这种情况下,它将是一个全零数组。

BertPackInputs 执行了 BERT 模型需要的许多不同的预处理操作。您可以在 www.tensorflow.org/api_docs/python/tfm/nlp/layers/BertPackInputs 上阅读关于此层接受的各种输入的信息。

要为模型生成准备好的训练、验证和测试数据,只需调用 get_bert_inputs() 函数:

train_inputs = get_bert_inputs(train_x, max_seq_len=80)
valid_inputs = get_bert_inputs(valid_x, max_seq_len=80)
test_inputs = get_bert_inputs(test_x, max_seq_len=80)

完成后,让我们作为预防措施对 train_inputs 中的数据进行洗牌。目前,数据是有序的,即垃圾邮件消息在正常邮件消息之后:

train_inds = np.random.permutation(len(train_inputs["input_word_ids"]))
train_inputs = dict(
    [(k, v[train_inds]) for k, v in train_inputs.items()]
)
train_y = train_y[train_inds]

记得对输入和标签都使用相同的洗牌方式来保持它们的关联。我们已经做好了为模型准备输入的一切。现在是揭晓模型的大时刻了。我们需要定义一个具有分类头的 BERT,以便模型可以在我们的分类数据集上进行端到端的训练。我们将分两步来完成这个过程。首先,我们将从 TensorFlow hub 下载 BERT 的编码器部分,然后使用 tensorflow-models-official 库中的 tfm.nlp.models.BertClassifier 对象来生成最终的带有分类器头的 BERT 模型。让我们来看看我们如何完成第一部分:

import tensorflow_hub as hub

hub_bert_url = "https://tfhub.dev/tensorflow/bert_en_uncased_L-12_H-768_A-12/4"
max_seq_length = 60
# Contains input token ids
input_word_ids = tf.keras.layers.Input(
    shape=(max_seq_length,), dtype=tf.int32, name="input_word_ids"
)
# Contains input mask values
input_mask = tf.keras.layers.Input(
    shape=(max_seq_length,), dtype=tf.int32, name="input_mask"
)
input_type_ids = tf.keras.layers.Input(
    shape=(max_seq_length,), dtype=tf.int32, name="input_type_ids"
)

# BERT encoder downloaded from TF hub
bert_layer = hub.KerasLayer(hub_bert_url, trainable=True)

# get the output of the encoder
output = bert_layer({
    "input_word_ids":input_word_ids, 
    "input_mask": input_mask, 
    "input_type_ids": input_type_ids
})

# Define the final encoder as with the Functional API
hub_encoder = tf.keras.models.Model(
    inputs={
        "input_word_ids": input_word_ids, 
        "input_mask": input_mask, 
        "input_type_ids": input_type_ids
    }, 
    outputs={
        "sequence_output": output["sequence_output"], 
        "pooled_output": output["pooled_output"]
    }
)

在这里,我们首先定义了三个输入层,每个输入层都映射到 BertPackInputs 的一个输出。例如,input_word_ids 输入层将接收到在 get_bert_inputs() 函数生成的字典中键为 input_word_ids 的输出。接下来,我们通过向 hub.KerasLayer 对象传递一个 URL 来下载预训练的 BERT 编码器。这个层会生成两个输出:sequence_output,其中包含所有时间步长的隐藏表示,和 pooled_output,其中包含与 [CLS] 标记位置对应的隐藏表示。对于这个问题,我们需要后者,将其传递给位于编码器顶部的分类头。最后,我们将使用 Functional API 定义一个 Keras 模型。这个模型需要通过字典定义特定的输入和输出签名,如之前所示。我们将使用这个模型来定义一个基于这个编码器的分类器模型:

# Generating a classifier and the encoder
bert_classifier = tfm.nlp.models.BertClassifier(
    network=hub_encoder, num_classes=2
)

正如您所见,定义分类器非常简单。我们只需将我们的 hub_encoder 传递给 BertClassifier,并声明我们有两个类别,即垃圾邮件和正常邮件(即 num_classes=2)。

获取 BERT 编码器的另一种方式

还有另一种方法可以获得一个 BERT 编码器。然而,它需要手动加载预训练的权重;因此,我们将保留此方法作为另一种选择。首先,你需要使用包含编码器模型各种超参数的配置文件。我已经为你提供了用于该模型的原始配置的 YAML 文件(Ch12/data/bert_en_uncased_base.yaml)。它包含了 BERT 使用的各种超参数(例如,隐藏维度大小,非线性激活等)。请随意查看它们,以了解用于该模型的不同参数。我们将使用 yaml 库将这些配置加载为字典,并将其存储在 config_dict 中。接下来,我们生成 encoder_config,一个使用我们加载的配置初始化的 EncoderConfig 对象。定义了 encoder_config 后,我们将构建一个 BERT 编码器模型,该模型能够生成标记的特征表示,然后使用此编码器作为网络调用 bert.bert_models.classifier_model()。请注意,此方法得到一个随机初始化的 BERT 模型:

import yaml

with open(os.path.join("data", "bert_en_uncased_base.yaml"), 'r') as stream:
    config_dict = yaml.safe_load(stream)['task']['model']['encoder']['bert']

encoder_config = tfm.nlp.encoders.EncoderConfig({
    'type':'bert',
    'bert': config_dict
})

bert_encoder = tfm.nlp.encoders.build_encoder(encoder_config)

bert_classifier = tfm.nlp.models.BertClassifier(
    network=bert_encoder, num_classes=2
)

如果你想要一个类似这样的预训练版本的 BERT,那么你需要下载 TensorFlow checkpoint。你可以通过转到 bert_url 中的链接,然后点击下载来完成这个过程。最后,你使用以下命令加载权重:

checkpoint = tf.train.Checkpoint(encoder=bert_encoder)
checkpoint.read(<path to .ckpt>).assert_consumed()

现在你有了一个预训练的 BERT 编码器。

接下来,让我们讨论如何编译构建好的模型。

编译模型

在这里,我们将定义优化器来训练模型。到目前为止,我们在 TensorFlow/Keras 中提供的默认优化器选项中没有太多变化。这次,让我们使用在 tf-models-official 库中提供的优化器。优化器可以通过调用 nlp.optimization.create_optimizer() 函数进行实例化。这在下一列表中有概述。

列表 13.4 在垃圾邮件分类任务上优化 BERT

epochs = 3
batch_size = 56
eval_batch_size = 56

train_data_size = train_x.shape[0]
steps_per_epoch = int(train_data_size / batch_size)
num_train_steps = steps_per_epoch * epochs
warmup_steps = int(num_train_steps * 0.1)

init_lr = 3e-6
end_lr = 0.0

linear_decay = tf.keras.optimizers.schedules.PolynomialDecay(
    initial_learning_rate=init_lr,
    end_learning_rate=end_lr,
    decay_steps=num_train_steps)

warmup_schedule = tfm.optimization.lr_schedule.LinearWarmup(
    warmup_learning_rate = 1e-10,
    after_warmup_lr_sched = linear_decay,
    warmup_steps = warmup_steps
)

optimizer = tf.keras.optimizers.experimental.Adam(
    learning_rate = warmup_schedule
)

作为默认优化器,带权重衰减的 Adamarxiv.org/pdf/1711.05101.pdf)被使用。带权重衰减的 Adam 是原始 Adam 优化器的一个变体,但具有更好的泛化性质。num_warmup_steps 表示学习率预热的持续时间。在预热期间,学习率在 num_warmup_steps 内线性增加,从一个小值线性增加到 init_lr(在 linear_decay 中定义)。之后,在 num_train_steps 期间,学习率使用多项式衰减(mng.bz/06lN)从 init_lr (在 linear_decay 中定义)衰减到 end_lr。这在图 13.9 中有所描述。

13-09

图 13.9 随着训练逐步进行(即迭代步数),学习率的行为

现在我们可以像以前一样编译模型了。我们将定义一个损失(稀疏分类交叉熵损失)和一个指标(使用标签而不是 one-hot 向量计算的准确度),然后将优化器、损失和指标传递给 hub_classifier.compile() 函数:

metrics = [tf.keras.metrics.SparseCategoricalAccuracy('accuracy', 
➥ dtype=tf.float32)]
loss = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)

hub_classifier.compile(
    optimizer=optimizer,
    loss=loss,
    metrics=metrics)

训练模型

我们已经走了很长的路,现在剩下的就是训练模型。模型训练非常简单,类似于我们使用 tf.keras.Model.fit() 函数训练模型的方式:

hub_classifier.fit(
      x=train_inputs, 
      y=train_y,
      validation_data=(valid_inputs, valid_y),
      validation_batch_size=eval_batch_size,
      batch_size=batch_size,
      epochs=epochs)

我们将使用 get_bert_inputs() 函数准备的 train_inputs 传递给参数 x,将 train_y(即一个由 1 和 0 组成的向量,分别表示输入是垃圾邮件还是正常邮件)传递给 y。类似地,我们将 validation_data 定义为一个包含 valid_inputs 和 valid_y 的元组。我们还传入了 batch_size(训练批量大小)、validation_batch_size 和要训练的 epochs 数量。

评估和解释结果

当你运行训练时,你应该得到接近以下结果。这里你可以看到训练损失和准确率,以及验证损失和准确率:

Epoch 1/3
18/18 [==============================] - 544s 29s/step - loss: 0.7082 - 
➥ accuracy: 0.4555 - val_loss: 0.6764 - val_accuracy: 0.5150
Epoch 2/3
18/18 [==============================] - 518s 29s/step - loss: 0.6645 - 
➥ accuracy: 0.6589 - val_loss: 0.6480 - val_accuracy: 0.8150
Epoch 3/3
18/18 [==============================] - 518s 29s/step - loss: 0.6414 - 
➥ accuracy: 0.7608 - val_loss: 0.6391 - val_accuracy: 0.8550

控制台输出清楚地显示了训练损失稳步下降,而准确率从 45% 上升到了 76%。模型已达到 85% 的验证准确率。这清楚地展示了像 BERT 这样的模型的强大之处。如果你要从头开始训练一个 NLP 模型,在这么短的时间内达到 85% 的验证准确率是不可能的。由于 BERT 具有非常强的语言理解能力,模型可以专注于学习手头的任务。请注意,由于我们的验证和测试集非常小(每个仅有 200 条记录),你可能会得到与此处所示不同水平的准确率。

注意 在一台配有 NVIDIA GeForce RTX 2070 8 GB 的 Intel Core i5 机器上,训练大约需要 40 秒来运行 3 个 epochs。

最后,让我们通过调用 evaluate() 函数在测试数据上测试模型。

hub_classifier.evaluate(test_inputs, test_y)

这将返回

7/7 [==============================] - 22s 3s/step - loss: 0.6432 - accuracy: 0.7950

再次,这是一个很棒的结果。仅经过三个 epochs,没有进行任何繁琐的参数调整,我们在测试数据上达到了 79.5% 的准确率。我们所做的一切就是在 BERT 之上拟合了一个逻辑回归层。

下一节将讨论我们如何定义一个能够从段落中找到答案的模型。为此,我们将使用到目前为止最流行的 Transformer 模型库之一:Hugging Face 的 transformers 库。

练习 2

你有一个包含五个类别的分类问题,并且想要修改 bert_classifier。给定正确格式的数据,你将如何更改所定义的 bert_classifier 对象?

13.3 使用 Hugging Face 的 Transformers 进行问答

你的朋友计划启动一个使用 ML 来找出开放领域问题答案的初创公司。缺乏 ML 背景,他转向你询问是否可以使用 ML 实现这一点。知道问题回答是机器可学习的,只要有标记数据,你决定使用 BERT 变体创建一个问答原型并进行演示。你将使用 SQUAD v1 问答数据集,并在该数据集上训练 DistilBERT(BERT 的一个变体)。为此,你将使用 Hugging Face 的 transformers 库(huggingface.co/transformers/)。Hugging Face 的 transformers 库提供了不同 Transformer 模型的实现和易于使用的 API 来训练/评估数据集上的模型。

BERT 旨在解决两种不同类型的任务:

  • 任务只有一个文本序列作为输入

  • 任务有两个文本序列(A 和 B)作为输入

垃圾邮件分类属于第一类。问题回答是一种具有两个输入序列的任务类型。在问题回答中,你有一个问题和一个内容(段落,句子等),其中可能包含问题的答案。然后训练一个模型来预测给定问题和内容的答案。让我们更好地了解一下过程。数据集中的每个记录将包含以下元素:

  • 一个问题(文本序列)

  • 一个内容(文本序列)

  • 答案在内容中的起始索引(整数)

  • 答案在内容中的结束索引(整数)

首先,我们需要将问题和内容结合起来,并添加几个特殊标记。在开头,我们需要添加一个[CLS]标记,然后添加一个[SEP]来分隔问题和内容,以及一个[SEP]来标记输入的结尾。此外,问题和内容将使用模型的分词器分解为标记(即子词)。对于具有

问题:狗对什么吠叫

答案:狗吠的是邮递员

如果我们将单个单词视为标记,输入将如下所示:

[CLS], What, did, the, dog, barked, at, [SEP], The, dog, barked, at, the, 
➥ mailman, [SEP]

接下来,这些标记被转换为 ID 并馈送到 BERT 模型。BERT 模型的输出连接到两个下游分类层:一个层预测答案的起始索引,而另一个层预测答案的结束索引。这两个分类层各自有自己的权重和偏差。

BERT 模型将为输入序列中的每个标记输出一个隐藏表示。跨越整个内容范围的标记输出表示被馈送给下游模型。每个分类层然后预测每个标记作为答案的起始/结束标记的概率。这些层的权重在时间维度上共享。换句话说,相同的权重矩阵应用于每个输出表示以预测概率。

对于本节,我们将使用 Hugging Face 的 transformers 库。有关更多详细信息,请参阅侧边栏。

Hugging Face 的 transformers 库

Hugging Face 是一家专注于解决 NLP 问题的公司。该公司提供了用于训练 NLP 模型以及托管数据集并在公开可访问的存储库中训练模型的库。我们将使用 Hugging Face 提供的两个 Python 库:transformers 和 datasets。

在撰写本书时,transformers 库(huggingface.co/transformers/)是最通用的 Python 库,提供了对许多已发布的 Transformer 模型(例如,BERT,XLNet,DistilBERT,Albert,RoBERT 等)以及社区发布的 NLP 模型(huggingface.co/models)的即时访问,transformers 库支持 TensorFlow 和 PyTorch 两种深度学习框架。PyTorch(pytorch.org/)是另一个类似于 TensorFlow 的深度学习框架,提供了全面的功能套件来实现和生产化深度学习模型。我在 transformers 库中看到的关键优势如下:

  • 一个易于理解的 API,用于预训练和微调模型,这对所有模型都是一致的

  • 能够下载各种 Transformer 模型的 TensorFlow 版本,并将其用作 Keras 模型

  • 能够转换 TensorFlow 和 PyTorch 模型

  • 强大的功能,如 Trainer(mng.bz/Kx9j),允许用户以非常灵活的方式创建和训练模型

  • 高度活跃的社区-贡献模型和数据集

13.3.1 理解数据

如前所述,我们将使用 SQuAD v1 数据集(rajpurkar.github.io/SQuAD-explorer/)。这是由斯坦福大学创建的问答数据集。您可以使用 Hugging Face 的 datasets 库轻松下载数据集,如下所示:

from datasets import load_dataset
dataset = load_dataset("squad")

让我们打印数据集并查看可用的属性:

print(dataset)

这将返回

DatasetDict({
    train: Dataset({
        features: ['id', 'title', 'context', 'question', 'answers'],
        num_rows: 87599
    })
    validation: Dataset({
        features: ['id', 'title', 'context', 'question', 'answers'],
        num_rows: 10570
    })
})

训练示例有 87,599 个,验证样本有 10,570 个。我们将使用这些示例来创建训练/验证/测试集分割。我们只对特征部分的最后三列感兴趣(即上下文,问题和答案)。其中,上下文和问题只是简单的字符串,而答案是一个字典。让我们进一步分析答案。您可以打印一些答案,如下所示:

dataset["train"]["answers"][:5]

这给了

[{'answer_start': [515], 'text': ['Saint Bernadette Soubirous']},
 {'answer_start': [188], 'text': ['a copper statue of Christ']},
 {'answer_start': [279], 'text': ['the Main Building']},
 {'answer_start': [381], 'text': ['a Marian place of prayer and reflection']},
 {'answer_start': [92], 'text': ['a golden statue of the Virgin Mary']}]

我们可以看到每个答案都有一个起始索引(基于字符)和包含答案的文本。有了这些信息,我们可以轻松计算答案的结束索引(即,结束索引 = 起始索引 + 文本长度)。

GLUE 基准任务套件

GLUE 基准测试(gluebenchmark.com/tasks)是一个用于评估自然语言处理模型的流行任务集合。它在多种任务上测试模型的自然语言理解能力。GLUE 任务集合包括以下任务。

13_table1

13.3.2 处理数据

此数据集存在几个完整性问题需要解决。我们将解决这些问题,然后创建一个 tf.data 流水线来传输数据。需要解决的第一个问题是给定的 answer_start 和实际的 answer_start 之间的对齐问题。一些示例往往在给定的 answer_start 和实际的 answer_start 位置之间存在大约两个字符的偏移。我们将编写一个函数来纠正此偏移,以及添加结束索引。下一节概述了执行此操作的代码。

列表 13.5 修正答案起始/结束索引中的不必要偏移量

def correct_indices_add_end_idx(answers, contexts):
    """ Correct the answer index of the samples (if wrong) """

    n_correct, n_fix = 0, 0                                       ❶
    fixed_answers = []                                            ❷
    for answer, context in zip(answers, contexts):                ❸

        gold_text = answer['text'][0]                             ❹
        answer['text'] = gold_text                                ❹
        start_idx = answer['answer_start'][0]                     ❺
        answer['answer_start'] = start_idx                        ❺
        if start_idx <0 or len(gold_text.strip())==0:
            print(answer)
        end_idx = start_idx + len(gold_text)                      ❻

        # sometimes squad answers are off by a character or two - fix this
        if context[start_idx:end_idx] == gold_text:               ❼
            answer['answer_end'] = end_idx
            n_correct += 1
        elif context[start_idx-1:end_idx-1] == gold_text:         ❽
            answer['answer_start'] = start_idx - 1
            answer['answer_end'] = end_idx - 1     
            n_fix += 1
        elif context[start_idx-2:end_idx-2] == gold_text:         ❾
            answer['answer_start'] = start_idx - 2
            answer['answer_end'] = end_idx - 2 
            n_fix +=1

        fixed_answers.append(answer)

    print(                                                        ❿
        "\t{}/{} examples had the correct answer indices".format(
            n_correct, len(answers)
        )
    )
    print(                                                        ❿
        "\t{}/{} examples had the wrong answer indices".format(
            n_fix, len(answers)
        )
    )
    return fixed_answers, contexts                                ⓫

❶ 记录正确并已修正的数量。

❷ 新修正的答案将存储在此变量中。

❸ 迭代每个答案上下文对。

❹ 将答案从字符串列表转换为字符串。

❺ 将答案的起始部分从整数列表转换为整数。

❻ 通过将答案的长度加到 start_idx 上来计算结束索引。

❼ 如果从 start_idx 到 end_idx 的片段与答案文本完全匹配,则不需要更改。

❽ 如果从 start_idx 到 end_idx 的片段需要偏移 1 才能与答案匹配,则相应地偏移。

❾ 如果从 start_idx 到 end_idx 的片段需要偏移 2 才能与答案匹配,则相应地偏移。

❿ 打印正确答案的数量(不需要更改)。

⓫ 打印需要修正的答案数量。

现在我们可以在数据集的两个子集(训练和验证)上调用此函数。我们将使用验证子集作为我们的测试集(未见过的示例)。将一部分训练示例保留为验证样本:

train_questions = dataset["train"]["question"]
train_answers, train_contexts = correct_indices_add_end_idx(
    dataset["train"]["answers"], dataset["train"]["context"]
)

test_questions = dataset["validation"]["question"]
test_answers, test_contexts = correct_indices_add_end_idx(
    dataset["validation"]["answers"], dataset["validation"]["context"]
)

当您运行此代码时,您将看到以下统计信息:

  • 训练数据修正

    • 87,341/87,599 个示例的答案索引是正确的。

    • 258/87,599 个示例的答案索引是错误的。

  • 验证数据修正

    • 10,565/10,570 个示例的答案索引是正确的。

    • 5/10,570 个示例的答案索引是错误的。

我们必须确保所需的修正数量不是非常高。如果修正数量显着高,通常意味着代码中存在错误或者数据加载逻辑存在问题。在这里,我们可以清楚地看到需要修正的示例数量很少。

定义和使用标记器

解决问题所需的所有数据都已经提供给我们了。现在是时候像以前一样对数据进行分词了。请记住,这些预训练的自然语言处理模型分为两部分:分词器和模型本身。分词器将文本分词为较小的部分(例如,子词),并以 ID 序列的形式呈现给模型。然后,模型接收这些 ID,并在它们上执行嵌入查找,以及各种计算,以得出最终的标记表示,这将作为输入传递给下游分类模型(即问答分类层)。在 transformers 库中,你有分词器对象和模型对象:

from transformers import DistilBertTokenizerFast
tokenizer = DistilBertTokenizerFast.from_pretrained('distilbert-base-uncased')

你可以看到我们正在使用一个名为 DistilBertTokenizerFast 的分词器。这个分词器来自一个称为 DistilBERT 的模型。DistilBERT 是 BERT 的一个较小版本,表现与 BERT 相似但体积较小。它使用了一种称为知识蒸馏的迁移学习技术进行训练(devopedia.org/knowledge-distillation)。要获得这个分词器,我们只需要调用 DistilBertTokenizerFast.from_pretrained() 函数,传入模型标签(即 distilbert-base-uncased)。这个标签表示模型是一个 DistilBERT 类型的模型,基本大小为(有不同大小的模型可用),并且忽略字符的大小写(由 uncased 表示)。模型标签指向 Hugging Face 的模型仓库中可用的一个模型,并为我们下载它。它将被存储在你的计算机上。

Hugging Face 提供了两种不同的分词器变体:标准分词器(PreTrainedTokenizer 对象;mng.bz/95d7)和快速分词器(PreTrainedTokenizerFast 对象;mng.bz/j2Xr)。你可以在mng.bz/WxEa中了解它们的区别)。在批量编码(即将字符串转换为标记序列)时,它们的速度明显更快。此外,快速分词器还有一些额外的方法,将帮助我们轻松地处理输入,以供问答模型使用。

什么是 DistilBERT?

跟随 BERT,DistilBERT 是 Hugging Face 在 2019 年的论文“DistilBERT, a distilled version of BERT: smaller, faster, cheaper and lighter” by Sanh et al. (arxiv.org/pdf/1910.01108v4.pdf)中介绍的模型。它是使用知识蒸馏的迁移学习技术训练的。其核心思想是拥有一个教师模型(即 BERT),其中一个较小的模型(即 DistilBERT)试图模仿教师的输出,这成为 DistilBERT 的学习目标。DistilBERT 比 BERT 要小,只有六层,而 BERT 有 12 层。DistilBERT 另一个关键的不同之处在于它仅在遮蔽语言建模任务上进行训练,而不是下一个句子预测任务。这个决定是基于一些研究的支持,这些研究质疑下一个句子预测任务(相比于遮蔽语言建模任务)对于自然语言理解的贡献。

有了下载的分词器,让我们通过提供一些示例文本来检查分词器以及它是如何转换输入的:

context = "This is the context"
question = "This is the question"

token_ids = tokenizer(context, question, return_tensors='tf')
print(token_ids)

这将返回

{'input_ids': <tf.Tensor: shape=(1, 11), dtype=int32, numpy=
array([[ 101, 2023, 2003, 1996, 6123,  102, 2023, 2003, 1996, 3160,  102]],
      dtype=int32)>, 
 'attention_mask': <tf.Tensor: shape=(1, 11), dtype=int32, numpy=array([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]], dtype=int32)>
}

接下来,打印与 ID 相对应的令牌

print(tokenizer.convert_ids_to_tokens(token_ids['input_ids'].numpy()[0]))

这将给出

['[CLS]', 'this', 'is', 'the', 'context', '[SEP]', 'this', 'is', 'the', 'question', '[SEP]']

现在我们可以使用接下来清单中的代码对所有训练和测试数据进行编码。

清单 13.6 编码训练和测试数据

train_encodings = tokenizer(                                          ❶
    train_contexts, train_questions, truncation=True, padding=True, 
➥ return_tensors='tf' 
)
print(
    "train_encodings.shape: {}".format(train_encodings["input_ids"].shape)
)

test_encodings = tokenizer(
    test_contexts, test_questions, truncation=True, padding=True, 
➥ return_tensors='tf'                                                ❷
)
print("test_encodings.shape: {}".format(test_encodings["input_ids"].shape))

❶ 编码 train 数据。

❷ 编码测试数据。

注意,我们在调用分词器时使用了几个参数。当启用截断和填充(即设置为 True)时,分词器将根据需要对输入序列进行填充/截断。你可以在创建分词器时传递一个参数(model_max_length)来将文本填充或截断到一定的长度。如果没有提供这个参数,它将使用在预训练时设置的默认长度作为分词器的配置之一。启用填充/截断后,你的输入将经历以下变化之一:

  • 如果序列比长度短,则在序列末尾添加特殊令牌[PAD]直到它达到指定的长度。

  • 如果序列比长度长,就会被截断。

  • 如果序列的长度恰好相同,则不会引入任何更改。

当你运行清单 13.6 中的代码时,会打印

train_encodings.shape: (87599, 512)
test_encodings.shape: (10570, 512)

我们可以看到,所有序列都被填充或截断,直到达到在模型预训练期间设置的 512 的长度。让我们看一下在使用 transformers 库定义分词器时需要注意的一些重要参数:

  • model_max_length (int, optional)—输入进行填充的最大长度(令牌数)。

  • padding_side (str, optional)—模型应用填充的一侧。可以是 ['right', 'left']。默认值从同名的类属性中选择。

  • model_input_names(List[string],可选)—模型前向传递接受的输入列表(例如,“token_type_ids”或“attention_mask”)。默认值从同名类属性中选取。

  • bos_token(str 或 tokenizers.AddedToken,可选)—表示句子开头的特殊标记。

  • eos_token(str 或 tokenizers.AddedToken,可选)—表示句子结尾的特殊标记。

  • unk_token(str 或 tokenizers.AddedToken,可选)—表示词汇表外单词的特殊标记。如果模型遇到以前没有见过的单词,词汇表外单词就很重要。

大多数这些参数可以安全地忽略,因为我们使用的是预训练的分词器模型,在训练之前已经设置了这些属性。

不幸的是,我们还需要做一些事情。我们必须做的一个重要转换是如何表示模型答案的开始和结束。正如我之前所说的,我们给出了答案的起始和结束字符位置。但是我们的模型只理解标记级别的分解,而不是字符级别的分解。因此,我们必须从给定的字符位置找到标记位置。幸运的是,快速分词器提供了一个方便的函数:char_to_token()。请注意,此函数仅适用于快速分词器(即 PreTrainedTokenizerFast 对象),而不适用于标准分词器(即 PreTrainedTokenizer 对象)。char_to_token()函数接受以下参数:

  • batch_or_char_index(int)—批处理中的示例索引。如果批处理有一个示例,则将其用作我们感兴趣的要转换为标记索引的字符索引。

  • char_index(int,可选—如果提供了批索引,则表示我们要转换为标记索引的字符索引。

  • sequence_index(int,可选—如果输入有多个序列,则表示字符/标记所属的序列。

使用此函数,我们将编写 update_char_to_token_positions_inplace()函数将基于字符的索引转换为基于标记的索引(见下一个列表)。

列表 13.7 将 char 索引转换为基于标记的索引

def update_char_to_token_positions_inplace(encodings, answers):
    start_positions = []
    end_positions = []
    n_updates = 0

    for i in range(len(answers)):                                     ❶
        start_positions.append(
            encodings.char_to_token(i, answers[i]['answer_start'])    ❷
        )
        end_positions.append(
            encodings.char_to_token(i, answers[i]['answer_end'] - 1)  ❷
        )

        if start_positions[-1] is None or end_positions[-1] is None:
            n_updates += 1                                            ❸

        # if start position is None, the answer passage has been truncated
        # In the guide, 
➥ https:/ /huggingface.co/transformers/custom_datasets.xhtml#qa-squad
        # they set it to model_max_length, but this will result in NaN 
➥ losses as the last
        # available label is model_max_length-1 (zero-indexed)
        if start_positions[-1] is None:        
            start_positions[-1] = tokenizer.model_max_length -1       ❹

        if end_positions[-1] is None:
            end_positions[-1] = tokenizer.model_max_length -1         ❺

    print("{}/{} had answers truncated".format(n_updates, len(answers)))
    encodings.update({
        'start_positions': start_positions, 'end_positions': end_positions
    })                                                                ❻

update_char_to_token_positions_inplace(train_encodings, train_answers)
update_char_to_token_positions_inplace(test_encodings, test_answers)

❶ 遍历所有答案。

❷ 获取起始和结束字符位置的标记位置。

❸ 跟踪缺少答案的样本数量。

❹ 如果找不到起始位置,请将其设置为最后一个可用索引。

❺ 如果找不到结束位置,请将其设置为最后一个可用索引。

❻ 在原地更新编码。

这将打印

10/87599 had answers truncated
8/10570 had answers truncated

在列表 13.7 中的代码中,我们遍历数据集中的每个答案,并对每个答案的起始和结束(字符索引)调用 char_to_token()方法。 答案的新起始和结束标记索引分配给新的键 start_positions 和 end_positions。 此外,您可以看到有一个验证步骤,检查起始或结束索引是否为 None(即,在预处理时未找到合适的标记 ID)。 如果是这种情况,我们将序列的最后一个索引分配为位置。

有多少个烂鸡蛋?

您可以看到,我们正在打印需要修改的示例数量(例如,需要纠正)或已损坏的示例数量(截断的答案)。 这是一个重要的健全性检查,因为很高的数字可能表明数据质量存在问题或数据处理工作流中存在错误。 因此,始终打印这些数字并确保它们低到可以安全忽略为止。

我们现在将看到如何定义 tf.data 流水线。

从标记到 tf.data 流水线

经过所有的清洁和必要的转换,我们的数据集就像新的一样好。 我们所剩下的就是从数据创建一个 tf.data.Dataset。 我们的数据流水线将非常简单。 它将创建一个训练数据集,该数据集将被分成两个子集,训练集和验证集,并对数据集进行批处理。 然后创建并批处理测试数据集。 首先让我们导入 TensorFlow:

import tensorflow as tf

然后我们将定义一个生成器,将产生模型训练所需的输入和输出。 正如您所看到的,我们的输入是两个项目的元组。 它有

  • 填充的输入标记 ID(形状为[<数据集大小>,512])

  • 注意力掩码(形状为[<数据集大小>,512])

输出将由以下组成

  • 起始令牌位置(形状为[<数据集大小>])

  • 结束令牌位置(形状为[<数据集大小>]

def data_gen(input_ids, attention_mask, start_positions, end_positions):
    for inps, attn, start_pos, end_pos in zip(
        input_ids, attention_mask, start_positions, end_positions
    ):

        yield (inps, attn), (start_pos, end_pos)

我们的数据生成器以非常特定的格式返回数据。 它返回一个输入元组和一个输出元组。 输入元组按顺序具有标记 ID(input_ids)由标记器返回和注意力掩码(attention_mask)。 输出元组具有所有输入的答案的起始位置(start_positions)和结束位置(end_positions)。

我们必须将我们的数据生成器定义为可调用的(即,它返回一个函数,而不是生成器对象)。 这是我们将要定义的 tf.data.Dataset 的要求。 要从我们之前定义的生成器获取可调用函数,让我们使用 partial()函数。 partial()函数接受一个可调用的、可选的可调用关键字参数,并返回一个部分填充的可调用函数,您只需要提供缺少的参数(即,在部分调用期间未指定的参数):

from functools import partial

train_data_gen = partial(
    data_gen,
    input_ids=train_encodings['input_ids'],
    attention_mask=train_encodings['attention_mask'],
    start_positions=train_encodings['start_positions'],  
    end_positions=train_encodings['end_positions']
)

train_data_gen 可以视为一个没有参数的函数,因为我们已经在部分调用中提供了所有参数。由于我们已经将我们的数据定义为一个生成器的形式,我们可以使用 tf.data.Dataset.from_generator()函数生成数据。请记住,在通过生成器定义数据时,我们必须传递 output_types 参数。我们所有的输出都是 int32 类型。但它们作为一对元组出现:

train_dataset = tf.data.Dataset.from_generator(
    train_data_gen, output_types=(('int32', 'int32'), ('int32', 'int32'))
)

接下来,我们对数据进行洗牌以确保没有顺序。确保传递 buffer_size 参数,该参数指定了有多少样本被带入内存进行洗牌。由于我们计划使用其中的 10000 个样本作为验证样本,我们将 buffer_size 设置为 20000:

train_dataset = train_dataset.shuffle(20000)

是时候将 train_dataset 分割为训练和验证数据了,因为我们将使用原始验证数据子集作为测试数据。为了将 train_dataset 分割为训练和验证子集,我们将采取以下方法。在洗牌后,将前 10000 个样本定义为验证数据集。我们将使用 tf.data.Dataset.batch()函数进行数据批处理,批处理大小为 8:

valid_dataset = train_dataset.take(10000)
valid_dataset = valid_dataset.batch(8)

跳过前 10000 个数据点,因为它们属于验证集,将其余部分作为 train_dataset。我们将使用 tf.data.Dataset.batch()函数对数据进行批处理,批处理大小为 8:

train_dataset = train_dataset.skip(10000)
train_dataset = train_dataset.batch(8)

最后,使用相同的数据生成器定义测试数据:

test_data_gen = partial(data_gen,
    input_ids=test_encodings['input_ids'], 
    attention_mask=test_encodings['attention_mask'],
    start_positions=test_encodings['start_positions'], 
    end_positions=test_encodings['end_positions']
)
test_dataset = tf.data.Dataset.from_generator(
    test_data_gen, output_types=(('int32', 'int32'), ('int32', 'int32'))
)
test_dataset = test_dataset.batch(8)

接下来我们将着手定义模型。

13.3.3 定义 DistilBERT 模型

我们仔细查看了数据,使用标记器进行了清理和处理,并定义了一个 tf.data.Dataset,以便快速检索模型将接受的格式的示例批次。现在是定义模型的时候了。为了定义模型,我们将导入以下模块:

from transformers import TFDistilBertForQuestionAnswering

transformers 库为您提供了一个出色的现成模型模板选择,您可以下载并在任务上进行训练。换句话说,您不必费心琢磨如何在预训练 transformers 之上插入下游模型。例如,我们正在解决一个问答问题,我们想要使用 DistilBERT 模型。transformers 库具有 DistilBERT 模型的内置问答适配。您只需导入模块并调用 from_pretrained()函数并提供模型标签即可下载它:

model = TFDistilBertForQuestionAnswering.from_pretrained("distilbert-base-uncased")

这将下载该模型并保存在您的本地计算机上。

Transformers 库还提供了哪些其他现成模型可用?

您可以查看mng.bz/8Mwz以了解您可以轻松完成的 DistilBERT 模型的操作。transformers 是一个完整的库,您可以使用它来解决几乎所有常见的 NLP 任务,使用 Transformer 模型。在这里,我们将查看 DistilBERT 模型的选项。

TFDistilBertForMaskedLM

这使您可以使用掩码语言建模任务对 DistilBERT 模型进行预训练。在掩码语言建模任务中,给定文本语料库,单词将被随机屏蔽,并要求模型预测屏蔽的单词。

TFDistilBertForSequenceClassification

如果您的问题具有单个输入序列,并且您希望为输入预测标签(例如,垃圾邮件分类、情感分析等),您可以使用此模型端到端地训练模型。

TFDistilBertForMultipleChoice

使用此变体,DistilBERT 可用于解决多项选择问题。输入包括一个问题和几个答案。这些通常组合成单个序列(即,[CLS] [Question] [SEP] [Answer 1] [SEP] [Answer 2] [SEP]等),并且模型被要求预测问题的最佳答案,通常通过将[CLS]标记的表示传递给分类层来完成,该分类层将预测正确答案的索引(例如,第一个答案、第二个答案等)。

TFDistilBertForTokenClassification

命名实体识别或词性标注等任务需要模型为输入序列中的每个标记预测一个标签(例如,人物、组织等)。对于这样的任务,可以简单地使用这种类型的模型。

TFDistilBertForQuestionAnswering

这是我们场景中将使用的模型。我们有一个问题和一个上下文,模型需要预测答案(或答案在上下文中的起始/结束位置)。可以使用此模块解决此类问题。

表 13.1 总结了此侧边栏中的模型及其用法。

表 13.1 Hugging Face 的 transformers 库中不同模型及其用法总结

13_table2

博客jalammar.github.io/illustrated-bert/提供了一张非常详细的图表,展示了 BERT-like 模型如何用于不同的任务(如“特定任务模型”部分所述)。

对这些模型的训练和评估将非常类似于您如果将它们用作 Keras 模型时的使用方式。只要数据格式符合模型期望的正确格式,您就可以对这些模型调用 model.fit()、model.predict()或 model.evaluate()。

要训练这些模型,您还可以使用高级 Trainer(mng.bz/EWZd)。我们将在稍后更详细地讨论 Trainer 对象。

这一部分是我在使用该库时的一个警示。通常,一旦定义了模型,你可以像使用 Keras 模型一样使用它。这意味着你可以使用 tf.data.Dataset 调用 model.fit() 并训练模型。当训练模型时,TensorFlow 和 Keras 期望模型输出为张量或张量元组。然而,Transformer 模型的输出是特定对象(继承自 transformers.file_utils.ModelOutput 对象),如 mng.bz/N6Rn 中所述。这将引发类似以下的错误

TypeError: The two structures don't have the same sequence type. 
Input structure has type <class 'tuple'>, while shallow structure has type 
<class 'transformers.modeling_tf_outputs.TFQuestionAnsweringModelOutput'>.

为了修复这个问题,transformers 库允许你设置一个名为 return_dict 的特定配置,并确保模型返回一个元组,而不是一个对象。然后,我们定义一个 Config 对象,该对象具有 return_dict=False,并用新的 config 对象覆盖模型的默认 config。例如,对于 DistilBERT 模型,可以这样做

from transformers import DistilBertConfig, TFDistilBertForQuestionAnswering

config = DistilBertConfig.from_pretrained(
    "distilbert-base-uncased", return_dict=False
)
model = TFDistilBertForQuestionAnswering.from_pretrained(
    "distilbert-base-uncased", config=config
)

不幸的是,我无法通过使用这种配置使模型表现出我预期的行为。这表明,在编写代码时,即使使用一流的库,你也需要预料到可能出现的问题。最简单的解决方法是让 transformers 库输出一个 ModelOutput 对象,并编写一个包装函数,该函数将提取该对象的所需输出,并从这些输出创建一个 tf.keras.Model。以下清单中的函数为我们执行了这个任务。

将模型包装在 tf.keras.models.Model 对象中以防止错误

def tf_wrap_model(model):
    """ Wraps the huggingface's model with in the Keras Functional API """

    # Define inputs
    input_ids = tf.keras.layers.Input(
        [None,], dtype=tf.int32, name="input_ids"
    )                                                  ❶
    attention_mask = tf.keras.layers.Input(
      [None,], dtype=tf.int32, name="attention_mask"   ❷
    )

   out = model([input_ids, attention_mask])            ❸

   wrap_model = tf.keras.models.Model(
     [input_ids, attention_mask], 
     outputs=(out.start_logits, out.end_logits)        ❹
   )

   return wrap_model

❶ 定义一个输入层,该层将接收一个令牌序列的批次。

❷ 定义一个输入,用于编码时返回的 attention mask。

❸ 给定输入 id 和 attention_mask 获取模型输出。

❹ 定义一个包装模型,该模型以定义的输入作为输入,并输出开始和结束索引预测层的对数。

你可以通过调用清单 13.8 中的函数来生成生成校正输出的模型:

model_v2 = tf_wrap_model(model)

最后,使用损失函数(稀疏分类交叉熵,因为我们使用整数标签)、度量(稀疏准确性)和优化器(Adam 优化器)对模型进行编译:

loss = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)
acc = tf.keras.metrics.SparseCategoricalAccuracy()
optimizer = tf.keras.optimizers.Adam(learning_rate=1e-5)

model_v2.compile(optimizer=optimizer, loss=loss, metrics=[acc])

视觉中的 Transformer

Transformer 模型通常在自然语言处理领域表现出色。直到最近,人们才开始努力了解它们在计算机视觉领域的位置。然后谷歌和 Facebook AI 发表了几篇重要论文,研究了 Transformer 模型如何在计算机视觉领域中使用。

视觉 Transformer(ViT)

想法是将图像分解成 16×16 个小块,并将每个小块视为一个单独的令牌。每个图像路径被展平为一个 1D 向量,并使用位置编码机制对其位置进行编码,类似于原始的 Transformer。需要注意的是,原始 Transformer 中的位置编码是 1D 的。然而,图像是 2D 的。作者认为 1D 的位置编码已足够,且 1D 和 2D 位置编码之间没有太大的性能差异。一旦图像被分成 16×16 的块并展平,每个图像就可以被表示为一个令牌序列,就像文本输入序列一样。

然后,该模型以一种自监督的方式在一个名为 JFT-300M(paperswithcode.com/dataset/jft-300m)的视觉数据集中进行预训练。在视觉中制定自监督任务并不是一件微不足道的事情,就像在 NLP 中那样。在 NLP 中,您可以简单地形成一个目标,即预测文本序列中的掩蔽令牌。但是,在计算机视觉的上下文中,令牌是一系列连续的值(归一化的像素值)。因此,ViT 预先训练以预测给定图像补丁的平均三位 RGB 颜色。每个通道(即红色,绿色和蓝色)都用三位表示(即每个位的值为 0 或 1),这给出 512 个可能或类。换句话说,对于给定的图像,补丁将被随机遮盖(使用与 BERT 相同的方法),并且要求模型预测该图像补丁的平均三位 RGB 颜色。

首先,Dosovitskiy 等人在 2020 年 10 月发表了题为“一张图片值 16x16 个单词:用于大规模图像识别的变压器”(An Image Is Worth 16X16 Words: Transformers for Image Recognition at Scale)的文章(arxiv.org/pdf/2010.11929.pdf)。这可以被认为是走向视觉变换器的第一步。在这篇论文中,作者将原始 Transformer 模型适应于计算机视觉,对架构进行了最小限度的修改。这个模型被称为视觉变换器(ViT)。

在预训练后,可以通过在 ViT 的顶部适配一个分类或回归头来针对特定任务的问题进行微调,就像 BERT 一样。ViT 还在序列的开头有[CLS]令牌,该令牌将用作插入到 ViT 上面的下游视觉模型的输入表示。以下图示说明了 ViT 的机制。

ViT 的原始代码是使用一个称为 Jax 的框架编写的(github.com/google-research/vision_transformer)。然而,该模型有几个第三方 TensorFlow 包装器(例如github.com/emla2805/vision-transformer)。如果使用第三方版本,请确保阅读代码并验证正确性,因为第三方与原始研究团队没有关联。

13-09-unnumb-1

视觉 Transformer(ViT)模型架构及其在下游图像分类任务中的使用方式

统一 Transformer(UniT)

随后,Facebook AI 的一篇更具突破性的论文问世,题为“Transformer 就是你所需要的一切:统一 Transformer 实现多模态多任务学习”(作者:Hu 等)(arxiv.org/pdf/2102.10772.pdf)。该模型被称为统一 Transformer(UniT)。UniT 可以在计算机视觉和自然语言处理领域执行大量任务,只需改变分类头即可。

即使模型很复杂,整体上还是很直观的。有三个 Transformer 模型。一个 Transformer 对图像输入(如果有)进行编码,以生成图像的编码表示。下一个 Transformer 对文本输入(如果有)进行编码。最后,另一个 Transformer 将任务索引作为输入,获取嵌入并将其传递给一个跨自注意力层,该层以连接的图像和文本编码作为查询和键,并将任务嵌入(经过自注意力层后)作为值。这类似于 Transformer 解码器在其编码器-解码器注意力层中使用最后一个编码器输出生成查询和键,并使用解码器的输入作为值。该模型在下一页的图中表示。

UniT 在涉及八个数据集的七项任务上进行了评估。这些任务包括对象检测、视觉问答、对象注释和四个仅语言任务。这四个仅语言任务来自 GLUE 基准数据集,其中包括以下内容:

任务

对象检测—模型预测图像中存在的对象的矩形坐标(数据集 COCO 和 Visual Genome Detection [VGD])。

视觉问答(VQAv2)—给定一幅图像和一个问题,然后模型预测问题的答案,该答案可以在图像中找到(数据集:VQAv2)。

视觉蕴涵任务—一种视觉蕴涵任务,其中给定一幅图像和一个文本序列,模型预测句子是否语义上蕴含图像(SNLI-VE)。

问题自然语言推理(QNLI)—通过从给定上下文中提取答案来回答问题。

Quora 问题对(QQP)—从给定的问题对中识别重复的问题。

文本蕴涵—文本蕴涵着重于预测句子 A 是否蕴含/与句子 B 矛盾/与句子 B 中性(数据集 MNLI)。

情感分析—为给定的评论/文本预测情感(正面/负面/中性)(数据集 Stanford Sentiment Treebank [SST-2])。

13-09-unnumb-2

UniT 的整体架构。该模型由三个组件组成:图像编码器、文本编码器和任务解码器。最后,有几个分类头安装在任务解码器之上。

接下来,我们将训练我们刚刚定义的模型。

13.3.4 训练模型

我们一直在耐心地解决这个问题,最终,我们可以训练我们刚刚定义的模型,使用之前创建的 train_dataset 数据集,并使用 valid_dataset 来监视模型的准确度:

model_v2.fit(
    train_dataset, 
    validation_data=valid_dataset,    
    epochs=3
)

这将打印以下输出:

Epoch 1/3
WARNING:tensorflow:The parameters `output_attentions`, 
➥ `output_hidden_states` and `use_cache` cannot be updated when calling a 
➥ model.They have to be set to True/False in the config object (i.e.: 
➥ `config=XConfig.from_pretrained('name', output_attentions=True)`).

WARNING:tensorflow:The parameter `return_dict` cannot be set in graph mode 
➥ and will always be set to `True`.

9700/9700 [==============================] - 3308s 340ms/step - loss: 
➥ 4.3138 - tf_distil_bert_for_question_answering_loss: 2.2146 - 
➥ tf_distil_bert_for_question_answering_1_loss: 2.0992 - 
➥ tf_distil_bert_for_question_answering_sparse_categorical_accuracy: 
➥ 0.4180 - 
➥ tf_distil_bert_for_question_answering_1_sparse_categorical_accuracy: 
➥ 0.4487 - val_loss: 2.3849 - 
➥ val_tf_distil_bert_for_question_answering_loss: 1.2053 - 
➥ val_tf_distil_bert_for_question_answering_1_loss: 1.1796 - 
➥ val_tf_distil_bert_for_question_answering_sparse_categorical_accuracy: 
➥ 0.6681 - 
➥ val_tf_distil_bert_for_question_answering_1_sparse_categorical_accuracy
➥ : 0.6909

...

Epoch 3/3
9700/9700 [==============================] - 3293s 339ms/step - loss: 
➥ 1.6349 - tf_distil_bert_for_question_answering_loss: 0.8647 - 
➥ tf_distil_bert_for_question_answering_1_loss: 0.7703 - 
➥ tf_distil_bert_for_question_answering_sparse_categorical_accuracy: 
➥ 0.7294 - 
➥ tf_distil_bert_for_question_answering_1_sparse_categorical_accuracy: 
➥ 0.7672 - val_loss: 2.4049 - 
➥ val_tf_distil_bert_for_question_answering_loss: 1.2048 - 
➥ val_tf_distil_bert_for_question_answering_1_loss: 1.2001 - 
➥ val_tf_distil_bert_for_question_answering_sparse_categorical_accuracy: 
➥ 0.6975 - 
➥ val_tf_distil_bert_for_question_answering_1_sparse_categorical_accuracy
➥ : 0.7200

训练更新内容相当长,因此让我们将它们分解一下。有两个损失:

  • tf_distil_bert_for_question_answering_loss——测量起始索引预测头的损失

  • tf_distil_bert_for_question_answering_1_loss——测量结束索引预测头的损失

正如之前提到的,对于问答问题,我们有两个分类头:一个用于预测起始索引,另一个用于预测结束索引。对于准确度也有类似的情况。有两个准确度来衡量各自头部的性能:

  • tf_distil_bert_for_question_answering_sparse_categorical_accuracy——测量分类头预测起始索引的准确度

  • tf_distil_bert_for_question_answering_1_sparse_categorical_accuracy——测量分类头预测终结索引的准确度

我们可以看到模型对于起始和结束索引预测的训练准确度大约为 77%,而验证准确度分别为 70%和 72%。鉴于我们只对该模型进行了三次 epochs 的训练,这些准确度是不错的。

注意在一台配备了 NVIDIA GeForce RTX 2070 8GB 的 Intel Core i5 机器上,训练大约需要 2 小时 45 分钟来运行三个 epochs。

您可以看到模型训练过程中产生了几个警告。由于这是一个新库,非常重要的一点是要注意这些警告是否在我们使用这些模型的上下文中具有意义。如果看到错误,你不必担心这些警告,因为警告并不总是指示问题。根据我们要解决的问题,一些警告是不适用的,可以安全地忽略。第一个警告表示在调用模型时无法更新参数 output_attentions、output_hidden_states 和 use_cache,而需要作为 config 对象传递。我们对此并不担心,因为我们对模型不感兴趣引入任何自定义修改,并且我们使用的模型已经设计用于问答问题。

第二个警告表示 return_dict 将始终设置为 TRUE。设置 return_dict=True 意味着 Transformer 模型将返回一个 TensorFlow 或 Keras 无法理解的 ModelOutput 对象。当我们希望使用 Keras API 与模型一起使用时,这将在后续过程中造成问题。这就是我们创建 tf_wrap_model()函数的原因之一:确保我们获得一个总是输出元组而不是 ModelOutput 对象的 tf.keras.Model。

最后,我们将保存模型:

import os

# Create folders
if not os.path.exists('models'):
    os.makedirs('models')
if not os.path.exists('tokenizers'):
    os.makedirs('tokenizers')

tokenizer.save_pretrained(os.path.join('tokenizers', 'distilbert_qa'))

model_v2.get_layer(
    "tf_distil_bert_for_question_answering").save_pretrained(
        os.path.join('models', 'distilbert_qa')
    )
)

确保保存分词器和模型。要保存分词器,你可以简单地调用 save_pretrained() 函数并提供一个文件夹路径。分词器将保存在该目录中。保存模型需要更多的工作。我们无法直接保存模型(model_v2),因为当你的模型有一个自定义层时,为了正确保存,该层需要实现 get_config() 函数并指定该层的所有属性。然而,对于作为自定义层存在的 Transformer 模型,这样做将会非常困难。因此,我们只会通过调用 model_v2.get_layer() 函数和层名称(即 tf_distil_bert_for_question_answering)来保存 Transformer 模型,然后使用文件夹路径调用 save_pretrained() 方法。每当我们需要构建完整模型时,我们只需在保存的模型上调用 tf_wrap_model() 函数即可。

13.3.5 询问 BERT 一个问题

评估模型也是调用 model_v2.evaluate() 函数与我们之前创建的测试数据集相关的事情:

model_v2.evaluate(test_dataset)

这将打印

1322/1322 [==============================] - 166s 126ms/step - loss: 2.4756 
➥ - tf_distil_bert_for_question_answering_loss: 1.2702 - 
➥ tf_distil_bert_for_question_answering_1_loss: 1.2054 - 
➥ tf_distil_bert_for_question_answering_sparse_categorical_accuracy: 
➥ 0.6577 - 
➥ tf_distil_bert_for_question_answering_1_sparse_categorical_accuracy: 
➥ 0.6942

这是个好消息!我们在预测答案起始索引时达到了约 65.7%的准确率,而模型能够以大约 69.4%的准确率预测结束索引。需要观察的两件事是起始和结束的准确率都相似,这意味着模型能够以这个准确率正确地获取答案(从开始到结束)。最后,这个准确率接近验证准确率,这意味着我们没有发生异常的过拟合。

正如我多次提到的,仅仅看一个数字通常不足以判断一个模型的性能。在评估对象时,视觉检查一直是人类的自然倾向。因此,作为一个严谨的数据科学家或机器学习工程师,尽可能地将其纳入机器学习工作流程是必要的。在下一个清单中,我们将向我们的模型提供来自测试集的一个问题,看看模型会产生什么。

清单 13.9 推断给定问题的模型的文本答案

i = 5

sample_q = test_questions[i]                                          ❶
sample_c = test_contexts[i]                                           ❶
sample_a = test_answers[i]                                            ❶
sample_input = (
    test_encodings["input_ids"][i:i+1], 
    test_encodings["attention_mask"][i:i+1]
)

def ask_bert(sample_input, tokenizer):

    out = model_v2.predict(sample_input)                              ❷
    pred_ans_start = tf.argmax(out[0][0])                             ❸
    pred_ans_end = tf.argmax(out[1][0])                               ❹
    print(
        "{}-{} token ids contain the answer".format(
            pred_ans_start, pred_ans_end
        )
    )
    ans_tokens = sample_input[0][0][pred_ans_start:pred_ans_end+1]    ❺

    return " ".join(tokenizer.convert_ids_to_tokens(ans_tokens))      ❻

print("Question")                                                     ❼
print("\t", sample_q, "\n")                                           ❼
print("Context")                                                      ❼
print("\t", sample_c, "\n")                                           ❼
print("Answer (char indexed)")                                        ❼
print("\t", sample_a, "\n")                                           ❼
print('='*50,'\n')

sample_pred_ans = ask_bert(sample_input, tokenizer)                   ❽

print("Answer (predicted)")                                           ❾
print(sample_pred_ans)                                                ❾
print('='*50,'\n')

❶ 定义一个示例问题、上下文和答案。

❷ 对示例输入进行模型预测。这将返回起始和结束索引的概率向量。

❸ 通过从起始索引概率向量中获取最大索引来获得预测的起始索引。

❹ 通过从结束索引概率向量中获取最大索引来获得预测的结束索引。

❺ 通过获取起始/结束索引之间的文本来获得字符串答案标记。

❻ 返回一个单一字符串形式的标记列表。

❼ 打印模型的输入。

❽ 在定义的输入上调用 ask_bert 函数。

❾ 打印答案。

让我们详细说明清单 13.9 中代码的细节。首先,我们定义一个索引 i。这个索引将用于从测试集中检索一个样本。sample_q、sample_c 和 sample_a 代表我们选择的样本的问题、上下文和答案。有了这些,我们可以定义 sample_input,它将包含模型理解的输入的编码表示。函数 ask_bert()接受一个使用 tokenizer 为模型准备的输入(用 sample_input 表示),以将答案的标记 ID 转换回可读的标记。该函数首先预测输入的输出,并获取答案的起始和结束标记 ID。最后,该函数将这些 ID 以及其中的单词转换为一个可理解的答案并返回文本。如果你打印这个过程的输出,你会得到以下结果:

Question
     What was the theme of Super Bowl 50? 

Context
     Super Bowl 50 was an American football game to determine the 
➥ champion of the National Football League (NFL) for the 2015 season. The 
➥ American Football Conference (AFC) champion Denver Broncos defeated the 
➥ National Football Conference (NFC) champion Carolina Panthers 24-10 to 
➥ earn their third Super Bowl title. The game was played on February 7, 
➥ 2016, at Levi's Stadium in the San Francisco Bay Area at Santa Clara, 
➥ California. As this was the 50th Super Bowl, the league emphasized the 
➥ "golden anniversary" with various gold-themed initiatives, as well as 
➥ temporarily suspending the tradition of naming each Super Bowl game 
➥ with Roman numerals (under which the game would have been known as 
➥ "Super Bowl L"), so that the logo could prominently feature the Arabic 
➥ numerals 50\. 

Answer (char indexed)
     {'answer_start': 487, 'text': '"golden anniversary"', 
➥ 'answer_end': 507} 

================================================== 

98-99 token ids contain the answer
Answer (predicted)
golden anniversary
================================================== 

这结束了我们关于使用 Hugging Face 的 Transformer 库实现 Transformer 模型的讨论。我们已经逐步介绍了您在解决任何 NLP 任务时可能遇到的所有步骤。Hugging Face 的 transformers 库仍然以在 TensorFlow 或 PyTorch 中实现 Transformer 模型而享有盛誉。

可视化注意力头

每当我们有机会解释深度学习模型并理解模型为何做出某种决定时,充分利用这个机会就显得很重要。拥有解剖和解释模型的能力有助于在用户之间建立信任。由于存在自注意力层,解释 Transformer 模型变得非常容易。使用自注意力层,我们可以找出模型在生成一个令牌的隐藏表示时注意到了哪些单词。

我们可以使用 bert_viz 库(github.com/jessevig/bertviz)来可视化任意层中任意注意力头中的注意力模式。重要的是要注意,bert_viz 不支持 TensorFlow,而是使用 PyTorch 库。尽管有这个小小的技术差异,但使用 bert_viz 很容易和直观。

首先,导入所需的库:

import torch
from bertviz import head_view

接下来,定义一个 BERT 模型。确保将 output_attentions=True 配置传递给输出注意力输出,因为默认情况下它是关闭的:

config = BertConfig.from_pretrained(
    'bert-base-uncased', output_attentions=True
)
bert = TFBertModel.from_pretrained(
    "bert-base-uncased", config=config
)

编码输入文本,然后获取模型的输出:

encoded_input = tokenizer(text, return_tensors='tf')
output = model(encoded_input)

最后调用 head_view()函数。你可以通过简单调用 output.attentions 来获取注意力输出,它将返回一个包含 12 个张量的元组。每个张量对应于 BERT 中的一个单独层。此外,确保将它们转换为 torch 张量。否则,该函数会出错。输出在图 13.10 中可视化。

head_view(
    [torch.from_numpy(layer_attn.numpy()) for layer_attn in output.attentions],
    encoded_tokens
)

这结束了我们关于 Transformer 模型的讨论。然而,重要的是要记住,Transformer 模型正在不断发展并变得更好。在下一章中,我们将讨论一个重要的可视化工具,称为 TensorBoard,它与 TensorFlow 一起提供。

13-10

图 13.10 bert_viz 库的注意力输出。您可以从下拉菜单中选择不同的层。不同的阴影代表该层中不同的注意力头,可以打开或关闭。两列之间的线表示模型在生成给定标记的隐藏表示时关注哪些词。

练习 3

您被要求实现一个命名实体识别模型。命名实体识别是一个标记分类任务,其中为每个标记分配一个标签(例如,人物、组织、地理、其他等)。有七种不同的标签。如果您想要使用 distilbert-base-uncased 模型进行此操作,您将如何定义模型?请记住,在 transformers 库中,您可以将 num_labels 作为关键字传递以定义输出类的数量。例如,如果您有一个要设置为 "abc" 的配置 "a",您可以这样做

<model>.from_pretrained(<model_tag>, *"*a*"*= *"*abc*"*)

概要

  • Transformer 模型的主要子组件包括嵌入(标记和位置)、自注意力子层、完全连接子层、残差连接和层归一化子层。

  • BERT 是一个 Transformer 编码器模型,为输入传递的每个标记生成一个隐藏的(关注的)表示。

  • BERT 使用特殊标记,如 [CLS](表示开始并用于生成分类头的输出)、[SEP](用于标记两个子序列之间的分隔;例如,在问答中标记问题和上下文之间的分隔)、[PAD](表示填充的标记,将所有序列都调整为固定长度)、以及 [MASK](用于在输入序列中屏蔽标记;例如,填充的标记)。

  • BERT 可以通过在 BERT 的最终输出之上适配一个分类头(例如,逻辑回归层)来用于下游、任务特定的分类任务。根据任务类型,可能需要多个分类头,并且分类头的利用可能有所不同。

  • Hugging Face 的 transformers 库提供了所有与 NLP 相关的 Transformer 模型的实现,并且可以轻松下载和在工作流中使用。可下载的预训练模型具有两个主要组件:分词器,将提供的字符串标记为标记序列;模型,接受标记序列以生成最终的隐藏输出。

练习答案

练习 1

PE(pos,2i ) = sin(pos/10000^(21/d[model]))

import tensorflow as tf

# Defining some hyperparameters
n_steps = 25 # Sequence length
n_en_vocab = 300 # Encoder's vocabulary size
n_heads = 8 # Number of attention heads
d = 512 # The feature dimensionality of each layer

# Encoder input layer
en_inp = tf.keras.layers.Input(shape=(n_steps,))
# Encoder input embedddings
en_emb = tf.keras.layers.Embedding(
    n_en_vocab, 512, input_length=n_steps
)(en_inp)

pos_inp = tf.constant(
    [[p/(10000**(2*i/d)) for p in range(d)] for i in range(n_steps)]
)
pos_inp = tf.expand_dims(pos_inp, axis=0)
en_pos_emb = tf.math.sin(pos_inp)

en_final_emb = en_emb + en_pos_emb

# Two encoder layers
en_out1 = EncoderLayer(d, n_heads)(en_emb)
en_out2 = EncoderLayer(d, n_heads)(en_out1)

model = tf.keras.models.Model(inputs=en_inp, output=en_out2)

练习 2

hub_classifier, hub_encoder = bert.bert_models.classifier_model(
    bert_config=bert_config, hub_module_url=bert_url, num_labels=5
)

练习 3

from transformers import TFDistilBertForTokenClassification

model = TFDistilBertForTokenClassification.from_pretrained(
    "distilbert-base-uncased", num_labels=7
*)*

第十四章:TensorBoard:TensorFlow 的大兄弟

本章内容包括

  • 在 TensorBoard 上运行和可视化图像数据

  • 实时监测模型性能和行为

  • 使用 TensorBoard 进行性能分析模型建模

  • 使用 tf.summary 在自定义模型训练过程中记录自定义指标

  • 在 TensorBoard 上可视化和分析词向量

到目前为止,我们已经重点关注了各种模型。我们讨论了全连接模型(例如自动编码器)、卷积神经网络和循环神经网络(例如 LSTM、GRU)。在第十三章,我们谈到了 Transformer,这是一类强大的深度学习模型,为语言理解领域的最新最优性能打下了基础。此外,受到自然语言处理领域的成就启发,Transformer 在计算机视觉领域也引起了轰动。我们已经完成了建模步骤,但还需要通过其他几个步骤来最终收获成果。其中一个步骤是确保向模型提供的数据/特征是正确的,并且模型按预期工作。

在本章中,我们将探索机器学习的一个新方面:利用可视化工具包来可视化高维数据(例如图像、词向量等),以及跟踪和监控模型性能。让我们了解为什么这是一个关键需求。由于机器学习在许多不同领域的成功示范,机器学习已经深深扎根于许多行业和研究领域。因此,这意味着我们需要更快速地训练新模型,并在数据科学工作流程的各个步骤中减少摩擦(例如了解数据、模型训练、模型评估等)。TensorBoard 是迈出这一步的方向。它可以轻松跟踪和可视化数据、模型性能,甚至对模型进行性能分析,了解数据在哪里花费了最多的时间。

通常,您会将数据和模型度量值以及其他要可视化的内容写入日志目录。写入日志目录的内容通常被组织成子文件夹,文件夹的命名包含了日期、时间以及对实验的简要描述。这将有助于在 TensorBoard 中快速识别一个实验。TensorBoard 会不断搜索指定的日志目录以查找更改,并在仪表板上进行可视化。您将在接下来的章节中了解到这些步骤的具体细节。

14.1 使用 TensorBoard 可视化数据

假设您是一家时尚公司的数据科学家。他们要求您评估构建一个可以在给定照片中识别服装的模型的可行性。为此,您选择了 Fashion-MNIST 数据集,该数据集包含黑白服装图像,属于 10 个类别之一。一些类别包括 T 恤、裤子和连衣裙。您将首先加载数据并通过 TensorBoard 进行分析,TensorBoard 是一种可视化工具,用于可视化数据和模型。在这里,您将可视化一些图像,并确保在加载到内存后正确分配了类标签。

首先,我们将下载 Fashion-MNIST 数据集。Fashion-MNIST 是一个带有各种服装图片和相应标签/类别的标记数据集。Fashion-MNIST 主要受到了 MNIST 数据集的启发。为了恢复我们的记忆,MNIST 数据集由 28×28 大小的 0-9 数字图像和相应的数字标签构成。由于任务的容易性,许多人建议不再将 MNIST 作为性能基准数据集,因此 Fashion-MNIST 应运而生。与 MNIST 相比,Fashion-MNIST 被认为是一项更具挑战性的任务。

下载 Fashion-MNIST 数据集非常容易,因为它可通过 tensorflow_datasets 库获取:

import tensorflow_datasets as tfds

# Construct a tf.data.Dataset
fashion_ds = tfds.load('fashion_mnist')

现在,让我们打印来查看数据的格式:

print(fashion_ds)

这将返回

{'test': <PrefetchDataset shapes: {image: (28, 28, 1), label: ()}, types: 
➥ {image: tf.uint8, label: tf.int64}>, 'train': <PrefetchDataset shapes: 
➥ {image: (28, 28, 1), label: ()}, types: {image: tf.uint8, label: 
➥ tf.int64}>}

数据集包含两个部分,一个训练数据集和一个测试数据集。训练集有两个项:图像(每个图像尺寸为 28×28×1)和一个整数标签。测试集上也有同样的项。接下来,我们将创建三个 tf.data 数据集:训练集、验证集和测试集。我们将原始训练数据集分为两部分,一个训练集和一个验证集,然后将测试集保持不变(参见下一个代码清单)。

代码清单 14.1 生成训练、验证和测试数据集

def get_train_valid_test_datasets(fashion_ds, batch_size, 
➥ flatten_images=False):

    train_ds = fashion_ds["train"].shuffle(batch_size*20).map(
        lambda xy: (xy["image"], tf.reshape(xy["label"], [-1]))         ❶
    )
    test_ds = fashion_ds["test"].map(
        lambda xy: (xy["image"], tf.reshape(xy["label"], [-1]))         ❷
    )

    if flatten_images:
        train_ds = train_ds.map(lambda x,y: (tf.reshape(x, [-1]), y))   ❸
        test_ds = test_ds.map(lambda x,y: (tf.reshape(x, [-1]), y))     ❸

    valid_ds = train_ds.take(10000).batch(batch_size)                   ❹

    train_ds = train_ds.skip(10000).batch(batch_size)                   ❺

    return train_ds, valid_ds, test_ds

❶ 获取训练数据集,对其进行洗牌,并输出一个(image, label)元组。

❷ 获取测试数据集,并输出一个(image, label)元组。

❸ 将图像扁平化为 1D 向量,用于全连接网络。

❹ 将验证数据集设置为前 10,000 个数据点。

❺ 将训练数据集设置为其余数据。

这是一个简单的数据流程。原始数据集中的每个记录都以字典形式提供,包含两个键:image 和 label。首先,我们使用 tf.data.Dataset.map()函数将基于字典的记录转换为(image, label)的元组。然后,如果数据集要用于全连接网络,则可选择性地将 2D 图像扁平化为 1D 向量。换句话说,28 × 28 的图像将变为 784 大小的向量。最后,我们将前 10,000 个数据点(经过洗牌)作为验证集,其余数据作为训练集。

在 TensorBoard 上可视化数据的方式是通过将信息记录到预定义的日志目录中,通过 tf.summary.SummaryWriter()。这个写入器将以 TensorBoard 理解的特殊格式写入我们感兴趣的数据。接下来,您启动一个 TensorBoard 的实例,将其指向日志目录。使用这种方法,让我们使用 TensorBoard 可视化一些训练数据。首先,我们定义一个从标签 ID 到字符串标签的映射:

id2label_map = {
    0: "T-shirt/top",
    1: "Trouser",
    2:"Pullover",
    3: "Dress",
    4: "Coat",
    5: "Sandal",
    6: "Shirt",
    7: "Sneaker",
    8: "Bag",
    9: "Ankle boot"
}

这些映射是从 mng.bz/DgdR 中获得的。然后我们将定义日志目录。我们将使用日期时间戳来生成不同运行的唯一标识符,如下所示:

log_datetimestamp_format = "%Y%m%d%H%M%S"

log_datetimestamp = datetime.strftime(datetime.now(), 
➥ log_datetimestamp_format)
image_logdir = "./logs/data_{}/train".format(log_datetimestamp)

如果你对 log_datetimestamp_format 变量中的奇怪格式感到困惑,那么它是 Python 的 datetime 库使用的标准格式,用于定义日期和时间的格式(mng.bz/lxM2),如果你在你的代码中使用它们。具体来说,我们将会得到运行时间(由 datetime.now() 给出)作为一个没有分隔符的数字字符串。我们将得到一天中给定时间的年份(%Y)、月份(%m)、日期(%d)、24 小时制的小时(%H)、分钟(%M)和秒(%S)。然后,我们将数字字符串附加到日志目录中,以根据运行时间创建一个唯一标识符。接下来,我们通过调用以下函数定义一个 tf.summary.SummaryWriter(),并将日志目录作为参数传递。有了这个,我们使用这个摘要写入器所做的任何写入都将被记录在定义的目录中:

image_writer = tf.summary.create_file_writer(image_logdir)

接下来,我们打开已定义的摘要写入器作为默认写入器,使用一个 with 子句。一旦摘要写入器打开,任何 tf.summary.<数据类型> 对象都将将该信息记录到日志目录中。在这里,我们使用了一个 tf.summary.image 对象。你可以使用几种不同的对象来记录(www.tensorflow.org/api_docs/python/tf/summary):

  • tf.summary.audio—用于记录音频文件并在 TensorBoard 上听取的对象类型

  • tf.summary.histogram—用于记录直方图并在 TensorBoard 上查看的对象类型

  • tf.summary.image—用于记录图像并在 TensorBoard 上查看的对象类型

  • tf.summary.scalar—用于记录标量值(例如,在几个周期内计算的模型损失)并在 TensorBoard 上显示的对象类型

  • tf.summary.text—用于记录原始文本数据并在 TensorBoard 上显示的对象类型

在这里,我们将使用 tf.summary.image() 来编写并在 TensorBoard 上显示图像。 tf.summary.image() 接受几个参数:

  • name—摘要的描述。这将在在 TensorBoard 上显示图像时用作标签。

  • data—大小为[b, h, w, c]的图像批次,其中 b 是批次大小,h 是图像高度,w 是图像宽度,c 是通道数(例如,RGB)。

  • step—一个整数,可用于显示属于不同批次/迭代的图像(默认为 None)。

  • max_outputs—在给定时间内显示的最大输出数量。如果数据中的图片数量超过 max_outputs,则将显示前 max_outputs 个图片,其余图片将被静默丢弃(默认为三)。

  • description—摘要的详细描述(默认为 None)

我们将以两种方式编写两组图像数据:

  • 首先,我们将从训练数据集中逐个取出 10 张图像,并带有其类标签标记地写入它们。然后,具有相同类标签(即,类别)的图像将被嵌套在 TensorBoard 上的同一部分中。

  • 最后,我们将一次写入一批 20 张图像。

with image_writer.as_default():
    for data in fashion_ds["train"].batch(1).take(10):
        tf.summary.image(
            id2label_map[int(data["label"].numpy())], 
            data["image"], 
            max_outputs=10, 
            step=0
        )

# Write a batch of 20 images at once
with image_writer.as_default():
    for data in fashion_ds["train"].batch(20).take(1):
        pass
    tf.summary.image("A training data batch", data["image"], max_outputs=20, step=0)

然后,我们就可以开始可视化 TensorBoard 了。在 Jupyter 笔记本代码单元格中只需运行以下命令即可简单地初始化和加载 TensorBoard。您现在已经知道,Jupyter 笔记本由单元格组成,您可以在其中输入文本/代码。单元格可以是代码单元格、Markdown 单元格等:

%load_ext tensorboard
%tensorboard --logdir ./logs --port 6006

图 14.1 显示了代码在笔记本单元格中的外观。

14-01

图 14.1 在笔记本单元格中的 Jupyter 魔术命令

您可能会注意到,这不是典型的 Python 语法。以 % 符号开头的命令被称为 Jupyter 魔术命令。请记住,Jupyter 是生成笔记本的 Python 库的名称。您可以在 mng.bz/BMd1 查看此类命令的列表。第一个命令加载 TensorBoard Jupyter 笔记本扩展程序。第二个命令使用提供的日志目录(--logdir)参数和端口(--port)参数实例化 TensorBoard。如果不指定端口,则 TensorBoard 默认在 6006(或大于 6006 的第一个未使用的端口)上运行。图 14.2 显示了可视化图像的 TensorBoard 的外观。

14-02

图 14.2 TensorBoard 可视化记录的图像,以内联方式在 Jupyter 笔记本中显示。您可以对图像执行各种操作,例如调整亮度或对比度。此外,数据被记录到的子目录显示在左侧面板上,让您可以轻松地显示/隐藏不同的子目录以便进行更容易的比较。

或者,您还可以将 TensorBoard 可视化为 Jupyter 笔记本之外的浏览器选项卡。在浏览器中运行这两个命令后,打开 http://localhost:6006,将显示 TensorBoard,如图 14.2 所示。在下一节中,我们将看到在模型训练过程中如何使用 TensorBoard 来跟踪和监视模型性能。

练习 1

您有一个名为 step_image_batches 的变量中包含五批图像的列表。列表中的每个项目对应于训练的前五个步骤。您希望在 TensorBoard 中显示这些批次,每个批次都具有正确的步骤值。您可以将每个批次命名为 batch_0、batch_1 等等。您该如何做?

14.2 使用 TensorBoard 跟踪和监视模型

通过对 Fashion-MNIST 数据集中的数据有很好的理解,您将使用神经网络在此数据上训练模型,以衡量您能够对不同类型的服装进行多么准确的分类。您计划使用密集网络和卷积神经网络。您将在相同的条件下训练这两个模型(例如,数据集),并在 TensorBoard 上可视化模型的准确性和损失。

TensorBoard 的主要作用是能够在模型训练时可视化模型的性能。深度神经网络以其长时间的训练时间而闻名。毫无疑问,尽早识别模型中的问题是非常值得的。TensorBoard 在其中发挥着至关重要的作用。您可以将模型性能(通过评估指标)导入到 TensorBoard 中实时显示。因此,您可以在花费过多时间之前快速发现模型中的任何异常行为并采取必要的行动。

在本节中,我们将比较全连接网络和卷积神经网络在 Fashion-MNIST 数据集上的性能。让我们将一个小型全连接模型定义为我们想要使用该数据集测试的第一个模型。它将有三层:

  • 一个具有 512 个神经元和 ReLU 激活的层,该层接收来自 Fashion-MNIST 数据集的平坦图像

  • 一个具有 256 个神经元和 ReLU 激活的层,该层接收前一层的输出

  • 一个具有 softmax 激活的具有 10 个输出的层(表示类别)

该模型使用稀疏分类交叉熵损失和 Adam 优化器进行编译。由于我们对模型准确性感兴趣,因此我们将其添加到要跟踪的指标列表中:

from tensorflow.keras import layers, models

dense_model = models.Sequential([
    layers.Dense(512, activation='relu', input_shape=(784,)),
    layers.Dense(256, activation='relu'),
    layers.Dense(10, activation='softmax')
])

dense_model.compile(loss="sparse_categorical_crossentropy", optimizer='adam', metrics=['accuracy'])

模型完全定义后,我们对训练数据进行训练,并在验证数据集上进行评估。首先,让我们定义全连接模型的日志目录:

log_datetimestamp_format = "%Y%m%d%H%M%S"
log_datetimestamp = datetime.strftime(
    datetime.now(), log_datetimestamp_format
)

dense_log_dir = os.path.join("logs","dense_{}".format(log_datetimestamp))

与以往一样,您可以看到我们不仅将写入子目录而不是普通的平面目录,而且还使用了基于运行时间的唯一标识符。这些子目录中的每一个代表了 TensorBoard 术语中所谓的一个 run

在 TensorBoard 中组织运行

通常,用户在通过 TensorBoard 可视化时利用某种运行组织。除了为多个算法创建多个运行之外,常见的做法是向运行添加日期时间戳,以区分同一算法在不同场合运行的不同运行。

例如,您可能会测试相同的算法与不同的超参数(例如,层数、学习率、优化器等),并且可能希望将它们都放在一个地方查看。假设您想测试具有不同学习率(0.01、0.001 和 0.0005)的全连接层。您将在主日志目录中具有以下目录结构:

./logs/dense/run_2021-05-27-03-14-21_lr=0.01
./logs/dense/run_2021-05-27-09-02-52_lr=0.001
./logs/dense/run_2021-05-27-10-12-09_lr=0.001
./logs/dense/run_2021-05-27-14-43-12_lr=0.0005

或者您甚至可以使用更嵌套的目录结构:

./logs/dense/lr=0.01/2021-05-27-03-14-21
./logs/dense/lr=0.001/2021-05-27-09-02-52
./logs/dense/lr=0.001/2021-05-27-10-12-09
./logs/dense/lr=0.0005/2021-05-27-14-43-12

我想强调时间戳您的运行很重要,如侧边栏中所述。这样,您将为每次运行都有一个唯一的文件夹,并且可以随时返回到以前的运行以进行比较。接下来,让我们使用 get_train_valid_test()函数生成训练/验证/测试数据集。请确保设置 flatten_images=True:

batch_size = 64
tr_ds, v_ds, ts_ds = get_train_valid_test_datasets(
    fashion_ds, batch_size=batch_size, flatten_images=True
)

将模型指标传递给 TensorBoard 非常简单。在模型训练/评估期间,可以传递一个特殊的 TensorBoard 回调函数:

tb_callback = tf.keras.callbacks.TensorBoard(
    log_dir=dense_log_dir, profile_batch=0
)

让我们讨论一些您可以传递给 TensorBoard 回调函数的关键参数。默认的 TensorBoard 回调函数如下所示:

tf.keras.callbacks.TensorBoard(
    log_dir='logs', histogram_freq=0, write_graph=True,
    write_images=False, write_steps_per_second=False, update_freq='epoch',
    profile_batch=2, embeddings_freq=0, embeddings_metadata=None, 
)

现在我们将查看所提供的参数:

  • log_dir—用于日志记录的目录。一旦使用该目录(或该目录的父目录)启动了 TensorBoard,可以在 TensorBoard 上可视化信息(默认为“logs”)。

  • histogram_freq—在各个层中创建激活分布的直方图(稍后详细讨论)。此选项指定要多频繁(以 epochs 为单位)记录这些直方图(默认值为 0,即禁用)。

  • write_graph—确定是否将模型以图形的形式写入 TensorBoard 以进行可视化(默认为 True)。

  • write_image—确定是否将模型权重写为图像(即热图)以在 TensorBoard 上可视化权重(默认为 False)。

  • write_steps_per_second—确定是否将每秒执行的步骤数写入 TensorBoard(默认为 False)。

  • update_freq('batch'、'epoch'或整数)—确定是否每个批次(如果值设置为 batch)或每个 epoch(如果值设置为 epoch)向 TensorBoard 写入更新。传递一个整数,TensorFlow 将解释为“每 x 个批次写入 TensorBoard”。默认情况下,将每个 epoch 写入更新。写入磁盘很昂贵,因此过于频繁地写入将会降低训练速度。

  • profile_batch(整数或两个数字的列表)—确定要用于对模型进行分析的批次。分析计算模型的计算和内存使用情况(稍后详细讨论)。如果传递一个整数,它将分析一个批次。如果传递一个范围(即一个包含两个数字的列表),它将分析该范围内的批次。如果设置为零,则不进行分析(默认为 2)。

  • embedding_freq—如果模型具有嵌入层,则此参数指定可视化嵌入层的间隔(以 epochs 为单位)。如果设置为零,则禁用此功能(默认为 0)。

  • embedding_metadata—一个将嵌入层名称映射到文件名的字典。该文件应包含与嵌入矩阵中每行对应的标记(按顺序排列;默认为 None)。

最后,我们将像以前一样训练模型。唯一的区别是将 tb_callback 作为回调参数传递给模型:

dense_model.fit(tr_ds, validation_data=v_ds, epochs=10, callbacks=[tb_callback])

模型应该达到约 85%的验证准确率。现在打开 TensorBoard,访问 http:/ /localhost:6006。它将显示类似于图 14.3 的仪表板。随着日志目录中出现新数据,仪表板将自动刷新。

14-03

图 14.3 显示了 TensorBoard 上如何显示跟踪的指标。您可以看到训练和验证准确率以及损失值被绘制为折线图。此外,还有各种控件,例如最大化图形,切换到对数刻度 y 轴等等。

TensorBoard 仪表板具有许多控件,可以帮助用户通过记录的指标深入了解他们的模型。您可以打开或关闭不同的运行,具体取决于您要分析的内容。例如,如果您只想查看验证指标,则可以关闭 dense/train 运行,并反之亦然。Data/train 运行不会影响此面板,因为它包含我们从训练数据中记录的图像。要查看它们,可以单击 IMAGES 面板。

接下来,您可以更改平滑参数以控制曲线的平滑程度。通过使用曲线的平滑版本,有助于消除指标中的局部小变化,聚焦于整体趋势。图 14.4 描述了平滑参数对折线图的影响。

14-04

图 14.4 展示了平滑参数如何改变折线图。在这里,我们显示了使用不同平滑参数的相同折线图。您可以看到,随着平滑参数的增加,线条变得更平滑。原始线条以淡色显示。

此外,您还可以进行其他控制,例如切换到对数刻度 y 轴而不是线性。如果指标随时间观察到大幅变化,则这非常有用。在对数刻度下,这些大变化将变得更小。如果您需要更详细地检查图表,还可以在标准大小和全尺寸图之间切换。图 14.3 突出显示了这些控件。

之后,我们将定义一个简单的卷积神经网络,并执行相同的操作。也就是说,我们将首先定义网络,然后在使用回调函数到 TensorBoard 的同时训练模型。

让我们定义下一个我们将与全连接网络进行比较的模型:卷积神经网络(CNN)。同样,我们正在定义一个非常简单的 CNN,它包括

  • 一个 2D 卷积层,具有 32 个过滤器,5×5 内核,2×2 步幅和 ReLU 激活,该层接受来自 Fashion-MNIST 数据集的 2D 28×28 大小的图像

  • 一个具有 16 个过滤器的 2D 卷积层,具有 3×3 内核,1×1 步幅和 ReLU 激活,该层接受先前层的输出

  • 一个扁平化层,将卷积输出压成适用于密集层的 1D 向量

  • 具有 10 个输出(表示类别)并具有 softmax 激活的层

conv_model = models.Sequential([
    layers.Conv2D(
       filters=32, 
       kernel_size=(5,5), 
       strides=(2,2), 
       padding='same', 
       activation='relu', 
       input_shape=(28,28,1)
    ),
    layers.Conv2D(
        filters=16, 
        kernel_size=(3,3), 
        strides=(1,1), 
        padding='same', 
        activation='relu'
    ),
    layers.Flatten(),
    layers.Dense(10, activation='softmax')
])

conv_model.compile(
    loss="sparse_categorical_crossentropy", optimizer='adam', 
➥ metrics=['accuracy']
)
conv_model.summary()

接下来,我们将 CNN 相关的指标记录到一个名为./logs/conv_{datetimestamp}的单独目录中。这样,我们可以分别绘制完全连接的网络和 CNN 的评估指标。我们将生成训练和验证数据集以及一个 TensorBoard 回调,就像之前做的那样。然后,在调用 fit()方法训练模型时将它们传递给模型:

log_datetimestamp_format = "%Y%m%d%H%M%S"
log_datetimestamp = datetime.strftime(
    datetime.now(), log_datetimestamp_format
)

conv_log_dir = os.path.join("logs","conv_{}".format(log_datetimestamp))

batch_size = 64
tr_ds, v_ds, ts_ds = get_train_valid_test_datasets(
    fashion_ds, batch_size=batch_size, flatten_images=False
)

tb_callback = tf.keras.callbacks.TensorBoard(
    log_dir=conv_log_dir, histogram_freq=2, profile_batch=0
)

conv_model.fit(
    tr_ds, validation_data=v_ds, epochs=10, callbacks=[tb_callback]
)

注意我们在训练 CNN 时所做的更改。首先,我们不像训练完全连接的网络时那样将图像展平(即,在 get_train_valid_test_datasets()函数中设置 flatten_ images=False)。接下来,我们向 TensorBoard 回调引入了一个新参数。我们将使用 histogram_freq 参数来记录模型在训练过程中的层激活直方图。我们将很快更深入地讨论层激活直方图。这将在同一张图中显示两个模型(即密集模型和卷积模型)的准确度和损失指标,以便它们可以轻松比较(图 14.5)。

14-05

图 14.5 查看密集模型和卷积模型的指标。你可以根据需要比较不同的运行状态。

让我们再次回到激活直方图。激活直方图让我们可以可视化不同层的神经元激活分布随着训练的进行而变化的情况。这是一个重要的检查,它可以让你看到模型在优化过程中是否正在收敛,从而提供关于模型训练或数据质量的问题的见解。

让我们更深入地看一下这些直方图显示了什么。图 14.6 说明了我们训练的 CNN 生成的直方图。我们每两个时代绘制一次直方图。以直方图表示的权重堆叠在一起,这样我们就可以很容易地了解它们随时间的变化情况。直方图中的每个切片显示了给定层和给定时代中的权重分布。换句话说,它将提供诸如“有 x 个输出,其值为 y,大约为 z”的信息。

14-06

图 14.6 由 TensorBoard 显示的激活直方图。这些图表显示了给定层的激活分布随时间的变化情况(较浅的图表表示更近期的时代)。

通常,如果你有一个值的向量,创建直方图就相当简单。例如,假设值为[0.1, 0.3, 0.35, 0.5, 0.6, 0.61, 0.63],并且假设你有四个箱子:0.0, 0.2),[0.2, 0.4),[0.4, 0.6),和[0.6, 0.8)。你将得到图 14.7 所示的直方图。如果你看一下连接各条的中点的线,它类似于你在仪表板中看到的内容。

![14-07

图 14.7 生成的序列[0.1, 0.3, 0.35, 0.5, 0.6, 0.61, 0.63]的直方图

然而,当数据很大且稀疏(例如在权重矩阵中)时,计算直方图涉及更复杂的数据操作。例如,在 TensorBoard 中计算直方图涉及使用指数 bin 大小(与示例中的均匀 bin 大小相反),这在接近零时提供了更细粒度的 bin 和远离零时提供了更宽的 bin。然后,它将这些不均匀大小的 bin 重新采样为统一大小的 bin,以便更容易、更有意义地进行可视化。这些计算的具体细节超出了本书的范围。如果您想了解更多细节,请参考mng.bz/d26o

我们可以看到,在训练过程中,权重正在收敛于一个近似的正态分布。但是偏差收敛于一个多峰分布,并在许多不同的地方出现峰值。

本节阐述了如何使用 TensorBoard 进行一些主要数据可视化和模型性能跟踪。这些是您在数据科学项目中必须设置的核心检查点的重要组成部分。数据可视化需要在项目早期完成,以帮助您理解数据及其结构。模型性能跟踪非常重要,因为深度学习模型需要更长的训练时间,而您需要在有限的预算(时间和成本)内完成培训。在下一节中,我们将讨论如何记录自定义指标到 TensorBoard 并可视化它们。

练习 2

您有一个由 classif_model 表示的二元分类模型。您想在 TensorBoard 中跟踪此模型的精度和召回率。此外,您想在每个时期可视化激活直方图。您将如何使用 TensorBoard 回调编译模型,并使用 TensorBoard 回调拟合数据以实现此目的?TensorFlow 提供了 tf.keras.metrics.Precision()和 tf.keras.metrics.Recall()来分别计算精度和召回率。您可以假设您直接记录到./logs 目录。假设您已经提供了 tf.data.Dataset 对象的训练数据(tr_ds)和验证数据(v_ds)。

14.3 使用 tf.summary 在模型训练期间编写自定义度量

想象一下,您是一名博士研究批量归一化的影响。特别是,您需要分析给定层中的权重均值和标准偏差如何随时间变化,以及有无批量归一化。为此,您将使用一个全连接网络,并在 TensorBoard 上记录每个步骤的权重均值和标准偏差。由于这不是您可以使用 Keras 模型生成的典型指标,因此您将在自定义培训循环中记录每个步骤的模型训练期间的指标。

为了比较批量归一化的效果,我们需要定义两个不同的模型:一个没有批量归一化,一个有批量归一化。这两个模型将具有相同的规格,除了使用批量归一化。首先,让我们定义一个没有批量归一化的模型:

from tensorflow.keras import layers, models
import tensorflow.keras.backend as K

K.clear_session()
dense_model = models.Sequential([
    layers.Dense(512, activation='relu', input_shape=(784,)),    
    layers.Dense(256, activation='relu', name='log_layer'),    
    layers.Dense(10, activation='softmax')
])

dense_model.compile(loss="sparse_categorical_crossentropy", optimizer='adam', metrics=['accuracy'])

该模型非常简单,与我们之前定义的完全连接模型相同。它有三个层,分别有 512、256 和 10 个节点。前两层使用 ReLU 激活函数,而最后一层使用 softmax 激活函数。请注意,我们将第二个 Dense 层命名为 log_layer。我们将使用该层来计算我们感兴趣的指标。最后,该模型使用稀疏分类交叉熵损失、Adam 优化器和准确度作为指标进行编译。接下来,我们使用批量归一化定义相同的模型:

dense_model_bn = models.Sequential([
    layers.Dense(512, activation='relu', input_shape=(784,)),
    layers.BatchNormalization(),
    layers.Dense(256, activation='relu', name='log_layer_bn'),
    layers.BatchNormalization(),
    layers.Dense(10, activation='softmax')
])

dense_model_bn.compile(
    loss="sparse_categorical_crossentropy", optimizer='adam', 
➥ metrics=['accuracy']
)

引入批量归一化意味着在 Dense 层之间添加 tf.keras.layers.BatchNormalization()层。我们将第二个模型中感兴趣的层命名为 log_layer_bn,因为我们不能同时使用相同名称的两个层。

有了定义好的模型,我们的任务是在每一步计算权重的均值和标准差。为此,我们将观察两个网络的第二层的权重的均值和标准差(log_layer 和 log_layer_bn)。正如我们已经讨论过的,我们不能简单地传递一个 TensorBoard 回调并期望这些指标可用。由于我们感兴趣的指标不常用,我们必须费力确保这些指标在每一步都被记录。

我们将定义一个 train_model()函数,可以将定义的模型传递给它,并在数据上进行训练。在训练过程中,我们将计算每一步权重的均值和标准差,并将其记录到 TensorBoard 中(见下一个清单)。

清单 14.2 在自定义循环中训练模型时编写 tf.summary 对象

def train_model(model, dataset, log_dir, log_layer_name, epochs):    

    writer = tf.summary.create_file_writer(log_dir)                        ❶
    step = 0

    with writer.as_default():                                              ❷

        for e in range(epochs):
            print("Training epoch {}".format(e+1))
            for batch in tr_ds:

                model.train_on_batch(*batch)                               ❸

                weights = model.get_layer(log_layer_name).get_weights()[0] ❹

                tf.summary.scalar("mean_weights",np.mean(np.abs(weights)), ❺
➥ step=step)                                                              ❺
                tf.summary.scalar("std_weights", np.std(np.abs(weights)),  ❺
➥ step=step)                                                              ❺

                writer.flush()                                             ❻

                step += 1
            print('\tDone')

    print("Training completed\n")

❶ 定义写入器。

❷ 打开写入器。

❸ 用一个批次进行训练。

❹ 获取层的权重。它是一个数组列表[权重,偏差],顺序是这样的。因此,我们只取权重(索引 0)。

❺ 记录权重的均值和标准差(对于给定 epoch 是两个标量)。

❻ 从缓冲区刷新到磁盘。

注意我们如何打开一个 tf.summary.writer(),然后使用 tf.summary.scalar()调用在每一步记录指标。我们给指标起了有意义的名称,以确保在 TensorBoard 上可视化时知道哪个是哪个。有了函数定义,我们为我们编译的两个不同模型调用它:

batch_size = 64
tr_ds, _, _ = get_train_valid_test_datasets(
    fashion_ds, batch_size=batch_size, flatten_images=True
)
train_model(dense_model, tr_ds, exp_log_dir + '/standard', "log_layer", 5)

tr_ds, _, _ = get_train_valid_test_datasets(
    fashion_ds, batch_size=batch_size, flatten_images=True
)
train_model(dense_model_bn, tr_ds, exp_log_dir + '/bn', "log_layer_bn", 5)

请注意,我们指定不同的日志子目录,以确保出现的两个模型是不同的运行。运行后,您将看到两个新的附加部分,名为 mean_weights 和 std_weights(图 14.8)。似乎当使用批量归一化时,权重的均值和方差更加剧烈地变化。这可能是因为批量归一化在层之间引入了显式归一化,使得层的权重更自由地移动。

14-08

图 14.8 权重的均值和标准差在 TensorBoard 中绘制

接下来的部分详细介绍了如何使用 TensorBoard 来分析模型并深入分析模型执行时时间和内存消耗情况。

练习 3

你计划计算斐波那契数列(即 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55 等),其中第 n 个数字 x_n 由 x_n = x_{n - 1} + x_{n - 2} 给出。编写一个代码,计算 100 步的斐波那契数列并在 TensorBoard 中将其绘制为折线图。你可以将名字“fibonacci”作为指标名称。

14.4 对模型进行性能瓶颈检测

你现在作为一位数据科学家加入了一家正在识别濒临灭绝的花卉物种的生物技术公司。之前的一位数据科学家开发了一个模型,而你将继续这项工作。首先,你想确定是否存在任何性能瓶颈。为了分析这些问题,你计划使用 TensorBoard 分析器。你将使用一个较小的花卉数据集来训练模型,以便分析器可以捕获各种计算配置文件。

我们从列表 14.3 中的模型开始。这是一个具有四个卷积层的 CNN 模型,中间有池化层,包括三个完全连接的层,最后一个是具有 17 个输出类别的 softmax 层。

列表 14.3 你可用的 CNN 模型

def get_cnn_model():

    conv_model = models.Sequential([                                ❶
        layers.Conv2D(                                              ❷
            filters=64, 
            kernel_size=(5,5), 
            strides=(1,1), 
            padding='same', 
            activation='relu', 
            input_shape=(64,64,3)
        ),
        layers.BatchNormalization(),                                ❸
        layers.MaxPooling2D(pool_size=(3,3), strides=(2,2)),        ❹
        layers.Conv2D(                                              ❺
            filters=128, 
            kernel_size=(3,3), 
            strides=(1,1), 
            padding='same', 
            activation='relu'
        ),
        layers.BatchNormalization(),                                ❺
        layers.Conv2D(                                              ❺
            filters=256, 
            kernel_size=(3,3), 
            strides=(1,1), 
            padding='same', 
            activation='relu'
        ),
        layers.BatchNormalization(),                                ❺
        layers.Conv2D(                                              ❺
            filters=512, 
            kernel_size=(3,3), 
            strides=(1,1), 
            padding='same', 
            activation='relu'
        ),
        layers.BatchNormalization(),                                ❺
        layers.AveragePooling2D(pool_size=(2,2), strides=(2,2)),    ❻
        layers.Flatten(),                                           ❼
        layers.Dense(512),                                          ❽
        layers.LeakyReLU(),                                         ❽
        layers.LayerNormalization(),                                ❽
        layers.Dense(256),                                          ❽
        layers.LeakyReLU(),                                         ❽
        layers.LayerNormalization(),                                ❽
        layers.Dense(17),                                           ❽
        layers.Activation('softmax', dtype='float32')               ❽
    ])
    return conv_model

❶ 使用顺序 API 定义一个 Keras 模型。

❷ 定义一个接受大小为 64 × 64 × 3 的输入的第一个卷积层。

❸ 一个批量归一化层

❹ 一个最大池化层

❺ 一系列交替的卷积和批量归一化层

❻ 一个平均池化层,标志着卷积/池化层的结束

❼ 将最后一个池化层的输出展平。

❽ 一组稠密层(带有渗漏线性整流激活),接着是一个具有 softmax 激活的层

我们将使用的数据集是在www.robots.ox.ac.uk/~vgg/data/flowers找到的花卉数据集,具体来说,是 17 类别数据集。它有一个包含花朵图像的单独文件夹,每个图像文件名上都有一个数字。这些图像按照文件名排序时,前 80 个图像属于类别 0,接下来的 80 个图像属于类别 1,依此类推。你已经提供了下载数据集的代码,位于笔记本 Ch14/14.1_Tensorboard.ipynb 中,我们这里不会讨论。接下来,我们将编写一个简单的 tf.data 流水线,通过读取这些图像来创建数据批次:

def get_flower_datasets(image_dir, batch_size, flatten_images=False):

    # Get the training dataset, shuffle it, and output a tuple of (image, 
➥ label)
    dataset = tf.data.Dataset.list_files(
        os.path.join(image_dir,'*.jpg'), shuffle=False
    )

    def get_image_and_label(file_path):

        tokens = tf.strings.split(file_path, os.path.sep)
        label = (
            tf.strings.to_number(
                tf.strings.split(
                    tf.strings.split(tokens[-1],'.')[0], '_'
                )[-1]
            ) -1
        )//80

        # load the raw data from the file as a string
        img = tf.io.read_file(file_path)
        img = tf.image.decode_jpeg(img, channels=3)

        return tf.image.resize(img, [64, 64]), label

    dataset = dataset.map(get_image_and_label).shuffle(400)

    # Make the validation dataset the first 10000 data
    valid_ds = dataset.take(250).batch(batch_size)
    # Make training dataset the rest
    train_ds = dataset.skip(250).batch(batch_size)
    )

    return train_ds, valid_ds

让我们分析一下我们在这里所做的事情。首先,我们从给定文件夹中读取具有.jpg 扩展名的文件。然后我们有一个名为 get_image_and_label()的嵌套函数,它接受一个图像的文件路径,并通过从磁盘中读取该图像产生图像和标签。标签可以通过计算得到

  • 提取图像 ID

  • 减去 1(即将 ID 减 1,以使其成为从零开始的索引)并除以 80

之后,我们对数据进行洗牌,并将前 250 个数据作为验证数据,其余的作为训练数据。接下来,我们使用定义的这些函数并训练 CNN 模型,同时创建模型的各种计算性能分析。为了使性能分析工作,你需要两个主要的先决条件:

  • 安装 Python 包tensorboard_plugin_profile

  • 安装 libcupti,CUDA 性能分析工具包接口。

安装 CUDA 性能分析工具包接口(libcupti)

TensorBoard 需要 libcupti CUDA 库才能进行模型性能分析。安装此库需要根据所使用的操作系统的不同步骤。这假设您的计算机配备了 NVIDIA GPU。不幸的是,你将无法在 Mac 上执行此操作,因为 Mac 上没有可用于数据收集的性能分析 API。(查看developer.nvidia.com/cupti-ctk10_1u1中的需求部分。)

在 Ubuntu 上安装 libcupti

要在 Linux 上安装 libcupti,只需运行sudo apt-get install libcupti-dev

在 Windows 上安装 libcupti

在 Windows 上安装 libcupti 需要更多工作:

  • 确保你已经安装了推荐的 CUDA 版本(例如 CUDA 11 [>= TensorFlow 2.4.0])。有关 CUDA 版本的更多信息,请访问www.tensorflow.org/install/source#gpu

  • 接下来,打开 NVIDIA 控制面板(通过右键单击桌面并选择菜单项)进行几项更改(mng.bz/rJVJ):

    • 确保你点击桌面 > 设置开发者模式,设置开发者模式。

    • 确保你为所有用户启用了 GRU 性能分析,而不仅仅是管理员(图 14.9)。

    • 更多可能遇到的错误,请参考来自官方 NVIDIA 网站的mng.bz/VMxy

  • 要安装 libcupti,请访问mng.bz/xn2d

    • extras\CUPTI\lib64中的libcupti_<version>.dllnvperf_host.dllnvperf_target .dll文件复制到bin文件夹中。确保 libcupti 文件的名称为libcupti_110.dll

    • extras\CUPTI\lib64中的所有文件复制到lib\x64中。

    • extras\CUPTI\include中的所有文件复制到include中。

14-09

图 14.9 为所有用户启用 GPU 性能分析

确保你已经在你所使用的环境中正确安装了 libcupti(例如 Ubuntu 或 Windows)。否则,你将看不到预期的结果。然后,要启用性能分析,你只需要将参数profile_batch传递给 TensorBoard 回调函数。该值是两个数字的列表:起始步骤和结束步骤。通常情况下,性能分析是跨越几个批次进行的,因此值需要一个范围。但是,也可以对单个批次进行性能分析:

batch_size = 32
tr_ds, v_ds = get_flower_datasets(
    os.path.join(
        'data', '17flowers','jpg'), batch_size=batch_size, 
➥ flatten_images=False
)

tb_callback = tf.keras.callbacks.TensorBoard(
    log_dir=profile_log_dir, profile_batch=[10, 20]
)

conv_model.fit(
    tr_ds, validation_data=v_ds, epochs=2, callbacks=[tb_callback]
)

训练完成后,您可以在 TensorBoard 上查看结果。TensorBoard 提供了大量有价值的信息和对模型性能的洞察。它将计算分解为更小的子任务,并根据这些子任务提供细粒度的计算时间分解。此外,TensorBoard 提供了关于改进空间的建议(图 14.10)。现在让我们更深入地了解该页面提供的信息。

14-10

图 14.10 TensorBoard 性能分析界面。它提供了有关在 GPU 上运行模型涉及的各种子任务的宝贵信息。此外,它还提供了改进模型性能的建议。

平均步骤时间是几个较小任务的总和:

  • 输入时间—用于读取与数据相关的操作(例如,tf.data.Dataset 操作)的时间。

  • 主机计算时间—在主机上执行的与模型相关的计算(例如,CPU)。

  • 设备到设备时间—要在 GPU 上运行东西,首先需要将数据传输到 GPU。这个子任务测量了这种传输所花费的时间。

  • 内核启动时间—为了使 GPU 执行传输的数据上的操作,CPU 需要为 GPU 启动内核。内核封装了对数据执行的原始计算(例如,矩阵乘法)。这测量了启动内核所需的时间。

  • 设备计算时间—发生在设备上的与模型相关的计算(例如,GPU)。

  • 设备集体通信时间—与在多设备(例如,多个 GPU)或多节点环境中通信所花费的时间相关。

  • 所有其他时间(例如,编译时间、输出时间、所有其他剩余时间)。

在这里,我们可以看到大部分时间都花在了设备计算上。从某种意义上说,这是好的,因为它意味着大多数计算发生在 GPU 上。下一个最大的时间消耗者是输入时间。这是有道理的,因为我们没有对我们的 tf.data 流水线进行任何优化,并且它是一个高度依赖磁盘的流水线,因为图像是从磁盘中读取的。

然后,在下面,您可以看到更多信息。接近 80%的 TensorFlow 操作被放置在此主机上,而仅有 20%在 GPU 上运行。此外,所有操作都是 32 位操作,没有 16 位操作;16 位(半精度浮点)操作比 32 位(单精度浮点)数据类型运行得更快,节省了大量内存。GPU 和 Tensor 处理单元(TPU)是经过优化的硬件,可以比 32 位操作更快地运行 16 位操作。因此,必须尽可能地将它们纳入其中。话虽如此,我们必须小心如何使用 16 位操作,因为不正确的使用可能会严重影响模型性能(例如,模型准确性)。将 16 位操作与 32 位操作一起用于训练模型称为混合精度训练

如果你看推荐部分,你会看到两个主要的建议:

  • 优化输入数据管道。

  • 在模型训练中利用更多的 16 位操作。

Brain Floating Point 数据类型(bfloat16)

Brain Floating Point 值,或称 bfloat16 值,是 Google 提出的一种数据类型。它与 float16(即,16 位)具有相同的位数,但能够表示 float32 值的动态范围,但在精度上会有一定损失。这种变化是通过增加更多的指数位(小数点左侧)和减少小数位(小数点右侧)来实现的。这种数据类型可以在优化的硬件上获得显著的优势,比如 TPU 和 GPU(假设它们有 Tensor 核心;developer.nvidia.com/tensor-cores))。

让我们看看如何利用这些建议来减少模型训练时间。

14.4.1 优化输入管道

为了优化数据管道,我们将对 get_flower_datasets() 函数进行两项更改:

  • 使用数据预取以避免模型等待数据可用。

  • 在调用 get_image_and_label() 函数时使用并行化的 map 函数。

就这些变化在代码中的体现来说,它们是小变化。在下面的列表中,这些变化用粗体表示。

列表 14.4 从花数据集生成训练/验证数据集的函数

def get_flower_datasets(image_dir, batch_size, flatten_images=False):

    dataset = tf.data.Dataset.list_files(
        os.path.join(image_dir,'*.jpg'), shuffle=False          ❶
    )

    def get_image_and_label(file_path):                         ❷

        tokens = tf.strings.split(file_path, os.path.sep)       ❸
        label = (tf.strings.to_number(
            tf.strings.split(
                tf.strings.split(tokens[-1],'.')[0], '_')[-1]   ❸
            ) - 1
        )//80

        img = tf.io.read_file(file_path)                        ❹
        img = tf.image.decode_jpeg(img, channels=3)             ❹

        return tf.image.resize(img, [64, 64]), label

    dataset = dataset.map(
        get_image_and_label,
        *num_parallel_calls=tf.data.AUTOTUNE        *             ❺
    ).shuffle(400)

    # Make the validation dataset the first 10000 data
    valid_ds = dataset.take(250).batch(batch_size)
    # Make training dataset the rest
    train_ds = dataset.skip(250).batch(batch_size).prefetch(
        tf.data.experimental.AUTOTUNE                           ❻
 )

    return train_ds, valid_ds

❶ 获取训练数据集,对其进行洗牌,并输出(图像,标签)元组。

❷ 定义一个函数,根据文件名获取图像和标签。

❸ 获取文件路径中的标记并从图像 ID 计算标签。

❹ 读取图像并转换为张量。

❺ 并行化 map 函数。

❻ 结合预取。

为了并行化 dataset.map() 函数,我们在其后添加了 num_parallel_calls=tf.data .AUTOTUNE 参数,这将导致 TensorFlow 在并行执行 map 函数,其中线程数将由主机在执行时承载的工作量确定。接下来,在批处理后我们调用 prefetch() 函数,以确保模型训练不会因为等待数据可用而受阻。

接下来,我们将设置一个特殊的环境变量,称为 TF_GPU_THREAD_MODE。要理解这个变量的影响,你首先需要弄清楚 GPU 如何高效执行指令。当你在一台带有 GPU 的机器上运行深度学习模型时,大多数数据并行操作(即可以并行执行的数据操作)都会在 GPU 上执行。但数据和指令是如何传输到 GPU 的呢?假设使用 GPU 执行两个矩阵之间的逐元素乘法。由于可以并行地对个别元素进行乘法,这是一个数据并行操作。为了在 GPU 上执行此操作(定义为一组指令并称为内核),主机(CPU)首先需要启动内核,以便 GPU 使用该函数对数据进行操作。特别地,CPU 中的一个线程(现代 Intel CPU 每个核心大约有两个线程)将需要触发此操作。想象一下如果 CPU 中的所有线程都非常忙碌会发生什么。换句话说,如果有很多 CPU 绑定的操作正在进行(例如,从磁盘读取大量数据),它可能会导致 CPU 竞争,从而延迟 GPU 内核的启动。这反过来又延迟了在 GPU 上执行的代码。有了 TF_GPU_THREAD_MODE 变量,你可以缓解 CPU 竞争引起的 GPU 延迟。更具体地说,这个变量控制着 CPU 线程如何分配到 GPU 上启动内核。它可以有三个不同的值:

  • 全局—对于为不同的进程分配线程没有特殊的偏好(默认)。

  • gpu_private—分配了一些专用线程来为 GPU 启动内核。这样,即使 CPU 正在执行大量负载,内核启动也不会延迟。如果有多个 GPU,则它们将拥有自己的私有线程。线程的数量默认为两个,并可以通过设置 TF_GPU_THREAD_COUNT 变量进行更改。

  • shared—与 gpu_private 相同,但在多 GPU 环境中,一组线程将在 GPU 之间共享。

我们将此变量设置为 gpu_private。我们将保持专用线程的数量为两个,因此不会创建 TF_GPU_THREAD_COUNT 变量。

设置环境变量

要设置 TF_GPU_THREAD_MODE 环境变量,你可以执行以下操作:

Linux 操作系统(例如 Ubuntu)

设置环境变量

  • 打开一个终端。

  • 运行 export TF_GPU_THREAD_MODE=gpu_private。

  • 通过调用 echo $TF_GPU_THREAD_MODE 来验证环境变量是否设置。

  • 打开一个新的 shell 并启动 Jupyter 笔记本服务器。

Windows 操作系统

环境变量

  • 从开始菜单中,选择编辑系统环境变量。

  • 单击名为环境变量的按钮。

  • 在打开的对话框中添加一个新的环境变量 TF_GPU_THREAD_MODE=gpu_private。

  • 打开一个新的命令提示符并启动 Jupyter 笔记本服务器。

conda 环境(Anaconda)

在 conda 环境中设置环境变量

  • 使用 conda activate manning.tf2 激活 conda 环境。

  • 运行 conda env config vars set TF_GPU_THREAD_MODE=gpu_private。

  • 停用并重新启用环境以使变量生效。

  • 启动 Jupyter 笔记本服务器。

在更改操作系统或 conda 环境中的环境变量后,重启笔记本服务器非常重要。有关更多详细信息,请参阅以下边栏。

重要:设置环境变量后重新启动笔记本服务器。

当您从 shell(例如,Windows 上的命令提示符或 Linux 上的终端)创建笔记本服务器时,笔记本服务器将作为 shell 的子进程创建。在启动笔记本服务器后对环境进行的更改(例如,添加环境变量)将不会反映在该子进程中。因此,您必须关闭任何现有的笔记本服务器,更改环境变量,然后重新启动笔记本服务器以查看效果。

我们对我们的 tf.data 流水线进行了三项优化:

  • 预取数据批次

  • 使用并行化的 map() 函数而不是标准的 map() 函数

  • 通过设置 TF_GPU_THREAD_MODE=gpu_private 使用专用的内核启动线程。

14.4.2 混合精度训练

正如前面所解释的,混合精度训练是指在模型训练中采用 16 位和 32 位操作的组合。例如,可训练参数(即变量)保持为 32 位浮点值,而操作(例如,矩阵乘法)产生 16 位浮点输出。

在 Keras 中,启用混合精度训练非常简单。您只需从 Keras 中导入 mixed_precision 命名空间,并创建一个使用 mixed precision 数据类型的策略,通过传递 mixed_float16。最后,将其设置为全局策略。然后,每当您定义一个新模型时,它都会使用此策略来确定模型的数据类型:

from tensorflow.keras import mixed_precision
policy = mixed_precision.Policy('mixed_float16')
mixed_precision.set_global_policy(policy)

让我们重新定义我们定义的 CNN 模型并快速检查数据类型,以了解此新策略如何更改模型数据类型:

conv_model = get_cnn_model()

现在我们将选择一个层并检查输入/内部参数(例如,可训练权重)和输出的数据类型:

print("Input to the layers have the data type: {}".format(
    conv_model.get_layer("conv2d_1").input.dtype)
)
print("Variables in the layers have the data type: {}".format(
    conv_model.get_layer("conv2d_1").trainable_variables[0].dtype)
)
print("Output of the layers have the data type: {}".format(
    conv_model.get_layer("conv2d_1").output.dtype)
)

这将打印。

Input to the layers have the data type: <dtype: 'float16'>
Variables in the layers have the data type: <dtype: 'float32'>
Output of the layers have the data type: <dtype: 'float16'>

正如您所见,输入和输出的数据类型为 float16,而变量的数据类型为 float32。这是混合精度训练所采用的设计原则。为了确保在更新权重时保留精度,变量保持为 float32 类型。

损失缩放以避免数值下溢。

使用混合精度训练时,必须小心处理损失。半精度浮点数(float16)值的动态范围比单精度浮点数(float32)值更小。动态范围是指每种数据类型可以表示的值的范围。例如,float16 可以表示的最大值为 65,504 (最小正数为 0.000000059604645),而 float32 可以达到 3.4 × 10³⁸ (最小正数为 1.4012984643 × 10 − 45)。由于 float16 数据类型的动态范围较小,损失值很容易下溢或溢出,导致反向传播时出现数值问题。为了避免这种情况,损失值需要乘以适当的值进行缩放,以使梯度保持在 float16 值的动态范围之内。幸运的是,Keras 会自动处理此问题。

当策略设置为 mixed_float16 且调用 model.compile() 时,优化器会自动包装为 tf.keras.mixed_precision.LossScaleOptimizer() (mng.bz/Aydo)。LossScaleOptimizer() 会在模型优化期间动态缩放损失,以避免数值上的问题。如果您没有使用 Keras 构建模型,则必须手动处理此问题。

现在重新运行模型训练:

batch_size = 32
tr_ds, v_ds = get_flower_datasets(
    os.path.join('data', '17flowers','jpg'), batch_size=batch_size, 
➥ flatten_images=False
)

# This tensorboard call back does the following
# 1\. Log loss and accuracy
# 2\. Profile the model memory/time for 10  batches
tb_callback = tf.keras.callbacks.TensorBoard(
    log_dir=profile_log_dir, profile_batch=[10, 20]
)

conv_model.fit(
    tr_ds, validation_data=v_ds, epochs=2, callbacks=[tb_callback]
)

在加入我们介绍的各种优化步骤后运行模型训练。通过改变 TensorBoard 上的运行来进行比较。例如,我们在概览页面上显示了使用和不使用优化技巧的元素的并排比较。我们可以看到,在引入 tf.data pipeline 相关的优化后,时间大大减少(图 14.11)。

14-11

图 14.11 显示了使用和不使用数据和模型相关优化的分析概览的并排比较。引入优化后,输入时间大大减少。

你可能认为,使用 16 位操作后设备的计算时间并没有显著降低。使用 16 位操作最大的优势在于减少了 GPU 的内存消耗。TensorBoard 提供了一个称为 memory profile 的单独视图,用于分析模型的内存占用情况(图 14.12)。您可以使用此视图来分析模型的内存瓶颈或内存泄漏。

14-12

图 14.12 显示了经过优化前后的内存占用情况差异。使用 16 位操作减少了模型的内存消耗。差异非常明显。

可以清楚地看出,在使用混合精度训练后,内存需求显著下降。当使用混合精度训练时(从 5.48 GB 至 1.25 GB),模型对内存的需求降低了约 76%。

图表明了两种类型的内存:。这些是程序用于在执行程序时跟踪变量和函数调用的基本内存空间。从这些中,堆将帮助我们了解内存使用模式或与内存相关的问题,因为在程序执行期间创建的各种对象和变量都保存在其中。例如,如果存在内存泄漏,您将看到堆使用的内存量正在增加。在这里,我们可以看到内存使用情况相当线性,并且可以假设没有重大的内存泄漏。您可以在下一页的侧边栏中阅读有关堆和栈的更多信息。

堆 vs. 栈

程序运行时的内存保持在堆栈或堆中。例如,函数调用保持在堆栈中,其中最后一次调用位于堆栈顶部,最早的调用位于底部。当这些函数调用创建对象时,例如,它们被写入堆中(术语“堆”来自与堆数据结构无关的对象集合)。您可以想象堆中包含许多对象和属性,没有特定顺序(因此术语“堆”)。随着函数调用结束,项目将自动从堆栈中弹出。但是,当对象不再使用时,由程序员释放堆的责任,因为它们在函数调用结束后仍然存在。然而,在现代编程语言中,垃圾收集器会自动处理这个问题。 (请参阅mng.bz/VMZ5mng.bz/ZAER。)

您可能听说过“堆栈溢出”的术语,当代码中的递归函数调用没有合理满足终止条件时,大量的函数调用会溢出堆栈。另外,我们不能忽视一个受开发者欢迎的网站的名称是如何产生的(stackoverflow.com)。我认为没有比 Stack Overflow 本身更好的资源来解释这个问题了:mng.bz/R4ZZ

我们还可以看到有关哪些操作使用了多少内存的细节。例如,我们知道 CNN 的主要瓶颈是在一系列卷积/池化层之后的第一个 Dense 层。表 14.1 证实了这一点。也就是说,它显示了 Dense 层,其形状为 [115200, 512](即第一个 Dense 层),使用了最多的内存。

表 14.1 内存分解表。该表显示了各种 TensorFlow 操作的内存使用情况以及它们的数据形状。

操作名称 分配大小(GiBs) 请求大小(GiBs) 发生次数 区域类型 数据类型 形状
预分配/未知 0.743 0.743 1 持久/动态 无效 未知
gradient_tape/sequential/dense/MatMul/Cast/Cast 0.220 0.220 1 输出 浮点 [115200,512]
gradient_tape/sequential/batch_normalisation_3/FusedBatchNormGradV3 0.051 0.029 1 temp half [32,512,31,31]
gradient_tape/sequential/average_pooling2d/AvgPool/AvgPoolGrad 0.036 0.029 1 output half [32,31,31,512]
gradient_tape/sequential/batch_normalisation_3/FusedBatchNormGradV3 0.029 0.029 1 output half [32,31,31,512]
gradient_tape/sequential/batch_normalisation_3/FusedBatchNormGradV3 0.029 0.029 2 temp half [32,512,31,31]

最后,您可以查看trace viewer。这个工具提供了各种操作在 CPU 或 GPU 上是如何执行的纵向视图以及所花费的时间。这提供了关于各种操作何时以及如何被安排和执行的非常详细的视图。

在左侧,您可以看到在 CPU 上执行了什么操作,而在 GPU 上执行了什么操作。例如,您可以看到大多数与模型相关的操作(例如,卷积)在 GPU 上执行,而 tf.data 操作(例如,解码图像)在 GPU 上执行。您还可以注意到,跟踪查看器单独显示了 GPU 私有线程。

TensorBoard 的用途远不止我们在这里列出的。要了解更多,请参考以下侧边栏。

TensorBoard 的其他视图

TensorBoard 有许多不同的视图可用。我们已经讨论了最常用的视图,我将让读者探索我们没有讨论的视图。然而,剩下的视图中有一些值得注意的视图:

Debugger v2

Debugger v2 是 TensorFlow 2.3 以后引入的工具。它的主要目的是调试模型中的数值问题。例如,在模型训练过程中出现 NaN 值是深度网络的一个非常常见的问题。Debugger v2 将提供模型中各种元素(例如,输入和输出张量)的全面逐步分解,以及哪些元素产生了数值错误。有关更多信息,请访问www.tensorflow.org/tensorboard/debugger_v2

HParams

Hparams 是一个视图,帮助超参数优化,并允许您深入研究个别运行,以了解哪些参数有助于改善模型性能。tensorboard.plugins.hparams.api 提供了各种有用的功能和回调,以轻松优化 Keras 模型的超参数。然后,可以在 HParams 视图中查看超参数优化期间发生的试验。有关更多信息,请访问mng.bz/2nKg

What-If 工具

What-If 是一个工具,可以为黑盒模型提供有价值的见解,有助于解释这些模型。例如,您可以使用一些数据运行模型推理。然后,您可以修改数据,并通过 What-If 工具查看输出如何变化。此外,它提供了各种工具,用于分析模型的性能和公平性。有关更多信息,请访问mng.bz/AyjW

在下一节中,我们将讨论如何在 TensorBoard 上可视化和与词向量交互。

练习 4

你已经进行了模型性能分析。你已经看到了以下时间概述:

  • 输入时间:1.5 毫秒

  • 设备计算时间:6.7 毫秒

  • 内核启动时间:9.8 毫秒

  • 输出时间:10.1 毫秒

  • 主机计算时间:21.2 毫秒

对于这种情况,假设超过 5 毫秒的时间是有改进空间的机会。列出三个代码/环境更改建议以提高模型性能。

14.5 使用 TensorBoard 可视化词向量

你正在一家电影推荐公司担任 NLP 工程师,负责开发一种可以在小设备上训练的电影推荐模型。为了减少训练开销,使用了一种技术:使用预训练的词向量并将其冻结(即不进行训练)。你认为 GloVe 词向量将是一个很好的起点,并计划使用它们。但在此之前,你必须确保这些向量充分捕捉到电影特定术语/单词中的语义/关系。为此,你需要在 TensorBoard 上可视化这些单词的词向量,并分析 GloVe 向量是否表示了单词之间的合理关系。

我们需要做的第一件事是下载 GloVe 词向量。你已经在笔记本中提供了下载 GloVe 向量的代码,它与我们过去下载数据集的方式非常相似。因此,我们不会详细讨论下载过程。GloVe 词向量可从nlp.stanford.edu/projects/glove/获取。GloVe 向量有几个不同的版本;它们具有不同的维度和词汇量:

  • 使用 Wikipedia 2014 + Gigaword 5 数据集进行训练,共有 60 亿个标记;词汇量为 400,000 个;大小写不敏感的标记;词向量维度为 50D、100D、200D 和 300D

  • 使用 Common Crawl 数据集训练,共有 420 亿个标记;词汇量为 1,900,000 个;大小写不敏感的标记;词向量维度为 300D

  • 使用 Common Crawl 数据集训练,共有 8400 亿个标记;词汇量为 2,200,000 个;大小写敏感的标记;词向量维度为 300D

  • 使用 Twitter 数据集进行训练,共有 20 亿个推文;总标记数为 270 亿个;词汇量为 1,200,000 个;大小写不敏感的标记;词向量维度为 25D、50D、100D 和 200D

GloVe 词向量

GloVe(代表 Global Vectors)是一种单词向量算法,通过查看语料库的全局和局部统计信息生成单词向量。例如,像 Skip-gram 或 Continuous Bag-of-Words 这样的单词向量算法仅依赖于给定单词的局部上下文来学习该单词的单词向量。对单词在较大语料库中的使用情况缺乏全局信息的关注会导致次优的单词向量。GloVe 通过计算一个大型共现矩阵来表示所有单词之间的共现频率(即,如果一个给定单词出现在另一个单词的上下文中)来融合全局统计信息。有关 GloVe 向量的更多信息,请参见 mng.bz/1oGX

我们将使用第一类别(最小的)中的 50 维词向量。一个 50 维词向量将对语料库中的每个标记有 50 个值的向量。一旦在笔记本中运行代码提取数据,你将看到一个名为 glove.6B.50d.txt 的文件出现在数据文件夹中。让我们使用 pd.read_csv() 函数将其加载为 pandas DataFrame:

df = pd.read_csv(
    os.path.join('data', 'glove.6B.50d.txt'), 
    header=None, 
    index_col=0, 
    sep=None, 
    error_bad_lines=False, 
    encoding='utf-8'
)
df.head()

这将返回表格 14.2. 现在我们将下载 IMDB 电影评论数据集(ai.stanford.edu/~amaas/data/sentiment/)。由于这个数据集可以轻松地作为 TensorFlow 数据集(通过 tensorflow_datasets 库)获得,我们可以使用它:

review_ds = tfds.load('imdb_reviews')
train_review_ds = review_ds["train"]

一旦我们下载了数据,我们将创建一个包含训练集中所有评论(文本)的语料库,以字符串列表的形式:

corpus = []
for data in train_review_ds:      
    txt = str(np.char.decode(data["text"].numpy(), encoding='utf-8')).lower()
    corpus.append(str(txt))

接下来,我们想要获取此语料库中最常见的 5,000 个单词,以便我们可以比较这些常见单词的 GloVe 向量,以查看它们是否包含合理的关系。为了获得最常见的单词,我们将使用内置的 Counter 对象。Counter 对象计算词汇表中单词的频率:

from collections import Counter

corpus = " ".join(corpus)

cnt = Counter(corpus.split())
most_common_words = [w for w,_ in cnt.most_common(5000)]
print(cnt.most_common(100))

这将打印

[('the', 322198), ('a', 159953), ('and', 158572), ('of', 144462), ('to', 
➥ 133967), ('is', 104171), ('in', 90527), ('i', 70480), ('this', 69714), 
➥ ('that', 66292), ('it', 65505), ('/><br', 50935), ('was', 47024), 
➥ ('as', 45102), ('for', 42843), ('with', 42729), ('but', 39764), ('on', 
➥ 31619), ('movie', 30887), ('his', 29059), 
➥ ... ,
➥ ('other', 8229), ('also', 8007), ('first', 7985), ('its', 7963), 
➥ ('time', 7945), ('do', 7904), ("don't", 7879), ('me', 7722), ('great', 
➥ 7714), ('people', 7676), ('could', 7594), ('make', 7590), ('any', 
➥ 7507), ('/>the', 7409), ('after', 7118), ('made', 7041), ('then', 
➥ 6945), ('bad', 6816), ('think', 6773), ('being', 6390), ('many', 6388), 
➥ ('him', 6385)]

14-12_table_14-2

使用了 IMDB 电影评论数据集中最常见的 5,000 个单词的语料库以及 GloVe 向量,我们找到了这两个集合之间的常见标记以进行可视化:

df_common = df.loc[df.index.isin(most_common_words)]

这将给出大约 3,600 个在两个集合中都出现的标记列表。

接下来,我们可以在 TensorBoard 上可视化这些向量。再次强调,单词向量是给定语料库中标记的数值表示。这些单词向量的特点(与独热编码单词相反)是它们捕捉了单词的语义。例如,如果计算“cat”和“dog”的单词向量之间的距离,它们会比“cat”和“volcano”更接近。但是在分析更大的一组标记之间的关系时,我们喜欢有一个可视化辅助工具。如果有一种方法可以在二维或三维平面上可视化这些单词向量,那将更容易可视化和理解。有降维算法,如主成分分析(PCA)(mng.bz/PnZw)或 t-SNE(distill.pub/2016/misread-tsne/)可以实现这一点。本书不涉及这些特定算法的使用。好消息是,使用 TensorBoard,你可以做到这一点。TensorBoard 可以将这些高维向量映射到一个更小的投影空间。要做到这一点,我们首先要将这些权重加载为一个 TensorFlow 变量,然后将其保存为 TensorFlow 检查点。然后我们还要将单词或标记保存为一个新文件,每行一个标记,对应于我们刚刚保存的一组单词向量中的每个向量。有了这个,你就可以在 TensorBoard 上可视化单词向量(见下一个列表)。

列表 14.5 在 TensorBoard 上可视化单词向量

from tensorboard.plugins import projector

log_dir=os.path.join('logs', 'embeddings')
weights = tf.Variable(df_common.values)                          ❶

checkpoint = tf.train.Checkpoint(embedding=weights)              ❷
checkpoint.save(os.path.join(log_dir, "embedding.ckpt"))         ❷

with open(os.path.join(log_dir, 'metadata.tsv'), 'w') as f:      ❸
    for w in df_common.index:
        f.write(w+'\n')

config = projector.ProjectorConfig()                             ❹
embedding = config.embeddings.add()
embedding.metadata_path = 'metadata.tsv'                         ❺
projector.visualize_embeddings(log_dir, config)

❶ 用我们捕获的嵌入创建一个 tf.Variable。

❷将嵌入保存为 TensorFlow 检查点。

❸ 保存元数据(一个 TSV 文件),其中每个与嵌入对应的单词被附加为新行。

❹创建一个特定于投影仪和嵌入的配置(有关详细信息,请参阅文本)。

❺设置元数据路径,以便 TensorBoard 可以在可视化中包含它。

要可视化来自保存的 TensorFlow 检查点和元数据(即,保存的单词向量对应的标记),我们使用 tensorboard.plugins.projector 对象。然后我们定义一个 ProjectorConfig 对象和一个嵌入配置。我们将保留它们的默认配置,这适合我们的问题。当调用 config.embeddings.add()时,它将生成一个使用默认配置的嵌入配置(类型为 EmbeddingInfo 对象)。ProjectorConfig 包含诸如以下信息:

  • model_checkpoint_directory —— 包含嵌入的检查点的目录

EmbeddingInfo 包含

  • tensor_name —— 如果嵌入使用了特殊的张量名称

  • metadata_path —— 包含嵌入标签的 TSV 文件的路径

要查看可用配置的完整列表,请参考 mng.bz/J2Zo 上的文件。在其当前状态下,投影仪的配置不支持太多的定制。因此,我们将保持默认设置。我们将在 EmbeddingInfo 配置中设置一个配置,即 metadata_path。我们将 metadata_path 设置为包含令牌的文件,最后将其传递给 projecter.visualize_embeddings() 函数。我们给它一个日志目录,投影仪将自动检测 TensorFlow 检查点并加载它。

我们一切都准备就绪。在您的笔记本上,执行以下行以打开 TensorBoard:

%tensorboard --logdir logs/embeddings/ --port 6007

要可视化词向量,它们需要在 --logdir 指向的确切目录中(即不在嵌套文件夹中)。因此,我们需要一个新的 TensorBoard 服务器。这行代码将在端口 6007 上打开一个新的 TensorBoard 服务器。图 14.13 描述了在 TensorBoard 中显示的内容。

关于 %tensorboard 魔术命令的有趣事实

%tensorboard 魔术命令足够智能,能够知道何时打开新的 TensorBoard 服务器以及何时不需要。如果您一遍又一遍地执行相同的命令,它将重用现有的 TensorBoard。但是,如果您执行带有不同 --logdir 或 --port 的命令,它将打开一个新的 TensorBoard 服务器。

14-13

图 14.13 在 TensorBoard 上的词向量视图。您可以选择使用哪种降维算法(以及参数)来获取词向量的二维或三维表示。在可视化中悬停在点上将显示由该点表示的单词。

您可以在可视化中悬停在显示的点上,它们将显示它们代表的语料库中的哪个单词。您可以通过切换维度控制器来可视化二维或三维空间中的词向量。您可能想知道我们选择的词向量。它们最初有 50 个维度 —— 我们如何在二维或三维空间中可视化这样高维度的数据呢?有一套降维算法可以为我们做到这一点。一些示例是 t-SNE (mng.bz/woxO),PCA(主成分分析; mng.bz/ZAmZ),以及 UMAP(Uniform Manifold Approximation and Projection; arxiv.org/pdf/1802.03426.pdf)。参考附带的链接以了解更多关于这些算法的信息。

您可以在 TensorBoard 上做的不仅仅是词向量的简单可视化。您可以通过突出显示可视化中的特定单词进行更详细的分析。为此,您可以使用正则表达式。例如,图 14.14 中显示的可视化是使用正则表达式(?:fred|larry|mrs.|mr.|michelle|sea|denzel|beach|comedy|theater|idiotic|sadistic|marvelous|loving|gorg|bus|truck|lugosi)生成的。

14-14

图 14.14 在可视化中搜索单词。您可以使用正则表达式来搜索单词的组合。

这就结束了我们关于 TensorBoard 的讨论。在下一章中,我们将讨论 TensorFlow 如何帮助我们轻松创建机器学习流水线并部署模型。

练习 5

如果您想在 TensorBoard 中显示单词向量时包含唯一标识符,而不仅仅是单词本身,例如,您想要看到“loving; 218”而不是“loving”,其中 218 是给予该单词的唯一标识符。为此,您需要更改写入 metadata.tsv 文件的内容。不仅仅是单词,每行上都写一个用分号分隔的递增 ID。例如,如果单词是[“a”, “b”, “c”],那么新行应该是[“a;1”, “b;2”, “c;3”]。您如何进行更改?

摘要

  • TensorBoard 是一个用于可视化数据(例如图像)和实时跟踪模型性能的优秀工具。

  • 在使用 Keras 构建模型时,您可以使用方便的 tf.keras.callbacks.TensorBoard() 回调来记录模型性能、层激活直方图等。

  • 如果您有自定义指标想要记录到 TensorBoard 中,您可以在 tf.summary 命名空间中使用相应的数据类型(例如,如果您想要记录随时间变化的模型精度等,可以使用 tf.summary.scalar())。

  • 每次将信息记录到 TensorBoard 中的会话称为一次运行。您应该为不同的运行制定一个可读且健壮的命名约定。一个好的命名约定应该捕捉您所做的主要更改以及运行执行的日期/时间。

  • TensorBoard Profile 提供了各种各样的性能分析结果(使用 NVIDIA 的 libcupti 库),例如模型训练过程中各个子任务所花费的时间(例如,设备计算时间、主机计算时间、输入时间等)、模型使用的内存以及各种操作是如何进行的顺序视图。

  • TensorBoard 是一个用于可视化高维数据(如图像和单词向量)的强大工具。

练习答案

练习 1

image_writer = tf.summary.create_file_writer(image_logdir)

with image_writer.as_default():
    for bi, batch in enumerate(steps_image_batches):
        tf.summary.image(
            “batch_{}”.format(bi), 
            batch, 
            max_outputs=10, 
            step=bi
        )

练习 2

log_dir = "./logs "

classif_model.compile(
    loss=’binary_crossentropy', 
    optimizer=’adam’, 
    metrics=[tf.keras.metrics.Precision(), tf.keras.metrics.Recall()]
)

tb_callback = tf.keras.callbacks.TensorBoard(
    log_dir=log_dir, histogram_freq=1, profile_batch=0
)

classif_model.fit(tr_ds, validation_data=v_ds, epochs=10, callbacks=[tb_callback])

练习 3

 writer = tf.summary.create_file_writer(log_dir)

 x_n_minus_1 = 1
 x_n_minus_2 = 0

 with writer.as_default():        
     for i in range(100):
         x_n = x_n_minus_1 + x_n_minus_2
         x_n_minus_1 = x_n
      x_n_minus_2 = x_n_minus_1

      tf.summary.scalar("fibonacci", x_n, step=i)

      writer.flush()

练习 4

  1. 主机上正在进行大量的计算。这可能是因为设备(例如,GPU)的内存不足。使用混合精度训练将有助于缓解这个问题。此外,可能有太多无法在 GPU 上运行的非 TensorFlow 代码。为此,使用更多的 TensorFlow 操作并将这样的代码转换为 TensorFlow 将获得加速。

  2. 内核启动时间增加了。这可能是因为工作负载严重受限于 CPU。在这种情况下,我们可以合并 TF_GPU_THREAD_MODE 环境变量,并将其设置为 gpu_private。这将确保有几个专用线程用于为 GPU 启动内核。

  3. 输出时间显著偏高。这可能是因为频繁向磁盘写入过多输出。为解决此问题,我们可以考虑将数据在内存中保存更长时间,并仅在少数时刻将其刷新到磁盘上。

练习 5

log_dir=os.path.join('logs', 'embeddings')

weights = tf.Variable(df_common.values)
   checkpoint = tf.train.Checkpoint(embedding=weights)
checkpoint.save(os.path.join(log_dir, "embedding.ckpt"))

with open(os.path.join(log_dir, 'metadata.tsv'), 'w') as f:
    for i, w in enumerate(df_common.index):
        f.write(w+'; '+str(i)+'\n')

第十五章:TFX:MLOps 和使用 TensorFlow 部署模型

本章涵盖内容

  • 使用 TFX(TensorFlow-Extended)编写端到端数据流水线

  • 通过 TFX Trainer API 训练一个简单的神经网络

  • 使用 Docker 将模型服务(推理)容器化,并将其作为服务呈现

  • 在本地机器上部署模型,以便通过 API 使用

在第十四章,我们研究了一个非常多功能的工具,它与 TensorFlow 捆绑在一起:TensorBoard。TensorBoard 是一个可视化工具,可以帮助你更好地理解数据和模型。除其他外,它可以方便

  • 监控和追踪模型性能

  • 可视化模型的数据输入(例如图片、音频)

  • 对模型进行分析以了解其性能或内存瓶颈

我们学习了如何使用 TensorBoard 来可视化像图片和词向量这样的高维数据。我们探讨了如何将 Keras 回调嵌入到 TensorBoard 中,以便可视化模型性能(准确率和损失)以及自定义指标。然后,我们使用 CUDA 性能分析工具来分析模型的执行,以理解执行模式和内存瓶颈。

在本章中,我们将探索最近引起极大关注的机器学习新领域:MLOps。MLOps 源自 ML 和 DevOps(源自开发和运维)术语。根据亚马逊网络服务(AWS)的说法,“DevOps 是文化哲学、实践和工具的组合,它增加了组织交付应用和服务的能力:以比使用传统软件开发和基础设施管理流程的组织更快的速度进化和改进产品。”还有一个与 MLOps 密切相关的术语,即模型的实际投入使用。很难区分这两个术语,因为它们有重叠之处,有时可以互换使用,但我倾向于这样理解这两个事物:MLOps 定义了一个工作流,将自动化大部分步骤,从收集数据到交付在该数据上训练的模型,几乎不需要人工干预。实际投入使用是部署训练好的模型(在私有服务器或云上),使客户能够以稳健的方式使用模型进行设计目的。它可以包括任务,例如设计可扩展的 API,可以扩展以处理每秒数千个请求。换句话说,MLOps 是一段旅程,让你到达的目的地是模型的实际投入使用。

让我们讨论为什么拥有(大部分)自动化的流水线来开发机器学习模型是重要的。要实现其价值,你必须考虑到规模问题。对于像谷歌、Facebook 和亚马逊这样的公司,机器学习已经深深扎根于他们提供的产品中。这意味着数以百计甚至数千个模型每秒产生预测。此外,对于拥有数十亿用户的公司来说,他们不能容忍他们的模型变得过时,这意味着不断地训练/微调现有模型以适应新数据的收集。MLOps 可以解决这个问题。MLOps 可用于摄取收集的数据、训练模型、自动评估模型,并在它们通过预定义的验证检查后将其推送到生产环境中。验证检查是为了确保模型达到预期的性能标准,并防范对抗不良表现的模型(例如,由于新的入站训练数据发生大幅变化、推送了新的未经测试的超参数变更等,可能会生成不良的模型)。最后,模型被推送到生产环境,通过 Web API 访问以获取输入的预测。具体而言,API 将为用户提供一些端点(以 URL 的形式),用户可以访问这些端点(可选地带上需要完成请求的参数)。话虽如此,即使对于依赖机器学习模型的较小公司来说,MLOps 也可以极大地标准化和加速数据科学家和机器学习工程师的工作流程。这将大大减少数据科学家和机器学习工程师在每次开展新项目时从头开始创建这些工作流程所花费的时间。阅读有关 MLOps 的更多信息,请访问mng.bz/Pnd9

我们如何在 TensorFlow 中进行 MLOps?无需寻找其他,TFX(TensorFlow 扩展)就是答案。TFX 是一个库,提供了实现摄取数据、将数据转换为特征、训练模型和将模型推送到指定生产环境所需的所有功能。这是通过定义一系列执行非常具体任务的组件来完成的。在接下来的几节中,我们将看看如何使用 TFX 来实现这一目标。

使用 TFX 编写数据管道

想象一下,你正在开发一个系统,根据天气条件来预测森林火灾的严重程度。你已经获得了过去观察到的森林火灾的数据集,并被要求创建一个模型。为了确保你能够将模型提供为服务,你决定创建一个工作流程来摄取数据并使用 TFX 训练模型。这个过程的第一步是创建一个能够读取数据(以 CSV 格式)并将其转换为特征的数据管道。作为这个管道的一部分,你将拥有一个数据读取器(从 CSV 生成示例),显示字段的摘要统计信息,了解数据的模式,并将其转换为模型理解的正确格式。

关于环境的重要信息

要运行本章的代码,强烈建议使用 Linux 环境(例如 Ubuntu),并且将提供该环境的说明。TFX 未针对 Windows 环境进行测试(mng.bz/J2Y0)。另一个重要的事项是我们将使用稍旧版本的 TFX(1.6.0)。撰写时,最新版本为 1.9.0。这是因为在 1.6.0 版本之后的版本中,运行 TFX 在诸如笔记本等交互式环境中所需的关键组件已损坏。此外,本章后面我们将使用一种名为 Docker 的技术。由于对资源的访问受到严格限制,使 Docker 按我们所需的方式运行在 Windows 上可能会相当困难。此外,对于本章,我们将定义一个新的 Anaconda 环境。要执行此操作,请按照以下说明操作:

  • 打开一个终端窗口,并进入代码存储库中的 Ch15-TFX-for-MLOps-in-TF2 目录。

  • 如果您已经激活了 Anaconda 虚拟环境(例如 manning.tf2),请通过运行 conda deactivate manning.tf2 来停用它。

  • 运行 conda create -n manning.tf2.tfx python=3.6 来创建一个新的虚拟 Anaconda 环境。

  • 运行 conda activate manning.tf2.tfx 以激活新环境。

  • 运行 pip install --use-deprecated=legacy-resolver -r requirements.txt。

  • 运行 jupyter notebook。

  • 打开 tfx/15.1_MLOps_with_tensorflow.ipynb 笔记本。

第一件事是下载数据集(列表 15.1)。我们将使用一个记录了葡萄牙蒙特西尼奥公园历史森林火灾的数据集。该数据集在archive.ics.uci.edu/ml/datasets/Forest+Fires上免费提供。它是一个 CSV 文件,具有以下特征:

  • X—蒙特西尼奥公园地图中的 x 轴空间坐标

  • Y—蒙特西尼奥公园地图中的 y 轴空间坐标

  • month—一年中的月份

  • day—一周中的日期

  • Fine Fuel Moisture Code (FFMC)—代表森林树冠阴影下的林地燃料湿度

  • DMC—土壤平均含水量的数字评级

  • Drought Code (DC)—表示土壤干燥程度的深度

  • Initial Spread Index (ISI)—预期的火灾蔓延速率

  • temp—摄氏度温度

  • RH—相对湿度,单位%

  • wind—风速,单位 km/h

  • rain—外部降雨量,单位 mm/m2

  • area—森林烧毁面积(单位公顷)

选择机器学习模型的特征

选择机器学习模型的特征不是一个微不足道的任务。通常,在使用特征之前,您必须了解特征,特征间的相关性,特征-目标相关性等等,然后就可以判断是否应使用特征。因此,不应该盲目地使用模型的所有给定特征。然而,在这种情况下,重点在于 MLOps,而不是数据科学决策,我们将使用所有特征。使用所有这些特征将稍后有助于解释在定义 MLOps 管道时可用的各种选项。

我们的任务是在给出所有其他特征的情况下预测烧毁面积。请注意,预测连续值(如面积)需要回归模型。因此,这是一个回归问题,而不是分类问题。

图 15.1 下载数据集

import os
import requests
import tarfile

import shutil

if not os.path.exists(os.path.join('data', 'csv', 'forestfires.csv')):    ❶
    url = "http:/ /archive.ics.uci.edu/ml/machine-learning-databases/forest-
➥ fires/forestfires.csv"
    r = requests.get(url)                                                 ❷

    if not os.path.exists(os.path.join('data', 'csv')):                   ❸
        os.makedirs(os.path.join('data', 'csv'))                          ❸

    with open(os.path.join('data', 'csv', 'forestfires.csv'), 'wb') as f: ❸
        f.write(r.content)                                                ❸
else:
    print("The forestfires.csv file already exists.")

if not os.path.exists(os.path.join('data', 'forestfires.names')):         ❹

    url = "http:/ /archive.ics.uci.edu/ml/machine-learning-databases/forest-
➥ fires/forestfires.names"
    r = requests.get(url)                                                 ❹

    if not os.path.exists('data'):                                        ❺
        os.makedirs('data')                                               ❺

    with open(os.path.join('data', 'forestfires.names'), 'wb') as f:      ❺
        f.write(r.content)                                                ❺

else:
    print("The forestfires.names file already exists.")

❶ 如果未下载数据文件,请下载该文件。

❷ 此行下载给定 URL 的文件。

❸ 创建必要的文件夹并将下载的数据写入其中。

❹ 如果未下载包含数据集描述的文件,请下载它。

❺ 创建必要的目录并将数据写入其中。

在这里,我们需要下载两个文件:forestfires.csv 和 forestfires.names。forestfires.csv 以逗号分隔的格式包含数据,第一行是标题,其余部分是数据。forestfires.names 包含更多关于数据的信息,以便您想更多地了解它。接下来,我们将分离出一个小的测试数据集以供后续手动测试。拥有一个专用的测试集,在任何阶段都没有被模型看到,将告诉我们模型的泛化情况如何。这将是原始数据集的 5%。其余 95%将用于训练和验证数据:

import pandas as pd

df = pd.read_csv(
    os.path.join('data', 'csv', 'forestfires.csv'), index_col=None, 
➥ header=0
)
train_df = df.sample(frac=0.95, random_state=random_seed)
test_df = df.loc[~df.index.isin(train_df.index), :]

train_path = os.path.join('data','csv','train')
os.makedirs(train_path, exist_ok=True)
test_path = os.path.join('data','csv','test')
os.makedirs(test_path, exist_ok=True)

train_df.to_csv(
    os.path.join(train_path, 'forestfires.csv'), index=False, header=True
)
test_df.to_csv(
    os.path.join(test_path, 'forestfires.csv'), index=False, header=True
)

现在,我们将开始 TFX 管道。第一步是定义存储管道工件的根目录。您可能会问什么是管道工件?在运行 TFX 管道时,它会在目录中存储各个阶段的中间结果(在某个子目录结构下)。其中的一个例子是,当您从 CSV 文件中读取数据时,TFX 管道会将数据拆分为训练和验证子集,将这些示例转换为 TFRecord 对象(即 TensorFlow 内部用于数据的对象类型),并将数据存储为压缩文件:

_pipeline_root = os.path.join(
    os.getcwd(), 'pipeline', 'examples', 'forest_fires_pipeline'
)

TFX 使用 Abseil 进行日志记录。Abseil 是从 Google 的内部代码库中提取的开源 C ++库集合。它提供了日志记录,命令行参数解析等功能。如果您感兴趣,请在abseil.io/docs/python/阅读有关该库的更多信息。我们将设置日志记录级别为 INFO,以便我们可以在 INFO 级别或更高级别看到日志记录语句。日志记录是具有重要功能的,因为我们可以获得很多见解,包括哪些步骤成功运行以及哪些错误被抛出:

absl.logging.set_verbosity(absl.logging.INFO)

完成初始设置后,我们将定义一个 InteractiveContext:

from tfx.orchestration.experimental.interactive.interactive_context import 
➥ InteractiveContext

context = InteractiveContext(
    pipeline_name = "forest_fires", pipeline_root=_pipeline_root
)

TFX 在一个上下文中运行流水线。上下文被用来运行你在流水线中定义的各个步骤。它还起着非常重要的作用,就是在我们在流水线中进行过程中管理不同步骤之间的状态。为了管理状态之间的转换并确保流水线按预期运行,它还维护了一个元数据存储(一个小规模的数据库)。元数据存储包含各种信息,如执行顺序、组件的最终状态和产生的错误。你可以在以下侧边栏中了解有关元数据的信息。

元数据中有什么?

一旦创建了 InteractiveContext,你会在流水线根目录中看到一个名为 metadata.sqlite 的数据库。这是一个轻量级、快速的 SQL 数据库(www.sqlite.org/index.xhtml),专为处理少量数据和传入请求而设计的。该数据库将记录有关输入、输出和执行相关输出的重要信息(组件的运行标识符、错误)。这些信息可以用于调试你的 TFX 流水线。元数据可以被视为不是直接输入或输出,但仍然是正确执行组件所必需的数据,以提供更大的透明度。在具有许多组件以许多不同方式相互连接的复杂 TFX 流水线的调试中,元数据可能非常有帮助。你可以在www.tensorflow.org/tfx/guide/mlmd上了解更多信息。

我们要开始定义流水线了。本节流水线的主要目的是

  • 从 CSV 文件加载数据并拆分为训练和验证数据

  • 了解数据的模式(例如各个列、数据类型、最小/最大值等)

  • 显示关于各种特征分布的摘要统计和图形

  • 将原始列转换为特征,可能需要特殊的中间处理步骤

这些步骤是模型训练和部署的前奏。每个任务都将是流水线中的一个单独组件,在合适的时候我们将详细讨论这些步骤。

15.1.1 从 CSV 文件加载数据

第一步是定义一个组件来从 CSV 文件中读取示例并将数据拆分为训练和评估数据。为此,你可以使用 tfx.components.CsvExampleGen 对象。我们所需要做的就是将包含数据的目录提供给 input_base 参数:

from tfx.components import CsvExampleGen

example_gen = CsvExampleGen(input_base=os.path.join('data', 'csv', 'train'))

然后我们使用之前定义的 InteractiveContext 来运行示例生成器:

context.run(example_gen)

让我们来看看这一步骤产生了什么。要查看数据,请前往 _pipeline_root 目录(例如,Ch15-TFX-for-MLOps-in-TF2/tfx/pipeline)。它应该具有类似于图 15.1 所示的目录/文件结构。

15-01

图 15.1 运行 CsvExampleGen 之后的目录/文件结构

您将看到在管道中创建了两个 GZip 文件(即带有 .gz 扩展名)。您会注意到在 CsvExampleGen 文件夹中有两个子目录:Split-train 和 Split-eval,分别包含训练和验证数据。当您运行包含前述代码的笔记本单元时,您还将看到一个输出 HTML 表格,显示 TFX 组件的输入和输出(图 15.2)。

15-02

图 15.2 运行 CsvExampleGen 组件生成的输出 HTML 表格

有一些值得注意的事项。首先,您将看到 execution_id,这是一个计数器生成的值,该计数器跟踪您运行 TFX 组件的次数。换句话说,每次运行 TFX 组件(如 CsvExampleGen)时,计数器都会增加 1。如果您继续向下看,您会看到一些关于 CsvExampleGen 如何分割数据的重要信息。如果您查看 component > CsvExampleGen > exec_properties > output_config 下,您会看到类似于

"split_config": { 
    "splits": [ 
        { "hash_buckets": 2, "name": "train" }, 
        { "hash_buckets": 1, "name": "eval" } 
    ] 
} 

这里说数据集已被分成两组:train 和 eval。训练集大约占原始数据的三分之二,而评估集大约占原始数据的三分之一。这些信息是通过查看 hash_buckets 属性推断出来的。TFX 使用哈希将数据分成训练集和评估集。默认情况下,它将定义三个哈希桶。然后 TFX 使用每个记录中的值为该记录生成哈希。记录中的值传递给哈希函数以生成哈希。然后使用生成的哈希来将该示例分配到一个桶中。例如,如果哈希值为 7,则 TFX 可以轻松找到具有 7% 的桶,3 = 1,这意味着它将被分配到第二个桶(因为桶是从零开始索引的)。您可以按以下方式访问 CsvExampleGen 中的元素。

关于哈希的更多信息

有许多哈希函数,例如 MD5、SHA1 等。您可以在 blog.jscrambler.com/hashing-algorithms/ 上阅读有关哈希函数的更多信息。在 TensorFlow 中,有两种不同的函数可用于生成哈希:tf.strings.to_hash_bucket_fast (mng.bz/woJq) 和 tf.strings.to_ hash_bucket_strong ()。强哈希函数速度较慢,但更能抵御可能操纵输入以控制生成的哈希值的恶意攻击。

artifact = example_gen.outputs['examples'].get()[0]

print("Artifact split names: {}".format(artifact.split_names))
print("Artifact URI: {}".format(artifact.uri)

这将打印以下输出:

Artifact split names: ["train", "eval"]
Artifact URI: <path to project>/Ch15-TFX-for-MLOps-in-
➥ TF2/tfx/pipeline/examples/forest_fires_pipeline/CsvExampleGen/examples/1

之前我们说过,随着我们在管道中的进展,TFX 会将中间输出存储起来。我们看到 CsvExampleGen 组件已将数据存储为 .gz 文件。事实上,它将 CSV 文件中找到的示例存储为 TFRecord 对象。TFRecord 用于将数据存储为字节流。由于 TFRecord 是在使用 TensorFlow 时存储数据的常用方法;这些记录可以轻松地作为 tf.data.Dataset 检索,并且可以检查数据。下一个清单显示了如何做到这一点。

列表 15.2 打印 CsvExampleGen 存储的数据

train_uri = os.path.join(
    example_gen.outputs['examples'].get()[0].uri, 'Split-train'       ❶
) 

tfrecord_filenames = [
    os.path.join(train_uri, name) for name in os.listdir(train_uri)   ❷
]

dataset = tf.data.TFRecordDataset(
    tfrecord_filenames, compression_type="GZIP"
)                                                                     ❸

for tfrecord in dataset.take(2):                                      ❹
  serialized_example = tfrecord.numpy()                               ❺
  example = tf.train.Example()                                        ❻
  example.ParseFromString(serialized_example)                         ❼
  print(example)                                                      ❽

❶ 获取代表训练示例的输出工件的 URL,该工件是一个目录。

❷ 获取此目录中的文件列表(所有压缩的 TFRecord 文件)。

❸ 创建一个 TFRecordDataset 来读取这些文件。GZip(扩展名为 .gz)包含一组 TFRecord 对象。

❹ 迭代前两个记录(可以是小于或等于数据集大小的任何数字)。

❺ 从 TFRecord(包含一个示例)获取字节流。

❻ 定义一个知道如何解析字节流的 tf.train.Example 对象。

❼ 将字节流解析为适当可读的示例。

❽ 打印数据。

如果你运行这段代码,你会看到以下内容:

features {
  feature {
    key: "DC"
    value {
      float_list {
        value: 605.7999877929688
      }
    }
  }
  ...
  feature {
    key: "RH"
    value {
      int64_list {
        value: 43
      }
    }
  }
  feature {
    key: "X"
    value {
      int64_list {
        value: 5
      }
    }
  }
  ...
  feature {
    key: "area"
    value {
      float_list {
        value: 2.0
      }
    }
  }
  feature {
    key: "day"
    value {
      bytes_list {
        value: "tue"
      }
    }
  }
  ...
}

...

tf.train.Example 将数据保存为一组特征,每个特征都有一个键(列描述符)和一个值。你会看到给定示例的所有特征。例如,DC 特征具有浮点值 605.799,RH 特征具有整数值 43,area 特征具有浮点值 2.0,而 day 特征具有 bytes_list(用于存储字符串)值为 "tue"(即星期二)。

在移动到下一节之前,让我们再次提醒自己我们的目标是什么:开发一个模型,可以根据数据集中的所有其他特征来预测火灾蔓延(以公顷为单位)。这个问题被构建为一个回归问题。

15.1.2 从数据生成基本统计信息

作为下一步,我们将更好地理解数据。这称为探索性数据分析(EDA)。EDA 通常不是很明确,并且非常依赖于您正在解决的问题和数据。您还必须考虑到通常在项目交付之前的有限时间。换句话说,您不能测试所有内容,必须优先考虑要测试的内容和要假设的内容。对于我们在这里处理的结构化数据,一个很好的起点是了解类型(数值与分类)以及各列值的分布。TFX 为此提供了一个组件。StatisticsGen 将自动生成这些统计信息。我们很快将更详细地看到此模块提供了什么样的见解:

from tfx.components import StatisticsGen

statistics_gen = StatisticsGen(
    examples=example_gen.outputs['examples'])

context.run(statistics_gen)

这将生成一个 HTML 表格,类似于您在运行 CsvExampleGen 后看到的表格(见图 15.3)。

15-03

图 15.3 StatisticsGen 组件提供的输出

然而,要检索此步骤的最有价值的输出,您必须运行以下命令:

context.show(statistics_gen.outputs['statistics'])

这将在管道根目录中创建以下文件(见图 15.4)。

15-04

图 15.4 运行 StatisticsGen 后的目录/文件结构

图 15.5 展示了 TFX 提供的有关数据的宝贵信息集合。图 15.5 中的输出图是一个包含丰富数据的金矿,提供了大量关于我们处理的数据的信息。它为你提供了基本但全面的图表套件,提供了有关数据中存在的列的许多信息。让我们从上到下来看。在顶部,你可以选择排序和过滤图 15.5 中显示的输出。例如,你可以改变图表的顺序,选择基于数据类型的图表,或者通过正则表达式进行筛选。

15-05

图 15.5 由 StatisticsGen 组件生成的数据的摘要统计图

默认情况下,StatisticsGen 将为训练集和评估集生成图表。然后每个训练和评估部分将有几个子部分;在这种情况下,我们有数值列和分类列的部分。

在左边,你可以看到一些数字统计和特征的评估,而在右边,你可以看到特征分布的视觉表示。例如,拿训练集中的 FFMC 特征来说。我们可以看到它有 333 个例子且 0%的特征缺失值。它的平均值约为 90,标准偏差为 6.34。在图表中,你可以看到分布是相当倾斜的。几乎所有的值都集中在 80-90 范围内。你将看到稍后这可能会给我们制造问题以及我们将如何解决它们。

在分类部分,你可以看到日和月特征的值。例如,日特征有七个唯一值,且 0%缺失。日特征的最频繁值(即模式)出现了 60 次。请注意,日表示为条形图,月表示为线图,因为对于唯一值高于阈值的特征,使用线图可以使图表清晰且减少混乱。

15.1.3 从数据推断模式

到目前为止,我们已经从 CSV 文件中加载了数据并探索了数据集的基本统计信息。下一个重要的步骤是推断数据的模式。一旦提供了数据,TFX 可以自动推断数据的模式。如果你使用过数据库,推断出的模式与数据库模式相同。它可以被视为数据的蓝图,表达数据的结构和重要属性。它也可以被视为一组规则,规定数据应该看起来像什么。例如,如果你有了模式,你可以通过参考模式来分类给定的记录是否有效。

不做更多的话,让我们创建一个 SchemaGen 对象。SchemaGen 需要前一步的输出(即 StatisticsGen 的输出)和一个名为 infer_feature_shape 的布尔参数。

from tfx.components import SchemaGen

schema_gen = SchemaGen(
    statistics=statistics_gen.outputs[‘statistics’],
    infer_feature_shape=False)

context.run(schema_gen)

在这里,我们将 infer_feature_shape 设置为 False,因为我们将在特征上进行一些转换。因此,我们将有更大的灵活性来自由操作特征形状。然而,设置这个参数(infer_feature_shape)意味着对下游步骤(称为 transform 步骤)的重要改变。当 infer_feature_shape 设置为 False 时,传递给 transform 步骤的张量被表示为 tf.SparseTensor 对象,而不是 tf.Tensor 对象。如果设置为 True,则需要是一个具有已知形状的 tf.Tensor 对象。接下来,要查看 SchemaGen 的输出,可以执行以下操作

context.show(schema_gen.outputs['schema'])

这将产生表 15.1 所示的输出。

表 15.1 TFX 生成的模式输出

特征名称 类型 存在 价值
‘day’ STRING 必须的 单个的 ‘day’
‘month’ STRING 必须的 单个的 ‘month’
‘DC’ FLOAT 必须的 单个的 -
‘DMC’ FLOAT 必须的 单个的 -
‘FFMC’ FLOAT 必须的 单个的 -
‘ISI’ FLOAT 必须的 单个的 -
‘RH’ INT 必须的 单个的 -
‘X’ INT 必须的 单个的 -
‘Y’ INT 必须的 单个的 -
‘area’ FLOAT 必须的 单个的 -
‘rain’ FLOAT 必须的 单个的 -
‘temp’ FLOAT 必须的 单个的 -
‘wind’ FLOAT 必须的 单个的
‘day’
‘month’ ‘apr’ ‘aug’ ‘dec’ ‘feb’

域定义了给定特征的约束。我们列出了 TFX 中定义的一些最受欢迎的域:

  • 整数域值(例如,定义整数特征的最小/最大值)

  • 浮点域值(例如,定义浮点值特征的最小/最大值)

  • 字符串域值(例如,为字符串特征定义允许的值/标记)

  • 布尔域值(例如,可以用于定义真/假状态的自定义值)

  • 结构域值(例如,可以用于定义递归域[域内的域]或具有多个特征的域)

  • 自然语言域值(例如,为相关语言特征定义一个词汇表[允许的标记集合])

  • 图像域值(例如,可以用来限制图像的最大字节大小)

  • 时间域值(例如,可以用来定义数据/时间特征)

  • 时间值域(例如,可以用来定义不带日期的时间)

域的列表可在名为 schema.proto 的文件中找到。schema.proto 在mng.bz/7yp9上定义。这些文件是使用一个叫做 Protobuf 的库定义的。Protobuf 是一种用于对象序列化的库。您可以阅读下面的侧边栏了解有关 Protobuf 库的更多信息。

Protobuf 库

Protobuf 是由 Google 开发的对象序列化/反序列化库。需要序列化的对象被定义为 Protobuf 消息。消息的模板由 .proto 文件定义。然后,为了进行反序列化,Protobuf 提供了诸如 ParseFromString() 等函数。要了解有关该库的更多信息,请参阅 mng.bz/R45P

接下来,我们将看到如何将数据转换为特征。

15.1.4 将数据转换为特征

我们已经到达了数据处理管道的最终阶段。最后一步是将我们提取的列转换为对我们的模型有意义的特征。我们将创建三种类型的特征:

  • 密集的浮点数特征—值以浮点数(例如,温度)的形式呈现。这意味着该值会按原样传递(可以选择进行归一化处理;例如,Z 分数归一化)以创建一个特征。

  • 分桶特征—根据预定义的分桶间隔对数值进行分桶。这意味着该值将根据其落入的分桶而转换为桶索引(例如,我们可以将相对湿度分成三个值:低[-inf,33),中[33,66),高[66,inf))。

  • 分类特征(基于整数或字符串)—值是从预定义的值集中选择的(例如,日期或月份)。如果该值尚未是整数索引(例如,日期作为字符串),则将使用将每个单词映射到索引的词汇表将其转换为整数索引(例如,“mon” 被映射为 0,“tue” 被映射为 1,等等)。

我们将向数据集中的每个字段介绍其中一种特征转换:

  • X(空间坐标)—以浮点数值表示

  • Y(空间坐标)—以浮点数值表示

  • wind(风速)—以浮点数值表示

  • rain(室外降雨)—以浮点数值表示

  • FFMC(燃料湿度)—以浮点数值表示

  • DMC(平均含水量)—以浮点数值表示

  • DC(土壤干燥深度)—以浮点数值表示

  • ISI(预期火灾蔓延速率)—以浮点数值表示

  • temp(温度)—以浮点数值表示

  • RH(相对湿度)—作为分桶值表示

  • month—作为分类特征表示

  • day—作为分类特征表示

  • area(烧毁面积)—作为数值保留的标签特征

我们首先要定义一些常量,这些常量将帮助我们跟踪哪个特征分配给了哪个类别。此外,我们将保留特定属性(例如,分类特征的最大类数;请参阅下一个列表)。

列表 15.3 定义特征转换步骤中与特征相关的常量

%%writefile forest_fires_constants.py                              ❶

VOCAB_FEATURE_KEYS = ['day','month']                               ❷

MAX_CATEGORICAL_FEATURE_VALUES = [7, 12]                           ❸

DENSE_FLOAT_FEATURE_KEYS = [
    'DC', 'DMC', 'FFMC', 'ISI', 'rain', 'temp', 'wind', 'X', 'Y'   ❹
]

BUCKET_FEATURE_KEYS = ['RH']                                       ❺

BUCKET_FEATURE_BOUNDARIES = [(33, 66)]                             ❻

LABEL_KEY = 'area'                                                 ❼

def transformed_name(key):                                         ❽

    return key + '_xf'

❶ 此命令将将此单元格的内容写入文件(阅读侧边栏以获取更多信息)。

❷ 基于词汇(或字符串)的分类特征。

❸ 数据集中假设每个分类特征都有一个最大值。

❹ 密集特征(这些将作为模型输入,或进行归一化处理)。

❺ 分桶特征。

❻ 分桶特征的分桶边界(例如,特征 RH 将被分桶为三个箱子:[0, 33),[33, 66),[66,inf))。

❼ 标签特征将保留为数值特征,因为我们正在解决回归问题。

❽ 定义一个函数,将在特征名称后添加后缀。这将帮助我们区分生成的特征和原始数据列。

我们将这些笔记本单元格写为 Python 脚本(或 Python 模块)的原因是因为 TFX 期望运行所需的一些代码部分作为 Python 模块。

%%writefile 魔术命令

%%writefile 是一个 Jupyter 魔术命令(类似于%%tensorboard)。它会导致 Jupyter 笔记本将单元格中的内容写入到新文件中(例如,Python 模块/脚本)。这是从笔记本单元格创建独立 Python 模块的好方法。笔记本很适合进行实验,但对于生产级别的代码,Python 脚本更好。例如,我们的 TFX 管道期望某些函数(例如,如何将原始列预处理为特征)是独立的 Python 模块。我们可以方便地使用%%writefile 命令来实现这一点。

此命令必须指定为要写入文件的单元格中的第一个命令。

接下来,我们将编写另一个模块 forest_fires_transform.py,其中将有一个预处理函数(称为 preprocessing_fn),该函数定义了每个数据列应如何处理以成为特征(请参见下一个列表)。

列表 15.4 定义将原始数据转换为特征的 Python 模块。

%%writefile forest_fires_transform.py                                      ❶

import tensorflow as tf
import tensorflow_transform as tft

import forest_fires_constants                                              ❷

_DENSE_FLOAT_FEATURE_KEYS = forest_fires_constants.DENSE_FLOAT_FEATURE_KEYS❸
_VOCAB_FEATURE_KEYS = forest_fires_constants.VOCAB_FEATURE_KEYS            ❸
_BUCKET_FEATURE_KEYS = forest_fires_constants.BUCKET_FEATURE_KEYS          ❸
_BUCKET_FEATURE_BOUNDARIES = 
➥ forest_fires_constants.BUCKET_FEATURE_BOUNDARIES                        ❸
_LABEL_KEY = forest_fires_constants.LABEL_KEY                              ❸
_transformed_name = forest_fires_constants.transformed_name                ❸

def preprocessing_fn(inputs):                                              ❹

  outputs = {}

  for key in _DENSE_FLOAT_FEATURE_KEYS:                                    ❺
    outputs[_transformed_name(key)] = tft.scale_to_z_score(                ❻
        sparse_to_dense(inputs[key])                                       ❼
    )

  for key in _VOCAB_FEATURE_KEYS:
    outputs[_transformed_name(key)] = tft.compute_and_apply_vocabulary(    ❽
        sparse_to_dense(inputs[key]),
        num_oov_buckets=1)

  for key, boundary in zip(_BUCKET_FEATURE_KEYS,                           ❾
➥ _BUCKET_FEATURE_BOUNDARIES):                                            ❾
    outputs[_transformed_name(key)] = tft.apply_buckets(                   ❾
        sparse_to_dense(inputs[key]), bucket_boundaries=[boundary]         ❾
    )                                                                      ❾

  outputs[_transformed_name(_LABEL_KEY)] = 
➥ sparse_to_dense(inputs[_LABEL_KEY])                                     ❿

  return outputs

def sparse_to_dense(x):                                                    ⓫

    return tf.squeeze(
        tf.sparse.to_dense(
            tf.SparseTensor(x.indices, x.values, [x.dense_shape[0], 1])
        ),
        axis=1
    )

❶ 此代码列表中的内容将被写入到单独的 Python 模块中。

❷ 导入先前定义的特征常量。

❸ 导入 forest_fires_constants 模块中定义的所有常量。

❹ 这是 tf.transform 库中必不可少的回调函数,用于将原始列转换为特征。

❺ 对所有密集特征进行处理。

❻ 对密集特征执行基于 Z-score 的缩放(或标准化)。

❼ 因为在 SchemaGen 步骤中 infer_feature_shape 设置为 False,我们的输入是稀疏张量。它们需要转换为密集张量。

❽ 对于基于词汇的特征,构建词汇表并将每个标记转换为整数 ID。

❾ 对待分桶的特征,使用定义的分桶边界,对特征进行分桶。

❿ 标签特征只是简单地转换为密集张量,没有其他特征转换。

⓫ 一个将稀疏张量转换为密集张量的实用函数。

您可以看到该文件被命名为 forest_fires_transform.py。它定义了一个 preprocessing_fn()函数,该函数接受一个名为 inputs 的参数。inputs 是一个从特征键到在 CSV 文件中找到的数据列的映射字典,从 example_gen 输出流动。最后,它返回一个字典,其中特征键映射到使用 tensorflow_transform 库转换的特征。在方法的中间,您可以看到预处理函数执行三项重要工作。

首先,它读取所有密集特征(其名称存储在 _DENSE_FLOAT_FEATURE_KEYS 中),并使用 z 分数对值进行归一化。z 分数将某一列x归一化为

15_05a

其中,μ(x)是列的平均值,σ(x)是列的标准差。要对数据进行归一化,可以调用 tensorflow_transform 库中的 scale_to_z_score()函数。您可以阅读有关 tensorflow_transform 的侧边栏,了解更多有关该库提供的内容。然后,该函数使用新的键(通过 _transformed_name 函数生成)将每个特征存储在输出中,该新键衍生自原始特征名称(新键通过在原始特征名称末尾添加 _xf 生成)。

接下来,它处理基于词汇的分类特征(其名称存储在 _VOCAB_FEATURE_KEYS 中),通过使用字典将每个字符串转换为索引。该字典将每个字符串映射到索引,并且可以自动从提供的训练数据中学习。这类似于我们如何使用 Keras 的 Tokenizer 对象学习字典,将单词转换为单词 ID。在 tensorflow_transform 库中,您可以使用 compute_and_apply_vocabulary()函数完成这一操作。对于 compute_and_apply_vocabulary()函数,我们可以通过传递 num_oov_buckets=1 来将任何未见字符串分配给特殊类别(除了已分配给已知类别的类别)。

然后,函数处理待进行桶化的特征。Bucketization 是将连续值应用于桶的过程,其中桶由一组边界定义。使用 apply_buckets()函数可以轻松地对特征进行 bucket 化,该函数将特征(在输入字典中提供)和桶边界作为输入参数。

最后,我们保留包含标签的列不变。通过这样,我们定义了 Transform 组件(mng.bz/mOGr)。

tensorflow_transform:将原始数据转换为特征

tensorflow_transform 是 TensorFlow 中的一个子库,主要关注特征转换。它提供了各种功能来计算各种东西:

  • 对特征进行桶化(例如,将一系列值分组到预定义的一组桶中)

  • 从字符串列中提取词袋特征

  • 数据集的协方差矩阵

  • 列的均值、标准差、最小值、最大值、计数等

您可以在mng.bz/5QgB上阅读有关此库提供的功能的更多信息。

from tfx.components import Transform

transform = Transform(
    examples=example_gen.outputs['examples'],
    schema=schema_gen.outputs['schema'],
    module_file=os.path.abspath('forest_fires_transform.py'),
)

context.run(transform)

Transform 组件接受三个输入:

  • CsvExampleGen 组件的输出示例

  • SchemaGen 生成的架构

  • 用于将数据转换为特征的 preprocessing_fn()函数的 Python 模块

当涉及到多组件流水线,比如 TFX 流水线时,我们必须尽可能地检查每一个中间输出。这比交给偶然性并祈祷一切顺利要好得多(通常情况下都不是这样)。因此,让我们通过打印运行 Transform 步骤后保存到磁盘上的一些数据来检查输出(见下一列表)。打印数据的代码与使用 CsvExampleGen 组件时打印数据的代码类似。

列表 15.5 检查 TFX Transform 步骤产生的输出

import forest_fires_constants

_DENSE_FLOAT_FEATURE_KEYS = forest_fires_constants.DENSE_FLOAT_FEATURE_KEYS
_VOCAB_FEATURE_KEYS = forest_fires_constants.VOCAB_FEATURE_KEYS
_BUCKET_FEATURE_KEYS = forest_fires_constants.BUCKET_FEATURE_KEYS
_LABEL_KEY = forest_fires_constants.LABEL_KEY

# Get the URI of the output artifact representing the training examples, which is a directory
train_uri = os.path.join(
    transform.outputs['transformed_examples'].get()[0].uri, 'Split-train'
)
tfrecord_filenames = [
    os.path.join(train_uri, name) for name in os.listdir(train_uri)        ❶
]

dataset = tf.data.TFRecordDataset(
    tfrecord_filenames, compression_type="GZIP"
)                                                                          ❷

example_records = []                                                       ❸
float_features = [
    _transformed_name(f) for f in _DENSE_FLOAT_FEATURE_KEYS + [_LABEL_KEY] ❹
]
int_features = [
    _transformed_name(f) for f in _BUCKET_FEATURE_KEYS + 
➥ _VOCAB_FEATURE_KEYS                                                     ❹
]
for tfrecord in dataset.take(5):                                           ❺
  serialized_example = tfrecord.numpy()                                    ❻
  example = tf.train.Example()                                             ❻
  example.ParseFromString(serialized_example)                              ❻
  record = [
    example.features.feature[f].int64_list.value for f in int_features     ❼
  ] + [
    example.features.feature[f].float_list.value for f in float_features   ❼
  ]
  example_records.append(record)                                           ❽
  print(example)
  print("="*50)

❶ 获取此目录中文件的列表(所有压缩的 TFRecord 文件)。

❷ 创建一个 TFRecordDataset 来读取这些文件。

❸ 用于存储检索到的特征值(以供以后检查)

❹ 稠密(即,浮点数)和整数(即,基于词汇和分桶)特征

❺ 获取数据集中的前五个示例。

❻ 获取一个 TF 记录并将其转换为可读的 tf.train.Example。

❼ 我们将从 tf.train.Example 对象中提取特征的值以供后续检查。

❽ 将提取的值作为记录(即,值的元组)附加到 example_records 中。

解释的代码将打印特征转换后的数据。每个示例都将整数值存储在属性路径下,例如 example.features.feature[] .int64_list.value,而浮点值存储在 example.features.feature [].float_list.value 中。这将打印例如

features {
  feature {
    key: "DC_xf"
    value {
      float_list {
        value: 0.4196213185787201
      }
    }
  }

  ...

  feature {
    key: "RH_xf"
    value {
      int64_list {
        value: 0
      }
    }
  }

  ...

  feature {
    key: "area_xf"
    value {
      float_list {
        value: 2.7699999809265137
      }
    }
  }

  ...
}

请注意,我们使用 _transformed_name()函数来获取转换后的特征名称。我们可以看到,浮点值(DC_xf)使用 z 分数标准化,基于词汇的特征(day_xf)转换为整数,并且分桶特征(RH_xf)被呈现为整数。

经验法则:尽可能检查您的管道

当使用 TFX 等第三方库提供的组件时,对于底层实际发生的事情几乎没有透明度。TFX 并不是一个高度成熟的工具,并且正在开发过程中,这使问题更加严重。因此,我们总是尝试并入一些代码片段来探查这些组件,这将帮助我们检查这些组件的输入和输出是否正常。

在下一节中,我们将训练一个简单的回归模型,作为我们一直在创建的流水线的一部分。

练习 1

假设你想要做以下事情而不是先前定义的特征转换:

  • DC—将数据缩放到[0, 1]的范围内

  • temp—利用边界值(-inf,20],(20,30]和(30,inf)进行分桶处理

一旦特征被转换,将它们添加到名为 outputs 的字典中,其中每个特征都以转换后的特征名称作为键。假设你可以通过调用 _transformed_name('temp') 来获取 temp 的转换后的特征名称。您如何使用 tensorflow_transform 库来实现此目标?您可以使用 scale_to_0_1() 和 apply_buckets() 函数来实现这一点。

15.2 训练一个简单的回归神经网络:TFX Trainer API

您已经定义了一个 TFX 数据管道,可以将 CSV 文件中的示例转换为模型准备的特征。现在,您将使用 TFX 定义一个模型训练器,该模型训练器将采用一个简单的两层全连接回归模型,并将其训练在从数据管道流出的数据上。最后,您将使用模型对一些样本评估数据进行预测。

使用 TFX 定义了一个良好定义的数据管道后,我们就可以使用从该管道流出的数据来训练模型。通过 TFX 训练模型一开始可能会稍微费劲,因为它期望的函数和数据的严格结构。但是,一旦您熟悉了您需要遵循的格式,它就会变得更容易。

我们将分三个阶段进行本节的学习。首先,让我们看看如何定义一个适合 TFX Transform 组件中定义的输出特征的 Keras 模型。最终,模型将接收 Transform 组件的输出。接下来,我们将研究如何编写一个封装了模型训练的函数。此函数将使用所定义的模型,并结合几个用户定义的参数,对模型进行训练并将其保存到所需的路径。保存的模型不能只是任意模型;在 TensorFlow 中它们必须具有所谓的 签名。这些签名规定了模型在最终通过 API 使用时的输入和输出是什么样子的。API 通过一个服务器提供,该服务器公开一个网络端口供客户端与 API 通信。图 15.6 描述了 API 如何与模型关联。

15-06

图 15.6 模型如何与 API、TensorFlow 服务器和客户端交互

让我们理解图 15.6 中发生了什么。首先,一个 HTTP 客户端发送请求到服务器。正在监听任何传入请求的服务器(即 TensorFlow 服务服务器)将读取请求并将其指向所需的模型签名。一旦模型签名接收到数据,它将对数据进行必要的处理,将其传递给模型,并生成输出(例如预测)。一旦预测可用,服务器将其返回给客户端。我们将在单独的部分详细讨论 API 和服务器端。在本节中,我们的重点是模型。

TensorFlow 服务中的签名是什么?

在现实生活中,签名的目的是唯一标识一个人。同样,TensorFlow 使用签名来唯一确定当通过 HTTP 请求将输入传递给模型时模型应该如何行为。一个签名有一个键和一个值。键是一个唯一标识符,定义了要激活该签名的确切 URL。值被定义为一个 TensorFlow 函数(即用 @tf.function 装饰的函数)。这个函数将定义如何处理输入并将其传递给模型以获得最终期望的结果。你现在不需要担心细节。我们有一个专门的部分来学习关于签名的内容。

我们将在单独的子部分回顾签名以更详细地理解它们。最后,我们将通过加载模型并向其提供一些数据来直观地检查模型预测。

15.2.1 定义 Keras 模型

使用 TFX 训练模型的基石是定义一个模型。有两种方法可以为 TFX 定义模型:使用 Estimator API 或使用 Keras API。我们将使用 Keras API,因为 Estimator API 不推荐用于 TensorFlow 2(有关详细信息,请参见下面的侧边栏)。

Estimator API vs. Keras API

我的观点是,未来,Keras 可能会成为构建模型的首选 API,而 Estimator API 可能会被弃用。TensorFlow 网站上说:

不建议使用 Estimators 编写新代码。Estimators 运行 v1.Session 风格的代码,这更难以编写正确,并且可能表现出乎意料,特别是当与 TF 2 代码结合使用时。Estimators 落在我们的兼容性保证下,但除了安全漏洞之外将不会收到任何修复。详情请参阅迁移指南。

来源:www.tensorflow.org/tfx/tutorials/tfx/components

我们首先要创建一个名为 _build_keras_model() 的函数,它将执行两项任务。首先,它将为我们在 Transform 步骤中定义的所有特征创建 tf.feature_column 类型的对象。tf.feature_column 是一种特征表示标准,被 TensorFlow 中定义的模型所接受。它是一种用于以列为导向的方式定义数据的便利工具(即,每个特征都表示为一列)。列式表示非常适用于结构化数据,其中每列通常是目标变量的独立预测器。让我们来看一些在 TensorFlow 中找到的具体 tf.feature_column 类型:

  • tf.feature_column.numeric_column——用于表示像温度这样的稠密浮点字段。

  • tf.feature_column.categorical_column_with_identity——用于表示分类字段或桶化字段,其中值是指向类别或桶的整数索引,例如日或月。因为传递给列本身的值是类别 ID,所以使用了“identity”这个术语。

  • tf.feature_column.indicator_column—将 tf.feature_column.categorical_column_with_identity 转换为独热编码表示。

  • tf.feature_column.embedding_column—可以用于从基于整数的列(如 tf.feature_column.categorical_column_with_identity)生成嵌入。它在内部维护一个嵌入层,并将给定整数 ID 返回相应的嵌入。

要查看完整列表,请参考mng.bz/6Xeo。在这里,我们将使用 tf.feature_columns 的前三种类型作为我们待定义模型的输入。以下列表概述了如何使用 tf.feature_columns 作为输入。

第 15.6 节 构建使用特征列的 Keras 模型

def _build_keras_model() -> tf.keras.Model:                     ❶

  real_valued_columns = [                                       ❷
      tf.feature_column.numeric_column(key=key, shape=(1,))
      for key in _transformed_names(_DENSE_FLOAT_FEATURE_KEYS)
  ]

  categorical_columns = [                                       ❸
      tf.feature_column.indicator_column(
          tf.feature_column.categorical_column_with_identity(
              key, 
              num_buckets=len(boundaries)+1
          )
      ) for key, boundaries in zip(
          _transformed_names(_BUCKET_FEATURE_KEYS),
          _BUCKET_FEATURE_BOUNDARIES
      )
  ]

  categorical_columns += [                                      ❹
      tf.feature_column.indicator_column(
          tf.feature_column.categorical_column_with_identity( 
              key,
              num_buckets=num_buckets,
              default_value=num_buckets-1
          )
      ) for key, num_buckets in zip(
              _transformed_names(_VOCAB_FEATURE_KEYS),
              _MAX_CATEGORICAL_FEATURE_VALUES
      )      
  ]

  model = _dnn_regressor(                                       ❺
      columns=real_valued_columns+categorical_columns,          ❻
      dnn_hidden_units=[128, 64]                                ❼
  )

  return model

❶ 定义函数签名。它将一个 Keras 模型作为输出返回。

❷ 为密集特征创建 tf.feature_column 对象。

❸ 为分桶特征创建 tf.feature_column 对象。

❹ 为分类特征创建 tf.feature_column 对象。

❺ 使用该函数定义一个深度回归模型。

❻ 使用上面定义的列

❼ 它将有两个中间层:128 个节点和 64 个节点。

让我们看一下存储在 real_valued_columns 中的第一组特征列。我们取密集浮点值列的原始键的转换名称,并为每列创建一个 tf.feature_column.numeric_column。您可以看到我们正在传递

  • (字符串)—特征的名称

  • 形状(一个列表/元组)—完整形状将派生为[批量大小] + 形状

例如,列 temp 的键将为 temp_xf,形状为(1,),意味着完整形状为[批量大小,1]。这个形状为[批量大小,1]是有意义的,因为每个密集特征每条记录只有一个值(这意味着我们在形状中不需要特征维度)。让我们通过一个玩具例子来看看 tf.feature_column.numeric_column 的运作:

a = tf.feature_column.numeric_column("a")
x = tf.keras.layers.DenseFeatures(a)({'a': [0.5, 0.6]})
print(x)

这将输出

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

在为分桶特征定义 tf.feature_column.categorical_column_with_identity 时,您需要传递

  • 键(字符串)—特征的名称

  • num_buckets(整数)—分桶特征中的桶数

例如,对于被分桶的 RH 特征,其键为 RH_xf,num_buckets = 3,其中桶为[[-inf,33),[33,66),[66,inf]]。由于我们将 RH 的分桶边界定义为(33, 66),num_buckets 被定义为 len(boundaries) +1 = 3。最后,每个分类特征都包装在 tf.feature_column.indicator_column 中,以将每个特征转换为独热编码表示。同样,我们可以进行一个快速实验来查看这些特征列的效果如何:

b = tf.feature_column.indicator_column(
    tf.feature_column.categorical_column_with_identity('b', num_buckets=10)
)
y = tf.keras.layers.DenseFeatures(b)({'b': [5, 2]})
print(y)

这将产生

tf.Tensor(
[[0\. 0\. 0\. 0\. 0\. 1\. 0\. 0\. 0\. 0.]
 [0\. 0\. 1\. 0\. 0\. 0\. 0\. 0\. 0\. 0.]], shape=(2, 10), dtype=float32)

最后,基于词汇的分类特征与分桶特征类似处理。对于每个特征,我们获取特征名称和最大类别数,并使用 tf.feature_column.categorical_column_with_identity 列定义一个列,其中

  • 键(字符串)—特征的名称。

  • num_buckets(整数)—类别数。

  • default_value(int)—如果遇到以前看不见的类别,将分配该值。

在这里,默认值是一个重要的部分。它将决定测试数据中出现的任何看不见的类别会发生什么,这些类别不是训练数据的一部分。我们问题中基于词汇的分类特征是天和月,分别只能有 7 和 12 个不同的值。但可能会出现这样的情况,训练集只有 11 个月,测试集有 12 个月。为了解决这个问题,我们将任何看不见的类别分配给我们可用的最后一个类别 ID(即,num_buckets - 1)。

现在我们有了一组明确定义的数据列,这些数据列包装在 tf.feature_column 对象中,准备馈送给模型。最后,我们看到一个名为 _dnn_regressor() 的函数,它将创建一个 Keras 模型,如下图所示,并将我们创建的列和一些其他超参数传递给它。现在让我们讨论一下这个函数的具体内容。

列表 15.7 定义回归神经网络

def _dnn_regressor(columns, dnn_hidden_units):                            ❶

  input_layers = {
      colname: tf.keras.layers.Input(
          name=colname, shape=(), dtype=tf.float32
      )                                                                   ❷
      for colname in _transformed_names(_DENSE_FLOAT_FEATURE_KEYS)
  }
  input_layers.update({
      colname: tf.keras.layers.Input(
          name=colname, shape=(), dtype='int32'
      )                                                                   ❸
      for colname in _transformed_names(_VOCAB_FEATURE_KEYS)
  })
  input_layers.update({
      colname: tf.keras.layers.Input(
          name=colname, shape=(), dtype='int32'
      )                                                                   ❹
      for colname in _transformed_names(_BUCKET_FEATURE_KEYS)
  })  

  output = tf.keras.layers.DenseFeatures(columns)(input_layers)           ❺
  for numnodes in dnn_hidden_units:
    output = tf.keras.layers.Dense(numnodes, activation='tanh')(output)   ❻

  output = tf.keras.layers.Dense(1)(output)                               ❼

  model = tf.keras.Model(input_layers, output)                            ❽
  model.compile(
      loss='mean_squared_error',                                          ❾
      optimizer=tf.keras.optimizers.Adam(lr=0.001)
  )
  model.summary(print_fn=absl.logging.info)                               ❿

  return model

❶ 定义一个函数,它以一堆列和一列隐藏维度的列表作为输入。

❷ 模型的输入: 输入字典,其中键是特征名称,值是 Keras 输入层

❸ 通过为基于词汇的分类特征创建输入层更新字典。

❹ 通过为分桶特征创建输入层更新字典。

❺ 由于输入层被定义为字典,我们使用 DenseFeatures 层生成单一的张量输出。

❻ 我们通过创建一系列稠密层来递归计算输出。

❼ 创建一个最终的回归层,它有一个输出节点和线性激活。

❽ 使用输入和输出定义模型。

❾ 编译模型。请注意它使用均方误差作为损失函数。

❿ 通过我们在开始定义的 absl 记录器打印模型的摘要。

我们已按列的形式定义了数据,其中每列都是 TensorFlow 特征列。定义数据后,我们使用一个特殊层叫做 tf.keras.layers.DenseFeatures 来处理这些数据。 DenseFeatures 接受

  • 特征列列表

  • 一个 tf.keras.layers.Input 层的字典,其中每个输入层的键都在特征列列表中找到的列名

有了这些数据,DenseFeatures 层可以将每个输入层映射到相应的特征列,并在最后产生一个单一的张量输出(存储在变量输出中)(图 15.7)。

15-07

图 15.7 DenseFeatures 层功能概述

然后我们通过将数据通过几个隐藏层流动来递归计算输出。这些隐藏层的大小(一个整数列表)作为参数传递给函数。我们将使用 tanh 非线性激活作为隐藏层。最终的隐藏输出进入具有线性激活的单节点回归层。

最后,我们使用 Adam 优化器和均方损失作为损失函数对模型进行编译。重要的是要注意,我们必须为模型使用与回归兼容的损失函数。均方误差是用于回归问题的非常常见的损失函数。

Python 中的类型提示

您将看到一些函数的定义方式与我们过去所做的方式不同。例如,函数定义为

def _build_keras_model() -> tf.keras.Model:

def run_fn(fn_args: tfx.components.FnArgs):

这是 Python 中的可视类型提示,并且在 Python 中是可用的。这意味着类型不会以任何方式由 Python 解释器强制执行;相反,它们是一种视觉提示,以确保开发人员使用正确的输入和输出类型。在函数中定义参数时,可以使用以下语法定义该参数期望的数据类型 def (: ):。例如,在函数 run_fn() 中,第一个参数 fn_args 必须是 tfx.components.FnArgs 类型。

然后,您还可以将函数返回的输出定义为 def (: ) -> :。例如,_build_keras_model() 函数返回的对象必须是一个 tf.keras.Model 对象。

有些对象需要使用多种数据类型或自定义数据类型(例如,字符串列表)创建复杂数据类型。对于这一点,您可以使用一个名为 typing 的内置 Python 库。typing 允许您方便地定义数据类型。有关更多信息,请参阅 docs.python.org/3/library/typing.xhtml

在列表 15.8 中,我们定义了一个函数,给定一组训练数据文件名和评估数据文件名,生成用于训练和评估数据的 tf.data.Dataset 对象。我们将这个特殊函数定义为 _input_fn()。_input_fn() 接受三个参数:

  • file_pattern — 一组文件路径,其中文件包含数据

  • data_accessor — TFX 中的特殊对象,通过接受文件名列表和其他配置来创建 tf.data.Dataset

  • batch_size — 指定数据批次大小的整数

列表 15.8 用于使用输入文件生成 tf.data.Dataset 的函数

from typing import List, Text                                ❶

def _input_fn(file_pattern: List[Text],                      ❷
              data_accessor: tfx.components.DataAccessor,    ❸
              tf_transform_output: tft.TFTransformOutput,    ❹
              batch_size: int = 200) -> tf.data.Dataset:     ❺

  return data_accessor.tf_dataset_factory(
      file_pattern,
      tfxio.TensorFlowDatasetOptions(
          batch_size=batch_size, label_key=_transformed_name(_LABEL_KEY)),
      tf_transform_output.transformed_metadata.schema)

❶ typing 库定义了函数输入的类型。

❷ 输入 tfrecord 文件的路径或模式的列表。它是 Text 类型对象(即字符串)的列表。

❸ DataAccessor 用于将输入转换为 RecordBatch

❹ 一个 TFTransformOutput

❺ 表示要合并为单个批次的返回数据集的连续元素的数量

您可以看到我们如何使用类型提示来标记参数以及返回对象。该函数通过调用 tf_dataset_factory() 函数获取 tf.data.Dataset,该函数使用文件路径列表和数据集选项(如批量大小和标签键)进行调用。标签键对于 data_accessor 来说非常重要,因为它能确定输入字段和目标。您可以看到 data_accessor 也需要从 Transform 步骤获取模式。这有助于 data_accessor 将原始示例转换为特征,然后分离输入和标签。在解释了所有关键函数之后,我们现在继续看看所有这些将如何被编排以进行模型训练。

15.2.2 定义模型训练

现在我们需要做的主要任务是模型的实际训练。负责模型训练的 TFX 组件(称为 Trainer)期望有一个名为 run_fn() 的特殊函数,该函数将告诉模型应该如何被训练和最终保存(见清单 15.9)。这个函数接受一个特殊类型的对象 called FnArgs,这是 TensorFlow 中的一个实用对象,可以用来声明需要传递给模型训练函数的与模型训练相关的用户定义参数。

清单 15.9 运行 Keras 模型训练与数据。

def run_fn(fn_args: tfx.components.FnArgs):                        ❶

  absl.logging.info("="*50)
  absl.logging.info("Printing the tfx.components.FnArgs object")   ❷
  absl.logging.info(fn_args)                                       ❷
  absl.logging.info("="*50)

  tf_transform_output = tft.TFTransformOutput(
    fn_args.transform_graph_path
  )                                                                ❸

  train_dataset = _input_fn(
    fn_args.train_files, fn_args.data_accessor, tf_transform_output, 
➥ 40                                                              ❹
  )
  eval_dataset = _input_fn(
    fn_args.eval_files, fn_args.data_accessor, tf_transform_output, 
➥ 40                                                              ❹
  )
  model = _build_keras_model()                                     ❺

  csv_write_dir = os.path.join(
    fn_args.model_run_dir,'model_performance'
)                                                                  ❻
  os.makedirs(csv_write_dir, exist_ok=True)

  csv_callback = tf.keras.callbacks.CSVLogger(
    os.path.join(csv_write_dir, 'performance.csv'), append=False   ❼
  )

  model.fit(                                                       ❽
      train_dataset,
      steps_per_epoch=fn_args.train_steps,
      validation_data=eval_dataset,
      validation_steps=fn_args.eval_steps,
      epochs=10,
      callbacks=[csv_callback]
  )

  signatures = {                                                   ❾
      'serving_default':
          _get_serve_tf_examples_fn(
              model, tf_transform_output
          ).get_concrete_function(
              tf.TensorSpec(
                  shape=[None],
                  dtype=tf.string,
                  name='examples'
              )
          ),

  }
  model.save(fn_args.serving_model_dir, save_format='tf', signatures=signatures)                                        ❿

❶ 定义一个名为 run_fn 的函数,该函数以 tfx.components.FnArgs 对象作为输入。

❷ 记录 fn_args 对象中的值。

❸ 加载 tensorflow_transform 图。

❹ 使用函数 _input_fn(即将讨论)将 CSV 文件中的数据转换为 tf.data.Dataset 对象。

❺ 使用先前定义的函数构建 Keras 模型。

❻ 定义一个目录来存储 Keras 回调 CSVLogger 生成的 CSV 日志。

❼ 定义 CSVLogger 回调。

❽ 使用创建的数据集和 fn_args 对象中存在的超参数来拟合模型。

❾ 为模型定义签名。签名告诉模型在模型部署时通过 API 调用时该做什么。

❿ 将模型保存到磁盘。

让我们首先检查 run_fn()的方法签名。run_fn()接受一个 FnArgs 类型的单一参数作为输入。如前所述,FnArgs 是一个实用对象,它存储了对模型训练有用的键值对集合。这个对象中的大部分元素是由 TFX 组件本身填充的。不过,你也有灵活性传递一些值。我们将定义这个对象中一些最重要的属性。但是一旦我们看到 TFX Trainer 组件生成的完整输出,我们将学习更多关于这个对象的属性列表。表 15.2 为你提供了这个对象中存储的内容的概览。如果你对这些元素的用途不是很理解,不要担心。随着我们的学习,它们会变得更清晰。一旦我们运行 Trainer 组件,它将显示用于每一个属性的值,因为我们在其中包含了记录语句来记录 fn_args 对象。这将帮助我们对当前运行的示例将这些属性进行上下文化,并更清晰地理解它们。

表 15.2 fn_args 类型对象中存储的属性概览

属性 描述 示例
train_files 训练文件名列表 ['.../Transform/transformed_examples/16/Split-train/*'],
eval_files 评估/验证文件名列表 ['.../Transform/transformed_examples/16/Split-eval/*']
train_steps 训练步数 100
eval_steps 评估/验证步数 100
schema_path TFX 组件 SchemaGen 生成的模式路径 '.../SchemaGen/schema/15/schema.pbtxt'
transform_graph_path TFX 组件 Transform 生成的转换图路径 '.../SchemaGen/schema/15/schema.pbtxt'
serve_model_dir 存储可提供服务的模型的输出目录 '.../Trainer/model/17/Format-Serving'
model_run_dir 存储模型的输出目录 '.../Trainer/model_run/17'

这个函数完成的第一个重要任务是为训练和评估数据生成 tf.data.Dataset 对象。我们定义了一个特殊的函数叫做 _input_fn()来实现这个功能(见 15.8 节)。

定义了数据集之后,我们使用之前讨论过的 _build_keras_model() 函数定义 Keras 模型。然后我们定义了一个 CSVLogger 回调函数来记录性能指标随时间的变化,就像我们之前做的那样。简要回顾一下,tf.keras.callbacks.CSVLogger 会在模型编译期间创建一个 CSV 文件,记录每个周期的所有损失和指标。我们将使用 fn_arg 对象的 model_run_dir 属性来为 CSV 文件创建一个路径,该路径位于模型创建目录内。这样,如果我们运行多个训练试验,每个试验都将与模型一起保存其自己的 CSV 文件。之后,我们像之前无数次那样调用 model.fit() 函数。我们使用的参数很简单,所以我们不会详细讨论它们,也不会不必要地延长这个讨论。

15.2.3 SignatureDefs:定义模型在 TensorFlow 外部的使用方式

一旦模型训练完成,我们必须将模型存储在磁盘上,以便以后可以重用。存储此模型的目的是通过基于 Web 的 API(即 REST API)来查询模型使用输入并获取预测结果。这通常是在在线环境中为客户提供服务的机器学习模型的使用方式。为了让模型理解基于 Web 的请求,我们需要定义称为 SignatureDefs 的东西。签名定义了模型的输入或目标是什么样子的(例如,数据类型)。您可以看到我们定义了一个叫做 signatures 的字典,并将其作为参数传递给 model.save()(清单 15.9)。

signatures 字典应该有键值对,其中键是签名名称,值是使用 @tf.function 装饰器装饰的函数。如果您想快速回顾一下此装饰器的作用,请阅读下面的侧边栏。

@tf.function 装饰器

@tf.function 装饰器接受一个执行各种 TensorFlow 操作的函数,该函数使用 TensorFlow 操作数,然后跟踪所有步骤并将其转换为数据流图。在大多数情况下,TensorFlow 需要显示输入和输出如何在操作之间连接的数据流图。尽管在 TensorFlow 1.x 中,您必须显式构建此图,但 TensorFlow 2.x 以后不再让开发人员负责此责任。每当一个函数被 @tf.function 装饰器装饰时,它会为我们构建数据流图。

还要注意,您不能将任意名称用作签名名称。TensorFlow 有一组根据您的需求定义的签名名称。这些在 TensorFlow 的特殊常量模块中定义(mng.bz/o2Kd)。有四种签名可供选择:

  • PREDICT_METHOD_NAME(值:'tensorflow/serving/predict')—这个签名用于预测传入输入的目标。这不期望目标存在。

  • REGRESS_METHOD_NAME(值为 'tensorflow/serving/regress')——此签名可用于从示例进行回归。它期望 HTTP 请求体中同时存在输入和输出(即目标值)。

  • CLASSIFY_METHOD_NAME(值为 'tensorflow/serving/classify')——与 REGRESS_METHOD_NAME 类似,但用于分类。此签名可用于分类示例。它期望 HTTP 请求中同时存在输入和输出(即目标值)。

  • DEFAULT_SERVING_SIGNATURE_DEF_KEY(值为 'serving_default')——这是默认签名名称。模型至少应该有默认的服务签名才能通过 API 使用。如果没有定义其他签名,则请求将经过此签名。

我们只定义了默认签名。签名采用 TensorFlow 函数(即用 @tf.function 装饰的函数)作为值。因此,我们需要定义一个函数(我们将其称为 _get_serve_tf_examples_fn() ),以告诉 TensorFlow 对输入做什么(请参见下一个清单)。

清单 15.10 解析通过 API 请求发送的示例并从中进行预测。

def _get_serve_tf_examples_fn(model, tf_transform_output):            ❶

  model.tft_layer = tf_transform_output.transform_features_layer()    ❷

  @tf.function
  def serve_tf_examples_fn(serialized_tf_examples):                   ❸
    """Returns the output to be used in the serving signature."""
    feature_spec = tf_transform_output.raw_feature_spec()             ❹
    feature_spec.pop(_LABEL_KEY)                                      ❺
    parsed_features = tf.io.parse_example(serialized_tf_examples, 
➥ feature_spec)                                                      ❻
    transformed_features = model.tft_layer(parsed_features)           ❼
    return model(transformed_features)                                ❽

  return serve_tf_examples_fn                                         ❾

❶ 返回一个函数,该函数解析序列化的 tf.Example 并应用特征转换。

❷ 以 Keras 层的形式获取要执行的特征转换。

❸ 被 @tf.function 装饰的函数将被返回。

❹ 获取原始列规范。

❺ 删除标签的特征规范,因为我们不需要在预测中使用它。

❻ 使用特征规范解析序列化示例。

❼ 使用定义的层将原始列转换为特征。

❽ 在提供转换后的特征之后返回模型的输出。

❾ 返回 TensorFlow 函数。

首先要注意的一件重要事情是,_get_serve_tf_examples_fn() 返回一个函数(即 serve_tf_examples_fn ),它是 TensorFlow 函数。_get_serve_tf_examples_fn() 接受两个输入:

  • Model — 我们在训练时建立的 Keras 模型。

  • tf_transform_output——将原始数据转换为特征的转换图。

返回函数应指示 TensorFlow 在模型部署后通过 API 请求传入的数据要执行什么操作。返回的函数以序列化示例作为输入,将它们解析为符合模型输入规范的正确格式,生成并返回输出。我们不会深入解析此功能的输入和输出,因为我们不会直接调用它,而是访问 TFX,在 API 调用时将访问它。

在这个过程中,函数首先获得原始特征规范映射,这是一个列名映射到 Feature 类型的字典。Feature 类型描述了放入特征中的数据类型。例如,对于我们的数据,特征规范将是这样的:

{
  'DC': VarLenFeature(dtype=tf.float32), 
  'DMC': VarLenFeature(dtype=tf.float32),
  'RH': VarLenFeature(dtype=tf.int64), 
  ...
  'X': VarLenFeature(dtype=tf.int64), 
  'area': VarLenFeature(dtype=tf.float32), 
  'day': VarLenFeature(dtype=tf.string), 
  'month': VarLenFeature(dtype=tf.string)
}

可以观察到,根据该列中的数据使用了不同的数据类型(例如 float、int、string)。您可以在 www.tensorflow.org/api_docs/python/tf/io/ 上看到一列特征类型的列表。接下来,我们删除了具有 _LABEL_KEY 的特征,因为它不应该是输入的一部分。然后我们使用 tf.io.parse_example() 函数通过传递特征规范映射来解析序列化的示例。结果被传递给 TransformFeaturesLayer (mng.bz/nNRa),它知道如何将一组解析后的示例转换为一批输入,其中每个输入具有多个特征。最后,转换后的特征被传递给模型,该模型返回最终输出(即,预测的森林烧毁面积)。让我们重新审视列表 15.9 中的签名定义:

signatures = {
      'serving_default':
          _get_serve_tf_examples_fn(
              model, tf_transform_output
          ).get_concrete_function(
              tf.TensorSpec(
                  shape=[None],
                  dtype=tf.string,
                  name='examples'
              )
          ),    
  }

您可以看到,我们并不只是简单地传递 _get_serve_tf_examples_fn() 的返回 TensorFlow 函数。相反,我们在返回函数(即 TensorFlow 函数)上调用了 get_concrete_function()。如果您还记得我们之前的讨论,当您执行带有 @tf.function 装饰的函数时,它会执行两件事:

  • 追踪函数并创建数据流图以执行函数的工作

  • 执行图以返回输出

get_concrete_function() 只做第一个任务。换句话说,它返回了追踪的函数。您可以在 mng.bz/v6K7 上阅读更多相关内容。

使用 TFX Trainer 训练 Keras 模型 15.2.4

我们现在有了训练模型的所有必要条件。再次强调,我们首先定义了一个 Keras 模型,定义了一个运行模型训练的函数,最后定义了指令,告诉模型当通过 API 发送 HTTP 请求时应该如何行事。现在我们将在 TFX 流水线的一部分中训练模型。为了训练模型,我们将使用 TFX Trainer 组件:

from tfx.components import Trainer
from tfx.proto import trainer_pb2
import tensorflow.keras.backend as K

K.clear_session()

n_dataset_size = df.shape[0]
batch_size = 40

n_train_steps_mod = 2*n_dataset_size % (3*batch_size)
n_train_steps = int(2*n_dataset_size/(3*batch_size))
if n_train_steps_mod != 0:
    n_train_steps += 1

n_eval_steps_mod = n_dataset_size % (3*batch_size)
n_eval_steps = int(n_dataset_size/(3*batch_size))
if n_eval_steps != 0:
    n_eval_steps += 1

trainer = Trainer(
    module_file=os.path.abspath("forest_fires_trainer.py"),
    transformed_examples=transform.outputs['transformed_examples'],
    schema=schema_gen.outputs['schema'],
    transform_graph=transform.outputs['transform_graph'],
    train_args=trainer_pb2.TrainArgs(num_steps=n_train_steps),
    eval_args=trainer_pb2.EvalArgs(num_steps=n_eval_steps))

context.run(trainer)

在 Trainer 组件之前的代码只是计算了一个周期中所需的正确迭代次数。为了计算这个值,我们首先得到了数据的总大小(记住我们将数据集存储在 DataFrame df 中)。然后我们为训练使用了两个哈希桶,评估使用了一个哈希桶。因此,我们大约有三分之二的训练数据和三分之一的评估数据。最后,如果值不能完全被整除,我们就会加上 +1 来包含数据的余数。

让我们更详细地研究 Trainer 组件的实例化。有几个重要的参数需要传递给构造函数:

  • module_file——包含 run_fn() 的 Python 模块的路径。

  • transformed_examples——TFX Transform 步骤的输出,特别是转换后的示例。

  • schema——TFX SchemaGen 步骤的输出。

  • train_args——指定与训练相关的参数的 TrainArgs 对象。(要查看为该对象定义的 proto 消息,请参见 mng.bz/44aw。)

  • eval_args—一个指定评估相关参数的 EvalArgs 对象。(要查看为此对象定义的 proto 消息,请参见mng.bz/44aw。)

这将输出以下日志。由于日志输出的长度,我们已经截断了某些日志消息的部分:

INFO:absl:Generating ephemeral wheel package for 
➥ '/home/thushv89/code/manning_tf2_in_action/Ch15-TFX-for-MLOps-in-
➥ TF2/tfx/forest_fires_trainer.py' (including modules: 
➥ ['forest_fires_constants', 'forest_fires_transform', 
➥ 'forest_fires_trainer']).

...

INFO:absl:Training model.

...

43840.0703WARNING:tensorflow:11 out of the last 11 calls to <function 
➥ recreate_function.<locals>.restored_function_body at 0x7f53c000ea60> 
➥ triggered tf.function retracing. Tracing is expensive and the excessive 
➥ number of tracings could be due to (1) creating @tf.function repeatedly 
➥ in a loop, (2) passing tensors with different shapes, (3) passing 
➥ Python objects instead of tensors. 

INFO:absl:____________________________________________________________________________
INFO:absl:Layer (type)                    Output Shape         Param #    
➥ Connected to                     
INFO:absl:=================================================================
➥ ===========

...

INFO:absl:dense_features (DenseFeatures)  (None, 31)           0           
➥ DC_xf[0][0]                      
INFO:absl:                                                                
➥ DMC_xf[0][0]                     
INFO:absl:                                                               
➥ FFMC_xf[0][0]                    
...
INFO:absl:                                                               
➥ temp_xf[0][0]                    
INFO:absl:                                                               
➥ wind_xf[0][0]                    
INFO:absl:_________________________________________________________________
➥ ___________

...

INFO:absl:Total params: 12,417

...

Epoch 1/10
9/9 [==============================] - ETA: 3s - loss: 43840.070 - 1s 
➥ 32ms/step - loss: 13635.6658 - val_loss: 574.2498
Epoch 2/10
9/9 [==============================] - ETA: 0s - loss: 240.241 - 0s 
➥ 10ms/step - loss: 3909.4543 - val_loss: 495.7877
...
Epoch 9/10
9/9 [==============================] - ETA: 0s - loss: 42774.250 - 0s 
➥ 8ms/step - loss: 15405.1482 - val_loss: 481.4183
Epoch 10/10
9/9 [==============================] - 1s 104ms/step - loss: 1104.7073 - 
➥ val_loss: 456.1211
...

INFO:tensorflow:Assets written to: 
➥ /home/thushv89/code/manning_tf2_in_action/Ch15-TFX-for-MLOps-in-
➥ TF2/tfx/pipeline/examples/forest_fires_pipeline/Trainer/model/5/Format-
➥ Serving/assets
INFO:absl:Training complete. Model written to 
➥ /home/thushv89/code/manning_tf2_in_action/Ch15-TFX-for-MLOps-in-
➥ TF2/tfx/pipeline/examples/forest_fires_pipeline/Trainer/model/5/Format-
➥ Serving. ModelRun written to 
➥ /home/thushv89/code/manning_tf2_in_action/Ch15-TFX-for-MLOps-in-
➥ TF2/tfx/pipeline/examples/forest_fires_pipeline/Trainer/model_run/5
INFO:absl:Running publisher for Trainer
INFO:absl:MetadataStore with DB connection initialized

在日志消息中,我们可以看到 Trainer 做了大量的繁重工作。首先,它使用 forest_fires_trainer.py 中定义的模型训练代码创建一个 wheel 包。wheel(扩展名为 .whl)是 Python 打包库的方式。例如,当你执行 pip install tensorflow 时,它会首先下载带有最新版本的 wheel 包并在本地安装。如果你有一个本地下载的 wheel 包,你可以使用 pip install <wheel 的路径>。你可以在 <pipeline 根目录路径>/examples/forest_fires_pipeline/_wheels 目录中找到生成的 wheel 包。然后它打印模型摘要。它为传递给模型的每个特征都有一个输入层。你可以看到 DenseFeatures 层聚合了所有这些输入层,以生成一个 [None, 31] 大小的张量。作为最终输出,模型产生了一个 [None, 1] 大小的张量。然后进行模型训练。你会看到警告,比如

out of the last x calls to <function 
➥ recreate_function.<locals>.restored_function_body at 0x7f53c000ea60> 
➥ triggered tf.function retracing. Tracing is expensive and the excessive 
➥ number of tracings could be due to

当 TensorFlow 函数跟踪发生太多次时,就会出现这个警告。这可能是代码编写不佳的迹象(例如,模型在循环内部被重建多次),有时是不可避免的。在我们的案例中,是后者。Trainer 模块的行为导致了这种行为,我们对此无能为力。最后,组件将模型以及一些实用工具写入到管道根目录的一个文件夹中。到目前为止,我们的管道根目录看起来是这样的(图 15.8)。

15-08

图 15.8 运行 Trainer 后的完整目录/文件结构

在 Trainer 的输出日志中,我们可以注意到一个主要问题是训练和验证损失。对于这个问题,它们相当大。我们使用的是计算得出的均方误差。

15_08a

其中 N 是示例的数量,y[i] 是第 i 个示例,[i] 是第 i 个示例的预测值。在训练结束时,我们的平方损失约为 481,意味着每个示例约有 22 公顷(即 0.22 平方公里)的误差。这不是一个小错误。如果你调查这个问题,你会意识到这主要是由数据中存在的异常引起的。有些异常是如此之大,以至于它们可能会使模型严重偏离正确方向。我们将在本章的一个即将到来的部分中解决这个问题。你将能够看到传递给 run_fn() 的 FnArgs 对象中的值:

INFO:absl:==================================================
INFO:absl:Printing the tfx.components.FnArgs object
INFO:absl:FnArgs(
    working_dir=None, 
    train_files=['.../Transform/transformed_examples/16/Split-train/*'], 
    eval_files=['.../Transform/transformed_examples/16/Split-eval/*'], 
    train_steps=100, 
    eval_steps=100, 
    schema_path='.../SchemaGen/schema/15/schema.pbtxt', 
    schema_file='.../SchemaGen/schema/15/schema.pbtxt', 
    transform_graph_path='.../Transform/transform_graph/16', 
    transform_output='.../Transform/transform_graph/16', 
    data_accessor=DataAccessor(
        tf_dataset_factory=<function 
➥ get_tf_dataset_factory_from_artifact.<locals>.dataset_factory at 
➥ 0x7f7a56329a60>, 
        record_batch_factory=<function 
➥ get_record_batch_factory_from_artifact.<locals>.record_batch_factory at 
➥ 0x7f7a563297b8>, 
        data_view_decode_fn=None
    ), 
    serving_model_dir='.../Trainer/model/17/Format-Serving', 
    eval_model_dir='.../Trainer/model/17/Format-TFMA', 
    model_run_dir='.../Trainer/model_run/17', 
    base_model=None, 
    hyperparameters=None, 
    custom_config=None
)
INFO:absl:==================================================

以下侧边栏讨论了我们在本讨论中的这一点上如何评估模型。

评估保存的模型

在流水线中,我们的模型将以 URL 形式通过 HTTP 接口提供服务。但是与其等待不如手动加载模型并用它来预测数据。这样做将为我们提供两个优势:

  • 验证模型是否按预期工作

  • 提供对模型输入和输出格式的深入理解

我们不会在本书中详细介绍这个问题,以保持我们讨论的重点在流水线上。但是,已在 tfv/15.1_MLOps_with_tensorflow.ipynb 笔记本中提供了代码,因此您可以进行实验。

接下来,我们将讨论如何检测数据中存在的异常并将其移除,以创建一个干净的数据集来训练我们的模型。

检测和移除异常值

我们的模型目前显示的验证损失约为 568。这里使用的损失是均方误差。我们已经看到,这意味着每个预测偏差 24 公顷(即 0.24 平方公里)。这不是一个可以忽略的问题。我们的数据中有很多异常值,这可能是我们看到如此大的误差边界的一个关键原因。以下图显示了我们早期创建的统计图。

15-08-unnumb

由 StatisticsGen 组件为数据生成的摘要统计图

您可以看到一些列严重偏斜。例如,特征 FFMC 具有最高的密度,约为 80-90,但范围为 18.7-96.2。

为了解决这个问题,我们将使用 tensorflow_data_validation(缩写为 tfdv)库。它提供了有用的功能,如 tfdv.validate_statistics(),可用于根据我们之前生成的数据模式验证数据,以及 tfdv.display_anomalies()函数,以列出异常样本。此外,我们可以编辑模式以修改异常值的标准。例如,要更改允许的 ISI 特征的最大值,您可以执行以下操作:

isi_feature = tfdv.get_feature(schema, 'ISI')
isi_feature.float_domain.max = 30.0

最后,您可以使用 tfdv.visualize_statistics()函数可视化原始数据与清理后的数据。最后,您可以使用 TFX 流水线中的 ExampleValidator 对象(mng.bz/XZxv)确保数据集中没有异常。

运行此操作后,您应该比以前得到更小的损失。例如,在这个实验中,平均观察到了约 150 的损失。这是之前错误的 75%减少。您可以在 tfv/15.1_MLOps_with_tensorflow.ipynb 笔记本中找到此代码。

接下来,我们将看一看一种名为 Docker 的技术,该技术用于在隔离且便携的环境中部署模型。我们将看看如何将我们的模型部署在所谓的 Docker 容器中。

练习 2

而不是使用 one-hot 编码来表示日和月的特征,并将它们追加到 categorical_columns 变量中,让我们假设你想使用嵌入来表示这些特征。您可以使用特征列 tf.feature_column.embedding_column 来完成这个任务。假设嵌入的维度是 32。你有存储在 _VOCAB_FEATURE_KEYS 中的特征名称(包括['day', 'month'])以及存储在 _MAX_CATEGORICAL_FEATURE_VALUES 中的维度(包括[7, 12])。

15.3 设置 Docker 以提供经过训练的模型

您已经开发了一个数据管道和一个强大的模型,可以根据天气数据预测森林火灾的严重程度。现在,您希望更进一步,通过在一台机器上部署模型并通过 REST API 提供更易访问的服务,这个过程也称为生产化机器学习模型。为了做到这一点,您首先要创建一个专门用于模型服务的隔离环境。您将使用的技术是 Docker。

注意在继续之前,确保您的计算机上已经安装了 Docker。要安装 Docker,请按照此指南:docs.docker.com/engine/install/ubuntu/

在 TFX 中,你可以将你的模型部署为一个容器,而这个容器是由 Docker 提供的。根据官方 Docker 网站的说法,Docker 容器是

软件的标准单元,它打包了代码和所有的依赖项,以便应用程序可以在一个计算环境中快速、可靠地运行,并在另一个计算环境中运行。

源:www.docker.com/resources/what-container

Docker 是一种容器技术,它可以帮助您在主机上隔离运行软件(或微服务)。在 Docker 中,您可以创建一个镜像,该镜像将使用各种规格(例如操作系统、库、依赖项)指示 Docker 需要在容器中以正确运行软件。然后,容器就是该镜像的运行时实例。这意味着您可以在一个计算机上创建一个容器,并且可以轻松地在另一台计算机上运行它(只要两台计算机上都安装了 Docker)。虚拟机(VMs)也试图实现类似的目标。有许多资源可以比较和对比 Docker 容器和虚拟机(例如,mng.bz/yvNB)。

如我们所说,要运行一个 Docker 容器,首先需要一个 Docker 镜像。Docker 有一个公共镜像注册表(称为 Docker Hub),位于 hub.docker.com/。我们正在寻找的 Docker 镜像是 TensorFlow serving 镜像。这个镜像已经安装了一切用于提供 TensorFlow 模型的服务,使用了 TensorFlow serving (github.com/tensorflow/serving),这是 TensorFlow 中的一个子库,可以围绕给定的模型创建一个 REST API,以便你可以发送 HTTP 请求来使用模型。你可以通过运行以下命令来下载这个镜像:

docker pull tensorflow/serving:2.6.3-gpu

让我们解析一下这条命令的结构。docker pull 是下载镜像的命令。tensorflow/serving 是镜像名称。Docker 镜像是有版本控制的,意味着每个 Docker 镜像都有一个版本标签(如果你没有提供的话,默认为最新版本)。2.6.3-gpu 是镜像的版本。这个镜像相当大,因为它支持 GPU 执行。如果你没有 GPU,你可以使用 docker pull tensorflow/serving:2.6.3,这个版本更轻量级。一旦命令成功执行,你就可以运行

docker images 

列出你下载的所有镜像。有了下载的镜像,你可以使用 docker run 命令来使用给定的镜像启动一个容器。docker run 命令非常灵活,带有许多可以设置和更改的参数。我们使用了其中的几个:

docker run \
  --rm \
  -it \
  --gpus all \
  -p 8501:8501 \
  --user $(id -u):$(id -g) \
  -v ${PWD}/tfx/forest-fires-pushed:/models/forest_fires_model \
  -e MODEL_NAME=forest_fires_model \
  tensorflow/serving:2.6.3-gpu

理解这里提供的参数是很重要的。通常,在 shell 环境中定义参数时,使用单划线前缀来表示单字符的参数(例如,-p),使用双划线前缀来表示更详细的参数(例如,--gpus):

  • --rm—容器是临时运行时,可以在服务运行后移除。--rm 意味着容器将在退出后被移除。

  • -it(简写形式为 -i 和 -t)—这意味着你可以进入容器,并在容器内部交互式地运行命令。

  • --gpus all—这告诉容器确保 GPU 设备(如果存在)在容器内可见。

  • -p—这将容器中的网络端口映射到主机。如果你想将某些服务(例如,用于提供模型的 API)暴露给外部,这一点很重要。例如,TensorFlow serving 默认运行在 8501 端口上。因此,我们将容器的 8501 端口映射到主机的 8501 端口。

  • --user \((id -u):\)(id -g)—这意味着命令将以与您在主机上登录的用户相同的用户身份运行。每个用户由用户 ID 标识,并分配给一个或多个组(由组 ID 标识)。您可以按照 --user <用户 ID>:<组 ID> 的语法传递用户和组。例如,您当前的用户 ID 可以通过命令 id -u 给出,而组则由 id -g 给出。默认情况下,容器以 root 用户(即通过 sudo 运行)运行命令,这可能会使您的服务更容易受到外部攻击。因此,我们使用较低特权的用户在容器中执行命令。

  • -v—这将一个目录挂载到容器内的位置。默认情况下,您在容器内存储的东西对外部是不可见的。这是因为容器有自己的存储空间/卷。如果您需要使容器看到主机上的某些内容,或者反之亦然,则需要将主机上的目录挂载到容器内的路径上。这被称为绑定挂载。例如,在这里,我们将我们推送的模型(将位于 ./tfx/forest-fires-pushed)暴露到容器内部路径 /models/forest_fires_model。

  • -e—此选项可用于将特殊环境变量传递给容器。例如,TensorFlow 服务服务期望一个模型名称(它将成为从模型获取结果所需命中的 URL 的一部分)。

此命令在 Ch15-TFX-for-MLOps-in-TF2 目录中的 tfx/run_server.sh 脚本中为您提供。让我们运行 run_server.sh 脚本,看看我们将得到什么。要运行脚本

  1. 打开一个终端。

  2. 将 cd 移动到 Ch15-TFX-for-MLOps-in-TF2/tfx 目录中。

  3. 运行 ./run_server.sh。

它将显示类似于以下的输出:

2.6.3-gpu: Pulling from tensorflow/serving
Digest: 
➥ sha256:e55c44c088f6b3896a8f66d8736f38b56a8c5687c105af65a58f2bfb0bf90812
Status: Image is up to date for tensorflow/serving:2.6.3-gpu
docker.io/tensorflow/serving:2.6.3-gpu
2021-07-16 05:59:37.786770: I
tensorflow_serving/model_servers/server.cc:88] Building single TensorFlow 
➥ model file config: model_name: forest_fires_model model_base_path: 
➥ /models/forest_fires_model
2021-07-16 05:59:37.786895: I
tensorflow_serving/model_servers/server_core.cc:464] Adding/updating 
➥ models.
2021-07-16 05:59:37.786915: I
tensorflow_serving/model_servers/server_core.cc:587]  (Re-)adding model: 
➥ forest_fires_model
2021-07-16 05:59:37.787498: W
tensorflow_serving/sources/storage_path/file_system_storage_path_source.cc:
➥ 267] No versions of servable forest_fires_model found under base path 
➥ /models/forest_fires_model. Did you forget to name your leaf directory 
➥ as a number (eg. '/1/')?
...

当然,这并不能完全奏效,因为我们提供的目录作为模型位置尚未被填充。我们仍然需要做一些事情,以便将最终模型放置在正确的位置。

在下一节中,我们将完成我们流水线的其余部分。我们将看到如何在流水线中自动评估新训练的模型,如果性能良好,则部署模型,并使用 REST API(即基于 Web 的 API)从模型进行预测。

练习 3

假设您想要下载 TensorFlow Docker 映像(其名称为 tensorflow/tensorflow),版本为 2.5.0,并启动一个容器,将您计算机上的 /tmp/inputs 目录挂载到容器内的 /data 卷中。此外,您希望将容器中的 5000 端口映射到计算机上的 5000 端口。您如何使用 Docker 命令执行此操作?您可以假设您在容器内以 root 用户身份运行命令。

15.4 部署模型并通过 API 进行服务

现在,你已经有了一个数据管道、训练好的模型以及一个可以运行包含了运行模型和访问模型的 API 所需的一切的脚本。现在,使用 TFX 提供的一些服务,你将在 Docker 容器中部署模型,并通过 API 进行访问。在这个过程中,你将运行一些步骤来验证基础结构(例如,容器是否可运行且健康)和模型(即在模型的新版本发布时,检查它是否比上一个版本更好),最后,如果一切都好,将模型部署到基础结构上。

这是一个漫长的旅程。让我们回顾一下我们到目前为止取得的成就。我们已经使用了以下 TFX 组件:

  • CsvExampleGen—从 CSV 文件中以 TFRecord 对象的形式加载数据。

  • StatisticsGen—关于 CSV 数据中各列分布的基本统计数据和可视化。

  • SchemaGen—生成数据的模式/模板(例如数据类型、域、允许的最小/最大值等)。

  • Transform—使用 tensorflow_transform 库中提供的操作(例如,独热编码、桶化)将原始列转换为特征。

  • Trainer—定义一个 Keras 模型,使用转换后的数据进行训练,并保存到磁盘。此模型具有一个名为 serving default 的签名,指示模型对于传入的请求应该执行什么操作。

  • ExampleValidator—用于验证训练和评估示例是否符合定义的模式,并可用于检测异常。

15.4.1 验证基础结构

使用 TFX,当你拥有一个完全自动化的管道时,几乎可以确保一切工作正常。我们将在这里讨论一个这样的步骤:基础结构验证步骤。在这个步骤中,tfx.components.InfraValidator 将自动进行

  • 使用提供的特定版本的 TensorFlow serving 镜像创建一个容器

  • 加载并在其中运行模型

  • 发送多个请求以确保模型能够响应

  • 关闭容器

让我们看一下如何使用这个组件来验证我们在前一节中设置的本地 Docker 配置(请参阅下一个清单)。

Listing 15.11 定义 InfraValidator

from tfx.components import InfraValidator
from tfx.proto import infra_validator_pb2

infra_validator = InfraValidator(
    model=trainer.outputs['model'],                                        ❶

    examples=example_gen.outputs['examples'],                              ❷
    serving_spec=infra_validator_pb2.ServingSpec(                          ❸
        tensorflow_serving=infra_validator_pb2.TensorFlowServing(          ❹
            tags=['2.6.3-gpu']
        ),
        local_docker=infra_validator_pb2.LocalDockerConfig(),              ❺
    ),
    request_spec=infra_validator_pb2.RequestSpec(                          ❻
        tensorflow_serving=infra_validator_pb2.TensorFlowServingRequestSpec(❼
            signature_names=['serving_default']
        ),
        num_examples=5                                                     ❽
    )
)

context.run(infra_validator)

❶ InfraValidator 需要验证的模型的位置。

❷ 用于构建对模型的 API 调用的数据来源

❸ 包含一组与对模型进行的具体调用相关的规范

❹ 定义要使用的 TensorFlow serving Docker Image 的版本/标签

❺ 告诉 InfraValidator 我们将使用本地的 Docker 服务进行测试

❻ 包含与对模型进行的特定调用相关的规范集合

❼ 定义要使用的模型签名

❽ 定义了向模型发送的请求数量

InfraValidator 和其他任何 TFX 组件一样,需要准确地提供多个参数才能运行。

  • model—由 Trainer 组件返回的 Keras 模型。

  • examples—由 CSVExampleGen 给出的原始示例。

  • serving_spec—期望一个 ServingSpec protobuf 消息。它将指定 TensorFlow serving Docker 镜像的版本以及是否使用本地 Docker 安装(这里已完成)。

  • request_spec—一个 RequestSpec protobuf 消息,它将指定需要达到的签名以验证模型是否正常工作。

如果此步骤无误完成,您将在管道根目录中看到图 15.9 中显示的文件。

15-09

图 15.9 运行 InfraValidator 后的目录/文件结构

您可以看到一个名为 INFRA_BLESSED 的文件出现在 InfraValidator 子目录中。这引出了“祝福”的概念。TFX 将在成功运行流水线中祝福某些元素。一旦被祝福,它将创建一个带有后缀 BLESSED 的文件。如果该步骤失败,那么将创建一个带有后缀 NOT_BLESSED 的文件。祝福有助于区分运行正常和运行失败的事物。例如,一旦被祝福,我们可以确信基础设施按预期工作。这意味着像

  • 架设一个容器

  • 加载模型

  • 达到定义的 API 端点

可以无问题地执行。

15.4.2 解析正确的模型

接下来,我们将定义一个解析器。解析器的目的是使用明确定义的策略(例如,使用最低验证错误的模型)解决随时间演变的特殊工件(如模型)。然后,解析器通知后续组件(例如,我们接下来将定义的模型评估器组件)要使用哪个工件版本。正如您可能已经猜到的那样,我们将使用解析器来解析管道中的经过训练的 Keras 模型。因此,如果您多次运行管道,则解析器将确保在下游组件中使用最新和最优秀的模型:

from tfx import v1 as tfx

model_resolver = tfx.dsl.Resolver(
      strategy_class=tfx.dsl.experimental.LatestBlessedModelStrategy,
      model=tfx.dsl.Channel(type=tfx.types.standard_artifacts.Model),
      model_blessing=tfx.dsl.Channel(
          type=tfx.types.standard_artifacts.ModelBlessing
      )
).with_id('latest_blessed_model_resolver')

context.run(model_resolver)

在定义验证模型的解析器时,我们将定义三件事:

  • strategy_class(来自 tfx.dsl.components.common.resolver.ResolverStrategy 命名空间的类)—定义解析策略。当前支持两种策略:最新的祝福模型(即通过一组定义的评估检查的模型)和最新的模型。

  • 模型(tfx.dsl.Channel)—将 TFX 工件类型的模型包装在一个 tfx.dsl.Channel 对象中。tfx.dsl.Channel 是一个 TFX 特定的抽象概念,连接数据消费者和数据生产者。例如,在管道中选择正确的模型时就需要一个通道,以从可用模型池中选择。

  • model_blessing(tfx.dsl.Channel)—将类型为 ModelBlessing 的 TFX 工件包装在 tfx.dsl.Channel 对象中。

你可以查看各种工件,将其包装在一个 tf.dsl.Channel 对象中,网址为mng.bz/2nQX

15.4.3 评估模型

我们将在将模型推送到指定的生产环境之前的最后一步对模型进行评估。基本上,我们将定义几个模型需要通过的评估检查。当模型通过时,TFX 将对模型进行认可。否则,TFX 将使模型保持未认可状态。我们将在后面学习如何检查模型是否被认可。为了定义评估检查,我们将使用 tensorflow_model_analysis 库。第一步是定义一个评估配置,其中指定了检查项:

import tensorflow_model_analysis as tfma

eval_config = tfma.EvalConfig(
    model_specs=[
        tfma.ModelSpec(label_key='area')                                  ❶
    ],
    metrics_specs=[
        tfma.MetricsSpec(
            metrics=[                                                     ❷
                tfma.MetricConfig(class_name='ExampleCount'),             ❸
                tfma.MetricConfig(
                    class_name='MeanSquaredError',                        ❹
                    threshold=tfma.MetricThreshold(                       ❺
                       value_threshold=tfma.GenericValueThreshold(
                           upper_bound={'value': 200.0}
                       ),
                       change_threshold=tfma.GenericChangeThreshold(      ❻
                           direction=tfma.MetricDirection.LOWER_IS_BETTER,
                           absolute={'value': 1e-10}
                       )
                   )
               )
           ]
        )
    ],
    slicing_specs=[                                                       ❼
        tfma.SlicingSpec(),                                               ❽
        tfma.SlicingSpec(feature_keys=['month'])                          ❾
    ])

❶ 定义一个包含标签特征名称的模型规范。

❷ 定义一个指标规范列表。

❸ 获取评估的示例数。

❹ 将均方误差定义为一项指标。

❺ 将阈值上限定义为一个检查。

❻ 将误差变化(与先前模型相比)定义为一个检查(即,误差越低越好)。

❼ 切片规范定义了在评估时数据需要如何分区。

❽ 在整个数据集上进行评估,不进行切片(即,空切片)。

❾ 在分区数据上进行评估,其中数据根据月份字段进行分区。

EvalConfig 相当复杂。让我们慢慢来。我们必须定义三件事:模型规范(作为 ModelSpec 对象)、指标规范(作为 MetricsSpec 对象列表)和切片规范(作为 SlicingSpec 对象列表)。ModelSpec 对象可用于定义以下内容:

  • name—可用于在此步骤中标识模型的别名模型名称。

  • model_type—标识模型类型的字符串。允许的值包括 tf_keras、tf_estimator、tf_lite 和 tf_js、tf_generic。对于像我们的 Keras 模型,类型会自动推导。

  • signature_name—用于推断的模型签名。默认情况下使用 serving_default。

  • label_key—示例中标签特征的名称。

  • label_keys—对于多输出模型,使用标签键列表。

  • example_weight_key—如果存在,则用于检索示例权重的可选键(或特征名称)。

有关 ModelSpec 对象的更多信息,请参阅mng.bz/M5wW。在 MetricsSpec 对象中,可以设置以下属性:

  • metrics—MetricConfig 对象的列表。每个 MetricConfig 对象将类名作为输入。您可以选择在 tfma.metrics.Metric(mng.bz/aJ97)或 tf.keras.metrics.Metric(mng.bz/gwmV)命名空间中定义的任何类。

SlicingSpec 定义了评估期间数据需要如何进行分区。例如,对于时间序列问题,您需要查看模型在不同月份或天数上的表现。为此,SlicingSpec 是一个方便的配置。SlicingSpec 具有以下参数:

  • feature_keys—可用于定义一个特征键,以便您可以根据其对数据进行分区。例如,对于特征键月份,它将通过选择具有特定月份的数据来为每个月份创建一个数据分区。如果未传递,它将返回整个数据集。

注意,如果没有提供,TFX 将使用您在管道最开始(即实施 CsvExampleGen 组件时)定义的评估集合。换句话说,所有指标都在数据集的评估集合上进行评估。接下来,它定义了两个评估通过的条件:

  • 均方误差小于 200。

  • 均方损失改善了 1e - 10。

如果对于一个新训练的模型满足下列两个条件,那么该模型将被标记为“通过”(即通过了测试)。

最后,我们定义了评估器(mng.bz/e7BQ),它将接收一个模型并运行在 eval_config 中定义的评估检查。您可以通过为 examples、model、baseline_model 和 eval_config 参数传入值来定义一个 TFX 评估器。baseline_model 是由 Resolver 解析的:

from tfx.components import Evaluator

evaluator = Evaluator(
    examples=example_gen.outputs['examples'],
    model=trainer.outputs['model'],
    baseline_model=model_resolver.outputs['model'],
    eval_config=eval_config)
context.run(evaluator)

不幸的是,运行评估器不会提供您所需的结果。事实上,它会导致评估失败。在日志的底部,您将看到如下输出:

INFO:absl:Evaluation complete. Results written to 
➥ .../pipeline/examples/forest_fires_pipeline/Evaluator/evaluation/14.
INFO:absl:Checking validation results.
INFO:absl:Blessing result False written to 
➥ .../pipeline/examples/forest_fires_pipeline/Evaluator/blessing/14.

上面的输出表明,Blessing 结果为 False。鉴于它只显示了约为 150 的损失,而我们将阈值设为 200,为什么模型失败仍然是一个谜。要了解发生了什么,我们需要查看写入磁盘的结果。如果您在/ examples\forest_fires_pipeline\Evaluator<execution ID>目录中查看,您会看到像 validation、metrics 等文件。使用 tensorflow_model_analysis 库,这些文件可以提供宝贵的见解,帮助我们理解出了什么问题。tensorflow_model_analysis 库提供了几个方便的函数来加载存储在这些文件中的结果:

import tensorflow_model_analysis as tfma

validation_path = os.path.join(
    evaluator.outputs['evaluation']._artifacts[0].uri, "validations"
)
validation_res = tfma.load_validation_result(validation_path)

print('='*20, " Output stored in validations file ", '='*20)
print(validation_res)
print("="*75)

运行结果为:

metric_validations_per_slice {
  slice_key {
    single_slice_keys {
      column: "month"
      bytes_value: "sep"
    }
  }
  failures {
    metric_key {
      name: "mean_squared_error"
    }
    metric_threshold {
      value_threshold {
        upper_bound {
          value: 200.0
        }
      }
    }
    metric_value {
      double_value {
        value: 269.11712646484375
      }
    }
  }
}
validation_details {
  slicing_details {
    slicing_spec {
    }
    num_matching_slices: 12
  }
}

您可以清楚地看到发生了什么。它指出,为月份"sep"创建的切片导致了 269 的错误,这就是为什么我们的评估失败了。如果您想要关于所有使用的切片及其结果的详细信息,您可以检查指标文件:

metrics_path = os.path.join(
    evaluator.outputs['evaluation']._artifacts[0].uri, "metrics"
)
metrics_res = tfma.load_metrics(metrics_path)

print('='*20, " Output stored in metrics file ", '='*20)
for r in metrics_res:
    print(r)
    print('-'*75)
print("="*75)

运行结果为以下内容。为了节省空间,这里只显示了完整输出的一小部分:

slice_key {
  single_slice_keys {
    column: "month"
    bytes_value: "sep"
  }
}
metric_keys_and_values {
  key {
    name: "loss"
  }
  value {
    double_value {
      value: 269.11712646484375
    }
  }
}
metric_keys_and_values {
  key {
    name: "mean_squared_error"
  }
  value {
    double_value {
      value: 269.11712646484375
    }
  }
}
metric_keys_and_values {
  key {
    name: "example_count"
  }
  value {
    double_value {
      value: 52.0
    }
  }
}

---------------------------------------------------------------------------
slice_key {
}
metric_keys_and_values {
  key {
    name: "loss"
  }
  value {
    double_value {
      value: 160.19691467285156
    }
  }
}
metric_keys_and_values {
  key {
    name: "mean_squared_error"
  }
  value {
    double_value {
      value: 160.19691467285156
    }
  }
}
metric_keys_and_values {
  key {
    name: "example_count"
  }
  value {
    double_value {
      value: 153.0
    }
  }
}
...

这个输出让我们对发生了什么有了更多了解。由于我们将示例计数视为指标之一,我们可以看到每个切片中的示例数。例如,在五月份,评估集合中只有一个示例,这很可能是一个异常值。为了解决这个问题,我们将阈值提高到 300。一旦您这样做了,需要重新运行评估器,从评估器的日志中可以看到我们的模型已通过检查:

INFO:absl:Evaluation complete. Results written to 
➥ .../pipeline/examples/forest_fires_pipeline/Evaluator/evaluation/15.
INFO:absl:Checking validation results.
INFO:absl:Blessing result True written to 
➥ .../pipeline/examples/forest_fires_pipeline/Evaluator/blessing/15.

解决这个问题的最佳方法是确定为什么“sep” 月份给出的值如此之大,而其他月份与整体损失值持平或低于。确定问题后,我们应该确定纠正措施以更正此问题(例如,重新考虑异常值定义)。在此之后,我们将继续进行管道的下一部分。

15.4.4 推送最终模型

我们已经达到管道中的最后步骤。我们需要定义一个推送器。 推送器(mng.bz/pOZz)负责将经过评估检查的认可模型(即通过的模型)推送到定义好的生产环境。 生产环境可以简单地是您文件系统中的本地位置:

from tfx.components import Pusher
from tfx.proto import pusher_pb2

pusher = Pusher(
  model=trainer.outputs['model'],
  model_blessing=evaluator.outputs['blessing'],
  infra_blessing=infra_validator.outputs['blessing'],
  push_destination=pusher_pb2.PushDestination(
    filesystem=pusher_pb2.PushDestination.Filesystem(
        base_directory=os.path.join('forestfires-model-pushed'))
  )
)
context.run(pusher)

推送器接受以下元素作为参数:

  • model—由 Trainer 组件返回的 Keras 模型

  • model_blessing—Evaluator 组件的认可状态

  • infra_blessing—InfraValidator 的认可状态

  • push_destination—作为 PushDestination protobuf 消息推送的目标

如果这一步运行成功,您将在我们的管道根目录中的称为 forestfires-model-pushed 的目录中保存模型。

15.4.5 使用 TensorFlow serving API 进行预测

最后一步是从推送目的地检索模型,并基于我们下载的 TensorFlow 服务镜像启动 Docker 容器。 Docker 容器将提供一个 API,我们可以通过各种请求进行 ping 。

让我们更详细地看一下如何将 API 融入整体架构中(图 15.10)。 机器学习模型位于 API 的后面。 API 定义了各种 HTTP 端点,您可以通过 Python 或类似 curl 的包来 ping 这些端点。 这些端点将以 URL 的形式提供,并且可以在 URL 中期望参数或将数据嵌入请求体中。 API 通过服务器提供。 服务器公开了一个网络端口,客户端可以与服务器进行通信。 客户端可以使用格式<主机名>:<端口>/<端点>向服务器发送请求。 我们将更详细地讨论请求实际的样子。

15-10

图 15.10 模型如何与 API、TensorFlow 服务器和客户端交互

要启动容器,只需

  1. 打开一个终端

  2. 将 cd 移入 Ch15-TFX-for-MLOps-in-TF2/tfx 目录

  3. 运行 ./run_server.sh

接下来,在 Jupyter 笔记本中,我们将发送一个 HTTP POST 请求。 HTTP 请求有两种主要类型:GET 和 POST。 如果您对差异感兴趣,请参考侧边栏。 HTTP POST 请求是一个包含了可以请求的 URL 和头信息的请求,也包含了负载,这对 API 完成请求是必要的。 例如,如果我们正在击中与 serving_default 签名对应的 API 端点,我们必须发送一个输入以进行预测。

GET vs. POST 请求

GET 和 POST 是 HTTP 方法。HTTP 是一个定义客户端和服务器应该如何通信的协议。客户端将发送请求,服务器将在特定的网络端口上监听请求。客户端和服务器不一定需要是两台单独的机器。在我们的情况下,客户端和服务器都在同一台机器上。

每当您通过键入 URL 访问网站时,您都在向该特定网站发出请求。一个请求具有以下解剖结构(mng.bz/OowE):

  • 方法类型 — GET 或 POST

  • 一个路径 — 到达您想要到达的服务器端点的 URL

  • 一个主体 — 需要客户端完成请求的任何大型有效载荷(例如,用于机器学习预测服务的输入)

  • 一个头部 — 服务器需要的附加信息(例如,发送主体中的数据类型)

主要区别在于 GET 用于请求数据,而 POST 请求用于将数据发送到服务器(可以选择返回某些内容)。GET 请求不会有请求主体,而 POST 请求会有请求主体。另一个区别是 GET 请求可以被缓存,而 POST 请求不会被缓存,这使得它们对于敏感数据更安全。您可以在mng.bz/YGZA中了解更多信息。

我们将定义一个请求主体,其中包含我们要击中的签名名称以及我们要为其预测的输入。接下来,我们将使用 Python 中的 requests 库发送一个请求到我们的 TensorFlow 模型服务器(即 Docker 容器)。在此请求中,我们将定义要到达的 URL(由 TensorFlow 模型服务器自动生成)和要携带的有效载荷。如果请求成功,我们应该会得到一个有效的预测作为输出:

import base64
import json
import requests

req_body = {
  "signature_name": "serving_default",

  "instances": 
    [
            str(base64.b64encode(
                b"{\"X\": 7,\"Y\": 
➥ 4,\"month\":\"oct\",\"day\":\"fri\",\"FFMC\":60,\"DMC\":30,\"DC\":200,\
➥ "ISI\":9,\"temp\":30,\"RH\":50,\"wind\":10,\"rain\":0}]")
               )
    ]

}

data = json.dumps(req_body)

json_response = requests.post(
    'http:/ /localhost:8501/v1/models/forest_fires_model:predict', 
    data=data, 
    headers={"content-type": "application/json"}
)
predictions = json.loads(json_response.text)

我们首先要做的是用特定的请求主体定义一个请求。对请求主体的要求在www.tensorflow.org/tfx/serving/api_rest中定义。它是一个键值对字典,应该有两个键:signature_name 和 instances。signature_name 定义要在模型中调用哪个签名,而 instances 将包含输入数据。请注意,我们不是直接传递原始形式的输入数据。相反,我们使用 base64 编码。它将字节流(即二进制输入)编码为 ASCII 文本字符串。您可以在mng.bz/1o4g中了解更多信息。您可以看到我们首先将字典转换为字节流(即 b"" 格式),然后在其上使用 base64 编码。如果您还记得我们之前讨论的编写模型服务函数(其中包含 signature def serve_tf_examples_fn(serialized_tf_examples))时,它期望一组序列化的示例。序列化是通过将数据转换为字节流来完成的。

当数据准备好后,我们使用 requests 库创建一个 POST 请求到 API。首先,我们定义一个头部,以表示我们传递的内容或载荷是 JSON 格式的。接下来,我们通过 requests.post() 方法发送一个 POST 请求,传递 URL,格式为 <server’s hostname>:<port>/v1/models/<model name>:predict,数据(即 JSON 载荷),和头部信息。这不是我们唯一可以使用的 API 端点。我们还有其他端点(www.tensorflow.org/tfx/serving/api_rest)。主要有四个可用的端点:

  • http:/ /<server’s hostname>:/v1/models/:predict — 使用模型和请求中传递的数据预测输出值。不需要提供给定输入的目标值。

  • http:/ /<server’s hostname>:/v1/models/:regress — 用于回归问题。当输入和目标值都可用时使用(即可以计算误差)。

  • http:/ /<server’s hostname>:/v1/models/:classify — 用于分类问题。当输入和目标值都可用时使用(即可以计算误差)。

  • http:/ /<server’s hostname>:/v1/models//metadata — 提供有关可用端点/模型签名的元数据。

这将返回一些响应。如果请求成功,将包含响应;否则,会包含 HTTP 错误。您可以在 mng.bz/Pn2P 上看到各种 HTTP 状态/错误代码。在我们的情况下,我们应该得到类似于

{'predictions': [[2.77522683]]}

这意味着我们的模型已成功处理了输入并产生了有效的预测。我们可以看到,模型返回的预测值完全在我们在数据探索期间看到的可能值范围内。这结束了我们对 TensorFlow 扩展(TFX)的讨论。

练习 4

如何将多个输入发送到模型的 HTTP 请求中?假设您有以下两个输入,您想要使用模型进行预测。

Example 1 Example 2
X 9 7
Y 6 4
month aug aug
day fri fri
FFMC 91 91
DMC 248 248
DC 553 553
ISI 6 6
temp 20.5 20.5
RH 58 20
wind 3 0
rain 0 0

要在 HTTP 请求中为该输入传递多个值,可以在 JSON 数据的实例列表中附加更多示例。

摘要

  • MLOps 定义了一个工作流程,将自动化大部分步骤,从收集数据到交付对该数据进行训练的模型。

  • 生产部署涉及部署一个带有健壮 API 的训练模型,使客户能够使用模型进行其设计目的的操作。该 API 提供几个 HTTP 端点,格式为客户端可以使用与服务器通信的 URL。

  • 在 TFX 中,您将 MLOps 管道定义为一系列 TFX 组件。

  • TFX 有组件用于加载数据(CsvExampleGen)、生成基本统计信息和可视化(StatisticsGen)、推断模式(SchemaGen)以及将原始列转换为特征(Transform)。

  • 要通过 HTTP 请求提供 Keras 模型,需要签名。

    • 签名定义输入和输出的数据格式,以及通过 TensorFlow 函数(例如,用@tf.function 装饰的函数)生成输出所需的步骤。
  • Docker 是一种容器化技术,可以将一个软件单元封装为一个单一容器,并可以在不同环境(或计算机)之间轻松移植。

  • Docker 在容器中运行一个软件单元。

  • TFX 为验证基础设施和模型提供了验证组件。TFX 可以启动一个容器并确保它按预期运行,还可以确保模型通过各种评估标准(例如,损失小于阈值),从而确保高质量的模型。

  • 一旦模型被推送到生产环境,我们会启动一个 Docker 容器(基于 TensorFlow 服务镜像),将模型装入容器并通过 API 提供服务。我们可以发出 HTTP 请求(嵌入输入),以生成预测。

练习答案

练习 1

  outputs = {}

  # Treating dense features
  outputs[_transformed_name('DC')] = tft.scale_to_0_1(
        sparse_to_dense(inputs['DC'])
    )

  # Treating bucketized features
  outputs[_transformed_name('temp')] = tft.apply_buckets(
        sparse_to_dense(inputs['temp']), bucket_boundaries=[(20, 30)])

练习 2

categorical_columns = [
      tf.feature_column.embedding_column(
          tf.feature_column.categorical_column_with_identity( 
              key,
              num_buckets=num_buckets,
              default_value=0
          ),
          dimension=32
      ) for key, num_buckets in zip(
              _transformed_names(_VOCAB_FEATURE_KEYS),
              _MAX_CATEGORICAL_FEATURE_VALUES
      )

练习 3

docker run -v /tmp/inputs:/data -p 5000:5000 tensorflow/tensorflow:2.5.0

练习 4

req_body = {
  "signature_name": "serving_default",

  "instances": 
    [
        str(base64.b64encode(
            b"{\"X\": 9,\"Y\": 
➥ 6,\"month\":\"aug\",\"day\":\"fri\",\"FFMC\":91,\"DMC\":248,\"DC\":553,
➥ \"ISI\":6,\"temp\":20.5,\"RH\":58,\"wind\":3,\"rain\":0}]")
        ),
        str(base64.b64encode(
            b"{\"X\": 7,\"Y\": 
➥ 4,\"month\":\"aug\",\"day\":\"fri\",\"FFMC\":91,\"DMC\":248,\"DC\":553,
➥ \"ISI\":6,\"temp\":20.5,\"RH\":20,\"wind\":0,\"rain\":0}]")
        ),

    ]

}

附录 A:设置环境

在这个附录中,您将配置计算机上的开发和运行时环境。提供了两个安装过程:一个用于基于 Unix 的环境,另一个用于 Windows 环境。请注意,我们将把 Unix 环境的讨论主要集中在 Ubuntu 上,而不是 MacOS 上。这是因为,对于机器学习和深度学习来说,Ubuntu 比 MacOS 更受欢迎,也得到了更好的支持。但是,我们将列出在 MacOS 上运行此项所需的资源。

A.1 在基于 Unix 的环境中

我们的讨论将分为三个部分。在第一部分中,我们将讨论设置虚拟 Python 环境以安装运行代码所需的库的步骤。接下来,我们将讨论需要 GPU 支持的事项。最后,我们将讨论在 MacOS 上执行相同操作的情况。

A.1.1 使用 Anaconda 发行版创建虚拟 Python 环境(Ubuntu)

在本节中,我们将讨论在 Ubuntu 中设置 conda 环境(通过 Anaconda 软件包创建的虚拟 Python 环境的术语)的步骤:

  1. 在 Linux 系统上安装 Anaconda(docs.anaconda.com/anaconda/install/linux/)。

  2. 打开终端并用您喜欢的文本编辑器打开 ~/.bashrc 文件(例如,对于 vim,请键入 vim ~/.bashrc)。

  3. 将以下行添加到文件末尾(带有您路径填充的占位符):

    if ! [[ "$PATH" == *"anaconda3"* ]]; then
      export PATH=${PATH}:<your anaconda3 installation path>/bin
    fi
    
  4. 保存并关闭编辑器。

  5. 打开一个新的命令行终端。

  6. 运行 conda create -n manning.tf2 python=3.9 设置一个新的 conda 虚拟环境。

  7. (推荐)在您的主目录中创建一个名为 code 的文件夹,您将在其中存储本地代码,并使用 cd~/code 进入该文件夹。

  8. 使用 git clone https://github.com/thushv89/manning_tf2_in_action.git 克隆托管在 Github 上的代码存储库。确保您的操作系统上已安装了 Git。

  9. 使用 cd manning_tf2_in_action 进入克隆的代码存储库。

  10. 使用以下命令激活环境

    1. Anaconda < 4.4:source activate manning.tf2

    2. Anaconda >= 4.4:conda activate manning.tf2

  11. 使用 pip install -r requirements.txt 安装所需的库。

A.1.2 GPU 支持的先决条件(Ubuntu)

安装 NVIDIA 驱动程序

确保您已安装了适用于您的 GPU 的最新 NVIDIA 图形驱动程序。您可以在 mng.bz/xnKe 找到驱动程序安装程序。如果您没有安装最新驱动程序,可能会在随后的步骤中遇到获取 TensorFlow GPU 支持的问题。

安装 CUDA

在本节中,我们将安装 CUDA 11.2,因为我们使用的 TensorFlow 版本高于 2.5.0。但是,您需要选择适合您 TensorFlow 版本的正确 CUDA 版本,如在 www.tensorflow.org/install/source#gpu 中指定的。最新 TensorFlow 版本的 CUDA 版本列在表 A.1 中。

表 A.1 最新 TensorFlow 版本支持的 CUDA 版本

TensorFlow 版本 CUDA 版本
2.4.x 11.0
2.8.x 11.2
2.9.x 11.2

要安装所需的 CUDA 版本,请按照以下步骤操作:

  1. 转到developer.nvidia.com/cuda-toolkit-archive页面。这将显示您可以下载的所有 CUDA 版本。

  2. 点击所需的 CUDA 版本,您将看到类似于图 A.1 的页面。例如,图 A.1 描绘了用于 Ubuntu 发行版下载 CUDA 版本 11.7 的选项。

  3. 确保您对下载的文件拥有执行权限(例如,在 Ubuntu 上,您可以通过终端运行 chmod a+x <路径到下载的文件>来提供执行权限)。

  4. 通过命令行终端打开下载的软件包进行安装(例如,在 Ubuntu 上,只需转到下载目录并使用./<文件名>运行安装)。

A-1

图 A.1 CUDA 下载页面(Ubuntu 安装)

安装完成后,需要将安装路径添加到特殊的环境变量中:

  1. 打开终端,使用您喜欢的文本编辑器(例如,对于 vim,请键入 vim/.bashrc)打开/.bashrc 文件。

  2. 将以下行添加到文件末尾。例如,路径可能类似于/usr/local/cuda-11.0:

    if ! [[ "$PATH" == *"cuda"* ]]; then
      export PATH=${PATH}:<path to CUDA>/bin
    fi
    
    export LD_LIBRARY_PATH=<path to CUDA>/lib64
    
  3. 保存并关闭编辑器。

安装 CuDNN

与 CUDA 类似,需要仔细选择 cuDNN 版本。表 A.2 列出了最新 TensorFlow 版本支持的 cuDNN 版本。要获取完整列表,请访问www.tensorflow.org/install/source#gpu

表 A.2 最新 TensorFlow 版本支持的 cuDNN 版本

TensorFlow 版本 cuDNN 版本
2.4.x 8.0
2.6.x 8.1
2.9.x 8.1

首先,按照developer.nvidia.com/cudnn上的说明和提示下载首选的 cuDNN 软件包。要安装 cuDNN,请按照mng.bz/AyQK提供的指南进行操作。

A.1.3 MacOS 注意事项

不幸的是,由于 NVIDIA 不认为 CUDA 是 CUDA 相关开发工作的主要开发环境,因此 CUDA 不再得到积极支持(mng.bz/ZAlO)。您仍然可以安装 Anaconda、创建虚拟环境并安装 TensorFlow 来进行开发工作。但是,您可能无法在 NVIDIA GPU 上运行执行 CUDA 实现的任何 TensorFlow 计算。

在 MacOS 上安装 Anaconda,请按照docs.anaconda.com/anaconda/install/mac-os/ 提供的指南进行操作。管理 conda 环境的指南在mng.bz/R4V0中提供。

A.2 在 Windows 环境中

在本节中,我们将讨论如何在 Windows 上安装虚拟环境,并确保 GPU 支持。

A.2.1 创建一个虚拟 Python 环境(Anaconda)

此节讨论了在 Windows 主机上创建 conda 环境的步骤:

  1. 在 Windows 系统上安装 Anaconda(docs.anaconda.com/anaconda/install/linux/),这也将安装一个用于执行 Anaconda 特定命令的 CLI(命令行接口)。

  2. 在开始菜单的搜索栏中输入 Anaconda Prompt,打开 Anaconda Prompt(如图 A.2 所示)。

  3. 在终端中运行 conda create -n manning.tf2 python=3.9 以设置 conda 虚拟环境。

  4. (建议)在您的主文件夹(例如,C:\Users<username>\Documents)中创建一个名为 code 的文件夹,在其中我们将本地存储代码,并使用 cd C:\Users<username>\Documents 进入该文件夹。

  5. 如果尚未安装,请为 Windows 安装 Git(例如,git-scm.com/download/win)。

  6. 使用 git clone https://github.com/thushv89/manning_tf2_in_action.git 克隆托管在 Github 上的代码库。

  7. 使用 cd manning_tf2_in_action 进入克隆代码库。

  8. 使用 conda activate manning.tf2 激活环境。

  9. 使用 pip install -r requirements.txt 安装所需的库。

A-2

图 A.2:在 Windows 上打开 Anaconda Prompt

A.2.2 GPU 支持的先决条件

在本节中,我们将讨论确保 GPU 被识别并正常工作的几个先决条件。

安装 NVIDIA 驱动程序

确保您已为您的 GPU 安装了最新的 NVIDIA 图形驱动程序。您可以在mng.bz/xnKe找到驱动程序安装程序。如果您不安装最新的驱动程序,您可能会在获取 TensorFlow 的 GPU 支持的后续步骤中遇到问题。

安装 CUDA

在本节中,我们将安装 CUDA 11.2,因为我们使用的是高于 2.5.0 版本的 TensorFlow。但是,您需要选择适合您 TensorFlow 版本的正确 CUDA 版本,如www.tensorflow.org/install/source#gpu中所述。

要安装所需的 CUDA 版本,请完成以下步骤:

  1. 转到developer.nvidia.com/cuda-toolkit-archive页面。这将显示您可以下载的所有 CUDA 版本。

  2. 通过单击所需的 CUDA 版本,进入页面如图 A.3 所示。例如,图 A.3 描述了选择 Windows 操作系统获取 CUDA 11.7 的选项。

  3. 以管理员身份运行下载的 .exe 文件,并按照提示进行操作。

A-3

图 A.3:CUDA 下载页面(Windows 安装)

安装完成后,需要将安装路径添加到特殊环境变量中:

  1. 通过从开始菜单中选择“编辑系统环境变量”来打开“环境变量”窗口(图 A.4)。

  2. 根据表 A.3 中的说明,将以下路径添加到路径变量中。 图 A.5 显示了如何在 Windows 上添加/修改环境变量。

A-4

图 A.4 打开系统属性窗口

表 A.3 需要添加和修改的路径变量

PATH \bin
CUDA_PATH

A-5

图 A.5 添加/修改路径变量的步骤

安装 CuDNN

与 CUDA 类似,需要仔细选择 cuDNN 版本。 表 A.4 列出了最新 TensorFlow 版本支持的 cuDNN 版本。 要获取完整列表,请访问www.tensorflow.org/install/source#gpu

表 A.4 最新 TensorFlow 版本支持的 cuDNN 版本

TensorFlow version cuDNN version
2.4.x 8.1
2.5.x 8.1
2.6.x 8.0

首先,按照developer.nvidia.com/cudnn上的说明和提示下载首选的 cuDNN 软件包。 要安装 cuDNN,请按照mng.bz/AyQK上提供的说明操作。

A.3 激活和停用 conda 环境

一旦 conda 环境被创建,完成以下步骤来激活或停用环境。

在 Windows 上(通过 Anaconda Prompt)(图 A.6)

  1. 运行 conda activate 以激活环境。

  2. 运行 conda deactivate 以停用当前活动环境。

A-6

图 A.6 激活 conda 环境

在 Ubuntu 上(通过终端)

  1. 运行 source activate (Anaconda < 4.4)或 conda activate (Anaconda >= 4.4)以激活环境。

  2. 运行 conda deactivate 以停用当前活动环境。

A.4 运行 Jupyter Notebook 服务器并创建笔记本

我们将使用 Jupyter Notebook 服务器编写代码并执行它。 具体来说,我们将启动 Jupyter Notebook 服务器,它将为您提供一个仪表板(网页)以创建 Jupyter Notebook。 Jupyter Notebook 是一个交互式 Python 运行时环境。 这意味着您可以在 Jupyter Notebooks 中编写代码,并根据需要运行不同的代码片段。 这是因为代码可以分隔成所谓的notebook cells。 让我们看看如何启动 Jupyter Notebook 服务器并开始编码:

  1. 打开命令行终端(例如,Ubuntu 终端或 Windows Anaconda Prompt),并激活虚拟环境 manning.tf2(如果尚未激活)。

  2. 在 CLI 中使用 cd 命令进入您下载代码的目录(例如,cd C:\Users<user>\Documents\code\manning_tf2_in_action)。

  3. 在 CLI 中运行命令 jupyter notebook。

  4. 这将在您默认的浏览器上打开 Jupyter Notebook 服务器的首页。

  5. 现在,您可以在该目录中浏览文件夹结构,打开任何笔记本,并运行它(图 A.7)。

  6. 一旦打开了一个笔记本,您就可以进行各种操作,如创建代码单元格、运行代码单元格等(图 A.8)。

A-7

图 A.7 Jupyter Notebook 服务器创建的首页

A-8

图 A.8 Jupyter Notebook 概览

A.5 杂项注释

为了让 TensorFlow/Keras 提供的绘图功能正常工作,您安装了一个名为 graphviz 的 Python 包。您可能需要将该库的路径(例如,如果您使用了 Anaconda 安装,则为 \envs\manning.tf2\Library\bin\graphviz)添加到操作系统的 PATH 变量中。

附录 B:计算机视觉

B.1 Grad-CAM:解释计算机视觉模型

Grad-CAM(代表梯度类激活映射)在第七章介绍过,是由 Ramprasaath R. Selvaraju 等人在“Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization”(arxiv.org/pdf/1610.02391.pdf)中介绍的一种深度神经网络模型解释技术。深度网络以其难以解释的特性而臭名昭著,因此被称为黑盒子。因此,我们必须进行一些分析,并确保模型按预期工作。

让我们在第七章实现的模型上刷新一下记忆:一个名为 InceptionResNet v2 的预训练模型,其顶部是一个具有 200 个节点的 softmax 分类器(即我们的图像分类数据集 TinyImageNet 中的类别数量相同;请参阅下面的列表)。

清单 B.1 我们在第七章定义的 InceptionResNet v2 模型

import tensorflow as tf
import tensorflow.keras.backend as K
from tensorflow.keras.applications import InceptionResNetV2
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Input, Dense, Dropout

K.clear_session()

def get_inception_resnet_v2_pretrained():
    model = Sequential([                                       ❶
        Input(shape=(224,224,3)),                              ❷
        InceptionResNetV2(include_top=False, pooling='avg'),   ❸
        Dropout(0.4),                                          ❹
        Dense(200, activation='softmax')                       ❺
    ])
    loss = tf.keras.losses.CategoricalCrossentropy()
    adam = tf.keras.optimizers.Adam(learning_rate=0.0001)
    model.compile(loss=loss, optimizer=adam, metrics=['accuracy'])
    return model 

model = get_inception_resnet_v2_pretrained()
model.summary()

❶ 使用 Sequential API 定义一个模型。

❷ 定义一个输入层来接收大小为 224 × 224 × 3 的图像批次。

❸ 下载并使用预训练的 InceptionResNetV2 模型(不包括内置分类器)。

❹ 添加一个 dropout 层。

❺ 添加一个具有 200 个节点的新分类器层。

如果你打印此模型的摘要,你将得到以下输出:

Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
inception_resnet_v2 (Model)  (None, 1536)              54336736  
_________________________________________________________________
dropout (Dropout)            (None, 1536)              0         
_________________________________________________________________
dense (Dense)                (None, 200)               307400    
=================================================================
Total params: 54,644,136
Trainable params: 54,583,592
Non-trainable params: 60,544
_________________________________________________________________

如您所见,InceptionResNet v2 模型被视为我们模型中的单个层。换句话说,它是一个嵌套模型,其中外层模型(sequential)有一个内层模型(inception_resnet_v2)。但是我们需要更多的透明度,因为我们将要访问 inception_resnet_v2 模型内的特定层,以实现 Grad-CAM。因此,我们将“解开”或移除此嵌套,并且只描述模型的层。我们可以使用以下代码实现这一点:

K.clear_session()

model = load_model(os.path.join('models','inception_resnet_v2.h5'))

def unwrap_model(model):
    inception = model.get_layer('inception_resnet_v2')
    inp = inception.input
    out = model.get_layer('dropout')(inception.output)
    out = model.get_layer('dense')(out)
    return Model(inp, out)   

unwrapped_model = unwrap_model(model)

unwrapped_model.summary()

实质上我们正在做的是取现有模型并略微更改其输入。在取得现有模型后,我们将输入更改为 inception_resnet_v2 模型的输入层。然后,我们定义一个新模型(本质上使用与旧模型相同的参数)。然后你将看到以下输出。没有更多的模型在模型内部:

Model: "model"
___________________________________________________________________________
➥ ________________
Layer (type)                    Output Shape         Param #     Connected 
➥ to                     
===========================================================================
➥ ================
input_2 (InputLayer)            [(None, None, None,  0                                            
___________________________________________________________________________
➥ ________________
conv2d (Conv2D)                 (None, None, None, 3 864         
➥ input_2[0][0]                    
___________________________________________________________________________
➥ ________________
batch_normalization (BatchNorma (None, None, None, 3 96          
➥ conv2d[0][0]                     
___________________________________________________________________________
➥ ________________
activation (Activation)         (None, None, None, 3 0           
➥ batch_normalization[0][0]        
___________________________________________________________________________
➥ ________________

...
___________________________________________________________________________
➥ ________________
conv_7b (Conv2D)                (None, None, None, 1 3194880     
➥ block8_10[0][0]                  
___________________________________________________________________________
➥ ________________
conv_7b_bn (BatchNormalization) (None, None, None, 1 4608        
➥ conv_7b[0][0]                    
___________________________________________________________________________
➥ ________________
conv_7b_ac (Activation)         (None, None, None, 1 0           
➥ conv_7b_bn[0][0]                 
___________________________________________________________________________
➥ ________________
global_average_pooling2d (Globa (None, 1536)         0           
➥ conv_7b_ac[0][0]                 
___________________________________________________________________________
➥ ________________
dropout (Dropout)               (None, 1536)         0           
➥ global_average_pooling2d[0][0]   
___________________________________________________________________________
➥ ________________
dense (Dense)                   (None, 200)          307400      
➥ dropout[1][0]                    
===========================================================================
➥ ================
Total params: 54,644,136
Trainable params: 54,583,592
Non-trainable params: 60,544
___________________________________________________________________________
➥ ________________

接下来,我们将进行一次更改:向我们的模型引入一个新输出。请记住,我们使用功能 API 来定义我们的模型。这意味着我们可以在我们的模型中定义多个输出。我们需要的输出是 inception_resnet_v2 模型中最后一个卷积层产生的特征图。这是 Grad-CAM 计算的核心部分。您可以通过查看解开模型的模型摘要来获得最后一个卷积层的层名称:

last_conv_layer = 'conv_7b' # This is the name of the last conv layer of the model

grad_model = Model(
    inputs=unwrapped_model.inputs, 
    outputs=[
        unwrapped_model.get_layer(last_conv_layer).output,
        unwrapped_model.output
    ]    
)

有了我们的模型准备好后,让我们转向数据。我们将使用验证数据集来检查我们的模型。特别地,我们将编写一个函数(见清单 B.2)来接收以下内容:

  • image_path(str)- 数据集中图像的路径。

  • val_df(pd.DataFrame)—一个包含从图像名称到 wnid(即 WordNet ID)的映射的 pandas 数据框。请记住,wnid 是用于识别特定对象类的特殊编码。

  • class_indices(dict)—一个 wnid(字符串)到类别(0-199 之间的整数)的映射。这保留了关于哪个 wnid 在模型的最终输出层中由哪个索引表示的信息。

  • words(pd.DataFrame)—一个包含从 wnid 到类别的可读描述的映射的 pandas 数据框。

清单 B.2 检索转换后的图像、类别索引和人类可读标签

img_path = 'data/tiny-imagenet-200/val/images/val_434.JPEG'

val_df = pd.read_csv(                                                          ❶
    os.path.join('data','tiny-imagenet-200', 'val', 'val_annotations.txt'),
    sep='\t', index_col=0, header=None
)

with open(os.path.join('data','class_indices'),'rb') as f:                     ❷
    class_indices = pickle.load(f)
words = pd.read_csv(                                                           ❸
    os.path.join('data','tiny-imagenet-200', 'words.txt'), 
    sep='\t', index_col=0, header=None
)

def get_image_class_label(img_path, val_df, class_indices, words):
    """ Returns the normalized input, class (int) and the label name for a given image"""

    img = np.expand_dims(                                                      ❹
        np.asarray(
            Image.open(img_path).resize((224,224)                              ❺
    )

    img /= 127.5                                                               ❻
    img -= 1                                                                   ❻

    if img.ndim == 3:
        img = np.repeat(np.expand_dims(img, axis=-1), 3, axis=-1)              ❼

    _, img_name = os.path.split(img_path)

    wnid = val_df.loc[img_name,1]                                              ❽
    cls = class_indices[wnid]                                                  ❾
    label = words.loc[wnid, 1]                                                 ❿
    return img, cls, label

# Test the function with a test image
img, cls, label = get_image_class_label(img_path, val_df, class_indices, words)⓫

❶ 读取 val_annotations.txt。这将创建一个数据框,其中包含从图像文件名到 wnid(即 WordNet ID)的映射。

❷ 加载将 wnid 映射到类索引(整数)的类索引。

❸ 这将创建一个数据框,其中包含从 wnid 到类描述的映射。

❹ 加载由文件路径给出的图像。首先,我们添加一个额外的维度来表示批次维度。

❺ 将图像调整大小为 224×224 大小的图像。

❻ 将图像像素值调整到[-1, 1]的范围内。

❼ 如果图像是灰度的,则在通道维度上将图像重复三次,以与 RGB 图像具有相同的格式。

❽ 获取图像的 wnid。

❾ 获取图像的类别索引。

❿ 获取类的字符串标签。

⓫ 对一个示例图像运行该函数。

get_image_class_label()函数使用指定的参数并加载由 image_path 给出的图像。首先,我们将图像调整大小为 224×224 大小的图像。我们还在开始时添加了一个额外的维度来表示图像作为一个图像批次。然后,它执行特定的数值转换(即,逐元素除以 127.5 并减去 1)。这是用于训练 InceptionResNet v2 模型的特殊转换。然后,我们使用传递给函数的数据框和 class_indices 获取该类的类索引(即,整数)和人类可读的标签。最后,它返回转换后的图像、类索引和图像所属类的标签。

下一个清单显示了如何为图像计算 Grad-CAMs。我们将使用 10 个图像分别计算 Grad-CAMs。

清单 B.3 为 10 个图像计算 Grad-CAM

# Define a sample probe set to get Grad-CAM
image_fnames = [
    os.path.join('data','tiny-imagenet-200', 'val','images',f) \
    for f in [
        'val_9917.JPEG', 'val_9816.JPEG', 'val_9800.JPEG', 'val_9673.JPEG', 
➥ 'val_9470.JPEG',
        'val_4.JPEG', 'val_127.JPEG', 'val_120.JPEG', 'val_256.JPEG', 
➥ 'val_692.JPEG'
    ]
]

grad_info = {}
for fname in image_fnames:                                                      ❶
    img, cls, label = get_image_class_label(fname, val_df, class_indices, words)❷

    with tf.GradientTape() as tape:                                             ❸
        conv_output, preds = grad_model(img)                                    ❸
        loss = preds[:, cls]                                                    ❹

    grads = tape.gradient(loss, conv_output)                                    ❺

    weights = tf.reduce_mean(grads, axis=(1, 2), keepdims=True)                 ❻
    grads *= weights                                                            ❻

    grads = tf.reduce_sum(grads, axis=(0,3))                                    ❼
    grads = tf.nn.relu(grads)                                                   ❼

    grads /= tf.reduce_max(grads)                                               ❽
    grads = tf.cast(grads*255.0, 'uint8')                                       ❽

    grad_info[fname] = {'image': img, 'class': cls, 'label':label, 'gradcam': 
➥ grads}                                                                       ❾

❶ 获取每个图像的标准化输入、类别(整数)和标签(字符串)。

❷ 在 GradientTape 环境中计算模型的输出。

❸ 这将使我们能够稍后访问在计算过程中出现的梯度。

❹ 我们只考虑输入图像的类索引对应的损失。

❺ 获取与最后一个卷积特征图相关的损失的梯度。

❻ 计算并应用权重。

❼ 将特征图折叠为单个通道,以获得最终的热图。

❽ 将值归一化为 0-255 的范围内。

❾ 将计算出的 GradCAMs 存储在字典中以便稍后可视化。

要计算一张图像的 Grad-CAM,我们遵循以下步骤。首先,我们获得给定图像路径的转换图像、类索引和标签。

接下来是此计算的最重要步骤!您知道,给定一张图像和一个标签,最终损失被计算为所有可用类别的类特定损失的总和。也就是说,如果您想象一个独热编码的标签和模型输出的概率向量,我们计算每个输出节点之间的损失。这里每个节点代表一个单独的类别。为了计算梯度映射,我们首先仅针对该图像的真实标签计算类特定损失的梯度,关于最后一个卷积层的输出。这给出了一个与最后一个卷积层的输出大小相同的张量。重要的是注意我们使用的典型损失和这里使用的损失之间的区别。通常,我们将所有类别的损失求和,而在 Grad-CAM 中,我们仅考虑与输入的真实类对应的特定节点的损失。

注意我们如何计算梯度。我们使用了一种称为 GradientTape 的东西(mng.bz/wo1Q)。这是 TensorFlow 中的一项创新技术。每当在 GradientTape 的上下文中计算某些东西时,它将记录所有这些计算的梯度。这意味着当我们在 GradientTape 的上下文中计算输出时,我们可以稍后访问该计算的梯度。

然后我们进行一些额外的转换。首先,我们计算输出特征图的每个通道的权重。这些权重简单地是该特征图的均值。然后将特征图的值乘以这些权重。然后我们对所有通道的输出进行求和。这意味着我们将得到一个宽度和高度都为一个通道的输出。这本质上是一个热图,其中高值表示在给定像素处更重要。为了将负值剪切为 0,然后在输出上应用 ReLU 激活。作为最终的归一化步骤,我们将所有值带到 0-255 的范围,以便我们可以将其作为热图叠加在实际图像上。然后只需使用 matplotlib 库绘制图像,并将我们生成的 Grad-CAM 输出叠加在图像上。如果您想查看此代码,请参阅 Ch07-Improving-CNNs-and-Explaining/7.3.Interpreting_CNNs_ Grad-CAM.ipynb 笔记本。最终输出将如图 B.1 所示。

B-1

图 B.1 几个探测图像的 Grad-CAM 输出可视化。图像中越红的区域,模型就越关注该图像的那部分。您可以看到我们的模型已经学会了理解一些复杂的场景,并分离出需要关注的模型。

B.2 图像分割:U-Net 模型

在第八章中,我们讨论了 DeepLab v3:一个图像分割模型。在本节中,我们将讨论另一个被称为 U-Net 的图像分割模型。它的架构与 DeepLab 模型不同,并且在实际中非常常用。因此,这是一个值得学习的模型。

B.2.1 理解和定义 U-Net 模型

U-Net 模型本质上是两个镜像反射的全卷积网络,充当编码器和解码器,还有一些额外的连接,将编码器的部分连接到解码器的部分。

U-Net 的背景

U-Net 是在论文“U-Net: Convolution Networks for Biomedical Image Segmentation”(arxiv.org/pdf/1505.04597.pdf)中被介绍的,其起源于生物医学图像分割。U-Net 的名称源自于网络的形状。它仍然是生物学/医学领域分割任务中常用的选择,并且已经被证明在更一般的任务中也能很好地工作。

首先,我们将看一下原始的 U-Net 模型,该模型在论文中被介绍。然后,我们稍微调整我们的讨论方向,使其更适合当前问题。原始模型使用的是一个 572 × 572 × 1 大小的图像(即灰度图像),并输出一个 392 × 392 × 2 大小的图像。该网络经过训练,可以识别/分割细胞边界。因此,输出中的两个通道表示像素是否属于细胞边界的二进制输出。

编码器由多个下采样模块组成,逐渐对输入进行下采样。一个下采样模块包括两个卷积层和一个最大池化层。具体来说,一个下采样模块包括:

  • 一个 3 × 3 卷积层(valid padding)× 2

  • 一个 2 × 2 最大池化层(除了最后一个下采样模块)

一系列这样的下采样层将大小为 572 × 572 × 1 的输入转换为大小为 28 × 28 × 1024 的输出。

接下来,解码器由多个上采样层组成。具体来说,每个解码器上采样模块包括:

  • 一个 2 × 2 转置卷积层

  • 一个 3 × 3 卷积层(valid padding)× 2

你可能已经想知道,什么是转置卷积层?转置卷积是反向计算卷积层中发生的计算得到的结果。转置卷积不是缩小输出的卷积运算(即使用步长),而是增加输出的大小(即上采样输入)。这也被称为分数步长,因为使用转置卷积时,增加步长会产生更大的输出。如图 B.2 所示。

B-2

图 B.2 标准卷积与转置卷积的对比。标准卷积的正向步长会导致输出更小,而转置卷积的正向步长会导致输出更大的图像。

最后,有跳跃连接将编码器的中间层连接到解码器的中间层。这是一个重要的架构设计,因为它为解码器提供了所需的空间/上下文信息,否则这些信息将会丢失。特别地,编码器的第 i^(th)级输出与解码器的第 n-i^(th)级输入连接起来(例如,第一级的输出[大小为 568 × 568 × 64]被连接到解码器的最后一级输入上 [大小为 392 × 392 × 64];图 B.3)。为了做到这一点,编码器的输出首先需要稍微裁剪一下,以匹配相应的解码器层的输出。

B-3

图 B.3 原始 U-Net 模型。浅色块代表编码器,深色块代表解码器。垂直数字表示给定位置的输出大小(高度和宽度),顶部的数字表示滤波器数量。

B.2.2 比编码器更好的是什么?一个预训练的编码器

如果你直接将原始网络用于 Pascal VOC 数据集,你可能会对其性能感到非常失望。这种行为背后可能有几个原因:

  • Pascal VOC 中的数据比原始 U-Net 设计的要复杂得多。例如,与黑白图像中包含简单细胞结构不同,我们有包含现实世界复杂场景的 RGB 图像。

  • 作为一个完全卷积网络,U-Net 具有很高的正则化程度(由于参数数量较少)。这个参数数量不足以以足够的准确度解决我们所面临的复杂任务。

  • 作为一个从随机初始化开始的网络,它需要学会在没有来自预训练模型的预训练知识的情况下解决任务。

按照这种推理,让我们讨论一下我们将对原始 U-Net 架构进行的一些改变。我们将实现一个具有

  • 预训练的编码器

  • 每个解码器模块中的滤波器数量更多

我们将使用的预训练编码器是一个 ResNet-50 模型(arxiv.org/pdf/1512.03385.pdf)。几年前,它是计算机视觉社区中引起轰动的开创性残差网络之一。我们只会简单地介绍 ResNet-50,因为我们将在 DeepLab v3 模型的部分详细讨论该模型。ResNet-50 模型由多个卷积块组成,后跟一个全局平均池化层和一个具有 softmax 激活的完全连接的最终预测层。卷积块是该模型的创新部分(在图 B.4 中用 B 表示)。原始模型有 16 个卷积块组织成 5 组。我们将仅使用前 13 个块(即前 4 组)。单个块由三个卷积层(步幅为 2 的 1 × 1 卷积层、3 × 3 卷积层和 1 × 1 卷积层)、批量归一化和残差连接组成,如图 B.4 所示。我们在第七章深入讨论了残差连接。

B-4

图 B.4 修改后的 U-Net 架构(最佳查看彩色)。此版本的 U-Net 将 ResNet-50 模型的前四个块作为编码器,并将解码器规格(例如,滤波器数量)增加到与匹配的编码器层的规格相匹配。

实现修改后的 U-Net

通过对模型及其不同组件进行深入的概念理解,是时候在 Keras 中实现它了。我们将使用 Keras 函数式 API。首先,我们定义网络的编码器部分:

inp = layers.Input(shape=(512, 512, 3))
# Defining the pretrained resnet 50 as the encoder
encoder = tf.keras.applications.ResNet50 (
    include_top=False, input_tensor=inp,pooling=None
)

接下来,我们讨论解码器的花哨之处。解码器由多个上采样层组成,这些层具有两个重要功能:

  • 将输入上采样到更大的输出

  • 复制、裁剪和连接匹配的编码器输入

下面的列表中显示的函数封装了我们概述的计算。

列表 B.4 修改后的 UNet 解码器的上采样层

def upsample_conv(inp, copy_and_crop, filters):
    """ Up sampling layer of the U-net """

    # 2x2 transpose convolution layer
    conv1_out = layers.Conv2DTranspose(
        filters, (2,2), (2,2), activation='relu'
    )(inp)
    # Size of the crop length for one side
    crop_side = int((copy_and_crop.shape[1]-conv1_out.shape[1])/2)

    # Crop if crop side is > 0
    if crop_side > 0:
        cropped_copy = layers.Cropping2D(crop_side)(copy_and_crop)
    else:
        cropped_copy = copy_and_crop

    # Concat the cropped encoder output and the decoder output
    concat_out = layers.Concatenate(axis=-1)([conv1_out, cropped_copy])

    # 3x3 convolution layer
    conv2_out = layers.Conv2D(
        filters, (3,3), activation='relu', padding='valid'
    )(concat_out)

    # 3x3 Convolution layer
    out = layers.Conv2D(
        filters, (3,3), activation='relu', padding='valid'
    )(conv2_out)

    return out

让我们分析我们编写的函数。它接受以下参数:

  • 输入—层的输入

  • copy_and_crop—从编码器复制过来的输入

  • filters—执行转置卷积后的输出滤波器数量

首先,我们执行转置卷积,如下所示:

conv1_out = layers.Conv2DTranspose(
                    filters=filters, kernel_size=(2,2), 
                    strides=(2,2), activation='relu'
    )(inp)

Conv2DTranspose 的语法与我们多次使用的 Conv2D 相同。它有一些滤波器、卷积核大小(高度和宽度)、步长(高度和宽度)、激活函数和填充(默认为 valid)。我们将根据转置卷积输出的大小和编码器的输入来计算裁剪参数。然后,根据需要使用 Keras 层 Cropping2D 进行裁剪:

crop_side = int((copy_and_crop.shape[1]-conv1_out.shape[1])/2)
if crop_side > 0:
        cropped_copy = layers.Cropping2D(crop_side)(copy_and_crop)
    else:
        cropped_copy = copy_and_crop

在这里,我们首先计算从一侧裁剪多少,方法是从上采样输出 conv1_out 中减去编码器的大小。然后,如果大小大于零,则通过将 crop_side 作为参数传递给 Cropping2D Keras 层来计算 cropped_copy。然后将裁剪后的编码器输出和上采样的 conv1_out 连接起来以产生单个张量。这通过两个具有 ReLU 激活和有效填充的 3 × 3 卷积层产生最终输出。我们现在完全定义解码器(请参见下一个清单)。解码器由三个上采样层组成,这些层使用前一层的输出以及复制的编码器输出。

清单 B.5 修改后的 U-Net 模型的解码器

def decoder(inp, encoder):
    """ Define the decoder of the U-net model """

    up_1 = upsample_conv(inp, encoder.get_layer("conv3_block4_out").output, 
➥ 512) # 32x32

    up_2 = upsample_conv(up_1, 
➥ encoder.get_layer("conv2_block3_out").output, 256) # 64x64

    up_3 = upsample_conv(up_2, encoder.get_layer("conv1_relu").output, 64) 
➥ # 128 x 128    

    return up_3

跨越预定义模型的中间输出的复制不是我们以前做过的事情。因此,值得进一步调查。我们不能够诉诸于先前定义的代表编码器输出的变量,因为这是一个通过 Keras 下载的预定义模型,没有用于创建模型的实际变量的引用。

但是访问中间输出并使用它们创建新连接并不那么困难。你只需要知道要访问的层的名称即可。这可以通过查看 encoder.summary() 的输出来完成。例如,在这里(根据图 B.4),我们获得了 conv3、conv2 和 conv1 模块的最后输出。要获取 conv3_block4_out 的输出,你需要做的就是

encoder.get_layer("conv3_block4_out").output

将其传递给我们刚刚定义的上采样卷积层。能够执行这样复杂的操作证明了 Keras 函数 API 有多么灵活。最后,你可以在下一个清单中的函数 unet_pretrained_encoder() 中定义完整修改后的 U-Net 模型。

清单 B.6 完整修改后的 U-Net 模型

def unet_pretrained_encoder():
    """ Define a pretrained encoder based on the Resnet50 model """

    # Defining an input layer of size 384x384x3
    inp = layers.Input(shape=(512, 512, 3))
    # Defining the pretrained resnet 50 as the encoder
    encoder = tf.keras.applications.ResNet50 (
        include_top=False, input_tensor=inp,pooling=None
    )

    # Encoder output # 8x8
    decoder_out = decoder(encoder.get_layer("conv4_block6_out").output, encoder)

    # Final output of the model (note no activation)
    final_out = layers.Conv2D(num_classes, (1,1))(decoder_out)    
    # Final model
    model = models.Model(encoder.input, final_out)
    return model

这里发生的情况非常清楚。我们首先定义一个大小为 512 × 512 × 3 的输入,将其传递给编码器。我们的编码器是一个没有顶部预测层或全局池化的 ResNet-50 模型。接下来,我们定义解码器,它将 conv4_block6_out 层的输出作为输入(即 ResNet-50 模型的 conv4 块的最终输出),然后逐渐使用转置卷积操作上采样它。此外,解码器复制、裁剪和连接匹配的编码器层。我们还定义一个产生最终输出的 1 × 1 卷积层。最后,我们使用 Keras 函数 API 定义端到端模型。

附录 C:自然语言处理

C.1 环游动物园:遇见其他 Transformer 模型

在第十三章,我们讨论了一种强大的基于 Transformer 的模型,称为 BERT(双向编码器表示来自 Transformer)。但 BERT 只是一波 Transformer 模型的开始。这些模型变得越来越强大,更好,要么通过解决 BERT 的理论问题,要么重新设计模型的各个方面以实现更快更好的性能。让我们了解一些流行的模型,看看它们与 BERT 的不同之处。

C.1.1 生成式预训练(GPT)模型(2018)

故事实际上甚至早于 BERT。OpenAI 在 Radford 等人的论文“通过生成式预训练改善语言理解”中引入了一个模型称为 GPT(mng.bz/1oXV)。它的训练方式类似于 BERT,首先在大型文本语料库上进行预训练,然后进行有区分性的任务微调。与 BERT 相比,GPT 模型是一个Transformer 解码器。它们的区别在于,GPT 模型具有从左到右(或因果)的注意力,而 BERT 在计算自注意力输出时使用双向(即从左到右和从右到左)注意力。换句话说,GPU 模型在计算给定单词的自注意力输出时只关注其左侧的单词。这与我们在第五章讨论的掩码注意力组件相同。因此,GPT 也被称为自回归模型,而 BERT 被称为自编码器。此外,与 BERT 不同,将 GPT 适应不同的任务(如序列分类、标记分类或问题回答)需要进行轻微的架构更改,这很麻烦。GPT 有三个版本(GPT-1、GPT-2 和 GPT-3);每个模型都变得更大,同时引入轻微的改进以提高性能。

注意 OpenAI,TensorFlow:github.com/openai/gpt-2

C.1.2 DistilBERT(2019)

跟随 BERT,DistilBERT 是由 Hugging Face 在 Sanh 等人的论文“DistilBERT, a distilled version of BERT: Smaller, faster, cheaper and lighter”(arxiv.org/pdf/1910.01108v4.pdf)中介绍的模型。DistilBERT 的主要焦点是在保持性能相似的情况下压缩 BERT。它是使用一种称为知识蒸馏mng.bz/qYV2)的迁移学习技术进行训练的。其思想是有一个教师模型(即 BERT),和一个更小的模型(即 DistilBERT),试图模仿教师的输出。DistilBERT 模型相比于 BERT 更小,只有 6 层,而不是 BERT 的 12 层。DistilBERT 模型是以 BERT 的每一层的初始化为基础进行初始化的(因为 DistilBERT 恰好有 BERT 层的一半)。DistilBERT 的另一个关键区别是它只在掩码语言建模任务上进行训练,而不是在下一个句子预测任务上进行训练。

注意 Hugging Face 的 Transformers:huggingface.co/transformers/model_doc/distilbert.xhtml

C.1.3 RoBERT/ToBERT(2019)

RoBERT(递归 BERT)和 ToBERT(基于 BERT 的 Transformer)是由 Pappagari 等人在论文“Hierarchical Transformer Models for Long Document Classification”(arxiv.org/pdf/1910.10781.pdf)中介绍的两个模型。这篇论文解决的主要问题是 BERT 在长文本序列(例如,电话转录)中的性能下降或无法处理。这是因为自注意力层对长度为 n 的序列具有 O(n²) 的计算复杂度。这些模型提出的解决方案是将长序列分解为长度为 k 的较小段(有重叠),并将每个段馈送到 BERT 以生成汇集输出(即 [CLS] 标记的输出)或来自任务特定分类层的后验概率。然后,堆叠 BERT 为每个段返回的输出,并将其传递给像 LSTM(RoBERT)或较小 Transformer(ToBERT)这样的递归模型。

注意 Hugging Face 的 Transformers:huggingface.co/transformers/model_doc/roberta.xhtml

C.1.4 BART(2019)

BART(双向和自回归 Transformer)由 Lewis 等人在“BART:去噪序列到序列预训练用于自然语言生成、翻译和理解”(arxiv.org/pdf/1910.13461.pdf)中提出,是一个序列到序列模型。我们在第 11 和 12 章中已经讨论了序列到序列模型,BART 也借鉴了这些概念。BART 有一个编码器和一个解码器。如果你还记得第五章,Transformer 模型也有一个编码器和一个解码器,可以视为序列到序列模型。Transformer 的编码器具有双向注意力,而 Transformer 的解码器具有从左到右的注意力(即是自回归的)。

与原始 Transformer 模型不同,BART 使用了几种创新的预训练技术(文档重建)来预训练模型。特别地,BART 被训练成为一个去噪自编码器,其中提供了一个有噪声的输入,并且模型需要重建真实的输入。在这种情况下,输入是一个文档(一系列句子)。这些文件使用表 C.1 中列出的方法进行损坏。

表 C.1 文档损坏所采用的各种方法。真实文档为“I was hungry. I went to the café.”下划线字符(_)代表遮蔽标记。

方法 描述 例子
记号遮蔽 句子中的记号随机遮蔽。 我饿了。我去了 _ 咖啡馆。
记号删除 随机删除记号。 我饿了。我去了咖啡馆。
句子排列 更改句子顺序。 我去了咖啡馆。我饿了。
文档旋转 旋转文档,以便文档的开头和结尾发生变化。 咖啡馆。我饿了。我去了
文本补齐 使用单一遮蔽标记遮蔽跨度标记。一个长度为 0 的跨度将插入遮蔽标记。 我饿了。我 _ 去了咖啡馆。

使用损坏逻辑,我们生成输入到 BART 的数据,目标就是没有损坏的真实文档。初始时,已经损坏的文档被输入到编码器,然后解码器被要求递归地预测出真实的序列,同时使用先前预测出的输出作为下一个输入。这类似于第十一章使用机器翻译模型预测翻译的方法。

模型预训练后,你可以将 BART 用于 Transformer 模型通常用于的任何 NLP 任务。例如,可以按以下方式将 BART 用于序列分类任务(例如,情感分析):

  1. 把记号序列(例如,电影评论)输入到编码器和解码器中。

  2. 在给解码器输入时,在序列末尾添加一个特殊记号(例如,[CLS])。我们在使用 BERT 时将特殊记号添加到序列开头。

  3. 通过解码器得到特殊标记的隐藏表示结果,并将其提供给下游分类器以预测最终输出(例如,积极/消极的预测结果)。

如果要将 BART 用于序列到序列的问题(例如机器翻译),请按照以下步骤进行:

  1. 将源序列输入编码器。

  2. 向目标序列的开头和结尾分别添加起始标记(例如,[SOS])和结束标记(例如,[EOS])。

  3. 在训练时,使用除了最后一个标记以外的所有目标序列标记作为输入,使用除了第一个标记以外的所有标记作为目标(即,teacher forcing)来训练解码器。

  4. 在推理时,将起始标记提供给解码器作为第一个输入,并递归地预测下一个输出,同时将前一个输出(件)作为输入(即自回归)。

注意 Hugging Face’s Transformers: mng.bz/7ygy

C.1.5 XLNet (2020)

XLNet 是由杨飞等人在 2020 年初发布的论文 "XLNet: Generalized Autoregressive Pretraining for Language Understanding" 中推出的。它的主要重点是捕捉基于自编码器模型(例如 BERT)和自回归模型(例如 GPT)两种方法的优点,这很重要。

作为自编码器模型,BERT 具有的一个关键优势是,任务特定的分类头包含了由双向上下文丰富化的标记的隐藏表示。正如你所想像的那样,了解给定标记之前和之后的内容能够得到更好的下游任务效果。相反地,GPT 只关注给定单词的左侧来生成表示。因此,GPT 的标记表示是次优的,因为它们只关注(左侧)上下文的单向 注意力。

另一方面,BERT 的预训练方法涉及引入特殊标记 [MASK]。虽然这个标记出现在预训练的上下文中,但它在微调的上下文中从未出现过,造成了预训练和微调之间的差异。

BERT 中存在一个更为关键的问题。BERT 假设掩盖标记是单独构建的(即独立假设),这在语言建模中是错误的。换句话说,如果你有句子 "I love [MASK][1] [MASK][2] city",第二个掩盖标记是独立于 [MASK][1] 的选择而生成的。这是错误的,因为要生成一个有效的城市名称,必须在生成 [MASK][2] 之前先了解 [MASK][1] 的值。然而,GPT 的自回归性质允许模型先预测 [MASK][1] 的值,然后使用其与其左侧的其他单词一起生成城市的第一个单词的值,然后生成第二个单词的值(即上下文感知)。

XLNet 将这两种语言建模方法融合为一体,因此你可以从 BERT 使用的方法中得到双向上下文,以及从 GPT 的方法中得到上下文感知。这种新方法被称为置换语言建模。其思想如下。考虑一个长度为 T 的单词序列。对于该序列,有 T!种排列方式。例如,句子“Bob 爱猫”将有 3! = 3 × 2 × 1 = 6 种排列方式:

Bob loves cats
Bob cats loves
loves Bob cats
loves cats Bob
cats Bob loves
cats loves Bob

如果用于学习的语言模型的参数在所有排列中共享,我们不仅可以使用自回归方法来学习它,还可以捕获给定单词的文本两侧的信息。这是论文中探讨的主要思想。

注意 Hugging Face 的 Transformers:mng.bz/mOl2

C.1.6 Albert(2020)

Albert 是 BERT 模型的一种变体,其性能与 BERT 相媲美,但参数更少。Albert 做出了两项重要贡献:减少模型大小和引入一种新的自监督损失,有助于模型更好地捕捉语言。

嵌入层的因式分解

首先,Albert 对 BERT 中使用的嵌入矩阵进行了因式分解。在 BERT 中,嵌入是大小为 V × H 的度量,其中 V 是词汇表大小,H 是隐藏状态大小。换句话说,嵌入大小(即嵌入向量的长度)与最终隐藏表示大小之间存在紧密耦合。然而,嵌入(例如 BERT 中的 WordPiece 嵌入)并不是设计用来捕捉上下文的,而隐藏状态是在考虑了标记及其上下文的情况下计算的。因此,增大隐藏状态大小 H 是有意义的,因为隐藏状态比嵌入更能捕获标记的信息性表示。但是,由于存在紧密耦合,这样做会增加嵌入矩阵的大小。因此,Albert 建议对嵌入矩阵进行因式分解为两个矩阵,V × E 和 E × H,从而解耦嵌入大小和隐藏状态大小。通过这种设计,可以增加隐藏状态大小,同时保持嵌入大小较小。

跨层参数共享

跨层参数共享是 Albert 中引入的另一种减少参数空间的技术。由于 BERT 中的所有层(以及 Transformer 模型一般)从顶部到底部都具有统一的层,参数共享是微不足道的。参数共享可以通过以下三种方式之一实现:

  • 在所有自注意子层之间

  • 在所有完全连接的子层之间

  • 在自注意和完全连接的子层之间(分开)

Albert 将跨层共享所有参数作为默认策略。通过使用这种策略,Albert 实现了 71%到 86%的参数减少,而不会显著影响模型的性能。

句序预测而非下一句预测

论文的作者最终认为,BERT 中基于下一句预测的预训练任务所增加的价值是值得怀疑的,这一点得到了多项先前研究的支持。因此,他们提出了一种新的、更具挑战性的模型,主要关注语言的一致性:句子顺序预测。在这个模型中,模型使用一个二元分类头来预测给定的一对句子是否是正确的顺序。数据可以很容易地生成,正样本是按照顺序排列的相邻句子,而负样本则是通过交换两个相邻句子生成的。作者认为这比基于下一句预测的任务更具挑战性,导致比 BERT 更具见解的模型。

备注 TFHub: (tfhub.dev/google/albert_base/3)。Hugging Face 的 Transformers: (mng.bz/5QM1)。

C.1.7 Reformer (2020)

Reformer 是最近加入 Transformer 家族的模型之一。Reformer 的主要思想是能够扩展到包含数万个标记的序列。Reformer 在 2020 年初由 Kitaev 等人在论文“Reformer: The Efficient Transformer”中提出(arxiv.org/pdf/2001.04451.pdf)。

防止普通 Transformer 被用于长序列的主要限制是自注意力层的计算复杂性。它需要对每个词查看所有其他词,以生成最终的表示,这对含有 L 个标记的序列具有 O(L²) 的复杂性。Reformer 使用局部敏感哈希(LSH)将这种复杂性降低到 O(L logL)。LSH 的思想是为每个输入分配一个哈希;具有相同哈希的输入被认为是相似的,并被分配到同一个桶。这样,相似的输入就会被放在同一个桶中。为此,我们需要对自注意力子层进行若干修改。

自注意力层中的局部敏感哈希

首先,我们需要确保 Q 和 K 矩阵是相同的。这是必要的,因为其思想是计算查询和键之间的相似性。这可以通过共享 Q 和 K 的权重矩阵的权重来轻松实现。接下来,需要开发一个哈希函数,可以为给定的查询/键生成哈希,使得相似的查询/键(共享-qk)获得相似的哈希值。同时需要记住,这必须以可微的方式完成,以确保模型的端到端训练。使用了以下哈希函数:

h(x) = argmax([xR; - xR])

其中,R 是大小为[d_model, b/2]的随机矩阵,用于用户定义的 b(即,桶的数量),x 是形状为[b, L, d_model]的输入。通过使用这个散列函数,您可以得到批处理中每个输入标记在给定位置的桶 ID。要了解更多关于这个技术的信息,请参考原始论文“Practical and Optimal LSH for Angular Distance” by Andoni et al.(arxiv.org/pdf/1509.02897.pdf)。根据桶 ID,共享的 qk 项被排序。

然后,排序后的共享的 qk 项使用固定的块大小进行分块。更大的块大小意味着更多的计算(即,会考虑更多单词来给定标记),而较小的块大小可能意味着性能不佳(即,没有足够的标记可供查看)。

最后,自注意力的计算如下。对于给定的标记,查看它所在的相同块以及前一个块,并关注这两个块中具有相同桶 ID 的单词。这将为输入中提供的所有标记产生自注意力输出。这样,模型不必为每个标记查看每个其他单词,可以专注于给定标记的子集单词或标记。这使得模型可伸缩到长达几万个标记的序列。

注意 Hugging Face's Transformers: mng.bz/6XaD

posted @ 2024-05-02 22:33  绝不原创的飞龙  阅读(25)  评论(0编辑  收藏  举报