面向程序员的人工智能和机器学习-全-

面向程序员的人工智能和机器学习(全)

原文:zh.annas-archive.org/md5/096676beecb8b24800dec9691eeca604

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

亲爱的读者,

AI 即将改变每一个行业,但几乎每个 AI 应用都需要根据其特定用途进行定制。用于阅读医疗记录的系统不同于用于发现工厂缺陷的系统,也不同于产品推荐引擎。为了发挥 AI 的全部潜力,工程师们需要能够帮助他们适应数百万具体问题的惊人能力的工具。

当我领导谷歌大脑团队时,我们开始构建 TensorFlow 的 C++ 前身 DistBelief。我们对利用成千上万的 CPU 训练神经网络的潜力感到兴奋(例如,使用 16,000 个 CPU 在未标记的 YouTube 视频上训练猫探测器)。自那时以来,深度学习已经发展了多远啊!曾经的尖端技术现在可以在约 3,000 美元的云计算学分内实现,而谷歌定期使用 TPU 和 GPU 训练神经网络,这在几年前是难以想象的规模。

TensorFlow 也走过了漫长的道路。它比起早期的版本更加易用,具有丰富的功能,从建模到使用预训练模型,再到部署在低计算能力的边缘设备上。如今,它赋予数十万开发者构建自己深度学习模型的能力。

作为谷歌首席 AI 倡导者,Laurence Moroney 是将 TensorFlow 打造成全球领先 AI 框架的重要推动力。我有幸支持他在 deeplearning.ai 和 Coursera 上教授 TensorFlow。这些课程已经吸引了超过 80,000 名学习者,并获得了许多积极的评价。

与 Laurence 友谊中一个意想不到的方面是,他也是爱尔兰诗歌的免费来源。他曾经在 Slack 上对我说过:

Andrew 唱了一首悲伤的老歌

陶醉于未见过的帽匠

邀请 Hoops

陶醉 陶醉

[...]

他曾经用 LSTM 训练过传统爱尔兰歌曲的歌词,生成了这些句子。如果 AI 打开了这样的乐趣之门,有谁不想参与呢?你可以 (i) 参与推动人类进步的激动人心的项目, (ii) 提升自己的职业生涯,和 (iii) 获得免费的爱尔兰诗歌。

祝你在学习 TensorFlow 的旅程中一切顺利。有 Laurence 作为导师,你将迎来伟大的冒险。

继续学习,

Andrew Ng

创始人,deeplearning.ai

前言

欢迎来到《编程人员的 AI 与机器学习》,这是一本我多年来一直想写的书,但直到最近机器学习(ML)特别是 TensorFlow 的最新进展才真正变得可能。本书的目标是为你准备好,作为一个程序员,应对许多可以用机器学习解决的场景,目的是让你成为一个 AI 和 ML 开发者,而无需博士学位!我希望你会发现它有用,并且它会赋予你开始这段美妙且有回报的旅程的信心。

该书适合谁阅读

如果你对人工智能(AI)和机器学习(ML)感兴趣,并且希望快速上手构建能从数据中学习的模型,那么这本书适合你。如果你想要开始学习常见的 AI 和 ML 概念——计算机视觉、自然语言处理、序列建模等等,并且想要了解神经网络如何被训练来解决这些领域的问题,我认为你会喜欢这本书。如果你已经训练好了模型,并且想要将它们交付给移动端用户、浏览器端用户,或者通过云端服务,那么这本书同样适合你!

最重要的是,如果你因为认为人工智能领域过于困难而被拒绝进入这个有价值的计算机科学领域,特别是认为你需要拾起你的旧微积分书籍,那么不用担心:本书采用的是先代码后理论的方法,向你展示了使用 Python 和 TensorFlow 轻松入门机器学习和人工智能世界的方式。

我为什么写这本书

我第一次真正接触人工智能是在 1992 年春季。当时我是一名新晋的物理学毕业生,居住在伦敦,正值一场严重的经济衰退中,我已经失业了六个月。英国政府启动了一个培训计划,培训 20 人掌握 AI 技术,并发出了招聘通知。我是第一个被选中的参与者。三个月后,这个计划失败了,因为虽然可以在 AI 领域做很多理论工作,但实际上却没有简便的方法来实际操作。人们可以用一种叫做 Prolog 的语言编写简单的推理,用一种叫做 Lisp 的语言进行列表处理,但在工业界应用它们的明确路径却并不清晰。著名的“AI 寒冬”随之而来。

后来,2016 年,当我在 Google 工作,参与一个名为 Firebase 的产品时,公司为所有工程师提供了机器学习培训。我和其他几个人坐在一间屋子里,听讲授微积分和梯度下降的课程。我无法将这些直接应用到机器学习的实际实现中,突然间,我回到了 1992 年。我给 TensorFlow 团队提供了关于这一点的反馈,以及我们应该如何教育人们机器学习的建议,他们于 2017 年雇用了我。随着 2018 年 TensorFlow 2.0 的发布,尤其是强调易于开发者入门的高级 API,我意识到需要一本书来利用这一点,并扩大 ML 的普及,使其不再仅限于数学家或博士。

我相信更多的人使用这项技术,并将其部署到最终用户,将会导致 AI 和 ML 的爆发,避免另一次 AI 寒冬,并使世界变得更加美好。我已经看到了这一点的影响,从 Google 在糖尿病视网膜病变上的工作,到宾夕法尼亚州立大学和 PlantVillage 为移动设备建立 ML 模型来帮助农民诊断木薯病,再到无国界医生使用 TensorFlow 模型来帮助诊断抗生素耐药性,等等!

浏览本书

本书分为两个主要部分。第一部分(章节 1–11)讨论如何使用 TensorFlow 构建各种场景的机器学习模型。它从基础原理开始——使用仅含一个神经元的神经网络建模——通过计算机视觉、自然语言处理和序列建模。第二部分(章节 12–20)则指导您将模型部署到 Android 和 iOS 设备、浏览器 JavaScript 以及通过云端服务。大多数章节都是独立的,因此您可以随时学习新知识,当然也可以从头到尾阅读整本书。

您需要了解的技术

本书上半部分的目标是帮助您学习如何使用 TensorFlow 构建各种架构的模型。这方面唯一的真正先决条件是理解 Python,特别是用于数据和数组处理的 Python 符号。您可能还希望探索 NumPy,这是一个用于数值计算的 Python 库。如果您对这些完全不熟悉,也很容易学会,您可以在学习过程中掌握所需的内容(尽管数组符号可能有点难以理解)。

本书的后半部分,我一般不会教授所展示的语言,而是展示如何在其中使用 TensorFlow 模型。例如,在 Android 章节(第十三章)中,您将探索使用 Kotlin 和 Android Studio 构建应用程序;在 iOS 章节(第十四章)中,您将探索使用 Swift 和 Xcode 构建应用程序。我不会教授这些语言的语法,所以如果您不熟悉它们,可能需要一本入门书籍——Learning Swift,作者 Jonathan Manning,Paris Buttfield-Addison 和 Tim Nugent(O’Reilly)是一个很好的选择。

在线资源

本书使用和支持各种在线资源。至少我建议您关注TensorFlow及其相关的YouTube 频道,以获取本书讨论的技术更新和重大变更。

本书的代码可以在https://github.com/lmoroney/tfbook找到,并且我会随着平台的演变而保持更新。

本书中使用的约定

本书使用以下印刷约定:

斜体

表示新术语、网址、电子邮件地址、文件名和文件扩展名。

常宽

用于程序清单,以及段落内指代程序元素,如变量或函数名,数据类型,环境变量,语句和关键字。

**常宽粗体**

用于代码片段的强调。

注意

此元素表示一则注释。

使用代码示例

本书旨在帮助您完成工作。一般情况下,如果本书提供了示例代码,您可以在您的程序和文档中使用它。除非您复制了大量代码,否则无需联系我们进行授权。例如,编写一个使用本书中多个代码块的程序并不需要授权。销售或分发 O’Reilly 书籍中的示例代码需要授权。引用本书并引用示例代码来回答问题不需要授权。将本书中大量示例代码整合到产品文档中需要授权。

我们赞赏但不要求署名。一般的署名包括标题,作者,出版商和 ISBN。例如:“AI and Machine Learning for Coders,作者 Laurence Moroney。版权所有 2021 年 Laurence Moroney,ISBN:978-1-492-07819-7。”

如果您觉得您使用的代码示例超出了合理使用范围或以上授权,请随时联系我们,邮箱地址为 permissions@oreilly.com。

致谢

我想要感谢许多人在创作本书过程中的帮助。

Jeff Dean 给了我加入 TensorFlow 团队的机会,开启了我 AI 之旅的第二阶段。还有整个团队,虽然无法一一列举,但我要感谢 Sarah Sirajuddin、Megan Kacholia、Martin Wicke 和 Francois Chollet 的出色领导和工程贡献!

TensorFlow 开发者关系团队,由 Kemal El Moujahid、Magnus Hyttsten 和 Wolff Dobson 领导,他们为人们学习 TensorFlow 提供了平台。

Andrew Ng,他不仅为本书撰写了前言,还相信我的 TensorFlow 教学方法,并与我共同在 Coursera 创建了三个专业化课程,教授了数十万人如何在机器学习和人工智能领域取得成功。Andrew 还领导了一个团队,来自deeplearning.ai,包括 Ortal Arel、Eddy Shu 和 Ryan Keenan,他们在帮助我成为更好的机器学习者方面表现出色。

使本书成为可能的 O'Reilly 团队:Rebecca Novack 和 Angela Rufino,没有她们的辛勤工作,我永远无法完成这本书!

了不起的技术审阅团队:Jialin Huang、Laura Uzcátegui、Lucy Wong、Margaret Maynard-Reid、Su Fu、Darren Richardson、Dominic Monn 和 Pin-Yu。

当然,最重要的是(比杰夫和安德鲁更重要 😉 )我的家人,他们让最重要的事情变得有意义:我的妻子丽贝卡·莫罗尼,我的女儿克劳迪娅·莫罗尼,和我的儿子克里斯托弗·莫罗尼。感谢你们让生活变得比我想象的更加美好。

第一部分: 建立模型

第一章:介绍 TensorFlow

要创建人工智能(AI),机器学习(ML)和深度学习是一个很好的起点。然而,当开始时,很容易被各种选项和新术语所淹没。本书旨在为程序员揭开神秘面纱,带您编写代码来实现机器学习和深度学习的概念,并构建更像人类行为的模型,例如计算机视觉、自然语言处理(NLP)等场景。因此,它们成为一种合成的或人工的智能形式。

但是当我们提到机器学习时,实际上指的是什么现象呢?让我们快速看看这个,从程序员的角度来考虑,在我们进一步深入之前。在此之后,本章将向您展示如何安装行业工具,从 TensorFlow 本身到您可以编写和调试 TensorFlow 模型的环境。

什么是机器学习?

在深入探讨机器学习的方方面面之前,让我们考虑它是如何从传统编程中演变而来的。我们将从研究传统编程的定义开始,然后考虑它存在局限的情况。接着我们将看到机器学习是如何演进来应对这些情况的,从而开启了实现新场景的新机会,并解锁了人工智能的许多概念。

传统编程涉及我们编写规则,用编程语言表达,作用于数据并给出答案。这几乎适用于任何可以用代码编程的地方。

例如,考虑像流行的打砖块游戏一样的游戏。代码确定球的运动、得分以及各种赢得或输掉游戏的条件。想象一下球撞击砖块的情景,就像图 1-1 中的代码一样。

打砖块游戏中的代码

图 1-1 打砖块游戏中的代码

在这里,球的运动可以由其dxdy属性决定。当它撞击到砖块时,砖块被移除,球的速度增加并改变方向。代码作用于游戏情况的数据。

或者考虑一个金融服务场景。你有关于公司股票的数据,比如当前价格和当前收益。你可以用类似于图 1-2 中的代码来计算一个有价值的比率,称为 P/E(即价格除以收益)。

金融服务场景中的代码

图 1-2 金融服务场景中的代码

你的代码读取价格,读取收益,并返回前者除以后者的值。

如果我试图用一个单一的图表来概括传统编程,它可能看起来像图 1-3。

传统编程的高层视图

图 1-3 传统编程的高层视图

正如你所见,你可以看到用编程语言表达的规则。这些规则作用于数据,结果是答案。

传统编程的局限性

自从其诞生以来,图 1-3 的模型一直是发展的支柱。但它有一个固有的限制:只有能够推导出规则的情景才能被实施。其他情景怎么办?通常因为代码太复杂而无法开发。编写处理它们的代码是不可能的。

例如,考虑活动检测。能够检测我们活动的健身监测器是一种新近的创新,不仅因为廉价和小型硬件的可用性,而且还因为处理检测的算法以前是不可行的。让我们探讨一下原因。

图 1-4 展示了一个简单的步行活动检测算法。它可以考虑个人的速度。如果速度低于特定值,我们可以确定他们可能在步行。

活动检测算法

图 1-4. 活动检测算法

考虑到我们的数据是速度,我们可以扩展到检测他们是否在跑步(图 1-5)。

扩展跑步算法

图 1-5. 扩展跑步算法

正如你所见,根据速度,如果速度低于特定值(比如说,4 英里每小时),我们可能会说这个人正在步行,否则他们在跑步。这还算有点用。

现在假设我们想要将其扩展到另一种流行的健身活动,骑行。算法可能类似于 图 1-6。

扩展骑行算法

图 1-6. 扩展骑行算法

我知道这个算法很幼稚,因为它只检测速度——有些人跑得比其他人快,而且你可能在下坡跑得比在上坡骑自行车快,例如。但总体来说,它仍然有效。但是,如果我们想要实现另一种情景,比如高尔夫(图 1-7)会发生什么?

如何编写高尔夫算法?

图 1-7. 如何编写高尔夫算法?

我们现在陷入困境。我们如何确定某人正在高尔夫运动?这个人可能会走一段路,停下来做一些活动,再走一段路,再停下来等等。但我们如何确定这是高尔夫?

我们使用传统规则检测这一活动的能力已经遇到了瓶颈。但也许有更好的方法。

进入机器学习。

从编程到学习

让我们回顾一下我们用来演示传统编程的图示(图 1-8)。在这里,我们有作用于数据并给我们答案的规则。在我们的活动检测场景中,数据是该人移动的速度;从中我们可以编写规则来检测他们的活动,无论是走路、骑车还是跑步。当涉及高尔夫运动时,我们遇到了困难,因为我们无法想出规则来确定该活动的样子。

传统编程流程

图 1-8. 传统编程流程

但是如果我们在这个图表上翻转轴会发生什么?如果我们不是制定规则,而是提供答案,并且在数据的基础上找到可能的规则呢?

图 1-9 展示了这个样子。我们可以通过这个高级别的图表来定义机器学习

改变轴以获得机器学习

图 1-9. 改变轴以获得机器学习

那么这意味着什么呢?现在我们不再需要我们来找出规则,我们得到了有关我们场景的大量数据,我们对这些数据进行了标记,计算机可以找出使一个数据匹配特定标签、另一个数据匹配不同标签的规则。

那么这对于我们的活动检测场景会如何工作呢?嗯,我们可以查看所有关于此人提供数据的传感器。如果他们有一个可穿戴设备可以检测诸如心率、位置、速度等信息—并且如果我们在他们进行不同活动时收集了大量这些数据的实例—我们最终会得到一种数据描述“这是走路的样子”,“这是跑步的样子”,诸如此类 (图 1-10)。

从编码到机器学习:收集和标记数据

图 1-10. 从编码到机器学习:收集和标记数据

现在我们作为程序员的工作从找出规则转变为确定活动,编写能够将数据与标签匹配的代码。如果我们能做到这一点,那么我们可以用代码实现更多的场景。机器学习是一种使我们能够做到这一点的技术,但是为了开始,我们需要一个框架—这就是 TensorFlow 登场的地方。在接下来的部分,我们将看看它是什么以及如何安装它,然后在本章后面的部分,您将编写第一个学习两个值之间模式的代码,就像在上一场景中一样。这是一个简单的“Hello World”场景,但它具有用于极为复杂的场景的相同基础代码模式。

人工智能领域广泛而抽象,涵盖了使计算机思考和行动方式与人类相似的所有内容。人类采用新行为的一种方式是通过示例学习。因此,机器学习学科可以被视为发展人工智能的入口。通过它,机器可以学会像人类一样看待事物(称为计算机视觉领域),阅读文本(自然语言处理),以及更多。在本书中,我们将介绍使用 TensorFlow 框架的机器学习基础知识。

什么是 TensorFlow?

TensorFlow 是一个用于创建和使用机器学习模型的开源平台。它实现了许多常见的机器学习算法和模式,使你不需要学习所有底层的数学和逻辑,从而能够专注于你的场景。它面向从业余爱好者、专业开发人员,以及推动人工智能边界的研究人员。重要的是,它还支持将模型部署到 Web、云、移动和嵌入式系统中。本书将涵盖这些场景中的每一个。

TensorFlow 的高级架构可以在图 1-11 中看到。

TensorFlow 高级架构

图 1-11. TensorFlow 高级架构

创建机器学习模型的过程称为训练。这是计算机使用一组算法来学习输入及其之间的区别的过程。例如,如果你想让计算机识别猫和狗,你可以使用大量猫和狗的图片来创建一个模型,计算机将使用该模型来尝试弄清楚是什么使得一只猫是猫,一只狗是狗。一旦模型训练好了,将其用于识别或分类未来输入的过程称为推断

因此,为了训练模型,你需要支持几件事情。首先是一组用于设计模型本身的 API。使用 TensorFlow 有三种主要方式来做到这一点:你可以手动编写所有代码,即你自己找出计算机学习的逻辑然后实现在代码中(不推荐);你可以使用内置的估计器,这些是已经实现的神经网络,你可以自定义;或者你可以使用 Keras,这是一个高级 API,允许你在代码中封装常见的机器学习范例。本书主要将关注在创建模型时使用 Keras API。

有许多方法可以训练模型。大多数情况下,您可能只会使用单个芯片,无论是中央处理单元(CPU)、图形处理单元(GPU)还是一种称为张量处理单元(TPU)的新型设备。在更高级的工作和研究环境中,可以使用跨多个芯片的并行训练,使用分布策略跨多个芯片进行训练。TensorFlow 也支持这种方式。

任何模型的生命线是其数据。正如之前讨论的那样,如果您想创建一个能够识别猫和狗的模型,它需要使用大量猫和狗的示例进行训练。但是您如何管理这些示例呢?随着时间的推移,您会发现,这往往比模型本身的创建需要更多的编码工作。TensorFlow 提供了 API 来尝试简化这个过程,称为 TensorFlow 数据服务。为了学习,它们包括许多预处理的数据集,您可以用一行代码就可以使用。它们还为您提供处理原始数据以便更容易使用的工具。

除了创建模型外,您还需要能够将它们交给用户使用。为此,TensorFlow 包含了用于服务的 API,您可以通过 HTTP 连接为云或 Web 用户提供模型推断。为了在移动设备或嵌入式系统上运行模型,TensorFlow 提供了 TensorFlow Lite,该工具支持在 Android 和 iOS 上以及基于 Linux 的嵌入式系统(如树莓派)上进行模型推断。TensorFlow Lite 的一个分支称为 TensorFlow Lite Micro(TFLM),也提供了对微控制器的推断,通过一个名为 TinyML 的新兴概念。最后,如果您想为浏览器或 Node.js 用户提供模型,TensorFlow.js 可以训练和执行模型。

接下来,我将向你展示如何安装 TensorFlow,以便您可以开始创建和使用 ML 模型。

使用 TensorFlow

在本节中,我们将介绍三种主要的安装和使用 TensorFlow 的方法。首先,我们将讲解如何在开发者框中使用命令行安装它。然后,我们将探讨如何使用流行的 PyCharm 集成开发环境(IDE)安装 TensorFlow。最后,我们将看看 Google Colab 以及如何在浏览器中使用基于云的后端访问 TensorFlow 代码。

在 Python 中安装 TensorFlow

TensorFlow 支持使用多种语言创建模型,包括 Python、Swift、Java 等。在本书中,我们将专注于使用 Python,因为它由于对数学模型的广泛支持而成为机器学习的事实标准语言。如果您还没有 Python,我强烈建议您访问Python来快速上手,以及learnpython.org来学习 Python 语法。

对于 Python,有许多安装框架的方法,但 TensorFlow 团队支持的默认方法是使用pip

因此,在您的 Python 环境中,安装 TensorFlow 就像使用以下命令一样简单:

pip install tensorflow

请注意,从 2.1 版本开始,默认安装 TensorFlow 的是 GPU 版本。在此之前,使用的是 CPU 版本。因此,在安装之前,请确保您有一个支持的 GPU 和所有必需的驱动程序。有关详细信息,请参阅TensorFlow

如果您没有所需的 GPU 或驱动程序,仍然可以在任何 Linux、PC 或 Mac 上安装 TensorFlow 的 CPU 版本:

pip install tensorflow-cpu

一旦您开始运行,可以使用以下代码测试您的 TensorFlow 版本:

import tensorflow as tf
print(tf.__version__)

您应该看到与图 1-12 类似的输出。这将打印当前运行的 TensorFlow 版本——在这里,您可以看到安装的版本为 2.0.0。

在 Python 中运行 TensorFlow

图 1-12. 在 Python 中运行 TensorFlow

在 PyCharm 中使用 TensorFlow

我特别喜欢使用PyCharm 的免费社区版本来构建使用 TensorFlow 的模型。PyCharm 有许多有用之处,但我最喜欢的之一是它简化了虚拟环境的管理。这意味着您可以拥有与特定项目相关的工具版本(例如 TensorFlow 的不同版本)的 Python 环境。因此,例如,如果您想在一个项目中使用 TensorFlow 2.0,而在另一个项目中使用 TensorFlow 2.1,您可以通过虚拟环境将它们分开,并且在切换时无需处理安装/卸载依赖项。此外,使用 PyCharm,您可以对您的 Python 代码进行逐步调试——这对于刚开始使用的人尤为重要。

例如,在图 1-13 中,我有一个名为 example1 的新项目,并且我指定要使用 Conda 创建一个新环境。当我创建项目时,我将拥有一个干净的新虚拟 Python 环境,可以在其中安装任何我想要的 TensorFlow 版本。

使用 PyCharm 创建新的虚拟环境

图 1-13. 使用 PyCharm 创建新的虚拟环境

创建项目后,您可以打开“文件”→“设置”对话框,并从左侧菜单中选择“项目:”条目。然后,您将看到更改项目解释器和项目结构设置的选项。选择“项目解释器”链接,您将看到您正在使用的解释器,以及安装在该虚拟环境中的包的列表(图 1-14)。

向虚拟环境添加包

图 1-14. 向虚拟环境添加包

点击右侧的 + 按钮,会弹出对话框显示当前可用的包。在搜索框中输入“tensorflow”,您将看到所有包含“tensorflow”名称的可用包(Figure 1-15)。

使用 PyCharm 安装 TensorFlow

Figure 1-15. 使用 PyCharm 安装 TensorFlow

一旦选择了 TensorFlow 或任何其他要安装的包,并点击安装包按钮,PyCharm 将完成剩下的工作。

一旦安装了 TensorFlow,您现在可以在 Python 中编写和调试 TensorFlow 代码了。

在 Google Colab 中使用 TensorFlow

另一个选项,也许是最简单的入门方法,是使用Google Colab,这是一个托管的 Python 环境,您可以通过浏览器访问。Colab 的真正好处在于它提供 GPU 和 TPU 后端,因此您可以免费使用先进硬件来训练模型。

当访问 Colab 网站时,您将有选项打开之前的 Colab 或开始一个新的笔记本,如 Figure 1-16 所示。

开始使用 Google Colab

Figure 1-16. 开始使用 Google Colab

点击“New Python 3 Notebook”链接将打开编辑器,在这里你可以添加代码或文本窗格(Figure 1-17)。你可以通过点击左边窗格的播放按钮(箭头)来执行代码。

在 Colab 中运行 TensorFlow 代码

Figure 1-17. 在 Colab 中运行 TensorFlow 代码

始终建议检查 TensorFlow 的版本,如此处所示,以确保您运行的是正确的版本。通常情况下,Colab 内置的 TensorFlow 版本可能比最新版本要旧一两个版本。如果是这种情况,您可以像之前展示的那样,通过使用如下代码块来更新它:

!pip install tensorflow==2.1.0

一旦运行此命令,您当前在 Colab 中的环境将使用所需版本的 TensorFlow。

机器学习入门

正如我们在本章早些时候看到的那样,机器学习范式是一种在其中有数据,数据被标记,然后你想找出将数据与标签匹配的规则的场景。展示这种情况的最简单的代码可能是这样的。考虑这两组数字:

X = –1, 0, 1, 2, 3, 4
Y = –3, –1, 1, 3, 5, 7

X 和 Y 值之间存在关系(例如,如果 X 是 –1,则 Y 是 –3,如果 X 是 3,则 Y 是 5,等等)。你能看出来吗?

几秒钟后,你可能会发现这里的模式是 Y = 2X – 1. 如何得出这个结果?不同的人有不同的方法,但我通常听到的观察是 X 在其序列中增加 1,而 Y 增加 2;因此,Y = 2X +/– 一些东西。然后他们看 X = 0 时 Y = –1,所以他们推测答案可能是 Y = 2X – 1. 接下来他们看其他数值,发现这个假设“符合”,答案就是 Y = 2X – 1。

这与机器学习过程非常相似。让我们看一下一些 TensorFlow 代码,你可以编写一个神经网络来帮你完成这个过程。

这是使用 TensorFlow Keras API 的完整代码。如果现在还不明白,不要担心;我们会逐行解释:

import tensorflow as tf
import numpy as np
from tensorflow.keras import Sequential
from tensorflow.keras.layers import Dense

model = Sequential([Dense(units=1, input_shape=[1])])
model.compile(optimizer='sgd', loss='mean_squared_error')

xs = np.array([-1.0, 0.0, 1.0, 2.0, 3.0, 4.0], dtype=float)
ys = np.array([-3.0, -1.0, 1.0, 3.0, 5.0, 7.0], dtype=float)

model.fit(xs, ys, epochs=500)

print(model.predict([10.0]))

让我们从第一行开始。你可能听说过神经网络,并且可能看到过用互连神经元层解释它们的图示,有点像图 1-18。

一个典型的神经网络

图 1-18. 一个典型的神经网络

当你看到这样的神经网络时,请将每个“圆圈”视为神经元,并将每个圆圈列视为。因此,在图 1-18 中,有三层:第一层有五个神经元,第二层有四个,第三层有两个。

如果我们回顾我们的代码,只看第一行,我们将看到我们在定义最简单可能的神经网络。只有一个层,它只包含一个神经元:

model = Sequential([Dense(units=1, input_shape=[1])])

在使用 TensorFlow 时,你使用 Sequential 定义你的层。在 Sequential 内部,你然后指定每一层的样子。我们只有一行在我们的 Sequential 内部,所以我们只有一层。

然后,你使用 keras.layers API 定义层的样子。有很多不同类型的层,但在这里我们使用了 Dense 层。Dense意味着一组完全(或密集)连接的神经元,这就是你在图 1-18 中看到的,其中每个神经元与下一层中的每个神经元连接。这是最常见的层类型。我们的 Dense 层指定了 units=1,所以我们在整个神经网络中只有一个密集层和一个神经元。最后,当你指定神经网络的第一层(在这种情况下,它是我们唯一的层)时,你必须告诉它输入数据的形状是什么。在这种情况下,我们的输入数据是我们的 X,它只是一个单一的值,所以我们指定它的形状。

接下来的一行是真正有趣的开始。让我们再看一遍:

model.compile(optimizer='sgd', loss='mean_squared_error')

如果你之前有机器学习的经验,你可能看到它涉及大量的数学。如果你多年没做过微积分,它可能看起来像是个门槛。这是数学涉及的部分——它是机器学习的核心。

在这种情况下,计算机完全不知道X 和 Y 之间的关系。因此它会猜测。例如,它猜测 Y = 10X + 10。然后它需要衡量这个猜测是好是坏。这就是损失函数的工作。

当 X 为–1, 0, 1, 2, 3 和 4 时,它已经知道答案,因此损失函数可以将这些与猜测关系的答案进行比较。如果它猜测 Y = 10X + 10,则当 X 为–1 时,Y 将为 0。那里的正确答案是–3,所以有点偏离。但当 X 为 4 时,猜测答案是 50,而正确答案是 7。那相差甚远。

掌握了这些知识后,计算机就可以进行另一次猜测。这就是优化器的工作。这是使用大量微积分的地方,但是使用 TensorFlow,这些可以对你隐藏起来。你只需选择适合不同情景的优化器来使用。在这种情况下,我们选择了一个称为sgd的优化器,即随机梯度下降——一种复杂的数学函数,当给定值、先前的猜测以及计算出的误差(或损失)时,可以生成另一个值。随着时间的推移,它的工作是最小化损失,并通过这种方式使猜测的公式越来越接近正确答案。

接下来,我们只需将我们的数字格式化为层所期望的数据格式。在 Python 中,有一个名为 Numpy 的库可以被 TensorFlow 使用,在这里我们将我们的数字放入一个 Numpy 数组中,以便于对它们进行处理:

xs = np.array([-1.0, 0.0, 1.0, 2.0, 3.0, 4.0], dtype=float)
ys = np.array([-3.0, -1.0, 1.0, 3.0, 5.0, 7.0], dtype=float)

然后学习过程将以model.fit命令开始,就像这样:

model.fit(xs, ys, epochs=500)

您可以将其理解为“将 X 适配到 Y,并尝试 500 次”。因此,在第一次尝试时,计算机将猜测关系(例如 Y = 10X + 10),并测量该猜测的好坏程度。然后将这些结果馈送给优化器,优化器将生成另一个猜测。这个过程将重复进行,逻辑是随着时间的推移,损失(或错误)将逐渐减少,结果“猜测”将变得越来越好。

图 1-19 展示了在 Colab 笔记本中运行时的截图。看看随时间变化的损失值。

训练神经网络

图 1-19。训练神经网络

我们可以看到,在前 10 个时期内,损失从 3.2868 降到了 0.9682。也就是说,仅经过 10 次尝试,网络的性能比初始猜测好了三倍。接下来看看在第 500 个时期时会发生什么(图 1-20)。

训练神经网络——最后五个时期

图 1-20。训练神经网络——最后五个时期

现在我们可以看到损失为 2.61 × 10^(-5)。损失已经变得非常小,以至于模型几乎已经弄清楚了数字之间的关系是 Y = 2X - 1。机器已经学会了它们之间的模式。

我们的最后一行代码然后使用训练好的模型来进行类似这样的预测:

print(model.predict([10.0]))
注意

预测 一词通常在处理 ML 模型时使用。不过不要把它看作是对未来的预测!之所以使用这个术语,是因为我们处理了一定程度的不确定性。回想一下我们之前讨论的活动检测场景。当人以某个速度移动时,她很 可能 是在走路。同样地,当模型学习到两者之间的模式时,它会告诉我们答案 可能 是什么。换句话说,它正在 预测 答案。 (稍后你还将了解 推理,在那里模型从多个答案中选择一个,并 推断 它已经选择了正确的答案。)

当我们要求模型预测 X 为 10 时,你认为答案会是什么?你可能会立即想到 19,但那是不正确的。它将选择一个与 19 非常接近 的值。这有几个原因。首先,我们的损失不是 0,它仍然是一个非常小的数量,因此我们应该期望任何预测答案都会有一个非常小的偏差。其次,神经网络仅基于少量数据进行了训练——在这种情况下只有六对 (X,Y) 值。

该模型中只有一个神经元,该神经元学习了一个 权重 和一个 偏差,使得 Y = WX + B。这看起来完全像我们想要的关系 Y = 2X – 1,其中我们希望它学到 W = 2 和 B = –1. 考虑到该模型仅使用六项数据进行了训练,不可能期望答案恰好是这些值,但会非常接近它们。

运行代码以查看你会得到什么结果。我在运行时得到的是 18.977888,但你的答案可能略有不同,因为当神经网络首次初始化时存在随机元素:你的初始猜测将与我的略有不同,也会与第三个人的不同。

查看网络学到了什么

显然,这是一个非常简单的情景,我们在其中匹配 X 到 Y 在线性关系中。如前所述,神经元有权重和偏差参数,它们学习使单个神经元能够很好地学习到这样的关系:即当 Y = 2X – 1 时,权重为 2,偏差为 –1. 使用 TensorFlow,我们实际上可以查看学到的权重和偏差,只需简单修改我们的代码如下:

import tensorflow as tf
import numpy as np
from tensorflow.keras import Sequential
from tensorflow.keras.layers import Dense

`l0` `=` `Dense``(``units``=``1``,` `input_shape``=``[``1``]``)`
model = Sequential([l0])
model.compile(optimizer='sgd', loss='mean_squared_error')

xs = np.array([-1.0, 0.0, 1.0, 2.0, 3.0, 4.0], dtype=float)
ys = np.array([-3.0, -1.0, 1.0, 3.0, 5.0, 7.0], dtype=float)

model.fit(xs, ys, epochs=500)

print(model.predict([10.0]))
`print``(``"``Here is what I learned: {}``"``.``format``(``l0``.``get_weights``(``)``)``)`

不同之处在于我创建了一个名为 l0 的变量来保存 Dense 层。然后,在网络完成学习后,我可以打印出该层学到的值(或权重)。

在我的案例中,输出如下所示:

Here is what I learned: [array([[1.9967953]], dtype=float32), 
array([-0.9900647], dtype=float32)]

因此,X 和 Y 之间学到的关系是 Y = 1.9967953X – 0.9900647。

这相当接近我们预期的结果(Y = 2X – 1),我们可以认为它甚至更接近现实,因为我们 假设 这种关系将适用于其他值。

总结

这就是你的第一个机器学习“Hello World”了。你可能会觉得,对于确定两个值之间的线性关系来说,这似乎有些杀鸡用牛刀。你的想法没错。但酷的是,我们在这里创建的代码模式,也是用于更复杂场景的相同模式。你将在第二章中看到这些,我们将探索一些基本的计算机视觉技术——机器将学会“看”图片中的模式,并识别其中的内容。

第二章:介绍计算机视觉

上一章介绍了机器学习的基础知识。你看到了如何使用神经网络开始编程,将数据与标签匹配,以及从那里推断可以用来区分物品的规则。一个逻辑的下一步是将这些概念应用到计算机视觉中,在那里我们将让一个模型学习如何识别图片中的内容,以便它可以“看到”其中的内容。在这一章中,你将使用一个流行的服装项目数据集构建一个模型,该模型能够区分它们之间的差异,从而“看到”不同类型的服装。

识别服装项目

对于我们的第一个例子,让我们考虑如何在图像中识别服装。例如,考虑图 2-1 中的物品。

服装示例

图 2-1. 服装示例

这里有许多不同的服装项目,你可以识别它们。你理解什么是衬衫,或者外套,或者连衣裙。但是如果要向从未见过服装的人解释这些,你该如何做?鞋子呢?这张图片中有两只鞋子,但你该如何向别人描述呢?这也是我们在第一章中谈到的基于规则的编程可能遇到困难的另一个领域。有时用规则描述某些事物是不可行的。

当然,计算机视觉也不例外。但考虑一下你是如何学会识别所有这些物品的——通过看到许多不同的例子,并且获得它们使用方式的经验。我们能否用同样的方法来训练计算机呢?答案是肯定的,但有一定的限制。让我们首先看一个例子,介绍如何教会计算机识别服装,使用一个名为 Fashion MNIST 的知名数据集。

数据:Fashion MNIST

对于学习和基准算法的基础数据集之一是由 Yann LeCun、Corinna Cortes 和 Christopher Burges 创建的 Modified National Institute of Standards and Technology(MNIST)数据库。这个数据集包含 70,000 张 0 到 9 的手写数字图像。图像为 28 × 28 灰度图像。

Fashion MNIST 被设计成 MNIST 的一个直接替代品,它具有相同数量的记录,相同的图像尺寸和相同数量的类别——因此,与数字 0 到 9 的图像不同,Fashion MNIST 包含 10 种不同类型的服装的图像。你可以在图 2-2 中看到数据集内容的示例。在这里,每种服装项目类型都有三行。

探索 Fashion MNIST 数据集

图 2-2. 探索 Fashion MNIST 数据集

它有各种不错的服装,包括衬衫、裤子、连衣裙和各种类型的鞋子。正如您可能注意到的,它是单色的,因此每张图片由一定数量的像素组成,其值在 0 到 255 之间。这使得数据集更容易管理。

您可以看到数据集中特定图像的特写在图 2-3 中。

Fashion MNIST 数据集中图像的特写

图 2-3. Fashion MNIST 数据集中的图像特写

就像任何图像一样,它是像素的矩形网格。在这种情况下,网格大小为 28 × 28,每个像素只是一个值,其范围在 0 到 255 之间,如前所述。现在让我们看看如何将这些像素值与我们之前看到的函数结合起来使用。

视觉神经元

在第 1 章中,您看到了一个非常简单的情景,其中一台机器获得了一组 X 和 Y 值,并且学会了它们之间的关系是 Y = 2X - 1。这是通过一个非常简单的只有一层和一个神经元的神经网络完成的。

如果您将其以图形方式绘制出来,它可能看起来像图 2-4。

每张图像都是一组 784 个值(28 × 28),其值在 0 到 255 之间。它们可以是我们的 X。我们知道我们的数据集中有 10 种不同类型的图像,所以让我们把它们视为我们的 Y。现在我们想学习当 Y 是 X 的函数时函数的样子。

单个神经元学习线性关系

图 2-4. 单个神经元学习线性关系

鉴于每个图像有 784 个 X 值,并且我们的 Y 将在 0 到 9 之间,很显然我们不能像之前那样做 Y = mX + c。

但是我们可以让多个神经元一起工作。每个神经元将学习参数,当我们将所有这些参数的组合函数一起工作时,我们可以看到我们是否能将这种模式匹配到我们想要的答案(图 2-5)。

扩展我们的模式以获取更复杂的例子

图 2-5. 扩展我们的模式以获取更复杂的例子

此图表顶部的框可以视为图像中的像素,或者我们的 X 值。当我们训练神经网络时,我们将这些加载到一层神经元中——图 2-5 显示它们只加载到第一个神经元中,但值加载到每个神经元中。考虑每个神经元的权重和偏置(m 和 c)是随机初始化的。然后,当我们总结每个神经元输出的值时,我们将得到一个值。这将对输出层中的每个神经元执行,因此神经元 0 将包含像素累加到标签 0 的概率值,神经元 1 对应标签 1,依此类推。

随着时间的推移,我们希望将该值与所需的输出值匹配——对于这幅图像,我们可以看到其标签是数字 9,即展示在图 2-3 中的脚踝靴。换句话说,这个神经元应该是所有输出神经元中值最大的一个。

鉴于有 10 个标签,随机初始化应该能在大约 10%的时间内得到正确答案。从那里,损失函数和优化器可以在每个时代逐步调整每个神经元的内部参数,以改进这 10%。因此,随着时间的推移,计算机将学会“看到”是什么使鞋子成为鞋子或服装成为服装。

设计神经网络

现在让我们看看这在代码中是什么样子。首先,我们将看看在图 2-5 中展示的神经网络的设计:

model = keras.Sequential([
    keras.layers.Flatten(input_shape=(28, 28)),
    keras.layers.Dense(128, activation=tf.nn.relu),
    keras.layers.Dense(10, activation=tf.nn.softmax)
])

如果你记得,在第一章中,我们有一个Sequential模型来指定我们有许多层。它只有一层,但在这种情况下,我们有多个层。

第一个Flatten不是一个神经元层,而是一个输入层规范。我们的输入是 28 × 28 的图像,但我们希望它们被视为一系列数值,就像图 2-5 顶部的灰色框中的那样。Flatten将那个“方形”值(一个 2D 数组)转换成一条线(一个 1D 数组)。

接下来的Dense是一个神经元层,我们正在指定我们想要 128 个神经元。这是在图 2-5 中展示的中间层。你经常会听到这样的层被描述为隐藏层。位于输入和输出之间的层对调用者是不可见的,因此术语“隐藏”用于描述它们。我们请求 128 个神经元以随机初始化其内部参数。通常在这一点上我会被问到的问题是“为什么是 128?”这完全是任意的——没有固定的神经元数量规则。在设计层时,您需要选择适当数量的值以使您的模型真正学习。更多的神经元意味着它会运行得更慢,因为它必须学习更多的参数。更多的神经元也可能导致网络非常擅长识别训练数据,但在识别以前没有见过的数据时可能不那么好(这称为过拟合,我们稍后在本章中讨论)。另一方面,更少的神经元意味着模型可能没有足够的参数来学习。

需要一些时间的实验来选择正确的值。这个过程通常被称为超参数调整。在机器学习中,超参数是用来控制训练的值,而不是被训练/学习的神经元的内部值,这些被称为参数。

您可能注意到该层还指定了一个激活函数。激活函数是在该层中每个神经元上执行的代码。TensorFlow 支持多种激活函数,但在中间层中非常常见的一种是relu,即修正线性单元。它是一个简单的函数,如果大于 0 则返回该值。在这种情况下,我们不希望负值传递到下一层,可能影响求和函数,所以我们可以简单地使用relu激活该层,而不是编写大量的if-then代码。

最后,还有另一个Dense层,这是输出层。这有 10 个神经元,因为我们有 10 个类别。这些神经元每个都将得到一个概率,即输入像素匹配该类别,所以我们的任务是确定哪一个具有最高的值。我们可以循环遍历它们来选择那个值,但softmax激活函数会为我们完成这个任务。

因此,现在当我们训练我们的神经网络时,目标是我们可以输入一个 28×28 像素数组,中间层的神经元将有权重和偏置(m 和 c 值),当组合时将这些像素匹配到 10 个输出值中的一个。

完整的代码

现在我们已经探讨了神经网络的架构,让我们来看看用 Fashion MNIST 数据训练的完整代码:

import tensorflow as tf
data = tf.keras.datasets.fashion_mnist

(training_images, training_labels), (test_images, test_labels) = data.load_data()

training_images  = training_images / 255.0
test_images = test_images / 255.0

model = tf.keras.models.Sequential([
            tf.keras.layers.Flatten(input_shape=(28, 28)),
            tf.keras.layers.Dense(128, activation=tf.nn.relu),
            tf.keras.layers.Dense(10, activation=tf.nn.softmax)
        ])

model.compile(optimizer='adam',
              loss='sparse_categorical_crossentropy',
              metrics=['accuracy'])

model.fit(training_images, training_labels, epochs=5)

让我们逐步走过这段文字。首先是一个方便的快捷方式来访问数据:

data = tf.keras.datasets.fashion_mnist

Keras 有许多内置数据集,您可以像这样用一行代码访问。在这种情况下,您不必处理下载 70,000 张图片、将它们分割为训练和测试集等问题,只需一行代码就可以搞定。这种方法已经得到了改进,使用一个名为TensorFlow 数据集的 API,但在这些早期章节中,为了减少您需要学习的新概念数量,我们将仅使用tf.keras.datasets

我们可以调用它的load_data方法来返回我们的训练集和测试集,就像这样:

(training_images, training_labels), 
(test_images, test_labels) = data.load_data()

Fashion MNIST 被设计为有 60,000 张训练图像和 10,000 张测试图像。所以,从data.load_data返回的将是一个包含 60,000 个 28×28 像素数组的training_images数组,以及一个包含 60,000 个值(0-9)的training_labels数组。类似地,test_images数组将包含 10,000 个 28×28 像素数组,而test_labels数组将包含 10,000 个值,范围在 0 到 9 之间。

我们的任务将是以类似的方式将训练图像适配到训练标签,就像我们在第一章中将 Y 适配到 X 一样。

我们将保留测试图像和测试标签,这样网络在训练时不会看到它们。这些可以用来测试网络在之前未见数据上的有效性。

接下来的几行代码可能看起来有点不寻常:

training_images  = training_images / `255.0`
test_images = test_images / `255.0`

Python 允许您使用此表示在整个数组上执行操作。请记住,我们图像中的所有像素都是灰度的,值在 0 到 255 之间。因此,除以 255 可确保每个像素由一个在 0 到 1 之间的数字表示。这个过程称为归一化图像。

为什么归一化数据对训练神经网络更好的数学原理超出了本书的范围,但请记住,在 TensorFlow 中训练神经网络时,归一化将改善性能。通常情况下,处理非归一化数据时,您的网络将无法学习,并且将会出现严重的错误。从第一章中的 Y = 2X - 1 示例可以看出,该数据不需要进行归一化,因为它非常简单,但是尝试使用 X 值不同的不同 Y 值进行训练,您将会看到它迅速失败!

接下来我们定义组成我们模型的神经网络,如前所述:

model = tf.keras.models.`Sequential`([
            tf.keras.layers.`Flatten`(input_shape=(`28`, `28`)),
            tf.keras.layers.`Dense`(`128`, activation=tf.nn.relu),
            tf.keras.layers.`Dense`(`10`, activation=tf.nn.softmax)
        ])

当我们编译我们的模型时,我们像以前一样指定损失函数和优化器:

 model.compile(optimizer=`'``adam``'`,
              loss=`'``sparse_categorical_crossentropy``'`,
              metrics=[`'``accuracy``'`])

在这种情况下,损失函数称为稀疏分类交叉熵,它是内置于 TensorFlow 中的损失函数库中的一员。再次选择使用哪种损失函数本身就是一门艺术,随着时间的推移,您将学会在哪些场景中使用最佳。这个模型与我们在第一章中创建的模型之间的一个主要区别是,这里不是我们试图预测一个单一的数字,而是我们正在选择一个类别。我们的服装物品将属于 10 个服装类别之一,因此使用分类损失函数是正确的选择。稀疏分类交叉熵是一个不错的选择。

选择优化器也是如此。adam 优化器是随机梯度下降 (sgd) 优化器的进化版,已被证明更快更高效。由于我们处理 60,000 张训练图像,我们能获得的任何性能提升都将是有帮助的,所以这里选择了它。

您可能会注意到代码中还有一行新的指定我们要报告的指标。在这里,我们想要报告网络的准确性,因为我们正在训练。在第一章中的简单示例只报告了损失,我们通过减少损失来解释网络正在学习。在这种情况下,更有用的是查看网络如何学习,即它将返回正确匹配输入像素与输出标签的频率。

接下来,我们将通过五个周期将训练图像拟合到训练标签来训练网络:

model.fit(training_images, training_labels, epochs=`5`)

最后,我们可以做一些新的事情——使用一行代码来评估模型。我们有一组 10,000 张图像和测试标签,可以将它们传递给训练好的模型,让它预测每张图像的内容,并将其与实际标签进行比较,然后汇总结果:

model.evaluate(test_images, test_labels)

训练神经网络

执行代码,你将看到网络逐个 epoch 进行训练。在运行训练后,你将看到类似于以下内容的结果:

`58016`/`60000` [=====>.] - ETA: `0``s` - loss: `0.2941` - accuracy: `0.8907`
`59552`/`60000` [=====>.] - ETA: `0``s` - loss: `0.2943` - accuracy: `0.8906`
`60000`/`60000` [] - `2``s` `34``us`/sample - loss: `0.2940` - accuracy: `0.8906`

请注意,现在它报告准确率。所以在这种情况下,使用训练数据,我们的模型在只经过五个 epochs 后的准确率约为 89%。

但是测试数据呢?在我们的测试数据上执行model.evaluate的结果会看起来像这样:

`10000`/`1` [====] - `0``s` `30``us`/sample - loss: `0.2521` - accuracy: `0.8736`

在这种情况下,模型的准确率为 87.36%,考虑到我们只训练了五个 epochs,这还算不错。

你可能会想为什么测试数据的准确率低于训练数据。这是非常常见的现象,仔细想想就明白了:神经网络实际上只知道如何将其训练过的输入与相应的输出进行匹配。我们希望,如果提供足够的数据,它能够从所见的例子中进行泛化,“学习”出鞋子或裙子的外观。但总会有一些它从未见过的与其所知不同的例子会让它感到困惑。

例如,如果你的成长经历中只见过运动鞋,那对你来说运动鞋就是鞋子的样子,当你第一次看到高跟鞋时可能会感到有些困惑。从你的经验来看,它可能是一只鞋,但你并不确定。这是一个类似的概念。

探索模型输出

现在模型已经训练好了,我们通过测试集也有了它的准确度,让我们来稍微探索一下它:

classifications = model.predict(test_images)
`print`(classifications[`0`])
`print`(test_labels[`0`])

我们将通过model.predict传递测试图像来获得一组分类。然后,让我们看看如果我们打印出第一个分类结果并将其与测试标签进行比较会得到什么:

`[`1.9177722e-05  1.9856788e-07  6.3756357e-07  7.1702580e-08  5.5287035e-07
 1.2249852e-02  6.0708484e-05  7.3229447e-02  8.3050705e-05  9.1435629e-01`]`
9

你会注意到分类结果返回给我们一个值数组。这些是 10 个输出神经元的值。标签是衣物的实际标签,本例中是9。浏览一下数组——你会看到一些值非常小,而最后一个值(数组索引 9)远远最大。这些是图像与特定索引处标签匹配的概率。所以,神经网络报告的是图像在索引 0 处是标签 9 的概率为 91.4%。我们知道它是标签 9,所以它预测正确了。

试试不同的值,看看模型哪里预测错误。

更长时间的训练——发现过拟合

在这种情况下,我们只训练了五个 epochs。也就是说,我们通过整个训练循环,神经元被随机初始化,根据它们的标签进行检查,通过损失函数来衡量性能,然后由优化器更新了五次。我们得到的结果非常不错:训练集准确率为 89%,测试集准确率为 87%。那么,如果我们训练更长时间会发生什么呢?

尝试将其更新为训练 50 个 epochs 而不是 5 个。在我的情况下,我在训练集上得到了这些准确率数据:

`58112`/`60000` [==>.] - ETA: `0``s` - loss: `0.0983` - accuracy: `0.9627`
`59520`/`60000` [==>.] - ETA: `0``s` - loss: `0.0987` - accuracy: `0.9627`
`60000`/`60000` [====] - `2``s` `35``us`/sample - loss: `0.0986` - accuracy: `0.9627`

这特别令人兴奋,因为我们的表现要好得多:96.27% 的准确率。对于测试集,我们达到了 88.6%:

[====] - `0``s` `30``us`/sample - loss: `0.3870` - accuracy: `0.8860`

所以,我们在训练集上有了很大的改进,而在测试集上只有小幅改进。这可能表明,如果我们训练我们的网络更长时间,结果会更好——但并非总是如此。网络在训练数据上表现更好,但不一定是一个更好的模型。事实上,准确率数字的分歧表明它已经过度专门化于训练数据,这个过程通常称为 过拟合。在构建更多神经网络时,这是需要注意的问题,而在你阅读本书的过程中,你将学习到一些避免这种情况的技巧。

停止训练

到目前为止,在每种情况下,我们都硬编码了我们训练的 epochs 数量。虽然这样做是有效的,但我们可能希望训练直到达到期望的准确率,而不是不断尝试不同的 epochs 数量,重新训练直到达到我们想要的值。例如,如果我们想要在训练集上达到 95% 的准确率而不知道需要多少 epochs,我们该怎么做?

最简单的方法是在训练上使用 callback。让我们看看使用回调函数的更新代码:

`import` tensorflow `as` tf

`class` myCallback(tf.keras.callbacks.`Callback`):
  `def` on_epoch_end(self, epoch, logs={}):
    `if`(logs.get(`'``accuracy``'`)>`0.95`):
      `print`(`"``\n``Reached 95``%` `accuracy so cancelling training!``"`)
      self.model.stop_training = `True`

callbacks = myCallback()
mnist = tf.keras.datasets.fashion_mnist

(training_images, training_labels), 
(test_images, test_labels) = mnist.load_data()

training_images=training_images/`255.0`
test_images=test_images/`255.0`

model = tf.keras.models.`Sequential`([
        tf.keras.layers.`Flatten`(),
        tf.keras.layers.`Dense`(`128`, activation=tf.nn.relu),
	    tf.keras.layers.`Dense`(`10`, activation=tf.nn.softmax)
])

model.compile(optimizer=`'``adam``'`, 
               loss=`'``sparse_categorical_crossentropy``'`, 
               metrics=[`'``accuracy``'`])

 model.fit(training_images, training_labels, epochs=`50`, 
           callbacks=[callbacks])

让我们看看这里发生了什么变化。首先,我们创建了一个名为 myCallback 的新类。它接受 tf.keras.callbacks.Callback 作为参数。在其中,我们定义了 on_epoch_end 函数,它将给我们提供这个 epoch 的日志详情。在这些日志中有一个准确率值,所以我们所要做的就是看它是否大于 0.95(或 95%);如果是,我们可以通过设置 self.model.stop_training = True 来停止训练。

一旦我们指定了这一点,我们创建一个 callbacks 对象,作为 myCallback 函数的一个实例。

现在看看 model.fit 语句。你会看到我已经更新为训练 50 个 epochs,并添加了一个 callbacks 参数。我将 callbacks 对象传递给它。

在训练过程中,每个 epoch 结束时,回调函数将被调用。所以在每个 epoch 结束后你会检查一下,在大约 34 个 epochs 后,你会看到你的训练结束了,因为训练已经达到了 95% 的准确率(由于初始随机初始化的不同,你的数字可能会有所不同,但它很可能非常接近 34):

`56896`/`60000` [====>..] - ETA: `0``s` - loss: `0.1309` - accuracy: `0.9500`
`58144`/`60000` [====>.] - ETA: `0``s` - loss: `0.1308` - accuracy: `0.9502`
`59424`/`60000` [====>.] - ETA: `0``s` - loss: `0.1308` - accuracy: `0.9502`
`Reached` `95`% accuracy so cancelling training!

摘要

在第一章中,你学习了机器学习是如何通过神经网络将特征与标签匹配来进行复杂的模式匹配。在这一章中,你将这一过程推向了更高的水平,超越了单个神经元,学会了如何创建你的第一个(非常基础的)计算机视觉神经网络。由于数据的限制,它有些受限。所有的图像都是 28 × 28 的灰度图像,服装物品居中在框架内。这是一个很好的开端,但这只是一个非常受控制的情景。要在视觉方面做得更好,我们可能需要计算机学习图像的特征,而不仅仅是原始像素。

我们可以通过一种叫做卷积的过程来实现这一点。在下一章中,你将学习如何定义卷积神经网络来理解图像的内容。

第三章:超越基础知识:检测图像中的特征

在第二章中,您学习了如何通过创建一个简单的神经网络,将 Fashion MNIST 数据集的输入像素与表示 10 种服装类型(或类别)的标签进行匹配,从而开始计算机视觉。虽然您创建的网络在检测服装类型方面相当不错,但显然也存在一个缺陷。您的神经网络是在小型单色图像上训练的,每个图像只包含一件服装,并且该服装位于图像的中心。

要将模型提升到下一个水平,您需要能够检测图像中的特征。例如,与其仅仅查看图像中的原始像素,如果我们能够有一种方法来将图像过滤成构成元素,会怎么样呢?匹配这些元素,而不是原始像素,将有助于更有效地检测图像的内容。考虑我们在上一章中使用的 Fashion MNIST 数据集——当检测到鞋子时,神经网络可能会被聚集在图像底部的大量暗像素激活,它会将其视为鞋子的鞋底。但是当鞋子不再居中且填满整个框架时,这种逻辑就不成立了。

检测特征的一种方法源自摄影和您可能熟悉的图像处理方法论。如果您曾使用过像 Photoshop 或 GIMP 这样的工具来增强图像,那么您正在使用一种对图像像素起作用的数学滤波器。这些滤波器的另一个名称是卷积,通过在神经网络中使用这些滤波器,您将创建一个卷积神经网络(CNN)。

在本章中,您将学习如何使用卷积来检测图像中的特征。然后,您将深入探讨基于图像内部特征分类图像。我们将探索增强图像以获取更多特征和迁移学习以利用他人学习的预先存在的特征,并简要探讨使用辍学来优化您的模型。

卷积

卷积简单来说是一组权重的滤波器,用于将一个像素与其邻居相乘,从而得到像素的新值。例如,考虑来自 Fashion MNIST 的踝靴图像及其像素值,如图 3-1 所示。

带有卷积的踝靴

图 3-1. 带有卷积的踝靴

如果我们看一下选择区域中间的像素,我们可以看到它的值为 192(请记住,Fashion MNIST 使用单色图像,像素值从 0 到 255)。上方和左侧的像素值为 0,正上方的像素值为 64,依此类推。

如果我们在同样的 3×3 网格中定义一个滤波器,如下所示的原始值下方,我们可以通过计算一个新值来转换该像素。我们通过将网格中每个像素的当前值乘以滤波器网格中相同位置的值,并将总和起来来实现这一点。这个总和将成为当前像素的新值。然后我们对图像中的所有像素重复此操作。

因此,在这种情况下,虽然选择中心像素的当前值为 192,但应用滤波器后的新值将为:

new_val = (-1 * 0) + (0 * 64) + (-2 * 128) + 
     (.5 * 48) + (4.5 * 192) + (-1.5 * 144) + 
     (1.5 * 142) + (2 * 226) + (-3 * 168)

这等于 577,这将成为该像素的新值。在图像的每个像素上重复此过程将给我们一个经过滤波的图像。

让我们考虑在更复杂的图像上应用滤波器的影响:内置于 SciPy 中用于简单测试的攀升图像。这是一幅 512×512 的灰度图像,显示两个人正在爬楼梯。

在左侧使用具有负值、右侧具有正值和中间为零的滤波器将会除去图像中的大部分信息,除了垂直线条,如您在图 3-2 中所见。

使用滤波器获取垂直线条

图 3-2。使用滤波器获取垂直线条

同样,对滤波器进行小的更改可以强调水平线条,如在图 3-3 中所示。

使用滤波器获取水平线条

图 3-3。使用滤波器获取水平线条

这些例子还显示图像中信息的减少,因此我们可以潜在地学习一组减少图像到特征的滤波器,并且这些特征可以像以前一样匹配标签。以前,我们学习了用于神经元中匹配输入与输出的参数。同样,可以随着时间的推移学习最佳的滤波器来匹配输入与输出。

当与池化结合使用时,我们可以减少图像中的信息量同时保持特征。我们接下来会探讨这一点。

池化

池化是在保持图像内容语义的同时消除图像中的像素的过程。最好通过视觉方式来解释。图 3-4 展示了最大池化的概念。

演示最大池化

图 3-4。演示最大池化

在这种情况下,将左侧的方框视为单色图像中的像素。然后,我们将它们分组为 2×2 的数组,因此在这种情况下,16 个像素被分成四个 2×2 的数组。这些被称为

然后,我们选择每个组中的最大值,并将它们重新组合成一个新的图像。因此,左侧的像素减少了 75%(从 16 到 4),每个池中的最大值构成了新图像。

图 3-5 展示了从图 3-2 获取的上升版本,在应用了最大池化后增强了垂直线条。

垂直滤波器和最大池化后的上升

图 3-5. 垂直滤波器和最大池化后的上升

注意过滤后的特征不仅得到了保留,而且进一步增强了。同时,图片的尺寸从 512 × 512 变为了 256 × 256——原始尺寸的四分之一。

注意

还有其他池化的方法,如min池化,它从池中取最小像素值,以及average池化,它取池中所有值的平均值。

实现卷积神经网络

在第二章中,你创建了一个识别时尚图片的神经网络。为了方便起见,这里是完整的代码:

`import` tensorflow `as` tf
data = tf.keras.datasets.fashion_mnist

(training_images, training_labels), (test_images, test_labels) = data.load_data()

training_images = training_images / `255.0`
test_images = test_images / `255.0`

model = tf.keras.models.`Sequential`([
      tf.keras.layers.`Flatten`(input_shape=(`28`, `28`)),
      tf.keras.layers.`Dense`(`128`, activation=tf.nn.relu),
      tf.keras.layers.`Dense`(`10`, activation=tf.nn.softmax)
    ])

model.compile(optimizer=`'``adam``'`,
       loss=`'``sparse_categorical_crossentropy``'`,
       metrics=[`'``accuracy``'`])

model.fit(training_images, training_labels, epochs=`5`)

要将这个转换为卷积神经网络,我们只需在模型定义中使用卷积层。我们还会添加池化层。

要实现卷积层,你将使用tf.keras.layers.Conv2D类型。这个类型接受多个参数,包括在层中使用的卷积数目、卷积的大小、激活函数等。

例如,这里是一个作为神经网络输入层的卷积层:

tf.keras.layers.`Conv2D`(`64`, (`3`, `3`), activation=`'``relu``'`, 
            input_shape=(`28`, `28`, `1`)),

在这种情况下,我们希望该层学习 64 个卷积。它将随机初始化这些卷积,随着时间的推移,会学习到最适合匹配输入值与标签的滤波值。(3, 3)指示了滤波器的大小。之前展示了 3 × 3 的滤波器,这里也是我们在指定的大小。这是最常见的滤波器大小;你可以根据需要进行更改,但通常会看到像 5 × 5 或 7 × 7 这样的奇数轴,因为滤波器会从图像的边缘移除像素,稍后你会看到这一点。

activationinput_shape参数与之前相同。由于我们在这个示例中使用 Fashion MNIST,因此形状仍然是 28 × 28。但请注意,由于Conv2D层设计用于多色彩图像,我们将第三个维度指定为 1,因此我们的输入形状是 28 × 28 × 1。彩色图像通常会将第三个参数设置为 3,因为它们存储为 R、G 和 B 的值。

这里展示了如何在神经网络中使用池化层。通常,在卷积层之后立即进行这样的操作:

tf.keras.layers.`MaxPooling2D`(`2`, `2`),

在图 3-4 的示例中,我们将图片分割成 2 × 2 的池,并且每个池中选择最大值。这个操作可以通过参数化来定义池的大小。这里展示的是池的参数——(2, 2)表示我们的池是 2 × 2 的。

现在让我们来探索使用 CNN 处理 Fashion MNIST 的完整代码:

`import` tensorflow `as` tf
data = tf.keras.datasets.fashion_mnist

(training_images, training_labels), (test_images, test_labels) = data.load_data()

training_images = training_images.reshape(`60000`, `28`, `28`, `1`)
training_images = training_images / `255.0`
test_images = test_images.reshape(`10000`, `28`, `28`, `1`)
test_images = test_images / `255.0`

model = tf.keras.models.`Sequential`([
      tf.keras.layers.`Conv2D`(`64`, (`3`, `3`), activation=`'``relu``'`, 
                  input_shape=(`28`, `28`, `1`)),
      tf.keras.layers.`MaxPooling2D`(`2`, `2`),
      tf.keras.layers.`Conv2D`(`64`, (`3`, `3`), activation=`'``relu``'`),
      tf.keras.layers.`MaxPooling2D`(`2`,`2`),
      tf.keras.layers.`Flatten`(),
      tf.keras.layers.`Dense`(`128`, activation=tf.nn.relu),
      tf.keras.layers.`Dense`(`10`, activation=tf.nn.softmax)
    ])

model.compile(optimizer=`'``adam``'`,
       loss=`'``sparse_categorical_crossentropy``'`,
       metrics=[`'``accuracy``'`])

model.fit(training_images, training_labels, epochs=`50`)

model.evaluate(test_images, test_labels)

classifications = model.predict(test_images)
`print`(classifications[`0`])
`print`(test_labels[`0`])

在这里有几点需要注意。还记得之前我提到过图像的输入形状必须与Conv2D层所期望的匹配,并且我们将其更新为一个 28 × 28 × 1 的图像吗?数据也必须相应地进行重新整形。28 × 28 是图像中的像素数量,1 是颜色通道的数量。通常情况下,灰度图像的颜色通道数为 1,彩色图像的颜色通道数为 3(红、绿、蓝),数字表示该颜色的强度。

因此,在归一化图像之前,我们还需要将每个数组重新整形以具有额外的维度。以下代码将我们的训练数据集从 60,000 个图像(每个 28 × 28,因此是一个 60,000 × 28 × 28 的数组)更改为 60,000 个图像,每个为 28 × 28 × 1:

training_images = training_images.reshape(`60000`, `28`, `28`, `1`)

我们然后对测试数据集执行相同的操作。

还要注意,在原始的深度神经网络(DNN)中,我们在将输入传递到第一个Dense层之前通过了一个Flatten层。但在这里的输入层中我们已经失去了这一点—而是直接指定了输入形状。请注意,在进行卷积和池化后,在Dense层之前,数据将被压平。

将这个网络与第第二章展示的网络在相同数据上进行相同的 50 个 epoch 训练,我们可以看到准确率显著提高。前一个例子在 50 个 epoch 中的测试集准确率达到了 89%,而这个例子在大约 24 或 25 个 epoch 中就能达到 99% 的准确率。因此我们可以看出,向神经网络添加卷积确实提高了其对图像进行分类的能力。接下来让我们看一下图像在网络中经历的过程,以便更好地理解其工作原理。

探索卷积网络

您可以使用 model.summary 命令来检查您的模型。当您在我们一直在工作的时尚 MNIST 卷积网络上运行它时,您将看到类似这样的输出:

Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape       Param # 
=================================================================
conv2d (Conv2D)              (None, 26, 26, 64) 640    
_________________________________________________________________
max_pooling2d (MaxPooling2D) (None, 13, 13, 64) 0     
_________________________________________________________________
conv2d_1 (Conv2D)            (None, 11, 11, 64) 36928   
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 5, 5, 64)   0     
_________________________________________________________________
flatten (Flatten)            (None, 1600)       0     
_________________________________________________________________
dense (Dense)                (None, 128)        204928  
_________________________________________________________________
dense_1 (Dense)              (None, 10)         1290   
=================================================================
Total params: 243,786
Trainable params: 243,786
Non-trainable params: 0

让我们首先看一下输出形状列,以了解这里发生了什么。我们的第一层将有 28 × 28 的图像,并对它们应用 64 个滤波器。但由于我们的滤波器是 3 × 3 的,图像周围将会丢失 1 个像素的边框,从而将我们的整体信息减少到 26 × 26 像素。考虑图 3-6。如果我们将每个方框看作图像中的像素,我们可以进行的第一个可能的滤波器从图表的第二行和第二列开始。在图表的右侧和底部也会发生同样的情况。

运行滤波器时丢失像素

图 3-6. 运行滤波器时丢失像素

因此,当一个 A × B 像素形状的图像通过一个 3 × 3 的滤波器时,其形状将变为 (A–2) × (B–2) 像素。同样地,一个 5 × 5 的滤波器会使其变为 (A–4) × (B–4) 像素,依此类推。由于我们使用的是一个 28 × 28 的图像和一个 3 × 3 的滤波器,我们的输出现在将是 26 × 26。

之后的池化层是 2 × 2,所以图像的尺寸将在每个轴上减半,然后变为(13 × 13)。接下来的卷积层将进一步减少至 11 × 11,然后池化,向下取整,将使图像变为 5 × 5。

因此,当图像通过两个卷积层后,结果将是许多 5 × 5 的图像。有多少?我们可以在 Param #(参数)列中看到。

每个卷积都是一个 3 × 3 的滤波器,再加上一个偏置。还记得我们之前的密集层,每一层都是 Y = mX + c 的形式吗?这非常相似,只不过因为滤波器是 3 × 3,所以有 9 个参数需要学习。考虑到我们定义了 64 个卷积,我们将有 640 个总体参数(每个卷积有 9 个参数加一个偏置,总共是 10 个,总共有 64 个)。

MaxPooling层不会学习任何东西,它们只是减少图像大小,因此那里没有学习到的参数——因此报告为 0。

接下来的卷积层有 64 个过滤器,但每个过滤器都与的 64 个过滤器相乘,每个过滤器有 9 个参数。每个新的 64 个过滤器都有一个偏置,因此我们的参数数量应为(64 × (64 × 9)) + 64,这给出了网络需要学习的 36,928 个参数。

如果这让你感到困惑,试着将第一层的卷积次数更改为某个数字——例如 10。你会看到第二层的参数数量变为 5,824,即(64 × (10 × 9)) + 64)。

当我们通过第二个卷积层时,我们的图像是 5 × 5,并且有 64 个。如果我们把这个乘起来,现在我们有 1,600 个值,我们将把它们馈送到 128 个神经元的密集层中。每个神经元都有一个权重和一个偏置,我们有 128 个神经元,所以网络将学习的参数数量是((5 × 5 × 64) × 128) + 128,给我们 204,928 个参数。

我们最后的密集层有 10 个神经元,接收前一个 128 个神经元的输出,因此学习到的参数数量将为(128 × 10) + 10,即 1,290。

然后,总参数数是所有这些参数的总和:243,786。

训练这个网络要求我们学习最佳的这些 243,786 个参数集,以匹配输入图像和它们的标签。这是一个较慢的过程,因为参数更多,但正如我们从结果中看到的,它也建立了一个更准确的模型!

当然,对于这个数据集,我们仍然有限制,即图像是 28 × 28 的单色图像,并且居中。接下来,我们将看看如何使用卷积来探索一个更复杂的数据集,包括马和人的彩色图片,然后尝试确定图像中是否包含其中一种。在这种情况下,主题不会像时尚 MNIST 那样总是居中显示,因此我们将依赖卷积来识别独特的特征。

建立一个 CNN 来区分马和人

在本节中,我们将探索比时尚 MNIST 分类器更复杂的场景。我们将扩展我们对卷积和卷积神经网络的了解,以尝试对那些特征位置不总是相同的图像内容进行分类。我为此创建了马或人类数据集。

马或人类数据集

本节的数据集包含了一千多个 300 × 300 像素的图像,大约一半是马,一半是人类,展示了不同的姿势。你可以在图 3-7 中看到一些示例。

马和人类

图 3-7. 马和人类

你可以看到,主体的朝向和姿势各不相同,图像的构图也有所不同。以两匹马为例,它们的头部朝向不同,一匹放大显示整个动物,而另一匹则放大显示头部和部分身体。同样,人物的光线不同,皮肤色调不同,姿势也各异。男子双手叉腰,而女子则伸出双手。图像还包括背景,如树木和海滩,因此分类器必须确定图像的哪些部分是决定马是马、人是人的重要特征,而不受背景影响。

虽然先前的例子,如预测 Y = 2X – 1 或分类小型单色服装图像,可能可以通过传统编码实现,但显然这要困难得多,你已经跨入机器学习解决问题的领域。

一个有趣的副产品是这些图像都是计算机生成的。理论上,发现在计算机生成图像中的特征应该也适用于真实图像。你将在本章后面看到这个理论实际效果如何。

Keras 的 ImageDataGenerator

到目前为止,你一直在使用的时尚 MNIST 数据集带有标签。每个图像文件都有一个关联的文件,其中包含标签详细信息。许多基于图像的数据集没有这种标签,马或人类数据集也不例外。相反,图像被分类到每种类型的子目录中。在 TensorFlow 的 Keras 中,一个名为 ImageDataGenerator 的工具可以利用这种结构来自动为图像分配标签。

要使用 ImageDataGenerator,你只需确保你的目录结构有一组带有命名子目录,每个子目录作为一个标签。例如,马或人类数据集提供了一组 ZIP 文件,一个是训练数据(1000 多张图像),另一个是验证数据(256 张图像)。当你下载并解压它们到用于训练和验证的本地目录时,请确保它们的文件结构类似于图 3-8 中的结构。

下面是获取训练数据并将其提取到适当命名的子目录中的代码,如图所示:

`import` urllib.request
`import` zipfile

url = `"``https://storage.googleapis.com/laurencemoroney-blog.appspot.com/`
                                            `horse``-``or``-``human``.``zip``"`
file_name = `"``horse-or-human.zip``"`
training_dir = `'``horse-or-human/training/``'`
urllib.request.urlretrieve(url, file_name)

zip_ref = zipfile.`ZipFile`(file_name, `'``r``'`)
zip_ref.extractall(training_dir)
zip_ref.close()

确保图像位于命名的子目录中

图 3-8。确保图像位于命名的子目录中

下面是获取训练数据并将其提取到适当命名的子目录中的代码,如图所示:

`import` urllib.request
`import` zipfile

url = `"``https://storage.googleapis.com/laurencemoroney-blog.appspot.com/`
                                            `horse``-``or``-``human``.``zip``"`
file_name = `"``horse-or-human.zip``"`
training_dir = `'``horse-or-human/training/``'`
urllib.request.urlretrieve(url, file_name)

zip_ref = zipfile.`ZipFile`(file_name, `'``r``'`)
zip_ref.extractall(training_dir)
zip_ref.close()

这只是下载训练数据的 ZIP 文件,并将其解压缩到一个目录horse-or-human/training中(我们将很快处理下载验证数据)。这是将包含图像类型子目录的父目录。

现在,要使用ImageDataGenerator,我们只需使用以下代码:

`from` tensorflow.keras.preprocessing.image `import` `ImageDataGenerator`

`# All images will be rescaled by 1./255`
train_datagen = `ImageDataGenerator`(rescale=`1`/`255`)

train_generator = train_datagen.flow_from_directory(
  training_dir,
  target_size=(`300`, `300`),
  class_mode=`'``binary``'`
)

首先,我们创建一个名为train_datagenImageDataGenerator实例。然后,我们指定它将从一个目录中流动生成训练过程中的图像。目录是之前指定的training_dir。我们还指定了一些关于数据的超参数,比如目标大小——在这种情况下是 300 × 300 像素,类别模式是binary。如果只有两种类型的图像(就像这种情况),通常模式是binary,如果超过两种则是categorical

《马或人的 CNN 架构》

当设计用于分类图像的架构时,此数据集与 Fashion MNIST 数据集之间有几个重要的区别需要考虑。首先,图像要大得多——300 × 300 像素,因此可能需要更多的层。其次,图像是全彩色的,而不是灰度的,所以每个图像将有三个通道而不是一个。第三,只有两种图像类型,因此我们可以使用只有一个输出神经元的二元分类器,其中一个类逼近 0,另一个逼近 1。在探索这种架构时,请记住这些考虑因素:

`model` `=` `tf``.``keras``.``models``.`Sequential`(``[`
 `tf``.``keras``.``layers``.`Conv2D`(``16``,` `(``3``,``3``)``,` `activation``=``'``relu``'` , 
              input_shape=(`300``,` `300``,` `3``)``)``,`
 `tf``.``keras``.``layers``.`MaxPooling2D`(``2``,` `2``)``,`
 `tf``.``keras``.``layers``.`Conv2D`(``32``,` `(``3``,``3``)``,` `activation``=``'``relu``'`),
 `tf``.``keras``.``layers``.`MaxPooling2D`(``2``,``2``)``,`
 `tf``.``keras``.``layers``.`Conv2D`(``64``,` `(``3``,``3``)``,` `activation``=``'``relu``'`),
 `tf``.``keras``.``layers``.`MaxPooling2D`(``2``,``2``)``,`
 `tf``.``keras``.``layers``.`Conv2D`(``64``,` `(``3``,``3``)``,` `activation``=``'``relu``'`),
 `tf``.``keras``.``layers``.`MaxPooling2D`(``2``,``2``)``,`
 `tf``.``keras``.``layers``.`Conv2D`(``64``,` `(``3``,``3``)``,` `activation``=``'``relu``'`),
 `tf``.``keras``.``layers``.`MaxPooling2D`(``2``,``2``)``,`
 `tf``.``keras``.``layers``.`Flatten`(``)``,`
 `tf``.``keras``.``layers``.`Dense`(``512``,` `activation``=``'``relu``'``)``,`
 `tf``.``keras``.``layers``.`Dense`(``1``,` `activation``=``'``sigmoid``'``)`
`]``)`

这里有几点需要注意。首先,这是第一层。我们定义了 16 个 3 × 3 的滤波器,但图像的输入形状是(300, 300, 3)。请记住,这是因为我们的输入图像是 300 × 300,并且是彩色的,所以有三个通道,而不是像我们之前使用的单色 Fashion MNIST 数据集中的一个通道。

另一端,请注意输出层只有一个神经元。这是因为我们使用的是二元分类器,如果我们使用 sigmoid 函数激活它,我们可以获得二元分类的结果。sigmoid 函数的目的是将一组值朝向 0,另一组值朝向 1,这非常适合二元分类。

接下来,请注意我们堆叠了几个更多的卷积层。我们之所以这样做,是因为我们的图像源非常大,我们希望随着时间的推移有许多更小的图像,每个都突出显示特征。如果我们查看model.summary的结果,我们将看到这一点在实际中的运作方式:

=================================================================
conv2d (Conv2D)              (None, 298, 298, 16)  448    
_________________________________________________________________
max_pooling2d (MaxPooling2D) (None, 149, 149, 16)  0     
_________________________________________________________________
conv2d_1 (Conv2D)            (None, 147, 147, 32)  4640   
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 73, 73, 32)    0     
_________________________________________________________________
conv2d_2 (Conv2D)            (None, 71, 71, 64)    18496   
_________________________________________________________________
max_pooling2d_2 (MaxPooling2 (None, 35, 35, 64)    0     
_________________________________________________________________
conv2d_3 (Conv2D)            (None, 33, 33, 64)    36928   
_________________________________________________________________
max_pooling2d_3 (MaxPooling2 (None, 16, 16, 64)    0     
_________________________________________________________________
conv2d_4 (Conv2D)            (None, 14, 14, 64)    36928   
_________________________________________________________________
max_pooling2d_4 (MaxPooling2 (None, 7, 7, 64)      0     
_________________________________________________________________
flatten (Flatten)            (None, 3136)          0     
_________________________________________________________________
dense (Dense)                (None, 512)           1606144  
_________________________________________________________________
dense_1 (Dense)              (None, 1)             513    
=================================================================
Total params: 1,704,097
Trainable params: 1,704,097
Non-trainable params: 0
_________________________________________________________________

注意,通过卷积层和池化层处理数据后,最终变为 7 × 7 个项目。理论上,这些将是激活的特征图,相对简单,仅包含 49 个像素。然后可以将这些特征图传递给密集神经网络,以将它们与相应的标签匹配。

当然,这会导致我们比之前的网络拥有更多的参数,因此训练速度会变慢。通过这种架构,我们将学习 170 万个参数。

要训练网络,我们将需要使用损失函数和优化器对其进行编译。在这种情况下,损失函数可以是二元交叉熵损失函数,因为只有两类,正如其名称所示,这是为这种情况设计的损失函数。我们可以尝试一个新的优化器,均方根传播RMSprop),它接受一个学习率(lr)参数,允许我们调整学习率。以下是代码:

`model``.``compile``(``loss``=`'binary_crossentropy'`,`
 `optimizer``=``RMSprop``(``lr``=``0.001``)``,`
 `metrics``=``[`'accuracy'`]``)`

我们通过使用fit_generator并将之前创建的training_generator传递给它进行训练:

history = model.fit_generator(
  train_generator,
  epochs=`15`
)

这个示例将在 Colab 上运行,但如果您想在自己的机器上运行它,请确保使用 pip install pillow 安装了 Pillow 库。

注意,使用 TensorFlow Keras,您可以使用model.fit将训练数据拟合到训练标签上。当使用生成器时,旧版本要求您使用model.fit_generator。TensorFlow 的较新版本将允许您使用任何一种方式。

在仅 15 个 epoch 中,这种架构在训练集上给我们提供了非常令人印象深刻的 95%+ 的准确率。当然,这仅限于训练数据,并不能说明在网络之前没有见过的数据上的表现。

接下来,我们将使用生成器添加验证集,并测量其性能,以便为我们提供模型在实际生活中的表现提供良好的指标。

将验证添加到马或人类数据集

要添加验证,您需要一个与训练集分开的验证数据集。在某些情况下,您将获得一个主数据集,需要自行分割,但在马或人的情况下,有一个可以下载的单独验证集。

注意

您可能会想为什么我们在这里谈论的是验证数据集,而不是测试数据集,它们是否相同。对于像前几章中开发的简单模型,将数据集分为两部分,一部分用于训练,一部分用于测试,通常就足够了。但对于像我们正在构建的这样更复杂的模型,您需要创建单独的验证集和测试集。它们有什么区别呢?训练数据是用于教网络如何将数据和标签匹配在一起的数据。验证数据在您训练网络时用于查看网络在以前未见过的数据上的表现,即它不用于将数据与标签匹配,而是用于检查拟合的效果如何。测试*数据在训练后用于查看网络在以前从未见过的数据上的表现。有些数据集自带三分法分割,其他情况下,您需要将测试集分成验证集和测试集两部分。在这里,您将下载一些额外的图像来测试模型。

您可以使用与训练图像相似的代码来下载验证集,并将其解压缩到不同的目录中:

validation_url = `"``https://storage.googleapis.com/laurencemoroney-blog.appspot.com`
                                                `/``validation``-``horse``-``or``-``human``.``zip``"`

validation_file_name = `"``validation-horse-or-human.zip``"`
validation_dir = `'``horse-or-human/validation/``'`
urllib.request.urlretrieve(validation_url, validation_file_name)

zip_ref = zipfile.`ZipFile`(validation_file_name, `'``r``'`)
zip_ref.extractall(validation_dir)
zip_ref.close()

一旦您有了验证数据,您可以设置另一个ImageDataGenerator来管理这些图像:

validation_datagen = `ImageDataGenerator`(rescale=`1`/`255`)

validation_generator = train_datagen.flow_from_directory(
  validation_dir,
  target_size=(`300`, `300`),
  class_mode=`'``binary``'`
)

要让 TensorFlow 为您执行验证,您只需更新model.fit_generator方法,指示您想使用验证数据来逐个 epoch 测试模型。您可以通过使用validation_data参数并传递刚刚构建的验证生成器来实现这一点:

history = model.fit_generator(
  train_generator,
  epochs=`15`,
  validation_data=validation_generator
)

训练了 15 个 epochs 后,您应该看到您的模型在训练集上达到了 99%+ 的准确率,但在验证集上只有约 88%。这表明模型出现了过拟合,就像我们在前一章中看到的一样

尽管它训练的图像数量很少,而且这些图像多种多样,但性能并不差。您开始因为缺乏数据而遇到瓶颈,但有一些技术可以提高模型的性能。我们将在本章后面探讨这些技术,但在此之前让我们看看如何使用这个模型。

测试马或人类图像

能够构建模型当然很好,但当然您也想尝试一下。在我开始我的 AI 之旅时,我主要的挫折之一是,我可以找到很多代码来展示如何构建模型,以及这些模型的表现图表,但很少有代码可以帮助我自己检验模型的性能。我会尽量避免在本书中重复这种情况!

使用 Colab 可能是测试模型最简单的方法。我在 GitHub 上提供了一个《马或人类》的笔记本,您可以直接在Colab中打开。

一旦你训练好了模型,你将看到一个称为“运行模型”的部分。在运行之前,找几张马或人类的图片并下载到你的电脑上。Pixabay.com是一个非常好的免费图像网站。最好先准备好你的测试图片,因为在你搜索时节点可能会超时。

图 3-9 展示了我从 Pixabay 下载用于测试模型的几张马和人类的图片。

测试图片

图 3-9. 测试图片

当它们被上传时,正如你在图 3-10 中所看到的,模型正确地将第一张图像分类为人类,第三张图像分类为马,但是中间的图像尽管明显是人类,却被错误地分类为马!

执行模型

图 3-10. 执行模型

你也可以同时上传多张图片,让模型为它们所有做出预测。你可能注意到它倾向于过拟合到马的一面。如果人类没有完全摆好姿势——也就是说,你看不到他们的全身——它可能会偏向马的一面。这就是这种情况发生的原因。第一个人类模型摆好了姿势,图像类似于数据集中许多姿势的样本,因此能够正确分类她。第二个模型面对相机,但只有她的上半身在图像中。没有训练数据看起来像这样,所以模型无法正确识别她。

现在让我们来探索代码,看看它在做什么。也许最重要的部分就是这一段:

img = image.load_img(path, target_size=(`300`, `300`))
x = image.img_to_array(img)
x = np.expand_dims(x, axis=`0`)

在这里,我们正在从 Colab 写入的路径加载图像。请注意,我们指定目标尺寸为 300 × 300。上传的图像可以是任何形状,但如果要将它们输入模型,它们必须是 300 × 300,因为这是模型训练识别的尺寸。因此,第一行代码加载图像并将其调整大小为 300 × 300。

下一行代码将图像转换为 2D 数组。然而,模型期望一个 3D 数组,如模型架构中的input_shape所示。幸运的是,Numpy 提供了一个expand_dims方法来处理这个问题,并允许我们轻松地向数组添加新的维度。

现在我们有了一个 3D 数组中的图像,我们只需确保它被垂直堆叠,以便与训练数据的形状相同:

image_tensor = np.vstack([x])

有了我们格式正确的图像,分类就变得很容易:

classes = model.predict(image_tensor)

模型返回一个包含分类的数组。因为在这种情况下只有一个分类,所以实际上是一个包含数组的数组。你可以在图 3-10 中看到这一点,在第一个(人类)模型中它看起来像[[1.]]

现在只需要检查该数组中第一个元素的值。如果大于 0.5,我们就在看一个人:

`if` classes[`0`]>`0.5`:
  `print`(fn + `"` `is a human``"`)
 `else`:
  `print`(fn + `"` `is a horse``"`)

在这里有几个重要的观点需要考虑。首先,即使网络是在合成的计算机生成的图像上训练的,它在识别真实照片中的马或人方面表现出色。这可能是一个好处,因为你可能不需要成千上万张照片来训练一个模型,而可以相对廉价地用 CGI 来完成。

但是这个数据集也展示了你将面临的一个根本性问题。你的训练集不可能希望能够代表模型在野外可能面对的每一种情况,因此模型总会在某种程度上对训练集过度专注。这里展示的一个清晰简单的例子是,在图 3-9 中心的人被错误分类。训练集中没有包含这种姿势的人物,因此模型没有“学到”人可以看起来像那样。结果,它有可能将该图像视为马,而在这种情况下确实如此。

解决方案是什么?一个明显的解决方案是增加更多的训练数据,包括那些在特定姿势下的人和最初未被代表的其他数据。但并非总是可能的。幸运的是,在 TensorFlow 中有一个巧妙的技巧,你可以使用它来虚拟扩展你的数据集——它被称为图像增强,我们接下来会探讨这个技术。

图像增强

在前一节中,你构建了一个基于相对较小数据集训练的马或人分类器模型。因此,你很快开始遇到一些问题,比如无法正确分类一些之前未见过的图像,例如因为训练集中没有包含人物在那个姿势下的图像而导致误分类。

处理这类问题的一种方法是使用图像增强技术。这种技术的核心思想是,在 TensorFlow 加载数据时,它可以通过一些转换来修改现有数据,从而创建额外的新数据。例如,请看图 3-11。尽管数据集中没有类似右侧女性的内容,但左侧的图像有些相似。

数据集相似性

图 3-11. 数据集相似性

因此,如果你能够例如在训练时放大左侧图像,如图 3-12,你将增加模型正确分类右侧图像为人的几率。

放大训练集数据

图 3-12. 放大训练集数据

以类似的方式,你可以通过各种其他变换来扩展训练集,包括:

  • 旋转

  • 水平移位

  • 垂直移位

  • 剪切

  • 放大

  • 翻转

因为你一直在使用ImageDataGenerator来加载图像,你已经看到它已经进行了一个转换,即当它像这样对图像进行了标准化:

train_datagen = `ImageDataGenerator`(rescale=`1`/`255`)

ImageDataGenerator中也轻松获得其他的转换方法,例如,你可以做这样的事情:

`train_datagen` `=` ImageDataGenerator`(`
 `rescale``=``1.``/``255``,`
 `rotation_range``=``40``,`
 `width_shift_range``=``0.2``,`
 `height_shift_range``=``0.2``,`
 `shear_range``=``0.2``,`
 `zoom_range``=``0.2``,`
 `horizontal_flip``=``True``,`
 `fill_mode``=``'``nearest``'`
`)`

在这里,除了重新缩放图像以进行标准化之外,您还在进行以下操作:

  • 随机将每个图像向左或向右旋转最多 40 度

  • 将图像垂直或水平平移最多 20%

  • 将图像剪切最多 20%

  • 将图像放大最多 20%

  • 随机水平或垂直翻转图像

  • 在移动或剪切后使用最近邻填充任何丢失的像素

当您使用这些参数重新训练时,您会注意到训练时间更长,因为所有的图像处理。此外,由于以前过度拟合大部分统一的数据集,您的模型准确性可能不如以前那样高。

在我的情况下,当使用这些增强技术进行训练时,我的准确率从 99%下降到了 85%,在 15 个 epochs 后,验证准确率略高,达到了 89%。(这表明模型略有欠拟合,因此参数可能需要稍作调整。)

那么图 3-9 中先前误分类的图像怎么样?这次它分类正确了。由于图像增强,训练集现在对于模型来说已经足够覆盖,以便了解这个特定的图像也是一个人类(见图 3-13)。这只是一个数据点,并不一定代表真实数据的结果,但这是朝着正确方向迈出的一小步。

放大的女性现在被正确分类

图 3-13. 放大的女性现在被正确分类

如您所见,即使是像马或人这样的相对较小的数据集,您也可以开始构建一个相当不错的分类器。使用更大的数据集可以进一步提升这一过程。另一种改进模型的技术是使用已经在其他地方学习过的特征。许多研究人员使用庞大的资源(数百万张图片)和经过数千类别训练的庞大模型共享了他们的模型,利用所谓的迁移学习,您可以使用这些模型学到的特征并将其应用到您的数据上。接下来我们将探讨这一点!

迁移学习

正如我们在本章中已经看到的那样,使用卷积来提取特征可以成为识别图像内容的强大工具。然后,将生成的特征图馈送到神经网络的密集层中,将其与标签匹配,从而给出一种更准确的确定图像内容的方式。使用这种方法,结合简单、快速训练的神经网络和一些图像增强技术,我们构建了一个模型,当在一个非常小的数据集上进行训练时,它在区分马和人方面的准确率达到了 80-90%。

但是我们可以使用一种称为迁移学习的方法来进一步改进我们的模型。迁移学习背后的思想很简单:不是从头开始学习我们数据集的一组滤波器,为什么不使用在比我们能够“负担得起”的更大数据集上学习的一组滤波器?我们可以将这些放入我们的网络中,然后使用预先学习的滤波器训练我们的数据集的模型。例如,我们的马或人类数据集只有两类。我们可以使用一个已经为一千个类别预训练的现有模型,但在某个时候,我们将不得不丢弃一些现有网络并添加层,以便让我们有一个两类分类器。

图 3-14 展示了像我们这样的分类任务的 CNN 架构可能看起来像什么。我们有一系列卷积层,这些层导致一个密集层,然后导致一个输出层。

卷积神经网络架构

Figure 3-14. 卷积神经网络架构

我们已经看到,使用这种架构我们能够构建一个相当不错的分类器。但是通过迁移学习,如果我们能够从另一个模型中获取预先学习的层,并且将它们冻结或锁定,以便它们不能再训练,然后像在图 3-15 中那样将它们放在我们的模型顶部,那会怎么样?

通过迁移学习从另一个架构获取层

Figure 3-15. 通过迁移学习从另一个架构获取层

当我们考虑到,一旦它们被训练,所有这些层都只是一组数字,指示着滤波器值、权重和偏差以及已知的架构(每层的滤波器数、滤波器大小等),重用它们的想法就非常简单了。

让我们看看这在代码中的表现。有许多已经来自各种来源的预训练模型。我们将使用来自谷歌的流行的 Inception V3 模型的第三版,该模型在名为 ImageNet 的数据库中训练了一百多万张图像。它有数十个层,并且可以将图像分类为一千个类别。有一个保存了预训练权重的模型可供使用。要使用它,我们只需下载权重,创建一个 Inception V3 架构的实例,然后像这样加载权重:

`from` `tensorflow.keras.applications.inception_v3` `import`  InceptionV3

`weights_url` `=` `"``https://storage.googleapis.com/mledu-`
`datasets``/``inception_v3_weights_tf_dim_ordering_tf_kernels_notop``.``h5``"`

`weights_file` `=` `"``inception_v3.h5``"`
`urllib``.``request``.``urlretrieve``(``weights_url``,` `weights_file``)`

`pre_trained_model` `=` InceptionV3`(``input_shape``=``(``150``,` `150``,` `3``)``,`
 `include_top``=``False``,`
 `weights``=``None``)`

`pre_trained_model``.``load_weights``(``weights_file``)`

现在我们有一个完整的预训练 Inception 模型。如果你想检查它的架构,可以这样做:

pre_trained_model.summary()

请注意——它非常庞大!不过,浏览一下它,看看各层及其名称是很有用的。我喜欢使用名为mixed7的那个,因为它的输出很好很小——7 × 7 的图像——但请随意尝试其他选项。

接下来,我们将冻结整个网络,使其不可重新训练,然后设置一个变量,指向mixed7的输出,作为我们希望裁剪的网络位置。我们可以使用以下代码来实现这一点:

`for` layer `in` pre_trained_model.layers:
  layer.trainable = `False`

last_layer = pre_trained_model.get_layer(`'``mixed7``'`)
`print`(`'``last layer output shape:` `'`, last_layer.output_shape)
last_output = last_layer.output

请注意,我们打印最后一层的输出形状,并且您将看到此时我们获得了 7 × 7 的图像。这表明,当图像通过mixed7层时,来自滤波器的输出图像大小为 7 × 7,因此它们非常易于管理。再次强调,您不必选择特定的层;欢迎您尝试其他层。

现在让我们看看如何在这之下添加我们的密集层:

# Flatten the output layer to 1 dimension
`x` `=` `layers``.``Flatten``(``)``(``last_output``)`
# Add a fully connected layer with 1,024 hidden units and ReLU activation
`x` `=` `layers``.``Dense``(``1024``,` `activation``=``'``relu``'``)``(``x``)`
# Add a final sigmoid layer for classification
`x` `=` `layers``.``Dense``(``1``,` `activation``=``'``sigmoid``'``)``(``x``)`

只需创建一组来自最后输出的平坦层,因为我们将把结果馈送到密集层中。然后,我们添加一个包含 1,024 个神经元的密集层,以及一个包含 1 个神经元的输出密集层。

现在,我们可以简单地定义我们的模型,即我们预训练模型的输入,然后是我们刚刚定义的x。然后,我们按照通常的方式进行编译:

model = `Model`(pre_trained_model.input, x)

model.compile(optimizer=`RMSprop`(lr=`0.0001`),
       loss=`'``binary_crossentropy``'`,
       metrics=[`'``acc``'`])

在这个架构上训练模型 40 个 epoch 后,准确率达到了 99%以上,验证准确率达到了 96%以上(见图 3-16)。

使用迁移学习训练马或人分类器

图 3-16. 使用迁移学习训练马或人分类器

这里的结果比我们以前的模型要好得多,但您可以继续微调和改进它。您还可以探索模型在更大的数据集上的工作方式,比如来自 Kaggle 的著名Dogs vs. Cats数据集。这是一个包含 25,000 张猫和狗图片的极为多样的数据集,通常被主体部分遮挡——例如,如果它们被人类抱着。

使用与之前相同的算法和模型设计,您可以在 Colab 上使用 GPU 每个 epoch 大约 3 分钟来训练 Dogs vs. Cats 分类器。对于 20 个 epoch,这相当于大约 1 小时的训练时间。

在像图 3-17 中那样非常复杂的图片上进行测试时,这个分类器全部正确。我选择了一张狗耳朵像猫的图片,以及一张背对着的狗的图片。这两张猫的图片都是非典型的。

正确分类的不寻常的狗和猫

图 3-17. 正确分类的不寻常的狗和猫

当加载到模型中时,位于右下角的那只猫,眼睛闭着,耳朵朝下,舌头伸出,正在洗爪,其结果显示在图 3-18 中。您可以看到,它给出了一个非常低的值(4.98 × 10^(–24)),这表明网络几乎可以确定它是一只猫!

分类洗爪的猫

图 3-18. 分类洗爪的猫

您可以在本书的GitHub 代码库中找到 Horses or Humans 和 Dogs vs. Cats 分类器的完整代码。

多类别分类

到目前为止,所有的例子中,你一直在构建二元分类器——即在两个选项之间进行选择(马或人类,猫或狗)。当构建多类分类器时,模型几乎相同,但有一些重要的不同之处。不再是一个 sigmoid 激活的单个神经元,或者两个二进制激活的神经元,你的输出层现在将需要n个神经元,其中n是你想分类的类别数。你还必须将损失函数更改为适合多类别的函数。例如,对于到目前为止在本章中构建的二元分类器,你的损失函数是二元交叉熵,如果要将模型扩展到多个类别,则应改用分类交叉熵。如果你使用ImageDataGenerator来提供图像,标签将自动完成,因此多个类别将与二元类别相同——ImageDataGenerator将根据子目录的数量进行标记。

比如说,考虑石头剪刀布游戏。如果你想训练一个数据集来识别不同的手势,你需要处理三个类别。幸运的是,这里有一个简单的数据集,你可以用来做这个。

这里有两个下载:一个是训练集,包含许多不同的手,大小、形状、颜色以及指甲油等细节各异;另一个是测试集,同样包含多样化的手,但没有在训练集中出现过的。

你可以在图 3-19 中看到一些示例。

石头/剪刀/布手势示例

图 3-19. 石头/剪刀/布手势示例

使用数据集很简单。下载并解压缩它——排序后的子目录已经在 ZIP 文件中存在——然后用它初始化一个ImageDataGenerator

!wget --no-check-certificate \
 https://storage.googleapis.com/laurencemoroney-blog.appspot.com/rps.zip \
 -O /tmp/rps.zip
local_zip = `'``/tmp/rps.zip``'`
zip_ref = zipfile.`ZipFile`(local_zip, `'``r``'`)
zip_ref.extractall(`'``/tmp/``'`)
zip_ref.close()
TRAINING_DIR = `"``/tmp/rps/``"`
training_datagen = `ImageDataGenerator`(
  rescale = `1.`/`255`,
  rotation_range=`40`,
  width_shift_range=`0.2`,
  height_shift_range=`0.2`,
  shear_range=`0.2`,
  zoom_range=`0.2`,
  horizontal_flip=`True`,
  fill_mode=`'``nearest``'`
)

注意,但是,当你从这里设置数据生成器时,你必须指定类别模式为分类,以便ImageDataGenerator可以使用超过两个子目录:

train_generator = training_datagen.flow_from_directory(
  TRAINING_DIR,
  target_size=(150,150),
 `class_mode``=``'``categorical``'`
)

在定义模型时,要注意输入和输出层,确保输入匹配数据的形状(在本例中为 150 × 150),输出匹配类别数(现在是三个):

model = tf.keras.models.Sequential([
  # Note the input shape is the desired size of the image: 
  # 150x150 with 3 bytes color
  # This is the first convolution
  tf.keras.layers.Conv2D(64, (3,3), activation='relu',`input_shape``=``(``150``,` `150``,` `3``)``)``,`
  tf.keras.layers.MaxPooling2D(2, 2),
  # The second convolution
  tf.keras.layers.Conv2D(64, (3,3), activation='relu'),
  tf.keras.layers.MaxPooling2D(2,2),
  # The third convolution
  tf.keras.layers.Conv2D(128, (3,3), activation='relu'),
  tf.keras.layers.MaxPooling2D(2,2),
  # The fourth convolution
  tf.keras.layers.Conv2D(128, (3,3), activation='relu'),
  tf.keras.layers.MaxPooling2D(2,2),
  # Flatten the results to feed into a DNN
  tf.keras.layers.Flatten(),
  # 512 neuron hidden layer
  tf.keras.layers.Dense(512, activation='relu'),
 `tf``.``keras``.``layers``.``Dense``(``3``,` `activation``=``'``softmax``'``)`
])

最后,在编译模型时,确保使用分类损失函数,如分类交叉熵。二元交叉熵不适用于超过两类:

model.compile(`loss` `=` `'``categorical_crossentropy``'`, optimizer='rmsprop', 
       metrics=['accuracy'])

训练过程与之前相同:

history = model.fit(train_generator, epochs=`25`, 
          validation_data = validation_generator, verbose = `1`)

测试预测代码需要进行一些更改。现在有三个输出神经元,它们将为预测的类输出接近 1 的值,对于其他类输出接近 0 的值。请注意,所使用的激活函数是softmax,这将确保所有三个预测的总和为 1。例如,如果模型对某些事物感到非常不确定,可能会输出 0.4, 0.4, 0.2,但如果它对某些事物非常确定,可能会得到 0.98, 0.01, 0.01。

同时使用ImageDataGenerator时,请注意类是按字母顺序加载的——因此,虽然您可能期望输出神经元按游戏名称的顺序排列,但实际上顺序将是 Paper,Rock,Scissors。

在 Colab 笔记本中尝试预测的代码如下所示。这与您之前看到的非常相似:

`import` numpy `as` np
`from` google.colab `import` files
`from` keras.preprocessing `import` image

uploaded = files.upload()

`for` fn `in` uploaded.keys():

  `# predicting images`
  path = fn
  img = image.load_img(path, target_size=(`150`, `150`))
  x = image.img_to_array(img)
  x = np.expand_dims(x, axis=`0`)

  images = np.vstack([x])
  classes = model.predict(images, batch_size=`10`)
  `print`(fn)
  `print`(classes)

请注意,它不会解析输出,只会打印类。图 3-20 展示了其使用情况。

测试石头纸剪刀分类器

图 3-20。测试石头/纸/剪刀分类器

您可以从文件名中看出图像的内容。Paper1.png 最终变成了[1, 0, 0],意味着第一个神经元被激活,其他两个没有被激活。类似地,Rock1.png 变成了[0, 1, 0],第二个神经元被激活,而Scissors2.png 则是[0, 0, 1]。请记住,神经元按照标签的字母顺序排列!

一些可用于测试数据集的图像可以下载。当然,您也可以尝试您自己的图像。请注意,训练图像都是在纯白色背景下完成的,因此如果您拍摄的照片背景有很多细节,可能会产生一些混淆。

Dropout 正则化

在本章的前面部分,我们讨论了过拟合问题,即网络可能对特定类型的输入数据过于专门化,而对其他数据表现不佳。一种克服这个问题的技术是使用dropout 正则化

当神经网络进行训练时,每个单独的神经元都会对后续层中的神经元产生影响。随着时间的推移,特别是在更大的网络中,一些神经元可能会变得过于专门化,这可能会导致整个网络变得过于专门化并导致过拟合。此外,相邻的神经元可能会具有相似的权重和偏差,如果没有监控,这可能会导致模型过于专门化于这些神经元激活的特征。

例如,考虑图 3-21 中的神经网络,其中有 2、6、6 和 2 个神经元层。中间层的神经元可能会具有非常相似的权重和偏差。

一个简单的神经网络

图 3-21。一个简单的神经网络

在训练过程中,如果移除了随机数量的神经元并忽略它们,它们对下一层神经元的贡献会被暂时阻断(图 3-22)。

带有随机失活的神经网络

图 3-22. 带有随机失活的神经网络

这降低了神经元过度专门化的可能性。网络仍然会学习相同数量的参数,但它应该更擅长泛化,即对不同的输入更具弹性。

注意

Nitish Srivastava 等人在其 2014 年的论文 “Dropout: A Simple Way to Prevent Neural Networks from Overfitting” 中提出了失活的概念。

要在 TensorFlow 中实现失活,您可以像这样使用简单的 Keras 层:

tf.keras.layers.Dropout(`0.2`),

这将在指定层中随机删除指定百分比的神经元(这里是 20%)。请注意,可能需要一些实验才能找到适合您网络的正确百分比。

作为展示这一点的简单示例,请考虑来自第二章的时尚 MNIST 分类器。我将更改网络定义,添加更多层,如下所示:

model = tf.keras.models.`Sequential`([
      tf.keras.layers.`Flatten`(input_shape=(`28`,`28`)),
      tf.keras.layers.`Dense`(`256`, activation=tf.nn.relu),
      tf.keras.layers.`Dense`(`128`, activation=tf.nn.relu),
      tf.keras.layers.`Dense`(`64`, activation=tf.nn.relu),
      tf.keras.layers.`Dense`(`10`, activation=tf.nn.softmax)
    ])

对这个进行 20 个 epoch 的训练,在训练集上达到了约 94% 的准确率,在验证集上约为 88.5%。这是过拟合的潜在迹象。

在每个密集层后引入失活看起来像这样:

model = tf.keras.models.Sequential([
 tf.keras.layers.Flatten(input_shape=(28,28)),
 tf.keras.layers.Dense(256, activation=tf.nn.relu),
 **tf.keras.layers.**Dropout(0.2),
 tf.keras.layers.Dense(128, activation=tf.nn.relu),
 **tf.keras.layers.**Dropout(0.2),
 tf.keras.layers.Dense(64, activation=tf.nn.relu),
 **tf.keras.layers.**Dropout(0.2),
 tf.keras.layers.Dense(10, activation=tf.nn.softmax)
 ])

当该网络在相同数据上相同时期训练时,训练集上的准确率下降到约 89.5%。验证集上的准确率保持大致相同,约为 88.3%。这些值更接近彼此;引入失活不仅表明了过拟合的发生,还表明使用失活可以通过确保网络不过于专注于训练数据来消除这种歧义。

设计神经网络时,请记住在训练集上获得良好结果并不总是件好事。这可能是过拟合的迹象。引入随机失活可以帮助您解决这个问题,这样您可以在其他领域优化网络,而不会因为虚假的安全感而影响效果。

摘要

本章向您介绍了使用卷积神经网络实现计算机视觉的更高级方法。您了解了如何使用卷积来应用能够从图像中提取特征的滤波器,并设计了第一个神经网络来处理比 MNIST 和时尚 MNIST 数据集更复杂的视觉场景。您还探索了改进网络准确性和避免过拟合的技术,如图像增强和失活的使用。

在我们进一步探讨其他情景之前,在第四章中,你将会对 TensorFlow Datasets 有所了解,这是一项使得你更容易获取用于训练和测试网络的数据的技术。在本章中,你下载了 ZIP 文件并提取了图像,但这并非总是可能的。使用 TensorFlow Datasets,你将能够通过标准 API 访问大量数据集。

第四章:使用 TensorFlow Datasets 进行公共数据集

在本书的前几章中,你使用了各种数据来训练模型,从与 Keras 捆绑的时尚 MNIST 数据集,到基于图像的 Horses or Humans 和 Dogs vs. Cats 数据集,后者作为 ZIP 文件提供,你需要下载并预处理。你可能已经意识到,获取数据来训练模型有很多不同的方式。

然而,许多公共数据集在你开始考虑模型架构之前,需要你掌握许多不同的领域特定技能。TensorFlow Datasets(TFDS)的目标是以易于消费的方式暴露数据集,其中包括获取数据和将其转换为 TensorFlow 友好 API 的所有预处理步骤。

您已经在第一章和第二章中稍微了解了 Keras 如何处理 Fashion MNIST 的这个想法。回顾一下,你只需要这样做就能获取数据:

data = tf.keras.datasets.fashion_mnist

(training_images, training_labels), (test_images, test_labels) = 
data.load_data()

TFDS 基于这一理念,不仅极大地扩展了可用数据集的数量,还增加了数据集类型的多样性。可用数据集列表不断增长,涵盖以下类别:

音频

语音和音乐数据

图像

从简单的学习数据集(如 Horses or Humans)到用于糖尿病视网膜病变检测等高级研究数据集

目标检测

COCO、Open Images 等

结构化数据

泰坦尼克号幸存者、亚马逊评论等

摘要

CNN 和每日邮报的新闻、科学论文、wikiHow 等

文本

IMDb 评论、自然语言问题等

翻译

各种翻译训练数据集

视频

Moving MNIST、Starcraft 等

注意

TensorFlow Datasets 与 TensorFlow 是分开安装的,请务必在尝试任何样本之前安装它!如果您使用 Google Colab,它已经预安装。

本章将介绍 TFDS 以及如何使用它来大大简化训练过程。我们将探讨基础的 TFRecord 结构以及它如何提供无论底层数据类型如何都通用的功能。您还将了解如何使用 TFDS 进行提取-转换-加载(ETL)模式,这可以有效地训练大量数据的模型。

开始使用 TFDS

让我们通过一些简单的示例来演示如何使用 TFDS,以说明它如何为我们提供数据的标准接口,无论数据类型如何。

如果需要安装,可以使用pip命令:

pip install tensorflow-datasets

安装完成后,您可以使用tfds.load访问数据集,只需传递所需数据集的名称。例如,如果您想使用 Fashion MNIST,可以使用以下代码:

`import` tensorflow `as` tf
`import` tensorflow_datasets `as` tfds
mnist_data = tfds.load(`"``fashion_mnist``"`)
`for` item `in` mnist_data:
  `print`(item)

确保检查从 tfds.load 命令返回的数据类型——打印项目的输出将是数据中本地可用的不同分割。在这种情况下,它是一个包含两个字符串 testtrain 的字典。这些是可用的分割。

如果您想将这些分割加载到包含实际数据的数据集中,您可以简单地在 tfds.load 命令中指定您想要的分割,就像这样:

`mnist_train` `=` `tfds``.``load``(``name``=`"fashion_mnist"`,` `split``=`"train"`)`
`assert` `isinstance``(``mnist_train``,` `tf``.``data``.``Dataset``)`
`print``(``type``(``mnist_train``)``)`

在这个例子中,您将看到输出是一个 DatasetAdapter,您可以通过迭代来检查数据。这个适配器的一个很好的特性是,您可以简单地调用 take(1) 来获取第一条记录。让我们这样做来检查数据的样子:

`for` item `in` mnist_train.take(`1`):
  `print`(type(item))
  `print`(item.keys())

第一个 print 的输出将显示每条记录中项目的类型是一个字典。当我们打印这些键时,我们将看到在这个图像集中类型是 imagelabel。因此,如果我们想要检查数据集中的一个值,我们可以做如下操作:

`for` item `in` mnist_train.take(`1`):
  `print`(type(item))
  `print`(item.keys())
  `print`(item[`'``image``'`])
  `print`(item[`'``label``'`])

您将看到图像的输出是一个 28 × 28 的值数组(在 tf.Tensor 中)从 0 到 255,表示像素强度。标签将以 tf.Tensor(2, shape=(), dtype=int64) 的形式输出,表明这个图像在数据集中属于类别 2。

在加载数据集时,还可以使用 with_info 参数获取关于数据集的信息,如下所示:

mnist_test, info = tfds.load(name=`"``fashion_mnist``"`, with_info=`"``true``"`)
`print`(info)

打印信息将提供有关数据集内容的详细信息。例如,对于 Fashion MNIST,您将看到类似于以下输出:

tfds.core.DatasetInfo(
    name='fashion_mnist',
    version=3.0.0,
    description='Fashion-MNIST is a dataset of Zalando's article images
      consisting of a training set of 60,000 examples and a test set of 10,000
      examples. Each example is a 28x28 grayscale image, associated with a
      label from 10 classes.',
    homepage='https://github.com/zalandoresearch/fashion-mnist',
    features=FeaturesDict({
        'image': Image(shape=(28, 28, 1), dtype=tf.uint8),
        'label': ClassLabel(shape=(), dtype=tf.int64, num_classes=10),
    }),
    total_num_examples=70000,
    splits={
        'test': 10000,
        'train': 60000,
    },
    supervised_keys=('image', 'label'),
    citation="""@article{DBLP:journals/corr/abs-1708-07747,
 author    = {Han Xiao and
 Kashif Rasul and
 Roland Vollgraf},
 title     = {Fashion-MNIST: a Novel Image Dataset for Benchmarking 
 Machine Learning
 Algorithms},
 journal   = {CoRR},
 volume    = {abs/1708.07747},
 year      = {2017},
 url       = {http://arxiv.org/abs/1708.07747},
 archivePrefix = {arXiv},
 eprint    = {1708.07747},
 timestamp = {Mon, 13 Aug 2018 16:47:27 +0200},
 biburl    = {https://dblp.org/rec/bib/journals/corr/abs-1708-07747},
 bibsource = {dblp computer science bibliography, https://dblp.org}
 }""",
    redistribution_info=,
)

在其中,您可以查看诸如分割(如前所示)和数据集中的特征以及额外信息(如引用、描述和数据集版本)的详细信息。

使用 TFDS 与 Keras 模型

在 第二章 中,您看到如何使用 TensorFlow 和 Keras 创建一个简单的计算机视觉模型,使用了来自 Keras 的内置数据集(包括 Fashion MNIST),并且使用了如下简单的代码:

mnist = tf.keras.datasets.fashion_mnist

(training_images, training_labels), 
(test_images, test_labels) = mnist.load_data()

当使用 TFDS 时,代码非常相似,但有一些小的改变。Keras 数据集给了我们在 model.fit 中可以直接使用的 ndarray 类型,但是在 TFDS 中,我们需要做一些转换工作:

(training_images, training_labels), 
(test_images, test_labels) =  
tfds.as_numpy(tfds.load(`'``fashion_mnist``'`,
                         split = [`'``train``'`, `'``test``'`], 
                         batch_size=-`1`, 
                         as_supervised=`True`))

在这种情况下,我们使用 tfds.load,将 fashion_mnist 作为所需数据集传递。我们知道它有训练和测试分割,因此将它们作为数组传递将返回包含图像和标签的数据集适配器数组。在调用 tfds.load 中使用 tfds.as_numpy 会将它们作为 Numpy 数组返回。指定 batch_size=-1 给我们返回所有数据,而 as_supervised=True 确保我们得到返回的元组(输入、标签)。

一旦我们完成了这些更改,我们基本上就获得了与 Keras 数据集中相同的数据格式,只有一点不同——TFDS 中的形状是 (28, 28, 1),而在 Keras 数据集中是 (28, 28)。

这意味着代码需要做一些更改,以明确指定输入数据的形状为 (28, 28, 1),而不是 (28, 28):

`import` tensorflow `as` tf
`import` tensorflow_datasets `as` tfds

(training_images, training_labels), (test_images, test_labels) =  
tfds.as_numpy(tfds.load(`'``fashion_mnist``'`, split = [`'``train``'`, `'``test``'`], 
batch_size=-`1`, as_supervised=`True`))

training_images = training_images / `255.0`
test_images = test_images / `255.0`

model = tf.keras.models.`Sequential`([
    tf.keras.layers.`Flatten`(input_shape=(`28`,`28`,`1`)),
    tf.keras.layers.`Dense`(`128`, activation=tf.nn.relu),
    tf.keras.layers.`Dropout`(`0.2`),
    tf.keras.layers.`Dense`(`10`, activation=tf.nn.softmax)
])

model.compile(optimizer=`'``adam``'`,
              loss=`'``sparse_categorical_crossentropy``'`,
              metrics=[`'``accuracy``'`])

model.fit(training_images, training_labels, epochs=`5`)

对于更复杂的例子,你可以查看在第三章中使用的 Horses or Humans 数据集。这也可以在 TFDS 中找到。这是用它来训练模型的完整代码:

`import` tensorflow `as` tf
`import` tensorflow_datasets `as` tfds

data = tfds.load(`'``horses_or_humans``'`, split=`'``train``'`, as_supervised=`True`)

train_batches = data.shuffle(`100`).batch(`10`)

model = tf.keras.models.`Sequential`([
    tf.keras.layers.`Conv2D`(`16`, (`3`,`3`), activation=`'``relu``'`, 
                           input_shape=(`300`, `300`, `3`)),
    tf.keras.layers.`MaxPooling2D`(`2`, `2`),
    tf.keras.layers.`Conv2D`(`32`, (`3`,`3`), activation=`'``relu``'`),
    tf.keras.layers.`MaxPooling2D`(`2`,`2`),
    tf.keras.layers.`Conv2D`(`64`, (`3`,`3`), activation=`'``relu``'`),
    tf.keras.layers.`MaxPooling2D`(`2`,`2`),
    tf.keras.layers.`Conv2D`(`64`, (`3`,`3`), activation=`'``relu``'`),
    tf.keras.layers.`MaxPooling2D`(`2`,`2`),
    tf.keras.layers.`Conv2D`(`64`, (`3`,`3`), activation=`'``relu``'`),
    tf.keras.layers.`MaxPooling2D`(`2`,`2`),
    tf.keras.layers.`Flatten`(),
    tf.keras.layers.`Dense`(`512`, activation=`'``relu``'`),
    tf.keras.layers.`Dense`(`1`, activation=`'``sigmoid``'`)
])

model.compile(optimizer=`'``Adam``'`, loss=`'``binary_crossentropy``'`,
metrics=[`'``accuracy``'`])

history = model.fit(train_batches, epochs=`10`)

如你所见,这非常简单:只需调用 tfds.load,传递你想要的分割(在本例中为 train),然后在模型中使用它。数据被批处理和洗牌以使训练更有效。

Horses or Humans 数据集被分成了训练集和测试集,因此如果你想在训练时验证模型,你可以像这样从 TFDS 加载一个单独的验证集:

val_data = tfds.load(`'``horses_or_humans``'`, split=`'``test``'`, as_supervised=`True`)

你需要对其进行分批处理,就像对训练集做的那样。例如:

validation_batches = val_data.batch(`32`)

然后,在训练时,将验证数据指定为这些批次。你还必须显式地设置每个 epoch 使用的验证步数,否则 TensorFlow 将抛出错误。如果不确定,就像这样设置为 1

history = model.fit(train_batches, epochs=`10`,
validation_data=validation_batches, validation_steps=`1`)

加载特定版本

所有存储在 TFDS 中的数据集都使用 MAJOR.MINOR.PATCH 编号系统。此系统的保证如下。如果更新 PATCH,则调用返回的数据相同,但底层组织可能已更改。任何更改对开发者来说都是不可见的。如果更新 MINOR,则数据仍然不变,除了每条记录可能有额外的特征(非破坏性更改)。另外,对于任何特定的切片(见“使用自定义拆分”),数据将保持不变,因此记录不会重新排序。如果更新 MAJOR,则记录的格式及其位置可能会有所更改,因此特定的切片可能会返回不同的值。

当你检查数据集时,你会看到不同版本的可用性,例如,cnn_dailymail 数据集 就是一个例子。如果你不想使用默认的版本,在撰写本文时版本为 3.0.0,而是想使用早期的版本,比如 1.0.0,你可以像这样加载:

data, info = tfds.load(`"``cnn_dailymail:1.0.0``"`, with_info=`True`)

注意,如果你在使用 Colab,检查它所使用的 TFDS 版本总是一个好主意。在撰写本文时,Colab 预配置为 TFDS 2.0,但加载数据集时(包括 cnn_dailymail 数据集)可能会出现一些错误,在 TFDS 2.1 及更高版本中已修复了这些问题,因此请务必使用其中一个版本,或者至少安装它们到 Colab,而不要依赖内置的默认设置。

使用映射函数进行增强

在第三章中,你看到了使用 ImageDataGenerator 提供模型训练数据时可用的有用增强工具。也许你会想知道在使用 TFDS 时如何实现同样的效果,因为你不再像以前那样从子目录流动图像。实现这一点——或者任何其他形式的转换——的最佳方式是在数据适配器上使用映射函数。让我们看看如何做到这一点。

早些时候,我们通过 TFDS 加载了 Horses or Humans 数据,并像这样为其创建了批次:

data = tfds.load(`'``horses_or_humans``'`, split=`'``train``'`, as_supervised=`True`)

train_batches = data.shuffle(`100`).batch(`10`)

要进行变换并将其映射到数据集,你可以创建一个映射函数。这只是标准的 Python 代码。例如,假设你创建了一个名为augmentimages的函数,并让它进行一些图像增强,就像这样:

`def` augmentimages(image, label):
  image = tf.cast(image, tf.float32)
  image = (image/`255`)
  image = tf.image.random_flip_left_right(image)
  `return` image, label

然后,你可以将其映射到数据上,创建一个名为train的新数据集:

train = data.map(augmentimages)

然后,在创建批次时,从train而不是data中进行,像这样操作:

train_batches = train.shuffle(`100`).batch(`32`)

你可以看到在augmentimages函数中有一个图像左右随机翻转的操作,使用的是tf.image.random_flip_left_right(image)tf.image库中有许多函数可用于增强;详细信息请参阅文档

使用 TensorFlow 插件

TensorFlow 插件库包含更多你可以使用的函数。某些函数在ImageDataGenerator增强中(例如rotate)只能在这里找到,因此查看一下是个好主意。

使用 TensorFlow 插件非常简单——你只需用以下命令安装该库:

pip install tensorflow-addons

完成后,你可以将插件混合到你的映射函数中。以下是一个例子,展示了从先前映射函数中使用rotate插件的情况:

`import` tensorflow_addons `as` tfa

`def` augmentimages(image, label):
  image = tf.cast(image, tf.float32)
  image = (image/`255`)
  image = tf.image.random_flip_left_right(image)
  image = tfa.image.rotate(image, `40`, interpolation=`'``NEAREST``'`)
  `return` image, label

使用自定义分片

到目前为止,你用来构建模型的所有数据都已经预先分为训练集和测试集。例如,对于 Fashion MNIST,分别有 60,000 条和 10,000 条记录。但如果你不想使用这些分割怎么办?如果你想根据自己的需要分割数据怎么办?这就是 TFDS 的一个非常强大的方面之一——它配备了一个 API,可以精细地控制你如何分割数据。

实际上,当你像这样加载数据时,你已经见过它:

data = tfds.load(`'``cats_vs_dogs``'`, split=`'``train``'`, as_supervised=`True`)

注意split参数是一个字符串,在这种情况下,你要求的是train分片,这恰好是整个数据集。如果你熟悉Python 切片表示法,你也可以使用它。这种表示法可以总结为在方括号内定义你所需的切片,如下所示:[<start>: <stop>: <step>]。这是一种非常复杂的语法,为你提供了极大的灵活性。

例如,如果你想要train的前 10,000 条记录作为训练数据,你可以省略<start>,只需调用train[:10000](一个有用的助记法是将前导冒号读作“第一个”,因此这将读作“训练前 10,000 条记录”):

data = tfds.load(`'``cats_vs_dogs``'`, split=`'``train[:10000]``'`, as_supervised=`True`)

你也可以使用%来指定分片。例如,如果你想要使用前 20%的记录进行训练,可以像这样使用:20%

data = tfds.load(`'``cats_vs_dogs``'`, split=`'``train[:20``%``]``'`, as_supervised=`True`)

你甚至可以有些疯狂,将这些分割方法结合起来。也就是说,如果你希望你的训练数据是前一千条记录和最后一千条记录的组合,你可以这样做(其中 -1000: 表示“最后 1,000 条记录”,:1000 表示“前 1,000 条记录”):

data = tfds.load(`'``cats_vs_dogs``'`, split=`'``train[-1000:]+train[:1000]``'`, 
                 as_supervised=`True`)

Dogs vs. Cats 数据集没有固定的训练、测试和验证分割,但是使用 TFDS,创建自己的数据集非常简单。假设你希望分割为 80%,10%,10%。你可以像这样创建三个集合:

`train_data` `=` `tfds``.``load``(`'cats_vs_dogs'`,` `split``=`'train[:80%]'`,` 
 `as_supervised``=``True``)` 
`validation_data` `=` `tfds``.``load``(`'cats_vs_dogs'`,` `split``=`'train[80%:90%]'`,` 
 `as_supervised``=``True``)` 
`test_data` `=` `tfds``.``load``(`'cats_vs_dogs'`,` `split``=`'train[-10%:]'`,` as_supervised=`True``)`

一旦你有了它们,你可以像任何命名分割一样使用它们。

一个注意事项是,由于返回的数据集不能被用来查询其长度,通常很难检查你是否正确分割了原始集合。要查看分割中有多少条记录,你必须遍历整个集合并逐个计数。这里是你刚刚创建的训练集的代码:

train_length = [i `for` i,_ `in` enumerate(train_data)][-`1`] + `1`
`print`(train_length)

这可能是一个缓慢的过程,所以只有在调试时才使用它!

理解 TFRecord

当你使用 TFDS 时,你的数据被下载并缓存在磁盘上,这样每次使用时就不需要重新下载。TFDS 使用 TFRecord 格式进行缓存。如果你仔细观察下载数据的过程,你将看到这一点——例如,图 4-1 展示了如何下载、洗牌并将cnn_dailymail数据集写入 TFRecord 文件。

作为 TFRecord 文件下载 cnn_dailymail 数据集

图 4-1. 作为 TFRecord 文件下载 cnn_dailymail 数据集

这是 TensorFlow 中存储和检索大量数据的首选格式。它是一个非常简单的文件结构,顺序读取以获得更好的性能。在磁盘上,文件的结构非常直接,每个记录由一个表示记录长度的整数、其循环冗余检查(CRC)、数据的字节数组和该字节数组的 CRC 组成。记录被连接成文件,然后在大型数据集的情况下进行分片。

例如,图 4-2 展示了从cnn_dailymail下载后如何将训练集分片成 16 个文件。

要查看一个更简单的例子,下载 MNIST 数据集并打印其信息:

data, info = tfds.load(`"``mnist``"`, with_info=`True`)
`print`(info)

在信息中,你将看到其特征存储如下:

features=`FeaturesDict`({
    `'``image``'`: `Image`(shape=(`28`, `28`, `1`), dtype=tf.uint8),
    `'``label``'`: `ClassLabel`(shape=(), dtype=tf.int64, num_classes=`10`),
}),

类似于 CNN/DailyMail 的例子,该文件下载到/root/tensorflow_datasets/mnist//files

你可以像这样加载原始记录作为TFRecordDataset

`filename``=`"/root/tensorflow_datasets/mnist/3.0.0/
                                   mnist-test.tfrecord-00000-of-00001"
`raw_dataset` `=` `tf``.``data``.``TFRecordDataset``(``filename``)`
`for` `raw_record` `in` `raw_dataset``.``take``(``1``)``:`

`print`(repr(raw_record))

请注意,根据您的操作系统,文件名的位置可能会有所不同。

检查 cnn_dailymail 的 TFRecords

图 4-2. 检查 cnn_dailymail 的 TFRecords

这将打印出记录的原始内容,就像这样:

<tf.Tensor: shape=(), dtype=string,
numpy=b"\n\x85\x03\n\xf2\x02\n\x05image\x12\xe8\x02\n\xe5\x02\n\xe2\x02\x89PNG\r
\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x1c\x00\x00\x00\x1c\x08\x00\x00\x00\x00Wf
\x80H\x00\x00\x01)IDAT(\x91\xc5\xd2\xbdK\xc3P\x14\x05\xf0S(v\x13)\x04,.\x82\xc5A
q\xac\xedb\x1d\xdc\n.\x12\x87n\x0e\x82\x93\x7f@Q\xb2\x08\xba\tbQ0.\xe2\xe2\xd4\x
b1\xa2h\x9c\x82\xba\x8a(\nq\xf0\x83Fh\x95\n6\x88\xe7R\x87\x88\xf9\xa8Y\xf5\x0e\x
8f\xc7\xfd\xdd\x0b\x87\xc7\x03\xfe\xbeb\x9d\xadT\x927Q\xe3\xe9\x07:\xab\xbf\xf4\
xf3\xcf\xf6\x8a\xd9\x14\xd29\xea\xb0\x1eKH\xde\xab\xea%\xaba\x1b=\xa4P/\xf5\x02\
xd7\\\x07\x00\xc4=,L\xc0,>\x01@2\xf6\x12\xde\x9c\xde[t/\xb3\x0e\x87\xa2\xe2\
xc2\xe0A<\xca\xb26\xd5(\x1b\xa9\xd3\xe8\x0e\xf5\x86\x17\xceE\xdarV\xae\xb7_\xf3
I\xf7(\x06m\xaaE\xbb\xb6\xac\r*\x9b$e<\xb8\xd7\xa2\x0e\x00\xd0l\x92\xb2\xd5\x15\
xcc\xae'\x00\xf4m\x08O'+\xc2y\x9f\x8d\xc9\x15\x80\xfe\x99[q\x962@CN|i\xf7\xa9!=\
\xab\x19\x00\xc8\xd6\xb8\xeb\xa1\xf0\xd8l\xca\xfb]\xee\xfb]*\x9fV\xe1\x07\xb7\xc
9\x8b55\xe7M\xef\xb0\x04\xc0\xfd&\x89\x01<\xbe\xf9\x03*\x8a\xf5\x81\x7f\xaa/2y\x
87ks\xec\x1e\xc1\x00\x00\x00\x00IEND\xaeB`\x82\n\x0e\n\x05label\x12\x05\x1a\x03\
n\x01\x02">

这是一个包含记录详细信息及校验和等内容的长字符串。但如果我们已经了解了特征,我们可以创建一个特征描述,并用它来解析数据。以下是代码:

# Create a description of the features
`feature_description` `=` `{`
  `'``image``'``:` `tf``.``io``.``FixedLenFeature``(``[``]``,` `dtype``=``tf``.``string``)``,`
  `'``label``'``:` `tf``.``io``.``FixedLenFeature``(``[``]``,` `dtype``=``tf``.``int64``)``,`
`}`

`def` `_parse_function``(``example_proto``)``:`
  # Parse the input `tf.Example` proto using the dictionary above
  `return` `tf``.``io``.``parse_single_example``(``example_proto``,` `feature_description``)`

`parsed_dataset` `=` `raw_dataset``.``map``(``_parse_function``)`
`for` `parsed_record` `in` `parsed_dataset``.``take``(``1``)``:`
  `print``(``(``parsed_record``)``)`

这个输出更加友好!首先,您可以看到图像是一个Tensor,它包含一个 PNG 格式的图像。PNG 是一种压缩图像格式,其头部由IHDR定义,图像数据位于IDATIEND之间。如果仔细观察字节流,您也可以看到它们。还有存储为int类型并包含值2的标签:

{'image': <tf.Tensor: shape=(), dtype=string,
numpy=b"\x89PNG\r\n\x1a\n\x00\x00\x00\**`rIHDR`**\x00\x00\x00\x1c\x00\x00\x00\x1c\x08\
x00\x00\x00\x00Wf\x80H\x00\x00\x01)**`IDAT`**(\x91\xc5\xd2\xbdK\xc3P\x14\x05\xf0S(v\x1
3)\x04,.\x82\xc5Aq\xac\xedb\x1d\xdc\n.\x12\x87n\x0e\x82\x93\x7f@Q\xb2\x08\xba\tb
Q0.\xe2\xe2\xd4\xb1\xa2h\x9c\x82\xba\x8a(\nq\xf0\x83Fh\x95\n6\x88\xe7R\x87\x88\x
f9\xa8Y\xf5\x0e\x8f\xc7\xfd\xdd\x0b\x87\xc7\x03\xfe\xbeb\x9d\xadT\x927Q\xe3\xe9\
x07:\xab\xbf\xf4\xf3\xcf\xf6\x8a\xd9\x14\xd29\xea\xb0\x1eKH\xde\xab\xea%\xaba\x1
b=\xa4P/\xf5\x02\xd7\\\x07\x00\xc4=,L\xc0,>\x01@2\xf6\x12\xde\x9c\xde[t/\xb3\x0e
\x87\xa2\xe2\xc2\xe0A<\xca\xb26\xd5(\x1b\xa9\xd3\xe8\x0e\xf5\x86\x17\xceE\xdarV\
xae\xb7_\xf3AR\r!I\xf7(\x06m\xaaE\xbb\xb6\xac\r*\x9b$e<\xb8\xd7\xa2\x0e\x00\xd0l
\x92\xb2\xd5\x15\xcc\xae'\x00\xf4m\x08O'+\xc2y\x9f\x8d\xc9\x15\x80\xfe\x99[q\x96
2@CN|i\xf7\xa9!=\xd7
\xab\x19\x00\xc8\xd6\xb8\xeb\xa1\xf0\xd8l\xca\xfb]\xee\xfb]*\x9fV\xe1\x07\xb7\xc
9\x8b55\xe7M\xef\xb0\x04\xc0\xfd&\x89\x01<\xbe\xf9\x03*\x8a\xf5\x81\x7f\xaa/2y\x
87ks\xec\x1e\xc1\x00\x00\x00\**`x00IEND`**\xaeB`\x82">, 'label': <tf.Tensor: shape=(),
dtype=int64, numpy=**`2`**>}

在这一点上,您可以读取原始的 TFRecord 并使用像 Pillow 这样的 PNG 解码器库将其解码为 PNG。

TensorFlow 中管理数据的 ETL 过程

ETL 是 TensorFlow 在训练中使用的核心模式,无论规模如何。在本书中,我们一直在探索小规模、单机模型构建,但相同的技术也可以用于跨多台机器进行大规模训练,使用海量数据集。

提取阶段 是 ETL 过程的一部分,当原始数据从存储位置加载并准备好可以转换的方式时。转换 阶段是当数据以适合或改进用于训练的方式进行操作时。例如,批处理、图像增强、映射到特征列等逻辑可以被视为此阶段的一部分。加载 阶段是当数据加载到神经网络进行训练时。

考虑到训练马匹或人类分类器的完整代码,如下所示。我已经添加了注释,显示了提取、转换和加载阶段的位置:

`import` tensorflow `as` tf
`import` tensorflow_datasets `as` tfds
`import` tensorflow_addons `as` tfa

`# MODEL DEFINITION START #`
model = tf.keras.models.`Sequential`([
    tf.keras.layers.`Conv2D`(`16`, (`3`,`3`), activation=`'``relu``'`, 
                           input_shape=(`300`, `300`, `3`)),
    tf.keras.layers.`MaxPooling2D`(`2`, `2`),
    tf.keras.layers.`Conv2D`(`32`, (`3`,`3`), activation=`'``relu``'`),
    tf.keras.layers.`MaxPooling2D`(`2`,`2`),
    tf.keras.layers.`Conv2D`(`64`, (`3`,`3`), activation=`'``relu``'`),
    tf.keras.layers.`MaxPooling2D`(`2`,`2`),
    tf.keras.layers.`Conv2D`(`64`, (`3`,`3`), activation=`'``relu``'`),
    tf.keras.layers.`MaxPooling2D`(`2`,`2`),
    tf.keras.layers.`Conv2D`(`64`, (`3`,`3`), activation=`'``relu``'`),
    tf.keras.layers.`MaxPooling2D`(`2`,`2`),
    tf.keras.layers.`Flatten`(),
    tf.keras.layers.`Dense`(`512`, activation=`'``relu``'`),
    tf.keras.layers.`Dense`(`1`, activation=`'``sigmoid``'`)
])
model.compile(optimizer=`'``Adam``'`, loss=`'``binary_crossentropy``'`, 
              metrics=[`'``accuracy``'`])
`# MODEL DEFINITION END #`

`# EXTRACT PHASE START #`
data = tfds.load(`'``horses_or_humans``'`, split=`'``train``'`, as_supervised=`True`)
val_data = tfds.load(`'``horses_or_humans``'`, split=`'``test``'`, as_supervised=`True`)
`# EXTRACT PHASE END`

`# TRANSFORM PHASE START #`
`def` augmentimages(image, label):
  image = tf.cast(image, tf.float32)
  image = (image/`255`)
  image = tf.image.random_flip_left_right(image)
  image = tfa.image.rotate(image, `40`, interpolation=`'``NEAREST``'`)
  `return` image, label

train = data.map(augmentimages)
train_batches = train.shuffle(`100`).batch(`32`)
validation_batches = val_data.batch(`32`)
`# TRANSFORM PHASE END`

`# LOAD PHASE START #`
history = model.fit(train_batches, epochs=`10`, 
                    validation_data=validation_batches, validation_steps=`1`)
`# LOAD PHASE END #`

使用这个过程可以使您的数据管道对数据和底层架构的变化更不易受到影响。当您使用 TFDS 提取数据时,不管数据是小到可以放入内存,还是大到简单的机器无法容纳,都会使用相同的底层结构。tf.data转换的 API 也是一致的,因此您可以使用类似的 API,无论底层数据源如何。当然,一旦转换完成,加载数据的过程也是一致的,无论您是在单个 CPU、GPU、GPU 集群甚至 TPU Pod 上进行训练。

然而,如何加载数据会对您的训练速度产生巨大影响。让我们接下来看看这一点。

优化加载阶段

让我们更仔细地看一下在训练模型时的提取-转换-加载过程。我们可以考虑数据的提取和转换可以在任何处理器上完成,包括 CPU。实际上,在这些阶段使用的代码执行任务,如下载数据、解压缩以及逐条记录地处理它们,不是 GPU 或 TPU 的用途,因此这些代码可能最终还是在 CPU 上执行。然而,当涉及到训练时,你可以从 GPU 或 TPU 中获得很大的好处,因此如果可能的话,在这个阶段使用 GPU 或 TPU 是有意义的。因此,在你可以使用 GPU 或 TPU 的情况下,最好将工作负载分配到 CPU 和 GPU/TPU 之间,提取和转换在 CPU 上进行,加载在 GPU/TPU 上进行。

假设你正在处理一个大型数据集。假设数据集如此之大,以至于你必须分批准备数据(即进行提取和转换),你将会得到类似于在 图 4-3 中展示的情况。在准备第一批数据时,GPU/TPU 是空闲的。当第一批数据准备好后,可以将其发送到 GPU/TPU 进行训练,但此时 CPU 则处于空闲状态,直到训练完成,然后才能开始准备第二批数据。这里存在大量的空闲时间,因此我们可以看到这里有优化的空间。

在 CPU/GPU 上训练

图 4-3. 在 CPU/GPU 上训练

逻辑解决方案是并行进行工作,准备和训练并行进行。这个过程称为管道化,并且在 图 4-4 中有所示。

管道化

图 4-4. 管道化

在这种情况下,当 CPU 准备第一批数据时,GPU/TPU 再次没有工作可做,因此它是空闲的。当第一批数据准备好后,GPU/TPU 可以开始训练,但与此同时,CPU 将准备第二批数据。当然,训练批次 n – 1 和准备批次 n 所需的时间不会总是相同。如果训练时间更快,您将在 GPU/TPU 上有空闲时间。如果更慢,则 CPU 将有空闲时间。选择正确的批次大小可以帮助您在这里进行优化——而且由于 GPU/TPU 的时间可能更昂贵,您可能希望尽可能减少其空闲时间。

当我们从使用 Keras 中的简单数据集(如时尚 MNIST)转到使用 TFDS 版本时,您可能注意到必须在训练之前对它们进行批处理。这就是为什么:管道化模型被设计成无论数据集有多大,您都将继续使用其上的 ETL 的一致模式。

并行化 ETL 以提高训练性能

TensorFlow 为您提供了所有并行化提取和转换过程所需的 API。让我们使用狗 vs. 猫和底层的 TFRecord 结构来探索它们的样子。

首先,您使用 tfds.load 来获取数据集:

train_data = tfds.load(`'``cats_vs_dogs``'`, split=`'``train``'`, with_info=`True`)

如果你想使用底层的 TFRecords,你需要访问下载的原始文件。由于数据集很大,在版本 4.0.0 中分成了多个文件(8 个)。

你可以创建这些文件的列表,并使用 tf.Data.Dataset.list_files 加载它们:

`file_pattern` `=` 
 `f`'/root/tensorflow_datasets/cats_vs_dogs/4.0.0/cats_vs_dogs-train.tfrecord*'
`files` `=` `tf``.``data``.``Dataset``.``list_files``(``file_pattern``)`

一旦你有了这些文件,可以使用 files.interleave 将它们加载到数据集中,就像这样:

train_dataset = files.interleave(
                     tf.data.`TFRecordDataset`, 
                     cycle_length=`4`,
                     num_parallel_calls=tf.data.experimental.AUTOTUNE
                )

这里有一些新概念,让我们花点时间来探索它们。

cycle_length 参数指定同时处理的输入元素数量。因此,马上你将看到从磁盘加载时解码记录的映射函数。因为 cycle_length 设置为 4,所以此过程将同时处理四条记录。如果不指定此值,则将根据可用 CPU 核心数来推导。

当设置 num_parallel_calls 参数时,将指定要执行的并行调用数。在这里使用 tf.data.experimental.AUTOTUNE 会使你的代码更具可移植性,因为该值是动态设置的,根据可用的 CPU。与 cycle_length 结合使用时,你正在设置最大并行度。因此,例如,如果在自动调整后将 num_parallel_calls 设置为 6cycle_length 设置为 4,那么将有六个单独的线程,每个线程一次加载四条记录。

现在提取过程已经并行化,让我们来探索数据转换的并行化。首先,创建加载原始 TFRecord 并将其转换为可用内容的映射函数,例如将 JPEG 图像解码为图像缓冲区:

`def` read_tfrecord(serialized_example):
  feature_description={
      `"``image``"`: tf.io.`FixedLenFeature`((), tf.string, `"``"`),
      `"``label``"`: tf.io.`FixedLenFeature`((), tf.int64, -`1`),
  }
  example = tf.io.parse_single_example(
       serialized_example, feature_description
  )
  image = tf.io.decode_jpeg(example[`'``image``'`], channels=`3`)
  image = tf.cast(image, tf.float32)
  image = image / `255`
  image = tf.image.resize(image, (`300`,`300`))
  `return` image, example[`'``label``'`]

正如你所看到的,这是一个典型的映射函数,没有做任何特定的工作来并行执行。在调用映射函数时,将会执行这些工作。这是如何做到的:

cores = multiprocessing.cpu_count()
`print`(cores)
train_dataset = train_dataset.map(read_tfrecord, num_parallel_calls=cores)
train_dataset = train_dataset.cache()

首先,如果你不想自动调整,可以使用 multiprocessing 库来获取你的 CPU 数量。然后,在调用映射函数时,将这个数字作为你想要进行的并行调用的数量传递进去。就是这么简单。

cache 方法将在内存中缓存数据集。如果你有大量的 RAM 可用,这将是一个非常有用的加速。尝试在 Colab 中使用 Dogs vs. Cats 很可能会因为数据集不适合内存而导致虚拟机崩溃。此后,如果可用,Colab 基础设施将为你提供一个新的、更高 RAM 的机器。

加载和训练也可以并行化。除了对数据进行洗牌和分批处理外,你还可以根据可用的 CPU 核心数量进行预取。以下是代码:

train_dataset = train_dataset.shuffle(`1024`).batch(`32`)
train_dataset = train_dataset.prefetch(tf.data.experimental.AUTOTUNE)

一旦你的训练集全部并行化,就可以像以前一样训练模型:

model.fit(train_dataset, epochs=`10`, verbose=`1`)

当我在 Google Colab 中尝试时,我发现这些额外的并行化 ETL 过程的代码将训练时间缩短到每个时期约 40 秒,而没有这些代码则为 75 秒。这些简单的更改几乎使我的训练时间减少了一半!

摘要

本章介绍了 TensorFlow Datasets,这是一个库,为您提供了从小型学习数据集到用于研究的大规模数据集的访问权限。您看到它们如何使用通用的 API 和通用格式来帮助减少您编写的代码量,以获取数据访问权限。您还学习了如何使用 ETL 过程,这是 TFDS 设计的核心,并特别探讨了并行化数据的提取、转换和加载,以改善训练性能。在下一章中,您将把学到的知识应用到自然语言处理问题中。

第五章:自然语言处理简介

自然语言处理(NLP)是人工智能中处理理解人类语言的技术。它涉及编程技术,用于创建能够理解语言、分类内容,甚至生成和创作新人类语言组合的模型。在接下来的几章中,我们将探讨这些技术。还有很多服务使用 NLP 创建应用程序,如聊天机器人,但这不在本书的范围内——相反,我们将研究 NLP 的基础以及如何建模语言,以便你能训练神经网络理解和分类文本。稍作调剂,你还将看到如何利用机器学习模型的预测元素来写一些诗歌!

我们将从如何将语言分解成数字开始这一章节,并探讨这些数字如何在神经网络中使用。

将语言编码成数字

你可以用多种方式将语言编码成数字。最常见的方法是按字母编码,就像在程序中存储字符串时自然而然地做的那样。然而,在内存中,你不是存储字母a,而是它的编码——也许是 ASCII 或 Unicode 值,或者其他什么。例如,考虑单词listen。可以用 ASCII 将其编码为数字 76、73、83、84、69 和 78。这样做很好,因为现在你可以用数字来代表这个词。但是再考虑一下单词silent,它是listen的反字。同样的数字表示那个单词,尽管顺序不同,这可能会使建立理解文本的模型变得有些困难。

注意

反字是一个单词,它是另一个单词的字谜,但意思相反。例如,uniteduntied是反字,restfulfluster也是,SantaSatanforty-fiveover fifty。我的职称过去是开发者福音使,但现在改为开发者倡导者——这是一件好事,因为福音使邪恶的代理人的反字!

一个更好的选择可能是使用数字来编码整个单词而不是其中的字母。在这种情况下,silent可以是数字xlisten可以是数字y,它们不会彼此重叠。

使用这种技术,考虑一句话像“I love my dog.”。你可以用数字[1, 2, 3, 4]来编码它。如果你想编码“I love my cat.”,它可能是[1, 2, 3, 5]。你已经到了能够告诉这些句子有相似含义的地步,因为它们在数值上相似——[1, 2, 3, 4]看起来很像[1, 2, 3, 5]。

这个过程称为标记化,接下来你将学习如何在代码中实现它。

开始标记化

TensorFlow Keras 包含一个名为preprocessing的库,提供了许多非常有用的工具来准备机器学习数据。其中之一是一个Tokenizer,它允许您将单词转换为标记。让我们用一个简单的例子来看它的工作原理:

`import` tensorflow `as` tf
`from` tensorflow `import` keras
`from` tensorflow.keras.preprocessing.text `import` `Tokenizer`

sentences = [
    `'``Today is a sunny day``'`,
    `'``Today is a rainy day``'`
]

tokenizer = `Tokenizer`(num_words = `100`)
tokenizer.fit_on_texts(sentences)
word_index = tokenizer.word_index
`print`(word_index)

在这种情况下,我们创建了一个Tokenizer对象,并指定它可以标记化的单词数。这将是从单词语料库生成的最大标记数。这里我们的语料库非常小,只包含六个唯一的单词,因此我们将远远低于指定的一百个单词。

一旦我们有了一个分词器,调用fit_on_texts将创建分词的单词索引。打印出来将显示语料库中单词的一组键/值对,如下所示:

`{`'today'`:` `1``,` 'is'`:` `2``,` 'a'`:` `3``,` 'day'`:` `4``,` 'sunny'`:` `5``,` 'rainy'`:` `6``}`

分词器非常灵活。例如,如果我们用另一个包含单词“今天”的句子扩展语料库,但后面加上了问号,结果显示它会智能地过滤掉“今天?”只保留“今天”:

sentences = [
    `'``Today is a sunny day``'`,
    `'``Today is a rainy day``'`,
    `'``Is it sunny today?``'`
]

{`'``today``'`: `1`, `'``is``'`: `2`, `'``a``'`: `3`, `'``sunny``'`: `4`, `'``day``'`: `5`, `'``rainy``'`: `6`, `'``it``'`: `7`}

这种行为由分词器的filters参数控制,默认情况下除了撇号字符之外会删除所有标点符号。因此,例如,“今天是个晴天”将变成一个包含[1, 2, 3, 4, 5]的序列,而“今天是晴天吗?”将变成[2, 7, 4, 1]。一旦您的句子中的单词被分词,下一步就是将您的句子转换为数字列表,其中数字是单词作为键的值。

将句子转换为序列

现在您已经看到如何将单词分词成数字,下一步是将句子编码成数字序列。这个分词器有一个叫做text_to_sequences的方法——您只需将您的句子列表传递给它,它将返回一个序列列表。因此,例如,如果您像这样修改前面的代码:

sentences = [
    `'``Today is a sunny day``'`,
    `'``Today is a rainy day``'`,
    `'``Is it sunny today?``'`
]

tokenizer = `Tokenizer`(num_words = `100`)
tokenizer.fit_on_texts(sentences)
word_index = tokenizer.word_index

`sequences` `=` `tokenizer``.``texts_to_sequences``(``sentences``)`

`print`(sequences)

您将获得表示这三个句子的序列。记住单词索引如下:

`{`'today'`:` `1``,` 'is'`:` `2``,` 'a'`:` `3``,` 'sunny'`:` `4``,` 'day'`:` `5``,` 'rainy'`:` `6``,` 'it'`:` `7``}`

输出将如下所示:

[[`1`, `2`, `3`, `4`, `5`], [`1`, `2`, `3`, `6`, `5`], [`2`, `7`, `4`, `1`]]

然后您可以用单词替换数字,您将看到句子是有意义的。

现在考虑一下,如果您在一组数据上训练神经网络会发生什么。典型模式是,您有一组用于训练的数据,您知道它不会覆盖您所有的需求,但您希望它尽可能地覆盖。在自然语言处理的情况下,您可能有成千上万个单词在您的训练数据中,用在许多不同的上下文中,但您不可能在每种可能的上下文中都有每个可能的单词。因此,当您向神经网络展示一些新的、以前未见过的文本,包含以前未见过的单词时,可能会发生什么?您猜对了——它会感到困惑,因为它根本没有这些单词的上下文,结果它给出的任何预测都会受到负面影响。

使用了超出词汇表的标记

处理这些情况的一种工具是超出词汇表(OOV)标记。这可以帮助你的神经网络理解包含以前未见过文本的数据的上下文。例如,考虑前面的小例子语料库,假设你想处理这样的句子:

test_data = [
    `'``Today is a snowy day``'`,
    `'``Will it be rainy tomorrow?``'`
]

记住,你并不是将此输入添加到现有文本语料库中(可以将其视为你的训练数据),而是考虑预训练网络可能如何查看此文本。如果使用你已经使用过的单词和现有的分词器对其进行分词,就像这样:

test_sequences = tokenizer.texts_to_sequences(test_data)
`print`(word_index)
`print`(test_sequences)

你的结果将如下所示:

`{`'today'`:` `1``,` 'is'`:` `2``,` 'a'`:` `3``,` 'sunny'`:` `4``,` 'day'`:` `5``,` 'rainy'`:` `6``,` 'it'`:` `7``}`
`[``[``1``,` `2``,` `3``,` `5``]``,` `[``7``,` `6``]``]`

所以新的句子,将单词替换回标记后,会变成“today is a day”和“it rainy.”

正如你所看到的,你几乎失去了所有的上下文和含义。在这里可能会有帮助的是一个超出词汇表的标记,你可以在分词器中指定它。你可以通过添加一个称为oov_token的参数来实现这一点,就像这样——你可以分配任何你喜欢的字符串,但确保它不是语料库中其他地方出现过的字符串:

tokenizer = Tokenizer(num_words = 100, `oov_token``=``"``<OOV>``"`)
tokenizer.fit_on_texts(sentences)
word_index = tokenizer.word_index

sequences = tokenizer.texts_to_sequences(sentences)

test_sequences = tokenizer.texts_to_sequences(test_data)
print(word_index)
print(test_sequences)

你会看到输出稍有改善:

`{`'<OOV>'`:` `1``,` 'today'`:` `2``,` 'is'`:` `3``,` 'a'`:` `4``,` 'sunny'`:` `5``,` 'day'`:` `6``,` 'rainy'`:` `7``,` 
  'it'`:` `8``}`

`[``[``2``,` `3``,` `4``,` `1``,` `6``]``,` `[``1``,` `8``,` `1``,` `7``,` `1``]``]`

你的标记列表有了一个新项目,“”,而你的测试句子保持了它们的长度。反向编码它们现在会得到“today is a day”和“ it rainy .”

前者更接近原始含义。后者因为大部分词汇不在语料库中,仍然缺乏很多上下文,但这是朝着正确方向迈出的一步。

理解填充

在训练神经网络时,通常需要使所有数据具有相同的形状。回想一下前几章,当处理图像训练时,你将图像重新格式化为相同的宽度和高度。处理文本时,你面临相同的问题——一旦对单词进行了分词并将句子转换为序列,它们可能具有不同的长度。为了使它们具有相同的大小和形状,你可以使用填充

要探索填充的功能,让我们在语料库中添加另一个更长的句子:

sentences = [
    `'``Today is a sunny day``'`,
    `'``Today is a rainy day``'`,
    `'``Is it sunny today?``'`,
    `'``I really enjoyed walking in the snow today``'`
]

当你对它进行序列化时,你会看到你的数字列表长度不同:

[
  [`2`, `3`, `4`, `5`, `6`], 
  [`2`, `3`, `4`, `7`, `6`], 
  [`3`, `8`, `5`, `2`], 
  [`9`, `10`, `11`, `12`, `13`, `14`, `15`, `2`]
]

(当你打印这些序列时,它们会全部在一行上,但我在这里为了清晰起见将它们分成了不同的行。)

如果你想使它们具有相同的长度,你可以使用pad_sequences API。首先,你需要导入它:

`from` tensorflow.keras.preprocessing.sequence `import` pad_sequences

使用这个 API 非常简单。要将(未填充的)序列转换为填充后的集合,你只需调用pad_sequences,像这样:

padded = pad_sequences(sequences)

`print`(padded)

你将得到一组格式良好的序列。它们也会像这样分开显示在不同的行上:

[[ `0`  `0`  `0`  `2`  `3`  `4`  `5`  `6`]
 [ `0`  `0`  `0`  `2`  `3`  `4`  `7`  `6`]
 [ `0`  `0`  `0`  `0`  `3`  `8`  `5`  `2`]
 [ `9` `10` `11` `12` `13` `14` `15`  `2`]]

序列将被填充为0,这不是我们单词列表中的标记。如果你曾想知道为什么标记列表从 1 开始,而程序员通常从 0 开始计数,现在你知道了!

现在你有了一个经过规则化处理的东西,可以用于训练。但在深入讨论之前,让我们稍微探讨一下这个 API,因为它提供了许多可以用来改进数据的选项。

首先,您可能已经注意到,在较短的句子的情况下,为了使它们与最长句子的形状相同,必须在开头添加相应数量的零。这称为预填充,这是默认行为。您可以使用padding参数来更改此行为。例如,如果您希望您的序列在末尾填充零,可以使用:

padded = pad_sequences(sequences, padding=`'``post``'`)

由此输出的结果将是:

[[ `2`  `3`  `4`  `5`  `6`  `0`  `0`  `0`]
 [ `2`  `3`  `4`  `7`  `6`  `0`  `0`  `0`]
 [ `3`  `8`  `5`  `2`  `0`  `0`  `0`  `0`]
 [ `9` `10` `11` `12` `13` `14` `15`  `2`]]

您可以看到现在单词在填充序列的开头,而0字符在末尾。

您可能注意到的下一个默认行为是,所有的句子都被制作成与最长句子相同的长度。这是一个合理的默认行为,因为它意味着您不会丢失任何数据。这样做的代价是会有很多填充。但是如果您不希望这样,也许因为您有一个非常长的句子,这意味着在填充的序列中会有太多的填充。为了解决这个问题,您可以在调用pad_sequences时使用maxlen参数,指定所需的最大长度,例如:

padded = pad_sequences(sequences, padding='post', `maxlen=6`)

由此输出的结果将是:

[[ `2`  `3`  `4`  `5`  `6`  `0`]
 [ `2`  `3`  `4`  `7`  `6`  `0`]
 [ `3`  `8`  `5`  `2`  `0`  `0`]
 [`11` `12` `13` `14` `15`  `2`]]

现在,您的填充序列的长度都相同,并且填充不过多。不过,您最长的句子确实丢失了一些单词,并且它们被从句子的开头截断了。如果您不想丢失开头的单词,而是希望它们从句子的末尾截断,您可以使用truncating参数覆盖默认行为,如下所示:

padded = pad_sequences(sequences, padding='post', maxlen=6, `truncating='post'`)

现在的结果将显示最长的句子现在是在结尾而不是开头截断:

[[ `2`  `3`  `4`  `5`  `6`  `0`]
 [ `2`  `3`  `4`  `7`  `6`  `0`]
 [ `3`  `8`  `5`  `2`  `0`  `0`]
 [ `9` `10` `11` `12` `13` `14`]]
注意

TensorFlow 支持使用“ragged”(形状不同的)张量进行训练,这非常适合 NLP 的需求。使用它们比我们在本书中覆盖的内容更为高级,但是一旦您完成了接下来几章提供的 NLP 介绍,您可以探索文档以获取更多信息。

去除停用词和清理文本

在下一节中,您将看到一些真实世界的数据集,您会发现通常有一些您不希望在数据集中的文本。您可能希望过滤掉所谓的停用词,它们太常见且没有任何意义,例如“the”,“and”和“but”。您在文本中可能还会遇到许多 HTML 标签,最好有一种清理方法将它们移除。您可能还希望过滤掉其他诸如粗鲁的词、标点符号或姓名之类的内容。稍后我们将探索一个推特数据集,这些推特通常会包含某人的用户 ID,我们希望将其过滤掉。

尽管每个任务基于您的文本语料库都是不同的,但有三件主要的事情可以通过编程方式清理文本。

第一个是去除 HTML 标签。幸运的是,有一个叫做BeautifulSoup的库可以轻松实现这一点。例如,如果您的句子包含 HTML 标签如<br>,则可以通过以下代码将其删除:

`from` `bs4` `import`  BeautifulSoup
`soup` `=` BeautifulSoup`(``sentence``)`
`sentence` `=` `soup``.``get_text``(``)`

删除停用词的常见方法是使用停用词列表预处理您的句子,删除停用词的实例。这里有一个简化的例子:

`stopwords` `=` `[`"a"`,` "about"`,` "above"`,` ...  "yours"`,` "yourself"`,` "yourselves"`]`

完整的停用词列表可以在本章的一些在线示例中找到。

然后,在迭代句子时,您可以使用如下代码从句子中删除停用词:

words = sentence.split()
filtered_sentence = `"``"`
`for` word `in` words:
    `if` word `not` `in` stopwords:
        filtered_sentence = filtered_sentence + word + `"` `"`
sentences.append(filtered_sentence)

您可能还考虑删除标点符号,这可能会误导停用词移除器。刚刚展示的方法寻找被空格包围的单词,因此紧跟在句号或逗号后面的停用词将不会被发现。

使用 Python string库提供的翻译函数轻松解决这个问题。它还提供了一个常量,string.punctuation,其中包含一组常见的标点符号,因此,要从单词中删除它们,您可以执行以下操作:

import  string
`table` `=` `str``.``maketrans``(``'``'``,` `'``'``,` string`.``punctuation``)`
`words` `=` `sentence``.``split``(``)`
`filtered_sentence` `=` `"``"`
for `word` in `words``:`
 `word` `=` `word``.``translate``(``table``)`
  if `word` not  in `stopwords``:`
 `filtered_sentence` `=` `filtered_sentence` `+` `word` `+` `"` `"`
`sentences``.``append``(``filtered_sentence``)`

在这里,在过滤停用词之前,句子中的每个单词都删除了标点符号。因此,如果分割句子后得到单词“it;”,它将被转换为“it”,然后作为停用词删除。然而,请注意,当进行此操作时,您可能需要更新停用词列表。这些列表通常包含缩写词和像“you’ll”这样的缩略词。翻译程序将“you’ll”更改为“youll”,如果您希望将其过滤掉,则需要更新您的停用词列表以包含它。

遵循这三个步骤将会为您提供一个更加干净的文本集合。当然,每个数据集都会有其特殊性,您需要与之配合工作。

处理真实数据源

现在,您已经了解了获取句子、使用单词索引对其进行编码并排序结果的基本知识,可以通过将一些知名的公共数据集与 Python 提供的工具结合使用,将其转换为可以轻松排序的格式。我们将从 TensorFlow 数据集中已经为您完成了大部分工作的 IMDb 数据集开始。之后,我们将更加亲手操作,处理基于 JSON 的数据集和包含情绪数据的几个逗号分隔值(CSV)数据集!

从 TensorFlow 数据集获取文本

我们在第四章中探讨了 TFDS,所以如果您在本节的某些概念上遇到困难,可以快速查看那里。TFDS 的目标是尽可能地简化以标准化方式获取数据的过程。它提供了对多个基于文本的数据集的访问;我们将探索imdb_reviews,这是来自互联网电影数据库(IMDb)的 50,000 条带有正面或负面情感标签的影评数据集。

此代码将从 IMDb 数据集加载训练拆分,并遍历它,将包含评论的文本字段添加到名为imdb_sentences的列表中。评论是包含评论情感的文本和标签的元组。请注意,通过将tfds.load调用包装在tfds.as_numpy中,您确保数据将被加载为字符串,而不是张量:

imdb_sentences = []
train_data = tfds.as_numpy(tfds.load(`'``imdb_reviews``'`, split=`"``train``"`))
`for` item `in` train_data:
    imdb_sentences.append(str(item[`'``text``'`]))

一旦你有了句子,你就可以创建一个分词器并像以前那样将它们拟合到其中,同时创建一个序列集:

tokenizer = tf.keras.preprocessing.text.`Tokenizer`(num_words=`5000`)
tokenizer.fit_on_texts(imdb_sentences)
sequences = tokenizer.texts_to_sequences(imdb_sentences)

你也可以打印出你的单词索引来检查它:

`print`(tokenizer.word_index)

它太大了,不能显示整个索引,但这里是前 20 个单词。请注意,分词器按照数据集中单词的频率顺序列出它们,因此像“the”、“and”和“a”这样的常见单词被索引了:

{'the': 1, 'and': 2, 'a': 3, 'of': 4, 'to': 5, 'is': 6, 'br': 7, 'in': 8, 
 'it': 9, 'i': 10, 'this': 11, 'that': 12, 'was': 13, 'as': 14, 'for': 15,  
 'with': 16, 'movie': 17, 'but': 18, 'film': 19, "'s": 20, ...}

这些是停用词,如前一节所述。由于它们是最常见的单词且不明显,它们可能会影响您的训练准确性。

还要注意,“br”包含在此列表中,因为它在语料库中常用作<br>HTML 标签。

你可以更新代码,使用BeautifulSoup去除 HTML 标签,添加字符串翻译以删除标点符号,并从给定列表中删除停用词如下:

from bs4 import BeautifulSoup
import string

stopwords = ["a", ... , "yourselves"]

table = str.maketrans('', '', string.punctuation)

imdb_sentences = []
train_data = tfds.as_numpy(tfds.load('imdb_reviews', split="train"))
for item in train_data:
    sentence = str(item['text'].decode('UTF-8').lower())
    soup = BeautifulSoup(sentence)
    sentence = soup.get_text()
    words = sentence.split()
    filtered_sentence = ""
    for word in words:
        word = word.translate(table)
        if word not in stopwords:
            filtered_sentence = filtered_sentence + word + " "
    imdb_sentences.append(filtered_sentence)

tokenizer = tf.keras.preprocessing.text.Tokenizer(num_words=25000)
tokenizer.fit_on_texts(imdb_sentences)
sequences = tokenizer.texts_to_sequences(imdb_sentences)
print(tokenizer.word_index)

所有的句子在处理之前都会转换为小写,因为所有的停用词都存储在小写中。当你打印出你的单词索引时,你会看到这样:

{'movie': 1, 'film': 2, 'not': 3, 'one': 4, 'like': 5, 'just': 6, 'good': 7, 
 'even': 8, 'no': 9, 'time': 10, 'really': 11, 'story': 12, 'see': 13, 
 'can': 14, 'much': 15, ...}

您可以看到,这比以前要干净得多。然而,总有改进的余地,当我查看完整的索引时,我注意到末尾的一些不太常见的单词是荒谬的。通常评论者会组合词语,例如用破折号(“annoying-conclusion”)或斜杠(“him/her”),而去除标点符号会错误地将它们转换为单个单词。您可以通过在创建句子后立即添加一些代码来避免这种情况,在这里我添加了以下内容:

sentence = sentence.replace(`"``,``"`, `"` `,` `"`)
sentence = sentence.replace(`"``.``"`, `"` `.` `"`)
sentence = sentence.replace(`"``-``"`, `"` `-` `"`)
sentence = sentence.replace(`"``/``"`, `"` `/` `"`)

这将组合词如“him/her”转换为“him / her”,然后将“/”去除并分词为两个单词。这可能会在后续的训练结果中带来更好的效果。

现在您有了语料库的分词器,您可以对句子进行编码。例如,我们之前在本章中看到的简单句子将会像这样输出:

sentences = [
    `'``Today is a sunny day``'`,
    `'``Today is a rainy day``'`,
    `'``Is it sunny today?``'`
]
sequences = tokenizer.texts_to_sequences(sentences)
`print`(sequences)

[[`516`, `5229`, `147`], [`516`, `6489`, `147`], [`5229`, `516`]]

如果你解码它们,你会发现停用词被删除了,你得到的句子编码为“今天晴天”,“今天雨天”,和“晴天今天”。

如果你想在代码中实现这一点,你可以创建一个新的dict,将键和值颠倒(即,对于单词索引中的键/值对,将值作为键,将键作为值),然后从中进行查找。以下是代码:

reverse_word_index = dict(
    [(value, key) for (key, value) in tokenizer.word_index.items()])

decoded_review = ' '.join([reverse_word_index.get(i, '?') for i in sequences[0]])

print(decoded_review)

这将产生以下结果:

today sunny day

使用 IMDb 子词数据集

TFDS 还包含几个使用子词预处理的 IMDb 数据集。在这里,您无需按单词分割句子;它们已经被分割成子词。使用子词是在将语料库分割成单个字母(具有较低语义意义的相对较少标记)和单个单词(具有高语义意义的许多标记)之间的一种折衷方法,这种方法通常非常有效地用于语言分类器的训练。这些数据集还包括用于分割和编码语料库的编码器和解码器。

要访问它们,可以调用tfds.load并像这样传递imdb_reviews/subwords8kimdb_reviews/subwords32k

(train_data, test_data), info = tfds.load(
    `'``imdb_reviews/subwords8k``'`, 
    split = (tfds.`Split`.TRAIN, tfds.`Split`.TEST),
    as_supervised=`True`,
    with_info=`True`
)

您可以像这样访问info对象上的编码器。这将帮助您查看vocab_size

encoder = info.features[`'``text``'`].encoder
`print` (`'``Vocabulary size: {}``'`.format(encoder.vocab_size))

这将输出8185,因为在此实例中,词汇表由 8,185 个标记组成。如果要查看子词列表,可以使用encoder.subwords属性获取它:

`print`(encoder.subwords)

[`'``the_``'`, `'``,` `'`, `'``.` `'`, `'``a_``'`, `'``and_``'`, `'``of_``'`, `'``to_``'`, `'``s_``'`, `'``is_``'`, `'``br``'`, `'``in_``'`, `'``I_``'`, 
 `'``that_``'`,...]

在这里你可能注意到的一些事情是停用词、标点和语法都在语料库中,HTML 标签如<br>也在其中。空格用下划线表示,所以第一个标记是单词“the”。

如果您想要编码一个字符串,可以像这样使用编码器:

`sample_string` `=` 'Today is a sunny day'

`encoded_string` `=` `encoder``.``encode``(``sample_string``)`
`print` `(`'Encoded string is {}'`.``format``(``encoded_string``)``)`

这将输出一个标记列表:

`Encoded`  `string`  `is` `[`6427`,` 4869`,` 9`,` 4`,` 2365`,` 1361`,` 606`]`

因此,您的五个单词被编码为七个标记。要查看标记,可以使用编码器的subwords属性,它返回一个数组。它是从零开始的,因此“Tod”在“Today”中被编码为6427,它是数组中的第 6,426 项:

`print``(``encoder``.``subwords``[`6426`]``)`
`Tod`

如果您想要解码,可以使用编码器的decode方法:

encoded_string = encoder.encode(sample_string)

original_string = encoder.decode(encoded_string)
test_string = encoder.decode([`6427`, `4869`, `9`, `4`, `2365`, `1361`, `606`])

由于其名称,后几行将具有相同的结果,尽管encoded_string是一个标记列表,就像在下一行上硬编码的那样。

从 CSV 文件获取文本

虽然 TFDS 拥有大量优秀的数据集,但并非涵盖一切,通常您需要自行加载数据。自然语言处理数据最常见的格式之一是 CSV 文件。在接下来的几章中,您将使用我从开源文本情感分析数据集中调整的 Twitter 数据的 CSV 文件。您将使用两个不同的数据集,一个将情感减少为“positive”或“negative”以进行二元分类,另一个使用完整的情感标签范围。每个的结构都是相同的,因此我只会在此处显示二元版本。

Python 的csv库使处理 CSV 文件变得简单。在这种情况下,数据存储为每行两个值。第一个是数字(0 或 1),表示情感是否为负面或正面。第二个是包含文本的字符串。

下面的代码将读取 CSV 文件,并对我们在前一节中看到的类似预处理进行处理。它在复合词中的标点周围添加空格,使用BeautifulSoup去除 HTML 内容,然后移除所有标点符号:

`import` csv
sentences=[]
labels=[]
`with` open(`'``/tmp/binary-emotion.csv``'`, encoding=`'``UTF-8``'`) `as` csvfile:
    reader = csv.reader(csvfile, delimiter=`"``,``"`)
    `for` row `in` reader:
        labels.append(`int`(row[`0`]))
        sentence = row[`1`].lower()
        sentence = sentence.replace(`"``,``"`, `"` `,` `"`)
        sentence = sentence.replace(`"``.``"`, `"` `.` `"`)
        sentence = sentence.replace(`"``-``"`, `"` `-` `"`)
        sentence = sentence.replace(`"``/``"`, `"` `/` `"`)
        soup = `BeautifulSoup`(sentence)
        sentence = soup.get_text()
        words = sentence.split()
        filtered_sentence = `"``"`
        `for` word `in` words:
            word = word.translate(table)
            `if` word `not` `in` stopwords:
                filtered_sentence = filtered_sentence + word + `"` `"`
        sentences.append(filtered_sentence)

这将为您提供一个包含 35,327 句子的列表。

创建训练和测试子集

现在文本语料库已经被读入句子列表中,您需要将其拆分为训练和测试子集以训练模型。例如,如果您想使用 28,000 个句子进行训练,并将其余部分保留用于测试,您可以使用如下代码:

training_size = `28000`

training_sentences = sentences[`0`:training_size]
testing_sentences = sentences[training_size:]
training_labels = labels[`0`:training_size]
testing_labels = labels[training_size:]

现在您有了一个训练集,您需要从中创建单词索引。以下是使用标记器创建最多 20,000 个单词的词汇表的代码。我们将句子的最大长度设置为 10 个单词,通过截断更长的句子来结束,通过在末尾填充较短的句子,并使用“”:

`vocab_size` `=` 20000
`max_length` `=` 10
`trunc_type``=``'``post``'`
`padding_type``=``'``post``'`
`oov_tok` `=` `"``<OOV>``"`

`tokenizer` `=` `Tokenizer``(``num_words``=``vocab_size``,` `oov_token``=``oov_tok``)`
`tokenizer``.``fit_on_texts``(``training_sentences``)`

`word_index` `=` `tokenizer``.``word_index`

`training_sequences` `=` `tokenizer``.``texts_to_sequences``(``training_sentences``)`

`training_padded` `=` `pad_sequences``(``training_sequences``,` `maxlen``=``max_length``,` 
                               padding=padding_type, 
                                truncating=trunc_type)

您可以通过查看training_sequencestraining_padded来检查结果。例如,在这里我们打印训练序列的第一项,您可以看到它是如何被填充到最大长度 10 的:

`print`(training_sequences[`0`])
`print`(training_padded[`0`])

[`18`, `3257`, `47`, `4770`, `613`, `508`, `951`, `423`]
[  `18` `3257`   `47` `4770`  `613`  `508`  `951`  `423`    `0`    `0`]

您也可以通过打印来检查单词索引:

`{`'<OOV>'`:` `1``,` 'just'`:` `2``,` 'not'`:` `3``,` 'now'`:` `4``,` 'day'`:` `5``,` 'get'`:` `6``,` 'no'`:` `7``,` 
  'good'`:` `8``,` 'like'`:` `9``,` 'go'`:` `10``,` 'dont'`:` `11``,` `.``.``.``}`

这里有很多词语你可能想要考虑作为停用词去掉,比如“like”和“dont”。检查词索引总是很有用的。

从 JSON 文件获取文本

另一种非常常见的文本文件格式是 JavaScript 对象表示法(JSON)。这是一种开放标准的文件格式,通常用于数据交换,特别是与 Web 应用程序的交互。它易于人类阅读,并设计为使用名称/值对。因此,它特别适合用于标记文本。在 Kaggle 数据集的快速搜索中,JSON 的结果超过 2,500 个。像斯坦福问答数据集(SQuAD)这样的流行数据集,例如,存储在 JSON 中。

JSON 有一个非常简单的语法,对象被包含在大括号中,作为以逗号分隔的名称/值对。例如,代表我的名字的 JSON 对象将是:

`{`"firstName" `:` "Laurence"`,`
  "lastName" `:` "Moroney"`}`

JSON 还支持数组,这些数组非常类似于 Python 列表,并且由方括号语法表示。这里是一个例子:

[
 {`"``firstName``"` : `"``Laurence``"`,
 `"``lastName``"` : `"``Moroney``"`},
 {`"``firstName``"` : `"``Sharon``"`,
 `"``lastName``"` : `"``Agathon``"`}
]

对象也可以包含数组,因此这是完全有效的 JSON:

[
 {`"``firstName``"` : `"``Laurence``"`,
 `"``lastName``"` : `"``Moroney``"`,
 `"``emails``"`: [`"``lmoroney@gmail.com``"`, `"``lmoroney@galactica.net``"`]
 },
 {`"``firstName``"` : `"``Sharon``"`,
 `"``lastName``"` : `"``Agathon``"`,
 `"``emails``"`: [`"``sharon@galactica.net``"`, `"``boomer@cylon.org``"`]
 }
]

一个存储在 JSON 中并且非常有趣的小数据集是由Rishabh Misra创建的用于讽刺检测的新闻标题数据集,可以在Kaggle上获取。这个数据集收集了来自两个来源的新闻标题:The Onion 提供有趣或讽刺的标题,HuffPost 提供正常的标题。

讽刺数据集中的文件结构非常简单:

{`"``is_sarcastic``"`: `1` `or` `0`, 
 `"``headline``"`: `String` containing headline, 
 `"``article_link``"`: `String` `Containing` link}

数据集包含大约 26,000 个项目,每行一个。为了在 Python 中使其更易读,我创建了一个将这些项目封装在数组中的版本,这样它可以作为单个列表进行读取,这在本章的源代码中使用。

读取 JSON 文件

Python 的json库使得读取 JSON 文件变得简单。鉴于 JSON 使用名称/值对,您可以根据字段的名称索引内容。因此,例如,对于讽刺数据集,您可以创建一个文件句柄到 JSON 文件,使用json库打开它,通过迭代逐行读取每个字段,通过字段的名称获取数据项。

下面是代码:

`import` json
`with` open(`"``/tmp/sarcasm.json``"`, `'``r``'`) `as` f:
    datastore = json.load(f)
    `for` item `in` datastore:
        sentence = item[`'``headline``'`].lower()
        label= item[`'``is_sarcastic``'`]
        link = item[`'``article_link``'`]

这使得创建句子和标签列表变得简单,就像您在整个本章中所做的那样,并对句子进行分词。您还可以在阅读句子时动态进行预处理,删除停用词,HTML 标签,标点符号等。以下是创建句子、标签和 URL 列表的完整代码,同时清理了不需要的词语和字符:

`with` `open``(`"/tmp/sarcasm.json"`,` 'r'`)` `as` `f``:`
 `datastore` `=` `json``.``load``(``f``)`

`sentences` `=` `[``]` 
`labels` `=` `[``]`
`urls` `=` `[``]`
`for` `item` `in` `datastore``:`
 `sentence` `=` `item``[`'headline'`]``.``lower``(``)`
 `sentence` `=` `sentence``.``replace``(`","`,` " , "`)`
 `sentence` `=` `sentence``.``replace``(`"."`,` " . "`)`
 `sentence` `=` `sentence``.``replace``(`"-"`,` " - "`)`
 `sentence` `=` `sentence``.``replace``(`"/"`,` " / "`)`
 `soup` `=` `BeautifulSoup``(``sentence``)`
 `sentence` `=` `soup``.``get_text``(``)`
 `words` `=` `sentence``.``split``(``)`
 `filtered_sentence` `=` ""
   `for` `word` `in` `words``:`
 `word` `=` `word``.``translate``(``table``)`
  `if` `word` `not`  `in` `stopwords``:`
 `filtered_sentence` `=` `filtered_sentence` `+` `word` `+` " "
 `sentences``.``append``(``filtered_sentence``)`
 `labels``.``append``(``item``[`'is_sarcastic'`]``)`
 `urls``.``append``(``item``[`'article_link'`]``)`

与以前一样,这些可以分为训练集和测试集。如果您想要使用数据集中的 26,000 项中的 23,000 项进行训练,可以执行以下操作:

`training_size` `=` 23000

`training_sentences` `=` `sentences``[`0`:``training_size``]`
`testing_sentences` `=` `sentences``[``training_size``:``]`
`training_labels` `=` `labels``[`0`:``training_size``]`
`testing_labels` `=` `labels``[``training_size``:``]`

为了对数据进行分词并准备好进行训练,您可以采用与之前相同的方法。在这里,我们再次指定词汇量为 20,000 个词,最大序列长度为 10,末尾截断和填充,并使用“”作为 OOV 标记:

vocab_size = `20000`
max_length = `10`
trunc_type=`'``post``'`
padding_type=`'``post``'`
oov_tok = `"``<OOV>``"`

tokenizer = `Tokenizer`(num_words=vocab_size, oov_token=oov_tok)
tokenizer.fit_on_texts(training_sentences)

word_index = tokenizer.word_index

training_sequences = tokenizer.texts_to_sequences(training_sentences)
padded = pad_sequences(training_sequences, padding=`'``post``'`)
`print`(word_index)

输出将按单词频率顺序排列整个索引:

`{`'<OOV>'`:` `1``,` 'new'`:` `2``,` 'trump'`:` `3``,` 'man'`:` `4``,` 'not'`:` `5``,` 'just'`:` `6``,` 'will'`:` `7``,`  
  'one'`:` `8``,` 'year'`:` `9``,` 'report'`:` `10``,` 'area'`:` `11``,` 'donald'`:` `12``,` `.``.``.` `}`

希望类似的代码能帮助您看到在准备文本供神经网络分类或生成时可以遵循的模式。在下一章中,您将看到如何使用嵌入来构建文本分类器,在第七章中,您将进一步探索,探讨循环神经网络。然后,在第八章中,您将看到如何进一步增强序列数据以创建能够生成新文本的神经网络!

总结

在前面的章节中,您使用图像构建了一个分类器。图像本质上是高度结构化的。您知道它们的尺寸。您知道格式。另一方面,文本可能要复杂得多。它经常是非结构化的,可能包含不想要的内容,比如格式化指令,不总是包含您想要的内容,通常必须进行过滤以去除荒谬或无关的内容。在本章中,您学习了如何使用单词分词将文本转换为数字,并探讨了如何阅读和过滤各种格式的文本。有了这些技能,您现在已经准备好迈出下一步,学习如何从单词中推断含义——这是理解自然语言的第一步。

第六章:利用嵌入使情感可编程

在第五章中,您看到如何将单词编码为标记。然后,您看到如何将充满单词的句子编码为充满标记的序列,根据需要进行填充或截断,以得到一个形状良好的数据集,可以用来训练神经网络。在所有这些过程中,都没有对单词的含义进行任何建模。虽然确实不存在能够完全数值化包含意义的编码,但存在相对的编码。在本章中,您将学习到这些内容,特别是嵌入的概念,其中在高维空间中创建向量以表示单词。这些向量的方向可以随着单词在语料库中的使用而学习。然后,当您获得一个句子时,您可以研究单词向量的方向,将它们加起来,并从总体方向中确定句子的情感作为其单词的产品。

在本章中,我们将探讨它的工作原理。使用来自第五章的讽刺数据集,您将构建嵌入以帮助模型检测句子中的讽刺。您还将看到一些很酷的可视化工具,帮助您理解语料库中的单词如何映射到向量,以便您可以看到哪些单词决定了整体分类。

从单词中建立意义

在深入探讨用于嵌入的高维向量之前,让我们尝试通过一些简单的例子来可视化如何从数字中推导出含义。考虑这个:使用来自第五章的讽刺数据集,如果您将组成讽刺标题的所有单词编码为正数,而将组成现实标题的单词编码为负数,会发生什么?

一个简单的例子:积极与消极

举例来说,数据集中的这则讽刺性标题:

christian bale given neutered male statuette named oscar

假设我们词汇表中的所有单词都以值 0 开始,我们可以为这个句子中的每个单词的值添加 1,那么我们将得到这样:

{ "christian" : 1, "bale" : 1, "given" : 1, "neutered": 1, "male" : 1, 
  "statuette": 1, "named" : 1, "oscar": 1}
注意

请注意,这与您在上一章中进行的单词标记化不同。您可以考虑用表示它的标记替换每个单词(例如,“christian”),该标记从语料库编码而来,但我现在保留这些单词以便阅读。

然后,在下一步中,考虑一个普通的标题,而不是一个讽刺的标题,比如这个:

gareth bale scores wonder goal against germany

因为这是一种不同的情感,我们可以从当前每个单词的值中减去 1,所以我们的值集将如下所示:

{ "christian" : 1, "bale" : 0, "given" : 1, "neutered": 1, "male" : 1,
  "statuette": 1, "named" : 1, "oscar": 1, "gareth" : -1, "scores": -1,
  "wonder" : -1, "goal" : -1, "against" : -1, "germany" : -1}

请注意,讽刺的“bale”(来自“christian bale”)通过非讽刺的“bale”(来自“gareth bale”)进行了偏移,因此其分数最终为 0。重复这个过程成千上万次,您将得到一个巨大的单词列表,根据它们在语料库中的使用得分。

现在想象一下,我们想要确定这句话的情感:

neutered male named against germany, wins statuette!

使用我们现有的价值集,我们可以查看每个单词的分数并将它们加起来。我们将得到一个分数为 2,这表明(因为它是正数)这是一个讽刺性的句子。

注意

关于“bale”,在 Sarcasm 数据集中出现了五次,其中两次出现在普通标题中,三次出现在讽刺标题中,因此在这样的模型中,“bale”一词在整个数据集中得分为-1。

深入一点:向量

希望前面的例子帮助您理解建立一种单词相对含义的心理模型,通过其与同一“方向”中其他单词的关联。在我们的例子中,虽然计算机不理解单词的含义,但它可以将已知讽刺标题中的标记单词向一个方向移动(通过添加 1),并将已知普通标题中的标记单词向另一个方向移动(通过减去 1)。这给我们带来了对单词含义的基本理解,但是它丢失了一些细微差别。

如果我们增加方向的维度以捕获更多信息会怎么样?例如,假设我们查看简·奥斯汀小说傲慢与偏见中的字符,考虑性别和贵族的维度。我们可以将前者绘制在 x 轴上,将后者绘制在 y 轴上,向量的长度表示每个字符的财富(图 6-1)。

傲慢与偏见中的字符作为向量

图 6-1. 傲慢与偏见中的字符作为向量

从图表的检查中,您可以推断出关于每个字符的相当多的信息。其中三个是男性。达西先生非常富有,但他的贵族身份不清楚(他被称为“先生”,不像较少富裕但显然更高贵的威廉·卢卡斯爵士)。另一个“先生”本内特先生显然不是贵族,经济上有困难。他的女儿伊丽莎白·本内特与他相似,但是女性。在我们的例子中,另一个女性角色凯瑟琳夫人是贵族,极其富有。达西先生和伊丽莎白之间的浪漫引发了紧张局势——偏见来自向较不贵族的向量的贵族一侧。

正如这个例子所示,通过考虑多个维度,我们可以开始看到单词(这里是角色名称)中的真实含义。再次强调,我们不是谈论具体的定义,而是基于轴线和单词向量之间关系的相对含义。

这导致我们引入了嵌入的概念,它仅仅是在训练神经网络时学习到的一个单词的向量表示。接下来我们将深入探讨这一点。

TensorFlow 中的嵌入

正如您在DenseConv2D中看到的那样,tf.keras使用层来实现嵌入。这创建了一个查找表,将从整数映射到嵌入表,表中的内容是表示由该整数标识的单词的向量的系数。因此,在前一节中的傲慢与偏见示例中,xy坐标将为我们提供书中特定字符的嵌入。当然,在真实的 NLP 问题中,我们将使用远比两个维度多得多。因此,向量空间中向量的方向可以被视为编码单词的“含义”,具有类似向量的单词——即大致指向同一方向的单词——可以被认为与该单词相关。

嵌入层将被随机初始化——也就是说,向量的坐标将完全随机起始,并将在训练过程中使用反向传播进行学习。训练完成后,嵌入将大致编码单词之间的相似性,使我们能够根据这些单词的向量方向识别出某种程度相似的单词。

这些都非常抽象,所以我认为理解如何使用嵌入的最佳方法是动手尝试一下。我们从使用来自第五章的 Sarcasm 数据集的讽刺检测器开始。

构建使用嵌入的讽刺检测器

在第五章中,您加载并对称为讽刺检测的新闻标题数据集进行了一些预处理(简称为 Sarcasm)。到最后,您已经得到了训练数据、测试数据和标签的列表。这些可以像这样转换为 TensorFlow 训练使用的 Numpy 格式:

`import` numpy `as` np
training_padded = np.array(training_padded)
training_labels = np.array(training_labels)
testing_padded = np.array(testing_padded)
testing_labels = np.array(testing_labels)

这些是使用具有指定最大词汇量和词汇表外标记的标记器创建的:

tokenizer = `Tokenizer`(num_words=vocab_size, oov_token=oov_tok)

要初始化嵌入层,您需要词汇量和指定的嵌入维度:

tf.keras.layers.`Embedding`(vocab_size, embedding_dim),

这将为每个单词初始化一个embedding_dim点的数组。例如,如果embedding_dim16,则词汇表中的每个单词将被分配一个 16 维向量。

随着网络通过将训练数据与其标签匹配进行学习,这些维度将随时间通过反向传播进行学习。

接下来的重要步骤是将嵌入层的输出馈送到密集层中。与使用卷积神经网络时类似,最简单的方法是使用池化。在这种情况下,将嵌入的维度平均化以生成一个固定长度的输出向量。

例如,考虑这个模型架构:

model = tf.keras.`Sequential`([
    tf.keras.layers.`Embedding`(`10000`, `16`),
    tf.keras.layers.`GlobalAveragePooling1D`(),
    tf.keras.layers.`Dense`(`24`, activation=`'``relu``'`),
    tf.keras.layers.`Dense`(`1`, activation=`'``sigmoid``'`)
])
model.compile(loss=`'``binary_crossentropy``'`,
              optimizer=`'``adam``'`,metrics=[`'``accuracy``'`])

在这里定义了一个嵌入层,并给出了词汇量(10000)和嵌入维度16。让我们通过model.summary查看网络中的可训练参数数量:

Model: "sequential_2"
_________________________________________________________________
Layer (type)                 Output Shape              Param # 
=================================================================
embedding_2 (Embedding)      (None, None, 16)          160000    
_________________________________________________________________
global_average_pooling1d_2 ( (None, 16)                0         
_________________________________________________________________
dense_4 (Dense)              (None, 24)                408       
_________________________________________________________________
dense_5 (Dense)              (None, 1)                 25        
=================================================================
Total params: 160,433
Trainable params: 160,433
Non-trainable params: 0
_________________________________________________________________

由于嵌入层的词汇量为 10,000 个词,并且每个词都将成为一个 16 维的向量,所以可训练的参数总数将为 160,000。

平均池化层没有可训练的参数,因为它只是对其前的嵌入层的参数进行平均,得到一个单一的 16 值向量。

然后将其馈送到 24 神经元的密集层中。请记住,密集神经元实际上是使用权重和偏差来计算的,因此它将需要学习(24 × 16) + 16 = 408 个参数。

然后,该层的输出被传递到最终的单神经元层,那里将会有(1 × 24) + 1 = 25 个需要学习的参数。

如果我们训练这个模型,在 30 个 epoch 之后,我们将会得到一个相当不错的准确率,超过 99%—但是我们的验证准确率只会约为 81%(图 6-2)。

训练准确率与验证准确率

图 6-2. 训练准确率与验证准确率

考虑到验证数据很可能包含训练数据中不存在的许多词,这个曲线看起来是合理的。然而,如果你查看 30 个 epoch 中的训练与验证损失曲线,你会发现一个问题。尽管你会期望看到训练准确率高于验证准确率,但一个明显的过拟合指标是,虽然验证准确率随时间略有下降(在图 6-2 中),但其损失却急剧增加,如图 6-3 所示。

训练损失与验证损失

图 6-3. 训练损失与验证损失

类似这样的过拟合在 NLP 模型中很常见,这是由于语言的某种不可预测性质导致的。在接下来的几节中,我们将探讨如何使用多种技术来减少这种影响。

减少语言模型的过拟合

过拟合发生在网络过于专注于训练数据时,其中一部分原因是网络变得非常擅长匹配训练集中存在但验证集中不存在的“噪声”数据中的模式。由于这种特定的噪声在验证集中不存在,网络越擅长匹配它,验证集的损失就会越严重。这可能导致你在图 6-3 中看到的损失不断升高。在本节中,我们将探讨几种泛化模型和减少过拟合的方法。

调整学习率

可能导致过拟合的最大因素是优化器的学习率过高。这意味着网络学习得太快。例如,对于这个例子,编译模型的代码如下:

model.compile(loss='binary_crossentropy',
              optimizer='adam', metrics=['accuracy'])

优化器简单地声明为adam,这将调用具有默认参数的 Adam 优化器。然而,该优化器支持多个参数,包括学习率。你可以将代码修改为以下内容:

adam = tf.keras.optimizers.Adam(learning_rate=0.0001, 
                                 beta_1=0.9, beta_2=0.999, amsgrad=False)

model.compile(loss='binary_crossentropy',
              optimizer=adam, metrics=['accuracy'])

默认学习率的数值通常为 0.001,但现在已经降低了 90%,变为 0.0001。beta_1beta_2 的值保持它们的默认值,amsgrad 也是如此。beta_1beta_2 必须介于 0 和 1 之间,通常都接近于 1。Amsgrad 是 Adam 优化器的另一种实现方式,由 Sashank Reddi、Satyen Kale 和 Sanjiv Kumar 在论文 “On the Convergence of Adam and Beyond” 中介绍。

这种显著降低的学习率对网络产生了深远影响。图 6-4 展示了网络在 100 个时期内的准确率。可以在前 10 个时期中看到较低的学习率,网络似乎没有学习,直到它“突破”并开始快速学习。

使用较低学习率的准确率

图 6-4. 使用较低学习率的准确率

探索损失(如图 6-5 所示),我们可以看到,即使在前几个时期准确率没有提升的情况下,损失却在下降,因此如果你逐个时期观察,可以确信网络最终会开始学习。

使用较低学习率的损失

图 6-5. 使用较低学习率的损失

虽然损失确实开始显示与 图 6-3 中看到的过拟合曲线相同的情况,但发生的时间要晚得多,并且速率要低得多。到了第 30 个时期,损失约为 0.45,而在 图 6-3 中使用较高学习率时,损失超过了这个数值的两倍。尽管网络花费更长的时间才能达到良好的准确率,但损失更少,因此你对结果更有信心。在这些超参数下,验证集上的损失在大约第 60 个时期开始增加,此时训练集达到了 90% 的准确率,而验证集约为 81%,显示出我们有一个非常有效的网络。

当然,仅仅调整优化器然后宣布胜利是很容易的,但你可以使用一些其他方法来改进模型,你将在接下来的几节中看到。因此,我重新使用默认的 Adam 优化器,以便调整学习率不会掩盖这些其他技术带来的好处。

探索词汇量

“讽刺数据集”处理的是单词,因此如果你探索数据集中的单词,特别是它们的频率,你可能会找到一些有助于解决过拟合问题的线索。

标记器通过其word_counts属性为你提供了一种方法。如果你打印它,你会看到类似于这样的内容,一个包含单词和单词计数元组的OrderedDict

wc=tokenizer.word_counts
print(wc)

OrderedDict([('former', 75), ('versace', 1), ('store', 35), ('clerk', 8), 
     ('sues', 12), ('secret', 68), ('black', 203), ('code', 16),...

单词的顺序是根据它们在数据集中出现的顺序确定的。如果查看训练集中的第一个标题,它是关于一名前凡赛斯店员的讽刺性标题。停用词已被移除;否则你会看到大量像 "a" 和 "the" 这样的单词。

鉴于它是一个 OrderedDict,你可以按单词体积的降序对其进行排序:

from collections import OrderedDict
newlist = (OrderedDict(sorted(wc.items(), key=lambda t: t[1], reverse=True)))
print(newlist)

OrderedDict([('new', 1143), ('trump', 966), ('man', 940), ('not', 555), ('just',
430), ('will', 427), ('one', 406), ('year', 386),

如果你想绘制这个图,可以迭代列表中的每个项目,并将 x 值设为当前的序数(第一个项目为 1,第二个项目为 2,依此类推)。然后 y 值将是 newlist[item]。然后可以使用 matplotlib 绘制出来。以下是代码:

xs=[]
ys=[]
curr_x = `1`
`for` item `in` newlist:
  xs.append(curr_x)
  curr_x=curr_x+`1`
  ys.append(newlist[item])

plt.plot(xs,ys)
plt.show()

结果显示在 图 6-6 中。

探索词频

图 6-6. 探索词频

这个“曲棍球”曲线向我们展示,很少有单词被多次使用,而大多数单词很少使用。但每个单词的权重是相等的,因为每个单词在嵌入中都有一个“入口”。考虑到我们相对较大的训练集与验证集相比,我们最终处于这样一种情况:训练集中存在许多在验证集中不存在的单词。

在调用 plt.show 前,可以通过改变绘图的坐标轴来放大数据。例如,为了查看 x 轴上单词 300 到 10,000 的体积,以及 y 轴上从 0 到 100 的比例,可以使用以下代码:

plt.plot(xs,ys)
plt.axis([`300`,`10000`,`0`,`100`])
plt.show()

结果显示在 图 6-7 中。

单词 300 到 10,000 的频率

图 6-7. 单词 300 到 10,000 的频率

尽管语料库中有超过 20,000 个单词,但代码仅设置为训练 10,000 个。但如果我们看一下位置在 2,000 到 10,000 的单词,这些单词占我们词汇量的超过 80%,我们会发现它们在整个语料库中的使用次数少于 20 次。

这可能解释了过拟合的原因。现在考虑如果将词汇量改为两千并重新训练会发生什么。图 6-8 显示了准确度指标。现在训练集的准确率约为 82%,验证集的准确率约为 76%。它们之间更接近,没有发散,这表明我们已经成功减少了大部分过拟合。

使用两千词汇量的准确率

图 6-8. 使用两千词汇量的准确率

这在 图 6-9 的损失图中有所体现。验证集上的损失在上升,但比之前缓慢得多,因此减少词汇量的大小,以防止训练集过拟合低频词,似乎是有效的。

使用两千词汇量的损失

图 6-9. 使用两千词汇量的损失

值得尝试不同的词汇大小,但记住你也可能会有过小的词汇量并且对此过拟合。你需要找到一个平衡点。在这种情况下,我选择了选择出现 20 次或更多次的单词纯属随意。

探索嵌入维度

例如,这个例子中,嵌入维度选择了 16。在这种情况下,单词被编码为 16 维空间中的向量,它们的方向表示它们的整体含义。但是 16 是一个好的数字吗?我们的词汇表中只有两千个单词,这可能稍微偏高,导致方向的稀疏性较高。

嵌入大小的最佳实践是使其成为词汇大小的四次方根。2000 的四次方根是 6.687,因此让我们看看如果将嵌入维度改为 7 并重新训练 100 个时期会发生什么。

您可以在 图 6-9 中看到准确性的结果。训练集的准确性稳定在约 83%,验证集在约 77%。尽管有些波动,线条仍然相当平坦,显示模型已经收敛。这与 图 6-6 中的结果并没有太大不同,但减少嵌入维度允许模型训练速度提高 30%。

七维度的训练与验证准确率

图 6-10. 七维度的训练与验证准确率

图 6-11 显示了训练和验证的损失。虽然最初似乎在第 20 个时期损失正在上升,但很快就趋于平稳。再次,这是一个好的迹象!

七维度的训练与验证损失

图 6-11. 七维度的训练与验证损失

现在维度已经降低,我们可以稍微调整模型架构。

探索模型架构

在前几节的优化之后,模型架构现在如下所示:

model = tf.keras.`Sequential`([
    tf.keras.layers.`Embedding`(`2000`, `7`),
    tf.keras.layers.`GlobalAveragePooling1D`(),
    tf.keras.layers.`Dense`(`24`, activation=`'``relu``'`),
    tf.keras.layers.`Dense`(`1`, activation=`'``sigmoid``'`)
])
model.compile(loss=`'``binary_crossentropy``'`,
              optimizer=`'``adam``'`,metrics=[`'``accuracy``'`])

一个值得注意的事情是维度——GlobalAveragePooling1D 层现在仅发出七个维度,但它们被馈送到具有 24 个神经元的密集层,这有些过度。让我们看看当这减少到仅有八个神经元并训练一百个时期时会发生什么。

您可以看到在 图 6-12 中的训练与验证准确率。与使用 24 个神经元的 图 6-7 相比,总体结果相似,但波动已经被平滑处理(可以从线条的不那么崎岖看出)。训练速度也有所提升。

降低密集架构的准确性结果

图 6-12. 降低密集架构的准确性结果

同样地,图 6-13 中的损失曲线显示了类似的结果,但是凹凸性降低了。

减少的密集架构损失结果

图 6-13. 减少的密集架构损失结果

使用 dropout

一种常见的减少过拟合的技术是向密集神经网络添加 dropout。我们在第三章中探讨过卷积神经网络的这一方法。虽然直接看看它对过拟合的影响是很诱人的,但在这种情况下,我想等到词汇量、嵌入大小和架构复杂性得到解决。这些变化通常会比使用 dropout 更大地影响结果,并且我们已经看到了一些不错的结果。

现在我们的架构已经简化为中间密集层仅有八个神经元,dropout 的效果可能会被最小化,但让我们仍然探讨一下。这是模型架构的更新代码,添加了 0.25 的 dropout(相当于我们八个神经元中的两个):

model = tf.keras.Sequential([
    tf.keras.layers.Embedding(vocab_size, embedding_dim),
    tf.keras.layers.GlobalAveragePooling1D(),
    tf.keras.layers.Dense(8, activation='relu'),
 `tf``.``keras``.``layers``.``Dropout``(``.``25``)``,`
    tf.keras.layers.Dense(1, activation='sigmoid')
])

图 6-14 显示了在训练了一百个 epochs 后的准确性结果。

这一次我们看到训练准确率正在超过先前的阈值,而验证准确率正在缓慢下降。这表明我们正在进入过拟合的领域。通过探索图 6-15 中的损失曲线,这一点得到了确认。

添加了 dropout 的准确率

图 6-14. 添加了 dropout 的准确率

添加了 dropout 的损失

图 6-15. 添加了 dropout 的损失

这里你可以看到模型正在回到先前随时间增加的验证损失模式。虽然情况没有以前那么糟糕,但它正在朝着错误的方向发展。

在这种情况下,当神经元很少时,引入 dropout 可能不是一个正确的想法。但是对于比这个更复杂的架构,仍然有必要考虑它,所以一定要记住它。

使用正则化

正则化 是一种技术,通过减少权重的极化来帮助防止过拟合。如果某些神经元的权重过重,正则化会有效地对其进行惩罚。总体来说,正则化主要有两种类型:L1L2

L1 正则化通常被称为套索(最小绝对收缩和选择算子)正则化。它有效地帮助我们在计算层结果时忽略零或接近零的权重。

L2 正则化通常被称为回归,因为它通过取平方来使值分离。这倾向于放大非零值与零或接近零值之间的差异,产生了岭效应。

这两种方法也可以结合起来,有时被称为弹性正则化。

对于像我们考虑的这种 NLP 问题,L2 正则化最常用。它可以作为Dense层的一个属性添加,使用kernel_regularizers属性,并采用浮点值作为正则化因子。这是另一个可以实验以改进模型的超参数!

这里有一个例子:

model = tf.keras.Sequential([
    tf.keras.layers.Embedding(vocab_size, embedding_dim),
    tf.keras.layers.GlobalAveragePooling1D(),
    tf.keras.layers.Dense(8, activation='relu', 
 `kernel_regularizer` `=` `tf``.``keras``.``regularizers``.``l2``(``0.01``)``)``,`
    tf.keras.layers.Dense(1, activation='sigmoid')
])

在像这样简单的模型中添加正则化的影响并不特别大,但它确实在一定程度上平滑了我们的训练损失和验证损失。这可能对于这种情况来说有点过度,但是像 dropout 一样,了解如何使用正则化来防止模型过度特化是个好主意。

其他优化考虑

尽管我们所做的修改大大改进了模型并减少了过拟合,但还有其他超参数可以进行实验。例如,我们选择将最大句子长度设置为一百,但这纯粹是任意选择,可能并不是最优的。探索语料库并查看更好的句子长度是个好主意。这里有一段代码片段,它查看句子的长度并按从低到高排序进行绘制:

xs=[]
ys=[]
current_item=`1`
`for` item `in` sentences:
  xs.append(current_item)
  current_item=current_item+`1`
  ys.append(len(item))
newys = sorted(ys)

`import` matplotlib.pyplot `as` plt
plt.plot(xs,newys)
plt.show()

这个结果显示在图 6-16 中。

探索句子长度

图 6-16. 探索句子长度

在总语料库中,少于 200 个句子的长度超过 100 个单词,所以通过选择这个作为最大长度,我们引入了很多不必要的填充,并影响了模型的性能。将其减少到 85 仍将保持 26000 个句子(99%+)完全没有填充。

使用模型进行句子分类

现在您已经创建了模型,对其进行了训练并对其进行了优化,以消除引起过度拟合的许多问题,下一步是运行模型并检查其结果。为此,请创建一个新句子的数组。例如:

`sentences` `=` `[`"granny starting to fear spiders in the garden might be real"`,` 
             "game of thrones season finale showing this sunday night"`,` 
             "TensorFlow book will be a best seller"`]`

然后,这些可以使用创建词汇表时使用的相同标记器进行编码。使用这个很重要,因为它包含了网络训练时使用的单词的标记!

sequences = tokenizer.texts_to_sequences(sentences)
`print`(sequences)

打印语句的输出将是前面句子的序列:

[[1, 816, 1, 691, 1, 1, 1, 1, 300, 1, 90], 
 [111, 1, 1044, 173, 1, 1, 1, 1463, 181], 
 [1, 234, 7, 1, 1, 46, 1]]

这里有很多1个标记(“”),因为像“in”和“the”这样的停用词已从字典中删除,而“granny”和“spiders”之类的词并不在字典中出现。

在您可以将序列传递给模型之前,它们需要在模型期望的形状中——即所需的长度中。您可以像训练模型时那样使用pad_sequences来做到这一点:

padded = pad_sequences(sequences, maxlen=max_length, 
                       padding=padding_type, truncating=trunc_type)
print(padded)

这将输出长度为100的句子序列,因此第一个序列的输出将是:

[   1  816    1  691    1    1    1    1  300    1   90    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    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0]

这是一个非常短的句子!

现在句子已经被分词并填充以适应模型对输入维度的期望,是时候将它们传递给模型并获得预测结果了。这样做就像这样简单:

`print`(model.predict(padded))

结果将以列表形式返回并打印出来,高值表示可能是讽刺。这是我们示例句子的结果:

`[``[`0.7194135 `]`
 `[`0.02041999`]`
 `[`0.13156283`]``]`

第一句的高分(“granny starting to fear spiders in the garden might be real”),尽管有很多停用词并被填充了大量零,表明这里有很高的讽刺水平。另外两个句子的分数要低得多,表明其中讽刺的可能性较低。

可视化嵌入

要可视化嵌入,您可以使用一个名为嵌入投影仪的工具。它预装有许多现有数据集,但在本节中,您将看到如何使用刚刚训练的模型数据来使用这个工具进行可视化。

首先,您需要一个函数来反转单词索引。当前它以单词作为标记,以键作为值,但需要反转以便我们有单词值来在投影仪上绘制。以下是实现此操作的代码:

reverse_word_index = dict([(`value`, key) 
`for` (key, `value`) `in` word_index.items()])

您还需要提取嵌入向量中的权重:

e = model.layers[`0`]
weights = e.get_weights()[`0`]
`print`(weights.shape)

如果您遵循本章节中的优化,输出将是(2000,7)—我们使用了一个包含 2000 个单词的词汇表,并且嵌入的维度为 7。如果您想探索一个单词及其向量细节,可以使用如下代码:

`print`(reverse_word_index[`2`])
`print`(weights[`2`])

这将产生以下输出:

`new`
[ `0.8091359`   `0.54640186` -`0.9058702`  -`0.94764805` -`0.8809764`  -`0.70225513`
  `0.86525863`]

因此,“new”这个词由一个具有这七个系数的向量表示在其轴上。

嵌入投影仪使用两个制表符分隔值(TSV)文件,一个用于向量维度,一个用于元数据。此代码将为您生成它们:

`import` io

out_v = io.open(`'``vecs.tsv``'`, `'``w``'`, encoding=`'``utf-8``'`)
out_m = io.open(`'``meta.tsv``'`, `'``w``'`, encoding=`'``utf-8``'`)
`for` word_num `in` range(`1`, vocab_size):
  word = reverse_word_index[word_num]
  embeddings = weights[word_num]
  out_m.write(word + `"``\n``"`)
  out_v.write(`'``\t``'`.`join`([str(x) `for` x `in` embeddings]) + `"``\n``"`)
out_v.close()
out_m.close()

如果您正在使用 Google Colab,您可以使用以下代码或从文件窗格下载 TSV 文件:

`try`:
  `from` google.colab `import` files
`except` `ImportError`:
  `pass`
`else`:
  files.download(`'``vecs.tsv``'`)
  files.download(`'``meta.tsv``'`)

一旦您拥有它们,您可以按下投影仪上的“加载”按钮来可视化嵌入,如图 6-17 所示。

在生成的对话框中建议使用向量和元数据 TSV 文件,然后单击投影仪上的“球化数据”。这将导致单词在球体上聚类,并清晰地显示此分类器的二元特性。它仅在讽刺和非讽刺句子上进行了训练,因此单词倾向于朝向一个标签或另一个聚类(图 6-18)。

使用嵌入投影仪

图 6-17. 使用嵌入投影仪

可视化讽刺嵌入

图 6-18. 可视化讽刺嵌入

屏幕截图无法真实展示其效果;您应该亲自尝试!您可以旋转中心球体并探索每个“极点”上的单词,以查看它们对整体分类的影响。您还可以选择单词并在右侧窗格中显示相关单词。玩得开心,进行实验。

使用来自 TensorFlow Hub 的预训练嵌入

训练自己的嵌入的另一种选择是使用已经预训练并打包为 Keras 层的嵌入。在TensorFlow Hub上有许多这样的资源供你探索。需要注意的是,它们还可以为你包含分词逻辑,因此你不必像之前那样处理分词、序列化和填充。

TensorFlow Hub 在 Google Colab 中预安装,因此本章中的代码将直接运行。如果你想在自己的机器上安装它作为依赖项,你需要按照说明安装最新版本。

例如,对于讽刺数据,你可以省去所有的分词、词汇管理、序列化、填充等逻辑,只需在拥有完整句子集和标签后执行如下操作。首先,将它们分为训练集和测试集:

training_size = `24000`
training_sentences = sentences[`0`:training_size]
testing_sentences = sentences[training_size:]
training_labels = labels[`0`:training_size]
testing_labels = labels[training_size:]

一旦你拥有这些,你可以像这样从 TensorFlow Hub 下载一个预训练层:

`import` tensorflow_hub `as` hub

 hub_layer = hub.`KerasLayer`(
    `"``https://tfhub.dev/google/tf2-preview/gnews-swivel-20dim/1``"`, 
    output_shape=[`20`], input_shape=[], 
     dtype=tf.`string`, trainable=`False`
)

这将使用从 Swivel 数据集中提取的嵌入,该数据集在 130 GB 的 Google News 上训练。使用这一层将编码你的句子,对它们进行分词,并使用从 Swivel 学习的单词嵌入进行编码,然后将你的句子编码成一个单一的嵌入。值得记住这最后一部分。到目前为止,我们一直使用的技术是仅使用单词编码,并基于它们来分类内容。使用这样的层时,你将获得整个句子聚合成一个新编码的效果。

然后,你可以通过使用这一层而不是嵌入层来创建模型架构。这里有一个使用它的简单模型:

model = tf.keras.`Sequential`([
    hub_layer,
    tf.keras.layers.`Dense`(`16`, activation=`'``relu``'`),
    tf.keras.layers.`Dense`(`1`, activation=`'``sigmoid``'`)
])

adam = tf.keras.optimizers.`Adam`(learning_rate=`0.0001`, beta_1=`0.9`, 
                                beta_2=`0.999`, amsgrad=`False`)

model.compile(loss=`'``binary_crossentropy``'`,optimizer=adam, 
              metrics=[`'``accuracy``'`])

这个模型将在训练中迅速达到峰值准确度,并且不会像我们之前看到的那样过拟合。50 个 epochs 中的准确度显示训练和验证非常一致(图 6-19)。

使用 Swivel 嵌入的准确度指标

图 6-19. 使用 Swivel 嵌入的准确度指标

损失值也是一致的,显示我们拟合得非常好(图 6-20)。

使用 Swivel 嵌入的损失指标

图 6-20. 使用 Swivel 嵌入的损失指标

然而,值得注意的是,总体准确率(约为 67%)相对较低,考虑到硬币翻转的几率为 50%!这是由于所有基于单词的嵌入被编码成基于句子的嵌入所导致的——在讽刺标题的情况下,似乎单个单词对分类有很大影响(见图 6-18)。因此,虽然使用预训练的嵌入可以实现更快的训练且减少过拟合,你也应理解它们的实际用途,并且它们并不总是适合你的场景。

摘要

在本章中,你建立了第一个模型来理解文本中的情感。它通过获取来自第五章的分词文本并将其映射到向量来实现这一目标。然后,利用反向传播,它学习了每个向量的适当“方向”,根据包含该向量的句子的标签。最后,它能够利用所有单词集合的向量来建立对句子情感的理解。你还探索了优化模型以避免过拟合的方法,并看到了表示单词的最终向量的清晰可视化。虽然这是分类句子的一个不错的方法,但它仅仅把每个句子视为一堆单词。没有固有的序列参与其中,而单词出现的顺序在确定句子真实含义方面非常重要,因此我们可以看看是否可以通过考虑序列来改进我们的模型是个好主意。在下一章中,我们将探索引入一种新的层类型——循环层,这是循环神经网络的基础。你还将看到另一个预训练嵌入,称为 GloVe,它允许你在迁移学习场景中使用基于单词的嵌入。

第七章:用于自然语言处理的递归神经网络

在第五章中,您看到了如何对文本进行标记化和序列化,将句子转换为可以输入神经网络的数字张量。然后在第六章中,您通过研究嵌入来扩展了这一概念,这是一种使具有相似含义的词汇聚集在一起的方法,以便计算情感。这种方法非常有效,正如您通过构建讽刺分类器所看到的那样。但是,这也有其局限性,即句子不仅仅是词汇的集合——词汇的顺序通常会决定其整体含义。形容词可以增加或改变它们旁边的名词的含义。例如,“蓝色”从情感角度来看可能毫无意义,同样“天空”也是如此,但是当您将它们组合成“蓝天”时,通常会产生一种明确的积极情感。有些名词可能会修饰其他名词,比如“雨云”、“写字桌”、“咖啡杯”。

要考虑到这样的序列,需要一种额外的方法,那就是在模型架构中考虑递归。在本章中,您将看到不同的实现方式。我们将探索如何学习序列信息,以及如何利用这些信息来创建一种更能理解文本的模型:递归神经网络(RNN)。

循环的基础

要理解递归如何工作,让我们首先考虑到目前为止在本书中使用的模型的局限性。最终,创建一个模型看起来有点像图 7-1。您提供数据和标签,并定义一个模型架构,模型学习适合数据与标签的规则。然后这些规则作为 API 提供给您,以便将来预测数据的标签。

模型创建的高级视图

图 7-1。模型创建的高级视图

但正如您所看到的,数据是作为整体存在的。没有细化的过程,也没有努力去理解数据发生的顺序。这意味着,“蓝色”和“天空”在“今天我感到忧郁,因为天空是灰色”和“今天我很开心,天空很美丽”这样的句子中没有不同的含义。对于我们来说,这些词的使用差异是显而易见的,但是对于一个使用这里显示的架构的模型来说,实际上是没有差异的。

那么我们该如何解决这个问题呢?让我们首先探索递归的本质,然后您就能看到基本的 RNN 是如何工作的。

考虑一下著名的斐波那契数列。如果您对它不熟悉,我已经将一些放在图 7-2 中。

斐波那契数列的前几个数字

图 7-2。斐波那契数列的前几个数字

这个序列背后的思想是,每个数字都是前两个数字的和。所以如果我们从 1 和 2 开始,下一个数字是 1 + 2,即 3。然后是 2 + 3,即 5,然后是 3 + 5,即 8,依此类推。

我们可以将这放在计算图中得到 Figure 7-3。

Fibonacci 序列的计算图表示

Figure 7-3. Fibonacci 序列的计算图表示

在这里,你可以看到我们将 1 和 2 输入函数,得到 3 作为输出。我们将第二个参数(2)传递到下一个步骤,并将它与前一个步骤的输出(3)一起输入函数。这样得到的输出是 5,并将其与前一个步骤的第二个参数(3)一起输入函数得到 8。这个过程无限进行下去,每个操作都依赖于前面的操作。左上角的 1 在整个过程中“存活”。它是传入第二次操作的 3 的一部分,是传入第三次操作的 5 的一部分,依此类推。因此,1 的某些本质在整个序列中得到了保留,尽管它对整体值的影响逐渐减弱。

这类似于递归神经元的结构。你可以在 Figure 7-4 中看到典型的递归神经元表示。

递归神经元

Figure 7-4. 一个递归神经元

一个值 x 在时间步中被输入到函数 F 中,通常标记为 x[t]。这产生该时间步的输出 y[t],通常标记为 y[t]。它还产生一个传递到下一个步骤的值,由从 F 到自身的箭头表示。

如果你看看时间步中的递归神经元如何在一起工作,这一点就会更清楚,你可以在 Figure 7-5 中看到。

时间步中的递归神经元

Figure 7-5. 时间步中的递归神经元

在这里,对 x[0]进行操作得到 y[0]和传递的值。下一步得到该值和 x[1],产生 y[1]和传递的值。接下来的步骤中,获取该值和 x[2],产生 y[2]和传递的值,依此类推。这与我们在斐波那契序列中看到的情况类似,我总是觉得这是一个很好的助记符,帮助记住 RNN 的工作原理。

扩展语言的递归

在前一节中,您看到了一个递归神经网络如何在多个时间步长上操作,以帮助在序列中保持上下文。确实,在本书的后面,RNNs 将用于序列建模。但是,当涉及到语言时,使用简单的 RNN(如图 7-4 中的递归神经元 和图 7-5 中的时间步骤中的递归神经元)可能会忽略一个细微差别。就像之前提到的斐波那契数列示例一样,随着时间的推移,所携带的上下文量会逐渐减少。在步骤 1 的神经元输出的影响在步骤 2 时很大,在步骤 3 时较小,在步骤 4 时更小,依此类推。因此,如果我们有一个句子如“今天天气很好,有美丽的蓝色”,单词“蓝色”对下一个单词的影响很大;我们可以猜测下一个单词可能是“天空”。但是来自句子更早部分的上下文呢?例如,考虑句子“I lived in Ireland, so in high school I had to learn how to speak and write。”。

那个是盖尔语,但真正给我们提供上下文的词是“Ireland”,它在句子中要远得多。因此,为了能够识别应该是什么,需要一种能够在更长距离上保留上下文的方法。RNN 的短期记忆需要变长,在承认这一点的基础上,对架构的改进被发明出来,称为长短期记忆(LSTM)。

我不会详细讨论 LSTM 如何工作的底层架构,但图 7-6 中显示的高级图表传达了主要观点。要了解更多关于内部操作的信息,请查看 Christopher Olah 的优秀博文

LSTM 架构通过添加“单元状态”来增强基本的 RNN,使得不仅可以在步骤与步骤之间,而且可以跨整个步骤序列中保持上下文。请记住这些是神经元,像神经元一样学习,这确保了重要的上下文会随着时间学习。

LSTM 架构的高级视图

图 7-6 LSTM 架构的高级视图

LSTM 的一个重要部分是它可以是双向的——时间步骤既可以向前迭代,也可以向后迭代,因此可以在两个方向上学习上下文。详见图 7-7 中的高级视图。

LSTM 双向架构的高级视图

图 7-7 LSTM 双向架构的高级视图

这样,从 0 到number_of_steps 的方向进行评估,以及从number_of_steps 到 0 进行评估。在每个步骤中,y 结果是“前向”传递和“后向”传递的汇总。您可以在图 7-8 中看到这一点。

双向 LSTM

图 7-8 双向 LSTM

在每个时间步考虑每个神经元为 F0、F1、F2 等。时间步的方向显示在这里,所以前向方向的 F1 计算是 F1(->),反向方向是(<-)F1。这些值被聚合以给出该时间步的 y 值。此外,细胞状态是双向的。这对于管理句子中的上下文非常有用。再次考虑句子“I lived in Ireland, so in high school I had to learn how to speak and write ”,你可以看到是通过上下文词“Ireland”来限定为“Gaelic”。但如果情况反过来:“I lived in ,so in high school I had to learn how to speak and write Gaelic”?通过反向遍历句子,我们可以了解应该是什么。因此,使用双向 LSTM 对于理解文本中的情感非常强大(正如你将在第八章中看到的,它们对于生成文本也非常强大!)。

当然,使用 LSTM 时有很多复杂的情况,特别是双向 LSTM,所以期望训练速度较慢。这时候值得投资一块 GPU,或者至少在 Google Colab 上使用托管的 GPU。

使用 RNN 创建文本分类器

在第六章中,你尝试使用嵌入创建了一个 Sarcasm 数据集的分类器。在那种情况下,单词在汇总之前被转换为向量,然后被送入密集层进行分类。当使用 RNN 层如 LSTM 时,你不会进行汇总,可以直接将嵌入层的输出馈送到递归层。当涉及到递归层的维度时,你经常会看到的一个经验法则是它与嵌入维度相同。这并非必需,但可以作为一个很好的起点。请注意,虽然在第六章中我提到嵌入维度通常是词汇量的四分之一根号,但当使用 RNN 时,你通常会看到这个规则被忽略,因为这会使递归层的大小太小。

因此,例如,在第六章中你开发的 Sarcasm 分类器的简单模型架构可以更新为使用双向 LSTM:

model = tf.keras.Sequential([
    tf.keras.layers.Embedding(vocab_size, embedding_dim),
 `tf``.``keras``.``layers``.``Bidirectional``(``tf``.``keras``.``layers``.``LSTM``(``embedding_dim``)``)``,`
    tf.keras.layers.Dense(24, activation='relu'),
    tf.keras.layers.Dense(1, activation='sigmoid')
])

损失函数和分类器可以设置为这个(请注意学习率为 0.00001,或者 1e–5):

adam = tf.keras.optimizers.Adam(learning_rate=0.00001, 
                                beta_1=0.9, beta_2=0.999, amsgrad=False)

model.compile(loss='binary_crossentropy',
              optimizer=adam, metrics=['accuracy'])

当你打印出模型架构摘要时,你会看到像这样的内容。注意词汇表大小为 20,000,嵌入维度为 64。这使得嵌入层有 1,280,000 个参数,双向层将有 128 个神经元(64 个前向,64 个后向):

Layer (type)                 Output Shape              Param # 
=================================================================
embedding_11 (Embedding)     (None, None, 64)          1280000   
_________________________________________________________________
bidirectional_7 (Bidirection (None, 128)               66048     
_________________________________________________________________
dense_18 (Dense)             (None, 24)                3096      
_________________________________________________________________
dense_19 (Dense)             (None, 1)                 25        
=================================================================
Total params: 1,349,169
Trainable params: 1,349,169
Non-trainable params: 0
_________________________________________________________________

图 7-9 显示了使用这个模型在 30 个 epochs 上训练的结果。

正如您所见,网络在训练数据上的准确率迅速上升至 90%以上,但验证数据在大约 80%处趋于平稳。这与我们之前得到的数据类似,但检查图 7-10 中的损失图表表明,尽管在 15 个 epoch 后验证集的损失出现了分歧,但与第六章中的损失图表相比,使用了 2 万个词而非 2 千个词后,它也趋于平缓并降至了较低的值。

LSTM 30 个 epoch 的准确率

图 7-9. LSTM 30 个 epoch 的准确率

LSTM 30 个 epoch 的损失

图 7-10. LSTM 30 个 epoch 的损失

不过,这仅使用了单个 LSTM 层。在下一节中,您将看到如何使用堆叠 LSTMs,并探讨其对分类此数据集准确性的影响。

堆叠 LSTMs

在上一节中,您看到如何在嵌入层后使用 LSTM 层来帮助分类讽刺数据集的内容。但是 LSTMs 可以相互堆叠,许多最先进的自然语言处理模型都使用了这种方法。

使用 TensorFlow 堆叠 LSTMs 非常简单。您只需像添加Dense层一样添加它们作为额外的层,但所有层都需要将其return_sequences属性设置为True,直到最后一层为止。以下是一个例子:

model = tf.keras.Sequential([
    tf.keras.layers.Embedding(vocab_size, embedding_dim),
    tf.keras.layers.Bidirectional(tf.keras.layers.LSTM(embedding_dim, 
	`return_sequences``=``True`)),
    tf.keras.layers.Bidirectional(tf.keras.layers.LSTM(embedding_dim)),
    tf.keras.layers.Dense(24, activation='relu'),
    tf.keras.layers.Dense(1, activation='sigmoid')
])

最后一层也可以设置return_sequences=True,在这种情况下,它将返回模型输出的序列值而不是单个值给密集层进行分类。这在解析模型输出时非常方便,稍后我们将讨论。模型架构将如下所示:

Layer (type)                 Output Shape              Param # 
=================================================================
embedding_12 (Embedding)     (None, None, 64)          1280000   
_________________________________________________________________
bidirectional_8 (Bidirection (None, None, 128)         66048     
_________________________________________________________________
bidirectional_9 (Bidirection (None, 128)               98816     
_________________________________________________________________
dense_20 (Dense)             (None, 24)                3096      
_________________________________________________________________
dense_21 (Dense)             (None, 1)                 25        
=================================================================
Total params: 1,447,985
Trainable params: 1,447,985
Non-trainable params: 0
_________________________________________________________________

添加额外的层将使我们大约有 100,000 个额外的参数需要学习,增加约 8%。因此,这可能会减慢网络的速度,但如果有合理的好处,成本相对较低。

在训练了 30 个 epoch 后,结果如图 7-11 所示。虽然验证集的准确率保持平稳,但检查损失(图 7-12)却讲述了不同的故事。

堆叠 LSTM 架构的准确率

图 7-11. 堆叠 LSTM 架构的准确率

如您在图 7-12 中所见,尽管训练和验证的准确率看起来都很好,但验证损失迅速上升,这是过拟合的明显迹象。

堆叠 LSTM 架构的损失

图 7-12. 堆叠 LSTM 架构的损失

过拟合(由于训练准确性向 100% 上升,同时损失平稳下降,而验证准确性相对稳定且损失急剧增加)是模型过于专注于训练集的结果。与 第六章 中的示例一样,这表明如果仅查看准确性指标而不检查损失,就很容易陷入错误的安全感中。

优化堆叠 LSTM

在 第六章 中,您看到减少学习率是减少过拟合非常有效的方法。值得探索一下,这是否对循环神经网络也会产生积极的影响。

例如,以下代码将学习率从 0.00001 减少了 20%,变为 0.000008:

adam = tf.keras.optimizers.Adam(`learning_rate``=``0.000008`, 
  beta_1=0.9, beta_2=0.999, amsgrad=False)

model.compile(loss='binary_crossentropy',
  optimizer=adam,metrics=['accuracy'])

图 7-13 展示了这一点对训练的影响。看起来差别不大,尽管曲线(特别是对于验证集)稍微平滑一些。

使用 dropout 的堆叠 LSTM 的准确性

图 7-13. 堆叠 LSTM 的准确性随降低学习率的影响

尽管初步观察 图 7-14 同样显示由于降低学习率而对损失的影响较小,但值得更仔细地观察。尽管曲线形状大致相似,但损失增长速率显然更低:经过 30 个 epoch 后,损失约为 0.6,而较高学习率时接近 0.8。调整学习率超参数确实值得研究。

堆叠 LSTM 的损失随降低学习率的影响

图 7-14. 减少学习率对堆叠 LSTM 的损失的影响

使用 dropout

除了改变学习率参数外,在 LSTM 层中使用 dropout 也是值得考虑的。它的工作原理与密集层完全相同,正如在 第三章 中讨论的那样,随机删除神经元以防止邻近偏差影响学习。

Dropout 可以通过 LSTM 层上的参数实现。以下是一个示例:

model = tf.keras.Sequential([
    tf.keras.layers.Embedding(vocab_size, embedding_dim),
    tf.keras.layers.Bidirectional(tf.keras.layers.LSTM(embedding_dim,
	return_sequences=True, `dropout``=``0.2`)),
    tf.keras.layers.Bidirectional(tf.keras.layers.LSTM(embedding_dim,
	`dropout``=``0.2`)),
    tf.keras.layers.Dense(24, activation='relu'),
    tf.keras.layers.Dense(1, activation='sigmoid')
])

注意,在实施 dropout 后会显著减慢训练速度。在我的情况下,使用 Colab,每个 epoch 的时间从约 10 秒增加到约 180 秒。

准确性结果可以在 图 7-15 中看到。

使用 dropout 的堆叠 LSTM 的准确性

图 7-15. 使用 dropout 的堆叠 LSTM 的准确性

正如您所见,使用 dropout 对网络的准确性影响不大,这是好事!总是担心失去神经元会使模型表现更差,但正如我们在这里看到的那样,情况并非如此。

正如你在图 7-16 中所见,损失也有积极的影响。

虽然曲线明显分开,但它们比之前更接近,验证集的损失在约 0.5 左右趋于平缓。这比之前看到的 0.8 显著更好。正如这个例子所示,dropout 是另一种可以用来提高基于 LSTM 的 RNN 性能的方便技术。

使用启用了 dropout 的 LSTM 的损失曲线

图 7-16. 使用 dropout 的 LSTM 的损失曲线

探索这些技术以避免过拟合您的数据,以及我们在第六章 中介绍的预处理数据的技术。但有一件事我们还没有尝试过——一种迁移学习的形式,您可以使用预先学习的词嵌入,而不是尝试学习您自己的。我们将在接下来探讨这个问题。

使用 RNNs 的预训练嵌入

在所有先前的例子中,您收集了用于训练集的完整单词集,然后用它们训练嵌入。这些最初是聚合的,然后被输入到稠密网络中,在这一章中,您探索了如何使用 RNN 来改进结果。在这样做的同时,您受限于数据集中的词汇及其嵌入如何使用从该数据集的标签中学习。

回想一下第四章,我们讨论了迁移学习。如果,你可以使用预先学习的嵌入,而不是自己学习嵌入,那会怎样呢?研究人员已经把词语转换为向量,并且这些向量是经过验证的,一个例子是由斯坦福大学的杰弗里·彭宁顿、理查德·索切尔和克里斯托弗·曼宁开发的GloVe(全球词向量表示)模型

在这种情况下,研究人员分享了各种数据集的预训练词向量:

  • 一个从维基百科和 Gigaword 中取词的 60 亿令牌、40 万字词汇、50、100、200 和 300 维度的集合

  • 来自常见爬网的 4200 亿令牌、190 万字词汇、300 维度

  • 一个来自常见爬网的 8400 亿令牌、220 万字词汇、300 维度

  • 一个来自 Twitter 爬 20 亿推文的 270 亿令牌、120 万字词汇、25、50、100 和 200 维度

鉴于这些向量已经预先训练,您可以简单地在您的 TensorFlow 代码中重复使用它们,而不是从头开始学习。首先,您需要下载 GloVe 数据。我选择使用 270 亿令牌和 120 万字词汇的 Twitter 数据。下载是一个包含 25、50、100 和 200 维度的存档。

为了使您更容易,我已经托管了 25 维度版本,并且您可以像这样将其下载到 Colab 笔记本中:

!wget --`no`-check-certificate \
    https://storage.googleapis.com/laurencemoroney-blog.appspot.com
 `/``glove``.``twitter``.``27``B``.``25``d``.``zip` `\`
    -O /tmp/glove.zip

这是一个 ZIP 文件,所以您可以像这样解压它,得到一个名为glove.twitter.27b.25d.txt的文件:

# Unzip GloVe embeddings
`import` `os`
`import` `zipfile`

`local_zip` `=` `'``/tmp/glove.zip``'`
`zip_ref` `=` `zipfile``.``ZipFile``(``local_zip``,` `'``r``'``)`
`zip_ref``.``extractall``(``'``/tmp/glove``'``)`
`zip_ref``.``close``(``)`

文件中的每个条目都是一个单词,后跟为其学习的维度系数。使用这种方法的最简单方式是创建一个字典,其中键是单词,值是嵌入。您可以像这样设置此字典:

glove_embeddings = dict()
f = open(`'``/tmp/glove/glove.twitter.27B.25d.txt``'`)
`for` line `in` f:
    values = line.split()
    word = values[`0`]
    coefs = np.asarray(values[`1`:], dtype=`'``float32``'`)
    glove_embeddings[word] = coefs
f.close()

在这一点上,您可以简单地使用单词作为键来查找任何单词的系数集。例如,要查看“frog”的嵌入,您可以使用:

glove_embeddings[`'`frog`'`]

有了这个资源,您可以像以前一样使用标记器获取您的语料库的单词索引,但现在您可以创建一个新的矩阵,我将其称为嵌入矩阵。这将使用 GloVe 集合的嵌入(从glove_embeddings中获取)作为其值。因此,如果您检查数据集中的单词索引的单词,就像这样:

`{`'<OOV>'`:` `1``,` 'new'`:` `2``,` … 'not'`:` `5``,` 'just'`:` `6``,` 'will'`:` `7`

然后,嵌入矩阵中的第一行应该是“”的 GloVe 系数,下一行将是“new”的系数,依此类推。

您可以使用以下代码创建该矩阵:

embedding_matrix = np.zeros((vocab_size, embedding_dim))
`for` word, index `in` tokenizer.word_index.items():
    `if` index > vocab_size - `1`:
        `break`
    `else`:
        embedding_vector = glove_embeddings.`get`(word)
        `if` embedding_vector `is` `not` `None`:
            embedding_matrix[index] = embedding_vector

这只是创建一个具有所需词汇大小和嵌入维度的矩阵。然后,对于标记器的每个单词索引中的每个项,您从glove_embeddings中查找系数,并将这些值添加到矩阵中。

然后,通过设置weights参数将嵌入层改为使用预训练的嵌入,并通过设置trainable=False来指定不训练该层:

model = tf.keras.Sequential([
    tf.keras.layers.Embedding(vocab_size, embedding_dim, 
`weights``=``[``embedding_matrix``]``,` `trainable``=``False`),
    tf.keras.layers.Bidirectional(tf.keras.layers.LSTM(embedding_dim, 
                                  return_sequences=True)),
    tf.keras.layers.Bidirectional(tf.keras.layers.LSTM(embedding_dim)),
    tf.keras.layers.Dense(24, activation='relu'),
    tf.keras.layers.Dense(1, activation='sigmoid')
])

现在您可以像以前一样进行训练。但是,您需要考虑您的词汇量大小。在上一章中,为了避免过拟合,您进行了优化,目的是避免使学习低频词汇的嵌入变得过度负担,您通过使用较小的经常使用的单词词汇来避免过拟合。在这种情况下,由于单词嵌入已经在 GloVe 中学习过,您可以扩展词汇表,但要扩展多少?

首先要探索的是您的语料库中有多少单词实际上在 GloVe 集合中。它有 120 万个单词,但不能保证它所有都有您的单词。

所以,这里有一些代码可以进行快速比较,以便您可以探索您的词汇量应该有多大。

首先让我们整理一下数据。创建一个 X 和 Y 的列表,其中 X 是单词索引,如果单词在嵌入中则 Y=1,否则为 0。此外,您可以创建一个累积集合,在每个时间步骤计算单词的商。例如,索引为 0 的单词“OOV”不在 GloVe 中,所以它的累积 Y 为 0。下一个索引处的单词“new”在 GloVe 中,所以它的累积 Y 为 0.5(即到目前为止一半的单词在 GloVe 中),您可以继续以此方式统计整个数据集:

xs=[]
ys=[]
cumulative_x=[]
cumulative_y=[]
total_y=`0`
`for` word, index `in` tokenizer.word_index.items():
  xs.append(index)
  cumulative_x.append(index)
  `if` glove_embeddings.`get`(word) `is` `not` `None`:
    total_y = total_y + `1`
    ys.append(`1`)
  `else`:
    ys.append(`0`)
  cumulative_y.append(total_y / index)

然后,您可以使用以下代码将 X 与 Y 进行绘制:

`import` matplotlib.pyplot `as` plt
fig, ax = plt.subplots(figsize=(`12`,`2`))
ax.spines[`'``top``'`].set_visible(`False`)

plt.margins(x=`0`, y=`None`, tight=`True`)
`#plt.axis([13000, 14000, 0, 1])`
plt.fill(ys)

这将为您提供一个词频图,看起来类似于图 7-17。

词频图

图 7-17. 词频图

正如您在图表中所见,密度在 10,000 和 15,000 之间发生变化。这使您可以粗略检查,大约在第 13,000 个 token 处,不在 GloVe 嵌入中的单词频率开始超过在其中的单词。

如果您然后绘制 cumulative_xcumulative_y,您可以更好地理解这一点。这是代码:

`import` matplotlib.pyplot `as` plt
plt.plot(cumulative_x, cumulative_y)
plt.axis([`0`, `25000`, .`915`, .`985`])

您可以在 图 7-18 中查看结果。

绘制单词索引频率对 GloVe 的影响

图 7-18. 绘制单词索引频率对 GloVe 的影响

您现在可以调整 plt.axis 中的参数来放大,以找到不在 GloVe 中的单词开始超过那些在 GloVe 中的单词的拐点。这是您设置词汇表大小的良好起点。

使用这种方法,我选择了词汇量为 13,200(而不是之前的 2,000,以避免过拟合),以及这种模型架构,其中 embedding_dim25,因为我使用的是 GloVe 集:

model = tf.keras.`Sequential`([
    tf.keras.layers.`Embedding`(vocab_size, embedding_dim, 
weights=[embedding_matrix], trainable=`False`),
    tf.keras.layers.`Bidirectional`(tf.keras.layers.LSTM(embedding_dim, 
return_sequences=`True`)),
    tf.keras.layers.`Bidirectional`(tf.keras.layers.LSTM(embedding_dim)),
    tf.keras.layers.`Dense`(`24`, activation=`'``relu``'`),
    tf.keras.layers.`Dense`(`1`, activation=`'``sigmoid``'`)
])
adam = tf.keras.optimizers.`Adam`(learning_rate=`0.00001`, beta_1=`0.9`, beta_2=`0.999`,
amsgrad=`False`)
model.compile(loss=`'``binary_crossentropy``'`,optimizer=adam, metrics=[`'``accuracy``'`])

将其训练 30 个 epoch 可以获得一些出色的结果。准确性显示在 图 7-19 中。验证准确性非常接近训练准确性,表明我们不再过拟合。

使用 GloVe 嵌入的堆叠 LSTM 准确性

图 7-19. 使用 GloVe 嵌入的堆叠 LSTM 准确性

这一点得到了损失曲线的支持,如 图 7-20 所示。验证损失不再发散,表明尽管我们的准确性只有约 73%,但我们可以相信模型在这个程度上是准确的。

使用 GloVe 嵌入的堆叠 LSTM 损失

图 7-20. 使用 GloVe 嵌入的堆叠 LSTM 损失

将模型训练更长时间显示出非常相似的结果,并表明尽管过拟合开始在大约第 80 个 epoch 发生,但模型仍然非常稳定。

准确性指标 (图 7-21) 显示出一个训练良好的模型。

损失指标 (图 7-22) 显示大约在第 80 个 epoch 开始发散,但模型仍然适合。

使用 GloVe 嵌入的堆叠 LSTM 准确性

图 7-21. 使用 GloVe 嵌入的堆叠 LSTM 准确性在 150 个 epoch 上

使用 GloVe 的堆叠 LSTM 在 150 个 epoch 上的损失

图 7-22. 使用 GloVe 嵌入的堆叠 LSTM 在 150 个 epoch 上的损失

这告诉我们,这个模型是早期停止的一个好候选,您只需训练 75–80 个 epoch 就可以得到最佳结果。

我用 The Onion 的标题测试了它,这是 Sarcasm 数据集中讽刺标题的来源,与其他句子进行了对比,如下所示:

`test_sentences` `=` `[`"It Was, For, Uh, Medical Reasons, Says Doctor To Boris Johnson,
Explaining Why They Had To Give Him Haircut"`,`

"It's a beautiful sunny day"`,`

"I lived in Ireland, so in high school they made me learn to speak and write in
Gaelic"`,`

"Census Foot Soldiers Swarm Neighborhoods, Kick Down Doors To Tally Household
Sizes"`]`

这些标题的结果如下 —— 请记住,接近 50%(0.5)的值被视为中性,接近 0 的值为非讽刺,接近 1 的值为讽刺:

[[`0.8170955` ]
 [`0.08711044`]
 [`0.61809343`]
 [`0.8015281` ]]

第一句和第四句,摘自The Onion,显示出 80%以上的讽刺可能性。关于天气的声明强烈地不是讽刺(9%),而关于在爱尔兰上高中的句子被认为可能是讽刺的,但置信度不高(62%)。

摘要

本章向您介绍了递归神经网络,它们在设计中使用面向序列的逻辑,并且可以帮助您理解句子中的情感,这不仅基于它们包含的单词,还基于它们出现的顺序。您了解了基本的 RNN 工作原理,以及 LSTM 如何在此基础上进行改进,从而能够长期保留上下文。您利用这些内容改进了自己一直在研究的情感分析模型。然后,您深入探讨了 RNN 的过拟合问题以及改进技术,包括利用预训练嵌入进行迁移学习。在第八章中,您将利用所学知识探索如何预测单词,并从中创建一个为您写诗的文本生成模型!

第八章:使用 TensorFlow 创建文本

  • 你什么都不知道,琼·雪诺

  • 他所驻扎的地方

  • 无论是在科克还是在蓝鸟的儿子那里

  • 航行到夏天

  • 旧甜长和喜悦的戒指

  • 所以我会等待野生的小姑娘去世

这段文字是由一个在小语料库上训练的非常简单的模型生成的。我稍微增强了它,通过添加换行和标点,但除了第一行外,其余都是由你将在本章学习如何构建的模型生成的。提到野生的小姑娘去世有点酷——如果你看过琼·雪诺来自的那部剧,你会明白为什么!

在过去的几章中,你看到如何使用基于文本的数据来使用 TensorFlow,首先将其标记化为可以由神经网络处理的数字和序列,然后使用嵌入来模拟使用向量的情感,最后使用深度和递归神经网络来分类文本。我们使用了 Sarcasm 数据集,一个小而简单的数据集,来说明所有这些是如何工作的。在本章中,我们将转换方向:不再分类现有文本,而是创建一个能够预测文本的神经网络。给定一个文本语料库,它将尝试理解其中的词语模式,以便在给出一个称为种子的新文本时,预测下一个应该出现的词语。一旦有了这个预测,种子和预测的词语将成为新的种子,然后可以预测下一个词语。因此,当神经网络在文本语料库上训练完成后,它可以尝试以类似的风格编写新的文本。为了创建上面的诗歌片段,我收集了许多传统爱尔兰歌曲的歌词,用它们来训练神经网络,并用它来预测词语。

我们会从简单开始,用少量文字来说明如何建立预测模型,最终会创建一个含有更多文字的完整模型。之后,你可以尝试一下,看它能创造出怎样的诗歌!

要开始,你必须对待这段文本有所不同,与你迄今为止所做的不同。在前几章中,你将句子转换为序列,然后根据其中标记的嵌入进行分类。

在创建用于训练这种预测模型的数据时,还有一个额外的步骤,需要将序列转换为输入序列标签,其中输入序列是一组词,标签是句子中的下一个词。然后你可以训练一个模型来将输入序列与它们的标签匹配,以便未来的预测可以选择接近输入序列的标签。

将序列转换为输入序列

在预测文本时,你需要用一个带有相关标签的输入序列(特征)来训练神经网络。将序列与标签匹配是预测文本的关键。

因此,例如,如果在你的语料库中有句子“今天有美丽的蓝天”,你可以将其分割为“今天有美丽的蓝”作为特征,“天空”作为标签。然后,如果你对文本“今天有美丽的蓝”进行预测,它很可能是“天空”。如果在训练数据中你还有“昨天有美丽的蓝天”,同样方式分割,如果你对文本“明天会有美丽的蓝色”进行预测,那么很可能下一个词是“天空”。

给定大量的句子,训练使用单词序列及其下一个单词作为标签的模型,可以快速建立起一个预测模型,在文本的现有体系中预测出句子中最可能的下一个单词。

我们将从一个非常小的文本语料库开始——这是一首传统爱尔兰歌曲的摘录,这首歌的部分歌词如下:

  • 在阿西镇有位杰里米·兰尼根

  • 一直打到他一文不名。

  • 他的父亲去世了,使他重新成为一个男子汉。

  • 留给他一块农场和十英亩地。

  • 他为亲朋好友举办了一场盛大的派对

  • 谁在到达墙壁时没有忘记他,

  • 如果你愿意听,我会让你的眼睛闪闪发亮,

  • 在兰尼根的舞会上的争执和混乱。

  • 我自己确实得到了自由邀请,

  • 对于所有的好女孩和好男孩,我可能会问,

  • 仅仅一分钟内,亲朋好友都来了,

  • 像蜜蜂围着一桶酒快乐地跳舞。

  • 朱迪·奥达利,那位可爱的小帽匠,

  • 她给我眨了一下眼睛让我去找她,

  • 我很快和佩吉·麦吉利根一起到了,

  • 刚好赶上兰尼根的舞会。

创建一个包含所有文本的单个字符串,并将其设置为您的数据。使用\n表示换行。然后,这个语料库可以像这样轻松加载和分词化:

tokenizer = `Tokenizer`()

data=`"``In the town of Athy one Jeremy Lanigan` `\n` `Battered away ... ...``"`
corpus = data.lower().split(`"``\n``"`)

tokenizer.fit_on_texts(corpus)
total_words = len(tokenizer.word_index) + `1`

此过程的结果是用它们的标记值替换单词,如图 8-1 所示。

句子分词

图 8-1. 句子分词

要训练一个预测模型,我们应该在这里进一步进行一步操作——将句子分割成多个较小的序列,例如,我们可以有一个由前两个标记组成的序列,另一个由前三个标记组成,依此类推(图 8-2)。

将序列转换为多个输入序列

图 8-2. 将序列转换为多个输入序列

要做到这一点,您需要逐行遍历语料库中的每一行,并使用texts_to_sequences将其转换为标记列表。然后,您可以通过循环遍历每个标记并制作一个包含所有标记的列表,来拆分每个列表。

这里是代码:

input_sequences = []
`for` line `in` corpus:
    token_list = tokenizer.texts_to_sequences([line])[`0`]
    `for` i `in` range(`1`, len(token_list)):
        n_gram_sequence = token_list[:i+`1`]
        input_sequences.append(n_gram_sequence)

`print`(input_sequences[:`5`])

一旦你有了这些输入序列,你可以将它们填充成常规形状。我们将使用预填充(图 8-3)。

填充输入序列

图 8-3. 填充输入序列

为此,你需要找到输入序列中最长的句子,并将所有内容填充到该长度。以下是代码:

max_sequence_len = max([len(x) `for` x `in` input_sequences])

input_sequences = np.array(pad_sequences(input_sequences, 
                            maxlen=max_sequence_len, padding=`'``pre``'`))

最后,一旦你有一组填充的输入序列,你可以将它们分为特征和标签,其中标签只是输入序列中的最后一个令牌(图 8-4)。

将填充的序列转换为特征(x)和标签(y)

图 8-4. 将填充序列转换为特征(x)和标签(y)

在训练神经网络时,你将要将每个特征与其对应的标签匹配。例如,[0 0 0 0 4 2 66 8 67 68 69] 的标签将是 [70]。

这是分离输入序列中标签的代码:

xs, labels = input_sequences[:,:-`1`],input_sequences[:,-`1`]

接下来,你需要对标签进行编码。目前它们只是令牌—例如,在图 8-4 的顶部的数字 2。但是如果你想要在分类器中使用令牌作为标签,它将必须映射到一个输出神经元。因此,如果你要分类 n 个单词,每个单词都是一个类,你将需要 n 个神经元。这里控制词汇量的大小非常重要,因为你拥有的单词越多,你就需要更多的类。回想一下在第 2 和第三章中,当你用 Fashion MNIST 数据集对时尚物品进行分类时,你有 10 种类型的服装?那就需要在输出层有 10 个神经元。在这种情况下,如果你想预测多达 10,000 个词汇单词,你将需要一个包含 10,000 个神经元的输出层!

此外,你需要对标签进行独热编码,以便它们与神经网络的期望输出匹配。考虑到图 8-4。如果神经网络被输入 X,其中包含一系列 0,然后是一个 4,你希望预测结果是 2,但网络是通过具有 词汇量 个神经元的输出层来实现这一点,其中第二个神经元具有最高的概率。

要将标签编码为一组 Y,然后用于训练,你可以使用 tf.keras 中的 to_categorical 实用程序:

ys = tf.keras.utils.to_categorical(labels, num_classes=total_words)

你可以在图 8-5 中看到这一点。

独热编码标签

图 8-5. 独热编码标签

这是一种非常稀疏的表示方法,如果你有大量的训练数据和大量的可能单词,内存消耗会非常快!假设你有 100,000 个训练句子,词汇量为 10,000 个词,你需要 1,000,000,000 字节来存储标签!但如果我们要设计我们的网络来分类和预测单词,这就是我们不得不采取的方式。

创建模型

现在让我们创建一个可以用这些输入数据进行训练的简单模型。它将只包括一个嵌入层,然后是一个 LSTM,再后面是一个稠密层。

对于嵌入,你将需要每个单词一个向量,因此参数将是单词总数和你想要嵌入的维度数。在这种情况下,我们单词不多,因此八个维度应该足够了。

你可以使 LSTM 双向运行,步骤数可以是序列的长度,即我们的最大长度减 1(因为我们取出了末尾的一个标记来作为标签)。

最后,输出层将是一个密集层,参数为单词的总数,由 softmax 激活。该层中的每个神经元将是下一个单词与该索引值的单词匹配的概率:

`model` `=` Sequential`(``)`
`model``.``add``(`Embedding`(``total_words``,` `8``)``)`
`model``.``add``(`Bidirectional`(``LSTM``(``max_sequence_len``-``1``)``)``)`
`model``.``add``(`Dense`(``total_words``,` `activation``=``'``softmax``'``)``)`

编译模型时,使用像分类交叉熵这样的分类损失函数和像 Adam 这样的优化器。你也可以指定你想要捕捉的指标:

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

这是一个非常简单的模型,没有太多的数据,所以你可以训练很长时间——比如 1,500 个时期:

history = model.fit(xs, ys, epochs=`1500`, verbose=`1`)

在经过 1,500 个时期后,你会发现它已经达到了非常高的准确率(图表 8-6)。

训练准确率

图表 8-6. 训练准确率

当模型达到大约 95%的准确率时,我们可以确信,如果我们有一段它已经见过的文本,它会大约 95%的时间准确地预测下一个单词。然而,请注意,当生成文本时,它将不断看到以前未曾见过的单词,因此尽管有这么好的数字,你会发现网络很快就会开始生成毫无意义的文本。我们将在下一节中探讨这一点。

生成文本

现在你已经训练出一个能够预测序列中下一个单词的网络,接下来的步骤是给它一段文本序列,并让它预测下一个单词。让我们看看如何实现这一点。

预测下一个单词

你将首先创建一个称为种子文本的短语。这是网络将基于其生成所有内容的初始表达式。它将通过预测下一个单词来完成这一操作。

从网络已经看到的短语开始,“在阿西镇”:

`seed_text` `=` "in the town of athy"

接下来你需要使用texts_to_sequences对其进行标记化。即使只有一个值,它也会返回一个数组,因此取该数组中的第一个元素:

token_list = tokenizer.texts_to_sequences([seed_text])[`0`]

然后你需要填充该序列,使其与训练时使用的数据形状相同:

token_list = pad_sequences([token_list], 
                            maxlen=max_sequence_len-`1`, padding=`'``pre``'`)

现在,你可以通过在标记列表上调用model.predict来预测该标记列表的下一个单词。这将返回语料库中每个单词的概率,因此将结果传递给np.argmax以获取最有可能的一个:

predicted = np.argmax(model.predict(token_list), axis=-`1`)
`print`(predicted)

这应该会给你一个值68。如果你查看单词索引,你会发现这是单词“one”:

'town'`:` `66``,` 'athy'`:` `67``,` 'one'`:` `68``,` 'jeremy'`:` `69``,` 'lanigan'`:` `70``,`

你可以通过搜索单词索引项目直到找到predicted并将其打印出来,在代码中查找它:

`for` word, index `in` tokenizer.word_index.items():
    `if` index == predicted:
        `print`(word)
        `break`

因此,从文本“在阿西镇”开始,网络预测下一个单词应该是“one”—如果你查看训练数据,这是正确的,因为歌曲以以下行开始:

  • 在阿西镇 一个杰里米·拉尼根

    • 打击直到他没了一磅

现在您已确认模型正在工作,您可以发挥创造力,并使用不同的种子文本。例如,当我使用种子文本“甜美的杰里米看到了都柏林”时,它预测的下一个单词是“然后”。(选择这段文本是因为所有这些词汇都在语料库中。您应该期望在这种情况下,至少在开始时,对预测单词的预期结果更为准确。)

合并预测以生成文本

在前一节中,您看到了如何使用模型预测给定种子文本的下一个单词。现在,要让神经网络创建新文本,只需重复预测,每次添加新单词即可。

例如,稍早时,当我使用短语“甜美的杰里米看到了都柏林”时,它预测下一个单词将是“然后”。您可以通过在种子文本后附加“然后”来扩展此过程,以获取“甜美的杰里米看到了都柏林然后”,并获得另一个预测。重复此过程将为您生成一个由 AI 创建的文本字符串。

这是前一节更新的代码,它执行了多次循环,次数由next_words参数设置:

seed_text = "sweet jeremy saw dublin"
next_words=10

for _ in range(next_words):
    token_list = tokenizer.texts_to_sequences([seed_text])[0]
    token_list = pad_sequences([token_list], 
 maxlen=max_sequence_len-1, padding='pre')
    predicted = model.predict_classes(token_list, verbose=0)
    output_word = ""

    for word, index in tokenizer.word_index.items():
        if index == predicted:
            output_word = word
            break
    seed_text += " " + output_word

print(seed_text)

这最终会创建一个类似这样的字符串:

sweet jeremy saw dublin then got there as me me a call doing me

它迅速陷入了胡言乱语。为什么呢?首先,训练文本的主体非常小,因此可用的上下文非常有限。其次,序列中下一个单词的预测取决于序列中的前一个单词,如果前几个单词匹配不好,即使是最佳的“下一个”匹配也会有很低的概率。当您将此添加到序列并预测其后的下一个单词时,它的概率会更高——因此,预测的单词看起来似乎是半随机的。

因此,例如,“甜美的杰里米看到了都柏林”中的所有单词虽然都存在于语料库中,但它们从未按照那种顺序出现过。在进行第一次预测时,选择了“然后”作为最可能的候选词,其概率相当高(89%)。当它被添加到种子中以得到“甜美的杰里米看到了都柏林然后”,我们得到了另一句在训练数据中未见过的短语,因此预测将最高概率赋予了单词“got”,概率为 44%。继续向句子添加单词会降低在训练数据中的匹配概率,因此预测准确性会降低——导致预测的单词具有更随机的“感觉”。

这导致了人工智能生成的内容随着时间的推移变得越来越荒谬。例如,看看优秀的科幻短片Sunspring,这部片子完全由基于 LSTM 的网络编写,就像您正在构建的这个网络,训练于科幻电影剧本。模型被给予种子内容,并被要求生成新的剧本。结果令人捧腹,您将看到,虽然初始内容是有意义的,但随着电影的进行,变得越来越难以理解。

扩展数据集

你可以很简单地将用于硬编码数据集的相同模式扩展到使用文本文件。我提供了一个文本文件,里面包含约 1700 行来自多首歌曲的文本,供你进行实验。稍作修改,你就可以使用这个文本文件代替单一的硬编码歌曲。

要在 Colab 中下载数据,请使用以下代码:

!wget --no-check-certificate \
    https://storage.googleapis.com/laurencemoroney-blog.appspot.com/ \
    irish-lyrics-eof.txt-O /tmp/irish-lyrics-eof.txt

然后你可以简单地从中加载文本到你的语料库中,像这样:

data = open('/tmp/irish-lyrics-eof.txt').read()
corpus = data.lower().split("\n")

然后你的其余代码就可以正常工作了!

将其训练一千个时期会使准确率达到约 60%,曲线趋于平缓(图 8-7)。

在更大的数据集上训练

图 8-7. 在更大的数据集上训练

再次尝试“在阿西镇”的短语,预测得到“one”,但这次只有 40%的概率。

对于“甜美的杰里米看到了都柏林”,预测的下一个词是“drawn”,概率为 59%。预测接下来的 10 个单词:

sweet jeremy saw dublin drawn and fondly i am dead and the parting graceful

看起来好了一点!但我们能进一步改进吗?

改变模型架构

你可以改变模型的架构来改进它,使用多个堆叠的 LSTM。这很简单 —— 只需确保在第一个 LSTM 上设置return_sequencesTrue。这里是代码:

model = Sequential()
model.add(Embedding(total_words, 8))
model.add(Bidirectional(LSTM(max_sequence_len-1, `return_sequences``=``'``True``'`)))
`model``.``add``(``Bidirectional``(``LSTM``(``max_sequence_len``-``1``)``)``)`
model.add(Dense(total_words, activation='softmax'))
model.compile(loss='categorical_crossentropy', optimizer='adam', 
			  metrics=['accuracy'])
history = model.fit(xs, ys, epochs=1000, verbose=1)

你可以看到这对一千个时期的训练的影响在图 8-8 中,它与先前的曲线没有显著不同。

添加第二个 LSTM 层

图 8-8. 添加第二个 LSTM 层

在使用之前相同的短语进行测试时,这一次在“在阿西镇”后,我得到了“more”作为下一个词,概率为 51%,而在“甜美的杰里米看到了都柏林”后,我得到了“cailín”(盖尔语中的“女孩”)的概率为 61%。同样地,在预测更多单词时,输出很快就变得支离破碎了。

这里有一些例子:

sweet jeremy saw dublin cailín loo ra fountain plundering that fulfill
you mccarthy you mccarthy down

you know nothing jon snow johnny cease and she danced that put to smother well
i must the wind flowers
dreams it love to laid ned the mossy and night i weirs

如果你得到了不同的结果,别担心 —— 你没有做错任何事情,但神经元的随机初始化会影响最终的得分。

改进数据

有一个小技巧,你可以用来扩展这个数据集的大小,而不添加任何新的歌曲,称为数据的“窗口化”。现在,每首歌的每一行都被读作一个单独的行,然后转换为输入序列,正如你在图 8-2 中看到的那样。虽然人类阅读歌曲时是逐行进行的,以听到韵律和节奏,但模型不必如此,尤其是在使用双向 LSTM 时。

所以,与其处理“在阿西镇,一个名叫杰里米·兰尼根”的这行,然后处理下一行(“击打直到他一文不剩”),我们可以将所有行视为一段长连续文本。我们可以创建一个n个单词的“窗口”来处理这段文本,然后移动窗口一个单词以获取下一个输入序列(图 8-9)。

一个移动的单词窗口

图 8-9. 一个移动的单词窗口

在这种情况下,可以通过增加输入序列的数量来产生更多的训练数据。将窗口移动到整个文本语料库上,我们可以得到((number_of_wordswindow_size) × window_size)个输入序列,我们可以用来训练。

代码非常简单——在加载数据时,我们可以直接从语料库中的单词创建“句子”,而不是将每个歌词行分割为“句子”:

window_size=10
sentences=[]
alltext=[]
data = open('/tmp/irish-lyrics-eof.txt').read()
corpus = data.lower()
words = corpus.split(" ")
range_size = len(words)-max_sequence_len
for i in range(0, range_size):
    thissentence=""
    for word in range(0, window_size-1):
        word = words[i+word]
        thissentence = thissentence + word
        thissentence = thissentence + " "
    sentences.append(thissentence)

在这种情况下,因为我们不再有句子,而是创建与移动窗口大小相同的序列,max_sequence_len就是窗口的大小。完整的文件被读取,转换为小写,并使用字符串分割分成一个单词数组。然后,代码循环遍历单词,并将每个从当前索引到当前索引加上窗口大小的单词构造句子,将每个新构造的句子添加到句子数组中。

在训练时,您会注意到额外的数据使每个时期变得更慢,但结果大大改善,并且生成的文本降入胡言乱语的速度明显减慢。

这是一个引起我注意的例子——尤其是最后一行!

  • 你什么都不知道,琼·雪已经离开

  • 和年轻人和玫瑰和广阔

  • 到我爱的地方我会玩

  • 咖哩的心

  • 墙壁上,我看到一个小小的整洁小镇

有许多超参数可以尝试调整。改变窗口大小将改变训练数据的数量——较小的窗口大小可以产生更多的数据,但给标签的词将更少,所以如果设置得太小,最终会得到毫无意义的诗歌。您还可以改变嵌入的维度、LSTM 的数量或用于训练的词汇量大小。考虑到百分比准确度并非最佳衡量标准——您需要对生成的诗歌“意义”的主观评估——没有硬性的规则可以确定您的模型是否“好”。

例如,当我尝试使用窗口大小为 6 时,将嵌入的维度增加到 16,将 LSTM 的数量从窗口大小(即 6)改为 32,并提高 Adam 优化器的学习率时,我得到了一个漂亮、平滑的学习曲线(图 8-10),并且一些诗歌开始变得更有意义了。

使用调整后的超参数的学习曲线

图 8-10. 使用调整后的超参数的学习曲线

当使用“甜脆的杰里米看到都柏林”作为种子(记住,种子中的所有单词都在语料库中),我得到了这首诗:

  • 甜脆的杰里米看到都柏林

  • 猛击,傻瓜

  • 所有看守来了

  • 如果你爱从凳子上站起来

  • 渴望去,我经过了我的年迈父亲

  • 如果你能访问新的罗斯

  • 英勇的话,我会做

  • 她的商品的力量

  • 和她的装备

  • 和她的卡利科衬衫

  • 她开始了那一个晚上

  • 清晨的雨

  • 在铁路和十字路口

  • 我走过我疲惫的道路

  • 穿越沼泽和高地

  • 我疲惫的双脚

  • 是好的天堂

尽管“whack fol”这个短语对许多读者来说可能毫无意义,但在一些爱尔兰歌曲中经常听到,有点像“la la la”或“doobie-doobie-doo”。我真正喜欢的是一些后来的短语保留了某种意义,比如“她的好和她的装备的力量,以及她的卡里古布衬衫”—但这可能是由于过度拟合到已存在于语料库中的短语。例如,从“oer railroad ties…”到“我疲惫的双脚”直接取自一首名为“Pontchartrain 湖”的歌曲,该歌曲包含在语料库中。如果您遇到此类问题,最好降低学习速率,可能减少 LSTM 的数量。但最重要的是,进行实验并享受乐趣!

基于字符的编码

在过去的几章中,我们一直在讨论使用基于单词的编码的 NLP。我发现这样更容易入门,但在生成文本时,您可能还想考虑基于字符的编码,因为语料库中独特字符的数量往往比独特的数量少得多。因此,您的输出层中可以有更少的神经元,并且您的输出预测分布在更少的概率上。例如,当查看莎士比亚全集的数据集时,您会看到整个集合中只有 65 个唯一的字符。因此,当您进行预测时,与爱尔兰歌曲数据集中的 2700 个单词的下一个单词的概率相比,您只需查看 65 个。这使得您的模型变得简单一些!

字符编码的另一个好处是包括了标点符号字符,因此可以预测换行等。例如,当我使用在莎士比亚语料库上训练的 RNN 来预测接下来跟随我最喜欢的权力的游戏台词的文本时,我得到了:

  • 伊格利特:

  • 你什么都不知道,乔恩·雪诺。

  • 晚安,我们将证明那些身体的仆人

  • 叛徒是这些我的:

  • 所以在这个 resceins 中希望在这个 diswarl 他的身体,

  • 我不能判断是否上诉。

  • 梅内尼乌斯:

  • 为什么,这是 pompetsion。

  • 理查德二世国王:

  • 我认为他让她想起了我的想法;

  • 她不会:忍受你的束缚:

  • 我祈祷,他会做到,

  • 我们将不会为你的爱而得名,你的结束

她将他视为叛徒并想要捆绑他(“diswarl his body”)的方式很酷,但我不知道“resceins”是什么意思!如果你看过这个节目,这是情节的一部分,所以也许莎士比亚在没有意识到的情况下表达了某种观点!

当然,我确实认为当我们使用像莎士比亚的文本作为我们的训练数据时,我们倾向于更加宽容,因为语言已经有些不熟悉。

与爱尔兰歌曲模型一样,输出很快就会变成毫无意义的文本,但玩起来仍然很有趣。要自己尝试一下,请查看Colab

摘要

在这一章中,我们探讨了如何使用训练过的基于 LSTM 的模型进行基本文本生成。你看到了如何将文本分割为训练特征和标签,使用单词作为标签,并创建一个模型,当给定种子文本时,可以预测下一个可能的单词。你通过迭代来改进模型以获得更好的结果,探索了传统爱尔兰歌曲的数据集。你还看到了如何通过使用莎士比亚文本的示例,可能通过基于字符的文本生成来改进这一过程。希望这是一个有趣的介绍,展示了机器学习模型如何合成文本!

第九章:理解序列和时间序列数据

时间序列无处不在。你可能在天气预报、股票价格以及像摩尔定律(图 9-1)这样的历史趋势中见过它们。如果你对摩尔定律不熟悉,它预测微芯片上的晶体管数量大约每两年翻一番。几乎 50 年来,它已被证明是计算能力和成本未来的准确预测者。

摩尔定律

图 9-1. 摩尔定律

时间序列数据是一组随时间间隔的值。当绘制时,x 轴通常是时间性质的。通常在时间轴上会绘制多个值,比如在这个例子中,晶体管数量是一个绘图,而来自摩尔定律的预测值是另一个。这被称为多变量时间序列。如果只有一个值,例如随时间变化的降雨量,那就称为单变量时间序列。

在摩尔定律的影响下,预测变得简单,因为存在一个固定且简单的规则,使我们能够粗略地预测未来——这一规则已经持续了大约 50 年。

那么像图 9-2 中的时间序列呢?

真实世界时间序列

图 9-2. 真实世界时间序列

虽然这个时间序列是人为创建的(稍后在本章中你将看到如何做到这一点),但它具有像股票图表或季节性降雨等复杂真实世界时间序列的所有属性。尽管看似随机,时间序列具有一些共同属性,这些属性对设计能够预测它们的机器学习模型非常有帮助,如下一节所述。

时间序列的共同属性

尽管时间序列可能看起来随机和嘈杂,通常存在可预测的共同属性。在本节中,我们将探讨其中一些。

趋势

时间序列通常沿特定方向移动。在摩尔定律的情况下,很容易看出随着时间推移,y 轴上的值增加,并且存在向上的趋势。在图 9-2 中的时间序列中也存在向上的趋势。当然,并非总是如此:有些时间序列可能随时间大致保持水平,尽管存在季节性变化,而其他时间序列则呈下降趋势。例如,在预测每个晶体管价格的反向版本中就是如此。

季节性

许多时间序列随时间的重复模式具有周期性,这些重复以称为季节的规则间隔发生。例如,天气中的温度。我们通常每年有四个季节,夏季温度最高。因此,如果你绘制几年的天气数据,你会看到每四个季节发生一次高峰,这给了我们季节性的概念。但这种现象不仅限于天气——例如,考虑图 9-3,这是一个显示网站流量的图表。

网站流量

图 9-3. 网站流量

它是逐周绘制的,您可以看到定期的低谷。您能猜到它们是什么吗?本例中的网站为软件开发人员提供信息,并且正如您所预期的那样,周末其流量较少!因此,这个时间序列每周有五天高峰和两天低谷的季节性。数据绘制了几个月,圣诞节和新年假期大致位于中间,所以您可以看到额外的季节性。如果我将其绘制多年,您将清楚地看到年底的额外低谷。

季节性在时间序列中表现出多种方式。例如,零售网站的流量可能在周末达到高峰。

自相关

在时间序列中可能会看到的另一个特征是事件后的可预测行为。您可以在图 9-4 中看到这一点,在那里有明显的峰值,但在每个峰值后,有确定性的衰减。这被称为自相关

在这种情况下,我们可以看到一组特定的行为,这些行为是重复的。自相关可能隐藏在时间序列模式中,但它们具有固有的可预测性,因此包含许多自相关的时间序列可能是可预测的。

自相关

图 9-4. 自相关

噪声

如其名称所示,噪声是时间序列中一组看似随机的扰动。这些扰动导致了高度的不可预测性,可以掩盖趋势、季节性行为和自相关。例如,图 9-5 展示了从图 9-4 中得到的同样的自相关,但添加了一些噪声。突然间,很难看出自相关并预测数值。

添加噪声的自相关序列

图 9-5. 添加噪声的自相关序列

鉴于所有这些因素,让我们探讨如何在包含这些属性的时间序列上进行预测。

预测时间序列的技术

在我们深入探讨基于机器学习的预测——接下来几章的主题之前——我们将探讨一些更为天真的预测方法。这些方法将使您能够建立一个可以用来衡量您的机器学习预测准确性的基线。

创建基线的天真预测

预测时间序列最基本的方法是说,在时间 t + 1 处的预测值与时间 t 的值相同,实质上将时间序列向前推移一个周期。

让我们从创建具有趋势、季节性和噪声的时间序列开始:

def plot_series(time, series, format="-", start=0, end=None):
    plt.plot(time[start:end], series[start:end], format)
    plt.xlabel("Time")
    plt.ylabel("Value")
    plt.grid(True)

def trend(time, slope=0):
    return slope * time

def seasonal_pattern(season_time):
    """Just an arbitrary pattern, you can change it if you wish"""
    return np.where(season_time < 0.4,
                    np.cos(season_time * 2 * np.pi),
                    1 / np.exp(3 * season_time))

def seasonality(time, period, amplitude=1, phase=0):
    """Repeats the same pattern at each period"""
    season_time = ((time + phase) % period) / period
    return amplitude * seasonal_pattern(season_time)

def noise(time, noise_level=1, seed=None):
    rnd = np.random.RandomState(seed)
    return rnd.randn(len(time)) * noise_level

time = np.arange(4 * 365 + 1, dtype="float32")
baseline = 10
series = trend(time, .05)  
baseline = 10
amplitude = 15
slope = 0.09
noise_level = 6

# Create the series
series = baseline + trend(time, slope) 
                  + seasonality(time, period=365, amplitude=amplitude)
# Update with noise
series += noise(time, noise_level, seed=42)

绘制后,您会看到类似图 9-6 的情况。

显示趋势、季节性和噪声的时间序列

图 9-6. 显示趋势、季节性和噪声的时间序列

现在您已经有了数据,您可以像任何数据源一样将其分割为训练集、验证集和测试集。当数据存在某种季节性时,正如在这种情况下所看到的,将系列拆分时,确保每个拆分中都有完整的季节是个好主意。因此,例如,如果您想将数据在图 9-6 中拆分为训练集和验证集,一个很好的分割点可能是在时间步 1,000 处,这样您就可以获得从步 1,000 开始的训练数据和步 1,000 之后的验证数据。

实际上在这里不需要分割数据,因为您只是进行了一个简单的天真预测,其中每个值t只是前一步t – 1 处的值。但是为了在接下来的几个图中进行说明,我们将放大从时间步 1,000 开始的数据。

要从分割的时间段开始预测系列,其中您希望分割的期间在变量split_time中,您可以使用以下代码:

naive_forecast = series[split_time - `1`:-`1`]

图 9-7 显示了验证集(从时间步 1,000 开始,可以通过将split_time设置为1000来获得)与天真预测的叠加。

时间序列的天真预测

图 9-7. 时间序列的天真预测

看起来还不错 —— 值之间存在一定的关系,并且随着时间的推移,预测似乎与原始值紧密匹配。但是如何衡量准确性呢?

测量预测准确性

有多种方法可以衡量预测准确性,但我们将集中在两种上:均方误差(MSE)和平均绝对误差(MAE)。

使用 MSE,您只需获取时间t处预测值与实际值之间的差值,平方(以去除负值),然后计算所有这些值的平均值。

对于 MAE,您计算预测值与时间t处的实际值之间的差值,取其绝对值以去除负值(而不是平方),然后计算所有这些值的平均值。

基于我们的合成时间序列创建的天真预测,您可以像这样获取 MSE 和 MAE:

`print`(keras.metrics.mean_squared_error(x_valid, naive_forecast).numpy())
`print`(keras.metrics.mean_absolute_error(x_valid, naive_forecast).numpy())

我得到了 MSE 为 76.47 和 MAE 为 6.89。与任何预测一样,如果能减少误差,就能提高预测的准确性。接下来我们将看看如何实现这一点。

不那么天真:使用移动平均线进行预测

先前的天真预测将时间t处的值取为时间t的预测值。使用移动平均线类似,但不只是取t – 1 处的值,而是取一组值(比如 30 个),求其平均值,并将其设置为时间t处的预测值。以下是代码:

  `def` moving_average_forecast(series, window_size):
  `"""Forecasts the mean of the last few values.`
 `If window_size=1, then this is equivalent to naive forecast"""`
  forecast = []
  `for` time `in` range(len(series) - window_size):
    forecast.append(series[time:time + window_size].mean())
  `return` np.array(forecast)

moving_avg = moving_average_forecast(series, `30`)[split_time - `30`:]

plt.figure(figsize=(`10`, `6`))
plot_series(time_valid, x_valid)
plot_series(time_valid, moving_avg)

图 9-8 显示了移动平均线与数据的图表。

绘制移动平均线

图 9-8. 绘制移动平均线

当我绘制这个时间序列时,得到的 MSE 和 MAE 分别为 49 和 5.5,因此预测明显有所改善。但这种方法没有考虑趋势或季节性,因此我们可能可以通过一些分析进一步改进。

改进移动平均分析

鉴于该时间序列的季节性为 365 天,您可以使用一种称为differencing的技术来平滑趋势和季节性,它只是从t时刻减去t - 365时刻的值。这将使图表变平。以下是代码:

diff_series = (series[`365`:] - series[:-`365`])
diff_time = time[`365`:]

您现在可以计算这些值的移动平均,并添加回过去的值:

diff_moving_avg = 
    moving_average_forecast(diff_series, `50`)[split_time - `365` - `50`:]

diff_moving_avg_plus_smooth_past = 
    moving_average_forecast(series[split_time - `370`:-`360`], `10`) + 
    diff_moving_avg

当您绘制图表时(参见图 9-9),您已经可以看到预测值有所改善:趋势线非常接近实际值,尽管噪音已经平滑化。季节性似乎有效,趋势也是如此。

改进的移动平均

图 9-9. 改进的移动平均

通过计算均方误差(MSE)和平均绝对误差(MAE)来确认这一印象,在本例中分别为 40.9 和 5.13,显示了预测结果的明显改进。

总结

本章介绍了时间序列数据及其常见属性。您创建了一个合成时间序列,并学习了如何开始进行简单的预测。从这些预测中,您使用均方误差和平均绝对误差建立了基准度量。这是从 TensorFlow 中的一个不错的转换,但在下一章中,您将回到使用 TensorFlow 和机器学习,看看能否进一步改进您的预测!

第十章:创建用于预测序列的 ML 模型

第九章介绍了序列数据和时间序列的属性,包括季节性、趋势、自相关性和噪声。您创建了一个合成序列用于预测,并探索了如何进行基本的统计预测。在接下来的几章中,您将学习如何使用 ML 进行预测。但在开始创建模型之前,您需要了解如何为训练预测模型结构化时间序列数据,这将创建我们称之为窗口数据集的内容。

要理解为什么需要这样做,请考虑您在第九章中创建的时间序列。您可以在图 10-1 中看到其图表。

合成时间序列

图 10-1. 合成时间序列

如果您想在时间t预测某个值,您将希望将其预测为时间t之前值的函数。例如,假设您希望预测时间步骤 1,200 的时间序列值,作为前 30 个时间步骤的函数。在这种情况下,从时间步骤 1,170 到 1,199 的值将确定时间步骤 1,200 的值,如图 10-2 所示。

前值影响预测

图 10-2. 前值影响预测

现在开始看起来很熟悉:您可以将从 1,170 到 1,199 的值视为特征,并将 1,200 处的值视为标签。如果您可以使数据集的一定数量的值成为特征,并使后续的值成为标签,并且对数据集中的每个已知值执行此操作,那么您将获得一组非常不错的特征和标签,可用于训练模型。

在为来自第九章的时间序列数据集做这些操作之前,让我们创建一个非常简单的数据集,具有相同的属性,但数据量要小得多。

创建窗口数据集

tf.data库包含许多用于数据操作的有用 API。您可以使用这些 API 创建一个基本数据集,其中包含 0 到 9 的数字,模拟一个时间序列。然后,您将把它转换为窗口数据集的开端。以下是代码:

`dataset` `=` `tf``.``data``.``Dataset``.``range``(`10`)`
`dataset` `=` `dataset``.``window``(`5`,` `shift``=`1`,` `drop_remainder``=``True``)`
`dataset` `=` `dataset``.``flat_map``(``lambda` `window``:` `window``.``batch``(`5`)``)`
`for` `window` `in` `dataset``:`
  `print``(``window``.``numpy``(``)``)`

首先,它使用一个范围创建数据集,这简单地使数据集包含值 0 到n – 1,其中n在本例中为 10。

接下来,调用dataset.window并传递一个参数5,指定将数据集分割成五个项目的窗口。设置shift=1会导致每个窗口向前移动一个位置:第一个窗口将包含从 0 开始的五个项目,下一个窗口将包含从 1 开始的五个项目,依此类推。将drop_remainder设置为True指定,一旦它接近数据集末尾并且窗口小于所需的五个项目,它们应该被丢弃。

给定窗口定义,可以进行数据集分割的过程。您可以使用flat_map函数来完成这个过程,在本例中请求一个包含五个窗口的批次。

运行这段代码将得到以下结果:

[`0` `1` `2` `3` `4`]
[`1` `2` `3` `4` `5`]
[`2` `3` `4` `5` `6`]
[`3` `4` `5` `6` `7`]
[`4` `5` `6` `7` `8`]
[`5` `6` `7` `8` `9`]

但是之前您看到,我们希望从中创建训练数据,其中有n个值定义一个特征,并且后续的值提供一个标签。您可以通过添加另一个 lambda 函数来完成这个操作,该函数将每个窗口分割为最后一个值之前的所有内容,然后是最后一个值。这会生成一个x和一个y数据集,如下所示:

dataset = tf.data.Dataset.range(10)
dataset = dataset.window(5, shift=1, drop_remainder=True)
dataset = dataset.flat_map(lambda window: window.batch(5))
`dataset` `=` `dataset``.``map``(``lambda` `window``:` `(``window``[``:``-``1``]``,` `window``[``-``1``:``]``)``)`
for x,y in dataset:
  print(x.numpy(), y.numpy())

现在结果与您期望的一致。窗口中的前四个值可以被视为特征,后续的值是标签:

[`0` `1` `2` `3`] [`4`]
[`1` `2` `3` `4`] [`5`]
[`2` `3` `4` `5`] [`6`]
[`3` `4` `5` `6`] [`7`]
[`4` `5` `6` `7`] [`8`]
[`5` `6` `7` `8`] [`9`]

而且因为这是一个数据集,它也可以通过 lambda 函数支持洗牌和分批处理。在这里,它已经被洗牌和批处理,批处理大小为 2:

dataset = tf.data.Dataset.range(10)
dataset = dataset.window(5, shift=1, drop_remainder=True)
dataset = dataset.flat_map(lambda window: window.batch(5))
dataset = dataset.map(lambda window: (window[:-1], window[-1:]))
`dataset` `=` `dataset``.``shuffle``(``buffer_size``=``10``)`
`dataset` `=` `dataset``.``batch``(``2``)``.``prefetch``(``1``)`
for x,y in dataset:
  print("x = ", x.numpy())
  print("y = ", y.numpy())

结果显示,第一个批次有两组x(分别从 2 和 3 开始)及其标签,第二个批次有两组x(分别从 1 和 5 开始)及其标签,依此类推:

x =  [[`2` `3` `4` `5`]
 [`3` `4` `5` `6`]]
y =  [[`6`]
 [`7`]]

x =  [[`1` `2` `3` `4`]
 [`5` `6` `7` `8`]]
y =  [[`5`]
 [`9`]]

x =  [[`0` `1` `2` `3`]
 [`4` `5` `6` `7`]]
y =  [[`4`]
 [`8`]]

使用这种技术,您现在可以将任何时间序列数据集转换为神经网络的训练数据集。在下一节中,您将探讨如何从第九章的合成数据中创建训练集。从那里,您将继续创建一个简单的 DNN,该网络经过训练可以用于预测未来的值。

创建时间序列数据集的窗口化版本

回顾一下,在上一章中使用的代码来创建一个合成的时间序列数据集:

`def` trend(time, slope=`0`):
    `return` slope * time

`def` seasonal_pattern(season_time):
    `return` np.where(season_time < `0.4`,
                    np.cos(season_time * `2` * np.pi),
                    `1` / np.exp(`3` * season_time))

`def` seasonality(time, period, amplitude=`1`, phase=`0`):
    season_time = ((time + phase) % period) / period
    `return` amplitude * seasonal_pattern(season_time)

`def` noise(time, noise_level=`1`, seed=`None`):
    rnd = np.random.`RandomState`(seed)
    `return` rnd.randn(len(time)) * noise_level

time = np.arange(`4` * `365` + `1`, dtype=`"``float32``"`)
series = trend(time, `0.1`)
baseline = `10`
amplitude = `20`
slope = `0.09`
noise_level = `5`

series = baseline + trend(time, slope) 
series += seasonality(time, period=`365`, amplitude=amplitude)
series += noise(time, noise_level, seed=`42`)

这将创建一个类似于图 10-1 的时间序列。如果您想要进行更改,请随意调整各种常量的值。

一旦您有了这个系列,您可以像前一节中的代码一样将其转换为窗口化的数据集。这里定义为一个独立的函数:

`def` windowed_dataset(series, window_size, 
                      batch_size, shuffle_buffer):
  dataset = tf.data.`Dataset`.from_tensor_slices(series)
  dataset = dataset.window(window_size + `1`, shift=`1`, 
                            drop_remainder=`True`)
  dataset = dataset.flat_map(`lambda` window: 
                               window.batch(window_size + `1`))
  dataset = dataset.shuffle(shuffle_buffer).map(
                             `lambda` window: 
                               (window[:-`1`], window[-`1`]))
  dataset = dataset.batch(batch_size).prefetch(`1`)
  `return` dataset

请注意,它使用了tf.data.Datasetfrom_tensor_slices方法,该方法允许您将一个系列转换为Dataset。您可以在TensorFlow 文档中了解更多关于这个方法的信息。

现在,要获取一个可用于训练的数据集,您可以简单地使用以下代码。首先,将系列分为训练集和验证集,然后指定细节,如窗口大小、批量大小和洗牌缓冲区大小:

split_time = `1000`
time_train = time[:split_time]
x_train = series[:split_time]
time_valid = time[split_time:]
x_valid = series[split_time:]
window_size = `20`
batch_size = `32`
shuffle_buffer_size = `1000`
dataset = windowed_dataset(x_train, window_size, batch_size, 
                           shuffle_buffer_size)

现在要记住的重要一点是,你的数据是一个tf.data.Dataset,因此可以轻松地将其作为单个参数传递给model.fittf.keras会照顾其余的工作。

如果你想查看数据的样子,可以用这样的代码来做:

dataset = windowed_dataset(series, window_size, `1`, shuffle_buffer_size)
`for` feature, label `in` dataset.take(`1`):
  `print`(feature)
  `print`(label)

这里将batch_size设置为1,只是为了使结果更易读。你将得到类似这样的输出,其中一个数据集在批次中:

`tf``.`Tensor`(`
`[``[``75.38214`  `66.902626`  `76.656364`  `71.96795`  `71.373764`  `76.881065`  `75.62607`
  `71.67851`  `79.358665`  `68.235466`  `76.79933`  `76.764114`  `72.32991`  `75.58744`
  `67.780426`  `78.73544`  `73.270195`  `71.66057`  `79.59881`  `70.9117` `]``]``,` 
  `shape``=``(``1``,` `20``)``,` `dtype``=``float32``)`
`tf``.`Tensor`(``[``67.47085``]``,` `shape``=``(``1``,``)``,` `dtype``=``float32``)`

第一批数字是特征。我们将窗口大小设置为 20,因此这是一个 1 × 20 的张量。第二个数字是标签(在这种情况下为 67.47085),模型将尝试将特征拟合到标签。你将在下一节看到它是如何工作的。

创建和训练一个 DNN 来拟合序列数据

现在,你已经有了一个tf.data.Dataset中的数据,使用tf.keras创建神经网络模型变得非常简单。让我们首先探索一个看起来像这样的简单 DNN:

dataset = windowed_dataset(series, window_size, 
                            batch_size, shuffle_buffer_size)

model = tf.keras.models.`Sequential`([
    tf.keras.layers.`Dense`(`10`, input_shape=[window_size], 
                           activation=`"``relu``"`), 
    tf.keras.layers.`Dense`(`10`, activation=`"``relu``"`), 
    tf.keras.layers.`Dense`(`1`)
])

这是一个超级简单的模型,有两个稠密层,第一个接受window_size的输入形状,然后是一个包含预测值的输出层。

该模型使用了与之前相同的损失函数和优化器。在这种情况下,损失函数被指定为mse,代表均方误差,通常用于回归问题(最终就是这种问题!)。对于优化器,sgd(随机梯度下降)是一个很好的选择。我不会在这本书中详细讨论这些函数类型,但是任何关于机器学习的良好资源都会教你它们——Andrew Ng 在 Coursera 的开创性 深度学习专项课程 就是一个很好的起点。SGD 有学习率(lr)和动量的参数,它们调整优化器的学习方式。每个数据集都不同,所以控制是很重要的。在下一节中,你将看到如何确定最佳值,但是现在,只需像这样设置它们:

model.compile(loss=`"``mse``"`,optimizer=tf.keras.optimizers.SGD(
                                                          lr=`1e-6`, 
                                                          momentum=`0.9`))

训练过程只需调用model.fit,将数据集传递给它,并指定训练的周期数即可:

model.fit(dataset,epochs=`100`,verbose=`1`)

在训练过程中,你会看到损失函数报告一个起初很高但会稳步下降的数字。这是前 10 个周期的结果:

Epoch 1/100
45/45 [==============================] - 1s 15ms/step - loss: 898.6162
Epoch 2/100
45/45 [==============================] - 0s 8ms/step - loss: 52.9352
Epoch 3/100
45/45 [==============================] - 0s 8ms/step - loss: 49.9154
Epoch 4/100
45/45 [==============================] - 0s 7ms/step - loss: 49.8471
Epoch 5/100
45/45 [==============================] - 0s 7ms/step - loss: 48.9934
Epoch 6/100
45/45 [==============================] - 0s 7ms/step - loss: 49.7624
Epoch 7/100
45/45 [==============================] - 0s 8ms/step - loss: 48.3613
Epoch 8/100
45/45 [==============================] - 0s 9ms/step - loss: 49.8874
Epoch 9/100
45/45 [==============================] - 0s 8ms/step - loss: 47.1426
Epoch 10/100
45/45 [==============================] - 0s 8ms/step - loss: 47.5133

评估 DNN 的结果

一旦你有了训练好的 DNN,你可以开始用它进行预测。但要记住,你有一个窗口化的数据集,因此,对于给定时间点的预测是基于它之前的若干时间步的值。

换句话说,由于你的数据是一个名为series的列表,要预测一个值,你必须将模型值从时间 t 到时间 t + window_size 传递给它。然后它会给你预测的下一个时间步的值。

例如,如果你想预测时间步 1,020 的值,你需要从时间步 1,000 到 1,019 的值,并用它们来预测序列中的下一个值。要获取这些值,你可以使用以下代码(注意,你要指定为series[1000:1020],而不是series[1000:1019]!):

`print`(series[`1000`:`1020`])

然后,要获取步骤 1,020 的值,你只需像这样使用 series[1020]

`print`(series[`1020`])

要获取该数据点的预测值,然后将系列传递给 model.predict。然而,请注意,为了保持输入形状一致,你需要 [np.newaxis],像这样:

`print`(model.predict(series[`1000`:`1020`][np.newaxis]))

或者,如果你想要更通用的代码,你可以使用这个:

`print`(series[start_point:start_point+window_size])
`print`(series[start_point+window_size])
`print`(model.predict(
      series[start_point:start_point+window_size][np.newaxis]))

请注意,这一切都假设窗口大小为 20 个数据点,这是相当小的。因此,你的模型可能会缺乏一些准确性。如果你想尝试不同的窗口大小,你需要再次调用 windowed_dataset 函数重新格式化数据集,然后重新训练模型。

这是在从 1,000 开始并预测下一个值时该数据集的输出:

`[`109.170746  106.86935  102.61668  99.15634  105.95478  104.503876
  107.08533  105.858284  108.00339  100.15279  109.4894  103.96404
  113.426094  99.67773  111.87749  104.26137  100.08899  101.00105
  101.893265  105.69048 `]`

106.258606
 `[``[`105.36248`]``]`

第一个张量包含值列表。接下来,我们看到 实际 的下一个值为 106.258606。最后,我们看到 预测 的下一个值为 105.36248。我们得到了一个合理的预测,但如何测量随时间的准确性?我们将在下一节中探讨这个问题。

探索总体预测

在前面的部分中,你看到了如何通过采用窗口大小(在本例中为 20)的先前一组值并将它们传递给模型来获取特定时间点的预测值。要查看模型的整体结果,你将不得不对每个时间步骤做同样的事情。

你可以像这样使用一个简单的循环来完成:

forecast = []
for time in range(len(series) - window_size):
  forecast.append(
    model.predict(series[time:time + window_size][np.newaxis]))

首先,你创建一个名为 forecast 的新数组,用于存储预测值。然后,对于原始系列中的每个时间步长,你调用 predict 方法并将结果存储在 forecast 数组中。对于数据的前 n 个元素,你无法这样做,其中 nwindow_size,因为在那时你没有足够的数据来进行预测,因为每次预测都需要前 n 个先前的值。

当这个循环结束时,forecast 数组将包含从时间步长 21 开始的预测值。

如果你回想一下,你还将数据集在时间步骤 1,000 处分成了训练集和验证集。因此,对于接下来的步骤,你也应该只取从此时间点开始的预测。由于你的预测数据已经错位了 20 个(或者你的窗口大小是多少),你可以将其拆分并将其转换为一个 Numpy 数组,像这样:

forecast = forecast[split_time-window_size:]
results = np.array(forecast)[:, `0`, `0`]

现在它与预测数据的形状相同,所以你可以像这样将它们相互绘制:

plt.figure(figsize=(`10`, `6`))

plot_series(time_valid, x_valid)
plot_series(time_valid, results)

绘图看起来会像 图 10-3。

绘制预测值对比图

图 10-3. 绘制预测值对比图

从快速的视觉检查中,你可以看到预测并不差。它通常会跟随原始数据的曲线。当数据发生快速变化时,预测需要一些时间来赶上,但总体上并不差。

然而,仅凭肉眼观察曲线很难准确。最好有一个良好的度量标准,在第九章中,你学到了一个——MAE(平均绝对误差)。现在,你已经有了有效的数据和结果,可以使用以下代码来测量 MAE:

tf.keras.metrics.mean_absolute_error(x_valid, results).numpy()

数据中引入了随机性,因此你的结果可能会有所不同,但当我尝试时,得到了 MAE 值为 4.51。

你可以说,尽可能准确地获取预测结果,然后将其最小化成为最小化 MAE 的过程。有一些技术可以帮助你做到这一点,包括明显的更改窗口大小。我让你去尝试一下,但在接下来的章节中,你将对优化器进行基本的超参数调整,以改善神经网络的学习效果,并了解这对 MAE 的影响。

调整学习率

在前面的例子中,你可能还记得,你使用了一个如下所示的优化器来编译模型:

model.compile(loss=`"``mse``"`,
              optimizer=tf.keras.optimizers.SGD(lr=`1e-6`, momentum=`0.9`))

在这种情况下,你使用了一个学习率为 1 × 10^(–6)。但这似乎是一个非常随意的数字。如果你改变它会怎样?你应该如何去改变它?需要大量的实验来找到最佳的学习率。

tf.keras 为你提供的一项功能是一个回调函数,帮助你随着时间调整学习率。你在第二章早些时候学习过回调函数——在那里,你使用一个回调函数在每个 epoch 结束时取消训练,当准确率达到预期值时。

你也可以使用回调函数来调整学习率参数,将该参数的值与适当 epoch 的损失绘制在一起,从而确定最佳的学习率使用。

要做到这一点,只需创建一个 tf.keras.callbacks.LearningRateScheduler,并让它使用所需的起始值填充 lr 参数。以下是一个例子:

`lr_schedule` `=` `tf``.``keras``.``callbacks``.`LearningRateScheduler`(`
  `lambda` `epoch``:` `1e-8` `*` `10``*``*``(``epoch` `/` `20``)``)`

在这种情况下,你将从 1e–8 开始学习率,并在每个 epoch 增加一个小量。到完成一百个 epochs 时,学习率将增加到约 1e–3。

现在,你可以使用学习率为 1e–8 初始化优化器,并指定在 model.fit 调用中使用此回调函数:

optimizer = tf.keras.optimizers.SGD(lr=1e-8, momentum=0.9)
model.compile(loss="mse", optimizer=optimizer)
 history = model.fit(dataset, epochs=100, 
                    **`callbacks=[lr_schedule]`**, verbose=0)

正如你使用了 history=model.fit,训练历史已经为你存储好了,包括损失。你可以像这样将其与每个 epoch 的学习率绘制在一起:

lrs = `1e-8` * (`10` ** (np.arange(`100`) / `20`))
plt.semilogx(lrs, history.history[`"``loss``"`])
plt.axis([`1e-8`, `1e-3`, `0`, `300`])

这只是使用与 lambda 函数相同的公式设置 lrs 值,并在 1e–8 到 1e–3 之间绘制其与损失的关系。图 10-4 展示了结果。

绘制损失与学习率的关系

图 10-4. 绘制损失与学习率的关系

因此,虽然之前你将学习率设置为 1e–6,但看起来 1e–5 的损失更小,所以现在你可以回到模型中,并用 1e–5 重新定义它作为新的学习率。

在训练模型之后,您可能会注意到损失有所减少。在我的情况下,学习率为 1e-6 时,最终损失为 36.5,但学习率为 1e-5 时,损失降至 32.9。然而,当我对所有数据运行预测时,结果是图表在图 10-5 中,可以看到看起来有点偏差。

调整后的学习率图表

图 10-5. 调整后的学习率图表

当我测量 MAE 时,结果为 4.96,所以它略有退步!话虽如此,一旦您确定了最佳学习率,您可以开始探索其他优化网络性能的方法。一个简单的起点是窗口的大小——预测 1 天的 20 天数据可能不足够,所以您可能希望尝试 40 天的窗口。另外,尝试增加训练的 epochs 数。通过一些实验,您可能可以将 MAE 降到接近 4,这还算不错。

使用 Keras 调参工具进行超参数调优

在上一节中,您看到了如何为随机梯度下降损失函数进行粗略的学习率优化。这确实是一个非常粗糙的尝试,每隔几个 epochs 更改一次学习率并测量损失。它也受到损失函数每个 epoch 之间已经改变的影响,因此您可能实际上并没有找到最佳值,而是一个近似值。要真正找到最佳值,您需要对每个潜在值进行完整 epochs 的训练,然后比较结果。这仅仅是一个超参数——学习率。如果您想找到最佳的动量或调整其他事物,如模型架构——每层多少个神经元,多少层等等——您可能需要测试成千上万个选项,并且跨所有这些进行训练将是很难编码的。

幸运的是,Keras 调参工具使这相对容易。您可以使用简单的pip命令安装 Keras 调参工具:

!pip install keras-tuner

然后,您可以使用它来参数化您的超参数,指定要测试的值范围。Keras 调参器将训练多个模型,每个可能的参数集合一个,评估模型到您想要的指标,然后报告排名靠前的模型。我不会在这里详细介绍工具提供的所有选项,但我会展示如何为这个特定模型使用它。

假设我们想要尝试两件事,第一件事是模型架构中输入神经元的数量。一直以来,您的模型架构是 10 个输入神经元,后面跟着一个 10 个神经元的隐藏层,然后是输出层。但是如果增加更多的神经元在输入层,例如 30 个,网络能否表现更好呢?

回想一下,输入层的定义如下:

tf.keras.layers.`Dense`(`10`, input_shape=[window_size], activation=`"``relu``"`),

如果您想测试不同于硬编码的 10 的值,您可以设置它循环遍历一些整数,就像这样:

tf.keras.layers.Dense(units=hp.Int('units', min_value=10, max_value=30, step=2), 
                      activation='relu', input_shape=[window_size])

在这里,您定义该层将使用多个输入值进行测试,从 10 开始,以步长 2 增加到 30。现在,不再是仅训练一次模型并查看损失,Keras Tuner 将训练该模型 11 次!

此外,在编译模型时,您将momentum参数的值硬编码为0.9。请回顾一下模型定义中的此代码:

optimizer = tf.keras.optimizers.SGD(lr=`1e-5`, momentum=`0.9`)

您可以通过使用hp.Choice结构将此更改为循环浏览几个选项。以下是一个示例:

optimizer=tf.keras.optimizers.SGD(hp.`Choice`(`'``momentum``'`, 
                                  values=[.`9`, .`7`, .`5`, .`3`]), 
                                  lr=`1e-5`)

这提供了四种可能的选择,因此,当与先前定义的模型架构结合使用时,您将循环浏览 44 种可能的组合。Keras Tuner 可以为您完成这些操作,并报告表现最佳的模型。

要完成设置,请首先创建一个为您构建模型的函数。以下是更新后的模型定义:

`def` build_model(hp):
  model = tf.keras.models.`Sequential`()
  model.add(tf.keras.layers.`Dense`(
     units=hp.`Int`(`'``units``'`, min_value=`10`, max_value=`30`, step=`2`), 
                 activation=`'``relu``'`, input_shape=[window_size]))
  model.add(tf.keras.layers.`Dense`(`10`, activation=`'``relu``'`))
  model.add(tf.keras.layers.`Dense`(`1`))

  model.compile(loss=`"``mse``"`, 
                optimizer=tf.keras.optimizers.SGD(hp.`Choice`(`'``momentum``'`, 
                                               values=[.`9`, .`7`, .`5`, .`3`]), 
                                               lr=`1e-5`))

  `return` model

现在,安装了 Keras Tuner 后,您可以创建一个RandomSearch对象来管理该模型的所有迭代:

tuner = `RandomSearch`(build_model, 
                     objective=`'``loss``'`, max_trials=`150`, 
                     executions_per_trial=`3`, directory=`'``my_dir``'`, 
                     project_name=`'``hello``'`)

请注意,您通过传递先前描述的函数来定义模型。超参数参数(hp)用于控制哪些值会发生变化。您指定objectiveloss,表示您希望最小化损失。您可以使用max_trials参数限制要运行的试验总数,并使用executions_per_trial参数指定要训练和评估模型的次数(从而在某种程度上消除随机波动)。

要启动搜索,只需调用tuner.search,就像调用model.fit一样。以下是代码:

tuner.search(dataset, epochs=`100`, verbose=`0`)

使用本章中您一直在工作的合成系列运行此操作,然后将会根据您定义的选项尝试所有可能的超参数来训练模型。

当完成时,您可以调用tuner.results_summary,它将显示基于目标的前 10 个试验结果:

tuner.results_summary()

您应该会看到像这样的输出:

`Results` summary
|-`Results` `in` my_dir/hello
|-`Showing` `10` best trials
|-`Objective`(name=`'``loss``'`, direction=`'``min``'`)
`Trial` summary
|-`Trial` ID: dcfd832e62daf4d34b729c546120fb14
|-`Score`: `33.18723194615371`
|-`Best` step: `0`
`Hyperparameters`:
|-momentum: `0.5`
|-units: `28`
`Trial` summary
|-`Trial` ID: `02``ca5958ac043f6be8b2e2b5479d1f09`
|-`Score`: `33.83273440510237`
|-`Best` step: `0`
`Hyperparameters`:
|-momentum: `0.7`
|-units: `28`

从结果中,您可以看到最佳损失得分是在动量为 0.5 和输入单元为 28 时实现的。您可以通过调用get_best_models来检索此模型和其他前几个模型,并指定您想要的数量——例如,如果您想要前四个模型,可以这样调用:

tuner.get_best_models(num_models=`4`)

然后,您可以测试这些模型。

或者,您可以使用学到的超参数从头开始创建一个新模型,就像这样:

dataset = windowed_dataset(x_train, window_size, batch_size, 
                           shuffle_buffer_size)

model = tf.keras.models.`Sequential`([
    tf.keras.layers.`Dense`(`28`, input_shape=[window_size], 
                           activation=`"``relu``"`), 
    tf.keras.layers.`Dense`(`10`, activation=`"``relu``"`), 
    tf.keras.layers.`Dense`(`1`)
])

optimizer = tf.keras.optimizers.SGD(lr=`1e-5`, momentum=`0.5`)
model.compile(loss=`"``mse``"`, optimizer=optimizer)
history = model.fit(dataset, epochs=`100`,  verbose=`1`)

当使用这些超参数进行训练并对整个验证集进行预测时,我得到了一个类似于图 10-6 的图表。

使用优化后的超参数的预测图表

图 10-6. 使用优化后的超参数的预测图表

在这次计算中,平均绝对误差(MAE)为 4.47,比原始结果 4.51 略有改善,并且比上一章节的统计方法(结果为 5.13)有了很大的提升。这是通过将学习速率改为 1e-5 实现的,这可能并不是最佳选择。使用 Keras 调参器,你可以调整类似这样的超参数,调整中间层的神经元数量,甚至尝试不同的损失函数和优化器。试一试,看看是否能改进这个模型!

摘要

在本章中,你对时间序列的统计分析进行了处理(来自第九章),并应用机器学习试图做出更好的预测。机器学习真正关键在于模式匹配,正如预期的那样,通过首先使用深度神经网络来识别模式,然后使用 Keras 调参器调整超参数来改进损失并提高准确性,你成功将平均绝对误差降低了近 10%。在第十一章,你将超越简单的深度神经网络,探索使用递归神经网络预测序列值的影响。

第十一章:使用序列模型的卷积和递归方法

最近的几章介绍了序列数据。您看到了如何首先使用统计方法,然后是基本的机器学习方法和深度神经网络来预测它。您还探索了如何使用 Keras 调谐器调整模型的超参数。在本章中,您将看到可能进一步增强您使用卷积神经网络和递归神经网络预测序列数据能力的其他技术。

序列数据的卷积

在第三章中,您介绍了卷积,其中 2D 滤波器通过图像以修改它并可能提取特征。随着时间的推移,神经网络学会了哪些滤波器值有效匹配修改后的像素到它们的标签,有效地从图像中提取特征。相同的技术可以应用于数值时间序列数据,但有一个修改:卷积将是一维而不是二维。

例如,考虑图 11-1 中的数字序列。

一系列数字

图 11-1. 一系列数字

1D 卷积可以如下操作。考虑卷积是一个 1 × 3 的滤波器,滤波器值分别为-0.5, 1 和 -0.5。在这种情况下,序列中的第一个值将丢失,第二个值将从 8 转换为-1.5,如图 11-2 所示。

使用数字序列的卷积

图 11-2. 使用数字序列的卷积

滤波器将跨值进行步幅,计算新值。例如,在下一个步幅中,15 将被转换为 3,如图 11-3 所示。

1D 卷积中的额外步幅

图 11-3. 1D 卷积中的额外步幅

使用这种方法,可以提取数值之间的模式并学习成功提取它们的过滤器,这与图像中的卷积在像素上提取特征的方式非常相似。在这种情况下没有标签,但可以学习最小化总体损失的卷积。

编写卷积

在编写卷积之前,您需要调整在前一章中使用的窗口数据集生成器。这是因为在编写卷积层时,您需要指定维度。窗口数据集是单维度的,但未定义为 1D 张量。这只需在windowed_dataset函数的开头添加一个tf.expand_dims语句,如下所示:

def windowed_dataset(series, window_size, batch_size, shuffle_buffer):
 `series` `=` `tf``.``expand_dims``(``series``,` `axis``=``-``1``)`
  dataset = tf.data.Dataset.from_tensor_slices(series)
  dataset = dataset.window(window_size + 1, shift=1, drop_remainder=True)
  dataset = dataset.flat_map(lambda window: window.batch(window_size + 1))
  dataset = dataset.shuffle(shuffle_buffer).map(
                 lambda window: (window[:-1], window[-1]))
  dataset = dataset.batch(batch_size).prefetch(1)
  return dataset

现在您有了修改后的数据集,可以在之前的密集层之前添加一个卷积层:

dataset = windowed_dataset(x_train, window_size, batch_size, shuffle_buffer_size)

model = tf.keras.models.`Sequential`([
    tf.keras.layers.`Conv1D`(filters=`128`, kernel_size=`3`,
                           strides=`1`, padding=`"``causal``"`,
                           activation=`"``relu``"`,
                           input_shape=[`None`, `1`]),
    tf.keras.layers.`Dense`(`28`, activation=`"``relu``"`), 
    tf.keras.layers.`Dense`(`10`, activation=`"``relu``"`), 
    tf.keras.layers.`Dense`(`1`),
])

optimizer = tf.keras.optimizers.SGD(lr=`1e-5`, momentum=`0.5`)
model.compile(loss=`"``mse``"`, optimizer=optimizer)
history = model.fit(dataset, epochs=`100`, verbose=`1`)

Conv1D层中,您有许多参数:

filters

是您希望该层学习的滤波器数量。它会生成这些数量,并随着学习过程调整以适应数据。

kernel_size

是滤波器的大小 —— 之前我们演示了一个具有值 -0.5、1、-0.5 的滤波器,这将是一个大小为 3 的内核尺寸。

strides

这是滤波器在扫描列表时采取的“步长”。通常为 1。

padding

决定了关于列表的行为,关于从哪一端丢弃数据。一个 3×1 的滤波器将“丢失”列表的第一个和最后一个值,因为它无法计算第一个的先前值或最后一个的后续值。通常在序列数据中,您会在这里使用causal,它只会从当前和前两个时间步中获取数据,永远不会从未来获取。因此,例如,一个 3×1 的滤波器将使用当前时间步和前两个时间步的数据。

activation

是激活函数。在这种情况下,relu 意味着有效地拒绝来自层的负值。

input_shape

一如既往,这是数据输入网络的形状。作为第一层,您必须指定它。

使用此进行训练将会得到一个与之前相同的模型,但是为了从模型中获得预测,由于输入层已经改变了形状,您需要适当修改您的预测代码。

此外,与其基于先前窗口逐个预测每个值,您实际上可以获得整个系列的单个预测,如果您正确将系列格式化为数据集的话。为了简化事情,这里有一个辅助函数,它可以基于模型预测整个系列,指定窗口大小:

`def` model_forecast(model, series, window_size):
    ds = tf.data.`Dataset`.from_tensor_slices(series)
    ds = ds.window(window_size, shift=`1`, drop_remainder=`True`)
    ds = ds.flat_map(`lambda` w: w.batch(window_size))
    ds = ds.batch(`32`).prefetch(`1`)
    forecast = model.predict(ds)
    `return` forecast

如果您想使用模型来预测此系列,您只需将系列传递进去,并添加一个新的轴来处理需要额外轴的层。您可以这样做:

forecast = model_forecast(model, series[..., np.newaxis], window_size)

并且您可以使用预先确定的分割时间仅将此预测拆分为验证集的预测:

results = forecast[split_time - window_size:-`1`, -`1`, `0`]

结果与系列的绘图见图 11-4。

在这种情况下,MAE 是 4.89,稍微比之前的预测差一些。这可能是因为我们没有适当调整卷积层,或者简单地说,卷积并没有帮助。这是您需要对您的数据进行的实验类型。

请注意,此数据具有随机元素,因此数值会在不同会话之间发生变化。如果您使用来自第十章的代码,然后单独运行此代码,当然会有随机波动影响您的数据和平均绝对误差。

卷积神经网络与时间序列数据预测

图 11-4. 卷积神经网络与时间序列数据预测

但是在使用卷积时,总是会有一个问题:为什么选择我们选择的参数?为什么是 128 个滤波器?为什么是大小为 3×1 的内核?好消息是,你可以使用Keras 调优器进行实验,如之前所示。接下来我们将探索这一点。

尝试 Conv1D 超参数

在前面的部分中,你看到了一个硬编码了参数的 1D 卷积,比如滤波器数量、内核大小、步幅数等。当用它训练神经网络时,似乎 MAE 稍微上升,所以我们没有从 Conv1D 中获得任何好处。这在你的数据中可能并非总是如此,但这可能是由于次优的超参数。因此,在本节中,你将看到 Keras 调优器如何为你优化它们。

在这个例子中,你将尝试调整滤波器数量、内核大小和步幅大小的超参数,保持其他参数不变:

def build_model(hp):
    model = tf.keras.models.Sequential()
    model.add(tf.keras.layers.Conv1D(
        `filters``=``hp``.``Int``(``'``units``'``,``min_value``=``128``,` `max_value``=``256``,` `step``=``64``)`, 
 `kernel_size``=``hp``.``Int``(``'``kernels``'``,` `min_value``=``3``,` `max_value``=``9``,` `step``=``3``)`,
 `strides``=``hp``.``Int``(``'``strides``'``,` `min_value``=``1``,` `max_value``=``3``,` `step``=``1``)``,`
        padding='causal', activation='relu', input_shape=[None, 1]
    ))

    model.add(tf.keras.layers.Dense(28, input_shape=[window_size], 
                                    activation='relu'))

    model.add(tf.keras.layers.Dense(10, activation='relu'))

    model.add(tf.keras.layers.Dense(1))

    model.compile(loss="mse", 
                   optimizer=tf.keras.optimizers.SGD(momentum=0.5, lr=1e-5))
    return model

滤波器值将从 128 开始,然后以 64 的增量向上增加到 256。内核大小将从 3 开始,以 3 的步长增加到 9,步幅将从 1 开始增加到 3。

这里有很多值的组合,所以实验需要一些时间才能运行。你也可以尝试其他更改,比如使用一个更小的起始值作为filters,看看它们的影响。

这里是进行搜索的代码:

tuner = `RandomSearch`(build_model, objective=`'``loss``'`, 
                      max_trials=`500`, executions_per_trial=`3`, 
                      directory=`'``my_dir``'`, project_name=`'``cnn-tune``'`)

tuner.search_space_summary()

tuner.search(dataset, epochs=`100`, verbose=`2`)

当我运行实验时,我发现 128 个滤波器,大小为 9 和步幅为 1,给出了最佳结果。所以,与初始模型相比,改变滤波器大小是最大的不同之处——这在有这么大量数据的情况下是有道理的。使用大小为 3 的滤波器,只有直接的邻居对应用滤波器的结果产生影响,而使用大小为 9 的滤波器,更远的邻居也会对结果产生影响。这将需要进一步的实验,从这些值开始尝试更大的滤波器大小,也许是更少的滤波器。我会留给你看看是否可以进一步改进模型!

将这些值插入模型架构,你将得到以下结果:

dataset = windowed_dataset(x_train, window_size, batch_size, 
                            shuffle_buffer_size)

model = tf.keras.models.`Sequential`([
    tf.keras.layers.`Conv1D`(filters=`128`, kernel_size=`9`,
                           strides=`1`, padding=`"``causal``"`,
                           activation=`"``relu``"`,
                           input_shape=[`None`, `1`]),
    tf.keras.layers.`Dense`(`28`, input_shape=[window_size], 
                           activation=`"``relu``"`), 
    tf.keras.layers.`Dense`(`10`, activation=`"``relu``"`), 
    tf.keras.layers.`Dense`(`1`),
])

optimizer = tf.keras.optimizers.SGD(lr=`1e-5`, momentum=`0.5`)
model.compile(loss=`"``mse``"`, optimizer=optimizer)
history = model.fit(dataset, epochs=`100`,  verbose=`1`)

在使用这个模型进行训练后,与早期创建的简单 CNN 和原始 DNN 相比,模型的准确性有了提高,给出了图 11-5。

优化的 CNN 预测

图 11-5. 优化的 CNN 预测

这导致了一个 MAE 值为 4.39,略优于我们在不使用卷积层时得到的 4.47。进一步尝试 CNN 的超参数可能会进一步改善结果。

在卷积之外,我们在使用 RNN 进行自然语言处理的章节中探索的技术,包括 LSTM,在处理序列数据时可能会非常有用。由于它们的本质,RNN 设计用于维持上下文,因此先前的值可能会影响后续的值。接下来,你将探索在序列建模中使用它们。但首先,让我们从合成数据集转移到真实数据。在这种情况下,我们将考虑天气数据。

使用 NASA 天气数据

时间序列天气数据的一个很好的资源是NASA 戈达德太空研究所(GISS)表面温度分析。如果你点击站点数据链接,在页面的右侧你可以选择一个气象站获取数据。例如,我选择了西雅图塔科马(SeaTac)机场,并被带到了图 11-6 中的页面。

GISS 的表面温度数据

图 11-6. GISS 的表面温度数据

你可以在本页面底部看到一个链接,用于下载月度 CSV 数据。选择此链接,将会下载一个名为station.csv的文件到你的设备上。如果你打开这个文件,你会看到它是一个包含年份和每列是一个月份的数据网格,就像图 11-7 中所示。

数据探索

图 11-7. 数据探索

由于这是 CSV 数据,在 Python 中处理起来非常容易,但是像处理任何数据集一样,注意数据的格式。读取 CSV 时,通常是逐行读取,每一行通常都包含你感兴趣的一个数据点。在这种情况下,每行至少有 12 个感兴趣的数据点,因此在读取数据时需要考虑这一点。

在 Python 中读取 GISS 数据

读取 GISS 数据的代码如下所示:

def get_data():
    data_file = "/home/ljpm/Desktop/bookpython/station.csv"
    f = open(data_file)
    data = f.read()
    f.close()
    lines = data.split('\n')
    header = lines[0].split(',')
    lines = lines[1:]
    temperatures=[]
    for line in lines:
        if line:
            linedata = line.split(',')
            linedata = linedata[1:13]
            for item in linedata:
                if item:
                    temperatures.append(float(item))

    series = np.asarray(temperatures)
    time = np.arange(len(temperatures), dtype="float32")
    return time, series

这将会在指定的路径中打开文件(当然你的路径可能会不同),并将其作为一组行读取,其中行分隔是换行符(\n)。然后它将循环遍历每一行,忽略第一行,并在逗号字符上将它们分割成一个名为linedata的新数组。此数组中从 1 到 13 的项目将指示为字符串的一月到二月的值。这些值被转换为浮点数并添加到名为temperatures的数组中。一旦完成,它将被转换为一个名为series的 Numpy 数组,并创建一个与series大小相同的名为time的另一个 Numpy 数组。由于它是使用np.arange创建的,因此第一个元素将是 1,第二个是 2,依此类推。因此,这个函数将返回从 1 到数据点数的步长的time,以及作为该时间数据的series

现在如果你需要一个归一化的时间序列,你可以简单地运行这段代码:

time, series = get_data()
mean = series.mean(axis=`0`)
series-=mean
std = series.std(axis=`0`)
series/=std

这可以像以前一样分成训练集和验证集。根据数据的大小选择分割时间点 —— 在这个案例中我有大约 840 个数据项,所以我在 792 处进行了分割(保留了四年的数据点用于验证):

split_time = `792`
time_train = time[:split_time]
x_train = series[:split_time]
time_valid = time[split_time:]
x_valid = series[split_time:]

因为数据现在是一个 Numpy 数组,你可以像之前一样使用相同的代码来创建窗口数据集,用于训练神经网络:

window_size = `24`
batch_size = `12`
shuffle_buffer_size = `48`
dataset = windowed_dataset(x_train, window_size, 
                           batch_size, shuffle_buffer_size)
valid_dataset = windowed_dataset(x_valid, window_size, 
                                 batch_size, shuffle_buffer_size)

这应该使用与本章前面的卷积网络相同的 windowed_dataset 函数,增加一个新的维度。当使用 RNN、GRU 和 LSTM 时,你需要按照那种形状准备数据。

用于序列建模的 RNN

现在你已经将 NASA CSV 中的数据转换成了一个窗口数据集,相对来说很容易创建一个用于训练预测器的模型(但要训练一个好的模型有点难!)。让我们从一个简单的、天真的模型开始,使用 RNN。以下是代码:

model = tf.keras.models.`Sequential`([
    tf.keras.layers.`SimpleRNN`(`100`, return_sequences=`True`, 
                              input_shape=[`None`, `1`]),
    tf.keras.layers.`SimpleRNN`(`100`),
    tf.keras.layers.`Dense`(`1`)
])

在这种情况下,使用了 Keras 的 SimpleRNN 层。RNN 是一类强大的神经网络,适用于探索序列模型。你在第七章中首次见到它们,当时你在研究自然语言处理。我不会在这里详细介绍它们的工作原理,但如果你感兴趣并跳过了那一章,现在可以回头看看。值得注意的是,RNN 具有一个内部循环,遍历序列的时间步长,同时保持它已经看到的时间步的内部状态。SimpleRNN 将每个时间步的输出馈送到下一个时间步中。

你可以使用与之前相同的超参数来编译和拟合模型,或者使用 Keras 调优器来找到更好的参数。为简单起见,你可以使用以下设置:

optimizer = tf.keras.optimizers.SGD(lr=`1.5e-6`, momentum=`0.9`)
model.compile(loss=tf.keras.losses.`Huber`(), 
               optimizer=optimizer, metrics=[`"``mae``"`])

 history = model.fit(dataset, epochs=`100`,  verbose=`1`,
                     validation_data=valid_dataset)

甚至一百个 epoch 就足以了解它如何预测数值。图 11-8 显示了结果。

SimpleRNN 的结果

图 11-8. SimpleRNN 的结果

如你所见,结果相当不错。在峰值处可能有些偏差,在模式意外更改时(例如时间步骤 815 和 828),但总体来说还不错。现在让我们看看如果我们将其训练 1,500 个 epoch 会发生什么(图 11-9)。

训练了 1,500 个 epoch 的 RNN

图 11-9. 训练了 1,500 个 epoch 的 RNN

没有太大的不同,只是一些峰值被平滑了。如果你查看验证集和训练集上损失的历史记录,看起来像是 图 11-10。

SimpleRNN 的训练和验证损失

图 11-10. SimpleRNN 的训练和验证损失

如你所见,训练损失和验证损失之间有良好的匹配,但随着 epoch 的增加,模型开始在训练集上过拟合。也许最佳的 epoch 数应该在五百左右。

其中一个原因可能是数据是月度天气数据,具有很强的季节性。另一个原因是训练集非常大,而验证集相对较小。接下来,我们将探索使用更大的气候数据集。

探索更大的数据集

KNMI 气候探索器允许您探索世界各地许多位置的详细气候数据。我下载了一个数据集,包括从 1772 年到 2020 年的英格兰中部地区的每日温度读数。这些数据的结构与 GISS 数据不同,日期作为字符串,后跟一些空格,然后是读数。

我已经准备好了数据,剥离了标题并删除了多余的空格。这样用像这样的代码很容易阅读:

def get_data():
    data_file = "tdaily_cet.dat.txt"
    f = open(data_file)
    data = f.read()
    f.close()
    lines = data.split('\n')
    temperatures=[]
    for line in lines:
        if line:
            linedata = line.split(' ')
            temperatures.append(float(linedata[1]))

    series = np.asarray(temperatures)
    time = np.arange(len(temperatures), dtype="float32")
    return time, series

此数据集中有 90,663 个数据点,因此,在训练模型之前,请确保适当地拆分它。我使用了 80000 的拆分时间,留下 10663 条记录用于验证。还要适当更新窗口大小、批处理大小和洗牌缓冲区大小。这里是一个示例:

window_size = `60`
batch_size = `120`
shuffle_buffer_size = `240`

其他都可以保持不变。正如你在图 11-11 中所看到的,在经过一百个周期的训练后,预测与验证集的绘图看起来非常不错。

预测与真实数据的绘图

图 11-11. 预测与真实数据的绘图

这里有大量的数据,所以让我们放大到最近一百天的数据(图 11-12)。

一百天数据的结果

图 11-12. 一百天数据的结果

虽然图表通常遵循数据的曲线,并且大致上正确地捕捉了趋势,但在极端端点处相差很远,所以还有改进的空间。

还要记住,我们对数据进行了归一化处理,因此虽然我们的损失和 MAE 可能看起来很低,但这是因为它们基于归一化值的损失和 MAE,这些值的方差远低于实际值。因此,显示损失小于 0.1 的图 11-13,可能会让你产生一种错误的安全感。

大数据集的损失和验证损失

图 11-13. 大数据集的损失和验证损失

要对数据进行反归一化,您可以执行归一化的逆操作:首先乘以标准偏差,然后加回均值。在那一点上,如果您希望,您可以像之前一样计算预测集的实际 MAE。

使用其他递归方法

除了SimpleRNN,TensorFlow 还有其他递归层类型,如门控递归单元(GRUs)和长短期记忆层(LSTMs),在第七章中讨论。如果要尝试,可以使用本章节始终使用的基于TFRecord的架构来简单地插入这些 RNN 类型。

所以,例如,如果您考虑之前创建的简单朴素 RNN:

model = tf.keras.models.`Sequential`([
    tf.keras.layers.`SimpleRNN`(`100`, input_shape=[`None`, `1`], 
                              return_sequences=`True`),
    tf.keras.layers.`SimpleRNN`(`100`),
    tf.keras.layers.`Dense`(`1`)
])

将其替换为 GRU 变得如此简单:

model = tf.keras.models.`Sequential`([
    tf.keras.layers.GRU(`100`, input_shape=[`None`, `1`], return_sequences=`True`),
    tf.keras.layers.GRU(`100`),
    tf.keras.layers.`Dense`(`1`)
])

使用 LSTM,情况类似:

model = tf.keras.models.`Sequential`([
    tf.keras.layers.LSTM(`100`, input_shape=[`None`, `1`], return_sequences=`True`),
    tf.keras.layers.LSTM(`100`),
    tf.keras.layers.`Dense`(`1`)
])

值得尝试这些层类型以及不同的超参数、损失函数和优化器。并没有一种适合所有情况的解决方案,因此在任何特定情况下,最适合您的将取决于您的数据以及您对该数据预测的需求。

使用 Dropout

如果您在模型中遇到过拟合问题,在训练数据的 MAE 或损失明显优于验证数据时,您可以使用 dropout。如在第三章中讨论的,在计算机视觉环境下,使用 dropout 时,随机忽略相邻神经元以避免熟悉偏差。当使用 RNN 时,还有一个递归丢失参数可供使用。

有什么不同?回想一下,当使用 RNN 时,通常有一个输入值,神经元计算一个输出值和一个传递到下一个时间步的值。Dropout 会随机丢弃输入值。递归丢失会随机丢弃传递到下一步的递归值。

例如,考虑在图 11-14 中显示的基本递归神经网络架构。

递归神经网络

图 11-14. 递归神经网络

在这里,您可以看到不同时间步骤的层的输入(x)。当前时间是t,显示的步骤是t – 2 至t + 1。还显示了相同时间步骤的相关输出(y)。通过时间步骤传递的递归值由虚线表示,并标记为r

使用dropout会随机丢弃x输入。使用递归丢失会随机丢弃r递归值。

您可以从更深入的数学角度了解递归丢失的工作原理,详见 Yarin Gal 和 Zoubin Ghahramani 撰写的论文“A Theoretically Grounded Application of Dropout in Recurrent Neural Networks”

在使用递归丢弃时需要考虑的一件事由 Gal 在他围绕 深度学习中的不确定性 的研究中讨论过,他证明了应该在每个时间步骤应用相同的丢弃单元模式,并且在每个时间步骤应用类似的恒定丢弃掩码。虽然丢弃通常是随机的,但 Gal 的工作已经融入了 Keras,因此在使用 tf.keras 时,建议保持其研究推荐的一致性。

要添加丢弃和递归丢弃,您只需在您的层上使用相关参数。例如,将它们添加到早期的简单 GRU 中将如下所示:

model = tf.keras.models.Sequential([
    tf.keras.layers.GRU(100, input_shape=[None, 1], return_sequences=True, 
                         **dropout=0.1, recurrent_dropout=0.1**),
    tf.keras.layers.GRU(100, **`dropout=0.1, recurrent_dropout=0.1`**),
    tf.keras.layers.Dense(1),
])

每个参数都接受一个介于 0 和 1 之间的值,表示要丢弃的值的比例。值为 0.1 将丢弃 10% 的必需值。

使用丢弃的 RNNs 通常需要更长时间才能收敛,因此请确保为它们进行更多的 epoch 进行测试。图 11-15 显示了在每层设置为 0.1 的丢弃和递归丢弃下,训练前述的 GRU 过程中的结果。

训练带有丢弃的 GRU

图 11-15. 训练带有丢弃的 GRU

正如您所看到的,损失和 MAE 在大约 300 个 epoch 之前迅速下降,之后继续下降,但噪声相当明显。当使用丢弃时,您经常会看到这种损失中的噪声,这表明您可能希望调整丢弃的数量以及损失函数的参数,如学习率。正如您在 图 11-16 中看到的,这个网络的预测形状相当不错,但是有改进的空间,即预测的峰值远低于实际峰值。

使用带有丢弃的 GRU 进行预测

图 11-16. 使用带有丢弃的 GRU 进行预测

正如您在本章中所看到的,使用神经网络预测时间序列数据是一个困难的任务,但通过调整它们的超参数(特别是使用 Keras Tuner 等工具)可以是改善模型及其后续预测的强大方式。

使用双向 RNNs

在分类序列时考虑的另一种技术是使用双向训练。这一开始可能看起来有些反直觉,因为您可能会想知道未来值如何影响过去值。但请记住,时间序列值可能包含季节性,即值随时间重复,并且当使用神经网络进行预测时,我们只是进行复杂的模式匹配。鉴于数据重复,可以在未来值中找到数据如何重复的信号。当使用双向训练时,我们可以训练网络试图识别从时间 t 到时间 t + x 的模式,以及从时间 t + x 到时间 t 的模式。

幸运的是,编写这个很简单。例如,考虑前一节中的 GRU。要使其双向,只需将每个 GRU 层包装在tf.keras.layers.Bidirectional调用中。这将在每一步上有效地两次训练——一次是原始顺序的序列数据,一次是反向顺序的数据。然后将结果合并,再进行下一步。

以下是一个例子:

model = tf.keras.models.Sequential([
    `tf``.``keras``.``layers``.``Bidirectional`(
        tf.keras.layers.GRU(100, input_shape=[None, 1],return_sequences=True, 
                            dropout=0.1, recurrent_dropout=0.1)),
    `tf``.``keras``.``layers``.``Bidirectional`(
        tf.keras.layers.GRU(100, dropout=0.1, recurrent_dropout=0.1)),
    tf.keras.layers.Dense(1),
])

使用带有丢失的双向 GRU 训练时间序列的结果图示在图 11-17 中展示。正如你所看到的,这里没有主要的差异,MAE 最终也相似。然而,对于更大的数据系列,你可能会看到相当大的准确度差异,而且调整训练参数——特别是window_size,以获取多个季节——可能会产生相当大的影响。

使用双向 GRU 进行训练

图 11-17. 使用双向 GRU 进行训练

这个网络在标准化数据上的 MAE 约为 0.48,主要是因为它在高峰值上表现不佳。使用更大的窗口和双向性重新训练会产生更好的结果:它的 MAE 显著降低至约 0.28(图 11-18)。

更大窗口,双向 GRU 结果

图 11-18. 更大窗口,双向 GRU 结果

如你所见,你可以尝试不同的网络架构和不同的超参数来提高整体预测效果。理想选择非常依赖于数据,因此你在本章学到的技能将帮助你处理特定的数据集!

总结

在本章中,你探索了不同的网络类型来构建用于预测时间序列数据的模型。你在简单的 DNN(来自第十章)的基础上增加了卷积,并尝试了简单 RNN、GRU 和 LSTM 等递归网络类型。你看到了如何调整超参数和网络架构来提高模型的准确性,并且实践了处理一些真实世界数据集,包括一组包含数百年温度读数的大型数据集。现在你已准备好开始构建适用于各种数据集的网络,并对如何优化它们有了很好的理解!

第二部分:使用模型

第十二章:TensorFlow Lite 简介

到目前为止,本书的所有章节都在探索如何使用 TensorFlow 创建机器学习模型,这些模型可以提供计算机视觉、自然语言处理和序列建模等功能,而无需明确编程规则。相反,使用标记数据,神经网络能够学习区分一件事物与另一件事物的模式,然后可以将其扩展为解决问题。在本书的其余部分,我们将转向并查看如何在常见场景中使用这些模型。第一个、最明显且可能最有用的主题是如何在移动应用程序中使用模型。在本章中,我将介绍使在移动(和嵌入式)设备上进行机器学习成为可能的基础技术:TensorFlow Lite。然后,在接下来的两章中,我们将探讨在 Android 和 iOS 上使用这些模型的场景。

TensorFlow Lite 是一套工具,用于补充 TensorFlow,实现两个主要目标。第一个目标是使您的模型适用于移动设备。这通常包括减小模型的大小和复杂性,尽可能少地影响其准确性,以使它们在像移动设备这样受电池限制的环境中更好地工作。第二个目标是为不同的移动平台提供运行时,包括 Android、iOS、移动 Linux(例如,树莓派)和各种微控制器。请注意,您不能使用 TensorFlow Lite 来训练模型。您的工作流程将是使用 TensorFlow 进行训练,然后转换为 TensorFlow Lite 格式,然后使用 TensorFlow Lite 解释器加载和运行它。

什么是 TensorFlow Lite?

TensorFlow Lite 最初是针对 Android 和 iOS 开发者的 TensorFlow 移动版本,旨在成为他们需求的有效 ML 工具包。在计算机或云服务上构建和执行模型时,电池消耗、屏幕大小和移动应用程序开发的其他方面不是问题,因此当针对移动设备时,需要解决一组新的约束条件。

第一个是移动应用程序框架需要轻量级。移动设备的资源远远有限于用于训练模型的典型机器。因此,开发人员不仅需要非常小心地使用应用程序所需的资源,还需要使用应用程序框架的资源。实际上,当用户浏览应用商店时,他们会看到每个应用程序的大小,并根据其数据使用情况决定是否下载。如果运行模型的框架很大,而且模型本身也很大,这将增加文件大小,并使用户失去兴趣。

框架还必须是低延迟的。在移动设备上运行的应用程序需要表现良好,否则用户可能会停止使用它们。只有 38%的应用程序被使用超过 11 次,意味着 62%的应用程序被使用 10 次或更少。事实上,所有应用程序中有 25%仅被使用一次。高延迟,即应用程序启动慢或处理关键数据慢,是导致这种放弃率的一个因素。因此,基于 ML 的应用程序需要加载快速并执行必要的推断。

与低延迟合作,移动框架需要一个高效的模型格式。在强大的超级计算机上训练时,模型格式通常不是最重要的信号。正如我们在早期章节中看到的,高模型准确度、低损失、避免过拟合等是模型创建者追求的指标。但是在移动设备上运行时,为了轻量化和低延迟,模型格式也需要考虑在内。到目前为止,我们所见的神经网络中的大部分数学都是高精度的浮点运算。对于科学发现来说,这是必不可少的。但是对于在移动设备上运行来说,可能并非如此。一个移动友好的框架将需要帮助您处理这种权衡,并为您提供必要的工具以便必要时转换您的模型。

在设备上运行您的模型具有一个重要的好处,即它们无需将数据传递给云服务以进行推断。这导致用户隐私以及能量消耗的改善。不需要使用无线电发送数据并接收预测的 WiFi 或蜂窝信号是好事,只要在设备上的推断不会在功耗方面成本过高。出于显而易见的原因,保持数据在设备上以运行预测也是一个强大且越来越重要的功能!(在本书的后面我们将讨论联邦学习,这是一种设备本地和基于云的混合机器学习方法,既能享受两全其美,又能保持隐私。)

因此,考虑到所有这些,TensorFlow Lite 应运而生。正如前面提到的,它不是一个用于训练模型的框架,而是一套补充工具,专门设计用于满足移动和嵌入式系统的所有限制。

它应该广泛被视为两个主要部分:一个转换器,将您的 TensorFlow 模型转换为.tflite格式,缩小并优化它,以及一套解释器,用于各种运行时环境(图 12-1)。

TensorFlow Lite 套件

图 12-1. TensorFlow Lite 套件

解释器环境还支持其特定框架内的加速选项。例如,在 Android 上支持神经网络 API,因此 TensorFlow Lite 可以在支持该 API 的设备上利用它。

请注意,并非每个 TensorFlow 中的操作(或“op”)目前都受到 TensorFlow Lite 或 TensorFlow Lite 转换器的支持。在转换模型时可能会遇到此问题,建议查看文档获取详细信息。本章后面的一个有用的工作流程是,获取一个现有的移动友好型模型,并为您的场景使用迁移学习。您可以在TensorFlow 网站TensorFlow Hub上找到与 TensorFlow Lite 优化工作的模型列表。

演练:创建并将模型转换为 TensorFlow Lite

我们将从逐步演练开始,展示如何创建一个简单的 TensorFlow 模型,将其转换为 TensorFlow Lite 格式,然后使用 TensorFlow Lite 解释器。在这个演练中,我将使用 Linux 解释器,因为它在 Google Colab 中很容易获取。在第十三章中,您将看到如何在 Android 上使用这个模型,在第十四章中,您将探索如何在 iOS 上使用它。

回到第一章,您看到了一个非常简单的 TensorFlow 模型,学习了两组数字之间的关系,最终得到 Y = 2X - 1。为方便起见,这里是完整的代码:

l0 = `Dense`(units=`1`, input_shape=[`1`])
model = `Sequential`([l0])
model.compile(optimizer=`'``sgd``'`, loss=`'``mean_squared_error``'`)

xs = np.array([-`1.0`, `0.0`, `1.0`, `2.0`, `3.0`, `4.0`], dtype=float)
ys = np.array([-`3.0`, -`1.0`, `1.0`, `3.0`, `5.0`, `7.0`], dtype=float)

model.fit(xs, ys, epochs=`500`)

`print`(model.predict([`10.0`]))
`print`(`"``Here is what I learned: {}``"`.format(l0.get_weights()))

一旦训练完成,如您所见,您可以执行model.predict[x]并得到预期的y。在前面的代码中,x=10,模型将返回一个接近 19 的值。

由于这个模型体积小、易于训练,我们可以将其作为示例,演示转换为 TensorFlow Lite 的所有步骤。

第 1 步。保存模型

TensorFlow Lite 转换器支持多种不同的文件格式,包括 SavedModel(推荐)和 Keras H5 格式。在这个示例中,我们将使用 SavedModel。

要实现这一点,只需指定一个目录来保存模型,并调用tf.saved_model.save,将模型和目录传递给它:

`export_dir` `=` 'saved_model/1'
`tf``.``saved_model``.``save``(``model``,` `export_dir``)`

模型将保存为资产和变量以及一个saved_model.pb文件,如图 12-2 所示。

SavedModel 结构

图 12-2。SavedModel 结构

一旦您拥有了保存的模型,您就可以使用 TensorFlow Lite 转换器将其转换。

注意

TensorFlow 团队建议使用 SavedModel 格式以确保在整个 TensorFlow 生态系统中的兼容性,包括未来与新 API 的兼容性。

第 2 步。转换并保存模型

TensorFlow Lite 转换器位于tf.lite包中。您可以调用它来通过首先使用from_saved_model方法调用它,并传递包含保存模型的目录,然后调用其convert方法来转换保存的模型:

# Convert the model.
`converter` `=` `tf``.``lite``.``TFLiteConverter``.``from_saved_model``(``export_dir``)`
`tflite_model` `=` `converter``.``convert``(``)`

然后,使用pathlib保存新的.tflite模型:

`import` pathlib
tflite_model_file = pathlib.`Path`(`'``model.tflite``'`)
tflite_model_file.write_bytes(tflite_model)

此时,你已经有一个名为 .tflite 的文件,可以在任何解释器环境中使用。稍后我们将在 Android 和 iOS 上使用它,但现在,让我们在基于 Python 的解释器中使用它,这样你就可以在 Colab 中运行它。这个相同的解释器也可以在嵌入式 Linux 环境中使用,比如树莓派!

步骤 3. 加载 TFLite 模型并分配张量

下一步是将模型加载到解释器中,分配张量以将数据输入到模型进行预测,然后读取模型输出的预测结果。从程序员的角度来看,这是使用 TensorFlow Lite 与使用 TensorFlow 的巨大区别。在 TensorFlow 中,你可以简单地说 model.predict(*something*) 并得到结果,但因为 TensorFlow Lite 不会像 TensorFlow 那样有许多依赖项,特别是在非 Python 环境中,你现在必须变得更加低级,处理输入和输出张量,格式化数据以适应它们,并以对设备有意义的方式解析输出。

首先,加载模型并分配张量:

interpreter = tf.lite.`Interpreter`(model_content=tflite_model)
interpreter.allocate_tensors()

然后,你可以从模型中获取输入和输出的详细信息,以便开始理解它期望的数据格式,以及它将返回给你的数据格式:

input_details = interpreter.get_input_details()
output_details = interpreter.get_output_details()
`print`(input_details)
`print`(output_details)

你将得到大量输出!

首先,让我们检查输入参数。请注意 shape 设置,它是一个类型为 [1,1] 的数组。还请注意类别,它是 numpy.float32。这些设置将决定输入数据的形状和格式:

`[``{`'name'`:` 'dense_input'`,` 'index'`:` `0``,` 'shape'`:` `array``(``[``1``,` `1``]``,` `dtype``=``int32``)``,` 
  'shape_signature'`:` `array``(``[``1``,` `1``]``,` `dtype``=``int32``)``,` 'dtype'`:` `<``class`  
  'numpy.float32'`>,` 'quantization'`: (``0.0``,` `0``),` 'quantization_parameters'`:` 
  `{`'scales'`:` `array``(``[``]``,` `dtype``=``float32``)``,` 'zero_points'`:` `array``(``[``]``,` `dtype``=``int32``)``,` 
  'quantized_dimension'`:` `0``}``,` 'sparsity_parameters'`:` `{``}``}``]`

因此,为了格式化输入数据,如果你想预测 x=10.0 对应的 y,你需要像这样使用代码定义输入数组的形状和类型:

to_predict = np.array([[`10.0`]], dtype=np.float32)
`print`(to_predict)

在这里 [1,1] 的数组周围有双括号,可能会引起一些混淆——我在这里使用的助记符是说这里有 1 个列表,给了我们第一组 [],而该列表只包含一个值,即 [10.0],因此得到 [[10.0]]。可能会让人困惑的是形状定义为 dtype=int32,而你使用的是 numpy.float32dtype 参数是定义形状的数据类型,而不是包含在该形状中封装的列表的内容。对于这个,你将使用类别。

输出细节非常相似,你需要关注的是其形状。因为它也是一个类型为 [1,1] 的数组,你可以期待答案也会像输入 [[x]] 一样是 [[y]]

`[``{`'name'`:` 'Identity'`,` 'index'`:` `3``,` 'shape'`:` `array``(``[``1``,` `1``]``,` `dtype``=``int32``)``,` 
  'shape_signature'`:` `array``(``[``1``,` `1``]``,` `dtype``=``int32``)``,` 'dtype'`:` `<``class`  
  'numpy.float32'`>,` 'quantization'`: (``0.0``,` `0``),` 'quantization_parameters'`:` 
  `{`'scales'`:` `array``(``[``]``,` `dtype``=``float32``)``,` 'zero_points'`:` `array``(``[``]``,` `dtype``=``int32``)``,` 
  'quantized_dimension'`:` `0``}``,` 'sparsity_parameters'`:` `{``}``}``]`

步骤 4. 执行预测

要使解释器执行预测,你需要将输入张量设置为要预测的值,并告诉它使用哪个输入值:

interpreter.set_tensor(input_details[`0`][`'``index``'`], to_predict)
interpreter.invoke()

输入张量是使用输入详细信息数组的索引来指定的。在这种情况下,您有一个非常简单的模型,只有一个单独的输入选项,因此它是input_details[0],您将在索引处进行处理。输入详细信息项目 0 只有一个索引,索引为 0,并且它希望一个形状为[1,1],如前所述。因此,您将to_predict值放入其中。然后使用invoke方法调用解释器。

然后,您可以通过调用get_tensor并提供您要读取的张量的详细信息来读取预测:

tflite_results = interpreter.get_tensor(output_details[`0`][`'``index``'`])
`print`(tflite_results)

同样,只有一个输出张量,因此它将是output_details[0],您指定索引以获取其下的详细信息,这将具有输出值。

因此,例如,如果您运行此代码:

to_predict = np.array([[`10.0`]], dtype=np.float32)
`print`(to_predict)
interpreter.set_tensor(input_details[`0`][`'``index``'`], to_predict)
interpreter.invoke()
tflite_results = interpreter.get_tensor(output_details[`0`][`'``index``'`])
`print`(tflite_results)

您应该看到如下输出:

[[`10.`]]
[[`18.975412`]]

其中 10 是输入值,18.97 是预测值,非常接近于 19,当 X = 10 时为 2X - 1。为什么不是 19,请参阅第一章!

鉴于这只是一个非常简单的例子,让我们来看看接下来的一些稍微复杂的东西——在一个知名的图像分类模型上使用迁移学习,然后将其转换为 TensorFlow Lite。从那里,我们还能更好地探索优化和量化模型的影响。

演示:转移学习图像分类器并转换为 TensorFlow Lite

在本节中,我们将从第三章和第四章的 Dogs vs. Cats 计算机视觉模型中构建一个使用迁移学习的新版本。这将使用来自 TensorFlow Hub 的模型,因此如果您需要安装它,可以按照网站上的说明进行操作。

第 1 步:构建并保存模型

首先,获取所有数据:

`import` numpy `as` np
`import` matplotlib.pylab `as` plt

`import` tensorflow `as` tf
`import` tensorflow_hub `as` hub
`import` tensorflow_datasets `as` tfds

`def` format_image(image, label):
    image = tf.image.resize(image, IMAGE_SIZE) / `255.0`
    `return`  image, label

(raw_train, raw_validation, raw_test), metadata = tfds.load(
    `'``cats_vs_dogs``'`,
    split=[`'``train[:80``%``]``'`, `'``train[80``%``:90``%``]``'`, `'``train[90``%``:]``'`],
    with_info=`True`,
    as_supervised=`True`,
)

num_examples = metadata.splits[`'``train``'`].num_examples
num_classes = metadata.features[`'``label``'`].num_classes
`print`(num_examples)
`print`(num_classes)

BATCH_SIZE = `32`
train_batches = 
raw_train.shuffle(num_examples // `4`)
.map(format_image).batch(BATCH_SIZE).prefetch(`1`)

validation_batches = raw_validation.map(format_image)
.batch(BATCH_SIZE).prefetch(`1`)
test_batches = raw_test.map(format_image).batch(`1`)

这将下载 Dogs vs. Cats 数据集,并将其分割为训练、测试和验证集。

接下来,您将使用来自 TensorFlow Hub 的mobilenet_v2模型创建一个名为feature_extractor的 Keras 层:

module_selection = (`"``mobilenet_v2``"`, `224`, `1280`) 
handle_base, pixels, FV_SIZE = module_selection

MODULE_HANDLE =`"``https://tfhub.dev/google/tf2-preview/{}/feature_vector/4``"`
.format(handle_base)

IMAGE_SIZE = (pixels, pixels)

feature_extractor = hub.`KerasLayer`(MODULE_HANDLE,
                                   input_shape=IMAGE_SIZE + (`3`,), 
                                   output_shape=[FV_SIZE],
                                   trainable=`False`)

现在您已经有了特征提取器,可以将其作为神经网络的第一层,并添加一个输出层,其神经元数量与类别数量相同(在本例中为两个)。然后您可以编译并训练它:

`model` `=` `tf``.``keras``.`Sequential`(``[`
 `feature_extractor``,`
 `tf``.``keras``.``layers``.`Dense`(``num_classes``,` `activation``=``'``softmax``'``)`
 `]``)`

`model``.``compile``(``optimizer``=``'``adam``'``,`
 `loss``=``'``sparse_categorical_crossentropy``'``,`
 `metrics``=``[``'``accuracy``'``]``)`

`hist` `=` `model``.``fit``(``train_batches``,`
 `epochs``=``5``,`
 `validation_data``=``validation_batches``)`

只需五个训练周期,这应该给出一个在训练集上达到 99%准确度,在验证集上超过 98%的模型。现在您只需简单地保存模型即可:

`CATS_VS_DOGS_SAVED_MODEL` `=` "exp_saved_model"
`tf``.``saved_model``.``save``(``model``,` `CATS_VS_DOGS_SAVED_MODEL``)`

一旦您有了保存的模型,您可以进行转换。

第 2 步:将模型转换为 TensorFlow Lite

如前所述,您现在可以取出保存的模型并将其转换为.tflite模型。您将其保存为converted_model.tflite

converter = tf.lite.`TFLiteConverter`.from_saved_model(CATS_VS_DOGS_SAVED_MODEL)
tflite_model = converter.convert()
tflite_model_file = `'``converted_model.tflite``'`

`with` open(tflite_model_file, `"``wb``"`) `as` f:
    f.write(tflite_model)

一旦您有了文件,您可以使用它实例化一个解释器。完成此操作后,您应该像以前一样获取输入和输出详细信息。将它们加载到名为input_indexoutput_index的变量中。这使得代码更易读!

interpreter = tf.lite.`Interpreter`(model_path=tflite_model_file)
interpreter.allocate_tensors()

input_index = interpreter.get_input_details()[`0`][`"``index``"`]
output_index = interpreter.get_output_details()[`0`][`"``index``"`]

predictions = []

数据集中有很多测试图像在test_batches中,所以如果您想取其中的一百张图像并对它们进行测试,可以这样做(可以自由更改100为任何其他值):

test_labels, test_imgs = [], []
`for` img, label `in` test_batches.take(`100`):
    interpreter.set_tensor(input_index, img)
    interpreter.invoke()
    predictions.append(interpreter.get_tensor(output_index))
    test_labels.append(label.numpy()[`0`])
    test_imgs.append(img)

在早期读取图像时,它们通过称为format_image的映射函数重新格式化,以便在训练和推断中都具有正确的大小,因此现在您所需做的就是将解释器的张量设置为输入索引处的图像。调用解释器后,您可以获取输出索引处的张量。

如果您想查看预测与标签的比较情况,可以运行如下代码:

score = `0`
`for` item `in` range(`0`,`99`):
    prediction=np.argmax(predictions[item])
    label = test_labels[item]
    `if` prediction==label:
        score=score+`1`

`print`(`"``Out of 100 predictions I got` `"` + str(score) + `"` `correct``"`)

这应该会给你一个 99 或 100 的正确预测分数。

您还可以使用此代码将模型的输出与测试数据进行可视化:

`for` index `in` range(`0`,`99`):
    plt.figure(figsize=(`6`,`3`))
    plt.subplot(`1`,`2`,`1`)
    plot_image(index, predictions, test_labels, test_imgs)
    plt.show()

您可以在图 12-3 中看到这些结果的一些内容。(请注意,所有代码都可以在书的GitHub 仓库中找到,所以如果需要的话,请去那里查看。)

推断结果

图 12-3. 推断结果

这只是普通的、转换后的模型,没有为移动设备添加任何优化。接下来,您将探索如何为移动设备优化此模型。

步骤 3. 优化模型

现在您已经看到了训练、转换和使用 TensorFlow Lite 解释器的端到端过程,接下来我们将看看如何开始优化和量化模型。

第一种优化类型,称为动态范围量化,是通过在转换器上设置optimizations属性来实现的,在执行转换之前进行设置。以下是代码:

converter = tf.lite.>TFLiteConverter.from_saved_model(CATS_VS_DOGS_SAVED_MODEL)
`converter``.``optimizations` `=` `[``tf``.``lite``.``>``Optimize``.``DEFAULT``]`

tflite_model = converter.convert()
tflite_model_file = >'converted_model.tflite'

>with open(tflite_model_file, >"wb") >as f:
    f.write(tflite_model)

在撰写本文时有几个可用的优化选项(稍后可能会添加更多)。这些选项包括:

OPTIMIZE_FOR_SIZE

执行优化,使模型尽可能小。

OPTIMIZE_FOR_LATENCY

执行优化,尽量减少推断时间。

DEFAULT

找到在大小和延迟之间的最佳平衡。

在这种情况下,此步骤之前模型的大小接近 9 MB,但之后仅为 2.3 MB,几乎减少了 70%。各种实验表明,模型可以缩小至原来的 4 倍,速度提高 2 到 3 倍。然而,根据模型类型的不同,可能会出现精度损失,因此如果像这样量化,建议对模型进行全面测试。在这种情况下,我发现模型的精度从 99%降至约 94%。

您可以使用完整整数量化float16 量化来优化这个模型,以利用特定的硬件优势。完整整数量化将模型中的权重从 32 位浮点数更改为 8 位整数,这对模型的大小和延迟(尤其是对于较大的模型)可能会产生巨大影响,但对准确性的影响相对较小。

要获得完整的整数量化,您需要指定一个代表性数据集,告诉转换器大致可以期待什么范围的数据。更新代码如下:

converter = tf.lite.TFLiteConverter.from_saved_model(CATS_VS_DOGS_SAVED_MODEL)

converter.optimizations = [tf.lite.Optimize.DEFAULT]

`def` `representative_data_gen``(``)``:`
 `for` `input_value``,` `_` `in` `test_batches``.``take``(``100``)``:`
 `yield` `[``input_value``]`

`converter``.``representative_dataset` `=` `representative_data_gen`
`converter``.``target_spec``.``supported_ops` `=` `[``tf``.``lite``.``OpsSet``.``TFLITE_BUILTINS_INT8``]`

tflite_model = converter.convert()
tflite_model_file = 'converted_model.tflite'

with open(tflite_model_file, "wb") as f:
    f.write(tflite_model)

拥有这些代表性数据使得转换器能够在数据通过模型时检查,并找到最佳的转换点。然后,通过设置支持的操作(在这种情况下设置为INT8),可以确保精度仅在模型的这些部分进行量化。结果可能是一个稍大一些的模型——在这种情况下,使用convertor.optimizations时从 2.3 MB 增加到了 2.8 MB。然而,精确度提高到了 99%。因此,通过遵循这些步骤,您可以将模型的大小减少约三分之二,同时保持其准确性!

摘要

在本章中,您了解了 TensorFlow Lite,并看到它是如何设计来使您的模型能够在比开发环境更小、更轻的设备上运行的。这些设备包括移动操作系统如 Android、iOS 和 iPadOS,以及移动 Linux 环境,比如树莓派和支持 TensorFlow 的微控制器系统。您构建了一个简单的模型,并使用它来探索转换工作流程。然后,您通过一个更复杂的例子来学习,使用迁移学习重新训练现有模型以适应您的数据集,将其转换为 TensorFlow Lite,并对移动环境进行优化。在下一章中,您将深入探讨如何使用基于 Android 的解释器在您的 Android 应用中使用 TensorFlow Lite。

第十三章:在 Android 应用中使用 TensorFlow Lite

第十二章向你介绍了 TensorFlow Lite,一套工具,帮助你将模型转换为移动或嵌入式系统可消费的格式。在接下来的几章中,你将了解如何在各种运行时环境中使用这些模型。在这里,你将看到如何创建使用 TensorFlow Lite 模型的 Android 应用程序。我们将从快速探索用于创建 Android 应用程序的主要工具开始:Android Studio。

什么是 Android Studio?

Android Studio 是一个集成开发环境(IDE),用于开发 Android 应用程序,适用于各种设备,从手机和平板电脑到电视、汽车、手表等等。在本章中,我们将专注于用于手机应用程序的使用。它可以免费下载,并且适用于所有主要操作系统。

Android Studio 给你提供的一个好处是 Android 模拟器,这样你就可以在不需要实体设备的情况下测试应用。在本章中,你将会广泛使用它!传统上,Android 应用是使用 Java 编程语言构建的,但最近 Google 在 Android Studio 中引入了 Kotlin,而你将在本章中使用这种语言。

创建你的第一个 TensorFlow Lite Android 应用程序

如果你还没有安装 Android Studio,请立即安装。设置、更新和准备好所有内容可能需要一些时间。在接下来的几页中,我将逐步指导你创建一个新应用,设计其用户界面,添加 TensorFlow Lite 依赖项,然后为推理编写代码。这将是一个非常简单的应用程序——你在其中输入一个值,它执行推理并计算 Y = 2X – 1,其中 X 是你输入的值。对于这样简单的功能来说,这有点大材小用,但是这样一个应用程序的脚手架几乎与更复杂的应用程序的相同。

步骤 1:创建一个新的 Android 项目

一旦你安装好了 Android Studio,可以通过 File → New → New Project 创建一个新应用,这将打开创建新项目对话框(图 13-1)。

在 Android Studio 中创建一个新项目

图 13-1:在 Android Studio 中创建一个新项目

如 图 13-1 所示,选择空活动(Empty Activity)。这是最简单的 Android 应用程序,几乎没有任何预先存在的代码。点击“Next”将进入配置项目对话框(图 13-2)。

配置你的项目

图 13-2:配置你的项目

在这个对话框中,如示例所示,将名称设置为 FirstTFLite,并确保语言为 Kotlin。最小 SDK 级别可能会默认为 API 23,如果你喜欢,可以保持不变。

当你完成后,请按完成。Android Studio 现在将为您的应用程序创建所有的代码。Android 应用程序需要大量文件。您创建的单个活动有一个布局文件(XML 格式),定义其外观,以及一个关联的 .kt(Kotlin)源文件。还有几个配置文件定义应用程序的构建方式,应用程序应使用的依赖项,以及其资源、资产等等。即使对于这样一个非常简单的应用程序,一开始可能会感到非常压制。

步骤 2. 编辑您的布局文件

在屏幕左侧,您将看到项目资源管理器。确保顶部选择了 Android,并找到 res 文件夹。在其中找到 layout 文件夹,在其中您会找到 activity_main.xml(参见图 13-3)。

查找您的活动设计文件

图 13-3. 查找您的活动设计文件

双击它以打开它,您将看到 Android Studio 布局编辑器。这为您提供了访问用户界面的视觉表示,以及显示定义的 XML 编辑器。您可能只会看到其中之一,但如果想同时看到两者(我建议这样做!),可以使用位于图 13-4 右上角的三个突出显示的按钮。它们依次提供仅 XML 编辑器,带有 XML 编辑器和可视化设计师的分屏视图,以及仅可视化设计师。还请注意直接在这些按钮下方的属性选项卡。它允许您编辑任何单个用户界面元素的属性。随着您构建更多的 Android 应用程序,您可能会发现使用可视化布局工具从控件面板拖放项目到设计表面,并使用属性窗口设置布局宽度等更容易。

在 Android Studio 中使用布局编辑器

图 13-4. 在 Android Studio 中使用布局编辑器

正如您在图 13-4 中所见,您将拥有一个非常基本的 Android 活动,其中包含一个单独的 TextView 控件,显示“Hello World”。将活动的所有代码替换为以下内容:

<?xml version=`"1.0"` encoding=`"utf-8"`?>
`<``LinearLayout` `xmlns``:``tools`=`"http://schemas.android.com/tools"`
        `android``:``orientation`=`"vertical"`
 `xmlns``:``android`=`"http://schemas.android.com/apk/res/android"` 
 `android``:``layout_height`=`"match_parent"` 
 `android``:``layout_width`=`"match_parent"``>`

    `<``LinearLayout`
        `android``:``layout_width`=`"match_parent"`
        `android``:``layout_height`=`"wrap_content"``>`

        `<``TextView`
            `android``:``id`=`"@+id/lblEnter"`
            `android``:``layout_width`=`"wrap_content"`
            `android``:``layout_height`=`"wrap_content"`
            `android``:``text`=`"Enter X:` "
            `android``:``textSize`=`"18sp"``>``<``/``TextView``>`

        `<``EditText`
            `android``:``id`=`"@+id/txtValue"`
            `android``:``layout_width`=`"180dp"`
            `android``:``layout_height`=`"wrap_content"`
            `android``:``inputType`=`"number"`
            `android``:``text`=`"1"``>``<``/``EditText``>`

        `<``Button`
            `android``:``id`=`"@+id/convertButton"`
            `android``:``layout_width`=`"wrap_content"`
            `android``:``layout_height`=`"wrap_content"`
            `android``:``text`=`"Convert"``>`

        `<``/``Button``>`
    `<``/``LinearLayout``>`
`<``/``LinearLayout``>`

在这段代码中需要注意的重要事项是 android:id 字段,特别是对于 EditTextButton。可以更改它们,但如果更改了,稍后在编写代码时需要使用相同的值。我分别称它们为 txtValueconvertButton,因此在代码中注意这些值!

步骤 3. 添加 TensorFlow Lite 依赖项

TensorFlow Lite 并不是 Android API 的一部分,因此当您在 Android 应用中使用它时,需要让环境知道您将导入外部库。在 Android Studio 中,可以通过 Gradle 构建工具实现此目的。此工具允许您通过描述 JSON 文件 build.gradle 来配置您的环境。对于新的 Android 开发者来说,这可能一开始有点令人困惑,因为 Android Studio 实际上提供了两个 Gradle 文件。通常这些被描述为“项目级” build.gradle 和“应用级” build.gradle。第一个文件位于项目文件夹中,后者位于 app 文件夹中(因此它们的名称),正如您可以在图 13-5 中看到的那样。

您需要编辑应用级文件,如图 13-5 中所示。该文件包含应用程序的依赖细节。打开它,并进行两处编辑。首先是在依赖项部分添加一个 implementation 来包含 TensorFlow Lite 库:

implementation 'org.tensorflow:tensorflow-lite:0.0.0-nightly'
注意

您可以在TensorFlow Lite 文档中获取此依赖项的最新版本号。

选择您的 build.gradle 文件

图 13-5. 选择您的 build.gradle 文件

第二个编辑要求您在 android{} 部分内创建一个新的设置,如下所示:

android{
...
    aaptOptions {
        noCompress "tflite"
    }
...
}

此步骤防止编译器压缩您的 .tflite 文件。Android Studio 编译器会编译资源以使其更小,从而减少从 Google Play 商店下载的时间。但是,如果 .tflite 文件被压缩,TensorFlow Lite 解释器将无法识别它。为确保不会被压缩,您需要将 aaptOptions 设置为对 .tflite 文件不进行压缩。如果使用了其他扩展名(有些人只使用 .lite),请确保在此处设置正确。

您现在可以尝试构建您的项目。TensorFlow Lite 库将被下载并链接。

步骤 4. 添加您的 TensorFlow Lite 模型

在第十二章中,您创建了一个非常简单的模型,该模型从一组训练的 X 和 Y 值推断出 Y = 2X – 1,将其转换为 TensorFlow Lite,并将其保存为 .tflite 文件。您需要在此步骤中使用该文件。

首先要做的是在您的项目中创建一个 assets 文件夹。要执行此操作,请在项目资源管理器中导航到 app/src/main 文件夹,右键单击 main 文件夹,然后选择新建文件夹。将其命名为 assets。将在训练模型后下载的 .tflite 文件拖放到该目录中。如果之前没有创建此文件,您可以在本书的GitHub 仓库中找到它。

完成后,项目资源管理器应该看起来像图 13-6。如果assets文件夹尚未具有特殊的资产图标,不要担心;这将在 Android Studio 下一次构建后更新。

将模型添加为资产

图 13-6。将模型添加为资产

现在所有的架构工作都完成了,是时候开始编码了!

第 5 步。编写活动代码以使用 TensorFlow Lite 进行推理

尽管你使用的是 Kotlin,但你的源文件位于java目录中,可以在图 13-6 中看到。打开这个文件夹,你会看到一个包含你的包名的文件夹。在其中,你应该会看到MainActivity.kt文件。双击此文件以在代码编辑器中打开它。

首先,你需要一个帮助函数,从assets目录中加载 TensorFlow Lite 模型:

private fun loadModelFile(assetManager: AssetManager, 
                                        modelPath: String): ByteBuffer {
    val fileDescriptor = assetManager.openFd(modelPath)
    val inputStream = FileInputStream(fileDescriptor.fileDescriptor)
    val fileChannel = inputStream.channel
    val startOffset = fileDescriptor.startOffset
    val declaredLength = fileDescriptor.declaredLength
    return fileChannel.map(FileChannel.MapMode.READ_ONLY, 
                           startOffset, declaredLength)
}

因为.tflite文件实际上是一个包含权重和偏差的压缩二进制数据块,解释器将使用它来构建内部神经网络模型,在 Android 术语中它是一个ByteBuffer。此代码将加载modelPath处的文件并将其作为ByteBuffer返回。

然后,在你的活动中,在类级别(即类声明的下方,不在任何类函数内),你可以添加模型和解释器的声明:

`private` lateinit `var` tflite : `Interpreter`
`private` lateinit `var` tflitemodel : `ByteBuffer`

因此,在这种情况下,执行所有工作的解释器对象将被称为tflite,而你将加载到解释器中作为ByteBuffer的模型将被称为tflitemodel

接下来,在onCreate方法中(当活动创建时调用),添加一些代码来实例化解释器并加载model.tflite到其中:

`try`{
    tflitemodel = loadModelFile(`this`.assets, `"model.tflite"`)
    tflite = `Interpreter`(tflitemodel)
} catch(ex: `Exception`){
    ex.printStackTrace()
}

此外,在onCreate中,还要添加与你将与之交互的两个控件的代码——EditText,在其中你将输入一个值,以及Button,在其中你将按下以进行推理:

`var` convertButton: `Button` = findViewById<`Button`>(R.id.convertButton)
convertButton.setOnClickListener{
    doInference()
}
txtValue = findViewById<`EditText`>(R.id.txtValue)

你还需要在类级别声明EditText以及tflitetflitemodel,因为它将在下一个函数中被引用。你可以用以下方式做到这一点:

`private` lateinit `var` txtValue : `EditText`

最后,是执行推理的时候了。你可以使用一个名为doInference的新函数来完成这个操作:

`private` `fun` doInference(){
}

在这个函数中,你可以从输入中收集数据,将其传递给 TensorFlow Lite 进行推理,然后显示返回的值。

EditText控件,你将在其中输入数字,将提供给你一个字符串,你需要将其转换为浮点数:

`var` userVal: `Float` = txtValue.text.toString().toFloat()

正如你在第十二章中所记得的,当向模型提供数据时,你需要将其格式化为一个 Numpy 数组。由于 Numpy 是一个 Python 构造,不支持在 Android 上使用,但在这种情况下可以使用FloatArray。即使你只传入一个值,它仍然需要在一个数组中,大致相当于一个张量:

`var` inputVal: `FloatArray` = floatArrayOf(userVal)

模型将返回一串字节流,需要进行解释。如您所知,模型输出的是一个浮点值,考虑到一个浮点数占用 4 个字节,您可以设置一个 4 字节的ByteBuffer来接收输出。字节可以按多种方式排序,但您只需使用默认的本机顺序:

`var` outputVal: `ByteBuffer` = `ByteBuffer`.allocateDirect(`4`)
outputVal.order(`ByteOrder`.nativeOrder())

要执行推理,您需要在解释器上调用run方法,传递输入和输出值。然后它将从输入值读取,并将结果写入输出值:

tflite.run(inputVal, outputVal)

输出写入ByteBuffer,其指针现在位于缓冲区的末尾。要读取它,您需要将其重置为缓冲区的开头:

outputVal.rewind()

现在您可以将ByteBuffer的内容作为浮点数读取:

`var` f:`Float` = outputVal.getFloat()

如果您希望将此显示给用户,您可以使用AlertDialog

`val` builder = AlertDialog.Builder(`this`)
with(builder)
{
    setTitle(`"TFLite Interpreter"`)
    setMessage(`"Your Value is:$f"`)
    setNeutralButton(`"OK"`, `DialogInterface`.`OnClickListener` {
        dialog, id -> dialog.cancel()
    })
    show()
}

现在运行应用程序并自行尝试!您可以在图 13-7 中看到结果。

在模拟器中运行解释器

图 13-7. 在模拟器中运行解释器

超越“Hello World”—处理图像

正如您在过去的几页中所看到的,构建 Android 应用涉及大量的脚手架,并且 TensorFlow Lite 解释器需要代码和配置才能正确初始化。现在您已经解决了这个问题,如果您想创建其他使用 TensorFlow Lite 的 Android 应用程序,您将会经历基本相同的过程。您将遇到的唯一主要区别在于,需要以模型理解的方式格式化输入数据,并以相同的方式解析输出数据。因此,例如,在第十二章中,您构建了一个狗与猫模型,允许您输入猫或狗的图像,并得到推理结果。该模型要求输入一张尺寸为 224 × 224 像素、三个颜色通道且已归一化的图像——这就需要弄清楚如何从 Android 图像控件获取图像并进行格式化,以便神经网络能够理解它!

例如,让我们从图 13-8 中的图像开始,这是一张狗的简单图像,尺寸为 395 × 500 像素。

狗的图像进行解释

图 13-8. 狗的图像进行解释

您需要做的第一件事是将其调整为模型训练时的 224 × 224 像素大小。在 Android 中可以使用Bitmap库来完成这个任务。例如,您可以创建一个新的 224 × 224 位图:

`val` scaledBitmap = `Bitmap`.createScaledBitmap(bitmap, 224, 224, `false`)

(在这种情况下,bitmap包含应用程序加载的原始图像资源。完整的应用程序可在书的GitHub 存储库中找到。)

现在,尺寸正确后,你需要协调 Android 中图像的结构与模型期望的结构相符。如果你回忆一下,在本书早些时候训练模型时,你把图像作为归一化的张量值输入。例如,像这样的图像会是(224,224,3):224 × 224 是图像尺寸,3 是颜色深度。这些值也都被归一化到 0 到 1 之间。

所以,总结一下,你需要 224 × 224 × 3 个 0 到 1 之间的浮点值来表示这幅图像。要将它存储在一个ByteArray中,其中 4 个字节组成一个浮点数,你可以使用这段代码:

`val` byteBuffer = `ByteBuffer`.allocateDirect(`4` * `2``2``4` * `2``2``4` * `3`)
byteBuffer.order(`ByteOrder`.nativeOrder())

另一方面,我们的 Android 图像将每个像素存储为一个 32 位整数的 RGB 值。这可能看起来像是对于特定像素的 0x0010FF10。前两个值是透明度,你可以忽略它们,剩下的是 RGB;即 0x10 表示红色,0xFF 表示绿色,0x10 表示蓝色。到目前为止,你一直在做的简单归一化只是将 R、G、B 通道值除以 255,这将给你红色的 0.06275,绿色的 1,蓝色的 0.06275。

所以,为了进行这种转换,让我们首先将我们的位图转换为一个 224 × 224 整数数组,并复制像素。你可以使用getPixels API 来实现这一点:

`val` intValues = `IntArray`(`2``2``4` * `2``2``4`)
scaledbitmap.getPixels(intValues, `0`, `2``2``4`, `0`, `0`, `2``2``4`, `2``2``4`)

现在,你需要遍历这个数组,逐个读取像素并将其转换为归一化的浮点数。你将使用位移来获取特定的通道。例如,考虑之前的值 0x0010FF10。如果你将其向右移动 16 位,你将得到 0x0010(FF10 部分将被“丢弃”)。然后,如果你对其进行 0xFF 的“与”操作,你将得到 0x10,保留底部的两个数字。类似地,如果你向右移动 8 位,你将得到 0x0010FF,并对其进行“与”操作将得到 0xFF。这是一种允许你快速轻松地剥离出组成像素的相关位的技术。你可以在整数上使用shr操作来实现这一点,比如input.shr(16)表示“将输入向右移动 16 个像素”:

`var` pixel = `0`
`for` (i `in` `0` until INPUT`_`SIZE) {
    `for` (j `in` `0` until INPUT`_`SIZE) {
        `val` input = intValues[pixel++]
        byteBuffer.putFloat(((input.shr(`1``6`)  and `0``xFF`) / 255))
        byteBuffer.putFloat(((input.shr(`8`) and `0``xFF`) / 255))
        byteBuffer.putFloat(((input and `0``xFF`)) / 255))
    }
}

与之前一样,当涉及到输出时,你需要定义一个数组来保存结果。它不一定必须是一个ByteArray;实际上,如果你知道结果通常是浮点数,你可以定义类似于FloatArray的东西。在这种情况下,使用猫狗模型,你有两个标签,模型架构在输出层定义了两个神经元,包含了类别猫和狗的相应属性。因此,为了读取结果,你可以定义一个结构来包含类似于这样的输出张量:

`val` result = `Array`(`1`) { `FloatArray`(`2`) }

请注意,它是一个包含两个项目数组的单一数组。回想一下,当使用 Python 时,你可能会看到像[[1.0 0.0]]这样的值——这里是一样的。Array(1)定义了包含数组[],而FloatArray(2)[1.0 0.0]。这可能有点令人困惑,但希望你在编写更多 TensorFlow 应用时能习惯!

与之前一样,你可以使用interpreter.run来解释:

interpreter.run(byteBuffer, result)

现在,您的结果将是一个包含两个值数组的数组。您可以在 Android 调试器中看到它的样子,如图 13-8 所示。

解析输出值

图 13-9. 解析输出值

在创建 Android 移动应用程序时,这是除了创建模型之外,您必须考虑的最复杂部分。Python 如何表示值,特别是使用 Numpy,可能与 Android 的方式非常不同。您将不得不创建转换器,以重新格式化数据,使其适应神经网络期望的数据输入,并且您必须了解神经网络使用的输出模式,以便解析结果。

TensorFlow Lite 示例应用程序

TensorFlow 团队提供了许多开源示例应用程序,您可以解析这些应用程序,从而了解它们如何从本章中构建的基础上工作。它们包括(但不限于)以下内容:

图像分类

从设备摄像头读取输入并对多达一千种不同的物品进行分类。

对象检测

从设备摄像头读取输入,并为检测到的对象提供边界框。

姿势估计

查看摄像机中的图像并推断它们的姿势。

语音识别

识别常见的口头命令。

手势识别

为手势训练模型并在摄像头中识别它们。

智能回复

接收输入消息并生成回复。

图像分割

类似于对象检测,但预测图像中每个像素属于哪个类。

风格转移

将新的艺术风格应用于任何图像。

数字分类器

识别手写数字。

文本分类

使用在 IMDb 数据集上训练的模型,识别文本中的情感。

问答

使用双向编码器表示转换(BERT),自动回答用户查询!

您可以在Awesome TFLite repo的 GitHub 上找到另一个经过精心筛选的应用程序列表。

摘要

在本章中,您体验了如何在 Android 上使用 TensorFlow Lite。您了解了 Android 应用程序的结构以及如何将 TensorFlow Lite 集成到其中。您学会了如何将模型实现为 Android 资产,以及如何加载和在解释器中使用它。最重要的是,您了解到需要将基于 Android 的数据(如图像或数字)转换为模型中使用的输入数组,并学会如何解析输出数据,认识到它也是在ByteBuffer中有效映射的张量。您详细了解了几个示例,展示了如何做到这一点,希望这使您能够处理其他场景。在下一章中,您将再次执行此操作,但这次是在使用 Swift 的 iOS 上。

第十五章:第二步是将 TensorFlow Lite 添加到它中去。

在 Xcode 中创建一个新的 iOS 应用程序

如果您想要按照本章的示例进行操作,您需要一台 Mac 电脑,因为开发工具 Xcode 仅在 Mac 上可用。如果您还没有安装它,可以从 App Store 安装。它将为您提供一切所需,包括 iOS 模拟器,您可以在其中运行 iPhone 和 iPod 应用程序,而无需物理设备。

使用 Xcode 创建您的第一个 TensorFlow Lite 应用程序

第十二章向您介绍了 TensorFlow Lite 及如何将 TensorFlow 模型转换为可在移动设备上使用的高效紧凑格式。在第十三章中,您将探索创建使用 TensorFlow Lite 模型的 Android 应用程序。在本章中,您将使用 iOS 执行相同的操作,创建几个简单的应用程序,并了解如何使用 Swift 编程语言对 TensorFlow Lite 模型进行推断。

图 14-1。在 Xcode 中创建一个新的 iOS 应用程序

选择您的新项目的选项

之后,您将被要求选择您的新项目的选项,包括应用程序的名称。称其为firstlite,确保语言是 Swift,用户界面是 Storyboard(见图 14-2)。

要向 iOS 项目添加依赖项,您可以使用一种称为CocoaPods的技术,这是一个具有数千个库的依赖管理项目,可以轻松集成到您的应用程序中。为此,您需要创建一个称为 Podfile 的规范文件,其中包含有关您的项目及您想要使用的依赖项的详细信息。这是一个简单的文本文件Podfile(无扩展名),您应该将其放在与 Xcode 为您创建的firstlite.xcodeproj文件相同的目录中。其内容如下:

打开 Xcode 并选择文件 → 新建项目。您将被要求选择新项目的模板。选择 Single View App,这是最简单的模板(见图 14-1),然后点击下一步。

图 14-2。选择您的新项目的选项

第十四章。在 iOS 应用程序中使用 TensorFlow Lite

点击“下一步”以创建一个基本的 iOS 应用程序,可以在 iPhone 或 iPad 模拟器上运行。

步骤 1。创建一个基本的 iOS 应用程序

步骤 2。将 TensorFlow Lite 添加到您的项目中

# Uncomment the next line to define a global platform for your project
`platform` `:``ios``,` `'``12.0``'`

`target` `'``firstlite``'` `do`
  # Comment the next line if you're not using Swift and don't want to 
  # use dynamic frameworks
 `use_frameworks``!`

  # Pods for ImageClassification
 `pod` `'``TensorFlowLiteSwift``'`
`end`

关键部分是这一行 pod 'TensorFlowLiteSwift',表示需要将 TensorFlow Lite Swift 库添加到项目中。

接下来,使用终端切换到包含 Podfile 的目录,并执行以下命令:

pod install

依赖项将被下载并添加到项目中,存储在名为 Pods 的新文件夹中。您还将添加一个 .xcworkspace 文件,如 图 14-3 所示。将来使用这个文件打开项目,而不是 .xcodeproj 文件。

运行 pod install 后的文件结构

图 14-3. 运行 pod install 后的文件结构

您现在拥有一个基本的 iOS 应用程序,并已添加了 TensorFlow Lite 依赖项。下一步是创建用户界面。

第 3 步. 创建用户界面

Xcode 故事板编辑器是一个可视化工具,允许您创建用户界面。打开工作区后,您将在左侧看到一列源文件。选择 Main.storyboard,并使用控件面板,您可以将控件拖放到 iPhone 屏幕的视图上(见 图 14-4)。

向故事板添加控件

图 14-4. 向故事板添加控件

如果找不到控件面板,可以通过点击屏幕右上角的 + 号(在 图 14-4 中突出显示)来访问它。使用它,添加一个标签,并将文本更改为“输入数字”。然后再添加一个文本,“结果在这里”。添加一个按钮,并将其标题更改为“Go”,最后添加一个文本字段。将它们排列得与 图 14-4 中看到的类似即可。它不必漂亮!

现在,控件已布局完成,您希望能够在代码中引用它们。在故事板术语中,您可以使用 outlets(当您希望访问控件以读取或设置其内容时)或 actions(当您希望在用户与控件交互时执行某些代码时)来实现这一点。

最简单的连接方法是将屏幕分成两部分,一边是故事板,另一边是支持其下的 ViewController.swift 代码。您可以通过选择分屏控制(在 图 14-5 中突出显示),点击一侧选择故事板,然后点击另一侧选择 ViewController.swift 来实现这一点。

分割屏幕

图 14-5. 分割屏幕

完成后,你可以通过拖放开始创建输出口和动作。在这个应用中,用户在文本字段中输入数字,点击 Go,然后对其值进行推断。结果将呈现在标签上,标签上写着“结果在这里”。

这意味着你需要读取或写入两个控件,从文本字段读取用户输入的内容,并将结果写入“结果显示区”标签。因此,你需要两个 outlet。要创建它们,按住 Ctrl 键,将控件从 storyboard 拖动到 ViewController.swift 文件中,并将其放置在类定义的正下方。将会出现一个弹出窗口要求你定义它(图 14-6)。

创建一个 outlet

图 14-6. 创建一个 outlet

确保连接类型为 Outlet,并创建一个文本字段的 outlet,名为 txtUserData,以及一个标签的 outlet,名为 txtResult

接下来,将按钮拖到 ViewController.swift 文件中。在弹出窗口中,确保连接类型为 Action,事件类型为 Touch Up Inside。使用此操作定义一个名为 btnGo 的 action(图 14-7)。

添加一个 action

图 14-7. 添加一个 action

此时你的 ViewController.swift 文件应该看起来像这样—注意 IBOutletIBAction 的代码:

`import` `UIKit`

`class` `ViewController`: `UIViewController` {
    @`IBOutlet` `weak` `var` txtUserData: `UITextField`!

    @`IBOutlet` `weak` `var` txtResult: `UILabel`!
    @`IBAction` `func` btnGo(`_` sender: `Any`) {
    }
    `override` `func` viewDidLoad() {
        `super`.viewDidLoad()
        `/``/` `Do` `any` `additional` `setup` `after` `loading` `the` `view``.`
    }
}

现在 UI 部分已经准备好,接下来的步骤将是创建处理推理的代码。不将其放在与 ViewController 逻辑相同的 Swift 文件中,而是放在一个单独的代码文件中。

步骤 4. 添加并初始化模型推理类

为了将 UI 与底层模型推理分离,你将创建一个新的 Swift 文件,其中包含一个名为 ModelParser 的类。这里将完成将数据输入模型、运行推理,然后解析结果的所有工作。在 Xcode 中,选择文件 → 新建文件,并选择 Swift 文件作为模板类型(图 14-8)。

添加一个新的 Swift 文件

图 14-8. 添加一个新的 Swift 文件

将其命名为 ModelParser,确保选中将其定向到 firstlite 项目的复选框(图 14-9)。

将 ModelParser.swift 添加到你的项目中

图 14-9. 将 ModelParser.swift 添加到你的项目中

这将在你的项目中添加一个 ModelParser.swift 文件,你可以编辑它以添加推理逻辑。首先确保文件顶部的导入包括 TensorFlowLite

`import`  Foundation
`import`  TensorFlowLite

将模型文件的引用传递给这个类,model.tflite,你还没有添加它,但很快会添加:

`typealias`  FileInfo `=` `(``name``:` String`,` `extension``:` String`)`

`enum`  ModelFile `{`
  `static`  `let` `modelInfo``:` FileInfo `=` `(``name``:` `"``model``"``,` `extension``:` `"``tflite``"``)`
`}`

typealiasenum 使得代码更加紧凑。稍后你将看到它们的使用。接下来,你需要将模型加载到解释器中,因此首先将解释器声明为类的私有变量:

`private` `var` interpreter: `Interpreter`

Swift 要求变量进行初始化,您可以在init函数中完成此操作。下面的函数将接受两个输入参数。第一个是您刚刚声明的FileInfo类型的modelFileInfo。第二个是要用于初始化解释器的线程数threadCount,我们将其设置为1。在此函数中,您将创建对先前描述的模型文件的引用(model.tflite):

`init`?(modelFileInfo: `FileInfo`, threadCount: `Int` = `1`) {
 `let` modelFilename = modelFileInfo.name

  `guard` `let` modelPath = `Bundle`.main.path
  (
    forResource: modelFilename,
    ofType: modelFileInfo.`extension`
 `)`
  `else` {
    print(`"``Failed to load the model file``"`)
    `return` `nil`
  }

一旦获取了捆绑中模型文件的路径,即可加载它:

`do`
  {
    interpreter = `try` `Interpreter`(modelPath: modelPath)
  }
  `catch` `let` error
  {
    print(`"``Failed to create the interpreter``"`)
 `return` `nil`
  }

第 5 步。执行推断

ModelParser类中,您可以进行推断。用户将在文本字段中键入一个字符串值,该值将转换为浮点数,因此您需要一个函数,接受一个浮点数,将其传递给模型,运行推断并解析返回值。

首先创建一个名为runModel的函数。您的代码需要捕获错误,因此以do{开头:

`func` runModel(withInput input: `Float`) -> `Float`? {
    `do`{

接下来,您需要在解释器上分配张量。这将初始化并准备好进行推断:

    `try` interpreter.allocateTensors()

然后,您将创建输入张量。由于 Swift 没有Tensor数据类型,您需要直接将数据写入UnsafeMutableBufferPointer中的内存。您可以指定其类型为Float,并写入一个值(因为只有一个浮点数),从名为data的变量的地址开始。这将有效地将浮点数的所有字节复制到缓冲区中:

    `var` data: `Float` = input
      `let` buffer: `UnsafeMutableBufferPointer`<`Float`> = 
 `UnsafeMutableBufferPointer`(start: &data, count: `1`)

有了缓冲区中的数据,您可以将其复制到输入 0 处的解释器中。因为只有一个输入张量,所以可以将其指定为缓冲区:

    `try` interpreter.copy(`Data`(buffer: buffer), toInputAt: `0`)

要执行推断,您需要调用解释器:

    `try` interpreter.invoke()

只有一个输出张量,因此可以通过获取索引为 0 的输出来读取它:

    `let` outputTensor = `try` interpreter.output(at: `0`)

类似于输入值时处理低级内存,这是不安全的数据。它是由Float32值的数组组成(虽然只有一个元素,但仍需视为数组),可以像这样读取它:

    `let` results: [`Float32`] = 
          `Float32` ?? []

如果您不熟悉??语法,这意味着将输出张量复制到Float32数组中,并在失败时使其成为空数组。为使此代码正常工作,您需要实现一个Array扩展;稍后将显示其完整代码。

一旦将结果放入数组中,第一个元素将是您的结果。如果失败,只需返回nil

    `guard` `let` result = results.first `else` {
        `return` `nil`
      }
      `return` result
    }

函数以do{开头,因此您需要捕获任何错误,将其打印出来,并在这种情况下返回nil

  `catch` {
      print(error)
      `return` `nil`
    }
  }
}

最后,在ModelParser.swift中,您可以添加处理不安全数据并将其加载到数组中的Array扩展:

extension  `Array` `{`
  init`?``(``unsafeData``:` `Data``)` `{`
  guard `unsafeData``.``count` `%` `MemoryLayout``<``Element``>``.``stride` `==` `0`
  else `{` return  `nil` `}`
 `#`if `swift(>=``5``.``0``)`
  self `=` `unsafeData``.``withUnsafeBytes` `{`
 `.`init`(``$``0``.``bindMemory``(``to``:` `Element``.`self`)``)`
 `}`
 `#`else
  self `=` `unsafeData``.``withUnsafeBytes` `{`
 `.`init`(``UnsafeBufferPointer``<``Element``>``(`
 `start``:` `$``0``,`
 `count``:` `unsafeData``.``count` `/` `MemoryLayout``<``Element``>``.``stride`
 `)``)`
 `}`
 `#endif` `// swift(>=5.0)`
 `}`
`}`

如果您想直接从 TensorFlow Lite 模型中解析浮点数,这是一个方便的帮助器。

现在,解析模型的类已经完成,下一步是将模型添加到您的应用程序中。

第 6 步。将模型添加到您的应用程序中

要将模型添加到应用程序中,您需要在应用程序中创建一个models目录。在 Xcode 中,右键单击firstlite文件夹,选择新建组(图 14-10)。将新组命名为models

向您的应用程序添加新组

图 14-10. 向应用程序添加新组

您可以通过训练来自第十二章的简单 Y = 2X - 1 示例来获取模型。如果您尚未拥有它,可以使用该书的 GitHub 仓库中的 Colab。

一旦您转换了模型文件(名为model.tflite),您可以将其拖放到刚刚添加的模型组中。选择“需要时复制项目”,并确保将其添加到目标firstlite中,勾选其旁边的框(图 14-11)。

将模型添加到您的项目中

图 14-11. 将模型添加到您的项目中

模型现在将会在您的项目中,并可用于推断。最后一步是完成用户界面逻辑——然后您就可以开始了!

步骤 7. 添加 UI 逻辑

之前,您创建了包含 UI 描述的 storyboard,并开始编辑包含 UI 逻辑的ViewController.swift文件。由于推断的大部分工作现在已经被转移到ModelParser类中,因此 UI 逻辑应该非常轻量级。

首先,通过添加一个私有变量来声明ModelParser类的实例:

`private` `var` modelParser: `ModelParser`? =
    `ModelParser`(modelFileInfo: `ModelFile`.modelInfo)

以前,您在名为btnGo的按钮上创建了一个动作。当用户触摸该按钮时,将更新为执行名为doInference的函数:

@`IBAction` `func` btnGo(`_` sender: `Any`) {
  doInference()
}

接下来,您将构建doInference函数:

`private` `func` doInference() {

用户将输入数据的文本字段称为txtUserData。读取此值,如果为空,则将结果设置为0.00,并且不进行任何推断操作:

`guard` `let` text = txtUserData.text, text.count > `0` `else` {
    txtResult.text = `"``0.00``"`
    `return`
  }

否则,请将其转换为浮点数。如果转换失败,请退出该函数:

`guard` `let` value = `Float`(text) `else` {
    `return`
  }

如果代码已经到达这一点,现在可以运行模型,将输入传递给它。ModelParser将会做剩下的工作,并返回一个结果或nil。如果返回值为nil,则将退出该函数:

`guard` `let` result = `self`.modelParser?.runModel(withInput: value) `else` {
    `return`
  }

最后,如果您已经到达这一步,那么您就有了一个结果,因此可以通过将浮点数格式化为字符串加载到标签(称为txtResult)中:

txtResult.text = `String`(format: `"``%.2f``"`, result)

就这样!模型加载和推断的复杂性由ModelParser类处理,使得您的ViewController非常轻量级。为方便起见,这里是完整的清单:

`import` `UIKit`

`class` `ViewController`: `UIViewController` {
  `private` `var` modelParser: `ModelParser`? =
      `ModelParser`(modelFileInfo: `ModelFile`.modelInfo)
  @`IBOutlet` `weak` `var` txtUserData: `UITextField`!

  @`IBOutlet` `weak` `var` txtResult: `UILabel`!
  @`IBAction` `func` btnGo(`_` sender: `Any`) {
    doInference()
  }
  `override` `func` viewDidLoad() {
    `super`.viewDidLoad()
    `/``/` `Do` `any` `additional` `setup` `after` `loading` `the` `view``.`
  }
  `private` `func` doInference() {

    `guard` `let` text = txtUserData.text, text.count > `0` `else` {
      txtResult.text = `"``0.00``"`
      `return`
    }
    `guard` `let` value = `Float`(text) `else` {
      `return`
    }
    `guard` `let` result = `self`.modelParser?.runModel(withInput: value) `else` {
      `return`
    }
    txtResult.text = `String`(format: `"``%.2f``"`, result)
  }

}

您现在已经完成了使应用程序工作所需的所有操作。运行它,您应该在模拟器中看到它。在文本字段中输入一个数字,按下按钮,您应该在结果字段中看到一个结果,如图 14-12 所示。

在 iPhone 模拟器中运行应用程序

图 14-12. 在 iPhone 模拟器中运行应用程序

尽管这对于一个非常简单的应用程序来说是一个漫长的旅程,但它应该提供了一个很好的模板,帮助您理解 TensorFlow Lite 的工作原理。在本教程中,您看到了如何:

  • 使用 pods 添加 TensorFlow Lite 依赖项。

  • 向您的应用程序添加 TensorFlow Lite 模型。

  • 将模型加载到解释器中。

  • 访问输入张量,并直接将其写入内存。

  • 从输出张量中读取内存,并将其复制到像浮点数组这样的高级数据结构中。

  • 通过 storyboard 和视图控制器将所有内容连接到用户界面。

在下一节中,您将超越这个简单的场景,看看如何处理更复杂的数据。

超越“Hello World”—处理图像

在前面的示例中,您看到了如何创建一个完整的应用程序,使用 TensorFlow Lite 进行非常简单的推断。但是,尽管应用程序很简单,将数据输入模型并解析模型输出的过程可能有点不直观,因为您在处理低级的位和字节。随着您涉及更复杂的情况,如管理图像,好消息是这个过程并不会变得太复杂。

考虑您在第十二章中创建的 Dogs vs. Cats 模型。在本节中,您将看到如何使用训练有素的模型创建一个 Swift iOS 应用程序,该应用程序可以根据猫或狗的图像推断出图像中的内容。该书的完整应用代码可以在 GitHub 仓库 中找到。

首先,回想一下图像的张量有三个维度:宽度、高度和颜色深度。例如,当使用基于 Dogs vs. Cats 移动样本的 MobileNet 架构时,尺寸为 224 × 224 × 3 ——每个图像为 224 × 224 像素,并且颜色深度为 3 字节。请注意,每个像素由介于 0 和 1 之间的值表示,指示该像素在红色、绿色和蓝色通道上的强度。

在 iOS 中,图像通常表示为 UIImage 类的实例,该类具有一个有用的 pixelBuffer 属性,返回图像中所有像素的缓冲区。

CoreImage 库中,有一个 CVPixelBufferGetPixelFormatType API,可以返回像素缓冲区的类型:

`let` sourcePixelFormat = `CVPixelBufferGetPixelFormatType`(pixelBuffer)

这通常是一个带有 alpha(即透明度)、红色、绿色和蓝色通道的 32 位图像。但是,有多种变体,通常这些通道的顺序不同。您需要确保它是这些格式之一,否则如果图像存储在不同的格式中,则其余代码将无法工作:

assert(sourcePixelFormat == kCVPixelFormatType`_32`ARGB ||
  sourcePixelFormat == kCVPixelFormatType`_32`BGRA ||
  sourcePixelFormat == kCVPixelFormatType`_32`RGBA)

因为期望的格式是 224 × 224,即正方形,下一步最好的做法是裁剪图像到其中心的最大正方形,使用 centerThumbnail 属性,然后将其缩小到 224 × 224:

`let` scaledSize = `CGSize`(width: `224`, height: `224`)
`guard` `let` thumbnailPixelBuffer = 
    pixelBuffer.centerThumbnail(ofSize: scaledSize) 
`else` {
  `return` `nil`
}

现在您已将图像调整大小为 224 × 224,下一步是删除 alpha 通道。请记住,该模型是在 224 × 224 × 3 上训练的,其中 3 是 RGB 通道,因此没有 alpha 通道。

现在你有了一个像素缓冲区,需要从中提取 RGB 数据。这个辅助函数通过查找 alpha 通道并切片来帮助你实现这一点:

`private` `func` rgbDataFromBuffer(`_` buffer: `CVPixelBuffer`,
                                byteCount: `Int`) -> `Data`? {

  `CVPixelBufferLockBaseAddress`(buffer, .readOnly)
  `defer` { `CVPixelBufferUnlockBaseAddress`(buffer, .readOnly) }
  `guard` `let` mutableRawPointer = 
 `CVPixelBufferGetBaseAddress`(buffer) 
 `else` {
 `return` `nil`
  }

 `let` count = `CVPixelBufferGetDataSize`(buffer)
  `let` bufferData = `Data`(bytesNoCopy: mutableRawPointer,
                          count: count, deallocator: .`none`)

  `var` rgbBytes = `Float`
  `var` index = `0`

  `for` component `in` bufferData.enumerated() {
    `let` offset = component.offset
    `let` isAlphaComponent = (offset % alphaComponent.baseOffset) == 
     alphaComponent.moduloRemainder

    `guard` !isAlphaComponent `else` { `continue` }

     rgbBytes[index] = `Float`(component.element) / `255`.`0`
    index += `1`
  }

  `return` rgbBytes.withUnsafeBufferPointer(`Data`.`init`)

}

此代码使用名为Data的扩展,将原始字节复制到数组中:

`extension` `Data` {
  `init`<T>(copyingBufferOf array: [T]) {
    `self` = array.withUnsafeBufferPointer(`Data`.`init`)
  }
}

现在你可以将刚刚创建的缩略图像素缓冲区传递给rgbDataFromBuffer

`guard` `let` rgbData = rgbDataFromBuffer(
    thumbnailPixelBuffer,
    byteCount: `224` * `224` * `3`
    ) 
`else` {
  print(`"``Failed to convert the image buffer to RGB data.``"`)
  `return` `nil`
}

现在你有了模型期望格式的原始 RGB 数据,你可以直接复制到输入张量中:

`try` interpreter.allocateTensors()
`try` interpreter.copy(rgbData, toInputAt: `0`)

然后,你可以调用解释器并读取输出张量:

`try` interpreter.invoke()
outputTensor = `try` interpreter.output(at: `0`)

在狗与猫的情况下,输出是一个包含两个值的浮点数组,第一个值表示图像是猫的概率,第二个值表示图像是狗的概率。这与之前看到的结果代码相同,并使用了上一个示例中的相同Array扩展:

`let` results = `Float32` ?? []

正如你所见,尽管这是一个更复杂的示例,但相同的设计模式仍然适用。你必须了解模型的架构,以及原始输入和输出格式。然后,你必须按照模型期望的方式结构化输入数据,通常意味着获取写入缓冲区的原始字节,或者至少模拟使用数组。接着,你必须读取模型输出的原始字节流,并创建一个数据结构来保存它们。从输出的角度来看,这几乎总是类似于本章中看到的一样——一个浮点数数组。有了你实现的辅助代码,你已经完成了大部分工作!

TensorFlow Lite 示例应用

TensorFlow 团队已经构建了大量的示例应用,并不断增加。掌握本章学到的知识,你将能够探索这些应用并理解它们的输入/输出逻辑。截至撰写本文时,iOS 平台上有以下示例应用:

图像分类

读取设备的摄像头,并对多达一千种不同物品进行分类。

物体检测

读取设备的摄像头,并为检测到的物体提供边界框。

姿势估计

查看摄像头中的图像,并推断它们的姿势。

语音识别

识别常见的口头命令。

手势识别

为手势训练一个模型,并在摄像头中识别它们。

图像分割

类似于物体检测,但预测图像中每个像素属于哪个类别。

数字分类器

识别手写数字。

总结

在本章中,您学习了如何将 TensorFlow Lite 整合到 iOS 应用程序中,通过全面的步骤演示构建一个简单应用程序,使用解释器调用模型进行推理。特别是,您看到处理模型时必须对数据进行低级处理,确保您的输入与模型预期的匹配。您还看到了如何解析模型输出的原始数据。这只是将机器学习引入 iOS 用户手中的长期而有趣的旅程的开端。在下一章中,我们将摆脱原生移动开发,看看如何使用 TensorFlow.js 在浏览器上训练和运行模型推理。

第十五章:TensorFlow.js 简介

除了 TensorFlow Lite,它可以在原生移动或嵌入式系统上运行,TensorFlow 生态系统还包括 TensorFlow.js,它允许您使用流行的 JavaScript 语言在浏览器中直接开发 ML 模型,或在 Node.js 上的后端使用。它允许您训练新模型并对其进行推断,并包括工具,可让您将基于 Python 的模型转换为与 JavaScript 兼容的模型。在本章中,您将介绍 TensorFlow.js 如何适应整体生态系统以及其架构的概述,并学习如何使用一个与浏览器集成的免费开源 IDE 构建您自己的模型。

什么是 TensorFlow.js?

TensorFlow 生态系统总结在图 15-1 中。它包括一套用于训练模型的工具,一个用于预先存在模型和层的存储库,以及一组技术,允许您为最终用户部署模型以获益。

与 TensorFlow Lite(第 12–14 章)和 TensorFlow Serving(第十九章)类似,TensorFlow.js 主要位于图表的右侧,因为虽然它主要用作模型的运行时,但也可用于训练模型,并且在这项任务中应被视为一流语言,与 Python 和 Swift 并驾齐驱。TensorFlow.js 可以在浏览器中运行或在像 Node.js 这样的后端上运行,但出于本书的目的,我们将主要关注浏览器。

TensorFlow 生态系统

图 15-1. TensorFlow 生态系统

TensorFlow.js 如何通过浏览器进行训练和推断的架构显示在图 15-2 中。

TensorFlow.js 高级架构

图 15-2. TensorFlow.js 高级架构

作为开发者,您通常会使用 Layers API,在 JavaScript 中提供类似 Keras 的语法,使您可以在 JavaScript 中使用您在本书开头学到的技能。这是由 Core API 支持的,正如其名称所示,在 JavaScript 中提供核心 TensorFlow 功能。除了为 Layers API 提供基础外,它还通过转换工具包允许您重用现有的基于 Python 的模型,将其转换为基于 JSON 的格式以便轻松消费。

核心 API 可以在 Web 浏览器中运行,并利用基于 WebGL 的 GPU 加速,或者在 Node.js 上运行,在这种环境配置下,除了 CPU 外,还可以利用 TPU 或 GPU 加速。

如果你不熟悉 HTML 或 JavaScript 的网页开发,不要担心;本章将作为入门指南,为你提供足够的背景帮助你构建你的第一个模型。虽然你可以使用任何你喜欢的 web/JavaScript 开发环境,但我推荐一款称为Brackets的新用户。在下一节中,你将看到如何安装它并使其运行,之后你将构建你的第一个模型。

安装和使用 Brackets IDE

Brackets 是一个免费的开源文本编辑器,非常适合网页开发者,特别是新手,因为它与浏览器集成得非常好,允许你本地提供文件以便测试和调试。通常,在设置网页开发环境时,这是棘手的部分。编写 HTML 或 JavaScript 代码很容易,但如果没有服务器将它们提供给浏览器,要真正测试和调试它们是困难的。Brackets 可在 Windows、Mac 和 Linux 上使用,所以无论你使用哪种操作系统,体验应该是类似的。对于本章,我在 Mint Linux 上试用了它,效果非常好!

安装并运行 Brackets 后,你会看到类似于图 15-3 的入门页面。在右上角,你会看到一个闪电图标。

Brackets 欢迎页面

图 15-3. Brackets 欢迎页面

点击它,你的网页浏览器将启动。当你在 Brackets 中编辑 HTML 代码时,浏览器将实时更新。例如,如果你修改第 13 行的代码:

`<h1``>`GETTING STARTED WITH BRACKETS`</h1>`

切换到其他东西,比如:

`<h1``>`Hello, TensorFlow Readers!`</h1>`

你会看到浏览器中的内容实时更改以匹配你的编辑,如图 15-4 所示。

浏览器中的实时更新

图 15-4. 浏览器中的实时更新

我发现这对于在浏览器中进行 HTML 和 JavaScript 开发非常方便,因为它让环境尽量不干扰你,让你可以专注于代码。尤其是在机器学习等许多新概念中,这是非常宝贵的,因为它帮助你在没有太多干扰的情况下工作。

在入门页面上,你会注意到你只是在一个普通的目录中工作,Brackets 从中提供文件。如果你想使用自己的目录,只需在文件系统中创建一个目录并打开它。你在 Brackets 中创建的新文件将从那里创建和运行。确保它是你有写访问权限的目录,这样你就可以保存你的工作!

现在你已经搭建好开发环境,是时候在 JavaScript 中创建你的第一个机器学习模型了。我们将回到我们的“Hello World”场景,训练一个能够推断两个数字之间关系的模型。如果你从头开始阅读这本书,你可能已经多次看到这个模型,但它仍然是一个有用的模型,可以帮助你理解在 JavaScript 编程时需要考虑的语法差异!

构建你的第一个 TensorFlow.js 模型

在浏览器中使用 TensorFlow.js 之前,你需要将 JavaScript 托管在一个 HTML 文件中。创建一个文件,并使用以下基本 HTML 结构填充它:

<html>
<head></head>
<body>
  <h1>`First HTML Page`</h1>
</body>
</html>

然后,在 <head> 部分和 <body> 标签之前,你可以插入一个 <script> 标签,指定 TensorFlow.js 库的位置:

<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest"></script>

如果现在运行页面,TensorFlow.js 将被下载,但你不会看到任何影响。

接下来,在第一个 <script> 标签的下方立即添加另一个 <script> 标签。在其中,你可以创建一个模型定义。请注意,虽然它与在 Python 中使用 TensorFlow 的方式非常相似(详细信息请参阅第一章),但也有一些差异。例如,在 JavaScript 中,每一行都以分号结束。还有,诸如 model.addmodel.compile 这类函数的参数使用 JSON 表示法。

这个模型是熟悉的“Hello World”模型,由单个神经元组成的单层。它将使用均方误差作为损失函数,并使用随机梯度下降作为优化器进行编译:

<script lang=`"js"`>        
    `const` model = tf.sequential();
    model.add(tf.layers.dense({units: `1`, inputShape: [`1`]}));
    model.compile({loss:`'meanSquaredError'`, optimizer:`'sgd'`});

接下来,你可以添加数据。这与 Python 有些不同,Python 使用 Numpy 数组。当然,在 JavaScript 中这些不可用,所以你将使用 tf.tensor2d 结构。它很接近,但有一个关键的区别:

const xs = tf.tensor2d([-1.0, 0.0, 1.0, 2.0, 3.0, 4.0], [6, 1]);
const ys = tf.tensor2d([-3.0, -1.0, 2.0, 3.0, 5.0, 7.0], [6, 1]);

注意,除了值列表外,你还有第二个数组,用于定义第一个数组的形状。因此,你的 tensor2d 初始化为一个 6 × 1 的值列表,后跟一个包含 [6,1] 的数组。如果你要输入七个值,则第二个参数将是 [7,1]

要进行训练,你可以创建一个名为 doTraining 的函数。这将使用 model.fit 训练模型,并且与之前一样,它的参数将以 JSON 列表的格式进行格式化:

  `a``s``y``n``c` `f``u``n``c``t``i``o``n` doTraining(model){
    `c``o``n``s``t` history = 
        `a``w``a``i``t` model.fit(xs, ys, 
                        { epochs: `5``0``0`,
                          callbacks:{
                              onEpochEnd: `a``s``y``n``c`(epoch, logs) =>{
                                  console.log(`"Epoch:"` 
                                              + epoch 
                                              + `" Loss:"` 
                                              + logs.loss);
                                  }
                              }
                        });
}

这是一个异步操作——训练会花费一段时间——因此最好将其创建为异步函数。然后,你可以await model.fit,将 epochs 数作为参数传递进去。你也可以指定一个回调函数,在每个 epoch 结束时输出损失。

最后要做的就是调用这个 doTraining 方法,将模型传递给它,并在训练完成后报告结果:

  doTraining(model).then(() => {
    alert(model.predict(tf.tensor2d([`10`], [`1`,`1`])));
});

这会调用 model.predict,将单个值传递给它以获取预测结果。因为它使用了一个 tensor2d 以及要预测的值,你还必须传递第二个参数,这个参数是第一个数组的形状。所以要预测 10 的结果,你创建一个包含该值的数组的 tensor2d,然后传递该数组的形状。

为方便起见,这里是完整的代码:

<html>
<head></head>
<script src=`"https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest"`></script>
<script lang=`"js"`>
    `async` `function` doTraining(model){
        `const` history = 
            `await` model.fit(xs, ys, 
                            { epochs: `500`,
                              callbacks:{
                                  onEpochEnd: `async`(epoch, logs) =>{
                                      console.log(`"Epoch:"` 
                                                  + epoch 
                                                  + `" Loss:"` 
                                                  + logs.loss);

                                  }
                              }
                            });
    }
    `const` model = tf.sequential();
    model.add(tf.layers.dense({units: `1`, inputShape: [`1`]}));
    model.compile({loss:`'meanSquaredError'`, 
                   optimizer:`'sgd'`});
    model.summary();
    `const` xs = tf.tensor2d([-`1.0`, `0.0`, `1.0`, `2.0`, `3.0`, `4.0`], [`6`, `1`]);
    `const` ys = tf.tensor2d([-`3.0`, -`1.0`, `2.0`, `3.0`, `5.0`, `7.0`], [`6`, `1`]);
    doTraining(model).then(() => {
        alert(model.predict(tf.tensor2d([`10`], [`1`,`1`])));
    });
</script>
<body>
    <h1>`First` HTML `Page`</h1>
</body>
</html>

当你运行这个页面时,看起来好像什么都没有发生。等待几秒钟,然后会出现一个对话框,类似于图 15-5 中显示的那个。这是一个警告对话框,显示了对 [10] 的预测结果。

训练后推理的结果

图 15-5. 训练后推理的结果

在对话框显示之前有那么长时间的停顿可能会让人感到有些不安 —— 正如你可能已经猜到的那样,模型在那段时间内正在训练。回想一下,在 doTraining 函数中,你创建了一个回调函数,将每个时期的损失写入控制台日志中。如果你想看到这个,可以使用浏览器的开发者工具来查看。在 Chrome 中,你可以通过点击右上角的三个点,选择“更多工具” → “开发者工具”,或者按 Ctrl-Shift-I 来访问这些工具。

一旦你拥有了它们,选择窗格顶部的控制台并刷新页面。当模型重新训练时,你将看到每个时期的损失(见图 15-6)。

在浏览器的开发者工具中探索每个时期的损失

图 15-6. 在浏览器的开发者工具中探索每个时期的损失

现在你已经完成了第一个(也是最简单的)模型,准备构建一个稍微复杂一点的东西。

创建一个鸢尾花分类器

最后一个例子非常简单,所以接下来让我们来处理一个稍微复杂一点的例子。如果你做过任何与机器学习相关的工作,你可能听说过鸢尾花数据集,这是一个学习机器学习的完美选择。

数据集包含 150 个数据项,每个数据项有四个描述三种花类的属性。这些属性是萼片长度和宽度,以及花瓣长度和宽度。当它们相互对比时,可以看到清晰的花类簇群(见图 15-7)。

在鸢尾花数据集中绘制特征

图 15-7. 在鸢尾花数据集中绘制特征(来源:Nicoguaro,可在维基媒体公共资源上找到)

图 15-7 显示了这个问题的复杂性,即使是像这样的简单数据集也是如此。如何使用规则分离三种类型的花?花瓣长度与花瓣宽度的图表接近,Iris setosa样本(红色)与其他样本非常不同,但蓝色和绿色的样本交织在一起。这使得它成为 ML 中的理想学习集:它很小,所以训练快速,您可以用它来解决基于规则的编程很难解决的问题!

您可以从UCI 机器学习库下载数据集,或使用书中的GitHub 仓库中的版本,我已将其转换为 CSV 以便在 JavaScript 中更轻松地使用。

CSV 文件如下所示:

sepal_length,sepal_width,petal_length,petal_width,species
5.1,3.5,1.4,0.2,setosa
4.9,3,1.4,0.2,setosa
4.7,3.2,1.3,0.2,setosa
4.6,3.1,1.5,0.2,setosa
5,3.6,1.4,0.2,setosa
5.4,3.9,1.7,0.4,setosa
4.6,3.4,1.4,0.3,setosa
5,3.4,1.5,0.2,setosa
...

每种花的四个数据点是前四个值。标签是第五个值,是setosaversicolorvirginica中的一个。CSV 文件的第一行包含列标签。记住这一点,以后会有用!

要开始,请像以前一样创建一个基本的 HTML 页面,并添加<script>标签以加载 TensorFlow.js:

<html>
<head></head>
`<script` `src=``"https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest"``>``</script>`
<body>
  <h1>`Iris Classifier`</h1>
</body>
</html>

要加载 CSV 文件,TensorFlow.js 提供了tf.data.csv API,您可以向其提供一个 URL。这还允许您指定哪一列是标签。因为我准备的 CSV 文件的第一行包含列名,您可以指定哪一列包含标签,本例中是species,如下所示:

`<script` `lang=`"js"`>`
  `async`  `function` `run(){`
  `const` `csvUrl =` 'iris.csv'`;`
  `const` `trainingData = tf.data.csv(csvUrl, {`
 `columnConfigs: {`
 `species: {`
 `isLabel:` `true`
 `}`
 `}`
 `});`

标签是字符串,您实际上不希望用神经网络训练它们。这将是一个多类分类器,具有三个输出神经元,每个神经元包含输入数据代表相应花种的概率。因此,标签的独热编码非常适合。

这样,如果您将setosa表示为[1, 0, 0],表明您希望该类的第一个神经元被激活,将virginica表示为[0, 1, 0],versicolor表示为[0, 0, 1],则您有效地定义了每个类的最终层神经元应如何行为的模板。

因为您使用了tf.data加载数据,您可以使用一个映射函数来单独处理xs(特征)和ys(标签)。因此,要保持特征不变并对标签进行独热编码,可以编写如下代码:

  `const` convertedData =
    trainingData.map(({xs, ys}) => {
        `const` labels = [
            ys.species == `"``setosa``"` ? `1` : `0`,
            ys.species == `"``virginica``"` ? `1` : `0`,
            ys.species == `"``versicolor``"` ? `1` : `0`
        ] 
        `return`{ xs: `Object`.values(xs), ys: `Object`.values(labels)};
    }).batch(`10`);

注意标签存储为三个值的数组。除非物种与给定字符串匹配,否则每个值默认为 0,此时它将是 1。因此,setosa将被编码为[1, 0, 0],依此类推。

映射函数将保持xs不变,并对ys进行独热编码。

您现在可以定义您的模型了。输入层的形状是特征的数量,即 CSV 文件中的列数减去 1(因为其中一列代表标签):

`const` numOfFeatures = (`await` trainingData.columnNames()).length - `1`;

`const` model = tf.sequential();
model.add(tf.layers.dense({inputShape: [numOfFeatures], 
                           activation: `"``sigmoid``"`, units: `5`}))

model.add(tf.layers.dense({activation: `"``softmax``"`, units: `3`}));

您的最终层有三个单元,因为训练数据中有三个进行了独热编码的类。

接下来,你将指定损失函数和优化器。因为这是一个多类别分类器,确保你使用类别交叉熵等分类损失函数。你可以使用 tf.train 命名空间中的 adam 优化器,并传递学习率等参数(这里是 0.06):

`model``.``compile``(``{``loss``:` "categoricalCrossentropy"`,` 
 `optimizer``:` `tf``.``train``.``adam``(``0.06``)``}``)``;`

因为数据格式化为数据集,你可以使用 model.fitDataset 进行训练,而不是 model.fit。要进行一百轮的训练并在控制台捕获损失,可以使用如下的回调函数:

`await` model.fitDataset(convertedData, 
                       {epochs:`100`,
                        callbacks:{
                            onEpochEnd: `async`(epoch, logs) =>{
                                console.log(`"``Epoch:` `"` + epoch + 
                                 `"` `Loss:` `"` + logs.loss);
                            }
                        }});

在模型训练完成后,要测试模型,你可以将值加载到 tensor2d 中。不要忘记,在使用 tensor2d 时,还必须指定数据的形状。在这种情况下,要测试四个值的集合,你可以像这样在 tensor2d 中定义它们:

`const` testVal = tf.tensor2d([`4.4`, `2.9`, `1.4`, `0.2`], [`1`, `4`]);

然后,你可以通过将其传递给 model.predict 来获取预测值:

`const` prediction = model.predict(testVal);

你将会得到一个类似这样的张量值:

`[``[`0.9968228`,` 0.00000029`,` 0.0031742`]``,``]`

要获取最大值,你可以使用 argMax 函数:

tf.argMax(prediction, axis=`1`)

对于前述数据,这将返回 [0],因为位置 0 的神经元具有最高的概率。

要将其解包为一个值,你可以使用 .dataSync。这个操作会同步从张量中下载一个值。它会阻塞 UI 线程,所以在使用时要小心!

下面的代码会简单地返回 0 而不是 [0]

`const` pIndex = tf.argMax(prediction, axis=`1`).dataSync();

然后,为了将其映射回带有类名的字符串,你可以使用以下代码:

`const` `classNames` `=` `[`"Setosa"`,` "Virginica"`,` "Versicolor"`]``;`
`alert``(``classNames``[``pIndex``]``)`

现在你已经学会了如何从 CSV 文件加载数据,将其转换为数据集,然后从该数据集拟合模型,以及如何运行模型的预测。你已经准备好尝试其他自选数据集,进一步磨练你的技能了!

总结

本章向你介绍了 TensorFlow.js 及其在浏览器中训练模型和执行推断的用法。你了解了如何使用开源的 Brackets IDE 来编写和在本地 Web 服务器上测试你的模型,并用它来训练你的第一个两个模型:一个“Hello World”线性回归模型和一个基本的鸢尾花数据集分类器。这些都是非常简单的场景,但在 第十六章 中,你将把事情推向更高的水平,看看如何使用 TensorFlow.js 训练计算机视觉模型。

第十六章:TensorFlow.js 中的计算机视觉编码技术

在第二章和第三章中,您看到 TensorFlow 如何用于创建计算机视觉模型,可以训练识别图像内容。本章中,您将使用 JavaScript 完成同样的任务。您将构建一个在浏览器中运行并基于 MNIST 数据集训练的手写识别器。您可以在图 16-1 中看到它。

浏览器中的手写识别器

图 16-1. 浏览器中的手写识别器

当您使用 TensorFlow.js 工作时,特别是在浏览器中构建应用程序时,有一些关键的实施细节需要注意。其中可能最大且最重要的是如何处理训练数据。在浏览器中,每次打开 URL 的资源时,都会进行一次 HTTP 连接。您可以使用此连接传递命令到服务器,服务器会返回结果供您解析。在机器学习中,通常会有大量的训练数据,例如在 MNIST 和 Fashion MNIST 的情况下,即使它们是小型学习数据集,每个仍包含 70,000 张图像,这将产生 70,000 次 HTTP 连接!您将在本章后面看到如何处理这些情况。

此外,正如您在上一章节看到的那样,即使对于像 Y = 2X – 1 这样非常简单的情况,除非您打开调试控制台,否则在训练周期中似乎没有任何操作。在调试控制台中,您可以看到逐个周期的损失情况。如果您在训练需要较长时间的更复杂的任务时,很难理解正在进行的情况。幸运的是,有内置的可视化工具可供使用,如图 16-1 右侧所示;您还将在本章中探索它们。

在 JavaScript 中定义卷积神经网络时,也需要注意一些语法上的差异,我们在前一章中已经提到了一些。我们将从考虑这些方面开始。如果您需要关于 CNN 的复习,请参阅第三章。

TensorFlow 开发人员的 JavaScript 考虑事项

在构建类似于本章节中的 JavaScript 应用程序时,有一些需要考虑的重要事项。JavaScript 与 Python 非常不同,因此,尽管 TensorFlow.js 团队努力使体验尽可能接近“传统”TensorFlow,但还是存在一些变化。

第一点是语法。虽然在许多方面,JavaScript 中的 TensorFlow 代码(特别是 Keras 代码)与 Python 中的非常相似,但在参数列表中使用 JSON 是一个显著的语法差异,正如前一章中提到的那样。

接下来是同步性。特别是在浏览器中运行时,当训练时不能锁定 UI 线程,而是需要异步执行许多操作,使用 JavaScript 的Promiseawait调用。本章不打算深入教授这些概念;如果你还不熟悉它们,可以将它们视为异步函数,这些函数在返回之前不会等待执行完毕,而是会自行执行并在完成后“回调”你。tfjs-vis库被创建来帮助你调试使用 TensorFlow.js 异步训练模型时的代码。可视化工具在浏览器中提供了一个独立的侧边栏,不会干扰当前页面,在其中可以绘制诸如训练进度之类的可视化内容;我们将在“使用回调进行可视化”中进一步讨论它们。

资源使用也是一个重要的考虑因素。因为浏览器是一个共享环境,你可能同时打开多个标签页进行不同的操作,或者在同一个 Web 应用中执行多个操作。因此,控制你使用的内存量是很重要的。ML 训练可能会消耗大量内存,因为需要大量数据来理解和区分将特征映射到标签的模式。因此,你应该注意在使用后进行整理。tidy API 就是为此设计的,并且应该尽可能使用:将一个函数包装在tidy中确保所有未被函数返回的张量都将被清理并释放内存。

虽然不是 TensorFlow API,但 JavaScript 中的arrayBuffer是另一个方便的构造。它类似于ByteBuffer,用于像低级内存一样管理数据。在机器学习应用中,通常最容易使用非常稀疏的编码,就像你已经在 one-hot 编码中看到的那样。记住,在 JavaScript 中处理可能是线程密集型的,你不希望锁定浏览器,所以更容易使用不需要处理器解码的稀疏数据编码。在本章的示例中,标签是以这种方式编码的:对于每个 10 个类别,其中 9 个将具有一个 0 × 00 字节,另一个表示该特征的匹配类将具有一个 0 × 01 字节。这意味着每个标签使用了 10 字节,或 80 位,作为编码人员,你可能认为只需要 4 位来编码 1 到 10 之间的数字。但当然,如果你这样做,你将不得不解码结果——对于这么多的标签,解码将会进行 65000 次。因此,使用arrayBuffer轻松表示的稀疏编码文件可能更快,尽管文件大小较大。

还值得一提的是tf.browser的 API,用于处理图像非常有用。在撰写时,有两种方法,tf.browser.toPixelstf.browser.fromPixels,顾名思义,用于在浏览器友好格式和张量格式之间转换像素。稍后当您想要绘制一幅图并让模型解释时,将会用到这些。

在 JavaScript 中构建 CNN

当使用 TensorFlow Keras 构建任何神经网络时,您定义了许多层。对于卷积神经网络,通常会有一系列卷积层,然后是池化层,其输出被展平并馈入密集层。例如,这是为分类 MNIST 数据集而定义的 CNN 示例,回到第三章:

model = tf.keras.models.`Sequential`([
    tf.keras.layers.`Conv2D`(`64`, (`3`, `3`), activation=`'relu'`, 
                           input_shape=(`28`, `28`, `1`)),
    tf.keras.layers.`MaxPooling2D`(`2`, `2`),
    tf.keras.layers.`Conv2D`(`64`, (`3`, `3`), activation=`'relu'`),
    tf.keras.layers.`MaxPooling2D`(`2`,`2`),
    tf.keras.layers.`Flatten`(),
    tf.keras.layers.`Dense`(`128`, activation=tf.nn.relu),
    tf.keras.layers.`Dense`(`10`, activation=tf.nn.softmax)])

让我们逐行分解如何在 JavaScript 中实现这一点。我们将首先将模型定义为sequential

model = tf.sequential();

接下来,我们将第一层定义为学习 64 个滤波器的 2D 卷积,核大小为 3×3,输入形状为 28×28×1。这里的语法与 Python 非常不同,但您可以看到相似之处:

model.add(tf.layers.conv2d({inputShape: [`28`, `28`, `1`], 
          kernelSize: `3`, filters: `64`, activation: `'relu'`}));

下一层是一个MaxPooling2D,池大小为 2×2。在 JavaScript 中实现如下:

model.add(tf.layers.maxPooling2d({poolSize: [`2`, `2`]}));

随后是另一个卷积层和最大池化层。区别在于这里没有输入形状,因为它不是一个输入层。在 JavaScript 中看起来像这样:

model.add(tf.layers.conv2d({filters: `64`, 
          kernelSize: `3`, activation: `'relu'`}));

model.add(tf.layers.maxPooling2d({poolSize: [`2`, `2`]}));

在此之后,输出被展平,在 JavaScript 中的语法如下:

model.add(tf.layers.flatten());

模型随后由两个密集层完成,一个具有 128 个神经元,激活函数为relu,输出层有 10 个神经元,激活函数为softmax

model.add(tf.layers.dense({units: `128`, activation: `'relu'`}));

model.add(tf.layers.dense({units: `10`, activation: `'softmax'`}));

正如您所见,JavaScript 的 API 看起来与 Python 非常相似,但存在语法上的差异,这可能是陷阱:API 的名称遵循驼峰命名约定,但以小写字母开头,正如 JavaScript 所期望的那样(即maxPooling2D而不是MaxPooling2D),参数在 JSON 中定义,而不是以逗号分隔的列表等等。在编写 JavaScript 中的神经网络时,请注意这些差异。

为了方便起见,这里是模型的完整 JavaScript 定义:

model = tf.sequential();

model.add(tf.layers.conv2d({inputShape: [`28`, `28`, `1`], 
          kernelSize: `3`, filters: `8`, activation: `'relu'`}));

model.add(tf.layers.maxPooling2d({poolSize: [`2`, `2`]}));

model.add(tf.layers.conv2d({filters: `16`, 
          kernelSize: `3`, activation: `'relu'`}));

model.add(tf.layers.maxPooling2d({poolSize: [`2`, `2`]}));

model.add(tf.layers.flatten());

model.add(tf.layers.dense({units: `128`, activation: `'relu'`}));

model.add(tf.layers.dense({units: `10`, activation: `'softmax'`}));

同样地,当编译模型时,请考虑 Python 和 JavaScript 之间的差异。这是 Python 的示例:

  `model``.``compile``(``optimizer``=`'adam'`,`
 `loss``=`'sparse_categorical_crossentropy'`,`
 `metrics``=``[`'accuracy'`]``)`

相应的 JavaScript 如下:

model.compile(
{  optimizer: tf.train.adam(), 
       loss: `'categoricalCrossentropy'`, 
       metrics: [`'accuracy'`]
});

尽管它们非常相似,但请记住参数的 JSON 语法(参数,而不是参数=)以及参数列表用大括号({})括起来。

使用回调进行可视化

在 Chapter 15 中,当您训练简单的神经网络时,每个 epoch 结束时将损失记录到控制台。然后,您可以使用浏览器的开发者工具查看控制台中损失随时间的变化。更高级的方法是使用专为浏览器开发而创建的 TensorFlow.js 可视化工具。这些工具包括用于报告训练指标、模型评估等的工具。可视化工具显示在浏览器窗口的另一个区域,不会干扰页面的其余部分。这个区域的术语叫做视觉器。它默认显示模型架构。

要在页面中使用 tfjs-vis 库,您可以通过以下脚本引入它:

`<``script`  `src``=`"https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-vis"`>``<``/``s``c``r``i``p``t``>`

然后,在训练时查看可视化,您需要在 model.fit 调用中指定一个回调。以下是一个示例:

`return` model.fit(trainXs, trainYs, {
    batchSize: BATCH_SIZE,
    validationData: [testXs, testYs],
    epochs: `20`,
    shuffle: `true`,
 `callbacks``:` `fitCallbacks`
});

回调函数被定义为 const,使用 tfvis.show.fitCallbacks。它接受两个参数——一个容器和所需的度量标准。这些也是使用 const 定义的,如下所示:

`const` `metrics` `=` `[`'loss'`,` 'val_loss'`,` 'accuracy'`,` 'val_accuracy'`]``;`
 `const` `container` `=` `{` `name``:` 'Model Training'`,` `styles``:` `{` `height``:` '640px' `}``,` 
                    `tab``:` 'Training Progress' `}``;`
 `const` `fitCallbacks` `=` `tfvis``.``show``.``fitCallbacks``(``container``,` `metrics``)``;`

container const 包含定义可视化区域的参数。所有可视化默认显示在单个选项卡中。通过使用 tab 参数(此处设置为“训练进度”),您可以将训练进度分割到单独的选项卡中。Figure 16-2 展示了运行时可视化区域中前述代码的效果。

接下来,让我们探讨如何管理训练数据。如前所述,通过 URL 连接处理成千上万的图像会导致浏览器冻结 UI 线程,这是不好的。但在游戏开发领域有一些技巧可以借鉴!

使用可视化工具

图 16-2. 使用可视化工具

使用 MNIST 数据集进行训练

在 TensorFlow.js 中处理数据训练的一个有用方法是将所有图像合并成一个单独的图像,通常称为精灵表。这种技术在游戏开发中常用,游戏的图形存储在单个文件中,而不是多个较小的文件,以提高文件存储效率。如果我们将所有训练图像存储在单个文件中,只需打开一个 HTTP 连接即可一次性下载它们。

出于学习目的,TensorFlow 团队已经从 MNIST 和 Fashion MNIST 数据集创建了精灵表,我们可以在这里使用。例如,MNIST 图像可在名为 mnist_images.png 的文件中找到(参见 Figure 16-3)。

在图像查看器中查看 mnist_images.png 的一部分

图 16-3. mnist_images.png 的一部分,通过图像查看器查看

如果你探索这幅图像的尺寸,你会发现它有 65,000 行,每行有 784(28 × 28)个像素。如果这些尺寸看起来很熟悉,你可能会记得 MNIST 图像是 28 × 28 的单色图像。因此,你可以下载这幅图像,逐行读取它,然后将每行分割成一个 28 × 28 像素的图像。

你可以通过加载图像的方式在 JavaScript 中完成这一操作,然后定义一个画布,在这个画布上你可以绘制从原始图像中提取出的单独的行。然后,这些画布中的字节可以被提取到一个数据集中,你将用它来进行训练。这可能看起来有点复杂,但考虑到 JavaScript 是一种在浏览器中使用的技术,它并不是真正为这样的数据和图像处理而设计的。话虽如此,它的工作效果非常好,而且运行速度非常快!然而,在我们深入讨论之前,你还应该看一下标签以及它们是如何存储的。

首先,设置训练和测试数据的常量,记住 MNIST 图像有 65,000 行,每行一个图像。训练数据和测试数据的比例可以定义为 5:1,由此可以计算出训练元素的数量和测试元素的数量:

`const` IMAGE_SIZE = `784`;
`const` NUM_CLASSES = `10`;
`const` NUM_DATASET_ELEMENTS = `65000`;

`const` TRAIN_TEST_RATIO = `5` / `6`;

`const` NUM_TRAIN_ELEMENTS = `Math`.floor(TRAIN_TEST_RATIO * NUM_DATASET_ELEMENTS);
`const` NUM_TEST_ELEMENTS = NUM_DATASET_ELEMENTS - NUM_TRAIN_ELEMENTS;

请注意,所有这些代码都在这本书的repo中,所以请随意从那里进行调整!

接下来,你需要为将用于保存雪碧表的图像控件和用于切片的画布创建一些常量:

`const` img = `new` `Image`();
`const` canvas = document.createElement(`'canvas'`);
`const` ctx = canvas.getContext(`'2d'`);

要加载图像,你只需将img控件设置为雪碧表的路径:

img.src = MNIST_IMAGES_SPRITE_PATH;

图像加载后,你可以设置一个缓冲区来保存其中的字节。图像是一个 PNG 文件,每个像素有 4 个字节,因此你需要为缓冲区预留 65,000(图像数量)× 768(28 × 28 图像中的像素数)× 4(每个像素的 PNG 字节数)个字节。你不需要逐个图像分割文件,而是可以分块处理。像这样指定chunkSize来一次取五千个图像:

img.onload = () => {
    img.width = img.naturalWidth;
    img.height = img.naturalHeight;

    `const` datasetBytesBuffer =
        `new` `ArrayBuffer`(NUM_DATASET_ELEMENTS * IMAGE_SIZE * `4`);

    `const` chunkSize = `5000`;
    canvas.width = img.width;
    canvas.height = chunkSize;

现在可以创建一个循环来逐个处理图像的块,为每个块创建一组字节并将其绘制到画布上。这将把 PNG 解码到画布中,使你能够从图像中获取原始字节。由于数据集中的单个图像是单色的,PNG 将具有相同级别的 R、G 和 B 字节,因此你可以任意取其中的一个:

`for` (`let` i = `0`; i < NUM_DATASET_ELEMENTS / chunkSize; i++) {
    `const` datasetBytesView = `new` `Float32Array`(
        datasetBytesBuffer, i * IMAGE_SIZE * chunkSize * `4`,
        IMAGE_SIZE * chunkSize);
    ctx.drawImage(
        img, `0`, i * chunkSize, img.width, chunkSize, `0`, `0`, img.width,
        chunkSize);

    `const` imageData = ctx.getImageData(`0`, `0`, canvas.width, canvas.height);

    `for` (`let` j = `0`; j < imageData.data.length / `4`; j++) {
        `// All channels hold an equal value since the image is grayscale, so`
        `// just read the red channel.`
        datasetBytesView[j] = imageData.data[j * `4`] / `255`;
    }
}

现在可以将这些图像加载到一个数据集中:

`this`.datasetImages = `new` `Float32Array`(datasetBytesBuffer);

与图像类似,标签存储在单个文件中。这是一个具有标签稀疏编码的二进制文件。每个标签由 10 个字节表示,其中一个字节的值为 01,表示该类别。这更容易通过可视化进行理解,请看一下图 16-4。

这显示了文件的十六进制视图,并突出显示了前 10 个字节。在这里,第 8 字节是 01,而其他全部为 00。这表明第一个图像的标签为 8。考虑到 MNIST 具有 10 个类别,表示数字 0 到 9,我们知道第八个标签对应数字 7。

探索标签文件

图 16-4. 探索标签文件

因此,除了逐行下载和解码图像的字节之外,您还需要解码标签。通过获取 URL 并解码标签成整数数组,可以将这些标签与图像一起下载,并使用 arrayBuffer 完成解码:

`const` labelsRequest = fetch(MNIST_LABELS_PATH);
`const` [imgResponse, labelsResponse] =
    `await` `Promise`.all([imgRequest, labelsRequest]);

`this`.datasetLabels = `new` `Uint8Array`(`await` labelsResponse.arrayBuffer());

标签编码的稀疏性极大简化了代码—通过这一行代码,您可以将所有标签获取到缓冲区中。如果您想知道为什么标签使用这种低效的存储方法,那是一种权衡:更复杂的存储方法但更简单的解码!

然后,可以将图像和标签拆分为训练集和测试集:

`this`.trainImages =
    `this`.datasetImages.slice(`0`, IMAGE_SIZE * NUM_TRAIN_ELEMENTS);
`this`.testImages = `this`.datasetImages.slice(IMAGE_SIZE * NUM_TRAIN_ELEMENTS);

`this`.trainLabels =
    `this`.datasetLabels.slice(`0`, NUM_CLASSES * NUM_TRAIN_ELEMENTS);
`this`.testLabels =
    `this`.datasetLabels.slice(NUM_CLASSES * NUM_TRAIN_ELEMENTS);

对于训练,数据也可以进行批处理。图像将以 Float32Array 的形式存在,而标签则以 UInt8Array 的形式存在。然后将它们转换为称为 xslabelstensor2d 类型:

nextBatch(batchSize, data, index) {
    `const` batchImagesArray = `new` `Float32Array`(batchSize * IMAGE_SIZE);
    `const` batchLabelsArray = `new` `Uint8Array`(batchSize * NUM_CLASSES);

    `for` (`let` i = `0`; i < batchSize; i++) {
        `const` idx = index();

        `const` image =
            data[`0`].slice(idx * IMAGE_SIZE, idx * IMAGE_SIZE + IMAGE_SIZE);
        batchImagesArray.`set`(image, i * IMAGE_SIZE);

        `const` label =
            data[`1`].slice(idx * NUM_CLASSES, idx * NUM_CLASSES + NUM_CLASSES);
        batchLabelsArray.`set`(label, i * NUM_CLASSES);
    }

    `const` xs = tf.tensor2d(batchImagesArray, [batchSize, IMAGE_SIZE]);
    `const` labels = tf.tensor2d(batchLabelsArray, [batchSize, NUM_CLASSES]);

    `return` {xs, labels};
}

训练数据可以使用此批处理函数返回所需批处理大小的随机训练批次:

nextTrainBatch(batchSize) {
    `return` `this`.nextBatch(
        batchSize, [`this`.trainImages, `this`.trainLabels], () => {
            `this`.shuffledTrainIndex =
                (`this`.shuffledTrainIndex + `1`) % `this`.trainIndices.length;
            `return` `this`.trainIndices[`this`.shuffledTrainIndex];
        });
}

测试数据可以像训练数据一样进行批处理和洗牌。

现在,为了准备训练,您可以设置一些要捕获的指标参数,可视化效果的外观,以及批处理大小等细节。要获取用于训练的批次,请调用 nextTrainBatch 并将 Xs 重塑为正确的张量大小。然后可以对测试数据做完全相同的操作:

const metrics = ['loss', 'val_loss', 'accuracy', 'val_accuracy'];
const container = { name: 'Model Training', styles: { height: '640px' }, 
                    tab: 'Training Progress' };
const fitCallbacks = tfvis.show.fitCallbacks(container, metrics);

const BATCH_SIZE = 512;
const TRAIN_DATA_SIZE = 5500;
const TEST_DATA_SIZE = 1000;

const [trainXs, trainYs] = tf.tidy(() => {
    const d = data.nextTrainBatch(TRAIN_DATA_SIZE);
    return [
        d.xs.reshape([TRAIN_DATA_SIZE, 28, 28, 1]),
        d.labels
    ];
});

const [testXs, testYs] = tf.tidy(() => {
    const d = data.nextTestBatch(TEST_DATA_SIZE);
    return [
        d.xs.reshape([TEST_DATA_SIZE, 28, 28, 1]),
        d.labels
    ];
});

请注意 tf.tidy 调用。在 TensorFlow.js 中,这将像其名称所示地整理,清除除了函数返回的所有中间张量。在浏览器中使用 TensorFlow.js 时,这是非常重要的,以防止内存泄漏。

现在,一切都设置好了,可以很容易地进行训练,提供训练数据 Xs 和 Ys(标签),以及验证数据 Xs 和 Ys:

`return` model.fit(trainXs, trainYs, {
    batchSize: BATCH_SIZE,
    validationData: [testXs, testYs],
    epochs: `20`,
    shuffle: `true`,
    callbacks: fitCallbacks
});

在训练过程中,回调函数会在监控器中为您提供可视化效果,就像您在 图 16-1 中看到的那样。

在 TensorFlow.js 中对图像进行推断

要运行推断,您首先需要一张图像。在 图 16-1 中,您看到了一个界面,用户可以手绘图像并进行推断。这使用了一个设置为 280 × 280 的画布:

rawImage = document.getElementById(`'canvasimg'`);
ctx = canvas.getContext(`"2d"`);
ctx.fillStyle = `"black"`;
ctx.fillRect(`0`,`0`,`280`,`280`);

注意,画布被称为 rawImage。用户绘制图像后(相关代码在本书的 GitHub 存储库中),可以使用 tf.browser.fromPixels API 获取其像素,然后在其上运行推断:

`var` raw = tf.browser.fromPixels(rawImage,`1`);

它是 280 × 280 的图像,所以需要调整大小为 28 × 28 以进行推断。可以使用 tf.image.resize API 完成这项工作:

`var` resized = tf.image.resizeBilinear(raw, [`28`,`28`]);

模型的输入张量为 28 × 28 × 1,因此需要扩展维度:

`var` tensor = resized.expandDims(`0`);

现在,你可以使用model.predict并传递张量来进行预测。模型的输出是一组概率值,因此你可以使用 TensorFlow 的argMax函数选择最大的一个:

`var` prediction = model.predict(tensor);
`var` pIndex = tf.argMax(prediction, `1`).dataSync();

包括页面所有的 HTML、绘图函数的 JavaScript 以及 TensorFlow.js 模型的训练和推断的全部代码都可以在该书的GitHub 存储库中找到。

摘要

JavaScript 是一种非常强大的基于浏览器的语言,可以用于许多场景。在本章中,你已经了解了在浏览器中训练基于图像的分类器所需的步骤,然后将其与用户可以绘制的画布结合在一起。然后可以将输入解析为可以进行分类的张量,并将结果返回给用户。这是一个有用的演示,整合了 JavaScript 编程的许多要素,展示了在训练中可能遇到的一些约束,例如需要减少 HTTP 连接数,并且如何利用内置解码器处理数据管理,正如你在稀疏编码标签中看到的那样。

你可能并不总是想在浏览器中训练新模型,而是想重用你在 Python 中使用 TensorFlow 创建的现有模型。在下一章中,你将探索如何做到这一点。

第十七章:重用和转换 Python 模型为 JavaScript

在浏览器中训练是一个强大的选择,但您可能并不总是希望这样做,因为涉及的时间。正如您在第 15 和 16 章节中所看到的,即使是训练简单的模型也可能会锁定浏览器一段时间。虽然有进度的可视化有所帮助,但体验仍不是最佳的。这种方法有三种替代方案。第一种是在 Python 中训练模型,然后将其转换为 JavaScript。第二种是使用已经在其他地方训练并以 JavaScript 准备格式提供的现有模型。第三种是使用迁移学习,介绍见 第 3 章。在这种情况下,已在一个场景中学习的特征、权重或偏差可以转移到另一个场景,而不是进行耗时的重新学习。我们将在本章中涵盖前两种情况,然后在 第 18 章中您将看到如何在 JavaScript 中进行迁移学习。

将基于 Python 的模型转换为 JavaScript

使用 TensorFlow 训练过的模型可以使用基于 Python 的 tensorflowjs 工具转换为 JavaScript。您可以使用以下命令安装这些工具:

!pip install tensorflowjs

例如,考虑我们在整本书中一直在使用的以下简单模型:

`import` numpy `as` np
`import` tensorflow `as` tf
`from` tensorflow.keras `import` `Sequential`
`from` tensorflow.keras.layers `import` `Dense`

l0 = `Dense`(units=`1`, input_shape=[`1`])
model = `Sequential`([l0])
model.compile(optimizer=`'``sgd``'`, loss=`'``mean_squared_error``'`)

xs = np.array([-`1.0`, `0.0`, `1.0`, `2.0`, `3.0`, `4.0`], dtype=`float`)
ys = np.array([-`3.0`, -`1.0`, `1.0`, `3.0`, `5.0`, `7.0`], dtype=`float`)

model.fit(xs, ys, epochs=`500`, verbose=`0`)

`print`(model.predict([`10.0`]))
`print`(`"``Here is what I learned: {}``"`.format(l0.get_weights()))

可以使用以下代码将训练好的模型保存为保存的模型:

tf.saved_model.save(model, '/tmp/saved_model/')

保存模型目录后,您可以通过传递输入格式给 TensorFlow.js 转换器来使用它——在本例中是一个保存的模型——以及保存模型目录的位置和 JSON 模型的所需位置:

!tensorflowjs_converter \
    --input_format=keras_saved_model \
    /tmp/saved_model/ \
    /tmp/linear

JSON 模型将在指定的目录(在本例中为 /tmp/linear)中创建。如果您查看此目录的内容,您还会看到一个二进制文件,本例中称为 group1-shardof1.bin(见 图 17-1)。此文件包含了网络学习的权重和偏差,以高效的二进制格式存储。

JS 转换器的输出

图 17-1. JS 转换器的输出

JSON 文件包含描述模型的文本。例如,在 JSON 文件中,您将看到如下设置:

"weightsManifest"`:` `[`
   `{`"paths"`:` `[`"group1-shard1of1.bin"`]``,` "weights"`:` `[``{`"name"`:` "dense_2/kernel"`,` "shape"`:` `[``1``,` `1``]``,` "dtype"`:` "float32"`}``,` 
 `{`"name"`:` "dense_2/bias"`,` "shape"`:` `[``1``]``,` "dtype"`:` "float32"`}``]``}`
`]``}`

这指示了保存权重和偏差的 .bin 文件的位置及其形状。

如果您在十六进制编辑器中检查 .bin 文件的内容,您会看到其中有 8 个字节(见 图 17-2)。

*.bin* 文件中的字节

图 17-2. .bin 文件中的字节

当我们的网络使用单个神经元学习得到 Y = 2X – 1 时,网络学到了一个单一的权重作为 float32(4 字节),以及一个单一的偏差作为 float32(4 字节)。这 8 个字节被写入了 .bin 文件中。

如果您回顾一下代码的输出:

Here is what I learned: [array([[1.9966108]], dtype=float32), 
array([-0.98949206], dtype=float32)]

然后,您可以使用类似浮点数转十六进制转换器(图 17-3)的工具将权重(1.9966108)转换为十六进制。

将浮点值转换为十六进制

图 17-3. 将浮点值转换为十六进制

您可以看到,权重 1.99661 被转换为十六进制 F190FF3F,在十六进制文件的前 4 个字节中的值是来自图 17-2。如果您将偏差转换为十六进制,您将看到类似的结果(请注意,您需要交换字节序)。

使用转换后的模型

一旦您有了 JSON 文件及其关联的 .bin 文件,您可以轻松在 TensorFlow.js 应用中使用它们。要从 JSON 文件加载模型,您需要指定托管模型的 URL。如果您正在使用 Brackets 的内置服务器,它将位于 127.0.0.1:。在指定此 URL 后,您可以使用命令 await tf.loadLayersModel(URL) 加载模型。以下是一个例子:

`const` `MODEL_URL` `=` 'http://127.0.0.1:35601/model.json'`;`
`const` `model` `=` `await` `tf``.``loadLayersModel``(``MODEL_URL``)``;`

您可能需要将 35601 更改为您的本地服务器端口。model.json 文件和 .bin 文件需要在同一个目录中。

如果您想使用该模型进行预测,您可以像以前一样使用 tensor2d,传递输入值及其形状。因此,在这种情况下,如果您想预测值为 10.0,您可以创建一个包含 [10.0] 作为第一个参数和 [1,1] 作为第二个参数的 tensor2d

`const` input = tf.tensor2d([`10.0`], [`1`, `1`]);
`const` result = model.predict(input);

为方便起见,这是用于该模型的整个 HTML 页面:

`<``html``>`
`<``head``>`
`<``script` `src`=`"``https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest``"``>``<``/``script``>`
`<``script``>`
    `async` `function` run(){
        `const` MODEL_URL = `'``http://127.0.0.1:35601/model.json``'`;
        `const` model = `await` tf.loadLayersModel(MODEL_URL);
        console.log(model.summary());
        `const` input = tf.tensor2d([`10.0`], [`1`, `1`]);
        `const` result = model.predict(input);
        alert(result);
    }
    run();
`<``/``script``>`
`<``body``>`
`<``/``body``>`
`<``/``html``>`

当您运行页面时,它将立即加载模型并显示预测结果。您可以在图 17-4 中看到这一点。

推理输出

图 17-4. 推理输出

很显然,这是一个非常简单的例子,其中模型二进制文件仅为 8 个字节,易于检查。但愿这个例子有助于帮助您理解 JSON 和二进制表示如何紧密相关。当您转换自己的模型时,您会看到更大的二进制文件——最终只是从您的模型中获取的二进制编码的权重和偏差,就像您在这里看到的那样。

在下一节中,您将看到一些已经使用此方法转换过的模型,以及如何在 JavaScript 中使用它们。

使用预转换的 JavaScript 模型

除了能将您的模型转换为 JavaScript,您还可以使用预转换的模型。TensorFlow 团队已经为您创建了几种这样的模型供您尝试,您可以在GitHub找到这些模型。这些模型适用于不同的数据类型,包括图像、音频和文本。让我们来探索一些这些模型以及您如何在 JavaScript 中使用它们。

使用毒性文本分类器

TensorFlow 团队提供的文本模型之一是毒性分类器。它接受一个文本字符串,并预测其是否包含以下类型的毒性:

  • 身份攻击

  • 侮辱

  • 猥亵

  • 严重毒性

  • 性别露骨

  • 威胁

  • 一般毒性

它是在Civil Comments 数据集上训练的,包含超过两百万条根据这些类型标记的评论。使用它非常简单。你可以像这样在加载 TensorFlow.js 时同时加载模型:

`<script`  `src``=`"https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest"`>``</script>`
`<script`  `src``=`"https://cdn.jsdelivr.net/npm/@tensorflow-models/toxicity"`>``</script>`

一旦你有了这些库,你可以设置一个阈值,超过这个阈值的句子将被分类。默认为 0.85,但你可以像这样改变它,加载模型时指定一个新的数值:

`const` threshold = `0.7`;
toxicity.load(threshold).`then`(model => {

然后,如果你想分类一个句子,你可以将它放入一个数组中。多个句子可以同时进行分类:

`const` `sentences` `=` `[`'you suck'`,` 'I think you are stupid'`,` 'i am going to kick your head in'`,` 
  'you feeling lucky, punk?'`]``;`

`model``.``classify``(``sentences``)``.``then``(``predictions` `=``>` `{`

此时,你可以解析predictions对象来获取结果。它将是一个包含七个条目的数组,每个条目对应一个毒性类型(Figure 17-5)。

毒性预测的结果

Figure 17-5. 毒性预测的结果

在每个句子的结果中都包含对每个类别的结果。例如,如果你查看项目 1,对于侮辱,你可以展开它来探索结果,你会看到有四个元素。这些是每个输入句子对应该类型毒性的概率(Figure 17-6)。

概率被测量为[负面,正面],所以第二个元素中的高值表明该类型的毒性存在。在这种情况下,“你很糟糕”这句话被测量为有 0.91875 的侮辱概率,而“我要踢你的头”,虽然有毒,但侮辱概率很低,只有 0.089。

要解析这些内容,你可以循环遍历predictions数组,循环遍历结果中的每一种侮辱类型,然后按顺序遍历它们的结果,以找出每个句子中识别的毒性类型。你可以通过使用match方法来做到这一点,如果预测值高于阈值,它将是正面的。

探索毒性结果

Figure 17-6. 探索毒性结果

这是代码:

`for`(sentence=`0`; sentence<sentences.length; sentence++){
  `for`(toxic_type=`0`; toxic_type<`7`; toxic_type++){
    `if`(predictions[toxic_type].results[sentence].match){
      console.log(`"``In the sentence:` `"` + sentences[sentence] + `"``\n``"` +
                   predictions[toxic_type].label +
                   `"` `was found with probability of` `"` +
                   predictions[toxic_type].results[sentence].probabilities[`1`]);
    }
  }
}

你可以在 Figure 17-7 中看到这个结果。

毒性分类器对样本输入的结果

Figure 17-7. 毒性分类器对样本输入的结果

因此,如果你想在你的网站上实现某种毒性过滤器,你可以像这样用很少的代码来做到!

另一个有用的快捷方式是,如果你不想捕捉所有七种毒性,你可以指定一个子集,就像这样:

`const` labelsToInclude = ['identity_attack', 'insult', 'threat'];

然后在加载模型时指定此列表和阈值:

toxicity.load(threshold, labelsToInclude).`then`(model => {}

当然,该模型也可以在 Node.js 后端使用,如果你想在后端捕捉和过滤毒性。

在浏览器中使用 MobileNet 进行图像分类

除了文本分类库,存储库还包括一些用于图像分类的库,如MobileNet。MobileNet 模型被设计为小巧和节能,同时在分类一千种图像类别时也非常准确。因此,它们有一千个输出神经元,每个都是图像包含该类别的概率。因此,当您将图像传递给模型时,将返回一个包含这些类别的一千个概率的列表。但是,JavaScript 库会为您抽象出来,按优先顺序选择前三个类,并仅提供这些类。

这是来自完整类列表的摘录:

`00`: background
`01`: tench
`02`: goldfish
`03`: great white shark
`04`: tiger shark
`05`: hammerhead
`06`: electric ray

要开始,您需要加载 TensorFlow.js 和mobilenet脚本,就像这样:

`<``script` `src``=``"`https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest`"``>` `<``/``script``>`
`<``script` `src``=``"`https://cdn.jsdelivr.net/npm/@tensorflow-models/mobilenet@1.0.0`"``>` 

要使用模型,您需要提供一张图像。最简单的方法是创建一个<img>标签并加载图像到其中。您还可以创建一个<div>标签来容纳输出:

<`body`>
    <`img` `id`="`img`"  `src`="`coffee.jpg`"></`img`>
    <`div` `id`="`output`" `style`="`font-family`:`courier`;`font-size`:`24``px`;`height=``300``px`">
    </`div`>
</`body`>

要使用模型来对图像进行分类,您只需加载它并将<img>标签的引用传递给分类器:

`const` img = `document`.getElementById(`'``img``'`);
`const` outp = `document`.getElementById(`'``output``'`);
mobilenet.load().then(model => {
    model.classify(img).then(predictions => {
        console.log(predictions);
    });
});

这会将输出打印到控制台日志中,看起来像图 17-8。

探索 MobileNet 输出

图 17-8. 探索 MobileNet 输出

输出作为预测对象也可以解析,因此您可以遍历它并像这样选择类名和概率:

for(var i = 0; i<predictions.length; i++){
    outp.innerHTML += "<`br`/>" + predictions[i].className + " : " 
     + predictions[i].probability;
}

图 17-9 显示了浏览器中样本图像与预测结果并排的情况。

图像分类

图 17-9. 图像分类

为了方便起见,这里是这个简单页面的完整代码清单。要使用它,您需要在相同的目录中有一张图像。我使用的是coffee.jpg,但您当然可以替换图像并更改<img>标签的src属性以分类其他内容:

<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest"> </script>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/mobilenet@1.0.0"> 
</script>
</head>
<body>
    <img id="img" src="coffee.jpg"></img>
    <div id="output" style="font-family:courier;font-size:24px;height=300px">
    </div>
</body>
<script>
    const img = document.getElementById('img');
    const outp = document.getElementById('output');
    mobilenet.load().then(model => {
        model.classify(img).then(predictions => {
            console.log(predictions);
            for(var i = 0; i<predictions.length; i++){
                outp.innerHTML += "<br/>" + predictions[i].className + " : " 
                + predictions[i].probability;
            }
        });
    });
</script>
</html>

使用 PoseNet

另一个由 TensorFlow 团队预先转换为您的有趣库是姿势网络,它可以在浏览器中实时估计姿势。它接收一张图像并返回图像中 17 个身体标志点的集合:

  • 鼻子

  • 左右眼

  • 左右耳

  • 左右肩膀

  • 左右肘部

  • 左右手腕

  • 左右臀部

  • 左右膝盖

  • 左右脚踝

对于一个简单的场景,我们可以看一下在浏览器中估计单个图像的姿势。要做到这一点,首先加载 TensorFlow.js 和posenet模型:

<`head`>
    <`script` `src`="`https://cdn.jsdelivr.net/npm/@tensorflow/tfjs`"></`script`>
    <`script` `src`="`https://cdn.jsdelivr.net/npm/@tensorflow-models/posenet`">
    </`script`>
</`head`>

在浏览器中,您可以将图像加载到<img>标签中,并创建一个画布,在此画布上可以绘制身体标志点的位置:

<`div`><`canvas` `id`='`cnv`'  `width`='`661px`'  `height`='`656px`'/></`div`>
<`div`><`img` `id`='`master`'  `src`="`tennis.png`"/></`div`>

要获取预测结果,您只需获取包含图片的图像元素,并将其传递给posenet,调用estimateSinglePose方法:

`var` imageElement = `document`.getElementById(`'``master``'`);
posenet.load().then(`function`(net) {
    `const` pose = net.estimateSinglePose(imageElement, {});
    `return` pose;
}).then(`function`(pose){
    `console`.log(pose);
    drawPredictions(pose);
})

这将返回名为pose的对象中的预测结果。这是一个包含身体部位关键点的数组(见图 17-10)。

姿势返回的位置

图 17-10. 姿势返回的位置

每个项目包含一个部位的文本描述(例如,nose),一个带有位置的(x, y)坐标的对象,以及指示正确识别标志物的置信度值score。例如,在图 17-10 中,标志物nose被识别的可能性为 0.999。

然后,您可以使用这些信息在图像上绘制标志物。首先将图像加载到画布中,然后可以在其上绘制:

`var` canvas = `document`.getElementById(`'``cnv``'`);
`var` context = canvas.getContext(`'``2d``'`);
`var` img = `new` `Image`()
img.src=`"``tennis.png``"`
img.onload = `function`(){
    context.drawImage(img, `0`, `0`)
    `var` centerX = canvas.width / `2`;
    `var` centerY = canvas.height / `2`;
    `var` radius = `2`;

然后,您可以循环遍历预测结果,检索部位名称和(x,y)坐标,并使用以下代码在画布上绘制它们:

`for`(i=`0`; i<pose.keypoints.length; i++){
    part = pose.keypoints[i].part
    loc = pose.keypoints[i].position;
    context.beginPath();
    context.font = `"``16px Arial``"`;
    context.fillStyle=`"``aqua``"`
    context.fillText(part, loc.x, loc.y)
    context.arc(loc.x, loc.y, radius, `0`, `2` * `Math`.`PI`, `false`);
    context.fill();
}

然后,在运行时,您应该看到类似于图 17-11 的内容。

估算和绘制图像上的身体部位位置

图 17-11. 估算和绘制图像上的身体部位位置

您还可以使用score属性来过滤掉错误的预测。例如,如果您的图片只包含一个人的面部,您可以更新代码以过滤掉低概率的预测,以便专注于相关的关键部位:

`for`(i=`0`; i<pose.keypoints.length; i++){
    `if`(pose.keypoints[i].score>`0.1`){
    // Plot the points
    }
}

如果图像是某人面部的特写,您不希望绘制肩膀、脚踝等部位。这些部位的得分可能很低但非零,如果不将它们过滤掉,它们会被绘制在图像的某个地方——由于图像并不包含这些标志物,这显然是一个错误!

图 17-12 显示了一张面部图像,已过滤掉低概率的标志物。请注意,因为 PoseNet 主要用于估算身体姿势而非面部,所以没有口部标志物。

在面部使用 PoseNet

图 17-12. 在面部使用 PoseNet

PoseNet 模型还提供了许多其他功能——我们仅仅触及了可能性的表面。您可以在浏览器中使用网络摄像头进行实时姿势检测,编辑姿势的准确性(低准确度的预测可能更快),选择优化速度的架构,检测多个人体的姿势等等。

总结

本章介绍了如何使用 TensorFlow.js 与 Python 创建的模型,可以通过训练自己的模型并使用提供的工具转换,或者使用现有模型。在转换过程中,您看到了tensorflowjs工具创建了一个包含模型元数据的 JSON 文件,以及一个包含权重和偏置的二进制文件。很容易将模型导入为 JavaScript 库,并直接在浏览器中使用它。

你随后查看了一些已经为你转换的现有模型的示例,以及如何将它们整合到你的 JavaScript 代码中。你首先尝试了毒性模型,用于处理文本以识别和过滤有毒评论。然后,你探索了使用 MobileNet 模型进行计算机视觉,以预测图像的内容。最后,你看到了如何使用 PoseNet 模型来检测图像中的身体地标并绘制它们,包括如何过滤低概率分数,以避免绘制看不见的地标。

在第十八章,你将看到另一种重用现有模型的方法:迁移学习,即利用现有的预训练特征,并将其应用到你自己的应用程序中。

第十八章:JavaScript 中的迁移学习

在第十七章中,你探讨了两种将模型转换到 JavaScript 的方法:转换基于 Python 的模型和使用 TensorFlow 团队提供的预先存在的模型。除了从头开始训练,还有一种选择:迁移学习,即之前为一个场景训练过的模型,可以重复使用其部分层次。例如,用于计算机视觉的卷积神经网络可能已经学习了多层滤波器。如果它是在大型数据集上训练的,以识别多个类别,那么它可能有非常通用的滤波器,可以用于其他情景。

使用 TensorFlow.js 进行迁移学习时,有多种选择,取决于预先存在的模型如何分布。可能性主要分为三类:

  • 如果模型有一个model.json文件,通过使用 TensorFlow.js 转换器将其转换为基于层的模型,你可以探索层次,选择其中之一,并使其成为你训练的新模型的输入。

  • 如果模型已转换为基于图的模型,例如 TensorFlow Hub 中常见的模型,你可以连接其特征向量到另一个模型,以利用其学习到的特征。

  • 如果模型已封装为 JavaScript 文件以便于分发,该文件将为你提供一些方便的快捷方式,用于通过访问嵌入或其他特征向量进行预测或迁移学习。

在本章中,你将探索这三种方法。我们将从检查如何访问 MobileNet 中的预先学习层开始,这是你在第十七章中用作图像分类器的模型,并将其添加到你自己的模型中。

从 MobileNet 进行迁移学习

MobileNet 架构定义了一个模型家族,主要用于设备上的图像识别。它们是在 ImageNet 数据集上训练的,该数据集包含超过 1000 万张图片,分为 1000 个类别。通过迁移学习,你可以使用它们预先学习的滤波器,并更改底部密集层,以适应你自己的类别,而不是原始模型训练时的 1000 个类别。

要构建一个使用迁移学习的应用程序,你需要遵循以下几个步骤:

  1. 下载 MobileNet 模型并确定要使用的层次。

  2. 创建自己的模型架构,其输入为 MobileNet 的输出。

  3. 将数据收集到可用于训练的数据集中。

  4. 训练模型。

  5. 运行推断。

通过构建一个从网络摄像头捕捉 Rock/Paper/Scissors 手势图像的浏览器应用程序,你将经历所有这些步骤。然后,该应用程序使用这些图像来训练一个新模型。该模型将使用 MobileNet 的预先学习层,并在其下方添加一组新的密集层用于你的类别。

步骤 1. 下载 MobileNet 并确定要使用的层次

TensorFlow.js 团队在 Google Cloud Storage 上托管了许多预转换的模型。如果你想自己尝试,你可以在这本书的 GitHub repo 中找到一个URL 列表。这里有几个 MobileNet 模型,包括你将在本章中使用的一个(mobilenet_v1_0.25_224/model.json)。

要探索这个模型,创建一个新的 HTML 文件并命名为mobilenet-transfer.html。在这个文件中,你会加载 TensorFlow.js 和一个名为index.js的外部文件,稍后你会创建它:

<`html`>
  <`head`>
    <`script` `src`="`https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest`">  
    </`script`>
  </`head`>
  <`body`></`body`>
  <`script` `src`="`index.js`"></`script`>
</`html`>

接下来,创建前面 HTML 文件所引用的index.js文件。这将包含一个异步方法,用于下载模型并打印其摘要:

`async` `function` init(){
 `const` url = `'https://storage.googleapis.com/tfjs- models/tfjs/mobilenet_v1_0.25_224/model.json'`

 `const` mobilenet = `await` tf.loadLayersModel(url);
  `console`.log(mobilenet.summary())
}

init()

如果你在控制台中查看model.summary输出并向下滚动,你会看到类似于图 18-1 的内容。

MobileNet JSON 模型的 model.summary 输出

图 18-1。MobileNet JSON 模型的 model.summary 输出

使用 MobileNet 进行迁移学习的关键是寻找激活层。正如你所看到的,底部有两个激活层。最后一个有一千个输出,对应于 MobileNet 支持的一千个类别。因此,如果你想要学习的激活层,特别是学习的卷积滤波器,可以寻找在此之上的激活层,并注意它们的名称。如你在图 18-1 中所见,模型中最后一个激活层,在最终层之前,被称为conv_pw_13_relu。如果你想进行迁移学习,你可以使用它(或者实际上,在它之前的任何激活层)作为模型的输出。

第二步。使用 MobileNet 的输出创建你自己的模型架构

在设计模型时,通常会设计所有的层,从输入层开始,到输出层结束。通过迁移学习,你将从要转移的模型传递输入,并创建新的输出层。考虑到图 18-2,这是 MobileNet 的粗略高级架构。它接收尺寸为 224 × 224 × 3 的图像,并将它们通过神经网络架构传递,输出一千个值,每个值代表图像包含相关类别的概率。

MobileNet 的高级架构

图 18-2。MobileNet 的高级架构

之前你查看了该架构的内部并识别了最后的激活卷积层,称为conv_pw_13_relu。你可以看到在图 18-3 中包含这一层的架构是什么样子的。

MobileNet 架构仍然识别一千个类别,而其中没有您要实现的类别(手势游戏“石头剪刀布”中的手势)。您需要一个新模型,该模型经过训练可以识别这三个类别。您可以从头开始训练它,并学习所有能够帮助您区分它们的滤波器,正如前几章所示。或者,您可以从 MobileNet 中获取预学习的滤波器,使用直到 conv_pw_13_relu 的架构,并将其提供给一个仅分类三个类别的新模型。请参见 图 18-4 的抽象化过程。

高级 MobileNet 架构展示 conv_pw_13_relu

图 18-3. 高级 MobileNet 架构展示 conv_pw_13_relu

从 conv_pw_13_relu 到新架构的转移学习

图 18-4. 从 conv_pw_13_relu 转移学习到新架构

要在代码中实现这一点,您可以将您的 index.js 更新如下:

`let` mobilenet

`async` `function` loadMobilenet() {
  `const` mobilenet = `await` tf.loadLayersModel(`url`);
  `const` layer = mobilenet.getLayer(`'conv_pw_13_relu'`);
  `return` tf.model({inputs: mobilenet.inputs, outputs: layer.output});
}

`async` `function` init(){
  mobilenet = `await` loadMobilenet()
  model = tf.sequential({
  layers: [
    tf.layers.flatten({inputShape: mobilenet.outputs[`0`].shape.slice(`1`)}),
    tf.layers.dense({ units: `100`, activation: `'relu'`}),
    tf.layers.dense({ units: `3`, activation: `'softmax'`})
  ]
  });
  `console`.log(model.summary())
}

init()

mobilenet 的加载已经放入了自己的异步函数中。一旦模型加载完成,可以使用 getLayer 方法从中提取 conv_pw_13_relu 层。然后该函数将返回一个模型,其输入设置为 mobilenet 的输入,输出设置为 conv_pw_13_relu 的输出。这由 图 18-4 中的右向箭头进行可视化。

一旦此函数返回,您可以创建一个新的序列模型。请注意其中的第一层——它是 mobilenet 输出的扁平化(即 conv_pw_13_relu 输出),然后进入一个包含一百个神经元的密集层,再进入一个包含三个神经元的密集层(分别对应石头、剪刀和布)。

如果您现在对这个模型进行 model.fit,您将训练它识别三个类别——但与其从头学习识别图像中的所有滤波器不同,您可以使用之前由 MobileNet 学到的滤波器。但在此之前,您需要一些数据。接下来的步骤将展示如何收集这些数据。

步骤 3. 收集和格式化数据

对于本示例,您将使用浏览器中的网络摄像头捕获手势“石头/剪刀/布”的图像。从网络摄像头捕获图像的数据超出了本书的范围,因此这里不会详细介绍,但在这本书的 GitHub 仓库中有一个名为 webcam.js 的文件(由 TensorFlow 团队创建),可以为您处理所有这些。它从网络摄像头捕获图像,并将它们以 TensorFlow 友好的格式返回为批处理图像。它还处理了浏览器所需的所有来自 TensorFlow.js 的清理代码,以避免内存泄漏。以下是该文件的一部分示例:

capture() {
  `return` tf.tidy(() => {
    `// Read the image as a tensor from the webcam <video> element.`
    `const` webcamImage = tf.browser.fromPixels(`this`.webcamElement);
    `const` reversedImage = webcamImage.reverse(`1`);
    `// Crop the image so we're using the center square of the rectangle.`
    `const` croppedImage = `this`.cropImage(reversedImage);
    `// Expand the outermost dimension so we have a batch size of 1.`
    `const` batchedImage = croppedImage.expandDims(`0`);
    `// Normalize the image between -1 and 1\. The image comes in between`
    `// 0-255, so we divide by 127 and subtract 1.`
    `return` batchedImage.toFloat().div(tf.scalar(`127`)).sub(tf.scalar(`1`));
  });
}

您可以通过简单的 <script> 标签将此 .js 文件包含在您的 HTML 中:

<script src=`"webcam.js"`></script>

您可以更新 HTML,使用一个 <div> 来容纳来自网络摄像头的视频预览,用户将选择捕获石头/剪刀/布手势样本的按钮,以及输出捕获样本数量的 <div>。应如下所示:

<html>
  <head>
    <script src=`"https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest"`> 
    </script>
    <script src=`"webcam.js"`></script>
  </head>
  <body>
    <div>
      <video autoplay playsinline muted id=`"wc"` width=`"224"` height=`"224"`/>
    </div>
    <button type=`"button"` id=`"0"` onclick=`"handleButton(this)"`>Rock</button>
    <button type=`"button"` id=`"1"` onclick=`"handleButton(this)"`>Paper</button>
    <button type=`"button"` id=`"2"` onclick=`"handleButton(this)"`>Scissors</button>
      <div id=`"rocksamples"`>Rock Samples:</div>
      <div id=`"papersamples"`>Paper Samples:</div>
      <div id=`"scissorssamples"`>Scissors Samples:</div>
  </body>
  <script src=`"index.js"`></script>
</html>

然后,您只需在您的 index.js 文件顶部添加一个 const 来初始化具有 HTML 中 <video> 标签 ID 的网络摄像头:

`const` webcam = `new` `Webcam`(`document`.getElementById(`'wc'`));

然后,您可以在 init 函数中初始化网络摄像头:

`await` webcam.setup();

运行页面现在将为您提供一个带有网络摄像头预览的页面,以及三个按钮(参见 图 18-5)。

使网络摄像头预览正常工作

图 18-5. 使网络摄像头预览正常工作

请注意,如果您看不到预览,请查看 Chrome 状态栏顶部突出显示的图标。如果它有一条红线,您需要允许浏览器使用网络摄像头,然后您应该能看到预览。接下来,您需要做的是捕获图像,并将其放入使您在第 2 步中创建的模型训练变得容易的格式中。

由于 TensorFlow.js 无法利用像 Python 那样的内置数据集,您将不得不自己创建数据集类。幸运的是,这并不像听起来那么困难。在 JavaScript 中,创建一个名为 rps-dataset.js 的新文件。构造一个带有标签数组的对象,如下所示:

`class` `RPSDataset` {
  constructor() {
    `this`.labels = []
  }
}

每次从网络摄像头捕获一个新的石头/剪刀/布手势示例时,您都希望将其添加到数据集中。可以通过 addExample 方法实现这一点。示例将作为 xs 添加。请注意,这不会添加原始图像,而是通过截断的 mobilenet 对图像进行分类。稍后您将看到这一点。

第一次调用此函数时,xs 将为 null,因此您将使用 tf.keep 方法创建 xs。正如其名称所示,此方法防止在 tf.tidy 调用中销毁张量。它还将标签推送到构造函数中创建的 labels 数组中。对于后续调用,xs 将不为 null,因此您将 xs 复制到 oldX 中,然后将示例连接到其中,使其成为新的 xs。然后,您将标签推送到 labels 数组中,并且丢弃旧的 xs

addExample(example, label) {
  `if` (`this`.xs == `null`) {
    `this`.xs = tf.keep(example);
    `this`.labels.push(label);
  } `else` {
    `const` oldX = `this`.xs;
    `this`.xs = tf.keep(oldX.concat(example, `0`));
    `this`.labels.push(label);
    oldX.dispose();
  }
}

遵循这种方法,您的标签将成为一个值数组。但是要训练模型,您需要将它们作为一个独热编码数组,因此您需要向数据集类添加一个辅助函数。此 JavaScript 将 labels 数组编码为 numClasses 参数指定的类数:

encodeLabels(numClasses) {
  for (var i = 0; i < this.labels.length; i++) {
    if (this.ys == null) {
      this.ys = tf.keep(tf.tidy(
          () => {`return` `tf``.``oneHot`(
              tf.tensor1d([this.labels[i]]).toInt(), numClasses)}));
    } else {
      const y = tf.tidy(
          () => {`return` `tf``.``oneHot`(
              tf.tensor1d([this.labels[i]]).toInt(), numClasses)});
      const oldY = this.ys;
      this.ys = tf.keep(oldY.concat(y, 0));
      oldY.dispose();
      y.dispose();
    }
  }
}

关键在于 tf.oneHot 方法,正如其名称所示,它将给定的参数编码为独热编码。

在您的 HTML 中,您已添加了三个按钮,并指定它们的 onclick 调用一个名为 handleButton 的函数,如下所示:

<button type=`"button"` id=`"0"` onclick=`"handleButton(this)"`>Rock</button>
<button type=`"button"` id=`"1"` onclick=`"handleButton(this)"`>Paper</button>
<button type=`"button"` id=`"2"` onclick=`"handleButton(this)"`>Scissors</button>

你可以在index.js脚本中实现这个功能,通过打开元素 ID(石头、剪刀和布分别是 0、1 或 2),将其转换为标签,捕获网络摄像头图像,调用mobilenetpredict方法,并使用你早先创建的方法将结果作为示例添加到数据集中:

`function` handleButton(elem){
  label = parseInt(elem.id);
  `const` img = webcam.capture();
 `dataset``.``addExample``(``mobilenet``.``predict``(``img``)``,` `label``)``;`
}

在继续之前,确保你理解了addExample方法。虽然你可以创建一个捕获原始图像并将其添加到数据集的数据集,但请回忆图 18-4。你用conv_pw_13_relu的输出创建了mobilenet对象。通过调用predict,你将得到该层的输出。如果你回顾一下图 18-1,你会看到输出是[?, 7, 7, 256]。这在图 18-6 中有总结。

mobilenet.predict 的结果

图 18-6. mobilenet.predict 的结果

请记住,使用 CNN,随着图像通过网络的进展,会学习到多个滤波器,并且这些滤波器的结果会乘以图像。它们通常被池化并传递到下一层。通过这种架构,当图像到达输出层时,你将得到 256 个 7 × 7 的图像,这些是所有滤波器应用的结果。然后可以将这些图像馈送到密集网络中进行分类。

你也可以添加代码以更新用户界面,计算添加的样本数。我这里为了简洁起见省略了它,但都在 GitHub 仓库中。

不要忘记使用<script>标签将rps-dataset.js文件添加到你的 HTML 中:

<script src=`"rps-dataset.js"`></script>

在 Chrome 开发者工具中,你可以添加断点并观察变量。运行你的代码,添加一个对dataset变量的观察,并在dataset.addExample方法上设置一个断点。点击石头/剪刀/布中的一个按钮,你将看到数据集被更新。在图 18-7 中,你可以看到在我点击这三个按钮后的结果。

探索数据集

图 18-7. 探索数据集

注意labels数组设置为 0、1、2 表示三种标签。它还没有进行独热编码。此外,在数据集中,你可以看到一个包含所有收集数据的 4D 张量。第一个维度(3)是收集的样本数。随后的维度(7, 7, 256)是来自mobilenet的激活。

现在你有了一个可以用来训练模型的数据集。在运行时,你可以让用户点击每个按钮来收集每种类型的样本数量,然后将其馈送到你为分类指定的密集层中。

步骤 4. 训练模型

这个应用程序将通过一个按钮来训练模型。一旦训练完成,你可以按一个按钮开始模型在网络摄像头中看到的内容进行预测,并按另一个按钮停止预测。

将以下 HTML 添加到您的页面以添加这三个按钮,并添加一些 <div> 标签来保存输出。请注意,这些按钮调用名为 doTrainingstartPredictingstopPredicting 的方法:

<`button` `type`="`button`"  `id`="`train`"  `onclick`="`doTraining()`"> Train Network </`button`>
<`div` `id`="`dummy`"> Once training is complete, click 'Start Predicting' to see predictions
  and 'Stop Predicting' to end </`div`>
<`button` `type`="`button`"  `id`="`startPredicting`"  `onclick`="`startPredicting()`"> Start Predicting </`button`>
<`button` `type`="`button`"  `id`="`stopPredicting`"  `onclick`="`stopPredicting()`"> Stop Predicting </`button`>
<`div` `id`="`prediction`"></`div`>

在您的 index.js 中,您可以添加一个名为 doTraining 的方法并填充它:

function doTraining(){
  train();
}

train 方法内部,您可以定义模型架构,对标签进行独热编码并训练模型。请注意,模型中的第一层的 inputShape 已定义为 mobilenet 的输出形状,而您之前已将 mobilenet 对象的输出指定为 conv_pw_13_relu

`async` `function` train() {
  dataset.ys = `null`;
  dataset.encodeLabels(`3`);
  model = tf.sequential({
    layers: [
      tf.layers.flatten({inputShape: mobilenet.outputs[`0`].shape.slice(`1`)}),
      tf.layers.dense({ units: `100`, activation: `'relu'`}),
      tf.layers.dense({ units: `3`, activation: `'softmax'`})
    ]
  });
  `const` optimizer = tf.train.adam(`0.0001`);
  model.compile({optimizer: optimizer, loss: `'categoricalCrossentropy'`});
  `let` loss = `0`;
  model.fit(dataset.xs, dataset.ys, {
    epochs: `10`,
    callbacks: {
      onBatchEnd: `async` (batch, logs) => {
        loss = logs.loss.toFixed(`5`);
        `console`.log(`'LOSS: '` + loss);
      }
    }
  });
}

这将训练模型进行 10 个 epochs。您可以根据模型中的损失情况自行调整。

init.js 中早些时候,您定义了模型,但是最好将其移到这里并将 init 函数仅用于初始化。因此,您的 init 应该如下所示:

`async` `function` init(){
  `await` webcam.setup();
  mobilenet = `await` loadMobilenet()
}

在此时,您可以在网络摄像头前练习做出石头/剪刀/布手势。按适当的按钮来捕获给定类别的示例。每个类别重复约 50 次,然后按“Train Network”按钮。几秒钟后,训练将完成,您将在控制台中看到损失值。在我的情况下,损失从约 2.5 开始,最终降至 0.0004,表明模型学习良好。

请注意,每个类别的 50 个样本足够了,因为当我们将示例添加到数据集时,我们添加了 activated 的示例。每个图像为我们提供 256 个 7 × 7 的图像以供馈送到密集层,因此,150 个样本为我们提供了总共 38,400 个用于训练的项目。

现在您已经有了一个训练好的模型,可以尝试使用它进行预测!

第 5 步:使用模型进行推断

完成第 4 步后,您应该拥有一个能够提供完全训练好的模型的代码。您还创建了用于启动和停止预测的 HTML 按钮。这些按钮配置为调用 startPredictingstopPredicting 方法,因此现在应创建它们。每个方法只需将一个 isPredicting 布尔值设置为 truefalse,分别用于确定是否要进行预测。然后它们调用 predict 方法:

`function` startPredicting(){
  isPredicting = `true`;
  predict();
}

`function` stopPredicting(){
  isPredicting = `false`;
  predict();
}

predict 方法可以使用您训练好的模型。它将捕获网络摄像头输入,并通过调用 mobilenet.predict 方法获取激活值。然后,一旦获取了激活值,它就可以将它们传递给模型以进行预测。由于标签是独热编码的,您可以在预测结果上调用 argMax 来获取可能的输出:

`async` `function` predict() {
  `while` (isPredicting) {
    `const` predictedClass = tf.tidy(() => {
      `const` img = webcam.capture();
      `const` activation = mobilenet.predict(img);
      `const` predictions = model.predict(activation);
      `return` predictions.as1D().argMax();
    });
    `const` classId = (`await` predictedClass.data())[`0`];
    `var` predictionText = `""`;
    `switch`(classId){
      `case` `0`:
        predictionText = `"I see Rock"`;
        `break`;
      `case` `1`:
        predictionText = `"I see Paper"`;
        `break`;
      `case` `2`:
        predictionText = `"I see Scissors"`;
        `break`;
    }
    `document`.getElementById(`"prediction"`).innerText = predictionText;

    predictedClass.dispose();
    `await` tf.nextFrame();
  }
}

结果为 0、1 或 2,然后您可以将该值写入预测 <div> 并进行清理。

注意这取决于 isPredicting 布尔值,因此您可以通过相关按钮打开或关闭预测功能。现在当您运行页面时,您可以收集样本、训练模型并进行推断。查看 Figure 18-8 的示例,它将我的手势分类为剪刀!

使用训练好的模型在浏览器中进行推断

图 18-8. 使用训练模型在浏览器中运行推理

从这个示例中,您看到了如何为迁移学习构建您自己的模型。接下来,您将探索一种使用存储在 TensorFlow Hub 中的基于图形的模型的替代方法。

从 TensorFlow Hub 进行迁移学习

TensorFlow Hub 是一个可重复使用的 TensorFlow 模型在线库。许多模型已经转换为 JavaScript 版本供您使用,但是在进行迁移学习时,您应该寻找“图像特征向量”模型类型,而不是完整的模型本身。这些模型已经被剪枝以输出学习到的特征。这里的方法与上一节示例中的方法略有不同,那里是从 MobileNet 输出激活值,然后将其转移到您的自定义模型中。相反,特征向量 是表示整个图像的一维张量。

要找到要试验的 MobileNet 模型,请访问 TFHub.dev,选择 TF.js 作为您想要的模型格式,并选择 MobileNet 架构。您将看到许多可用的模型选项,如图 18-9 所示。

使用 TFHub.dev 查找 JavaScript 模型

图 18-9. 使用 TFHub.dev 查找 JavaScript 模型

找到一个图像特征向量模型(我使用 025_224),并选择它。在模型详细信息页面的“示例用法”部分,您将找到如何下载图像的代码,例如:

`tf``.``loadGraphModel``(`"https://tfhub.dev/google/tfjs-model/imagenet/
    mobilenet_v1_025_224/feature_vector/3/default/1"`,` `{` `fromTFHub``:` `true` `}``)`

您可以使用此功能来下载模型,以便检查特征向量的维度。这里是一个简单的 HTML 文件,其中包含此代码,用于对称为 dog.jpg 的图像进行分类:

<html>
<head>
<script src=`"https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest"`> </script>   
</head>
<body>
  <img id=`"img"` src=`"dog.jpg"`/>
</body>
</html>
<script>
`async` `function` run(){
  `const` img = `document`.getElementById(`'img'`);
  model = `await` tf.loadGraphModel(`'https://tfhub.dev/google/tfjs-model/imagenet/`
 `mobilenet_v1_025_224/feature_vector/3/default/1'`, {fromTFHub: `true`});
  `var` raw = tf.browser.fromPixels(img).toFloat();
  `var` resized = tf.image.resizeBilinear(raw, [`224`, `224`]);
  `var` tensor = resized.expandDims(`0`);
  `var` result = `await` model.predict(tensor).data();
  `console`.log(result)
}

run();

</script>

当您运行此代码并查看控制台时,您将看到此分类器的输出(如图 18-10)。如果您使用与我相同的模型,则应看到其中有 256 个元素的 Float32Array。其他 MobileNet 版本可能具有不同大小的输出。

探索控制台输出

图 18-10. 探索控制台输出

一旦您知道图像特征向量模型的输出形状,您就可以将其用于迁移学习。因此,例如,对于石头/剪刀/布的示例,您可以使用类似于图 18-11 中的架构。

使用图像特征向量进行迁移学习

图 18-11. 使用图像特征向量进行迁移学习

现在,您可以通过更改从何处以及如何加载模型,并修改分类器以接受图像特征向量而不是先前的激活特征来编辑您的迁移学习石头/剪刀/布应用程序的代码。

如果您想从 TensorFlow Hub 加载您的模型,只需像这样更新 loadMobilenet 函数:

`async` `function` loadMobilenet() {
  `const` mobilenet = 
 `await` tf.loadGraphModel(`"https://tfhub.dev/google/tfjs-model/imagenet/`
 `mobilenet_v1_050_160/feature_vector/3/default/1"`, {fromTFHub: `true`})
  `return` mobilenet
}

然后,在您的train方法中,您定义分类模型时,更新它以接收来自图像特征向量的输出([256])到第一层。以下是代码:

model = tf.sequential({
  layers: [
    tf.layers.dense({ `inputShape``:` `[``256``]`, units: 100, activation: 'relu'}),
    tf.layers.dense({ units: 3, activation: 'softmax'})
  ]
});

请注意,对于不同的模型,这种形状会有所不同。如果没有为您发布,您可以使用类似之前显示的 HTML 代码来查找它。

一旦完成,您就可以在 TensorFlow Hub 模型上使用 JavaScript 进行迁移学习!

使用来自 TensorFlow.org 的模型

JavaScript 开发者的模型另一个来源是 TensorFlow.org(见图 18-12)。这里提供的模型,如图像分类、物体检测等,可立即使用。点击任何链接都会带您进入一个包装了基于图的模型的 JavaScript 类的 GitHub 仓库,使其使用变得更加容易。

对于MobileNet,您可以像这样使用包含<script>的模型:

`<script` `src=`"https://cdn.jsdelivr.net/npm/@tensorflow-models/mobilenet@1.0.0"`>`
`</script>`

在 TensorFlow.org 上浏览模型

图 18-12. 在TensorFlow.org上浏览模型

如果您看一下代码,您会注意到两件事。首先,标签集编码在 JavaScript 内部,为您提供了一种方便的查看推理结果的方式,无需进行第二次查找。您可以在这里看到代码片段:

`var` `i``=``{` `0``:`"tench, Tinca tinca"`,` `1``:`"goldfish, Carassius auratus"`,` `2``:`"great white shark, white shark, man-eater, man-eating shark, Carcharodon
 carcharias"`,` `3``:`"tiger shark, Galeocerdo cuvieri"
...

此外,文件底部还有来自 TensorFlow Hub 的许多模型、层和激活,您可以将它们加载到 JavaScript 变量中。例如,对于 MobileNet 的版本 1,您可能会看到像这样的条目:

>n={"1.00">:
>{>.25>:"`>https://tfhub.dev/google/imagenet/mobilenet_v1_025_224/classification/1`">,
 "0.50">:"`>https://tfhub.dev/google/imagenet/mobilenet_v1_050_224/classification/1`">,
>.75>:"`>https://tfhub.dev/google/imagenet/mobilenet_v1_075_224/classification/1`">,
"1.00">:"`>https://tfhub.dev/google/imagenet/mobilenet_v1_100_224/classification/1`"
>}

值 0.25、0.50、0.75 等是“宽度乘数”值。这些用于构建更小、计算量较少的模型;您可以在引入架构的原始论文中找到详细信息。

代码提供了许多便捷的快捷方式。例如,当在图像上运行推理时,请将以下列表与稍早显示的列表进行比较,其中您使用 MobileNet 获取了狗图像的推理结果。以下是完整的 HTML 代码:

<html>
<head>
<script src=`"https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest"`> 
</script> 
<script src=`"https://cdn.jsdelivr.net/npm/@tensorflow-models/mobilenet@1.0.0"`> 
</script>
</head>
<body>
  <img id=`"img"` src=`"dog.jpg"`/>
</body>
</html>
<script>
`async` `function` run(){
  `const` img = `document`.getElementById(`'img'`);
  mobilenet.load().then(model => {
    model.classify(img).then(predictions => {
      `console`.log(`'Predictions: '`);
      `console`.log(predictions);
    });

  });
}

run();

</script>

请注意,您不必预先将图像转换为张量以进行分类。这段代码更加清晰,允许您专注于预测。要获取嵌入,您可以像这样使用model.infer而不是model.classify

embeddings = model.infer(img, embedding=`true`);
`console`.log(embeddings);

因此,如果您愿意,可以使用这些嵌入从 MobileNet 创建一个迁移学习场景。

摘要

在本章中,您了解了从现有基于 JavaScript 的模型进行迁移学习的各种选项。由于存在不同的模型实现类型,因此也有多种访问它们以进行迁移学习的选项。首先,您看到了如何使用 TensorFlow.js 转换器创建的 JSON 文件来探索模型的层,并选择其中一层进行迁移。第二个选项是使用基于图形的模型。这是 TensorFlow Hub 上首选的模型类型(因为它们通常提供更快的推断),但您会失去一些选择从哪一层进行迁移学习的灵活性。当使用这种方法时,您下载的 JavaScript 捆绑包不包含完整的模型,而是截断为特征向量输出。您可以将其从这里转移到您自己的模型中。最后,您了解了如何使用 TensorFlow 团队在TensorFlow.org上提供的预包装 JavaScript 模型,这些模型包括用于访问数据、检查类别以及获取模型的嵌入或其他特征向量的辅助函数,以便用于迁移学习。

总的来说,我建议采用 TensorFlow Hub 的方法,并在可用时使用具有预构建特征向量输出的模型——但如果没有,了解 TensorFlow.js 具有足够灵活的生态系统以允许以多种方式进行迁移学习也是很好的。

第十九章:使用 TensorFlow Serving 进行部署

在过去的几章中,你已经研究了模型的部署表面——在 Android 和 iOS 上以及在 Web 浏览器中。另一个明显的模型部署地点是服务器,这样你的用户可以将数据传递到你的服务器,并使用你的模型进行推理并返回结果。这可以通过 TensorFlow Serving 来实现,它是一个简单的模型“包装器”,提供 API 界面以及生产级可扩展性。在本章中,你将会介绍 TensorFlow Serving 以及如何使用它来部署和管理简单模型的推理。

什么是 TensorFlow Serving?

本书主要侧重于创建模型的代码,虽然这本身是一个庞大的工作,但它只是在使用机器学习模型进行生产时所需的整体图景中的一小部分。正如你在图 19-1 中所看到的那样,你的代码需要与配置代码、数据收集、数据验证、监控、机器资源管理、特征提取以及分析工具、流程管理工具和服务基础设施并存。

TensorFlow 的这些工具生态系统称为TensorFlow Extended(TFX)。除了本章中涵盖的服务基础设施之外,我不会深入探讨 TFX 的其他方面。如果你想进一步了解它,可以参考 Hannes Hapke 和 Catherine Nelson(O'Reilly)的书籍Building Machine Learning Pipelines

ML 系统的系统架构模块

图 19-1. ML 系统的系统架构模块

机器学习模型的流程总结在图 19-2 中。

机器学习生产流水线

图 19-2. 机器学习生产流水线

流程要求首先收集和摄取数据,然后进行验证。一旦数据“干净”,则将其转换为可以用于训练的格式,包括适当地进行标记。从这里开始,模型可以进行训练,一旦完成,它们将被分析。在测试模型准确性、查看损失曲线等时,你已经在做这些了。一旦满意,你就拥有了一个生产模型。

一旦你拥有了该模型,你可以将其部署到移动设备上,例如使用 TensorFlow Lite(图 19-3)。

TensorFlow Serving 通过提供基础设施来托管你的模型在服务器上,符合这种架构。客户端可以使用 HTTP 将请求传递到此服务器,并带有数据负载。数据将被传递到模型,模型将进行推理,获取结果,并将其返回给客户端(图 19-4)。

将生产模型部署到移动设备

图 19-3. 将生产模型部署到移动设备

将模型服务架构添加到管道中

图 19-4. 将模型服务架构添加到管道中

这种类型的架构的一个重要特征是,您还可以控制客户端使用的模型版本。例如,当模型部署到移动设备时,可能会出现模型漂移,即不同的客户端使用不同的版本。但是在基础架构中提供服务时(如 图 19-4 所示),您可以避免这种情况。此外,这也使得可以尝试不同的模型版本,其中一些客户端将从一个版本的推断中获取结果,而其他客户端则从其他版本中获取结果(图 19-5)。

使用 TensorFlow Serving 处理多个模型版本

图 19-5. 使用 TensorFlow Serving 处理多个模型版本

安装 TensorFlow Serving

TensorFlow Serving 可以使用两种不同的服务器架构安装。第一种是 tensorflow-model-server,它是一个完全优化的服务器,使用平台特定的编译器选项适用于各种架构。通常情况下,这是首选的选项,除非您的服务器机器没有这些架构。另一种选择是 tensorflow-model-server-universal,它使用基本优化进行编译,应该适用于所有机器,并在 tensorflow-model-server 不起作用时提供一个良好的备用选项。您可以使用多种方法安装 TensorFlow Serving,包括使用 Docker 或直接使用 apt 安装软件包。接下来我们将看看这两个选项。

使用 Docker 进行安装

使用 Docker 可能是快速启动和运行的最简单方法。要开始,请使用 docker pull 获取 TensorFlow Serving 软件包:

docker pull tensorflow/serving

一旦您完成了这一步骤,可以从 GitHub 克隆 TensorFlow Serving 代码:

git clone https://github.com/tensorflow/serving

这包括一些样本模型,包括一个名为 Half Plus Two 的模型,给定一个值,将返回该值的一半加二。为此,请先设置一个名为 TESTDATA 的变量,其中包含样本模型的路径:

TESTDATA="$(pwd)/serving/tensorflow_serving/servables/tensorflow/testdata"

现在可以从 Docker 镜像中运行 TensorFlow Serving:

docker run -t --rm -p 8501:8501 \
 -v "$TESTDATA/saved_model_half_plus_two_cpu:/models/half_plus_two" \
 -e MODEL_NAME=half_plus_two \
 tensorflow/serving &

这将在 8501 端口实例化一个服务器——本章后面会详细介绍如何做这个——并在该服务器上执行模型。然后,您可以通过 http://localhost:8501/v1/models/half_plus_two:predict 访问该模型。

要传递要进行推断的数据,您可以将包含这些值的张量 POST 到此 URL。以下是使用 curl 的示例(如果在开发机器上运行,请在单独的终端中运行):

curl -d '{"instances": [1.0, 2.0, 5.0]}' \
 -X  POST http://localhost:8501/v1/models/half_plus_two:predict

您可以在 图 19-6 中查看结果。

运行 TensorFlow Serving 的结果

图 19-6. 运行 TensorFlow Serving 的结果

虽然 Docker 镜像确实很方便,但你可能也希望完全控制地直接在你的机器上安装它。接下来你将学习如何做到这一点。

直接在 Linux 上安装

无论你使用的是 tensorflow-model-server 还是 tensorflow-model-server-universal,软件包名称都是一样的。所以,在开始之前最好先删除 tensorflow-model-server,以确保你获得正确的软件包。如果你想在自己的硬件上尝试这个,我在 GitHub 仓库中提供了 一个 Colab 笔记本 与代码:

apt-get remove tensorflow-model-server

然后,将 TensorFlow 软件包源 添加到你的系统中:

echo "deb http://storage.googleapis.com/tensorflow-serving-apt stable
     tensorflow-model-server tensorflow-model-server-universal" | tee
     /etc/apt/sources.list.d/tensorflow-serving.list && \ curl
     https://storage.googleapis.com/tensorflow-serving-apt/tensorflow-
     serving.release.pub.gpg | apt-key add -

如果你需要在本地系统上使用 sudo,你可以像这样操作:

**sudo** echo "deb http://storage.googleapis.com/tensorflow-serving-apt stable
tensorflow-model-server tensorflow-model-server-universal" | **`sudo`** tee
/etc/apt/sources.list.d/tensorflow-serving.list && \ curl
https://storage.googleapis.com/tensorflow-serving-apt/tensorflow-
serving.release.pub.gpg | **sudo** apt-key add -

接下来,你需要更新 apt-get

apt-get update

一旦完成这一步骤,你可以使用 apt 安装模型服务器:

apt-get install tensorflow-model-server

你可以通过以下方式确保你有最新版本:

apt-get upgrade tensorflow-model-server

软件包现在应该已经准备好使用了。

构建和提供模型

在本节中,我们将详细介绍创建模型、准备模型以进行服务、使用 TensorFlow Serving 部署模型以及运行推理的完整过程。

你将使用我们在整本书中都在探索的简单的“Hello World”模型:

xs = np.array([-`1.0`,  `0.0`, `1.0`, `2.0`, `3.0`, `4.0`], dtype=float)
ys = np.array([-`3.0`, -`1.0`, `1.0`, `3.0`, `5.0`, `7.0`], dtype=float)

model = tf.keras.Sequential([tf.keras.layers.Dense(units=`1`, input_shape=[`1`])])

model.compile(optimizer=`'``sgd``'`, loss=`'``mean_squared_error``'`)

history = model.fit(xs, ys, epochs=`500`, verbose=`0`)

print(`"``Finished training the model``"`)

print(model.predict([`10.0`]))

当要求预测 Y 为 10.0 时,这应该会快速训练并给出大约 18.98 的结果。

接下来,模型需要被保存。你将需要一个临时文件夹来保存它:

`import` tempfile
`import` os
`MODEL_DIR` = tempfile.gettempdir()
version = `1`
export_path = os.path.join(`MODEL_DIR`, str(version))
print(export_path)

在 Colab 中运行时,这应该会给出类似 /tmp/1 的输出。如果你在自己的系统上运行,可以将其导出到任何你想要的目录,但我喜欢使用临时目录。

如果在你保存模型的目录中有任何东西,最好在继续之前将其删除(避免这个问题是我喜欢使用临时目录的原因之一!)。为确保你的模型是主模型,你可以删除 export_path 目录的内容:

`if` os.path.isdir(export_path):
   print(`'``\n``Already saved a model, cleaning up``\n``'`)
   !rm -r {export_path}

现在你可以保存模型了:

model.save(export_path, save_format=`"``tf``"`)

print(`'``\n``export_path = {}``'`.format(export_path))
!ls -l {export_path}

完成后,请查看目录的内容。列表应该显示类似于这样的内容:

`INFO`:tensorflow:Assets written to: /tmp/`1`/assets

export_path = /tmp/`1`
total `48`
drwxr-xr-x `2` root root  `4096` May `21` `14`:`40` assets
-rw-r--r-- `1` root root `39128` May `21` `14`:`50` saved_model.pb
drwxr-xr-x `2` root root  `4096` May `21` `14`:`50` variables

TensorFlow Serving 工具包括一个名为 saved_model_cli 的实用程序,可用于检查模型。你可以使用 show 命令调用它,给它模型的目录以获取完整的模型元数据:

!saved_model_cli show --dir {export_path} --all

请注意,! 用于 Colab 表示一个 shell 命令。如果你在使用自己的机器,这是不必要的。

此命令的输出将非常长,但将包含如下详细信息:

signature_def['serving_default']:
 The given SavedModel SignatureDef contains the following input(s):
 inputs['dense_input'] tensor_info:
 dtype: DT_FLOAT
 shape: (-1, 1)
 name: serving_default_dense_input:0
 The given SavedModel SignatureDef contains the following output(s):
 outputs['dense'] tensor_info:
 dtype: DT_FLOAT
 shape: (-1, 1)
 name: StatefulPartitionedCall:0

注意 signature_def 的内容,在这种情况下是 serving_default。稍后你会需要它们。

请注意,输入和输出都有定义的形状和类型。在这种情况下,每个都是浮点数,形状为(–1, 1)。你可以有效地忽略 –1,只需记住模型的输入是浮点数,输出也是浮点数。

如果你在使用 Colab,你需要告诉操作系统模型目录的位置,以便在从 bash 命令运行 TensorFlow Serving 时,系统能够知道该位置。这可以通过操作系统中的环境变量来完成:

os.environ["MODEL_DIR"] = MODEL_DIR

要使用命令行运行 TensorFlow 模型服务器,你需要一些参数。首先,你将使用--bg开关确保命令在后台运行。nohup命令代表“不挂断”,请求脚本继续运行。然后,你需要指定一些参数给tensorflow_model_server命令。rest_api_port是你想在其上运行服务器的端口号。在这里,设置为8501。然后,使用model_name开关为模型命名——这里我称之为helloworld。最后,使用model_base_path将服务器传递到模型保存在MODEL_DIR操作系统环境变量的路径中。这是代码:

%%bash --bg
nohup tensorflow_model_server \
  --rest_api_port=8501 \
  --model_name=helloworld \
  --model_base_path="${MODEL_DIR}" >server.log 2>&1

在脚本的末尾,有代码将结果输出到server.log。在 Colab 中,其输出将简单地是这样:

Starting job # 0 in a separate thread.

你可以使用以下方法进行检查:

!tail server.log

检查此输出,你应该看到服务器成功启动,并显示一个提示,说明它正在localhost:8501导出 HTTP/REST API:

2020-05-21 14:41:20.026123: I tensorflow_serving/model_servers/server.cc:358]
Running gRPC ModelServer at 0.0.0.0:8500 ...
[warn] getaddrinfo: address family for nodename not supported
2020-05-21 14:41:20.026777: I tensorflow_serving/model_servers/server.cc:378]
**`Exporting HTTP/REST API at:localhost:8501`** ...
[evhttp_server.cc : 238] NET_LOG: Entering the event loop ...

如果失败,你应该会看到有关失败的通知。如果发生这种情况,你可能需要重新启动系统。

如果你想测试服务器,你可以在 Python 中这样做:

`import` json
xs = np.array([[`9.0`], [`10.0`]])
data = json.dumps({`"``signature_name``"`: `"``serving_default``"`, `"``instances``"`: 
       xs.tolist()})
print(data)

要向服务器发送数据,你需要将其格式化为 JSON 格式。因此在 Python 中,你需要创建一个 Numpy 数组,其中包含你要发送的值——在这种情况下是两个值的列表,9.0 和 10.0。每个值本身都是一个数组,因为,正如你之前看到的那样,输入形状是(-1,1)。单个值应发送到模型,因此如果要发送多个值,应该是一个列表的列表,内部列表只包含单个值。

在 Python 中使用json.dumps创建有效负载,其中包含两个名称/值对。第一个是调用模型的签名名称,在本例中为serving_default(正如你之前检查模型时所看到的)。第二个是instances,这是你要传递给模型的值列表。

打印这个将显示你的有效负载是什么样的:

{"signature_name": "serving_default", "instances": [[9.0], [10.0]]}

你可以使用requests库调用服务器进行 HTTP POST 请求。注意 URL 结构。模型被称为helloworld,你想要运行它的预测。POST 命令需要数据,即你刚创建的有效负载,还需要一个头部规范,告诉服务器内容类型为 JSON:

`import` requests
headers = {`"``content-type``"`: `"``application/json``"`}
json_response = 
    requests.post(`'``http://localhost:8501/v1/models/helloworld:predict``'`, 
    data=data, headers=headers)

print(json_response.text)

响应将是一个包含预测的 JSON 有效负载:

{
    "predictions": [[16.9834747], [18.9806728]]
}

探索服务器配置

在前面的示例中,您创建了一个模型,并通过从命令行启动 TensorFlow Serving 来提供它。您使用参数来确定要提供哪个模型,并提供诸如应在哪个端口上提供它的元数据。TensorFlow Serving 通过配置文件为您提供了更多高级的服务选项。

模型配置文件遵循名为 ModelServerConfig 的 protobuf 格式。在此文件中最常用的设置是 model_config_list,其中包含多个配置。这允许您拥有多个模型,每个模型都以特定的名称提供服务。例如,与其在启动 TensorFlow Serving 时指定模型名称和路径,您可以像这样在配置文件中指定它们:

 model_config_list {
  config {
    name: '2x-1model'
    base_path: '/tmp/2xminus1/'
  }
  config {
    name: '3x+1model'
    base_path: '/tmp/3xplus1/'
  }
}

现在,如果您使用此配置文件启动 TensorFlow Serving,而不是使用模型名称和路径的开关,您可以将多个 URL 映射到多个模型。例如,这个命令:

%%bash --bg
nohup tensorflow_model_server \
  --rest_api_port=8501 \
  --model_config=/path/to/model.config >server.log 2>&1

现在您可以向 <server>:8501/v1/models/2x-1model:predict<server>:8501/v1/models/3x+1model:predict 发送 POST 请求,TensorFlow Serving 将处理加载正确的模型,执行推理并返回结果。

模型配置还可以允许您针对每个模型指定版本详细信息。例如,如果您将先前的模型配置更新为以下内容:

model_config_list {
  config {
    name: '2x-1model'
    base_path: '/tmp/2xminus1/'
    model_version_policy: {
      specific {
        versions : 1
        versions : 2
      }
    }
  }
  config {
    name: '3x+1model'
    base_path: '/tmp/3xplus1/'
    model_version_policy: {
      all : {}
    }
  }
}

这将允许您服务第一个模型的版本 1 和 2,并且第二个模型的所有版本。如果您不使用这些设置,那么将会使用在base_path中配置的版本,或者如果未指定,则使用模型的最新版本。此外,第一个模型的特定版本可以被赋予显式名称,例如,您可以通过分配这些标签来指定版本 1 为主版本,版本 2 为测试版。以下是更新后的配置来实现这一点:

model_config_list {
  config {
    name: '2x-1model'
    base_path: '/tmp/2xminus1/'
    model_version_policy: {
      specific {
        versions : 1
        versions : 2
      }
    }
 version_labels {
 key: 'master'
 value: 1
 }
 version_labels {
 key: 'beta'
 value: 2
 }
  }
  config {
    name: '3x+1model'
    base_path: '/tmp/3xplus1/'
    model_version_policy: {
        all : {}
    }
  }
}

现在,如果您想要访问第一个模型的测试版本,可以这样做:

<server>:8501/v1/models/2x-1model/versions/beta

如果您想要更改模型服务器配置,而不需要停止和重新启动服务器,您可以让它定期轮询配置文件;如果它检测到变化,您将获得新的配置。例如,假设您不再希望主版本是版本 1,而是希望它是 v2。您可以更新配置文件以考虑此更改,如果服务器已经使用了 --model_config_file_poll_wait_seconds 参数启动,如下所示,一旦达到超时时间,新的配置将被加载:

%%bash --bg
nohup tensorflow_model_server \
  --rest_api_port=8501 \
  --model_config=/path/to/model.config
  **--model_config_file_poll_wait_seconds=60** >server.log 2>&1

总结

在本章中,你首次接触了 TFX。你看到任何机器学习系统都有远超出仅构建模型的组件,并学习了其中一个组件——TensorFlow Serving,它提供模型服务能力——如何安装和配置。你探索了如何构建模型,为其准备服务,将其部署到服务器,然后使用 HTTP POST 请求进行推断。之后,你研究了使用配置文件配置服务器的选项,查看了如何使用该文件部署多个模型及其不同版本。在下一章中,我们将朝着不同的方向前进,看看如何通过联邦学习管理分布式模型,同时保护用户隐私。

第二十章:AI 伦理、公平性与隐私

在本书中,您已经通过 TensorFlow 生态系统中提供的 API 接口进行了程序员的讲解,以训练多种任务的模型并将这些模型部署到多个不同的平台上。正是这种训练模型的方法,使用标记数据而不是显式编程逻辑,这是机器学习以及人工智能革命的核心。

在 第一章 中,我们将这对程序员所涉及的变化概括成了一个图表,如 图 20-1 所示。

传统编程与机器学习对比

图 20-1. 传统编程与机器学习对比

这带来了一个新的挑战。使用源代码,可以通过逐步执行和探索代码来检查系统的工作原理。但是,当您构建一个模型时,即使是一个简单的模型,其结果是一个包含模型内部学习参数的二进制文件。这些参数可以是权重、偏置、学习到的滤波器等。因此,它们可能非常晦涩,导致难以理解其作用和工作原理。

如果我们作为一个社会开始依赖训练模型来帮助我们进行计算任务,那么了解模型工作方式的透明度对我们非常重要——因此,作为 AI 工程师,了解以道德、公平和隐私为构建目标是非常重要的。有很多内容需要学习,以至于可以填满几本书,因此在本章中我们只是触及了皮毛,但我希望这能为您提供一个良好的入门,帮助您了解所需知识。

最重要的是,以用户公平为目标构建系统并不是一件新事情,也不是虚伪的表现或政治正确。无论任何人对工程整体公平性重要性的感受如何,这一章的一个不可争议的事实是:从工程角度来看,构建既 公平道德 的系统是正确的做法,并将帮助您避免未来的技术债务。

程序公平性

尽管最近机器学习和人工智能的进展使道德和公平的概念成为关注焦点,但需要注意的是,不平等和不公平在计算机系统中一直是关注的话题。在我的职业生涯中,我看到过许多例子,其中系统在设计某个场景时没有考虑到公平性和偏见的整体影响。

考虑这个例子:您的公司拥有客户数据库,并希望推出一项营销活动,以在已识别增长机会的特定邮政编码区域中更多地接触客户。为此,公司将向该邮政编码区域中已经连接但尚未购买任何产品的人员发送折扣券。您可以编写如下 SQL 来识别这些潜在客户:

`SELECT` * `from` Customers `WHERE` `ZIP`=target_zip `AND` `PURCHASES`=`0`

这看起来可能是非常合理的代码。但考虑一下该邮政编码的人口统计数据。如果那里的大多数居民是特定种族或年龄段的人,你可能会过度针对某个人群,或更糟糕的是,通过给某个种族提供折扣而对另一个种族不做任何优惠来进行歧视。随着时间的推移,持续这样的目标定位可能导致一个对社会人口统计数据不平衡的客户基础,最终将你的公司局限在主要服务于社会某个细分市场的境地。

这里还有一个例子——这件事真的发生在我身上!回到 第一章,我使用了几个表情符号来演示活动检测的机器学习概念(见 图 20-2)。

用于演示机器学习的表情符号

图 20-2. 表示机器学习的表情符号

这背后有一个故事,几年前开始。我有幸访问东京,当时我开始学习跑步。我在城市的一位好友邀请我绕着皇宫跑步。她发了一条带有几个表情符号的短信,看起来像是 图 20-3。

包含表情符号的文本

图 20-3. 包含表情符号的文本

文本包含两个表情符号,一个是跑步的女性,另一个是跑步的男性。我想回复并发送相同的表情符号,但是我使用的是桌面聊天应用程序,它没有现代的能力从列表中选择表情符号。如果你想要一个表情符号,你必须输入一个简码。

当我输入简码(running)时,我得到男性表情符号。但如果我想要女性表情符号,似乎没有办法做到。经过一番谷歌搜索,我发现可以通过输入 (running)+♀ 来获得女性表情符号。然后问题来了,如何输入 ♀?

这取决于操作系统,但例如在 Windows 上,你必须按住 Alt 键并在数字键盘上输入 12。在 Linux 上,你必须按下左 Ctrl-Shift-U,然后输入该符号的 Unicode 码,即 2640。

要获得女性表情符号需要这么多的工作,更不用说隐含声明一个女性表情符号是一个男性符号,通过添加 ♀ 来修改成女性。这不是包容性编程。但它是如何产生的呢?

考虑一下表情符号的历史。当它们首次使用时,它们只是文本中的字符,是侧面视图键入的,例如 😃 表示微笑或 😉 表示眨眼,或者我个人最喜欢的 😃 ,看起来像 Sesame Street 中的 Ernie。它们本质上是无性别的,因为它们的分辨率很低。由于表情符号(或表情符号)从字符演变为图形,它们通常是单色的“小人”类型插图。名称中的线索就在这里——小人。随着图形的改进,特别是在移动设备上,表情符号变得更加清晰。例如,在最初的 iPhone OS(2.2)中,跑步表情符号(重命名为“人物跑步”)看起来像这样:内联

随着图形的进一步改进和屏幕像素密度的增加,表情符号继续演变,到 iOS 13.3 时看起来像图 20-4。

iOS 13.3 中的人物跑步表情符号

图 20-4. iOS 13.3 中的人物跑步表情符号

在工程方面,除了改进图形外,保持向后兼容性也非常重要——因此,如果您的软件的早期版本使用简码(running)来表示小人跑步,那么具有更丰富图形的后续版本可以为您提供这样一个图形,现在非常明显是一个男性在跑步。

考虑一下这在伪代码中会是什么样子:

if shortcode.contains("(running)"){
  showGraphic(personRunning)
}

这是良好设计的代码,因为它在图形变化时保持了向后兼容性。您永远不需要为新屏幕和新图形更新代码;您只需更改personRunning资源即可。但是对于您的最终用户来说,效果会发生变化,因此您随后确定您还需要有一个女性跑步表情符号以保持公平。

但是您不能使用相同的简码,也不想破坏向后兼容性,因此您必须修改您的代码,也许类似于这样:

if shortcode.contains("(running)"){
  if(shortcode.contains("+♀")){
 showGraphic(womanRunning);
 } else {
 showGraphic(personRunning);
 }
}

从编码的角度来看,这是有道理的。你可以提供额外的功能而不会破坏向后兼容性,而且很容易记住——如果你想要一个女性跑步者,你可以使用女性金星符号。但生活并不仅仅是从编码的角度来看,正如这个例子所示。这样的工程设计导致了运行时环境对大部分人口的使用案例增加了过多的摩擦。

从一开始,在创建表情符号时并未考虑到性别平等所产生的技术债务,直到今天仍然存在,像这样的解决方案仍然存在。查看 Emojipedia 上的女性跑步页面,您会看到这个表情符号被定义为零宽连接器(ZWJ)序列,将人物跑步、ZWJ 和金星符号组合在一起。尝试为最终用户提供正确的女性跑步表情符号体验的唯一方法是实施解决方案。

幸运的是,现在许多提供表情符号的应用程序使用选择器,你可以从菜单中选择表情符号,而不是输入简码,这个问题已经在某种程度上隐藏起来。但它仍然潜藏在表面之下,虽然这个例子相当微不足道,但我希望它能展示出过去未考虑公平性或偏见的决策如何在后续产生影响。不要为了现在的利益牺牲你的未来!

因此,回到人工智能和机器学习。因为我们正处在应用类型新时代的黎明时期,对于你考虑应用程序使用的所有方面至关重要。你希望尽可能地确保公平性内置。你还希望尽可能避免偏见。这是正确的做法,可以帮助避免未来解决方案的技术债务。

机器学习中的公平性

机器学习系统是数据驱动的,而不是代码驱动的,因此识别偏见和其他问题区域成为理解你的数据的问题。需要工具来帮助你探索数据,看看它如何通过模型流动。即使你有优秀的数据,一个工程不良的系统也可能导致问题。以下是一些在构建机器学习系统时需要考虑的提示,可以帮助你避免这些问题:

确定机器学习是否真的必要

当新趋势冲击技术市场时,通常存在施加压力的情况来实施这些趋势。投资者、销售渠道或其他地方往往会推动你展示自己是前沿并使用最新最好的技术。因此,你可能被要求将机器学习整合到你的产品中。但如果这并非必要呢?如果为了满足这个非功能性需求,你却因为机器学习不适合这项任务,或者因为虽然它可能在未来有用,但你现在没有足够的数据覆盖率,而把自己逼入死胡同呢?

我曾参加过一个学生竞赛,参赛者挑战使用生成对抗网络(GANs)生成图像,根据脸部的上半部分预测下半部分的样子。那是在 COVID-19 之前的流感季节,许多人戴着口罩,体现了日本人的经典风度。他们的想法是看看是否能预测口罩下面的脸部。为此,他们需要访问面部数据,所以他们使用了带有年龄和性别标签的 IMDb 数据集中的面部图像。问题在于?考虑到数据来源是 IMDb,这个数据集中绝大多数的面孔并不是日本人。因此,他们的模型在预测我的脸时表现出色,但对他们自己的脸却不够准确。在没有足够数据覆盖的情况下,匆忙推出机器学习解决方案会导致产生偏见的解决方案。这只是一个展示竞赛,他们的工作非常出色,但它也是一个很好的提醒:在没有真正需要的情况下或者没有足够的数据来构建适当的模型时,急于将机器学习产品推向市场,可能会导致建立偏见模型并承担未来严重的技术债务的风险。

从第一天起设计并实施度量标准。

或者说,从零开始,因为我们都是程序员。特别是,如果您正在修改或升级非机器学习系统以添加机器学习功能,则应尽可能跟踪当前系统的使用情况。考虑之前的表情符号故事作为一个例子。如果人们早早地意识到——因为女性跑步者和男性跑步者一样多——拥有一个男性跑步者表情符号是一个用户体验的错误,那么问题可能根本就不会出现。您应该始终尝试了解用户的需求,以便在为机器学习设计数据导向架构时,确保您有足够的覆盖范围来满足这些需求,并可能预测未来的趋势并超前应对。

构建一个最小可行模型并进行迭代。

在您设定任何部署机器学习模型到系统中的期望之前,您应该尝试构建一个最小可行模型*。机器学习和人工智能并非万能解决方案。在手头的数据基础上,构建一个能让您开始将机器学习应用到系统中的最小可行产品(MVP)。它能胜任工作吗?您有获取更多所需数据以扩展系统的路径吗?同时保持公平对待所有用户?一旦有了您的 MVP,进行迭代、原型设计,并继续测试,而不是仓促投入生产。

确保您的基础设施支持快速重新部署。

无论您将模型部署到使用 TensorFlow Serving 的服务器,使用 TensorFlow Lite 的移动设备,还是使用 TensorFlow.js 的浏览器中,都需要注意如何在需要时重新部署模型的能力。如果遇到失败的情况(不仅仅是偏见),能够快速部署新模型而不会影响到最终用户的体验是非常重要的。例如,使用 TensorFlow Serving 的配置文件允许您定义多个带有命名值的模型,以便快速在它们之间进行切换。对于 TensorFlow Lite,您的模型被部署为一个资产,因此不需要将其硬编码到应用程序中,您可以让应用程序检查互联网上是否有更新版本的模型,并在检测到更新时进行更新。此外,通过抽象运行推断的代码(例如避免硬编码的标签),可以帮助您避免重新部署时的回归错误。

公平性工具

有一个不断增长的工具市场,用于理解训练模型使用的数据、模型本身以及模型推断的输出。我们将在这里探讨一些当前可用的选项。

什么是工具?

我最喜欢的之一是来自 Google 的 What-If 工具。它的目标是让您无需编写大量代码即可检查 ML 模型。通过这个工具,您可以同时检查数据和模型对该数据的输出。它有一个演示,使用基于 1994 年美国人口普查数据集的约 30,000 条记录的模型进行训练,该模型旨在预测一个人的收入可能是多少。例如,想象一下,这被一家抵押贷款公司用来确定一个人是否有能力偿还贷款,从而决定是否授予他们贷款。

工具的一部分允许您选择一个推断值,并查看导致该推断的数据集中的数据点。例如,请参考图 20-5。

此模型返回一个从 0 到 1 的低收入概率,其中数值低于 0.5 表示高收入,高于 0.5 表示低收入。这位用户的得分为 0.528,在我们的假设抵押贷款申请场景中,可能因收入过低而被拒绝。通过这个工具,你实际上可以改变用户的一些数据,比如他们的年龄,然后看看推断结果会如何改变。在这个人的情况下,将他们的年龄从 42 岁改变到 48 岁,使得他们的得分跨过了 0.5 的门槛,结果从贷款申请的“拒绝”变成了“接受”。请注意,除了年龄之外,用户的任何其他信息都没有改变。这表明模型可能存在年龄偏见的强烈信号。

What-If 工具允许您尝试各种信号,包括性别、种族等详细信息。为了避免单一情况成为主导因素,导致您修改整个模型以防止一个客户问题,而非模型本身,该工具包含寻找最接近反事实的能力。也就是说,它找到一组最接近的数据,导致不同的推断,以便您开始深入研究数据(或模型架构),以查找偏见。

使用 What-If 工具

图 20-5. 使用 What-If 工具

我在这里只是触及了 What-If 工具可以做的一部分,但我强烈建议您去了解它。网站上有很多示例,展示了您可以使用它做什么。正如其名称所示,它提供了在部署之前测试“假设”场景的工具。因此,我相信它可以成为您机器学习工具箱中的重要组成部分。

Facets

Facets 是一个可以通过可视化为您提供数据深入洞察的工具,可以补充 What-If 工具的作用。Facets 的目标是帮助您了解数据集中各个特征的值分布情况。如果您的数据分成多个子集用于训练、测试、验证或其他用途,这将特别有用。在这种情况下,您可能很容易陷入一个数据集中某个分割对某个特定特征偏向的情况,导致您拥有一个有缺陷的模型。该工具可以帮助您确定每个分割中每个特征是否有足够的覆盖度。

例如,使用与上一个示例中相同的美国人口普查数据集和 What-If 工具进行简单检查显示,训练/测试分割非常好,但使用资本收益和资本损失特征可能会对训练产生偏倚影响。请参见图 20-6,当检查分位数时,大交叉点在除这两个特征外的所有特征上非常平衡。这表明这些值的大多数数据点为零,但数据集中有一些值明显较高。在资本收益的情况下,您可以看到训练集的 91.67% 为零,其余值接近 100,000。这可能会导致您的训练产生偏差,并可视为调试信号。这可能会导致您的人口中的一小部分产生偏向性。

使用 Facets 探索数据集

图 20-6. 使用 Facets 探索数据集

Facets 还包括一种称为 Facets Dive 的工具,可让您根据多个轴可视化数据集的内容。它可以帮助识别数据集中的错误,甚至是现有的偏见,以便您知道如何处理它们。例如,请参见图 20-7,在此图中,我按目标、教育水平和性别拆分数据集。

使用 Facets 进行深入分析

图 20-7. 使用 Facets 进行深入分析

红色表示“预测高收入”,从左到右是教育水平。在几乎所有情况下,男性有高收入的概率都大于女性,特别是在较高的教育水平下,这种对比变得非常明显。例如看看 13-14 列(相当于学士学位):数据显示男性高收入者的比例远高于同等教育水平的女性。虽然模型中还有许多其他因素来决定收入水平,但在高度受教育的人群中出现这样的差异很可能是模型中偏见的指标。

为了帮助您识别这些功能,以及使用 What-If 工具,我强烈建议使用 Facets 来探索您的数据和模型输出。

这两个工具都来自 Google 的 People + AI Research(PAIR)团队。我建议您收藏他们的网站以获取最新发布的信息,以及People + AI Guidebook来帮助您遵循以人为本的 AI 方法。

联邦学习

当您的模型部署并分发后,它们有巨大的机会在基于整个用户群体使用情况的基础上持续改进。例如,具有预测文本的设备键盘必须从每个用户那里学习以提高效果。但是有一个问题——为了模型学习,需要收集数据,而未经用户同意收集数据来训练模型,尤其是这种涉及到隐私的行为可能会是一种巨大的侵犯。并不是每个用户输入的每个字都应该被用来改进键盘预测,因为那样每封电子邮件、每条短信的内容都会被外部第三方知晓。因此,为了实现这种学习方式,需要采用一种技术来保护用户隐私,同时分享数据的有价值部分。这通常被称为联邦学习,我们将在本节中探讨它。

联邦学习的核心思想是用户数据永远不会发送到中央服务器。而是使用像以下各节中概述的过程。

- 步骤 1. 确定可用于训练的设备

首先,您需要确定一组适合进行训练工作的用户。考虑对用户进行设备上训练的影响至关重要。要确定设备是否可用,请权衡诸如设备是否已在使用中或是否已连接电源等因素(参见图 20-8)。

识别可用设备

图 20-8. 识别可用设备

- 步骤 2. 确定适合用于训练的设备

其中一些设备可能不太适合。它们可能没有足够的数据,可能最近没有使用过等等。基于您的训练标准,可能有多个因素会决定适合性。基于这些因素,您将不得不将可用设备过滤成一组适合的可用设备(参见图 20-9)。

选择适合的可用设备

图 20-9. 选择适合的可用设备

步骤 3. 将可训练模型部署到您的训练集

现在,您已经确定了一组适合的可用设备,可以将模型部署到这些设备上(参见图 20-10)。模型将在设备上进行训练,这就是为什么那些当前未被使用且已插入(以避免电池耗尽)的设备是合适的选择家庭使用的原因。请注意,目前没有公共 API 可以在 TensorFlow 中进行设备端训练。 您可以在 Colab 中测试这个环境,但在撰写本文时还没有 Android/iOS 的等效方案。

将新的训练模型部署到设备

图 20-10. 将新的训练模型部署到设备

步骤 4. 将训练结果返回给服务器

请注意,在个体设备上训练模型所使用的数据永远不会离开该设备。但是,模型学习到的权重、偏差和其他参数可以离开设备。可以在此添加另一层安全性和隐私性(详见“使用联邦学习进行安全聚合”)。在这种情况下,每个设备学习到的值可以传递给服务器,服务器随后可以将它们聚合回主模型,有效地创建一个具有每个客户端分布式学习的新版本模型(参见图 20-11)。

从客户学习中创建新的主模型

图 20-11. 从客户学习中创建新的主模型

步骤 5. 将新的主模型部署到客户端

然后,随着客户端可用于接收新的主模型,它可以被部署到它们,以便每个人都可以访问新功能(参见图 20-12)。

遵循这种模式将使您拥有一个概念框架,在这个框架中,您可以从所有用户的经验中训练一个集中模型,而不会通过将数据发送到您的服务器来侵犯他们的隐私。相反,训练的一个子集直接在他们的设备上完成,并且该训练的结果是离开设备的唯一内容。接下来描述的方法称为安全聚合,可以通过混淆提供额外的隐私保护层。

新的主模型部署到所有客户端

图 20-12. 新的主模型部署到所有客户端

使用联邦学习进行安全聚合

先前的步骤演示了联邦学习的概念框架。这可以与安全聚合的概念结合起来,以在从客户端到服务器的传输过程中进一步混淆学习到的权重和偏差。其背后的想法很简单。服务器将设备配对到另一个设备组成伙伴系统。例如,考虑 图 20-13,其中有多个设备,每个设备都有两个伙伴。每对伙伴都会接收相同的随机值,用作混淆其发送的数据的乘数。

伙伴设备

图 20-13. 伙伴设备

在这里,第一个设备与第二个设备配对,由暗色三角形指示。这些数值在合并时会互相抵消:所以,暗色的“向下”三角形可能是 2.0,而暗色的“向上”可能是 0.5。当它们相乘时,结果为 1。类似地,第一个设备与第三个设备配对。每个设备都有两个“伙伴”,设备上的数字与另一设备上的对应。

特定设备的数据,由 图 20-14 中的圆圈表示,可以在发送到服务器之前与随机因子结合。

使用安全聚合将数值发送到服务器

图 20-14. 使用安全聚合将数值发送到服务器

服务器了解发送给伙伴的数值后,可以取消这些数值,只获取有效载荷。数据在传输到服务器时,会被密钥混淆。

使用 TensorFlow Federated 进行联邦学习

TensorFlow Federated (TFF) 是一个开源框架,在模拟服务器环境中提供联邦学习功能。在撰写本文时,它仍处于实验阶段,但值得关注。TFF 设计有两个核心 API。第一个是联邦学习 API,为您的现有模型添加了联邦学习和评估功能的一组接口。例如,它允许您定义受分布式客户端学习值影响的分布式变量。第二个是联邦核心 API,在功能编程环境中实现了联邦通信操作。它是如 Google 键盘 Gboard 等现有部署方案的基础。

我不会在本章详细介绍如何使用 TFF,因为它仍处于早期阶段,但我鼓励您查看它,以便为设备上的联邦学习库成熟的那一天做好准备!

谷歌的 AI 原则

TensorFlow 是由谷歌工程师基于公司产品和内部系统中的许多现有项目开发的。在其开源后,发现了许多机器学习新路径,并且在机器学习和人工智能领域的创新速度惊人。考虑到这一点,谷歌决定发布一份公开声明,概述其关于如何创建和使用人工智能的原则。这些原则是负责任采纳的良好指导方针,值得探索。总之,这些原则包括:

对社会有益

人工智能的进步是变革性的,随着这种变化的发生,目标是考虑所有社会和经济因素,仅在总体上可能的好处超过可预见的风险和不利因素时才进行推进。

避免创建或强化不公平的偏见

如本章讨论的那样,偏见可以轻易地渗入任何系统。人工智能——特别是在它转变行业的情况下——为消除现有偏见提供了机会,同时确保不会产生新的偏见。应当谨记这一点。

为安全性而建立和测试

谷歌继续发展强大的安全与保障实践,以避免人工智能带来的意外伤害。这包括在受限环境中开发人工智能技术,并在部署后持续监控其运行。

对人负责

目标是构建受适当人类指导和控制的人工智能系统。这意味着必须始终提供适当的反馈、申诉和相关解释的机会。支持这一点的工具将是生态系统中至关重要的一部分。

纳入隐私设计原则

人工智能系统必须纳入确保充分隐私和告知用户其数据使用方式的保障措施。应当明确提供通知和同意的机会。

维护高科学卓越标准

当技术创新在科学严谨性和对开放探讨与协作的承诺下进行时,其表现最佳。如果人工智能要帮助揭示关键科学领域的知识,它应当努力达到这些领域期望的科学卓越标准。

提供符合这些原则的用途

尽管这一点可能显得有些元信息,但重要的是强调这些原则并不孤立存在,也不仅仅适用于构建系统的人员。它们也旨在为您构建的系统如何被使用提供指导。意识到某人可能以您未曾预期的方式使用您的系统是件好事,因此为您的用户设立一套原则也是非常必要的!

摘要

最后,这本书就要告一段落了。对我来说,写作过程是一段令人惊奇而有趣的旅程,希望你在阅读中找到了价值!你已经走过了漫长的路程,从机器学习的“Hello World”开始,逐步构建了你的第一个计算机视觉、自然语言处理和序列建模系统等等。你已经在从移动设备到网络和浏览器的各个地方部署模型,并且在本章中,我们通过一瞥了解了如何以一种审慎和有益的方式使用你的模型,以及为什么要这样做。你看到了偏见在计算中是一个问题,尤其在人工智能中可能是一个巨大的问题,但同时也看到了现在是一个机会,因为这个领域正处于起步阶段,我们有机会尽可能地在前沿消除这些问题。我们探讨了一些可用的工具来帮助你完成这一任务,并且你对联邦学习有了初步了解,以及它如何可能成为移动应用开发的未来。

非常感谢你和我一起分享这段旅程!期待听到你的反馈,并尽我所能回答任何问题。

posted @ 2025-11-22 09:01  绝不原创的飞龙  阅读(26)  评论(0)    收藏  举报